Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 58 additions & 2 deletions web/libs/editor/src/tags/object/Video/HtxVideo.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,13 @@ const HtxVideoView = ({ item, store }) => {
const [videoLength, _setVideoLength] = useState(0);
const [playing, setPlaying] = useState(false);
const [position, _setPosition] = useState(1);
const scrubStateRef = useRef({
isScrubbing: false,
wasPlaying: false,
timeoutId: null,
lastChangeTime: 0,
});
const seekRafRef = useRef(null);

const [videoSize, setVideoSize] = useState(null);
const [videoDimensions, setVideoDimensions] = useState({
Expand Down Expand Up @@ -449,15 +456,63 @@ const HtxVideoView = ({ item, store }) => {
const handleTimelinePositionChange = useCallback(
(newPosition) => {
if (position !== newPosition) {
item.setFrame(newPosition);
const now = Date.now();
const state = scrubStateRef.current;
const isRapidScrubbing = now - state.lastChangeTime < 100;
state.lastChangeTime = now;

// Handle pause/resume when scrubbing while playing
if (playing && !state.isScrubbing) {
state.isScrubbing = true;
state.wasPlaying = true;
item.ref.current?.pause();
item.triggerSyncPause();

// Resume after scrubbing ends
if (state.timeoutId) clearTimeout(state.timeoutId);
state.timeoutId = setTimeout(() => {
state.isScrubbing = false;
if (state.wasPlaying && item.ref.current && !item.ref.current.playing) {
const video = item.ref.current.videoRef?.current;
const resume = () => {
if (item.ref.current && !item.ref.current.playing) {
item.ref.current.play();
item.triggerSyncPlay();
}
};
// Wait for seek to complete before resuming
video?.seeking ? video.addEventListener("seeked", resume, { once: true }) : resume();
}
state.wasPlaying = false;
}, 200);
}

setPosition(newPosition);

// Batch rapid seeks, immediate seek for single changes
if (seekRafRef.current) cancelAnimationFrame(seekRafRef.current);

if (isRapidScrubbing) {
// Batch rapid scrubbing seeks
seekRafRef.current = requestAnimationFrame(() => {
seekRafRef.current = null;
item.setFrame(newPosition);
});
} else {
// Immediate seek for single frame changes (tests, single clicks)
item.setFrame(newPosition);
}
}
},
[item, position],
[item, position, playing],
);

useEffect(
() => () => {
// Cleanup
const state = scrubStateRef.current;
if (state.timeoutId) clearTimeout(state.timeoutId);
if (seekRafRef.current) cancelAnimationFrame(seekRafRef.current);
item.ref.current = null;
},
[],
Expand Down Expand Up @@ -528,6 +583,7 @@ const HtxVideoView = ({ item, store }) => {
workingArea={videoDimensions}
allowRegionsOutsideWorkingArea={!limitCanvasDrawingBoundaries}
stageRef={stageRef}
currentFrame={position}
/>
)}
<VideoCanvas
Expand Down
18 changes: 13 additions & 5 deletions web/libs/editor/src/tags/object/Video/Video.js
Original file line number Diff line number Diff line change
Expand Up @@ -372,12 +372,20 @@ const Model = types
},

setFrame(frame) {
if (self.frame !== frame && self.framerate) {
if (self.frame !== frame && self.framerate && self.ref.current) {
self.frame = frame;
if (isFF(FF_VIDEO_FRAME_SEEK_PRECISION)) {
self.ref.current.goToFrame(frame);
} else {
self.ref.current.currentTime = frame / self.framerate;

// Seek immediately - batching is handled at a higher level
if (!self.ref.current) return;

try {
if (isFF(FF_VIDEO_FRAME_SEEK_PRECISION)) {
self.ref.current.goToFrame(frame);
} else {
self.ref.current.currentTime = frame / self.framerate;
}
} catch (error) {
console.warn("Error seeking video:", error);
}
}
},
Expand Down
56 changes: 34 additions & 22 deletions web/libs/editor/src/tags/object/Video/VideoRegions.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ const VideoRegionsPure = ({
allowRegionsOutsideWorkingArea = true,
pan = { x: 0, y: 0 },
stageRef,
currentFrame, // Add currentFrame prop to force re-renders when frame changes
}) => {
const [newRegion, setNewRegion] = useState();
const [isDrawing, setDrawingMode] = useState(false);
Expand Down Expand Up @@ -214,6 +215,7 @@ const VideoRegionsPure = ({
workinAreaCoordinates={workinAreaCoordinates}
onDragMove={createOnDragMoveHandler(workinAreaCoordinates, !allowRegionsOutsideWorkingArea)}
stageRef={stageRef}
currentFrame={currentFrame}
/>
</Layer>
{!item.annotation?.isReadOnly() && isDrawing ? (
Expand All @@ -237,28 +239,38 @@ const VideoRegionsPure = ({
);
};

const RegionsLayer = observer(({ regions, item, locked, isDrawing, workinAreaCoordinates, stageRef, onDragMove }) => {
return (
<>
{regions.map((reg) => (
<Shape
id={reg.id}
key={reg.id}
reg={reg}
frame={item.frame}
workingArea={workinAreaCoordinates}
draggable={!reg.isReadOnly() && !isDrawing && !locked}
selected={reg.selected || reg.inSelection}
listening={!reg.locked && !reg.hidden}
stageRef={stageRef}
onDragMove={onDragMove}
/>
))}
</>
);
});

const Shape = observer(({ id, reg, frame, stageRef, ...props }) => {
const RegionsLayer = observer(
({ regions, item, locked, isDrawing, workinAreaCoordinates, stageRef, onDragMove, currentFrame }) => {
// Use currentFrame prop (from React state) to ensure regions update during fast scrubbing
// Since item.frame is volatile, React state triggers re-renders
const frame = currentFrame ?? item.frame;

return (
<>
{regions.map((reg) => (
<Shape
id={reg.id}
key={reg.id}
reg={reg}
item={item}
workingArea={workinAreaCoordinates}
draggable={!reg.isReadOnly() && !isDrawing && !locked}
selected={reg.selected || reg.inSelection}
listening={!reg.locked && !reg.hidden}
stageRef={stageRef}
onDragMove={onDragMove}
currentFrame={frame}
/>
))}
</>
);
},
);

const Shape = observer(({ id, reg, item, stageRef, currentFrame, ...props }) => {
// Use currentFrame prop to ensure we get the latest frame value during fast scrubbing
// Since item.frame is volatile, React state (currentFrame) ensures proper updates
const frame = currentFrame ?? item.frame;
const box = reg.getShape(frame);

return (
Expand Down
Loading