얼굴 없는 모션 릴
짧은 스크립트를 15~30초 분량의 세로 동영상으로 변환합니다. '미니멀 비주얼, 최대 임팩트' 스타일: 깨끗한 흰색 캔버스, 검은색 타입, 하나의 강조 색상, 각 아이디어당 하나의 작은 애니메이션 오브젝트. 영상, 얼굴, 보이스오버가 필요 없습니다. 동영상은 Remotion과 함께 React 코드로 작성되므로 모든 프레임이 정확하고 수정이 쉽습니다.
룩 (이 스타일이 효과적인 이유)
이 규칙들은 참고 릴을 프레임별로 분석한 결과입니다. 어떤 단일 효과보다 중요하므로, 새로운 장면을 만들 때도 꼭 지켜주세요.
- 장면당 하나의 아이디어. 각 장면은 하나의 짧은 라인(2~6단어)과 의미를 전달하는 하나의 오브젝트를 보여줍니다. 시청자는 소리를 끄고도 이해할 수 있어야 합니다.
- 세 가지 색상만 사용. 흰색 배경, 거의 검은색 잉크, 하나의 강조 색상. 회색은 아직 '자리 잡지 않은' 텍스트에만 사용합니다. 두 번째 강조 색상은 템플릿처럼 보이게 만듭니다.
- 두 가지 타입 보이스. 대부분의 단어에는 타이트한 산세리프(Inter, 두께 500, 자간 약 -0.04em)를 사용하고, 중요한 구문에는 강조 색상의 기울임꼴 세리프를 사용합니다. 세리프는 장면당 최대 한 번만 사용하세요.
- 텍스트는 도착합니다. 그냥 나타나지 않습니다. 새로운 단어는 회색과 약간 흐릿하게 들어와서 검은색으로 자리잡습니다. 눈은 가장 최신 단어를 따라갑니다.
- 아무것도 가만히 있지 않습니다. 모든 장면은 느린 줌을 유지하고 배경 링은 계속 움직입니다. 정지된 프레임은 슬라이드쇼처럼 보입니다.
- 전환은 카메라 움직임입니다. 장면은 모션 블러와 함께 위나 옆으로 밀리거나 흰색으로 사라집니다. 와이프, 회전, 줌 버스트는 없습니다.
- 작은 타입, 많은 여백. 헤드라인은 1080 너비 프레임에서 약 84~100px로 설정합니다. 빈 공간이 스타일입니다.
- 오브젝트가 아이디어입니다. 시간을 위한 카운트다운 링, 문제 목록을 위한 레이블 배지, 특성 목록을 위한 쌓이는 알약, 핵심 구문 주변의 루프. 스크립트에 강력한 이미지(시계, 차트, 커서)가 있을 때는 킷 부품을 재사용하는 대신 SVG로 그 오브젝트를 그립니다.
워크플로
1. 브리프 받기
필요한 것: 메시지 또는 스크립트, 강조 색상, 마지막 장면을 위한 로고 또는 이름, 음악 파일 유무. 사용자가 주제만 주고 스크립트를 주지 않으면, 직접 스크립트를 작성하여 비트 시트에 표시합니다. 질문에 막히지 마세요. 합리적인 기본값: 약 20초, 1080x1920, 30fps, 강조 #1FB81A, 오디오 없음.
2. 먼저 비트 시트 작성
코드 작성 전에, 계획을 작은 표로 만들어 빌드를 시작하는 동일한 메시지에서 사용자에게 보여줍니다. 이 부분이 동영상의 성패를 좌우합니다. 약한 비트는 애니메이션으로 고칠 수 없기 때문입니다.
| # | 화면에 표시될 라인 | 강조 구문 | 오브젝트 | 인/아웃 | 프레임 |
|---|
지침:
- 비트 1은 훅: 2~5단어로 긴장감이나 대담한 주장으로 제시합니다.
- 15
30초에 69개의 비트. 대부분의 비트는 60110프레임(23.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';
// 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: variant === 'soft' ? `blur(${d * 0.1}px)` : undefined}} />
);
})}
</AbsoluteFill>
);
};