添加项目基础文件结构,包括Tauri配置、前端框架和核心功能模块 实现WebGPU渲染器基础、纹理管理和着色器 添加UI组件包括标题栏、确认对话框、帮助窗口等 设置状态管理存储和工具函数 配置构建工具和开发环境
414 lines
10 KiB
TypeScript
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;
|
|
}
|