Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5416a2df36 | |||
| a9e998b603 | |||
| 75c26d479a |
@@ -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
3
src-tauri/Cargo.lock
generated
@@ -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",
|
||||||
|
|||||||
@@ -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 = [
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
90
src/App.vue
90
src/App.vue
@@ -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
|
||||||
const currentState = sceneManager.getState();
|
if (hasContent.value) {
|
||||||
historyManager.record(currentState.elements, currentState.viewTransform);
|
const currentState = sceneManager.getState();
|
||||||
|
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') {
|
||||||
await loadSceneFromPath(scenePath);
|
if (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"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user