Baobaobaolin.com
date
entry
054
topic
tooling
rev
—

Remotion's <Freeze> freezes the clock, not just the picture: a 22-second hold left captions and highlights stuck on one line

The last frame of a recording had to hold for 22.36 seconds so the captions could finish. With the whole scene wrapped in <Freeze>, the captions and highlight boxes held too: two captions and one highlight never appeared. <Freeze> replaces the frame that every component below it reads, video and overlays alike.

I edit screen-recording tutorials in Remotion (a framework for writing video as React components; version 4.0.536 here). The raw footage sits in a rounded window, with captions, orange highlight boxes, an enlarged cursor and blur boxes layered on top. Holding a frame is an ordinary need: the picture stops on its last frame while the captions or narration finish. Remotion ships <Freeze> for this; wrap something in it and it stops.

The trouble is that it stops too much. In order: how the pipeline schedules a hold, why the first version froze captions and highlights along with the video (from the source), a half-fix, and the final version that splits video and overlays onto two clocks. The last section is something I hit while reproducing it: when the frozen frame works out to a position past the end of the composition, the frame children read gets clamped.

Where holds come from: captions longer than the footage

Each shot is an in/out range in seconds of raw footage, plus overlays such as captions and highlights, timed in output seconds from the start of the shot. Caption length comes from character count: characters divided by 4.3 per second, plus 0.35 seconds, at least 1.8 seconds, placed one after another starting at 0.3 seconds. If they run longer than the footage, the script that builds the shots adds a hold:

need = t + 0.4
if need > play: sc["hold"] = round(need - play, 2)

The shot that broke was a recording of a Settings › Memory page:

ItemValue
Footage range16 to 44 s: 28 s, or 840 frames at 30 fps
Captions7, the last ending at 49.96 s
Hold49.96 + 0.4 − 28 = 22.36 s (the script rounds to two decimals), which becomes 671 frames
Due during the holdcaptions 6 and 7 (from 36.47 s and 45.19 s) and the Export highlight (from 36.47 s)

The first version (October 25) appended a second Sequence after the playing one and wrapped the whole shot in Freeze:

<Sequence from={at} durationInFrames={play}>
  <ShotView shot={s} />
</Sequence>
{dur > play && (
  <Sequence from={at + play} durationInFrames={dur - play}>
    <Freeze frame={play - 1}><ShotView shot={s} /></Freeze>
  </Sequence>
)}

Why the captions stopped: Freeze replaces the clock

Inside ShotView, captions, highlights and zoom are all derived from useCurrentFrame() converted to seconds. What <Freeze> does is plain in node_modules/remotion/dist/cjs/freeze.js: it provides a new TimelineContext whose frame is a fixed value, and sets cumulatedFrom in the SequenceContext to 0:

frame: {
  [videoConfig.id]: frameToFreeze / playbackRate + relativeFrom,
},
...
return { ...sequenceContext, cumulatedFrom: 0 };

useCurrentFrame() takes the timeline frame, subtracts cumulatedFrom + relativeFrom, and multiplies by the playback rate. The two relativeFrom terms cancel, so components below the Freeze read frameToFreeze, here 839. That cancellation has two preconditions: no other Sequence in between changes the coordinates, and the timeline frame is not clamped (the last section is a case where it is). For ShotView, every frame of the 22.36-second hold was 27.97 seconds: the caption stayed on line 5 (the one about importing memory from other AIs), the highlight stayed on the Start import button, and captions 6 and 7 and the Export highlight never got their turn.

Freeze has no notion of "freeze only the video". The comment in the source reads Freezes its children at the specified frame: it hands one fixed clock to the whole subtree, and the video is just one of the components that happens to read it.

Half a fix: captions out, highlights still in

The first fix pulled captions out of Freeze and ran them in a separate component:

<Freeze frame={play - 1}><SceneView shot={{ ...s, captions: undefined }} /></Freeze>
<HoldCaptions captions={s.captions} offset={play / SD_FPS} />

HoldCaptions sits outside Freeze and reads the hold Sequence's own frame; adding offset (the length of the playing part, in seconds) lines it up. The captions moved again. A still from the hold showed the highlight still on Start import: highlights, zoom and cursor are all drawn inside ShotView, which was still under Freeze. Down that road, every kind of overlay would have to move out and get its own offset.

The final version: two clocks

The other direction: Freeze wraps only the video, and ShotView knows whether it is rendering a hold. The hold Sequence passes holdFrames (the frame count of the playing part), and two times are computed:

const t = (holdFrames ? holdFrames + frame : frame) / fps; // output seconds
const tau = holdFrames
  ? shot.in + ((holdFrames - 1) / fps) * speed            // source seconds: pinned to the last frame
  : shot.in + t * speed;
