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:
@@ -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