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
@@ -0,0 +1,68 @@
import type { Project } from '../../../shared/types';
import type { SceneId } from '../../../shared/types/ids';
import type { SceneGraphSceneAudioSummary, SceneGraphSceneCard } from './SceneGraph';
export function stableSceneGraphAudios(
prevCard: SceneGraphSceneCard | undefined,
nextRaw: SceneGraphSceneAudioSummary[],
): readonly SceneGraphSceneAudioSummary[] {
if (!prevCard) return nextRaw;
const pa = prevCard.audios;
if (pa.length !== nextRaw.length) return nextRaw;
for (let i = 0; i < nextRaw.length; i++) {
const p = pa[i];
const n = nextRaw[i];
if (p?.assetId !== n?.assetId || p?.loop !== n?.loop || p?.autoplay !== n?.autoplay) return nextRaw;
}
return pa;
}
export function buildNextSceneCardById(
prevRecord: Record<SceneId, SceneGraphSceneCard>,
project: Project,
): Record<SceneId, SceneGraphSceneCard> {
const nextMap: Record<SceneId, SceneGraphSceneCard> = {};
for (const id of Object.keys(project.scenes) as SceneId[]) {
const s = project.scenes[id];
if (!s) continue;
const prevCard = prevRecord[id];
const nextAudiosRaw: SceneGraphSceneAudioSummary[] = s.media.audios.map((a) => ({
assetId: a.assetId,
loop: a.loop,
autoplay: a.autoplay,
}));
const audios = stableSceneGraphAudios(prevCard, nextAudiosRaw);
const loopVideo = s.settings.loopVideo;
if (
prevCard?.title === s.title &&
prevCard.previewAssetId === s.previewAssetId &&
prevCard.previewAssetType === s.previewAssetType &&
prevCard.previewVideoAutostart === s.previewVideoAutostart &&
prevCard.previewRotationDeg === s.previewRotationDeg &&
prevCard.loopVideo === loopVideo &&
prevCard.audios === audios
) {
nextMap[id] = prevCard;
} else {
nextMap[id] = {
title: s.title,
previewAssetId: s.previewAssetId,
previewAssetType: s.previewAssetType,
previewVideoAutostart: s.previewVideoAutostart,
previewRotationDeg: s.previewRotationDeg,
loopVideo,
audios,
};
}
}
const prevKeys = Object.keys(prevRecord);
const nextKeys = Object.keys(nextMap);
const reuseRecord =
prevKeys.length === nextKeys.length &&
nextKeys.every((k) => prevRecord[k as SceneId] === nextMap[k as SceneId]);
return reuseRecord ? prevRecord : nextMap;
}