feat: 初始化项目基础架构和核心功能
添加项目基础文件结构,包括Tauri配置、前端框架和核心功能模块 实现WebGPU渲染器基础、纹理管理和着色器 添加UI组件包括标题栏、确认对话框、帮助窗口等 设置状态管理存储和工具函数 配置构建工具和开发环境
This commit is contained in:
413
src/core/scene/serializer.ts
Normal file
413
src/core/scene/serializer.ts
Normal file
@@ -0,0 +1,413 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
Reference in New Issue
Block a user