feat(compression): add zlib compression for scene files and image optimization
- Add flate2 dependency for zlib compression support in Rust backend - Implement compressed scene file format with magic number (PURGIFZ1) for format detection - Add backward compatibility for loading uncompressed legacy scene files - Convert scene JSON to compact format (no indentation) to reduce file size - Add image compression utilities with configurable quality and dimensions - Implement image size detection and compression necessity checking - Add helper functions for base64 image size calculation and MIME type detection - Disable global Tauri object in web context for security - Reduce memory footprint for large scene files and high-resolution images through compression
This commit is contained in:
1
src-tauri/Cargo.lock
generated
1
src-tauri/Cargo.lock
generated
@@ -3559,6 +3559,7 @@ version = "0.1.1"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"base64 0.22.1",
|
||||
"flate2",
|
||||
"image",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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<String, String> {
|
||||
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
|
||||
|
||||
@@ -25,7 +25,8 @@
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
}
|
||||
},
|
||||
"withGlobalTauri": false
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -373,3 +373,110 @@ export async function loadImageFromClipboard(): Promise<ImageBitmap | null> {
|
||||
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<string> {
|
||||
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<boolean> {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -144,8 +144,9 @@ export class SceneService {
|
||||
* Save scene to specific path
|
||||
*/
|
||||
async saveToPath(path: string): Promise<boolean> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user