From d9fbecf5a7553447d24035175edb8eb05129b64a Mon Sep 17 00:00:00 2001 From: Ivan Fontosh Date: Thu, 23 Jul 2026 11:58:16 +0800 Subject: [PATCH] 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 --- app/main/index.ts | 4 + app/main/license/deviceId.ts | 5 +- app/main/license/machineFingerprint.test.ts | 42 + app/main/license/machineFingerprint.ts | 71 +- app/main/license/paths.ts | 5 + app/main/safeConsole.test.ts | 16 + app/main/safeConsole.ts | 26 + .../windows/createWindows.editorClose.test.ts | 7 + app/main/windows/createWindows.ts | 13 +- app/renderer/control/ControlApp.module.css | 49 +- app/renderer/control/ControlApp.tsx | 833 +++++++++--------- app/renderer/control/ControlAudioCard.tsx | 304 +++++++ ...rolApp.audioPerf.networkRegression.test.ts | 27 + ...rolApp.brushPerf.networkRegression.test.ts | 35 + .../control/controlApp.effectsPanel.test.ts | 25 + app/renderer/editor/i18n/editorMessages.ts | 2 + app/renderer/editor/state/projectState.ts | 4 + app/renderer/shared/PresentationView.tsx | 18 +- .../effects/PxiEffectsOverlay.pointer.test.ts | 21 + .../shared/effects/PxiEffectsOverlay.tsx | 248 +++++- .../materials/MaterialOverlay.module.css | 30 +- .../shared/materials/MaterialOverlay.tsx | 192 ++-- app/renderer/shared/npcs/NpcsSceneOverlay.tsx | 129 ++- .../shared/sceneOverlay/SceneOverlayHost.tsx | 116 +++ .../sceneOverlay/SceneOverlayViewContext.tsx | 12 + .../sceneOverlay/sceneOverlayHost.test.ts | 79 ++ .../shared/useAssetImageUrl.cache.test.ts | 24 + app/renderer/shared/useAssetImageUrl.ts | 71 +- package.json | 2 +- 29 files changed, 1825 insertions(+), 585 deletions(-) create mode 100644 app/main/safeConsole.test.ts create mode 100644 app/main/safeConsole.ts create mode 100644 app/renderer/control/ControlAudioCard.tsx create mode 100644 app/renderer/control/controlApp.audioPerf.networkRegression.test.ts create mode 100644 app/renderer/control/controlApp.brushPerf.networkRegression.test.ts create mode 100644 app/renderer/shared/sceneOverlay/SceneOverlayHost.tsx create mode 100644 app/renderer/shared/sceneOverlay/SceneOverlayViewContext.tsx create mode 100644 app/renderer/shared/sceneOverlay/sceneOverlayHost.test.ts create mode 100644 app/renderer/shared/useAssetImageUrl.cache.test.ts diff --git a/app/main/index.ts b/app/main/index.ts index 394e1a8..1791993 100644 --- a/app/main/index.ts +++ b/app/main/index.ts @@ -3,6 +3,10 @@ import path from 'node:path'; import { app, BrowserWindow, dialog, Menu, protocol } from 'electron'; +import { installStdoutEpipeGuards } from './safeConsole'; + +installStdoutEpipeGuards(); + import { openDialogFilterLabel } from '../shared/appBranding'; import { ipcChannels, type ScenePreviewImportEvent, type SessionState } from '../shared/ipc/contracts'; import { diff --git a/app/main/license/deviceId.ts b/app/main/license/deviceId.ts index 0b8440d..d5d5589 100644 --- a/app/main/license/deviceId.ts +++ b/app/main/license/deviceId.ts @@ -25,7 +25,8 @@ export function clearLegacyDeviceId(userData: string): void { /** * Идентификатор устройства для лицензии: отпечаток физической машины. * Одинаков для всех пользователей ОС на одном ПК (Windows/macOS/Linux). + * `userData` — путь для дискового кэша fingerprint (без повторного reg/wmic). */ -export function getOrCreateDeviceId(_userData?: string): string { - return resolveMachineFingerprint(); +export function getOrCreateDeviceId(userData?: string): string { + return resolveMachineFingerprint(userData ? { userData } : {}); } diff --git a/app/main/license/machineFingerprint.test.ts b/app/main/license/machineFingerprint.test.ts index 24d8f54..6316018 100644 --- a/app/main/license/machineFingerprint.test.ts +++ b/app/main/license/machineFingerprint.test.ts @@ -5,6 +5,7 @@ import path from 'node:path'; import test from 'node:test'; import { + clearMachineFingerprintMemoryCache, hashMachineRawId, machineWideIdPath, parseMacIOPlatformUUID, @@ -12,6 +13,7 @@ import { parseWmicUuid, resolveMachineFingerprint, } from './machineFingerprint'; +import { machineFingerprintCachePath } from './paths'; void test('hashMachineRawId: стабилен и не зависит от регистра GUID', () => { const a = hashMachineRawId('win32', 'ABCDEF00-1111-2222-3333-444455556666'); @@ -46,6 +48,7 @@ void test('parseWmicUuid', () => { }); void test('resolveMachineFingerprint: override через DND_LICENSE_DEVICE_ID', () => { + clearMachineFingerprintMemoryCache(); const id = resolveMachineFingerprint({ platform: 'linux', env: { DND_LICENSE_DEVICE_ID: 'override-device-id-12345' }, @@ -54,6 +57,7 @@ void test('resolveMachineFingerprint: override через DND_LICENSE_DEVICE_ID' }); void test('resolveMachineFingerprint: Windows MachineGuid → одинаковый hash', () => { + clearMachineFingerprintMemoryCache(); const exec = () => ` HKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\Cryptography @@ -64,6 +68,7 @@ HKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\Cryptography env: {}, execFileSync: exec as never, }); + clearMachineFingerprintMemoryCache(); const b = resolveMachineFingerprint({ platform: 'win32', env: {}, @@ -74,6 +79,7 @@ HKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\Cryptography }); void test('resolveMachineFingerprint: Linux /etc/machine-id', () => { + clearMachineFingerprintMemoryCache(); const id = resolveMachineFingerprint({ platform: 'linux', env: {}, @@ -86,6 +92,7 @@ void test('resolveMachineFingerprint: Linux /etc/machine-id', () => { }); void test('resolveMachineFingerprint: fallback в machine-wide путь', () => { + clearMachineFingerprintMemoryCache(); const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'machine-fp-')); const env = { PROGRAMDATA: tmp }; const p = machineWideIdPath('win32', env); @@ -96,6 +103,7 @@ void test('resolveMachineFingerprint: fallback в machine-wide путь', () => throw new Error('no reg'); }, }); + clearMachineFingerprintMemoryCache(); const id2 = resolveMachineFingerprint({ platform: 'win32', env, @@ -107,3 +115,37 @@ void test('resolveMachineFingerprint: fallback в machine-wide путь', () => assert.ok(fs.existsSync(p)); fs.rmSync(tmp, { recursive: true, force: true }); }); + +void test('resolveMachineFingerprint: disk cache — без повторного exec', () => { + clearMachineFingerprintMemoryCache(); + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'machine-fp-cache-')); + const expected = hashMachineRawId('win32', 'A1B2C3D4-E5F6-7890-ABCD-EF1234567890'); + let execCalls = 0; + const exec = () => { + execCalls += 1; + return ` +HKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\Cryptography + MachineGuid REG_SZ A1B2C3D4-E5F6-7890-ABCD-EF1234567890 +`; + }; + const a = resolveMachineFingerprint({ + platform: 'win32', + env: {}, + userData: tmp, + execFileSync: exec as never, + }); + assert.equal(a, expected); + assert.equal(execCalls, 1); + assert.ok(fs.existsSync(machineFingerprintCachePath(tmp))); + + clearMachineFingerprintMemoryCache(); + const b = resolveMachineFingerprint({ + platform: 'win32', + env: {}, + userData: tmp, + execFileSync: exec as never, + }); + assert.equal(b, expected); + assert.equal(execCalls, 1, 'второй вызов читает disk cache, без reg/wmic'); + fs.rmSync(tmp, { recursive: true, force: true }); +}); diff --git a/app/main/license/machineFingerprint.ts b/app/main/license/machineFingerprint.ts index 1580cf3..7bc168e 100644 --- a/app/main/license/machineFingerprint.ts +++ b/app/main/license/machineFingerprint.ts @@ -4,6 +4,8 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; +import { machineFingerprintCachePath } from './paths'; + type ExecFile = ( file: string, args: readonly string[], @@ -13,6 +15,8 @@ type ExecFile = ( export type MachineFingerprintDeps = { platform?: NodeJS.Platform; env?: NodeJS.ProcessEnv; + /** Electron userData — для дискового кэша hashed fingerprint. */ + userData?: string; execFileSync?: ExecFile; readFileSync?: (p: string, encoding: 'utf8') => string; existsSync?: (p: string) => boolean; @@ -20,6 +24,18 @@ export type MachineFingerprintDeps = { writeFileSync?: (p: string, data: string, opts?: { mode?: number }) => void; }; +/** Process-level кэш: повторные вызовы в том же процессе без I/O. */ +let memoryFingerprint: string | null = null; + +/** Только для тестов. */ +export function clearMachineFingerprintMemoryCache(): void { + memoryFingerprint = null; +} + +function isPlausiblyFingerprint(id: string): boolean { + return id.length >= 8 && id.length <= 128 && !/\s/.test(id); +} + const HASH_PREFIX = 'TTRPGPlayer.machine.v1\0'; /** Стабильный opaque id из сырого машинного идентификатора ОС. */ @@ -149,9 +165,38 @@ function readOrCreateMachineWideFallback( } } +function readDiskFingerprintCache( + cacheFile: string, + readFile: (p: string, encoding: 'utf8') => string, + existsSync: (p: string) => boolean, +): string | null { + try { + if (!existsSync(cacheFile)) return null; + const cached = readFile(cacheFile, 'utf8').trim(); + return isPlausiblyFingerprint(cached) ? cached : null; + } catch { + return null; + } +} + +function writeDiskFingerprintCache( + cacheFile: string, + fingerprint: string, + mkdirSync: (p: string, opts: { recursive: boolean }) => void, + writeFileSync: (p: string, data: string, opts?: { mode?: number }) => void, +): void { + try { + mkdirSync(path.dirname(cacheFile), { recursive: true }); + writeFileSync(cacheFile, `${fingerprint}\n`, { mode: 0o644 }); + } catch { + /* кэш необязателен */ + } +} + /** * Стабильный идентификатор физической машины (одинаковый для всех пользователей ОС на одном ПК). * Источники: Windows MachineGuid, macOS IOPlatformUUID, Linux /etc/machine-id. + * Кэш: память процесса → userData/machine.fingerprint → sync probe ОС только при miss. */ export function resolveMachineFingerprint(deps: MachineFingerprintDeps = {}): string { const platform = deps.platform ?? process.platform; @@ -167,7 +212,24 @@ export function resolveMachineFingerprint(deps: MachineFingerprintDeps = {}): st }); const override = env.DND_LICENSE_DEVICE_ID?.trim(); - if (override && override.length >= 8) return override; + if (override && override.length >= 8) { + memoryFingerprint = override; + return override; + } + + if (memoryFingerprint && isPlausiblyFingerprint(memoryFingerprint)) { + return memoryFingerprint; + } + + const userData = deps.userData?.trim(); + const cacheFile = userData ? machineFingerprintCachePath(userData) : null; + if (cacheFile) { + const fromDisk = readDiskFingerprintCache(cacheFile, readFile, existsSync); + if (fromDisk) { + memoryFingerprint = fromDisk; + return fromDisk; + } + } let raw: string | null = null; if (platform === 'win32') raw = readWindowsRawId(exec); @@ -189,5 +251,10 @@ export function resolveMachineFingerprint(deps: MachineFingerprintDeps = {}): st ); } - return hashMachineRawId(platform, raw); + const fingerprint = hashMachineRawId(platform, raw); + memoryFingerprint = fingerprint; + if (cacheFile) { + writeDiskFingerprintCache(cacheFile, fingerprint, mkdirSync, writeFileSync); + } + return fingerprint; } diff --git a/app/main/license/paths.ts b/app/main/license/paths.ts index 210479e..4e79929 100644 --- a/app/main/license/paths.ts +++ b/app/main/license/paths.ts @@ -14,6 +14,11 @@ export function deviceIdPath(userData: string): string { return path.join(userData, 'device.id'); } +/** Кэш hashed machine fingerprint — без повторного reg/wmic/ioreg на каждом старте. */ +export function machineFingerprintCachePath(userData: string): string { + return path.join(userData, 'machine.fingerprint'); +} + export function preferencesPath(userData: string): string { return path.join(userData, 'preferences.json'); } diff --git a/app/main/safeConsole.test.ts b/app/main/safeConsole.test.ts new file mode 100644 index 0000000..c7867cc --- /dev/null +++ b/app/main/safeConsole.test.ts @@ -0,0 +1,16 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; + +const here = path.dirname(fileURLToPath(import.meta.url)); + +void test('main: EPIPE guards ставятся до app.whenReady', () => { + const index = fs.readFileSync(path.join(here, 'index.ts'), 'utf8'); + const safe = fs.readFileSync(path.join(here, 'safeConsole.ts'), 'utf8'); + assert.ok(index.includes('installStdoutEpipeGuards')); + assert.ok(index.indexOf('installStdoutEpipeGuards()') < index.indexOf('app.requestSingleInstanceLock')); + assert.ok(safe.includes('EPIPE')); + assert.ok(safe.includes('safeConsoleError')); +}); diff --git a/app/main/safeConsole.ts b/app/main/safeConsole.ts new file mode 100644 index 0000000..fe8dfcb --- /dev/null +++ b/app/main/safeConsole.ts @@ -0,0 +1,26 @@ +/** + * В Electron (особенно после рестарта в dev) stdout/stderr часто уже закрыты. + * Обычный `console.error` тогда даёт EPIPE и валит main process диалогом Uncaught Exception. + */ + +function isBrokenPipe(err: unknown): boolean { + const code = (err as NodeJS.ErrnoException | undefined)?.code; + return code === 'EPIPE' || code === 'ERR_STREAM_DESTROYED'; +} + +export function installStdoutEpipeGuards(): void { + for (const stream of [process.stdout, process.stderr]) { + stream?.on('error', (err: NodeJS.ErrnoException) => { + if (isBrokenPipe(err)) return; + }); + } +} + +export function safeConsoleError(...args: unknown[]): void { + try { + console.error(...args); + } catch (err) { + if (isBrokenPipe(err)) return; + throw err; + } +} diff --git a/app/main/windows/createWindows.editorClose.test.ts b/app/main/windows/createWindows.editorClose.test.ts index f7f5ca9..5b5c673 100644 --- a/app/main/windows/createWindows.editorClose.test.ts +++ b/app/main/windows/createWindows.editorClose.test.ts @@ -73,3 +73,10 @@ void test('createWindows: показ окна — не только ready-to-sho assert.ok(src.includes('ensureWindowBecomesVisible')); assert.ok(src.includes('did-finish-load')); }); + +void test('createWindows: логи окон не валят main через EPIPE', () => { + const src = readCreateWindows(); + assert.ok(src.includes('safeConsoleError')); + assert.ok(src.includes('errorCode === -3')); + assert.ok(src.includes('isMainFrame')); +}); diff --git a/app/main/windows/createWindows.ts b/app/main/windows/createWindows.ts index b4a2914..7687e96 100644 --- a/app/main/windows/createWindows.ts +++ b/app/main/windows/createWindows.ts @@ -5,6 +5,8 @@ import { app, BrowserWindow, screen } from 'electron'; import { windowChromeTitle } from '../../shared/appBranding'; import { ipcChannels } from '../../shared/ipc/contracts'; +import { safeConsoleError } from '../safeConsole'; + import { getBootSplashWindow } from './bootWindow'; import { loadBrandingWindowIcon } from './brandingIcon'; @@ -245,13 +247,16 @@ function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow } win.webContents.on('preload-error', (_event, preloadPath, error) => { - console.error(`[preload-error] ${preloadPath}:`, error); + safeConsoleError(`[preload-error] ${preloadPath}:`, error); }); - win.webContents.on('did-fail-load', (_event, errorCode, errorDescription, validatedURL) => { - console.error(`[did-fail-load] ${String(errorCode)} ${errorDescription} ${validatedURL}`); + win.webContents.on('did-fail-load', (_event, errorCode, errorDescription, validatedURL, isMainFrame) => { + // -3 ERR_ABORTED: частый артефакт при navigate/maximize/закрытии — не шумим. + if (errorCode === -3) return; + if (!isMainFrame) return; + safeConsoleError(`[did-fail-load] ${String(errorCode)} ${errorDescription} ${validatedURL}`); }); win.webContents.on('render-process-gone', (_event, details) => { - console.error('[render-process-gone]', details.reason, details.exitCode); + safeConsoleError('[render-process-gone]', details.reason, details.exitCode); }); if (!deferEditor) { diff --git a/app/renderer/control/ControlApp.module.css b/app/renderer/control/ControlApp.module.css index b454a94..fdc6115 100644 --- a/app/renderer/control/ControlApp.module.css +++ b/app/renderer/control/ControlApp.module.css @@ -4,14 +4,20 @@ display: grid; grid-template-columns: 280px 1fr; gap: 16px; + overflow: auto; + min-height: 0; + box-sizing: border-box; } .remote { padding: 12px; height: 100%; - min-height: 0; + min-height: calc(100vh - 32px); + min-width: 0; + overflow: hidden; display: flex; flex-direction: column; + box-sizing: border-box; } .remoteTitle { @@ -456,10 +462,51 @@ white-space: nowrap; } +.audioControls { + display: flex; + flex-direction: column; + gap: 8px; + flex-shrink: 0; + align-items: stretch; + min-width: 132px; +} + .audioTransport { display: flex; gap: 10px; flex-shrink: 0; + justify-content: flex-end; +} + +.audioVolumeRow { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} + +.audioVolumeIcon { + flex-shrink: 0; + width: 16px; + height: 16px; + color: var(--text2); + display: flex; + align-items: center; + justify-content: center; +} + +.audioVolumeIcon svg { + display: block; + width: 16px; + height: 16px; +} + +.audioVolume { + flex: 1; + min-width: 0; + width: 100%; + margin: 0; + accent-color: var(--accent-fill-solid); } .scrubFill { diff --git a/app/renderer/control/ControlApp.tsx b/app/renderer/control/ControlApp.tsx index 5e5e68e..6743450 100644 --- a/app/renderer/control/ControlApp.tsx +++ b/app/renderer/control/ControlApp.tsx @@ -13,7 +13,11 @@ import { useEditorI18n } from '../editor/i18n/EditorI18nContext'; import { isSceneDescriptionEmpty } from '../editor/sceneDescriptionHtml'; import { getDndApi } from '../shared/dndApi'; import { RotatedImage } from '../shared/RotatedImage'; -import { PixiEffectsOverlay } from '../shared/effects/PxiEffectsOverlay'; +import { + PixiEffectsOverlay, + type PixiEffectsOverlayHandle, +} from '../shared/effects/PxiEffectsOverlay'; +import type { EffectInstance } from '../../shared/types/effects'; import { SceneDarknessOverlay } from '../shared/effects/SceneDarknessOverlay'; import { useEffectsState } from '../shared/effects/useEffectsState'; import { useSceneDarknessState } from '../shared/effects/useSceneDarknessState'; @@ -22,11 +26,13 @@ import { MaterialOverlay } from '../shared/materials/MaterialOverlay'; import { useMaterialsOverlayState } from '../shared/materials/useMaterialsOverlayState'; import { NpcsSceneOverlay } from '../shared/npcs/NpcsSceneOverlay'; import { useNpcsOverlayState } from '../shared/npcs/useNpcsOverlayState'; +import { SceneOverlayHost } from '../shared/sceneOverlay/SceneOverlayHost'; import { Button } from '../shared/ui/controls'; import { Surface } from '../shared/ui/Surface'; import { useAssetUrl } from '../shared/useAssetImageUrl'; import styles from './ControlApp.module.css'; +import { ControlAudioCard } from './ControlAudioCard'; import { ControlScenePreview } from './ControlScenePreview'; import { getFreezeEffectLifeMs, playFreezeEffectSound } from './freezeSfx'; import { getPoisonCloudEffectLifeMs, playPoisonCloudEffectSound } from './poisonCloudSfx'; @@ -35,12 +41,23 @@ import { getSunbeamEffectLifeMs, playSunbeamEffectSound } from './sunbeamSfx'; /** Длительность молнии: быстрый удар + акцент в точке попадания. */ const LIGHTNING_EFFECT_MS = 840; -function formatTime(sec: number): string { - if (!Number.isFinite(sec) || sec < 0) return '0:00'; - const s = Math.floor(sec); - const m = Math.floor(s / 60); - const r = s % 60; - return `${String(m)}:${String(r).padStart(2, '0')}`; +function clampAudioGain(v: number): number { + if (!Number.isFinite(v)) return 1; + return Math.max(0, Math.min(1, v)); +} + +function readAudioGain(gains: Map, assetId: string): number { + return gains.get(assetId) ?? 1; +} + +/** Применяет пользовательскую громкость; `factor` — для fade in/out (0…1). */ +function applyAudioGain( + el: HTMLAudioElement, + gains: Map, + assetId: string, + factor = 1, +): void { + el.volume = clampAudioGain(readAudioGain(gains, assetId) * factor); } /** Файл из `app/renderer/public/molniya.mp3` — рядом с `control.html` в dev и в dist. */ @@ -103,11 +120,14 @@ export function ControlApp() { // Сюжетная линия — только UI-состояние пульта. Не меняет граф, сцены и связи проекта. const sceneAudioElsRef = useRef>(new Map()); const sceneAudioMetaRef = useRef>(new Map()); + /** Пользовательская громкость 0…1 по assetId (сохраняется между сменами сцен для того же трека). */ + const sceneAudioGainRef = useRef>(new Map()); const [sceneAudioStateTick, setSceneAudioStateTick] = useState(0); const sceneAudioLoadRunRef = useRef(0); const campaignAudioElsRef = useRef>(new Map()); const campaignAudioMetaRef = useRef>(new Map()); + const campaignAudioGainRef = useRef>(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. */ @@ -132,7 +152,6 @@ export function ControlApp() { startN?: { x: number; y: number }; points?: { x: number; y: number; tMs: number }[]; } | null>(null); - const [draftFxTick, setDraftFxTick] = useState(0); const [previewSize, setPreviewSize] = useState<{ w: number; h: number }>({ w: 1, h: 1 }); const [previewContentRect, setPreviewContentRect] = useState<{ x: number; @@ -147,6 +166,8 @@ export function ControlApp() { const brushCursorElRef = useRef(null); const cursorPosRef = useRef<{ x: number; y: number } | null>(null); const draftPaintRafRef = useRef(0); + const effectsOverlayRef = useRef(null); + const draftMetaRef = useRef<{ createdAtMs: number; seed: number } | null>(null); useEffect(() => { void api.invoke(ipcChannels.project.get, {}).then((res) => { @@ -191,21 +212,29 @@ export function ControlApp() { }, []); const [freezeDraftLifeMs, setFreezeDraftLifeMs] = useState(820); + const freezeDraftLifeMsRef = useRef(freezeDraftLifeMs); + freezeDraftLifeMsRef.current = freezeDraftLifeMs; useEffect(() => { void getFreezeEffectLifeMs().then(setFreezeDraftLifeMs); }, []); const [darknessDraftLifeMs, setDarknessDraftLifeMs] = useState(820); + const darknessDraftLifeMsRef = useRef(darknessDraftLifeMs); + darknessDraftLifeMsRef.current = darknessDraftLifeMs; useEffect(() => { void getFreezeEffectLifeMs().then(setDarknessDraftLifeMs); }, []); const [sunbeamDraftLifeMs, setSunbeamDraftLifeMs] = useState(600); + const sunbeamDraftLifeMsRef = useRef(sunbeamDraftLifeMs); + sunbeamDraftLifeMsRef.current = sunbeamDraftLifeMs; useEffect(() => { void getSunbeamEffectLifeMs().then(setSunbeamDraftLifeMs); }, []); const [poisonDraftLifeMs, setPoisonDraftLifeMs] = useState(1600); + const poisonDraftLifeMsRef = useRef(poisonDraftLifeMs); + poisonDraftLifeMsRef.current = poisonDraftLifeMs; useEffect(() => { void getPoisonCloudEffectLifeMs().then(setPoisonDraftLifeMs); }, []); @@ -268,11 +297,11 @@ export function ControlApp() { const FADE_OUT_MS = 450; const fadeOutCtl = { raf: 0, cancelled: false }; const finishFadeOut = (): void => { - for (const el of oldEls.values()) { + for (const [id, el] of oldEls) { try { el.pause(); el.currentTime = 0; - el.volume = 1; + applyAudioGain(el, sceneAudioGainRef.current, id); } catch { // ignore } @@ -324,7 +353,8 @@ export function ControlApp() { const el = new Audio(r.url); el.loop = item.loop; el.preload = 'auto'; - el.volume = item.autoplay ? 0 : 1; + if (item.autoplay) el.volume = 0; + else applyAudioGain(el, sceneAudioGainRef.current, item.assetId); sceneAudioMetaRef.current.set(item.assetId, { lastPlayError: null }); el.addEventListener('play', () => setSceneAudioStateTick((x) => x + 1)); el.addEventListener('pause', () => setSceneAudioStateTick((x) => x + 1)); @@ -340,7 +370,7 @@ export function ControlApp() { try { el.pause(); el.currentTime = 0; - el.volume = 1; + applyAudioGain(el, sceneAudioGainRef.current, ref.assetId); } catch { // ignore } @@ -357,7 +387,7 @@ export function ControlApp() { }); setSceneAudioStateTick((x) => x + 1); try { - el.volume = 1; + applyAudioGain(el, sceneAudioGainRef.current, ref.assetId); } catch { // ignore } @@ -365,7 +395,7 @@ export function ControlApp() { } if (sceneAudioLoadRunRef.current !== runId || audioUnmountRef.current) { try { - el.volume = 1; + applyAudioGain(el, sceneAudioGainRef.current, ref.assetId); } catch { // ignore } @@ -375,7 +405,7 @@ export function ControlApp() { const tickIn = (now: number): void => { if (sceneAudioLoadRunRef.current !== runId || audioUnmountRef.current) { try { - el.volume = 1; + applyAudioGain(el, sceneAudioGainRef.current, ref.assetId); } catch { // ignore } @@ -383,7 +413,7 @@ export function ControlApp() { } const u = Math.min(1, (now - tIn0) / FADE_IN_MS); try { - el.volume = u; + applyAudioGain(el, sceneAudioGainRef.current, ref.assetId, u); } catch { // ignore } @@ -411,10 +441,10 @@ export function ControlApp() { campaignAudioMetaRef.current.clear(); setCampaignAudioStateTick((x) => x + 1); - for (const el of oldEls.values()) { + for (const [id, el] of oldEls) { try { el.pause(); - el.volume = 1; + applyAudioGain(el, campaignAudioGainRef.current, id); } catch { // ignore } @@ -433,7 +463,8 @@ export function ControlApp() { const el = new Audio(r.url); el.loop = item.loop; el.preload = 'auto'; - el.volume = item.autoplay ? 0 : 1; + if (item.autoplay) el.volume = 0; + else applyAudioGain(el, campaignAudioGainRef.current, item.assetId); campaignAudioMetaRef.current.set(item.assetId, { lastPlayError: null }); el.addEventListener('play', () => setCampaignAudioStateTick((x) => x + 1)); el.addEventListener('pause', () => setCampaignAudioStateTick((x) => x + 1)); @@ -460,7 +491,7 @@ export function ControlApp() { }); setCampaignAudioStateTick((x) => x + 1); try { - el.volume = 1; + applyAudioGain(el, campaignAudioGainRef.current, ref.assetId); } catch { // ignore } @@ -468,7 +499,7 @@ export function ControlApp() { } if (campaignAudioLoadRunRef.current !== runId || audioUnmountRef.current) { try { - el.volume = 1; + applyAudioGain(el, campaignAudioGainRef.current, ref.assetId); } catch { // ignore } @@ -478,7 +509,7 @@ export function ControlApp() { const tickIn = (now: number): void => { if (campaignAudioLoadRunRef.current !== runId || audioUnmountRef.current) { try { - el.volume = 1; + applyAudioGain(el, campaignAudioGainRef.current, ref.assetId); } catch { // ignore } @@ -486,7 +517,7 @@ export function ControlApp() { } const u = Math.min(1, (now - tIn0) / 550); try { - el.volume = u; + applyAudioGain(el, campaignAudioGainRef.current, ref.assetId, u); } catch { // ignore } @@ -512,7 +543,7 @@ export function ControlApp() { 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; + if (el.volume === 0) applyAudioGain(el, campaignAudioGainRef.current, assetId); await el.play(); } catch { // ignore; user can press play @@ -547,7 +578,7 @@ export function ControlApp() { if (!allowCampaignAudioRef.current || audioUnmountRef.current) return; const u = Math.min(1, (now - tIn0) / 550); try { - el.volume = u; + applyAudioGain(el, campaignAudioGainRef.current, ref.assetId, u); } catch { // ignore } @@ -578,29 +609,6 @@ export function ControlApp() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [allowCampaignAudio]); - const anyPlaying = useMemo(() => { - for (const el of sceneAudioElsRef.current.values()) { - if (!el.paused) return true; - } - for (const el of campaignAudioElsRef.current.values()) { - if (!el.paused) return true; - } - return false; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [campaignAudioStateTick, sceneAudioStateTick]); - - useEffect(() => { - if (!anyPlaying) return; - let raf = 0; - const tick = () => { - setSceneAudioStateTick((x) => x + 1); - setCampaignAudioStateTick((x) => x + 1); - raf = window.requestAnimationFrame(tick); - }; - raf = window.requestAnimationFrame(tick); - return () => window.cancelAnimationFrame(raf); - }, [anyPlaying]); - useEffect(() => { const host = previewHostRef.current; if (!host) return; @@ -726,11 +734,147 @@ export function ControlApp() { el.style.height = `${String(size)}px`; } + function buildDraftEffectInstance(): EffectInstance | null { + const b = brushRef.current; + if (!b) return null; + const meta = draftMetaRef.current ?? { createdAtMs: Date.now(), seed: 12345 }; + const { createdAtMs, seed } = meta; + const t = toolRef.current; + if (b.tool === 'fog' && b.points && b.points.length > 0) { + return { + id: '__draft__', + type: 'fog', + seed, + createdAtMs, + points: b.points, + radiusN: t.radiusN, + opacity: Math.max(0.05, Math.min(0.6, t.intensity * 0.7)), + lifetimeMs: null, + }; + } + if (b.tool === 'fire' && b.points && b.points.length > 0) { + return { + id: '__draft__', + type: 'fire', + seed, + createdAtMs, + points: b.points, + radiusN: t.radiusN, + opacity: 1, + lifetimeMs: null, + }; + } + if (b.tool === 'rain' && b.points && b.points.length > 0) { + return { + id: '__draft__', + type: 'rain', + seed, + createdAtMs, + points: b.points, + radiusN: t.radiusN, + opacity: Math.max(0.08, Math.min(0.65, t.intensity * 0.85)), + lifetimeMs: null, + }; + } + if (b.tool === 'water' && b.points && b.points.length > 0) { + return { + id: '__draft__', + type: 'water', + seed, + createdAtMs, + points: b.points, + radiusN: t.radiusN, + opacity: Math.max(0.06, Math.min(0.55, t.intensity * 0.72)), + lifetimeMs: null, + }; + } + if (b.tool === 'darkness' && b.points && b.points.length > 0) { + const last = b.points[b.points.length - 1]; + if (last === undefined) return null; + return { + id: '__draft__', + type: 'darkness', + seed, + createdAtMs, + at: { x: last.x, y: last.y }, + intensity: Math.max(0.8, Math.min(1.25, t.intensity * 1.15)), + lifetimeMs: darknessDraftLifeMsRef.current, + }; + } + if (b.tool === 'lightning' && b.startN && b.points && b.points.length > 0) { + const last = b.points[b.points.length - 1]; + if (last === undefined) return null; + return { + id: '__draft__', + type: 'lightning', + seed, + createdAtMs, + start: { x: last.x, y: 0 }, + end: { x: last.x, y: last.y }, + widthN: Math.max(0.014, t.radiusN * 1.15), + intensity: Math.max(1, Math.min(1.4, t.intensity * 1.45)), + lifetimeMs: LIGHTNING_EFFECT_MS, + }; + } + if (b.tool === 'sunbeam' && b.startN && b.points && b.points.length > 0) { + const last = b.points[b.points.length - 1]; + if (last === undefined) return null; + return { + id: '__draft__', + type: 'sunbeam', + seed, + createdAtMs, + start: { x: last.x, y: 0 }, + end: { x: last.x, y: last.y }, + widthN: Math.max(0.012, t.radiusN * 0.95), + intensity: Math.max(0.95, Math.min(1.25, t.intensity * 1.4)), + lifetimeMs: sunbeamDraftLifeMsRef.current, + }; + } + if (b.tool === 'poisonCloud' && b.points && b.points.length > 0) { + const last = b.points[b.points.length - 1]; + if (last === undefined) return null; + return { + id: '__draft__', + type: 'poisonCloud', + seed, + createdAtMs, + at: { x: last.x, y: last.y }, + radiusN: Math.max(0.03, t.radiusN * 0.95), + intensity: Math.max(0.75, Math.min(1.2, t.intensity * 1.15)), + lifetimeMs: poisonDraftLifeMsRef.current, + }; + } + if (b.tool === 'freeze' && b.points && b.points.length > 0) { + const last = b.points[b.points.length - 1]; + if (last === undefined) return null; + return { + id: '__draft__', + type: 'freeze', + seed, + createdAtMs, + at: { x: last.x, y: last.y }, + intensity: Math.max(0.8, Math.min(1.25, t.intensity * 1.15)), + lifetimeMs: freezeDraftLifeMsRef.current, + }; + } + return null; + } + + function pushDraftToPixi(): void { + effectsOverlayRef.current?.setDraft(buildDraftEffectInstance()); + } + + function clearDraftFromPixi(): void { + draftMetaRef.current = null; + effectsOverlayRef.current?.setDraft(null); + } + function scheduleDraftRepaint(): void { if (draftPaintRafRef.current !== 0) return; draftPaintRafRef.current = requestAnimationFrame(() => { draftPaintRafRef.current = 0; - setDraftFxTick((x) => x + 1); + pushDraftToPixi(); const b = brushRef.current; if (b?.tool === 'exploreBrush' && b.points) { void sd.dispatch({ @@ -780,7 +924,7 @@ export function ControlApp() { async function commitStroke(): Promise { if (isVideoPreviewScene) { brushRef.current = null; - setDraftFxTick((x) => x + 1); + clearDraftFromPixi(); return; } if (!fxState) return; @@ -1006,149 +1150,8 @@ export function ControlApp() { playFreezeEffectSound(); } brushRef.current = null; - setDraftFxTick((x) => x + 1); + clearDraftFromPixi(); } - const draftInstance = useMemo(() => { - const b = brushRef.current; - if (!b) return null; - const seed = 12345; - const createdAtMs = Date.now(); - if (b.tool === 'fog' && b.points && b.points.length > 0) { - return { - id: '__draft__', - type: 'fog' as const, - seed, - createdAtMs, - points: b.points, - radiusN: tool.radiusN, - opacity: Math.max(0.05, Math.min(0.6, tool.intensity * 0.7)), - lifetimeMs: null, - }; - } - if (b.tool === 'fire' && b.points && b.points.length > 0) { - return { - id: '__draft__', - type: 'fire' as const, - seed, - createdAtMs, - points: b.points, - radiusN: tool.radiusN, - opacity: 1, - lifetimeMs: null, - }; - } - if (b.tool === 'rain' && b.points && b.points.length > 0) { - return { - id: '__draft__', - type: 'rain' as const, - seed, - createdAtMs, - points: b.points, - radiusN: tool.radiusN, - opacity: Math.max(0.08, Math.min(0.65, tool.intensity * 0.85)), - lifetimeMs: null, - }; - } - if (b.tool === 'water' && b.points && b.points.length > 0) { - return { - id: '__draft__', - type: 'water' as const, - seed, - createdAtMs, - points: b.points, - radiusN: tool.radiusN, - opacity: Math.max(0.06, Math.min(0.55, tool.intensity * 0.72)), - lifetimeMs: null, - }; - } - if (b.tool === 'darkness' && b.points && b.points.length > 0) { - const last = b.points[b.points.length - 1]; - if (last === undefined) return null; - return { - id: '__draft__', - type: 'darkness' as const, - seed, - createdAtMs, - at: { x: last.x, y: last.y }, - intensity: Math.max(0.8, Math.min(1.25, tool.intensity * 1.15)), - lifetimeMs: darknessDraftLifeMs, - }; - } - if (b.tool === 'lightning' && b.startN && b.points && b.points.length > 0) { - const last = b.points[b.points.length - 1]; - if (last === undefined) return null; - return { - id: '__draft__', - type: 'lightning' as const, - seed, - createdAtMs, - start: { x: last.x, y: 0 }, - end: { x: last.x, y: last.y }, - widthN: Math.max(0.014, tool.radiusN * 1.15), - intensity: Math.max(1, Math.min(1.4, tool.intensity * 1.45)), - lifetimeMs: LIGHTNING_EFFECT_MS, - }; - } - if (b.tool === 'sunbeam' && b.startN && b.points && b.points.length > 0) { - const last = b.points[b.points.length - 1]; - if (last === undefined) return null; - return { - id: '__draft__', - type: 'sunbeam' as const, - seed, - createdAtMs, - start: { x: last.x, y: 0 }, - end: { x: last.x, y: last.y }, - widthN: Math.max(0.012, tool.radiusN * 0.95), - intensity: Math.max(0.95, Math.min(1.25, tool.intensity * 1.4)), - lifetimeMs: sunbeamDraftLifeMs, - }; - } - if (b.tool === 'poisonCloud' && b.points && b.points.length > 0) { - const last = b.points[b.points.length - 1]; - if (last === undefined) return null; - return { - id: '__draft__', - type: 'poisonCloud' as const, - seed, - createdAtMs, - at: { x: last.x, y: last.y }, - radiusN: Math.max(0.03, tool.radiusN * 0.95), - intensity: Math.max(0.75, Math.min(1.2, tool.intensity * 1.15)), - lifetimeMs: poisonDraftLifeMs, - }; - } - if (b.tool === 'freeze' && b.points && b.points.length > 0) { - const last = b.points[b.points.length - 1]; - if (last === undefined) return null; - return { - id: '__draft__', - type: 'freeze' as const, - seed, - createdAtMs, - at: { x: last.x, y: last.y }, - intensity: Math.max(0.8, Math.min(1.25, tool.intensity * 1.15)), - lifetimeMs: freezeDraftLifeMs, - }; - } - return null; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [ - draftFxTick, - darknessDraftLifeMs, - freezeDraftLifeMs, - poisonDraftLifeMs, - sunbeamDraftLifeMs, - tool.intensity, - tool.radiusN, - tool.tool, - ]); - - const fxMergedState = useMemo(() => { - if (!fxState) return null; - if (!draftInstance) return fxState; - return { ...fxState, instances: [...fxState.instances, draftInstance] }; - }, [draftInstance, fxState]); return (
@@ -1471,7 +1474,8 @@ export function ControlApp() { {!isVideoPreviewScene ? ( <> x + 1); + pushDraftToPixi(); }} onPointerMove={(e) => { const p = toNPoint(e); @@ -1579,7 +1584,7 @@ export function ControlApp() { void sd.dispatch({ kind: 'draft.set', draft: null }); } brushRef.current = null; - setDraftFxTick((x) => x + 1); + clearDraftFromPixi(); }} /> @@ -1589,58 +1594,88 @@ export function ControlApp() { session?.project && materialsOverlay?.activeMaterialId ? (session.project.materials ?? []).find((m) => m.id === materialsOverlay.activeMaterialId) : undefined; - if (!activeMaterial) return null; - return ( - { - void materialsApi.dispatch({ kind: 'hide' }); - }} - onLayoutChange={(layout) => { - void materialsApi.dispatch({ kind: 'layout.set', layout }); - }} - onZoomAt={(nx, ny) => { - void materialsApi.dispatch({ kind: 'zoomAt', nx, ny }); - }} - /> - ); - })()} - {(() => { const project = session?.project; const activeIds = npcsOverlay?.activeNpcIds ?? []; - if (!project || activeIds.length === 0) return null; - const items = activeIds - .map((id) => { - const npc = (project.npcs ?? []).find((n) => n.id === id); - if (!npc) return null; - return { - npcId: npc.id, - assetId: npc.avatarAssetId, - layout: npcsOverlay?.layouts[id] ?? DEFAULT_NPCS_OVERLAY_LAYOUT, - }; - }) - .filter((x): x is NonNullable => x !== null); - if (items.length === 0) return null; + const npcItems = + project && activeIds.length > 0 + ? activeIds + .map((id) => { + const npc = (project.npcs ?? []).find((n) => n.id === id); + if (!npc) return null; + return { + npcId: npc.id, + assetId: npc.avatarAssetId, + layout: npcsOverlay?.layouts[id] ?? DEFAULT_NPCS_OVERLAY_LAYOUT, + }; + }) + .filter((x): x is NonNullable => x !== null) + : []; + const showMaterial = Boolean(activeMaterial); + const showNpcs = npcItems.length > 0; + if (!showMaterial && !showNpcs) return null; + const closes = [ + ...(showMaterial + ? [ + { + key: 'materials', + label: t('materials.closeOverlay'), + onClose: () => { + void materialsApi.dispatch({ kind: 'hide' }); + }, + }, + ] + : []), + ...(showNpcs + ? [ + { + key: 'npcs', + label: t('npcs.closeOverlay'), + onClose: () => { + void npcsApi.dispatch({ kind: 'hide' }); + }, + }, + ] + : []), + ]; + const materialsZoom = materialsOverlay?.zoomTool ?? null; return ( - { - void npcsApi.dispatch({ kind: 'hide' }); - }} - onLayoutChange={(npcId, layout) => { - void npcsApi.dispatch({ kind: 'layout.set', npcId, layout }); - }} - /> + { + void materialsApi.dispatch({ kind: 'zoomAt', nx, ny }); + }, + } + : {})} + closes={closes} + > + {showMaterial && activeMaterial ? ( + { + void materialsApi.dispatch({ kind: 'layout.set', layout }); + }} + /> + ) : null} + {showNpcs ? ( + { + void npcsApi.dispatch({ kind: 'layout.set', npcId, layout }); + }} + /> + ) : null} + ); })()}
@@ -1708,97 +1743,61 @@ export function ControlApp() { {sceneAudios.map(({ ref, asset }) => { const el = sceneAudioElsRef.current.get(ref.assetId) ?? null; const st = audioStatus('scene', 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 ( -
-
-
{asset.originalName}
-
-
{ref.autoplay ? t('control.modeAuto') : t('control.modeManual')}
-
{ref.loop ? t('control.loop') : t('control.once')}
-
{st.label}
-
-
-
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); - setSceneAudioStateTick((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)); - setSceneAudioStateTick((x) => x + 1); - }} - className={[ - styles.audioScrub, - dur > 0 ? styles.audioScrubPointer : styles.audioScrubDefault, - ].join(' ')} - title={dur > 0 ? t('control.scrubSeek') : t('control.durationUnknown')} - > -
-
-
-
{formatTime(cur)}
-
{dur ? formatTime(dur) : '—:—'}
-
-
-
- - - -
-
+ setSceneAudioStateTick((x) => x + 1)} + onPlay={() => { + if (!el) return; + const m = sceneAudioMetaRef.current.get(ref.assetId) ?? { lastPlayError: null }; + sceneAudioMetaRef.current.set(ref.assetId, { ...m, lastPlayError: null }); + try { + applyAudioGain(el, sceneAudioGainRef.current, ref.assetId); + } catch { + // ignore + } + void el.play().catch(() => { + const mm = + sceneAudioMetaRef.current.get(ref.assetId) ?? + ({ lastPlayError: null } as const); + sceneAudioMetaRef.current.set(ref.assetId, { + ...mm, + lastPlayError: t('control.playFailed'), + }); + setSceneAudioStateTick((x) => x + 1); + }); + }} + onPause={() => { + if (!el) return; + el.pause(); + }} + onStop={() => { + if (!el) return; + el.pause(); + el.currentTime = 0; + setSceneAudioStateTick((x) => x + 1); + }} + /> ); })}
@@ -1814,108 +1813,72 @@ export function ControlApp() { {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 ( -
-
-
{asset.originalName}
-
-
{ref.autoplay ? t('control.modeAuto') : t('control.modeManual')}
-
{ref.loop ? t('control.loop') : t('control.once')}
-
{st.label}
- {!allowCampaignAudio ? ( -
{t('control.pauseSceneMusic')}
- ) : null} -
-
-
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 ? t('control.scrubSeek') : t('control.durationUnknown')} - > -
-
-
-
{formatTime(cur)}
-
{dur ? formatTime(dur) : '—:—'}
-
-
-
- - - -
-
+ {t('control.pauseSceneMusic')}
+ ), + } + : {})} + audioEl={el} + initialGain={readAudioGain(campaignAudioGainRef.current, ref.assetId)} + gainMap={campaignAudioGainRef.current} + playTitle={ + !allowCampaignAudio ? t('control.pauseCampaignTitle') : t('control.transportPlay') + } + playLabel={t('control.transportPlay')} + pauseLabel={t('control.transportPause')} + stopLabel={t('control.transportStop')} + volumeLabel={t('control.volume')} + modeAutoLabel={t('control.modeAuto')} + modeManualLabel={t('control.modeManual')} + loopLabel={t('control.loop')} + onceLabel={t('control.once')} + scrubSeekLabel={t('control.scrubSeek')} + durationUnknownLabel={t('control.durationUnknown')} + onStatusChange={() => setCampaignAudioStateTick((x) => x + 1)} + onPlay={() => { + if (!el) return; + const m = campaignAudioMetaRef.current.get(ref.assetId) ?? { lastPlayError: null }; + campaignAudioMetaRef.current.set(ref.assetId, { ...m, lastPlayError: null }); + try { + if (el.volume === 0) { + applyAudioGain(el, campaignAudioGainRef.current, ref.assetId); + } + } catch { + // ignore + } + void el.play().catch(() => { + const mm = + campaignAudioMetaRef.current.get(ref.assetId) ?? + ({ lastPlayError: null } as const); + campaignAudioMetaRef.current.set(ref.assetId, { + ...mm, + lastPlayError: t('control.playFailed'), + }); + setCampaignAudioStateTick((x) => x + 1); + }); + }} + onPause={() => { + if (!el) return; + el.pause(); + }} + onStop={() => { + if (!el) return; + el.pause(); + el.currentTime = 0; + setCampaignAudioStateTick((x) => x + 1); + }} + /> ); })}
diff --git a/app/renderer/control/ControlAudioCard.tsx b/app/renderer/control/ControlAudioCard.tsx new file mode 100644 index 0000000..c7551b9 --- /dev/null +++ b/app/renderer/control/ControlAudioCard.tsx @@ -0,0 +1,304 @@ +import React, { useEffect, useRef, useState } from 'react'; + +import { Button } from '../shared/ui/controls'; + +import styles from './ControlApp.module.css'; + +function formatTime(sec: number): string { + if (!Number.isFinite(sec) || sec < 0) return '0:00'; + const s = Math.floor(sec); + const m = Math.floor(s / 60); + const r = s % 60; + return `${String(m)}:${String(r).padStart(2, '0')}`; +} + +function clampAudioGain(v: number): number { + if (!Number.isFinite(v)) return 1; + return Math.max(0, Math.min(1, v)); +} + +function VolumeSpeakerIcon({ gain }: { gain: number }) { + if (gain <= 0.001) { + return ( + + + + ); + } + if (gain < 0.5) { + return ( + + + + ); + } + return ( + + + + ); +} + +export type ControlAudioCardProps = { + assetId: string; + name: string; + autoplay: boolean; + loop: boolean; + statusLabel: string; + statusDetail?: string; + extraBadge?: React.ReactNode; + audioEl: HTMLAudioElement | null; + initialGain: number; + gainMap: Map; + playTitle: string; + playLabel: string; + pauseLabel: string; + stopLabel: string; + volumeLabel: string; + modeAutoLabel: string; + modeManualLabel: string; + loopLabel: string; + onceLabel: string; + scrubSeekLabel: string; + durationUnknownLabel: string; + /** Редкий bump родителя (play/pause/error) — не для scrub. */ + onStatusChange: () => void; + onPlay: () => void; + onPause: () => void; + onStop: () => void; +}; + +/** + * Карточка трека: scrub/time обновляются локально (RAF → DOM), без ре-рендера всего ControlApp. + */ +export function ControlAudioCard({ + assetId, + name, + autoplay, + loop, + statusLabel, + statusDetail, + extraBadge, + audioEl, + initialGain, + gainMap, + playTitle, + playLabel, + pauseLabel, + stopLabel, + volumeLabel, + modeAutoLabel, + modeManualLabel, + loopLabel, + onceLabel, + scrubSeekLabel, + durationUnknownLabel, + onStatusChange, + onPlay, + onPause, + onStop, +}: ControlAudioCardProps) { + const scrubRef = useRef(null); + const scrubFillRef = useRef(null); + const curTimeRef = useRef(null); + const durTimeRef = useRef(null); + const [gainUi, setGainUi] = useState(() => clampAudioGain(initialGain)); + const onStatusChangeRef = useRef(onStatusChange); + onStatusChangeRef.current = onStatusChange; + + useEffect(() => { + setGainUi(clampAudioGain(gainMap.get(assetId) ?? initialGain)); + }, [assetId, gainMap, initialGain]); + + useEffect(() => { + if (!audioEl) return; + let raf = 0; + + const paint = (): void => { + const dur = audioEl.duration && Number.isFinite(audioEl.duration) ? audioEl.duration : 0; + const cur = audioEl.currentTime && Number.isFinite(audioEl.currentTime) ? audioEl.currentTime : 0; + const pct = dur > 0 ? Math.max(0, Math.min(1, cur / dur)) : 0; + if (scrubFillRef.current) { + scrubFillRef.current.style.width = `${String(Math.round(pct * 100))}%`; + } + if (curTimeRef.current) curTimeRef.current.textContent = formatTime(cur); + if (durTimeRef.current) durTimeRef.current.textContent = dur ? formatTime(dur) : '—:—'; + if (scrubRef.current) { + scrubRef.current.setAttribute('aria-valuemin', '0'); + scrubRef.current.setAttribute('aria-valuemax', String(dur > 0 ? Math.round(dur) : 0)); + scrubRef.current.setAttribute('aria-valuenow', String(Math.round(cur))); + scrubRef.current.title = dur > 0 ? scrubSeekLabel : durationUnknownLabel; + scrubRef.current.classList.toggle(styles.audioScrubPointer ?? 'audioScrubPointer', dur > 0); + scrubRef.current.classList.toggle(styles.audioScrubDefault ?? 'audioScrubDefault', dur <= 0); + } + }; + + const stopLoop = (): void => { + if (raf !== 0) { + window.cancelAnimationFrame(raf); + raf = 0; + } + }; + + const loopPaint = (): void => { + paint(); + if (!audioEl.paused) { + raf = window.requestAnimationFrame(loopPaint); + } else { + raf = 0; + } + }; + + const startLoop = (): void => { + stopLoop(); + raf = window.requestAnimationFrame(loopPaint); + }; + + const onPlayEv = (): void => { + startLoop(); + onStatusChangeRef.current(); + }; + const onPauseEv = (): void => { + stopLoop(); + paint(); + onStatusChangeRef.current(); + }; + const onEndedEv = (): void => { + stopLoop(); + paint(); + onStatusChangeRef.current(); + }; + const onMetaEv = (): void => { + paint(); + onStatusChangeRef.current(); + }; + + audioEl.addEventListener('play', onPlayEv); + audioEl.addEventListener('pause', onPauseEv); + audioEl.addEventListener('ended', onEndedEv); + audioEl.addEventListener('canplay', onMetaEv); + audioEl.addEventListener('error', onMetaEv); + paint(); + if (!audioEl.paused) startLoop(); + + return () => { + stopLoop(); + audioEl.removeEventListener('play', onPlayEv); + audioEl.removeEventListener('pause', onPauseEv); + audioEl.removeEventListener('ended', onEndedEv); + audioEl.removeEventListener('canplay', onMetaEv); + audioEl.removeEventListener('error', onMetaEv); + }; + }, [audioEl, durationUnknownLabel, scrubSeekLabel]); + + const seekByClientX = (clientX: number): void => { + if (!audioEl || !scrubRef.current) return; + const dur = audioEl.duration && Number.isFinite(audioEl.duration) ? audioEl.duration : 0; + if (!dur) return; + const rect = scrubRef.current.getBoundingClientRect(); + const next = (clientX - rect.left) / Math.max(1, rect.width); + audioEl.currentTime = Math.max(0, Math.min(dur, next * dur)); + const cur = audioEl.currentTime; + const pct = Math.max(0, Math.min(1, cur / dur)); + if (scrubFillRef.current) scrubFillRef.current.style.width = `${String(Math.round(pct * 100))}%`; + if (curTimeRef.current) curTimeRef.current.textContent = formatTime(cur); + }; + + const applyGain = (v: number): void => { + const g = clampAudioGain(v); + gainMap.set(assetId, g); + if (audioEl) { + try { + audioEl.volume = g; + } catch { + // ignore + } + } + setGainUi(g); + }; + + return ( +
+
+
{name}
+
+
{autoplay ? modeAutoLabel : modeManualLabel}
+
{loop ? loopLabel : onceLabel}
+
{statusLabel}
+ {extraBadge} +
+
+
{ + if (!audioEl) return; + const dur = audioEl.duration && Number.isFinite(audioEl.duration) ? audioEl.duration : 0; + if (!dur) return; + if (e.key === 'ArrowLeft') audioEl.currentTime = Math.max(0, audioEl.currentTime - 5); + if (e.key === 'ArrowRight') audioEl.currentTime = Math.min(dur, audioEl.currentTime + 5); + if (curTimeRef.current) curTimeRef.current.textContent = formatTime(audioEl.currentTime); + const pct = Math.max(0, Math.min(1, audioEl.currentTime / dur)); + if (scrubFillRef.current) scrubFillRef.current.style.width = `${String(Math.round(pct * 100))}%`; + }} + onClick={(e) => seekByClientX(e.clientX)} + > +
+
+
+
0:00
+
—:—
+
+
+
+
+ + + +
+
+ + + + applyGain(Number(e.currentTarget.value))} + /> +
+
+
+ ); +} diff --git a/app/renderer/control/controlApp.audioPerf.networkRegression.test.ts b/app/renderer/control/controlApp.audioPerf.networkRegression.test.ts new file mode 100644 index 0000000..60c8491 --- /dev/null +++ b/app/renderer/control/controlApp.audioPerf.networkRegression.test.ts @@ -0,0 +1,27 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; + +const here = path.dirname(fileURLToPath(import.meta.url)); + +/** Регресс: RAF в ControlApp бампил оба audio-tick на каждом кадре → полный ре-рендер пульта. */ +void test('ControlApp: нет per-frame RAF setState для аудио scrub', () => { + const app = fs.readFileSync(path.join(here, 'ControlApp.tsx'), 'utf8'); + const card = fs.readFileSync(path.join(here, 'ControlAudioCard.tsx'), 'utf8'); + + assert.doesNotMatch(app, /\banyPlaying\b/); + // Старый паттерн: RAF tick → оба set*AudioStateTick. + assert.doesNotMatch( + app, + /const tick = \(\) => \{\s*setSceneAudioStateTick/, + 'корневой RAF-тик аудио удалён', + ); + assert.doesNotMatch(app, /requestAnimationFrame\s*\(\s*tick\s*\)/); + + assert.ok(app.includes('ControlAudioCard')); + assert.ok(card.includes('requestAnimationFrame'), 'scrub крутится локально в карточке'); + assert.ok(card.includes('scrubFillRef'), 'прогресс пишется в DOM, не через setState корня'); + assert.ok(card.includes('setGainUi'), 'громкость обновляет только карточку'); +}); diff --git a/app/renderer/control/controlApp.brushPerf.networkRegression.test.ts b/app/renderer/control/controlApp.brushPerf.networkRegression.test.ts new file mode 100644 index 0000000..9e58ec9 --- /dev/null +++ b/app/renderer/control/controlApp.brushPerf.networkRegression.test.ts @@ -0,0 +1,35 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; + +const here = path.dirname(fileURLToPath(import.meta.url)); + +/** Регресс: scheduleDraftRepaint бампил draftFxTick → полный ре-рендер ControlApp на кадр штриха. */ +void test('ControlApp: draft кисти без корневого draftFxTick', () => { + const app = fs.readFileSync(path.join(here, 'ControlApp.tsx'), 'utf8'); + const pixi = fs.readFileSync( + path.join(here, '../shared/effects/PxiEffectsOverlay.tsx'), + 'utf8', + ); + + assert.doesNotMatch(app, /\bdraftFxTick\b/); + assert.doesNotMatch(app, /\bsetDraftFxTick\b/); + assert.doesNotMatch(app, /\bfxMergedState\b/); + + assert.ok(app.includes('scheduleDraftRepaint')); + assert.ok(app.includes('pushDraftToPixi')); + assert.ok(app.includes('effectsOverlayRef')); + assert.match(app, /scheduleDraftRepaint[\s\S]*?pushDraftToPixi\(\)/); + assert.doesNotMatch( + app, + /scheduleDraftRepaint[\s\S]*?setDraftFxTick/, + 'RAF draft не трогает React state корня', + ); + + assert.ok(pixi.includes('PixiEffectsOverlayHandle')); + assert.ok(pixi.includes('setDraft')); + assert.ok(pixi.includes('mergeDraftState')); + assert.ok(pixi.includes('forwardRef')); +}); diff --git a/app/renderer/control/controlApp.effectsPanel.test.ts b/app/renderer/control/controlApp.effectsPanel.test.ts index 85b83b4..8a2bf9d 100644 --- a/app/renderer/control/controlApp.effectsPanel.test.ts +++ b/app/renderer/control/controlApp.effectsPanel.test.ts @@ -152,6 +152,31 @@ void test('ControlApp: музыка разделена на сцену и кам assert.match(src, /pause campaign\./i); }); +void test('ControlApp: у каждой аудиозаписи есть регулятор громкости под транспортом', () => { + const src = readControlApp(); + const card = fs.readFileSync(path.join(here, 'ControlAudioCard.tsx'), 'utf8'); + const css = readControlAppCss(); + assert.ok(src.includes('ControlAudioCard')); + assert.ok(src.includes("t('control.volume')")); + assert.ok(src.includes('sceneAudioGainRef')); + assert.ok(src.includes('campaignAudioGainRef')); + assert.ok(src.includes('applyAudioGain')); + assert.ok(card.includes('VolumeSpeakerIcon')); + assert.ok(card.includes('styles.audioVolume')); + assert.ok(card.includes('styles.audioVolumeRow')); + assert.match(css, /\.audioControls[\s\S]*?flex-direction:\s*column/); + assert.match(css, /\.audioVolumeRow\b/); + assert.match(css, /\.audioVolumeIcon\b/); + assert.match(css, /\.audioVolume\b/); +}); + +void test('ControlApp: весь контент скроллится в окне, отступы сверху и снизу равны', () => { + const css = readControlAppCss(); + assert.match(css, /\.page\s*\{[^}]*padding:\s*16px/s); + assert.match(css, /\.page\s*\{[^}]*overflow:\s*auto/s); + assert.doesNotMatch(css, /\.rightStack\s*\{[^}]*overflow-y:\s*auto/s); +}); + void test('ControlApp: загрузка камп. аудио — useEffect зависит только от api и campaignAudioSpecKey', () => { const src = readControlApp(); const re = /\/\/ Campaign elements:[\s\S]*?useEffect\(\(\) => \{[\s\S]*?\}\s*,\s*\[([^\]]*)\]\s*\)\s*;/; diff --git a/app/renderer/editor/i18n/editorMessages.ts b/app/renderer/editor/i18n/editorMessages.ts index 13928a6..2f0db99 100644 --- a/app/renderer/editor/i18n/editorMessages.ts +++ b/app/renderer/editor/i18n/editorMessages.ts @@ -588,6 +588,7 @@ export const EDITOR_MESSAGES: Record> = { 'control.transportPlay': 'Воспроизведение', 'control.transportPause': 'Пауза', 'control.transportStop': 'Стоп', + 'control.volume': 'Громкость', }, en: { 'common.close': 'Close', @@ -1125,6 +1126,7 @@ export const EDITOR_MESSAGES: Record> = { 'control.transportPlay': 'Play', 'control.transportPause': 'Pause', 'control.transportStop': 'Stop', + 'control.volume': 'Volume', }, }; diff --git a/app/renderer/editor/state/projectState.ts b/app/renderer/editor/state/projectState.ts index 8762884..4bf781b 100644 --- a/app/renderer/editor/state/projectState.ts +++ b/app/renderer/editor/state/projectState.ts @@ -19,6 +19,7 @@ import type { SceneId, } from '../../../shared/types'; import { getDndApi } from '../../shared/dndApi'; +import { invalidateAssetUrlCache } from '../../shared/useAssetImageUrl'; type ProjectSummary = { id: ProjectId; name: string; updatedAt: string; fileName: string }; @@ -277,6 +278,8 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts) projectDataEpochRef.current += 1; const epoch = projectDataEpochRef.current; openInFlightRef.current = null; + // URL ассетов зависят от открытого проекта — сбрасываем renderer-кэш. + invalidateAssetUrlCache(); const job = (async () => { setState((s) => ({ ...s, openingProjectId: id })); @@ -308,6 +311,7 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts) const closeProject = async () => { projectDataEpochRef.current += 1; openInFlightRef.current = null; + invalidateAssetUrlCache(); try { await api.invoke(ipcChannels.project.close, {}); } finally { diff --git a/app/renderer/shared/PresentationView.tsx b/app/renderer/shared/PresentationView.tsx index 2a0e7ba..55f3a23 100644 --- a/app/renderer/shared/PresentationView.tsx +++ b/app/renderer/shared/PresentationView.tsx @@ -12,6 +12,7 @@ import { MaterialOverlay } from './materials/MaterialOverlay'; import { useMaterialsOverlayState } from './materials/useMaterialsOverlayState'; import { NpcsSceneOverlay } from './npcs/NpcsSceneOverlay'; import { useNpcsOverlayState } from './npcs/useNpcsOverlayState'; +import { SceneOverlayHost } from './sceneOverlay/SceneOverlayHost'; import styles from './PresentationView.module.css'; import { RotatedImage } from './RotatedImage'; import { useAssetUrl } from './useAssetImageUrl'; @@ -164,13 +165,16 @@ export function PresentationView({ {showEffects && scene?.previewAssetType === 'image' && scene.darkenScene && contentRect ? ( ) : null} - {activeMaterial ? ( - - ) : null} - {activeNpcItems.length > 0 ? : null} + 0}> + {activeMaterial ? ( + + ) : null} + {activeNpcItems.length > 0 ? : null} + {showTitle ? (
diff --git a/app/renderer/shared/effects/PxiEffectsOverlay.pointer.test.ts b/app/renderer/shared/effects/PxiEffectsOverlay.pointer.test.ts index 0d11e18..6da81f6 100644 --- a/app/renderer/shared/effects/PxiEffectsOverlay.pointer.test.ts +++ b/app/renderer/shared/effects/PxiEffectsOverlay.pointer.test.ts @@ -15,3 +15,24 @@ void test('PxiEffectsOverlay: ограничение FPS тикера для н const src = fs.readFileSync(path.join(here, 'PxiEffectsOverlay.tsx'), 'utf8'); assert.ok(src.includes('app.ticker.maxFPS')); }); + +void test('PxiEffectsOverlay: imperative setDraft для кисти без React state', () => { + const src = fs.readFileSync(path.join(here, 'PxiEffectsOverlay.tsx'), 'utf8'); + assert.ok(src.includes('setDraft')); + assert.ok(src.includes('useImperativeHandle')); + assert.ok(src.includes('mergeDraftState')); +}); + +void test('PxiEffectsOverlay: lazy VFX packs + idle ticker stop', () => { + const src = fs.readFileSync(path.join(here, 'PxiEffectsOverlay.tsx'), 'utf8'); + assert.ok(src.includes('ensureVfxPacksForState')); + assert.ok(src.includes('collectNeededVfxPacks')); + assert.ok(src.includes('syncTickerForState')); + assert.ok(src.includes('app.ticker.stop')); + // Eager preload всех наборов при init убран. + assert.doesNotMatch( + src, + /syncNodes\([\s\S]*?stateRef\.current[\s\S]*?\);\s*void preloadLightningVfxFrameTextures\(pixi\);\s*void preloadElectricAccentFrameTextures\(pixi\);\s*void preloadFogVfxFrameTextures\(pixi\);/, + ); + assert.ok(src.includes('Lazy VFX')); +}); diff --git a/app/renderer/shared/effects/PxiEffectsOverlay.tsx b/app/renderer/shared/effects/PxiEffectsOverlay.tsx index e072236..fa23279 100644 --- a/app/renderer/shared/effects/PxiEffectsOverlay.tsx +++ b/app/renderer/shared/effects/PxiEffectsOverlay.tsx @@ -1,9 +1,140 @@ -import React, { useEffect, useMemo, useRef } from 'react'; +import React, { forwardRef, useEffect, useImperativeHandle, useMemo, useRef } from 'react'; -import type { EffectsState, EffectInstance } from '../../../shared/types/effects'; +import type { + EffectInstance, + EffectInstanceType, + EffectsState, + EffectToolType, +} from '../../../shared/types/effects'; import styles from './PxiEffectsOverlay.module.css'; +export type PixiEffectsOverlayHandle = { + /** Draft-штрих без React re-render родителя; null — сброс. */ + setDraft: (instance: EffectInstance | null) => void; +}; + +/** Наборы кадровых VFX — подгружаем только по tool / живым instances. */ +type VfxFramePack = + | 'fog' + | 'fire' + | 'rain' + | 'water' + | 'lightning' + | 'sunbeam' + | 'poisonCloud'; + +function mergeDraftState( + state: EffectsState | null, + draft: EffectInstance | null, +): EffectsState | null { + if (!draft) return state; + if (!state) { + return { + revision: 0, + serverNowMs: Date.now(), + tool: { tool: 'fog', radiusN: 0.05, intensity: 1 }, + instances: [draft], + }; + } + const rest = state.instances.filter((i) => i.id !== '__draft__'); + return { ...state, instances: [...rest, draft] }; +} + +function vfxPacksForTool(tool: EffectToolType): readonly VfxFramePack[] { + switch (tool) { + case 'fog': + return ['fog']; + case 'fire': + return ['fire']; + case 'rain': + return ['rain']; + case 'water': + return ['water']; + case 'lightning': + return ['lightning']; + case 'sunbeam': + return ['sunbeam']; + case 'poisonCloud': + return ['poisonCloud']; + default: + return []; + } +} + +function vfxPacksForInstanceType(type: EffectInstanceType): readonly VfxFramePack[] { + switch (type) { + case 'fog': + return ['fog']; + case 'fire': + return ['fire']; + case 'rain': + return ['rain']; + case 'water': + return ['water']; + case 'lightning': + return ['lightning']; + case 'sunbeam': + return ['sunbeam']; + case 'poisonCloud': + return ['poisonCloud']; + default: + return []; + } +} + +function collectNeededVfxPacks(state: EffectsState | null): VfxFramePack[] { + if (!state) return []; + const packs = new Set(); + for (const p of vfxPacksForTool(state.tool.tool)) packs.add(p); + for (const inst of state.instances) { + for (const p of vfxPacksForInstanceType(inst.type)) packs.add(p); + } + return [...packs]; +} + +function preloadVfxFramePack(pixi: any, pack: VfxFramePack): void { + switch (pack) { + case 'fog': + void preloadFogVfxFrameTextures(pixi); + break; + case 'fire': + void preloadGroundFireVfxFrameTextures(pixi); + break; + case 'rain': + void preloadRainVfxFrameTextures(pixi); + break; + case 'water': + void preloadWaterVfxFrameTextures(pixi); + break; + case 'lightning': + void preloadLightningVfxFrameTextures(pixi); + void preloadElectricAccentFrameTextures(pixi); + break; + case 'sunbeam': + void preloadPulseDischargeFrameTextures(pixi); + break; + case 'poisonCloud': + void preloadDustBurstFrameTextures(pixi); + break; + default: { + const _exhaustive: never = pack; + void _exhaustive; + } + } +} + +function ensureVfxPacksForState(pixi: any, state: EffectsState | null): void { + if (!pixi) return; + for (const pack of collectNeededVfxPacks(state)) { + preloadVfxFramePack(pixi, pack); + } +} + +function effectsHaveWork(state: EffectsState | null): boolean { + return Boolean(state && state.instances.length > 0); +} + const LIGHTNING_VFX_FRAME_COUNT = 19; const LIGHTNING_VFX_FRAME_ASPECT = 420 / 473; const LIGHTNING_VFX_STRIKE_MS = 320; @@ -52,18 +183,81 @@ type Props = { * - Pixi `Application` — это WebGL-рендерер + тикер. * - Мы держим один `Application` на компонент, и при изменении `state` просто перерисовываем сцену. * - Вариант A: рисуем "инстансы эффектов" (данные), а не пиксели. + * - Draft кисти идёт через `setDraft` (imperative), без re-render ControlApp. */ -export function PixiEffectsOverlay({ state, interactive = false, style, viewport }: Props) { +export const PixiEffectsOverlay = forwardRef(function PixiEffectsOverlay( + { state, interactive = false, style, viewport }, + ref, +) { const hostRef = useRef(null); const appRef = useRef(null); const rootRef = useRef(null); const pixiRef = useRef(null); const nodesRef = useRef>(new Map()); + const committedStateRef = useRef(null); + const draftRef = useRef(null); const stateRef = useRef(null); const timeOffsetRef = useRef(0); const sizeRef = useRef<{ w: number; h: number }>({ w: 1, h: 1 }); const viewportRef = useRef<{ x: number; y: number; w: number; h: number }>({ x: 0, y: 0, w: 1, h: 1 }); const viewportProvidedRef = useRef(false); + /** null = ещё не синхронизировали с Pixi (ticker по умолчанию бежит). */ + const tickerWantedRef = useRef(null); + + const syncTickerForState = (merged: EffectsState | null): void => { + const app = appRef.current; + if (!app?.ticker) return; + const want = effectsHaveWork(merged); + if (tickerWantedRef.current === want) return; + tickerWantedRef.current = want; + if (want) { + try { + app.ticker.start(); + } catch { + /* ignore */ + } + return; + } + const root = rootRef.current; + if (root) { + root.x = 0; + root.y = 0; + } + try { + app.ticker.stop(); + } catch { + /* ignore */ + } + }; + + const applyMergedState = (committed: EffectsState | null, draft: EffectInstance | null): void => { + committedStateRef.current = committed; + draftRef.current = draft; + const merged = mergeDraftState(committed, draft); + stateRef.current = merged; + if (merged) { + timeOffsetRef.current = merged.serverNowMs - Date.now(); + } + const pixi = pixiRef.current; + const root = rootRef.current; + if (!pixi || !root) { + syncTickerForState(merged); + return; + } + ensureVfxPacksForState(pixi, merged); + syncNodes(pixi, root, nodesRef.current, merged, sizeRef.current, viewportRef.current); + syncTickerForState(merged); + }; + + useImperativeHandle( + ref, + () => ({ + setDraft: (instance) => { + applyMergedState(committedStateRef.current, instance); + }, + }), + [], + ); /** Снижаем resolution на HiDPI — меньше пикселей в WebGL, визуально ок для оверлея эффектов. */ const dpr = useMemo(() => Math.min(1.5, window.devicePixelRatio || 1), []); @@ -121,32 +315,28 @@ export function PixiEffectsOverlay({ state, interactive = false, style, viewport if (!viewportProvidedRef.current) { viewportRef.current = { x: 0, y: 0, w: sizeRef.current.w, h: sizeRef.current.h }; } + // Lazy VFX: только pack'и для текущего tool / уже размещённых instances (не все наборы сразу). + ensureVfxPacksForState(pixi, stateRef.current); syncNodes(pixi, root, nodesRef.current, stateRef.current, sizeRef.current, viewportRef.current); - void preloadLightningVfxFrameTextures(pixi); - void preloadElectricAccentFrameTextures(pixi); - void preloadFogVfxFrameTextures(pixi); - void preloadGroundFireVfxFrameTextures(pixi); - void preloadRainVfxFrameTextures(pixi); - void preloadWaterVfxFrameTextures(pixi); - void preloadPulseDischargeFrameTextures(pixi); - void preloadDustBurstFrameTextures(pixi); // Animation loop: на каждом кадре обновляем свойства инстансов (alpha/дрейф/фликер). + // В idle (нет instances/draft) ticker останавливается — см. syncTickerForState. app.ticker.add(() => { const s = stateRef.current; - if (!s) return; + if (!s || s.instances.length === 0) return; const nowMs = Date.now() + timeOffsetRef.current; animateNodes(pixi, nodesRef.current, s, nowMs, sizeRef.current, viewportRef.current); // Лёгкое “потряхивание” сцены в момент удара молнии. // Делаем через смещение корневого контейнера, чтобы не вмешиваться в рендерер/камера-логику. - const root = rootRef.current; - if (root) { + const rootNode = rootRef.current; + if (rootNode) { const { x, y } = computeSceneShake(s, nowMs, sizeRef.current); - root.x = x; - root.y = y; + rootNode.x = x; + rootNode.y = y; } }); + syncTickerForState(stateRef.current); cleanup = () => ro.disconnect(); } catch (e) { @@ -181,16 +371,7 @@ export function PixiEffectsOverlay({ state, interactive = false, style, viewport }, [interactive]); useEffect(() => { - const app = appRef.current; - const root = rootRef.current; - if (!app || !root) return; - stateRef.current = state; - if (state) { - timeOffsetRef.current = state.serverNowMs - Date.now(); - } - const pixi = pixiRef.current; - if (!pixi) return; - syncNodes(pixi, root, nodesRef.current, state, sizeRef.current, viewportRef.current); + applyMergedState(state, draftRef.current); }, [state]); useEffect(() => { @@ -207,7 +388,7 @@ export function PixiEffectsOverlay({ state, interactive = false, style, viewport const hostClass = [styles.host, interactive ? styles.hostInteractive : styles.hostPassthrough].join(' '); return
; -} +}); function syncNodes( pixi: any, @@ -236,6 +417,19 @@ function syncNodes( const sig = instanceSig(inst, viewport); const existing = nodes.get(inst.id); if (existing && (existing as any).__sig === sig) continue; + // Water draft: перерисовываем Graphics in-place (без destroy/create на каждую точку). + if ( + existing && + inst.id === '__draft__' && + inst.type === 'water' && + (existing as any).__fx?.kind === 'waterDraft' + ) { + const halfW = Math.max(1.5, inst.radiusN * Math.min(viewport.w, viewport.h)); + redrawWaterDraft((existing as any).__fx.g, inst, viewport, halfW); + existing.alpha = Math.max(0.35, Math.min(0.95, inst.opacity * 1.1)); + (existing as any).__sig = sig; + continue; + } if (existing) { const fx = (existing as any).__fx; fx?.video?.pause?.(); diff --git a/app/renderer/shared/materials/MaterialOverlay.module.css b/app/renderer/shared/materials/MaterialOverlay.module.css index 4c6aa99..80232ea 100644 --- a/app/renderer/shared/materials/MaterialOverlay.module.css +++ b/app/renderer/shared/materials/MaterialOverlay.module.css @@ -12,6 +12,15 @@ pointer-events: none; } +/** Общий host: клики проходят сквозь dim к сцене; кадры/кнопки ловят сами. */ +.hostHitThrough { + pointer-events: none; +} + +.captureZoom { + pointer-events: auto; +} + .cursorZoomIn { cursor: zoom-in; } @@ -89,11 +98,22 @@ cursor: nwse-resize; } -.close { +.closeStack { position: absolute; top: 14px; right: 14px; z-index: 3; + display: flex; + flex-direction: column; + gap: 8px; + pointer-events: none; +} + +.close { + position: relative; + top: auto; + right: auto; + z-index: 3; width: 36px; height: 36px; border: none; @@ -105,12 +125,20 @@ cursor: pointer; display: grid; place-items: center; + pointer-events: auto; } .close:hover { background: rgba(24, 24, 32, 0.9); } +/** Standalone-оверлей (без host): одна кнопка в углу. */ +.root > .close { + position: absolute; + top: 14px; + right: 14px; +} + .frameRotate { position: absolute; left: 50%; diff --git a/app/renderer/shared/materials/MaterialOverlay.tsx b/app/renderer/shared/materials/MaterialOverlay.tsx index 44f49ef..74ed02e 100644 --- a/app/renderer/shared/materials/MaterialOverlay.tsx +++ b/app/renderer/shared/materials/MaterialOverlay.tsx @@ -2,6 +2,7 @@ import React, { useEffect, useRef, useState } from 'react'; import type { AssetId, MaterialsOverlayLayout, MaterialsZoomTool, NpcsZoomTool } from '../../../shared/types'; import { DEFAULT_MATERIALS_OVERLAY_LAYOUT } from '../../../shared/types'; +import { useSceneOverlayView } from '../sceneOverlay/SceneOverlayViewContext'; import { useAssetUrl } from '../useAssetImageUrl'; import styles from './MaterialOverlay.module.css'; @@ -19,6 +20,8 @@ type MaterialOverlayProps = { rotateLabel?: string; onLayoutChange?: (layout: MaterialsOverlayLayout) => void; onZoomAt?: (nx: number, ny: number) => void; + /** Без собственного root/dim — внутри `SceneOverlayHost`. */ + embedded?: boolean; }; function RotateIcon() { @@ -95,11 +98,21 @@ export function MaterialOverlay({ rotateLabel = 'Rotate', onLayoutChange, onZoomAt, + embedded = false, }: MaterialOverlayProps) { const url = useAssetUrl(assetId); - const rootRef = useRef(null); + const host = useSceneOverlayView(); + const localRootRef = useRef(null); + const rootRef = embedded && host ? host.rootRef : localRootRef; const [natural, setNatural] = useState<{ w: number; h: number }>({ w: 1600, h: 900 }); - const [view, setView] = useState({ w: 1, h: 1 }); + const [localView, setLocalView] = useState({ w: 1, h: 1 }); + /** Локальный layout + IPC не чаще 1/frame (лайв, без спама pointermove). */ + const [draftLayout, setDraftLayout] = useState(null); + const pendingLayoutRef = useRef(null); + const draftRafRef = useRef(0); + const onLayoutChangeRef = useRef(onLayoutChange); + onLayoutChangeRef.current = onLayoutChange; + const view = embedded && host ? host.view : localView; const dragRef = useRef< | { mode: 'move'; startX: number; startY: number; origin: MaterialsOverlayLayout } | { @@ -124,14 +137,33 @@ export function MaterialOverlay({ >(null); useEffect(() => { - const el = rootRef.current; + if (embedded) return; + const el = localRootRef.current; if (!el) return; - const sync = () => setView({ w: el.clientWidth, h: el.clientHeight }); + let raf = 0; + const sync = () => { + if (raf !== 0) return; + raf = window.requestAnimationFrame(() => { + raf = 0; + const w = Math.max(1, el.clientWidth); + const h = Math.max(1, el.clientHeight); + setLocalView((prev) => (prev.w === w && prev.h === h ? prev : { w, h })); + }); + }; sync(); const ro = new ResizeObserver(sync); ro.observe(el); - return () => ro.disconnect(); - }, [url]); + return () => { + ro.disconnect(); + if (raf !== 0) window.cancelAnimationFrame(raf); + }; + }, [embedded, url]); + + useEffect(() => { + if (dragRef.current) return; + setDraftLayout(null); + pendingLayoutRef.current = null; + }, [layout]); if (!assetId || !url) return null; @@ -139,7 +171,7 @@ export function MaterialOverlay({ const zoomCursor = zoomTool === 'zoomIn' ? styles.cursorZoomIn : zoomTool === 'zoomOut' ? styles.cursorZoomOut : ''; - const effectiveLayout = layout ?? DEFAULT_MATERIALS_OVERLAY_LAYOUT; + const effectiveLayout = draftLayout ?? layout ?? DEFAULT_MATERIALS_OVERLAY_LAYOUT; const rotationDeg = effectiveLayout.rotationDeg ?? 0; const base = nextBaseSize(view.w, view.h, natural.w, natural.h); const w = base.w * effectiveLayout.scale; @@ -167,6 +199,18 @@ export function MaterialOverlay({ }; }; + const publishDraft = (next: MaterialsOverlayLayout): void => { + pendingLayoutRef.current = next; + if (draftRafRef.current !== 0) return; + draftRafRef.current = window.requestAnimationFrame(() => { + draftRafRef.current = 0; + const pending = pendingLayoutRef.current; + if (!pending) return; + setDraftLayout(pending); + onLayoutChangeRef.current?.(pending); + }); + }; + const onPointerMove = (e: PointerEvent) => { const drag = dragRef.current; if (!drag || !onLayoutChange) return; @@ -174,7 +218,7 @@ export function MaterialOverlay({ if (drag.mode === 'rotate') { const angle = pointerAngleDeg(drag.centerX, drag.centerY, e.clientX, e.clientY); const delta = shortestAngleDelta(drag.startPointerAngle, angle); - onLayoutChange({ + publishDraft({ ...drag.origin, rotationDeg: drag.origin.rotationDeg + delta, }); @@ -188,7 +232,7 @@ export function MaterialOverlay({ if (drag.mode === 'move') { const dx = (e.clientX - drag.startX) / Math.max(1, r.width); const dy = (e.clientY - drag.startY) / Math.max(1, r.height); - onLayoutChange({ + publishDraft({ ...drag.origin, cx: drag.origin.cx + dx, cy: drag.origin.cy + dy, @@ -247,7 +291,7 @@ export function MaterialOverlay({ const localCenterY = nextTop + hh / 2; const screenOffset = localToScreenOffset(localCenterX, localCenterY, origin.rotationDeg ?? 0); - onLayoutChange({ + publishDraft({ ...origin, cx: origin.cx + screenOffset.x / Math.max(1, viewW), cy: origin.cy + screenOffset.y / Math.max(1, viewH), @@ -259,6 +303,15 @@ export function MaterialOverlay({ dragRef.current = null; window.removeEventListener('pointermove', onPointerMove); window.removeEventListener('pointerup', endDrag); + if (draftRafRef.current !== 0) { + window.cancelAnimationFrame(draftRafRef.current); + draftRafRef.current = 0; + } + const finalLayout = pendingLayoutRef.current; + if (finalLayout) { + setDraftLayout(finalLayout); + onLayoutChangeRef.current?.(finalLayout); + } }; const startDrag = (e: React.PointerEvent, mode: 'move' | 'resize', corner?: Corner) => { @@ -306,9 +359,70 @@ export function MaterialOverlay({ window.addEventListener('pointerup', endDrag); }; + const frame = ( +
{ + if (zoomTool) return; + startDrag(e, 'move'); + }} + > + { + const img = e.currentTarget; + setNatural({ w: img.naturalWidth || 1, h: img.naturalHeight || 1 }); + }} + /> + {editable && !zoomTool + ? (['nw', 'ne', 'sw', 'se'] as const).map((corner) => ( + + ) : null} +
+ ); + + if (embedded) { + return frame; + } + return (
-
{ - if (zoomTool) return; - startDrag(e, 'move'); - }} - > - { - const img = e.currentTarget; - setNatural({ w: img.naturalWidth || 1, h: img.naturalHeight || 1 }); - }} - /> - {editable && !zoomTool - ? (['nw', 'ne', 'sw', 'se'] as const).map((corner) => ( - - ) : null} -
+ {frame} {showClose ? ( + ))} +
+ ) : null} +
+ + ); +} diff --git a/app/renderer/shared/sceneOverlay/SceneOverlayViewContext.tsx b/app/renderer/shared/sceneOverlay/SceneOverlayViewContext.tsx new file mode 100644 index 0000000..18395c6 --- /dev/null +++ b/app/renderer/shared/sceneOverlay/SceneOverlayViewContext.tsx @@ -0,0 +1,12 @@ +import React, { createContext, useContext } from 'react'; + +export type SceneOverlayViewContextValue = { + rootRef: React.RefObject; + view: { w: number; h: number }; +}; + +export const SceneOverlayViewContext = createContext(null); + +export function useSceneOverlayView(): SceneOverlayViewContextValue | null { + return useContext(SceneOverlayViewContext); +} diff --git a/app/renderer/shared/sceneOverlay/sceneOverlayHost.test.ts b/app/renderer/shared/sceneOverlay/sceneOverlayHost.test.ts new file mode 100644 index 0000000..baed15e --- /dev/null +++ b/app/renderer/shared/sceneOverlay/sceneOverlayHost.test.ts @@ -0,0 +1,79 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const root = path.resolve(here, '..', '..', '..', '..'); + +void test('SceneOverlayHost: один dim для materials + npcs в Control и Presentation', () => { + const host = fs.readFileSync(path.join(here, 'SceneOverlayHost.tsx'), 'utf8'); + const control = fs.readFileSync(path.join(root, 'app/renderer/control/ControlApp.tsx'), 'utf8'); + const presentation = fs.readFileSync(path.join(root, 'app/renderer/shared/PresentationView.tsx'), 'utf8'); + const css = fs.readFileSync( + path.join(root, 'app/renderer/shared/materials/MaterialOverlay.module.css'), + 'utf8', + ); + + assert.ok(host.includes('styles.dim')); + assert.ok(host.includes('hostHitThrough')); + assert.ok(control.includes('SceneOverlayHost')); + assert.ok(control.includes('embedded')); + assert.ok(presentation.includes('SceneOverlayHost')); + assert.ok(presentation.includes('embedded')); + + // Control/Presentation монтируют оверлеи только как embedded внутри host. + assert.ok(control.includes(' { + const material = fs.readFileSync( + path.join(root, 'app/renderer/shared/materials/MaterialOverlay.tsx'), + 'utf8', + ); + const npcs = fs.readFileSync(path.join(root, 'app/renderer/shared/npcs/NpcsSceneOverlay.tsx'), 'utf8'); + assert.ok(material.includes('embedded')); + assert.ok(material.includes('useSceneOverlayView')); + assert.ok(npcs.includes('embedded')); + assert.ok(npcs.includes('useSceneOverlayView')); + // В embedded-ветке не рисуем второй dim. + assert.match(material, /if \(embedded\) \{\s*return frame;/); + assert.match(npcs, /if \(embedded\) \{\s*return <>\{frames\}<\/>;/); +}); + +void test('overlays: layout IPC live через rAF coalesce, RO host coalesced', () => { + const host = fs.readFileSync(path.join(here, 'SceneOverlayHost.tsx'), 'utf8'); + const material = fs.readFileSync( + path.join(root, 'app/renderer/shared/materials/MaterialOverlay.tsx'), + 'utf8', + ); + const npcs = fs.readFileSync(path.join(root, 'app/renderer/shared/npcs/NpcsSceneOverlay.tsx'), 'utf8'); + + assert.ok(host.includes('requestAnimationFrame')); + assert.ok(host.includes('prev.w === w && prev.h === h')); + + // Лайв: publishDraft шлёт onLayoutChange внутри rAF (не на каждый pointermove). + assert.ok(material.includes('publishDraft')); + assert.ok(material.includes('onLayoutChangeRef')); + assert.match(material, /requestAnimationFrame\(\(\) => \{[\s\S]*?onLayoutChangeRef\.current\?\.\(pending\)/); + assert.match( + material, + /if \(drag\.mode === 'move'\) \{[\s\S]*?publishDraft\(\{[\s\S]*?return;\s*\}/, + ); + + assert.ok(npcs.includes('publishDraft')); + assert.ok(npcs.includes('onLayoutChangeRef')); + assert.match( + npcs, + /requestAnimationFrame\(\(\) => \{[\s\S]*?onLayoutChangeRef\.current\?\.\(item\.npcId, pending\)/, + ); + assert.match( + npcs, + /if \(drag\.mode === 'move'\) \{[\s\S]*?publishDraft\(\{[\s\S]*?return;\s*\}/, + ); +}); diff --git a/app/renderer/shared/useAssetImageUrl.cache.test.ts b/app/renderer/shared/useAssetImageUrl.cache.test.ts new file mode 100644 index 0000000..00a7fb5 --- /dev/null +++ b/app/renderer/shared/useAssetImageUrl.cache.test.ts @@ -0,0 +1,24 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; + +const here = path.dirname(fileURLToPath(import.meta.url)); + +/** Регресс: списки сцен/материалов не должны дёргать assetFileUrl на каждый mount одного id. */ +void test('useAssetUrl: module cache + invalidate при смене проекта', () => { + const src = fs.readFileSync(path.join(here, 'useAssetImageUrl.ts'), 'utf8'); + const projectState = fs.readFileSync(path.join(here, '../editor/state/projectState.ts'), 'utf8'); + + assert.ok(src.includes('urlCache')); + assert.ok(src.includes('invalidateAssetUrlCache')); + assert.ok(src.includes('peekAssetUrlCache')); + assert.ok(src.includes('session.stateChanged')); + assert.match(src, /urlCache\.set\(id,\s*r\.url\)/); + assert.match(src, /peekAssetUrlCache\(id\)/); + + assert.ok(projectState.includes('invalidateAssetUrlCache')); + assert.match(projectState, /openProject[\s\S]*?invalidateAssetUrlCache\(\)/); + assert.match(projectState, /closeProject[\s\S]*?invalidateAssetUrlCache\(\)/); +}); diff --git a/app/renderer/shared/useAssetImageUrl.ts b/app/renderer/shared/useAssetImageUrl.ts index 79063d2..3602697 100644 --- a/app/renderer/shared/useAssetImageUrl.ts +++ b/app/renderer/shared/useAssetImageUrl.ts @@ -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(); +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; diff --git a/package.json b/package.json index c021c0a..079c5bd 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "build:obfuscate": "node scripts/build.mjs --production --obfuscate", "lint": "eslint . --max-warnings 0", "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/renderer/editor/fileDrop.test.ts app/shared/graph/sceneListOrder.test.ts app/renderer/editor/graph/sceneCardById.test.ts app/renderer/editor/i18n/editorMessages.locale.test.ts app/renderer/editor/sceneDescriptionHtml.test.ts app/shared/graph/storylineExportImport.test.ts app/shared/foundry/foundryGraph.test.ts app/shared/ipc/contracts.mediaRemoval.test.ts app/shared/effectEraserHitTest.test.ts app/shared/fieldEffectEraser.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/effects/sceneDarknessStore.test.ts app/main/project/assetPrune.test.ts app/main/project/optimizeImageImport.test.ts app/main/project/scenePreviewThumbnail.test.ts app/main/project/fsRetry.test.ts app/main/project/zipRead.test.ts app/main/project/replaceFileAtomic.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 app/main/license/machineFingerprint.test.ts && node --test scripts/build-env.test.mjs scripts/obfuscate-main.test.mjs scripts/release-mac-prep.test.mjs", + "test": "tsx --test app/renderer/shared/ui/controls.tooltip.test.ts app/renderer/editor/state/projectState.race.test.ts app/renderer/editor/fileDrop.test.ts app/shared/graph/sceneListOrder.test.ts app/renderer/editor/graph/sceneCardById.test.ts app/renderer/editor/i18n/editorMessages.locale.test.ts app/renderer/editor/sceneDescriptionHtml.test.ts app/shared/graph/storylineExportImport.test.ts app/shared/foundry/foundryGraph.test.ts app/shared/ipc/contracts.mediaRemoval.test.ts app/shared/effectEraserHitTest.test.ts app/shared/fieldEffectEraser.test.ts app/renderer/control/controlApp.effectsPanel.test.ts app/renderer/control/controlApp.audioPerf.networkRegression.test.ts app/renderer/control/controlApp.brushPerf.networkRegression.test.ts app/renderer/shared/useAssetImageUrl.cache.test.ts app/renderer/shared/sceneOverlay/sceneOverlayHost.test.ts app/renderer/shared/effects/PxiEffectsOverlay.pointer.test.ts app/main/windows/createWindows.editorClose.test.ts app/main/safeConsole.test.ts app/main/windows/bootWindow.test.ts app/main/effects/effectsStore.test.ts app/main/effects/sceneDarknessStore.test.ts app/main/project/assetPrune.test.ts app/main/project/optimizeImageImport.test.ts app/main/project/scenePreviewThumbnail.test.ts app/main/project/fsRetry.test.ts app/main/project/zipRead.test.ts app/main/project/replaceFileAtomic.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 app/main/license/machineFingerprint.test.ts && node --test scripts/build-env.test.mjs scripts/obfuscate-main.test.mjs scripts/release-mac-prep.test.mjs", "format": "prettier . --check", "format:write": "prettier . --write", "postinstall": "patch-package",