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:
@@ -165,6 +165,7 @@ export class ZipProjectStore {
|
||||
},
|
||||
scenes: {},
|
||||
assets: {},
|
||||
campaignAudios: [],
|
||||
currentSceneId: null,
|
||||
currentGraphNodeId: null,
|
||||
sceneGraphNodes: [],
|
||||
@@ -182,6 +183,11 @@ export class ZipProjectStore {
|
||||
|
||||
async openProjectById(projectId: ProjectId): Promise<Project> {
|
||||
await this.ensureRoots();
|
||||
// Mutations are persisted to cache immediately, but zip packing is debounced (queueSave).
|
||||
// When switching projects we delete the cache and restore it from the zip, so flush pending saves first.
|
||||
if (this.openProject) {
|
||||
await this.saveNow();
|
||||
}
|
||||
this.projectSession += 1;
|
||||
const list = await this.listProjects();
|
||||
const entry = list.find((p) => p.id === projectId);
|
||||
@@ -589,6 +595,61 @@ export class ZipProjectStore {
|
||||
return { project: latest, imported: staged };
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies audio files into cache `assets/` and registers them on the project as campaign audio.
|
||||
*/
|
||||
async importCampaignAudioFiles(filePaths: string[]): Promise<{ project: Project; imported: MediaAsset[] }> {
|
||||
const open = this.openProject;
|
||||
if (!open) throw new Error('No open project');
|
||||
if (filePaths.length === 0) {
|
||||
return { project: open.project, imported: [] };
|
||||
}
|
||||
|
||||
const staged: MediaAsset[] = [];
|
||||
for (const filePath of filePaths) {
|
||||
const kind = classifyMediaPath(filePath);
|
||||
if (kind?.type !== 'audio') continue;
|
||||
const buf = await fs.readFile(filePath);
|
||||
const sha256 = crypto.createHash('sha256').update(buf).digest('hex');
|
||||
const id = asAssetId(this.randomId());
|
||||
const orig = path.basename(filePath);
|
||||
const safeOrig = sanitizeFileName(orig);
|
||||
const relPath = `assets/${id}_${safeOrig}`;
|
||||
const abs = path.join(open.cacheDir, relPath);
|
||||
await fs.mkdir(path.dirname(abs), { recursive: true });
|
||||
await fs.copyFile(filePath, abs);
|
||||
staged.push(buildMediaAsset(id, kind, orig, relPath, sha256, buf.length));
|
||||
}
|
||||
|
||||
if (staged.length === 0) {
|
||||
return { project: open.project, imported: [] };
|
||||
}
|
||||
|
||||
await this.updateProject((p) => {
|
||||
const assets = { ...p.assets };
|
||||
const campaignAudios = [...p.campaignAudios];
|
||||
for (const asset of staged) {
|
||||
assets[asset.id] = asset;
|
||||
if (asset.type !== 'audio') continue;
|
||||
campaignAudios.push({ assetId: asset.id, autoplay: true, loop: true });
|
||||
}
|
||||
return { ...p, assets, campaignAudios };
|
||||
});
|
||||
|
||||
const latest = this.getOpenProject();
|
||||
if (!latest) throw new Error('No open project');
|
||||
return { project: latest, imported: staged };
|
||||
}
|
||||
|
||||
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 latest = this.getOpenProject();
|
||||
if (!latest) throw new Error('No open project');
|
||||
return latest;
|
||||
}
|
||||
|
||||
async saveNow(): Promise<void> {
|
||||
const open = this.openProject;
|
||||
if (!open) return;
|
||||
@@ -777,6 +838,10 @@ export class ZipProjectStore {
|
||||
/** Копия файла проекта в указанный путь (полный путь к `.dnd.zip`). */
|
||||
async exportProjectZipToPath(projectId: ProjectId, destinationPath: string): Promise<void> {
|
||||
await this.ensureRoots();
|
||||
// If exporting the currently open project, make sure pending debounced pack is flushed.
|
||||
if (this.openProject?.id === projectId) {
|
||||
await this.saveNow();
|
||||
}
|
||||
const list = await this.listProjects();
|
||||
const entry = list.find((p) => p.id === projectId);
|
||||
if (!entry) {
|
||||
@@ -982,6 +1047,18 @@ function normalizeProject(p: Project): Project {
|
||||
}
|
||||
sceneGraphNodes = normalizeSceneGraphNodeFlags(sceneGraphNodes);
|
||||
const currentGraphNodeId = (p as { currentGraphNodeId?: GraphNodeId | null }).currentGraphNodeId ?? null;
|
||||
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 (a && typeof a === 'object') {
|
||||
const obj = a as { assetId?: AssetId; autoplay?: boolean; loop?: boolean };
|
||||
if (!obj.assetId) return null;
|
||||
return { assetId: obj.assetId, autoplay: Boolean(obj.autoplay), loop: Boolean(obj.loop) };
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter((x): x is { assetId: AssetId; autoplay: boolean; loop: boolean } => Boolean(x));
|
||||
const metaRaw = p.meta as unknown as { createdWithAppVersion?: string; appVersion?: string };
|
||||
const createdWithAppVersion = (() => {
|
||||
const c = metaRaw.createdWithAppVersion?.trim();
|
||||
@@ -1001,6 +1078,7 @@ function normalizeProject(p: Project): Project {
|
||||
schemaVersion: PROJECT_SCHEMA_VERSION,
|
||||
},
|
||||
scenes,
|
||||
campaignAudios,
|
||||
sceneGraphNodes,
|
||||
sceneGraphEdges,
|
||||
currentGraphNodeId,
|
||||
|
||||
Reference in New Issue
Block a user