DNDGamePlayer: Electron редактор сцен, презентация, упаковка electron-builder
Made-with: Cursor
This commit is contained in:
@@ -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)));
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user