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:
@@ -100,6 +100,10 @@
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.spacer18 {
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.sidebarScroll {
|
||||
overflow: auto;
|
||||
padding-right: 2px;
|
||||
@@ -408,7 +412,7 @@
|
||||
overflow: hidden;
|
||||
background: var(--color-overlay-dark-3);
|
||||
aspect-ratio: 16 / 9;
|
||||
max-height: 140px;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -19,6 +19,8 @@ type Actions = {
|
||||
closeProject: () => Promise<void>;
|
||||
createScene: () => Promise<void>;
|
||||
selectScene: (id: SceneId) => Promise<void>;
|
||||
importCampaignAudio: () => Promise<void>;
|
||||
updateCampaignAudios: (next: Project['campaignAudios']) => Promise<void>;
|
||||
updateScene: (
|
||||
sceneId: SceneId,
|
||||
patch: {
|
||||
@@ -129,6 +131,22 @@ export function useProjectState(licenseActive: boolean): readonly [State, Action
|
||||
await api.invoke(ipcChannels.project.setCurrentScene, { sceneId: id });
|
||||
};
|
||||
|
||||
const importCampaignAudio = async () => {
|
||||
const res = await api.invoke(ipcChannels.project.importCampaignAudio, {});
|
||||
if (res.canceled) return;
|
||||
if (res.imported.length === 0) {
|
||||
window.alert('Аудио не добавлено. Проверьте формат файла.');
|
||||
}
|
||||
setState((s) => ({ ...s, project: res.project }));
|
||||
await refreshProjects();
|
||||
};
|
||||
|
||||
const updateCampaignAudios = async (next: Project['campaignAudios']) => {
|
||||
const res = await api.invoke(ipcChannels.project.updateCampaignAudios, { audios: next });
|
||||
setState((s) => ({ ...s, project: res.project }));
|
||||
await refreshProjects();
|
||||
};
|
||||
|
||||
const updateScene = async (
|
||||
sceneId: SceneId,
|
||||
patch: {
|
||||
@@ -299,6 +317,8 @@ export function useProjectState(licenseActive: boolean): readonly [State, Action
|
||||
closeProject,
|
||||
createScene,
|
||||
selectScene,
|
||||
importCampaignAudio,
|
||||
updateCampaignAudios,
|
||||
updateScene,
|
||||
updateConnections,
|
||||
importMediaToScene,
|
||||
|
||||
Reference in New Issue
Block a user