- 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 抽成獨立元件,元件只回傳 Freeze | 49 | — |
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 秒裡字幕不動,是因為字幕也讀同一個時鐘。只想讓影片停,就只包影片,其他東西用成片時間自己算;跟畫面內容綁在一起的游標、模糊框,用來源時間,跟著畫面一起停。
修訂紀錄
- 首次發布