包包baobaolin.com
date
entry
054
topic
tooling
rev
—

Remotion 的 <Freeze> 凍住的是時間,不只是畫面:停格 22 秒,字幕和框線一起停在同一句

錄影的最後一格要停 22.36 秒,讓字幕講完。用 <Freeze> 包住整個場景之後,字幕和框線也停在同一句:後面兩句字幕、一個框線都沒出現。<Freeze> 換掉的是底下每個元件讀到的 frame,不分影片還是疊加層。

我用 Remotion(用 React 元件寫影片的框架,這裡是 4.0.536)剪螢幕操作的教學影片。毛片放進一個圓角視窗,上面疊字幕、橘色框線、放大的游標和模糊區塊。影片停格是很普通的需求:畫面停在最後一格,等旁白或字幕講完。Remotion 有現成的 <Freeze>,包起來就停。

問題是它停得太徹底。這篇依序講:管線怎麼排停格、第一版為什麼把字幕和框線一起凍住(看原始碼)、中間修了一半的版本、最後把影片和疊加層拆成兩個時鐘的寫法。最後一段是重現時多踩到的一件事:凍結格算出來超過合成結尾時,讀到的 frame 會被夾住。

停格怎麼來的:字幕比畫面長

每段畫面(程式裡叫 shot)是一段毛片的 in/out 秒數,加上字幕、框線(highlight)這些疊加層,疊加層的時間寫成「這段開始後的成片秒數」。字幕長度依字數算:字數除以每秒 4.3 字再加 0.35 秒、最短 1.8 秒,從 0.3 秒開始一句接一句排。排完如果比畫面長,組 shot 的腳本就補上停格:

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

出問題的那段是「設定 › 記憶」頁面的錄影:

項目值
毛片區間16 到 44 秒,28 秒,30 fps 下 840 格
字幕7 句,最後一句在 49.96 秒結束
停格49.96 + 0.4 − 28 = 22.36 秒(腳本取到小數兩位),換算 671 格
停格中該出現的第 6、7 句字幕(36.47、45.19 秒起)、「匯出」框線(36.47 秒起)

第一版(10 月 25 日)的停格寫法,是在播放的 Sequence 後面接一段,整個 shot 包進 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>
)}

為什麼字幕也停:Freeze 換掉的是時鐘

ShotView 裡的字幕、框線、推近倍率,全部從 useCurrentFrame() 換算成秒數再決定要畫什麼。<Freeze> 做的事在 node_modules/remotion/dist/cjs/freeze.js 裡寫得很直接:它提供一個新的 TimelineContext,裡面的 frame 是固定值,同時把 SequenceContext 的 cumulatedFrom 設成 0:

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

useCurrentFrame() 的算法是 timeline 的 frame 減掉 cumulatedFrom + relativeFrom,再乘上 playbackRate。兩邊的 relativeFrom 抵銷,Freeze 底下的元件讀到的就是 frameToFreeze,這裡是 839。這個抵銷有兩個前提:中間沒有再經過別的 Sequence 改變座標,以及 timeline frame 沒有被夾住(最後一節就是被夾住的例子)。對 ShotView 來說,停格的 22.36 秒裡每一格都是 27.97 秒:字幕停在第 5 句(「從其他 AI 匯入記憶」那句),框線停在「開始匯入」按鈕上,第 6、7 句字幕和「匯出」框線一直沒輪到。

Freeze 沒有「只凍影片」的意思。原始碼的註解寫的是 Freezes its children at the specified frame:它把一個固定的時鐘交給整個子樹,影片只是剛好也讀這個時鐘的元件之一。

修一半:字幕拿出來,框線還在裡面

第一次的修法是把字幕從 Freeze 裡拿出來,另外用一個元件照時間跑:

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

HoldCaptions 在 Freeze 外面,讀到的是停格 Sequence 自己的 frame,加上 offset(播放段的秒數)就接上了。字幕會往下走了。抽一格停格中的畫面,框線還停在「開始匯入」:框線、推近、游標都是 ShotView 裡面畫的,還在 Freeze 底下。照這個方向,每一種疊加層都要搬出來,再各自補 offset。

最後的寫法:兩個時鐘

換個方向:Freeze 只包影片,ShotView 自己知道現在是不是停格段。停格段傳入 holdFrames(播放段的格數),裡面算兩個時間:

const t = (holdFrames ? holdFrames + frame : frame) / fps; // 成片秒數
const tau = holdFrames
  ? shot.in + ((holdFrames - 1) / fps) * speed            // 來源秒數:停在最後一格
  : shot.in + t * speed;
