フェイスレスモーションリール
短いスクリプトを、15〜30秒の「最小限のビジュアルで最大のインパクト」スタイルの縦型動画に変換します。白いキャンバス、黒いタイプ、一つのアクセントカラー、そして各アイデアに一つの小さなアニメーションオブジェクト。映像素材、顔出し、ナレーションは一切不要です。動画は Remotion を使用して React コードとして記述されるため、すべてのフレームが正確で、修正も容易です。
見た目(このスタイルを機能させるルール)
これらのルールは、参考リールをフレームごとに研究して導き出されました。特定のエフェクトよりも重要なので、新しいシーンを考案する際もこれらを守ってください。
- 1シーン=1アイデア。 各シーンは短い行(2〜6単語)と、その意味を伝える一つのオブジェクトで構成されます。音声がなくても視聴者が理解できるようにします。
- 3色のみ。 白い背景、ほぼ黒のインク、一つのアクセント。グレーは「まだ確定していない」テキストにのみ使用します。2つ目のアクセントがあるとテンプレートのように見えます。
- 2つのタイプの声。 ほとんどの単語にはタイトなサンセリフ(Inter、ウェイト500、トラッキング約 -0.04em)を使用し、重要なフレーズにはアクセントカラーのイタリックセリフを使用します。セリフは各シーンで最大1回のみ使用します。
- テキストは「出現」するのではなく「到着」する。 新しい単語はグレーでややぼやけた状態で現れ、その後黒に落ち着きます。目は最も新しい単語を追います。
- 何も静止しない。 各シーンはスローなズームを維持し、背景のリングは漂い続けます。フレームが止まるとスライドショーのように見えます。
- トランジションはカメラの動き。 シーンはモーションブラーを伴って上下または横にプッシュするか、ホワイトにディップします。ワイプ、スピン、ズームバーストは使用しません。
- 小さなタイプ、たっぷりの余白。 1080幅のフレームで見出しは約84〜100pxに設定します。空きスペース自体がスタイルです。
- オブジェクトがアイデア。 時間を表すカウントダウンリング、問題リスト用のラベル付きバッジ、特性リスト用の積み重ねピル、決めゼリフの周りのループ描画。スクリプトに強いイメージ(時計、グラフ、カーソル)がある場合は、キットの部品を再利用する代わりに、そのオブジェクトを SVG で描画します。
ワークフロー
1. 依頼内容の把握
必要なもの:メッセージまたはスクリプト、アクセントカラー、ラストシーン用のロゴまたは名前、音楽ファイルの有無。ユーザーがトピックのみを提示した場合は、自分でスクリプトを作成し、ビートシートで表示します。質問で立ち止まらないでください。適切なデフォルト値:約20秒、1080x1920、30fps、アクセント #1FB81A、オーディオなし。
2. 最初にビートシートを作成する
コードの前に、計画を小さなテーブルとして作成し、ビルドを開始する同じメッセージでユーザーに表示します。ここが動画の成否を分けるポイントです。弱いビートはアニメーションで修正できません。
| # | 画面に表示される行 | アクセントフレーズ | オブジェクト | In / Out | フレーム数 |
|---|
ガイドライン:
- ビート1はフック:2〜5単語で、緊張感または大胆な主張として提示します。
- 15〜30秒で6〜9ビート。ほとんどのビートは60〜110フレーム(2〜3.5秒)で実行します。リストビート(バッジ、ピル)にはより多くの時間を割り当てます:アイテムあたり約20フレーム + 読み取りに30フレーム。
- 終盤の一つの決めゼリフに向けて構築し、その周りにループ(
Circled)を配置します。 - 最後のビートはロゴまたはハンドルで、45〜60フレーム。
- ビートごとにオブジェクトを変化させます。テキストのみのビートが2回連続するのは問題ありませんが、バッジビートが2回連続するのは避けてください。
- トランジションを変化させます:主に
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';
// Fonts are local files (copied from @fontsource at setup), so renders never need the network.
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';
// ---- Theme: one background, one ink, one grey, ONE accent. Swap accent per brand (6-digit hex). ----
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); // slow, fast, slow: the "camera whip"
/** 0 to 1 spring that starts at `delay`. Smooth by default; pass a config for bounce. */
export const useEnter = (delay = 0, config: object = {damping: 200}) => {
const frame = useCurrentFrame();
const {fps} = useVideoConfig();
return spring({frame: frame - delay, fps, config});
};
// ---- Timeline: scenes overlap by OVERLAP frames so the exit and the enter happen together ----
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>
);
};
// ---- Scene: wraps one idea. Handles the push/fade in and out, motion blur, slow zoom. ----
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"` = content travels up into place. For a push, give scene A exit="up" and scene 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(); // = this Sequence's length
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; // blur follows speed, like a real shutter
const by = Math.abs(y - py) * 0.35;
const fIn = enter === 'fade' ? interpolate(frame, [t * 0.5, t], [0, 1], CL) : 1; // old scene clears first
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>
</>
);
};
// ---- Text ----
/** Reveals text word by word (or letter by letter). New text lands grey, then settles to ink.
* Wrap the key phrase in *stars* to make it the accent. Use \n for a line break. */
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>
);
};
/** The opening hit: starts huge and blurred, snaps to size in about 8 frames. */
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>
);
};
/** Small supporting line under the headline. */
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>
);
};
// ---- Atmosphere ----
/** Soft accent light behind the key word. Place it with x / y as CSS values. */
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)`}} />
);
};
/** Drifting circles near the edges so the frame never sits still. thin = outlines, soft = fat blurred rings. */
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
…
[翻訳のため一部省略]