统一音视频时长展示元数据
删除 EditorAsset、CanvasLayer 与画布布局中的通用 durationSeconds 链路。 将上传及生成请求时长统一写入 generationInputs.fields,并由服务端去重补齐。 音频播放只使用 loadedmetadata,素材详情、导出和精选成本读取展示字段。 补充前后端回归测试并同步架构文档与共享决策。
This commit is contained in:
@@ -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', () => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { resolvePreferredAssetUploadFolder } from './ImageCanvasAssetLibraryModel';
|
||||
import { resolveLayerResolutionSize } from './ImageCanvasEditorModel';
|
||||
import type {
|
||||
CanvasGenerationInputs,
|
||||
CanvasLayer,
|
||||
CanvasMediaType,
|
||||
CanvasViewport,
|
||||
@@ -26,6 +27,7 @@ type PersistedUploadAsset = {
|
||||
height: number;
|
||||
objectKey?: string | null;
|
||||
assetObjectId?: string | null;
|
||||
generationInputs?: CanvasGenerationInputs | null;
|
||||
};
|
||||
type GenerationReferenceUploadTarget =
|
||||
| 'character-reference'
|
||||
@@ -436,6 +438,8 @@ export function applyPersistedUploadAsset({
|
||||
height: persistedAsset.height,
|
||||
objectKey: persistedAsset.objectKey ?? undefined,
|
||||
assetObjectId: persistedAsset.assetObjectId ?? undefined,
|
||||
generationInputs:
|
||||
persistedAsset.generationInputs ?? asset.generationInputs,
|
||||
persisted: true,
|
||||
uploadStatus: undefined,
|
||||
uploadProgress: undefined,
|
||||
@@ -529,6 +533,25 @@ export function normalizeUploadScreenPoint({
|
||||
};
|
||||
}
|
||||
|
||||
export function buildUploadedMediaGenerationInputs(
|
||||
mediaType: CanvasMediaType,
|
||||
durationSeconds: number | undefined,
|
||||
): CanvasGenerationInputs | undefined {
|
||||
if (
|
||||
mediaType === 'image' ||
|
||||
typeof durationSeconds !== 'number' ||
|
||||
!Number.isFinite(durationSeconds) ||
|
||||
durationSeconds <= 0
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const roundedDurationSeconds = Math.round(durationSeconds * 1_000) / 1_000;
|
||||
return {
|
||||
fields: [{ title: '时长', value: `${roundedDurationSeconds}秒` }],
|
||||
references: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function createUploadCanvasLayer({
|
||||
uploadIndex,
|
||||
fileName,
|
||||
@@ -591,7 +614,10 @@ export function createUploadCanvasLayer({
|
||||
sourceAssetId: `upload-${uploadIndex}`,
|
||||
objectKey: objectKey ?? undefined,
|
||||
assetObjectId: assetObjectId ?? undefined,
|
||||
durationSeconds,
|
||||
generationInputs: buildUploadedMediaGenerationInputs(
|
||||
mediaType,
|
||||
durationSeconds,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -900,7 +900,10 @@ describe('ImageCanvasWorldView', () => {
|
||||
originalWidth: 420,
|
||||
originalHeight: 120,
|
||||
prompt: '紧张的地下城循环音乐',
|
||||
durationSeconds: 75,
|
||||
generationInputs: {
|
||||
fields: [{ title: '时长', value: '75秒' }],
|
||||
references: [],
|
||||
},
|
||||
});
|
||||
const { props, rerender } = renderWorldView({
|
||||
layers: [layer],
|
||||
@@ -948,6 +951,20 @@ describe('ImageCanvasWorldView', () => {
|
||||
|
||||
const hoveredAudio =
|
||||
within(hoveredLayerButton).getByLabelText('画布音频:游戏背景音乐');
|
||||
const progress = within(hoveredLayerButton).getByLabelText(
|
||||
'调整游戏背景音乐播放进度',
|
||||
) as HTMLInputElement;
|
||||
expect(progress.disabled).toBe(true);
|
||||
expect(progress.max).toBe('0');
|
||||
|
||||
Object.defineProperty(hoveredAudio, 'duration', {
|
||||
configurable: true,
|
||||
value: 90,
|
||||
});
|
||||
fireEvent.loadedMetadata(hoveredAudio);
|
||||
expect(progress.disabled).toBe(false);
|
||||
expect(progress.max).toBe('90');
|
||||
|
||||
fireEvent.play(hoveredAudio);
|
||||
expect(
|
||||
within(hoveredLayerButton)
|
||||
@@ -973,7 +990,6 @@ describe('ImageCanvasWorldView', () => {
|
||||
height: 120,
|
||||
originalWidth: 420,
|
||||
originalHeight: 120,
|
||||
durationSeconds: 12,
|
||||
});
|
||||
const { props } = renderWorldView({
|
||||
layers: [layer],
|
||||
|
||||
@@ -364,14 +364,6 @@ function getAudioLayerIcon(layer: CanvasLayer) {
|
||||
);
|
||||
}
|
||||
|
||||
function getAudioDurationSeconds(durationSeconds: CanvasLayer['durationSeconds']) {
|
||||
return typeof durationSeconds === 'number' &&
|
||||
Number.isFinite(durationSeconds) &&
|
||||
durationSeconds > 0
|
||||
? durationSeconds
|
||||
: 0;
|
||||
}
|
||||
|
||||
function formatAudioControlTime(seconds: number) {
|
||||
if (!Number.isFinite(seconds) || seconds <= 0) {
|
||||
return '0:00';
|
||||
@@ -402,21 +394,20 @@ function ImageCanvasAudioLayerCard({
|
||||
refreshKey: layer.taskId ?? layer.resourceId,
|
||||
});
|
||||
const prompt = layer.prompt?.trim();
|
||||
const fallbackDuration = getAudioDurationSeconds(layer.durationSeconds);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [duration, setDuration] = useState(fallbackDuration);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [volume, setVolume] = useState(1);
|
||||
const [isMuted, setIsMuted] = useState(false);
|
||||
const showPlaybackButton = isPlaying || isHovered;
|
||||
const controlDuration = duration > 0 ? duration : fallbackDuration;
|
||||
const controlDuration = duration > 0 ? duration : 0;
|
||||
const safeCurrentTime =
|
||||
controlDuration > 0 ? Math.min(currentTime, controlDuration) : 0;
|
||||
|
||||
useEffect(() => {
|
||||
setIsPlaying(false);
|
||||
setCurrentTime(0);
|
||||
setDuration(fallbackDuration);
|
||||
}, [fallbackDuration, layer.src]);
|
||||
setDuration(0);
|
||||
}, [layer.src]);
|
||||
|
||||
const togglePlayback = async () => {
|
||||
const audio = audioRef.current;
|
||||
@@ -519,7 +510,7 @@ function ImageCanvasAudioLayerCard({
|
||||
setDuration(
|
||||
Number.isFinite(nextDuration) && nextDuration > 0
|
||||
? nextDuration
|
||||
: fallbackDuration,
|
||||
: 0,
|
||||
);
|
||||
}}
|
||||
onTimeUpdate={(event) => setCurrentTime(event.currentTarget.currentTime)}
|
||||
|
||||
@@ -322,7 +322,7 @@ function SubmissionWorkflowHarness({
|
||||
{layers
|
||||
.map(
|
||||
(layer) =>
|
||||
`${layer.id}:${layer.title}:${layer.sourceResourceId ?? '-'}:${layer.assetKind ?? '-'}:${layer.mediaType ?? '-'}:${layer.src}:${layer.thumbnailSrc ?? '-'}:${layer.imageSequenceDurationMs ?? layer.durationSeconds ?? '-'}`,
|
||||
`${layer.id}:${layer.title}:${layer.sourceResourceId ?? '-'}:${layer.assetKind ?? '-'}:${layer.mediaType ?? '-'}:${layer.src}:${layer.thumbnailSrc ?? '-'}:${layer.imageSequenceDurationMs ?? '-'}`,
|
||||
)
|
||||
.join('|')}
|
||||
</span>
|
||||
|
||||
@@ -1486,6 +1486,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({
|
||||
);
|
||||
const generationInputs = buildVideoGenerationInputs(
|
||||
normalizedPrompt,
|
||||
videoDurationSeconds,
|
||||
quickEditReferences,
|
||||
);
|
||||
const generated = await runEditorGenerationWithWalletRefresh(
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user