Compare commits

...

1 Commits

Author SHA1 Message Date
Ivan Fontosh add699a320 fix(video): prevent preview/presentation playback loop
- Avoid per-render target.set dispatch that reset playback

- Stop timer-based and per-frame React ticking while playing

- Add regression tests for render-loop sources

Made-with: Cursor
2026-04-20 18:30:47 +08:00
6 changed files with 85 additions and 34 deletions
+11 -15
View File
@@ -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)}
>
<track kind="captions" srcLang="ru" label="Превью без субтитров" />
</video>
+10 -1
View File
@@ -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 (
<div className={styles.root}>
@@ -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<void> },
] {
const api = getDndApi();
const [state, setState] = useState<VideoPlaybackState | null>(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<VideoPlaybackState | null>(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 });
@@ -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/);
});
@@ -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/);
});
+1 -1
View File
@@ -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",