合并最新主线并统一画布素材合同
Project CI / Repository checks (pull_request) Successful in 1m10s
Project CI / Frontend tests (pull_request) Successful in 3m4s
Project CI / Backend tests (pull_request) Successful in 3m39s
Project CI / Native shell tests (pull_request) Successful in 13m22s

合并主线背景音乐与角色动作素材能力
保留无限画布共享渲染和历史恢复合同
统一正式序列帧与素材类型字段
补齐后端、SpacetimeDB 与 CI 回归验证
This commit is contained in:
2026-08-07 10:17:41 +08:00
156 changed files with 22143 additions and 1456 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;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,480 @@
import {
ChevronDown,
ChevronLeft,
ChevronRight,
ChevronUp,
} from 'lucide-react';
import {
type PointerEvent as ReactPointerEvent,
type ReactNode,
useCallback,
useEffect,
useId,
useRef,
useState,
} from 'react';
import { PlatformActionButton } from '../common/PlatformActionButton';
import type { BackgroundMusicPromptPreset } from './ImageCanvasBackgroundMusicPresetModel';
/** 多份等宽队列拼接实现无缝循环:首尾之间不跳回也不留白。 */
const PRESET_QUEUE_COPIES = 3;
/** 初始 scrollLeft 正对中间队列,因此它同时承担唯一的键盘与屏幕阅读器入口。 */
const PRESET_ACCESSIBLE_QUEUE_INDEX = 1;
/** 默认缓慢循环与 hover 加速的速度,单位 px/s。 */
const PRESET_BASE_VELOCITY = 26;
/**
* 加速档按真机实测下调到原值 340 的 70%。左右 15% 控制区与左右箭头共用同一个速度:
* 需求要求“箭头和滚动栏两边都有加速滚动”,两者相邻,拆成两个速度会让指针在箭头与
* 控制区之间移动时出现速度突变。
*/
const PRESET_FAST_VELOCITY = 238;
/** 左右控制区各占 viewport 宽度的 15%,中间 70% 为暂停区。 */
const PRESET_SIDE_ZONE_RATIO = 0.15;
/** 箭头点击的一次离散滚动量,按 viewport 宽度比例计算。 */
const PRESET_ARROW_STEP_RATIO = 0.6;
/** 单帧最大步进:标签页被挂起后回到前台时不产生一次巨大跳变。 */
const PRESET_MAX_FRAME_MS = 100;
/**
* 触摸抬手后判定“惯性已经停下”的空闲窗口。原生惯性可能持续一两秒,固定等待再恢复
* rAF 一定会打断它,所以这里等的是滚动事件停止,而不是一个固定时长。
*/
const PRESET_TOUCH_IDLE_MS = 160;
const PRESET_REDUCED_MOTION_QUERY = '(prefers-reduced-motion: reduce)';
/** 桌面 hover 控制区只在真正支持悬停的精确指针设备上启用。 */
const PRESET_HOVER_CONTROL_QUERY = '(hover: hover) and (pointer: fine)';
type BackgroundMusicPresetZone = 'left' | 'center' | 'right' | null;
function readsMediaQueryMatches(query: string) {
if (
typeof window === 'undefined' ||
typeof window.matchMedia !== 'function'
) {
return false;
}
return window.matchMedia(query).matches;
}
function useMediaQueryMatches(query: string) {
const [matches, setMatches] = useState(() => readsMediaQueryMatches(query));
useEffect(() => {
if (
typeof window === 'undefined' ||
typeof window.matchMedia !== 'function'
) {
return undefined;
}
const mediaQuery = window.matchMedia(query);
// 运行中切换(系统偏好变化、外接鼠标插拔)也要立即生效,不能只读初值。
setMatches(mediaQuery.matches);
const handleChange = (event: MediaQueryListEvent) => {
setMatches(event.matches);
};
mediaQuery.addEventListener('change', handleChange);
return () => {
mediaQuery.removeEventListener('change', handleChange);
};
}, [query]);
return matches;
}
export function ImageCanvasBackgroundMusicPresetMarquee({
presets,
isLocked,
onSelectPreset,
headerTrailing,
}: {
presets: readonly BackgroundMusicPromptPreset[];
isLocked: boolean;
onSelectPreset: (preset: BackgroundMusicPromptPreset) => void;
/** 标题行右端的展示位。字数计数要和「预设」同排,但它属于 composer 的状态。 */
headerTrailing?: ReactNode;
}) {
const trackId = useId();
const [isExpanded, setIsExpanded] = useState(false);
const [zone, setZone] = useState<BackgroundMusicPresetZone>(null);
const [isKeyboardFocused, setIsKeyboardFocused] = useState(false);
const [isPageVisible, setIsPageVisible] = useState(
() =>
typeof document === 'undefined' || document.visibilityState !== 'hidden',
);
const prefersReducedMotion = useMediaQueryMatches(
PRESET_REDUCED_MOTION_QUERY,
);
const supportsHoverControls = useMediaQueryMatches(
PRESET_HOVER_CONTROL_QUERY,
);
const [isTouchScrolling, setIsTouchScrolling] = useState(false);
const viewportRef = useRef<HTMLDivElement | null>(null);
const queueRef = useRef<HTMLDivElement | null>(null);
const queueWidthRef = useRef(0);
const frameRef = useRef<number | null>(null);
const frameTimestampRef = useRef<number | null>(null);
const touchSettleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(
null,
);
// 归位只按实测的单份队列宽度取模,不硬编码 chip 或轨道宽度。
const normalizeScrollPosition = useCallback(() => {
const viewport = viewportRef.current;
const queueWidth = queueWidthRef.current;
if (!viewport || queueWidth <= 0) {
return;
}
if (viewport.scrollLeft < queueWidth * 0.5) {
viewport.scrollLeft += queueWidth;
return;
}
if (viewport.scrollLeft > queueWidth * 1.5) {
viewport.scrollLeft -= queueWidth;
}
}, []);
const velocity = (() => {
if (prefersReducedMotion || isLocked || !isExpanded || !isPageVisible) {
return 0;
}
if (isTouchScrolling || isKeyboardFocused || zone === 'center') {
return 0;
}
if (zone === 'left') {
return -PRESET_FAST_VELOCITY;
}
if (zone === 'right') {
return PRESET_FAST_VELOCITY;
}
return PRESET_BASE_VELOCITY;
})();
useEffect(() => {
if (velocity === 0) {
return undefined;
}
const step = (timestamp: number) => {
const previousTimestamp = frameTimestampRef.current ?? timestamp;
const deltaMs = Math.min(
Math.max(timestamp - previousTimestamp, 0),
PRESET_MAX_FRAME_MS,
);
frameTimestampRef.current = timestamp;
const viewport = viewportRef.current;
if (viewport) {
viewport.scrollLeft += (velocity * deltaMs) / 1000;
normalizeScrollPosition();
}
frameRef.current = requestAnimationFrame(step);
};
frameRef.current = requestAnimationFrame(step);
return () => {
frameTimestampRef.current = null;
if (frameRef.current !== null) {
cancelAnimationFrame(frameRef.current);
frameRef.current = null;
}
};
}, [normalizeScrollPosition, velocity]);
useEffect(() => {
if (typeof document === 'undefined') {
return undefined;
}
const handleVisibilityChange = () => {
setIsPageVisible(document.visibilityState !== 'hidden');
};
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
}, []);
useEffect(() => {
const queue = queueRef.current;
if (!queue || typeof ResizeObserver === 'undefined') {
return undefined;
}
const observer = new ResizeObserver(() => {
const nextQueueWidth = queue.getBoundingClientRect().width;
if (nextQueueWidth <= 0) {
return;
}
const previousQueueWidth = queueWidthRef.current;
queueWidthRef.current = nextQueueWidth;
const viewport = viewportRef.current;
if (!viewport) {
return;
}
// 尺寸变化后按新宽度重新建立基准,避免停在旧队列的相位上。
if (previousQueueWidth <= 0) {
viewport.scrollLeft = nextQueueWidth;
return;
}
normalizeScrollPosition();
});
observer.observe(queue);
return () => {
observer.disconnect();
};
}, [isExpanded, normalizeScrollPosition]);
useEffect(
() => () => {
if (touchSettleTimerRef.current !== null) {
clearTimeout(touchSettleTimerRef.current);
}
},
[],
);
useEffect(() => {
if (isExpanded && !isLocked && supportsHoverControls) {
return;
}
// 收起、锁定或失去 hover 能力时回到默认状态。最后一种尤其重要:没有 hover 的
// 设备不会再有 pointerleave,残留的 `center` 会让循环永久停住。
setZone(null);
setIsKeyboardFocused(false);
}, [isExpanded, isLocked, supportsHoverControls]);
const handleViewportPointerMove = (
event: ReactPointerEvent<HTMLDivElement>,
) => {
// 只在真正支持悬停的精确指针设备上启用;触摸指针再挡一道。控制区按 viewport
// 相对 X 计算,不在 chip 上方覆盖三个透明 div。
if (!supportsHoverControls || event.pointerType === 'touch' || isLocked) {
return;
}
const bounds = event.currentTarget.getBoundingClientRect();
if (bounds.width <= 0) {
return;
}
const ratio = (event.clientX - bounds.left) / bounds.width;
// 位置算不出来时保持当前区域:默认落到“中间暂停”会让指针一进入轨道就停住循环。
if (!Number.isFinite(ratio)) {
return;
}
if (ratio < PRESET_SIDE_ZONE_RATIO) {
setZone('left');
return;
}
if (ratio > 1 - PRESET_SIDE_ZONE_RATIO) {
setZone('right');
return;
}
setZone('center');
};
const handleViewportPointerLeave = (
event: ReactPointerEvent<HTMLDivElement>,
) => {
if (event.pointerType === 'touch') {
return;
}
setZone(null);
};
const armTouchSettleWatch = () => {
if (touchSettleTimerRef.current !== null) {
clearTimeout(touchSettleTimerRef.current);
}
touchSettleTimerRef.current = setTimeout(() => {
touchSettleTimerRef.current = null;
setIsTouchScrolling(false);
}, PRESET_TOUCH_IDLE_MS);
};
const beginTouchScroll = (event: ReactPointerEvent<HTMLDivElement>) => {
if (event.pointerType !== 'touch') {
return;
}
if (touchSettleTimerRef.current !== null) {
clearTimeout(touchSettleTimerRef.current);
touchSettleTimerRef.current = null;
}
setIsTouchScrolling(true);
};
const endTouchScroll = (event: ReactPointerEvent<HTMLDivElement>) => {
if (event.pointerType !== 'touch') {
return;
}
// 抬手只是开始等待:原生惯性可能还要跑一两秒,真正的恢复条件是滚动停止。
armTouchSettleWatch();
};
const handleViewportScroll = () => {
// 只有在等待惯性稳定的窗口内才顺延;否则收到的是 rAF 自己写 scrollLeft 触发的
// scroll,会把恢复条件永远推后。
if (touchSettleTimerRef.current === null) {
return;
}
armTouchSettleWatch();
};
const scrollByArrow = (direction: -1 | 1) => {
const viewport = viewportRef.current;
if (!viewport) {
return;
}
const step =
viewport.getBoundingClientRect().width * PRESET_ARROW_STEP_RATIO;
// 离散滚动不使用平滑动画,供触摸和键盘使用。
viewport.scrollLeft += direction * (step || PRESET_FAST_VELOCITY);
normalizeScrollPosition();
};
return (
<section className="image-canvas-editor__background-music-presets">
<div className="image-canvas-editor__background-music-presets-header">
<span className="image-canvas-editor__background-music-presets-title">
预设
</span>
<PlatformActionButton
type="button"
tone="secondary"
size="xs"
shape="pill"
className="image-canvas-editor__background-music-presets-toggle"
// 图标化后文案不再进入可访问名,必须由 aria-label 承担。
aria-label={isExpanded ? '收起' : '展开'}
aria-expanded={isExpanded}
aria-controls={trackId}
disabled={isLocked}
onClick={() => setIsExpanded((expanded) => !expanded)}
>
{isExpanded ? (
<ChevronUp aria-hidden="true" />
) : (
<ChevronDown aria-hidden="true" />
)}
</PlatformActionButton>
{headerTrailing}
</div>
{isExpanded ? (
<div
id={trackId}
className="image-canvas-editor__background-music-presets-track"
>
<button
type="button"
className="image-canvas-editor__background-music-presets-arrow image-canvas-editor__background-music-presets-arrow--left"
aria-label="预设向左滚动"
disabled={isLocked}
onPointerEnter={(event) => {
if (!supportsHoverControls || event.pointerType === 'touch') {
return;
}
setZone('left');
}}
onPointerLeave={(event) => {
if (event.pointerType === 'touch') {
return;
}
setZone(null);
}}
onClick={() => scrollByArrow(-1)}
>
<ChevronLeft aria-hidden="true" />
</button>
<div
ref={viewportRef}
className="image-canvas-editor__background-music-presets-viewport"
onPointerMove={handleViewportPointerMove}
onPointerLeave={handleViewportPointerLeave}
onPointerDown={beginTouchScroll}
onPointerUp={endTouchScroll}
onPointerCancel={endTouchScroll}
onScroll={handleViewportScroll}
onFocus={() => setIsKeyboardFocused(true)}
onBlur={() => setIsKeyboardFocused(false)}
>
{Array.from({ length: PRESET_QUEUE_COPIES }, (_, copyIndex) => {
const isAccessibleQueue =
copyIndex === PRESET_ACCESSIBLE_QUEUE_INDEX;
return (
<div
key={copyIndex}
ref={isAccessibleQueue ? queueRef : undefined}
className="image-canvas-editor__background-music-presets-queue"
// 中间队列是唯一真实控件队列;首尾只负责无缝循环视觉,不能持有焦点。
aria-hidden={isAccessibleQueue ? undefined : true}
>
{presets.map((preset) => {
const className = `image-canvas-editor__background-music-preset image-canvas-editor__background-music-preset--${preset.group}`;
if (isAccessibleQueue) {
return (
<button
key={preset.id}
type="button"
className={className}
disabled={isLocked}
onClick={() => onSelectPreset(preset)}
>
{preset.label}
</button>
);
}
return (
<span
key={preset.id}
className={`${className}${
isLocked
? ' image-canvas-editor__background-music-preset--disabled'
: ''
}`}
onClick={() => {
if (!isLocked) {
onSelectPreset(preset);
}
}}
>
{preset.label}
</span>
);
})}
</div>
);
})}
</div>
<button
type="button"
className="image-canvas-editor__background-music-presets-arrow image-canvas-editor__background-music-presets-arrow--right"
aria-label="预设向右滚动"
disabled={isLocked}
onPointerEnter={(event) => {
if (!supportsHoverControls || event.pointerType === 'touch') {
return;
}
setZone('right');
}}
onPointerLeave={(event) => {
if (event.pointerType === 'touch') {
return;
}
setZone(null);
}}
onClick={() => scrollByArrow(1)}
>
<ChevronRight aria-hidden="true" />
</button>
<div
className={
zone
? `image-canvas-editor__background-music-presets-hairline image-canvas-editor__background-music-presets-hairline--${zone}`
: 'image-canvas-editor__background-music-presets-hairline'
}
data-zone={zone ?? 'none'}
aria-hidden="true"
/>
</div>
) : null}
</section>
);
}
@@ -0,0 +1,234 @@
import { describe, expect, it } from 'vitest';
import {
appendBackgroundMusicPromptPreset,
BACKGROUND_MUSIC_PROMPT_PRESETS,
type BackgroundMusicPromptPreset,
} from './ImageCanvasBackgroundMusicPresetModel';
// 权威设计《画板音乐生成入口设计》「预设库与追加规则」的固定文案。
// 这张表就是转录校验本身,改动必须先改权威设计。
const AUTHORITATIVE_PRESETS: ReadonlyArray<
[BackgroundMusicPromptPreset['group'], string, string]
> = [
['purpose', '菜单待机', '低干扰、适合菜单待机的背景音乐'],
['purpose', '休闲消除', '轻快可爱的休闲消除背景音乐'],
['purpose', '解谜思考', '安静专注的解谜思考背景音乐'],
['purpose', '探索冒险', '温和推进的探索冒险背景音乐'],
['purpose', '对白场景', '克制柔和、留出对白空间的背景音乐'],
['purpose', '战斗前', '蓄势待发的战斗前背景音乐'],
['purpose', '胜利结算', '明亮满足的胜利结算背景音乐'],
['purpose', '失败结算', '克制低落的失败结算背景音乐'],
['purpose', '日常经营', '轻松有序的日常经营背景音乐'],
['purpose', '农场经营', '自然温暖的农场经营背景音乐'],
['purpose', '校园日常', '青春轻松的校园日常背景音乐'],
['purpose', '美食厨房', '温暖活泼的美食厨房背景音乐'],
['atmosphere', '温暖治愈', '柔和明亮的治愈背景音乐'],
['atmosphere', '神秘悬疑', '克制神秘的悬疑背景音乐'],
['atmosphere', '紧张推进', '稳定推进、逐渐紧张的背景音乐'],
['scene', '森林自然', '清新自然的森林背景音乐'],
['scene', '雨夜静谧', '雨夜静谧、略带神秘感的背景音乐'],
['scene', '海洋漂流', '开阔舒缓的海洋漂流背景音乐'],
['scene', '山野远行', '自由舒展的山野远行背景音乐'],
['scene', '糖果乐园', '甜美活泼的糖果乐园背景音乐'],
['scene', '温馨小屋', '温暖安静的温馨小屋背景音乐'],
['scene', '城市夜晚', '克制迷人的城市夜晚背景音乐'],
['scene', '太空科幻', '空灵未来感的太空科幻背景音乐'],
['scene', '赛博街区', '冷静律动的赛博街区背景音乐'],
['scene', '古风幻想', '空灵雅致的古风幻想背景音乐'],
['scene', '童话花园', '梦幻轻盈的童话花园背景音乐'],
['scene', '海底遗迹', '深邃神秘的海底遗迹背景音乐'],
['scene', '沙漠遗迹', '苍茫神秘的沙漠遗迹背景音乐'],
['scene', '熔岩洞穴', '炽热压迫的熔岩洞穴背景音乐'],
['scene', '奇妙博物馆', '好奇灵动的奇妙博物馆背景音乐'],
];
function presetByLabel(label: string) {
const preset = BACKGROUND_MUSIC_PROMPT_PRESETS.find(
(candidate) => candidate.label === label,
);
if (!preset) {
throw new Error(`missing background music preset: ${label}`);
}
return preset;
}
const explorationPreset = presetByLabel('探索冒险');
const healingPreset = presetByLabel('温暖治愈');
describe('ImageCanvasBackgroundMusicPresetModel', () => {
it('keeps the authoritative three groups at 12 / 3 / 15 and 30 total', () => {
const groupCounts = BACKGROUND_MUSIC_PROMPT_PRESETS.reduce<
Record<string, number>
>(
(counts, preset) => ({
...counts,
[preset.group]: (counts[preset.group] ?? 0) + 1,
}),
{},
);
expect(BACKGROUND_MUSIC_PROMPT_PRESETS).toHaveLength(30);
expect(groupCounts).toEqual({ purpose: 12, atmosphere: 3, scene: 15 });
});
it('copies every authoritative label and prompt without rewriting them', () => {
expect(
BACKGROUND_MUSIC_PROMPT_PRESETS.map((preset) => [
preset.group,
preset.label,
preset.prompt,
]),
).toEqual(AUTHORITATIVE_PRESETS);
});
it('exposes stable unique ids that never appear in the visible prompt', () => {
const ids = BACKGROUND_MUSIC_PROMPT_PRESETS.map((preset) => preset.id);
expect(new Set(ids).size).toBe(ids.length);
for (const preset of BACKGROUND_MUSIC_PROMPT_PRESETS) {
expect(preset.id).toMatch(/^[a-z][a-z0-9-]*$/u);
expect(preset.prompt).not.toContain(preset.id);
expect(preset.prompt).not.toContain(preset.group);
}
});
it('writes the preset text directly into an empty or all-whitespace prompt', () => {
// 不可见 code point 一律用显式转义:字面量在编辑器和 lint autofix 里容易被吃掉。
for (const currentPrompt of [
'',
' ',
'…',
' \t\n\r\n  

',
]) {
expect(
appendBackgroundMusicPromptPreset(currentPrompt, explorationPreset),
).toBe(explorationPreset.prompt);
}
});
it('appends directly after Chinese, ASCII and other Unicode punctuation', () => {
for (const ending of [
'。',
',',
'、',
'!',
'?',
':',
';',
'”',
')',
'.',
',',
'!',
'?',
';',
':',
'-',
'_',
'(',
')',
'"',
"'",
'…',
'—',
'「',
'」',
'·',
]) {
const currentPrompt = `阳光动物园${ending}`;
expect(
appendBackgroundMusicPromptPreset(currentPrompt, explorationPreset),
).toBe(`${currentPrompt}${explorationPreset.prompt}`);
}
});
it('inserts a Chinese period after non-punctuation, symbols, emoji and combining marks', () => {
// `+` `=` `$` 属于 Unicode 符号而不是标点,必须补句号;emoji 与组合字符
// 用于证明末位是按 code point 而不是 UTF-16 code unit 读取的。
for (const ending of [
'园',
'a',
'7',
'+',
'=',
'$',
'\u{1f3b5}',
'\u{1f469}‍\u{1f3a4}',
'á',
]) {
const currentPrompt = `阳光动物园${ending}`;
expect(
appendBackgroundMusicPromptPreset(currentPrompt, explorationPreset),
).toBe(`${currentPrompt}。${explorationPreset.prompt}`);
}
});
it('removes only boundary whitespace and keeps internal CR / LF / CRLF unchanged', () => {
const currentPrompt = ' 阳光动物园\n第二行\r\n第三行\r第四行  ';
expect(
appendBackgroundMusicPromptPreset(currentPrompt, explorationPreset),
).toBe(`阳光动物园\n第二行\r\n第三行\r第四行。${explorationPreset.prompt}`);
});
it('keeps boundary zero-width code points because they are not Unicode White_Space', () => {
const currentPrompt = '​阳光动物园';
expect(
appendBackgroundMusicPromptPreset(currentPrompt, explorationPreset),
).toBe(`${currentPrompt}。${explorationPreset.prompt}`);
});
it('appends again on every repeated click of the same preset', () => {
const first = appendBackgroundMusicPromptPreset('', explorationPreset);
const second = appendBackgroundMusicPromptPreset(first, explorationPreset);
const third = appendBackgroundMusicPromptPreset(second, explorationPreset);
expect(second).toBe(`${first}。${explorationPreset.prompt}`);
expect(third).toBe(`${second}。${explorationPreset.prompt}`);
});
it('merges different presets in click order without deduplicating', () => {
const withExploration = appendBackgroundMusicPromptPreset(
'阳光动物园',
explorationPreset,
);
expect(
appendBackgroundMusicPromptPreset(withExploration, healingPreset),
).toBe(`阳光动物园。${explorationPreset.prompt}。${healingPreset.prompt}`);
});
it('keeps the whole text after crossing the 200 and 2000 code point limits', () => {
for (const currentLength of [200, 2001]) {
const currentPrompt = 'A'.repeat(currentLength);
const appended = appendBackgroundMusicPromptPreset(
currentPrompt,
explorationPreset,
);
expect(appended.startsWith(currentPrompt)).toBe(true);
expect(appended).toBe(`${currentPrompt}。${explorationPreset.prompt}`);
expect(Array.from(appended)).toHaveLength(
currentLength + 1 + Array.from(explorationPreset.prompt).length,
);
}
});
it('returns only the visible prompt, never the preset id or group metadata', () => {
const appended = appendBackgroundMusicPromptPreset(
'阳光动物园',
explorationPreset,
);
// chip 上的 label 可能本来就是预设正文的一部分,所以只断言结果逐字等于
// 「canonical + 分隔符 + preset.prompt」,并禁止隐藏元数据泄漏。
expect(typeof appended).toBe('string');
expect(appended).toBe(`阳光动物园。${explorationPreset.prompt}`);
expect(appended).not.toContain(explorationPreset.id);
expect(appended).not.toContain(explorationPreset.group);
});
});
@@ -0,0 +1,243 @@
import { canonicalizeBackgroundMusicPrompt } from './ImageCanvasBackgroundMusicPromptModel';
export type BackgroundMusicPromptPresetGroup =
| 'purpose'
| 'atmosphere'
| 'scene';
export type BackgroundMusicPromptPreset = {
id: string;
group: BackgroundMusicPromptPresetGroup;
label: string;
prompt: string;
};
const UNICODE_PUNCTUATION_CODE_POINT = /^\p{Punctuation}$/u;
const BACKGROUND_MUSIC_PRESET_SENTENCE_SEPARATOR = '。';
export const BACKGROUND_MUSIC_PROMPT_PRESET_GROUPS = [
'purpose',
'atmosphere',
'scene',
] as const satisfies readonly BackgroundMusicPromptPresetGroup[];
/**
* 权威设计《画板音乐生成入口设计》「预设库与追加规则」的固定文案,逐项复制,不改写。
*
* `id`、`group` 只用于滚动轨道的颜色分组和 React key,`label` 只用于 chip 展示;
* 写入输入框的永远只有 `prompt`,隐藏元数据不得进入 Prompt 或 Suno 请求。
*/
export const BACKGROUND_MUSIC_PROMPT_PRESETS = [
{
id: 'menu-idle',
group: 'purpose',
label: '菜单待机',
prompt: '低干扰、适合菜单待机的背景音乐',
},
{
id: 'casual-match',
group: 'purpose',
label: '休闲消除',
prompt: '轻快可爱的休闲消除背景音乐',
},
{
id: 'puzzle-thinking',
group: 'purpose',
label: '解谜思考',
prompt: '安静专注的解谜思考背景音乐',
},
{
id: 'exploration-adventure',
group: 'purpose',
label: '探索冒险',
prompt: '温和推进的探索冒险背景音乐',
},
{
id: 'dialogue-scene',
group: 'purpose',
label: '对白场景',
prompt: '克制柔和、留出对白空间的背景音乐',
},
{
id: 'pre-battle',
group: 'purpose',
label: '战斗前',
prompt: '蓄势待发的战斗前背景音乐',
},
{
id: 'victory-settlement',
group: 'purpose',
label: '胜利结算',
prompt: '明亮满足的胜利结算背景音乐',
},
{
id: 'defeat-settlement',
group: 'purpose',
label: '失败结算',
prompt: '克制低落的失败结算背景音乐',
},
{
id: 'daily-management',
group: 'purpose',
label: '日常经营',
prompt: '轻松有序的日常经营背景音乐',
},
{
id: 'farm-management',
group: 'purpose',
label: '农场经营',
prompt: '自然温暖的农场经营背景音乐',
},
{
id: 'campus-daily',
group: 'purpose',
label: '校园日常',
prompt: '青春轻松的校园日常背景音乐',
},
{
id: 'food-kitchen',
group: 'purpose',
label: '美食厨房',
prompt: '温暖活泼的美食厨房背景音乐',
},
{
id: 'warm-healing',
group: 'atmosphere',
label: '温暖治愈',
prompt: '柔和明亮的治愈背景音乐',
},
{
id: 'mystery-suspense',
group: 'atmosphere',
label: '神秘悬疑',
prompt: '克制神秘的悬疑背景音乐',
},
{
id: 'tension-buildup',
group: 'atmosphere',
label: '紧张推进',
prompt: '稳定推进、逐渐紧张的背景音乐',
},
{
id: 'forest-nature',
group: 'scene',
label: '森林自然',
prompt: '清新自然的森林背景音乐',
},
{
id: 'rainy-night',
group: 'scene',
label: '雨夜静谧',
prompt: '雨夜静谧、略带神秘感的背景音乐',
},
{
id: 'ocean-drift',
group: 'scene',
label: '海洋漂流',
prompt: '开阔舒缓的海洋漂流背景音乐',
},
{
id: 'mountain-journey',
group: 'scene',
label: '山野远行',
prompt: '自由舒展的山野远行背景音乐',
},
{
id: 'candy-park',
group: 'scene',
label: '糖果乐园',
prompt: '甜美活泼的糖果乐园背景音乐',
},
{
id: 'cozy-cabin',
group: 'scene',
label: '温馨小屋',
prompt: '温暖安静的温馨小屋背景音乐',
},
{
id: 'city-night',
group: 'scene',
label: '城市夜晚',
prompt: '克制迷人的城市夜晚背景音乐',
},
{
id: 'space-scifi',
group: 'scene',
label: '太空科幻',
prompt: '空灵未来感的太空科幻背景音乐',
},
{
id: 'cyber-block',
group: 'scene',
label: '赛博街区',
prompt: '冷静律动的赛博街区背景音乐',
},
{
id: 'ancient-fantasy',
group: 'scene',
label: '古风幻想',
prompt: '空灵雅致的古风幻想背景音乐',
},
{
id: 'fairytale-garden',
group: 'scene',
label: '童话花园',
prompt: '梦幻轻盈的童话花园背景音乐',
},
{
id: 'undersea-ruins',
group: 'scene',
label: '海底遗迹',
prompt: '深邃神秘的海底遗迹背景音乐',
},
{
id: 'desert-ruins',
group: 'scene',
label: '沙漠遗迹',
prompt: '苍茫神秘的沙漠遗迹背景音乐',
},
{
id: 'lava-cave',
group: 'scene',
label: '熔岩洞穴',
prompt: '炽热压迫的熔岩洞穴背景音乐',
},
{
id: 'curious-museum',
group: 'scene',
label: '奇妙博物馆',
prompt: '好奇灵动的奇妙博物馆背景音乐',
},
] as const satisfies readonly BackgroundMusicPromptPreset[];
function readLastCodePoint(value: string) {
// 必须按 code point 读取:直接取最后一个 UTF-16 code unit 会在 emoji 等
// surrogate pair 结尾时拿到半个字符,标点判断随之失真。
const codePoints = Array.from(value);
return codePoints[codePoints.length - 1];
}
/**
* 按权威设计的追加规则返回新的可见 Prompt。
*
* 输入先复用共享 canonicalizer(不使用会额外删除 U+FEFF 的原生 `trim()`),因此调用方
* 传入尚未规范化的文本也不会产出 ` 。预设文本` 这类结果;canonicalization 幂等,
* 已经写回过的文本再算一次结果相同。
*
* 不去重、不截断、不改内部空格与换行;追加后允许超过 200 或 2000 字,文本必须完整保留。
*/
export function appendBackgroundMusicPromptPreset(
currentPrompt: string,
preset: BackgroundMusicPromptPreset,
) {
const canonicalPrompt = canonicalizeBackgroundMusicPrompt(currentPrompt);
if (!canonicalPrompt) {
return preset.prompt;
}
const lastCodePoint = readLastCodePoint(canonicalPrompt);
if (lastCodePoint && UNICODE_PUNCTUATION_CODE_POINT.test(lastCodePoint)) {
return `${canonicalPrompt}${preset.prompt}`;
}
return `${canonicalPrompt}${BACKGROUND_MUSIC_PRESET_SENTENCE_SEPARATOR}${preset.prompt}`;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,461 @@
import type {
CanvasGenerationDialogState,
GenerateDialogState,
} from './ImageCanvasEditorTypes';
export const BACKGROUND_MUSIC_PROMPT_MAX_CODE_POINTS = 200;
export const BACKGROUND_MUSIC_PROMPT_SIMPLIFICATION_MAX_CODE_POINTS =
BACKGROUND_MUSIC_PROMPT_MAX_CODE_POINTS * 10;
export const BACKGROUND_MUSIC_PROMPT_GENERATION_MIN_EFFECTIVE_CODE_POINTS = 1;
export const BACKGROUND_MUSIC_PROMPT_COMPLETION_MIN_EFFECTIVE_CODE_POINTS = 2;
const UNICODE_WHITE_SPACE_CODE_POINT = /^\p{White_Space}$/u;
const LEADING_UNICODE_WHITE_SPACE = /^\p{White_Space}+/u;
const TRAILING_UNICODE_WHITE_SPACE = /\p{White_Space}+$/u;
const BACKGROUND_MUSIC_PROMPT_OPERATION_ID_PREFIX =
'background-music-prompt-operation';
export type BackgroundMusicPromptOperationStatus =
| 'completing'
| 'simplifying'
| 'submitting';
export type BackgroundMusicPromptDialogStatus =
| 'idle'
| BackgroundMusicPromptOperationStatus;
export type BackgroundMusicPromptOperation = {
dialogId: string;
operationId: string;
status: BackgroundMusicPromptOperationStatus;
canonicalPrompt: string;
};
export type BackgroundMusicPromptDialogState = {
dialogId: string;
status: BackgroundMusicPromptDialogStatus;
operationId: string | null;
undoPromptSnapshot: string | null;
temporaryPromptSnapshot: string | null;
};
export type BackgroundMusicPromptOperationStartResult = {
started: boolean;
reason: 'started' | 'duplicate' | 'ineligible' | 'locked';
canonicalPrompt: string;
operation: BackgroundMusicPromptOperation | null;
state: BackgroundMusicPromptDialogState;
};
export type BackgroundMusicPromptOperationResolution = {
applied: boolean;
prompt: string | null;
state: BackgroundMusicPromptDialogState;
};
export type BackgroundMusicPromptBoundaryResult = {
applied: boolean;
prompt: string;
state: BackgroundMusicPromptDialogState;
};
type InternalBackgroundMusicPromptDialogState =
BackgroundMusicPromptDialogState & {
activeCanonicalPrompt: string | null;
};
export type BackgroundMusicPromptStateModel = ReturnType<
typeof createBackgroundMusicPromptStateModel
>;
export type BackgroundMusicGenerationDialogState =
CanvasGenerationDialogState & {
mode: 'audio-background-music';
};
/**
* `GenerateDialogState` 是单一对象类型而不是可判别联合,`mode === 'audio-background-music'`
* 只窄化属性读取,展开后 `mode` 与可选 `id` 仍是宽类型。助手动作全部按 dialog ID 生效,
* 所以消费前必须先收窄出带稳定 ID 的 BGM dialog,不能把可选 `id` 直接传下去。
*/
export function toBackgroundMusicGenerationDialog(
dialog: GenerateDialogState | null,
): BackgroundMusicGenerationDialogState | null {
if (!dialog || dialog.mode !== 'audio-background-music' || !dialog.id) {
return null;
}
return { ...dialog, id: dialog.id, mode: 'audio-background-music' };
}
export function canonicalizeBackgroundMusicPrompt(prompt: string) {
return prompt
.replace(LEADING_UNICODE_WHITE_SPACE, '')
.replace(TRAILING_UNICODE_WHITE_SPACE, '');
}
export function countPromptCodePoints(prompt: string) {
return Array.from(canonicalizeBackgroundMusicPrompt(prompt)).length;
}
export function countEffectivePromptCodePoints(prompt: string) {
let effectiveCodePoints = 0;
for (const codePoint of canonicalizeBackgroundMusicPrompt(prompt)) {
if (!UNICODE_WHITE_SPACE_CODE_POINT.test(codePoint)) {
effectiveCodePoints += 1;
}
}
return effectiveCodePoints;
}
export function canGenerateBackgroundMusicFromPrompt(prompt: string) {
return (
countPromptCodePoints(prompt) <= BACKGROUND_MUSIC_PROMPT_MAX_CODE_POINTS &&
countEffectivePromptCodePoints(prompt) >=
BACKGROUND_MUSIC_PROMPT_GENERATION_MIN_EFFECTIVE_CODE_POINTS
);
}
export function canCompleteBackgroundMusicPrompt(prompt: string) {
return (
countPromptCodePoints(prompt) <= BACKGROUND_MUSIC_PROMPT_MAX_CODE_POINTS &&
countEffectivePromptCodePoints(prompt) >=
BACKGROUND_MUSIC_PROMPT_COMPLETION_MIN_EFFECTIVE_CODE_POINTS
);
}
export function canSimplifyBackgroundMusicPrompt(prompt: string) {
const codePointCount = countPromptCodePoints(prompt);
return (
codePointCount > BACKGROUND_MUSIC_PROMPT_MAX_CODE_POINTS &&
codePointCount <= BACKGROUND_MUSIC_PROMPT_SIMPLIFICATION_MAX_CODE_POINTS &&
countEffectivePromptCodePoints(prompt) >=
BACKGROUND_MUSIC_PROMPT_GENERATION_MIN_EFFECTIVE_CODE_POINTS
);
}
function createIdleBackgroundMusicPromptDialogState(
dialogId: string,
): InternalBackgroundMusicPromptDialogState {
return {
dialogId,
status: 'idle',
operationId: null,
undoPromptSnapshot: null,
temporaryPromptSnapshot: null,
activeCanonicalPrompt: null,
};
}
function toPublicBackgroundMusicPromptDialogState(
state: InternalBackgroundMusicPromptDialogState,
): BackgroundMusicPromptDialogState {
return {
dialogId: state.dialogId,
status: state.status,
operationId: state.operationId,
undoPromptSnapshot: state.undoPromptSnapshot,
temporaryPromptSnapshot: state.temporaryPromptSnapshot,
};
}
function canStartBackgroundMusicPromptOperation(
status: BackgroundMusicPromptOperationStatus,
canonicalPrompt: string,
) {
if (status === 'completing') {
return canCompleteBackgroundMusicPrompt(canonicalPrompt);
}
if (status === 'simplifying') {
return canSimplifyBackgroundMusicPrompt(canonicalPrompt);
}
return canGenerateBackgroundMusicFromPrompt(canonicalPrompt);
}
/**
* Keeps dialog-scoped Prompt workflow state outside React. All operation claims
* happen synchronously, so callers can store one model instance in a ref and
* decide whether to issue a request before their first await.
*/
export function createBackgroundMusicPromptStateModel() {
const dialogStates = new Map<
string,
InternalBackgroundMusicPromptDialogState
>();
let operationSequence = 0;
const readInternalState = (dialogId: string) =>
dialogStates.get(dialogId) ??
createIdleBackgroundMusicPromptDialogState(dialogId);
const saveState = (state: InternalBackgroundMusicPromptDialogState) => {
dialogStates.set(state.dialogId, state);
return toPublicBackgroundMusicPromptDialogState(state);
};
const getDialogState = (dialogId: string): BackgroundMusicPromptDialogState =>
toPublicBackgroundMusicPromptDialogState(readInternalState(dialogId));
const beginOperation = ({
dialogId,
status,
prompt,
}: {
dialogId: string;
status: BackgroundMusicPromptOperationStatus;
prompt: string;
}): BackgroundMusicPromptOperationStartResult => {
const currentState = readInternalState(dialogId);
const canonicalPrompt = canonicalizeBackgroundMusicPrompt(prompt);
if (currentState.status === status && currentState.operationId !== null) {
return {
started: false,
reason: 'duplicate',
canonicalPrompt: currentState.activeCanonicalPrompt ?? canonicalPrompt,
operation: null,
state: toPublicBackgroundMusicPromptDialogState(currentState),
};
}
if (currentState.status === 'submitting') {
return {
started: false,
reason: 'locked',
canonicalPrompt: currentState.activeCanonicalPrompt ?? canonicalPrompt,
operation: null,
state: toPublicBackgroundMusicPromptDialogState(currentState),
};
}
if (!canStartBackgroundMusicPromptOperation(status, canonicalPrompt)) {
return {
started: false,
reason: 'ineligible',
canonicalPrompt,
operation: null,
state: toPublicBackgroundMusicPromptDialogState(currentState),
};
}
operationSequence += 1;
const operation: BackgroundMusicPromptOperation = {
dialogId,
operationId: `${BACKGROUND_MUSIC_PROMPT_OPERATION_ID_PREFIX}-${operationSequence}`,
status,
canonicalPrompt,
};
const isAiOperation = status === 'completing' || status === 'simplifying';
const nextState: InternalBackgroundMusicPromptDialogState = {
...currentState,
status,
operationId: operation.operationId,
undoPromptSnapshot: isAiOperation
? null
: currentState.undoPromptSnapshot,
temporaryPromptSnapshot: isAiOperation ? canonicalPrompt : null,
activeCanonicalPrompt: canonicalPrompt,
};
return {
started: true,
reason: 'started',
canonicalPrompt,
operation,
state: saveState(nextState),
};
};
const isCurrentOperation = (
operation: BackgroundMusicPromptOperation,
state: InternalBackgroundMusicPromptDialogState,
) =>
state.dialogId === operation.dialogId &&
state.operationId === operation.operationId &&
state.status === operation.status;
const resolveAiOperation = (
operation: BackgroundMusicPromptOperation,
prompt: string,
): BackgroundMusicPromptOperationResolution => {
const currentState = readInternalState(operation.dialogId);
if (
operation.status === 'submitting' ||
!isCurrentOperation(operation, currentState)
) {
return {
applied: false,
prompt: null,
state: toPublicBackgroundMusicPromptDialogState(currentState),
};
}
const canonicalPrompt = canonicalizeBackgroundMusicPrompt(prompt);
if (!canGenerateBackgroundMusicFromPrompt(canonicalPrompt)) {
const failedState: InternalBackgroundMusicPromptDialogState = {
...currentState,
status: 'idle',
operationId: null,
undoPromptSnapshot: null,
temporaryPromptSnapshot: null,
activeCanonicalPrompt: null,
};
return {
applied: false,
prompt: null,
state: saveState(failedState),
};
}
const completedState: InternalBackgroundMusicPromptDialogState = {
...currentState,
status: 'idle',
operationId: null,
undoPromptSnapshot: currentState.temporaryPromptSnapshot,
temporaryPromptSnapshot: null,
activeCanonicalPrompt: null,
};
return {
applied: true,
prompt: canonicalPrompt,
state: saveState(completedState),
};
};
const rejectOperation = (
operation: BackgroundMusicPromptOperation,
): BackgroundMusicPromptOperationResolution => {
const currentState = readInternalState(operation.dialogId);
if (!isCurrentOperation(operation, currentState)) {
return {
applied: false,
prompt: null,
state: toPublicBackgroundMusicPromptDialogState(currentState),
};
}
const isAiOperation = operation.status !== 'submitting';
const failedState: InternalBackgroundMusicPromptDialogState = {
...currentState,
status: 'idle',
operationId: null,
undoPromptSnapshot: isAiOperation
? null
: currentState.undoPromptSnapshot,
temporaryPromptSnapshot: null,
activeCanonicalPrompt: null,
};
return {
applied: true,
prompt: currentState.activeCanonicalPrompt,
state: saveState(failedState),
};
};
const completeSubmittingOperation = (
operation: BackgroundMusicPromptOperation,
): BackgroundMusicPromptOperationResolution => {
const currentState = readInternalState(operation.dialogId);
if (
operation.status !== 'submitting' ||
!isCurrentOperation(operation, currentState)
) {
return {
applied: false,
prompt: null,
state: toPublicBackgroundMusicPromptDialogState(currentState),
};
}
const submittedPrompt =
currentState.activeCanonicalPrompt ?? operation.canonicalPrompt;
const completedState: InternalBackgroundMusicPromptDialogState = {
...currentState,
status: 'idle',
operationId: null,
temporaryPromptSnapshot: null,
activeCanonicalPrompt: null,
};
return {
applied: true,
prompt: submittedPrompt,
state: saveState(completedState),
};
};
const preparePreset = (
dialogId: string,
prompt: string,
): BackgroundMusicPromptBoundaryResult => {
const currentState = readInternalState(dialogId);
const canonicalPrompt = canonicalizeBackgroundMusicPrompt(prompt);
if (currentState.status === 'submitting') {
return {
applied: false,
prompt: currentState.activeCanonicalPrompt ?? canonicalPrompt,
state: toPublicBackgroundMusicPromptDialogState(currentState),
};
}
const nextState: InternalBackgroundMusicPromptDialogState = {
...currentState,
status: 'idle',
operationId: null,
undoPromptSnapshot: null,
temporaryPromptSnapshot: null,
activeCanonicalPrompt: null,
};
return {
applied: true,
prompt: canonicalPrompt,
state: saveState(nextState),
};
};
const swapUndoSnapshot = (
dialogId: string,
prompt: string,
): BackgroundMusicPromptBoundaryResult => {
const currentState = readInternalState(dialogId);
if (
currentState.status !== 'idle' ||
currentState.undoPromptSnapshot === null
) {
return {
applied: false,
prompt,
state: toPublicBackgroundMusicPromptDialogState(currentState),
};
}
const canonicalPrompt = canonicalizeBackgroundMusicPrompt(prompt);
const nextPrompt = currentState.undoPromptSnapshot;
const nextState: InternalBackgroundMusicPromptDialogState = {
...currentState,
undoPromptSnapshot: canonicalPrompt,
};
return {
applied: true,
prompt: nextPrompt,
state: saveState(nextState),
};
};
const closeDialog = (dialogId: string) => {
dialogStates.delete(dialogId);
};
const reset = () => {
dialogStates.clear();
};
return {
getDialogState,
beginOperation,
resolveAiOperation,
rejectOperation,
completeSubmittingOperation,
preparePreset,
swapUndoSnapshot,
closeDialog,
reset,
};
}
@@ -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
@@ -11,11 +11,11 @@ import type {
import type {
EditorAssetSnapshot,
EditorCharacterAnimationFrameCount,
EditorCharacterAnimationFrameResult,
EditorCharacterAnimationGenerationResult,
EditorCharacterAnimationRatio,
EditorCharacterAnimationResolution,
EditorImageGenerationStyle,
EditorImageSequenceFrameResult,
EditorPixelArtSnapInput,
EditorVideoAspectRatio,
EditorVideoModel,
@@ -72,7 +72,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;
@@ -80,7 +81,7 @@ export type EditorAsset = {
export type CanvasLayer = SharedCanvasLayer & {
generatedAssetSnapshot?: EditorAssetSnapshot | null;
imageSequenceFrames?: EditorCharacterAnimationFrameResult[];
imageSequenceFrames?: EditorImageSequenceFrameResult[];
};
export type SidebarPanel = 'assets' | 'layers';
@@ -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(() => {
@@ -1451,7 +1451,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,
};
}
@@ -778,6 +780,7 @@ export function ImageCanvasEditorView({
updateCanvasGenerationDialogById,
removeCanvasGenerationDialogById,
hasCanvasGenerationDialogById,
getCanvasGenerationDialogById,
archiveActiveCanvasGenerationDialog,
activateCanvasGenerationDialog,
restoreCanvasGenerationDialogs,
@@ -1067,11 +1070,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,
@@ -1094,8 +1108,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),
},
]);
@@ -1124,13 +1140,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],
@@ -1159,7 +1181,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),
},
@@ -1227,6 +1250,7 @@ export function ImageCanvasEditorView({
const {
projectId,
isProjectReady,
assetKindFallbackNotice,
appendCanvasLayersWithResources,
applyProjectSnapshot,
flushProjectPersistence,
@@ -1302,7 +1326,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,
@@ -1356,6 +1381,7 @@ export function ImageCanvasEditorView({
isExportingAssets,
exportCanvasAssets,
exportLayerImage,
reportAssetError,
} = useImageCanvasAssetExportWorkflow({
layers,
projectId,
@@ -1428,6 +1454,7 @@ export function ImageCanvasEditorView({
activateCanvasGenerationDialog,
updateCanvasGenerationDialogById,
hasCanvasGenerationDialogById,
getCanvasGenerationDialogById,
archiveActiveCanvasGenerationDialog,
removeCanvasGenerationDialogsByLayerId,
getGeneratingDialogPlaceholder,
@@ -1456,6 +1483,11 @@ export function ImageCanvasEditorView({
generationSurface.refreshTaskList();
}, [generationSurface]);
const showGenerationWarning = generationSurface.showGenerationWarning;
useEffect(() => {
if (assetKindFallbackNotice) {
showGenerationWarning(assetKindFallbackNotice.message);
}
}, [assetKindFallbackNotice, showGenerationWarning]);
useEffect(() => {
if (deadInlinePlaceholderDropCount === 0) {
return;
@@ -2095,12 +2127,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 = {
@@ -2122,7 +2158,7 @@ export function ImageCanvasEditorView({
setImageContextMenu(null);
setContextMenu(null);
},
[captureCanvasHistory],
[captureCanvasHistory, showGenerationWarning],
);
const selectAllCanvasObjects = useCallback(() => {
const layerSelectionIds = layersRef.current
@@ -2381,6 +2417,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(
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -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',
});
});
});

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