完善快速编辑参考图与生成占位
- 快速编辑支持最多8张额外参考图,并将原图自动作为最后一张参考图提交 - 快速编辑尺寸和模型沿用原图配置,按禁用态参数胶囊展示 - 快速编辑提交前同步改写原图引用为实际图序号 - 快速编辑生成改为创建独立画布生成占位,并支持键盘删除生成中对象 - 对齐生成类面板参考图图标、规范类固定参数和相关文档测试
This commit is contained in:
@@ -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
Reference in New Issue
Block a user