3 Commits
v0.1.0 ... main

Author SHA1 Message Date
5416a2df36 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
2025-12-22 12:16:22 +08:00
a9e998b603 chore: bump version to 0.1.1 2025-12-19 20:51:13 +08:00
75c26d479a feat(文件管理): 添加已打开文件状态标记及相应逻辑
引入hasOpenedFile状态标记,用于跟踪用户是否已打开或操作过文件
- 修改欢迎界面显示逻辑,仅在无内容且未打开文件时显示
- 调整保存/另存为/新建场景等操作的权限判断
- 更新标题栏显示逻辑,支持已打开文件但无内容的场景
- 在文件拖放、加载等操作时自动标记文件打开状态
2025-12-19 20:46:30 +08:00
11 changed files with 244 additions and 27 deletions

View File

@@ -1,7 +1,7 @@
{ {
"name": "refvroid", "name": "refvroid",
"private": true, "private": true,
"version": "0.1.0", "version": "0.1.1",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",

3
src-tauri/Cargo.lock generated
View File

@@ -3555,10 +3555,11 @@ dependencies = [
[[package]] [[package]]
name = "refvroid" name = "refvroid"
version = "0.1.0" version = "0.1.1"
dependencies = [ dependencies = [
"arboard", "arboard",
"base64 0.22.1", "base64 0.22.1",
"flate2",
"image", "image",
"serde", "serde",
"serde_json", "serde_json",

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "refvroid" name = "refvroid"
version = "0.1.0" version = "0.1.1"
description = "RefVroid - Animation Reference Desktop App" description = "RefVroid - Animation Reference Desktop App"
authors = ["you"] authors = ["you"]
edition = "2021" edition = "2021"
@@ -28,6 +28,7 @@ serde_json = "1"
base64 = "0.22" base64 = "0.22"
image = "0.25" image = "0.25"
arboard = "3" arboard = "3"
flate2 = "1.0" # zlib 压缩
[target.'cfg(windows)'.dependencies] [target.'cfg(windows)'.dependencies]
windows = { version = "0.58", features = [ windows = { version = "0.58", features = [

View File

@@ -2,8 +2,12 @@
// Handles scene save/load and file reading operations // Handles scene save/load and file reading operations
use std::fs; use std::fs;
use std::io::{Read, Write};
use std::path::Path; use std::path::Path;
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64}; 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 serde::{Deserialize, Serialize};
use tauri_plugin_dialog::{DialogExt, FilePath}; use tauri_plugin_dialog::{DialogExt, FilePath};
use tauri::AppHandle; use tauri::AppHandle;
@@ -15,16 +19,51 @@ pub struct FileDialogResult {
pub cancelled: bool, 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] #[tauri::command]
pub async fn save_scene(path: String, data: String) -> Result<(), String> { 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] #[tauri::command]
pub async fn load_scene(path: String) -> Result<String, String> { 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 /// Read a file and return its contents as base64

View File

@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "RefVroid", "productName": "RefVroid",
"version": "0.1.0", "version": "0.1.1",
"identifier": "com.refvroid.app", "identifier": "com.refvroid.app",
"build": { "build": {
"beforeDevCommand": "pnpm dev", "beforeDevCommand": "pnpm dev",
@@ -25,7 +25,8 @@
], ],
"security": { "security": {
"csp": null "csp": null
} },
"withGlobalTauri": false
}, },
"bundle": { "bundle": {
"active": true, "active": true,

View File

@@ -59,6 +59,7 @@ const state = reactive({
showOpenSceneDialog: false, showOpenSceneDialog: false,
pendingScenePath: null as string | null, pendingScenePath: null as string | null,
pendingAction: null as 'close' | 'openScene' | null, pendingAction: null as 'close' | 'openScene' | null,
hasOpenedFile: false, // 标记是否已打开过文件或添加过内容
}); });
// Computed title // Computed title
@@ -71,6 +72,12 @@ const hasContent = computed(() => {
return sceneManager.elements.value.length > 0; return sceneManager.elements.value.length > 0;
}); });
// Check if should show welcome overlay
// Only show when: no content AND no file has been opened/used
const showWelcome = computed(() => {
return !hasContent.value && !state.hasOpenedFile;
});
// Selection box state // Selection box state
const selectionBox = reactive({ const selectionBox = reactive({
visible: false, visible: false,
@@ -125,7 +132,9 @@ function getContextMenuItems(): MenuItem[] {
items.push({ label: '', action: () => {}, separator: true }); items.push({ label: '', action: () => {}, separator: true });
// File operations // File operations
const canSave = hasContent.value; // 允许保存:有内容 或 已打开文件(可以保存空文件)
const canSave = hasContent.value || state.hasOpenedFile;
const canCreateNew = hasContent.value || state.hasOpenedFile;
items.push( items.push(
{ {
@@ -148,7 +157,7 @@ function getContextMenuItems(): MenuItem[] {
action: () => handleNew(), action: () => handleNew(),
shortcut: 'Ctrl+N', shortcut: 'Ctrl+N',
checked: false, checked: false,
disabled: !canSave, disabled: !canCreateNew,
} }
); );
@@ -212,6 +221,9 @@ async function handleCanvasInitialized() {
try { try {
const loaded = await sceneService.loadOnStartup(); const loaded = await sceneService.loadOnStartup();
if (loaded) { if (loaded) {
// 标记已打开文件
state.hasOpenedFile = true;
// Sync renderer and view transform after loading // Sync renderer and view transform after loading
canvasViewRef.value?.syncRendererElements(); canvasViewRef.value?.syncRendererElements();
canvasViewRef.value?.syncViewTransform(); canvasViewRef.value?.syncViewTransform();
@@ -305,6 +317,9 @@ async function handleTauriFileDrop(paths: string[], dropX: number, dropY: number
// Handle image files // Handle image files
if (imageFiles.length === 0) return; if (imageFiles.length === 0) return;
// 标记已开始使用场景,防止删除元素后显示欢迎界面
state.hasOpenedFile = true;
// Record state for undo // Record state for undo
const currentState = sceneManager.getState(); const currentState = sceneManager.getState();
historyManager.record(currentState.elements, currentState.viewTransform); historyManager.record(currentState.elements, currentState.viewTransform);
@@ -335,6 +350,9 @@ async function loadSceneFromPath(path: string) {
try { try {
const success = await sceneService.loadFromPath(path); const success = await sceneService.loadFromPath(path);
if (success) { if (success) {
// 标记已打开文件,防止删除元素后显示欢迎界面
state.hasOpenedFile = true;
canvasViewRef.value?.syncRendererElements(); canvasViewRef.value?.syncRendererElements();
canvasViewRef.value?.syncViewTransform(); canvasViewRef.value?.syncViewTransform();
canvasViewRef.value?.getRenderer()?.requestRender(); canvasViewRef.value?.getRenderer()?.requestRender();
@@ -355,8 +373,12 @@ async function loadSceneFromPath(path: string) {
async function handleConfirmOpenScene() { async function handleConfirmOpenScene() {
state.showOpenSceneDialog = false; state.showOpenSceneDialog = false;
if (state.pendingScenePath) { if (state.pendingScenePath) {
// 从指定路径加载(拖放文件)
await loadSceneFromPath(state.pendingScenePath); await loadSceneFromPath(state.pendingScenePath);
state.pendingScenePath = null; state.pendingScenePath = null;
} else {
// 通过对话框选择文件Ctrl+L
await performLoad();
} }
} }
@@ -553,8 +575,8 @@ function handleGlobalAction(action: string, data: unknown) {
// ============================================ // ============================================
async function handleSave() { async function handleSave() {
// Don't save if no content // 允许保存:有内容 或 已打开文件(可以保存空文件)
if (!hasContent.value) return; if (!hasContent.value && !state.hasOpenedFile) return;
try { try {
const success = await sceneService.save(); const success = await sceneService.save();
@@ -567,8 +589,8 @@ async function handleSave() {
} }
async function handleSaveAs() { async function handleSaveAs() {
// Don't save if no content // 允许另存为:有内容 或 已打开文件(可以保存空文件)
if (!hasContent.value) return; if (!hasContent.value && !state.hasOpenedFile) return;
try { try {
const success = await sceneService.saveAs(); const success = await sceneService.saveAs();
@@ -581,6 +603,29 @@ async function handleSaveAs() {
} }
async function handleLoad() { async function handleLoad() {
// 如果有未保存的更改,显示提示对话框
if (state.isDirty && (hasContent.value || state.hasOpenedFile)) {
state.pendingAction = 'openScene';
state.pendingScenePath = null; // null 表示通过对话框选择文件
state.showUnsavedDialog = true;
return;
}
// 如果有内容但没有未保存更改,显示确认对话框
if (hasContent.value || state.hasOpenedFile) {
state.pendingScenePath = null;
state.showOpenSceneDialog = true;
return;
}
// 没有内容,直接打开
await performLoad();
}
/**
* 实际执行加载操作
*/
async function performLoad() {
try { try {
// Record state for undo before loading // Record state for undo before loading
const currentState = sceneManager.getState(); const currentState = sceneManager.getState();
@@ -588,6 +633,9 @@ async function handleLoad() {
const success = await sceneService.load(); const success = await sceneService.load();
if (success) { if (success) {
// 标记已打开文件,防止显示欢迎界面
state.hasOpenedFile = true;
// Sync renderer and view transform after loading // Sync renderer and view transform after loading
canvasViewRef.value?.syncRendererElements(); canvasViewRef.value?.syncRendererElements();
canvasViewRef.value?.syncViewTransform(); canvasViewRef.value?.syncViewTransform();
@@ -619,14 +667,18 @@ async function handleBrowseFiles() {
} }
function handleNew() { function handleNew() {
// Don't create new scene if already empty // Don't create new scene if already empty and no file opened
if (!hasContent.value) return; if (!hasContent.value && !state.hasOpenedFile) return;
// Record state for undo // Record state for undo if there's content
if (hasContent.value) {
const currentState = sceneManager.getState(); const currentState = sceneManager.getState();
historyManager.record(currentState.elements, currentState.viewTransform); historyManager.record(currentState.elements, currentState.viewTransform);
}
sceneService.newScene(); sceneService.newScene();
// 重置标志,允许显示欢迎界面
state.hasOpenedFile = false;
canvasViewRef.value?.syncRendererElements(); canvasViewRef.value?.syncRendererElements();
} }
@@ -1005,8 +1057,14 @@ async function executePendingAction() {
if (action === 'close') { if (action === 'close') {
await forceCloseWindow(); await forceCloseWindow();
} else if (action === 'openScene' && scenePath) { } else if (action === 'openScene') {
if (scenePath) {
// 从指定路径加载(拖放文件)
await loadSceneFromPath(scenePath); await loadSceneFromPath(scenePath);
} else {
// 通过对话框选择文件Ctrl+L
await performLoad();
}
} }
} }
@@ -1140,6 +1198,7 @@ onUnmounted(() => {
:always-on-top="state.alwaysOnTop" :always-on-top="state.alwaysOnTop"
:is-dirty="state.isDirty" :is-dirty="state.isDirty"
:has-content="hasContent" :has-content="hasContent"
:has-opened-file="state.hasOpenedFile"
@minimize="minimizeWindow" @minimize="minimizeWindow"
@maximize="toggleFullscreen" @maximize="toggleFullscreen"
@close="closeWindow" @close="closeWindow"
@@ -1156,9 +1215,10 @@ onUnmounted(() => {
@file-drop="handleFileDrop" @file-drop="handleFileDrop"
/> />
<!-- Welcome Overlay (when scene is empty) --> <!-- Welcome Overlay (when scene is empty and no file opened) -->
<WelcomeOverlay <WelcomeOverlay
v-if="state.isInitialized && !hasContent" v-if="state.isInitialized && showWelcome"
:key="'welcome-' + hasContent + '-' + state.hasOpenedFile"
@browse="handleBrowseFiles" @browse="handleBrowseFiles"
@help="state.showHelp = true" @help="state.showHelp = true"
/> />

View File

@@ -502,6 +502,7 @@ async function handleDelete() {
if (sceneManager.getSelectedCount() > 0) { if (sceneManager.getSelectedCount() > 0) {
recordState(); recordState();
sceneManager.deleteSelected(); sceneManager.deleteSelected();
sceneService.markDirty();
await syncRendererElements(); await syncRendererElements();
// Force render update // Force render update
renderer?.requestRender(); renderer?.requestRender();

View File

@@ -10,6 +10,7 @@ const props = defineProps<{
alwaysOnTop?: boolean; alwaysOnTop?: boolean;
isDirty?: boolean; isDirty?: boolean;
hasContent?: boolean; hasContent?: boolean;
hasOpenedFile?: boolean;
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
@@ -133,7 +134,7 @@ onUnmounted(() => {
@mousedown="handleDragStart" @mousedown="handleDragStart"
> >
<span class="title-bar__title"> <span class="title-bar__title">
<template v-if="hasContent">{{ isDirty ? '*' : '' }}{{ title || 'Untitled' }}</template> <template v-if="hasContent || hasOpenedFile">{{ isDirty ? '*' : '' }}{{ title || 'Untitled' }}</template>
</span> </span>
</div> </div>

View File

@@ -203,6 +203,7 @@ export function deserializeScene(scene: Scene): {
/** /**
* Convert scene to JSON string for saving * Convert scene to JSON string for saving
* 使用紧凑格式减少内存占用
*/ */
export function sceneToJson(manager: SceneManager): string { export function sceneToJson(manager: SceneManager): string {
const sceneFile: SceneFile = { const sceneFile: SceneFile = {
@@ -211,7 +212,8 @@ export function sceneToJson(manager: SceneManager): string {
scene: serializeScene(manager), scene: serializeScene(manager),
}; };
return JSON.stringify(sceneFile, null, 2); // 使用紧凑格式(不带缩进)减少内存占用
return JSON.stringify(sceneFile);
} }
/** /**

View File

@@ -373,3 +373,110 @@ export async function loadImageFromClipboard(): Promise<ImageBitmap | null> {
return 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);
}

View File

@@ -144,8 +144,9 @@ export class SceneService {
* Save scene to specific path * Save scene to specific path
*/ */
async saveToPath(path: string): Promise<boolean> { async saveToPath(path: string): Promise<boolean> {
let json: string | null = null;
try { try {
const json = sceneToJson(this.sceneManager); json = sceneToJson(this.sceneManager);
await invoke('save_scene', { path, data: json }); await invoke('save_scene', { path, data: json });
this.setCurrentFilePath(path); this.setCurrentFilePath(path);
@@ -158,6 +159,9 @@ export class SceneService {
} catch (error) { } catch (error) {
console.error('Failed to save scene:', error); console.error('Failed to save scene:', error);
return false; return false;
} finally {
// 清理临时变量,帮助垃圾回收
json = null;
} }
} }