import { readAssetBytes } from '../../services/assetReadUrlService'; import type { CanvasAssetExportMetadata, CanvasGenerationInputField, CanvasLayer, CanvasMediaType, } from './ImageCanvasEditorTypes'; import { DEFAULT_ICON_DESCRIPTIONS, formatEditorGenerationInputFieldValue, formatGenerationInputValue, formatLayerImageType, getEditorLayerModelDisplayName, isEditorUserVisibleGenerationInputField, UI_DESIGN_ASSET_EXTRACTION_PROMPT, } from './ImageCanvasGenerationModel'; 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'); const hour = String(date.getHours()).padStart(2, '0'); const minute = String(date.getMinutes()).padStart(2, '0'); const second = String(date.getSeconds()).padStart(2, '0'); return `${year}${month}${day}-${hour}${minute}${second}`; } export function getLayerExportKey(layer: CanvasLayer) { if (layer.mediaType === 'image-sequence') { if (layer.taskId) { return `image-sequence:task:${layer.taskId}`; } if (layer.sourceResourceId) { return `image-sequence:resource:${layer.sourceResourceId}`; } const frameIdentities = layer.imageSequenceFrames?.map((frame) => frame.assetObjectId ? `asset-object:${frame.assetObjectId}` : frame.objectKey ? `object-key:${frame.objectKey}` : `source:${frame.imageSrc}`, ); return frameIdentities?.length ? `image-sequence:frames:${JSON.stringify(frameIdentities)}` : `image-sequence:source:${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; }) { 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, }); } export const readLayerImageBlob = readLayerAssetBlob; export function getLayerImageSequenceFrames(layer: CanvasLayer) { return layer.imageSequenceFrames ?? []; } export async function readLayerImageSequenceFrameBlob( _layer: CanvasLayer, frame: ReturnType[number], ) { return readAssetSourceBlob({ source: frame.imageSrc, objectKey: frame.objectKey, }); } export function getImageSequenceFrameFileName( frame: ReturnType[number], index: number, blobType = '', ) { const extension = getImageExtensionFromTypeOrSrc(blobType, frame.imageSrc); const frameNumber = String(index + 1).padStart(2, '0'); return `frame-${frameNumber}.${extension}`; } export type ExportedImageSequenceFrame = { 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.imageSequenceDurationMs === 'number' && layer.imageSequenceDurationMs > 0 ? layer.imageSequenceDurationMs / 1_000 : 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((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 = formatGenerationInputValue(field.value).trim(); const builtInValues = BUILT_IN_GENERATION_INPUT_VALUES_BY_TITLE.get(normalizedTitle); return ( !isEditorUserVisibleGenerationInputField(field) || 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: formatEditorGenerationInputFieldValue(field), })) ?? []; const references = layer.generationInputs?.references.map((reference) => ({ title: reference.title, label: reference.label?.trim() || '参考素材', 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: getEditorLayerModelDisplayName(layer.model), task: formatTaskIdForDisplay(layer.taskId), object: layer.objectKey ?? layer.assetObjectId ?? '-', }; return layer.mediaType === 'audio' ? base : { ...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, }; }