1176c71f72
将角色动作结果图层主类型改为 image-sequence,并持久化完整帧列表与预览视频来源 在画布中增加序列帧播放器,展示透明 PNG 帧而不是预览视频 将角色动作单素材下载和画布素材导出改为序列帧 ZIP/frames 目录 更新角色动作、导出方案和项目记忆文档,补齐相关前端测试
272 lines
7.2 KiB
TypeScript
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,
|
|
},
|
|
};
|
|
}
|