chore: move project-converter out of repo to dnd_project sibling
Remove tools/project-converter; converter now lives next to this repo under dnd_project/project-converter. Update eslint ignore comment and optimizeImage lib header. Converter main.js imports dnd_player via ../../dnd_player/... Made-with: Cursor
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* Visually lossless re-encode for imported raster images (same pixel dimensions).
|
* Visually lossless re-encode for imported raster images (same pixel dimensions).
|
||||||
* Node-only; shared by the main app and tools/project-converter.
|
* Node-only; shared by the main app and ../project-converter (monorepo sibling).
|
||||||
*/
|
*/
|
||||||
import sharp from 'sharp';
|
import sharp from 'sharp';
|
||||||
|
|
||||||
|
|||||||
+1
-2
@@ -18,9 +18,8 @@ export default tseslint.config(
|
|||||||
'node_modules/**',
|
'node_modules/**',
|
||||||
'.cursor/**',
|
'.cursor/**',
|
||||||
'scripts/**',
|
'scripts/**',
|
||||||
'tools/**',
|
|
||||||
'eslint.config.js',
|
'eslint.config.js',
|
||||||
// Plain ESM; shared with tools/project-converter (not parsed as TS project file).
|
// Plain ESM; shared with sibling ../project-converter (not parsed as TS project file).
|
||||||
'app/main/project/optimizeImageImport.lib.mjs',
|
'app/main/project/optimizeImageImport.lib.mjs',
|
||||||
'app/main/project/optimizeImageImport.lib.d.mts',
|
'app/main/project/optimizeImageImport.lib.d.mts',
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
## 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
@@ -1,21 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
<!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>
|
|
||||||
|
|
||||||
@@ -1,346 +0,0 @@
|
|||||||
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();
|
|
||||||
});
|
|
||||||
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
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));
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
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();
|
|
||||||
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
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