feat(audio): volume в проекте, DnD, параллельные плейлисты

Громкость треков сцены/игры сохраняется в проекте; порядок через DnD; несколько плейлистов сразу; автоимя из файла; стоп превью при запуске сессии.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-09-05 17:09:26 +08:00
parent dbe8ab1fda
commit 586166cacd
20 changed files with 640 additions and 239 deletions
+38 -15
View File
@@ -45,12 +45,13 @@ import type {
ProjectNpcGroup,
ProjectNpcRelation,
Scene,
SceneAudioRef,
SceneGraphEdge,
SceneGraphNode,
SceneId,
SceneTrap,
} from '../../shared/types';
import { PROJECT_SCHEMA_VERSION } from '../../shared/types';
import { normalizeSceneAudioRef, PROJECT_SCHEMA_VERSION } from '../../shared/types';
import { normalizeMaterialLegend } from '../../shared/types/materialLegend';
import { normalizeSceneToken, type SceneToken } from '../../shared/types/appTokens';
import {
@@ -817,7 +818,14 @@ export class ZipProjectStore {
: null),
...(patch.grid !== undefined ? { grid: normalizeSceneGrid(patch.grid) } : null),
...(patch.settings ? { settings: { ...base.settings, ...patch.settings } } : null),
...(patch.media ? { media: { ...base.media, ...patch.media } } : null),
...(patch.media
? {
media: {
videos: patch.media.videos ?? base.media.videos,
audios: (patch.media.audios ?? base.media.audios).map((a) => normalizeSceneAudioRef(a)),
},
}
: null),
...(patch.layout ? { layout: { ...base.layout, ...patch.layout } } : null),
};
@@ -1180,8 +1188,9 @@ export class ZipProjectStore {
else
media.audios.push({
assetId: asset.id,
autoplay: sc.settings.autoplayAudio,
loop: sc.settings.loopAudio,
autoplay: false,
loop: false,
volume: 1,
});
}
return {
@@ -1232,7 +1241,7 @@ export class ZipProjectStore {
for (const asset of staged) {
assets[asset.id] = asset;
if (asset.type !== 'audio') continue;
campaignAudios.push({ assetId: asset.id, autoplay: false, loop: false });
campaignAudios.push({ assetId: asset.id, autoplay: false, loop: false, volume: 1 });
}
return { ...p, assets, campaignAudios };
});
@@ -1245,7 +1254,10 @@ export class ZipProjectStore {
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 normalized = (Array.isArray(audios) ? audios : [])
.filter((a) => a && typeof a === 'object' && a.assetId)
.map((a) => normalizeSceneAudioRef(a));
await this.updateProject((p) => ({ ...p, campaignAudios: normalized }));
const latest = this.getOpenProject();
if (!latest) throw new Error('No open project');
return latest;
@@ -2624,20 +2636,26 @@ function normalizeScene(s: Scene): Scene {
const audios = rawAudios
.map((a) => {
if (typeof a === 'string') {
return {
return normalizeSceneAudioRef({
assetId: a as AssetId,
autoplay: Boolean((s.settings as { autoplayAudio?: boolean } | undefined)?.autoplayAudio),
loop: Boolean((s.settings as { loopAudio?: boolean } | undefined)?.loopAudio),
};
volume: 1,
});
}
if (a && typeof a === 'object') {
const obj = a as { assetId?: AssetId; autoplay?: boolean; loop?: boolean };
const obj = a as { assetId?: AssetId; autoplay?: boolean; loop?: boolean; volume?: unknown };
if (!obj.assetId) return null;
return { assetId: obj.assetId, autoplay: Boolean(obj.autoplay), loop: Boolean(obj.loop) };
return normalizeSceneAudioRef({
assetId: obj.assetId,
autoplay: Boolean(obj.autoplay),
loop: Boolean(obj.loop),
volume: obj.volume,
});
}
return null;
})
.filter((x): x is { assetId: AssetId; autoplay: boolean; loop: boolean } => Boolean(x));
.filter((x): x is SceneAudioRef => Boolean(x));
return {
...s,
@@ -2682,15 +2700,20 @@ function normalizeProject(p: Project): Project {
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 (typeof a === 'string') return normalizeSceneAudioRef({ assetId: a as AssetId, autoplay: false, loop: false, volume: 1 });
if (a && typeof a === 'object') {
const obj = a as { assetId?: AssetId; autoplay?: boolean; loop?: boolean };
const obj = a as { assetId?: AssetId; autoplay?: boolean; loop?: boolean; volume?: unknown };
if (!obj.assetId) return null;
return { assetId: obj.assetId, autoplay: Boolean(obj.autoplay), loop: Boolean(obj.loop) };
return normalizeSceneAudioRef({
assetId: obj.assetId,
autoplay: Boolean(obj.autoplay),
loop: Boolean(obj.loop),
volume: obj.volume,
});
}
return null;
})
.filter((x): x is { assetId: AssetId; autoplay: boolean; loop: boolean } => Boolean(x));
.filter((x): x is SceneAudioRef => Boolean(x));
const rawMaterials = (p as unknown as { materials?: unknown[] }).materials;
const materials = (Array.isArray(rawMaterials) ? rawMaterials : [])
.map((m) => {