Merge branch 'codex/editor-asset-library' of https://git.genarrative.world/git/GenarrativeAI/Genarrative into codex/editor-asset-library
This commit is contained in:
@@ -1,16 +1,3 @@
|
||||
import {
|
||||
Box,
|
||||
Code2,
|
||||
Gamepad2,
|
||||
Image as ImageIcon,
|
||||
Layers3,
|
||||
Megaphone,
|
||||
Music,
|
||||
Palette,
|
||||
Plus,
|
||||
Sparkles,
|
||||
Wand2,
|
||||
} from 'lucide-react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import {
|
||||
@@ -56,7 +43,7 @@ type ShowcaseTabId =
|
||||
type CreationFeatureItem = {
|
||||
title: string;
|
||||
description: string;
|
||||
icon: typeof Palette;
|
||||
iconSrc: string;
|
||||
};
|
||||
|
||||
type ShowcaseAssetItem = {
|
||||
@@ -70,51 +57,57 @@ type ShowcaseAssetItem = {
|
||||
cost: string;
|
||||
};
|
||||
|
||||
const CREATION_HOME_ASSETS = {
|
||||
heroCreate: '/creation-home/hero-create.png',
|
||||
heroCommunity: '/creation-home/hero-community.png',
|
||||
projectNew: '/creation-home/project-new.png',
|
||||
} as const;
|
||||
|
||||
const CREATION_FEATURES: CreationFeatureItem[] = [
|
||||
{
|
||||
title: '游戏视觉规范',
|
||||
description: '轻松约束多类素材视觉一致性',
|
||||
icon: Palette,
|
||||
iconSrc: '/creation-home/feature-visual-spec.png',
|
||||
},
|
||||
{
|
||||
title: '游戏角色',
|
||||
description: '整套高完成度2D/3D角色素材及动画',
|
||||
icon: Box,
|
||||
iconSrc: '/creation-home/feature-character.png',
|
||||
},
|
||||
{
|
||||
title: '游戏UI',
|
||||
description: '轻松生成高一致性的UI素材',
|
||||
icon: Layers3,
|
||||
iconSrc: '/creation-home/feature-ui.png',
|
||||
},
|
||||
{
|
||||
title: '游戏音乐',
|
||||
description: '轻松制作游戏背景音乐、音效',
|
||||
icon: Music,
|
||||
iconSrc: '/creation-home/feature-music.png',
|
||||
},
|
||||
{
|
||||
title: '游戏特效',
|
||||
description: '低成本生成精品游戏特效',
|
||||
icon: Sparkles,
|
||||
iconSrc: '/creation-home/feature-effects.png',
|
||||
},
|
||||
{
|
||||
title: '游戏场景',
|
||||
description: '轻松生成各类游戏场景素材',
|
||||
icon: ImageIcon,
|
||||
iconSrc: '/creation-home/feature-scene.png',
|
||||
},
|
||||
{
|
||||
title: '游戏美宣',
|
||||
description: '轻松生成游戏上架所需图片和视频',
|
||||
icon: Megaphone,
|
||||
iconSrc: '/creation-home/feature-marketing.png',
|
||||
},
|
||||
{
|
||||
title: '游戏功能编码',
|
||||
description: '自然语言驱动游戏生成',
|
||||
icon: Code2,
|
||||
iconSrc: '/creation-home/feature-coding.png',
|
||||
},
|
||||
{
|
||||
title: '一键导出',
|
||||
description: '支持导出为微抖小游戏、Godot、Unity、UE工程',
|
||||
icon: Gamepad2,
|
||||
iconSrc: '/creation-home/feature-export.png',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -479,18 +472,35 @@ export function CreationLandingView({
|
||||
<div className="creation-landing__hero-actions">
|
||||
<PlatformActionButton
|
||||
size="md"
|
||||
shape="pill"
|
||||
className="creation-landing__hero-action creation-landing__hero-action--primary"
|
||||
onClick={createProject}
|
||||
disabled={isCreatingProject}
|
||||
>
|
||||
<Wand2 className="h-4 w-4" />
|
||||
<img
|
||||
src={CREATION_HOME_ASSETS.heroCreate}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
draggable={false}
|
||||
className="creation-landing__button-art"
|
||||
/>
|
||||
开始创作
|
||||
</PlatformActionButton>
|
||||
{onOpenCommunity ? (
|
||||
<PlatformActionButton
|
||||
tone="secondary"
|
||||
size="md"
|
||||
shape="pill"
|
||||
className="creation-landing__hero-action creation-landing__hero-action--secondary"
|
||||
onClick={onOpenCommunity}
|
||||
>
|
||||
<img
|
||||
src={CREATION_HOME_ASSETS.heroCommunity}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
draggable={false}
|
||||
className="creation-landing__button-art"
|
||||
/>
|
||||
玩家社区
|
||||
</PlatformActionButton>
|
||||
) : null}
|
||||
@@ -510,18 +520,26 @@ export function CreationLandingView({
|
||||
</div>
|
||||
</div>
|
||||
<div className="creation-landing__feature-grid">
|
||||
{CREATION_FEATURES.map((feature) => {
|
||||
const Icon = feature.icon;
|
||||
return (
|
||||
<article key={feature.title} className="creation-landing__feature">
|
||||
{CREATION_FEATURES.map((feature) => (
|
||||
<article key={feature.title} className="creation-landing__feature">
|
||||
<span className="creation-landing__feature-figure">
|
||||
<span className="creation-landing__feature-icon">
|
||||
<Icon className="h-5 w-5" />
|
||||
<img
|
||||
src={feature.iconSrc}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
loading="lazy"
|
||||
draggable={false}
|
||||
className="creation-landing__feature-art"
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
<span className="creation-landing__feature-copy">
|
||||
<h3>{feature.title}</h3>
|
||||
<p>{feature.description}</p>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -552,7 +570,13 @@ export function CreationLandingView({
|
||||
disabled={isCreatingProject}
|
||||
>
|
||||
<span>
|
||||
<Plus className="h-5 w-5" />
|
||||
<img
|
||||
src={CREATION_HOME_ASSETS.projectNew}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
draggable={false}
|
||||
className="creation-landing__project-new-art"
|
||||
/>
|
||||
</span>
|
||||
新建项目
|
||||
</button>
|
||||
|
||||
@@ -128,4 +128,40 @@ describe('ImageCanvasAssetRowView', () => {
|
||||
screen.getByLabelText('素材账号素材A上传进度').getAttribute('value'),
|
||||
).toBe('42');
|
||||
});
|
||||
|
||||
it('uses icon thumbnails for audio assets instead of rendering media as images', () => {
|
||||
renderAssetRow({
|
||||
asset: createAsset({
|
||||
label: '胜利音效.mp3',
|
||||
src: '/generated-character-drafts/editor/asset-library/audio/胜利音效.mp3',
|
||||
mediaType: 'audio',
|
||||
}),
|
||||
});
|
||||
|
||||
expect(screen.getByText('音频')).toBeTruthy();
|
||||
expect(screen.queryByRole('img', { name: '素材:胜利音效.mp3' })).toBeNull();
|
||||
expect(
|
||||
document.querySelector('.image-canvas-editor__asset-media-overlay'),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
document.querySelector('.image-canvas-editor__media-preview--audio'),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('uses a stable visual preview for video asset cards without image thumbnails', () => {
|
||||
renderAssetRow({
|
||||
asset: createAsset({
|
||||
label: '生成视频.mp4',
|
||||
src: '/generated-character-drafts/editor/asset-library/video/demo.mp4',
|
||||
mediaType: 'video',
|
||||
}),
|
||||
});
|
||||
|
||||
expect(screen.getByText('视频')).toBeTruthy();
|
||||
expect(screen.queryByRole('img', { name: '素材:生成视频.mp4' })).toBeNull();
|
||||
expect(
|
||||
document.querySelector('.image-canvas-editor__media-preview--video')
|
||||
?.className,
|
||||
).toContain('image-canvas-editor__media-preview--variant-');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Check, Pencil, Trash2, X } from 'lucide-react';
|
||||
import { Check, Music2, Pencil, Trash2, Video, X } from 'lucide-react';
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
|
||||
import { PlatformTextField } from '../common/PlatformTextField';
|
||||
@@ -16,6 +16,10 @@ import type {
|
||||
AssetPointerDragState,
|
||||
EditorAsset,
|
||||
} from './ImageCanvasEditorTypes';
|
||||
import {
|
||||
getCanvasMediaPreviewClassName,
|
||||
getCanvasMediaPreviewMarker,
|
||||
} from './ImageCanvasMediaModel';
|
||||
import type { UploadFilesOptions } from './ImageCanvasAssetLibraryPanelView';
|
||||
|
||||
export type ImageCanvasAssetRowViewProps = {
|
||||
@@ -65,6 +69,29 @@ export function ImageCanvasAssetRowView({
|
||||
const isUploadingAsset = asset.uploadStatus === 'uploading';
|
||||
const isFailedUpload = asset.uploadStatus === 'failed';
|
||||
const uploadProgress = clamp(asset.uploadProgress ?? 0, 0, 100);
|
||||
const mediaPreviewMarker = getCanvasMediaPreviewMarker(asset);
|
||||
const mediaPreviewClassName =
|
||||
getCanvasMediaPreviewClassName(mediaPreviewMarker);
|
||||
const mediaDetail =
|
||||
asset.mediaType === 'audio'
|
||||
? '音频'
|
||||
: asset.mediaType === 'video'
|
||||
? '视频'
|
||||
: `${asset.width} x ${asset.height}`;
|
||||
const mediaOverlay =
|
||||
asset.mediaType === 'audio' ? (
|
||||
<div
|
||||
className={`image-canvas-editor__asset-media-overlay ${mediaPreviewClassName}`}
|
||||
>
|
||||
<Music2 className="h-5 w-5" />
|
||||
</div>
|
||||
) : asset.mediaType === 'video' ? (
|
||||
<div
|
||||
className={`image-canvas-editor__asset-media-overlay ${mediaPreviewClassName}`}
|
||||
>
|
||||
<Video className="h-5 w-5" />
|
||||
</div>
|
||||
) : undefined;
|
||||
const titleNode = isRenaming ? (
|
||||
<PlatformTextField
|
||||
aria-label={`重命名素材${asset.label}`}
|
||||
@@ -134,8 +161,7 @@ export function ImageCanvasAssetRowView({
|
||||
<div data-asset-id={asset.id}>
|
||||
<SidebarMediaItem
|
||||
title={asset.label}
|
||||
detail={`${asset.width} x ${asset.height}`}
|
||||
imageSrc={asset.src}
|
||||
detail={mediaDetail}
|
||||
imageAlt={`素材:${asset.label}`}
|
||||
primaryLabel={
|
||||
isUploadingAsset
|
||||
@@ -172,6 +198,11 @@ export function ImageCanvasAssetRowView({
|
||||
primaryClassName="image-canvas-editor__asset-button"
|
||||
thumbnailClassName="image-canvas-editor__asset-thumb"
|
||||
metaClassName="image-canvas-editor__asset-meta"
|
||||
imageSrc={
|
||||
asset.mediaType === 'image'
|
||||
? asset.src
|
||||
: (asset.thumbnailSrc ?? '')
|
||||
}
|
||||
titleNode={
|
||||
isUploadingAsset || isFailedUpload ? <span>{asset.label}</span> : titleNode
|
||||
}
|
||||
@@ -182,7 +213,9 @@ export function ImageCanvasAssetRowView({
|
||||
<div className="image-canvas-editor__asset-upload-overlay">
|
||||
<span>上传中</span>
|
||||
</div>
|
||||
) : undefined
|
||||
) : (
|
||||
mediaOverlay
|
||||
)
|
||||
}
|
||||
footerNode={
|
||||
isUploadingAsset || isFailedUpload ? (
|
||||
|
||||
@@ -29,12 +29,10 @@ function BasicGenerationHarness({
|
||||
initialDialog = createDialog(),
|
||||
onRequestUpload = vi.fn(),
|
||||
onSubmit = vi.fn(),
|
||||
onClose = vi.fn(),
|
||||
}: {
|
||||
initialDialog?: GenerateDialogState;
|
||||
onRequestUpload?: (target: UploadTarget) => void;
|
||||
onSubmit?: (dialog: GenerateDialogState) => void;
|
||||
onClose?: () => void;
|
||||
}) {
|
||||
const [dialog, setDialog] = useState<GenerateDialogState | null>(
|
||||
initialDialog,
|
||||
@@ -58,7 +56,6 @@ function BasicGenerationHarness({
|
||||
onRequestUpload={onRequestUpload}
|
||||
onToggleReferenceMenu={() => setIsReferenceMenuOpen((open) => !open)}
|
||||
onSubmit={onSubmit}
|
||||
onClose={onClose}
|
||||
/>
|
||||
<output aria-label="当前提示词">{dialog.prompt}</output>
|
||||
<output aria-label="当前比例">{dialog.aspectRatio}</output>
|
||||
@@ -206,12 +203,11 @@ describe('ImageCanvasBasicGenerationComposerView', () => {
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('closes through its interface', () => {
|
||||
const closeComposer = vi.fn();
|
||||
render(<BasicGenerationHarness onClose={closeComposer} />);
|
||||
it('does not render a standalone close button', () => {
|
||||
render(<BasicGenerationHarness />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '关闭生成图片' }));
|
||||
|
||||
expect(closeComposer).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
screen.queryByRole('button', { name: '关闭生成图片' }),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ImageIcon, X } from 'lucide-react';
|
||||
import { ImageIcon } from 'lucide-react';
|
||||
import {
|
||||
type CSSProperties,
|
||||
type Dispatch,
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
} from '../common/PlatformFloatingMenu';
|
||||
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
|
||||
import { PlatformTextField } from '../common/PlatformTextField';
|
||||
import { EditorIconButton } from './ImageCanvasEditorPrimitives';
|
||||
import type {
|
||||
CharacterReferenceImage,
|
||||
GenerateDialogState,
|
||||
@@ -42,7 +41,6 @@ type ImageCanvasBasicGenerationComposerViewProps = {
|
||||
onToggleReferenceMenu?: () => void;
|
||||
onRememberImageModel?: (model: string) => void;
|
||||
onSubmit: (dialog: GenerateDialogState) => void;
|
||||
onClose: () => void;
|
||||
};
|
||||
function resetFailedDialogStatus(dialog: GenerateDialogState) {
|
||||
return {
|
||||
@@ -90,7 +88,6 @@ export function ImageCanvasBasicGenerationComposerView({
|
||||
onToggleReferenceMenu,
|
||||
onRememberImageModel = () => {},
|
||||
onSubmit,
|
||||
onClose,
|
||||
}: ImageCanvasBasicGenerationComposerViewProps) {
|
||||
const references = dialog.generationReferences ?? [];
|
||||
useImageCanvasFloatingOptionDismiss({
|
||||
@@ -203,14 +200,6 @@ export function ImageCanvasBasicGenerationComposerView({
|
||||
{dialog.errorMessage}
|
||||
</PlatformStatusMessage>
|
||||
) : null}
|
||||
<EditorIconButton
|
||||
className="image-canvas-editor__generation-close"
|
||||
label="关闭生成图片"
|
||||
icon={X}
|
||||
variant="surfaceFloating"
|
||||
disabled={dialog.status === 'generating'}
|
||||
onClick={onClose}
|
||||
/>
|
||||
</form>
|
||||
{isGenerationReferenceMenuOpen && generationReferenceButtonRef
|
||||
? renderEditorPortal(
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
within,
|
||||
} from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
ApiClientError,
|
||||
@@ -38,6 +38,7 @@ const loadEditorProjectMock = vi.hoisted(() => vi.fn());
|
||||
const loadOrCreateRecentEditorProjectMock = vi.hoisted(() => vi.fn());
|
||||
const renameEditorProjectMock = vi.hoisted(() => vi.fn());
|
||||
const saveEditorProjectLayoutMock = vi.hoisted(() => vi.fn());
|
||||
const uploadEditorMediaAssetFileMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('../../services/image-editor/editorProjectClient', async () => {
|
||||
const actual = await vi.importActual<
|
||||
@@ -64,6 +65,49 @@ vi.mock('../../services/image-editor/editorProjectClient', async () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../services/image-editor/editorMediaAssetUploadClient', () => ({
|
||||
uploadEditorMediaAssetFile: uploadEditorMediaAssetFileMock,
|
||||
}));
|
||||
|
||||
function createSizedPngFile(name: string, width: number, height: number) {
|
||||
const bytes = new Uint8Array([
|
||||
0x89,
|
||||
0x50,
|
||||
0x4e,
|
||||
0x47,
|
||||
0x0d,
|
||||
0x0a,
|
||||
0x1a,
|
||||
0x0a,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
13,
|
||||
0x49,
|
||||
0x48,
|
||||
0x44,
|
||||
0x52,
|
||||
(width >> 24) & 0xff,
|
||||
(width >> 16) & 0xff,
|
||||
(width >> 8) & 0xff,
|
||||
width & 0xff,
|
||||
(height >> 24) & 0xff,
|
||||
(height >> 16) & 0xff,
|
||||
(height >> 8) & 0xff,
|
||||
height & 0xff,
|
||||
8,
|
||||
6,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
]);
|
||||
return new File([bytes], name, { type: 'image/png' });
|
||||
}
|
||||
|
||||
describe('ImageCanvasEditorView asset library integration', () => {
|
||||
setupImageCanvasEditorViewTestLifecycle({
|
||||
generateEditorImageMock,
|
||||
@@ -84,6 +128,17 @@ describe('ImageCanvasEditorView asset library integration', () => {
|
||||
saveEditorProjectLayoutMock,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
uploadEditorMediaAssetFileMock.mockImplementation(
|
||||
async (file: File, mediaType: 'video' | 'audio') => ({
|
||||
src: `https://signed.example.com/editor/${mediaType}/${file.name}`,
|
||||
legacyPublicPath: `/generated-character-drafts/editor/asset-library/${mediaType}/${file.name}`,
|
||||
objectKey: `generated-character-drafts/editor/asset-library/${mediaType}/${file.name}`,
|
||||
assetObjectId: `assetobj-editor-${mediaType}`,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps only one default asset folder when the persisted library returns duplicated defaults', async () => {
|
||||
loadEditorAssetLibraryMock.mockResolvedValueOnce({
|
||||
folders: [
|
||||
@@ -202,7 +257,7 @@ describe('ImageCanvasEditorView asset library integration', () => {
|
||||
await user.type(folderNameInput, '角色上传');
|
||||
await user.click(screen.getByRole('button', { name: '保存素材文件夹' }));
|
||||
|
||||
const uploadInput = screen.getByLabelText('上传图片文件');
|
||||
const uploadInput = screen.getByLabelText('上传媒体文件');
|
||||
await user.click(screen.getByRole('button', { name: '上传到角色上传' }));
|
||||
await userEvent.upload(
|
||||
uploadInput,
|
||||
@@ -346,7 +401,7 @@ describe('ImageCanvasEditorView asset library integration', () => {
|
||||
it('uploads multiple files as account-level assets without adding canvas layers', async () => {
|
||||
render(<ImageCanvasEditorView />);
|
||||
|
||||
await userEvent.upload(screen.getByLabelText('上传图片文件'), [
|
||||
await userEvent.upload(screen.getByLabelText('上传媒体文件'), [
|
||||
new File(['image-a'], '第一张.png', { type: 'image/png' }),
|
||||
new File(['image-b'], '第二张.png', { type: 'image/png' }),
|
||||
]);
|
||||
@@ -364,6 +419,106 @@ describe('ImageCanvasEditorView asset library integration', () => {
|
||||
expect(screen.queryByAltText('画布图片:第二张.png')).toBeNull();
|
||||
});
|
||||
|
||||
it('adds bottom toolbar uploads to the canvas using the image resolution', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ImageCanvasEditorView />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '上传工具' }));
|
||||
await userEvent.upload(
|
||||
screen.getByLabelText('上传媒体文件'),
|
||||
createSizedPngFile('底栏上传.png', 1536, 1024),
|
||||
);
|
||||
|
||||
expect(await screen.findByAltText('画布图片:底栏上传.png')).toBeTruthy();
|
||||
await waitFor(() => {
|
||||
expect(createEditorAssetMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
label: '底栏上传.png',
|
||||
width: 1536,
|
||||
height: 1024,
|
||||
}),
|
||||
);
|
||||
});
|
||||
const layerButton = screen.getByRole('button', {
|
||||
name: '选择底栏上传.png',
|
||||
});
|
||||
expect(layerButton.getAttribute('style')).toContain('width: 1536px');
|
||||
expect(layerButton.getAttribute('style')).toContain('height: 1024px');
|
||||
});
|
||||
|
||||
it('uploads MP3 and MP4 files through OSS before adding them to the asset library', async () => {
|
||||
const createdMediaElements: HTMLMediaElement[] = [];
|
||||
const originalCreateElement = document.createElement.bind(document);
|
||||
vi.spyOn(document, 'createElement').mockImplementation((tagName, options) => {
|
||||
const element = originalCreateElement(tagName, options);
|
||||
if (tagName === 'video' || tagName === 'audio') {
|
||||
createdMediaElements.push(element as HTMLMediaElement);
|
||||
}
|
||||
return element;
|
||||
});
|
||||
Object.defineProperty(URL, 'createObjectURL', {
|
||||
configurable: true,
|
||||
value: vi.fn(() => 'blob:editor-media'),
|
||||
});
|
||||
Object.defineProperty(URL, 'revokeObjectURL', {
|
||||
configurable: true,
|
||||
value: vi.fn(),
|
||||
});
|
||||
Object.defineProperty(HTMLMediaElement.prototype, 'duration', {
|
||||
configurable: true,
|
||||
value: 6,
|
||||
});
|
||||
vi.spyOn(HTMLMediaElement.prototype, 'load').mockImplementation(
|
||||
() => undefined,
|
||||
);
|
||||
render(<ImageCanvasEditorView />);
|
||||
|
||||
await userEvent.upload(screen.getByLabelText('上传媒体文件'), [
|
||||
new File(['audio'], '胜利音效.mp3', { type: 'audio/mpeg' }),
|
||||
new File(['video'], '开场动画.mp4', { type: 'video/mp4' }),
|
||||
]);
|
||||
act(() => {
|
||||
createdMediaElements.forEach((element) =>
|
||||
element.onloadedmetadata?.(new Event('loadedmetadata')),
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(uploadEditorMediaAssetFileMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: '胜利音效.mp3' }),
|
||||
'audio',
|
||||
);
|
||||
expect(uploadEditorMediaAssetFileMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: '开场动画.mp4' }),
|
||||
'video',
|
||||
);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: '添加胜利音效.mp3' })).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: '添加开场动画.mp4' })).toBeTruthy();
|
||||
});
|
||||
expect(createEditorAssetMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
label: '胜利音效.mp3',
|
||||
imageSrc:
|
||||
'/generated-character-drafts/editor/asset-library/audio/胜利音效.mp3',
|
||||
objectKey:
|
||||
'generated-character-drafts/editor/asset-library/audio/胜利音效.mp3',
|
||||
assetObjectId: 'assetobj-editor-audio',
|
||||
}),
|
||||
);
|
||||
expect(createEditorAssetMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
label: '开场动画.mp4',
|
||||
imageSrc:
|
||||
'/generated-character-drafts/editor/asset-library/video/开场动画.mp4',
|
||||
objectKey:
|
||||
'generated-character-drafts/editor/asset-library/video/开场动画.mp4',
|
||||
assetObjectId: 'assetobj-editor-video',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('opens login before uploading assets while logged out and resumes after login', async () => {
|
||||
const openLoginModal = vi.fn();
|
||||
const authValue = createAuthValue({ openLoginModal });
|
||||
@@ -374,7 +529,7 @@ describe('ImageCanvasEditorView asset library integration', () => {
|
||||
</AuthUiContext.Provider>,
|
||||
);
|
||||
|
||||
await userEvent.upload(screen.getByLabelText('上传图片文件'), [
|
||||
await userEvent.upload(screen.getByLabelText('上传媒体文件'), [
|
||||
new File(['image'], '登录后上传.png', { type: 'image/png' }),
|
||||
]);
|
||||
|
||||
@@ -429,7 +584,7 @@ describe('ImageCanvasEditorView asset library integration', () => {
|
||||
createEditorAssetMock.mockReturnValueOnce(deferredAsset.promise);
|
||||
render(<ImageCanvasEditorView />);
|
||||
|
||||
await userEvent.upload(screen.getByLabelText('上传图片文件'), [
|
||||
await userEvent.upload(screen.getByLabelText('上传媒体文件'), [
|
||||
new File(['image'], '素材上传进度.png', { type: 'image/png' }),
|
||||
]);
|
||||
|
||||
@@ -489,7 +644,7 @@ describe('ImageCanvasEditorView asset library integration', () => {
|
||||
</AuthUiContext.Provider>,
|
||||
);
|
||||
|
||||
await userEvent.upload(screen.getByLabelText('上传图片文件'), [
|
||||
await userEvent.upload(screen.getByLabelText('上传媒体文件'), [
|
||||
new File(['image'], '过期登录.png', { type: 'image/png' }),
|
||||
]);
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -50,6 +50,58 @@ describe('ImageCanvasEditorModel', () => {
|
||||
expect(library.folders[0]?.id).toBe('project');
|
||||
});
|
||||
|
||||
it('infers MP3 and MP4 asset media types from persisted paths', () => {
|
||||
const library = normalizeAssetLibrary({
|
||||
folders: [
|
||||
{
|
||||
folderId: 'project',
|
||||
label: '项目素材',
|
||||
sortOrder: 0,
|
||||
collapsed: false,
|
||||
systemDefault: true,
|
||||
},
|
||||
],
|
||||
assets: [
|
||||
{
|
||||
assetId: 'asset-audio',
|
||||
folderId: 'project',
|
||||
label: '胜利音效.mp3',
|
||||
imageSrc: '/generated-character-drafts/editor/asset-library/audio/胜利音效.mp3',
|
||||
width: 420,
|
||||
height: 120,
|
||||
sourceType: 'uploaded',
|
||||
objectKey:
|
||||
'generated-character-drafts/editor/asset-library/audio/胜利音效.mp3',
|
||||
},
|
||||
{
|
||||
assetId: 'asset-video',
|
||||
folderId: 'project',
|
||||
label: '开场动画.mp4',
|
||||
imageSrc: '/generated-character-drafts/editor/asset-library/video/开场动画.mp4',
|
||||
width: 560,
|
||||
height: 315,
|
||||
sourceType: 'uploaded',
|
||||
objectKey:
|
||||
'generated-character-drafts/editor/asset-library/video/开场动画.mp4',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(library.assets[0]).toMatchObject({ mediaType: 'audio' });
|
||||
expect(library.assets[1]).toMatchObject({ mediaType: 'video' });
|
||||
expect(
|
||||
createLayerFromAsset(
|
||||
library.assets[0]!,
|
||||
1,
|
||||
{ x: 0, y: 0, scale: 1 },
|
||||
{ x: 300, y: 200 },
|
||||
),
|
||||
).toMatchObject({
|
||||
mediaType: 'audio',
|
||||
assetKind: 'sound-effect',
|
||||
});
|
||||
});
|
||||
|
||||
it('creates a layer from an account asset at the requested screen point', () => {
|
||||
const asset: EditorAsset = {
|
||||
id: 'asset-1',
|
||||
@@ -112,10 +164,24 @@ describe('ImageCanvasEditorModel', () => {
|
||||
|
||||
const snapshot = serializeLayer(layer);
|
||||
expect(snapshot).not.toHaveProperty('src');
|
||||
expect(snapshot).not.toHaveProperty('assetKind');
|
||||
expect(snapshot).not.toHaveProperty('generationInputs');
|
||||
|
||||
const hydrated = hydrateLayer(
|
||||
snapshot,
|
||||
new Map([['resource-generated', { imageSrc: '/read/generated.png' }]]),
|
||||
new Map([
|
||||
[
|
||||
'resource-generated',
|
||||
{
|
||||
imageSrc: '/read/generated.png',
|
||||
assetKind: 'character',
|
||||
generationInputs: {
|
||||
fields: [{ title: '角色设定', value: '骑士' }],
|
||||
references: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
]),
|
||||
);
|
||||
|
||||
expect(hydrated).toMatchObject({
|
||||
@@ -126,6 +192,44 @@ describe('ImageCanvasEditorModel', () => {
|
||||
objectKey: 'generated/object.png',
|
||||
locked: true,
|
||||
});
|
||||
expect(hydrated?.generationInputs?.fields[0]?.value).toBe('骑士');
|
||||
});
|
||||
|
||||
it('hydrates object metadata from project resources when the saved layer is lean', () => {
|
||||
const hydrated = hydrateLayer(
|
||||
{
|
||||
layerId: 'layer-uploaded',
|
||||
resourceId: 'resource-uploaded',
|
||||
title: '上传图',
|
||||
x: 10,
|
||||
y: 20,
|
||||
width: 640,
|
||||
height: 360,
|
||||
originalWidth: 640,
|
||||
originalHeight: 360,
|
||||
zIndex: 2,
|
||||
sourceType: 'uploaded',
|
||||
},
|
||||
new Map([
|
||||
[
|
||||
'resource-uploaded',
|
||||
{
|
||||
imageSrc: '/generated-character-drafts/editor/asset-library/image.png',
|
||||
objectKey: 'generated-character-drafts/editor/asset-library/image.png',
|
||||
assetObjectId: 'asset-object-uploaded',
|
||||
sourceResourceId: 'resource-source',
|
||||
},
|
||||
],
|
||||
]),
|
||||
);
|
||||
|
||||
expect(hydrated).toMatchObject({
|
||||
id: 'layer-uploaded',
|
||||
src: '/generated-character-drafts/editor/asset-library/image.png',
|
||||
objectKey: 'generated-character-drafts/editor/asset-library/image.png',
|
||||
assetObjectId: 'asset-object-uploaded',
|
||||
sourceResourceId: 'resource-source',
|
||||
});
|
||||
});
|
||||
|
||||
it('hydrates manually selected publication material tags from saved layout', () => {
|
||||
@@ -166,30 +270,30 @@ describe('ImageCanvasEditorModel', () => {
|
||||
originalWidth: 420,
|
||||
originalHeight: 120,
|
||||
zIndex: 5,
|
||||
sourceType: 'generated',
|
||||
mediaType: 'audio',
|
||||
assetKind: 'sound-effect',
|
||||
durationSeconds: 2.4,
|
||||
},
|
||||
new Map([
|
||||
[
|
||||
'resource-audio',
|
||||
{
|
||||
imageSrc: '/generated-character-drafts/editor-audios/sfx.mp3',
|
||||
durationSeconds: 2.4,
|
||||
},
|
||||
],
|
||||
]),
|
||||
);
|
||||
sourceType: 'generated',
|
||||
mediaType: 'audio',
|
||||
assetKind: 'sound-effect',
|
||||
durationSeconds: 2.4,
|
||||
},
|
||||
new Map([
|
||||
[
|
||||
'resource-audio',
|
||||
{
|
||||
imageSrc: '/generated-character-drafts/editor-audios/sfx.mp3',
|
||||
durationSeconds: 2.4,
|
||||
},
|
||||
],
|
||||
]),
|
||||
);
|
||||
|
||||
expect(hydrated).toMatchObject({
|
||||
id: 'layer-audio',
|
||||
src: '/generated-character-drafts/editor-audios/sfx.mp3',
|
||||
mediaType: 'audio',
|
||||
assetKind: 'sound-effect',
|
||||
durationSeconds: 2.4,
|
||||
src: '/generated-character-drafts/editor-audios/sfx.mp3',
|
||||
mediaType: 'audio',
|
||||
assetKind: 'sound-effect',
|
||||
durationSeconds: 2.4,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('restores audio generation dialogs from saved layout', () => {
|
||||
const dialog: CanvasGenerationDialogState = {
|
||||
@@ -227,6 +331,71 @@ describe('ImageCanvasEditorModel', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('restores publication material generator inputs from saved layout', () => {
|
||||
const dialog: CanvasGenerationDialogState = {
|
||||
id: 'generation-dialog-publication',
|
||||
mode: 'publication',
|
||||
prompt: '马戏团午夜惊魂|非对称对抗|找到钥匙逃离',
|
||||
status: 'idle',
|
||||
composerOpen: true,
|
||||
publicationWorkflowId: 'publication-promo-poster',
|
||||
publicationGameInfo: {
|
||||
gameName: '马戏团午夜惊魂',
|
||||
gameCategories: '非对称对抗',
|
||||
gameDescription: '找到钥匙,开门逃离马戏团',
|
||||
},
|
||||
publicationReferences: [
|
||||
{
|
||||
id: 'publication-reference-1',
|
||||
label: '首图参考',
|
||||
src: '/generated-character-drafts/editor/reference.png',
|
||||
objectKey: 'generated-character-drafts/editor/reference.png',
|
||||
assetObjectId: 'asset-object-publication-reference',
|
||||
},
|
||||
],
|
||||
imageModel: 'gpt-image-2',
|
||||
aspectRatio: '16:9',
|
||||
imageSize: '2K',
|
||||
generatedLayerId: 'layer-publication',
|
||||
placeholder: {
|
||||
x: 100,
|
||||
y: 120,
|
||||
width: 1280,
|
||||
height: 720,
|
||||
originalWidth: 1280,
|
||||
originalHeight: 720,
|
||||
},
|
||||
};
|
||||
|
||||
const { generationDialogs } = splitCanvasLayoutItems(
|
||||
serializeCanvasLayout({
|
||||
layers: [],
|
||||
canvasGenerationDialogs: [dialog],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(generationDialogs).toHaveLength(1);
|
||||
expect(generationDialogs[0]).toMatchObject({
|
||||
id: 'generation-dialog-publication',
|
||||
mode: 'publication',
|
||||
publicationWorkflowId: 'publication-promo-poster',
|
||||
publicationGameInfo: {
|
||||
gameName: '马戏团午夜惊魂',
|
||||
gameCategories: '非对称对抗',
|
||||
gameDescription: '找到钥匙,开门逃离马戏团',
|
||||
},
|
||||
imageModel: 'gpt-image-2',
|
||||
generatedLayerId: 'layer-publication',
|
||||
});
|
||||
expect(generationDialogs[0]?.publicationReferences?.[0]).toMatchObject({
|
||||
id: 'publication-reference-1',
|
||||
label: '首图参考',
|
||||
src: '/generated-character-drafts/editor/reference.png',
|
||||
objectKey: 'generated-character-drafts/editor/reference.png',
|
||||
assetObjectId: 'asset-object-publication-reference',
|
||||
});
|
||||
});
|
||||
|
||||
it('serializes generation dialogs beside layers and splits them on load', () => {
|
||||
const layer: CanvasLayer = {
|
||||
id: 'layer-generated',
|
||||
|
||||
@@ -114,12 +114,19 @@ export function createLayerFromAsset(
|
||||
const worldCenterX = (safeScreenCenter.x - viewport.x) / safeScale;
|
||||
const worldCenterY = (safeScreenCenter.y - viewport.y) / safeScale;
|
||||
const offset = index * 34;
|
||||
const assetKind: CanvasAssetKind | undefined =
|
||||
asset.mediaType === 'video'
|
||||
? 'video'
|
||||
: asset.mediaType === 'audio'
|
||||
? inferAudioAssetKindFromLabel(asset.label)
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
id: `layer-${asset.id}-${index}`,
|
||||
resourceId: `local-resource-${asset.id}-${index}`,
|
||||
title: asset.label,
|
||||
src: asset.src,
|
||||
mediaType: asset.mediaType,
|
||||
x: worldCenterX - width / 2 + offset,
|
||||
y: worldCenterY - height / 2 + offset,
|
||||
width,
|
||||
@@ -137,6 +144,8 @@ export function createLayerFromAsset(
|
||||
assetObjectId: asset.assetObjectId,
|
||||
durationSeconds: asset.durationSeconds,
|
||||
sourceAssetId: asset.id,
|
||||
assetKind: asset.assetKind ?? assetKind,
|
||||
generationInputs: asset.generationInputs,
|
||||
} satisfies CanvasLayer;
|
||||
}
|
||||
|
||||
@@ -167,8 +176,6 @@ export function serializeLayer(
|
||||
sourceResourceId: layer.sourceResourceId,
|
||||
sourceAssetId: layer.sourceAssetId,
|
||||
groupId: layer.groupId,
|
||||
assetKind: layer.assetKind,
|
||||
generationInputs: layer.generationInputs,
|
||||
hidden: layer.hidden,
|
||||
locked: layer.locked,
|
||||
flipX: layer.flipX,
|
||||
@@ -288,6 +295,15 @@ export function hydrateCanvasGenerationDialog(
|
||||
typeof description === 'string',
|
||||
)
|
||||
: undefined,
|
||||
publicationWorkflowId: hydratePublicationWorkflowId(
|
||||
snapshot.publicationWorkflowId,
|
||||
),
|
||||
publicationGameInfo: hydratePublicationGameInfo(
|
||||
snapshot.publicationGameInfo,
|
||||
),
|
||||
publicationReferences: hydrateCharacterReferences(
|
||||
snapshot.publicationReferences,
|
||||
),
|
||||
uiDesignSpecReference: hydrateCharacterReference(
|
||||
snapshot.uiDesignSpecReference,
|
||||
),
|
||||
@@ -356,13 +372,14 @@ export function hydrateCanvasGenerationDialog(
|
||||
|
||||
export function hydrateLayer(
|
||||
snapshot: EditorProjectLayerSnapshot,
|
||||
resourcesById: Map<string, { imageSrc: string }>,
|
||||
resourcesById: Map<string, CanvasLayerResourceMetadata>,
|
||||
): CanvasLayer | null {
|
||||
const resourceId =
|
||||
typeof snapshot.resourceId === 'string' ? snapshot.resourceId : '';
|
||||
const layerId = typeof snapshot.layerId === 'string' ? snapshot.layerId : '';
|
||||
const resource = resourcesById.get(resourceId);
|
||||
const snapshotSrc = typeof snapshot.src === 'string' ? snapshot.src : '';
|
||||
const src = snapshotSrc || resourcesById.get(resourceId)?.imageSrc || '';
|
||||
const src = snapshotSrc || resource?.imageSrc || '';
|
||||
const title =
|
||||
typeof snapshot.title === 'string' ? snapshot.title : '画布图片';
|
||||
if (!resourceId || !layerId || !src) {
|
||||
@@ -401,20 +418,23 @@ export function hydrateLayer(
|
||||
model: stringOrNull(snapshot.model),
|
||||
provider: stringOrNull(snapshot.provider),
|
||||
taskId: stringOrNull(snapshot.taskId),
|
||||
objectKey: stringOrNull(snapshot.objectKey),
|
||||
assetObjectId: stringOrNull(snapshot.assetObjectId),
|
||||
objectKey: stringOrNull(snapshot.objectKey) ?? stringOrNull(resource?.objectKey),
|
||||
assetObjectId:
|
||||
stringOrNull(snapshot.assetObjectId) ?? stringOrNull(resource?.assetObjectId),
|
||||
durationSeconds:
|
||||
audioDurationOrNull(snapshot.durationSeconds) ??
|
||||
audioDurationOrNull(
|
||||
resourcesById.get(resourceId) as
|
||||
| ({ imageSrc: string } & { durationSeconds?: unknown })
|
||||
| undefined,
|
||||
),
|
||||
sourceResourceId: stringOrNull(snapshot.sourceResourceId),
|
||||
audioDurationOrNull(resource?.durationSeconds),
|
||||
sourceResourceId:
|
||||
stringOrNull(snapshot.sourceResourceId) ??
|
||||
stringOrNull(resource?.sourceResourceId),
|
||||
sourceAssetId: stringOrNull(snapshot.sourceAssetId),
|
||||
groupId: stringOrNull(snapshot.groupId),
|
||||
assetKind: canvasAssetKindOrNull(snapshot.assetKind),
|
||||
generationInputs: generationInputsOrNull(snapshot.generationInputs),
|
||||
assetKind:
|
||||
canvasAssetKindOrNull(resource?.assetKind) ??
|
||||
canvasAssetKindOrNull(snapshot.assetKind),
|
||||
generationInputs:
|
||||
generationInputsOrNull(resource?.generationInputs) ??
|
||||
generationInputsOrNull(snapshot.generationInputs),
|
||||
hidden: booleanFromSnapshot(snapshot.hidden),
|
||||
locked: booleanFromSnapshot(snapshot.locked),
|
||||
flipX: booleanFromSnapshot(snapshot.flipX),
|
||||
@@ -436,28 +456,68 @@ export function mapAssetLibrarySnapshot(
|
||||
systemDefault: folder.systemDefault,
|
||||
persisted: true,
|
||||
})),
|
||||
assets: library.assets.map((asset) => ({
|
||||
id: asset.assetId,
|
||||
label: asset.label,
|
||||
src: asset.imageSrc,
|
||||
width: asset.width,
|
||||
height: asset.height,
|
||||
folderId: asset.folderId,
|
||||
sourceKind: 'uploaded',
|
||||
sourceType: asset.sourceType,
|
||||
persisted: true,
|
||||
prompt: asset.prompt ?? undefined,
|
||||
actualPrompt: asset.actualPrompt ?? undefined,
|
||||
model: asset.model ?? undefined,
|
||||
provider: asset.provider ?? undefined,
|
||||
taskId: asset.taskId ?? undefined,
|
||||
objectKey: asset.objectKey ?? undefined,
|
||||
assetObjectId: asset.assetObjectId ?? undefined,
|
||||
durationSeconds: asset.durationSeconds ?? undefined,
|
||||
})),
|
||||
assets: library.assets.map((asset) => {
|
||||
const mediaType = inferEditorAssetMediaType(
|
||||
asset.imageSrc,
|
||||
asset.objectKey ?? undefined,
|
||||
);
|
||||
return {
|
||||
id: asset.assetId,
|
||||
label: asset.label,
|
||||
src: asset.imageSrc,
|
||||
mediaType,
|
||||
width: asset.width,
|
||||
height: asset.height,
|
||||
folderId: asset.folderId,
|
||||
sourceKind: 'uploaded',
|
||||
sourceType: asset.sourceType,
|
||||
persisted: true,
|
||||
prompt: asset.prompt ?? undefined,
|
||||
actualPrompt: asset.actualPrompt ?? undefined,
|
||||
model: asset.model ?? undefined,
|
||||
provider: asset.provider ?? undefined,
|
||||
taskId: asset.taskId ?? undefined,
|
||||
objectKey: asset.objectKey ?? undefined,
|
||||
assetObjectId: asset.assetObjectId ?? undefined,
|
||||
assetKind: canvasAssetKindOrNull(asset.assetKind),
|
||||
generationInputs: generationInputsOrNull(asset.generationInputs),
|
||||
durationSeconds: asset.durationSeconds ?? undefined,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function inferEditorAssetMediaType(
|
||||
imageSrc: string,
|
||||
objectKey?: string | null,
|
||||
): CanvasMediaType {
|
||||
const source = `${objectKey ?? ''} ${imageSrc}`.toLowerCase();
|
||||
const sourceWithoutQuery = source.split(/[?#]/u)[0] ?? source;
|
||||
if (sourceWithoutQuery.includes('.mp4')) {
|
||||
return 'video';
|
||||
}
|
||||
if (sourceWithoutQuery.includes('.mp3')) {
|
||||
return 'audio';
|
||||
}
|
||||
return 'image';
|
||||
}
|
||||
|
||||
function inferAudioAssetKindFromLabel(label: string): CanvasAssetKind {
|
||||
return label.includes('背景音乐') || label.toLowerCase().includes('music')
|
||||
? 'background-music'
|
||||
: 'sound-effect';
|
||||
}
|
||||
|
||||
export type CanvasLayerResourceMetadata = {
|
||||
imageSrc: string;
|
||||
objectKey?: string | null;
|
||||
assetObjectId?: string | null;
|
||||
durationSeconds?: number | null;
|
||||
sourceResourceId?: string | null;
|
||||
assetKind?: string | null;
|
||||
generationInputs?: unknown;
|
||||
};
|
||||
|
||||
export function normalizeAssetLibrary(library: EditorAssetLibrarySnapshot) {
|
||||
const mapped = mapAssetLibrarySnapshot(library);
|
||||
let hasDefaultFolder = false;
|
||||
@@ -697,6 +757,7 @@ function isCanvasGenerationDialogMode(
|
||||
value === 'spec' ||
|
||||
value === 'character' ||
|
||||
value === 'icon' ||
|
||||
value === 'publication' ||
|
||||
value === 'ui-design' ||
|
||||
value === 'quick-edit' ||
|
||||
value === 'character-animation' ||
|
||||
@@ -723,6 +784,30 @@ function isSpecGenerationType(
|
||||
);
|
||||
}
|
||||
|
||||
function hydratePublicationWorkflowId(
|
||||
value: unknown,
|
||||
): CanvasGenerationDialogState['publicationWorkflowId'] {
|
||||
return value === 'publication-cover-image' ||
|
||||
value === 'publication-detail-gallery' ||
|
||||
value === 'publication-promo-poster'
|
||||
? value
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function hydratePublicationGameInfo(
|
||||
value: unknown,
|
||||
): CanvasGenerationDialogState['publicationGameInfo'] {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return undefined;
|
||||
}
|
||||
const snapshot = value as Record<string, unknown>;
|
||||
return {
|
||||
gameName: stringOrUndefined(snapshot.gameName) ?? '',
|
||||
gameCategories: stringOrUndefined(snapshot.gameCategories) ?? '',
|
||||
gameDescription: stringOrUndefined(snapshot.gameDescription) ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
function hydrateSpecFormValues(
|
||||
value: unknown,
|
||||
): CanvasGenerationDialogState['specValues'] {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type {
|
||||
EditorAssetSnapshot,
|
||||
EditorCharacterAnimationFrameCount,
|
||||
EditorCharacterAnimationGenerationResult,
|
||||
EditorCharacterAnimationRatio,
|
||||
@@ -28,6 +29,7 @@ export type EditorAsset = {
|
||||
label: string;
|
||||
src: string;
|
||||
mediaType?: CanvasMediaType;
|
||||
thumbnailSrc?: string | null;
|
||||
width: number;
|
||||
height: number;
|
||||
folderId: string;
|
||||
@@ -41,6 +43,8 @@ export type EditorAsset = {
|
||||
taskId?: string;
|
||||
objectKey?: string;
|
||||
assetObjectId?: string;
|
||||
assetKind?: CanvasAssetKind | null;
|
||||
generationInputs?: CanvasGenerationInputs | null;
|
||||
durationSeconds?: number;
|
||||
uploadStatus?: 'uploading' | 'failed';
|
||||
uploadProgress?: number;
|
||||
@@ -69,6 +73,7 @@ export type CanvasLayer = {
|
||||
title: string;
|
||||
src: string;
|
||||
mediaType?: CanvasMediaType;
|
||||
thumbnailSrc?: string | null;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
@@ -86,6 +91,7 @@ export type CanvasLayer = {
|
||||
assetObjectId?: string | null;
|
||||
sourceResourceId?: string | null;
|
||||
sourceAssetId?: string | null;
|
||||
generatedAssetSnapshot?: EditorAssetSnapshot | null;
|
||||
groupId?: string | null;
|
||||
assetKind?: CanvasAssetKind | null;
|
||||
generationInputs?: CanvasGenerationInputs | null;
|
||||
@@ -427,6 +433,7 @@ export type CanvasAssetExportMetadata = {
|
||||
layerId: string;
|
||||
title: string;
|
||||
file: string | null;
|
||||
mediaType?: CanvasMediaType | null;
|
||||
sourceType: CanvasSourceType;
|
||||
prompt?: string | null;
|
||||
actualPrompt?: string | null;
|
||||
|
||||
@@ -261,9 +261,14 @@ export function setupImageCanvasEditorViewTestLifecycle({
|
||||
folderId: input.folderId,
|
||||
label: input.label,
|
||||
imageSrc: input.imageSrc,
|
||||
objectKey: input.objectKey,
|
||||
assetObjectId: input.assetObjectId,
|
||||
width: input.width,
|
||||
height: input.height,
|
||||
sourceType: input.sourceType,
|
||||
assetKind: input.assetKind,
|
||||
generationInputs: input.generationInputs,
|
||||
durationSeconds: input.durationSeconds,
|
||||
}));
|
||||
createEditorAssetFolderMock.mockResolvedValue({
|
||||
folderId: 'folder-role-persisted',
|
||||
|
||||
@@ -582,7 +582,7 @@ describe('ImageCanvasEditorView', () => {
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: '下载按钮' }));
|
||||
|
||||
expect(downloadName).toBe('拼图素材.png');
|
||||
expect(downloadName).toBe('拼图素材.webp');
|
||||
expect(downloadHref).toContain('/creation-type-references/puzzle.webp');
|
||||
expect(screen.getByAltText('画布图片:拼图素材')).toBeTruthy();
|
||||
expect(screen.getByAltText('画布图片:大鱼素材')).toBeTruthy();
|
||||
|
||||
@@ -11,10 +11,15 @@ import {
|
||||
import {
|
||||
createEditorAsset,
|
||||
createEditorProjectResource,
|
||||
type EditorAssetSnapshot,
|
||||
} from '../../services/image-editor/editorProjectClient';
|
||||
import { useAuthUi } from '../auth/AuthUiContext';
|
||||
import { PlatformDangerConfirmDialog } from '../common/PlatformDangerConfirmDialog';
|
||||
import { resolveContextMenuPosition } from './ImageCanvasEditorModel';
|
||||
import {
|
||||
canvasAssetKindOrNull,
|
||||
generationInputsOrNull,
|
||||
resolveContextMenuPosition,
|
||||
} from './ImageCanvasEditorModel';
|
||||
import { ImageCanvasEditorShellView } from './ImageCanvasEditorShellView';
|
||||
import type {
|
||||
AssetPointerDragState,
|
||||
@@ -246,6 +251,7 @@ export function ImageCanvasEditorView() {
|
||||
updateCanvasGenerationDialogById,
|
||||
removeCanvasGenerationDialogById,
|
||||
hasCanvasGenerationDialogById,
|
||||
archiveActiveCanvasGenerationDialog,
|
||||
activateCanvasGenerationDialog,
|
||||
restoreCanvasGenerationDialogs,
|
||||
removeCanvasGenerationDialogsByLayerId,
|
||||
@@ -341,6 +347,9 @@ export function ImageCanvasEditorView() {
|
||||
model: layer.model,
|
||||
provider: layer.provider,
|
||||
taskId: layer.taskId,
|
||||
durationSeconds: layer.durationSeconds,
|
||||
assetKind: layer.assetKind,
|
||||
generationInputs: layer.generationInputs,
|
||||
})
|
||||
.then((asset) => {
|
||||
setAssets((currentAssets) => [
|
||||
@@ -351,6 +360,7 @@ export function ImageCanvasEditorView() {
|
||||
id: asset.assetId,
|
||||
label: asset.label,
|
||||
src: asset.imageSrc,
|
||||
mediaType: layer.mediaType,
|
||||
width: asset.width,
|
||||
height: asset.height,
|
||||
folderId: asset.folderId,
|
||||
@@ -364,6 +374,9 @@ export function ImageCanvasEditorView() {
|
||||
taskId: asset.taskId ?? undefined,
|
||||
objectKey: asset.objectKey ?? undefined,
|
||||
assetObjectId: asset.assetObjectId ?? undefined,
|
||||
durationSeconds: asset.durationSeconds ?? layer.durationSeconds,
|
||||
assetKind: canvasAssetKindOrNull(asset.assetKind),
|
||||
generationInputs: generationInputsOrNull(asset.generationInputs),
|
||||
},
|
||||
]);
|
||||
setAssetFolders((currentFolders) =>
|
||||
@@ -402,6 +415,55 @@ export function ImageCanvasEditorView() {
|
||||
},
|
||||
[activeUploadFolderId, openEditorLoginModal, setAssetFolders, setAssets],
|
||||
);
|
||||
const upsertGeneratedAsset = useCallback(
|
||||
(asset: EditorAssetSnapshot) => {
|
||||
setAssets((currentAssets) => [
|
||||
...currentAssets.filter(
|
||||
(currentAsset) => currentAsset.id !== asset.assetId,
|
||||
),
|
||||
{
|
||||
id: asset.assetId,
|
||||
label: asset.label,
|
||||
src: asset.imageSrc,
|
||||
width: asset.width,
|
||||
height: asset.height,
|
||||
folderId: asset.folderId,
|
||||
sourceKind: 'uploaded',
|
||||
sourceType: asset.sourceType,
|
||||
persisted: true,
|
||||
prompt: asset.prompt ?? undefined,
|
||||
actualPrompt: asset.actualPrompt ?? undefined,
|
||||
model: asset.model ?? undefined,
|
||||
provider: asset.provider ?? undefined,
|
||||
taskId: asset.taskId ?? undefined,
|
||||
objectKey: asset.objectKey ?? undefined,
|
||||
assetObjectId: asset.assetObjectId ?? undefined,
|
||||
durationSeconds: asset.durationSeconds ?? undefined,
|
||||
assetKind: canvasAssetKindOrNull(asset.assetKind),
|
||||
generationInputs: generationInputsOrNull(asset.generationInputs),
|
||||
},
|
||||
]);
|
||||
setAssetFolders((currentFolders) =>
|
||||
currentFolders.some((folder) => folder.id === asset.folderId)
|
||||
? currentFolders.map((folder) =>
|
||||
folder.id === asset.folderId
|
||||
? { ...folder, collapsed: false }
|
||||
: folder,
|
||||
)
|
||||
: [
|
||||
...currentFolders,
|
||||
{
|
||||
id: asset.folderId,
|
||||
label: '项目素材',
|
||||
collapsed: false,
|
||||
systemDefault: true,
|
||||
persisted: true,
|
||||
},
|
||||
],
|
||||
);
|
||||
},
|
||||
[setAssetFolders, setAssets],
|
||||
);
|
||||
const projectPersistenceRefs = useMemo(
|
||||
() => ({
|
||||
layersRef,
|
||||
@@ -530,6 +592,7 @@ export function ImageCanvasEditorView() {
|
||||
openCanvasGenerationDialog,
|
||||
updateCanvasGenerationDialogById,
|
||||
hasCanvasGenerationDialogById,
|
||||
archiveActiveCanvasGenerationDialog,
|
||||
removeCanvasGenerationDialogsByLayerId,
|
||||
getGeneratingDialogPlaceholder,
|
||||
appendCanvasLayersWithResources,
|
||||
@@ -543,9 +606,14 @@ export function ImageCanvasEditorView() {
|
||||
requestUpload,
|
||||
persistGeneratedAsset,
|
||||
persistUpdatedLayerResource,
|
||||
projectId,
|
||||
assetFolderId: activeUploadFolderId,
|
||||
upsertGeneratedAsset,
|
||||
});
|
||||
const {
|
||||
setQuickEditPanel,
|
||||
setCropExpandPanel,
|
||||
setCharacterAnimationPanel,
|
||||
setIsSpecMenuOpen,
|
||||
setIsGenerationReferenceMenuOpen,
|
||||
setIsCharacterSpecMenuOpen,
|
||||
@@ -586,6 +654,11 @@ export function ImageCanvasEditorView() {
|
||||
switchGenerationTool,
|
||||
} = generationSurface;
|
||||
setQuickEditPanelRef.current = setQuickEditPanel;
|
||||
const clearGenerationPanelsAfterBlur = useCallback(() => {
|
||||
setQuickEditPanel(null);
|
||||
setCropExpandPanel(null);
|
||||
setCharacterAnimationPanel(null);
|
||||
}, [setCharacterAnimationPanel, setCropExpandPanel, setQuickEditPanel]);
|
||||
const {
|
||||
selectedLayer,
|
||||
selectedToolbarStyle,
|
||||
@@ -607,6 +680,7 @@ export function ImageCanvasEditorView() {
|
||||
canvasSize,
|
||||
selectSingleLayer,
|
||||
hideGeneratedLayerPanelAfterBlur,
|
||||
clearGenerationPanelsAfterBlur,
|
||||
getCanvasPointFromClient,
|
||||
});
|
||||
const {
|
||||
@@ -846,7 +920,7 @@ export function ImageCanvasEditorView() {
|
||||
const switchTool = (tool: CanvasTool) => {
|
||||
clearActiveInteraction();
|
||||
if (tool === 'upload') {
|
||||
requestUpload('asset');
|
||||
requestUpload('asset', { addToCanvas: true });
|
||||
return;
|
||||
}
|
||||
if (switchGenerationTool(tool)) {
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
buildLayerExportMetadata,
|
||||
dataUrlToBlob,
|
||||
formatExportDate,
|
||||
getImageExtensionFromTypeOrSrc,
|
||||
getLayerAssetExtensionFromTypeOrSrc,
|
||||
getLayerExportKey,
|
||||
readLayerAssetBlob,
|
||||
sanitizeExportFilePart,
|
||||
} from './ImageCanvasExportModel';
|
||||
import type { CanvasLayer } from './ImageCanvasEditorTypes';
|
||||
@@ -47,6 +49,20 @@ describe('ImageCanvasExportModel', () => {
|
||||
);
|
||||
expect(getImageExtensionFromTypeOrSrc('', '/image.webp?x=1')).toBe('webp');
|
||||
expect(getImageExtensionFromTypeOrSrc('', '/image.unknown')).toBe('png');
|
||||
expect(
|
||||
getLayerAssetExtensionFromTypeOrSrc(
|
||||
'video',
|
||||
'video/mp4',
|
||||
'/video.png',
|
||||
),
|
||||
).toBe('mp4');
|
||||
expect(
|
||||
getLayerAssetExtensionFromTypeOrSrc(
|
||||
'audio',
|
||||
'',
|
||||
'/generated-audios/effect.wav?token=1',
|
||||
),
|
||||
).toBe('wav');
|
||||
});
|
||||
|
||||
it('converts data URLs and builds layer metadata for manifest files', async () => {
|
||||
@@ -58,6 +74,7 @@ describe('ImageCanvasExportModel', () => {
|
||||
layerId: 'layer-1',
|
||||
title: '导出图层',
|
||||
file: 'images/001-layer.png',
|
||||
mediaType: 'image',
|
||||
sourceType: 'generated',
|
||||
prompt: '生成提示',
|
||||
actualPrompt: '实际提示',
|
||||
@@ -85,9 +102,57 @@ describe('ImageCanvasExportModel', () => {
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('reads private object-key assets through signed URLs before exporting', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const fetchMock = vi.fn(async (url: string) => {
|
||||
if (url.startsWith('/api/assets/read-url?')) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
ok: true,
|
||||
data: {
|
||||
read: {
|
||||
objectKey: 'generated/video.mp4',
|
||||
signedUrl: 'https://oss.example.com/generated/video.mp4?x-oss-signature=1',
|
||||
expiresAt: '2026-06-20T00:00:00.000Z',
|
||||
},
|
||||
},
|
||||
error: null,
|
||||
meta: { apiVersion: '2026-06-16' },
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (url === 'https://oss.example.com/generated/video.mp4?x-oss-signature=1') {
|
||||
return new Response(new Blob(['video'], { type: 'video/mp4' }));
|
||||
}
|
||||
return new Response(null, { status: 404 });
|
||||
});
|
||||
globalThis.fetch = fetchMock as typeof fetch;
|
||||
|
||||
try {
|
||||
const blob = await readLayerAssetBlob(
|
||||
buildLayer({
|
||||
src: '/generated-editor-videos/video.mp4',
|
||||
mediaType: 'video',
|
||||
objectKey: 'generated/video.mp4',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(blob.type).toBe('video/mp4');
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/api/assets/read-url?objectKey=generated%2Fvideo.mp4'),
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'https://oss.example.com/generated/video.mp4?x-oss-signature=1',
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function buildLayer(): CanvasLayer {
|
||||
function buildLayer(overrides: Partial<CanvasLayer> = {}): CanvasLayer {
|
||||
return {
|
||||
id: 'layer-1',
|
||||
resourceId: 'resource-1',
|
||||
@@ -114,5 +179,6 @@ function buildLayer(): CanvasLayer {
|
||||
locked: false,
|
||||
flipX: true,
|
||||
flipY: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import type {
|
||||
CanvasAssetExportMetadata,
|
||||
CanvasMediaType,
|
||||
CanvasLayer,
|
||||
} from './ImageCanvasEditorTypes';
|
||||
import {
|
||||
getSignedAssetReadUrl,
|
||||
resolveAssetReadUrl,
|
||||
} from '../../services/assetReadUrlService';
|
||||
|
||||
export function sanitizeExportFilePart(value: string, fallback: string) {
|
||||
const safeValue = value
|
||||
@@ -30,15 +35,80 @@ export function getLayerExportKey(layer: CanvasLayer) {
|
||||
}
|
||||
|
||||
export function getImageExtensionFromTypeOrSrc(type: string, src: string) {
|
||||
if (type.includes('jpeg') || /\.(jpe?g)(?:[?#].*)?$/iu.test(src)) {
|
||||
return getLayerAssetExtensionFromTypeOrSrc('image', type, src);
|
||||
}
|
||||
|
||||
function getExtensionFromSrc(src: string) {
|
||||
const withoutQuery = src.split(/[?#]/u)[0] ?? '';
|
||||
const match = /\.([a-z0-9]+)$/iu.exec(withoutQuery);
|
||||
return match?.[1]?.toLowerCase() ?? null;
|
||||
}
|
||||
|
||||
function pickKnownExtension(
|
||||
extension: string | null,
|
||||
allowedExtensions: string[],
|
||||
fallback: string,
|
||||
) {
|
||||
return extension && allowedExtensions.includes(extension) ? extension : fallback;
|
||||
}
|
||||
|
||||
export function getLayerAssetExtensionFromTypeOrSrc(
|
||||
mediaType: CanvasMediaType | undefined,
|
||||
type: string,
|
||||
src: string,
|
||||
) {
|
||||
const normalizedType = type.toLowerCase();
|
||||
const extensionFromSrc = getExtensionFromSrc(src);
|
||||
if (mediaType === 'audio') {
|
||||
if (normalizedType.includes('mpeg') || normalizedType.includes('mp3')) {
|
||||
return 'mp3';
|
||||
}
|
||||
if (normalizedType.includes('wav')) {
|
||||
return 'wav';
|
||||
}
|
||||
if (normalizedType.includes('ogg')) {
|
||||
return 'ogg';
|
||||
}
|
||||
if (normalizedType.includes('aac')) {
|
||||
return 'aac';
|
||||
}
|
||||
if (normalizedType.includes('flac')) {
|
||||
return 'flac';
|
||||
}
|
||||
return pickKnownExtension(
|
||||
extensionFromSrc,
|
||||
['mp3', 'wav', 'ogg', 'aac', 'flac', 'm4a'],
|
||||
'mp3',
|
||||
);
|
||||
}
|
||||
if (mediaType === 'video') {
|
||||
if (normalizedType.includes('mp4')) {
|
||||
return 'mp4';
|
||||
}
|
||||
if (normalizedType.includes('webm')) {
|
||||
return 'webm';
|
||||
}
|
||||
if (normalizedType.includes('quicktime')) {
|
||||
return 'mov';
|
||||
}
|
||||
return pickKnownExtension(extensionFromSrc, ['mp4', 'webm', 'mov'], 'mp4');
|
||||
}
|
||||
if (
|
||||
normalizedType.includes('jpeg') ||
|
||||
extensionFromSrc === 'jpg' ||
|
||||
extensionFromSrc === 'jpeg'
|
||||
) {
|
||||
return 'jpg';
|
||||
}
|
||||
if (type.includes('webp') || /\.webp(?:[?#].*)?$/iu.test(src)) {
|
||||
if (normalizedType.includes('webp') || extensionFromSrc === 'webp') {
|
||||
return 'webp';
|
||||
}
|
||||
if (type.includes('gif') || /\.gif(?:[?#].*)?$/iu.test(src)) {
|
||||
if (normalizedType.includes('gif') || extensionFromSrc === 'gif') {
|
||||
return 'gif';
|
||||
}
|
||||
if (normalizedType.includes('svg') || extensionFromSrc === 'svg') {
|
||||
return 'svg';
|
||||
}
|
||||
return 'png';
|
||||
}
|
||||
|
||||
@@ -59,17 +129,26 @@ export function dataUrlToBlob(dataUrl: string) {
|
||||
return new Blob([bytes], { type });
|
||||
}
|
||||
|
||||
export async function readLayerImageBlob(layer: CanvasLayer) {
|
||||
export async function readLayerAssetBlob(layer: CanvasLayer) {
|
||||
if (layer.src.startsWith('data:')) {
|
||||
return dataUrlToBlob(layer.src);
|
||||
}
|
||||
const response = await fetch(layer.src);
|
||||
const source = layer.objectKey
|
||||
? await getSignedAssetReadUrl(
|
||||
{ objectKey: layer.objectKey },
|
||||
undefined,
|
||||
{ cacheVersion: layer.taskId ?? layer.resourceId },
|
||||
)
|
||||
: await resolveAssetReadUrl(layer.src);
|
||||
const response = await fetch(source);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
return response.blob();
|
||||
}
|
||||
|
||||
export const readLayerImageBlob = readLayerAssetBlob;
|
||||
|
||||
export async function blobToUint8Array(blob: Blob) {
|
||||
if (typeof blob.arrayBuffer === 'function') {
|
||||
return new Uint8Array(await blob.arrayBuffer());
|
||||
@@ -98,6 +177,7 @@ export function buildLayerExportMetadata(
|
||||
layerId: layer.id,
|
||||
title: layer.title,
|
||||
file,
|
||||
mediaType: layer.mediaType ?? 'image',
|
||||
sourceType: layer.sourceType,
|
||||
prompt: layer.prompt,
|
||||
actualPrompt: layer.actualPrompt,
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { probeImageFileDimensions } from './ImageCanvasFileModel';
|
||||
|
||||
function makeFile(bytes: number[], name: string, type: string) {
|
||||
return new File([new Uint8Array(bytes)], name, { type });
|
||||
}
|
||||
|
||||
function uint16be(value: number) {
|
||||
return [(value >> 8) & 0xff, value & 0xff];
|
||||
}
|
||||
|
||||
function uint32be(value: number) {
|
||||
return [
|
||||
(value >> 24) & 0xff,
|
||||
(value >> 16) & 0xff,
|
||||
(value >> 8) & 0xff,
|
||||
value & 0xff,
|
||||
];
|
||||
}
|
||||
|
||||
function uint16le(value: number) {
|
||||
return [value & 0xff, (value >> 8) & 0xff];
|
||||
}
|
||||
|
||||
function uint24le(value: number) {
|
||||
return [value & 0xff, (value >> 8) & 0xff, (value >> 16) & 0xff];
|
||||
}
|
||||
|
||||
function uint32le(value: number) {
|
||||
return [
|
||||
value & 0xff,
|
||||
(value >> 8) & 0xff,
|
||||
(value >> 16) & 0xff,
|
||||
(value >> 24) & 0xff,
|
||||
];
|
||||
}
|
||||
|
||||
describe('ImageCanvasFileModel', () => {
|
||||
it('probes common upload image dimensions from file headers', async () => {
|
||||
const png = makeFile(
|
||||
[
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
|
||||
...uint32be(13),
|
||||
0x49, 0x48, 0x44, 0x52,
|
||||
...uint32be(1536),
|
||||
...uint32be(1024),
|
||||
8,
|
||||
6,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
],
|
||||
'画布.png',
|
||||
'image/png',
|
||||
);
|
||||
const jpeg = makeFile(
|
||||
[
|
||||
0xff, 0xd8,
|
||||
0xff, 0xe0,
|
||||
...uint16be(16),
|
||||
0x4a, 0x46, 0x49, 0x46,
|
||||
0, 1, 1, 0, 0, 1, 0, 1, 0, 0,
|
||||
0xff, 0xc0,
|
||||
...uint16be(17),
|
||||
8,
|
||||
...uint16be(720),
|
||||
...uint16be(1280),
|
||||
3,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
],
|
||||
'画布.jpg',
|
||||
'image/jpeg',
|
||||
);
|
||||
const gif = makeFile(
|
||||
[
|
||||
0x47, 0x49, 0x46, 0x38, 0x39, 0x61,
|
||||
...uint16le(640),
|
||||
...uint16le(480),
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
],
|
||||
'画布.gif',
|
||||
'image/gif',
|
||||
);
|
||||
const webp = makeFile(
|
||||
[
|
||||
0x52, 0x49, 0x46, 0x46,
|
||||
...uint32le(22),
|
||||
0x57, 0x45, 0x42, 0x50,
|
||||
0x56, 0x50, 0x38, 0x58,
|
||||
...uint32le(10),
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
...uint24le(799),
|
||||
...uint24le(599),
|
||||
],
|
||||
'画布.webp',
|
||||
'image/webp',
|
||||
);
|
||||
const bmp = makeFile(
|
||||
[
|
||||
0x42, 0x4d,
|
||||
...uint32le(54),
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
...uint32le(54),
|
||||
...uint32le(40),
|
||||
...uint32le(300),
|
||||
...uint32le(200),
|
||||
],
|
||||
'画布.bmp',
|
||||
'image/bmp',
|
||||
);
|
||||
|
||||
await expect(probeImageFileDimensions(png)).resolves.toEqual({
|
||||
width: 1536,
|
||||
height: 1024,
|
||||
});
|
||||
await expect(probeImageFileDimensions(jpeg)).resolves.toEqual({
|
||||
width: 1280,
|
||||
height: 720,
|
||||
});
|
||||
await expect(probeImageFileDimensions(gif)).resolves.toEqual({
|
||||
width: 640,
|
||||
height: 480,
|
||||
});
|
||||
await expect(probeImageFileDimensions(webp)).resolves.toEqual({
|
||||
width: 800,
|
||||
height: 600,
|
||||
});
|
||||
await expect(probeImageFileDimensions(bmp)).resolves.toEqual({
|
||||
width: 300,
|
||||
height: 200,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,9 @@ export function isImageFile(file: File) {
|
||||
return file.type.startsWith('image/');
|
||||
}
|
||||
|
||||
export const EDITOR_ASSET_UPLOAD_ACCEPT =
|
||||
'image/*,.mp3,.mp4,audio/mpeg,audio/mp3,video/mp4';
|
||||
|
||||
export const SEEDANCE_REFERENCE_ACCEPT = {
|
||||
image:
|
||||
'.jpg,.jpeg,.png,.webp,.bmp,.tiff,.tif,.gif,.heic,.heif,image/jpeg,image/png,image/webp,image/bmp,image/tiff,image/gif,image/heic,image/heif',
|
||||
@@ -19,7 +22,7 @@ export function getEditorUploadAccept(target: string) {
|
||||
if (target === 'video-reference-image') {
|
||||
return SEEDANCE_REFERENCE_ACCEPT.image;
|
||||
}
|
||||
return 'image/*';
|
||||
return EDITOR_ASSET_UPLOAD_ACCEPT;
|
||||
}
|
||||
|
||||
export const SEEDANCE_REFERENCE_FILE_LIMITS = {
|
||||
@@ -45,6 +48,8 @@ const SEEDANCE_IMAGE_EXTENSIONS = new Set([
|
||||
]);
|
||||
const SEEDANCE_VIDEO_EXTENSIONS = new Set(['mp4', 'mov']);
|
||||
const SEEDANCE_AUDIO_EXTENSIONS = new Set(['wav', 'mp3']);
|
||||
const EDITOR_ASSET_VIDEO_EXTENSIONS = new Set(['mp4']);
|
||||
const EDITOR_ASSET_AUDIO_EXTENSIONS = new Set(['mp3']);
|
||||
|
||||
function getFileExtension(file: File) {
|
||||
return file.name.split('.').pop()?.toLowerCase() ?? '';
|
||||
@@ -77,10 +82,37 @@ export function isSeedanceReferenceAudioFile(file: File) {
|
||||
file.type === 'audio/mpeg' ||
|
||||
file.type === 'audio/mp3' ||
|
||||
file.type === '') &&
|
||||
hasAllowedExtension(file, SEEDANCE_AUDIO_EXTENSIONS)
|
||||
hasAllowedExtension(file, SEEDANCE_AUDIO_EXTENSIONS)
|
||||
);
|
||||
}
|
||||
|
||||
export function isEditorAssetVideoFile(file: File) {
|
||||
return (
|
||||
(file.type === 'video/mp4' || file.type === '') &&
|
||||
hasAllowedExtension(file, EDITOR_ASSET_VIDEO_EXTENSIONS)
|
||||
);
|
||||
}
|
||||
|
||||
export function isEditorAssetAudioFile(file: File) {
|
||||
return (
|
||||
(file.type === 'audio/mpeg' || file.type === 'audio/mp3' || file.type === '') &&
|
||||
hasAllowedExtension(file, EDITOR_ASSET_AUDIO_EXTENSIONS)
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveEditorAssetUploadMediaType(file: File) {
|
||||
if (isEditorAssetVideoFile(file)) {
|
||||
return 'video' as const;
|
||||
}
|
||||
if (isEditorAssetAudioFile(file)) {
|
||||
return 'audio' as const;
|
||||
}
|
||||
if (isImageFile(file)) {
|
||||
return 'image' as const;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function readFileAsDataUrl(file: File) {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
@@ -100,6 +132,296 @@ export function readImageFileAsDataUrl(file: File) {
|
||||
return readFileAsDataUrl(file);
|
||||
}
|
||||
|
||||
function readBlobAsArrayBuffer(blob: Blob) {
|
||||
if (typeof blob.arrayBuffer === 'function') {
|
||||
return blob.arrayBuffer();
|
||||
}
|
||||
return new Promise<ArrayBuffer>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
if (reader.result instanceof ArrayBuffer) {
|
||||
resolve(reader.result);
|
||||
return;
|
||||
}
|
||||
reject(new Error('文件读取失败'));
|
||||
};
|
||||
reader.onerror = () => reject(reader.error ?? new Error('文件读取失败'));
|
||||
reader.readAsArrayBuffer(blob);
|
||||
});
|
||||
}
|
||||
|
||||
export type ImageFileDimensions = {
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
function readUint24LittleEndian(bytes: Uint8Array, offset: number) {
|
||||
return (
|
||||
byteAt(bytes, offset) +
|
||||
(byteAt(bytes, offset + 1) << 8) +
|
||||
(byteAt(bytes, offset + 2) << 16)
|
||||
);
|
||||
}
|
||||
|
||||
function byteAt(bytes: Uint8Array, offset: number) {
|
||||
return bytes[offset] ?? 0;
|
||||
}
|
||||
|
||||
// 中文注释:上传素材需要在创建画布图层和持久化素材前拿到原图 Resolution,避免先写入固定兜底尺寸。
|
||||
function parseImageDimensionsFromHeader(
|
||||
buffer: ArrayBuffer,
|
||||
): ImageFileDimensions | null {
|
||||
const bytes = new Uint8Array(buffer);
|
||||
const view = new DataView(buffer);
|
||||
if (
|
||||
bytes.length >= 24 &&
|
||||
byteAt(bytes, 0) === 0x89 &&
|
||||
byteAt(bytes, 1) === 0x50 &&
|
||||
byteAt(bytes, 2) === 0x4e &&
|
||||
byteAt(bytes, 3) === 0x47
|
||||
) {
|
||||
return {
|
||||
width: view.getUint32(16, false),
|
||||
height: view.getUint32(20, false),
|
||||
};
|
||||
}
|
||||
if (
|
||||
bytes.length >= 10 &&
|
||||
byteAt(bytes, 0) === 0x47 &&
|
||||
byteAt(bytes, 1) === 0x49 &&
|
||||
byteAt(bytes, 2) === 0x46
|
||||
) {
|
||||
return {
|
||||
width: view.getUint16(6, true),
|
||||
height: view.getUint16(8, true),
|
||||
};
|
||||
}
|
||||
if (
|
||||
bytes.length >= 26 &&
|
||||
byteAt(bytes, 0) === 0x42 &&
|
||||
byteAt(bytes, 1) === 0x4d
|
||||
) {
|
||||
return {
|
||||
width: Math.abs(view.getInt32(18, true)),
|
||||
height: Math.abs(view.getInt32(22, true)),
|
||||
};
|
||||
}
|
||||
if (
|
||||
bytes.length >= 30 &&
|
||||
byteAt(bytes, 0) === 0x52 &&
|
||||
byteAt(bytes, 1) === 0x49 &&
|
||||
byteAt(bytes, 2) === 0x46 &&
|
||||
byteAt(bytes, 3) === 0x46 &&
|
||||
byteAt(bytes, 8) === 0x57 &&
|
||||
byteAt(bytes, 9) === 0x45 &&
|
||||
byteAt(bytes, 10) === 0x42 &&
|
||||
byteAt(bytes, 11) === 0x50
|
||||
) {
|
||||
const chunkType = String.fromCharCode(
|
||||
byteAt(bytes, 12),
|
||||
byteAt(bytes, 13),
|
||||
byteAt(bytes, 14),
|
||||
byteAt(bytes, 15),
|
||||
);
|
||||
const chunkDataOffset = 20;
|
||||
if (chunkType === 'VP8X' && bytes.length >= 30) {
|
||||
return {
|
||||
width: readUint24LittleEndian(bytes, 24) + 1,
|
||||
height: readUint24LittleEndian(bytes, 27) + 1,
|
||||
};
|
||||
}
|
||||
if (
|
||||
chunkType === 'VP8 ' &&
|
||||
bytes.length >= chunkDataOffset + 10 &&
|
||||
byteAt(bytes, chunkDataOffset + 3) === 0x9d &&
|
||||
byteAt(bytes, chunkDataOffset + 4) === 0x01 &&
|
||||
byteAt(bytes, chunkDataOffset + 5) === 0x2a
|
||||
) {
|
||||
return {
|
||||
width: view.getUint16(chunkDataOffset + 6, true) & 0x3fff,
|
||||
height: view.getUint16(chunkDataOffset + 8, true) & 0x3fff,
|
||||
};
|
||||
}
|
||||
if (
|
||||
chunkType === 'VP8L' &&
|
||||
bytes.length >= chunkDataOffset + 5 &&
|
||||
byteAt(bytes, chunkDataOffset) === 0x2f
|
||||
) {
|
||||
const packed =
|
||||
byteAt(bytes, chunkDataOffset + 1) |
|
||||
(byteAt(bytes, chunkDataOffset + 2) << 8) |
|
||||
(byteAt(bytes, chunkDataOffset + 3) << 16) |
|
||||
(byteAt(bytes, chunkDataOffset + 4) << 24);
|
||||
return {
|
||||
width: (packed & 0x3fff) + 1,
|
||||
height: ((packed >> 14) & 0x3fff) + 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
if (
|
||||
bytes.length >= 4 &&
|
||||
byteAt(bytes, 0) === 0xff &&
|
||||
byteAt(bytes, 1) === 0xd8
|
||||
) {
|
||||
let offset = 2;
|
||||
while (offset + 9 < bytes.length) {
|
||||
if (byteAt(bytes, offset) !== 0xff) {
|
||||
offset += 1;
|
||||
continue;
|
||||
}
|
||||
const marker = byteAt(bytes, offset + 1);
|
||||
if (marker === 0xd8 || marker === 0xd9) {
|
||||
offset += 2;
|
||||
continue;
|
||||
}
|
||||
const segmentLength = view.getUint16(offset + 2, false);
|
||||
if (segmentLength < 2 || offset + 2 + segmentLength > bytes.length) {
|
||||
break;
|
||||
}
|
||||
if (
|
||||
(marker >= 0xc0 && marker <= 0xc3) ||
|
||||
(marker >= 0xc5 && marker <= 0xc7) ||
|
||||
(marker >= 0xc9 && marker <= 0xcb) ||
|
||||
(marker >= 0xcd && marker <= 0xcf)
|
||||
) {
|
||||
return {
|
||||
height: view.getUint16(offset + 5, false),
|
||||
width: view.getUint16(offset + 7, false),
|
||||
};
|
||||
}
|
||||
offset += 2 + segmentLength;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const HEADER_DIMENSION_IMAGE_TYPES = new Set([
|
||||
'image/png',
|
||||
'image/jpeg',
|
||||
'image/jpg',
|
||||
'image/webp',
|
||||
'image/gif',
|
||||
'image/bmp',
|
||||
'image/x-ms-bmp',
|
||||
]);
|
||||
|
||||
function hasSupportedImageHeaderSignature(buffer: ArrayBuffer) {
|
||||
const bytes = new Uint8Array(buffer);
|
||||
return (
|
||||
(bytes.length >= 4 &&
|
||||
byteAt(bytes, 0) === 0x89 &&
|
||||
byteAt(bytes, 1) === 0x50 &&
|
||||
byteAt(bytes, 2) === 0x4e &&
|
||||
byteAt(bytes, 3) === 0x47) ||
|
||||
(bytes.length >= 3 &&
|
||||
byteAt(bytes, 0) === 0x47 &&
|
||||
byteAt(bytes, 1) === 0x49 &&
|
||||
byteAt(bytes, 2) === 0x46) ||
|
||||
(bytes.length >= 2 &&
|
||||
byteAt(bytes, 0) === 0x42 &&
|
||||
byteAt(bytes, 1) === 0x4d) ||
|
||||
(bytes.length >= 12 &&
|
||||
byteAt(bytes, 0) === 0x52 &&
|
||||
byteAt(bytes, 1) === 0x49 &&
|
||||
byteAt(bytes, 2) === 0x46 &&
|
||||
byteAt(bytes, 3) === 0x46 &&
|
||||
byteAt(bytes, 8) === 0x57 &&
|
||||
byteAt(bytes, 9) === 0x45 &&
|
||||
byteAt(bytes, 10) === 0x42 &&
|
||||
byteAt(bytes, 11) === 0x50) ||
|
||||
(bytes.length >= 2 &&
|
||||
byteAt(bytes, 0) === 0xff &&
|
||||
byteAt(bytes, 1) === 0xd8)
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeImageFileDimensions(
|
||||
dimensions: ImageFileDimensions | null,
|
||||
) {
|
||||
if (
|
||||
!dimensions ||
|
||||
dimensions.width <= 0 ||
|
||||
dimensions.height <= 0 ||
|
||||
!Number.isFinite(dimensions.width) ||
|
||||
!Number.isFinite(dimensions.height)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
width: Math.round(dimensions.width),
|
||||
height: Math.round(dimensions.height),
|
||||
};
|
||||
}
|
||||
|
||||
async function probeImageDimensionsWithBrowserDecode(file: File) {
|
||||
if (typeof createImageBitmap === 'function') {
|
||||
try {
|
||||
const bitmap = await createImageBitmap(file);
|
||||
const dimensions = { width: bitmap.width, height: bitmap.height };
|
||||
bitmap.close();
|
||||
return normalizeImageFileDimensions(dimensions);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (
|
||||
typeof Image === 'undefined' ||
|
||||
typeof URL === 'undefined' ||
|
||||
typeof URL.createObjectURL !== 'function'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return new Promise<ImageFileDimensions | null>((resolve) => {
|
||||
const objectUrl = URL.createObjectURL(file);
|
||||
const image = new Image();
|
||||
let settled = false;
|
||||
const cleanup = () => {
|
||||
if (typeof URL.revokeObjectURL === 'function') {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
}
|
||||
};
|
||||
const finish = (dimensions: ImageFileDimensions | null) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
cleanup();
|
||||
resolve(dimensions);
|
||||
};
|
||||
const timeout = setTimeout(() => finish(null), 180);
|
||||
image.onload = () => {
|
||||
finish(
|
||||
normalizeImageFileDimensions({
|
||||
width: image.naturalWidth || image.width,
|
||||
height: image.naturalHeight || image.height,
|
||||
}),
|
||||
);
|
||||
};
|
||||
image.onerror = () => {
|
||||
finish(null);
|
||||
};
|
||||
image.src = objectUrl;
|
||||
});
|
||||
}
|
||||
|
||||
export async function probeImageFileDimensions(file: File) {
|
||||
const headerBuffer = await readBlobAsArrayBuffer(file.slice(0, 65_536));
|
||||
const headerDimensions = normalizeImageFileDimensions(
|
||||
parseImageDimensionsFromHeader(headerBuffer),
|
||||
);
|
||||
if (headerDimensions) {
|
||||
return headerDimensions;
|
||||
}
|
||||
if (
|
||||
HEADER_DIMENSION_IMAGE_TYPES.has(file.type.toLowerCase()) &&
|
||||
!hasSupportedImageHeaderSignature(headerBuffer)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return probeImageDimensionsWithBrowserDecode(file);
|
||||
}
|
||||
|
||||
export function probeMediaDurationSeconds(
|
||||
file: File,
|
||||
mediaType: 'video' | 'audio',
|
||||
|
||||
@@ -97,7 +97,6 @@ function createComposerProps(
|
||||
onSubmitQuickEdit: vi.fn(),
|
||||
onSubmitCropExpand: vi.fn(),
|
||||
onSubmitCharacterAnimation: vi.fn(),
|
||||
onCloseGenerateComposer: vi.fn(),
|
||||
onUpdateSpecFormValue: vi.fn(),
|
||||
onUpdateIconDescriptionText: vi.fn(),
|
||||
onUpdateCharacterAnimationDuration: vi.fn(),
|
||||
@@ -278,6 +277,52 @@ describe('ImageCanvasGenerationComposerView', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('恢复宣发素材生成卡片的字段和参考图', () => {
|
||||
renderComposer({
|
||||
mode: 'publication',
|
||||
prompt: '马戏团午夜惊魂|非对称对抗|找到钥匙逃离',
|
||||
status: 'idle',
|
||||
composerOpen: true,
|
||||
publicationWorkflowId: 'publication-promo-poster',
|
||||
publicationGameInfo: {
|
||||
gameName: '马戏团午夜惊魂',
|
||||
gameCategories: '非对称对抗',
|
||||
gameDescription: '找到钥匙,开门逃离马戏团',
|
||||
},
|
||||
publicationReferences: [
|
||||
{
|
||||
id: 'publication-reference-1',
|
||||
label: '首图参考',
|
||||
src: '/generated-character-drafts/editor/reference.png',
|
||||
},
|
||||
],
|
||||
imageModel: 'gpt-image-2',
|
||||
aspectRatio: '16:9',
|
||||
imageSize: '2K',
|
||||
});
|
||||
|
||||
const panel = screen.getByRole('dialog', { name: '运营海报生成卡片' });
|
||||
expect(
|
||||
(within(panel).getByRole('textbox', {
|
||||
name: '运营海报游戏名',
|
||||
}) as HTMLInputElement).value,
|
||||
).toBe('马戏团午夜惊魂');
|
||||
expect(
|
||||
(within(panel).getByRole('textbox', {
|
||||
name: '运营海报游戏分类',
|
||||
}) as HTMLInputElement).value,
|
||||
).toBe('非对称对抗');
|
||||
expect(
|
||||
(within(panel).getByRole('textbox', {
|
||||
name: '运营海报一句话描述游戏',
|
||||
}) as HTMLTextAreaElement).value,
|
||||
).toBe('找到钥匙,开门逃离马戏团');
|
||||
expect(within(panel).getByLabelText('首图参考')).toBeTruthy();
|
||||
expect(
|
||||
within(panel).getByRole('button', { name: '删除首图参考' }),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('生成图片参考图点击先弹来源菜单,不直接打开上传', () => {
|
||||
const onRequestUpload = vi.fn();
|
||||
const setIsGenerationReferenceMenuOpen = vi.fn();
|
||||
|
||||
@@ -118,7 +118,6 @@ type ImageCanvasGenerationComposerViewProps = {
|
||||
onSubmitQuickEdit: () => void;
|
||||
onSubmitCropExpand: () => void;
|
||||
onSubmitCharacterAnimation: () => void;
|
||||
onCloseGenerateComposer: () => void;
|
||||
onUpdateSpecFormValue: (key: keyof SpecFormValues, value: string) => void;
|
||||
onUpdateIconDescriptionText: (value: string) => void;
|
||||
onUpdateCharacterAnimationDuration: (frameCountValue: string) => void;
|
||||
@@ -883,7 +882,6 @@ export function ImageCanvasGenerationComposerView({
|
||||
onSubmitQuickEdit,
|
||||
onSubmitCropExpand,
|
||||
onSubmitCharacterAnimation,
|
||||
onCloseGenerateComposer,
|
||||
onUpdateSpecFormValue,
|
||||
onUpdateIconDescriptionText,
|
||||
onUpdateCharacterAnimationDuration,
|
||||
@@ -939,7 +937,6 @@ export function ImageCanvasGenerationComposerView({
|
||||
}
|
||||
onRememberImageModel={onRememberImageModel}
|
||||
onSubmit={onSubmitImageGeneration}
|
||||
onClose={onCloseGenerateComposer}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -225,6 +225,8 @@ describe('ImageCanvasGenerationDialogModel', () => {
|
||||
expect(createSoundEffectGenerationDialogDraft({ canvasSize, viewport }))
|
||||
.toMatchObject({
|
||||
mode: 'audio-sound-effect',
|
||||
prompt:
|
||||
'短促清脆的游戏交互音效,带轻微闪光质感,干净收尾,适合按钮确认或收集奖励。',
|
||||
status: 'idle',
|
||||
composerOpen: true,
|
||||
soundModel: 'audio1.0',
|
||||
@@ -241,6 +243,8 @@ describe('ImageCanvasGenerationDialogModel', () => {
|
||||
expect(createBackgroundMusicGenerationDialogDraft({ canvasSize, viewport }))
|
||||
.toMatchObject({
|
||||
mode: 'audio-background-music',
|
||||
prompt:
|
||||
'适合轻松冒险游戏的循环背景音乐,温暖明亮,旋律简洁,不包含人声。',
|
||||
status: 'idle',
|
||||
composerOpen: true,
|
||||
makeInstrumental: true,
|
||||
@@ -311,6 +315,37 @@ describe('ImageCanvasGenerationDialogModel', () => {
|
||||
expect(createAudioRedrawGenerationDialogDraft(createLayer())).toBeNull();
|
||||
});
|
||||
|
||||
it('uses audio prompt templates when remodeling old audio layers without prompts', () => {
|
||||
expect(
|
||||
createAudioRedrawGenerationDialogDraft(
|
||||
createLayer({
|
||||
mediaType: 'audio',
|
||||
assetKind: 'sound-effect',
|
||||
prompt: '',
|
||||
generationInputs: { fields: [], references: [] },
|
||||
}),
|
||||
),
|
||||
).toMatchObject({
|
||||
mode: 'audio-sound-effect',
|
||||
prompt:
|
||||
'短促清脆的游戏交互音效,带轻微闪光质感,干净收尾,适合按钮确认或收集奖励。',
|
||||
});
|
||||
expect(
|
||||
createAudioRedrawGenerationDialogDraft(
|
||||
createLayer({
|
||||
mediaType: 'audio',
|
||||
assetKind: 'background-music',
|
||||
title: '游戏背景音乐',
|
||||
prompt: '',
|
||||
generationInputs: { fields: [], references: [] },
|
||||
}),
|
||||
),
|
||||
).toMatchObject({
|
||||
mode: 'audio-background-music',
|
||||
prompt: '适合轻松冒险游戏的循环背景音乐,温暖明亮,旋律简洁,不包含人声。',
|
||||
});
|
||||
});
|
||||
|
||||
it('creates edit, quick-edit, and character animation panel drafts', () => {
|
||||
const sourceLayer = createLayer({
|
||||
prompt: '原图提示',
|
||||
|
||||
@@ -40,6 +40,10 @@ const VIDEO_REFERENCE_LIMITS = {
|
||||
video: 3,
|
||||
audio: 3,
|
||||
} as const;
|
||||
const DEFAULT_SOUND_EFFECT_PROMPT =
|
||||
'短促清脆的游戏交互音效,带轻微闪光质感,干净收尾,适合按钮确认或收集奖励。';
|
||||
const DEFAULT_BACKGROUND_MUSIC_PROMPT =
|
||||
'适合轻松冒险游戏的循环背景音乐,温暖明亮,旋律简洁,不包含人声。';
|
||||
|
||||
function getViewportWorldCenter({
|
||||
canvasSize,
|
||||
@@ -352,7 +356,7 @@ export function createSoundEffectGenerationDialogDraft({
|
||||
const worldCenter = getViewportWorldCenter({ canvasSize, viewport });
|
||||
return {
|
||||
mode: 'audio-sound-effect',
|
||||
prompt: '',
|
||||
prompt: DEFAULT_SOUND_EFFECT_PROMPT,
|
||||
status: 'idle',
|
||||
composerOpen: true,
|
||||
soundModel: DEFAULT_SOUND_EFFECT_MODEL,
|
||||
@@ -378,7 +382,7 @@ export function createBackgroundMusicGenerationDialogDraft({
|
||||
const worldCenter = getViewportWorldCenter({ canvasSize, viewport });
|
||||
return {
|
||||
mode: 'audio-background-music',
|
||||
prompt: '',
|
||||
prompt: DEFAULT_BACKGROUND_MUSIC_PROMPT,
|
||||
status: 'idle',
|
||||
composerOpen: true,
|
||||
makeInstrumental: true,
|
||||
@@ -405,7 +409,24 @@ function findGenerationInputFieldValue(
|
||||
);
|
||||
}
|
||||
|
||||
function resolveAudioRedrawPrompt(sourceLayer: CanvasLayer) {
|
||||
function resolveDefaultAudioPrompt(
|
||||
mode: Extract<
|
||||
GenerateDialogState['mode'],
|
||||
'audio-sound-effect' | 'audio-background-music'
|
||||
>,
|
||||
) {
|
||||
return mode === 'audio-background-music'
|
||||
? DEFAULT_BACKGROUND_MUSIC_PROMPT
|
||||
: DEFAULT_SOUND_EFFECT_PROMPT;
|
||||
}
|
||||
|
||||
function resolveAudioRedrawPrompt(
|
||||
sourceLayer: CanvasLayer,
|
||||
mode: Extract<
|
||||
GenerateDialogState['mode'],
|
||||
'audio-sound-effect' | 'audio-background-music'
|
||||
>,
|
||||
) {
|
||||
return (
|
||||
sourceLayer.prompt?.trim() ||
|
||||
findGenerationInputFieldValue(sourceLayer, [
|
||||
@@ -417,7 +438,7 @@ function resolveAudioRedrawPrompt(sourceLayer: CanvasLayer) {
|
||||
'背景音乐提示词',
|
||||
])?.trim() ||
|
||||
sourceLayer.generationInputs?.fields[0]?.value?.trim() ||
|
||||
''
|
||||
resolveDefaultAudioPrompt(mode)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -465,7 +486,7 @@ export function createAudioRedrawGenerationDialogDraft(
|
||||
const baseDraft = {
|
||||
mode,
|
||||
sourceLayerId: sourceLayer.id,
|
||||
prompt: resolveAudioRedrawPrompt(sourceLayer),
|
||||
prompt: resolveAudioRedrawPrompt(sourceLayer, mode),
|
||||
status: 'idle' as const,
|
||||
composerOpen: true,
|
||||
placeholder: {
|
||||
|
||||
@@ -14,9 +14,11 @@ import {
|
||||
|
||||
function ImageOptionsHarness({
|
||||
initialDialog,
|
||||
includeDimensions = true,
|
||||
onRememberImageModel = vi.fn(),
|
||||
}: {
|
||||
initialDialog: GenerateDialogState;
|
||||
includeDimensions?: boolean;
|
||||
onRememberImageModel?: (model: string) => void;
|
||||
}) {
|
||||
const [dialog, setDialog] = useState<GenerateDialogState | null>(
|
||||
@@ -28,7 +30,7 @@ function ImageOptionsHarness({
|
||||
<ImageCanvasGenerationImageOptionsView
|
||||
dialog={dialog}
|
||||
setGenerateDialog={setDialog}
|
||||
includeDimensions
|
||||
includeDimensions={includeDimensions}
|
||||
onRememberImageModel={onRememberImageModel}
|
||||
cost={calculateEditorImageGenerationPrice({ model: dialog.imageModel })}
|
||||
submitLabel="生成"
|
||||
@@ -191,6 +193,53 @@ describe('ImageCanvasGenerationImageOptionsView', () => {
|
||||
expect(submit.textContent).toBe('生成12泥点');
|
||||
});
|
||||
|
||||
it('keeps fixed-card placeholders unchanged when model-only controls are shown', () => {
|
||||
render(
|
||||
<ImageOptionsHarness
|
||||
includeDimensions={false}
|
||||
initialDialog={{
|
||||
mode: 'publication',
|
||||
prompt: '',
|
||||
status: 'idle',
|
||||
imageModel: IMAGE_MODEL_NANOBANANA2,
|
||||
aspectRatio: '4:3',
|
||||
imageSize: '1K',
|
||||
placeholder: {
|
||||
x: 120,
|
||||
y: 160,
|
||||
width: 720,
|
||||
height: 540,
|
||||
originalWidth: 720,
|
||||
originalHeight: 540,
|
||||
},
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /生成图片尺寸/u }),
|
||||
).toBeNull();
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '生成图片模型 nanobanana2' }),
|
||||
);
|
||||
fireEvent.click(
|
||||
within(screen.getByRole('menu', { name: '生成图片模型选项' })).getByRole(
|
||||
'button',
|
||||
{ name: 'gpt-image-2' },
|
||||
),
|
||||
);
|
||||
|
||||
expect(screen.getByLabelText('当前模型').textContent).toBe(
|
||||
IMAGE_MODEL_GPT_IMAGE_2,
|
||||
);
|
||||
expect(screen.getByLabelText('当前比例').textContent).toBe('4:3');
|
||||
expect(screen.getByLabelText('当前尺寸').textContent).toBe('1K');
|
||||
expect(screen.getByLabelText('当前占位').textContent).toBe(
|
||||
'120:160:720:540:720:540',
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the canvas placeholder centered while syncing its ratio and size selection', () => {
|
||||
render(
|
||||
<ImageOptionsHarness
|
||||
|
||||
@@ -152,10 +152,16 @@ export function ImageCanvasGenerationImageOptionsView({
|
||||
if (!currentDialog || currentDialog.mode !== dialog.mode) {
|
||||
return currentDialog;
|
||||
}
|
||||
if (!includeDimensions) {
|
||||
return {
|
||||
...resetFailedDialogStatus(currentDialog),
|
||||
imageModel: model,
|
||||
};
|
||||
}
|
||||
const nextOptions = getImageDimensionOptions(model);
|
||||
const nextAspectRatios = nextOptions.aspectRatios as readonly string[];
|
||||
const nextImageSizes = nextOptions.imageSizes as readonly string[];
|
||||
return resizeGenerationPlaceholderToImageSelection({
|
||||
const nextDialog = {
|
||||
...resetFailedDialogStatus(currentDialog),
|
||||
imageModel: model,
|
||||
aspectRatio:
|
||||
@@ -169,7 +175,8 @@ export function ImageCanvasGenerationImageOptionsView({
|
||||
? currentDialog.imageSize
|
||||
: (nextOptions.imageSizes.find((size) => size === '1K') ??
|
||||
nextOptions.imageSizes[0]),
|
||||
});
|
||||
};
|
||||
return resizeGenerationPlaceholderToImageSelection(nextDialog);
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
createGeneratedResultLayer,
|
||||
createIconSpritesheetResultLayers,
|
||||
createQuickEditResultLayer,
|
||||
createVideoResultLayer,
|
||||
} from './ImageCanvasGenerationLayerModel';
|
||||
|
||||
function createGenerated(overrides = {}) {
|
||||
@@ -97,6 +98,115 @@ describe('ImageCanvasGenerationLayerModel', () => {
|
||||
expect(layer.generationInputs?.fields[0]?.value).toBe('生成提示词');
|
||||
});
|
||||
|
||||
it('keeps publication material result dimensions constrained by the active placeholder', () => {
|
||||
const layer = createGeneratedResultLayer({
|
||||
generated: createGenerated({ width: 1024, height: 1024 }),
|
||||
generatedIndex: 8,
|
||||
canvasSize: { width: 900, height: 640 },
|
||||
viewport: { x: 10, y: 20, scale: 2 },
|
||||
frame: {
|
||||
x: 180,
|
||||
y: 120,
|
||||
width: 360,
|
||||
height: 640,
|
||||
originalWidth: 720,
|
||||
originalHeight: 1280,
|
||||
},
|
||||
assetKind: 'publication-material',
|
||||
title: '8 宣发素材',
|
||||
generationInputs: createGenerationInputs(),
|
||||
});
|
||||
|
||||
expect(layer).toMatchObject({
|
||||
id: 'layer-generated-8',
|
||||
title: '8 宣发素材',
|
||||
width: 720,
|
||||
height: 1280,
|
||||
originalWidth: 720,
|
||||
originalHeight: 1280,
|
||||
assetKind: 'publication-material',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps publication cover image results at the 4:3 workflow dimensions', () => {
|
||||
const layer = createGeneratedResultLayer({
|
||||
generated: createGenerated({ width: 1024, height: 1024 }),
|
||||
generatedIndex: 9,
|
||||
canvasSize: { width: 900, height: 640 },
|
||||
viewport: { x: 10, y: 20, scale: 2 },
|
||||
frame: {
|
||||
x: 100,
|
||||
y: 100,
|
||||
width: 480,
|
||||
height: 360,
|
||||
originalWidth: 720,
|
||||
originalHeight: 540,
|
||||
},
|
||||
assetKind: 'publication-material',
|
||||
title: '9 宣发素材',
|
||||
generationInputs: createGenerationInputs(),
|
||||
});
|
||||
|
||||
expect(layer).toMatchObject({
|
||||
width: 720,
|
||||
height: 540,
|
||||
originalWidth: 720,
|
||||
originalHeight: 540,
|
||||
assetKind: 'publication-material',
|
||||
});
|
||||
});
|
||||
|
||||
it('uses backend-persisted resource and asset snapshots for generated layers', () => {
|
||||
const generationInputs = createGenerationInputs();
|
||||
const layer = createGeneratedResultLayer({
|
||||
generated: createGenerated({
|
||||
resource: {
|
||||
resourceId: 'resource-generated-bff',
|
||||
projectId: 'editor-project-1',
|
||||
imageSrc: 'data:image/png;base64,generated',
|
||||
objectKey: 'generated/from-resource.png',
|
||||
assetObjectId: 'asset-object-from-resource',
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
sourceType: 'generated',
|
||||
assetKind: 'character',
|
||||
generationInputs,
|
||||
},
|
||||
asset: {
|
||||
assetId: 'asset-generated-bff',
|
||||
folderId: 'user-1:asset-folder:project',
|
||||
label: '角色形象',
|
||||
imageSrc: 'data:image/png;base64,generated',
|
||||
objectKey: 'generated/from-asset.png',
|
||||
assetObjectId: 'asset-object-from-asset',
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
sourceType: 'generated',
|
||||
assetKind: 'character',
|
||||
generationInputs,
|
||||
},
|
||||
}),
|
||||
generatedIndex: 9,
|
||||
canvasSize: { width: 900, height: 640 },
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
assetKind: 'spec',
|
||||
generationInputs: {
|
||||
fields: [{ title: '旧输入', value: '本地兜底' }],
|
||||
references: [],
|
||||
},
|
||||
});
|
||||
|
||||
expect(layer).toMatchObject({
|
||||
resourceId: 'resource-generated-bff',
|
||||
sourceAssetId: 'asset-generated-bff',
|
||||
objectKey: 'generated/from-resource.png',
|
||||
assetObjectId: 'asset-object-from-resource',
|
||||
assetKind: 'character',
|
||||
});
|
||||
expect(layer.generationInputs?.fields[0]?.value).toBe('生成提示词');
|
||||
expect(layer.generatedAssetSnapshot?.assetId).toBe('asset-generated-bff');
|
||||
});
|
||||
|
||||
it('creates edit result layers beside the source image', () => {
|
||||
const sourceLayer = createSourceLayer();
|
||||
const layer = createGeneratedResultLayer({
|
||||
@@ -267,6 +377,8 @@ describe('ImageCanvasGenerationLayerModel', () => {
|
||||
const layer = createAudioResultLayer({
|
||||
generated: {
|
||||
audioSrc: '/generated-character-drafts/editor-audios/sfx.mp3',
|
||||
objectKey: 'generated-character-drafts/editor-audios/sfx.mp3',
|
||||
assetObjectId: 'assetobj-audio-1',
|
||||
width: 420,
|
||||
height: 120,
|
||||
sourceType: 'generated',
|
||||
@@ -315,10 +427,45 @@ describe('ImageCanvasGenerationLayerModel', () => {
|
||||
model: 'audio1.0',
|
||||
provider: 'VectorEngine',
|
||||
taskId: 'sound-task-1',
|
||||
objectKey: 'generated-character-drafts/editor-audios/sfx.mp3',
|
||||
assetObjectId: 'assetobj-audio-1',
|
||||
durationSeconds: 75,
|
||||
});
|
||||
});
|
||||
|
||||
it('creates video result layers with OSS asset identity', () => {
|
||||
const layer = createVideoResultLayer({
|
||||
generated: {
|
||||
videoSrc: '/generated-character-drafts/editor-videos/video.mp4',
|
||||
objectKey: 'generated-character-drafts/editor-videos/video.mp4',
|
||||
assetObjectId: 'assetobj-video-1',
|
||||
width: 1280,
|
||||
height: 720,
|
||||
sourceType: 'generated',
|
||||
prompt: '开场动画',
|
||||
actualPrompt: '开场动画',
|
||||
model: 'seedance2.0-fast',
|
||||
provider: 'VectorEngine',
|
||||
taskId: 'video-task-1',
|
||||
priceMudPoints: 40,
|
||||
},
|
||||
generatedIndex: 15,
|
||||
title: '生成视频 15',
|
||||
canvasSize: { width: 900, height: 640 },
|
||||
viewport: { x: 10, y: 20, scale: 2 },
|
||||
generationInputs: createGenerationInputs(),
|
||||
});
|
||||
|
||||
expect(layer).toMatchObject({
|
||||
id: 'layer-video-15',
|
||||
src: '/generated-character-drafts/editor-videos/video.mp4',
|
||||
mediaType: 'video',
|
||||
assetKind: 'video',
|
||||
objectKey: 'generated-character-drafts/editor-videos/video.mp4',
|
||||
assetObjectId: 'assetobj-video-1',
|
||||
});
|
||||
});
|
||||
|
||||
it('creates character animation result layers with action metadata', () => {
|
||||
const layer = createCharacterAnimationResultLayer({
|
||||
generated: {
|
||||
|
||||
@@ -101,16 +101,24 @@ function applyGeneratedMetadata(
|
||||
generated: EditorImageGenerationResult,
|
||||
generationInputs: CanvasGenerationInputs | undefined,
|
||||
): CanvasLayer {
|
||||
const resource = generated.resource;
|
||||
const asset = generated.asset;
|
||||
return {
|
||||
...layer,
|
||||
resourceId: resource?.resourceId ?? layer.resourceId,
|
||||
sourceAssetId: asset?.assetId ?? layer.sourceAssetId,
|
||||
prompt: generated.prompt,
|
||||
actualPrompt: generated.actualPrompt ?? generated.prompt,
|
||||
model: generated.model,
|
||||
provider: generated.provider,
|
||||
taskId: generated.taskId,
|
||||
objectKey: generated.objectKey,
|
||||
assetObjectId: generated.assetObjectId,
|
||||
generationInputs,
|
||||
objectKey: resource?.objectKey ?? generated.objectKey,
|
||||
assetObjectId: resource?.assetObjectId ?? generated.assetObjectId,
|
||||
assetKind:
|
||||
(resource?.assetKind as CanvasLayer['assetKind'] | undefined) ??
|
||||
layer.assetKind,
|
||||
generationInputs: resource?.generationInputs ?? generationInputs,
|
||||
generatedAssetSnapshot: asset,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -125,8 +133,8 @@ export function createGeneratedResultLayer({
|
||||
title,
|
||||
generationInputs,
|
||||
}: GeneratedResultLayerOptions): CanvasLayer {
|
||||
const originalWidth = generated.width || 1024;
|
||||
const originalHeight = generated.height || 1024;
|
||||
const originalWidth = frame?.originalWidth || generated.width || 1024;
|
||||
const originalHeight = frame?.originalHeight || generated.height || 1024;
|
||||
const { width, height } = resolveLayerResolutionSize(
|
||||
originalWidth,
|
||||
originalHeight,
|
||||
@@ -232,6 +240,8 @@ export function createIconSpritesheetResultLayers({
|
||||
frame,
|
||||
spritesheetTitle,
|
||||
}: IconSpritesheetResultLayerOptions): CanvasLayer[] {
|
||||
const spritesheetResource = generated.spritesheetResource;
|
||||
const spritesheetAsset = generated.spritesheetAsset;
|
||||
const worldCenter = getViewportWorldCenter({ canvasSize, viewport });
|
||||
const startX =
|
||||
frame?.x ?? worldCenter.x - ICON_FRAME_DISPLAY_SIZE.width / 2;
|
||||
@@ -250,7 +260,9 @@ export function createIconSpritesheetResultLayers({
|
||||
);
|
||||
const spritesheetLayer: CanvasLayer = {
|
||||
id: `layer-icon-spritesheet-${startIndex}`,
|
||||
resourceId: `local-resource-icon-spritesheet-${startIndex}`,
|
||||
resourceId:
|
||||
spritesheetResource?.resourceId ??
|
||||
`local-resource-icon-spritesheet-${startIndex}`,
|
||||
title: spritesheetTitle ?? `图标素材图集 ${startIndex}`,
|
||||
src: generated.spritesheetImageSrc,
|
||||
x: startX,
|
||||
@@ -267,7 +279,12 @@ export function createIconSpritesheetResultLayers({
|
||||
provider: generated.provider,
|
||||
taskId: generated.taskId,
|
||||
assetKind: 'icon-spritesheet',
|
||||
generationInputs,
|
||||
sourceAssetId: spritesheetAsset?.assetId,
|
||||
objectKey: spritesheetResource?.objectKey ?? undefined,
|
||||
assetObjectId: spritesheetResource?.assetObjectId ?? undefined,
|
||||
generationInputs:
|
||||
spritesheetResource?.generationInputs ?? generationInputs,
|
||||
generatedAssetSnapshot: spritesheetAsset,
|
||||
};
|
||||
const iconStartX = startX + spritesheetDisplaySize.width + 32;
|
||||
let cursorX = iconStartX;
|
||||
@@ -275,6 +292,8 @@ export function createIconSpritesheetResultLayers({
|
||||
let rowHeight = 0;
|
||||
|
||||
const iconLayers = iconResults.map((icon, index) => {
|
||||
const resource = icon.resource;
|
||||
const asset = icon.asset;
|
||||
const originalWidth = icon.width || 128;
|
||||
const originalHeight = icon.height || 128;
|
||||
const { width, height } = resolveLayerResolutionSize(
|
||||
@@ -291,7 +310,7 @@ export function createIconSpritesheetResultLayers({
|
||||
const generatedIndex = startIndex + index + 1;
|
||||
const layer: CanvasLayer = {
|
||||
id: `layer-icon-${generatedIndex}`,
|
||||
resourceId: `local-resource-icon-${generatedIndex}`,
|
||||
resourceId: resource?.resourceId ?? `local-resource-icon-${generatedIndex}`,
|
||||
title: icon.name,
|
||||
src: icon.imageSrc,
|
||||
x: cursorX,
|
||||
@@ -308,7 +327,11 @@ export function createIconSpritesheetResultLayers({
|
||||
provider: generated.provider,
|
||||
taskId: generated.taskId,
|
||||
assetKind: 'icon',
|
||||
generationInputs,
|
||||
sourceAssetId: asset?.assetId,
|
||||
objectKey: resource?.objectKey ?? undefined,
|
||||
assetObjectId: resource?.assetObjectId ?? undefined,
|
||||
generationInputs: resource?.generationInputs ?? generationInputs,
|
||||
generatedAssetSnapshot: asset,
|
||||
};
|
||||
|
||||
cursorX += width + spacing;
|
||||
@@ -365,6 +388,8 @@ export function createVideoResultLayer({
|
||||
model: generated.model,
|
||||
provider: generated.provider,
|
||||
taskId: generated.taskId,
|
||||
objectKey: generated.objectKey,
|
||||
assetObjectId: generated.assetObjectId,
|
||||
generationInputs,
|
||||
};
|
||||
}
|
||||
@@ -415,6 +440,8 @@ export function createAudioResultLayer({
|
||||
model: generated.model,
|
||||
provider: generated.provider,
|
||||
taskId: generated.taskId,
|
||||
objectKey: generated.objectKey,
|
||||
assetObjectId: generated.assetObjectId,
|
||||
durationSeconds: generated.durationSeconds ?? undefined,
|
||||
generationInputs,
|
||||
};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user