Reel de gráficos en movimiento sin rostro
Convierte un guion corto en un video vertical de 15 a 30 segundos con el estilo "visuales mínimos, máximo impacto": un lienzo blanco limpio, texto negro, un color de acento y un objeto animado pequeño por idea. Sin necesidad de grabación, sin rostro, sin locución. El video se escribe como código React con Remotion, por lo que cada fotograma es exacto y fácil de revisar.
El estilo (por qué funciona)
Estas reglas provienen del estudio de un reel de referencia fotograma a fotograma. Importan más que cualquier efecto individual, así que consérvalas incluso cuando inventes nuevas escenas.
- Una idea por escena. Cada escena muestra una línea corta (2 a 6 palabras) más un objeto que transmite el significado. El espectador debe entenderlo con el sonido apagado.
- Solo tres colores. Fondo blanco, tinta casi negra, un acento. El gris solo se usa para el texto que "aún no ha aterrizado". Un segundo acento haría que pareciera una plantilla.
- Dos voces tipográficas. Una sans-serif compacta (Inter, peso 500, tracking alrededor de -0.04em) para la mayoría de las palabras, y una serif itálica en el color de acento para la frase importante. Usa la serif como máximo una vez por escena.
- El texto llega, no solo aparece. Las palabras nuevas aparecen en gris y ligeramente borrosas, luego se asientan en negro. El ojo sigue la palabra más nueva.
- Nunca está quieto. Cada escena mantiene un zoom lento y los anillos de fondo se desplazan constantemente. Un fotograma congelado parece una presentación de diapositivas.
- Las transiciones son movimientos de cámara. Las escenas se empujan hacia arriba o hacia los lados con desenfoque de movimiento, o se desvanecen en blanco. Sin cortinillas, giros ni estallidos de zoom.
- Texto pequeño, mucho espacio aéreo. Los titulares tienen entre 84 y 100px en un fotograma de 1080 de ancho. El espacio vacío es el estilo.
- El objeto es la idea. Un anillo de cuenta atrás para el tiempo, insignias etiquetadas para una lista de problemas, píldoras apiladas para una lista de rasgos, un bucle dibujado alrededor de la línea clave. Cuando el guion tiene una imagen fuerte (un reloj, un gráfico, un cursor), dibuja ese objeto en SVG en lugar de reutilizar una pieza del kit.
Flujo de trabajo
1. Obtén el brief
Necesitas: el mensaje o guion, el color de acento, el logotipo o nombre para la última escena, y si hay un archivo de música. Si el usuario dio un tema pero no un guion, escribe el guion tú mismo y muéstralo en la tabla de ritmos. No te detengas en preguntas. Valores predeterminados sensatos: unos 20 segundos, 1080x1920, 30fps, acento #1FB81A, sin audio.
2. Escribe la tabla de ritmos primero
Antes de cualquier código, escribe el plan como una tabla pequeña y muéstraselo al usuario en el mismo mensaje donde comiences a construir. Aquí es donde se gana o se pierde el video, porque los ritmos débiles no se pueden arreglar con animación.
| # | Línea en pantalla | Frase de acento | Objeto | Entrada / Salida | Fotogramas |
|---|
Pautas:
- El ritmo 1 es el gancho: de 2 a 5 palabras, planteado como una tensión o una afirmación audaz.
- De 6 a 9 ritmos para 15 a 30 segundos. La mayoría de los ritmos duran de 60 a 110 fotogramas (2 a 3.5 segundos). Da más tiempo a los ritmos de listas (insignias, píldoras): unos 20 fotogramas por elemento más 30 para leer.
- Construye hacia una línea clave cerca del final y dale el bucle (
Circled). - El último ritmo es el logotipo o identificador, de 45 a 60 fotogramas.
- Varía el objeto de un ritmo a otro. Dos ritmos solo de texto seguidos está bien. Dos ritmos de insignias seguidos no lo está.
- Varía las transiciones: principalmente empujes
up(hacia arriba), un empuje lateral, unfade(desvanecimiento) antes del clímax.
3. Configura el proyecto
Ejecuta esto una vez en una carpeta vacía. Las fuentes se copian en el proyecto para que el renderizado nunca dependa de la red.
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. Agrega el kit de movimiento
Crea src/kit.tsx exactamente con este código. Ha sido renderizado y verificado, así que cópialo tal cual y cambia solo la línea del tema. Construye nuevas piezas junto a él con el mismo espíritu.
import React from 'react';
import {
AbsoluteFill, Easing, Sequence, interpolate, interpolateColors, random, spring,
staticFile, useCurrentFrame, useVideoConfig,
} from 'remotion';
import {loadFont} from '@remotion/fonts';
// Las fuentes son archivos locales (copiados de @fontsource durante la configuración), por lo que los renderizados nunca necesitan la red.
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: un fondo, una tinta, un gris, UN acento. Cambia el acento 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: el "latigazo de cámara"
/** Muelle de 0 a 1 que comienza en `delay`. Suave por defecto; pasa una configuración para rebote. */
export const useEnter = (delay = 0, config: object = {damping: 200}) => {
const frame = useCurrentFrame();
const {fps} = useVideoConfig();
return spring({frame: frame - delay, fps, config});
};
// ---- Línea de tiempo: las escenas se superponen en OVERLAP fotogramas para que la salida y la entrada ocurran 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>
);
};
// ---- Escena: envuelve una idea. Maneja el empuje/desvanecimiento de entrada y salida, desenfoque de movimiento, 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"` = el contenido viaja hacia arriba hasta su lugar. Para un empuje, dale a la escena A exit="up" y a la escena 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(); // = duración de esta secuencia
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; // desenfoque sigue la velocidad, como un obturador real
const by = Math.abs(y - py) * 0.35;
const fIn = enter === 'fade' ? interpolate(frame, [t * 0.5, t], [0, 1], CL) : 1; // la escena antigua se limpia primero
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 el texto palabra por palabra (o letra por letra). El texto nuevo aparece gris y luego se asienta en tinta.
* Envuelve la frase clave en *estrellas* para que sea el acento. Usa \n para un salto de línea. */
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>
);
};
/** El golpe de apertura: comienza enorme y borroso, se ajusta al tamaño en unos 8 fotogramas. */
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>
);
};
/** Línea de apoyo pequeña debajo del titular. */
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>
);
};
// ---- Atmósfera ----
/** Luz de acento suave detrás de la palabra clave. Colócala con 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 cerca de los bordes para que el fotograma nunca esté quieto. thin = contornos, soft = anillos gruesos borrosos. */
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.03}px)` : undefined,
}} />
);
})}
</AbsoluteFill>
);
};
// ---- Objetos ----
export type ShapeVariant = 'badge' | 'pill' | 'circle' | 'line';
/** Insignia con texto: un rectángulo redondeado con una etiqueta. */
export const Badge: React.FC<{label: string; delay?: number; color?: string}> = ({label, delay = 0, color = T.accent}) => {
const s = useEnter(delay);
const frame = useCurrentFrame();
const w = 200 + label.length * 32;
return (
<div style={{display: 'flex', gap: 10, justifyContent: 'center', alignItems: 'center', marginTop: 40, opacity: s,
transform: `scale(${interpolate(s, [0, 1], [0.8, 1])})`,
}}>
<div style={{width: w, padding: '8px 18px', borderRadius: 50, border: `2px solid ${color}`, display: 'flex',
alignItems: 'center', justifyContent: 'center', fontSize: 36, fontWeight: 500, letterSpacing: '-0.02em', color,
}}>
{label}
</div>
</div>
);
};
/** Píldora apilable horizontal. */
export const Pill: React.FC<{text: string; index?: number; color?: string}> = ({text, index = 0, color = T.accent}) => {
const frame = useCurrentFrame();
const s = spring({frame: frame - index * 3, fps: 30, config: {damping: 200}});
return (
<div style={{display: 'inline-block', padding: '6px 24px', borderRadius: 60, background: `${color}1A`, margin: 4,
fontSize: 32, fontWeight: 500, letterSpacing: '-0.02em', color: T.ink, opacity: s,
transform: `translateX(${interpolate(s, [0, 1], [30, 0])}px)`,
}}>
{text}
</div>
);
};
/** Un círculo centrado con texto animado (útil para cuenta atrás o números). */
export const CountDown: React.FC<{value: number; max?: number; delay?: number; size?: number; color?: string}> = ({value, max = 10, delay = 0, size = 160, color = T.accent}) => {
const s = useEnter(delay);
const frame = useCurrentFrame();
const progress = 1 - value / max;
const angle = progress * 360;
return (
<div style={{display: 'flex', justifyContent: 'center', marginTop: 40, opacity: s,
transform: `scale(${interpolate(s, [0, 1], [0.6, 1])})`,
}}>
<svg width={size} height={size} viewBox="0 0 100 100">
<circle cx="50" cy="50" r="42" fill="none" stroke={`${color}33`} strokeWidth="6" />
<circle cx="50" cy="50" r="42" fill="none" stroke={color} strokeWidth="6" strokeDasharray={`${angle / 360 * 264} 264`} transform={`rotate(-90 50 50)`} />
<text x="50" y="50" textAnchor="middle" dominantBaseline="central" fill={T.ink} fontSize="28" fontWeight="bold">{value}</text>
</svg>
</div>
);
};
/** Un lazo alrededor del texto (el objeto "bucle" para el clímax). */
export const Circled: React.FC<{delay?: number; padding?: number; color?: string}> = ({delay = 0, padding = 20, color = T.accent}) => {
const s = useEnter(delay);
const frame = useCurrentFrame();
return (
<div style={{position: 'absolute', left: '50%', top: '50%', transform: 'translate(-50%, -50%)', opacity: s, pointerEvents: 'none'}}>
<svg width="300" height="100" viewBox="0 0 300 100" style={{display: 'block'}}>
<path d="M20 50 Q150 -10 280 50" fill="none" stroke={color} strokeWidth="4" strokeDasharray={`${interpolate(frame % 120, [0, 120], [0, 300])} 300`} opacity={0.6} />
<circle cx="20" cy="50" r="6" fill={color} />
</svg>
</div>
);
};
5. Escribe tu video
Crea src/Video.tsx. Este es el único archivo que debes editar para cada video. Copia esta estructura y llena tu tabla de ritmos y componentes.
import React from 'react';
import {Timeline, Scene, Words, PunchIn, Caption, Glow, Rings, Badge, Pill, CountDown, Circled, T, type Beat} from './kit';
export const beats: Beat[] = []; // ← completa aquí
export const Video: React.FC = () => (
<Timeline beats={beats} />
);
6. Renderiza
npx remotion render src/index.ts Reel out/reel.mp4