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
+211 -46
View File
@@ -4,7 +4,7 @@ import { createPortal } from 'react-dom';
import { ipcChannels } from '../../shared/ipc/contracts';
import { EULA_CURRENT_VERSION } from '../../shared/license/eulaVersion';
import type { LicenseSnapshot } from '../../shared/license/licenseSnapshot';
import type { AssetId, MediaAsset, ProjectId, SceneAudioRef, SceneId } from '../../shared/types';
import type { AssetId, MediaAsset, Project, ProjectId, SceneAudioRef, SceneId } from '../../shared/types';
import { AppLogo } from '../shared/branding/AppLogo';
import { getDndApi } from '../shared/dndApi';
import { RotatedImage } from '../shared/RotatedImage';
@@ -13,7 +13,8 @@ import { LayoutShell } from '../shared/ui/LayoutShell';
import { useAssetUrl } from '../shared/useAssetImageUrl';
import styles from './EditorApp.module.css';
import { DND_SCENE_ID_MIME, SceneGraph } from './graph/SceneGraph';
import { buildNextSceneCardById } from './graph/sceneCardById';
import { DND_SCENE_ID_MIME, SceneGraph, type SceneGraphSceneCard } from './graph/SceneGraph';
import { EulaModal, LicenseAboutModal, LicenseTokenModal } from './license/EditorLicenseModals';
import { useProjectState } from './state/projectState';
@@ -27,6 +28,33 @@ type SceneCard = {
previewRotationDeg: 0 | 90 | 180 | 270;
};
/** Лёгкая карта сцен для графа: стабильные ссылки на объекты, пока не меняются поля карточки. */
function useStableSceneCardById(project: Project | null): Record<SceneId, SceneGraphSceneCard> {
const recordRef = useRef<Record<SceneId, SceneGraphSceneCard>>({});
const projectIdRef = useRef<ProjectId | null>(null);
/* Ref cache: avoid new Record / per-scene object identities when only irrelevant Scene fields change
* (e.g. description). react-hooks/refs disallows ref access during render; this is intentional. */
/* eslint-disable react-hooks/refs -- stable graph input identity */
return useMemo(() => {
if (!project) {
recordRef.current = {};
projectIdRef.current = null;
return {};
}
if (projectIdRef.current !== project.id) {
recordRef.current = {};
projectIdRef.current = project.id;
}
const prevRecord = recordRef.current;
const nextMap = buildNextSceneCardById(prevRecord, project);
recordRef.current = nextMap;
return nextMap;
}, [project]);
/* eslint-enable react-hooks/refs */
}
export function EditorApp() {
const [appVersionText, setAppVersionText] = useState<string | null>(null);
const [query, setQuery] = useState('');
@@ -42,6 +70,7 @@ export function EditorApp() {
const [openKeyAfterEula, setOpenKeyAfterEula] = useState(false);
const licenseActive = licenseSnap?.active === true;
const [state, actions] = useProjectState(licenseActive);
const sceneCardById = useStableSceneCardById(state.project);
const fileMenuBtnRef = useRef<HTMLButtonElement | null>(null);
const projectMenuBtnRef = useRef<HTMLButtonElement | null>(null);
const settingsMenuBtnRef = useRef<HTMLButtonElement | null>(null);
@@ -51,15 +80,23 @@ export function EditorApp() {
const scenes = useMemo<SceneCard[]>(() => {
const p = state.project;
if (!p) return [];
return Object.values(p.scenes).map((s) => ({
id: s.id,
title: s.title,
active: s.id === state.selectedSceneId,
previewAssetId: s.previewAssetId,
previewAssetType: s.previewAssetType,
previewVideoAutostart: s.previewVideoAutostart,
previewRotationDeg: s.previewRotationDeg,
}));
const createdAtSortKey = (sceneId: string): number => {
// sceneId создаётся как `${prefix}_${rand}_${Date.now().toString(16)}`
const last = sceneId.split('_').at(-1) ?? '';
const n = Number.parseInt(last, 16);
return Number.isFinite(n) ? n : 0;
};
return Object.values(p.scenes)
.map((s) => ({
id: s.id,
title: s.title,
active: s.id === state.selectedSceneId,
previewAssetId: s.previewAssetId,
previewAssetType: s.previewAssetType,
previewVideoAutostart: s.previewVideoAutostart,
previewRotationDeg: s.previewRotationDeg,
}))
.sort((a, b) => createdAtSortKey(b.id) - createdAtSortKey(a.id));
}, [state.project, state.selectedSceneId]);
const filtered = useMemo(
@@ -86,6 +123,16 @@ export function EditorApp() {
return scene.media.audios;
}, [state.project, state.selectedSceneId]);
const campaignAudioRefs = useMemo<SceneAudioRef[]>(() => {
return state.project?.campaignAudios ?? [];
}, [state.project]);
const campaignAudioAssets = useMemo<MediaAsset[]>(() => {
const p = state.project;
if (!p) return [];
return campaignAudioRefs.map((r) => p.assets[r.assetId]).filter((a): a is MediaAsset => Boolean(a));
}, [campaignAudioRefs, state.project]);
const graphStartSceneId = useMemo(() => {
const p = state.project;
if (!p) return null;
@@ -354,7 +401,7 @@ export function EditorApp() {
<SceneGraph
sceneGraphNodes={state.project.sceneGraphNodes}
sceneGraphEdges={state.project.sceneGraphEdges}
sceneById={state.project.scenes}
sceneCardById={sceneCardById}
currentSceneId={state.selectedSceneId}
onCurrentSceneChange={(id) => void actions.selectScene(id)}
onConnect={(sourceGn, targetGn) => void actions.addSceneGraphEdge(sourceGn, targetGn)}
@@ -376,40 +423,66 @@ export function EditorApp() {
}
right={
<div className={styles.editorInspector}>
<div className={styles.inspectorTitle}>Свойства сцены</div>
<div className={styles.inspectorScroll}>
{state.project && state.selectedSceneId ? (
(() => {
const proj = state.project;
const sid = state.selectedSceneId;
const sc = proj.scenes[sid];
return (
<SceneInspector
title={sc?.title ?? ''}
description={sc?.description ?? ''}
previewAssetId={sc?.previewAssetId ?? null}
previewAssetType={sc?.previewAssetType ?? null}
previewVideoAutostart={sc?.previewVideoAutostart ?? false}
previewRotationDeg={sc?.previewRotationDeg ?? 0}
mediaAssets={sceneMediaAssets}
audioRefs={sceneAudioRefs}
onAudioRefsChange={(next) => void actions.updateScene(sid, { media: { audios: next } })}
onPreviewVideoAutostartChange={(next) =>
void actions.updateScene(sid, { previewVideoAutostart: next })
}
onTitleChange={(title) => void actions.updateScene(sid, { title })}
onDescriptionChange={(description) => void actions.updateScene(sid, { description })}
onImportPreview={() => void actions.importScenePreview(sid)}
onClearPreview={() => void actions.clearScenePreview(sid)}
onRotatePreview={(previewRotationDeg) =>
void actions.updateScene(sid, { previewRotationDeg })
}
onUploadMedia={() => void actions.importMediaToScene(sid)}
/>
);
})()
{state.project ? (
<>
<div className={styles.inspectorTitle}>Свойства игры</div>
<CampaignInspector
audioRefs={campaignAudioRefs}
mediaAssets={campaignAudioAssets}
onAudioRefsChange={(next) => void actions.updateCampaignAudios(next)}
onUploadAudio={() => {
void (async () => {
try {
await actions.importCampaignAudio();
} catch (e) {
window.alert(e instanceof Error ? e.message : String(e));
}
})();
}}
/>
<div className={styles.spacer18} />
<div className={styles.inspectorTitle}>Свойства сцены</div>
{state.selectedSceneId ? (
(() => {
const proj = state.project;
const sid = state.selectedSceneId;
const sc = proj.scenes[sid];
return (
<SceneInspector
title={sc?.title ?? ''}
description={sc?.description ?? ''}
previewAssetId={sc?.previewAssetId ?? null}
previewAssetType={sc?.previewAssetType ?? null}
previewVideoAutostart={sc?.previewVideoAutostart ?? false}
previewRotationDeg={sc?.previewRotationDeg ?? 0}
mediaAssets={sceneMediaAssets}
audioRefs={sceneAudioRefs}
onAudioRefsChange={(next) =>
void actions.updateScene(sid, { media: { audios: next } })
}
onPreviewVideoAutostartChange={(next) =>
void actions.updateScene(sid, { previewVideoAutostart: next })
}
onTitleChange={(title) => void actions.updateScene(sid, { title })}
onDescriptionChange={(description) =>
void actions.updateScene(sid, { description })
}
onImportPreview={() => void actions.importScenePreview(sid)}
onClearPreview={() => void actions.clearScenePreview(sid)}
onRotatePreview={(previewRotationDeg) =>
void actions.updateScene(sid, { previewRotationDeg })
}
onUploadMedia={() => void actions.importMediaToScene(sid)}
/>
);
})()
) : (
<div className={styles.muted}>Выберите сцену слева, чтобы редактировать её свойства.</div>
)}
</>
) : (
<div className={styles.muted}>Откройте проект, чтобы редактировать сцену.</div>
<div className={styles.muted}>Откройте проект, чтобы редактировать кампанию и сцены.</div>
)}
</div>
</div>
@@ -955,6 +1028,92 @@ type SceneInspectorProps = {
onUploadMedia: () => void;
};
type CampaignInspectorProps = {
mediaAssets: MediaAsset[];
audioRefs: SceneAudioRef[];
onAudioRefsChange: (next: SceneAudioRef[]) => void;
onUploadAudio: () => void;
};
function CampaignInspector({
mediaAssets,
audioRefs,
onAudioRefsChange,
onUploadAudio,
}: CampaignInspectorProps) {
const audioById = useMemo(() => new Map(audioRefs.map((a) => [a.assetId, a])), [audioRefs]);
return (
<div className={styles.sceneInspector}>
<div className={styles.labelSm}>АУДИО ИГРЫ</div>
<div className={styles.audioDrop}>
{mediaAssets.filter((a) => a.type === 'audio').length === 0 ? (
<div className={[styles.muted, styles.spanSm].join(' ')}>Файлов пока нет. Добавьте аудио.</div>
) : (
<div className={styles.audioList}>
{mediaAssets
.filter((a) => a.type === 'audio')
.map((a) => (
<div key={a.id} className={styles.audioRow}>
<span className={styles.audioName}>{a.originalName}</span>
<span className={styles.audioControls}>
<label className={styles.checkboxLabelSm}>
<input
type="checkbox"
checked={audioById.get(a.id)?.autoplay ?? false}
onChange={(e) => {
const next = audioRefs.map((x) =>
x.assetId === a.id ? { ...x, autoplay: e.target.checked } : x,
);
onAudioRefsChange(next);
}}
/>
<span className={styles.spanXs}>Авто</span>
</label>
<label className={styles.checkboxLabelSm}>
<input
type="checkbox"
checked={audioById.get(a.id)?.loop ?? false}
onChange={(e) => {
const next = audioRefs.map((x) =>
x.assetId === a.id ? { ...x, loop: e.target.checked } : x,
);
onAudioRefsChange(next);
}}
/>
<span className={styles.spanXs}>Цикл</span>
</label>
<button
type="button"
title="Убрать из кампании"
className={styles.audioRemove}
onClick={() => {
onAudioRefsChange(audioRefs.filter((x) => x.assetId !== a.id));
}}
>
<svg
className={styles.audioRemoveIcon}
viewBox="0 0 24 24"
width={16}
height={16}
aria-hidden
>
<path
fill="currentColor"
d="M9 3h6a1 1 0 0 1 1 1v1h4v2H4V5h4V4a1 1 0 0 1 1-1zm1 5h2v9h-2V8zm4 0h2v9h-2V8zM7 8h2v9H7V8zm9-3H8v1h8V5zM6 21a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V8H6v13z"
/>
</svg>
</button>
</span>
</div>
))}
</div>
)}
<Button onClick={onUploadAudio}>Загрузить</Button>
</div>
</div>
);
}
function SceneInspector({
title,
description,
@@ -988,7 +1147,7 @@ function SceneInspector({
/>
<div className={styles.spacer6} />
<div className={styles.labelSm}>ПРЕВЬЮ СЦЕНЫ</div>
<div className={styles.hint}>Отдельный файл изображения (PNG, JPG, WebP, GIF и т.д.).</div>
<div className={styles.hint}>Файл изображения (PNG, JPG, WebP, GIF и т.д.).</div>
<div className={styles.previewBox}>
{previewUrl && previewAssetType === 'image' ? (
<RotatedImage url={previewUrl} rotationDeg={previewRotationDeg} mode="cover" />
@@ -1159,7 +1318,13 @@ function SceneListCard({ scene, onSelect, onDeleteScene }: SceneListCardProps) {
<div className={url ? styles.sceneThumb : styles.sceneThumbEmpty}>
{url && scene.previewAssetType === 'image' ? (
<div className={styles.sceneThumbInner}>
<RotatedImage url={url} rotationDeg={scene.previewRotationDeg} mode="cover" />
<RotatedImage
url={url}
rotationDeg={scene.previewRotationDeg}
mode="cover"
loading="lazy"
decoding="async"
/>
</div>
) : url && scene.previewAssetType === 'video' ? (
<div className={styles.sceneThumbInner}>