合并编辑器素材库分支
合并 codex/editor-asset-library 到 master 保留原生壳桥接统一分支已合入内容 合并图片编辑器素材库、模型定价和项目记忆更新 解决项目记忆踩坑记录冲突
This commit is contained in:
@@ -171,6 +171,7 @@ export function ImageCanvasBasicGenerationComposerView({
|
||||
optionLabelPrefix="生成图片"
|
||||
cost={calculateEditorImageGenerationPrice({
|
||||
model: dialog.imageModel,
|
||||
imageSize: dialog.imageSize,
|
||||
})}
|
||||
submitLabel="生成"
|
||||
submitAriaLabel="生成"
|
||||
|
||||
@@ -294,6 +294,7 @@ export function ImageCanvasCharacterGenerationComposerView({
|
||||
cost={calculateEditorImageGenerationPrice({
|
||||
kind: 'character',
|
||||
model: dialog.imageModel,
|
||||
imageSize: dialog.imageSize,
|
||||
})}
|
||||
submitLabel="生成"
|
||||
submitAriaLabel="生成"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,11 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
canvasDisplayScaleToViewportScale,
|
||||
canvasDisplayViewportToViewport,
|
||||
CANVAS_WORLD_ORIGIN,
|
||||
createLayerFromAsset,
|
||||
formatCanvasDisplayScalePercent,
|
||||
hydrateLayer,
|
||||
normalizeAssetLibrary,
|
||||
normalizeCanvasBackgroundHex,
|
||||
@@ -10,6 +13,8 @@ import {
|
||||
serializeCanvasLayout,
|
||||
serializeLayer,
|
||||
splitCanvasLayoutItems,
|
||||
viewportScaleToCanvasDisplayScale,
|
||||
viewportToCanvasDisplayViewport,
|
||||
} from './ImageCanvasEditorModel';
|
||||
import type {
|
||||
CanvasGenerationDialogState,
|
||||
@@ -18,6 +23,23 @@ import type {
|
||||
} from './ImageCanvasEditorTypes';
|
||||
|
||||
describe('ImageCanvasEditorModel', () => {
|
||||
it('maps viewport scale to the user-facing canvas zoom scale', () => {
|
||||
expect(canvasDisplayScaleToViewportScale(0.5)).toBe(0.25);
|
||||
expect(canvasDisplayScaleToViewportScale(1)).toBe(0.5);
|
||||
expect(canvasDisplayScaleToViewportScale(2)).toBe(1);
|
||||
expect(viewportScaleToCanvasDisplayScale(0.25)).toBe(0.5);
|
||||
expect(viewportScaleToCanvasDisplayScale(0.5)).toBe(1);
|
||||
expect(viewportScaleToCanvasDisplayScale(1)).toBe(2);
|
||||
expect(formatCanvasDisplayScalePercent(0.5)).toBe('100%');
|
||||
expect(formatCanvasDisplayScalePercent(Number.NaN)).toBe('100%');
|
||||
expect(
|
||||
viewportToCanvasDisplayViewport({ x: 10, y: 20, scale: 0.25 }),
|
||||
).toEqual({ x: 10, y: 20, scale: 0.5 });
|
||||
expect(
|
||||
canvasDisplayViewportToViewport({ x: 10, y: 20, scale: 2 }),
|
||||
).toEqual({ x: 10, y: 20, scale: 1 });
|
||||
});
|
||||
|
||||
it('normalizes valid canvas background hex values and rejects invalid input', () => {
|
||||
expect(normalizeCanvasBackgroundHex(' #ABC ')).toBe('#aabbcc');
|
||||
expect(normalizeCanvasBackgroundHex('#f8fafc')).toBe('#f8fafc');
|
||||
@@ -295,14 +317,29 @@ describe('ImageCanvasEditorModel', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('serializes and hydrates video thumbnails for character animation layers', () => {
|
||||
it('serializes and hydrates image sequence frames for character animation layers', () => {
|
||||
const layer: CanvasLayer = {
|
||||
id: 'layer-action',
|
||||
resourceId: 'resource-action',
|
||||
title: '角色动作',
|
||||
src: '/generated-character-drafts/editor/preview.mp4',
|
||||
mediaType: 'video',
|
||||
src: '/generated-character-drafts/editor/frame01.png',
|
||||
mediaType: 'image-sequence',
|
||||
thumbnailSrc: '/generated-character-drafts/editor/frame01.png',
|
||||
imageSequenceFrames: [
|
||||
{
|
||||
frameIndex: 1,
|
||||
imageSrc: '/generated-character-drafts/editor/frame01.png',
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
},
|
||||
{
|
||||
frameIndex: 2,
|
||||
imageSrc: '/generated-character-drafts/editor/frame02.png',
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
},
|
||||
],
|
||||
previewVideoPath: '/generated-character-drafts/editor/preview.mp4',
|
||||
x: 10,
|
||||
y: 20,
|
||||
width: 420,
|
||||
@@ -319,6 +356,8 @@ describe('ImageCanvasEditorModel', () => {
|
||||
expect(snapshot.thumbnailSrc).toBe(
|
||||
'/generated-character-drafts/editor/frame01.png',
|
||||
);
|
||||
expect(snapshot.mediaType).toBe('image-sequence');
|
||||
expect(snapshot.imageSequenceFrames).toHaveLength(2);
|
||||
|
||||
const hydrated = hydrateLayer(
|
||||
snapshot,
|
||||
@@ -334,8 +373,10 @@ describe('ImageCanvasEditorModel', () => {
|
||||
);
|
||||
|
||||
expect(hydrated).toMatchObject({
|
||||
mediaType: 'video',
|
||||
mediaType: 'image-sequence',
|
||||
thumbnailSrc: '/generated-character-drafts/editor/frame01.png',
|
||||
imageSequenceFrames: layer.imageSequenceFrames,
|
||||
previewVideoPath: '/generated-character-drafts/editor/preview.mp4',
|
||||
assetKind: 'character-animation',
|
||||
durationSeconds: 4,
|
||||
});
|
||||
|
||||
@@ -32,6 +32,7 @@ export const CANVAS_WORLD_SIZE = 12000;
|
||||
export const CANVAS_WORLD_ORIGIN = CANVAS_WORLD_SIZE / 2;
|
||||
export const MIN_SCALE = 0.24;
|
||||
export const MAX_SCALE = 3.2;
|
||||
export const CANVAS_DISPLAY_SCALE_BASE = 0.5;
|
||||
export const TOOLBAR_HALF_WIDTH = 132;
|
||||
export const DEFAULT_CANVAS_SIZE = { width: 900, height: 640 };
|
||||
export const SNAP_THRESHOLD_SCREEN_PX = 18;
|
||||
@@ -79,6 +80,39 @@ export function formatPercent(value: number) {
|
||||
return `${Math.round(value * 100)}%`;
|
||||
}
|
||||
|
||||
export function formatCanvasDisplayScalePercent(scale: number) {
|
||||
const safeScale = Number.isFinite(scale) ? scale : CANVAS_DISPLAY_SCALE_BASE;
|
||||
return formatPercent(safeScale / CANVAS_DISPLAY_SCALE_BASE);
|
||||
}
|
||||
|
||||
export function canvasDisplayScaleToViewportScale(displayScale: number) {
|
||||
const safeDisplayScale = Number.isFinite(displayScale) ? displayScale : 1;
|
||||
return safeDisplayScale * CANVAS_DISPLAY_SCALE_BASE;
|
||||
}
|
||||
|
||||
export function viewportScaleToCanvasDisplayScale(scale: number) {
|
||||
const safeScale = Number.isFinite(scale) ? scale : CANVAS_DISPLAY_SCALE_BASE;
|
||||
return safeScale / CANVAS_DISPLAY_SCALE_BASE;
|
||||
}
|
||||
|
||||
export function viewportToCanvasDisplayViewport(
|
||||
viewport: CanvasViewport,
|
||||
): CanvasViewport {
|
||||
return {
|
||||
...viewport,
|
||||
scale: viewportScaleToCanvasDisplayScale(viewport.scale),
|
||||
};
|
||||
}
|
||||
|
||||
export function canvasDisplayViewportToViewport(
|
||||
viewport: CanvasViewport,
|
||||
): CanvasViewport {
|
||||
return {
|
||||
...viewport,
|
||||
scale: canvasDisplayScaleToViewportScale(viewport.scale),
|
||||
};
|
||||
}
|
||||
|
||||
export function formatImageSizeValue(width: number, height: number) {
|
||||
const safeWidth = Math.max(1, Math.round(width || 1024));
|
||||
const safeHeight = Math.max(1, Math.round(height || 1024));
|
||||
@@ -168,6 +202,8 @@ export function serializeLayer(
|
||||
sourceType: layer.sourceType,
|
||||
mediaType: layer.mediaType,
|
||||
thumbnailSrc: layer.thumbnailSrc,
|
||||
imageSequenceFrames: layer.imageSequenceFrames,
|
||||
previewVideoPath: layer.previewVideoPath,
|
||||
prompt: layer.prompt,
|
||||
actualPrompt: layer.actualPrompt,
|
||||
model: layer.model,
|
||||
@@ -497,7 +533,11 @@ export function hydrateLayer(
|
||||
const layerId = typeof snapshot.layerId === 'string' ? snapshot.layerId : '';
|
||||
const resource = resourcesById.get(resourceId);
|
||||
const snapshotSrc = typeof snapshot.src === 'string' ? snapshot.src : '';
|
||||
const src = snapshotSrc || resource?.imageSrc || '';
|
||||
const imageSequenceFrames = hydrateImageSequenceFrames(
|
||||
snapshot.imageSequenceFrames,
|
||||
);
|
||||
const firstSequenceFrame = imageSequenceFrames[0]?.imageSrc ?? '';
|
||||
const src = snapshotSrc || firstSequenceFrame || resource?.imageSrc || '';
|
||||
const title =
|
||||
typeof snapshot.title === 'string' ? snapshot.title : '画布图片';
|
||||
if (!resourceId || !layerId || !src) {
|
||||
@@ -527,11 +567,12 @@ export function hydrateLayer(
|
||||
sourceType: isCanvasSourceType(snapshot.sourceType)
|
||||
? snapshot.sourceType
|
||||
: 'uploaded',
|
||||
mediaType:
|
||||
snapshot.mediaType === 'video' || snapshot.mediaType === 'audio'
|
||||
? snapshot.mediaType
|
||||
: 'image',
|
||||
mediaType: resolveHydratedLayerMediaType(snapshot, imageSequenceFrames),
|
||||
thumbnailSrc: stringOrNull(snapshot.thumbnailSrc),
|
||||
imageSequenceFrames: imageSequenceFrames.length
|
||||
? imageSequenceFrames
|
||||
: undefined,
|
||||
previewVideoPath: stringOrNull(snapshot.previewVideoPath),
|
||||
prompt: stringOrNull(snapshot.prompt),
|
||||
actualPrompt: stringOrNull(snapshot.actualPrompt),
|
||||
model: stringOrNull(snapshot.model),
|
||||
@@ -621,6 +662,25 @@ export function inferEditorAssetMediaType(
|
||||
return 'image';
|
||||
}
|
||||
|
||||
function resolveHydratedLayerMediaType(
|
||||
snapshot: EditorProjectLayerSnapshot,
|
||||
imageSequenceFrames: CanvasLayer['imageSequenceFrames'],
|
||||
): CanvasMediaType {
|
||||
if (snapshot.mediaType === 'image-sequence') {
|
||||
return 'image-sequence';
|
||||
}
|
||||
if (
|
||||
snapshot.assetKind === 'character-animation' &&
|
||||
(imageSequenceFrames?.length || stringOrNull(snapshot.thumbnailSrc))
|
||||
) {
|
||||
return 'image-sequence';
|
||||
}
|
||||
if (snapshot.mediaType === 'video' || snapshot.mediaType === 'audio') {
|
||||
return snapshot.mediaType;
|
||||
}
|
||||
return 'image';
|
||||
}
|
||||
|
||||
function inferAudioAssetKindFromLabel(label: string): CanvasAssetKind {
|
||||
return label.includes('背景音乐') || label.toLowerCase().includes('music')
|
||||
? 'background-music'
|
||||
@@ -754,6 +814,33 @@ function hydrateCharacterAnimationResult(
|
||||
return result as EditorCharacterAnimationGenerationResult;
|
||||
}
|
||||
|
||||
function hydrateImageSequenceFrames(
|
||||
value: unknown,
|
||||
): NonNullable<CanvasLayer['imageSequenceFrames']> {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
const frames = value.flatMap((frame, index) => {
|
||||
if (!frame || typeof frame !== 'object') {
|
||||
return [];
|
||||
}
|
||||
const snapshot = frame as Record<string, unknown>;
|
||||
const imageSrc = stringOrNull(snapshot.imageSrc);
|
||||
if (!imageSrc) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
{
|
||||
frameIndex: numberFromSnapshot(snapshot.frameIndex, index + 1),
|
||||
imageSrc,
|
||||
width: numberFromSnapshot(snapshot.width, 0),
|
||||
height: numberFromSnapshot(snapshot.height, 0),
|
||||
},
|
||||
];
|
||||
});
|
||||
return frames;
|
||||
}
|
||||
|
||||
export function booleanFromSnapshot(value: unknown) {
|
||||
return value === true;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react';
|
||||
import { createRef } from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
@@ -98,6 +98,8 @@ function createTopbarProps(): ImageCanvasTopbarViewProps {
|
||||
isProjectRenameSaving: false,
|
||||
projectRenameError: null,
|
||||
layers: [],
|
||||
walletBalanceLabel: '0泥点',
|
||||
isWalletBalanceLoading: false,
|
||||
assetExportStatus: null,
|
||||
isExportingAssets: false,
|
||||
setProjectRenameValue: vi.fn(),
|
||||
@@ -107,6 +109,7 @@ function createTopbarProps(): ImageCanvasTopbarViewProps {
|
||||
resetProjectRenameError: vi.fn(),
|
||||
exportCanvasAssets: vi.fn(),
|
||||
onOpenShortcuts: vi.fn(),
|
||||
onOpenWallet: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -131,6 +134,10 @@ function createStageProps(): ImageCanvasStageViewProps {
|
||||
generateDialog: null,
|
||||
cropExpandPanel: null,
|
||||
cropExpandSourceLayer: null,
|
||||
uiAssetExtractionState: null,
|
||||
uiAssetExtractionSourceLayer: null,
|
||||
quickEditSelectionState: null,
|
||||
quickEditSelectionSourceLayer: null,
|
||||
generationComposerStyle: null,
|
||||
selectedToolbarStyle: null,
|
||||
uploadDropTarget: null,
|
||||
@@ -170,6 +177,15 @@ function createStageProps(): ImageCanvasStageViewProps {
|
||||
onOpenCropExpandPanel: vi.fn(),
|
||||
onRemoveBackground: vi.fn(),
|
||||
onExtractUiDesignAssets: vi.fn(),
|
||||
onUiAssetExtractionToolChange: vi.fn(),
|
||||
onUiAssetExtractionPointerStart: vi.fn(),
|
||||
onUiAssetExtractionPointerMove: vi.fn(),
|
||||
onUiAssetExtractionPointerEnd: vi.fn(),
|
||||
onSubmitUiAssetExtraction: vi.fn(),
|
||||
onQuickEditSelectionToolChange: vi.fn(),
|
||||
onQuickEditSelectionPointerStart: vi.fn(),
|
||||
onQuickEditSelectionPointerMove: vi.fn(),
|
||||
onQuickEditSelectionPointerEnd: vi.fn(),
|
||||
onOpenCharacterAnimationPanel: vi.fn(),
|
||||
onDownloadLayer: vi.fn(),
|
||||
onPasteCanvasClipboard: vi.fn(),
|
||||
@@ -265,4 +281,44 @@ describe('ImageCanvasEditorShellView', () => {
|
||||
expect((preview as HTMLElement).style.left).toBe('0px');
|
||||
expect((preview as HTMLElement).style.top).toBe('48px');
|
||||
});
|
||||
|
||||
it('renders quick edit selection tools through the stage props', () => {
|
||||
const layer = createLayer();
|
||||
const handleQuickEditToolChange = vi.fn();
|
||||
const stageProps = {
|
||||
...createStageProps(),
|
||||
layers: [layer],
|
||||
quickEditSelectionSourceLayer: layer,
|
||||
quickEditSelectionState: {
|
||||
sourceLayerId: layer.id,
|
||||
tool: 'rect' as const,
|
||||
marks: [],
|
||||
draftMark: null,
|
||||
status: 'idle' as const,
|
||||
},
|
||||
onQuickEditSelectionToolChange: handleQuickEditToolChange,
|
||||
};
|
||||
|
||||
render(
|
||||
<ImageCanvasEditorShellView
|
||||
editorRootRef={createRef<HTMLElement>()}
|
||||
uploadInputRef={createRef<HTMLInputElement>()}
|
||||
onUploadInputChange={vi.fn()}
|
||||
assetDragPreview={null}
|
||||
sidebarProps={createSidebarProps()}
|
||||
topbarProps={createTopbarProps()}
|
||||
stageProps={stageProps}
|
||||
metadataProps={createMetadataProps()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const toolbar = screen.getByRole('toolbar', {
|
||||
name: '快速编辑框选工具',
|
||||
});
|
||||
fireEvent.click(
|
||||
within(toolbar).getByRole('button', { name: '椭圆框选' }),
|
||||
);
|
||||
|
||||
expect(handleQuickEditToolChange).toHaveBeenCalledWith('ellipse');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type {
|
||||
EditorAssetSnapshot,
|
||||
EditorCharacterAnimationFrameCount,
|
||||
EditorCharacterAnimationFrameResult,
|
||||
EditorCharacterAnimationGenerationResult,
|
||||
EditorCharacterAnimationRatio,
|
||||
EditorCharacterAnimationResolution,
|
||||
@@ -25,7 +26,7 @@ export type CanvasAssetKind =
|
||||
| 'sound-effect'
|
||||
| 'background-music';
|
||||
|
||||
export type CanvasMediaType = 'image' | 'video' | 'audio';
|
||||
export type CanvasMediaType = 'image' | 'video' | 'audio' | 'image-sequence';
|
||||
|
||||
export type EditorAsset = {
|
||||
id: string;
|
||||
@@ -100,6 +101,8 @@ export type CanvasLayer = {
|
||||
assetKind?: CanvasAssetKind | null;
|
||||
generationInputs?: CanvasGenerationInputs | null;
|
||||
durationSeconds?: number;
|
||||
imageSequenceFrames?: EditorCharacterAnimationFrameResult[];
|
||||
previewVideoPath?: string | null;
|
||||
hidden?: boolean;
|
||||
locked?: boolean;
|
||||
flipX?: boolean;
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
} from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import JSZip from 'jszip';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { afterEach, 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 getPlatformProfileDashboardMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('../../services/image-editor/editorProjectClient', async () => {
|
||||
const actual = await vi.importActual<
|
||||
@@ -64,6 +65,10 @@ vi.mock('../../services/image-editor/editorProjectClient', async () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../services/platform-entry/platformProfileClient', () => ({
|
||||
getPlatformProfileDashboard: getPlatformProfileDashboardMock,
|
||||
}));
|
||||
|
||||
describe('ImageCanvasEditorView', () => {
|
||||
setupImageCanvasEditorViewTestLifecycle({
|
||||
generateEditorImageMock,
|
||||
@@ -84,6 +89,19 @@ describe('ImageCanvasEditorView', () => {
|
||||
saveEditorProjectLayoutMock,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
getPlatformProfileDashboardMock.mockResolvedValue({
|
||||
walletBalance: 1234,
|
||||
totalPlayTimeMs: 0,
|
||||
playedWorldCount: 0,
|
||||
updatedAt: null,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
getPlatformProfileDashboardMock.mockReset();
|
||||
});
|
||||
|
||||
it('loads the project from projectid query before falling back to recent project', async () => {
|
||||
loadEditorProjectMock.mockResolvedValueOnce({
|
||||
projectId: 'editor-project-query',
|
||||
@@ -157,6 +175,67 @@ describe('ImageCanvasEditorView', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('shows the live mud point balance in the canvas topbar when logged in', async () => {
|
||||
render(
|
||||
<AuthUiContext.Provider
|
||||
value={createAuthValue({
|
||||
user: {
|
||||
id: 'user-1',
|
||||
publicUserCode: 'U001',
|
||||
displayName: '测试用户',
|
||||
avatarUrl: null,
|
||||
phoneNumberMasked: '138****0000',
|
||||
loginMethod: 'password',
|
||||
bindingStatus: 'active',
|
||||
wechatBound: false,
|
||||
},
|
||||
canAccessProtectedData: true,
|
||||
})}
|
||||
>
|
||||
<ImageCanvasEditorView />
|
||||
</AuthUiContext.Provider>,
|
||||
);
|
||||
|
||||
expect(await screen.findByLabelText('泥点余额 1,234泥点')).toBeTruthy();
|
||||
expect(getPlatformProfileDashboardMock).toHaveBeenCalledWith({
|
||||
authImpact: 'local',
|
||||
skipRefresh: true,
|
||||
notifyAuthStateChange: false,
|
||||
clearAuthOnUnauthorized: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('opens the same account wallet entry from the canvas topbar mud point button', async () => {
|
||||
render(
|
||||
<AuthUiContext.Provider
|
||||
value={createAuthValue({
|
||||
user: {
|
||||
id: 'user-1',
|
||||
publicUserCode: 'U001',
|
||||
displayName: '测试用户',
|
||||
avatarUrl: null,
|
||||
phoneNumberMasked: '138****0000',
|
||||
loginMethod: 'password',
|
||||
bindingStatus: 'active',
|
||||
wechatBound: false,
|
||||
},
|
||||
canAccessProtectedData: true,
|
||||
})}
|
||||
>
|
||||
<ImageCanvasEditorView />
|
||||
</AuthUiContext.Provider>,
|
||||
);
|
||||
|
||||
const walletButton = await screen.findByRole('button', {
|
||||
name: '泥点余额 1,234泥点',
|
||||
});
|
||||
|
||||
fireEvent.click(walletButton);
|
||||
|
||||
expect(await screen.findByRole('dialog', { name: '兑换码' })).toBeTruthy();
|
||||
expect(screen.getByPlaceholderText('输入兑换码')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('opens the login modal immediately when entering the editor while logged out', async () => {
|
||||
const openLoginModal = vi.fn();
|
||||
|
||||
@@ -281,6 +360,7 @@ describe('ImageCanvasEditorView', () => {
|
||||
originalHeight: 1024,
|
||||
zIndex: 3,
|
||||
sourceType: 'generated',
|
||||
sourceResourceId: 'resource-generated',
|
||||
prompt: '明亮主视觉',
|
||||
model: 'gpt-image-2',
|
||||
provider: 'VectorEngine',
|
||||
@@ -299,6 +379,7 @@ describe('ImageCanvasEditorView', () => {
|
||||
originalHeight: 120,
|
||||
zIndex: 4,
|
||||
sourceType: 'generated',
|
||||
sourceResourceId: 'resource-failed',
|
||||
},
|
||||
],
|
||||
resources: [],
|
||||
@@ -328,7 +409,8 @@ describe('ImageCanvasEditorView', () => {
|
||||
});
|
||||
const originalFetch = globalThis.fetch;
|
||||
const fetchMock = vi.fn(async (url: string) => {
|
||||
if (url === '/generated-ok.png') {
|
||||
const pathname = new URL(url, 'http://localhost').pathname;
|
||||
if (pathname === '/generated-ok.png') {
|
||||
return new Response(new Blob(['generated'], { type: 'image/png' }));
|
||||
}
|
||||
return new Response(null, { status: 404 });
|
||||
@@ -1093,7 +1175,7 @@ describe('ImageCanvasEditorView', () => {
|
||||
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '缩放至100%' }));
|
||||
expect(
|
||||
screen.getByRole('button', { name: /当前缩放比例 \d+%/u }),
|
||||
screen.getByRole('button', { name: '当前缩放比例 100%' }),
|
||||
).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '当前缩放比例 100%' }));
|
||||
@@ -1216,6 +1298,17 @@ describe('ImageCanvasEditorView', () => {
|
||||
expect(ctrlWheelEvent.defaultPrevented).toBe(true);
|
||||
});
|
||||
|
||||
it('renders 100 percent zoom with the previous 50 percent visual scale', () => {
|
||||
render(<ImageCanvasEditorView />);
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: '当前缩放比例 100%' }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
(screen.getByTestId('image-canvas-world') as HTMLElement).style.transform,
|
||||
).toBe('translate(0px, 0px) scale(0.5)');
|
||||
});
|
||||
|
||||
it('selects multiple canvas layers with shift click', async () => {
|
||||
render(<ImageCanvasEditorView />);
|
||||
|
||||
@@ -1497,8 +1590,8 @@ describe('ImageCanvasEditorView', () => {
|
||||
});
|
||||
dispatchPointerEvent(screen.getByLabelText('画布工作区'), 'pointermove', {
|
||||
pointerId: 21,
|
||||
clientX: -60,
|
||||
clientY: 180,
|
||||
clientX: 30,
|
||||
clientY: 150,
|
||||
});
|
||||
|
||||
expect(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
type Dispatch,
|
||||
type PointerEvent as ReactPointerEvent,
|
||||
type SetStateAction,
|
||||
useCallback,
|
||||
useEffect,
|
||||
@@ -12,9 +13,20 @@ import {
|
||||
createEditorAsset,
|
||||
createEditorProjectResource,
|
||||
type EditorAssetSnapshot,
|
||||
loadEditorGenerationPricing,
|
||||
} from '../../services/image-editor/editorProjectClient';
|
||||
import { shouldShowRechargeEntry } from '../../services/payment/paymentPlatform';
|
||||
import { getPlatformProfileDashboard } from '../../services/platform-entry/platformProfileClient';
|
||||
import { useAuthUi } from '../auth/AuthUiContext';
|
||||
import { PlatformDangerConfirmDialog } from '../common/PlatformDangerConfirmDialog';
|
||||
import { PlatformProfileRechargeModal } from '../platform-entry/PlatformProfileRechargeModal';
|
||||
import { PlatformProfileRewardCodeRedeemModal } from '../platform-entry/PlatformProfileRewardCodeRedeemModal';
|
||||
import {
|
||||
PlatformRechargePaymentConfirmationMask,
|
||||
PlatformRechargePaymentResultDialog,
|
||||
} from '../platform-entry/PlatformRechargePaymentStatusDialogs';
|
||||
import { usePlatformProfileCenterController } from '../platform-entry/usePlatformProfileCenterController';
|
||||
import { formatDashboardCount } from '../rpg-entry/rpgEntryProfileDashboardPresentation';
|
||||
import {
|
||||
canvasAssetKindOrNull,
|
||||
generationInputsOrNull,
|
||||
@@ -34,6 +46,7 @@ import type {
|
||||
QuickEditPanelState,
|
||||
} from './ImageCanvasEditorTypes';
|
||||
import { getEditorUploadAccept } from './ImageCanvasFileModel';
|
||||
import { applyEditorGenerationPricingConfig } from './ImageCanvasGenerationModel';
|
||||
import { isCanvasGenerationComposerVisible } from './ImageCanvasOverlayModel';
|
||||
import {
|
||||
getCanvasGenerationSelectionId,
|
||||
@@ -63,6 +76,11 @@ import {
|
||||
|
||||
export function ImageCanvasEditorView() {
|
||||
const authUi = useAuthUi();
|
||||
const [, setGenerationPricingVersion] = useState(0);
|
||||
const [walletBalanceLabel, setWalletBalanceLabel] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [isWalletBalanceLoading, setIsWalletBalanceLoading] = useState(false);
|
||||
const editorRootRef = useRef<HTMLElement | null>(null);
|
||||
const canvasViewportRef = useRef<HTMLDivElement | null>(null);
|
||||
const assetListRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -84,6 +102,25 @@ export function ImageCanvasEditorView() {
|
||||
const publicationReferenceButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
const iconSpecButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
const selectedLayerIdRef = useRef<string | null>(null);
|
||||
const showRechargeEntry = shouldShowRechargeEntry();
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
void loadEditorGenerationPricing()
|
||||
.then((pricing) => {
|
||||
if (!isMounted) {
|
||||
return;
|
||||
}
|
||||
applyEditorGenerationPricingConfig(pricing);
|
||||
setGenerationPricingVersion((version) => version + 1);
|
||||
})
|
||||
.catch(() => {
|
||||
// 中文注释:价格接口异常时保留前端内置兜底,不阻断画板使用。
|
||||
});
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, []);
|
||||
const selectedLayerIdsRef = useRef<string[]>([]);
|
||||
const setQuickEditPanelRef = useRef<
|
||||
Dispatch<SetStateAction<QuickEditPanelState | null>>
|
||||
@@ -140,6 +177,56 @@ export function ImageCanvasEditorView() {
|
||||
},
|
||||
[],
|
||||
);
|
||||
const refreshEditorWalletBalance = useCallback(() => {
|
||||
if (!authUiRef.current?.canAccessProtectedData || !authUiRef.current.user) {
|
||||
return;
|
||||
}
|
||||
void getPlatformProfileDashboard({
|
||||
authImpact: 'local',
|
||||
skipRefresh: true,
|
||||
notifyAuthStateChange: false,
|
||||
clearAuthOnUnauthorized: false,
|
||||
})
|
||||
.then((dashboard) => {
|
||||
setWalletBalanceLabel(
|
||||
`${formatDashboardCount(dashboard.walletBalance)}泥点`,
|
||||
);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}, []);
|
||||
const {
|
||||
activeRechargeTab,
|
||||
buyRechargeProduct,
|
||||
confirmNativeWechatPayment,
|
||||
isLoadingRechargeCenter,
|
||||
isRechargeOpen,
|
||||
isRewardCodeOpen,
|
||||
isSubmittingRewardCode,
|
||||
loadRechargeCenter,
|
||||
nativeWechatPayment,
|
||||
openRechargeOrRewardCodeModal,
|
||||
rechargeCenter,
|
||||
rechargeError,
|
||||
rechargePaymentResult,
|
||||
rewardCodeError,
|
||||
rewardCodeInput,
|
||||
rewardCodeSuccess,
|
||||
setActiveRechargeTab,
|
||||
setIsRechargeOpen,
|
||||
setIsRewardCodeOpen,
|
||||
setRechargePaymentResult,
|
||||
setRewardCodeInput,
|
||||
submittingRechargeProductId,
|
||||
submitRewardCode,
|
||||
wechatRechargeOrderConfirmationState,
|
||||
} = usePlatformProfileCenterController({
|
||||
activeTab: 'editor-canvas',
|
||||
isAuthenticated: Boolean(authUi?.user),
|
||||
showRechargeEntry,
|
||||
onRechargeSuccess: refreshEditorWalletBalance,
|
||||
requestLogin: () => authUiRef.current?.openLoginModal(),
|
||||
currentUser: authUi?.user ?? null,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!authUi || authUi.user || authUi.canAccessProtectedData) {
|
||||
@@ -154,6 +241,66 @@ export function ImageCanvasEditorView() {
|
||||
window.location.reload();
|
||||
});
|
||||
}, [authUi]);
|
||||
useEffect(() => {
|
||||
if (!authUi?.canAccessProtectedData || !authUi.user) {
|
||||
setWalletBalanceLabel(null);
|
||||
setIsWalletBalanceLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let isMounted = true;
|
||||
let requestId = 0;
|
||||
|
||||
const refreshWalletBalance = () => {
|
||||
const currentRequestId = requestId + 1;
|
||||
requestId = currentRequestId;
|
||||
setIsWalletBalanceLoading(true);
|
||||
void getPlatformProfileDashboard({
|
||||
authImpact: 'local',
|
||||
skipRefresh: true,
|
||||
notifyAuthStateChange: false,
|
||||
clearAuthOnUnauthorized: false,
|
||||
})
|
||||
.then((dashboard) => {
|
||||
if (!isMounted || currentRequestId !== requestId) {
|
||||
return;
|
||||
}
|
||||
setWalletBalanceLabel(`${formatDashboardCount(dashboard.walletBalance)}泥点`);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!isMounted || currentRequestId !== requestId) {
|
||||
return;
|
||||
}
|
||||
setWalletBalanceLabel(null);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!isMounted || currentRequestId !== requestId) {
|
||||
return;
|
||||
}
|
||||
setIsWalletBalanceLoading(false);
|
||||
});
|
||||
};
|
||||
|
||||
refreshWalletBalance();
|
||||
|
||||
const handleWindowFocus = () => {
|
||||
refreshWalletBalance();
|
||||
};
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
refreshWalletBalance();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('focus', handleWindowFocus);
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
window.removeEventListener('focus', handleWindowFocus);
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
};
|
||||
}, [authUi?.canAccessProtectedData, authUi?.user?.id]);
|
||||
const {
|
||||
projectTitle,
|
||||
setProjectTitle,
|
||||
@@ -663,6 +810,20 @@ export function ImageCanvasEditorView() {
|
||||
pickUiDesignSpecFromLayer,
|
||||
hideGeneratedLayerPanelAfterBlur,
|
||||
clearDeletedLayerGenerationState,
|
||||
uiAssetExtractionState,
|
||||
uiAssetExtractionSourceLayer,
|
||||
quickEditSelectionState,
|
||||
quickEditSelectionSourceLayer,
|
||||
changeUiAssetExtractionTool,
|
||||
changeQuickEditSelectionTool,
|
||||
startUiAssetExtractionPointer,
|
||||
startQuickEditSelectionPointer,
|
||||
moveUiAssetExtractionPointer,
|
||||
moveQuickEditSelectionPointer,
|
||||
endUiAssetExtractionPointer,
|
||||
endQuickEditSelectionPointer,
|
||||
cancelUiAssetExtraction,
|
||||
submitUiAssetExtraction,
|
||||
generationComposerStyle,
|
||||
generationComposerNode,
|
||||
switchGenerationTool,
|
||||
@@ -788,6 +949,17 @@ export function ImageCanvasEditorView() {
|
||||
onCloseImageContextMenu: () => setImageContextMenu(null),
|
||||
});
|
||||
resetCanvasInteractionStateRef.current = clearActiveInteraction;
|
||||
const handleCanvasPointerDownWithUiExtractionDismiss = useCallback(
|
||||
(event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
if (uiAssetExtractionState) {
|
||||
event.preventDefault();
|
||||
cancelUiAssetExtraction();
|
||||
return;
|
||||
}
|
||||
handleCanvasPointerDown(event);
|
||||
},
|
||||
[cancelUiAssetExtraction, handleCanvasPointerDown, uiAssetExtractionState],
|
||||
);
|
||||
const deleteLayerByIdFromShortcut = useCallback(
|
||||
(layerId: string | null) => deleteLayerByIdRef.current(layerId),
|
||||
[],
|
||||
@@ -1144,6 +1316,8 @@ export function ImageCanvasEditorView() {
|
||||
isProjectRenameSaving,
|
||||
projectRenameError,
|
||||
layers,
|
||||
walletBalanceLabel,
|
||||
isWalletBalanceLoading,
|
||||
assetExportStatus,
|
||||
isExportingAssets,
|
||||
setProjectRenameValue,
|
||||
@@ -1153,6 +1327,7 @@ export function ImageCanvasEditorView() {
|
||||
resetProjectRenameError,
|
||||
exportCanvasAssets,
|
||||
onOpenShortcuts: () => setIsShortcutDialogOpen(true),
|
||||
onOpenWallet: openRechargeOrRewardCodeModal,
|
||||
};
|
||||
const stageProps = {
|
||||
canvasViewportRef,
|
||||
@@ -1174,6 +1349,10 @@ export function ImageCanvasEditorView() {
|
||||
generateDialog,
|
||||
cropExpandPanel: generationSurface.cropExpandPanel,
|
||||
cropExpandSourceLayer: generationSurface.cropExpandSourceLayer,
|
||||
uiAssetExtractionState,
|
||||
uiAssetExtractionSourceLayer,
|
||||
quickEditSelectionState,
|
||||
quickEditSelectionSourceLayer,
|
||||
generationComposerStyle,
|
||||
selectedToolbarStyle,
|
||||
uploadDropTarget,
|
||||
@@ -1190,7 +1369,7 @@ export function ImageCanvasEditorView() {
|
||||
activeSidebarPanel,
|
||||
isMinimapOpen,
|
||||
minimapModel,
|
||||
onCanvasPointerDown: handleCanvasPointerDown,
|
||||
onCanvasPointerDown: handleCanvasPointerDownWithUiExtractionDismiss,
|
||||
onCanvasPointerMove: handlePointerMove,
|
||||
onCanvasPointerUp: finishDrag,
|
||||
onCanvasDragOver: handleCanvasDragOver,
|
||||
@@ -1220,6 +1399,15 @@ export function ImageCanvasEditorView() {
|
||||
onOpenCropExpandPanel: openCropExpandPanel,
|
||||
onRemoveBackground: removeSelectedLayerBackground,
|
||||
onExtractUiDesignAssets: extractUiDesignAssets,
|
||||
onUiAssetExtractionToolChange: changeUiAssetExtractionTool,
|
||||
onUiAssetExtractionPointerStart: startUiAssetExtractionPointer,
|
||||
onUiAssetExtractionPointerMove: moveUiAssetExtractionPointer,
|
||||
onUiAssetExtractionPointerEnd: endUiAssetExtractionPointer,
|
||||
onSubmitUiAssetExtraction: () => void submitUiAssetExtraction(),
|
||||
onQuickEditSelectionToolChange: changeQuickEditSelectionTool,
|
||||
onQuickEditSelectionPointerStart: startQuickEditSelectionPointer,
|
||||
onQuickEditSelectionPointerMove: moveQuickEditSelectionPointer,
|
||||
onQuickEditSelectionPointerEnd: endQuickEditSelectionPointer,
|
||||
onOpenCharacterAnimationPanel: openCharacterAnimationPanel,
|
||||
onDownloadLayer: exportLayerImage,
|
||||
onPasteCanvasClipboard: pasteCanvasClipboard,
|
||||
@@ -1283,6 +1471,43 @@ export function ImageCanvasEditorView() {
|
||||
open={isShortcutDialogOpen}
|
||||
onClose={() => setIsShortcutDialogOpen(false)}
|
||||
/>
|
||||
{isRewardCodeOpen ? (
|
||||
<PlatformProfileRewardCodeRedeemModal
|
||||
value={rewardCodeInput}
|
||||
isSubmitting={isSubmittingRewardCode}
|
||||
error={rewardCodeError}
|
||||
success={rewardCodeSuccess}
|
||||
onChange={setRewardCodeInput}
|
||||
onSubmit={submitRewardCode}
|
||||
onClose={() => setIsRewardCodeOpen(false)}
|
||||
/>
|
||||
) : null}
|
||||
{isRechargeOpen ? (
|
||||
<PlatformProfileRechargeModal
|
||||
center={rechargeCenter}
|
||||
isLoading={isLoadingRechargeCenter}
|
||||
error={rechargeError}
|
||||
submittingProductId={submittingRechargeProductId}
|
||||
nativePayment={nativeWechatPayment}
|
||||
activeTab={activeRechargeTab}
|
||||
onTabChange={setActiveRechargeTab}
|
||||
onClose={() => setIsRechargeOpen(false)}
|
||||
onRetry={loadRechargeCenter}
|
||||
onBuy={buyRechargeProduct}
|
||||
onConfirmNativePayment={confirmNativeWechatPayment}
|
||||
/>
|
||||
) : null}
|
||||
{rechargePaymentResult ? (
|
||||
<PlatformRechargePaymentResultDialog
|
||||
result={rechargePaymentResult}
|
||||
onClose={() => setRechargePaymentResult(null)}
|
||||
/>
|
||||
) : null}
|
||||
{wechatRechargeOrderConfirmationState ? (
|
||||
<PlatformRechargePaymentConfirmationMask
|
||||
orderId={wechatRechargeOrderConfirmationState.orderId}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -41,6 +41,21 @@ describe('ImageCanvasExportModel', () => {
|
||||
src: 'data:image/png;base64,one',
|
||||
}),
|
||||
).toBe('object-key');
|
||||
expect(
|
||||
getLayerExportKey({
|
||||
...buildLayer(),
|
||||
mediaType: 'image-sequence',
|
||||
taskId: 'sequence-task',
|
||||
imageSequenceFrames: [
|
||||
{
|
||||
frameIndex: 1,
|
||||
imageSrc: '/generated-character-drafts/editor/frame01.png',
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toBe('image-sequence:sequence-task');
|
||||
});
|
||||
|
||||
it('detects image extensions from content type before falling back to src', () => {
|
||||
@@ -70,6 +85,13 @@ describe('ImageCanvasExportModel', () => {
|
||||
'/generated-audios/effect.wav?token=1',
|
||||
),
|
||||
).toBe('wav');
|
||||
expect(
|
||||
getLayerAssetExtensionFromTypeOrSrc(
|
||||
'image-sequence',
|
||||
'',
|
||||
'/generated-character-drafts/editor/frame01.png',
|
||||
),
|
||||
).toBe('zip');
|
||||
});
|
||||
|
||||
it('converts data URLs and builds layer metadata for manifest files', async () => {
|
||||
|
||||
@@ -25,6 +25,14 @@ export function formatExportDate(date: Date) {
|
||||
}
|
||||
|
||||
export function getLayerExportKey(layer: CanvasLayer) {
|
||||
if (layer.mediaType === 'image-sequence') {
|
||||
return `image-sequence:${
|
||||
layer.taskId ||
|
||||
layer.sourceResourceId ||
|
||||
layer.imageSequenceFrames?.map((frame) => frame.imageSrc).join('|') ||
|
||||
layer.src
|
||||
}`;
|
||||
}
|
||||
return (
|
||||
layer.assetObjectId ||
|
||||
layer.objectKey ||
|
||||
@@ -49,7 +57,9 @@ function pickKnownExtension(
|
||||
allowedExtensions: string[],
|
||||
fallback: string,
|
||||
) {
|
||||
return extension && allowedExtensions.includes(extension) ? extension : fallback;
|
||||
return extension && allowedExtensions.includes(extension)
|
||||
? extension
|
||||
: fallback;
|
||||
}
|
||||
|
||||
export function getLayerAssetExtensionFromTypeOrSrc(
|
||||
@@ -59,6 +69,9 @@ export function getLayerAssetExtensionFromTypeOrSrc(
|
||||
) {
|
||||
const normalizedType = type.toLowerCase();
|
||||
const extensionFromSrc = getExtensionFromSrc(src);
|
||||
if (mediaType === 'image-sequence') {
|
||||
return 'zip';
|
||||
}
|
||||
if (mediaType === 'audio') {
|
||||
if (normalizedType.includes('mpeg') || normalizedType.includes('mp3')) {
|
||||
return 'mp3';
|
||||
@@ -129,26 +142,77 @@ export function dataUrlToBlob(dataUrl: string) {
|
||||
return new Blob([bytes], { type });
|
||||
}
|
||||
|
||||
export async function readLayerAssetBlob(layer: CanvasLayer) {
|
||||
if (layer.src.startsWith('data:')) {
|
||||
return dataUrlToBlob(layer.src);
|
||||
export async function readAssetSourceBlob({
|
||||
source,
|
||||
objectKey,
|
||||
refreshKey,
|
||||
}: {
|
||||
source: string;
|
||||
objectKey?: string | null;
|
||||
refreshKey?: string | number | null;
|
||||
}) {
|
||||
if (source.startsWith('data:')) {
|
||||
return dataUrlToBlob(source);
|
||||
}
|
||||
const source = layer.objectKey
|
||||
? await getSignedAssetReadUrl(
|
||||
{ objectKey: layer.objectKey },
|
||||
undefined,
|
||||
{ cacheVersion: layer.taskId ?? layer.resourceId },
|
||||
)
|
||||
: await resolveAssetReadUrl(layer.src);
|
||||
const response = await fetch(source);
|
||||
const resolvedSource = objectKey
|
||||
? await getSignedAssetReadUrl({ objectKey }, undefined, {
|
||||
cacheVersion: refreshKey,
|
||||
})
|
||||
: await resolveAssetReadUrl(source, { refreshKey });
|
||||
const response = await fetch(resolvedSource);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
return response.blob();
|
||||
}
|
||||
|
||||
export async function readLayerAssetBlob(layer: CanvasLayer) {
|
||||
return readAssetSourceBlob({
|
||||
source: layer.src,
|
||||
objectKey: layer.objectKey,
|
||||
refreshKey: layer.taskId ?? layer.resourceId,
|
||||
});
|
||||
}
|
||||
|
||||
export const readLayerImageBlob = readLayerAssetBlob;
|
||||
|
||||
export function getLayerImageSequenceFrames(layer: CanvasLayer) {
|
||||
if (layer.imageSequenceFrames?.length) {
|
||||
return layer.imageSequenceFrames;
|
||||
}
|
||||
const fallbackSrc = layer.thumbnailSrc?.trim() || layer.src.trim();
|
||||
return fallbackSrc
|
||||
? [
|
||||
{
|
||||
frameIndex: 1,
|
||||
imageSrc: fallbackSrc,
|
||||
width: layer.originalWidth,
|
||||
height: layer.originalHeight,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
}
|
||||
|
||||
export async function readLayerImageSequenceFrameBlob(
|
||||
layer: CanvasLayer,
|
||||
frame: ReturnType<typeof getLayerImageSequenceFrames>[number],
|
||||
) {
|
||||
return readAssetSourceBlob({
|
||||
source: frame.imageSrc,
|
||||
refreshKey: `${layer.taskId ?? layer.resourceId}:${frame.frameIndex}`,
|
||||
});
|
||||
}
|
||||
|
||||
export function getImageSequenceFrameFileName(
|
||||
frame: ReturnType<typeof getLayerImageSequenceFrames>[number],
|
||||
index: number,
|
||||
blobType = '',
|
||||
) {
|
||||
const extension = getImageExtensionFromTypeOrSrc(blobType, frame.imageSrc);
|
||||
const frameNumber = String(frame.frameIndex || index + 1).padStart(2, '0');
|
||||
return `frame-${frameNumber}.${extension}`;
|
||||
}
|
||||
|
||||
export async function blobToUint8Array(blob: Blob) {
|
||||
if (typeof blob.arrayBuffer === 'function') {
|
||||
return new Uint8Array(await blob.arrayBuffer());
|
||||
|
||||
@@ -510,6 +510,23 @@ describe('ImageCanvasGenerationComposerView', () => {
|
||||
const paramsPanel = screen.getByRole('menu', {
|
||||
name: '视频参数选项',
|
||||
});
|
||||
const ratioChoice = within(paramsPanel).getByRole('button', {
|
||||
name: '比例 16:9',
|
||||
});
|
||||
expect(ratioChoice.className).toContain(
|
||||
'image-canvas-editor__option-popover-choice--ratio',
|
||||
);
|
||||
expect(
|
||||
ratioChoice
|
||||
.querySelector('.image-canvas-editor__ratio-wireframe')
|
||||
?.getAttribute('data-ratio'),
|
||||
).toBe('16:9');
|
||||
expect(
|
||||
within(paramsPanel)
|
||||
.getByRole('button', { name: '比例 21:9' })
|
||||
.querySelector('.image-canvas-editor__ratio-wireframe')
|
||||
?.getAttribute('data-ratio'),
|
||||
).toBe('21:9');
|
||||
fireEvent.change(within(paramsPanel).getByRole('slider', { name: '视频时长' }), {
|
||||
target: { value: '5' },
|
||||
});
|
||||
@@ -614,14 +631,18 @@ describe('ImageCanvasGenerationComposerView', () => {
|
||||
within(panel).getByRole('button', { name: '音效时长 5秒' }),
|
||||
);
|
||||
const optionPanel = screen.getByRole('menu', { name: '音效时长选项' });
|
||||
const durationSlider = within(optionPanel).getByRole('slider', {
|
||||
name: '音效时长',
|
||||
}) as HTMLInputElement;
|
||||
expect(durationSlider.min).toBe('2');
|
||||
expect(durationSlider.max).toBe('10');
|
||||
expect(durationSlider.step).toBe('1');
|
||||
expect(durationSlider.value).toBe('5');
|
||||
expect(
|
||||
within(optionPanel).getByRole('button', { name: '时长 2秒' }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
within(optionPanel).getByRole('button', { name: '时长 10秒' }),
|
||||
).toBeTruthy();
|
||||
within(optionPanel).queryByRole('button', { name: '时长 2秒' }),
|
||||
).toBeNull();
|
||||
expect(within(optionPanel).queryByText(/BPM/u)).toBeNull();
|
||||
fireEvent.click(within(optionPanel).getByRole('button', { name: '时长 8秒' }));
|
||||
fireEvent.change(durationSlider, { target: { value: '8' } });
|
||||
|
||||
expect(screen.getByLabelText('当前音效时长').textContent).toBe('8');
|
||||
expect(
|
||||
|
||||
@@ -47,7 +47,6 @@ import {
|
||||
DEFAULT_VIDEO_WEB_SEARCH_ENABLED,
|
||||
EDITOR_VIDEO_ASPECT_RATIO_OPTIONS,
|
||||
EDITOR_SOUND_EFFECT_MODEL_OPTIONS,
|
||||
SOUND_EFFECT_DURATION_OPTIONS,
|
||||
EDITOR_VIDEO_MODEL_OPTIONS,
|
||||
EDITOR_VIDEO_RESOLUTION_OPTIONS,
|
||||
VIDEO_MODEL_SEEDANCE_2_FAST,
|
||||
@@ -430,11 +429,12 @@ function ImageCanvasVideoGenerationComposerView({
|
||||
<span className="image-canvas-editor__option-popover-title">
|
||||
比例
|
||||
</span>
|
||||
<div className="image-canvas-editor__option-popover-items">
|
||||
<div className="image-canvas-editor__option-popover-items image-canvas-editor__option-popover-items--card">
|
||||
{EDITOR_VIDEO_ASPECT_RATIO_OPTIONS.map((option) => (
|
||||
<VideoOptionChoice
|
||||
key={option}
|
||||
selected={aspectRatio === option}
|
||||
className="image-canvas-editor__option-popover-choice--ratio"
|
||||
disabled={isGenerating}
|
||||
ariaLabel={`比例 ${option}`}
|
||||
onClick={() =>
|
||||
@@ -443,7 +443,12 @@ function ImageCanvasVideoGenerationComposerView({
|
||||
})
|
||||
}
|
||||
>
|
||||
{option}
|
||||
<span
|
||||
className="image-canvas-editor__ratio-wireframe"
|
||||
data-ratio={option}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>{option}</span>
|
||||
</VideoOptionChoice>
|
||||
))}
|
||||
</div>
|
||||
@@ -811,18 +816,27 @@ function ImageCanvasAudioGenerationComposerView({
|
||||
'above',
|
||||
)}
|
||||
>
|
||||
<div className="image-canvas-editor__option-popover-items image-canvas-editor__audio-type-options">
|
||||
{SOUND_EFFECT_DURATION_OPTIONS.map((option) => (
|
||||
<VideoOptionChoice
|
||||
key={option}
|
||||
selected={soundDuration === option}
|
||||
disabled={isGenerating}
|
||||
ariaLabel={`时长 ${option}秒`}
|
||||
onClick={() => updateSoundDuration(option)}
|
||||
>
|
||||
{option}秒
|
||||
</VideoOptionChoice>
|
||||
))}
|
||||
<div className="image-canvas-editor__option-popover-sections">
|
||||
<section className="image-canvas-editor__option-popover-section">
|
||||
<span className="image-canvas-editor__option-popover-title">
|
||||
时长
|
||||
</span>
|
||||
<div className="image-canvas-editor__video-duration-slider">
|
||||
<input
|
||||
type="range"
|
||||
min={2}
|
||||
max={10}
|
||||
step={1}
|
||||
value={soundDuration}
|
||||
disabled={isGenerating}
|
||||
aria-label="音效时长"
|
||||
onChange={(event) =>
|
||||
updateSoundDuration(Number(event.target.value))
|
||||
}
|
||||
/>
|
||||
<span>{soundDuration}秒</span>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</PlatformFloatingMenu>,
|
||||
)
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
createIconGenerationDialogDraft,
|
||||
createPublicationGenerationDialogDraft,
|
||||
createQuickEditPanelDraft,
|
||||
createRedrawPanelDraft,
|
||||
createSoundEffectGenerationDialogDraft,
|
||||
createSpecDialogDraft,
|
||||
createUiDesignGenerationDialogDraft,
|
||||
@@ -263,7 +264,7 @@ describe('ImageCanvasGenerationDialogModel', () => {
|
||||
title: '游戏音效',
|
||||
mediaType: 'audio',
|
||||
assetKind: 'sound-effect',
|
||||
prompt: '金币收集音',
|
||||
prompt: '【系统内置】完整音效提示词:金币收集音,自动混音,内部参数。',
|
||||
generationInputs: {
|
||||
fields: [
|
||||
{ title: 'prompt', value: '金币收集音' },
|
||||
@@ -315,20 +316,19 @@ describe('ImageCanvasGenerationDialogModel', () => {
|
||||
expect(createAudioRedrawGenerationDialogDraft(createLayer())).toBeNull();
|
||||
});
|
||||
|
||||
it('uses audio prompt templates when remodeling old audio layers without prompts', () => {
|
||||
it('keeps audio remodel prompts blank when no user input snapshot exists', () => {
|
||||
expect(
|
||||
createAudioRedrawGenerationDialogDraft(
|
||||
createLayer({
|
||||
mediaType: 'audio',
|
||||
assetKind: 'sound-effect',
|
||||
prompt: '',
|
||||
prompt: '【系统内置】完整音效提示词',
|
||||
generationInputs: { fields: [], references: [] },
|
||||
}),
|
||||
),
|
||||
).toMatchObject({
|
||||
mode: 'audio-sound-effect',
|
||||
prompt:
|
||||
'短促清脆的游戏交互音效,带轻微闪光质感,干净收尾,适合按钮确认或收集奖励。',
|
||||
prompt: '',
|
||||
});
|
||||
expect(
|
||||
createAudioRedrawGenerationDialogDraft(
|
||||
@@ -336,26 +336,33 @@ describe('ImageCanvasGenerationDialogModel', () => {
|
||||
mediaType: 'audio',
|
||||
assetKind: 'background-music',
|
||||
title: '游戏背景音乐',
|
||||
prompt: '',
|
||||
generationInputs: { fields: [], references: [] },
|
||||
prompt: '【系统内置】完整背景音乐提示词',
|
||||
generationInputs: {
|
||||
fields: [{ title: 'duration', value: '10秒' }],
|
||||
references: [],
|
||||
},
|
||||
}),
|
||||
),
|
||||
).toMatchObject({
|
||||
mode: 'audio-background-music',
|
||||
prompt: '适合轻松冒险游戏的循环背景音乐,温暖明亮,旋律简洁,不包含人声。',
|
||||
prompt: '',
|
||||
});
|
||||
});
|
||||
|
||||
it('creates edit, quick-edit, and character animation panel drafts', () => {
|
||||
const sourceLayer = createLayer({
|
||||
prompt: '原图提示',
|
||||
prompt: '【系统内置】完整拼接提示词:原图提示,自动画质优化。',
|
||||
model: 'gpt-image-2',
|
||||
assetKind: 'character',
|
||||
generationInputs: {
|
||||
fields: [{ title: '生成提示词', value: '原图提示' }],
|
||||
references: [],
|
||||
},
|
||||
});
|
||||
|
||||
expect(createEditDialogDraft(sourceLayer)).toEqual({
|
||||
mode: 'edit',
|
||||
prompt: '原图提示,在保持主体结构的基础上优化画面细节',
|
||||
prompt: '原图提示',
|
||||
status: 'idle',
|
||||
composerOpen: true,
|
||||
sourceLayerId: 'layer-source',
|
||||
@@ -371,6 +378,7 @@ describe('ImageCanvasGenerationDialogModel', () => {
|
||||
quickEditReferences: [],
|
||||
status: 'idle',
|
||||
});
|
||||
expect(createRedrawPanelDraft(sourceLayer).prompt).toBe('原图提示');
|
||||
expect(createCharacterAnimationPanelDraft(sourceLayer)).toEqual({
|
||||
sourceLayerId: 'layer-source',
|
||||
promptText: '',
|
||||
@@ -383,6 +391,86 @@ describe('ImageCanvasGenerationDialogModel', () => {
|
||||
expect(createCharacterAnimationPanelDraft(createLayer())).toBeNull();
|
||||
});
|
||||
|
||||
it('restores remodel prompts from user-visible generation input snapshots only', () => {
|
||||
const cases: Array<{
|
||||
fields: NonNullable<CanvasLayer['generationInputs']>['fields'];
|
||||
expectedPrompt: string;
|
||||
}> = [
|
||||
{
|
||||
fields: [{ title: '视频描述', value: '用户的视频描述' }],
|
||||
expectedPrompt: '用户的视频描述',
|
||||
},
|
||||
{
|
||||
fields: [{ title: '角色设定', value: '用户的角色设定' }],
|
||||
expectedPrompt: '用户的角色设定',
|
||||
},
|
||||
{
|
||||
fields: [{ title: '用户输入', value: '用户的 UI 需求' }],
|
||||
expectedPrompt: '用户的 UI 需求',
|
||||
},
|
||||
{
|
||||
fields: [{ title: '素材描述', value: '返回按钮\n设置按钮' }],
|
||||
expectedPrompt: '返回按钮\n设置按钮',
|
||||
},
|
||||
{
|
||||
fields: [{ title: '自定义规范提示词', value: '用户自定义规范' }],
|
||||
expectedPrompt: '用户自定义规范',
|
||||
},
|
||||
{
|
||||
fields: [
|
||||
{ title: '玩法设定', value: '平台跳跃' },
|
||||
{ title: '美术风格', value: '像素风' },
|
||||
],
|
||||
expectedPrompt: '玩法设定:平台跳跃\n美术风格:像素风',
|
||||
},
|
||||
{
|
||||
fields: [
|
||||
{ title: '游戏名', value: '星海远征' },
|
||||
{ title: '游戏分类', value: '冒险' },
|
||||
{ title: '一句话描述游戏', value: '驾驶飞船探索星云' },
|
||||
],
|
||||
expectedPrompt:
|
||||
'游戏名:星海远征\n游戏分类:冒险\n一句话描述游戏:驾驶飞船探索星云',
|
||||
},
|
||||
{
|
||||
fields: [{ title: '动作描述', value: '待机动作' }],
|
||||
expectedPrompt: '待机动作',
|
||||
},
|
||||
];
|
||||
|
||||
for (const testCase of cases) {
|
||||
expect(
|
||||
createRedrawPanelDraft(
|
||||
createLayer({
|
||||
prompt: '【系统内置】完整拼接提示词,包含默认规则和模型约束。',
|
||||
generationInputs: {
|
||||
fields: testCase.fields,
|
||||
references: [],
|
||||
},
|
||||
}),
|
||||
).prompt,
|
||||
).toBe(testCase.expectedPrompt);
|
||||
}
|
||||
|
||||
expect(
|
||||
createRedrawPanelDraft(
|
||||
createLayer({
|
||||
prompt: '【系统内置】固定素材提取提示词',
|
||||
generationInputs: {
|
||||
fields: [
|
||||
{
|
||||
title: '提取提示词',
|
||||
value: '仅提取被红色框框选的素材并整理成spritesheet',
|
||||
},
|
||||
{ title: 'model', value: 'gpt-image-2' },
|
||||
],
|
||||
references: [],
|
||||
},
|
||||
}),
|
||||
).prompt,
|
||||
).toBe('');
|
||||
});
|
||||
|
||||
it('normalizes legacy nanobanana model aliases in quick edit drafts', () => {
|
||||
expect(
|
||||
createQuickEditPanelDraft(
|
||||
|
||||
@@ -416,37 +416,62 @@ function findGenerationInputFieldValue(
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
const USER_PROMPT_INPUT_TITLES = new Set(
|
||||
[
|
||||
'生成提示词',
|
||||
'视频描述',
|
||||
'prompt',
|
||||
'sound',
|
||||
'gpt_description_prompt',
|
||||
'音效提示词',
|
||||
'背景音乐提示词',
|
||||
'角色设定',
|
||||
'用户输入',
|
||||
'素材描述',
|
||||
'自定义规范提示词',
|
||||
'修改要求',
|
||||
'快速编辑提示词',
|
||||
'重绘提示词',
|
||||
'动作描述',
|
||||
].map((title) => title.toLowerCase()),
|
||||
);
|
||||
|
||||
const STRUCTURED_USER_INPUT_TITLES = new Set(
|
||||
[
|
||||
'玩法设定',
|
||||
'美术风格',
|
||||
'头身比',
|
||||
'角色视角',
|
||||
'游戏名',
|
||||
'游戏分类',
|
||||
'一句话描述游戏',
|
||||
].map((title) => title.toLowerCase()),
|
||||
);
|
||||
|
||||
function resolveUserGenerationPromptSnapshot(sourceLayer: CanvasLayer) {
|
||||
const fields = sourceLayer.generationInputs?.fields ?? [];
|
||||
const promptField = fields.find((field) =>
|
||||
USER_PROMPT_INPUT_TITLES.has(field.title.trim().toLowerCase()),
|
||||
);
|
||||
const promptValue = promptField?.value.trim();
|
||||
if (promptValue) {
|
||||
return promptValue;
|
||||
}
|
||||
|
||||
const structuredLines = fields.flatMap((field) => {
|
||||
if (!STRUCTURED_USER_INPUT_TITLES.has(field.title.trim().toLowerCase())) {
|
||||
return [];
|
||||
}
|
||||
const value = field.value.trim();
|
||||
return value ? [`${field.title.trim()}:${value}`] : [];
|
||||
});
|
||||
return structuredLines.join('\n');
|
||||
}
|
||||
|
||||
function resolveAudioRedrawPrompt(
|
||||
sourceLayer: CanvasLayer,
|
||||
mode: Extract<
|
||||
GenerateDialogState['mode'],
|
||||
'audio-sound-effect' | 'audio-background-music'
|
||||
>,
|
||||
) {
|
||||
return (
|
||||
sourceLayer.prompt?.trim() ||
|
||||
findGenerationInputFieldValue(sourceLayer, [
|
||||
'prompt',
|
||||
'sound',
|
||||
'gpt_description_prompt',
|
||||
'生成提示词',
|
||||
'音效提示词',
|
||||
'背景音乐提示词',
|
||||
])?.trim() ||
|
||||
sourceLayer.generationInputs?.fields[0]?.value?.trim() ||
|
||||
resolveDefaultAudioPrompt(mode)
|
||||
);
|
||||
return resolveUserGenerationPromptSnapshot(sourceLayer);
|
||||
}
|
||||
|
||||
function resolveAudioRedrawMode(sourceLayer: CanvasLayer) {
|
||||
@@ -493,7 +518,7 @@ export function createAudioRedrawGenerationDialogDraft(
|
||||
const baseDraft = {
|
||||
mode,
|
||||
sourceLayerId: sourceLayer.id,
|
||||
prompt: resolveAudioRedrawPrompt(sourceLayer, mode),
|
||||
prompt: resolveAudioRedrawPrompt(sourceLayer),
|
||||
status: 'idle' as const,
|
||||
composerOpen: true,
|
||||
placeholder: {
|
||||
@@ -562,9 +587,7 @@ export function createEditDialogDraft(
|
||||
): GenerateDialogState {
|
||||
return {
|
||||
mode: 'edit',
|
||||
prompt: sourceLayer.prompt
|
||||
? `${sourceLayer.prompt},在保持主体结构的基础上优化画面细节`
|
||||
: '',
|
||||
prompt: resolveUserGenerationPromptSnapshot(sourceLayer),
|
||||
status: 'idle',
|
||||
composerOpen: true,
|
||||
sourceLayerId: sourceLayer.id,
|
||||
@@ -646,10 +669,7 @@ export function createRedrawPanelDraft(
|
||||
return {
|
||||
...createQuickEditPanelDraft(sourceLayer),
|
||||
mode: 'redraw',
|
||||
prompt:
|
||||
sourceLayer.prompt?.trim() ||
|
||||
sourceLayer.generationInputs?.fields[0]?.value?.trim() ||
|
||||
'',
|
||||
prompt: resolveUserGenerationPromptSnapshot(sourceLayer),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -16,10 +16,12 @@ function ImageOptionsHarness({
|
||||
initialDialog,
|
||||
includeDimensions = true,
|
||||
onRememberImageModel = vi.fn(),
|
||||
lockedModel,
|
||||
}: {
|
||||
initialDialog: GenerateDialogState;
|
||||
includeDimensions?: boolean;
|
||||
onRememberImageModel?: (model: string) => void;
|
||||
lockedModel?: string;
|
||||
}) {
|
||||
const [dialog, setDialog] = useState<GenerateDialogState | null>(
|
||||
initialDialog,
|
||||
@@ -32,7 +34,11 @@ function ImageOptionsHarness({
|
||||
setGenerateDialog={setDialog}
|
||||
includeDimensions={includeDimensions}
|
||||
onRememberImageModel={onRememberImageModel}
|
||||
cost={calculateEditorImageGenerationPrice({ model: dialog.imageModel })}
|
||||
lockedModel={lockedModel}
|
||||
cost={calculateEditorImageGenerationPrice({
|
||||
model: lockedModel ?? dialog.imageModel,
|
||||
imageSize: dialog.imageSize,
|
||||
})}
|
||||
submitLabel="生成"
|
||||
submitAriaLabel="生成角色形象"
|
||||
/>
|
||||
@@ -118,15 +124,11 @@ describe('ImageCanvasGenerationImageOptionsView', () => {
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '生成图片尺寸 1:1·1K' }),
|
||||
);
|
||||
expect(
|
||||
screen.getByRole('menu', { name: '生成图片尺寸选项' }),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByRole('menu', { name: '生成图片尺寸选项' })).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByLabelText('当前模型'));
|
||||
|
||||
expect(
|
||||
screen.queryByRole('menu', { name: '生成图片尺寸选项' }),
|
||||
).toBeNull();
|
||||
expect(screen.queryByRole('menu', { name: '生成图片尺寸选项' })).toBeNull();
|
||||
});
|
||||
|
||||
it('renders model choices as one model per row and uses a check mark for selection', () => {
|
||||
@@ -150,7 +152,9 @@ describe('ImageCanvasGenerationImageOptionsView', () => {
|
||||
);
|
||||
const panel = screen.getByRole('menu', { name: '生成图片模型选项' });
|
||||
|
||||
expect(within(panel).getByRole('button', { name: 'nanobanana2' })).toBeTruthy();
|
||||
expect(
|
||||
within(panel).getByRole('button', { name: 'nanobanana2' }),
|
||||
).toBeTruthy();
|
||||
fireEvent.click(within(panel).getByRole('button', { name: 'gpt-image-2' }));
|
||||
|
||||
expect(screen.getByRole('menu', { name: '生成图片模型选项' })).toBeTruthy();
|
||||
@@ -163,9 +167,40 @@ describe('ImageCanvasGenerationImageOptionsView', () => {
|
||||
expect(
|
||||
screen.getByRole('button', { name: '生成图片模型 gpt-image-2' }),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: '生成角色形象' }).textContent).toBe(
|
||||
'生成20泥点',
|
||||
expect(
|
||||
screen.getByRole('button', { name: '生成角色形象' }).textContent,
|
||||
).toBe('生成3泥点');
|
||||
});
|
||||
|
||||
it('locks the model trigger when a fixed model is provided', () => {
|
||||
const rememberImageModel = vi.fn();
|
||||
render(
|
||||
<ImageOptionsHarness
|
||||
initialDialog={{
|
||||
mode: 'ui-design',
|
||||
prompt: '',
|
||||
status: 'idle',
|
||||
imageModel: 'nanobanana2',
|
||||
aspectRatio: '16:9',
|
||||
imageSize: '1K',
|
||||
}}
|
||||
lockedModel={IMAGE_MODEL_GPT_IMAGE_2}
|
||||
onRememberImageModel={rememberImageModel}
|
||||
/>,
|
||||
);
|
||||
|
||||
const modelButton = screen.getByRole('button', {
|
||||
name: '生成图片模型 gpt-image-2',
|
||||
}) as HTMLButtonElement;
|
||||
|
||||
expect(modelButton.disabled).toBe(true);
|
||||
expect(modelButton.closest('div')?.className).toContain(
|
||||
'image-canvas-editor__readonly-generation-option',
|
||||
);
|
||||
expect(modelButton.textContent).not.toContain('nanobanana2');
|
||||
fireEvent.click(modelButton);
|
||||
expect(screen.queryByRole('menu', { name: '生成图片模型选项' })).toBeNull();
|
||||
expect(rememberImageModel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('renders option menus and shows mud point text in the submit button', () => {
|
||||
@@ -186,9 +221,7 @@ describe('ImageCanvasGenerationImageOptionsView', () => {
|
||||
screen.getByRole('button', { name: '生成图片尺寸 1:1·1K' }),
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByRole('menu', { name: '生成图片尺寸选项' }),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByRole('menu', { name: '生成图片尺寸选项' })).toBeTruthy();
|
||||
const submit = screen.getByRole('button', { name: '生成角色形象' });
|
||||
expect(submit.textContent).toBe('生成12泥点');
|
||||
});
|
||||
@@ -216,9 +249,7 @@ describe('ImageCanvasGenerationImageOptionsView', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /生成图片尺寸/u }),
|
||||
).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: /生成图片尺寸/u })).toBeNull();
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '生成图片模型 nanobanana2' }),
|
||||
|
||||
@@ -33,6 +33,7 @@ type ImageCanvasGenerationImageOptionsViewProps = {
|
||||
cost?: number;
|
||||
submitLabel?: string;
|
||||
submitAriaLabel?: string;
|
||||
lockedModel?: string;
|
||||
renderEditorPortal?: (node: ReactNode) => ReactNode;
|
||||
buildPortalMenuStyle?: (
|
||||
anchor: HTMLElement | null,
|
||||
@@ -53,13 +54,16 @@ function resetFailedDialogStatus(dialog: GenerateDialogState) {
|
||||
function getImageDimensionOptions(model: string | null | undefined) {
|
||||
return (
|
||||
EDITOR_IMAGE_DIMENSION_OPTIONS[
|
||||
(model ?? IMAGE_MODEL_NANOBANANA2) as keyof typeof EDITOR_IMAGE_DIMENSION_OPTIONS
|
||||
(model ??
|
||||
IMAGE_MODEL_NANOBANANA2) as keyof typeof EDITOR_IMAGE_DIMENSION_OPTIONS
|
||||
] ?? EDITOR_IMAGE_DIMENSION_OPTIONS[IMAGE_MODEL_NANOBANANA2]
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeImageDialogSelection(dialog: GenerateDialogState) {
|
||||
const model = normalizeEditorImageModel(dialog.imageModel ?? IMAGE_MODEL_NANOBANANA2);
|
||||
const model = normalizeEditorImageModel(
|
||||
dialog.imageModel ?? IMAGE_MODEL_NANOBANANA2,
|
||||
);
|
||||
const options = getImageDimensionOptions(model);
|
||||
const aspectRatios = options.aspectRatios as readonly string[];
|
||||
const imageSizes = options.imageSizes as readonly string[];
|
||||
@@ -113,19 +117,28 @@ export function ImageCanvasGenerationImageOptionsView({
|
||||
cost,
|
||||
submitLabel = '生成',
|
||||
submitAriaLabel = '生成',
|
||||
lockedModel,
|
||||
renderEditorPortal = (node) => node,
|
||||
buildPortalMenuStyle = () => ({}),
|
||||
}: ImageCanvasGenerationImageOptionsViewProps) {
|
||||
const [openPanel, setOpenPanel] = useState<OpenPanel>(null);
|
||||
const dimensionsButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
const modelButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
const selection = normalizeImageDialogSelection(dialog);
|
||||
const normalizedLockedModel = lockedModel
|
||||
? normalizeEditorImageModel(lockedModel)
|
||||
: null;
|
||||
const selection = normalizeImageDialogSelection(
|
||||
normalizedLockedModel
|
||||
? { ...dialog, imageModel: normalizedLockedModel }
|
||||
: dialog,
|
||||
);
|
||||
const selectedModelLabel = getEditorImageModelDisplayName(selection.model);
|
||||
const selectedSizeLabel = resolveEditorImageSizeLabel({
|
||||
aspectRatio: selection.aspectRatio,
|
||||
imageSize: selection.imageSize,
|
||||
});
|
||||
const isGenerating = dialog.status === 'generating';
|
||||
const isModelLocked = Boolean(normalizedLockedModel);
|
||||
const labelPrefix = optionLabelPrefix ?? '生成图片';
|
||||
const dismissOpenPanel = useCallback(() => setOpenPanel(null), []);
|
||||
|
||||
@@ -257,24 +270,42 @@ export function ImageCanvasGenerationImageOptionsView({
|
||||
: null}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="image-canvas-editor__option-popover-anchor image-canvas-editor__option-popover-anchor--model">
|
||||
<div
|
||||
className={[
|
||||
'image-canvas-editor__option-popover-anchor image-canvas-editor__option-popover-anchor--model',
|
||||
isModelLocked
|
||||
? 'image-canvas-editor__readonly-generation-option'
|
||||
: '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
>
|
||||
<PlatformInlineOptionButton
|
||||
ref={modelButtonRef}
|
||||
className="image-canvas-editor__option-cluster image-canvas-editor__option-cluster--model"
|
||||
aria-label={`${labelPrefix}模型 ${selectedModelLabel}`}
|
||||
aria-expanded={openPanel === 'model'}
|
||||
disabled={isGenerating}
|
||||
trailingIcon={<ChevronDown className="h-3 w-3" />}
|
||||
onClick={() => togglePanel('model')}
|
||||
aria-expanded={isModelLocked ? undefined : openPanel === 'model'}
|
||||
disabled={isGenerating || isModelLocked}
|
||||
trailingIcon={
|
||||
isModelLocked ? undefined : <ChevronDown className="h-3 w-3" />
|
||||
}
|
||||
onClick={() => {
|
||||
if (!isModelLocked) {
|
||||
togglePanel('model');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className="image-canvas-editor__model-trigger-label">
|
||||
<span className="image-canvas-editor__model-icon" aria-hidden="true">
|
||||
<span
|
||||
className="image-canvas-editor__model-icon"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Cpu />
|
||||
</span>
|
||||
<span>{selectedModelLabel}</span>
|
||||
</span>
|
||||
</PlatformInlineOptionButton>
|
||||
{openPanel === 'model'
|
||||
{!isModelLocked && openPanel === 'model'
|
||||
? renderEditorPortal(
|
||||
<PlatformFloatingMenu
|
||||
className="image-canvas-editor__option-popover image-canvas-editor__option-popover--model image-canvas-editor__portal-menu"
|
||||
|
||||
@@ -466,7 +466,7 @@ describe('ImageCanvasGenerationLayerModel', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('creates character animation result layers with playable video preview and frame metadata', () => {
|
||||
it('creates character animation result layers as playable image sequences', () => {
|
||||
const layer = createCharacterAnimationResultLayer({
|
||||
generated: {
|
||||
taskId: 'character-animation-task-1',
|
||||
@@ -508,9 +508,18 @@ describe('ImageCanvasGenerationLayerModel', () => {
|
||||
id: 'layer-character-animation-15',
|
||||
resourceId: 'local-resource-character-animation-15',
|
||||
title: '角色动作',
|
||||
src: '/generated-character-drafts/editor/preview.mp4',
|
||||
mediaType: 'video',
|
||||
src: '/generated-character-drafts/editor/frame1.png',
|
||||
mediaType: 'image-sequence',
|
||||
thumbnailSrc: '/generated-character-drafts/editor/frame1.png',
|
||||
imageSequenceFrames: [
|
||||
{
|
||||
frameIndex: 1,
|
||||
imageSrc: '/generated-character-drafts/editor/frame1.png',
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
},
|
||||
],
|
||||
previewVideoPath: '/generated-character-drafts/editor/preview.mp4',
|
||||
x: -222,
|
||||
y: -242,
|
||||
width: 1024,
|
||||
|
||||
@@ -494,9 +494,11 @@ export function createCharacterAnimationResultLayer({
|
||||
id: `layer-character-animation-${generatedIndex}`,
|
||||
resourceId: `local-resource-character-animation-${generatedIndex}`,
|
||||
title,
|
||||
src: generated.previewVideoPath,
|
||||
mediaType: 'video',
|
||||
src: previewFrame.imageSrc,
|
||||
mediaType: 'image-sequence',
|
||||
thumbnailSrc: previewFrame.imageSrc,
|
||||
imageSequenceFrames: generated.frames,
|
||||
previewVideoPath: generated.previewVideoPath,
|
||||
x: frameX ?? worldCenter.x - width / 2,
|
||||
y: frameY ?? worldCenter.y - height / 2,
|
||||
width,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { ApiClientError } from '../../services/apiClient';
|
||||
import type {
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
} from './ImageCanvasEditorTypes';
|
||||
import {
|
||||
BACKGROUND_MUSIC_MODEL_SUNO,
|
||||
applyEditorGenerationPricingConfig,
|
||||
buildCharacterGenerationInputs,
|
||||
buildEditGenerationInputs,
|
||||
buildIconGenerationInputs,
|
||||
@@ -30,12 +31,11 @@ import {
|
||||
DEFAULT_SOUND_EFFECT_MODEL,
|
||||
DEFAULT_VIDEO_MODEL,
|
||||
EDITOR_BACKGROUND_MUSIC_MODEL_MUD_POINT_CONFIG,
|
||||
EDITOR_CHARACTER_ANIMATION_MODEL_MUD_POINT_CONFIG,
|
||||
EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG,
|
||||
EDITOR_MODEL_MUD_POINT_CONFIG,
|
||||
EDITOR_IMAGE_MODEL_OPTIONS,
|
||||
EDITOR_SOUND_EFFECT_MODEL_MUD_POINT_CONFIG,
|
||||
EDITOR_SOUND_EFFECT_MODEL_OPTIONS,
|
||||
EDITOR_SPEC_MODEL_MUD_POINT_CONFIG,
|
||||
EDITOR_VIDEO_MODEL_MUD_POINT_CONFIG,
|
||||
EDITOR_VIDEO_MODEL_OPTIONS,
|
||||
getGenerationFrameAriaLabel,
|
||||
@@ -52,29 +52,37 @@ import {
|
||||
} from './ImageCanvasGenerationModel';
|
||||
|
||||
describe('ImageCanvasGenerationModel', () => {
|
||||
afterEach(() => {
|
||||
applyEditorGenerationPricingConfig({
|
||||
models: EDITOR_MODEL_MUD_POINT_CONFIG,
|
||||
});
|
||||
});
|
||||
|
||||
it('为所有编辑器生成模型提供显式定价配置', () => {
|
||||
expect(
|
||||
EDITOR_IMAGE_MODEL_OPTIONS.map((option) => option.value).filter(
|
||||
(model) => !(model in EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG),
|
||||
(model) => !(model in EDITOR_MODEL_MUD_POINT_CONFIG),
|
||||
),
|
||||
).toEqual([]);
|
||||
expect(
|
||||
EDITOR_VIDEO_MODEL_OPTIONS.map((option) => option.value).filter(
|
||||
(model) => !(model in EDITOR_VIDEO_MODEL_MUD_POINT_CONFIG),
|
||||
(model) => !(model in EDITOR_MODEL_MUD_POINT_CONFIG),
|
||||
),
|
||||
).toEqual([]);
|
||||
expect(
|
||||
EDITOR_SOUND_EFFECT_MODEL_OPTIONS.map((option) => option.value).filter(
|
||||
(model) => !(model in EDITOR_SOUND_EFFECT_MODEL_MUD_POINT_CONFIG),
|
||||
(model) => !(model in EDITOR_MODEL_MUD_POINT_CONFIG),
|
||||
),
|
||||
).toEqual([]);
|
||||
expect(EDITOR_SPEC_MODEL_MUD_POINT_CONFIG[IMAGE_MODEL_GPT_IMAGE_2]).toBe(5);
|
||||
expect(EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG[IMAGE_MODEL_GPT_IMAGE_2]['1K']).toBe(3);
|
||||
expect(EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG[IMAGE_MODEL_GPT_IMAGE_2]['2K']).toBe(5);
|
||||
expect(EDITOR_MODEL_MUD_POINT_CONFIG[CHARACTER_ANIMATION_MODEL]).toBeTruthy();
|
||||
expect(
|
||||
EDITOR_CHARACTER_ANIMATION_MODEL_MUD_POINT_CONFIG[CHARACTER_ANIMATION_MODEL],
|
||||
).toBeTruthy();
|
||||
EDITOR_SOUND_EFFECT_MODEL_MUD_POINT_CONFIG[DEFAULT_SOUND_EFFECT_MODEL],
|
||||
).toBe(5);
|
||||
expect(
|
||||
EDITOR_BACKGROUND_MUSIC_MODEL_MUD_POINT_CONFIG[BACKGROUND_MUSIC_MODEL_SUNO],
|
||||
).toBe(5);
|
||||
).toBe(12);
|
||||
// 中文注释:Veo 已从前端入口移除,但客户端类型和后端兼容层仍保留,定价表也必须覆盖,避免旧布局回放或测试请求价格漂移。
|
||||
expect(EDITOR_VIDEO_MODEL_MUD_POINT_CONFIG[VIDEO_MODEL_VEO_3_1]).toBeTruthy();
|
||||
expect(
|
||||
@@ -84,23 +92,25 @@ describe('ImageCanvasGenerationModel', () => {
|
||||
|
||||
it('所有生成按钮价格都从模型定价配置推导', () => {
|
||||
expect(calculateEditorImageModelPrice(DEFAULT_IMAGE_MODEL)).toBe(
|
||||
EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG[DEFAULT_IMAGE_MODEL],
|
||||
EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG[DEFAULT_IMAGE_MODEL]['1K'],
|
||||
);
|
||||
expect(calculateEditorImageModelPrice(IMAGE_MODEL_GPT_IMAGE_2)).toBe(20);
|
||||
expect(calculateEditorImageModelPrice(IMAGE_MODEL_GPT_IMAGE_2, '1K')).toBe(3);
|
||||
expect(calculateEditorImageModelPrice(IMAGE_MODEL_GPT_IMAGE_2, '2K')).toBe(5);
|
||||
expect(
|
||||
calculateEditorImageGenerationPrice({
|
||||
kind: 'character',
|
||||
model: IMAGE_MODEL_GPT_IMAGE_2,
|
||||
imageSize: '1K',
|
||||
}),
|
||||
).toBe(EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG[IMAGE_MODEL_GPT_IMAGE_2]);
|
||||
).toBe(EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG[IMAGE_MODEL_GPT_IMAGE_2]['1K']);
|
||||
expect(calculateEditorIconSpritesheetPrice(DEFAULT_IMAGE_MODEL)).toBe(
|
||||
EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG[DEFAULT_IMAGE_MODEL],
|
||||
EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG[DEFAULT_IMAGE_MODEL]['1K'],
|
||||
);
|
||||
expect(calculateEditorUiDesignPrice(IMAGE_MODEL_GPT_IMAGE_2)).toBe(
|
||||
EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG[IMAGE_MODEL_GPT_IMAGE_2],
|
||||
expect(calculateEditorUiDesignPrice(IMAGE_MODEL_GPT_IMAGE_2, '2K')).toBe(
|
||||
EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG[IMAGE_MODEL_GPT_IMAGE_2]['2K'],
|
||||
);
|
||||
expect(calculateEditorSpecGenerationPrice(IMAGE_MODEL_GPT_IMAGE_2)).toBe(
|
||||
EDITOR_SPEC_MODEL_MUD_POINT_CONFIG[IMAGE_MODEL_GPT_IMAGE_2],
|
||||
EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG[IMAGE_MODEL_GPT_IMAGE_2]['2K'],
|
||||
);
|
||||
expect(calculateEditorVideoPrice(DEFAULT_VIDEO_MODEL, '720p', 5)).toBe(
|
||||
EDITOR_VIDEO_MODEL_MUD_POINT_CONFIG[DEFAULT_VIDEO_MODEL]['720p'] * 5,
|
||||
@@ -119,9 +129,7 @@ describe('ImageCanvasGenerationModel', () => {
|
||||
);
|
||||
expect(calculateCharacterAnimationPrice(CHARACTER_ANIMATION_MODEL, '720p', 6))
|
||||
.toBe(
|
||||
EDITOR_CHARACTER_ANIMATION_MODEL_MUD_POINT_CONFIG[
|
||||
CHARACTER_ANIMATION_MODEL
|
||||
]['720p'] * 6,
|
||||
EDITOR_VIDEO_MODEL_MUD_POINT_CONFIG[CHARACTER_ANIMATION_MODEL]['720p'] * 6,
|
||||
);
|
||||
expect(calculateEditorSoundEffectPrice(DEFAULT_SOUND_EFFECT_MODEL)).toBe(
|
||||
EDITOR_SOUND_EFFECT_MODEL_MUD_POINT_CONFIG[DEFAULT_SOUND_EFFECT_MODEL],
|
||||
@@ -131,6 +139,46 @@ describe('ImageCanvasGenerationModel', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('支持用后端下发的模型定价覆盖按钮泥点', () => {
|
||||
applyEditorGenerationPricingConfig({
|
||||
models: {
|
||||
...EDITOR_MODEL_MUD_POINT_CONFIG,
|
||||
[IMAGE_MODEL_GPT_IMAGE_2]: {
|
||||
unit: 'perGeneration',
|
||||
prices: {
|
||||
'1K': 29,
|
||||
'2K': 58,
|
||||
},
|
||||
},
|
||||
[VIDEO_MODEL_SEEDANCE_2]: {
|
||||
unit: 'perSecond',
|
||||
prices: {
|
||||
...EDITOR_VIDEO_MODEL_MUD_POINT_CONFIG[VIDEO_MODEL_SEEDANCE_2],
|
||||
'720p': 26,
|
||||
},
|
||||
},
|
||||
[DEFAULT_SOUND_EFFECT_MODEL]: {
|
||||
unit: 'perGeneration',
|
||||
price: 15,
|
||||
},
|
||||
[BACKGROUND_MUSIC_MODEL_SUNO]: {
|
||||
unit: 'perGeneration',
|
||||
price: 9,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(calculateEditorImageModelPrice(IMAGE_MODEL_GPT_IMAGE_2, '1K')).toBe(29);
|
||||
expect(calculateEditorSpecGenerationPrice(IMAGE_MODEL_GPT_IMAGE_2)).toBe(58);
|
||||
expect(calculateEditorVideoPrice(VIDEO_MODEL_SEEDANCE_2, '720p', 5)).toBe(
|
||||
130,
|
||||
);
|
||||
expect(calculateEditorSoundEffectPrice(DEFAULT_SOUND_EFFECT_MODEL)).toBe(15);
|
||||
expect(calculateEditorBackgroundMusicPrice(BACKGROUND_MUSIC_MODEL_SUNO)).toBe(
|
||||
9,
|
||||
);
|
||||
});
|
||||
|
||||
it('builds user-facing generation input snapshots instead of backend prompts', () => {
|
||||
expect(buildImageGenerationInputs(' 一张明亮主视觉 ')).toEqual({
|
||||
fields: [{ title: '生成提示词', value: '一张明亮主视觉' }],
|
||||
@@ -351,13 +399,13 @@ describe('ImageCanvasGenerationModel', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('uses the extracted transparent frame when reusing a playable character animation layer', () => {
|
||||
it('uses the extracted transparent frame when reusing a character animation sequence layer', () => {
|
||||
expect(
|
||||
resolveCharacterAnimationSourceImageSrc({
|
||||
...buildSourceLayer(),
|
||||
assetKind: 'character-animation',
|
||||
mediaType: 'video',
|
||||
src: '/generated-character-drafts/editor/preview.mp4',
|
||||
mediaType: 'image-sequence',
|
||||
src: '/generated-character-drafts/editor/frame01.png',
|
||||
thumbnailSrc: '/generated-character-drafts/editor/frame01.png',
|
||||
}),
|
||||
).toBe('/generated-character-drafts/editor/frame01.png');
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ApiClientError } from '../../services/apiClient';
|
||||
import type { EditorGenerationPricingConfig } from '../../services/image-editor/editorProjectClient';
|
||||
import type {
|
||||
EditorCharacterAnimationRatio,
|
||||
EditorCharacterAnimationResolution,
|
||||
@@ -22,24 +23,18 @@ import {
|
||||
type PublicationMaterialsWorkflow,
|
||||
} from './ImageCanvasPublicationMaterialsModel';
|
||||
|
||||
// 中文注释:与 api-server/src/editor_generation_config.rs 保持同名语义,当前作为前端展示兜底。
|
||||
export const EDITOR_GENERATION_MUD_POINT_CONFIG = {
|
||||
image: 12,
|
||||
spec: 5,
|
||||
character: 12,
|
||||
icon: 12,
|
||||
uiDesign: 12,
|
||||
videoPerSecond: {
|
||||
'480p': 10,
|
||||
'720p': 20,
|
||||
},
|
||||
characterAnimationPerSecond: {
|
||||
'480p': 10,
|
||||
'720p': 20,
|
||||
},
|
||||
soundEffect: 10,
|
||||
backgroundMusic: 5,
|
||||
} as const;
|
||||
// 中文注释:与 api-server/src/editor_generation_config.rs 保持同名模型定价语义,前端仅作为展示兜底。
|
||||
export const IMAGE_MODEL_GPT_IMAGE_2 = 'gpt-image-2';
|
||||
export const IMAGE_MODEL_NANOBANANA2 = 'gemini-3.1-flash-image-preview';
|
||||
export const DEFAULT_IMAGE_MODEL = IMAGE_MODEL_NANOBANANA2;
|
||||
const IMAGE_MODEL_NANOBANANA_ALIASES = new Set([
|
||||
IMAGE_MODEL_NANOBANANA2,
|
||||
'nanobanana2',
|
||||
'nano-banana',
|
||||
]);
|
||||
export const SPEC_GENERATION_MODEL = IMAGE_MODEL_GPT_IMAGE_2;
|
||||
export const SPEC_GENERATION_ASPECT_RATIO = '16:9';
|
||||
export const SPEC_GENERATION_IMAGE_SIZE = '2K';
|
||||
export const SPEC_GENERATION_SIZE = '2048x1152';
|
||||
export const SPEC_FRAME_ORIGINAL_SIZE = { width: 2048, height: 1152 };
|
||||
export const SPEC_FRAME_DISPLAY_SIZE = { width: 560, height: 315 };
|
||||
@@ -73,27 +68,19 @@ export const PUBLICATION_FRAME_DISPLAY_SIZE: Record<
|
||||
'publication-detail-gallery': { width: 360, height: 640 },
|
||||
'publication-promo-poster': { width: 560, height: 315 },
|
||||
};
|
||||
export const IMAGE_MODEL_GPT_IMAGE_2 = 'gpt-image-2';
|
||||
export const IMAGE_MODEL_NANOBANANA2 = 'gemini-3.1-flash-image-preview';
|
||||
export const DEFAULT_IMAGE_MODEL = IMAGE_MODEL_NANOBANANA2;
|
||||
const IMAGE_MODEL_NANOBANANA_ALIASES = new Set([
|
||||
IMAGE_MODEL_NANOBANANA2,
|
||||
'nanobanana2',
|
||||
'nano-banana',
|
||||
]);
|
||||
export const SPEC_GENERATION_MODEL = IMAGE_MODEL_GPT_IMAGE_2;
|
||||
export const EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG = {
|
||||
[IMAGE_MODEL_NANOBANANA2]: EDITOR_GENERATION_MUD_POINT_CONFIG.image,
|
||||
// 中文注释:gpt-image-2 作为更高成本图片模型独立计费,避免切换模型后按钮价格不变。
|
||||
[IMAGE_MODEL_GPT_IMAGE_2]: 20,
|
||||
} as const;
|
||||
export const EDITOR_SPEC_MODEL_MUD_POINT_CONFIG = {
|
||||
[IMAGE_MODEL_GPT_IMAGE_2]: EDITOR_GENERATION_MUD_POINT_CONFIG.spec,
|
||||
[IMAGE_MODEL_NANOBANANA2]: {
|
||||
'0.5K': 8,
|
||||
'1K': 12,
|
||||
'2K': 24,
|
||||
},
|
||||
[IMAGE_MODEL_GPT_IMAGE_2]: {
|
||||
'1K': 3,
|
||||
'2K': 5,
|
||||
},
|
||||
} as const;
|
||||
export const SPEC_GENERATION_COST =
|
||||
EDITOR_SPEC_MODEL_MUD_POINT_CONFIG[SPEC_GENERATION_MODEL];
|
||||
export const SPEC_GENERATION_ASPECT_RATIO = '16:9';
|
||||
export const SPEC_GENERATION_IMAGE_SIZE = '2K';
|
||||
EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG[SPEC_GENERATION_MODEL][SPEC_GENERATION_IMAGE_SIZE];
|
||||
export const ICON_DESCRIPTION_LIMIT = 100;
|
||||
export const DEFAULT_PUBLICATION_GAME_INFO: PublicationMaterialsGameInfo = {
|
||||
gameName: '',
|
||||
@@ -109,7 +96,7 @@ export const DEFAULT_ICON_DESCRIPTIONS = [
|
||||
'冻结按钮',
|
||||
];
|
||||
export const UI_DESIGN_ASSET_EXTRACTION_PROMPT =
|
||||
'提取画面中的所有独立并整理成spritesheet';
|
||||
'仅提取被红色框框选的素材并整理成spritesheet';
|
||||
export const QUICK_EDIT_SIZE_PRESETS = [
|
||||
'1024x1024',
|
||||
'1536x1024',
|
||||
@@ -322,9 +309,9 @@ export const SOUND_EFFECT_DURATION_OPTIONS = Array.from(
|
||||
export const BACKGROUND_MUSIC_MODEL_SUNO = 'chirp-v5';
|
||||
export const DEFAULT_BACKGROUND_MUSIC_MODEL = BACKGROUND_MUSIC_MODEL_SUNO;
|
||||
const EDITOR_VIDEO_RESOLUTION_RATE_CONFIG = {
|
||||
'480p': EDITOR_GENERATION_MUD_POINT_CONFIG.videoPerSecond['480p'],
|
||||
'720p': EDITOR_GENERATION_MUD_POINT_CONFIG.videoPerSecond['720p'],
|
||||
'1080p': EDITOR_GENERATION_MUD_POINT_CONFIG.videoPerSecond['720p'] * 2,
|
||||
'480p': 10,
|
||||
'720p': 20,
|
||||
'1080p': 40,
|
||||
} as const;
|
||||
const EDITOR_VIDEO_STANDARD_RESOLUTION_RATE_CONFIG = {
|
||||
'480p': 12,
|
||||
@@ -346,22 +333,63 @@ export const EDITOR_VIDEO_MODEL_MUD_POINT_CONFIG = {
|
||||
[VIDEO_MODEL_SEEDANCE_2]: EDITOR_VIDEO_STANDARD_RESOLUTION_RATE_CONFIG,
|
||||
[VIDEO_MODEL_KLING_3]: EDITOR_VIDEO_KLING_RESOLUTION_RATE_CONFIG,
|
||||
[VIDEO_MODEL_KLING_3_OMNI]: EDITOR_VIDEO_KLING_OMNI_RESOLUTION_RATE_CONFIG,
|
||||
// 中文注释:前端不展示 Veo 入口,但类型与后端兼容层仍可接收旧布局回放,定价表必须同步覆盖。
|
||||
// 中文注释:前端不展示 Veo 入口,但客户端类型和后端兼容层仍可接收旧布局回放,定价表必须同步覆盖。
|
||||
[VIDEO_MODEL_VEO_3_1]: EDITOR_VIDEO_RESOLUTION_RATE_CONFIG,
|
||||
[VIDEO_MODEL_VEO_3_1_FAST]: EDITOR_VIDEO_RESOLUTION_RATE_CONFIG,
|
||||
} as const;
|
||||
export const EDITOR_CHARACTER_ANIMATION_MODEL_MUD_POINT_CONFIG = {
|
||||
[CHARACTER_ANIMATION_MODEL]: {
|
||||
'480p': EDITOR_GENERATION_MUD_POINT_CONFIG.characterAnimationPerSecond['480p'],
|
||||
'720p': EDITOR_GENERATION_MUD_POINT_CONFIG.characterAnimationPerSecond['720p'],
|
||||
},
|
||||
} as const;
|
||||
export const EDITOR_SOUND_EFFECT_MODEL_MUD_POINT_CONFIG = {
|
||||
[SOUND_EFFECT_MODEL_VIDU]: EDITOR_GENERATION_MUD_POINT_CONFIG.soundEffect,
|
||||
[SOUND_EFFECT_MODEL_VIDU]: 5,
|
||||
} as const;
|
||||
export const EDITOR_BACKGROUND_MUSIC_MODEL_MUD_POINT_CONFIG = {
|
||||
[BACKGROUND_MUSIC_MODEL_SUNO]: EDITOR_GENERATION_MUD_POINT_CONFIG.backgroundMusic,
|
||||
[BACKGROUND_MUSIC_MODEL_SUNO]: 12,
|
||||
} as const;
|
||||
export const EDITOR_MODEL_MUD_POINT_CONFIG = {
|
||||
[IMAGE_MODEL_NANOBANANA2]: {
|
||||
unit: 'perGeneration',
|
||||
prices: EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG[IMAGE_MODEL_NANOBANANA2],
|
||||
},
|
||||
[IMAGE_MODEL_GPT_IMAGE_2]: {
|
||||
unit: 'perGeneration',
|
||||
prices: EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG[IMAGE_MODEL_GPT_IMAGE_2],
|
||||
},
|
||||
[VIDEO_MODEL_SEEDANCE_2_FAST]: {
|
||||
unit: 'perSecond',
|
||||
prices: EDITOR_VIDEO_MODEL_MUD_POINT_CONFIG[VIDEO_MODEL_SEEDANCE_2_FAST],
|
||||
},
|
||||
[VIDEO_MODEL_SEEDANCE_2]: {
|
||||
unit: 'perSecond',
|
||||
prices: EDITOR_VIDEO_MODEL_MUD_POINT_CONFIG[VIDEO_MODEL_SEEDANCE_2],
|
||||
},
|
||||
[VIDEO_MODEL_KLING_3]: {
|
||||
unit: 'perSecond',
|
||||
prices: EDITOR_VIDEO_MODEL_MUD_POINT_CONFIG[VIDEO_MODEL_KLING_3],
|
||||
},
|
||||
[VIDEO_MODEL_KLING_3_OMNI]: {
|
||||
unit: 'perSecond',
|
||||
prices: EDITOR_VIDEO_MODEL_MUD_POINT_CONFIG[VIDEO_MODEL_KLING_3_OMNI],
|
||||
},
|
||||
[VIDEO_MODEL_VEO_3_1]: {
|
||||
unit: 'perSecond',
|
||||
prices: EDITOR_VIDEO_MODEL_MUD_POINT_CONFIG[VIDEO_MODEL_VEO_3_1],
|
||||
},
|
||||
[VIDEO_MODEL_VEO_3_1_FAST]: {
|
||||
unit: 'perSecond',
|
||||
prices: EDITOR_VIDEO_MODEL_MUD_POINT_CONFIG[VIDEO_MODEL_VEO_3_1_FAST],
|
||||
},
|
||||
[SOUND_EFFECT_MODEL_VIDU]: {
|
||||
unit: 'perGeneration',
|
||||
price: EDITOR_SOUND_EFFECT_MODEL_MUD_POINT_CONFIG[SOUND_EFFECT_MODEL_VIDU],
|
||||
},
|
||||
[BACKGROUND_MUSIC_MODEL_SUNO]: {
|
||||
unit: 'perGeneration',
|
||||
price: EDITOR_BACKGROUND_MUSIC_MODEL_MUD_POINT_CONFIG[
|
||||
BACKGROUND_MUSIC_MODEL_SUNO
|
||||
],
|
||||
},
|
||||
} satisfies EditorGenerationPricingConfig['models'];
|
||||
let runtimeEditorGenerationPricingConfig: EditorGenerationPricingConfig = {
|
||||
models: cloneModelPricing(EDITOR_MODEL_MUD_POINT_CONFIG),
|
||||
};
|
||||
export const EDITOR_SOUND_EFFECT_MODEL_OPTIONS = [
|
||||
{ label: 'Vidu', value: SOUND_EFFECT_MODEL_VIDU },
|
||||
] as const;
|
||||
@@ -600,6 +628,9 @@ export function getLayerKindLabel(layer: CanvasLayer) {
|
||||
if (layer.mediaType === 'audio') {
|
||||
return '音频';
|
||||
}
|
||||
if (layer.mediaType === 'image-sequence') {
|
||||
return '序列帧';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -621,7 +652,7 @@ export function formatLayerImageType(layer: CanvasLayer) {
|
||||
return '角色图片';
|
||||
}
|
||||
if (layer.assetKind === 'character-animation') {
|
||||
return '角色动作视频';
|
||||
return '角色动作序列帧';
|
||||
}
|
||||
if (layer.assetKind === 'icon') {
|
||||
return '图标素材图片';
|
||||
@@ -650,20 +681,79 @@ export function formatLayerImageType(layer: CanvasLayer) {
|
||||
if (layer.mediaType === 'audio') {
|
||||
return '生成音频';
|
||||
}
|
||||
if (layer.mediaType === 'image-sequence') {
|
||||
return '序列帧';
|
||||
}
|
||||
return isGeneratedLayer(layer) ? '生成图片' : '上传图片';
|
||||
}
|
||||
|
||||
function normalizeImagePriceSize(
|
||||
model: string,
|
||||
imageSize: string | null | undefined,
|
||||
) {
|
||||
const normalizedImageSize = getSafeImageSizeValue(imageSize);
|
||||
if (model === IMAGE_MODEL_NANOBANANA2 && normalizedImageSize === '0.5K') {
|
||||
return '0.5K';
|
||||
}
|
||||
if (normalizedImageSize === '2K') {
|
||||
return '2K';
|
||||
}
|
||||
return '1K';
|
||||
}
|
||||
|
||||
function getModelPrices(model: string | null | undefined) {
|
||||
const normalizedModel = model?.trim() || '';
|
||||
return normalizedModel
|
||||
? runtimeEditorGenerationPricingConfig.models[normalizedModel]
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function readModelTierPrice({
|
||||
model,
|
||||
fallbackModel,
|
||||
tier,
|
||||
fallbackTier,
|
||||
}: {
|
||||
model: string;
|
||||
fallbackModel: string;
|
||||
tier: string;
|
||||
fallbackTier: string;
|
||||
}) {
|
||||
const modelPricing = getModelPrices(model) ?? getModelPrices(fallbackModel);
|
||||
const fallbackPricing = getModelPrices(fallbackModel);
|
||||
return (
|
||||
modelPricing?.prices?.[tier] ??
|
||||
modelPricing?.prices?.[fallbackTier] ??
|
||||
modelPricing?.price ??
|
||||
fallbackPricing?.prices?.[tier] ??
|
||||
fallbackPricing?.prices?.[fallbackTier] ??
|
||||
fallbackPricing?.price ??
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
function readModelFlatPrice(model: string, fallbackModel: string) {
|
||||
return (
|
||||
getModelPrices(model)?.price ??
|
||||
getModelPrices(fallbackModel)?.price ??
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
export function calculateCharacterAnimationPrice(
|
||||
model: string | null | undefined,
|
||||
resolution: EditorCharacterAnimationResolution,
|
||||
durationSeconds: number,
|
||||
) {
|
||||
const normalizedModel = (model ?? CHARACTER_ANIMATION_MODEL).trim();
|
||||
const modelRates =
|
||||
EDITOR_CHARACTER_ANIMATION_MODEL_MUD_POINT_CONFIG[
|
||||
normalizedModel as keyof typeof EDITOR_CHARACTER_ANIMATION_MODEL_MUD_POINT_CONFIG
|
||||
] ?? EDITOR_CHARACTER_ANIMATION_MODEL_MUD_POINT_CONFIG[CHARACTER_ANIMATION_MODEL];
|
||||
return modelRates[resolution] * durationSeconds;
|
||||
return (
|
||||
readModelTierPrice({
|
||||
model: normalizedModel,
|
||||
fallbackModel: CHARACTER_ANIMATION_MODEL,
|
||||
tier: resolution,
|
||||
fallbackTier: '480p',
|
||||
}) * durationSeconds
|
||||
);
|
||||
}
|
||||
|
||||
export function calculateEditorVideoPrice(
|
||||
@@ -672,83 +762,100 @@ export function calculateEditorVideoPrice(
|
||||
durationSeconds: number,
|
||||
) {
|
||||
const normalizedModel = (model ?? DEFAULT_VIDEO_MODEL).trim();
|
||||
const modelRates =
|
||||
EDITOR_VIDEO_MODEL_MUD_POINT_CONFIG[
|
||||
normalizedModel as keyof typeof EDITOR_VIDEO_MODEL_MUD_POINT_CONFIG
|
||||
] ?? EDITOR_VIDEO_MODEL_MUD_POINT_CONFIG[DEFAULT_VIDEO_MODEL];
|
||||
return modelRates[resolution] * durationSeconds;
|
||||
return (
|
||||
readModelTierPrice({
|
||||
model: normalizedModel,
|
||||
fallbackModel: DEFAULT_VIDEO_MODEL,
|
||||
tier: resolution,
|
||||
fallbackTier: '480p',
|
||||
}) * durationSeconds
|
||||
);
|
||||
}
|
||||
|
||||
export function calculateEditorImageModelPrice(
|
||||
model: string | null | undefined,
|
||||
imageSize: string | null | undefined = '1K',
|
||||
) {
|
||||
const normalizedModel = normalizeEditorImageModel(model);
|
||||
return (
|
||||
EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG[
|
||||
normalizedModel as keyof typeof EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG
|
||||
] ?? EDITOR_GENERATION_MUD_POINT_CONFIG.image
|
||||
);
|
||||
const normalizedSize = normalizeImagePriceSize(normalizedModel, imageSize);
|
||||
return readModelTierPrice({
|
||||
model: normalizedModel,
|
||||
fallbackModel: DEFAULT_IMAGE_MODEL,
|
||||
tier: normalizedSize,
|
||||
fallbackTier: '1K',
|
||||
});
|
||||
}
|
||||
|
||||
export function calculateEditorSpecGenerationPrice(
|
||||
model: string | null | undefined = SPEC_GENERATION_MODEL,
|
||||
) {
|
||||
const normalizedModel = (model ?? SPEC_GENERATION_MODEL).trim();
|
||||
return (
|
||||
EDITOR_SPEC_MODEL_MUD_POINT_CONFIG[
|
||||
normalizedModel as keyof typeof EDITOR_SPEC_MODEL_MUD_POINT_CONFIG
|
||||
] ?? EDITOR_SPEC_MODEL_MUD_POINT_CONFIG[SPEC_GENERATION_MODEL]
|
||||
);
|
||||
return calculateEditorImageModelPrice(model ?? SPEC_GENERATION_MODEL, SPEC_GENERATION_IMAGE_SIZE);
|
||||
}
|
||||
|
||||
export function calculateEditorImageGenerationPrice({
|
||||
kind,
|
||||
model,
|
||||
imageSize,
|
||||
}: {
|
||||
kind?: string | null;
|
||||
model?: string | null;
|
||||
imageSize?: string | null;
|
||||
} = {}) {
|
||||
const normalizedKind = kind?.trim();
|
||||
if (normalizedKind === 'spec') {
|
||||
return calculateEditorSpecGenerationPrice(model);
|
||||
}
|
||||
return calculateEditorImageModelPrice(model);
|
||||
return calculateEditorImageModelPrice(model, imageSize);
|
||||
}
|
||||
|
||||
export function calculateEditorIconSpritesheetPrice(
|
||||
model: string | null | undefined,
|
||||
imageSize: string | null | undefined = '1K',
|
||||
) {
|
||||
return calculateEditorImageModelPrice(model);
|
||||
return calculateEditorImageModelPrice(model, imageSize);
|
||||
}
|
||||
|
||||
export function calculateEditorUiDesignPrice(
|
||||
model: string | null | undefined,
|
||||
imageSize: string | null | undefined = '1K',
|
||||
) {
|
||||
return calculateEditorImageModelPrice(model);
|
||||
return calculateEditorImageModelPrice(model, imageSize);
|
||||
}
|
||||
|
||||
export function calculateEditorSoundEffectPrice(
|
||||
model: string | null | undefined,
|
||||
) {
|
||||
const normalizedModel = (model ?? DEFAULT_SOUND_EFFECT_MODEL).trim();
|
||||
return (
|
||||
EDITOR_SOUND_EFFECT_MODEL_MUD_POINT_CONFIG[
|
||||
normalizedModel as keyof typeof EDITOR_SOUND_EFFECT_MODEL_MUD_POINT_CONFIG
|
||||
] ?? EDITOR_SOUND_EFFECT_MODEL_MUD_POINT_CONFIG[DEFAULT_SOUND_EFFECT_MODEL]
|
||||
);
|
||||
return readModelFlatPrice(normalizedModel, DEFAULT_SOUND_EFFECT_MODEL);
|
||||
}
|
||||
|
||||
export function calculateEditorBackgroundMusicPrice(
|
||||
model: string | null | undefined = DEFAULT_BACKGROUND_MUSIC_MODEL,
|
||||
) {
|
||||
const normalizedModel = (model ?? DEFAULT_BACKGROUND_MUSIC_MODEL).trim();
|
||||
return (
|
||||
EDITOR_BACKGROUND_MUSIC_MODEL_MUD_POINT_CONFIG[
|
||||
normalizedModel as keyof typeof EDITOR_BACKGROUND_MUSIC_MODEL_MUD_POINT_CONFIG
|
||||
] ??
|
||||
EDITOR_BACKGROUND_MUSIC_MODEL_MUD_POINT_CONFIG[
|
||||
DEFAULT_BACKGROUND_MUSIC_MODEL
|
||||
]
|
||||
return readModelFlatPrice(normalizedModel, DEFAULT_BACKGROUND_MUSIC_MODEL);
|
||||
}
|
||||
|
||||
export function applyEditorGenerationPricingConfig(
|
||||
pricing: EditorGenerationPricingConfig,
|
||||
) {
|
||||
runtimeEditorGenerationPricingConfig = {
|
||||
models: cloneModelPricing(pricing.models),
|
||||
};
|
||||
}
|
||||
|
||||
function cloneModelPricing<T extends EditorGenerationPricingConfig['models']>(
|
||||
value: T,
|
||||
): EditorGenerationPricingConfig['models'] {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).map(([model, pricing]) => [
|
||||
model,
|
||||
{
|
||||
unit: pricing.unit,
|
||||
...(typeof pricing.price === 'number' ? { price: pricing.price } : {}),
|
||||
...(pricing.prices ? { prices: { ...pricing.prices } } : {}),
|
||||
},
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -880,7 +987,10 @@ export function buildVideoGenerationInputs(
|
||||
return {
|
||||
fields: createGenerationInputField('视频描述', prompt),
|
||||
references: (references ?? []).flatMap((reference) => {
|
||||
const mediaType = reference.mediaType ?? 'image';
|
||||
const mediaType =
|
||||
reference.mediaType === 'video' || reference.mediaType === 'audio'
|
||||
? reference.mediaType
|
||||
: 'image';
|
||||
mediaIndexes[mediaType] += 1;
|
||||
const title =
|
||||
mediaType === 'video'
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
calculateEditorVideoPrice,
|
||||
CHARACTER_ANIMATION_MODEL,
|
||||
DEFAULT_SOUND_EFFECT_MODEL,
|
||||
IMAGE_MODEL_GPT_IMAGE_2,
|
||||
IMAGE_MODEL_NANOBANANA2,
|
||||
} from './ImageCanvasGenerationModel';
|
||||
|
||||
@@ -167,20 +168,22 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
|
||||
title: '图标规范 8',
|
||||
},
|
||||
});
|
||||
expect(plan.kind === 'image' ? plan.result.generationInputs : null).toEqual({
|
||||
fields: [
|
||||
{ title: '玩法设定', value: '休闲消除' },
|
||||
{ title: '美术风格', value: '清爽卡通' },
|
||||
],
|
||||
references: [
|
||||
{
|
||||
title: '参考图',
|
||||
label: '参考.png',
|
||||
refType: 'project-resource',
|
||||
refId: 'resource-spec-ref',
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(plan.kind === 'image' ? plan.result.generationInputs : null).toEqual(
|
||||
{
|
||||
fields: [
|
||||
{ title: '玩法设定', value: '休闲消除' },
|
||||
{ title: '美术风格', value: '清爽卡通' },
|
||||
],
|
||||
references: [
|
||||
{
|
||||
title: '参考图',
|
||||
label: '参考.png',
|
||||
refType: 'project-resource',
|
||||
refId: 'resource-spec-ref',
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('treats the renamed UI spec entry as an icon spec asset', () => {
|
||||
@@ -209,6 +212,12 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
|
||||
title: '图标规范 2',
|
||||
},
|
||||
});
|
||||
expect(plan.kind === 'image' ? plan.input : null).not.toHaveProperty(
|
||||
'referenceImageSrcs',
|
||||
);
|
||||
expect(plan.kind === 'image' ? plan.input.prompt : '').not.toContain(
|
||||
'参考图生成规范',
|
||||
);
|
||||
});
|
||||
|
||||
it('builds character plans with references and remembered model', () => {
|
||||
@@ -259,22 +268,62 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
|
||||
},
|
||||
rememberImageModel: 'gpt-image-2',
|
||||
});
|
||||
expect(plan.kind === 'image' ? plan.result.generationInputs : null).toEqual({
|
||||
fields: [{ title: '角色设定', value: '白发骑士' }],
|
||||
references: [
|
||||
{
|
||||
title: '角色规范',
|
||||
label: '角色规范',
|
||||
refType: 'project-resource',
|
||||
refId: 'resource-character-spec',
|
||||
expect(plan.kind === 'image' ? plan.result.generationInputs : null).toEqual(
|
||||
{
|
||||
fields: [{ title: '角色设定', value: '白发骑士' }],
|
||||
references: [
|
||||
{
|
||||
title: '角色规范',
|
||||
label: '角色规范',
|
||||
refType: 'project-resource',
|
||||
refId: 'resource-character-spec',
|
||||
},
|
||||
{
|
||||
title: '常规参考图 1',
|
||||
label: '盔甲参考',
|
||||
refType: 'project-resource',
|
||||
refId: 'resource-armor',
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('locks UI design generation submissions to gpt-image-2', () => {
|
||||
const plan = buildImageGenerationSubmissionPlan({
|
||||
dialog: {
|
||||
mode: 'ui-design',
|
||||
prompt: ' 设计移动端商城首页 ',
|
||||
status: 'idle',
|
||||
imageModel: 'nanobanana2',
|
||||
aspectRatio: '16:9',
|
||||
imageSize: '0.5K',
|
||||
uiDesignSpecReference: {
|
||||
id: 'ui-spec',
|
||||
label: 'UI图标规范',
|
||||
src: 'data:image/png;base64,ui-spec',
|
||||
resourceId: 'resource-ui-spec',
|
||||
},
|
||||
{
|
||||
title: '常规参考图 1',
|
||||
label: '盔甲参考',
|
||||
refType: 'project-resource',
|
||||
refId: 'resource-armor',
|
||||
},
|
||||
],
|
||||
},
|
||||
layers: [],
|
||||
nextGeneratedIndex: 6,
|
||||
});
|
||||
|
||||
expect(plan).toMatchObject({
|
||||
kind: 'image',
|
||||
normalizedPrompt: '设计移动端商城首页',
|
||||
input: {
|
||||
kind: 'ui-design',
|
||||
model: IMAGE_MODEL_GPT_IMAGE_2,
|
||||
aspectRatio: '16:9',
|
||||
imageSize: '1K',
|
||||
referenceImageSrcs: ['data:image/png;base64,ui-spec'],
|
||||
},
|
||||
result: {
|
||||
assetKind: 'ui-design',
|
||||
title: 'UI设计图 6',
|
||||
},
|
||||
rememberImageModel: IMAGE_MODEL_GPT_IMAGE_2,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -536,7 +585,10 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
|
||||
model: 'gpt-image-2',
|
||||
aspectRatio: '3:2',
|
||||
imageSize: '2K',
|
||||
priceMudPoints: calculateEditorIconSpritesheetPrice('gpt-image-2'),
|
||||
priceMudPoints: calculateEditorIconSpritesheetPrice(
|
||||
'gpt-image-2',
|
||||
'2K',
|
||||
),
|
||||
},
|
||||
generationInputs: {
|
||||
fields: [{ title: '素材描述', value: '返回按钮\n设置按钮' }],
|
||||
@@ -598,7 +650,6 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
it('builds seedance video plans with multimodal references', () => {
|
||||
const plan = buildImageGenerationSubmissionPlan({
|
||||
dialog: {
|
||||
@@ -779,7 +830,11 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
|
||||
normalizedPrompt: '角色绕场一周',
|
||||
input: {
|
||||
model: 'seedance2.0-fast',
|
||||
priceMudPoints: calculateEditorVideoPrice('seedance2.0-fast', '480p', 4),
|
||||
priceMudPoints: calculateEditorVideoPrice(
|
||||
'seedance2.0-fast',
|
||||
'480p',
|
||||
4,
|
||||
),
|
||||
},
|
||||
result: {
|
||||
title: '生成视频 3',
|
||||
@@ -837,7 +892,9 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
|
||||
prompt: '金币掉落叮当声',
|
||||
model: 'audio1.0',
|
||||
duration: 7,
|
||||
priceMudPoints: calculateEditorSoundEffectPrice(DEFAULT_SOUND_EFFECT_MODEL),
|
||||
priceMudPoints: calculateEditorSoundEffectPrice(
|
||||
DEFAULT_SOUND_EFFECT_MODEL,
|
||||
),
|
||||
},
|
||||
result: {
|
||||
title: '游戏音效 4',
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
DEFAULT_VIDEO_SOUND,
|
||||
DEFAULT_VIDEO_WEB_SEARCH_ENABLED,
|
||||
ICON_DESCRIPTION_LIMIT,
|
||||
IMAGE_MODEL_GPT_IMAGE_2,
|
||||
normalizeEditorImageModel,
|
||||
resolveCharacterAnimationSourceImageSrc,
|
||||
SEEDANCE_VIDEO_REFERENCE_LIMITS,
|
||||
@@ -68,6 +69,10 @@ export function isSeedanceEditorVideoModel(model: string) {
|
||||
return isSeedanceVideoModel(model);
|
||||
}
|
||||
|
||||
function normalizeGptImageSize(imageSize: string | null | undefined) {
|
||||
return imageSize?.trim().toUpperCase() === '2K' ? '2K' : '1K';
|
||||
}
|
||||
|
||||
function estimateReferenceRequestBytes(references: Array<{ src: string }>) {
|
||||
// 中文注释:JSON 包装字段还有少量额外字节;这里按 src 字符串长度估算,前后端都会再兜底校验 64MB。
|
||||
return references.reduce((sum, reference) => sum + reference.src.length, 0);
|
||||
@@ -76,8 +81,10 @@ function estimateReferenceRequestBytes(references: Array<{ src: string }>) {
|
||||
function buildSeedanceVideoReferenceInput(
|
||||
references: NonNullable<GenerateDialogState['generationReferences']>,
|
||||
) {
|
||||
const resolveReferenceSource = (reference: { src: string; objectKey?: string | null }) =>
|
||||
reference.objectKey?.trim() || reference.src;
|
||||
const resolveReferenceSource = (reference: {
|
||||
src: string;
|
||||
objectKey?: string | null;
|
||||
}) => reference.objectKey?.trim() || reference.src;
|
||||
const imageReferences = references.filter(
|
||||
(reference) => (reference.mediaType ?? 'image') === 'image',
|
||||
);
|
||||
@@ -97,7 +104,11 @@ function buildSeedanceVideoReferenceInput(
|
||||
if (audioReferences.length > SEEDANCE_VIDEO_REFERENCE_LIMITS.audio) {
|
||||
throw new Error('参考音频最多 3 个');
|
||||
}
|
||||
if (audioReferences.length && !imageReferences.length && !videoReferences.length) {
|
||||
if (
|
||||
audioReferences.length &&
|
||||
!imageReferences.length &&
|
||||
!videoReferences.length
|
||||
) {
|
||||
throw new Error('参考音频必须搭配参考图片或参考视频');
|
||||
}
|
||||
if (
|
||||
@@ -202,7 +213,9 @@ export function buildImageGenerationSubmissionPlan({
|
||||
dialog.prompt.trim() || getDialogDefaultPrompt(dialog.mode);
|
||||
|
||||
if (dialog.mode === 'edit') {
|
||||
const sourceLayer = layers.find((layer) => layer.id === dialog.sourceLayerId);
|
||||
const sourceLayer = layers.find(
|
||||
(layer) => layer.id === dialog.sourceLayerId,
|
||||
);
|
||||
if (!sourceLayer) {
|
||||
throw new Error('未找到要修改的图片');
|
||||
}
|
||||
@@ -284,7 +297,7 @@ export function buildImageGenerationSubmissionPlan({
|
||||
}
|
||||
|
||||
if (dialog.mode === 'ui-design') {
|
||||
const imageModel = normalizeEditorImageModel(dialog.imageModel);
|
||||
const imageModel = IMAGE_MODEL_GPT_IMAGE_2;
|
||||
const referenceImageSrcs = [dialog.uiDesignSpecReference?.src].filter(
|
||||
(src): src is string => Boolean(src),
|
||||
);
|
||||
@@ -296,7 +309,7 @@ export function buildImageGenerationSubmissionPlan({
|
||||
kind: 'ui-design',
|
||||
model: imageModel,
|
||||
aspectRatio: dialog.aspectRatio ?? '16:9',
|
||||
imageSize: dialog.imageSize ?? '1K',
|
||||
imageSize: normalizeGptImageSize(dialog.imageSize),
|
||||
...(referenceImageSrcs.length ? { referenceImageSrcs } : {}),
|
||||
},
|
||||
result: {
|
||||
@@ -448,7 +461,8 @@ export function buildImageGenerationSubmissionPlan({
|
||||
},
|
||||
result: {
|
||||
title: `游戏背景音乐 ${nextGeneratedIndex}`,
|
||||
generationInputs: buildBackgroundMusicGenerationInputs(normalizedPrompt),
|
||||
generationInputs:
|
||||
buildBackgroundMusicGenerationInputs(normalizedPrompt),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -484,9 +498,10 @@ export function buildImageGenerationSubmissionPlan({
|
||||
export function buildIconSpritesheetGenerationSubmissionPlan(
|
||||
dialog: GenerateDialogState,
|
||||
): IconSpritesheetGenerationSubmissionPlan {
|
||||
const iconDescriptions = (dialog.prompt.trim()
|
||||
? dialog.prompt.split(/[\r\n,,、;;/|]+/u)
|
||||
: (dialog.iconDescriptions ?? DEFAULT_ICON_DESCRIPTIONS)
|
||||
const iconDescriptions = (
|
||||
dialog.prompt.trim()
|
||||
? dialog.prompt.split(/[\r\n,,、;;/|]+/u)
|
||||
: (dialog.iconDescriptions ?? DEFAULT_ICON_DESCRIPTIONS)
|
||||
)
|
||||
.map((description) => description.trim())
|
||||
.filter(Boolean)
|
||||
@@ -516,7 +531,10 @@ export function buildIconSpritesheetGenerationSubmissionPlan(
|
||||
model: rememberImageModel,
|
||||
aspectRatio: dialog.aspectRatio ?? '1:1',
|
||||
imageSize: dialog.imageSize ?? '1K',
|
||||
priceMudPoints: calculateEditorIconSpritesheetPrice(rememberImageModel),
|
||||
priceMudPoints: calculateEditorIconSpritesheetPrice(
|
||||
rememberImageModel,
|
||||
dialog.imageSize ?? '1K',
|
||||
),
|
||||
},
|
||||
generationInputs: buildIconGenerationInputs(
|
||||
iconDescriptions,
|
||||
|
||||
@@ -200,7 +200,10 @@ export function ImageCanvasIconSpritesheetComposerView({
|
||||
includeDimensions
|
||||
onRememberImageModel={onRememberImageModel}
|
||||
optionLabelPrefix="生成图片"
|
||||
cost={calculateEditorIconSpritesheetPrice(dialog.imageModel)}
|
||||
cost={calculateEditorIconSpritesheetPrice(
|
||||
dialog.imageModel,
|
||||
dialog.imageSize,
|
||||
)}
|
||||
submitLabel="生成"
|
||||
submitAriaLabel="生成"
|
||||
renderEditorPortal={renderEditorPortal}
|
||||
|
||||
@@ -29,6 +29,8 @@ export function ImageCanvasMetadataModalView({
|
||||
? '音频'
|
||||
: layer?.mediaType === 'video'
|
||||
? '视频'
|
||||
: layer?.mediaType === 'image-sequence'
|
||||
? '序列帧'
|
||||
: '图片';
|
||||
return (
|
||||
<UnifiedModal
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user