合并主线并保留素材多选导出修复
合并主线角色动作序列帧、完美像素、外部编辑器与 AI 游戏创作 Runtime 最新改动 保留 objectKey-only 私有素材多选、批量导出与统一时间戳语义 统一素材删除门禁、参考图上传锁和侧栏选择状态清理 同步图片画布技术方案与角色动作下载集成测试
This commit is contained in:
@@ -0,0 +1,288 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { AutoGrowTextArea } from './AutoGrowTextArea';
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('AutoGrowTextArea', () => {
|
||||
it('uses native field sizing when the browser supports it', () => {
|
||||
const supports = vi.fn().mockReturnValue(true);
|
||||
const resizeObserver = vi.fn();
|
||||
const onChange = vi.fn();
|
||||
vi.stubGlobal('CSS', { supports });
|
||||
vi.stubGlobal('ResizeObserver', resizeObserver);
|
||||
|
||||
render(
|
||||
<AutoGrowTextArea
|
||||
aria-label="自动增长输入框"
|
||||
value=""
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
const input = screen.getByLabelText(
|
||||
'自动增长输入框',
|
||||
) as HTMLTextAreaElement;
|
||||
expect(supports).toHaveBeenCalledWith('field-sizing', 'content');
|
||||
expect(resizeObserver).not.toHaveBeenCalled();
|
||||
expect(input.getAttribute('rows')).toBe('1');
|
||||
expect(input.className).toContain('auto-grow-text-area');
|
||||
expect(input.className).toContain('[field-sizing:content]');
|
||||
expect(input.className).toContain('min-h-10');
|
||||
expect(input.className).toContain('max-h-32');
|
||||
expect(input.className).toContain('overflow-y-auto');
|
||||
expect(input.className).toContain('disabled:cursor-not-allowed');
|
||||
expect(input.className).toContain('disabled:opacity-60');
|
||||
expect(input.style.height).toBe('');
|
||||
|
||||
fireEvent.change(input, { target: { value: '新内容' } });
|
||||
expect(onChange).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('falls back to measured sizing, preserves css limits and remeasures width changes', () => {
|
||||
let resizeObserverCallback!: ResizeObserverCallback;
|
||||
const observe = vi.fn();
|
||||
const disconnect = vi.fn();
|
||||
vi.stubGlobal('CSS', { supports: vi.fn().mockReturnValue(false) });
|
||||
vi.stubGlobal(
|
||||
'ResizeObserver',
|
||||
vi.fn().mockImplementation((callback: ResizeObserverCallback) => {
|
||||
resizeObserverCallback = callback;
|
||||
return {
|
||||
observe,
|
||||
unobserve: vi.fn(),
|
||||
disconnect,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
const { rerender, unmount } = render(
|
||||
<AutoGrowTextArea
|
||||
aria-label="自动增长输入框"
|
||||
value=""
|
||||
style={{
|
||||
boxSizing: 'border-box',
|
||||
minHeight: '80px',
|
||||
maxHeight: '128px',
|
||||
}}
|
||||
onChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
const input = screen.getByLabelText(
|
||||
'自动增长输入框',
|
||||
) as HTMLTextAreaElement;
|
||||
expect(observe).toHaveBeenCalledWith(input);
|
||||
let contentHeight = 92;
|
||||
Object.defineProperty(input, 'offsetHeight', {
|
||||
configurable: true,
|
||||
get: () => {
|
||||
const styleHeight = Number.parseFloat(input.style.height);
|
||||
return Number.isNaN(styleHeight) ? 82 : styleHeight;
|
||||
},
|
||||
});
|
||||
Object.defineProperty(input, 'clientHeight', {
|
||||
configurable: true,
|
||||
get: () => input.offsetHeight - 2,
|
||||
});
|
||||
Object.defineProperty(input, 'scrollHeight', {
|
||||
configurable: true,
|
||||
get: () => contentHeight,
|
||||
});
|
||||
|
||||
rerender(
|
||||
<AutoGrowTextArea
|
||||
aria-label="自动增长输入框"
|
||||
value="第一行\n第二行\n第三行\n第四行\n第五行"
|
||||
style={{
|
||||
boxSizing: 'border-box',
|
||||
minHeight: '80px',
|
||||
maxHeight: '128px',
|
||||
}}
|
||||
onChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(input.style.height).toBe('94px');
|
||||
expect(input.style.overflowY).toBe('hidden');
|
||||
|
||||
contentHeight = 180;
|
||||
act(() => {
|
||||
resizeObserverCallback(
|
||||
[
|
||||
{
|
||||
target: input,
|
||||
contentRect: { width: 180 },
|
||||
} as unknown as ResizeObserverEntry,
|
||||
],
|
||||
{} as ResizeObserver,
|
||||
);
|
||||
});
|
||||
expect(input.style.height).toBe('128px');
|
||||
expect(input.style.overflowY).toBe('auto');
|
||||
|
||||
contentHeight = 30;
|
||||
rerender(
|
||||
<AutoGrowTextArea
|
||||
aria-label="自动增长输入框"
|
||||
value="短内容"
|
||||
style={{
|
||||
boxSizing: 'border-box',
|
||||
minHeight: '80px',
|
||||
maxHeight: '128px',
|
||||
}}
|
||||
onChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(input.style.height).toBe('80px');
|
||||
expect(input.style.overflowY).toBe('hidden');
|
||||
|
||||
unmount();
|
||||
expect(disconnect).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('remeasures uncontrolled input changes and forwards the consumer input handler', () => {
|
||||
const onInput = vi.fn();
|
||||
vi.stubGlobal('CSS', { supports: vi.fn().mockReturnValue(false) });
|
||||
vi.stubGlobal(
|
||||
'ResizeObserver',
|
||||
vi.fn().mockImplementation(() => ({
|
||||
observe: vi.fn(),
|
||||
unobserve: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
})),
|
||||
);
|
||||
|
||||
render(
|
||||
<AutoGrowTextArea
|
||||
aria-label="非受控自动增长输入框"
|
||||
defaultValue="初始内容"
|
||||
style={{
|
||||
boxSizing: 'border-box',
|
||||
minHeight: '40px',
|
||||
maxHeight: '128px',
|
||||
}}
|
||||
onInput={onInput}
|
||||
/>,
|
||||
);
|
||||
const input = screen.getByLabelText(
|
||||
'非受控自动增长输入框',
|
||||
) as HTMLTextAreaElement;
|
||||
let contentHeight = 90;
|
||||
Object.defineProperty(input, 'offsetHeight', {
|
||||
configurable: true,
|
||||
get: () => {
|
||||
const styleHeight = Number.parseFloat(input.style.height);
|
||||
return Number.isNaN(styleHeight) ? 42 : styleHeight;
|
||||
},
|
||||
});
|
||||
Object.defineProperty(input, 'clientHeight', {
|
||||
configurable: true,
|
||||
get: () => input.offsetHeight - 2,
|
||||
});
|
||||
Object.defineProperty(input, 'scrollHeight', {
|
||||
configurable: true,
|
||||
get: () => contentHeight,
|
||||
});
|
||||
|
||||
fireEvent.input(input, {
|
||||
target: { value: '第一行\n第二行\n第三行\n第四行' },
|
||||
});
|
||||
expect(input.style.height).toBe('92px');
|
||||
expect(input.style.overflowY).toBe('hidden');
|
||||
|
||||
contentHeight = 180;
|
||||
fireEvent.input(input, {
|
||||
target: { value: '很多行内容'.repeat(30) },
|
||||
});
|
||||
expect(input.style.height).toBe('128px');
|
||||
expect(input.style.overflowY).toBe('auto');
|
||||
|
||||
contentHeight = 20;
|
||||
fireEvent.input(input, { target: { value: '短内容' } });
|
||||
expect(input.style.height).toBe('40px');
|
||||
expect(input.style.overflowY).toBe('hidden');
|
||||
expect(onInput).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('measures content-box height without double-counting padding or borders', () => {
|
||||
vi.stubGlobal('CSS', { supports: vi.fn().mockReturnValue(false) });
|
||||
vi.stubGlobal(
|
||||
'ResizeObserver',
|
||||
vi.fn().mockImplementation(() => ({
|
||||
observe: vi.fn(),
|
||||
unobserve: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
})),
|
||||
);
|
||||
|
||||
const { rerender } = render(
|
||||
<AutoGrowTextArea
|
||||
aria-label="内容盒输入框"
|
||||
value=""
|
||||
style={{
|
||||
boxSizing: 'content-box',
|
||||
minHeight: '40px',
|
||||
maxHeight: '128px',
|
||||
paddingTop: '10px',
|
||||
paddingBottom: '6px',
|
||||
}}
|
||||
onChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
const input = screen.getByLabelText(
|
||||
'内容盒输入框',
|
||||
) as HTMLTextAreaElement;
|
||||
let contentHeight = 120;
|
||||
Object.defineProperty(input, 'offsetHeight', {
|
||||
configurable: true,
|
||||
get: () => 122,
|
||||
});
|
||||
Object.defineProperty(input, 'clientHeight', {
|
||||
configurable: true,
|
||||
get: () => 120,
|
||||
});
|
||||
Object.defineProperty(input, 'scrollHeight', {
|
||||
configurable: true,
|
||||
get: () => contentHeight,
|
||||
});
|
||||
|
||||
rerender(
|
||||
<AutoGrowTextArea
|
||||
aria-label="内容盒输入框"
|
||||
value="多行内容"
|
||||
style={{
|
||||
boxSizing: 'content-box',
|
||||
minHeight: '40px',
|
||||
maxHeight: '128px',
|
||||
paddingTop: '10px',
|
||||
paddingBottom: '6px',
|
||||
}}
|
||||
onChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(input.style.height).toBe('104px');
|
||||
expect(input.style.overflowY).toBe('hidden');
|
||||
|
||||
contentHeight = 160;
|
||||
rerender(
|
||||
<AutoGrowTextArea
|
||||
aria-label="内容盒输入框"
|
||||
value="更多多行内容"
|
||||
style={{
|
||||
boxSizing: 'content-box',
|
||||
minHeight: '40px',
|
||||
maxHeight: '128px',
|
||||
paddingTop: '10px',
|
||||
paddingBottom: '6px',
|
||||
}}
|
||||
onChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(input.style.height).toBe('128px');
|
||||
expect(input.style.overflowY).toBe('auto');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
import {
|
||||
type InputEventHandler,
|
||||
type TextareaHTMLAttributes,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
} from 'react';
|
||||
|
||||
const DEFAULT_MIN_HEIGHT_PX = 40;
|
||||
const DEFAULT_MAX_HEIGHT_PX = 128;
|
||||
|
||||
function supportsNativeFieldSizing() {
|
||||
return (
|
||||
typeof CSS !== 'undefined' &&
|
||||
typeof CSS.supports === 'function' &&
|
||||
CSS.supports('field-sizing', 'content')
|
||||
);
|
||||
}
|
||||
|
||||
function parseComputedPixelValue(value: string, fallback: number) {
|
||||
const parsedValue = Number.parseFloat(value);
|
||||
return Number.isFinite(parsedValue) ? parsedValue : fallback;
|
||||
}
|
||||
|
||||
function resizeFallbackTextArea(textarea: HTMLTextAreaElement) {
|
||||
textarea.style.height = 'auto';
|
||||
|
||||
const computedStyle = window.getComputedStyle(textarea);
|
||||
const minHeight = parseComputedPixelValue(
|
||||
computedStyle.minHeight,
|
||||
DEFAULT_MIN_HEIGHT_PX,
|
||||
);
|
||||
const maxHeight = Math.max(
|
||||
minHeight,
|
||||
parseComputedPixelValue(computedStyle.maxHeight, DEFAULT_MAX_HEIGHT_PX),
|
||||
);
|
||||
const contentHeight = textarea.scrollHeight;
|
||||
const borderHeight = Math.max(
|
||||
0,
|
||||
textarea.offsetHeight - textarea.clientHeight,
|
||||
);
|
||||
const isBorderBox = computedStyle.boxSizing === 'border-box';
|
||||
const paddingHeight =
|
||||
parseComputedPixelValue(computedStyle.paddingTop, 0) +
|
||||
parseComputedPixelValue(computedStyle.paddingBottom, 0);
|
||||
const measuredHeight = isBorderBox
|
||||
? contentHeight + borderHeight
|
||||
: Math.max(0, contentHeight - paddingHeight);
|
||||
const nextHeight = Math.min(
|
||||
Math.max(measuredHeight, minHeight),
|
||||
maxHeight,
|
||||
);
|
||||
|
||||
textarea.style.height = `${nextHeight}px`;
|
||||
textarea.style.overflowY = measuredHeight > maxHeight ? 'auto' : 'hidden';
|
||||
}
|
||||
|
||||
export function AutoGrowTextArea({
|
||||
className,
|
||||
onInput,
|
||||
rows = 1,
|
||||
value,
|
||||
...textareaProps
|
||||
}: TextareaHTMLAttributes<HTMLTextAreaElement>) {
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const textarea = textareaRef.current;
|
||||
if (!textarea || supportsNativeFieldSizing()) {
|
||||
return;
|
||||
}
|
||||
resizeFallbackTextArea(textarea);
|
||||
}, [value]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const textarea = textareaRef.current;
|
||||
if (!textarea || supportsNativeFieldSizing()) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let previousWidth = textarea.getBoundingClientRect().width;
|
||||
const resizeWhenWidthChanges = (nextWidth: number) => {
|
||||
if (Math.abs(nextWidth - previousWidth) < 0.5) {
|
||||
return;
|
||||
}
|
||||
previousWidth = nextWidth;
|
||||
resizeFallbackTextArea(textarea);
|
||||
};
|
||||
|
||||
if (typeof ResizeObserver !== 'undefined') {
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
const entry = entries[0];
|
||||
if (entry) {
|
||||
resizeWhenWidthChanges(entry.contentRect.width);
|
||||
}
|
||||
});
|
||||
observer.observe(textarea);
|
||||
return () => observer.disconnect();
|
||||
}
|
||||
|
||||
const handleWindowResize = () => {
|
||||
resizeWhenWidthChanges(textarea.getBoundingClientRect().width);
|
||||
};
|
||||
window.addEventListener('resize', handleWindowResize);
|
||||
return () => window.removeEventListener('resize', handleWindowResize);
|
||||
}, []);
|
||||
|
||||
const resolvedClassName = [
|
||||
'auto-grow-text-area max-h-32 min-h-10 resize-none overflow-x-hidden overflow-y-auto overscroll-contain [field-sizing:content] disabled:cursor-not-allowed disabled:opacity-60',
|
||||
className,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
const handleInput: InputEventHandler<HTMLTextAreaElement> = (event) => {
|
||||
if (!supportsNativeFieldSizing()) {
|
||||
resizeFallbackTextArea(event.currentTarget);
|
||||
}
|
||||
onInput?.(event);
|
||||
};
|
||||
|
||||
return (
|
||||
<textarea
|
||||
{...textareaProps}
|
||||
ref={textareaRef}
|
||||
className={resolvedClassName}
|
||||
onInput={handleInput}
|
||||
rows={rows}
|
||||
value={value}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+17
-89
@@ -1,108 +1,36 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import type { FormEvent } from 'react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { EditorAgentDraftTextarea } from './EditorAgentDraftTextarea.tsx';
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('EditorAgentDraftTextarea', () => {
|
||||
it('uses native field sizing when the browser supports it', () => {
|
||||
const supports = vi.fn().mockReturnValue(true);
|
||||
const resizeObserver = vi.fn();
|
||||
it('forwards draft changes and paste events through the Agent wrapper', () => {
|
||||
const onChange = vi.fn();
|
||||
vi.stubGlobal('CSS', { supports });
|
||||
vi.stubGlobal('ResizeObserver', resizeObserver);
|
||||
const onPaste = vi.fn();
|
||||
|
||||
render(<EditorAgentDraftTextarea value="" onChange={onChange} />);
|
||||
render(
|
||||
<EditorAgentDraftTextarea
|
||||
value=""
|
||||
onChange={onChange}
|
||||
onPaste={onPaste}
|
||||
/>,
|
||||
);
|
||||
|
||||
const input = screen.getByLabelText(
|
||||
'发送给画布 Agent',
|
||||
) as HTMLTextAreaElement;
|
||||
expect(supports).toHaveBeenCalledWith('field-sizing', 'content');
|
||||
expect(resizeObserver).not.toHaveBeenCalled();
|
||||
expect(input.className).toContain('[field-sizing:content]');
|
||||
expect(input.className).toContain('min-h-10');
|
||||
expect(input.className).toContain('max-h-32');
|
||||
expect(input.className).toContain('overflow-y-auto');
|
||||
expect(input.style.height).toBe('');
|
||||
expect(input.className).toContain('auto-grow-text-area');
|
||||
expect(input.className).toContain(
|
||||
'editor-agent-conversation__draft-input',
|
||||
);
|
||||
|
||||
fireEvent.change(input, { target: { value: '新草稿' } });
|
||||
expect(onChange).toHaveBeenCalledWith('新草稿');
|
||||
});
|
||||
|
||||
it('falls back to measured sizing and remeasures when width changes', () => {
|
||||
let resizeObserverCallback!: ResizeObserverCallback;
|
||||
const observe = vi.fn();
|
||||
const disconnect = vi.fn();
|
||||
vi.stubGlobal('CSS', { supports: vi.fn().mockReturnValue(false) });
|
||||
vi.stubGlobal(
|
||||
'ResizeObserver',
|
||||
vi.fn().mockImplementation((callback: ResizeObserverCallback) => {
|
||||
resizeObserverCallback = callback;
|
||||
return {
|
||||
observe,
|
||||
unobserve: vi.fn(),
|
||||
disconnect,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
const { rerender, unmount } = render(
|
||||
<EditorAgentDraftTextarea value="" onChange={vi.fn()} />,
|
||||
);
|
||||
const input = screen.getByLabelText(
|
||||
'发送给画布 Agent',
|
||||
) as HTMLTextAreaElement;
|
||||
expect(observe).toHaveBeenCalledWith(input);
|
||||
let contentHeight = 92;
|
||||
Object.defineProperty(input, 'offsetHeight', {
|
||||
configurable: true,
|
||||
get: () => {
|
||||
const styleHeight = Number.parseFloat(input.style.height);
|
||||
return Number.isNaN(styleHeight) ? 42 : styleHeight;
|
||||
},
|
||||
});
|
||||
Object.defineProperty(input, 'clientHeight', {
|
||||
configurable: true,
|
||||
get: () => input.offsetHeight - 2,
|
||||
});
|
||||
Object.defineProperty(input, 'scrollHeight', {
|
||||
configurable: true,
|
||||
get: () => contentHeight,
|
||||
});
|
||||
|
||||
rerender(
|
||||
<EditorAgentDraftTextarea
|
||||
value="第一行\n第二行\n第三行\n第四行\n第五行"
|
||||
onChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(input.style.height).toBe('94px');
|
||||
expect(input.style.overflowY).toBe('hidden');
|
||||
expect(input.scrollHeight).toBeLessThanOrEqual(input.clientHeight);
|
||||
|
||||
contentHeight = 180;
|
||||
act(() => {
|
||||
resizeObserverCallback(
|
||||
[
|
||||
{
|
||||
target: input,
|
||||
contentRect: { width: 180 },
|
||||
} as unknown as ResizeObserverEntry,
|
||||
],
|
||||
{} as ResizeObserver,
|
||||
);
|
||||
});
|
||||
expect(input.style.height).toBe('128px');
|
||||
expect(input.style.overflowY).toBe('auto');
|
||||
|
||||
unmount();
|
||||
expect(disconnect).toHaveBeenCalledTimes(1);
|
||||
fireEvent.paste(input);
|
||||
expect(onPaste).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('submits on Enter and keeps line breaks or IME confirmation from submitting', () => {
|
||||
|
||||
@@ -1,38 +1,9 @@
|
||||
import {
|
||||
type ClipboardEventHandler,
|
||||
type KeyboardEvent,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
} from 'react';
|
||||
|
||||
const DRAFT_MIN_HEIGHT_PX = 40;
|
||||
const DRAFT_MAX_HEIGHT_PX = 128;
|
||||
|
||||
// for fallback on some browser
|
||||
function supportsNativeFieldSizing() {
|
||||
return (
|
||||
typeof CSS !== 'undefined' &&
|
||||
typeof CSS.supports === 'function' &&
|
||||
CSS.supports('field-sizing', 'content')
|
||||
);
|
||||
}
|
||||
|
||||
function resizeFallbackTextarea(textarea: HTMLTextAreaElement) {
|
||||
textarea.style.height = 'auto';
|
||||
const contentHeight = textarea.scrollHeight;
|
||||
const borderHeight = Math.max(
|
||||
0,
|
||||
textarea.offsetHeight - textarea.clientHeight,
|
||||
);
|
||||
const borderBoxContentHeight = contentHeight + borderHeight;
|
||||
const nextHeight = Math.min(
|
||||
Math.max(borderBoxContentHeight, DRAFT_MIN_HEIGHT_PX),
|
||||
DRAFT_MAX_HEIGHT_PX,
|
||||
);
|
||||
textarea.style.height = `${nextHeight}px`;
|
||||
textarea.style.overflowY =
|
||||
borderBoxContentHeight > DRAFT_MAX_HEIGHT_PX ? 'auto' : 'hidden';
|
||||
}
|
||||
import { AutoGrowTextArea } from '@/src/components/common/AutoGrowTextArea';
|
||||
|
||||
export function EditorAgentDraftTextarea({
|
||||
value,
|
||||
@@ -43,49 +14,6 @@ export function EditorAgentDraftTextarea({
|
||||
onChange: (value: string) => void;
|
||||
onPaste?: ClipboardEventHandler<HTMLTextAreaElement>;
|
||||
}) {
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const textarea = textareaRef.current;
|
||||
if (!textarea || supportsNativeFieldSizing()) {
|
||||
return;
|
||||
}
|
||||
resizeFallbackTextarea(textarea);
|
||||
}, [value]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const textarea = textareaRef.current;
|
||||
if (!textarea || supportsNativeFieldSizing()) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let previousWidth = textarea.getBoundingClientRect().width;
|
||||
const resizeWhenWidthChanges = (nextWidth: number) => {
|
||||
if (Math.abs(nextWidth - previousWidth) < 0.5) {
|
||||
return;
|
||||
}
|
||||
previousWidth = nextWidth;
|
||||
resizeFallbackTextarea(textarea);
|
||||
};
|
||||
|
||||
if (typeof ResizeObserver !== 'undefined') {
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
const entry = entries[0];
|
||||
if (entry) {
|
||||
resizeWhenWidthChanges(entry.contentRect.width);
|
||||
}
|
||||
});
|
||||
observer.observe(textarea);
|
||||
return () => observer.disconnect();
|
||||
}
|
||||
|
||||
const handleWindowResize = () => {
|
||||
resizeWhenWidthChanges(textarea.getBoundingClientRect().width);
|
||||
};
|
||||
window.addEventListener('resize', handleWindowResize);
|
||||
return () => window.removeEventListener('resize', handleWindowResize);
|
||||
}, []);
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (
|
||||
event.key === 'Enter' &&
|
||||
@@ -99,12 +27,10 @@ export function EditorAgentDraftTextarea({
|
||||
};
|
||||
|
||||
return (
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
className="editor-agent-conversation__draft-input max-h-32 min-h-10 min-w-0 flex-1 resize-none overflow-x-hidden overflow-y-auto overscroll-contain rounded-3xl border border-slate-200 bg-slate-50 px-3 py-2 text-sm text-slate-800 outline-none [field-sizing:content] focus:border-slate-400"
|
||||
<AutoGrowTextArea
|
||||
className="editor-agent-conversation__draft-input min-w-0 flex-1 rounded-3xl border border-slate-200 bg-slate-50 px-3 py-2 text-sm text-slate-800 outline-none focus:border-slate-400"
|
||||
aria-label="发送给画布 Agent"
|
||||
value={value}
|
||||
rows={1}
|
||||
onChange={(event) => onChange(event.currentTarget.value)}
|
||||
onPaste={onPaste}
|
||||
onKeyDown={handleKeyDown}
|
||||
|
||||
@@ -190,6 +190,7 @@ describe('ImageCanvasBasicGenerationComposerView', () => {
|
||||
});
|
||||
|
||||
it('keeps quick edit to one prompt box with image parameters', () => {
|
||||
const submitQuickEdit = vi.fn();
|
||||
render(
|
||||
<BasicGenerationHarness
|
||||
initialDialog={createDialog({
|
||||
@@ -206,14 +207,19 @@ describe('ImageCanvasBasicGenerationComposerView', () => {
|
||||
aspectRatio: '1:1',
|
||||
imageSize: '1K',
|
||||
})}
|
||||
onSubmit={submitQuickEdit}
|
||||
/>,
|
||||
);
|
||||
|
||||
const panel = screen.getByRole('dialog', { name: '快速编辑图片' });
|
||||
const prompt = within(panel).getByRole('textbox', {
|
||||
name: '快速编辑提示词',
|
||||
});
|
||||
|
||||
expect(
|
||||
within(panel).getByRole('textbox', { name: '快速编辑提示词' }),
|
||||
).toBeTruthy();
|
||||
expect(prompt.className).toContain('auto-grow-text-area');
|
||||
expect(prompt.className).not.toContain('platform-text-field');
|
||||
fireEvent.keyDown(prompt, { key: 'Enter' });
|
||||
expect(submitQuickEdit).not.toHaveBeenCalled();
|
||||
expect(
|
||||
within(panel).queryByRole('button', { name: '添加参考图' }),
|
||||
).toBeNull();
|
||||
|
||||
@@ -7,12 +7,12 @@ import {
|
||||
type SetStateAction,
|
||||
} from 'react';
|
||||
|
||||
import { AutoGrowTextArea } from '../common/AutoGrowTextArea';
|
||||
import {
|
||||
PlatformFloatingMenu,
|
||||
PlatformFloatingMenuItem,
|
||||
} from '../common/PlatformFloatingMenu';
|
||||
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
|
||||
import { PlatformTextField } from '../common/PlatformTextField';
|
||||
import type {
|
||||
CharacterReferenceImage,
|
||||
GenerateDialogState,
|
||||
@@ -54,6 +54,7 @@ type ImageCanvasBasicGenerationComposerViewProps = {
|
||||
onPickReferenceFromCanvas?: () => void;
|
||||
onToggleReferenceMenu?: () => void;
|
||||
onRememberImageModel?: (model: string) => void;
|
||||
hasPendingImageReferenceUploads?: boolean;
|
||||
onSubmit: (dialog: GenerateDialogState) => void;
|
||||
dialogLabel?: string;
|
||||
includeDimensions?: boolean;
|
||||
@@ -107,6 +108,7 @@ export function ImageCanvasBasicGenerationComposerView({
|
||||
onPickReferenceFromCanvas,
|
||||
onToggleReferenceMenu,
|
||||
onRememberImageModel = () => {},
|
||||
hasPendingImageReferenceUploads = false,
|
||||
onSubmit,
|
||||
dialogLabel,
|
||||
includeDimensions = true,
|
||||
@@ -247,14 +249,11 @@ export function ImageCanvasBasicGenerationComposerView({
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<PlatformTextField
|
||||
variant="textarea"
|
||||
<AutoGrowTextArea
|
||||
aria-label={resolvedPromptLabel}
|
||||
value={dialog.prompt}
|
||||
disabled={dialog.status === 'generating'}
|
||||
placeholder={resolvedPromptPlaceholder}
|
||||
size="sm"
|
||||
density="compact"
|
||||
className={promptClassName}
|
||||
onChange={(event) =>
|
||||
setGenerateDialog((currentDialog) =>
|
||||
@@ -274,6 +273,7 @@ export function ImageCanvasBasicGenerationComposerView({
|
||||
includeDimensions={shouldIncludeDimensions}
|
||||
includeModel={includeModel}
|
||||
onRememberImageModel={onRememberImageModel}
|
||||
hasPendingImageReferenceUploads={hasPendingImageReferenceUploads}
|
||||
dimensionRatioAriaLabelPrefix={dimensionRatioAriaLabelPrefix}
|
||||
dimensionSizeAriaLabelPrefix={dimensionSizeAriaLabelPrefix}
|
||||
optionLabelPrefix={resolvedOptionLabelPrefix}
|
||||
|
||||
@@ -113,7 +113,11 @@ describe('ImageCanvasCharacterAnimationPanelView', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByLabelText('动画描述'), {
|
||||
const description = screen.getByLabelText('动画描述');
|
||||
expect(description.className).toContain('auto-grow-text-area');
|
||||
expect(description.className).not.toContain('platform-text-field');
|
||||
|
||||
fireEvent.change(description, {
|
||||
target: { value: `${'a'.repeat(4001)}` },
|
||||
});
|
||||
fireEvent.click(
|
||||
@@ -207,9 +211,12 @@ describe('ImageCanvasCharacterAnimationPanelView', () => {
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '生成中' }));
|
||||
|
||||
expect((screen.getByLabelText('动画描述') as HTMLTextAreaElement).disabled).toBe(
|
||||
true,
|
||||
);
|
||||
const description = screen.getByLabelText(
|
||||
'动画描述',
|
||||
) as HTMLTextAreaElement;
|
||||
expect(description.disabled).toBe(true);
|
||||
expect(description.className).toContain('disabled:cursor-not-allowed');
|
||||
expect(description.className).toContain('disabled:opacity-60');
|
||||
expect((screen.getByRole('button', { name: '待机' }) as HTMLButtonElement).disabled).toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
@@ -13,11 +13,11 @@ import type {
|
||||
EditorCharacterAnimationRatio,
|
||||
EditorCharacterAnimationResolution,
|
||||
} from '../../services/image-editor/editorProjectClient';
|
||||
import { AutoGrowTextArea } from '../common/AutoGrowTextArea';
|
||||
import { PlatformActionButton } from '../common/PlatformActionButton';
|
||||
import { PlatformFloatingMenu } from '../common/PlatformFloatingMenu';
|
||||
import { PlatformInlineOptionButton } from '../common/PlatformInlineOptionButton';
|
||||
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
|
||||
import { PlatformTextField } from '../common/PlatformTextField';
|
||||
import { ImageCanvasEditorPortal } from './ImageCanvasEditorPortal';
|
||||
import { EditorIconButton } from './ImageCanvasEditorPrimitives';
|
||||
import type {
|
||||
@@ -194,16 +194,13 @@ export function ImageCanvasCharacterAnimationPanelView({
|
||||
disabled={isGenerating}
|
||||
onClick={closePanel}
|
||||
/>
|
||||
<PlatformTextField
|
||||
variant="textarea"
|
||||
<AutoGrowTextArea
|
||||
aria-label="动画描述"
|
||||
value={panel.promptText}
|
||||
maxLength={4000}
|
||||
disabled={isGenerating}
|
||||
placeholder="你希望角色做什么动作?"
|
||||
size="sm"
|
||||
density="compact"
|
||||
className="image-canvas-editor__generation-prompt image-canvas-editor__character-animation-textarea"
|
||||
className="max-h-64 image-canvas-editor__generation-prompt image-canvas-editor__character-animation-textarea"
|
||||
onChange={(event) =>
|
||||
updatePanel({ promptText: event.target.value.slice(0, 4000) })
|
||||
}
|
||||
|
||||
@@ -105,6 +105,8 @@ describe('ImageCanvasCharacterGenerationComposerView', () => {
|
||||
.join(''),
|
||||
).not.toContain('角色设定');
|
||||
expect(prompt.getAttribute('placeholder')).toBe('你希望角色如何设计?');
|
||||
expect(prompt.className).toContain('auto-grow-text-area');
|
||||
expect(prompt.className).not.toContain('platform-text-field');
|
||||
expect(prompt.className).toContain('image-canvas-editor__generation-prompt');
|
||||
expect(prompt.className).not.toContain(
|
||||
'image-canvas-editor__generation-prompt--borderless',
|
||||
|
||||
@@ -7,12 +7,12 @@ import {
|
||||
type SetStateAction,
|
||||
} from 'react';
|
||||
|
||||
import { AutoGrowTextArea } from '../common/AutoGrowTextArea';
|
||||
import {
|
||||
PlatformFloatingMenu,
|
||||
PlatformFloatingMenuItem,
|
||||
} from '../common/PlatformFloatingMenu';
|
||||
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
|
||||
import { PlatformTextField } from '../common/PlatformTextField';
|
||||
import type {
|
||||
CharacterReferenceImage,
|
||||
GenerateDialogState,
|
||||
@@ -44,6 +44,7 @@ type ImageCanvasCharacterGenerationComposerViewProps = {
|
||||
onOpenSpecDialog: (specType: SpecGenerationType) => void;
|
||||
onRequestUpload: (target: UploadTarget) => void;
|
||||
onRememberImageModel: (model: string) => void;
|
||||
hasPendingImageReferenceUploads?: boolean;
|
||||
onSubmit: (dialog: GenerateDialogState) => void;
|
||||
};
|
||||
|
||||
@@ -97,6 +98,7 @@ export function ImageCanvasCharacterGenerationComposerView({
|
||||
onOpenSpecDialog,
|
||||
onRequestUpload,
|
||||
onRememberImageModel,
|
||||
hasPendingImageReferenceUploads = false,
|
||||
onSubmit,
|
||||
}: ImageCanvasCharacterGenerationComposerViewProps) {
|
||||
useImageCanvasFloatingOptionDismiss({
|
||||
@@ -254,14 +256,11 @@ export function ImageCanvasCharacterGenerationComposerView({
|
||||
: null}
|
||||
</div>
|
||||
<label className="image-canvas-editor__field-block image-canvas-editor__field-block--single">
|
||||
<PlatformTextField
|
||||
variant="textarea"
|
||||
<AutoGrowTextArea
|
||||
aria-label="角色设定"
|
||||
value={dialog.prompt}
|
||||
disabled={dialog.status === 'generating'}
|
||||
placeholder="你希望角色如何设计?"
|
||||
size="sm"
|
||||
density="compact"
|
||||
className="image-canvas-editor__generation-prompt"
|
||||
onChange={(event) =>
|
||||
setGenerateDialog((currentDialog) =>
|
||||
@@ -292,6 +291,7 @@ export function ImageCanvasCharacterGenerationComposerView({
|
||||
setGenerateDialog={setGenerateDialog}
|
||||
includeDimensions
|
||||
onRememberImageModel={onRememberImageModel}
|
||||
hasPendingImageReferenceUploads={hasPendingImageReferenceUploads}
|
||||
optionLabelPrefix="生成图片"
|
||||
cost={calculateEditorImageGenerationPrice({
|
||||
kind: 'character',
|
||||
|
||||
@@ -36,6 +36,8 @@ function renderContextMenus(
|
||||
contextMenuLayer: null,
|
||||
contextShouldShowLayer: false,
|
||||
contextShouldUnlockLayer: false,
|
||||
canCopyContextLayers: true,
|
||||
isCanvasClipboardCopyBlocked: false,
|
||||
onPasteCanvasClipboard: vi.fn(),
|
||||
onCopyContextLayers: vi.fn(),
|
||||
onDuplicateContextLayers: vi.fn(),
|
||||
@@ -183,6 +185,87 @@ describe('ImageCanvasContextMenusView', () => {
|
||||
expect(props.onDeleteContextLayers).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('disables copy actions while the target resource is still being saved', () => {
|
||||
const layer = createLayer({ resourceId: 'local-resource-pending' });
|
||||
const props = renderContextMenus({
|
||||
contextMenu: {
|
||||
kind: 'layer',
|
||||
x: 16,
|
||||
y: 18,
|
||||
layerId: layer.id,
|
||||
canvasPoint: { x: 40, y: 42 },
|
||||
},
|
||||
contextMenuLayer: layer,
|
||||
canCopyContextLayers: false,
|
||||
contextLayerCopyBlockReason: 'pending-resource',
|
||||
});
|
||||
|
||||
expect(
|
||||
(screen.getByRole('menuitem', { name: '复制' }) as HTMLButtonElement)
|
||||
.disabled,
|
||||
).toBe(true);
|
||||
expect(
|
||||
(screen.getByRole('menuitem', { name: '剪切' }) as HTMLButtonElement)
|
||||
.disabled,
|
||||
).toBe(true);
|
||||
expect(
|
||||
(screen.getByRole('menuitem', { name: '创建副本' }) as HTMLButtonElement)
|
||||
.disabled,
|
||||
).toBe(true);
|
||||
expect(props.onCopyContextLayers).not.toHaveBeenCalled();
|
||||
expect(props.onDuplicateContextLayers).not.toHaveBeenCalled();
|
||||
expect(
|
||||
screen.getByRole('menuitem', { name: '复制' }).getAttribute('title'),
|
||||
).toBe('素材仍在保存,请稍后重试');
|
||||
});
|
||||
|
||||
it('explains that persisted historical sequences cannot be copied', () => {
|
||||
const layer = createLayer({
|
||||
resourceId: 'local-resource-historical-sequence',
|
||||
resourcePersistenceState: 'self-contained-local',
|
||||
mediaType: 'image-sequence',
|
||||
});
|
||||
renderContextMenus({
|
||||
contextMenu: {
|
||||
kind: 'layer',
|
||||
x: 16,
|
||||
y: 18,
|
||||
layerId: layer.id,
|
||||
canvasPoint: { x: 40, y: 42 },
|
||||
},
|
||||
contextMenuLayer: layer,
|
||||
canCopyContextLayers: false,
|
||||
contextLayerCopyBlockReason: 'self-contained-local-unsupported',
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.getByRole('menuitem', { name: '复制' }).getAttribute('title'),
|
||||
).toBe('历史序列帧素材暂不支持复制');
|
||||
});
|
||||
|
||||
it('explains that unresolved local resources are not registered', () => {
|
||||
const layer = createLayer({
|
||||
resourceId: 'local-resource-unresolved',
|
||||
resourcePersistenceState: 'unresolved-local',
|
||||
});
|
||||
renderContextMenus({
|
||||
contextMenu: {
|
||||
kind: 'layer',
|
||||
x: 16,
|
||||
y: 18,
|
||||
layerId: layer.id,
|
||||
canvasPoint: { x: 40, y: 42 },
|
||||
},
|
||||
contextMenuLayer: layer,
|
||||
canCopyContextLayers: false,
|
||||
contextLayerCopyBlockReason: 'unresolved-resource',
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.getByRole('menuitem', { name: '复制' }).getAttribute('title'),
|
||||
).toBe('素材资源尚未登记,暂不能复制');
|
||||
});
|
||||
|
||||
it('keeps measured layer menus and submenus inside the viewport', async () => {
|
||||
const originalInnerWidth = window.innerWidth;
|
||||
const originalInnerHeight = window.innerHeight;
|
||||
@@ -274,9 +357,7 @@ describe('ImageCanvasContextMenusView', () => {
|
||||
});
|
||||
|
||||
expect(screen.queryByRole('menuitem', { name: '快速编辑' })).toBeNull();
|
||||
expect(
|
||||
screen.getByRole('menuitem', { name: '查看图片信息' }),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByRole('menuitem', { name: '查看图片信息' })).toBeTruthy();
|
||||
});
|
||||
|
||||
it('keeps quick edit in the standalone menu for icon spritesheets', () => {
|
||||
|
||||
@@ -19,6 +19,7 @@ import type {
|
||||
ImageContextMenuState,
|
||||
} from './ImageCanvasEditorTypes';
|
||||
import { isQuickEditUnsupportedAssetKind } from './ImageCanvasGenerationModel';
|
||||
import type { CanvasLayerCopyBlockReason } from './ImageCanvasLayerCommandModel';
|
||||
|
||||
type ImageCanvasContextMenusViewProps = {
|
||||
viewport: CanvasViewport;
|
||||
@@ -29,6 +30,9 @@ type ImageCanvasContextMenusViewProps = {
|
||||
contextMenuLayer: CanvasLayer | null;
|
||||
contextShouldShowLayer: boolean;
|
||||
contextShouldUnlockLayer: boolean;
|
||||
canCopyContextLayers: boolean;
|
||||
contextLayerCopyBlockReason?: CanvasLayerCopyBlockReason | null;
|
||||
isCanvasClipboardCopyBlocked: boolean;
|
||||
onPasteCanvasClipboard: (canvasPoint?: { x: number; y: number }) => void;
|
||||
onCopyContextLayers: (options?: { cut?: boolean }) => void;
|
||||
onDuplicateContextLayers: () => void;
|
||||
@@ -69,6 +73,9 @@ export function ImageCanvasContextMenusView({
|
||||
contextMenuLayer,
|
||||
contextShouldShowLayer,
|
||||
contextShouldUnlockLayer,
|
||||
canCopyContextLayers,
|
||||
contextLayerCopyBlockReason = null,
|
||||
isCanvasClipboardCopyBlocked,
|
||||
onPasteCanvasClipboard,
|
||||
onCopyContextLayers,
|
||||
onDuplicateContextLayers,
|
||||
@@ -90,6 +97,14 @@ export function ImageCanvasContextMenusView({
|
||||
onOpenCharacterAnimationPanel,
|
||||
}: ImageCanvasContextMenusViewProps) {
|
||||
const isImageSequenceLayer = contextMenuLayer?.mediaType === 'image-sequence';
|
||||
const layerCopyBlockedTitle =
|
||||
contextLayerCopyBlockReason === 'self-contained-local-unsupported'
|
||||
? '历史序列帧素材暂不支持复制'
|
||||
: contextLayerCopyBlockReason === 'pending-resource'
|
||||
? '素材仍在保存,请稍后重试'
|
||||
: contextLayerCopyBlockReason === 'unresolved-resource'
|
||||
? '素材资源尚未登记,暂不能复制'
|
||||
: undefined;
|
||||
const menuRef = useRef<HTMLDivElement | null>(null);
|
||||
const [measuredMenuLayout, setMeasuredMenuLayout] =
|
||||
useState<MeasuredMenuLayout | null>(null);
|
||||
@@ -282,6 +297,7 @@ export function ImageCanvasContextMenusView({
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
disabled={isCanvasClipboardCopyBlocked}
|
||||
onClick={() => onPasteCanvasClipboard(contextMenu.canvasPoint)}
|
||||
>
|
||||
粘贴
|
||||
@@ -331,6 +347,8 @@ export function ImageCanvasContextMenusView({
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
disabled={!canCopyContextLayers}
|
||||
title={layerCopyBlockedTitle}
|
||||
onClick={() => onCopyContextLayers()}
|
||||
>
|
||||
复制
|
||||
@@ -338,6 +356,8 @@ export function ImageCanvasContextMenusView({
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
disabled={!canCopyContextLayers}
|
||||
title={layerCopyBlockedTitle}
|
||||
onClick={() => onCopyContextLayers({ cut: true })}
|
||||
>
|
||||
剪切
|
||||
@@ -345,6 +365,7 @@ export function ImageCanvasContextMenusView({
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
disabled={isCanvasClipboardCopyBlocked}
|
||||
onClick={() => onPasteCanvasClipboard(contextMenu.canvasPoint)}
|
||||
>
|
||||
粘贴
|
||||
@@ -352,6 +373,8 @@ export function ImageCanvasContextMenusView({
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
disabled={!canCopyContextLayers}
|
||||
title={layerCopyBlockedTitle}
|
||||
onClick={onDuplicateContextLayers}
|
||||
>
|
||||
创建副本
|
||||
|
||||
@@ -17,6 +17,8 @@ import {
|
||||
createAuthValue,
|
||||
createDataTransferStub,
|
||||
createDeferred,
|
||||
defaultEditorProjectLayers,
|
||||
defaultEditorProjectResources,
|
||||
dispatchPointerEvent,
|
||||
ImageCanvasEditorView,
|
||||
setupImageCanvasEditorViewTestLifecycle,
|
||||
@@ -166,6 +168,7 @@ function createTestImageAsset({
|
||||
width: 320,
|
||||
height: 240,
|
||||
sourceType,
|
||||
assetKind: 'image' as const,
|
||||
sourceResourceId,
|
||||
};
|
||||
}
|
||||
@@ -345,6 +348,86 @@ 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).toMatch(/^角色挥手-SpineJSON-\d{8}-\d{6}\.zip$/u);
|
||||
});
|
||||
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');
|
||||
@@ -1454,6 +1537,145 @@ describe('ImageCanvasEditorView asset library integration', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('restores a copied layout that references the same project resource after re-entering', async () => {
|
||||
const firstRender = render(<ImageCanvasEditorView />);
|
||||
await waitFor(() => {
|
||||
expect(loadOrCreateRecentEditorProjectMock).toHaveBeenCalled();
|
||||
});
|
||||
saveEditorProjectLayoutMock.mockClear();
|
||||
createEditorProjectResourceMock.mockClear();
|
||||
|
||||
fireEvent.contextMenu(
|
||||
screen.getByAltText('画布图片:拼图素材').closest('button')!,
|
||||
{
|
||||
clientX: 510,
|
||||
clientY: 330,
|
||||
},
|
||||
);
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '复制' }));
|
||||
fireEvent.contextMenu(screen.getByLabelText('画布工作区'), {
|
||||
clientX: 360,
|
||||
clientY: 240,
|
||||
});
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '粘贴' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(saveEditorProjectLayoutMock).toHaveBeenCalled();
|
||||
});
|
||||
const savedLayers = saveEditorProjectLayoutMock.mock.calls.at(-1)?.[1]
|
||||
?.layers as Array<{ layerId: string; resourceId: string }>;
|
||||
const copiedPuzzleLayouts = savedLayers.filter(
|
||||
(layer) => layer.resourceId === 'resource-puzzle',
|
||||
);
|
||||
expect(copiedPuzzleLayouts).toHaveLength(2);
|
||||
expect(
|
||||
new Set(copiedPuzzleLayouts.map((layer) => layer.layerId)).size,
|
||||
).toBe(2);
|
||||
expect(createEditorProjectResourceMock).not.toHaveBeenCalled();
|
||||
|
||||
firstRender.unmount();
|
||||
window.sessionStorage.clear();
|
||||
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce(
|
||||
withEditorProjectCanvasRevision(
|
||||
{
|
||||
projectId: 'editor-project-default',
|
||||
title: '默认项目',
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
layers: savedLayers,
|
||||
resources: defaultEditorProjectResources,
|
||||
updatedAt: '2026-08-03T00:00:00.000Z',
|
||||
},
|
||||
1,
|
||||
),
|
||||
);
|
||||
|
||||
render(<ImageCanvasEditorView />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByAltText(/画布图片:拼图素材/u)).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
it('restores different layer overrides for one shared resource after re-entering', async () => {
|
||||
const sharedResourceProject = withEditorProjectCanvasRevision(
|
||||
{
|
||||
projectId: 'editor-project-default',
|
||||
title: '共享资源标签项目',
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
layers: [
|
||||
{
|
||||
...defaultEditorProjectLayers[0],
|
||||
layerId: 'layer-shared-default',
|
||||
title: '共享默认角色',
|
||||
resourceId: 'resource-shared-kind',
|
||||
assetKindOverride: null,
|
||||
},
|
||||
{
|
||||
...defaultEditorProjectLayers[0],
|
||||
layerId: 'layer-shared-override',
|
||||
title: '共享图标覆盖',
|
||||
resourceId: 'resource-shared-kind',
|
||||
assetKindOverride: 'icon',
|
||||
x: 1140,
|
||||
},
|
||||
],
|
||||
resources: [
|
||||
{
|
||||
resourceId: 'resource-shared-kind',
|
||||
projectId: 'editor-project-default',
|
||||
imageSrc: '/creation-type-references/puzzle.webp',
|
||||
width: 640,
|
||||
height: 640,
|
||||
sourceType: 'uploaded',
|
||||
assetKind: 'character',
|
||||
},
|
||||
],
|
||||
updatedAt: '2026-08-04T00:00:00.000Z',
|
||||
},
|
||||
1,
|
||||
);
|
||||
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce(
|
||||
sharedResourceProject,
|
||||
);
|
||||
|
||||
const firstRender = render(<ImageCanvasEditorView />);
|
||||
const defaultLayer = await screen.findByRole('button', {
|
||||
name: '选择共享默认角色',
|
||||
});
|
||||
const overrideLayer = await screen.findByRole('button', {
|
||||
name: '选择共享图标覆盖',
|
||||
});
|
||||
expect(
|
||||
within(defaultLayer).getByRole('button', { name: '素材标签:角色' }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
within(overrideLayer).getByRole('button', { name: '素材标签:图标' }),
|
||||
).toBeTruthy();
|
||||
|
||||
firstRender.unmount();
|
||||
window.sessionStorage.clear();
|
||||
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce(
|
||||
sharedResourceProject,
|
||||
);
|
||||
render(<ImageCanvasEditorView />);
|
||||
|
||||
const restoredDefaultLayer = await screen.findByRole('button', {
|
||||
name: '选择共享默认角色',
|
||||
});
|
||||
const restoredOverrideLayer = await screen.findByRole('button', {
|
||||
name: '选择共享图标覆盖',
|
||||
});
|
||||
expect(
|
||||
within(restoredDefaultLayer).getByRole('button', {
|
||||
name: '素材标签:角色',
|
||||
}),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
within(restoredOverrideLayer).getByRole('button', {
|
||||
name: '素材标签:图标',
|
||||
}),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('drops files into the asset panel only once without creating canvas layers', async () => {
|
||||
render(<ImageCanvasEditorView />);
|
||||
|
||||
@@ -1520,6 +1742,92 @@ 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: [
|
||||
|
||||
@@ -40,6 +40,7 @@ const loadOrCreateRecentEditorProjectMock = vi.hoisted(() => vi.fn());
|
||||
const renameEditorProjectMock = vi.hoisted(() => vi.fn());
|
||||
const saveEditorProjectLayoutMock = vi.hoisted(() => vi.fn());
|
||||
const uploadEditorMediaAssetFileMock = vi.hoisted(() => vi.fn());
|
||||
const uploadEditorMediaAssetObjectFileMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('../../services/image-editor/editorProjectClient', async () => {
|
||||
const actual = await vi.importActual<
|
||||
@@ -74,7 +75,10 @@ vi.mock('./ImageCanvasUiAssetExtractionRasterModel', () => ({
|
||||
|
||||
vi.mock('../../services/image-editor/editorMediaAssetUploadClient', () => ({
|
||||
uploadEditorMediaAssetFile: uploadEditorMediaAssetFileMock,
|
||||
uploadEditorMediaAssetObjectFile: vi.fn(),
|
||||
// 中文注释:inline 源图上传走的是 object-only 版本,不是带签名 URL 的那个。留成裸 vi.fn()
|
||||
// 会返回 undefined,取 objectKey 抛的 TypeError 被 extractUiDesignAssets 的 catch 吞成
|
||||
// window.alert,最终只表现为「提取接口一次都没调」,报错里看不到任何线索。
|
||||
uploadEditorMediaAssetObjectFile: uploadEditorMediaAssetObjectFileMock,
|
||||
}));
|
||||
|
||||
vi.mock('./ImageCanvasProjectCoverSnapshotRenderer', () => ({
|
||||
@@ -113,6 +117,17 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
objectKey:
|
||||
'generated-character-drafts/editor/generation-references/marked-ui-design.png',
|
||||
assetObjectId: 'asset-object-marked-ui-design',
|
||||
legacyPublicPath:
|
||||
'/generated-character-drafts/editor/generation-references/marked-ui-design.png',
|
||||
src: 'https://oss.example.com/marked-ui-design.png',
|
||||
});
|
||||
uploadEditorMediaAssetObjectFileMock.mockReset();
|
||||
uploadEditorMediaAssetObjectFileMock.mockResolvedValue({
|
||||
objectKey:
|
||||
'generated-character-drafts/editor/generation-references/marked-ui-design.png',
|
||||
assetObjectId: 'asset-object-marked-ui-design',
|
||||
legacyPublicPath:
|
||||
'/generated-character-drafts/editor/generation-references/marked-ui-design.png',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -823,7 +838,8 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
.className,
|
||||
).toContain('image-canvas-editor__reference-chip');
|
||||
const generatePrompt = screen.getByLabelText('生成提示词');
|
||||
expect(generatePrompt.className).toContain('platform-text-field');
|
||||
expect(generatePrompt.className).toContain('auto-grow-text-area');
|
||||
expect(generatePrompt.className).not.toContain('platform-text-field');
|
||||
expect(generatePrompt.className).toContain(
|
||||
'image-canvas-editor__generation-prompt',
|
||||
);
|
||||
@@ -3214,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 =
|
||||
@@ -3386,7 +3414,11 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
'https://assets.test/generated-character-drafts/editor/frame1.png',
|
||||
);
|
||||
});
|
||||
expect(screen.getByText('1/48')).toBeTruthy();
|
||||
// 中文注释:分子不能钉死。序列帧图层默认自动播放(frames.length > 1 即 isPlaying),
|
||||
// 48 帧 6 秒算出 125ms 的真实 setInterval,而这里用的是真实定时器下的 waitFor——
|
||||
// 上一条 waitFor 在 CI 上多轮询一次就已经越过 125ms,计数器变成 2/48。要断言的是
|
||||
// 「按序列帧播放器渲染、总帧数 48」(下一行排除视频元素),分母才是这条断言的意义。
|
||||
expect(screen.getByText(/^\d+\/48$/u)).toBeTruthy();
|
||||
expect(screen.queryByLabelText('画布视频:角色动作')).toBeNull();
|
||||
expect(screen.getByText('动作')).toBeTruthy();
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -181,6 +181,8 @@ function createStageProps(): ImageCanvasStageViewProps {
|
||||
contextMenuLayer: null,
|
||||
contextShouldShowLayer: false,
|
||||
contextShouldUnlockLayer: false,
|
||||
canCopyContextLayers: true,
|
||||
isCanvasClipboardCopyBlocked: false,
|
||||
canUndo: false,
|
||||
canRedo: false,
|
||||
historyFeedback: null,
|
||||
@@ -215,6 +217,7 @@ function createStageProps(): ImageCanvasStageViewProps {
|
||||
onOpenRedrawPanel: vi.fn(),
|
||||
onOpenCropExpandPanel: vi.fn(),
|
||||
onRemoveBackground: vi.fn(),
|
||||
onPerfectPixel: vi.fn(),
|
||||
onSplitIconSpritesheet: vi.fn(),
|
||||
onExtractUiDesignAssets: vi.fn(),
|
||||
onUiAssetExtractionToolChange: vi.fn(),
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import type {
|
||||
EditorAssetSnapshot,
|
||||
EditorCharacterAnimationFrameCount,
|
||||
EditorCharacterAnimationFrameResult,
|
||||
EditorCharacterAnimationGenerationResult,
|
||||
EditorCharacterAnimationRatio,
|
||||
EditorCharacterAnimationResolution,
|
||||
EditorImageGenerationStyle,
|
||||
EditorImageSequenceFrameResult,
|
||||
EditorPixelArtSnapInput,
|
||||
EditorVideoAspectRatio,
|
||||
EditorVideoModel,
|
||||
EditorVideoResolution,
|
||||
@@ -15,6 +16,8 @@ import type {
|
||||
export type CanvasSourceType = 'uploaded' | 'generated' | 'mock_generated';
|
||||
|
||||
export type CanvasAssetKind =
|
||||
| 'image'
|
||||
| 'audio'
|
||||
| 'spec'
|
||||
| 'character'
|
||||
| 'character-animation'
|
||||
@@ -58,7 +61,8 @@ export type EditorAsset = {
|
||||
showcaseSubmitError?: string | null;
|
||||
assetKind?: CanvasAssetKind | null;
|
||||
generationInputs?: CanvasGenerationInputs | null;
|
||||
durationSeconds?: number;
|
||||
imageSequenceFrames?: EditorImageSequenceFrameResult[];
|
||||
imageSequenceDurationMs?: number;
|
||||
uploadStatus?: 'uploading' | 'failed';
|
||||
uploadProgress?: number;
|
||||
uploadMessage?: string;
|
||||
@@ -84,6 +88,8 @@ export type CanvasGenerationInputs = {
|
||||
export type CanvasLayer = {
|
||||
id: string;
|
||||
resourceId: string;
|
||||
resourcePersistenceState?:
|
||||
'pending' | 'registered' | 'self-contained-local' | 'unresolved-local';
|
||||
title: string;
|
||||
src: string;
|
||||
mediaType?: CanvasMediaType;
|
||||
@@ -107,10 +113,12 @@ export type CanvasLayer = {
|
||||
sourceAssetId?: string | null;
|
||||
generatedAssetSnapshot?: EditorAssetSnapshot | null;
|
||||
groupId?: string | null;
|
||||
resourceAssetKind?: CanvasAssetKind | null;
|
||||
assetKindOverride?: CanvasAssetKind | null;
|
||||
assetKind?: CanvasAssetKind | null;
|
||||
generationInputs?: CanvasGenerationInputs | null;
|
||||
durationSeconds?: number;
|
||||
imageSequenceFrames?: EditorCharacterAnimationFrameResult[];
|
||||
imageSequenceFrames?: EditorImageSequenceFrameResult[];
|
||||
imageSequenceDurationMs?: number;
|
||||
previewVideoPath?: string | null;
|
||||
hidden?: boolean;
|
||||
locked?: boolean;
|
||||
@@ -182,6 +190,16 @@ export type PublicationMaterialsWorkflowId =
|
||||
| 'publication-detail-gallery'
|
||||
| 'publication-promo-poster';
|
||||
|
||||
export type PerfectPixelOperationSnapshot = {
|
||||
version: 1;
|
||||
kind: 'perfect-pixel';
|
||||
operationId: string;
|
||||
taskId: string;
|
||||
request: EditorPixelArtSnapInput;
|
||||
submittedAt: number;
|
||||
reconcileUntil: number;
|
||||
};
|
||||
|
||||
export type GenerateDialogState = {
|
||||
id?: string;
|
||||
mode:
|
||||
@@ -199,7 +217,7 @@ export type GenerateDialogState = {
|
||||
| 'audio-background-music';
|
||||
prompt: string;
|
||||
assetLabel?: string;
|
||||
status: 'idle' | 'generating' | 'failed';
|
||||
status: 'idle' | 'generating' | 'pending-confirmation' | 'failed';
|
||||
composerOpen?: boolean;
|
||||
sourceLayerId?: string;
|
||||
generatedLayerId?: string;
|
||||
@@ -237,6 +255,17 @@ export type GenerateDialogState = {
|
||||
aspectRatio?: string;
|
||||
imageSize?: string;
|
||||
errorMessage?: string;
|
||||
// 中文注释:标记该占位的收口只能由创建它的页面会话完成——链路是同步 HTTP、服务端没有
|
||||
// durable job,进程一死就再没有任何东西会把它推向终态。队列型占位(去除背景恒队列、
|
||||
// 图片生成默认队列)不得置位:它们的 job 在服务端继续跑,worker 会替换占位,刷新后
|
||||
// 必须原样恢复。
|
||||
requiresLiveSession?: boolean;
|
||||
// 中文注释:请求账本只存在于本机(见 perfectPixelOperationStore),布局里只留这个 id
|
||||
// 标记「该占位是一次完美像素操作」。换设备打开时标记还在、账本读不到,占位收口为可删除
|
||||
// 的失败态——这是明确设计,不得据此阻断用户删除或重做。
|
||||
perfectPixelOperationId?: string;
|
||||
perfectPixelOperation?: PerfectPixelOperationSnapshot;
|
||||
perfectPixelOperationInvalid?: boolean;
|
||||
generationStartedAt?: number;
|
||||
generationFinishedAt?: number;
|
||||
placeholder?: {
|
||||
@@ -289,6 +318,7 @@ export type CanvasHistoryActionType =
|
||||
| 'generate-image'
|
||||
| 'expand-image'
|
||||
| 'remove-background'
|
||||
| 'perfect-pixel'
|
||||
| 'split-atlas'
|
||||
| 'replace-image'
|
||||
| 'show-image'
|
||||
@@ -363,6 +393,7 @@ export type CanvasContextMenuState =
|
||||
};
|
||||
|
||||
export type QuickEditPanelState = {
|
||||
referenceUploadContextId?: string;
|
||||
mode?: 'quick-edit' | 'redraw';
|
||||
sourceLayerId: string;
|
||||
prompt: string;
|
||||
@@ -390,12 +421,7 @@ export type CropExpandPanelState = {
|
||||
};
|
||||
|
||||
export type CropExpandRatioValue =
|
||||
| 'free'
|
||||
| '1:1'
|
||||
| '4:3'
|
||||
| '3:4'
|
||||
| '16:9'
|
||||
| '9:16';
|
||||
'free' | '1:1' | '4:3' | '3:4' | '16:9' | '9:16';
|
||||
|
||||
export type CropExpandResizeHandle =
|
||||
| 'left'
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -12,7 +12,6 @@ import userEvent from '@testing-library/user-event';
|
||||
import JSZip from 'jszip';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { EditorProjectResourceSnapshot } from '../../services/image-editor/editorProjectClient';
|
||||
import type { EditorAgentConversationClient } from './EditorAgentConversation/useEditorAgentConversation';
|
||||
import {
|
||||
ApiClientError,
|
||||
@@ -1454,7 +1453,7 @@ describe('ImageCanvasEditorView', () => {
|
||||
fields: [
|
||||
{ title: '音效提示词', value: '金币跳出音' },
|
||||
{ title: 'model', value: 'audio1.0' },
|
||||
{ title: 'duration', value: '8秒' },
|
||||
{ title: '时长', value: '8秒' },
|
||||
],
|
||||
references: [
|
||||
{
|
||||
@@ -2034,42 +2033,31 @@ describe('ImageCanvasEditorView', () => {
|
||||
|
||||
await screen.findByRole('button', { name: '素材标签:角色' });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createEditorProjectResourceMock).toHaveBeenCalledWith(
|
||||
'editor-project-default',
|
||||
expect.objectContaining({
|
||||
imageSrc: '/creation-type-references/puzzle.webp',
|
||||
assetKind: 'character',
|
||||
}),
|
||||
);
|
||||
});
|
||||
await waitFor(() => {
|
||||
const savedLayout = saveEditorProjectLayoutMock.mock.calls
|
||||
.map((call) => call[1])
|
||||
.find((layout) =>
|
||||
layout.layers.some(
|
||||
(layer: { resourceId?: unknown; title?: unknown }) =>
|
||||
(layer: {
|
||||
resourceId?: unknown;
|
||||
title?: unknown;
|
||||
assetKindOverride?: unknown;
|
||||
}) =>
|
||||
layer.title === '拼图素材' &&
|
||||
layer.resourceId === 'resource-editor-project-default-640',
|
||||
layer.resourceId === 'resource-puzzle' &&
|
||||
layer.assetKindOverride === 'character',
|
||||
),
|
||||
);
|
||||
|
||||
expect(savedLayout).toBeTruthy();
|
||||
expect(savedLayout.layers).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.not.objectContaining({
|
||||
title: '拼图素材',
|
||||
assetKind: expect.anything(),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
expect(createEditorProjectResourceMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps atlas splitting busy until the new asset kind resource is confirmed', async () => {
|
||||
const persistedResource = createDeferred<EditorProjectResourceSnapshot>();
|
||||
createEditorProjectResourceMock.mockImplementationOnce(
|
||||
() => persistedResource.promise,
|
||||
it('flushes the layer override before splitting without replacing its resource', async () => {
|
||||
const savedLayout = createDeferred<unknown>();
|
||||
saveEditorProjectLayoutMock.mockImplementationOnce(
|
||||
() => savedLayout.promise,
|
||||
);
|
||||
render(<ImageCanvasEditorView />);
|
||||
|
||||
@@ -2087,80 +2075,48 @@ describe('ImageCanvasEditorView', () => {
|
||||
),
|
||||
);
|
||||
|
||||
const pendingSplitButton = await screen.findByRole('button', {
|
||||
name: '素材类型保存中',
|
||||
});
|
||||
expect((pendingSplitButton as HTMLButtonElement).disabled).toBe(true);
|
||||
expect(pendingSplitButton.getAttribute('aria-busy')).toBe('true');
|
||||
fireEvent.click(pendingSplitButton);
|
||||
const splitButton = await screen.findByRole('button', { name: '拆分图集' });
|
||||
fireEvent.click(splitButton);
|
||||
expect(splitEditorIconSpritesheetMock).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => {
|
||||
persistedResource.resolve({
|
||||
resourceId: 'resource-confirmed-atlas',
|
||||
projectId: 'editor-project-default',
|
||||
imageSrc: '/creation-type-references/puzzle.webp',
|
||||
width: 640,
|
||||
height: 640,
|
||||
sourceType: 'uploaded',
|
||||
assetKind: 'icon-spritesheet',
|
||||
});
|
||||
await persistedResource.promise;
|
||||
savedLayout.resolve({});
|
||||
await savedLayout.promise;
|
||||
});
|
||||
|
||||
const splitButton = await screen.findByRole('button', {
|
||||
name: '拆分图集',
|
||||
});
|
||||
expect((splitButton as HTMLButtonElement).disabled).toBe(false);
|
||||
fireEvent.click(splitButton);
|
||||
await waitFor(() => {
|
||||
expect(splitEditorIconSpritesheetMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
projectId: 'editor-project-default',
|
||||
sourceResourceId: 'resource-confirmed-atlas',
|
||||
sourceLayerId: 'layer-puzzle',
|
||||
sourceResourceId: 'resource-puzzle',
|
||||
}),
|
||||
);
|
||||
});
|
||||
expect(createEditorProjectResourceMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rolls back the asset kind and resource when persistence fails', async () => {
|
||||
const persistedResource = createDeferred<EditorProjectResourceSnapshot>();
|
||||
createEditorProjectResourceMock.mockImplementationOnce(
|
||||
() => persistedResource.promise,
|
||||
);
|
||||
it('undoes and redoes only the layer override while keeping the resource stable', async () => {
|
||||
render(<ImageCanvasEditorView />);
|
||||
|
||||
const layerButton = await screen.findByRole('button', {
|
||||
name: '选择拼图素材',
|
||||
});
|
||||
fireEvent.click(layerButton);
|
||||
fireEvent.click(
|
||||
within(layerButton).getByRole('button', { name: '素材标签:未知' }),
|
||||
);
|
||||
fireEvent.click(
|
||||
within(screen.getByRole('menu', { name: '选择素材标签' })).getByRole(
|
||||
'menuitem',
|
||||
{ name: '图集' },
|
||||
{ name: '角色' },
|
||||
),
|
||||
);
|
||||
await screen.findByRole('button', { name: '素材类型保存中' });
|
||||
|
||||
await act(async () => {
|
||||
persistedResource.reject(new Error('资源类型写入失败'));
|
||||
try {
|
||||
await persistedResource.promise;
|
||||
} catch {
|
||||
// The component consumes the rejection and restores the prior layer.
|
||||
}
|
||||
});
|
||||
await within(layerButton).findByRole('button', { name: '素材标签:角色' });
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '撤销' }));
|
||||
await within(layerButton).findByRole('button', {
|
||||
name: '素材标签:未知',
|
||||
});
|
||||
expect(
|
||||
screen.getByText('素材类型保存失败,已恢复原标签:资源类型写入失败'),
|
||||
).toBeTruthy();
|
||||
expect(screen.queryByRole('button', { name: '拆分图集' })).toBeNull();
|
||||
await waitFor(() => {
|
||||
const latestLayout = saveEditorProjectLayoutMock.mock.calls.at(-1)?.[1];
|
||||
expect(latestLayout.layers).toEqual(
|
||||
@@ -2168,45 +2124,13 @@ describe('ImageCanvasEditorView', () => {
|
||||
expect.objectContaining({
|
||||
layerId: 'layer-puzzle',
|
||||
resourceId: 'resource-puzzle',
|
||||
assetKindOverride: null,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('rolls back when the resource response keeps the old asset kind', async () => {
|
||||
createEditorProjectResourceMock.mockResolvedValueOnce({
|
||||
resourceId: 'resource-stale-icon-spec',
|
||||
projectId: 'editor-project-default',
|
||||
imageSrc: '/creation-type-references/puzzle.webp',
|
||||
width: 640,
|
||||
height: 640,
|
||||
sourceType: 'uploaded',
|
||||
assetKind: 'icon-spec',
|
||||
});
|
||||
render(<ImageCanvasEditorView />);
|
||||
|
||||
const layerButton = await screen.findByRole('button', {
|
||||
name: '选择拼图素材',
|
||||
});
|
||||
fireEvent.click(layerButton);
|
||||
fireEvent.click(
|
||||
within(layerButton).getByRole('button', { name: '素材标签:未知' }),
|
||||
);
|
||||
fireEvent.click(
|
||||
within(screen.getByRole('menu', { name: '选择素材标签' })).getByRole(
|
||||
'menuitem',
|
||||
{ name: '图集' },
|
||||
),
|
||||
);
|
||||
|
||||
await within(layerButton).findByRole('button', { name: '素材标签:未知' });
|
||||
expect(
|
||||
screen.getByText(
|
||||
'素材类型保存失败,已恢复原标签:服务器未确认新的素材类型',
|
||||
),
|
||||
).toBeTruthy();
|
||||
expect(screen.queryByRole('button', { name: '拆分图集' })).toBeNull();
|
||||
fireEvent.click(screen.getByRole('button', { name: '恢复' }));
|
||||
await within(layerButton).findByRole('button', { name: '素材标签:角色' });
|
||||
await waitFor(() => {
|
||||
const latestLayout = saveEditorProjectLayoutMock.mock.calls.at(-1)?.[1];
|
||||
expect(latestLayout.layers).toEqual(
|
||||
@@ -2214,249 +2138,12 @@ describe('ImageCanvasEditorView', () => {
|
||||
expect.objectContaining({
|
||||
layerId: 'layer-puzzle',
|
||||
resourceId: 'resource-puzzle',
|
||||
assetKindOverride: 'character',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('persists an undone asset kind and ignores the older pending response', async () => {
|
||||
const characterResource = createDeferred<EditorProjectResourceSnapshot>();
|
||||
const restoredResource = createDeferred<EditorProjectResourceSnapshot>();
|
||||
createEditorProjectResourceMock
|
||||
.mockImplementationOnce(() => characterResource.promise)
|
||||
.mockImplementationOnce(() => restoredResource.promise);
|
||||
render(<ImageCanvasEditorView />);
|
||||
|
||||
const layerButton = await screen.findByRole('button', {
|
||||
name: '选择拼图素材',
|
||||
});
|
||||
fireEvent.click(
|
||||
within(layerButton).getByRole('button', { name: '素材标签:未知' }),
|
||||
);
|
||||
fireEvent.click(
|
||||
within(screen.getByRole('menu', { name: '选择素材标签' })).getByRole(
|
||||
'menuitem',
|
||||
{ name: '角色' },
|
||||
),
|
||||
);
|
||||
await screen.findByRole('button', { name: '素材标签:角色' });
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '撤销' }));
|
||||
await within(layerButton).findByRole('button', { name: '素材标签:未知' });
|
||||
await waitFor(() => {
|
||||
expect(createEditorProjectResourceMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
expect(createEditorProjectResourceMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'editor-project-default',
|
||||
expect.objectContaining({ assetKind: null }),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
restoredResource.resolve({
|
||||
resourceId: 'resource-restored-kind',
|
||||
projectId: 'editor-project-default',
|
||||
imageSrc: '/creation-type-references/puzzle.webp',
|
||||
width: 640,
|
||||
height: 640,
|
||||
sourceType: 'uploaded',
|
||||
assetKind: null,
|
||||
});
|
||||
await restoredResource.promise;
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
saveEditorProjectLayoutMock.mock.calls.some((call) =>
|
||||
call[1].layers.some(
|
||||
(layer: { resourceId?: unknown }) =>
|
||||
layer.resourceId === 'resource-restored-kind',
|
||||
),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
characterResource.resolve({
|
||||
resourceId: 'resource-stale-character',
|
||||
projectId: 'editor-project-default',
|
||||
imageSrc: '/creation-type-references/puzzle.webp',
|
||||
width: 640,
|
||||
height: 640,
|
||||
sourceType: 'uploaded',
|
||||
assetKind: 'character',
|
||||
});
|
||||
await characterResource.promise;
|
||||
});
|
||||
expect(
|
||||
saveEditorProjectLayoutMock.mock.calls.some((call) =>
|
||||
call[1].layers.some(
|
||||
(layer: { resourceId?: unknown }) =>
|
||||
layer.resourceId === 'resource-stale-character',
|
||||
),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('rolls back an overlapping undo failure to the last confirmed resource', async () => {
|
||||
const staleCharacterResource =
|
||||
createDeferred<EditorProjectResourceSnapshot>();
|
||||
const failedUndoResource = createDeferred<EditorProjectResourceSnapshot>();
|
||||
createEditorProjectResourceMock
|
||||
.mockImplementationOnce(() => staleCharacterResource.promise)
|
||||
.mockImplementationOnce(() => failedUndoResource.promise);
|
||||
render(<ImageCanvasEditorView />);
|
||||
|
||||
const layerButton = await screen.findByRole('button', {
|
||||
name: '选择拼图素材',
|
||||
});
|
||||
fireEvent.click(
|
||||
within(layerButton).getByRole('button', { name: '素材标签:未知' }),
|
||||
);
|
||||
fireEvent.click(
|
||||
within(screen.getByRole('menu', { name: '选择素材标签' })).getByRole(
|
||||
'menuitem',
|
||||
{ name: '角色' },
|
||||
),
|
||||
);
|
||||
await within(layerButton).findByRole('button', {
|
||||
name: '素材标签:角色',
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '撤销' }));
|
||||
await within(layerButton).findByRole('button', { name: '素材标签:未知' });
|
||||
await waitFor(() => {
|
||||
expect(createEditorProjectResourceMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
failedUndoResource.reject(new Error('撤销后资源写入失败'));
|
||||
try {
|
||||
await failedUndoResource.promise;
|
||||
} catch {
|
||||
// The latest failure must restore the server-confirmed original layer.
|
||||
}
|
||||
});
|
||||
|
||||
await within(layerButton).findByRole('button', { name: '素材标签:未知' });
|
||||
expect(
|
||||
screen.getByText('素材类型保存失败,已恢复原标签:撤销后资源写入失败'),
|
||||
).toBeTruthy();
|
||||
await waitFor(() => {
|
||||
const latestLayout = saveEditorProjectLayoutMock.mock.calls.at(-1)?.[1];
|
||||
expect(latestLayout.layers).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
layerId: 'layer-puzzle',
|
||||
resourceId: 'resource-puzzle',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
const undoButton = screen.getByRole('button', { name: '撤销' });
|
||||
expect((undoButton as HTMLButtonElement).disabled).toBe(true);
|
||||
fireEvent.click(undoButton);
|
||||
expect(createEditorProjectResourceMock).toHaveBeenCalledTimes(2);
|
||||
|
||||
await act(async () => {
|
||||
staleCharacterResource.resolve({
|
||||
resourceId: 'resource-stale-character-after-failed-undo',
|
||||
projectId: 'editor-project-default',
|
||||
imageSrc: '/creation-type-references/puzzle.webp',
|
||||
width: 640,
|
||||
height: 640,
|
||||
sourceType: 'uploaded',
|
||||
assetKind: 'character',
|
||||
});
|
||||
await staleCharacterResource.promise;
|
||||
});
|
||||
|
||||
expect(
|
||||
within(layerButton).getByRole('button', { name: '素材标签:未知' }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
saveEditorProjectLayoutMock.mock.calls.some((call) =>
|
||||
call[1].layers.some(
|
||||
(layer: { resourceId?: unknown }) =>
|
||||
layer.resourceId === 'resource-stale-character-after-failed-undo',
|
||||
),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps the newer asset kind pending when an older request fails', async () => {
|
||||
const staleCharacterResource =
|
||||
createDeferred<EditorProjectResourceSnapshot>();
|
||||
const currentAtlasResource =
|
||||
createDeferred<EditorProjectResourceSnapshot>();
|
||||
createEditorProjectResourceMock
|
||||
.mockImplementationOnce(() => staleCharacterResource.promise)
|
||||
.mockImplementationOnce(() => currentAtlasResource.promise);
|
||||
render(<ImageCanvasEditorView />);
|
||||
|
||||
const layerButton = await screen.findByRole('button', {
|
||||
name: '选择拼图素材',
|
||||
});
|
||||
fireEvent.click(layerButton);
|
||||
fireEvent.click(
|
||||
within(layerButton).getByRole('button', { name: '素材标签:未知' }),
|
||||
);
|
||||
fireEvent.click(
|
||||
within(screen.getByRole('menu', { name: '选择素材标签' })).getByRole(
|
||||
'menuitem',
|
||||
{ name: '角色' },
|
||||
),
|
||||
);
|
||||
await within(layerButton).findByRole('button', {
|
||||
name: '素材标签:角色',
|
||||
});
|
||||
|
||||
fireEvent.click(
|
||||
within(layerButton).getByRole('button', { name: '素材标签:角色' }),
|
||||
);
|
||||
fireEvent.click(
|
||||
within(screen.getByRole('menu', { name: '选择素材标签' })).getByRole(
|
||||
'menuitem',
|
||||
{ name: '图集' },
|
||||
),
|
||||
);
|
||||
await screen.findByRole('button', { name: '素材类型保存中' });
|
||||
|
||||
await act(async () => {
|
||||
staleCharacterResource.reject(new Error('旧请求失败'));
|
||||
try {
|
||||
await staleCharacterResource.promise;
|
||||
} catch {
|
||||
// The stale rejection must not affect the newer request state.
|
||||
}
|
||||
});
|
||||
|
||||
expect(
|
||||
within(layerButton).getByRole('button', { name: '素材标签:图集' }),
|
||||
).toBeTruthy();
|
||||
const stillPendingButton = screen.getByRole('button', {
|
||||
name: '素材类型保存中',
|
||||
});
|
||||
expect((stillPendingButton as HTMLButtonElement).disabled).toBe(true);
|
||||
expect(screen.queryByText(/素材类型保存失败/u)).toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
currentAtlasResource.resolve({
|
||||
resourceId: 'resource-current-atlas',
|
||||
projectId: 'editor-project-default',
|
||||
imageSrc: '/creation-type-references/puzzle.webp',
|
||||
width: 640,
|
||||
height: 640,
|
||||
sourceType: 'uploaded',
|
||||
assetKind: 'icon-spritesheet',
|
||||
});
|
||||
await currentAtlasResource.promise;
|
||||
});
|
||||
|
||||
expect(
|
||||
await screen.findByRole('button', { name: '拆分图集' }),
|
||||
).toBeTruthy();
|
||||
expect(createEditorProjectResourceMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('offers Lovart-style zoom menu commands', async () => {
|
||||
@@ -2582,10 +2269,10 @@ describe('ImageCanvasEditorView', () => {
|
||||
expect(
|
||||
bigFishLayer.classList.contains('image-canvas-editor__layer--selected'),
|
||||
).toBe(false);
|
||||
fireEvent.contextMenu(
|
||||
within(messageLog).getByRole('presentation'),
|
||||
{ clientX: 30, clientY: 40 },
|
||||
);
|
||||
fireEvent.contextMenu(within(messageLog).getByRole('presentation'), {
|
||||
clientX: 30,
|
||||
clientY: 40,
|
||||
});
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '在画布中定位' }));
|
||||
expect(world.style.transform).toBe(initialTransform);
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user