/** * 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 { 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 { 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 { 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; 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; 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; }