合并主分支
Project CI / Repository checks (pull_request) Successful in 1m2s
Project CI / Frontend tests (pull_request) Successful in 3m39s
Project CI / Backend tests (pull_request) Successful in 4m4s
Project CI / Native shell tests (pull_request) Successful in 14m1s

解决简单冲突
This commit is contained in:
2026-08-06 17:06:12 +08:00
111 changed files with 10824 additions and 1262 deletions
@@ -1,6 +1,13 @@
/* @vitest-environment jsdom */
import { act, render, screen, waitFor, within } from '@testing-library/react';
import {
act,
fireEvent,
render,
screen,
waitFor,
within,
} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { ContextType } from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
@@ -147,6 +154,7 @@ describe('CreationLandingView', () => {
listPublicEditorProjectResourcesMock.mockReset();
createEditorProjectMock.mockReset();
toggleEditorShowcaseAssetLikeMock.mockReset();
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
@@ -470,6 +478,177 @@ describe('CreationLandingView', () => {
expect(within(card as HTMLElement).getByText('20泥点')).toBeTruthy();
});
it('plays a character action on card focus and in the preview dialog', async () => {
const user = userEvent.setup();
const setIntervalSpy = vi.spyOn(window, 'setInterval');
listEditorProjectsMock.mockResolvedValueOnce(projectItems);
listPublicEditorProjectResourcesMock.mockResolvedValueOnce([
{
resourceId: 'resource-action',
projectId: 'editor-showcase',
showcaseId: 'showcase-action',
label: '待机动作',
imageSrc: '/public/action/frame-01.png',
objectKey: null,
width: 192,
height: 256,
sourceType: 'generated',
assetKind: 'character-animation',
generationCostMudPoints: 103,
imageSequenceFrames: [
{
imageSrc: '/public/action/frame-01.png',
width: 192,
height: 256,
},
{
imageSrc: '/public/action/frame-02.png',
width: 192,
height: 256,
},
{
imageSrc: '/public/action/frame-03.png',
width: 192,
height: 256,
},
{
imageSrc: '/public/action/frame-04.png',
width: 192,
height: 256,
},
{
imageSrc: '/public/action/frame-05.png',
width: 192,
height: 256,
},
],
imageSequenceDurationMs: 250,
},
]);
renderCreationLanding();
const title = await screen.findByText('待机动作');
const card = title.closest('.creation-landing__asset-card') as HTMLElement;
const openButton = card.querySelector(
'.creation-landing__asset-card-open',
) as HTMLButtonElement;
expect(
card.querySelectorAll('.creation-landing__image-sequence-frame'),
).toHaveLength(0);
fireEvent.focus(openButton);
await waitFor(() => {
expect(
card.querySelectorAll('.creation-landing__image-sequence-frame'),
).toHaveLength(3);
});
expect(setIntervalSpy.mock.calls.some(([, delay]) => delay === 50)).toBe(
true,
);
fireEvent.pointerEnter(openButton);
fireEvent.blur(openButton);
expect(
card.querySelectorAll('.creation-landing__image-sequence-frame'),
).toHaveLength(3);
fireEvent.pointerLeave(openButton);
await waitFor(() => {
expect(
card.querySelectorAll('.creation-landing__image-sequence-frame'),
).toHaveLength(0);
});
await user.click(openButton);
expect(
await screen.findByRole('button', { name: '暂停角色动作' }),
).toBeTruthy();
expect(
document.querySelectorAll(
'.creation-landing__showcase-current .creation-landing__image-sequence-frame',
),
).toHaveLength(3);
const failedModalFrame = document.querySelector(
'.creation-landing__showcase-current .creation-landing__image-sequence-frame',
) as HTMLImageElement;
await user.click(screen.getByRole('button', { name: '暂停角色动作' }));
fireEvent.error(failedModalFrame);
expect(await screen.findByText('1 帧加载失败')).toBeTruthy();
expect(await screen.findByText('2/5')).toBeTruthy();
await user.click(screen.getByRole('button', { name: '重试失败帧' }));
await waitFor(() => {
expect(screen.queryByText('1 帧加载失败')).toBeNull();
});
await user.click(screen.getByRole('button', { name: '暂停角色动作' }));
expect(screen.getByRole('button', { name: '播放角色动作' })).toBeTruthy();
setIntervalSpy.mockRestore();
});
it('keeps character action previews paused when reduced motion is requested', async () => {
const user = userEvent.setup();
vi.stubGlobal(
'matchMedia',
vi.fn().mockReturnValue({
matches: true,
media: '(prefers-reduced-motion: reduce)',
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(),
}),
);
listEditorProjectsMock.mockResolvedValueOnce(projectItems);
listPublicEditorProjectResourcesMock.mockResolvedValueOnce([
{
resourceId: 'resource-action-reduced-motion',
projectId: 'editor-showcase',
showcaseId: 'showcase-action-reduced-motion',
label: '缓慢待机动作',
imageSrc: '/public/action/frame-01.png',
objectKey: null,
width: 192,
height: 256,
sourceType: 'generated',
assetKind: 'character-animation',
generationCostMudPoints: 103,
imageSequenceFrames: [
{
imageSrc: '/public/action/frame-01.png',
width: 192,
height: 256,
},
{
imageSrc: '/public/action/frame-02.png',
width: 192,
height: 256,
},
],
imageSequenceDurationMs: 250,
},
]);
renderCreationLanding();
const title = await screen.findByText('缓慢待机动作');
const card = title.closest('.creation-landing__asset-card') as HTMLElement;
const openButton = card.querySelector(
'.creation-landing__asset-card-open',
) as HTMLButtonElement;
fireEvent.focus(openButton);
expect(
card.querySelectorAll('.creation-landing__image-sequence-frame'),
).toHaveLength(0);
await user.click(openButton);
expect(
await screen.findByRole('button', { name: '播放角色动作' }),
).toBeTruthy();
});
it('likes a featured asset from the list card action', async () => {
const user = userEvent.setup();
listEditorProjectsMock.mockResolvedValueOnce(projectItems);
@@ -615,6 +794,19 @@ describe('CreationLandingView', () => {
taskId: 'task-2',
assetKind: 'character-animation',
priceMudPoints: 40,
imageSequenceFrames: [
{
imageSrc: 'data:image/png;base64,animation-1',
width: 512,
height: 512,
},
{
imageSrc: 'data:image/png;base64,animation-2',
width: 512,
height: 512,
},
],
imageSequenceDurationMs: 4_000,
generationInputs: {
fields: [{ title: '动作描述', value: '待机动作' }],
references: [
@@ -1,4 +1,4 @@
import { Film, Image as ImageIcon, Music2 } from 'lucide-react';
import { Film, Image as ImageIcon, Music2, Pause, Play } from 'lucide-react';
import {
useCallback,
useEffect,
@@ -74,6 +74,30 @@ const CREATION_HOME_ASSETS = {
} as const;
const SHOWCASE_LIKE_ICON_SRC = '/creation-home/showcase-like-thumb.png';
const REDUCED_MOTION_MEDIA_QUERY = '(prefers-reduced-motion: reduce)';
function usePrefersReducedMotion() {
const [prefersReducedMotion, setPrefersReducedMotion] = useState(() =>
typeof window.matchMedia === 'function'
? window.matchMedia(REDUCED_MOTION_MEDIA_QUERY).matches
: false,
);
useEffect(() => {
if (typeof window.matchMedia !== 'function') {
return undefined;
}
const mediaQuery = window.matchMedia(REDUCED_MOTION_MEDIA_QUERY);
const handleChange = (event: MediaQueryListEvent) => {
setPrefersReducedMotion(event.matches);
};
setPrefersReducedMotion(mediaQuery.matches);
mediaQuery.addEventListener('change', handleChange);
return () => mediaQuery.removeEventListener('change', handleChange);
}, []);
return prefersReducedMotion;
}
const CREATION_FEATURES: CreationFeatureItem[] = [
{
@@ -239,7 +263,7 @@ function getPreviewIcon(preview: ShowcaseAssetPreview) {
if (preview.mediaType === 'audio') {
return <Music2 aria-hidden="true" />;
}
if (preview.mediaType === 'video') {
if (preview.mediaType === 'video' || preview.mediaType === 'image-sequence') {
return <Film aria-hidden="true" />;
}
return <ImageIcon aria-hidden="true" />;
@@ -276,9 +300,11 @@ function ShowcaseAudioPreview({
function ShowcasePreview({
preview,
variant = 'card',
sequencePlaying = false,
}: {
preview: ShowcaseAssetPreview;
variant?: 'card' | 'modal' | 'thumb';
sequencePlaying?: boolean;
}) {
if (preview.mediaType === 'audio') {
return (
@@ -294,9 +320,308 @@ function ShowcasePreview({
/>
);
}
if (preview.mediaType === 'image-sequence') {
return (
<ShowcaseImageSequencePreview
preview={preview}
variant={variant}
playing={sequencePlaying}
/>
);
}
return <ShowcaseImagePreview preview={preview} variant={variant} />;
}
function ShowcaseImageSequenceFrame({
preview,
frame,
frameKey,
visible,
loaded,
retryGeneration,
onReady,
onFailed,
}: {
preview: ShowcaseAssetPreview;
frame: NonNullable<ShowcaseAssetPreview['imageSequenceFrames']>[number];
frameKey: string;
visible: boolean;
loaded: boolean;
retryGeneration: number;
onReady: (frameKey: string) => void;
onFailed: (frameKey: string) => void;
}) {
const failureReportedRef = useRef(false);
const { resolvedUrl, isResolving, shouldResolve } = useResolvedAssetReadUrl(
frame.imageSrc,
{
objectKey: frame.objectKey,
refreshKey: `${preview.id}:${frameKey}:${retryGeneration}`,
},
);
const reportFailure = useCallback(() => {
if (failureReportedRef.current) {
return;
}
failureReportedRef.current = true;
onFailed(frameKey);
}, [frameKey, onFailed]);
useEffect(() => {
if (shouldResolve && !isResolving && !resolvedUrl) {
reportFailure();
}
}, [isResolving, reportFailure, resolvedUrl, shouldResolve]);
if (!resolvedUrl) {
return null;
}
return (
<img
src={resolvedUrl}
alt={visible ? `角色动作预览:${preview.label}` : ''}
aria-hidden={visible ? undefined : true}
className="creation-landing__image-sequence-frame"
decoding="async"
style={{
opacity: visible && loaded ? 1 : 0,
transition: 'none',
}}
onLoad={() => onReady(frameKey)}
onError={reportFailure}
/>
);
}
function ShowcaseImageSequencePreview({
preview,
variant,
playing,
}: {
preview: ShowcaseAssetPreview;
variant: 'card' | 'modal' | 'thumb';
playing: boolean;
}) {
const frames = useMemo(
() => preview.imageSequenceFrames ?? [],
[preview.imageSequenceFrames],
);
const durationMs = preview.imageSequenceDurationMs ?? 0;
const shouldMountSequence = variant === 'modal' || playing;
const frameItems = useMemo(
() =>
frames.map((frame, index) => ({
frame,
index,
key: [preview.id, frame.objectKey ?? '', frame.imageSrc, index].join(
':',
),
})),
[frames, preview.id],
);
const frameSequenceKey = frameItems.map((item) => item.key).join('|');
const firstFrameKey = frameItems[0]?.key ?? '';
const [frameIndex, setFrameIndex] = useState(0);
const [isPlaying, setIsPlaying] = useState(playing);
const [loadedFrameKeys, setLoadedFrameKeys] = useState<ReadonlySet<string>>(
() => new Set(),
);
const [failedFrameKeys, setFailedFrameKeys] = useState<ReadonlySet<string>>(
() => new Set(),
);
const [retryGeneration, setRetryGeneration] = useState(0);
const [visibleFrameKey, setVisibleFrameKey] = useState(firstFrameKey);
const currentFrameItem =
frameItems[Math.min(frameIndex, frameItems.length - 1)];
const currentFrameKey = currentFrameItem?.key ?? '';
const mountedFrameKeys = new Set<string>();
if (visibleFrameKey && !failedFrameKeys.has(visibleFrameKey)) {
mountedFrameKeys.add(visibleFrameKey);
}
for (
let offset = 0;
offset < frameItems.length && mountedFrameKeys.size < 3;
offset += 1
) {
const item = frameItems[(frameIndex + offset) % frameItems.length];
if (item && !failedFrameKeys.has(item.key)) {
mountedFrameKeys.add(item.key);
}
}
const availableFrameCount = frameItems.length - failedFrameKeys.size;
const handleFrameReady = useCallback((frameKey: string) => {
setLoadedFrameKeys((currentKeys) => {
if (currentKeys.has(frameKey)) {
return currentKeys;
}
const nextKeys = new Set(currentKeys);
nextKeys.add(frameKey);
return nextKeys;
});
}, []);
const handleFrameFailed = useCallback((frameKey: string) => {
setFailedFrameKeys((currentKeys) => {
if (currentKeys.has(frameKey)) {
return currentKeys;
}
const nextKeys = new Set(currentKeys);
nextKeys.add(frameKey);
return nextKeys;
});
}, []);
const retryFailedFrames = useCallback(() => {
setFrameIndex(0);
setIsPlaying(playing);
setLoadedFrameKeys(new Set());
setFailedFrameKeys(new Set());
setVisibleFrameKey(firstFrameKey);
setRetryGeneration((current) => current + 1);
}, [firstFrameKey, playing]);
useEffect(() => {
setFrameIndex(0);
setLoadedFrameKeys(new Set());
setFailedFrameKeys(new Set());
setVisibleFrameKey(firstFrameKey);
setIsPlaying(playing);
}, [firstFrameKey, frameSequenceKey, playing]);
useEffect(() => {
if (!currentFrameKey || !failedFrameKeys.has(currentFrameKey)) {
return;
}
const nextIndex = findNextShowcaseFrameIndex(
frameItems,
failedFrameKeys,
frameIndex,
);
if (nextIndex === null) {
setVisibleFrameKey('');
setIsPlaying(false);
return;
}
setFrameIndex(nextIndex);
}, [currentFrameKey, failedFrameKeys, frameIndex, frameItems]);
useEffect(() => {
if (!currentFrameKey) {
setVisibleFrameKey('');
return;
}
if (!loadedFrameKeys.has(currentFrameKey) && visibleFrameKey) {
return;
}
setVisibleFrameKey(currentFrameKey);
}, [currentFrameKey, loadedFrameKeys, visibleFrameKey]);
useEffect(() => {
if (!isPlaying || availableFrameCount < 2 || durationMs <= 0) {
return undefined;
}
const frameIntervalMs = Math.min(2_147_483_647, durationMs / frames.length);
const timer = window.setInterval(() => {
setFrameIndex(
(currentIndex) =>
findNextShowcaseFrameIndex(
frameItems,
failedFrameKeys,
currentIndex,
) ?? currentIndex,
);
}, frameIntervalMs);
return () => window.clearInterval(timer);
}, [
availableFrameCount,
durationMs,
failedFrameKeys,
frameItems,
frames.length,
isPlaying,
]);
if (!shouldMountSequence) {
return <ShowcaseImagePreview preview={preview} variant={variant} />;
}
const mediaClassName =
variant === 'modal'
? 'creation-landing__showcase-media'
: 'creation-landing__asset-preview-media';
return (
<div
aria-label={`角色动作预览:${preview.label}`}
className={`${mediaClassName} creation-landing__image-sequence`}
>
{frameItems
.filter((item) => mountedFrameKeys.has(item.key))
.map((item) => (
<ShowcaseImageSequenceFrame
key={`${item.key}:${retryGeneration}`}
preview={preview}
frame={item.frame}
frameKey={item.key}
visible={item.key === visibleFrameKey}
loaded={loadedFrameKeys.has(item.key)}
retryGeneration={retryGeneration}
onReady={handleFrameReady}
onFailed={handleFrameFailed}
/>
))}
{variant === 'modal' ? (
<div className="creation-landing__image-sequence-controls">
<button
type="button"
aria-label={isPlaying ? '暂停角色动作' : '播放角色动作'}
onClick={() => setIsPlaying((current) => !current)}
>
{isPlaying ? (
<Pause size={16} aria-hidden="true" />
) : (
<Play size={16} aria-hidden="true" />
)}
</button>
<span>{`${currentFrameItem ? currentFrameItem.index + 1 : 0}/${frames.length}`}</span>
{failedFrameKeys.size ? (
<>
<span aria-live="polite">{`${failedFrameKeys.size} 帧加载失败`}</span>
<button type="button" onClick={retryFailedFrames}>
</button>
</>
) : null}
</div>
) : availableFrameCount === 0 ? (
<span
aria-live="polite"
className="creation-landing__image-sequence-error"
>
</span>
) : null}
</div>
);
}
function findNextShowcaseFrameIndex(
frameItems: ReadonlyArray<{ key: string }>,
failedFrameKeys: ReadonlySet<string>,
currentIndex: number,
) {
for (let offset = 1; offset <= frameItems.length; offset += 1) {
const nextIndex = (currentIndex + offset) % frameItems.length;
const item = frameItems[nextIndex];
if (item && !failedFrameKeys.has(item.key)) {
return nextIndex;
}
}
return null;
}
function ShowcaseImagePreview({
preview,
variant,
@@ -344,7 +669,13 @@ function ShowcaseVideoPreview({
);
}
function ShowcaseBundlePreview({ item }: { item: ShowcaseAssetItem }) {
function ShowcaseBundlePreview({
item,
playSequences,
}: {
item: ShowcaseAssetItem;
playSequences: boolean;
}) {
const visiblePreviews = item.previews.slice(0, 4);
const primaryPreview = visiblePreviews[0];
const previewClassName = [
@@ -372,7 +703,7 @@ function ShowcaseBundlePreview({ item }: { item: ShowcaseAssetItem }) {
className="creation-landing__asset-preview-tile"
aria-label={preview.label}
>
<ShowcasePreview preview={preview} />
<ShowcasePreview preview={preview} sequencePlaying={playSequences} />
</span>
))}
{item.previews.length > visiblePreviews.length ? (
@@ -387,11 +718,13 @@ function ShowcaseBundlePreview({ item }: { item: ShowcaseAssetItem }) {
function CreationShowcaseModal({
item,
activeIndex,
playSequences,
onSelectIndex,
onClose,
}: {
item: ShowcaseAssetItem | null;
activeIndex: number;
playSequences: boolean;
onSelectIndex: (index: number) => void;
onClose: () => void;
}) {
@@ -427,7 +760,11 @@ function CreationShowcaseModal({
/>
<div className="creation-landing__showcase-modal-stage">
<div className="creation-landing__showcase-current">
<ShowcasePreview preview={activePreview} variant="modal" />
<ShowcasePreview
preview={activePreview}
variant="modal"
sequencePlaying={playSequences}
/>
</div>
</div>
<div className="creation-landing__showcase-dock">
@@ -470,7 +807,8 @@ function CreationShowcaseModal({
>
<span className="creation-landing__showcase-thumb-media">
{preview.mediaType === 'image' ||
preview.mediaType === 'audio' ? (
preview.mediaType === 'audio' ||
preview.mediaType === 'image-sequence' ? (
<ShowcasePreview preview={preview} variant="thumb" />
) : (
getPreviewIcon(preview)
@@ -493,6 +831,7 @@ export function CreationLandingView({
onOpenCommunity,
searchKeyword = '',
}: CreationLandingViewProps) {
const prefersReducedMotion = usePrefersReducedMotion();
const authUi = useAuthUi();
const isAuthenticated = Boolean(authUi?.user);
const normalizedSearchKeyword = searchKeyword.trim().toLocaleLowerCase();
@@ -520,6 +859,10 @@ export function CreationLandingView({
useState<ShowcaseTabId>('all');
const [selectedShowcaseItem, setSelectedShowcaseItem] =
useState<ShowcaseAssetItem | null>(null);
const [hoveredShowcaseSequenceCardId, setHoveredShowcaseSequenceCardId] =
useState<string | null>(null);
const [focusedShowcaseSequenceCardId, setFocusedShowcaseSequenceCardId] =
useState<string | null>(null);
const [selectedShowcaseIndex, setSelectedShowcaseIndex] = useState(0);
const [likedShowcaseIds, setLikedShowcaseIds] = useState<Set<string>>(
() => new Set(),
@@ -906,12 +1249,34 @@ export function CreationLandingView({
<button
type="button"
className="creation-landing__asset-card-open"
onPointerEnter={() =>
setHoveredShowcaseSequenceCardId(item.id)
}
onPointerLeave={() =>
setHoveredShowcaseSequenceCardId((current) =>
current === item.id ? null : current,
)
}
onFocus={() => setFocusedShowcaseSequenceCardId(item.id)}
onBlur={() =>
setFocusedShowcaseSequenceCardId((current) =>
current === item.id ? null : current,
)
}
onClick={() => {
setSelectedShowcaseItem(item);
setSelectedShowcaseIndex(0);
}}
>
<ShowcaseBundlePreview item={item} />
<ShowcaseBundlePreview
item={item}
playSequences={
!prefersReducedMotion &&
(hoveredShowcaseSequenceCardId === item.id ||
focusedShowcaseSequenceCardId === item.id) &&
!selectedShowcaseItem
}
/>
<div className="creation-landing__asset-meta">
<h3>{item.label}</h3>
<p>{item.prompt}</p>
@@ -1166,6 +1531,7 @@ export function CreationLandingView({
<CreationShowcaseModal
item={selectedShowcaseItem}
activeIndex={selectedShowcaseIndex}
playSequences={!prefersReducedMotion}
onSelectIndex={setSelectedShowcaseIndex}
onClose={() => setSelectedShowcaseItem(null)}
/>
@@ -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(
@@ -375,6 +376,19 @@ describe('creationShowcaseModel', () => {
label: '待机动画',
assetKind: 'character-animation',
imageSrc: 'data:image/png;base64,animation',
imageSequenceFrames: [
{
imageSrc: 'data:image/png;base64,animation-1',
width: 512,
height: 512,
},
{
imageSrc: 'data:image/png;base64,animation-2',
width: 512,
height: 512,
},
],
imageSequenceDurationMs: 4_000,
generationInputs: {
fields: [{ title: '动作描述', value: '待机动作' }],
references: [
@@ -531,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',
@@ -555,46 +597,118 @@ describe('creationShowcaseModel', () => {
expect(items[0]?.previews[0]?.mediaType).toBe('video');
});
it('infers costs from generated asset kinds when stored price is absent', () => {
it.each([
{ frameCount: 32, durationMs: 4_000 },
{ frameCount: 40, durationMs: 5_000 },
{ frameCount: 48, durationMs: 6_000 },
])(
'carries a $frameCount-frame/$durationMs ms character action into the sequence preview',
({ frameCount, durationMs }) => {
const frames = Array.from({ length: frameCount }, (_, index) => ({
imageSrc: `/generated/action/frame-${index + 1}.png`,
objectKey: `generated/action/frame-${index + 1}.png`,
assetObjectId: `asset-object-frame-${index + 1}`,
width: 192,
height: 256,
}));
const items = buildCreationShowcaseItems({
activeTab: 'all',
projectResources: [
createResource({
resourceId: 'animation-1',
label: '待机动作',
assetKind: 'character-animation',
imageSrc: frames[0]?.imageSrc,
objectKey: frames[0]?.objectKey,
imageSequenceFrames: frames,
imageSequenceDurationMs: durationMs,
}),
],
});
expect(items).toHaveLength(1);
expect(items[0]?.previews[0]).toMatchObject({
mediaType: 'image-sequence',
imageSequenceDurationMs: durationMs,
});
expect(items[0]?.previews[0]?.imageSequenceFrames).toHaveLength(
frameCount,
);
},
);
it('omits a damaged character action instead of falling back to its first frame', () => {
const items = buildCreationShowcaseItems({
activeTab: 'characters',
activeTab: 'all',
projectResources: [
createResource({
resourceId: 'character-1',
label: '游戏角色图',
imageSrc: '/generated-editor-assets/character.png',
objectKey: 'generated-editor-assets/character.png',
assetKind: 'character',
model: 'gpt-image-2',
prompt: '角色提示词',
}),
createResource({
resourceId: 'animation-1',
label: '待机动画',
resourceId: 'animation-broken',
label: '损坏动作',
assetKind: 'character-animation',
imageSrc: '/generated-editor-videos/animation.mp4',
objectKey: 'generated-editor-videos/animation.mp4',
model: 'seedance2.0-fast',
durationSeconds: 6,
generationInputs: {
fields: [{ title: '清晰度', value: '720p' }],
references: [
{
title: '角色图片',
label: '游戏角色图',
refType: 'project-resource',
refId: 'character-1',
},
],
},
imageSrc: '/generated/action/frame-01.png',
objectKey: 'generated/action/frame-01.png',
imageSequenceFrames: [
{
imageSrc: '/generated/action/frame-01.png',
objectKey: 'generated/action/frame-01.png',
width: 192,
height: 256,
},
],
imageSequenceDurationMs: 5_000,
}),
],
});
expect(items).toHaveLength(1);
expect(items[0]?.cost).toBe('123泥点');
expect(items[0]?.previews[0]?.objectKey).toBe(
'generated-editor-assets/character.png',
);
expect(items).toEqual([]);
});
it.each([
{ durationMs: 5_000, expectedCost: '103泥点' },
{ durationMs: 6_000, expectedCost: '123泥点' },
])(
'infers costs from $durationMs ms character actions when stored price is absent',
({ durationMs, expectedCost }) => {
const items = buildCreationShowcaseItems({
activeTab: 'characters',
projectResources: [
createResource({
resourceId: 'character-1',
label: '游戏角色图',
imageSrc: '/generated-editor-assets/character.png',
objectKey: 'generated-editor-assets/character.png',
assetKind: 'character',
model: 'gpt-image-2',
prompt: '角色提示词',
}),
createResource({
resourceId: 'animation-1',
label: '待机动画',
assetKind: 'character-animation',
imageSrc: '/generated-editor-videos/animation.mp4',
objectKey: 'generated-editor-videos/animation.mp4',
model: 'seedance2.0-fast',
imageSequenceDurationMs: durationMs,
generationInputs: {
fields: [{ title: '清晰度', value: '720p' }],
references: [
{
title: '角色图片',
label: '游戏角色图',
refType: 'project-resource',
refId: 'character-1',
},
],
},
}),
],
});
expect(items).toHaveLength(1);
expect(items[0]?.cost).toBe(expectedCost);
expect(items[0]?.previews[0]?.objectKey).toBe(
'generated-editor-assets/character.png',
);
},
);
});
@@ -1,6 +1,7 @@
import type {
EditorAssetGenerationInputReference,
EditorAssetSnapshot,
EditorImageSequenceFrameResult,
EditorProjectResourceSnapshot,
EditorShowcaseCampaignSnapshot,
} from '../../services/image-editor/editorProjectClient';
@@ -19,7 +20,11 @@ import {
export type ShowcaseTabId = 'all' | 'characters' | 'ui' | 'music' | 'marketing';
export type ShowcaseAssetMediaType = 'image' | 'video' | 'audio';
export type ShowcaseAssetMediaType =
| 'image'
| 'video'
| 'audio'
| 'image-sequence';
export type ShowcaseAssetPreview = {
id: string;
@@ -30,6 +35,8 @@ export type ShowcaseAssetPreview = {
mediaType: ShowcaseAssetMediaType;
width?: number;
height?: number;
imageSequenceFrames?: EditorImageSequenceFrameResult[];
imageSequenceDurationMs?: number;
};
export type ShowcaseAssetItem = {
@@ -246,7 +253,8 @@ function projectResourceToShowcaseAsset(
actualPrompt: resource.actualPrompt ?? null,
model: resource.model ?? null,
taskId: resource.taskId ?? null,
durationSeconds: resource.durationSeconds,
imageSequenceFrames: resource.imageSequenceFrames,
imageSequenceDurationMs: resource.imageSequenceDurationMs,
sourceResourceId: resource.sourceResourceId,
assetKind: resource.assetKind,
showcaseCategory: normalizeShowcaseCategory(
@@ -394,6 +402,9 @@ function isVideoAsset(asset: EditorAssetSnapshot) {
function resolveAssetMediaType(
asset: EditorAssetSnapshot,
): ShowcaseAssetMediaType {
if (isCharacterAnimationAsset(asset)) {
return 'image-sequence';
}
if (isAudioAsset(asset)) {
return 'audio';
}
@@ -792,18 +803,23 @@ function inferAssetCost(asset: EditorAssetSnapshot) {
return calculateCharacterAnimationPrice(
model ?? CHARACTER_ANIMATION_MODEL,
inferVideoResolution(asset, ['480p', '720p'], '480p') as '480p' | '720p',
normalizeInt(asset.durationSeconds, 4, 4, 6),
normalizeInt(
asset.imageSequenceDurationMs
? asset.imageSequenceDurationMs / 1_000
: undefined,
4,
4,
6,
),
);
}
if (kind === 'video' || isVideoAsset(asset)) {
return calculateEditorVideoPrice(
model ?? DEFAULT_VIDEO_MODEL,
inferVideoResolution(asset, ['480p', '720p', '1080p'], '480p') as
| '480p'
| '720p'
| '1080p',
'480p' | '720p' | '1080p',
normalizeInt(
asset.durationSeconds,
inferMediaDurationSeconds(asset),
DEFAULT_VIDEO_DURATION_SECONDS,
4,
15,
@@ -816,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[],
@@ -849,8 +874,70 @@ function resolveGroupCost(assets: EditorAssetSnapshot[]) {
return `${totalCost}泥点`;
}
function toAssetPreview(asset: EditorAssetSnapshot): ShowcaseAssetPreview {
function normalizeShowcaseImageSequence(asset: EditorAssetSnapshot) {
const frames = asset.imageSequenceFrames;
const durationMs = asset.imageSequenceDurationMs;
if (
!Array.isArray(frames) ||
frames.length < 2 ||
typeof durationMs !== 'number' ||
!Number.isFinite(durationMs) ||
durationMs <= 0
) {
return null;
}
const normalizedFrames = frames.flatMap((frame) => {
const imageSrc = frame.imageSrc?.trim() ?? '';
if (
!imageSrc ||
!Number.isFinite(frame.width) ||
frame.width <= 0 ||
!Number.isFinite(frame.height) ||
frame.height <= 0
) {
return [];
}
return [
{
...frame,
imageSrc,
objectKey: frame.objectKey?.trim() || null,
assetObjectId: frame.assetObjectId?.trim() || null,
},
];
});
if (normalizedFrames.length !== frames.length) {
return null;
}
return { frames: normalizedFrames, durationMs };
}
function toAssetPreview(
asset: EditorAssetSnapshot,
): ShowcaseAssetPreview | null {
const mediaType = resolveAssetMediaType(asset);
if (mediaType === 'image-sequence') {
const sequence = normalizeShowcaseImageSequence(asset);
if (!sequence) {
return null;
}
const firstFrame = sequence.frames[0];
if (!firstFrame) {
return null;
}
return {
id: asset.assetId,
label: asset.label,
src: firstFrame.imageSrc,
coverSrc: null,
objectKey: firstFrame.objectKey,
mediaType,
width: firstFrame.width,
height: firstFrame.height,
imageSequenceFrames: sequence.frames,
imageSequenceDurationMs: sequence.durationMs,
};
}
return {
id: asset.assetId,
label: asset.label,
@@ -870,7 +957,10 @@ function toLibraryShowcaseItem(
const sortedAssets = sortGroupAssets(group.assets);
const previews = sortedAssets
.map(toAssetPreview)
.filter((preview) => preview.src.trim());
.filter(
(preview): preview is ShowcaseAssetPreview =>
Boolean(preview?.src.trim()),
);
if (!previews.length) {
return null;
}
@@ -167,6 +167,7 @@ function createTestImageAsset({
width: 320,
height: 240,
sourceType,
assetKind: 'image' as const,
sourceResourceId,
};
}
@@ -346,6 +347,85 @@ describe('ImageCanvasEditorView asset library integration', () => {
}
});
it('downloads a character action from the asset library as a sequence zip', async () => {
loadEditorAssetLibraryMock.mockResolvedValueOnce({
folders: [
{
folderId: 'project',
label: '项目素材',
sortOrder: 0,
collapsed: false,
systemDefault: true,
},
],
assets: [
{
assetId: 'asset-action-download',
folderId: 'project',
label: '角色挥手',
imageSrc: '/generated/action/frame01.png',
width: 192,
height: 256,
sourceType: 'generated',
assetKind: 'character-animation',
imageSequenceFrames: [
{
imageSrc: '/generated/action/frame01.png',
width: 192,
height: 256,
},
{
imageSrc: '/generated/action/frame02.png',
width: 192,
height: 256,
},
],
imageSequenceDurationMs: 4_000,
},
],
});
const originalFetch = globalThis.fetch;
const fetchMock = vi.fn(async (..._args: Parameters<typeof fetch>) =>
new Response(new Blob(['frame'], { type: 'image/png' })),
);
globalThis.fetch = fetchMock as typeof fetch;
let downloadName = '';
const downloadCapture: { blob: Blob | null } = { blob: null };
Object.defineProperty(URL, 'createObjectURL', {
configurable: true,
value: vi.fn((blob: Blob) => {
downloadCapture.blob = blob;
return 'blob:character-action-zip';
}),
});
Object.defineProperty(URL, 'revokeObjectURL', {
configurable: true,
value: vi.fn(),
});
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(
function click(this: HTMLAnchorElement) {
downloadName = this.download;
},
);
try {
render(<ImageCanvasEditorView />);
fireEvent.click(
await screen.findByRole('button', { name: '下载素材角色挥手' }),
);
await waitFor(() => {
expect(downloadName).toBe('角色挥手-SpineJSON.zip');
});
const fetchedUrls = fetchMock.mock.calls.map(([url]) => url);
expect(fetchedUrls).toContain('/generated/action/frame01.png');
expect(fetchedUrls).toContain('/generated/action/frame02.png');
expect(downloadCapture.blob?.type).toBe('application/zip');
} finally {
globalThis.fetch = originalFetch;
}
});
it('collapses folders, creates upload folders, and deletes uploaded materials', async () => {
const user = userEvent.setup();
const createObjectUrlSpy = vi.fn(() => 'blob:folder-uploaded-image');
@@ -1500,6 +1580,94 @@ describe('ImageCanvasEditorView asset library integration', () => {
expect(createEditorAssetMock).not.toHaveBeenCalled();
});
it('drags a character action onto the canvas as a saved playable sequence', async () => {
const frames = [
{
imageSrc: '/generated/action/run-frame01.png',
width: 192,
height: 256,
},
{
imageSrc: '/generated/action/run-frame02.png',
width: 192,
height: 256,
},
];
loadEditorAssetLibraryMock.mockResolvedValueOnce({
folders: [
{
folderId: 'project',
label: '项目素材',
sortOrder: 0,
collapsed: false,
systemDefault: true,
},
],
assets: [
{
assetId: 'asset-action-run',
folderId: 'project',
label: '角色奔跑',
imageSrc: frames[0]!.imageSrc,
width: 192,
height: 256,
sourceType: 'generated',
assetKind: 'character-animation',
imageSequenceFrames: frames,
imageSequenceDurationMs: 4_000,
},
],
});
render(<ImageCanvasEditorView />);
const sourceAsset = await screen.findByRole('button', {
name: '添加角色奔跑',
});
const sourceAssetRow = sourceAsset.closest(
'.image-canvas-editor__asset-row',
);
const viewport = screen.getByLabelText('画布工作区');
const dataTransfer = createDataTransferStub();
if (!sourceAssetRow) {
throw new Error('asset row should exist');
}
fireEvent.dragStart(sourceAssetRow, { dataTransfer });
fireEvent.drop(viewport, {
clientX: 520,
clientY: 300,
dataTransfer,
});
expect(
await screen.findByAltText('画布序列帧:角色奔跑'),
).toBeTruthy();
expect(
screen.getByRole('button', { name: '暂停角色奔跑序列帧播放' }),
).toBeTruthy();
await waitFor(() => {
expect(createEditorProjectResourceMock).toHaveBeenCalledWith(
'editor-project-default',
expect.objectContaining({
assetKind: 'character-animation',
imageSequenceFrames: frames,
imageSequenceDurationMs: 4_000,
}),
);
});
await waitFor(() => {
expect(saveEditorProjectLayoutMock).toHaveBeenCalledWith(
'editor-project-default',
expect.objectContaining({
layers: expect.arrayContaining([
expect.objectContaining({
title: '角色奔跑',
}),
]),
}),
);
});
});
it('keeps the source resource id when dragging a generated asset onto the viewport', async () => {
loadEditorAssetLibraryMock.mockResolvedValueOnce({
folders: [
@@ -3230,20 +3230,32 @@ describe('ImageCanvasEditorView generation integration', () => {
resources: [],
updatedAt: '2026-06-15T00:00:00.000Z',
});
const animationFrames = Array.from({ length: 48 }, (_, index) => ({
imageSrc: `/generated-character-drafts/editor/frame${index + 1}.png`,
width: 1024,
height: 1024,
}));
generateEditorCharacterAnimationMock.mockResolvedValueOnce({
taskId: 'character-animation-task-1',
model: 'seedance2.0-fast',
prompt: '生成游戏角色动画\n动作描述:\n待机',
previewVideoPath: '/generated-character-drafts/editor/preview.mp4',
frames: Array.from({ length: 48 }, (_, index) => ({
frameIndex: index + 1,
imageSrc: `/generated-character-drafts/editor/frame${index + 1}.png`,
width: 1024,
height: 1024,
})),
frames: animationFrames,
frameCount: 48,
durationSeconds: 6,
fps: 8,
resource: {
resourceId: 'resource-character-animation-final',
projectId: 'editor-project-character-animation',
imageSrc: animationFrames[0]!.imageSrc,
width: 1024,
height: 1024,
sourceType: 'generated',
sourceResourceId: 'resource-character-animation-preview',
assetKind: 'character-animation',
imageSequenceFrames: animationFrames,
imageSequenceDurationMs: 6_000,
},
});
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
const requestUrl =
@@ -16,6 +16,7 @@ import {
hydrateCanvasGenerationDialog,
hydrateLayer,
INLINE_GENERATION_PLACEHOLDER_LIVE_WINDOW_MS,
isCanvasAssetKindOverrideCompatible,
normalizeAssetLibrary,
normalizeCanvasBackgroundHex,
PERFECT_PIXEL_RECONCILIATION_WINDOW_MS,
@@ -197,7 +198,7 @@ describe('ImageCanvasEditorModel', () => {
expect(library.folders[0]?.id).toBe('project');
});
it('infers MP3 and MP4 asset media types from persisted paths', () => {
it('uses media types published by the asset API', () => {
const library = normalizeAssetLibrary({
folders: [
{
@@ -218,6 +219,7 @@ describe('ImageCanvasEditorModel', () => {
width: 420,
height: 120,
sourceType: 'uploaded',
assetKind: 'audio',
objectKey:
'generated-character-drafts/editor/asset-library/audio/胜利音效.mp3',
},
@@ -232,6 +234,7 @@ describe('ImageCanvasEditorModel', () => {
width: 560,
height: 315,
sourceType: 'uploaded',
assetKind: 'video',
objectKey:
'generated-character-drafts/editor/asset-library/video/开场动画.mp4',
},
@@ -253,10 +256,63 @@ describe('ImageCanvasEditorModel', () => {
),
).toMatchObject({
mediaType: 'audio',
assetKind: 'sound-effect',
assetKind: 'audio',
});
});
it('infers kinds for legacy persisted media assets from source extensions', () => {
const library = normalizeAssetLibrary({
folders: [],
assets: [
{
assetId: 'legacy-video',
folderId: 'project',
label: '旧开场动画.mp4',
imageSrc: '/legacy/opening.mp4?signature=video',
width: 1280,
height: 720,
sourceType: 'uploaded',
},
{
assetId: 'legacy-audio',
folderId: 'project',
label: '旧背景音乐',
imageSrc: '/api/assets/read',
objectKey: 'legacy/music.mp3?version=1',
width: 420,
height: 120,
sourceType: 'uploaded',
assetKind: 'unrecognized-legacy-kind',
},
],
});
expect(library.assets[0]).toMatchObject({
mediaType: 'video',
assetKind: 'video',
});
expect(library.assets[1]).toMatchObject({
mediaType: 'audio',
assetKind: 'background-music',
});
expect(
library.assets.map((asset, index) =>
createLayerFromAsset(
asset,
index,
{ x: 0, y: 0, scale: 1 },
{ x: 300, y: 200 },
),
),
).toEqual([
expect.objectContaining({ mediaType: 'video', assetKind: 'video' }),
expect.objectContaining({
mediaType: 'audio',
assetKind: 'background-music',
}),
]);
});
it('creates a cascaded layer from an account asset near the requested screen point', () => {
const asset: EditorAsset = {
id: 'asset-1',
@@ -344,6 +400,7 @@ describe('ImageCanvasEditorModel', () => {
width: 640,
height: 640,
sourceType: 'generated',
assetKind: 'image',
sourceResourceId: 'resource-generated',
},
],
@@ -355,6 +412,87 @@ describe('ImageCanvasEditorModel', () => {
});
});
it('round-trips an explicit character action into a movable sequence layer', () => {
const frames = [
{
imageSrc: '/generated/action/frame01.png',
width: 192,
height: 256,
},
{
imageSrc: '/generated/action/frame02.png',
width: 192,
height: 256,
},
];
const library = normalizeAssetLibrary({
folders: [
{
folderId: 'project',
label: '项目素材',
sortOrder: 0,
collapsed: false,
systemDefault: true,
},
],
assets: [
{
assetId: 'asset-action',
folderId: 'project',
label: '角色挥手',
imageSrc: frames[0]!.imageSrc,
width: 192,
height: 256,
sourceType: 'generated',
assetKind: 'character-animation',
imageSequenceFrames: frames,
imageSequenceDurationMs: 4_000,
},
],
});
const asset = library.assets[0] as EditorAsset;
const layer = createLayerFromAsset(
asset,
1,
{ x: 0, y: 0, scale: 1 },
{ x: 400, y: 300 },
{ applyCascadeOffset: false },
);
expect(asset.mediaType).toBe('image-sequence');
expect(layer).toMatchObject({
mediaType: 'image-sequence',
imageSequenceFrames: frames,
imageSequenceDurationMs: 4_000,
x: 304,
y: 172,
});
});
it('rejects an explicitly typed but incomplete character action', () => {
expect(() =>
createLayerFromAsset(
{
id: 'asset-corrupt-action',
label: '损坏动作',
src: '/generated/action/frame01.png',
mediaType: 'image-sequence',
width: 192,
height: 256,
folderId: 'project',
sourceKind: 'uploaded',
sourceType: 'generated',
persisted: true,
assetKind: 'character-animation',
},
1,
{ x: 0, y: 0, scale: 1 },
{ x: 400, y: 300 },
),
).toThrow('缺少可用序列帧');
});
it('serializes and hydrates canvas layer metadata without embedding image payloads', () => {
const layer: CanvasLayer = {
id: 'layer-generated',
@@ -458,7 +596,7 @@ describe('ImageCanvasEditorModel', () => {
width: 320,
height: 240,
},
],
] as unknown as CanvasLayer['imageSequenceFrames'],
model: 'seedance2.0-fast',
provider: 'ark',
};
@@ -526,6 +664,7 @@ describe('ImageCanvasEditorModel', () => {
};
expect(hydrateLayer(validSequence, new Map())).toMatchObject({
mediaType: 'image-sequence',
resourcePersistenceState: 'self-contained-local',
});
expect(
@@ -553,6 +692,75 @@ describe('ImageCanvasEditorModel', () => {
).toMatchObject({ resourcePersistenceState: 'unresolved-local' });
});
it('derives audio and video media types from assetKind without persisting mediaType', () => {
for (const [mediaType, assetKind] of [
['video', 'video'],
['audio', 'background-music'],
] as const) {
const resourceId = `${mediaType}-resource`;
const resources = new Map([
[
resourceId,
{
imageSrc:
mediaType === 'video'
? '/legacy/cutscene.mp4'
: '/legacy/theme.mp3',
assetKind,
},
],
]);
const firstHydration = hydrateLayer(
{
layerId: `${mediaType}-layer`,
resourceId,
title: mediaType,
sourceType: 'uploaded',
assetKind,
},
resources,
);
expect(firstHydration?.mediaType).toBe(mediaType);
if (!firstHydration) {
throw new Error(`${mediaType} layer should hydrate`);
}
const savedSnapshot = serializeLayer(firstHydration);
expect(savedSnapshot.mediaType).toBe(mediaType);
expect(savedSnapshot.assetKind).toBeUndefined();
const secondHydration = hydrateLayer(savedSnapshot, resources);
expect(secondHydration?.mediaType).toBe(mediaType);
}
});
it('preserves legacy audio and video media types when assetKind is absent', () => {
for (const mediaType of ['video', 'audio'] as const) {
const hydrated = hydrateLayer(
{
layerId: `legacy-${mediaType}-layer`,
resourceId: `legacy-${mediaType}-resource`,
title: `${mediaType}`,
sourceType: 'uploaded',
mediaType,
},
new Map([
[
`legacy-${mediaType}-resource`,
{
imageSrc:
mediaType === 'video'
? '/legacy/cutscene.mp4'
: '/legacy/theme.mp3',
},
],
]),
);
expect(hydrated?.mediaType).toBe(mediaType);
}
});
it('recovers the normal source model after the owner payload removes an internal model', () => {
const hydrated = hydrateLayer(
{
@@ -661,7 +869,65 @@ describe('ImageCanvasEditorModel', () => {
});
});
it('hydrates audio media type and sound effect asset kind from saved layout', () => {
it('falls back from an incompatible persisted override without dropping the layer', () => {
const onAssetKindOverrideFallback = vi.fn();
const hydrated = hydrateLayer(
{
layerId: 'layer-image-with-action-label',
resourceId: 'resource-image',
title: '普通图片',
x: 10,
y: 20,
width: 320,
height: 240,
originalWidth: 320,
originalHeight: 240,
zIndex: 1,
sourceType: 'uploaded',
assetKindOverride: 'character-animation',
},
new Map([
['resource-image', { imageSrc: '/read/image.png', assetKind: 'image' }],
]),
{ onAssetKindOverrideFallback },
);
expect(hydrated).toMatchObject({
id: 'layer-image-with-action-label',
src: '/read/image.png',
mediaType: 'image',
resourceAssetKind: 'image',
assetKindOverride: null,
assetKind: 'image',
});
expect(onAssetKindOverrideFallback).toHaveBeenCalledWith({
layerId: 'layer-image-with-action-label',
resourceId: 'resource-image',
resourceAssetKind: 'image',
rejectedAssetKindOverride: 'character-animation',
});
});
it('uses the same media-family compatibility matrix for layer labels', () => {
expect(isCanvasAssetKindOverrideCompatible('image', 'character')).toBe(
true,
);
expect(isCanvasAssetKindOverrideCompatible(null, 'icon')).toBe(true);
expect(
isCanvasAssetKindOverrideCompatible('sound-effect', 'background-music'),
).toBe(true);
expect(isCanvasAssetKindOverrideCompatible('image', 'video')).toBe(false);
expect(
isCanvasAssetKindOverrideCompatible('image', 'character-animation'),
).toBe(false);
expect(isCanvasAssetKindOverrideCompatible('video', 'character')).toBe(
false,
);
expect(isCanvasAssetKindOverrideCompatible('audio', 'icon')).toBe(false);
expect(isCanvasAssetKindOverrideCompatible('video', null)).toBe(true);
});
it('hydrates audio display duration only from resource generation inputs', () => {
const hydrated = hydrateLayer(
{
layerId: 'layer-audio',
@@ -677,14 +943,16 @@ describe('ImageCanvasEditorModel', () => {
sourceType: 'generated',
mediaType: 'audio',
assetKind: 'sound-effect',
durationSeconds: 2.4,
},
new Map([
[
'resource-audio',
{
imageSrc: '/generated-character-drafts/editor-audios/sfx.mp3',
durationSeconds: 2.4,
generationInputs: {
fields: [{ title: '时长', value: '3.5秒' }],
references: [],
},
},
],
]),
@@ -695,11 +963,15 @@ describe('ImageCanvasEditorModel', () => {
src: '/generated-character-drafts/editor-audios/sfx.mp3',
mediaType: 'audio',
assetKind: 'sound-effect',
durationSeconds: 2.4,
generationInputs: {
fields: [{ title: '时长', value: '3.5秒' }],
references: [],
},
});
expect(hydrated).not.toHaveProperty('durationSeconds');
});
it('serializes and hydrates image sequence frames for character animation layers', () => {
it('hydrates character animation sequence fields from the project resource', () => {
const layer: CanvasLayer = {
id: 'layer-action',
resourceId: 'resource-action',
@@ -709,7 +981,6 @@ describe('ImageCanvasEditorModel', () => {
thumbnailSrc: '/generated-character-drafts/editor/frame01.png',
imageSequenceFrames: [
{
frameIndex: 1,
imageSrc: '/generated-character-drafts/editor/frame01.png',
objectKey: 'generated-character-drafts/editor/frame01.png',
assetObjectId: 'assetobj-frame-01',
@@ -717,7 +988,6 @@ describe('ImageCanvasEditorModel', () => {
height: 1024,
},
{
frameIndex: 2,
imageSrc: '/generated-character-drafts/editor/frame02.png',
objectKey: 'generated-character-drafts/editor/frame02.png',
assetObjectId: 'assetobj-frame-02',
@@ -725,7 +995,6 @@ describe('ImageCanvasEditorModel', () => {
height: 1024,
},
],
previewVideoPath: '/generated-character-drafts/editor/preview.mp4',
x: 10,
y: 20,
width: 420,
@@ -735,15 +1004,14 @@ describe('ImageCanvasEditorModel', () => {
zIndex: 6,
sourceType: 'generated',
assetKind: 'character-animation',
durationSeconds: 4,
imageSequenceDurationMs: 4_000,
};
const snapshot = serializeLayer(layer);
expect(snapshot.thumbnailSrc).toBe(
'/generated-character-drafts/editor/frame01.png',
);
expect(snapshot.mediaType).toBe('image-sequence');
expect(snapshot.imageSequenceFrames).toHaveLength(2);
expect(snapshot.thumbnailSrc).toBeUndefined();
expect(snapshot.imageSequenceFrames).toBeUndefined();
expect(snapshot.imageSequenceDurationMs).toBeUndefined();
expect(snapshot.mediaType).toBeUndefined();
const hydrated = hydrateLayer(
snapshot,
@@ -753,6 +1021,8 @@ describe('ImageCanvasEditorModel', () => {
{
imageSrc: layer.src,
assetKind: 'character-animation',
imageSequenceFrames: layer.imageSequenceFrames,
imageSequenceDurationMs: 4_000,
},
],
]),
@@ -762,12 +1032,55 @@ describe('ImageCanvasEditorModel', () => {
mediaType: 'image-sequence',
thumbnailSrc: '/generated-character-drafts/editor/frame01.png',
imageSequenceFrames: layer.imageSequenceFrames,
previewVideoPath: '/generated-character-drafts/editor/preview.mp4',
assetKind: 'character-animation',
durationSeconds: 4,
imageSequenceDurationMs: 4_000,
});
});
it('rejects an action layer when its project resource lacks formal sequence fields', () => {
const frames = [
{
imageSrc: '/generated-character-drafts/editor/legacy-frame01.png',
objectKey: 'generated-character-drafts/editor/legacy-frame01.png',
assetObjectId: 'assetobj-legacy-frame-01',
width: 512,
height: 512,
},
{
imageSrc: '/generated-character-drafts/editor/legacy-frame02.png',
objectKey: 'generated-character-drafts/editor/legacy-frame02.png',
assetObjectId: 'assetobj-legacy-frame-02',
width: 512,
height: 512,
},
];
const resources = new Map([
[
'resource-legacy-action',
{
imageSrc: frames[0]!.imageSrc,
assetKind: 'character-animation',
},
],
]);
const hydration = hydrateLayer(
{
layerId: 'layer-legacy-action',
resourceId: 'resource-legacy-action',
title: '历史角色动作',
sourceType: 'generated',
assetKind: 'character-animation',
imageSequenceFrames: frames,
imageSequenceDurationMs: 3_600,
previewVideoPath:
'/generated-character-drafts/editor/legacy-preview.mp4',
},
resources,
);
expect(hydration).toBeNull();
});
it('restores audio generation dialogs from saved layout', () => {
const dialog: CanvasGenerationDialogState = {
id: 'generation-dialog-audio',
File diff suppressed because it is too large Load Diff
@@ -1,11 +1,11 @@
import type {
EditorAssetSnapshot,
EditorCharacterAnimationFrameCount,
EditorCharacterAnimationFrameResult,
EditorCharacterAnimationGenerationResult,
EditorCharacterAnimationRatio,
EditorCharacterAnimationResolution,
EditorImageGenerationStyle,
EditorImageSequenceFrameResult,
EditorPixelArtSnapInput,
EditorVideoAspectRatio,
EditorVideoModel,
@@ -16,6 +16,8 @@ import type {
export type CanvasSourceType = 'uploaded' | 'generated' | 'mock_generated';
export type CanvasAssetKind =
| 'image'
| 'audio'
| 'spec'
| 'character'
| 'character-animation'
@@ -59,7 +61,8 @@ export type EditorAsset = {
showcaseSubmitError?: string | null;
assetKind?: CanvasAssetKind | null;
generationInputs?: CanvasGenerationInputs | null;
durationSeconds?: number;
imageSequenceFrames?: EditorImageSequenceFrameResult[];
imageSequenceDurationMs?: number;
uploadStatus?: 'uploading' | 'failed';
uploadProgress?: number;
uploadMessage?: string;
@@ -114,8 +117,8 @@ export type CanvasLayer = {
assetKindOverride?: CanvasAssetKind | null;
assetKind?: CanvasAssetKind | null;
generationInputs?: CanvasGenerationInputs | null;
durationSeconds?: number;
imageSequenceFrames?: EditorCharacterAnimationFrameResult[];
imageSequenceFrames?: EditorImageSequenceFrameResult[];
imageSequenceDurationMs?: number;
previewVideoPath?: string | null;
hidden?: boolean;
locked?: boolean;
@@ -267,6 +267,16 @@ export function setupImageCanvasEditorViewTestLifecycle({
immediateAsync(withEditorProjectCanvasRevision({
projectId: 'editor-project-default',
title: '默认项目',
canvas: {
canvasId: 'editor-project-default:canvas:default',
projectId: 'editor-project-default',
title: '默认画布',
viewport: { x: 0, y: 0, scale: 1 },
layers: defaultEditorProjectLayers,
revision: 0,
layoutStorageVersion: 0,
updatedAt: '2026-06-12T00:00:00.000Z',
},
viewport: { x: 0, y: 0, scale: 1 },
layers: defaultEditorProjectLayers,
resources: defaultEditorProjectResources,
@@ -299,7 +309,8 @@ export function setupImageCanvasEditorViewTestLifecycle({
sourceType: input.sourceType,
assetKind: input.assetKind,
generationInputs: input.generationInputs,
durationSeconds: input.durationSeconds,
imageSequenceFrames: input.imageSequenceFrames,
imageSequenceDurationMs: input.imageSequenceDurationMs,
}));
createEditorAssetFolderMock.mockResolvedValue({
folderId: 'folder-role-persisted',
@@ -353,12 +364,20 @@ export function setupImageCanvasEditorViewTestLifecycle({
width: input.width,
height: input.height,
sourceType: input.sourceType,
durationSeconds: input.durationSeconds,
assetKind: input.assetKind,
generationInputs: input.generationInputs,
imageSequenceFrames: input.imageSequenceFrames,
imageSequenceDurationMs: input.imageSequenceDurationMs,
}),
);
saveEditorProjectLayoutMock.mockImplementation(
async (projectId, input) => ({
projectId,
canvasId: `${projectId}:canvas:default`,
revision: input.expectedRevision + 1,
updatedAt: '2026-06-12T00:00:01.000Z',
}),
);
saveEditorProjectLayoutMock.mockResolvedValue({});
});
afterEach(() => {
@@ -1478,7 +1478,7 @@ describe('ImageCanvasEditorView', () => {
fields: [
{ title: '音效提示词', value: '金币跳出音' },
{ title: 'model', value: 'audio1.0' },
{ title: 'duration', value: '8秒' },
{ title: '时长', value: '8秒' },
],
references: [
{
@@ -38,6 +38,7 @@ import {
canvasAssetKindOrNull,
DEFAULT_CANVAS_BACKGROUND_COLOR,
generationInputsOrNull,
isCanvasAssetKindOverrideCompatible,
isInlineEditorMediaSource,
resolveContextMenuPosition,
resolveLayerResourceAssetKind,
@@ -305,7 +306,8 @@ function createAssetActionLayer(asset: EditorAsset): CanvasLayer {
sourceAssetId: asset.id,
assetKind: asset.assetKind ?? null,
generationInputs: asset.generationInputs ?? null,
durationSeconds: asset.durationSeconds,
imageSequenceFrames: asset.imageSequenceFrames,
imageSequenceDurationMs: asset.imageSequenceDurationMs,
};
}
@@ -1018,11 +1020,22 @@ export function ImageCanvasEditorView({
model: layer.model,
provider: layer.provider,
taskId: layer.taskId,
durationSeconds: layer.durationSeconds,
imageSequenceFrames: layer.imageSequenceFrames,
imageSequenceDurationMs: layer.imageSequenceDurationMs,
assetKind: layer.assetKind,
generationInputs: layer.generationInputs,
})
.then((asset) => {
const persistedAssetKind = canvasAssetKindOrNull(asset.assetKind);
if (
layer.assetKind === 'character-animation' &&
(persistedAssetKind !== 'character-animation' ||
(asset.imageSequenceFrames?.length ?? 0) < 2 ||
!(asset.imageSequenceDurationMs &&
asset.imageSequenceDurationMs > 0))
) {
throw new Error('服务器未返回完整的角色动作正式字段');
}
setAssets((currentAssets) => [
...currentAssets.filter(
(currentAsset) => currentAsset.id !== asset.assetId,
@@ -1045,8 +1058,10 @@ export function ImageCanvasEditorView({
taskId: asset.taskId ?? undefined,
objectKey: asset.objectKey ?? undefined,
assetObjectId: asset.assetObjectId ?? undefined,
durationSeconds: asset.durationSeconds ?? layer.durationSeconds,
assetKind: canvasAssetKindOrNull(asset.assetKind),
imageSequenceFrames: asset.imageSequenceFrames ?? undefined,
imageSequenceDurationMs:
asset.imageSequenceDurationMs ?? undefined,
assetKind: persistedAssetKind,
generationInputs: generationInputsOrNull(asset.generationInputs),
},
]);
@@ -1075,13 +1090,19 @@ export function ImageCanvasEditorView({
);
})
.catch((error: unknown) => {
if (
const isAuthError =
error instanceof Error &&
'status' in error &&
(error.status === 401 || error.status === 403)
) {
(error.status === 401 || error.status === 403);
if (isAuthError) {
openEditorLoginModal();
return;
}
window.alert(
error instanceof Error && error.message.trim()
? error.message
: '素材保存失败,请稍后重试',
);
});
},
[activeUploadFolderId, openEditorLoginModal, setAssetFolders, setAssets],
@@ -1110,7 +1131,8 @@ export function ImageCanvasEditorView({
objectKey: asset.objectKey ?? undefined,
assetObjectId: asset.assetObjectId ?? undefined,
thumbnailSrc: asset.thumbnailSrc ?? undefined,
durationSeconds: asset.durationSeconds ?? undefined,
imageSequenceFrames: asset.imageSequenceFrames ?? undefined,
imageSequenceDurationMs: asset.imageSequenceDurationMs ?? undefined,
assetKind: canvasAssetKindOrNull(asset.assetKind),
generationInputs: generationInputsOrNull(asset.generationInputs),
},
@@ -1178,6 +1200,7 @@ export function ImageCanvasEditorView({
const {
projectId,
isProjectReady,
assetKindFallbackNotice,
appendCanvasLayersWithResources,
applyProjectSnapshot,
flushProjectPersistence,
@@ -1253,7 +1276,8 @@ export function ImageCanvasEditorView({
model: layer.model,
provider: layer.provider,
taskId: layer.taskId,
durationSeconds: layer.durationSeconds,
imageSequenceFrames: layer.imageSequenceFrames,
imageSequenceDurationMs: layer.imageSequenceDurationMs,
sourceResourceId: layer.sourceResourceId,
assetKind: resourceAssetKind,
generationInputs: layer.generationInputs,
@@ -1307,6 +1331,7 @@ export function ImageCanvasEditorView({
isExportingAssets,
exportCanvasAssets,
exportLayerImage,
reportAssetError,
} = useImageCanvasAssetExportWorkflow({
layers,
projectId,
@@ -1407,6 +1432,11 @@ export function ImageCanvasEditorView({
generationSurface.refreshTaskList();
}, [generationSurface]);
const showGenerationWarning = generationSurface.showGenerationWarning;
useEffect(() => {
if (assetKindFallbackNotice) {
showGenerationWarning(assetKindFallbackNotice.message);
}
}, [assetKindFallbackNotice, showGenerationWarning]);
useEffect(() => {
if (deadInlinePlaceholderDropCount === 0) {
return;
@@ -2045,12 +2075,16 @@ export function ImageCanvasEditorView({
if (!targetLayer || targetLayer.assetKind === assetKind) {
return;
}
const resourceAssetKind = resolveLayerResourceAssetKind(targetLayer);
if (!isCanvasAssetKindOverrideCompatible(resourceAssetKind, assetKind)) {
showGenerationWarning('该标签与素材的媒体类型不兼容,已保留原标签。');
return;
}
captureCanvasHistory({
type: 'change-asset-kind',
count: 1,
layerIds: [layerId],
});
const resourceAssetKind = resolveLayerResourceAssetKind(targetLayer);
const assetKindOverride =
assetKind === resourceAssetKind ? null : assetKind;
const nextLayer = {
@@ -2072,7 +2106,7 @@ export function ImageCanvasEditorView({
setImageContextMenu(null);
setContextMenu(null);
},
[captureCanvasHistory],
[captureCanvasHistory, showGenerationWarning],
);
const selectAllCanvasObjects = useCallback(() => {
const layerSelectionIds = layersRef.current
@@ -2331,6 +2365,7 @@ export function ImageCanvasEditorView({
appendCanvasLayersWithResources,
selectSingleLayer,
addUploadedFiles,
onAssetError: reportAssetError,
});
deleteLayerByIdRef.current = deleteLayerById;
@@ -98,7 +98,6 @@ describe('ImageCanvasExportModel', () => {
taskId: 'sequence-task',
imageSequenceFrames: [
{
frameIndex: 1,
imageSrc: '/generated-character-drafts/editor/frame01.png',
width: 1024,
height: 1024,
@@ -195,7 +194,7 @@ describe('ImageCanvasExportModel', () => {
},
{ title: '用户输入', value: '用户填写的视觉要求' },
{ title: '游戏分类', value: '游戏音效' },
{ title: 'duration', value: '5秒' },
{ title: '时长', value: '5秒' },
{ title: '处理模型', value: 'birefnet' },
],
references: [],
@@ -208,7 +207,7 @@ describe('ImageCanvasExportModel', () => {
fields: [
{ title: '用户输入', value: '用户填写的视觉要求' },
{ title: '游戏分类', value: '游戏音效' },
{ title: 'duration', value: '5秒' },
{ title: '时长', value: '5秒' },
],
references: [],
});
@@ -220,15 +219,15 @@ describe('ImageCanvasExportModel', () => {
mediaType: 'image-sequence',
originalWidth: 192,
originalHeight: 256,
durationSeconds: 0.5,
imageSequenceDurationMs: 500,
});
const spineJson = buildSpineImageSequenceJson({
layer,
frames: [
{ frameIndex: 1, fileName: 'frame-01.png', width: 192, height: 256 },
{ frameIndex: 2, fileName: 'frame-02.png', width: 192, height: 256 },
{ frameIndex: 3, fileName: 'frame-03.png', width: 192, height: 256 },
{ frameIndex: 4, fileName: 'frame-04.png', width: 192, height: 256 },
{ fileName: 'frame-01.png', width: 192, height: 256 },
{ fileName: 'frame-02.png', width: 192, height: 256 },
{ fileName: 'frame-03.png', width: 192, height: 256 },
{ fileName: 'frame-04.png', width: 192, height: 256 },
],
});
@@ -386,7 +385,6 @@ describe('ImageCanvasExportModel', () => {
mediaType: 'image-sequence',
imageSequenceFrames: [
{
frameIndex: 1,
imageSrc: '/generated-editor-frames/frame-1.png',
objectKey: 'generated/frame-1.png',
width: 512,
@@ -395,7 +393,6 @@ describe('ImageCanvasExportModel', () => {
],
}),
{
frameIndex: 1,
imageSrc: '/generated-editor-frames/frame-1.png',
objectKey: 'generated/frame-1.png',
width: 512,
@@ -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
@@ -154,7 +153,6 @@ export async function readAssetSourceBlob({
}: {
source: string;
objectKey?: string | null;
refreshKey?: string | number | null;
}) {
if (source.startsWith('data:')) {
return dataUrlToBlob(source);
@@ -167,37 +165,22 @@ export async function readLayerAssetBlob(layer: CanvasLayer) {
return readAssetSourceBlob({
source: layer.src,
objectKey: layer.objectKey,
refreshKey: layer.taskId ?? layer.resourceId,
});
}
export const readLayerImageBlob = readLayerAssetBlob;
export function getLayerImageSequenceFrames(layer: CanvasLayer) {
if (layer.imageSequenceFrames?.length) {
return layer.imageSequenceFrames;
}
const fallbackSrc = layer.thumbnailSrc?.trim() || layer.src.trim();
return fallbackSrc
? [
{
frameIndex: 1,
imageSrc: fallbackSrc,
width: layer.originalWidth,
height: layer.originalHeight,
},
]
: [];
return layer.imageSequenceFrames ?? [];
}
export async function readLayerImageSequenceFrameBlob(
layer: CanvasLayer,
_layer: CanvasLayer,
frame: ReturnType<typeof getLayerImageSequenceFrames>[number],
) {
return readAssetSourceBlob({
source: frame.imageSrc,
objectKey: frame.objectKey,
refreshKey: `${layer.taskId ?? layer.resourceId}:${frame.frameIndex}`,
});
}
@@ -207,12 +190,11 @@ export function getImageSequenceFrameFileName(
blobType = '',
) {
const extension = getImageExtensionFromTypeOrSrc(blobType, frame.imageSrc);
const frameNumber = String(frame.frameIndex || index + 1).padStart(2, '0');
const frameNumber = String(index + 1).padStart(2, '0');
return `frame-${frameNumber}.${extension}`;
}
export type ExportedImageSequenceFrame = {
frameIndex: number;
fileName: string;
width: number;
height: number;
@@ -418,8 +400,9 @@ export function buildSpineImageSequenceJson({
}
const durationSeconds =
typeof layer.durationSeconds === 'number' && layer.durationSeconds > 0
? layer.durationSeconds
typeof layer.imageSequenceDurationMs === 'number' &&
layer.imageSequenceDurationMs > 0
? layer.imageSequenceDurationMs / 1_000
: frames.length;
const frameDuration = durationSeconds / frames.length;
const skeletonWidth = frames[0]?.width || layer.originalWidth;
@@ -579,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: [],
},
}),
@@ -531,7 +531,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,31 +589,47 @@ 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();
});
it('creates character animation result layers as playable image sequences', () => {
const frames = [
{
imageSrc: '/generated-character-drafts/editor/frame1.png',
width: 1024,
height: 1024,
},
{
imageSrc: '/generated-character-drafts/editor/frame2.png',
width: 1024,
height: 1024,
},
];
const layer = createCharacterAnimationResultLayer({
generated: {
taskId: 'character-animation-task-1',
model: 'seedance2.0-fast',
prompt: '待机动作',
previewVideoPath: '/generated-character-drafts/editor/preview.mp4',
frames: [
{
frameIndex: 1,
imageSrc: '/generated-character-drafts/editor/frame1.png',
width: 1024,
height: 1024,
},
],
frameCount: 32,
frames,
frameCount: 2,
durationSeconds: 4,
fps: 8,
priceMudPoints: 40,
resource: {
resourceId: 'resource-character-animation-15',
projectId: 'project-1',
imageSrc: frames[0]!.imageSrc,
width: 1024,
height: 1024,
sourceType: 'generated',
assetKind: 'character-animation',
imageSequenceFrames: frames,
imageSequenceDurationMs: 4_000,
},
},
generatedIndex: 15,
title: '角色动作',
@@ -625,20 +651,13 @@ describe('ImageCanvasGenerationLayerModel', () => {
expect(layer).toMatchObject({
id: 'layer-character-animation-15',
resourceId: 'local-resource-character-animation-15',
resourceId: 'resource-character-animation-15',
title: '角色动作',
src: '/generated-character-drafts/editor/frame1.png',
mediaType: 'image-sequence',
thumbnailSrc: '/generated-character-drafts/editor/frame1.png',
imageSequenceFrames: [
{
frameIndex: 1,
imageSrc: '/generated-character-drafts/editor/frame1.png',
width: 1024,
height: 1024,
},
],
previewVideoPath: '/generated-character-drafts/editor/preview.mp4',
imageSequenceFrames: frames,
previewVideoPath: null,
x: -222,
y: -242,
width: 1024,
@@ -649,12 +668,131 @@ describe('ImageCanvasGenerationLayerModel', () => {
sourceType: 'generated',
prompt: '待机动作',
actualPrompt: '待机动作',
imageSequenceDurationMs: 4_000,
model: 'seedance2.0-fast',
taskId: 'character-animation-task-1',
assetKind: 'character-animation',
durationSeconds: 4,
});
expect(layer?.generationInputs?.fields[0]?.value).toBe('待机动作');
expect(layer?.provider).toBeUndefined();
});
it('links a character animation result layer to its persisted project resource', () => {
const frames = [
{
imageSrc: '/generated-character-drafts/editor/frame1.png',
objectKey: 'generated-character-drafts/editor/frame1.png',
assetObjectId: 'asset-object-frame-1',
width: 512,
height: 768,
},
{
imageSrc: '/generated-character-drafts/editor/frame2.png',
objectKey: 'generated-character-drafts/editor/frame2.png',
assetObjectId: 'asset-object-frame-2',
width: 512,
height: 768,
},
];
const layer = createCharacterAnimationResultLayer({
generated: {
taskId: 'character-animation-task-persisted',
model: 'seedance2.0-fast',
prompt: '奔跑动作',
previewVideoPath: '/generated-character-drafts/editor/preview.mp4',
frames,
frameCount: 2,
durationSeconds: 4,
fps: 8,
priceMudPoints: 40,
resource: {
resourceId: 'resource-character-animation-final',
projectId: 'project-1',
imageSrc: frames[0]!.imageSrc,
objectKey: frames[0]!.objectKey,
assetObjectId: frames[0]!.assetObjectId,
width: 512,
height: 768,
sourceType: 'generated',
sourceResourceId: 'resource-character-animation-preview',
assetKind: 'character-animation',
imageSequenceFrames: frames,
imageSequenceDurationMs: 4_000,
},
},
generatedIndex: 16,
title: '角色奔跑',
canvasSize: { width: 900, height: 640 },
viewport: { x: 0, y: 0, scale: 1 },
generationInputs: createGenerationInputs(),
sourceLayer: createSourceLayer({
resourceId: 'resource-original-character',
}),
});
expect(layer).toMatchObject({
resourceId: 'resource-character-animation-final',
src: frames[0]!.imageSrc,
objectKey: frames[0]!.objectKey,
assetObjectId: frames[0]!.assetObjectId,
sourceResourceId: 'resource-character-animation-preview',
imageSequenceFrames: frames,
imageSequenceDurationMs: 4_000,
});
});
it('keeps persisted animation lineage when generation returns only an asset', () => {
const frames = [
{
imageSrc: '/generated-character-drafts/editor/frame1.png',
width: 512,
height: 768,
},
{
imageSrc: '/generated-character-drafts/editor/frame2.png',
width: 512,
height: 768,
},
];
const layer = createCharacterAnimationResultLayer({
generated: {
taskId: 'character-animation-task-asset-only',
model: 'seedance2.0-fast',
prompt: '跳跃动作',
previewVideoPath: '/generated-character-drafts/editor/preview.mp4',
frames,
frameCount: 2,
durationSeconds: 4,
fps: 8,
priceMudPoints: 40,
asset: {
assetId: 'asset-character-animation-final',
folderId: 'user-1:asset-folder:project',
label: '角色跳跃',
imageSrc: frames[0]!.imageSrc,
width: 512,
height: 768,
sourceType: 'generated',
sourceResourceId: 'resource-character-animation-preview',
assetKind: 'character-animation',
imageSequenceFrames: frames,
imageSequenceDurationMs: 4_000,
},
},
generatedIndex: 17,
title: '角色跳跃',
canvasSize: { width: 900, height: 640 },
viewport: { x: 0, y: 0, scale: 1 },
generationInputs: createGenerationInputs(),
sourceLayer: createSourceLayer({
resourceId: 'resource-original-character',
}),
});
expect(layer).toMatchObject({
resourceId: 'local-resource-asset-character-animation-final',
sourceResourceId: 'resource-character-animation-preview',
sourceAssetId: 'asset-character-animation-final',
});
});
});
@@ -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,
};
@@ -529,8 +527,17 @@ export function createCharacterAnimationResultLayer({
frame,
sourceLayer,
}: CharacterAnimationResultLayerOptions): CanvasLayer | null {
const previewFrame = generated.frames[0];
if (!previewFrame) {
const resource = generated.resource ?? undefined;
const asset = generated.asset ?? undefined;
const persisted = resource ?? asset;
const imageSequenceFrames = persisted?.imageSequenceFrames ?? [];
const imageSequenceDurationMs = persisted?.imageSequenceDurationMs;
const previewFrame = imageSequenceFrames[0];
if (
imageSequenceFrames.length < 2 ||
!previewFrame ||
!(imageSequenceDurationMs && imageSequenceDurationMs > 0)
) {
return null;
}
const originalWidth = previewFrame.width || 1024;
@@ -552,13 +559,15 @@ export function createCharacterAnimationResultLayer({
return {
id: `layer-character-animation-${generatedIndex}`,
resourceId: `local-resource-character-animation-${generatedIndex}`,
resourceId:
resource?.resourceId ??
`local-resource-${asset?.assetId ?? `character-animation-${generatedIndex}`}`,
title,
src: previewFrame.imageSrc,
src: persisted?.imageSrc ?? previewFrame.imageSrc,
mediaType: 'image-sequence',
thumbnailSrc: previewFrame.imageSrc,
imageSequenceFrames: generated.frames,
previewVideoPath: generated.previewVideoPath,
imageSequenceFrames,
previewVideoPath: null,
x: frameX ?? worldCenter.x - width / 2,
y: frameY ?? worldCenter.y - height / 2,
width,
@@ -572,9 +581,16 @@ export function createCharacterAnimationResultLayer({
model: generated.model,
taskId: generated.taskId,
assetKind: 'character-animation',
durationSeconds: generated.durationSeconds,
sourceResourceId: sourceLayer?.resourceId,
imageSequenceDurationMs,
objectKey: persisted?.objectKey,
assetObjectId: persisted?.assetObjectId,
sourceAssetId: asset?.assetId,
sourceResourceId:
resource?.sourceResourceId ??
asset?.sourceResourceId ??
sourceLayer?.resourceId,
groupId: sourceLayer?.groupId,
generationInputs,
generationInputs: persisted?.generationInputs ?? generationInputs,
generatedAssetSnapshot: asset,
};
}
@@ -504,8 +504,21 @@ describe('ImageCanvasGenerationModel', () => {
mediaType: 'image-sequence',
src: '/generated-character-drafts/editor/frame01.png',
thumbnailSrc: '/generated-character-drafts/editor/frame01.png',
imageSequenceFrames: [
{
imageSrc: '/generated-character-drafts/editor/frame01.png',
objectKey: 'generated-character-drafts/editor/frame01.png',
width: 512,
height: 512,
},
{
imageSrc: '/generated-character-drafts/editor/frame02.png',
width: 512,
height: 512,
},
],
}),
).toBe('/generated-character-drafts/editor/frame01.png');
).toBe('generated-character-drafts/editor/frame01.png');
});
it('maps generation dialog mode and authorization errors to user-facing copy', () => {
@@ -533,7 +546,7 @@ describe('ImageCanvasGenerationModel', () => {
fields: [
{ title: 'prompt', value: '金币掉落叮当声' },
{ title: 'model', value: 'audio1.0' },
{ title: 'duration', value: '7秒' },
{ title: '时长', value: '7秒' },
],
references: [],
});
@@ -992,8 +992,13 @@ function cloneModelPricing<T extends EditorGenerationPricingConfig['models']>(
}
export function resolveCharacterAnimationSourceImageSrc(layer: CanvasLayer) {
if (layer.assetKind === 'character-animation' && layer.thumbnailSrc?.trim()) {
return layer.thumbnailSrc.trim();
if (layer.assetKind === 'character-animation') {
const firstFrame = layer.imageSequenceFrames?.[0];
const source = firstFrame?.objectKey?.trim() || firstFrame?.imageSrc?.trim();
if (!source) {
throw new Error('角色动作缺少正式序列首帧,无法继续生成。');
}
return source;
}
// 中文注释:角色图已持久化到 OSS 时优先传 objectKey,避免把大 Data URL 塞进 JSON 请求体触发 body limit。
return layer.objectKey?.trim() || layer.src;
@@ -1113,6 +1118,7 @@ export function buildImageGenerationInputs(
export function buildVideoGenerationInputs(
prompt: string,
durationSeconds: number,
references?: CharacterReferenceImage[],
): CanvasGenerationInputs {
const mediaIndexes = {
@@ -1121,7 +1127,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'
@@ -1148,7 +1157,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,
),
},
@@ -254,7 +254,6 @@ describe('ImageCanvasHistoryModel', () => {
it('blocks replacing an image sequence frame', () => {
const frame = {
frameIndex: 0,
imageSrc: '/frames/0.png',
objectKey: 'frames/0.png',
width: 100,
@@ -96,7 +96,6 @@ function hasSameImageSequenceFrames(
const targetFrame = targetFrames[index];
return (
targetFrame !== undefined &&
currentFrame.frameIndex === targetFrame.frameIndex &&
hasSameStableMediaReference(
{
src: currentFrame.imageSrc,
@@ -120,7 +119,8 @@ function hasSameLayerContent(
return (
(current.mediaType ?? 'image') === (target.mediaType ?? 'image') &&
hasSameStableMediaReference(current, target) &&
hasSameImageSequenceFrames(current, target)
hasSameImageSequenceFrames(current, target) &&
current.imageSequenceDurationMs === target.imageSequenceDurationMs
);
}
@@ -137,7 +137,6 @@ describe('ImageCanvasLayerCommandModel', () => {
mediaType: 'image-sequence',
imageSequenceFrames: [
{
frameIndex: 1,
imageSrc: '/historical/frame-1.png',
width: 100,
height: 80,

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