From 6afc1cf920cfa8ead6bcdaf468f14242678e7ec7 Mon Sep 17 00:00:00 2001 From: kdletters Date: Wed, 17 Jun 2026 20:21:07 +0800 Subject: [PATCH 01/16] =?UTF-8?q?=E6=8A=BD=E5=87=BA=E4=B8=8A=E4=BC=A0?= =?UTF-8?q?=E5=8F=82=E8=80=83=E5=9B=BE=E7=8A=B6=E6=80=81=E6=A8=A1=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 扩展 ImageCanvasUploadModel 承载生成参考图上传后的状态转换 精简 useImageCanvasUploadWorkflow 中的参考图 dialog 写回逻辑 补充上传模型单测覆盖参考图写入和失败态清理 更新 TRACKING.md 记录第四十五执行批次验证 --- TRACKING.md | 2 + .../ImageCanvasUploadModel.test.ts | 131 +++++++++++++++++- .../image-editor/ImageCanvasUploadModel.ts | 86 ++++++++++++ .../useImageCanvasUploadWorkflow.ts | 104 +++++++------- 4 files changed, 272 insertions(+), 51 deletions(-) diff --git a/TRACKING.md b/TRACKING.md index b671815ba..b3e47ea13 100644 --- a/TRACKING.md +++ b/TRACKING.md @@ -169,3 +169,5 @@ - 2026-06-17 前端拆分第四十三执行批次:继续收口 `useImageCanvasGenerationWorkflow`,新增 `useImageCanvasGenerationSubmissionWorkflow`,把普通生图 / 修改图 / 图标素材批量生成 / 快速编辑 / 角色动画的提交 API、提交中 / 失败 / 完成状态回写、生成结果落画布、选中图层、切换图层侧栏、适合视图和 last image model 记忆从入口 workflow 中抽成生成提交流水线 hook;原 workflow 保留生成入口、菜单开关、画布选取引用、表单字段更新、生成器关闭和删除图层后的状态清理,避免把 UI 选择态和提交态混在一起。新增 hook 单测覆盖 quick edit 成功 / 失败、edit 成功清理 modal、生成使用最新移动占位、icon 缺规范校验、icon 成功批量落图并记住模型、角色动画 completed / failed 状态机;同步修复新测试文件的 mock 隔离,避免 `vi.clearAllMocks()` 清空并行集成测试的调用记录;`useImageCanvasGenerationWorkflow.ts` 从 870 行降至 499 行。统一验证命令:`npm run test -- src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx`、`npm run test -- src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts src/components/image-editor/ImageCanvasGenerationLayerModel.test.ts src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx`、`npm run test -- src/components/image-editor/ImageCanvasStageInteractionModel.test.ts src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts src/components/image-editor/ImageCanvasAssetLibraryModel.test.ts src/components/image-editor/ImageCanvasUploadModel.test.ts src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts src/components/image-editor/ImageCanvasGenerationLayerModel.test.ts src/components/image-editor/ImageCanvasAssetRowView.test.tsx src/components/image-editor/ImageCanvasSidebarView.test.tsx src/components/image-editor/ImageCanvasBasicGenerationComposerView.test.tsx src/components/image-editor/ImageCanvasCharacterGenerationComposerView.test.tsx src/components/image-editor/ImageCanvasEditGenerationModalView.test.tsx src/components/image-editor/ImageCanvasEditorShellView.test.tsx src/components/image-editor/ImageCanvasBottomToolbarView.test.tsx src/components/image-editor/ImageCanvasPanelDockView.test.tsx src/components/image-editor/ImageCanvasContextMenusView.test.tsx src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx src/components/image-editor/ImageCanvasIconSpritesheetComposerView.test.tsx src/components/image-editor/ImageCanvasSpecGenerationPanelView.test.tsx src/components/image-editor/ImageCanvasGenerationImageOptionsView.test.tsx src/components/image-editor/ImageCanvasQuickEditPanelView.test.tsx src/components/image-editor/ImageCanvasCharacterAnimationPanelView.test.tsx src/components/image-editor/ImageCanvasWorldView.test.tsx src/components/image-editor/useImageCanvasAssetLibrary.test.tsx src/components/image-editor/useImageCanvasGenerationSurface.test.tsx src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx src/components/image-editor/useImageCanvasUploadWorkflow.test.tsx src/components/image-editor/useImageCanvasStageInteractions.test.tsx src/components/image-editor/ImageCanvasEditorAssetsIntegration.test.tsx src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`;浏览器回归:`http://127.0.0.1:10007/editor/canvas` 临时 `XDG_CONFIG_HOME` 下 Chrome headless 打开成功,未登录显示 `账号入口`;关闭后 `画布背景设置` 可打开,点击 `暖灰` 后 viewport 背景为 `rgb(243, 240, 234)` 且 `background-image: none`;点击 `生成工具` 后 `Image Generator` 占位、`生成图片` 对话框和 `AI画布工具栏` 均可见,控制台仅有预期的未登录 `/api/auth/refresh` 401。 - 2026-06-17 前端拆分第四十四执行批次:继续收口 `useImageCanvasAssetLibrary`,扩展 `ImageCanvasAssetLibraryModel`,把素材文件夹坐标命中、素材拖拽目标文件夹置顶判断、素材框选起点 / 移动 / 反向拖拽矩形归一化、框选只命中 uploaded 素材和框选启动规则从 hook 中抽成纯几何模型;asset library hook 保留 DOM 查询、dataset 读取、pointer capture / release、React 状态写入、后端 CRUD 和登录弹窗副作用,避免把 DOM 与服务调用塞进模型。新增模型单测覆盖文件夹边界命中、列表外返回空、文件夹 header 视野外置顶、反向框选归一化、边缘相交命中、忽略 built-in / unknown 素材;只读子代理复核同意优先抽 Rect/Point + folder hit + pinned + marquee selection,不继续拆新 hook。验证命令:`npm run test -- src/components/image-editor/ImageCanvasAssetLibraryModel.test.ts src/components/image-editor/useImageCanvasAssetLibrary.test.tsx`、`npm run test -- src/components/image-editor/ImageCanvasAssetLibraryModel.test.ts src/components/image-editor/useImageCanvasAssetLibrary.test.tsx src/components/image-editor/useImageCanvasAssetPointerDragBridge.test.tsx src/components/image-editor/useImageCanvasAssetCanvasBridge.test.tsx src/components/image-editor/ImageCanvasEditorAssetsIntegration.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx`。 - 2026-06-17 前端拆分第四十四执行批次验证补充:统一编辑器回归命令 `npm run test -- src/components/image-editor/ImageCanvasStageInteractionModel.test.ts src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts src/components/image-editor/ImageCanvasAssetLibraryModel.test.ts src/components/image-editor/ImageCanvasUploadModel.test.ts src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts src/components/image-editor/ImageCanvasGenerationLayerModel.test.ts src/components/image-editor/ImageCanvasAssetRowView.test.tsx src/components/image-editor/ImageCanvasSidebarView.test.tsx src/components/image-editor/ImageCanvasBasicGenerationComposerView.test.tsx src/components/image-editor/ImageCanvasCharacterGenerationComposerView.test.tsx src/components/image-editor/ImageCanvasEditGenerationModalView.test.tsx src/components/image-editor/ImageCanvasEditorShellView.test.tsx src/components/image-editor/ImageCanvasBottomToolbarView.test.tsx src/components/image-editor/ImageCanvasPanelDockView.test.tsx src/components/image-editor/ImageCanvasContextMenusView.test.tsx src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx src/components/image-editor/ImageCanvasIconSpritesheetComposerView.test.tsx src/components/image-editor/ImageCanvasSpecGenerationPanelView.test.tsx src/components/image-editor/ImageCanvasGenerationImageOptionsView.test.tsx src/components/image-editor/ImageCanvasQuickEditPanelView.test.tsx src/components/image-editor/ImageCanvasCharacterAnimationPanelView.test.tsx src/components/image-editor/ImageCanvasWorldView.test.tsx src/components/image-editor/useImageCanvasAssetLibrary.test.tsx src/components/image-editor/useImageCanvasGenerationSurface.test.tsx src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx src/components/image-editor/useImageCanvasUploadWorkflow.test.tsx src/components/image-editor/useImageCanvasStageInteractions.test.tsx src/components/image-editor/useImageCanvasAssetPointerDragBridge.test.tsx src/components/image-editor/useImageCanvasAssetCanvasBridge.test.tsx src/components/image-editor/ImageCanvasEditorAssetsIntegration.test.tsx src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx` 通过 33 个文件 / 219 个测试;`npm run typecheck`、`npm run check:encoding`、`git diff --check` 均通过。浏览器回归:`http://127.0.0.1:10007/editor/canvas` 使用 Playwright CLI headless 打开成功,未登录显示 `账号入口`;关闭登录后 `画布背景设置` 可打开,点击 `暖灰` 后 viewport 背景为 `rgb(243, 240, 234)` 且 `background-image: none`;默认素材侧栏显示 `素材` / `项目素材` / `上传到项目素材`,切换 `打开图层` 后侧栏显示 `图层`,点击 `生成工具` 后 `Image Generator` 占位、`生成图片` 对话框和 `AI画布工具栏` 均可见;网络请求仅有预期未登录 `/api/auth/refresh` 401,其余 editor smoke 相关请求正常。 +- 2026-06-17 前端拆分第四十五执行批次:继续收口 `useImageCanvasUploadWorkflow`,扩展 `ImageCanvasUploadModel`,把生成参考图上传后的 dialog 状态转换从 hook 中抽成纯模型:统一创建上传参考图对象、将 failed 生成面板恢复为 idle、按 `spec-reference` / `character-spec` / `character-reference` / `icon-spec` 写入对应引用字段,并保持不匹配 dialog 不变;upload workflow hook 继续保留文件筛选、Data URL 读取、文件 input、未登录素材上传续传、真实素材创建 API、上传进度和画布落图副作用。新增模型单测覆盖参考图 fallback label、failed 状态清理、规范 / 角色 / 图标参考图写入和角色参考图追加;局部验证命令:`npm run test -- src/components/image-editor/ImageCanvasUploadModel.test.ts src/components/image-editor/useImageCanvasUploadWorkflow.test.tsx`。 +- 2026-06-17 前端拆分第四十五执行批次验证补充:统一编辑器回归命令 `npm run test -- src/components/image-editor/ImageCanvasStageInteractionModel.test.ts src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts src/components/image-editor/ImageCanvasAssetLibraryModel.test.ts src/components/image-editor/ImageCanvasUploadModel.test.ts src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts src/components/image-editor/ImageCanvasGenerationLayerModel.test.ts src/components/image-editor/ImageCanvasAssetRowView.test.tsx src/components/image-editor/ImageCanvasSidebarView.test.tsx src/components/image-editor/ImageCanvasBasicGenerationComposerView.test.tsx src/components/image-editor/ImageCanvasCharacterGenerationComposerView.test.tsx src/components/image-editor/ImageCanvasEditGenerationModalView.test.tsx src/components/image-editor/ImageCanvasEditorShellView.test.tsx src/components/image-editor/ImageCanvasBottomToolbarView.test.tsx src/components/image-editor/ImageCanvasPanelDockView.test.tsx src/components/image-editor/ImageCanvasContextMenusView.test.tsx src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx src/components/image-editor/ImageCanvasIconSpritesheetComposerView.test.tsx src/components/image-editor/ImageCanvasSpecGenerationPanelView.test.tsx src/components/image-editor/ImageCanvasGenerationImageOptionsView.test.tsx src/components/image-editor/ImageCanvasQuickEditPanelView.test.tsx src/components/image-editor/ImageCanvasCharacterAnimationPanelView.test.tsx src/components/image-editor/ImageCanvasWorldView.test.tsx src/components/image-editor/useImageCanvasAssetLibrary.test.tsx src/components/image-editor/useImageCanvasGenerationSurface.test.tsx src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx src/components/image-editor/useImageCanvasUploadWorkflow.test.tsx src/components/image-editor/useImageCanvasStageInteractions.test.tsx src/components/image-editor/useImageCanvasAssetPointerDragBridge.test.tsx src/components/image-editor/useImageCanvasAssetCanvasBridge.test.tsx src/components/image-editor/ImageCanvasEditorAssetsIntegration.test.tsx src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx` 通过 33 个文件 / 223 个测试;`npm run typecheck`、`npm run check:encoding`、`git diff --check` 均通过。浏览器回归:`http://127.0.0.1:10007/editor/canvas` 使用 Playwright CLI headless 打开成功,未登录显示 `账号入口`;关闭登录后 `画布背景设置` 可打开,点击 `暖灰` 后 viewport 背景为 `rgb(243, 240, 234)` 且 `background-image: none`;默认素材侧栏显示 `素材` / `项目素材` / `上传到项目素材`,切换 `打开图层` 后侧栏显示 `图层`,点击 `生成工具` 后 `Image Generator` 占位、`生成图片` 对话框和 `AI画布工具栏` 均可见;网络请求仅有预期未登录 `/api/auth/refresh` 401,其余 editor smoke 相关请求正常。只读子代理复核生成提交链路后建议暂停继续硬拆,除非后续新增生成模式再考虑按“生成成功后的落图提交事务”抽内部 hook。 diff --git a/src/components/image-editor/ImageCanvasUploadModel.test.ts b/src/components/image-editor/ImageCanvasUploadModel.test.ts index 2bdef3245..663d289c6 100644 --- a/src/components/image-editor/ImageCanvasUploadModel.test.ts +++ b/src/components/image-editor/ImageCanvasUploadModel.test.ts @@ -1,14 +1,18 @@ -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import type { CanvasLayer, EditorAssetFolder, + GenerateDialogState, } from './ImageCanvasEditorTypes'; import { + applyGenerationReferenceUpload, createUploadCanvasLayer, + createUploadedGenerationReference, createUploadingAssetPlaceholder, resizeUploadCanvasLayerToImage, resolveUploadFolderId, + setFailedGenerationIdle, } from './ImageCanvasUploadModel'; function createFolder( @@ -43,7 +47,23 @@ function createLayer(overrides: Partial = {}): CanvasLayer { }; } +function createDialog( + overrides: Partial = {}, +): GenerateDialogState { + return { + mode: 'character', + prompt: '', + status: 'failed', + errorMessage: '旧错误', + ...overrides, + }; +} + describe('ImageCanvasUploadModel', () => { + afterEach(() => { + vi.useRealTimers(); + }); + it('resolves upload folder ids with project fallback', () => { const folders = [ createFolder(), @@ -89,6 +109,115 @@ describe('ImageCanvasUploadModel', () => { }); }); + it('creates uploaded generation references with fallback labels', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-06-17T12:00:00.000Z')); + + expect( + createUploadedGenerationReference({ + idPrefix: 'upload-character-reference', + index: 2, + fileName: '', + fallbackLabel: '参考图3', + imageSrc: 'data:image/png;base64,ref', + }), + ).toEqual({ + id: 'upload-character-reference-1781697600000-2', + label: '参考图3', + src: 'data:image/png;base64,ref', + }); + }); + + it('resets failed generation dialogs when reference uploads are applied', () => { + expect( + setFailedGenerationIdle( + createDialog({ status: 'failed', errorMessage: '旧错误' }), + ), + ).toMatchObject({ status: 'idle', errorMessage: undefined }); + expect( + setFailedGenerationIdle(createDialog({ status: 'generating' })), + ).toMatchObject({ status: 'generating', errorMessage: '旧错误' }); + }); + + it('applies uploaded references to matching generation dialogs', () => { + const reference = { + id: 'ref-a', + label: '参考图', + src: 'data:image/png;base64,ref', + }; + + expect( + applyGenerationReferenceUpload({ + dialog: createDialog({ mode: 'spec' }), + target: 'spec-reference', + references: [reference], + }), + ).toMatchObject({ + mode: 'spec', + status: 'idle', + specReference: reference, + errorMessage: undefined, + }); + expect( + applyGenerationReferenceUpload({ + dialog: createDialog({ mode: 'character' }), + target: 'character-spec', + references: [reference], + }), + ).toMatchObject({ + mode: 'character', + status: 'idle', + characterSpecReference: reference, + }); + expect( + applyGenerationReferenceUpload({ + dialog: createDialog({ mode: 'icon' }), + target: 'icon-spec', + references: [reference], + }), + ).toMatchObject({ + mode: 'icon', + status: 'idle', + iconSpecReference: reference, + }); + }); + + it('appends character reference uploads without changing unmatched dialogs', () => { + const previousReference = { + id: 'ref-old', + label: '旧参考图', + src: 'data:image/png;base64,old', + }; + const nextReference = { + id: 'ref-new', + label: '新参考图', + src: 'data:image/png;base64,new', + }; + + expect( + applyGenerationReferenceUpload({ + dialog: createDialog({ + mode: 'character', + characterReferences: [previousReference], + }), + target: 'character-reference', + references: [nextReference], + }), + ).toMatchObject({ + mode: 'character', + status: 'idle', + characterReferences: [previousReference, nextReference], + }); + const iconDialog = createDialog({ mode: 'icon' }); + expect( + applyGenerationReferenceUpload({ + dialog: iconDialog, + target: 'character-reference', + references: [nextReference], + }), + ).toBe(iconDialog); + }); + it('creates upload canvas layers centered on the target canvas point', () => { const layer = createUploadCanvasLayer({ uploadIndex: 1, diff --git a/src/components/image-editor/ImageCanvasUploadModel.ts b/src/components/image-editor/ImageCanvasUploadModel.ts index 84033df9a..5337c0b6e 100644 --- a/src/components/image-editor/ImageCanvasUploadModel.ts +++ b/src/components/image-editor/ImageCanvasUploadModel.ts @@ -1,13 +1,20 @@ import { resolveLayerResolutionSize } from './ImageCanvasEditorModel'; import type { + CharacterReferenceImage, CanvasLayer, CanvasViewport, EditorAsset, EditorAssetFolder, + GenerateDialogState, } from './ImageCanvasEditorTypes'; type CanvasSize = { width: number; height: number }; type CanvasPoint = { x: number; y: number }; +type GenerationReferenceUploadTarget = + | 'character-reference' + | 'character-spec' + | 'icon-spec' + | 'spec-reference'; export const UPLOAD_LAYER_FALLBACK_SIZE = { width: 420, @@ -29,6 +36,85 @@ export function resolveUploadFolderId({ : 'project'; } +export function createUploadedGenerationReference({ + idPrefix, + index, + fileName, + fallbackLabel, + imageSrc, +}: { + idPrefix: string; + index?: number; + fileName?: string; + fallbackLabel: string; + imageSrc: string; +}): CharacterReferenceImage { + return { + id: + typeof index === 'number' + ? `${idPrefix}-${Date.now()}-${index}` + : `${idPrefix}-${Date.now()}`, + label: fileName || fallbackLabel, + src: imageSrc, + }; +} + +export function setFailedGenerationIdle(dialog: GenerateDialogState) { + return { + ...dialog, + status: dialog.status === 'failed' ? 'idle' : dialog.status, + errorMessage: dialog.status === 'failed' ? undefined : dialog.errorMessage, + }; +} + +export function applyGenerationReferenceUpload({ + dialog, + target, + references, +}: { + dialog: GenerateDialogState | null; + target: GenerationReferenceUploadTarget; + references: CharacterReferenceImage[]; +}): GenerateDialogState | null { + const firstReference = references[0]; + if (!firstReference) { + return dialog; + } + if (target === 'spec-reference') { + return dialog?.mode === 'spec' + ? { + ...setFailedGenerationIdle(dialog), + specReference: firstReference, + } + : dialog; + } + if (target === 'character-spec') { + return dialog?.mode === 'character' + ? { + ...setFailedGenerationIdle(dialog), + characterSpecReference: firstReference, + } + : dialog; + } + if (target === 'icon-spec') { + return dialog?.mode === 'icon' + ? { + ...setFailedGenerationIdle(dialog), + iconSpecReference: firstReference, + } + : dialog; + } + return dialog?.mode === 'character' + ? { + ...setFailedGenerationIdle(dialog), + characterReferences: [ + ...(dialog.characterReferences ?? []), + ...references, + ], + } + : dialog; +} + export function createUploadingAssetPlaceholder({ uploadIndex, fileName, diff --git a/src/components/image-editor/useImageCanvasUploadWorkflow.ts b/src/components/image-editor/useImageCanvasUploadWorkflow.ts index 220fa2fa9..2dc5fefba 100644 --- a/src/components/image-editor/useImageCanvasUploadWorkflow.ts +++ b/src/components/image-editor/useImageCanvasUploadWorkflow.ts @@ -20,7 +20,9 @@ import type { } from './ImageCanvasEditorTypes'; import { isImageFile, readImageFileAsDataUrl } from './ImageCanvasFileModel'; import { + applyGenerationReferenceUpload, createUploadCanvasLayer, + createUploadedGenerationReference, createUploadingAssetPlaceholder, resizeUploadCanvasLayerToImage, resolveUploadFolderId, @@ -60,14 +62,6 @@ function isEditorAuthError(error: unknown) { ); } -function setFailedGenerationIdle(dialog: GenerateDialogState) { - return { - ...dialog, - status: dialog.status === 'failed' ? 'idle' : dialog.status, - errorMessage: dialog.status === 'failed' ? undefined : dialog.errorMessage, - }; -} - export function useImageCanvasUploadWorkflow({ canAccessProtectedData, openEditorLoginModal, @@ -113,16 +107,18 @@ export function useImageCanvasUploadWorkflow({ const imageSrc = await readImageFileAsDataUrl(imageFile); setGenerateDialog((currentDialog) => - currentDialog?.mode === 'character' - ? { - ...setFailedGenerationIdle(currentDialog), - characterSpecReference: { - id: `upload-character-spec-${Date.now()}`, - label: imageFile.name || '角色形象规范', - src: imageSrc, - }, - } - : currentDialog, + applyGenerationReferenceUpload({ + dialog: currentDialog, + target: 'character-spec', + references: [ + createUploadedGenerationReference({ + idPrefix: 'upload-character-spec', + fileName: imageFile.name, + fallbackLabel: '角色形象规范', + imageSrc, + }), + ], + }), ); }, [setGenerateDialog], @@ -138,16 +134,18 @@ export function useImageCanvasUploadWorkflow({ const imageSrc = await readImageFileAsDataUrl(imageFile); setGenerateDialog((currentDialog) => - currentDialog?.mode === 'spec' - ? { - ...setFailedGenerationIdle(currentDialog), - specReference: { - id: `upload-spec-reference-${Date.now()}`, - label: imageFile.name || '参考图', - src: imageSrc, - }, - } - : currentDialog, + applyGenerationReferenceUpload({ + dialog: currentDialog, + target: 'spec-reference', + references: [ + createUploadedGenerationReference({ + idPrefix: 'upload-spec-reference', + fileName: imageFile.name, + fallbackLabel: '参考图', + imageSrc, + }), + ], + }), ); }, [setGenerateDialog], @@ -163,21 +161,25 @@ export function useImageCanvasUploadWorkflow({ const references = await Promise.all( imageFiles.map(async (file, index) => ({ - id: `upload-character-reference-${Date.now()}-${index}`, - label: file.name || `参考图${index + 1}`, - src: await readImageFileAsDataUrl(file), + file, + index, + imageSrc: await readImageFileAsDataUrl(file), })), ); setGenerateDialog((currentDialog) => - currentDialog?.mode === 'character' - ? { - ...setFailedGenerationIdle(currentDialog), - characterReferences: [ - ...(currentDialog.characterReferences ?? []), - ...references, - ], - } - : currentDialog, + applyGenerationReferenceUpload({ + dialog: currentDialog, + target: 'character-reference', + references: references.map(({ file, imageSrc, index }) => + createUploadedGenerationReference({ + idPrefix: 'upload-character-reference', + index, + fileName: file.name, + fallbackLabel: `参考图${index + 1}`, + imageSrc, + }), + ), + }), ); }, [setGenerateDialog], @@ -193,16 +195,18 @@ export function useImageCanvasUploadWorkflow({ const imageSrc = await readImageFileAsDataUrl(imageFile); setGenerateDialog((currentDialog) => - currentDialog?.mode === 'icon' - ? { - ...setFailedGenerationIdle(currentDialog), - iconSpecReference: { - id: `upload-icon-spec-${Date.now()}`, - label: imageFile.name || '图标素材规范', - src: imageSrc, - }, - } - : currentDialog, + applyGenerationReferenceUpload({ + dialog: currentDialog, + target: 'icon-spec', + references: [ + createUploadedGenerationReference({ + idPrefix: 'upload-icon-spec', + fileName: imageFile.name, + fallbackLabel: '图标素材规范', + imageSrc, + }), + ], + }), ); }, [setGenerateDialog], From d8bd371c69f519d25a005d916ad767d36031d0ad Mon Sep 17 00:00:00 2001 From: kdletters Date: Wed, 17 Jun 2026 20:36:11 +0800 Subject: [PATCH 02/16] =?UTF-8?q?=E6=8A=BD=E5=87=BA=E7=B4=A0=E6=9D=90?= =?UTF-8?q?=E4=B8=8A=E4=BC=A0=E7=94=9F=E5=91=BD=E5=91=A8=E6=9C=9F=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 扩展 ImageCanvasUploadModel 承载素材上传占位、进度、失败和持久化回写状态迁移 精简 useImageCanvasUploadWorkflow 中的资产与图层状态补丁逻辑 补充上传模型单测覆盖生命周期状态和图层绑定 更新 TRACKING.md 记录第四十六执行批次与验证结果 --- TRACKING.md | 2 + .../ImageCanvasUploadModel.test.ts | 182 ++++++++++++++++++ .../image-editor/ImageCanvasUploadModel.ts | 179 +++++++++++++++++ .../useImageCanvasUploadWorkflow.ts | 132 +++++-------- 4 files changed, 409 insertions(+), 86 deletions(-) diff --git a/TRACKING.md b/TRACKING.md index b3e47ea13..366d27a80 100644 --- a/TRACKING.md +++ b/TRACKING.md @@ -171,3 +171,5 @@ - 2026-06-17 前端拆分第四十四执行批次验证补充:统一编辑器回归命令 `npm run test -- src/components/image-editor/ImageCanvasStageInteractionModel.test.ts src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts src/components/image-editor/ImageCanvasAssetLibraryModel.test.ts src/components/image-editor/ImageCanvasUploadModel.test.ts src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts src/components/image-editor/ImageCanvasGenerationLayerModel.test.ts src/components/image-editor/ImageCanvasAssetRowView.test.tsx src/components/image-editor/ImageCanvasSidebarView.test.tsx src/components/image-editor/ImageCanvasBasicGenerationComposerView.test.tsx src/components/image-editor/ImageCanvasCharacterGenerationComposerView.test.tsx src/components/image-editor/ImageCanvasEditGenerationModalView.test.tsx src/components/image-editor/ImageCanvasEditorShellView.test.tsx src/components/image-editor/ImageCanvasBottomToolbarView.test.tsx src/components/image-editor/ImageCanvasPanelDockView.test.tsx src/components/image-editor/ImageCanvasContextMenusView.test.tsx src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx src/components/image-editor/ImageCanvasIconSpritesheetComposerView.test.tsx src/components/image-editor/ImageCanvasSpecGenerationPanelView.test.tsx src/components/image-editor/ImageCanvasGenerationImageOptionsView.test.tsx src/components/image-editor/ImageCanvasQuickEditPanelView.test.tsx src/components/image-editor/ImageCanvasCharacterAnimationPanelView.test.tsx src/components/image-editor/ImageCanvasWorldView.test.tsx src/components/image-editor/useImageCanvasAssetLibrary.test.tsx src/components/image-editor/useImageCanvasGenerationSurface.test.tsx src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx src/components/image-editor/useImageCanvasUploadWorkflow.test.tsx src/components/image-editor/useImageCanvasStageInteractions.test.tsx src/components/image-editor/useImageCanvasAssetPointerDragBridge.test.tsx src/components/image-editor/useImageCanvasAssetCanvasBridge.test.tsx src/components/image-editor/ImageCanvasEditorAssetsIntegration.test.tsx src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx` 通过 33 个文件 / 219 个测试;`npm run typecheck`、`npm run check:encoding`、`git diff --check` 均通过。浏览器回归:`http://127.0.0.1:10007/editor/canvas` 使用 Playwright CLI headless 打开成功,未登录显示 `账号入口`;关闭登录后 `画布背景设置` 可打开,点击 `暖灰` 后 viewport 背景为 `rgb(243, 240, 234)` 且 `background-image: none`;默认素材侧栏显示 `素材` / `项目素材` / `上传到项目素材`,切换 `打开图层` 后侧栏显示 `图层`,点击 `生成工具` 后 `Image Generator` 占位、`生成图片` 对话框和 `AI画布工具栏` 均可见;网络请求仅有预期未登录 `/api/auth/refresh` 401,其余 editor smoke 相关请求正常。 - 2026-06-17 前端拆分第四十五执行批次:继续收口 `useImageCanvasUploadWorkflow`,扩展 `ImageCanvasUploadModel`,把生成参考图上传后的 dialog 状态转换从 hook 中抽成纯模型:统一创建上传参考图对象、将 failed 生成面板恢复为 idle、按 `spec-reference` / `character-spec` / `character-reference` / `icon-spec` 写入对应引用字段,并保持不匹配 dialog 不变;upload workflow hook 继续保留文件筛选、Data URL 读取、文件 input、未登录素材上传续传、真实素材创建 API、上传进度和画布落图副作用。新增模型单测覆盖参考图 fallback label、failed 状态清理、规范 / 角色 / 图标参考图写入和角色参考图追加;局部验证命令:`npm run test -- src/components/image-editor/ImageCanvasUploadModel.test.ts src/components/image-editor/useImageCanvasUploadWorkflow.test.tsx`。 - 2026-06-17 前端拆分第四十五执行批次验证补充:统一编辑器回归命令 `npm run test -- src/components/image-editor/ImageCanvasStageInteractionModel.test.ts src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts src/components/image-editor/ImageCanvasAssetLibraryModel.test.ts src/components/image-editor/ImageCanvasUploadModel.test.ts src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts src/components/image-editor/ImageCanvasGenerationLayerModel.test.ts src/components/image-editor/ImageCanvasAssetRowView.test.tsx src/components/image-editor/ImageCanvasSidebarView.test.tsx src/components/image-editor/ImageCanvasBasicGenerationComposerView.test.tsx src/components/image-editor/ImageCanvasCharacterGenerationComposerView.test.tsx src/components/image-editor/ImageCanvasEditGenerationModalView.test.tsx src/components/image-editor/ImageCanvasEditorShellView.test.tsx src/components/image-editor/ImageCanvasBottomToolbarView.test.tsx src/components/image-editor/ImageCanvasPanelDockView.test.tsx src/components/image-editor/ImageCanvasContextMenusView.test.tsx src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx src/components/image-editor/ImageCanvasIconSpritesheetComposerView.test.tsx src/components/image-editor/ImageCanvasSpecGenerationPanelView.test.tsx src/components/image-editor/ImageCanvasGenerationImageOptionsView.test.tsx src/components/image-editor/ImageCanvasQuickEditPanelView.test.tsx src/components/image-editor/ImageCanvasCharacterAnimationPanelView.test.tsx src/components/image-editor/ImageCanvasWorldView.test.tsx src/components/image-editor/useImageCanvasAssetLibrary.test.tsx src/components/image-editor/useImageCanvasGenerationSurface.test.tsx src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx src/components/image-editor/useImageCanvasUploadWorkflow.test.tsx src/components/image-editor/useImageCanvasStageInteractions.test.tsx src/components/image-editor/useImageCanvasAssetPointerDragBridge.test.tsx src/components/image-editor/useImageCanvasAssetCanvasBridge.test.tsx src/components/image-editor/ImageCanvasEditorAssetsIntegration.test.tsx src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx` 通过 33 个文件 / 223 个测试;`npm run typecheck`、`npm run check:encoding`、`git diff --check` 均通过。浏览器回归:`http://127.0.0.1:10007/editor/canvas` 使用 Playwright CLI headless 打开成功,未登录显示 `账号入口`;关闭登录后 `画布背景设置` 可打开,点击 `暖灰` 后 viewport 背景为 `rgb(243, 240, 234)` 且 `background-image: none`;默认素材侧栏显示 `素材` / `项目素材` / `上传到项目素材`,切换 `打开图层` 后侧栏显示 `图层`,点击 `生成工具` 后 `Image Generator` 占位、`生成图片` 对话框和 `AI画布工具栏` 均可见;网络请求仅有预期未登录 `/api/auth/refresh` 401,其余 editor smoke 相关请求正常。只读子代理复核生成提交链路后建议暂停继续硬拆,除非后续新增生成模式再考虑按“生成成功后的落图提交事务”抽内部 hook。 +- 2026-06-17 前端拆分第四十六执行批次:继续收口 `useImageCanvasUploadWorkflow`,扩展 `ImageCanvasUploadModel`,把素材上传生命周期中的文件夹展开、读取成功 / 失败、上传中、持久化资产替换、上传失败、图片尺寸回写和画布图层绑定持久化素材抽成纯模型;upload workflow hook 继续保留文件筛选、Data URL 读取、登录弹窗、真实素材创建 API、Image 尺寸读取、画布落图和 React setter 编排。新增模型单测覆盖上传占位卡各阶段状态、持久化后清理上传态、尺寸回写和图层绑定;局部验证命令:`npm run test -- src/components/image-editor/ImageCanvasUploadModel.test.ts src/components/image-editor/useImageCanvasUploadWorkflow.test.tsx`。 +- 2026-06-17 前端拆分第四十六执行批次验证补充:统一编辑器回归命令 `npm run test -- src/components/image-editor/ImageCanvasStageInteractionModel.test.ts src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts src/components/image-editor/ImageCanvasAssetLibraryModel.test.ts src/components/image-editor/ImageCanvasUploadModel.test.ts src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts src/components/image-editor/ImageCanvasGenerationLayerModel.test.ts src/components/image-editor/ImageCanvasAssetRowView.test.tsx src/components/image-editor/ImageCanvasSidebarView.test.tsx src/components/image-editor/ImageCanvasBasicGenerationComposerView.test.tsx src/components/image-editor/ImageCanvasCharacterGenerationComposerView.test.tsx src/components/image-editor/ImageCanvasEditGenerationModalView.test.tsx src/components/image-editor/ImageCanvasEditorShellView.test.tsx src/components/image-editor/ImageCanvasBottomToolbarView.test.tsx src/components/image-editor/ImageCanvasPanelDockView.test.tsx src/components/image-editor/ImageCanvasContextMenusView.test.tsx src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx src/components/image-editor/ImageCanvasIconSpritesheetComposerView.test.tsx src/components/image-editor/ImageCanvasSpecGenerationPanelView.test.tsx src/components/image-editor/ImageCanvasGenerationImageOptionsView.test.tsx src/components/image-editor/ImageCanvasQuickEditPanelView.test.tsx src/components/image-editor/ImageCanvasCharacterAnimationPanelView.test.tsx src/components/image-editor/ImageCanvasWorldView.test.tsx src/components/image-editor/useImageCanvasAssetLibrary.test.tsx src/components/image-editor/useImageCanvasGenerationSurface.test.tsx src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx src/components/image-editor/useImageCanvasUploadWorkflow.test.tsx src/components/image-editor/useImageCanvasStageInteractions.test.tsx src/components/image-editor/useImageCanvasAssetPointerDragBridge.test.tsx src/components/image-editor/useImageCanvasAssetCanvasBridge.test.tsx src/components/image-editor/ImageCanvasEditorAssetsIntegration.test.tsx src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx` 通过 33 个文件 / 228 个测试;`npm run typecheck`、`npm run check:encoding`、`git diff --check` 均通过。浏览器回归:`http://127.0.0.1:10007/editor/canvas` 使用系统 Chrome headless 打开成功,未登录显示 `账号入口`;默认素材侧栏显示 `素材` / `项目素材`;关闭登录后 `画布背景设置` 可打开,点击 `暖灰` 后 viewport 背景为 `rgb(243, 240, 234)` 且 `background-image: none`;切换 `打开图层` 后侧栏显示 `图层`;点击 `生成工具` 后 `Image Generator` 占位、生成表单控件和 `AI画布工具栏` 均可见;除预期未登录 `/api/auth/refresh` 401 外无额外 API 失败或控制台错误。 diff --git a/src/components/image-editor/ImageCanvasUploadModel.test.ts b/src/components/image-editor/ImageCanvasUploadModel.test.ts index 663d289c6..562b7714b 100644 --- a/src/components/image-editor/ImageCanvasUploadModel.test.ts +++ b/src/components/image-editor/ImageCanvasUploadModel.test.ts @@ -7,9 +7,17 @@ import type { } from './ImageCanvasEditorTypes'; import { applyGenerationReferenceUpload, + applyMeasuredUploadAssetSize, + applyPersistedUploadAsset, + applyUploadAssetCreateFailure, + applyUploadAssetCreatePending, + applyUploadAssetReadFailure, + applyUploadAssetReadSuccess, + bindUploadLayerToPersistedAsset, createUploadCanvasLayer, createUploadedGenerationReference, createUploadingAssetPlaceholder, + expandUploadFolder, resizeUploadCanvasLayerToImage, resolveUploadFolderId, setFailedGenerationIdle, @@ -47,6 +55,19 @@ function createLayer(overrides: Partial = {}): CanvasLayer { }; } +function createUploadingAsset( + overrides: Partial> = {}, +) { + return { + ...createUploadingAssetPlaceholder({ + uploadIndex: 1, + fileName: '上传图片.png', + folderId: 'project', + }), + ...overrides, + }; +} + function createDialog( overrides: Partial = {}, ): GenerateDialogState { @@ -109,6 +130,167 @@ describe('ImageCanvasUploadModel', () => { }); }); + it('opens the target upload folder without changing other folders', () => { + expect( + expandUploadFolder({ + folders: [ + createFolder({ id: 'project', collapsed: true }), + createFolder({ id: 'characters', label: '角色素材', collapsed: true }), + ], + folderId: 'characters', + }), + ).toEqual([ + createFolder({ id: 'project', collapsed: true }), + createFolder({ id: 'characters', label: '角色素材', collapsed: false }), + ]); + }); + + it('applies upload asset lifecycle patches', () => { + const asset = createUploadingAsset(); + const otherAsset = createUploadingAsset({ + id: 'upload-other', + label: '其他图片.png', + }); + + expect( + applyUploadAssetReadSuccess({ + assets: [asset, otherAsset], + uploadAssetId: asset.id, + imageSrc: 'data:image/png;base64,read', + }), + ).toMatchObject([ + { + id: 'upload-1', + src: 'data:image/png;base64,read', + uploadProgress: 42, + uploadMessage: '读取图片', + }, + { id: 'upload-other', label: '其他图片.png' }, + ]); + + expect( + applyUploadAssetCreatePending({ + assets: [asset], + uploadAssetId: asset.id, + }), + ).toMatchObject([ + { + id: 'upload-1', + uploadStatus: 'uploading', + uploadProgress: 68, + uploadMessage: '上传中', + }, + ]); + + expect( + applyUploadAssetReadFailure({ + assets: [asset], + uploadAssetId: asset.id, + }), + ).toMatchObject([ + { + id: 'upload-1', + uploadStatus: 'failed', + uploadProgress: 100, + uploadMessage: '读取失败', + }, + ]); + + expect( + applyUploadAssetCreateFailure({ + assets: [asset], + uploadAssetId: asset.id, + message: '请先登录', + }), + ).toMatchObject([ + { + id: 'upload-1', + uploadStatus: 'failed', + uploadProgress: 100, + uploadMessage: '请先登录', + }, + ]); + }); + + it('replaces upload placeholders with persisted assets and clears upload state', () => { + expect( + applyPersistedUploadAsset({ + assets: [createUploadingAsset()], + uploadAssetId: 'upload-1', + persistedAsset: { + assetId: 'asset-1', + folderId: 'characters', + label: '角色.png', + imageSrc: 'data:image/png;base64,persisted', + width: 1024, + height: 768, + objectKey: 'object-key', + assetObjectId: 'asset-object', + }, + }), + ).toEqual([ + { + id: 'asset-1', + label: '角色.png', + src: 'data:image/png;base64,persisted', + width: 1024, + height: 768, + folderId: 'characters', + sourceKind: 'uploaded', + sourceType: 'uploaded', + persisted: true, + objectKey: 'object-key', + assetObjectId: 'asset-object', + uploadStatus: undefined, + uploadProgress: undefined, + uploadMessage: undefined, + }, + ]); + }); + + it('updates measured asset size after the browser loads the uploaded image', () => { + expect( + applyMeasuredUploadAssetSize({ + assets: [createUploadingAsset()], + uploadAssetId: 'upload-1', + originalWidth: 1600, + originalHeight: 900, + }), + ).toMatchObject([ + { + id: 'upload-1', + width: 1600, + height: 900, + }, + ]); + }); + + it('binds uploaded canvas layers to persisted asset metadata', () => { + expect( + bindUploadLayerToPersistedAsset({ + layers: [createLayer({ objectKey: 'local-object' })], + layerId: 'layer-upload-1', + persistedAsset: { + assetId: 'asset-1', + folderId: 'project', + label: '上传图片.png', + imageSrc: 'data:image/png;base64,persisted', + width: 420, + height: 315, + objectKey: 'object-key', + assetObjectId: 'asset-object', + }, + }), + ).toMatchObject([ + { + id: 'layer-upload-1', + sourceAssetId: 'asset-1', + objectKey: 'object-key', + assetObjectId: 'asset-object', + }, + ]); + }); + it('creates uploaded generation references with fallback labels', () => { vi.useFakeTimers(); vi.setSystemTime(new Date('2026-06-17T12:00:00.000Z')); diff --git a/src/components/image-editor/ImageCanvasUploadModel.ts b/src/components/image-editor/ImageCanvasUploadModel.ts index 5337c0b6e..7d5a10262 100644 --- a/src/components/image-editor/ImageCanvasUploadModel.ts +++ b/src/components/image-editor/ImageCanvasUploadModel.ts @@ -10,6 +10,16 @@ import type { type CanvasSize = { width: number; height: number }; type CanvasPoint = { x: number; y: number }; +type PersistedUploadAsset = { + assetId: string; + folderId: string; + label: string; + imageSrc: string; + width: number; + height: number; + objectKey?: string | null; + assetObjectId?: string | null; +}; type GenerationReferenceUploadTarget = | 'character-reference' | 'character-spec' @@ -140,6 +150,175 @@ export function createUploadingAssetPlaceholder({ }; } +export function expandUploadFolder({ + folders, + folderId, +}: { + folders: EditorAssetFolder[]; + folderId: string; +}) { + return folders.map((folder) => + folder.id === folderId + ? { + ...folder, + collapsed: false, + } + : folder, + ); +} + +export function applyUploadAssetReadSuccess({ + assets, + uploadAssetId, + imageSrc, +}: { + assets: EditorAsset[]; + uploadAssetId: string; + imageSrc: string; +}) { + return assets.map((asset) => + asset.id === uploadAssetId + ? { + ...asset, + src: imageSrc, + uploadProgress: 42, + uploadMessage: '读取图片', + } + : asset, + ); +} + +export function applyUploadAssetReadFailure({ + assets, + uploadAssetId, +}: { + assets: EditorAsset[]; + uploadAssetId: string; +}) { + return assets.map((asset) => + asset.id === uploadAssetId + ? { + ...asset, + uploadStatus: 'failed' as const, + uploadProgress: 100, + uploadMessage: '读取失败', + } + : asset, + ); +} + +export function applyUploadAssetCreatePending({ + assets, + uploadAssetId, +}: { + assets: EditorAsset[]; + uploadAssetId: string; +}) { + return assets.map((asset) => + asset.id === uploadAssetId + ? { + ...asset, + uploadProgress: 68, + uploadMessage: '上传中', + } + : asset, + ); +} + +export function applyPersistedUploadAsset({ + assets, + uploadAssetId, + persistedAsset, +}: { + assets: EditorAsset[]; + uploadAssetId: string; + persistedAsset: PersistedUploadAsset; +}) { + return assets.map((asset) => + asset.id === uploadAssetId + ? { + ...asset, + id: persistedAsset.assetId, + folderId: persistedAsset.folderId, + label: persistedAsset.label, + src: persistedAsset.imageSrc, + width: persistedAsset.width, + height: persistedAsset.height, + objectKey: persistedAsset.objectKey ?? undefined, + assetObjectId: persistedAsset.assetObjectId ?? undefined, + persisted: true, + uploadStatus: undefined, + uploadProgress: undefined, + uploadMessage: undefined, + } + : asset, + ); +} + +export function applyUploadAssetCreateFailure({ + assets, + uploadAssetId, + message, +}: { + assets: EditorAsset[]; + uploadAssetId: string; + message: string; +}) { + return assets.map((asset) => + asset.id === uploadAssetId + ? { + ...asset, + uploadStatus: 'failed' as const, + uploadProgress: 100, + uploadMessage: message, + } + : asset, + ); +} + +export function applyMeasuredUploadAssetSize({ + assets, + uploadAssetId, + originalWidth, + originalHeight, +}: { + assets: EditorAsset[]; + uploadAssetId: string; + originalWidth: number; + originalHeight: number; +}) { + return assets.map((asset) => + asset.id === uploadAssetId + ? { + ...asset, + width: originalWidth, + height: originalHeight, + } + : asset, + ); +} + +export function bindUploadLayerToPersistedAsset({ + layers, + layerId, + persistedAsset, +}: { + layers: CanvasLayer[]; + layerId: string; + persistedAsset: PersistedUploadAsset; +}) { + return layers.map((layer) => + layer.id === layerId + ? { + ...layer, + sourceAssetId: persistedAsset.assetId, + objectKey: persistedAsset.objectKey ?? layer.objectKey, + assetObjectId: persistedAsset.assetObjectId ?? layer.assetObjectId, + } + : layer, + ); +} + export function normalizeUploadScreenPoint({ canvasPoint, canvasSize, diff --git a/src/components/image-editor/useImageCanvasUploadWorkflow.ts b/src/components/image-editor/useImageCanvasUploadWorkflow.ts index 2dc5fefba..61168d7cf 100644 --- a/src/components/image-editor/useImageCanvasUploadWorkflow.ts +++ b/src/components/image-editor/useImageCanvasUploadWorkflow.ts @@ -21,9 +21,17 @@ import type { import { isImageFile, readImageFileAsDataUrl } from './ImageCanvasFileModel'; import { applyGenerationReferenceUpload, + applyMeasuredUploadAssetSize, + applyPersistedUploadAsset, + applyUploadAssetCreateFailure, + applyUploadAssetCreatePending, + applyUploadAssetReadFailure, + applyUploadAssetReadSuccess, + bindUploadLayerToPersistedAsset, createUploadCanvasLayer, createUploadedGenerationReference, createUploadingAssetPlaceholder, + expandUploadFolder, resizeUploadCanvasLayerToImage, resolveUploadFolderId, } from './ImageCanvasUploadModel'; @@ -232,43 +240,28 @@ export function useImageCanvasUploadWorkflow({ }); setAssets((currentAssets) => [...currentAssets, uploadedAsset]); setAssetFolders((currentFolders) => - currentFolders.map((folder) => - folder.id === uploadFolderId - ? { - ...folder, - collapsed: false, - } - : folder, - ), + expandUploadFolder({ + folders: currentFolders, + folderId: uploadFolderId, + }), ); let imageSrc = ''; try { imageSrc = await readImageFileAsDataUrl(file); setAssets((currentAssets) => - currentAssets.map((asset) => - asset.id === uploadedAsset.id - ? { - ...asset, - src: imageSrc, - uploadProgress: 42, - uploadMessage: '读取图片', - } - : asset, - ), + applyUploadAssetReadSuccess({ + assets: currentAssets, + uploadAssetId: uploadedAsset.id, + imageSrc, + }), ); } catch { setAssets((currentAssets) => - currentAssets.map((asset) => - asset.id === uploadedAsset.id - ? { - ...asset, - uploadStatus: 'failed', - uploadProgress: 100, - uploadMessage: '读取失败', - } - : asset, - ), + applyUploadAssetReadFailure({ + assets: currentAssets, + uploadAssetId: uploadedAsset.id, + }), ); return; } @@ -288,15 +281,10 @@ export function useImageCanvasUploadWorkflow({ } setAssets((currentAssets) => - currentAssets.map((asset) => - asset.id === uploadedAsset.id - ? { - ...asset, - uploadProgress: 68, - uploadMessage: '上传中', - } - : asset, - ), + applyUploadAssetCreatePending({ + assets: currentAssets, + uploadAssetId: uploadedAsset.id, + }), ); createEditorAsset({ folderId: uploadFolderId, @@ -308,39 +296,19 @@ export function useImageCanvasUploadWorkflow({ }) .then((asset) => { setAssets((currentAssets) => - currentAssets.map((currentAsset) => - currentAsset.id === uploadedAsset.id - ? { - ...currentAsset, - id: asset.assetId, - folderId: asset.folderId, - label: asset.label, - src: asset.imageSrc, - width: asset.width, - height: asset.height, - objectKey: asset.objectKey ?? undefined, - assetObjectId: asset.assetObjectId ?? undefined, - persisted: true, - uploadStatus: undefined, - uploadProgress: undefined, - uploadMessage: undefined, - } - : currentAsset, - ), + applyPersistedUploadAsset({ + assets: currentAssets, + uploadAssetId: uploadedAsset.id, + persistedAsset: asset, + }), ); if (options.addToCanvas) { setLayers((currentLayers) => - currentLayers.map((currentLayer) => - currentLayer.id === nextLayer.id - ? { - ...currentLayer, - sourceAssetId: asset.assetId, - objectKey: asset.objectKey ?? currentLayer.objectKey, - assetObjectId: - asset.assetObjectId ?? currentLayer.assetObjectId, - } - : currentLayer, - ), + bindUploadLayerToPersistedAsset({ + layers: currentLayers, + layerId: nextLayer.id, + persistedAsset: asset, + }), ); } }) @@ -350,16 +318,11 @@ export function useImageCanvasUploadWorkflow({ openEditorLoginModal(); } setAssets((currentAssets) => - currentAssets.map((asset) => - asset.id === uploadedAsset.id - ? { - ...asset, - uploadStatus: 'failed', - uploadProgress: 100, - uploadMessage: isAuthError ? '请先登录' : '上传失败', - } - : asset, - ), + applyUploadAssetCreateFailure({ + assets: currentAssets, + uploadAssetId: uploadedAsset.id, + message: isAuthError ? '请先登录' : '上传失败', + }), ); }); @@ -387,15 +350,12 @@ export function useImageCanvasUploadWorkflow({ ); } setAssets((currentAssets) => - currentAssets.map((asset) => - asset.id === uploadedAsset.id - ? { - ...asset, - width: originalWidth, - height: originalHeight, - } - : asset, - ), + applyMeasuredUploadAssetSize({ + assets: currentAssets, + uploadAssetId: uploadedAsset.id, + originalWidth, + originalHeight, + }), ); }; uploadedImage.src = imageSrc; From d1cd30069548869c5cfa5bcb953737569906af64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=AB=98=E7=89=A9?= <253518756@qq.com> Date: Wed, 17 Jun 2026 17:46:13 +0800 Subject: [PATCH 03/16] =?UTF-8?q?=E9=9A=90=E8=97=8F=E5=9B=BE=E7=89=87?= =?UTF-8?q?=E4=BF=A1=E6=81=AF=E4=B8=AD=E7=9A=84=E4=B8=8A=E6=B8=B8=E6=A0=87?= =?UTF-8?q?=E8=AF=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 图片信息弹窗标题固定为图片信息,不再拼接图片名称 图片信息弹窗移除 Provider 展示行 Task 展示收口为任务标识末尾数字,避免暴露 Provider 字符串 补充图片信息脱敏展示回归测试 更新画板角色形象生成文档中的图片信息展示口径 --- ...编辑器】画板角色形象生成入口设计-2026-06-15.md | 7 +++++++ .../ImageCanvasMetadataModalView.test.tsx | 14 +++++++++----- .../image-editor/ImageCanvasMetadataModalView.tsx | 11 +++++++---- 3 files changed, 23 insertions(+), 9 deletions(-) diff --git a/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md b/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md index 41360ec04..b67d32183 100644 --- a/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md +++ b/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md @@ -57,6 +57,13 @@ - 提交生成规范时,若存在参考图,前端必须把参考图作为 `referenceImageSrcs[0]` 提交到 `/api/editor/images/generations`,并在生图提示词开头自动追加“参考图生成规范”语义:要求模型参考图 1 的构图、风格、材质、色彩、形状语言和视觉层级生成规范图,但不要复制参考图中的文字、水印或无关背景。 - 生成结果的信息快照必须记录该参考图,标题为 `参考图`,便于后续在图片信息面板回看生成输入。 +## 图片信息展示 + +- 图片信息弹窗标题固定为 `图片信息`,不拼接图片 / 图层名称。 +- 图片信息弹窗不展示 Provider 行,也不在 Task 中暴露 Provider 字符串。 +- Task 只展示任务标识里的数字部分;若任务标识没有数字,则显示 `-`。 +- 图片 / 图层名称只用于画布内部选择、图层列表和素材管理,不进入图片信息弹窗。 + ## 可访问性与状态 - 点选状态下画布显示状态提示 `请选择画布中的图片作为角色形象规范,按 Esc 退出`。 diff --git a/src/components/image-editor/ImageCanvasMetadataModalView.test.tsx b/src/components/image-editor/ImageCanvasMetadataModalView.test.tsx index ba2e0efa5..2263bdf0d 100644 --- a/src/components/image-editor/ImageCanvasMetadataModalView.test.tsx +++ b/src/components/image-editor/ImageCanvasMetadataModalView.test.tsx @@ -31,7 +31,7 @@ describe('ImageCanvasMetadataModalView', () => { layer={createLayer({ model: 'gpt-image-2', provider: 'VectorEngine', - taskId: 'task-123', + taskId: 'gpt-image-2-task-123', objectKey: 'generated/object.png', generationInputs: { fields: [{ title: '生成提示词', value: '清爽游戏按钮' }], @@ -48,8 +48,9 @@ describe('ImageCanvasMetadataModalView', () => { />, ); - const dialog = screen.getByRole('dialog', { name: '生成主图图片信息' }); + const dialog = screen.getByRole('dialog', { name: '图片信息' }); + expect(within(dialog).queryByText('生成主图')).toBeNull(); expect(within(dialog).getByText('生成图片')).toBeTruthy(); expect(within(dialog).getByText('生成提示词')).toBeTruthy(); expect(within(dialog).getByText('清爽游戏按钮')).toBeTruthy(); @@ -57,8 +58,10 @@ describe('ImageCanvasMetadataModalView', () => { expect(within(dialog).getByText('角色立绘')).toBeTruthy(); expect(within(dialog).getByText('gpt-image-2')).toBeTruthy(); expect(within(dialog).getByText('1024 x 768 px')).toBeTruthy(); - expect(within(dialog).getByText('VectorEngine')).toBeTruthy(); - expect(within(dialog).getByText('task-123')).toBeTruthy(); + expect(within(dialog).queryByText('Provider')).toBeNull(); + expect(within(dialog).queryByText(/VectorEngine/u)).toBeNull(); + expect(within(dialog).queryByText(/task-/u)).toBeNull(); + expect(within(dialog).getByText('123')).toBeTruthy(); expect(within(dialog).getByText('generated/object.png')).toBeTruthy(); }); @@ -80,8 +83,9 @@ describe('ImageCanvasMetadataModalView', () => { />, ); - const dialog = screen.getByRole('dialog', { name: '上传素材图片信息' }); + const dialog = screen.getByRole('dialog', { name: '图片信息' }); + expect(within(dialog).queryByText('上传素材')).toBeNull(); expect(within(dialog).getByText('上传图片')).toBeTruthy(); expect(within(dialog).getAllByText('-').length).toBeGreaterThanOrEqual(3); expect(within(dialog).getByText('asset-object-1')).toBeTruthy(); diff --git a/src/components/image-editor/ImageCanvasMetadataModalView.tsx b/src/components/image-editor/ImageCanvasMetadataModalView.tsx index 6751cd6a5..628ea40b3 100644 --- a/src/components/image-editor/ImageCanvasMetadataModalView.tsx +++ b/src/components/image-editor/ImageCanvasMetadataModalView.tsx @@ -7,6 +7,11 @@ export type ImageCanvasMetadataModalViewProps = { onClose: () => void; }; +function formatTaskIdForDisplay(taskId?: string | null) { + const numericParts = taskId?.match(/\d+/gu); + return numericParts?.length ? numericParts[numericParts.length - 1] : '-'; +} + export function ImageCanvasMetadataModalView({ layer, onClose, @@ -14,7 +19,7 @@ export function ImageCanvasMetadataModalView({ return ( {layer.originalWidth} x {layer.originalHeight} px -
Provider
-
{layer.provider ?? '-'}
Task
-
{layer.taskId ?? '-'}
+
{formatTaskIdForDisplay(layer.taskId)}
Object
{layer.objectKey ?? layer.assetObjectId ?? '-'}
From b2fd5574dbc07e11253e1c127c30427e874b216b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=AB=98=E7=89=A9?= <253518756@qq.com> Date: Wed, 17 Jun 2026 20:47:27 +0800 Subject: [PATCH 04/16] =?UTF-8?q?=E6=8E=A5=E5=85=A5=E7=94=BB=E6=9D=BF?= =?UTF-8?q?=E7=94=9F=E6=88=90=E8=A7=86=E9=A2=91=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增画板底部生成视频入口、Lovart 风格面板、视频图层渲染与元数据展示。 接入 /api/editor/videos/generations 契约与后端 Ark/VectorEngine 视频任务链路。 统一编辑器生成类泥点配置,并补充 UI 设计图、参考图与生成面板结构测试。 更新编辑器技术方案、生成类面板方案和 Hermes 共享决策/踩坑记录。 --- .hermes/shared-memory/decision-log.md | 20 +- .hermes/shared-memory/pitfalls.md | 8 + ...架构】图片画布编辑器MVP接入方案-2026-06-11.md | 8 +- ...】生成类面板Lovart统一改造方案-2026-06-17.md | 101 +++ ...辑器】画板UI设计图生成入口设计-2026-06-17.md | 53 ++ ...辑器】画板图标素材生成入口设计-2026-06-15.md | 16 +- ...辑器】画板角色形象生成入口设计-2026-06-15.md | 34 +- .../src/character_animation_assets.rs | 415 ++++++++++- .../src/editor_generation_config.rs | 72 ++ .../crates/api-server/src/editor_project.rs | 327 +++++++-- server-rs/crates/api-server/src/main.rs | 1 + .../api-server/src/modules/play_flow.rs | 9 +- .../api-server/src/openai_image_generation.rs | 92 ++- server-rs/crates/platform-image/src/lib.rs | 7 +- .../src/vector_engine/client.rs | 143 +++- .../platform-image/src/vector_engine/mod.rs | 5 +- .../src/vector_engine/payload.rs | 39 + .../src/vector_engine/request.rs | 81 ++- .../platform-image/src/vector_engine/tests.rs | 60 +- .../platform-image/tests/vector_engine.rs | 127 +++- .../crates/shared-contracts/src/assets.rs | 79 +++ .../image-editor/ImageCanvasEditorModel.ts | 6 +- .../image-editor/ImageCanvasEditorTypes.ts | 35 +- ...ImageCanvasGenerationComposerView.test.tsx | 296 ++++++++ .../ImageCanvasGenerationLayerModel.ts | 61 ++ .../ImageCanvasGenerationModel.ts | 118 +++- ...mageCanvasGenerationPlacementModel.test.ts | 113 +++ .../ImageCanvasGenerationPlacementModel.ts | 257 +++++++ .../ImageCanvasMetadataModalView.test.tsx | 36 + .../ImageCanvasMetadataModalView.tsx | 6 +- .../useImageCanvasGenerationWorkflow.test.tsx | 101 ++- .../useImageCanvasKeyboardShortcuts.test.tsx | 9 + .../useImageCanvasKeyboardShortcuts.ts | 22 +- .../useImageCanvasStageInteractions.test.tsx | 8 + .../useImageCanvasStageInteractions.ts | 37 + .../useImageCanvasUploadWorkflow.test.tsx | 58 +- src/index.css | 665 +++++++++++++++--- .../image-editor/editorProjectClient.test.ts | 53 ++ .../image-editor/editorProjectClient.ts | 50 +- 39 files changed, 3390 insertions(+), 238 deletions(-) create mode 100644 docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md create mode 100644 docs/【编辑器】画板UI设计图生成入口设计-2026-06-17.md create mode 100644 server-rs/crates/api-server/src/editor_generation_config.rs create mode 100644 src/components/image-editor/ImageCanvasGenerationComposerView.test.tsx create mode 100644 src/components/image-editor/ImageCanvasGenerationPlacementModel.test.ts create mode 100644 src/components/image-editor/ImageCanvasGenerationPlacementModel.ts diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index 5ccebe0eb..a34f90aff 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -2263,13 +2263,13 @@ - 验证方式:新增或增删素材描述项时,面板宽度应随项数变化;图标素材规范入口应呈现参考卡视觉而非纯文本按钮;移动端下仍应固定在底部锚定,不出现内部滚动条。 - 关联文档:`docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md`、`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 -## 2026-06-16 图片画布图标素材采用 nanobanana2 spritesheet + 后端连通域拆分 +## 2026-06-16 图片画布图标素材与角色生成支持双图片模型 -- 背景:图片画布需要一次生成多枚 UI 图标素材,并保证生成后能按用户输入顺序命名、拆成独立透明素材铺回画布。 -- 决策:底部 `生成图标素材` 入口创建一叠空白图标占位和独立面板;图标规范参考图只允许绑定 `assetKind="icon-spec"`。前端提交 `/api/editor/icon-spritesheets/generations`,后端固定使用 VectorEngine `gemini-3.1-flash-image-preview`,`<=25` 个描述用 `512x512`,`>25` 个描述用 `1024x1024`,先生成绿幕 1:1 spritesheet,再由 `platform-image` 绿幕去背并按 8 邻域连通域从上到下、从左到右拆分。成品图标图层写入 `assetKind="icon"`。 -- 影响范围:`src/components/image-editor/ImageCanvasEditorView.tsx`、`src/services/image-editor/editorProjectClient.ts`、`server-rs/crates/api-server/src/editor_project.rs`、`server-rs/crates/platform-image/src/generated_asset_sheets/sheet.rs`、图片画布技术方案。 -- 验证方式:`npm run test -- src/components/image-editor/ImageCanvasEditorView.test.tsx src/services/image-editor/editorProjectClient.test.ts`、`cargo test --manifest-path server-rs/Cargo.toml -p platform-image generated_asset_sheets::sheet::tests -- --nocapture`、`cargo test --manifest-path server-rs/Cargo.toml -p api-server editor_project -- --nocapture`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。 -- 关联文档:`docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md`、`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 +- 背景:图片画布需要一次生成多枚 UI 图标素材,并保证生成后能按用户输入顺序命名、拆成独立透明素材铺回画布;角色形象生成也需要和图标素材共用同一套图片模型选择、比例和大小口径。 +- 决策:底部 `生成图标素材` 入口创建一叠空白图标占位和独立面板;图标规范参考图只允许绑定 `assetKind="icon-spec"`。`生成角色形象` 与 `生成图标素材` 均支持 VectorEngine `gemini-3.1-flash-image-preview`(UI 显示 `nanobanana2`)和 `gpt-image-2`,默认 `nanobanana2`,用户在两类面板中切换过模型后下一次打开继续沿用上次模型。前端提交 `model`、`aspectRatio`、`imageSize`;后端不再按图标数量分 `512x512/1024x1024`,而是按模型归一尺寸:`nanobanana2` 走 `/v1beta/models/{model}:generateContent`,把参考图写成 `inline_data`,并在 `generationConfig.imageConfig` 写入比例和大小,`0.5K` 传 `"512"`;`gpt-image-2` 无参考图走 generations,有参考图走 edits,按文档支持的 `size` 字符串映射。图标素材仍先生成绿幕 spritesheet,再由 `platform-image` 绿幕去背并按 8 邻域连通域从上到下、从左到右拆分。成品图标图层写入 `assetKind="icon"`,角色图层写入 `assetKind="character"`。 +- 影响范围:`src/components/image-editor/ImageCanvasEditorView.tsx`、`src/components/image-editor/useImageCanvasGenerationWorkflow.ts`、`src/services/image-editor/editorProjectClient.ts`、`server-rs/crates/api-server/src/editor_project.rs`、`server-rs/crates/platform-image/src/vector_engine/*`、`server-rs/crates/platform-image/src/generated_asset_sheets/sheet.rs`、图片画布技术方案。 +- 验证方式:`npm run test -- src/components/image-editor/ImageCanvasEditorView.test.tsx src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx src/components/image-editor/ImageCanvasGenerationPlacementModel.test.ts src/services/image-editor/editorProjectClient.test.ts`、`cargo test -p api-server editor_generation_dimensions_follow_model_options --manifest-path server-rs/Cargo.toml`、`cargo test -p platform-image nanobanana_generate_content --manifest-path server-rs/Cargo.toml`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。 +- 关联文档:`docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md`、`docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md`、`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 ## 2026-06-16 图片画布生成面板与浮层层级收口 @@ -2294,3 +2294,11 @@ - 影响范围:`src/components/image-editor/ImageCanvasEditorView.tsx`、图片画布 layout hydrate、新建 / 上传 / 生成 / 快速编辑 / 图标素材生成结果铺回画布逻辑,以及图片画布技术方案。 - 验证方式:`npm run test -- src/components/image-editor/ImageCanvasEditorView.test.tsx -t "hydrates canvas images from Resolution instead of saved Size|opens generated image info from the corner button and creates a real right-side edit result|shows image resolution on hover"`。 - 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 + +## 2026-06-17 图片画布底部生成视频接入 Lovart 面板 + +- 背景:编辑器画板底部工具栏需要新增 `生成视频`,并和现有 Lovart 式生成类面板、泥点展示、占位图和画布结果图层保持一致。 +- 决策:`生成视频` 点击后创建独立视频生成占位和极简面板,提交 `POST /api/editor/videos/generations`;首期支持 `seedance2.0`、`seedance2.0-fast`、`kling3.0`、`kling3.0-omni`、`veo3.1`、`veo3.1-fast`,固定文字转视频、`16:9`、标准模式和静音。后端复用 Ark / VectorEngine content generation task 轮询链路,下载视频后持久化到 OSS。生成结果在画布中写入 `mediaType="video"` 与 `assetKind="video"`,图片信息弹窗按视频显示为 `视频信息` / `视频类型`。生成类泥点价格统一走 `editor_generation_config`,视频和角色动画均为 480p 每秒 10 泥点、720p 每秒 20 泥点。 +- 影响范围:图片画布生成工作流、前端 editorProjectClient、`shared-contracts`、`api-server` 视频生成 BFF、编辑器技术方案和生成类面板方案。 +- 验证方式:`npm run test -- src/components/image-editor/ImageCanvasEditorView.test.tsx -t "opens the bottom generate video panel"`、`npm run test -- src/components/image-editor/ImageCanvasMetadataModalView.test.tsx`、`npm run test -- src/services/image-editor/editorProjectClient.test.ts`、`cargo test -p shared-contracts editor_video --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server editor_video --manifest-path server-rs/Cargo.toml`、`npm run check:encoding`、`git diff --check`。 +- 关联文档:`docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md`、`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 diff --git a/.hermes/shared-memory/pitfalls.md b/.hermes/shared-memory/pitfalls.md index 0be1a9739..0ac3c3eaa 100644 --- a/.hermes/shared-memory/pitfalls.md +++ b/.hermes/shared-memory/pitfalls.md @@ -55,6 +55,14 @@ - 验证:测试断言菜单不包含在底部工具栏 / 参考图行里,并且生成面板打开时底部 `AI画布工具栏` 仍存在。 - 关联:`src/components/image-editor/ImageCanvasEditorView.tsx`、`src/components/image-editor/ImageCanvasEditorView.test.tsx`。 +## 图片编辑器规范图片面板不要脱离统一生成 shell + +- 现象:生成 UI 设计图或新建图标素材规范时,面板参考图、输入区和底部生成按钮相对生成图片 / 生成角色 / 生成视频错位;图标素材规范甚至可能缺少首行参考图入口。 +- 原因:规范、UI 设计图等面板虽然都属于生成类入口,但 JSX 和 CSS 曾各自维护 `spec-footer`、局部 field wrapper 或缺省参考区,导致后续改造只覆盖普通图片 / 角色 / 视频,规范图片类面板结构漂移。 +- 处理:生成规范下的角色形象规范、UI 素材规范、图标素材规范、自定义规范,以及生成 UI 设计图,都必须复用 `image-canvas-editor__generation-composer image-canvas-editor__generation-composer--image` 外层 shell;首行统一 `image-canvas-editor__generation-ref`,底部统一 `image-canvas-editor__generation-composer-footer` + `image-canvas-editor__generation-submit`。多字段内容只在中央字段区保持紧凑,不单独发明 footer 或省略参考区。 +- 验证:`npm test -- src/components/image-editor/ImageCanvasGenerationComposerView.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx -t "生成UI设计图|生成规范|visible titles|图标素材规范|character spec"`。 +- 关联:`src/components/image-editor/ImageCanvasGenerationComposerView.tsx`、`src/index.css`、`docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md`。 + ## 图片编辑器生成占位图在生成中也要使用最新拖拽位置 - 现象:用户在图片编辑器里提交生成后继续拖动画布占位图,预览框可以移动,但生成完成后的真实图片仍落回提交瞬间的旧位置。 diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index 5c882f3a1..67c0ecec9 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -12,13 +12,13 @@ - 编辑器左侧为图片素材栏,可展开 / 收起;移动端优先保持素材栏可折叠。 - 中央画布支持背景拖拽平移、滚轮缩放、缩放百分比菜单、显示所有元素和固定比例缩放。 - 画布左下角提供 Lovart 式状态控件:背景色圆点、素材 / 图层入口、小地图开关;小地图显示图层缩略分布和当前视口框,点击小地图执行显示所有元素。 -- 画布中的图片可展示、悬浮显示图片 Resolution 尺寸与边框,点击后在图片上方显示浮动工具栏;图片不再维护独立展示 `Size` 字段,画布显示宽高统一取 `originalWidth/originalHeight`(图片信息中的 `Resolution`)。 +- 画布中的图片可展示、悬浮显示图片 Resolution 尺寸与边框,点击后在图片上方显示浮动工具栏;图片右上角素材类型标签、查看信息按钮和悬浮尺寸标签在画布缩小时必须按 viewport 反向缩放,保持屏幕可读尺寸;查看信息按钮使用圆形 `i` 图标,不使用中括号或花括号样式。图片不再维护独立展示 `Size` 字段,画布显示宽高统一取 `originalWidth/originalHeight`(图片信息中的 `Resolution`)。 - 默认工具为选择模式;底部工具栏采用 AI 画布工作流工具组:选择、抓手、上传、生成、局部修改 / 蒙版、文字、形状 / 标注、导出。 - 鼠标中键拖拽始终平移画布;长按 Space 临时进入抓手模式,松开后恢复原工具。 - 图片拖拽时显示水平 / 垂直吸附参考线,吸附到其它图层或画板的边缘与中心线。 - 生成资源右上角显示元数据按钮,点击打开独立元数据窗口。图片信息页不展示后端组装后的生图 Prompt,也不提供复制 Prompt;只展示该图片生成时用户在面板里提交的输入快照,包括普通生成提示词、规范表单字段、角色设定、图标素材描述、修改要求,以及角色形象规范 / 常规参考图 / 图标素材规范 / 修改参考图等参考图卡片。旧数据或上传图片没有输入快照时显示 `-`,禁止回退展示内部 Prompt。 - 对生成资源执行修改时,在右侧创建新的生成结果图层,并自动调整视图显示原图和新图。 -- 图片生成 / 修改统一经 api-server BFF 接入 VectorEngine `gpt-image-2`:纯文本生成走 `/api/editor/images/generations`,基于当前生成图的修改走 `/api/editor/images/edits`。纯文本生成入口采用 Lovart 式画布内占位图 + 锚定生成输入框:点击生成工具后先在画布中心创建选中的灰色占位框,输入框跟随占位框显示;待生成、生成中和失败后保留的占位图都必须继续支持拖动,生成完成时真实生成图落在最新占位框位置,输入框继续跟随新生成图;点击所有图片生成入口并确认请求开始后,必须隐藏对应设置面板,只保留画布内占位图或原图预览,并在预览上显示 Lovart 式生成中遮罩,避免“面板仍占屏”或“预览一起消失”。快速编辑和修改图片在调用后端前必须把当前图层图片源读取为图片 Data URL,来源可以是本地上传 Data URL、站内 public 图片、历史 `/generated-*` 路径或可读取的 OSS generated URL;后端仍只接收图片 Data URL,不把普通 URL 直接透传到 VectorEngine edits。前端不持有 provider 密钥;上游失败或配置缺失时恢复当前生成设置面板展示失败,不创建 mock 成功图。 +- 图片生成 / 修改统一经 api-server BFF 接入 VectorEngine。普通生成、生成规范和快速编辑保留既有 `gpt-image-2` 路径;`生成角色形象` 与 `生成图标素材` 支持 `nanobanana2`(`gemini-3.1-flash-image-preview`)和 `gpt-image-2`,默认 `nanobanana2`,并在两类面板之间沿用用户上次选择的模型。`nanobanana2` 走 `/v1beta/models/{model}:generateContent`,请求体写入 `generationConfig.imageConfig.aspectRatio/imageSize`;`gpt-image-2` 走 `/v1/images/generations` 或 `/v1/images/edits`,请求体按 VectorEngine 文档映射 `size`。纯文本生成走 `/api/editor/images/generations`,基于当前生成图的修改走 `/api/editor/images/edits`。`生成视频` 走 `/api/editor/videos/generations`,支持 Seedance 2.0 / Seedance 2.0 Fast / Kling 3.0 / Kling 3.0 Omni / Veo 3.1 / Veo 3.1 Fast,首期固定文字转视频、`16:9`、标准模式、静音,生成结果以视频图层加入画布。纯文本生成入口采用 Lovart 式画布内占位图 + 锚定生成输入框:点击生成工具后先在画布中心创建选中的灰色占位框,输入框跟随占位框显示;待生成、生成中和失败后保留的占位图都必须继续支持拖动,生成完成时真实生成图或视频落在最新占位框位置,输入框继续跟随新生成图层;占位图失焦时隐藏高亮边框、左上角生成器名称和右上角原始尺寸,重新聚焦时再显示,且名称 / 尺寸在画布缩小时按 viewport 反向缩放保持屏幕尺寸稳定;点击所有图片 / 视频生成入口并确认请求开始后,必须隐藏对应设置面板,只保留画布内占位图或原图预览,并在预览上显示 Lovart 式生成中遮罩,避免“面板仍占屏”或“预览一起消失”。快速编辑和修改图片在调用后端前必须把当前图层图片源读取为图片 Data URL,来源可以是本地上传 Data URL、站内 public 图片、历史 `/generated-*` 路径或可读取的 OSS generated URL;后端仍只接收图片 Data URL,不把普通 URL 直接透传到 VectorEngine edits。前端不持有 provider 密钥;上游失败或配置缺失时恢复当前生成设置面板展示失败,不创建 mock 成功图。 - 底部生成类按钮每次点击都必须创建独立的画布生成对象;新建规范、角色形象或图标素材时,只切换当前编辑面板,不得销毁此前尚未生成或已生成后的其它生成对象状态。归档为非当前编辑对象的生成占位仍可拖动、删除和等待异步完成,完成 / 失败回写必须按生成对象 ID 读取最新占位状态,不能使用提交瞬间的旧快照。 ## 交互规则 @@ -62,8 +62,10 @@ - `POST /api/editor/assets`:批量或单个创建账号级素材,支持按钮上传和拖拽上传后的 data URL / 后续 OSS 元数据。 - `PATCH /api/editor/assets/{assetId}`:重命名素材或移动素材到文件夹。 - `DELETE /api/editor/assets/{assetId}`:删除素材。已放入画布的 project resource 不被级联删除,避免旧画布丢图。 -- `POST /api/editor/images/generations`:按提示词调用 VectorEngine `gpt-image-2` 生成图片;携带参考图的快速编辑也走该接口,前端必须把参考图源预读成图片 Data URL 后放入 `referenceImageSrcs`;接口返回 data URL、尺寸、prompt、model、provider 和 taskId。 +- `POST /api/editor/images/generations`:按提示词调用 VectorEngine 生成图片;角色生成可携带 `model`、`aspectRatio`、`imageSize` 和 `referenceImageSrcs`。`nanobanana2` 参考图作为 `inline_data` 进入 `generateContent`,`gpt-image-2` 参考图进入 edits。携带参考图的快速编辑也走该接口,前端必须把参考图源预读成图片 Data URL 后放入 `referenceImageSrcs`;接口返回 data URL、尺寸、prompt、model、provider 和 taskId。 +- `POST /api/editor/icon-spritesheets/generations`:按图标素材规范图和素材描述数组生成 spritesheet,再由后端切分为独立透明图标。请求支持 `model`、`aspectRatio`、`imageSize`;`nanobanana2` 走原生 `generateContent` 并写入 `generationConfig.imageConfig.aspectRatio/imageSize`,`0.5K` 传 `"512"`;`gpt-image-2` 走 `/v1/images/edits`,后端把 UI 尺寸归一为文档支持的 `1024x1024`、`1024x1536`、`1536x1024`、`2048x2048`、`2048x1152` 等 `size` 字符串。 - `POST /api/editor/images/edits`:按提示词和当前图片 Data URL 调用 VectorEngine edits,返回新的生成图片元数据。 +- `POST /api/editor/videos/generations`:按视频描述、模型、比例、时长、分辨率、模式、声音和泥点价格生成视频。请求模型支持 `seedance2.0`、`seedance2.0-fast`、`kling3.0`、`kling3.0-omni`、`veo3.1`、`veo3.1-fast`;后端复用 Ark / VectorEngine content generation task 轮询链路,下载最终视频并持久化到 OSS,返回 `videoSrc`、尺寸、prompt、model、provider、taskId、durationSeconds、resolution 和 `priceMudPoints`。 所有写接口都必须校验 Bearer 登录态和 owner;接口只返回当前用户有权读取的工程与资源。 diff --git a/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md b/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md new file mode 100644 index 000000000..d617fa79b --- /dev/null +++ b/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md @@ -0,0 +1,101 @@ +# 生成类面板 Lovart 统一改造方案 + +日期:`2026-06-17` + +## 范围 + +图片画布编辑器内以下生成类面板统一对齐 Lovart 式极简创作工具风格: + +- `生成图片` +- `生成规范` +- `生成角色形象` +- `生成图标素材` +- `生成UI设计图` +- `生成视频` +- `角色动画生成面板` + +## 统一布局 + +1. 参考图区域永远位于面板第一行。 +2. 参考图使用统一参考图卡片: + - 普通参考图:灰蓝色图片图标。 + - 规范参考图:紫色剪贴板图标。 + - 图标素材规范:绿色图标图标。 + - UI 图标规范:琥珀色图标图标。 + - 视频参考图 / 角色视频源图:深色视频或角色图标。 +3. 参考图卡片尽量少文字;必要文字写在图标块内或短标签内,不写规则说明。 +4. 普通参考图支持连续追加:已有图缩略图后始终保留一个 `+` 入口。点击入口只弹出“从画布中选择 / 上传图片”来源选项,不再直接打开系统文件选择器;生成图片、生成视频、角色常规参考图等同类参考图入口都遵循同一交互。 +5. 单文本输入面板不显示文本框标题,用问题式 placeholder: + - 生成图片:`今天想生成什么画面?` + - 生成角色:`你希望角色如何设计?` + - 生成 UI:`你希望这个 UI 长什么样?` + - 生成视频:`你希望生成什么视频?` + - 角色动画:`你希望角色做什么动作?` +6. 多输入框面板必须保留每个字段标题和输入框边界,例如生成规范、图标素材多描述。 +7. 生成规范下的角色形象规范、UI素材规范、图标素材规范和自定义规范都使用同一生成类 shell:首行参考图区域、中央字段区、底部生成按钮区,不再出现缺首行参考区或单独 footer 样式。 + +## 参数交互 + +画面比例、大小尺寸、模型、分辨率、时长等生成参数统一为 Lovart 式底部胶囊按钮: + +```text +左下:[比例 · 尺寸 ˄] 右下:[模型 ˄] [生成 · 图标 12] +``` + +- 面板内不显示 `画面比例`、`大小尺寸`、`模型` 等字段标题,只显示当前选择值。 +- 图片类面板把画面比例和大小尺寸合并成一个左下角选项框;视频 / 角色动画把比例、时长、清晰度合并成一个左下角选项框。 +- 模型选项框和生成按钮位于右下角。 +- 点击后在父级生成面板内向上弹出子面板;父级面板隐藏或销毁时,子面板同步销毁。 +- 子面板展示时,点击父级面板中除当前子面板和选项触发器以外的任意区域即可收起,不额外显示右上角关闭按钮。 +- 弹出面板内可分组展示字段,点击某个选项只更新当前字段和选中样式,不关闭弹出面板,方便连续修改。 +- 模型子面板每行固定一个模型,不用方框包裹模型名;模型名不换行,前置对应模型类型图标,选中项在模型名后用对号标记。 +- 比例选项卡片内展示对应比例的线框。 +- 父级面板、底部选项框、弹出子面板字号保持一致。 +- 底部组合值使用 `·` 分隔,例如 `16:9 · 4秒 · 480p`。 +- 底部参数热区与生成按钮等高,默认不显示阴影;悬停显示轻量阴影;箭头默认向下,展开后旋转向上。 +- 生成图片和生成视频文本输入框紧贴参考图下方,取消旧网格预留导致的空白高度。 +- 不再在底部常驻展开全部可选项。 + +## 泥点显示 + +- 本次消耗泥点必须显示在生成按钮内部。 +- `泥点` 文本不在 UI 中显示,改用图标 + 数值,例如 `生成 ✦ 12`。 +- 泥点配置统一收口到 `api-server` 的编辑器生成配置模块;前端只保留与后端配置同名的展示兜底,后续可接接口动态下发。 + +## 第一版计费配置 + +```text +生成图片:12 泥点 +生成规范:5 泥点 +生成角色形象:12 泥点 +生成图标素材:12 泥点 +生成UI设计图:12 泥点 +生成视频:480p 每秒 10 泥点,720p 每秒 20 泥点 +角色动画:480p 每秒 10 泥点,720p 每秒 20 泥点 +``` + +## 生成视频模型与接口 + +- 底部 `生成视频` 面板提交到 `POST /api/editor/videos/generations`。 +- 首期只开放文字生成视频,固定 `16:9`、静音、标准模式;结果作为 `mediaType="video"`、`assetKind="video"` 的视频图层加入画布。 +- 支持模型: + - `seedance2.0` + - `seedance2.0-fast` + - `kling3.0` + - `kling3.0-omni` +- 不展示 Veo 模型入口。 + - `veo3.1` + - `veo3.1-fast` +- 后端复用现有 Ark / VectorEngine content generation task 轮询链路,并把生成视频持久化到 OSS;缺少 `ARK_CHARACTER_VIDEO_BASE_URL` 或 `ARK_CHARACTER_VIDEO_API_KEY` 时 fail-closed 返回配置错误。 + +## 验收 + +- 所有生成类面板首行都是参考图区域。 +- 比例 / 尺寸 / 模型不再平铺全部选项;比例与尺寸合并为左下角当前值按钮,模型与生成按钮位于右下角。 +- 单文本输入面板不显示字段标题,placeholder 是问题式文案。 +- 多文本输入面板字段标题和边界仍清晰。 +- 生成按钮内能看到图标化泥点消耗数值。 +- 弹出选项面板点击选项后保持打开,可连续修改多个字段。 +- 规范面板比图片生成面板更紧凑,字段间距和输入高度更小,但外层 shell、首行参考图和底部按钮区必须继续对齐生成图片 / 生成角色 / 生成视频。 +- 后端存在独立编辑器生成计费配置文件,角色动画价格校验使用该配置。 +- 生成视频结果以视频图层加入画布,画布媒体元素标记为 `画布视频:生成视频 N`。 diff --git a/docs/【编辑器】画板UI设计图生成入口设计-2026-06-17.md b/docs/【编辑器】画板UI设计图生成入口设计-2026-06-17.md new file mode 100644 index 000000000..5244dcfdf --- /dev/null +++ b/docs/【编辑器】画板UI设计图生成入口设计-2026-06-17.md @@ -0,0 +1,53 @@ +# 【编辑器】画板UI设计图生成入口设计 + +日期:2026-06-17 + +## 入口与画布状态 + +- 底部 AI 画布工具栏新增 `生成UI设计图`。 +- 点击后立即在画布中新建 `UI设计图生成占位图`,不复用普通新建图片的空白样式。 +- 占位图默认 16:9 展示,生成成功后替换为 `assetKind: "ui-design"` 的画布图层。 + +## 生成面板 + +- 占位图下方打开独立生成面板,标题为 `生成UI设计图`。 +- 面板复用普通 `生成图片` / `生成角色形象` / `生成视频` 的生成类 shell:首行参考图区域、中央单文本输入、底部参数与生成按钮区。 +- 面板第一个模块为 `图标素材规范`,并放在首行参考图区域。 +- 点击图标素材规范卡片后,在卡片旁弹出来源菜单: + - `从画布中选择` + - `新建图标素材规范` + - `上传图片` +- `从画布中选择` 只接受 `assetKind: "icon-spec"` 的图层;普通图片、其他类别图层和不携带标签的图片不绑定。 +- `新建图标素材规范` 复用现有图标素材规范生成表单。 +- `上传图片` 仅绑定到当前 UI 设计面板的图标素材规范参考,不自动添加为画布图层。 + +## 生成参数 + +- 支持自定义画面比例和大小尺寸。 +- 模型固定为 `gpt-image-2`,面板不提供切换到其他模型的能力。 +- 默认画面比例为 `16:9`,默认大小为 `1K`。 + +## 提示词契约 + +后端收到 `kind: "ui-design"` 时固定拼接: + +```text +生成玩法UI原型图 +【用户输入】<用户输入> +``` + +如果用户设置了图标素材规范参考图,则追加: + +```text +参考图1为图标素材规范,请在UI图标、按钮符号、描边、材质、圆角、阴影和状态层级上严格遵循参考图1的素材规范。 +``` + +生成请求固定使用 `gpt-image-2`。有参考图时走图片编辑请求;无参考图时走图片生成请求。 + +## 验收点 + +- 点击 `生成UI设计图` 后出现 UI 设计占位图和独立生成面板。 +- 面板第一模块为图标素材规范,来源菜单包含三个动作。 +- 从画布选择时只能绑定图标素材规范图片。 +- 请求参数包含 `kind: "ui-design"`、`model: "gpt-image-2"`、比例、大小与可选参考图。 +- 生成图层信息面板展示 `用户输入` 与 `图标素材规范`。 diff --git a/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md b/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md index b49d87aed..2ecd41871 100644 --- a/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md +++ b/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md @@ -39,11 +39,13 @@ - 请求字段: - `referenceImageSrc`:图标素材规范 Data URL。 - `iconDescriptions`:过滤空文本后的图标描述数组,`1..100`。 - - `model`:固定 `gemini-3.1-flash-image-preview`。 -- 后端根据图标数量选择尺寸: - - `<=25` 个:`512x512`,即 0.5K 1:1。 - - `>25` 个:`1024x1024`,即 1K 1:1。 -- 后端使用 VectorEngine 图片编辑接口,把 `referenceImageSrc` 作为参考图 1,模型固定传 `gemini-3.1-flash-image-preview`。 + - `model`:支持 `gemini-3.1-flash-image-preview`(UI 显示 `nanobanana2`)和 `gpt-image-2`,默认 `nanobanana2`。 + - `aspectRatio`:按 `x:y` 展示,选项跟随模型。 + - `imageSize`:按 `0.5K / 1K / 2K` 展示,选项跟随模型。 +- 模型与尺寸选项: + - `nanobanana2`:比例 `1:1 / 2:3 / 3:2 / 9:16 / 16:9`;大小 `0.5K / 1K / 2K`。后端走 `/v1beta/models/{model}:generateContent`,把图标素材规范图作为 `inline_data`,并把 `aspectRatio` / `imageSize` 写入 `generationConfig.imageConfig`;`0.5K` 按 VectorEngine 文档传 `"512"`。 + - `gpt-image-2`:比例 `1:1 / 2:3 / 3:2 / 9:16 / 16:9`;大小 `1K / 2K`。后端走 `/v1/images/edits`,把图标素材规范图作为 multipart `image`,按 `size` 映射:`1K 1:1 -> 1024x1024`、`1K 2:3/9:16 -> 1024x1536`、`1K 3:2/16:9 -> 1536x1024`、`2K 1:1 -> 2048x2048`、`2K 3:2/16:9 -> 2048x1152`;文档未列出 `2K` 竖版,`2K 2:3/9:16` 后端回落到 `1024x1536`。 +- 用户在角色或图标素材面板中切换过模型后,下一次打开这两类面板继续使用上次模型。 - Prompt 固定为: ```text @@ -72,6 +74,6 @@ - 点击 `生成图标素材` 后出现一叠空白图标占位和图标素材面板。 - `图标素材规范 -> 从画布中选择` 只能选择图标素材规范图,点击普通图片或角色规范图不会绑定。 - 默认 6 个素材描述会进入 prompt;新增描述最多到 100 个。 -- `<=25` 个描述提交时后端请求尺寸为 `512x512`;`>25` 个描述提交时后端请求尺寸为 `1024x1024`。 -- VectorEngine 请求体的 `model` 为 `gemini-3.1-flash-image-preview`。 +- 默认打开图标素材面板时选中 `nanobanana2 / 1:1 / 1K`;模型切换后,角色和图标素材面板之间沿用上次选择的模型。 +- 图标素材生成请求必须带 `model`、`aspectRatio` 和 `imageSize`;`nanobanana2` 请求体必须包含 `generationConfig.imageConfig.aspectRatio/imageSize`,`gpt-image-2` 请求必须包含文档映射后的 `size`。 - 生成成功后画布出现按描述命名的多个透明图标素材图层,图层之间不重叠。 diff --git a/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md b/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md index b67d32183..9ac63ce6c 100644 --- a/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md +++ b/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md @@ -32,6 +32,16 @@ 4. 左下角展示画面比例和大小选择按钮。 5. 右下角展示模型选择和生成按钮。 +## 普通生成面板视觉口径 + +普通 `生成图片`、`生成角色形象`、`生成UI设计图` 等锚定在图片下方的生成面板统一采用 Lovart 式极简创作工具风格: + +- PC 端优先保持面板锚定在占位图或目标图片下方,宽度允许比旧版略宽,避免比例、尺寸、模型和生成按钮挤成一行难以阅读。 +- 面板只展示创作必需输入:参考图、提示词、比例、大小、模型和生成按钮;不在 UI 内铺说明性规则文案。 +- 视觉以白色半透明面板、轻边框、低阴影、低饱和选项按钮和清晰黑色主按钮为主,减少游戏式厚重装饰,贴近 Lovart 的极简画布工具感。 +- 普通 `生成图片` 面板的比例、大小和模型必须使用真实选项按钮,不再使用占位式参数按钮或弹出“建设中”提示。 +- 移动端仍可固定在底部工具栏上方并允许内部滚动,保证不遮挡底部 AI 工具栏和画布操作。 + ## 生成与参考图契约 - 前端提交角色生成时,使用 `POST /api/editor/images/generations`。 @@ -39,8 +49,14 @@ - 角色形象规范与常规参考图作为 `referenceImageSrcs` 传入,顺序固定为: 1. 角色形象规范图。 2. 常规参考图列表。 -- 当前请求尺寸沿用编辑器普通生成默认值;比例和大小按钮先复用现有占位交互。 -- 后端如果收到参考图,则走带多参考图的图片编辑/参考图生成链路;没有参考图时走纯文本生成链路。 +- 请求同时提交 `model`、`aspectRatio` 和 `imageSize`: + - `model` 支持 `gemini-3.1-flash-image-preview`(UI 显示 `nanobanana2`)和 `gpt-image-2`,默认 `nanobanana2`。 + - 用户在角色或图标素材面板中切换过模型后,下一次打开这两类面板继续使用上次模型。 + - 比例按 `x:y` 展示;大小按 `0.5K / 1K / 2K` 展示。 +- 尺寸选项来源以 VectorEngine 接入文档为准: + - `nanobanana2`:比例 `1:1 / 2:3 / 3:2 / 9:16 / 16:9`;大小 `0.5K / 1K / 2K`。后端走 `/v1beta/models/{model}:generateContent`,把比例写入 `generationConfig.imageConfig.aspectRatio`,把大小写入 `generationConfig.imageConfig.imageSize`;其中 `0.5K` 按文档传 `"512"`。 + - `gpt-image-2`:比例 `1:1 / 2:3 / 3:2 / 9:16 / 16:9`;大小 `1K / 2K`。后端走 `/v1/images/generations` 或 `/v1/images/edits`,按文档尺寸映射:`1K 1:1 -> 1024x1024`、`1K 2:3/9:16 -> 1024x1536`、`1K 3:2/16:9 -> 1536x1024`、`2K 1:1 -> 2048x2048`、`2K 3:2/16:9 -> 2048x1152`;文档未列出 `2K` 竖版,`2K 2:3/9:16` 后端回落到 `1024x1536`。 +- 后端如果收到参考图,`nanobanana2` 把参考图作为 `inline_data` 传入原生 `generateContent`;`gpt-image-2` 走带多参考图的图片编辑链路。没有参考图时按所选模型走纯文本生成链路。 - `kind = "character"` 时,后端不直接把前端文本当完整生图提示词,而是把文本作为 `角色设定` 填入固定提示词骨架: ```text @@ -52,11 +68,18 @@ ## 生成规范参考图 -- `生成规范 -> 角色形象规范`、`UI素材规范`、`自定义规范` 的设定面板支持上传 1 张参考图;`图标素材规范` 继续使用后续图标素材生成面板里的专用规范图链路,不在这里重复新增入口。 +- `生成规范 -> 角色形象规范`、`UI素材规范`、`图标素材规范`、`自定义规范` 的设定面板都支持上传 1 张参考图,并统一放在面板首行参考图区域。 - 参考图入口只展示字段标题、缩略图或上传图标、文件名,不把参考规则说明铺在 UI 上。 - 提交生成规范时,若存在参考图,前端必须把参考图作为 `referenceImageSrcs[0]` 提交到 `/api/editor/images/generations`,并在生图提示词开头自动追加“参考图生成规范”语义:要求模型参考图 1 的构图、风格、材质、色彩、形状语言和视觉层级生成规范图,但不要复制参考图中的文字、水印或无关背景。 - 生成结果的信息快照必须记录该参考图,标题为 `参考图`,便于后续在图片信息面板回看生成输入。 +## 新建生成图落点避让 + +- 普通生成、生成规范、生成角色形象和生成图标素材在创建画布占位图前,必须先检测占位图矩形是否与画布中已有可见图片图层或已有生成占位图重叠。 +- 若当前屏幕中心对应的画板位置可用,则占位图仍创建在该中心;若重叠,则以当前屏幕中心对应画板位置为原点,按距离由近到远查找不重叠候选位置。 +- 候选占位图与相邻图片或占位图之间保留 `32px` 画板间距;隐藏图层不阻挡新建落点。 +- 选中落点后保持当前缩放比例不变,将视口中心移动到新占位图中心,确保用户创建后立即看到新图和生成面板。 + ## 图片信息展示 - 图片信息弹窗标题固定为 `图片信息`,不拼接图片 / 图层名称。 @@ -78,13 +101,16 @@ - `角色形象规范` 与 `上传常规参考图` 入口是带预览视觉块的参考图卡片,不是无样式文字。 - `从画布中选择` 后点击已有画布图片可绑定为角色形象规范,`Esc` 可退出点选状态。 - 上传常规参考图后缩略图右下角显示序号。 -- 输入角色设定并生成时,请求包含 `kind: "character"`、角色设定 prompt 和参考图数组。 +- 输入角色设定并生成时,请求包含 `kind: "character"`、角色设定 prompt、参考图数组、`model`、`aspectRatio` 和 `imageSize`。 +- 默认打开角色生成面板时选中 `nanobanana2 / 1:1 / 1K`;切换到 `gpt-image-2` 后再次打开角色或图标素材面板应沿用该模型。 - 生成成功后在占位图位置创建 `assetKind: "character"` 图层,右上角显示 `角色` 标签,布局保存包含该字段。 ## 当前落地记录 - 前端画板已接入 `生成角色形象` 底部入口、角色占位图、角色面板、画布点选规范图、上传规范图、上传常规参考图和序号角标。 +- 画布生成类入口已统一接入新建占位图落点避让:优先使用当前屏幕中心对应画板位置,重叠时自动选择最近的不重叠位置,并将视口中心移动到新占位图。 - 角色生成提交统一走 `/api/editor/images/generations`,按 `角色形象规范 -> 常规参考图` 顺序传 `referenceImageSrcs`,并写入 `assetKind: "character"`。 +- 角色和图标素材生成已接入 `nanobanana2` / `gpt-image-2` 模型切换、上次模型记忆,以及按模型归一的比例 / 大小尺寸;`nanobanana2` 使用原生 `generateContent` 的 `imageConfig.aspectRatio/imageSize`,`gpt-image-2` 使用文档列出的 `size` 字符串。 - 角色生成后端已按固定 prompt 骨架补入 `角色设定`,并在生成成功后自动执行绿幕去背、写入 `generated-character-drafts/editor/character-images//image.png` 路径下的 OSS 私有对象,返回的 `objectKey` / `assetObjectId` 会随画板资源记录保存。 - `Esc` 只退出角色规范画布点选状态,不关闭角色生成面板。 - 已补充回归测试覆盖角色形象生成、点选退出、角色动画入口隔离和快速编辑入口。 diff --git a/server-rs/crates/api-server/src/character_animation_assets.rs b/server-rs/crates/api-server/src/character_animation_assets.rs index 8740d3795..26ee14cc0 100644 --- a/server-rs/crates/api-server/src/character_animation_assets.rs +++ b/server-rs/crates/api-server/src/character_animation_assets.rs @@ -42,7 +42,8 @@ use shared_contracts::assets::{ CharacterVisualDraftPayload, CharacterWorkflowCacheGetResponse, CharacterWorkflowCachePayload, CharacterWorkflowCacheSaveRequest, CharacterWorkflowCacheSaveResponse, EditorCharacterAnimationFramePayload, EditorCharacterAnimationGenerateRequest, - EditorCharacterAnimationGenerateResponse, + EditorCharacterAnimationGenerateResponse, EditorVideoGenerateRequest, + EditorVideoGenerateResponse, }; use spacetime_client::SpacetimeClientError; @@ -87,6 +88,15 @@ const ARK_VIDEO_TASK_POLL_INTERVAL_MS: u64 = 5_000; const EDITOR_CHARACTER_ANIMATION_MODEL: &str = "seedance2.0"; const EDITOR_CHARACTER_ANIMATION_ASSET_KIND: &str = "editor_character_animation"; const EDITOR_CHARACTER_ANIMATION_PROMPT_PREFIX: &str = "生成游戏角色动画,参考图作为首帧和尾帧,画面中心构图,角色主体完整置于画面中央,禁止镜头透视,禁止特写。背景固定为纯绿色绿幕,只作为抠像底色,禁止出现建筑、室内布景、风景、地面道具、漂浮物、烟雾叙事元素、文字或其他角色以外的场景内容。\n动作描述:"; +const EDITOR_VIDEO_ASSET_KIND: &str = "editor_video"; +const EDITOR_VIDEO_ENTITY_KIND: &str = "editor_canvas"; +const EDITOR_VIDEO_SLOT: &str = "video_preview"; +const EDITOR_VIDEO_MODEL_SEEDANCE_2: &str = "seedance2.0"; +const EDITOR_VIDEO_MODEL_SEEDANCE_2_FAST: &str = "seedance2.0-fast"; +const EDITOR_VIDEO_MODEL_KLING_3: &str = "kling3.0"; +const EDITOR_VIDEO_MODEL_KLING_3_OMNI: &str = "kling3.0-omni"; +const EDITOR_VIDEO_MODEL_VEO_3_1: &str = "veo3.1"; +const EDITOR_VIDEO_MODEL_VEO_3_1_FAST: &str = "veo3.1-fast"; const BUILT_IN_MOTION_TEMPLATES: [MotionTemplate; 4] = [ MotionTemplate { @@ -575,6 +585,63 @@ pub async fn generate_editor_character_animation( )) } +pub async fn generate_editor_video( + State(state): State, + Extension(request_context): Extension, + payload: Result, JsonRejection>, +) -> Result, Response> { + let Json(payload) = payload.map_err(|error| { + editor_video_error_response( + &request_context, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "editor-video", + "message": error.body_text(), + })), + ) + })?; + + let normalized = + normalize_editor_video_request(payload).map_err(|error| { + editor_video_error_response(&request_context, error) + })?; + let settings = require_editor_video_settings(&state, normalized.model.as_str()).map_err( + |error| editor_video_error_response(&request_context, error), + )?; + let http_client = build_upstream_http_client(settings.ark.request_timeout_ms) + .map_err(|error| editor_video_error_response(&request_context, error))?; + let task_id = generate_ai_task_id(current_utc_micros()); + + let generated = request_editor_video_preview( + &state, + &http_client, + &settings, + "editor-video", + task_id.as_str(), + &normalized, + ) + .await + .map_err(|error| editor_video_error_response(&request_context, error))?; + + Ok(json_success_body( + Some(&request_context), + EditorVideoGenerateResponse { + ok: true, + video_src: generated.preview_video_path, + width: normalized.width, + height: normalized.height, + source_type: "generated".to_string(), + prompt: normalized.prompt, + actual_prompt: Some(generated.submitted_prompt), + model: normalized.model, + provider: "VectorEngine".to_string(), + task_id, + duration_seconds: normalized.duration_seconds, + resolution: normalized.resolution, + price_mud_points: normalized.price_mud_points, + }, + )) +} + pub async fn get_character_animation_job( State(state): State, Extension(request_context): Extension, @@ -1371,6 +1438,114 @@ async fn request_editor_character_animation_preview( }) } +async fn request_editor_video_preview( + state: &AppState, + http_client: &reqwest::Client, + settings: &EditorVideoSettings, + owner_user_id: &str, + task_id: &str, + request: &NormalizedEditorVideoRequest, +) -> Result { + let upstream_task_id = + create_editor_text_to_video_task(http_client, settings, request).await?; + let video_url = + wait_for_ark_content_generation_task(http_client, &settings.ark, upstream_task_id.as_str()) + .await?; + let preview_payload = + download_generated_video(http_client, video_url.as_str(), "下载画板生成视频失败。").await?; + let preview_video_path = + put_generated_editor_video(state, owner_user_id, task_id, request, preview_payload).await?; + + Ok(GeneratedAnimationPreview { + preview_video_path, + upstream_task_id, + submitted_prompt: request.prompt.clone(), + moderation_fallback_applied: false, + }) +} + +async fn create_editor_text_to_video_task( + http_client: &reqwest::Client, + settings: &EditorVideoSettings, + request: &NormalizedEditorVideoRequest, +) -> Result { + let response = http_client + .post(format!("{}/contents/generations/tasks", settings.ark.base_url)) + .header( + reqwest::header::AUTHORIZATION, + format!("Bearer {}", settings.ark.api_key), + ) + .header(reqwest::header::CONTENT_TYPE, "application/json") + .json(&json!({ + "model": request.provider_model, + "content": [ + { + "type": "text", + "text": request.prompt, + } + ], + "resolution": request.resolution, + "ratio": request.aspect_ratio, + "duration": request.duration_seconds, + "mode": "std", + "watermark": false, + })) + .send() + .await + .map_err(|error| { + map_character_animation_upstream_error(format!("请求画板视频服务失败:{error}")) + })?; + + let status = response.status(); + let body = response.text().await.map_err(|error| { + map_character_animation_upstream_error(format!("读取画板视频任务响应失败:{error}")) + })?; + if !status.is_success() { + return Err(parse_animation_upstream_error( + body.as_str(), + "创建画板视频任务失败。", + )); + } + let payload = parse_animation_json_payload(body.as_str(), "创建画板视频任务失败。")?; + extract_animation_task_id(&payload.payload).ok_or_else(|| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "editor-video", + "message": "画板视频任务未返回任务 id。", + })) + }) +} + +async fn put_generated_editor_video( + state: &AppState, + owner_user_id: &str, + task_id: &str, + request: &NormalizedEditorVideoRequest, + preview_payload: MediaPayload, +) -> Result { + let put_result = put_character_animation_object( + state, + LegacyAssetPrefix::CharacterDrafts, + vec![ + "editor-videos".to_string(), + sanitize_storage_segment(request.model.as_str(), "model"), + task_id.to_string(), + ], + format!("preview.{}", preview_payload.extension), + preview_payload.mime_type, + preview_payload.bytes, + build_asset_metadata( + EDITOR_VIDEO_ASSET_KIND, + owner_user_id, + EDITOR_VIDEO_ENTITY_KIND, + task_id, + EDITOR_VIDEO_SLOT, + request.model.as_str(), + ), + ) + .await?; + Ok(put_result.legacy_public_path) +} + async fn create_editor_ark_image_to_video_task( http_client: &reqwest::Client, settings: &EditorCharacterAnimationSettings, @@ -2131,7 +2306,10 @@ fn normalize_editor_character_animation_request( let frame_count = normalize_editor_character_animation_frame_count(payload.frame_count)?; let duration_seconds = normalize_editor_character_animation_duration(payload.duration_seconds, frame_count)?; - let expected_price = calculate_editor_character_animation_price(resolution, duration_seconds); + let expected_price = crate::editor_generation_config::editor_character_animation_mud_points( + resolution, + duration_seconds, + ); if payload.price_mud_points != expected_price { return Err(editor_character_animation_bad_request(format!( "priceMudPoints 与分辨率和时长不一致,应为 {expected_price}。" @@ -2212,6 +2390,145 @@ fn require_editor_character_animation_settings( }) } +fn normalize_editor_video_request( + payload: EditorVideoGenerateRequest, +) -> Result { + let prompt = payload.prompt.trim().chars().take(4000).collect::(); + if prompt.is_empty() { + return Err(editor_video_bad_request("视频描述不能为空。")); + } + let model = normalize_editor_video_model(payload.model.as_str())?; + let aspect_ratio = normalize_editor_video_aspect_ratio(payload.aspect_ratio.as_str())?; + let resolution = normalize_editor_video_resolution(payload.resolution.as_str())?; + let duration_seconds = normalize_editor_video_duration(payload.duration_seconds)?; + if payload.mode.trim() != "std" { + return Err(editor_video_bad_request("mode 只支持 std。")); + } + if payload.sound.trim() != "off" { + return Err(editor_video_bad_request("sound 只支持 off。")); + } + let expected_price = crate::editor_generation_config::editor_video_generation_mud_points( + resolution, + duration_seconds, + ); + if payload.price_mud_points != expected_price { + return Err(editor_video_bad_request(format!( + "priceMudPoints 与分辨率和时长不一致,应为 {expected_price}。" + ))); + } + let (width, height) = resolve_editor_video_size(aspect_ratio, resolution); + + Ok(NormalizedEditorVideoRequest { + prompt, + model: model.to_string(), + provider_model: resolve_editor_video_provider_model(model).to_string(), + aspect_ratio: aspect_ratio.to_string(), + resolution: resolution.to_string(), + duration_seconds, + price_mud_points: expected_price, + width, + height, + }) +} + +fn normalize_editor_video_model(value: &str) -> Result<&'static str, AppError> { + match value.trim() { + EDITOR_VIDEO_MODEL_SEEDANCE_2 => Ok(EDITOR_VIDEO_MODEL_SEEDANCE_2), + EDITOR_VIDEO_MODEL_SEEDANCE_2_FAST => Ok(EDITOR_VIDEO_MODEL_SEEDANCE_2_FAST), + EDITOR_VIDEO_MODEL_KLING_3 => Ok(EDITOR_VIDEO_MODEL_KLING_3), + EDITOR_VIDEO_MODEL_KLING_3_OMNI => Ok(EDITOR_VIDEO_MODEL_KLING_3_OMNI), + EDITOR_VIDEO_MODEL_VEO_3_1 => Ok(EDITOR_VIDEO_MODEL_VEO_3_1), + EDITOR_VIDEO_MODEL_VEO_3_1_FAST => Ok(EDITOR_VIDEO_MODEL_VEO_3_1_FAST), + _ => Err(editor_video_bad_request( + "model 只支持 seedance2.0、seedance2.0-fast、kling3.0、kling3.0-omni、veo3.1、veo3.1-fast。", + )), + } +} + +fn resolve_editor_video_provider_model(model: &str) -> &str { + match model { + // 中文注释:Seedance 2.0 Fast 复用平台已有 fast seedance 上游模型;标准版按产品 ID 透传。 + EDITOR_VIDEO_MODEL_SEEDANCE_2_FAST => CHARACTER_ANIMATION_MODEL, + _ => model, + } +} + +fn normalize_editor_video_aspect_ratio(value: &str) -> Result<&'static str, AppError> { + match value.trim() { + "16:9" => Ok("16:9"), + _ => Err(editor_video_bad_request("aspectRatio 只支持 16:9。")), + } +} + +fn normalize_editor_video_resolution(value: &str) -> Result<&'static str, AppError> { + match value.trim() { + "480p" => Ok("480p"), + "720p" => Ok("720p"), + _ => Err(editor_video_bad_request("resolution 只支持 480p 或 720p。")), + } +} + +fn normalize_editor_video_duration(value: u32) -> Result { + match value { + 4 | 5 => Ok(value), + _ => Err(editor_video_bad_request("durationSeconds 只支持 4 或 5。")), + } +} + +fn resolve_editor_video_size(aspect_ratio: &str, resolution: &str) -> (u32, u32) { + match (aspect_ratio, resolution) { + ("16:9", "720p") => (1280, 720), + _ => (854, 480), + } +} + +fn require_editor_video_settings( + state: &AppState, + model: &str, +) -> Result { + let base_url = state + .config + .ark_character_video_base_url + .trim() + .trim_end_matches('/'); + if base_url.is_empty() { + return Err( + AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ + "provider": "ark", + "reason": "ARK_CHARACTER_VIDEO_BASE_URL 未配置", + })), + ); + } + let api_key = state + .config + .ark_character_video_api_key + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ + "provider": "ark", + "reason": "ARK_CHARACTER_VIDEO_API_KEY 未配置", + })) + })?; + + Ok(EditorVideoSettings { + ark: ArkVideoSettings { + base_url: base_url.to_string(), + api_key: api_key.to_string(), + request_timeout_ms: state.config.ark_character_video_request_timeout_ms.max(1), + model: resolve_editor_video_provider_model(model).to_string(), + }, + }) +} + +fn editor_video_bad_request(message: impl Into) -> AppError { + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "editor-video", + "message": message.into(), + })) +} + fn build_editor_character_animation_prompt(prompt_text: &str) -> String { format!( "{}\n{}", @@ -2272,11 +2589,6 @@ fn normalize_editor_character_animation_duration( } } -fn calculate_editor_character_animation_price(resolution: &str, duration_seconds: u32) -> u32 { - let per_second = if resolution == "720p" { 20 } else { 10 }; - per_second * duration_seconds -} - fn resolve_editor_character_animation_provider_ratio( ratio: &str, source_width: u32, @@ -3792,6 +4104,10 @@ fn character_animation_error_response( error.into_response_with_context(Some(request_context)) } +fn editor_video_error_response(request_context: &RequestContext, error: AppError) -> Response { + error.into_response_with_context(Some(request_context)) +} + pub(crate) struct MotionTemplate { pub(crate) id: &'static str, pub(crate) label: &'static str, @@ -3836,6 +4152,10 @@ struct EditorCharacterAnimationSettings { duration_seconds: u32, } +struct EditorVideoSettings { + ark: ArkVideoSettings, +} + #[derive(Debug)] struct NormalizedEditorCharacterAnimationRequest { source_layer_id: String, @@ -3851,6 +4171,19 @@ struct NormalizedEditorCharacterAnimationRequest { fps: u32, } +#[derive(Debug)] +struct NormalizedEditorVideoRequest { + prompt: String, + model: String, + provider_model: String, + aspect_ratio: String, + resolution: String, + duration_seconds: u32, + price_mud_points: u32, + width: u32, + height: u32, +} + struct GeneratedAnimationPreview { preview_video_path: String, upstream_task_id: String, @@ -4112,7 +4445,71 @@ mod tests { #[test] fn editor_character_animation_price_depends_on_resolution_and_duration() { - assert_eq!(calculate_editor_character_animation_price("480p", 4), 40); - assert_eq!(calculate_editor_character_animation_price("720p", 6), 120); + assert_eq!( + crate::editor_generation_config::editor_character_animation_mud_points("480p", 4), + 40 + ); + assert_eq!( + crate::editor_generation_config::editor_character_animation_mud_points("720p", 6), + 120 + ); + } + + #[test] + fn editor_video_normalizes_lovart_model_contract() { + let normalized = normalize_editor_video_request(EditorVideoGenerateRequest { + prompt: " 让角色向镜头挥手 ".to_string(), + model: EDITOR_VIDEO_MODEL_KLING_3_OMNI.to_string(), + aspect_ratio: "16:9".to_string(), + duration_seconds: 5, + resolution: "480p".to_string(), + mode: "std".to_string(), + sound: "off".to_string(), + price_mud_points: 50, + }) + .expect("editor video request should normalize"); + + assert_eq!(normalized.prompt, "让角色向镜头挥手"); + assert_eq!(normalized.model, EDITOR_VIDEO_MODEL_KLING_3_OMNI); + assert_eq!(normalized.provider_model, EDITOR_VIDEO_MODEL_KLING_3_OMNI); + assert_eq!(normalized.width, 854); + assert_eq!(normalized.height, 480); + assert_eq!(normalized.price_mud_points, 50); + } + + #[test] + fn editor_video_seedance_fast_uses_existing_fast_seedance_model() { + let normalized = normalize_editor_video_request(EditorVideoGenerateRequest { + prompt: "快速生成镜头推进。".to_string(), + model: EDITOR_VIDEO_MODEL_SEEDANCE_2_FAST.to_string(), + aspect_ratio: "16:9".to_string(), + duration_seconds: 4, + resolution: "720p".to_string(), + mode: "std".to_string(), + sound: "off".to_string(), + price_mud_points: 80, + }) + .expect("seedance fast request should normalize"); + + assert_eq!(normalized.provider_model, CHARACTER_ANIMATION_MODEL); + assert_eq!(normalized.width, 1280); + assert_eq!(normalized.height, 720); + } + + #[test] + fn editor_video_rejects_price_mismatch() { + let error = normalize_editor_video_request(EditorVideoGenerateRequest { + prompt: "生成镜头。".to_string(), + model: EDITOR_VIDEO_MODEL_VEO_3_1.to_string(), + aspect_ratio: "16:9".to_string(), + duration_seconds: 5, + resolution: "480p".to_string(), + mode: "std".to_string(), + sound: "off".to_string(), + price_mud_points: 40, + }) + .expect_err("wrong price should fail"); + + assert!(error.body_text().contains("priceMudPoints")); } } diff --git a/server-rs/crates/api-server/src/editor_generation_config.rs b/server-rs/crates/api-server/src/editor_generation_config.rs new file mode 100644 index 000000000..1fb6ad586 --- /dev/null +++ b/server-rs/crates/api-server/src/editor_generation_config.rs @@ -0,0 +1,72 @@ +/// 图片画布编辑器生成类能力的泥点配置。 +/// +/// 中文注释:先用 api-server 静态配置收口价格事实源,避免继续把价格散落在 +/// 前端常量和具体 handler 内;后续若接后台配置,可只替换本模块读取来源。 +const EDITOR_IMAGE_GENERATION_MUD_POINTS: u32 = 12; +const EDITOR_SPEC_GENERATION_MUD_POINTS: u32 = 5; +const EDITOR_CHARACTER_IMAGE_GENERATION_MUD_POINTS: u32 = 12; +const EDITOR_ICON_SPRITESHEET_GENERATION_MUD_POINTS: u32 = 12; +const EDITOR_UI_DESIGN_GENERATION_MUD_POINTS: u32 = 12; + +pub(crate) const EDITOR_VIDEO_GENERATION_480P_MUD_POINTS_PER_SECOND: u32 = 10; +pub(crate) const EDITOR_VIDEO_GENERATION_720P_MUD_POINTS_PER_SECOND: u32 = 20; + +pub(crate) const EDITOR_CHARACTER_ANIMATION_480P_MUD_POINTS_PER_SECOND: u32 = 10; +pub(crate) const EDITOR_CHARACTER_ANIMATION_720P_MUD_POINTS_PER_SECOND: u32 = 20; + +pub(crate) fn editor_image_generation_mud_points(kind: Option<&str>) -> u32 { + match kind.map(str::trim) { + Some("spec") => EDITOR_SPEC_GENERATION_MUD_POINTS, + Some("character") => EDITOR_CHARACTER_IMAGE_GENERATION_MUD_POINTS, + Some("icon") => EDITOR_ICON_SPRITESHEET_GENERATION_MUD_POINTS, + Some("ui-design") => EDITOR_UI_DESIGN_GENERATION_MUD_POINTS, + _ => EDITOR_IMAGE_GENERATION_MUD_POINTS, + } +} + +pub(crate) fn editor_video_generation_mud_points(resolution: &str, duration_seconds: u32) -> u32 { + let per_second = if resolution == "720p" { + EDITOR_VIDEO_GENERATION_720P_MUD_POINTS_PER_SECOND + } else { + EDITOR_VIDEO_GENERATION_480P_MUD_POINTS_PER_SECOND + }; + per_second * duration_seconds +} + +pub(crate) fn editor_character_animation_mud_points( + resolution: &str, + duration_seconds: u32, +) -> u32 { + let per_second = if resolution == "720p" { + EDITOR_CHARACTER_ANIMATION_720P_MUD_POINTS_PER_SECOND + } else { + EDITOR_CHARACTER_ANIMATION_480P_MUD_POINTS_PER_SECOND + }; + per_second * duration_seconds +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn editor_character_animation_price_uses_configured_resolution_rates() { + assert_eq!(editor_character_animation_mud_points("480p", 4), 40); + assert_eq!(editor_character_animation_mud_points("720p", 6), 120); + } + + #[test] + fn editor_video_generation_price_uses_configured_resolution_rates() { + assert_eq!(editor_video_generation_mud_points("480p", 4), 40); + assert_eq!(editor_video_generation_mud_points("720p", 5), 100); + } + + #[test] + fn editor_image_generation_price_uses_configured_kind_rates() { + assert_eq!(editor_image_generation_mud_points(None), 12); + assert_eq!(editor_image_generation_mud_points(Some("spec")), 5); + assert_eq!(editor_image_generation_mud_points(Some("character")), 12); + assert_eq!(editor_image_generation_mud_points(Some("icon")), 12); + assert_eq!(editor_image_generation_mud_points(Some("ui-design")), 12); + } +} diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index 5e002c9ac..b979116bf 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -42,7 +42,8 @@ use crate::{ openai_image_generation::{ DownloadedOpenAiImage, GPT_IMAGE_2_MODEL, OpenAiReferenceImage, build_openai_image_http_client, create_openai_image_edit_with_references, - create_openai_image_edit_with_references_and_model, create_openai_image_generation, + create_openai_image_edit_with_references_and_model, + create_openai_image_generation_with_model, create_openai_nanobanana_generate_content, require_openai_image_settings, }, platform_errors::map_oss_error, @@ -57,9 +58,7 @@ const EDITOR_ASSET_ID_PREFIX: &str = "editor-asset-"; const EDITOR_LAYOUT_MAX_BYTES: usize = 256 * 1024; const EDITOR_PROJECT_DEFAULT_TITLE: &str = "未命名画布"; const EDITOR_IMAGE_GENERATION_SIZE: &str = "1024x1024"; -const EDITOR_ICON_SPRITESHEET_MODEL: &str = "gemini-3.1-flash-image-preview"; -const EDITOR_ICON_SPRITESHEET_SMALL_SIZE: &str = "512x512"; -const EDITOR_ICON_SPRITESHEET_LARGE_SIZE: &str = "1024x1024"; +const EDITOR_IMAGE_MODEL_NANOBANANA2: &str = "gemini-3.1-flash-image-preview"; const EDITOR_ICON_DESCRIPTION_LIMIT: usize = 100; const EDITOR_CHARACTER_IMAGE_ASSET_KIND: &str = "editor_character_image"; const EDITOR_CHARACTER_IMAGE_ENTITY_KIND: &str = "editor_project"; @@ -155,6 +154,8 @@ pub struct EditorImageGenerationRequest { size: Option, kind: Option, model: Option, + aspect_ratio: Option, + image_size: Option, reference_image_srcs: Option>, } @@ -171,6 +172,8 @@ pub struct EditorIconSpritesheetGenerationRequest { reference_image_src: String, icon_descriptions: Vec, model: Option, + aspect_ratio: Option, + image_size: Option, } #[derive(Debug, Serialize)] @@ -238,7 +241,7 @@ pub struct EditorImageGenerationResponse { source_type: &'static str, prompt: String, actual_prompt: Option, - model: &'static str, + model: String, provider: &'static str, task_id: String, } @@ -757,12 +760,33 @@ pub async fn generate_editor_image( ); } - let image_size = normalize_editor_image_generation_size(payload.size.as_deref()); - let _requested_model = payload.model.as_deref(); + let generation_options = normalize_editor_generation_options( + payload.model.as_deref(), + payload.aspect_ratio.as_deref(), + payload.image_size.as_deref(), + ); + let has_dimension_options = payload.aspect_ratio.is_some() || payload.image_size.is_some(); + let legacy_size = normalize_editor_image_generation_size(payload.size.as_deref()); + let image_size = if has_dimension_options { + Cow::Owned(generation_options.size.clone()) + } else { + legacy_size + }; let normalized_kind = payload.kind.as_deref().map(str::trim); + let _configured_price_mud_points = + crate::editor_generation_config::editor_image_generation_mud_points(normalized_kind); let is_character_generation = matches!(normalized_kind, Some("character")); + let is_ui_design_generation = matches!(normalized_kind, Some("ui-design")); let submitted_prompt = if is_character_generation { build_editor_character_image_prompt(role_setting.as_str()) + } else if is_ui_design_generation { + build_editor_ui_design_prompt( + role_setting.as_str(), + payload + .reference_image_srcs + .as_ref() + .is_some_and(|references| references.iter().any(|source| !source.trim().is_empty())), + ) } else { role_setting.clone() }; @@ -770,6 +794,7 @@ pub async fn generate_editor_image( Some("character") => "图片画布生成角色形象", Some("spec") => "图片画布生成规范", Some("quick-edit") => "图片画布快速编辑图片", + Some("ui-design") => "图片画布生成UI设计图", _ => "图片画布生成图片", }; let reference_sources = payload @@ -787,10 +812,46 @@ pub async fn generate_editor_image( ); let http_client = build_openai_image_http_client(&settings)?; let negative_prompt = Some("文字、水印、边框、按钮、UI 控件、低清晰度、变形主体"); - let generated = if reference_sources.is_empty() { - create_openai_image_generation( + let reference_images = if reference_sources.is_empty() { + Vec::new() + } else { + reference_sources + .iter() + .map(|source| parse_editor_reference_image(source.as_str())) + .collect::, _>>()? + }; + let generation_options = if is_ui_design_generation { + normalize_editor_generation_options( + Some(GPT_IMAGE_2_MODEL), + payload.aspect_ratio.as_deref(), + payload.image_size.as_deref(), + ) + } else { + generation_options + }; + let image_size = if is_ui_design_generation { + Cow::Owned(generation_options.size.clone()) + } else { + image_size + }; + let generated = if generation_options.model == EDITOR_IMAGE_MODEL_NANOBANANA2 { + create_openai_nanobanana_generate_content( &http_client, &settings, + generation_options.model, + submitted_prompt.as_str(), + negative_prompt, + generation_options.aspect_ratio, + generation_options.provider_image_size, + reference_images.as_slice(), + failure_context, + ) + .await? + } else if reference_images.is_empty() { + create_openai_image_generation_with_model( + &http_client, + &settings, + generation_options.model, submitted_prompt.as_str(), negative_prompt, image_size.as_ref(), @@ -800,13 +861,10 @@ pub async fn generate_editor_image( ) .await? } else { - let reference_images = reference_sources - .iter() - .map(|source| parse_editor_reference_image(source.as_str())) - .collect::, _>>()?; - create_openai_image_edit_with_references( + create_openai_image_edit_with_references_and_model( &http_client, &settings, + generation_options.model, submitted_prompt.as_str(), negative_prompt, image_size.as_ref(), @@ -863,7 +921,7 @@ pub async fn generate_editor_image( source_type: "generated", prompt: role_setting, actual_prompt: generated.actual_prompt, - model: GPT_IMAGE_2_MODEL, + model: generation_options.model.to_string(), provider: "VectorEngine", task_id: generated.task_id, }, @@ -895,6 +953,96 @@ fn is_editor_custom_image_size(value: &str) -> bool { (64..=4096).contains(&width) && (64..=4096).contains(&height) } +fn normalize_editor_generation_options( + model: Option<&str>, + aspect_ratio: Option<&str>, + image_size: Option<&str>, +) -> EditorGenerationOptions { + let normalized_model = match model.map(str::trim).filter(|value| !value.is_empty()) { + Some(GPT_IMAGE_2_MODEL) => GPT_IMAGE_2_MODEL, + Some(EDITOR_IMAGE_MODEL_NANOBANANA2) => EDITOR_IMAGE_MODEL_NANOBANANA2, + // 中文注释:未显式传模型的旧普通生成、快速编辑和生成规范继续走 gpt-image-2; + // 角色 / 图标素材入口由前端显式传入 nanobanana2 默认值。 + None => GPT_IMAGE_2_MODEL, + _ => EDITOR_IMAGE_MODEL_NANOBANANA2, + }; + let aspect_ratio = normalize_editor_generation_aspect_ratio(aspect_ratio); + let image_size = normalize_editor_generation_image_size(normalized_model, image_size); + let size = editor_generation_size_for_model(normalized_model, aspect_ratio, image_size); + let provider_image_size = + editor_generation_provider_image_size_for_model(normalized_model, image_size); + + EditorGenerationOptions { + model: normalized_model, + aspect_ratio, + image_size, + size, + provider_image_size, + } +} + +fn normalize_editor_generation_aspect_ratio(aspect_ratio: Option<&str>) -> &'static str { + match aspect_ratio + .map(str::trim) + .filter(|value| !value.is_empty()) + { + Some("2:3") => "2:3", + Some("3:2") => "3:2", + Some("9:16") => "9:16", + Some("16:9") => "16:9", + _ => "1:1", + } +} + +fn normalize_editor_generation_image_size(model: &str, image_size: Option<&str>) -> &'static str { + match ( + model, + image_size.map(str::trim).filter(|value| !value.is_empty()), + ) { + (EDITOR_IMAGE_MODEL_NANOBANANA2, Some("0.5K")) => "0.5K", + (_, Some("2K")) => "2K", + _ => "1K", + } +} + +fn editor_generation_size_for_model(model: &str, aspect_ratio: &str, image_size: &str) -> String { + if model == EDITOR_IMAGE_MODEL_NANOBANANA2 { + return match image_size { + // 中文注释:VectorEngine 的 nanobanana2 文档要求 0.5K 传入 512。 + "0.5K" => "512", + "2K" => "2048", + _ => "1024", + } + .to_string(); + } + + match (image_size, aspect_ratio) { + ("2K", "1:1") => "2048x2048", + // 中文注释:gpt-image-2 文档未列出 2K 竖版,竖版选择回落到文档明确支持的 1K 竖版。 + ("2K", "2:3") | ("2K", "9:16") => "1024x1536", + ("2K", "16:9") | ("2K", "3:2") => "2048x1152", + ("1K", "2:3") | ("1K", "9:16") => "1024x1536", + ("1K", "3:2") | ("1K", "16:9") => "1536x1024", + _ => "1024x1024", + } + .to_string() +} + +fn editor_generation_provider_image_size_for_model( + model: &str, + image_size: &'static str, +) -> &'static str { + if model == EDITOR_IMAGE_MODEL_NANOBANANA2 { + return match image_size { + "0.5K" => "512", + "2K" => "2K", + _ => "1K", + }; + } + + image_size +} + pub async fn edit_editor_image( State(state): State, Extension(request_context): Extension, @@ -955,7 +1103,7 @@ pub async fn edit_editor_image( source_type: "generated", prompt, actual_prompt: generated.actual_prompt, - model: GPT_IMAGE_2_MODEL, + model: GPT_IMAGE_2_MODEL.to_string(), provider: "VectorEngine", task_id: generated.task_id, }, @@ -977,14 +1125,12 @@ pub async fn generate_editor_icon_spritesheet( "message": "图标素材规范必须是图片 Data URL。", })) })?; - let model = payload - .model - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .unwrap_or(EDITOR_ICON_SPRITESHEET_MODEL) - .to_string(); - let size = editor_icon_spritesheet_size_for_count(icon_descriptions.len()); + let generation_options = normalize_editor_generation_options( + payload.model.as_deref(), + payload.aspect_ratio.as_deref(), + payload.image_size.as_deref(), + ); + let size = generation_options.size.as_str(); let prompt = build_editor_icon_spritesheet_prompt(&icon_descriptions); let settings = require_openai_image_settings(&state)?.with_external_api_audit_context( @@ -993,18 +1139,33 @@ pub async fn generate_editor_icon_spritesheet( None, ); let http_client = build_openai_image_http_client(&settings)?; - let generated = create_openai_image_edit_with_references_and_model( - &http_client, - &settings, - model.as_str(), - prompt.as_str(), - None, - size, - 1, - &[reference_image], - "图片画布生成图标素材 spritesheet", - ) - .await?; + let generated = if generation_options.model == EDITOR_IMAGE_MODEL_NANOBANANA2 { + create_openai_nanobanana_generate_content( + &http_client, + &settings, + generation_options.model, + prompt.as_str(), + None, + generation_options.aspect_ratio, + generation_options.provider_image_size, + &[reference_image], + "图片画布生成图标素材 spritesheet", + ) + .await? + } else { + create_openai_image_edit_with_references_and_model( + &http_client, + &settings, + generation_options.model, + prompt.as_str(), + None, + size, + 1, + &[reference_image], + "图片画布生成图标素材 spritesheet", + ) + .await? + }; let image = generated.images.into_iter().next().ok_or_else(|| { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "vector-engine", @@ -1045,7 +1206,7 @@ pub async fn generate_editor_icon_spritesheet( icon_image_srcs, prompt, actual_prompt: generated.actual_prompt, - model, + model: generation_options.model.to_string(), provider: "VectorEngine", task_id: generated.task_id, }, @@ -1249,6 +1410,17 @@ fn build_editor_icon_spritesheet_prompt(icon_descriptions: &[String]) -> String ) } +fn build_editor_ui_design_prompt(user_input: &str, has_icon_spec_reference: bool) -> String { + let mut prompt = vec![ + "生成玩法UI原型图".to_string(), + format!("【用户输入】{}", user_input.trim()), + ]; + if has_icon_spec_reference { + prompt.push("参考图1为图标素材规范,请在UI图标、按钮符号、描边、材质、圆角、阴影和状态层级上严格遵循参考图1的素材规范。".to_string()); + } + prompt.join("\n") +} + fn build_editor_character_image_prompt(role_setting: &str) -> String { vec![ "严格基于图1的角色美术视觉规范指导中的美术风格、角色头身比、角色朝向等特征生成游戏角色形象图。画面中心构图,角色主体完整置于画面中央,禁止镜头透视,禁止特写。背景固定为纯绿色绿幕,只作为抠像底色,禁止生成美术视觉规范、出现建筑、室内布景、风景、地面道具、漂浮物、烟雾叙事元素、文字或其他角色以外的场景内容。".to_string(), @@ -1295,14 +1467,6 @@ fn prepare_editor_character_image_for_response( } } -fn editor_icon_spritesheet_size_for_count(icon_count: usize) -> &'static str { - if icon_count <= 25 { - EDITOR_ICON_SPRITESHEET_SMALL_SIZE - } else { - EDITOR_ICON_SPRITESHEET_LARGE_SIZE - } -} - fn data_url_from_image_bytes(mime_type: &str, bytes: &[u8]) -> String { format!( "data:{};base64,{}", @@ -1322,6 +1486,15 @@ fn editor_icon_response_from_slice( } } +#[derive(Debug, Clone, PartialEq, Eq)] +struct EditorGenerationOptions { + model: &'static str, + aspect_ratio: &'static str, + image_size: &'static str, + size: String, + provider_image_size: &'static str, +} + struct PersistedEditorGeneratedImage { object_key: String, asset_object_id: String, @@ -1548,6 +1721,68 @@ mod tests { ); } + #[test] + fn editor_generation_dimensions_follow_model_options() { + let default_generation = normalize_editor_generation_options(None, Some("1:1"), Some("1K")); + assert_eq!(default_generation.model, GPT_IMAGE_2_MODEL); + assert_eq!(default_generation.size, "1024x1024"); + + let nanobanana = normalize_editor_generation_options( + Some(EDITOR_IMAGE_MODEL_NANOBANANA2), + Some("1:1"), + Some("0.5K"), + ); + assert_eq!(nanobanana.model, EDITOR_IMAGE_MODEL_NANOBANANA2); + assert_eq!(nanobanana.size, "512"); + assert_eq!(nanobanana.aspect_ratio, "1:1"); + assert_eq!(nanobanana.image_size, "0.5K"); + assert_eq!(nanobanana.provider_image_size, "512"); + + let gpt = normalize_editor_generation_options(Some("gpt-image-2"), Some("2:3"), Some("1K")); + assert_eq!(gpt.model, GPT_IMAGE_2_MODEL); + assert_eq!(gpt.size, "1024x1536"); + assert_eq!(gpt.aspect_ratio, "2:3"); + assert_eq!(gpt.image_size, "1K"); + assert_eq!(gpt.provider_image_size, "1K"); + + let gpt_landscape_2k = + normalize_editor_generation_options(Some("gpt-image-2"), Some("16:9"), Some("2K")); + assert_eq!(gpt_landscape_2k.model, GPT_IMAGE_2_MODEL); + assert_eq!(gpt_landscape_2k.size, "2048x1152"); + assert_eq!(gpt_landscape_2k.aspect_ratio, "16:9"); + assert_eq!(gpt_landscape_2k.image_size, "2K"); + + let gpt_portrait_2k_fallback = + normalize_editor_generation_options(Some("gpt-image-2"), Some("9:16"), Some("2K")); + assert_eq!(gpt_portrait_2k_fallback.model, GPT_IMAGE_2_MODEL); + assert_eq!(gpt_portrait_2k_fallback.size, "1024x1536"); + assert_eq!(gpt_portrait_2k_fallback.aspect_ratio, "9:16"); + assert_eq!(gpt_portrait_2k_fallback.image_size, "2K"); + + let fallback = normalize_editor_generation_options( + Some("unknown-model"), + Some("bad-ratio"), + Some("bad-size"), + ); + assert_eq!(fallback.model, EDITOR_IMAGE_MODEL_NANOBANANA2); + assert_eq!(fallback.size, "1024"); + assert_eq!(fallback.aspect_ratio, "1:1"); + assert_eq!(fallback.image_size, "1K"); + assert_eq!(fallback.provider_image_size, "1K"); + } + + #[test] + fn editor_ui_design_prompt_uses_fixed_user_input_block_and_optional_icon_spec() { + let prompt = build_editor_ui_design_prompt("二消玩法,主界面和结算弹窗", true); + + assert!(prompt.contains("生成玩法UI原型图")); + assert!(prompt.contains("【用户输入】二消玩法,主界面和结算弹窗")); + assert!(prompt.contains("参考图1为图标素材规范")); + + let no_reference_prompt = build_editor_ui_design_prompt("只要战斗HUD", false); + assert!(!no_reference_prompt.contains("参考图1为图标素材规范")); + } + #[test] fn editor_character_image_prompt_appends_user_role_setting() { let prompt = build_editor_character_image_prompt("菜市场卖菜大妈"); @@ -1631,8 +1866,6 @@ mod tests { assert!(prompt.contains("参考图1的图标素材规范")); assert!(prompt.contains("纯绿幕背景方便扣除背景")); assert!(prompt.contains("返回按钮、设置按钮、下一关按钮")); - assert_eq!(editor_icon_spritesheet_size_for_count(25), "512x512"); - assert_eq!(editor_icon_spritesheet_size_for_count(26), "1024x1024"); } #[test] diff --git a/server-rs/crates/api-server/src/main.rs b/server-rs/crates/api-server/src/main.rs index ec6d4451f..7f51e5a64 100644 --- a/server-rs/crates/api-server/src/main.rs +++ b/server-rs/crates/api-server/src/main.rs @@ -36,6 +36,7 @@ mod custom_world_asset_prompts; mod custom_world_foundation_draft; mod custom_world_result_prompts; mod custom_world_rpg_draft_prompts; +mod editor_generation_config; mod editor_project; mod edutainment_baby_drawing; mod edutainment_baby_object; diff --git a/server-rs/crates/api-server/src/modules/play_flow.rs b/server-rs/crates/api-server/src/modules/play_flow.rs index bc800e299..f80609751 100644 --- a/server-rs/crates/api-server/src/modules/play_flow.rs +++ b/server-rs/crates/api-server/src/modules/play_flow.rs @@ -16,7 +16,7 @@ use crate::{ assets::get_asset_history, auth::require_bearer_auth, character_animation_assets::{ - generate_character_animation, generate_editor_character_animation, + generate_character_animation, generate_editor_character_animation, generate_editor_video, get_character_animation_job, get_character_workflow_cache, import_character_animation_video, list_character_animation_templates, publish_character_animation, put_role_asset_workflow, resolve_role_asset_workflow, @@ -461,6 +461,13 @@ fn play_flow_support_router(state: AppState) -> Router { EDITOR_CHARACTER_ANIMATION_BODY_LIMIT_BYTES, )), ) + .route( + "/api/editor/videos/generations", + post(generate_editor_video).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) .route( "/api/assets/character-animation/jobs/{task_id}", get(get_character_animation_job), diff --git a/server-rs/crates/api-server/src/openai_image_generation.rs b/server-rs/crates/api-server/src/openai_image_generation.rs index 987896fb8..3742350c7 100644 --- a/server-rs/crates/api-server/src/openai_image_generation.rs +++ b/server-rs/crates/api-server/src/openai_image_generation.rs @@ -3,7 +3,9 @@ use platform_image::{ DownloadedImage, GeneratedImages, PlatformImageError, PlatformImageStatusHint, ReferenceImage, VECTOR_ENGINE_PROVIDER, VectorEngineImageSettings, build_vector_engine_image_http_client, create_vector_engine_image_edit, create_vector_engine_image_edit_with_references, - create_vector_engine_image_edit_with_references_and_model, create_vector_engine_image_generation, + create_vector_engine_image_edit_with_references_and_model, + create_vector_engine_image_generation, create_vector_engine_image_generation_with_model, + create_vector_engine_nanobanana_generate_content, }; #[cfg(test)] use platform_image::{ @@ -159,6 +161,94 @@ pub(crate) async fn create_openai_image_generation( .await } +#[allow(clippy::too_many_arguments)] +pub(crate) async fn create_openai_image_generation_with_model( + http_client: &reqwest::Client, + settings: &OpenAiImageSettings, + model: &str, + prompt: &str, + negative_prompt: Option<&str>, + size: &str, + candidate_count: u32, + reference_images: &[String], + failure_context: &str, +) -> Result { + let started_at_micros = current_utc_micros(); + let request_payload = json!({ + "model": model, + "size": size, + "candidateCount": candidate_count, + "promptChars": prompt.chars().count(), + "negativePromptChars": negative_prompt.map(str::chars).map(Iterator::count), + "referenceImageCount": reference_images.len(), + }); + let result = create_vector_engine_image_generation_with_model( + http_client, + &settings.provider_settings(), + model, + prompt, + negative_prompt, + size, + candidate_count, + reference_images, + failure_context, + ) + .await; + map_platform_image_result( + settings, + result, + "image_generation", + failure_context, + request_payload, + started_at_micros, + ) + .await +} + +#[allow(clippy::too_many_arguments)] +pub(crate) async fn create_openai_nanobanana_generate_content( + http_client: &reqwest::Client, + settings: &OpenAiImageSettings, + model: &str, + prompt: &str, + negative_prompt: Option<&str>, + aspect_ratio: &str, + image_size: &str, + reference_images: &[OpenAiReferenceImage], + failure_context: &str, +) -> Result { + let started_at_micros = current_utc_micros(); + let request_payload = json!({ + "model": model, + "aspectRatio": aspect_ratio, + "imageSize": image_size, + "promptChars": prompt.chars().count(), + "negativePromptChars": negative_prompt.map(str::chars).map(Iterator::count), + "referenceImageCount": reference_images.len(), + }); + let result = create_vector_engine_nanobanana_generate_content( + http_client, + &settings.provider_settings(), + model, + prompt, + negative_prompt, + aspect_ratio, + image_size, + reference_images, + failure_context, + ) + .await; + map_platform_image_result( + settings, + result, + "nanobanana_generate_content", + failure_context, + request_payload, + started_at_micros, + ) + .await +} + pub(crate) async fn create_openai_image_edit( http_client: &reqwest::Client, settings: &OpenAiImageSettings, diff --git a/server-rs/crates/platform-image/src/lib.rs b/server-rs/crates/platform-image/src/lib.rs index 267a08492..4e7aaa896 100644 --- a/server-rs/crates/platform-image/src/lib.rs +++ b/server-rs/crates/platform-image/src/lib.rs @@ -7,8 +7,11 @@ pub use vector_engine::{ PlatformImageFailureAudit, PlatformImageStatusHint, ReferenceImage, VECTOR_ENGINE_GPT_IMAGE_2_MODEL, VECTOR_ENGINE_PROVIDER, VectorEngineImageSettings, build_vector_engine_image_http_client, build_vector_engine_image_request_body, - create_vector_engine_image_edit, create_vector_engine_image_edit_with_references, + build_vector_engine_nanobanana_generate_content_request_body, create_vector_engine_image_edit, + create_vector_engine_image_edit_with_references, create_vector_engine_image_edit_with_references_and_model, create_vector_engine_image_generation, create_vector_engine_image_generation_with_model, - download_remote_image, vector_engine_images_edit_url, vector_engine_images_generation_url, + create_vector_engine_nanobanana_generate_content, download_remote_image, + vector_engine_images_edit_url, vector_engine_images_generation_url, + vector_engine_nanobanana_generate_content_url, }; diff --git a/server-rs/crates/platform-image/src/vector_engine/client.rs b/server-rs/crates/platform-image/src/vector_engine/client.rs index e65d08ee5..e5024124a 100644 --- a/server-rs/crates/platform-image/src/vector_engine/client.rs +++ b/server-rs/crates/platform-image/src/vector_engine/client.rs @@ -14,9 +14,10 @@ use super::{ image_source::resolve_reference_images, request::{ build_vector_engine_image_edit_request_log_params, - build_vector_engine_image_request_body_with_model, normalize_image_size, + build_vector_engine_image_request_body_with_model, + build_vector_engine_nanobanana_generate_content_request_body, normalize_image_size, normalize_vector_engine_image_model, vector_engine_images_edit_url, - vector_engine_images_generation_url, + vector_engine_images_generation_url, vector_engine_nanobanana_generate_content_url, }, response::handle_vector_engine_response, types::{GeneratedImages, ReferenceImage, VectorEngineImageSettings}, @@ -181,6 +182,144 @@ pub async fn create_vector_engine_image_generation_with_model( .await } +#[allow(clippy::too_many_arguments)] +pub async fn create_vector_engine_nanobanana_generate_content( + http_client: &reqwest::Client, + settings: &VectorEngineImageSettings, + model: &str, + prompt: &str, + negative_prompt: Option<&str>, + aspect_ratio: &str, + image_size: &str, + reference_images: &[ReferenceImage], + failure_context: &str, +) -> Result { + let model = normalize_vector_engine_image_model(model); + let request_url = vector_engine_nanobanana_generate_content_url(settings, model); + let request_body = build_vector_engine_nanobanana_generate_content_request_body( + prompt, + negative_prompt, + aspect_ratio, + image_size, + reference_images, + ); + let reference_image_count = reference_images.iter().take(14).count(); + let reference_image_bytes_total: usize = reference_images + .iter() + .take(14) + .map(|image| image.bytes.len()) + .sum(); + let request_params = serde_json::json!({ + "model": model, + "promptChars": prompt.trim().chars().count(), + "negativePromptChars": negative_prompt + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::chars) + .map(Iterator::count) + .unwrap_or_default(), + "aspectRatio": aspect_ratio, + "imageSize": image_size, + "referenceImageCount": reference_image_count, + "referenceImageBytesTotal": reference_image_bytes_total, + }); + let started_at = std::time::Instant::now(); + let mut attempt = 1; + let response = loop { + match send_vector_engine_json_request_with_curl( + request_url.as_str(), + settings.api_key.as_str(), + &request_body, + settings.request_timeout_ms, + ) + .await + { + Ok(response) => { + if should_retry_vector_engine_upstream_status(response.status, attempt) { + retry_vector_engine_upstream_status_after_delay( + "nanobanana_generate_content", + request_url.as_str(), + attempt, + response.status, + response.body.as_str(), + started_at.elapsed().as_millis() as u64, + Some(prompt.chars().count()), + Some(reference_image_count), + Some(&request_params), + ) + .await; + attempt += 1; + continue; + } + break response; + } + Err(error) => { + if should_retry_vector_engine_curl_send_error(&error, attempt) { + retry_vector_engine_send_after_delay( + "nanobanana_generate_content", + request_url.as_str(), + "request_send", + attempt, + error.is_timeout(), + error.is_connect() || error.is_transient_transport(), + true, + false, + error.to_string().as_str(), + started_at.elapsed().as_millis() as u64, + Some(prompt.chars().count()), + Some(reference_image_count), + Some(&request_params), + ) + .await; + attempt += 1; + continue; + } + return Err(map_curl_error( + format!("{failure_context}:创建 nanobanana2 图片生成任务失败").as_str(), + request_url.as_str(), + "request_send", + error, + started_at.elapsed().as_millis() as u64, + Some(prompt.chars().count()), + Some(reference_image_count), + Some(&request_params), + )); + } + } + }; + let response_status = response.status; + tracing::info!( + provider = VECTOR_ENGINE_PROVIDER, + endpoint = %request_url, + status = response_status, + image_model = model, + prompt_chars = prompt.chars().count(), + aspect_ratio, + image_size, + reference_image_count, + reference_image_bytes_total, + request_params = %request_params, + attempt, + elapsed_ms = started_at.elapsed().as_millis() as u64, + failure_context, + "VectorEngine nanobanana2 图片生成 HTTP 返回" + ); + let response_text = response.body; + handle_vector_engine_response( + http_client, + request_url.as_str(), + response_status, + response_text.as_str(), + failure_context, + started_at.elapsed().as_millis() as u64, + Some(prompt.chars().count()), + Some(reference_image_count), + 1, + "vector-engine-nanobanana", + ) + .await +} + pub async fn create_vector_engine_image_edit( http_client: &reqwest::Client, settings: &VectorEngineImageSettings, diff --git a/server-rs/crates/platform-image/src/vector_engine/mod.rs b/server-rs/crates/platform-image/src/vector_engine/mod.rs index 698be30bc..f8d953e8a 100644 --- a/server-rs/crates/platform-image/src/vector_engine/mod.rs +++ b/server-rs/crates/platform-image/src/vector_engine/mod.rs @@ -16,13 +16,16 @@ pub use client::{ create_vector_engine_image_edit, create_vector_engine_image_edit_with_references, create_vector_engine_image_edit_with_references_and_model, create_vector_engine_image_generation, create_vector_engine_image_generation_with_model, + create_vector_engine_nanobanana_generate_content, }; pub use constants::{GPT_IMAGE_2_MODEL, VECTOR_ENGINE_GPT_IMAGE_2_MODEL, VECTOR_ENGINE_PROVIDER}; pub use error::{PlatformImageError, PlatformImageStatusHint}; pub use image_source::download_remote_image; pub use request::{ build_vector_engine_image_request_body, build_vector_engine_image_request_body_with_model, - normalize_image_size, vector_engine_images_edit_url, vector_engine_images_generation_url, + build_vector_engine_nanobanana_generate_content_request_body, normalize_image_size, + vector_engine_images_edit_url, vector_engine_images_generation_url, + vector_engine_nanobanana_generate_content_url, }; pub use transport::build_vector_engine_image_http_client; pub use types::{DownloadedImage, GeneratedImages, ReferenceImage, VectorEngineImageSettings}; diff --git a/server-rs/crates/platform-image/src/vector_engine/payload.rs b/server-rs/crates/platform-image/src/vector_engine/payload.rs index f7b4a8e68..9f5130a62 100644 --- a/server-rs/crates/platform-image/src/vector_engine/payload.rs +++ b/server-rs/crates/platform-image/src/vector_engine/payload.rs @@ -88,9 +88,48 @@ pub(super) fn extract_image_urls(payload: &Value) -> Vec { pub(super) fn extract_b64_images(payload: &Value) -> Vec { let mut values = Vec::new(); collect_strings_by_key(payload, "b64_json", &mut values); + collect_inline_image_data(payload, &mut values); values } +fn collect_inline_image_data(value: &Value, results: &mut Vec) { + match value { + Value::Array(entries) => { + for entry in entries { + collect_inline_image_data(entry, results); + } + } + Value::Object(object) => { + for key in ["inlineData", "inline_data"] { + if let Some(Value::Object(inline_data)) = object.get(key) { + let mime_type = inline_data + .get("mimeType") + .or_else(|| inline_data.get("mime_type")) + .and_then(Value::as_str) + .map(str::trim) + .unwrap_or("image/png") + .to_ascii_lowercase(); + if !mime_type.is_empty() && !mime_type.starts_with("image/") { + continue; + } + if let Some(data) = inline_data + .get("data") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + { + results.push(data.to_string()); + } + } + } + for nested_value in object.values() { + collect_inline_image_data(nested_value, results); + } + } + _ => {} + } +} + pub(super) fn parse_api_error_message(raw_text: &str, fallback_message: &str) -> String { if raw_text.trim().is_empty() { return fallback_message.to_string(); diff --git a/server-rs/crates/platform-image/src/vector_engine/request.rs b/server-rs/crates/platform-image/src/vector_engine/request.rs index 26219012c..930dc159c 100644 --- a/server-rs/crates/platform-image/src/vector_engine/request.rs +++ b/server-rs/crates/platform-image/src/vector_engine/request.rs @@ -32,10 +32,7 @@ pub fn build_vector_engine_image_request_body_with_model( ) -> Value { let model = normalize_vector_engine_image_model(model); let body = Map::from_iter([ - ( - "model".to_string(), - Value::String(model.to_string()), - ), + ("model".to_string(), Value::String(model.to_string())), ( "prompt".to_string(), Value::String(build_prompt_with_negative(prompt, negative_prompt)), @@ -50,6 +47,42 @@ pub fn build_vector_engine_image_request_body_with_model( Value::Object(body) } +pub fn build_vector_engine_nanobanana_generate_content_request_body( + prompt: &str, + negative_prompt: Option<&str>, + aspect_ratio: &str, + image_size: &str, + reference_images: &[ReferenceImage], +) -> Value { + let prompt = build_prompt_with_negative(prompt, negative_prompt); + let mut parts = vec![json!({ "text": prompt })]; + for reference_image in reference_images.iter().take(14) { + parts.push(json!({ + "inline_data": { + "mime_type": reference_image.mime_type, + "data": base64::Engine::encode( + &base64::engine::general_purpose::STANDARD, + reference_image.bytes.as_slice() + ), + }, + })); + } + + json!({ + "contents": [{ + "role": "user", + "parts": parts, + }], + "generationConfig": { + "responseModalities": ["IMAGE"], + "imageConfig": { + "aspectRatio": normalize_nanobanana_aspect_ratio(aspect_ratio), + "imageSize": normalize_nanobanana_image_size(image_size), + }, + }, + }) +} + pub fn normalize_vector_engine_image_model(model: &str) -> &str { match model.trim() { "" => GPT_IMAGE_2_MODEL, @@ -71,6 +104,31 @@ pub fn normalize_image_size(size: &str) -> String { .to_string() } +fn normalize_nanobanana_aspect_ratio(aspect_ratio: &str) -> &str { + match aspect_ratio.trim() { + "2:3" => "2:3", + "3:2" => "3:2", + "3:4" => "3:4", + "4:3" => "4:3", + "4:5" => "4:5", + "5:4" => "5:4", + "9:16" => "9:16", + "16:9" => "16:9", + "21:9" => "21:9", + _ => "1:1", + } +} + +fn normalize_nanobanana_image_size(image_size: &str) -> &str { + match image_size.trim() { + // 中文注释:nanobanana / Gemini 3.1 的 0.5K 在 VectorEngine 文档中要求传 512。 + "512" | "0.5K" => "512", + "2K" => "2K", + "4K" => "4K", + _ => "1K", + } +} + pub fn vector_engine_images_generation_url(settings: &VectorEngineImageSettings) -> String { if settings.base_url.ends_with("/v1") { format!("{}/images/generations", settings.base_url) @@ -87,6 +145,21 @@ pub fn vector_engine_images_edit_url(settings: &VectorEngineImageSettings) -> St } } +pub fn vector_engine_nanobanana_generate_content_url( + settings: &VectorEngineImageSettings, + model: &str, +) -> String { + let base_url = settings + .base_url + .trim_end_matches("/v1") + .trim_end_matches('/'); + format!( + "{}/v1beta/models/{}:generateContent", + base_url, + normalize_vector_engine_image_model(model) + ) +} + pub(crate) fn build_vector_engine_image_edit_request_log_params( model: &str, prompt: &str, diff --git a/server-rs/crates/platform-image/src/vector_engine/tests.rs b/server-rs/crates/platform-image/src/vector_engine/tests.rs index 1f1d6691f..dc9a37ea6 100644 --- a/server-rs/crates/platform-image/src/vector_engine/tests.rs +++ b/server-rs/crates/platform-image/src/vector_engine/tests.rs @@ -66,8 +66,10 @@ mod tests { assert_eq!(reference.mime_type, "image/png"); assert_eq!(reference.bytes, b"\x89PNG\r\n\x1A\nrest"); - let image = decode_generated_image_base64(BASE64_STANDARD.encode(b"\x89PNG\r\n\x1A\nrest").as_str()) - .expect("base64 image should decode"); + let image = decode_generated_image_base64( + BASE64_STANDARD.encode(b"\x89PNG\r\n\x1A\nrest").as_str(), + ) + .expect("base64 image should decode"); assert_eq!(image.extension, "png"); assert_eq!(image.mime_type, "image/png"); assert_eq!(image.bytes, b"\x89PNG\r\n\x1A\nrest"); @@ -121,10 +123,22 @@ mod tests { audit: Some(audit.clone()), }; - assert_eq!(invalid_config.status_hint(), PlatformImageStatusHint::ServiceUnavailable); - assert_eq!(invalid_request.status_hint(), PlatformImageStatusHint::BadRequest); - assert_eq!(request_error.status_hint(), PlatformImageStatusHint::GatewayTimeout); - assert_eq!(upstream_timeout.status_hint(), PlatformImageStatusHint::GatewayTimeout); + assert_eq!( + invalid_config.status_hint(), + PlatformImageStatusHint::ServiceUnavailable + ); + assert_eq!( + invalid_request.status_hint(), + PlatformImageStatusHint::BadRequest + ); + assert_eq!( + request_error.status_hint(), + PlatformImageStatusHint::GatewayTimeout + ); + assert_eq!( + upstream_timeout.status_hint(), + PlatformImageStatusHint::GatewayTimeout + ); assert_eq!( PlatformImageError::MissingImage { provider: VECTOR_ENGINE_PROVIDER, @@ -137,7 +151,10 @@ mod tests { let audit_ref = upstream_timeout.audit().expect("audit should be preserved"); assert_eq!(audit_ref.provider, VECTOR_ENGINE_PROVIDER); - assert_eq!(audit_ref.endpoint, "https://vector.example/v1/images/generations"); + assert_eq!( + audit_ref.endpoint, + "https://vector.example/v1/images/generations" + ); assert_eq!(audit_ref.status_code, Some(504)); assert_eq!(audit_ref.status_class, Some("5xx")); assert!(audit_ref.timeout); @@ -158,7 +175,27 @@ mod tests { {"url": "https://example.com/b.png"} ], "nested": { - "b64_json": ["YWJj", "ZGVm"] + "b64_json": ["YWJj", "ZGVm"], + "parts": [ + { + "inlineData": { + "mimeType": "image/png", + "data": "aW1hZ2UtMQ==" + } + }, + { + "inline_data": { + "mime_type": "image/jpeg", + "data": "aW1hZ2UtMg==" + } + }, + { + "inlineData": { + "mimeType": "text/plain", + "data": "bm90LWltYWdl" + } + } + ] } }); @@ -171,7 +208,12 @@ mod tests { ); assert_eq!( extract_b64_images(&payload), - vec!["YWJj".to_string(), "ZGVm".to_string()] + vec![ + "YWJj".to_string(), + "ZGVm".to_string(), + "aW1hZ2UtMQ==".to_string(), + "aW1hZ2UtMg==".to_string(), + ] ); } } diff --git a/server-rs/crates/platform-image/tests/vector_engine.rs b/server-rs/crates/platform-image/tests/vector_engine.rs index 208a3b9b2..ec757c685 100644 --- a/server-rs/crates/platform-image/tests/vector_engine.rs +++ b/server-rs/crates/platform-image/tests/vector_engine.rs @@ -1,9 +1,11 @@ use platform_image::vector_engine::{ GPT_IMAGE_2_MODEL, ReferenceImage, VECTOR_ENGINE_PROVIDER, VectorEngineImageSettings, build_vector_engine_image_http_client, build_vector_engine_image_request_body, - build_vector_engine_image_request_body_with_model, create_vector_engine_image_edit, - create_vector_engine_image_generation, + build_vector_engine_image_request_body_with_model, + build_vector_engine_nanobanana_generate_content_request_body, create_vector_engine_image_edit, + create_vector_engine_image_generation, create_vector_engine_nanobanana_generate_content, vector_engine_images_edit_url, vector_engine_images_generation_url, + vector_engine_nanobanana_generate_content_url, }; use std::{ sync::{ @@ -69,6 +71,60 @@ fn vector_engine_request_body_can_use_nanobanana2_model() { assert_eq!(body["n"], 1); } +#[test] +fn vector_engine_request_body_can_use_nanobanana2_half_k() { + let body = build_vector_engine_image_request_body_with_model( + "gemini-3.1-flash-image-preview", + "生成图标 spritesheet", + None, + "512", + 1, + &[], + ); + + assert_eq!(body["model"], "gemini-3.1-flash-image-preview"); + assert_eq!(body["size"], "512"); +} + +#[test] +fn nanobanana_generate_content_body_carries_aspect_ratio_and_image_size() { + let body = build_vector_engine_nanobanana_generate_content_request_body( + "生成角色图", + Some("文字、水印"), + "2:3", + "512", + &[], + ); + + assert_eq!(body["contents"][0]["role"], "user"); + assert_eq!( + body["contents"][0]["parts"][0]["text"], + "生成角色图\n避免:文字、水印" + ); + assert_eq!(body["generationConfig"]["responseModalities"][0], "IMAGE"); + assert_eq!( + body["generationConfig"]["imageConfig"]["aspectRatio"], + "2:3" + ); + assert_eq!(body["generationConfig"]["imageConfig"]["imageSize"], "512"); + assert!(body.get("model").is_none()); + assert!(body.get("n").is_none()); +} + +#[test] +fn nanobanana_generate_content_url_uses_model_path() { + let settings = VectorEngineImageSettings { + base_url: "https://vector.example/v1".to_string(), + api_key: "test-key".to_string(), + request_timeout_ms: 1_000, + }; + + assert_eq!( + vector_engine_nanobanana_generate_content_url(&settings, "gemini-3.1-flash-image-preview"), + "https://vector.example/v1beta/models/gemini-3.1-flash-image-preview:generateContent" + ); +} + #[tokio::test] async fn vector_engine_image_edit_retries_send_timeout_once_and_succeeds() { let listener = TcpListener::bind("127.0.0.1:0") @@ -136,6 +192,73 @@ async fn vector_engine_image_edit_retries_send_timeout_once_and_succeeds() { server.abort(); } +#[tokio::test] +async fn nanobanana_generate_content_posts_native_body_and_reads_inline_data() { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("mock server should bind"); + let server_addr = listener + .local_addr() + .expect("mock server address should be readable"); + let server = tokio::spawn(async move { + let Ok((mut stream, _)) = listener.accept().await else { + return; + }; + let mut request = Vec::new(); + let mut buffer = [0_u8; 4096]; + loop { + let Ok(read) = stream.read(&mut buffer).await else { + return; + }; + if read == 0 { + return; + } + request.extend_from_slice(&buffer[..read]); + if request.windows(4).any(|window| window == b"\r\n\r\n") { + break; + } + } + let request_text = String::from_utf8_lossy(request.as_slice()); + assert!( + request_text.contains("/v1beta/models/gemini-3.1-flash-image-preview:generateContent") + ); + assert!(request_text.contains("\"aspectRatio\":\"2:3\"")); + assert!(request_text.contains("\"imageSize\":\"512\"")); + + let body = r#"{"candidates":[{"content":{"parts":[{"inlineData":{"mimeType":"image/png","data":"iVBORw0KGgpyZXN0"}}]}}]}"#; + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}", + body.len(), + body + ); + let _ = stream.write_all(response.as_bytes()).await; + }); + let settings = VectorEngineImageSettings { + base_url: format!("http://{}", server_addr), + api_key: "test-key".to_string(), + request_timeout_ms: 1_000, + }; + let client = build_vector_engine_image_http_client(&settings).expect("client should build"); + + let generated = create_vector_engine_nanobanana_generate_content( + &client, + &settings, + "gemini-3.1-flash-image-preview", + "生成角色图", + Some("文字、水印"), + "2:3", + "512", + &[], + "测试 nanobanana", + ) + .await + .expect("nanobanana response should parse"); + + assert_eq!(generated.images.len(), 1); + assert_eq!(generated.images[0].mime_type, "image/png"); + server.abort(); +} + #[tokio::test] async fn vector_engine_image_generation_retries_upstream_502_once_and_succeeds() { let listener = TcpListener::bind("127.0.0.1:0") diff --git a/server-rs/crates/shared-contracts/src/assets.rs b/server-rs/crates/shared-contracts/src/assets.rs index b414eb1de..a07cc8f85 100644 --- a/server-rs/crates/shared-contracts/src/assets.rs +++ b/server-rs/crates/shared-contracts/src/assets.rs @@ -346,6 +346,38 @@ pub struct EditorCharacterAnimationGenerateResponse { pub price_mud_points: u32, } +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct EditorVideoGenerateRequest { + pub prompt: String, + pub model: String, + pub aspect_ratio: String, + pub duration_seconds: u32, + pub resolution: String, + pub mode: String, + pub sound: String, + pub price_mud_points: u32, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct EditorVideoGenerateResponse { + pub ok: bool, + pub video_src: String, + pub width: u32, + pub height: u32, + pub source_type: String, + pub prompt: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub actual_prompt: Option, + pub model: String, + pub provider: String, + pub task_id: String, + pub duration_seconds: u32, + pub resolution: String, + pub price_mud_points: u32, +} + #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct CharacterAnimationDraftPayload { @@ -908,6 +940,53 @@ mod tests { assert_eq!(payload["fps"], json!(8)); } + #[test] + fn editor_video_request_supports_lovart_model_contract() { + let payload = serde_json::to_value(EditorVideoGenerateRequest { + prompt: "让角色向镜头挥手".to_string(), + model: "kling3.0-omni".to_string(), + aspect_ratio: "16:9".to_string(), + duration_seconds: 5, + resolution: "480p".to_string(), + mode: "std".to_string(), + sound: "off".to_string(), + price_mud_points: 50, + }) + .expect("request should serialize"); + + assert_eq!(payload["aspectRatio"], json!("16:9")); + assert_eq!(payload["durationSeconds"], json!(5)); + assert_eq!(payload["priceMudPoints"], json!(50)); + assert_eq!(payload["model"], json!("kling3.0-omni")); + } + + #[test] + fn editor_video_response_uses_canvas_video_shape() { + let payload = serde_json::to_value(EditorVideoGenerateResponse { + ok: true, + video_src: "/generated-editor-videos/task-1/preview.mp4".to_string(), + width: 1280, + height: 720, + source_type: "generated".to_string(), + prompt: "让角色向镜头挥手".to_string(), + actual_prompt: Some("让角色向镜头挥手".to_string()), + model: "kling3.0-omni".to_string(), + provider: "VectorEngine".to_string(), + task_id: "task-1".to_string(), + duration_seconds: 5, + resolution: "480p".to_string(), + price_mud_points: 50, + }) + .expect("response should serialize"); + + assert_eq!( + payload["videoSrc"], + json!("/generated-editor-videos/task-1/preview.mp4") + ); + assert_eq!(payload["sourceType"], json!("generated")); + assert_eq!(payload["durationSeconds"], json!(5)); + } + #[test] fn character_workflow_cache_response_keeps_legacy_shape() { let payload = serde_json::to_value(CharacterWorkflowCacheSaveResponse { diff --git a/src/components/image-editor/ImageCanvasEditorModel.ts b/src/components/image-editor/ImageCanvasEditorModel.ts index 9e85e6fc0..c2dde13b6 100644 --- a/src/components/image-editor/ImageCanvasEditorModel.ts +++ b/src/components/image-editor/ImageCanvasEditorModel.ts @@ -150,6 +150,7 @@ export function serializeLayer( originalHeight: layer.originalHeight, zIndex: layer.zIndex, sourceType: layer.sourceType, + mediaType: layer.mediaType, prompt: layer.prompt, actualPrompt: layer.actualPrompt, model: layer.model, @@ -207,6 +208,7 @@ export function hydrateLayer( sourceType: isCanvasSourceType(snapshot.sourceType) ? snapshot.sourceType : 'uploaded', + mediaType: snapshot.mediaType === 'video' ? 'video' : 'image', prompt: stringOrNull(snapshot.prompt), actualPrompt: stringOrNull(snapshot.actualPrompt), model: stringOrNull(snapshot.model), @@ -406,7 +408,9 @@ export function canvasAssetKindOrNull(value: unknown): CanvasAssetKind | null { return value === 'spec' || value === 'character' || value === 'icon' || - value === 'icon-spec' + value === 'icon-spec' || + value === 'ui-design' || + value === 'video' ? value : null; } diff --git a/src/components/image-editor/ImageCanvasEditorTypes.ts b/src/components/image-editor/ImageCanvasEditorTypes.ts index 30bc68204..a0a56d243 100644 --- a/src/components/image-editor/ImageCanvasEditorTypes.ts +++ b/src/components/image-editor/ImageCanvasEditorTypes.ts @@ -7,12 +7,21 @@ import type { export type CanvasSourceType = 'uploaded' | 'generated' | 'mock_generated'; -export type CanvasAssetKind = 'spec' | 'character' | 'icon' | 'icon-spec'; +export type CanvasAssetKind = + | 'spec' + | 'character' + | 'icon' + | 'icon-spec' + | 'ui-design' + | 'video'; + +export type CanvasMediaType = 'image' | 'video'; export type EditorAsset = { id: string; label: string; src: string; + mediaType?: CanvasMediaType; width: number; height: number; folderId: string; @@ -52,6 +61,7 @@ export type CanvasLayer = { resourceId: string; title: string; src: string; + mediaType?: CanvasMediaType; x: number; y: number; width: number; @@ -89,9 +99,11 @@ export type CanvasTool = | 'hand' | 'upload' | 'generate' + | 'video' | 'spec' | 'character' | 'icon' + | 'ui-design' | 'text' | 'shape' | 'export'; @@ -124,7 +136,14 @@ export type CharacterReferenceImage = { export type GenerateDialogState = { id?: string; - mode: 'generate' | 'edit' | 'spec' | 'character' | 'icon'; + mode: + | 'generate' + | 'edit' + | 'spec' + | 'character' + | 'icon' + | 'ui-design' + | 'video'; prompt: string; status: 'idle' | 'generating' | 'failed'; composerOpen?: boolean; @@ -133,11 +152,19 @@ export type GenerateDialogState = { specType?: SpecGenerationType; specValues?: SpecFormValues; specReference?: CharacterReferenceImage | null; + generationReferences?: CharacterReferenceImage[]; characterSpecReference?: CharacterReferenceImage | null; characterReferences?: CharacterReferenceImage[]; iconSpecReference?: CharacterReferenceImage | null; iconDescriptions?: string[]; + uiDesignSpecReference?: CharacterReferenceImage | null; imageModel?: string; + videoModel?: string; + videoAspectRatio?: string; + videoResolution?: string; + videoDurationSeconds?: 4 | 5; + videoMode?: 'std'; + videoSound?: 'off'; aspectRatio?: string; imageSize?: string; errorMessage?: string; @@ -219,10 +246,12 @@ export type CharacterAnimationPanelState = { export type UploadTarget = | 'asset' + | 'generation-reference' | 'spec-reference' | 'character-spec' | 'character-reference' - | 'icon-spec'; + | 'icon-spec' + | 'ui-design-icon-spec'; export type SnapGuide = { vertical?: number; diff --git a/src/components/image-editor/ImageCanvasGenerationComposerView.test.tsx b/src/components/image-editor/ImageCanvasGenerationComposerView.test.tsx new file mode 100644 index 000000000..23089a2f8 --- /dev/null +++ b/src/components/image-editor/ImageCanvasGenerationComposerView.test.tsx @@ -0,0 +1,296 @@ +/* @vitest-environment jsdom */ + +import { fireEvent, render, screen, within } from '@testing-library/react'; +import { + createRef, + type ComponentProps, + type Dispatch, + type SetStateAction, +} from 'react'; +import { describe, expect, it, vi } from 'vitest'; + +import type { GenerateDialogState } from './ImageCanvasEditorTypes'; +import { ImageCanvasGenerationComposerView } from './ImageCanvasGenerationComposerView'; + +function mockStateSetter() { + return vi.fn() as unknown as Dispatch>; +} + +function renderComposer( + generateDialog: GenerateDialogState, + overrides: Partial< + ComponentProps + > = {}, +) { + const props: ComponentProps = { + specToolWrapRef: createRef(), + characterSpecButtonRef: createRef(), + characterReferenceButtonRef: createRef(), + generationReferenceButtonRef: createRef(), + iconSpecButtonRef: createRef(), + isSpecMenuOpen: false, + isCharacterSpecMenuOpen: false, + isCharacterReferenceMenuOpen: false, + isGenerationReferenceMenuOpen: false, + isIconSpecMenuOpen: false, + isUiDesignSpecMenuOpen: false, + isPickingCharacterSpecFromCanvas: false, + isPickingCharacterReferenceFromCanvas: false, + isPickingGenerationReferenceFromCanvas: false, + isPickingIconSpecFromCanvas: false, + isPickingUiDesignSpecFromCanvas: false, + generateDialog, + generationComposerStyle: { left: 320, top: 240 }, + iconComposerStyle: { left: 320, top: 240, width: '32rem' }, + quickEditPanel: null, + quickEditSourceLayer: null, + quickEditPanelStyle: null, + quickEditSizeOptions: ['1024x1024'], + quickEditModelOptions: [{ label: 'gpt-image-2', value: 'gpt-image-2' }], + characterAnimationPanel: null, + characterAnimationSourceLayer: null, + characterAnimationPanelStyle: null, + characterAnimationPrice: 40, + setGenerateDialog: mockStateSetter(), + setQuickEditPanel: + mockStateSetter< + ComponentProps< + typeof ImageCanvasGenerationComposerView + >['quickEditPanel'] + >(), + setCharacterAnimationPanel: + mockStateSetter< + ComponentProps< + typeof ImageCanvasGenerationComposerView + >['characterAnimationPanel'] + >(), + setIsCharacterSpecMenuOpen: mockStateSetter(), + setIsCharacterReferenceMenuOpen: mockStateSetter(), + setIsGenerationReferenceMenuOpen: mockStateSetter(), + setIsIconSpecMenuOpen: mockStateSetter(), + setIsUiDesignSpecMenuOpen: mockStateSetter(), + setIsPickingCharacterSpecFromCanvas: mockStateSetter(), + setIsPickingCharacterReferenceFromCanvas: mockStateSetter(), + setIsPickingGenerationReferenceFromCanvas: mockStateSetter(), + setIsPickingIconSpecFromCanvas: mockStateSetter(), + setIsPickingUiDesignSpecFromCanvas: mockStateSetter(), + onOpenSpecDialog: vi.fn(), + onRequestUpload: vi.fn(), + onSubmitImageGeneration: vi.fn(), + onSubmitIconSpritesheetGeneration: vi.fn(), + onSubmitQuickEdit: vi.fn(), + onSubmitCharacterAnimation: vi.fn(), + onCloseGenerateComposer: vi.fn(), + onUpdateSpecFormValue: vi.fn(), + onUpdateIconDescription: vi.fn(), + onAddIconDescription: vi.fn(), + onUpdateCharacterAnimationDuration: vi.fn(), + onRememberImageModel: vi.fn(), + ...overrides, + }; + + return render(); +} + +describe('ImageCanvasGenerationComposerView', () => { + it('让生成UI设计图面板复用普通图片生成面板的纵向结构', () => { + renderComposer({ + mode: 'ui-design', + prompt: '', + status: 'idle', + composerOpen: true, + uiDesignSpecReference: null, + imageModel: 'gpt-image-2', + aspectRatio: '16:9', + imageSize: '1K', + }); + + const panel = screen.getByRole('dialog', { name: '生成UI设计图' }); + expect(panel.className).toContain( + 'image-canvas-editor__generation-composer', + ); + expect(panel.className).toContain( + 'image-canvas-editor__generation-composer--image', + ); + expect( + within(panel).getByRole('button', { name: 'UI设计图标素材规范' }) + .parentElement?.className, + ).toContain('image-canvas-editor__generation-ref'); + expect( + within(panel).getByRole('textbox', { name: 'UI设计要求' }).className, + ).toContain('image-canvas-editor__generation-prompt'); + expect( + panel.querySelector('.image-canvas-editor__generation-composer-footer'), + ).toBeTruthy(); + }); + + it('让生成规范图片面板复用生成类面板 shell 和底部按钮结构', () => { + renderComposer({ + mode: 'spec', + prompt: '', + status: 'idle', + composerOpen: true, + specType: 'character', + specValues: { + playSetting: '战棋类RPG玩法', + artStyle: '像素风', + bodyRatio: '3', + characterView: '右向斜侧身站姿', + customPrompt: '', + }, + specReference: null, + }); + + const panel = screen.getByRole('dialog', { name: '生成规范' }); + expect(panel.className).toContain( + 'image-canvas-editor__generation-composer', + ); + expect(panel.className).toContain( + 'image-canvas-editor__generation-composer--image', + ); + expect( + within(panel).getByRole('button', { name: '参考图' }).parentElement + ?.className, + ).toContain('image-canvas-editor__generation-ref'); + expect( + panel.querySelector('.image-canvas-editor__generation-composer-footer'), + ).toBeTruthy(); + expect( + within(panel).getByRole('button', { name: '提交生成规范' }).className, + ).toContain('image-canvas-editor__generation-submit'); + }); + + it('让图标素材规范生成面板也保留首行参考区', () => { + renderComposer({ + mode: 'spec', + prompt: '', + status: 'idle', + composerOpen: true, + specType: 'icon', + specValues: { + playSetting: '休闲小游戏', + artStyle: '清爽卡通', + bodyRatio: '3', + characterView: '右向斜侧身站姿', + customPrompt: '', + }, + specReference: null, + }); + + const panel = screen.getByRole('dialog', { name: '生成规范' }); + expect( + within(panel).getByRole('button', { name: '参考图' }).parentElement + ?.className, + ).toContain('image-canvas-editor__generation-ref'); + }); + it('生成图片参考图点击先弹来源菜单,不直接打开上传', () => { + const onRequestUpload = vi.fn(); + const setIsGenerationReferenceMenuOpen = vi.fn(); + + renderComposer( + { + mode: 'generate', + prompt: '', + status: 'idle', + composerOpen: true, + generationReferences: [], + }, + { + isGenerationReferenceMenuOpen: true, + onRequestUpload, + setIsGenerationReferenceMenuOpen, + }, + ); + + const panel = screen.getByRole('dialog', { name: '生成图片' }); + fireEvent.click(within(panel).getByRole('button', { name: '添加参考图' })); + + expect(onRequestUpload).not.toHaveBeenCalled(); + expect(setIsGenerationReferenceMenuOpen).toHaveBeenCalled(); + const menu = screen.getByRole('menu', { name: '参考图来源' }); + expect( + within(menu).getByRole('menuitem', { name: '从画布中选择' }), + ).toBeTruthy(); + expect( + within(menu).getByRole('menuitem', { name: '上传图片' }), + ).toBeTruthy(); + }); + + it('生成视频参考图点击先弹来源菜单,不直接打开上传', () => { + const onRequestUpload = vi.fn(); + const setIsGenerationReferenceMenuOpen = vi.fn(); + + renderComposer( + { + mode: 'video', + prompt: '', + status: 'idle', + composerOpen: true, + generationReferences: [], + videoModel: 'seedance2.0', + videoAspectRatio: '16:9', + videoDurationSeconds: 4, + videoResolution: '480p', + videoMode: 'std', + videoSound: 'off', + }, + { + isGenerationReferenceMenuOpen: true, + onRequestUpload, + setIsGenerationReferenceMenuOpen, + }, + ); + + const panel = screen.getByRole('dialog', { name: '生成视频' }); + fireEvent.click( + within(panel).getByRole('button', { name: '添加视频参考图' }), + ); + + expect(onRequestUpload).not.toHaveBeenCalled(); + expect(setIsGenerationReferenceMenuOpen).toHaveBeenCalled(); + const menu = screen.getByRole('menu', { name: '参考图来源' }); + expect( + within(menu).getByRole('menuitem', { name: '从画布中选择' }), + ).toBeTruthy(); + expect( + within(menu).getByRole('menuitem', { name: '上传图片' }), + ).toBeTruthy(); + }); + it('生成规范参考图点击先弹来源菜单,不直接打开上传', () => { + const onRequestUpload = vi.fn(); + const setIsGenerationReferenceMenuOpen = vi.fn(); + + renderComposer( + { + mode: 'spec', + prompt: '', + status: 'idle', + composerOpen: true, + specType: 'character', + specValues: { + playSetting: '战棋类RPG玩法', + artStyle: '像素风', + bodyRatio: '3', + characterView: '右向斜侧身站姿', + customPrompt: '', + }, + specReference: null, + }, + { + isGenerationReferenceMenuOpen: true, + onRequestUpload, + setIsGenerationReferenceMenuOpen, + }, + ); + + const panel = screen.getByRole('dialog', { name: '生成规范' }); + fireEvent.click(within(panel).getByRole('button', { name: '参考图' })); + + expect(onRequestUpload).not.toHaveBeenCalled(); + expect(setIsGenerationReferenceMenuOpen).toHaveBeenCalled(); + const menu = screen.getByRole('menu', { name: '参考图来源' }); + expect(within(menu).getByRole('menuitem', { name: '从画布中选择' })).toBeTruthy(); + expect(within(menu).getByRole('menuitem', { name: '上传图片' })).toBeTruthy(); + }); +}); + diff --git a/src/components/image-editor/ImageCanvasGenerationLayerModel.ts b/src/components/image-editor/ImageCanvasGenerationLayerModel.ts index 6ad7874a2..af77efbc7 100644 --- a/src/components/image-editor/ImageCanvasGenerationLayerModel.ts +++ b/src/components/image-editor/ImageCanvasGenerationLayerModel.ts @@ -2,6 +2,7 @@ import type { EditorIconSpritesheetGenerationResult, EditorIconSpritesheetIconResult, EditorImageGenerationResult, + EditorVideoGenerationResult, } from '../../services/image-editor/editorProjectClient'; import { resolveLayerResolutionSize } from './ImageCanvasEditorModel'; import { ICON_FRAME_DISPLAY_SIZE } from './ImageCanvasGenerationModel'; @@ -43,6 +44,16 @@ type IconSpritesheetResultLayerOptions = { frame?: GenerateDialogState['placeholder']; }; +type VideoResultLayerOptions = { + generated: EditorVideoGenerationResult; + generatedIndex: number; + title: string; + canvasSize: CanvasSize; + viewport: CanvasViewport; + generationInputs: CanvasGenerationInputs; + frame?: GenerateDialogState['placeholder']; +}; + function getViewportWorldCenter({ canvasSize, viewport, @@ -235,3 +246,53 @@ export function createIconSpritesheetResultLayers({ return layer; }); } + +export function createVideoResultLayer({ + generated, + generatedIndex, + title, + canvasSize, + viewport, + generationInputs, + frame, +}: VideoResultLayerOptions): CanvasLayer { + const originalWidth = generated.width || 1280; + const originalHeight = generated.height || 720; + const { width, height } = resolveLayerResolutionSize( + originalWidth, + originalHeight, + { width: 560, height: 315 }, + ); + const worldCenter = getViewportWorldCenter({ canvasSize, viewport }); + 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 { + id: `layer-video-${generatedIndex}`, + resourceId: `local-resource-video-${generatedIndex}`, + title, + src: generated.videoSrc, + mediaType: 'video', + assetKind: 'video', + x: frameX ?? worldCenter.x - width / 2, + y: frameY ?? worldCenter.y - height / 2, + width, + height, + originalWidth, + originalHeight, + zIndex: generatedIndex + 10, + sourceType: generated.sourceType, + prompt: generated.prompt, + actualPrompt: generated.actualPrompt ?? generated.prompt, + model: generated.model, + provider: generated.provider, + taskId: generated.taskId, + generationInputs, + }; +} diff --git a/src/components/image-editor/ImageCanvasGenerationModel.ts b/src/components/image-editor/ImageCanvasGenerationModel.ts index 7a119efc2..34e942e82 100644 --- a/src/components/image-editor/ImageCanvasGenerationModel.ts +++ b/src/components/image-editor/ImageCanvasGenerationModel.ts @@ -15,7 +15,23 @@ import type { } from './ImageCanvasEditorTypes'; import { isGeneratedLayer } from './ImageCanvasEditorModel'; -export const SPEC_GENERATION_COST = 5; +// 中文注释:与 api-server/src/editor_generation_config.rs 保持同名语义,当前作为前端展示兜底。 +export const EDITOR_GENERATION_MUD_POINT_CONFIG = { + image: 12, + spec: 5, + character: 12, + icon: 12, + uiDesign: 12, + videoPerSecond: { + '480p': 10, + '720p': 20, + }, + characterAnimationPerSecond: { + '480p': 10, + '720p': 20, + }, +} as const; +export const SPEC_GENERATION_COST = EDITOR_GENERATION_MUD_POINT_CONFIG.spec; export const SPEC_GENERATION_SIZE = '2048x1152'; export const SPEC_FRAME_ORIGINAL_SIZE = { width: 2048, height: 1152 }; export const SPEC_FRAME_DISPLAY_SIZE = { width: 560, height: 315 }; @@ -23,6 +39,8 @@ export const CHARACTER_FRAME_ORIGINAL_SIZE = { width: 2048, height: 2048 }; export const CHARACTER_FRAME_DISPLAY_SIZE = { width: 420, height: 420 }; export const ICON_FRAME_ORIGINAL_SIZE = { width: 512, height: 512 }; export const ICON_FRAME_DISPLAY_SIZE = { width: 360, height: 360 }; +export const UI_DESIGN_FRAME_ORIGINAL_SIZE = { width: 2048, height: 1152 }; +export const UI_DESIGN_FRAME_DISPLAY_SIZE = { width: 560, height: 315 }; export const IMAGE_MODEL_GPT_IMAGE_2 = 'gpt-image-2'; export const IMAGE_MODEL_NANOBANANA2 = 'gemini-3.1-flash-image-preview'; export const DEFAULT_IMAGE_MODEL = IMAGE_MODEL_NANOBANANA2; @@ -86,6 +104,18 @@ export const CHARACTER_ANIMATION_DURATION_OPTIONS = [ { label: '40帧·5秒', frameCount: 40, durationSeconds: 5 }, { label: '48帧·6秒', frameCount: 48, durationSeconds: 6 }, ] as const; +export const VIDEO_FRAME_ORIGINAL_SIZE = { width: 1280, height: 720 }; +export const VIDEO_FRAME_DISPLAY_SIZE = { width: 560, height: 315 }; +export const EDITOR_VIDEO_MODEL_OPTIONS = [ + { label: 'Seedance 2.0', value: 'seedance2.0' }, + { label: 'Seedance 2.0 Fast', value: 'seedance2.0-fast' }, + { label: 'Kling 3.0', value: 'kling3.0' }, + { label: 'Kling 3.0 Omni', value: 'kling3.0-omni' }, +] as const; +export const EDITOR_VIDEO_DURATION_OPTIONS = [ + { label: '4秒', value: '4' }, + { label: '5秒', value: '5' }, +] as const; export const DEFAULT_SPEC_FORM_VALUES: Record< SpecGenerationType, @@ -215,6 +245,12 @@ export function getLayerKindLabel(layer: CanvasLayer) { if (layer.assetKind === 'icon-spec') { return '图标规范'; } + if (layer.assetKind === 'ui-design') { + return 'UI设计'; + } + if (layer.assetKind === 'video' || layer.mediaType === 'video') { + return '视频'; + } return null; } @@ -231,6 +267,12 @@ export function formatLayerImageType(layer: CanvasLayer) { if (layer.assetKind === 'icon-spec') { return '图标素材规范图片'; } + if (layer.assetKind === 'ui-design') { + return 'UI设计图'; + } + if (layer.assetKind === 'video' || layer.mediaType === 'video') { + return '生成视频'; + } return isGeneratedLayer(layer) ? '生成图片' : '上传图片'; } @@ -238,7 +280,20 @@ export function calculateCharacterAnimationPrice( resolution: EditorCharacterAnimationResolution, durationSeconds: number, ) { - return (resolution === '720p' ? 20 : 10) * durationSeconds; + return ( + EDITOR_GENERATION_MUD_POINT_CONFIG.characterAnimationPerSecond[resolution] * + durationSeconds + ); +} + +export function calculateEditorVideoPrice( + resolution: '480p' | '720p', + durationSeconds: number, +) { + return ( + EDITOR_GENERATION_MUD_POINT_CONFIG.videoPerSecond[resolution] * + durationSeconds + ); } export function resolveCharacterAnimationSourceImageSrc(layer: CanvasLayer) { @@ -264,10 +319,31 @@ export function createGenerationInputField( return normalizedValue ? [{ title, value: normalizedValue }] : []; } -export function buildImageGenerationInputs(prompt: string): CanvasGenerationInputs { +export function buildImageGenerationInputs( + prompt: string, + references?: CharacterReferenceImage[], +): CanvasGenerationInputs { return { fields: createGenerationInputField('生成提示词', prompt), - references: [], + references: (references ?? []).map((reference, index) => ({ + title: `参考图 ${index + 1}`, + label: reference.label, + src: reference.src, + })), + }; +} + +export function buildVideoGenerationInputs( + prompt: string, + references?: CharacterReferenceImage[], +): CanvasGenerationInputs { + return { + fields: createGenerationInputField('视频描述', prompt), + references: (references ?? []).map((reference, index) => ({ + title: `参考图 ${index + 1}`, + label: reference.label, + src: reference.src, + })), }; } @@ -328,6 +404,24 @@ export function buildCharacterGenerationInputs( }; } +export function buildUiDesignGenerationInputs( + prompt: string, + specReference: CharacterReferenceImage | null | undefined, +): CanvasGenerationInputs { + return { + fields: createGenerationInputField('用户输入', prompt), + references: specReference + ? [ + { + title: '图标素材规范', + label: specReference.label, + src: specReference.src, + }, + ] + : [], + }; +} + export function buildIconGenerationInputs( iconDescriptions: string[], specReference: CharacterReferenceImage, @@ -372,7 +466,9 @@ export function isCanvasGenerationDialog( (dialog.mode === 'generate' || dialog.mode === 'spec' || dialog.mode === 'character' || - dialog.mode === 'icon'), + dialog.mode === 'icon' || + dialog.mode === 'ui-design' || + dialog.mode === 'video'), ); } @@ -388,6 +484,12 @@ export function getGenerationFrameAriaLabel( if (dialog.mode === 'icon') { return '图标素材生成占位图'; } + if (dialog.mode === 'ui-design') { + return 'UI设计图生成占位图'; + } + if (dialog.mode === 'video') { + return '视频生成占位图'; + } return '图像生成占位图'; } @@ -401,6 +503,12 @@ export function getGenerationFrameLabel(dialog: CanvasGenerationDialogState) { if (dialog.mode === 'icon') { return 'Icon Generator'; } + if (dialog.mode === 'ui-design') { + return 'UI Design Generator'; + } + if (dialog.mode === 'video') { + return 'Video Generator'; + } return 'Image Generator'; } diff --git a/src/components/image-editor/ImageCanvasGenerationPlacementModel.test.ts b/src/components/image-editor/ImageCanvasGenerationPlacementModel.test.ts new file mode 100644 index 000000000..c3b81995c --- /dev/null +++ b/src/components/image-editor/ImageCanvasGenerationPlacementModel.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from 'vitest'; + +import { + chooseGenerationPlacement, + centerViewportOnPlacement, +} from './ImageCanvasGenerationPlacementModel'; +import type { CanvasLayer, CanvasViewport } from './ImageCanvasEditorTypes'; + +const canvasSize = { width: 900, height: 640 }; +const viewport: CanvasViewport = { x: 10, y: 20, scale: 2 }; +const frame = { + x: 0, + y: 0, + width: 420, + height: 420, + originalWidth: 2048, + originalHeight: 2048, +}; + +function layer(overrides: Partial): CanvasLayer { + return { + id: 'layer-1', + resourceId: 'resource-1', + title: '图片', + src: '/image.png', + x: 0, + y: 0, + width: 100, + height: 100, + originalWidth: 100, + originalHeight: 100, + zIndex: 1, + sourceType: 'uploaded', + ...overrides, + }; +} + +describe('ImageCanvasGenerationPlacementModel', () => { + it('places an empty-canvas generation frame at the current viewport center', () => { + const placement = chooseGenerationPlacement({ + canvasSize, + viewport, + frame, + layers: [], + generationDialogs: [], + }); + + expect(placement).toEqual({ + x: 10, + y: -60, + width: 420, + height: 420, + originalWidth: 2048, + originalHeight: 2048, + }); + }); + + it('keeps the nearest non-overlapping gap from visible layers and generation placeholders', () => { + const placement = chooseGenerationPlacement({ + canvasSize, + viewport, + frame: { ...frame, width: 100, height: 100 }, + layers: [ + layer({ id: 'center', x: 170, y: 110, width: 120, height: 120 }), + layer({ id: 'right', x: 322, y: 110, width: 100, height: 120 }), + layer({ + id: 'hidden', + hidden: true, + x: 18, + y: 118, + width: 100, + height: 100, + }), + ], + generationDialogs: [ + { + id: 'dialog-left', + mode: 'generate', + prompt: '', + status: 'idle', + placeholder: { + x: 18, + y: 118, + width: 100, + height: 100, + originalWidth: 2048, + originalHeight: 2048, + }, + }, + ], + }); + + expect(placement.x).toBe(170); + expect(placement.y).toBe(-22); + }); + + it('centers the viewport on the chosen placement without changing zoom', () => { + const nextViewport = centerViewportOnPlacement({ + canvasSize, + viewport, + placement: { + x: 170, + y: 262, + width: 100, + height: 100, + originalWidth: 2048, + originalHeight: 2048, + }, + }); + + expect(nextViewport).toEqual({ x: 10, y: -304, scale: 2 }); + }); +}); diff --git a/src/components/image-editor/ImageCanvasGenerationPlacementModel.ts b/src/components/image-editor/ImageCanvasGenerationPlacementModel.ts new file mode 100644 index 000000000..d7a85b840 --- /dev/null +++ b/src/components/image-editor/ImageCanvasGenerationPlacementModel.ts @@ -0,0 +1,257 @@ +import type { + CanvasGenerationDialogState, + CanvasLayer, + CanvasViewport, + GenerateDialogState, +} from './ImageCanvasEditorTypes'; + +type CanvasSize = { width: number; height: number }; +type GenerationFrame = NonNullable; +type Rect = { x: number; y: number; width: number; height: number }; + +const GENERATION_PLACEMENT_GAP = 32; +const MAX_PLACEMENT_RING = 12; + +function getViewportWorldCenter({ + canvasSize, + viewport, +}: { + canvasSize: CanvasSize; + viewport: CanvasViewport; +}) { + const safeScale = viewport.scale > 0 ? viewport.scale : 1; + return { + x: (canvasSize.width / 2 - viewport.x) / safeScale, + y: (canvasSize.height / 2 - viewport.y) / safeScale, + }; +} + +function expandRect(rect: Rect, gap: number): Rect { + return { + x: rect.x - gap, + y: rect.y - gap, + width: rect.width + gap * 2, + height: rect.height + gap * 2, + }; +} + +function rectsOverlap(a: Rect, b: Rect) { + return ( + a.x < b.x + b.width && + a.x + a.width > b.x && + a.y < b.y + b.height && + a.y + a.height > b.y + ); +} + +function distanceSquared( + a: { x: number; y: number }, + b: { x: number; y: number }, +) { + const dx = a.x - b.x; + const dy = a.y - b.y; + return dx * dx + dy * dy; +} + +function buildBlockingRects({ + layers, + generationDialogs, +}: { + layers: CanvasLayer[]; + generationDialogs: CanvasGenerationDialogState[]; +}) { + return [ + ...layers + .filter((layer) => !layer.hidden) + .map((layer) => ({ + x: layer.x, + y: layer.y, + width: layer.width, + height: layer.height, + })), + ...generationDialogs.flatMap((dialog) => + dialog.placeholder + ? [ + { + x: dialog.placeholder.x, + y: dialog.placeholder.y, + width: dialog.placeholder.width, + height: dialog.placeholder.height, + }, + ] + : [], + ), + ].map((rect) => expandRect(rect, GENERATION_PLACEMENT_GAP)); +} + +function isFree(candidate: Rect, blockingRects: Rect[]) { + return !blockingRects.some((rect) => rectsOverlap(candidate, rect)); +} + +function createCandidate({ + center, + frame, + offsetX, + offsetY, +}: { + center: { x: number; y: number }; + frame: Pick; + offsetX: number; + offsetY: number; +}): Rect { + return { + x: center.x - frame.width / 2 + offsetX, + y: center.y - frame.height / 2 + offsetY, + width: frame.width, + height: frame.height, + }; +} + +function pushUniqueCandidate(candidates: Rect[], candidate: Rect) { + if ( + candidates.some( + (current) => current.x === candidate.x && current.y === candidate.y, + ) + ) { + return; + } + candidates.push(candidate); +} + +function buildPlacementCandidates({ + center, + frame, + blockingRects, +}: { + center: { x: number; y: number }; + frame: Pick; + blockingRects: Rect[]; +}) { + const baseX = center.x - frame.width / 2; + const baseY = center.y - frame.height / 2; + const stepX = frame.width + GENERATION_PLACEMENT_GAP; + const stepY = frame.height + GENERATION_PLACEMENT_GAP; + const candidates: Rect[] = []; + pushUniqueCandidate(candidates, { + x: baseX, + y: baseY, + width: frame.width, + height: frame.height, + }); + + blockingRects.forEach((rect) => { + [ + { x: rect.x + rect.width, y: baseY }, + { x: baseX, y: rect.y + rect.height }, + { x: rect.x - frame.width, y: baseY }, + { x: baseX, y: rect.y - frame.height }, + { x: rect.x + rect.width, y: rect.y + rect.height }, + { x: rect.x - frame.width, y: rect.y + rect.height }, + { x: rect.x + rect.width, y: rect.y - frame.height }, + { x: rect.x - frame.width, y: rect.y - frame.height }, + ].forEach((candidate) => + pushUniqueCandidate(candidates, { + ...candidate, + width: frame.width, + height: frame.height, + }), + ); + }); + + for (let ring = 1; ring <= MAX_PLACEMENT_RING; ring += 1) { + const offsets = [ + { x: ring, y: 0 }, + { x: 0, y: ring }, + { x: -ring, y: 0 }, + { x: 0, y: -ring }, + { x: ring, y: ring }, + { x: -ring, y: ring }, + { x: ring, y: -ring }, + { x: -ring, y: -ring }, + ]; + offsets.forEach((offset) => + pushUniqueCandidate( + candidates, + createCandidate({ + center, + frame, + offsetX: offset.x * stepX, + offsetY: offset.y * stepY, + }), + ), + ); + } + + return candidates.sort((a, b) => { + const aDistance = distanceSquared( + { x: a.x + a.width / 2, y: a.y + a.height / 2 }, + center, + ); + const bDistance = distanceSquared( + { x: b.x + b.width / 2, y: b.y + b.height / 2 }, + center, + ); + if (aDistance !== bDistance) { + return aDistance - bDistance; + } + const aDownBias = a.y >= baseY ? 0 : 1; + const bDownBias = b.y >= baseY ? 0 : 1; + if (aDownBias !== bDownBias) { + return aDownBias - bDownBias; + } + if (a.x !== b.x) { + return a.x - b.x; + } + return a.y - b.y; + }); +} + +export function chooseGenerationPlacement({ + canvasSize, + viewport, + frame, + layers, + generationDialogs, +}: { + canvasSize: CanvasSize; + viewport: CanvasViewport; + frame: GenerationFrame; + layers: CanvasLayer[]; + generationDialogs: CanvasGenerationDialogState[]; +}): GenerationFrame { + const center = getViewportWorldCenter({ canvasSize, viewport }); + const blockingRects = buildBlockingRects({ layers, generationDialogs }); + const candidates = buildPlacementCandidates({ center, frame, blockingRects }); + const fallbackPlacement = createCandidate({ + center, + frame, + offsetX: 0, + offsetY: 0, + }); + const placement = + candidates.find((candidate) => isFree(candidate, blockingRects)) ?? + fallbackPlacement; + + return { + ...frame, + x: placement.x, + y: placement.y, + }; +} + +export function centerViewportOnPlacement({ + canvasSize, + viewport, + placement, +}: { + canvasSize: CanvasSize; + viewport: CanvasViewport; + placement: GenerationFrame; +}): CanvasViewport { + const scale = viewport.scale > 0 ? viewport.scale : 1; + return { + x: canvasSize.width / 2 - (placement.x + placement.width / 2) * scale, + y: canvasSize.height / 2 - (placement.y + placement.height / 2) * scale, + scale, + }; +} diff --git a/src/components/image-editor/ImageCanvasMetadataModalView.test.tsx b/src/components/image-editor/ImageCanvasMetadataModalView.test.tsx index 2263bdf0d..1b4837d70 100644 --- a/src/components/image-editor/ImageCanvasMetadataModalView.test.tsx +++ b/src/components/image-editor/ImageCanvasMetadataModalView.test.tsx @@ -95,6 +95,42 @@ describe('ImageCanvasMetadataModalView', () => { expect(onClose).toHaveBeenCalledTimes(1); }); + it('renders generated video metadata with video labels and close action', () => { + const onClose = vi.fn(); + render( + , + ); + + const dialog = screen.getByRole('dialog', { name: '视频信息' }); + + expect(within(dialog).getByText('视频类型')).toBeTruthy(); + expect(within(dialog).getByText('生成视频')).toBeTruthy(); + expect(within(dialog).getByText('视频描述')).toBeTruthy(); + expect(within(dialog).getByText('让角色向镜头挥手')).toBeTruthy(); + expect(within(dialog).getByText('kling3.0-omni')).toBeTruthy(); + expect(within(dialog).getByText('1280 x 720 px')).toBeTruthy(); + + fireEvent.click(within(dialog).getByRole('button', { name: '关闭视频信息' })); + + expect(onClose).toHaveBeenCalledTimes(1); + }); + it('does not render a dialog when no layer is selected', () => { render(); diff --git a/src/components/image-editor/ImageCanvasMetadataModalView.tsx b/src/components/image-editor/ImageCanvasMetadataModalView.tsx index 628ea40b3..c66dc0375 100644 --- a/src/components/image-editor/ImageCanvasMetadataModalView.tsx +++ b/src/components/image-editor/ImageCanvasMetadataModalView.tsx @@ -19,16 +19,16 @@ export function ImageCanvasMetadataModalView({ return ( {layer ? (
-
图片类型
+
{layer.mediaType === 'video' ? '视频类型' : '图片类型'}
{formatLayerImageType(layer)}
生成输入
diff --git a/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx b/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx index 6569c3801..ef29319e8 100644 --- a/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx +++ b/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx @@ -1,6 +1,12 @@ /* @vitest-environment jsdom */ -import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { + act, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react'; import { useRef, useState } from 'react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; @@ -72,10 +78,13 @@ function createGenerated(overrides = {}) { function GenerationWorkflowHarness({ initialLayers = [createLayer()], + initialViewport = { x: 10, y: 20, scale: 2 }, }: { initialLayers?: CanvasLayer[]; + initialViewport?: { x: number; y: number; scale: number }; }) { const [layers, setLayers] = useState(initialLayers); + const [viewport, setViewport] = useState(initialViewport); const [activeTool, setActiveTool] = useState('select'); const [activeSidebarPanel, setActiveSidebarPanel] = useState('assets'); @@ -93,7 +102,9 @@ function GenerationWorkflowHarness({ const workflow = useImageCanvasGenerationWorkflow({ layers, canvasSize: { width: 900, height: 640 }, - viewport: { x: 10, y: 20, scale: 2 }, + viewport, + setViewport, + canvasGenerationDialogs: dialogs.canvasGenerationDialogs, layerCounterRef, generateDialog: dialogs.generateDialog, setGenerateDialog: dialogs.setGenerateDialog, @@ -126,6 +137,12 @@ function GenerationWorkflowHarness({ {selectedLayerId ?? '-'} {metadataLayer?.id ?? '-'} {imageContextMenu ? 'open' : '-'} + {`${viewport.x}:${viewport.y}:${viewport.scale}`} + + {activeDialog?.placeholder + ? `${activeDialog.placeholder.x}:${activeDialog.placeholder.y}:${activeDialog.placeholder.width}:${activeDialog.placeholder.height}` + : '-'} + {layers .map( @@ -139,6 +156,11 @@ function GenerationWorkflowHarness({ ? `${activeDialog.mode}:${activeDialog.status}:${activeDialog.composerOpen !== false ? 'open' : 'closed'}:${activeDialog.generatedLayerId ?? '-'}:${activeDialog.placeholder ? 'placeholder' : '-'}` : '-'} + + {activeDialog?.generationReferences + ?.map((reference) => reference.label) + .join('|') ?? '-'} + {workflow.quickEditPanel ? `${workflow.quickEditPanel.sourceLayerId}:${workflow.quickEditPanel.status}:${workflow.quickEditPanel.prompt || '-'}` @@ -190,7 +212,9 @@ function GenerationWorkflowHarness({ + - @@ -298,6 +333,29 @@ describe('useImageCanvasGenerationWorkflow', () => { ); }); + it('places a new generation placeholder away from existing canvas images and centers the viewport on it', () => { + render( + , + ); + + fireEvent.click(screen.getByRole('button', { name: '打开生成' })); + + expect(screen.getByTestId('placeholder').textContent).toBe( + '-452:-60:420:420', + ); + expect(screen.getByTestId('viewport').textContent).toBe('934:20:2'); + }); + it('submits a normal generation, appends the generated layer, and keeps the composer anchored', async () => { generateEditorImageMock.mockResolvedValueOnce( createGenerated({ prompt: '一张生成图' }), @@ -329,6 +387,30 @@ describe('useImageCanvasGenerationWorkflow', () => { ); }); + it('adds picked canvas layers as normal generation references and submits them', async () => { + generateEditorImageMock.mockResolvedValueOnce( + createGenerated({ prompt: '参考生成图' }), + ); + render(); + + fireEvent.click(screen.getByRole('button', { name: '打开生成' })); + fireEvent.click(screen.getByRole('button', { name: '选择画布参考图' })); + + expect(screen.getByTestId('generation-references').textContent).toBe( + '画布参考图', + ); + + fireEvent.click(screen.getByRole('button', { name: '填写生成提示词' })); + fireEvent.click(screen.getByRole('button', { name: '提交生成' })); + + await waitFor(() => { + expect(generateEditorImageMock).toHaveBeenCalledWith({ + prompt: '一张生成图', + referenceImageSrcs: ['data:image/png;base64,source'], + }); + }); + }); + it('submits spec generation with reference image and reference prompt semantics', async () => { generateEditorImageMock.mockResolvedValueOnce( createGenerated({ prompt: 'UI规范图' }), @@ -344,6 +426,7 @@ describe('useImageCanvasGenerationWorkflow', () => { expect.objectContaining({ size: '2048x1152', kind: 'spec', + model: 'gpt-image-2', referenceImageSrcs: ['data:image/png;base64,ref'], prompt: expect.stringContaining('参考图生成规范'), }), diff --git a/src/components/image-editor/useImageCanvasKeyboardShortcuts.test.tsx b/src/components/image-editor/useImageCanvasKeyboardShortcuts.test.tsx index 1f44b871c..e246a8994 100644 --- a/src/components/image-editor/useImageCanvasKeyboardShortcuts.test.tsx +++ b/src/components/image-editor/useImageCanvasKeyboardShortcuts.test.tsx @@ -78,6 +78,8 @@ function KeyboardShortcutsHarness({ const [imageContextMenuOpen, setImageContextMenuOpen] = useState(true); const [contextMenuOpen, setContextMenuOpen] = useState(true); const [isSpecMenuOpen, setIsSpecMenuOpen] = useState(true); + const [, setIsGenerationReferenceMenuOpen] = useState(true); + const [, setIsPickingGenerationReferenceFromCanvas] = useState(true); const [isCharacterSpecMenuOpen, setIsCharacterSpecMenuOpen] = useState(true); const [isCharacterReferenceMenuOpen, setIsCharacterReferenceMenuOpen] = useState(true); @@ -90,6 +92,9 @@ function KeyboardShortcutsHarness({ const [isIconSpecMenuOpen, setIsIconSpecMenuOpen] = useState(true); const [isPickingIconSpecFromCanvas, setIsPickingIconSpecFromCanvas] = useState(true); + const [, setIsUiDesignSpecMenuOpen] = useState(true); + const [, setIsPickingUiDesignSpecFromCanvas] = + useState(true); const [isSpacePanning, setIsSpacePanning] = useState(false); const [shiftPressed, setShiftPressed] = useState(false); const generateDialogRef = useRef(generateDialog); @@ -116,12 +121,16 @@ function KeyboardShortcutsHarness({ ), closeEditorChromePanels, setIsSpecMenuOpen, + setIsGenerationReferenceMenuOpen, + setIsPickingGenerationReferenceFromCanvas, setIsCharacterSpecMenuOpen, setIsCharacterReferenceMenuOpen, setIsPickingCharacterSpecFromCanvas, setIsPickingCharacterReferenceFromCanvas, setIsIconSpecMenuOpen, setIsPickingIconSpecFromCanvas, + setIsUiDesignSpecMenuOpen, + setIsPickingUiDesignSpecFromCanvas, setIsSpacePanning, setShiftPressed, }); diff --git a/src/components/image-editor/useImageCanvasKeyboardShortcuts.ts b/src/components/image-editor/useImageCanvasKeyboardShortcuts.ts index ed2fd4795..982f66369 100644 --- a/src/components/image-editor/useImageCanvasKeyboardShortcuts.ts +++ b/src/components/image-editor/useImageCanvasKeyboardShortcuts.ts @@ -30,12 +30,16 @@ type UseImageCanvasKeyboardShortcutsOptions = { ) => void; closeEditorChromePanels: () => void; setIsSpecMenuOpen: (open: boolean) => void; + setIsGenerationReferenceMenuOpen: (open: boolean) => void; + setIsPickingGenerationReferenceFromCanvas: (picking: boolean) => void; setIsCharacterSpecMenuOpen: (open: boolean) => void; setIsCharacterReferenceMenuOpen: (open: boolean) => void; setIsPickingCharacterSpecFromCanvas: (picking: boolean) => void; setIsPickingCharacterReferenceFromCanvas: (picking: boolean) => void; setIsIconSpecMenuOpen: (open: boolean) => void; setIsPickingIconSpecFromCanvas: (picking: boolean) => void; + setIsUiDesignSpecMenuOpen: (open: boolean) => void; + setIsPickingUiDesignSpecFromCanvas: (picking: boolean) => void; setIsSpacePanning: (panning: boolean) => void; setShiftPressed: (pressed: boolean) => void; }; @@ -61,7 +65,8 @@ function isCanvasGenerationPlaceholderDialog( (dialog?.mode === 'generate' || dialog?.mode === 'spec' || dialog?.mode === 'character' || - dialog?.mode === 'icon') + dialog?.mode === 'icon' || + dialog?.mode === 'ui-design') ); } @@ -78,12 +83,16 @@ export function useImageCanvasKeyboardShortcuts({ setQuickEditPanel, closeEditorChromePanels, setIsSpecMenuOpen, + setIsGenerationReferenceMenuOpen, + setIsPickingGenerationReferenceFromCanvas, setIsCharacterSpecMenuOpen, setIsCharacterReferenceMenuOpen, setIsPickingCharacterSpecFromCanvas, setIsPickingCharacterReferenceFromCanvas, setIsIconSpecMenuOpen, setIsPickingIconSpecFromCanvas, + setIsUiDesignSpecMenuOpen, + setIsPickingUiDesignSpecFromCanvas, setIsSpacePanning, setShiftPressed, }: UseImageCanvasKeyboardShortcutsOptions) { @@ -91,6 +100,8 @@ export function useImageCanvasKeyboardShortcuts({ const closeTransientEditorPanels = () => { closeEditorChromePanels(); setIsSpecMenuOpen(false); + setIsGenerationReferenceMenuOpen(false); + setIsPickingGenerationReferenceFromCanvas(false); setImageContextMenu(null); setContextMenu(null); setQuickEditPanel((currentPanel) => @@ -102,6 +113,8 @@ export function useImageCanvasKeyboardShortcuts({ setIsPickingCharacterReferenceFromCanvas(false); setIsIconSpecMenuOpen(false); setIsPickingIconSpecFromCanvas(false); + setIsUiDesignSpecMenuOpen(false); + setIsPickingUiDesignSpecFromCanvas(false); setGenerateDialog((currentDialog) => { if (!currentDialog || currentDialog.status === 'generating') { return currentDialog; @@ -118,6 +131,9 @@ export function useImageCanvasKeyboardShortcuts({ if (currentDialog.mode === 'icon') { return currentDialog; } + if (currentDialog.mode === 'ui-design') { + return currentDialog; + } return null; }); }; @@ -154,6 +170,8 @@ export function useImageCanvasKeyboardShortcuts({ event.preventDefault(); setGenerateDialog(null); setActiveTool('select'); + setIsGenerationReferenceMenuOpen(false); + setIsPickingGenerationReferenceFromCanvas(false); setIsCharacterSpecMenuOpen(false); setIsCharacterReferenceMenuOpen(false); setIsPickingCharacterSpecFromCanvas(false); @@ -200,6 +218,8 @@ export function useImageCanvasKeyboardShortcuts({ setContextMenu, setGenerateDialog, setImageContextMenu, + setIsGenerationReferenceMenuOpen, + setIsPickingGenerationReferenceFromCanvas, setIsCharacterSpecMenuOpen, setIsCharacterReferenceMenuOpen, setIsIconSpecMenuOpen, diff --git a/src/components/image-editor/useImageCanvasStageInteractions.test.tsx b/src/components/image-editor/useImageCanvasStageInteractions.test.tsx index df67f5055..8b8fe15fa 100644 --- a/src/components/image-editor/useImageCanvasStageInteractions.test.tsx +++ b/src/components/image-editor/useImageCanvasStageInteractions.test.tsx @@ -108,13 +108,17 @@ function asCanvasGenerationDialog( function StageInteractionsHarness({ pickCharacterSpecFromLayer = vi.fn(), + pickGenerationReferenceFromLayer = vi.fn(), pickIconSpecFromLayer = vi.fn(), + isPickingGenerationReferenceFromCanvas = false, activateCanvasGenerationDialog = vi.fn(), moveViewportFromMinimapPointer = vi.fn(), updateViewportFromMinimapDrag = vi.fn(), }: { pickCharacterSpecFromLayer?: (layer: CanvasLayer) => void; + pickGenerationReferenceFromLayer?: (layer: CanvasLayer) => void; pickIconSpecFromLayer?: (layer: CanvasLayer) => void; + isPickingGenerationReferenceFromCanvas?: boolean; activateCanvasGenerationDialog?: ( dialog: CanvasGenerationDialogState, ) => void; @@ -175,16 +179,20 @@ function StageInteractionsHarness({ generateDialog, setGenerateDialog, isPickingCharacterSpecFromCanvas: false, + isPickingGenerationReferenceFromCanvas, isPickingCharacterReferenceFromCanvas: false, isPickingIconSpecFromCanvas: false, + isPickingUiDesignSpecFromCanvas: false, clearCanvasFocus: () => { setSelectedLayerId(null); setSelectedLayerIds([]); setClearCount((currentCount) => currentCount + 1); }, pickCharacterSpecFromLayer, + pickGenerationReferenceFromLayer, pickCharacterReferenceFromLayer: vi.fn(), pickIconSpecFromLayer, + pickUiDesignSpecFromLayer: vi.fn(), activateCanvasGenerationDialog, updateCanvasGenerationDialogById: (dialogId, updater) => { setGenerateDialog((currentDialog) => { diff --git a/src/components/image-editor/useImageCanvasStageInteractions.ts b/src/components/image-editor/useImageCanvasStageInteractions.ts index 865a27cb3..6d967a6f2 100644 --- a/src/components/image-editor/useImageCanvasStageInteractions.ts +++ b/src/components/image-editor/useImageCanvasStageInteractions.ts @@ -54,12 +54,16 @@ type UseImageCanvasStageInteractionsOptions = { generateDialog: GenerateDialogState | null; setGenerateDialog: Dispatch>; isPickingCharacterSpecFromCanvas: boolean; + isPickingGenerationReferenceFromCanvas: boolean; isPickingCharacterReferenceFromCanvas: boolean; isPickingIconSpecFromCanvas: boolean; + isPickingUiDesignSpecFromCanvas: boolean; clearCanvasFocus: () => void; pickCharacterSpecFromLayer: (layer: CanvasLayer) => void; + pickGenerationReferenceFromLayer: (layer: CanvasLayer) => void; pickCharacterReferenceFromLayer: (layer: CanvasLayer) => void; pickIconSpecFromLayer: (layer: CanvasLayer) => void; + pickUiDesignSpecFromLayer: (layer: CanvasLayer) => void; activateCanvasGenerationDialog: ( dialog: CanvasGenerationDialogState, ) => void; @@ -92,12 +96,16 @@ export function useImageCanvasStageInteractions({ generateDialog, setGenerateDialog, isPickingCharacterSpecFromCanvas, + isPickingGenerationReferenceFromCanvas, isPickingCharacterReferenceFromCanvas, isPickingIconSpecFromCanvas, + isPickingUiDesignSpecFromCanvas, clearCanvasFocus, pickCharacterSpecFromLayer, + pickGenerationReferenceFromLayer, pickCharacterReferenceFromLayer, pickIconSpecFromLayer, + pickUiDesignSpecFromLayer, activateCanvasGenerationDialog, updateCanvasGenerationDialogById, moveViewportFromMinimapPointer, @@ -190,6 +198,18 @@ export function useImageCanvasStageInteractions({ event.stopPropagation(); return; } + if ( + isPickingGenerationReferenceFromCanvas && + (generateDialog?.mode === 'generate' || + generateDialog?.mode === 'video' || + generateDialog?.mode === 'spec') + ) { + event.preventDefault(); + event.stopPropagation(); + suppressNextLayerClickRef.current = true; + pickGenerationReferenceFromLayer(layer); + return; + } if ( isPickingCharacterSpecFromCanvas && generateDialog?.mode === 'character' @@ -217,6 +237,16 @@ export function useImageCanvasStageInteractions({ pickIconSpecFromLayer(layer); return; } + if ( + isPickingUiDesignSpecFromCanvas && + generateDialog?.mode === 'ui-design' + ) { + event.preventDefault(); + event.stopPropagation(); + suppressNextLayerClickRef.current = true; + pickUiDesignSpecFromLayer(layer); + return; + } event.preventDefault(); event.stopPropagation(); @@ -244,12 +274,15 @@ export function useImageCanvasStageInteractions({ effectiveTool, generateDialog?.mode, isPickingCharacterSpecFromCanvas, + isPickingGenerationReferenceFromCanvas, isPickingCharacterReferenceFromCanvas, isPickingIconSpecFromCanvas, layers, + pickGenerationReferenceFromLayer, pickCharacterReferenceFromLayer, pickCharacterSpecFromLayer, pickIconSpecFromLayer, + pickUiDesignSpecFromLayer, selectedLayerIds, setGenerateDialog, setSelectedLayerId, @@ -271,6 +304,9 @@ export function useImageCanvasStageInteractions({ if (isPickingCharacterSpecFromCanvas) { return; } + if (isPickingGenerationReferenceFromCanvas) { + return; + } if (isPickingCharacterReferenceFromCanvas) { return; } @@ -289,6 +325,7 @@ export function useImageCanvasStageInteractions({ }, [ isPickingCharacterSpecFromCanvas, + isPickingGenerationReferenceFromCanvas, isPickingCharacterReferenceFromCanvas, isPickingIconSpecFromCanvas, onCloseImageContextMenu, diff --git a/src/components/image-editor/useImageCanvasUploadWorkflow.test.tsx b/src/components/image-editor/useImageCanvasUploadWorkflow.test.tsx index 3696420e0..210792b05 100644 --- a/src/components/image-editor/useImageCanvasUploadWorkflow.test.tsx +++ b/src/components/image-editor/useImageCanvasUploadWorkflow.test.tsx @@ -1,6 +1,12 @@ /* @vitest-environment jsdom */ -import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { + act, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react'; import { useRef, useState } from 'react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; @@ -130,7 +136,7 @@ function UploadWorkflowHarness({ {selectedLayerId ?? '-'} {generateDialog - ? `${generateDialog.mode}:${generateDialog.status}:${generateDialog.characterSpecReference?.label ?? '-'}:${generateDialog.characterReferences?.length ?? 0}:${generateDialog.iconSpecReference?.label ?? '-'}:${generateDialog.specReference?.label ?? '-'}` + ? `${generateDialog.mode}:${generateDialog.status}:${generateDialog.characterSpecReference?.label ?? '-'}:${generateDialog.characterReferences?.length ?? 0}:${generateDialog.iconSpecReference?.label ?? '-'}:${generateDialog.specReference?.label ?? '-'}:${generateDialog.generationReferences?.length ?? 0}:${generateDialog.generationReferences?.[0]?.label ?? '-'}` : '-'} + + ); -} +}); diff --git a/src/components/image-editor/ImageCanvasBasicGenerationComposerView.test.tsx b/src/components/image-editor/ImageCanvasBasicGenerationComposerView.test.tsx index 39861f183..212a5029c 100644 --- a/src/components/image-editor/ImageCanvasBasicGenerationComposerView.test.tsx +++ b/src/components/image-editor/ImageCanvasBasicGenerationComposerView.test.tsx @@ -77,7 +77,7 @@ describe('ImageCanvasBasicGenerationComposerView', () => { expect(screen.getByLabelText('当前提示词').textContent).toBe('新的提示'); expect(screen.getByLabelText('当前状态').textContent).toBe('idle'); expect(screen.getByLabelText('当前错误').textContent).toBe('-'); - expect(requestUpload).toHaveBeenCalledWith('asset'); + expect(requestUpload).not.toHaveBeenCalled(); expect(submitGeneration).toHaveBeenCalledWith( expect.objectContaining({ prompt: '新的提示', status: 'idle' }), ); diff --git a/src/components/image-editor/ImageCanvasBasicGenerationComposerView.tsx b/src/components/image-editor/ImageCanvasBasicGenerationComposerView.tsx index 913597dd5..003e14201 100644 --- a/src/components/image-editor/ImageCanvasBasicGenerationComposerView.tsx +++ b/src/components/image-editor/ImageCanvasBasicGenerationComposerView.tsx @@ -1,7 +1,17 @@ import { ChevronDown, ImageIcon, X } from 'lucide-react'; -import { type CSSProperties, type Dispatch, type SetStateAction } from 'react'; +import { + type CSSProperties, + type Dispatch, + type ReactNode, + type RefObject, + type SetStateAction, +} from 'react'; import { PlatformActionButton } from '../common/PlatformActionButton'; +import { + PlatformFloatingMenu, + PlatformFloatingMenuItem, +} from '../common/PlatformFloatingMenu'; import { PlatformIconButton } from '../common/PlatformIconButton'; import { PlatformInlineOptionButton } from '../common/PlatformInlineOptionButton'; import { PlatformStatusMessage } from '../common/PlatformStatusMessage'; @@ -9,14 +19,23 @@ import { PlatformTextField } from '../common/PlatformTextField'; import { EditorIconButton } from './ImageCanvasEditorPrimitives'; import type { GenerateDialogState, - UploadTarget, } from './ImageCanvasEditorTypes'; type ImageCanvasBasicGenerationComposerViewProps = { dialog: GenerateDialogState; style: CSSProperties; setGenerateDialog: Dispatch>; - onRequestUpload: (target: UploadTarget) => void; + generationReferenceButtonRef?: RefObject; + isGenerationReferenceMenuOpen?: boolean; + setIsGenerationReferenceMenuOpen?: Dispatch>; + setIsPickingGenerationReferenceFromCanvas?: Dispatch>; + renderEditorPortal?: (node: ReactNode) => ReactNode; + buildPortalMenuStyle?: ( + anchor: HTMLElement | null, + placement: 'above' | 'below', + ) => CSSProperties; + onRequestUpload: (target: 'generation-reference') => void; + onToggleReferenceMenu?: () => void; onSubmit: (dialog: GenerateDialogState) => void; onClose: () => void; }; @@ -37,30 +56,39 @@ export function ImageCanvasBasicGenerationComposerView({ dialog, style, setGenerateDialog, + generationReferenceButtonRef, + isGenerationReferenceMenuOpen = false, + setIsGenerationReferenceMenuOpen, + setIsPickingGenerationReferenceFromCanvas, + renderEditorPortal = (node) => node, + buildPortalMenuStyle = () => ({}), onRequestUpload, + onToggleReferenceMenu, onSubmit, onClose, }: ImageCanvasBasicGenerationComposerViewProps) { return ( -
event.stopPropagation()} - onSubmit={(event) => { - event.preventDefault(); - if (dialog.status !== 'generating') { - onSubmit(dialog); - } - }} - > + <> + event.stopPropagation()} + onSubmit={(event) => { + event.preventDefault(); + if (dialog.status !== 'generating') { + onSubmit(dialog); + } + }} + > onRequestUpload('asset')} + onClick={() => onToggleReferenceMenu?.()} icon={} > 参考图 @@ -146,6 +174,38 @@ export function ImageCanvasBasicGenerationComposerView({ disabled={dialog.status === 'generating'} onClick={onClose} /> - + + {isGenerationReferenceMenuOpen && generationReferenceButtonRef + ? renderEditorPortal( + + { + setIsGenerationReferenceMenuOpen?.(false); + setIsPickingGenerationReferenceFromCanvas?.(true); + }} + > + 从画布中选择 + + { + setIsGenerationReferenceMenuOpen?.(false); + setIsPickingGenerationReferenceFromCanvas?.(false); + onRequestUpload('generation-reference'); + }} + > + 上传图片 + + , + ) + : null} + ); } diff --git a/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx b/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx index 08059010c..6577a72d1 100644 --- a/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx @@ -173,9 +173,7 @@ describe('ImageCanvasEditorView generation integration', () => { expect(metadataButtons[0]).toBeTruthy(); fireEvent.click(metadataButtons[0]!); - const infoPanel = screen.getByRole('dialog', { - name: /生成图片 .*图片信息/, - }); + const infoPanel = screen.getByRole('dialog', { name: '图片信息' }); expect(within(infoPanel).queryByText('Prompt')).toBeNull(); expect( within(infoPanel).queryByRole('button', { name: '复制Prompt' }), @@ -1452,7 +1450,7 @@ describe('ImageCanvasEditorView generation integration', () => { })[0]!, ); const characterInfoPanel = screen.getByRole('dialog', { - name: /角色形象 .*图片信息/u, + name: '图片信息', }); expect(within(characterInfoPanel).queryByText('Prompt')).toBeNull(); expect(within(characterInfoPanel).getByText('生成输入')).toBeTruthy(); @@ -1679,9 +1677,7 @@ describe('ImageCanvasEditorView generation integration', () => { fireEvent.click( screen.getAllByRole('button', { name: '查看返回按钮图片信息' })[0]!, ); - const iconInfoPanel = screen.getByRole('dialog', { - name: '返回按钮图片信息', - }); + const iconInfoPanel = screen.getByRole('dialog', { name: '图片信息' }); expect(within(iconInfoPanel).queryByText('Prompt')).toBeNull(); expect(within(iconInfoPanel).getByText('生成输入')).toBeTruthy(); expect(within(iconInfoPanel).getByText('素材描述 1')).toBeTruthy(); @@ -2216,9 +2212,7 @@ describe('ImageCanvasEditorView generation integration', () => { ); fireEvent.click(metadataCornerButton); - const metadataDialog = screen.getByRole('dialog', { - name: /生成图片 .*图片信息/, - }); + const metadataDialog = screen.getByRole('dialog', { name: '图片信息' }); expect(metadataDialog).toBeTruthy(); expect(within(metadataDialog).getByText('图片类型')).toBeTruthy(); expect(within(metadataDialog).getByText('生成图片')).toBeTruthy(); @@ -2266,7 +2260,7 @@ describe('ImageCanvasEditorView generation integration', () => { })[0]!, ); const editedMetadataDialog = screen.getByRole('dialog', { - name: /生成图片 .* 修改结果图片信息/u, + name: '图片信息', }); expect(within(editedMetadataDialog).queryByText('Prompt')).toBeNull(); expect(within(editedMetadataDialog).getByText('修改要求')).toBeTruthy(); diff --git a/src/components/image-editor/ImageCanvasEditorShellView.test.tsx b/src/components/image-editor/ImageCanvasEditorShellView.test.tsx index 06234edeb..dc89bdf54 100644 --- a/src/components/image-editor/ImageCanvasEditorShellView.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorShellView.test.tsx @@ -225,7 +225,7 @@ describe('ImageCanvasEditorShellView', () => { expect(screen.getByRole('heading', { name: '默认项目' })).toBeTruthy(); expect(screen.getByLabelText('画布工作区')).toBeTruthy(); expect(screen.getByRole('toolbar', { name: 'AI画布工具栏' })).toBeTruthy(); - expect(screen.getByRole('dialog', { name: '测试图片图片信息' })).toBeTruthy(); + expect(screen.getByRole('dialog', { name: '图片信息' })).toBeTruthy(); const uploadInput = screen.getByLabelText('上传图片文件') as HTMLInputElement; fireEvent.change(uploadInput); diff --git a/src/components/image-editor/ImageCanvasEditorTypes.ts b/src/components/image-editor/ImageCanvasEditorTypes.ts index a0a56d243..4da5459cd 100644 --- a/src/components/image-editor/ImageCanvasEditorTypes.ts +++ b/src/components/image-editor/ImageCanvasEditorTypes.ts @@ -3,6 +3,7 @@ import type { EditorCharacterAnimationGenerationResult, EditorCharacterAnimationRatio, EditorCharacterAnimationResolution, + EditorVideoModel, } from '../../services/image-editor/editorProjectClient'; export type CanvasSourceType = 'uploaded' | 'generated' | 'mock_generated'; @@ -159,9 +160,9 @@ export type GenerateDialogState = { iconDescriptions?: string[]; uiDesignSpecReference?: CharacterReferenceImage | null; imageModel?: string; - videoModel?: string; + videoModel?: EditorVideoModel; videoAspectRatio?: string; - videoResolution?: string; + videoResolution?: '480p' | '720p'; videoDurationSeconds?: 4 | 5; videoMode?: 'std'; videoSound?: 'off'; diff --git a/src/components/image-editor/ImageCanvasEditorView.test.tsx b/src/components/image-editor/ImageCanvasEditorView.test.tsx index 816b63bd8..906893638 100644 --- a/src/components/image-editor/ImageCanvasEditorView.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.test.tsx @@ -489,7 +489,7 @@ describe('ImageCanvasEditorView', () => { ); fireEvent.click(infoButton); - const infoPanel = screen.getByRole('dialog', { name: '拼图素材图片信息' }); + const infoPanel = screen.getByRole('dialog', { name: '图片信息' }); expect(within(infoPanel).getByText('图片类型')).toBeTruthy(); expect(within(infoPanel).getByText('上传图片')).toBeTruthy(); expect(within(infoPanel).getByText('生成输入')).toBeTruthy(); @@ -547,9 +547,7 @@ describe('ImageCanvasEditorView', () => { fireEvent.click( screen.getAllByRole('button', { name: '查看旧布局图片图片信息' })[0]!, ); - const infoPanel = screen.getByRole('dialog', { - name: '旧布局图片图片信息', - }); + const infoPanel = screen.getByRole('dialog', { name: '图片信息' }); expect(within(infoPanel).queryByText('Size')).toBeNull(); expect(within(infoPanel).getByText('Resolution')).toBeTruthy(); expect(within(infoPanel).getByText('1536 x 1024 px')).toBeTruthy(); diff --git a/src/components/image-editor/ImageCanvasEditorView.tsx b/src/components/image-editor/ImageCanvasEditorView.tsx index a3621262b..c61a0f543 100644 --- a/src/components/image-editor/ImageCanvasEditorView.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.tsx @@ -49,6 +49,7 @@ export function ImageCanvasEditorView() { const specToolWrapRef = useRef(null); const characterSpecButtonRef = useRef(null); const characterReferenceButtonRef = useRef(null); + const generationReferenceButtonRef = useRef(null); const iconSpecButtonRef = useRef(null); const selectedLayerIdRef = useRef(null); const selectedLayerIdsRef = useRef([]); @@ -350,14 +351,17 @@ export function ImageCanvasEditorView() { layers, canvasSize, viewport, + setViewport, layerCounterRef, specToolWrapRef, characterSpecButtonRef, characterReferenceButtonRef, + generationReferenceButtonRef, iconSpecButtonRef, generateDialog, setGenerateDialog, activeCanvasGenerationDialog, + canvasGenerationDialogs, openCanvasGenerationDialog, updateCanvasGenerationDialogById, removeCanvasGenerationDialogById, @@ -376,8 +380,11 @@ export function ImageCanvasEditorView() { quickEditPanel, setQuickEditPanel, setIsSpecMenuOpen, + setIsGenerationReferenceMenuOpen, setIsCharacterSpecMenuOpen, setIsCharacterReferenceMenuOpen, + isPickingGenerationReferenceFromCanvas, + setIsPickingGenerationReferenceFromCanvas, isPickingCharacterSpecFromCanvas, setIsPickingCharacterSpecFromCanvas, isPickingCharacterReferenceFromCanvas, @@ -385,12 +392,17 @@ export function ImageCanvasEditorView() { setIsIconSpecMenuOpen, isPickingIconSpecFromCanvas, setIsPickingIconSpecFromCanvas, + setIsUiDesignSpecMenuOpen, + isPickingUiDesignSpecFromCanvas, + setIsPickingUiDesignSpecFromCanvas, openCharacterAnimationPanel, openEditDialog, openQuickEditPanel, pickCharacterSpecFromLayer, + pickGenerationReferenceFromLayer, pickCharacterReferenceFromLayer, pickIconSpecFromLayer, + pickUiDesignSpecFromLayer, hideGeneratedLayerPanelAfterBlur, clearDeletedLayerGenerationState, generationComposerStyle, @@ -482,12 +494,16 @@ export function ImageCanvasEditorView() { generateDialog, setGenerateDialog, isPickingCharacterSpecFromCanvas, + isPickingGenerationReferenceFromCanvas, isPickingCharacterReferenceFromCanvas, isPickingIconSpecFromCanvas, + isPickingUiDesignSpecFromCanvas, clearCanvasFocus, pickCharacterSpecFromLayer, + pickGenerationReferenceFromLayer, pickCharacterReferenceFromLayer, pickIconSpecFromLayer, + pickUiDesignSpecFromLayer, activateCanvasGenerationDialog, updateCanvasGenerationDialogById, moveViewportFromMinimapPointer, @@ -514,12 +530,16 @@ export function ImageCanvasEditorView() { setQuickEditPanel, closeEditorChromePanels, setIsSpecMenuOpen, + setIsGenerationReferenceMenuOpen, + setIsPickingGenerationReferenceFromCanvas, setIsCharacterSpecMenuOpen, setIsCharacterReferenceMenuOpen, setIsPickingCharacterSpecFromCanvas, setIsPickingCharacterReferenceFromCanvas, setIsIconSpecMenuOpen, setIsPickingIconSpecFromCanvas, + setIsUiDesignSpecMenuOpen, + setIsPickingUiDesignSpecFromCanvas, setIsSpacePanning, setShiftPressed, }); diff --git a/src/components/image-editor/ImageCanvasGenerationComposerView.tsx b/src/components/image-editor/ImageCanvasGenerationComposerView.tsx index 9e646bda4..668ba6dcf 100644 --- a/src/components/image-editor/ImageCanvasGenerationComposerView.tsx +++ b/src/components/image-editor/ImageCanvasGenerationComposerView.tsx @@ -1,3 +1,4 @@ +import { ImageIcon } from 'lucide-react'; import { type CSSProperties, type Dispatch, @@ -11,6 +12,9 @@ import { PlatformFloatingMenu, PlatformFloatingMenuItem, } from '../common/PlatformFloatingMenu'; +import { PlatformActionButton } from '../common/PlatformActionButton'; +import { PlatformIconButton } from '../common/PlatformIconButton'; +import { PlatformTextField } from '../common/PlatformTextField'; import { ImageCanvasBasicGenerationComposerView } from './ImageCanvasBasicGenerationComposerView'; import { ImageCanvasCharacterAnimationPanelView } from './ImageCanvasCharacterAnimationPanelView'; import { ImageCanvasCharacterGenerationComposerView } from './ImageCanvasCharacterGenerationComposerView'; @@ -18,7 +22,10 @@ import { ImageCanvasEditGenerationModalView } from './ImageCanvasEditGenerationM import { ImageCanvasIconSpritesheetComposerView } from './ImageCanvasIconSpritesheetComposerView'; import { ImageCanvasQuickEditPanelView } from './ImageCanvasQuickEditPanelView'; import { ImageCanvasSpecGenerationPanelView } from './ImageCanvasSpecGenerationPanelView'; -import { SPEC_TYPE_LABEL } from './ImageCanvasGenerationModel'; +import { + SPEC_TYPE_LABEL, + calculateEditorVideoPrice, +} from './ImageCanvasGenerationModel'; import type { CharacterAnimationPanelState, CanvasLayer, @@ -33,14 +40,19 @@ type ImageCanvasGenerationComposerViewProps = { specToolWrapRef: RefObject; characterSpecButtonRef: RefObject; characterReferenceButtonRef: RefObject; + generationReferenceButtonRef: RefObject; iconSpecButtonRef: RefObject; isSpecMenuOpen: boolean; + isGenerationReferenceMenuOpen: boolean; isCharacterSpecMenuOpen: boolean; isCharacterReferenceMenuOpen: boolean; isIconSpecMenuOpen: boolean; + isUiDesignSpecMenuOpen: boolean; + isPickingGenerationReferenceFromCanvas: boolean; isPickingCharacterSpecFromCanvas: boolean; isPickingCharacterReferenceFromCanvas: boolean; isPickingIconSpecFromCanvas: boolean; + isPickingUiDesignSpecFromCanvas: boolean; generateDialog: GenerateDialogState | null; generationComposerStyle: CSSProperties | null; iconComposerStyle: CSSProperties | null; @@ -58,12 +70,16 @@ type ImageCanvasGenerationComposerViewProps = { setCharacterAnimationPanel: Dispatch< SetStateAction >; + setIsGenerationReferenceMenuOpen: Dispatch>; setIsCharacterSpecMenuOpen: Dispatch>; setIsCharacterReferenceMenuOpen: Dispatch>; setIsIconSpecMenuOpen: Dispatch>; + setIsUiDesignSpecMenuOpen: Dispatch>; + setIsPickingGenerationReferenceFromCanvas: Dispatch>; setIsPickingCharacterSpecFromCanvas: Dispatch>; setIsPickingCharacterReferenceFromCanvas: Dispatch>; setIsPickingIconSpecFromCanvas: Dispatch>; + setIsPickingUiDesignSpecFromCanvas: Dispatch>; onOpenSpecDialog: (specType: SpecGenerationType) => void; onRequestUpload: (target: UploadTarget) => void; onSubmitImageGeneration: (dialog: GenerateDialogState) => void; @@ -116,18 +132,153 @@ function renderEditorPortal(node: ReactNode) { return createPortal(node, document.body); } +function ImageCanvasVideoGenerationComposerView({ + dialog, + style, + generationReferenceButtonRef, + isGenerationReferenceMenuOpen, + setGenerateDialog, + setIsGenerationReferenceMenuOpen, + setIsPickingGenerationReferenceFromCanvas, + onRequestUpload, + onSubmit, +}: { + dialog: GenerateDialogState; + style: CSSProperties; + generationReferenceButtonRef: RefObject; + isGenerationReferenceMenuOpen: boolean; + setGenerateDialog: Dispatch>; + setIsGenerationReferenceMenuOpen: Dispatch>; + setIsPickingGenerationReferenceFromCanvas: Dispatch>; + onRequestUpload: (target: UploadTarget) => void; + onSubmit: (dialog: GenerateDialogState) => void; +}) { + const resolution = dialog.videoResolution ?? '480p'; + const durationSeconds = dialog.videoDurationSeconds ?? 4; + const price = calculateEditorVideoPrice(resolution, durationSeconds); + return ( + <> +
event.stopPropagation()} + onSubmit={(event) => { + event.preventDefault(); + if (dialog.status !== 'generating') { + onSubmit(dialog); + } + }} + > + setIsGenerationReferenceMenuOpen((open) => !open)} + icon={} + > + 参考图 + + + setGenerateDialog((currentDialog) => + currentDialog + ? { + ...currentDialog, + prompt: event.target.value, + status: + currentDialog.status === 'failed' + ? 'idle' + : currentDialog.status, + errorMessage: + currentDialog.status === 'failed' + ? undefined + : currentDialog.errorMessage, + } + : currentDialog, + ) + } + /> +
+ + 16:9 · {resolution} · {durationSeconds}秒 + + + {dialog.status === 'generating' ? '生成中' : `${price}`} + +
+ + {isGenerationReferenceMenuOpen + ? renderEditorPortal( + + { + setIsGenerationReferenceMenuOpen(false); + setIsPickingGenerationReferenceFromCanvas(true); + }} + > + 从画布中选择 + + { + setIsGenerationReferenceMenuOpen(false); + setIsPickingGenerationReferenceFromCanvas(false); + onRequestUpload('generation-reference'); + }} + > + 上传图片 + + , + ) + : null} + + ); +} + export function ImageCanvasGenerationComposerView({ specToolWrapRef, characterSpecButtonRef, characterReferenceButtonRef, + generationReferenceButtonRef, iconSpecButtonRef, isSpecMenuOpen, + isGenerationReferenceMenuOpen, isCharacterSpecMenuOpen, isCharacterReferenceMenuOpen, isIconSpecMenuOpen, + isUiDesignSpecMenuOpen, + isPickingGenerationReferenceFromCanvas, isPickingCharacterSpecFromCanvas, isPickingCharacterReferenceFromCanvas, isPickingIconSpecFromCanvas, + isPickingUiDesignSpecFromCanvas, generateDialog, generationComposerStyle, iconComposerStyle, @@ -143,12 +294,16 @@ export function ImageCanvasGenerationComposerView({ setGenerateDialog, setQuickEditPanel, setCharacterAnimationPanel, + setIsGenerationReferenceMenuOpen, setIsCharacterSpecMenuOpen, setIsCharacterReferenceMenuOpen, setIsIconSpecMenuOpen, + setIsUiDesignSpecMenuOpen, + setIsPickingGenerationReferenceFromCanvas, setIsPickingCharacterSpecFromCanvas, setIsPickingCharacterReferenceFromCanvas, setIsPickingIconSpecFromCanvas, + setIsPickingUiDesignSpecFromCanvas, onOpenSpecDialog, onRequestUpload, onSubmitImageGeneration, @@ -192,7 +347,18 @@ export function ImageCanvasGenerationComposerView({ dialog={generateDialog} style={generationComposerStyle} setGenerateDialog={setGenerateDialog} + generationReferenceButtonRef={generationReferenceButtonRef} + isGenerationReferenceMenuOpen={isGenerationReferenceMenuOpen} + setIsGenerationReferenceMenuOpen={setIsGenerationReferenceMenuOpen} + setIsPickingGenerationReferenceFromCanvas={ + setIsPickingGenerationReferenceFromCanvas + } + renderEditorPortal={renderEditorPortal} + buildPortalMenuStyle={buildPortalMenuStyle} onRequestUpload={onRequestUpload} + onToggleReferenceMenu={() => + setIsGenerationReferenceMenuOpen((open) => !open) + } onSubmit={onSubmitImageGeneration} onClose={onCloseGenerateComposer} /> @@ -204,12 +370,58 @@ export function ImageCanvasGenerationComposerView({ ) : null} + {generateDialog?.mode === 'ui-design' && + generateDialog.composerOpen !== false && + generationComposerStyle ? ( + + ) : null} + + {generateDialog?.mode === 'video' && + generateDialog.composerOpen !== false && + generationComposerStyle ? ( + + ) : null} + {generateDialog?.mode === 'character' && generationComposerStyle ? ( ) : null} + {isPickingGenerationReferenceFromCanvas ? ( +
+ 请选择画布中的图片作为参考图,按 Esc 退出 +
+ ) : null} {isPickingIconSpecFromCanvas ? (
请选择画布中的图标素材规范,按 Esc 退出
) : null} + {isPickingUiDesignSpecFromCanvas ? ( +
+ 请选择画布中的图标素材规范,按 Esc 退出 +
+ ) : null} {quickEditPanel && quickEditPanel.status !== 'generating' && diff --git a/src/components/image-editor/ImageCanvasGenerationDialogModel.ts b/src/components/image-editor/ImageCanvasGenerationDialogModel.ts index c03421283..cb0e63a10 100644 --- a/src/components/image-editor/ImageCanvasGenerationDialogModel.ts +++ b/src/components/image-editor/ImageCanvasGenerationDialogModel.ts @@ -23,6 +23,8 @@ import { ICON_FRAME_ORIGINAL_SIZE, SPEC_FRAME_DISPLAY_SIZE, SPEC_FRAME_ORIGINAL_SIZE, + UI_DESIGN_FRAME_DISPLAY_SIZE, + UI_DESIGN_FRAME_ORIGINAL_SIZE, } from './ImageCanvasGenerationModel'; type CanvasSize = { width: number; height: number }; @@ -180,6 +182,37 @@ export function createIconGenerationDialogDraft({ }; } +export function createUiDesignGenerationDialogDraft({ + canvasSize, + viewport, + imageModel, +}: { + canvasSize: CanvasSize; + viewport: CanvasViewport; + imageModel: string; +}): Omit { + const worldCenter = getViewportWorldCenter({ canvasSize, viewport }); + const dimensionDefaults = resolveImageDimensionDefaults(imageModel); + return { + mode: 'ui-design', + prompt: '', + status: 'idle', + composerOpen: true, + uiDesignSpecReference: null, + imageModel, + aspectRatio: dimensionDefaults.aspectRatio, + imageSize: dimensionDefaults.imageSize, + placeholder: { + x: worldCenter.x - UI_DESIGN_FRAME_DISPLAY_SIZE.width / 2, + y: worldCenter.y - UI_DESIGN_FRAME_DISPLAY_SIZE.height / 2, + width: UI_DESIGN_FRAME_DISPLAY_SIZE.width, + height: UI_DESIGN_FRAME_DISPLAY_SIZE.height, + originalWidth: UI_DESIGN_FRAME_ORIGINAL_SIZE.width, + originalHeight: UI_DESIGN_FRAME_ORIGINAL_SIZE.height, + }, + }; +} + export function createEditDialogDraft( sourceLayer: CanvasLayer, ): GenerateDialogState { @@ -255,6 +288,24 @@ export function appendCharacterReference( : dialog; } +export function appendGenerationReference( + dialog: GenerateDialogState | null, + layer: CanvasLayer, +): GenerateDialogState | null { + return dialog?.mode === 'generate' || + dialog?.mode === 'video' || + dialog?.mode === 'spec' + ? { + ...resetFailedGenerationDialog(dialog), + generationReferences: [ + ...(dialog.generationReferences ?? []), + createCanvasLayerReference(layer), + ], + composerOpen: true, + } + : dialog; +} + export function assignIconSpecReference( dialog: GenerateDialogState | null, layer: CanvasLayer, @@ -271,6 +322,19 @@ export function assignIconSpecReference( : dialog; } +export function assignUiDesignSpecReference( + dialog: GenerateDialogState | null, + layer: CanvasLayer, +): GenerateDialogState | null { + return dialog?.mode === 'ui-design' + ? { + ...resetFailedGenerationDialog(dialog), + uiDesignSpecReference: createCanvasLayerReference(layer), + composerOpen: true, + } + : dialog; +} + export function updateSpecFormDialogValue( dialog: GenerateDialogState | null, key: keyof SpecFormValues, diff --git a/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts b/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts index c92bc16ff..65697cd2b 100644 --- a/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts +++ b/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts @@ -2,6 +2,7 @@ import type { EditorCharacterAnimationGenerationInput, EditorIconSpritesheetGenerationInput, EditorImageGenerationInput, + EditorVideoGenerationInput, } from '../../services/image-editor/editorProjectClient'; import type { CanvasGenerationInputs, @@ -22,7 +23,10 @@ import { buildImageGenerationInputs, buildSpecGenerationInputs, buildSpecPrompt, + buildUiDesignGenerationInputs, + buildVideoGenerationInputs, calculateCharacterAnimationPrice, + calculateEditorVideoPrice, resolveCharacterAnimationSourceImageSrc, } from './ImageCanvasGenerationModel'; @@ -49,6 +53,15 @@ export type ImageGenerationSubmissionPlan = generationInputs: CanvasGenerationInputs; }; rememberImageModel?: string; + } + | { + kind: 'video'; + normalizedPrompt: string; + input: EditorVideoGenerationInput; + result: { + title: string; + generationInputs: CanvasGenerationInputs; + }; }; export type IconSpritesheetGenerationSubmissionPlan = @@ -156,14 +169,82 @@ export function buildImageGenerationSubmissionPlan({ }; } + if (dialog.mode === 'ui-design') { + const imageModel = dialog.imageModel ?? DEFAULT_IMAGE_MODEL; + const referenceImageSrcs = [dialog.uiDesignSpecReference?.src].filter( + (src): src is string => Boolean(src), + ); + return { + kind: 'image', + normalizedPrompt, + input: { + prompt: normalizedPrompt, + kind: 'ui-design', + model: imageModel, + aspectRatio: dialog.aspectRatio ?? '16:9', + imageSize: dialog.imageSize ?? '1K', + ...(referenceImageSrcs.length ? { referenceImageSrcs } : {}), + }, + result: { + assetKind: 'ui-design', + title: `UI设计图 ${nextGeneratedIndex}`, + generationInputs: buildUiDesignGenerationInputs( + normalizedPrompt, + dialog.uiDesignSpecReference, + ), + }, + rememberImageModel: imageModel, + }; + } + + if (dialog.mode === 'video') { + const resolution = dialog.videoResolution ?? '480p'; + const durationSeconds = dialog.videoDurationSeconds ?? 4; + const model = dialog.videoModel ?? 'seedance2.0'; + return { + kind: 'video', + normalizedPrompt, + input: { + prompt: normalizedPrompt, + model, + aspectRatio: '16:9', + durationSeconds, + resolution, + mode: 'std', + sound: 'off', + priceMudPoints: calculateEditorVideoPrice( + resolution, + durationSeconds, + ), + }, + result: { + title: `生成视频 ${nextGeneratedIndex}`, + generationInputs: buildVideoGenerationInputs( + normalizedPrompt, + dialog.generationReferences, + ), + }, + }; + } + return { kind: 'image', normalizedPrompt, input: { prompt: normalizedPrompt, + ...(dialog.generationReferences?.length + ? { + referenceImageSrcs: dialog.generationReferences.map( + (reference) => reference.src, + ), + } + : {}), }, result: { - generationInputs: buildImageGenerationInputs(normalizedPrompt), + generationInputs: buildImageGenerationInputs( + normalizedPrompt, + dialog.generationReferences, + ), }, }; } diff --git a/src/components/image-editor/ImageCanvasIconSpritesheetComposerView.test.tsx b/src/components/image-editor/ImageCanvasIconSpritesheetComposerView.test.tsx index a0b025d92..eb10fbae8 100644 --- a/src/components/image-editor/ImageCanvasIconSpritesheetComposerView.test.tsx +++ b/src/components/image-editor/ImageCanvasIconSpritesheetComposerView.test.tsx @@ -4,7 +4,10 @@ import { fireEvent, render, screen } from '@testing-library/react'; import { createRef, useState } from 'react'; import { describe, expect, it, vi } from 'vitest'; -import type { GenerateDialogState } from './ImageCanvasEditorTypes'; +import type { + GenerateDialogState, + UploadTarget, +} from './ImageCanvasEditorTypes'; import { ImageCanvasIconSpritesheetComposerView } from './ImageCanvasIconSpritesheetComposerView'; function createIconDialog( @@ -35,7 +38,7 @@ function IconComposerHarness({ initialDialog: GenerateDialogState; initialMenuOpen?: boolean; onOpenSpecDialog?: (specType: 'character' | 'ui' | 'icon' | 'custom') => void; - onRequestUpload?: (target: 'asset' | 'spec-reference' | 'character-spec' | 'character-reference' | 'icon-spec') => void; + onRequestUpload?: (target: UploadTarget) => void; onUpdateIconDescription?: (index: number, value: string) => void; onAddIconDescription?: () => void; onRememberImageModel?: (model: string) => void; diff --git a/src/components/image-editor/ImageCanvasSpecGenerationPanelView.test.tsx b/src/components/image-editor/ImageCanvasSpecGenerationPanelView.test.tsx index f2af6a669..eada5c475 100644 --- a/src/components/image-editor/ImageCanvasSpecGenerationPanelView.test.tsx +++ b/src/components/image-editor/ImageCanvasSpecGenerationPanelView.test.tsx @@ -109,7 +109,7 @@ describe('ImageCanvasSpecGenerationPanelView', () => { cleanup(); renderPanel({ dialog: createSpecDialog({ specType: 'icon' }) }); - expect(screen.queryByText('添加参考图')).toBeNull(); + expect(screen.getByRole('button', { name: '参考图' })).toBeTruthy(); }); it('requests reference upload and submits while idle', () => { @@ -122,7 +122,7 @@ describe('ImageCanvasSpecGenerationPanelView', () => { onSubmit: submitSpec, }); - fireEvent.click(screen.getByRole('button', { name: '添加参考图' })); + fireEvent.click(screen.getByRole('button', { name: '参考图' })); fireEvent.click(screen.getByRole('button', { name: '提交生成规范' })); expect(requestUpload).toHaveBeenCalledWith('spec-reference'); diff --git a/src/components/image-editor/ImageCanvasSpecGenerationPanelView.tsx b/src/components/image-editor/ImageCanvasSpecGenerationPanelView.tsx index 5d2aeb722..2fef0779c 100644 --- a/src/components/image-editor/ImageCanvasSpecGenerationPanelView.tsx +++ b/src/components/image-editor/ImageCanvasSpecGenerationPanelView.tsx @@ -1,8 +1,18 @@ -import { type CSSProperties } from 'react'; +import { + type CSSProperties, + type Dispatch, + type ReactNode, + type RefObject, + type SetStateAction, +} from 'react'; import { ImagePlus } from 'lucide-react'; import { PlatformActionButton } from '../common/PlatformActionButton'; import { PlatformFieldLabel } from '../common/PlatformFieldLabel'; +import { + PlatformFloatingMenu, + PlatformFloatingMenuItem, +} from '../common/PlatformFloatingMenu'; import { PlatformStatusMessage } from '../common/PlatformStatusMessage'; import { PlatformSelectField, @@ -21,6 +31,15 @@ import type { type ImageCanvasSpecGenerationPanelViewProps = { dialog: GenerateDialogState; style: CSSProperties; + isGenerationReferenceMenuOpen?: boolean; + generationReferenceButtonRef?: RefObject; + setIsGenerationReferenceMenuOpen?: Dispatch>; + setIsPickingGenerationReferenceFromCanvas?: Dispatch>; + renderEditorPortal?: (node: ReactNode) => ReactNode; + buildPortalMenuStyle?: ( + anchor: HTMLElement | null, + placement: 'above' | 'below', + ) => CSSProperties; onUpdateSpecFormValue: (key: keyof SpecFormValues, value: string) => void; onRequestUpload: (target: UploadTarget) => void; onSubmit: (dialog: GenerateDialogState) => void; @@ -29,26 +48,71 @@ type ImageCanvasSpecGenerationPanelViewProps = { export function ImageCanvasSpecGenerationPanelView({ dialog, style, + isGenerationReferenceMenuOpen = false, + generationReferenceButtonRef, + setIsGenerationReferenceMenuOpen, + setIsPickingGenerationReferenceFromCanvas, + renderEditorPortal = (node) => node, + buildPortalMenuStyle = () => ({}), onUpdateSpecFormValue, onRequestUpload, onSubmit, }: ImageCanvasSpecGenerationPanelViewProps) { + const isUiDesignDialog = dialog.mode === 'ui-design'; + const referenceLabel = isUiDesignDialog ? 'UI设计图标素材规范' : '参考图'; + const uploadTarget: UploadTarget = isUiDesignDialog + ? 'ui-design-icon-spec' + : 'spec-reference'; + const reference = isUiDesignDialog + ? dialog.uiDesignSpecReference + : dialog.specReference; + const openReferenceMenu = () => { + if (setIsGenerationReferenceMenuOpen) { + setIsGenerationReferenceMenuOpen((open) => !open); + return; + } + onRequestUpload(uploadTarget); + }; + return ( -
event.stopPropagation()} - onSubmit={(event) => { - event.preventDefault(); - if (dialog.status !== 'generating') { - onSubmit(dialog); - } - }} - > + <> + event.stopPropagation()} + onSubmit={(event) => { + event.preventDefault(); + if (dialog.status !== 'generating') { + onSubmit(dialog); + } + }} + >
- {dialog.specType === 'custom' ? ( + {isUiDesignDialog ? ( + + ) : dialog.specType === 'custom' ? (
@@ -102,8 +108,9 @@ export function ImageCanvasQuickEditPanelView({ ))} @@ -131,8 +138,9 @@ export function ImageCanvasQuickEditPanelView({ tone="secondary" size="sm" className="image-canvas-editor__quick-edit-submit" + disabled={isGenerating} > - 生成 + {isGenerating ? '生成中' : '生成'} ); diff --git a/src/components/image-editor/ImageCanvasRasterEditModel.test.ts b/src/components/image-editor/ImageCanvasRasterEditModel.test.ts new file mode 100644 index 000000000..d67eeb8ed --- /dev/null +++ b/src/components/image-editor/ImageCanvasRasterEditModel.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest'; + +import { + buildCropExpandGeometry, + buildCropExpandLayerPatch, + parseCropExpandInsetValue, +} from './ImageCanvasRasterEditModel'; + +describe('ImageCanvasRasterEditModel', () => { + it('builds transparent expansion geometry around the source image', () => { + expect( + buildCropExpandGeometry({ + width: 100, + height: 80, + insets: { left: 10, top: 6, right: 12, bottom: 8 }, + }), + ).toEqual({ + outputWidth: 122, + outputHeight: 94, + sourceX: 0, + sourceY: 0, + sourceWidth: 100, + sourceHeight: 80, + targetX: 10, + targetY: 6, + }); + }); + + it('builds crop geometry and moves the layer origin to the cropped bounds', () => { + const insets = { left: -10, top: -6, right: -12, bottom: -8 }; + expect( + buildCropExpandGeometry({ + width: 100, + height: 80, + insets, + }), + ).toMatchObject({ + outputWidth: 78, + outputHeight: 66, + sourceX: 10, + sourceY: 6, + sourceWidth: 78, + sourceHeight: 66, + targetX: 0, + targetY: 0, + }); + expect( + buildCropExpandLayerPatch({ + layerX: 50, + layerY: 40, + insets, + outputWidth: 78, + outputHeight: 66, + }), + ).toEqual({ + x: 60, + y: 46, + width: 78, + height: 66, + originalWidth: 78, + originalHeight: 66, + }); + }); + + it('parses integer crop-expand values and rejects invalid input', () => { + expect(parseCropExpandInsetValue(' 12 ')).toBe(12); + expect(parseCropExpandInsetValue('')).toBe(0); + expect(() => parseCropExpandInsetValue('左')).toThrow('裁扩数值必须是整数'); + }); +}); diff --git a/src/components/image-editor/ImageCanvasRasterEditModel.ts b/src/components/image-editor/ImageCanvasRasterEditModel.ts new file mode 100644 index 000000000..9ae0e651e --- /dev/null +++ b/src/components/image-editor/ImageCanvasRasterEditModel.ts @@ -0,0 +1,263 @@ +export type CropExpandInsets = { + left: number; + top: number; + right: number; + bottom: number; +}; + +export type CropExpandGeometry = { + outputWidth: number; + outputHeight: number; + sourceX: number; + sourceY: number; + sourceWidth: number; + sourceHeight: number; + targetX: number; + targetY: number; +}; + +export type RasterEditResult = { + imageSrc: string; + width: number; + height: number; +}; + +function assertCanvasSupport() { + if (typeof document === 'undefined') { + throw new Error('当前环境不支持图片编辑'); + } +} + +function loadImageElement(source: string) { + return new Promise((resolve, reject) => { + const image = new Image(); + image.crossOrigin = 'anonymous'; + image.onload = () => resolve(image); + image.onerror = () => reject(new Error('图片读取失败')); + image.src = source; + }); +} + +function createRasterCanvas(width: number, height: number) { + assertCanvasSupport(); + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + const context = canvas.getContext('2d', { willReadFrequently: true }); + if (!context) { + throw new Error('当前浏览器不支持图片编辑'); + } + return { canvas, context }; +} + +function resolveImageSize(image: HTMLImageElement) { + return { + width: Math.max(1, image.naturalWidth || image.width || 1), + height: Math.max(1, image.naturalHeight || image.height || 1), + }; +} + +export function parseCropExpandInsetValue(value: string) { + const trimmedValue = value.trim(); + if (!trimmedValue) { + return 0; + } + const parsedValue = Number.parseInt(trimmedValue, 10); + if (!Number.isFinite(parsedValue)) { + throw new Error('裁扩数值必须是整数'); + } + return parsedValue; +} + +export function buildCropExpandGeometry({ + width, + height, + insets, +}: { + width: number; + height: number; + insets: CropExpandInsets; +}): CropExpandGeometry { + const sourceWidth = Math.max(1, Math.round(width || 1)); + const sourceHeight = Math.max(1, Math.round(height || 1)); + const cropLeft = Math.max(0, -insets.left); + const cropTop = Math.max(0, -insets.top); + const cropRight = Math.max(0, -insets.right); + const cropBottom = Math.max(0, -insets.bottom); + const targetX = Math.max(0, insets.left); + const targetY = Math.max(0, insets.top); + const drawableWidth = sourceWidth - cropLeft - cropRight; + const drawableHeight = sourceHeight - cropTop - cropBottom; + if (drawableWidth <= 0 || drawableHeight <= 0) { + throw new Error('裁剪范围不能小于 1 像素'); + } + + return { + outputWidth: targetX + drawableWidth + Math.max(0, insets.right), + outputHeight: targetY + drawableHeight + Math.max(0, insets.bottom), + sourceX: cropLeft, + sourceY: cropTop, + sourceWidth: drawableWidth, + sourceHeight: drawableHeight, + targetX, + targetY, + }; +} + +export function buildCropExpandLayerPatch({ + layerX, + layerY, + insets, + outputWidth, + outputHeight, +}: { + layerX: number; + layerY: number; + insets: CropExpandInsets; + outputWidth: number; + outputHeight: number; +}) { + return { + x: layerX - insets.left, + y: layerY - insets.top, + width: outputWidth, + height: outputHeight, + originalWidth: outputWidth, + originalHeight: outputHeight, + }; +} + +export async function renderCropExpandImage({ + source, + insets, +}: { + source: string; + insets: CropExpandInsets; +}): Promise { + const image = await loadImageElement(source); + const imageSize = resolveImageSize(image); + const geometry = buildCropExpandGeometry({ + width: imageSize.width, + height: imageSize.height, + insets, + }); + const { canvas, context } = createRasterCanvas( + geometry.outputWidth, + geometry.outputHeight, + ); + // 中文注释:画布默认透明,扩大出来的新区域不填色,直接保留 alpha=0。 + context.clearRect(0, 0, geometry.outputWidth, geometry.outputHeight); + context.drawImage( + image, + geometry.sourceX, + geometry.sourceY, + geometry.sourceWidth, + geometry.sourceHeight, + geometry.targetX, + geometry.targetY, + geometry.sourceWidth, + geometry.sourceHeight, + ); + + return { + imageSrc: canvas.toDataURL('image/png'), + width: geometry.outputWidth, + height: geometry.outputHeight, + }; +} + +function colorDistance( + pixels: Uint8ClampedArray, + offset: number, + color: [number, number, number], +) { + const red = (pixels[offset] ?? 0) - color[0]; + const green = (pixels[offset + 1] ?? 0) - color[1]; + const blue = (pixels[offset + 2] ?? 0) - color[2]; + return Math.sqrt(red * red + green * green + blue * blue); +} + +function readPixelColor( + pixels: Uint8ClampedArray, + width: number, + x: number, + y: number, +): [number, number, number] { + const offset = (y * width + x) * 4; + return [ + pixels[offset] ?? 0, + pixels[offset + 1] ?? 0, + pixels[offset + 2] ?? 0, + ]; +} + +export async function removeImageBackground( + source: string, +): Promise { + const image = await loadImageElement(source); + const { width, height } = resolveImageSize(image); + const { canvas, context } = createRasterCanvas(width, height); + context.drawImage(image, 0, 0, width, height); + const imageData = context.getImageData(0, 0, width, height); + const pixels = imageData.data; + const cornerColors = [ + readPixelColor(pixels, width, 0, 0), + readPixelColor(pixels, width, width - 1, 0), + readPixelColor(pixels, width, 0, height - 1), + readPixelColor(pixels, width, width - 1, height - 1), + ]; + const visited = new Uint8Array(width * height); + const queue: number[] = []; + const threshold = 42; + + const shouldClearPixel = (index: number) => { + const offset = index * 4; + const alpha = pixels[offset + 3] ?? 0; + if (alpha <= 8) { + return true; + } + return cornerColors.some( + (color) => colorDistance(pixels, offset, color) <= threshold, + ); + }; + + const enqueue = (x: number, y: number) => { + if (x < 0 || y < 0 || x >= width || y >= height) { + return; + } + const index = y * width + x; + if (visited[index] || !shouldClearPixel(index)) { + return; + } + visited[index] = 1; + queue.push(index); + }; + + for (let x = 0; x < width; x += 1) { + enqueue(x, 0); + enqueue(x, height - 1); + } + for (let y = 0; y < height; y += 1) { + enqueue(0, y); + enqueue(width - 1, y); + } + + while (queue.length) { + const index = queue.shift()!; + const offset = index * 4; + pixels[offset + 3] = 0; + const x = index % width; + const y = Math.floor(index / width); + enqueue(x + 1, y); + enqueue(x - 1, y); + enqueue(x, y + 1); + enqueue(x, y - 1); + } + + context.putImageData(imageData, 0, 0); + return { + imageSrc: canvas.toDataURL('image/png'), + width, + height, + }; +} diff --git a/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx b/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx index fbf3bffca..7d60a9b7d 100644 --- a/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx +++ b/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx @@ -1,6 +1,12 @@ /* @vitest-environment jsdom */ -import { fireEvent, render, screen, within } from '@testing-library/react'; +import { + cleanup, + fireEvent, + render, + screen, + within, +} from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; import type { CanvasLayer } from './ImageCanvasEditorTypes'; @@ -30,11 +36,12 @@ function renderSelectedToolbar( const props: Parameters[0] = { selectedLayer: createLayer(), selectedToolbarStyle: { left: 12, top: 24 }, - onDeleteSelectedLayer: vi.fn(), onOpenQuickEditPanel: vi.fn(), - onOpenEditDialog: vi.fn(), + onOpenRedrawPanel: vi.fn(), + onOpenCropExpandPanel: vi.fn(), + onRemoveBackground: vi.fn(), onOpenCharacterAnimationPanel: vi.fn(), - onOpenLayerMetadata: vi.fn(), + onDownloadLayer: vi.fn(), ...overrides, }; @@ -44,33 +51,59 @@ function renderSelectedToolbar( } describe('ImageCanvasSelectedLayerToolbarView', () => { - it('renders common layer actions and forwards callbacks', () => { + it('renders common layer actions in the Lovart toolbar order and forwards callbacks', () => { const props = renderSelectedToolbar(); const toolbar = screen.getByRole('toolbar', { name: '图片工具栏' }); + const buttons = within(toolbar).getAllByRole('button'); + + expect(buttons.map((button) => button.getAttribute('aria-label'))).toEqual([ + '快速编辑', + '裁扩按钮', + '去除背景按钮', + '重绘', + '下载按钮', + ]); - fireEvent.click(within(toolbar).getByRole('button', { name: '删除图片' })); fireEvent.click(within(toolbar).getByRole('button', { name: '快速编辑' })); + fireEvent.click(within(toolbar).getByRole('button', { name: '裁扩按钮' })); + fireEvent.click( + within(toolbar).getByRole('button', { name: '去除背景按钮' }), + ); + fireEvent.click(within(toolbar).getByRole('button', { name: '重绘' })); + fireEvent.click(within(toolbar).getByRole('button', { name: '下载按钮' })); - expect(props.onDeleteSelectedLayer).toHaveBeenCalledTimes(1); expect(props.onOpenQuickEditPanel).toHaveBeenCalledWith(props.selectedLayer); + expect(props.onOpenCropExpandPanel).toHaveBeenCalledWith( + props.selectedLayer, + ); + expect(props.onRemoveBackground).toHaveBeenCalledWith(props.selectedLayer); + expect(props.onOpenRedrawPanel).toHaveBeenCalledWith(props.selectedLayer); + expect(props.onDownloadLayer).toHaveBeenCalledWith(props.selectedLayer); + expect( + within(toolbar).queryByRole('button', { name: '删除图片' }), + ).toBeNull(); + expect( + within(toolbar).queryByRole('button', { name: '修改图片' }), + ).toBeNull(); + expect( + within(toolbar).queryByRole('button', { name: /查看.*图片信息/u }), + ).toBeNull(); }); - it('renders generated and character actions only when applicable', () => { + it('renders character animation only for character layers', () => { const layer = createLayer({ sourceType: 'generated', assetKind: 'character', }); const props = renderSelectedToolbar({ selectedLayer: layer }); - fireEvent.click(screen.getByRole('button', { name: '修改图片' })); fireEvent.click(screen.getByRole('button', { name: '生成动画' })); - fireEvent.click( - screen.getByRole('button', { name: `查看${layer.title}图片信息` }), - ); - expect(props.onOpenEditDialog).toHaveBeenCalledWith(layer); expect(props.onOpenCharacterAnimationPanel).toHaveBeenCalledWith(layer); - expect(props.onOpenLayerMetadata).toHaveBeenCalledWith(layer); + + cleanup(); + renderSelectedToolbar({ selectedLayer: createLayer({ id: 'layer-2' }) }); + expect(screen.queryByRole('button', { name: '生成动画' })).toBeNull(); }); it('renders nothing without a selected layer or toolbar position', () => { @@ -78,11 +111,12 @@ describe('ImageCanvasSelectedLayerToolbarView', () => { , ); @@ -92,11 +126,12 @@ describe('ImageCanvasSelectedLayerToolbarView', () => { , ); diff --git a/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.tsx b/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.tsx index c879a33f6..718f12e6b 100644 --- a/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.tsx +++ b/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.tsx @@ -1,47 +1,36 @@ import { - Copy, Crop, - Info, - SlidersHorizontal, + Download, + Eraser, Sparkles, - Trash2, WandSparkles, } from 'lucide-react'; import type { CSSProperties } from 'react'; +import { PlatformIconButton } from '../common/PlatformIconButton'; import { EditorIconButton } from './ImageCanvasEditorPrimitives'; -import { isGeneratedLayer } from './ImageCanvasEditorModel'; import type { CanvasLayer } from './ImageCanvasEditorTypes'; type ImageCanvasSelectedLayerToolbarViewProps = { selectedLayer: CanvasLayer | null; selectedToolbarStyle: CSSProperties | null; - onDeleteSelectedLayer: () => void; onOpenQuickEditPanel: (layer: CanvasLayer) => void; - onOpenEditDialog: (layer: CanvasLayer) => void; + onOpenRedrawPanel: (layer: CanvasLayer) => void; + onOpenCropExpandPanel: (layer: CanvasLayer) => void; + onRemoveBackground: (layer: CanvasLayer) => void; onOpenCharacterAnimationPanel: (layer: CanvasLayer) => void; - onOpenLayerMetadata: (layer: CanvasLayer) => void; + onDownloadLayer: (layer: CanvasLayer) => void; }; -const layerToolButtons = [ - { label: '裁剪', icon: Crop }, - { label: '重绘', icon: Sparkles }, - { label: '调整', icon: SlidersHorizontal }, - { label: '复制', icon: Copy }, -]; - -function triggerPlaceholderAction(label: string) { - window.alert(`${label}功能建设中`); -} - export function ImageCanvasSelectedLayerToolbarView({ selectedLayer, selectedToolbarStyle, - onDeleteSelectedLayer, onOpenQuickEditPanel, - onOpenEditDialog, + onOpenRedrawPanel, + onOpenCropExpandPanel, + onRemoveBackground, onOpenCharacterAnimationPanel, - onOpenLayerMetadata, + onDownloadLayer, }: ImageCanvasSelectedLayerToolbarViewProps) { if (!selectedLayer || !selectedToolbarStyle) { return null; @@ -55,43 +44,31 @@ export function ImageCanvasSelectedLayerToolbarView({ aria-label="图片工具栏" onPointerDown={(event) => event.stopPropagation()} > - {layerToolButtons.map(({ label, icon: Icon }) => ( - triggerPlaceholderAction(label)} - /> - ))} - - } onClick={() => onOpenQuickEditPanel(selectedLayer)} + > + 快速编辑 + +
); } diff --git a/src/components/image-editor/ImageCanvasStageView.tsx b/src/components/image-editor/ImageCanvasStageView.tsx index 16a5c6ac0..80edbe4d0 100644 --- a/src/components/image-editor/ImageCanvasStageView.tsx +++ b/src/components/image-editor/ImageCanvasStageView.tsx @@ -9,10 +9,6 @@ import type { import { ImageCanvasBottomToolbarView } from './ImageCanvasBottomToolbarView'; import { ImageCanvasContextMenusView } from './ImageCanvasContextMenusView'; -import { ImageCanvasPanelDockView } from './ImageCanvasPanelDockView'; -import { ImageCanvasSelectedLayerToolbarView } from './ImageCanvasSelectedLayerToolbarView'; -import { ImageCanvasWorldView } from './ImageCanvasWorldView'; -import type { StageMinimapModel } from './ImageCanvasInteractionModel'; import type { CanvasClipboard, CanvasContextMenuState, @@ -27,6 +23,10 @@ import type { SidebarPanel, SnapGuide, } from './ImageCanvasEditorTypes'; +import type { StageMinimapModel } from './ImageCanvasInteractionModel'; +import { ImageCanvasPanelDockView } from './ImageCanvasPanelDockView'; +import { ImageCanvasSelectedLayerToolbarView } from './ImageCanvasSelectedLayerToolbarView'; +import { ImageCanvasWorldView } from './ImageCanvasWorldView'; export type ImageCanvasStageViewProps = { canvasViewportRef: RefObject; @@ -90,10 +90,12 @@ export type ImageCanvasStageViewProps = { dialog: CanvasGenerationDialogState, ) => void; onActivateGenerationDialog: (dialog: CanvasGenerationDialogState) => void; - onDeleteSelectedLayer: () => void; onOpenQuickEditPanel: (layer: CanvasLayer) => void; - onOpenEditDialog: (layer: CanvasLayer) => void; + onOpenRedrawPanel: (layer: CanvasLayer) => void; + onOpenCropExpandPanel: (layer: CanvasLayer) => void; + onRemoveBackground: (layer: CanvasLayer) => void; onOpenCharacterAnimationPanel: (layer: CanvasLayer) => void; + onDownloadLayer: (layer: CanvasLayer) => void; onPasteCanvasClipboard: (canvasPoint?: { x: number; y: number }) => void; onCopyContextLayers: (options?: { cut?: boolean }) => void; onDuplicateContextLayers: () => void; @@ -175,10 +177,12 @@ export function ImageCanvasStageView({ onOpenLayerMetadata, onGenerationFramePointerDown, onActivateGenerationDialog, - onDeleteSelectedLayer, onOpenQuickEditPanel, - onOpenEditDialog, + onOpenRedrawPanel, + onOpenCropExpandPanel, + onRemoveBackground, onOpenCharacterAnimationPanel, + onDownloadLayer, onPasteCanvasClipboard, onCopyContextLayers, onDuplicateContextLayers, @@ -257,11 +261,12 @@ export function ImageCanvasStageView({ { id: 'upload-character-reference-1781697600000-2', label: '参考图3', src: 'data:image/png;base64,ref', + mediaType: 'image', + mimeType: undefined, + sizeBytes: undefined, + durationSeconds: undefined, + objectKey: undefined, + assetObjectId: undefined, }); }); diff --git a/src/components/image-editor/ImageCanvasWorldView.test.tsx b/src/components/image-editor/ImageCanvasWorldView.test.tsx index 2a55a421d..d8106df4a 100644 --- a/src/components/image-editor/ImageCanvasWorldView.test.tsx +++ b/src/components/image-editor/ImageCanvasWorldView.test.tsx @@ -6,6 +6,7 @@ import { describe, expect, it, vi } from 'vitest'; import type { CanvasGenerationDialogState, CanvasLayer, + GenerateDialogState, } from './ImageCanvasEditorTypes'; import { ImageCanvasWorldView } from './ImageCanvasWorldView'; @@ -252,6 +253,184 @@ describe('ImageCanvasWorldView', () => { expect(within(focusedFrame).getByText('1024 x 768')).toBeTruthy(); }); + it.each([ + { + mode: 'character', + ariaLabel: '角色生成占位图', + generatorLabel: 'Character Generator', + badgeLabel: '角色', + frameClassName: 'image-canvas-editor__generation-frame--character', + iconClassName: 'lucide-user-round', + }, + { + mode: 'video', + ariaLabel: '视频生成占位图', + generatorLabel: 'Video Generator', + badgeLabel: '视频', + frameClassName: 'image-canvas-editor__generation-frame--video', + iconClassName: 'lucide-film', + }, + { + mode: 'audio-sound-effect', + ariaLabel: '音效生成占位图', + generatorLabel: 'Sound Generator', + badgeLabel: '音效', + frameClassName: + 'image-canvas-editor__generation-frame--audio-sound-effect', + iconClassName: 'lucide-volume-2', + }, + { + mode: 'audio-background-music', + ariaLabel: '背景音乐生成占位图', + generatorLabel: 'Music Generator', + badgeLabel: '背景音乐', + frameClassName: + 'image-canvas-editor__generation-frame--audio-background-music', + iconClassName: 'lucide-music-2', + }, + ])( + 'renders $mode generation placeholder with its own icon and badge', + ({ mode, ariaLabel, generatorLabel, badgeLabel, frameClassName, iconClassName }) => { + const dialog = createGenerationDialog({ + id: `dialog-${mode}`, + mode: mode as CanvasGenerationDialogState['mode'], + }); + + renderWorldView({ + canvasGenerationDialogs: [dialog], + generateDialog: dialog as GenerateDialogState, + }); + + const frame = screen.getByRole('button', { name: ariaLabel }); + const generatorLabelNode = within(frame).getByText( + generatorLabel, + ) as HTMLElement; + expect(frame.className).toContain(frameClassName); + expect(generatorLabelNode).toBeTruthy(); + expect( + generatorLabelNode.querySelector('svg')?.getAttribute('class'), + ).toContain(iconClassName); + expect(within(frame).getByText(badgeLabel)).toBeTruthy(); + expect( + frame + .querySelector('.image-canvas-editor__generation-frame-icon svg') + ?.getAttribute('class'), + ).toContain(iconClassName); + }, + ); + + it.each([ + { + mode: 'character', + ariaLabel: '角色生成占位图', + generatorLabel: 'Character Generator', + badgeLabel: '角色', + }, + { + mode: 'video', + ariaLabel: '视频生成占位图', + generatorLabel: 'Video Generator', + badgeLabel: '视频', + }, + { + mode: 'audio-sound-effect', + ariaLabel: '音效生成占位图', + generatorLabel: 'Sound Generator', + badgeLabel: '音效', + }, + { + mode: 'audio-background-music', + ariaLabel: '背景音乐生成占位图', + generatorLabel: 'Music Generator', + badgeLabel: '背景音乐', + }, + ])( + 'keeps $mode generation title size and badge readable when zoomed out', + ({ mode, ariaLabel, generatorLabel, badgeLabel }) => { + const dialog = createGenerationDialog({ + id: `dialog-zoom-${mode}`, + mode: mode as CanvasGenerationDialogState['mode'], + }); + + renderWorldView({ + viewport: { x: 0, y: 0, scale: 0.25 }, + canvasGenerationDialogs: [dialog], + generateDialog: dialog as GenerateDialogState, + }); + + const frame = screen.getByRole('button', { name: ariaLabel }); + const inverseScale = '4'; + + expect( + (within(frame).getByText(generatorLabel) as HTMLElement).style.getPropertyValue( + '--image-canvas-editor-inverse-scale', + ), + ).toBe(inverseScale); + expect( + (within(frame).getByText('1024 x 768') as HTMLElement).style.getPropertyValue( + '--image-canvas-editor-inverse-scale', + ), + ).toBe(inverseScale); + expect( + (within(frame).getByText(badgeLabel) as HTMLElement).style.getPropertyValue( + '--image-canvas-editor-inverse-scale', + ), + ).toBe(inverseScale); + }, + ); + + it.each([ + { + mode: 'character', + ariaLabel: '角色生成占位图', + generatorLabel: 'Character Generator', + badgeLabel: '角色', + }, + { + mode: 'video', + ariaLabel: '视频生成占位图', + generatorLabel: 'Video Generator', + badgeLabel: '视频', + }, + { + mode: 'audio-sound-effect', + ariaLabel: '音效生成占位图', + generatorLabel: 'Sound Generator', + badgeLabel: '音效', + }, + { + mode: 'audio-background-music', + ariaLabel: '背景音乐生成占位图', + generatorLabel: 'Music Generator', + badgeLabel: '背景音乐', + }, + ])( + 'keeps $mode placeholder icon and badge visible after blur without helper chrome', + ({ mode, ariaLabel, generatorLabel, badgeLabel }) => { + const dialog = createGenerationDialog({ + id: `dialog-blurred-${mode}`, + mode: mode as CanvasGenerationDialogState['mode'], + status: 'idle', + }); + + renderWorldView({ + generateDialog: null, + canvasGenerationDialogs: [dialog], + }); + + const frame = screen.getByRole('button', { name: ariaLabel }); + expect(frame.className).not.toContain( + 'image-canvas-editor__generation-frame--focused', + ); + expect(within(frame).queryByText(generatorLabel)).toBeNull(); + expect(within(frame).queryByText('1024 x 768')).toBeNull(); + expect(within(frame).getByText(badgeLabel)).toBeTruthy(); + expect( + frame.querySelector('.image-canvas-editor__generation-frame-icon svg'), + ).toBeTruthy(); + }, + ); + it('keeps layer and generation chrome readable when the canvas is zoomed out', () => { const layer = createLayer({ assetKind: 'character' }); const dialog = createGenerationDialog({ id: 'dialog-focused' }); diff --git a/src/components/image-editor/ImageCanvasWorldView.tsx b/src/components/image-editor/ImageCanvasWorldView.tsx index 7ca40870c..904356ff7 100644 --- a/src/components/image-editor/ImageCanvasWorldView.tsx +++ b/src/components/image-editor/ImageCanvasWorldView.tsx @@ -1,4 +1,4 @@ -import { ImageIcon, Info } from 'lucide-react'; +import { Film, ImageIcon, Info, Music2, UserRound, Volume2 } from 'lucide-react'; import type { CSSProperties, MouseEvent as ReactMouseEvent, @@ -24,6 +24,77 @@ import { getLayerKindLabel, } from './ImageCanvasGenerationModel'; +function getGenerationPlaceholderMeta(dialog: CanvasGenerationDialogState) { + switch (dialog.mode) { + case 'character': + return { + frameClassName: 'image-canvas-editor__generation-frame--character', + badgeClassName: 'character', + badgeLabel: '角色', + icon: , + labelIcon: , + }; + case 'spec': + return { + frameClassName: 'image-canvas-editor__generation-frame--spec', + badgeClassName: 'spec', + badgeLabel: '规范', + icon: , + labelIcon: , + }; + case 'icon': + return { + frameClassName: 'image-canvas-editor__generation-frame--icon', + badgeClassName: 'icon', + badgeLabel: '图标', + icon: , + labelIcon: , + }; + case 'ui-design': + return { + frameClassName: 'image-canvas-editor__generation-frame--ui-design', + badgeClassName: 'ui-design', + badgeLabel: 'UI设计', + icon: , + labelIcon: , + }; + case 'video': + return { + frameClassName: 'image-canvas-editor__generation-frame--video', + badgeClassName: 'video', + badgeLabel: '视频', + icon: , + labelIcon: , + }; + case 'audio-sound-effect': + return { + frameClassName: + 'image-canvas-editor__generation-frame--audio-sound-effect', + badgeClassName: 'sound-effect', + badgeLabel: '音效', + icon: , + labelIcon: , + }; + case 'audio-background-music': + return { + frameClassName: + 'image-canvas-editor__generation-frame--audio-background-music', + badgeClassName: 'background-music', + badgeLabel: '背景音乐', + icon: , + labelIcon: , + }; + default: + return { + frameClassName: '', + badgeClassName: null, + badgeLabel: null, + icon: , + labelIcon: , + }; + } +} + function getInverseViewportScale(viewport: CanvasViewport) { if (!Number.isFinite(viewport.scale) || viewport.scale <= 0) { return 1; @@ -136,7 +207,8 @@ export function ImageCanvasWorldView({ generateDialog.status === 'generating' && generateDialog.sourceLayerId === layer.id ? '修改中' - : quickEditPanel?.status === 'generating' && + : quickEditPanel?.mode !== 'redraw' && + quickEditPanel?.status === 'generating' && quickEditPanel.sourceLayerId === layer.id ? '生成中' : null; @@ -271,14 +343,13 @@ export function ImageCanvasWorldView({ const isFocused = generateDialog?.id === dialog.id; const showFocusedChrome = isFocused || dialog.status === 'generating'; + const placeholderMeta = getGenerationPlaceholderMeta(dialog); return (
- + {placeholderMeta.labelIcon} {getGenerationFrameLabel(dialog)} ) : null} - {dialog.mode === 'character' ? ( + {placeholderMeta.badgeLabel ? ( - 角色 - - ) : null} - {dialog.mode === 'spec' ? ( - - 规范 - - ) : null} - {dialog.mode === 'icon' ? ( - - 图标 + {placeholderMeta.badgeLabel} ) : null} {showFocusedChrome ? ( @@ -345,7 +400,7 @@ export function ImageCanvasWorldView({ ) : null} - + {placeholderMeta.icon} {dialog.status === 'generating' ? ( vi.fn()); @@ -422,7 +422,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { initialDialog={{ id: 'dialog-icon', mode: 'icon', - prompt: '', + prompt: ' 返回按钮 \n\n设置按钮', status: 'idle', composerOpen: true, iconDescriptions: ['返回按钮'], @@ -448,6 +448,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { model: 'gpt-image-2', provider: 'VectorEngine', taskId: 'task-icons', + priceMudPoints: 12, iconImageSrcs: [ createIconResult('返回按钮', 'data:image/png;base64,back'), createIconResult('设置按钮', 'data:image/png;base64,setting'), @@ -458,7 +459,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { initialDialog={{ id: 'dialog-icon', mode: 'icon', - prompt: '', + prompt: ' 返回按钮 \n\n 设置按钮 ', status: 'idle', composerOpen: true, imageModel: 'gpt-image-2', @@ -467,7 +468,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { label: '图标规范', src: 'data:image/png;base64,spec', }, - iconDescriptions: [' 返回按钮 ', '', '设置按钮'], + iconDescriptions: ['旧返回', '旧设置'], placeholder: { x: 140, y: 160, @@ -489,6 +490,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { referenceImageSrc: 'data:image/png;base64,spec', iconDescriptions: ['返回按钮', '设置按钮'], model: 'gpt-image-2', + priceMudPoints: 12, }), ); }); diff --git a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts index cdf5859cb..8775ac9f8 100644 --- a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts +++ b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts @@ -28,8 +28,8 @@ import type { SidebarPanel, } from './ImageCanvasEditorTypes'; import { - createGeneratedResultLayer, createAudioResultLayer, + createGeneratedResultLayer, createIconSpritesheetResultLayers, createQuickEditResultLayer, createVideoResultLayer, @@ -185,6 +185,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({ generated: Parameters[0]['generated'], sourceLayer: CanvasLayer, generationInputs: CanvasGenerationInputs, + mode: QuickEditPanelState['mode'] = 'quick-edit', ) => { layerCounterRef.current += 1; const generatedIndex = layerCounterRef.current; @@ -193,13 +194,27 @@ export function useImageCanvasGenerationSubmissionWorkflow({ generatedIndex, sourceLayer, generationInputs, + mode, }); addGeneratedLayersToCanvas([nextLayer]); - selectSingleLayer(nextLayer.id); + selectSingleLayer(mode === 'redraw' ? sourceLayer.id : nextLayer.id); setActiveSidebarPanel('layers'); - setQuickEditPanel(null); - setActiveTool('select'); + if (mode === 'redraw') { + setQuickEditPanel((currentPanel) => + currentPanel?.sourceLayerId === sourceLayer.id + ? { + ...currentPanel, + mode: 'redraw', + status: 'idle', + errorMessage: undefined, + } + : currentPanel, + ); + } else { + setQuickEditPanel(null); + setActiveTool('select'); + } fitLayers([sourceLayer, nextLayer]); }, [ @@ -383,7 +398,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({ }; const submissionPlan = buildIconSpritesheetGenerationSubmissionPlan(dialog); - if (!submissionPlan.ok) { + if (submissionPlan.ok === false) { if (canvasDialog) { setSubmittingIconDialog({ ...canvasDialog, @@ -442,7 +457,10 @@ export function useImageCanvasGenerationSubmissionWorkflow({ return; } - const normalizedPrompt = quickEditPanel.prompt.trim() || '快速编辑图片'; + const panelMode = quickEditPanel.mode ?? 'quick-edit'; + const normalizedPrompt = + quickEditPanel.prompt.trim() || + (panelMode === 'redraw' ? '重绘图片' : '快速编辑图片'); setQuickEditPanel({ ...quickEditPanel, prompt: normalizedPrompt, @@ -465,10 +483,11 @@ export function useImageCanvasGenerationSubmissionWorkflow({ generated, quickEditSourceLayer, buildEditGenerationInputs( - '快速编辑提示词', + panelMode === 'redraw' ? '重绘提示词' : '快速编辑提示词', normalizedPrompt, quickEditSourceLayer, ), + panelMode, ); } catch (error) { setQuickEditPanel({ diff --git a/src/components/image-editor/useImageCanvasGenerationSurface.test.tsx b/src/components/image-editor/useImageCanvasGenerationSurface.test.tsx index 50c9a326e..c9ce1524d 100644 --- a/src/components/image-editor/useImageCanvasGenerationSurface.test.tsx +++ b/src/components/image-editor/useImageCanvasGenerationSurface.test.tsx @@ -8,7 +8,6 @@ import type { CanvasGenerationDialogState, CanvasLayer, CanvasTool, - GenerateDialogState, ImageContextMenuState, SidebarPanel, } from './ImageCanvasEditorTypes'; @@ -77,6 +76,7 @@ function GenerationSurfaceHarness() { canvasSize: { width: 900, height: 640 }, viewport, setViewport, + setLayers, layerCounterRef, specToolWrapRef, musicToolWrapRef, @@ -98,6 +98,7 @@ function GenerationSurfaceHarness() { setLayers((currentLayers) => [...currentLayers, ...nextLayers]), selectSingleLayer: () => {}, fitLayers: () => {}, + captureCanvasHistory: () => {}, setActiveTool, setActiveSidebarPanel, setMetadataLayer, @@ -134,13 +135,22 @@ function GenerationSurfaceHarness() { > 切换生成 - - - - - {surface.generationComposerNode}
@@ -170,7 +186,7 @@ describe('useImageCanvasGenerationSurface', () => { it('switches generation tools while leaving non-generation tools untouched', () => { render(); - fireEvent.click(screen.getByRole('button', { name: '切换文字' })); + fireEvent.click(screen.getByRole('button', { name: '切换选择' })); expect(screen.getByTestId('tool').textContent).toBe('select'); fireEvent.click(screen.getByRole('button', { name: '切换生成' })); @@ -202,7 +218,9 @@ describe('useImageCanvasGenerationSurface', () => { fireEvent.click(screen.getByRole('button', { name: '切换规范' })); expect(screen.getByTestId('tool').textContent).toBe('spec'); const menu = screen.getByRole('menu', { name: '生成规范类型' }); - expect(within(menu).getByRole('menuitem', { name: '角色形象规范' })).toBeTruthy(); + expect( + within(menu).getByRole('menuitem', { name: '角色形象规范' }), + ).toBeTruthy(); }); it('anchors the music menu to the music tool and closes after hover leaves', () => { @@ -283,16 +301,16 @@ describe('useImageCanvasGenerationSurface', () => { within(menu).getByRole('menuitem', { name: '生成游戏背景音乐' }), ).toBeTruthy(); - fireEvent.click(within(menu).getByRole('menuitem', { name: '生成游戏音效' })); + fireEvent.click( + within(menu).getByRole('menuitem', { name: '生成游戏音效' }), + ); expect(screen.getByTestId('dialog').textContent).toBe( 'audio-sound-effect:open:placeholder', ); expect(screen.getByRole('dialog', { name: '生成游戏音效' })).toBeTruthy(); fireEvent.click(screen.getByRole('button', { name: '切换音乐' })); - fireEvent.click( - screen.getByRole('menuitem', { name: '生成游戏背景音乐' }), - ); + fireEvent.click(screen.getByRole('menuitem', { name: '生成游戏背景音乐' })); expect(screen.getByTestId('dialog').textContent).toBe( 'audio-background-music:open:placeholder', ); diff --git a/src/components/image-editor/useImageCanvasGenerationSurface.tsx b/src/components/image-editor/useImageCanvasGenerationSurface.tsx index f8a01874a..e31cb9f46 100644 --- a/src/components/image-editor/useImageCanvasGenerationSurface.tsx +++ b/src/components/image-editor/useImageCanvasGenerationSurface.tsx @@ -15,14 +15,6 @@ import { PlatformFloatingMenu, PlatformFloatingMenuItem, } from '../common/PlatformFloatingMenu'; -import { ImageCanvasGenerationComposerView } from './ImageCanvasGenerationComposerView'; -import { - resolveCharacterAnimationPanelStyle, - resolveGenerationAnchor, - resolveGenerationComposerStyle, - resolveIconComposerStyle, - resolveQuickEditPanelStyle, -} from './ImageCanvasOverlayModel'; import type { CanvasGenerationDialogState, CanvasLayer, @@ -33,6 +25,15 @@ import type { SidebarPanel, UploadTarget, } from './ImageCanvasEditorTypes'; +import { ImageCanvasGenerationComposerView } from './ImageCanvasGenerationComposerView'; +import { + resolveCharacterAnimationPanelStyle, + resolveCropExpandPanelStyle, + resolveGenerationAnchor, + resolveGenerationComposerStyle, + resolveIconComposerStyle, + resolveQuickEditPanelStyle, +} from './ImageCanvasOverlayModel'; import { useImageCanvasGenerationWorkflow } from './useImageCanvasGenerationWorkflow'; type CanvasGenerationDialogUpdater = ( @@ -46,6 +47,7 @@ type ImageCanvasGenerationSurfaceOptions = { canvasSize: { width: number; height: number }; viewport: CanvasViewport; setViewport: Dispatch>; + setLayers: Dispatch>; layerCounterRef: MutableRefObject; specToolWrapRef: RefObject; musicToolWrapRef: RefObject; @@ -72,12 +74,14 @@ type ImageCanvasGenerationSurfaceOptions = { appendCanvasLayersWithResources: (nextLayers: CanvasLayer[]) => void; selectSingleLayer: (layerId: string | null) => void; fitLayers: (targetLayers?: CanvasLayer[]) => void; + captureCanvasHistory: () => void; setActiveTool: Dispatch>; setActiveSidebarPanel: Dispatch>; setMetadataLayer: Dispatch>; setImageContextMenu: Dispatch>; requestUpload: (target: UploadTarget) => void; persistGeneratedAsset?: (layer: CanvasLayer) => void; + persistUpdatedLayerResource?: (layer: CanvasLayer) => void; }; function renderEditorPortal(node: ReactNode) { @@ -120,6 +124,7 @@ export function useImageCanvasGenerationSurface({ canvasSize, viewport, setViewport, + setLayers, layerCounterRef, specToolWrapRef, musicToolWrapRef, @@ -139,12 +144,14 @@ export function useImageCanvasGenerationSurface({ appendCanvasLayersWithResources, selectSingleLayer, fitLayers, + captureCanvasHistory, setActiveTool, setActiveSidebarPanel, setMetadataLayer, setImageContextMenu, requestUpload, persistGeneratedAsset, + persistUpdatedLayerResource, }: ImageCanvasGenerationSurfaceOptions) { const toolbarOptionCloseTimerRef = useRef layer.id === activeCanvasGenerationDialog.generatedLayerId, - ) ?? null) - : null; + const activeGenerationLayer = activeCanvasGenerationDialog?.generatedLayerId + ? (layers.find( + (layer) => layer.id === activeCanvasGenerationDialog.generatedLayerId, + ) ?? null) + : null; const generationAnchor = resolveGenerationAnchor({ dialog: activeCanvasGenerationDialog, generatedLayer: activeGenerationLayer, @@ -191,7 +200,6 @@ export function useImageCanvasGenerationSurface({ const iconComposerStyle = resolveIconComposerStyle({ dialog: activeCanvasGenerationDialog, composerStyle: generationComposerStyle, - iconDescriptionCount: generationWorkflow.iconDescriptionValues.length, }); const quickEditPanelStyle = resolveQuickEditPanelStyle({ panel: generationWorkflow.quickEditPanel, @@ -199,6 +207,12 @@ export function useImageCanvasGenerationSurface({ viewport, canvasSize, }); + const cropExpandPanelStyle = resolveCropExpandPanelStyle({ + panel: generationWorkflow.cropExpandPanel, + sourceLayer: generationWorkflow.cropExpandSourceLayer, + viewport, + canvasSize, + }); const characterAnimationPanelStyle = resolveCharacterAnimationPanelStyle({ panel: generationWorkflow.characterAnimationPanel, sourceLayer: generationWorkflow.characterAnimationSourceLayer, @@ -284,105 +298,109 @@ export function useImageCanvasGenerationSurface({ const generationComposerNode = ( <> - void generationWorkflow.submitImageGeneration(dialog) - } - onSubmitIconSpritesheetGeneration={(dialog) => - void generationWorkflow.submitIconSpritesheetGeneration(dialog) - } - onSubmitQuickEdit={() => void generationWorkflow.submitQuickEdit()} - onSubmitCharacterAnimation={() => - void generationWorkflow.submitCharacterAnimation() - } - onCloseGenerateComposer={generationWorkflow.closeGenerateComposer} - onUpdateSpecFormValue={generationWorkflow.updateSpecFormValue} - onUpdateIconDescription={generationWorkflow.updateIconDescription} - onAddIconDescription={generationWorkflow.addIconDescription} - onUpdateCharacterAnimationDuration={ - generationWorkflow.updateCharacterAnimationDuration - } - onRememberImageModel={generationWorkflow.rememberImageModel} - onSpecMenuPointerEnter={() => openToolbarOptionMenu('spec')} - onSpecMenuPointerLeave={() => closeToolbarOptionMenu('spec')} + specToolWrapRef={specToolWrapRef} + characterSpecButtonRef={characterSpecButtonRef} + characterReferenceButtonRef={characterReferenceButtonRef} + generationReferenceButtonRef={generationReferenceButtonRef} + iconSpecButtonRef={iconSpecButtonRef} + isSpecMenuOpen={generationWorkflow.isSpecMenuOpen} + isGenerationReferenceMenuOpen={ + generationWorkflow.isGenerationReferenceMenuOpen + } + isCharacterSpecMenuOpen={generationWorkflow.isCharacterSpecMenuOpen} + isCharacterReferenceMenuOpen={ + generationWorkflow.isCharacterReferenceMenuOpen + } + isIconSpecMenuOpen={generationWorkflow.isIconSpecMenuOpen} + isUiDesignSpecMenuOpen={generationWorkflow.isUiDesignSpecMenuOpen} + isPickingGenerationReferenceFromCanvas={ + generationWorkflow.isPickingGenerationReferenceFromCanvas + } + isPickingCharacterSpecFromCanvas={ + generationWorkflow.isPickingCharacterSpecFromCanvas + } + isPickingCharacterReferenceFromCanvas={ + generationWorkflow.isPickingCharacterReferenceFromCanvas + } + isPickingIconSpecFromCanvas={ + generationWorkflow.isPickingIconSpecFromCanvas + } + isPickingUiDesignSpecFromCanvas={ + generationWorkflow.isPickingUiDesignSpecFromCanvas + } + generateDialog={generateDialog} + generationComposerStyle={generationComposerStyle} + iconComposerStyle={iconComposerStyle} + quickEditPanel={generationWorkflow.quickEditPanel} + quickEditSourceLayer={generationWorkflow.quickEditSourceLayer} + quickEditPanelStyle={quickEditPanelStyle} + cropExpandPanel={generationWorkflow.cropExpandPanel} + cropExpandSourceLayer={generationWorkflow.cropExpandSourceLayer} + cropExpandPanelStyle={cropExpandPanelStyle} + quickEditSizeOptions={generationWorkflow.quickEditSizeOptions} + quickEditModelOptions={generationWorkflow.quickEditModelOptions} + characterAnimationPanel={generationWorkflow.characterAnimationPanel} + characterAnimationSourceLayer={ + generationWorkflow.characterAnimationSourceLayer + } + characterAnimationPanelStyle={characterAnimationPanelStyle} + characterAnimationPrice={generationWorkflow.characterAnimationPrice} + setGenerateDialog={setGenerateDialog} + setQuickEditPanel={generationWorkflow.setQuickEditPanel} + setCropExpandPanel={generationWorkflow.setCropExpandPanel} + setCharacterAnimationPanel={ + generationWorkflow.setCharacterAnimationPanel + } + setIsCharacterSpecMenuOpen={ + generationWorkflow.setIsCharacterSpecMenuOpen + } + setIsCharacterReferenceMenuOpen={ + generationWorkflow.setIsCharacterReferenceMenuOpen + } + setIsGenerationReferenceMenuOpen={ + generationWorkflow.setIsGenerationReferenceMenuOpen + } + setIsIconSpecMenuOpen={generationWorkflow.setIsIconSpecMenuOpen} + setIsUiDesignSpecMenuOpen={generationWorkflow.setIsUiDesignSpecMenuOpen} + setIsPickingGenerationReferenceFromCanvas={ + generationWorkflow.setIsPickingGenerationReferenceFromCanvas + } + setIsPickingCharacterSpecFromCanvas={ + generationWorkflow.setIsPickingCharacterSpecFromCanvas + } + setIsPickingCharacterReferenceFromCanvas={ + generationWorkflow.setIsPickingCharacterReferenceFromCanvas + } + setIsPickingIconSpecFromCanvas={ + generationWorkflow.setIsPickingIconSpecFromCanvas + } + setIsPickingUiDesignSpecFromCanvas={ + generationWorkflow.setIsPickingUiDesignSpecFromCanvas + } + onOpenSpecDialog={generationWorkflow.openSpecDialog} + onRequestUpload={requestUpload} + onSubmitImageGeneration={(dialog) => + void generationWorkflow.submitImageGeneration(dialog) + } + onSubmitIconSpritesheetGeneration={(dialog) => + void generationWorkflow.submitIconSpritesheetGeneration(dialog) + } + onSubmitQuickEdit={() => void generationWorkflow.submitQuickEdit()} + onSubmitCropExpand={() => void generationWorkflow.submitCropExpand()} + onSubmitCharacterAnimation={() => + void generationWorkflow.submitCharacterAnimation() + } + onCloseGenerateComposer={generationWorkflow.closeGenerateComposer} + onUpdateSpecFormValue={generationWorkflow.updateSpecFormValue} + onUpdateIconDescriptionText={ + generationWorkflow.updateIconDescriptionsText + } + onUpdateCharacterAnimationDuration={ + generationWorkflow.updateCharacterAnimationDuration + } + onRememberImageModel={generationWorkflow.rememberImageModel} + onSpecMenuPointerEnter={() => openToolbarOptionMenu('spec')} + onSpecMenuPointerLeave={() => closeToolbarOptionMenu('spec')} /> {generationWorkflow.isMusicMenuOpen ? renderEditorPortal( diff --git a/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx b/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx index 9b133f4f3..0b4a74b3d 100644 --- a/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx +++ b/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx @@ -1,7 +1,6 @@ /* @vitest-environment jsdom */ import { - act, fireEvent, render, screen, @@ -14,7 +13,6 @@ import type { CanvasGenerationDialogState, CanvasLayer, CanvasTool, - GenerateDialogState, ImageContextMenuState, SidebarPanel, } from './ImageCanvasEditorTypes'; @@ -104,6 +102,7 @@ function GenerationWorkflowHarness({ canvasSize: { width: 900, height: 640 }, viewport, setViewport, + setLayers, canvasGenerationDialogs: dialogs.canvasGenerationDialogs, layerCounterRef, generateDialog: dialogs.generateDialog, @@ -118,6 +117,7 @@ function GenerationWorkflowHarness({ setLayers((currentLayers) => [...currentLayers, ...nextLayers]), selectSingleLayer: setSelectedLayerId, fitLayers: fitLayersMockRef.current, + captureCanvasHistory: () => {}, setActiveTool, setActiveSidebarPanel, setMetadataLayer, diff --git a/src/components/image-editor/useImageCanvasGenerationWorkflow.ts b/src/components/image-editor/useImageCanvasGenerationWorkflow.ts index 14d56f66c..c570f405c 100644 --- a/src/components/image-editor/useImageCanvasGenerationWorkflow.ts +++ b/src/components/image-editor/useImageCanvasGenerationWorkflow.ts @@ -13,6 +13,7 @@ import type { CanvasTool, CanvasViewport, CharacterAnimationPanelState, + CropExpandPanelState, GenerateDialogState, ImageContextMenuState, QuickEditPanelState, @@ -23,10 +24,9 @@ import type { import { appendCharacterReference, appendGenerationReference, - appendIconDescriptionToDialog, - assignUiDesignSpecReference, assignCharacterSpecReference, assignIconSpecReference, + assignUiDesignSpecReference, closeGenerateComposerDialog, createBackgroundMusicGenerationDialogDraft, createCharacterAnimationPanelDraft, @@ -35,26 +35,34 @@ import { createGenerateDialogDraft, createIconGenerationDialogDraft, createQuickEditPanelDraft, + createRedrawPanelDraft, createSoundEffectGenerationDialogDraft, createSpecDialogDraft, createUiDesignGenerationDialogDraft, createVideoGenerationDialogDraft, hideGeneratedLayerComposerAfterBlur, updateCharacterAnimationDurationPanel, - updateIconDescriptionInDialog, + updateIconDescriptionsTextInDialog, updateSpecFormDialogValue, } from './ImageCanvasGenerationDialogModel'; -import { - centerViewportOnPlacement, - chooseGenerationPlacement, -} from './ImageCanvasGenerationPlacementModel'; import { buildQuickEditModelOptions, buildQuickEditSizeOptions, calculateCharacterAnimationPrice, DEFAULT_ICON_DESCRIPTIONS, DEFAULT_IMAGE_MODEL, + ICON_DESCRIPTION_LIMIT, } from './ImageCanvasGenerationModel'; +import { + centerViewportOnPlacement, + chooseGenerationPlacement, +} from './ImageCanvasGenerationPlacementModel'; +import { + buildCropExpandLayerPatch, + parseCropExpandInsetValue, + removeImageBackground, + renderCropExpandImage, +} from './ImageCanvasRasterEditModel'; import { useImageCanvasGenerationSubmissionWorkflow } from './useImageCanvasGenerationSubmissionWorkflow'; type CanvasSize = { width: number; height: number }; @@ -68,6 +76,7 @@ type GenerationWorkflowOptions = { canvasSize: CanvasSize; viewport: CanvasViewport; setViewport: Dispatch>; + setLayers: Dispatch>; canvasGenerationDialogs: CanvasGenerationDialogState[]; layerCounterRef: MutableRefObject; generateDialog: GenerateDialogState | null; @@ -87,11 +96,13 @@ type GenerationWorkflowOptions = { appendCanvasLayersWithResources: (nextLayers: CanvasLayer[]) => void; selectSingleLayer: (layerId: string | null) => void; fitLayers: (targetLayers?: CanvasLayer[]) => void; + captureCanvasHistory: () => void; setActiveTool: Dispatch>; setActiveSidebarPanel: Dispatch>; setMetadataLayer: Dispatch>; setImageContextMenu: Dispatch>; persistGeneratedAsset?: (layer: CanvasLayer) => void; + persistUpdatedLayerResource?: (layer: CanvasLayer) => void; }; export function useImageCanvasGenerationWorkflow({ @@ -99,6 +110,7 @@ export function useImageCanvasGenerationWorkflow({ canvasSize, viewport, setViewport, + setLayers, canvasGenerationDialogs, layerCounterRef, generateDialog, @@ -111,11 +123,13 @@ export function useImageCanvasGenerationWorkflow({ appendCanvasLayersWithResources, selectSingleLayer, fitLayers, + captureCanvasHistory, setActiveTool, setActiveSidebarPanel, setMetadataLayer, setImageContextMenu, persistGeneratedAsset, + persistUpdatedLayerResource, }: GenerationWorkflowOptions) { const [isSpecMenuOpen, setIsSpecMenuOpen] = useState(false); const [isGenerationReferenceMenuOpen, setIsGenerationReferenceMenuOpen] = @@ -144,6 +158,8 @@ export function useImageCanvasGenerationWorkflow({ const [isMusicMenuOpen, setIsMusicMenuOpen] = useState(false); const [quickEditPanel, setQuickEditPanel] = useState(null); + const [cropExpandPanel, setCropExpandPanel] = + useState(null); const [characterAnimationPanel, setCharacterAnimationPanel] = useState(null); const [lastImageModel, setLastImageModel] = useState(DEFAULT_IMAGE_MODEL); @@ -152,6 +168,10 @@ export function useImageCanvasGenerationWorkflow({ ? (layers.find((layer) => layer.id === quickEditPanel.sourceLayerId) ?? null) : null; + const cropExpandSourceLayer = cropExpandPanel + ? (layers.find((layer) => layer.id === cropExpandPanel.sourceLayerId) ?? + null) + : null; const characterAnimationSourceLayer = characterAnimationPanel ? (layers.find( (layer) => layer.id === characterAnimationPanel.sourceLayerId, @@ -175,7 +195,13 @@ export function useImageCanvasGenerationWorkflow({ : 0; const iconDescriptionValues = generateDialog?.mode === 'icon' - ? (generateDialog.iconDescriptions ?? DEFAULT_ICON_DESCRIPTIONS) + ? generateDialog.prompt.trim() + ? generateDialog.prompt + .split(/[\r\n,,、;;/|]+/u) + .map((description) => description.trim()) + .filter(Boolean) + .slice(0, ICON_DESCRIPTION_LIMIT) + : (generateDialog.iconDescriptions ?? DEFAULT_ICON_DESCRIPTIONS) : DEFAULT_ICON_DESCRIPTIONS; const closeGenerationTransientState = useCallback(() => { @@ -237,6 +263,7 @@ export function useImageCanvasGenerationWorkflow({ setActiveTool(activeTool); selectSingleLayer(null); setQuickEditPanel(null); + setCropExpandPanel(null); setCharacterAnimationPanel(null); }, [closeGenerationTransientState, selectSingleLayer, setActiveTool], @@ -277,6 +304,7 @@ export function useImageCanvasGenerationWorkflow({ } setImageContextMenu(null); setQuickEditPanel(null); + setCropExpandPanel(null); setCharacterAnimationPanel(nextPanel); selectSingleLayer(layer.id); }, @@ -374,6 +402,7 @@ export function useImageCanvasGenerationWorkflow({ setMetadataLayer(null); setImageContextMenu(null); setQuickEditPanel(null); + setCropExpandPanel(null); setGenerateDialog(createEditDialogDraft(sourceLayer)); setActiveTool('generate'); }, @@ -385,6 +414,7 @@ export function useImageCanvasGenerationWorkflow({ setImageContextMenu(null); setMetadataLayer(null); setGenerateDialog(null); + setCropExpandPanel(null); setCharacterAnimationPanel(null); setQuickEditPanel(createQuickEditPanelDraft(sourceLayer)); selectSingleLayer(sourceLayer.id); @@ -399,6 +429,176 @@ export function useImageCanvasGenerationWorkflow({ ], ); + const updateSourceLayer = useCallback( + ( + sourceLayerId: string, + updater: (layer: CanvasLayer) => CanvasLayer, + ) => { + const sourceLayer = layers.find((layer) => layer.id === sourceLayerId); + if (!sourceLayer) { + return; + } + const updatedLayer = updater(sourceLayer); + captureCanvasHistory(); + setLayers((currentLayers) => + currentLayers.map((layer) => + layer.id === sourceLayerId ? updatedLayer : layer, + ), + ); + persistUpdatedLayerResource?.(updatedLayer); + selectSingleLayer(sourceLayerId); + fitLayers([updatedLayer]); + }, + [ + captureCanvasHistory, + fitLayers, + layers, + persistUpdatedLayerResource, + selectSingleLayer, + setLayers, + ], + ); + + const openRedrawPanel = useCallback( + (sourceLayer: CanvasLayer) => { + setImageContextMenu(null); + setMetadataLayer(null); + setGenerateDialog(null); + setCropExpandPanel(null); + setCharacterAnimationPanel(null); + setQuickEditPanel(createRedrawPanelDraft(sourceLayer)); + selectSingleLayer(sourceLayer.id); + setActiveTool('generate'); + }, + [ + selectSingleLayer, + setActiveTool, + setGenerateDialog, + setImageContextMenu, + setMetadataLayer, + ], + ); + + const openCropExpandPanel = useCallback( + (sourceLayer: CanvasLayer) => { + setImageContextMenu(null); + setMetadataLayer(null); + setGenerateDialog(null); + setQuickEditPanel(null); + setCharacterAnimationPanel(null); + setCropExpandPanel({ + sourceLayerId: sourceLayer.id, + insets: { + left: '0', + top: '0', + right: '0', + bottom: '0', + }, + status: 'idle', + }); + selectSingleLayer(sourceLayer.id); + setActiveTool('select'); + }, + [ + selectSingleLayer, + setActiveTool, + setGenerateDialog, + setImageContextMenu, + setMetadataLayer, + ], + ); + + const submitCropExpand = useCallback(async () => { + if (!cropExpandPanel || !cropExpandSourceLayer) { + return; + } + + try { + const insets = { + left: parseCropExpandInsetValue(cropExpandPanel.insets.left), + top: parseCropExpandInsetValue(cropExpandPanel.insets.top), + right: parseCropExpandInsetValue(cropExpandPanel.insets.right), + bottom: parseCropExpandInsetValue(cropExpandPanel.insets.bottom), + }; + setCropExpandPanel({ + ...cropExpandPanel, + status: 'processing', + errorMessage: undefined, + }); + const result = await renderCropExpandImage({ + source: cropExpandSourceLayer.src, + insets, + }); + const layerPatch = buildCropExpandLayerPatch({ + layerX: cropExpandSourceLayer.x, + layerY: cropExpandSourceLayer.y, + insets, + outputWidth: result.width, + outputHeight: result.height, + }); + updateSourceLayer(cropExpandSourceLayer.id, (layer) => ({ + ...layer, + ...layerPatch, + src: result.imageSrc, + objectKey: null, + assetObjectId: null, + sourceAssetId: null, + })); + setCropExpandPanel(null); + setActiveSidebarPanel('layers'); + } catch (error) { + setCropExpandPanel({ + ...cropExpandPanel, + status: 'failed', + errorMessage: + error instanceof Error && error.message.trim() + ? error.message + : '裁扩图片失败', + }); + } + }, [ + cropExpandPanel, + cropExpandSourceLayer, + setActiveSidebarPanel, + updateSourceLayer, + ]); + + const removeSelectedLayerBackground = useCallback( + async (sourceLayer: CanvasLayer) => { + setImageContextMenu(null); + setMetadataLayer(null); + setCropExpandPanel(null); + setQuickEditPanel(null); + try { + const result = await removeImageBackground(sourceLayer.src); + updateSourceLayer(sourceLayer.id, (layer) => ({ + ...layer, + src: result.imageSrc, + width: result.width, + height: result.height, + originalWidth: result.width, + originalHeight: result.height, + objectKey: null, + assetObjectId: null, + sourceAssetId: null, + })); + setActiveSidebarPanel('layers'); + } catch (error) { + window.alert( + error instanceof Error && error.message.trim() + ? error.message + : '去除背景失败', + ); + } + }, + [ + setActiveSidebarPanel, + setImageContextMenu, + setMetadataLayer, + updateSourceLayer, + ], + ); + const pickCharacterSpecFromLayer = useCallback( (layer: CanvasLayer) => { setGenerateDialog((currentDialog) => @@ -461,19 +661,15 @@ export function useImageCanvasGenerationWorkflow({ [setGenerateDialog, setImageContextMenu], ); - const updateIconDescription = useCallback( - (index: number, value: string) => { + const updateIconDescriptionsText = useCallback( + (value: string) => { setGenerateDialog((currentDialog) => - updateIconDescriptionInDialog(currentDialog, index, value), + updateIconDescriptionsTextInDialog(currentDialog, value), ); }, [setGenerateDialog], ); - const addIconDescription = useCallback(() => { - setGenerateDialog(appendIconDescriptionToDialog); - }, [setGenerateDialog]); - const generationSubmissionWorkflow = useImageCanvasGenerationSubmissionWorkflow({ layers, canvasSize, @@ -538,6 +734,9 @@ export function useImageCanvasGenerationWorkflow({ setQuickEditPanel((currentPanel) => currentPanel?.sourceLayerId === targetLayerId ? null : currentPanel, ); + setCropExpandPanel((currentPanel) => + currentPanel?.sourceLayerId === targetLayerId ? null : currentPanel, + ); setCharacterAnimationPanel((currentPanel) => currentPanel?.sourceLayerId === targetLayerId ? null : currentPanel, ); @@ -557,6 +756,9 @@ export function useImageCanvasGenerationWorkflow({ quickEditPanel, setQuickEditPanel, quickEditSourceLayer, + cropExpandPanel, + setCropExpandPanel, + cropExpandSourceLayer, quickEditSizeOptions, quickEditModelOptions, characterAnimationPanel, @@ -599,6 +801,9 @@ export function useImageCanvasGenerationWorkflow({ openBackgroundMusicGenerationDialog, openEditDialog, openQuickEditPanel, + openRedrawPanel, + openCropExpandPanel, + removeSelectedLayerBackground, pickCharacterSpecFromLayer, pickGenerationReferenceFromLayer, pickCharacterReferenceFromLayer, @@ -606,10 +811,10 @@ export function useImageCanvasGenerationWorkflow({ pickUiDesignSpecFromLayer, submitIconSpritesheetGeneration, submitQuickEdit, + submitCropExpand, submitImageGeneration, updateSpecFormValue, - updateIconDescription, - addIconDescription, + updateIconDescriptionsText, updateCharacterAnimationDuration, rememberImageModel: setLastImageModel, submitCharacterAnimation, @@ -618,12 +823,13 @@ export function useImageCanvasGenerationWorkflow({ clearDeletedLayerGenerationState, }), [ - addIconDescription, characterAnimationPanel, characterAnimationPrice, characterAnimationSourceLayer, clearDeletedLayerGenerationState, closeGenerateComposer, + cropExpandPanel, + cropExpandSourceLayer, hideGeneratedLayerPanelAfterBlur, iconDescriptionValues, isCharacterReferenceMenuOpen, @@ -641,9 +847,11 @@ export function useImageCanvasGenerationWorkflow({ openBackgroundMusicGenerationDialog, openCharacterAnimationPanel, openCharacterGenerationDialog, + openCropExpandPanel, openEditDialog, openGenerateDialog, openIconGenerationDialog, + openRedrawPanel, openUiDesignGenerationDialog, openQuickEditPanel, openSpecDialog, @@ -658,12 +866,14 @@ export function useImageCanvasGenerationWorkflow({ quickEditPanel, quickEditSizeOptions, quickEditSourceLayer, + removeSelectedLayerBackground, submitCharacterAnimation, + submitCropExpand, submitIconSpritesheetGeneration, submitImageGeneration, submitQuickEdit, updateCharacterAnimationDuration, - updateIconDescription, + updateIconDescriptionsText, updateSpecFormValue, ], ); diff --git a/src/components/image-editor/useImageCanvasLayerCommands.test.tsx b/src/components/image-editor/useImageCanvasLayerCommands.test.tsx index 7202fa741..552341205 100644 --- a/src/components/image-editor/useImageCanvasLayerCommands.test.tsx +++ b/src/components/image-editor/useImageCanvasLayerCommands.test.tsx @@ -61,7 +61,7 @@ function LayerCommandsHarness({ ); const [historyCount, setHistoryCount] = useState(0); const [imageContextClosedCount, setImageContextClosedCount] = useState(0); - const [activeTool, setActiveTool] = useState('shape'); + const [activeTool, setActiveTool] = useState('select'); const selectSingleLayer = (layerId: string | null) => { setSelectedLayerId(layerId); diff --git a/src/index.css b/src/index.css index 652df66c1..8fe69b7d4 100644 --- a/src/index.css +++ b/src/index.css @@ -3965,6 +3965,31 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { border-radius: 0.45rem; } +.image-canvas-editor__bottom-tool-group { + display: inline-flex; + align-items: center; + gap: 0.35rem; +} + +.image-canvas-editor__bottom-toolbar-divider, +.image-canvas-editor__floating-toolbar-divider { + display: inline-block; + width: 1px; + height: 0.5rem; + flex: 0 0 auto; + align-self: center; + border-radius: 999px; + background: #d9dee8; +} + +.image-canvas-editor__floating-toolbar-divider { + height: 1.125rem; +} + +.image-canvas-editor__bottom-toolbar-option-wrap { + display: inline-flex; +} + .image-canvas-editor__viewport { position: relative; min-width: 0; @@ -4120,6 +4145,62 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { color: #c7c7c7; } +.image-canvas-editor__generation-frame--character { + background: radial-gradient( + circle at 50% 34%, + rgba(250, 245, 255, 0.94), + transparent 30% + ), + linear-gradient(135deg, rgba(250, 245, 255, 0.84), rgba(243, 232, 255, 0.72)); +} + +.image-canvas-editor__generation-frame--character.image-canvas-editor__generation-frame--focused { + border-color: rgba(147, 51, 234, 0.72); +} + +.image-canvas-editor__generation-frame--character + .image-canvas-editor__generation-frame-icon { + color: #9333ea; +} + +.image-canvas-editor__generation-frame--video { + background: + linear-gradient(90deg, rgba(15, 23, 42, 0.12) 10%, transparent 10% 90%, rgba(15, 23, 42, 0.12) 90%), + linear-gradient(135deg, rgba(239, 246, 255, 0.9), rgba(219, 234, 254, 0.82)); +} + +.image-canvas-editor__generation-frame--video.image-canvas-editor__generation-frame--focused { + border-color: rgba(37, 99, 235, 0.72); +} + +.image-canvas-editor__generation-frame--video + .image-canvas-editor__generation-frame-icon { + color: #2563eb; +} + +.image-canvas-editor__generation-frame--audio-sound-effect, +.image-canvas-editor__generation-frame--audio-background-music { + background: + repeating-linear-gradient( + 90deg, + rgba(79, 70, 229, 0.14) 0 0.22rem, + transparent 0.22rem 0.58rem + ), + linear-gradient(135deg, rgba(238, 242, 255, 0.92), rgba(224, 231, 255, 0.8)); +} + +.image-canvas-editor__generation-frame--audio-sound-effect.image-canvas-editor__generation-frame--focused, +.image-canvas-editor__generation-frame--audio-background-music.image-canvas-editor__generation-frame--focused { + border-color: rgba(79, 70, 229, 0.72); +} + +.image-canvas-editor__generation-frame--audio-sound-effect + .image-canvas-editor__generation-frame-icon, +.image-canvas-editor__generation-frame--audio-background-music + .image-canvas-editor__generation-frame-icon { + color: #4f46e5; +} + .image-canvas-editor__generation-frame--generating { overflow: hidden; background: radial-gradient( @@ -4385,6 +4466,11 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { box-shadow: 0 8px 18px rgba(245, 158, 11, 0.24); } +.image-canvas-editor__kind-badge--video { + background: rgba(37, 99, 235, 0.92); + box-shadow: 0 8px 18px rgba(37, 99, 235, 0.24); +} + .image-canvas-editor__kind-badge--sound-effect, .image-canvas-editor__kind-badge--background-music { background: rgba(79, 70, 229, 0.92); @@ -4408,6 +4494,7 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { position: absolute; z-index: 8; display: inline-flex; + align-items: center; gap: 0.3rem; border: 1px solid #d9dee8; border-radius: 0.5rem; @@ -4417,6 +4504,16 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { transform: translate(-50%, -100%); } +.image-canvas-editor__floating-toolbar button.image-canvas-editor__floating-toolbar-text-button { + width: auto; + min-width: 0; + gap: 0.35rem; + padding: 0 0.62rem; + font-size: 0.74rem; + font-weight: 850; + white-space: nowrap; +} + .image-canvas-editor__reset-button { position: absolute; right: 0.85rem; @@ -5319,6 +5416,47 @@ button.image-canvas-editor__reference-chip:disabled { box-shadow: inset 0 0 0 1px #202124; } +.image-canvas-editor__option-popover--audio-options { + display: grid; + min-width: 16.4rem; + gap: 0.72rem; + padding: 0.88rem; +} + +.image-canvas-editor__audio-type-options { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.image-canvas-editor__audio-bpm-control { + display: grid; + grid-template-columns: minmax(7rem, 1fr) 4.7rem; + align-items: center; + gap: 0.56rem; +} + +.image-canvas-editor__audio-bpm-control input[type='range'] { + width: 100%; + accent-color: #4f46e5; +} + +.image-canvas-editor__audio-bpm-control input[type='number'] { + min-width: 0; + border: 1px solid rgba(15, 23, 42, 0.1); + border-radius: 999px; + background: #f8fafc; + padding: 0.46rem 0.62rem; + color: #0f172a; + font-size: 0.82rem; + font-weight: 760; + outline: none; +} + +.image-canvas-editor__audio-bpm-control input[type='number']:focus { + border-color: rgba(79, 70, 229, 0.42); + box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1); +} + .image-canvas-editor__option-popover-choice--model { display: grid; width: 100%; @@ -5514,41 +5652,22 @@ button.image-canvas-editor__reference-chip:disabled { z-index: 13; display: grid; grid-template-columns: minmax(0, 1fr); - min-width: 28rem; - max-width: none; - gap: 0.55rem; - overflow: visible; - border: 1px solid rgba(134, 146, 166, 0.24); - border-radius: 1.45rem; - background: radial-gradient( - circle at 10% 0%, - rgba(240, 253, 250, 0.95), - transparent 36% - ), - linear-gradient( - 135deg, - rgba(255, 255, 255, 0.98), - rgba(248, 250, 252, 0.96) - ); - padding: 0.72rem 0.78rem 0.76rem; + grid-template-rows: auto minmax(5.8rem, auto) auto auto; + width: min(34rem, calc(100% - 1.5rem)); + min-height: 11.2rem; + gap: 0.5rem; + border: 1px solid #cfd6df; + border-radius: 1.35rem; + background: #ffffff; + padding: 0.62rem 0.7rem 0.68rem; color: #1f2937; - box-shadow: - 0 20px 54px rgba(15, 23, 42, 0.17), - inset 0 1px 0 rgba(255, 255, 255, 0.95); + box-shadow: 0 18px 46px rgba(15, 23, 42, 0.16); transform: translateX(-50%); } -.image-canvas-editor__icon-spec-row { - display: grid; - grid-template-columns: minmax(15.5rem, 1fr) auto; - min-width: 0; - align-items: stretch; - gap: 0.5rem; -} - -.image-canvas-editor__icon-spec-row .image-canvas-editor__character-spec-wrap { - display: flex; - min-width: 0; +.image-canvas-editor__icon-composer .image-canvas-editor__generation-prompt { + min-height: 5.8rem; + padding-top: 0.42rem; } .image-canvas-editor__icon-spec-card:disabled { @@ -5556,47 +5675,6 @@ button.image-canvas-editor__reference-chip:disabled { opacity: 0.68; } -.image-canvas-editor__icon-spec-actions { - display: grid; - grid-auto-flow: column; - align-items: stretch; - gap: 0.32rem; -} - -.image-canvas-editor__icon-spec-actions button { - min-width: 3rem; - border: 1px solid rgba(148, 163, 184, 0.25); - border-radius: 0.9rem; - background: rgba(255, 255, 255, 0.86); - padding: 0 0.42rem; - color: #475569; - font-size: 0.72rem; - font-weight: 900; - box-shadow: 0 8px 18px rgba(15, 23, 42, 0.08); -} - -.image-canvas-editor__icon-spec-actions button:hover:not(:disabled) { - transform: translateY(-1px); - border-color: rgba(16, 185, 129, 0.35); - background: #ffffff; - color: #047857; -} - -.image-canvas-editor__icon-spec-actions button:disabled { - cursor: wait; - opacity: 0.56; -} - -.image-canvas-editor__icon-description-list { - display: grid; - grid-auto-flow: column; - grid-auto-columns: 8.4rem; - align-items: stretch; - gap: 0.46rem; - overflow: visible; - padding: 0.02rem 0; -} - .image-canvas-editor__field-block { display: grid; gap: 0.42rem; @@ -5636,61 +5714,6 @@ button.image-canvas-editor__reference-chip:disabled { scrollbar-width: thin; } -.image-canvas-editor__icon-description-card { - display: grid; - min-width: 0; - gap: 0.32rem; - border: 1px solid rgba(148, 163, 184, 0.22); - border-radius: 1.05rem; - background: linear-gradient( - 180deg, - rgba(255, 255, 255, 0.96), - rgba(248, 250, 252, 0.92) - ), - #ffffff; - padding: 0.45rem; - box-shadow: - 0 10px 24px rgba(15, 23, 42, 0.08), - inset 0 1px 0 rgba(255, 255, 255, 0.9); -} - -.image-canvas-editor__icon-description-index { - display: inline-flex; - width: fit-content; - align-items: center; - border-radius: 999px; - background: #111827; - padding: 0.1rem 0.36rem; - color: #ffffff; - font-size: 0.64rem; - font-weight: 900; - letter-spacing: 0.05em; -} - -.image-canvas-editor__icon-description-title { - color: #475569; - font-size: 0.7rem; - font-weight: 900; - letter-spacing: 0.04em; -} - -.image-canvas-editor__icon-description-input { - min-height: 2.25rem; -} - -.image-canvas-editor__icon-footer { - display: grid; - grid-template-columns: minmax(0, 1fr) auto auto; - align-items: center; - gap: 0.48rem; -} - -.image-canvas-editor__icon-footer - .image-canvas-editor__character-reference-add { - min-width: 6.2rem; - height: 2.55rem; -} - .image-canvas-editor__character-reference-row { display: flex; min-width: 0; @@ -6004,7 +6027,8 @@ button.image-canvas-editor__reference-chip:disabled { color: #991b1b !important; } -.image-canvas-editor__quick-edit-panel { +.image-canvas-editor__quick-edit-panel, +.image-canvas-editor__crop-expand-panel { position: absolute; z-index: 14; display: grid; @@ -6021,6 +6045,10 @@ button.image-canvas-editor__reference-chip:disabled { transform: translateX(-50%); } +.image-canvas-editor__crop-expand-panel { + width: min(19rem, calc(100vw - 1.5rem)); +} + .image-canvas-editor__quick-edit-head { display: flex; align-items: center; @@ -6068,6 +6096,30 @@ button.image-canvas-editor__reference-chip:disabled { min-width: 6.8rem; } +.image-canvas-editor__crop-expand-fields { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 0.45rem; +} + +.image-canvas-editor__crop-expand-field { + display: grid; + gap: 0.26rem; + color: #475569; + font-size: 0.72rem; + font-weight: 850; +} + +.image-canvas-editor__crop-expand-error { + margin: 0; + border-radius: 0.72rem; + background: #fff1f2; + padding: 0.48rem 0.62rem; + color: #be123c; + font-size: 0.74rem; + font-weight: 800; +} + .image-canvas-editor__character-animation-panel { position: absolute; z-index: 14; @@ -6402,15 +6454,11 @@ button.image-canvas-editor__reference-chip:disabled { right: auto; top: auto !important; bottom: 3.85rem; + width: calc(100% - 1.5rem) !important; min-width: 0; - max-width: none; transform: none; } - .image-canvas-editor__icon-spec-row { - grid-template-columns: minmax(14rem, 1fr) auto; - } - .image-canvas-editor__canvas-pick-hint { position: fixed; top: 4.35rem; @@ -6438,6 +6486,10 @@ button.image-canvas-editor__reference-chip:disabled { height: 2rem; } + .image-canvas-editor__floating-toolbar button.image-canvas-editor__floating-toolbar-text-button { + width: auto; + } + .image-canvas-editor__floating-toolbar { max-width: calc(100% - 1.5rem); overflow-x: auto; diff --git a/src/services/image-editor/editorProjectClient.test.ts b/src/services/image-editor/editorProjectClient.test.ts index e7d15df65..db886302a 100644 --- a/src/services/image-editor/editorProjectClient.test.ts +++ b/src/services/image-editor/editorProjectClient.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { createEditorAsset, @@ -9,8 +9,8 @@ import { deleteEditorAssetFolder, deleteEditorProject, editEditorImage, - generateEditorCharacterAnimation, generateEditorBackgroundMusic, + generateEditorCharacterAnimation, generateEditorIconSpritesheet, generateEditorImage, generateEditorSoundEffect, @@ -591,11 +591,13 @@ describe('editorProjectClient', () => { model: 'gemini-3.1-flash-image-preview', provider: 'VectorEngine', taskId: 'icon-spritesheet-task-1', + priceMudPoints: 12, }); const result = await generateEditorIconSpritesheet({ referenceImageSrc: 'data:image/png;base64,spec', iconDescriptions: ['返回按钮', '设置按钮'], + priceMudPoints: 12, }); expect(result.taskId).toBe('icon-spritesheet-task-1'); @@ -608,6 +610,7 @@ describe('editorProjectClient', () => { referenceImageSrc: 'data:image/png;base64,spec', iconDescriptions: ['返回按钮', '设置按钮'], model: 'gemini-3.1-flash-image-preview', + priceMudPoints: 12, }), }), '生成图标素材失败', @@ -629,6 +632,7 @@ describe('editorProjectClient', () => { model: 'gpt-image-2', provider: 'VectorEngine', taskId: 'icon-spritesheet-task-2', + priceMudPoints: 12, }); await generateEditorIconSpritesheet({ @@ -637,6 +641,7 @@ describe('editorProjectClient', () => { model: 'gpt-image-2', aspectRatio: '1:1', imageSize: '2K', + priceMudPoints: 12, }); expect(requestJsonMock).toHaveBeenCalledWith( @@ -650,6 +655,7 @@ describe('editorProjectClient', () => { model: 'gpt-image-2', aspectRatio: '1:1', imageSize: '2K', + priceMudPoints: 12, }), }), '生成图标素材失败', @@ -842,8 +848,12 @@ describe('editorProjectClient', () => { sound: 'off', priceMudPoints: 100, referenceImageSrcs: ['data:image/png;base64,image'], - referenceVideoSrcs: ['data:video/mp4;base64,video'], - referenceAudioSrcs: ['data:audio/mpeg;base64,audio'], + referenceVideoSrcs: [ + 'generated-character-drafts/editor/seedance-references/video/video.mp4', + ], + referenceAudioSrcs: [ + 'generated-character-drafts/editor/seedance-references/audio/audio.mp3', + ], }); expect(requestJsonMock).toHaveBeenCalledWith( @@ -861,8 +871,12 @@ describe('editorProjectClient', () => { sound: 'off', priceMudPoints: 100, referenceImageSrcs: ['data:image/png;base64,image'], - referenceVideoSrcs: ['data:video/mp4;base64,video'], - referenceAudioSrcs: ['data:audio/mpeg;base64,audio'], + referenceVideoSrcs: [ + 'generated-character-drafts/editor/seedance-references/video/video.mp4', + ], + referenceAudioSrcs: [ + 'generated-character-drafts/editor/seedance-references/audio/audio.mp3', + ], }), }), '生成视频失败', @@ -887,8 +901,8 @@ describe('editorProjectClient', () => { const result = await generateEditorSoundEffect({ sound: '金币掉落叮当声', - type: 'ui', - tempo: 'fast', + type: 'one-shot', + tempo: 120, priceMudPoints: 10, }); @@ -900,8 +914,8 @@ describe('editorProjectClient', () => { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sound: '金币掉落叮当声', - type: 'ui', - tempo: 'fast', + type: 'one-shot', + tempo: 120, priceMudPoints: 10, }), }), @@ -913,6 +927,43 @@ describe('editorProjectClient', () => { ); }); + it('keeps editor sound effect tempo as null when BPM is not selected', async () => { + requestJsonMock.mockResolvedValueOnce({ + audioSrc: '/generated-character-drafts/editor-audios/sfx-null.mp3', + width: 420, + height: 120, + sourceType: 'generated', + prompt: '按钮确认短促音', + actualPrompt: '按钮确认短促音', + model: 'audio1.0', + provider: 'VectorEngine', + taskId: 'sound-task-null', + priceMudPoints: 10, + audioKind: 'sound-effect', + }); + + await generateEditorSoundEffect({ + sound: '按钮确认短促音', + type: 'one-shot', + tempo: null, + priceMudPoints: 10, + }); + + expect(requestJsonMock).toHaveBeenCalledWith( + '/api/editor/audios/sound-effects/generations', + expect.objectContaining({ + body: JSON.stringify({ + sound: '按钮确认短促音', + type: 'one-shot', + tempo: null, + priceMudPoints: 10, + }), + }), + '生成游戏音效失败', + expect.any(Object), + ); + }); + it('generates editor background music through the backend BFF', async () => { requestJsonMock.mockResolvedValueOnce({ audioSrc: '/generated-character-drafts/editor-audios/bgm.mp3', diff --git a/src/services/image-editor/editorProjectClient.ts b/src/services/image-editor/editorProjectClient.ts index 2dfee690e..62f3f47ce 100644 --- a/src/services/image-editor/editorProjectClient.ts +++ b/src/services/image-editor/editorProjectClient.ts @@ -101,6 +101,7 @@ export type EditorIconSpritesheetGenerationInput = { model?: string; aspectRatio?: string; imageSize?: string; + priceMudPoints: number; }; export type EditorImageEditInput = { @@ -141,6 +142,7 @@ export type EditorIconSpritesheetGenerationResult = { model: string; provider: string; taskId: string; + priceMudPoints: number; }; export type EditorCharacterAnimationResolution = '480p' | '720p'; @@ -227,8 +229,8 @@ export type EditorVideoGenerationResult = { export type EditorSoundEffectGenerationInput = { sound: string; - type: 'ui' | 'foley' | 'ambient'; - tempo: 'slow' | 'medium' | 'fast'; + type: 'one-shot' | 'loop'; + tempo: number | null; priceMudPoints: number; }; @@ -558,6 +560,7 @@ export async function generateEditorIconSpritesheet( model: input.model?.trim() || EDITOR_IMAGE_MODEL_NANOBANANA2, ...(input.aspectRatio ? { aspectRatio: input.aspectRatio } : {}), ...(input.imageSize ? { imageSize: input.imageSize } : {}), + priceMudPoints: input.priceMudPoints, }), '生成图标素材失败', { From 8c749c5f6839bc60d4630e35e747cea92c8876ed Mon Sep 17 00:00:00 2001 From: kdletters Date: Thu, 18 Jun 2026 20:34:29 +0800 Subject: [PATCH 16/16] =?UTF-8?q?=E6=94=B9=E8=BF=9B=E7=94=9F=E6=88=90?= =?UTF-8?q?=E4=B8=AD=E5=8D=A0=E4=BD=8D=E5=9B=BE=E5=88=A0=E9=99=A4=E7=A1=AE?= =?UTF-8?q?=E8=AE=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 生成中占位图删除前提示泥点不返还 占位图增加独立删除按钮并接入键盘删除请求 补充生成中占位图删除确认回归测试 记录本轮画布删除确认验证进度 --- TRACKING.md | 1 + ...CanvasEditorGenerationIntegration.test.tsx | 62 +++++++++++++ .../ImageCanvasEditorShellView.test.tsx | 1 + .../image-editor/ImageCanvasEditorView.tsx | 88 +++++++++++++++---- .../image-editor/ImageCanvasStageView.tsx | 3 + .../ImageCanvasWorldView.test.tsx | 5 ++ .../image-editor/ImageCanvasWorldView.tsx | 28 +++++- .../useImageCanvasKeyboardShortcuts.test.tsx | 30 ++++--- .../useImageCanvasKeyboardShortcuts.ts | 16 ++-- src/index.css | 11 +++ 10 files changed, 207 insertions(+), 38 deletions(-) diff --git a/TRACKING.md b/TRACKING.md index 366d27a80..9d577ebd0 100644 --- a/TRACKING.md +++ b/TRACKING.md @@ -144,6 +144,7 @@ - 2026-06-17 前端拆分第十九执行批次:新增 `useImageCanvasCanvasDropWorkflow`,把画布区域 drag over / drag leave / drop 分流从主视图抽出,覆盖素材库图片拖入画布、本地文件拖入画布、无关拖拽不拦截、默认文件夹选择和画布遮罩清理;主视图继续注入素材建层、文件上传、drop 点换算和素材移动高亮清理。新增 hook 单测覆盖拖拽入画布细节,主视图从 1452 行降至 1405 行。验证命令:`npm run test -- src/components/image-editor/useImageCanvasCanvasDropWorkflow.test.tsx src/components/image-editor/useImageCanvasStageInteractions.test.tsx src/components/image-editor/useImageCanvasViewportControls.test.tsx src/components/image-editor/ImageCanvasInteractionModel.test.ts src/components/image-editor/useCanvasHistory.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`;浏览器回归:`http://127.0.0.1:10003/editor/canvas` 登录态刷新后素材 / 画布 / 小地图和底部工具栏可见,真实鼠标拖拽素材库图片到画布时出现 `添加到画布` 遮罩,松手后画布图层数量从 4 增至 5;`画布背景设置` 点击 `暖灰` 后 viewport 背景为 `rgb(243, 240, 234)` 且 `background-image: none`;抓手工具可切回选择工具,登录后控制台无前端 error。 - 2026-06-17 前端拆分第二十执行批次:新增 `ImageCanvasMetadataModalView`,把图片信息弹窗从主视图抽出,承载图片类型、生成输入、参考图、模型、分辨率、Provider、Task 和 Object 信息渲染;主视图只保留 `metadataLayer` 状态和关闭回调。同步修复未登录进入编辑器时项目 / 素材接口抢跑 401、`重置画布视图` 点击事件误传给适合视图函数的问题。新增组件单测覆盖生成图 metadata、上传图 fallback 和关闭回调,新增 hook / 主视图测试覆盖未登录不请求受保护素材 / 工程数据和重置按钮回归;主视图从 1405 行降至 1337 行。验证命令:`npm run test -- src/components/image-editor/useImageCanvasAssetLibrary.test.tsx src/components/image-editor/useImageCanvasProjectPersistence.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx src/components/image-editor/ImageCanvasMetadataModalView.test.tsx src/components/image-editor/useImageCanvasEditorChrome.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`;浏览器回归:`http://127.0.0.1:10003/editor/canvas` 清空会话后直接弹出 `账号入口`,且未登录状态下没有发起 `/api/editor/*` 请求;登录临时开发账号后 `重置画布视图` 无控制台错误,`画布背景设置` 保持 Lovart 式白色浮层,点击 `暖灰` 后 viewport 背景为 `rgb(243, 240, 234)`,上传素材可加入画布,右上角图片信息按钮可打开不透明白底元数据弹窗,关闭后 `AI画布工具栏` 仍可见。 - 2026-06-17 前端拆分第二十一执行批次:新增 `useImageCanvasKeyboardShortcuts`,把 Ctrl / Cmd + Z 撤销、Ctrl / Cmd + Shift + Z 重做、Shift 状态、Backspace / Delete 删除、Escape 关闭临时面板和 Space 临时抓手从主视图抽出;主视图继续注入图层删除、生成对话框、快速编辑和 chrome 面板 setter。新增 hook 单测覆盖输入框忽略快捷键、删除选中图层、删除生成占位、Escape 保留生成中面板、Space 和 Shift;主视图从 1337 行降至 1250 行。验证命令:`npm run test -- src/components/image-editor/useImageCanvasKeyboardShortcuts.test.tsx src/components/image-editor/ImageCanvasMetadataModalView.test.tsx src/components/image-editor/useImageCanvasAssetLibrary.test.tsx src/components/image-editor/useImageCanvasProjectPersistence.test.tsx src/components/image-editor/useImageCanvasCanvasDropWorkflow.test.tsx src/components/image-editor/useImageCanvasStageInteractions.test.tsx src/components/image-editor/useImageCanvasViewportControls.test.tsx src/components/image-editor/ImageCanvasInteractionModel.test.ts src/components/image-editor/useCanvasHistory.test.tsx src/components/image-editor/useImageCanvasEditorChrome.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`;浏览器回归:`http://127.0.0.1:10003/editor/canvas` 未登录直接弹出 `账号入口` 且未抢跑 `/api/editor/*`,登录后 `/api/editor/assets/library` 和 `/api/editor/projects/recent` 为 200,`AI画布工具栏` 与 `画布面板入口` 可见,viewport 背景为 `rgb(248, 250, 252)` 且 `background-image: none`;按住 Space 从 `文字工具` 临时切到 `抓手工具`,松开恢复 `文字工具`;`画布背景设置` 点击 `暖灰` 后背景变为 `rgb(243, 240, 234)`;点击 `生成工具` 后 `Image Generator` 占位框、`生成图片` 对话框和 `AI画布工具栏` 同时可见,登录后控制台无前端 error。 +- 2026-06-18 生成中占位图删除确认:画布生成占位框增加删除按钮,删除 `generating` 状态占位图时统一弹出 `删除生成中的占位图` 二次确认,并提示 `已消耗的泥点不会返还`;空闲占位仍直接删除。键盘 Backspace / Delete 删除生成占位也改走同一请求入口,避免绕过提示;确认删除后清理选中态、右键菜单并切回选择工具,已删除的生成任务即使后续返回结果也不会落图。验证命令:`npm run test -- src/components/image-editor/ImageCanvasWorldView.test.tsx src/components/image-editor/useImageCanvasKeyboardShortcuts.test.tsx src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`;浏览器回归:本地 Vite + Chrome CDP 打开 `/editor/canvas`,点击 `生成图片` 创建生成中占位,删除按钮弹出确认框,取消后占位保留,确认后占位移除且提示包含泥点不返还。 - 2026-06-17 前端拆分第二十二执行批次:新增 `useImageCanvasAssetPointerDragBridge`,把素材卡片 pointer 拖拽到画布 / 文件夹的全局监听、拖拽激活、画布 drop 提示、文件夹高亮、移动素材、加入画布和点击抑制从主视图抽出;主视图继续负责素材库事实、画布建层、历史和工程资源持久化。同步恢复 `账号入口` 认证弹窗 portal 渲染,避免全屏画布遮住登录弹窗;`画布背景设置` 调整为独立白色浮层,包含当前色、色域、色相、自定义颜色、预设网格、HEX 输入和恢复默认。验证命令:`npm run test -- src/components/image-editor/useImageCanvasAssetPointerDragBridge.test.tsx src/components/image-editor/useImageCanvasKeyboardShortcuts.test.tsx src/components/image-editor/ImageCanvasMetadataModalView.test.tsx src/components/image-editor/useImageCanvasAssetLibrary.test.tsx src/components/image-editor/useImageCanvasProjectPersistence.test.tsx src/components/image-editor/useImageCanvasCanvasDropWorkflow.test.tsx src/components/image-editor/useImageCanvasStageInteractions.test.tsx src/components/image-editor/useImageCanvasViewportControls.test.tsx src/components/image-editor/ImageCanvasInteractionModel.test.ts src/components/image-editor/useCanvasHistory.test.tsx src/components/image-editor/useImageCanvasEditorChrome.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx src/components/auth/PlatformAuthModalShell.test.tsx src/components/auth/AuthGate.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`;浏览器回归:`http://127.0.0.1:10007/editor/canvas` 未登录打开直接显示 `账号入口`;登录临时开发账号后 `画布背景设置` 显示当前色、色域、色相、自定义颜色、预设、HEX 和恢复默认,点击 `暖灰` 后 viewport 背景为 `rgb(243, 240, 234)` 且 `background-image: none`;上传图片进入 `项目素材`,真实 pointer 拖拽素材到画布后图层数从 0 到 1 且 `AI画布工具栏` 保持可见;新建 `SmokeFolder` 后真实 pointer 拖拽素材到文件夹,`项目素材` 变 0、`SmokeFolder` 变 1,素材执行移动而非拷贝。控制台仅有未登录 refresh 401,登录后编辑器 API 均为 200。 - 2026-06-17 前端拆分第二十三执行批次:新增 `ImageCanvasOverlayModel`,把生成输入框锚定、图标素材生成面板宽度、选中图片工具栏边界、快速编辑面板和角色动画面板定位从主视图抽出为纯模型;主视图继续保留生成 / quick edit / 角色动画状态机和舞台编排。新增模型单测覆盖锚定优先级、生成中隐藏、icon 宽度、工具栏 clamp、quick edit / 角色动画边界和生成 dialog 模式识别;主视图从 1182 行降至 1133 行。验证命令:`npm run test -- src/components/image-editor/ImageCanvasOverlayModel.test.ts src/components/image-editor/ImageCanvasEditorView.test.tsx`、`npm run test -- src/components/image-editor/useImageCanvasAssetPointerDragBridge.test.tsx src/components/image-editor/useImageCanvasEditorChrome.test.tsx src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx`、`npm run test -- src/components/image-editor/ImageCanvasOverlayModel.test.ts src/components/image-editor/useImageCanvasAssetPointerDragBridge.test.tsx src/components/image-editor/useImageCanvasKeyboardShortcuts.test.tsx src/components/image-editor/ImageCanvasMetadataModalView.test.tsx src/components/image-editor/useImageCanvasAssetLibrary.test.tsx src/components/image-editor/useImageCanvasProjectPersistence.test.tsx src/components/image-editor/useImageCanvasCanvasDropWorkflow.test.tsx src/components/image-editor/useImageCanvasStageInteractions.test.tsx src/components/image-editor/useImageCanvasViewportControls.test.tsx src/components/image-editor/ImageCanvasInteractionModel.test.ts src/components/image-editor/useCanvasHistory.test.tsx src/components/image-editor/useImageCanvasEditorChrome.test.tsx src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx src/components/auth/PlatformAuthModalShell.test.tsx src/components/auth/AuthGate.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`;浏览器回归:`http://127.0.0.1:10007/editor/canvas` 未登录打开显示 `账号入口`,登录后 `画布背景设置` 点击 `暖灰` 后 viewport 背景为 `rgb(243, 240, 234)` 且 `background-image: none`,点击 `生成工具` 后 `Image Generator`、`生成图片` 对话框和 `AI画布工具栏` 均可见;素材库已有素材真实 pointer 拖入画布后图层数从 0 到 1,工具栏保持可见,截图留存于 `output/playwright/editor-overlay-model-regression-20260617.png`。 - 2026-06-17 前端拆分第二十四执行批次:新增 `useImageCanvasAssetCanvasBridge`,把删除素材清理画布图层、素材加入画布、素材 pointer 拖入画布 / 文件夹、画布 drag/drop 分流和文件拖入画布参数组装从主视图抽成素材到画布桥接 hook;主视图继续保留素材库事实、上传读取、工程资源持久化和历史触发。新增 hook 单测覆盖素材建层、pointer drop 入画布和删除素材清理关联图层 / 选中态;主视图从 1133 行降至 1086 行。验证命令:`npm run test -- src/components/image-editor/useImageCanvasAssetCanvasBridge.test.tsx src/components/image-editor/useImageCanvasAssetPointerDragBridge.test.tsx src/components/image-editor/useImageCanvasCanvasDropWorkflow.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`;浏览器回归:`http://127.0.0.1:10007/editor/canvas` 清空会话后未登录直接显示 `账号入口`;登录临时开发账号后 `画布背景设置` 是完整设置面板,包含当前色、色域、色相、自定义颜色、预设、HEX 和恢复默认,点击 `暖灰` 后 viewport 背景为 `rgb(243, 240, 234)` 且 `background-image: none`,HEX 输入 `#ffffff` 后变为白色,按 Escape 关闭面板;登录后上传图片请求 `/api/editor/assets` 返回 200,素材库出现上传素材,`AI画布工具栏` 保持可见。 diff --git a/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx b/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx index 2871ce2a0..5432b7a23 100644 --- a/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx @@ -497,6 +497,68 @@ describe('ImageCanvasEditorView generation integration', () => { ); }); + it('confirms before deleting a generating placeholder because mud points are not refunded', async () => { + let resolveGeneration!: (value: unknown) => void; + generateEditorImageMock.mockReturnValueOnce( + new Promise((resolve) => { + resolveGeneration = resolve; + }), + ); + render(); + + await waitFor(() => { + expect(loadOrCreateRecentEditorProjectMock).toHaveBeenCalled(); + }); + + fireEvent.click(screen.getByRole('button', { name: '生成图片' })); + fireEvent.change(screen.getByLabelText('生成提示词'), { + target: { value: '准备删除的生成中图片' }, + }); + fireEvent.click(screen.getByRole('button', { name: '生成' })); + + const frame = screen.getByLabelText('图像生成占位图'); + expect(frame.className).toContain( + 'image-canvas-editor__generation-frame--generating', + ); + + fireEvent.keyDown(window, { key: 'Delete', code: 'Delete' }); + + const confirmDialog = screen.getByRole('dialog', { + name: '删除生成中的占位图', + }); + expect( + within(confirmDialog).getByText( + '删除后会停止在画布中显示这个生成任务,已消耗的泥点不会返还。', + ), + ).toBeTruthy(); + expect(screen.getByLabelText('图像生成占位图')).toBeTruthy(); + + fireEvent.click(within(confirmDialog).getByRole('button', { name: '取消' })); + expect(screen.queryByRole('dialog', { name: '删除生成中的占位图' })).toBeNull(); + expect(screen.getByLabelText('图像生成占位图')).toBeTruthy(); + + fireEvent.keyDown(window, { key: 'Delete', code: 'Delete' }); + fireEvent.click(screen.getByRole('button', { name: '确认删除' })); + + expect(screen.queryByLabelText('图像生成占位图')).toBeNull(); + + await act(async () => { + resolveGeneration({ + imageSrc: 'data:image/png;base64,ZGVsZXRlZC1wbGFjZWhvbGRlcg==', + width: 1024, + height: 1024, + sourceType: 'generated', + prompt: '准备删除的生成中图片', + actualPrompt: '准备删除的生成中图片', + model: 'gpt-image-2', + provider: 'VectorEngine', + taskId: 'editor-deleted-placeholder-1', + }); + }); + + expect(screen.queryByAltText(/画布图片:生成图片/)).toBeNull(); + }); + it('hides the generation composer when selecting another image but keeps the placeholder', () => { render(); diff --git a/src/components/image-editor/ImageCanvasEditorShellView.test.tsx b/src/components/image-editor/ImageCanvasEditorShellView.test.tsx index 27292fe9e..409bba246 100644 --- a/src/components/image-editor/ImageCanvasEditorShellView.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorShellView.test.tsx @@ -159,6 +159,7 @@ function createStageProps(): ImageCanvasStageViewProps { onOpenLayerMetadata: vi.fn(), onGenerationFramePointerDown: vi.fn(), onActivateGenerationDialog: vi.fn(), + onDeleteGenerationDialog: vi.fn(), onOpenQuickEditPanel: vi.fn(), onOpenRedrawPanel: vi.fn(), onOpenCropExpandPanel: vi.fn(), diff --git a/src/components/image-editor/ImageCanvasEditorView.tsx b/src/components/image-editor/ImageCanvasEditorView.tsx index eff6791e1..9d7a9cd82 100644 --- a/src/components/image-editor/ImageCanvasEditorView.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.tsx @@ -5,6 +5,7 @@ import { createEditorProjectResource, } from '../../services/image-editor/editorProjectClient'; import { useAuthUi } from '../auth/AuthUiContext'; +import { PlatformDangerConfirmDialog } from '../common/PlatformDangerConfirmDialog'; import { resolveContextMenuPosition } from './ImageCanvasEditorModel'; import { ImageCanvasEditorShellView } from './ImageCanvasEditorShellView'; import type { @@ -70,6 +71,8 @@ export function ImageCanvasEditorView() { const [selectedLayerIds, setSelectedLayerIds] = useState([]); const [hoveredLayerId, setHoveredLayerId] = useState(null); const [metadataLayer, setMetadataLayer] = useState(null); + const [pendingGenerationDeleteDialog, setPendingGenerationDeleteDialog] = + useState(null); const [imageContextMenu, setImageContextMenu] = useState(null); const [contextMenu, setContextMenu] = useState( @@ -664,6 +667,45 @@ export function ImageCanvasEditorView() { (layerId: string | null) => deleteLayerByIdRef.current(layerId), [], ); + const removeCanvasGenerationDialog = useCallback( + (dialogId: string) => { + captureCanvasHistory(); + removeCanvasGenerationDialogById(dialogId); + setSelectedLayerId(null); + setSelectedLayerIds([]); + setImageContextMenu(null); + setContextMenu(null); + setActiveTool('select'); + }, + [ + captureCanvasHistory, + removeCanvasGenerationDialogById, + setActiveTool, + setContextMenu, + setImageContextMenu, + setSelectedLayerId, + setSelectedLayerIds, + ], + ); + const requestRemoveCanvasGenerationDialog = useCallback( + (dialog: CanvasGenerationDialogState) => { + if (dialog.status === 'generating') { + activateCanvasGenerationDialog(dialog); + setPendingGenerationDeleteDialog(dialog); + return; + } + removeCanvasGenerationDialog(dialog.id); + }, + [activateCanvasGenerationDialog, removeCanvasGenerationDialog], + ); + const confirmRemoveGeneratingDialog = useCallback(() => { + const dialog = pendingGenerationDeleteDialog; + if (!dialog) { + return; + } + setPendingGenerationDeleteDialog(null); + removeCanvasGenerationDialog(dialog.id); + }, [pendingGenerationDeleteDialog, removeCanvasGenerationDialog]); useImageCanvasKeyboardShortcuts({ generateDialogRef, @@ -671,11 +713,7 @@ export function ImageCanvasEditorView() { redoCanvasChange, undoCanvasChange, deleteLayerById: deleteLayerByIdFromShortcut, - removeCanvasGenerationDialogById: (dialogId: string) => { - captureCanvasHistory(); - removeCanvasGenerationDialogById(dialogId); - setActiveTool('select'); - }, + requestRemoveCanvasGenerationDialog, setActiveTool, setGenerateDialog, setImageContextMenu, @@ -897,6 +935,7 @@ export function ImageCanvasEditorView() { }, onGenerationFramePointerDown: handleGenerationFramePointerDown, onActivateGenerationDialog: activateCanvasGenerationDialog, + onDeleteGenerationDialog: requestRemoveCanvasGenerationDialog, onOpenQuickEditPanel: openQuickEditPanel, onOpenRedrawPanel: openRedrawPanel, onOpenCropExpandPanel: openCropExpandPanel, @@ -936,20 +975,31 @@ export function ImageCanvasEditorView() { }; return ( - setMetadataLayer(null), - }} - /> + <> + setMetadataLayer(null), + }} + /> + setPendingGenerationDeleteDialog(null)} + onConfirm={confirmRemoveGeneratingDialog} + > + 删除后会停止在画布中显示这个生成任务,已消耗的泥点不会返还。 + + ); } diff --git a/src/components/image-editor/ImageCanvasStageView.tsx b/src/components/image-editor/ImageCanvasStageView.tsx index 80edbe4d0..43a5f0fba 100644 --- a/src/components/image-editor/ImageCanvasStageView.tsx +++ b/src/components/image-editor/ImageCanvasStageView.tsx @@ -90,6 +90,7 @@ export type ImageCanvasStageViewProps = { dialog: CanvasGenerationDialogState, ) => void; onActivateGenerationDialog: (dialog: CanvasGenerationDialogState) => void; + onDeleteGenerationDialog: (dialog: CanvasGenerationDialogState) => void; onOpenQuickEditPanel: (layer: CanvasLayer) => void; onOpenRedrawPanel: (layer: CanvasLayer) => void; onOpenCropExpandPanel: (layer: CanvasLayer) => void; @@ -177,6 +178,7 @@ export function ImageCanvasStageView({ onOpenLayerMetadata, onGenerationFramePointerDown, onActivateGenerationDialog, + onDeleteGenerationDialog, onOpenQuickEditPanel, onOpenRedrawPanel, onOpenCropExpandPanel, @@ -256,6 +258,7 @@ export function ImageCanvasStageView({ onOpenLayerMetadata={onOpenLayerMetadata} onGenerationFramePointerDown={onGenerationFramePointerDown} onActivateGenerationDialog={onActivateGenerationDialog} + onDeleteGenerationDialog={onDeleteGenerationDialog} /> { fireEvent.pointerDown(frame); fireEvent.doubleClick(frame); + fireEvent.click( + within(frame).getByRole('button', { name: '删除图标素材生成占位图' }), + ); expect(props.onGenerationFramePointerDown).toHaveBeenCalledWith( expect.any(Object), dialog, ); expect(props.onActivateGenerationDialog).toHaveBeenCalledWith(dialog); + expect(props.onDeleteGenerationDialog).toHaveBeenCalledWith(dialog); expect(screen.queryByText('dialog-without-placeholder')).toBeNull(); }); diff --git a/src/components/image-editor/ImageCanvasWorldView.tsx b/src/components/image-editor/ImageCanvasWorldView.tsx index 904356ff7..f8107e53d 100644 --- a/src/components/image-editor/ImageCanvasWorldView.tsx +++ b/src/components/image-editor/ImageCanvasWorldView.tsx @@ -1,4 +1,12 @@ -import { Film, ImageIcon, Info, Music2, UserRound, Volume2 } from 'lucide-react'; +import { + Film, + ImageIcon, + Info, + Music2, + Trash2, + UserRound, + Volume2, +} from 'lucide-react'; import type { CSSProperties, MouseEvent as ReactMouseEvent, @@ -111,6 +119,7 @@ function buildInverseScaleStyle( '--image-canvas-editor-kind-offset': `calc(0.38rem * ${inverseScale})`, '--image-canvas-editor-beside-kind-offset': `calc(3.95rem * ${inverseScale})`, '--image-canvas-editor-frame-label-offset': `calc(-1.35rem * ${inverseScale})`, + '--image-canvas-editor-frame-action-offset': `calc(-0.75rem * ${inverseScale})`, }; } @@ -145,6 +154,7 @@ export type ImageCanvasWorldViewProps = { dialog: CanvasGenerationDialogState, ) => void; onActivateGenerationDialog: (dialog: CanvasGenerationDialogState) => void; + onDeleteGenerationDialog: (dialog: CanvasGenerationDialogState) => void; }; export function ImageCanvasWorldView({ @@ -166,6 +176,7 @@ export function ImageCanvasWorldView({ onOpenLayerMetadata, onGenerationFramePointerDown, onActivateGenerationDialog, + onDeleteGenerationDialog, }: ImageCanvasWorldViewProps) { const inverseScale = getInverseViewportScale(viewport); const inverseScaleStyle = buildInverseScaleStyle(inverseScale); @@ -382,6 +393,21 @@ export function ImageCanvasWorldView({ {getGenerationFrameLabel(dialog)}
) : null} + {showFocusedChrome ? ( + } + style={inverseScaleStyle} + onPointerDown={(event) => event.stopPropagation()} + onClick={(event) => { + event.preventDefault(); + event.stopPropagation(); + onDeleteGenerationDialog(dialog); + }} + /> + ) : null} {placeholderMeta.badgeLabel ? ( void; redoCanvasChange?: () => void; deleteLayerById?: (layerId: string | null) => void; - removeCanvasGenerationDialogById?: (dialogId: string) => void; + requestRemoveCanvasGenerationDialog?: ( + dialog: CanvasGenerationDialogState, + ) => void; closeEditorChromePanels?: () => void; }) { const [activeTool, setActiveTool] = useState(initialTool); @@ -110,7 +113,7 @@ function KeyboardShortcutsHarness({ redoCanvasChange, undoCanvasChange, deleteLayerById, - removeCanvasGenerationDialogById, + requestRemoveCanvasGenerationDialog, setActiveTool: (tool) => setActiveTool(tool), setGenerateDialog: (updater) => setGenerateDialogState((currentDialog) => @@ -229,13 +232,14 @@ describe('useImageCanvasKeyboardShortcuts', () => { expect(deleteLayerById).toHaveBeenCalledWith('layer-selected'); }); - it('removes editable generation placeholders with Backspace', () => { - const removeCanvasGenerationDialogById = vi.fn(); + it('requests deleting editable generation placeholders with Backspace', () => { + const requestRemoveCanvasGenerationDialog = vi.fn(); + const dialog = createPlaceholderDialog('character'); render( , ); @@ -243,7 +247,7 @@ describe('useImageCanvasKeyboardShortcuts', () => { fireEvent.keyDown(window, { key: 'Backspace', code: 'Backspace' }); }); - expect(removeCanvasGenerationDialogById).toHaveBeenCalledWith('dialog-1'); + expect(requestRemoveCanvasGenerationDialog).toHaveBeenCalledWith(dialog); expect(screen.getByTestId('generate-dialog').textContent).toBe( 'character:idle:true', ); @@ -254,15 +258,15 @@ describe('useImageCanvasKeyboardShortcuts', () => { expect(screen.getByTestId('icon-picking').textContent).toBe('false'); }); - it('removes generating placeholders with Delete', () => { - const removeCanvasGenerationDialogById = vi.fn(); + it('requests deleting generating placeholders with Delete', () => { + const requestRemoveCanvasGenerationDialog = vi.fn(); const generatingDialog = createPlaceholderDialog('generate'); generatingDialog.status = 'generating'; render( , ); @@ -270,7 +274,9 @@ describe('useImageCanvasKeyboardShortcuts', () => { fireEvent.keyDown(window, { key: 'Delete', code: 'Delete' }); }); - expect(removeCanvasGenerationDialogById).toHaveBeenCalledWith('dialog-1'); + expect(requestRemoveCanvasGenerationDialog).toHaveBeenCalledWith( + generatingDialog, + ); expect(screen.getByTestId('active-tool').textContent).toBe('select'); }); diff --git a/src/components/image-editor/useImageCanvasKeyboardShortcuts.ts b/src/components/image-editor/useImageCanvasKeyboardShortcuts.ts index 8815e7900..8e8d1c97c 100644 --- a/src/components/image-editor/useImageCanvasKeyboardShortcuts.ts +++ b/src/components/image-editor/useImageCanvasKeyboardShortcuts.ts @@ -12,7 +12,9 @@ type UseImageCanvasKeyboardShortcutsOptions = { redoCanvasChange: () => void; undoCanvasChange: () => void; deleteLayerById: (layerId: string | null) => void; - removeCanvasGenerationDialogById?: (dialogId: string) => void; + requestRemoveCanvasGenerationDialog?: ( + dialog: CanvasGenerationDialogState, + ) => void; setActiveTool: (tool: 'select') => void; setGenerateDialog: ( updater: @@ -68,7 +70,9 @@ function isCanvasGenerationPlaceholderDialog( dialog?.mode === 'character' || dialog?.mode === 'icon' || dialog?.mode === 'ui-design' || - dialog?.mode === 'video') + dialog?.mode === 'video' || + dialog?.mode === 'audio-sound-effect' || + dialog?.mode === 'audio-background-music') ); } @@ -84,7 +88,7 @@ export function useImageCanvasKeyboardShortcuts({ redoCanvasChange, undoCanvasChange, deleteLayerById, - removeCanvasGenerationDialogById, + requestRemoveCanvasGenerationDialog, setActiveTool, setGenerateDialog, setImageContextMenu, @@ -182,9 +186,9 @@ export function useImageCanvasKeyboardShortcuts({ const currentDialog = generateDialogRef.current; if ( isCanvasGenerationDialogState(currentDialog) && - removeCanvasGenerationDialogById + requestRemoveCanvasGenerationDialog ) { - removeCanvasGenerationDialogById(currentDialog.id); + requestRemoveCanvasGenerationDialog(currentDialog); } else { setGenerateDialog(null); } @@ -233,7 +237,7 @@ export function useImageCanvasKeyboardShortcuts({ closeEditorChromePanels, deleteLayerById, generateDialogRef, - removeCanvasGenerationDialogById, + requestRemoveCanvasGenerationDialog, redoCanvasChange, selectedLayerIdRef, setActiveTool, diff --git a/src/index.css b/src/index.css index 8fe69b7d4..3db6ddfa1 100644 --- a/src/index.css +++ b/src/index.css @@ -4139,6 +4139,17 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { transform-origin: bottom right; } +.image-canvas-editor__generation-frame-delete { + position: absolute; + right: var(--image-canvas-editor-frame-action-offset, -0.75rem); + top: var(--image-canvas-editor-frame-action-offset, -0.75rem); + z-index: 5; + width: 1.5rem; + height: 1.5rem; + transform: scale(var(--image-canvas-editor-inverse-scale, 1)); + transform-origin: top right; +} + .image-canvas-editor__generation-frame-icon { display: grid; place-items: center;