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
+41 -22
View File
@@ -19,19 +19,29 @@ import ReactFlow, {
import 'reactflow/dist/style.css';
import { isSceneGraphEdgeRejected } from '../../../shared/graph/sceneGraphEdgeRules';
import type {
AssetId,
GraphNodeId,
Scene,
SceneGraphEdge,
SceneGraphNode,
SceneId,
} from '../../../shared/types';
import type { AssetId, GraphNodeId, SceneGraphEdge, SceneGraphNode, SceneId } from '../../../shared/types';
import { RotatedImage } from '../../shared/RotatedImage';
import { useAssetUrl } from '../../shared/useAssetImageUrl';
import styles from './SceneGraph.module.css';
/** Поля сцены, нужные только для карточки узла графа (без описания и прочего). */
export type SceneGraphSceneAudioSummary = {
assetId: AssetId;
loop: boolean;
autoplay: boolean;
};
export type SceneGraphSceneCard = {
title: string;
previewAssetId: AssetId | null;
previewAssetType: 'image' | 'video' | null;
previewVideoAutostart: boolean;
previewRotationDeg: 0 | 90 | 180 | 270;
loopVideo: boolean;
audios: readonly SceneGraphSceneAudioSummary[];
};
/** MIME для перетаскивания сцены из списка на граф (см. EditorApp). */
export const DND_SCENE_ID_MIME = 'application/x-dnd-scene-id';
@@ -42,7 +52,7 @@ const SCENE_CARD_H = 248;
export type SceneGraphProps = {
sceneGraphNodes: SceneGraphNode[];
sceneGraphEdges: SceneGraphEdge[];
sceneById: Record<SceneId, Scene>;
sceneCardById: Record<SceneId, SceneGraphSceneCard>;
currentSceneId: SceneId | null;
onCurrentSceneChange: (id: SceneId) => void;
onConnect: (sourceGraphNodeId: GraphNodeId, targetGraphNodeId: GraphNodeId) => void;
@@ -132,12 +142,21 @@ function SceneCardNode({ data }: NodeProps<SceneCardData>) {
{url && data.previewAssetType === 'image' ? (
<div className={styles.previewFill}>
{data.previewRotationDeg === 0 ? (
<img src={url} alt="" className={styles.imageCover} draggable={false} />
<img
src={url}
alt=""
className={styles.imageCover}
draggable={false}
loading="lazy"
decoding="async"
/>
) : (
<RotatedImage
url={url}
rotationDeg={data.previewRotationDeg}
mode="cover"
loading="lazy"
decoding="async"
style={{ width: '100%', height: '100%' }}
/>
)}
@@ -261,7 +280,7 @@ function GraphZoomToolbar() {
function SceneGraphCanvas({
sceneGraphNodes,
sceneGraphEdges,
sceneById,
sceneCardById,
currentSceneId,
onCurrentSceneChange,
onConnect,
@@ -291,33 +310,33 @@ function SceneGraphCanvas({
const desiredNodes = useMemo<Node<SceneCardData>[]>(() => {
return sceneGraphNodes.map((gn) => {
const s = sceneById[gn.sceneId];
const c = sceneCardById[gn.sceneId];
const active = gn.sceneId === currentSceneId;
const audios = s?.media.audios ?? [];
const audios = c?.audios ?? [];
return {
id: gn.id,
type: 'sceneCard',
position: { x: gn.x, y: gn.y },
data: {
sceneId: gn.sceneId,
title: s?.title ?? '',
title: c?.title ?? '',
active,
previewAssetId: s?.previewAssetId ?? null,
previewAssetType: s?.previewAssetType ?? null,
previewVideoAutostart: s?.previewVideoAutostart ?? false,
previewRotationDeg: s?.previewRotationDeg ?? 0,
previewAssetId: c?.previewAssetId ?? null,
previewAssetType: c?.previewAssetType ?? null,
previewVideoAutostart: c?.previewVideoAutostart ?? false,
previewRotationDeg: c?.previewRotationDeg ?? 0,
isStartScene: gn.isStartScene,
hasSceneAudio: audios.length >= 1,
previewIsVideo: s?.previewAssetType === 'video',
previewIsVideo: c?.previewAssetType === 'video',
hasAnyAudioLoop: audios.some((a) => a.loop),
hasAnyAudioAutoplay: audios.some((a) => a.autoplay),
showPreviewVideoAutostart: s?.previewAssetType === 'video' ? s.previewVideoAutostart : false,
showPreviewVideoLoop: s?.previewAssetType === 'video' ? s.settings.loopVideo : false,
showPreviewVideoAutostart: c?.previewAssetType === 'video' ? c.previewVideoAutostart : false,
showPreviewVideoLoop: c?.previewAssetType === 'video' ? c.loopVideo : false,
},
style: { padding: 0, background: 'transparent', border: 'none' },
};
});
}, [currentSceneId, sceneById, sceneGraphNodes]);
}, [currentSceneId, sceneCardById, sceneGraphNodes]);
const desiredEdges = useMemo<Edge[]>(() => {
return sceneGraphEdges.map((e) => ({
@@ -0,0 +1,101 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { Project } from '../../../shared/types';
import type { AssetId, SceneId } from '../../../shared/types/ids';
import { buildNextSceneCardById } from './sceneCardById';
function minimalProject(overrides: Partial<Project>): Project {
return {
id: 'p1' as unknown as Project['id'],
meta: {
name: 'n',
fileBaseName: 'f',
createdAt: '',
updatedAt: '',
createdWithAppVersion: '1',
appVersion: '1',
schemaVersion: 1 as unknown as Project['meta']['schemaVersion'],
},
scenes: {},
assets: {},
campaignAudios: [],
currentSceneId: null,
currentGraphNodeId: null,
sceneGraphNodes: [],
sceneGraphEdges: [],
...overrides,
} as unknown as Project;
}
void test('buildNextSceneCardById: does not change refs when irrelevant fields change', () => {
const sid = 's1' as SceneId;
const base = minimalProject({
scenes: {
[sid]: {
id: sid,
title: 'T',
description: 'A',
media: { videos: [], audios: [{ assetId: 'a1' as AssetId, autoplay: false, loop: true }] },
settings: { autoplayVideo: false, autoplayAudio: false, loopVideo: false, loopAudio: false },
connections: [],
layout: { x: 0, y: 0 },
previewAssetId: null,
previewAssetType: null,
previewVideoAutostart: false,
previewRotationDeg: 0,
},
} as unknown as Project['scenes'],
});
const first = buildNextSceneCardById({}, base);
const card1 = first[sid];
assert.ok(card1);
const changedOnlyDescription = minimalProject({
...base,
scenes: {
...base.scenes,
[sid]: { ...base.scenes[sid], description: 'B' },
},
});
const second = buildNextSceneCardById(first, changedOnlyDescription);
assert.equal(second, first, 'record identity should be reused');
assert.equal(second[sid], card1, 'card identity should be reused');
});
void test('buildNextSceneCardById: changes card when title changes', () => {
const sid = 's1' as SceneId;
const base = minimalProject({
scenes: {
[sid]: {
id: sid,
title: 'T',
description: '',
media: { videos: [], audios: [] },
settings: { autoplayVideo: false, autoplayAudio: false, loopVideo: false, loopAudio: false },
connections: [],
layout: { x: 0, y: 0 },
previewAssetId: null,
previewAssetType: null,
previewVideoAutostart: false,
previewRotationDeg: 0,
},
} as unknown as Project['scenes'],
});
const first = buildNextSceneCardById({}, base);
const card1 = first[sid];
const changedTitle = minimalProject({
...base,
scenes: {
...base.scenes,
[sid]: { ...base.scenes[sid], title: 'T2' },
},
});
const second = buildNextSceneCardById(first, changedTitle);
assert.notEqual(second[sid], card1);
});
@@ -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;
}