Files
Genarrative/src/components/image-editor/ImageCanvasExportModel.ts
T
kdletters 1176c71f72 角色动作素材改为序列帧
将角色动作结果图层主类型改为 image-sequence,并持久化完整帧列表与预览视频来源

在画布中增加序列帧播放器,展示透明 PNG 帧而不是预览视频

将角色动作单素材下载和画布素材导出改为序列帧 ZIP/frames 目录

更新角色动作、导出方案和项目记忆文档,补齐相关前端测试
2026-06-22 20:49:51 +08:00

272 lines
7.2 KiB
TypeScript

import type {
CanvasAssetExportMetadata,
CanvasMediaType,
CanvasLayer,
} from './ImageCanvasEditorTypes';
import {
getSignedAssetReadUrl,
resolveAssetReadUrl,
} from '../../services/assetReadUrlService';
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,
refreshKey,
}: {
source: string;
objectKey?: string | null;
refreshKey?: string | number | null;
}) {
if (source.startsWith('data:')) {
return dataUrlToBlob(source);
}
const resolvedSource = objectKey
? await getSignedAssetReadUrl({ objectKey }, undefined, {
cacheVersion: refreshKey,
})
: await resolveAssetReadUrl(source, { refreshKey });
const response = await fetch(resolvedSource);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
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,
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 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 buildLayerExportMetadata(
layer: CanvasLayer,
file: string | null,
exportError?: string,
): CanvasAssetExportMetadata['layers'][number] {
return {
layerId: layer.id,
title: layer.title,
file,
mediaType: layer.mediaType ?? 'image',
sourceType: layer.sourceType,
prompt: layer.prompt,
actualPrompt: layer.actualPrompt,
model: layer.model,
provider: layer.provider,
taskId: layer.taskId,
objectKey: layer.objectKey,
assetObjectId: layer.assetObjectId,
sourceResourceId: layer.sourceResourceId,
sourceAssetId: layer.sourceAssetId,
exportError,
canvas: {
x: layer.x,
y: layer.y,
width: layer.width,
height: layer.height,
originalWidth: layer.originalWidth,
originalHeight: layer.originalHeight,
zIndex: layer.zIndex,
groupId: layer.groupId,
hidden: layer.hidden,
locked: layer.locked,
flipX: layer.flipX,
flipY: layer.flipY,
},
};
}