修复角色动作精选逐帧读取授权
生成端把每帧已确认的 objectKey 与 assetObjectId 写入正式序列载荷。 精选公开授权按稳定对象引用逐帧核验,避免仅首帧可读。 补充后端、SpacetimeDB 回归测试和契约文档。
This commit is contained in:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user