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