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:
@@ -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, {});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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\)/);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user