Faceless Motion Reel – Gesichtsloser Bewegtclip
Verwandle ein kurzes Skript in ein 15- bis 30-sekündiges vertikales Video im Stil „minimale visuelle Elemente, maximale Wirkung“: eine saubere weiße Leinwand, schwarze Schrift, eine Akzentfarbe und ein kleines animiertes Objekt pro Idee. Kein Filmmaterial, kein Gesicht, keine Sprachaufnahme nötig. Das Video wird als React-Code mit Remotion geschrieben, sodass jedes Bild exakt und einfach zu überarbeiten ist.
Der Look (was diesen Stil auszeichnet)
Diese Regeln stammen aus einer Frame-für-Frame-Analyse eines Referenzclips. Sie sind wichtiger als jeder einzelne Effekt. Behalte sie daher bei, auch wenn du neue Szenen erfindest.
- Eine Idee pro Szene. Jede Szene zeigt eine kurze Zeile (2 bis 6 Wörter) plus ein Objekt, das die Bedeutung transportiert. Ein Zuschauer sollte es auch ohne Ton verstehen.
- Nur drei Farben. Weißer Hintergrund, fast schwarze Tinte, ein Akzent. Grau wird nur für Text verwendet, der „noch nicht angekommen ist“. Ein zweiter Akzent lässt es wie eine Vorlage aussehen.
- Zwei Schriftarten. Eine schmale Sans (Inter, Stärke 500, Laufweite ca. -0,04em) für die meisten Wörter und eine kursive Serife in der Akzentfarbe für die wichtige Phrase. Verwende die Serife höchstens einmal pro Szene.
- Text kommt an, er erscheint nicht einfach. Neue Wörter tauchen in Grau und leicht unscharf auf und werden dann schwarz. Das Auge folgt dem neuesten Wort.
- Nichts bleibt jemals stehen. Jede Szene hat einen langsamen Zoom, und Hintergrundringe treiben ständig. Ein eingefrorenes Bild wirkt wie eine Diashow.
- Übergänge sind Kamerafahrten. Szenen schieben sich mit Bewegungsunschärfe nach oben oder zur Seite oder blenden auf Weiß. Keine Wipes, Spins oder Zoom-Bursts.
- Kleine Schrift, viel Luft. Überschriften liegen bei etwa 84 bis 100px auf einem 1080 breiten Bild. Der leere Raum ist der Stil.
- Das Objekt ist die Idee. Ein Countdown-Ring für die Zeit, beschriftete Badges für eine Liste von Problemen, übereinanderliegende Pills für eine Liste von Eigenschaften, eine Schleife um die entscheidende Zeile gezogen. Wenn das Skript ein starkes Bild enthält (eine Uhr, ein Diagramm, einen Cursor), zeichne dieses Objekt in SVG, anstatt ein Teil aus dem Baukasten wiederzuverwenden.
Arbeitsablauf
1. Die Vorgaben einholen
Du benötigst: die Botschaft oder das Skript, die Akzentfarbe, das Logo oder den Namen für die letzte Szene und die Information, ob es eine Musikdatei gibt. Wenn der Benutzer ein Thema, aber kein Skript angegeben hat, schreibe das Skript selbst und zeige es im Beat Sheet. Zögere nicht mit Fragen. Sinnvolle Standardeinstellungen: etwa 20 Sekunden, 1080x1920, 30fps, Akzentfarbe #1FB81A, kein Audio.
2. Zuerst das Beat Sheet schreiben
Erstelle vor jeglichem Code den Plan als kleine Tabelle und zeige ihn dem Benutzer in derselben Nachricht, in der du mit dem Bau beginnst. Hier wird das Video gewonnen oder verloren, denn schwache Beats lassen sich nicht durch Animation reparieren.
| # | Zeile auf dem Bildschirm | Akzentphrase | Objekt | Ein/Aus | Frames |
|---|
Richtlinien:
- Beat 1 ist der Aufhänger: 2 bis 5 Wörter, formuliert als Spannung oder kühne Behauptung.
- 6 bis 9 Beats für 15 bis 30 Sekunden. Die meisten Beats laufen 60 bis 110 Frames (2 bis 3,5 Sekunden). Gib Listen-Beat (Badges, Pills) mehr Zeit: ca. 20 Frames pro Item plus 30 zum Lesen.
- Baue auf eine entscheidende Zeile gegen Ende hin und gib ihr die Schleife (
Circled). - Der letzte Beat ist das Logo oder der Handle, 45 bis 60 Frames.
- Variiere das Objekt von Beat zu Beat. Zwei reine Text-Beats hintereinander sind in Ordnung. Zwei Badges-Beats hintereinander nicht.
- Variiere die Übergänge: meistens
up- Schübe, ein seitlicher Schub, einfadevor der entscheidenden Zeile.
3. Das Projekt einrichten
Führe dies einmal in einem leeren Ordner aus. Schriftarten werden in das Projekt kopiert, sodass das Rendern nie vom Netzwerk abhängt.
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. Das Motion-Kit hinzufügen
Erstelle src/kit.tsx mit genau diesem Code. Er wurde gerendert und überprüft, also kopiere ihn so, wie er ist, und ändere nur die Theme-Zeile. Baue neue Teile im gleichen Geist daneben.
import React from 'react';
import {
AbsoluteFill, Easing, Sequence, interpolate, interpolateColors, random, spring,
staticFile, useCurrentFrame, useVideoConfig,
} from 'remotion';
import {loadFont} from '@remotion/fonts';
// Schriftarten sind lokale Dateien (bei der Einrichtung von @fontsource kopiert), daher benötigt das Rendern nie das Netzwerk.
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: ein Hintergrund, eine Tinte, ein Grau, EIN Akzent. Akzent pro Marke tauschen (6-stelliger 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); // langsam, schnell, langsam: der "Kamera-Whip"
/** 0 bis 1 Federung, die bei `delay` startet. Standardmäßig glatt; übergib eine Konfiguration für den Sprung. */
export const useEnter = (delay = 0, config: object = {damping: 200}) => {
const frame = useCurrentFrame();
const {fps} = useVideoConfig();
return spring({frame: frame - delay, fps, config});
};
// ---- Zeitstrahl: Szenen überlappen sich um OVERLAP Frames, sodass Ausgang und Eingang zusammen stattfinden ----
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>
);
};
// ---- Szene: umschließt eine Idee. Kümmert sich um den Push/Fade Ein- und Ausgang, Bewegungsunschärfe, langsamen 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"` = Inhalt bewegt sich nach oben an seinen Platz. Für einen Push gib Szene A exit="up" und Szene 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(); // = Länge dieser 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; // Unschärfe folgt der Geschwindigkeit, wie ein echter Verschluss
const by = Math.abs(y - py) * 0.35;
const fIn = enter === 'fade' ? interpolate(frame, [t * 0.5, t], [0, 1], CL) : 1; // alte Szene wird zuerst geleert
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 ----
/** Zeigt Text Wort für Wort (oder Buchstabe für Buchstabe) an. Neuer Text erscheint grau und wird dann zur Tinte.
* Umschließe die Schlüsselphrase mit *Sternchen*, um sie zum Akzent zu machen. Verwende \n für einen Zeilenumbruch. */
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>
);
};
/** Der Eröffnungs-Hit: startet riesig und unscharf, schnappt in etwa 8 Frames auf seine Größe ein. */
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>
);
};
/** Kleine unterstützende Zeile unter der Überschrift. */
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>
);
};
// ---- Atmosphäre ----
/** Weiches Akzentlicht hinter dem Schlüsselwort. Platziere es mit x / y als CSS-Werten. */
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)`}} />
);
};
/** Treibende Kreise in der Nähe der Ränder, damit das Bild nie stillsteht. thin = Umrisse, soft = fette unscharfe Ringe. */
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(22px)' : undefined}} />
);
})}
</AbsoluteFill>
);
};
// ---- Objekte ----
/** Kreis, der von der Gesamtzeit herunterzählt. `total` Sekunden, `per` ist, was jede Umdrehung darstellt. */
export const CountRing: React.FC<{total: number; delay?: number; size?: number; label?: string}> = ({total, delay = 0, size = 120, label}) => {
const frame = useCurrentFrame();
const {fps} = useVideoConfig();
const s = useEnter(delay);
const sec = Math.max(0, total - (frame - delay) / fps);
const ang = (1 - sec / total) * Math.PI * 2;
const r = size * 0.45;
const dashArray = r * Math.PI * 2;
const dashOffset = dashArray * (1 - sec / total);
return (
<div style={{position: 'relative', display: 'flex', alignItems: 'center', justifyContent: 'center', width: size, height: size, opacity: s, transform: `scale(${s})`}}>
<svg width={size} height={size} style={{position: 'absolute', transform: 'rotate(-90deg)'}}>
<circle cx={size/2} cy={size/2} r={r} fill="none" stroke={`${T.accent}33`} strokeWidth={size * 0.06} />
<circle cx={size/2} cy={size/2} r={r} fill="none" stroke={T.accent} strokeWidth={size * 0.06}
strokeDasharray={dashArray} strokeDashoffset={dashOffset} strokeLinecap="round" />
</svg>
<span style={{fontFamily: T.sans, fontSize: size * 0.26, fontWeight: 600, color: T.accent}}>{Math.ceil(sec)}{label}</span>
</div>
);
};
// ---- Badges / Pills ----
/** Eine einzelne Badge. `label` wird innen angezeigt. optional `icon` als lucide-react Name (String) oder null. */
export const Badge: React.FC<{label: string; delay?: number; icon?: string | null; color?: string}> = ({label, delay = 0, icon, color = T.accent}) => {
const s = useEnter(delay);
return (
<div style={{display: 'inline-flex', alignItems: 'center', gap: 12, background: `${color}1A`, borderRadius: 60, padding: '16px 30px', margin: '6px 0',
opacity: s, transform: `translateX(${(1 - s) * 20}px)`, filter: s < 0.98 ? `blur(${(1 - s) * 6}px)` : undefined}}>
{icon && <span style={{color, display: 'flex'}}>{/* Hier würde ein lucide icon kommen */}</span>}
<span style={{fontFamily: T.sans, fontSize: 28, fontWeight: 600, letterSpacing: '-0.03em', color: T.ink}}>{label}</span>
</div>
);
};
/** Stapel von Badges. */
export const Badges: React.FC<{items: {label: string; icon?: string | null; delay?: number; color?: string}[]}> = ({items}) => (
<div style={{display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center'}}>
{items.map((item, i) => <Badge key={i} {...item} delay={item.delay ?? i * 5} />)}
</div>
);
// ---- Schleife ----
/** Zeichnet eine ovale Schleife um die Kinder. Der Strich beginnt unsichtbar und zeichnet sich dann ein. */
export const Circled: React.FC<{delay?: number; children: React.ReactNode}> = ({delay = 0, children}) => {
const s = useEnter(delay);
return (
<div style={{position: 'relative', display: 'inline-block'}}>
{children}
<svg style={{position: 'absolute', left: '-8%', top: '-12%', width: '116%', height: '130%', pointerEvents: 'none'}}>
<ellipse cx="50%" cy="50%" rx="50%" ry="50%" fill="none" stroke={T.accent} strokeWidth={4}
strokeDasharray={`${s * 600} 600`} strokeLinecap="round" opacity={s} />
</svg>
</div>
);
};
5. Die Videodatei erstellen
Erstelle src/Video.tsx. Definiere die beats als Array von Beat-Objekten und baue die letzte Szene (Logo/Ausklang). Verwende die Komponenten aus src/kit.tsx.
import React from 'react';
import {Timeline, Beat, Scene, Words, PunchIn, Caption, Glow, Rings, CountRing, Badges, Circled, T} from './kit';
export const beats: Beat[] = [
// Definiere hier die Beats
];
export const Video: React.FC = () => (
<Timeline beats={beats} />
);
6. Rendern
npx remotion render src/index.ts Reel out/output.mp4
Stil-Referenz
- Schriftart: Inter (500) für Fließtext, Instrument Serif (400 italic) für Akzentphrasen.
- Farben: Hintergrund
#FFFFFF, Tinte#0B0B0C, deaktiviert#B4B6BB, Akzent (hier#1FB81A, aber anpassbar). - Timing: Meiste Beats 2-3,5 Sekunden (60-110 Frames bei 30fps). Letzter Beat (Logo) 45-60 Frames.
- Übergänge: Standardmäßig
up-Push, mit gelegentlichen seitlichen Pushes und einemfadevor der entscheidenden Zeile. - Objekte:
CountRing,Badges,Circledoder benutzerdefinierte SVGs.
Fehlerbehebung
- Schriftarten werden nicht geladen: Stelle sicher, dass
public/fonts/die .woff2-Dateien enthält und die Pfade in derkit.tsx-Datei korrekt sind. - Rendering schlägt fehl: Überprüfe
tsconfig.jsonund die import-Pfade insrc/index.tsundsrc/Root.tsx. - Animationen sind ruckelig: Stelle sicher, dass die
fpsin derRoot.tsx-Komposition mit dem Wert in derVideo.tsx-Datei übereinstimmen.