Merge branch 'codex/editor-asset-library' of https://git.genarrative.world/git/GenarrativeAI/Genarrative into codex/editor-asset-library

This commit is contained in:
2026-06-20 00:04:22 +08:00
236 changed files with 68658 additions and 1401 deletions
@@ -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