6277a823ba
新增动作图层导出二级菜单 序列帧导出改为ZIP并生成GIF预览 兼容历史项目封面资源字段
608 lines
17 KiB
TypeScript
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,
|
|
};
|
|
}
|