规范角色动作预览视频生成与序列字段持久化
清理角色动作预览视频生成输入,并确保正式序列字段仅在最终资源上持久化;新增相关单测并同步更新数据契约和架构文档。
This commit is contained in:
@@ -5811,7 +5811,7 @@
|
||||
- 持久化边界:`editor_project_resource` 与 `editor_asset` 表尾只保存 `image_sequence_frames_json` 与 `image_sequence_duration_ms`,默认均为 `None`;`editor_showcase_asset` 作为提交时冻结的审核与公开快照,同样在表尾保存这两个字段并从账号素材逐字段复制,旧行默认均为 `None`。正式帧对象和角色动作生成响应都不保存或返回 `frameIndex`,数组位置是唯一播放顺序;帧数取数组长度,FPS 由帧数和毫秒时长即时推导,不持久化 `frame_count`、`fps` 或通用 `duration_seconds`。后端处理抽帧和逐帧去背时仍保留内部 `frame_index`,仅用于乱序并发收口、OSS 命名、日志和错误定位;仓库内旧 helper 同步升级,不为它保留外部兼容字段。
|
||||
- 时长口径:`image_sequence_duration_ms` 只表示角色图片序列完整播放一次的毫秒时长,与音频 / 视频生成请求中的 `durationSeconds` 完全分离。角色动作与视频生成响应仍可携带各自既有的请求 / 结果级 `frameCount/fps/durationSeconds`;通用音视频秒数不进入资源 / 素材正式列、`EditorAsset`、`CanvasLayer` 或画布 layout,只允许把上传探测值或生成请求值格式化为用户可见字符串后写入 `generation_inputs_json.fields[]` 供素材详情展示。素材详情和画布 ZIP 用户可见元数据只透传实际存在的 `fields[]` 时长项;缺少时直接省略,不生成 `--:--` 占位。素材放置与工程恢复不做媒体探测,也不从 layout / resource 回退;音频播放只使用媒体 `loadedmetadata.duration`。不得为音频生成响应新增 `durationSeconds`,也不得把音视频秒数写入图片序列字段。前端角色动作播放器使用 `imageSequenceDurationMs / imageSequenceFrames.length`,Spine 导出时才换算秒数并推导 FPS。
|
||||
- 写入与复用边界:`assetKind=character-animation` 必须在 SpacetimeDB storage/procedure 边界同时提供至少两帧有效数组与大于 0 的图片序列毫秒时长;其他类别不得携带图片序列字段。同项目同源同媒体资源只允许 `None → Some` 单调回填,非空冲突失败关闭,延迟重试的 `updated_at` 取请求时间与既有时间的较大值。
|
||||
- 生成关联边界:角色动作生成响应同时返回已经持久化的最终 `resource` / `asset`;带项目上下文时前端结果图层必须直接使用 `resource.resourceId` 及其预览视频来源血缘,缺少 resource 直接失败。无项目放置也必须绑定响应中的正式账号素材,不能从响应 `frames` 构造无资产真相的本地动作结果。
|
||||
- 生成关联边界:角色动作生成响应同时返回已经持久化的最终 `resource` / `asset`;带项目上下文时前端结果图层必须直接使用 `resource.resourceId` 及其预览视频来源血缘,缺少 resource 直接失败。无项目放置也必须绑定响应中的正式账号素材,不能从响应 `frames` 构造无资产真相的本地动作结果。“动作(原始视频)”只是 `asset_kind = video` 的 provider 中间产物,其 `editor_project_resource` 与 `editor_asset` 两行都固定 `generation_inputs_json = NULL`,不得携带引用或开放参数复用;完整用户生成配方只保存在最终 `asset_kind = character-animation` 的序列资源 / 素材。角色动作规范化迁移对权威证明的 preview video 同样清空整份生成输入,但不改写其 `source_resource_id`。
|
||||
- repair 幂等边界:legacy 音频 repair 不再派生或回填任何资源级通用时长,因此新列上线前已完成 repair 的重放继续按原字段精确匹配;图片序列字段在音频行上均为 `None`。
|
||||
- 布局边界:`editor_project_resource` 的正式字段是角色动作唯一媒体真相;动作 layout 只保存资源引用和 placement,不保存帧、时长、预览、生成输入、资源元数据或顶层 `mediaType`。前端仍可从 `assetKind` 派生内部 `CanvasMediaType`,但后端响应清洗不得把普通 layer 的旧 `mediaType` 传回前端,也不得递归删除 `generationInputs.references[*].mediaType`。
|
||||
- 迁移边界(2026-08-04 收口):存量 `generation_inputs_json.characterAnimation`、已证明动作行的 helper 顶层 `frames/previewVideoPath/frameCount/fps/durationSeconds`、`screenColorHex`、正式帧 `frameIndex`、误标预览 MP4 和动作 layout 副本,由 `normalize_editor_character_animation_metadata_and_return` 按 `asset → project-resource → showcase → canvas` 一次性规范化。顶层字段只有先证明动作身份后才解释;普通图片 / 视频任意 JSON 中的同名字段不动。canvas dry-run 可消费前置 scope 的计划态结果,但 apply 仍要求前置 scope 已物理完成;同 task 候选先按权威对象规划分类并排除预览视频,只有唯一最终图片序列可补建资源。正式序列每帧必须按首帧 bucket 的稳定路径精确匹配同 owner / task 的图片 `editor_character_animation` 对象并补齐 `objectKey/assetObjectId`。迁移永不验证 layout 复制的 `sourceResourceId`,补建资源采用最终素材的 DB 血缘;新生成直接来源链仍严格校验。正式/旧版冲突、最终候选为零或多个、对象不匹配形成 blocker;apply 必须绑定同批 dry-run SHA-256,结束后全量复核零匹配、零 blocker。迁移完成后删除 api-server、admin、Web 与 helper 的 action fallback。
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -827,7 +827,12 @@ pub(crate) async fn generate_editor_character_animation_for_owner(
|
||||
asset_kind: Some(
|
||||
EDITOR_CHARACTER_ANIMATION_PREVIEW_RESOURCE_ASSET_KIND.to_string(),
|
||||
),
|
||||
generation_inputs: generation_inputs.clone(),
|
||||
// “动作(原始视频)”只是 provider 中间产物,不是可重放的角色动作结果:
|
||||
// 它固定以 asset_kind=video 保存,且 project resource / account asset 两行的
|
||||
// generation_inputs_json 都必须为 NULL。通用持久化 helper 会把这里传入的同一个
|
||||
// generation_inputs 同时写入两行,因此 preview 必须显式传 None;完整生成配方只
|
||||
// 保存在后面的最终 character-animation 序列资源 / 素材上。
|
||||
generation_inputs: None,
|
||||
thumbnail_src: None,
|
||||
generation_cost_mud_points: u64::from(normalized.price_mud_points),
|
||||
image_sequence_frames: None,
|
||||
@@ -6492,6 +6497,23 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_character_animation_persists_recipe_only_on_final_sequence() {
|
||||
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",
|
||||
&[
|
||||
"EDITOR_CHARACTER_ANIMATION_PREVIEW_RESOURCE_ASSET_KIND",
|
||||
"generation_inputs: None",
|
||||
"let provider_source_resource_id",
|
||||
"EDITOR_CHARACTER_ANIMATION_RESOURCE_ASSET_KIND",
|
||||
"generation_inputs: generation_inputs.clone()",
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_character_animation_bgfilter_frames_share_budget_helper_without_per_frame_timeout() {
|
||||
let source = include_str!("character_animation_assets.rs");
|
||||
|
||||
@@ -9371,18 +9371,23 @@ fn normalize_editor_character_animation_row(
|
||||
if !proven_action {
|
||||
return plan;
|
||||
}
|
||||
if generation_inputs_json.is_some() && generation_value.is_none() {
|
||||
let preview_without_frames =
|
||||
evidence.is_preview_video && formal_frames.is_none() && legacy_frames.is_none();
|
||||
if !preview_without_frames && generation_inputs_json.is_some() && generation_value.is_none() {
|
||||
plan.blocker = Some("generation_inputs_json 不是合法 JSON".to_string());
|
||||
return plan;
|
||||
}
|
||||
|
||||
let preview_without_frames =
|
||||
evidence.is_preview_video && formal_frames.is_none() && legacy_frames.is_none();
|
||||
if preview_without_frames {
|
||||
plan.asset_kind = Some("video".to_string());
|
||||
// 角色动作 preview video 只是 provider 中间产物;无论原值是完整配方、旧运行字段
|
||||
// 还是损坏 JSON,规范化后 resource / asset 的 generation_inputs_json 都必须为 NULL。
|
||||
// 最终 character-animation 行继续保留用户可重放的 fields / references。
|
||||
plan.generation_inputs_json = None;
|
||||
plan.image_sequence_frames_json = None;
|
||||
plan.image_sequence_duration_ms = None;
|
||||
plan.reclassified_preview = stored_asset_kind.as_deref() != Some("video");
|
||||
plan.cleaned_generation_inputs = generation_inputs_json.is_some();
|
||||
} else {
|
||||
let next_frames = match (formal_frames.as_ref(), legacy_frames.as_ref()) {
|
||||
(Some((formal, _)), Some((legacy, _))) => {
|
||||
@@ -9422,7 +9427,7 @@ fn normalize_editor_character_animation_row(
|
||||
plan.image_sequence_duration_ms = next_duration;
|
||||
}
|
||||
|
||||
if let Some(mut generation_value) = generation_value {
|
||||
if !preview_without_frames && let Some(mut generation_value) = generation_value {
|
||||
if let Some(inputs) = generation_value.as_object_mut() {
|
||||
let original = inputs.clone();
|
||||
inputs.remove("characterAnimation");
|
||||
@@ -11268,6 +11273,11 @@ mod tests {
|
||||
fn character_animation_normalization_reclassifies_verified_preview_video() {
|
||||
let inputs = json!({
|
||||
"fields": [{ "title": "动作", "value": "挥手" }],
|
||||
"references": [{
|
||||
"title": "角色图片",
|
||||
"refType": "project-resource",
|
||||
"refId": "resource-character"
|
||||
}],
|
||||
"screenColorHex": "#00ff00"
|
||||
});
|
||||
|
||||
@@ -11284,16 +11294,44 @@ mod tests {
|
||||
|
||||
assert!(plan.changed);
|
||||
assert!(plan.reclassified_preview);
|
||||
assert!(plan.cleaned_generation_inputs);
|
||||
assert_eq!(plan.asset_kind.as_deref(), Some("video"));
|
||||
assert!(plan.image_sequence_frames_json.is_none());
|
||||
assert!(plan.image_sequence_duration_ms.is_none());
|
||||
assert!(plan.generation_inputs_json.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn character_animation_normalization_clears_malformed_preview_inputs_but_not_regular_video() {
|
||||
let preview = normalize_editor_character_animation_row(
|
||||
Some(EDITOR_CHARACTER_ANIMATION_ASSET_KIND),
|
||||
Some("{broken-preview-inputs"),
|
||||
None,
|
||||
None,
|
||||
EditorCharacterAnimationObjectEvidence {
|
||||
is_action_object: true,
|
||||
is_preview_video: true,
|
||||
},
|
||||
);
|
||||
assert!(preview.changed);
|
||||
assert!(preview.blocker.is_none());
|
||||
assert!(preview.cleaned_generation_inputs);
|
||||
assert_eq!(preview.asset_kind.as_deref(), Some("video"));
|
||||
assert!(preview.generation_inputs_json.is_none());
|
||||
|
||||
let regular_video_inputs = r#"{"references":[{"refId":"video-itself"}]}"#;
|
||||
let regular_video = normalize_editor_character_animation_row(
|
||||
Some("video"),
|
||||
Some(regular_video_inputs),
|
||||
None,
|
||||
None,
|
||||
EditorCharacterAnimationObjectEvidence::default(),
|
||||
);
|
||||
assert!(!regular_video.changed);
|
||||
assert!(regular_video.blocker.is_none());
|
||||
assert_eq!(
|
||||
plan.generation_inputs_json.as_deref(),
|
||||
Some(
|
||||
json!({ "fields": [{ "title": "动作", "value": "挥手" }] })
|
||||
.to_string()
|
||||
.as_str()
|
||||
)
|
||||
regular_video.generation_inputs_json.as_deref(),
|
||||
Some(regular_video_inputs)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user