修正画布普通图片类型与Agent改图入队载荷

移除前端CanvasAssetKind中的image并将普通图片统一表示为null。

在API与SpacetimeDB读写边界清理历史image素材类型。

让画布Agent确认改图时生成版本化来源快照载荷且不扩展worker兼容。

补充快速编辑、迁移、入队回归测试并同步架构文档与项目决策。
This commit is contained in:
2026-08-08 17:46:31 +08:00
parent 68e879907a
commit 4cc431b770
16 changed files with 217 additions and 126 deletions
@@ -6603,6 +6603,8 @@
- 2026-08-06 修订:画布 Agent 的 `edit_image` 只接受图片输入,新任务以 `assetKind=null` 表示普通静态图片,不再使用 synthetic `editor_agent_edit_image`。worker 仅按服务端生成的 `editor-agent:` dedupe namespace 识别并归一历史排队 payload;普通调用伪造旧值继续被拒绝。已持久化资源中的旧值只有在后端从真实目标图层 / 项目资源解析后才兼容为空类型,避免历史 Agent 结果失去快速编辑能力,同时不扩大请求白名单。
- 2026-08-07 修订:站内与 External v1 图片编辑请求统一只接受必填 `sourceReferenceId`,且该值必须是当前账号已登记的项目资源 ID 或素材 IDobjectKey、URL、Data URL、Blob URL 以及旧 `sourceImageSrc/sourceResourceId/assetKind` 字段全部返回 400,不提供兼容别名。后端用共享窄查询分别按两张表主键定点解析,双表同 ID、未命中、跨账号、对象缺失或越权均失败关闭;权威类型完全来自业务记录,只允许普通静态图片、规范图、角色图、完整图标图集、图标规范、宣发图和 UI 设计图。请求带 `targetLayerId` 时必须同时带 `projectId`,来源与目标优先比较 `assetObjectId`,任一方缺失才比较 canonical `(bucket, objectKey)`,且来源默认类型必须与目标资源默认类型一致;最终类型取目标覆盖值或目标资源类型。HTTP 入队写入版本化服务端解析快照,worker 执行前按同一业务 ID 再次定点解析,身份或类型漂移即失败关闭。旧任务只把已有资源 ID 或旧来源字符串本身当业务 ID 迁移,绝不按 objectKey 反查。Canvas Agent 必须从 `ImageMetadata.reference_id` 取主来源;红框标注上传图只作为辅助 `referenceImageSrcs`,不能冒充被编辑资源。仅以素材 ID 编辑时,队列审计与 `generationInputs.references` 保留素材 ID,不伪造项目资源关系。
- 2026-08-08 修订:`scene` 是单张静态图片素材,加入前端快速编辑正向白名单和 api-server 权威来源白名单;编辑结果继续保留 `scene`。用户标签覆盖侧的前端菜单与 SpacetimeDB 结构化布局白名单也必须显式覆盖 `scene`。通用图片生成接口仍拒绝 `scene`,避免绕过结构化场景生成契约。
- 2026-08-08 修订:普通静态图片的正式语义值固定为 `assetKind=null/None``"image"` 只属于前端派生 `CanvasMediaType`、MIME / content type 和生成参考媒体类型,不是 `CanvasAssetKind`,不得由上传、生成、资源 / 素材写入或 read model 生产。API 与 SpacetimeDB storage 的读写边界把短期误写的历史 `assetKind="image"` 原位归一为空;快速编辑白名单继续直接允许空类型,不新增 `"image"` 分支。
- 2026-08-08 修订:画布 Agent 的 `edit_image` 工具内部可先形成待确认的 `EditorImageEditRequest`,但确认接口必须在通用入队前复用站内图片编辑的来源解析与目标预检,写入 `{ version, request, source }` 服务端快照。worker 只解析正式 versioned payload 与既有历史 payload,不接受当前 direct request 作为 fallback;这样未上线的 Agent 路径在生产端原位修正,不扩大消费端协议。
- 验证:模型测试覆盖允许与拒绝类型,工具栏和两类右键菜单测试覆盖单个拆分图标、角色动作及音频不展示,提交工作流测试覆盖单个拆分图标和角色动作绕过入口时仍拒绝;后端表驱动测试覆盖全部现役素材 / 媒体类型与未知类型,锁定图片编辑端点失败关闭。
- 关联:`src/components/image-editor/ImageCanvasGenerationModel.ts``ImageCanvasSelectedLayerToolbarView.tsx``ImageCanvasContextMenusView.tsx``useImageCanvasGenerationWorkflow.ts``useImageCanvasGenerationSubmissionWorkflow.ts`
@@ -84,6 +84,7 @@ npm run check:server-rs-ddd
- 画布 Agent 的 `gpt-5.4-mini` Chat Completions 规划使用 1024 生成 token 预算;VectorEngine 专用 client 显式发送当前字段 `max_completion_tokens`,其预算包含可见输出和隐藏 reasoning token。通用 OpenAI-compatible client 默认保留旧 `max_tokens`,只有确认 endpoint 能力后才 opt-in,禁止按模型名猜测或在 `400` 后自动重放。前端在 POST pending 120 秒后显示不入库的耐心等待提示;provider request future 明确返回 connect/timeout/HTTP/transport 错误时立即进入正式失败,尚未返回则继续等待。专用 provider 单 attempt hard timeout 为 8 分钟;请求发起阶段的 timeout、连接失败、`408``429``5xx` 读取 `GENARRATIVE_LLM_MAX_RETRIES`,但画布 Agent 最多重试 1 次,显式配置 0 仍可关闭,专用重试退避最多 60 秒。消息规划生命周期从 handler 入口开始计入 18 分钟总 deadline,进入 `agent.prompt(...)` 时只使用剩余预算;该 deadline 覆盖会话锁/上下文准备与最多 3 轮规划,并为错误持久化/HTTP 返回预留约 2 分钟,不允许多轮规划绕过前端 20 分钟 timeout。已收到成功响应头后的响应体读取或解析失败直接按明确失败收口,并使用该成功响应所属的真实 attempt 记录错误。重试只包围 LLM 规划请求并发生在任何待确认工具执行之前,因此不会重复提交生成任务或扣费。
- 对话附件只允许引用当前工程 `editor_project_resource` 或当前账号 `editor_asset` 的图片;前端可提交展示用 `imageSrc` / `thumbnailSrc`,后端必须按 `resourceId` / `assetId` 重新归一、校验 owner / project 和 `objectKey`,再给 LLM 或生成工具使用。
- `edit-image` 只接受当前图片上下文中的 `object_image_id``source_image_id` 不是现役 schema 字段,prompt、tool args、确认执行和测试中都不得生成或兼容该字段。
- `edit-image` 在工具准备阶段生成的 direct `EditorImageEditRequest` 只用于待确认消息内部状态,不是 worker queue contract。确认接口必须先按当前 owner 对 `sourceReferenceId` 做权威来源解析、目标预检与参考图上限校验,再写入 `{ version: 1, request, source }`;站内图片编辑与画布 Agent 共用同一准备 helper。worker 不接受当前 direct request fallback,只保留正式 versioned payload 和已经存在的历史任务迁移解析。
- 画布 Agent 工具复用既有编辑器图片生成 / 修改 / 图标 spritesheet BFF,并继续使用后端模型定价和 `execute_billable_asset_operation_with_cost`;前端不提交 `priceMudPoints`
- api-server 对 `PromptRunError` 的持久化顺序固定为:先按 `partial_outputs` 原顺序映射已成功工具,将其保存为 `status=not_completed` 且无 `externalJobId` 的待确认消息;再在同一会话增量末尾追加 `ERROR ` terminal system 消息并整体写入 OSS。后续规划失败不得吞掉失败前已执行的成功工具结果;结构化 `ToolFailed` 可用于调用方诊断与流程决策,但画布确认面不得把它伪装成成功待确认卡。
- `/messages/{messageId}/confirm``/messages/{messageId}/cancel` 只返回成功确认;前端成功后立即重新读取整个会话,以会话详情中的权威消息状态和 `externalJobId` 驱动气泡展示与任务轮询。
@@ -437,7 +438,7 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复
## 编辑器角色动作素材持久化契约(2026-07-28)
- `asset_kind` 是资源 / 素材唯一权威媒体类别,不新增或返回 `media_type` / `mediaType`。角色动作预览 MP4 使用 `asset_kind = video`,最终透明帧集使用 `asset_kind = character-animation`;前端据此派生具体渲染器。
- `asset_kind` 是资源 / 素材可选的权威语义类别,不新增或返回并列的 `media_type` / `mediaType`普通静态图片固定使用 `asset_kind = NULL``image` 只可作为前端派生媒体类型、MIME / content type 或生成参考的媒体类型,不能作为语义 `asset_kind`API 与 SpacetimeDB storage 的写入和 snapshot 边界把历史误值 `image` 归一为 `None`角色动作预览 MP4 使用 `asset_kind = video`,最终透明帧集使用 `asset_kind = character-animation`;前端据此派生具体渲染器。
- `editor_project_resource``editor_asset` 表尾只追加 `image_sequence_frames_json: Option<String>``image_sequence_duration_ms: Option<u64>``editor_showcase_asset` 作为提交时冻结的审核与公开快照,也在表尾追加并从账号素材复制相同两字段。前者保存完整有效帧数组,数组位置是唯一播放顺序,正式帧对象不保存或返回 `frameIndex`;后者只表示该图片序列完整播放一次的毫秒时长。帧数始终取数组长度,FPS 在播放或导出时即时推导,不持久化 `frame_count``fps`
- 图片序列时长不能复用音频 / 视频生成请求的 `durationSeconds`,资源和素材也不保存通用 `duration_seconds`。角色动作与视频生成响应保留各自既有的请求 / 结果级秒数;音频 / 视频的用户可见时长只作为字符串展示项写入 `generation_inputs_json.fields[]`,上传媒体使用本次上传探测值,带时长选项的生成任务使用用户提交值,不再复制到 `EditorAsset``CanvasLayer` 或画布 layout,也不在素材放置 / 工程恢复时探测或从 layout、resource 做双来源回退。素材详情和画布 ZIP 用户可见元数据只透传实际存在的 `fields[]` 时长项;缺少该项时省略时长,不生成 `--:--` 等占位值。音频播放控件只信任 `<audio>``loadedmetadata.duration`,展示字段不得参与播放、裁切或变速。音频生成响应不得新增 `durationSeconds`,这些展示值也不得映射到资源、素材正式列或图片序列字段。
- 角色动作 worker 在透明帧全部持久化后创建最终项目资源与账号素材,并写入帧数组与 `image_sequence_duration_ms`;生成响应可继续返回请求 / 结果层面的 `frameCount``fps``durationSeconds`,但它们不是持久化真相。预览视频仍作为独立 `asset_kind = video` 中间素材保留并承担生成成本,最终派生素材成本为 0。
@@ -473,7 +474,7 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复
其它注意事项:
- `assetKind` 是数据库、Rust DTO 和对外 JSON 的唯一媒体类别真相;不要新增或返回并列的 `mediaType`前端 renderer 可以保留内部派生类型,但不得回写后端。
- `assetKind` 是数据库、Rust DTO 和对外 JSON 的可选语义类别真相;普通静态图片必须为空,不能新增 `image` 类别。不要新增或返回并列的 `mediaType`前端 renderer 可以保留内部派生媒体类型,但不得回写后端。
- 每次新增 `assetKind` 都必须分别完成“用户标签覆盖”和“快速编辑”两项资格评估;两者互不推导。可被用户选择不等于可快速编辑,属于图片媒体族也不等于自动进入快速编辑正向白名单。
- 只是新增分类或 renderer 且现有媒体字段足够时,不改 schema。只有必须跨刷新、复用、审核或公开保留的数据才新增类别专属字段。
- legacy 数据必须先通过有界、可审计、带 dry-run/hash/apply 门禁的数据库迁移收口;迁移后的 api-server、mapper、主站、后台和画布只读取正式字段,不保留运行时 fallback。
@@ -1,6 +1,7 @@
use std::time::Duration;
use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::{Extension, Json};
use module_editor_agent::{
EDITOR_AGENT_CONVERSATION_ID_PREFIX, EDITOR_AGENT_DEFAULT_CONVERSATION_TITLE,
@@ -42,8 +43,13 @@ use crate::editor_agent::utils::{
};
use crate::editor_agent::{context, reconcile};
use crate::editor_generation_config::EditorGenerationPricingConfig;
use crate::editor_generation_queue::enqueue_editor_generation_job_with_identity;
use crate::editor_project::{current_utc_micros, map_editor_project_error};
use crate::editor_generation_queue::{
EDITOR_IMAGE_EDIT_JOB_KIND, enqueue_editor_generation_job_with_identity,
};
use crate::editor_project::{
EditorImageEditRequest, current_utc_micros, map_editor_project_error,
prepare_editor_image_edit_queue_payload_for_owner,
};
use crate::http_error::AppError;
use crate::request_context::RequestContext;
use crate::state::AppState;
@@ -711,6 +717,25 @@ mod tests {
"ERROR 美术 Agent 规划轮数已达上限:3"
);
}
#[test]
fn confirmed_image_edit_is_wrapped_before_queueing() {
let source = include_str!("api.rs");
let start = source
.rfind("pub async fn confirm_editor_agent_tool_call")
.expect("confirm endpoint should exist");
let body = &source[start..];
let prepare = body
.find("prepare_editor_image_edit_queue_payload_for_owner")
.expect("image edit should build its versioned queue payload");
let enqueue = body
.find("enqueue_editor_generation_job_with_identity")
.expect("confirmed job should be queued");
assert!(body[..prepare].contains("job_kind == EDITOR_IMAGE_EDIT_JOB_KIND"));
assert!(body[prepare..enqueue].contains("serde_json::to_value(queue_payload)"));
assert!(prepare < enqueue);
}
}
fn build_delta_messages(
outputs: Vec<PromptOutput>,
@@ -1066,7 +1091,29 @@ pub async fn confirm_editor_agent_tool_call(
let job_kind = prepared_job.job_kind;
let request_label = prepared_job.request_label;
let price_mud_points = prepared_job.price_mud_points;
let payload = prepared_job.payload;
let payload = if job_kind == EDITOR_IMAGE_EDIT_JOB_KIND {
let request = serde_json::from_value::<EditorImageEditRequest>(prepared_job.payload)
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": "editor-agent",
"message": format!("画布 Agent 图片编辑任务参数无效:{error}"),
}))
})?;
let queue_payload = prepare_editor_image_edit_queue_payload_for_owner(
&state,
conversation.owner_user_id.as_str(),
request,
)
.await?;
serde_json::to_value(queue_payload).map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": "editor-agent",
"message": format!("画布 Agent 图片编辑任务载荷序列化失败:{error}"),
}))
})?
} else {
prepared_job.payload
};
let (job_id, dedupe_key) = editor_agent_tool_job_identity(
conversation.conversation_id.as_str(),
message_id,
@@ -2145,8 +2145,9 @@ pub async fn create_editor_project_resource(
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Json(payload): Json<EditorProjectResourceCreateRequest>,
) -> Result<Json<Value>, AppError> {
let asset_kind = normalize_editor_semantic_asset_kind(payload.asset_kind);
let generation_inputs_json = serialize_editor_generation_inputs(
payload.asset_kind.as_deref(),
asset_kind.as_deref(),
sanitize_editor_untrusted_generation_inputs(payload.generation_inputs.clone()),
)?;
let image_sequence_frames_json =
@@ -2171,7 +2172,7 @@ pub async fn create_editor_project_resource(
provider: normalize_optional_string(payload.provider),
task_id: normalize_optional_string(payload.task_id),
source_resource_id: normalize_optional_string(payload.source_resource_id),
asset_kind: normalize_optional_string(payload.asset_kind),
asset_kind,
generation_inputs_json,
updated_at_micros: current_utc_micros(),
image_sequence_frames_json,
@@ -2319,8 +2320,9 @@ pub async fn create_editor_asset(
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Json(payload): Json<EditorAssetCreateRequest>,
) -> Result<Json<Value>, AppError> {
let asset_kind = normalize_editor_semantic_asset_kind(payload.asset_kind);
let generation_inputs_json = serialize_editor_generation_inputs(
payload.asset_kind.as_deref(),
asset_kind.as_deref(),
sanitize_editor_untrusted_generation_inputs(payload.generation_inputs.clone()),
)?;
let image_sequence_frames_json =
@@ -2345,7 +2347,7 @@ pub async fn create_editor_asset(
model: normalize_optional_string(payload.model),
provider: normalize_optional_string(payload.provider),
task_id: normalize_optional_string(payload.task_id),
asset_kind: normalize_optional_string(payload.asset_kind),
asset_kind,
generation_inputs_json,
source_resource_id: None,
now_micros: current_utc_micros(),
@@ -4010,10 +4012,7 @@ struct EditorImageEditTargetLayerSource {
fn normalize_editor_image_edit_resolved_source_kind(
resolved_asset_kind: Option<&str>,
) -> Option<String> {
resolved_asset_kind
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
normalize_editor_semantic_asset_kind(resolved_asset_kind.map(ToOwned::to_owned))
}
fn resolve_editor_image_edit_target_layer_source(
@@ -4976,21 +4975,13 @@ fn resolve_editor_pixel_art_snap_asset_kind(
discovered_source_asset_kinds: &[String],
storage_asset_kinds: &[String],
) -> Result<Option<String>, AppError> {
let requested_asset_kind = requested_asset_kind
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned);
let preferred_source_asset_kind = source_asset_kind
.flatten()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned);
let requested_asset_kind =
normalize_editor_semantic_asset_kind(requested_asset_kind.map(ToOwned::to_owned));
let preferred_source_asset_kind =
normalize_editor_semantic_asset_kind(source_asset_kind.flatten().map(ToOwned::to_owned));
let discovered_source_asset_kinds = discovered_source_asset_kinds
.iter()
.map(String::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.filter_map(|value| normalize_editor_semantic_asset_kind(Some(value.clone())))
.collect::<Vec<_>>();
let storage_asset_kinds = storage_asset_kinds
.iter()
@@ -5540,9 +5531,53 @@ pub(crate) async fn enqueue_editor_image_edit_for_owner(
state: &AppState,
request_context: &RequestContext,
caller: &EditorGenerationCaller,
mut payload: EditorImageEditRequest,
payload: EditorImageEditRequest,
external_idempotency_key: Option<&str>,
) -> Result<ExternalGenerationJobRecord, AppError> {
let queue_payload = prepare_editor_image_edit_queue_payload_for_owner(
state,
caller.owner_user_id.as_str(),
payload,
)
.await?;
let payload = &queue_payload.request;
let generation_options = normalize_editor_image_edit_generation_options(
payload.model.as_deref(),
payload.aspect_ratio.as_deref(),
payload.image_size.as_deref(),
payload.size.as_deref(),
);
let image_size = normalize_editor_image_generation_size(payload.size.as_deref());
let price_mud_points = u64::from(
resolve_editor_image_edit_price(
state,
generation_options.model,
image_size.as_ref(),
Some(generation_options.image_size),
)
.await?,
);
let source_entity_id =
editor_generation_source_entity_id(payload.project_id.as_deref(), "editor-image-edit");
enqueue_editor_generation_job_for_caller(
state,
request_context,
caller.owner_user_id.as_str(),
EDITOR_IMAGE_EDIT_JOB_KIND,
source_entity_id,
"图片画布修改图片",
price_mud_points,
&queue_payload,
external_idempotency_key,
)
.await
}
pub(crate) async fn prepare_editor_image_edit_queue_payload_for_owner(
state: &AppState,
owner_user_id: &str,
mut payload: EditorImageEditRequest,
) -> Result<EditorImageEditQueuePayload, AppError> {
payload.generation_inputs =
sanitize_editor_untrusted_generation_inputs(payload.generation_inputs.take());
ensure_editor_reference_image_sources_are_stable(
@@ -5554,15 +5589,14 @@ pub(crate) async fn enqueue_editor_image_edit_for_owner(
ensure_editor_image_edit_source_reference_id_allowed(&payload.source_reference_id)?;
let target = preflight_editor_billable_generation_target(
state,
caller.owner_user_id.as_str(),
owner_user_id,
payload.project_id.clone(),
payload.asset_folder_id.clone(),
)
.await?;
payload.project_id = target.project_id;
payload.asset_folder_id = target.asset_folder_id;
let source =
resolve_editor_image_edit_source(state, caller.owner_user_id.as_str(), &payload).await?;
let source = resolve_editor_image_edit_source(state, owner_user_id, &payload).await?;
let generation_options = normalize_editor_image_edit_generation_options(
payload.model.as_deref(),
payload.aspect_ratio.as_deref(),
@@ -5578,35 +5612,11 @@ pub(crate) async fn enqueue_editor_image_edit_for_owner(
"referenceImageSrcs",
"修改参考图",
)?;
let image_size = normalize_editor_image_generation_size(payload.size.as_deref());
let price_mud_points = u64::from(
resolve_editor_image_edit_price(
state,
generation_options.model,
image_size.as_ref(),
Some(generation_options.image_size),
)
.await?,
);
let source_entity_id =
editor_generation_source_entity_id(payload.project_id.as_deref(), "editor-image-edit");
let queue_payload = EditorImageEditQueuePayload {
Ok(EditorImageEditQueuePayload {
version: EDITOR_IMAGE_EDIT_QUEUE_PAYLOAD_VERSION,
request: payload,
source,
};
enqueue_editor_generation_job_for_caller(
state,
request_context,
caller.owner_user_id.as_str(),
EDITOR_IMAGE_EDIT_JOB_KIND,
source_entity_id,
"图片画布修改图片",
price_mud_points,
&queue_payload,
external_idempotency_key,
)
.await
})
}
pub(crate) async fn edit_editor_image_for_owner(
@@ -10759,8 +10769,8 @@ pub(crate) fn resolve_editor_asset_kind(
image_src: &str,
object_key: Option<&str>,
) -> Option<String> {
if asset_kind.is_some() {
return asset_kind;
if let Some(asset_kind) = normalize_editor_semantic_asset_kind(asset_kind) {
return Some(asset_kind);
}
let source_is_video =
editor_asset_source_has_extension(image_src, object_key, &["mp4", "mov", "webm", "avi"]);
@@ -10774,7 +10784,7 @@ pub(crate) fn resolve_editor_asset_kind(
} else if source_is_audio {
Some("audio".to_string())
} else {
Some("image".to_string())
None
}
}
@@ -12755,6 +12765,10 @@ pub(crate) fn normalize_optional_string(value: Option<String>) -> Option<String>
.filter(|item| !item.is_empty())
}
pub(crate) fn normalize_editor_semantic_asset_kind(value: Option<String>) -> Option<String> {
normalize_optional_string(value).filter(|asset_kind| asset_kind != "image")
}
fn resolve_editor_generated_asset_label(value: Option<String>, fallback: &str) -> String {
normalize_optional_string(value)
.unwrap_or_else(|| fallback.to_string())
@@ -14327,13 +14341,13 @@ mod tests {
"purpose": "unrelated image metadata"
});
let unrelated = editor_asset_payload_from_record(EditorAssetRecord {
asset_kind: Some("image".to_string()),
asset_kind: None,
generation_inputs: Some(unrelated_generation_inputs.clone()),
image_sequence_frames: None,
image_sequence_duration_ms: None,
..record
});
assert_eq!(unrelated.asset_kind.as_deref(), Some("image"));
assert_eq!(unrelated.asset_kind, None);
assert_eq!(
unrelated.generation_inputs,
Some(unrelated_generation_inputs)
@@ -16985,7 +16999,7 @@ mod tests {
Some("character")
);
let mismatch = resolve_editor_pixel_art_snap_asset_kind(
Some("image"),
Some("icon"),
Some(Some("character")),
&[],
&[],
@@ -17034,8 +17048,8 @@ mod tests {
);
let image_kind = resolve_editor_pixel_art_snap_asset_kind(Some("image"), None, &[], &[])
.expect("static image kind should be accepted");
assert_eq!(image_kind.as_deref(), Some("image"));
.expect("legacy ordinary-image kind should normalize to none");
assert_eq!(image_kind, None);
}
#[test]
@@ -20383,6 +20397,10 @@ mod tests {
#[test]
fn legacy_editor_agent_resource_kind_fails_closed() {
assert_eq!(
normalize_editor_image_edit_resolved_source_kind(Some("image")),
None
);
let resolved_kind =
normalize_editor_image_edit_resolved_source_kind(Some("editor_agent_edit_image"));
assert_eq!(resolved_kind.as_deref(), Some("editor_agent_edit_image"));
@@ -20601,9 +20619,15 @@ mod tests {
source,
"pub async fn edit_editor_image(",
"pub(crate) async fn edit_editor_image_for_owner",
&["enqueue_editor_image_edit_for_owner"],
);
assert_function_contains_in_order(
source,
"pub(crate) async fn prepare_editor_image_edit_queue_payload_for_owner",
"pub(crate) async fn edit_editor_image_for_owner",
&[
"resolve_editor_image_edit_source(",
"enqueue_editor_generation_job",
"Ok(EditorImageEditQueuePayload",
],
);
assert_function_contains_in_order(
@@ -20626,10 +20650,6 @@ mod tests {
"pub async fn generate_editor_image(",
"pub(crate) async fn generate_editor_image_for_owner",
),
(
"pub async fn edit_editor_image(",
"pub(crate) async fn edit_editor_image_for_owner",
),
(
"pub async fn remove_editor_image_background(",
"pub(crate) async fn remove_editor_image_background_for_owner",
@@ -22252,11 +22272,11 @@ mod tests {
"execute_billable_asset_operation_with_cost",
),
(
"pub(crate) async fn enqueue_editor_image_edit_for_owner",
"pub(crate) async fn prepare_editor_image_edit_queue_payload_for_owner",
"pub(crate) async fn edit_editor_image_for_owner",
"ensure_editor_reference_image_sources_are_stable",
"resolve_editor_image_edit_source",
"enqueue_editor_generation_job_for_caller",
"Ok(EditorImageEditQueuePayload",
),
(
"pub(crate) async fn edit_editor_image_for_owner",
@@ -22309,6 +22329,17 @@ mod tests {
);
}
assert_function_contains_in_order(
source,
"pub(crate) async fn enqueue_editor_image_edit_for_owner",
"pub(crate) async fn prepare_editor_image_edit_queue_payload_for_owner",
&[
"prepare_editor_image_edit_queue_payload_for_owner",
"resolve_editor_image_edit_price",
"enqueue_editor_generation_job_for_caller",
],
);
assert_function_contains_in_order(
source,
"pub(crate) async fn preflight_editor_billable_generation_target",
@@ -1697,7 +1697,7 @@ mod pixel_art_persist_mapper_tests {
owner_user_id: Some("user-1".to_string()),
profile_id: None,
entity_id: Some("project-1".to_string()),
asset_kind: "image".to_string(),
asset_kind: "editor_pixel_art_snap".to_string(),
created_at_micros: 10,
updated_at_micros: 11,
}
@@ -1721,7 +1721,7 @@ mod pixel_art_persist_mapper_tests {
provider: None,
task_id: Some("pixel-art-snap-dialog-1".to_string()),
source_resource_id: Some("source-resource".to_string()),
asset_kind: Some("image".to_string()),
asset_kind: None,
generation_inputs_json: Some(r#"{"source":"canvas"}"#.to_string()),
public_showcase_enabled: false,
created_at_micros: 10,
@@ -1747,7 +1747,7 @@ mod pixel_art_persist_mapper_tests {
model: None,
provider: None,
task_id: Some("pixel-art-snap-dialog-1".to_string()),
asset_kind: Some("image".to_string()),
asset_kind: None,
generation_inputs_json: Some(r#"{"source":"canvas"}"#.to_string()),
source_resource_id: Some("source-resource".to_string()),
public_showcase_enabled: Some(false),
@@ -2586,7 +2586,7 @@ fn prepare_editor_project_resource(
let model = normalize_optional(input.model);
let provider = normalize_optional(input.provider);
let task_id = normalize_optional(input.task_id);
let asset_kind = normalize_optional(input.asset_kind);
let asset_kind = normalize_editor_semantic_asset_kind(input.asset_kind);
let generation_inputs_json = normalize_optional(input.generation_inputs_json);
let image_sequence_frames_json = normalize_optional(input.image_sequence_frames_json);
let image_sequence_duration_ms = input.image_sequence_duration_ms;
@@ -2667,7 +2667,7 @@ fn repair_editor_project_resource_media(
source_resource_id: resource.source_resource_id,
created_at: resource.created_at,
updated_at: now,
asset_kind: resource.asset_kind,
asset_kind: normalize_editor_semantic_asset_kind(resource.asset_kind),
generation_inputs_json: resource.generation_inputs_json,
public_showcase_enabled: resource.public_showcase_enabled,
image_sequence_frames_json: resource.image_sequence_frames_json,
@@ -4388,8 +4388,9 @@ fn validate_editor_pixel_art_result_input(
.ok_or_else(|| "完美像素 asset_object 缺少稳定 task_id".to_string())?;
let resource_task_id = normalize_optional(input.project_resource.task_id.clone());
let asset_task_id = normalize_optional(input.asset.task_id.clone());
let resource_asset_kind = normalize_optional(input.project_resource.asset_kind.clone());
let asset_asset_kind = normalize_optional(input.asset.asset_kind.clone());
let resource_asset_kind =
normalize_editor_semantic_asset_kind(input.project_resource.asset_kind.clone());
let asset_asset_kind = normalize_editor_semantic_asset_kind(input.asset.asset_kind.clone());
let expected_task_id = format!("pixel-art-snap-{operation_id}");
let expected_asset_object_id = editor_pixel_art_stable_record_id(
owner_user_id,
@@ -4691,7 +4692,7 @@ fn editor_pixel_art_project_resource_matches(
&& existing.provider == normalize_optional(input.provider.clone())
&& existing.task_id == normalize_optional(input.task_id.clone())
&& existing.source_resource_id == normalize_optional(input.source_resource_id.clone())
&& existing.asset_kind == normalize_optional(input.asset_kind.clone())
&& existing.asset_kind == normalize_editor_semantic_asset_kind(input.asset_kind.clone())
&& existing.generation_inputs_json
== normalize_optional(input.generation_inputs_json.clone())
&& !existing.public_showcase_enabled
@@ -4713,7 +4714,7 @@ fn editor_pixel_art_asset_matches(existing: &EditorAsset, input: &EditorAssetCre
&& existing.model == normalize_optional(input.model.clone())
&& existing.provider == normalize_optional(input.provider.clone())
&& existing.task_id == normalize_optional(input.task_id.clone())
&& existing.asset_kind == normalize_optional(input.asset_kind.clone())
&& existing.asset_kind == normalize_editor_semantic_asset_kind(input.asset_kind.clone())
&& existing.generation_inputs_json
== normalize_optional(input.generation_inputs_json.clone())
&& existing.source_resource_id == normalize_optional(input.source_resource_id.clone())
@@ -5033,7 +5034,7 @@ fn editor_spritesheet_asset_matches_create_input(
&& existing.model == normalize_optional(input.model.clone())
&& existing.provider == normalize_optional(input.provider.clone())
&& existing.task_id == normalize_optional(input.task_id.clone())
&& existing.asset_kind == normalize_optional(input.asset_kind.clone())
&& existing.asset_kind == normalize_editor_semantic_asset_kind(input.asset_kind.clone())
&& existing.generation_inputs_json
== normalize_optional(input.generation_inputs_json.clone())
&& existing.source_resource_id == normalize_optional(input.source_resource_id.clone())
@@ -5424,7 +5425,7 @@ fn create_editor_asset(
if group_task_id.is_some() {
require_editor_generation_runtime_service_identity(ctx, caller)?;
}
let asset_kind = normalize_optional(input.asset_kind);
let asset_kind = normalize_editor_semantic_asset_kind(input.asset_kind);
let generation_inputs_json = normalize_optional(input.generation_inputs_json);
let image_sequence_frames_json = normalize_optional(input.image_sequence_frames_json);
validate_editor_generation_inputs(asset_kind.as_deref(), generation_inputs_json.as_deref())?;
@@ -5507,7 +5508,7 @@ fn update_editor_asset(
task_id: asset.task_id,
created_at: asset.created_at,
updated_at: now,
asset_kind: asset.asset_kind,
asset_kind: normalize_editor_semantic_asset_kind(asset.asset_kind),
generation_inputs_json: asset.generation_inputs_json,
source_resource_id: asset.source_resource_id,
thumbnail_src: asset.thumbnail_src,
@@ -5557,7 +5558,7 @@ fn repair_editor_asset_media(
task_id: asset.task_id,
created_at: asset.created_at,
updated_at: now,
asset_kind: asset.asset_kind,
asset_kind: normalize_editor_semantic_asset_kind(asset.asset_kind),
generation_inputs_json: asset.generation_inputs_json,
source_resource_id: asset.source_resource_id,
thumbnail_src: asset.thumbnail_src,
@@ -5681,7 +5682,7 @@ fn build_pending_editor_showcase_asset(
provider: asset.provider,
task_id: asset.task_id,
source_resource_id: asset.source_resource_id,
asset_kind: asset.asset_kind,
asset_kind: normalize_editor_semantic_asset_kind(asset.asset_kind),
generation_inputs_json: asset.generation_inputs_json,
thumbnail_src: asset.thumbnail_src,
generation_cost_mud_points: asset.generation_cost_mud_points,
@@ -7241,7 +7242,7 @@ fn normalize_legacy_editor_source_asset_kind(
object_key: Option<&str>,
) -> Option<String> {
if asset_kind.as_deref() != Some(EDITOR_LEGACY_GREEN_SCREEN_SOURCE_ASSET_KIND) {
return asset_kind;
return normalize_editor_semantic_asset_kind(asset_kind);
}
let object_key = object_key.unwrap_or_default().to_ascii_lowercase();
if object_key.contains("/character-images/") {
@@ -7359,7 +7360,7 @@ fn showcase_snapshot_from_row(row: EditorShowcaseAsset) -> EditorShowcaseAssetSn
provider: row.provider,
task_id: row.task_id,
source_resource_id: row.source_resource_id,
asset_kind: row.asset_kind,
asset_kind: normalize_editor_semantic_asset_kind(row.asset_kind),
generation_inputs_json: row.generation_inputs_json,
thumbnail_src: row.thumbnail_src,
generation_cost_mud_points: row.generation_cost_mud_points,
@@ -7800,7 +7801,7 @@ fn asset_snapshot_from_row(ctx: &ReducerContext, row: EditorAsset) -> EditorAsse
model: row.model,
provider: row.provider,
task_id: row.task_id,
asset_kind: row.asset_kind,
asset_kind: normalize_editor_semantic_asset_kind(row.asset_kind),
generation_inputs_json: row.generation_inputs_json,
source_resource_id: row.source_resource_id.or(inferred_source_resource_id),
public_showcase_enabled,
@@ -7842,7 +7843,7 @@ fn admin_asset_snapshot_from_row(
model: row.model,
provider: row.provider,
task_id: row.task_id,
asset_kind: row.asset_kind,
asset_kind: normalize_editor_semantic_asset_kind(row.asset_kind),
generation_inputs_json: row.generation_inputs_json,
source_resource_id: row.source_resource_id,
thumbnail_src: row.thumbnail_src,
@@ -8306,6 +8307,10 @@ fn normalize_optional(value: Option<String>) -> Option<String> {
.filter(|item| !item.is_empty())
}
fn normalize_editor_semantic_asset_kind(value: Option<String>) -> Option<String> {
normalize_optional(value).filter(|asset_kind| asset_kind != "image")
}
fn normalize_title(value: &str) -> String {
let title = value.trim();
if title.is_empty() {
@@ -11808,7 +11813,8 @@ fn normalize_editor_character_animation_row(
image_sequence_duration_ms: Option<u64>,
evidence: EditorCharacterAnimationObjectEvidence,
) -> EditorCharacterAnimationRowPlan {
let stored_asset_kind = asset_kind.map(str::to_string);
let stored_asset_kind = normalize_editor_semantic_asset_kind(asset_kind.map(str::to_string));
let normalized_ordinary_image_kind = asset_kind == Some("image");
let formal_frames = parse_migration_frames_json(image_sequence_frames_json);
let generation_value =
generation_inputs_json.and_then(|value| serde_json::from_str::<JsonValue>(value).ok());
@@ -11891,7 +11897,7 @@ fn normalize_editor_character_animation_row(
generation_inputs_json: generation_inputs_json.map(str::to_string),
image_sequence_frames_json: image_sequence_frames_json.map(str::to_string),
image_sequence_duration_ms,
changed: false,
changed: normalized_ordinary_image_kind,
backfilled_frames: false,
backfilled_duration: false,
reclassified_preview: false,
@@ -13071,7 +13077,7 @@ fn canonical_action_asset_for_legacy_canvas_layer(
source_resource_id: asset.source_resource_id,
created_at: ctx.timestamp,
updated_at: ctx.timestamp,
asset_kind: asset.asset_kind,
asset_kind: normalize_editor_semantic_asset_kind(asset.asset_kind),
generation_inputs_json: asset.generation_inputs_json,
public_showcase_enabled: false,
image_sequence_frames_json: asset.image_sequence_frames_json,
@@ -14272,9 +14278,9 @@ mod tests {
EditorCharacterAnimationObjectEvidence::default(),
);
assert!(!plan.changed);
assert!(plan.changed);
assert!(plan.blocker.is_none());
assert_eq!(plan.asset_kind.as_deref(), Some("image"));
assert_eq!(plan.asset_kind, None);
assert_eq!(
plan.generation_inputs_json.as_deref(),
Some(inputs_json.as_str())
@@ -15205,7 +15211,7 @@ mod tests {
provider: Some("Genarrative".to_string()),
task_id: Some("pixel-art-snap-dialog-1".to_string()),
source_resource_id: Some("source-resource".to_string()),
asset_kind: Some("image".to_string()),
asset_kind: None,
generation_inputs_json: Some("{\"source\":\"canvas\"}".to_string()),
public_showcase_enabled: false,
image_sequence_frames_json: None,
@@ -15923,9 +15929,7 @@ mod tests {
.is_err(),
"{field} must be rejected for character actions"
);
assert!(
validate_editor_generation_inputs(Some("image"), Some(inputs.as_str())).is_ok()
);
assert!(validate_editor_generation_inputs(None, Some(inputs.as_str())).is_ok());
}
}
@@ -17153,9 +17157,9 @@ mod tests {
#[test]
fn structured_canvas_incompatible_asset_kind_override_falls_back_to_resource_default() {
for (resource_asset_kind, asset_kind_override, expected_override) in [
(Some("image"), "character-animation", None),
(Some("image"), "video", None),
(Some("image"), "sound-effect", None),
(None, "character-animation", None),
(None, "video", None),
(None, "sound-effect", None),
(Some("video"), "character", None),
(Some("audio"), "background-music", Some("background-music")),
(
@@ -17169,7 +17173,7 @@ mod tests {
"character-animation",
Some("character-animation"),
),
(Some("image"), "icon", Some("icon")),
(None, "icon", Some("icon")),
(None, "character", Some("character")),
] {
let resource = canvas_override_test_resource(resource_asset_kind);
@@ -168,7 +168,7 @@ function createTestImageAsset({
width: 320,
height: 240,
sourceType,
assetKind: 'image' as const,
assetKind: null,
sourceResourceId,
};
}
@@ -6,6 +6,7 @@ import type {
} from '../../services/image-editor/editorProjectClient';
import {
CANVAS_WORLD_ORIGIN,
canvasAssetKindOrNull,
canvasDisplayScaleToViewportScale,
canvasDisplayViewportToViewport,
collectExpiredInlineGenerationDialogIds,
@@ -400,7 +401,7 @@ describe('ImageCanvasEditorModel', () => {
width: 640,
height: 640,
sourceType: 'generated',
assetKind: 'image',
assetKind: null,
sourceResourceId: 'resource-generated',
},
],
@@ -409,6 +410,7 @@ describe('ImageCanvasEditorModel', () => {
expect(library.assets[0]).toMatchObject({
id: 'asset-generated',
sourceResourceId: 'resource-generated',
assetKind: null,
});
});
@@ -887,7 +889,7 @@ describe('ImageCanvasEditorModel', () => {
assetKindOverride: 'character-animation',
},
new Map([
['resource-image', { imageSrc: '/read/image.png', assetKind: 'image' }],
['resource-image', { imageSrc: '/read/image.png', assetKind: null }],
]),
{ onAssetKindOverrideFallback },
);
@@ -896,29 +898,28 @@ describe('ImageCanvasEditorModel', () => {
id: 'layer-image-with-action-label',
src: '/read/image.png',
mediaType: 'image',
resourceAssetKind: 'image',
resourceAssetKind: null,
assetKindOverride: null,
assetKind: 'image',
assetKind: null,
});
expect(onAssetKindOverrideFallback).toHaveBeenCalledWith({
layerId: 'layer-image-with-action-label',
resourceId: 'resource-image',
resourceAssetKind: 'image',
resourceAssetKind: null,
rejectedAssetKindOverride: 'character-animation',
});
});
it('uses the same media-family compatibility matrix for layer labels', () => {
expect(isCanvasAssetKindOverrideCompatible('image', 'character')).toBe(
true,
);
expect(canvasAssetKindOrNull('image')).toBeNull();
expect(isCanvasAssetKindOverrideCompatible(null, 'character')).toBe(true);
expect(isCanvasAssetKindOverrideCompatible(null, 'icon')).toBe(true);
expect(
isCanvasAssetKindOverrideCompatible('sound-effect', 'background-music'),
).toBe(true);
expect(isCanvasAssetKindOverrideCompatible('image', 'video')).toBe(false);
expect(isCanvasAssetKindOverrideCompatible(null, 'video')).toBe(false);
expect(
isCanvasAssetKindOverrideCompatible('image', 'character-animation'),
isCanvasAssetKindOverrideCompatible(null, 'character-animation'),
).toBe(false);
expect(isCanvasAssetKindOverrideCompatible('video', 'character')).toBe(
false,
@@ -1842,7 +1842,7 @@ function inferEditorAssetKind(
objectKey?: string | null,
assetKind?: string | null,
mediaType?: CanvasMediaType,
): CanvasAssetKind {
): CanvasAssetKind | null {
const normalizedAssetKind = canvasAssetKindOrNull(assetKind);
if (normalizedAssetKind) {
return normalizedAssetKind;
@@ -1858,7 +1858,7 @@ function inferEditorAssetKind(
if (inferredMediaType === 'audio') {
return inferAudioAssetKindFromLabel(label);
}
return 'image';
return null;
}
function hasMediaFileExtension(
@@ -2215,8 +2215,7 @@ export function generationInputsOrNull(
}
export function canvasAssetKindOrNull(value: unknown): CanvasAssetKind | null {
return value === 'image' ||
value === 'audio' ||
return value === 'audio' ||
value === 'spec' ||
value === 'character' ||
value === 'character-animation' ||
@@ -17,7 +17,6 @@ import type {
export type CanvasSourceType = 'uploaded' | 'generated' | 'mock_generated';
export type CanvasAssetKind =
| 'image'
| 'audio'
| 'spec'
| 'character'
@@ -423,6 +423,13 @@ describe('ImageCanvasGenerationModel', () => {
},
{ label: 'gpt-image-2', value: 'gpt-image-2' },
]);
expect(
isQuickEditSupportedLayer({
...buildSourceLayer(),
mediaType: 'image',
assetKind: null,
}),
).toBe(true);
expect(
isQuickEditSupportedLayer({
...buildSourceLayer(),
@@ -4,7 +4,7 @@ import { resolveUploadedAssetKind } from './ImageCanvasUploadUtils';
describe('ImageCanvasUploadUtils', () => {
it('maps supported upload media types to persisted asset kinds', () => {
expect(resolveUploadedAssetKind('image')).toBe('image');
expect(resolveUploadedAssetKind('image')).toBeUndefined();
expect(resolveUploadedAssetKind('video')).toBe('video');
expect(resolveUploadedAssetKind('audio')).toBe('audio');
});
@@ -5,7 +5,7 @@ import type {
export function resolveUploadedAssetKind(
mediaType: CanvasMediaType,
): CanvasAssetKind {
): CanvasAssetKind | undefined {
switch (mediaType) {
case 'video':
return 'video';
@@ -14,6 +14,6 @@ export function resolveUploadedAssetKind(
case 'image-sequence':
throw new Error('序列帧素材不支持按单个媒体文件上传');
case 'image':
return 'image';
return undefined;
}
}
@@ -1088,7 +1088,7 @@ describe('ImageCanvasWorldView', () => {
it('disables labels from other media families', () => {
const imageLayer = createLayer({
resourceAssetKind: 'image',
resourceAssetKind: null,
assetKind: 'character',
});
renderWorldView({ layers: [imageLayer] });
@@ -1110,7 +1110,7 @@ describe('ImageCanvasWorldView', () => {
it('offers scene as a user-selectable label for image layers', () => {
const imageLayer = createLayer({
resourceAssetKind: 'image',
resourceAssetKind: null,
assetKind: 'character',
});
const { props } = renderWorldView({ layers: [imageLayer] });
@@ -73,7 +73,7 @@ function createAssetSnapshot(
height: 240,
folderId: 'project',
sourceType: 'uploaded',
assetKind: 'image',
assetKind: null,
...overrides,
};
}
@@ -1044,7 +1044,7 @@ describe('useImageCanvasProjectPersistence', () => {
width: 320,
height: 240,
sourceType: 'uploaded',
assetKind: 'image',
assetKind: null,
},
],
updatedAt: '2026-08-05T00:00:00.000Z',
@@ -1054,7 +1054,7 @@ describe('useImageCanvasProjectPersistence', () => {
await waitFor(() => {
expect(screen.getByTestId('layers').textContent).toContain(
'layer-image:resource-image:image',
'layer-image:resource-image:-',
);
});
expect(