diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 7dcd8f7..2e0dae7 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -3559,6 +3559,7 @@ version = "0.1.1" dependencies = [ "arboard", "base64 0.22.1", + "flate2", "image", "serde", "serde_json", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 112b163..8c6031b 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -28,6 +28,7 @@ serde_json = "1" base64 = "0.22" image = "0.25" arboard = "3" +flate2 = "1.0" # zlib 压缩 [target.'cfg(windows)'.dependencies] windows = { version = "0.58", features = [ diff --git a/src-tauri/src/commands/file.rs b/src-tauri/src/commands/file.rs index f517861..5f2be4c 100644 --- a/src-tauri/src/commands/file.rs +++ b/src-tauri/src/commands/file.rs @@ -2,8 +2,12 @@ // Handles scene save/load and file reading operations use std::fs; +use std::io::{Read, Write}; use std::path::Path; use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64}; +use flate2::Compression; +use flate2::read::ZlibDecoder; +use flate2::write::ZlibEncoder; use serde::{Deserialize, Serialize}; use tauri_plugin_dialog::{DialogExt, FilePath}; use tauri::AppHandle; @@ -15,16 +19,51 @@ pub struct FileDialogResult { pub cancelled: bool, } -/// Save scene data to a file +/// 文件魔数,用于识别压缩格式 +const COMPRESSED_MAGIC: &[u8] = b"PURGIFZ1"; + +/// Save scene data to a file (with zlib compression) #[tauri::command] pub async fn save_scene(path: String, data: String) -> Result<(), String> { - fs::write(&path, data).map_err(|e| format!("Failed to save scene: {}", e)) + // 使用 zlib 压缩数据 + let mut encoder = ZlibEncoder::new(Vec::new(), Compression::default()); + encoder.write_all(data.as_bytes()) + .map_err(|e| format!("Failed to compress data: {}", e))?; + let compressed = encoder.finish() + .map_err(|e| format!("Failed to finish compression: {}", e))?; + + // 写入魔数 + 压缩数据 + let mut output = Vec::with_capacity(COMPRESSED_MAGIC.len() + compressed.len()); + output.extend_from_slice(COMPRESSED_MAGIC); + output.extend_from_slice(&compressed); + + let output_len = output.len(); + fs::write(&path, output).map_err(|e| format!("Failed to save scene: {}", e))?; + + println!("Scene saved: {} bytes -> {} bytes (compressed)", data.len(), output_len); + Ok(()) } -/// Load scene data from a file +/// Load scene data from a file (supports both compressed and uncompressed) #[tauri::command] pub async fn load_scene(path: String) -> Result { - fs::read_to_string(&path).map_err(|e| format!("Failed to load scene: {}", e)) + let data = fs::read(&path).map_err(|e| format!("Failed to read scene: {}", e))?; + + // 检查是否是压缩格式 + if data.starts_with(COMPRESSED_MAGIC) { + // 解压缩 + let compressed = &data[COMPRESSED_MAGIC.len()..]; + let mut decoder = ZlibDecoder::new(compressed); + let mut decompressed = String::new(); + decoder.read_to_string(&mut decompressed) + .map_err(|e| format!("Failed to decompress data: {}", e))?; + + println!("Scene loaded: {} bytes -> {} bytes (decompressed)", data.len(), decompressed.len()); + Ok(decompressed) + } else { + // 旧格式,直接读取为字符串 + String::from_utf8(data).map_err(|e| format!("Failed to parse scene: {}", e)) + } } /// Read a file and return its contents as base64 diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 5b63b7d..a679494 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -25,7 +25,8 @@ ], "security": { "csp": null - } + }, + "withGlobalTauri": false }, "bundle": { "active": true, diff --git a/src/core/scene/serializer.ts b/src/core/scene/serializer.ts index 419f3c2..01d3234 100644 --- a/src/core/scene/serializer.ts +++ b/src/core/scene/serializer.ts @@ -203,6 +203,7 @@ export function deserializeScene(scene: Scene): { /** * Convert scene to JSON string for saving + * 使用紧凑格式减少内存占用 */ export function sceneToJson(manager: SceneManager): string { const sceneFile: SceneFile = { @@ -211,7 +212,8 @@ export function sceneToJson(manager: SceneManager): string { scene: serializeScene(manager), }; - return JSON.stringify(sceneFile, null, 2); + // 使用紧凑格式(不带缩进)减少内存占用 + return JSON.stringify(sceneFile); } /** diff --git a/src/utils/image.ts b/src/utils/image.ts index 928a6b2..457f12e 100644 --- a/src/utils/image.ts +++ b/src/utils/image.ts @@ -373,3 +373,110 @@ export async function loadImageFromClipboard(): Promise { return null; } } + + +// ============================================ +// Image Compression for Memory Optimization +// ============================================ + +/** + * 默认最大图片尺寸(像素) + * 超过此尺寸的图片会被自动缩小 + */ +export const DEFAULT_MAX_IMAGE_SIZE = 2048; + +/** + * 压缩图片配置 + */ +export interface CompressOptions { + maxWidth?: number; + maxHeight?: number; + quality?: number; // 0-1, JPEG/WebP 质量 + format?: 'image/jpeg' | 'image/png' | 'image/webp'; +} + +/** + * 压缩图片到指定尺寸和质量 + * @param base64DataUrl 原始 base64 图片 + * @param options 压缩选项 + * @returns 压缩后的 base64 图片 + */ +export async function compressImage( + base64DataUrl: string, + options: CompressOptions = {} +): Promise { + const { + maxWidth = DEFAULT_MAX_IMAGE_SIZE, + maxHeight = DEFAULT_MAX_IMAGE_SIZE, + quality = 0.85, + format, + } = options; + + // 加载图片 + const blob = base64ToBlob(base64DataUrl); + const bitmap = await createImageBitmap(blob); + + const { width, height } = bitmap; + + // 计算缩放比例 + const scale = Math.min(maxWidth / width, maxHeight / height, 1); + + // 如果不需要缩放且不需要转换格式,直接返回原图 + if (scale >= 1 && !format) { + bitmap.close(); + return base64DataUrl; + } + + const newWidth = Math.round(width * scale); + const newHeight = Math.round(height * scale); + + // 创建 canvas 进行压缩 + const canvas = document.createElement('canvas'); + canvas.width = newWidth; + canvas.height = newHeight; + + const ctx = canvas.getContext('2d'); + if (!ctx) { + bitmap.close(); + throw new Error('Failed to get 2D context'); + } + + // 使用高质量缩放 + ctx.imageSmoothingEnabled = true; + ctx.imageSmoothingQuality = 'high'; + ctx.drawImage(bitmap, 0, 0, newWidth, newHeight); + + bitmap.close(); + + // 确定输出格式 + const originalMimeType = getMimeTypeFromBase64(base64DataUrl); + const outputFormat = format || (originalMimeType === 'image/png' ? 'image/png' : 'image/jpeg'); + + // 转换为 base64 + const result = canvas.toDataURL(outputFormat, quality); + + console.log(`Image compressed: ${width}x${height} -> ${newWidth}x${newHeight}, size: ${Math.round(base64DataUrl.length / 1024)}KB -> ${Math.round(result.length / 1024)}KB`); + + return result; +} + +/** + * 检查图片是否需要压缩 + */ +export async function needsCompression( + base64DataUrl: string, + maxWidth: number = DEFAULT_MAX_IMAGE_SIZE, + maxHeight: number = DEFAULT_MAX_IMAGE_SIZE +): Promise { + const info = await getImageInfoFromBase64(base64DataUrl); + return info.width > maxWidth || info.height > maxHeight; +} + +/** + * 获取图片大小(字节) + */ +export function getBase64Size(base64DataUrl: string): number { + // base64 编码后大小约为原始大小的 4/3 + const base64Content = base64DataUrl.split(',')[1] || ''; + return Math.round(base64Content.length * 0.75); +} diff --git a/src/utils/scene-service.ts b/src/utils/scene-service.ts index 878eda5..c18abd4 100644 --- a/src/utils/scene-service.ts +++ b/src/utils/scene-service.ts @@ -144,8 +144,9 @@ export class SceneService { * Save scene to specific path */ async saveToPath(path: string): Promise { + let json: string | null = null; try { - const json = sceneToJson(this.sceneManager); + json = sceneToJson(this.sceneManager); await invoke('save_scene', { path, data: json }); this.setCurrentFilePath(path); @@ -158,6 +159,9 @@ export class SceneService { } catch (error) { console.error('Failed to save scene:', error); return false; + } finally { + // 清理临时变量,帮助垃圾回收 + json = null; } }