Skip to content

Commit 2ca269f

Browse files
fix: BROS-669: Video regions/scrubbing stuck (#9157)
Co-authored-by: nick-skriabin <nick-skriabin@users.noreply.github.com>
1 parent 17f26c6 commit 2ca269f

File tree

3 files changed

+105
-29
lines changed

3 files changed

+105
-29
lines changed

web/libs/editor/src/tags/object/Video/HtxVideo.jsx

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,13 @@ const HtxVideoView = ({ item, store }) => {
142142
const [videoLength, _setVideoLength] = useState(0);
143143
const [playing, setPlaying] = useState(false);
144144
const [position, _setPosition] = useState(1);
145+
const scrubStateRef = useRef({
146+
isScrubbing: false,
147+
wasPlaying: false,
148+
timeoutId: null,
149+
lastChangeTime: 0,
150+
});
151+
const seekRafRef = useRef(null);
145152

146153
const [videoSize, setVideoSize] = useState(null);
147154
const [videoDimensions, setVideoDimensions] = useState({
@@ -454,15 +461,63 @@ const HtxVideoView = ({ item, store }) => {
454461
const handleTimelinePositionChange = useCallback(
455462
(newPosition) => {
456463
if (position !== newPosition) {
457-
item.setFrame(newPosition);
464+
const now = Date.now();
465+
const state = scrubStateRef.current;
466+
const isRapidScrubbing = now - state.lastChangeTime < 100;
467+
state.lastChangeTime = now;
468+
469+
// Handle pause/resume when scrubbing while playing
470+
if (playing && !state.isScrubbing) {
471+
state.isScrubbing = true;
472+
state.wasPlaying = true;
473+
item.ref.current?.pause();
474+
item.triggerSyncPause();
475+
476+
// Resume after scrubbing ends
477+
if (state.timeoutId) clearTimeout(state.timeoutId);
478+
state.timeoutId = setTimeout(() => {
479+
state.isScrubbing = false;
480+
if (state.wasPlaying && item.ref.current && !item.ref.current.playing) {
481+
const video = item.ref.current.videoRef?.current;
482+
const resume = () => {
483+
if (item.ref.current && !item.ref.current.playing) {
484+
item.ref.current.play();
485+
item.triggerSyncPlay();
486+
}
487+
};
488+
// Wait for seek to complete before resuming
489+
video?.seeking ? video.addEventListener("seeked", resume, { once: true }) : resume();
490+
}
491+
state.wasPlaying = false;
492+
}, 200);
493+
}
494+
458495
setPosition(newPosition);
496+
497+
// Batch rapid seeks, immediate seek for single changes
498+
if (seekRafRef.current) cancelAnimationFrame(seekRafRef.current);
499+
500+
if (isRapidScrubbing) {
501+
// Batch rapid scrubbing seeks
502+
seekRafRef.current = requestAnimationFrame(() => {
503+
seekRafRef.current = null;
504+
item.setFrame(newPosition);
505+
});
506+
} else {
507+
// Immediate seek for single frame changes (tests, single clicks)
508+
item.setFrame(newPosition);
509+
}
459510
}
460511
},
461-
[item, position],
512+
[item, position, playing],
462513
);
463514

464515
useEffect(
465516
() => () => {
517+
// Cleanup
518+
const state = scrubStateRef.current;
519+
if (state.timeoutId) clearTimeout(state.timeoutId);
520+
if (seekRafRef.current) cancelAnimationFrame(seekRafRef.current);
466521
item.ref.current = null;
467522
},
468523
[],
@@ -533,6 +588,7 @@ const HtxVideoView = ({ item, store }) => {
533588
workingArea={videoDimensions}
534589
allowRegionsOutsideWorkingArea={!limitCanvasDrawingBoundaries}
535590
stageRef={stageRef}
591+
currentFrame={position}
536592
/>
537593
)}
538594
<VideoCanvas

web/libs/editor/src/tags/object/Video/Video.js

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -372,12 +372,20 @@ const Model = types
372372
},
373373

374374
setFrame(frame) {
375-
if (self.frame !== frame && self.framerate) {
375+
if (self.frame !== frame && self.framerate && self.ref.current) {
376376
self.frame = frame;
377-
if (isFF(FF_VIDEO_FRAME_SEEK_PRECISION)) {
378-
self.ref.current.goToFrame(frame);
379-
} else {
380-
self.ref.current.currentTime = frame / self.framerate;
377+
378+
// Seek immediately - batching is handled at a higher level
379+
if (!self.ref.current) return;
380+
381+
try {
382+
if (isFF(FF_VIDEO_FRAME_SEEK_PRECISION)) {
383+
self.ref.current.goToFrame(frame);
384+
} else {
385+
self.ref.current.currentTime = frame / self.framerate;
386+
}
387+
} catch (error) {
388+
console.warn("Error seeking video:", error);
381389
}
382390
}
383391
},

web/libs/editor/src/tags/object/Video/VideoRegions.jsx

Lines changed: 34 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ const VideoRegionsPure = ({
3232
allowRegionsOutsideWorkingArea = true,
3333
pan = { x: 0, y: 0 },
3434
stageRef,
35+
currentFrame, // Add currentFrame prop to force re-renders when frame changes
3536
}) => {
3637
const [newRegion, setNewRegion] = useState();
3738
const [isDrawing, setDrawingMode] = useState(false);
@@ -214,6 +215,7 @@ const VideoRegionsPure = ({
214215
workinAreaCoordinates={workinAreaCoordinates}
215216
onDragMove={createOnDragMoveHandler(workinAreaCoordinates, !allowRegionsOutsideWorkingArea)}
216217
stageRef={stageRef}
218+
currentFrame={currentFrame}
217219
/>
218220
</Layer>
219221
{!item.annotation?.isReadOnly() && isDrawing ? (
@@ -237,28 +239,38 @@ const VideoRegionsPure = ({
237239
);
238240
};
239241

240-
const RegionsLayer = observer(({ regions, item, locked, isDrawing, workinAreaCoordinates, stageRef, onDragMove }) => {
241-
return (
242-
<>
243-
{regions.map((reg) => (
244-
<Shape
245-
id={reg.id}
246-
key={reg.id}
247-
reg={reg}
248-
frame={item.frame}
249-
workingArea={workinAreaCoordinates}
250-
draggable={!reg.isReadOnly() && !isDrawing && !locked}
251-
selected={reg.selected || reg.inSelection}
252-
listening={!reg.locked && !reg.hidden}
253-
stageRef={stageRef}
254-
onDragMove={onDragMove}
255-
/>
256-
))}
257-
</>
258-
);
259-
});
260-
261-
const Shape = observer(({ id, reg, frame, stageRef, ...props }) => {
242+
const RegionsLayer = observer(
243+
({ regions, item, locked, isDrawing, workinAreaCoordinates, stageRef, onDragMove, currentFrame }) => {
244+
// Use currentFrame prop (from React state) to ensure regions update during fast scrubbing
245+
// Since item.frame is volatile, React state triggers re-renders
246+
const frame = currentFrame ?? item.frame;
247+
248+
return (
249+
<>
250+
{regions.map((reg) => (
251+
<Shape
252+
id={reg.id}
253+
key={reg.id}
254+
reg={reg}
255+
item={item}
256+
workingArea={workinAreaCoordinates}
257+
draggable={!reg.isReadOnly() && !isDrawing && !locked}
258+
selected={reg.selected || reg.inSelection}
259+
listening={!reg.locked && !reg.hidden}
260+
stageRef={stageRef}
261+
onDragMove={onDragMove}
262+
currentFrame={frame}
263+
/>
264+
))}
265+
</>
266+
);
267+
},
268+
);
269+
270+
const Shape = observer(({ id, reg, item, stageRef, currentFrame, ...props }) => {
271+
// Use currentFrame prop to ensure we get the latest frame value during fast scrubbing
272+
// Since item.frame is volatile, React state (currentFrame) ensures proper updates
273+
const frame = currentFrame ?? item.frame;
262274
const box = reg.getShape(frame);
263275

264276
return (

0 commit comments

Comments
 (0)