Merge remote-tracking branch 'refs/remotes/origin/master' into feat/gptimage2to2.5
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Has been cancelled

# Conflicts:
#	docs/project-memory/shared-memory/decision-log.md
#	server-rs/crates/api-server/src/state.rs
#	src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts
This commit is contained in:
2026-09-19 15:37:29 +08:00
301 changed files with 37940 additions and 7824 deletions
@@ -2749,6 +2749,7 @@ describe('ImageCanvasEditorView generation integration', () => {
expect.objectContaining({
referenceId: 'resource-icon-spec',
iconDescriptions: ['返回按钮\n设置按钮'],
sliceMode: 'connected-components',
model: 'gemini-3.1-flash-image-preview',
aspectRatio: '1:1',
imageSize: '1K',
@@ -3874,9 +3875,7 @@ describe('ImageCanvasEditorView generation integration', () => {
if (!metadataCornerButton) {
throw new Error('metadata corner button should exist');
}
expect(metadataCornerButton.className).toContain(
'image-canvas-editor__metadata-corner',
);
expect(metadataCornerButton.className).toContain('shared-canvas-card-info');
fireEvent.click(metadataCornerButton);
const metadataDialog = screen.getByRole('dialog', { name: '图片信息' });
@@ -1387,9 +1387,7 @@ describe('ImageCanvasEditorView', () => {
const infoButton = screen.getByRole('button', {
name: '查看拼图素材图片信息',
});
expect(infoButton.className).toContain(
'image-canvas-editor__metadata-corner',
);
expect(infoButton.className).toContain('shared-canvas-card-info');
fireEvent.click(infoButton);
const infoPanel = screen.getByRole('dialog', { name: '图片信息' });
@@ -1081,6 +1081,7 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
referenceId: 'resource-icon-spec',
referenceImageSrcs: ['data:image/png;base64,ref'],
iconDescriptions: ['返回按钮\n\n设置按钮'],
sliceMode: 'connected-components',
model: 'gpt-image-2.5',
screenColor: 'auto',
segModel: 'birefnet',
@@ -1136,6 +1136,9 @@ export function buildIconSpritesheetGenerationSubmissionPlan(
}
: {}),
iconDescriptions,
// 切分模式没有默认值:画板链路按自由排布生成图标表,因此显式声明连通域切分;
// 等分网格或固定槽位需求必须由调用方显式传 grid + gridX/gridY。
sliceMode: 'connected-components',
model: rememberImageModel,
screenColor,
segModel,
@@ -70,6 +70,50 @@ function renderSelectedToolbar(
}
describe('ImageCanvasSelectedLayerToolbarView', () => {
it('AGC 收纳包含宿主末组,换选后关闭旧资源更多菜单', () => {
const props = renderSelectedToolbar({
maxVisibleActions: 5,
supportedActions: new Set([
'quick-edit',
'character-animation',
'download',
]),
downloadLabel: '导出',
extraActions: (
<>
<button></button>
<button></button>
<button></button>
</>
),
endActions: <button></button>,
});
const toolbar = screen.getByRole('toolbar');
expect(
within(toolbar)
.getAllByRole('button')
.map((button) => button.textContent),
).toEqual(['快速编辑', '生成动画', '引用', '编辑标签', '重命名', '更多 ▴']);
fireEvent.mouseEnter(screen.getByRole('button', { name: '更多' }));
const group = screen.getByRole('group', { name: '更多操作' });
expect(
within(group)
.getAllByRole('button')
.map((button) => button.textContent),
).toEqual(['导出', '删除素材']);
fireEvent.click(within(group).getByRole('button', { name: '导出' }));
expect(props.onDownloadLayer).toHaveBeenCalledWith(props.selectedLayer);
cleanup();
const view = render(<ImageCanvasSelectedLayerToolbarView {...props} />);
fireEvent.mouseEnter(screen.getByRole('button', { name: '更多' }));
view.rerender(
<ImageCanvasSelectedLayerToolbarView
{...props}
selectedLayer={createLayer({ id: 'other' })}
/>,
);
expect(screen.queryByRole('group', { name: '更多操作' })).toBeNull();
});
it('常规动作与末组分别分隔,导出紧邻删除且位于删除之前', () => {
const props = renderSelectedToolbar({
supportedActions: new Set(['quick-edit', 'download']),
@@ -1,4 +1,5 @@
import { CanvasChromeButton } from '@genarrative/image-canvas-react';
import { OverflowActions } from '@genarrative/shared/components';
import {
Crop,
Download,
@@ -38,6 +39,7 @@ export type ImageCanvasSelectedToolbarAction =
| 'download';
type ImageCanvasSelectedLayerToolbarViewProps = {
maxVisibleActions?: number;
/**
* 宿主显式声明的可用动作集合。
*
@@ -78,6 +80,7 @@ function hasToolbarActions(actions: ReactNode): boolean {
}
export function ImageCanvasSelectedLayerToolbarView({
maxVisibleActions,
supportedActions = null,
extraActions,
endActions,
@@ -148,22 +151,24 @@ export function ImageCanvasSelectedLayerToolbarView({
aria-label="素材工具栏"
onPointerDown={(event) => event.stopPropagation()}
>
{canRedraw ? (
<CanvasChromeButton
className="image-canvas-editor__floating-toolbar-text-button"
label="改造"
title="改造"
icon={<WandSparkles className="h-4 w-4" />}
onClick={() => onOpenRedrawPanel(selectedLayer)}
>
<span></span>
</CanvasChromeButton>
) : null}
{canRedraw && hasExtraActions ? divider : null}
{extraActions}
{(canRedraw || hasExtraActions) && hasEndActions ? divider : null}
{downloadAction}
{endActions}
<OverflowActions key={selectedLayer.id} maxVisible={maxVisibleActions}>
{canRedraw ? (
<CanvasChromeButton
className="image-canvas-editor__floating-toolbar-text-button"
label="改造"
title="改造"
icon={<WandSparkles className="h-4 w-4" />}
onClick={() => onOpenRedrawPanel(selectedLayer)}
>
<span></span>
</CanvasChromeButton>
) : null}
{canRedraw && hasExtraActions ? divider : null}
{extraActions}
{(canRedraw || hasExtraActions) && hasEndActions ? divider : null}
{downloadAction}
{endActions}
</OverflowActions>
</div>
);
}
@@ -208,170 +213,172 @@ export function ImageCanvasSelectedLayerToolbarView({
aria-label="图片工具栏"
onPointerDown={(event) => event.stopPropagation()}
>
{showQuickEdit ? (
<CanvasChromeButton
className="image-canvas-editor__floating-toolbar-text-button"
label="快速编辑"
title="快速编辑"
icon={<Sparkles className="h-4 w-4" />}
onClick={() => onOpenQuickEditPanel(selectedLayer)}
>
<span></span>
</CanvasChromeButton>
) : null}
{showQuickEdit && hasEditingActions ? divider : null}
{showCropExpand ? (
<EditorIconButton
label="裁扩按钮"
title="裁扩按钮"
icon={Crop}
onClick={() => onOpenCropExpandPanel(selectedLayer)}
/>
) : null}
{showRemoveBackground ? (
<EditorIconButton
label="去除背景按钮"
title="去除背景按钮"
icon={ImageOff}
onClick={() => onRemoveBackground(selectedLayer)}
/>
) : null}
{showPerfectPixel ? (
<CanvasChromeButton
className="image-canvas-editor__floating-toolbar-text-button"
// 中文注释:保存态名称不能直接用「素材类型保存中」——icon-spritesheet 图层上
// 拆分图集按钮同时显示该文案,两个控件会撞同一个无障碍名称。
label={
isPersistingAssetKind
? '完美像素等待素材类型保存'
: isPerfectPixelProcessing
? '完美像素处理中'
: isPerfectPixelPendingConfirmation
? '完美像素结果待确认'
: '完美像素'
}
title={
isPersistingAssetKind
? '完美像素等待素材类型保存'
: isPerfectPixelProcessing
? '完美像素处理中'
: isPerfectPixelPendingConfirmation
? '结果待确认,双击原占位继续核对或重试'
: '自动识别并规整像素网格'
}
icon={
isPersistingAssetKind || isPerfectPixelProcessing ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Grid2X2 className="h-4 w-4" />
)
}
// 中文注释:素材类型保存在途时必须一并禁用。请求同时带 assetKind 和
// sourceResourceId,本地类型已改但资源尚未落库时两者不一致,后端
// resolve_editor_pixel_art_snap_asset_kind 会直接 400,只留下失败占位。
// 与相邻的拆分图集按钮保持同一套门禁。
disabled={
isPersistingAssetKind ||
isPerfectPixelProcessing ||
isPerfectPixelPendingConfirmation
}
aria-busy={isPersistingAssetKind || isPerfectPixelProcessing}
onClick={() => onPerfectPixel(selectedLayer)}
>
<span>
{isPersistingAssetKind
? '保存中'
: isPerfectPixelProcessing
? '处理中'
: isPerfectPixelPendingConfirmation
? '待确认'
: '完美像素'}
</span>
</CanvasChromeButton>
) : null}
{showSplitIconSpritesheet ? (
<CanvasChromeButton
className="image-canvas-editor__floating-toolbar-text-button"
label={
isPersistingAssetKind
? '素材类型保存中'
: isSplittingIconSpritesheet
? '拆图中'
: '拆分图集'
}
title={
isPersistingAssetKind
? '素材类型保存中'
: isSplittingIconSpritesheet
? '拆图中'
: '拆分图集'
}
icon={
isPersistingAssetKind || isSplittingIconSpritesheet ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Scissors className="h-4 w-4" />
)
}
disabled={isPersistingAssetKind || isSplittingIconSpritesheet}
aria-busy={isPersistingAssetKind || isSplittingIconSpritesheet}
onClick={() => onSplitIconSpritesheet(selectedLayer)}
>
<span>
{isPersistingAssetKind
? '保存中'
: isSplittingIconSpritesheet
? '拆图中'
: '拆分图集'}
</span>
</CanvasChromeButton>
) : null}
{showExtractUiDesign ? (
<CanvasChromeButton
className="image-canvas-editor__floating-toolbar-text-button"
label="提取素材"
title="提取素材"
icon={<Scissors className="h-4 w-4" />}
onClick={() => onExtractUiDesignAssets(selectedLayer)}
>
<span></span>
</CanvasChromeButton>
) : null}
{showCharacterAnimation ? (
<CanvasChromeButton
className="image-canvas-editor__floating-toolbar-text-button"
label="生成动画"
title="生成动画"
icon={<PersonStanding className="h-4 w-4" />}
onClick={() => onOpenCharacterAnimationPanel(selectedLayer)}
>
<span></span>
</CanvasChromeButton>
) : null}
{canRedraw ? (
<>
{showQuickEdit || hasEditingActions ? divider : null}
<OverflowActions key={selectedLayer.id} maxVisible={maxVisibleActions}>
{showQuickEdit ? (
<CanvasChromeButton
className="image-canvas-editor__floating-toolbar-text-button"
label="改造"
title="改造"
icon={<WandSparkles className="h-4 w-4" />}
onClick={() => onOpenRedrawPanel(selectedLayer)}
label="快速编辑"
title="快速编辑"
icon={<Sparkles className="h-4 w-4" />}
onClick={() => onOpenQuickEditPanel(selectedLayer)}
>
<span></span>
<span></span>
</CanvasChromeButton>
</>
) : null}
{(showQuickEdit || hasEditingActions || canRedraw) && hasExtraActions
? divider
: null}
{extraActions}
{(showQuickEdit || hasEditingActions || canRedraw || hasExtraActions) &&
hasEndActions
? divider
: null}
{downloadAction}
{endActions}
) : null}
{showQuickEdit && hasEditingActions ? divider : null}
{showCropExpand ? (
<EditorIconButton
label="裁扩按钮"
title="裁扩按钮"
icon={Crop}
onClick={() => onOpenCropExpandPanel(selectedLayer)}
/>
) : null}
{showRemoveBackground ? (
<EditorIconButton
label="去除背景按钮"
title="去除背景按钮"
icon={ImageOff}
onClick={() => onRemoveBackground(selectedLayer)}
/>
) : null}
{showPerfectPixel ? (
<CanvasChromeButton
className="image-canvas-editor__floating-toolbar-text-button"
// 中文注释:保存态名称不能直接用「素材类型保存中」——icon-spritesheet 图层上
// 拆分图集按钮同时显示该文案,两个控件会撞同一个无障碍名称。
label={
isPersistingAssetKind
? '完美像素等待素材类型保存'
: isPerfectPixelProcessing
? '完美像素处理中'
: isPerfectPixelPendingConfirmation
? '完美像素结果待确认'
: '完美像素'
}
title={
isPersistingAssetKind
? '完美像素等待素材类型保存'
: isPerfectPixelProcessing
? '完美像素处理中'
: isPerfectPixelPendingConfirmation
? '结果待确认,双击原占位继续核对或重试'
: '自动识别并规整像素网格'
}
icon={
isPersistingAssetKind || isPerfectPixelProcessing ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Grid2X2 className="h-4 w-4" />
)
}
// 中文注释:素材类型保存在途时必须一并禁用。请求同时带 assetKind 和
// sourceResourceId,本地类型已改但资源尚未落库时两者不一致,后端
// resolve_editor_pixel_art_snap_asset_kind 会直接 400,只留下失败占位。
// 与相邻的拆分图集按钮保持同一套门禁。
disabled={
isPersistingAssetKind ||
isPerfectPixelProcessing ||
isPerfectPixelPendingConfirmation
}
aria-busy={isPersistingAssetKind || isPerfectPixelProcessing}
onClick={() => onPerfectPixel(selectedLayer)}
>
<span>
{isPersistingAssetKind
? '保存中'
: isPerfectPixelProcessing
? '处理中'
: isPerfectPixelPendingConfirmation
? '待确认'
: '完美像素'}
</span>
</CanvasChromeButton>
) : null}
{showSplitIconSpritesheet ? (
<CanvasChromeButton
className="image-canvas-editor__floating-toolbar-text-button"
label={
isPersistingAssetKind
? '素材类型保存中'
: isSplittingIconSpritesheet
? '拆图中'
: '拆分图集'
}
title={
isPersistingAssetKind
? '素材类型保存中'
: isSplittingIconSpritesheet
? '拆图中'
: '拆分图集'
}
icon={
isPersistingAssetKind || isSplittingIconSpritesheet ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Scissors className="h-4 w-4" />
)
}
disabled={isPersistingAssetKind || isSplittingIconSpritesheet}
aria-busy={isPersistingAssetKind || isSplittingIconSpritesheet}
onClick={() => onSplitIconSpritesheet(selectedLayer)}
>
<span>
{isPersistingAssetKind
? '保存中'
: isSplittingIconSpritesheet
? '拆图中'
: '拆分图集'}
</span>
</CanvasChromeButton>
) : null}
{showExtractUiDesign ? (
<CanvasChromeButton
className="image-canvas-editor__floating-toolbar-text-button"
label="提取素材"
title="提取素材"
icon={<Scissors className="h-4 w-4" />}
onClick={() => onExtractUiDesignAssets(selectedLayer)}
>
<span></span>
</CanvasChromeButton>
) : null}
{showCharacterAnimation ? (
<CanvasChromeButton
className="image-canvas-editor__floating-toolbar-text-button"
label="生成动画"
title="生成动画"
icon={<PersonStanding className="h-4 w-4" />}
onClick={() => onOpenCharacterAnimationPanel(selectedLayer)}
>
<span></span>
</CanvasChromeButton>
) : null}
{canRedraw ? (
<>
{showQuickEdit || hasEditingActions ? divider : null}
<CanvasChromeButton
className="image-canvas-editor__floating-toolbar-text-button"
label="改造"
title="改造"
icon={<WandSparkles className="h-4 w-4" />}
onClick={() => onOpenRedrawPanel(selectedLayer)}
>
<span></span>
</CanvasChromeButton>
</>
) : null}
{(showQuickEdit || hasEditingActions || canRedraw) && hasExtraActions
? divider
: null}
{extraActions}
{(showQuickEdit || hasEditingActions || canRedraw || hasExtraActions) &&
hasEndActions
? divider
: null}
{downloadAction}
{endActions}
</OverflowActions>
</div>
);
}
@@ -814,7 +814,7 @@ describe('ImageCanvasWorldView', () => {
expect(
(
within(layerButton).getByText('角色') as HTMLElement
within(layerButton).getByText('角色').parentElement as HTMLElement
).style.getPropertyValue('--image-canvas-editor-inverse-scale'),
).toBe(inverseScale);
expect(
@@ -825,7 +825,9 @@ describe('ImageCanvasWorldView', () => {
expect(
within(layerButton)
.getByRole('button', { name: '查看角色主图图片信息' })
.style.getPropertyValue('--image-canvas-editor-inverse-scale'),
.parentElement!.style.getPropertyValue(
'--image-canvas-editor-inverse-scale',
),
).toBe(inverseScale);
expect(
(
@@ -1118,12 +1120,8 @@ describe('ImageCanvasWorldView', () => {
name: '查看角色主图图片信息',
});
expect(badge.className).toContain(
'image-canvas-editor__kind-badge--beside-info',
);
expect(metadataButton.className).not.toContain(
'image-canvas-editor__metadata-corner--beside-kind',
);
expect(badge.parentElement?.className).toBe('shared-canvas-card-corners');
expect(badge.nextElementSibling).toBe(metadataButton);
});
it('keeps the layer type menu scrollable and closes it when clicking outside', () => {
@@ -3,13 +3,13 @@ import {
LayerRenderer,
SelectionOverlay,
} from '@genarrative/image-canvas-react';
import { CanvasCardCornerActions } from '@genarrative/shared/components';
import {
AppWindow,
Clapperboard,
ClipboardList,
Grid2X2,
ImageIcon,
Info,
Megaphone,
Mountain,
Music,
@@ -44,7 +44,6 @@ import {
PlatformFloatingMenu,
PlatformFloatingMenuItem,
} from '../common/PlatformFloatingMenu';
import { PlatformIconButton } from '../common/PlatformIconButton';
import { PlatformPillBadge } from '../common/PlatformPillBadge';
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
import {
@@ -367,17 +366,6 @@ function handleLayerKeyboardActivation(event: ReactKeyboardEvent<HTMLElement>) {
event.currentTarget.click();
}
function handleLayerTagKeyboardActivation(
event: ReactKeyboardEvent<HTMLElement>,
) {
if (event.key !== 'Enter' && event.key !== ' ') {
return;
}
event.preventDefault();
event.stopPropagation();
event.currentTarget.click();
}
function stopMediaControlKeyPropagation(
event: ReactKeyboardEvent<HTMLElement>,
) {
@@ -1012,38 +1000,19 @@ const MemoizedCanvasLayerNode = memo(function CanvasLayerNode({
mediaTransform={mediaTransform}
/>
)}
{kindLabel ? (
<span
ref={isKindMenuOpen ? openLayerKindTriggerRef : undefined}
role="button"
tabIndex={0}
aria-label={`素材标签:${kindLabel}`}
className={`image-canvas-editor__kind-badge image-canvas-editor__kind-badge--${kindTagMeta.className} image-canvas-editor__kind-badge--beside-info image-canvas-editor__kind-badge--editable`}
style={inverseScaleStyle}
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
handlers.setOpenLayerKindMenuId((currentId) =>
currentId === layer.id ? null : layer.id,
);
}}
onKeyDown={handleLayerTagKeyboardActivation}
>
{kindLabel}
</span>
) : null}
<PlatformIconButton
asChild="spanButton"
variant="darkMini"
className="image-canvas-editor__metadata-corner"
label={`查看${layer.title}${metadataMediaLabel}信息`}
icon={<Info className="h-3 w-3" />}
<CanvasCardCornerActions
kindLabel={kindLabel}
kindAriaLabel={`素材标签:${kindLabel}`}
kindRef={isKindMenuOpen ? openLayerKindTriggerRef : undefined}
kindClassName={`image-canvas-editor__kind-badge--${kindTagMeta.className}`}
infoLabel={`查看${layer.title}${metadataMediaLabel}信息`}
style={inverseScaleStyle}
onClick={(event) => {
event.stopPropagation();
handlers.onOpenLayerMetadata(layer);
}}
onPointerDown={(event) => event.stopPropagation()}
onKindClick={() =>
handlers.setOpenLayerKindMenuId((currentId) =>
currentId === layer.id ? null : layer.id,
)
}
onInfoClick={() => handlers.onOpenLayerMetadata(layer)}
/>
{isHovered && layer.mediaType !== 'audio' ? (
<PlatformPillBadge
@@ -4249,6 +4249,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
'generated-character-drafts/editor/refs/icon-style.png',
],
iconDescriptions: ['返回按钮'],
sliceMode: 'connected-components',
}),
);
});
@@ -1112,6 +1112,7 @@ describe('editorProjectClient', () => {
referenceId: 'editor-resource-spec',
referenceImageSrcs: references.slice(0, 5),
iconDescriptions: ['返回按钮'],
sliceMode: 'connected-components',
model: 'gpt-image-2',
}),
).rejects.toThrow('图标素材参考图最多允许 4 张');
@@ -1162,6 +1163,29 @@ describe('editorProjectClient', () => {
);
});
it('requires an explicit slice declaration and rejects contradictory grid dimensions', async () => {
requestJsonMock.mockResolvedValue({ queueState: { status: 'queued' } });
await expect(
generateEditorIconSpritesheet({
referenceId: 'editor-resource-icon-spec',
iconDescriptions: ['返回按钮'],
sliceMode: 'grid',
}),
).rejects.toThrow('sliceMode=grid 必须同时提供 gridX 与 gridY');
await expect(
generateEditorIconSpritesheet({
referenceId: 'editor-resource-icon-spec',
iconDescriptions: ['返回按钮'],
sliceMode: 'connected-components',
gridX: 2,
gridY: 2,
}),
).rejects.toThrow('sliceMode=connected-components 不接受 gridX/gridY');
expect(requestJsonMock).not.toHaveBeenCalled();
});
it('rejects oversized stable video reference fields before submission', async () => {
await expect(
generateEditorVideo({
@@ -1331,6 +1355,9 @@ describe('editorProjectClient', () => {
referenceId: 'editor-resource-spec',
referenceImageSrcs: ['/generated-images/editor/icon-ref.png'],
iconDescriptions: ['返回按钮', '设置按钮'],
sliceMode: 'grid',
gridX: 3,
gridY: 2,
assetLabel: '冒险游戏图标',
});
@@ -1348,6 +1375,9 @@ describe('editorProjectClient', () => {
referenceId: 'editor-resource-spec',
referenceImageSrcs: ['/generated-images/editor/icon-ref.png'],
iconDescriptions: ['返回按钮', '设置按钮'],
sliceMode: 'grid',
gridX: 3,
gridY: 2,
model: 'gemini-3.1-flash-image-preview',
assetLabel: '冒险游戏图标',
}),
@@ -1365,6 +1395,7 @@ describe('editorProjectClient', () => {
generateEditorIconSpritesheet({
referenceId: 'editor-resource-spec',
iconDescriptions: ['图'.repeat(EDITOR_ICON_DESCRIPTION_MAX_CHARS + 1)],
sliceMode: 'connected-components',
}),
).rejects.toThrow(`不能超过 ${EDITOR_ICON_DESCRIPTION_MAX_CHARS} 个字符`);
@@ -1377,6 +1408,7 @@ describe('editorProjectClient', () => {
'图'.repeat(EDITOR_ICON_DESCRIPTION_MAX_CHARS),
),
],
sliceMode: 'connected-components',
}),
).rejects.toThrow(
`合计不能超过 ${EDITOR_ICON_DESCRIPTIONS_MAX_TOTAL_CHARS} 个字符`,
@@ -1388,6 +1420,7 @@ describe('editorProjectClient', () => {
iconDescriptions: Array.from({ length: 8 }, () =>
'😀'.repeat(EDITOR_ICON_DESCRIPTION_MAX_CHARS),
),
sliceMode: 'connected-components',
}),
).rejects.toThrow(
`合计不能超过 ${EDITOR_ICON_DESCRIPTIONS_MAX_TOTAL_UTF8_BYTES} 个 UTF-8 字节`,
@@ -1412,6 +1445,7 @@ describe('editorProjectClient', () => {
await generateEditorIconSpritesheet({
referenceId: 'editor-resource-spec',
iconDescriptions: ['返回按钮'],
sliceMode: 'connected-components',
model: 'gpt-image-2',
screenColor: '#E6D8FF',
segModel: 'anime-seg',
@@ -1434,6 +1468,7 @@ describe('editorProjectClient', () => {
body: JSON.stringify({
referenceId: 'editor-resource-spec',
iconDescriptions: ['返回按钮'],
sliceMode: 'connected-components',
model: 'gpt-image-2',
screenColor: '#E6D8FF',
segModel: 'anime-seg',
@@ -380,7 +380,8 @@ export type EditorIconSpritesheetGenerationInput = {
referenceId: string;
referenceImageSrcs?: string[];
iconDescriptions: string[];
sliceMode?: 'connected-components' | 'grid';
/** 必填且没有默认值:必须显式声明连通域或网格切分。 */
sliceMode: 'connected-components' | 'grid';
gridX?: number;
gridY?: number;
model?: string;
@@ -1300,6 +1301,16 @@ export async function generateEditorIconSpritesheet(
input.iconDescriptions,
);
const model = input.model?.trim() || EDITOR_IMAGE_MODEL_NANOBANANA2;
// 切分模式没有默认值:声明必须自洽,网格尺寸只能与 grid 同时提交。
if (input.sliceMode === 'grid') {
if (input.gridX === undefined || input.gridY === undefined) {
throw new Error('sliceMode=grid 必须同时提供 gridX 与 gridY');
}
} else if (input.gridX !== undefined || input.gridY !== undefined) {
throw new Error(
'sliceMode=connected-components 不接受 gridX/gridY:网格尺寸只能与 grid 同时提交',
);
}
assertStableEditorMediaReferences(input.referenceImageSrcs, '图标素材参考图');
assertEditorReferenceLimit(
input.referenceImageSrcs,
@@ -1317,9 +1328,13 @@ export async function generateEditorIconSpritesheet(
? { referenceImageSrcs: input.referenceImageSrcs }
: {}),
iconDescriptions,
...(input.sliceMode ? { sliceMode: input.sliceMode } : {}),
...(input.gridX !== undefined ? { gridX: input.gridX } : {}),
...(input.gridY !== undefined ? { gridY: input.gridY } : {}),
sliceMode: input.sliceMode,
...(input.sliceMode === 'grid' && input.gridX !== undefined
? { gridX: input.gridX }
: {}),
...(input.sliceMode === 'grid' && input.gridY !== undefined
? { gridY: input.gridY }
: {}),
model,
...(input.screenColor ? { screenColor: input.screenColor } : {}),
...(input.segModel ? { segModel: input.segModel } : {}),