Files
Genarrative/src/components/image-editor/ImageCanvasExportModel.ts
T
kdletters 6277a823ba 调整动作导出与项目封面兼容
新增动作图层导出二级菜单

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

兼容历史项目封面资源字段
2026-06-25 19:58:12 +08:00

608 lines
17 KiB
TypeScript

import { readAssetBytes } from '../../services/assetReadUrlService';
import type {
CanvasAssetExportMetadata,
CanvasGenerationInputField,
CanvasLayer,
CanvasMediaType,
} from './ImageCanvasEditorTypes';
import {
DEFAULT_ICON_DESCRIPTIONS,
formatLayerImageType,
getEditorImageModelDisplayName,
UI_DESIGN_ASSET_EXTRACTION_PROMPT,
} from './ImageCanvasGenerationModel';
import { formatCanvasDurationMetric } from './ImageCanvasMediaModel';
export function sanitizeExportFilePart(value: string, fallback: string) {
const safeValue = value
.trim()
.replace(/[\\/:*?"<>|]+/gu, ' ')
.replace(/\s+/gu, ' ')
.trim();
return safeValue || fallback;
}
export function formatExportDate(date: Date) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}${month}${day}`;
}
export function getLayerExportKey(layer: CanvasLayer) {
if (layer.mediaType === 'image-sequence') {
return `image-sequence:${
layer.taskId ||
layer.sourceResourceId ||
layer.imageSequenceFrames?.map((frame) => frame.imageSrc).join('|') ||
layer.src
}`;
}
return (
layer.assetObjectId ||
layer.objectKey ||
layer.sourceAssetId ||
layer.sourceResourceId ||
layer.src
);
}
export function getImageExtensionFromTypeOrSrc(type: string, src: string) {
return getLayerAssetExtensionFromTypeOrSrc('image', type, src);
}
function getExtensionFromSrc(src: string) {
const withoutQuery = src.split(/[?#]/u)[0] ?? '';
const match = /\.([a-z0-9]+)$/iu.exec(withoutQuery);
return match?.[1]?.toLowerCase() ?? null;
}
function pickKnownExtension(
extension: string | null,
allowedExtensions: string[],
fallback: string,
) {
return extension && allowedExtensions.includes(extension)
? extension
: fallback;
}
export function getLayerAssetExtensionFromTypeOrSrc(
mediaType: CanvasMediaType | undefined,
type: string,
src: string,
) {
const normalizedType = type.toLowerCase();
const extensionFromSrc = getExtensionFromSrc(src);
if (mediaType === 'image-sequence') {
return 'zip';
}
if (mediaType === 'audio') {
if (normalizedType.includes('mpeg') || normalizedType.includes('mp3')) {
return 'mp3';
}
if (normalizedType.includes('wav')) {
return 'wav';
}
if (normalizedType.includes('ogg')) {
return 'ogg';
}
if (normalizedType.includes('aac')) {
return 'aac';
}
if (normalizedType.includes('flac')) {
return 'flac';
}
return pickKnownExtension(
extensionFromSrc,
['mp3', 'wav', 'ogg', 'aac', 'flac', 'm4a'],
'mp3',
);
}
if (mediaType === 'video') {
if (normalizedType.includes('mp4')) {
return 'mp4';
}
if (normalizedType.includes('webm')) {
return 'webm';
}
if (normalizedType.includes('quicktime')) {
return 'mov';
}
return pickKnownExtension(extensionFromSrc, ['mp4', 'webm', 'mov'], 'mp4');
}
if (
normalizedType.includes('jpeg') ||
extensionFromSrc === 'jpg' ||
extensionFromSrc === 'jpeg'
) {
return 'jpg';
}
if (normalizedType.includes('webp') || extensionFromSrc === 'webp') {
return 'webp';
}
if (normalizedType.includes('gif') || extensionFromSrc === 'gif') {
return 'gif';
}
if (normalizedType.includes('svg') || extensionFromSrc === 'svg') {
return 'svg';
}
return 'png';
}
export function dataUrlToBlob(dataUrl: string) {
const [header = '', payload = ''] = dataUrl.split(',');
const mimeMatch = /^data:([^;]+)(;base64)?$/iu.exec(header);
const type = mimeMatch?.[1] ?? 'application/octet-stream';
const isBase64 = Boolean(mimeMatch?.[2]);
const binary = isBase64
? typeof atob === 'function'
? atob(payload)
: Buffer.from(payload, 'base64').toString('binary')
: decodeURIComponent(payload);
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) {
bytes[index] = binary.charCodeAt(index);
}
return new Blob([bytes], { type });
}
export async function readAssetSourceBlob({
source,
objectKey,
}: {
source: string;
objectKey?: string | null;
refreshKey?: string | number | null;
}) {
if (source.startsWith('data:')) {
return dataUrlToBlob(source);
}
const response = await readAssetBytes(source, { objectKey });
return response.blob();
}
export async function readLayerAssetBlob(layer: CanvasLayer) {
return readAssetSourceBlob({
source: layer.src,
objectKey: layer.objectKey,
refreshKey: layer.taskId ?? layer.resourceId,
});
}
export const readLayerImageBlob = readLayerAssetBlob;
export function getLayerImageSequenceFrames(layer: CanvasLayer) {
if (layer.imageSequenceFrames?.length) {
return layer.imageSequenceFrames;
}
const fallbackSrc = layer.thumbnailSrc?.trim() || layer.src.trim();
return fallbackSrc
? [
{
frameIndex: 1,
imageSrc: fallbackSrc,
width: layer.originalWidth,
height: layer.originalHeight,
},
]
: [];
}
export async function readLayerImageSequenceFrameBlob(
layer: CanvasLayer,
frame: ReturnType<typeof getLayerImageSequenceFrames>[number],
) {
return readAssetSourceBlob({
source: frame.imageSrc,
objectKey: frame.objectKey,
refreshKey: `${layer.taskId ?? layer.resourceId}:${frame.frameIndex}`,
});
}
export function getImageSequenceFrameFileName(
frame: ReturnType<typeof getLayerImageSequenceFrames>[number],
index: number,
blobType = '',
) {
const extension = getImageExtensionFromTypeOrSrc(blobType, frame.imageSrc);
const frameNumber = String(frame.frameIndex || index + 1).padStart(2, '0');
return `frame-${frameNumber}.${extension}`;
}
export type ExportedImageSequenceFrame = {
frameIndex: number;
fileName: string;
width: number;
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';
const SPINE_SEQUENCE_ANIMATION_NAME = 'animation';
function stripFileExtension(fileName: string) {
return fileName.replace(/\.[^.]+$/u, '');
}
function roundSpineTime(value: number) {
return Number(value.toFixed(6));
}
export function buildSpineImageSequenceJson({
layer,
frames,
}: {
layer: CanvasLayer;
frames: ExportedImageSequenceFrame[];
}) {
if (!frames.length) {
throw new Error('序列帧为空');
}
const durationSeconds =
typeof layer.durationSeconds === 'number' && layer.durationSeconds > 0
? layer.durationSeconds
: frames.length;
const frameDuration = durationSeconds / frames.length;
const skeletonWidth = frames[0]?.width || layer.originalWidth;
const skeletonHeight = frames[0]?.height || layer.originalHeight;
const attachments = Object.fromEntries(
frames.map((frame, index) => {
const attachmentName = `${SPINE_SEQUENCE_ANIMATION_NAME}_${String(
index + 1,
).padStart(4, '0')}`;
return [
attachmentName,
{
type: 'region',
path: stripFileExtension(frame.fileName),
x: 0,
y: roundSpineTime(frame.height / 2),
width: frame.width,
height: frame.height,
},
];
}),
);
const timeline = frames.map((_frame, index) => ({
time: roundSpineTime(index * frameDuration),
name: `${SPINE_SEQUENCE_ANIMATION_NAME}_${String(index + 1).padStart(
4,
'0',
)}`,
}));
timeline.push({
time: roundSpineTime(durationSeconds),
name: `${SPINE_SEQUENCE_ANIMATION_NAME}_0001`,
});
return {
skeleton: {
spine: DEFAULT_SPINE_VERSION,
images: './frames/',
fps: Math.max(1, Math.round(frames.length / durationSeconds)),
width: skeletonWidth,
height: skeletonHeight,
},
bones: [{ name: SPINE_SEQUENCE_BONE_NAME }],
slots: [
{
name: SPINE_SEQUENCE_SLOT_NAME,
bone: SPINE_SEQUENCE_BONE_NAME,
attachment: `${SPINE_SEQUENCE_ANIMATION_NAME}_0001`,
},
],
skins: [
{
name: 'default',
attachments: {
[SPINE_SEQUENCE_SLOT_NAME]: attachments,
},
},
],
animations: {
[SPINE_SEQUENCE_ANIMATION_NAME]: {
slots: {
[SPINE_SEQUENCE_SLOT_NAME]: {
attachment: timeline,
},
},
},
},
};
}
export function buildSpineImageSequenceReadme(layer: CanvasLayer) {
return [
`素材:${layer.title}`,
'内容:Spine JSON 序列帧导入包',
'导入:在 Spine Editor 中导入 skeleton.json,并保持 frames/ 目录与 skeleton.json 同级。',
'说明:该 JSON 使用单 slot attachment timeline 播放序列帧,不包含拆件、骨骼绑定或 mesh 权重。',
].join('\n');
}
export async function blobToUint8Array(blob: Blob) {
if (typeof blob.arrayBuffer === 'function') {
return new Uint8Array(await blob.arrayBuffer());
}
return new Promise<Uint8Array>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const result = reader.result;
if (result instanceof ArrayBuffer) {
resolve(new Uint8Array(result));
return;
}
reject(new Error('Blob 读取失败'));
};
reader.onerror = () => reject(new Error('Blob 读取失败'));
reader.readAsArrayBuffer(blob);
});
}
export function formatTaskIdForDisplay(taskId?: string | null) {
const numericParts = taskId?.match(/\d+/gu);
return numericParts?.length
? (numericParts[numericParts.length - 1] ?? '-')
: '-';
}
const BUILT_IN_GENERATION_INPUT_TITLES = new Set(['提取提示词']);
const BUILT_IN_GENERATION_INPUT_VALUES_BY_TITLE = new Map(
Object.entries({
生成提示词: new Set(['AI 生成图片']),
修改要求: new Set(['修改当前图片']),
快速编辑提示词: new Set(['修改当前图片']),
重绘提示词: new Set(['修改当前图片']),
prompt: new Set(['游戏音效']),
gpt_description_prompt: new Set(['游戏背景音乐']),
素材描述: new Set([DEFAULT_ICON_DESCRIPTIONS.join('\n')]),
}),
);
function isBuiltInGenerationInputField(field: CanvasGenerationInputField) {
const normalizedTitle = field.title.trim();
const normalizedValue = field.value.trim();
const builtInValues =
BUILT_IN_GENERATION_INPUT_VALUES_BY_TITLE.get(normalizedTitle);
return (
BUILT_IN_GENERATION_INPUT_TITLES.has(normalizedTitle) ||
normalizedValue === UI_DESIGN_ASSET_EXTRACTION_PROMPT ||
Boolean(builtInValues?.has(normalizedValue))
);
}
function buildVisibleGenerationInputs(layer: CanvasLayer) {
const fields =
layer.generationInputs?.fields
.filter((field) => !isBuiltInGenerationInputField(field))
.map((field) => ({
title: field.title,
value: field.value,
})) ?? [];
const references =
layer.generationInputs?.references.map((reference) => ({
title: reference.title,
label: reference.label,
refType: reference.refType,
refId: reference.refId,
})) ?? [];
return fields.length || references.length ? { fields, references } : null;
}
export function buildLayerVisibleExportMetadata(layer: CanvasLayer) {
const base = {
type: formatLayerImageType(layer),
generationInputs: buildVisibleGenerationInputs(layer),
model: layer.model ? getEditorImageModelDisplayName(layer.model) : '-',
task: formatTaskIdForDisplay(layer.taskId),
object: layer.objectKey ?? layer.assetObjectId ?? '-',
};
if (layer.mediaType === 'audio') {
return {
...base,
duration: formatCanvasDurationMetric(layer.durationSeconds).replace(
/^时长\s*/u,
'',
),
};
}
return {
...base,
resolution: `${layer.originalWidth} x ${layer.originalHeight} px`,
};
}
export function buildLayerExportMetadata(
layer: CanvasLayer,
file: string | null,
exportError?: string,
): CanvasAssetExportMetadata['layers'][number] {
return {
title: layer.title,
file,
visible: buildLayerVisibleExportMetadata(layer),
exportError,
};
}