---
name: remotion-galilei-video
description: >
  Experto en edición de videos estilo Galilei con Remotion para Instagram/TikTok (1080x1920).
  Usa esta skill SIEMPRE que alguien quiera crear o editar un video con: sujeto hablando a cámara,
  subtítulos palabra por palabra, hook textual con pill verde, b-rolls, color cards, música de fondo,
  zoom cinematográfico, o cualquier composición Remotion para redes sociales verticales.
  También activar cuando alguien mencione: roughcut, cortar silencios, transcripción con Whisper,
  margen seguro IG, hook agresivo, contenedor verde, bubble pop SFX, o edición de testimonial.
---

# Remotion Galilei Video — Guía de Edición

Video testimonial para Instagram/TikTok (1080×1920). Sujeto habla a cámara. El objetivo es retención máxima: hook agresivo, subtítulos claros, b-rolls donde sea necesario, momentos de impacto con color card.

---

## FLUJO DE TRABAJO

1. Analizar fuente → 2. Transcribir → 3. Fix encoding → 4. Construir roughcut → 5. Analizar frames → 6. Seleccionar b-rolls → 7. Implementar composición → 8. Verificar márgenes → 9. Renderizar

---

## 1. ROUGHCUT

**Nunca aplicar filtro de ruido** (`afftdn` o similar) — destruye la voz y suena "bajo el agua". Usar siempre el audio original sin procesar.

Cortar con ffmpeg concatenando segmentos limpios:

```bash
ffmpeg -i fuente.MOV -ss [start] -to [end] -c:v libx264 -c:a aac segmento.mp4
ffmpeg -f concat -safe 0 -i lista.txt -c copy roughcut.mp4
```

**Criterio editorial de cortes:**
- Eliminar: silencios > 0.5s, respiraciones audibles, muletillas ("eh", "o sea", "como que")
- Si hay duda sobre un corte → NO cortarlo. Esperar feedback del cliente
- Los 3 videos pueden ser UNA secuencia continua, no separados

### Técnicas de corte

**J-Cut** — el audio del clip siguiente empieza antes de que cambie el video. Oculta errores de grabación y hace los diálogos más dinámicos.

```tsx
// Audio que arranca antes del corte visual
<Sequence from={corteVisual - prerollAudio} durationInFrames={duracion}>
  <Audio src={staticFile("voz.mp3")} startFrom={prerollAudio} />
</Sequence>
// prerollAudio típico: 6-12 frames (0.2-0.4s a 30fps)
```

**Match Cut** — une dos escenas distintas por similitud de forma o composición visual. Checklist:
- [ ] Objeto/forma similar en la misma región de pantalla en ambos clips
- [ ] Color o valor tonal parecido en el punto de corte
- [ ] Movimiento de cámara en la misma dirección en ambos clips

**Zoom como disimulo** — cuando no hay stock ni animaciones para tapar un corte abrupto.

```tsx
function zoomAtCut(frame: number, cutFrame: number, fps: number): number {
  const delta = frame - cutFrame;
  if (delta < 0 || delta > fps) return 1.0;
  return interpolate(
    delta,
    [0, 4, Math.round(fps * 0.5), fps],
    [1.0, 1.08, 1.05, 1.0],
    { easing: Easing.out(Easing.ease), extrapolateLeft: "clamp", extrapolateRight: "clamp" }
  );
}
// Aplicar sobre el video principal junto con getZoomScale()
const scale = Math.max(getZoomScale(frame, fps), zoomAtCut(frame, CUT_FRAME, fps));
```

---

## 2. TRANSCRIPCIÓN

```python
from faster_whisper import WhisperModel
model = WhisperModel("medium")
segments, _ = model.transcribe("roughcut.mp4", word_timestamps=True, vad_filter=True)
```

**Fix mojibake SIEMPRE antes de usar el JSON:**

```js
const content = fs.readFileSync('subtitles.json', 'utf-8');
const fixed = Buffer.from(content, 'latin1').toString('utf8');
// Si contains('Ã') → tiene mojibake, aplicar fix y reescribir
fs.writeFileSync('subtitles.json', fixed, 'utf-8');
```

Corregir manualmente si Whisper malinterpreta el hook (es lo más crítico — siempre verificar las primeras líneas).

**Estructura JSON de subtítulos:**

```json
{
  "lines": [
    {
      "start": 0.02, "end": 1.76,
      "words": [
        {"word": "A", "start": 0.02, "end": 0.15},
        {"word": "esta", "start": 0.15, "end": 0.35}
      ]
    }
  ]
}
```

