diff --git a/app/main/index.ts b/app/main/index.ts index bdc33c5..175b712 100644 --- a/app/main/index.ts +++ b/app/main/index.ts @@ -21,6 +21,7 @@ import { import type { Project } from '../shared/types'; import { asNpcId } from '../shared/types/ids'; import { listVisibleSceneNpcTokens } from '../shared/types/appPlayers'; +import { remapProjectSceneTokenIds } from '../shared/graph/storylineExportImport'; import { asPreviewRotationDeg, previewRotationStepsCw, @@ -390,6 +391,18 @@ function emitTokensState(): void { } } +/** Подтянуть `app-tokens/` из открытого кэша (экспорт сюжетной линии / полный zip). */ +async function importAppTokensFromOpenProjectCache(): Promise { + const cacheDir = projectStore.getOpenCacheDir(); + if (!tokensStore || !cacheDir) return null; + const remap = await tokensStore.importFromExportDir(cacheDir); + if (remap.size === 0) return null; + emitTokensState(); + const needsRemap = [...remap.entries()].some(([from, to]) => from !== to); + if (!needsRemap) return null; + return projectStore.updateProject((p) => remapProjectSceneTokenIds(p, remap)); +} + function emitSceneTokensSessionState(): void { const state = sceneTokensSessionStore.getState(); for (const win of BrowserWindow.getAllWindows()) { @@ -882,7 +895,9 @@ async function main() { return { project }; }); registerHandler(ipcChannels.project.open, async ({ projectId }) => { - const project = await projectStore.openProjectById(projectId); + let project = await projectStore.openProjectById(projectId); + const remapped = await importAppTokensFromOpenProjectCache(); + if (remapped) project = remapped; sceneViewStore.reset(); sceneTokensSessionStore.reset(); sceneNpcTokensSessionStore.reset(); @@ -1445,7 +1460,7 @@ async function main() { } const srcPath = filePaths[0]; emitZipProgress({ kind: 'import', stage: 'copy', percent: 0, detail: 'Копирование…' }); - const project = await projectStore.importProjectFromExternalZip(srcPath, (p) => { + let project = await projectStore.importProjectFromExternalZip(srcPath, (p) => { emitZipProgress({ kind: 'import', stage: p.stage, @@ -1454,6 +1469,8 @@ async function main() { }); }); const cacheDir = projectStore.getOpenCacheDir(); + const remapped = await importAppTokensFromOpenProjectCache(); + if (remapped) project = remapped; if (playlistsStore && cacheDir) { await playlistsStore.importFromExportDir(cacheDir); emitPlaylistsState(); @@ -1564,7 +1581,7 @@ async function main() { ); registerHandler(ipcChannels.project.importZipFromPath, async ({ filePath }) => { emitZipProgress({ kind: 'import', stage: 'copy', percent: 0, detail: 'Копирование…' }); - const project = await projectStore.importProjectFromExternalZip(filePath, (p) => { + let project = await projectStore.importProjectFromExternalZip(filePath, (p) => { emitZipProgress({ kind: 'import', stage: p.stage, @@ -1573,6 +1590,8 @@ async function main() { }); }); const cacheDir = projectStore.getOpenCacheDir(); + const remapped = await importAppTokensFromOpenProjectCache(); + if (remapped) project = remapped; if (playlistsStore && cacheDir) { await playlistsStore.importFromExportDir(cacheDir); emitPlaylistsState(); diff --git a/app/main/project/zipStore.legacyContract.test.ts b/app/main/project/zipStore.legacyContract.test.ts index 464965b..fc03535 100644 --- a/app/main/project/zipStore.legacyContract.test.ts +++ b/app/main/project/zipStore.legacyContract.test.ts @@ -122,6 +122,23 @@ void test('main index: setCurrentGraphNode uses setSessionPointer not updateProj assert.doesNotMatch(m[0], /updateProject/); }); +void test('main index: full zip import loads app-tokens like playlists', () => { + const src = fs.readFileSync(path.join(here, '..', 'index.ts'), 'utf8'); + assert.match(src, /async function importAppTokensFromOpenProjectCache/); + assert.match(src, /tokensStore\.importFromExportDir\(cacheDir\)/); + assert.match(src, /remapProjectSceneTokenIds/); + const importZip = src.match( + /registerHandler\(ipcChannels\.project\.importZip,[\s\S]*?return \{ canceled: false as const, project \}/, + ); + assert.ok(importZip); + assert.match(importZip[0], /importAppTokensFromOpenProjectCache/); + const importFromPath = src.match( + /registerHandler\(ipcChannels\.project\.importZipFromPath,[\s\S]*?return \{ project \}/, + ); + assert.ok(importFromPath); + assert.match(importFromPath[0], /importAppTokensFromOpenProjectCache/); +}); + void test('atomicReplace: replaceFileAtomic must not rm destination before successful commit', () => { const src = fs.readFileSync(path.join(here, 'atomicReplace.ts'), 'utf8'); const i = src.indexOf('export async function replaceFileAtomic'); diff --git a/app/main/windows/createWindows.editorClose.test.ts b/app/main/windows/createWindows.editorClose.test.ts index 71b5709..ed69d15 100644 --- a/app/main/windows/createWindows.editorClose.test.ts +++ b/app/main/windows/createWindows.editorClose.test.ts @@ -113,3 +113,10 @@ void test('createWindows: логи окон не валят main через EPIP assert.ok(src.includes('errorCode === -3')); assert.ok(src.includes('isMainFrame')); }); + +void test('createWindows: presentation/control не троттлят RAF в фоне (macOS paths)', () => { + const src = readCreateWindows(); + assert.match(src, /kind === 'presentation' \|\| kind === 'control'/); + assert.match(src, /backgroundThrottling:\s*false/); + assert.match(src, /setBackgroundThrottling\(false\)/); +}); diff --git a/app/main/windows/createWindows.ts b/app/main/windows/createWindows.ts index b97b2d3..9791e05 100644 --- a/app/main/windows/createWindows.ts +++ b/app/main/windows/createWindows.ts @@ -398,6 +398,10 @@ function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow autoplayPolicy: 'no-user-gesture-required', // file:// + бандл Vite: без этого на Windows часто не грузятся чанки; http:// (dev server) оставляем строгим. webSecurity: Boolean(process.env.VITE_DEV_SERVER_URL), + // macOS throttles RAF in unfocused windows — token paths and video stutter on presentation. + ...(kind === 'presentation' || kind === 'control' || kind === 'tokenPathEditor' + ? { backgroundThrottling: false } + : {}), ...(kind === 'npcs' ? { webviewTag: true, @@ -416,6 +420,9 @@ function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow } bindWindowChromeTitle(win, kind); + if (kind === 'presentation' || kind === 'control' || kind === 'tokenPathEditor') { + win.webContents.setBackgroundThrottling(false); + } if (kind === 'npcs') { win.webContents.on('will-attach-webview', (event, guestPrefs, params) => { delete guestPrefs.preload; diff --git a/app/renderer/control/ControlApp.tsx b/app/renderer/control/ControlApp.tsx index 9b3056f..9ee33ba 100644 --- a/app/renderer/control/ControlApp.tsx +++ b/app/renderer/control/ControlApp.tsx @@ -1073,7 +1073,7 @@ export function ControlApp() { /** Действия с токенами/ловушками только без активной кисти эффектов. */ const markersInteractive = tool.tool === 'none'; - const pathLivePoses = useTokenPathLivePoses({ + useTokenPathLivePoses({ tokens: currentScene?.tokens ?? [], npcTokens: visibleNpcTokens, pathSession: tokenPathSession, @@ -2727,7 +2727,6 @@ export function ControlApp() { viewport={previewContentRect} editable={markersInteractive} snapNorm={snapNormActive} - poseOverrides={pathLivePoses.tokenPoses} dragEnabledById={tokenPathDragEnabled} onMove={(placementId, nx, ny) => { const snapped = snapNormActive(nx, ny); @@ -2767,7 +2766,6 @@ export function ControlApp() { grid={currentScene?.grid ?? null} editable={markersInteractive} snapNorm={snapNormActive} - poseOverrides={pathLivePoses.npcPoses} dragEnabledById={npcPathDragEnabled} onMove={(placementId, nx, ny) => { const snapped = snapNormActive(nx, ny); diff --git a/app/renderer/control/controlApp.brushPerf.networkRegression.test.ts b/app/renderer/control/controlApp.brushPerf.networkRegression.test.ts index 9e58ec9..59c7a34 100644 --- a/app/renderer/control/controlApp.brushPerf.networkRegression.test.ts +++ b/app/renderer/control/controlApp.brushPerf.networkRegression.test.ts @@ -33,3 +33,15 @@ void test('ControlApp: draft кисти без корневого draftFxTick', assert.ok(pixi.includes('mergeDraftState')); assert.ok(pixi.includes('forwardRef')); }); + +void test('ControlApp: path playback не гоняет setState корня', () => { + const app = fs.readFileSync(path.join(here, 'ControlApp.tsx'), 'utf8'); + const hook = fs.readFileSync( + path.join(here, '../shared/tokens/useTokenPathLivePoses.ts'), + 'utf8', + ); + assert.doesNotMatch(app, /\bpathLivePoses\b/); + assert.doesNotMatch(hook, /\bsetPoses\b/); + assert.ok(hook.includes('applyLivePathPose')); + assert.ok(hook.includes('requestAnimationFrame')); +}); diff --git a/app/renderer/shared/PresentationView.tsx b/app/renderer/shared/PresentationView.tsx index 37bf601..cfb52cf 100644 --- a/app/renderer/shared/PresentationView.tsx +++ b/app/renderer/shared/PresentationView.tsx @@ -107,7 +107,7 @@ export function PresentationView({ sceneNpcTokensSession.spawned, session?.currentSceneId, ); - const pathLivePoses = useTokenPathLivePoses({ + useTokenPathLivePoses({ tokens: scene?.tokens ?? [], npcTokens: visibleNpcTokens, pathSession: tokenPathSession, @@ -257,7 +257,6 @@ export function PresentationView({ library={appTokens} session={sceneTokensSession} viewport={contentRect} - poseOverrides={pathLivePoses.tokenPoses} /> ) : null} {USERS_BRANCH_FEATURES_ENABLED && @@ -269,7 +268,6 @@ export function PresentationView({ session={sceneNpcTokensSession} viewport={contentRect} grid={scene.grid} - poseOverrides={pathLivePoses.npcPoses} /> ) : null} {USERS_BRANCH_FEATURES_ENABLED && diff --git a/app/renderer/shared/playerToken/SceneNpcTokensOverlay.module.css b/app/renderer/shared/playerToken/SceneNpcTokensOverlay.module.css index 3b1d40d..40ca8bb 100644 --- a/app/renderer/shared/playerToken/SceneNpcTokensOverlay.module.css +++ b/app/renderer/shared/playerToken/SceneNpcTokensOverlay.module.css @@ -7,8 +7,9 @@ .token { position: absolute; + left: 0; + top: 0; overflow: visible; - transform: translate(-50%, -50%); pointer-events: none; touch-action: none; } diff --git a/app/renderer/shared/playerToken/SceneNpcTokensOverlay.tsx b/app/renderer/shared/playerToken/SceneNpcTokensOverlay.tsx index 912a26a..a937637 100644 --- a/app/renderer/shared/playerToken/SceneNpcTokensOverlay.tsx +++ b/app/renderer/shared/playerToken/SceneNpcTokensOverlay.tsx @@ -1,4 +1,4 @@ -import React, { useRef, useState } from 'react'; +import React, { useLayoutEffect, useRef, useState } from 'react'; import type { NpcDisposition, @@ -16,6 +16,8 @@ import { sceneGridTokenFitFactor } from '../../../shared/types/sceneGrid'; import { useAssetUrl } from '../useAssetImageUrl'; import { PlayerTokenView } from './PlayerTokenView'; +import { tokenPathKey } from '../../../shared/types/tokenPathSession'; +import { registerPathTokenNode, writeTokenPathTransform } from '../tokens/tokenPathLiveDom'; import { useLiveDragBroadcast, useLiveResizeBroadcast } from './useLiveDragBroadcast'; import { TokenInitiativeDecor, @@ -100,6 +102,8 @@ function NpcSprite({ onResize, String(placement.id), ); + const elRef = useRef(null); + const pathKey = tokenPathKey('npcToken', String(placement.id)); const pos = localPos ?? { nx, ny }; const shownSizeN = localSize ?? sizeN; const minDim = Math.min(viewport.w, viewport.h); @@ -107,6 +111,17 @@ function NpcSprite({ const canDrag = editable && dragEnabled && onMove !== undefined; const canResize = editable && dragEnabled && onResize !== undefined; + useLayoutEffect(() => { + registerPathTokenNode(pathKey, elRef.current, viewport, false); + return () => registerPathTokenNode(pathKey, null, viewport, false); + }, [pathKey, viewport]); + + useLayoutEffect(() => { + const el = elRef.current; + if (!el || el.dataset.pathLive === '1') return; + writeTokenPathTransform(el, pos.nx, pos.ny, 0, viewport, false); + }, [pos.nx, pos.ny, viewport]); + const point = (e: React.PointerEvent, host?: HTMLElement | null) => { const rect = (host ?? e.currentTarget.parentElement)?.getBoundingClientRect(); return { @@ -133,6 +148,7 @@ function NpcSprite({ return (
(null); const posNx = localPos?.nx ?? nx; const posNy = localPos?.ny ?? ny; const shownSizeN = localSize ?? sizeN; + const pathKey = tokenPathKey('token', String(placement.id)); const minDim = Math.min(viewport.w, viewport.h); const sizePx = Math.max(16, shownSizeN * minDim); - const left = viewport.x + posNx * viewport.w; - const top = viewport.y + posNy * viewport.h; const canDrag = editable && dragEnabled && Boolean(onMove); const canResize = editable && dragEnabled && Boolean(onResize); const interactive = canDrag || canResize || Boolean(onContextMenu); + useLayoutEffect(() => { + registerPathTokenNode(pathKey, elRef.current, viewport, true); + return () => registerPathTokenNode(pathKey, null, viewport, true); + }, [pathKey, viewport]); + + useLayoutEffect(() => { + const el = elRef.current; + if (!el || el.dataset.pathLive === '1') return; + writeTokenPathTransform(el, posNx, posNy, rotationDeg, viewport, true); + }, [posNx, posNy, rotationDeg, viewport]); + const hostToNorm = (clientX: number, clientY: number, host: HTMLElement) => { const r = host.getBoundingClientRect(); const w = Math.max(1e-6, viewport.w); @@ -160,6 +173,7 @@ function TokenSprite({ return (
{ + const el = { style: { transform: '' } } as unknown as HTMLElement; + writeTokenPathTransform(el, 0.25, 0.5, 45, { x: 10, y: 20, w: 200, h: 100 }, true); + assert.equal(el.style.transform, 'translate3d(60px, 70px, 0) translate(-50%, -50%) rotate(45deg)'); +}); + +void test('writeTokenPathTransform: NPC without rotate keeps centering only', () => { + const el = { style: { transform: '' } } as unknown as HTMLElement; + writeTokenPathTransform(el, 0, 0, 90, { x: 0, y: 0, w: 100, h: 100 }, false); + assert.equal(el.style.transform, 'translate3d(0px, 0px, 0) translate(-50%, -50%)'); +}); diff --git a/app/renderer/shared/tokens/tokenPathLiveDom.ts b/app/renderer/shared/tokens/tokenPathLiveDom.ts new file mode 100644 index 0000000..7ae3bcc --- /dev/null +++ b/app/renderer/shared/tokens/tokenPathLiveDom.ts @@ -0,0 +1,65 @@ +/** Direct DOM transforms for path playback — avoids React re-renders on every RAF tick. */ + +export type TokenPathViewport = { x: number; y: number; w: number; h: number }; + +type NodeReg = { + el: HTMLElement; + viewport: TokenPathViewport; + rotate: boolean; +}; + +const registry = new Map(); + +export function writeTokenPathTransform( + el: HTMLElement, + nx: number, + ny: number, + rotationDeg: number, + viewport: TokenPathViewport, + rotate: boolean, +): void { + const x = viewport.x + nx * viewport.w; + const y = viewport.y + ny * viewport.h; + const rot = rotate ? ` rotate(${String(rotationDeg)}deg)` : ''; + el.style.transform = `translate3d(${String(x)}px, ${String(y)}px, 0) translate(-50%, -50%)${rot}`; +} + +export function registerPathTokenNode( + key: string, + el: HTMLElement | null, + viewport: TokenPathViewport, + rotate: boolean, +): void { + if (!el) { + registry.delete(key); + return; + } + registry.set(key, { el, viewport, rotate }); +} + +export function applyLivePathPose( + key: string, + nx: number, + ny: number, + rotationDeg: number, + animating: boolean, +): void { + const n = registry.get(key); + if (!n) return; + n.el.dataset.pathLive = '1'; + n.el.style.willChange = animating ? 'transform' : ''; + writeTokenPathTransform(n.el, nx, ny, rotationDeg, n.viewport, n.rotate); +} + +export function endLivePathPose(key: string): void { + const n = registry.get(key); + if (!n) return; + delete n.el.dataset.pathLive; + n.el.style.willChange = ''; +} + +export function endAllLivePathPoses(): void { + for (const key of [...registry.keys()]) { + endLivePathPose(key); + } +} diff --git a/app/renderer/shared/tokens/useTokenPathLivePoses.ts b/app/renderer/shared/tokens/useTokenPathLivePoses.ts index 65b938f..6644b50 100644 --- a/app/renderer/shared/tokens/useTokenPathLivePoses.ts +++ b/app/renderer/shared/tokens/useTokenPathLivePoses.ts @@ -1,58 +1,17 @@ -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useRef } from 'react'; import { computePathPlaybackSample } from '../../../shared/types/tokenPathPlayback'; import { tokenPathKey, - type TokenPathPlaybackPhase, type TokenPathSessionState, } from '../../../shared/types/tokenPathSession'; import type { SceneNpcToken, SceneToken } from '../../../shared/types'; -export type TokenPathLivePose = { - nx: number; - ny: number; - rotationDeg: number; - phase: TokenPathPlaybackPhase; - dist: number; -}; - -type PoseMaps = { - tokenPoses: Record; - npcPoses: Record; -}; - -const EMPTY: PoseMaps = { tokenPoses: {}, npcPoses: {} }; - -function posesEqual(a: PoseMaps, b: PoseMaps): boolean { - const aT = a.tokenPoses; - const bT = b.tokenPoses; - const aN = a.npcPoses; - const bN = b.npcPoses; - const aTk = Object.keys(aT); - const bTk = Object.keys(bT); - const aNk = Object.keys(aN); - const bNk = Object.keys(bN); - if (aTk.length !== bTk.length || aNk.length !== bNk.length) return false; - for (const k of aTk) { - const x = aT[k]; - const y = bT[k]; - if (!y || x!.nx !== y.nx || x!.ny !== y.ny || x!.rotationDeg !== y.rotationDeg || x!.phase !== y.phase) { - return false; - } - } - for (const k of aNk) { - const x = aN[k]; - const y = bN[k]; - if (!y || x!.nx !== y.nx || x!.ny !== y.ny || x!.rotationDeg !== y.rotationDeg || x!.phase !== y.phase) { - return false; - } - } - return true; -} +import { applyLivePathPose, endAllLivePathPoses, endLivePathPose } from './tokenPathLiveDom'; /** * RAF poses for control/presentation while path playback is active. - * When phase === 'stopped', pose is omitted so session/placement (drag) wins. + * Writes transforms on registered token DOM nodes — no React setState per frame. * * Uses local Date.now() — segmentStartedAtMs is also wall-clock from main (same machine). * Do NOT clamp to stale serverNowMs: that froze motion after ~250ms until the next IPC bump. @@ -63,10 +22,10 @@ export function useTokenPathLivePoses(args: { pathSession: TokenPathSessionState | null; enabled?: boolean; onMarkDone?: (kind: 'token' | 'npcToken', placementId: string, atDist: number) => void; -}): PoseMaps { +}): void { const { tokens, npcTokens, pathSession, enabled = true, onMarkDone } = args; - const [poses, setPoses] = useState(EMPTY); const markedDoneRef = useRef>(new Set()); + const liveKeysRef = useRef>(new Set()); const onMarkDoneRef = useRef(onMarkDone); onMarkDoneRef.current = onMarkDone; @@ -83,7 +42,8 @@ export function useTokenPathLivePoses(args: { useEffect(() => { if (!enabled) { - setPoses(EMPTY); + endAllLivePathPoses(); + liveKeysRef.current.clear(); return; } let raf = 0; @@ -92,55 +52,51 @@ export function useTokenPathLivePoses(args: { const tick = () => { if (!alive) return; const session = pathSessionRef.current; - if (!session) { - setPoses((prev) => (prev === EMPTY || Object.keys(prev.tokenPoses).length + Object.keys(prev.npcPoses).length === 0 ? prev : EMPTY)); - raf = requestAnimationFrame(tick); - return; - } + const nextKeys = new Set(); + if (session) { + const sampleNow = Date.now(); - // Wall clock: matches main's Date.now() for segmentStartedAtMs (Electron, one host). - const sampleNow = Date.now(); - const tokenPoses: Record = {}; - const npcPoses: Record = {}; - - const sampleOne = ( - kind: 'token' | 'npcToken', - placementId: string, - path: NonNullable, - out: Record, - ) => { - const key = tokenPathKey(kind, placementId); - const entry = session.playback[key]; - if (!entry) return; - if (entry.phase === 'stopped') return; - const result = computePathPlaybackSample({ path, entry, nowMs: sampleNow }); - if (!result) return; - out[placementId] = { - nx: result.sample.nx, - ny: result.sample.ny, - rotationDeg: result.sample.rotationDeg, - phase: result.phase, - dist: result.sample.dist, + const sampleOne = ( + kind: 'token' | 'npcToken', + placementId: string, + path: NonNullable, + ) => { + const key = tokenPathKey(kind, placementId); + const entry = session.playback[key]; + if (!entry) return; + if (entry.phase === 'stopped') return; + const result = computePathPlaybackSample({ path, entry, nowMs: sampleNow }); + if (!result) return; + nextKeys.add(key); + applyLivePathPose( + key, + result.sample.nx, + result.sample.ny, + result.sample.rotationDeg, + entry.phase === 'moving' || entry.phase === 'delay', + ); + if (result.markDone && !markedDoneRef.current.has(key)) { + markedDoneRef.current.add(key); + onMarkDoneRef.current?.(kind, placementId, result.sample.dist); + } }; - if (result.markDone && !markedDoneRef.current.has(key)) { - markedDoneRef.current.add(key); - onMarkDoneRef.current?.(kind, placementId, result.sample.dist); - } - }; - for (const t of tokensRef.current) { - if (t.path && t.path.points.length >= 2) { - sampleOne('token', String(t.id), t.path, tokenPoses); + for (const t of tokensRef.current) { + if (t.path && t.path.points.length >= 2) { + sampleOne('token', String(t.id), t.path); + } } - } - for (const t of npcTokensRef.current) { - if (t.path && t.path.points.length >= 2) { - sampleOne('npcToken', String(t.id), t.path, npcPoses); + for (const t of npcTokensRef.current) { + if (t.path && t.path.points.length >= 2) { + sampleOne('npcToken', String(t.id), t.path); + } } } - const next: PoseMaps = { tokenPoses, npcPoses }; - setPoses((prev) => (posesEqual(prev, next) ? prev : next)); + for (const key of liveKeysRef.current) { + if (!nextKeys.has(key)) endLivePathPose(key); + } + liveKeysRef.current = nextKeys; raf = requestAnimationFrame(tick); }; @@ -148,8 +104,10 @@ export function useTokenPathLivePoses(args: { return () => { alive = false; cancelAnimationFrame(raf); + for (const key of liveKeysRef.current) { + endLivePathPose(key); + } + liveKeysRef.current.clear(); }; }, [enabled]); - - return poses; } diff --git a/app/shared/graph/storylineExportImport.test.ts b/app/shared/graph/storylineExportImport.test.ts index 5ceedb4..22d365b 100644 --- a/app/shared/graph/storylineExportImport.test.ts +++ b/app/shared/graph/storylineExportImport.test.ts @@ -11,6 +11,8 @@ import { asProjectId, asSceneId, asSceneNpcTokenId, + asSceneTokenId, + asTokenId, } from '../types/ids'; import { PROJECT_SCHEMA_VERSION } from '../types'; @@ -21,8 +23,10 @@ import { findSceneTitleConflicts, listExportedNpcsFromBundle, listImportableStorylines, + collectTokenIdsFromProject, mergeStorylinesIntoProject, newExportBundleProjectId, + remapProjectSceneTokenIds, selectNpcsByIds, } from './storylineExportImport'; @@ -550,3 +554,48 @@ void test('mergeStorylinesIntoProject: reuse existing scene by resolution', () = assert.equal(merged.sceneGraphNodes.length, 1); assert.equal(merged.sceneGraphNodes[0]!.sceneId, targetSceneId); }); + +void test('collectTokenIdsFromProject: собирает id неигровых токенов со сцен', () => { + const s1 = scene('s1', 'Start'); + s1.tokens = [ + { + id: asSceneTokenId('st1'), + tokenId: asTokenId('tok_a'), + nx: 0.2, + ny: 0.3, + sizeN: 0.08, + rotationDeg: 0, + }, + { + id: asSceneTokenId('st2'), + tokenId: asTokenId('tok_b'), + nx: 0.5, + ny: 0.5, + sizeN: 0.08, + rotationDeg: 0, + }, + ]; + const project = minimalProject({ + scenes: { [asSceneId('s1')]: s1 }, + sceneGraphNodes: [node('main', 's1', { isStartScene: true })], + }); + const ids = collectTokenIdsFromProject(project).map(String).sort(); + assert.deepEqual(ids, ['tok_a', 'tok_b']); +}); + +void test('remapProjectSceneTokenIds: переписывает tokenId на сценах', () => { + const s1 = scene('s1', 'Start'); + s1.tokens = [ + { + id: asSceneTokenId('st1'), + tokenId: asTokenId('old'), + nx: 0.2, + ny: 0.3, + sizeN: 0.08, + rotationDeg: 0, + }, + ]; + const project = minimalProject({ scenes: { [asSceneId('s1')]: s1 } }); + const remapped = remapProjectSceneTokenIds(project, new Map([['old', 'new']])); + assert.equal(String(remapped.scenes[asSceneId('s1')]!.tokens[0]!.tokenId), 'new'); +}); diff --git a/package.json b/package.json index 03c4666..ac1049f 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 && 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/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",