新增编辑器生成规范、生成角色形象、生成图标素材等功能 #64

Merged
kdletters merged 6 commits from dev-jenken into codex/editor-asset-library 2026-06-16 23:48:31 +08:00
37 changed files with 11629 additions and 3225 deletions
+48
View File
@@ -2246,3 +2246,51 @@
- 影响范围:`server-rs/crates/spacetime-module/src/editor_project_storage.rs``server-rs/crates/spacetime-client/src/editor_project.rs``server-rs/crates/api-server/src/editor_project.rs``src/services/image-editor/editorProjectClient.ts``src/components/image-editor/ImageCanvasEditorView.tsx`、后端数据契约文档和图片画布前端技术方案。
- 验证方式:`npm run spacetime:generate -- --rust-only``npm run test -- src/components/image-editor/ImageCanvasEditorView.test.tsx src/services/image-editor/editorProjectClient.test.ts``npm run typecheck``npm run check:spacetime-schema``npm run check:encoding``cargo check -p spacetime-client -p api-server --manifest-path server-rs/Cargo.toml``git diff --check`
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md``docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`
## 2026-06-15 图片画布角色图层新增动画生成入口
- 背景:图片画布已有角色形象图层标记 `assetKind="character"`,需要只对角色图片开放动画生成,不让普通素材误触发角色动画链路。
- 决策:角色动画入口只由画布图层 `assetKind="character"` 控制,在图片上方浮动工具条和右键菜单显示 `生成动画`;非角色图层不展示入口。点击后打开独立 `角色动画生成面板`,桌面端锚定到图片右侧,移动端按底部面板承接。前端固定提交 `seedance2.0`、分辨率 / 比例 / 帧数 / 时长 / 价格字段;后端经 `/api/editor/character-animations/generations` 使用角色图作为首帧和尾帧生成视频,并立即抽取 32 / 40 / 48 帧、绿幕去背后写入 OSS。
- 影响范围:`src/components/image-editor/ImageCanvasEditorView.tsx``src/services/image-editor/editorProjectClient.ts``server-rs/crates/api-server/src/character_animation_assets.rs``server-rs/crates/shared-contracts/src/assets.rs`、图片画布技术方案。
- 验证方式:`npm run test -- src/components/image-editor/ImageCanvasEditorView.test.tsx src/services/image-editor/editorProjectClient.test.ts``cargo test -p api-server editor_character_animation --manifest-path server-rs/Cargo.toml``cargo check -p api-server --manifest-path server-rs/Cargo.toml``npm run typecheck``npm run check:encoding``git diff --check`
- 关联文档:`docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md``docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`
## 2026-06-16 图片画布图标素材面板采用 Lovart 式参考卡与横向增宽布局
- 背景:图标素材生成面板里,规范入口与素材描述项过于平铺,且子面板内部采用滑动列表,和 Lovart 风格画布的参考卡 / 物料卡不一致。
- 决策:`生成图标素材` 面板不使用内部纵向滚动列表;每新增一个素材描述项就让面板整体增宽,保持描述项横向卡片一眼可扫。图标素材规范入口改为 Lovart 式参考卡:缩略图、名称、绑定状态和轻量动作分区分开呈现,独立菜单只负责来源切换,不再承载说明文案。
- 影响范围:`src/components/image-editor/ImageCanvasEditorView.tsx``src/index.css`、图标素材生成专项设计文档。
- 验证方式:新增或增删素材描述项时,面板宽度应随项数变化;图标素材规范入口应呈现参考卡视觉而非纯文本按钮;移动端下仍应固定在底部锚定,不出现内部滚动条。
- 关联文档:`docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md``docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`
## 2026-06-16 图片画布图标素材采用 nanobanana2 spritesheet + 后端连通域拆分
- 背景:图片画布需要一次生成多枚 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`
## 2026-06-16 图片画布生成面板与浮层层级收口
- 背景:图片画布底部工具栏和角色参考图行都存在局部滚动 / 裁切容器,生成规范菜单和角色规范来源菜单如果仍内嵌在触发按钮附近,会被边界遮挡;同时生成类面板打开后隐藏底部工具栏会破坏连续创作节奏。
- 决策:`生成规范``角色形象规范来源``图标素材规范来源` 菜单统一通过页面级 fixed portal 渲染到 `document.body`,触发按钮只提供定位锚点;点击 `生成工具``生成角色形象``生成图标素材` 后底部 AI 工具栏保持可见。点击画布空白区域只关闭当前生成面板并清除图片选中样式,不删除新建的占位图。角色面板中的 `角色形象规范``上传常规参考图` 入口统一改为 Lovart 式参考图卡片。
- 影响范围:`src/components/image-editor/ImageCanvasEditorView.tsx``src/index.css``src/components/image-editor/ImageCanvasEditorView.test.tsx`、图片画布前端技术方案和角色形象生成设计文档。
- 验证方式:`npm run test -- src/components/image-editor/ImageCanvasEditorView.test.tsx``npm run typecheck``npm run check:encoding``git diff --check`
- 关联文档:`docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md``docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`
## 2026-06-16 图片画布图片信息页不展示生图 Prompt
- 背景:图片画布中每张生成图片的信息页原来展示 `Prompt` 和复制 Prompt,但该字段可能是后端组装后的生图提示词,不适合作为用户可见的图片输入信息。
- 决策:图片信息页删除生图 Prompt 展示和复制入口,改为展示生成时的用户面板输入快照,包括普通生成提示词、规范表单字段、角色设定、图标素材描述、修改要求,以及角色形象规范、常规参考图、图标素材规范和修改参考图等参考图卡片。旧数据或上传图片没有输入快照时显示 `-`,不得回退展示内部 Prompt。
- 影响范围:`src/components/image-editor/ImageCanvasEditorView.tsx`、图片画布 layout snapshot、图片画布技术方案。
- 验证方式:`npm run test -- src/components/image-editor/ImageCanvasEditorView.test.tsx` 应覆盖图片信息页无 `Prompt`、无 `复制Prompt`,并展示普通生成、角色生成、图标素材和修改结果的输入快照。
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`
## 2026-06-16 图片画布按 Resolution 原分辨率显示
- 背景:图片画布图层曾同时维护展示 `Size` 与资源 `Resolution`,旧布局快照里的 `width/height` 可能把大图缩成小图,导致画布视觉和图片信息里的原始分辨率不一致。
- 决策:图片图层不再把独立 `Size` 作为用户可见字段或展示真相;画布图层渲染宽高、悬浮尺寸胶囊和图片信息页统一以 `originalWidth/originalHeight`(即 `Resolution`)为准。旧 layout 中的 `width/height` 只作为缺少 Resolution 时的兼容兜底,不再优先决定展示大小。
- 影响范围:`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`
+57 -1
View File
@@ -1,4 +1,4 @@
# 踩坑与排障记录
# 踩坑与排障记录
> 用途:记录已验证、未来很可能再次遇到的问题。每条都应包含现象、原因、处理方式和验证方式。
@@ -15,6 +15,62 @@
- 关联:相关文件、文档、提交或 Issue
```
## 图片编辑器底部生成按钮不要复用单一画布生成状态
- 现象:图片画布里先新建一个“生成规范”占位,再点击“生成角色形象”或其它底部生成入口,前一个规范占位和面板状态被销毁。
- 原因:底部普通生成、规范、角色和图标素材曾共用单个 `generateDialog` 状态;后一次点击直接覆盖该状态,等同把前一个画布生成对象卸载。
- 处理:底部生成类入口每次点击都创建独立 generation dialog id;当前 active 对象只负责显示编辑面板,旧对象归档为 inactive 后仍保留占位和生成逻辑状态。生成完成 / 失败回写、生成中拖拽和删除都必须按 dialog id 读取 active + inactive 中的最新对象,不能回退到提交瞬间的旧占位快照。
- 验证:`npm run test -- src/components/image-editor/ImageCanvasEditorView.test.tsx -t "keeps existing generation placeholders"` 应断言规范占位和角色占位可同时存在;`npm run test -- src/components/image-editor/ImageCanvasEditorView.test.tsx -t "keeps archived generation logic"` 应断言旧对象归档后拖动,占位完成回写仍落在最新位置。
- 关联:`src/components/image-editor/ImageCanvasEditorView.tsx``src/components/image-editor/ImageCanvasEditorView.test.tsx``docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`
## 图片编辑器生成中设定面板不要和预览框绑成同一可见性
- 现象:图片编辑器里点击生成后,有时设定面板没收起,有时连画布上的占位预览一起消失,看起来像“生成中界面掉了”。
- 原因:生成中状态只收了 composer 可见性,或把占位框和设定面板共用了同一段条件渲染;面板隐藏后把 placeholder 也一起卸掉,就会丢掉 Lovart 式生成中预览。
- 处理:进入 `generating` 后只隐藏设定面板,保留占位框和生成中状态胶囊;面板外观、预览框和结果图层分开控制,不共用同一个 `composerOpen` 条件。
- 验证:对应测试应断言生成按钮点击后 `dialog` 消失但 `image-canvas-editor__generation-frame--generating` 仍然存在。
- 关联:`src/components/image-editor/ImageCanvasEditorView.tsx``src/components/image-editor/ImageCanvasEditorView.test.tsx`
## 图片画布快速编辑不要直接提交普通图片 URL
- 现象:图片画布快速编辑站内示例图、历史 generated 图或 OSS generated 图时,后端返回 `修改图片参考图必须是图片 Data URL。`
- 原因:快速编辑直接把图层 `src` 塞进 `/api/editor/images/generations``referenceImageSrcs`;默认示例图和部分持久化图层的 `src``/creation-type-references/*.webp``/generated-*` 或 OSS URL,而 `api-server` 的编辑参考图解析只接收 `data:image/*;base64,...`
- 处理:前端统一通过 `resolveEditorImageReferenceDataUrl(...)` 在提交前读取图片字节并转成图片 Data URL;Data URL 原样透传,`/generated-*` 和 generated OSS URL 走 `/api/assets/read-bytes` 避免 CORS,普通 public 路径直接 fetch。
- 验证:`npm run test -- src/services/image-editor/editorImageReference.test.ts src/components/image-editor/ImageCanvasEditorView.test.tsx -t "editorImageReference|converts non-data-url quick edit source images before submitting references"`
- 关联:`src/services/image-editor/editorImageReference.ts``src/components/image-editor/ImageCanvasEditorView.tsx``docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`
## 图片编辑器角色动画不要默认提交大图 Data URL
- 现象:图片编辑器里对角色图点击 `生成动画` 后,后端返回 `Failed to buffer the request body: length limit exceeded`,请求还没进入角色动画 handler。
- 原因:角色动画生成请求曾把角色图片 `src` 原样作为 `sourceImageSrc` 放进 JSON;角色图如果是较大的 Data URL,会超过 Axum 默认 `2MB` body limit,在 `Json` 提取器阶段被拦截。
- 处理:前端在角色图已持久化时优先提交 `objectKey`,只把 Data URL 作为未持久化本地临时图兜底;后端 `/api/editor/character-animations/generations` 单独配置 `12MB` body limit 兼容旧请求,但新链路不应依赖传大图 JSON。
- 验证:`npm run test -- src/components/image-editor/ImageCanvasEditorView.test.tsx -t "only exposes character animation"``cargo test -p api-server editor_character_animation_accepts_character_image_body_above_default_limit --manifest-path server-rs/Cargo.toml`
- 关联:`src/components/image-editor/ImageCanvasEditorView.tsx``server-rs/crates/api-server/src/modules/play_flow.rs``server-rs/crates/api-server/src/app.rs``docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md`
## 图片编辑器生成类菜单要挂到页面级 portal
- 现象:底部 `生成规范` 菜单、角色面板里的 `角色形象规范` 来源菜单点击后像没有弹出来,实际被按钮所在的局部滚动容器挡住了。
- 原因:菜单仍然渲染在底部工具栏或参考图横向滚动行内部,父容器带 `overflow`,弹层无法越出边界。
- 处理:这类轻量菜单统一用页面级 fixed portal 挂到 `document.body`,位置根据触发按钮的 `getBoundingClientRect()` 计算;底部 AI 工具栏在生成面板打开时仍保持可见,不要整栏隐藏。
- 验证:测试断言菜单不包含在底部工具栏 / 参考图行里,并且生成面板打开时底部 `AI画布工具栏` 仍存在。
- 关联:`src/components/image-editor/ImageCanvasEditorView.tsx``src/components/image-editor/ImageCanvasEditorView.test.tsx`
## 图片编辑器生成占位图在生成中也要使用最新拖拽位置
- 现象:用户在图片编辑器里提交生成后继续拖动画布占位图,预览框可以移动,但生成完成后的真实图片仍落回提交瞬间的旧位置。
- 原因:生成提交函数闭包里保存了旧的 `dialog.placeholder` 快照;如果完成回包仍用这个快照创建图层,就会丢失生成中期间的拖拽坐标。若 `handleGenerationFramePointerDown` 又按 `status === 'generating'` 拦截,则生成中占位图完全不能拖动。
- 处理:生成占位图的 pointer down 不因 `generating` 禁止;普通图片、规范图、角色图和图标素材回包创建图层时,都从当前 `generateDialogRef.current.placeholder` 读取最新占位位置,失败后保留的占位图也继续走同一拖拽链路。
- 验证:`npm test -- src/components/image-editor/ImageCanvasEditorView.test.tsx -t "keeps the generation placeholder draggable while the image is generating"`
- 关联:`src/components/image-editor/ImageCanvasEditorView.tsx``src/components/image-editor/ImageCanvasEditorView.test.tsx``docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`
## Windows 本地 dev 不要把 RUSTC_WRAPPER 绕过写成 rustc
- 现象:Windows 上执行 `npm run dev:api-server` 时,api-server 在 Cargo 启动阶段失败,日志出现 `error: multiple input filenames provided (first two filenames are ... rustc.exe and -)``/healthz` 无法访问。
- 原因:`server-rs/.cargo/config.toml` 默认配置 `rustc-wrapper = "sccache"`;本地 dev 脚本为了绕过损坏的 sccache 需要覆盖 wrapper。Windows 下如果把 `RUSTC_WRAPPER` 设置为 `rustc`Cargo 会按 wrapper 协议调用 `rustc <真实rustc路径> - ...`,真实 rustc 把 wrapper 传入的 rustc 路径和 stdin `-` 都当输入文件。
- 处理:Windows 本地 dev 脚本应把 `RUSTC_WRAPPER``CARGO_BUILD_RUSTC_WRAPPER` 显式设为空字符串,让 Cargo 覆盖项目配置并直连真实 rustc;Linux 保持 `/usr/bin/env` 绕过 sccache。
- 验证:`npm run test -- scripts/dev.test.ts -t "Windows 下本地 dev Rust env 用空 wrapper 覆盖项目 sccache"`,并用 `npm run dev:api-server` 拉起后访问实际 api 端口的 `/healthz` 返回 200。
- 关联:`scripts/dev.mjs``scripts/dev.test.ts``docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`
## 外部生成 worker 业务失败重试会撞上钱包扣退费幂等
- 现象:同一个外部生成 job 如果第一次业务失败后退款,再用同一个业务资源 ID 自动重试并成功,钱包 `consume` ledger 可能因为同 ID 已存在而跳过,最终出现“失败已退、成功不再扣”的余额漂移。
+1
View File
@@ -108,3 +108,4 @@
- 2026-06-14 组件复用修正:编辑器侧栏素材和图层缩略图通过 `SidebarMediaItem` 改为复用 `PlatformMediaFrame`,删除缩略图内部图片填充的重复 CSS,统一媒体预览框和 fallback 结构;验证命令:`npm run test -- src/components/image-editor/ImageCanvasEditorPrimitives.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx src/components/common/PlatformMediaFrame.test.tsx``npm run typecheck`
- 2026-06-14 组件复用修正:画布图片 hover 尺寸标签改为复用 `PlatformPillBadge tone="lightOverlay"`,局部 CSS 只保留定位和深色覆盖,不再重复维护 badge 的圆角、字号和基础排版;验证命令:`npm run test -- src/components/image-editor/ImageCanvasEditorView.test.tsx src/components/common/PlatformPillBadge.test.tsx``npm run typecheck`
- 2026-06-14 组件复用修正:生成跟随框的关闭按钮改为复用 `PlatformIconButton variant="surfaceFloating"`,编辑器薄包装 `EditorIconButton` 增加 variant 透传,删除局部关闭按钮基础 chrome;验证命令:`npm run test -- src/components/image-editor/ImageCanvasEditorPrimitives.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx src/components/common/PlatformIconButton.test.tsx``npm run typecheck`
- 2026-06-16 编辑器回归修正:工程 / 素材 / 上传等编辑器请求恢复全局 401 / 403 登录弹窗;未登录上传会先弹登录并在登录后续传;画布背景入口恢复为 `画布背景设置` 面板,支持预设色、自定义颜色、HEX 输入、非法值不应用、恢复默认和 Escape 关闭。验证命令:`npm run test -- src/components/image-editor/ImageCanvasEditorView.test.tsx src/services/image-editor/editorProjectClient.test.ts``npm run typecheck``npm run check:encoding``git diff --check`;浏览器 smoke`http://127.0.0.1:10006/editor/canvas` 未登录打开 `账号入口`,登录后上传素材成功,背景面板打开后点击“暖灰”使画布背景变为 `rgb(243, 240, 234)`
@@ -0,0 +1,146 @@
# Editor Image Model Options Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 让图片画布的“生成角色形象”和“生成图标素材”支持 `nanobanana2``gpt-image-2`,并按模型提供合法的尺寸比例与大小尺寸选项。
**Architecture:** 前端抽出编辑器图片模型配置,生成面板只保存模型、比例、大小三个轻量状态;后端集中归一模型和尺寸组合,角色图与图标 spritesheet 继续走现有 VectorEngine、去背、OSS 和拆分链路。用户模型偏好用 localStorage 记住,默认 `nanobanana2`
**Tech Stack:** React + TypeScript + VitestRust Axum api-serverplatform-image VectorEngine providerMarkdown 项目文档。
---
## File Structure
- Modify `C:\Genarrative\src\services\image-editor\editorProjectClient.ts`
- 扩展图片生成和图标 spritesheet 请求类型,加入 `aspectRatio``imageSize`
- 默认图标模型改为 `gemini-3.1-flash-image-preview` 对应的 `nanobanana2`
- Modify `C:\Genarrative\src\services\image-editor\editorProjectClient.test.ts`
- 先补失败测试:角色 / 图标请求会带模型、比例、大小。
- Modify `C:\Genarrative\src\components\image-editor\ImageCanvasEditorView.tsx`
- 增加模型配置、选项归一、localStorage 偏好、角色 / 图标面板字段和提交 payload。
- Modify `C:\Genarrative\src\components\image-editor\ImageCanvasEditorView.test.tsx`
- 先补失败测试:默认显示 nanobanana2,切换模型后比例 / 大小选项变更,并在请求中传递。
- Modify `C:\Genarrative\server-rs\crates\platform-image\src\vector_engine\request.rs`
-`512` 不被 normalize 成非法尺寸,并保留 gpt-image-2 尺寸。
- Modify `C:\Genarrative\server-rs\crates\platform-image\tests\vector_engine.rs`
- 先补失败测试:nanobanana2 0.5K 请求 body 保留 `512`
- Modify `C:\Genarrative\server-rs\crates\api-server\src\editor_project.rs`
- 扩展请求 DTO,集中校验 `nanobanana2 / gpt-image-2` 与尺寸组合。
- 角色生成按模型走 with_model 调用;图标生成按模型和组合选择尺寸。
- Modify docs:
- `C:\Genarrative\docs\【编辑器】画板角色形象生成入口设计-2026-06-15.md`
- `C:\Genarrative\docs\【编辑器】画板图标素材生成入口设计-2026-06-15.md`
- `C:\Genarrative\docs\technical\【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`
---
### Task 1: Client request contract
**Files:**
- Modify: `C:\Genarrative\src\services\image-editor\editorProjectClient.ts`
- Test: `C:\Genarrative\src\services\image-editor\editorProjectClient.test.ts`
- [ ] **Step 1: Write failing tests**
Add tests asserting `generateEditorImage` and `generateEditorIconSpritesheet` serialize `model`, `aspectRatio`, and `imageSize` when supplied.
- [ ] **Step 2: Run test to verify failure**
Run: `npm run test -- src/services/image-editor/editorProjectClient.test.ts -t "image model options"`
Expected: FAIL because payloads do not include `aspectRatio` / `imageSize`.
- [ ] **Step 3: Minimal implementation**
Extend input types and JSON body builders to include optional `aspectRatio` and `imageSize`; change icon default model constant to `gemini-3.1-flash-image-preview` if not already.
- [ ] **Step 4: Verify green**
Run same test command. Expected: PASS.
### Task 2: Frontend panel state and local preference
**Files:**
- Modify: `C:\Genarrative\src\components\image-editor\ImageCanvasEditorView.tsx`
- Test: `C:\Genarrative\src\components\image-editor\ImageCanvasEditorView.test.tsx`
- [ ] **Step 1: Write failing tests**
Add tests for:
1. opening `生成角色形象` defaults to model `nanobanana2` and shows `尺寸比例` / `大小尺寸`;
2. switching to `gpt-image-2` limits visible combinations and submits model + mapped size metadata;
3. icon spritesheet defaults to `nanobanana2` and submits the chosen model.
- [ ] **Step 2: Run test to verify failure**
Run: `npm run test -- src/components/image-editor/ImageCanvasEditorView.test.tsx -t "模型|尺寸比例|大小尺寸"`
Expected: FAIL because current UI has placeholder model button only.
- [ ] **Step 3: Minimal implementation**
Add model option config, dialog fields `imageModel/aspectRatio/imageSize`, localStorage helpers, option buttons/selects, and submit payload wiring.
- [ ] **Step 4: Verify green**
Run same test command. Expected: PASS.
### Task 3: Backend model and dimension normalization
**Files:**
- Modify: `C:\Genarrative\server-rs\crates\platform-image\src\vector_engine\request.rs`
- Modify: `C:\Genarrative\server-rs\crates\api-server\src\editor_project.rs`
- Test: `C:\Genarrative\server-rs\crates\platform-image\tests\vector_engine.rs`
- Test: existing unit tests inside `editor_project.rs`
- [ ] **Step 1: Write failing tests**
Add tests covering:
1. `build_vector_engine_image_request_body_with_model("gemini-3.1-flash-image-preview", ..., "512", ...)` keeps `size = "512"`.
2. editor character model normalization defaults to nanobanana2 and maps `gpt-image-2 + 2:3 + 1K` to `1024x1536`.
3. icon spritesheet model normalization accepts both models.
- [ ] **Step 2: Run backend tests to verify failure**
Run:
`cargo test -p platform-image --manifest-path server-rs/Cargo.toml vector_engine_request_body_can_use_nanobanana2_half_k -- --nocapture`
`cargo test -p api-server --manifest-path server-rs/Cargo.toml editor_project -- --nocapture`
Expected: FAIL until normalization functions exist.
- [ ] **Step 3: Minimal implementation**
Add constants and helpers:
- `EDITOR_IMAGE_MODEL_NANOBANANA2 = "gemini-3.1-flash-image-preview"`
- `EDITOR_IMAGE_MODEL_GPT_IMAGE_2 = "gpt-image-2"`
- `normalize_editor_image_model`
- `normalize_editor_generation_dimensions`
Use with_model calls for character and icon generation responses.
- [ ] **Step 4: Verify green**
Run the same backend tests. Expected: PASS.
### Task 4: Documentation and final checks
**Files:**
- Modify docs listed above.
- [ ] **Step 1: Update docs**
Document defaults, user preference, model-specific options, and Apifox source URLs.
- [ ] **Step 2: Run focused verification**
Run:
`npm run test -- src/services/image-editor/editorProjectClient.test.ts src/components/image-editor/ImageCanvasEditorView.test.tsx`
`cargo test -p platform-image --manifest-path server-rs/Cargo.toml vector_engine_request_body_can_use_nanobanana2_half_k -- --nocapture`
`cargo test -p api-server --manifest-path server-rs/Cargo.toml editor_project -- --nocapture`
`npm run typecheck -- --pretty false`
`npm run check:encoding`
---
## Self-Review
- Spec coverage: covers role generation, icon spritesheet generation, default model, user preference, model-specific dimensions, docs.
- Placeholder scan: no unresolved placeholders.
- Type consistency: frontend uses `model/aspectRatio/imageSize`; backend DTO mirrors camelCase fields.
@@ -0,0 +1,84 @@
# 图片信息生成输入快照 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 图片画布编辑器的图片信息页删除生图 Prompt 字段,改为展示图片生成时的面板输入快照,包括文本字段和参考图。
**Architecture:**`CanvasLayer` 上新增轻量 `generationInputs` 前端快照,只保存用户可见输入和参考图摘要;生成成功时从对应面板状态构建快照,随画布 layout JSON 持久化。图片信息弹窗只读取该快照渲染,不回退显示后端组装 Prompt。
**Tech Stack:** React + TypeScript + Vitest + Testing Library;现有 `UnifiedModal`、平台按钮和图片画布 layout snapshot。
---
### Task 1: 信息弹窗行为测试
**Files:**
- Test: `C:/Genarrative/src/components/image-editor/ImageCanvasEditorView.test.tsx`
- [ ] **Step 1: Write the failing tests**
- 修改已有图片信息测试,断言弹窗不出现 `Prompt``复制Prompt`
- 新增普通生成图片测试:生成后打开信息页,应显示 `生成输入``生成提示词` 和用户输入值。
- 新增角色生成图片测试:绑定角色规范参考图后生成,信息页应显示 `角色设定``角色形象规范` 与参考图名称。
- 新增图标素材生成测试:绑定图标素材规范后生成,信息页应显示 `素材描述`、具体描述和 `图标素材规范` 参考图。
- [ ] **Step 2: Run test to verify it fails**
- Run: `npm run test -- src/components/image-editor/ImageCanvasEditorView.test.tsx`
- Expected: FAIL because `generationInputs` is not yet implemented and old `Prompt` field still exists.
### Task 2: 生成输入快照模型与持久化
**Files:**
- Modify: `C:/Genarrative/src/components/image-editor/ImageCanvasEditorView.tsx`
- [ ] **Step 1: Add minimal types**
- Add `CanvasGenerationInputField`, `CanvasGenerationInputReference`, `CanvasGenerationInputs`.
- Add optional `generationInputs` to `CanvasLayer`.
- [ ] **Step 2: Serialize and hydrate**
- Include `generationInputs` in `serializeLayer`.
- Hydrate only trusted shapes: string `title`, string `value`, string `label`, string `src`.
### Task 3: Build snapshots when creating generated layers
**Files:**
- Modify: `C:/Genarrative/src/components/image-editor/ImageCanvasEditorView.tsx`
- [ ] **Step 1: Add builders**
- `buildGenerationInputsForImagePrompt(prompt)`.
- `buildGenerationInputsForSpec(specType, specValues)`.
- `buildGenerationInputsForCharacter(prompt, specRef, references)`.
- `buildGenerationInputsForIcon(iconDescriptions, iconSpecRef)`.
- `buildGenerationInputsForEdit(prompt, sourceLayer)`.
- [ ] **Step 2: Attach snapshots**
- Pass `generationInputs` into `addGeneratedResultLayer`, `addQuickEditResultLayer`, and `addIconSpritesheetResultLayers`.
- Keep old `prompt/actualPrompt` for backend metadata and backward compatibility, but do not render them in UI.
### Task 4: Render image info without生图 Prompt
**Files:**
- Modify: `C:/Genarrative/src/components/image-editor/ImageCanvasEditorView.tsx`
- Modify: `C:/Genarrative/src/index.css` if existing metadata styles need a reference grid helper.
- [ ] **Step 1: Replace Prompt row**
- Remove `Prompt` dt/dd and `复制Prompt` button.
- Render `生成输入` row.
- [ ] **Step 2: Render fields and references**
- If `generationInputs` has fields, render each field title/value.
- If it has references, render thumbnail cards with title and image.
- If both empty or absent, render `-`.
### Task 5: Documentation and verification
**Files:**
- Modify: `C:/Genarrative/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`
- [ ] **Step 1: Update documentation**
- Add note: image info displays panel input snapshot and references, never assembled generation Prompt.
- [ ] **Step 2: Run verification**
- Run: `npm run test -- src/components/image-editor/ImageCanvasEditorView.test.tsx`
- Run: `npm run typecheck`
- Run: `npm run check:encoding`
- Run: `git diff --check`
@@ -0,0 +1,106 @@
# 图片画布生成对象独立化修复 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [x]`) syntax for tracking.
**Goal:** 修复图片画布底部生成按钮复用单一状态导致后创建对象销毁前一个对象的问题。
**Architecture:** 保留现有图片画布组件结构,先以回归测试锁定“规范占位 + 角色占位可并存”。实现上把生成占位状态从单个 `generateDialog` 扩展为 active dialog + inactive dialog 列表;每次新建生成对象只新增一个 dialog 实例,旧实例保留占位和逻辑状态,只有当前 active 实例渲染编辑面板。
**Tech Stack:** React、TypeScript、Vitest、Testing Library。
---
### Task 1: 补充失败回归测试
**Files:**
- Modify: `C:/Genarrative/src/components/image-editor/ImageCanvasEditorView.test.tsx`
- [x] **Step 1: Write the failing test**
`keeps the bottom AI toolbar visible while generation panels are open` 附近新增测试:
```tsx
it('keeps existing generation placeholders when another bottom generation object is created', () => {
render(<ImageCanvasEditorView />);
const bottomToolbar = screen.getByRole('toolbar', { name: 'AI画布工具栏' });
fireEvent.click(
within(bottomToolbar).getByRole('button', { name: '生成规范' }),
);
fireEvent.click(screen.getByRole('menuitem', { name: '角色形象规范' }));
expect(screen.getByLabelText('规范生成占位图')).toBeTruthy();
expect(screen.getByRole('dialog', { name: '生成规范' })).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '生成角色形象' }));
expect(screen.getByLabelText('规范生成占位图')).toBeTruthy();
expect(screen.getByLabelText('角色生成占位图')).toBeTruthy();
expect(screen.getByRole('dialog', { name: '生成角色形象' })).toBeTruthy();
});
```
- [x] **Step 2: Run test to verify it fails**
Run: `npm run test -- src/components/image-editor/ImageCanvasEditorView.test.tsx -t "keeps existing generation placeholders"`
Expected: FAIL because only the latest placeholder remains.
### Task 2: 实现最小独立生成对象状态
**Files:**
- Modify: `C:/Genarrative/src/components/image-editor/ImageCanvasEditorView.tsx`
- [x] **Step 1: Add stable dialog ids and inactive dialog state**
Add `id: string` to `GenerateDialogState`; add `generationDialogCounterRef`; add `inactiveGenerateDialogs` state. Provide helpers to create ids, archive current active dialog before replacing it, update active/inactive dialogs by id, and list all canvas generation dialogs.
- [x] **Step 2: Update bottom generation openers**
Change `openGenerateDialog``openSpecDialog``openCharacterGenerationDialog``openIconGenerationDialog` so each call archives the current active canvas generation dialog and sets a newly created active dialog. Edit modal remains single active dialog and does not archive.
- [x] **Step 3: Render all placeholders**
Replace the single placeholder render block with a map over inactive dialogs plus active dialog. Only active dialog shows composer; inactive dialogs remain visible and can be clicked to reactivate their own panel.
- [x] **Step 4: Keep actions scoped to active dialog**
Keep submit/update/upload/pick actions operating on active dialog only. Adjust delete, drag, blur, and generated-layer cleanup so they update or remove only the matching active/inactive dialog.
- [x] **Step 5: Keep archived async generation writeback scoped by dialog id**
当一个生成对象已经进入 `generating`,随后用户再创建第二个生成对象并把第一个对象归档为 inactive 时,第一个对象仍可能继续被拖拽或等待异步完成。完成回写必须按 `dialog.id` 从 active + inactive 的最新状态读取占位图,不能使用提交瞬间的旧 `placeholder` 快照。
回归测试:
```bash
npm run test -- src/components/image-editor/ImageCanvasEditorView.test.tsx -t "keeps archived generation logic"
```
### Task 3: 验证并更新文档
**Files:**
- Modify: `C:/Genarrative/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`
- Optional Modify: `C:/Genarrative/.hermes/shared-memory/pitfalls.md`
- [x] **Step 1: Run focused tests**
Run: `npm run test -- src/components/image-editor/ImageCanvasEditorView.test.tsx -t "keeps existing generation placeholders|opens character spec generation form|opens icon asset generation panel|removes the active character generation placeholder"`
Expected: PASS.
- [x] **Step 2: Run full image editor test**
Run: `npm run test -- src/components/image-editor/ImageCanvasEditorView.test.tsx`
Expected: PASS.
- [x] **Step 3: Run encoding check**
Run: `npm run check:encoding`
Expected: PASS.
- [x] **Step 4: Document behavior**
Add one sentence to the image canvas editor technical plan: bottom generation buttons create independent canvas generation objects; creating a new one must not destroy previous placeholders or generated-object logic.
@@ -0,0 +1,128 @@
# 画板角色动画生成 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 在图片画布编辑器中,仅对角色图片提供角色动画生成入口,并通过后端 seedance2.0 链路生成视频、抽帧、去绿幕并持久化到 OSS。
**Architecture:** 前端在 `ImageCanvasEditorView` 中基于图层 `assetKind === "character"` 控制悬浮按钮和右键菜单,打开锚定到图片右侧的独立动画生成面板。前端 service 调用新增编辑器角色动画 API,后端复用 `character_animation_assets.rs` 中现有视频生成、抽帧、绿幕去背、OSS 写入能力,避免新建平行资产系统。
**Tech Stack:** React + TypeScript + VitestRust Axum `api-server`;现有 `shared-contracts` 资产 DTOAliyun OSS 资产持久化;VectorEngine/Ark seedance2.0 角色动画链路。
---
### Task 1: 文档补充
**Files:**
- Modify: `C:/Genarrative/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md`
- [ ] **Step 1: 补充角色动画生成章节**
- 明确仅 `assetKind: "character"` 图层展示入口。
- 明确右侧独立面板字段、预设动作、价格、模型、抽帧和 OSS 存储口径。
- 明确非角色图层不展示按钮。
- [ ] **Step 2: 运行编码检查**
- Run: `npm run check:encoding`
- Expected: PASS 或仅与本任务无关的既有问题。
### Task 2: 前端失败测试
**Files:**
- Modify: `C:/Genarrative/src/components/image-editor/ImageCanvasEditorView.test.tsx`
- Modify: `C:/Genarrative/src/services/image-editor/editorProjectClient.test.ts`
- [ ] **Step 1: 写失败测试**
- 测试角色图层显示悬浮 / 右键 `生成动画`
- 测试非角色图层不显示 `生成动画`
- 测试面板提交请求包含 `sourceLayerId``sourceImageSrc`、prompt、resolution、ratio、frameCount、durationSeconds、priceMudPoints、model。
- [ ] **Step 2: 验证 RED**
- Run: `npm run test -- src/components/image-editor/ImageCanvasEditorView.test.tsx src/services/image-editor/editorProjectClient.test.ts`
- Expected: FAIL,失败原因是功能/API 尚未实现。
### Task 3: 前端实现
**Files:**
- Modify: `C:/Genarrative/src/services/image-editor/editorProjectClient.ts`
- Modify: `C:/Genarrative/src/components/image-editor/ImageCanvasEditorView.tsx`
- Modify: `C:/Genarrative/src/index.css`
- [ ] **Step 1: 新增 service 类型和请求函数**
- `generateEditorCharacterAnimation(input)` 调用 `/api/editor/character-animations/generations`
- 限定 model 固定为 `seedance2.0` 的回包展示字段。
- [ ] **Step 2: 扩展图层 assetKind**
- `CanvasLayer.assetKind` 支持 `'character' | 'spec' | null`
- hydrate / serialize / 图片类型展示跟随扩展。
- [ ] **Step 3: 加入口和面板**
- 角色图层悬浮工具条和右键菜单显示 `生成动画`
- 面板锚定图片右侧,字段按设计实现,文本框 maxLength=4000。
- 价格通过 `resolution * durationSeconds` 计算。
- [ ] **Step 4: 验证 GREEN**
- Run: `npm run test -- src/components/image-editor/ImageCanvasEditorView.test.tsx src/services/image-editor/editorProjectClient.test.ts`
- Expected: PASS。
### Task 4: 后端失败测试
**Files:**
- Modify: `C:/Genarrative/server-rs/crates/shared-contracts/src/assets.rs`
- Modify: `C:/Genarrative/server-rs/crates/api-server/src/character_animation_assets.rs`
- Modify: `C:/Genarrative/server-rs/crates/api-server/src/modules/play_flow.rs` 或现有 editor router 文件(按现有路由事实选择)
- [ ] **Step 1: 写 DTO / prompt / plan 单测**
- 验证请求 480p/720p、32/40/48 帧、比例枚举、模型固定 seedance2.0。
- 验证构造 prompt 包含用户给定固定骨架与动作描述。
- 验证价格计算:480p 每秒 10,720p 每秒 20。
- [ ] **Step 2: 验证 RED**
- Run: `cargo test -p api-server editor_character_animation --manifest-path server-rs/Cargo.toml`
- Expected: FAIL,失败原因是 helper 或 handler 尚未实现。
### Task 5: 后端实现
**Files:**
- Modify: `C:/Genarrative/server-rs/crates/shared-contracts/src/assets.rs`
- Modify: `C:/Genarrative/server-rs/crates/api-server/src/character_animation_assets.rs`
- Modify: `C:/Genarrative/server-rs/crates/api-server/src/modules/play_flow.rs` 或现有 editor router 文件
- [ ] **Step 1: 新增编辑器角色动画 DTO**
- 请求字段:sourceLayerId/sourceImageSrc/promptText/resolution/ratio/frameCount/durationSeconds/sourceWidth/sourceHeight。
- 响应字段:taskId/model/prompt/previewVideoPath/frames/priceMudPoints。
- [ ] **Step 2: 新增 handler**
- 校验 prompt 1..4000、resolution、ratio、frameCount 与 durationSeconds 组合。
- sourceImageSrc 作为首尾帧参考。
- 调用现有 seedance image-to-video 逻辑生成预览视频。
- 调用现有抽帧 + 绿幕去背 + OSS 持久化逻辑输出帧。
- [ ] **Step 3: 路由接入**
- `POST /api/editor/character-animations/generations`
- 保持走 play_flow 创作/游玩支撑主干或 editor 路由现有聚合,不回到 `app.rs` 平行挂载。
- [ ] **Step 4: 验证 GREEN**
- Run: `cargo test -p api-server editor_character_animation --manifest-path server-rs/Cargo.toml`
- Expected: PASS。
### Task 6: 总验证与收口
**Files:**
- All modified files.
- [ ] **Step 1: 定向前端测试**
- Run: `npm run test -- src/components/image-editor/ImageCanvasEditorView.test.tsx src/services/image-editor/editorProjectClient.test.ts`
- Expected: PASS。
- [ ] **Step 2: 定向后端测试 / check**
- Run: `cargo test -p api-server editor_character_animation --manifest-path server-rs/Cargo.toml`
- Run: `cargo check -p api-server --manifest-path server-rs/Cargo.toml`
- Expected: PASS。
- [ ] **Step 3: 类型与编码**
- Run: `npm run typecheck`
- Run: `npm run check:encoding`
- Expected: PASS。
- [ ] **Step 4: 检查 diff**
- Run: `git diff --check`
- Expected: PASS。
@@ -12,13 +12,14 @@
- 编辑器左侧为图片素材栏,可展开 / 收起;移动端优先保持素材栏可折叠。
- 中央画布支持背景拖拽平移、滚轮缩放、缩放百分比菜单、显示所有元素和固定比例缩放。
- 画布左下角提供 Lovart 式状态控件:背景色圆点、素材 / 图层入口、小地图开关;小地图显示图层缩略分布和当前视口框,点击小地图执行显示所有元素。
- 画布中的图片可展示、悬浮显示图片尺寸与边框,点击后在图片上方显示浮动工具栏。
- 画布中的图片可展示、悬浮显示图片 Resolution 尺寸与边框,点击后在图片上方显示浮动工具栏;图片不再维护独立展示 `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 式画布内占位图 + 锚定生成输入框:点击生成工具后先在画布中心创建选中的灰色占位框,输入框跟随占位框显示;提交成功后真实生成图落在占位框位置,输入框继续跟随新生成图;基于已有生成图的修改仍通过轻量弹窗承载。前端不持有 provider 密钥;上游失败或配置缺失时只在当前生成输入框展示失败,不创建 mock 成功图。
- 图片生成 / 修改统一经 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 成功图。
- 底部生成类按钮每次点击都必须创建独立的画布生成对象;新建规范、角色形象或图标素材时,只切换当前编辑面板,不得销毁此前尚未生成或已生成后的其它生成对象状态。归档为非当前编辑对象的生成占位仍可拖动、删除和等待异步完成,完成 / 失败回写必须按生成对象 ID 读取最新占位状态,不能使用提交瞬间的旧快照。
## 交互规则
@@ -40,7 +41,7 @@
- `editor_project_resource` 表保存工程画布引用过的资源快照:`resourceId``projectId``ownerUserId`、OSS / asset object 引用、图片尺寸、来源类型、prompt、actualPrompt、model、provider、taskId、sourceResourceId、创建时间和更新时间。上传素材被拖入画布时会复制为 project resource,图层只引用 resourceId。
- 图片文件本体继续走 OSS,浏览器读取私有 generated 对象仍经 `/api/assets/read-url` 换签。
- 当前 MVP 的本地上传先以 data URL 持久化在素材记录中,保证刷新和跨项目可见;后续接入正式 OSS 上传时,只替换 `imageSrc/objectKey/assetObjectId` 的写入方式,账号级素材表和画布资源表不变。
- 资源表只保存资源元数据;图层位置、尺寸、缩放、层级、分组选中所需 ID 和 groupId 保存在 `editor_canvas` 的布局 JSON。图层组第一版是画布内布局语义,不单独建表。
- 资源表只保存资源元数据;图层位置、层级、分组选中所需 ID 和 groupId 保存在 `editor_canvas` 的布局 JSON。图层展示尺寸不再作为独立 `Size` 真相保存,刷新与新建图层均按 `Resolution``originalWidth/originalHeight`)原分辨率显示。图层组第一版是画布内布局语义,不单独建表。
- 前端不直接订阅 SpacetimeDB,统一通过 api-server 的 `/api/editor/projects*` BFF 读写。
- 未登录用户可以使用本地演示态,但不触发工程自动保存;真实图片生成 / 修改需要登录。编辑器 API 请求允许使用 refresh cookie 静默补 access token,但 401 / 403 只在编辑器局部提示登录,不清空整站登录态,也不把后端 requestId 直接作为生图弹窗主文案。
@@ -61,8 +62,8 @@
- `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、尺寸、prompt、model、provider 和 taskId。
- `POST /api/editor/images/edits`:按提示词和当前生成图 Data URL 调用 VectorEngine edits,返回新的生成图片元数据。
- `POST /api/editor/images/generations`:按提示词调用 VectorEngine `gpt-image-2` 生成图片;携带参考图的快速编辑也走该接口,前端必须把参考图源预读成图片 Data URL 后放入 `referenceImageSrcs`;接口返回 data URL、尺寸、prompt、model、provider 和 taskId。
- `POST /api/editor/images/edits`:按提示词和当前图 Data URL 调用 VectorEngine edits,返回新的生成图片元数据。
所有写接口都必须校验 Bearer 登录态和 owner;接口只返回当前用户有权读取的工程与资源。
@@ -82,8 +83,11 @@
- 默认选择模式;底部工具栏能切换工具;中键拖拽和 Space 临时抓手都能平移画布。
- 拖拽图片接近其它图片边缘或中心时显示吸附线,并保存吸附后的最终布局。
- 生成工具点击后显示画布内 `Image Generator` 占位框和跟随占位框的生成输入框,生成失败保留占位和输入状态,生成成功后在占位位置创建真实图层,并让输入框继续跟随该生成图。
- 生成资源显示元数据按钮,元数据窗口展示来源、prompt、model、provider、task、尺寸和 OSS 引用
- 生成类入口打开画布内面板时,底部 AI 工具栏必须保持可见;`生成规范`、角色 / 图标规范来源这类轻量菜单通过页面级 fixed portal 渲染,不能留在底部工具栏或参考图横向滚动容器内部,避免被局部 `overflow` 裁切
- 点击生成、生成规范、生成角色形象或生成图标素材后创建的占位图可继续保留;点击画布空白区域让当前图片或占位图失焦时,关闭当前生成面板并移除图片选中样式,但不删除占位图本身。
- 生成资源显示元数据按钮,元数据窗口展示来源、生成输入快照、model、provider、task、Resolution 和 OSS 引用;生成输入快照只包含用户面板输入和参考图,不包含后端拼接 Prompt,不再展示独立 Size 字段。
- 修改生成资源后,右侧出现新生成结果图层,并自动 fit 原图 + 新图。
- 快速编辑站内 public 示例图、历史 generated 图或 OSS generated 图时,前端先读取成 `data:image/*;base64,...` 再提交,后端不得再收到 `/creation-type-references/*``/generated-*` 或 OSS URL 作为 `referenceImageSrcs/sourceImageSrc`
- 素材文件夹可以新建、折叠、重命名和删除;删除普通文件夹后,其素材移动到“项目素材”。
- 上传按钮和拖拽上传都支持多文件;拖到文件夹或该文件夹内素材时进入目标文件夹;拖到画布时进入默认文件夹并在投放点创建画布图层。
- 素材面板支持选择模式框选,一次选中多个素材,并可批量移动或删除上传素材。
@@ -49,6 +49,8 @@ Linux 本机多用户并发开发时,`npm run dev` 和 `npm run dev:*` 单模
后端日志默认写入 `logs/api-server/`。后端 API smoke 使用 `npm run dev:api-server` 并检查 `/healthz`;需要确认实例可接生产流量时检查 `/readyz`。不要使用旧 `api-server:maincloud` 或任何 `GENARRATIVE_SPACETIME_MAINCLOUD_*` 口径。
Windows 本地 `npm run dev` / `npm run dev:api-server` 会用空的 `RUSTC_WRAPPER` / `CARGO_BUILD_RUSTC_WRAPPER` 覆盖 `server-rs/.cargo/config.toml` 里的 `sccache`,从而直连真实 `rustc`。不要把 wrapper 绕过值写成 `rustc`Cargo 会按 wrapper 协议调用 `rustc <真实rustc路径> - ...`,最终报 `multiple input filenames provided` 并导致 api-server 无法启动。排查本地启动失败时,先看 dev 日志是否出现该错误,再确认脚本注入的 wrapper 为空。
开发态 `npm run dev``npm run dev:api-server` 会默认注入 `GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED=true`,因此密码登录在本地开发环境可直接注册未知手机号账号;生产环境仍按 `api-server` 配置默认关闭该开关。
本地 `npm run dev``npm run dev:api-server` 默认保留 inline 开发体验:未显式设置 `GENARRATIVE_EXTERNAL_GENERATION_MODE=queue` 时,外部生成 handler 会同步复用 worker executor,完成后返回 `completed`,便于快速确认 provider、OSS 和 SpacetimeDB 写回链路。inline 不创建 `external_generation_job`,也不能验证 worker lease、队列等待展示或动态扩缩容。
@@ -0,0 +1,77 @@
# 画板图标素材生成入口设计
日期:`2026-06-15`
## 背景
图片画布编辑器已有普通图片生成、生成规范、生成角色形象和角色动画入口。本次新增 `生成图标素材`,用于一次输入多条图标素材描述,先生成一张绿幕 spritesheet,再自动去背、按连通域拆分为独立图标素材并铺到画布。
## 入口与画布表现
- 底部 AI 画布工具栏新增 `生成图标素材` 按钮。
- 点击后立即在画布中心创建图标素材占位图,不复用普通“单张空白图片”图标;占位图表现为一叠空白素材图标卡片。
- 图标素材面板锚定在占位图下方,和现有生成输入框同一层级展示。
- 生成完成后删除占位态,把拆分出的每个独立图标素材作为画布图片图层铺开,图标之间不重叠,并保留少量间距。
- 图标素材图层写入 `assetKind: "icon"`;图标素材规范图写入 `assetKind: "icon-spec"`,用于刷新后保留标签和限制点选来源。
## 面板结构
1. 第一模块为 `图标素材规范`
- 点击后弹出菜单:`从画布中选择``新建图标素材规范``上传图片`
- `从画布中选择` 进入画布点选状态,只允许选择 `assetKind: "icon-spec"` 的图标素材规范图片;其它图片点击无效。
- `新建图标素材规范` 复用生成规范表单,规格类型为 `图标素材规范`,生成成功后图层标记为 `icon-spec`
- `上传图片` 使用现有本地图片上传入口,上传图只绑定到本次面板,不自动放入画布。
2. 第二模块为素材描述列表。
- 每个文本框输入一个素材描述。
- 默认填入:`返回按钮``设置按钮``下一关按钮``提示按钮``原图按钮``冻结按钮`
- 可以继续添加新的素材描述框,最多 `100` 个。
- 生成时过滤空文本,按面板从上到下顺序作为 prompt 的素材清单。
## 面板外观
- 图标素材面板不使用内部纵向滑动列表;素材描述项按横向卡片铺开,新增一项就让面板整体更宽,保持列表一眼可扫。
- 图标素材规范入口采用 Lovart 式参考卡:左侧预览缩略图,中间显示当前绑定名称,右侧显示绑定状态和三个轻量动作入口,不再只是两行文字平铺。
- 规范卡的 `从画布中选择 / 新建图标素材规范 / 上传图片` 继续保留独立菜单,但菜单只负责来源切换,不承载说明文案。
## 生成契约
- 前端提交到 `POST /api/editor/icon-spritesheets/generations`
- 请求字段:
- `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`
- Prompt 固定为:
```text
参考图1的图标素材规范,纯绿幕背景方便扣除背景,禁止出现文字,保证每个图标素材的所有内容区域是完全连通的。按照以下的素材的顺序从上到下从左到右依次生成并整理成一张spritesheet
<素材描述按中文顿号拼接>
```
## 去背与拆分
- 后端收到 spritesheet 后先复用 `platform-image::generated_asset_sheets` 的绿幕去背能力。
- 去背后基于 alpha 可见像素执行 8 邻域连通域检测。
- 连通域按从上到下、从左到右排序;排序结果依次绑定面板中的素材描述名称。
- 每个连通域裁剪时保留少量透明边距,并输出独立透明 PNG Data URL。
- 若模型返回的连通域数量少于素材描述数量,接口返回失败,不在画布上铺半成品,避免名称和图标错位。
## 前端铺放规则
- 第一张图标放在原占位图左上附近。
- 后续图标按行铺开,图标之间保留约 `24px` 世界坐标间距。
- 每个图标图层标题使用对应素材描述文本。
- 生成成功后关闭图标素材面板,选中第一张图标素材,并打开图层面板。
## 验收
- 点击 `生成图标素材` 后出现一叠空白图标占位和图标素材面板。
- `图标素材规范 -> 从画布中选择` 只能选择图标素材规范图,点击普通图片或角色规范图不会绑定。
- 默认 6 个素材描述会进入 prompt;新增描述最多到 100 个。
- `<=25` 个描述提交时后端请求尺寸为 `512x512``>25` 个描述提交时后端请求尺寸为 `1024x1024`
- VectorEngine 请求体的 `model``gemini-3.1-flash-image-preview`
- 生成成功后画布出现按描述命名的多个透明图标素材图层,图层之间不重叠。
@@ -0,0 +1,124 @@
# 画板角色形象生成入口设计
日期:`2026-06-15`
## 背景
图片画布编辑器已有普通图片生成与“生成规范”能力。本次新增“生成角色形象”入口,用于在同一画布内生成标注为“角色”的单张角色形象图片,并支持绑定角色形象规范与常规参考图。
## 入口与画布表现
- 底部 AI 画布工具栏新增 `生成角色形象` 按钮。
- 点击后在画布中心创建一张空白图片占位图,面板锚定在占位图下方,视觉风格复用现有生成新图片面板;底部 AI 工具栏继续保持可见,不因角色面板打开而隐藏。
- 占位图与生成成功后的图片右上角都覆盖 `角色` 标签。
- 角色图层在布局快照中写入 `assetKind: "character"`,刷新后仍显示 `角色` 标签。
## 面板结构
角色生成面板只包含创作必需输入;每个输入框、参考图入口和选项按钮都必须展示对应中文字段标题,不只依赖 placeholder、按钮文案或 aria-label
1. 第一项参考图入口为 `角色形象规范`
- 入口必须采用 Lovart 式参考图卡片:左侧小预览 / 图标,中间短标题,右侧仅保留必要状态,不把说明性规则文案铺在 UI 上。
- 点击后弹出菜单:`从画布中选择``新建角色形象规范``上传图片`
- 来源菜单通过页面级 fixed portal 渲染,层级高于角色面板与参考图横向滚动区,不能被 `.image-canvas-editor__character-reference-row` 裁切。
- `从画布中选择` 进入画布点选状态,点击已有图片后把该图绑定为角色形象规范;按 `Esc` 退出点选状态。
- `新建角色形象规范` 复用当前 `生成规范 -> 角色形象规范` 流程。
- `上传图片` 使用现有本地图片上传入口。
2. 规范入口后方是常规参考图入口。
- `上传常规参考图` 同样使用 Lovart 式参考图卡片,不只显示一段文字按钮。
- 上传后的每张常规参考图以缩略图展示。
- 每张常规参考图右下角显示大号序号,从 `1` 开始递增。
3. 唯一文本框为 `角色设定`
4. 左下角展示画面比例和大小选择按钮。
5. 右下角展示模型选择和生成按钮。
## 生成与参考图契约
- 前端提交角色生成时,使用 `POST /api/editor/images/generations`
- `kind``character`,用于后端日志 / 审计语义识别。
- 角色形象规范与常规参考图作为 `referenceImageSrcs` 传入,顺序固定为:
1. 角色形象规范图。
2. 常规参考图列表。
- 当前请求尺寸沿用编辑器普通生成默认值;比例和大小按钮先复用现有占位交互。
- 后端如果收到参考图,则走带多参考图的图片编辑/参考图生成链路;没有参考图时走纯文本生成链路。
- `kind = "character"` 时,后端不直接把前端文本当完整生图提示词,而是把文本作为 `角色设定` 填入固定提示词骨架:
```text
基于图1的角色美术视觉规范指导生成游戏角色形象图。画面中心构图,角色主体完整置于画面中央,禁止镜头透视,禁止特写。背景固定为纯绿色绿幕,只作为抠像底色,禁止生成美术视觉规范、出现建筑、室内布景、风景、地面道具、漂浮物、烟雾叙事元素、文字或其他角色以外的场景内容。
角色设定:<用户输入的角色设定>
```
- 角色图生成完成后,后端必须先对返回图片执行绿幕 / 近白背景去背,并统一输出透明背景 PNG;随后写入 OSS 私有对象,并确认 `asset_object`。接口回包仍返回透明 PNG Data URL 供画板立即显示,同时返回 `objectKey` / `assetObjectId`,前端创建图层和画板资源记录时必须保存这两个字段。
## 可访问性与状态
- 点选状态下画布显示状态提示 `请选择画布中的图片作为角色形象规范,按 Esc 退出`
- 已绑定的角色形象规范入口显示所选图片标题。
- 生成中禁用参考图入口、文本框和按钮。
- 点击画布空白区域让当前占位图或图片失焦时,关闭角色生成面板并移除图片选中样式;占位图本身保持可重新打开。删除图层逻辑沿用现有图层删除能力。
## 验收
- 点击 `生成角色形象` 后出现角色占位图、角色标签和角色生成面板。
- 角色生成面板打开时底部 AI 工具栏仍可见;点击画布空白区域后面板关闭,当前图片不再显示选中边框。
- `角色形象规范``上传常规参考图` 入口是带预览视觉块的参考图卡片,不是无样式文字。
- `从画布中选择` 后点击已有画布图片可绑定为角色形象规范,`Esc` 可退出点选状态。
- 上传常规参考图后缩略图右下角显示序号。
- 输入角色设定并生成时,请求包含 `kind: "character"`、角色设定 prompt 和参考图数组。
- 生成成功后在占位图位置创建 `assetKind: "character"` 图层,右上角显示 `角色` 标签,布局保存包含该字段。
## 当前落地记录
- 前端画板已接入 `生成角色形象` 底部入口、角色占位图、角色面板、画布点选规范图、上传规范图、上传常规参考图和序号角标。
- 角色生成提交统一走 `/api/editor/images/generations`,按 `角色形象规范 -> 常规参考图` 顺序传 `referenceImageSrcs`,并写入 `assetKind: "character"`
- 角色生成后端已按固定 prompt 骨架补入 `角色设定`,并在生成成功后自动执行绿幕去背、写入 `generated-character-drafts/editor/character-images/<taskId>/image.png` 路径下的 OSS 私有对象,返回的 `objectKey` / `assetObjectId` 会随画板资源记录保存。
- `Esc` 只退出角色规范画布点选状态,不关闭角色生成面板。
- 已补充回归测试覆盖角色形象生成、点选退出、角色动画入口隔离和快速编辑入口。
- 本次验证命令:
- `npm test -- src/components/image-editor/ImageCanvasEditorView.test.tsx`
- `npm test -- src/services/image-editor/editorProjectClient.test.ts`
- `npm run typecheck -- --pretty false`
## 角色动画生成
### 入口规则
-`assetKind: "character"` 的角色图片图层显示 `生成动画` 入口。
- 入口同时出现在图片上方悬浮工具条和图片右键功能列表中。
- 非角色图片不得显示 `生成动画`,也不得通过右键菜单触发角色动画生成。
- 点击 `生成动画` 后,在角色图片右侧打开独立的 `角色动画生成面板`;面板不在当前图片下方展开。
### 面板字段
1. 第一模块为 `动画描述` 文本框,最多输入 `4000` 字。
2. 文本框下方提供预设动作提示词按钮:`待机``行走``奔跑``跳跃``攻击``受击``倒下`。点击后把对应动作文本写入动画描述文本框。
3. 其它设置包括:
- 分辨率:`480p``720p`
- 画面比例:默认 `与角色图片保持同尺寸`,可选 `1:1``4:3``16:9``9:16``3:4`
- 时长:`32帧·4秒``40帧·5秒``48帧·6秒`
4. 模型固定使用 `seedance2.0`,前端不提供模型切换。
5. 生成按钮上方显示本次生成文本摘要和生成价格:
- `480p` 每秒 `10` 泥点。
- `720p` 每秒 `20` 泥点。
### Prompt 与生成契约
- 前端提交到 `POST /api/editor/character-animations/generations`
- 请求必须带上角色图片来源、原始尺寸、动画描述、分辨率、画面比例、帧数和时长。角色图片已经持久化到 OSS 时,`sourceImageSrc` 必须优先传 `objectKey`;只有未持久化的本地临时图片才允许传 Data URL。
- 后端使用角色图片作为首帧和尾帧参考,模型固定映射到 seedance2.0 对应后端模型。
- 后端路由兼容旧 Data URL 请求并单独放宽 JSON body limit 到 `12MB`,但该限额只作为兼容兜底,不作为新链路默认传大图的方式。
- 后端 prompt 使用以下固定骨架,并把面板输入追加到 `动作描述:` 后:
```text
生成游戏角色动画,参考图作为首帧和尾帧,画面中心构图,角色主体完整置于画面中央,禁止镜头透视,禁止特写。背景固定为纯绿色绿幕,只作为抠像底色,禁止出现建筑、室内布景、风景、地面道具、漂浮物、烟雾叙事元素、文字或其他角色以外的场景内容。
动作描述:
<用户输入的动画描述>
```
### 抽帧与 OSS 存储
- 视频生成完成后,后端按面板选择抽取对应帧数:`32``40``48`
- 每帧必须执行绿幕去背,输出透明背景 PNG。
- 抽帧结果写入 OSS,并返回帧路径、帧尺寸、帧数、fps、预览视频路径、模型、价格和实际 prompt。
- 画板前端首版只展示生成完成结果摘要,不把帧序列自动铺到画布上;后续若要展示逐帧图层,必须继续复用画布图层与素材库资源模型。
+7 -3
View File
@@ -37,7 +37,10 @@ const manifestPath = resolve(serverRsDir, 'Cargo.toml');
const modulePath = resolve(serverRsDir, 'crates/spacetime-module');
const viteCliPath = resolve(repoRoot, 'scripts/vite-cli.mjs');
const adminWebDir = resolve(repoRoot, 'apps/admin-web');
const LOCAL_DEV_RUSTC_WRAPPER_BYPASS = process.platform === 'win32' ? 'rustc' : '/usr/bin/env';
function resolveLocalDevRustcWrapperBypass() {
// Windows 下不能把 rustc 自身当成 Cargo wrapper;空值会覆盖仓库 .cargo/config.toml 中的 sccache。
return process.platform === 'win32' ? '' : '/usr/bin/env';
}
const SERVICE_NAMES = ['spacetime', 'api-server', 'web', 'admin-web'];
const SERVICE_ALIASES = new Map([
@@ -423,8 +426,9 @@ function buildLocalRustProcessEnv(env, options = {}) {
return mergedEnv;
}
mergedEnv.RUSTC_WRAPPER = LOCAL_DEV_RUSTC_WRAPPER_BYPASS;
mergedEnv.CARGO_BUILD_RUSTC_WRAPPER = LOCAL_DEV_RUSTC_WRAPPER_BYPASS;
const rustcWrapperBypass = resolveLocalDevRustcWrapperBypass();
mergedEnv.RUSTC_WRAPPER = rustcWrapperBypass;
mergedEnv.CARGO_BUILD_RUSTC_WRAPPER = rustcWrapperBypass;
if (options.log !== false) {
console.warn(
'[dev:rust] 本地 dev 构建绕过项目 sccache wrapper,避免缓存进程异常阻断启动。',
+25
View File
@@ -213,6 +213,31 @@ describe('dev scheduler Rust build env', () => {
expect(env.RUSTC_WRAPPER).toBe('custom-wrapper');
expect(env.CARGO_BUILD_RUSTC_WRAPPER).toBe('custom-wrapper');
});
test('Windows 下本地 dev Rust env 用空 wrapper 覆盖项目 sccache', () => {
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform');
Object.defineProperty(process, 'platform', {
configurable: true,
value: 'win32',
});
try {
const env = buildLocalRustProcessEnv(
{
RUSTC_WRAPPER: 'sccache',
CARGO_BUILD_RUSTC_WRAPPER: 'sccache',
},
{log: false},
);
expect(env.RUSTC_WRAPPER).toBe('');
expect(env.CARGO_BUILD_RUSTC_WRAPPER).toBe('');
} finally {
if (originalPlatform) {
Object.defineProperty(process, 'platform', originalPlatform);
}
}
});
});
describe('dev scheduler stack state file', () => {
+47
View File
@@ -1469,6 +1469,53 @@ mod tests {
assert!(!body_text.contains("length limit exceeded"));
}
#[tokio::test]
async fn editor_character_animation_accepts_character_image_body_above_default_limit() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
let source_image_src = format!("data:image/png;base64,{}", "A".repeat(3 * 1024 * 1024));
let request_body = serde_json::json!({
"sourceLayerId": "layer-character-large",
"sourceImageSrc": source_image_src,
"sourceWidth": 1024,
"sourceHeight": 1024,
"promptText": "待机呼吸循环。",
"resolution": "480p",
"ratio": "same",
"frameCount": 32,
"durationSeconds": 4,
"priceMudPoints": 40,
"model": "seedance2.0"
})
.to_string();
assert!(request_body.len() > 2 * 1024 * 1024);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/editor/character-animations/generations")
.header("content-type", "application/json")
.body(Body::from(request_body))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
let body = response
.into_body()
.collect()
.await
.expect("response body should collect")
.to_bytes();
let body_text = String::from_utf8_lossy(&body);
assert!(
body_text.contains("ARK_CHARACTER_VIDEO_BASE_URL"),
"handler should parse the oversized character source image before checking Ark config: {body_text}"
);
assert!(!body_text.contains("length limit exceeded"));
}
#[tokio::test]
async fn password_entry_rejects_unknown_phone_without_registration() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -744,6 +744,7 @@ mod tests {
started_at: Some("2026-06-03T00:00:00Z".to_string()),
completed_at: None,
updated_at: "2026-06-03T00:00:00Z".to_string(),
updated_at_micros: 1_780_000_000_000_000,
lease_token: lease_token.map(ToOwned::to_owned),
}
}
@@ -9,9 +9,9 @@ use crate::{
create_editor_asset, create_editor_asset_folder, create_editor_project,
create_editor_project_resource, delete_editor_asset, delete_editor_asset_folder,
delete_editor_project, edit_editor_image, generate_editor_image,
get_editor_asset_library, get_editor_project, list_editor_projects,
load_recent_editor_project, rename_editor_project, save_editor_project_layout,
update_editor_asset, update_editor_asset_folder,
generate_editor_icon_spritesheet, get_editor_asset_library, get_editor_project,
list_editor_projects, load_recent_editor_project, rename_editor_project,
save_editor_project_layout, update_editor_asset, update_editor_asset_folder,
},
state::AppState,
};
@@ -111,4 +111,11 @@ pub fn router(state: AppState) -> Router<AppState> {
require_bearer_auth,
)),
)
.route(
"/api/editor/icon-spritesheets/generations",
post(generate_editor_icon_spritesheet).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
}
@@ -16,8 +16,9 @@ use crate::{
assets::get_asset_history,
auth::require_bearer_auth,
character_animation_assets::{
generate_character_animation, get_character_animation_job, get_character_workflow_cache,
import_character_animation_video, list_character_animation_templates,
generate_character_animation, generate_editor_character_animation,
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,
save_character_workflow_cache,
},
@@ -48,6 +49,7 @@ use crate::{
};
const HYPER3D_IMAGE_TO_MODEL_BODY_LIMIT_BYTES: usize = 56 * 1024 * 1024;
const EDITOR_CHARACTER_ANIMATION_BODY_LIMIT_BYTES: usize = 12 * 1024 * 1024;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct PlayFlowDomainAdapter {
@@ -452,6 +454,13 @@ fn play_flow_support_router(state: AppState) -> Router<AppState> {
"/api/assets/character-animation/generate",
post(generate_character_animation),
)
.route(
"/api/editor/character-animations/generations",
post(generate_editor_character_animation).layer(DefaultBodyLimit::max(
// 中文注释:画板角色动画首版仍兼容角色图 Data URL 入参,避免大于 Axum 默认 2MB 的角色图在 handler 前被拦截。
EDITOR_CHARACTER_ANIMATION_BODY_LIMIT_BYTES,
)),
)
.route(
"/api/assets/character-animation/jobs/{task_id}",
get(get_character_animation_job),
@@ -3,7 +3,7 @@ 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_generation,
create_vector_engine_image_edit_with_references_and_model, create_vector_engine_image_generation,
};
#[cfg(test)]
use platform_image::{
@@ -236,6 +236,49 @@ pub(crate) async fn create_openai_image_edit_with_references(
.await
}
#[allow(clippy::too_many_arguments)]
pub(crate) async fn create_openai_image_edit_with_references_and_model(
http_client: &reqwest::Client,
settings: &OpenAiImageSettings,
model: &str,
prompt: &str,
negative_prompt: Option<&str>,
size: &str,
candidate_count: u32,
reference_images: &[OpenAiReferenceImage],
failure_context: &str,
) -> Result<OpenAiGeneratedImages, AppError> {
let started_at_micros = current_utc_micros();
let request_payload = json!({
"model": model,
"size": size,
"promptChars": prompt.chars().count(),
"negativePromptChars": negative_prompt.map(str::chars).map(Iterator::count),
"referenceImageCount": reference_images.len(),
});
let result = create_vector_engine_image_edit_with_references_and_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_edit_with_references",
failure_context,
request_payload,
started_at_micros,
)
.await
}
#[cfg(test)]
pub(crate) fn build_openai_image_request_body(
prompt: &str,
@@ -16,7 +16,9 @@ pub use persist::{
};
pub use prompt::{GeneratedAssetSheetPromptInput, build_generated_asset_sheet_prompt};
pub use sheet::{
GeneratedAssetSheetSliceImage, crop_generated_asset_sheet_view_edge_matte,
GeneratedAssetSheetConnectedIcon, GeneratedAssetSheetSliceImage,
crop_generated_asset_sheet_view_edge_matte,
crop_generated_asset_sheet_view_edge_matte_with_options, slice_generated_asset_sheet,
slice_generated_asset_sheet_two_items_per_row,
slice_generated_icon_spritesheet_by_connected_components,
};
@@ -132,6 +132,25 @@ pub fn slice_generated_asset_sheet_two_items_per_row(
Ok(slices)
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct GeneratedAssetSheetConnectedIcon {
pub name: String,
pub bytes: Vec<u8>,
pub width: u32,
pub height: u32,
}
pub fn slice_generated_icon_spritesheet_by_connected_components(
image: &crate::DownloadedImage,
icon_names: &[String],
) -> Result<Vec<GeneratedAssetSheetConnectedIcon>, GeneratedAssetSheetError> {
let source = image::load_from_memory(image.bytes.as_slice()).map_err(|error| {
GeneratedAssetSheetError::decode_image(format!("图标 spritesheet 解码失败:{error}"))
})?;
let source = apply_generated_asset_sheet_green_screen_alpha(source);
slice_generated_icon_spritesheet_rgba_by_connected_components(source, icon_names)
}
pub fn crop_generated_asset_sheet_view_edge_matte(
image: image::DynamicImage,
) -> image::DynamicImage {
@@ -141,6 +160,207 @@ pub fn crop_generated_asset_sheet_view_edge_matte(
)
}
fn slice_generated_icon_spritesheet_rgba_by_connected_components(
source: image::DynamicImage,
icon_names: &[String],
) -> Result<Vec<GeneratedAssetSheetConnectedIcon>, GeneratedAssetSheetError> {
let image = source.to_rgba8();
let (width, height) = image.dimensions();
let pixel_count = (width as usize).saturating_mul(height as usize);
if pixel_count == 0 {
return Err(GeneratedAssetSheetError::invalid_request(
"图标 spritesheet 尺寸为空。",
));
}
let mut visited = vec![false; pixel_count];
let mut components = Vec::<GeneratedAssetSheetCellBounds>::new();
for y in 0..height {
for x in 0..width {
let pixel_index = (y as usize)
.saturating_mul(width as usize)
.saturating_add(x as usize);
if visited[pixel_index] || image.get_pixel(x, y).0[3] == 0 {
continue;
}
components.push(flood_fill_generated_icon_component(
&image,
&mut visited,
width,
height,
x,
y,
));
}
}
components.sort_by_key(|bounds| (bounds.y0, bounds.x0));
if components.len() < icon_names.len() {
return Err(GeneratedAssetSheetError::invalid_request(format!(
"图标 spritesheet 连通域数量不足:需要 {} 个,实际 {} 个。",
icon_names.len(),
components.len()
)));
}
let mut icons = Vec::with_capacity(icon_names.len());
for (name, bounds) in icon_names.iter().zip(components.into_iter()) {
let pad_x = (bounds.width() / 12).clamp(4, 16);
let pad_y = (bounds.height() / 12).clamp(4, 16);
let crop = GeneratedAssetSheetCellBounds {
x0: bounds.x0.saturating_sub(pad_x),
y0: bounds.y0.saturating_sub(pad_y),
x1: bounds.x1.saturating_add(pad_x).min(width),
y1: bounds.y1.saturating_add(pad_y).min(height),
};
let cropped = image::imageops::crop_imm(
&image,
crop.x0,
crop.y0,
crop.width(),
crop.height(),
)
.to_image();
let mut cursor = std::io::Cursor::new(Vec::new());
image::DynamicImage::ImageRgba8(cropped)
.write_to(&mut cursor, ImageFormat::Png)
.map_err(|error| {
GeneratedAssetSheetError::encode_image(format!(
"图标 spritesheet 切割失败:{error}"
))
})?;
icons.push(GeneratedAssetSheetConnectedIcon {
name: name.clone(),
bytes: cursor.into_inner(),
width: crop.width(),
height: crop.height(),
});
}
Ok(icons)
}
fn flood_fill_generated_icon_component(
image: &image::RgbaImage,
visited: &mut [bool],
width: u32,
height: u32,
start_x: u32,
start_y: u32,
) -> GeneratedAssetSheetCellBounds {
let mut queue = vec![(start_x, start_y)];
let mut queue_index = 0usize;
let start_index = (start_y as usize)
.saturating_mul(width as usize)
.saturating_add(start_x as usize);
visited[start_index] = true;
let mut bounds = GeneratedAssetSheetCellBounds {
x0: start_x,
y0: start_y,
x1: start_x.saturating_add(1),
y1: start_y.saturating_add(1),
};
while queue_index < queue.len() {
let (x, y) = queue[queue_index];
queue_index += 1;
bounds.x0 = bounds.x0.min(x);
bounds.y0 = bounds.y0.min(y);
bounds.x1 = bounds.x1.max(x.saturating_add(1));
bounds.y1 = bounds.y1.max(y.saturating_add(1));
for next_y in y.saturating_sub(1)..=(y.saturating_add(1).min(height.saturating_sub(1))) {
for next_x in x.saturating_sub(1)..=(x.saturating_add(1).min(width.saturating_sub(1))) {
if next_x == x && next_y == y {
continue;
}
let next_index = (next_y as usize)
.saturating_mul(width as usize)
.saturating_add(next_x as usize);
if visited[next_index] || image.get_pixel(next_x, next_y).0[3] == 0 {
continue;
}
visited[next_index] = true;
queue.push((next_x, next_y));
}
}
}
bounds
}
#[cfg(test)]
mod tests {
use super::*;
use image::{ImageBuffer, Rgba};
fn encode_png(image: image::RgbaImage) -> Vec<u8> {
let mut cursor = std::io::Cursor::new(Vec::new());
image::DynamicImage::ImageRgba8(image)
.write_to(&mut cursor, ImageFormat::Png)
.expect("png should encode");
cursor.into_inner()
}
#[test]
fn slices_icon_spritesheet_by_connected_components_in_reading_order() {
let mut sheet: image::RgbaImage =
ImageBuffer::from_pixel(96, 64, Rgba([0, 255, 0, 255]));
for y in 10..24 {
for x in 12..28 {
sheet.put_pixel(x, y, Rgba([240, 80, 80, 255]));
}
}
for y in 32..46 {
for x in 52..70 {
sheet.put_pixel(x, y, Rgba([80, 120, 240, 255]));
}
}
let source = crate::DownloadedImage {
bytes: encode_png(sheet),
mime_type: "image/png".to_string(),
extension: "png".to_string(),
};
let icons = slice_generated_icon_spritesheet_by_connected_components(
&source,
&["返回按钮".to_string(), "设置按钮".to_string()],
)
.expect("icons should slice");
assert_eq!(icons.len(), 2);
assert_eq!(icons[0].name, "返回按钮");
assert_eq!(icons[1].name, "设置按钮");
assert!(icons[0].width >= 16);
assert!(icons[0].height >= 14);
assert!(image::load_from_memory(icons[0].bytes.as_slice()).is_ok());
}
#[test]
fn rejects_when_connected_components_are_fewer_than_icon_names() {
let mut sheet: image::RgbaImage =
ImageBuffer::from_pixel(48, 48, Rgba([0, 255, 0, 255]));
for y in 12..24 {
for x in 12..24 {
sheet.put_pixel(x, y, Rgba([240, 80, 80, 255]));
}
}
let source = crate::DownloadedImage {
bytes: encode_png(sheet),
mime_type: "image/png".to_string(),
extension: "png".to_string(),
};
let error = slice_generated_icon_spritesheet_by_connected_components(
&source,
&["返回按钮".to_string(), "设置按钮".to_string()],
)
.expect_err("missing component should fail");
assert!(error.to_string().contains("连通域数量不足"));
}
}
pub fn crop_generated_asset_sheet_view_edge_matte_with_options(
image: image::DynamicImage,
options: GeneratedAssetSheetAlphaOptions,
+3 -2
View File
@@ -8,6 +8,7 @@ pub use vector_engine::{
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,
create_vector_engine_image_generation, download_remote_image, vector_engine_images_edit_url,
vector_engine_images_generation_url,
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,
};
@@ -13,8 +13,10 @@ use super::{
error::PlatformImageError,
image_source::resolve_reference_images,
request::{
build_vector_engine_image_edit_request_log_params, build_vector_engine_image_request_body,
normalize_image_size, vector_engine_images_edit_url, vector_engine_images_generation_url,
build_vector_engine_image_edit_request_log_params,
build_vector_engine_image_request_body_with_model, normalize_image_size,
normalize_vector_engine_image_model, vector_engine_images_edit_url,
vector_engine_images_generation_url,
},
response::handle_vector_engine_response,
types::{GeneratedImages, ReferenceImage, VectorEngineImageSettings},
@@ -31,12 +33,40 @@ pub async fn create_vector_engine_image_generation(
reference_images: &[String],
failure_context: &str,
) -> Result<GeneratedImages, PlatformImageError> {
create_vector_engine_image_generation_with_model(
http_client,
settings,
GPT_IMAGE_2_MODEL,
prompt,
negative_prompt,
size,
candidate_count,
reference_images,
failure_context,
)
.await
}
#[allow(clippy::too_many_arguments)]
pub async fn create_vector_engine_image_generation_with_model(
http_client: &reqwest::Client,
settings: &VectorEngineImageSettings,
model: &str,
prompt: &str,
negative_prompt: Option<&str>,
size: &str,
candidate_count: u32,
reference_images: &[String],
failure_context: &str,
) -> Result<GeneratedImages, PlatformImageError> {
let model = normalize_vector_engine_image_model(model);
if !reference_images.is_empty() {
let resolved_references =
resolve_reference_images(http_client, reference_images, failure_context).await?;
return create_vector_engine_image_edit_with_references(
return create_vector_engine_image_edit_with_references_and_model(
http_client,
settings,
model,
prompt,
negative_prompt,
size,
@@ -49,7 +79,8 @@ pub async fn create_vector_engine_image_generation(
let request_url = vector_engine_images_generation_url(settings);
let normalized_size = normalize_image_size(size);
let request_body = build_vector_engine_image_request_body(
let request_body = build_vector_engine_image_request_body_with_model(
model,
prompt,
negative_prompt,
normalized_size.as_str(),
@@ -125,6 +156,7 @@ pub async fn create_vector_engine_image_generation(
provider = VECTOR_ENGINE_PROVIDER,
endpoint = %request_url,
status = response_status,
image_model = model,
prompt_chars = prompt.chars().count(),
size = %normalized_size,
reference_image_count = reference_images.len(),
@@ -181,6 +213,33 @@ pub async fn create_vector_engine_image_edit_with_references(
reference_images: &[ReferenceImage],
failure_context: &str,
) -> Result<GeneratedImages, PlatformImageError> {
create_vector_engine_image_edit_with_references_and_model(
http_client,
settings,
GPT_IMAGE_2_MODEL,
prompt,
negative_prompt,
size,
candidate_count,
reference_images,
failure_context,
)
.await
}
#[allow(clippy::too_many_arguments)]
pub async fn create_vector_engine_image_edit_with_references_and_model(
http_client: &reqwest::Client,
settings: &VectorEngineImageSettings,
model: &str,
prompt: &str,
negative_prompt: Option<&str>,
size: &str,
candidate_count: u32,
reference_images: &[ReferenceImage],
failure_context: &str,
) -> Result<GeneratedImages, PlatformImageError> {
let model = normalize_vector_engine_image_model(model);
if reference_images.is_empty() {
return Err(PlatformImageError::InvalidRequest {
provider: VECTOR_ENGINE_PROVIDER,
@@ -191,6 +250,7 @@ pub async fn create_vector_engine_image_edit_with_references(
let request_url = vector_engine_images_edit_url(settings);
let normalized_size = normalize_image_size(size);
let request_params = build_vector_engine_image_edit_request_log_params(
model,
prompt,
negative_prompt,
normalized_size.as_str(),
@@ -208,7 +268,7 @@ pub async fn create_vector_engine_image_edit_with_references(
tracing::info!(
provider = VECTOR_ENGINE_PROVIDER,
endpoint = %request_url,
image_model = GPT_IMAGE_2_MODEL,
image_model = model,
size = %normalized_size,
candidate_count = candidate_count.clamp(1, 4),
requested_candidate_count = candidate_count,
@@ -230,6 +290,7 @@ pub async fn create_vector_engine_image_edit_with_references(
match send_vector_engine_multipart_edit_request_with_curl(
request_url.as_str(),
settings.api_key.as_str(),
model,
prompt,
negative_prompt,
normalized_size.as_str(),
@@ -8,7 +8,7 @@ use serde_json::Value;
use super::{
audit::build_failure_audit,
constants::{GPT_IMAGE_2_MODEL, VECTOR_ENGINE_PROVIDER},
constants::VECTOR_ENGINE_PROVIDER,
error::PlatformImageError,
request::build_prompt_with_negative,
types::ReferenceImage,
@@ -115,6 +115,7 @@ pub(crate) async fn send_vector_engine_json_request_with_curl(
pub(crate) async fn send_vector_engine_multipart_edit_request_with_curl(
request_url: &str,
api_key: &str,
model: &str,
prompt: &str,
negative_prompt: Option<&str>,
normalized_size: &str,
@@ -124,6 +125,7 @@ pub(crate) async fn send_vector_engine_multipart_edit_request_with_curl(
) -> Result<VectorEngineCurlResponse, VectorEngineCurlError> {
let request_url = request_url.to_string();
let api_key = api_key.to_string();
let model = model.to_string();
let prompt = prompt.to_string();
let negative_prompt = negative_prompt.map(str::to_string);
let normalized_size = normalized_size.to_string();
@@ -132,6 +134,7 @@ pub(crate) async fn send_vector_engine_multipart_edit_request_with_curl(
send_multipart_edit_request_with_curl_blocking(
request_url.as_str(),
api_key.as_str(),
model.as_str(),
prompt.as_str(),
negative_prompt.as_deref(),
normalized_size.as_str(),
@@ -230,6 +233,7 @@ fn send_json_request_with_curl_blocking(
fn send_multipart_edit_request_with_curl_blocking(
request_url: &str,
api_key: &str,
model: &str,
prompt: &str,
negative_prompt: Option<&str>,
normalized_size: &str,
@@ -239,7 +243,7 @@ fn send_multipart_edit_request_with_curl_blocking(
) -> Result<VectorEngineCurlResponse, VectorEngineCurlError> {
let mut form = Form::new();
form.part("model")
.contents(GPT_IMAGE_2_MODEL.as_bytes())
.contents(model.as_bytes())
.add()?;
form.part("prompt")
.contents(build_prompt_with_negative(prompt, negative_prompt).as_bytes())
@@ -295,7 +299,7 @@ fn perform_curl_request(mut easy: Easy) -> Result<VectorEngineCurlResponse, curl
#[cfg(test)]
mod tests {
use super::*;
use crate::vector_engine::types::ReferenceImage;
use crate::vector_engine::{constants::GPT_IMAGE_2_MODEL, types::ReferenceImage};
use tokio::{
io::{AsyncReadExt, AsyncWriteExt},
net::TcpListener,
@@ -330,6 +334,7 @@ mod tests {
let response = send_vector_engine_multipart_edit_request_with_curl(
format!("{base_url}/v1/images/edits").as_str(),
"test-key",
GPT_IMAGE_2_MODEL,
"测试提示词",
None,
"1024x1024",

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