Reel de motion graphics sem rosto
Transforme um script curto em um vídeo vertical de 15 a 30 segundos no estilo "visual mínimo, impacto máximo": uma tela branca limpa, tipografia preta, uma cor de destaque e um pequeno objeto animado por ideia. Sem filmagens, sem rosto, sem narração necessária. O vídeo é escrito como código React com Remotion, então cada quadro é exato e fácil de revisar.
A aparência (o que faz esse estilo funcionar)
Essas regras vieram do estudo de um reel de referência quadro a quadro. Elas importam mais do que qualquer efeito isolado, então mantenha-as mesmo quando inventar novas cenas.
- Uma ideia por cena. Cada cena mostra uma linha curta (2 a 6 palavras) mais um objeto que carrega o significado. Um espectador deve entendê-la com o som desligado.
- Apenas três cores. Fundo branco, tinta quase preta, um destaque. Cinza é usado apenas para texto que "ainda não pousou". Um segundo destaque faz parecer um modelo.
- Duas vozes tipográficas. Uma sans-serif justa (Inter, peso 500, tracking por volta de -0.04em) para a maioria das palavras, e uma serifa itálica na cor de destaque para a frase que importa. Use a serifa no máximo uma vez por cena.
- O texto chega, não apenas aparece. Novas palavras entram em cinza e ligeiramente borradas, depois se fixam em preto. O olho segue a palavra mais nova.
- Nada nunca fica parado. Cada cena mantém um zoom lento e os anéis de fundo continuam se movendo. Um quadro congelado parece uma apresentação de slides.
- Transições são movimentos de câmera. As cenas empurram para cima ou para os lados com desfoque de movimento, ou desaparecem para branco. Sem cortes, giros ou explosões de zoom.
- Tipografia pequena, muito espaço. Títulos ficam por volta de 84 a 100px em um quadro de 1080 de largura. O espaço vazio é o estilo.
- O objeto é a ideia. Um anel de contagem regressiva para tempo, badges rotulados para uma lista de problemas, pills empilhadas para uma lista de traços, um loop desenhado ao redor da linha de recompensa. Quando o script tem uma imagem forte (um relógio, um gráfico, um cursor), desenhe esse objeto em SVG em vez de reutilizar uma peça do kit.
Fluxo de trabalho
1. Obtenha o briefing
Você precisa: da mensagem ou script, da cor de destaque, do logotipo ou nome para a última cena, e se há um arquivo de música. Se o usuário deu um tópico, mas nenhum script, escreva o script você mesmo e mostre-o no beat sheet. Não empate em perguntas. Padrões sensatos: cerca de 20 segundos, 1080x1920, 30fps, destaque #1FB81A, sem áudio.
2. Escreva o beat sheet primeiro
Antes de qualquer código, escreva o plano como uma pequena tabela e mostre ao usuário na mesma mensagem em que você começar a construir. É aqui que o vídeo é ganho ou perdido, porque beats fracos não podem ser corrigidos com animação.
| # | Linha na tela | Frase de destaque | Objeto | Entrada / Saída | Quadros |
|---|
Diretrizes:
- Beat 1 é o gancho: 2 a 5 palavras, declaradas como uma tensão ou uma afirmação ousada.
- 6 a 9 beats para 15 a 30 segundos. A maioria dos beats dura de 60 a 110 quadros (2 a 3,5 segundos). Dê mais tempo aos beats de lista (badges, pills): cerca de 20 quadros por item mais 30 para ler.
- Construa em direção a uma linha de recompensa perto do fim e dê a ela o loop (
Círculo). - O último beat é o logotipo ou handle, de 45 a 60 quadros.
- Varie o objeto de beat a beat. Dois beats apenas com texto seguidos é aceitável. Dois beats de badge seguidos não é.
- Varie as transições: principalmente pushes para
cima, um push lateral, umfadeantes da recompensa.
3. Configure o projeto
Execute isso uma vez em uma pasta vazia. As fontes são copiadas para o projeto para que a renderização nunca dependa da rede.
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. Adicione o kit de motion
Crie src/kit.tsx com exatamente este código. Ele foi renderizado e verificado, então copie-o como está e altere apenas a linha do tema. Construa novas peças ao lado dele no mesmo espírito.
import React from 'react';
import {
AbsoluteFill, Easing, Sequence, interpolate, interpolateColors, random, spring,
staticFile, useCurrentFrame, useVideoConfig,
} from 'remotion';
import {loadFont} from '@remotion/fonts';
// As fontes são arquivos locais (copiados do @fontsource na configuração), então as renderizações nunca precisam da rede.
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';
// ---- Tema: um fundo, uma tinta, um cinza, UM destaque. Troque o destaque por marca (hex de 6 dígitos). ----
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); // lento, rápido, lento: o "chicote de câmera"
/** Mola de 0 a 1 que começa em `delay`. Suave por padrão; passe uma configuração para bounce. */
export const useEnter = (delay = 0, config: object = {damping: 200}) => {
const frame = useCurrentFrame();
const {fps} = useVideoConfig();
return spring({frame: frame - delay, fps, config});
};
// ---- Linha do tempo: as cenas se sobrepõem por OVERLAP quadros para que a saída e a entrada aconteçam juntas ----
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>
);
};
// ---- Cena: envolve uma ideia. Lida com o push/fade de entrada e saída, desfoque de movimento, zoom lento. ----
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"` = conteúdo viaja para cima até o lugar. Para um push, dê à cena A exit="up" e à cena 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(); // = duração desta Sequência
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; // desfoque segue a velocidade, como um obturador real
const by = Math.abs(y - py) * 0.35;
const fIn = enter === 'fade' ? interpolate(frame, [t * 0.5, t], [0, 1], CL) : 1; // a cena antiga limpa primeiro
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>
</>
);
};
// ---- Texto ----
/** Revela o texto palavra por palavra (ou letra por letra). O novo texto chega cinza e depois se fixa na tinta.
* Envolva a frase-chave em *estrelas* para torná-la o destaque. Use \n para uma quebra de linha. */
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>
);
};
/** O hit de abertura: começa enorme e desfocado, ajusta-se ao tamanho em cerca de 8 quadros. */
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>
);
};
/** Linha de apoio pequena abaixo do título. */
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>
);
};
// ---- Atmosfera ----
/** Luz de destaque suave atrás da palavra-chave. Coloque-a com x / y como valores CSS. */
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)`}} />
);
};
/** Círculos errantes perto das bordas para que o quadro nunca fique parado. thin = contornos, soft = anéis grossos desfocados. */
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
…
[truncado para tradução]