無臉動態短片
將簡短腳本轉換為 15 到 30 秒的直式影片,風格為「極簡視覺,最大衝擊」:乾淨的白色背景、黑色字體、一種強調色,以及每個想法搭配一個小動畫物件。無需影片素材、人物或配音。影片以 React 程式碼搭配 Remotion 撰寫,因此每一幀都精確且易於修改。
風格(此風格的關鍵要素)
這些規則來自逐幀研究參考短片,它們比任何單一特效都更重要,即使在設計新場景時也要遵守。
- 一個想法一個場景。 每個場景顯示一行簡短文字(2 到 6 個字)加上一個能傳達意義的物件。觀眾即使關掉聲音也能理解。
- 僅三種顏色。 白色背景、近乎黑色的墨水色、一種強調色。灰色僅用於「尚未定位」的文字。第二種強調色會讓它看起來像套版。
- 兩種字體聲音。 緊湊無襯線字體(Inter,字重 500,字距約 -0.04em)用於大部分文字,強調色斜體襯線字體用於重點短語。每個場景最多使用一次襯線字體。
- 文字是浮現的,不是突然出現。 新文字以灰色且略微模糊的狀態出現,然後穩定為黑色。眼睛會跟隨最新的文字。
- 沒有任何東西靜止不動。 每個場景都保持緩慢的縮放,背景環持續漂移。凍結的畫面看起來像幻燈片。
- 轉場是攝影機移動。 場景向上或向側邊推動並帶有動態模糊,或淡出至白色。沒有擦拭、旋轉或縮放爆發。
- 小字體,大量留白。 標題在 1080 寬的畫面上約為 84 到 100px。空白空間就是風格。
- 物件就是想法。 用倒數環表示時間,用標籤顯示問題列表,用堆疊膠囊顯示特質列表,用迴圈包圍重點句子。當腳本中有強烈的圖像(時鐘、圖表、游標)時,使用 SVG 繪製該物件,而不是重複使用套件元件。
工作流程
1. 獲取簡報
你需要:訊息或腳本、強調色、最後場景的標誌或名稱,以及是否有音樂檔案。如果使用者只提供主題但沒有腳本,請自行撰寫腳本並在節奏表中呈現。不要因為問題而停滯。合理的預設值:約 20 秒、1080x1920、30fps、強調色 #1FB81A、無音訊。
2. 先撰寫節奏表
在任何程式碼之前,先將計畫撰寫為一個小表格,並在開始建構的同一則訊息中顯示給使用者。這是影片成敗的關鍵,因為薄弱節拍無法透過動畫修復。
| # | 螢幕上的文字 | 強調短語 | 物件 | 進 / 出 | 幀數 |
|---|
指南:
- 節拍 1 是鉤子:2 到 5 個字,以緊張或大膽的主張呈現。
- 15 到 30 秒大約需要 6 到 9 個節拍。大多數節拍持續 60 到 110 幀(2 到 3.5 秒)。列表節拍(標籤、膠囊)需要更多時間:每項約 20 幀,再加上 30 幀來閱讀。
- 在結尾附近建立一個回報句,並為其加上迴圈(
Circled)。 - 最後一個節拍是標誌或帳號名稱,持續 45 到 60 幀。
- 每個節拍的物件要有所不同。連續兩個純文字節拍沒問題,但連續兩個標籤節拍則不行。
- 轉場要多樣化:主要是
up推動,一個側向推動,在回報句前使用fade。
3. 設定專案
在空資料夾中執行一次。字型會被複製到專案中,因此渲染永遠不依賴網路。
npm init -y
npm i remotion @remotion/cli @remotion/fonts react react-dom lucide-react @fontsource/inter @fontsource/instrument-serif
npm i -D typescript @types/react
mkdir -p src public/fonts out
cp node_modules/@fontsource/inter/files/inter-latin-400-normal.woff2 node_modules/@fontsource/inter/files/inter-latin-500-normal.woff2 node_modules/@fontsource/instrument-serif/files/instrument-serif-latin-400-italic.woff2 public/fonts/
tsconfig.json:
{"compilerOptions":{"target":"ES2022","module":"ESNext","moduleResolution":"Bundler","jsx":"react-jsx","strict":true,"skipLibCheck":true,"noEmit":true,"esModuleInterop":true},"include":["src"]}
src/index.ts:
import {registerRoot} from 'remotion';
import {Root} from './Root';
registerRoot(Root);
src/Root.tsx:
import React from 'react';
import {Composition} from 'remotion';
import {totalFrames} from './kit';
import {Video, beats} from './Video';
export const Root: React.FC = () => (
<Composition id="Reel" component={Video} width={1080} height={1920} fps={30} durationInFrames={totalFrames(beats)} />
);
4. 加入動態工具箱
建立 src/kit.tsx 並使用以下確切的程式碼。它已經過渲染並檢查,請原樣複製,只更改主題行。以同樣的精神在其旁邊建立新元件。
import React from 'react';
import {
AbsoluteFill, Easing, Sequence, interpolate, interpolateColors, random, spring,
staticFile, useCurrentFrame, useVideoConfig,
} from 'remotion';
import {loadFont} from '@remotion/fonts';
// 字型為本地檔案(設定時從 @fontsource 複製),因此渲染永遠不需要網路。
const font = (family: string, file: string, weight: string, style = 'normal') =>
loadFont({family, url: staticFile(`fonts/${file}.woff2`), weight, style});
font('Inter', 'inter-latin-400-normal', '400');
font('Inter', 'inter-latin-500-normal', '500');
font('Instrument Serif', 'instrument-serif-latin-400-italic', '400', 'italic');
const sans = 'Inter, sans-serif';
const serif = '"Instrument Serif", serif';
// ---- 主題:一種背景、一種墨水、一種灰色、一種強調色。依品牌交換強調色(6位十六進位)。 ----
export const T = {bg: '#FFFFFF', ink: '#0B0B0C', muted: '#B4B6BB', accent: '#1FB81A', sans, serif};
const CL = {extrapolateLeft: 'clamp', extrapolateRight: 'clamp'} as const;
const c01 = (n: number) => Math.min(1, Math.max(0, n));
const whip = Easing.bezier(0.76, 0, 0.18, 1); // 慢、快、慢:所謂的「攝影機甩動」
/** 從 `delay` 開始的 0 到 1 彈簧動畫。預設為平滑;傳入 config 可產生彈跳效果。 */
export const useEnter = (delay = 0, config: object = {damping: 200}) => {
const frame = useCurrentFrame();
const {fps} = useVideoConfig();
return spring({frame: frame - delay, fps, config});
};
// ---- 時間軸:場景重疊 OVERLAP 幀,使退出和進入同時發生 ----
export const OVERLAP = 12;
export type Beat = {dur: number; el: React.ReactNode};
export const beatStarts = (beats: Beat[], overlap = OVERLAP) =>
beats.map((_, i) => beats.slice(0, i).reduce((s, x) => s + x.dur - overlap, 0));
export const totalFrames = (beats: Beat[], overlap = OVERLAP) =>
beats.reduce((s, b) => s + b.dur, 0) - overlap * (beats.length - 1);
export const Timeline: React.FC<{beats: Beat[]; overlap?: number}> = ({beats, overlap = OVERLAP}) => {
const starts = beatStarts(beats, overlap);
return (
<AbsoluteFill style={{background: T.bg}}>
{beats.map((b, i) => (
<Sequence key={i} from={starts[i]} durationInFrames={b.dur}>
{b.el}
</Sequence>
))}
</AbsoluteFill>
);
};
// ---- 場景:包裝一個想法。處理推動/淡入淡出、動態模糊、緩慢縮放。 ----
type Dir = 'up' | 'down' | 'left' | 'right' | 'fade';
const VEC: Record<Dir, [number, number]> = {up: [0, -1], down: [0, 1], left: [-1, 0], right: [1, 0], fade: [0, 0]};
/** `enter="up"` = 內容向上移動到位。對於推動,給予場景 A exit="up" 和場景 B enter="up"。 */
export const Scene: React.FC<{
enter?: Dir; exit?: Dir; t?: number; zoom?: number; children: React.ReactNode;
}> = ({enter = 'fade', exit = 'fade', t = OVERLAP, zoom = 0.05, children}) => {
const frame = useCurrentFrame();
const {width, height, durationInFrames: dur} = useVideoConfig(); // = 此 Sequence 的長度
const id = React.useId().replace(/:/g, '');
const pos = (f: number) => {
const a = 1 - whip(c01(f / t));
const b = whip(c01((f - (dur - t)) / t));
const [ex, ey] = VEC[enter];
const [lx, ly] = VEC[exit];
return [(-ex * a + lx * b) * width, (-ey * a + ly * b) * height];
};
const [x, y] = pos(frame);
const [px, py] = pos(frame - 1);
const bx = Math.abs(x - px) * 0.35; // 模糊跟隨速度,如同真實快門
const by = Math.abs(y - py) * 0.35;
const fIn = enter === 'fade' ? interpolate(frame, [t * 0.5, t], [0, 1], CL) : 1; // 舊場景先清除
const fOut = exit === 'fade' ? interpolate(frame, [dur - t, dur - t * 0.5], [1, 0], CL) : 1;
const soft = (1 - fIn * fOut) * 14;
const filter = bx + by > 0.4 ? `url(#${id})` : soft > 0.2 ? `blur(${soft}px)` : undefined;
return (
<>
<svg width="0" height="0" style={{position: 'absolute'}}>
<filter id={id} x="-40%" y="-40%" width="180%" height="180%">
<feGaussianBlur stdDeviation={`${bx} ${by}`} />
</filter>
</svg>
<AbsoluteFill
style={{
transform: `translate(${x}px, ${y}px) scale(${1 + (zoom * frame) / dur})`,
opacity: fIn * fOut, filter,
fontFamily: T.sans, color: T.ink, alignItems: 'center', justifyContent: 'center',
}}
>
{children}
</AbsoluteFill>
</>
);
};
// ---- 文字 ----
/** 逐字(或逐字母)顯示文字。新文字以灰色出現,然後穩定為墨水色。
* 使用 *星星* 包圍關鍵短語以使其成為強調色。使用 \n 進行換行。 */
export const Words: React.FC<{
text: string; delay?: number; stagger?: number; by?: 'word' | 'char';
size?: number; accent?: 'serif' | 'sans'; style?: React.CSSProperties;
}> = ({text, delay = 0, stagger, by = 'word', size = 92, accent = 'serif', style}) => {
const frame = useCurrentFrame();
const step = stagger ?? (by === 'word' ? 5 : 1.2);
let i = 0;
const unit = (s: string, hot: boolean, key: string) => {
const local = frame - delay - i++ * step;
const p = interpolate(local, [0, 6], [0, 1], CL);
const final = hot ? T.accent : T.ink;
return (
<span
key={key}
style={{
display: 'inline-block', whiteSpace: 'pre', opacity: p,
color: interpolateColors(c01((local - 3) / 9), [0, 1], [T.muted, final]),
transform: by === 'word' ? `translateY(${(1 - p) * 0.2}em)` : undefined,
filter: p < 1 ? `blur(${(1 - p) * 7}px)` : undefined,
}}
>
{s}
</span>
);
};
return (
<div style={{fontSize: size, fontWeight: 500, letterSpacing: '-0.04em', lineHeight: 1.08, textAlign: 'center', ...style}}>
{text.split('\n').map((line, li) => (
<div key={li}>
{line.split('*').map((seg, si) => {
const hot = si % 2 === 1;
const hotStyle: React.CSSProperties = hot && accent === 'serif'
? {fontFamily: T.serif, fontStyle: 'italic', fontWeight: 400, fontSize: '1.14em', letterSpacing: '-0.02em'}
: {};
return (
<span key={si} style={hotStyle}>
{seg.split(/(\s+)/).filter(Boolean).map((w, wi) =>
/^\s+$/.test(w) ? <span key={wi} style={{whiteSpace: 'pre'}}>{w}</span>
: by === 'word' ? unit(w, hot, `${wi}`)
: <span key={wi} style={{display: 'inline-block', whiteSpace: 'nowrap'}}>{[...w].map((ch, ci) => unit(ch, hot, `${wi}-${ci}`))}</span>
)}
</span>
);
})}
</div>
))}
</div>
);
};
/** 開場重擊:從巨大且模糊開始,在大約 8 幀內縮小到位。 */
export const PunchIn: React.FC<{delay?: number; from?: number; children: React.ReactNode}> = ({delay = 0, from = 3, children}) => {
const s = useEnter(delay, {damping: 200, stiffness: 220});
return (
<div style={{transform: `scale(${interpolate(s, [0, 1], [from, 1])})`, opacity: c01(s * 5), filter: s < 0.98 ? `blur(${(1 - s) * 34}px)` : undefined}}>
{children}
</div>
);
};
/** 標題下的小型輔助文字行。 */
export const Caption: React.FC<{children: React.ReactNode; delay?: number; width?: number}> = ({children, delay = 0, width = 620}) => {
const s = useEnter(delay);
return (
<div style={{fontSize: 31, lineHeight: 1.28, letterSpacing: '-0.02em', color: '#2B2C2F', textAlign: 'center', width, marginTop: 44, opacity: s, filter: s < 0.98 ? `blur(${(1 - s) * 6}px)` : undefined}}>
{children}
</div>
);
};
// ---- 氛圍 ----
/** 關鍵字後方的柔和強調光。使用 CSS 值設定 x / y 位置。 */
export const Glow: React.FC<{x?: string; y?: string; size?: number; delay?: number}> = ({x = '50%', y = '50%', size = 900, delay = 0}) => {
const s = useEnter(delay);
const frame = useCurrentFrame();
return (
<div style={{position: 'absolute', left: x, top: y, width: size, height: size, borderRadius: '50%', opacity: s,
transform: `translate(-50%, -50%) scale(${0.6 + 0.4 * s + frame * 0.0012})`,
background: `radial-gradient(closest-side, ${T.accent}3D, ${T.accent}00)`}} />
);
};
/** 邊緣附近的漂移圓圈,使畫面永不靜止。thin = 輪廓,soft = 粗模糊環。 */
export const Rings: React.FC<{seed?: string; variant?: 'thin' | 'soft'; count?: number}> = ({seed = 'a', variant = 'thin', count = 3}) => {
const frame = useCurrentFrame();
const {width, height} = useVideoConfig();
return (
<AbsoluteFill>
{Array.from({length: count}).map((_, i) => {
const r = (k: string) => random(`${seed}-${i}-${k}`);
const d = 300 + r('d') * 420;
const cx = (i % 2 ? 0.86 + r('x') * 0.2 : -0.06 + r('x') * 0.2) * width;
const cy = (r('y') * 1.1 - 0.05) * height;
const ang = r('a') * Math.PI * 2;
const s = spring({frame: frame - i * 4, fps: 30, config: {damping: 200}});
return (
<div key={i} style={{position: 'absolute', width: d, height: d, borderRadius: '50%', opacity: s,
left: cx - d / 2 + Math.cos(ang) * frame * 0.9, top: cy - d / 2 + Math.sin(ang) * frame * 0.9,
border: variant === 'thin' ? `2px solid ${T.accent}99` : `${d * 0.2}px solid ${T.accent}1F`,
filter
...
[因篇幅關係截斷]