调整动作导出与项目封面兼容
新增动作图层导出二级菜单 序列帧导出改为ZIP并生成GIF预览 兼容历史项目封面资源字段
This commit is contained in:
+1
-1
@@ -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)。
|
||||
|
||||
|
||||
@@ -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 验证器
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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({
|
||||
移动至底层
|
||||
</button>
|
||||
<hr />
|
||||
<button type="button" role="menuitem" onClick={onGroupContextLayers}>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={onGroupContextLayers}
|
||||
>
|
||||
创建组
|
||||
</button>
|
||||
<button
|
||||
@@ -228,28 +235,42 @@ export function ImageCanvasContextMenusView({
|
||||
垂直翻转
|
||||
</button>
|
||||
{isImageSequenceLayer ? (
|
||||
<PlatformFloatingMenu
|
||||
label="导出为"
|
||||
placement="bottom-start"
|
||||
style={{ margin: 0 }}
|
||||
>
|
||||
<PlatformFloatingMenuItem
|
||||
className="image-canvas-editor__context-menu-item"
|
||||
onClick={() =>
|
||||
onExportContextLayer({
|
||||
mode: 'sequence-with-preview',
|
||||
})
|
||||
}
|
||||
<div className="image-canvas-editor__context-submenu">
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
aria-haspopup="menu"
|
||||
className="image-canvas-editor__context-submenu-trigger"
|
||||
>
|
||||
序列帧导出(zip)
|
||||
</PlatformFloatingMenuItem>
|
||||
<PlatformFloatingMenuItem
|
||||
className="image-canvas-editor__context-menu-item"
|
||||
onClick={() => onExportContextLayer({ mode: 'spine-json' })}
|
||||
<span>导出为</span>
|
||||
<span aria-hidden="true">›</span>
|
||||
</button>
|
||||
<PlatformFloatingMenu
|
||||
className="image-canvas-editor__context-submenu-panel"
|
||||
label="动作导出选项"
|
||||
placement="bottom-start"
|
||||
style={{ margin: 0 }}
|
||||
>
|
||||
Spine 导出(zip)
|
||||
</PlatformFloatingMenuItem>
|
||||
</PlatformFloatingMenu>
|
||||
<PlatformFloatingMenuItem
|
||||
className="image-canvas-editor__context-menu-item"
|
||||
onClick={() =>
|
||||
onExportContextLayer({
|
||||
mode: 'sequence-with-preview',
|
||||
})
|
||||
}
|
||||
>
|
||||
序列帧导出(zip)
|
||||
</PlatformFloatingMenuItem>
|
||||
<PlatformFloatingMenuItem
|
||||
className="image-canvas-editor__context-menu-item"
|
||||
onClick={() =>
|
||||
onExportContextLayer({ mode: 'spine-json' })
|
||||
}
|
||||
>
|
||||
Spine 导出(zip)
|
||||
</PlatformFloatingMenuItem>
|
||||
</PlatformFloatingMenu>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { CanvasLayer } from './ImageCanvasEditorTypes';
|
||||
import {
|
||||
buildAnimatedGifPreviewBytes,
|
||||
buildLayerExportMetadata,
|
||||
buildSpineImageSequenceJson,
|
||||
buildSpineImageSequenceReadme,
|
||||
@@ -20,6 +21,48 @@ import {
|
||||
} from './ImageCanvasGenerationModel';
|
||||
|
||||
describe('ImageCanvasExportModel', () => {
|
||||
it('builds a real animated GIF preview from sequence frames', () => {
|
||||
const gifBytes = buildAnimatedGifPreviewBytes({
|
||||
width: 2,
|
||||
height: 1,
|
||||
frameDelayCentiseconds: 12,
|
||||
frames: [
|
||||
{
|
||||
width: 2,
|
||||
height: 1,
|
||||
rgba: Uint8Array.from([255, 0, 0, 255, 0, 0, 0, 0]),
|
||||
},
|
||||
{
|
||||
width: 2,
|
||||
height: 1,
|
||||
rgba: Uint8Array.from([0, 255, 0, 255, 0, 0, 255, 255]),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(readAscii(gifBytes, 0, 6)).toBe('GIF89a');
|
||||
expect(readAscii(gifBytes, 0, gifBytes.length)).toContain('NETSCAPE2.0');
|
||||
expect(countGifImageDescriptors(gifBytes)).toBe(2);
|
||||
expect(gifBytes.at(-1)).toBe(0x3b);
|
||||
});
|
||||
|
||||
it('rejects GIF preview frames with mismatched dimensions', () => {
|
||||
expect(() =>
|
||||
buildAnimatedGifPreviewBytes({
|
||||
width: 2,
|
||||
height: 2,
|
||||
frameDelayCentiseconds: 12,
|
||||
frames: [
|
||||
{
|
||||
width: 1,
|
||||
height: 2,
|
||||
rgba: Uint8Array.from([255, 0, 0, 255, 0, 0, 255, 255]),
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toThrow('GIF 预览帧尺寸不一致');
|
||||
});
|
||||
|
||||
it('normalizes export file names and dates', () => {
|
||||
expect(sanitizeExportFilePart(' 角色/草图:*? ', 'fallback')).toBe(
|
||||
'角色 草图',
|
||||
@@ -72,11 +115,7 @@ describe('ImageCanvasExportModel', () => {
|
||||
expect(getImageExtensionFromTypeOrSrc('', '/image.webp?x=1')).toBe('webp');
|
||||
expect(getImageExtensionFromTypeOrSrc('', '/image.unknown')).toBe('png');
|
||||
expect(
|
||||
getLayerAssetExtensionFromTypeOrSrc(
|
||||
'video',
|
||||
'video/mp4',
|
||||
'/video.png',
|
||||
),
|
||||
getLayerAssetExtensionFromTypeOrSrc('video', 'video/mp4', '/video.png'),
|
||||
).toBe('mp4');
|
||||
expect(
|
||||
getLayerAssetExtensionFromTypeOrSrc(
|
||||
@@ -106,7 +145,9 @@ describe('ImageCanvasExportModel', () => {
|
||||
expect(blob.type).toBe('text/plain');
|
||||
expect(await blob.text()).toBe('Hello');
|
||||
|
||||
expect(buildLayerExportMetadata(buildLayer(), 'images/001-layer.png')).toEqual({
|
||||
expect(
|
||||
buildLayerExportMetadata(buildLayer(), 'images/001-layer.png'),
|
||||
).toEqual({
|
||||
title: '导出图层',
|
||||
file: 'images/001-layer.png',
|
||||
visible: {
|
||||
@@ -209,9 +250,7 @@ describe('ImageCanvasExportModel', () => {
|
||||
width: 192,
|
||||
height: 256,
|
||||
});
|
||||
expect(
|
||||
spineJson.animations.animation.slots.character.attachment,
|
||||
).toEqual([
|
||||
expect(spineJson.animations.animation.slots.character.attachment).toEqual([
|
||||
{ time: 0, name: 'animation_0001' },
|
||||
{ time: 0.125, name: 'animation_0002' },
|
||||
{ time: 0.25, name: 'animation_0003' },
|
||||
@@ -242,7 +281,9 @@ describe('ImageCanvasExportModel', () => {
|
||||
|
||||
expect(blob.type).toBe('video/mp4');
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/api/assets/read-bytes?objectKey=generated%2Fvideo.mp4'),
|
||||
expect.stringContaining(
|
||||
'/api/assets/read-bytes?objectKey=generated%2Fvideo.mp4',
|
||||
),
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(fetchMock).not.toHaveBeenCalledWith(
|
||||
@@ -300,6 +341,62 @@ describe('ImageCanvasExportModel', () => {
|
||||
});
|
||||
});
|
||||
|
||||
function readAscii(bytes: Uint8Array, start: number, end: number) {
|
||||
return Array.from(bytes.slice(start, end), (byte) =>
|
||||
String.fromCharCode(byte),
|
||||
).join('');
|
||||
}
|
||||
|
||||
function skipGifDataSubBlocks(bytes: Uint8Array, offset: number) {
|
||||
let cursor = offset;
|
||||
while (cursor < bytes.length) {
|
||||
const blockLength = bytes[cursor] ?? 0;
|
||||
cursor += 1;
|
||||
if (blockLength === 0) {
|
||||
return cursor;
|
||||
}
|
||||
cursor += blockLength;
|
||||
}
|
||||
return cursor;
|
||||
}
|
||||
|
||||
function countGifImageDescriptors(bytes: Uint8Array) {
|
||||
let cursor = 13 + 256 * 3;
|
||||
let count = 0;
|
||||
while (cursor < bytes.length) {
|
||||
const blockType = bytes[cursor];
|
||||
if (blockType === 0x3b) {
|
||||
return count;
|
||||
}
|
||||
if (blockType === 0x21) {
|
||||
const label = bytes[cursor + 1];
|
||||
if (label === 0xf9) {
|
||||
const blockLength = bytes[cursor + 2] ?? 0;
|
||||
cursor += 3 + blockLength + 1;
|
||||
continue;
|
||||
}
|
||||
cursor = skipGifDataSubBlocks(
|
||||
bytes,
|
||||
cursor + 3 + (bytes[cursor + 2] ?? 0),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (blockType === 0x2c) {
|
||||
count += 1;
|
||||
const packed = bytes[cursor + 9] ?? 0;
|
||||
cursor += 10;
|
||||
if (packed & 0x80) {
|
||||
cursor += 3 * 2 ** ((packed & 0x07) + 1);
|
||||
}
|
||||
cursor += 1;
|
||||
cursor = skipGifDataSubBlocks(bytes, cursor);
|
||||
continue;
|
||||
}
|
||||
throw new Error(`Unknown GIF block: ${blockType}`);
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
function buildLayer(overrides: Partial<CanvasLayer> = {}): CanvasLayer {
|
||||
return {
|
||||
id: 'layer-1',
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import {
|
||||
readAssetBytes,
|
||||
} from '../../services/assetReadUrlService';
|
||||
import { readAssetBytes } from '../../services/assetReadUrlService';
|
||||
import type {
|
||||
CanvasAssetExportMetadata,
|
||||
CanvasGenerationInputField,
|
||||
@@ -219,6 +217,181 @@ export type ExportedImageSequenceFrame = {
|
||||
height: number;
|
||||
};
|
||||
|
||||
export type AnimatedGifPreviewFrame = {
|
||||
width: number;
|
||||
height: number;
|
||||
rgba: Uint8Array | Uint8ClampedArray;
|
||||
};
|
||||
|
||||
const GIF_PREVIEW_COLOR_TABLE_SIZE = 256;
|
||||
const GIF_PREVIEW_TRANSPARENT_INDEX = 0;
|
||||
const GIF_PREVIEW_COLOR_COUNT = 252;
|
||||
const GIF_PREVIEW_LZW_MIN_CODE_SIZE = 8;
|
||||
|
||||
function pushAscii(bytes: number[], value: string) {
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
bytes.push(value.charCodeAt(index) & 0xff);
|
||||
}
|
||||
}
|
||||
|
||||
function pushUint16Le(bytes: number[], value: number) {
|
||||
bytes.push(value & 0xff, (value >> 8) & 0xff);
|
||||
}
|
||||
|
||||
function pushByteArray(bytes: number[], value: Uint8Array) {
|
||||
for (const byte of value) {
|
||||
bytes.push(byte);
|
||||
}
|
||||
}
|
||||
|
||||
function buildGifPreviewPalette() {
|
||||
const palette = new Uint8Array(GIF_PREVIEW_COLOR_TABLE_SIZE * 3);
|
||||
let index = 1;
|
||||
for (let red = 0; red < 6; red += 1) {
|
||||
for (let green = 0; green < 7; green += 1) {
|
||||
for (let blue = 0; blue < 6; blue += 1) {
|
||||
palette[index * 3] = Math.round((red * 255) / 5);
|
||||
palette[index * 3 + 1] = Math.round((green * 255) / 6);
|
||||
palette[index * 3 + 2] = Math.round((blue * 255) / 5);
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
return palette;
|
||||
}
|
||||
|
||||
function quantizeGifPreviewColor(red: number, green: number, blue: number) {
|
||||
const redIndex = Math.round((red * 5) / 255);
|
||||
const greenIndex = Math.round((green * 6) / 255);
|
||||
const blueIndex = Math.round((blue * 5) / 255);
|
||||
return Math.min(
|
||||
GIF_PREVIEW_COLOR_COUNT,
|
||||
1 + (redIndex * 7 + greenIndex) * 6 + blueIndex,
|
||||
);
|
||||
}
|
||||
|
||||
function mapRgbaToGifPreviewIndices(frame: AnimatedGifPreviewFrame) {
|
||||
const pixelCount = frame.width * frame.height;
|
||||
if (frame.rgba.length < pixelCount * 4) {
|
||||
throw new Error('GIF 预览帧像素数据不足');
|
||||
}
|
||||
const indices = new Uint8Array(pixelCount);
|
||||
for (let pixelIndex = 0; pixelIndex < pixelCount; pixelIndex += 1) {
|
||||
const rgbaIndex = pixelIndex * 4;
|
||||
const alpha = frame.rgba[rgbaIndex + 3] ?? 255;
|
||||
indices[pixelIndex] =
|
||||
alpha < 128
|
||||
? GIF_PREVIEW_TRANSPARENT_INDEX
|
||||
: quantizeGifPreviewColor(
|
||||
frame.rgba[rgbaIndex] ?? 0,
|
||||
frame.rgba[rgbaIndex + 1] ?? 0,
|
||||
frame.rgba[rgbaIndex + 2] ?? 0,
|
||||
);
|
||||
}
|
||||
return indices;
|
||||
}
|
||||
|
||||
function encodeGifLzwData(indices: Uint8Array) {
|
||||
const clearCode = 1 << GIF_PREVIEW_LZW_MIN_CODE_SIZE;
|
||||
const endCode = clearCode + 1;
|
||||
const codeSize = GIF_PREVIEW_LZW_MIN_CODE_SIZE + 1;
|
||||
const bytes: number[] = [];
|
||||
let bitBuffer = 0;
|
||||
let bitLength = 0;
|
||||
|
||||
function writeCode(code: number) {
|
||||
bitBuffer |= code << bitLength;
|
||||
bitLength += codeSize;
|
||||
while (bitLength >= 8) {
|
||||
bytes.push(bitBuffer & 0xff);
|
||||
bitBuffer >>= 8;
|
||||
bitLength -= 8;
|
||||
}
|
||||
}
|
||||
|
||||
writeCode(clearCode);
|
||||
let codesSinceClear = 0;
|
||||
for (const index of indices) {
|
||||
if (codesSinceClear >= 250) {
|
||||
writeCode(clearCode);
|
||||
codesSinceClear = 0;
|
||||
}
|
||||
writeCode(index);
|
||||
codesSinceClear += 1;
|
||||
}
|
||||
writeCode(endCode);
|
||||
if (bitLength > 0) {
|
||||
bytes.push(bitBuffer & 0xff);
|
||||
}
|
||||
|
||||
return Uint8Array.from(bytes);
|
||||
}
|
||||
|
||||
function pushGifDataSubBlocks(bytes: number[], data: Uint8Array) {
|
||||
bytes.push(GIF_PREVIEW_LZW_MIN_CODE_SIZE);
|
||||
for (let offset = 0; offset < data.length; offset += 255) {
|
||||
const chunk = data.slice(offset, offset + 255);
|
||||
bytes.push(chunk.length);
|
||||
pushByteArray(bytes, chunk);
|
||||
}
|
||||
bytes.push(0);
|
||||
}
|
||||
|
||||
export function buildAnimatedGifPreviewBytes({
|
||||
width,
|
||||
height,
|
||||
frames,
|
||||
frameDelayCentiseconds,
|
||||
}: {
|
||||
width: number;
|
||||
height: number;
|
||||
frames: AnimatedGifPreviewFrame[];
|
||||
frameDelayCentiseconds: number;
|
||||
}) {
|
||||
const gifWidth = Math.max(1, Math.round(width));
|
||||
const gifHeight = Math.max(1, Math.round(height));
|
||||
if (!frames.length) {
|
||||
throw new Error('GIF 预览帧为空');
|
||||
}
|
||||
const delay = Math.max(
|
||||
1,
|
||||
Math.min(65_535, Math.round(frameDelayCentiseconds)),
|
||||
);
|
||||
const bytes: number[] = [];
|
||||
|
||||
pushAscii(bytes, 'GIF89a');
|
||||
pushUint16Le(bytes, gifWidth);
|
||||
pushUint16Le(bytes, gifHeight);
|
||||
bytes.push(0xf7, 0, 0);
|
||||
pushByteArray(bytes, buildGifPreviewPalette());
|
||||
|
||||
bytes.push(0x21, 0xff, 0x0b);
|
||||
pushAscii(bytes, 'NETSCAPE2.0');
|
||||
bytes.push(0x03, 0x01, 0x00, 0x00, 0x00);
|
||||
|
||||
for (const frame of frames) {
|
||||
if (frame.width !== gifWidth || frame.height !== gifHeight) {
|
||||
throw new Error('GIF 预览帧尺寸不一致');
|
||||
}
|
||||
bytes.push(0x21, 0xf9, 0x04, 0x09);
|
||||
pushUint16Le(bytes, delay);
|
||||
bytes.push(GIF_PREVIEW_TRANSPARENT_INDEX, 0);
|
||||
bytes.push(0x2c);
|
||||
pushUint16Le(bytes, 0);
|
||||
pushUint16Le(bytes, 0);
|
||||
pushUint16Le(bytes, gifWidth);
|
||||
pushUint16Le(bytes, gifHeight);
|
||||
bytes.push(0);
|
||||
pushGifDataSubBlocks(
|
||||
bytes,
|
||||
encodeGifLzwData(mapRgbaToGifPreviewIndices(frame)),
|
||||
);
|
||||
}
|
||||
|
||||
bytes.push(0x3b);
|
||||
return Uint8Array.from(bytes);
|
||||
}
|
||||
|
||||
const DEFAULT_SPINE_VERSION = '4.2.00';
|
||||
const SPINE_SEQUENCE_BONE_NAME = 'root';
|
||||
const SPINE_SEQUENCE_SLOT_NAME = 'character';
|
||||
|
||||
@@ -36,6 +36,12 @@ async function readZipText(zip: JSZip, path: string) {
|
||||
return file.async('text');
|
||||
}
|
||||
|
||||
function readAscii(bytes: Uint8Array, start: number, end: number) {
|
||||
return Array.from(bytes.slice(start, end), (byte) =>
|
||||
String.fromCharCode(byte),
|
||||
).join('');
|
||||
}
|
||||
|
||||
function ExportWorkflowHarness({ layers }: { layers: CanvasLayer[] }) {
|
||||
const workflow = useImageCanvasAssetExportWorkflow({
|
||||
layers,
|
||||
@@ -535,6 +541,25 @@ describe('useImageCanvasAssetExportWorkflow', () => {
|
||||
});
|
||||
|
||||
it('downloads character animation layers as sequence zip with gif preview', async () => {
|
||||
Object.defineProperty(globalThis, 'createImageBitmap', {
|
||||
configurable: true,
|
||||
value: vi.fn(async () => ({ width: 1024, height: 1024 })),
|
||||
});
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({
|
||||
clearRect: vi.fn(),
|
||||
drawImage: vi.fn(),
|
||||
getImageData: vi.fn(
|
||||
(_x: number, _y: number, width: number, height: number) => {
|
||||
const data = new Uint8ClampedArray(width * height * 4);
|
||||
for (let index = 0; index < data.length; index += 4) {
|
||||
data[index] = 255;
|
||||
data[index + 3] = 255;
|
||||
}
|
||||
return { data };
|
||||
},
|
||||
),
|
||||
} as unknown as CanvasRenderingContext2D);
|
||||
|
||||
let exportedBlob: Blob | null = null;
|
||||
let downloadName = '';
|
||||
Object.defineProperty(URL, 'createObjectURL', {
|
||||
@@ -583,9 +608,7 @@ describe('useImageCanvasAssetExportWorkflow', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '导出序列帧zip' }),
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: '导出序列帧zip' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(exportedBlob).toBeTruthy();
|
||||
@@ -594,12 +617,20 @@ describe('useImageCanvasAssetExportWorkflow', () => {
|
||||
const zip = await JSZip.loadAsync(exportedBlob!);
|
||||
expect(zip.file('frames/frame-01.png')).toBeTruthy();
|
||||
expect(zip.file('frames/frame-02.png')).toBeTruthy();
|
||||
expect(zip.file('preview.gif')).toBeTruthy();
|
||||
const previewGif = await zip.file('preview.gif')!.async('uint8array');
|
||||
expect(readAscii(previewGif, 0, 6)).toBe('GIF89a');
|
||||
expect(readAscii(previewGif, 0, previewGif.length)).toContain(
|
||||
'NETSCAPE2.0',
|
||||
);
|
||||
expect(await readZipText(zip, 'manifest.txt')).toContain('类型:序列帧');
|
||||
expect(await readZipText(zip, 'metadata.json')).toContain('"frameCount": 2');
|
||||
expect(await readZipText(zip, 'metadata.json')).toContain(
|
||||
'"frameCount": 2',
|
||||
);
|
||||
expect(zip.file('skeleton.json')).toBeNull();
|
||||
expect(zip.file('README.md')).toBeNull();
|
||||
} finally {
|
||||
delete (globalThis as unknown as { createImageBitmap?: unknown })
|
||||
.createImageBitmap;
|
||||
delete (URL as unknown as { createObjectURL?: unknown }).createObjectURL;
|
||||
delete (URL as unknown as { revokeObjectURL?: unknown }).revokeObjectURL;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
CanvasLayer,
|
||||
} from './ImageCanvasEditorTypes';
|
||||
import {
|
||||
buildAnimatedGifPreviewBytes,
|
||||
blobToUint8Array,
|
||||
buildLayerExportMetadata,
|
||||
buildLayerVisibleExportMetadata,
|
||||
@@ -36,6 +37,8 @@ type UseImageCanvasAssetExportWorkflowOptions = {
|
||||
projectTitle: string;
|
||||
};
|
||||
|
||||
const GIF_PREVIEW_MAX_EDGE = 320;
|
||||
|
||||
function triggerBrowserDownload(blob: Blob, downloadName: string) {
|
||||
if (
|
||||
typeof URL.createObjectURL !== 'function' ||
|
||||
@@ -60,40 +63,84 @@ function createFallbackPreviewBlob(frameBlob: Blob) {
|
||||
: new Blob([], { type: 'image/gif' });
|
||||
}
|
||||
|
||||
async function buildAnimatedSequencePreview(frameBlob: Blob, size: { width: number; height: number }) {
|
||||
if (!frameBlob.size) {
|
||||
return createFallbackPreviewBlob(frameBlob);
|
||||
function resolveGifPreviewSize(width: number, height: number) {
|
||||
const safeWidth = Math.max(1, Math.round(width));
|
||||
const safeHeight = Math.max(1, Math.round(height));
|
||||
const scale = Math.min(
|
||||
1,
|
||||
GIF_PREVIEW_MAX_EDGE / Math.max(safeWidth, safeHeight),
|
||||
);
|
||||
return {
|
||||
width: Math.max(1, Math.round(safeWidth * scale)),
|
||||
height: Math.max(1, Math.round(safeHeight * scale)),
|
||||
};
|
||||
}
|
||||
|
||||
async function buildAnimatedSequencePreview(
|
||||
frameBlobs: Blob[],
|
||||
size: { width: number; height: number },
|
||||
durationSeconds: number | null | undefined,
|
||||
) {
|
||||
const firstFrameBlob = frameBlobs[0];
|
||||
if (!firstFrameBlob?.size) {
|
||||
return createFallbackPreviewBlob(firstFrameBlob ?? new Blob());
|
||||
}
|
||||
|
||||
try {
|
||||
if (typeof createImageBitmap === 'undefined') {
|
||||
return createFallbackPreviewBlob(frameBlob);
|
||||
return createFallbackPreviewBlob(firstFrameBlob);
|
||||
}
|
||||
const frameImage = await createImageBitmap(frameBlob);
|
||||
const firstFrameImage = await createImageBitmap(firstFrameBlob);
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = size.width || frameImage.width;
|
||||
canvas.height = size.height || frameImage.height;
|
||||
canvas.width = size.width || firstFrameImage.width;
|
||||
canvas.height = size.height || firstFrameImage.height;
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) {
|
||||
return createFallbackPreviewBlob(frameBlob);
|
||||
return createFallbackPreviewBlob(firstFrameBlob);
|
||||
}
|
||||
|
||||
const width = Math.max(1, size.width || frameImage.width);
|
||||
const height = Math.max(1, size.height || frameImage.height);
|
||||
const { width, height } = resolveGifPreviewSize(
|
||||
size.width || firstFrameImage.width,
|
||||
size.height || firstFrameImage.height,
|
||||
);
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
context.drawImage(frameImage, 0, 0, width, height);
|
||||
|
||||
return await new Promise<Blob>((resolve) => {
|
||||
canvas.toBlob(
|
||||
(blob) => {
|
||||
resolve(blob ?? createFallbackPreviewBlob(frameBlob));
|
||||
},
|
||||
'image/gif',
|
||||
);
|
||||
});
|
||||
const previewFrames = [];
|
||||
for (const frameBlob of frameBlobs) {
|
||||
const frameImage =
|
||||
frameBlob === firstFrameBlob
|
||||
? firstFrameImage
|
||||
: await createImageBitmap(frameBlob);
|
||||
context.clearRect(0, 0, width, height);
|
||||
context.drawImage(frameImage, 0, 0, width, height);
|
||||
previewFrames.push({
|
||||
width,
|
||||
height,
|
||||
rgba: context.getImageData(0, 0, width, height).data,
|
||||
});
|
||||
}
|
||||
|
||||
const duration =
|
||||
typeof durationSeconds === 'number' && durationSeconds > 0
|
||||
? durationSeconds
|
||||
: frameBlobs.length;
|
||||
const delayCentiseconds = (duration / Math.max(1, frameBlobs.length)) * 100;
|
||||
return new Blob(
|
||||
[
|
||||
buildAnimatedGifPreviewBytes({
|
||||
width,
|
||||
height,
|
||||
frames: previewFrames,
|
||||
frameDelayCentiseconds: delayCentiseconds,
|
||||
}),
|
||||
],
|
||||
{
|
||||
type: 'image/gif',
|
||||
},
|
||||
);
|
||||
} catch {
|
||||
return createFallbackPreviewBlob(frameBlob);
|
||||
return createFallbackPreviewBlob(firstFrameBlob);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,14 +160,12 @@ async function buildImageSequenceZip(
|
||||
}> = [];
|
||||
const exportedFrames: ExportedImageSequenceFrame[] = [];
|
||||
let successCount = 0;
|
||||
let firstFrameBlob: Blob | null = null;
|
||||
const frameBlobs: Blob[] = [];
|
||||
|
||||
for (const [index, frame] of frames.entries()) {
|
||||
try {
|
||||
const blob = await readLayerImageSequenceFrameBlob(layer, frame);
|
||||
if (!firstFrameBlob) {
|
||||
firstFrameBlob = blob;
|
||||
}
|
||||
frameBlobs.push(blob);
|
||||
const fileName = getImageSequenceFrameFileName(frame, index, blob.type);
|
||||
framesFolder.file(fileName, await blobToUint8Array(blob));
|
||||
exportedFrames.push({
|
||||
@@ -151,15 +196,20 @@ async function buildImageSequenceZip(
|
||||
].filter(Boolean);
|
||||
|
||||
if (mode === 'sequence-with-preview') {
|
||||
if (firstFrameBlob) {
|
||||
const previewBlob = await buildAnimatedSequencePreview(firstFrameBlob, {
|
||||
width: layer.width,
|
||||
height: layer.height,
|
||||
});
|
||||
if (frameBlobs.length) {
|
||||
const previewBlob = await buildAnimatedSequencePreview(
|
||||
frameBlobs,
|
||||
{
|
||||
width: layer.width,
|
||||
height: layer.height,
|
||||
},
|
||||
layer.durationSeconds,
|
||||
);
|
||||
zip.file('preview.gif', await blobToUint8Array(previewBlob));
|
||||
}
|
||||
|
||||
zip.file('metadata.json',
|
||||
zip.file(
|
||||
'metadata.json',
|
||||
JSON.stringify(
|
||||
{
|
||||
title: layer.title,
|
||||
|
||||
@@ -78,4 +78,43 @@ describe('resolveProjectCoverSnapshotResource', () => {
|
||||
|
||||
expect(cover).toBeNull();
|
||||
});
|
||||
|
||||
it('supports legacy editor object asset kind identifier', () => {
|
||||
const cover = resolveProjectCoverSnapshotResource(
|
||||
project([
|
||||
{
|
||||
resourceId: 'resource-legacy-cover',
|
||||
projectId: 'editor-project-cover',
|
||||
imageSrc: '',
|
||||
objectKey:
|
||||
'generated-character-drafts/editor/project-covers/editor-project-cover/1771400000200/cover.png',
|
||||
width: 320,
|
||||
height: 240,
|
||||
sourceType: 'uploaded',
|
||||
assetKind: 'editor_project_cover_snapshot',
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
expect(cover?.resourceId).toBe('resource-legacy-cover');
|
||||
});
|
||||
|
||||
it('supports legacy snake-case project cover resource payload', () => {
|
||||
const cover = resolveProjectCoverSnapshotResource(
|
||||
project([
|
||||
{
|
||||
resourceId: 'resource-snake-cover',
|
||||
projectId: 'editor-project-cover',
|
||||
imageSrc: '',
|
||||
width: 320,
|
||||
height: 240,
|
||||
sourceType: 'uploaded',
|
||||
asset_kind: 'project-cover-snapshot',
|
||||
src: '/generated-character-drafts/editor/project-covers/editor-project-cover/1771400000300/cover.png',
|
||||
} as EditorProjectSnapshot['resources'][number],
|
||||
]),
|
||||
);
|
||||
|
||||
expect(cover?.resourceId).toBe('resource-snake-cover');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,14 +4,47 @@ import type {
|
||||
} from '../../services/image-editor/editorProjectClient';
|
||||
import { PROJECT_COVER_SNAPSHOT_ASSET_KIND } from '../image-editor/ImageCanvasProjectCoverSnapshotModel';
|
||||
|
||||
function hasProjectCoverSnapshotSource(resource: EditorProjectResourceSnapshot) {
|
||||
return Boolean(resource.objectKey?.trim() || resource.imageSrc.trim());
|
||||
const PROJECT_COVER_SNAPSHOT_ASSET_KIND_LEGACY =
|
||||
'editor_project_cover_snapshot';
|
||||
|
||||
type ProjectResourceSnakeKind = {
|
||||
asset_kind?: string | null;
|
||||
src?: string | null;
|
||||
};
|
||||
|
||||
function readProjectCoverSnapshotKind(resource: EditorProjectResourceSnapshot) {
|
||||
const legacyResource = resource as ProjectResourceSnakeKind;
|
||||
return (
|
||||
resource.assetKind?.trim() ||
|
||||
legacyResource.asset_kind?.trim() ||
|
||||
''
|
||||
).trim();
|
||||
}
|
||||
|
||||
function readProjectCoverSnapshotImageSrc(
|
||||
resource: EditorProjectResourceSnapshot,
|
||||
) {
|
||||
const legacyResource = resource as ProjectResourceSnakeKind;
|
||||
return (
|
||||
(typeof resource.imageSrc === 'string' ? resource.imageSrc.trim() : '') ||
|
||||
(typeof legacyResource.src === 'string' ? legacyResource.src.trim() : '')
|
||||
);
|
||||
}
|
||||
|
||||
function hasProjectCoverSnapshotSource(
|
||||
resource: EditorProjectResourceSnapshot,
|
||||
) {
|
||||
const resourceObjectKey = resource.objectKey?.trim() || '';
|
||||
const resourceImageSrc = readProjectCoverSnapshotImageSrc(resource);
|
||||
return Boolean(resourceObjectKey || resourceImageSrc);
|
||||
}
|
||||
|
||||
function projectCoverSnapshotObjectTimestamp(
|
||||
resource: EditorProjectResourceSnapshot,
|
||||
) {
|
||||
const snapshotPath = resource.objectKey?.trim() || resource.imageSrc.trim();
|
||||
const objectKey = resource.objectKey?.trim() || '';
|
||||
const imageSrc = readProjectCoverSnapshotImageSrc(resource);
|
||||
const snapshotPath = objectKey || imageSrc;
|
||||
const timestampSegment = snapshotPath.match(
|
||||
/\/project-covers\/[^/]+\/(\d{10,})\//u,
|
||||
)?.[1];
|
||||
@@ -19,7 +52,9 @@ function projectCoverSnapshotObjectTimestamp(
|
||||
return Number.isFinite(timestampMs) ? timestampMs : 0;
|
||||
}
|
||||
|
||||
function projectCoverSnapshotSortScore(resource: EditorProjectResourceSnapshot) {
|
||||
function projectCoverSnapshotSortScore(
|
||||
resource: EditorProjectResourceSnapshot,
|
||||
) {
|
||||
const objectTimestamp = projectCoverSnapshotObjectTimestamp(resource);
|
||||
if (objectTimestamp > 0) {
|
||||
return objectTimestamp;
|
||||
@@ -32,16 +67,21 @@ function projectCoverSnapshotSortScore(resource: EditorProjectResourceSnapshot)
|
||||
export function resolveProjectCoverSnapshotResource(
|
||||
project: EditorProjectSnapshot,
|
||||
) {
|
||||
return [...project.resources]
|
||||
.filter(
|
||||
(resource) =>
|
||||
resource.assetKind === PROJECT_COVER_SNAPSHOT_ASSET_KIND &&
|
||||
hasProjectCoverSnapshotSource(resource),
|
||||
)
|
||||
.sort((left, right) => {
|
||||
const byUpdatedAt =
|
||||
projectCoverSnapshotSortScore(right) -
|
||||
projectCoverSnapshotSortScore(left);
|
||||
return byUpdatedAt || right.resourceId.localeCompare(left.resourceId);
|
||||
})[0] ?? null;
|
||||
return (
|
||||
[...project.resources]
|
||||
.filter(
|
||||
(resource) =>
|
||||
[
|
||||
PROJECT_COVER_SNAPSHOT_ASSET_KIND,
|
||||
PROJECT_COVER_SNAPSHOT_ASSET_KIND_LEGACY,
|
||||
].includes(readProjectCoverSnapshotKind(resource)) &&
|
||||
hasProjectCoverSnapshotSource(resource),
|
||||
)
|
||||
.sort((left, right) => {
|
||||
const byUpdatedAt =
|
||||
projectCoverSnapshotSortScore(right) -
|
||||
projectCoverSnapshotSortScore(left);
|
||||
return byUpdatedAt || right.resourceId.localeCompare(left.resourceId);
|
||||
})[0] ?? null
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7743,6 +7743,51 @@ button.image-canvas-editor__reference-chip:disabled {
|
||||
color: #a3acba;
|
||||
}
|
||||
|
||||
.image-canvas-editor__context-submenu {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.image-canvas-editor__context-submenu-trigger {
|
||||
justify-content: space-between !important;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.image-canvas-editor__context-submenu-panel {
|
||||
top: -0.32rem;
|
||||
left: calc(100% + 0.42rem);
|
||||
width: max-content;
|
||||
min-width: 9.5rem;
|
||||
overflow: visible;
|
||||
padding: 0.32rem;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transform: translateX(-0.12rem);
|
||||
transition:
|
||||
opacity 120ms ease,
|
||||
transform 120ms ease,
|
||||
visibility 120ms linear;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.image-canvas-editor__context-submenu:hover
|
||||
.image-canvas-editor__context-submenu-panel,
|
||||
.image-canvas-editor__context-submenu:focus-within
|
||||
.image-canvas-editor__context-submenu-panel {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
transform: translateX(0);
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.image-canvas-editor__context-submenu-panel::before {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: -0.55rem;
|
||||
width: 0.55rem;
|
||||
content: '';
|
||||
}
|
||||
|
||||
.image-canvas-editor__asset-context-menu {
|
||||
z-index: 42;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user