统一音视频时长展示元数据

删除 EditorAsset、CanvasLayer 与画布布局中的通用 durationSeconds 链路。
将上传及生成请求时长统一写入 generationInputs.fields,并由服务端去重补齐。
音频播放只使用 loadedmetadata,素材详情、导出和精选成本读取展示字段。
补充前后端回归测试并同步架构文档与共享决策。
This commit is contained in:
2026-08-05 15:22:24 +08:00
parent a3d3a1b8ba
commit dcf77ad7a8
34 changed files with 295 additions and 123 deletions
@@ -5809,7 +5809,7 @@
- 决策:`assetKind` 是编辑器资源和素材唯一权威媒体类别;不新增或返回 `mediaType``character-animation` 渲染为序列帧,`video` 渲染为视频,`audio/sound-effect/background-music` 渲染为音频,其余类别渲染为图片。前端内部可保留派生的 `CanvasMediaType` 选择渲染器,但不能把它作为后端事实。
- 持久化边界:`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`,音视频画布布局也可保留既有运行时秒数;不得为音频生成响应新增 `durationSeconds`,也不得把任何音视频秒数写入资源 / 素材或图片序列字段。前端播放器使用 `imageSequenceDurationMs / imageSequenceFrames.length`,Spine 导出时才换算秒数并推导 FPS。
- 时长口径:`image_sequence_duration_ms` 只表示角色图片序列完整播放一次的毫秒时长,与音频 / 视频生成请求中的 `durationSeconds` 完全分离。角色动作与视频生成响应仍可携带各自既有的请求 / 结果级 `frameCount/fps/durationSeconds`;通用音视频秒数不进入资源 / 素材正式列、`EditorAsset``CanvasLayer` 或画布 layout,只允许把上传探测值或生成请求值格式化为用户可见字符串后写入 `generation_inputs_json.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` 构造无资产真相的本地动作结果。
- repair 幂等边界:legacy 音频 repair 不再派生或回填任何资源级通用时长,因此新列上线前已完成 repair 的重放继续按原字段精确匹配;图片序列字段在音频行上均为 `None`
@@ -429,7 +429,7 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复
- `asset_kind` 是资源 / 素材唯一权威媒体类别,不新增或返回 `media_type` / `mediaType`。角色动作预览 MP4 使用 `asset_kind = video`,最终透明帧集使用 `asset_kind = character-animation`;前端据此派生具体渲染器。
- `editor_project_resource``editor_asset` 表尾只追加 `image_sequence_frames_json: Option<String>``image_sequence_duration_ms: Option<u64>``editor_showcase_asset` 作为提交时冻结的审核与公开快照,也在表尾追加并从账号素材复制相同两字段。前者保存完整有效帧数组,数组位置是唯一播放顺序,正式帧对象不保存或返回 `frameIndex`;后者只表示该图片序列完整播放一次的毫秒时长。帧数始终取数组长度,FPS 在播放或导出时即时推导,不持久化 `frame_count``fps`
- 图片序列时长不能复用音频 / 视频生成请求的 `durationSeconds`,资源和素材也不保存通用 `duration_seconds`。角色动作与视频生成响应保留各自既有的请求 / 结果级秒数音频 / 视频图层的既有运行时秒数可留在画布布局中;音频生成响应不得新增 `durationSeconds`,音频实际时长仍可由 `<audio>``loadedmetadata.duration` 获取,但这些值都不得映射到资源、素材或图片序列字段。
- 图片序列时长不能复用音频 / 视频生成请求的 `durationSeconds`,资源和素材也不保存通用 `duration_seconds`。角色动作与视频生成响应保留各自既有的请求 / 结果级秒数音频 / 视频的用户可见时长只作为字符串展示项写入 `generation_inputs_json.fields[]`,上传媒体使用本次上传探测值,带时长选项的生成任务使用用户提交值,不再复制到 `EditorAsset``CanvasLayer` 或画布 layout,也不在素材放置 / 工程恢复时探测或从 layout、resource 做双来源回退。音频播放控件只信任 `<audio>``loadedmetadata.duration`,展示字段不得参与播放、裁切或变速。音频生成响应不得新增 `durationSeconds`,这些展示值也不得映射到资源、素材正式列或图片序列字段。
- 角色动作 worker 在透明帧全部持久化后创建最终项目资源与账号素材,并写入帧数组与 `image_sequence_duration_ms`;生成响应可继续返回请求 / 结果层面的 `frameCount``fps``durationSeconds`,但它们不是持久化真相。预览视频仍作为独立 `asset_kind = video` 中间素材保留并承担生成成本,最终派生素材成本为 0。
- `create_editor_project_resource``create_editor_asset` 和同源资源回填必须在 procedure/storage 边界验证最终状态:`asset_kind = character-animation` 必须同时包含至少两帧的有效数组和大于 0 的 `image_sequence_duration_ms`;其他类别不得携带任一图片序列字段。同源资源只允许从 `None` 单调补齐字段,非空冲突失败关闭,补写后的 `updated_at` 不得早于既有时间。
- `generation_inputs_json` 只保存用户生成 / 重放输入,例如 `fields``references``artSpec`;动作输出和内部背景决策色不得再写入该 JSON。背景决策审计继续走独立审计链路。
@@ -74,7 +74,7 @@ use crate::{
build_editor_canvas_resource_placement_item, complete_editor_canvas_generation_with_items,
persist_editor_generated_media_asset,
remove_editor_generated_screen_background_with_bgfilter,
resolve_editor_reference_object_key_for_owner,
resolve_editor_reference_object_key_for_owner, with_editor_media_duration_generation_input,
},
editor_screen_background_decision::{
EditorScreenBackgroundDecisionInput, EditorScreenBackgroundDecisionKind,
@@ -1088,6 +1088,8 @@ pub(crate) async fn generate_editor_video_for_owner(
})?;
let normalized = normalize_editor_video_request_with_pricing(payload, &pricing)
.map_err(|error| editor_video_error_response(&request_context, error))?;
let generation_inputs =
with_editor_media_duration_generation_input(generation_inputs, normalized.duration_seconds);
let settings = require_editor_video_settings(&state, normalized.model.as_str())
.map_err(|error| editor_video_error_response(&request_context, error))?;
let http_client = build_upstream_http_client(settings.ark.request_timeout_ms)
@@ -1171,7 +1173,6 @@ pub(crate) async fn generate_editor_video_for_owner(
task_id: Some(task_id.clone()),
object_key: generated.object_key.clone(),
asset_object_id: generated.asset_object_id.clone(),
duration_seconds: Some(normalized.duration_seconds),
source_resource_id,
asset_kind: Some(asset_kind.clone()),
generation_inputs,
@@ -671,7 +671,6 @@ pub(crate) struct EditorCanvasGeneratedLayerInput {
pub(crate) task_id: Option<String>,
pub(crate) object_key: Option<String>,
pub(crate) asset_object_id: Option<String>,
pub(crate) duration_seconds: Option<u32>,
pub(crate) source_resource_id: Option<String>,
pub(crate) asset_kind: Option<String>,
pub(crate) generation_inputs: Option<Value>,
@@ -8104,7 +8103,6 @@ pub(crate) fn build_editor_canvas_generated_layer_item(
"taskId": input.task_id,
"objectKey": input.object_key,
"assetObjectId": input.asset_object_id,
"durationSeconds": input.duration_seconds,
"sourceResourceId": input.source_resource_id,
"assetKind": input.asset_kind,
"generationInputs": input.generation_inputs,
@@ -8535,6 +8533,41 @@ pub(crate) fn serialize_editor_generation_inputs(
serialize_editor_asset_metadata(metadata)
}
pub(crate) fn with_editor_media_duration_generation_input(
metadata: Option<Value>,
duration_seconds: u32,
) -> Option<Value> {
if duration_seconds == 0 {
return metadata;
}
let mut object = match metadata {
Some(Value::Object(object)) => object,
None => serde_json::Map::new(),
Some(value) => return Some(value),
};
let fields = object
.entry("fields".to_string())
.or_insert_with(|| Value::Array(Vec::new()));
let Some(fields) = fields.as_array_mut() else {
return Some(Value::Object(object));
};
fields.retain(|field| {
field
.get("title")
.and_then(Value::as_str)
.map(str::trim)
.is_none_or(|title| title != "时长" && title != "duration")
});
fields.push(json!({
"title": "时长",
"value": format!("{duration_seconds}秒"),
}));
object
.entry("references".to_string())
.or_insert_with(|| Value::Array(Vec::new()));
Some(Value::Object(object))
}
pub(crate) fn serialize_editor_image_sequence_frames(
frames: Option<Value>,
) -> Result<Option<String>, AppError> {
@@ -10160,6 +10193,68 @@ mod tests {
}
}
#[test]
fn generated_media_canvas_item_keeps_duration_only_in_generation_inputs() {
let generation_inputs = with_editor_media_duration_generation_input(
Some(json!({
"fields": [
{ "title": "视频描述", "value": "角色挥手" },
{ "title": "duration", "value": "4秒" }
],
"references": []
})),
5,
);
let completion = EditorCanvasGenerationCompletionRequest {
dialog_id: Some("dialog-video".to_string()),
title: "生成视频".to_string(),
placeholder: EditorCanvasGenerationPlaceholderPayload {
x: 120.0,
y: 80.0,
width: 560.0,
height: 315.0,
original_width: 1280.0,
original_height: 720.0,
},
};
let item = build_editor_canvas_generated_layer_item(
&completion,
EditorCanvasGeneratedLayerInput {
layer_id: "layer-video".to_string(),
resource_id: "resource-video".to_string(),
title: "生成视频".to_string(),
src: "/generated/video.mp4".to_string(),
thumbnail_src: None,
original_width: 1280,
original_height: 720,
display_width: 560.0,
display_height: 315.0,
source_type: "generated".to_string(),
prompt: Some("角色挥手".to_string()),
actual_prompt: None,
model: Some("seedance2.0-fast".to_string()),
provider: Some("VectorEngine".to_string()),
task_id: Some("task-video".to_string()),
object_key: Some("generated/video.mp4".to_string()),
asset_object_id: Some("asset-object-video".to_string()),
source_resource_id: None,
asset_kind: Some("video".to_string()),
generation_inputs,
},
);
assert!(item.get("durationSeconds").is_none());
assert_eq!(
item["generationInputs"]["fields"].as_array().map(Vec::len),
Some(2)
);
assert_eq!(
item["generationInputs"]["fields"][1]["title"],
json!("时长")
);
assert_eq!(item["generationInputs"]["fields"][1]["value"], json!("5秒"));
}
#[test]
fn editor_project_revision_conflicts_map_to_http_conflict() {
let error = map_editor_project_error(SpacetimeClientError::Procedure(
@@ -23,7 +23,7 @@ use crate::{
editor_project::{
EditorCanvasGeneratedLayerInput, PersistEditorGeneratedAssetRequest,
build_editor_canvas_generated_layer_item, complete_editor_canvas_generation_with_items,
persist_editor_generated_media_asset,
persist_editor_generated_media_asset, with_editor_media_duration_generation_input,
},
http_error::AppError,
request_context::RequestContext,
@@ -220,6 +220,10 @@ pub(crate) async fn generate_editor_sound_effect_for_owner(
.map_err(|error| error.into_response_with_context(Some(&request_context)))?;
let normalized = normalize_editor_sound_effect_request_with_pricing(payload, &pricing)
.map_err(|error| error.into_response_with_context(Some(&request_context)))?;
let generation_inputs = with_editor_media_duration_generation_input(
generation_inputs,
u32::from(normalized.duration),
);
let settings = require_vector_engine_audio_settings(&state)
.map_err(|error| error.into_response_with_context(Some(&request_context)))?;
let http_client = platform_audio::build_vector_engine_audio_http_client(&settings)
@@ -316,7 +320,6 @@ pub(crate) async fn generate_editor_sound_effect_for_owner(
task_id: Some(generated.task_id.clone()),
object_key: generated.object_key.clone(),
asset_object_id: generated.asset_object_id.clone(),
duration_seconds: Some(u32::from(normalized.duration)),
source_resource_id: None,
asset_kind: Some("sound-effect".to_string()),
generation_inputs,
@@ -556,7 +559,6 @@ pub(crate) async fn generate_editor_background_music_for_owner(
task_id: Some(generated.task_id.clone()),
object_key: generated.object_key.clone(),
asset_object_id: generated.asset_object_id.clone(),
duration_seconds: None,
source_resource_id: None,
asset_kind: Some("background-music".to_string()),
generation_inputs,
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest';
import type { EditorProjectResourceSnapshot } from '../../services/image-editor/editorProjectClient';
import { calculateEditorVideoPrice } from '../image-editor/ImageCanvasGenerationModel';
import { buildCreationShowcaseItems } from './creationShowcaseModel';
function createResource(
@@ -544,6 +545,34 @@ describe('creationShowcaseModel', () => {
expect(items[0]?.cost).toBe('5泥点');
});
it('infers video cost from the duration field instead of legacy top-level metadata', () => {
const items = buildCreationShowcaseItems({
activeTab: 'all',
projectResources: [
createResource({
resourceId: 'video-1',
label: '角色宣传片',
assetKind: 'video',
imageSrc: '/generated-editor-videos/video.mp4',
model: 'seedance2.0-fast',
generationInputs: {
fields: [
{ title: '清晰度', value: '720p' },
{ title: '时长', value: '6秒' },
],
references: [],
durationSeconds: 4,
},
}),
],
});
expect(items).toHaveLength(1);
expect(items[0]?.cost).toBe(
`${calculateEditorVideoPrice('seedance2.0-fast', '720p', 6)}泥点`,
);
});
it('keeps character animation videos out of the marketing tab', () => {
const items = buildCreationShowcaseItems({
activeTab: 'marketing',
@@ -819,7 +819,7 @@ function inferAssetCost(asset: EditorAssetSnapshot) {
inferVideoResolution(asset, ['480p', '720p', '1080p'], '480p') as
'480p' | '720p' | '1080p',
normalizeInt(
asset.generationInputs?.durationSeconds,
inferMediaDurationSeconds(asset),
DEFAULT_VIDEO_DURATION_SECONDS,
4,
15,
@@ -832,6 +832,15 @@ function inferAssetCost(asset: EditorAssetSnapshot) {
return null;
}
function inferMediaDurationSeconds(asset: EditorAssetSnapshot) {
const durationField = asset.generationInputs?.fields.find((field) => {
const title = field.title.trim().toLowerCase();
return title === '时长' || title === 'duration';
});
const matchedValue = durationField?.value.match(/\d+(?:\.\d+)?/u)?.[0];
return matchedValue ? Number(matchedValue) : undefined;
}
function inferVideoResolution(
asset: EditorAssetSnapshot,
allowedValues: string[],
@@ -737,7 +737,7 @@ describe('ImageCanvasEditorModel', () => {
});
});
it('hydrates audio media type and sound effect asset kind from saved layout', () => {
it('hydrates audio display duration only from resource generation inputs', () => {
const hydrated = hydrateLayer(
{
layerId: 'layer-audio',
@@ -753,13 +753,16 @@ describe('ImageCanvasEditorModel', () => {
sourceType: 'generated',
mediaType: 'audio',
assetKind: 'sound-effect',
durationSeconds: 3.5,
},
new Map([
[
'resource-audio',
{
imageSrc: '/generated-character-drafts/editor-audios/sfx.mp3',
generationInputs: {
fields: [{ title: '时长', value: '3.5秒' }],
references: [],
},
},
],
]),
@@ -770,8 +773,12 @@ describe('ImageCanvasEditorModel', () => {
src: '/generated-character-drafts/editor-audios/sfx.mp3',
mediaType: 'audio',
assetKind: 'sound-effect',
durationSeconds: 3.5,
generationInputs: {
fields: [{ title: '时长', value: '3.5秒' }],
references: [],
},
});
expect(hydrated).not.toHaveProperty('durationSeconds');
});
it('hydrates character animation sequence fields from the project resource', () => {
@@ -205,7 +205,6 @@ export function createLayerFromAsset(
objectKey: asset.objectKey,
assetObjectId: asset.assetObjectId,
sourceResourceId: asset.sourceResourceId,
durationSeconds: asset.durationSeconds,
sourceAssetId: asset.id,
resourceAssetKind: asset.assetKind ?? assetKind ?? null,
assetKindOverride: null,
@@ -335,7 +334,6 @@ export function serializeLayer(layer: CanvasLayer): EditorProjectLayerSnapshot {
taskId: layer.taskId,
objectKey: layer.objectKey,
assetObjectId: layer.assetObjectId,
durationSeconds: layer.durationSeconds,
sourceResourceId: layer.sourceResourceId,
sourceAssetId: layer.sourceAssetId,
}),
@@ -875,10 +873,6 @@ export function hydrateLayer(
? stringOrNull(resource?.assetObjectId)
: (stringOrNull(snapshot.assetObjectId) ??
stringOrNull(resource?.assetObjectId)),
durationSeconds: isFormalCharacterAnimation
? undefined
: (audioDurationOrNull(snapshot.durationSeconds) ??
audioDurationOrNull(resource?.durationSeconds)),
imageSequenceDurationMs,
sourceResourceId: isFormalCharacterAnimation
? stringOrNull(resource?.sourceResourceId)
@@ -1207,7 +1201,6 @@ export type CanvasLayerResourceMetadata = {
width?: number | null;
height?: number | null;
sourceType?: string | null;
durationSeconds?: number | null;
imageSequenceDurationMs?: number | null;
sourceResourceId?: string | null;
assetKind?: string | null;
@@ -60,7 +60,6 @@ export type EditorAsset = {
showcaseSubmitError?: string | null;
assetKind?: CanvasAssetKind | null;
generationInputs?: CanvasGenerationInputs | null;
durationSeconds?: number;
imageSequenceFrames?: EditorImageSequenceFrameResult[];
imageSequenceDurationMs?: number;
uploadStatus?: 'uploading' | 'failed';
@@ -117,7 +116,6 @@ export type CanvasLayer = {
assetKindOverride?: CanvasAssetKind | null;
assetKind?: CanvasAssetKind | null;
generationInputs?: CanvasGenerationInputs | null;
durationSeconds?: number;
imageSequenceFrames?: EditorImageSequenceFrameResult[];
imageSequenceDurationMs?: number;
previewVideoPath?: string | null;
@@ -1451,7 +1451,7 @@ describe('ImageCanvasEditorView', () => {
fields: [
{ title: '音效提示词', value: '金币跳出音' },
{ title: 'model', value: 'audio1.0' },
{ title: 'duration', value: '8秒' },
{ title: '时长', value: '8秒' },
],
references: [
{
@@ -194,7 +194,7 @@ describe('ImageCanvasExportModel', () => {
},
{ title: '用户输入', value: '用户填写的视觉要求' },
{ title: '游戏分类', value: '游戏音效' },
{ title: 'duration', value: '5秒' },
{ title: '时长', value: '5秒' },
{ title: '处理模型', value: 'birefnet' },
],
references: [],
@@ -207,7 +207,7 @@ describe('ImageCanvasExportModel', () => {
fields: [
{ title: '用户输入', value: '用户填写的视觉要求' },
{ title: '游戏分类', value: '游戏音效' },
{ title: 'duration', value: '5秒' },
{ title: '时长', value: '5秒' },
],
references: [],
});
@@ -12,7 +12,6 @@ import {
isEditorUserVisibleGenerationInputField,
UI_DESIGN_ASSET_EXTRACTION_PROMPT,
} from './ImageCanvasGenerationModel';
import { formatCanvasDurationMetric } from './ImageCanvasMediaModel';
export function sanitizeExportFilePart(value: string, fallback: string) {
const safeValue = value
@@ -563,20 +562,12 @@ export function buildLayerVisibleExportMetadata(layer: CanvasLayer) {
object: layer.objectKey ?? layer.assetObjectId ?? '-',
};
if (layer.mediaType === 'audio') {
return {
...base,
duration: formatCanvasDurationMetric(layer.durationSeconds).replace(
/^\s*/u,
'',
),
};
}
return {
...base,
resolution: `${layer.originalWidth} x ${layer.originalHeight} px`,
};
return layer.mediaType === 'audio'
? base
: {
...base,
resolution: `${layer.originalWidth} x ${layer.originalHeight} px`,
};
}
export function buildLayerExportMetadata(
@@ -292,7 +292,7 @@ describe('ImageCanvasGenerationDialogModel', () => {
fields: [
{ title: 'prompt', value: '金币收集音' },
{ title: 'model', value: 'audio1.0' },
{ title: 'duration', value: '7秒' },
{ title: '时长', value: '7秒' },
],
references: [],
},
@@ -361,7 +361,7 @@ describe('ImageCanvasGenerationDialogModel', () => {
title: '游戏背景音乐',
prompt: '【系统内置】完整背景音乐提示词',
generationInputs: {
fields: [{ title: 'duration', value: '10秒' }],
fields: [{ title: '时长', value: '10秒' }],
references: [],
},
}),
@@ -529,7 +529,10 @@ function resolveAudioRedrawSoundModel(
}
function resolveAudioRedrawSoundDuration(sourceLayer: CanvasLayer) {
const fieldValue = findGenerationInputFieldValue(sourceLayer, ['duration']);
const fieldValue = findGenerationInputFieldValue(sourceLayer, [
'时长',
'duration',
]);
const matchedValue = fieldValue?.match(/\d+/u)?.[0];
const duration = matchedValue ? Number.parseInt(matchedValue, 10) : null;
if (!duration || !Number.isFinite(duration)) {
@@ -501,7 +501,10 @@ describe('ImageCanvasGenerationLayerModel', () => {
originalHeight: 120,
},
generationInputs: {
fields: [{ title: 'prompt', value: '金币掉落叮当声' }],
fields: [
{ title: 'prompt', value: '金币掉落叮当声' },
{ title: '时长', value: '75秒' },
],
references: [],
},
});
@@ -526,11 +529,18 @@ describe('ImageCanvasGenerationLayerModel', () => {
objectKey: 'generated-character-drafts/editor-audios/sfx.mp3',
assetObjectId: 'assetobj-audio-1',
sourceAssetId: 'asset-audio-bff',
durationSeconds: 75,
generationInputs: {
fields: [
{ title: 'prompt', value: '金币掉落叮当声' },
{ title: '时长', value: '75秒' },
],
references: [],
},
generatedAssetSnapshot: expect.objectContaining({
assetId: 'asset-audio-bff',
}),
});
expect(layer).not.toHaveProperty('durationSeconds');
expect(layer.provider).toBeUndefined();
});
@@ -579,8 +589,8 @@ describe('ImageCanvasGenerationLayerModel', () => {
objectKey: 'generated-character-drafts/editor-videos/video.mp4',
assetObjectId: 'assetobj-video-1',
thumbnailSrc: '/generated-character-drafts/editor-videos/cover.png',
durationSeconds: 5,
});
expect(layer).not.toHaveProperty('durationSeconds');
expect(layer.generatedAssetSnapshot?.assetId).toBe('asset-video-bff');
expect(layer.provider).toBeUndefined();
});
@@ -457,7 +457,6 @@ export function createVideoResultLayer({
sourceAssetId: asset?.assetId,
groupId: sourceLayer?.groupId,
generationInputs: resource?.generationInputs ?? generationInputs,
durationSeconds: generated.durationSeconds,
generatedAssetSnapshot: asset ?? generated.asset ?? undefined,
};
}
@@ -513,7 +512,6 @@ export function createAudioResultLayer({
objectKey: resource?.objectKey ?? generated.objectKey,
assetObjectId: resource?.assetObjectId ?? generated.assetObjectId,
sourceAssetId: asset?.assetId,
durationSeconds: generated.durationSeconds ?? undefined,
generationInputs: resource?.generationInputs ?? generationInputs,
generatedAssetSnapshot: asset ?? undefined,
};
@@ -546,7 +546,7 @@ describe('ImageCanvasGenerationModel', () => {
fields: [
{ title: 'prompt', value: '金币掉落叮当声' },
{ title: 'model', value: 'audio1.0' },
{ title: 'duration', value: '7秒' },
{ title: '时长', value: '7秒' },
],
references: [],
});
@@ -1031,6 +1031,7 @@ export function buildImageGenerationInputs(
export function buildVideoGenerationInputs(
prompt: string,
durationSeconds: number,
references?: CharacterReferenceImage[],
): CanvasGenerationInputs {
const mediaIndexes = {
@@ -1039,7 +1040,10 @@ export function buildVideoGenerationInputs(
audio: 0,
};
return {
fields: createGenerationInputField('视频描述', prompt),
fields: [
...createGenerationInputField('视频描述', prompt),
...createGenerationInputField('时长', `${durationSeconds}`),
],
references: (references ?? []).flatMap((reference) => {
const mediaType =
reference.mediaType === 'video' || reference.mediaType === 'audio'
@@ -1066,7 +1070,7 @@ export function buildSoundEffectGenerationInputs(
fields: [
...createGenerationInputField('prompt', prompt),
...createGenerationInputField('model', model),
...createGenerationInputField('duration', `${duration}`),
...createGenerationInputField('时长', `${duration}`),
],
references: [],
};
@@ -851,6 +851,10 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
},
result: {
generationInputs: {
fields: [
{ title: '视频描述', value: '根据参考生成角色宣传片' },
{ title: '时长', value: '5秒' },
],
references: [
{
title: '参考图 1',
@@ -1031,7 +1035,7 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
fields: [
{ title: 'prompt', value: '金币掉落叮当声' },
{ title: 'model', value: 'audio1.0' },
{ title: 'duration', value: '7秒' },
{ title: '时长', value: '7秒' },
],
references: [],
},
@@ -1063,7 +1067,7 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
fields: [
{ title: 'prompt', value: '按钮确认短促音' },
{ title: 'model', value: 'audio1.0' },
{ title: 'duration', value: '5秒' },
{ title: '时长', value: '5秒' },
],
},
},
@@ -502,6 +502,7 @@ export function buildImageGenerationSubmissionPlan({
),
generationInputs: buildVideoGenerationInputs(
normalizedPrompt,
durationSeconds,
dialog.generationReferences,
),
},
@@ -1,33 +1,3 @@
export function normalizeCanvasDurationSeconds(value: unknown) {
return typeof value === 'number' && Number.isFinite(value) && value > 0
? value
: null;
}
export function formatCanvasDurationLabel(value: unknown) {
const durationSeconds = normalizeCanvasDurationSeconds(value);
if (durationSeconds === null) {
return '--:--';
}
const totalSeconds = Math.max(1, Math.round(durationSeconds));
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
const paddedSeconds = String(seconds).padStart(2, '0');
if (hours > 0) {
return `${hours}:${String(minutes).padStart(2, '0')}:${paddedSeconds}`;
}
return `${minutes}:${paddedSeconds}`;
}
export function formatCanvasDurationMetric(value: unknown) {
// 中文注释:音频对象没有视觉分辨率语义,画布角标统一显示时长。
return `时长 ${formatCanvasDurationLabel(value)}`;
}
type MediaPreviewTone = 'audio' | 'video';
type MediaPreviewMarker = {
@@ -224,7 +224,7 @@ describe('ImageCanvasMetadataModalView', () => {
expect(onClose).toHaveBeenCalledTimes(1);
});
it('renders generated audio metadata with duration instead of resolution', () => {
it('renders audio duration from generation inputs instead of layer metadata', () => {
render(
<ImageCanvasMetadataModalView
layer={createLayer({
@@ -236,9 +236,11 @@ describe('ImageCanvasMetadataModalView', () => {
originalHeight: 120,
model: 'suno',
taskId: 'audio-task-75',
durationSeconds: 75,
generationInputs: {
fields: [{ title: '音乐描述', value: '紧张的地下城循环音乐' }],
fields: [
{ title: '音乐描述', value: '紧张的地下城循环音乐' },
{ title: '时长', value: '75秒' },
],
references: [],
},
})}
@@ -253,7 +255,7 @@ describe('ImageCanvasMetadataModalView', () => {
expect(within(dialog).getByText('音乐描述')).toBeTruthy();
expect(within(dialog).getByText('紧张的地下城循环音乐')).toBeTruthy();
expect(within(dialog).getByText('时长')).toBeTruthy();
expect(within(dialog).getByText('1:15')).toBeTruthy();
expect(within(dialog).getByText('75秒')).toBeTruthy();
expect(within(dialog).queryByText('Resolution')).toBeNull();
expect(within(dialog).queryByText('420 x 120 px')).toBeNull();
});
@@ -6,7 +6,6 @@ import {
getEditorLayerModelDisplayName,
isEditorUserVisibleGenerationInputField,
} from './ImageCanvasGenerationModel';
import { formatCanvasDurationMetric } from './ImageCanvasMediaModel';
export type ImageCanvasMetadataModalViewProps = {
layer: CanvasLayer | null;
@@ -17,13 +16,6 @@ function formatReferenceType(refType: 'project-resource' | 'asset') {
return refType === 'project-resource' ? '项目资源' : '素材';
}
function formatLayerDurationForDisplay(layer: CanvasLayer) {
return formatCanvasDurationMetric(layer.durationSeconds).replace(
/^\s*/u,
'',
);
}
export function ImageCanvasMetadataModalView({
layer,
onClose,
@@ -104,19 +96,14 @@ export function ImageCanvasMetadataModalView({
<dd>
{getEditorLayerModelDisplayName(layer.model)}
</dd>
{layer.mediaType === 'audio' ? (
<>
<dt></dt>
<dd>{formatLayerDurationForDisplay(layer)}</dd>
</>
) : (
{layer.mediaType !== 'audio' ? (
<>
<dt>Resolution</dt>
<dd>
{layer.originalWidth} x {layer.originalHeight} px
</dd>
</>
)}
) : null}
<dt>Task</dt>
<dd>{formatTaskIdForDisplay(layer.taskId)}</dd>
</dl>
@@ -14,6 +14,7 @@ import {
applyUploadAssetReadFailure,
applyUploadAssetReadSuccess,
bindUploadLayerToPersistedAsset,
buildUploadedMediaGenerationInputs,
createUploadCanvasLayer,
createUploadedGenerationReference,
createUploadingAssetPlaceholder,
@@ -539,6 +540,7 @@ describe('ImageCanvasUploadModel', () => {
fileName: '画布视频.mp4',
imageSrc: '/generated/video.mp4',
mediaType: 'video',
durationSeconds: 4.25,
imageDimensions: { width: 1280, height: 720 },
canvasPoint: { x: 110, y: 120 },
canvasSize: { width: 900, height: 640 },
@@ -555,7 +557,19 @@ describe('ImageCanvasUploadModel', () => {
height: 720,
originalWidth: 1280,
originalHeight: 720,
generationInputs: {
fields: [{ title: '时长', value: '4.25秒' }],
references: [],
},
});
expect(layer).not.toHaveProperty('durationSeconds');
});
it('omits invalid or image durations from uploaded media metadata', () => {
expect(buildUploadedMediaGenerationInputs('image', 4)).toBeUndefined();
expect(
buildUploadedMediaGenerationInputs('audio', Number.NaN),
).toBeUndefined();
});
it('uses viewport center fallback for invalid upload points', () => {

Some files were not shown because too many files have changed in this diff Show More