Si hay múltiples fuentes (g1, g2, g3), sumar el offset de tiempo de cada segmento al transcribir.

---

## 3. HOOK TEXTUAL

**Estructura SIEMPRE:** Texto blanco → Contenedor #B3F131 → Texto blanco

```tsx
// Texto blanco (82px) siempre MÁS GRANDE que texto en pill (70px)
const HOOK_DURATION = 3.5; // segundos

{t < HOOK_DURATION && (
  <div style={{
    position: "absolute", inset: 0,
    display: "flex", flexDirection: "column",
    justifyContent: "flex-end", alignItems: "center",
    paddingBottom: 400,  // >= 384px para margen seguro IG bottom
    paddingLeft: 52, paddingRight: 52,
  }}>
    <div style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 18 }}>
      <span style={{ fontFamily, fontWeight: 700, fontSize: 82, color: "#FFFFFF",
        textShadow: "2px 2px 10px #000, 0 0 20px #000", textAlign: "center", lineHeight: 1.1 }}>
        {HOOK_LINE_1}
      </span>
      <span style={{ fontFamily, fontWeight: 700, fontSize: 70, color: "#000000",
        backgroundColor: "#B3F131", borderRadius: 999,
        paddingLeft: 36, paddingRight: 36, paddingTop: 10, paddingBottom: 10,
        textAlign: "center", lineHeight: 1.2 }}>
        {HOOK_PILL}
      </span>
      <span style={{ fontFamily, fontWeight: 700, fontSize: 82, color: "#FFFFFF",
        textShadow: "2px 2px 10px #000, 0 0 20px #000", textAlign: "center", lineHeight: 1.1 }}>
        {HOOK_LINE_3}
      </span>
    </div>
  </div>
)}
```

**Posicionamiento — OBLIGATORIO analizar antes de escribir código:**

```bash
ffmpeg -ss 1.0 -i roughcut.mp4 -vframes 1 -q:v 2 public/tmp_hook_frame.jpg
```

Leer la imagen e identificar zona vertical de la cara:
- Cara en zona superior (0-40%) → hook abajo: `justifyContent: "flex-end"`, `paddingBottom: 400`
- Cara en zona inferior (60-100%) → hook arriba: `top: "14%"` (minimo 269px)
- Cara en zona media → hook abajo con `paddingBottom: 400`

**Reglas absolutas del hook:**
- NO subtítulos durante el hook (`!inHook` en condición de subtítulos)
- NO b-roll durante el hook (el roughcut se ve directo)
- NUNCA tapar la cara
- Siempre centrado horizontalmente

---

## 4. SUBTÍTULOS

**Posición — OBLIGATORIO analizar antes de escribir código** (mismo paso que el hook):

```bash
ffmpeg -ss 2.0 -i roughcut.mp4 -vframes 1 -q:v 2 public/tmp_sub_frame.jpg
```

- Cara en zona superior (0-40%) → subtítulos abajo: `top: "70%"` (1344px, 576px del fondo)
- Cara en zona inferior (60-100%) → subtítulos arriba: `top: "14%"` (mín. 269px)
- Cara en zona media → subtítulos abajo: `top: "70%"`

```tsx
import { loadFont } from "@remotion/google-fonts/RadioCanadaBig";
const { fontFamily } = loadFont("normal", { weights: ["700"] });

function cleanWord(w: string) {
  return w.replace(/[.,;:!?¿¡]/g, "").toLowerCase();
}

// FACE_ZONE: "top" | "bottom" | "mid" — definir según análisis de frame
const subtitleTop = FACE_ZONE === "bottom" ? "14%" : "70%";

{currentWord && !inCard && !inHook && (
  <div style={{ position: "absolute", top: subtitleTop, left: 64, right: 64,
    display: "flex", justifyContent: "center", alignItems: "center" }}>
    <span style={{
      fontFamily, fontWeight: 700,
      fontSize: isBig ? 90 : isGreen ? 72 : 62,
      color: isGreen ? "#B3F131" : "#FFFFFF",
      textShadow: "2px 2px 6px #000, 0px 0px 12px #000",
      lineHeight: 1.2, textAlign: "center"
    }}>
      {currentWord.word}
    </span>
  </div>
)}
```

Tamaños: normal 62px | green keywords 72px | impact words 90px

---

## 5. COLOR CARD

Momento de impacto: fondo oscuro + palabra prominente centrada.

