Skip to content

Commit 090298d

Browse files
fix: BROS-677: Fix video stuck on scrub (#9033)
Co-authored-by: nick-skriabin <nick-skriabin@users.noreply.github.com>
1 parent 841dffd commit 090298d

File tree

3 files changed

+105
-37
lines changed

3 files changed

+105
-37
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({
@@ -449,15 +456,63 @@ const HtxVideoView = ({ item, store }) => {
449456
const handleTimelinePositionChange = useCallback(
450457
(newPosition) => {
451458
if (position !== newPosition) {
452-
item.setFrame(newPosition);
459+
const now = Date.now();
460+
const state = scrubStateRef.current;
461+
const isRapidScrubbing = now - state.lastChangeTime < 100;
462+
state.lastChangeTime = now;
463+
464+
// Handle pause/resume when scrubbing while playing
465+
if (playing && !state.isScrubbing) {
466+
state.isScrubbing = true;
467+
state.wasPlaying = true;
468+
item.ref.current?.pause();
469+
item.triggerSyncPause();
470+
471+
// Resume after scrubbing ends
472+
if (state.timeoutId) clearTimeout(state.timeoutId);
473+
state.timeoutId = setTimeout(() => {
474+
state.isScrubbing = false;
475+
if (state.wasPlaying && item.ref.current && !item.ref.current.playing) {
476+
const video = item.ref.current.videoRef?.current;
477+
const resume = () => {
478+
if (item.ref.current && !item.ref.current.playing) {
479+
item.ref.current.play();
480+
item.triggerSyncPlay();
481+
}
482+
};
483+
// Wait for seek to complete before resuming
484+
video?.seeking ? video.addEventListener("seeked", resume, { once: true }) : resume();
485+
}
486+
state.wasPlaying = false;
487+
}, 200);
488+
}
489+
453490
setPosition(newPosition);
491+
492+
// Batch rapid seeks, immediate seek for single changes
493+
if (seekRafRef.current) cancelAnimationFrame(seekRafRef.current);
494+
495+
if (isRapidScrubbing) {
496+
// Batch rapid scrubbing seeks
497+
seekRafRef.current = requestAnimationFrame(() => {
498+
seekRafRef.current = null;
499+
item.setFrame(newPosition);
500+
});
501+
} else {
502+
// Immediate seek for single frame changes (tests, single clicks)
503+
item.setFrame(newPosition);
504+
}
454505
}
455506
},
456-
[item, position],
507+
[item, position, playing],
457508
);
458509

459510
useEffect(
460511
() => () => {
512+
// Cleanup
513+
const state = scrubStateRef.current;
514+
if (state.timeoutId) clearTimeout(state.timeoutId);
515+
if (seekRafRef.current) cancelAnimationFrame(seekRafRef.current);
461516
item.ref.current = null;
462517
},
463518
[],
@@ -528,6 +583,7 @@ const HtxVideoView = ({ item, store }) => {
528583
workingArea={videoDimensions}
529584
allowRegionsOutsideWorkingArea={!limitCanvasDrawingBoundaries}
530585
stageRef={stageRef}
586+
currentFrame={position}
531587
/>
532588
)}
533589
<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 & 30 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,36 +239,38 @@ const VideoRegionsPure = ({
237239
);
238240
};
239241

240-
const RegionsLayer = observer(({ regions, item, locked, isDrawing, workinAreaCoordinates, stageRef, onDragMove }) => {
241-
// Access item.frame here to ensure the observer tracks frame changes
242-
// This ensures regions update correctly during fast scrubbing
243-
const frame = item.frame;
244-
245-
return (
246-
<>
247-
{regions.map((reg) => (
248-
<Shape
249-
id={reg.id}
250-
key={reg.id}
251-
reg={reg}
252-
item={item}
253-
workingArea={workinAreaCoordinates}
254-
draggable={!reg.isReadOnly() && !isDrawing && !locked}
255-
selected={reg.selected || reg.inSelection}
256-
listening={!reg.locked && !reg.hidden}
257-
stageRef={stageRef}
258-
onDragMove={onDragMove}
259-
/>
260-
))}
261-
</>
262-
);
263-
});
264-
265-
const Shape = observer(({ id, reg, item, stageRef, ...props }) => {
266-
// Access item.frame directly inside the observer to ensure MobX tracks it
267-
// Even though frame is volatile, accessing it here ensures the observer
268-
// will re-render when frame changes during fast scrubbing
269-
const frame = item.frame;
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;
270274
const box = reg.getShape(frame);
271275

272276
return (

0 commit comments

Comments
 (0)