Remotion LabRemotion Lab
建構應用建構時間軸影片編輯器

建構時間軸影片編輯器

學習如何建構一個基於時間軸的影片編輯器,支援多軌道、可拖曳元素和不同類型的媒體

本文件從高層次描述如何將 Remotion Player 與時間軸同步。閱讀本文件以獲得建構具有以下特性的影片編輯器的指導:

  • 多個相互疊加的軌道
  • 元素可以任意放置在軌道上
  • 元素可以是不同類型(例如影片、音頻、文字等)

獲取 <Timeline> 組件

Remotion 提供了一個可複製貼上的 <Timeline> 組件,遵循 Remotion 的最佳實踐,並且已經處理了縮放功能。如果你想節省時間並獲得一個起點,可以在 Remotion Store 中購買它

你也可以建構自己的時間軸組件。以下步驟將使用我們建構 Timeline 組件時採用的相同方法。

觀看「在 React 中建構影片編輯器」演講

觀看 Remotion 創始人 Jonny Burger 的演講「在 React 中建構影片編輯器」在這裡。你將在 30 分鐘內獲得如何建構時間軸、畫布、字幕和匯出功能的大綱。

建構你自己的時間軸

步驟 1:定義類型

定義一個 TypeScript 類型 Item 來定義不同的元素類型。再建立一個類型來定義 Track 的形狀:

types.ts
type BaseItem = {
  from: number;
  durationInFrames: number;
  id: string;
};
 
export type SolidItem = BaseItem & {
  type: 'solid';
  color: string;
};
 
export type TextItem = BaseItem & {
  type: 'text';
  text: string;
  color: string;
};
 
export type VideoItem = BaseItem & {
  type: 'video';
  src: string;
};
 
export type Item = SolidItem | TextItem | VideoItem;
 
export type Track = {
  name: string;
  items: Item[];
};

步驟 2:建立渲染軌道的組件

建立一個可以渲染軌道列表的組件:

remotion/Main.tsx
import type { Track, Item } from './types';
import React from 'react';
import { AbsoluteFill, Sequence, OffthreadVideo } from 'remotion';
 
// 渲染單個元素
const ItemComp: React.FC<{ item: Item }> = ({ item }) => {
  if (item.type === 'solid') {
    return <AbsoluteFill style={{ backgroundColor: item.color }} />;
  }
 
  if (item.type === 'text') {
    return (
      <AbsoluteFill
        style={{
          display: 'flex',
          alignItems: 'center',
          justifyContent: 'center',
          color: item.color,
          fontSize: '72px',
          fontWeight: 'bold',
        }}
      >
        {item.text}
      </AbsoluteFill>
    );
  }
 
  if (item.type === 'video') {
    return <OffthreadVideo src={item.src} />;
  }
 
  throw new Error(`未知的元素類型:${JSON.stringify(item)}`);
};
 
// 渲染單個軌道
const TrackComp: React.FC<{ track: Track }> = ({ track }) => {
  return (
    <AbsoluteFill>
      {track.items.map((item) => {
        return (
          <Sequence
            key={item.id}
            from={item.from}
            durationInFrames={item.durationInFrames}
          >
            <ItemComp item={item} />
          </Sequence>
        );
      })}
    </AbsoluteFill>
  );
};
 
// 主組件:渲染所有軌道
export const Main: React.FC<{ tracks: Track[] }> = ({ tracks }) => {
  return (
    <AbsoluteFill>
      {tracks.map((track) => {
        return <TrackComp key={track.name} track={track} />;
      })}
    </AbsoluteFill>
  );
};

提示:在 CSS 中,後面渲染的元素會出現在頂部。參見:Layers

步驟 3:建立編輯器組件

保持軌道狀態,每個軌道包含一個元素陣列。渲染 <Player> 組件,並將 tracks 作為 inputProps 傳遞:

Editor.tsx
import React, { useMemo, useState } from 'react';
import { Player } from '@remotion/player';
import type { Item, Track } from './types';
import { Main } from './remotion/Main';
 
export const Editor = () => {
  const [tracks, setTracks] = useState<Track[]>([
    { name: 'Track 1', items: [] },
    { name: 'Track 2', items: [] },
  ]);
 
  const inputProps = useMemo(() => {
    return {
      tracks,
    };
  }, [tracks]);
 
  return (
    <>
      <Player
        component={Main}
        inputProps={inputProps}
        durationInFrames={300}
        fps={30}
        compositionWidth={1920}
        compositionHeight={1080}
        style={{ width: '100%' }}
        controls
      />
    </>
  );
};

步驟 4:建構時間軸 UI 組件

現在你可以存取 tracks 狀態並使用 setTracks 函數更新它。

目前我們沒有提供如何建構時間軸組件的範例,因為每個人都有不同的需求和樣式偏好。

一個有主見的範例實作在 Remotion Store 中可供購買

remotion/Timeline.tsx(概念範例)
import React, { useCallback } from 'react';
import { Player } from '@remotion/player';
import { useState, useMemo } from 'react';
import type { Track, Item } from '../types';
import { Main } from './Main';
 