```tsx
const COLOR_CARD = { start: 37.23, end: 39.0 };
const inCard = t >= COLOR_CARD.start && t <= COLOR_CARD.end;

{inCard && (
  <>
    <div style={{ position: "absolute", inset: 0, backgroundColor: "#171a1e" }} />
    {currentWord && (
      <div style={{ position: "absolute", inset: 0, display: "flex",
        justifyContent: "center", alignItems: "center",
        paddingLeft: 64, paddingRight: 64 }}>
        <span style={{ fontFamily, fontWeight: 700, fontSize: 90,
          color: "#FFFFFF", lineHeight: 1.2, textAlign: "center" }}>
          {currentWord.word}
        </span>
      </div>
    )}
  </>
)}
```

**Música pausada durante color card** — usar `startFrom` para continuidad:

```tsx
const cardStartF = Math.round(COLOR_CARD.start * fps);
const cardEndF   = Math.round(COLOR_CARD.end   * fps);
const musicSegments = [
  { from: 0,        duration: cardStartF,              startFrom: 0 },
  { from: cardEndF, duration: totalFrames - cardEndF,  startFrom: cardStartF },
];
{musicSegments.map((seg, i) => (
  <Sequence key={i} from={seg.from} durationInFrames={seg.duration}>
    <Audio src={staticFile("music.mp3")} volume={0.1} startFrom={seg.startFrom} />
  </Sequence>
))}
```

**Bubble pop SFX en cada palabra del color card** (volume: 1.778 = +5dB):

```tsx
const CARD_WORD_TIMES = [37.23, 37.6, 37.9, 38.3];
{CARD_WORD_TIMES.map((t_word, i) => (
  <Sequence key={i} from={Math.round(t_word * fps)} durationInFrames={60}>
    <Audio src={staticFile("sfx/bubble-pop.mp3")} volume={1.778} />
  </Sequence>
))}
```

---

## 6. ZOOM

```tsx
function getZoomScale(frame: number, fps: number): number {
  const ZOOM_OUT = 24;
  let scale = frame < ZOOM_OUT
    ? interpolate(frame, [0, ZOOM_OUT], [1.2, 1.0], {
        easing: Easing.out(Easing.ease),
        extrapolateLeft: "clamp", extrapolateRight: "clamp"
      })
    : 1.0;

  for (const moment of ZOOM_MOMENTS) {
    const mf = Math.round(moment * fps);
    const delta = frame - mf;
    if (delta >= 0 && delta < fps * 2) {
      const z = interpolate(delta, [0, 4, fps * 2], [1.0, 1.1, 1.03], {
        extrapolateLeft: "clamp", extrapolateRight: "clamp"
      });
      scale = Math.max(scale, z);
    }
  }
  return scale;
}

<AbsoluteFill style={{ transform: `scale(${scale})`, transformOrigin: "center center" }}>
  <OffthreadVideo src={ROUGHCUT_SRC} style={{ width: "100%", height: "100%" }} />
</AbsoluteFill>
```

---

## 7. AUDIO

| Elemento | Volumen linear | dB equivalente |
|---|---|---|
| Música de fondo | `0.1` | -20 dB |
| SFX flash de cámara | `0.562` | -5 dB |
| SFX bubble pop (color card) | `1.778` | +5 dB |
| B-rolls | `0` | muted |

```tsx
<Sequence from={0} durationInFrames={90}>
  <Audio src={staticFile("sfx/flash.mp3")} volume={0.562} />
</Sequence>
```

### Selección de música

Criterio único: que vaya con el **ritmo del video**, no con la popularidad ni con lo que esté en tendencia.

**Media Bank** — ruta: `🏞️ Media Bank/Música/`

| Categoría | Cuándo usarla |
|---|---|
| Energético | Acción, logros, high-energy |
| Motivacional | Transformación, crecimiento, superación |
| Story | Narración tranquila, testimonial personal |
| Emocional | Vulnerabilidad, conexión |
| Chill | Lifestyle, reflexión, tono casual |
| Misterioso | Reveal, tensión narrativa, suspenso |
| Épico | Grandes proclamas, momentos de impacto |
| Oscuro | Crítica, contraste, conflicto dramático |
| Gracioso | Humor, micro-teatro cómico |
| Edit Audios | Cortes rápidos, style edit |
| Cultural | Música por país/destino (subcarpetas: Argentina · Caño Cristales · Corea del Sur · Japón · Kenia · Marruecos · Mompox · Tailandia · Turquía · Túnez) |

**Categoría por defecto para Galilei:** Motivacional o Story según el tono del video.

