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:
+174
-23
@@ -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':
|
||||
|
||||
Reference in New Issue
Block a user