影片跳切(Jump Cut)
學習如何在 Remotion 中實作跳切效果,跳過影片中的特定片段(例如口誤、停頓),並確保瀏覽器預覽與最終渲染都能精確呈現
影片跳切(Jump Cut)
跳切(Jump Cut)是影片剪輯中常用的技巧,用於跳過影片中不需要的片段,例如說話中的「呃」、停頓,或是任何你想略去的內容。
本文說明在 Remotion 中實作跳切時需要考慮的技術細節,以及如何確保瀏覽器預覽和最終渲染的準確性。
實作前的考量
在 Remotion 中實作跳切時,有三個主要情境需要分開處理:
情境一:小幅度跳切(1~2 秒以內) 瀏覽器可能已預先載入接下來幾秒的影片。可以重複使用同一個影片標籤,瀏覽器能直接 seek 到新位置。
情境二:大幅度跳切(超過 2 秒) 目標位置的影片尚未載入。應預先掛載第二個影片標籤,讓它在背景靜默載入。
情境三:極小幅度跳切(小於 0.45 秒)
Remotion 預設不會 seek 這麼小的時間差(由 acceptableTimeshiftInSeconds 控制)。需要暫時將這個值設為接近零,強制觸發 seek。
重要: 以上考量只影響瀏覽器中的預覽流暢度。在正式渲染時,Remotion 逐幀獨立處理,無論如何都能做到逐幀精確。
情境一:重複使用同一個影片標籤
針對小幅度跳切,以下是完整的實作範例,同時也處理了情境三(極小跳切強制 seek):
import React, {useMemo} from 'react';
import {
CalculateMetadataFunction,
OffthreadVideo,
staticFile,
useCurrentFrame,
} from 'remotion';
const fps = 30;
// 定義一個片段的型別:trimBefore 和 trimAfter 的單位都是「幀」
type Section = {
trimBefore: number;
trimAfter: number;
};
// 範例片段:保留 0-5 秒、7-10 秒、13-18 秒,其餘跳過
export const SAMPLE_SECTIONS: Section[] = [
{trimBefore: 0, trimAfter: 5 * fps},
{
trimBefore: 7 * fps,
trimAfter: 10 * fps,
},
{
trimBefore: 13 * fps,
trimAfter: 18 * fps,
},
];
type Props = {
sections: Section[];
};
// 計算合成總時長:所有片段的時長加總
export const calculateMetadata: CalculateMetadataFunction<Props> = ({props}) => {
const durationInFrames = props.sections.reduce((acc, section) => {
return acc + section.trimAfter - section.trimBefore;
}, 0);
return {
fps,
durationInFrames,
};
};
export const JumpCuts: React.FC<Props> = ({sections}) => {
const frame = useCurrentFrame();
// 根據當前合成幀,計算應播放影片的哪個位置
const cut = useMemo(() => {
let summedUpDurations = 0;
for (const section of sections) {
summedUpDurations += section.trimAfter - section.trimBefore;
if (summedUpDurations > frame) {
// 計算在此片段內,目前應播放的影片幀號
const trimBefore = section.trimAfter - summedUpDurations;
const offset = section.trimBefore - frame - trimBefore;
return {
trimBefore,
// 是否為此片段的第一幀(需要強制 seek)
firstFrameOfSection: offset === 0,
};
}
}
return null;
}, [frame, sections]);
if (cut === null) {
return null;
}
return (
<OffthreadVideo
pauseWhenBuffering
trimBefore={cut.trimBefore}
// Remotion 預設會根據 trimBefore/trimAfter 自動在 URL 後加上時間片段
// 在此我們手動加上 #t=0, 來覆蓋此行為,避免衝突
// 參考:https://www.remotion.dev/docs/media-fragments
src={`${staticFile('time.mp4')}#t=0,`}
// 若為片段的第一幀,強制 seek(即使跳切幅度極小)
acceptableTimeShiftInSeconds={cut.firstFrameOfSection ? 0.000001 : undefined}
/>
);
};程式碼深度解析
calculateMetadata:動態計算合成時長
export const calculateMetadata: CalculateMetadataFunction<Props> = ({props}) => {
const durationInFrames = props.sections.reduce((acc, section) => {
return acc + section.trimAfter - section.trimBefore;
}, 0);
return { fps, durationInFrames };
};合成的時長等於所有保留片段的時長加總。以 SAMPLE_SECTIONS 為例:
- 第一段:5 * 30 = 150 幀
- 第二段:3 * 30 = 90 幀
- 第三段:5 * 30 = 150 幀
- 合計:390 幀(13 秒)
useMemo 中的幀位置計算
const cut = useMemo(() => {
let summedUpDurations = 0;
for (const section of sections) {
summedUpDurations += section.trimAfter - section.trimBefore;
if (summedUpDurations > frame) {
const trimBefore = section.trimAfter - summedUpDurations;
// ...
}
}
}, [frame, sections]);這段邏輯將「合成時間」對應到「影片時間」:
- 累加每個片段的時長,直到超過當前合成幀
- 從超過的片段中,反推當前應播放影片的哪個幀
firstFrameOfSection標記是否正好在片段的起始點,用於決定是否要強制 seek
acceptableTimeShiftInSeconds:強制 seek 的關鍵
acceptableTimeShiftInSeconds={cut.firstFrameOfSection ? 0.000001 : undefined}Remotion 的 <OffthreadVideo> 預設允許影片時間有最多 0.45 秒的偏差才觸發 seek(acceptableTimeshiftInSeconds 預設值)。這個設計是為了避免不必要的 seek 造成卡頓。
但在跳切的第一幀,影片必須精確跳到新位置,因此將容差設為接近零(0.000001),強制觸發 seek。
媒體片段提示(Media Fragments)
src={`${staticFile('time.mp4')}#t=0,`}Remotion 預設會在影片 URL 後自動附加媒體片段(例如 #t=10,20),告訴瀏覽器只需載入哪段影片。由於我們手動管理 trimBefore,需要覆蓋這個行為。
加上 #t=0, 表示從頭到結尾都載入,防止 Remotion 覆蓋此設定。
情境二:大幅度跳切(預先掛載第二個影片標籤)
若跳切幅度超過 1-2 秒,目標位置的影片尚未在瀏覽器中預載,此時應使用多個影片標籤並搭配預先掛載。
實作方式與「依序播放多段影片」相同,詳見 依序播放多段影片:
- 使用
<Series>將多個片段串接 - 為每個
<Series.Sequence>加上premountForprop,讓下一段影片提前載入 - 搭配
pauseWhenBuffering確保播放流暢
各情境方案總結
| 情境 | 跳切幅度 | 建議方案 |
|---|---|---|
| 小幅跳切 | < 1-2 秒 | 重複使用同一影片標籤 + trimBefore |
| 極小跳切 | < 0.45 秒 | 同上 + acceptableTimeShiftInSeconds: 0.000001 |
| 大幅跳切 | > 2 秒 | 預先掛載第二個影片標籤(<Series> + premountFor) |
在 Root.tsx 中使用
import React from 'react';
import {Composition} from 'remotion';
import {JumpCuts, calculateMetadata, SAMPLE_SECTIONS} from './JumpCuts';
export const Root: React.FC = () => {
return (
<Composition
id="JumpCuts"
component={JumpCuts}
width={1920}
height={1080}
// 這些值會被 calculateMetadata 覆蓋
durationInFrames={300}
fps={30}
defaultProps={{
sections: SAMPLE_SECTIONS,
}}
calculateMetadata={calculateMetadata}
/>
);
};