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
+2 -2
View File
@@ -17,11 +17,11 @@ void test('collectReferencedAssetIds: превью, видео и аудио', (
previewThumbAssetId: 'th' as AssetId,
media: {
videos: ['v1' as AssetId],
audios: [{ assetId: 'a1' as AssetId, autoplay: true, loop: true }],
audios: [{ assetId: 'a1' as AssetId, autoplay: true, loop: true, volume: 1 }],
},
},
},
campaignAudios: [{ assetId: 'ca1' as AssetId, autoplay: true, loop: true }],
campaignAudios: [{ assetId: 'ca1' as AssetId, autoplay: true, loop: true, volume: 1 }],
materials: [{ id: 'm1', name: 'Map', assetId: 'mat1' as AssetId }],
npcs: [{ id: 'n1', name: 'Guard', avatarAssetId: 'npc1' as AssetId }],
} as unknown as Project;
+1 -1
View File
@@ -69,7 +69,7 @@ void test('readProjectJsonFromZip: campaignAudios round-trips inside project.jso
},
scenes: {},
assets: {},
campaignAudios: [{ assetId, autoplay: true, loop: false }],
campaignAudios: [{ assetId, autoplay: true, loop: false, volume: 1 }],
currentSceneId: null,
currentGraphNodeId: null,
sceneGraphNodes: [],
+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) => {