diff --git a/docs/openapi/genarrative-external-v1.openapi.json b/docs/openapi/genarrative-external-v1.openapi.json index a88961e71..0326fca0d 100644 --- a/docs/openapi/genarrative-external-v1.openapi.json +++ b/docs/openapi/genarrative-external-v1.openapi.json @@ -1810,6 +1810,12 @@ "imageSrc": { "type": "string" }, + "thumbnailSrc": { + "type": [ + "string", + "null" + ] + }, "objectKey": { "type": [ "string", diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 5fe718b42..4ece5d8c3 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -124,7 +124,7 @@ ## 2026-06-19 图片画布素材生成元数据上移到资源和素材 - 背景:角色、图标、UI 设计图、视频和音频等生成结果会在图片信息页展示用户可见输入快照;此前这些 `assetKind/generationInputs` 主要保存在画布 layer JSON 中,素材进入账号级素材库后跨项目复用和刷新恢复都依赖画布布局,不符合素材库作为账号级事实源的边界。 -- 决策:普通图层的新保存不再把 `assetKind/generationInputs` 写入 `editor_canvas.layers_json`。`editor_project_resource` 保存项目画布资源快照的 `asset_kind/generation_inputs_json`,`editor_asset` 保存账号级素材的同名元数据;图片 / 图标 / UI 提取等生成 BFF 在请求携带 `projectId` / `assetFolderId` 时由后端创建新 resource / asset 并把快照回传前端,前端只用回包更新画布图层和素材栏,不再把同一生成结果二次调用保存接口。前端加载时优先从 resource / asset 恢复素材类别和生成输入快照,旧 layout 中的同名字段只作为历史兼容兜底。生成器对象本身仍作为 `itemType="generation-dialog"` 保存在画布布局中。 +- 决策:普通图层的新保存不再把 `assetKind/generationInputs` 写入 `editor_canvas.layers_json`。`editor_project_resource` 保存项目画布资源快照的 `asset_kind/generation_inputs_json`,`editor_asset` 保存账号级素材的同名元数据和可选封面 `thumbnail_src`;图片 / 图标 / UI 提取等生成 BFF 在请求携带 `projectId` / `assetFolderId` 时由后端创建新 resource / asset 并把快照回传前端,前端只用回包更新画布图层和素材栏,不再把同一生成结果二次调用保存接口。生成视频由后端单独抽取首帧封面写入 `editor_asset.thumbnail_src` 和画布图层 `thumbnailSrc`,刷新素材库或从素材库拖回画布时继续作为视频 poster 使用。前端加载时优先从 resource / asset 恢复素材类别和生成输入快照,旧 layout 中的同名字段只作为历史兼容兜底。生成器对象本身仍作为 `itemType="generation-dialog"` 保存在画布布局中。 - 影响范围:`server-rs/crates/spacetime-module/src/editor_project_storage.rs`、`server-rs/crates/spacetime-client/src/mapper/editor_project.rs`、`server-rs/crates/api-server/src/editor_project.rs`、`src/services/image-editor/editorProjectClient.ts`、图片画布 hydrate / serialize / project persistence / asset library 代码和后端数据契约文档。 - 验证方式:运行 `npm run spacetime:generate`、`npm run check:spacetime-schema`、`npm run test -- src/components/image-editor/ImageCanvasEditorModel.test.ts src/components/image-editor/useImageCanvasProjectPersistence.test.tsx src/services/image-editor/editorProjectClient.test.ts`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`,并按需补充 `cargo check -p spacetime-client -p api-server --manifest-path server-rs/Cargo.toml`。 - 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。 @@ -2867,7 +2867,7 @@ ## 2026-06-17 图片画布生成器快照纳入画布布局 - 背景:生成占位图和生成器对话框里包含用户输入、参数、参考图、占位框位置和生成结果绑定,刷新后丢失会让已生成图片无法回到 Lovart 式跟随编辑状态。 -- 决策:生成器对象统一作为 `editor_canvas` 布局 JSON 的 `itemType: "generation-dialog"` 项保存,不新增表;成功生成后仍保留生成器快照和最后占位框位置,并通过 `generatedLayerId` 锚定到成品图层,渲染时不重复显示灰色占位框。图片类生成结果同步写入账号级素材库;视频结果当前只作为画布视频资源保存。 +- 决策:生成器对象统一作为 `editor_canvas` 布局 JSON 的 `itemType: "generation-dialog"` 项保存,不新增表;成功生成后仍保留生成器快照和最后占位框位置,并通过 `generatedLayerId` 锚定到成品图层,渲染时不重复显示灰色占位框。图片类和生成视频结果同步写入账号级素材库;生成视频素材当前没有独立 poster 字段,素材栏以视频图标叠层展示。 - 影响范围:图片画布 layout 序列化 / hydrate、生成工作流、生成器渲染、项目自动保存、素材库回填和编辑器技术方案。 - 验证方式:`npm run test -- src/components/image-editor/ImageCanvasEditorModel.test.ts src/components/image-editor/useCanvasGenerationDialogs.test.tsx src/components/image-editor/useImageCanvasProjectPersistence.test.tsx src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx src/components/image-editor/ImageCanvasWorldView.test.tsx src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`、浏览器刷新 smoke。 - 关联文档:`docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md`、`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index 97af23d9b..b66cb75aa 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -44,13 +44,13 @@ - 新增 `editor_project` 表保存图片画布工程:`projectId`、`ownerUserId`、标题、创建时间和更新时间;历史 layout 字段暂保留为兼容列,不再作为权威画布数据。 - 新增 `editor_canvas` 表保存工程下的画布:`canvasId`、`projectId`、`ownerUserId`、标题、viewport、图层布局 JSON、创建时间和更新时间。当前编辑器使用项目默认画布,后续可扩展为一个 project 下多个 canvas。 - 新增 `editor_asset_folder` 表保存账号级素材文件夹:`folderId`、`ownerUserId`、名称、排序、折叠状态、系统默认标记、创建时间和更新时间。素材文件夹不归属于 project,同一个账号进入任一项目都能看到。 -- 新增 `editor_asset` 表保存账号级素材:`assetId`、`ownerUserId`、`folderId`、名称、图片读取地址、OSS / asset object 引用、图片尺寸、来源类型、prompt、actualPrompt、model、provider、taskId、`assetKind`、`generationInputs`、创建时间和更新时间。素材只跟账号走,不跟 project 走;角色、图标、UI 设计图、视频和音频等生成结果的用户可见输入快照随素材保存。 +- 新增 `editor_asset` 表保存账号级素材:`assetId`、`ownerUserId`、`folderId`、名称、图片读取地址、可选封面 `thumbnailSrc`、OSS / asset object 引用、图片尺寸、来源类型、prompt、actualPrompt、model、provider、taskId、`assetKind`、`generationInputs`、创建时间和更新时间。素材只跟账号走,不跟 project 走;角色、图标、UI 设计图、视频和音频等生成结果的用户可见输入快照随素材保存。 - `editor_project_resource` 表保存工程画布引用过的资源快照:`resourceId`、`projectId`、`ownerUserId`、OSS / asset object 引用、图片尺寸、来源类型、prompt、actualPrompt、model、provider、taskId、sourceResourceId、`assetKind`、`generationInputs`、创建时间和更新时间。上传素材被拖入画布时会复制为 project resource,图层只引用 resourceId;图片、图标和 UI 素材生成 BFF 在请求携带 `projectId` 时由后端直接创建新 resource,并把 `resourceId` 随生成响应返回给前端。图片生成请求如果同时携带 `canvasCompletion`(生成器 `dialogId`、标题和占位框,或无 dialog 的右侧完成占位),BFF / worker 在生成成功后必须直接读取当前项目布局,优先使用最新 `generation-dialog` 占位框位置;只有当前布局仍存在对应 `generation-dialog` 时才插入轻量结果图层、把生成器标记为 `idle` 并写入 `generatedLayerId`,沿用后端当前 viewport 保存布局,再返回或刷新最新项目快照;前端只应用该快照刷新显示,不把生成完成态作为本地业务真相,也不在项目加载时根据资源行推断完成态。有项目上下文但后端没有返回项目快照时,前端不得本地补结果图层,只保留当前生成器交互状态等待下一次项目刷新。 - 项目封面图是画布当前视口栅格化后的静态快照资源,不在项目列表页临时重放 `layers + viewport`。前端在项目加载后和防抖保存 layout 时生成 320x240 PNG,走私有 OSS / asset object 上传,再创建 `editor_project_resource`,其中 `assetKind="project-cover-snapshot"`、`sourceType="uploaded"`。`/project` 与 `/creation` 最近项目卡只读取最新封面快照资源渲染;没有封面快照时显示普通项目占位,不回退为实时画布组合。 - 图片、音频、视频和角色动画帧文件本体继续走 OSS / asset object;浏览器读取私有 generated 对象统一经 `/api/assets/read-url` 换签,签名 URL 可在 session 内复用,但不得作为持久化真相。`/api/assets/read-url` 属于页面展示层高频后台请求,前端统一在 `assetReadUrlService` 内做同 key pending 去重、session 缓存和跨组件节流;UI 设计切片、角色动画帧或大量素材恢复时不得绕过该服务并发换签,否则单页可在同一秒内打满发布入口 `genarrative_api_rps` burst。 - 登录态上传和生成结果必须先落 OSS / asset object,再向 `editor_project_resource` / `editor_asset` 写入轻量 `imageSrc: "/"`、`objectKey` 和 `assetObjectId`;未登录演示态可以在内存里使用 Data URL 预览,但项目、素材库、项目资源和 `editor_canvas.layers_json` 不得写入 `data:image/*`、`data:video/*`、`data:audio/*` 或 `blob:`。旧数据读取时如果已有 `objectKey`,`imageSrc` 归一成 `/`;没有 `objectKey` 的旧 Data URL 需要走修复上传并回写轻量引用。上传到生成面板参考图槽位的图片必须先创建 `editor_project_resource` 行;没有当前工程 ID 时才创建账号级 `editor_asset` 行,随后把对应 `resourceId` 或 `assetId` 写入参考图临时状态,生成请求仍使用临时状态中的图片源或 `objectKey`。 - 资源表保存资源和素材级元数据;图层位置、层级、分组选中所需 ID 和 groupId 保存在 `editor_canvas` 的布局 JSON。布局 JSON 是混合数组:普通图层按 `layerId/resourceId` 保存,生成器占位和生成器对话框按 `itemType: "generation-dialog"` 保存,不新增单独表。普通图层的新保存不再把 `assetKind/generationInputs` 写入布局 JSON;刷新时优先从 `editor_project_resource` 恢复,旧布局中的同名字段只作为兼容兜底。生成器快照必须包含生成器 ID、模式、提示词、参数、参考图、状态、占位框位置和可选 `generatedLayerId`;宣发素材生成器还必须保存并恢复 `publicationWorkflowId`、`publicationGameInfo` 和 `publicationReferences`,避免刷新后生成卡片字段或参考图丢失。生成器快照中的参考图同样只保存 `resourceId/sourceAssetId` 行引用和展示所需 label,不保存图片 Data URL、signed URL 或 `objectKey`;刷新时用 `editor_project_resource` / `editor_asset` 行恢复临时生成请求所需图片源。生成成功后仍保存该快照,只是渲染时由 `generatedLayerId` 锚定到成品图层而不重复显示灰色占位框。`generationInputs.references` 是用户可见输入快照中的行级索引,只允许保存 `{ title, label, refType, refId }`;生成接口所需的图片 Data URL、signed URL 或 `objectKey` 只存在于提交前的临时参考图状态和请求体字段,不进入资源 / 素材元数据。图层展示尺寸不再作为独立 `Size` 真相保存,刷新与新建图层均按 `Resolution`(`originalWidth/originalHeight`)原分辨率显示。图层组第一版是画布内布局语义,不单独建表。 -- 图片类生成结果除作为 `editor_project_resource` 和画布图层保存外,还要写入账号级 `editor_asset` 素材库;该写入由生成 BFF 在请求携带 `assetFolderId` 时完成。`GENARRATIVE_EXTERNAL_GENERATION_MODE=queue` 下,画布图片、改图、图标素材、UI 素材提取、角色动作、视频、音效和背景音乐生成都先返回 `queueState`,前端轮询 `/api/runtime/external-generation/jobs/{jobId}` 到完成后重新读取项目快照;`inline` 或无项目上下文时才使用响应中的 resource / asset 快照做本地落画布兜底,不再把同一生成结果二次调用素材创建接口。生成请求失败、inline 完成或 queue 任务终态完成 / 失败后,右上角泥点 chip 必须通过 `/profile/dashboard` 回读余额,不做本地乐观扣减。视频结果当前只保存为画布视频资源,不进入图片素材库。 +- 图片类和生成视频结果除作为 `editor_project_resource` 和画布图层保存外,还要写入账号级 `editor_asset` 素材库;该写入由生成 BFF 在请求携带 `assetFolderId` 时完成。`GENARRATIVE_EXTERNAL_GENERATION_MODE=queue` 下,画布图片、改图、图标素材、UI 素材提取、角色动作、视频、音效和背景音乐生成都先返回 `queueState`,前端轮询 `/api/runtime/external-generation/jobs/{jobId}` 到完成后重新读取项目快照;`inline` 或无项目上下文时才使用响应中的 resource / asset 快照做本地落画布兜底,不再把同一生成结果二次调用素材创建接口。生成请求失败、inline 完成或 queue 任务终态完成 / 失败后,右上角泥点 chip 必须通过 `/profile/dashboard` 回读余额,不做本地乐观扣减。生成视频会单独抽取首帧封面并写入 `thumbnailSrc`,素材栏和拖回画布时沿用该封面作为 poster。 - 前端不直接订阅 SpacetimeDB,统一通过 api-server 的 `/api/editor/projects*` BFF 读写。 - 工程刷新恢复可先应用 session 级轻量项目快照缓存,让画布和素材 chrome 尽快显示;缓存快照必须排除 `data:*` / `blob:` 内联媒体,且在后端项目快照返回前不得触发自动保存。后端快照回来后覆盖本地缓存显示并恢复正常保存队列。 - 未登录用户可以使用本地演示态,但不触发工程自动保存;真实图片生成 / 修改需要登录。编辑器 API 请求允许使用 refresh cookie 静默补 access token,但 401 / 403 只在编辑器局部提示登录,不清空整站登录态,也不把后端 requestId 直接作为生图弹窗主文案。 @@ -76,7 +76,7 @@ - `POST /api/editor/icon-spritesheets/generations`:按图标规范图和素材描述数组生成 spritesheet,再由后端切分为独立透明图标。请求支持 `model`、`aspectRatio`、`imageSize`、`priceMudPoints`、`projectId`、`assetFolderId` 和 `generationInputs`;`priceMudPoints` 必须来自编辑器生成计费配置中对应生图模型的尺寸档位(如 `nanobanana2` 的 `0.5K / 1K / 2K` 或 `gpt-image-2` 的 `1K / 2K`),后端用 `editor_generation_config` 校验后才调用上游;`nanobanana2` 走原生 `generateContent` 并写入 `generationConfig.imageConfig.aspectRatio/imageSize`,`0.5K` 传 `"512"`;`gpt-image-2` 走 `/v1/images/edits`。后端把 spritesheet 和拆分后的 icon 都保存为 project resource / 账号素材,并随响应返回对应快照。 - `POST /api/editor/ui-designs/assets/extractions`:以前端已绘入红色框选轮廓的 UI 设计图 Data URL 作为参考图,固定 `gpt-image-2` 和提示词 `仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕,方便后续扣除背景;素材自身不要出现绿色描边、绿色底板或绿色阴影。` 生成素材 spritesheet,再按连通域自动拆分为 `素材 1..N`,返回结构复用图标 spritesheet 响应。请求必须携带 `aspectRatio: "1:1"`、`imageSize: "1K" | "2K"` 和 `priceMudPoints`;框选数量不超过 6 个时前端按 `1:1·1K` 与 gpt-image-2 1K 价格提交,超过 6 个时按 `1:1·2K` 与 2K 价格提交。后端必须在调用上游前校验比例、尺寸和泥点价格,只允许 `1:1 / 1K / 2K`。请求可携带 `projectId`、`assetFolderId`、`generationInputs` 和 `spritesheetLabel`,后端保存 spritesheet / 拆分素材并返回对应 resource / asset 快照;前端必须把 spritesheet 原图与拆分素材都加入画布。 - `POST /api/editor/images/edits`:按提示词和当前图片 Data URL 调用 VectorEngine edits,返回新的生成图片元数据;请求携带 project / asset 上下文时由后端创建新 resource / asset,前端只消费响应快照。 -- `POST /api/editor/videos/generations`:按视频描述、模型、比例、时长、分辨率、模式、声音、默认联网搜索标记和泥点价格生成视频。前端可选模型为 `seedance2.0-fast`、`seedance2.0`、`kling3.0`、`kling3.0-omni`,默认 `seedance2.0-fast`;后端必须将 `seedance2.0-fast` 映射到 `doubao-seedance-2-0-fast-260128`,将 `seedance2.0` 映射到 `doubao-seedance-2-0-260128`,两者不得混用。后端允许 6 类比例、4 到 15 秒整数、`480p / 720p / 1080p`,并拒绝 `seedance2.0-fast + 1080p`;`sound=on/off` 映射 Ark `generate_audio=true/false`。后端复用 Ark / VectorEngine content generation task 轮询链路,下载最终视频并持久化到 OSS,返回 `videoSrc`、尺寸、prompt、model、provider、taskId、durationSeconds、resolution 和 `priceMudPoints`。 +- `POST /api/editor/videos/generations`:按视频描述、模型、比例、时长、分辨率、模式、声音、默认联网搜索标记和泥点价格生成视频。前端可选模型为 `seedance2.0-fast`、`seedance2.0`、`kling3.0`、`kling3.0-omni`,默认 `seedance2.0-fast`;后端必须将 `seedance2.0-fast` 映射到 `doubao-seedance-2-0-fast-260128`,将 `seedance2.0` 映射到 `doubao-seedance-2-0-260128`,两者不得混用。后端允许 6 类比例、4 到 15 秒整数、`480p / 720p / 1080p`,并拒绝 `seedance2.0-fast + 1080p`;`sound=on/off` 映射 Ark `generate_audio=true/false`。后端复用 Ark / VectorEngine content generation task 轮询链路,下载最终视频并持久化到 OSS;请求携带 `projectId` / `assetFolderId` 时同步创建 project resource / 账号素材并返回 `project` / `asset` 快照,基础响应返回 `videoSrc`、尺寸、prompt、model、provider、taskId、durationSeconds、resolution 和 `priceMudPoints`。 所有写接口都必须校验 Bearer 登录态和 owner;接口只返回当前用户有权读取的工程与资源。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 1153d4391..f9dc1e16a 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -482,7 +482,7 @@ npm run check:server-rs-ddd - Rust 结构体:`EditorAsset` - 源码:`server-rs/crates/spacetime-module/src/editor_project_storage.rs` -- 说明:图片画布账号级素材表,保存用户上传 / 生成素材的名称、文件夹、图片读取地址、OSS 引用、尺寸、来源类型、prompt、provider、task、`asset_kind`、`generation_inputs_json` 和可选 `source_resource_id`。素材在同一账号的所有项目中可见;图片 / 图标 / UI 提取等生成 BFF 在请求携带 `asset_folder_id` 时负责创建账号级生成素材并返回 asset 快照,若同次生成也创建了 `editor_project_resource`,则把该 `resource_id` 写入 `source_resource_id`。素材库快照通过 `source_resource_id` 回查对应 project resource 的 `public_showcase_enabled`,供左侧素材菜单展示和切换公开状态;公开开关本身仍只落在 `editor_project_resource`,不在账号素材表复制真相。素材放入画布时复制为 `editor_project_resource` 并由图层引用 resourceId,画布从 resource / asset 级元数据恢复素材类别和用户可见生成输入快照。 +- 说明:图片画布账号级素材表,保存用户上传 / 生成素材的名称、文件夹、图片读取地址、可选封面 `thumbnail_src`、OSS 引用、尺寸、来源类型、prompt、provider、task、`asset_kind`、`generation_inputs_json` 和可选 `source_resource_id`。素材在同一账号的所有项目中可见;图片 / 图标 / UI 提取等生成 BFF 在请求携带 `asset_folder_id` 时负责创建账号级生成素材并返回 asset 快照,若同次生成也创建了 `editor_project_resource`,则把该 `resource_id` 写入 `source_resource_id`。生成视频会抽取首帧封面写入 `thumbnail_src`,素材库和再次放入画布时用它作为 video poster。素材库快照通过 `source_resource_id` 回查对应 project resource 的 `public_showcase_enabled`,供左侧素材菜单展示和切换公开状态;公开开关本身仍只落在 `editor_project_resource`,不在账号素材表复制真相。素材放入画布时复制为 `editor_project_resource` 并由图层引用 resourceId,画布从 resource / asset 级元数据恢复素材类别和用户可见生成输入快照。 - 索引:`by_editor_asset_owner_user_id`、`by_editor_asset_folder_id`。 ### `inventory_slot` diff --git a/server-rs/crates/api-server/src/character_animation_assets.rs b/server-rs/crates/api-server/src/character_animation_assets.rs index 996a5a507..b3e565222 100644 --- a/server-rs/crates/api-server/src/character_animation_assets.rs +++ b/server-rs/crates/api-server/src/character_animation_assets.rs @@ -61,8 +61,9 @@ use crate::{ editor_generation_source_entity_id, enqueue_editor_generation_job, }, editor_project::{ - EditorCanvasGeneratedLayerInput, build_editor_canvas_generated_layer_item, - complete_editor_canvas_generation_with_items, + EditorCanvasGeneratedLayerInput, PersistEditorGeneratedAssetInput, + build_editor_canvas_generated_layer_item, complete_editor_canvas_generation_with_items, + persist_editor_generated_asset, }, http_error::AppError, platform_errors::map_oss_error, @@ -104,6 +105,7 @@ const EDITOR_CHARACTER_ANIMATION_PROMPT_PREFIX: &str = "生成游戏角色动画 const EDITOR_VIDEO_ASSET_KIND: &str = "editor_video"; const EDITOR_VIDEO_ENTITY_KIND: &str = "editor_canvas"; const EDITOR_VIDEO_SLOT: &str = "video_preview"; +const EDITOR_VIDEO_COVER_SLOT: &str = "video_cover"; const EDITOR_VIDEO_MODEL_SEEDANCE_2: &str = "seedance2.0"; const EDITOR_VIDEO_MODEL_SEEDANCE_2_FAST: &str = "seedance2.0-fast"; const EDITOR_VIDEO_MODEL_KLING_3: &str = "kling3.0"; @@ -151,6 +153,7 @@ struct PersistedEditorVideo { legacy_public_path: String, object_key: String, asset_object_id: String, + thumbnail_src: String, } pub async fn list_character_animation_templates( @@ -828,6 +831,8 @@ pub(crate) async fn generate_editor_video_for_owner( let generation_inputs = payload.generation_inputs.clone(); let source_resource_id = payload.source_resource_id.clone(); let asset_kind = payload.asset_kind.clone(); + let asset_folder_id = payload.asset_folder_id.clone(); + let asset_label = payload.asset_label.clone(); let pricing = state.editor_generation_pricing().map_err(|error| { editor_video_error_response( @@ -863,17 +868,53 @@ pub(crate) async fn generate_editor_video_for_owner( ) .await .map_err(|error| editor_video_error_response(&request_context, error))?; + let image_src = generated.preview_video_path.clone(); + let effective_asset_kind = asset_kind.clone().unwrap_or_else(|| "video".to_string()); + let generated_asset = persist_editor_generated_asset( + &state, + PersistEditorGeneratedAssetInput { + project_id: project_id.clone(), + owner_user_id: owner_user_id.clone(), + folder_id: asset_folder_id, + label: asset_label + .and_then(|value| { + let trimmed = value.trim().to_string(); + (!trimmed.is_empty()).then_some(trimmed) + }) + .unwrap_or_else(|| "生成视频".to_string()), + image_src: image_src.clone(), + object_key: generated.object_key.clone(), + asset_object_id: generated.asset_object_id.clone(), + width: normalized.width, + height: normalized.height, + prompt: normalized.prompt.clone(), + actual_prompt: Some(generated.submitted_prompt.clone()), + model: normalized.model.clone(), + provider: "VectorEngine".to_string(), + task_id: task_id.clone(), + source_resource_id: source_resource_id.clone(), + asset_kind: Some(effective_asset_kind.clone()), + generation_inputs: generation_inputs.clone(), + thumbnail_src: generated.thumbnail_src.clone(), + }, + ) + .await + .map_err(|error| editor_video_error_response(&request_context, error))?; let completed_project = if let Some(completion) = canvas_completion.as_ref() { let layer_id = format!("layer-editor-video-{task_id}"); let item = build_editor_canvas_generated_layer_item( completion, EditorCanvasGeneratedLayerInput { layer_id: layer_id.clone(), - resource_id: format!("local-resource-editor-video-{task_id}"), + resource_id: generated_asset + .resource + .as_ref() + .map(|resource| resource.resource_id.clone()) + .unwrap_or_else(|| format!("local-resource-editor-video-{task_id}")), title: completion.title.trim().to_string(), - src: generated.preview_video_path.clone(), + src: image_src.clone(), media_type: Some("video".to_string()), - thumbnail_src: None, + thumbnail_src: generated.thumbnail_src.clone(), original_width: normalized.width, original_height: normalized.height, display_width: completion.placeholder.width, @@ -888,7 +929,7 @@ pub(crate) async fn generate_editor_video_for_owner( asset_object_id: generated.asset_object_id.clone(), duration_seconds: Some(normalized.duration_seconds), source_resource_id, - asset_kind: Some(asset_kind.unwrap_or_else(|| "video".to_string())), + asset_kind: Some(effective_asset_kind.clone()), generation_inputs, image_sequence_frames: None, preview_video_path: None, @@ -909,12 +950,16 @@ pub(crate) async fn generate_editor_video_for_owner( }; let completed_project = completed_project.and_then(|project| serde_json::to_value(project).ok()); + let generated_asset = generated_asset + .asset + .and_then(|asset| serde_json::to_value(asset).ok()); Ok(json_success_body( Some(&request_context), EditorVideoGenerateResponse { ok: true, video_src: generated.preview_video_path, + thumbnail_src: generated.thumbnail_src, object_key: generated.object_key, asset_object_id: generated.asset_object_id, width: normalized.width, @@ -929,6 +974,7 @@ pub(crate) async fn generate_editor_video_for_owner( resolution: normalized.resolution, price_mud_points: normalized.price_mud_points, project: completed_project, + asset: generated_asset, queue_state: None, }, )) @@ -1124,13 +1170,12 @@ pub async fn resolve_role_asset_workflow( })?; let cache_scope_id = trim_optional_text(payload.cache_scope_id.as_deref()); - let cache = match load_workflow_cache(&state, character_id.as_str(), cache_scope_id.as_deref()) - .await - { - Ok(cache) => cache, - Err(error) if is_missing_oss_config_error(&error) => None, - Err(error) => return Err(character_animation_error_response(&request_context, error)), - }; + let cache = + match load_workflow_cache(&state, character_id.as_str(), cache_scope_id.as_deref()).await { + Ok(cache) => cache, + Err(error) if is_missing_oss_config_error(&error) => None, + Err(error) => return Err(character_animation_error_response(&request_context, error)), + }; let workflow = build_role_asset_workflow(payload.role, cache.as_ref()); Ok(json_success_body( @@ -1441,6 +1486,7 @@ async fn request_image_to_video_preview( preview_video_path, object_key: None, asset_object_id: None, + thumbnail_src: None, upstream_task_id, submitted_prompt, moderation_fallback_applied, @@ -1735,6 +1781,7 @@ async fn request_editor_character_animation_preview( preview_video_path, object_key: None, asset_object_id: None, + thumbnail_src: None, upstream_task_id, submitted_prompt: prompt.to_string(), moderation_fallback_applied: false, @@ -1763,6 +1810,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), upstream_task_id, submitted_prompt: request.prompt.clone(), moderation_fallback_applied: false, @@ -1820,6 +1868,9 @@ async fn put_generated_editor_video( preview_payload: MediaPayload, ) -> Result { 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, @@ -1853,9 +1904,65 @@ async fn put_generated_editor_video( legacy_public_path: put_result.legacy_public_path, object_key: put_result.object_key, asset_object_id: confirmed.record.asset_object_id, + thumbnail_src, }) } +async fn put_generated_editor_video_cover( + state: &AppState, + owner_user_id: &str, + task_id: &str, + request: &NormalizedEditorVideoRequest, + preview_payload: &MediaPayload, +) -> Result { + let temp_dir = create_animation_temp_dir()?; + let input_path = temp_dir.join(format!("preview.{}", preview_payload.extension)); + let output_path = temp_dir.join("cover.png"); + fs::write(&input_path, &preview_payload.bytes).map_err(|error| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ + "provider": "editor-video", + "message": format!("写入画板视频封面临时视频失败:{error}"), + })) + })?; + + let extraction_result = (|| { + let settings = resolve_backend_frame_extraction_settings(state); + extract_video_frame_to_png(&input_path, &output_path, 0.0, &settings)?; + let cover_bytes = fs::read(&output_path).map_err(|error| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "editor-video", + "message": format!("读取画板视频首帧封面失败:{error}"), + })) + })?; + Ok::<_, AppError>(cover_bytes) + })(); + let _ = fs::remove_dir_all(&temp_dir); + let cover_bytes = extraction_result?; + + let put_result = put_character_animation_object( + state, + LegacyAssetPrefix::CharacterDrafts, + vec![ + "editor-videos".to_string(), + sanitize_storage_segment(request.model.as_str(), "model"), + task_id.to_string(), + ], + "cover.png".to_string(), + "image/png".to_string(), + cover_bytes, + build_asset_metadata( + EDITOR_VIDEO_ASSET_KIND, + owner_user_id, + EDITOR_VIDEO_ENTITY_KIND, + task_id, + EDITOR_VIDEO_COVER_SLOT, + request.model.as_str(), + ), + ) + .await?; + Ok(put_result.legacy_public_path) +} + async fn create_editor_ark_image_to_video_task( http_client: &reqwest::Client, settings: &EditorCharacterAnimationSettings, @@ -4925,6 +5032,7 @@ struct GeneratedAnimationPreview { preview_video_path: String, object_key: Option, asset_object_id: Option, + thumbnail_src: Option, upstream_task_id: String, submitted_prompt: String, moderation_fallback_applied: bool, @@ -5363,6 +5471,8 @@ mod tests { generation_inputs: None, source_resource_id: None, asset_kind: None, + asset_folder_id: None, + asset_label: None, }) .expect("editor video request should normalize"); @@ -5393,6 +5503,8 @@ mod tests { generation_inputs: None, source_resource_id: None, asset_kind: None, + asset_folder_id: None, + asset_label: None, }) .expect("seedance fast request should normalize"); @@ -5420,6 +5532,8 @@ mod tests { generation_inputs: None, source_resource_id: None, asset_kind: None, + asset_folder_id: None, + asset_label: None, }) .expect("seedance standard request should normalize"); @@ -5447,6 +5561,8 @@ mod tests { generation_inputs: None, source_resource_id: None, asset_kind: None, + asset_folder_id: None, + asset_label: None, }) .expect("seedance standard 1080p request should normalize"); @@ -5475,6 +5591,8 @@ mod tests { generation_inputs: None, source_resource_id: None, asset_kind: None, + asset_folder_id: None, + asset_label: None, }) .expect_err("seedance fast should reject 1080p"); @@ -5502,6 +5620,8 @@ mod tests { generation_inputs: None, source_resource_id: None, asset_kind: None, + asset_folder_id: None, + asset_label: None, }) .expect("seedance multimodal request should normalize"); @@ -5538,6 +5658,8 @@ mod tests { generation_inputs: None, source_resource_id: None, asset_kind: None, + asset_folder_id: None, + asset_label: None, }) .expect_err("Seedance reference videos must not accept Base64 data URLs"); @@ -5564,6 +5686,8 @@ mod tests { generation_inputs: None, source_resource_id: None, asset_kind: None, + asset_folder_id: None, + asset_label: None, }) .expect("seedance multimodal request should normalize"); @@ -5610,6 +5734,8 @@ mod tests { generation_inputs: None, source_resource_id: None, asset_kind: None, + asset_folder_id: None, + asset_label: None, }) .expect("seedance objectKey request should normalize"); @@ -5650,6 +5776,8 @@ mod tests { generation_inputs: None, source_resource_id: None, asset_kind: None, + asset_folder_id: None, + asset_label: None, }) .expect_err("audio-only references should fail"); diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index 4695e5180..f4be80a88 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -339,9 +339,9 @@ pub struct EditorAssetResponse { } #[derive(Debug)] -struct EditorGeneratedAssetRecord { - resource: Option, - asset: Option, +pub(crate) struct EditorGeneratedAssetRecord { + pub(crate) resource: Option, + pub(crate) asset: Option, } struct EditorCanvasGenerationCompletionResult { @@ -350,24 +350,25 @@ struct EditorCanvasGenerationCompletionResult { } #[derive(Debug)] -struct PersistEditorGeneratedAssetInput { - project_id: Option, - owner_user_id: String, - folder_id: Option, - label: String, - image_src: String, - object_key: Option, - asset_object_id: Option, - width: u32, - height: u32, - prompt: String, - actual_prompt: Option, - model: String, - provider: String, - task_id: String, - source_resource_id: Option, - asset_kind: Option, - generation_inputs: Option, +pub(crate) struct PersistEditorGeneratedAssetInput { + pub(crate) project_id: Option, + pub(crate) owner_user_id: String, + pub(crate) folder_id: Option, + pub(crate) label: String, + pub(crate) image_src: String, + pub(crate) object_key: Option, + pub(crate) asset_object_id: Option, + pub(crate) width: u32, + pub(crate) height: u32, + pub(crate) prompt: String, + pub(crate) actual_prompt: Option, + pub(crate) model: String, + pub(crate) provider: String, + pub(crate) task_id: String, + pub(crate) source_resource_id: Option, + pub(crate) asset_kind: Option, + pub(crate) generation_inputs: Option, + pub(crate) thumbnail_src: Option, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -479,7 +480,7 @@ pub struct EditorCanvasPayload { #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct EditorProjectResourcePayload { - resource_id: String, + pub(crate) resource_id: String, project_id: String, owner_user_id: String, #[serde(skip_serializing_if = "Option::is_none")] @@ -545,6 +546,7 @@ pub struct EditorAssetPayload { generation_inputs: Option, source_resource_id: Option, public_showcase_enabled: Option, + thumbnail_src: Option, created_at: String, updated_at: String, } @@ -966,6 +968,7 @@ pub async fn create_editor_asset( generation_inputs_json, source_resource_id: None, now_micros: current_utc_micros(), + thumbnail_src: None, }) .await .map_err(map_editor_project_error)?; @@ -1300,6 +1303,7 @@ pub(crate) async fn generate_editor_image_for_owner( source_resource_id: payload.source_resource_id, asset_kind: payload.asset_kind, generation_inputs: payload.generation_inputs, + thumbnail_src: None, }, ) .await?; @@ -1658,6 +1662,7 @@ pub(crate) async fn edit_editor_image_for_owner( source_resource_id: payload.source_resource_id, asset_kind: payload.asset_kind, generation_inputs: payload.generation_inputs, + thumbnail_src: None, }, ) .await?; @@ -1856,6 +1861,7 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( source_resource_id: None, asset_kind: Some("icon-spritesheet".to_string()), generation_inputs: generation_inputs.clone(), + thumbnail_src: None, }, ) .await?; @@ -2088,6 +2094,7 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner( source_resource_id: None, asset_kind: Some("icon-spritesheet".to_string()), generation_inputs: generation_inputs.clone(), + thumbnail_src: None, }, ) .await?; @@ -2137,6 +2144,7 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner( source_resource_id: None, asset_kind: Some("icon".to_string()), generation_inputs: generation_inputs.clone(), + thumbnail_src: None, }, ) .await?; @@ -2570,6 +2578,7 @@ pub(crate) fn editor_asset_payload_from_record(record: EditorAssetRecord) -> Edi .map(sanitize_editor_payload_inline_media), source_resource_id: record.source_resource_id, public_showcase_enabled: record.public_showcase_enabled, + thumbnail_src: normalize_optional_string(record.thumbnail_src), created_at: record.created_at, updated_at: record.updated_at, } @@ -2692,7 +2701,7 @@ fn fill_missing_media_identity_field( } } -async fn persist_editor_generated_asset( +pub(crate) async fn persist_editor_generated_asset( state: &AppState, input: PersistEditorGeneratedAssetInput, ) -> Result { @@ -2760,6 +2769,7 @@ async fn persist_editor_generated_asset( generation_inputs_json, source_resource_id: created_resource_id, now_micros, + thumbnail_src: normalize_optional_string(input.thumbnail_src), }) .await .map_err(map_editor_project_error)?; diff --git a/server-rs/crates/api-server/src/external_editor_api.rs b/server-rs/crates/api-server/src/external_editor_api.rs index 829b22d23..4994dffea 100644 --- a/server-rs/crates/api-server/src/external_editor_api.rs +++ b/server-rs/crates/api-server/src/external_editor_api.rs @@ -492,6 +492,7 @@ pub async fn create_external_editor_asset( generation_inputs_json, source_resource_id: None, now_micros: current_utc_micros(), + thumbnail_src: None, }) .await .map_err(map_editor_project_error)?; diff --git a/server-rs/crates/shared-contracts/src/assets.rs b/server-rs/crates/shared-contracts/src/assets.rs index 8bfc456e1..841ca516c 100644 --- a/server-rs/crates/shared-contracts/src/assets.rs +++ b/server-rs/crates/shared-contracts/src/assets.rs @@ -387,6 +387,10 @@ pub struct EditorVideoGenerateRequest { pub source_resource_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub asset_kind: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub asset_folder_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub asset_label: Option, } fn default_editor_video_web_search_enabled() -> bool { @@ -399,6 +403,8 @@ pub struct EditorVideoGenerateResponse { pub ok: bool, pub video_src: String, #[serde(skip_serializing_if = "Option::is_none")] + pub thumbnail_src: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub object_key: Option, #[serde(skip_serializing_if = "Option::is_none")] pub asset_object_id: Option, @@ -417,6 +423,8 @@ pub struct EditorVideoGenerateResponse { #[serde(default, skip_serializing_if = "Option::is_none")] pub project: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + pub asset: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub queue_state: Option, } @@ -1141,6 +1149,8 @@ mod tests { generation_inputs: None, source_resource_id: None, asset_kind: None, + asset_folder_id: None, + asset_label: None, }) .expect("request should serialize"); @@ -1172,6 +1182,8 @@ mod tests { generation_inputs: None, source_resource_id: None, asset_kind: None, + asset_folder_id: None, + asset_label: None, }) .expect("request should serialize"); @@ -1194,6 +1206,7 @@ mod tests { let payload = serde_json::to_value(EditorVideoGenerateResponse { ok: true, video_src: "/generated-editor-videos/task-1/preview.mp4".to_string(), + thumbnail_src: Some("/generated-editor-videos/task-1/cover.png".to_string()), object_key: Some( "generated-character-drafts/editor-videos/task-1/preview.mp4".to_string(), ), @@ -1210,6 +1223,7 @@ mod tests { resolution: "480p".to_string(), price_mud_points: 100, project: None, + asset: None, queue_state: None, }) .expect("response should serialize"); @@ -1218,6 +1232,10 @@ mod tests { payload["videoSrc"], json!("/generated-editor-videos/task-1/preview.mp4") ); + assert_eq!( + payload["thumbnailSrc"], + json!("/generated-editor-videos/task-1/cover.png") + ); assert_eq!( payload["objectKey"], json!("generated-character-drafts/editor-videos/task-1/preview.mp4") diff --git a/server-rs/crates/spacetime-client/src/mapper/editor_project.rs b/server-rs/crates/spacetime-client/src/mapper/editor_project.rs index c2893f781..f959b62f3 100644 --- a/server-rs/crates/spacetime-client/src/mapper/editor_project.rs +++ b/server-rs/crates/spacetime-client/src/mapper/editor_project.rs @@ -94,6 +94,7 @@ pub struct EditorAssetRecord { pub generation_inputs: Option, pub source_resource_id: Option, pub public_showcase_enabled: Option, + pub thumbnail_src: Option, pub created_at: String, pub updated_at: String, } @@ -232,6 +233,7 @@ pub struct EditorAssetCreateRecordInput { pub generation_inputs_json: Option, pub source_resource_id: Option, pub now_micros: i64, + pub thumbnail_src: Option, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -442,6 +444,7 @@ impl From for crate::module_bindings::EditorAssetC generation_inputs_json: input.generation_inputs_json, source_resource_id: input.source_resource_id, now_micros: input.now_micros, + thumbnail_src: input.thumbnail_src, } } } @@ -756,6 +759,7 @@ fn map_editor_asset_snapshot( generation_inputs, source_resource_id: snapshot.source_resource_id, public_showcase_enabled: snapshot.public_showcase_enabled, + thumbnail_src: snapshot.thumbnail_src, created_at: format_timestamp_micros(snapshot.created_at_micros), updated_at: format_timestamp_micros(snapshot.updated_at_micros), }) diff --git a/server-rs/crates/spacetime-client/src/module_bindings.rs b/server-rs/crates/spacetime-client/src/module_bindings.rs index 4a647bd63..c0691341c 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings.rs @@ -1,7 +1,7 @@ // THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE // WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. -// This was generated using spacetimedb cli version 2.5.0 (commit 19cbf2b219ac9fd7fdca6c5e2c61a3a2f0319515). +// This was generated using spacetimedb cli version 2.5.0 (commit ca16958ef0a5f8c816700d2255a0b20ecacff901). #![allow(unused, clippy::all)] use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_asset_create_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_asset_create_input_type.rs index 5d39f1fcc..74aa13bfd 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/editor_asset_create_input_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_asset_create_input_type.rs @@ -26,6 +26,7 @@ pub struct EditorAssetCreateInput { pub generation_inputs_json: Option, pub source_resource_id: Option, pub now_micros: i64, + pub thumbnail_src: Option, } impl __sdk::InModule for EditorAssetCreateInput { diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_asset_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_asset_snapshot_type.rs index 419250158..c1111852c 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/editor_asset_snapshot_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_asset_snapshot_type.rs @@ -27,6 +27,7 @@ pub struct EditorAssetSnapshot { pub public_showcase_enabled: Option, pub created_at_micros: i64, pub updated_at_micros: i64, + pub thumbnail_src: Option, } impl __sdk::InModule for EditorAssetSnapshot { diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_asset_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_asset_type.rs index ad126e534..dccacd1f3 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/editor_asset_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_asset_type.rs @@ -27,6 +27,7 @@ pub struct EditorAsset { pub asset_kind: Option, pub generation_inputs_json: Option, pub source_resource_id: Option, + pub thumbnail_src: Option, } impl __sdk::InModule for EditorAsset { @@ -57,6 +58,7 @@ pub struct EditorAssetCols { pub asset_kind: __sdk::__query_builder::Col>, pub generation_inputs_json: __sdk::__query_builder::Col>, pub source_resource_id: __sdk::__query_builder::Col>, + pub thumbnail_src: __sdk::__query_builder::Col>, } impl __sdk::__query_builder::HasCols for EditorAsset { @@ -86,6 +88,7 @@ impl __sdk::__query_builder::HasCols for EditorAsset { "generation_inputs_json", ), source_resource_id: __sdk::__query_builder::Col::new(table_name, "source_resource_id"), + thumbnail_src: __sdk::__query_builder::Col::new(table_name, "thumbnail_src"), } } } diff --git a/server-rs/crates/spacetime-module/src/editor_project_storage.rs b/server-rs/crates/spacetime-module/src/editor_project_storage.rs index 03b550d26..e88e2f61e 100644 --- a/server-rs/crates/spacetime-module/src/editor_project_storage.rs +++ b/server-rs/crates/spacetime-module/src/editor_project_storage.rs @@ -124,6 +124,8 @@ pub struct EditorAsset { generation_inputs_json: Option, #[default(None::)] source_resource_id: Option, + #[default(None::)] + thumbnail_src: Option, } #[derive(Clone, Debug, PartialEq, SpacetimeType)] @@ -282,6 +284,7 @@ pub struct EditorAssetSnapshot { pub public_showcase_enabled: Option, pub created_at_micros: i64, pub updated_at_micros: i64, + pub thumbnail_src: Option, } #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] @@ -342,6 +345,7 @@ pub struct EditorAssetCreateInput { pub generation_inputs_json: Option, pub source_resource_id: Option, pub now_micros: i64, + pub thumbnail_src: Option, } #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] @@ -1267,6 +1271,7 @@ fn create_editor_asset( asset_kind: normalize_optional(input.asset_kind), generation_inputs_json: normalize_optional(input.generation_inputs_json), source_resource_id: normalize_optional(input.source_resource_id), + thumbnail_src: normalize_optional(input.thumbnail_src), }); ctx.db .editor_asset() @@ -1315,6 +1320,7 @@ fn update_editor_asset( asset_kind: asset.asset_kind, generation_inputs_json: asset.generation_inputs_json, source_resource_id: asset.source_resource_id, + thumbnail_src: asset.thumbnail_src, }); ctx.db .editor_asset() @@ -1359,6 +1365,7 @@ fn repair_editor_asset_media( asset_kind: asset.asset_kind, generation_inputs_json: asset.generation_inputs_json, source_resource_id: asset.source_resource_id, + thumbnail_src: asset.thumbnail_src, }); ctx.db @@ -1726,6 +1733,7 @@ fn asset_snapshot_from_row(ctx: &ReducerContext, row: EditorAsset) -> EditorAsse public_showcase_enabled, created_at_micros: row.created_at.to_micros_since_unix_epoch(), updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), + thumbnail_src: row.thumbnail_src, } } @@ -1847,12 +1855,7 @@ fn project_resource_input_copies_source_media( .find(&source_resource_id.to_string()) .filter(|source_resource| source_resource.owner_user_id == owner_user_id) .is_some_and(|source_resource| { - project_resource_matches_media( - &source_resource, - asset_object_id, - object_key, - image_src, - ) + project_resource_matches_media(&source_resource, asset_object_id, object_key, image_src) }) } @@ -1883,12 +1886,7 @@ fn find_reusable_project_resource_for_input( .as_deref() .map(str::trim) .is_some_and(|value| value == source_resource_id)) - && project_resource_matches_media( - resource, - asset_object_id, - object_key, - image_src, - ) + && project_resource_matches_media(resource, asset_object_id, object_key, image_src) }) .collect::>(); matches.sort_by(|left, right| { @@ -1934,12 +1932,7 @@ fn matching_media_ref(left: &str, right: &str) -> bool { } fn normalize_media_ref(value: &str) -> String { - let trimmed_value = value - .trim() - .split('?') - .next() - .unwrap_or("") - .trim(); + let trimmed_value = value.trim().split('?').next().unwrap_or("").trim(); let path_value = if let Some(protocol_index) = trimmed_value.find("://") { let after_protocol = &trimmed_value[protocol_index + 3..]; after_protocol diff --git a/server-rs/crates/spacetime-module/src/migration.rs b/server-rs/crates/spacetime-module/src/migration.rs index b76f1ab31..ef6bffff4 100644 --- a/server-rs/crates/spacetime-module/src/migration.rs +++ b/server-rs/crates/spacetime-module/src/migration.rs @@ -1408,6 +1408,9 @@ fn normalize_migration_row(table_name: &str, value: &serde_json::Value) -> serde object .entry("source_resource_id".to_string()) .or_insert(serde_json::Value::Null); + object + .entry("thumbnail_src".to_string()) + .or_insert(serde_json::Value::Null); } } next_value diff --git a/src/components/image-editor/ImageCanvasEditorModel.test.ts b/src/components/image-editor/ImageCanvasEditorModel.test.ts index 31922ec29..935f5be14 100644 --- a/src/components/image-editor/ImageCanvasEditorModel.test.ts +++ b/src/components/image-editor/ImageCanvasEditorModel.test.ts @@ -102,6 +102,8 @@ describe('ImageCanvasEditorModel', () => { folderId: 'project', label: '开场动画.mp4', imageSrc: '/generated-character-drafts/editor/asset-library/video/开场动画.mp4', + thumbnailSrc: + '/generated-character-drafts/editor/asset-library/video/开场动画-cover.png', width: 560, height: 315, sourceType: 'uploaded', @@ -112,7 +114,11 @@ describe('ImageCanvasEditorModel', () => { }); expect(library.assets[0]).toMatchObject({ mediaType: 'audio' }); - expect(library.assets[1]).toMatchObject({ mediaType: 'video' }); + expect(library.assets[1]).toMatchObject({ + mediaType: 'video', + thumbnailSrc: + '/generated-character-drafts/editor/asset-library/video/开场动画-cover.png', + }); expect( createLayerFromAsset( library.assets[0]!, @@ -139,6 +145,7 @@ describe('ImageCanvasEditorModel', () => { persisted: true, objectKey: 'oss/asset-1.png', assetObjectId: 'object-1', + thumbnailSrc: '/generated-character-drafts/editor/asset-1-cover.png', sourceResourceId: 'resource-source-1', }; @@ -158,6 +165,7 @@ describe('ImageCanvasEditorModel', () => { originalHeight: 480, objectKey: 'oss/asset-1.png', assetObjectId: 'object-1', + thumbnailSrc: '/generated-character-drafts/editor/asset-1-cover.png', sourceAssetId: 'asset-1', sourceResourceId: 'resource-source-1', }); diff --git a/src/components/image-editor/ImageCanvasEditorModel.ts b/src/components/image-editor/ImageCanvasEditorModel.ts index b3466e6a7..dae0eeecc 100644 --- a/src/components/image-editor/ImageCanvasEditorModel.ts +++ b/src/components/image-editor/ImageCanvasEditorModel.ts @@ -172,6 +172,7 @@ export function createLayerFromAsset( originalHeight: asset.height, zIndex: index + 10, sourceType: asset.sourceType, + thumbnailSrc: asset.thumbnailSrc, prompt: asset.prompt, actualPrompt: asset.actualPrompt, model: asset.model, @@ -710,6 +711,7 @@ export function mapAssetLibrarySnapshot( taskId: asset.taskId ?? undefined, objectKey: asset.objectKey ?? undefined, assetObjectId: asset.assetObjectId ?? undefined, + thumbnailSrc: asset.thumbnailSrc ?? undefined, sourceResourceId: asset.sourceResourceId ?? null, publicShowcaseEnabled: asset.publicShowcaseEnabled ?? null, assetKind: canvasAssetKindOrNull(asset.assetKind), diff --git a/src/components/image-editor/ImageCanvasEditorView.tsx b/src/components/image-editor/ImageCanvasEditorView.tsx index 8ff814f0a..e4505aa32 100644 --- a/src/components/image-editor/ImageCanvasEditorView.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.tsx @@ -647,6 +647,7 @@ export function ImageCanvasEditorView() { taskId: asset.taskId ?? undefined, objectKey: asset.objectKey ?? undefined, assetObjectId: asset.assetObjectId ?? undefined, + thumbnailSrc: asset.thumbnailSrc ?? undefined, durationSeconds: asset.durationSeconds ?? undefined, assetKind: canvasAssetKindOrNull(asset.assetKind), generationInputs: generationInputsOrNull(asset.generationInputs), diff --git a/src/components/image-editor/ImageCanvasGenerationLayerModel.test.ts b/src/components/image-editor/ImageCanvasGenerationLayerModel.test.ts index 76c858d25..611a0cb2d 100644 --- a/src/components/image-editor/ImageCanvasGenerationLayerModel.test.ts +++ b/src/components/image-editor/ImageCanvasGenerationLayerModel.test.ts @@ -519,6 +519,21 @@ describe('ImageCanvasGenerationLayerModel', () => { provider: 'VectorEngine', taskId: 'video-task-1', priceMudPoints: 40, + durationSeconds: 5, + thumbnailSrc: '/generated-character-drafts/editor-videos/cover.png', + asset: { + assetId: 'asset-video-bff', + folderId: 'user-1:asset-folder:project', + label: '生成视频 15', + imageSrc: '/generated-character-drafts/editor-videos/video.mp4', + thumbnailSrc: '/generated-character-drafts/editor-videos/cover.png', + objectKey: 'generated-character-drafts/editor-videos/video.mp4', + assetObjectId: 'assetobj-video-1', + width: 1280, + height: 720, + sourceType: 'generated', + assetKind: 'video', + }, }, generatedIndex: 15, title: '生成视频 15', @@ -534,7 +549,10 @@ describe('ImageCanvasGenerationLayerModel', () => { assetKind: 'video', objectKey: 'generated-character-drafts/editor-videos/video.mp4', assetObjectId: 'assetobj-video-1', + thumbnailSrc: '/generated-character-drafts/editor-videos/cover.png', + durationSeconds: 5, }); + expect(layer.generatedAssetSnapshot?.assetId).toBe('asset-video-bff'); }); it('creates character animation result layers as playable image sequences', () => { diff --git a/src/components/image-editor/ImageCanvasGenerationLayerModel.ts b/src/components/image-editor/ImageCanvasGenerationLayerModel.ts index e9619f61d..1b0de8309 100644 --- a/src/components/image-editor/ImageCanvasGenerationLayerModel.ts +++ b/src/components/image-editor/ImageCanvasGenerationLayerModel.ts @@ -438,9 +438,12 @@ export function createVideoResultLayer({ taskId: generated.taskId, objectKey: generated.objectKey, assetObjectId: generated.assetObjectId, + thumbnailSrc: generated.thumbnailSrc ?? generated.asset?.thumbnailSrc ?? undefined, sourceResourceId: sourceLayer?.resourceId, groupId: sourceLayer?.groupId, generationInputs, + durationSeconds: generated.durationSeconds, + generatedAssetSnapshot: generated.asset ?? undefined, }; } diff --git a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx index a9401c304..fe02a5cba 100644 --- a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx +++ b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx @@ -161,6 +161,7 @@ function SubmissionWorkflowHarness({ initialCharacterAnimationPanel = null, initialLayers = [createLayer()], projectId = undefined, + upsertGeneratedAsset = undefined, applyProjectSnapshot = undefined, onWalletBalanceMayHaveChanged = undefined, }: { @@ -170,6 +171,9 @@ function SubmissionWorkflowHarness({ initialCharacterAnimationPanel?: CharacterAnimationPanelState | null; initialLayers?: CanvasLayer[]; projectId?: string | null; + upsertGeneratedAsset?: Parameters< + typeof useImageCanvasGenerationSubmissionWorkflow + >[0]['upsertGeneratedAsset']; applyProjectSnapshot?: Parameters< typeof useImageCanvasGenerationSubmissionWorkflow >[0]['applyProjectSnapshot']; @@ -245,6 +249,7 @@ function SubmissionWorkflowHarness({ }, projectId, assetFolderId: projectId ? 'project' : undefined, + upsertGeneratedAsset, applyProjectSnapshot, onWalletBalanceMayHaveChanged, }); @@ -758,11 +763,39 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { }); it('submits video quick edits through the video API with the source video', async () => { + const applyProjectSnapshot = vi.fn(); + const upsertGeneratedAsset = vi.fn(); generateEditorVideoMock.mockResolvedValueOnce( - createVideoGenerated({ prompt: '把当前图改成雨天' }), + createVideoGenerated({ + prompt: '把当前图改成雨天', + project: { + projectId: 'editor-project-1', + title: '项目', + coverImageSrc: null, + viewport: { x: 0, y: 0, scale: 1 }, + layers: [], + resources: [], + createdAt: '2026-06-27T00:00:00.000Z', + updatedAt: '2026-06-27T00:00:00.000Z', + }, + asset: { + assetId: 'asset-video-quick-edit', + folderId: 'user-1:asset-folder:project', + label: '源视频 快速编辑', + imageSrc: 'https://assets.example.test/generated-video.mp4', + thumbnailSrc: 'https://assets.example.test/generated-video-cover.png', + width: 854, + height: 480, + sourceType: 'generated', + assetKind: 'video', + }, + }), ); render( { referenceVideoSrcs: [ 'generated-character-drafts/editor/videos/source.mp4', ], + assetFolderId: 'project', + assetLabel: '源视频 快速编辑', }), ); }); expect(generateEditorImageMock).not.toHaveBeenCalled(); expect(generateEditorCharacterAnimationMock).not.toHaveBeenCalled(); await waitFor(() => { - expect(screen.getByTestId('layers').textContent).toContain( - 'layer-video-1:源视频 快速编辑:resource-source:video:video:https://assets.example.test/generated-video.mp4', + expect(applyProjectSnapshot).toHaveBeenCalledWith( + expect.objectContaining({ projectId: 'editor-project-1' }), + ); + expect(upsertGeneratedAsset).toHaveBeenCalledWith( + expect.objectContaining({ + assetId: 'asset-video-quick-edit', + thumbnailSrc: 'https://assets.example.test/generated-video-cover.png', + }), ); }); }); diff --git a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts index 0e4cca2ec..8f6ee8bde 100644 --- a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts +++ b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts @@ -1136,6 +1136,8 @@ export function useImageCanvasGenerationSubmissionWorkflow({ generationInputs, sourceResourceId: quickEditSourceLayer.resourceId, assetKind: quickEditSourceLayer.assetKind, + assetFolderId, + assetLabel: `${quickEditSourceLayer.title} 快速编辑`, ...(projectId && quickEditCanvasCompletionPlaceholder ? { canvasCompletion: { @@ -1160,6 +1162,9 @@ export function useImageCanvasGenerationSubmissionWorkflow({ } if (generated.project && applyProjectSnapshot) { applyProjectSnapshot(generated.project); + if (generated.asset) { + upsertGeneratedAsset?.(generated.asset); + } return; } addVideoResultLayer( @@ -1324,6 +1329,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({ openCanvasGenerationDialog, projectId, assetFolderId, + upsertGeneratedAsset, quickEditPanel, quickEditSourceLayer, quickEditSelectionState, @@ -1476,6 +1482,8 @@ export function useImageCanvasGenerationSubmissionWorkflow({ ...submissionPlan.input, projectId, generationInputs: submissionPlan.result.generationInputs, + assetFolderId, + assetLabel: submissionPlan.result.title, ...(projectId && canvasCompletionPlaceholder ? { canvasCompletion: { @@ -1500,6 +1508,9 @@ export function useImageCanvasGenerationSubmissionWorkflow({ } if (generated.project && applyProjectSnapshot) { applyProjectSnapshot(generated.project); + if (generated.asset) { + upsertGeneratedAsset?.(generated.asset); + } return; } addVideoResultLayer( diff --git a/src/services/image-editor/editorProjectClient.test.ts b/src/services/image-editor/editorProjectClient.test.ts index 5aacb0da2..1a6efd309 100644 --- a/src/services/image-editor/editorProjectClient.test.ts +++ b/src/services/image-editor/editorProjectClient.test.ts @@ -1097,6 +1097,18 @@ describe('editorProjectClient', () => { taskId: 'video-task-1', durationSeconds: 5, resolution: '480p', + thumbnailSrc: '/generated-editor-videos/video-task-1/cover.png', + asset: { + assetId: 'asset-video-1', + folderId: 'user-1:asset-folder:project', + label: '生成视频 1', + imageSrc: '/generated-editor-videos/video-task-1/preview.mp4', + thumbnailSrc: '/generated-editor-videos/video-task-1/cover.png', + width: 1280, + height: 720, + sourceType: 'generated', + assetKind: 'video', + }, }); const result = await generateEditorVideo({ @@ -1107,9 +1119,18 @@ describe('editorProjectClient', () => { resolution: '480p', mode: 'std', sound: 'off', + assetFolderId: 'project', + assetLabel: '生成视频 1', }); expect(result.taskId).toBe('video-task-1'); + expect(result.thumbnailSrc).toBe( + '/generated-editor-videos/video-task-1/cover.png', + ); + expect(result.asset?.assetId).toBe('asset-video-1'); + expect(result.asset?.thumbnailSrc).toBe( + '/generated-editor-videos/video-task-1/cover.png', + ); expect(requestJsonMock).toHaveBeenCalledWith( '/api/editor/videos/generations', expect.objectContaining({ @@ -1124,6 +1145,8 @@ describe('editorProjectClient', () => { mode: 'std', sound: 'off', webSearchEnabled: true, + assetFolderId: 'project', + assetLabel: '生成视频 1', }), }), '生成视频失败', diff --git a/src/services/image-editor/editorProjectClient.ts b/src/services/image-editor/editorProjectClient.ts index 746018eb2..885bb2235 100644 --- a/src/services/image-editor/editorProjectClient.ts +++ b/src/services/image-editor/editorProjectClient.ts @@ -97,6 +97,7 @@ export type EditorAssetSnapshot = { folderId: string; label: string; imageSrc: string; + thumbnailSrc?: string | null; objectKey?: string | null; assetObjectId?: string | null; width: number; @@ -329,10 +330,13 @@ export type EditorVideoGenerationInput = { generationInputs?: EditorAssetGenerationInputs | null; sourceResourceId?: string | null; assetKind?: string | null; + assetFolderId?: string | null; + assetLabel?: string | null; }; export type EditorVideoGenerationResult = { videoSrc: string; + thumbnailSrc?: string | null; objectKey?: string | null; assetObjectId?: string | null; width: number; @@ -343,8 +347,10 @@ export type EditorVideoGenerationResult = { model: string; provider: string; taskId: string; + durationSeconds: number; priceMudPoints?: number; project?: EditorProjectSnapshot | null; + asset?: EditorAssetSnapshot | null; queueState?: ExternalGenerationJobStatusRecord | null; }; @@ -912,6 +918,8 @@ export async function generateEditorVideo(input: EditorVideoGenerationInput) { ? { sourceResourceId: input.sourceResourceId } : {}), ...(input.assetKind ? { assetKind: input.assetKind } : {}), + ...(input.assetFolderId ? { assetFolderId: input.assetFolderId } : {}), + ...(input.assetLabel ? { assetLabel: input.assetLabel } : {}), }), '生成视频失败', {