Communitygithub.com

mvpdsigner/faceless-motion-reel

Construye un reel de gráficos en movimiento sin rostro (tipografía cinética, lienzo blanco, un color de acento, iconos animados) como código con Remotion y renderízalo a MP4. Ideal para reels, cortos, TikTok, Instagram o LinkedIn, videos sin rostro, gráficos en movimiento, texto cinético o solicitudes de videos explicativos, o cuando un guion, consejo o cita debe convertirse en un video vertical corto.

¿Qué es faceless-motion-reel?

Este skill transforma cualquier guion corto en un video vertical de 15 a 30 segundos con el estilo 'visuales mínimos, máximo impacto', sin necesidad de grabación, rostros ni locución. Está diseñado para creadores de contenido, desarrolladores y agentes de IA que desean producir videos cinéticos de aspecto profesional en segundos. El skill genera el video completo como código React con Remotion, lo que permite un control exacto de cada fotograma y revisiones sencillas. Se apoya en una biblioteca de componentes de animación (Scene, Words, PunchIn, Timeline, Rings, Glow) que manejan transiciones, tipografía por palabras y elementos visuales sin necesidad de edición manual. Soporta plataformas como Remotion, React, TypeScript y Node.js, y se integra con herramientas como Cursor, Claude Code y Codex para automatización total. El skill es útil para recibir un guion o una idea y devolver un video renderizado en MP4, siguiendo reglas de estilo estrictas: una idea por escena, solo tres colores, dos voces tipográficas, y animación constante para evitar que el video se sienta estático. Incluye una tabla de ritmos (beat sheet) que planifica la estructura del video antes de escribir código, garantizando un resultado pulido y profesional.

Compatible con~Claude Code~Codex CLI✓Cursor
npx skills add https://github.com/mvpdsigner/claude-skills/tree/HEAD/skills/faceless-motion-reel

Preguntar en tu IA favorita

Abre un nuevo chat con esta habilidad de agente ya precargada.

Documentación

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 pantallaFrase de acentoObjetoEntrada / SalidaFotogramas

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, un fade (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

Individual skills in this repo

This repo contains 1 individual skill — each has its own dedicated page.

Skills relacionados