修复角色动作精选逐帧读取授权

生成端把每帧已确认的 objectKey 与 assetObjectId 写入正式序列载荷。
精选公开授权按稳定对象引用逐帧核验,避免仅首帧可读。
补充后端、SpacetimeDB 回归测试和契约文档。
This commit is contained in:
2026-08-04 10:49:33 +08:00
parent 471ab58ec2
commit c6dc6b0c50
5 changed files with 149 additions and 7 deletions
@@ -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。
## 可复用资源回填必须保持时间戳单调
@@ -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`
@@ -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<ProcessedEditorCharacterAnimationFrame>,
) -> Result<PersistedEditorCharacterAnimationFrameSet, AppError> {
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");
@@ -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<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub asset_object_id: Option<String>,
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));
}
@@ -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();