4 Commits

Author SHA1 Message Date
Ivan Fontosh 36776f4c5d chore: drop tracked .cursor; use sibling cursorAi repo (local junction)
Made-with: Cursor
2026-04-24 07:37:16 +08:00
Ivan Fontosh c9cad4dafd 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
2026-04-24 07:10:01 +08:00
Ivan Fontosh d94a11d466 Редактор: превью с поворотом, проекты, безопасное сохранение zip, dev-меню
RotatedImage: размер контейнера через clientWidth/Height (не getBoundingClientRect), чтобы cover при 90°/270° работал под zoom React Flow; убраны отладочные логи.

Главное меню в dev: пункт «Вид» с DevTools (Ctrl+Shift+I без пустого application menu).

Список проектов: project.list без лицензии; список подгружается при неактивной лицензии; ProjectPicker с подсказками; listProjects пропускает битые zip.

Сохранение проектов: atomicReplace — замена zip без rm до commit; восстановление *.dnd.zip.tmp при старте; тесты.

EditorApp: блокировка UI при открытых окнах презентации и пульта; стили оверлея.
Made-with: Cursor
2026-04-24 07:04:42 +08:00
Ivan Fontosh a24e87035a feat(editor): highlight edges and show preview import loader
- Highlight all edges connected to selected scene

- Show overlay spinner while uploading/optimizing preview image

- macOS: keep control window independent from presentation

