DNDGamePlayer: Electron редактор сцен, презентация, упаковка electron-builder

Made-with: Cursor
This commit is contained in:
Ivan Fontosh
2026-04-19 14:16:54 +08:00
commit a6cbcc273e
82 changed files with 22195 additions and 0 deletions
+119
View File
@@ -0,0 +1,119 @@
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 { PROJECT_SCHEMA_VERSION, type Project } from '../../shared/types';
import type { AssetId } from '../../shared/types/ids';
import { collectReferencedAssetIds, reconcileAssetFiles } from './assetPrune';
void test('collectReferencedAssetIds: превью, видео и аудио', () => {
const p = {
scenes: {
s1: {
previewAssetId: 'pr' as AssetId,
media: {
videos: ['v1' as AssetId],
audios: [{ assetId: 'a1' as AssetId, autoplay: true, loop: true }],
},
},
},
} as unknown as Project;
const s = collectReferencedAssetIds(p);
assert.deepEqual([...s].sort(), ['a1', 'pr', 'v1'].sort());
});
void test('reconcileAssetFiles: снимает осиротевшие assets и удаляет файлы', async () => {
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dnd-asset-prune-'));
const relPath = 'assets/orphan.bin';
await fs.mkdir(path.join(tmp, 'assets'), { recursive: true });
await fs.writeFile(path.join(tmp, relPath), Buffer.from([1, 2, 3]));
const asset = {
id: 'orph' as AssetId,
type: 'audio' as const,
mime: 'audio/wav',
originalName: 'x.wav',
relPath,
sha256: 'a',
sizeBytes: 3,
createdAt: new Date().toISOString(),
};
const base = {
id: 'p1',
meta: {
name: 't',
fileBaseName: 't',
createdAt: '',
updatedAt: '',
createdWithAppVersion: '1',
appVersion: '1',
schemaVersion: PROJECT_SCHEMA_VERSION,
},
currentSceneId: null,
currentGraphNodeId: null,
sceneGraphNodes: [],
sceneGraphEdges: [],
} as unknown as Project;
const prev: Project = {
...base,
scenes: {},
assets: { orphan: asset } as Project['assets'],
};
const next: Project = {
...base,
scenes: {},
assets: { orphan: asset } as Project['assets'],
};
const out = await reconcileAssetFiles(prev, next, tmp);
assert.ok(!('orphan' in out.assets));
await assert.rejects(() => fs.stat(path.join(tmp, relPath)));
});
void test('reconcileAssetFiles: удаляет файл при исключении id из assets', async () => {
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dnd-asset-prune-'));
const relPath = 'assets/gone.bin';
await fs.mkdir(path.join(tmp, 'assets'), { recursive: true });
await fs.writeFile(path.join(tmp, relPath), Buffer.from([9]));
const asset = {
id: 'gone' as AssetId,
type: 'audio' as const,
mime: 'audio/wav',
originalName: 'x.wav',
relPath,
sha256: 'b',
sizeBytes: 1,
createdAt: new Date().toISOString(),
};
const base = {
id: 'p1',
meta: {
name: 't',
fileBaseName: 't',
createdAt: '',
updatedAt: '',
createdWithAppVersion: '1',
appVersion: '1',
schemaVersion: PROJECT_SCHEMA_VERSION,
},
scenes: {},
currentSceneId: null,
currentGraphNodeId: null,
sceneGraphNodes: [],
sceneGraphEdges: [],
} as unknown as Project;
const prev: Project = { ...base, assets: { gone: asset } as Project['assets'] };
const next: Project = { ...base, assets: {} as Project['assets'] };
const out = await reconcileAssetFiles(prev, next, tmp);
assert.deepEqual(out.assets, {});
await assert.rejects(() => fs.stat(path.join(tmp, relPath)));
});
+51
View File
@@ -0,0 +1,51 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import type { MediaAsset, Project } from '../../shared/types';
import type { AssetId } from '../../shared/types/ids';
/** Все asset id, на которые есть ссылки из сцен (превью, видео, аудио). */
export function collectReferencedAssetIds(p: Project): Set<AssetId> {
const refs = new Set<AssetId>();
for (const sc of Object.values(p.scenes)) {
if (sc.previewAssetId) refs.add(sc.previewAssetId);
for (const vid of sc.media.videos) refs.add(vid);
for (const au of sc.media.audios) refs.add(au.assetId);
}
return refs;
}
/**
* Удаляет с диска файлы снятых материалов и записи в `assets`, на которые больше нет ссылок.
*/
export async function reconcileAssetFiles(prev: Project, next: Project, cacheDir: string): Promise<Project> {
const prevIds = new Set(Object.keys(prev.assets) as AssetId[]);
const nextIds = new Set(Object.keys(next.assets) as AssetId[]);
for (const id of prevIds) {
if (nextIds.has(id)) continue;
const a = prev.assets[id];
if (a) {
const abs = path.join(cacheDir, a.relPath);
await fs.unlink(abs).catch(() => undefined);
}
}
const refs = collectReferencedAssetIds(next);
const assets = next.assets;
const kept: Record<AssetId, MediaAsset> = {} as Record<AssetId, MediaAsset>;
let droppedOrphans = false;
for (const id of Object.keys(assets) as AssetId[]) {
const a = assets[id];
if (!a) continue;
if (refs.has(id)) {
kept[id] = a;
} else {
droppedOrphans = true;
const abs = path.join(cacheDir, a.relPath);
await fs.unlink(abs).catch(() => undefined);
}
}
return droppedOrphans ? { ...next, assets: kept } : next;
}
+27
View File
@@ -0,0 +1,27 @@
import path from 'node:path';
import { app } from 'electron';
export function getProjectsRootDir(): string {
return path.join(app.getPath('userData'), 'projects');
}
export function getProjectsCacheRootDir(): string {
return path.join(app.getPath('userData'), 'projects-cache');
}
/**
* Каталоги `…/projects` из других имён приложения в `%AppData%` (родитель `userData`).
* Если когда‑то меняли `app.setName`, проекты могли остаться в соседней папке — их подхватываем при старте.
*/
export function getLegacyProjectsRootDirs(): string[] {
const cur = getProjectsRootDir();
const parent = path.dirname(app.getPath('userData'));
const siblingNames = ['DnD Player', 'dnd-player', 'DNDGamePlayer', 'dnd_player'];
const out: string[] = [];
for (const n of siblingNames) {
const p = path.join(parent, n, 'projects');
if (p !== cur) out.push(p);
}
return out;
}
File diff suppressed because it is too large Load Diff