补齐生成中间产物持久化与拆图提示

图标和UI图集拆分失败时保留主图集并在内联、队列及刷新路径提示
角色、图片修改、图标UI和角色动作链路把可恢复中间产物写入项目与素材库
外部生成摘要新增独立告警字段并清理成功任务的历史错误
同步共享契约、SpacetimeDB迁移与绑定、OpenAPI、设计文档和回归测试
This commit is contained in:
2026-07-13 21:41:46 +08:00
parent f1639de5fe
commit 4e7bcb8f24
29 changed files with 945 additions and 216 deletions
@@ -3285,6 +3285,13 @@
"string",
"null"
]
},
"assetFolderId": {
"type": [
"string",
"null"
],
"description": "角色动作绿幕预览视频写入账号素材库的文件夹;省略时写入默认项目素材文件夹。"
}
},
"additionalProperties": false
@@ -16,12 +16,13 @@
---
## 2026-07-13 图片画布多产物生成任务必须回填全部产物
## 2026-07-13 图片画布多产物生成任务必须保存全部可恢复产物
- 背景:角色形象、图标 spritesheet 和 UI 素材提取会先得到带纯色背景的原图,再执行抠图或拆分;此前原图只登记到 OSS,后端完成快照只把后处理结果放到画布,用户无法在画布继续使用同一任务的完整产物
- 决策:凡一次资产生成任务产生多个可复产物,所有产物都必须登记为 `editor_project_resource` 并由同一次后端 `canvasCompletion` 写入权威画布快照,不能只返回最终产物或由前端临时补图。角色形象、图标 spritesheet 和 UI 素材提取至少同时保留纯色背景原图与透明后处理结果;UI 素材提取还继续保留拆分素材。生成器 `generatedLayerId` 始终锚定主后处理结果,附属产物在主结果右侧错开放置,避免重叠并保持后续改造入口语义不变。没有项目上下文时继续只持久化 OSS / asset object,不虚构画布资源
- 影响范围:`server-rs/crates/api-server/src/editor_project.rs`、图片画布生成完成快照、角色形象 / 图标 spritesheet / UI 素材提取链路和图片画布文档
- 验证方式:覆盖多产物画布布局、主结果锚定和资源去重的定向 Rust 测试,并运行 `cargo check -p api-server --manifest-path server-rs/Cargo.toml``npm run check:encoding``git diff --check`
- 背景:角色形象、图标 spritesheet 和 UI 素材提取会先得到带纯色背景的原图,再执行抠图或拆分;图片修改会先得到模型对齐尺寸的原始输出,角色动作会先得到绿幕预览视频,再抽帧和抠图。此前部分原始产物只登记到 OSS,或者要等后处理成功后才进入项目资源,用户无法在失败后找回已经生成成功的内容
- 决策:凡一次资产生成任务产生多个可复产物,后端必须先把上游已返回的中间产物写入 OSS、`asset_object`、项目资源和账号素材库,再执行抠图、尺寸恢复、抽帧或拆分;未指定素材文件夹时进入默认“项目”文件夹。角色形象、图标 spritesheet 和 UI 素材提取同时保留纯色背景原图与透明后处理结果;图片修改保留模型原始输出与尺寸恢复结果;角色动作把绿幕预览视频作为一个可复用素材保存,逐帧源图继续留在同一任务 OSS 路径,不把 32 至 48 帧逐张灌入素材库。普通图片、去背景、音频等没有独立上游中间产物的任务不制造重复副本
- 画布与降级:有项目上下文的图片多产物继续由同一次 `canvasCompletion` 写入权威画布快照,生成器 `generatedLayerId` 锚定主后处理结果。图标和 UI 图集自动拆分是 best-effort;识别或切片持久化失败仍完成整张透明图集,并在 inline、队列轮询和刷新后任务列表中提示非阻断 warning,不得借用失败错误字段
- 影响范围:`server-rs/crates/api-server/src/editor_project.rs``character_animation_assets.rs`、外部生成任务摘要、图片画布完成快照、账号素材库和前端生成提示
- 验证方式:覆盖中间产物登记先于后处理、默认素材文件夹、图集拆分降级、inline / queue warning 和主结果锚定的定向测试,并运行 `cargo check -p api-server --manifest-path server-rs/Cargo.toml``npm run check:spacetime-schema`、前端定向测试、`npm run check:encoding``git diff --check`
- 关联文档:`docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md``docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`
## 2026-07-12 泥点充值收敛为四档并统一资产入口
@@ -270,13 +270,13 @@ npm run check:server-rs-ddd
- Rust 结构体:`ExternalGenerationJob`
- 源码:`server-rs/crates/spacetime-module/src/external_generation.rs`
- 用途:外部生成 worker 的内部持久任务队列;`GENARRATIVE_EXTERNAL_GENERATION_MODE=queue` 时,`api-server` HTTP 角色只入队,`external-generation-worker` 角色通过 claim lease 领取、续租、执行,并用 `lease_token` 栅栏回写完成 / 失败。队列行继续保存 worker 执行、计费与滚动发布兼容所需字段,但用户可见任务列表、价格、状态、未确认终态数量和通知确认时间的正式读取事实源已经迁到 `external_generation_job_summary`;BFF 不得再为列表 / 详情 / acknowledge 读取该大表。拼图 `compile_puzzle_draft` 的前置 `compile_puzzle_agent_draft``generate_puzzle_images``generate_puzzle_ui_background` 的业务写回也在对应 SpacetimeDB transaction 内校验 `job_id + worker_id + lease_token`、job kind、owner 和 source entity,避免过期 worker 写 session / work profile;图片画布编辑器的 `editor_image_generation``editor_image_edit``editor_background_removal``editor_icon_spritesheet_generation``editor_ui_design_asset_extraction``editor_character_animation_generation``editor_video_generation``editor_sound_effect_generation``editor_background_music_generation` 复用同一队列表,worker 成功后经 `api-server` facade 写入 `editor_project_resource` / `editor_asset` / `editor_canvas.layers_json`,前端只通过 BFF job 状态轮询和项目快照读取恢复完成态。`GENARRATIVE_EXTERNAL_GENERATION_MODE=inline` 时不创建该队列行,三个 external generation guard 字段必须同时为空才允许 api-server 受控同步写回,半空 guard 仍会拒绝。worker 成功写回业务事实后才能 complete job;业务失败态写回成功后才能 fail job,失败态未写回时保留租约等待后续重领。
- 载荷约束:本次先对 `source_module = editor-canvas``request_payload_json` / `result_payload_json` 实施有限大小合法 JSON、任意层级禁止 `data:` / `blob:` 的双层门禁,只保存 worker 执行必需的普通参数和已登记媒体引用;其它玩法在完成各自参考图资源化之前不由本次门禁静默改变既有请求契约。该主表只供 worker claim / 执行和受控维护读取;正式用户任务列表、单任务状态、队列概览与 acknowledge 不得再返回或解析这两个 payload。
- 载荷约束:本次先对 `source_module = editor-canvas``request_payload_json` / `result_payload_json` 实施有限大小合法 JSON、任意层级禁止 `data:` / `blob:` 的双层门禁,只保存 worker 执行必需的普通参数和已登记媒体引用;图标图集生成和 UI 素材提取成功但自动拆分降级时,worker 的 `result_payload_json` 只额外保存有界的 `warning.code/reason`,不保存 spritesheet、切片列表或媒体 URL。其它玩法在完成各自参考图资源化之前不由本次门禁静默改变既有请求契约。该主表只供 worker claim / 执行和受控维护读取;正式用户任务列表、单任务状态、队列概览与 acknowledge 不得再返回或解析这两个 payload。
### `external_generation_job_summary`
- Rust 结构体:`ExternalGenerationJobSummary`
- 源码:`server-rs/crates/spacetime-module/src/external_generation.rs`
- 用途:外部生成正式任务列表的轻量投影,按 `job_id` 保存 owner、来源、状态、价格、有界错误摘要、通知确认时间、各阶段时间和入队时提取的 `request_prompt`,不包含 request/result payload、worker lease 或 dedupe 内部字段。错误摘要统一拒绝内联媒体并限制为 2048 字符;列表在单次 owner 扫描中同时计数并只保留请求 limit 的固定大小 top-N,不得先收集全量历史再截断。enqueue、claim、renew、complete、fail 事务同步投影;acknowledge 只更新该轻量表并写审计事件,后续主任务同步必须保留已有确认时间,禁止为了写确认时间加载 / 重写大 payload 行。BFF 的列表、状态和确认只调用 summary procedure。历史终态任务由迁移操作员的游标分批 maintenance procedure 在压缩 payload 时同步回填摘要,正式列表不得为兼容旧数据回扫完整主表。
- 用途:外部生成正式任务列表的轻量投影,按 `job_id` 保存 owner、来源、状态、价格、有界错误摘要、有界非阻断告警 `warning_message`通知确认时间、各阶段时间和入队时提取的 `request_prompt`,不包含 request/result payload、worker lease 或 dedupe 内部字段。错误与告警摘要都不复制内联媒体并限制为 2048 字符;`warning_message` 由完成任务的轻量 `result_payload_json.warning.reason` 提取,complete 和历史 backfill 共用同一构建路径。列表在单次 owner 扫描中同时计数并只保留请求 limit 的固定大小 top-N,不得先收集全量历史再截断。enqueue、claim、renew、complete、fail 事务同步投影;acknowledge 只更新该轻量表并写审计事件,后续主任务同步必须保留已有确认时间,禁止为了写确认时间加载 / 重写大 payload 行。BFF 的列表、状态和确认只调用 summary procedure。历史终态任务由迁移操作员的游标分批 maintenance procedure 在压缩 payload 时同步回填摘要,正式列表不得为兼容旧数据回扫完整主表。
- 正式读取 procedure 为 `get_external_generation_job_summary_and_return``list_external_generation_job_summaries_and_return``acknowledge_external_generation_job_summaries_and_return`。历史维护 procedure 为 `compact_external_generation_job_payloads_and_return``backfill_external_generation_job_summaries_and_return`,仅 migration operator 可调用;运维入口统一使用 `npm run spacetime:external-generation:maintain -- ...`,默认 dry-run、单批最多 25 条。B-tree cursor 选择阶段最多反序列化 `limit + 1` 行,apply 再按主键逐条读取选中行;怀疑存在单行异常巨型 JSON 时必须先使用 `--limit 1`。payload 压缩额外固定使用 `source_module = editor-canvas` 的复合 cursor 索引,不得静默改写其它玩法历史任务。
### `external_generation_job_event`
@@ -117,6 +117,8 @@
- 保存时在现有画布布局数组中追加 `itemType: "generation-dialog"` 项,记录生成器 ID、模式、提示词、参数、参考图、状态、占位框位置和 `generatedLayerId`
- 生成成功后仍保留生成器快照;画布渲染优先用 `generatedLayerId` 锚定到成品图层,不再重复显示灰色占位框。
- 一次生成任务产生多个可复用产物时,全部产物都必须由后端登记为项目资源并随同一次完成快照加入画布,不能只保留最终产物或由前端临时追加。角色形象、图标 spritesheet 和 UI 素材提取至少同时回填纯色背景原图与透明后处理结果;UI 素材提取继续一并回填拆分素材。`generatedLayerId` 仍锚定透明后处理主结果,附属产物从主结果右侧开始错开放置。
- 多产物任务的可恢复中间产物还必须进入账号素材库,未传 `assetFolderId` 时落默认“项目”文件夹,并在抠图、尺寸恢复、抽帧或拆分前完成登记。图片修改保存模型对齐尺寸的原始输出;角色动作把绿幕预览视频保存为一个素材,逐帧绿幕源图只保留在同一任务 OSS 路径,避免素材库一次新增 32 至 48 张帧图。普通图片、去背景和音频等没有独立上游中间产物的任务不重复复制最终结果。
- 图标和 UI 图集自动拆分属于非阻断附加动作;识别或切片持久化失败时整张透明图集仍完成并回填,前端通过 warning toast 提示用户可手动重试。inline 响应、worker 队列终态和刷新后的任务列表必须使用同一 warning 语义,不能把已完成图集标记为失败。
- 图片、视频和音频生成结果都要写入账号级素材库;视频 / 音频结果由后端持久化到 OSS 并回传 `objectKey` / `assetObjectId`,前端保存素材库时一并记录,后续预览和再次加入画布走统一换签链路。
- 刷新项目后,画布需要同时恢复图层、生成器快照和生成输入框跟随关系。
@@ -61,7 +61,8 @@
仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用后端自动决策出的抠图背景色。纯色背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现与背景色相同或相近的描边、底板、投影或反光。
```
- 后端收到 spritesheet 后先把带解析后纯色背景的源图写入 OSS,再调用 BgFilter 按默认 `segModel=birefnet` 透明化,并复用图标素材的连通域拆分能力;未知素材数量时按从上到下、从左到右自动命名为 `素材 1``素材 2`
- 后端收到 spritesheet 后先把带解析后纯色背景的源图写入 OSS、项目资源和账号素材库,再调用 BgFilter 按默认 `segModel=birefnet` 透明化;透明 spritesheet 同样先进入 OSS、项目资源和账号素材库,再复用图标素材的连通域拆分能力。调用方未指定素材文件夹时落默认“项目”文件夹
- UI 素材自动拆分与图标图集一致,属于 best-effort 附加动作。未知素材数量时按从上到下、从左到右自动命名为 `素材 1``素材 2`;识别或切片持久化失败仍返回整张透明图集和 `sliceWarning`,前端显示非阻断 warning toast,用户可手动重试。
- 前端先把 spritesheet 原图作为 `assetKind: "icon-spritesheet"` 图集图层放在 UI 设计图右侧,再把拆分出的独立素材作为 `assetKind: "icon"` 图标图层继续放到画布。图集图层同样提供 `拆分图集` 工具栏按钮,可使用相同连通域规则重新拆分。
## 验收点
@@ -61,7 +61,7 @@
- 后端收到 spritesheet 后先把带解析后纯色背景的源图写入 OSS,再调用 BgFilter 透明化;请求字段包含 `screenColor``segModel`,前端用户路径固定提交 `screenColor=auto` 与默认 `birefnet`,后端仍识别内部保留的 `anime-seg`,但该选项不对用户可见。
- 带背景原图和去背后的透明 spritesheet 都先同时写入 OSS、项目资源和账号素材库,再按 alpha 连通域和素材描述顺序执行附加拆分;调用方未指定素材文件夹时统一落默认“项目”文件夹。每个成功切片单独写入 OSS、项目资源和账号素材库,`sourceResourceId` 指向透明图集资源。
- 自动拆分是生成后的 best-effort 附加动作,不参与图集生成的成功判定。连通域识别或切片持久化失败时,接口仍返回并回填整张透明图集,`iconImageSrcs=[]`,并通过 `sliceWarning.code/reason` 暴露非阻断原因;用户可在图集工具栏手动重试。
- 自动拆分是生成后的 best-effort 附加动作,不参与图集生成的成功判定。连通域识别或切片持久化失败时,接口仍返回并回填整张透明图集,`iconImageSrcs=[]`,并通过 `sliceWarning.code/reason` 暴露非阻断原因;前端在 inline、worker 队列完成和刷新恢复三条路径统一显示 warning toast用户可在图集工具栏手动重试。
- 响应通过 `iconImageSrcs` 返回成功切片素材;自动生成使用用户输入的素材描述命名,UI 设计提取和手动拆分按从上到下、从左到右自动命名为 `素材 N`
- 手动拆分调用 `POST /api/editor/icon-spritesheets/slices`,只允许读取当前用户项目中的 `icon-spritesheet` 资源,不调用图片生成 provider,不扣除泥点。输入限制为单边最多 `4096` 像素、总像素最多 `2048×2048`,单次最多持久化 `64` 个切片;超限在任何切片写入前拒绝。
@@ -147,7 +147,7 @@
### Prompt 与生成契约
- 前端提交到 `POST /api/editor/character-animations/generations`
- 请求必须带上角色图片来源、原始尺寸、动画描述、分辨率、画面比例、帧数和时长。角色图片已经持久化到 OSS 时,`sourceImageSrc` 必须优先传 `objectKey`;只有未持久化的本地临时图片才允许传 Data URL。
- 请求必须带上角色图片来源、原始尺寸、动画描述、分辨率、画面比例、帧数和时长,并可通过 `assetFolderId` 指定中间视频素材落点。角色图片已经持久化到 OSS 时,`sourceImageSrc` 必须优先传 `objectKey`;只有未持久化的本地临时图片才允许传 Data URL。
- 后端使用角色图片作为首帧和尾帧参考,模型固定映射到 `doubao-seedance-2-0-fast-260128`
- 后端路由兼容旧 Data URL 请求并单独放宽 JSON body limit 到 `12MB`,但该限额只作为兼容兜底,不作为新链路默认传大图的方式。
- 后端 prompt 使用以下固定骨架,并把面板输入追加到 `动作描述:` 后:
@@ -160,7 +160,7 @@
### 抽帧与 OSS 存储
- 视频生成完成后,后端按面板选择抽取对应帧数:`32``40``48`
- 视频生成完成后,后端先把带纯色背景的预览视频登记为 OSS 私有对象、`asset_object`、项目资源和账号素材,再按面板选择抽取对应帧数:`32``40``48`。未传 `assetFolderId` 时进入默认“项目”素材文件夹;后续抽帧或抠图失败不能抹掉这份已经生成成功的可恢复视频
- 抽帧采样必须按目标帧数预留视频尾部安全步长,例如 `32帧·4秒` 最后一帧采 `3.875s`,避免 FFmpeg 在尾点附近返回成功但输出 `0` 帧。
- 每帧必须先把带自动决策纯色背景的源图写入 OSS,再优先调用阿里云通用抠图输出透明背景 PNG;阿里云失败时降级执行本地 `editor_green_screen`,并按同一次生成已选定的 `screenColor` 去背。
- 抽帧结果写入 OSS,并返回帧路径、帧尺寸、帧数、fps、预览视频路径、模型、价格和实际 prompt。
@@ -22,6 +22,7 @@ export interface ExternalGenerationJobStatusRecord {
phaseDetail: string;
progress: number;
error?: string | null;
warning?: string | null;
updatedAtMicros: number;
}
@@ -41,6 +42,7 @@ export interface ExternalGenerationTaskRecord {
phaseDetail: string;
progress: number;
error?: string | null;
warning?: string | null;
priceMudPoints: number;
refundLedgerId?: string | null;
notificationAcknowledgedAt?: string | null;
@@ -165,7 +165,13 @@ struct PersistedEditorVideo {
legacy_public_path: String,
object_key: String,
asset_object_id: String,
thumbnail_src: String,
thumbnail_src: Option<String>,
}
struct PersistedEditorCharacterAnimationPreview {
legacy_public_path: String,
object_key: String,
asset_object_id: String,
}
pub async fn list_character_animation_templates(
@@ -627,6 +633,7 @@ pub(crate) async fn generate_editor_character_animation_for_owner(
let canvas_completion = payload.canvas_completion.clone();
let generation_inputs = payload.generation_inputs.clone();
let source_resource_id = payload.source_resource_id.clone();
let asset_folder_id = payload.asset_folder_id.clone();
let pricing = state.editor_generation_pricing().await.map_err(|error| {
character_animation_error_response(
@@ -727,6 +734,33 @@ pub(crate) async fn generate_editor_character_animation_for_owner(
source_data_url.as_str(),
)
.await?;
persist_editor_generated_media_asset(
&state,
PersistEditorGeneratedAssetRequest {
project_id: project_id.clone(),
owner_user_id: owner_user_id.clone(),
folder_id: asset_folder_id
.clone()
.or_else(|| Some("project".to_string())),
label: "角色动作原始视频".to_string(),
image_src: generated.preview_video_path.clone(),
object_key: generated.object_key.clone(),
asset_object_id: generated.asset_object_id.clone(),
width: normalized.frame_width,
height: normalized.frame_height,
prompt: normalized.prompt.clone(),
actual_prompt: Some(generated.submitted_prompt.clone()),
model: EDITOR_CHARACTER_ANIMATION_MODEL.to_string(),
provider: "Ark".to_string(),
task_id: task_id.clone(),
source_resource_id: source_resource_id.clone(),
asset_kind: Some(EDITOR_GREEN_SCREEN_SOURCE_ASSET_KIND.to_string()),
generation_inputs: generation_inputs.clone(),
thumbnail_src: None,
generation_cost_mud_points: 0,
},
)
.await?;
let frames = extract_and_persist_editor_character_animation_frames(
&state,
owner_user_id.as_str(),
@@ -1819,6 +1853,53 @@ async fn put_generated_preview_video(
Ok(put_result.legacy_public_path)
}
async fn put_generated_editor_character_animation_preview(
state: &AppState,
owner_user_id: &str,
source_layer_id: &str,
task_id: &str,
preview_payload: MediaPayload,
) -> Result<PersistedEditorCharacterAnimationPreview, AppError> {
let content_type = preview_payload.mime_type.clone();
let put_result = put_character_animation_object(
state,
LegacyAssetPrefix::CharacterDrafts,
vec![
sanitize_storage_segment(source_layer_id, "layer"),
"animation".to_string(),
"editor-character-animation".to_string(),
task_id.to_string(),
],
format!("preview.{}", preview_payload.extension),
preview_payload.mime_type,
preview_payload.bytes,
build_asset_metadata(
EDITOR_GREEN_SCREEN_SOURCE_ASSET_KIND,
owner_user_id,
"editor_layer",
source_layer_id,
EDITOR_GREEN_SCREEN_SOURCE_SLOT,
"editor-character-animation",
),
)
.await?;
let confirmed = confirm_editor_character_animation_source_asset_object(
state,
owner_user_id,
source_layer_id,
task_id,
put_result.object_key.clone(),
content_type,
)
.await?;
Ok(PersistedEditorCharacterAnimationPreview {
legacy_public_path: put_result.legacy_public_path,
object_key: put_result.object_key,
asset_object_id: confirmed.record.asset_object_id,
})
}
async fn request_editor_character_animation_preview(
state: &AppState,
http_client: &reqwest::Client,
@@ -1841,20 +1922,19 @@ async fn request_editor_character_animation_preview(
"下载画板角色动画视频失败。",
)
.await?;
let preview_video_path = put_generated_preview_video(
let persisted = put_generated_editor_character_animation_preview(
state,
owner_user_id,
source_layer_id,
"editor-character-animation",
task_id,
preview_payload,
)
.await?;
Ok(GeneratedAnimationPreview {
preview_video_path,
object_key: None,
asset_object_id: None,
preview_video_path: persisted.legacy_public_path,
object_key: Some(persisted.object_key),
asset_object_id: Some(persisted.asset_object_id),
thumbnail_src: None,
upstream_task_id,
submitted_prompt: prompt.to_string(),
@@ -1884,7 +1964,7 @@ async fn request_editor_video_preview(
preview_video_path: persisted.legacy_public_path,
object_key: Some(persisted.object_key),
asset_object_id: Some(persisted.asset_object_id),
thumbnail_src: Some(persisted.thumbnail_src),
thumbnail_src: persisted.thumbnail_src,
upstream_task_id,
submitted_prompt: request.prompt.clone(),
moderation_fallback_applied: false,
@@ -1942,9 +2022,6 @@ async fn put_generated_editor_video(
preview_payload: MediaPayload,
) -> Result<PersistedEditorVideo, AppError> {
let content_type = preview_payload.mime_type.clone();
let thumbnail_src =
put_generated_editor_video_cover(state, owner_user_id, task_id, request, &preview_payload)
.await?;
let put_result = put_character_animation_object(
state,
LegacyAssetPrefix::CharacterDrafts,
@@ -1954,8 +2031,8 @@ async fn put_generated_editor_video(
task_id.to_string(),
],
format!("preview.{}", preview_payload.extension),
preview_payload.mime_type,
preview_payload.bytes,
preview_payload.mime_type.clone(),
preview_payload.bytes.clone(),
build_asset_metadata(
EDITOR_VIDEO_ASSET_KIND,
owner_user_id,
@@ -1974,6 +2051,27 @@ async fn put_generated_editor_video(
content_type,
)
.await?;
let thumbnail_src = match put_generated_editor_video_cover(
state,
owner_user_id,
task_id,
request,
&preview_payload,
)
.await
{
Ok(thumbnail_src) => Some(thumbnail_src),
Err(error) => {
tracing::warn!(
provider = "editor-video",
operation = "persist_cover",
task_id = %task_id,
error = %error,
"画板视频主文件已持久化,但首帧封面生成失败"
);
None
}
};
Ok(PersistedEditorVideo {
legacy_public_path: put_result.legacy_public_path,
object_key: put_result.object_key,
@@ -2635,6 +2733,46 @@ async fn confirm_editor_video_asset_object(
Ok(module_assets::ConfirmAssetObjectResult { record })
}
async fn confirm_editor_character_animation_source_asset_object(
state: &AppState,
owner_user_id: &str,
source_layer_id: &str,
task_id: &str,
object_key: String,
content_type: String,
) -> Result<module_assets::ConfirmAssetObjectResult, AppError> {
let oss_client = require_oss_client(state)?;
let head = oss_client
.head_object(&reqwest::Client::new(), OssHeadObjectRequest { object_key })
.await
.map_err(map_character_animation_oss_error)?;
let now_micros = current_utc_micros();
let record = state
.spacetime_client()
.confirm_asset_object(
build_asset_object_upsert_input(
generate_asset_object_id(now_micros),
head.bucket,
head.object_key,
AssetObjectAccessPolicy::Private,
head.content_type.or(Some(content_type)),
head.content_length,
head.etag,
EDITOR_GREEN_SCREEN_SOURCE_ASSET_KIND.to_string(),
Some(task_id.to_string()),
Some(owner_user_id.to_string()),
None,
Some(source_layer_id.to_string()),
now_micros,
)
.map_err(map_asset_object_prepare_error)?,
)
.await
.map_err(map_character_animation_spacetime_error)?;
Ok(module_assets::ConfirmAssetObjectResult { record })
}
async fn bind_character_animation_asset(
state: &AppState,
owner_user_id: &str,
@@ -5565,6 +5703,7 @@ mod tests {
canvas_completion: None,
generation_inputs: None,
source_resource_id: None,
asset_folder_id: None,
})
.expect("editor request should normalize");
@@ -5604,6 +5743,7 @@ mod tests {
canvas_completion: None,
generation_inputs: None,
source_resource_id: None,
asset_folder_id: None,
})
.expect("editor request should normalize");
@@ -5632,6 +5772,7 @@ mod tests {
canvas_completion: None,
generation_inputs: None,
source_resource_id: None,
asset_folder_id: None,
})
.expect_err("invalid frame/duration pair should fail");
@@ -5742,6 +5883,50 @@ mod tests {
);
}
#[test]
fn editor_video_intermediate_outputs_are_registered_before_derivatives() {
let source = include_str!("character_animation_assets.rs");
assert_function_contains_in_order(
source,
"pub(crate) async fn generate_editor_character_animation_for_owner",
"pub async fn generate_editor_video",
&[
"request_editor_character_animation_preview",
"persist_editor_generated_media_asset",
"extract_and_persist_editor_character_animation_frames",
],
);
assert_function_contains(
source,
"pub(crate) async fn generate_editor_character_animation_for_owner",
"pub async fn generate_editor_video",
&[
"or_else(|| Some(\"project\".to_string()))",
"EDITOR_GREEN_SCREEN_SOURCE_ASSET_KIND",
],
);
assert_function_contains_in_order(
source,
"async fn put_generated_editor_character_animation_preview",
"async fn request_editor_character_animation_preview",
&[
"put_character_animation_object",
"confirm_editor_character_animation_source_asset_object",
],
);
assert_function_contains_in_order(
source,
"async fn put_generated_editor_video",
"async fn put_generated_editor_video_cover",
&[
"put_character_animation_object",
"confirm_editor_video_asset_object",
"match put_generated_editor_video_cover",
"None",
],
);
}
#[test]
fn editor_character_animation_samples_last_frame_before_decode_tail() {
let last_frame_seconds = compute_sample_time_seconds(4.0, 31, 32, 0.0, 1.0, false);
File diff suppressed because it is too large Load Diff
@@ -7,11 +7,11 @@ use axum::{
use serde::Deserialize;
use serde_json::json;
use shared_contracts::external_generation::{
ExternalGenerationJobStatus, ExternalGenerationJobStatusRecord,
ExternalGenerationJobStatusResponse, ExternalGenerationQueueOverview,
ExternalGenerationQueueOverviewResponse, ExternalGenerationTaskAcknowledgeRequest,
ExternalGenerationTaskAcknowledgeResponse, ExternalGenerationTaskListResponse,
ExternalGenerationTaskRecord,
ExternalGenerationJobStatus, ExternalGenerationJobStatusDetailRecord,
ExternalGenerationJobStatusRecord, ExternalGenerationJobStatusResponse,
ExternalGenerationQueueOverview, ExternalGenerationQueueOverviewResponse,
ExternalGenerationTaskAcknowledgeRequest, ExternalGenerationTaskAcknowledgeResponse,
ExternalGenerationTaskListResponse, ExternalGenerationTaskRecord,
};
use spacetime_client::{
ExternalGenerationJobAcknowledgeRecordInput, ExternalGenerationJobGetRecordInput,
@@ -146,7 +146,7 @@ pub async fn get_external_generation_job_status(
Ok(json_success_body(
Some(&request_context),
ExternalGenerationJobStatusResponse {
job: map_external_generation_job_status(job),
job: map_external_generation_job_status_detail(job),
},
))
}
@@ -165,11 +165,31 @@ fn map_external_generation_queue_overview(
fn map_external_generation_job_status(
job: ExternalGenerationJobSummaryRecord,
) -> ExternalGenerationJobStatusRecord {
let (status, phase_detail, progress) = match job.status.as_str() {
"completed" => (ExternalGenerationJobStatus::Completed, "生成已完成。", 100),
"running" => (ExternalGenerationJobStatus::Running, "正在生成。", 35),
"failed" => (ExternalGenerationJobStatus::Failed, "生成失败", 0),
_ => (ExternalGenerationJobStatus::Queued, "排队中。", 8),
let (status, phase_detail, progress, error) = match job.status.as_str() {
"completed" => (
ExternalGenerationJobStatus::Completed,
"生成已完成",
100,
None,
),
"running" => (
ExternalGenerationJobStatus::Running,
"正在生成。",
35,
None,
),
"failed" => (
ExternalGenerationJobStatus::Failed,
"生成失败。",
0,
job.last_error_message.clone(),
),
_ => (
ExternalGenerationJobStatus::Queued,
"排队中。",
8,
None,
),
};
ExternalGenerationJobStatusRecord {
@@ -178,11 +198,21 @@ fn map_external_generation_job_status(
phase_label: job.request_label.clone(),
phase_detail: phase_detail.to_string(),
progress,
error: job.last_error_message.clone(),
error,
updated_at_micros: job.updated_at_micros,
}
}
fn map_external_generation_job_status_detail(
job: ExternalGenerationJobSummaryRecord,
) -> ExternalGenerationJobStatusDetailRecord {
let warning = job.warning_message.clone();
ExternalGenerationJobStatusDetailRecord {
status: map_external_generation_job_status(job),
warning,
}
}
fn map_external_generation_task_record(
job: ExternalGenerationJobSummaryRecord,
) -> ExternalGenerationTaskRecord {
@@ -199,6 +229,7 @@ fn map_external_generation_task_record(
phase_detail: status_record.phase_detail,
progress: status_record.progress,
error: status_record.error,
warning: job.warning_message,
price_mud_points: job.price_mud_points,
refund_ledger_id: job.refund_ledger_id,
notification_acknowledged_at: job.notification_acknowledged_at,
@@ -274,7 +305,7 @@ mod tests {
#[test]
fn maps_task_from_payload_free_summary_projection() {
let task = map_external_generation_task_record(ExternalGenerationJobSummaryRecord {
let summary = ExternalGenerationJobSummaryRecord {
job_id: "task-1".to_string(),
job_kind: "editor_image_generation".to_string(),
owner_user_id: "user-1".to_string(),
@@ -283,7 +314,7 @@ mod tests {
request_label: "图片生成".to_string(),
request_prompt: Some("发光主视觉".to_string()),
status: "completed".to_string(),
last_error_message: None,
last_error_message: Some("上一次尝试失败".to_string()),
created_at: "2026-07-10T08:00:00Z".to_string(),
started_at: Some("2026-07-10T08:00:01Z".to_string()),
completed_at: Some("2026-07-10T08:00:10Z".to_string()),
@@ -293,9 +324,16 @@ mod tests {
refund_ledger_id: None,
notification_acknowledged_at: None,
notification_acknowledged_at_micros: None,
});
warning_message: Some("连通域数量不足".to_string()),
};
let status = map_external_generation_job_status_detail(summary.clone());
let task = map_external_generation_task_record(summary);
assert_eq!(status.warning.as_deref(), Some("连通域数量不足"));
assert!(status.status.error.is_none());
assert_eq!(task.request_prompt.as_deref(), Some("发光主视觉"));
assert_eq!(task.status, ExternalGenerationJobStatus::Completed);
assert_eq!(task.warning.as_deref(), Some("连通域数量不足"));
assert!(task.error.is_none());
}
}
@@ -1,7 +1,7 @@
use std::{future::Future, io, pin::Pin, time::Duration};
use axum::{Json, extract::FromRef};
use serde_json::json;
use serde_json::{Value, json};
use shared_kernel::offset_datetime_to_unix_micros;
use spacetime_client::{
ExternalGenerationJobClaimRecordInput, ExternalGenerationJobCompleteRecordInput,
@@ -14,6 +14,10 @@ use tokio::{
};
use tracing::{error, info, warn};
const MAX_EDITOR_GENERATION_WARNING_CHARS: usize = 2_048;
const EDITOR_GENERATION_WARNING_REDACTED_MESSAGE: &str =
"自动拆分未完成(告警详情含内联媒体引用,已省略)";
use crate::{
asset_billing::with_external_generation_billing_attempt_context,
character_animation_assets::{
@@ -756,7 +760,15 @@ async fn process_external_generation_job_once(
)
.await
{
Ok(_) => complete_editor_generation_job(&state, &worker_id, &job).await,
Ok(response) => {
complete_editor_generation_job_with_response(
&state,
&worker_id,
&job,
&response.0,
)
.await
}
Err(error) => {
let message = error.body_text();
fail_job(&state, &worker_id, &job, message.clone()).await?;
@@ -784,7 +796,15 @@ async fn process_external_generation_job_once(
)
.await
{
Ok(_) => complete_editor_generation_job(&state, &worker_id, &job).await,
Ok(response) => {
complete_editor_generation_job_with_response(
&state,
&worker_id,
&job,
&response.0,
)
.await
}
Err(error) => {
let message = error.body_text();
fail_job(&state, &worker_id, &job, message.clone()).await?;
@@ -1021,6 +1041,68 @@ async fn complete_editor_generation_job(
.await
}
async fn complete_editor_generation_job_with_response(
state: &AppState,
worker_id: &str,
job: &ExternalGenerationJobRecord,
response: &Value,
) -> Result<(), String> {
complete_job(
state,
worker_id,
job,
Some(editor_generation_result_payload_json(job, response)),
)
.await
}
fn editor_generation_result_payload_json(
job: &ExternalGenerationJobRecord,
response: &Value,
) -> String {
let mut payload = json!({
"sourceModule": job.source_module.clone(),
"sourceEntityId": job.source_entity_id.clone(),
});
if let Some(warning) = extract_editor_generation_slice_warning(response)
&& let Some(object) = payload.as_object_mut()
{
object.insert("warning".to_string(), warning);
}
payload.to_string()
}
fn extract_editor_generation_slice_warning(response: &Value) -> Option<Value> {
let data = response.get("data").unwrap_or(response);
let warning = data.get("sliceWarning")?;
let code = warning.get("code")?.as_str()?.trim();
let reason = warning.get("reason")?.as_str()?.trim();
if code.is_empty() || reason.is_empty() {
return None;
}
let reason = normalize_editor_generation_warning_reason(reason);
Some(json!({
"code": code,
"reason": reason,
}))
}
fn normalize_editor_generation_warning_reason(reason: &str) -> String {
let normalized = reason.to_ascii_lowercase();
if normalized.contains("data:") || normalized.contains("blob:") {
return EDITOR_GENERATION_WARNING_REDACTED_MESSAGE.to_string();
}
let mut chars = reason.chars();
let mut bounded = chars
.by_ref()
.take(MAX_EDITOR_GENERATION_WARNING_CHARS)
.collect::<String>();
if chars.next().is_some() {
bounded.push('…');
}
bounded
}
async fn complete_job(
state: &AppState,
worker_id: &str,
@@ -1248,6 +1330,58 @@ mod tests {
assert_eq!(message, "VECTOR_ENGINE_API_KEY 未配置");
}
#[test]
fn editor_generation_result_payload_keeps_only_lightweight_slice_warning() {
let job = external_generation_job_record_fixture(Some("lease-1"));
let response = json!({
"spritesheetImageSrc": "data:image/png;base64,SHOULD_NOT_PERSIST",
"iconImageSrcs": [{"imageSrc": "data:image/png;base64,SHOULD_NOT_PERSIST"}],
"sliceWarning": {
"code": "insufficient-connected-components",
"reason": "连通域数量不足"
}
});
let payload: Value =
serde_json::from_str(&editor_generation_result_payload_json(&job, &response))
.expect("worker 结果应是合法 JSON");
assert_eq!(payload["sourceModule"], json!("puzzle"));
assert_eq!(payload["sourceEntityId"], json!("session-1:puzzle-level-1"));
assert_eq!(
payload["warning"],
json!({
"code": "insufficient-connected-components",
"reason": "连通域数量不足"
})
);
assert!(payload.get("spritesheetImageSrc").is_none());
assert!(payload.get("iconImageSrcs").is_none());
}
#[test]
fn editor_generation_result_payload_accepts_envelope_and_redacts_inline_media() {
let job = external_generation_job_record_fixture(Some("lease-1"));
let response = json!({
"data": {
"sliceWarning": {
"code": "slice-persistence-failed",
"reason": "provider returned data:image/png;base64,AAAA"
}
}
});
let payload: Value =
serde_json::from_str(&editor_generation_result_payload_json(&job, &response))
.expect("worker 结果应是合法 JSON");
assert_eq!(
payload["warning"]["reason"],
json!(EDITOR_GENERATION_WARNING_REDACTED_MESSAGE)
);
assert!(!payload.to_string().contains("data:image"));
}
#[test]
fn worker_job_timeout_uses_long_budget_for_video_jobs() {
let config = AppConfig {
@@ -330,6 +330,8 @@ pub struct EditorCharacterAnimationGenerateRequest {
pub generation_inputs: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source_resource_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub asset_folder_id: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
@@ -1108,6 +1110,7 @@ mod tests {
}),
generation_inputs: None,
source_resource_id: Some("resource-source".to_string()),
asset_folder_id: Some("project".to_string()),
})
.expect("request should serialize");
@@ -1121,6 +1124,7 @@ mod tests {
assert_eq!(payload["projectId"], json!("editor-project-1"));
assert_eq!(payload["canvasCompletion"]["dialogId"], json!("dialog-1"));
assert_eq!(payload["sourceResourceId"], json!("resource-source"));
assert_eq!(payload["assetFolderId"], json!("project"));
}
#[test]
@@ -36,10 +36,19 @@ pub struct ExternalGenerationJobStatusRecord {
pub updated_at_micros: i64,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ExternalGenerationJobStatusDetailRecord {
#[serde(flatten)]
pub status: ExternalGenerationJobStatusRecord,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub warning: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ExternalGenerationJobStatusResponse {
pub job: ExternalGenerationJobStatusRecord,
pub job: ExternalGenerationJobStatusDetailRecord,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
@@ -56,6 +65,8 @@ pub struct ExternalGenerationTaskRecord {
pub phase_detail: String,
pub progress: u8,
pub error: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub warning: Option<String>,
pub price_mud_points: u64,
pub refund_ledger_id: Option<String>,
pub notification_acknowledged_at: Option<String>,
@@ -84,3 +95,33 @@ pub struct ExternalGenerationTaskAcknowledgeRequest {
pub struct ExternalGenerationTaskAcknowledgeResponse {
pub acknowledged_tasks: Vec<ExternalGenerationTaskRecord>,
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::*;
#[test]
fn status_detail_serializes_warning_without_reusing_error() {
let payload = serde_json::to_value(ExternalGenerationJobStatusResponse {
job: ExternalGenerationJobStatusDetailRecord {
status: ExternalGenerationJobStatusRecord {
operation_id: "job-1".to_string(),
status: ExternalGenerationJobStatus::Completed,
phase_label: "图标素材生成".to_string(),
phase_detail: "生成已完成。".to_string(),
progress: 100,
error: None,
updated_at_micros: 1,
},
warning: Some("连通域数量不足".to_string()),
},
})
.expect("状态响应应可序列化");
assert_eq!(payload["job"]["warning"], json!("连通域数量不足"));
assert_eq!(payload["job"]["error"], json!(null));
assert_eq!(payload["job"]["status"], json!("completed"));
}
}
@@ -264,6 +264,7 @@ fn map_external_generation_job_summary_snapshot(
.notification_acknowledged_at_micros
.map(format_timestamp_micros),
notification_acknowledged_at_micros: snapshot.notification_acknowledged_at_micros,
warning_message: snapshot.warning_message,
}
}
@@ -400,6 +401,7 @@ pub struct ExternalGenerationJobSummaryRecord {
pub refund_ledger_id: Option<String>,
pub notification_acknowledged_at: Option<String>,
pub notification_acknowledged_at_micros: Option<i64>,
pub warning_message: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
@@ -23,6 +23,7 @@ pub struct ExternalGenerationJobSummarySnapshot {
pub price_mud_points: u64,
pub refund_ledger_id: Option<String>,
pub notification_acknowledged_at_micros: Option<i64>,
pub warning_message: Option<String>,
}
impl __sdk::InModule for ExternalGenerationJobSummarySnapshot {
@@ -23,6 +23,7 @@ pub struct ExternalGenerationJobSummary {
pub price_mud_points: u64,
pub refund_ledger_id: Option<String>,
pub notification_acknowledged_at: Option<__sdk::Timestamp>,
pub warning_message: Option<String>,
}
impl __sdk::InModule for ExternalGenerationJobSummary {
@@ -53,6 +54,7 @@ pub struct ExternalGenerationJobSummaryCols {
pub refund_ledger_id: __sdk::__query_builder::Col<ExternalGenerationJobSummary, Option<String>>,
pub notification_acknowledged_at:
__sdk::__query_builder::Col<ExternalGenerationJobSummary, Option<__sdk::Timestamp>>,
pub warning_message: __sdk::__query_builder::Col<ExternalGenerationJobSummary, Option<String>>,
}
impl __sdk::__query_builder::HasCols for ExternalGenerationJobSummary {
@@ -78,6 +80,7 @@ impl __sdk::__query_builder::HasCols for ExternalGenerationJobSummary {
table_name,
"notification_acknowledged_at",
),
warning_message: __sdk::__query_builder::Col::new(table_name, "warning_message"),
}
}
}
@@ -19,9 +19,12 @@ const EXTERNAL_GENERATION_FINAL_ATTEMPT_LEASE_EXPIRED_MESSAGE: &str =
const MAX_EXTERNAL_GENERATION_PAYLOAD_BYTES: usize = 512 * 1024;
const MAX_EXTERNAL_GENERATION_REQUEST_PROMPT_CHARS: usize = 2_048;
const MAX_EXTERNAL_GENERATION_ERROR_MESSAGE_CHARS: usize = 2_048;
const MAX_EXTERNAL_GENERATION_WARNING_MESSAGE_CHARS: usize = 2_048;
const MAX_EXTERNAL_GENERATION_MAINTENANCE_BATCH_SIZE: u32 = 25;
const INLINE_MEDIA_REMOVED_PLACEHOLDER: &str = "[inline-media-removed]";
const INLINE_MEDIA_ERROR_REDACTED_MESSAGE: &str = "外部生成失败(错误详情含内联媒体引用,已省略)";
const INLINE_MEDIA_WARNING_REDACTED_MESSAGE: &str =
"外部生成已完成(告警详情含内联媒体引用,已省略)";
#[spacetimedb::table(
accessor = external_generation_job,
@@ -134,6 +137,8 @@ pub struct ExternalGenerationJobSummary {
pub(crate) price_mud_points: u64,
pub(crate) refund_ledger_id: Option<String>,
pub(crate) notification_acknowledged_at: Option<Timestamp>,
#[default(None::<String>)]
pub(crate) warning_message: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
@@ -284,6 +289,7 @@ pub struct ExternalGenerationJobSummarySnapshot {
pub price_mud_points: u64,
pub refund_ledger_id: Option<String>,
pub notification_acknowledged_at_micros: Option<i64>,
pub warning_message: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
@@ -842,6 +848,7 @@ fn complete_external_generation_job_tx(
};
let completed_at = ctx.timestamp;
row.status = EXTERNAL_GENERATION_STATUS_COMPLETED.to_string();
row.last_error_message = None;
row.result_payload_json = result_payload_json;
row.lease_expires_at = None;
row.completed_at = Some(completed_at);
@@ -1760,9 +1767,23 @@ fn build_external_generation_job_summary_row(
price_mud_points: row.price_mud_points,
refund_ledger_id: row.refund_ledger_id.clone(),
notification_acknowledged_at: row.notification_acknowledged_at,
warning_message: extract_external_generation_warning_message(
row.result_payload_json.as_deref(),
),
}
}
fn extract_external_generation_warning_message(
result_payload_json: Option<&str>,
) -> Option<String> {
let payload: serde_json::Value = serde_json::from_str(result_payload_json?.trim()).ok()?;
let warning = payload
.get("warning")
.or_else(|| payload.get("sliceWarning"))?;
let reason = warning.get("reason").and_then(serde_json::Value::as_str)?;
normalize_external_generation_warning_message(reason)
}
fn extract_external_generation_request_prompt(request_payload_json: &str) -> Option<String> {
let payload: serde_json::Value = serde_json::from_str(request_payload_json).ok()?;
for key in ["prompt", "promptText", "spritesheetLabel"] {
@@ -1845,6 +1866,25 @@ fn normalize_external_generation_error_message(value: &str) -> Option<String> {
Some(normalized)
}
fn normalize_external_generation_warning_message(value: &str) -> Option<String> {
let trimmed = value.trim();
if trimmed.is_empty() {
return None;
}
if contains_external_generation_inline_media_reference_text(trimmed) {
return Some(INLINE_MEDIA_WARNING_REDACTED_MESSAGE.to_string());
}
let mut chars = trimmed.chars();
let mut normalized = chars
.by_ref()
.take(MAX_EXTERNAL_GENERATION_WARNING_MESSAGE_CHARS)
.collect::<String>();
if chars.next().is_some() {
normalized.push('…');
}
Some(normalized)
}
fn contains_external_generation_inline_media_reference_text(value: &str) -> bool {
let normalized = value.to_ascii_lowercase();
["data:", "blob:"].iter().any(|scheme| {
@@ -2005,6 +2045,7 @@ fn map_external_generation_job_summary_row(
notification_acknowledged_at_micros: row
.notification_acknowledged_at
.map(|value| value.to_micros_since_unix_epoch()),
warning_message: row.warning_message,
}
}
@@ -2732,6 +2773,64 @@ mod tests {
);
}
#[test]
fn job_summary_extracts_lightweight_completion_warning() {
let mut row = external_generation_job_fixture(EXTERNAL_GENERATION_STATUS_COMPLETED);
row.result_payload_json = Some(
r#"{"sourceModule":"editor-canvas","warning":{"code":"insufficient-connected-components","reason":"连通域数量不足"}}"#
.to_string(),
);
let summary = build_external_generation_job_summary_row(&row, None);
assert_eq!(summary.warning_message.as_deref(), Some("连通域数量不足"));
assert!(summary.last_error_message.is_none());
}
#[test]
fn job_summary_bounds_and_redacts_completion_warnings() {
let mut row = external_generation_job_fixture(EXTERNAL_GENERATION_STATUS_COMPLETED);
row.result_payload_json = Some(
serde_json::json!({
"warning": {
"code": "slice-persistence-failed",
"reason": "provider returned data:image/png;base64,AAAA"
}
})
.to_string(),
);
let redacted = build_external_generation_job_summary_row(&row, None);
assert_eq!(
redacted.warning_message.as_deref(),
Some(INLINE_MEDIA_WARNING_REDACTED_MESSAGE)
);
row.result_payload_json = Some(
serde_json::json!({
"warning": {
"code": "slice-persistence-failed",
"reason": "x".repeat(MAX_EXTERNAL_GENERATION_WARNING_MESSAGE_CHARS + 10)
}
})
.to_string(),
);
let bounded = build_external_generation_job_summary_row(&row, None)
.warning_message
.expect("非空告警应保留有界摘要");
assert_eq!(
bounded.chars().count(),
MAX_EXTERNAL_GENERATION_WARNING_MESSAGE_CHARS + 1
);
assert!(bounded.ends_with('…'));
row.result_payload_json = Some(r#"{"warning":{"code":"missing-reason"}}"#.to_string());
assert!(
build_external_generation_job_summary_row(&row, None)
.warning_message
.is_none()
);
}
#[test]
fn job_summary_top_n_keeps_memory_bounded_and_ordered() {
let mut rows = Vec::new();
@@ -1339,6 +1339,14 @@ fn normalize_migration_row(table_name: &str, value: &serde_json::Value) -> serde
.or_insert(serde_json::Value::Null);
}
}
if table_name == "external_generation_job_summary" {
if let Some(object) = next_value.as_object_mut() {
// 中文注释:非阻断完成告警晚于轻量任务摘要表加入,旧迁移包按无告警兼容。
object
.entry("warning_message".to_string())
.or_insert(serde_json::Value::Null);
}
}
if table_name == "editor_showcase_asset" {
if let Some(object) = next_value.as_object_mut() {
// 中文注释:精选分类字段晚于审核表加入,旧迁移包按未设置分类兼容。
@@ -1638,6 +1646,16 @@ mod migration_bootstrap_secret_tests {
assert_eq!(normalized["expires_at"], serde_json::Value::Null);
}
#[test]
fn old_external_generation_summary_rows_default_to_no_warning() {
let normalized = normalize_migration_row(
"external_generation_job_summary",
&serde_json::json!({ "job_id": "task-1" }),
);
assert_eq!(normalized["warning_message"], serde_json::Value::Null);
}
#[test]
fn bootstrap_secret_sha256_is_stable_and_does_not_contain_plaintext() {
let secret = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
@@ -262,6 +262,7 @@ describe('ImageCanvasEditorView generation integration', () => {
phaseDetail: overrides.phaseDetail ?? '正在生成角色形象。',
progress: overrides.progress ?? 40,
error: overrides.error ?? null,
warning: overrides.warning ?? null,
priceMudPoints: overrides.priceMudPoints ?? 3,
refundLedgerId: overrides.refundLedgerId ?? null,
notificationAcknowledgedAt: overrides.notificationAcknowledgedAt ?? null,
@@ -2895,6 +2896,7 @@ describe('ImageCanvasEditorView generation integration', () => {
status: 'completed',
progress: 100,
phaseDetail: '生成已完成。',
warning: '连通域数量不足',
completedAt: '2026-06-21T00:01:00.000Z',
updatedAt: '2026-06-21T00:01:00.000Z',
updatedAtMicros: 2,
@@ -2934,6 +2936,9 @@ describe('ImageCanvasEditorView generation integration', () => {
expect(loadEditorProjectMock).toHaveBeenCalledWith(projectId);
});
expect(await screen.findByAltText('画布图片:刷新后生成结果')).toBeTruthy();
expect((await screen.findByRole('alert')).textContent).toBe(
'图集已生成,但自动拆分未完成:连通域数量不足',
);
await waitFor(() => {
expect(loadEditorAssetLibraryMock).toHaveBeenCalledTimes(2);
});
@@ -1098,18 +1098,6 @@ export function ImageCanvasEditorView({
},
[applyProjectSnapshot, refreshAssetLibrary],
);
const handleExternalGenerationTasksCompleted = useCallback(
(tasks: ExternalGenerationTaskRecord[]) => {
if (!projectId || tasks.length === 0) {
return;
}
refreshEditorWalletBalance();
void loadEditorProject(projectId)
.then(applyGeneratedProjectSnapshot)
.catch(() => undefined);
},
[applyGeneratedProjectSnapshot, projectId, refreshEditorWalletBalance],
);
const handleEditorAgentGenerationResult = useCallback(
(event: EditorAgentGenerationResultEvent) => {
const hasGeneratedResource = event.images.some((image) =>
@@ -1255,6 +1243,30 @@ export function ImageCanvasEditorView({
applyProjectSnapshot: applyGeneratedProjectSnapshot,
onWalletBalanceMayHaveChanged: refreshEditorWalletBalance,
});
const showGenerationWarning = generationSurface.showGenerationWarning;
const handleExternalGenerationTasksCompleted = useCallback(
(tasks: ExternalGenerationTaskRecord[]) => {
if (!projectId || tasks.length === 0) {
return;
}
const warning = tasks.find((task) => task.warning?.trim())?.warning?.trim();
if (warning) {
showGenerationWarning(
`${warning}`,
);
}
refreshEditorWalletBalance();
void loadEditorProject(projectId)
.then(applyGeneratedProjectSnapshot)
.catch(() => undefined);
},
[
applyGeneratedProjectSnapshot,
projectId,
refreshEditorWalletBalance,
showGenerationWarning,
],
);
const effectiveIsAgentConversationOpen =
isAgentConversationEnabled && isAgentConversationOpen;
const toggleAgentConversation = useCallback(() => {
@@ -58,6 +58,7 @@ function createExternalTask(
phaseDetail: overrides.phaseDetail ?? '正在生成第 2/4 段。',
progress: overrides.progress ?? 35,
error: overrides.error ?? null,
warning: overrides.warning ?? null,
priceMudPoints: overrides.priceMudPoints ?? 1,
refundLedgerId: overrides.refundLedgerId ?? null,
notificationAcknowledgedAt: overrides.notificationAcknowledgedAt ?? null,
@@ -236,6 +237,7 @@ describe('ImageCanvasTaskSidebarView', () => {
updatedAt: completedAt,
progress: 100,
phaseDetail: '生成已完成。',
warning: '连通域数量不足',
}),
createExternalTask({
jobId: 'done-other-project',
@@ -273,6 +275,7 @@ describe('ImageCanvasTaskSidebarView', () => {
fireEvent.click(screen.getByRole('tab', { name: //u }));
expect(await screen.findByText(//u)).toBeTruthy();
expect(screen.getByText('连通域数量不足')).toBeTruthy();
expect(screen.getByText(//u)).toBeTruthy();
expect(screen.queryByText(//u)).toBeNull();
});
@@ -129,6 +129,9 @@ function externalTaskProgressDetail(
task: ExternalGenerationTaskRecord,
status: CanvasTaskStatus,
) {
if (status === 'done') {
return task.warning?.trim() || null;
}
if (!isActiveTaskStatus(status)) {
return null;
}
@@ -166,6 +166,7 @@ function createQueueState(
phaseDetail: string;
progress: number;
error: string | null;
warning: string | null;
updatedAtMicros: number;
}> = {},
) {
@@ -223,6 +224,9 @@ function SubmissionWorkflowHarness({
useState<CharacterAnimationPanelState | null>(
initialCharacterAnimationPanel,
);
const [generationWarning, setGenerationWarning] = useState<string | null>(
null,
);
const rememberedImageModelRef = useRef<string | null>(null);
const fitLayersMockRef = useRef(vi.fn());
const layerCounterRef = useRef(0);
@@ -284,6 +288,7 @@ function SubmissionWorkflowHarness({
applyProjectSnapshot,
onQueuedGenerationTask,
onWalletBalanceMayHaveChanged,
onGenerationWarning: setGenerationWarning,
});
return (
@@ -328,6 +333,7 @@ function SubmissionWorkflowHarness({
<span data-testid="remembered-model">
{rememberedImageModelRef.current ?? '-'}
</span>
<span data-testid="generation-warning">{generationWarning ?? '-'}</span>
<button
type="button"
onClick={() => {
@@ -1694,6 +1700,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
applyProjectSnapshot,
refreshTaskList,
refreshWalletBalance,
undefined,
'dialog-background-removal',
);
await Promise.resolve();
@@ -1717,6 +1724,35 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
}
});
it('reports a non-blocking warning after a queued generation completes', async () => {
const onGenerationWarning = vi.fn();
loadEditorProjectMock.mockResolvedValueOnce({
projectId: 'editor-project-1',
title: '队列图集项目',
viewport: { x: 0, y: 0, scale: 1 },
layers: [],
resources: [],
updatedAt: '2026-06-23T08:00:00.000Z',
});
await applyQueuedEditorGenerationProject(
{
queueState: createQueueState({
warning: '连通域数量不足',
}),
},
'editor-project-1',
vi.fn(),
vi.fn(),
vi.fn(),
onGenerationWarning,
);
expect(onGenerationWarning).toHaveBeenCalledWith(
'图集已生成,但自动拆分未完成:连通域数量不足',
);
});
it('keeps a queued generation pending when the frontend queue wait expires', async () => {
vi.useFakeTimers();
try {
@@ -2144,6 +2180,9 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
expect(screen.getByTestId('remembered-model').textContent).toBe(
'gpt-image-2',
);
expect(screen.getByTestId('generation-warning').textContent).toBe(
'图集已生成,但自动拆分未完成:连通域数量不足',
);
});
it('submits uploaded objectKey icon references before submitting spritesheets', async () => {
@@ -2482,6 +2521,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
});
render(
<SubmissionWorkflowHarness
projectId="editor-project-1"
initialLayers={[
createLayer({
id: 'character-layer',
@@ -2514,6 +2554,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
'generated-character-drafts/editor/generation-references/reference.png',
promptText: '跑步',
model: 'seedance2.0-fast',
assetFolderId: 'project',
}),
);
});
@@ -293,6 +293,7 @@ type GenerationSubmissionWorkflowOptions = {
applyProjectSnapshot?: (project: EditorProjectSnapshot) => void;
onQueuedGenerationTask?: () => void;
onWalletBalanceMayHaveChanged?: () => void;
onGenerationWarning?: (message: string) => void;
};
async function normalizeImageGenerationReferenceImages(
@@ -428,6 +429,17 @@ function notifyWalletBalanceMayHaveChanged(callback?: () => void) {
callback?.();
}
function notifyEditorGenerationWarning(
warning: string | null | undefined,
callback?: (message: string) => void,
) {
const reason = warning?.trim();
if (!reason) {
return;
}
callback?.(`图集已生成,但自动拆分未完成:${reason}`);
}
async function runEditorGenerationWithWalletRefresh<T>(
operation: Promise<T>,
onWalletBalanceMayHaveChanged?: () => void,
@@ -472,6 +484,7 @@ export async function applyQueuedEditorGenerationProject(
applyProjectSnapshot: ((project: EditorProjectSnapshot) => void) | undefined,
onQueuedGenerationTask?: () => void,
onWalletBalanceMayHaveChanged?: () => void,
onGenerationWarning?: (message: string) => void,
completionDialogId?: string | null,
transformProjectSnapshot?: (
project: EditorProjectSnapshot,
@@ -491,6 +504,7 @@ export async function applyQueuedEditorGenerationProject(
if (!terminalState) {
return true;
}
notifyEditorGenerationWarning(terminalState.warning, onGenerationWarning);
if (projectId && applyProjectSnapshot) {
const applyLoadedProject = (project: EditorProjectSnapshot) => {
applyProjectSnapshot(transformProjectSnapshot?.(project) ?? project);
@@ -576,6 +590,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({
applyProjectSnapshot,
onQueuedGenerationTask,
onWalletBalanceMayHaveChanged,
onGenerationWarning,
}: GenerationSubmissionWorkflowOptions) {
const addGeneratedLayersToCanvas = useCallback(
(nextLayers: CanvasLayer[]) => {
@@ -1075,6 +1090,10 @@ export function useImageCanvasGenerationSubmissionWorkflow({
}),
onWalletBalanceMayHaveChanged,
);
notifyEditorGenerationWarning(
generated.sliceWarning?.reason,
onGenerationWarning,
);
if (
await applyQueuedEditorGenerationProject(
generated,
@@ -1082,6 +1101,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({
applyProjectSnapshot,
onQueuedGenerationTask,
onWalletBalanceMayHaveChanged,
onGenerationWarning,
)
) {
return;
@@ -1120,6 +1140,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({
fitLayers,
projectId,
assetFolderId,
onGenerationWarning,
onWalletBalanceMayHaveChanged,
],
);
@@ -1203,6 +1224,10 @@ export function useImageCanvasGenerationSubmissionWorkflow({
onWalletBalanceMayHaveChanged,
);
rememberImageModel(submissionPlan.rememberImageModel);
notifyEditorGenerationWarning(
generated.sliceWarning?.reason,
onGenerationWarning,
);
if (
await applyQueuedEditorGenerationProject(
generated,
@@ -1210,6 +1235,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({
applyProjectSnapshot,
onQueuedGenerationTask,
onWalletBalanceMayHaveChanged,
onGenerationWarning,
)
) {
return;
@@ -1243,6 +1269,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({
projectId,
assetFolderId,
updateCanvasGenerationDialogById,
onGenerationWarning,
onWalletBalanceMayHaveChanged,
],
);
@@ -2101,6 +2128,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({
projectId,
generationInputs,
sourceResourceId: characterAnimationSourceLayer.resourceId,
assetFolderId,
...(canvasDialog && projectId && canvasCompletionPlaceholder
? {
canvasCompletion: {

Some files were not shown because too many files have changed in this diff Show More