From c6dc6b0c502fc505b715d1f7ca59b8d5602bbbd7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Tue, 4 Aug 2026 10:49:33 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E8=A7=92=E8=89=B2=E5=8A=A8?= =?UTF-8?q?=E4=BD=9C=E7=B2=BE=E9=80=89=E9=80=90=E5=B8=A7=E8=AF=BB=E5=8F=96?= =?UTF-8?q?=E6=8E=88=E6=9D=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 生成端把每帧已确认的 objectKey 与 assetObjectId 写入正式序列载荷。 精选公开授权按稳定对象引用逐帧核验,避免仅首帧可读。 补充后端、SpacetimeDB 回归测试和契约文档。 --- docs/project-memory/shared-memory/pitfalls.md | 2 +- ...】server-rs与SpacetimeDB数据契约-2026-05-15.md | 2 +- .../src/character_animation_assets.rs | 80 +++++++++++++++++-- .../crates/shared-contracts/src/assets.rs | 14 ++++ .../src/editor_project_storage.rs | 58 ++++++++++++++ 5 files changed, 149 insertions(+), 7 deletions(-) diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 3059ede6b..22656f785 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -3912,7 +3912,7 @@ - 现象:精选接口已经返回 `imageSequenceFrames` 和正确的 5 / 6 秒成本,但创作主页或后台审核仍只显示首帧;接入播放器后又可能只有第一帧成功、后续帧换签返回 404。 - 原因:快照字段、展示 renderer 和私有对象授权是三道独立边界。公开 `imageSrc/objectKey` 只代表首帧,不能让前端自动获得完整帧集;顶层精选 exact grant 也不会自动覆盖其它帧对象。 -- 处理:公开精选模型必须把 `assetKind=character-animation` 映射到序列 renderer,并携带完整帧与毫秒时长;后台素材查询和精选审核共同透传同一字段并复用 `AdminEditorAssetMedia`。公开授权在 SpacetimeDB 同一事务快照中只按有效精选动作的同 owner 逐帧 `assetObjectId/objectKey` 匹配,不能放宽 generated 前缀。列表未交互时只读首帧,打开或激活动作预览后才逐帧换签,避免再次制造换签突发。 +- 处理:公开精选模型必须把 `assetKind=character-animation` 映射到序列 renderer,并携带完整帧与毫秒时长;后台素材查询和精选审核共同透传同一字段并复用 `AdminEditorAssetMedia`。生成端不能在 `ProcessedEditorCharacterAnimationFrame → EditorCharacterAnimationFramePayload` 收口时丢弃逐帧 `assetObjectId/objectKey`,正式序列 JSON 必须保留已确认对象的稳定引用。公开授权在 SpacetimeDB 同一事务快照中只按有效精选动作的同 owner 逐帧 `assetObjectId/objectKey` 匹配,不能放宽 generated 前缀。列表未交互时只读首帧,打开或激活动作预览后才逐帧换签,避免再次制造换签突发。 - 验证:模型 / 组件测试覆盖 4 / 5 / 6 秒动作、损坏序列不回退 PNG、后台两页共用播放器和未激活列表不逐帧请求;SpacetimeDB 测试覆盖主对象、每帧对象、无关对象、跨 owner 与取消展示后的授权撤销。真实浏览器和端到端验收由人工单独执行,不把 unit / component 结果写成 E2E PASS。 ## 可复用资源回填必须保持时间戳单调 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index ddd68232a..26d74e67b 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -671,7 +671,7 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复 - Rust 结构体:`EditorAsset` - 源码:`server-rs/crates/spacetime-module/src/editor_project_storage.rs` -- 说明:图片画布账号级素材表,保存用户上传 / 生成素材的名称、文件夹、媒体读取地址、可选封面 `thumbnail_src`、OSS 引用、尺寸、来源类型、prompt、provider、真实操作 `task_id`、可选后台归组 `group_task_id`、拆分批次预期数量 `group_task_expected_asset_count`、`asset_kind`、`generation_inputs_json`、可选 `source_resource_id`、`generation_cost_mud_points` 和序列媒体结果字段。`asset_kind` 是唯一权威媒体类别;`image_sequence_frames_json` 与 `image_sequence_duration_ms` 保存角色动作正式结果,数组长度派生帧数、数组顺序决定播放顺序、FPS 按需推导,`generation_inputs_json` 只保存用户可见生成 / 重放输入。归组字段只用于稳定派生任务的后台分组,不替代 `task_id`;批次是否初始完整以独立完成事实为准,不按当前剩余素材数反推。素材在同一账号的所有项目中可见;图片 / 图标 / UI 提取等生成 BFF 在请求携带 `asset_folder_id` 时负责创建账号级生成素材并返回 asset 快照,若同次生成也创建了 `editor_project_resource`,则把该 `resource_id` 写入 `source_resource_id`。角色动作生成把原始绿幕预览视频作为独立 `asset_kind = video` 素材保存,再把最终帧序列作为一条 `asset_kind = character-animation` 素材入库:首帧写入 `image_src` / `thumbnail_src`,完整帧数组与图片序列毫秒时长写入两个正式媒体结果字段;存在项目上下文时,最终动作的 `source_resource_id` 指向预览视频资源。不把每帧拆成独立素材,也不重复保存预览路径。生成视频会抽取首帧封面写入 `thumbnail_src`,素材库和再次放入画布时用它作为 video poster。素材库快照通过 `asset_id` 回查对应 `editor_showcase_asset`,供左侧素材菜单展示 `pending` / `approved` / `rejected` 审核状态;公开事实不落在账号素材表,素材库只发起提交审核。素材放入画布时复制为 `editor_project_resource` 并由图层引用 resourceId,画布从 resource / asset 级元数据恢复素材类别、媒体结果和用户可见生成输入快照。 +- 说明:图片画布账号级素材表,保存用户上传 / 生成素材的名称、文件夹、媒体读取地址、可选封面 `thumbnail_src`、OSS 引用、尺寸、来源类型、prompt、provider、真实操作 `task_id`、可选后台归组 `group_task_id`、拆分批次预期数量 `group_task_expected_asset_count`、`asset_kind`、`generation_inputs_json`、可选 `source_resource_id`、`generation_cost_mud_points` 和序列媒体结果字段。`asset_kind` 是唯一权威媒体类别;`image_sequence_frames_json` 与 `image_sequence_duration_ms` 保存角色动作正式结果,数组长度派生帧数、数组顺序决定播放顺序、FPS 按需推导,`generation_inputs_json` 只保存用户可见生成 / 重放输入。归组字段只用于稳定派生任务的后台分组,不替代 `task_id`;批次是否初始完整以独立完成事实为准,不按当前剩余素材数反推。素材在同一账号的所有项目中可见;图片 / 图标 / UI 提取等生成 BFF 在请求携带 `asset_folder_id` 时负责创建账号级生成素材并返回 asset 快照,若同次生成也创建了 `editor_project_resource`,则把该 `resource_id` 写入 `source_resource_id`。角色动作生成把原始绿幕预览视频作为独立 `asset_kind = video` 素材保存,再把最终帧序列作为一条 `asset_kind = character-animation` 素材入库:首帧写入 `image_src` / `thumbnail_src`,完整帧数组与图片序列毫秒时长写入两个正式媒体结果字段;生成端确认每个最终帧对象后,必须把该帧的 `objectKey` 与 `assetObjectId` 同时写入正式帧 payload,不能只在内部处理中暂存或仅保留首帧引用;存在项目上下文时,最终动作的 `source_resource_id` 指向预览视频资源。不把每帧拆成独立素材,也不重复保存预览路径。生成视频会抽取首帧封面写入 `thumbnail_src`,素材库和再次放入画布时用它作为 video poster。素材库快照通过 `asset_id` 回查对应 `editor_showcase_asset`,供左侧素材菜单展示 `pending` / `approved` / `rejected` 审核状态;公开事实不落在账号素材表,素材库只发起提交审核。素材放入画布时复制为 `editor_project_resource` 并由图层引用 resourceId,画布从 resource / asset 级元数据恢复素材类别、媒体结果和用户可见生成输入快照。 - 索引:`by_editor_asset_owner_user_id`、`by_editor_asset_folder_id`。 ### `editor_asset_group_source_provenance` 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 f570901d8..b2b01a3d1 100644 --- a/server-rs/crates/api-server/src/character_animation_assets.rs +++ b/server-rs/crates/api-server/src/character_animation_assets.rs @@ -2372,6 +2372,12 @@ async fn extract_and_persist_editor_character_animation_frames( return Err(error); } + build_persisted_editor_character_animation_frame_set(frame_payloads) +} + +fn build_persisted_editor_character_animation_frame_set( + frame_payloads: Vec, +) -> Result { let (first_frame_object_key, first_frame_asset_object_id) = frame_payloads .first() .map(|frame| (frame.object_key.clone(), frame.asset_object_id.clone())) @@ -2492,16 +2498,34 @@ async fn process_and_persist_editor_character_animation_frame( Ok(ProcessedEditorCharacterAnimationFrame { frame_index, - payload: EditorCharacterAnimationFramePayload { - image_src: put_result.legacy_public_path, - width: frame_width, - height: frame_height, - }, + payload: build_editor_character_animation_frame_payload( + put_result.legacy_public_path, + put_result.object_key.clone(), + confirmed.record.asset_object_id.clone(), + frame_width, + frame_height, + ), object_key: put_result.object_key, asset_object_id: confirmed.record.asset_object_id, }) } +fn build_editor_character_animation_frame_payload( + image_src: String, + object_key: String, + asset_object_id: String, + width: u32, + height: u32, +) -> EditorCharacterAnimationFramePayload { + EditorCharacterAnimationFramePayload { + image_src, + object_key: Some(object_key), + asset_object_id: Some(asset_object_id), + width, + height, + } +} + async fn publish_animation_set( state: &AppState, owner_user_id: &str, @@ -6626,6 +6650,52 @@ mod tests { ); } + #[test] + fn generated_character_animation_frame_set_retains_each_stable_asset_reference() { + let frame_set = build_persisted_editor_character_animation_frame_set(vec![ + ProcessedEditorCharacterAnimationFrame { + frame_index: 0, + payload: build_editor_character_animation_frame_payload( + "/generated-animations/editor/layer/task/frame01.png".to_string(), + "generated-animations/editor/layer/task/frame01.png".to_string(), + "asset-object-frame01".to_string(), + 192, + 256, + ), + object_key: "generated-animations/editor/layer/task/frame01.png".to_string(), + asset_object_id: "asset-object-frame01".to_string(), + }, + ProcessedEditorCharacterAnimationFrame { + frame_index: 1, + payload: build_editor_character_animation_frame_payload( + "/generated-animations/editor/layer/task/frame02.png".to_string(), + "generated-animations/editor/layer/task/frame02.png".to_string(), + "asset-object-frame02".to_string(), + 192, + 256, + ), + object_key: "generated-animations/editor/layer/task/frame02.png".to_string(), + asset_object_id: "asset-object-frame02".to_string(), + }, + ]) + .expect("generated frame set should retain stable references"); + + let frames = serde_json::to_value(frame_set.frames) + .expect("generated frame payloads should serialize"); + assert_eq!( + frames[0]["objectKey"], + json!("generated-animations/editor/layer/task/frame01.png") + ); + assert_eq!(frames[0]["assetObjectId"], json!("asset-object-frame01")); + assert_eq!( + frames[1]["objectKey"], + json!("generated-animations/editor/layer/task/frame02.png") + ); + assert_eq!(frames[1]["assetObjectId"], json!("asset-object-frame02")); + assert!(frames[0].get("frameIndex").is_none()); + assert!(frames[1].get("frameIndex").is_none()); + } + #[test] fn editor_character_animation_canvas_completion_uses_the_final_resource_lineage() { let source = include_str!("character_animation_assets.rs"); diff --git a/server-rs/crates/shared-contracts/src/assets.rs b/server-rs/crates/shared-contracts/src/assets.rs index dcdef9af5..ba37e17d4 100644 --- a/server-rs/crates/shared-contracts/src/assets.rs +++ b/server-rs/crates/shared-contracts/src/assets.rs @@ -342,6 +342,10 @@ pub struct EditorCharacterAnimationGenerateRequest { #[serde(rename_all = "camelCase")] pub struct EditorCharacterAnimationFramePayload { pub image_src: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub object_key: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub asset_object_id: Option, pub width: u32, pub height: u32, } @@ -1146,6 +1150,8 @@ mod tests { preview_video_path: "/generated-character-drafts/editor/layer/preview.mp4".to_string(), frames: vec![EditorCharacterAnimationFramePayload { image_src: "/generated-animations/editor/layer/frame01.png".to_string(), + object_key: Some("generated-animations/editor/layer/frame01.png".to_string()), + asset_object_id: Some("asset-object-frame01".to_string()), width: 768, height: 1024, }], @@ -1185,6 +1191,14 @@ mod tests { payload["frames"][0]["imageSrc"], json!("/generated-animations/editor/layer/frame01.png") ); + assert_eq!( + payload["frames"][0]["objectKey"], + json!("generated-animations/editor/layer/frame01.png") + ); + assert_eq!( + payload["frames"][0]["assetObjectId"], + json!("asset-object-frame01") + ); assert!(payload["frames"][0].get("frameIndex").is_none()); assert_eq!(payload["fps"], json!(8)); } 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 3c96f34c7..66177eba5 100644 --- a/server-rs/crates/spacetime-module/src/editor_project_storage.rs +++ b/server-rs/crates/spacetime-module/src/editor_project_storage.rs @@ -11630,6 +11630,64 @@ mod tests { )); } + #[test] + fn generated_character_animation_payload_grants_each_frame_after_showcase_snapshot() { + let generated_frames = json!([ + { + "imageSrc": "/generated/action/frame-01.png", + "objectKey": "generated/action/frame-01.png", + "assetObjectId": "asset-object-frame-01", + "width": 192, + "height": 256 + }, + { + "imageSrc": "/generated/action/frame-02.png", + "objectKey": "generated/action/frame-02.png", + "assetObjectId": "asset-object-frame-02", + "width": 192, + "height": 256 + } + ]); + let frames_json = + serde_json::to_string(&generated_frames).expect("generated frames should serialize"); + let submitted_at = Timestamp::from_micros_since_unix_epoch(2_000_000); + let mut asset = generated_editor_asset( + "action-asset", + "action-task", + "character-animation", + "generated/action/frame-01.png", + 10, + 1, + ); + asset.asset_object_id = Some("asset-object-frame-01".to_string()); + asset.image_sequence_frames_json = Some(frames_json.clone()); + asset.image_sequence_duration_ms = Some(5_000); + + let mut showcase = build_pending_editor_showcase_asset( + asset, + "editor-showcase:action-asset".to_string(), + submitted_at, + ); + showcase.review_status = EDITOR_SHOWCASE_STATUS_APPROVED.to_string(); + showcase.display_enabled = true; + showcase.refund_completed_at = Some(submitted_at); + showcase.approved_at = Some(submitted_at); + + assert_eq!( + showcase.image_sequence_frames_json.as_deref(), + Some(frames_json.as_str()) + ); + for (asset_object_id, object_key) in [ + ("asset-object-frame-01", "generated/action/frame-01.png"), + ("asset-object-frame-02", "generated/action/frame-02.png"), + ] { + assert!(public_showcase_asset_grants_asset_object( + &showcase, + &showcase_asset_object("user-1", asset_object_id, object_key), + )); + } + } + #[test] fn public_showcase_character_action_frame_grant_fails_closed_and_revokes_with_display() { let mut showcase = public_showcase_asset();