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:
@@ -172,6 +172,27 @@ async function runStartupAfterHandlers(licenseService: LicenseService): Promise<
|
|||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
await app.whenReady();
|
await app.whenReady();
|
||||||
|
/**
|
||||||
|
* `ZipProjectStore` пишет `project.json` в кэш синхронно с мутациями, а упаковку `.dnd.zip`
|
||||||
|
* откладывает (`queueSave`, ~250 мс). Без финального `saveNow()` при выходе на диске
|
||||||
|
* остаётся старый zip — при следующем открытии кэш пересоздаётся из него и теряются
|
||||||
|
* недавние поля (в т.ч. `campaignAudios`).
|
||||||
|
*/
|
||||||
|
let appQuittingAfterPendingSave = false;
|
||||||
|
app.on('before-quit', (e) => {
|
||||||
|
if (appQuittingAfterPendingSave) return;
|
||||||
|
e.preventDefault();
|
||||||
|
appQuittingAfterPendingSave = true;
|
||||||
|
void projectStore
|
||||||
|
.saveNow()
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
console.error('[before-quit] saveNow failed', err);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
app.quit();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
const licenseService = new LicenseService(app.getPath('userData'));
|
const licenseService = new LicenseService(app.getPath('userData'));
|
||||||
setLicenseAssert(() => {
|
setLicenseAssert(() => {
|
||||||
licenseService.assertForIpc();
|
licenseService.assertForIpc();
|
||||||
@@ -286,6 +307,30 @@ async function main() {
|
|||||||
emitSessionState();
|
emitSessionState();
|
||||||
return result;
|
return result;
|
||||||
});
|
});
|
||||||
|
registerHandler(ipcChannels.project.importCampaignAudio, async () => {
|
||||||
|
const { canceled, filePaths } = await dialog.showOpenDialog({
|
||||||
|
properties: ['openFile', 'multiSelections'],
|
||||||
|
filters: [
|
||||||
|
{
|
||||||
|
name: 'Аудио',
|
||||||
|
extensions: ['mp3', 'wav', 'ogg', 'm4a', 'aac'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
if (canceled || filePaths.length === 0) {
|
||||||
|
const project = projectStore.getOpenProject();
|
||||||
|
if (!project) throw new Error('No open project');
|
||||||
|
return { canceled: true as const, project, imported: [] };
|
||||||
|
}
|
||||||
|
const result = await projectStore.importCampaignAudioFiles(filePaths);
|
||||||
|
emitSessionState();
|
||||||
|
return { canceled: false as const, ...result };
|
||||||
|
});
|
||||||
|
registerHandler(ipcChannels.project.updateCampaignAudios, async ({ audios }) => {
|
||||||
|
const project = await projectStore.setCampaignAudios(audios);
|
||||||
|
emitSessionState();
|
||||||
|
return { project };
|
||||||
|
});
|
||||||
registerHandler(ipcChannels.project.importScenePreview, async ({ sceneId }) => {
|
registerHandler(ipcChannels.project.importScenePreview, async ({ sceneId }) => {
|
||||||
const { canceled, filePaths } = await dialog.showOpenDialog({
|
const { canceled, filePaths } = await dialog.showOpenDialog({
|
||||||
properties: ['openFile'],
|
properties: ['openFile'],
|
||||||
|
|||||||
@@ -20,9 +20,10 @@ void test('collectReferencedAssetIds: превью, видео и аудио', (
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
campaignAudios: [{ assetId: 'ca1' as AssetId, autoplay: true, loop: true }],
|
||||||
} as unknown as Project;
|
} as unknown as Project;
|
||||||
const s = collectReferencedAssetIds(p);
|
const s = collectReferencedAssetIds(p);
|
||||||
assert.deepEqual([...s].sort(), ['a1', 'pr', 'v1'].sort());
|
assert.deepEqual([...s].sort(), ['a1', 'ca1', 'pr', 'v1'].sort());
|
||||||
});
|
});
|
||||||
|
|
||||||
void test('reconcileAssetFiles: снимает осиротевшие assets и удаляет файлы', async () => {
|
void test('reconcileAssetFiles: снимает осиротевшие assets и удаляет файлы', async () => {
|
||||||
@@ -62,11 +63,13 @@ void test('reconcileAssetFiles: снимает осиротевшие assets и
|
|||||||
const prev: Project = {
|
const prev: Project = {
|
||||||
...base,
|
...base,
|
||||||
scenes: {},
|
scenes: {},
|
||||||
|
campaignAudios: [],
|
||||||
assets: { orphan: asset } as Project['assets'],
|
assets: { orphan: asset } as Project['assets'],
|
||||||
};
|
};
|
||||||
const next: Project = {
|
const next: Project = {
|
||||||
...base,
|
...base,
|
||||||
scenes: {},
|
scenes: {},
|
||||||
|
campaignAudios: [],
|
||||||
assets: { orphan: asset } as Project['assets'],
|
assets: { orphan: asset } as Project['assets'],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -111,7 +114,7 @@ void test('reconcileAssetFiles: удаляет файл при исключен
|
|||||||
} as unknown as Project;
|
} as unknown as Project;
|
||||||
|
|
||||||
const prev: Project = { ...base, assets: { gone: asset } as Project['assets'] };
|
const prev: Project = { ...base, assets: { gone: asset } as Project['assets'] };
|
||||||
const next: Project = { ...base, assets: {} as Project['assets'] };
|
const next: Project = { ...base, campaignAudios: [], assets: {} as Project['assets'] };
|
||||||
|
|
||||||
const out = await reconcileAssetFiles(prev, next, tmp);
|
const out = await reconcileAssetFiles(prev, next, tmp);
|
||||||
assert.deepEqual(out.assets, {});
|
assert.deepEqual(out.assets, {});
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export function collectReferencedAssetIds(p: Project): Set<AssetId> {
|
|||||||
for (const vid of sc.media.videos) refs.add(vid);
|
for (const vid of sc.media.videos) refs.add(vid);
|
||||||
for (const au of sc.media.audios) refs.add(au.assetId);
|
for (const au of sc.media.audios) refs.add(au.assetId);
|
||||||
}
|
}
|
||||||
|
for (const au of p.campaignAudios) refs.add(au.assetId);
|
||||||
return refs;
|
return refs;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -51,3 +51,48 @@ void test('readProjectJsonFromZip: sequential reads close yauzl (no EMFILE)', as
|
|||||||
|
|
||||||
await fs.rm(tmp, { recursive: true, force: true });
|
await fs.rm(tmp, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
void test('readProjectJsonFromZip: campaignAudios round-trips inside project.json', async () => {
|
||||||
|
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dnd-zip-campaign-'));
|
||||||
|
const zipPath = path.join(tmp, 'test.dnd.zip');
|
||||||
|
const assetId = 'audio_asset_1';
|
||||||
|
const minimal = {
|
||||||
|
id: 'p1',
|
||||||
|
meta: {
|
||||||
|
name: 'n',
|
||||||
|
fileBaseName: 'f',
|
||||||
|
createdAt: '2020-01-01',
|
||||||
|
updatedAt: '2020-01-01',
|
||||||
|
createdWithAppVersion: '1',
|
||||||
|
appVersion: '1',
|
||||||
|
schemaVersion: PROJECT_SCHEMA_VERSION,
|
||||||
|
},
|
||||||
|
scenes: {},
|
||||||
|
assets: {},
|
||||||
|
campaignAudios: [{ assetId, autoplay: true, loop: false }],
|
||||||
|
currentSceneId: null,
|
||||||
|
currentGraphNodeId: null,
|
||||||
|
sceneGraphNodes: [],
|
||||||
|
sceneGraphEdges: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const zipfile = new ZipFile();
|
||||||
|
zipfile.addBuffer(Buffer.from(JSON.stringify(minimal), 'utf8'), 'project.json', { compressionLevel: 9 });
|
||||||
|
const out = fssync.createWriteStream(zipPath);
|
||||||
|
const done = new Promise<void>((resolve, reject) => {
|
||||||
|
out.on('close', resolve);
|
||||||
|
out.on('error', reject);
|
||||||
|
});
|
||||||
|
zipfile.outputStream.pipe(out);
|
||||||
|
zipfile.end();
|
||||||
|
await done;
|
||||||
|
|
||||||
|
const p = await readProjectJsonFromZip(zipPath);
|
||||||
|
assert.ok(Array.isArray((p as { campaignAudios?: unknown }).campaignAudios));
|
||||||
|
assert.deepEqual(
|
||||||
|
(p as { campaignAudios: typeof minimal.campaignAudios }).campaignAudios,
|
||||||
|
minimal.campaignAudios,
|
||||||
|
);
|
||||||
|
|
||||||
|
await fs.rm(tmp, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|||||||
@@ -23,3 +23,19 @@ void test('zipStore: legacy migration moves or copy\\+rm so deleted projects are
|
|||||||
assert.match(src, /rmWithRetries\(fs\.rm, from, \{ force: true \}\)/);
|
assert.match(src, /rmWithRetries\(fs\.rm, from, \{ force: true \}\)/);
|
||||||
assert.match(src, /не «возрождались»/);
|
assert.match(src, /не «возрождались»/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
void test('zipStore: openProjectById flushes pending saveNow before cache reset', () => {
|
||||||
|
const src = fs.readFileSync(path.join(here, 'zipStore.ts'), 'utf8');
|
||||||
|
// When switching projects we rm cacheDir and unzip zip; ensure pending debounced pack is flushed first.
|
||||||
|
assert.match(src, /async openProjectById/);
|
||||||
|
assert.match(src, /if \(this\.openProject\)\s*\{\s*await this\.saveNow\(\);\s*\}/);
|
||||||
|
assert.match(src, /await fs\.rm\(cacheDir, \{ recursive: true, force: true \}\)/);
|
||||||
|
assert.match(src, /await unzipToDir\(zipPath, cacheDir\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('zipStore: exportProjectZipToPath flushes saveNow for currently open project', () => {
|
||||||
|
const src = fs.readFileSync(path.join(here, 'zipStore.ts'), 'utf8');
|
||||||
|
assert.match(src, /async exportProjectZipToPath/);
|
||||||
|
assert.match(src, /if \(this\.openProject\?\.id === projectId\)\s*\{\s*await this\.saveNow\(\);\s*\}/);
|
||||||
|
assert.match(src, /await fs\.copyFile\(src, dest\)/);
|
||||||
|
});
|
||||||
|
|||||||
@@ -165,6 +165,7 @@ export class ZipProjectStore {
|
|||||||
},
|
},
|
||||||
scenes: {},
|
scenes: {},
|
||||||
assets: {},
|
assets: {},
|
||||||
|
campaignAudios: [],
|
||||||
currentSceneId: null,
|
currentSceneId: null,
|
||||||
currentGraphNodeId: null,
|
currentGraphNodeId: null,
|
||||||
sceneGraphNodes: [],
|
sceneGraphNodes: [],
|
||||||
@@ -182,6 +183,11 @@ export class ZipProjectStore {
|
|||||||
|
|
||||||
async openProjectById(projectId: ProjectId): Promise<Project> {
|
async openProjectById(projectId: ProjectId): Promise<Project> {
|
||||||
await this.ensureRoots();
|
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;
|
this.projectSession += 1;
|
||||||
const list = await this.listProjects();
|
const list = await this.listProjects();
|
||||||
const entry = list.find((p) => p.id === projectId);
|
const entry = list.find((p) => p.id === projectId);
|
||||||
@@ -589,6 +595,61 @@ export class ZipProjectStore {
|
|||||||
return { project: latest, imported: staged };
|
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> {
|
async saveNow(): Promise<void> {
|
||||||
const open = this.openProject;
|
const open = this.openProject;
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
@@ -777,6 +838,10 @@ export class ZipProjectStore {
|
|||||||
/** Копия файла проекта в указанный путь (полный путь к `.dnd.zip`). */
|
/** Копия файла проекта в указанный путь (полный путь к `.dnd.zip`). */
|
||||||
async exportProjectZipToPath(projectId: ProjectId, destinationPath: string): Promise<void> {
|
async exportProjectZipToPath(projectId: ProjectId, destinationPath: string): Promise<void> {
|
||||||
await this.ensureRoots();
|
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 list = await this.listProjects();
|
||||||
const entry = list.find((p) => p.id === projectId);
|
const entry = list.find((p) => p.id === projectId);
|
||||||
if (!entry) {
|
if (!entry) {
|
||||||
@@ -982,6 +1047,18 @@ function normalizeProject(p: Project): Project {
|
|||||||
}
|
}
|
||||||
sceneGraphNodes = normalizeSceneGraphNodeFlags(sceneGraphNodes);
|
sceneGraphNodes = normalizeSceneGraphNodeFlags(sceneGraphNodes);
|
||||||
const currentGraphNodeId = (p as { currentGraphNodeId?: GraphNodeId | null }).currentGraphNodeId ?? null;
|
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 metaRaw = p.meta as unknown as { createdWithAppVersion?: string; appVersion?: string };
|
||||||
const createdWithAppVersion = (() => {
|
const createdWithAppVersion = (() => {
|
||||||
const c = metaRaw.createdWithAppVersion?.trim();
|
const c = metaRaw.createdWithAppVersion?.trim();
|
||||||
@@ -1001,6 +1078,7 @@ function normalizeProject(p: Project): Project {
|
|||||||
schemaVersion: PROJECT_SCHEMA_VERSION,
|
schemaVersion: PROJECT_SCHEMA_VERSION,
|
||||||
},
|
},
|
||||||
scenes,
|
scenes,
|
||||||
|
campaignAudios,
|
||||||
sceneGraphNodes,
|
sceneGraphNodes,
|
||||||
sceneGraphEdges,
|
sceneGraphEdges,
|
||||||
currentGraphNodeId,
|
currentGraphNodeId,
|
||||||
|
|||||||
@@ -49,10 +49,18 @@ export function ControlApp() {
|
|||||||
const historyRef = useRef<GraphNodeId[]>([]);
|
const historyRef = useRef<GraphNodeId[]>([]);
|
||||||
const suppressNextHistoryPushRef = useRef(false);
|
const suppressNextHistoryPushRef = useRef(false);
|
||||||
const [history, setHistory] = useState<GraphNodeId[]>([]);
|
const [history, setHistory] = useState<GraphNodeId[]>([]);
|
||||||
const audioElsRef = useRef<Map<string, HTMLAudioElement>>(new Map());
|
const sceneAudioElsRef = useRef<Map<string, HTMLAudioElement>>(new Map());
|
||||||
const audioMetaRef = useRef<Map<string, { lastPlayError: string | null }>>(new Map());
|
const sceneAudioMetaRef = useRef<Map<string, { lastPlayError: string | null }>>(new Map());
|
||||||
const [audioStateTick, setAudioStateTick] = useState(0);
|
const [sceneAudioStateTick, setSceneAudioStateTick] = useState(0);
|
||||||
const audioLoadRunRef = useRef(0);
|
const sceneAudioLoadRunRef = useRef(0);
|
||||||
|
|
||||||
|
const campaignAudioElsRef = useRef<Map<string, HTMLAudioElement>>(new Map());
|
||||||
|
const campaignAudioMetaRef = useRef<Map<string, { lastPlayError: string | null }>>(new Map());
|
||||||
|
const [campaignAudioStateTick, setCampaignAudioStateTick] = useState(0);
|
||||||
|
const campaignAudioLoadRunRef = useRef(0);
|
||||||
|
/** Snapshot of `!el.paused` per assetId when scene music takes over; used to resume when `allowCampaignAudio` is true again. */
|
||||||
|
const campaignResumeAfterSceneRef = useRef<Map<string, boolean> | null>(null);
|
||||||
|
const allowCampaignAudioRef = useRef<boolean>(true);
|
||||||
const audioUnmountRef = useRef(false);
|
const audioUnmountRef = useRef(false);
|
||||||
const previewHostRef = useRef<HTMLDivElement | null>(null);
|
const previewHostRef = useRef<HTMLDivElement | null>(null);
|
||||||
const previewVideoRef = useRef<HTMLVideoElement | null>(null);
|
const previewVideoRef = useRef<HTMLVideoElement | null>(null);
|
||||||
@@ -137,6 +145,17 @@ export function ControlApp() {
|
|||||||
project && session?.currentSceneId ? project.scenes[session.currentSceneId] : undefined;
|
project && session?.currentSceneId ? project.scenes[session.currentSceneId] : undefined;
|
||||||
const isVideoPreviewScene = currentScene?.previewAssetType === 'video';
|
const isVideoPreviewScene = currentScene?.previewAssetType === 'video';
|
||||||
const sceneAudioRefs = useMemo(() => currentScene?.media.audios ?? [], [currentScene]);
|
const sceneAudioRefs = useMemo(() => currentScene?.media.audios ?? [], [currentScene]);
|
||||||
|
// Keep this memo as narrow as possible: project changes on scene switch,
|
||||||
|
// but campaign audio list/config often does not.
|
||||||
|
const campaignAudioRefs = useMemo(() => project?.campaignAudios ?? [], [project?.campaignAudios]);
|
||||||
|
const allowCampaignAudio = !sceneAudioRefs.some((a) => a.autoplay);
|
||||||
|
allowCampaignAudioRef.current = allowCampaignAudio;
|
||||||
|
|
||||||
|
const campaignAudioSpecKey = useMemo(
|
||||||
|
() =>
|
||||||
|
campaignAudioRefs.map((r) => `${r.assetId}:${r.loop ? '1' : '0'}:${r.autoplay ? '1' : '0'}`).join('|'),
|
||||||
|
[campaignAudioRefs],
|
||||||
|
);
|
||||||
|
|
||||||
const sceneAudios = useMemo(() => {
|
const sceneAudios = useMemo(() => {
|
||||||
if (!project) return [];
|
if (!project) return [];
|
||||||
@@ -150,14 +169,26 @@ export function ControlApp() {
|
|||||||
);
|
);
|
||||||
}, [project, sceneAudioRefs]);
|
}, [project, sceneAudioRefs]);
|
||||||
|
|
||||||
useEffect(() => {
|
const campaignAudios = useMemo(() => {
|
||||||
audioLoadRunRef.current += 1;
|
if (!project) return [];
|
||||||
const runId = audioLoadRunRef.current;
|
return campaignAudioRefs
|
||||||
|
.map((r) => {
|
||||||
|
const a = project.assets[r.assetId];
|
||||||
|
return a?.type === 'audio' ? { ref: r, asset: a } : null;
|
||||||
|
})
|
||||||
|
.filter((x): x is { ref: (typeof campaignAudioRefs)[number]; asset: NonNullable<typeof x>['asset'] } =>
|
||||||
|
Boolean(x),
|
||||||
|
);
|
||||||
|
}, [campaignAudioRefs, project]);
|
||||||
|
|
||||||
const oldEls = new Map(audioElsRef.current);
|
useEffect(() => {
|
||||||
audioElsRef.current = new Map();
|
sceneAudioLoadRunRef.current += 1;
|
||||||
audioMetaRef.current.clear();
|
const runId = sceneAudioLoadRunRef.current;
|
||||||
setAudioStateTick((x) => x + 1);
|
|
||||||
|
const oldEls = new Map(sceneAudioElsRef.current);
|
||||||
|
sceneAudioElsRef.current = new Map();
|
||||||
|
sceneAudioMetaRef.current.clear();
|
||||||
|
setSceneAudioStateTick((x) => x + 1);
|
||||||
|
|
||||||
const FADE_OUT_MS = 450;
|
const FADE_OUT_MS = 450;
|
||||||
const fadeOutCtl = { raf: 0, cancelled: false };
|
const fadeOutCtl = { raf: 0, cancelled: false };
|
||||||
@@ -213,24 +244,24 @@ export function ControlApp() {
|
|||||||
const loaded: { ref: (typeof sceneAudioRefs)[number]; el: HTMLAudioElement }[] = [];
|
const loaded: { ref: (typeof sceneAudioRefs)[number]; el: HTMLAudioElement }[] = [];
|
||||||
for (const item of sceneAudioRefs) {
|
for (const item of sceneAudioRefs) {
|
||||||
const r = await api.invoke(ipcChannels.project.assetFileUrl, { assetId: item.assetId });
|
const r = await api.invoke(ipcChannels.project.assetFileUrl, { assetId: item.assetId });
|
||||||
if (audioLoadRunRef.current !== runId) return;
|
if (sceneAudioLoadRunRef.current !== runId) return;
|
||||||
if (!r.url) continue;
|
if (!r.url) continue;
|
||||||
const el = new Audio(r.url);
|
const el = new Audio(r.url);
|
||||||
el.loop = item.loop;
|
el.loop = item.loop;
|
||||||
el.preload = 'auto';
|
el.preload = 'auto';
|
||||||
el.volume = item.autoplay ? 0 : 1;
|
el.volume = item.autoplay ? 0 : 1;
|
||||||
audioMetaRef.current.set(item.assetId, { lastPlayError: null });
|
sceneAudioMetaRef.current.set(item.assetId, { lastPlayError: null });
|
||||||
el.addEventListener('play', () => setAudioStateTick((x) => x + 1));
|
el.addEventListener('play', () => setSceneAudioStateTick((x) => x + 1));
|
||||||
el.addEventListener('pause', () => setAudioStateTick((x) => x + 1));
|
el.addEventListener('pause', () => setSceneAudioStateTick((x) => x + 1));
|
||||||
el.addEventListener('ended', () => setAudioStateTick((x) => x + 1));
|
el.addEventListener('ended', () => setSceneAudioStateTick((x) => x + 1));
|
||||||
el.addEventListener('canplay', () => setAudioStateTick((x) => x + 1));
|
el.addEventListener('canplay', () => setSceneAudioStateTick((x) => x + 1));
|
||||||
el.addEventListener('error', () => setAudioStateTick((x) => x + 1));
|
el.addEventListener('error', () => setSceneAudioStateTick((x) => x + 1));
|
||||||
loaded.push({ ref: item, el });
|
loaded.push({ ref: item, el });
|
||||||
audioElsRef.current.set(item.assetId, el);
|
sceneAudioElsRef.current.set(item.assetId, el);
|
||||||
}
|
}
|
||||||
setAudioStateTick((x) => x + 1);
|
setSceneAudioStateTick((x) => x + 1);
|
||||||
for (const { ref, el } of loaded) {
|
for (const { ref, el } of loaded) {
|
||||||
if (audioLoadRunRef.current !== runId) {
|
if (sceneAudioLoadRunRef.current !== runId) {
|
||||||
try {
|
try {
|
||||||
el.pause();
|
el.pause();
|
||||||
el.currentTime = 0;
|
el.currentTime = 0;
|
||||||
@@ -244,13 +275,13 @@ export function ControlApp() {
|
|||||||
try {
|
try {
|
||||||
await el.play();
|
await el.play();
|
||||||
} catch {
|
} catch {
|
||||||
const m = audioMetaRef.current.get(ref.assetId) ?? { lastPlayError: null };
|
const m = sceneAudioMetaRef.current.get(ref.assetId) ?? { lastPlayError: null };
|
||||||
audioMetaRef.current.set(ref.assetId, {
|
sceneAudioMetaRef.current.set(ref.assetId, {
|
||||||
...m,
|
...m,
|
||||||
lastPlayError:
|
lastPlayError:
|
||||||
'Автозапуск заблокирован (нужно действие пользователя) или ошибка воспроизведения.',
|
'Автозапуск заблокирован (нужно действие пользователя) или ошибка воспроизведения.',
|
||||||
});
|
});
|
||||||
setAudioStateTick((x) => x + 1);
|
setSceneAudioStateTick((x) => x + 1);
|
||||||
try {
|
try {
|
||||||
el.volume = 1;
|
el.volume = 1;
|
||||||
} catch {
|
} catch {
|
||||||
@@ -258,7 +289,7 @@ export function ControlApp() {
|
|||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (audioLoadRunRef.current !== runId || audioUnmountRef.current) {
|
if (sceneAudioLoadRunRef.current !== runId || audioUnmountRef.current) {
|
||||||
try {
|
try {
|
||||||
el.volume = 1;
|
el.volume = 1;
|
||||||
} catch {
|
} catch {
|
||||||
@@ -268,7 +299,7 @@ export function ControlApp() {
|
|||||||
}
|
}
|
||||||
const tIn0 = performance.now();
|
const tIn0 = performance.now();
|
||||||
const tickIn = (now: number): void => {
|
const tickIn = (now: number): void => {
|
||||||
if (audioLoadRunRef.current !== runId || audioUnmountRef.current) {
|
if (sceneAudioLoadRunRef.current !== runId || audioUnmountRef.current) {
|
||||||
try {
|
try {
|
||||||
el.volume = 1;
|
el.volume = 1;
|
||||||
} catch {
|
} catch {
|
||||||
@@ -294,19 +325,203 @@ export function ControlApp() {
|
|||||||
};
|
};
|
||||||
}, [api, currentScene, project, sceneAudioRefs]);
|
}, [api, currentScene, project, sceneAudioRefs]);
|
||||||
|
|
||||||
|
// Campaign elements: lifecycle depends only on campaign track list/config, not scene or allowCampaignAudio
|
||||||
|
// (scene music uses allowCampaignAudioRef + separate pause/resume effect).
|
||||||
|
// Spec is encoded in campaignAudioSpecKey; campaignAudioRefs is intentionally omitted to avoid scene-switch churn.
|
||||||
|
useEffect(() => {
|
||||||
|
campaignAudioLoadRunRef.current += 1;
|
||||||
|
const runId = campaignAudioLoadRunRef.current;
|
||||||
|
|
||||||
|
const oldEls = new Map(campaignAudioElsRef.current);
|
||||||
|
campaignAudioElsRef.current = new Map();
|
||||||
|
campaignAudioMetaRef.current.clear();
|
||||||
|
setCampaignAudioStateTick((x) => x + 1);
|
||||||
|
|
||||||
|
for (const el of oldEls.values()) {
|
||||||
|
try {
|
||||||
|
el.pause();
|
||||||
|
el.volume = 1;
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (campaignAudioSpecKey === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
const loaded: { ref: (typeof campaignAudioRefs)[number]; el: HTMLAudioElement }[] = [];
|
||||||
|
for (const item of campaignAudioRefs) {
|
||||||
|
const r = await api.invoke(ipcChannels.project.assetFileUrl, { assetId: item.assetId });
|
||||||
|
if (campaignAudioLoadRunRef.current !== runId) return;
|
||||||
|
if (!r.url) continue;
|
||||||
|
const el = new Audio(r.url);
|
||||||
|
el.loop = item.loop;
|
||||||
|
el.preload = 'auto';
|
||||||
|
el.volume = item.autoplay ? 0 : 1;
|
||||||
|
campaignAudioMetaRef.current.set(item.assetId, { lastPlayError: null });
|
||||||
|
el.addEventListener('play', () => setCampaignAudioStateTick((x) => x + 1));
|
||||||
|
el.addEventListener('pause', () => setCampaignAudioStateTick((x) => x + 1));
|
||||||
|
el.addEventListener('ended', () => setCampaignAudioStateTick((x) => x + 1));
|
||||||
|
el.addEventListener('canplay', () => setCampaignAudioStateTick((x) => x + 1));
|
||||||
|
el.addEventListener('error', () => setCampaignAudioStateTick((x) => x + 1));
|
||||||
|
loaded.push({ ref: item, el });
|
||||||
|
campaignAudioElsRef.current.set(item.assetId, el);
|
||||||
|
}
|
||||||
|
setCampaignAudioStateTick((x) => x + 1);
|
||||||
|
|
||||||
|
if (!allowCampaignAudioRef.current) return;
|
||||||
|
|
||||||
|
for (const { ref, el } of loaded) {
|
||||||
|
if (campaignAudioLoadRunRef.current !== runId) return;
|
||||||
|
if (!ref.autoplay) continue;
|
||||||
|
try {
|
||||||
|
await el.play();
|
||||||
|
} catch {
|
||||||
|
const m = campaignAudioMetaRef.current.get(ref.assetId) ?? { lastPlayError: null };
|
||||||
|
campaignAudioMetaRef.current.set(ref.assetId, {
|
||||||
|
...m,
|
||||||
|
lastPlayError:
|
||||||
|
'Автозапуск заблокирован (нужно действие пользователя) или ошибка воспроизведения.',
|
||||||
|
});
|
||||||
|
setCampaignAudioStateTick((x) => x + 1);
|
||||||
|
try {
|
||||||
|
el.volume = 1;
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (campaignAudioLoadRunRef.current !== runId || audioUnmountRef.current) {
|
||||||
|
try {
|
||||||
|
el.volume = 1;
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const tIn0 = performance.now();
|
||||||
|
const tickIn = (now: number): void => {
|
||||||
|
if (campaignAudioLoadRunRef.current !== runId || audioUnmountRef.current) {
|
||||||
|
try {
|
||||||
|
el.volume = 1;
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const u = Math.min(1, (now - tIn0) / 550);
|
||||||
|
try {
|
||||||
|
el.volume = u;
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
if (u < 1) window.requestAnimationFrame(tickIn);
|
||||||
|
};
|
||||||
|
window.requestAnimationFrame(tickIn);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
// Deps: api + campaignAudioSpecKey only; list iteration uses current campaignAudioRefs (stable while spec is stable).
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [api, campaignAudioSpecKey]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (allowCampaignAudio) {
|
||||||
|
const snap = campaignResumeAfterSceneRef.current;
|
||||||
|
campaignResumeAfterSceneRef.current = null;
|
||||||
|
if (snap && snap.size > 0) {
|
||||||
|
void (async () => {
|
||||||
|
for (const [assetId, wasPlaying] of snap) {
|
||||||
|
if (!wasPlaying) continue;
|
||||||
|
const el = campaignAudioElsRef.current.get(assetId) ?? null;
|
||||||
|
if (!el) continue;
|
||||||
|
try {
|
||||||
|
// If a track was created with autoplay volume ramp but never started yet,
|
||||||
|
// ensure it is audible on resume.
|
||||||
|
if (el.volume === 0) el.volume = 1;
|
||||||
|
await el.play();
|
||||||
|
} catch {
|
||||||
|
// ignore; user can press play
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setCampaignAudioStateTick((x) => x + 1);
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
// If we entered a scene that allows campaign audio and there was no "resume snapshot"
|
||||||
|
// (e.g. first scene had autoplay scene music so campaign autoplay was blocked),
|
||||||
|
// start campaign tracks that have autoplay enabled.
|
||||||
|
if (!snap || snap.size === 0) {
|
||||||
|
void (async () => {
|
||||||
|
for (const ref of campaignAudioRefs) {
|
||||||
|
if (!ref.autoplay) continue;
|
||||||
|
const el = campaignAudioElsRef.current.get(ref.assetId) ?? null;
|
||||||
|
if (!el) continue;
|
||||||
|
if (!el.paused) continue;
|
||||||
|
try {
|
||||||
|
el.volume = 0;
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await el.play();
|
||||||
|
} catch {
|
||||||
|
// ignore; user can press play
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const tIn0 = performance.now();
|
||||||
|
const tickIn = (now: number): void => {
|
||||||
|
if (!allowCampaignAudioRef.current || audioUnmountRef.current) return;
|
||||||
|
const u = Math.min(1, (now - tIn0) / 550);
|
||||||
|
try {
|
||||||
|
el.volume = u;
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
if (u < 1) window.requestAnimationFrame(tickIn);
|
||||||
|
};
|
||||||
|
window.requestAnimationFrame(tickIn);
|
||||||
|
}
|
||||||
|
setCampaignAudioStateTick((x) => x + 1);
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Scene has its own audio: remember what was playing, then pause campaign. (keep currentTime)
|
||||||
|
const snap = new Map<string, boolean>();
|
||||||
|
for (const [assetId, el] of campaignAudioElsRef.current) {
|
||||||
|
snap.set(assetId, !el.paused);
|
||||||
|
}
|
||||||
|
campaignResumeAfterSceneRef.current = snap;
|
||||||
|
for (const el of campaignAudioElsRef.current.values()) {
|
||||||
|
try {
|
||||||
|
el.pause();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setCampaignAudioStateTick((x) => x + 1);
|
||||||
|
// Intentionally not depending on campaignAudioRefs: this effect is about scene-driven pausing/resuming.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [allowCampaignAudio]);
|
||||||
|
|
||||||
const anyPlaying = useMemo(() => {
|
const anyPlaying = useMemo(() => {
|
||||||
for (const el of audioElsRef.current.values()) {
|
for (const el of sceneAudioElsRef.current.values()) {
|
||||||
|
if (!el.paused) return true;
|
||||||
|
}
|
||||||
|
for (const el of campaignAudioElsRef.current.values()) {
|
||||||
if (!el.paused) return true;
|
if (!el.paused) return true;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [audioStateTick]);
|
}, [campaignAudioStateTick, sceneAudioStateTick]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!anyPlaying) return;
|
if (!anyPlaying) return;
|
||||||
let raf = 0;
|
let raf = 0;
|
||||||
const tick = () => {
|
const tick = () => {
|
||||||
setAudioStateTick((x) => x + 1);
|
setSceneAudioStateTick((x) => x + 1);
|
||||||
|
setCampaignAudioStateTick((x) => x + 1);
|
||||||
raf = window.requestAnimationFrame(tick);
|
raf = window.requestAnimationFrame(tick);
|
||||||
};
|
};
|
||||||
raf = window.requestAnimationFrame(tick);
|
raf = window.requestAnimationFrame(tick);
|
||||||
@@ -326,10 +541,16 @@ export function ControlApp() {
|
|||||||
return () => ro.disconnect();
|
return () => ro.disconnect();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
function audioStatus(assetId: string): { label: string; detail?: string } {
|
function audioStatus(group: 'scene' | 'campaign', assetId: string): { label: string; detail?: string } {
|
||||||
const el = audioElsRef.current.get(assetId) ?? null;
|
const el =
|
||||||
|
group === 'scene'
|
||||||
|
? (sceneAudioElsRef.current.get(assetId) ?? null)
|
||||||
|
: (campaignAudioElsRef.current.get(assetId) ?? null);
|
||||||
if (!el) return { label: 'URL не получен', detail: 'Не удалось получить dnd://asset URL для аудио.' };
|
if (!el) return { label: 'URL не получен', detail: 'Не удалось получить dnd://asset URL для аудио.' };
|
||||||
const meta = audioMetaRef.current.get(assetId) ?? { lastPlayError: null };
|
const meta =
|
||||||
|
group === 'scene'
|
||||||
|
? (sceneAudioMetaRef.current.get(assetId) ?? { lastPlayError: null })
|
||||||
|
: (campaignAudioMetaRef.current.get(assetId) ?? { lastPlayError: null });
|
||||||
if (meta.lastPlayError) return { label: 'Ошибка/блок', detail: meta.lastPlayError };
|
if (meta.lastPlayError) return { label: 'Ошибка/блок', detail: meta.lastPlayError };
|
||||||
if (el.error)
|
if (el.error)
|
||||||
return {
|
return {
|
||||||
@@ -1075,13 +1296,16 @@ export function ControlApp() {
|
|||||||
<div className={styles.previewTitle}>Музыка</div>
|
<div className={styles.previewTitle}>Музыка</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.spacer10} />
|
<div className={styles.spacer10} />
|
||||||
|
<div className={styles.sectionLabel}>МУЗЫКА СЦЕНЫ</div>
|
||||||
|
<div className={styles.spacer10} />
|
||||||
{sceneAudios.length === 0 ? (
|
{sceneAudios.length === 0 ? (
|
||||||
<div className={styles.musicEmpty}>В текущей сцене нет аудио.</div>
|
<div className={styles.musicEmpty}>В текущей сцене нет аудио.</div>
|
||||||
) : (
|
) : null}
|
||||||
|
{sceneAudios.length > 0 ? (
|
||||||
<div className={styles.audioList}>
|
<div className={styles.audioList}>
|
||||||
{sceneAudios.map(({ ref, asset }) => {
|
{sceneAudios.map(({ ref, asset }) => {
|
||||||
const el = audioElsRef.current.get(ref.assetId) ?? null;
|
const el = sceneAudioElsRef.current.get(ref.assetId) ?? null;
|
||||||
const st = audioStatus(ref.assetId);
|
const st = audioStatus('scene', ref.assetId);
|
||||||
const dur = el?.duration && Number.isFinite(el.duration) ? el.duration : 0;
|
const dur = el?.duration && Number.isFinite(el.duration) ? el.duration : 0;
|
||||||
const cur = el?.currentTime && Number.isFinite(el.currentTime) ? el.currentTime : 0;
|
const cur = el?.currentTime && Number.isFinite(el.currentTime) ? el.currentTime : 0;
|
||||||
const pct = dur > 0 ? Math.max(0, Math.min(1, cur / dur)) : 0;
|
const pct = dur > 0 ? Math.max(0, Math.min(1, cur / dur)) : 0;
|
||||||
@@ -1106,7 +1330,7 @@ export function ControlApp() {
|
|||||||
if (!dur) return;
|
if (!dur) return;
|
||||||
if (e.key === 'ArrowLeft') el.currentTime = Math.max(0, el.currentTime - 5);
|
if (e.key === 'ArrowLeft') el.currentTime = Math.max(0, el.currentTime - 5);
|
||||||
if (e.key === 'ArrowRight') el.currentTime = Math.min(dur, el.currentTime + 5);
|
if (e.key === 'ArrowRight') el.currentTime = Math.min(dur, el.currentTime + 5);
|
||||||
setAudioStateTick((x) => x + 1);
|
setSceneAudioStateTick((x) => x + 1);
|
||||||
}}
|
}}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
@@ -1114,7 +1338,7 @@ export function ControlApp() {
|
|||||||
const rect = (e.currentTarget as HTMLDivElement).getBoundingClientRect();
|
const rect = (e.currentTarget as HTMLDivElement).getBoundingClientRect();
|
||||||
const next = (e.clientX - rect.left) / rect.width;
|
const next = (e.clientX - rect.left) / rect.width;
|
||||||
el.currentTime = Math.max(0, Math.min(dur, next * dur));
|
el.currentTime = Math.max(0, Math.min(dur, next * dur));
|
||||||
setAudioStateTick((x) => x + 1);
|
setSceneAudioStateTick((x) => x + 1);
|
||||||
}}
|
}}
|
||||||
className={[
|
className={[
|
||||||
styles.audioScrub,
|
styles.audioScrub,
|
||||||
@@ -1137,15 +1361,17 @@ export function ControlApp() {
|
|||||||
variant="primary"
|
variant="primary"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
const m = audioMetaRef.current.get(ref.assetId) ?? { lastPlayError: null };
|
const m = sceneAudioMetaRef.current.get(ref.assetId) ?? { lastPlayError: null };
|
||||||
audioMetaRef.current.set(ref.assetId, { ...m, lastPlayError: null });
|
sceneAudioMetaRef.current.set(ref.assetId, { ...m, lastPlayError: null });
|
||||||
void el.play().catch(() => {
|
void el.play().catch(() => {
|
||||||
const mm = audioMetaRef.current.get(ref.assetId) ?? { lastPlayError: null };
|
const mm =
|
||||||
audioMetaRef.current.set(ref.assetId, {
|
sceneAudioMetaRef.current.get(ref.assetId) ??
|
||||||
|
({ lastPlayError: null } as const);
|
||||||
|
sceneAudioMetaRef.current.set(ref.assetId, {
|
||||||
...mm,
|
...mm,
|
||||||
lastPlayError: 'Не удалось запустить.',
|
lastPlayError: 'Не удалось запустить.',
|
||||||
});
|
});
|
||||||
setAudioStateTick((x) => x + 1);
|
setSceneAudioStateTick((x) => x + 1);
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -1164,7 +1390,122 @@ export function ControlApp() {
|
|||||||
if (!el) return;
|
if (!el) return;
|
||||||
el.pause();
|
el.pause();
|
||||||
el.currentTime = 0;
|
el.currentTime = 0;
|
||||||
setAudioStateTick((x) => x + 1);
|
setSceneAudioStateTick((x) => x + 1);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
■
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className={styles.spacer12} />
|
||||||
|
<div className={styles.sectionLabel}>МУЗЫКА ИГРЫ</div>
|
||||||
|
<div className={styles.spacer10} />
|
||||||
|
{campaignAudios.length === 0 ? (
|
||||||
|
<div className={styles.musicEmpty}>В игре нет аудио.</div>
|
||||||
|
) : (
|
||||||
|
<div className={styles.audioList}>
|
||||||
|
{campaignAudios.map(({ ref, asset }) => {
|
||||||
|
const el = campaignAudioElsRef.current.get(ref.assetId) ?? null;
|
||||||
|
const st = audioStatus('campaign', ref.assetId);
|
||||||
|
const dur = el?.duration && Number.isFinite(el.duration) ? el.duration : 0;
|
||||||
|
const cur = el?.currentTime && Number.isFinite(el.currentTime) ? el.currentTime : 0;
|
||||||
|
const pct = dur > 0 ? Math.max(0, Math.min(1, cur / dur)) : 0;
|
||||||
|
return (
|
||||||
|
<div key={ref.assetId} className={styles.audioCard}>
|
||||||
|
<div className={styles.audioMeta}>
|
||||||
|
<div className={styles.audioName}>{asset.originalName}</div>
|
||||||
|
<div className={styles.audioBadges}>
|
||||||
|
<div>{ref.autoplay ? 'Авто' : 'Ручн.'}</div>
|
||||||
|
<div>{ref.loop ? 'Цикл' : 'Один раз'}</div>
|
||||||
|
<div title={st.detail}>{st.label}</div>
|
||||||
|
{!allowCampaignAudio ? <div title="В сцене есть музыка">Пауза (сцена)</div> : null}
|
||||||
|
</div>
|
||||||
|
<div className={styles.spacer10} />
|
||||||
|
<div
|
||||||
|
role="slider"
|
||||||
|
aria-valuemin={0}
|
||||||
|
aria-valuemax={dur > 0 ? Math.round(dur) : 0}
|
||||||
|
aria-valuenow={Math.round(cur)}
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (!el) return;
|
||||||
|
if (!dur) return;
|
||||||
|
if (e.key === 'ArrowLeft') el.currentTime = Math.max(0, el.currentTime - 5);
|
||||||
|
if (e.key === 'ArrowRight') el.currentTime = Math.min(dur, el.currentTime + 5);
|
||||||
|
setCampaignAudioStateTick((x) => x + 1);
|
||||||
|
}}
|
||||||
|
onClick={(e) => {
|
||||||
|
if (!el) return;
|
||||||
|
if (!dur) return;
|
||||||
|
const rect = (e.currentTarget as HTMLDivElement).getBoundingClientRect();
|
||||||
|
const next = (e.clientX - rect.left) / rect.width;
|
||||||
|
el.currentTime = Math.max(0, Math.min(dur, next * dur));
|
||||||
|
setCampaignAudioStateTick((x) => x + 1);
|
||||||
|
}}
|
||||||
|
className={[
|
||||||
|
styles.audioScrub,
|
||||||
|
dur > 0 ? styles.audioScrubPointer : styles.audioScrubDefault,
|
||||||
|
].join(' ')}
|
||||||
|
title={dur > 0 ? 'Клик — перемотка' : 'Длительность неизвестна'}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={styles.scrubFill}
|
||||||
|
style={{ width: `${String(Math.round(pct * 100))}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={styles.timeRow}>
|
||||||
|
<div>{formatTime(cur)}</div>
|
||||||
|
<div>{dur ? formatTime(dur) : '—:—'}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.audioTransport}>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
title={!allowCampaignAudio ? 'Пауза: в сцене есть музыка' : undefined}
|
||||||
|
onClick={() => {
|
||||||
|
if (!el) return;
|
||||||
|
const m = campaignAudioMetaRef.current.get(ref.assetId) ?? { lastPlayError: null };
|
||||||
|
campaignAudioMetaRef.current.set(ref.assetId, { ...m, lastPlayError: null });
|
||||||
|
// If this track was created for autoplay but autoplay was blocked (e.g. scene music),
|
||||||
|
// it might still be at volume 0. Ensure manual play is audible.
|
||||||
|
try {
|
||||||
|
if (el.volume === 0) el.volume = 1;
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
void el.play().catch(() => {
|
||||||
|
const mm =
|
||||||
|
campaignAudioMetaRef.current.get(ref.assetId) ??
|
||||||
|
({ lastPlayError: null } as const);
|
||||||
|
campaignAudioMetaRef.current.set(ref.assetId, {
|
||||||
|
...mm,
|
||||||
|
lastPlayError: 'Не удалось запустить.',
|
||||||
|
});
|
||||||
|
setCampaignAudioStateTick((x) => x + 1);
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
▶
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
if (!el) return;
|
||||||
|
el.pause();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
❚❚
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
if (!el) return;
|
||||||
|
el.pause();
|
||||||
|
el.currentTime = 0;
|
||||||
|
setCampaignAudioStateTick((x) => x + 1);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
■
|
■
|
||||||
|
|||||||
@@ -94,3 +94,38 @@ void test('ControlApp: радиус кисти не в блоке предпро
|
|||||||
'Слайдер радиуса должен быть в пульте (файл: выше заголовка предпросмотра)',
|
'Слайдер радиуса должен быть в пульте (файл: выше заголовка предпросмотра)',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
void test('ControlApp: музыка разделена на сцену и кампанию', () => {
|
||||||
|
const src = readControlApp();
|
||||||
|
assert.ok(src.includes('МУЗЫКА СЦЕНЫ'));
|
||||||
|
assert.ok(src.includes('МУЗЫКА ИГРЫ'));
|
||||||
|
// при музыке сцены — кампанию ставим на паузу
|
||||||
|
assert.ok(src.includes('allowCampaignAudio'));
|
||||||
|
assert.ok(
|
||||||
|
src.includes('campaignAudioSpecKey'),
|
||||||
|
'кампания: перезагрузка аудио привязана к списку треков, не к смене сцены',
|
||||||
|
);
|
||||||
|
assert.match(src, /pause campaign\./i);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('ControlApp: загрузка камп. аудио — useEffect зависит только от api и campaignAudioSpecKey', () => {
|
||||||
|
const src = readControlApp();
|
||||||
|
const re = /\/\/ Campaign elements:[\s\S]*?useEffect\(\(\) => \{[\s\S]*?\}\s*,\s*\[([^\]]*)\]\s*\)\s*;/;
|
||||||
|
const m = re.exec(src);
|
||||||
|
assert.ok(m, 'ожидается useEffect загрузки кампании после комментария Campaign elements');
|
||||||
|
const depList = m[1];
|
||||||
|
assert.ok(depList !== undefined);
|
||||||
|
const deps = depList
|
||||||
|
.split(',')
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
assert.deepEqual(
|
||||||
|
deps,
|
||||||
|
['api', 'campaignAudioSpecKey'],
|
||||||
|
'смена сцены / allowCampaignAudio / campaignAudioRefs не должны перезапускать загрузку кампании',
|
||||||
|
);
|
||||||
|
assert.ok(!/\ballowCampaignAudio\b/.test(depList));
|
||||||
|
assert.ok(!/\bcurrentScene\b/.test(depList));
|
||||||
|
assert.ok(!/\bproject\b/.test(depList));
|
||||||
|
assert.ok(!/\bcampaignAudioRefs\b/.test(depList));
|
||||||
|
});
|
||||||
|
|||||||
@@ -100,6 +100,10 @@
|
|||||||
height: 14px;
|
height: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.spacer18 {
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
.sidebarScroll {
|
.sidebarScroll {
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
padding-right: 2px;
|
padding-right: 2px;
|
||||||
@@ -408,7 +412,7 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: var(--color-overlay-dark-3);
|
background: var(--color-overlay-dark-3);
|
||||||
aspect-ratio: 16 / 9;
|
aspect-ratio: 16 / 9;
|
||||||
max-height: 140px;
|
width: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { createPortal } from 'react-dom';
|
|||||||
import { ipcChannels } from '../../shared/ipc/contracts';
|
import { ipcChannels } from '../../shared/ipc/contracts';
|
||||||
import { EULA_CURRENT_VERSION } from '../../shared/license/eulaVersion';
|
import { EULA_CURRENT_VERSION } from '../../shared/license/eulaVersion';
|
||||||
import type { LicenseSnapshot } from '../../shared/license/licenseSnapshot';
|
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 { AppLogo } from '../shared/branding/AppLogo';
|
||||||
import { getDndApi } from '../shared/dndApi';
|
import { getDndApi } from '../shared/dndApi';
|
||||||
import { RotatedImage } from '../shared/RotatedImage';
|
import { RotatedImage } from '../shared/RotatedImage';
|
||||||
@@ -13,7 +13,8 @@ import { LayoutShell } from '../shared/ui/LayoutShell';
|
|||||||
import { useAssetUrl } from '../shared/useAssetImageUrl';
|
import { useAssetUrl } from '../shared/useAssetImageUrl';
|
||||||
|
|
||||||
import styles from './EditorApp.module.css';
|
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 { EulaModal, LicenseAboutModal, LicenseTokenModal } from './license/EditorLicenseModals';
|
||||||
import { useProjectState } from './state/projectState';
|
import { useProjectState } from './state/projectState';
|
||||||
|
|
||||||
@@ -27,6 +28,33 @@ type SceneCard = {
|
|||||||
previewRotationDeg: 0 | 90 | 180 | 270;
|
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() {
|
export function EditorApp() {
|
||||||
const [appVersionText, setAppVersionText] = useState<string | null>(null);
|
const [appVersionText, setAppVersionText] = useState<string | null>(null);
|
||||||
const [query, setQuery] = useState('');
|
const [query, setQuery] = useState('');
|
||||||
@@ -42,6 +70,7 @@ export function EditorApp() {
|
|||||||
const [openKeyAfterEula, setOpenKeyAfterEula] = useState(false);
|
const [openKeyAfterEula, setOpenKeyAfterEula] = useState(false);
|
||||||
const licenseActive = licenseSnap?.active === true;
|
const licenseActive = licenseSnap?.active === true;
|
||||||
const [state, actions] = useProjectState(licenseActive);
|
const [state, actions] = useProjectState(licenseActive);
|
||||||
|
const sceneCardById = useStableSceneCardById(state.project);
|
||||||
const fileMenuBtnRef = useRef<HTMLButtonElement | null>(null);
|
const fileMenuBtnRef = useRef<HTMLButtonElement | null>(null);
|
||||||
const projectMenuBtnRef = useRef<HTMLButtonElement | null>(null);
|
const projectMenuBtnRef = useRef<HTMLButtonElement | null>(null);
|
||||||
const settingsMenuBtnRef = useRef<HTMLButtonElement | null>(null);
|
const settingsMenuBtnRef = useRef<HTMLButtonElement | null>(null);
|
||||||
@@ -51,15 +80,23 @@ export function EditorApp() {
|
|||||||
const scenes = useMemo<SceneCard[]>(() => {
|
const scenes = useMemo<SceneCard[]>(() => {
|
||||||
const p = state.project;
|
const p = state.project;
|
||||||
if (!p) return [];
|
if (!p) return [];
|
||||||
return Object.values(p.scenes).map((s) => ({
|
const createdAtSortKey = (sceneId: string): number => {
|
||||||
id: s.id,
|
// sceneId создаётся как `${prefix}_${rand}_${Date.now().toString(16)}`
|
||||||
title: s.title,
|
const last = sceneId.split('_').at(-1) ?? '';
|
||||||
active: s.id === state.selectedSceneId,
|
const n = Number.parseInt(last, 16);
|
||||||
previewAssetId: s.previewAssetId,
|
return Number.isFinite(n) ? n : 0;
|
||||||
previewAssetType: s.previewAssetType,
|
};
|
||||||
previewVideoAutostart: s.previewVideoAutostart,
|
return Object.values(p.scenes)
|
||||||
previewRotationDeg: s.previewRotationDeg,
|
.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]);
|
}, [state.project, state.selectedSceneId]);
|
||||||
|
|
||||||
const filtered = useMemo(
|
const filtered = useMemo(
|
||||||
@@ -86,6 +123,16 @@ export function EditorApp() {
|
|||||||
return scene.media.audios;
|
return scene.media.audios;
|
||||||
}, [state.project, state.selectedSceneId]);
|
}, [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 graphStartSceneId = useMemo(() => {
|
||||||
const p = state.project;
|
const p = state.project;
|
||||||
if (!p) return null;
|
if (!p) return null;
|
||||||
@@ -354,7 +401,7 @@ export function EditorApp() {
|
|||||||
<SceneGraph
|
<SceneGraph
|
||||||
sceneGraphNodes={state.project.sceneGraphNodes}
|
sceneGraphNodes={state.project.sceneGraphNodes}
|
||||||
sceneGraphEdges={state.project.sceneGraphEdges}
|
sceneGraphEdges={state.project.sceneGraphEdges}
|
||||||
sceneById={state.project.scenes}
|
sceneCardById={sceneCardById}
|
||||||
currentSceneId={state.selectedSceneId}
|
currentSceneId={state.selectedSceneId}
|
||||||
onCurrentSceneChange={(id) => void actions.selectScene(id)}
|
onCurrentSceneChange={(id) => void actions.selectScene(id)}
|
||||||
onConnect={(sourceGn, targetGn) => void actions.addSceneGraphEdge(sourceGn, targetGn)}
|
onConnect={(sourceGn, targetGn) => void actions.addSceneGraphEdge(sourceGn, targetGn)}
|
||||||
@@ -376,40 +423,66 @@ export function EditorApp() {
|
|||||||
}
|
}
|
||||||
right={
|
right={
|
||||||
<div className={styles.editorInspector}>
|
<div className={styles.editorInspector}>
|
||||||
<div className={styles.inspectorTitle}>Свойства сцены</div>
|
|
||||||
<div className={styles.inspectorScroll}>
|
<div className={styles.inspectorScroll}>
|
||||||
{state.project && state.selectedSceneId ? (
|
{state.project ? (
|
||||||
(() => {
|
<>
|
||||||
const proj = state.project;
|
<div className={styles.inspectorTitle}>Свойства игры</div>
|
||||||
const sid = state.selectedSceneId;
|
<CampaignInspector
|
||||||
const sc = proj.scenes[sid];
|
audioRefs={campaignAudioRefs}
|
||||||
return (
|
mediaAssets={campaignAudioAssets}
|
||||||
<SceneInspector
|
onAudioRefsChange={(next) => void actions.updateCampaignAudios(next)}
|
||||||
title={sc?.title ?? ''}
|
onUploadAudio={() => {
|
||||||
description={sc?.description ?? ''}
|
void (async () => {
|
||||||
previewAssetId={sc?.previewAssetId ?? null}
|
try {
|
||||||
previewAssetType={sc?.previewAssetType ?? null}
|
await actions.importCampaignAudio();
|
||||||
previewVideoAutostart={sc?.previewVideoAutostart ?? false}
|
} catch (e) {
|
||||||
previewRotationDeg={sc?.previewRotationDeg ?? 0}
|
window.alert(e instanceof Error ? e.message : String(e));
|
||||||
mediaAssets={sceneMediaAssets}
|
}
|
||||||
audioRefs={sceneAudioRefs}
|
})();
|
||||||
onAudioRefsChange={(next) => void actions.updateScene(sid, { media: { audios: next } })}
|
}}
|
||||||
onPreviewVideoAutostartChange={(next) =>
|
/>
|
||||||
void actions.updateScene(sid, { previewVideoAutostart: next })
|
<div className={styles.spacer18} />
|
||||||
}
|
<div className={styles.inspectorTitle}>Свойства сцены</div>
|
||||||
onTitleChange={(title) => void actions.updateScene(sid, { title })}
|
{state.selectedSceneId ? (
|
||||||
onDescriptionChange={(description) => void actions.updateScene(sid, { description })}
|
(() => {
|
||||||
onImportPreview={() => void actions.importScenePreview(sid)}
|
const proj = state.project;
|
||||||
onClearPreview={() => void actions.clearScenePreview(sid)}
|
const sid = state.selectedSceneId;
|
||||||
onRotatePreview={(previewRotationDeg) =>
|
const sc = proj.scenes[sid];
|
||||||
void actions.updateScene(sid, { previewRotationDeg })
|
return (
|
||||||
}
|
<SceneInspector
|
||||||
onUploadMedia={() => void actions.importMediaToScene(sid)}
|
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>
|
||||||
</div>
|
</div>
|
||||||
@@ -955,6 +1028,92 @@ type SceneInspectorProps = {
|
|||||||
onUploadMedia: () => void;
|
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({
|
function SceneInspector({
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
@@ -988,7 +1147,7 @@ function SceneInspector({
|
|||||||
/>
|
/>
|
||||||
<div className={styles.spacer6} />
|
<div className={styles.spacer6} />
|
||||||
<div className={styles.labelSm}>ПРЕВЬЮ СЦЕНЫ</div>
|
<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}>
|
<div className={styles.previewBox}>
|
||||||
{previewUrl && previewAssetType === 'image' ? (
|
{previewUrl && previewAssetType === 'image' ? (
|
||||||
<RotatedImage url={previewUrl} rotationDeg={previewRotationDeg} mode="cover" />
|
<RotatedImage url={previewUrl} rotationDeg={previewRotationDeg} mode="cover" />
|
||||||
@@ -1159,7 +1318,13 @@ function SceneListCard({ scene, onSelect, onDeleteScene }: SceneListCardProps) {
|
|||||||
<div className={url ? styles.sceneThumb : styles.sceneThumbEmpty}>
|
<div className={url ? styles.sceneThumb : styles.sceneThumbEmpty}>
|
||||||
{url && scene.previewAssetType === 'image' ? (
|
{url && scene.previewAssetType === 'image' ? (
|
||||||
<div className={styles.sceneThumbInner}>
|
<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>
|
</div>
|
||||||
) : url && scene.previewAssetType === 'video' ? (
|
) : url && scene.previewAssetType === 'video' ? (
|
||||||
<div className={styles.sceneThumbInner}>
|
<div className={styles.sceneThumbInner}>
|
||||||
|
|||||||
@@ -19,19 +19,29 @@ import ReactFlow, {
|
|||||||
import 'reactflow/dist/style.css';
|
import 'reactflow/dist/style.css';
|
||||||
|
|
||||||
import { isSceneGraphEdgeRejected } from '../../../shared/graph/sceneGraphEdgeRules';
|
import { isSceneGraphEdgeRejected } from '../../../shared/graph/sceneGraphEdgeRules';
|
||||||
import type {
|
import type { AssetId, GraphNodeId, SceneGraphEdge, SceneGraphNode, SceneId } from '../../../shared/types';
|
||||||
AssetId,
|
|
||||||
GraphNodeId,
|
|
||||||
Scene,
|
|
||||||
SceneGraphEdge,
|
|
||||||
SceneGraphNode,
|
|
||||||
SceneId,
|
|
||||||
} from '../../../shared/types';
|
|
||||||
import { RotatedImage } from '../../shared/RotatedImage';
|
import { RotatedImage } from '../../shared/RotatedImage';
|
||||||
import { useAssetUrl } from '../../shared/useAssetImageUrl';
|
import { useAssetUrl } from '../../shared/useAssetImageUrl';
|
||||||
|
|
||||||
import styles from './SceneGraph.module.css';
|
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). */
|
/** MIME для перетаскивания сцены из списка на граф (см. EditorApp). */
|
||||||
export const DND_SCENE_ID_MIME = 'application/x-dnd-scene-id';
|
export const DND_SCENE_ID_MIME = 'application/x-dnd-scene-id';
|
||||||
|
|
||||||
@@ -42,7 +52,7 @@ const SCENE_CARD_H = 248;
|
|||||||
export type SceneGraphProps = {
|
export type SceneGraphProps = {
|
||||||
sceneGraphNodes: SceneGraphNode[];
|
sceneGraphNodes: SceneGraphNode[];
|
||||||
sceneGraphEdges: SceneGraphEdge[];
|
sceneGraphEdges: SceneGraphEdge[];
|
||||||
sceneById: Record<SceneId, Scene>;
|
sceneCardById: Record<SceneId, SceneGraphSceneCard>;
|
||||||
currentSceneId: SceneId | null;
|
currentSceneId: SceneId | null;
|
||||||
onCurrentSceneChange: (id: SceneId) => void;
|
onCurrentSceneChange: (id: SceneId) => void;
|
||||||
onConnect: (sourceGraphNodeId: GraphNodeId, targetGraphNodeId: GraphNodeId) => void;
|
onConnect: (sourceGraphNodeId: GraphNodeId, targetGraphNodeId: GraphNodeId) => void;
|
||||||
@@ -132,12 +142,21 @@ function SceneCardNode({ data }: NodeProps<SceneCardData>) {
|
|||||||
{url && data.previewAssetType === 'image' ? (
|
{url && data.previewAssetType === 'image' ? (
|
||||||
<div className={styles.previewFill}>
|
<div className={styles.previewFill}>
|
||||||
{data.previewRotationDeg === 0 ? (
|
{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
|
<RotatedImage
|
||||||
url={url}
|
url={url}
|
||||||
rotationDeg={data.previewRotationDeg}
|
rotationDeg={data.previewRotationDeg}
|
||||||
mode="cover"
|
mode="cover"
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
style={{ width: '100%', height: '100%' }}
|
style={{ width: '100%', height: '100%' }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -261,7 +280,7 @@ function GraphZoomToolbar() {
|
|||||||
function SceneGraphCanvas({
|
function SceneGraphCanvas({
|
||||||
sceneGraphNodes,
|
sceneGraphNodes,
|
||||||
sceneGraphEdges,
|
sceneGraphEdges,
|
||||||
sceneById,
|
sceneCardById,
|
||||||
currentSceneId,
|
currentSceneId,
|
||||||
onCurrentSceneChange,
|
onCurrentSceneChange,
|
||||||
onConnect,
|
onConnect,
|
||||||
@@ -291,33 +310,33 @@ function SceneGraphCanvas({
|
|||||||
|
|
||||||
const desiredNodes = useMemo<Node<SceneCardData>[]>(() => {
|
const desiredNodes = useMemo<Node<SceneCardData>[]>(() => {
|
||||||
return sceneGraphNodes.map((gn) => {
|
return sceneGraphNodes.map((gn) => {
|
||||||
const s = sceneById[gn.sceneId];
|
const c = sceneCardById[gn.sceneId];
|
||||||
const active = gn.sceneId === currentSceneId;
|
const active = gn.sceneId === currentSceneId;
|
||||||
const audios = s?.media.audios ?? [];
|
const audios = c?.audios ?? [];
|
||||||
return {
|
return {
|
||||||
id: gn.id,
|
id: gn.id,
|
||||||
type: 'sceneCard',
|
type: 'sceneCard',
|
||||||
position: { x: gn.x, y: gn.y },
|
position: { x: gn.x, y: gn.y },
|
||||||
data: {
|
data: {
|
||||||
sceneId: gn.sceneId,
|
sceneId: gn.sceneId,
|
||||||
title: s?.title ?? '',
|
title: c?.title ?? '',
|
||||||
active,
|
active,
|
||||||
previewAssetId: s?.previewAssetId ?? null,
|
previewAssetId: c?.previewAssetId ?? null,
|
||||||
previewAssetType: s?.previewAssetType ?? null,
|
previewAssetType: c?.previewAssetType ?? null,
|
||||||
previewVideoAutostart: s?.previewVideoAutostart ?? false,
|
previewVideoAutostart: c?.previewVideoAutostart ?? false,
|
||||||
previewRotationDeg: s?.previewRotationDeg ?? 0,
|
previewRotationDeg: c?.previewRotationDeg ?? 0,
|
||||||
isStartScene: gn.isStartScene,
|
isStartScene: gn.isStartScene,
|
||||||
hasSceneAudio: audios.length >= 1,
|
hasSceneAudio: audios.length >= 1,
|
||||||
previewIsVideo: s?.previewAssetType === 'video',
|
previewIsVideo: c?.previewAssetType === 'video',
|
||||||
hasAnyAudioLoop: audios.some((a) => a.loop),
|
hasAnyAudioLoop: audios.some((a) => a.loop),
|
||||||
hasAnyAudioAutoplay: audios.some((a) => a.autoplay),
|
hasAnyAudioAutoplay: audios.some((a) => a.autoplay),
|
||||||
showPreviewVideoAutostart: s?.previewAssetType === 'video' ? s.previewVideoAutostart : false,
|
showPreviewVideoAutostart: c?.previewAssetType === 'video' ? c.previewVideoAutostart : false,
|
||||||
showPreviewVideoLoop: s?.previewAssetType === 'video' ? s.settings.loopVideo : false,
|
showPreviewVideoLoop: c?.previewAssetType === 'video' ? c.loopVideo : false,
|
||||||
},
|
},
|
||||||
style: { padding: 0, background: 'transparent', border: 'none' },
|
style: { padding: 0, background: 'transparent', border: 'none' },
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}, [currentSceneId, sceneById, sceneGraphNodes]);
|
}, [currentSceneId, sceneCardById, sceneGraphNodes]);
|
||||||
|
|
||||||
const desiredEdges = useMemo<Edge[]>(() => {
|
const desiredEdges = useMemo<Edge[]>(() => {
|
||||||
return sceneGraphEdges.map((e) => ({
|
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>;
|
closeProject: () => Promise<void>;
|
||||||
createScene: () => Promise<void>;
|
createScene: () => Promise<void>;
|
||||||
selectScene: (id: SceneId) => Promise<void>;
|
selectScene: (id: SceneId) => Promise<void>;
|
||||||
|
importCampaignAudio: () => Promise<void>;
|
||||||
|
updateCampaignAudios: (next: Project['campaignAudios']) => Promise<void>;
|
||||||
updateScene: (
|
updateScene: (
|
||||||
sceneId: SceneId,
|
sceneId: SceneId,
|
||||||
patch: {
|
patch: {
|
||||||
@@ -129,6 +131,22 @@ export function useProjectState(licenseActive: boolean): readonly [State, Action
|
|||||||
await api.invoke(ipcChannels.project.setCurrentScene, { sceneId: id });
|
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 (
|
const updateScene = async (
|
||||||
sceneId: SceneId,
|
sceneId: SceneId,
|
||||||
patch: {
|
patch: {
|
||||||
@@ -299,6 +317,8 @@ export function useProjectState(licenseActive: boolean): readonly [State, Action
|
|||||||
closeProject,
|
closeProject,
|
||||||
createScene,
|
createScene,
|
||||||
selectScene,
|
selectScene,
|
||||||
|
importCampaignAudio,
|
||||||
|
updateCampaignAudios,
|
||||||
updateScene,
|
updateScene,
|
||||||
updateConnections,
|
updateConnections,
|
||||||
importMediaToScene,
|
importMediaToScene,
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ type RotatedImageProps = {
|
|||||||
rotationDeg: 0 | 90 | 180 | 270;
|
rotationDeg: 0 | 90 | 180 | 270;
|
||||||
mode: Mode;
|
mode: Mode;
|
||||||
alt?: string;
|
alt?: string;
|
||||||
|
loading?: React.ImgHTMLAttributes<HTMLImageElement>['loading'];
|
||||||
|
decoding?: React.ImgHTMLAttributes<HTMLImageElement>['decoding'];
|
||||||
/** Высота/ширина полностью контролируются родителем. */
|
/** Высота/ширина полностью контролируются родителем. */
|
||||||
style?: React.CSSProperties;
|
style?: React.CSSProperties;
|
||||||
/** Прямоугольник видимого контента (contain/cover) внутри контейнера. */
|
/** Прямоугольник видимого контента (contain/cover) внутри контейнера. */
|
||||||
@@ -40,6 +42,8 @@ export function RotatedImage({
|
|||||||
rotationDeg,
|
rotationDeg,
|
||||||
mode,
|
mode,
|
||||||
alt = '',
|
alt = '',
|
||||||
|
loading,
|
||||||
|
decoding,
|
||||||
style,
|
style,
|
||||||
onContentRectChange,
|
onContentRectChange,
|
||||||
}: RotatedImageProps) {
|
}: RotatedImageProps) {
|
||||||
@@ -91,6 +95,8 @@ export function RotatedImage({
|
|||||||
<img
|
<img
|
||||||
alt={alt}
|
alt={alt}
|
||||||
src={url}
|
src={url}
|
||||||
|
loading={loading}
|
||||||
|
decoding={decoding}
|
||||||
className={styles.img}
|
className={styles.img}
|
||||||
style={{
|
style={{
|
||||||
width: w ?? '100%',
|
width: w ?? '100%',
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ export const ipcChannels = {
|
|||||||
setCurrentScene: 'project.setCurrentScene',
|
setCurrentScene: 'project.setCurrentScene',
|
||||||
setCurrentGraphNode: 'project.setCurrentGraphNode',
|
setCurrentGraphNode: 'project.setCurrentGraphNode',
|
||||||
importMedia: 'project.importMedia',
|
importMedia: 'project.importMedia',
|
||||||
|
importCampaignAudio: 'project.importCampaignAudio',
|
||||||
|
updateCampaignAudios: 'project.updateCampaignAudios',
|
||||||
importScenePreview: 'project.importScenePreview',
|
importScenePreview: 'project.importScenePreview',
|
||||||
clearScenePreview: 'project.clearScenePreview',
|
clearScenePreview: 'project.clearScenePreview',
|
||||||
assetFileUrl: 'project.assetFileUrl',
|
assetFileUrl: 'project.assetFileUrl',
|
||||||
@@ -120,6 +122,14 @@ export type IpcInvokeMap = {
|
|||||||
req: { sceneId: SceneId };
|
req: { sceneId: SceneId };
|
||||||
res: { project: Project; imported: MediaAsset[] };
|
res: { project: Project; imported: MediaAsset[] };
|
||||||
};
|
};
|
||||||
|
[ipcChannels.project.importCampaignAudio]: {
|
||||||
|
req: Record<string, never>;
|
||||||
|
res: { canceled: boolean; project: Project; imported: MediaAsset[] };
|
||||||
|
};
|
||||||
|
[ipcChannels.project.updateCampaignAudios]: {
|
||||||
|
req: { audios: Project['campaignAudios'] };
|
||||||
|
res: { project: Project };
|
||||||
|
};
|
||||||
[ipcChannels.project.importScenePreview]: {
|
[ipcChannels.project.importScenePreview]: {
|
||||||
req: { sceneId: SceneId };
|
req: { sceneId: SceneId };
|
||||||
res: { project: Project };
|
res: { project: Project };
|
||||||
|
|||||||
@@ -112,6 +112,8 @@ export type Project = {
|
|||||||
meta: ProjectMeta;
|
meta: ProjectMeta;
|
||||||
scenes: Record<SceneId, Scene>;
|
scenes: Record<SceneId, Scene>;
|
||||||
assets: Record<AssetId, MediaAsset>;
|
assets: Record<AssetId, MediaAsset>;
|
||||||
|
/** Аудио кампании: играет в пульте на протяжении всей презентации, если в сцене нет своей музыки. */
|
||||||
|
campaignAudios: SceneAudioRef[];
|
||||||
currentSceneId: SceneId | null;
|
currentSceneId: SceneId | null;
|
||||||
/** Текущая нода графа (важно, когда одна сцена имеет несколько нод). */
|
/** Текущая нода графа (важно, когда одна сцена имеет несколько нод). */
|
||||||
currentGraphNodeId: GraphNodeId | null;
|
currentGraphNodeId: GraphNodeId | null;
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
## План оптимизации редактора (агент)
|
||||||
|
|
||||||
|
Цель: убрать лаги визуального редактора на проектах 20+ сцен (пан/зум графа, drag, создание связей) и снизить влияние тяжёлых ассетов (изображения/видео) на редактор.
|
||||||
|
|
||||||
|
### Этап 0 — Замер и воспроизведение
|
||||||
|
|
||||||
|
- **Сценарии**: пан/зум графа, drag узлов, создание/удаление связей, работа со списком сцен, редактирование свойств сцены.
|
||||||
|
- **Сбор данных**: DevTools Performance (CPU/Rendering), наблюдение нагрузки (GPU/Video Decode), фиксация “где именно тормозит”.
|
||||||
|
|
||||||
|
**Результат**: подтверждены основные “горячие места” (граф/рендер/медиа/IPC).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Этап 1 — Быстрый выигрыш без изменения формата проекта (минимально и безопасно)
|
||||||
|
|
||||||
|
#### 1.1 Lazy/async для изображений в карточках
|
||||||
|
|
||||||
|
- Для `<img>` в карточках графа и списка сцен применить:
|
||||||
|
- `loading="lazy"`
|
||||||
|
- `decoding="async"`
|
||||||
|
- Убедиться, что контейнеры превью не провоцируют лишние перерасчёты размеров/перерисовки.
|
||||||
|
|
||||||
|
#### 1.2 Снижение перерисовок графа (минимально и безопасно)
|
||||||
|
|
||||||
|
- **Стабилизировать входные данные** графа:
|
||||||
|
- вместо передачи “всего `project.scenes`” подготовить “лёгкое” представление сцен, содержащее только поля, нужные для карточек (title, preview refs, флаги).
|
||||||
|
- Цель: изменения в инспекторе (описание/аудио/и т.п.) не должны заставлять ReactFlow пересобирать все `nodes/edges`.
|
||||||
|
|
||||||
|
#### 1.3 Ограничить частоту тяжёлых обновлений при действиях в графе
|
||||||
|
|
||||||
|
- Проверить, чтобы обновления позиций/связей не вызывали лишних “полных” пересборок и не инициировали дорогие операции чаще, чем нужно.
|
||||||
|
|
||||||
|
**Критерий готовности**: на проекте с 22 сценами взаимодействие с графом заметно плавнее, задержка при создании связей снижается.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Этап 2 — Самое эффективное: thumbnails вместо оригиналов
|
||||||
|
|
||||||
|
#### 2.1 Модель данных для миниатюр
|
||||||
|
|
||||||
|
- Для превью/медиа хранить:
|
||||||
|
- **original asset** (как сейчас) — для просмотра/презентации
|
||||||
|
- **thumbnail asset** — для графа и списков
|
||||||
|
- Миниатюра — отдельный asset в `project.assets`, связанный с оригиналом (через поле в сцене или метаданные asset).
|
||||||
|
|
||||||
|
#### 2.2 Генерация thumbnail при импорте изображений
|
||||||
|
|
||||||
|
- При импорте превью/изображений:
|
||||||
|
- ресайз до ~**320px по длинной стороне**
|
||||||
|
- кодек: **WebP** (или JPEG)
|
||||||
|
- Сохранять thumbnail как отдельный файл/asset.
|
||||||
|
|
||||||
|
#### 2.3 Генерация thumbnail для видео по первому кадру
|
||||||
|
|
||||||
|
- При импорте видео:
|
||||||
|
- извлечь кадр (0.5–1s если на 0s чёрный)
|
||||||
|
- сохранить как **image/webp/jpeg** thumbnail
|
||||||
|
- В UI использовать thumbnail как постер для карточек.
|
||||||
|
|
||||||
|
#### 2.4 Использование thumbnails в UI
|
||||||
|
|
||||||
|
- **Граф сцен**: показывает только thumbnail.
|
||||||
|
- **Список сцен**: показывает только thumbnail.
|
||||||
|
- **Инспектор**: по желанию — thumbnail или оригинал (можно оставить оригинал только тут).
|
||||||
|
- **Презентация/просмотр**: оригинал.
|
||||||
|
|
||||||
|
#### 2.5 Обратная совместимость
|
||||||
|
|
||||||
|
- Старые проекты без thumbnails:
|
||||||
|
- “ленивая” догенерация в фоне при первом отображении,
|
||||||
|
- или отдельная команда “Оптимизировать проект (создать миниатюры)”.
|
||||||
|
|
||||||
|
**Критерий готовности**: тяжёлые исходники почти не влияют на редактор; масштабируемость по сценам растёт.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Этап 3 — Дополнительные ускорения (по ситуации)
|
||||||
|
|
||||||
|
- **3.1 Виртуализация списка сцен** (если список остаётся тяжёлым при 50+ сценах).
|
||||||
|
- **3.2 Дифф‑обновление `nodes/edges`** в ReactFlow вместо “пересборки целиком” (для 100+ сцен).
|
||||||
|
- **3.3 LOD‑поведение**: при сильном зуме карточки упрощаются (только заголовок/иконки), thumbnails отключаются.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Приоритизация
|
||||||
|
|
||||||
|
- **Сразу**: Этап 1.
|
||||||
|
- **Максимальный эффект**: Этап 2.
|
||||||
|
- **Для больших проектов**: Этап 3.
|
||||||
|
|
||||||
+1
-1
@@ -10,7 +10,7 @@
|
|||||||
"build:obfuscate": "node scripts/build.mjs --production --obfuscate",
|
"build:obfuscate": "node scripts/build.mjs --production --obfuscate",
|
||||||
"lint": "eslint . --max-warnings 0",
|
"lint": "eslint . --max-warnings 0",
|
||||||
"typecheck": "tsc -p tsconfig.eslint.json --noEmit",
|
"typecheck": "tsc -p tsconfig.eslint.json --noEmit",
|
||||||
"test": "tsx --test app/renderer/shared/ui/controls.tooltip.test.ts app/renderer/editor/state/projectState.race.test.ts app/shared/ipc/contracts.mediaRemoval.test.ts app/shared/effectEraserHitTest.test.ts app/renderer/control/controlApp.effectsPanel.test.ts app/renderer/shared/effects/PxiEffectsOverlay.pointer.test.ts app/main/windows/createWindows.editorClose.test.ts app/main/windows/bootWindow.test.ts app/main/effects/effectsStore.test.ts app/main/project/assetPrune.test.ts app/main/project/fsRetry.test.ts app/main/project/zipRead.test.ts app/main/project/zipStore.legacyContract.test.ts app/shared/package.build.test.ts app/shared/license/canonicalJson.test.ts app/shared/license/productKey.test.ts app/shared/license/licenseService.networkRegression.test.ts app/shared/video/videoPlaybackPerf.networkRegression.test.ts app/shared/video/videoPlaybackLoop.networkRegression.test.ts app/main/license/verifyLicenseToken.test.ts && node --test scripts/build-env.test.mjs scripts/obfuscate-main.test.mjs",
|
"test": "tsx --test app/renderer/shared/ui/controls.tooltip.test.ts app/renderer/editor/state/projectState.race.test.ts app/renderer/editor/graph/sceneCardById.test.ts app/shared/ipc/contracts.mediaRemoval.test.ts app/shared/effectEraserHitTest.test.ts app/renderer/control/controlApp.effectsPanel.test.ts app/renderer/shared/effects/PxiEffectsOverlay.pointer.test.ts app/main/windows/createWindows.editorClose.test.ts app/main/windows/bootWindow.test.ts app/main/effects/effectsStore.test.ts app/main/project/assetPrune.test.ts app/main/project/fsRetry.test.ts app/main/project/zipRead.test.ts app/main/project/zipStore.legacyContract.test.ts app/shared/package.build.test.ts app/shared/license/canonicalJson.test.ts app/shared/license/productKey.test.ts app/shared/license/licenseService.networkRegression.test.ts app/shared/video/videoPlaybackPerf.networkRegression.test.ts app/shared/video/videoPlaybackLoop.networkRegression.test.ts app/main/license/verifyLicenseToken.test.ts && node --test scripts/build-env.test.mjs scripts/obfuscate-main.test.mjs",
|
||||||
"format": "prettier . --check",
|
"format": "prettier . --check",
|
||||||
"format:write": "prettier . --write",
|
"format:write": "prettier . --write",
|
||||||
"release:info": "node scripts/print-release-info.mjs",
|
"release:info": "node scripts/print-release-info.mjs",
|
||||||
|
|||||||
Reference in New Issue
Block a user