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