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",