Files
pureref-tauri/src/core/scene/serializer.ts
小煜 f5b8a563cb feat: 初始化项目基础架构和核心功能
添加项目基础文件结构,包括Tauri配置、前端框架和核心功能模块
实现WebGPU渲染器基础、纹理管理和着色器
添加UI组件包括标题栏、确认对话框、帮助窗口等
设置状态管理存储和工具函数
配置构建工具和开发环境
2025-12-19 16:22:27 +08:00

414 lines
10 KiB
TypeScript

/**
* Scene Serialization/Deserialization
* Implements .purgif file format compatibility
*/
import type {
Scene,
SerializedElement,
MediaElement as IMediaElement,
ElementType,
ViewTransform,
ImageData,
GifData,
VideoData,
TextData,
YouTubeData,
} from '../../types';
import { MediaElement } from './element';
import { SceneManager } from './index';
// ============================================
// Constants
// ============================================
const SCENE_VERSION = '1.0.0';
const PURGIF_MAGIC = 'PURGIF'; // File format identifier
// ============================================
// Scene File Format
// ============================================
export interface SceneFile {
magic: string;
version: string;
scene: Scene;
}
// ============================================
// Serialization Functions
// ============================================
/**
* Serialize a MediaElement to SerializedElement format
*/
export function serializeElement(element: IMediaElement): SerializedElement {
const serialized: SerializedElement = {
type: element.type,
path: '',
x: element.x,
y: element.y,
width: element.width,
height: element.height,
};
switch (element.type) {
case 'image':
case 'gif': {
const imgData = element.data as ImageData | GifData;
serialized.path = imgData.src; // base64 data URL
break;
}
case 'video': {
const videoData = element.data as VideoData;
serialized.path = videoData.src;
serialized.loopPairs = [[videoData.loopStart, videoData.loopEnd]];
serialized.activeLoopPair = 0;
break;
}
case 'text': {
const textData = element.data as TextData;
// Encode text content as base64 to handle special characters
serialized.path = `text:${btoa(encodeURIComponent(textData.content))}`;
break;
}
case 'youtube': {
const ytData = element.data as YouTubeData;
serialized.path = `youtube:${ytData.videoId}`;
serialized.loopPairs = [[ytData.loopStart, ytData.loopEnd]];
serialized.activeLoopPair = 0;
break;
}
}
return serialized;
}
/**
* Deserialize a SerializedElement to MediaElement
*/
export function deserializeElement(serialized: SerializedElement): MediaElement {
let data;
switch (serialized.type) {
case 'image': {
data = {
src: serialized.path,
originalWidth: serialized.width,
originalHeight: serialized.height,
} as ImageData;
break;
}
case 'gif': {
data = {
src: serialized.path,
originalWidth: serialized.width,
originalHeight: serialized.height,
frames: [], // Will be populated when loading
frameDelays: [],
currentFrame: 0,
} as GifData;
break;
}
case 'video': {
const loopPair = serialized.loopPairs?.[serialized.activeLoopPair ?? 0] ?? [0, 100];
data = {
src: serialized.path,
loopStart: loopPair[0],
loopEnd: loopPair[1],
muted: true,
} as VideoData;
break;
}
case 'text': {
// Decode text content from base64
const content = serialized.path.startsWith('text:')
? decodeURIComponent(atob(serialized.path.slice(5)))
: serialized.path;
data = {
content,
fontSize: 16,
color: '#ffffff',
} as TextData;
break;
}
case 'youtube': {
const videoId = serialized.path.startsWith('youtube:')
? serialized.path.slice(8)
: serialized.path;
const loopPair = serialized.loopPairs?.[serialized.activeLoopPair ?? 0] ?? [0, 100];
data = {
videoId,
loopStart: loopPair[0],
loopEnd: loopPair[1],
} as YouTubeData;
break;
}
default:
throw new Error(`Unknown element type: ${serialized.type}`);
}
return new MediaElement(serialized.type, data, {
x: serialized.x,
y: serialized.y,
width: serialized.width,
height: serialized.height,
});
}
/**
* Serialize a SceneManager state to Scene format
*/
export function serializeScene(manager: SceneManager): Scene {
const state = manager.getState();
const bounds = manager.getWorkspaceBounds();
return {
version: SCENE_VERSION,
currentScale: state.viewTransform.scale,
translate: {
translateX: state.viewTransform.translateX,
translateY: state.viewTransform.translateY,
},
workspaceRect: {
x1: bounds.minX,
y1: bounds.minY,
x2: bounds.maxX,
y2: bounds.maxY,
},
elements: state.elements.map(serializeElement),
};
}
/**
* Deserialize a Scene to SceneManager state
*/
export function deserializeScene(scene: Scene): {
elements: MediaElement[];
viewTransform: ViewTransform;
} {
return {
elements: scene.elements.map(deserializeElement),
viewTransform: {
scale: scene.currentScale,
translateX: scene.translate.translateX,
translateY: scene.translate.translateY,
},
};
}
// ============================================
// File I/O Functions
// ============================================
/**
* Convert scene to JSON string for saving
*/
export function sceneToJson(manager: SceneManager): string {
const sceneFile: SceneFile = {
magic: PURGIF_MAGIC,
version: SCENE_VERSION,
scene: serializeScene(manager),
};
return JSON.stringify(sceneFile, null, 2);
}
/**
* Parse JSON string to scene data
*/
export function jsonToScene(json: string): {
elements: MediaElement[];
viewTransform: ViewTransform;
} {
const sceneFile = JSON.parse(json) as SceneFile;
// Validate file format
if (sceneFile.magic !== PURGIF_MAGIC) {
throw new Error('Invalid file format: not a valid .purgif file');
}
return deserializeScene(sceneFile.scene);
}
/**
* Load scene from JSON and apply to SceneManager
*/
export function loadSceneFromJson(manager: SceneManager, json: string): void {
const { elements, viewTransform } = jsonToScene(json);
manager.clear();
for (const element of elements) {
manager.addExistingElement(element);
}
manager.setViewTransform(viewTransform);
}
// ============================================
// Base64 Utilities
// ============================================
/**
* Convert ArrayBuffer to base64 string
*/
export function arrayBufferToBase64(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer);
let binary = '';
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary);
}
/**
* Convert base64 string to ArrayBuffer
*/
export function base64ToArrayBuffer(base64: string): ArrayBuffer {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes.buffer;
}
/**
* Convert base64 string to Blob
*/
export function base64ToBlob(base64: string, mimeType: string): Blob {
const buffer = base64ToArrayBuffer(base64);
return new Blob([buffer], { type: mimeType });
}
/**
* Convert Blob to base64 data URL
*/
export async function blobToBase64DataUrl(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = reject;
reader.readAsDataURL(blob);
});
}
/**
* Convert File to base64 data URL
*/
export async function fileToBase64DataUrl(file: File): Promise<string> {
return blobToBase64DataUrl(file);
}
/**
* Extract base64 data from data URL
*/
export function extractBase64FromDataUrl(dataUrl: string): string {
const match = dataUrl.match(/^data:[^;]+;base64,(.+)$/);
if (!match) {
throw new Error('Invalid data URL format');
}
return match[1];
}
/**
* Get MIME type from data URL
*/
export function getMimeTypeFromDataUrl(dataUrl: string): string {
const match = dataUrl.match(/^data:([^;]+);base64,/);
if (!match) {
throw new Error('Invalid data URL format');
}
return match[1];
}
/**
* Create data URL from base64 and MIME type
*/
export function createDataUrl(base64: string, mimeType: string): string {
return `data:${mimeType};base64,${base64}`;
}
// ============================================
// Image Processing Utilities
// ============================================
/**
* Load image from data URL and get dimensions
*/
export async function loadImageFromDataUrl(
dataUrl: string
): Promise<{ image: HTMLImageElement; width: number; height: number }> {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => {
resolve({
image: img,
width: img.naturalWidth,
height: img.naturalHeight,
});
};
img.onerror = reject;
img.src = dataUrl;
});
}
/**
* Create ImageBitmap from data URL
*/
export async function createImageBitmapFromDataUrl(
dataUrl: string
): Promise<ImageBitmap> {
const blob = base64ToBlob(
extractBase64FromDataUrl(dataUrl),
getMimeTypeFromDataUrl(dataUrl)
);
return createImageBitmap(blob);
}
// ============================================
// Validation Functions
// ============================================
/**
* Validate scene file structure
*/
export function validateSceneFile(data: unknown): data is SceneFile {
if (typeof data !== 'object' || data === null) return false;
const file = data as Record<string, unknown>;
if (file.magic !== PURGIF_MAGIC) return false;
if (typeof file.version !== 'string') return false;
if (typeof file.scene !== 'object' || file.scene === null) return false;
const scene = file.scene as Record<string, unknown>;
if (typeof scene.currentScale !== 'number') return false;
if (typeof scene.translate !== 'object') return false;
if (!Array.isArray(scene.elements)) return false;
return true;
}
/**
* Validate element type
*/
export function isValidElementType(type: string): type is ElementType {
return ['image', 'gif', 'video', 'text', 'youtube'].includes(type);
}
// ============================================
// Migration Functions
// ============================================
/**
* Migrate old scene format to current version
*/
export function migrateScene(sceneFile: SceneFile): SceneFile {
// Currently no migrations needed
// Add version-specific migrations here as needed
return sceneFile;
}