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
This commit is contained in:
Ivan Fontosh
2026-04-20 18:30:47 +08:00
parent 2ce1e02753
commit add699a320
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>