Made-with: Cursor
2026-04-23 22:01:07 +08:00
37 changed files with 494 additions and 2667 deletions
-35
View File
@@ -1,35 +0,0 @@
---
name: frontend-senior
description: Senior frontend engineer
model: auto
tools: all
---
Ты senior frontend engineer.
Задача:
- реализовать feature или fix
- писать production-quality React + TypeScript код
Правила:
- сначала изучи nearby components
- следуй существующим patterns
- не делай лишних изменений
- избегай overengineering
- используй composition
UI:
- учитывай loading / error / empty / disabled
- соблюдай accessibility
Не делай:
- large refactors без запроса
- новые dependencies без причины
Output:
- список изменённых файлов
- краткое описание изменений
-33
View File
@@ -1,33 +0,0 @@
---
name: reviewer
description: Strict code reviewer
model: auto
tools: all
---
Ты строгий reviewer.
Цель:
- найти проблемы в изменениях
Проверяй:
- correctness
- regressions
- type safety
- accessibility
- performance
- edge cases
- missing tests
Формат:
- Severity: high / medium / low
- Problem
- Why
- Fix
Правила:
- не переписывай код без причины
- предлагай minimal fixes
-36
View File
@@ -1,36 +0,0 @@
---
name: unit-tests
description: Unit test specialist
model: auto
tools: all
---
Ты unit-test specialist.
Задача:
- добавить/обновить tests
- добиться green status
Подход:
- test behavior, not implementation
- follow existing patterns
- избегай flaky tests
Покрытие:
- happy path
- edge case
- error case
Workflow:
- сначала run relevant tests:
`npm run test -- <file>`
- затем при необходимости весь suite
Output:
- какие тесты добавлены
- что они проверяют
-10
View File
@@ -1,10 +0,0 @@
{
"version": 1,
"hooks": {
"stop": [
{
"command": "node .cursor/hooks/final-verify.cjs"
}
]
}
}
-55
View File
@@ -1,55 +0,0 @@
const fs = require("fs");
const path = require("path");
const { execSync } = require("child_process");
const statePath = path.join(process.cwd(), ".cursor", "pipeline-state.json");
function readState() {
if (!fs.existsSync(statePath)) {
return {
implementation: "pending",
review: "pending",
tests: "pending"
};
}
return JSON.parse(fs.readFileSync(statePath, "utf8"));
}
function fail(msg) {
process.stdout.write(JSON.stringify({ followup_message: msg }));
process.exit(0);
}
const state = readState();
if (state.implementation !== "done") {
fail("Run frontend-senior stage");
}
if (state.review !== "done") {
fail("Run reviewer stage");
}
if (state.tests !== "done") {
fail("Run unit-tests stage");
}
try {
execSync("npm run lint", { stdio: "pipe" });
} catch {
fail("Lint failed");
}
try {
execSync("npm run typecheck", { stdio: "pipe" });
} catch {
fail("Typecheck failed");
}
try {
execSync("npm run test", { stdio: "pipe" });
} catch {
fail("Tests failed");
}
process.exit(0);
-10
View File
@@ -1,10 +0,0 @@
{
"version": 1,
"hooks": {
"stop": [
{
"command": "node .cursor/hooks/final-verify.cjs"
}
]
}
}
-6
View File
@@ -1,6 +0,0 @@
{
"implementation": "done",
"review": "done",
"tests": "done",
"verify": "done"
}
-112
View File
@@ -1,112 +0,0 @@
# PR: UI — выравнивание под макеты (редактор + пульт)
Связь с **feature-pipeline** (`.cursor/skills/feature-pipeline/SKILL.md`): критерии ниже сгруппированы по стадиям; после merge обновляют `.cursor/pipeline-state.json` по мере прохождения стадий.
---
## Мета PR
| Поле | Значение |
|------|----------|
| **Цель** | Визуальная и UX-полировка существующих экранов без новых продуктовых фич из макета |
| **Область** | `EditorApp`, `SceneGraph`, `ControlApp`, общие токены/мелкие UI-баги |
| **Вне скоупа** | Новые пункты меню, новые типы сцен, новые каналы микшера, если их нет в коде |
---
## Stage 1 — Implementation (subagent: `frontend-senior`)
### Чеклист PR
- [ ] **P0-A** Карточка узла графа: статусы «Авто / Цикл / Аудио» отражают **реальные** поля сцены (`previewVideoAutostart`, `settings.loopVideo`, наличие `media.audios`), а не статичный текст.
- [ ] **P0-B** Пульт: для сцены с видео-превью явно подписано, что **кисть эффектов** недоступна (согласовано с `PresentationView`: эффекты только для image).
- [ ] **P1-A** Пульт: порядок секций **Предпросмотр → Варианты ветвления → Музыка сцены → Быстрый микшер** (вертикальный поток как в референсе).
- [ ] **P1-B** Сетка «Варианты ветвления» без «дыр»: `repeat(auto-fit, minmax(...))` **или** фиксированные слоты с disabled/placeholder **без новых сценариев** — только визуальная сетка.
- [ ] **P2-A** Редактор: меню «Файл» — цвет текста пункта на валидном токене (`--text0` / `--text1`), без несуществующего `--text`.
- [ ] **P2-B** Список сцен: убрать пустой статус-ряд у неактивных карточек (без лишнего вертикального зазора).
### Критерии приёмки (Stage 1)
1. На графе для сцены **без видео-превью** не отображаются вводящие в заблуждение чипы «Авто/Цикл» видео-превью; при наличии аудио — корректный индикатор аудио.
2. Для сцены с **видео-превью** чипы «Авто»/«Цикл» совпадают с `previewVideoAutostart` и `scene.settings.loopVideo`.
3. В пульте при `previewAssetType === 'video'` пользователь видит **одну строку-пояснение** (вторичный текст), почему нет панели эффектов.
4. Визуальный порядок блоков пульта соответствует чеклисту P1-A; отступы между `Surface` единообразны (`gap` 16).
5. Нет регрессий drag-and-drop сцен на граф и переключения веток.
**Definition of Done (Stage 1):** все пункты чеклиста Stage 1 отмечены; `npm run build` успешен.
---
## Stage 2 — Review (subagent: `reviewer`)
### Чеклист PR
- [ ] Нет «мёртвых» веток UI и ложных состояний (чипы/подписи соответствуют данным).
- [ ] a11y: фокус/клавиатура на интерактивах не сломаны; `aria-*` не ухудшены.
- [ ] Нет лишнего diff вне файлов задачи.
### Критерии приёмки (Stage 2)
1. Reviewer фиксирует замечания с **Severity**; все **high** устранены или явно отклонены с причиной в PR.
2. После правок по review снова выполняется `npm run build`.
**Definition of Done (Stage 2):** review-замечания закрыты; state `review: done`.
---
## Stage 3 — Tests (subagent: `unit-tests`)
### Чеклист PR
- [ ] Добавлены или обновлены тесты на **чистую логику** (например, хелпер разметки чипов по `Scene`, если вынесен в `app/shared`).
- [ ] Либо задокументировано в PR, что изменения только презентационные и покрыты smoke-тестом пайплайна.
### Критерии приёмки (Stage 3)
1. `npm run test` завершается с кодом 0.
2. Новые тесты не flaky, не зависят от Electron UI.
**Definition of Done (Stage 3):** `tests: done` в `.cursor/pipeline-state.json`.
---
## Stage 4 — Verify (локально / hook `final-verify.cjs`)
Выполнить подряд:
```bash
npm run lint
npm run typecheck
npm run test
```
**Если `npm run lint` падает на массовых `Delete ␍` (CRLF/LF) в файлах вне PR** — до отдельного chore-PR с нормализацией строк для этого чеклиста достаточно:
```bash
npx eslint app/renderer/control/ControlApp.tsx app/renderer/editor/graph/SceneGraph.tsx app/renderer/shared/ui/sceneGraphChips.ts app/renderer/shared/ui/sceneGraphChips.test.ts --max-warnings 0
npm run typecheck
npm run test
npm run build
```
### Критерии приёмки (Stage 4)
1. `npm run typecheck`, `npm run test`, `npm run build`**exit 0**.
2. Линт: либо полный `npm run lint`**exit 0**, либо (при известном eol-долге репозитория) scoped-ESLint по файлам PR — **exit 0**, как в блоке выше.
3. `node .cursor/hooks/final-verify.cjs` — успешное завершение пайплайна только когда полный `npm run lint` зелёный (хук вызывает полный lint; при падении lint хук пишет `followup_message` в stdout).
---
## Синхронизация с `.cursor/pipeline-state.json`
После каждой стадии обновлять файл:
```json
{
"implementation": "done",
"review": "done",
"tests": "done"
}
```
До начала работы — все `"pending"`.
-39
View File
@@ -1,39 +0,0 @@
---
description: Project-wide workflow and conventions
alwaysApply: true
---
# DNDGamePlayer — правила работы над задачами (future-pipeline)
Эти правила применяются **только** когда запрос пользователя требует **изменений в репозитории** (код/конфиги/тесты). Для чисто текстовых задач (описания, маркетинг, переписка) pipeline не запускаем.
## future-pipeline (обязательный порядок)
### 1) Implementation
- Прочитать релевантный код (минимум 1 файл), найти реальную причину бага/задачи.
- Делать **minimal, review-friendly diff** и следовать текущим паттернам проекта.
- Не добавлять зависимости без явной причины.
### 2) Review
- Самопроверка изменений: edge-cases, состояние UI (loading/error/empty/disabled), a11y, регрессии.
- Если задача нетривиальная: запустить внутренний “строгий ревью” (под-агент reviewer).
### 3) Tests
- Обновить/добавить тест(ы), если поведение изменилось или был баг.
- Для мелких правок допускается “облегчённый режим” без под-агентов, но тесты всё равно должны проходить.
### 4) Verify (всегда, перед ответом)
Обязательно выполнить:
- `npm run lint`
- `npm run typecheck`
- `npm run test`
Если что-то упало — исправить и повторить до green.
## Команды проекта (справка)
- install: `npm install`
- dev: `npm run dev`
- build: `npm run build`
- lint: `npm run lint`
- typecheck: `npm run typecheck`
- test: `npm run test`
-80
View File
@@ -1,80 +0,0 @@
---
name: feature-pipeline
description: Implementation → Review → Tests → Verify
---
# Workflow
## Stage 1 — Implementation
Используй subagent: frontend-senior
- реализуй задачу
- сделай minimal diff
Обнови state:
{
"implementation": "done"
}
---
## Stage 2 — Review
Используй subagent: reviewer
- проверь изменения
- найди проблемы
- исправь минимально
Обнови state:
{
"implementation": "done",
"review": "done"
}
---
## Stage 3 — Tests
Используй subagent: unit-tests
- добавь/обнови tests
- добейся green status
Обнови state:
{
"implementation": "done",
"review": "done",
"tests": "done"
}
---
## Stage 4 — Verify
Выполни:
- `npm run lint`
- `npm run typecheck`
- `npm run test`
Если ошибка:
- исправь
- повтори
---
## Final Output
- implementation summary
- review summary
- test summary
- verification status
## PR-чеклисты (приёмка по задаче)
Готовые чеклисты с критериями по стадиям лежат в `.cursor/pr-checklists/`. Для UI-выравнивания под макеты: `ui-mock-alignment.md`.
+3
View File
@@ -1,3 +1,6 @@
# Cursor: канонический `.cursor/` в репозитории cursorAi; локально — junction на ../cursorAi/.cursor
.cursor/
release/
build/
mcps/
+34 -1
View File
@@ -21,6 +21,7 @@ import {
createEditorWindowDeferred,
createWindows,
focusEditorWindow,
isMultiWindowOpen,
markAppQuitting,
openMultiWindow,
togglePresentationFullscreen,
@@ -77,6 +78,35 @@ if (!gotTheLock) {
}
const projectStore = new ZipProjectStore();
/** Без меню Electron не вешает горячие клавиши DevTools (Ctrl+Shift+I / F12). */
function wantsDevToolsMenu(): boolean {
return (
process.env.NODE_ENV === 'development' ||
Boolean(process.env.VITE_DEV_SERVER_URL) ||
process.env.DND_OPEN_DEVTOOLS === '1'
);
}
function installAppMenuForSession(): void {
if (!wantsDevToolsMenu()) {
Menu.setApplicationMenu(null);
return;
}
const template: Electron.MenuItemConstructorOptions[] = [];
if (process.platform === 'darwin') {
template.push({
label: app.name,
submenu: [{ role: 'about' }, { type: 'separator' }, { role: 'quit' }],
});
}
template.push({
label: 'Вид',
submenu: [{ role: 'reload' }, { role: 'forceReload' }, { type: 'separator' }, { role: 'toggleDevTools' }],
});
Menu.setApplicationMenu(Menu.buildFromTemplate(template));
}
const effectsStore = new EffectsStore();
const videoStore = new VideoPlaybackStore();
@@ -211,7 +241,7 @@ async function main() {
setLicenseAssert(() => {
licenseService.assertForIpc();
});
Menu.setApplicationMenu(null);
installAppMenuForSession();
registerDndAssetProtocol(projectStore);
registerHandler(ipcChannels.app.quit, () => {
markAppQuitting();
@@ -238,6 +268,9 @@ async function main() {
const isFullScreen = togglePresentationFullscreen();
return { ok: true, isFullScreen };
});
registerHandler(ipcChannels.windows.getMultiWindowState, () => {
return { open: isMultiWindowOpen() };
});
registerHandler(ipcChannels.project.list, async () => {
const projects = await projectStore.listProjects();
+2
View File
@@ -19,6 +19,8 @@ function channelRequiresLicense(channel: string): boolean {
if (channel.startsWith('app.')) return false;
if (channel === ipcChannels.windows.closeMultiWindow) return false;
if (channel === ipcChannels.windows.togglePresentationFullscreen) return false;
// Список файлов в %userData%/projects — только чтение; без лицензии список не должен «пропадать».
if (channel === ipcChannels.project.list) return false;
return true;
}
+82
View File
@@ -0,0 +1,82 @@
import crypto from 'node:crypto';
import fs from 'node:fs/promises';
import path from 'node:path';
import { readProjectJsonFromZip } from './yauzlProjectZip';
/**
* Подменяет файл `finalPath` готовым `completedSrc` (обычно `*.dnd.zip.tmp`).
* Нельзя сначала удалять `finalPath`: при сбое rename после rm проект теряется (Windows/антивирус).
*/
export async function replaceFileAtomic(completedSrc: string, finalPath: string): Promise<void> {
if (completedSrc === finalPath) return;
const stSrc = await fs.stat(completedSrc).catch(() => null);
if (!stSrc?.isFile() || stSrc.size === 0) {
throw new Error(`replaceFileAtomic: нет или пустой источник: ${completedSrc}`);
}
try {
await fs.rename(completedSrc, finalPath);
return;
} catch (first: unknown) {
const c = (first as NodeJS.ErrnoException).code;
if (c === 'EXDEV') {
await fs.copyFile(completedSrc, finalPath);
await fs.unlink(completedSrc).catch(() => undefined);
return;
}
// Windows: чаще всего EEXIST — целевой файл есть, rename не перезаписывает; идём через backup ниже.
}
const stFinal = await fs.stat(finalPath).catch(() => null);
if (!stFinal?.isFile()) {
try {
await fs.rename(completedSrc, finalPath);
return;
} catch (again: unknown) {
throw again instanceof Error ? again : new Error(String(again));
}
}
const backupPath = `${finalPath}.bak.${Date.now().toString(36)}_${crypto.randomBytes(4).toString('hex')}`;
await fs.rename(finalPath, backupPath);
try {
await fs.rename(completedSrc, finalPath);
} catch (place: unknown) {
await fs.rename(backupPath, finalPath).catch(() => undefined);
throw place instanceof Error ? place : new Error(String(place));
}
await fs.unlink(backupPath).catch(() => undefined);
}
/** Если сохранение оборвалось, остаётся только `*.dnd.zip.tmp` — восстанавливаем в `*.dnd.zip`. */
export async function recoverOrphanDndZipTmpInRoot(root: string): Promise<void> {
let names: string[];
try {
names = await fs.readdir(root);
} catch {
return;
}
for (const name of names) {
if (!name.endsWith('.dnd.zip.tmp')) continue;
const tmpPath = path.join(root, name);
const finalName = name.slice(0, -'.tmp'.length);
if (!finalName.endsWith('.dnd.zip')) continue;
const finalPath = path.join(root, finalName);
try {
await fs.access(finalPath);
continue;
} catch {
/* final отсутствует — пробуем поднять из tmp */
}
try {
const st = await fs.stat(tmpPath);
if (!st.isFile() || st.size < 22) continue;
await readProjectJsonFromZip(tmpPath);
await fs.rename(tmpPath, finalPath);
} catch {
/* битый tmp не трогаем */
}
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
/**
* 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';
@@ -0,0 +1,28 @@
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 { replaceFileAtomic } from './atomicReplace';
void test('replaceFileAtomic: replaces existing file without deleting before rename succeeds', async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'dnd-replace-'));
const finalPath = path.join(dir, 'out.bin');
const tmpPath = path.join(dir, 'out.bin.tmp');
await fs.writeFile(finalPath, 'previous', 'utf8');
await fs.writeFile(tmpPath, 'updated-content', 'utf8');
await replaceFileAtomic(tmpPath, finalPath);
assert.equal(await fs.readFile(finalPath, 'utf8'), 'updated-content');
await assert.rejects(() => fs.stat(tmpPath));
});
void test('replaceFileAtomic: rejects empty source', async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'dnd-replace-'));
const finalPath = path.join(dir, 'out.bin');
const tmpPath = path.join(dir, 'out.bin.tmp');
await fs.writeFile(finalPath, 'x', 'utf8');
await fs.writeFile(tmpPath, '', 'utf8');
await assert.rejects(() => replaceFileAtomic(tmpPath, finalPath));
assert.equal(await fs.readFile(finalPath, 'utf8'), 'x');
});
@@ -45,3 +45,22 @@ void test('zipStore: normalizeScene defaults previewThumbAssetId for older proje
assert.match(src, /previewThumbAssetId/);
assert.match(src, /function normalizeScene\(/);
});
void test('zipStore: listProjects skips unreadable archives', () => {
const src = fs.readFileSync(path.join(here, 'zipStore.ts'), 'utf8');
assert.match(
src,
/async listProjects[\s\S]+?for \(const filePath of files\)[\s\S]+?try \{[\s\S]+?readProjectJsonFromZip/,
);
});
void test('atomicReplace: replaceFileAtomic must not rm destination before successful commit', () => {
const src = fs.readFileSync(path.join(here, 'atomicReplace.ts'), 'utf8');
const i = src.indexOf('export async function replaceFileAtomic');
assert.ok(i >= 0);
const j = src.indexOf('export async function recoverOrphanDndZipTmpInRoot', i);
assert.ok(j > i);
const block = src.slice(i, j);
assert.match(block, /rename\(finalPath, backupPath\)/);
assert.doesNotMatch(block, /\.rm\(\s*finalPath/);
});
+19 -32
View File
@@ -24,6 +24,7 @@ import { asAssetId, asGraphNodeId, asProjectId } from '../../shared/types/ids';
import { getAppSemanticVersion } from '../versionInfo';
import { reconcileAssetFiles } from './assetPrune';
import { recoverOrphanDndZipTmpInRoot, replaceFileAtomic } from './atomicReplace';
import { rmWithRetries } from './fsRetry';
import { optimizeImageBufferVisuallyLossless } from './optimizeImageImport.lib.mjs';
import { getLegacyProjectsRootDirs, getProjectsCacheRootDir, getProjectsRootDir } from './paths';
@@ -75,6 +76,7 @@ export class ZipProjectStore {
await fs.mkdir(getProjectsRootDir(), { recursive: true });
await fs.mkdir(getProjectsCacheRootDir(), { recursive: true });
await this.migrateLegacyProjectZipsIfNeeded();
await recoverOrphanDndZipTmpInRoot(getProjectsRootDir());
}
/** Копирует .dnd.zip из каталогов с «чужим» app name, если в текущем каталоге такого файла ещё нет. */
@@ -135,13 +137,17 @@ export class ZipProjectStore {
const out: ProjectIndexEntry[] = [];
for (const filePath of files) {
const project = await readProjectJsonFromZip(filePath);
out.push({
id: project.id,
name: project.meta.name,
updatedAt: project.meta.updatedAt,
fileName: path.basename(filePath),
});
try {
const project = await readProjectJsonFromZip(filePath);
out.push({
id: project.id,
name: project.meta.name,
updatedAt: project.meta.updatedAt,
fileName: path.basename(filePath),
});
} catch {
// Один битый архив не должен скрывать остальные проекты в списке.
}
}
out.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
return out;
@@ -868,6 +874,11 @@ export class ZipProjectStore {
const tmpPath = `${zipPath}.tmp`;
await fs.mkdir(path.dirname(zipPath), { recursive: true });
await zipDir(cacheDir, tmpPath);
const st = await fs.stat(tmpPath).catch(() => null);
if (!st?.isFile() || st.size < 22) {
await fs.unlink(tmpPath).catch(() => undefined);
throw new Error('Сборка архива проекта не удалась (пустой или повреждённый временный файл)');
}
await replaceFileAtomic(tmpPath, zipPath);
}
@@ -1226,20 +1237,6 @@ async function uniqueDndZipFileName(root: string, preferredBaseFileName: string)
}
}
async function replaceFileAtomic(srcPath: string, destPath: string): Promise<void> {
try {
await fs.rename(srcPath, destPath);
} catch {
try {
await fs.rm(destPath, { force: true });
await fs.rename(srcPath, destPath);
} catch (second: unknown) {
await fs.unlink(srcPath).catch(() => undefined);
throw second instanceof Error ? second : new Error(String(second));
}
}
}
async function copyFileWithProgress(
src: string,
dest: string,
@@ -1339,17 +1336,7 @@ async function atomicWriteFile(filePath: string, contents: string): Promise<void
await fs.mkdir(dir, { recursive: true });
const tmp = path.join(dir, `.tmp_${path.basename(filePath)}_${crypto.randomBytes(8).toString('hex')}`);
await fs.writeFile(tmp, contents, 'utf8');
try {
await fs.rename(tmp, filePath);
} catch {
try {
await fs.rm(filePath, { force: true });
await fs.rename(tmp, filePath);
} catch (second: unknown) {
await fs.unlink(tmp).catch(() => undefined);
throw second instanceof Error ? second : new Error(String(second));
}
}
await replaceFileAtomic(tmp, filePath);
}
/** Уже сжатые контейнеры/кодеки — в ZIP кладём без deflate, качество не трогаем; project.json и сырьё — deflate 9. */
+20 -1
View File
@@ -3,6 +3,8 @@ import path from 'node:path';
import { app, BrowserWindow, nativeImage, screen } from 'electron';
import { ipcChannels } from '../../shared/ipc/contracts';
import { getBootSplashWindow } from './bootWindow';
type WindowKind = 'editor' | 'presentation' | 'control';
@@ -207,6 +209,13 @@ function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow
});
}
win.on('closed', () => windows.delete(kind));
win.on('closed', () => {
if (kind !== 'presentation' && kind !== 'control') return;
const open = windows.has('presentation') || windows.has('control');
for (const w of BrowserWindow.getAllWindows()) {
w.webContents.send(ipcChannels.windows.multiWindowStateChanged, { open });
}
});
windows.set(kind, win);
return win;
}
@@ -279,7 +288,13 @@ export function openMultiWindow() {
presentation.maximize();
}
if (!windows.has('control')) {
createWindow('control', { parent: presentation });
// macOS: parent-child window binding moves child with the parent (unlike Windows behavior we want).
// Keep control window independent on darwin.
createWindow('control', process.platform === 'darwin' ? undefined : { parent: presentation });
}
const open = true;
for (const w of BrowserWindow.getAllWindows()) {
w.webContents.send(ipcChannels.windows.multiWindowStateChanged, { open });
}
}
@@ -290,6 +305,10 @@ export function closeMultiWindow(): void {
if (ctrl) ctrl.close();
}
export function isMultiWindowOpen(): boolean {
return windows.has('presentation') || windows.has('control');
}
export function togglePresentationFullscreen(): boolean {
const pres = windows.get('presentation');
if (!pres) return false;
+77
View File
@@ -134,6 +134,38 @@
z-index: 10000;
}
.editorLockOverlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.35);
display: flex;
align-items: center;
justify-content: center;
z-index: 11000;
}
.editorLockModal {
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);
display: grid;
gap: 8px;
}
.editorLockTitle {
font-weight: 800;
opacity: 0.95;
}
.editorLockText {
opacity: 0.85;
font-size: 12px;
line-height: 1.45;
}
.progressModal {
width: min(520px, calc(100vw - 32px));
border-radius: 12px;
@@ -462,6 +494,51 @@
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
.previewFill {
position: absolute;
inset: 0;
}
.previewBusyOverlay {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.55);
display: flex;
align-items: center;
justify-content: center;
padding: 12px;
}
.previewBusyModal {
display: grid;
justify-items: center;
gap: 10px;
color: var(--text1);
text-align: center;
}
.previewBusyText {
font-size: var(--text-xs);
font-weight: 700;
color: rgba(255, 255, 255, 0.92);
}
.previewSpinner {
width: 26px;
height: 26px;
border-radius: 999px;
border: 3px solid rgba(255, 255, 255, 0.25);
border-top-color: rgba(255, 255, 255, 0.9);
animation: previewSpin 0.9s linear infinite;
}
@keyframes previewSpin {
to {
transform: rotate(360deg);
}
}
.videoCover {
+107 -15
View File
@@ -64,6 +64,8 @@ export function EditorApp() {
const [settingsMenuOpen, setSettingsMenuOpen] = useState(false);
const [renameOpen, setRenameOpen] = useState(false);
const [exportModalOpen, setExportModalOpen] = useState(false);
const [previewBusy, setPreviewBusy] = useState(false);
const [presentationOpen, setPresentationOpen] = useState(false);
const [licenseSnap, setLicenseSnap] = useState<LicenseSnapshot | null>(null);
const [licenseKeyModalOpen, setLicenseKeyModalOpen] = useState(false);
const [eulaModalOpen, setEulaModalOpen] = useState(false);
@@ -215,6 +217,24 @@ export function EditorApp() {
return () => window.removeEventListener('mousedown', onDown);
}, [settingsMenuOpen]);
useEffect(() => {
let off: (() => void) | null = null;
void (async () => {
try {
const snap = await getDndApi().invoke(ipcChannels.windows.getMultiWindowState, {});
setPresentationOpen(snap.open);
} catch {
// ignore
}
off = getDndApi().on(ipcChannels.windows.multiWindowStateChanged, ({ open }) => {
setPresentationOpen(open);
});
})();
return () => {
off?.();
};
}, []);
const reloadLicense = useCallback(() => {
void (async () => {
try {
@@ -265,6 +285,19 @@ export function EditorApp() {
return (
<>
{presentationOpen
? createPortal(
<div className={styles.editorLockOverlay} role="dialog" aria-label="Презентация запущена">
<div className={styles.editorLockModal}>
<div className={styles.editorLockTitle}>Презентация запущена</div>
<div className={styles.editorLockText}>
Редактор заблокирован. Закройте окна «Презентация» и «Панель управления», чтобы продолжить.
</div>
</div>
</div>,
document.body,
)
: null}
{state.zipProgress
? createPortal(
<div className={styles.progressOverlay} role="dialog" aria-label="Прогресс операции">
@@ -412,6 +445,7 @@ export function EditorApp() {
) : (
<ProjectPicker
projects={state.projects}
licenseActive={licenseActive}
onCreate={actions.createProject}
onOpen={actions.openProject}
onDelete={actions.deleteProject}
@@ -480,6 +514,7 @@ export function EditorApp() {
previewAssetType={sc?.previewAssetType ?? null}
previewVideoAutostart={sc?.previewVideoAutostart ?? false}
previewRotationDeg={sc?.previewRotationDeg ?? 0}
previewBusy={previewBusy}
mediaAssets={sceneMediaAssets}
audioRefs={sceneAudioRefs}
onAudioRefsChange={(next) =>
@@ -492,7 +527,18 @@ export function EditorApp() {
onDescriptionChange={(description) =>
void actions.updateScene(sid, { description })
}
onImportPreview={() => void actions.importScenePreview(sid)}
onImportPreview={() => {
setPreviewBusy(true);
void (async () => {
try {
await actions.importScenePreview(sid);
} catch (e) {
window.alert(e instanceof Error ? e.message : String(e));
} finally {
setPreviewBusy(false);
}
})();
}}
onClearPreview={() => void actions.clearScenePreview(sid)}
onRotatePreview={(previewRotationDeg) =>
void actions.updateScene(sid, { previewRotationDeg })
@@ -915,12 +961,13 @@ function RenameProjectModal({
type ProjectPickerProps = {
projects: { id: ProjectId; name: string; updatedAt: string }[];
licenseActive: boolean;
onCreate: (name: string) => Promise<void>;
onOpen: (id: ProjectId) => Promise<void>;
onDelete: (id: ProjectId) => Promise<void>;
};
function ProjectPicker({ projects, onCreate, onOpen, onDelete }: ProjectPickerProps) {
function ProjectPicker({ projects, licenseActive, onCreate, onOpen, onDelete }: ProjectPickerProps) {
const [name, setName] = useState('Моя кампания');
const [rowMenuFor, setRowMenuFor] = useState<ProjectId | null>(null);
const [rowMenuPos, setRowMenuPos] = useState<{ left: number; top: number } | null>(null);
@@ -943,23 +990,46 @@ function ProjectPicker({ projects, onCreate, onOpen, onDelete }: ProjectPickerPr
<div className={styles.projectPickerTitle}>Проекты</div>
<div className={styles.projectPickerForm}>
<Input value={name} onChange={setName} placeholder="Название нового проекта…" />
<Button variant="primary" onClick={() => void onCreate(name)}>
<Button
variant="primary"
disabled={!licenseActive}
title={!licenseActive ? 'Доступно после активации лицензии' : undefined}
onClick={() => {
if (!licenseActive) return;
void onCreate(name);
}}
>
Создать проект
</Button>
</div>
<div className={styles.spacer6} />
<div className={styles.sectionLabel}>СУЩЕСТВУЮЩИЕ</div>
{!licenseActive && projects.length > 0 ? (
<>
<div className={styles.muted}>
Открытие и создание после активации лицензии. Список показывает файлы в папке приложения.
</div>
<div className={styles.spacer6} />
</>
) : null}
<div className={styles.projectListScroll}>
<div className={styles.projectList}>
{projects.map((p) => (
<div key={p.id} className={styles.projectCard}>
<div
className={styles.projectCardBody}
onClick={() => void onOpen(p.id)}
onClick={() => {
if (!licenseActive) return;
void onOpen(p.id);
}}
role="button"
tabIndex={0}
title={!licenseActive ? 'Открытие проекта — после активации лицензии' : undefined}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') void onOpen(p.id);
if (e.key === 'Enter' || e.key === ' ') {
if (!licenseActive) return;
void onOpen(p.id);
}
}}
>
<div className={styles.projectCardName}>{p.name}</div>
@@ -972,8 +1042,11 @@ function ProjectPicker({ projects, onCreate, onOpen, onDelete }: ProjectPickerPr
aria-label="Меню проекта"
aria-haspopup="menu"
aria-expanded={rowMenuFor === p.id}
disabled={!licenseActive}
title={!licenseActive ? 'Доступно после активации лицензии' : undefined}
onClick={(e) => {
e.stopPropagation();
if (!licenseActive) return;
const r = e.currentTarget.getBoundingClientRect();
const menuW = 220;
const left = Math.max(8, Math.min(r.right - menuW, window.innerWidth - menuW - 8));
@@ -1040,6 +1113,7 @@ type SceneInspectorProps = {
previewAssetType: 'image' | 'video' | null;
previewVideoAutostart: boolean;
previewRotationDeg: 0 | 90 | 180 | 270;
previewBusy: boolean;
mediaAssets: MediaAsset[];
audioRefs: SceneAudioRef[];
onAudioRefsChange: (next: SceneAudioRef[]) => void;
@@ -1145,6 +1219,7 @@ function SceneInspector({
previewAssetType,
previewVideoAutostart,
previewRotationDeg,
previewBusy,
mediaAssets,
audioRefs,
onAudioRefsChange,
@@ -1174,20 +1249,37 @@ function SceneInspector({
<div className={styles.hint}>Файл изображения (PNG, JPG, WebP, GIF и т.д.).</div>
<div className={styles.previewBox}>
{previewUrl && previewAssetType === 'image' ? (
<RotatedImage url={previewUrl} rotationDeg={previewRotationDeg} mode="cover" />
<div className={styles.previewFill}>
<RotatedImage
url={previewUrl}
rotationDeg={previewRotationDeg}
mode="cover"
style={{ width: '100%', height: '100%' }}
/>
</div>
) : previewUrl && previewAssetType === 'video' ? (
<video
src={previewUrl}
muted
playsInline
autoPlay={previewVideoAutostart}
loop
preload="metadata"
className={styles.videoCover}
/>
<div className={styles.previewFill}>
<video
src={previewUrl}
muted
playsInline
autoPlay={previewVideoAutostart}
loop
preload="metadata"
className={styles.videoCover}
/>
</div>
) : (
<div className={styles.previewEmpty}>Превью не задано</div>
)}
{previewBusy ? (
<div className={styles.previewBusyOverlay} aria-live="polite">
<div className={styles.previewBusyModal}>
<div className={styles.previewSpinner} aria-hidden />
<div className={styles.previewBusyText}>Загрузка и оптимизация изображения</div>
</div>
</div>
) : null}
</div>
<div className={styles.actionsRow}>
<Button variant="primary" onClick={onImportPreview}>
@@ -20,8 +20,10 @@
}
.cardActive {
border-color: var(--graph-node-active-border);
box-shadow: 0 25px 50px -12px rgba(139, 92, 246, 0.1);
border-color: rgba(167, 139, 250, 0.95);
box-shadow:
0 0 0 2px rgba(167, 139, 250, 0.35),
0 25px 50px -12px rgba(167, 139, 250, 0.12);
}
.previewShell {
+23 -3
View File
@@ -365,16 +365,36 @@ function SceneGraphCanvas({
}, [currentSceneId, sceneCardById, sceneGraphNodes]);
const desiredEdges = useMemo<Edge[]>(() => {
const selectedGraphNodeIds = new Set<GraphNodeId>();
if (currentSceneId) {
for (const gn of sceneGraphNodes) {
if (gn.sceneId === currentSceneId) selectedGraphNodeIds.add(gn.id);
}
}
const hasSelection = selectedGraphNodeIds.size > 0;
return sceneGraphEdges.map((e) => ({
...(hasSelection
? {
style:
selectedGraphNodeIds.has(e.sourceGraphNodeId) || selectedGraphNodeIds.has(e.targetGraphNodeId)
? { stroke: 'rgba(167,139,250,0.95)', strokeWidth: 3 }
: { stroke: 'rgba(255,255,255,0.10)', strokeWidth: 2 },
markerEnd:
selectedGraphNodeIds.has(e.sourceGraphNodeId) || selectedGraphNodeIds.has(e.targetGraphNodeId)
? { type: MarkerType.ArrowClosed, color: 'rgba(167,139,250,0.95)', strokeWidth: 2 }
: { type: MarkerType.ArrowClosed, color: 'rgba(255,255,255,0.18)', strokeWidth: 2 },
}
: {
style: { stroke: 'rgba(167,139,250,0.55)', strokeWidth: 2 },
markerEnd: { type: MarkerType.ArrowClosed, color: 'rgba(167,139,250,0.85)', strokeWidth: 2 },
}),
id: e.id,
source: e.sourceGraphNodeId,
target: e.targetGraphNodeId,
type: 'smoothstep',
animated: false,
style: { stroke: 'rgba(167,139,250,0.55)', strokeWidth: 2 },
markerEnd: { type: MarkerType.ArrowClosed, color: 'rgba(167,139,250,0.85)', strokeWidth: 2 },
}));
}, [sceneGraphEdges]);
}, [currentSceneId, sceneGraphEdges, sceneGraphNodes]);
const [nodes, setNodes, onNodesChange] = useNodesState<Node<SceneCardData>>([]);
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
@@ -9,7 +9,7 @@ const here = path.dirname(fileURLToPath(import.meta.url));
void test('projectState: list/get after delete invalidates in-flight initial load (epoch guard)', () => {
const src = fs.readFileSync(path.join(here, 'projectState.ts'), 'utf8');
assert.match(src, /projectDataEpochRef/);
assert.match(src, /const epoch = projectDataEpochRef\.current/);
assert.match(src, /const epoch = \+\+projectDataEpochRef\.current/);
assert.match(src, /if \(projectDataEpochRef\.current !== epoch\) return/);
assert.match(
src,
@@ -21,3 +21,8 @@ void test('projectState: list/get after delete invalidates in-flight initial loa
);
assert.match(src, /const refreshProjects = async \(\) => \{[\s\S]+?projectDataEpochRef\.current \+= 1/);
});
void test('ipc router: project.list does not require license', () => {
const routerSrc = fs.readFileSync(path.join(here, '..', '..', '..', 'main', 'ipc', 'router.ts'), 'utf8');
assert.match(routerSrc, /if \(channel === ipcChannels\.project\.list\) return false/);
});
+29 -16
View File
@@ -131,7 +131,7 @@ export function useProjectState(licenseActive: boolean): readonly [State, Action
const closeProject = async () => {
setState((s) => ({ ...s, project: null, selectedSceneId: null }));
if (licenseActive) await refreshProjects();
await refreshProjects();
};
const createScene = async () => {
@@ -385,24 +385,37 @@ export function useProjectState(licenseActive: boolean): readonly [State, Action
exportProject,
deleteProject,
};
}, [api, licenseActive]);
}, [api]);
useEffect(() => {
if (!licenseActive) {
queueMicrotask(() => {
projectDataEpochRef.current += 1;
setState({ projects: [], project: null, selectedSceneId: null, zipProgress: null });
});
return;
}
const epoch = ++projectDataEpochRef.current;
void (async () => {
const epoch = projectDataEpochRef.current;
const listRes = await api.invoke(ipcChannels.project.list, {});
if (projectDataEpochRef.current !== epoch) return;
setState((s) => ({ ...s, projects: listRes.projects }));
const res = await api.invoke(ipcChannels.project.get, {});
if (projectDataEpochRef.current !== epoch) return;
setState((s) => ({ ...s, project: res.project, selectedSceneId: res.project?.currentSceneId ?? null }));
try {
const listRes = await api.invoke(ipcChannels.project.list, {});
if (projectDataEpochRef.current !== epoch) return;
if (!licenseActive) {
setState((s) => ({
...s,
projects: listRes.projects,
project: null,
selectedSceneId: null,
}));
return;
}
setState((s) => ({ ...s, projects: listRes.projects }));
const res = await api.invoke(ipcChannels.project.get, {});
if (projectDataEpochRef.current !== epoch) return;
setState((s) => ({
...s,
project: res.project,
selectedSceneId: res.project?.currentSceneId ?? null,
}));
} catch {
if (projectDataEpochRef.current !== epoch) return;
if (!licenseActive) {
setState((s) => ({ ...s, project: null, selectedSceneId: null }));
}
}
})();
}, [licenseActive, api]);
+31 -16
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import styles from './RotatedImage.module.css';
@@ -24,13 +24,15 @@ function useElementSize<T extends HTMLElement>() {
useEffect(() => {
const el = ref.current;
if (!el) return;
// clientWidth/Height — локальная вёрстка; getBoundingClientRect учитывает transform предков (React Flow zoom).
const readLayoutSize = () => {
setSize({ w: el.clientWidth, h: el.clientHeight });
};
const ro = new ResizeObserver(() => {
const r = el.getBoundingClientRect();
setSize({ w: r.width, h: r.height });
readLayoutSize();
});
ro.observe(el);
const r = el.getBoundingClientRect();
setSize({ w: r.width, h: r.height });
readLayoutSize();
return () => ro.disconnect();
}, []);
@@ -49,18 +51,19 @@ export function RotatedImage({
}: RotatedImageProps) {
const [ref, size] = useElementSize<HTMLDivElement>();
const [imgSize, setImgSize] = useState<{ w: number; h: number } | null>(null);
const imgRef = useRef<HTMLImageElement | null>(null);
useEffect(() => {
let cancelled = false;
const img = new Image();
img.onload = () => {
if (cancelled) return;
setImgSize({ w: img.naturalWidth || 1, h: img.naturalHeight || 1 });
};
img.src = url;
return () => {
cancelled = true;
};
useLayoutEffect(() => {
// If the image is served from cache, onLoad may fire before listeners attach.
// Reading from the <img> element itself is the most reliable source.
const el = imgRef.current;
if (!el) return;
if (!el.complete) return;
const w0 = el.naturalWidth || 0;
const h0 = el.naturalHeight || 0;
if (w0 <= 0 || h0 <= 0) return;
// eslint-disable-next-line react-hooks/set-state-in-effect, @typescript-eslint/prefer-optional-chain -- read cached <img> dimensions when onLoad may not fire
setImgSize((prev) => (prev && prev.w === w0 && prev.h === h0 ? prev : { w: w0, h: h0 }));
}, [url]);
const scale = useMemo(() => {
@@ -93,11 +96,23 @@ export function RotatedImage({
return (
<div ref={ref} className={styles.root} style={style}>
<img
ref={imgRef}
alt={alt}
src={url}
loading={loading}
decoding={decoding}
className={styles.img}
onLoad={(e) => {
const el = e.currentTarget;
const w0 = el.naturalWidth || 0;
const h0 = el.naturalHeight || 0;
if (w0 <= 0 || h0 <= 0) return;
setImgSize((prev) => {
// eslint-disable-next-line @typescript-eslint/prefer-optional-chain -- rule can misfire on React state unions
if (prev && prev.w === w0 && prev.h === h0) return prev;
return { w: w0, h: h0 };
});
}}
style={{
width: w ?? '100%',
height: h ?? '100%',
+7
View File
@@ -52,6 +52,8 @@ export const ipcChannels = {
openMultiWindow: 'windows.openMultiWindow',
closeMultiWindow: 'windows.closeMultiWindow',
togglePresentationFullscreen: 'windows.togglePresentationFullscreen',
getMultiWindowState: 'windows.getMultiWindowState',
multiWindowStateChanged: 'windows.multiWindowStateChanged',
},
session: {
stateChanged: 'session.stateChanged',
@@ -87,6 +89,7 @@ export type IpcEventMap = {
[ipcChannels.effects.stateChanged]: { state: EffectsState };
[ipcChannels.video.stateChanged]: { state: VideoPlaybackState };
[ipcChannels.license.statusChanged]: { snapshot: LicenseSnapshot };
[ipcChannels.windows.multiWindowStateChanged]: { open: boolean };
[ipcChannels.project.importZipProgress]: ZipProgressEvent;
[ipcChannels.project.exportZipProgress]: ZipProgressEvent;
};
@@ -216,6 +219,10 @@ export type IpcInvokeMap = {
req: Record<string, never>;
res: { ok: true; isFullScreen: boolean };
};
[ipcChannels.windows.getMultiWindowState]: {
req: Record<string, never>;
res: { open: boolean };
};
[ipcChannels.effects.getState]: {
req: Record<string, never>;
res: { state: EffectsState };
+1 -2
View File
@@ -18,9 +18,8 @@ export default tseslint.config(
'node_modules/**',
'.cursor/**',
'scripts/**',
'tools/**',
'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.d.mts',
],
+1 -1
View File
@@ -10,7 +10,7 @@
"build:obfuscate": "node scripts/build.mjs --production --obfuscate",
"lint": "eslint . --max-warnings 0",
"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/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",
"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/replaceFileAtomic.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:write": "prettier . --write",
"release:info": "node scripts/print-release-info.mjs",
-28
View File
@@ -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`.
File diff suppressed because it is too large Load Diff
-21
View File
@@ -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"
}
}
-104
View File
@@ -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>
-346
View File
@@ -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();
});
-13
View File
@@ -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));
},
});
-83
View File
@@ -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;
});