Remotion LabRemotion Lab
影片影片跳切(Jump Cut)

影片跳切(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]);

這段邏輯將「合成時間」對應到「影片時間」:

  1. 累加每個片段的時長,直到超過當前合成幀
  2. 從超過的片段中,反推當前應播放影片的哪個幀
  3. 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> 加上 premountFor prop,讓下一段影片提前載入
  • 搭配 pauseWhenBuffering 確保播放流暢

各情境方案總結

情境跳切幅度建議方案
小幅跳切< 1-2 秒重複使用同一影片標籤 + trimBefore
極小跳切< 0.45 秒同上 + acceptableTimeShiftInSeconds: 0.000001
大幅跳切> 2 秒預先掛載第二個影片標籤(<Series> + premountFor

在 Root.tsx 中使用

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}
    />
  );
};

延伸閱讀