...
{holdFrames
  ? <Freeze frame={holdFrames - 1}><OffthreadVideo ... /></Freeze>
  : <OffthreadVideo ... />}
  • t is output time; during the hold it keeps counting from where the playing part ended. Captions, highlights, drag arrows, callouts and zoom use it, so they keep appearing and fading on schedule.
  • tau is source time in the raw footage, pinned to the last played frame during the hold. The cursor position and the time windows of blur boxes use it because they have to line up with what is in the picture; when the picture stops, the cursor and the blur over notifications should stay on the same spot in the picture. They sit under the same transform as the video, so they still scale and move with the zoom.

The hold Sequence changed with it: screen recordings use holdFrames; animated title cards (card) are still frozen whole, with shot.captions handed to HoldCaptions (the gap branch in the condition is another kind of series card whose captions come from elsewhere, and the shot-building script never gives it a hold):

{s.card || s.gap
  ? <><Freeze frame={play - 1}><SceneView shot={{ ...s, captions: undefined }} /></Freeze>
      <HoldCaptions captions={s.captions} offset={play / SD_FPS} /></>
  : <SceneView shot={s} holdFrames={play} />}

Stills from the three versions

I put this one shot into a composition of its own (840 + 671 = 1,511 frames, plus 200 spare frames at the end, for the reason in the next section) and took two stills from each version:

VersionFrame 1150 (38.33 s)Frame 1400 (46.67 s)
Whole shot in Freezecaption 5, Start import highlightsame
Freeze + HoldCaptionscaption 6, Start import highlightcaption 7, Start import highlight
holdFramescaption 6, Export highlightcaption 7, no highlight

The last row matches the schedule: the Export highlight runs from 36.47 to 42.2 seconds, so there should be no highlight at 46.67. I also added a line in ShotView that prints useCurrentFrame(): with the whole shot in Freeze it reads 839; in the holdFrames version at frame 1150 it reads 310 (frame 310 of the hold Sequence).

What I hit while reproducing it: a frozen frame past the end gets clamped

The first time I built that standalone composition, without the spare frames, the whole-shot Freeze version showed caption 4 during the hold, one line earlier than the original bug. Printed, ShotView was reading 670, not 839.

The numbers match clampFrameToCompositionRange in timeline-position-state.js: useCurrentFrame() takes the timeline frame and clamps it to the composition range before subtracting offsets. The frozen timeline frame is 839 + 840 (where the hold Sequence starts) = 1,679, past the composition's last frame of 1,510, so it is clamped to 1,510; minus 840 gives 670. In the full episode more scenes follow this shot, so "hold start plus 839 frames" falls inside the composition and nothing is clamped; the still I took from the full episode back then showed the highlight stuck on Start import, which matches 839.

A minimal composition to confirm (remotion still at frame 120; a Sequence from frame 100, 50 frames long, <Freeze frame={80}>, a child printing useCurrentFrame(), next to a test video that counts seconds):

Structure150-frame composition300-frame composition
<Sequence><Freeze> written together49, video also at frame 4980
A <div> in between49—
Freeze moved into its own component that returns only the Freeze49—
Sequence with premountFor={45}49—
An <AbsoluteFill> around the Freeze80—

At first I thought the difference was the component boundary; separating the variables showed it is AbsoluteFill. In 4.0.536, AbsoluteFill.js is more than a div: it wraps its children in <Sequence layout="none" from={0}>. Inside that Sequence, the outer 100-frame offset moves into cumulatedFrom and relativeFrom becomes 0; Freeze writes 80 + 0 = 80 to the timeline, which is within 149 and not clamped, then resets cumulatedFrom to 0, so the child reads 80. Without that layer it writes 80 + 100 = 180, clamped to 149, minus 100 gives 49. This is read off the source, and it accounts for every number in the table.

The current ShotView has an AbsoluteFill as its outermost element, with the Freeze inside it; the old version put the Freeze directly under the hold Sequence. I tried another episode from the same pipeline whose last shot plays 75 frames and holds 36, so its frozen frame lands past the end of the composition. In a copy, using remotion still at frame 2330 inside the hold: with the old whole-shot Freeze it reads 35; with the current code a child inside the Freeze reads 74, and with the source swapped for the second-counting video the picture shows second 2 (frame 35 would show second 1), consistent with 74. All of this was tested only with single-frame remotion still output, not in the Studio preview.

My check for holds is now two things: a still from inside a hold at a moment when the schedule says a caption or highlight should change, to see whether the overlay changes; and, when the last shot has a hold, a still from inside it to confirm the video stopped on the last played frame rather than somewhere earlier.

If you remember one thing

<Freeze> freezes the clock of every component below it. The captions in those 22 seconds didn't move because they read the same clock. To stop only the video, wrap only the video and compute everything else from output time; things tied to the picture, like the cursor and blur boxes, use source time and stop with it.

Revision history

  1. First published