建構時間軸影片編輯器
學習如何建構一個基於時間軸的影片編輯器,支援多軌道、可拖曳元素和不同類型的媒體
本文件從高層次描述如何將 Remotion Player 與時間軸同步。閱讀本文件以獲得建構具有以下特性的影片編輯器的指導:
- 多個相互疊加的軌道
- 元素可以任意放置在軌道上
- 元素可以是不同類型(例如影片、音頻、文字等)
獲取 <Timeline> 組件
Remotion 提供了一個可複製貼上的 <Timeline> 組件,遵循 Remotion 的最佳實踐,並且已經處理了縮放功能。如果你想節省時間並獲得一個起點,可以在 Remotion Store 中購買它。
你也可以建構自己的時間軸組件。以下步驟將使用我們建構 Timeline 組件時採用的相同方法。
觀看「在 React 中建構影片編輯器」演講
觀看 Remotion 創始人 Jonny Burger 的演講「在 React 中建構影片編輯器」在這裡。你將在 30 分鐘內獲得如何建構時間軸、畫布、字幕和匯出功能的大綱。
建構你自己的時間軸
步驟 1:定義類型
定義一個 TypeScript 類型 Item 來定義不同的元素類型。再建立一個類型來定義 Track 的形狀:
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:建立渲染軌道的組件
建立一個可以渲染軌道列表的組件:
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 傳遞:
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 中可供購買。
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 來同步播放頭位置:
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>
);
};