合并最新主线并统一画布素材合同
合并主线背景音乐与角色动作素材能力 保留无限画布共享渲染和历史恢复合同 统一正式序列帧与素材类型字段 补齐后端、SpacetimeDB 与 CI 回归验证
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;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,480 @@
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ChevronUp,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
type PointerEvent as ReactPointerEvent,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useId,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import { PlatformActionButton } from '../common/PlatformActionButton';
|
||||
import type { BackgroundMusicPromptPreset } from './ImageCanvasBackgroundMusicPresetModel';
|
||||
|
||||
/** 多份等宽队列拼接实现无缝循环:首尾之间不跳回也不留白。 */
|
||||
const PRESET_QUEUE_COPIES = 3;
|
||||
/** 初始 scrollLeft 正对中间队列,因此它同时承担唯一的键盘与屏幕阅读器入口。 */
|
||||
const PRESET_ACCESSIBLE_QUEUE_INDEX = 1;
|
||||
/** 默认缓慢循环与 hover 加速的速度,单位 px/s。 */
|
||||
const PRESET_BASE_VELOCITY = 26;
|
||||
/**
|
||||
* 加速档按真机实测下调到原值 340 的 70%。左右 15% 控制区与左右箭头共用同一个速度:
|
||||
* 需求要求“箭头和滚动栏两边都有加速滚动”,两者相邻,拆成两个速度会让指针在箭头与
|
||||
* 控制区之间移动时出现速度突变。
|
||||
*/
|
||||
const PRESET_FAST_VELOCITY = 238;
|
||||
/** 左右控制区各占 viewport 宽度的 15%,中间 70% 为暂停区。 */
|
||||
const PRESET_SIDE_ZONE_RATIO = 0.15;
|
||||
/** 箭头点击的一次离散滚动量,按 viewport 宽度比例计算。 */
|
||||
const PRESET_ARROW_STEP_RATIO = 0.6;
|
||||
/** 单帧最大步进:标签页被挂起后回到前台时不产生一次巨大跳变。 */
|
||||
const PRESET_MAX_FRAME_MS = 100;
|
||||
/**
|
||||
* 触摸抬手后判定“惯性已经停下”的空闲窗口。原生惯性可能持续一两秒,固定等待再恢复
|
||||
* rAF 一定会打断它,所以这里等的是滚动事件停止,而不是一个固定时长。
|
||||
*/
|
||||
const PRESET_TOUCH_IDLE_MS = 160;
|
||||
const PRESET_REDUCED_MOTION_QUERY = '(prefers-reduced-motion: reduce)';
|
||||
/** 桌面 hover 控制区只在真正支持悬停的精确指针设备上启用。 */
|
||||
const PRESET_HOVER_CONTROL_QUERY = '(hover: hover) and (pointer: fine)';
|
||||
|
||||
type BackgroundMusicPresetZone = 'left' | 'center' | 'right' | null;
|
||||
|
||||
function readsMediaQueryMatches(query: string) {
|
||||
if (
|
||||
typeof window === 'undefined' ||
|
||||
typeof window.matchMedia !== 'function'
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return window.matchMedia(query).matches;
|
||||
}
|
||||
|
||||
function useMediaQueryMatches(query: string) {
|
||||
const [matches, setMatches] = useState(() => readsMediaQueryMatches(query));
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
typeof window === 'undefined' ||
|
||||
typeof window.matchMedia !== 'function'
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const mediaQuery = window.matchMedia(query);
|
||||
// 运行中切换(系统偏好变化、外接鼠标插拔)也要立即生效,不能只读初值。
|
||||
setMatches(mediaQuery.matches);
|
||||
const handleChange = (event: MediaQueryListEvent) => {
|
||||
setMatches(event.matches);
|
||||
};
|
||||
mediaQuery.addEventListener('change', handleChange);
|
||||
|
||||
return () => {
|
||||
mediaQuery.removeEventListener('change', handleChange);
|
||||
};
|
||||
}, [query]);
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
export function ImageCanvasBackgroundMusicPresetMarquee({
|
||||
presets,
|
||||
isLocked,
|
||||
onSelectPreset,
|
||||
headerTrailing,
|
||||
}: {
|
||||
presets: readonly BackgroundMusicPromptPreset[];
|
||||
isLocked: boolean;
|
||||
onSelectPreset: (preset: BackgroundMusicPromptPreset) => void;
|
||||
/** 标题行右端的展示位。字数计数要和「预设」同排,但它属于 composer 的状态。 */
|
||||
headerTrailing?: ReactNode;
|
||||
}) {
|
||||
const trackId = useId();
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [zone, setZone] = useState<BackgroundMusicPresetZone>(null);
|
||||
const [isKeyboardFocused, setIsKeyboardFocused] = useState(false);
|
||||
const [isPageVisible, setIsPageVisible] = useState(
|
||||
() =>
|
||||
typeof document === 'undefined' || document.visibilityState !== 'hidden',
|
||||
);
|
||||
const prefersReducedMotion = useMediaQueryMatches(
|
||||
PRESET_REDUCED_MOTION_QUERY,
|
||||
);
|
||||
const supportsHoverControls = useMediaQueryMatches(
|
||||
PRESET_HOVER_CONTROL_QUERY,
|
||||
);
|
||||
const [isTouchScrolling, setIsTouchScrolling] = useState(false);
|
||||
|
||||
const viewportRef = useRef<HTMLDivElement | null>(null);
|
||||
const queueRef = useRef<HTMLDivElement | null>(null);
|
||||
const queueWidthRef = useRef(0);
|
||||
const frameRef = useRef<number | null>(null);
|
||||
const frameTimestampRef = useRef<number | null>(null);
|
||||
const touchSettleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
// 归位只按实测的单份队列宽度取模,不硬编码 chip 或轨道宽度。
|
||||
const normalizeScrollPosition = useCallback(() => {
|
||||
const viewport = viewportRef.current;
|
||||
const queueWidth = queueWidthRef.current;
|
||||
if (!viewport || queueWidth <= 0) {
|
||||
return;
|
||||
}
|
||||
if (viewport.scrollLeft < queueWidth * 0.5) {
|
||||
viewport.scrollLeft += queueWidth;
|
||||
return;
|
||||
}
|
||||
if (viewport.scrollLeft > queueWidth * 1.5) {
|
||||
viewport.scrollLeft -= queueWidth;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const velocity = (() => {
|
||||
if (prefersReducedMotion || isLocked || !isExpanded || !isPageVisible) {
|
||||
return 0;
|
||||
}
|
||||
if (isTouchScrolling || isKeyboardFocused || zone === 'center') {
|
||||
return 0;
|
||||
}
|
||||
if (zone === 'left') {
|
||||
return -PRESET_FAST_VELOCITY;
|
||||
}
|
||||
if (zone === 'right') {
|
||||
return PRESET_FAST_VELOCITY;
|
||||
}
|
||||
return PRESET_BASE_VELOCITY;
|
||||
})();
|
||||
|
||||
useEffect(() => {
|
||||
if (velocity === 0) {
|
||||
return undefined;
|
||||
}
|
||||
const step = (timestamp: number) => {
|
||||
const previousTimestamp = frameTimestampRef.current ?? timestamp;
|
||||
const deltaMs = Math.min(
|
||||
Math.max(timestamp - previousTimestamp, 0),
|
||||
PRESET_MAX_FRAME_MS,
|
||||
);
|
||||
frameTimestampRef.current = timestamp;
|
||||
const viewport = viewportRef.current;
|
||||
if (viewport) {
|
||||
viewport.scrollLeft += (velocity * deltaMs) / 1000;
|
||||
normalizeScrollPosition();
|
||||
}
|
||||
frameRef.current = requestAnimationFrame(step);
|
||||
};
|
||||
frameRef.current = requestAnimationFrame(step);
|
||||
|
||||
return () => {
|
||||
frameTimestampRef.current = null;
|
||||
if (frameRef.current !== null) {
|
||||
cancelAnimationFrame(frameRef.current);
|
||||
frameRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [normalizeScrollPosition, velocity]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof document === 'undefined') {
|
||||
return undefined;
|
||||
}
|
||||
const handleVisibilityChange = () => {
|
||||
setIsPageVisible(document.visibilityState !== 'hidden');
|
||||
};
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const queue = queueRef.current;
|
||||
if (!queue || typeof ResizeObserver === 'undefined') {
|
||||
return undefined;
|
||||
}
|
||||
const observer = new ResizeObserver(() => {
|
||||
const nextQueueWidth = queue.getBoundingClientRect().width;
|
||||
if (nextQueueWidth <= 0) {
|
||||
return;
|
||||
}
|
||||
const previousQueueWidth = queueWidthRef.current;
|
||||
queueWidthRef.current = nextQueueWidth;
|
||||
const viewport = viewportRef.current;
|
||||
if (!viewport) {
|
||||
return;
|
||||
}
|
||||
// 尺寸变化后按新宽度重新建立基准,避免停在旧队列的相位上。
|
||||
if (previousQueueWidth <= 0) {
|
||||
viewport.scrollLeft = nextQueueWidth;
|
||||
return;
|
||||
}
|
||||
normalizeScrollPosition();
|
||||
});
|
||||
observer.observe(queue);
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [isExpanded, normalizeScrollPosition]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (touchSettleTimerRef.current !== null) {
|
||||
clearTimeout(touchSettleTimerRef.current);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isExpanded && !isLocked && supportsHoverControls) {
|
||||
return;
|
||||
}
|
||||
// 收起、锁定或失去 hover 能力时回到默认状态。最后一种尤其重要:没有 hover 的
|
||||
// 设备不会再有 pointerleave,残留的 `center` 会让循环永久停住。
|
||||
setZone(null);
|
||||
setIsKeyboardFocused(false);
|
||||
}, [isExpanded, isLocked, supportsHoverControls]);
|
||||
|
||||
const handleViewportPointerMove = (
|
||||
event: ReactPointerEvent<HTMLDivElement>,
|
||||
) => {
|
||||
// 只在真正支持悬停的精确指针设备上启用;触摸指针再挡一道。控制区按 viewport
|
||||
// 相对 X 计算,不在 chip 上方覆盖三个透明 div。
|
||||
if (!supportsHoverControls || event.pointerType === 'touch' || isLocked) {
|
||||
return;
|
||||
}
|
||||
const bounds = event.currentTarget.getBoundingClientRect();
|
||||
if (bounds.width <= 0) {
|
||||
return;
|
||||
}
|
||||
const ratio = (event.clientX - bounds.left) / bounds.width;
|
||||
// 位置算不出来时保持当前区域:默认落到“中间暂停”会让指针一进入轨道就停住循环。
|
||||
if (!Number.isFinite(ratio)) {
|
||||
return;
|
||||
}
|
||||
if (ratio < PRESET_SIDE_ZONE_RATIO) {
|
||||
setZone('left');
|
||||
return;
|
||||
}
|
||||
if (ratio > 1 - PRESET_SIDE_ZONE_RATIO) {
|
||||
setZone('right');
|
||||
return;
|
||||
}
|
||||
setZone('center');
|
||||
};
|
||||
|
||||
const handleViewportPointerLeave = (
|
||||
event: ReactPointerEvent<HTMLDivElement>,
|
||||
) => {
|
||||
if (event.pointerType === 'touch') {
|
||||
return;
|
||||
}
|
||||
setZone(null);
|
||||
};
|
||||
|
||||
const armTouchSettleWatch = () => {
|
||||
if (touchSettleTimerRef.current !== null) {
|
||||
clearTimeout(touchSettleTimerRef.current);
|
||||
}
|
||||
touchSettleTimerRef.current = setTimeout(() => {
|
||||
touchSettleTimerRef.current = null;
|
||||
setIsTouchScrolling(false);
|
||||
}, PRESET_TOUCH_IDLE_MS);
|
||||
};
|
||||
|
||||
const beginTouchScroll = (event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
if (event.pointerType !== 'touch') {
|
||||
return;
|
||||
}
|
||||
if (touchSettleTimerRef.current !== null) {
|
||||
clearTimeout(touchSettleTimerRef.current);
|
||||
touchSettleTimerRef.current = null;
|
||||
}
|
||||
setIsTouchScrolling(true);
|
||||
};
|
||||
|
||||
const endTouchScroll = (event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
if (event.pointerType !== 'touch') {
|
||||
return;
|
||||
}
|
||||
// 抬手只是开始等待:原生惯性可能还要跑一两秒,真正的恢复条件是滚动停止。
|
||||
armTouchSettleWatch();
|
||||
};
|
||||
|
||||
const handleViewportScroll = () => {
|
||||
// 只有在等待惯性稳定的窗口内才顺延;否则收到的是 rAF 自己写 scrollLeft 触发的
|
||||
// scroll,会把恢复条件永远推后。
|
||||
if (touchSettleTimerRef.current === null) {
|
||||
return;
|
||||
}
|
||||
armTouchSettleWatch();
|
||||
};
|
||||
|
||||
const scrollByArrow = (direction: -1 | 1) => {
|
||||
const viewport = viewportRef.current;
|
||||
if (!viewport) {
|
||||
return;
|
||||
}
|
||||
const step =
|
||||
viewport.getBoundingClientRect().width * PRESET_ARROW_STEP_RATIO;
|
||||
// 离散滚动不使用平滑动画,供触摸和键盘使用。
|
||||
viewport.scrollLeft += direction * (step || PRESET_FAST_VELOCITY);
|
||||
normalizeScrollPosition();
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="image-canvas-editor__background-music-presets">
|
||||
<div className="image-canvas-editor__background-music-presets-header">
|
||||
<span className="image-canvas-editor__background-music-presets-title">
|
||||
预设
|
||||
</span>
|
||||
<PlatformActionButton
|
||||
type="button"
|
||||
tone="secondary"
|
||||
size="xs"
|
||||
shape="pill"
|
||||
className="image-canvas-editor__background-music-presets-toggle"
|
||||
// 图标化后文案不再进入可访问名,必须由 aria-label 承担。
|
||||
aria-label={isExpanded ? '收起' : '展开'}
|
||||
aria-expanded={isExpanded}
|
||||
aria-controls={trackId}
|
||||
disabled={isLocked}
|
||||
onClick={() => setIsExpanded((expanded) => !expanded)}
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronUp aria-hidden="true" />
|
||||
) : (
|
||||
<ChevronDown aria-hidden="true" />
|
||||
)}
|
||||
</PlatformActionButton>
|
||||
{headerTrailing}
|
||||
</div>
|
||||
{isExpanded ? (
|
||||
<div
|
||||
id={trackId}
|
||||
className="image-canvas-editor__background-music-presets-track"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="image-canvas-editor__background-music-presets-arrow image-canvas-editor__background-music-presets-arrow--left"
|
||||
aria-label="预设向左滚动"
|
||||
disabled={isLocked}
|
||||
onPointerEnter={(event) => {
|
||||
if (!supportsHoverControls || event.pointerType === 'touch') {
|
||||
return;
|
||||
}
|
||||
setZone('left');
|
||||
}}
|
||||
onPointerLeave={(event) => {
|
||||
if (event.pointerType === 'touch') {
|
||||
return;
|
||||
}
|
||||
setZone(null);
|
||||
}}
|
||||
onClick={() => scrollByArrow(-1)}
|
||||
>
|
||||
<ChevronLeft aria-hidden="true" />
|
||||
</button>
|
||||
<div
|
||||
ref={viewportRef}
|
||||
className="image-canvas-editor__background-music-presets-viewport"
|
||||
onPointerMove={handleViewportPointerMove}
|
||||
onPointerLeave={handleViewportPointerLeave}
|
||||
onPointerDown={beginTouchScroll}
|
||||
onPointerUp={endTouchScroll}
|
||||
onPointerCancel={endTouchScroll}
|
||||
onScroll={handleViewportScroll}
|
||||
onFocus={() => setIsKeyboardFocused(true)}
|
||||
onBlur={() => setIsKeyboardFocused(false)}
|
||||
>
|
||||
{Array.from({ length: PRESET_QUEUE_COPIES }, (_, copyIndex) => {
|
||||
const isAccessibleQueue =
|
||||
copyIndex === PRESET_ACCESSIBLE_QUEUE_INDEX;
|
||||
return (
|
||||
<div
|
||||
key={copyIndex}
|
||||
ref={isAccessibleQueue ? queueRef : undefined}
|
||||
className="image-canvas-editor__background-music-presets-queue"
|
||||
// 中间队列是唯一真实控件队列;首尾只负责无缝循环视觉,不能持有焦点。
|
||||
aria-hidden={isAccessibleQueue ? undefined : true}
|
||||
>
|
||||
{presets.map((preset) => {
|
||||
const className = `image-canvas-editor__background-music-preset image-canvas-editor__background-music-preset--${preset.group}`;
|
||||
if (isAccessibleQueue) {
|
||||
return (
|
||||
<button
|
||||
key={preset.id}
|
||||
type="button"
|
||||
className={className}
|
||||
disabled={isLocked}
|
||||
onClick={() => onSelectPreset(preset)}
|
||||
>
|
||||
{preset.label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span
|
||||
key={preset.id}
|
||||
className={`${className}${
|
||||
isLocked
|
||||
? ' image-canvas-editor__background-music-preset--disabled'
|
||||
: ''
|
||||
}`}
|
||||
onClick={() => {
|
||||
if (!isLocked) {
|
||||
onSelectPreset(preset);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{preset.label}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="image-canvas-editor__background-music-presets-arrow image-canvas-editor__background-music-presets-arrow--right"
|
||||
aria-label="预设向右滚动"
|
||||
disabled={isLocked}
|
||||
onPointerEnter={(event) => {
|
||||
if (!supportsHoverControls || event.pointerType === 'touch') {
|
||||
return;
|
||||
}
|
||||
setZone('right');
|
||||
}}
|
||||
onPointerLeave={(event) => {
|
||||
if (event.pointerType === 'touch') {
|
||||
return;
|
||||
}
|
||||
setZone(null);
|
||||
}}
|
||||
onClick={() => scrollByArrow(1)}
|
||||
>
|
||||
<ChevronRight aria-hidden="true" />
|
||||
</button>
|
||||
<div
|
||||
className={
|
||||
zone
|
||||
? `image-canvas-editor__background-music-presets-hairline image-canvas-editor__background-music-presets-hairline--${zone}`
|
||||
: 'image-canvas-editor__background-music-presets-hairline'
|
||||
}
|
||||
data-zone={zone ?? 'none'}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
appendBackgroundMusicPromptPreset,
|
||||
BACKGROUND_MUSIC_PROMPT_PRESETS,
|
||||
type BackgroundMusicPromptPreset,
|
||||
} from './ImageCanvasBackgroundMusicPresetModel';
|
||||
|
||||
// 权威设计《画板音乐生成入口设计》「预设库与追加规则」的固定文案。
|
||||
// 这张表就是转录校验本身,改动必须先改权威设计。
|
||||
const AUTHORITATIVE_PRESETS: ReadonlyArray<
|
||||
[BackgroundMusicPromptPreset['group'], string, string]
|
||||
> = [
|
||||
['purpose', '菜单待机', '低干扰、适合菜单待机的背景音乐'],
|
||||
['purpose', '休闲消除', '轻快可爱的休闲消除背景音乐'],
|
||||
['purpose', '解谜思考', '安静专注的解谜思考背景音乐'],
|
||||
['purpose', '探索冒险', '温和推进的探索冒险背景音乐'],
|
||||
['purpose', '对白场景', '克制柔和、留出对白空间的背景音乐'],
|
||||
['purpose', '战斗前', '蓄势待发的战斗前背景音乐'],
|
||||
['purpose', '胜利结算', '明亮满足的胜利结算背景音乐'],
|
||||
['purpose', '失败结算', '克制低落的失败结算背景音乐'],
|
||||
['purpose', '日常经营', '轻松有序的日常经营背景音乐'],
|
||||
['purpose', '农场经营', '自然温暖的农场经营背景音乐'],
|
||||
['purpose', '校园日常', '青春轻松的校园日常背景音乐'],
|
||||
['purpose', '美食厨房', '温暖活泼的美食厨房背景音乐'],
|
||||
['atmosphere', '温暖治愈', '柔和明亮的治愈背景音乐'],
|
||||
['atmosphere', '神秘悬疑', '克制神秘的悬疑背景音乐'],
|
||||
['atmosphere', '紧张推进', '稳定推进、逐渐紧张的背景音乐'],
|
||||
['scene', '森林自然', '清新自然的森林背景音乐'],
|
||||
['scene', '雨夜静谧', '雨夜静谧、略带神秘感的背景音乐'],
|
||||
['scene', '海洋漂流', '开阔舒缓的海洋漂流背景音乐'],
|
||||
['scene', '山野远行', '自由舒展的山野远行背景音乐'],
|
||||
['scene', '糖果乐园', '甜美活泼的糖果乐园背景音乐'],
|
||||
['scene', '温馨小屋', '温暖安静的温馨小屋背景音乐'],
|
||||
['scene', '城市夜晚', '克制迷人的城市夜晚背景音乐'],
|
||||
['scene', '太空科幻', '空灵未来感的太空科幻背景音乐'],
|
||||
['scene', '赛博街区', '冷静律动的赛博街区背景音乐'],
|
||||
['scene', '古风幻想', '空灵雅致的古风幻想背景音乐'],
|
||||
['scene', '童话花园', '梦幻轻盈的童话花园背景音乐'],
|
||||
['scene', '海底遗迹', '深邃神秘的海底遗迹背景音乐'],
|
||||
['scene', '沙漠遗迹', '苍茫神秘的沙漠遗迹背景音乐'],
|
||||
['scene', '熔岩洞穴', '炽热压迫的熔岩洞穴背景音乐'],
|
||||
['scene', '奇妙博物馆', '好奇灵动的奇妙博物馆背景音乐'],
|
||||
];
|
||||
|
||||
function presetByLabel(label: string) {
|
||||
const preset = BACKGROUND_MUSIC_PROMPT_PRESETS.find(
|
||||
(candidate) => candidate.label === label,
|
||||
);
|
||||
if (!preset) {
|
||||
throw new Error(`missing background music preset: ${label}`);
|
||||
}
|
||||
return preset;
|
||||
}
|
||||
|
||||
const explorationPreset = presetByLabel('探索冒险');
|
||||
const healingPreset = presetByLabel('温暖治愈');
|
||||
|
||||
describe('ImageCanvasBackgroundMusicPresetModel', () => {
|
||||
it('keeps the authoritative three groups at 12 / 3 / 15 and 30 total', () => {
|
||||
const groupCounts = BACKGROUND_MUSIC_PROMPT_PRESETS.reduce<
|
||||
Record<string, number>
|
||||
>(
|
||||
(counts, preset) => ({
|
||||
...counts,
|
||||
[preset.group]: (counts[preset.group] ?? 0) + 1,
|
||||
}),
|
||||
{},
|
||||
);
|
||||
|
||||
expect(BACKGROUND_MUSIC_PROMPT_PRESETS).toHaveLength(30);
|
||||
expect(groupCounts).toEqual({ purpose: 12, atmosphere: 3, scene: 15 });
|
||||
});
|
||||
|
||||
it('copies every authoritative label and prompt without rewriting them', () => {
|
||||
expect(
|
||||
BACKGROUND_MUSIC_PROMPT_PRESETS.map((preset) => [
|
||||
preset.group,
|
||||
preset.label,
|
||||
preset.prompt,
|
||||
]),
|
||||
).toEqual(AUTHORITATIVE_PRESETS);
|
||||
});
|
||||
|
||||
it('exposes stable unique ids that never appear in the visible prompt', () => {
|
||||
const ids = BACKGROUND_MUSIC_PROMPT_PRESETS.map((preset) => preset.id);
|
||||
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
for (const preset of BACKGROUND_MUSIC_PROMPT_PRESETS) {
|
||||
expect(preset.id).toMatch(/^[a-z][a-z0-9-]*$/u);
|
||||
expect(preset.prompt).not.toContain(preset.id);
|
||||
expect(preset.prompt).not.toContain(preset.group);
|
||||
}
|
||||
});
|
||||
|
||||
it('writes the preset text directly into an empty or all-whitespace prompt', () => {
|
||||
// 不可见 code point 一律用显式转义:字面量在编辑器和 lint autofix 里容易被吃掉。
|
||||
for (const currentPrompt of [
|
||||
'',
|
||||
' ',
|
||||
'
',
|
||||
' \t\n\r\n
',
|
||||
]) {
|
||||
expect(
|
||||
appendBackgroundMusicPromptPreset(currentPrompt, explorationPreset),
|
||||
).toBe(explorationPreset.prompt);
|
||||
}
|
||||
});
|
||||
|
||||
it('appends directly after Chinese, ASCII and other Unicode punctuation', () => {
|
||||
for (const ending of [
|
||||
'。',
|
||||
',',
|
||||
'、',
|
||||
'!',
|
||||
'?',
|
||||
':',
|
||||
';',
|
||||
'”',
|
||||
')',
|
||||
'.',
|
||||
',',
|
||||
'!',
|
||||
'?',
|
||||
';',
|
||||
':',
|
||||
'-',
|
||||
'_',
|
||||
'(',
|
||||
')',
|
||||
'"',
|
||||
"'",
|
||||
'…',
|
||||
'—',
|
||||
'「',
|
||||
'」',
|
||||
'·',
|
||||
]) {
|
||||
const currentPrompt = `阳光动物园${ending}`;
|
||||
|
||||
expect(
|
||||
appendBackgroundMusicPromptPreset(currentPrompt, explorationPreset),
|
||||
).toBe(`${currentPrompt}${explorationPreset.prompt}`);
|
||||
}
|
||||
});
|
||||
|
||||
it('inserts a Chinese period after non-punctuation, symbols, emoji and combining marks', () => {
|
||||
// `+` `=` `$` 属于 Unicode 符号而不是标点,必须补句号;emoji 与组合字符
|
||||
// 用于证明末位是按 code point 而不是 UTF-16 code unit 读取的。
|
||||
for (const ending of [
|
||||
'园',
|
||||
'a',
|
||||
'7',
|
||||
'+',
|
||||
'=',
|
||||
'$',
|
||||
'\u{1f3b5}',
|
||||
'\u{1f469}\u{1f3a4}',
|
||||
'á',
|
||||
]) {
|
||||
const currentPrompt = `阳光动物园${ending}`;
|
||||
|
||||
expect(
|
||||
appendBackgroundMusicPromptPreset(currentPrompt, explorationPreset),
|
||||
).toBe(`${currentPrompt}。${explorationPreset.prompt}`);
|
||||
}
|
||||
});
|
||||
|
||||
it('removes only boundary whitespace and keeps internal CR / LF / CRLF unchanged', () => {
|
||||
const currentPrompt = ' 阳光动物园\n第二行\r\n第三行\r第四行 ';
|
||||
|
||||
expect(
|
||||
appendBackgroundMusicPromptPreset(currentPrompt, explorationPreset),
|
||||
).toBe(`阳光动物园\n第二行\r\n第三行\r第四行。${explorationPreset.prompt}`);
|
||||
});
|
||||
|
||||
it('keeps boundary zero-width code points because they are not Unicode White_Space', () => {
|
||||
const currentPrompt = '阳光动物园';
|
||||
|
||||
expect(
|
||||
appendBackgroundMusicPromptPreset(currentPrompt, explorationPreset),
|
||||
).toBe(`${currentPrompt}。${explorationPreset.prompt}`);
|
||||
});
|
||||
|
||||
it('appends again on every repeated click of the same preset', () => {
|
||||
const first = appendBackgroundMusicPromptPreset('', explorationPreset);
|
||||
const second = appendBackgroundMusicPromptPreset(first, explorationPreset);
|
||||
const third = appendBackgroundMusicPromptPreset(second, explorationPreset);
|
||||
|
||||
expect(second).toBe(`${first}。${explorationPreset.prompt}`);
|
||||
expect(third).toBe(`${second}。${explorationPreset.prompt}`);
|
||||
});
|
||||
|
||||
it('merges different presets in click order without deduplicating', () => {
|
||||
const withExploration = appendBackgroundMusicPromptPreset(
|
||||
'阳光动物园',
|
||||
explorationPreset,
|
||||
);
|
||||
|
||||
expect(
|
||||
appendBackgroundMusicPromptPreset(withExploration, healingPreset),
|
||||
).toBe(`阳光动物园。${explorationPreset.prompt}。${healingPreset.prompt}`);
|
||||
});
|
||||
|
||||
it('keeps the whole text after crossing the 200 and 2000 code point limits', () => {
|
||||
for (const currentLength of [200, 2001]) {
|
||||
const currentPrompt = 'A'.repeat(currentLength);
|
||||
const appended = appendBackgroundMusicPromptPreset(
|
||||
currentPrompt,
|
||||
explorationPreset,
|
||||
);
|
||||
|
||||
expect(appended.startsWith(currentPrompt)).toBe(true);
|
||||
expect(appended).toBe(`${currentPrompt}。${explorationPreset.prompt}`);
|
||||
expect(Array.from(appended)).toHaveLength(
|
||||
currentLength + 1 + Array.from(explorationPreset.prompt).length,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('returns only the visible prompt, never the preset id or group metadata', () => {
|
||||
const appended = appendBackgroundMusicPromptPreset(
|
||||
'阳光动物园',
|
||||
explorationPreset,
|
||||
);
|
||||
|
||||
// chip 上的 label 可能本来就是预设正文的一部分,所以只断言结果逐字等于
|
||||
// 「canonical + 分隔符 + preset.prompt」,并禁止隐藏元数据泄漏。
|
||||
expect(typeof appended).toBe('string');
|
||||
expect(appended).toBe(`阳光动物园。${explorationPreset.prompt}`);
|
||||
expect(appended).not.toContain(explorationPreset.id);
|
||||
expect(appended).not.toContain(explorationPreset.group);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,243 @@
|
||||
import { canonicalizeBackgroundMusicPrompt } from './ImageCanvasBackgroundMusicPromptModel';
|
||||
|
||||
export type BackgroundMusicPromptPresetGroup =
|
||||
| 'purpose'
|
||||
| 'atmosphere'
|
||||
| 'scene';
|
||||
|
||||
export type BackgroundMusicPromptPreset = {
|
||||
id: string;
|
||||
group: BackgroundMusicPromptPresetGroup;
|
||||
label: string;
|
||||
prompt: string;
|
||||
};
|
||||
|
||||
const UNICODE_PUNCTUATION_CODE_POINT = /^\p{Punctuation}$/u;
|
||||
const BACKGROUND_MUSIC_PRESET_SENTENCE_SEPARATOR = '。';
|
||||
|
||||
export const BACKGROUND_MUSIC_PROMPT_PRESET_GROUPS = [
|
||||
'purpose',
|
||||
'atmosphere',
|
||||
'scene',
|
||||
] as const satisfies readonly BackgroundMusicPromptPresetGroup[];
|
||||
|
||||
/**
|
||||
* 权威设计《画板音乐生成入口设计》「预设库与追加规则」的固定文案,逐项复制,不改写。
|
||||
*
|
||||
* `id`、`group` 只用于滚动轨道的颜色分组和 React key,`label` 只用于 chip 展示;
|
||||
* 写入输入框的永远只有 `prompt`,隐藏元数据不得进入 Prompt 或 Suno 请求。
|
||||
*/
|
||||
export const BACKGROUND_MUSIC_PROMPT_PRESETS = [
|
||||
{
|
||||
id: 'menu-idle',
|
||||
group: 'purpose',
|
||||
label: '菜单待机',
|
||||
prompt: '低干扰、适合菜单待机的背景音乐',
|
||||
},
|
||||
{
|
||||
id: 'casual-match',
|
||||
group: 'purpose',
|
||||
label: '休闲消除',
|
||||
prompt: '轻快可爱的休闲消除背景音乐',
|
||||
},
|
||||
{
|
||||
id: 'puzzle-thinking',
|
||||
group: 'purpose',
|
||||
label: '解谜思考',
|
||||
prompt: '安静专注的解谜思考背景音乐',
|
||||
},
|
||||
{
|
||||
id: 'exploration-adventure',
|
||||
group: 'purpose',
|
||||
label: '探索冒险',
|
||||
prompt: '温和推进的探索冒险背景音乐',
|
||||
},
|
||||
{
|
||||
id: 'dialogue-scene',
|
||||
group: 'purpose',
|
||||
label: '对白场景',
|
||||
prompt: '克制柔和、留出对白空间的背景音乐',
|
||||
},
|
||||
{
|
||||
id: 'pre-battle',
|
||||
group: 'purpose',
|
||||
label: '战斗前',
|
||||
prompt: '蓄势待发的战斗前背景音乐',
|
||||
},
|
||||
{
|
||||
id: 'victory-settlement',
|
||||
group: 'purpose',
|
||||
label: '胜利结算',
|
||||
prompt: '明亮满足的胜利结算背景音乐',
|
||||
},
|
||||
{
|
||||
id: 'defeat-settlement',
|
||||
group: 'purpose',
|
||||
label: '失败结算',
|
||||
prompt: '克制低落的失败结算背景音乐',
|
||||
},
|
||||
{
|
||||
id: 'daily-management',
|
||||
group: 'purpose',
|
||||
label: '日常经营',
|
||||
prompt: '轻松有序的日常经营背景音乐',
|
||||
},
|
||||
{
|
||||
id: 'farm-management',
|
||||
group: 'purpose',
|
||||
label: '农场经营',
|
||||
prompt: '自然温暖的农场经营背景音乐',
|
||||
},
|
||||
{
|
||||
id: 'campus-daily',
|
||||
group: 'purpose',
|
||||
label: '校园日常',
|
||||
prompt: '青春轻松的校园日常背景音乐',
|
||||
},
|
||||
{
|
||||
id: 'food-kitchen',
|
||||
group: 'purpose',
|
||||
label: '美食厨房',
|
||||
prompt: '温暖活泼的美食厨房背景音乐',
|
||||
},
|
||||
{
|
||||
id: 'warm-healing',
|
||||
group: 'atmosphere',
|
||||
label: '温暖治愈',
|
||||
prompt: '柔和明亮的治愈背景音乐',
|
||||
},
|
||||
{
|
||||
id: 'mystery-suspense',
|
||||
group: 'atmosphere',
|
||||
label: '神秘悬疑',
|
||||
prompt: '克制神秘的悬疑背景音乐',
|
||||
},
|
||||
{
|
||||
id: 'tension-buildup',
|
||||
group: 'atmosphere',
|
||||
label: '紧张推进',
|
||||
prompt: '稳定推进、逐渐紧张的背景音乐',
|
||||
},
|
||||
{
|
||||
id: 'forest-nature',
|
||||
group: 'scene',
|
||||
label: '森林自然',
|
||||
prompt: '清新自然的森林背景音乐',
|
||||
},
|
||||
{
|
||||
id: 'rainy-night',
|
||||
group: 'scene',
|
||||
label: '雨夜静谧',
|
||||
prompt: '雨夜静谧、略带神秘感的背景音乐',
|
||||
},
|
||||
{
|
||||
id: 'ocean-drift',
|
||||
group: 'scene',
|
||||
label: '海洋漂流',
|
||||
prompt: '开阔舒缓的海洋漂流背景音乐',
|
||||
},
|
||||
{
|
||||
id: 'mountain-journey',
|
||||
group: 'scene',
|
||||
label: '山野远行',
|
||||
prompt: '自由舒展的山野远行背景音乐',
|
||||
},
|
||||
{
|
||||
id: 'candy-park',
|
||||
group: 'scene',
|
||||
label: '糖果乐园',
|
||||
prompt: '甜美活泼的糖果乐园背景音乐',
|
||||
},
|
||||
{
|
||||
id: 'cozy-cabin',
|
||||
group: 'scene',
|
||||
label: '温馨小屋',
|
||||
prompt: '温暖安静的温馨小屋背景音乐',
|
||||
},
|
||||
{
|
||||
id: 'city-night',
|
||||
group: 'scene',
|
||||
label: '城市夜晚',
|
||||
prompt: '克制迷人的城市夜晚背景音乐',
|
||||
},
|
||||
{
|
||||
id: 'space-scifi',
|
||||
group: 'scene',
|
||||
label: '太空科幻',
|
||||
prompt: '空灵未来感的太空科幻背景音乐',
|
||||
},
|
||||
{
|
||||
id: 'cyber-block',
|
||||
group: 'scene',
|
||||
label: '赛博街区',
|
||||
prompt: '冷静律动的赛博街区背景音乐',
|
||||
},
|
||||
{
|
||||
id: 'ancient-fantasy',
|
||||
group: 'scene',
|
||||
label: '古风幻想',
|
||||
prompt: '空灵雅致的古风幻想背景音乐',
|
||||
},
|
||||
{
|
||||
id: 'fairytale-garden',
|
||||
group: 'scene',
|
||||
label: '童话花园',
|
||||
prompt: '梦幻轻盈的童话花园背景音乐',
|
||||
},
|
||||
{
|
||||
id: 'undersea-ruins',
|
||||
group: 'scene',
|
||||
label: '海底遗迹',
|
||||
prompt: '深邃神秘的海底遗迹背景音乐',
|
||||
},
|
||||
{
|
||||
id: 'desert-ruins',
|
||||
group: 'scene',
|
||||
label: '沙漠遗迹',
|
||||
prompt: '苍茫神秘的沙漠遗迹背景音乐',
|
||||
},
|
||||
{
|
||||
id: 'lava-cave',
|
||||
group: 'scene',
|
||||
label: '熔岩洞穴',
|
||||
prompt: '炽热压迫的熔岩洞穴背景音乐',
|
||||
},
|
||||
{
|
||||
id: 'curious-museum',
|
||||
group: 'scene',
|
||||
label: '奇妙博物馆',
|
||||
prompt: '好奇灵动的奇妙博物馆背景音乐',
|
||||
},
|
||||
] as const satisfies readonly BackgroundMusicPromptPreset[];
|
||||
|
||||
function readLastCodePoint(value: string) {
|
||||
// 必须按 code point 读取:直接取最后一个 UTF-16 code unit 会在 emoji 等
|
||||
// surrogate pair 结尾时拿到半个字符,标点判断随之失真。
|
||||
const codePoints = Array.from(value);
|
||||
return codePoints[codePoints.length - 1];
|
||||
}
|
||||
|
||||
/**
|
||||
* 按权威设计的追加规则返回新的可见 Prompt。
|
||||
*
|
||||
* 输入先复用共享 canonicalizer(不使用会额外删除 U+FEFF 的原生 `trim()`),因此调用方
|
||||
* 传入尚未规范化的文本也不会产出 ` 。预设文本` 这类结果;canonicalization 幂等,
|
||||
* 已经写回过的文本再算一次结果相同。
|
||||
*
|
||||
* 不去重、不截断、不改内部空格与换行;追加后允许超过 200 或 2000 字,文本必须完整保留。
|
||||
*/
|
||||
export function appendBackgroundMusicPromptPreset(
|
||||
currentPrompt: string,
|
||||
preset: BackgroundMusicPromptPreset,
|
||||
) {
|
||||
const canonicalPrompt = canonicalizeBackgroundMusicPrompt(currentPrompt);
|
||||
if (!canonicalPrompt) {
|
||||
return preset.prompt;
|
||||
}
|
||||
|
||||
const lastCodePoint = readLastCodePoint(canonicalPrompt);
|
||||
if (lastCodePoint && UNICODE_PUNCTUATION_CODE_POINT.test(lastCodePoint)) {
|
||||
return `${canonicalPrompt}${preset.prompt}`;
|
||||
}
|
||||
return `${canonicalPrompt}${BACKGROUND_MUSIC_PRESET_SENTENCE_SEPARATOR}${preset.prompt}`;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,461 @@
|
||||
import type {
|
||||
CanvasGenerationDialogState,
|
||||
GenerateDialogState,
|
||||
} from './ImageCanvasEditorTypes';
|
||||
|
||||
export const BACKGROUND_MUSIC_PROMPT_MAX_CODE_POINTS = 200;
|
||||
export const BACKGROUND_MUSIC_PROMPT_SIMPLIFICATION_MAX_CODE_POINTS =
|
||||
BACKGROUND_MUSIC_PROMPT_MAX_CODE_POINTS * 10;
|
||||
export const BACKGROUND_MUSIC_PROMPT_GENERATION_MIN_EFFECTIVE_CODE_POINTS = 1;
|
||||
export const BACKGROUND_MUSIC_PROMPT_COMPLETION_MIN_EFFECTIVE_CODE_POINTS = 2;
|
||||
|
||||
const UNICODE_WHITE_SPACE_CODE_POINT = /^\p{White_Space}$/u;
|
||||
const LEADING_UNICODE_WHITE_SPACE = /^\p{White_Space}+/u;
|
||||
const TRAILING_UNICODE_WHITE_SPACE = /\p{White_Space}+$/u;
|
||||
const BACKGROUND_MUSIC_PROMPT_OPERATION_ID_PREFIX =
|
||||
'background-music-prompt-operation';
|
||||
|
||||
export type BackgroundMusicPromptOperationStatus =
|
||||
| 'completing'
|
||||
| 'simplifying'
|
||||
| 'submitting';
|
||||
|
||||
export type BackgroundMusicPromptDialogStatus =
|
||||
| 'idle'
|
||||
| BackgroundMusicPromptOperationStatus;
|
||||
|
||||
export type BackgroundMusicPromptOperation = {
|
||||
dialogId: string;
|
||||
operationId: string;
|
||||
status: BackgroundMusicPromptOperationStatus;
|
||||
canonicalPrompt: string;
|
||||
};
|
||||
|
||||
export type BackgroundMusicPromptDialogState = {
|
||||
dialogId: string;
|
||||
status: BackgroundMusicPromptDialogStatus;
|
||||
operationId: string | null;
|
||||
undoPromptSnapshot: string | null;
|
||||
temporaryPromptSnapshot: string | null;
|
||||
};
|
||||
|
||||
export type BackgroundMusicPromptOperationStartResult = {
|
||||
started: boolean;
|
||||
reason: 'started' | 'duplicate' | 'ineligible' | 'locked';
|
||||
canonicalPrompt: string;
|
||||
operation: BackgroundMusicPromptOperation | null;
|
||||
state: BackgroundMusicPromptDialogState;
|
||||
};
|
||||
|
||||
export type BackgroundMusicPromptOperationResolution = {
|
||||
applied: boolean;
|
||||
prompt: string | null;
|
||||
state: BackgroundMusicPromptDialogState;
|
||||
};
|
||||
|
||||
export type BackgroundMusicPromptBoundaryResult = {
|
||||
applied: boolean;
|
||||
prompt: string;
|
||||
state: BackgroundMusicPromptDialogState;
|
||||
};
|
||||
|
||||
type InternalBackgroundMusicPromptDialogState =
|
||||
BackgroundMusicPromptDialogState & {
|
||||
activeCanonicalPrompt: string | null;
|
||||
};
|
||||
|
||||
export type BackgroundMusicPromptStateModel = ReturnType<
|
||||
typeof createBackgroundMusicPromptStateModel
|
||||
>;
|
||||
|
||||
export type BackgroundMusicGenerationDialogState =
|
||||
CanvasGenerationDialogState & {
|
||||
mode: 'audio-background-music';
|
||||
};
|
||||
|
||||
/**
|
||||
* `GenerateDialogState` 是单一对象类型而不是可判别联合,`mode === 'audio-background-music'`
|
||||
* 只窄化属性读取,展开后 `mode` 与可选 `id` 仍是宽类型。助手动作全部按 dialog ID 生效,
|
||||
* 所以消费前必须先收窄出带稳定 ID 的 BGM dialog,不能把可选 `id` 直接传下去。
|
||||
*/
|
||||
export function toBackgroundMusicGenerationDialog(
|
||||
dialog: GenerateDialogState | null,
|
||||
): BackgroundMusicGenerationDialogState | null {
|
||||
if (!dialog || dialog.mode !== 'audio-background-music' || !dialog.id) {
|
||||
return null;
|
||||
}
|
||||
return { ...dialog, id: dialog.id, mode: 'audio-background-music' };
|
||||
}
|
||||
|
||||
export function canonicalizeBackgroundMusicPrompt(prompt: string) {
|
||||
return prompt
|
||||
.replace(LEADING_UNICODE_WHITE_SPACE, '')
|
||||
.replace(TRAILING_UNICODE_WHITE_SPACE, '');
|
||||
}
|
||||
|
||||
export function countPromptCodePoints(prompt: string) {
|
||||
return Array.from(canonicalizeBackgroundMusicPrompt(prompt)).length;
|
||||
}
|
||||
|
||||
export function countEffectivePromptCodePoints(prompt: string) {
|
||||
let effectiveCodePoints = 0;
|
||||
for (const codePoint of canonicalizeBackgroundMusicPrompt(prompt)) {
|
||||
if (!UNICODE_WHITE_SPACE_CODE_POINT.test(codePoint)) {
|
||||
effectiveCodePoints += 1;
|
||||
}
|
||||
}
|
||||
return effectiveCodePoints;
|
||||
}
|
||||
|
||||
export function canGenerateBackgroundMusicFromPrompt(prompt: string) {
|
||||
return (
|
||||
countPromptCodePoints(prompt) <= BACKGROUND_MUSIC_PROMPT_MAX_CODE_POINTS &&
|
||||
countEffectivePromptCodePoints(prompt) >=
|
||||
BACKGROUND_MUSIC_PROMPT_GENERATION_MIN_EFFECTIVE_CODE_POINTS
|
||||
);
|
||||
}
|
||||
|
||||
export function canCompleteBackgroundMusicPrompt(prompt: string) {
|
||||
return (
|
||||
countPromptCodePoints(prompt) <= BACKGROUND_MUSIC_PROMPT_MAX_CODE_POINTS &&
|
||||
countEffectivePromptCodePoints(prompt) >=
|
||||
BACKGROUND_MUSIC_PROMPT_COMPLETION_MIN_EFFECTIVE_CODE_POINTS
|
||||
);
|
||||
}
|
||||
|
||||
export function canSimplifyBackgroundMusicPrompt(prompt: string) {
|
||||
const codePointCount = countPromptCodePoints(prompt);
|
||||
return (
|
||||
codePointCount > BACKGROUND_MUSIC_PROMPT_MAX_CODE_POINTS &&
|
||||
codePointCount <= BACKGROUND_MUSIC_PROMPT_SIMPLIFICATION_MAX_CODE_POINTS &&
|
||||
countEffectivePromptCodePoints(prompt) >=
|
||||
BACKGROUND_MUSIC_PROMPT_GENERATION_MIN_EFFECTIVE_CODE_POINTS
|
||||
);
|
||||
}
|
||||
|
||||
function createIdleBackgroundMusicPromptDialogState(
|
||||
dialogId: string,
|
||||
): InternalBackgroundMusicPromptDialogState {
|
||||
return {
|
||||
dialogId,
|
||||
status: 'idle',
|
||||
operationId: null,
|
||||
undoPromptSnapshot: null,
|
||||
temporaryPromptSnapshot: null,
|
||||
activeCanonicalPrompt: null,
|
||||
};
|
||||
}
|
||||
|
||||
function toPublicBackgroundMusicPromptDialogState(
|
||||
state: InternalBackgroundMusicPromptDialogState,
|
||||
): BackgroundMusicPromptDialogState {
|
||||
return {
|
||||
dialogId: state.dialogId,
|
||||
status: state.status,
|
||||
operationId: state.operationId,
|
||||
undoPromptSnapshot: state.undoPromptSnapshot,
|
||||
temporaryPromptSnapshot: state.temporaryPromptSnapshot,
|
||||
};
|
||||
}
|
||||
|
||||
function canStartBackgroundMusicPromptOperation(
|
||||
status: BackgroundMusicPromptOperationStatus,
|
||||
canonicalPrompt: string,
|
||||
) {
|
||||
if (status === 'completing') {
|
||||
return canCompleteBackgroundMusicPrompt(canonicalPrompt);
|
||||
}
|
||||
if (status === 'simplifying') {
|
||||
return canSimplifyBackgroundMusicPrompt(canonicalPrompt);
|
||||
}
|
||||
return canGenerateBackgroundMusicFromPrompt(canonicalPrompt);
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps dialog-scoped Prompt workflow state outside React. All operation claims
|
||||
* happen synchronously, so callers can store one model instance in a ref and
|
||||
* decide whether to issue a request before their first await.
|
||||
*/
|
||||
export function createBackgroundMusicPromptStateModel() {
|
||||
const dialogStates = new Map<
|
||||
string,
|
||||
InternalBackgroundMusicPromptDialogState
|
||||
>();
|
||||
let operationSequence = 0;
|
||||
|
||||
const readInternalState = (dialogId: string) =>
|
||||
dialogStates.get(dialogId) ??
|
||||
createIdleBackgroundMusicPromptDialogState(dialogId);
|
||||
|
||||
const saveState = (state: InternalBackgroundMusicPromptDialogState) => {
|
||||
dialogStates.set(state.dialogId, state);
|
||||
return toPublicBackgroundMusicPromptDialogState(state);
|
||||
};
|
||||
|
||||
const getDialogState = (dialogId: string): BackgroundMusicPromptDialogState =>
|
||||
toPublicBackgroundMusicPromptDialogState(readInternalState(dialogId));
|
||||
|
||||
const beginOperation = ({
|
||||
dialogId,
|
||||
status,
|
||||
prompt,
|
||||
}: {
|
||||
dialogId: string;
|
||||
status: BackgroundMusicPromptOperationStatus;
|
||||
prompt: string;
|
||||
}): BackgroundMusicPromptOperationStartResult => {
|
||||
const currentState = readInternalState(dialogId);
|
||||
const canonicalPrompt = canonicalizeBackgroundMusicPrompt(prompt);
|
||||
|
||||
if (currentState.status === status && currentState.operationId !== null) {
|
||||
return {
|
||||
started: false,
|
||||
reason: 'duplicate',
|
||||
canonicalPrompt: currentState.activeCanonicalPrompt ?? canonicalPrompt,
|
||||
operation: null,
|
||||
state: toPublicBackgroundMusicPromptDialogState(currentState),
|
||||
};
|
||||
}
|
||||
|
||||
if (currentState.status === 'submitting') {
|
||||
return {
|
||||
started: false,
|
||||
reason: 'locked',
|
||||
canonicalPrompt: currentState.activeCanonicalPrompt ?? canonicalPrompt,
|
||||
operation: null,
|
||||
state: toPublicBackgroundMusicPromptDialogState(currentState),
|
||||
};
|
||||
}
|
||||
|
||||
if (!canStartBackgroundMusicPromptOperation(status, canonicalPrompt)) {
|
||||
return {
|
||||
started: false,
|
||||
reason: 'ineligible',
|
||||
canonicalPrompt,
|
||||
operation: null,
|
||||
state: toPublicBackgroundMusicPromptDialogState(currentState),
|
||||
};
|
||||
}
|
||||
|
||||
operationSequence += 1;
|
||||
const operation: BackgroundMusicPromptOperation = {
|
||||
dialogId,
|
||||
operationId: `${BACKGROUND_MUSIC_PROMPT_OPERATION_ID_PREFIX}-${operationSequence}`,
|
||||
status,
|
||||
canonicalPrompt,
|
||||
};
|
||||
const isAiOperation = status === 'completing' || status === 'simplifying';
|
||||
const nextState: InternalBackgroundMusicPromptDialogState = {
|
||||
...currentState,
|
||||
status,
|
||||
operationId: operation.operationId,
|
||||
undoPromptSnapshot: isAiOperation
|
||||
? null
|
||||
: currentState.undoPromptSnapshot,
|
||||
temporaryPromptSnapshot: isAiOperation ? canonicalPrompt : null,
|
||||
activeCanonicalPrompt: canonicalPrompt,
|
||||
};
|
||||
|
||||
return {
|
||||
started: true,
|
||||
reason: 'started',
|
||||
canonicalPrompt,
|
||||
operation,
|
||||
state: saveState(nextState),
|
||||
};
|
||||
};
|
||||
|
||||
const isCurrentOperation = (
|
||||
operation: BackgroundMusicPromptOperation,
|
||||
state: InternalBackgroundMusicPromptDialogState,
|
||||
) =>
|
||||
state.dialogId === operation.dialogId &&
|
||||
state.operationId === operation.operationId &&
|
||||
state.status === operation.status;
|
||||
|
||||
const resolveAiOperation = (
|
||||
operation: BackgroundMusicPromptOperation,
|
||||
prompt: string,
|
||||
): BackgroundMusicPromptOperationResolution => {
|
||||
const currentState = readInternalState(operation.dialogId);
|
||||
if (
|
||||
operation.status === 'submitting' ||
|
||||
!isCurrentOperation(operation, currentState)
|
||||
) {
|
||||
return {
|
||||
applied: false,
|
||||
prompt: null,
|
||||
state: toPublicBackgroundMusicPromptDialogState(currentState),
|
||||
};
|
||||
}
|
||||
|
||||
const canonicalPrompt = canonicalizeBackgroundMusicPrompt(prompt);
|
||||
if (!canGenerateBackgroundMusicFromPrompt(canonicalPrompt)) {
|
||||
const failedState: InternalBackgroundMusicPromptDialogState = {
|
||||
...currentState,
|
||||
status: 'idle',
|
||||
operationId: null,
|
||||
undoPromptSnapshot: null,
|
||||
temporaryPromptSnapshot: null,
|
||||
activeCanonicalPrompt: null,
|
||||
};
|
||||
return {
|
||||
applied: false,
|
||||
prompt: null,
|
||||
state: saveState(failedState),
|
||||
};
|
||||
}
|
||||
|
||||
const completedState: InternalBackgroundMusicPromptDialogState = {
|
||||
...currentState,
|
||||
status: 'idle',
|
||||
operationId: null,
|
||||
undoPromptSnapshot: currentState.temporaryPromptSnapshot,
|
||||
temporaryPromptSnapshot: null,
|
||||
activeCanonicalPrompt: null,
|
||||
};
|
||||
return {
|
||||
applied: true,
|
||||
prompt: canonicalPrompt,
|
||||
state: saveState(completedState),
|
||||
};
|
||||
};
|
||||
|
||||
const rejectOperation = (
|
||||
operation: BackgroundMusicPromptOperation,
|
||||
): BackgroundMusicPromptOperationResolution => {
|
||||
const currentState = readInternalState(operation.dialogId);
|
||||
if (!isCurrentOperation(operation, currentState)) {
|
||||
return {
|
||||
applied: false,
|
||||
prompt: null,
|
||||
state: toPublicBackgroundMusicPromptDialogState(currentState),
|
||||
};
|
||||
}
|
||||
|
||||
const isAiOperation = operation.status !== 'submitting';
|
||||
const failedState: InternalBackgroundMusicPromptDialogState = {
|
||||
...currentState,
|
||||
status: 'idle',
|
||||
operationId: null,
|
||||
undoPromptSnapshot: isAiOperation
|
||||
? null
|
||||
: currentState.undoPromptSnapshot,
|
||||
temporaryPromptSnapshot: null,
|
||||
activeCanonicalPrompt: null,
|
||||
};
|
||||
return {
|
||||
applied: true,
|
||||
prompt: currentState.activeCanonicalPrompt,
|
||||
state: saveState(failedState),
|
||||
};
|
||||
};
|
||||
|
||||
const completeSubmittingOperation = (
|
||||
operation: BackgroundMusicPromptOperation,
|
||||
): BackgroundMusicPromptOperationResolution => {
|
||||
const currentState = readInternalState(operation.dialogId);
|
||||
if (
|
||||
operation.status !== 'submitting' ||
|
||||
!isCurrentOperation(operation, currentState)
|
||||
) {
|
||||
return {
|
||||
applied: false,
|
||||
prompt: null,
|
||||
state: toPublicBackgroundMusicPromptDialogState(currentState),
|
||||
};
|
||||
}
|
||||
|
||||
const submittedPrompt =
|
||||
currentState.activeCanonicalPrompt ?? operation.canonicalPrompt;
|
||||
const completedState: InternalBackgroundMusicPromptDialogState = {
|
||||
...currentState,
|
||||
status: 'idle',
|
||||
operationId: null,
|
||||
temporaryPromptSnapshot: null,
|
||||
activeCanonicalPrompt: null,
|
||||
};
|
||||
return {
|
||||
applied: true,
|
||||
prompt: submittedPrompt,
|
||||
state: saveState(completedState),
|
||||
};
|
||||
};
|
||||
|
||||
const preparePreset = (
|
||||
dialogId: string,
|
||||
prompt: string,
|
||||
): BackgroundMusicPromptBoundaryResult => {
|
||||
const currentState = readInternalState(dialogId);
|
||||
const canonicalPrompt = canonicalizeBackgroundMusicPrompt(prompt);
|
||||
if (currentState.status === 'submitting') {
|
||||
return {
|
||||
applied: false,
|
||||
prompt: currentState.activeCanonicalPrompt ?? canonicalPrompt,
|
||||
state: toPublicBackgroundMusicPromptDialogState(currentState),
|
||||
};
|
||||
}
|
||||
const nextState: InternalBackgroundMusicPromptDialogState = {
|
||||
...currentState,
|
||||
status: 'idle',
|
||||
operationId: null,
|
||||
undoPromptSnapshot: null,
|
||||
temporaryPromptSnapshot: null,
|
||||
activeCanonicalPrompt: null,
|
||||
};
|
||||
return {
|
||||
applied: true,
|
||||
prompt: canonicalPrompt,
|
||||
state: saveState(nextState),
|
||||
};
|
||||
};
|
||||
|
||||
const swapUndoSnapshot = (
|
||||
dialogId: string,
|
||||
prompt: string,
|
||||
): BackgroundMusicPromptBoundaryResult => {
|
||||
const currentState = readInternalState(dialogId);
|
||||
if (
|
||||
currentState.status !== 'idle' ||
|
||||
currentState.undoPromptSnapshot === null
|
||||
) {
|
||||
return {
|
||||
applied: false,
|
||||
prompt,
|
||||
state: toPublicBackgroundMusicPromptDialogState(currentState),
|
||||
};
|
||||
}
|
||||
|
||||
const canonicalPrompt = canonicalizeBackgroundMusicPrompt(prompt);
|
||||
const nextPrompt = currentState.undoPromptSnapshot;
|
||||
const nextState: InternalBackgroundMusicPromptDialogState = {
|
||||
...currentState,
|
||||
undoPromptSnapshot: canonicalPrompt,
|
||||
};
|
||||
return {
|
||||
applied: true,
|
||||
prompt: nextPrompt,
|
||||
state: saveState(nextState),
|
||||
};
|
||||
};
|
||||
|
||||
const closeDialog = (dialogId: string) => {
|
||||
dialogStates.delete(dialogId);
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
dialogStates.clear();
|
||||
};
|
||||
|
||||
return {
|
||||
getDialogState,
|
||||
beginOperation,
|
||||
resolveAiOperation,
|
||||
rejectOperation,
|
||||
completeSubmittingOperation,
|
||||
preparePreset,
|
||||
swapUndoSnapshot,
|
||||
closeDialog,
|
||||
reset,
|
||||
};
|
||||
}
|
||||
@@ -167,6 +167,7 @@ function createTestImageAsset({
|
||||
width: 320,
|
||||
height: 240,
|
||||
sourceType,
|
||||
assetKind: 'image' as const,
|
||||
sourceResourceId,
|
||||
};
|
||||
}
|
||||
@@ -346,6 +347,85 @@ describe('ImageCanvasEditorView asset library integration', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('downloads a character action from the asset library as a sequence zip', async () => {
|
||||
loadEditorAssetLibraryMock.mockResolvedValueOnce({
|
||||
folders: [
|
||||
{
|
||||
folderId: 'project',
|
||||
label: '项目素材',
|
||||
sortOrder: 0,
|
||||
collapsed: false,
|
||||
systemDefault: true,
|
||||
},
|
||||
],
|
||||
assets: [
|
||||
{
|
||||
assetId: 'asset-action-download',
|
||||
folderId: 'project',
|
||||
label: '角色挥手',
|
||||
imageSrc: '/generated/action/frame01.png',
|
||||
width: 192,
|
||||
height: 256,
|
||||
sourceType: 'generated',
|
||||
assetKind: 'character-animation',
|
||||
imageSequenceFrames: [
|
||||
{
|
||||
imageSrc: '/generated/action/frame01.png',
|
||||
width: 192,
|
||||
height: 256,
|
||||
},
|
||||
{
|
||||
imageSrc: '/generated/action/frame02.png',
|
||||
width: 192,
|
||||
height: 256,
|
||||
},
|
||||
],
|
||||
imageSequenceDurationMs: 4_000,
|
||||
},
|
||||
],
|
||||
});
|
||||
const originalFetch = globalThis.fetch;
|
||||
const fetchMock = vi.fn(async (..._args: Parameters<typeof fetch>) =>
|
||||
new Response(new Blob(['frame'], { type: 'image/png' })),
|
||||
);
|
||||
globalThis.fetch = fetchMock as typeof fetch;
|
||||
let downloadName = '';
|
||||
const downloadCapture: { blob: Blob | null } = { blob: null };
|
||||
Object.defineProperty(URL, 'createObjectURL', {
|
||||
configurable: true,
|
||||
value: vi.fn((blob: Blob) => {
|
||||
downloadCapture.blob = blob;
|
||||
return 'blob:character-action-zip';
|
||||
}),
|
||||
});
|
||||
Object.defineProperty(URL, 'revokeObjectURL', {
|
||||
configurable: true,
|
||||
value: vi.fn(),
|
||||
});
|
||||
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(
|
||||
function click(this: HTMLAnchorElement) {
|
||||
downloadName = this.download;
|
||||
},
|
||||
);
|
||||
|
||||
try {
|
||||
render(<ImageCanvasEditorView />);
|
||||
fireEvent.click(
|
||||
await screen.findByRole('button', { name: '下载素材角色挥手' }),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(downloadName).toBe('角色挥手-SpineJSON.zip');
|
||||
});
|
||||
const fetchedUrls = fetchMock.mock.calls.map(([url]) => url);
|
||||
expect(fetchedUrls).toContain('/generated/action/frame01.png');
|
||||
expect(fetchedUrls).toContain('/generated/action/frame02.png');
|
||||
expect(downloadCapture.blob?.type).toBe('application/zip');
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
it('collapses folders, creates upload folders, and deletes uploaded materials', async () => {
|
||||
const user = userEvent.setup();
|
||||
const createObjectUrlSpy = vi.fn(() => 'blob:folder-uploaded-image');
|
||||
@@ -1500,6 +1580,94 @@ describe('ImageCanvasEditorView asset library integration', () => {
|
||||
expect(createEditorAssetMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('drags a character action onto the canvas as a saved playable sequence', async () => {
|
||||
const frames = [
|
||||
{
|
||||
imageSrc: '/generated/action/run-frame01.png',
|
||||
width: 192,
|
||||
height: 256,
|
||||
},
|
||||
{
|
||||
imageSrc: '/generated/action/run-frame02.png',
|
||||
width: 192,
|
||||
height: 256,
|
||||
},
|
||||
];
|
||||
loadEditorAssetLibraryMock.mockResolvedValueOnce({
|
||||
folders: [
|
||||
{
|
||||
folderId: 'project',
|
||||
label: '项目素材',
|
||||
sortOrder: 0,
|
||||
collapsed: false,
|
||||
systemDefault: true,
|
||||
},
|
||||
],
|
||||
assets: [
|
||||
{
|
||||
assetId: 'asset-action-run',
|
||||
folderId: 'project',
|
||||
label: '角色奔跑',
|
||||
imageSrc: frames[0]!.imageSrc,
|
||||
width: 192,
|
||||
height: 256,
|
||||
sourceType: 'generated',
|
||||
assetKind: 'character-animation',
|
||||
imageSequenceFrames: frames,
|
||||
imageSequenceDurationMs: 4_000,
|
||||
},
|
||||
],
|
||||
});
|
||||
render(<ImageCanvasEditorView />);
|
||||
|
||||
const sourceAsset = await screen.findByRole('button', {
|
||||
name: '添加角色奔跑',
|
||||
});
|
||||
const sourceAssetRow = sourceAsset.closest(
|
||||
'.image-canvas-editor__asset-row',
|
||||
);
|
||||
const viewport = screen.getByLabelText('画布工作区');
|
||||
const dataTransfer = createDataTransferStub();
|
||||
if (!sourceAssetRow) {
|
||||
throw new Error('asset row should exist');
|
||||
}
|
||||
fireEvent.dragStart(sourceAssetRow, { dataTransfer });
|
||||
fireEvent.drop(viewport, {
|
||||
clientX: 520,
|
||||
clientY: 300,
|
||||
dataTransfer,
|
||||
});
|
||||
|
||||
expect(
|
||||
await screen.findByAltText('画布序列帧:角色奔跑'),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
screen.getByRole('button', { name: '暂停角色奔跑序列帧播放' }),
|
||||
).toBeTruthy();
|
||||
await waitFor(() => {
|
||||
expect(createEditorProjectResourceMock).toHaveBeenCalledWith(
|
||||
'editor-project-default',
|
||||
expect.objectContaining({
|
||||
assetKind: 'character-animation',
|
||||
imageSequenceFrames: frames,
|
||||
imageSequenceDurationMs: 4_000,
|
||||
}),
|
||||
);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(saveEditorProjectLayoutMock).toHaveBeenCalledWith(
|
||||
'editor-project-default',
|
||||
expect.objectContaining({
|
||||
layers: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
title: '角色奔跑',
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the source resource id when dragging a generated asset onto the viewport', async () => {
|
||||
loadEditorAssetLibraryMock.mockResolvedValueOnce({
|
||||
folders: [
|
||||
|
||||
@@ -3230,20 +3230,32 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
resources: [],
|
||||
updatedAt: '2026-06-15T00:00:00.000Z',
|
||||
});
|
||||
const animationFrames = Array.from({ length: 48 }, (_, index) => ({
|
||||
imageSrc: `/generated-character-drafts/editor/frame${index + 1}.png`,
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
}));
|
||||
generateEditorCharacterAnimationMock.mockResolvedValueOnce({
|
||||
taskId: 'character-animation-task-1',
|
||||
model: 'seedance2.0-fast',
|
||||
prompt: '生成游戏角色动画\n动作描述:\n待机',
|
||||
previewVideoPath: '/generated-character-drafts/editor/preview.mp4',
|
||||
frames: Array.from({ length: 48 }, (_, index) => ({
|
||||
frameIndex: index + 1,
|
||||
imageSrc: `/generated-character-drafts/editor/frame${index + 1}.png`,
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
})),
|
||||
frames: animationFrames,
|
||||
frameCount: 48,
|
||||
durationSeconds: 6,
|
||||
fps: 8,
|
||||
resource: {
|
||||
resourceId: 'resource-character-animation-final',
|
||||
projectId: 'editor-project-character-animation',
|
||||
imageSrc: animationFrames[0]!.imageSrc,
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
sourceType: 'generated',
|
||||
sourceResourceId: 'resource-character-animation-preview',
|
||||
assetKind: 'character-animation',
|
||||
imageSequenceFrames: animationFrames,
|
||||
imageSequenceDurationMs: 6_000,
|
||||
},
|
||||
});
|
||||
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
|
||||
const requestUrl =
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
hydrateCanvasGenerationDialog,
|
||||
hydrateLayer,
|
||||
INLINE_GENERATION_PLACEHOLDER_LIVE_WINDOW_MS,
|
||||
isCanvasAssetKindOverrideCompatible,
|
||||
normalizeAssetLibrary,
|
||||
normalizeCanvasBackgroundHex,
|
||||
PERFECT_PIXEL_RECONCILIATION_WINDOW_MS,
|
||||
@@ -197,7 +198,7 @@ describe('ImageCanvasEditorModel', () => {
|
||||
expect(library.folders[0]?.id).toBe('project');
|
||||
});
|
||||
|
||||
it('infers MP3 and MP4 asset media types from persisted paths', () => {
|
||||
it('uses media types published by the asset API', () => {
|
||||
const library = normalizeAssetLibrary({
|
||||
folders: [
|
||||
{
|
||||
@@ -218,6 +219,7 @@ describe('ImageCanvasEditorModel', () => {
|
||||
width: 420,
|
||||
height: 120,
|
||||
sourceType: 'uploaded',
|
||||
assetKind: 'audio',
|
||||
objectKey:
|
||||
'generated-character-drafts/editor/asset-library/audio/胜利音效.mp3',
|
||||
},
|
||||
@@ -232,6 +234,7 @@ describe('ImageCanvasEditorModel', () => {
|
||||
width: 560,
|
||||
height: 315,
|
||||
sourceType: 'uploaded',
|
||||
assetKind: 'video',
|
||||
objectKey:
|
||||
'generated-character-drafts/editor/asset-library/video/开场动画.mp4',
|
||||
},
|
||||
@@ -253,10 +256,63 @@ describe('ImageCanvasEditorModel', () => {
|
||||
),
|
||||
).toMatchObject({
|
||||
mediaType: 'audio',
|
||||
assetKind: 'sound-effect',
|
||||
assetKind: 'audio',
|
||||
});
|
||||
});
|
||||
|
||||
it('infers kinds for legacy persisted media assets from source extensions', () => {
|
||||
const library = normalizeAssetLibrary({
|
||||
folders: [],
|
||||
assets: [
|
||||
{
|
||||
assetId: 'legacy-video',
|
||||
folderId: 'project',
|
||||
label: '旧开场动画.mp4',
|
||||
imageSrc: '/legacy/opening.mp4?signature=video',
|
||||
width: 1280,
|
||||
height: 720,
|
||||
sourceType: 'uploaded',
|
||||
},
|
||||
{
|
||||
assetId: 'legacy-audio',
|
||||
folderId: 'project',
|
||||
label: '旧背景音乐',
|
||||
imageSrc: '/api/assets/read',
|
||||
objectKey: 'legacy/music.mp3?version=1',
|
||||
width: 420,
|
||||
height: 120,
|
||||
sourceType: 'uploaded',
|
||||
assetKind: 'unrecognized-legacy-kind',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(library.assets[0]).toMatchObject({
|
||||
mediaType: 'video',
|
||||
assetKind: 'video',
|
||||
});
|
||||
expect(library.assets[1]).toMatchObject({
|
||||
mediaType: 'audio',
|
||||
assetKind: 'background-music',
|
||||
});
|
||||
expect(
|
||||
library.assets.map((asset, index) =>
|
||||
createLayerFromAsset(
|
||||
asset,
|
||||
index,
|
||||
{ x: 0, y: 0, scale: 1 },
|
||||
{ x: 300, y: 200 },
|
||||
),
|
||||
),
|
||||
).toEqual([
|
||||
expect.objectContaining({ mediaType: 'video', assetKind: 'video' }),
|
||||
expect.objectContaining({
|
||||
mediaType: 'audio',
|
||||
assetKind: 'background-music',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('creates a cascaded layer from an account asset near the requested screen point', () => {
|
||||
const asset: EditorAsset = {
|
||||
id: 'asset-1',
|
||||
@@ -344,6 +400,7 @@ describe('ImageCanvasEditorModel', () => {
|
||||
width: 640,
|
||||
height: 640,
|
||||
sourceType: 'generated',
|
||||
assetKind: 'image',
|
||||
sourceResourceId: 'resource-generated',
|
||||
},
|
||||
],
|
||||
@@ -355,6 +412,87 @@ describe('ImageCanvasEditorModel', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('round-trips an explicit character action into a movable sequence layer', () => {
|
||||
const frames = [
|
||||
{
|
||||
imageSrc: '/generated/action/frame01.png',
|
||||
width: 192,
|
||||
height: 256,
|
||||
},
|
||||
{
|
||||
imageSrc: '/generated/action/frame02.png',
|
||||
width: 192,
|
||||
height: 256,
|
||||
},
|
||||
];
|
||||
const library = normalizeAssetLibrary({
|
||||
folders: [
|
||||
{
|
||||
folderId: 'project',
|
||||
label: '项目素材',
|
||||
sortOrder: 0,
|
||||
collapsed: false,
|
||||
systemDefault: true,
|
||||
},
|
||||
],
|
||||
assets: [
|
||||
{
|
||||
assetId: 'asset-action',
|
||||
folderId: 'project',
|
||||
label: '角色挥手',
|
||||
imageSrc: frames[0]!.imageSrc,
|
||||
width: 192,
|
||||
height: 256,
|
||||
sourceType: 'generated',
|
||||
assetKind: 'character-animation',
|
||||
imageSequenceFrames: frames,
|
||||
imageSequenceDurationMs: 4_000,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const asset = library.assets[0] as EditorAsset;
|
||||
const layer = createLayerFromAsset(
|
||||
asset,
|
||||
1,
|
||||
{ x: 0, y: 0, scale: 1 },
|
||||
{ x: 400, y: 300 },
|
||||
{ applyCascadeOffset: false },
|
||||
);
|
||||
|
||||
expect(asset.mediaType).toBe('image-sequence');
|
||||
expect(layer).toMatchObject({
|
||||
mediaType: 'image-sequence',
|
||||
imageSequenceFrames: frames,
|
||||
imageSequenceDurationMs: 4_000,
|
||||
x: 304,
|
||||
y: 172,
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects an explicitly typed but incomplete character action', () => {
|
||||
expect(() =>
|
||||
createLayerFromAsset(
|
||||
{
|
||||
id: 'asset-corrupt-action',
|
||||
label: '损坏动作',
|
||||
src: '/generated/action/frame01.png',
|
||||
mediaType: 'image-sequence',
|
||||
width: 192,
|
||||
height: 256,
|
||||
folderId: 'project',
|
||||
sourceKind: 'uploaded',
|
||||
sourceType: 'generated',
|
||||
persisted: true,
|
||||
assetKind: 'character-animation',
|
||||
},
|
||||
1,
|
||||
{ x: 0, y: 0, scale: 1 },
|
||||
{ x: 400, y: 300 },
|
||||
),
|
||||
).toThrow('缺少可用序列帧');
|
||||
});
|
||||
|
||||
it('serializes and hydrates canvas layer metadata without embedding image payloads', () => {
|
||||
const layer: CanvasLayer = {
|
||||
id: 'layer-generated',
|
||||
@@ -458,7 +596,7 @@ describe('ImageCanvasEditorModel', () => {
|
||||
width: 320,
|
||||
height: 240,
|
||||
},
|
||||
],
|
||||
] as unknown as CanvasLayer['imageSequenceFrames'],
|
||||
model: 'seedance2.0-fast',
|
||||
provider: 'ark',
|
||||
};
|
||||
@@ -526,6 +664,7 @@ describe('ImageCanvasEditorModel', () => {
|
||||
};
|
||||
|
||||
expect(hydrateLayer(validSequence, new Map())).toMatchObject({
|
||||
mediaType: 'image-sequence',
|
||||
resourcePersistenceState: 'self-contained-local',
|
||||
});
|
||||
expect(
|
||||
@@ -553,6 +692,75 @@ describe('ImageCanvasEditorModel', () => {
|
||||
).toMatchObject({ resourcePersistenceState: 'unresolved-local' });
|
||||
});
|
||||
|
||||
it('derives audio and video media types from assetKind without persisting mediaType', () => {
|
||||
for (const [mediaType, assetKind] of [
|
||||
['video', 'video'],
|
||||
['audio', 'background-music'],
|
||||
] as const) {
|
||||
const resourceId = `${mediaType}-resource`;
|
||||
const resources = new Map([
|
||||
[
|
||||
resourceId,
|
||||
{
|
||||
imageSrc:
|
||||
mediaType === 'video'
|
||||
? '/legacy/cutscene.mp4'
|
||||
: '/legacy/theme.mp3',
|
||||
assetKind,
|
||||
},
|
||||
],
|
||||
]);
|
||||
const firstHydration = hydrateLayer(
|
||||
{
|
||||
layerId: `${mediaType}-layer`,
|
||||
resourceId,
|
||||
title: mediaType,
|
||||
sourceType: 'uploaded',
|
||||
assetKind,
|
||||
},
|
||||
resources,
|
||||
);
|
||||
|
||||
expect(firstHydration?.mediaType).toBe(mediaType);
|
||||
if (!firstHydration) {
|
||||
throw new Error(`${mediaType} layer should hydrate`);
|
||||
}
|
||||
const savedSnapshot = serializeLayer(firstHydration);
|
||||
expect(savedSnapshot.mediaType).toBe(mediaType);
|
||||
expect(savedSnapshot.assetKind).toBeUndefined();
|
||||
|
||||
const secondHydration = hydrateLayer(savedSnapshot, resources);
|
||||
expect(secondHydration?.mediaType).toBe(mediaType);
|
||||
}
|
||||
});
|
||||
|
||||
it('preserves legacy audio and video media types when assetKind is absent', () => {
|
||||
for (const mediaType of ['video', 'audio'] as const) {
|
||||
const hydrated = hydrateLayer(
|
||||
{
|
||||
layerId: `legacy-${mediaType}-layer`,
|
||||
resourceId: `legacy-${mediaType}-resource`,
|
||||
title: `旧${mediaType}`,
|
||||
sourceType: 'uploaded',
|
||||
mediaType,
|
||||
},
|
||||
new Map([
|
||||
[
|
||||
`legacy-${mediaType}-resource`,
|
||||
{
|
||||
imageSrc:
|
||||
mediaType === 'video'
|
||||
? '/legacy/cutscene.mp4'
|
||||
: '/legacy/theme.mp3',
|
||||
},
|
||||
],
|
||||
]),
|
||||
);
|
||||
|
||||
expect(hydrated?.mediaType).toBe(mediaType);
|
||||
}
|
||||
});
|
||||
|
||||
it('recovers the normal source model after the owner payload removes an internal model', () => {
|
||||
const hydrated = hydrateLayer(
|
||||
{
|
||||
@@ -661,7 +869,65 @@ describe('ImageCanvasEditorModel', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('hydrates audio media type and sound effect asset kind from saved layout', () => {
|
||||
it('falls back from an incompatible persisted override without dropping the layer', () => {
|
||||
const onAssetKindOverrideFallback = vi.fn();
|
||||
const hydrated = hydrateLayer(
|
||||
{
|
||||
layerId: 'layer-image-with-action-label',
|
||||
resourceId: 'resource-image',
|
||||
title: '普通图片',
|
||||
x: 10,
|
||||
y: 20,
|
||||
width: 320,
|
||||
height: 240,
|
||||
originalWidth: 320,
|
||||
originalHeight: 240,
|
||||
zIndex: 1,
|
||||
sourceType: 'uploaded',
|
||||
assetKindOverride: 'character-animation',
|
||||
},
|
||||
new Map([
|
||||
['resource-image', { imageSrc: '/read/image.png', assetKind: 'image' }],
|
||||
]),
|
||||
{ onAssetKindOverrideFallback },
|
||||
);
|
||||
|
||||
expect(hydrated).toMatchObject({
|
||||
id: 'layer-image-with-action-label',
|
||||
src: '/read/image.png',
|
||||
mediaType: 'image',
|
||||
resourceAssetKind: 'image',
|
||||
assetKindOverride: null,
|
||||
assetKind: 'image',
|
||||
});
|
||||
expect(onAssetKindOverrideFallback).toHaveBeenCalledWith({
|
||||
layerId: 'layer-image-with-action-label',
|
||||
resourceId: 'resource-image',
|
||||
resourceAssetKind: 'image',
|
||||
rejectedAssetKindOverride: 'character-animation',
|
||||
});
|
||||
});
|
||||
|
||||
it('uses the same media-family compatibility matrix for layer labels', () => {
|
||||
expect(isCanvasAssetKindOverrideCompatible('image', 'character')).toBe(
|
||||
true,
|
||||
);
|
||||
expect(isCanvasAssetKindOverrideCompatible(null, 'icon')).toBe(true);
|
||||
expect(
|
||||
isCanvasAssetKindOverrideCompatible('sound-effect', 'background-music'),
|
||||
).toBe(true);
|
||||
expect(isCanvasAssetKindOverrideCompatible('image', 'video')).toBe(false);
|
||||
expect(
|
||||
isCanvasAssetKindOverrideCompatible('image', 'character-animation'),
|
||||
).toBe(false);
|
||||
expect(isCanvasAssetKindOverrideCompatible('video', 'character')).toBe(
|
||||
false,
|
||||
);
|
||||
expect(isCanvasAssetKindOverrideCompatible('audio', 'icon')).toBe(false);
|
||||
expect(isCanvasAssetKindOverrideCompatible('video', null)).toBe(true);
|
||||
});
|
||||
|
||||
it('hydrates audio display duration only from resource generation inputs', () => {
|
||||
const hydrated = hydrateLayer(
|
||||
{
|
||||
layerId: 'layer-audio',
|
||||
@@ -677,14 +943,16 @@ describe('ImageCanvasEditorModel', () => {
|
||||
sourceType: 'generated',
|
||||
mediaType: 'audio',
|
||||
assetKind: 'sound-effect',
|
||||
durationSeconds: 2.4,
|
||||
},
|
||||
new Map([
|
||||
[
|
||||
'resource-audio',
|
||||
{
|
||||
imageSrc: '/generated-character-drafts/editor-audios/sfx.mp3',
|
||||
durationSeconds: 2.4,
|
||||
generationInputs: {
|
||||
fields: [{ title: '时长', value: '3.5秒' }],
|
||||
references: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
]),
|
||||
@@ -695,11 +963,15 @@ describe('ImageCanvasEditorModel', () => {
|
||||
src: '/generated-character-drafts/editor-audios/sfx.mp3',
|
||||
mediaType: 'audio',
|
||||
assetKind: 'sound-effect',
|
||||
durationSeconds: 2.4,
|
||||
generationInputs: {
|
||||
fields: [{ title: '时长', value: '3.5秒' }],
|
||||
references: [],
|
||||
},
|
||||
});
|
||||
expect(hydrated).not.toHaveProperty('durationSeconds');
|
||||
});
|
||||
|
||||
it('serializes and hydrates image sequence frames for character animation layers', () => {
|
||||
it('hydrates character animation sequence fields from the project resource', () => {
|
||||
const layer: CanvasLayer = {
|
||||
id: 'layer-action',
|
||||
resourceId: 'resource-action',
|
||||
@@ -709,7 +981,6 @@ describe('ImageCanvasEditorModel', () => {
|
||||
thumbnailSrc: '/generated-character-drafts/editor/frame01.png',
|
||||
imageSequenceFrames: [
|
||||
{
|
||||
frameIndex: 1,
|
||||
imageSrc: '/generated-character-drafts/editor/frame01.png',
|
||||
objectKey: 'generated-character-drafts/editor/frame01.png',
|
||||
assetObjectId: 'assetobj-frame-01',
|
||||
@@ -717,7 +988,6 @@ describe('ImageCanvasEditorModel', () => {
|
||||
height: 1024,
|
||||
},
|
||||
{
|
||||
frameIndex: 2,
|
||||
imageSrc: '/generated-character-drafts/editor/frame02.png',
|
||||
objectKey: 'generated-character-drafts/editor/frame02.png',
|
||||
assetObjectId: 'assetobj-frame-02',
|
||||
@@ -725,7 +995,6 @@ describe('ImageCanvasEditorModel', () => {
|
||||
height: 1024,
|
||||
},
|
||||
],
|
||||
previewVideoPath: '/generated-character-drafts/editor/preview.mp4',
|
||||
x: 10,
|
||||
y: 20,
|
||||
width: 420,
|
||||
@@ -735,15 +1004,14 @@ describe('ImageCanvasEditorModel', () => {
|
||||
zIndex: 6,
|
||||
sourceType: 'generated',
|
||||
assetKind: 'character-animation',
|
||||
durationSeconds: 4,
|
||||
imageSequenceDurationMs: 4_000,
|
||||
};
|
||||
|
||||
const snapshot = serializeLayer(layer);
|
||||
expect(snapshot.thumbnailSrc).toBe(
|
||||
'/generated-character-drafts/editor/frame01.png',
|
||||
);
|
||||
expect(snapshot.mediaType).toBe('image-sequence');
|
||||
expect(snapshot.imageSequenceFrames).toHaveLength(2);
|
||||
expect(snapshot.thumbnailSrc).toBeUndefined();
|
||||
expect(snapshot.imageSequenceFrames).toBeUndefined();
|
||||
expect(snapshot.imageSequenceDurationMs).toBeUndefined();
|
||||
expect(snapshot.mediaType).toBeUndefined();
|
||||
|
||||
const hydrated = hydrateLayer(
|
||||
snapshot,
|
||||
@@ -753,6 +1021,8 @@ describe('ImageCanvasEditorModel', () => {
|
||||
{
|
||||
imageSrc: layer.src,
|
||||
assetKind: 'character-animation',
|
||||
imageSequenceFrames: layer.imageSequenceFrames,
|
||||
imageSequenceDurationMs: 4_000,
|
||||
},
|
||||
],
|
||||
]),
|
||||
@@ -762,12 +1032,55 @@ describe('ImageCanvasEditorModel', () => {
|
||||
mediaType: 'image-sequence',
|
||||
thumbnailSrc: '/generated-character-drafts/editor/frame01.png',
|
||||
imageSequenceFrames: layer.imageSequenceFrames,
|
||||
previewVideoPath: '/generated-character-drafts/editor/preview.mp4',
|
||||
assetKind: 'character-animation',
|
||||
durationSeconds: 4,
|
||||
imageSequenceDurationMs: 4_000,
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects an action layer when its project resource lacks formal sequence fields', () => {
|
||||
const frames = [
|
||||
{
|
||||
imageSrc: '/generated-character-drafts/editor/legacy-frame01.png',
|
||||
objectKey: 'generated-character-drafts/editor/legacy-frame01.png',
|
||||
assetObjectId: 'assetobj-legacy-frame-01',
|
||||
width: 512,
|
||||
height: 512,
|
||||
},
|
||||
{
|
||||
imageSrc: '/generated-character-drafts/editor/legacy-frame02.png',
|
||||
objectKey: 'generated-character-drafts/editor/legacy-frame02.png',
|
||||
assetObjectId: 'assetobj-legacy-frame-02',
|
||||
width: 512,
|
||||
height: 512,
|
||||
},
|
||||
];
|
||||
const resources = new Map([
|
||||
[
|
||||
'resource-legacy-action',
|
||||
{
|
||||
imageSrc: frames[0]!.imageSrc,
|
||||
assetKind: 'character-animation',
|
||||
},
|
||||
],
|
||||
]);
|
||||
const hydration = hydrateLayer(
|
||||
{
|
||||
layerId: 'layer-legacy-action',
|
||||
resourceId: 'resource-legacy-action',
|
||||
title: '历史角色动作',
|
||||
sourceType: 'generated',
|
||||
assetKind: 'character-animation',
|
||||
imageSequenceFrames: frames,
|
||||
imageSequenceDurationMs: 3_600,
|
||||
previewVideoPath:
|
||||
'/generated-character-drafts/editor/legacy-preview.mp4',
|
||||
},
|
||||
resources,
|
||||
);
|
||||
|
||||
expect(hydration).toBeNull();
|
||||
});
|
||||
|
||||
it('restores audio generation dialogs from saved layout', () => {
|
||||
const dialog: CanvasGenerationDialogState = {
|
||||
id: 'generation-dialog-audio',
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -11,11 +11,11 @@ import type {
|
||||
import type {
|
||||
EditorAssetSnapshot,
|
||||
EditorCharacterAnimationFrameCount,
|
||||
EditorCharacterAnimationFrameResult,
|
||||
EditorCharacterAnimationGenerationResult,
|
||||
EditorCharacterAnimationRatio,
|
||||
EditorCharacterAnimationResolution,
|
||||
EditorImageGenerationStyle,
|
||||
EditorImageSequenceFrameResult,
|
||||
EditorPixelArtSnapInput,
|
||||
EditorVideoAspectRatio,
|
||||
EditorVideoModel,
|
||||
@@ -72,7 +72,8 @@ export type EditorAsset = {
|
||||
showcaseSubmitError?: string | null;
|
||||
assetKind?: CanvasAssetKind | null;
|
||||
generationInputs?: CanvasGenerationInputs | null;
|
||||
durationSeconds?: number;
|
||||
imageSequenceFrames?: EditorImageSequenceFrameResult[];
|
||||
imageSequenceDurationMs?: number;
|
||||
uploadStatus?: 'uploading' | 'failed';
|
||||
uploadProgress?: number;
|
||||
uploadMessage?: string;
|
||||
@@ -80,7 +81,7 @@ export type EditorAsset = {
|
||||
|
||||
export type CanvasLayer = SharedCanvasLayer & {
|
||||
generatedAssetSnapshot?: EditorAssetSnapshot | null;
|
||||
imageSequenceFrames?: EditorCharacterAnimationFrameResult[];
|
||||
imageSequenceFrames?: EditorImageSequenceFrameResult[];
|
||||
};
|
||||
|
||||
export type SidebarPanel = 'assets' | 'layers';
|
||||
|
||||
@@ -267,6 +267,16 @@ export function setupImageCanvasEditorViewTestLifecycle({
|
||||
immediateAsync(withEditorProjectCanvasRevision({
|
||||
projectId: 'editor-project-default',
|
||||
title: '默认项目',
|
||||
canvas: {
|
||||
canvasId: 'editor-project-default:canvas:default',
|
||||
projectId: 'editor-project-default',
|
||||
title: '默认画布',
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
layers: defaultEditorProjectLayers,
|
||||
revision: 0,
|
||||
layoutStorageVersion: 0,
|
||||
updatedAt: '2026-06-12T00:00:00.000Z',
|
||||
},
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
layers: defaultEditorProjectLayers,
|
||||
resources: defaultEditorProjectResources,
|
||||
@@ -299,7 +309,8 @@ export function setupImageCanvasEditorViewTestLifecycle({
|
||||
sourceType: input.sourceType,
|
||||
assetKind: input.assetKind,
|
||||
generationInputs: input.generationInputs,
|
||||
durationSeconds: input.durationSeconds,
|
||||
imageSequenceFrames: input.imageSequenceFrames,
|
||||
imageSequenceDurationMs: input.imageSequenceDurationMs,
|
||||
}));
|
||||
createEditorAssetFolderMock.mockResolvedValue({
|
||||
folderId: 'folder-role-persisted',
|
||||
@@ -353,12 +364,20 @@ export function setupImageCanvasEditorViewTestLifecycle({
|
||||
width: input.width,
|
||||
height: input.height,
|
||||
sourceType: input.sourceType,
|
||||
durationSeconds: input.durationSeconds,
|
||||
assetKind: input.assetKind,
|
||||
generationInputs: input.generationInputs,
|
||||
imageSequenceFrames: input.imageSequenceFrames,
|
||||
imageSequenceDurationMs: input.imageSequenceDurationMs,
|
||||
}),
|
||||
);
|
||||
saveEditorProjectLayoutMock.mockImplementation(
|
||||
async (projectId, input) => ({
|
||||
projectId,
|
||||
canvasId: `${projectId}:canvas:default`,
|
||||
revision: input.expectedRevision + 1,
|
||||
updatedAt: '2026-06-12T00:00:01.000Z',
|
||||
}),
|
||||
);
|
||||
saveEditorProjectLayoutMock.mockResolvedValue({});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
||||
@@ -1451,7 +1451,7 @@ describe('ImageCanvasEditorView', () => {
|
||||
fields: [
|
||||
{ title: '音效提示词', value: '金币跳出音' },
|
||||
{ title: 'model', value: 'audio1.0' },
|
||||
{ title: 'duration', value: '8秒' },
|
||||
{ title: '时长', value: '8秒' },
|
||||
],
|
||||
references: [
|
||||
{
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
canvasAssetKindOrNull,
|
||||
DEFAULT_CANVAS_BACKGROUND_COLOR,
|
||||
generationInputsOrNull,
|
||||
isCanvasAssetKindOverrideCompatible,
|
||||
isInlineEditorMediaSource,
|
||||
resolveContextMenuPosition,
|
||||
resolveLayerResourceAssetKind,
|
||||
@@ -305,7 +306,8 @@ function createAssetActionLayer(asset: EditorAsset): CanvasLayer {
|
||||
sourceAssetId: asset.id,
|
||||
assetKind: asset.assetKind ?? null,
|
||||
generationInputs: asset.generationInputs ?? null,
|
||||
durationSeconds: asset.durationSeconds,
|
||||
imageSequenceFrames: asset.imageSequenceFrames,
|
||||
imageSequenceDurationMs: asset.imageSequenceDurationMs,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -778,6 +780,7 @@ export function ImageCanvasEditorView({
|
||||
updateCanvasGenerationDialogById,
|
||||
removeCanvasGenerationDialogById,
|
||||
hasCanvasGenerationDialogById,
|
||||
getCanvasGenerationDialogById,
|
||||
archiveActiveCanvasGenerationDialog,
|
||||
activateCanvasGenerationDialog,
|
||||
restoreCanvasGenerationDialogs,
|
||||
@@ -1067,11 +1070,22 @@ export function ImageCanvasEditorView({
|
||||
model: layer.model,
|
||||
provider: layer.provider,
|
||||
taskId: layer.taskId,
|
||||
durationSeconds: layer.durationSeconds,
|
||||
imageSequenceFrames: layer.imageSequenceFrames,
|
||||
imageSequenceDurationMs: layer.imageSequenceDurationMs,
|
||||
assetKind: layer.assetKind,
|
||||
generationInputs: layer.generationInputs,
|
||||
})
|
||||
.then((asset) => {
|
||||
const persistedAssetKind = canvasAssetKindOrNull(asset.assetKind);
|
||||
if (
|
||||
layer.assetKind === 'character-animation' &&
|
||||
(persistedAssetKind !== 'character-animation' ||
|
||||
(asset.imageSequenceFrames?.length ?? 0) < 2 ||
|
||||
!(asset.imageSequenceDurationMs &&
|
||||
asset.imageSequenceDurationMs > 0))
|
||||
) {
|
||||
throw new Error('服务器未返回完整的角色动作正式字段');
|
||||
}
|
||||
setAssets((currentAssets) => [
|
||||
...currentAssets.filter(
|
||||
(currentAsset) => currentAsset.id !== asset.assetId,
|
||||
@@ -1094,8 +1108,10 @@ export function ImageCanvasEditorView({
|
||||
taskId: asset.taskId ?? undefined,
|
||||
objectKey: asset.objectKey ?? undefined,
|
||||
assetObjectId: asset.assetObjectId ?? undefined,
|
||||
durationSeconds: asset.durationSeconds ?? layer.durationSeconds,
|
||||
assetKind: canvasAssetKindOrNull(asset.assetKind),
|
||||
imageSequenceFrames: asset.imageSequenceFrames ?? undefined,
|
||||
imageSequenceDurationMs:
|
||||
asset.imageSequenceDurationMs ?? undefined,
|
||||
assetKind: persistedAssetKind,
|
||||
generationInputs: generationInputsOrNull(asset.generationInputs),
|
||||
},
|
||||
]);
|
||||
@@ -1124,13 +1140,19 @@ export function ImageCanvasEditorView({
|
||||
);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (
|
||||
const isAuthError =
|
||||
error instanceof Error &&
|
||||
'status' in error &&
|
||||
(error.status === 401 || error.status === 403)
|
||||
) {
|
||||
(error.status === 401 || error.status === 403);
|
||||
if (isAuthError) {
|
||||
openEditorLoginModal();
|
||||
return;
|
||||
}
|
||||
window.alert(
|
||||
error instanceof Error && error.message.trim()
|
||||
? error.message
|
||||
: '素材保存失败,请稍后重试',
|
||||
);
|
||||
});
|
||||
},
|
||||
[activeUploadFolderId, openEditorLoginModal, setAssetFolders, setAssets],
|
||||
@@ -1159,7 +1181,8 @@ export function ImageCanvasEditorView({
|
||||
objectKey: asset.objectKey ?? undefined,
|
||||
assetObjectId: asset.assetObjectId ?? undefined,
|
||||
thumbnailSrc: asset.thumbnailSrc ?? undefined,
|
||||
durationSeconds: asset.durationSeconds ?? undefined,
|
||||
imageSequenceFrames: asset.imageSequenceFrames ?? undefined,
|
||||
imageSequenceDurationMs: asset.imageSequenceDurationMs ?? undefined,
|
||||
assetKind: canvasAssetKindOrNull(asset.assetKind),
|
||||
generationInputs: generationInputsOrNull(asset.generationInputs),
|
||||
},
|
||||
@@ -1227,6 +1250,7 @@ export function ImageCanvasEditorView({
|
||||
const {
|
||||
projectId,
|
||||
isProjectReady,
|
||||
assetKindFallbackNotice,
|
||||
appendCanvasLayersWithResources,
|
||||
applyProjectSnapshot,
|
||||
flushProjectPersistence,
|
||||
@@ -1302,7 +1326,8 @@ export function ImageCanvasEditorView({
|
||||
model: layer.model,
|
||||
provider: layer.provider,
|
||||
taskId: layer.taskId,
|
||||
durationSeconds: layer.durationSeconds,
|
||||
imageSequenceFrames: layer.imageSequenceFrames,
|
||||
imageSequenceDurationMs: layer.imageSequenceDurationMs,
|
||||
sourceResourceId: layer.sourceResourceId,
|
||||
assetKind: resourceAssetKind,
|
||||
generationInputs: layer.generationInputs,
|
||||
@@ -1356,6 +1381,7 @@ export function ImageCanvasEditorView({
|
||||
isExportingAssets,
|
||||
exportCanvasAssets,
|
||||
exportLayerImage,
|
||||
reportAssetError,
|
||||
} = useImageCanvasAssetExportWorkflow({
|
||||
layers,
|
||||
projectId,
|
||||
@@ -1428,6 +1454,7 @@ export function ImageCanvasEditorView({
|
||||
activateCanvasGenerationDialog,
|
||||
updateCanvasGenerationDialogById,
|
||||
hasCanvasGenerationDialogById,
|
||||
getCanvasGenerationDialogById,
|
||||
archiveActiveCanvasGenerationDialog,
|
||||
removeCanvasGenerationDialogsByLayerId,
|
||||
getGeneratingDialogPlaceholder,
|
||||
@@ -1456,6 +1483,11 @@ export function ImageCanvasEditorView({
|
||||
generationSurface.refreshTaskList();
|
||||
}, [generationSurface]);
|
||||
const showGenerationWarning = generationSurface.showGenerationWarning;
|
||||
useEffect(() => {
|
||||
if (assetKindFallbackNotice) {
|
||||
showGenerationWarning(assetKindFallbackNotice.message);
|
||||
}
|
||||
}, [assetKindFallbackNotice, showGenerationWarning]);
|
||||
useEffect(() => {
|
||||
if (deadInlinePlaceholderDropCount === 0) {
|
||||
return;
|
||||
@@ -2095,12 +2127,16 @@ export function ImageCanvasEditorView({
|
||||
if (!targetLayer || targetLayer.assetKind === assetKind) {
|
||||
return;
|
||||
}
|
||||
const resourceAssetKind = resolveLayerResourceAssetKind(targetLayer);
|
||||
if (!isCanvasAssetKindOverrideCompatible(resourceAssetKind, assetKind)) {
|
||||
showGenerationWarning('该标签与素材的媒体类型不兼容,已保留原标签。');
|
||||
return;
|
||||
}
|
||||
captureCanvasHistory({
|
||||
type: 'change-asset-kind',
|
||||
count: 1,
|
||||
layerIds: [layerId],
|
||||
});
|
||||
const resourceAssetKind = resolveLayerResourceAssetKind(targetLayer);
|
||||
const assetKindOverride =
|
||||
assetKind === resourceAssetKind ? null : assetKind;
|
||||
const nextLayer = {
|
||||
@@ -2122,7 +2158,7 @@ export function ImageCanvasEditorView({
|
||||
setImageContextMenu(null);
|
||||
setContextMenu(null);
|
||||
},
|
||||
[captureCanvasHistory],
|
||||
[captureCanvasHistory, showGenerationWarning],
|
||||
);
|
||||
const selectAllCanvasObjects = useCallback(() => {
|
||||
const layerSelectionIds = layersRef.current
|
||||
@@ -2381,6 +2417,7 @@ export function ImageCanvasEditorView({
|
||||
appendCanvasLayersWithResources,
|
||||
selectSingleLayer,
|
||||
addUploadedFiles,
|
||||
onAssetError: reportAssetError,
|
||||
});
|
||||
|
||||
deleteLayerByIdRef.current = deleteLayerById;
|
||||
|
||||
@@ -98,7 +98,6 @@ describe('ImageCanvasExportModel', () => {
|
||||
taskId: 'sequence-task',
|
||||
imageSequenceFrames: [
|
||||
{
|
||||
frameIndex: 1,
|
||||
imageSrc: '/generated-character-drafts/editor/frame01.png',
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
@@ -195,7 +194,7 @@ describe('ImageCanvasExportModel', () => {
|
||||
},
|
||||
{ title: '用户输入', value: '用户填写的视觉要求' },
|
||||
{ title: '游戏分类', value: '游戏音效' },
|
||||
{ title: 'duration', value: '5秒' },
|
||||
{ title: '时长', value: '5秒' },
|
||||
{ title: '处理模型', value: 'birefnet' },
|
||||
],
|
||||
references: [],
|
||||
@@ -208,7 +207,7 @@ describe('ImageCanvasExportModel', () => {
|
||||
fields: [
|
||||
{ title: '用户输入', value: '用户填写的视觉要求' },
|
||||
{ title: '游戏分类', value: '游戏音效' },
|
||||
{ title: 'duration', value: '5秒' },
|
||||
{ title: '时长', value: '5秒' },
|
||||
],
|
||||
references: [],
|
||||
});
|
||||
@@ -220,15 +219,15 @@ describe('ImageCanvasExportModel', () => {
|
||||
mediaType: 'image-sequence',
|
||||
originalWidth: 192,
|
||||
originalHeight: 256,
|
||||
durationSeconds: 0.5,
|
||||
imageSequenceDurationMs: 500,
|
||||
});
|
||||
const spineJson = buildSpineImageSequenceJson({
|
||||
layer,
|
||||
frames: [
|
||||
{ frameIndex: 1, fileName: 'frame-01.png', width: 192, height: 256 },
|
||||
{ frameIndex: 2, fileName: 'frame-02.png', width: 192, height: 256 },
|
||||
{ frameIndex: 3, fileName: 'frame-03.png', width: 192, height: 256 },
|
||||
{ frameIndex: 4, fileName: 'frame-04.png', width: 192, height: 256 },
|
||||
{ fileName: 'frame-01.png', width: 192, height: 256 },
|
||||
{ fileName: 'frame-02.png', width: 192, height: 256 },
|
||||
{ fileName: 'frame-03.png', width: 192, height: 256 },
|
||||
{ fileName: 'frame-04.png', width: 192, height: 256 },
|
||||
],
|
||||
});
|
||||
|
||||
@@ -386,7 +385,6 @@ describe('ImageCanvasExportModel', () => {
|
||||
mediaType: 'image-sequence',
|
||||
imageSequenceFrames: [
|
||||
{
|
||||
frameIndex: 1,
|
||||
imageSrc: '/generated-editor-frames/frame-1.png',
|
||||
objectKey: 'generated/frame-1.png',
|
||||
width: 512,
|
||||
@@ -395,7 +393,6 @@ describe('ImageCanvasExportModel', () => {
|
||||
],
|
||||
}),
|
||||
{
|
||||
frameIndex: 1,
|
||||
imageSrc: '/generated-editor-frames/frame-1.png',
|
||||
objectKey: 'generated/frame-1.png',
|
||||
width: 512,
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
isEditorUserVisibleGenerationInputField,
|
||||
UI_DESIGN_ASSET_EXTRACTION_PROMPT,
|
||||
} from './ImageCanvasGenerationModel';
|
||||
import { formatCanvasDurationMetric } from './ImageCanvasMediaModel';
|
||||
|
||||
export function sanitizeExportFilePart(value: string, fallback: string) {
|
||||
const safeValue = value
|
||||
@@ -154,7 +153,6 @@ export async function readAssetSourceBlob({
|
||||
}: {
|
||||
source: string;
|
||||
objectKey?: string | null;
|
||||
refreshKey?: string | number | null;
|
||||
}) {
|
||||
if (source.startsWith('data:')) {
|
||||
return dataUrlToBlob(source);
|
||||
@@ -167,37 +165,22 @@ export async function readLayerAssetBlob(layer: CanvasLayer) {
|
||||
return readAssetSourceBlob({
|
||||
source: layer.src,
|
||||
objectKey: layer.objectKey,
|
||||
refreshKey: layer.taskId ?? layer.resourceId,
|
||||
});
|
||||
}
|
||||
|
||||
export const readLayerImageBlob = readLayerAssetBlob;
|
||||
|
||||
export function getLayerImageSequenceFrames(layer: CanvasLayer) {
|
||||
if (layer.imageSequenceFrames?.length) {
|
||||
return layer.imageSequenceFrames;
|
||||
}
|
||||
const fallbackSrc = layer.thumbnailSrc?.trim() || layer.src.trim();
|
||||
return fallbackSrc
|
||||
? [
|
||||
{
|
||||
frameIndex: 1,
|
||||
imageSrc: fallbackSrc,
|
||||
width: layer.originalWidth,
|
||||
height: layer.originalHeight,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
return layer.imageSequenceFrames ?? [];
|
||||
}
|
||||
|
||||
export async function readLayerImageSequenceFrameBlob(
|
||||
layer: CanvasLayer,
|
||||
_layer: CanvasLayer,
|
||||
frame: ReturnType<typeof getLayerImageSequenceFrames>[number],
|
||||
) {
|
||||
return readAssetSourceBlob({
|
||||
source: frame.imageSrc,
|
||||
objectKey: frame.objectKey,
|
||||
refreshKey: `${layer.taskId ?? layer.resourceId}:${frame.frameIndex}`,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -207,12 +190,11 @@ export function getImageSequenceFrameFileName(
|
||||
blobType = '',
|
||||
) {
|
||||
const extension = getImageExtensionFromTypeOrSrc(blobType, frame.imageSrc);
|
||||
const frameNumber = String(frame.frameIndex || index + 1).padStart(2, '0');
|
||||
const frameNumber = String(index + 1).padStart(2, '0');
|
||||
return `frame-${frameNumber}.${extension}`;
|
||||
}
|
||||
|
||||
export type ExportedImageSequenceFrame = {
|
||||
frameIndex: number;
|
||||
fileName: string;
|
||||
width: number;
|
||||
height: number;
|
||||
@@ -418,8 +400,9 @@ export function buildSpineImageSequenceJson({
|
||||
}
|
||||
|
||||
const durationSeconds =
|
||||
typeof layer.durationSeconds === 'number' && layer.durationSeconds > 0
|
||||
? layer.durationSeconds
|
||||
typeof layer.imageSequenceDurationMs === 'number' &&
|
||||
layer.imageSequenceDurationMs > 0
|
||||
? layer.imageSequenceDurationMs / 1_000
|
||||
: frames.length;
|
||||
const frameDuration = durationSeconds / frames.length;
|
||||
const skeletonWidth = frames[0]?.width || layer.originalWidth;
|
||||
@@ -579,20 +562,12 @@ export function buildLayerVisibleExportMetadata(layer: CanvasLayer) {
|
||||
object: layer.objectKey ?? layer.assetObjectId ?? '-',
|
||||
};
|
||||
|
||||
if (layer.mediaType === 'audio') {
|
||||
return {
|
||||
...base,
|
||||
duration: formatCanvasDurationMetric(layer.durationSeconds).replace(
|
||||
/^时长\s*/u,
|
||||
'',
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...base,
|
||||
resolution: `${layer.originalWidth} x ${layer.originalHeight} px`,
|
||||
};
|
||||
return layer.mediaType === 'audio'
|
||||
? base
|
||||
: {
|
||||
...base,
|
||||
resolution: `${layer.originalWidth} x ${layer.originalHeight} px`,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildLayerExportMetadata(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -292,7 +292,7 @@ describe('ImageCanvasGenerationDialogModel', () => {
|
||||
fields: [
|
||||
{ title: 'prompt', value: '金币收集音' },
|
||||
{ title: 'model', value: 'audio1.0' },
|
||||
{ title: 'duration', value: '7秒' },
|
||||
{ title: '时长', value: '7秒' },
|
||||
],
|
||||
references: [],
|
||||
},
|
||||
@@ -361,7 +361,7 @@ describe('ImageCanvasGenerationDialogModel', () => {
|
||||
title: '游戏背景音乐',
|
||||
prompt: '【系统内置】完整背景音乐提示词',
|
||||
generationInputs: {
|
||||
fields: [{ title: 'duration', value: '10秒' }],
|
||||
fields: [{ title: '时长', value: '10秒' }],
|
||||
references: [],
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -531,7 +531,10 @@ function resolveAudioRedrawSoundModel(
|
||||
}
|
||||
|
||||
function resolveAudioRedrawSoundDuration(sourceLayer: CanvasLayer) {
|
||||
const fieldValue = findGenerationInputFieldValue(sourceLayer, ['duration']);
|
||||
const fieldValue = findGenerationInputFieldValue(sourceLayer, [
|
||||
'时长',
|
||||
'duration',
|
||||
]);
|
||||
const matchedValue = fieldValue?.match(/\d+/u)?.[0];
|
||||
const duration = matchedValue ? Number.parseInt(matchedValue, 10) : null;
|
||||
if (!duration || !Number.isFinite(duration)) {
|
||||
|
||||
@@ -501,7 +501,10 @@ describe('ImageCanvasGenerationLayerModel', () => {
|
||||
originalHeight: 120,
|
||||
},
|
||||
generationInputs: {
|
||||
fields: [{ title: 'prompt', value: '金币掉落叮当声' }],
|
||||
fields: [
|
||||
{ title: 'prompt', value: '金币掉落叮当声' },
|
||||
{ title: '时长', value: '75秒' },
|
||||
],
|
||||
references: [],
|
||||
},
|
||||
});
|
||||
@@ -526,11 +529,18 @@ describe('ImageCanvasGenerationLayerModel', () => {
|
||||
objectKey: 'generated-character-drafts/editor-audios/sfx.mp3',
|
||||
assetObjectId: 'assetobj-audio-1',
|
||||
sourceAssetId: 'asset-audio-bff',
|
||||
durationSeconds: 75,
|
||||
generationInputs: {
|
||||
fields: [
|
||||
{ title: 'prompt', value: '金币掉落叮当声' },
|
||||
{ title: '时长', value: '75秒' },
|
||||
],
|
||||
references: [],
|
||||
},
|
||||
generatedAssetSnapshot: expect.objectContaining({
|
||||
assetId: 'asset-audio-bff',
|
||||
}),
|
||||
});
|
||||
expect(layer).not.toHaveProperty('durationSeconds');
|
||||
expect(layer.provider).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -579,31 +589,47 @@ describe('ImageCanvasGenerationLayerModel', () => {
|
||||
objectKey: 'generated-character-drafts/editor-videos/video.mp4',
|
||||
assetObjectId: 'assetobj-video-1',
|
||||
thumbnailSrc: '/generated-character-drafts/editor-videos/cover.png',
|
||||
durationSeconds: 5,
|
||||
});
|
||||
expect(layer).not.toHaveProperty('durationSeconds');
|
||||
expect(layer.generatedAssetSnapshot?.assetId).toBe('asset-video-bff');
|
||||
expect(layer.provider).toBeUndefined();
|
||||
});
|
||||
|
||||
it('creates character animation result layers as playable image sequences', () => {
|
||||
const frames = [
|
||||
{
|
||||
imageSrc: '/generated-character-drafts/editor/frame1.png',
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
},
|
||||
{
|
||||
imageSrc: '/generated-character-drafts/editor/frame2.png',
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
},
|
||||
];
|
||||
const layer = createCharacterAnimationResultLayer({
|
||||
generated: {
|
||||
taskId: 'character-animation-task-1',
|
||||
model: 'seedance2.0-fast',
|
||||
prompt: '待机动作',
|
||||
previewVideoPath: '/generated-character-drafts/editor/preview.mp4',
|
||||
frames: [
|
||||
{
|
||||
frameIndex: 1,
|
||||
imageSrc: '/generated-character-drafts/editor/frame1.png',
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
},
|
||||
],
|
||||
frameCount: 32,
|
||||
frames,
|
||||
frameCount: 2,
|
||||
durationSeconds: 4,
|
||||
fps: 8,
|
||||
priceMudPoints: 40,
|
||||
resource: {
|
||||
resourceId: 'resource-character-animation-15',
|
||||
projectId: 'project-1',
|
||||
imageSrc: frames[0]!.imageSrc,
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
sourceType: 'generated',
|
||||
assetKind: 'character-animation',
|
||||
imageSequenceFrames: frames,
|
||||
imageSequenceDurationMs: 4_000,
|
||||
},
|
||||
},
|
||||
generatedIndex: 15,
|
||||
title: '角色动作',
|
||||
@@ -625,20 +651,13 @@ describe('ImageCanvasGenerationLayerModel', () => {
|
||||
|
||||
expect(layer).toMatchObject({
|
||||
id: 'layer-character-animation-15',
|
||||
resourceId: 'local-resource-character-animation-15',
|
||||
resourceId: 'resource-character-animation-15',
|
||||
title: '角色动作',
|
||||
src: '/generated-character-drafts/editor/frame1.png',
|
||||
mediaType: 'image-sequence',
|
||||
thumbnailSrc: '/generated-character-drafts/editor/frame1.png',
|
||||
imageSequenceFrames: [
|
||||
{
|
||||
frameIndex: 1,
|
||||
imageSrc: '/generated-character-drafts/editor/frame1.png',
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
},
|
||||
],
|
||||
previewVideoPath: '/generated-character-drafts/editor/preview.mp4',
|
||||
imageSequenceFrames: frames,
|
||||
previewVideoPath: null,
|
||||
x: -222,
|
||||
y: -242,
|
||||
width: 1024,
|
||||
@@ -649,12 +668,131 @@ describe('ImageCanvasGenerationLayerModel', () => {
|
||||
sourceType: 'generated',
|
||||
prompt: '待机动作',
|
||||
actualPrompt: '待机动作',
|
||||
imageSequenceDurationMs: 4_000,
|
||||
model: 'seedance2.0-fast',
|
||||
taskId: 'character-animation-task-1',
|
||||
assetKind: 'character-animation',
|
||||
durationSeconds: 4,
|
||||
});
|
||||
expect(layer?.generationInputs?.fields[0]?.value).toBe('待机动作');
|
||||
expect(layer?.provider).toBeUndefined();
|
||||
});
|
||||
|
||||
it('links a character animation result layer to its persisted project resource', () => {
|
||||
const frames = [
|
||||
{
|
||||
imageSrc: '/generated-character-drafts/editor/frame1.png',
|
||||
objectKey: 'generated-character-drafts/editor/frame1.png',
|
||||
assetObjectId: 'asset-object-frame-1',
|
||||
width: 512,
|
||||
height: 768,
|
||||
},
|
||||
{
|
||||
imageSrc: '/generated-character-drafts/editor/frame2.png',
|
||||
objectKey: 'generated-character-drafts/editor/frame2.png',
|
||||
assetObjectId: 'asset-object-frame-2',
|
||||
width: 512,
|
||||
height: 768,
|
||||
},
|
||||
];
|
||||
const layer = createCharacterAnimationResultLayer({
|
||||
generated: {
|
||||
taskId: 'character-animation-task-persisted',
|
||||
model: 'seedance2.0-fast',
|
||||
prompt: '奔跑动作',
|
||||
previewVideoPath: '/generated-character-drafts/editor/preview.mp4',
|
||||
frames,
|
||||
frameCount: 2,
|
||||
durationSeconds: 4,
|
||||
fps: 8,
|
||||
priceMudPoints: 40,
|
||||
resource: {
|
||||
resourceId: 'resource-character-animation-final',
|
||||
projectId: 'project-1',
|
||||
imageSrc: frames[0]!.imageSrc,
|
||||
objectKey: frames[0]!.objectKey,
|
||||
assetObjectId: frames[0]!.assetObjectId,
|
||||
width: 512,
|
||||
height: 768,
|
||||
sourceType: 'generated',
|
||||
sourceResourceId: 'resource-character-animation-preview',
|
||||
assetKind: 'character-animation',
|
||||
imageSequenceFrames: frames,
|
||||
imageSequenceDurationMs: 4_000,
|
||||
},
|
||||
},
|
||||
generatedIndex: 16,
|
||||
title: '角色奔跑',
|
||||
canvasSize: { width: 900, height: 640 },
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
generationInputs: createGenerationInputs(),
|
||||
sourceLayer: createSourceLayer({
|
||||
resourceId: 'resource-original-character',
|
||||
}),
|
||||
});
|
||||
|
||||
expect(layer).toMatchObject({
|
||||
resourceId: 'resource-character-animation-final',
|
||||
src: frames[0]!.imageSrc,
|
||||
objectKey: frames[0]!.objectKey,
|
||||
assetObjectId: frames[0]!.assetObjectId,
|
||||
sourceResourceId: 'resource-character-animation-preview',
|
||||
imageSequenceFrames: frames,
|
||||
imageSequenceDurationMs: 4_000,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps persisted animation lineage when generation returns only an asset', () => {
|
||||
const frames = [
|
||||
{
|
||||
imageSrc: '/generated-character-drafts/editor/frame1.png',
|
||||
width: 512,
|
||||
height: 768,
|
||||
},
|
||||
{
|
||||
imageSrc: '/generated-character-drafts/editor/frame2.png',
|
||||
width: 512,
|
||||
height: 768,
|
||||
},
|
||||
];
|
||||
const layer = createCharacterAnimationResultLayer({
|
||||
generated: {
|
||||
taskId: 'character-animation-task-asset-only',
|
||||
model: 'seedance2.0-fast',
|
||||
prompt: '跳跃动作',
|
||||
previewVideoPath: '/generated-character-drafts/editor/preview.mp4',
|
||||
frames,
|
||||
frameCount: 2,
|
||||
durationSeconds: 4,
|
||||
fps: 8,
|
||||
priceMudPoints: 40,
|
||||
asset: {
|
||||
assetId: 'asset-character-animation-final',
|
||||
folderId: 'user-1:asset-folder:project',
|
||||
label: '角色跳跃',
|
||||
imageSrc: frames[0]!.imageSrc,
|
||||
width: 512,
|
||||
height: 768,
|
||||
sourceType: 'generated',
|
||||
sourceResourceId: 'resource-character-animation-preview',
|
||||
assetKind: 'character-animation',
|
||||
imageSequenceFrames: frames,
|
||||
imageSequenceDurationMs: 4_000,
|
||||
},
|
||||
},
|
||||
generatedIndex: 17,
|
||||
title: '角色跳跃',
|
||||
canvasSize: { width: 900, height: 640 },
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
generationInputs: createGenerationInputs(),
|
||||
sourceLayer: createSourceLayer({
|
||||
resourceId: 'resource-original-character',
|
||||
}),
|
||||
});
|
||||
|
||||
expect(layer).toMatchObject({
|
||||
resourceId: 'local-resource-asset-character-animation-final',
|
||||
sourceResourceId: 'resource-character-animation-preview',
|
||||
sourceAssetId: 'asset-character-animation-final',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user