// 時間軸中的每個元素
const TimelineItem: React.FC<{
  item: Item;
  pixelsPerFrame: number;
  onMove: (id: string, newFrom: number) => void;
}> = ({ item, pixelsPerFrame, onMove }) => {
  const style: React.CSSProperties = {
    position: 'absolute',
    left: item.from * pixelsPerFrame,
    width: item.durationInFrames * pixelsPerFrame,
    height: '40px',
    background: item.type === 'video' ? '#4CAF50' :
                item.type === 'text' ? '#2196F3' : '#FF9800',
    borderRadius: '4px',
    cursor: 'grab',
    display: 'flex',
    alignItems: 'center',
    paddingLeft: '8px',
    overflow: 'hidden',
    color: 'white',
    fontSize: '12px',
  };
 
  return (
    <div style={style}>
      {item.type === 'text' ? item.text :
       item.type === 'video' ? '影片' : '色塊'}
    </div>
  );
};
 
// 時間軸軌道
const TimelineTrack: React.FC<{
  track: Track;
  pixelsPerFrame: number;
  onItemMove: (trackName: string, itemId: string, newFrom: number) => void;
}> = ({ track, pixelsPerFrame, onItemMove }) => {
  return (
    <div style={{ position: 'relative', height: '48px', marginBottom: '4px' }}>
      <div style={{
        position: 'absolute',
        left: 0,
        width: '80px',
        height: '40px',
        background: '#333',
        color: 'white',
        display: 'flex',
        alignItems: 'center',
        paddingLeft: '8px',
        fontSize: '12px',
      }}>
        {track.name}
      </div>
      <div style={{ marginLeft: '80px', position: 'relative', height: '40px', background: '#1a1a1a' }}>
        {track.items.map((item) => (
          <TimelineItem
            key={item.id}
            item={item}
            pixelsPerFrame={pixelsPerFrame}
            onMove={(id, newFrom) => onItemMove(track.name, id, newFrom)}
          />
        ))}
      </div>
    </div>
  );
};
 
// 完整的編輯器組件
const Editor: React.FC = () => {
  const [tracks, setTracks] = useState<Track[]>([
    { name: 'Track 1', items: [] },
    { name: 'Track 2', items: [] },
  ]);
 
  const PIXELS_PER_FRAME = 3;
  const TOTAL_FRAMES = 300;
 
  const inputProps = useMemo(() => ({ tracks }), [tracks]);
 
  const handleItemMove = useCallback(
    (trackName: string, itemId: string, newFrom: number) => {
      setTracks((prev) =>
        prev.map((track) =>
          track.name === trackName
            ? {
                ...track,
                items: track.items.map((item) =>
                  item.id === itemId ? { ...item, from: newFrom } : item
                ),
              }
            : track
        )
      );
    },
    []
  );
 
  const addTextItem = useCallback(() => {
    setTracks((prev) => {
      const newItem: Item = {
        id: crypto.randomUUID(),
        type: 'text',
        text: '新文字',
        color: 'white',
        from: 0,
        durationInFrames: 60,
      };
 
      return prev.map((track, index) =>
        index === 0 ? { ...track, items: [...track.items, newItem] } : track
      );
    });
  }, []);
 
  return (
    <div style={{ background: '#111', color: 'white', padding: '20px' }}>
      {/* 播放器預覽 */}
      <Player
        component={Main}
        inputProps={inputProps}
        durationInFrames={TOTAL_FRAMES}
        fps={30}
        compositionWidth={1920}
        compositionHeight={1080}
        style={{ width: '100%', marginBottom: '20px' }}
        controls
      />
 
      {/* 工具列 */}
      <div style={{ marginBottom: '12px' }}>
        <button onClick={addTextItem} style={{ marginRight: '8px' }}>
          新增文字
        </button>
      </div>
 
      {/* 時間軸 */}
      <div style={{ overflowX: 'auto' }}>
        <div style={{ minWidth: TOTAL_FRAMES * PIXELS_PER_FRAME + 80 }}>
          {tracks.map((track) => (
            <TimelineTrack
              key={track.name}
              track={track}
              pixelsPerFrame={PIXELS_PER_FRAME}
              onItemMove={handleItemMove}
            />
          ))}
        </div>
      </div>
    </div>
  );
};

同步播放頭與時間軸

使用 Player 的 playerRef 來同步播放頭位置:

PlayerWithTimeline.tsx
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { Player, PlayerRef } from '@remotion/player';
import { Main } from './remotion/Main';
import type { Track } from './types';
 
export const PlayerWithTimeline: React.FC = () => {
  const playerRef = useRef<PlayerRef>(null);
  const [currentFrame, setCurrentFrame] = useState(0);
  const [tracks] = useState<Track[]>([]);
 
  // 監聽播放進度
  useEffect(() => {
    const player = playerRef.current;
    if (!player) return;
 
    const handleFrameUpdate = (e: { detail: { frame: number } }) => {
      setCurrentFrame(e.detail.frame);
    };
 
    player.addEventListener('frameupdate', handleFrameUpdate);
    return () => player.removeEventListener('frameupdate', handleFrameUpdate);
  }, []);
 
  // 從時間軸跳轉到特定幀
  const seekToFrame = useCallback((frame: number) => {
    playerRef.current?.seekTo(frame);
  }, []);
 
  const inputProps = { tracks };
 
  return (
    <div>
      <Player
        ref={playerRef}
        component={Main}
        inputProps={inputProps}
        durationInFrames={300}
        fps={30}
        compositionWidth={1920}
        compositionHeight={1080}
        style={{ width: '100%' }}
        controls
      />
 
      {/* 時間軸播放頭 */}
      <div style={{ position: 'relative', marginTop: '10px' }}>
        <div
          style={{
            position: 'absolute',
            left: currentFrame * 3,
            width: '2px',
            height: '100%',
            background: 'red',
            pointerEvents: 'none',
          }}
        />
      </div>
    </div>
  );
};

另請參閱