perf: cut ControlApp re-renders and lazy-load VFX packs
Move audio scrub/volume and brush drafts off root React ticks, coalesce overlay layout IPC, cache machine fingerprint and asset URLs, share one scene overlay host, and stop idle Pixi ticker. Also harden console against EPIPE on window load failures. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,31 +1,94 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { ipcChannels } from '../../shared/ipc/contracts';
|
||||
import type { AssetId } from '../../shared/types';
|
||||
import type { AssetId, ProjectId } from '../../shared/types';
|
||||
|
||||
import { getDndApi } from './dndApi';
|
||||
|
||||
/** Module-level кэш assetId → url; сбрасывается при смене/закрытии проекта. */
|
||||
const urlCache = new Map<AssetId, string | null>();
|
||||
const invalidateListeners = new Set<() => void>();
|
||||
let sessionProjectHooked = false;
|
||||
let lastSessionProjectId: ProjectId | null | undefined;
|
||||
|
||||
function ensureSessionProjectInvalidation(): void {
|
||||
if (sessionProjectHooked) return;
|
||||
sessionProjectHooked = true;
|
||||
try {
|
||||
getDndApi().on(ipcChannels.session.stateChanged, ({ state }) => {
|
||||
const next = state.project?.id ?? null;
|
||||
if (lastSessionProjectId === undefined) {
|
||||
lastSessionProjectId = next;
|
||||
return;
|
||||
}
|
||||
if (lastSessionProjectId !== next) {
|
||||
lastSessionProjectId = next;
|
||||
invalidateAssetUrlCache();
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
/* вне Electron / тесты */
|
||||
}
|
||||
}
|
||||
|
||||
export function peekAssetUrlCache(assetId: AssetId): string | null | undefined {
|
||||
return urlCache.has(assetId) ? (urlCache.get(assetId) ?? null) : undefined;
|
||||
}
|
||||
|
||||
export function invalidateAssetUrlCache(): void {
|
||||
urlCache.clear();
|
||||
for (const fn of invalidateListeners) {
|
||||
try {
|
||||
fn();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Возвращает `file://` URL для превью изображения. Пока загрузка или сменился id — `null`.
|
||||
* Возвращает `dnd://` / file URL для превью. Пока загрузка или сменился id — `null`.
|
||||
* Повторные запросы того же id не ходят в IPC, пока кэш не инвалидирован.
|
||||
*/
|
||||
export function useAssetUrl(assetId: AssetId | null | undefined): string | null {
|
||||
ensureSessionProjectInvalidation();
|
||||
const id = assetId ?? null;
|
||||
const [entry, setEntry] = useState<{ assetId: AssetId; url: string | null } | null>(null);
|
||||
const [entry, setEntry] = useState<{ assetId: AssetId; url: string | null } | null>(() => {
|
||||
if (id === null) return null;
|
||||
const hit = peekAssetUrlCache(id);
|
||||
return hit === undefined ? null : { assetId: id, url: hit };
|
||||
});
|
||||
const [epoch, setEpoch] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const onInvalidate = () => setEpoch((n) => n + 1);
|
||||
invalidateListeners.add(onInvalidate);
|
||||
return () => {
|
||||
invalidateListeners.delete(onInvalidate);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (id === null) {
|
||||
setEntry(null);
|
||||
return undefined;
|
||||
}
|
||||
const hit = peekAssetUrlCache(id);
|
||||
if (hit !== undefined) {
|
||||
setEntry({ assetId: id, url: hit });
|
||||
return undefined;
|
||||
}
|
||||
let cancelled = false;
|
||||
void getDndApi()
|
||||
.invoke(ipcChannels.project.assetFileUrl, { assetId: id })
|
||||
.then((r) => {
|
||||
urlCache.set(id, r.url);
|
||||
if (!cancelled) setEntry({ assetId: id, url: r.url });
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [id]);
|
||||
}, [id, epoch]);
|
||||
|
||||
if (id === null) {
|
||||
return null;
|
||||
|
||||
Reference in New Issue
Block a user