fix: game audio persistence and editor perf

- Keep game/campaign audio assets referenced (no prune)
- Flush pending project save on quit/switch/export to avoid losing campaignAudios
- Control: prevent game music restarts on scene changes; allow always-on controls; handle autoplay-after-scene-audio
- Editor: reduce ReactFlow churn with stable scene card map; lazy/async image decode
- Add contract/unit tests and update test script

Made-with: Cursor
This commit is contained in:
Ivan Fontosh
2026-04-22 19:06:16 +08:00
parent f823a7c05f
commit 1d051f8bf9
19 changed files with 1164 additions and 115 deletions
+5 -2
View File
@@ -20,9 +20,10 @@ void test('collectReferencedAssetIds: превью, видео и аудио', (
},
},
},
campaignAudios: [{ assetId: 'ca1' as AssetId, autoplay: true, loop: true }],
} as unknown as Project;
const s = collectReferencedAssetIds(p);
assert.deepEqual([...s].sort(), ['a1', 'pr', 'v1'].sort());
assert.deepEqual([...s].sort(), ['a1', 'ca1', 'pr', 'v1'].sort());
});
void test('reconcileAssetFiles: снимает осиротевшие assets и удаляет файлы', async () => {
@@ -62,11 +63,13 @@ void test('reconcileAssetFiles: снимает осиротевшие assets и
const prev: Project = {
...base,
scenes: {},
campaignAudios: [],
assets: { orphan: asset } as Project['assets'],
};
const next: Project = {
...base,
scenes: {},
campaignAudios: [],
assets: { orphan: asset } as Project['assets'],
};
@@ -111,7 +114,7 @@ void test('reconcileAssetFiles: удаляет файл при исключен
} as unknown as Project;
const prev: Project = { ...base, assets: { gone: asset } as Project['assets'] };
const next: Project = { ...base, assets: {} as Project['assets'] };
const next: Project = { ...base, campaignAudios: [], assets: {} as Project['assets'] };
const out = await reconcileAssetFiles(prev, next, tmp);
assert.deepEqual(out.assets, {});
+1
View File
@@ -12,6 +12,7 @@ export function collectReferencedAssetIds(p: Project): Set<AssetId> {
for (const vid of sc.media.videos) refs.add(vid);
for (const au of sc.media.audios) refs.add(au.assetId);
}
for (const au of p.campaignAudios) refs.add(au.assetId);
return refs;
}
+45
View File
@@ -51,3 +51,48 @@ void test('readProjectJsonFromZip: sequential reads close yauzl (no EMFILE)', as
await fs.rm(tmp, { recursive: true, force: true });
});
void test('readProjectJsonFromZip: campaignAudios round-trips inside project.json', async () => {
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dnd-zip-campaign-'));
const zipPath = path.join(tmp, 'test.dnd.zip');
const assetId = 'audio_asset_1';
const minimal = {
id: 'p1',
meta: {
name: 'n',
fileBaseName: 'f',
createdAt: '2020-01-01',
updatedAt: '2020-01-01',
createdWithAppVersion: '1',
appVersion: '1',
schemaVersion: PROJECT_SCHEMA_VERSION,
},
scenes: {},
assets: {},
campaignAudios: [{ assetId, autoplay: true, loop: false }],
currentSceneId: null,
currentGraphNodeId: null,
sceneGraphNodes: [],
sceneGraphEdges: [],
};
const zipfile = new ZipFile();
zipfile.addBuffer(Buffer.from(JSON.stringify(minimal), 'utf8'), 'project.json', { compressionLevel: 9 });
const out = fssync.createWriteStream(zipPath);
const done = new Promise<void>((resolve, reject) => {
out.on('close', resolve);
out.on('error', reject);
});
zipfile.outputStream.pipe(out);
zipfile.end();
await done;
const p = await readProjectJsonFromZip(zipPath);
assert.ok(Array.isArray((p as { campaignAudios?: unknown }).campaignAudios));
assert.deepEqual(
(p as { campaignAudios: typeof minimal.campaignAudios }).campaignAudios,
minimal.campaignAudios,
);
await fs.rm(tmp, { recursive: true, force: true });
});
@@ -23,3 +23,19 @@ void test('zipStore: legacy migration moves or copy\\+rm so deleted projects are
assert.match(src, /rmWithRetries\(fs\.rm, from, \{ force: true \}\)/);
assert.match(src, /не «возрождались»/);
});
void test('zipStore: openProjectById flushes pending saveNow before cache reset', () => {
const src = fs.readFileSync(path.join(here, 'zipStore.ts'), 'utf8');
// When switching projects we rm cacheDir and unzip zip; ensure pending debounced pack is flushed first.
assert.match(src, /async openProjectById/);
assert.match(src, /if \(this\.openProject\)\s*\{\s*await this\.saveNow\(\);\s*\}/);
assert.match(src, /await fs\.rm\(cacheDir, \{ recursive: true, force: true \}\)/);
assert.match(src, /await unzipToDir\(zipPath, cacheDir\)/);
});
void test('zipStore: exportProjectZipToPath flushes saveNow for currently open project', () => {
const src = fs.readFileSync(path.join(here, 'zipStore.ts'), 'utf8');
assert.match(src, /async exportProjectZipToPath/);
assert.match(src, /if \(this\.openProject\?\.id === projectId\)\s*\{\s*await this\.saveNow\(\);\s*\}/);
assert.match(src, /await fs\.copyFile\(src, dest\)/);
});
+78
View File
@@ -165,6 +165,7 @@ export class ZipProjectStore {
},
scenes: {},
assets: {},
campaignAudios: [],
currentSceneId: null,
currentGraphNodeId: null,
sceneGraphNodes: [],
@@ -182,6 +183,11 @@ export class ZipProjectStore {
async openProjectById(projectId: ProjectId): Promise<Project> {
await this.ensureRoots();
// Mutations are persisted to cache immediately, but zip packing is debounced (queueSave).
// When switching projects we delete the cache and restore it from the zip, so flush pending saves first.
if (this.openProject) {
await this.saveNow();
}
this.projectSession += 1;
const list = await this.listProjects();
const entry = list.find((p) => p.id === projectId);
@@ -589,6 +595,61 @@ export class ZipProjectStore {
return { project: latest, imported: staged };
}
/**
* Copies audio files into cache `assets/` and registers them on the project as campaign audio.
*/
async importCampaignAudioFiles(filePaths: string[]): Promise<{ project: Project; imported: MediaAsset[] }> {
const open = this.openProject;
if (!open) throw new Error('No open project');
if (filePaths.length === 0) {
return { project: open.project, imported: [] };
}
const staged: MediaAsset[] = [];
for (const filePath of filePaths) {
const kind = classifyMediaPath(filePath);
if (kind?.type !== 'audio') continue;
const buf = await fs.readFile(filePath);
const sha256 = crypto.createHash('sha256').update(buf).digest('hex');
const id = asAssetId(this.randomId());
const orig = path.basename(filePath);
const safeOrig = sanitizeFileName(orig);
const relPath = `assets/${id}_${safeOrig}`;
const abs = path.join(open.cacheDir, relPath);
await fs.mkdir(path.dirname(abs), { recursive: true });
await fs.copyFile(filePath, abs);
staged.push(buildMediaAsset(id, kind, orig, relPath, sha256, buf.length));
}
if (staged.length === 0) {
return { project: open.project, imported: [] };
}
await this.updateProject((p) => {
const assets = { ...p.assets };
const campaignAudios = [...p.campaignAudios];
for (const asset of staged) {
assets[asset.id] = asset;
if (asset.type !== 'audio') continue;
campaignAudios.push({ assetId: asset.id, autoplay: true, loop: true });
}
return { ...p, assets, campaignAudios };
});
const latest = this.getOpenProject();
if (!latest) throw new Error('No open project');
return { project: latest, imported: staged };
}
async setCampaignAudios(audios: Project['campaignAudios']): Promise<Project> {
const open = this.openProject;
if (!open) throw new Error('No open project');
await this.updateProject((p) => ({ ...p, campaignAudios: Array.isArray(audios) ? audios : [] }));
const latest = this.getOpenProject();
if (!latest) throw new Error('No open project');
return latest;
}
async saveNow(): Promise<void> {
const open = this.openProject;
if (!open) return;
@@ -777,6 +838,10 @@ export class ZipProjectStore {
/** Копия файла проекта в указанный путь (полный путь к `.dnd.zip`). */
async exportProjectZipToPath(projectId: ProjectId, destinationPath: string): Promise<void> {
await this.ensureRoots();
// If exporting the currently open project, make sure pending debounced pack is flushed.
if (this.openProject?.id === projectId) {
await this.saveNow();
}
const list = await this.listProjects();
const entry = list.find((p) => p.id === projectId);
if (!entry) {
@@ -982,6 +1047,18 @@ function normalizeProject(p: Project): Project {
}
sceneGraphNodes = normalizeSceneGraphNodeFlags(sceneGraphNodes);
const currentGraphNodeId = (p as { currentGraphNodeId?: GraphNodeId | null }).currentGraphNodeId ?? null;
const rawCampaignAudios = (p as unknown as { campaignAudios?: unknown[] }).campaignAudios;
const campaignAudios = (Array.isArray(rawCampaignAudios) ? rawCampaignAudios : [])
.map((a) => {
if (typeof a === 'string') return { assetId: a as AssetId, autoplay: false, loop: false };
if (a && typeof a === 'object') {
const obj = a as { assetId?: AssetId; autoplay?: boolean; loop?: boolean };
if (!obj.assetId) return null;
return { assetId: obj.assetId, autoplay: Boolean(obj.autoplay), loop: Boolean(obj.loop) };
}
return null;
})
.filter((x): x is { assetId: AssetId; autoplay: boolean; loop: boolean } => Boolean(x));
const metaRaw = p.meta as unknown as { createdWithAppVersion?: string; appVersion?: string };
const createdWithAppVersion = (() => {
const c = metaRaw.createdWithAppVersion?.trim();
@@ -1001,6 +1078,7 @@ function normalizeProject(p: Project): Project {
schemaVersion: PROJECT_SCHEMA_VERSION,
},
scenes,
campaignAudios,
sceneGraphNodes,
sceneGraphEdges,
currentGraphNodeId,