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
+36 -2
View File
@@ -27,6 +27,20 @@ import {
waitForEditorWindowReady,
} from './windows/createWindows';
function emitZipProgress(evt: {
kind: 'import' | 'export';
stage: string;
percent: number;
detail?: string;
}): void {
for (const win of BrowserWindow.getAllWindows()) {
win.webContents.send(
evt.kind === 'import' ? ipcChannels.project.importZipProgress : ipcChannels.project.exportZipProgress,
evt,
);
}
}
/**
* Отключение GPU ломает скорость вторичных окон (презентация/пульт — WebGL). По умолчанию не трогаем.
* При чёрном экране в упакованной сборке: `DND_DISABLE_GPU=1`.
@@ -403,7 +417,18 @@ async function main() {
if (canceled || !filePaths[0]) {
return { canceled: true as const };
}
const project = await projectStore.importProjectFromExternalZip(filePaths[0]);
const srcPath = filePaths[0];
emitZipProgress({ kind: 'import', stage: 'copy', percent: 0, detail: 'Копирование…' });
// Let store import; progress for unzip is emitted from unzipToDir wrapper in store.
const project = await projectStore.importProjectFromExternalZip(srcPath, (p) => {
emitZipProgress({
kind: 'import',
stage: p.stage,
percent: p.percent,
...(p.detail ? { detail: p.detail } : null),
});
});
emitZipProgress({ kind: 'import', stage: 'done', percent: 100, detail: 'Готово' });
emitSessionState();
return { canceled: false as const, project };
});
@@ -428,7 +453,16 @@ async function main() {
if (!lower.endsWith('.dnd.zip')) {
dest = lower.endsWith('.zip') ? dest.replace(/\.zip$/iu, '.dnd.zip') : `${dest}.dnd.zip`;
}
await projectStore.exportProjectZipToPath(projectId, dest);
emitZipProgress({ kind: 'export', stage: 'copy', percent: 0, detail: 'Экспорт…' });
await projectStore.exportProjectZipToPath(projectId, dest, (p) => {
emitZipProgress({
kind: 'export',
stage: p.stage,
percent: p.percent,
...(p.detail ? { detail: p.detail } : null),
});
});
emitZipProgress({ kind: 'export', stage: 'done', percent: 100, detail: 'Готово' });
return { canceled: false as const };
});
registerHandler(ipcChannels.project.deleteProject, async ({ projectId }) => {
+2 -1
View File
@@ -14,6 +14,7 @@ void test('collectReferencedAssetIds: превью, видео и аудио', (
scenes: {
s1: {
previewAssetId: 'pr' as AssetId,
previewThumbAssetId: 'th' as AssetId,
media: {
videos: ['v1' as AssetId],
audios: [{ assetId: 'a1' as AssetId, autoplay: true, loop: true }],
@@ -23,7 +24,7 @@ void test('collectReferencedAssetIds: превью, видео и аудио', (
campaignAudios: [{ assetId: 'ca1' as AssetId, autoplay: true, loop: true }],
} as unknown as Project;
const s = collectReferencedAssetIds(p);
assert.deepEqual([...s].sort(), ['a1', 'ca1', 'pr', 'v1'].sort());
assert.deepEqual([...s].sort(), ['a1', 'ca1', 'pr', 'th', 'v1'].sort());
});
void test('reconcileAssetFiles: снимает осиротевшие assets и удаляет файлы', async () => {
+1
View File
@@ -9,6 +9,7 @@ export function collectReferencedAssetIds(p: Project): Set<AssetId> {
const refs = new Set<AssetId>();
for (const sc of Object.values(p.scenes)) {
if (sc.previewAssetId) refs.add(sc.previewAssetId);
if (sc.previewThumbAssetId) refs.add(sc.previewThumbAssetId);
for (const vid of sc.media.videos) refs.add(vid);
for (const au of sc.media.audios) refs.add(au.assetId);
}
@@ -0,0 +1,14 @@
import type { Buffer } from 'node:buffer';
export type OptimizeImageImportResult = {
buffer: Buffer;
mime: string;
ext: string;
width: number;
height: number;
passthrough: boolean;
};
export function optimizeImageBufferVisuallyLossless(
input: Buffer,
): Promise<OptimizeImageImportResult>;
@@ -0,0 +1,218 @@
/**
* Visually lossless re-encode for imported raster images (same pixel dimensions).
* Node-only; shared by the main app and tools/project-converter.
*/
import sharp from 'sharp';
/** @typedef {import('node:buffer').Buffer} Buffer */
/**
* @typedef {Object} OptimizeImageImportResult
* @property {Buffer} buffer
* @property {string} mime
* @property {string} ext filename extension without dot (e.g. webp, jpg, png)
* @property {number} width
* @property {number} height
* @property {boolean} passthrough true if original bytes were kept
*/
const WEBP_EFFORT = 6;
const RASTER_QUALITY = 95;
/**
* @param {Buffer} buf
* @param {import('sharp').Metadata | null} meta
* @returns {OptimizeImageImportResult}
*/
function makePassthrough(buf, meta) {
const fmt = meta?.format;
if (fmt === 'jpeg' || fmt === 'jpg') {
return {
buffer: buf,
mime: 'image/jpeg',
ext: 'jpg',
width: meta?.width ?? 0,
height: meta?.height ?? 0,
passthrough: true,
};
}
if (fmt === 'png') {
return {
buffer: buf,
mime: 'image/png',
ext: 'png',
width: meta?.width ?? 0,
height: meta?.height ?? 0,
passthrough: true,
};
}
if (fmt === 'webp') {
return {
buffer: buf,
mime: 'image/webp',
ext: 'webp',
width: meta?.width ?? 0,
height: meta?.height ?? 0,
passthrough: true,
};
}
if (fmt === 'gif') {
return {
buffer: buf,
mime: 'image/gif',
ext: 'gif',
width: meta?.width ?? 0,
height: meta?.height ?? 0,
passthrough: true,
};
}
if (fmt === 'tiff' || fmt === 'tif') {
return {
buffer: buf,
mime: 'image/tiff',
ext: 'tiff',
width: meta?.width ?? 0,
height: meta?.height ?? 0,
passthrough: true,
};
}
if (fmt === 'bmp') {
return {
buffer: buf,
mime: 'image/bmp',
ext: 'bmp',
width: meta?.width ?? 0,
height: meta?.height ?? 0,
passthrough: true,
};
}
return {
buffer: buf,
mime: 'application/octet-stream',
ext: 'bin',
width: meta?.width ?? 0,
height: meta?.height ?? 0,
passthrough: true,
};
}
/**
* @param {Buffer} outBuf
* @param {number} w0
* @param {number} h0
*/
async function sameDimensionsOrThrow(outBuf, w0, h0) {
const m = await sharp(outBuf).metadata();
if ((m.width ?? 0) !== w0 || (m.height ?? 0) !== h0) {
const err = new Error('encode changed dimensions');
err.code = 'DIM';
throw err;
}
}
/**
* @param {Buffer} src
* @returns {Promise<OptimizeImageImportResult>}
*/
export async function optimizeImageBufferVisuallyLossless(src) {
const input = Buffer.isBuffer(src) ? src : Buffer.from(src);
if (input.length === 0) {
return makePassthrough(input, { width: 0, height: 0, format: 'png' });
}
let meta0;
try {
meta0 = await sharp(input, { failOn: 'error', unlimited: true }).metadata();
} catch {
return makePassthrough(input, null);
}
const width = meta0.width ?? 0;
const height = meta0.height ?? 0;
if (width <= 0 || height <= 0) {
return makePassthrough(input, meta0);
}
const pages = meta0.pages ?? 1;
if (pages > 1) {
return makePassthrough(input, meta0);
}
const rawFmt = meta0.format;
if (rawFmt === 'svg' || rawFmt === 'pdf' || rawFmt === 'heif' || rawFmt === 'jxl' || rawFmt === 'vips') {
return makePassthrough(input, meta0);
}
const hasAlpha = meta0.hasAlpha === true;
try {
if (hasAlpha) {
const webpLo = await sharp(input)
.rotate()
.ensureAlpha()
.webp({ lossless: true, effort: WEBP_EFFORT })
.toBuffer();
const pngOut = await sharp(input)
.rotate()
.ensureAlpha()
.png({ compressionLevel: 9, adaptiveFiltering: true })
.toBuffer();
await sameDimensionsOrThrow(webpLo, width, height);
await sameDimensionsOrThrow(pngOut, width, height);
const pickWebp = webpLo.length <= pngOut.length;
const outBuf = pickWebp ? webpLo : pngOut;
if (outBuf.length >= input.length) {
return makePassthrough(input, meta0);
}
return {
buffer: outBuf,
mime: pickWebp ? 'image/webp' : 'image/png',
ext: pickWebp ? 'webp' : 'png',
width,
height,
passthrough: false,
};
}
const webpColor = await sharp(input)
.rotate()
.webp({
quality: RASTER_QUALITY,
nearLossless: true,
effort: WEBP_EFFORT,
smartSubsample: false,
})
.toBuffer();
const jpegColor = await sharp(input)
.rotate()
.jpeg({
quality: RASTER_QUALITY,
chromaSubsampling: '4:4:4',
mozjpeg: true,
optimizeScans: true,
})
.toBuffer();
await sameDimensionsOrThrow(webpColor, width, height);
await sameDimensionsOrThrow(jpegColor, width, height);
const useWebp = webpColor.length <= jpegColor.length;
const outBuf = useWebp ? webpColor : jpegColor;
if (outBuf.length >= input.length) {
return makePassthrough(input, meta0);
}
return {
buffer: outBuf,
mime: useWebp ? 'image/webp' : 'image/jpeg',
ext: useWebp ? 'webp' : 'jpg',
width,
height,
passthrough: false,
};
} catch (e) {
if (e && typeof e === 'object' && /** @type {{ code?: string }} */ (e).code === 'DIM') {
return makePassthrough(input, meta0);
}
return makePassthrough(input, meta0);
}
}
@@ -0,0 +1,54 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import sharp from 'sharp';
import { optimizeImageBufferVisuallyLossless } from './optimizeImageImport.lib.mjs';
void test('optimizeImageBufferVisuallyLossless: preserves dimensions for opaque RGB', async () => {
const input = await sharp({
create: {
width: 400,
height: 300,
channels: 3,
background: { r: 10, g: 100, b: 200 },
},
})
.png({ compressionLevel: 0 })
.toBuffer();
const out = await optimizeImageBufferVisuallyLossless(input);
assert.equal(out.passthrough, false);
assert.ok(out.buffer.length > 0);
assert.ok(out.buffer.length < input.length);
const meta = await sharp(out.buffer).metadata();
assert.equal(meta.width, 400);
assert.equal(meta.height, 300);
});
void test('optimizeImageBufferVisuallyLossless: preserves dimensions for alpha', async () => {
const input = await sharp({
create: {
width: 200,
height: 200,
channels: 4,
background: { r: 255, g: 0, b: 0, alpha: 0.5 },
},
})
.png({ compressionLevel: 0 })
.toBuffer();
const out = await optimizeImageBufferVisuallyLossless(input);
assert.equal(out.passthrough, false);
assert.ok(out.mime === 'image/webp' || out.mime === 'image/png');
assert.ok(out.buffer.length < input.length);
const meta = await sharp(out.buffer).metadata();
assert.equal(meta.width, 200);
assert.equal(meta.height, 200);
assert.equal(meta.hasAlpha, true);
});
void test('optimizeImageBufferVisuallyLossless: non-image buffer is passthrough', async () => {
const out = await optimizeImageBufferVisuallyLossless(Buffer.from('not an image'));
assert.equal(out.passthrough, true);
});
@@ -0,0 +1,35 @@
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import sharp from 'sharp';
import { generateScenePreviewThumbnailBytes, SCENE_PREVIEW_THUMB_MAX_PX } from './scenePreviewThumbnail';
void test('generateScenePreviewThumbnailBytes: image scales to max edge', async () => {
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dnd-thumb-test-'));
const src = path.join(tmp, 'wide.png');
await sharp({
create: {
width: 800,
height: 400,
channels: 3,
background: { r: 200, g: 40, b: 40 },
},
})
.png()
.toFile(src);
const buf = await generateScenePreviewThumbnailBytes(src, 'image');
assert.ok(buf !== null);
assert.ok(buf.length > 0);
const meta = await sharp(buf).metadata();
assert.equal(typeof meta.width, 'number');
assert.equal(typeof meta.height, 'number');
assert.ok(meta.width > 0 && meta.height > 0);
assert.ok(Math.max(meta.width, meta.height) <= SCENE_PREVIEW_THUMB_MAX_PX);
await fs.rm(tmp, { recursive: true, force: true });
});
+86
View File
@@ -0,0 +1,86 @@
import { execFile } from 'node:child_process';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { promisify } from 'node:util';
import ffmpegStatic from 'ffmpeg-static';
import sharp from 'sharp';
const execFileAsync = promisify(execFile);
/** Longest edge; presentation uses the original asset. */
export const SCENE_PREVIEW_THUMB_MAX_PX = 320;
/**
* Builds a small WebP still for graph/list previews. Returns null if generation fails (import still succeeds).
*/
export async function generateScenePreviewThumbnailBytes(
sourceAbsPath: string,
kind: 'image' | 'video',
): Promise<Buffer | null> {
try {
if (kind === 'image') {
return await sharp(sourceAbsPath)
.rotate()
.resize(SCENE_PREVIEW_THUMB_MAX_PX, SCENE_PREVIEW_THUMB_MAX_PX, {
fit: 'inside',
withoutEnlargement: true,
})
.webp({ quality: 82 })
.toBuffer();
}
const ffmpegPath = ffmpegStatic;
if (!ffmpegPath) return null;
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'dnd-thumb-'));
const tmpPng = path.join(tmpDir, 'frame.png');
try {
const seekSeconds = ['0.5', '0.25', '0'];
let extracted = false;
for (const ss of seekSeconds) {
await fs.rm(tmpPng, { force: true }).catch(() => undefined);
try {
await execFileAsync(
ffmpegPath,
[
'-hide_banner',
'-loglevel',
'error',
'-y',
'-ss',
ss,
'-i',
sourceAbsPath,
'-frames:v',
'1',
tmpPng,
],
{ maxBuffer: 16 * 1024 * 1024 },
);
const st = await fs.stat(tmpPng).catch(() => null);
if (st !== null && st.isFile() && st.size > 0) {
extracted = true;
break;
}
} catch {
/* try next seek */
}
}
if (!extracted) return null;
return await sharp(tmpPng)
.resize(SCENE_PREVIEW_THUMB_MAX_PX, SCENE_PREVIEW_THUMB_MAX_PX, {
fit: 'inside',
withoutEnlargement: true,
})
.webp({ quality: 82 })
.toBuffer();
} finally {
await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => undefined);
}
} catch {
return null;
}
}
+15 -1
View File
@@ -5,12 +5,18 @@ import yauzl from 'yauzl';
import type { Project } from '../../shared/types';
export function unzipToDir(zipPath: string, outDir: string): Promise<void> {
export function unzipToDir(
zipPath: string,
outDir: string,
onProgress?: (done: number, total: number) => void,
): Promise<void> {
return new Promise((resolve, reject) => {
yauzl.open(zipPath, { lazyEntries: true }, (err, zip) => {
if (err) return reject(err);
const zipFile = zip;
let settled = false;
const total = zipFile.entryCount || 0;
let done = 0;
const safeClose = (): void => {
try {
@@ -37,6 +43,14 @@ export function unzipToDir(zipPath: string, outDir: string): Promise<void> {
zipFile.readEntry();
zipFile.on('entry', (entry: yauzl.Entry) => {
if (settled) return;
done += 1;
if (onProgress && total > 0) {
try {
onProgress(done, total);
} catch {
// ignore
}
}
const filePath = path.join(outDir, entry.fileName);
if (entry.fileName.endsWith('/')) {
fssync.mkdirSync(filePath, { recursive: true });
@@ -39,3 +39,9 @@ void test('zipStore: exportProjectZipToPath flushes saveNow for currently open p
assert.match(src, /if \(this\.openProject\?\.id === projectId\)\s*\{\s*await this\.saveNow\(\);\s*\}/);
assert.match(src, /await fs\.copyFile\(src, dest\)/);
});
void test('zipStore: normalizeScene defaults previewThumbAssetId for older projects', () => {
const src = fs.readFileSync(path.join(here, 'zipStore.ts'), 'utf8');
assert.match(src, /previewThumbAssetId/);
assert.match(src, /function normalizeScene\(/);
});
+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':
+46
View File
@@ -124,6 +124,52 @@
background: var(--bg0);
}
.progressOverlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.55);
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
}
.progressModal {
width: min(520px, calc(100vw - 32px));
border-radius: 12px;
padding: 14px;
background: rgba(25, 28, 38, 0.92);
border: 1px solid rgba(255, 255, 255, 0.12);
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.55);
}
.progressTitle {
font-weight: 700;
margin-bottom: 10px;
}
.progressBar {
height: 10px;
border-radius: 999px;
overflow: hidden;
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.1);
}
.progressFill {
height: 100%;
width: 0%;
background: rgba(167, 139, 250, 0.85);
}
.progressMeta {
margin-top: 10px;
display: flex;
justify-content: space-between;
opacity: 0.9;
font-size: 12px;
}
.inspectorTitle {
font-weight: 800;
margin-bottom: 12px;
+41 -6
View File
@@ -23,6 +23,7 @@ type SceneCard = {
title: string;
active: boolean;
previewAssetId: AssetId | null;
previewThumbAssetId: AssetId | null;
previewAssetType: 'image' | 'video' | null;
previewVideoAutostart: boolean;
previewRotationDeg: 0 | 90 | 180 | 270;
@@ -92,6 +93,7 @@ export function EditorApp() {
title: s.title,
active: s.id === state.selectedSceneId,
previewAssetId: s.previewAssetId,
previewThumbAssetId: s.previewThumbAssetId,
previewAssetType: s.previewAssetType,
previewVideoAutostart: s.previewVideoAutostart,
previewRotationDeg: s.previewRotationDeg,
@@ -263,6 +265,28 @@ export function EditorApp() {
return (
<>
{state.zipProgress
? createPortal(
<div className={styles.progressOverlay} role="dialog" aria-label="Прогресс операции">
<div className={styles.progressModal}>
<div className={styles.progressTitle}>
{state.zipProgress.kind === 'import' ? 'Импорт проекта' : 'Экспорт проекта'}
</div>
<div className={styles.progressBar}>
<div
className={styles.progressFill}
style={{ width: `${String(Math.max(0, Math.min(100, state.zipProgress.percent)))}%` }}
/>
</div>
<div className={styles.progressMeta}>
<div>{state.zipProgress.detail ?? state.zipProgress.stage}</div>
<div>{state.zipProgress.percent}%</div>
</div>
</div>
</div>,
document.body,
)
: null}
<LayoutShell
bodyOverlay={bodyOverlay}
topBar={
@@ -1276,7 +1300,8 @@ type SceneListCardProps = {
};
function SceneListCard({ scene, onSelect, onDeleteScene }: SceneListCardProps) {
const url = useAssetUrl(scene.previewAssetId);
const thumbUrl = useAssetUrl(scene.previewThumbAssetId);
const previewUrl = useAssetUrl(scene.previewAssetId);
const [menu, setMenu] = useState<{ x: number; y: number } | null>(null);
useEffect(() => {
@@ -1315,21 +1340,31 @@ function SceneListCard({ scene, onSelect, onDeleteScene }: SceneListCardProps) {
if (e.key === 'Enter' || e.key === ' ') onSelect();
}}
>
<div className={url ? styles.sceneThumb : styles.sceneThumbEmpty}>
{url && scene.previewAssetType === 'image' ? (
<div className={thumbUrl || previewUrl ? styles.sceneThumb : styles.sceneThumbEmpty}>
{thumbUrl ? (
<div className={styles.sceneThumbInner}>
<RotatedImage
url={url}
url={thumbUrl}
rotationDeg={scene.previewRotationDeg}
mode="cover"
loading="lazy"
decoding="async"
/>
</div>
) : url && scene.previewAssetType === 'video' ? (
) : previewUrl && scene.previewAssetType === 'image' ? (
<div className={styles.sceneThumbInner}>
<RotatedImage
url={previewUrl}
rotationDeg={scene.previewRotationDeg}
mode="cover"
loading="lazy"
decoding="async"
/>
</div>
) : previewUrl && scene.previewAssetType === 'video' ? (
<div className={styles.sceneThumbInner}>
<video
src={url}
src={previewUrl}
muted
playsInline
preload="metadata"
+32 -6
View File
@@ -35,6 +35,7 @@ export type SceneGraphSceneAudioSummary = {
export type SceneGraphSceneCard = {
title: string;
previewAssetId: AssetId | null;
previewThumbAssetId: AssetId | null;
previewAssetType: 'image' | 'video' | null;
previewVideoAutostart: boolean;
previewRotationDeg: 0 | 90 | 180 | 270;
@@ -69,6 +70,7 @@ type SceneCardData = {
title: string;
active: boolean;
previewAssetId: AssetId | null;
previewThumbAssetId: AssetId | null;
previewAssetType: 'image' | 'video' | null;
previewVideoAutostart: boolean;
previewRotationDeg: 0 | 90 | 180 | 270;
@@ -129,7 +131,8 @@ function IconVideoPreviewAutostart() {
}
function SceneCardNode({ data }: NodeProps<SceneCardData>) {
const url = useAssetUrl(data.previewAssetId);
const thumbUrl = useAssetUrl(data.previewThumbAssetId);
const previewUrl = useAssetUrl(data.previewAssetId);
const cardClass = [styles.card, data.active ? styles.cardActive : ''].filter(Boolean).join(' ');
const showCornerVideo = data.previewIsVideo;
const showCornerAudio = data.hasSceneAudio;
@@ -139,11 +142,11 @@ function SceneCardNode({ data }: NodeProps<SceneCardData>) {
<div className={cardClass}>
<div className={styles.previewShell}>
{data.isStartScene ? <div className={styles.badgeStart}>НАЧАЛО</div> : null}
{url && data.previewAssetType === 'image' ? (
{thumbUrl ? (
<div className={styles.previewFill}>
{data.previewRotationDeg === 0 ? (
<img
src={url}
src={thumbUrl}
alt=""
className={styles.imageCover}
draggable={false}
@@ -152,7 +155,7 @@ function SceneCardNode({ data }: NodeProps<SceneCardData>) {
/>
) : (
<RotatedImage
url={url}
url={thumbUrl}
rotationDeg={data.previewRotationDeg}
mode="cover"
loading="lazy"
@@ -161,9 +164,31 @@ function SceneCardNode({ data }: NodeProps<SceneCardData>) {
/>
)}
</div>
) : url && data.previewAssetType === 'video' ? (
) : previewUrl && data.previewAssetType === 'image' ? (
<div className={styles.previewFill}>
{data.previewRotationDeg === 0 ? (
<img
src={previewUrl}
alt=""
className={styles.imageCover}
draggable={false}
loading="lazy"
decoding="async"
/>
) : (
<RotatedImage
url={previewUrl}
rotationDeg={data.previewRotationDeg}
mode="cover"
loading="lazy"
decoding="async"
style={{ width: '100%', height: '100%' }}
/>
)}
</div>
) : previewUrl && data.previewAssetType === 'video' ? (
<video
src={url}
src={previewUrl}
muted
playsInline
preload="metadata"
@@ -322,6 +347,7 @@ function SceneGraphCanvas({
title: c?.title ?? '',
active,
previewAssetId: c?.previewAssetId ?? null,
previewThumbAssetId: c?.previewThumbAssetId ?? null,
previewAssetType: c?.previewAssetType ?? null,
previewVideoAutostart: c?.previewVideoAutostart ?? false,
previewRotationDeg: c?.previewRotationDeg ?? 0,
@@ -42,6 +42,7 @@ void test('buildNextSceneCardById: does not change refs when irrelevant fields c
connections: [],
layout: { x: 0, y: 0 },
previewAssetId: null,
previewThumbAssetId: null,
previewAssetType: null,
previewVideoAutostart: false,
previewRotationDeg: 0,
@@ -79,6 +80,7 @@ void test('buildNextSceneCardById: changes card when title changes', () => {
connections: [],
layout: { x: 0, y: 0 },
previewAssetId: null,
previewThumbAssetId: null,
previewAssetType: null,
previewVideoAutostart: false,
previewRotationDeg: 0,
@@ -38,6 +38,7 @@ export function buildNextSceneCardById(
if (
prevCard?.title === s.title &&
prevCard.previewAssetId === s.previewAssetId &&
prevCard.previewThumbAssetId === s.previewThumbAssetId &&
prevCard.previewAssetType === s.previewAssetType &&
prevCard.previewVideoAutostart === s.previewVideoAutostart &&
prevCard.previewRotationDeg === s.previewRotationDeg &&
@@ -49,6 +50,7 @@ export function buildNextSceneCardById(
nextMap[id] = {
title: s.title,
previewAssetId: s.previewAssetId,
previewThumbAssetId: s.previewThumbAssetId,
previewAssetType: s.previewAssetType,
previewVideoAutostart: s.previewVideoAutostart,
previewRotationDeg: s.previewRotationDeg,
+51 -2
View File
@@ -10,6 +10,7 @@ type State = {
projects: ProjectSummary[];
project: Project | null;
selectedSceneId: SceneId | null;
zipProgress: { kind: 'import' | 'export'; percent: number; stage: string; detail?: string } | null;
};
type Actions = {
@@ -27,6 +28,7 @@ type Actions = {
title?: string;
description?: string;
previewAssetId?: AssetId | null;
previewThumbAssetId?: AssetId | null;
previewAssetType?: 'image' | 'video' | null;
previewVideoAutostart?: boolean;
previewRotationDeg?: 0 | 90 | 180 | 270;
@@ -58,7 +60,12 @@ function randomId(prefix: string): string {
export function useProjectState(licenseActive: boolean): readonly [State, Actions] {
const api = getDndApi();
const [state, setState] = useState<State>({ projects: [], project: null, selectedSceneId: null });
const [state, setState] = useState<State>({
projects: [],
project: null,
selectedSceneId: null,
zipProgress: null,
});
const projectRef = useRef<Project | null>(null);
/** Bumps on mutations / refresh; initial license load only applies if still current (avoids racing late list/get over newer state). */
const projectDataEpochRef = useRef(0);
@@ -66,6 +73,43 @@ export function useProjectState(licenseActive: boolean): readonly [State, Action
projectRef.current = state.project;
}, [state.project]);
useEffect(() => {
const offImport = api.on(ipcChannels.project.importZipProgress, (evt) => {
const e = evt as unknown as { percent: number; stage: string; detail?: string };
setState((s) => ({
...s,
zipProgress: {
kind: 'import',
percent: e.percent,
stage: e.stage,
...(e.detail ? { detail: e.detail } : null),
},
}));
if (e.stage === 'done' || e.percent >= 100) {
setTimeout(() => setState((s) => ({ ...s, zipProgress: null })), 450);
}
});
const offExport = api.on(ipcChannels.project.exportZipProgress, (evt) => {
const e = evt as unknown as { percent: number; stage: string; detail?: string };
setState((s) => ({
...s,
zipProgress: {
kind: 'export',
percent: e.percent,
stage: e.stage,
...(e.detail ? { detail: e.detail } : null),
},
}));
if (e.stage === 'done' || e.percent >= 100) {
setTimeout(() => setState((s) => ({ ...s, zipProgress: null })), 450);
}
});
return () => {
offImport();
offExport();
};
}, [api]);
const actions = useMemo<Actions>(() => {
const refreshProjects = async () => {
projectDataEpochRef.current += 1;
@@ -99,6 +143,7 @@ export function useProjectState(licenseActive: boolean): readonly [State, Action
title: `Новая сцена`,
description: '',
previewAssetId: null,
previewThumbAssetId: null,
previewAssetType: null,
previewVideoAutostart: false,
previewRotationDeg: 0,
@@ -153,6 +198,7 @@ export function useProjectState(licenseActive: boolean): readonly [State, Action
title?: string;
description?: string;
previewAssetId?: AssetId | null;
previewThumbAssetId?: AssetId | null;
previewAssetType?: 'image' | 'video' | null;
previewVideoAutostart?: boolean;
previewRotationDeg?: 0 | 90 | 180 | 270;
@@ -171,6 +217,9 @@ export function useProjectState(licenseActive: boolean): readonly [State, Action
...(patch.title !== undefined ? { title: patch.title } : null),
...(patch.description !== undefined ? { description: patch.description } : null),
...(patch.previewAssetId !== undefined ? { previewAssetId: patch.previewAssetId } : null),
...(patch.previewThumbAssetId !== undefined
? { previewThumbAssetId: patch.previewThumbAssetId }
: null),
...(patch.previewAssetType !== undefined ? { previewAssetType: patch.previewAssetType } : null),
...(patch.previewVideoAutostart !== undefined
? { previewVideoAutostart: patch.previewVideoAutostart }
@@ -342,7 +391,7 @@ export function useProjectState(licenseActive: boolean): readonly [State, Action
if (!licenseActive) {
queueMicrotask(() => {
projectDataEpochRef.current += 1;
setState({ projects: [], project: null, selectedSceneId: null });
setState({ projects: [], project: null, selectedSceneId: null, zipProgress: null });
});
return;
}
+37 -7
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useRef } from 'react';
import React, { useEffect, useRef, useState } from 'react';
import { computeTimeSec } from '../../main/video/videoPlaybackStore';
import type { SessionState } from '../../shared/ipc/contracts';
@@ -32,9 +32,39 @@ export function PresentationView({
);
const scene =
session?.project && session.currentSceneId ? session.project.scenes[session.currentSceneId] : undefined;
const previewUrl = useAssetUrl(scene?.previewAssetId ?? null);
const originalUrl = useAssetUrl(scene?.previewAssetId ?? null);
const thumbUrl = useAssetUrl(scene?.previewThumbAssetId ?? null);
const [shownImageUrl, setShownImageUrl] = useState<string | null>(null);
const rot = scene?.previewRotationDeg ?? 0;
useEffect(() => {
if (!scene) {
setShownImageUrl(null);
return;
}
if (scene.previewAssetType !== 'image') {
setShownImageUrl(null);
return;
}
// Show thumbnail instantly (if exists) to avoid stutter on slide switch, then swap to original when loaded.
setShownImageUrl(thumbUrl ?? originalUrl);
if (!thumbUrl || !originalUrl || thumbUrl === originalUrl) return;
let cancelled = false;
const img = new Image();
img.decoding = 'async';
img.onload = () => {
if (cancelled) return;
setShownImageUrl(originalUrl);
};
img.onerror = () => {
// keep thumbnail
};
img.src = originalUrl;
return () => {
cancelled = true;
};
}, [originalUrl, scene, thumbUrl]);
useEffect(() => {
const el = videoElRef.current;
if (!el) return;
@@ -57,7 +87,7 @@ export function PresentationView({
}, [
scene?.previewAssetId,
scene?.previewAssetType,
previewUrl,
originalUrl,
vp?.revision,
vp?.targetAssetId,
vp?.playing,
@@ -66,20 +96,20 @@ export function PresentationView({
return (
<div className={styles.root}>
{previewUrl && scene?.previewAssetType === 'image' ? (
{shownImageUrl && scene?.previewAssetType === 'image' ? (
<div className={styles.fill}>
<RotatedImage
url={previewUrl}
url={shownImageUrl}
rotationDeg={rot}
mode="contain"
onContentRectChange={setContentRect}
/>
</div>
) : previewUrl && scene?.previewAssetType === 'video' ? (
) : originalUrl && scene?.previewAssetType === 'video' ? (
<video
ref={videoElRef}
className={styles.video}
src={previewUrl}
src={originalUrl}
muted
playsInline
loop={false}
+20 -1
View File
@@ -45,6 +45,8 @@ export const ipcChannels = {
importZip: 'project.importZip',
exportZip: 'project.exportZip',
deleteProject: 'project.deleteProject',
importZipProgress: 'project.importZipProgress',
exportZipProgress: 'project.exportZipProgress',
},
windows: {
openMultiWindow: 'windows.openMultiWindow',
@@ -73,6 +75,22 @@ export const ipcChannels = {
},
} as const;
export type ZipProgressEvent = {
kind: 'import' | 'export';
stage: 'copy' | 'unzip' | 'zip' | 'done';
percent: number; // 0..100
detail?: string;
};
export type IpcEventMap = {
[ipcChannels.session.stateChanged]: { state: SessionState };
[ipcChannels.effects.stateChanged]: { state: EffectsState };
[ipcChannels.video.stateChanged]: { state: VideoPlaybackState };
[ipcChannels.license.statusChanged]: { snapshot: LicenseSnapshot };
[ipcChannels.project.importZipProgress]: ZipProgressEvent;
[ipcChannels.project.exportZipProgress]: ZipProgressEvent;
};
export type IpcInvokeMap = {
[ipcChannels.app.quit]: {
req: Record<string, never>;
@@ -237,7 +255,7 @@ export type SessionState = {
currentSceneId: SceneId | null;
};
export type IpcEventMap = {
export type LegacyIpcEventMap = {
[ipcChannels.session.stateChanged]: { state: SessionState };
[ipcChannels.effects.stateChanged]: { state: EffectsState };
[ipcChannels.video.stateChanged]: { state: VideoPlaybackState };
@@ -249,6 +267,7 @@ export type ScenePatch = {
description?: string;
previewAssetId?: AssetId | null;
previewAssetType?: 'image' | 'video' | null;
previewThumbAssetId?: AssetId | null;
previewVideoAutostart?: boolean;
previewRotationDeg?: 0 | 90 | 180 | 270;
settings?: Partial<Scene['settings']>;
+2
View File
@@ -84,6 +84,8 @@ export type Scene = {
/** Превью ассет (изображение или видео). */
previewAssetId: AssetId | null;
previewAssetType: 'image' | 'video' | null;
/** Уменьшенное изображение для графа/списков; оригинал — в `previewAssetId`. */
previewThumbAssetId: AssetId | null;
/** Для видео-превью: автозапуск (в редакторе/списках/на графе). */
previewVideoAutostart: boolean;
/** Поворот превью в градусах (0/90/180/270). */