From 48a9c933af408b61c9c8f0f93b57516591925fbc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C2=A0=C2=A0=C2=A0=C2=A0=C2=A0=D0=A4=D0=BE=D0=BD=D1=82?= =?UTF-8?q?=D0=BE=D1=88=20=D0=98=D0=B2=D0=B0=D0=BD=20=D0=A1=D0=B5=D1=80?= =?UTF-8?q?=D0=B3=D0=B5=D0=B5=D0=B2=D0=B8=D1=87?= Date: Mon, 7 Sep 2026 17:13:37 +0800 Subject: [PATCH] =?UTF-8?q?fix(effects):=20=D1=81=D0=BD=D0=B8=D0=B7=D0=B8?= =?UTF-8?q?=D1=82=D1=8C=20=D0=BB=D0=B0=D0=B3=D0=B8=20=D1=81=D1=86=D0=B5?= =?UTF-8?q?=D0=BD=D1=8B=20=D0=BD=D0=B0=20Mac?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pixi режется по contentRect, пульт идёт в preview (12 FPS, 0.5 DPR), штампы тумана/огня/дождя прореживаются, draft темноты и маркера не чаще 20 Гц. Co-authored-by: Cursor --- app/renderer/control/ControlApp.tsx | 7 ++ ...rolApp.brushPerf.networkRegression.test.ts | 2 + .../effects/PxiEffectsOverlay.module.css | 9 +- .../effects/PxiEffectsOverlay.pointer.test.ts | 13 +++ .../shared/effects/PxiEffectsOverlay.tsx | 96 +++++++++++++------ .../shared/effects/SceneDarknessOverlay.tsx | 2 +- .../shared/effects/SceneMarkerOverlay.tsx | 2 +- app/renderer/shared/grid/SceneGridOverlay.tsx | 2 +- app/shared/fieldEffectStampPoints.test.ts | 46 +++++++++ app/shared/fieldEffectStampPoints.ts | 68 +++++++++++++ package.json | 2 +- 11 files changed, 215 insertions(+), 34 deletions(-) create mode 100644 app/shared/fieldEffectStampPoints.test.ts create mode 100644 app/shared/fieldEffectStampPoints.ts diff --git a/app/renderer/control/ControlApp.tsx b/app/renderer/control/ControlApp.tsx index 9ee33ba..116e60b 100644 --- a/app/renderer/control/ControlApp.tsx +++ b/app/renderer/control/ControlApp.tsx @@ -95,6 +95,8 @@ import { getSunbeamEffectLifeMs, playSunbeamEffectSound } from './sunbeamSfx'; /** Длительность молнии: быстрый удар + акцент в точке попадания. */ const LIGHTNING_EFFECT_MS = 840; +/** Темнота/маркер на презентацию — не чаще 20 Гц (полный canvas replay в обоих окнах). */ +const OVERLAY_DRAFT_IPC_MS = 50; function dispositionMakeKey(d: NpcDisposition): string { if (d === 'hostile') return 'npcs.makeHostile'; @@ -305,6 +307,7 @@ export function ControlApp() { const brushCursorElRef = useRef(null); const cursorPosRef = useRef<{ x: number; y: number } | null>(null); const draftPaintRafRef = useRef(0); + const overlayDraftIpcAtRef = useRef(0); const effectsOverlayRef = useRef(null); const [explosionDraft, setExplosionDraft] = useState(null); const draftMetaRef = useRef<{ createdAtMs: number; seed: number } | null>(null); @@ -1387,6 +1390,9 @@ export function ControlApp() { draftPaintRafRef.current = 0; pushDraftToPixi(); const b = brushRef.current; + const now = performance.now(); + if (now - overlayDraftIpcAtRef.current < OVERLAY_DRAFT_IPC_MS) return; + overlayDraftIpcAtRef.current = now; if ( (b?.tool === 'exploreBrush' || b?.tool === 'closeBrush') && b.points @@ -2512,6 +2518,7 @@ export function ControlApp() { { diff --git a/app/renderer/shared/effects/PxiEffectsOverlay.module.css b/app/renderer/shared/effects/PxiEffectsOverlay.module.css index a2defe0..4295baa 100644 --- a/app/renderer/shared/effects/PxiEffectsOverlay.module.css +++ b/app/renderer/shared/effects/PxiEffectsOverlay.module.css @@ -1,9 +1,16 @@ .host { position: absolute; - inset: 0; contain: layout paint; } +.hostFill { + inset: 0; +} + +.hostBound { + overflow: hidden; +} + .hostInteractive { pointer-events: auto; } diff --git a/app/renderer/shared/effects/PxiEffectsOverlay.pointer.test.ts b/app/renderer/shared/effects/PxiEffectsOverlay.pointer.test.ts index eebe766..709c89f 100644 --- a/app/renderer/shared/effects/PxiEffectsOverlay.pointer.test.ts +++ b/app/renderer/shared/effects/PxiEffectsOverlay.pointer.test.ts @@ -49,3 +49,16 @@ void test('PxiEffectsOverlay: эффекты перекладываются пр assert.ok(src.includes('viewportSig')); assert.match(src, /\$\{contentSig\}@\$\{viewportSig\(viewport\)\}/); }); + +void test('PxiEffectsOverlay: canvas по contentRect + preview profile', () => { + const src = fs.readFileSync(path.join(here, 'PxiEffectsOverlay.tsx'), 'utf8'); + const css = fs.readFileSync(path.join(here, 'PxiEffectsOverlay.module.css'), 'utf8'); + assert.ok(css.includes('.hostBound')); + assert.ok(css.includes('.hostFill')); + assert.match(src, /perfProfile\s*=\s*'live'/); + assert.match(src, /resolution:\s*preview\s*\?\s*0\.5\s*:\s*dpr/); + assert.match(src, /maxFPS\s*=\s*preview\s*\?\s*12\s*:\s*32/); + assert.ok(src.includes('decimateFieldStampPoints')); + assert.ok(src.includes('fieldStampPoints')); + assert.match(src, /viewportRef\.current = \{ x: 0, y: 0, w: viewport\.w/); +}); diff --git a/app/renderer/shared/effects/PxiEffectsOverlay.tsx b/app/renderer/shared/effects/PxiEffectsOverlay.tsx index d24766d..e0f8eeb 100644 --- a/app/renderer/shared/effects/PxiEffectsOverlay.tsx +++ b/app/renderer/shared/effects/PxiEffectsOverlay.tsx @@ -1,5 +1,6 @@ import React, { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'; +import { decimateFieldStampPoints, fieldStampMinSpacingN } from '../../../shared/fieldEffectStampPoints'; import type { EffectInstance, EffectInstanceType, @@ -9,6 +10,10 @@ import type { import styles from './PxiEffectsOverlay.module.css'; +function fieldStampPoints(inst: Extract) { + return decimateFieldStampPoints(inst.points, fieldStampMinSpacingN(inst.type, inst.radiusN)); +} + export type PixiEffectsOverlayHandle = { /** Draft-штрих без React re-render родителя; null — сброс. */ setDraft: (instance: EffectInstance | null) => void; @@ -193,6 +198,11 @@ type Props = { style?: React.CSSProperties; /** Область “контента” внутри контейнера (например при object-fit: contain). */ viewport?: { x: number; y: number; w: number; h: number } | undefined; + /** + * `preview` — пульт: ниже FPS, меньший backing store, без покачивания спрайтов. + * Презентация остаётся `live`. + */ + perfProfile?: 'live' | 'preview'; }; /** @@ -203,7 +213,7 @@ type Props = { * - Draft кисти идёт через `setDraft` (imperative), без re-render ControlApp. */ export const PixiEffectsOverlay = forwardRef(function PixiEffectsOverlay( - { state, interactive = false, style, viewport }, + { state, interactive = false, style, viewport, perfProfile = 'live' }, ref, ) { const hostRef = useRef(null); @@ -320,17 +330,18 @@ export const PixiEffectsOverlay = forwardRef(fu const pixi = await import('pixi.js'); pixiRef.current = pixi; if (destroyed) return; + const preview = perfProfile === 'preview'; app = new pixi.Application(); await app.init({ backgroundAlpha: 0, antialias: false, powerPreference: 'high-performance', - resolution: dpr, + resolution: preview ? 0.5 : dpr, autoDensity: true, preference: 'webgl', }); - // Меньше кадров — меньше CPU/GPU; анимации эффектов остаются плавными. - app.ticker.maxFPS = 32; + // live: 32 FPS; preview (пульт): 12 — второй WebGL поверх видео на Mac дешевле. + app.ticker.maxFPS = preview ? 12 : 32; if (destroyed) return; host.appendChild(app.canvas); // Canvas по умолчанию перехватывает hit-test; оставляем клики «сквозь» оверлей для слоя кисти сверху. @@ -370,12 +381,15 @@ export const PixiEffectsOverlay = forwardRef(fu const s = stateRef.current; if (!s || s.instances.length === 0) return; const nowMs = Date.now() + timeOffsetRef.current; - animateNodes(pixi, nodesRef.current, s, nowMs, sizeRef.current, viewportRef.current); + const motion = !preview; + animateNodes(pixi, nodesRef.current, s, nowMs, sizeRef.current, viewportRef.current, { + motion, + }); // Лёгкое “потряхивание” сцены в момент удара молнии. // Делаем через смещение корневого контейнера, чтобы не вмешиваться в рендерер/камера-логику. const rootNode = rootRef.current; - if (rootNode) { + if (rootNode && motion) { const { x, y } = computeSceneShake(s, nowMs, sizeRef.current); rootNode.x = x; rootNode.y = y; @@ -404,7 +418,7 @@ export const PixiEffectsOverlay = forwardRef(fu host.replaceChildren(); }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [gpuBoot]); + }, [gpuBoot, perfProfile]); useEffect(() => { const host = hostRef.current; @@ -420,9 +434,9 @@ export const PixiEffectsOverlay = forwardRef(fu }, [state]); useEffect(() => { - // Если viewport не задан — используем “весь холст”. + // Host уже стоит на contentRect — в Pixi локальные координаты с (0,0). viewportProvidedRef.current = Boolean(viewport); - if (viewport) viewportRef.current = viewport; + if (viewport) viewportRef.current = { x: 0, y: 0, w: viewport.w, h: viewport.h }; else viewportRef.current = { x: 0, y: 0, w: sizeRef.current.w, h: sizeRef.current.h }; const pixi = pixiRef.current; const root = rootRef.current; @@ -437,9 +451,22 @@ export const PixiEffectsOverlay = forwardRef(fu } }, [viewport]); - const hostClass = [styles.host, interactive ? styles.hostInteractive : styles.hostPassthrough].join(' '); + const hostClass = [ + styles.host, + viewport ? styles.hostBound : styles.hostFill, + interactive ? styles.hostInteractive : styles.hostPassthrough, + ].join(' '); + const hostStyle: React.CSSProperties = viewport + ? { + ...style, + left: viewport.x, + top: viewport.y, + width: viewport.w, + height: viewport.h, + } + : { ...style }; - return
; + return
; }); function syncNodes( @@ -530,8 +557,9 @@ function createInstanceNode( const r = inst.radiusN * minDim; const textures = getFogVfxFrameTextures(pixi); const c = new pixi.Container(); - for (let i = 0; i < inst.points.length; i += 1) { - const p = inst.points[i]; + const stamps = fieldStampPoints(inst); + for (let i = 0; i < stamps.length; i += 1) { + const p = stamps[i]; if (!p) continue; const s = new pixi.Sprite(textures[0] ?? pixi.Texture.EMPTY); s.anchor?.set?.(0.5, 0.5); @@ -570,8 +598,9 @@ function createInstanceNode( const r = inst.radiusN * minDim; const textures = getGroundFireVfxFrameTextures(pixi); const c = new pixi.Container(); - for (let i = 0; i < inst.points.length; i += 1) { - const p = inst.points[i]; + const stamps = fieldStampPoints(inst); + for (let i = 0; i < stamps.length; i += 1) { + const p = stamps[i]; if (!p) continue; const s = new pixi.Sprite(textures[0] ?? pixi.Texture.EMPTY); s.anchor?.set?.(0.5, 0.62); @@ -613,8 +642,9 @@ function createInstanceNode( const r = inst.radiusN * minDim; const textures = getRainVfxFrameTextures(pixi); const c = new pixi.Container(); - for (let i = 0; i < inst.points.length; i += 1) { - const p = inst.points[i]; + const stamps = fieldStampPoints(inst); + for (let i = 0; i < stamps.length; i += 1) { + const p = stamps[i]; if (!p) continue; const s = new pixi.Sprite(textures[0] ?? pixi.Texture.EMPTY); s.anchor?.set?.(0.5, 0.5); @@ -885,6 +915,7 @@ function animateNodes( nowMs: number, size: { w: number; h: number }, viewport: { x: number; y: number; w: number; h: number }, + opts?: { motion?: boolean }, ) { const instById = new Map(state.instances.map((i) => [i.id, i])); void pixi; @@ -940,12 +971,14 @@ function animateNodes( child.texture = frame; } } - const wobble = typeof fx.wobble === 'number' ? fx.wobble : 2; - child.x = fx.bx + Math.sin(0.0016 * t + (fx.phase ?? 0) * 6.28) * wobble; - child.y = fx.by + Math.cos(0.0019 * t + (fx.phase ?? 0) * 6.28) * wobble * 0.45; - const local = 0.93 + 0.07 * Math.sin(0.0042 * t + (fx.phase ?? 0) * 12.56); - child.width = (typeof fx.w0 === 'number' ? fx.w0 : child.width) * local; - child.height = (typeof fx.h0 === 'number' ? fx.h0 : child.height) * (0.96 + (local - 0.93) * 0.7); + if (opts?.motion !== false) { + const wobble = typeof fx.wobble === 'number' ? fx.wobble : 2; + child.x = fx.bx + Math.sin(0.0016 * t + (fx.phase ?? 0) * 6.28) * wobble; + child.y = fx.by + Math.cos(0.0019 * t + (fx.phase ?? 0) * 6.28) * wobble * 0.45; + const local = 0.93 + 0.07 * Math.sin(0.0042 * t + (fx.phase ?? 0) * 12.56); + child.width = (typeof fx.w0 === 'number' ? fx.w0 : child.width) * local; + child.height = (typeof fx.h0 === 'number' ? fx.h0 : child.height) * (0.96 + (local - 0.93) * 0.7); + } child.alpha = 1; } } @@ -991,9 +1024,11 @@ function animateNodes( } } const alphaMul = typeof fx.alphaMul === 'number' ? fx.alphaMul : 1; - const wob = typeof fx.drift === 'number' ? fx.drift : 0; - child.x = fx.bx + Math.sin(0.0011 * t + (fx.phase ?? 0) * 6.28) * wob; - child.y = fx.by + Math.cos(0.0014 * t + (fx.phase ?? 0) * 6.28) * wob * 0.35; + if (opts?.motion !== false) { + const wob = typeof fx.drift === 'number' ? fx.drift : 0; + child.x = fx.bx + Math.sin(0.0011 * t + (fx.phase ?? 0) * 6.28) * wob; + child.y = fx.by + Math.cos(0.0014 * t + (fx.phase ?? 0) * 6.28) * wob * 0.35; + } child.width = typeof fx.w0 === 'number' ? fx.w0 : child.width; child.height = typeof fx.h0 === 'number' ? fx.h0 : child.height; child.alpha = Math.max(0.25, Math.min(1, inst.opacity * base * alphaMul)); @@ -1867,10 +1902,11 @@ function relayoutInstanceNode( if (inst.type === 'fog') { const r = inst.radiusN * minDim; const fogSize = Math.max(4, r * 2); + const stamps = fieldStampPoints(inst); const children = node.children ?? []; let pi = 0; for (const child of children) { - const p = inst.points[pi]; + const p = stamps[pi]; pi += 1; if (!p) continue; const fx = (child as any).__fx ?? {}; @@ -1891,10 +1927,11 @@ function relayoutInstanceNode( const r = inst.radiusN * minDim; const flameWidth = Math.max(r * 3.2, minDim * 0.05); const flameHeight = flameWidth / GROUND_FIRE_VFX_FRAME_ASPECT; + const stamps = fieldStampPoints(inst); const children = node.children ?? []; let pi = 0; for (const child of children) { - const p = inst.points[pi]; + const p = stamps[pi]; pi += 1; if (!p) continue; const fx = (child as any).__fx ?? {}; @@ -1915,10 +1952,11 @@ function relayoutInstanceNode( const r = inst.radiusN * minDim; const rainWidth = Math.max(r * 3.8, minDim * 0.08); const rainHeight = rainWidth / RAIN_VFX_FRAME_ASPECT; + const stamps = fieldStampPoints(inst); const children = node.children ?? []; let pi = 0; for (const child of children) { - const p = inst.points[pi]; + const p = stamps[pi]; pi += 1; if (!p) continue; const fx = (child as any).__fx ?? {}; diff --git a/app/renderer/shared/effects/SceneDarknessOverlay.tsx b/app/renderer/shared/effects/SceneDarknessOverlay.tsx index 048cb1e..5cc5210 100644 --- a/app/renderer/shared/effects/SceneDarknessOverlay.tsx +++ b/app/renderer/shared/effects/SceneDarknessOverlay.tsx @@ -114,7 +114,7 @@ export function SceneDarknessOverlay({ const h = Math.max(1, Math.round(viewport.h)); if (canvas.width !== w) canvas.width = w; if (canvas.height !== h) canvas.height = h; - const ctx = canvas.getContext('2d'); + const ctx = canvas.getContext('2d', { alpha: true, desynchronized: true }); if (!ctx) return; ctx.globalCompositeOperation = 'source-over'; diff --git a/app/renderer/shared/effects/SceneMarkerOverlay.tsx b/app/renderer/shared/effects/SceneMarkerOverlay.tsx index 91a8262..ea4661a 100644 --- a/app/renderer/shared/effects/SceneMarkerOverlay.tsx +++ b/app/renderer/shared/effects/SceneMarkerOverlay.tsx @@ -98,7 +98,7 @@ export function SceneMarkerOverlay({ state, viewport, style }: SceneMarkerOverla const h = Math.max(1, Math.round(viewport.h)); if (canvas.width !== w) canvas.width = w; if (canvas.height !== h) canvas.height = h; - const ctx = canvas.getContext('2d'); + const ctx = canvas.getContext('2d', { alpha: true, desynchronized: true }); if (!ctx) return; ctx.clearRect(0, 0, w, h); diff --git a/app/renderer/shared/grid/SceneGridOverlay.tsx b/app/renderer/shared/grid/SceneGridOverlay.tsx index c55e983..c140d1c 100644 --- a/app/renderer/shared/grid/SceneGridOverlay.tsx +++ b/app/renderer/shared/grid/SceneGridOverlay.tsx @@ -70,7 +70,7 @@ export function SceneGridOverlay({ grid, viewport }: Props) { canvas.style.width = `${w}px`; canvas.style.height = `${h}px`; - const ctx = canvas.getContext('2d'); + const ctx = canvas.getContext('2d', { alpha: true, desynchronized: true }); if (!ctx) return; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.clearRect(0, 0, w, h); diff --git a/app/shared/fieldEffectStampPoints.test.ts b/app/shared/fieldEffectStampPoints.test.ts new file mode 100644 index 0000000..1b662f9 --- /dev/null +++ b/app/shared/fieldEffectStampPoints.test.ts @@ -0,0 +1,46 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + FIELD_STAMP_MAX_SPRITES, + decimateFieldStampPoints, + fieldStampMinSpacingN, +} from './fieldEffectStampPoints'; +import type { NPoint } from './types/effects'; + +function pt(x: number, y: number, tMs = 0): NPoint { + return { x, y, tMs }; +} + +void test('decimateFieldStampPoints: пустой и одна точка', () => { + assert.deepEqual(decimateFieldStampPoints([], 0.05), []); + const one = [pt(0.2, 0.3)]; + assert.deepEqual(decimateFieldStampPoints(one, 0.05), one); +}); + +void test('decimateFieldStampPoints: оставляет концы и выкидывает близкие середины', () => { + const points = [pt(0, 0.5), pt(0.01, 0.5), pt(0.02, 0.5), pt(0.5, 0.5), pt(0.51, 0.5), pt(1, 0.5)]; + const out = decimateFieldStampPoints(points, 0.1); + assert.equal(out[0]?.x, 0); + assert.equal(out[out.length - 1]?.x, 1); + assert.ok(out.length < points.length); + assert.ok(out.length >= 3); +}); + +void test('decimateFieldStampPoints: maxPoints режет длинный штрих', () => { + const points: NPoint[] = []; + for (let i = 0; i <= 200; i += 1) { + points.push(pt(i / 200, 0.4, i)); + } + const out = decimateFieldStampPoints(points, 0.001, 16); + assert.ok(out.length <= 16); + assert.equal(out[0]?.x, 0); + assert.equal(out[out.length - 1]?.x, 1); +}); + +void test('fieldStampMinSpacingN: огонь реже тумана', () => { + const r = 0.04; + assert.ok(fieldStampMinSpacingN('fire', r) > fieldStampMinSpacingN('fog', r)); + assert.ok(fieldStampMinSpacingN('fog', r) >= 0.006); + assert.equal(FIELD_STAMP_MAX_SPRITES, 40); +}); diff --git a/app/shared/fieldEffectStampPoints.ts b/app/shared/fieldEffectStampPoints.ts new file mode 100644 index 0000000..39770d5 --- /dev/null +++ b/app/shared/fieldEffectStampPoints.ts @@ -0,0 +1,68 @@ +import type { NPoint } from './types/effects'; + +/** Верхняя граница спрайтов на один штрих тумана/огня/дождя. */ +export const FIELD_STAMP_MAX_SPRITES = 40; + +/** + * Минимальное расстояние между центрами спрайтов (норм. 0..1). + * Огонь крупнее пятна радиуса — реже ставим штампы. + */ +export function fieldStampMinSpacingN(type: 'fog' | 'fire' | 'rain', radiusN: number): number { + const factor = type === 'fire' ? 0.75 : type === 'rain' ? 0.55 : 0.5; + return Math.max(0.006, radiusN * factor); +} + +/** + * Оставляет концы штриха и точки не ближе `minSpacingN`. + * Если точек всё ещё больше `maxPoints` — равномерный stride. + */ +export function decimateFieldStampPoints( + points: readonly NPoint[], + minSpacingN: number, + maxPoints: number = FIELD_STAMP_MAX_SPRITES, +): NPoint[] { + if (points.length <= 1) return points.slice(); + const first = points[0]; + const last = points[points.length - 1]; + if (!first || !last) { + return points.filter((p): p is NPoint => Boolean(p)); + } + + const cap = Math.max(2, Math.floor(maxPoints)); + const minDistSq = Math.max(0, minSpacingN) * Math.max(0, minSpacingN); + const kept: NPoint[] = [first]; + for (let i = 1; i < points.length - 1; i += 1) { + const p = points[i]; + const prev = kept[kept.length - 1]; + if (!p || !prev) continue; + const dx = p.x - prev.x; + const dy = p.y - prev.y; + if (dx * dx + dy * dy >= minDistSq) kept.push(p); + } + const tail = kept[kept.length - 1]; + if (!tail) { + kept.push(last); + } else { + const dx = last.x - tail.x; + const dy = last.y - tail.y; + if (dx * dx + dy * dy > 1e-12) kept.push(last); + } + + if (kept.length <= cap) return kept; + + const reduced: NPoint[] = []; + const lastIdx = kept.length - 1; + const stride = Math.max(1, Math.ceil(lastIdx / (cap - 1))); + for (let i = 0; i < kept.length; i += stride) { + const p = kept[i]; + if (p) reduced.push(p); + } + const reducedTail = reduced[reduced.length - 1]; + if (reducedTail !== last) reduced.push(last); + if (reduced.length > cap) { + const head = reduced[0]; + if (!head) return kept.slice(0, cap); + return [head, ...reduced.slice(reduced.length - (cap - 1))]; + } + return reduced; +} diff --git a/package.json b/package.json index ac1049f..7a8fcb7 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/shared/ui/secondaryWindows.stability.test.ts app/renderer/npcs/tiptapEditorSafe.test.ts app/renderer/editor/state/projectState.race.test.ts app/renderer/editor/fileDrop.test.ts app/shared/graph/sceneListOrder.test.ts app/shared/graph/activeStorylineScenes.test.ts app/renderer/editor/graph/sceneCardById.test.ts app/renderer/editor/i18n/editorMessages.locale.test.ts app/renderer/editor/help/helpLinkify.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/main/sessionIpc.phase6.networkRegression.test.ts app/renderer/shared/sceneOverlay/sceneOverlayHost.test.ts app/renderer/shared/effects/PxiEffectsOverlay.pointer.test.ts app/renderer/shared/traps/trapActivation.test.ts app/renderer/shared/videoSceneMapParity.test.ts app/main/windows/createWindows.editorClose.test.ts app/main/preferences/appPreferences.test.ts app/main/playlists/playlistsStore.test.ts app/main/sessionTimer/timerSessionStore.test.ts app/main/initiative/initiativeSessionStore.test.ts app/main/diceRoll/diceRollSessionStore.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/effects/sceneMarkerStore.test.ts app/shared/sceneMarkerEraser.test.ts app/main/sceneTraps/sceneTrapsStore.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 app/shared/types/sceneAudioRef.test.ts app/shared/types/appPlayers.test.ts app/shared/types/npcDisposition.test.ts app/shared/types/npcExternalUrl.test.ts app/main/npcs/npcsOverlayStore.test.ts app/shared/types/sceneGrid.test.ts app/shared/types/sceneGridSnap.test.ts app/shared/types/sceneDarkness.test.ts app/shared/types/materialLegend.test.ts app/main/tokens/tokenGridSnapSessionStore.test.ts app/shared/players/playerTeams.test.ts app/shared/players/launchPlayersSelection.test.ts app/main/players/playersStore.test.ts app/main/players/sceneNpcTokensSessionStore.test.ts app/main/players/scenePlayerTokensSessionStore.test.ts app/main/tokens/sceneTokensSessionStore.test.ts app/shared/ipc/contracts.players.test.ts app/shared/types/containMediaRect.test.ts app/shared/types/scenePreviewRotation.test.ts app/shared/types/tokenPath.test.ts app/shared/types/tokenPathPlayback.test.ts app/renderer/shared/tokens/tokenPathLiveDom.test.ts && node --test scripts/build-env.test.mjs scripts/obfuscate-main.test.mjs scripts/release-mac-prep.test.mjs scripts/release-native-prep.test.mjs scripts/verify-packaged-sharp.test.mjs app/main/project/sharpRuntime.test.mjs", + "test": "tsx --test app/renderer/shared/ui/controls.tooltip.test.ts app/renderer/shared/ui/secondaryWindows.stability.test.ts app/renderer/npcs/tiptapEditorSafe.test.ts app/renderer/editor/state/projectState.race.test.ts app/renderer/editor/fileDrop.test.ts app/shared/graph/sceneListOrder.test.ts app/shared/graph/activeStorylineScenes.test.ts app/renderer/editor/graph/sceneCardById.test.ts app/renderer/editor/i18n/editorMessages.locale.test.ts app/renderer/editor/help/helpLinkify.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/shared/fieldEffectStampPoints.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/main/sessionIpc.phase6.networkRegression.test.ts app/renderer/shared/sceneOverlay/sceneOverlayHost.test.ts app/renderer/shared/effects/PxiEffectsOverlay.pointer.test.ts app/renderer/shared/traps/trapActivation.test.ts app/renderer/shared/videoSceneMapParity.test.ts app/main/windows/createWindows.editorClose.test.ts app/main/preferences/appPreferences.test.ts app/main/playlists/playlistsStore.test.ts app/main/sessionTimer/timerSessionStore.test.ts app/main/initiative/initiativeSessionStore.test.ts app/main/diceRoll/diceRollSessionStore.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/effects/sceneMarkerStore.test.ts app/shared/sceneMarkerEraser.test.ts app/main/sceneTraps/sceneTrapsStore.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 app/shared/types/sceneAudioRef.test.ts app/shared/types/appPlayers.test.ts app/shared/types/npcDisposition.test.ts app/shared/types/npcExternalUrl.test.ts app/main/npcs/npcsOverlayStore.test.ts app/shared/types/sceneGrid.test.ts app/shared/types/sceneGridSnap.test.ts app/shared/types/sceneDarkness.test.ts app/shared/types/materialLegend.test.ts app/main/tokens/tokenGridSnapSessionStore.test.ts app/shared/players/playerTeams.test.ts app/shared/players/launchPlayersSelection.test.ts app/main/players/playersStore.test.ts app/main/players/sceneNpcTokensSessionStore.test.ts app/main/players/scenePlayerTokensSessionStore.test.ts app/main/tokens/sceneTokensSessionStore.test.ts app/shared/ipc/contracts.players.test.ts app/shared/types/containMediaRect.test.ts app/shared/types/scenePreviewRotation.test.ts app/shared/types/tokenPath.test.ts app/shared/types/tokenPathPlayback.test.ts app/renderer/shared/tokens/tokenPathLiveDom.test.ts && node --test scripts/build-env.test.mjs scripts/obfuscate-main.test.mjs scripts/release-mac-prep.test.mjs scripts/release-native-prep.test.mjs scripts/verify-packaged-sharp.test.mjs app/main/project/sharpRuntime.test.mjs", "format": "prettier . --check", "format:write": "prettier . --write", "postinstall": "patch-package",