feat(project): optimize image imports and converter

- Optimize imported scene preview images (smart WebP/JPEG/PNG, preserve alpha, keep pixel size)

- Update converter to re-encode existing image assets with same algorithm

- Improve import/export progress overlay and reduce presentation slide stutter

Made-with: Cursor
This commit is contained in:
Ivan Fontosh
2026-04-23 17:59:57 +08:00
parent 1d051f8bf9
commit 8f8eef53c9
33 changed files with 3684 additions and 68 deletions
+174 -23
View File
@@ -25,7 +25,9 @@ import { getAppSemanticVersion } from '../versionInfo';
import { reconcileAssetFiles } from './assetPrune';
import { rmWithRetries } from './fsRetry';
import { optimizeImageBufferVisuallyLossless } from './optimizeImageImport.lib.mjs';
import { getLegacyProjectsRootDirs, getProjectsCacheRootDir, getProjectsRootDir } from './paths';
import { generateScenePreviewThumbnailBytes } from './scenePreviewThumbnail';
import { readProjectJsonFromZip, unzipToDir } from './yauzlProjectZip';
type ProjectIndexEntry = {
@@ -213,6 +215,44 @@ export class ZipProjectStore {
return project;
}
private async openProjectByIdWithProgress(
projectId: ProjectId,
onUnzipPercent: (pct: number) => void,
): Promise<Project> {
await this.ensureRoots();
// Mutations are persisted to cache immediately, but zip packing is debounced (queueSave).
// When switching projects we delete the cache and restore it from the zip, so flush pending saves first.
if (this.openProject) {
await this.saveNow();
}
this.projectSession += 1;
const list = await this.listProjects();
const entry = list.find((p) => p.id === projectId);
if (!entry) {
throw new Error('Project not found');
}
const zipPath = path.join(getProjectsRootDir(), entry.fileName);
const cacheDir = path.join(getProjectsCacheRootDir(), projectId);
await fs.rm(cacheDir, { recursive: true, force: true });
await fs.mkdir(cacheDir, { recursive: true });
await unzipToDir(zipPath, cacheDir, (done, total) => {
const pct = total > 0 ? Math.round((done / total) * 100) : 0;
onUnzipPercent(Math.max(0, Math.min(100, pct)));
});
const projectPath = path.join(cacheDir, 'project.json');
const projectRaw = await fs.readFile(projectPath, 'utf8');
const parsed = JSON.parse(projectRaw) as unknown as Project;
const project = normalizeProject(parsed);
const fileBaseName = entry.fileName.replace(/\.dnd\.zip$/iu, '');
project.meta.fileBaseName = project.meta.fileBaseName.trim().length
? project.meta.fileBaseName
: fileBaseName;
this.openProject = { id: projectId, zipPath, cacheDir, projectPath, project };
return project;
}
getOpenProject(): Project | null {
return this.openProject?.project ?? null;
}
@@ -245,35 +285,78 @@ export class ZipProjectStore {
const sc = open.project.scenes[sceneId];
if (!sc) throw new Error('Scene not found');
const kind = classifyMediaPath(filePath);
if (kind?.type !== 'image' && kind?.type !== 'video') {
const kind0 = classifyMediaPath(filePath);
if (!kind0 || (kind0.type !== 'image' && kind0.type !== 'video')) {
throw new Error('Файл превью должен быть изображением или видео');
}
let kind: MediaKind = kind0;
const buf = await fs.readFile(filePath);
const sha256 = crypto.createHash('sha256').update(buf).digest('hex');
const id = asAssetId(this.randomId());
const orig = path.basename(filePath);
const safeOrig = sanitizeFileName(orig);
const relPath = `assets/${id}_${safeOrig}`;
const abs = path.join(open.cacheDir, relPath);
let safeOrig = sanitizeFileName(orig);
let relPath = `assets/${id}_${safeOrig}`;
let abs = path.join(open.cacheDir, relPath);
let writeBuf = buf;
let storedOrig = orig;
if (kind.type === 'image') {
const opt = await optimizeImageBufferVisuallyLossless(buf);
if (!opt.passthrough) {
writeBuf = Buffer.from(opt.buffer);
kind = { type: 'image', mime: opt.mime };
safeOrig = sanitizeFileName(`${path.parse(orig).name}.${opt.ext}`);
relPath = `assets/${id}_${safeOrig}`;
abs = path.join(open.cacheDir, relPath);
storedOrig = `${path.parse(orig).name}.${opt.ext}`;
}
}
const sha256 = crypto.createHash('sha256').update(writeBuf).digest('hex');
await fs.mkdir(path.dirname(abs), { recursive: true });
await fs.copyFile(filePath, abs);
const asset = buildMediaAsset(id, kind, orig, relPath, sha256, buf.length);
await fs.writeFile(abs, writeBuf);
const asset = buildMediaAsset(id, kind, storedOrig, relPath, sha256, writeBuf.length);
const thumbKind = kind.type === 'image' ? 'image' : 'video';
const thumbBytes = await generateScenePreviewThumbnailBytes(abs, thumbKind);
let thumbAsset: MediaAsset | null = null;
let thumbId: AssetId | null = null;
if (thumbBytes !== null && thumbBytes.length > 0) {
thumbId = asAssetId(this.randomId());
const thumbRelPath = `assets/${thumbId}_preview_thumb.webp`;
const thumbAbs = path.join(open.cacheDir, thumbRelPath);
await fs.writeFile(thumbAbs, thumbBytes);
const thumbSha = crypto.createHash('sha256').update(thumbBytes).digest('hex');
const thumbOrigName = `${path.parse(safeOrig).name}_preview_thumb.webp`;
thumbAsset = buildMediaAsset(
thumbId,
{ type: 'image', mime: 'image/webp' },
thumbOrigName,
thumbRelPath,
thumbSha,
thumbBytes.length,
);
}
const oldPreviewId = sc.previewAssetId;
const oldThumbId = sc.previewThumbAssetId ?? null;
await this.updateProject((p) => {
const scene = p.scenes[sceneId];
if (!scene) throw new Error('Scene not found');
let assets: Record<AssetId, MediaAsset> = { ...p.assets };
if (oldPreviewId) {
assets = Object.fromEntries(Object.entries(assets).filter(([k]) => k !== oldPreviewId)) as Record<
AssetId,
MediaAsset
>;
const drop = new Set<AssetId>();
if (oldPreviewId) drop.add(oldPreviewId);
if (oldThumbId) drop.add(oldThumbId);
if (drop.size > 0) {
assets = Object.fromEntries(
Object.entries(assets).filter(([k]) => !drop.has(k as AssetId)),
) as Record<AssetId, MediaAsset>;
}
assets[id] = asset;
if (thumbAsset !== null && thumbId !== null) {
assets[thumbId] = thumbAsset;
}
return {
...p,
assets,
@@ -283,6 +366,7 @@ export class ZipProjectStore {
...scene,
previewAssetId: id,
previewAssetType: kind.type,
previewThumbAssetId: thumbId,
previewVideoAutostart: kind.type === 'video' ? scene.previewVideoAutostart : false,
},
},
@@ -300,14 +384,17 @@ export class ZipProjectStore {
const sc = open.project.scenes[sceneId];
if (!sc) throw new Error('Scene not found');
const oldId = sc.previewAssetId;
if (!oldId) {
const oldThumbId = sc.previewThumbAssetId ?? null;
if (!oldId && !oldThumbId) {
return open.project;
}
await this.updateProject((p) => {
const assets = Object.fromEntries(Object.entries(p.assets).filter(([k]) => k !== oldId)) as Record<
AssetId,
MediaAsset
>;
const drop = new Set<AssetId>();
if (oldId) drop.add(oldId);
if (oldThumbId) drop.add(oldThumbId);
const assets = Object.fromEntries(
Object.entries(p.assets).filter(([k]) => !drop.has(k as AssetId)),
) as Record<AssetId, MediaAsset>;
return {
...p,
assets,
@@ -317,6 +404,7 @@ export class ZipProjectStore {
...p.scenes[sceneId],
previewAssetId: null,
previewAssetType: null,
previewThumbAssetId: null,
previewVideoAutostart: false,
},
},
@@ -355,6 +443,7 @@ export class ZipProjectStore {
layout: { x: 0, y: 0 },
previewAssetId: null,
previewAssetType: null,
previewThumbAssetId: null,
previewVideoAutostart: false,
previewRotationDeg: 0,
} satisfies Scene);
@@ -365,6 +454,9 @@ export class ZipProjectStore {
...(patch.description !== undefined ? { description: patch.description } : null),
...(patch.previewAssetId !== undefined ? { previewAssetId: patch.previewAssetId } : null),
...(patch.previewAssetType !== undefined ? { previewAssetType: patch.previewAssetType } : null),
...(patch.previewThumbAssetId !== undefined
? { previewThumbAssetId: patch.previewThumbAssetId }
: null),
...(patch.previewVideoAutostart !== undefined
? { previewVideoAutostart: patch.previewVideoAutostart }
: null),
@@ -784,7 +876,10 @@ export class ZipProjectStore {
* Если архив уже лежит в `projects`, только открывает.
* При конфликте `id` с другим файлом перезаписывает `project.json` в копии с новым id.
*/
async importProjectFromExternalZip(sourcePath: string): Promise<Project> {
async importProjectFromExternalZip(
sourcePath: string,
onProgress?: (p: { stage: 'copy' | 'unzip' | 'done'; percent: number; detail?: string }) => void,
): Promise<Project> {
await this.ensureRoots();
const resolved = path.resolve(sourcePath);
const st = await fs.stat(resolved).catch(() => null);
@@ -809,7 +904,12 @@ export class ZipProjectStore {
} else {
destFileName = await uniqueDndZipFileName(root, baseName);
destPath = path.join(root, destFileName);
await fs.copyFile(resolved, destPath);
if (onProgress) onProgress({ stage: 'copy', percent: 1, detail: 'Копирование…' });
await copyFileWithProgress(resolved, destPath, (pct) => {
if (!onProgress) return;
// Copy is ~70% of the operation; unzip/open happens after.
onProgress({ stage: 'copy', percent: Math.max(1, Math.min(70, pct)), detail: 'Копирование…' });
});
}
let project = await readProjectJsonFromZip(destPath);
@@ -832,11 +932,19 @@ export class ZipProjectStore {
}
this.projectSession += 1;
return this.openProjectById(project.id);
const opened = await this.openProjectByIdWithProgress(project.id, (pct) => {
if (onProgress) onProgress({ stage: 'unzip', percent: pct, detail: 'Распаковка…' });
});
if (onProgress) onProgress({ stage: 'done', percent: 100, detail: 'Готово' });
return opened;
}
/** Копия файла проекта в указанный путь (полный путь к `.dnd.zip`). */
async exportProjectZipToPath(projectId: ProjectId, destinationPath: string): Promise<void> {
async exportProjectZipToPath(
projectId: ProjectId,
destinationPath: string,
onProgress?: (p: { stage: 'copy' | 'done'; percent: number; detail?: string }) => void,
): Promise<void> {
await this.ensureRoots();
// If exporting the currently open project, make sure pending debounced pack is flushed.
if (this.openProject?.id === projectId) {
@@ -850,7 +958,11 @@ export class ZipProjectStore {
const src = path.join(getProjectsRootDir(), entry.fileName);
const dest = path.resolve(destinationPath);
await fs.mkdir(path.dirname(dest), { recursive: true });
await fs.copyFile(src, dest);
if (onProgress) onProgress({ stage: 'copy', percent: 1, detail: 'Копирование…' });
await copyFileWithProgress(src, dest, (pct) => {
if (onProgress) onProgress({ stage: 'copy', percent: pct, detail: 'Копирование…' });
});
if (onProgress) onProgress({ stage: 'done', percent: 100, detail: 'Готово' });
}
/** Удаляет архив проекта и кэш распаковки с диска. Если проект открыт — сбрасывает сессию. */
@@ -993,6 +1105,8 @@ function normalizeScene(s: Scene): Scene {
(s as unknown as { previewVideoAutostostart?: boolean; previewVideoAutostart?: boolean })
.previewVideoAutostart,
);
const previewThumbAssetId =
(s as unknown as { previewThumbAssetId?: AssetId | null }).previewThumbAssetId ?? null;
const rawAudios = Array.isArray(raw.audios) ? raw.audios : [];
const audios = rawAudios
@@ -1017,6 +1131,7 @@ function normalizeScene(s: Scene): Scene {
...s,
previewAssetId: previewAssetId ?? null,
previewAssetType,
previewThumbAssetId,
previewVideoAutostart,
previewRotationDeg,
layout: layoutIn ?? { x: 0, y: 0 },
@@ -1125,6 +1240,40 @@ async function replaceFileAtomic(srcPath: string, destPath: string): Promise<voi
}
}
async function copyFileWithProgress(
src: string,
dest: string,
onPercent: (pct: number) => void,
): Promise<void> {
const st = await fs.stat(src);
const total = st.size || 0;
if (total <= 0) {
await fs.copyFile(src, dest);
onPercent(100);
return;
}
await fs.mkdir(path.dirname(dest), { recursive: true });
await new Promise<void>((resolve, reject) => {
let done = 0;
const rs = fssync.createReadStream(src);
const ws = fssync.createWriteStream(dest);
const onErr = (e: unknown) => reject(e instanceof Error ? e : new Error(String(e)));
rs.on('error', onErr);
ws.on('error', onErr);
rs.on('data', (chunk: Buffer) => {
done += chunk.length;
const pct = Math.round((done / total) * 100);
try {
onPercent(Math.max(0, Math.min(100, pct)));
} catch {
// ignore
}
});
ws.on('close', () => resolve());
rs.pipe(ws);
});
}
type MediaKind = { type: MediaAssetType; mime: string };
function classifyMediaPath(filePath: string): MediaKind | null {
@@ -1139,6 +1288,8 @@ function classifyMediaPath(filePath: string): MediaKind | null {
return { type: 'image', mime: 'image/webp' };
case '.gif':
return { type: 'image', mime: 'image/gif' };
case '.bmp':
return { type: 'image', mime: 'image/bmp' };
case '.mp4':
return { type: 'video', mime: 'video/mp4' };
case '.webm':