diff --git a/docs/README.md b/docs/README.md index 9f4736622..33948359c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -30,7 +30,7 @@ Expo React Native 移动壳和 Tauri 桌面壳的工程结构、同源 WebView `/editor/agent` 浏览器内 AI Web 工程编辑器的静态 SPA 沙箱预览 MVP,采用“平台编辑器壳 + api-server 控制面 + 独立 runner worker + 独立预览域”四层结构;技术方案、威胁模型和验收清单见 [【技术方案】浏览器内AIWeb工程沙箱预览方案-2026-06-13.md](./technical/【技术方案】浏览器内AIWeb工程沙箱预览方案-2026-06-13.md)、[【安全模型】AIWeb工程Runner与预览隔离威胁模型-2026-06-13.md](./technical/【安全模型】AIWeb工程Runner与预览隔离威胁模型-2026-06-13.md) 和 [【测试用例】AIWeb工程静态预览MVP验收清单-2026-06-13.md](./technical/【测试用例】AIWeb工程静态预览MVP验收清单-2026-06-13.md)。P1 先用确定性 mock Agent 生成结构化 patch、真实打通项目 / 快照 / 构建 / artifact / 预览闭环,落地拆分见 [【技术方案】EditorAgentMockAgentP1落地计划-2026-06-15.md](./technical/【技术方案】EditorAgentMockAgentP1落地计划-2026-06-15.md)。 -`/editor/canvas` 图片画布编辑器的画布素材 ZIP 导出能力,入口放在右上角标题栏下载图标内,第一版采用前端 JSZip 打包画布中有效图层引用的上传图、生成图和修改结果,方案见 [【前端架构】图片画布素材导出方案-2026-06-15.md](./technical/【前端架构】图片画布素材导出方案-2026-06-15.md)。 +`/editor/canvas` 图片画布编辑器的画布素材 ZIP 导出能力,入口放在右上角标题栏下载图标内,采用前端 JSZip 打包画布中有效图层引用的上传图、生成图、修改结果和角色动作序列帧;动作图层右键“导出为”提供序列帧 ZIP(含前端生成的 `preview.gif`)与 Spine JSON ZIP 两个二级选项,方案见 [【前端架构】图片画布素材导出方案-2026-06-15.md](./technical/【前端架构】图片画布素材导出方案-2026-06-15.md)。 图片画布生成类面板的模型泥点默认 JSON、运行时 override、后台“模型定价”页面和主站动态下发口径见 [【编辑器】模型定价配置管理方案-2026-06-22.md](./%E3%80%90%E7%BC%96%E8%BE%91%E5%99%A8%E3%80%91%E6%A8%A1%E5%9E%8B%E5%AE%9A%E4%BB%B7%E9%85%8D%E7%BD%AE%E7%AE%A1%E7%90%86%E6%96%B9%E6%A1%88-2026-06-22.md)。 diff --git a/docs/technical/【前端架构】图片画布素材导出方案-2026-06-15.md b/docs/technical/【前端架构】图片画布素材导出方案-2026-06-15.md index 66a94e92b..ba0fb6d88 100644 --- a/docs/technical/【前端架构】图片画布素材导出方案-2026-06-15.md +++ b/docs/technical/【前端架构】图片画布素材导出方案-2026-06-15.md @@ -64,7 +64,24 @@ └─ manifest.txt ``` -角色动作单图层导出时,直接生成独立 ZIP: +角色动作单图层右键“导出为”展开二级菜单: + +- `序列帧导出(zip)` 生成普通序列帧 ZIP,保留完整 `frames/`,并由前端把成功读取的帧拼成 `preview.gif`。 +- `Spine 导出(zip)` 生成 Spine JSON ZIP,保留 `frames/`,并附带 `skeleton.json`。 + +普通序列帧 ZIP 结构: + +```text +角色动作-Sequence.zip +├─ frames/ +│ ├─ frame-01.png +│ └─ frame-02.png +├─ preview.gif +├─ metadata.json +└─ manifest.txt +``` + +Spine JSON ZIP 结构: ```text 角色动作-SpineJSON.zip @@ -151,7 +168,8 @@ assetObjectId > objectKey > sourceAssetId > src - 同源或可访问 URL 使用 `fetch` 拉取 Blob。 - 私有 generated / OSS 素材必须走同源 `/api/assets/read-bytes` 读取字节;不要在导出流程里直接 `fetch` OSS 签名 URL,避免浏览器 CORS 拦截。 - `mediaType="image-sequence"` 逐帧读取 `imageSequenceFrames`,写入 `sequences/<编号-标题>/frames/`。 - - `mediaType="image-sequence"` 同步写入 `skeleton.json`,供 Spine Editor 或 runtime 以 slot attachment timeline 方式播放序列帧。 + - 画布素材 ZIP 内的 `mediaType="image-sequence"` 同步写入 `skeleton.json`,供 Spine Editor 或 runtime 以 slot attachment timeline 方式播放序列帧。 + - 单图层普通序列帧导出写入 `preview.gif`,由前端基于成功读取的帧生成动画预览,不依赖压缩包读取时再临时播放 PNG。 - 拉取失败时记录失败项,不中断整个导出。 5. 使用 `JSZip` 写入 `images/`、`metadata.json` 和 `manifest.txt`。 6. `zip.generateAsync({ type: 'blob' })` 生成文件。 @@ -176,6 +194,8 @@ assetObjectId > objectKey > sourceAssetId > src - 图层的锁定、翻转、分组状态写入元数据。 - `data:image` 图片不经过网络请求即可导出。 - URL 图片 fetch 失败时不中断其他素材导出。 +- 动作图层右键菜单把 `导出为` 作为一级入口,二级菜单提供 `序列帧导出(zip)` 和 `Spine 导出(zip)`。 +- 单图层普通序列帧 ZIP 包含 `frames/`、`preview.gif`、`metadata.json` 和 `manifest.txt`,且不包含 `skeleton.json`。 - 序列帧导出的 `skeleton.json` 可被独立验证器解析并预览。 ## Spine JSON 验证器 diff --git a/src/components/image-editor/ImageCanvasContextMenusView.test.tsx b/src/components/image-editor/ImageCanvasContextMenusView.test.tsx index 928c676e0..3819db426 100644 --- a/src/components/image-editor/ImageCanvasContextMenusView.test.tsx +++ b/src/components/image-editor/ImageCanvasContextMenusView.test.tsx @@ -109,8 +109,17 @@ describe('ImageCanvasContextMenusView', () => { fireEvent.click(screen.getByRole('menuitem', { name: '显示' })); fireEvent.click(screen.getByRole('menuitem', { name: '解锁' })); fireEvent.click(screen.getByRole('menuitem', { name: '水平翻转' })); - fireEvent.click(screen.getByRole('menuitem', { name: 'Spine 导出(zip)' })); - fireEvent.click(screen.getByRole('menuitem', { name: '序列帧导出(zip)' })); + expect( + screen + .getByRole('menuitem', { name: '导出为' }) + .getAttribute('aria-haspopup'), + ).toBe('menu'); + fireEvent.click( + screen.getByRole('menuitem', { name: '序列帧导出(zip)' }), + ); + fireEvent.click( + screen.getByRole('menuitem', { name: 'Spine 导出(zip)' }), + ); fireEvent.click(screen.getByRole('menuitem', { name: '生成动画' })); fireEvent.click(screen.getByRole('menuitem', { name: '删除' })); @@ -123,11 +132,11 @@ describe('ImageCanvasContextMenusView', () => { expect(props.onFlipContextLayers).toHaveBeenCalledWith('x'); expect(props.onExportContextLayer).toHaveBeenNthCalledWith( 1, - expect.objectContaining({ mode: 'spine-json' }), + expect.objectContaining({ mode: 'sequence-with-preview' }), ); expect(props.onExportContextLayer).toHaveBeenNthCalledWith( 2, - expect.objectContaining({ mode: 'sequence-with-preview' }), + expect.objectContaining({ mode: 'spine-json' }), ); expect(props.onOpenCharacterAnimationPanel).toHaveBeenCalledWith(layer); expect(props.onCloseContextMenu).toHaveBeenCalledTimes(1); diff --git a/src/components/image-editor/ImageCanvasContextMenusView.tsx b/src/components/image-editor/ImageCanvasContextMenusView.tsx index 5853ccfe3..99c3c5182 100644 --- a/src/components/image-editor/ImageCanvasContextMenusView.tsx +++ b/src/components/image-editor/ImageCanvasContextMenusView.tsx @@ -1,4 +1,7 @@ -import { PlatformFloatingMenu, PlatformFloatingMenuItem } from '../common/PlatformFloatingMenu'; +import { + PlatformFloatingMenu, + PlatformFloatingMenuItem, +} from '../common/PlatformFloatingMenu'; import type { CanvasClipboard, CanvasContextMenuState, @@ -188,7 +191,11 @@ export function ImageCanvasContextMenusView({ 移动至底层
- + - Spine 导出(zip) - - + + onExportContextLayer({ + mode: 'sequence-with-preview', + }) + } + > + 序列帧导出(zip) + + + onExportContextLayer({ mode: 'spine-json' }) + } + > + Spine 导出(zip) + + + ) : (