...
{holdFrames
  ? <Freeze frame={holdFrames - 1}><OffthreadVideo ... /></Freeze>
  : <OffthreadVideo ... />}
  • t 是成片時間,停格段從播放段的最後接著往下數。字幕、框線、拖曳箭頭、標籤(callout)、推近都用它,所以照原本的時間表繼續出現、淡出。
  • tau 是毛片的來源時間,停格時固定在最後播放的那一格。游標位置和模糊區塊的出現區間用它,因為它們要對齊畫面裡的內容;畫面停了,游標和遮住通知的模糊框也該停在畫面上同一個位置。它們和影片在同一層 transform 底下,推近時跟著畫面一起放大移動。

停格段的組法跟著改,螢幕錄影走 holdFrames;動畫字卡(card)維持整張凍住,shot.captions 交給 HoldCaptions 接著跑(條件式裡的 gap 是另一種系列字卡,字幕來源不同,組 shot 的腳本也不會替它加停格):

{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} />}

三種寫法抽格比對

把這段單獨做成一個合成(840 + 671 = 1,511 格,尾端多留 200 格,原因見下一節),三種寫法各抽兩格:

寫法第 1150 格(38.33 秒)第 1400 格(46.67 秒)
整段包 Freeze第 5 句字幕、「開始匯入」框線同左
Freeze + HoldCaptions第 6 句字幕、「開始匯入」框線第 7 句字幕、「開始匯入」框線
holdFrames第 6 句字幕、「匯出」框線第 7 句字幕、沒有框線

最後一列和時間表一致:「匯出」框線在 36.47 秒出現、42.2 秒結束,46.67 秒時本來就不該有框線。另外在 ShotView 裡加一行印出 useCurrentFrame():整段包 Freeze 時讀到 839,holdFrames 版本在第 1150 格讀到 310(停格 Sequence 內的第 310 格)。

重現時多踩到的:凍結格超出合成結尾會被夾住

第一次做這個單獨的合成時,沒有在尾端多留格數,整段包 Freeze 的版本在停格中顯示的是第 4 句字幕,比原本的問題還早一句。印出來,ShotView 讀到的是 670,不是 839。

數字對得上 timeline-position-state.js 的 clampFrameToCompositionRange:useCurrentFrame() 先取 timeline frame、夾進合成範圍,才扣掉 offset。凍結的 timeline frame 是 839 + 840(停格 Sequence 的起點)= 1,679,超過合成的最後一格 1,510,被夾成 1,510,減掉 840 就是 670。正式的整集裡,這段後面還有別的場景,「停格起點再往後 839 格」落在合成範圍內,不會被夾;當初在整集裡抽格看到框線停在「開始匯入」,和 839 對得上。

用一個最小的合成確認(remotion still 輸出第 120 格;Sequence 從第 100 格開始、長 50 格,<Freeze frame={80}>,子元件印出 useCurrentFrame(),旁邊放一段會數秒的測試影片):

結構合成 150 格合成 300 格
<Sequence><Freeze> 直接寫在一起49,影片也是第 49 格80
中間隔一個 <div>49—
Freeze 抽成獨立元件,元件只回傳 Freeze49—
Sequence 加 premountFor={45}49—
Freeze 外面包一層 <AbsoluteFill>80—

我一開始以為差別在元件邊界,拆開變因後才發現是 AbsoluteFill。4.0.536 的 AbsoluteFill.js 不只是一個 div:它把 children 包在 <Sequence layout="none" from={0}> 裡。進了這層 Sequence,外面的 100 格 offset 移到 cumulatedFrom,relativeFrom 變成 0;Freeze 寫進 timeline 的就是 80 + 0 = 80,沒有超過 149,不會被夾,再把 cumulatedFrom 清成 0,讀到 80。沒有這層時寫進去的是 80 + 100 = 180,夾成 149,減 100 得 49。這是對著原始碼推的,和表上每個數字都對得上。

現在的 ShotView 最外層就是 AbsoluteFill,Freeze 在它裡面;舊版的 Freeze 則直接放在停格 Sequence 底下。拿同一個管線裡另一集試,最後一段是播放 75 格、停格 36 格,凍結格算出來超過合成結尾。在副本裡用 remotion still 抽停格中的第 2330 格:換回舊的整段包 Freeze,讀到 35;現在的寫法,Freeze 裡讀到 74,把來源換成數秒影片,畫面顯示第 2 秒(第 35 格會是第 1 秒),和 74 一致。以上都只用 remotion still 單格輸出測過,Studio 預覽沒有測。

我現在對停格的檢查是兩件事:抽一格停格中、而且時間表上應該換字幕或框線的格子,看疊加層有沒有換;以及最後一段有停格時,抽停格中的一格,確認影片停在播放段的最後一格,不是更早的地方。

如果只記得一件事

<Freeze> 凍住的是它底下所有元件的時鐘。開頭那 22 秒裡字幕不動,是因為字幕也讀同一個時鐘。只想讓影片停,就只包影片,其他東西用成片時間自己算;跟畫面內容綁在一起的游標、模糊框,用來源時間,跟著畫面一起停。

修訂紀錄

  1. 首次發布