完善快速编辑参考图与生成占位

- 快速编辑支持最多8张额外参考图,并将原图自动作为最后一张参考图提交

- 快速编辑尺寸和模型沿用原图配置,按禁用态参数胶囊展示

- 快速编辑提交前同步改写原图引用为实际图序号

- 快速编辑生成改为创建独立画布生成占位,并支持键盘删除生成中对象

- 对齐生成类面板参考图图标、规范类固定参数和相关文档测试
This commit is contained in:
2026-06-18 22:39:03 +08:00
parent b04b6cee2b
commit ea29887d9c
65 changed files with 2819 additions and 531 deletions
@@ -152,7 +152,7 @@ describe('ImageCanvasCharacterGenerationComposerView', () => {
expect(screen.getByLabelText('选择角色规范').textContent).toBe('true');
fireEvent.click(screen.getByRole('button', { name: '角色规范A' }));
fireEvent.click(screen.getByRole('menuitem', { name: '新建角色形象规范' }));
fireEvent.click(screen.getByRole('menuitem', { name: '新建角色规范' }));
expect(openSpecDialog).toHaveBeenCalledWith('character');
@@ -170,8 +170,8 @@ describe('ImageCanvasCharacterGenerationComposerView', () => {
it('removes bound character spec and regular references from their delete buttons', () => {
render(<CharacterGenerationHarness />);
fireEvent.click(screen.getByRole('button', { name: '删除角色形象规范' }));
expect(screen.getByRole('button', { name: '角色形象规范' })).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '删除角色规范' }));
expect(screen.getByRole('button', { name: '角色规范' })).toBeTruthy();
expect(screen.queryByRole('button', { name: '角色规范A' })).toBeNull();
fireEvent.click(screen.getByRole('button', { name: '删除参考图A' }));
@@ -186,12 +186,12 @@ describe('ImageCanvasCharacterGenerationComposerView', () => {
);
fireEvent.click(screen.getByRole('button', { name: '角色规范A' }));
expect(screen.getByRole('menu', { name: '角色形象规范来源' })).toBeTruthy();
expect(screen.getByRole('menu', { name: '角色规范来源' })).toBeTruthy();
fireEvent.click(screen.getByRole('textbox', { name: '角色设定' }));
expect(
screen.queryByRole('menu', { name: '角色形象规范来源' }),
screen.queryByRole('menu', { name: '角色规范来源' }),
).toBeNull();
});
});
@@ -128,8 +128,8 @@ export function ImageCanvasCharacterGenerationComposerView({
icon={<ClipboardList className="h-4 w-4" aria-hidden="true" />}
imageSrc={dialog.characterSpecReference?.src}
label="角色规范"
ariaLabel={dialog.characterSpecReference?.label ?? '角色形象规范'}
title={dialog.characterSpecReference?.label ?? '角色形象规范'}
ariaLabel={dialog.characterSpecReference?.label ?? '角色规范'}
title={dialog.characterSpecReference?.label ?? '角色规范'}
disabled={dialog.status === 'generating'}
isAdd={!dialog.characterSpecReference}
onClick={() => setIsCharacterSpecMenuOpen((open) => !open)}
@@ -146,13 +146,13 @@ export function ImageCanvasCharacterGenerationComposerView({
)
: undefined
}
removeLabel="删除角色形象规范"
removeLabel="删除角色规范"
/>
{isCharacterSpecMenuOpen
? renderEditorPortal(
<PlatformFloatingMenu
className="image-canvas-editor__character-spec-menu image-canvas-editor__portal-menu"
label="角色形象规范来源"
label="角色规范来源"
placement="top-start"
style={buildPortalMenuStyle(
characterSpecButtonRef.current,
@@ -175,7 +175,7 @@ export function ImageCanvasCharacterGenerationComposerView({
onOpenSpecDialog('character');
}}
>
</PlatformFloatingMenuItem>
<PlatformFloatingMenuItem
className="image-canvas-editor__context-menu-item"
@@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest';
import { resizeCropExpandFrame } from './ImageCanvasCropExpandModel';
describe('ImageCanvasCropExpandModel', () => {
it('resizes crop-expand frame freely from canvas handles', () => {
expect(
resizeCropExpandFrame({
frame: { x: 100, y: 80, width: 200, height: 120 },
handle: 'right',
deltaX: 40,
deltaY: 0,
ratio: 'free',
}),
).toEqual({ x: 100, y: 80, width: 240, height: 120 });
});
it('keeps a fixed ratio while dragging a crop-expand handle', () => {
const frame = resizeCropExpandFrame({
frame: { x: 100, y: 80, width: 200, height: 120 },
handle: 'bottom-right',
deltaX: 80,
deltaY: 10,
ratio: '1:1',
});
expect(frame.width).toBe(frame.height);
expect(frame).toEqual({ x: 100, y: 80, width: 280, height: 280 });
});
});
@@ -0,0 +1,184 @@
import type {
CropExpandPanelState,
CropExpandResizeHandle,
CropExpandRatioValue,
} from './ImageCanvasEditorTypes';
export const CROP_EXPAND_MIN_FRAME_SIZE = 16;
type CropExpandFrame = CropExpandPanelState['frame'];
type ResizeCropExpandFrameOptions = {
frame: CropExpandFrame;
handle: CropExpandResizeHandle;
deltaX: number;
deltaY: number;
ratio: CropExpandRatioValue;
minSize?: number;
};
export function resolveCropExpandRatioNumber(
ratio: CropExpandRatioValue,
): number | null {
if (ratio === 'free') {
return null;
}
const [widthValue, heightValue] = ratio.split(':').map(Number);
if (!widthValue || !heightValue) {
return null;
}
return widthValue / heightValue;
}
function normalizeFreeFrame(frame: CropExpandFrame, minSize: number) {
const right = frame.x + frame.width;
const bottom = frame.y + frame.height;
return {
x: Math.min(frame.x, right - minSize),
y: Math.min(frame.y, bottom - minSize),
width: Math.max(minSize, frame.width),
height: Math.max(minSize, frame.height),
};
}
function buildFreeResizeFrame({
frame,
handle,
deltaX,
deltaY,
minSize,
}: Omit<ResizeCropExpandFrameOptions, 'ratio'> & {
minSize: number;
}): CropExpandFrame {
const nextFrame = { ...frame };
const right = frame.x + frame.width;
const bottom = frame.y + frame.height;
if (handle.includes('left')) {
nextFrame.x = Math.min(frame.x + deltaX, right - minSize);
nextFrame.width = right - nextFrame.x;
}
if (handle.includes('right')) {
nextFrame.width = Math.max(minSize, frame.width + deltaX);
}
if (handle.includes('top')) {
nextFrame.y = Math.min(frame.y + deltaY, bottom - minSize);
nextFrame.height = bottom - nextFrame.y;
}
if (handle.includes('bottom')) {
nextFrame.height = Math.max(minSize, frame.height + deltaY);
}
return normalizeFreeFrame(nextFrame, minSize);
}
function resolveFixedDimensions({
candidate,
ratio,
minSize,
}: {
candidate: CropExpandFrame;
ratio: number;
minSize: number;
}) {
const widthFromHeight = Math.max(minSize, candidate.height * ratio);
const heightFromWidth = Math.max(minSize, candidate.width / ratio);
const optionFromWidth = {
width: Math.max(minSize, candidate.width),
height: heightFromWidth,
};
const optionFromHeight = {
width: widthFromHeight,
height: Math.max(minSize, candidate.height),
};
const widthDistance =
Math.abs(optionFromWidth.width - candidate.width) +
Math.abs(optionFromWidth.height - candidate.height);
const heightDistance =
Math.abs(optionFromHeight.width - candidate.width) +
Math.abs(optionFromHeight.height - candidate.height);
return widthDistance <= heightDistance ? optionFromWidth : optionFromHeight;
}
export function resizeCropExpandFrame({
frame,
handle,
deltaX,
deltaY,
ratio,
minSize = CROP_EXPAND_MIN_FRAME_SIZE,
}: ResizeCropExpandFrameOptions): CropExpandFrame {
const freeFrame = buildFreeResizeFrame({
frame,
handle,
deltaX,
deltaY,
minSize,
});
const ratioNumber = resolveCropExpandRatioNumber(ratio);
if (!ratioNumber) {
return freeFrame;
}
const sourceRight = frame.x + frame.width;
const sourceBottom = frame.y + frame.height;
const sourceCenterX = frame.x + frame.width / 2;
const sourceCenterY = frame.y + frame.height / 2;
const dimensions = resolveFixedDimensions({
candidate: freeFrame,
ratio: ratioNumber,
minSize,
});
switch (handle) {
case 'left':
return {
x: sourceRight - dimensions.width,
y: sourceCenterY - dimensions.height / 2,
...dimensions,
};
case 'right':
return {
x: frame.x,
y: sourceCenterY - dimensions.height / 2,
...dimensions,
};
case 'top':
return {
x: sourceCenterX - dimensions.width / 2,
y: sourceBottom - dimensions.height,
...dimensions,
};
case 'bottom':
return {
x: sourceCenterX - dimensions.width / 2,
y: frame.y,
...dimensions,
};
case 'top-left':
return {
x: sourceRight - dimensions.width,
y: sourceBottom - dimensions.height,
...dimensions,
};
case 'top-right':
return {
x: frame.x,
y: sourceBottom - dimensions.height,
...dimensions,
};
case 'bottom-left':
return {
x: sourceRight - dimensions.width,
y: frame.y,
...dimensions,
};
case 'bottom-right':
default:
return {
x: frame.x,
y: frame.y,
...dimensions,
};
}
}
@@ -1,24 +1,29 @@
import { type CSSProperties, type Dispatch, type SetStateAction } from 'react';
import { X } from 'lucide-react';
import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformTextField } from '../common/PlatformTextField';
import { EditorIconButton } from './ImageCanvasEditorPrimitives';
import type { CanvasLayer, CropExpandPanelState } from './ImageCanvasEditorTypes';
import type {
CropExpandPanelState,
CropExpandRatioValue,
} from './ImageCanvasEditorTypes';
type ImageCanvasCropExpandPanelViewProps = {
panel: CropExpandPanelState;
sourceLayer: CanvasLayer;
style: CSSProperties;
setCropExpandPanel: Dispatch<SetStateAction<CropExpandPanelState | null>>;
onSubmit: () => void;
};
const CROP_EXPAND_FIELDS = [
{ key: 'left', label: '左' },
{ key: 'top', label: '上' },
{ key: 'right', label: '右' },
{ key: 'bottom', label: '下' },
const CROP_EXPAND_RATIO_OPTIONS: Array<{
value: CropExpandRatioValue;
label: string;
shape?: 'square' | 'landscape' | 'portrait';
}> = [
{ value: 'free', label: '自由比例' },
{ value: '1:1', label: '1:1', shape: 'square' },
{ value: '4:3', label: '4:3', shape: 'landscape' },
{ value: '3:4', label: '3:4', shape: 'portrait' },
{ value: '16:9', label: '16:9', shape: 'landscape' },
{ value: '9:16', label: '9:16', shape: 'portrait' },
] as const;
function resetFailedCropExpandPanel(panel: CropExpandPanelState) {
@@ -31,7 +36,6 @@ function resetFailedCropExpandPanel(panel: CropExpandPanelState) {
export function ImageCanvasCropExpandPanelView({
panel,
sourceLayer,
style,
setCropExpandPanel,
onSubmit,
@@ -50,43 +54,41 @@ export function ImageCanvasCropExpandPanelView({
}
}}
>
<div className="image-canvas-editor__quick-edit-head">
<div className="image-canvas-editor__quick-edit-reference">
<img src={sourceLayer.src} alt={`${sourceLayer.title}参考图`} />
<span>{sourceLayer.title}</span>
</div>
<EditorIconButton
label="关闭裁扩图片"
title="关闭"
icon={X}
onClick={() => setCropExpandPanel(null)}
/>
<div className="image-canvas-editor__crop-expand-head">
<strong></strong>
</div>
<div className="image-canvas-editor__crop-expand-fields">
{CROP_EXPAND_FIELDS.map(({ key, label }) => (
<label key={key} className="image-canvas-editor__crop-expand-field">
<span>{label}</span>
<PlatformTextField
aria-label={`裁扩${label}`}
value={String(panel.insets[key])}
inputMode="numeric"
size="xs"
density="compact"
onChange={(event) =>
setCropExpandPanel((currentPanel) =>
currentPanel
? {
...resetFailedCropExpandPanel(currentPanel),
insets: {
...currentPanel.insets,
[key]: event.target.value,
},
}
: currentPanel,
)
}
<div className="image-canvas-editor__crop-expand-ratios">
{CROP_EXPAND_RATIO_OPTIONS.map((option) => (
<button
key={option.value}
type="button"
className={`image-canvas-editor__crop-expand-ratio ${
panel.ratio === option.value
? 'image-canvas-editor__crop-expand-ratio--selected'
: ''
}`}
aria-pressed={panel.ratio === option.value}
onClick={() =>
setCropExpandPanel((currentPanel) =>
currentPanel
? {
...resetFailedCropExpandPanel(currentPanel),
ratio: option.value,
}
: currentPanel,
)
}
>
<span
className={`image-canvas-editor__crop-expand-ratio-shape ${
option.shape
? `image-canvas-editor__crop-expand-ratio-shape--${option.shape}`
: ''
}`}
aria-hidden="true"
/>
</label>
<span>{option.label}</span>
</button>
))}
</div>
{panel.status === 'failed' ? (
@@ -94,15 +96,25 @@ export function ImageCanvasCropExpandPanelView({
{panel.errorMessage}
</p>
) : null}
<PlatformActionButton
type="submit"
tone="secondary"
size="sm"
className="image-canvas-editor__quick-edit-submit"
disabled={panel.status === 'processing'}
>
{panel.status === 'processing' ? '处理中' : '应用'}
</PlatformActionButton>
<div className="image-canvas-editor__crop-expand-actions">
<PlatformActionButton
type="button"
tone="secondary"
size="sm"
disabled={panel.status === 'processing'}
onClick={() => setCropExpandPanel(null)}
>
</PlatformActionButton>
<PlatformActionButton
type="submit"
tone="primary"
size="sm"
disabled={panel.status === 'processing'}
>
{panel.status === 'processing' ? '处理中' : '完成'}
</PlatformActionButton>
</div>
</form>
);
}
File diff suppressed because it is too large Load Diff
@@ -1,4 +1,5 @@
import type {
EditorCharacterAnimationGenerationResult,
EditorAssetLibrarySnapshot,
EditorProjectLayerSnapshot,
} from '../../services/image-editor/editorProjectClient';
@@ -331,11 +332,9 @@ export function hydrateCanvasGenerationDialog(
snapshot.characterAnimationDurationSeconds === 6
? snapshot.characterAnimationDurationSeconds
: undefined,
characterAnimationResult:
snapshot.characterAnimationResult &&
typeof snapshot.characterAnimationResult === 'object'
? snapshot.characterAnimationResult
: undefined,
characterAnimationResult: hydrateCharacterAnimationResult(
snapshot.characterAnimationResult,
),
soundType:
snapshot.soundType === 'loop' || snapshot.soundType === 'one-shot'
? snapshot.soundType
@@ -498,6 +497,29 @@ export function stringOrUndefined(value: unknown) {
return typeof value === 'string' && value.trim() ? value : undefined;
}
function hydrateCharacterAnimationResult(
value: unknown,
): EditorCharacterAnimationGenerationResult | undefined {
if (!value || typeof value !== 'object') {
return undefined;
}
const result = value as Partial<EditorCharacterAnimationGenerationResult>;
if (
typeof result.taskId !== 'string' ||
result.model !== 'seedance2.0-fast' ||
typeof result.prompt !== 'string' ||
typeof result.previewVideoPath !== 'string' ||
!Array.isArray(result.frames) ||
typeof result.frameCount !== 'number' ||
typeof result.durationSeconds !== 'number' ||
typeof result.fps !== 'number' ||
typeof result.priceMudPoints !== 'number'
) {
return undefined;
}
return result as EditorCharacterAnimationGenerationResult;
}
export function booleanFromSnapshot(value: unknown) {
return value === true;
}
@@ -641,6 +663,7 @@ function isCanvasGenerationDialogMode(
value === 'character' ||
value === 'icon' ||
value === 'ui-design' ||
value === 'quick-edit' ||
value === 'character-animation' ||
value === 'video' ||
value === 'audio-sound-effect' ||
@@ -127,7 +127,8 @@ function createStageProps(): ImageCanvasStageViewProps {
canvasMarquee: null,
canvasGenerationDialogs: [],
generateDialog: null,
quickEditPanel: null,
cropExpandPanel: null,
cropExpandSourceLayer: null,
generationComposerStyle: null,
selectedToolbarStyle: null,
uploadDropTarget: null,
@@ -159,6 +160,7 @@ function createStageProps(): ImageCanvasStageViewProps {
onOpenLayerMetadata: vi.fn(),
onGenerationFramePointerDown: vi.fn(),
onActivateGenerationDialog: vi.fn(),
onCropExpandHandlePointerDown: vi.fn(),
onOpenQuickEditPanel: vi.fn(),
onOpenRedrawPanel: vi.fn(),
onOpenCropExpandPanel: vi.fn(),
@@ -39,6 +39,7 @@ export type EditorAsset = {
taskId?: string;
objectKey?: string;
assetObjectId?: string;
durationSeconds?: number;
uploadStatus?: 'uploading' | 'failed';
uploadProgress?: number;
uploadMessage?: string;
@@ -86,6 +87,7 @@ export type CanvasLayer = {
groupId?: string | null;
assetKind?: CanvasAssetKind | null;
generationInputs?: CanvasGenerationInputs | null;
durationSeconds?: number;
hidden?: boolean;
locked?: boolean;
flipX?: boolean;
@@ -151,6 +153,7 @@ export type GenerateDialogState = {
| 'character'
| 'icon'
| 'ui-design'
| 'quick-edit'
| 'character-animation'
| 'video'
| 'audio-sound-effect'
@@ -249,22 +252,42 @@ export type QuickEditPanelState = {
prompt: string;
size: string;
model: string;
quickEditReferences?: CharacterReferenceImage[];
status: 'idle' | 'generating' | 'failed';
errorMessage?: string;
};
export type CropExpandPanelState = {
sourceLayerId: string;
insets: {
left: string;
top: string;
right: string;
bottom: string;
frame: {
x: number;
y: number;
width: number;
height: number;
};
ratio: CropExpandRatioValue;
status: 'idle' | 'processing' | 'failed';
errorMessage?: string;
};
export type CropExpandRatioValue =
| 'free'
| '1:1'
| '4:3'
| '3:4'
| '16:9'
| '9:16';
export type CropExpandResizeHandle =
| 'left'
| 'top'
| 'right'
| 'bottom'
| 'top-left'
| 'top-right'
| 'bottom-left'
| 'bottom-right';
export type CharacterAnimationPanelState = {
sourceLayerId: string;
promptText: string;
@@ -280,6 +303,7 @@ export type CharacterAnimationPanelState = {
export type UploadTarget =
| 'asset'
| 'generation-reference'
| 'quick-edit-reference'
| 'video-reference-image'
| 'video-reference-video'
| 'video-reference-audio'
@@ -464,6 +464,12 @@ describe('ImageCanvasEditorView', () => {
fireEvent.click(cropButton);
expect(screen.getByRole('dialog', { name: '裁扩图片' })).toBeTruthy();
expect(
screen.getByRole('button', { name: '自由比例' }).getAttribute(
'aria-pressed',
),
).toBe('true');
expect(screen.queryByLabelText('裁扩左')).toBeNull();
expect(screen.getByRole('toolbar', { name: '图片工具栏' })).toBeTruthy();
});
@@ -1,4 +1,12 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
type Dispatch,
type SetStateAction,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import {
createEditorAsset,
@@ -15,6 +23,7 @@ import type {
CanvasTool,
CanvasViewport,
ImageContextMenuState,
QuickEditPanelState,
} from './ImageCanvasEditorTypes';
import { getEditorUploadAccept } from './ImageCanvasFileModel';
import { isCanvasGenerationComposerVisible } from './ImageCanvasOverlayModel';
@@ -61,6 +70,9 @@ export function ImageCanvasEditorView() {
const iconSpecButtonRef = useRef<HTMLButtonElement | null>(null);
const selectedLayerIdRef = useRef<string | null>(null);
const selectedLayerIdsRef = useRef<string[]>([]);
const setQuickEditPanelRef = useRef<
Dispatch<SetStateAction<QuickEditPanelState | null>>
>(() => {});
const deleteLayerByIdRef = useRef<(targetLayerId: string | null) => void>(
() => {},
);
@@ -486,6 +498,7 @@ export function ImageCanvasEditorView() {
setAssets,
setLayers,
setGenerateDialog,
setQuickEditPanel: (updater) => setQuickEditPanelRef.current(updater),
appendCanvasLayersWithResources,
selectSingleLayer,
});
@@ -524,7 +537,6 @@ export function ImageCanvasEditorView() {
persistUpdatedLayerResource,
});
const {
quickEditPanel,
setQuickEditPanel,
setIsSpecMenuOpen,
setIsGenerationReferenceMenuOpen,
@@ -532,6 +544,8 @@ export function ImageCanvasEditorView() {
setIsCharacterReferenceMenuOpen,
isPickingGenerationReferenceFromCanvas,
setIsPickingGenerationReferenceFromCanvas,
isPickingQuickEditReferenceFromCanvas,
setIsPickingQuickEditReferenceFromCanvas,
isPickingCharacterSpecFromCanvas,
setIsPickingCharacterSpecFromCanvas,
isPickingCharacterReferenceFromCanvas,
@@ -549,6 +563,7 @@ export function ImageCanvasEditorView() {
removeSelectedLayerBackground,
pickCharacterSpecFromLayer,
pickGenerationReferenceFromLayer,
pickQuickEditReferenceFromLayer,
pickCharacterReferenceFromLayer,
pickIconSpecFromLayer,
pickUiDesignSpecFromLayer,
@@ -558,6 +573,7 @@ export function ImageCanvasEditorView() {
generationComposerNode,
switchGenerationTool,
} = generationSurface;
setQuickEditPanelRef.current = setQuickEditPanel;
const {
selectedLayer,
selectedToolbarStyle,
@@ -643,12 +659,14 @@ export function ImageCanvasEditorView() {
setGenerateDialog,
isPickingCharacterSpecFromCanvas,
isPickingGenerationReferenceFromCanvas,
isPickingQuickEditReferenceFromCanvas,
isPickingCharacterReferenceFromCanvas,
isPickingIconSpecFromCanvas,
isPickingUiDesignSpecFromCanvas,
clearCanvasFocus,
pickCharacterSpecFromLayer,
pickGenerationReferenceFromLayer,
pickQuickEditReferenceFromLayer,
pickCharacterReferenceFromLayer,
pickIconSpecFromLayer,
pickUiDesignSpecFromLayer,
@@ -685,6 +703,7 @@ export function ImageCanvasEditorView() {
setIsSpecMenuOpen,
setIsGenerationReferenceMenuOpen,
setIsPickingGenerationReferenceFromCanvas,
setIsPickingQuickEditReferenceFromCanvas,
setIsCharacterSpecMenuOpen,
setIsCharacterReferenceMenuOpen,
setIsPickingCharacterSpecFromCanvas,
@@ -859,7 +878,8 @@ export function ImageCanvasEditorView() {
canvasMarquee,
canvasGenerationDialogs,
generateDialog,
quickEditPanel,
cropExpandPanel: generationSurface.cropExpandPanel,
cropExpandSourceLayer: generationSurface.cropExpandSourceLayer,
generationComposerStyle,
selectedToolbarStyle,
uploadDropTarget,
@@ -897,6 +917,8 @@ export function ImageCanvasEditorView() {
},
onGenerationFramePointerDown: handleGenerationFramePointerDown,
onActivateGenerationDialog: activateCanvasGenerationDialog,
onCropExpandHandlePointerDown:
generationSurface.startCropExpandFrameResize,
onOpenQuickEditPanel: openQuickEditPanel,
onOpenRedrawPanel: openRedrawPanel,
onOpenCropExpandPanel: openCropExpandPanel,
@@ -39,6 +39,7 @@ function createComposerProps(
isPickingCharacterSpecFromCanvas: false,
isPickingCharacterReferenceFromCanvas: false,
isPickingGenerationReferenceFromCanvas: false,
isPickingQuickEditReferenceFromCanvas: false,
isPickingIconSpecFromCanvas: false,
isPickingUiDesignSpecFromCanvas: false,
generateDialog,
@@ -50,8 +51,6 @@ function createComposerProps(
cropExpandPanel: null,
cropExpandSourceLayer: null,
cropExpandPanelStyle: null,
quickEditSizeOptions: ['1024x1024'],
quickEditModelOptions: [{ label: 'gpt-image-2', value: 'gpt-image-2' }],
characterAnimationPanel: null,
characterAnimationSourceLayer: null,
characterAnimationPanelStyle: null,
@@ -78,6 +77,7 @@ function createComposerProps(
setIsCharacterSpecMenuOpen: mockStateSetter<boolean>(),
setIsCharacterReferenceMenuOpen: mockStateSetter<boolean>(),
setIsGenerationReferenceMenuOpen: mockStateSetter<boolean>(),
setIsPickingQuickEditReferenceFromCanvas: mockStateSetter<boolean>(),
setIsIconSpecMenuOpen: mockStateSetter<boolean>(),
setIsUiDesignSpecMenuOpen: mockStateSetter<boolean>(),
setIsPickingCharacterSpecFromCanvas: mockStateSetter<boolean>(),
@@ -145,7 +145,7 @@ describe('ImageCanvasGenerationComposerView', () => {
'image-canvas-editor__generation-composer--image',
);
expect(
within(panel).getByRole('button', { name: 'UI设计图标素材规范' }),
within(panel).getByRole('button', { name: 'UI设计图标规范' }),
).toBeTruthy();
expect(
within(panel).getByRole('textbox', { name: 'UI设计要求' }).className,
@@ -159,7 +159,7 @@ describe('ImageCanvasGenerationComposerView', () => {
expect(setGenerateDialog).toHaveBeenCalled();
const menu = screen.getByRole('menu', { name: '参考图来源' });
expect(
within(menu).getByRole('menuitem', { name: '新建图标素材规范' }),
within(menu).getByRole('menuitem', { name: '新建图标规范' }),
).toBeTruthy();
});
@@ -201,7 +201,7 @@ describe('ImageCanvasGenerationComposerView', () => {
).toContain('image-canvas-editor__generation-submit');
});
it('让图标素材规范生成面板也保留首行参考区', () => {
it('让图标规范生成面板也保留首行参考区', () => {
renderComposer({
mode: 'spec',
prompt: '',
@@ -60,6 +60,7 @@ type ImageCanvasGenerationComposerViewProps = {
isIconSpecMenuOpen: boolean;
isUiDesignSpecMenuOpen: boolean;
isPickingGenerationReferenceFromCanvas: boolean;
isPickingQuickEditReferenceFromCanvas: boolean;
isPickingCharacterSpecFromCanvas: boolean;
isPickingCharacterReferenceFromCanvas: boolean;
isPickingIconSpecFromCanvas: boolean;
@@ -73,8 +74,6 @@ type ImageCanvasGenerationComposerViewProps = {
cropExpandPanel: CropExpandPanelState | null;
cropExpandSourceLayer: CanvasLayer | null;
cropExpandPanelStyle: CSSProperties | null;
quickEditSizeOptions: string[];
quickEditModelOptions: Array<{ label: string; value: string }>;
characterAnimationPanel: CharacterAnimationPanelState | null;
characterAnimationSourceLayer: CanvasLayer | null;
characterAnimationPanelStyle: CSSProperties | null;
@@ -91,6 +90,7 @@ type ImageCanvasGenerationComposerViewProps = {
setIsIconSpecMenuOpen: Dispatch<SetStateAction<boolean>>;
setIsUiDesignSpecMenuOpen: Dispatch<SetStateAction<boolean>>;
setIsPickingGenerationReferenceFromCanvas: Dispatch<SetStateAction<boolean>>;
setIsPickingQuickEditReferenceFromCanvas: Dispatch<SetStateAction<boolean>>;
setIsPickingCharacterSpecFromCanvas: Dispatch<SetStateAction<boolean>>;
setIsPickingCharacterReferenceFromCanvas: Dispatch<SetStateAction<boolean>>;
setIsPickingIconSpecFromCanvas: Dispatch<SetStateAction<boolean>>;
@@ -852,6 +852,7 @@ export function ImageCanvasGenerationComposerView({
isIconSpecMenuOpen,
isUiDesignSpecMenuOpen,
isPickingGenerationReferenceFromCanvas,
isPickingQuickEditReferenceFromCanvas,
isPickingCharacterSpecFromCanvas,
isPickingCharacterReferenceFromCanvas,
isPickingIconSpecFromCanvas,
@@ -865,8 +866,6 @@ export function ImageCanvasGenerationComposerView({
cropExpandPanel,
cropExpandSourceLayer,
cropExpandPanelStyle,
quickEditSizeOptions,
quickEditModelOptions,
characterAnimationPanel,
characterAnimationSourceLayer,
characterAnimationPanelStyle,
@@ -881,6 +880,7 @@ export function ImageCanvasGenerationComposerView({
setIsIconSpecMenuOpen,
setIsUiDesignSpecMenuOpen,
setIsPickingGenerationReferenceFromCanvas,
setIsPickingQuickEditReferenceFromCanvas,
setIsPickingCharacterSpecFromCanvas,
setIsPickingCharacterReferenceFromCanvas,
setIsPickingIconSpecFromCanvas,
@@ -1080,7 +1080,7 @@ export function ImageCanvasGenerationComposerView({
{isPickingCharacterSpecFromCanvas ? (
<div className="image-canvas-editor__canvas-pick-hint">
Esc 退
Esc 退
</div>
) : null}
{isPickingCharacterReferenceFromCanvas ? (
@@ -1093,14 +1093,19 @@ export function ImageCanvasGenerationComposerView({
Esc 退
</div>
) : null}
{isPickingQuickEditReferenceFromCanvas ? (
<div className="image-canvas-editor__canvas-pick-hint">
Esc 退
</div>
) : null}
{isPickingIconSpecFromCanvas ? (
<div className="image-canvas-editor__canvas-pick-hint">
Esc 退
Esc 退
</div>
) : null}
{isPickingUiDesignSpecFromCanvas ? (
<div className="image-canvas-editor__canvas-pick-hint">
Esc 退
Esc 退
</div>
) : null}
@@ -1113,8 +1118,15 @@ export function ImageCanvasGenerationComposerView({
panel={quickEditPanel}
sourceLayer={quickEditSourceLayer}
style={quickEditPanelStyle}
sizeOptions={quickEditSizeOptions}
modelOptions={quickEditModelOptions}
referenceButtonRef={generationReferenceButtonRef}
isReferenceMenuOpen={isGenerationReferenceMenuOpen}
setIsReferenceMenuOpen={setIsGenerationReferenceMenuOpen}
setIsPickingQuickEditReferenceFromCanvas={
setIsPickingQuickEditReferenceFromCanvas
}
renderEditorPortal={renderEditorPortal}
buildPortalMenuStyle={buildPortalMenuStyle}
onRequestUpload={onRequestUpload}
setQuickEditPanel={setQuickEditPanel}
onSubmit={onSubmitQuickEdit}
/>
@@ -1125,7 +1137,6 @@ export function ImageCanvasGenerationComposerView({
cropExpandPanelStyle ? (
<ImageCanvasCropExpandPanelView
panel={cropExpandPanel}
sourceLayer={cropExpandSourceLayer}
style={cropExpandPanelStyle}
setCropExpandPanel={setCropExpandPanel}
onSubmit={onSubmitCropExpand}
@@ -13,6 +13,7 @@ import {
assignUiDesignSpecReference,
closeGenerateComposerDialog,
createBackgroundMusicGenerationDialogDraft,
createCharacterAnimationGenerationDialogDraft,
createCharacterAnimationPanelDraft,
createCharacterGenerationDialogDraft,
createEditDialogDraft,
@@ -240,6 +241,7 @@ describe('ImageCanvasGenerationDialogModel', () => {
prompt: '',
size: '1024x768',
model: 'gpt-image-2',
quickEditReferences: [],
status: 'idle',
});
expect(createCharacterAnimationPanelDraft(sourceLayer)).toEqual({
@@ -254,6 +256,47 @@ describe('ImageCanvasGenerationDialogModel', () => {
expect(createCharacterAnimationPanelDraft(createLayer())).toBeNull();
});
it('creates character animation generation dialog drafts for character layers', () => {
const canvasSize = { width: 960, height: 720 };
const viewport = { x: 0, y: 0, scale: 1 };
const characterLayer = createLayer({
id: 'layer-character',
assetKind: 'character',
});
expect(
createCharacterAnimationGenerationDialogDraft({
canvasSize,
viewport,
layer: characterLayer,
}),
).toMatchObject({
mode: 'character-animation',
sourceLayerId: 'layer-character',
status: 'idle',
composerOpen: true,
characterAnimationResolution: '480p',
characterAnimationRatio: 'same',
characterAnimationFrameCount: 32,
characterAnimationDurationSeconds: 4,
placeholder: {
x: 270,
y: 150,
width: 420,
height: 420,
originalWidth: 1024,
originalHeight: 1024,
},
});
expect(
createCharacterAnimationGenerationDialogDraft({
canvasSize,
viewport,
layer: createLayer(),
}),
).toBeNull();
});
it('resets failed character and icon dialogs when picking references', () => {
const characterDialog: GenerateDialogState = {
mode: 'character',
@@ -288,7 +331,7 @@ describe('ImageCanvasGenerationDialogModel', () => {
mode: 'icon',
prompt: '',
status: 'failed',
errorMessage: '请选择图标素材规范',
errorMessage: '请选择图标规范',
};
expect(
assignIconSpecReference(
@@ -301,6 +344,28 @@ describe('ImageCanvasGenerationDialogModel', () => {
iconSpecReference: { id: 'canvas-layer-source' },
});
expect(assignIconSpecReference(iconDialog, sourceLayer)).toBe(iconDialog);
expect(
assignIconSpecReference(
iconDialog,
createLayer({
title: '旧UI素材规范',
assetKind: 'spec',
generationInputs: {
fields: [
{
title: '玩法设定',
value: '生成一张完整游戏UI规范汇总设定展板',
},
],
references: [],
},
}),
),
).toMatchObject({
status: 'idle',
errorMessage: undefined,
iconSpecReference: { label: '旧UI素材规范' },
});
});
it('routes picked canvas references to spec, image, video, and UI design fields', () => {
@@ -17,6 +17,7 @@ import {
CHARACTER_ANIMATION_DURATION_OPTIONS,
CHARACTER_FRAME_DISPLAY_SIZE,
CHARACTER_FRAME_ORIGINAL_SIZE,
appendLimitedQuickEditReferences,
createCanvasLayerReference,
DEFAULT_ICON_DESCRIPTIONS,
DEFAULT_IMAGE_MODEL,
@@ -63,6 +64,14 @@ function resetFailedGenerationDialog(dialog: GenerateDialogState) {
};
}
function resetFailedGenerationPanel(panel: QuickEditPanelState) {
return {
...panel,
status: panel.status === 'failed' ? 'idle' : panel.status,
errorMessage: panel.status === 'failed' ? undefined : panel.errorMessage,
};
}
export function parseIconDescriptionsText(text: string): string[] {
return text
.split(/[\r\n,;/|]+/u)
@@ -86,6 +95,24 @@ function getReferenceMediaType(layer: CanvasLayer) {
: 'image';
}
function isIconSpecReferenceLayer(layer: CanvasLayer) {
if (layer.assetKind === 'icon-spec') {
return true;
}
// 中文注释:历史“UI素材规范”图层以普通 spec 保存,但语义上就是图标规范,允许继续作为图标生成参考。
if (layer.assetKind !== 'spec') {
return false;
}
return (
layer.title.includes('图标规范') ||
layer.title.includes('UI素材规范') ||
layer.generationInputs?.fields.some(
(field) =>
field.value.includes('游戏UI规范') || field.value.includes('图标规范'),
) === true
);
}
function appendLimitedSeedanceCanvasReference(
references: NonNullable<GenerateDialogState['generationReferences']>,
layer: CanvasLayer,
@@ -374,10 +401,45 @@ export function createQuickEditPanelDraft(
sourceLayer.originalHeight,
),
model: sourceLayer.model?.trim() || DEFAULT_IMAGE_MODEL,
quickEditReferences: [],
status: 'idle',
};
}
export function createQuickEditGenerationDialogDraft({
sourceLayer,
prompt,
status = 'idle',
references = [],
}: {
sourceLayer: CanvasLayer;
prompt: string;
status?: CanvasGenerationDialogState['status'];
references?: NonNullable<QuickEditPanelState['quickEditReferences']>;
}): Omit<CanvasGenerationDialogState, 'id'> {
return {
mode: 'quick-edit',
prompt,
status,
composerOpen: false,
sourceLayerId: sourceLayer.id,
generationReferences: references.slice(0, 8),
imageModel: sourceLayer.model?.trim() || DEFAULT_IMAGE_MODEL,
imageSize: formatImageSizeValue(
sourceLayer.originalWidth,
sourceLayer.originalHeight,
),
placeholder: {
x: sourceLayer.x + sourceLayer.width + 32,
y: sourceLayer.y,
width: sourceLayer.width,
height: sourceLayer.height,
originalWidth: sourceLayer.originalWidth,
originalHeight: sourceLayer.originalHeight,
},
};
}
export function createRedrawPanelDraft(
sourceLayer: CanvasLayer,
): QuickEditPanelState {
@@ -517,7 +579,7 @@ export function assignIconSpecReference(
dialog: GenerateDialogState | null,
layer: CanvasLayer,
): GenerateDialogState | null {
if (layer.assetKind !== 'icon-spec') {
if (!isIconSpecReferenceLayer(layer)) {
return dialog;
}
return dialog?.mode === 'icon'
@@ -533,7 +595,7 @@ export function assignUiDesignSpecReference(
dialog: GenerateDialogState | null,
layer: CanvasLayer,
): GenerateDialogState | null {
if (layer.assetKind !== 'icon-spec') {
if (!isIconSpecReferenceLayer(layer)) {
return dialog;
}
return dialog?.mode === 'ui-design'
@@ -577,6 +639,22 @@ export function updateIconDescriptionsTextInDialog(
: dialog;
}
export function appendQuickEditReference(
panel: QuickEditPanelState | null,
layer: CanvasLayer,
): QuickEditPanelState | null {
if (!panel || panel.mode === 'redraw' || getReferenceMediaType(layer) !== 'image') {
return panel;
}
return {
...resetFailedGenerationPanel(panel),
quickEditReferences: appendLimitedQuickEditReferences(
panel.quickEditReferences,
[createCanvasLayerReference(layer)],
),
};
}
export function updateCharacterAnimationDurationPanel(
panel: CharacterAnimationPanelState | null,
frameCountValue: string,
@@ -604,6 +682,7 @@ export function hideGeneratedLayerComposerAfterBlur(
dialog?.mode === 'character' ||
dialog?.mode === 'icon' ||
dialog?.mode === 'ui-design' ||
dialog?.mode === 'quick-edit' ||
dialog?.mode === 'character-animation' ||
dialog?.mode === 'video' ||
dialog?.mode === 'audio-sound-effect' ||
@@ -624,6 +703,7 @@ export function closeGenerateComposerDialog(
dialog?.mode === 'character' ||
dialog?.mode === 'icon' ||
dialog?.mode === 'ui-design' ||
dialog?.mode === 'quick-edit' ||
dialog?.mode === 'character-animation' ||
dialog?.mode === 'video' ||
dialog?.mode === 'audio-sound-effect' ||
@@ -6,6 +6,7 @@ import type {
} from './ImageCanvasEditorTypes';
import {
createAudioResultLayer,
createCharacterAnimationResultLayer,
createGeneratedResultLayer,
createIconSpritesheetResultLayers,
createQuickEditResultLayer,
@@ -71,14 +72,14 @@ describe('ImageCanvasGenerationLayerModel', () => {
originalHeight: 1152,
},
assetKind: 'spec',
title: 'UI素材规范 7',
title: '图标规范 7',
generationInputs: createGenerationInputs(),
});
expect(layer).toMatchObject({
id: 'layer-generated-7',
resourceId: 'local-resource-generated-7',
title: 'UI素材规范 7',
title: '图标规范 7',
x: -664,
y: -358.5,
width: 2048,
@@ -120,6 +121,32 @@ describe('ImageCanvasGenerationLayerModel', () => {
});
});
it('creates quick edit layers centered on the generating placeholder', () => {
const sourceLayer = createSourceLayer();
const layer = createQuickEditResultLayer({
generated: createGenerated({ width: 1536, height: 1024 }),
generatedIndex: 10,
sourceLayer,
generationInputs: createGenerationInputs(),
frame: {
x: 600,
y: 300,
width: 320,
height: 240,
originalWidth: 1536,
originalHeight: 1024,
},
});
expect(layer).toMatchObject({
id: 'layer-quick-edit-10',
x: -8,
y: -92,
width: 1536,
height: 1024,
});
});
it('creates quick edit layers with source group and asset kind preserved', () => {
const sourceLayer = createSourceLayer();
const layer = createQuickEditResultLayer({
@@ -278,4 +305,66 @@ describe('ImageCanvasGenerationLayerModel', () => {
taskId: 'sound-task-1',
});
});
it('creates character animation result layers with action metadata', () => {
const layer = createCharacterAnimationResultLayer({
generated: {
taskId: 'character-animation-task-1',
model: 'seedance2.0-fast',
prompt: '待机动作',
previewVideoPath: '/generated-character-drafts/editor/preview.mp4',
frames: [
{
frameIndex: 1,
imageSrc: '/generated-character-drafts/editor/frame1.png',
width: 1024,
height: 1024,
},
],
frameCount: 32,
durationSeconds: 4,
fps: 8,
priceMudPoints: 40,
},
generatedIndex: 15,
title: '角色动作',
canvasSize: { width: 900, height: 640 },
viewport: { x: 10, y: 20, scale: 2 },
frame: {
x: 80,
y: 60,
width: 420,
height: 420,
originalWidth: 1024,
originalHeight: 1024,
},
generationInputs: {
fields: [{ title: '动作描述', value: '待机动作' }],
references: [],
},
});
expect(layer).toMatchObject({
id: 'layer-character-animation-15',
resourceId: 'local-resource-character-animation-15',
title: '角色动作',
src: '/generated-character-drafts/editor/frame1.png',
x: -222,
y: -242,
width: 1024,
height: 1024,
originalWidth: 1024,
originalHeight: 1024,
zIndex: 25,
sourceType: 'generated',
prompt: '待机动作',
actualPrompt: '待机动作',
model: 'seedance2.0-fast',
provider: 'ark',
taskId: 'character-animation-task-1',
assetKind: 'character-animation',
});
expect(layer?.mediaType).toBeUndefined();
expect(layer?.generationInputs?.fields[0]?.value).toBe('待机动作');
});
});
@@ -1,6 +1,6 @@
import type {
EditorCharacterAnimationGenerationResult,
EditorAudioGenerationResult,
EditorCharacterAnimationGenerationResult,
EditorIconSpritesheetGenerationResult,
EditorIconSpritesheetIconResult,
EditorImageGenerationResult,
@@ -35,6 +35,7 @@ type QuickEditResultLayerOptions = {
sourceLayer: CanvasLayer;
generationInputs: CanvasGenerationInputs;
mode?: 'quick-edit' | 'redraw';
frame?: GenerateDialogState['placeholder'];
};
type IconSpritesheetResultLayerOptions = {
@@ -173,6 +174,7 @@ export function createQuickEditResultLayer({
sourceLayer,
generationInputs,
mode = 'quick-edit',
frame,
}: QuickEditResultLayerOptions): CanvasLayer {
const originalWidth = generated.width || sourceLayer.originalWidth || 1024;
const originalHeight = generated.height || sourceLayer.originalHeight || 1024;
@@ -184,6 +186,14 @@ export function createQuickEditResultLayer({
height: sourceLayer.height,
},
);
const frameX =
frame && frame.width > 0
? frame.x + frame.width / 2 - width / 2
: undefined;
const frameY =
frame && frame.height > 0
? frame.y + frame.height / 2 - height / 2
: undefined;
return applyGeneratedMetadata(
{
@@ -191,8 +201,8 @@ export function createQuickEditResultLayer({
resourceId: `local-resource-quick-edit-${generatedIndex}`,
title: `${sourceLayer.title} ${mode === 'redraw' ? '重绘' : '快速编辑'}`,
src: generated.imageSrc,
x: sourceLayer.x + sourceLayer.width + 32,
y: sourceLayer.y,
x: frameX ?? sourceLayer.x + sourceLayer.width + 32,
y: frameY ?? sourceLayer.y,
width,
height,
originalWidth,
@@ -10,6 +10,7 @@ import {
buildEditGenerationInputs,
buildIconGenerationInputs,
buildImageGenerationInputs,
buildQuickEditGenerationInputs,
buildQuickEditModelOptions,
buildSoundEffectGenerationInputs,
buildSpecGenerationInputs,
@@ -17,6 +18,7 @@ import {
DEFAULT_IMAGE_MODEL,
getGenerationFrameAriaLabel,
getGenerationFrameLabel,
normalizeQuickEditPromptSourceReference,
resolveCharacterAnimationSourceImageSrc,
resolveImageGenerationErrorMessage,
} from './ImageCanvasGenerationModel';
@@ -59,7 +61,7 @@ describe('ImageCanvasGenerationModel', () => {
).toEqual({
fields: [{ title: '角色设定', value: '主角骑士' }],
references: [
{ title: '角色形象规范', label: '角色规范', src: '/spec.png' },
{ title: '角色规范', label: '角色规范', src: '/spec.png' },
{ title: '常规参考图 1', label: '盔甲参考', src: '/armor.png' },
],
});
@@ -75,7 +77,7 @@ describe('ImageCanvasGenerationModel', () => {
{ title: '素材描述', value: '返回按钮\n设置按钮' },
],
references: [
{ title: '图标素材规范', label: '图标规范', src: '/icon-spec.png' },
{ title: '图标规范', label: '图标规范', src: '/icon-spec.png' },
],
});
@@ -87,6 +89,35 @@ describe('ImageCanvasGenerationModel', () => {
{ title: '参考图', label: '原图', src: '/source.png' },
],
});
expect(
buildQuickEditGenerationInputs('快速编辑提示词', '让图2下雨', sourceLayer, [
{ id: 'ref-1', label: '天空', src: '/sky.png' },
]),
).toEqual({
fields: [{ title: '快速编辑提示词', value: '让图2下雨' }],
references: [
{ title: '参考图 1', label: '天空', src: '/sky.png' },
{ title: '参考图 2', label: '原图', src: '/source.png' },
],
});
});
it('rewrites quick edit source image references to the final image index', () => {
expect(normalizeQuickEditPromptSourceReference('把图1改成夜晚', 3)).toBe(
'把图3改成夜晚',
);
expect(normalizeQuickEditPromptSourceReference('参考原图增加暖光', 2)).toBe(
'参考图2增加暖光',
);
expect(normalizeQuickEditPromptSourceReference('保留图1构图', 1)).toBe(
'保留图1构图',
);
expect(
normalizeQuickEditPromptSourceReference('保持当前图片构图', 1),
).toBe('保持图1构图');
expect(
normalizeQuickEditPromptSourceReference('不要影响图10内容', 2),
).toBe('不要影响图10内容');
});
it('keeps generated prompts and quick edit options stable', () => {
@@ -166,6 +197,15 @@ describe('ImageCanvasGenerationModel', () => {
expect(getGenerationFrameAriaLabel(iconDialog)).toBe('图标素材生成占位图');
expect(getGenerationFrameLabel(iconDialog)).toBe('Icon Generator');
expect(
getGenerationFrameAriaLabel({
...iconDialog,
mode: 'quick-edit',
}),
).toBe('快速编辑生成占位图');
expect(getGenerationFrameLabel({ ...iconDialog, mode: 'quick-edit' })).toBe(
'Quick Edit Generator',
);
expect(
buildSoundEffectGenerationInputs('金币掉落叮当声', 'one-shot', null),
).toEqual({
@@ -97,6 +97,7 @@ export const SEEDANCE_VIDEO_REFERENCE_LIMITS = {
audio: 3,
requestBytes: 64 * 1024 * 1024,
} as const;
export const QUICK_EDIT_REFERENCE_LIMIT = 8;
export const CHARACTER_ANIMATION_ACTION_PROMPTS = [
{ label: '待机', text: '待机动作,轻微呼吸起伏。' },
{ label: '行走', text: '循环行走动作,步伐稳定。' },
@@ -176,9 +177,9 @@ export const DEFAULT_SPEC_FORM_VALUES: Record<
};
export const SPEC_TYPE_LABEL: Record<SpecGenerationType, string> = {
character: '角色形象规范',
ui: 'UI素材规范',
icon: '图标素材规范',
character: '角色规范',
ui: '图标规范',
icon: '图标规范',
custom: '自定义规范',
};
@@ -316,7 +317,7 @@ export function formatLayerImageType(layer: CanvasLayer) {
return '图标素材图片';
}
if (layer.assetKind === 'icon-spec') {
return '图标素材规范图片';
return '图标规范图片';
}
if (layer.assetKind === 'ui-design') {
return 'UI设计图';
@@ -377,6 +378,30 @@ export function createCanvasLayerReference(
};
}
export function appendLimitedQuickEditReferences(
references: CharacterReferenceImage[] | undefined,
nextReferences: CharacterReferenceImage[],
) {
const imageReferences = nextReferences.filter(
(reference) => (reference.mediaType ?? 'image') === 'image',
);
return [
...(references ?? []),
...imageReferences,
].slice(0, QUICK_EDIT_REFERENCE_LIMIT);
}
export function normalizeQuickEditPromptSourceReference(
prompt: string,
sourceReferenceIndex: number,
) {
const normalizedIndex = Math.max(1, sourceReferenceIndex);
const sourceReferenceLabel = `${normalizedIndex}`;
return prompt
.replace(/||/gu, sourceReferenceLabel)
.replace(/\s*1(?!\d)/gu, sourceReferenceLabel);
}
export function createGenerationInputField(
title: string,
value: string | null | undefined,
@@ -499,7 +524,7 @@ export function buildCharacterGenerationInputs(
...(specReference
? [
{
title: '角色形象规范',
title: '角色规范',
label: specReference.label,
src: specReference.src,
},
@@ -523,7 +548,7 @@ export function buildUiDesignGenerationInputs(
references: specReference
? [
{
title: '图标素材规范',
title: '图标规范',
label: specReference.label,
src: specReference.src,
},
@@ -545,7 +570,7 @@ export function buildIconGenerationInputs(
],
references: [
{
title: '图标素材规范',
title: '图标规范',
label: specReference.label,
src: specReference.src,
},
@@ -570,6 +595,33 @@ export function buildEditGenerationInputs(
};
}
export function buildQuickEditGenerationInputs(
title: '快速编辑提示词' | '重绘提示词',
prompt: string,
sourceLayer: CanvasLayer,
references: CharacterReferenceImage[] | undefined,
): CanvasGenerationInputs {
const limitedReferences = (references ?? []).slice(
0,
QUICK_EDIT_REFERENCE_LIMIT,
);
return {
fields: createGenerationInputField(title, prompt),
references: [
...limitedReferences.map((reference, index) => ({
title: `参考图 ${index + 1}`,
label: reference.label,
src: reference.src,
})),
{
title: `参考图 ${limitedReferences.length + 1}`,
label: sourceLayer.title,
src: sourceLayer.objectKey?.trim() || sourceLayer.src,
},
],
};
}
export function isCanvasGenerationDialog(
dialog: GenerateDialogState | null,
): dialog is CanvasGenerationDialogState {
@@ -580,6 +632,7 @@ export function isCanvasGenerationDialog(
dialog.mode === 'character' ||
dialog.mode === 'icon' ||
dialog.mode === 'ui-design' ||
dialog.mode === 'quick-edit' ||
dialog.mode === 'character-animation' ||
dialog.mode === 'video' ||
dialog.mode === 'audio-sound-effect' ||
@@ -602,6 +655,9 @@ export function getGenerationFrameAriaLabel(
if (dialog.mode === 'ui-design') {
return 'UI设计图生成占位图';
}
if (dialog.mode === 'quick-edit') {
return '快速编辑生成占位图';
}
if (dialog.mode === 'video') {
return '视频生成占位图';
}
@@ -630,6 +686,9 @@ export function getGenerationFrameLabel(dialog: CanvasGenerationDialogState) {
if (dialog.mode === 'ui-design') {
return 'UI Design Generator';
}
if (dialog.mode === 'quick-edit') {
return 'Quick Edit Generator';
}
if (dialog.mode === 'video') {
return 'Video Generator';
}
@@ -125,7 +125,7 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
},
result: {
assetKind: 'icon-spec',
title: '图标素材规范 8',
title: '图标规范 8',
},
});
expect(plan.kind === 'image' ? plan.result.generationInputs : null).toEqual({
@@ -143,6 +143,34 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
});
});
it('treats the renamed UI spec entry as an icon spec asset', () => {
const plan = buildImageGenerationSubmissionPlan({
dialog: {
mode: 'spec',
prompt: '',
status: 'idle',
specType: 'ui',
specValues: {
playSetting: '消除类派对玩法',
artStyle: '糖果玻璃拟物',
bodyRatio: '3',
characterView: '',
customPrompt: '',
},
},
layers: [],
nextGeneratedIndex: 2,
});
expect(plan).toMatchObject({
kind: 'image',
result: {
assetKind: 'icon-spec',
title: '图标规范 2',
},
});
});
it('builds character plans with references and remembered model', () => {
const plan = buildImageGenerationSubmissionPlan({
dialog: {
@@ -193,7 +221,7 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
fields: [{ title: '角色设定', value: '白发骑士' }],
references: [
{
title: '角色形象规范',
title: '角色规范',
label: '角色规范',
src: 'data:image/png;base64,spec',
},
@@ -231,7 +259,7 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
expect(plan).toEqual({
ok: false,
errorMessage: '请选择图标素材规范',
errorMessage: '请选择图标规范',
});
});
@@ -285,7 +313,7 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
fields: [{ title: '素材描述', value: '返回按钮\n设置按钮' }],
references: [
{
title: '图标素材规范',
title: '图标规范',
label: '图标规范',
src: 'data:image/png;base64,spec',
},
@@ -223,7 +223,9 @@ export function buildImageGenerationSubmissionPlan({
: {}),
},
result: {
assetKind: specType === 'icon' ? 'icon-spec' : 'spec',
// 中文注释:生成规范菜单里的“图标规范”沿用历史 ui specType,但产物语义应作为图标规范供图标素材引用。
assetKind:
specType === 'ui' || specType === 'icon' ? 'icon-spec' : 'spec',
title: `${SPEC_TYPE_LABEL[specType]} ${nextGeneratedIndex}`,
generationInputs: buildSpecGenerationInputs(
specType,
@@ -420,7 +422,7 @@ export function buildIconSpritesheetGenerationSubmissionPlan(
if (!dialog.iconSpecReference) {
return {
ok: false,
errorMessage: '请选择图标素材规范',
errorMessage: '请选择图标规范',
};
}
@@ -87,18 +87,18 @@ describe('ImageCanvasIconSpritesheetComposerView', () => {
/>,
);
fireEvent.click(screen.getByRole('button', { name: '图标素材规范' }));
expect(screen.getByRole('menu', { name: '图标素材规范来源' })).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '图标规范' }));
expect(screen.getByRole('menu', { name: '图标规范来源' })).toBeTruthy();
fireEvent.click(screen.getByRole('menuitem', { name: '从画布中选择' }));
expect(screen.getByLabelText('正在选择图标规范').textContent).toBe('true');
expect(screen.getByLabelText('当前菜单').textContent).toBe('false');
fireEvent.click(screen.getByRole('button', { name: '图标素材规范' }));
fireEvent.click(screen.getByRole('menuitem', { name: '新建图标素材规范' }));
fireEvent.click(screen.getByRole('button', { name: '图标规范' }));
fireEvent.click(screen.getByRole('menuitem', { name: '新建图标规范' }));
expect(openSpecDialog).toHaveBeenCalledWith('icon');
fireEvent.click(screen.getByRole('button', { name: '图标素材规范' }));
fireEvent.click(screen.getByRole('button', { name: '图标规范' }));
fireEvent.click(screen.getByRole('menuitem', { name: '上传图片' }));
expect(requestUpload).toHaveBeenCalledWith('icon-spec');
});
@@ -112,7 +112,7 @@ describe('ImageCanvasIconSpritesheetComposerView', () => {
expect(firstRow).toBeTruthy();
expect(
within(firstRow as HTMLElement).getByRole('button', {
name: '图标素材规范',
name: '图标规范',
}),
).toBeTruthy();
expect(
@@ -136,9 +136,9 @@ describe('ImageCanvasIconSpritesheetComposerView', () => {
/>,
);
fireEvent.click(screen.getByRole('button', { name: '删除图标素材规范' }));
fireEvent.click(screen.getByRole('button', { name: '删除图标规范' }));
expect(screen.getByRole('button', { name: '图标素材规范' })).toBeTruthy();
expect(screen.getByRole('button', { name: '图标规范' })).toBeTruthy();
expect(screen.queryByRole('button', { name: '图标规范A' })).toBeNull();
});
@@ -108,8 +108,8 @@ export function ImageCanvasIconSpritesheetComposerView({
icon={<ImageIcon className="h-4 w-4" aria-hidden="true" />}
imageSrc={dialog.iconSpecReference?.src}
label="图标规范"
ariaLabel={dialog.iconSpecReference?.label ?? '图标素材规范'}
title={dialog.iconSpecReference?.label ?? '图标素材规范'}
ariaLabel={dialog.iconSpecReference?.label ?? '图标规范'}
title={dialog.iconSpecReference?.label ?? '图标规范'}
disabled={dialog.status === 'generating'}
isAdd={!dialog.iconSpecReference}
onClick={() => setIsIconSpecMenuOpen((open) => !open)}
@@ -134,13 +134,13 @@ export function ImageCanvasIconSpritesheetComposerView({
)
: undefined
}
removeLabel="删除图标素材规范"
removeLabel="删除图标规范"
/>
{isIconSpecMenuOpen
? renderEditorPortal(
<PlatformFloatingMenu
className="image-canvas-editor__character-spec-menu image-canvas-editor__portal-menu"
label="图标素材规范来源"
label="图标规范来源"
placement="top-start"
style={buildPortalMenuStyle(
iconSpecButtonRef.current,
@@ -157,7 +157,7 @@ export function ImageCanvasIconSpritesheetComposerView({
className="image-canvas-editor__context-menu-item"
onClick={openIconSpecDialog}
>
</PlatformFloatingMenuItem>
<PlatformFloatingMenuItem
className="image-canvas-editor__context-menu-item"
@@ -0,0 +1,29 @@
export function normalizeCanvasDurationSeconds(value: unknown) {
return typeof value === 'number' && Number.isFinite(value) && value > 0
? value
: null;
}
export function formatCanvasDurationLabel(value: unknown) {
const durationSeconds = normalizeCanvasDurationSeconds(value);
if (durationSeconds === null) {
return '--:--';
}
const totalSeconds = Math.max(1, Math.round(durationSeconds));
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
const paddedSeconds = String(seconds).padStart(2, '0');
if (hours > 0) {
return `${hours}:${String(minutes).padStart(2, '0')}:${paddedSeconds}`;
}
return `${minutes}:${paddedSeconds}`;
}
export function formatCanvasDurationMetric(value: unknown) {
// 中文注释:音频对象没有视觉分辨率语义,画布角标统一显示时长。
return `时长 ${formatCanvasDurationLabel(value)}`;
}
@@ -150,14 +150,13 @@ export function resolveCropExpandPanelStyle({
return {
left: clamp(
viewport.x +
(sourceLayer.x + sourceLayer.width / 2) * viewport.scale,
(sourceLayer.x + sourceLayer.width) * viewport.scale +
12,
12,
Math.max(12, canvasSize.width - 12),
Math.max(12, canvasSize.width - 312),
),
top: clamp(
viewport.y +
(sourceLayer.y + sourceLayer.height) * viewport.scale +
12,
viewport.y + sourceLayer.y * viewport.scale,
12,
Math.max(12, canvasSize.height - 320),
),
@@ -203,6 +202,7 @@ export function isCanvasGenerationComposerVisible(
dialog?.mode === 'character' ||
dialog?.mode === 'icon' ||
dialog?.mode === 'ui-design' ||
dialog?.mode === 'quick-edit' ||
dialog?.mode === 'character-animation' ||
dialog?.mode === 'video' ||
dialog?.mode === 'audio-sound-effect' ||

Some files were not shown because too many files have changed in this diff Show More