调整动作导出与项目封面兼容

新增动作图层导出二级菜单

序列帧导出改为ZIP并生成GIF预览

兼容历史项目封面资源字段
This commit is contained in:
2026-06-25 19:58:12 +08:00
parent 960568364e
commit 6277a823ba
11 changed files with 618 additions and 93 deletions
+1 -1
View File
@@ -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');
});
});
+56 -16
View File
@@ -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
);
}
+45
View File
@@ -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;
}