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:
+36
-2
@@ -27,6 +27,20 @@ import {
|
|||||||
waitForEditorWindowReady,
|
waitForEditorWindowReady,
|
||||||
} from './windows/createWindows';
|
} 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). По умолчанию не трогаем.
|
* Отключение GPU ломает скорость вторичных окон (презентация/пульт — WebGL). По умолчанию не трогаем.
|
||||||
* При чёрном экране в упакованной сборке: `DND_DISABLE_GPU=1`.
|
* При чёрном экране в упакованной сборке: `DND_DISABLE_GPU=1`.
|
||||||
@@ -403,7 +417,18 @@ async function main() {
|
|||||||
if (canceled || !filePaths[0]) {
|
if (canceled || !filePaths[0]) {
|
||||||
return { canceled: true as const };
|
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();
|
emitSessionState();
|
||||||
return { canceled: false as const, project };
|
return { canceled: false as const, project };
|
||||||
});
|
});
|
||||||
@@ -428,7 +453,16 @@ async function main() {
|
|||||||
if (!lower.endsWith('.dnd.zip')) {
|
if (!lower.endsWith('.dnd.zip')) {
|
||||||
dest = lower.endsWith('.zip') ? dest.replace(/\.zip$/iu, '.dnd.zip') : `${dest}.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 };
|
return { canceled: false as const };
|
||||||
});
|
});
|
||||||
registerHandler(ipcChannels.project.deleteProject, async ({ projectId }) => {
|
registerHandler(ipcChannels.project.deleteProject, async ({ projectId }) => {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ void test('collectReferencedAssetIds: превью, видео и аудио', (
|
|||||||
scenes: {
|
scenes: {
|
||||||
s1: {
|
s1: {
|
||||||
previewAssetId: 'pr' as AssetId,
|
previewAssetId: 'pr' as AssetId,
|
||||||
|
previewThumbAssetId: 'th' as AssetId,
|
||||||
media: {
|
media: {
|
||||||
videos: ['v1' as AssetId],
|
videos: ['v1' as AssetId],
|
||||||
audios: [{ assetId: 'a1' as AssetId, autoplay: true, loop: true }],
|
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 }],
|
campaignAudios: [{ assetId: 'ca1' as AssetId, autoplay: true, loop: true }],
|
||||||
} as unknown as Project;
|
} as unknown as Project;
|
||||||
const s = collectReferencedAssetIds(p);
|
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 () => {
|
void test('reconcileAssetFiles: снимает осиротевшие assets и удаляет файлы', async () => {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export function collectReferencedAssetIds(p: Project): Set<AssetId> {
|
|||||||
const refs = new Set<AssetId>();
|
const refs = new Set<AssetId>();
|
||||||
for (const sc of Object.values(p.scenes)) {
|
for (const sc of Object.values(p.scenes)) {
|
||||||
if (sc.previewAssetId) refs.add(sc.previewAssetId);
|
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 vid of sc.media.videos) refs.add(vid);
|
||||||
for (const au of sc.media.audios) refs.add(au.assetId);
|
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 });
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,12 +5,18 @@ import yauzl from 'yauzl';
|
|||||||
|
|
||||||
import type { Project } from '../../shared/types';
|
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) => {
|
return new Promise((resolve, reject) => {
|
||||||
yauzl.open(zipPath, { lazyEntries: true }, (err, zip) => {
|
yauzl.open(zipPath, { lazyEntries: true }, (err, zip) => {
|
||||||
if (err) return reject(err);
|
if (err) return reject(err);
|
||||||
const zipFile = zip;
|
const zipFile = zip;
|
||||||
let settled = false;
|
let settled = false;
|
||||||
|
const total = zipFile.entryCount || 0;
|
||||||
|
let done = 0;
|
||||||
|
|
||||||
const safeClose = (): void => {
|
const safeClose = (): void => {
|
||||||
try {
|
try {
|
||||||
@@ -37,6 +43,14 @@ export function unzipToDir(zipPath: string, outDir: string): Promise<void> {
|
|||||||
zipFile.readEntry();
|
zipFile.readEntry();
|
||||||
zipFile.on('entry', (entry: yauzl.Entry) => {
|
zipFile.on('entry', (entry: yauzl.Entry) => {
|
||||||
if (settled) return;
|
if (settled) return;
|
||||||
|
done += 1;
|
||||||
|
if (onProgress && total > 0) {
|
||||||
|
try {
|
||||||
|
onProgress(done, total);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
const filePath = path.join(outDir, entry.fileName);
|
const filePath = path.join(outDir, entry.fileName);
|
||||||
if (entry.fileName.endsWith('/')) {
|
if (entry.fileName.endsWith('/')) {
|
||||||
fssync.mkdirSync(filePath, { recursive: true });
|
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, /if \(this\.openProject\?\.id === projectId\)\s*\{\s*await this\.saveNow\(\);\s*\}/);
|
||||||
assert.match(src, /await fs\.copyFile\(src, dest\)/);
|
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
@@ -25,7 +25,9 @@ import { getAppSemanticVersion } from '../versionInfo';
|
|||||||
|
|
||||||
import { reconcileAssetFiles } from './assetPrune';
|
import { reconcileAssetFiles } from './assetPrune';
|
||||||
import { rmWithRetries } from './fsRetry';
|
import { rmWithRetries } from './fsRetry';
|
||||||
|
import { optimizeImageBufferVisuallyLossless } from './optimizeImageImport.lib.mjs';
|
||||||
import { getLegacyProjectsRootDirs, getProjectsCacheRootDir, getProjectsRootDir } from './paths';
|
import { getLegacyProjectsRootDirs, getProjectsCacheRootDir, getProjectsRootDir } from './paths';
|
||||||
|
import { generateScenePreviewThumbnailBytes } from './scenePreviewThumbnail';
|
||||||
import { readProjectJsonFromZip, unzipToDir } from './yauzlProjectZip';
|
import { readProjectJsonFromZip, unzipToDir } from './yauzlProjectZip';
|
||||||
|
|
||||||
type ProjectIndexEntry = {
|
type ProjectIndexEntry = {
|
||||||
@@ -213,6 +215,44 @@ export class ZipProjectStore {
|
|||||||
return project;
|
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 {
|
getOpenProject(): Project | null {
|
||||||
return this.openProject?.project ?? null;
|
return this.openProject?.project ?? null;
|
||||||
}
|
}
|
||||||
@@ -245,35 +285,78 @@ export class ZipProjectStore {
|
|||||||
const sc = open.project.scenes[sceneId];
|
const sc = open.project.scenes[sceneId];
|
||||||
if (!sc) throw new Error('Scene not found');
|
if (!sc) throw new Error('Scene not found');
|
||||||
|
|
||||||
const kind = classifyMediaPath(filePath);
|
const kind0 = classifyMediaPath(filePath);
|
||||||
if (kind?.type !== 'image' && kind?.type !== 'video') {
|
if (!kind0 || (kind0.type !== 'image' && kind0.type !== 'video')) {
|
||||||
throw new Error('Файл превью должен быть изображением или видео');
|
throw new Error('Файл превью должен быть изображением или видео');
|
||||||
}
|
}
|
||||||
|
let kind: MediaKind = kind0;
|
||||||
|
|
||||||
const buf = await fs.readFile(filePath);
|
const buf = await fs.readFile(filePath);
|
||||||
const sha256 = crypto.createHash('sha256').update(buf).digest('hex');
|
|
||||||
const id = asAssetId(this.randomId());
|
const id = asAssetId(this.randomId());
|
||||||
const orig = path.basename(filePath);
|
const orig = path.basename(filePath);
|
||||||
const safeOrig = sanitizeFileName(orig);
|
let safeOrig = sanitizeFileName(orig);
|
||||||
const relPath = `assets/${id}_${safeOrig}`;
|
let relPath = `assets/${id}_${safeOrig}`;
|
||||||
const abs = path.join(open.cacheDir, relPath);
|
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.mkdir(path.dirname(abs), { recursive: true });
|
||||||
await fs.copyFile(filePath, abs);
|
await fs.writeFile(abs, writeBuf);
|
||||||
const asset = buildMediaAsset(id, kind, orig, relPath, sha256, buf.length);
|
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 oldPreviewId = sc.previewAssetId;
|
||||||
|
const oldThumbId = sc.previewThumbAssetId ?? null;
|
||||||
|
|
||||||
await this.updateProject((p) => {
|
await this.updateProject((p) => {
|
||||||
const scene = p.scenes[sceneId];
|
const scene = p.scenes[sceneId];
|
||||||
if (!scene) throw new Error('Scene not found');
|
if (!scene) throw new Error('Scene not found');
|
||||||
let assets: Record<AssetId, MediaAsset> = { ...p.assets };
|
let assets: Record<AssetId, MediaAsset> = { ...p.assets };
|
||||||
if (oldPreviewId) {
|
const drop = new Set<AssetId>();
|
||||||
assets = Object.fromEntries(Object.entries(assets).filter(([k]) => k !== oldPreviewId)) as Record<
|
if (oldPreviewId) drop.add(oldPreviewId);
|
||||||
AssetId,
|
if (oldThumbId) drop.add(oldThumbId);
|
||||||
MediaAsset
|
if (drop.size > 0) {
|
||||||
>;
|
assets = Object.fromEntries(
|
||||||
|
Object.entries(assets).filter(([k]) => !drop.has(k as AssetId)),
|
||||||
|
) as Record<AssetId, MediaAsset>;
|
||||||
}
|
}
|
||||||
assets[id] = asset;
|
assets[id] = asset;
|
||||||
|
if (thumbAsset !== null && thumbId !== null) {
|
||||||
|
assets[thumbId] = thumbAsset;
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
...p,
|
...p,
|
||||||
assets,
|
assets,
|
||||||
@@ -283,6 +366,7 @@ export class ZipProjectStore {
|
|||||||
...scene,
|
...scene,
|
||||||
previewAssetId: id,
|
previewAssetId: id,
|
||||||
previewAssetType: kind.type,
|
previewAssetType: kind.type,
|
||||||
|
previewThumbAssetId: thumbId,
|
||||||
previewVideoAutostart: kind.type === 'video' ? scene.previewVideoAutostart : false,
|
previewVideoAutostart: kind.type === 'video' ? scene.previewVideoAutostart : false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -300,14 +384,17 @@ export class ZipProjectStore {
|
|||||||
const sc = open.project.scenes[sceneId];
|
const sc = open.project.scenes[sceneId];
|
||||||
if (!sc) throw new Error('Scene not found');
|
if (!sc) throw new Error('Scene not found');
|
||||||
const oldId = sc.previewAssetId;
|
const oldId = sc.previewAssetId;
|
||||||
if (!oldId) {
|
const oldThumbId = sc.previewThumbAssetId ?? null;
|
||||||
|
if (!oldId && !oldThumbId) {
|
||||||
return open.project;
|
return open.project;
|
||||||
}
|
}
|
||||||
await this.updateProject((p) => {
|
await this.updateProject((p) => {
|
||||||
const assets = Object.fromEntries(Object.entries(p.assets).filter(([k]) => k !== oldId)) as Record<
|
const drop = new Set<AssetId>();
|
||||||
AssetId,
|
if (oldId) drop.add(oldId);
|
||||||
MediaAsset
|
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 {
|
return {
|
||||||
...p,
|
...p,
|
||||||
assets,
|
assets,
|
||||||
@@ -317,6 +404,7 @@ export class ZipProjectStore {
|
|||||||
...p.scenes[sceneId],
|
...p.scenes[sceneId],
|
||||||
previewAssetId: null,
|
previewAssetId: null,
|
||||||
previewAssetType: null,
|
previewAssetType: null,
|
||||||
|
previewThumbAssetId: null,
|
||||||
previewVideoAutostart: false,
|
previewVideoAutostart: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -355,6 +443,7 @@ export class ZipProjectStore {
|
|||||||
layout: { x: 0, y: 0 },
|
layout: { x: 0, y: 0 },
|
||||||
previewAssetId: null,
|
previewAssetId: null,
|
||||||
previewAssetType: null,
|
previewAssetType: null,
|
||||||
|
previewThumbAssetId: null,
|
||||||
previewVideoAutostart: false,
|
previewVideoAutostart: false,
|
||||||
previewRotationDeg: 0,
|
previewRotationDeg: 0,
|
||||||
} satisfies Scene);
|
} satisfies Scene);
|
||||||
@@ -365,6 +454,9 @@ export class ZipProjectStore {
|
|||||||
...(patch.description !== undefined ? { description: patch.description } : null),
|
...(patch.description !== undefined ? { description: patch.description } : null),
|
||||||
...(patch.previewAssetId !== undefined ? { previewAssetId: patch.previewAssetId } : null),
|
...(patch.previewAssetId !== undefined ? { previewAssetId: patch.previewAssetId } : null),
|
||||||
...(patch.previewAssetType !== undefined ? { previewAssetType: patch.previewAssetType } : null),
|
...(patch.previewAssetType !== undefined ? { previewAssetType: patch.previewAssetType } : null),
|
||||||
|
...(patch.previewThumbAssetId !== undefined
|
||||||
|
? { previewThumbAssetId: patch.previewThumbAssetId }
|
||||||
|
: null),
|
||||||
...(patch.previewVideoAutostart !== undefined
|
...(patch.previewVideoAutostart !== undefined
|
||||||
? { previewVideoAutostart: patch.previewVideoAutostart }
|
? { previewVideoAutostart: patch.previewVideoAutostart }
|
||||||
: null),
|
: null),
|
||||||
@@ -784,7 +876,10 @@ export class ZipProjectStore {
|
|||||||
* Если архив уже лежит в `projects`, только открывает.
|
* Если архив уже лежит в `projects`, только открывает.
|
||||||
* При конфликте `id` с другим файлом перезаписывает `project.json` в копии с новым id.
|
* При конфликте `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();
|
await this.ensureRoots();
|
||||||
const resolved = path.resolve(sourcePath);
|
const resolved = path.resolve(sourcePath);
|
||||||
const st = await fs.stat(resolved).catch(() => null);
|
const st = await fs.stat(resolved).catch(() => null);
|
||||||
@@ -809,7 +904,12 @@ export class ZipProjectStore {
|
|||||||
} else {
|
} else {
|
||||||
destFileName = await uniqueDndZipFileName(root, baseName);
|
destFileName = await uniqueDndZipFileName(root, baseName);
|
||||||
destPath = path.join(root, destFileName);
|
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);
|
let project = await readProjectJsonFromZip(destPath);
|
||||||
@@ -832,11 +932,19 @@ export class ZipProjectStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.projectSession += 1;
|
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`). */
|
/** Копия файла проекта в указанный путь (полный путь к `.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();
|
await this.ensureRoots();
|
||||||
// If exporting the currently open project, make sure pending debounced pack is flushed.
|
// If exporting the currently open project, make sure pending debounced pack is flushed.
|
||||||
if (this.openProject?.id === projectId) {
|
if (this.openProject?.id === projectId) {
|
||||||
@@ -850,7 +958,11 @@ export class ZipProjectStore {
|
|||||||
const src = path.join(getProjectsRootDir(), entry.fileName);
|
const src = path.join(getProjectsRootDir(), entry.fileName);
|
||||||
const dest = path.resolve(destinationPath);
|
const dest = path.resolve(destinationPath);
|
||||||
await fs.mkdir(path.dirname(dest), { recursive: true });
|
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 })
|
(s as unknown as { previewVideoAutostostart?: boolean; previewVideoAutostart?: boolean })
|
||||||
.previewVideoAutostart,
|
.previewVideoAutostart,
|
||||||
);
|
);
|
||||||
|
const previewThumbAssetId =
|
||||||
|
(s as unknown as { previewThumbAssetId?: AssetId | null }).previewThumbAssetId ?? null;
|
||||||
|
|
||||||
const rawAudios = Array.isArray(raw.audios) ? raw.audios : [];
|
const rawAudios = Array.isArray(raw.audios) ? raw.audios : [];
|
||||||
const audios = rawAudios
|
const audios = rawAudios
|
||||||
@@ -1017,6 +1131,7 @@ function normalizeScene(s: Scene): Scene {
|
|||||||
...s,
|
...s,
|
||||||
previewAssetId: previewAssetId ?? null,
|
previewAssetId: previewAssetId ?? null,
|
||||||
previewAssetType,
|
previewAssetType,
|
||||||
|
previewThumbAssetId,
|
||||||
previewVideoAutostart,
|
previewVideoAutostart,
|
||||||
previewRotationDeg,
|
previewRotationDeg,
|
||||||
layout: layoutIn ?? { x: 0, y: 0 },
|
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 };
|
type MediaKind = { type: MediaAssetType; mime: string };
|
||||||
|
|
||||||
function classifyMediaPath(filePath: string): MediaKind | null {
|
function classifyMediaPath(filePath: string): MediaKind | null {
|
||||||
@@ -1139,6 +1288,8 @@ function classifyMediaPath(filePath: string): MediaKind | null {
|
|||||||
return { type: 'image', mime: 'image/webp' };
|
return { type: 'image', mime: 'image/webp' };
|
||||||
case '.gif':
|
case '.gif':
|
||||||
return { type: 'image', mime: 'image/gif' };
|
return { type: 'image', mime: 'image/gif' };
|
||||||
|
case '.bmp':
|
||||||
|
return { type: 'image', mime: 'image/bmp' };
|
||||||
case '.mp4':
|
case '.mp4':
|
||||||
return { type: 'video', mime: 'video/mp4' };
|
return { type: 'video', mime: 'video/mp4' };
|
||||||
case '.webm':
|
case '.webm':
|
||||||
|
|||||||
@@ -124,6 +124,52 @@
|
|||||||
background: var(--bg0);
|
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 {
|
.inspectorTitle {
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ type SceneCard = {
|
|||||||
title: string;
|
title: string;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
previewAssetId: AssetId | null;
|
previewAssetId: AssetId | null;
|
||||||
|
previewThumbAssetId: AssetId | null;
|
||||||
previewAssetType: 'image' | 'video' | null;
|
previewAssetType: 'image' | 'video' | null;
|
||||||
previewVideoAutostart: boolean;
|
previewVideoAutostart: boolean;
|
||||||
previewRotationDeg: 0 | 90 | 180 | 270;
|
previewRotationDeg: 0 | 90 | 180 | 270;
|
||||||
@@ -92,6 +93,7 @@ export function EditorApp() {
|
|||||||
title: s.title,
|
title: s.title,
|
||||||
active: s.id === state.selectedSceneId,
|
active: s.id === state.selectedSceneId,
|
||||||
previewAssetId: s.previewAssetId,
|
previewAssetId: s.previewAssetId,
|
||||||
|
previewThumbAssetId: s.previewThumbAssetId,
|
||||||
previewAssetType: s.previewAssetType,
|
previewAssetType: s.previewAssetType,
|
||||||
previewVideoAutostart: s.previewVideoAutostart,
|
previewVideoAutostart: s.previewVideoAutostart,
|
||||||
previewRotationDeg: s.previewRotationDeg,
|
previewRotationDeg: s.previewRotationDeg,
|
||||||
@@ -263,6 +265,28 @@ export function EditorApp() {
|
|||||||
|
|
||||||
return (
|
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
|
<LayoutShell
|
||||||
bodyOverlay={bodyOverlay}
|
bodyOverlay={bodyOverlay}
|
||||||
topBar={
|
topBar={
|
||||||
@@ -1276,7 +1300,8 @@ type SceneListCardProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function SceneListCard({ scene, onSelect, onDeleteScene }: 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);
|
const [menu, setMenu] = useState<{ x: number; y: number } | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -1315,21 +1340,31 @@ function SceneListCard({ scene, onSelect, onDeleteScene }: SceneListCardProps) {
|
|||||||
if (e.key === 'Enter' || e.key === ' ') onSelect();
|
if (e.key === 'Enter' || e.key === ' ') onSelect();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className={url ? styles.sceneThumb : styles.sceneThumbEmpty}>
|
<div className={thumbUrl || previewUrl ? styles.sceneThumb : styles.sceneThumbEmpty}>
|
||||||
{url && scene.previewAssetType === 'image' ? (
|
{thumbUrl ? (
|
||||||
<div className={styles.sceneThumbInner}>
|
<div className={styles.sceneThumbInner}>
|
||||||
<RotatedImage
|
<RotatedImage
|
||||||
url={url}
|
url={thumbUrl}
|
||||||
rotationDeg={scene.previewRotationDeg}
|
rotationDeg={scene.previewRotationDeg}
|
||||||
mode="cover"
|
mode="cover"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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}>
|
<div className={styles.sceneThumbInner}>
|
||||||
<video
|
<video
|
||||||
src={url}
|
src={previewUrl}
|
||||||
muted
|
muted
|
||||||
playsInline
|
playsInline
|
||||||
preload="metadata"
|
preload="metadata"
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export type SceneGraphSceneAudioSummary = {
|
|||||||
export type SceneGraphSceneCard = {
|
export type SceneGraphSceneCard = {
|
||||||
title: string;
|
title: string;
|
||||||
previewAssetId: AssetId | null;
|
previewAssetId: AssetId | null;
|
||||||
|
previewThumbAssetId: AssetId | null;
|
||||||
previewAssetType: 'image' | 'video' | null;
|
previewAssetType: 'image' | 'video' | null;
|
||||||
previewVideoAutostart: boolean;
|
previewVideoAutostart: boolean;
|
||||||
previewRotationDeg: 0 | 90 | 180 | 270;
|
previewRotationDeg: 0 | 90 | 180 | 270;
|
||||||
@@ -69,6 +70,7 @@ type SceneCardData = {
|
|||||||
title: string;
|
title: string;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
previewAssetId: AssetId | null;
|
previewAssetId: AssetId | null;
|
||||||
|
previewThumbAssetId: AssetId | null;
|
||||||
previewAssetType: 'image' | 'video' | null;
|
previewAssetType: 'image' | 'video' | null;
|
||||||
previewVideoAutostart: boolean;
|
previewVideoAutostart: boolean;
|
||||||
previewRotationDeg: 0 | 90 | 180 | 270;
|
previewRotationDeg: 0 | 90 | 180 | 270;
|
||||||
@@ -129,7 +131,8 @@ function IconVideoPreviewAutostart() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function SceneCardNode({ data }: NodeProps<SceneCardData>) {
|
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 cardClass = [styles.card, data.active ? styles.cardActive : ''].filter(Boolean).join(' ');
|
||||||
const showCornerVideo = data.previewIsVideo;
|
const showCornerVideo = data.previewIsVideo;
|
||||||
const showCornerAudio = data.hasSceneAudio;
|
const showCornerAudio = data.hasSceneAudio;
|
||||||
@@ -139,11 +142,11 @@ function SceneCardNode({ data }: NodeProps<SceneCardData>) {
|
|||||||
<div className={cardClass}>
|
<div className={cardClass}>
|
||||||
<div className={styles.previewShell}>
|
<div className={styles.previewShell}>
|
||||||
{data.isStartScene ? <div className={styles.badgeStart}>НАЧАЛО</div> : null}
|
{data.isStartScene ? <div className={styles.badgeStart}>НАЧАЛО</div> : null}
|
||||||
{url && data.previewAssetType === 'image' ? (
|
{thumbUrl ? (
|
||||||
<div className={styles.previewFill}>
|
<div className={styles.previewFill}>
|
||||||
{data.previewRotationDeg === 0 ? (
|
{data.previewRotationDeg === 0 ? (
|
||||||
<img
|
<img
|
||||||
src={url}
|
src={thumbUrl}
|
||||||
alt=""
|
alt=""
|
||||||
className={styles.imageCover}
|
className={styles.imageCover}
|
||||||
draggable={false}
|
draggable={false}
|
||||||
@@ -152,7 +155,7 @@ function SceneCardNode({ data }: NodeProps<SceneCardData>) {
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<RotatedImage
|
<RotatedImage
|
||||||
url={url}
|
url={thumbUrl}
|
||||||
rotationDeg={data.previewRotationDeg}
|
rotationDeg={data.previewRotationDeg}
|
||||||
mode="cover"
|
mode="cover"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
@@ -161,9 +164,31 @@ function SceneCardNode({ data }: NodeProps<SceneCardData>) {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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
|
<video
|
||||||
src={url}
|
src={previewUrl}
|
||||||
muted
|
muted
|
||||||
playsInline
|
playsInline
|
||||||
preload="metadata"
|
preload="metadata"
|
||||||
@@ -322,6 +347,7 @@ function SceneGraphCanvas({
|
|||||||
title: c?.title ?? '',
|
title: c?.title ?? '',
|
||||||
active,
|
active,
|
||||||
previewAssetId: c?.previewAssetId ?? null,
|
previewAssetId: c?.previewAssetId ?? null,
|
||||||
|
previewThumbAssetId: c?.previewThumbAssetId ?? null,
|
||||||
previewAssetType: c?.previewAssetType ?? null,
|
previewAssetType: c?.previewAssetType ?? null,
|
||||||
previewVideoAutostart: c?.previewVideoAutostart ?? false,
|
previewVideoAutostart: c?.previewVideoAutostart ?? false,
|
||||||
previewRotationDeg: c?.previewRotationDeg ?? 0,
|
previewRotationDeg: c?.previewRotationDeg ?? 0,
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ void test('buildNextSceneCardById: does not change refs when irrelevant fields c
|
|||||||
connections: [],
|
connections: [],
|
||||||
layout: { x: 0, y: 0 },
|
layout: { x: 0, y: 0 },
|
||||||
previewAssetId: null,
|
previewAssetId: null,
|
||||||
|
previewThumbAssetId: null,
|
||||||
previewAssetType: null,
|
previewAssetType: null,
|
||||||
previewVideoAutostart: false,
|
previewVideoAutostart: false,
|
||||||
previewRotationDeg: 0,
|
previewRotationDeg: 0,
|
||||||
@@ -79,6 +80,7 @@ void test('buildNextSceneCardById: changes card when title changes', () => {
|
|||||||
connections: [],
|
connections: [],
|
||||||
layout: { x: 0, y: 0 },
|
layout: { x: 0, y: 0 },
|
||||||
previewAssetId: null,
|
previewAssetId: null,
|
||||||
|
previewThumbAssetId: null,
|
||||||
previewAssetType: null,
|
previewAssetType: null,
|
||||||
previewVideoAutostart: false,
|
previewVideoAutostart: false,
|
||||||
previewRotationDeg: 0,
|
previewRotationDeg: 0,
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ export function buildNextSceneCardById(
|
|||||||
if (
|
if (
|
||||||
prevCard?.title === s.title &&
|
prevCard?.title === s.title &&
|
||||||
prevCard.previewAssetId === s.previewAssetId &&
|
prevCard.previewAssetId === s.previewAssetId &&
|
||||||
|
prevCard.previewThumbAssetId === s.previewThumbAssetId &&
|
||||||
prevCard.previewAssetType === s.previewAssetType &&
|
prevCard.previewAssetType === s.previewAssetType &&
|
||||||
prevCard.previewVideoAutostart === s.previewVideoAutostart &&
|
prevCard.previewVideoAutostart === s.previewVideoAutostart &&
|
||||||
prevCard.previewRotationDeg === s.previewRotationDeg &&
|
prevCard.previewRotationDeg === s.previewRotationDeg &&
|
||||||
@@ -49,6 +50,7 @@ export function buildNextSceneCardById(
|
|||||||
nextMap[id] = {
|
nextMap[id] = {
|
||||||
title: s.title,
|
title: s.title,
|
||||||
previewAssetId: s.previewAssetId,
|
previewAssetId: s.previewAssetId,
|
||||||
|
previewThumbAssetId: s.previewThumbAssetId,
|
||||||
previewAssetType: s.previewAssetType,
|
previewAssetType: s.previewAssetType,
|
||||||
previewVideoAutostart: s.previewVideoAutostart,
|
previewVideoAutostart: s.previewVideoAutostart,
|
||||||
previewRotationDeg: s.previewRotationDeg,
|
previewRotationDeg: s.previewRotationDeg,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ type State = {
|
|||||||
projects: ProjectSummary[];
|
projects: ProjectSummary[];
|
||||||
project: Project | null;
|
project: Project | null;
|
||||||
selectedSceneId: SceneId | null;
|
selectedSceneId: SceneId | null;
|
||||||
|
zipProgress: { kind: 'import' | 'export'; percent: number; stage: string; detail?: string } | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type Actions = {
|
type Actions = {
|
||||||
@@ -27,6 +28,7 @@ type Actions = {
|
|||||||
title?: string;
|
title?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
previewAssetId?: AssetId | null;
|
previewAssetId?: AssetId | null;
|
||||||
|
previewThumbAssetId?: AssetId | null;
|
||||||
previewAssetType?: 'image' | 'video' | null;
|
previewAssetType?: 'image' | 'video' | null;
|
||||||
previewVideoAutostart?: boolean;
|
previewVideoAutostart?: boolean;
|
||||||
previewRotationDeg?: 0 | 90 | 180 | 270;
|
previewRotationDeg?: 0 | 90 | 180 | 270;
|
||||||
@@ -58,7 +60,12 @@ function randomId(prefix: string): string {
|
|||||||
|
|
||||||
export function useProjectState(licenseActive: boolean): readonly [State, Actions] {
|
export function useProjectState(licenseActive: boolean): readonly [State, Actions] {
|
||||||
const api = getDndApi();
|
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);
|
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). */
|
/** Bumps on mutations / refresh; initial license load only applies if still current (avoids racing late list/get over newer state). */
|
||||||
const projectDataEpochRef = useRef(0);
|
const projectDataEpochRef = useRef(0);
|
||||||
@@ -66,6 +73,43 @@ export function useProjectState(licenseActive: boolean): readonly [State, Action
|
|||||||
projectRef.current = state.project;
|
projectRef.current = state.project;
|
||||||
}, [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 actions = useMemo<Actions>(() => {
|
||||||
const refreshProjects = async () => {
|
const refreshProjects = async () => {
|
||||||
projectDataEpochRef.current += 1;
|
projectDataEpochRef.current += 1;
|
||||||
@@ -99,6 +143,7 @@ export function useProjectState(licenseActive: boolean): readonly [State, Action
|
|||||||
title: `Новая сцена`,
|
title: `Новая сцена`,
|
||||||
description: '',
|
description: '',
|
||||||
previewAssetId: null,
|
previewAssetId: null,
|
||||||
|
previewThumbAssetId: null,
|
||||||
previewAssetType: null,
|
previewAssetType: null,
|
||||||
previewVideoAutostart: false,
|
previewVideoAutostart: false,
|
||||||
previewRotationDeg: 0,
|
previewRotationDeg: 0,
|
||||||
@@ -153,6 +198,7 @@ export function useProjectState(licenseActive: boolean): readonly [State, Action
|
|||||||
title?: string;
|
title?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
previewAssetId?: AssetId | null;
|
previewAssetId?: AssetId | null;
|
||||||
|
previewThumbAssetId?: AssetId | null;
|
||||||
previewAssetType?: 'image' | 'video' | null;
|
previewAssetType?: 'image' | 'video' | null;
|
||||||
previewVideoAutostart?: boolean;
|
previewVideoAutostart?: boolean;
|
||||||
previewRotationDeg?: 0 | 90 | 180 | 270;
|
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.title !== undefined ? { title: patch.title } : null),
|
||||||
...(patch.description !== undefined ? { description: patch.description } : null),
|
...(patch.description !== undefined ? { description: patch.description } : null),
|
||||||
...(patch.previewAssetId !== undefined ? { previewAssetId: patch.previewAssetId } : null),
|
...(patch.previewAssetId !== undefined ? { previewAssetId: patch.previewAssetId } : null),
|
||||||
|
...(patch.previewThumbAssetId !== undefined
|
||||||
|
? { previewThumbAssetId: patch.previewThumbAssetId }
|
||||||
|
: null),
|
||||||
...(patch.previewAssetType !== undefined ? { previewAssetType: patch.previewAssetType } : null),
|
...(patch.previewAssetType !== undefined ? { previewAssetType: patch.previewAssetType } : null),
|
||||||
...(patch.previewVideoAutostart !== undefined
|
...(patch.previewVideoAutostart !== undefined
|
||||||
? { previewVideoAutostart: patch.previewVideoAutostart }
|
? { previewVideoAutostart: patch.previewVideoAutostart }
|
||||||
@@ -342,7 +391,7 @@ export function useProjectState(licenseActive: boolean): readonly [State, Action
|
|||||||
if (!licenseActive) {
|
if (!licenseActive) {
|
||||||
queueMicrotask(() => {
|
queueMicrotask(() => {
|
||||||
projectDataEpochRef.current += 1;
|
projectDataEpochRef.current += 1;
|
||||||
setState({ projects: [], project: null, selectedSceneId: null });
|
setState({ projects: [], project: null, selectedSceneId: null, zipProgress: null });
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useEffect, useRef } from 'react';
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { computeTimeSec } from '../../main/video/videoPlaybackStore';
|
import { computeTimeSec } from '../../main/video/videoPlaybackStore';
|
||||||
import type { SessionState } from '../../shared/ipc/contracts';
|
import type { SessionState } from '../../shared/ipc/contracts';
|
||||||
@@ -32,9 +32,39 @@ export function PresentationView({
|
|||||||
);
|
);
|
||||||
const scene =
|
const scene =
|
||||||
session?.project && session.currentSceneId ? session.project.scenes[session.currentSceneId] : undefined;
|
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;
|
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(() => {
|
useEffect(() => {
|
||||||
const el = videoElRef.current;
|
const el = videoElRef.current;
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
@@ -57,7 +87,7 @@ export function PresentationView({
|
|||||||
}, [
|
}, [
|
||||||
scene?.previewAssetId,
|
scene?.previewAssetId,
|
||||||
scene?.previewAssetType,
|
scene?.previewAssetType,
|
||||||
previewUrl,
|
originalUrl,
|
||||||
vp?.revision,
|
vp?.revision,
|
||||||
vp?.targetAssetId,
|
vp?.targetAssetId,
|
||||||
vp?.playing,
|
vp?.playing,
|
||||||
@@ -66,20 +96,20 @@ export function PresentationView({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.root}>
|
<div className={styles.root}>
|
||||||
{previewUrl && scene?.previewAssetType === 'image' ? (
|
{shownImageUrl && scene?.previewAssetType === 'image' ? (
|
||||||
<div className={styles.fill}>
|
<div className={styles.fill}>
|
||||||
<RotatedImage
|
<RotatedImage
|
||||||
url={previewUrl}
|
url={shownImageUrl}
|
||||||
rotationDeg={rot}
|
rotationDeg={rot}
|
||||||
mode="contain"
|
mode="contain"
|
||||||
onContentRectChange={setContentRect}
|
onContentRectChange={setContentRect}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : previewUrl && scene?.previewAssetType === 'video' ? (
|
) : originalUrl && scene?.previewAssetType === 'video' ? (
|
||||||
<video
|
<video
|
||||||
ref={videoElRef}
|
ref={videoElRef}
|
||||||
className={styles.video}
|
className={styles.video}
|
||||||
src={previewUrl}
|
src={originalUrl}
|
||||||
muted
|
muted
|
||||||
playsInline
|
playsInline
|
||||||
loop={false}
|
loop={false}
|
||||||
|
|||||||
@@ -45,6 +45,8 @@ export const ipcChannels = {
|
|||||||
importZip: 'project.importZip',
|
importZip: 'project.importZip',
|
||||||
exportZip: 'project.exportZip',
|
exportZip: 'project.exportZip',
|
||||||
deleteProject: 'project.deleteProject',
|
deleteProject: 'project.deleteProject',
|
||||||
|
importZipProgress: 'project.importZipProgress',
|
||||||
|
exportZipProgress: 'project.exportZipProgress',
|
||||||
},
|
},
|
||||||
windows: {
|
windows: {
|
||||||
openMultiWindow: 'windows.openMultiWindow',
|
openMultiWindow: 'windows.openMultiWindow',
|
||||||
@@ -73,6 +75,22 @@ export const ipcChannels = {
|
|||||||
},
|
},
|
||||||
} as const;
|
} 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 = {
|
export type IpcInvokeMap = {
|
||||||
[ipcChannels.app.quit]: {
|
[ipcChannels.app.quit]: {
|
||||||
req: Record<string, never>;
|
req: Record<string, never>;
|
||||||
@@ -237,7 +255,7 @@ export type SessionState = {
|
|||||||
currentSceneId: SceneId | null;
|
currentSceneId: SceneId | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type IpcEventMap = {
|
export type LegacyIpcEventMap = {
|
||||||
[ipcChannels.session.stateChanged]: { state: SessionState };
|
[ipcChannels.session.stateChanged]: { state: SessionState };
|
||||||
[ipcChannels.effects.stateChanged]: { state: EffectsState };
|
[ipcChannels.effects.stateChanged]: { state: EffectsState };
|
||||||
[ipcChannels.video.stateChanged]: { state: VideoPlaybackState };
|
[ipcChannels.video.stateChanged]: { state: VideoPlaybackState };
|
||||||
@@ -249,6 +267,7 @@ export type ScenePatch = {
|
|||||||
description?: string;
|
description?: string;
|
||||||
previewAssetId?: AssetId | null;
|
previewAssetId?: AssetId | null;
|
||||||
previewAssetType?: 'image' | 'video' | null;
|
previewAssetType?: 'image' | 'video' | null;
|
||||||
|
previewThumbAssetId?: AssetId | null;
|
||||||
previewVideoAutostart?: boolean;
|
previewVideoAutostart?: boolean;
|
||||||
previewRotationDeg?: 0 | 90 | 180 | 270;
|
previewRotationDeg?: 0 | 90 | 180 | 270;
|
||||||
settings?: Partial<Scene['settings']>;
|
settings?: Partial<Scene['settings']>;
|
||||||
|
|||||||
@@ -84,6 +84,8 @@ export type Scene = {
|
|||||||
/** Превью ассет (изображение или видео). */
|
/** Превью ассет (изображение или видео). */
|
||||||
previewAssetId: AssetId | null;
|
previewAssetId: AssetId | null;
|
||||||
previewAssetType: 'image' | 'video' | null;
|
previewAssetType: 'image' | 'video' | null;
|
||||||
|
/** Уменьшенное изображение для графа/списков; оригинал — в `previewAssetId`. */
|
||||||
|
previewThumbAssetId: AssetId | null;
|
||||||
/** Для видео-превью: автозапуск (в редакторе/списках/на графе). */
|
/** Для видео-превью: автозапуск (в редакторе/списках/на графе). */
|
||||||
previewVideoAutostart: boolean;
|
previewVideoAutostart: boolean;
|
||||||
/** Поворот превью в градусах (0/90/180/270). */
|
/** Поворот превью в градусах (0/90/180/270). */
|
||||||
|
|||||||
@@ -61,7 +61,7 @@
|
|||||||
|
|
||||||
- **Граф сцен**: показывает только thumbnail.
|
- **Граф сцен**: показывает только thumbnail.
|
||||||
- **Список сцен**: показывает только thumbnail.
|
- **Список сцен**: показывает только thumbnail.
|
||||||
- **Инспектор**: по желанию — thumbnail или оригинал (можно оставить оригинал только тут).
|
- **Инспектор**: по желанию — только thumbnail.
|
||||||
- **Презентация/просмотр**: оригинал.
|
- **Презентация/просмотр**: оригинал.
|
||||||
|
|
||||||
#### 2.5 Обратная совместимость
|
#### 2.5 Обратная совместимость
|
||||||
|
|||||||
+12
-1
@@ -12,7 +12,18 @@ const tsProject = ['./tsconfig.eslint.json'];
|
|||||||
|
|
||||||
export default tseslint.config(
|
export default tseslint.config(
|
||||||
{
|
{
|
||||||
ignores: ['dist/**', 'release/**', 'node_modules/**', '.cursor/**', 'scripts/**', 'eslint.config.js'],
|
ignores: [
|
||||||
|
'dist/**',
|
||||||
|
'release/**',
|
||||||
|
'node_modules/**',
|
||||||
|
'.cursor/**',
|
||||||
|
'scripts/**',
|
||||||
|
'tools/**',
|
||||||
|
'eslint.config.js',
|
||||||
|
// Plain ESM; shared with tools/project-converter (not parsed as TS project file).
|
||||||
|
'app/main/project/optimizeImageImport.lib.mjs',
|
||||||
|
'app/main/project/optimizeImageImport.lib.d.mts',
|
||||||
|
],
|
||||||
},
|
},
|
||||||
js.configs.recommended,
|
js.configs.recommended,
|
||||||
...tseslint.configs.strictTypeChecked,
|
...tseslint.configs.strictTypeChecked,
|
||||||
|
|||||||
Generated
+628
-13
@@ -10,10 +10,12 @@
|
|||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource/inter": "^5.2.8",
|
"@fontsource/inter": "^5.2.8",
|
||||||
|
"ffmpeg-static": "^5.3.0",
|
||||||
"pixi.js": "^8.18.1",
|
"pixi.js": "^8.18.1",
|
||||||
"react": "^19.2.5",
|
"react": "^19.2.5",
|
||||||
"react-dom": "^19.2.5",
|
"react-dom": "^19.2.5",
|
||||||
"reactflow": "^11.11.4"
|
"reactflow": "^11.11.4",
|
||||||
|
"sharp": "^0.34.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.4",
|
"@eslint/js": "^9.39.4",
|
||||||
@@ -289,6 +291,21 @@
|
|||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@derhuerst/http-basic": {
|
||||||
|
"version": "8.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@derhuerst/http-basic/-/http-basic-8.2.4.tgz",
|
||||||
|
"integrity": "sha512-F9rL9k9Xjf5blCz8HsJRO4diy111cayL2vkY2XE4r4t3n0yPXVYy3KD3nJ1qbrSn9743UWSXH4IwuCa/HWlGFw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"caseless": "^0.12.0",
|
||||||
|
"concat-stream": "^2.0.0",
|
||||||
|
"http-response-object": "^3.0.1",
|
||||||
|
"parse-cache-control": "^1.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@develar/schema-utils": {
|
"node_modules/@develar/schema-utils": {
|
||||||
"version": "2.6.5",
|
"version": "2.6.5",
|
||||||
"resolved": "https://registry.npmjs.org/@develar/schema-utils/-/schema-utils-2.6.5.tgz",
|
"resolved": "https://registry.npmjs.org/@develar/schema-utils/-/schema-utils-2.6.5.tgz",
|
||||||
@@ -1472,6 +1489,471 @@
|
|||||||
"url": "https://github.com/sponsors/nzakas"
|
"url": "https://github.com/sponsors/nzakas"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@img/colour": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-darwin-arm64": {
|
||||||
|
"version": "0.34.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
|
||||||
|
"integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-darwin-arm64": "1.2.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-darwin-x64": {
|
||||||
|
"version": "0.34.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
|
||||||
|
"integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-darwin-x64": "1.2.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-darwin-arm64": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-darwin-x64": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linux-arm": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linux-arm64": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linux-ppc64": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linux-riscv64": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linux-s390x": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==",
|
||||||
|
"cpu": [
|
||||||
|
"s390x"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linux-x64": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linux-arm": {
|
||||||
|
"version": "0.34.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
|
||||||
|
"integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linux-arm": "1.2.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linux-arm64": {
|
||||||
|
"version": "0.34.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
|
||||||
|
"integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linux-arm64": "1.2.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linux-ppc64": {
|
||||||
|
"version": "0.34.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz",
|
||||||
|
"integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linux-ppc64": "1.2.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linux-riscv64": {
|
||||||
|
"version": "0.34.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz",
|
||||||
|
"integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linux-riscv64": "1.2.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linux-s390x": {
|
||||||
|
"version": "0.34.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz",
|
||||||
|
"integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==",
|
||||||
|
"cpu": [
|
||||||
|
"s390x"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linux-s390x": "1.2.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linux-x64": {
|
||||||
|
"version": "0.34.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
|
||||||
|
"integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linux-x64": "1.2.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linuxmusl-arm64": {
|
||||||
|
"version": "0.34.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
|
||||||
|
"integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linuxmusl-x64": {
|
||||||
|
"version": "0.34.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
|
||||||
|
"integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linuxmusl-x64": "1.2.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-wasm32": {
|
||||||
|
"version": "0.34.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz",
|
||||||
|
"integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==",
|
||||||
|
"cpu": [
|
||||||
|
"wasm32"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@emnapi/runtime": "^1.7.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-win32-arm64": {
|
||||||
|
"version": "0.34.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz",
|
||||||
|
"integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-win32-ia32": {
|
||||||
|
"version": "0.34.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz",
|
||||||
|
"integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-win32-x64": {
|
||||||
|
"version": "0.34.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
|
||||||
|
"integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@inversifyjs/common": {
|
"node_modules/@inversifyjs/common": {
|
||||||
"version": "1.3.3",
|
"version": "1.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/@inversifyjs/common/-/common-1.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/@inversifyjs/common/-/common-1.3.3.tgz",
|
||||||
@@ -4478,7 +4960,6 @@
|
|||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
||||||
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
|
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/builder-util": {
|
"node_modules/builder-util": {
|
||||||
@@ -4767,6 +5248,12 @@
|
|||||||
],
|
],
|
||||||
"license": "CC-BY-4.0"
|
"license": "CC-BY-4.0"
|
||||||
},
|
},
|
||||||
|
"node_modules/caseless": {
|
||||||
|
"version": "0.12.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz",
|
||||||
|
"integrity": "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==",
|
||||||
|
"license": "Apache-2.0"
|
||||||
|
},
|
||||||
"node_modules/chalk": {
|
"node_modules/chalk": {
|
||||||
"version": "4.1.2",
|
"version": "4.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||||
@@ -5034,6 +5521,21 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/concat-stream": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
|
||||||
|
"engines": [
|
||||||
|
"node >= 6.0"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"buffer-from": "^1.0.0",
|
||||||
|
"inherits": "^2.0.3",
|
||||||
|
"readable-stream": "^3.0.2",
|
||||||
|
"typedarray": "^0.0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/conf": {
|
"node_modules/conf": {
|
||||||
"version": "15.0.2",
|
"version": "15.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/conf/-/conf-15.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/conf/-/conf-15.0.2.tgz",
|
||||||
@@ -5398,7 +5900,6 @@
|
|||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ms": "^2.1.3"
|
"ms": "^2.1.3"
|
||||||
@@ -5521,7 +6022,6 @@
|
|||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
@@ -6021,7 +6521,6 @@
|
|||||||
"version": "2.2.1",
|
"version": "2.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz",
|
||||||
"integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==",
|
"integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
@@ -7060,6 +7559,47 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ffmpeg-static": {
|
||||||
|
"version": "5.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ffmpeg-static/-/ffmpeg-static-5.3.0.tgz",
|
||||||
|
"integrity": "sha512-H+K6sW6TiIX6VGend0KQwthe+kaceeH/luE8dIZyOP35ik7ahYojDuqlTV1bOrtEwl01sy2HFNGQfi5IDJvotg==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "GPL-3.0-or-later",
|
||||||
|
"dependencies": {
|
||||||
|
"@derhuerst/http-basic": "^8.2.0",
|
||||||
|
"env-paths": "^2.2.0",
|
||||||
|
"https-proxy-agent": "^5.0.0",
|
||||||
|
"progress": "^2.0.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ffmpeg-static/node_modules/agent-base": {
|
||||||
|
"version": "6.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
|
||||||
|
"integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"debug": "4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ffmpeg-static/node_modules/https-proxy-agent": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"agent-base": "6",
|
||||||
|
"debug": "4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/file-entry-cache": {
|
"node_modules/file-entry-cache": {
|
||||||
"version": "8.0.0",
|
"version": "8.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
|
||||||
@@ -7783,6 +8323,21 @@
|
|||||||
"node": ">= 14"
|
"node": ">= 14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/http-response-object": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/http-response-object/-/http-response-object-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-bqX0XTF6fnXSQcEJ2Iuyr75yVakyjIDCqroJQ/aHfSdlM743Cwqoi2nDYMzLGWUcuTWGWy8AAvOKXTfiv6q9RA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "^10.0.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/http-response-object/node_modules/@types/node": {
|
||||||
|
"version": "10.17.60",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-10.17.60.tgz",
|
||||||
|
"integrity": "sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/http2-wrapper": {
|
"node_modules/http2-wrapper": {
|
||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz",
|
||||||
@@ -7929,7 +8484,6 @@
|
|||||||
"version": "2.0.4",
|
"version": "2.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/internal-slot": {
|
"node_modules/internal-slot": {
|
||||||
@@ -9495,7 +10049,6 @@
|
|||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/multimatch": {
|
"node_modules/multimatch": {
|
||||||
@@ -10052,6 +10605,11 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/parse-cache-control": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/parse-cache-control/-/parse-cache-control-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-60zvsJReQPX5/QP0Kzfd/VrpjScIQ7SHBW6bFCYfEP+fp0Eppr1SHhIO5nd1PjZtvclzSzES9D/p5nFJurwfWg=="
|
||||||
|
},
|
||||||
"node_modules/parse-svg-path": {
|
"node_modules/parse-svg-path": {
|
||||||
"version": "0.1.2",
|
"version": "0.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/parse-svg-path/-/parse-svg-path-0.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/parse-svg-path/-/parse-svg-path-0.1.2.tgz",
|
||||||
@@ -10343,7 +10901,6 @@
|
|||||||
"version": "2.0.3",
|
"version": "2.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",
|
||||||
"integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==",
|
"integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.4.0"
|
"node": ">=0.4.0"
|
||||||
@@ -10486,7 +11043,6 @@
|
|||||||
"version": "3.6.2",
|
"version": "3.6.2",
|
||||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
|
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
|
||||||
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
|
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"inherits": "^2.0.3",
|
"inherits": "^2.0.3",
|
||||||
@@ -10796,7 +11352,6 @@
|
|||||||
"version": "5.2.1",
|
"version": "5.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||||
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
||||||
"dev": true,
|
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@@ -10965,6 +11520,62 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/sharp": {
|
||||||
|
"version": "0.34.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
|
||||||
|
"integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@img/colour": "^1.0.0",
|
||||||
|
"detect-libc": "^2.1.2",
|
||||||
|
"semver": "^7.7.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-darwin-arm64": "0.34.5",
|
||||||
|
"@img/sharp-darwin-x64": "0.34.5",
|
||||||
|
"@img/sharp-libvips-darwin-arm64": "1.2.4",
|
||||||
|
"@img/sharp-libvips-darwin-x64": "1.2.4",
|
||||||
|
"@img/sharp-libvips-linux-arm": "1.2.4",
|
||||||
|
"@img/sharp-libvips-linux-arm64": "1.2.4",
|
||||||
|
"@img/sharp-libvips-linux-ppc64": "1.2.4",
|
||||||
|
"@img/sharp-libvips-linux-riscv64": "1.2.4",
|
||||||
|
"@img/sharp-libvips-linux-s390x": "1.2.4",
|
||||||
|
"@img/sharp-libvips-linux-x64": "1.2.4",
|
||||||
|
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4",
|
||||||
|
"@img/sharp-libvips-linuxmusl-x64": "1.2.4",
|
||||||
|
"@img/sharp-linux-arm": "0.34.5",
|
||||||
|
"@img/sharp-linux-arm64": "0.34.5",
|
||||||
|
"@img/sharp-linux-ppc64": "0.34.5",
|
||||||
|
"@img/sharp-linux-riscv64": "0.34.5",
|
||||||
|
"@img/sharp-linux-s390x": "0.34.5",
|
||||||
|
"@img/sharp-linux-x64": "0.34.5",
|
||||||
|
"@img/sharp-linuxmusl-arm64": "0.34.5",
|
||||||
|
"@img/sharp-linuxmusl-x64": "0.34.5",
|
||||||
|
"@img/sharp-wasm32": "0.34.5",
|
||||||
|
"@img/sharp-win32-arm64": "0.34.5",
|
||||||
|
"@img/sharp-win32-ia32": "0.34.5",
|
||||||
|
"@img/sharp-win32-x64": "0.34.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/sharp/node_modules/semver": {
|
||||||
|
"version": "7.7.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
|
||||||
|
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"bin": {
|
||||||
|
"semver": "bin/semver.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/shebang-command": {
|
"node_modules/shebang-command": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||||
@@ -11244,7 +11855,6 @@
|
|||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
||||||
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
|
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"safe-buffer": "~5.2.0"
|
"safe-buffer": "~5.2.0"
|
||||||
@@ -11782,7 +12392,7 @@
|
|||||||
"version": "2.8.1",
|
"version": "2.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "0BSD"
|
"license": "0BSD"
|
||||||
},
|
},
|
||||||
"node_modules/tsx": {
|
"node_modules/tsx": {
|
||||||
@@ -12394,6 +13004,12 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/typedarray": {
|
||||||
|
"version": "0.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
|
||||||
|
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/typescript": {
|
"node_modules/typescript": {
|
||||||
"version": "6.0.2",
|
"version": "6.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz",
|
||||||
@@ -12619,7 +13235,6 @@
|
|||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/validator": {
|
"node_modules/validator": {
|
||||||
|
|||||||
+8
-3
@@ -10,7 +10,7 @@
|
|||||||
"build:obfuscate": "node scripts/build.mjs --production --obfuscate",
|
"build:obfuscate": "node scripts/build.mjs --production --obfuscate",
|
||||||
"lint": "eslint . --max-warnings 0",
|
"lint": "eslint . --max-warnings 0",
|
||||||
"typecheck": "tsc -p tsconfig.eslint.json --noEmit",
|
"typecheck": "tsc -p tsconfig.eslint.json --noEmit",
|
||||||
"test": "tsx --test app/renderer/shared/ui/controls.tooltip.test.ts app/renderer/editor/state/projectState.race.test.ts app/renderer/editor/graph/sceneCardById.test.ts app/shared/ipc/contracts.mediaRemoval.test.ts app/shared/effectEraserHitTest.test.ts app/renderer/control/controlApp.effectsPanel.test.ts app/renderer/shared/effects/PxiEffectsOverlay.pointer.test.ts app/main/windows/createWindows.editorClose.test.ts app/main/windows/bootWindow.test.ts app/main/effects/effectsStore.test.ts app/main/project/assetPrune.test.ts app/main/project/fsRetry.test.ts app/main/project/zipRead.test.ts app/main/project/zipStore.legacyContract.test.ts app/shared/package.build.test.ts app/shared/license/canonicalJson.test.ts app/shared/license/productKey.test.ts app/shared/license/licenseService.networkRegression.test.ts app/shared/video/videoPlaybackPerf.networkRegression.test.ts app/shared/video/videoPlaybackLoop.networkRegression.test.ts app/main/license/verifyLicenseToken.test.ts && node --test scripts/build-env.test.mjs scripts/obfuscate-main.test.mjs",
|
"test": "tsx --test app/renderer/shared/ui/controls.tooltip.test.ts app/renderer/editor/state/projectState.race.test.ts app/renderer/editor/graph/sceneCardById.test.ts app/shared/ipc/contracts.mediaRemoval.test.ts app/shared/effectEraserHitTest.test.ts app/renderer/control/controlApp.effectsPanel.test.ts app/renderer/shared/effects/PxiEffectsOverlay.pointer.test.ts app/main/windows/createWindows.editorClose.test.ts app/main/windows/bootWindow.test.ts app/main/effects/effectsStore.test.ts app/main/project/assetPrune.test.ts app/main/project/optimizeImageImport.test.ts app/main/project/scenePreviewThumbnail.test.ts app/main/project/fsRetry.test.ts app/main/project/zipRead.test.ts app/main/project/zipStore.legacyContract.test.ts app/shared/package.build.test.ts app/shared/license/canonicalJson.test.ts app/shared/license/productKey.test.ts app/shared/license/licenseService.networkRegression.test.ts app/shared/video/videoPlaybackPerf.networkRegression.test.ts app/shared/video/videoPlaybackLoop.networkRegression.test.ts app/main/license/verifyLicenseToken.test.ts && node --test scripts/build-env.test.mjs scripts/obfuscate-main.test.mjs",
|
||||||
"format": "prettier . --check",
|
"format": "prettier . --check",
|
||||||
"format:write": "prettier . --write",
|
"format:write": "prettier . --write",
|
||||||
"release:info": "node scripts/print-release-info.mjs",
|
"release:info": "node scripts/print-release-info.mjs",
|
||||||
@@ -25,10 +25,12 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource/inter": "^5.2.8",
|
"@fontsource/inter": "^5.2.8",
|
||||||
|
"ffmpeg-static": "^5.3.0",
|
||||||
"pixi.js": "^8.18.1",
|
"pixi.js": "^8.18.1",
|
||||||
"react": "^19.2.5",
|
"react": "^19.2.5",
|
||||||
"react-dom": "^19.2.5",
|
"react-dom": "^19.2.5",
|
||||||
"reactflow": "^11.11.4"
|
"reactflow": "^11.11.4",
|
||||||
|
"sharp": "^0.34.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.4",
|
"@eslint/js": "^9.39.4",
|
||||||
@@ -76,7 +78,10 @@
|
|||||||
],
|
],
|
||||||
"asar": true,
|
"asar": true,
|
||||||
"asarUnpack": [
|
"asarUnpack": [
|
||||||
"dist/preload/**"
|
"dist/preload/**",
|
||||||
|
"node_modules/sharp/**",
|
||||||
|
"node_modules/@img/**",
|
||||||
|
"node_modules/ffmpeg-static/**"
|
||||||
],
|
],
|
||||||
"mac": {
|
"mac": {
|
||||||
"category": "public.app-category.games",
|
"category": "public.app-category.games",
|
||||||
|
|||||||
+1
-1
@@ -62,7 +62,7 @@ async function buildNodeTargets() {
|
|||||||
bundle: true,
|
bundle: true,
|
||||||
minify: isProd,
|
minify: isProd,
|
||||||
sourcemap: !isProd,
|
sourcemap: !isProd,
|
||||||
external: ['electron'],
|
external: ['electron', 'sharp', 'ffmpeg-static'],
|
||||||
define,
|
define,
|
||||||
drop: isProd ? ['console', 'debugger'] : [],
|
drop: isProd ? ['console', 'debugger'] : [],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
## Project Converter (DNDGamePlayer)
|
||||||
|
|
||||||
|
Мини-приложение для конвертации `.dnd.zip` проектов в новый формат, добавляя **миниатюры превью сцен** (thumbnail) для ускорения редактора.
|
||||||
|
|
||||||
|
### Что делает
|
||||||
|
|
||||||
|
- Открывает исходный `.dnd.zip`
|
||||||
|
- Читает `project.json`
|
||||||
|
- Для каждой сцены с `previewAssetId`:
|
||||||
|
- генерирует `previewThumbAssetId` (WebP, max 320px по длинной стороне)
|
||||||
|
- кладёт файл миниатюры в `assets/` и добавляет `MediaAsset` в `project.assets`
|
||||||
|
- Пишет новый `.dnd.zip` (исходник не трогает)
|
||||||
|
|
||||||
|
Оригинальные ассеты (изображения/видео) **не перекодируются** — меняется только `project.json` + добавляются миниатюры.
|
||||||
|
|
||||||
|
### Запуск
|
||||||
|
|
||||||
|
Из папки `tools/project-converter/`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### Почему не попадает в сборку DNDGamePlayer
|
||||||
|
|
||||||
|
Это отдельный пакет со своим `package.json` в `tools/`. Сборка основного приложения берёт только `dist/**/*` и корневой `package.json`.
|
||||||
|
|
||||||
+1547
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "dnd-project-converter",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Simple UI tool to convert .dnd.zip projects (add scene preview thumbnails).",
|
||||||
|
"type": "module",
|
||||||
|
"main": "src/main.js",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "node src/run-electron.mjs",
|
||||||
|
"start": "node src/run-electron.mjs",
|
||||||
|
"lint": "node -e \"console.log('no lint')\""
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"electron": "^41.2.0",
|
||||||
|
"ffmpeg-static": "^5.3.0",
|
||||||
|
"sharp": "^0.34.5",
|
||||||
|
"yauzl": "^3.3.0",
|
||||||
|
"yazl": "^3.3.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>DND Project Converter</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif;
|
||||||
|
background: #0b0f19;
|
||||||
|
color: #e6e8ee;
|
||||||
|
}
|
||||||
|
.wrap {
|
||||||
|
max-width: 760px;
|
||||||
|
margin: 24px auto;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
font-size: 18px;
|
||||||
|
margin: 0 0 12px 0;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin: 10px 0;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
appearance: none;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.16);
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
color: inherit;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
button.primary {
|
||||||
|
border-color: rgba(167, 139, 250, 0.55);
|
||||||
|
background: rgba(167, 139, 250, 0.16);
|
||||||
|
}
|
||||||
|
button:disabled {
|
||||||
|
opacity: 0.55;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.path {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0.9;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
.log {
|
||||||
|
margin-top: 12px;
|
||||||
|
height: 320px;
|
||||||
|
overflow: auto;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 10px;
|
||||||
|
background: rgba(0, 0, 0, 0.35);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
.muted {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="wrap">
|
||||||
|
<h1>DND Project Converter</h1>
|
||||||
|
<div class="card">
|
||||||
|
<div class="muted">Конвертация: добавление миниатюр превью сцен (WebP 320px) в новый `.dnd.zip`.</div>
|
||||||
|
<div class="row">
|
||||||
|
<button id="pick">Выбрать .dnd.zip</button>
|
||||||
|
<button id="convert" class="primary" disabled>Конвертировать</button>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="muted">Вход:</div>
|
||||||
|
<div id="in" class="path">—</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="muted">Выход:</div>
|
||||||
|
<div id="out" class="path">—</div>
|
||||||
|
</div>
|
||||||
|
<div id="log" class="log"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script type="module" src="./renderer.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
@@ -0,0 +1,346 @@
|
|||||||
|
import crypto from 'node:crypto';
|
||||||
|
import fs from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
import { app, BrowserWindow, dialog, ipcMain } from 'electron';
|
||||||
|
import sharp from 'sharp';
|
||||||
|
import ffmpegStatic from 'ffmpeg-static';
|
||||||
|
import { execFile } from 'node:child_process';
|
||||||
|
import { promisify } from 'node:util';
|
||||||
|
import fsSync from 'node:fs';
|
||||||
|
|
||||||
|
import yauzl from 'yauzl';
|
||||||
|
import { ZipFile } from 'yazl';
|
||||||
|
|
||||||
|
import { optimizeImageBufferVisuallyLossless } from '../../../app/main/project/optimizeImageImport.lib.mjs';
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
|
const THUMB_MAX_PX = 320;
|
||||||
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
function isDndZip(p) {
|
||||||
|
return typeof p === 'string' && p.toLowerCase().endsWith('.dnd.zip');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fileExists(p) {
|
||||||
|
try {
|
||||||
|
const st = await fs.stat(p);
|
||||||
|
return st.isFile();
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function asBuffer(x) {
|
||||||
|
return Buffer.isBuffer(x) ? x : Buffer.from(x);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateImageThumbWebp(absPath) {
|
||||||
|
return await sharp(absPath)
|
||||||
|
.rotate()
|
||||||
|
.resize(THUMB_MAX_PX, THUMB_MAX_PX, { fit: 'inside', withoutEnlargement: true })
|
||||||
|
.webp({ quality: 82 })
|
||||||
|
.toBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function extractVideoFrameToPng(absVideo, absPng) {
|
||||||
|
const ffmpegPath = ffmpegStatic;
|
||||||
|
if (!ffmpegPath) throw new Error('ffmpeg-static not available');
|
||||||
|
const seekSeconds = ['0.5', '0.25', '0'];
|
||||||
|
for (const ss of seekSeconds) {
|
||||||
|
try {
|
||||||
|
await fs.rm(absPng, { force: true }).catch(() => undefined);
|
||||||
|
await execFileAsync(
|
||||||
|
ffmpegPath,
|
||||||
|
['-hide_banner', '-loglevel', 'error', '-y', '-ss', ss, '-i', absVideo, '-frames:v', '1', absPng],
|
||||||
|
{ maxBuffer: 16 * 1024 * 1024 },
|
||||||
|
);
|
||||||
|
const st = await fs.stat(absPng).catch(() => null);
|
||||||
|
if (st && st.isFile() && st.size > 0) return true;
|
||||||
|
} catch {
|
||||||
|
// try next seek
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateVideoThumbWebp(absVideo) {
|
||||||
|
const tmpDir = await fs.mkdtemp(path.join(app.getPath('temp'), 'dnd-thumb-'));
|
||||||
|
const tmpPng = path.join(tmpDir, 'frame.png');
|
||||||
|
try {
|
||||||
|
const ok = await extractVideoFrameToPng(absVideo, tmpPng);
|
||||||
|
if (!ok) return null;
|
||||||
|
return await sharp(tmpPng)
|
||||||
|
.resize(THUMB_MAX_PX, THUMB_MAX_PX, { fit: 'inside', withoutEnlargement: true })
|
||||||
|
.webp({ quality: 82 })
|
||||||
|
.toBuffer();
|
||||||
|
} finally {
|
||||||
|
await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openZip(zipPath) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
yauzl.open(zipPath, { lazyEntries: true }, (err, zip) => {
|
||||||
|
if (err || !zip) reject(err ?? new Error('Failed to open zip'));
|
||||||
|
else resolve(zip);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function readEntryBuffer(zip, entry) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
zip.openReadStream(entry, (err, rs) => {
|
||||||
|
if (err || !rs) return reject(err ?? new Error('No stream'));
|
||||||
|
const chunks = [];
|
||||||
|
rs.on('data', (c) => chunks.push(c));
|
||||||
|
rs.on('end', () => resolve(Buffer.concat(chunks)));
|
||||||
|
rs.on('error', reject);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readZipAll(zipPath) {
|
||||||
|
const zip = await openZip(zipPath);
|
||||||
|
try {
|
||||||
|
const files = new Map(); // name -> Buffer
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
zip.on('error', reject);
|
||||||
|
zip.on('entry', (entry) => {
|
||||||
|
// Process each entry sequentially; calling openReadStream after 'end' can fail with 'closed'.
|
||||||
|
void (async () => {
|
||||||
|
if (!entry.fileName.endsWith('/')) {
|
||||||
|
const buf = await readEntryBuffer(zip, entry);
|
||||||
|
files.set(entry.fileName, buf);
|
||||||
|
}
|
||||||
|
zip.readEntry();
|
||||||
|
})().catch(reject);
|
||||||
|
});
|
||||||
|
zip.on('end', resolve);
|
||||||
|
zip.readEntry();
|
||||||
|
});
|
||||||
|
return files;
|
||||||
|
} finally {
|
||||||
|
zip.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sha256(buf) {
|
||||||
|
return crypto.createHash('sha256').update(buf).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeFileName(name) {
|
||||||
|
return String(name).replace(/[<>:"/\\|?*\u0000-\u001F]+/g, '_').slice(0, 180);
|
||||||
|
}
|
||||||
|
|
||||||
|
function newAssetId() {
|
||||||
|
return crypto.randomBytes(16).toString('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
function guessMimeFromWebp() {
|
||||||
|
return 'image/webp';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-encode raster image assets to visually lossless smaller files; updates `files` and `project.assets` in place.
|
||||||
|
* @param {Map<string, Buffer>} files
|
||||||
|
* @param {Record<string, unknown>} project
|
||||||
|
* @param {(s: string) => void} [onLog]
|
||||||
|
*/
|
||||||
|
async function optimizeProjectImageAssets(files, project, onLog) {
|
||||||
|
const assets = project.assets && typeof project.assets === 'object' ? project.assets : {};
|
||||||
|
let n = 0;
|
||||||
|
for (const aid of Object.keys(assets)) {
|
||||||
|
const asset = assets[aid];
|
||||||
|
if (!asset || typeof asset !== 'object') continue;
|
||||||
|
if (asset.type !== 'image' || typeof asset.relPath !== 'string') continue;
|
||||||
|
const rel = asset.relPath.replace(/^\//u, '');
|
||||||
|
if (!rel.startsWith('assets/')) continue;
|
||||||
|
const bytes = files.get(rel);
|
||||||
|
if (!bytes || bytes.length === 0) continue;
|
||||||
|
|
||||||
|
onLog?.(`Optimize image: ${rel}…`);
|
||||||
|
const opt = await optimizeImageBufferVisuallyLossless(bytes);
|
||||||
|
if (opt.passthrough) {
|
||||||
|
onLog?.(` skip (passthrough)`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const newName = `${path.parse(path.basename(rel)).name}.${opt.ext}`;
|
||||||
|
const newRel = `assets/${newName}`;
|
||||||
|
files.delete(rel);
|
||||||
|
files.set(newRel, asBuffer(opt.buffer));
|
||||||
|
|
||||||
|
const origName = typeof asset.originalName === 'string' ? asset.originalName : path.basename(rel);
|
||||||
|
const stem = path.parse(origName).name;
|
||||||
|
asset.relPath = newRel;
|
||||||
|
asset.mime = opt.mime;
|
||||||
|
asset.originalName = `${stem}.${opt.ext}`;
|
||||||
|
asset.sha256 = sha256(opt.buffer);
|
||||||
|
asset.sizeBytes = opt.buffer.length;
|
||||||
|
n += 1;
|
||||||
|
onLog?.(` OK → ${newRel} (${bytes.length} → ${opt.buffer.length} bytes)`);
|
||||||
|
}
|
||||||
|
if (n > 0) onLog?.(`Optimized image assets: ${n}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function convertZip({ inputPath, outputPath, onLog }) {
|
||||||
|
if (!(await fileExists(inputPath))) throw new Error('Input file not found');
|
||||||
|
if (!isDndZip(inputPath)) throw new Error('Expected .dnd.zip');
|
||||||
|
if (!outputPath || !isDndZip(outputPath)) throw new Error('Output path must end with .dnd.zip');
|
||||||
|
|
||||||
|
const files = await readZipAll(inputPath);
|
||||||
|
const projectBuf = files.get('project.json');
|
||||||
|
if (!projectBuf) throw new Error('project.json not found in zip');
|
||||||
|
|
||||||
|
const project = JSON.parse(projectBuf.toString('utf8'));
|
||||||
|
project.scenes = project.scenes ?? {};
|
||||||
|
project.assets = project.assets ?? {};
|
||||||
|
|
||||||
|
onLog?.('Optimizing image assets…');
|
||||||
|
await optimizeProjectImageAssets(files, project, onLog);
|
||||||
|
|
||||||
|
let added = 0;
|
||||||
|
for (const sid of Object.keys(project.scenes)) {
|
||||||
|
const sc = project.scenes[sid];
|
||||||
|
if (!sc) continue;
|
||||||
|
if (sc.previewThumbAssetId) continue;
|
||||||
|
const assetId = sc.previewAssetId ?? null;
|
||||||
|
const assetType = sc.previewAssetType ?? null;
|
||||||
|
if (!assetId || !assetType) continue;
|
||||||
|
|
||||||
|
const asset = project.assets[assetId] ?? null;
|
||||||
|
if (!asset || typeof asset.relPath !== 'string') continue;
|
||||||
|
const assetRel = asset.relPath.replace(/^\//, '');
|
||||||
|
const assetBytes = files.get(assetRel);
|
||||||
|
if (!assetBytes) continue;
|
||||||
|
|
||||||
|
onLog?.(`Scene ${sid}: generating thumb…`);
|
||||||
|
const kind = assetType === 'video' ? 'video' : 'image';
|
||||||
|
|
||||||
|
// Write source to temp to allow sharp/ffmpeg to read it.
|
||||||
|
const tmpDir = await fs.mkdtemp(path.join(app.getPath('temp'), 'dnd-conv-'));
|
||||||
|
const tmpSrc = path.join(tmpDir, sanitizeFileName(path.basename(assetRel)));
|
||||||
|
await fs.writeFile(tmpSrc, assetBytes);
|
||||||
|
try {
|
||||||
|
const thumbBytes =
|
||||||
|
kind === 'image' ? await generateImageThumbWebp(tmpSrc) : await generateVideoThumbWebp(tmpSrc);
|
||||||
|
if (!thumbBytes) {
|
||||||
|
onLog?.(`Scene ${sid}: thumb skipped (failed).`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const thumbId = newAssetId();
|
||||||
|
const thumbName = `${thumbId}_preview_thumb.webp`;
|
||||||
|
const thumbRel = `assets/${thumbName}`;
|
||||||
|
|
||||||
|
files.set(thumbRel, asBuffer(thumbBytes));
|
||||||
|
project.assets[thumbId] = {
|
||||||
|
id: thumbId,
|
||||||
|
type: 'image',
|
||||||
|
mime: guessMimeFromWebp(),
|
||||||
|
originalName: thumbName,
|
||||||
|
relPath: thumbRel,
|
||||||
|
sha256: sha256(thumbBytes),
|
||||||
|
sizeBytes: thumbBytes.length,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
sc.previewThumbAssetId = thumbId;
|
||||||
|
added += 1;
|
||||||
|
onLog?.(`Scene ${sid}: OK`);
|
||||||
|
} finally {
|
||||||
|
await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
files.set('project.json', Buffer.from(JSON.stringify(project, null, 2), 'utf8'));
|
||||||
|
|
||||||
|
const zipfile = new ZipFile();
|
||||||
|
for (const [name, buf] of files) {
|
||||||
|
const isProjectJson = name === 'project.json';
|
||||||
|
zipfile.addBuffer(buf, name, { compressionLevel: isProjectJson ? 9 : 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await fs.mkdir(path.dirname(outputPath), { recursive: true });
|
||||||
|
const tmpOut = `${outputPath}.tmp`;
|
||||||
|
await fs.rm(tmpOut, { force: true }).catch(() => undefined);
|
||||||
|
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
const out = fsSync.createWriteStream(tmpOut);
|
||||||
|
out.on('close', resolve);
|
||||||
|
out.on('error', reject);
|
||||||
|
zipfile.outputStream.pipe(out);
|
||||||
|
zipfile.end();
|
||||||
|
});
|
||||||
|
await fs.rm(outputPath, { force: true }).catch(() => undefined);
|
||||||
|
await fs.rename(tmpOut, outputPath);
|
||||||
|
|
||||||
|
onLog?.(`Added thumbnails: ${added}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createWindow() {
|
||||||
|
const win = new BrowserWindow({
|
||||||
|
width: 860,
|
||||||
|
height: 640,
|
||||||
|
backgroundColor: '#0b0f19',
|
||||||
|
webPreferences: {
|
||||||
|
preload: path.join(here, 'preload.cjs'),
|
||||||
|
contextIsolation: true,
|
||||||
|
nodeIntegration: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
win.removeMenu();
|
||||||
|
void win.loadFile(path.join(here, 'index.html'));
|
||||||
|
}
|
||||||
|
|
||||||
|
app.whenReady().then(() => {
|
||||||
|
createWindow();
|
||||||
|
|
||||||
|
ipcMain.handle('converter.pickInputZip', async () => {
|
||||||
|
const res = await dialog.showOpenDialog({
|
||||||
|
title: 'Выберите файл проекта (.dnd.zip)',
|
||||||
|
filters: [{ name: 'DND Project', extensions: ['zip'] }],
|
||||||
|
properties: ['openFile'],
|
||||||
|
});
|
||||||
|
if (res.canceled || res.filePaths.length === 0) return { canceled: true };
|
||||||
|
const p = res.filePaths[0];
|
||||||
|
if (!isDndZip(p)) return { canceled: false, path: p }; // allow, but user should pick correct
|
||||||
|
return { canceled: false, path: p };
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('converter.pickOutputZip', async (_e, { inputPath }) => {
|
||||||
|
const suggested = inputPath && typeof inputPath === 'string' ? inputPath.replace(/\.dnd\.zip$/i, '.thumbs.dnd.zip') : 'converted.dnd.zip';
|
||||||
|
const res = await dialog.showSaveDialog({
|
||||||
|
title: 'Сохранить конвертированный проект',
|
||||||
|
defaultPath: suggested,
|
||||||
|
filters: [{ name: 'DND Project', extensions: ['zip'] }],
|
||||||
|
});
|
||||||
|
if (res.canceled || !res.filePath) return { canceled: true };
|
||||||
|
return { canceled: false, path: res.filePath };
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('converter.convert', async (_e, { inputPath, outputPath }) => {
|
||||||
|
try {
|
||||||
|
await convertZip({
|
||||||
|
inputPath,
|
||||||
|
outputPath,
|
||||||
|
onLog: (line) => {
|
||||||
|
const win = BrowserWindow.getAllWindows()[0];
|
||||||
|
if (win && !win.isDestroyed()) win.webContents.send('converter.log', { line });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return { ok: true };
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
const stack = err instanceof Error ? err.stack : null;
|
||||||
|
return { ok: false, error: stack ? `${msg}\n${stack}` : msg };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.on('converter.log', () => undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.on('window-all-closed', () => {
|
||||||
|
app.quit();
|
||||||
|
});
|
||||||
|
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
const { contextBridge, ipcRenderer } = require('electron');
|
||||||
|
|
||||||
|
contextBridge.exposeInMainWorld('converter', {
|
||||||
|
pickInputZip: () => ipcRenderer.invoke('converter.pickInputZip'),
|
||||||
|
pickOutputZip: (inputPath) => ipcRenderer.invoke('converter.pickOutputZip', { inputPath }),
|
||||||
|
convert: ({ inputPath, outputPath }) =>
|
||||||
|
ipcRenderer.invoke('converter.convert', { inputPath, outputPath }),
|
||||||
|
onLog: (cb) => {
|
||||||
|
ipcRenderer.removeAllListeners('converter.log');
|
||||||
|
ipcRenderer.on('converter.log', (_e, { line }) => cb(line));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
let inputPath = null;
|
||||||
|
let outputPath = null;
|
||||||
|
|
||||||
|
const $pick = document.getElementById('pick');
|
||||||
|
const $convert = document.getElementById('convert');
|
||||||
|
const $in = document.getElementById('in');
|
||||||
|
const $out = document.getElementById('out');
|
||||||
|
const $log = document.getElementById('log');
|
||||||
|
|
||||||
|
function log(line) {
|
||||||
|
$log.textContent += `${line}\n`;
|
||||||
|
$log.scrollTop = $log.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('error', (e) => {
|
||||||
|
try {
|
||||||
|
log(`JS error: ${String(e.message || e.error || 'unknown')}`);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!window.converter) {
|
||||||
|
log('Ошибка: window.converter не найден.');
|
||||||
|
log('Похоже, preload не подключился. Перезапустите утилиту.');
|
||||||
|
$pick.disabled = true;
|
||||||
|
$convert.disabled = true;
|
||||||
|
} else {
|
||||||
|
window.converter.onLog((line) => log(line));
|
||||||
|
log('Готово. Нажмите "Выбрать .dnd.zip".');
|
||||||
|
}
|
||||||
|
|
||||||
|
function setPaths() {
|
||||||
|
$in.textContent = inputPath ?? '—';
|
||||||
|
$out.textContent = outputPath ?? '—';
|
||||||
|
$convert.disabled = !inputPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
$pick.addEventListener('click', async () => {
|
||||||
|
$log.textContent = '';
|
||||||
|
if (!window.converter) {
|
||||||
|
log('Ошибка: preload не подключился (window.converter отсутствует).');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const res = await window.converter.pickInputZip();
|
||||||
|
if (!res || res.canceled) return;
|
||||||
|
inputPath = res.path;
|
||||||
|
outputPath = null;
|
||||||
|
setPaths();
|
||||||
|
log(`Выбран файл: ${inputPath}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
$convert.addEventListener('click', async () => {
|
||||||
|
if (!inputPath) return;
|
||||||
|
if (!window.converter) {
|
||||||
|
log('Ошибка: preload не подключился (window.converter отсутствует).');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$convert.disabled = true;
|
||||||
|
try {
|
||||||
|
log('Готовлю выходной файл…');
|
||||||
|
const dest = await window.converter.pickOutputZip(inputPath);
|
||||||
|
if (!dest || dest.canceled) {
|
||||||
|
log('Отмена.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
outputPath = dest.path;
|
||||||
|
setPaths();
|
||||||
|
|
||||||
|
log('Конвертация…');
|
||||||
|
const res = await window.converter.convert({ inputPath, outputPath });
|
||||||
|
if (res.ok) {
|
||||||
|
log('Готово.');
|
||||||
|
} else {
|
||||||
|
log(`Ошибка: ${res.error}`);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
$convert.disabled = !inputPath;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setPaths();
|
||||||
|
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
|
||||||
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const root = path.resolve(here, '..');
|
||||||
|
const electronBin = path.resolve(root, 'node_modules', '.bin', process.platform === 'win32' ? 'electron.cmd' : 'electron');
|
||||||
|
|
||||||
|
const child = spawn(electronBin, ['.'], {
|
||||||
|
cwd: root,
|
||||||
|
stdio: 'inherit',
|
||||||
|
shell: process.platform === 'win32',
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on('exit', (code) => {
|
||||||
|
process.exitCode = code ?? 0;
|
||||||
|
});
|
||||||
|
|
||||||
Reference in New Issue
Block a user