diff --git a/app/renderer/control/ControlScenePreview.tsx b/app/renderer/control/ControlScenePreview.tsx index d8f3cc6..22dafa6 100644 --- a/app/renderer/control/ControlScenePreview.tsx +++ b/app/renderer/control/ControlScenePreview.tsx @@ -30,6 +30,7 @@ export function ControlScenePreview({ session, videoRef, onContentRectChange }: const rot = scene?.previewRotationDeg ?? 0; const isVideo = scene?.previewAssetType === 'video'; const assetId = scene?.previewAssetType === 'video' ? scene.previewAssetId : null; + const autostart = scene?.previewVideoAutostart ?? false; const [tick, setTick] = useState(0); const dur = useMemo( @@ -38,7 +39,7 @@ export function ControlScenePreview({ session, videoRef, onContentRectChange }: if (!v) return 0; return Number.isFinite(v.duration) ? v.duration : 0; }, - // tick: перечитываем duration из video ref на каждом кадре RAF + // tick: timeupdate / loadedmetadata перечитывают duration и currentTime // eslint-disable-next-line react-hooks/exhaustive-deps -- намеренно [tick, videoRef], ); @@ -55,23 +56,15 @@ export function ControlScenePreview({ session, videoRef, onContentRectChange }: useEffect(() => { if (!isVideo) return; - let raf = 0; - const loop = () => { - setTick((x) => x + 1); - raf = window.requestAnimationFrame(loop); - }; - raf = window.requestAnimationFrame(loop); - return () => window.cancelAnimationFrame(raf); - }, [isVideo]); - - useEffect(() => { - if (!isVideo) return; + if (!assetId) return; + // `target.set` bumps revision and resets anchors; avoid firing on every render. + if (vp?.targetAssetId === assetId) return; void video.dispatch({ kind: 'target.set', assetId, - autostart: scene.previewVideoAutostart, + autostart, }); - }, [assetId, isVideo, scene, video]); + }, [assetId, isVideo, autostart, vp?.targetAssetId, video]); useEffect(() => { const v = videoRef.current; @@ -88,7 +81,8 @@ export function ControlScenePreview({ session, videoRef, onContentRectChange }: } else { v.pause(); } - }, [assetId, vp, videoRef]); + // eslint-disable-next-line react-hooks/exhaustive-deps -- avoid reruns on 500ms heartbeats (serverNowMs-only updates) + }, [assetId, url, vp?.revision, vp?.targetAssetId, vp?.playing, vp?.playbackRate, videoRef]); const scrubClass = [styles.scrub, dur ? styles.scrubPointer : styles.scrubDefault].join(' '); @@ -105,6 +99,8 @@ export function ControlScenePreview({ session, videoRef, onContentRectChange }: src={url} playsInline preload="auto" + onTimeUpdate={() => setTick((x) => x + 1)} + onLoadedMetadata={() => setTick((x) => x + 1)} > diff --git a/app/renderer/shared/PresentationView.tsx b/app/renderer/shared/PresentationView.tsx index 792a7bb..ffda668 100644 --- a/app/renderer/shared/PresentationView.tsx +++ b/app/renderer/shared/PresentationView.tsx @@ -53,7 +53,16 @@ export function PresentationView({ } else { el.pause(); } - }, [scene?.previewAssetId, scene?.previewAssetType, vp]); + // eslint-disable-next-line react-hooks/exhaustive-deps -- avoid reruns on 500ms heartbeats (serverNowMs-only updates) + }, [ + scene?.previewAssetId, + scene?.previewAssetType, + previewUrl, + vp?.revision, + vp?.targetAssetId, + vp?.playing, + vp?.playbackRate, + ]); return (
diff --git a/app/renderer/shared/video/useVideoPlaybackState.ts b/app/renderer/shared/video/useVideoPlaybackState.ts index 031db12..114ef99 100644 --- a/app/renderer/shared/video/useVideoPlaybackState.ts +++ b/app/renderer/shared/video/useVideoPlaybackState.ts @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { ipcChannels } from '../../../shared/ipc/contracts'; import type { VideoPlaybackEvent, VideoPlaybackState } from '../../../shared/types'; @@ -9,31 +9,36 @@ export function useVideoPlaybackState(): readonly [ { dispatch: (event: VideoPlaybackEvent) => Promise }, ] { const api = getDndApi(); - const [state, setState] = useState(null); - const [timeOffsetMs, setTimeOffsetMs] = useState(0); - const [clientNowMs, setClientNowMs] = useState(() => Date.now()); - - useEffect(() => { - if (!state) return; - const id = window.setInterval(() => { - setClientNowMs(Date.now()); - }, 250); - return () => window.clearInterval(id); - }, [state]); + const [playback, setPlayback] = useState(null); + /** serverNowMs − Date.now() at last IPC sync; lets us compute a live clock without React timers. */ + const timeOffsetMsRef = useRef(0); useEffect(() => { void api.invoke(ipcChannels.video.getState, {}).then((r) => { - setState(r.state); - setTimeOffsetMs(r.state.serverNowMs - Date.now()); + timeOffsetMsRef.current = r.state.serverNowMs - Date.now(); + setPlayback(r.state); }); return api.on(ipcChannels.video.stateChanged, ({ state: next }) => { - setState(next); - setTimeOffsetMs(next.serverNowMs - Date.now()); + timeOffsetMsRef.current = next.serverNowMs - Date.now(); + setPlayback((prev) => { + if (prev?.revision === next.revision) return prev; + return next; + }); }); }, [api]); + const state = useMemo((): VideoPlaybackState | null => { + if (!playback) return null; + return { + ...playback, + get serverNowMs(): number { + return Date.now() + timeOffsetMsRef.current; + }, + }; + }, [playback]); + return [ - state ? { ...state, serverNowMs: clientNowMs + timeOffsetMs } : null, + state, { dispatch: async (event) => { await api.invoke(ipcChannels.video.dispatch, { event }); diff --git a/app/shared/video/videoPlaybackLoop.networkRegression.test.ts b/app/shared/video/videoPlaybackLoop.networkRegression.test.ts new file mode 100644 index 0000000..8ee2549 --- /dev/null +++ b/app/shared/video/videoPlaybackLoop.networkRegression.test.ts @@ -0,0 +1,17 @@ +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 root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..'); + +void test('video playback: control preview does not dispatch target.set on every render', () => { + const src = fs.readFileSync( + path.join(root, 'app', 'renderer', 'control', 'ControlScenePreview.tsx'), + 'utf8', + ); + // Регресс: зависимость `[... , scene, ...]` заставляла эффект с `target.set` срабатывать постоянно, + // сбрасывая видео (доля секунды проигрывается и начинается сначала). + assert.doesNotMatch(src, /\]\s*,\s*\[\s*[^\]]*\bscene\b/); +}); diff --git a/app/shared/video/videoPlaybackPerf.networkRegression.test.ts b/app/shared/video/videoPlaybackPerf.networkRegression.test.ts new file mode 100644 index 0000000..850e7de --- /dev/null +++ b/app/shared/video/videoPlaybackPerf.networkRegression.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 root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..'); + +void test('video playback: renderer hooks/components do not tick React each frame', () => { + const hookSrc = fs.readFileSync( + path.join(root, 'app', 'renderer', 'shared', 'video', 'useVideoPlaybackState.ts'), + 'utf8', + ); + // Регресс: раньше был setInterval(250ms) для clientNowMs, который заставлял перерисовываться окна. + assert.doesNotMatch(hookSrc, /\bsetInterval\s*\(/); + + const previewSrc = fs.readFileSync( + path.join(root, 'app', 'renderer', 'control', 'ControlScenePreview.tsx'), + 'utf8', + ); + // Регресс: раньше был RAF loop с setState на каждом кадре. + assert.doesNotMatch(previewSrc, /\brequestAnimationFrame\s*\(\s*loop\s*\)/); + assert.doesNotMatch(previewSrc, /\brequestAnimationFrame\s*\(\s*loop\b/); +}); diff --git a/package.json b/package.json index 0562857..edd2f6b 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/shared/ipc/contracts.mediaRemoval.test.ts app/shared/effectEraserHitTest.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/project/assetPrune.test.ts app/main/project/zipRead.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/main/license/verifyLicenseToken.test.ts && node --test scripts/build-env.test.mjs scripts/obfuscate-main.test.mjs", + "test": "tsx --test app/renderer/shared/ui/controls.tooltip.test.ts app/shared/ipc/contracts.mediaRemoval.test.ts app/shared/effectEraserHitTest.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/project/assetPrune.test.ts app/main/project/zipRead.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 && node --test scripts/build-env.test.mjs scripts/obfuscate-main.test.mjs", "format": "prettier . --check", "format:write": "prettier . --write", "release:info": "node scripts/print-release-info.mjs",