Si la propiedad `Música` del guión en Notion tiene valor → usar ese track específico en lugar del default.

### Detección de BPM y sincronización

El BPM define el ritmo del montaje. Los cortes y efectos deben caer en el compás.

```bash
python3 -c "
import librosa
y, sr = librosa.load('music.mp3')
tempo, beats = librosa.beat.beat_track(y=y, sr=sr)
beat_times = librosa.frames_to_time(beats, sr=sr)
print(f'BPM: {tempo:.1f}')
print('Beat frames (30fps):', [round(t * 30) for t in beat_times[:20]])
"
```

Conversión BPM a frames (30fps):
- 60 BPM → 1 beat cada 30 frames (1s)
- 90 BPM → 1 beat cada 20 frames (0.67s)
- 120 BPM → 1 beat cada 15 frames (0.5s)
- 140 BPM → 1 beat cada ~13 frames (0.43s)

### Silencio estratégico

El recurso más infravalorado. Úsalo antes de una revelación o conclusión clave: baja la música a cero, deja que la voz respire sola.

```tsx
// Fade out/in de música alrededor del silencio
<Audio
  src={staticFile("music.mp3")}
  volume={(f) => interpolate(
    f,
    [SILENCE_START_F - 6, SILENCE_START_F, SILENCE_END_F, SILENCE_END_F + 6],
    [0.1, 0, 0, 0.1],
    { extrapolateLeft: "clamp", extrapolateRight: "clamp" }
  )}
/>
```

---

## 8. B-ROLLS Y FONDO OSCURO

### EVALUACIÓN DE CONTACTO VISUAL — PASO CRÍTICO OBLIGATORIO

Antes de definir cualquier b-roll, extraer frames y analizar visualmente cada uno. Esto no es opcional.

```bash
for t in 4 8 12 16 20 25 30 35 40 45 50 55 60 65 70; do
  ffmpeg -ss $t -i roughcut.mp4 -vframes 1 -q:v 2 public/tmp_frame_${t}s.jpg -y
done
```

**Cómo evaluar cada frame:**
- ¿Los ojos del sujeto miran al lente de la cámara? → **SÍ mira** → mostrar al personaje directamente (sin b-roll)
- ¿Los ojos miran hacia un lado, abajo, o tiene gesto de estar leyendo/recordando? → **NO mira** → B-Roll o fondo oscuro obligatorio
- Signos de "leyendo": ojos moviéndose horizontalmente, mirada baja sostenida, cabeza inclinada hacia apuntes

**Balance de escenas — fundamental para que el video se sienta vivo:**
- Las escenas donde SÍ mira a cámara son oro: muestran al personaje con presencia, generan conexión. Dejarlas siempre sin b-roll.
- Las escenas donde NO mira son oportunidades visuales: usar b-roll contextual o fondo oscuro con subtítulos.
- No saturar de b-rolls. Un video con el personaje visible en los momentos correctos + b-rolls en los incorrectos tiene más ritmo que uno con b-roll continuo.

### Opciones para cuando NO mira a cámara

**Opción A — B-Roll contextual:**

```tsx
{brollsData.brolls.map((b) => (
  <Sequence key={b.file + b.start}
    from={Math.round(b.start * fps)}
    durationInFrames={Math.round((b.end - b.start) * fps)}>
    <AbsoluteFill>
      <OffthreadVideo src={staticFile(b.file)} volume={0}
        style={{ width: "100%", height: "100%", objectFit: "cover" }} />
    </AbsoluteFill>
  </Sequence>
))}
```

**Opción B — Fondo oscuro #171A1E + subtítulos:**
Cuando el momento es narrativamente fuerte pero no hay b-roll adecuado, o cuando se quiere énfasis visual sin cortar al personaje. El fondo oscuro hace que las palabras respiren y el espectador se enfoque en lo que se dice.

```tsx
// En el componente de subtítulos — segmentos con fondo oscuro
const DARK_BG_SEGMENTS = [
  { start: 22.0, end: 27.0 },  // momento de impacto sin b-roll disponible
];
const inDarkBg = DARK_BG_SEGMENTS.some(s => t >= s.start && t <= s.end);

{inDarkBg && (
  <div style={{ position: "absolute", inset: 0, backgroundColor: "#171a1e" }} />
)}

// Los subtítulos normales se siguen mostrando encima del fondo oscuro
{currentWord && !inCard && !inHook && (
  <div style={{ position: "absolute", top: "70%", ... }}>
    {/* subtítulo word-by-word */}
  </div>
)}
```

**Criterio para elegir entre Opción A y B:**
- Hay b-roll contextual que encaja con la narración → usar b-roll (Opción A)
- No hay b-roll adecuado, o el momento es muy verbal/filosófico → fondo oscuro (Opción B)
- Momentos de "destruido", "difícil", "fracaso" → b-roll pesado o fondo oscuro
- Nunca mezclar fondo oscuro con b-roll al mismo tiempo

**Verificar archivos antes de incluir:**

```bash
ffprobe.exe -v error -show_entries format=duration -of default=nw=1 archivo.mp4
# Si falla con exit code 1 → archivo corrupto, descartar y usar otro
```

**Criterio contextual de b-rolls:**
- Relacionar el clip con lo que se narra en ese momento
- No poner clips celebratorios en momentos negativos/de queja
- No poner b-roll durante el hook

---

## 9. MARGEN SEGURO IG (1080x1920)

```
Top unsafe:    0 - 269px   (14%) — UI bar
Zona segura: 269 - 1536px
Bottom unsafe: 1536 - 1920px (20%) — botones, caption
Lados unsafe:  0 - 54px    (5%)  — cada lado
```

Checklist antes de renderizar:
- [ ] Hook arriba: top >= 269px | Hook abajo: paddingBottom >= 384px
- [ ] Subtítulos: top "70%" = 1344px desde arriba (576px desde fondo) OK
- [ ] Color card centrado OK
- [ ] Ningún texto a menos de 54px de los bordes laterales

---

## 10. ANIMACIONES

Toda animación debe sentirse orgánica. Sin easing = movimiento robótico. Regla inamovible.

### Ease In/Out con interpolate

```tsx
// Ease out (entra rápido, frena suave) — para entradas
const opacity = interpolate(frame, [START, START + 12], [0, 1], {
  easing: Easing.out(Easing.ease),
  extrapolateLeft: "clamp", extrapolateRight: "clamp"
});

// Ease in (arranca lento, termina rápido) — para salidas
const slideOut = interpolate(frame, [EXIT, EXIT + 10], [0, -80], {
  easing: Easing.in(Easing.ease),
  extrapolateLeft: "clamp", extrapolateRight: "clamp"
});
```

### Spring (para elementos UI — lo más natural)

```tsx
const scale = spring({
  frame,
  fps,
  from: 0.8,
  to: 1.0,
  config: {
    damping: 14,    // más alto = menos rebote
    stiffness: 180, // más alto = más rápido
    mass: 0.8,
  },
});
```

Valores de referencia:
- Entrada suave: `{ damping: 18, stiffness: 120, mass: 1 }`
- Pop snappy: `{ damping: 10, stiffness: 220, mass: 0.6 }`
- Rebote notorio: `{ damping: 6, stiffness: 200, mass: 0.8 }`

### Graph Editor mental (control milimétrico de velocidad)

```tsx
// Movimiento que arranca rápido, se frena a la mitad y aterriza suave
const y = interpolate(
  frame,
  [0,   4,   10,  18,  24],  // keyframes
  [80,  20,  8,   2,   0],   // valores en px
  { extrapolateLeft: "clamp", extrapolateRight: "clamp" }
);
// Cada par de puntos define un segmento de la curva de velocidad
```

### Entrada estándar de elementos de texto

```tsx
function entryAnimation(frame: number, startFrame: number) {
  const f = frame - startFrame;
  return {
    opacity: interpolate(f, [0, 10], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" }),
    transform: `translateY(${interpolate(f, [0, 12], [20, 0], {
      easing: Easing.out(Easing.ease),
      extrapolateLeft: "clamp", extrapolateRight: "clamp",
    })}px)`,
  };
}
```

---

## 12. ESTRUCTURA COMPOSICIÓN

```tsx
// Orden de capas (de abajo hacia arriba):
<AbsoluteFill style={{ backgroundColor: "#000" }}>
  {/* 1. Video principal con zoom */}
  {/* 2. B-rolls silenciados */}
  {/* 3. Música segmentada */}
  {/* 4. SFX flash inicio */}
  {/* 5. Subtítulos + hook + color card — siempre encima */}
  <NombreVideoSubtitles />
</AbsoluteFill>
```

---

## 13. RENDERIZAR

```bash
npx remotion render [CompositionId] out/[nombre]-final.mp4
```

Si falla con un b-roll: `Invalid data found when processing input` → verificar con ffprobe, remover el corrupto y reemplazar.
