# Spec técnico de implementación — Pipeline completo de producción de videos (AgentSquad / AI4Managers)

> **Propósito de este documento:** especificación técnica completa y autocontenida del pipeline de producción de videos (Shorts verticales 9:16 y long-form), escrita para que un LLM lo re-implemente en una plataforma completamente nueva **sin acceso al código original**. Incluye contratos de datos, esquemas SQL, endpoints de APIs externas, recetas FFmpeg con filtros exactos, máquinas de estados, gates de calidad con umbrales numéricos, y las reglas duras de contenido/marca. Consolidado a 2026-07-05.
>
> **Seguridad:** este documento solo nombra variables de entorno, nunca contiene valores de credenciales. El implementador debe provisionar sus propias keys.

**Visión macro:** `Ideación → Scout/Library → Guion → VO → Avatar/B-rolls → Composición → Packaging → Publicación → Métricas` (9 etapas, QA gates bloqueantes en cada transición). El sistema produce Shorts bilingües (ES/EN) multi-persona con **2 gates humanos** (confirmar Scene Element + render del avatar en HeyGen); todo lo demás es automatizable.

---

## 0. Arquitectura general y principios de diseño

### 0.1 Unidad de trabajo
Un video = una carpeta `queue/<slug>/` gobernada por un archivo **`spec.json`** (contrato único, ver §6.1). Artefactos dentro de la carpeta: `spec.json`, `script.txt`, `heygen-brief.md`, `vo.mp3`, `avatar.mp4` (lo deja el humano), `intro.mp4` (cold-open), `brolls/`, `final.mp4`, `proposal.md`, `contact-sheet.jpg`.

### 0.2 Máquina de estados (SSOT)
```
scene_pending → scene_confirmed → brief_ready → avatar_dropped → composed → published
                                                       ↘ qa_failed → (re-drop) avatar_dropped
composed → qa_failed
```
Toda transición se valida contra una tabla `_ALLOWED` de transiciones forward; una transición ilegal lanza error. Los watchers de cron (`compose`, `publish`) solo procesan carpetas en el estado que les corresponde.

### 0.3 Principios no negociables
1. **Idempotencia en publicación:** el `video_id` de YouTube se persiste en `spec.json` ANTES de operaciones secundarias (thumbnail, playlist, comment). Un re-run nunca re-sube.
2. **Gates de calidad bloqueantes** con umbrales numéricos (no juicios subjetivos): VO ≥0.97 de alineación, thumbnail score ≥7, avatar QA visual, QA de video final.
3. **Cost gates antes de toda generación paga** (MuAPI, Apify, ElevenLabs): estimar → confirmar → generar. Iteración generativa 1-by-1, nunca batch.
4. **Captions = guion verbatim:** el texto en pantalla sale del guion, con los *timings* de Whisper (nunca el texto de Whisper, que dropea negaciones/tildes/acrónimos).
5. **Identidad facial:** el rostro real de una persona SOLO sale de su avatar HeyGen entrenado. Los modelos generativos (MuAPI/Higgsfield/Sora) reinterpretan caras — solo sirven para b-roll no-persona.
6. **Filtrar antes de pagar:** en la ingesta de winners, se filtra por thresholds ANTES de generar embeddings.

### 0.4 Dataflow (nodos y artefactos que viajan)

| De → A | Artefacto | Gate/criterio |
|---|---|---|
| Roberto → Scout | tema grounded | experiencia real (INTERVIEW) |
| Scout → library.db | winners filtrados | thresholds views/comments/edad |
| library.db → Guion REX | briefing de patterns | top-5 semántico |
| Guion REX → VO | guion validado | gates.py OK (duración/beats) |
| Guion REX → B-rolls | prompts b-roll | 1-by-1 + cost gate |
| VO → Avatar | VO aprobado | voqa ≥0.97 |
| Avatar → Composición | clips avatar | avatar_qa sin deformidades |
| B-rolls → Composición | b-rolls fullscreen/cards | estilo premium |
| Composición → Packaging | master MP4 | +faststart, QA check |
| Packaging → publish | thumbnail + título | JuntaYT + score ≥7 + ≤60c |
| publish → YouTube | video_id | por canal, idempotente |
| YouTube → Métricas | views/AVD/retención | loop mensual (CTR = Studio-only) |

---

## 1. Etapa 1 — Ideación & Grounding

**Regla dura: nunca generar ideas de video en frío.**

1. **Step 0 INTERVIEW (mandatorio):** antes de proponer temas, entrevistar al dueño del canal con 3-4 preguntas ("¿qué problema resolviste esta semana con IA que te sorprendió?", "¿qué pregunta de un cliente no supiste responder?", "¿qué herramienta cambió tu forma de trabajar?", "¿qué opinión impopular tienes?"). Las ideas salen SOLO de las respuestas. Se salta únicamente si el usuario llega con tema específico.
2. **Registro de ideas rechazadas** (`rejected-ideas.md`): leerlo ANTES de proponer; nunca re-proponer ángulos similares. Cada rechazo se anexa con fecha, razón y quién lo propuso.
3. **Checklist REX de 8 criterios antes de escribir** (mínimo 6/8 o se optimiza la idea): ¿lo entiende un niño de 10 años? · ¿genera comentarios/debate? · ¿alguien pagaría por esto? · ¿50% se identifican? · ¿evita tecnicismos? · ¿transformación clara? · ¿alcanzable? · ¿tiene giro propio?

---

## 2. Etapa 2 — Scout & Library (corpus de winners del nicho)

Data layer que ingesta videos virales del nicho ("winners"), los filtra por métricas, embebe sus transcripts y permite búsqueda semántica. Reemplaza la intuición por modelado de winners validados. Python 3.12.

### 2.1 Esquema SQL (`library.db`, SQLite + extensión sqlite-vec)

```sql
CREATE TABLE IF NOT EXISTS videos (
  id TEXT PRIMARY KEY,          -- "{platform}:{native_id}", p.ej. "youtube:abc123"
  platform TEXT NOT NULL, url TEXT NOT NULL, title TEXT, channel_handle TEXT,
  published_at DATETIME, views INTEGER, likes INTEGER, comments INTEGER, shares INTEGER,
  duration_seconds INTEGER,
  transcript TEXT,              -- SRT limpiado o fallback a descripción/caption
  thumbnail_url TEXT, thumbnail_path TEXT,
  topic_tags TEXT,              -- JSON array serializado (json.dumps)
  ingested_at DATETIME DEFAULT CURRENT_TIMESTAMP,
  notebook_pushed INTEGER DEFAULT 0
);
CREATE INDEX idx_videos_platform_views ON videos(platform, views);
CREATE INDEX idx_videos_published ON videos(published_at);

CREATE TABLE IF NOT EXISTS thresholds (
  platform TEXT PRIMARY KEY, min_views INTEGER NOT NULL,
  min_comments INTEGER NOT NULL, max_age_days INTEGER NOT NULL DEFAULT 90);
INSERT OR IGNORE INTO thresholds VALUES
  ('youtube',    100000,  100, 365),   -- winners técnicos son evergreen
  ('tiktok',     500000,  500, 180),
  ('instagram',  300000,  200, 180);

CREATE VIRTUAL TABLE IF NOT EXISTS video_embeddings
  USING vec0(video_id TEXT PRIMARY KEY, embedding FLOAT[1024]);
```

- `FLOAT[1024]` DEBE coincidir con la dimensión del proveedor de embeddings. Cambiar de proveedor = recrear tabla + re-embeder TODO el corpus (los espacios vectoriales no son compatibles).
- Serialización del vector: `struct.pack(f"{len(vec)}f", *vec)` (float32) → BLOB.
- Conexión: `sqlite3.connect(path, detect_types=PARSE_DECLTYPES)` + `enable_load_extension(True)` + carga de sqlite-vec.

### 2.2 Ingesta (comando `scout`)

```bash
python -m scout.scout "TEMA" [--platform all|youtube|tiktok|instagram] [--limit 20] [--dry-run] [--no-render]
```

Orden estricto (filtrar ANTES de embeder para no gastar API en descartados):
1. Por plataforma: adapter Apify → `fetch(topic, limit)`.
2. `normalize(raw)` → registro normalizado; `passes_threshold(v)` contra la tabla `thresholds` (False si views/comments/fecha son None, views<min, comments<min, o edad>max_age_days).
3. Solo los aprobados: `embed_batch([v.transcript or v.title or ""])`.
4. Upsert de video + embedding; `topic_tags=[topic]`.
5. Render opcional de un playground HTML con la cosecha.

Errores por plataforma se logean y saltan (no abortan la corrida). `--dry-run` no toca Apify. **Regla dura: siempre `--dry-run` primero.**

**Actors de Apify y parámetros:**

| Plataforma | Actor | run_input |
|---|---|---|
| YouTube | `streamers/youtube-scraper` | `{searchQueries:[query], maxResults:limit, subtitlesLanguage:"any", downloadSubtitles:true}` |
| TikTok | `clockworks/tiktok-scraper` | `{hashtags:[query sin espacios], resultsPerPage:limit, shouldDownloadVideos:false, shouldDownloadCovers:false}` |
| Instagram | `apify/instagram-scraper` | `{search:query, searchType:"hashtag", resultsLimit:limit, resultsType:"posts"}` — solo items `type=="Video"` |

Patrón de invocación: `client.actor(ID).call(run_input=...)` → `client.dataset(run["defaultDatasetId"]).iterate_items()`.

**Mapeo de normalización por plataforma:**
- YouTube: views=`viewCount`, comments=`commentsCount`, published=`date`; duración parsea ISO-8601 (`PT8M30S`) o reloj (`HH:MM:SS`); transcript prioriza `subtitles[].srt` (regex quita índices y timestamps SRT), fallback a la descripción.
- TikTok: views=`playCount`, likes=`diggCount`, comments=`commentCount`, shares=`shareCount`, published=`createTime` (epoch→UTC), transcript=`text`.
- Instagram: views=`videoViewCount`, transcript=`caption`, id=`shortCode`.

### 2.3 Embeddings — Cloudflare Workers AI bge-m3 (gratuito)

Provider default: `@cf/baai/bge-m3` (free tier diario, **1024 dims**, multilingüe ES/EN — un query en español encuentra winners en inglés).

```
POST https://api.cloudflare.com/client/v4/accounts/{ACCOUNT_ID}/ai/run/@cf/baai/bge-m3
Headers: X-Auth-Email: {email} · X-Auth-Key: {global_key} · Content-Type: application/json
Body:    {"text": ["...", "..."]}
Resp:    payload["result"]["data"]  (validar payload["success"])
```

- **Auth SOLO con Global API Key legacy** (headers `X-Auth-Email` + `X-Auth-Key`) — los bearer tokens fallan con este endpoint. Env vars: `CLOUDFLARE_API_GLOBAL` (requerida), `CLOUDFLARE_ACCOUNT_ID`, `CLOUDFLARE_EMAIL`.
- Constantes: `MAX_CHARS=32000` (truncado base por string), `EMBED_DIMS=1024`, batch de **20** textos por request.
- **Manejo del límite de ~8k tokens:** bge-m3 responde HTTP 400 si el input excede ~8k tokens. Ante un 400 en un batch, reintentar **texto a texto** con truncado progresivo `16000 → 8000 → 4000` chars (degradando solo los que se pasan); si con 4000 sigue en 400, propagar el error. Otros códigos HTTP se propagan de inmediato.
- Fallback: `EMBEDDINGS_PROVIDER=openai` → `text-embedding-3-small` (1536d; requiere `OPENAI_API_KEY` y schema `FLOAT[1536]`).
- API interna: `embed_batch(texts) -> list[list[float]]`, `embed_text(text) -> list[float]`.

### 2.4 Búsqueda semántica (comando `library`)

```bash
python -m scout.library "TEMA" [--top 5] [--no-render]
```
1. `embed_text(query)` — **mismo proveedor que la ingesta** (crítico).
2. Query sqlite-vec:
```sql
SELECT video_id, distance FROM video_embeddings
WHERE embedding MATCH ?   -- vector serializado float32
ORDER BY distance LIMIT ?
```
3. Por cada hit, `SELECT *` del video + distancia. Render opcional a HTML.

**Análisis de patterns/briefing:** deliberadamente SIN CLI ni LLM externo — el agente (Claude) lee `library.db` directo, diseca hooks/estructura/CTAs de los top-K y produce un briefing markdown que alimenta el guion. **Regla dura: no saltar fases — scout → library → briefing → guion.**

---

## 3. Etapa 3 — Guion

### 3.1 Selección de formato (channel check, primero)
- Canal AI4Managers / talking-head del dueño → formato fijo **"Señales de IA"** (§3.4).
- Resto (UGC, demos, otros canales) → estructura REX (§3.2).

### 3.2 Sistema REX (short-form y long-form)
- 5 principios: claridad > creatividad · retención > estética · identificación masiva · contraste constante · emoción antes que lógica.
- **13 estructuras narrativas** por objetivo:
  - VENDER: Problema Invisible · Escalera de Autoridad · Historia que Vende · Recurso Gratuito Agresivo
  - COMENTARIOS: Acelera Comentarios · El Espejo · Desafío Contracorriente
  - RETENCIÓN: Caída Doble · Vacío de Información · Efecto Boomerang · Historia Inesperada
  - INSPIRAR: Viaje del Héroe · Paradoja Personal
- **Los 4 hooks** (los cuatro deben comunicar LO MISMO; si no, reescribir): verbal ≤5s (problema/promesa + curiosidad) · visual 1-2s (movimiento/acción/contraste) · textual ≤7 palabras (ERROR / SECRETO / NO HAGAS / NADIE TE DICE) · auditivo (sonido o silencio).
- Retención (Blackman STP): cada segmento con Setup (1-2 líneas) → Tensión (grueso) → Payoff; mini-payoff y pattern-interrupt cada 60-90s; re-engagement mayor cada 3 min en videos >3 min (regla MrBeast).

### 3.3 Reglas de copy (horneadas, bloqueantes)
- **Anti-cliché blacklist** — prohibido (EN): "In today's fast-paced world", "As technology continues to evolve", "Let's dive in", "But here's the thing", "Revolutionize", "Cutting-edge", "Unlock the potential", "Leverage", triadas snappy. (ES): "en el mundo de hoy", "en la era de", "imagina por un momento", "no es ningún secreto", "todos sabemos que", "game changer", "revoluciona", "desbloquea tu potencial". Test: si podría aparecer en cualquier post genérico de LinkedIn → reescribir.
- **Enemy naming** (obligatorio): una línea "El problema no es [lo obvio]. El problema es que [insight]."
- **Momento ajá aislado** (no enterrado en párrafo): "[Acción]. [Pausa]. [Consecuencia inesperada]."
- **CTA invisible:** ≤2 frases al final, sin lenguaje de urgencia.
- **4 textos de thumbnail** marcados durante la escritura: `[THUMB-BEFORE-TITLE]` (contexto antes), `[THUMB-AFTER-TITLE]` (contexto después), `[THUMB-BEFORE-BADGE]` (problema en 2-3 palabras), `[THUMB-AFTER-BADGE]` (beneficio en términos de negocio).

### 3.4 Formato "Señales de IA" (canal del dueño, 2-10 min, 5 bloques fijos en orden)
1. **LA SEÑAL** (10-15%): hook con dato concreto — fecha/hora/cifra/quote sin contexto. Prohibido "la IA está cambiando todo".
2. **EL CONTEXTO** (15-20%): por qué importa; fuentes en orden de prioridad (hilo con tracción → fuente primaria → consultora → post viral → noticia con números).
3. **LO QUE VI** (30-40%, nunca <30%): prueba operativa real — "lo opero, no lo leí". Evidencia: audio/video real > screenshot > métrica > momento específico.
4. **EL FRAMEWORK** (20-25%): 2-3 puntos accionables con verbo, nombre memorable, modular. Máximo 3.
5. **LA PREGUNTA** (5-10%): POV explícito + pregunta abierta + CTA fijo inmodificable del canal.

### 3.5 Reglas duras de contenido (bloquean el guion — checklist final)
- **POV obligatorio:** el guion declara a qué se opone el autor, qué apoya, en qué cree y qué experiencias lo validan. Prohibido cerrar en "no lo sé" o ambiguo.
- **Español LATAM neutro, sin voseo** (nada de vos/tenés/sos/querés/mirá/dale).
- En VO en español se escribe **"inteligencia artificial"**, nunca la sigla "IA" (el TTS la pronuncia mal).
- **NUNCA nombrar cantidad de agentes** ni tenure específico; agentes con lenguaje gender-neutral (repetir el nombre, evitar él/ella).
- **Nunca fabricar stats/montos/credenciales** — todo dato verificable u omitido.

### 3.6 Gates pre-TTS (utilidades deterministas, sin LLM)

```python
WORDS_PER_SECOND = 2.5
MODEL_DURATION_BUCKETS = {"sora-2":[4,8,12,16,20], "veo-3.1":[4,6,8,10,12], "kling-3.0":[5,10],
  "wan-2.1":[4,6,8,10,12,15], "creatify":[10,15,20,30,45,60], "generic":[5,10,15,20,30,45,60]}
```
- **`word_to_duration(word_count, model)`**: `raw = words/2.5`; `comfortable = raw*1.15`; elige el bucket más chico ≥ comfortable; si ninguno cabe → `should_split=True`.
- **`dialogue_confirmation(script, target_seconds)`**: parsea beats (1 línea = 1 beat), cuenta palabras excluyendo `[corchetes]` y `(paréntesis)`, calcula fit y devuelve un bloque numerado para confirmación humana ANTES de gastar créditos de TTS/video.
- **`cost_estimate(model, params, qty)`**: prioridad (1) mediana del log histórico JSONL (mismo modelo, duración ±2s), (2) tabla de rates por unidad (second/image/1k_chars), (3) unknown → pedir input. Siempre presenta "Proceed?" antes de generar.
- **`ImageQALoop(prompt, max_attempts=3)`**: post-generación de imágenes — checklist de 8 categorías de defectos (manos/dedos, número de extremidades, cara distorsionada, objetos derretidos, anatomía imposible, extremidades sueltas, artefactos de compresión, texto ilegible); `revise_prompt(defects)` anexa cláusulas correctivas; `best_attempt()`.

### 3.7 Subsistema VOC → guiones (voc-shorts, upstream opcional)

Pipeline de inteligencia de audiencia que convierte comentarios de YouTube en briefs y guiones, y los entrega al factory de Shorts. Estados forward-only: `niche_input → mined → brief_ready →[GATE humano 1]→ scripts_ready →[GATE humano 2]→ handed_off`. Los gates NUNCA los dispara el cron.

- **Mining:** actor `streamers/youtube-comments-scraper` (+ `streamers/youtube-scraper` para resolver nicho→URL top-viewed). Budget guard: `COST_PER_VIDEO_USD=0.10`, cap = `int(budget//0.10)`. Limpieza determinista de comentarios: dedup near-duplicate (lowercase, sin acentos, solo `[a-z0-9 ]`), drop emoji-only, drop <4 palabras, drop spam (URLs + frases "suscrib"/"mi canal"/"telegram"/"gana dinero"), drop boilerplate ("like si", "primero", "saludos desde").
- **Brief (LLM):** Claude CLI headless (`claude -p`, costo $0 con plan Max) primero; fallback `gpt-4o-mini` con `response_format=json_object`. Máx 200 comentarios al LLM. Output JSON: `{audience, phrases[], pains[{text,evidence}], desires[], questions[], microniches[{text,evidence}], hooks[{text,evidence}]}`. **Anti-alucinación en código:** cada `evidence` DEBE ser substring verbatim exacto de un comentario real (`evidence in comment` contra el set completo); items sin evidencia válida se dropean. Validación con JSON Schema draft-07 (`additionalProperties:false`) antes de retornar.
- **Guiones:** `generate_scripts(brief, microniche, n, target_duration=48, lang)` — el prompt hornea las reglas virales (hook 1-2s, enemy naming, momento ajá, CTA invisible, anti-cliché) + LATAM neutro. `target_words = duration * 2.4`. Post-validaciones en código:
  - `hook_card_text` truncado a ≤6 palabras.
  - **Gate anti-voseo** (solo ES): regex accent-aware sobre `vos|tenés|llegás|sos|querés|hacés|podés|decís|venís|mirá|escuchá|andá|probá|dale`; si detecta, regenera hasta 2 veces; si persiste, marca `voseo_flag=True` (nunca pasa en silencio).
  - **`_validate_title` (reglas JuntaYT horneadas):** no vacío · **≤60 chars** · sin clickbait (lista prohibida: "you won't believe", "shocking", "no creerás", "el secreto que"…) · no copia verbatim del hook · debe llevar la keyword del microniche. Si falla → título vacío y el downstream deriva fallback.
- **Handoff:** escribe `queue/<slug>/spec.json` (+`script.txt` = VO literal) con `status="scene_pending"` y `source:"voc-shorts"`. Slug `{YYYY-MM-DD}-{slugify(angle)}` con desambiguación -2/-3. El factory respeta guion+hook+título upstream y solo completa descripción/tags.

---

## 4. Etapa 4 — Voice-Over (ElevenLabs)

### 4.1 API
```
POST https://api.elevenlabs.io/v1/text-to-speech/{voice_id}
Headers: xi-api-key: $ELEVENLABS_API_KEY · Content-Type: application/json
Body: {"text":"<respelled>","model_id":"eleven_multilingual_v2",
       "voice_settings":{"stability":0.5,"similarity_boost":0.9,"style":0.2,
                         "use_speaker_boost":true,"speed":<persona.default_speed>}}
Timeout: 300s
```

### 4.2 Receta LOCKEADA (prohibido re-tunear por video)
`stability=0.5 · similarity_boost=0.9 · style=0.2 · use_speaker_boost=true`. **El único knob por video es `speed`** (viene del perfil de persona; ejemplo calibrado: 1.12 — a 1.15 la voz se desestabiliza en staccato/gibberish). Prohibiciones: nunca `ffmpeg atempo` sobre la VO; `style` nunca >0.3; `stability` nunca <0.45.

### 4.3 Respell de pronunciación (solo audio, nunca captions)
Diccionario por persona, sustitución case-insensitive con word-boundary, aplicado al texto que va a ElevenLabs. Ejemplos: `"AI for Managers"→"Ei Ai for Mánashers"` (marca), `"suscríbete"→"Suskríbete"`, `"agentsquad"→"Agent Squad"` (dos palabras evita code-switch a inglés). Los captions salen del guion original verbatim.

### 4.4 Single-take vs long
- **Guiones ≤160 palabras:** UN take (`generate()`), factura los chars reales. (Regla de eficiencia de créditos: multi-take en guiones cortos multiplica el costo ×N sin ganancia.)
- **Guiones >120 palabras (SOP con QA):** `generate_long()`:
  1. `split_chunks(text, max_words=40)` — parte por frases (`re.split(r"(?<=[.!?])\s+")`) y agrupa frases completas en tramos ≤40 palabras.
  2. Por tramo i: `previous_text = todo lo anterior[-600:]`, `next_text = todo lo posterior[:600]` (prosodia continua entre tramos).
  3. Reintenta cada tramo hasta 3 veces; corta cuando el validador (voqa) da OK.
  4. Concat sin gaps: cada MP3 → WAV mono 44100 Hz → `ffmpeg -f concat` → re-encode `libmp3lame -b:a 128k`.

### 4.5 QA word-level (voqa) — gate obligatorio en CADA VO
Motor: `faster_whisper.WhisperModel("small", compute_type="int8")` — SIEMPRE int8 (el Whisper de OpenAI hace OOM conviviendo con otros procesos); `language="es"`, `word_timestamps=True`, `condition_on_previous_text=False`. **Solo válido para ES.**

Normalización de palabras: lowercase, quita no-alfanuméricos preservando `áéíóúñü`, mapea dígitos a palabra ("2"→"dos"), elimina tildes (NFD).

**Checks con umbrales exactos:**
1. **Alineación:** `SequenceMatcher(target, heard).ratio() ≥ 0.97` contra el guion. (Un umbral laxo dejó pasar gibberish dos veces — 0.97 es el mínimo.)
2. **Gibberish:** falla si hay racha de ≥2 palabras consecutivas con `probability < 0.5`.
3. **Palabras estiradas:** falla si `(end-start) > 1.2 s` (excluye la última palabra por el fade).
4. **Stutter absorbido por el LM:** palabra-función (de/a/y/en/el/la/que/te/lo/tu/un/es/no/se/mi) con duración >0.6s → sospecha; se desambigua acústicamente con `voiced_fraction(mp3, start, end)` (RMS por frames de 50 ms a 16 kHz): <0.6 = pausa natural (descarta), ≥0.6 = stutter CONFIRMADO.
5. **Pacing:** ventana deslizante de 8 palabras; `ppm = 8/dur*60`; falla si alguna ventana < **100 ppm**.

Resultado: `{ok, problems[], align, words, duration_s, ppm, worst_window_ppm, worst_window_at}` — exit 0/1 para uso en scripts.

Complementos del SOP: spellcheck ES del guion con hunspell (diccionario LATAM `es_MX` + whitelist de marca) — avisa, no bloquea; fade-in de audio hasta la primera palabra (los renders de avatar traen artefactos pre-voz).

---

## 5. Etapa 5 — Avatar & B-rolls (router de identidad)

### 5.1 Router de plataformas (decisión ANTES de escribir el prompt)

| Necesidad en pantalla | Plataforma | Razón |
|---|---|---|
| Rostro real de una persona del proyecto (talking-head, hook a cámara) | **HeyGen** | ÚNICO que preserva la identidad del avatar entrenado. El prompt solo dirige movimiento/expresión. |
| B-roll SIN persona, motion sutil sobre imagen | **MuAPI** (Hunyuan ~$0.15, Seedance) | Barato, no necesita identidad. |
| Escena no-persona CON figura humana | **MuAPI gemini-omni** | 9:16 nativo, manos limpias. |
| Cámara cinemática dramática / VFX | **Higgsfield** | Presets de cámara + efectos (+ Soul ID para persona sintética propia). |
| B-rolls premium alternativos | Sora 2 / Seedance | Según catálogo del proyecto. |

**Reglas duras:**
- Rostro de persona = HeyGen SIEMPRE. MuAPI/Higgsfield i2v reinterpretan la cara incluso sembrando desde un frame real ("parecida pero no es ella"). Para arreglar un clip defectuoso de la persona: editar el footage o re-render en HeyGen — nunca regenerar con modelos generativos.
- **No doble persona:** si el clip principal es la persona, todos los b-rolls son no-persona.
- **Cost gate MuAPI:** `POST /app/calculate_dynamic_cost` SIEMPRE antes de cada submit; abortar si el costo excede el máximo configurado (default $0.50). Modelo default barato (hunyuan) para motion sutil.
- **1-by-1, nunca batch:** submit → review → adopt/redirect → next.
- Renderizar la **pose final**, no la transición (coreografías de sentarse/caminar son poco confiables): si la quieres sentada, generala ya sentada. Menos movimiento descrito = menos warping.

### 5.2 Flujo HeyGen (gate humano)
El sistema genera un `heygen-brief.md` (escena, plate, look, motion prompt — el brief NO describe la cara; la identidad es la selección del avatar). El humano renderiza en HeyGen en **modo AUDIO** alimentado con el `vo.mp3` aprobado (lip-sync nativo), usando Avatar V + motion prompt (p.ej. open-palm), y deposita `avatar.mp4` en la carpeta del video.

### 5.3 avatar_qa (gate post-render, ANTES de componer)
Tras CADA render de HeyGen: generar un grid de frames de cara+manos y revisarlo (deformidades, dedos, dientes, warping). Nunca componer sobre material defectuoso — el costo de re-render es mucho menor que re-componer.

---

## 6. Etapa 6 — Composición & Captions (el corazón del build)

### 6.1 Contrato de datos `spec.json` (SSOT del video)

| Campo | Tipo | Oblig. | Default | Descripción |
|---|---|---|---|---|
| `slug` | str | sí | — | id del short = nombre de carpeta |
| `persona` | str | sí | — | clave de `personas/<persona>.json` |
| `voice_id` | str | sí | del perfil | voz ElevenLabs |
| `vo_speed` | float | sí | del perfil (p.ej. 1.10) | único knob de VO |
| `scene_element` | str\|null | sí | null | Scene Element confirmado (gate humano 1) |
| `angle` | str\|null | sí | null | ángulo del guion |
| `hook_card_text` | str\|null | sí | null | texto del hook card (≤4-6 palabras) |
| `script` | str\|null | sí | null | guion de VO (captions = este texto verbatim) |
| `beats` | list | sí | `[]` | b-rolls temporizados (abajo) |
| `title` / `description` / `pin_comment` / `tags` | — | sí | null/[] | metadata YouTube |
| `target_duration` | int | sí | 49 | duración objetivo del body (s) |
| `status` | str | sí | `"scene_pending"` | estado de la máquina (§0.2) |
| `video_id` | str\|null | sí | null | id YouTube tras publicar — **gate de idempotencia** |
| `channel` | str | sí | `"agentsquad"` | clave del mapa de canales (§8.1) |
| `thumbnail` | str\|null | no | null | ruta al thumbnail (abs o relativa a la carpeta) |
| `playlist_id` | str\|null | no | null | playlist destino |
| `coldopen_dur` / `hook_card_dur` / `hook_by` | — | no | perfil / =coldopen / 300 | walk-in + hook card (y en px; ≥600 = lower-center) |
| `lang` | str | no | `"es"` | idioma (activa spellcheck ES) |
| `avatar_zoom` / `avatar_crop_x` | float/int | no | — | reframe a medium shot si zoom>1.0 |
| `card` / `card_y` / `card_border` / `card_border_thickness` | — | no | — | geometría/borde del b-roll card |
| `auto_card` | bool | no | true | detección de cabeza (insightface) para anclar el card |
| `broll_fullscreen` | bool | no | — | b-rolls full-screen en vez de cards |
| `caption_margin_v` | int | no | 600 (300 si fullscreen) | MarginV del ASS |
| `coldopen_xfade` / `hook_fade` / `hook_keyword` | — | no | 0 / 0.3 / — | transiciones + keyword resaltada |
| `auto_brolls` / `broll_filter` / `broll_threshold` | — | no | true / — / 0.30 | resolver semántico de b-rolls |
| `source` | str | no | — | `"voc-shorts"` = respetar guion/título upstream |

**Sub-esquema `beats[]`** (tiempos relativos al body):
```json
{"start":6.0,"end":9.0,"clip":"nova-4k.mp4","crop":"2784:1566:528:438",
 "caption":"the orchestrator","keyword":"orchestrator","ss":1.0,"score":0.42}
```
`crop` = `W:H:X:Y` en dimensiones reales del clip · `ss` = seek dentro del clip (default 1.0) · `score` lo escribe el resolver semántico (embeddings de la línea de VO vs banco de b-rolls; threshold default 0.30, cross-lingual).

### 6.2 Pipeline de transcripción → ASS (común a ambos sistemas)
1. `transcribe_words(vo.mp3)` — Whisper con word timestamps; **cachear por firma del audio** (hash) para no re-transcribir.
2. `align_to_script(words, script)` — conserva los **timings** de Whisper pero sustituye el **texto** por el guion verbatim vía difflib (Whisper dropea negaciones, tildes y acrónimos — inaceptable en pantalla).
3. Correcciones de marca solo-pantalla: nombre del producto siempre con mayúscula correcta; diccionario de errores frecuentes de Whisper (p.ej. "una gente"→"un agente").
4. Chunking por frases y render a ASS según el sistema elegido.

### 6.3 Sistema de captions C1 (pipeline automatizado por cron)
Header ASS: `PlayResX 1080 · PlayResY 1920 · WrapStyle 0`. Un estilo:
```
Style: Big,Inter Black,72,&H00141414,&H00FFFFFF,&H00FFFFFF,-1,3,12,0,2,110,110,{margin_v}
```
- Inter Black 72 px, texto casi-negro `&H00141414`, **caja blanca** (`BorderStyle=3, Outline=12`), Bold, Alignment=2 (bottom-center), márgenes laterales 110.
- Keyword resaltada en rojo de marca `&H003E46C8` (= #c8463e en BGR).
- Chunking: `MAX_WORDS=8`, `MAX_CHARS=46`; rompe en `.!?` (si ≥2 palabras) o `,;:` (si ≥4); al forzar corte, nunca termina línea en conector.
- `margin_v` 600 default / 300 con b-roll fullscreen. `skip_before` (no pintar captions bajo el hook card) y `suppress_windows` (suprimir frases que solapan un b-roll fullscreen sobre la cara).

### 6.4 Sistema dual-style (reels editoriales AI4M)
**Constantes canónicas (SSOT — cambiarlas exige actualizar la SPEC):**
```python
CAP_Y = 1075            # centro vertical del bloque de captions (bajo la cara, en safe-zone)
CHARF = 0.362           # ancho medio por glifo / fontsize (calibrado con PIL)
SERIF_TARGET_W = 760    # ancho objetivo de la línea más larga (~70% del frame; xmax<936)
SERIF_MAXFS = 236       # techo de fontsize serif
SERIF_RATIO = 0.72      # leading / fontsize
HOOK_END = 6.2          # los primeros 6.2s van todos en serif grande
STYLES = {"Box": (94, 20, 132), "Serif": (200, 10, 144)}   # (fontsize, max_chars, line_height)
```
Header ASS: `PlayResX 1080 · PlayResY 1920 · ScaledBorderAndShadow: yes · WrapStyle: 2`. Dos estilos:
```
Style: Box,DejaVu Sans,94,&H00141414,&H00141414,&H00FFFFFF,&H00FFFFFF,1,0,0,0,100,100,1,0,3,9,0,5,60,60,0,1
Style: Serif,PP Editorial New,200,&H00FFFFFF,&H00FFFFFF,&H00000000,&H80000000,0,0,0,0,100,100,0,0,1,0,2,5,60,60,0,1
```
- **Box** (preguntas/setup): sans negra sobre caja blanca (`BorderStyle=3 Outline=9`), Alignment=5, máx 20 chars/línea.
- **Serif** (afirmaciones): serif editorial blanca con sombra suave (`BorderStyle=1 Shadow=2`, sombra `&H80000000`), fontsize **dinámico**: `fs = min(SERIF_MAXFS, int(SERIF_TARGET_W / (longest_line_chars * CHARF)))`, `line_height = round(SERIF_RATIO * fs)`.
- Asignación de estilo por frase: Box si (índice par o termina en `?`), si no Serif; toda frase que empiece antes de `HOOK_END` → Serif.
- Posicionamiento absoluto por línea: `\pos(540, y)` con `y = CAP_Y + (k-(N-1)/2)*line_height`; el serif inyecta `\fs{fs}` inline.
- Invariante verificado con assert: el join de las líneas == el texto original (nunca se dropea una palabra).
- Complementos visuales: scrim (PNG de gradiente alfa generado por código, de y=820 a y=1500, alfa máx 185) entre el video y los captions; push-in sutil del avatar: `zoompan=z='min(1.0+0.00006*on,1.12)':d=1:s=1080x1920:fps=30`.

### 6.5 Recetas FFmpeg (compositor C1 completo)
Flags comunes de todo encode: `-c:v libx264 -preset veryfast -pix_fmt yuv420p -movflags +faststart`; CRF 18 (reframe/intro) o 19 (body/concat/final).

1. **Normalizar avatar** (HeyGen a veces entrega 4K): `scale=1080:1920:flags=lanczos,setsar=1`.
2. **Reframe opcional** a medium shot: `scale={1080*z}:{1920*z}:flags=lanczos,crop=1080:1920:{cx}:0,setsar=1`.
3. **Fix de audio en una pasada** (después solo `-c:a copy`):
   `-af "afade=t=in:st={first_word-0.3}:d=0.25,afade=t=out:st={dur-0.35}:d=0.35,aresample=44100,alimiter=limit=0.84" -c:v copy -c:a aac -b:a 192k` — silencia artefactos pre-voz, fade hacia el outro, limita picos ~-1.5 dB.
4. **B-roll cards + captions en UN solo filter_complex → UN solo encode:**
   - Fill de cada card: `crop={crop},scale={cw}:{ch}:force_original_aspect_ratio=increase,crop={cw}:{ch},fps=30,setsar=1` (+ `drawbox` si lleva borde).
   - Esquinas redondeadas: máscara PNG generada por código (PIL) + `alphamerge`; el loop de la máscara debe ser **finito** (un `-loop 1` infinito cuelga el encode).
   - Cada beat: `fade=t=in/out:alpha=1:d=0.2` → `overlay={x}:{y}:enable='between(t,{start},{end})'` encadenado.
   - Al final de la cadena: `subtitles={captions.ass}:fontsdir={dir_de_fuentes}` y `-t {duración_del_VO}`.
   - Geometría default del card: `x:110, y:120, w:860, h:484, radius:28`; con `auto_card`, detectar el tope de la cabeza (insightface buffalo_l en CPU) y anclar el card encima; forzar dimensiones pares (`h -= h%2` — libx264/yuv420p las exige).
   - Modo `broll_fullscreen`: el b-roll cubre todo el frame (`format=yuva420p` + fade alpha + `overlay=0:0:enable=between(...)`), captions encima; no cargar insightface en este modo (evita OOM en videos largos).
5. **Ensamblaje final** cold-open + body + outro:
   - Usar **concat FILTER, no el demuxer** (el demuxer mangla duraciones con streams heterogéneos), normalizando cada pieza: video `fps=30,scale=1080:1920,setsar=1,format=yuv420p`, audio `aresample=44100,aformat=sample_fmts=fltp:channel_layouts=stereo`.
   - Cold-open (walk-in): pre-escalado a 1080×1920 con audio silenciado (`anullsrc`), trim a `coldopen_dur`; transición opcional `xfade=transition=fade` + `acrossfade`.
   - **Hook card quemado al frente del ensamblado** (`overlay=0:0:enable='between(t,0,{hook_card_dur})'` + fade alpha opcional): tarjeta PIL charcoal RGB(18,16,12), tipografía bold 92 px, keyword en ámbar RGB(235,165,60), posición upper-third (y=300).
   - Outro de marca (end-card 9:16 crema #f8f1dd, headline 120 px, CTA "SUBSCRIBE" en pill rojo RGB(206,43,43), ~2.5 s): `-loop 1 -t {dur} -i card.png -f lavfi -i anullsrc -vf "fps=30,format=yuv420p" -shortest`.
   - Presupuesto de generaciones de audio: máximo 2 re-encodes en toda la cadena.
6. **Audio del compositor dual-style:** `loudnorm=I=-14:TP=-1.5:LRA=11` (un solo clip). En multi-clip, normalización POR SEGMENTO con `volume=XdB` medido (loudnorm 2-pass por pieza), nunca un loudnorm global al final.

### 6.6 Reglas duras de composición
- **Safe-zones Shorts:** todo texto dentro de x<936, y<1632; los captions nunca pisan la cara.
- Wrap balanceado sin truncar palabras (verificación programática: 0 drops).
- Text-behind (palabras detrás de la cabeza) solo con palabras inferibles por los extremos visibles.
- **Lip-sync compuesto en UN comando FFmpeg** con `split` + `enable` — prohibido concat de segmentos y prohibido `-ss` antes de `-i` (desincroniza).
- `+faststart` en todo MP4 final.
- Validación de captions con **frames reales renderizados por libass** (nunca una maqueta HTML/Playwright) y screenshots del video FINAL antes de declarar "listo".

### 6.7 QA del video final (gate programático)
- Duración dentro de `[min, max]` esperados.
- Silencio inicial correcto: `silencedetect=n=-40dB:d=0.1` — el primer `silence_end` debe ser ≥ `first_word - 0.6`.
- Hook card presente en frame 0 (muestreo de píxeles oscuros en x=540, y∈[300,1700]).
- Sin letterbox en b-roll cards (bandas negras pareadas top+bottom o left+right >0.9 → falla).
- Identidad (opcional): embedding facial ArcFace del video vs referencia de la persona, cosine ≥ 0.45.
- Artefacto de revisión: contact-sheet de frames.

### 6.8 Entrega al usuario — Cloudflare Stream
La entrega de previews/masters al dueño se hace por Cloudflare Stream (evita caches intermedios). **Auth SOLO Global API Key legacy** (bearer no funciona): headers `X-Auth-Email` + `X-Auth-Key`; env vars `CLOUDFLARE_API_GLOBAL`, `CLOUDFLARE_EMAIL`, `CLOUDFLARE_ACCOUNT_ID`.

```
POST   /client/v4/accounts/{account}/stream            # multipart file+meta (directa hasta ~200MB)
GET    /client/v4/accounts/{account}/stream/{uid}      # poll readyToStream (timeout 300s, cada 5s)
POST   /client/v4/accounts/{account}/stream/{uid}/downloads   # habilita MP4; GET → poll default.status=="ready"
DELETE /client/v4/accounts/{account}/stream/{uid}      # 204
```
`upload_video(...)` → `{uid, ready, watch_url, iframe_url, hls_url, thumbnail_url, download_url}`.

---

## 7. Etapa 7 — Packaging (thumbnail + título)

### 7.1 Thumbnails — API Pikzels
```
Base: https://api.pikzels.com/v2 · Header: X-Api-Key
POST /v2/thumbnail/text      {model, format, prompt, persona?, support_image_base64?}   (120s)
POST /v2/thumbnail/image     {model, format, image_base64, prompt, image_weight, persona?} (120s)
POST /v2/thumbnail/score     {image_base64, title?}                                     (30s)
POST /v2/thumbnail/faceswap  {format, image_base64, face_image_base64}                  (120s)
```
- Imágenes: redimensionar a máx 1280 px, codificar `data:{mime};base64,...`. La respuesta trae `output` (URL) → descargar PNG.
- Persona entrenada del presentador (ID de persona en Pikzels); `--reference imagen --weight low` = el prompt domina sobre el estilo de la referencia. Modelos `pkz_2|pkz_3|pkz_4` (default el más nuevo). Formatos `16:9|9:16|1:1`.
- Estructura del prompt (plantilla before/after con los 4 textos THUMB-*):
```
Same visual LAYOUT as reference but with DIFFERENT content.
[Presenter] center, large face 60%, [expression].
Top-left: "[THUMB-BEFORE-TITLE]" white text.
Small grayscale photo bottom-left of [before], dark badge "[THUMB-BEFORE-BADGE]".
Top-right: "[THUMB-AFTER-TITLE]" white text.
Small color [after] bottom-right, green badge "[THUMB-AFTER-BADGE]".
Green arrow left to right. Dark cinematic background.
NO dollars, NO ages — use ONLY the texts defined above.
```

**Gate de score (obligatorio):** `score` devuelve score principal + subscores `clarity, curiosity, emotion, idea, virality` + sugerencias. `main_score ≥ 7` pasa; `< 7` se itera (prompt/textos) **o** se registra un override consciente del panel de packaging con la razón. Nunca se entrega thumbnail sin score ni veredicto. Tras cada generación, inspección anatómica (ImageQALoop §3.6, máx 3 intentos).

### 7.2 Título — panel "JuntaYT" (automático)
Todo título pasa por un panel de criterios de packaging de YouTube (perspectivas: packaging/CCN, algoritmo/benchmarks CTR-AVD, AI-search/question-answer, fandom/relatabilidad). **Protocolo: la invocación es automática y se ejecuta la mejor recomendación sin aprobación manual.**

Reglas del título (también horneadas como gate determinista, §3.7):
- Formato pregunta-respuesta real (era Ask-YouTube/AI-search) + keyword del nicho.
- **≤60 caracteres.**
- Complementa el hook, no lo duplica verbatim.
- Sin clickbait ni stats inventadas.

### 7.3 Reglas duras de packaging
- El thumbnail NUNCA exhibe duda — vende fuerza; el claim honesto/matizado va en el guion.
- Short de persona: frame con CONTACTO VISUAL + hook card sobre el pecho.
- Nunca un grid contable de agentes — usar cluster difuminado (regla no-count).
- Tildes/acentos correctos en overlays (verificación visual).

---

## 8. Etapa 8 — Publicación (YouTube Data API, multi-canal, idempotente)

### 8.1 Mapa de canales
```python
CHANNELS = {
    "agentsquad": "GOOGLE_REFRESH_TOKEN_AGENTSSQUAD",  # canal EN (ojo: doble S en el nombre de la var)
    "ai4m":       "GOOGLE_REFRESH_TOKEN_AI4MANAGERS",  # canal del dueño
    "thalx":      "GOOGLE_REFRESH_TOKEN_THALX",        # canal LATAM (ES)
}
DEFAULT_CHANNEL = "agentsquad"
```
OAuth común a todos: `GOOGLE_CLIENT_ID` + `GOOGLE_CLIENT_SECRET`; refresh token por canal. Credenciales: `Credentials(token=None, refresh_token=env, token_uri="https://oauth2.googleapis.com/token", scopes=["youtube","youtube.force-ssl"])` → cliente `youtube v3`.

⚠️ Trampa conocida: specs antiguos usaban la clave `"latam"` con un campo suelto `token_env` — el canal LATAM correcto en publicación es la clave **`thalx`**. (En métricas, §9.1, la clave sí es `latam` con el mismo token.) Al re-implementar, unificar la clave en ambos módulos.

**Ruteo editorial:** Shorts en español → canal LATAM; el canal EN publica solo inglés (evita canibalización).

### 8.2 Flujo `upload(short_dir)` — orden exacto
1. Cargar spec; **exigir `status == "composed"`**.
2. **Gate de idempotencia: si `video_id` ya existe en el spec, retornar sin llamar a YouTube.**
3. `videos().insert(part="snippet,status")` con upload **resumable** (chunks de 8 MB, loop `next_chunk()`). Snippet: `title, description, tags, categoryId="28"` (Science & Technology). Status: `privacyStatus="public"`, `selfDeclaredMadeForKids=False`.
   - ⚠️ Para **updates** posteriores (`videos().update`): enviar el snippet COMPLETO (title+description+tags+categoryId+defaultLanguage) — la API borra los campos omitidos.
4. **Persistir `video_id` en el spec ANTES de thumbnail/playlist/comment** — un fallo posterior nunca causa re-upload.
5. Thumbnail (si hay): `thumbnails().set(videoId, media_body)` (mimetype por extensión).
6. Playlist (si hay): `playlistItems().insert(...)`.
7. Pin comment (si hay): `commentThreads().insert(...)` — el "fijar" real es manual en Studio (la API no lo soporta). Regla de copy del comment: frase puente antes del link, sin `:` ni `—`, tono distante.
8. Transición a `published`; retornar `video_id`.

**Retry:** wrapper `_with_retry(fn, tries=4, base_delay=2.0)` sobre thumbnail y playlist — reintenta `HttpError` con status 403/429/500/503, backoff exponencial `base_delay * 2^attempt`.

**Cadencia:** watcher de publicación corre en slots (p.ej. 09:00/15:00/21:00) con `daily_cap` (p.ej. 3) verificado contra un historial `state/history.json` (`{slug, video_id, date}`).

---

## 9. Etapa 9 — Métricas (loop mensual de aprendizaje)

### 9.1 Registro de canales (módulo de métricas)
```python
CHANNELS = {
    "agentsquad": ("GOOGLE_REFRESH_TOKEN_AGENTSSQUAD", "AgentSquad EN"),
    "ai4m":       ("GOOGLE_REFRESH_TOKEN_AI4MANAGERS", "AI4Managers"),
    "latam":      ("GOOGLE_REFRESH_TOKEN_THALX",       "AgentSquad LATAM"),
}
```
Scopes requeridos: `yt-analytics.readonly` + `youtube.force-ssl` (un refresh token sin el scope de Analytics falla con `invalid_scope`). Clientes: `youtube v3` (Data) + `youtubeAnalytics v2`.

### 9.2 Reporte mensual (cron día 1) — queries exactas
Todas con `ids="channel==MINE"`, ventana default 30 días:
1. Data API: `channels().list(part="snippet,statistics", mine=True)` → título, subs, views totales.
2. Por video: `metrics="views,estimatedMinutesWatched,averageViewDuration,averageViewPercentage"`, `dimensions="video"`, `sort="-views"`, `maxResults=50`; títulos con `videos().list(part="snippet")` en lotes de 50.
3. Subs de la ventana: `metrics="subscribersGained,subscribersLost"`.
4. Tráfico: `metrics="views"`, `dimensions="insightTrafficSourceType"`, `sort="-views"`, `maxResults=10` (etiquetas traducidas: SHORTS→Feed Shorts, YT_SEARCH→Búsqueda, BROWSE_FEATURES→Browse/Home, SUGGESTED_VIDEO→Sugeridos).

**Flags de retención** (sobre `averageViewPercentage`): 🟢 ≥50% · 🟡 30-49% · 🔴 <30% (>100% = rewatch/bucles en Shorts). Baseline: mediana de retención del contenido real (excluyendo lofi/streams por título), con piso de views adaptativo 20→10→3.

Reporte a Telegram: subs (total, delta mes a mes, ganados/perdidos), views vs mes previo, retención mediana, top-5 fuentes de tráfico, top-4 mejores y bottom-3 peores videos, 1 acción recomendada. Snapshot mensual en JSON + markdown para comparación mes-a-mes.

**Regla dura: CTR e impresiones NO los expone la Analytics API** (son Studio-only). PROHIBIDO fabricar el número; el reporte declara "SIN DATO (Studio-only)".

### 9.3 Scorecard "source-cited" (motor determinista, sin LLM)
Un adaptador convierte el export por-video en un scorecard donde cada métrica se compara contra bandas oficiales de la documentación de YouTube:
```python
CTR_BAND_LOW  = 0.02   # piso banda CTR oficial (la mitad de los canales: 2-10%)
CTR_BAND_HIGH = 0.10
APV_GOOD      = 0.50   # piso "sano" de average percentage viewed
MIDROLL_MIN_SECONDS = 480   # runtime mínimo para mid-rolls
```
- Export por video: `{video_id, title, published, format("short" si ≤180s), length_seconds, impressions(0 si API), views, average_view_duration_seconds, watch_time_hours, subscribers_gained, estimated_revenue_usd}`.
- Honestidad del dato: si no hay CTR, se remueve el flag "CTR bajo banda" (sería falso negativo) y se anota la ausencia.
- Roadmap generado: (1) arreglar hooks 0-3s si APV<30% con tracción; (2) long-form de 6-8 min → alargar a ≥8 min para mid-roll; (3) piezas con APV≥50% y tracción → escalar con CTA/end-screens; (4) cargar CSV de Studio para el gap de packaging.

### 9.4 Seguimiento de discovery de livestreams
Para un stream 24/7: chequear cada N días `views/likes/concurrentViewers` (Data API) + `views, estimatedMinutesWatched, averageViewDuration` e `impressions, impressionClickThroughRate` (Analytics; devuelve 400 hasta que hay datos → try/except → None). Estado en baseline JSON + history JSONL. Veredictos: `trigger` (ya hay impresiones → retomar A/B de thumbnail), `grew` (Δviews sobre umbral), estancado.

---

## 10. Vía de render paralela — Remotion en AWS Lambda (stack Thalx)

Vía programática React para piezas animadas (motion graphics, cards, oficina 3D). **FFmpeg sigue siendo el backend productivo de ensamblaje; Remotion es complementario.**

### 10.1 Reglas de arquitectura
- **Versión pineada:** `remotion@4.0.434` en todos los paquetes (`@remotion/lambda`, `bundler`, `renderer`); la versión del cliente DEBE coincidir con la función Lambda desplegada.
- **Dos sites (bundles S3) separados:**
  - `thalx-remotion` (entry `src/index.ts` → `Root.tsx`): todas las composiciones 2D.
  - `thalx-pixeloffice` (entry `src/index-pixeloffice.ts` → `RootPixelOffice.tsx`): SOLO la composición 3D con react-three-fiber.
  - **Regla permanente:** R3F/Three NUNCA se importa en `Root.tsx` — un import estático de fiber incompatible crashea el bundle ENTERO en la inicialización (todas las composiciones, no solo la 3D). Cuarentena en site propio.
- **El nombre de la función Lambda miente:** dice `mem2048mb-…-240sec` pero los params reales (leídos de AWS) son RAM 3008 MB / disk 2048 MB. Nunca confiar en el nombre: leer la config real.

### 10.2 IaC (`deploy.mjs`) — 3 subcomandos
- `check` (read-only): compara la versión local de `@remotion/lambda` contra la función desplegada; reporta RAM/timeout/disk REALES; exit≠0 si hay mismatch. Correr tras cada bump de versión.
- `sites`: `deploySite()` de ambos bundles.
- `function [--yes]`: dry-run por defecto; con `--yes`, `deployFunction({region, timeoutInSeconds, memorySizeInMb, diskSizeInMb, createCloudWatchLogGroup:true})`.

### 10.3 Render E2E en Lambda
1. Input JSON: params de nivel render (`width, height, outputPath, compositionId`) + el resto como `inputProps`.
2. `renderMediaOnLambda({codec:"h264", imageFormat:"jpeg", framesPerLambda:20, maxRetries:1, privacy:"private", overwrite:true, forceWidth/forceHeight, compositionDurationInFrames: Math.ceil(duration*30)})` con retry propio (3 intentos, backoff 5s×intento, detecta rate-limit).
3. Poll `getRenderProgress` cada 1s; `fatalErrorEncountered` → abort.
4. `presignUrl({expiresInSeconds:120})` → descarga.
5. Output JSON `{video_path, duration, width, height, size_bytes}`.

⚠️ fps=30 hardcodeado en los invocadores; si una composición usa otro fps (hay una a 25), calcular frames con SU fps.

### 10.4 Composiciones registradas (site principal)
`Reel`/`TikTok` (1080×1920) y `YouTube` (1920×1080) — escenas data-driven (`scenes[]`); `FullReel`/`FullReelLandscape`; `MotionGraphic` (1080×1920, props `text/style/mood/accentColor/duration`) — se inyecta como clip en escenas key_moment del pipeline FFmpeg; `ContentCards` (bullets); `ScreencastScene`; `ImageSlideshow`; `Video001Overlay` (KeyTerm); `VideoTransition`/`VideoTransition2` (xfade de 2-3 clips). Site 3D: `PixelOffice` (agents[], scene, camera orbit360/dolly/tour, format landscape/portrait/square).

### 10.5 Render SSR local (alternativa sin Lambda)
`@remotion/bundler` (`bundle()`) + `@remotion/renderer` (`selectComposition` + `renderMedia`) con Chromium headless: `codec:"h264", concurrency:1, timeoutInMilliseconds:600000, delayRenderTimeoutInMilliseconds:120000, chromiumOptions.enableMultiProcessOnLinux:true`. Hard timeout de proceso 25 min.

### 10.6 Backbone híbrido de ensamblaje (backend productivo)
Worker FFmpeg que orquesta: (1) smart-crop del footage, (1b) clips MotionGraphic vía Remotion SSR, (2) subtítulos ASS karaoke word-by-word, (3) concat con xfade, (4) mix de audio, (5) hook overlay en escena 1, (6) composición final video+audio+subs, (7) upload a storage. Orquestación de jobs con cola async (`ingest → transcribe → analyze → scripts → tts → footage → render → upload → publish`).

---

## 11. Inventario de variables de entorno (solo nombres)

| Área | Variables |
|---|---|
| ElevenLabs | `ELEVENLABS_API_KEY` |
| Apify (scout, VOC) | `APIFY_TOKEN` (o `APIFY_API_KEY`), `APIFY_BUDGET_USD` |
| Cloudflare (embeddings + Stream) | `CLOUDFLARE_API_GLOBAL`, `CLOUDFLARE_EMAIL`, `CLOUDFLARE_ACCOUNT_ID`, `EMBEDDINGS_PROVIDER` |
| OpenAI (fallbacks) | `OPENAI_API_KEY` |
| MuAPI (b-rolls) | `MUAPI_KEY` |
| Pikzels (thumbnails) | `PIKZELS_KEY` / `PIKZELS_API_KEY` |
| YouTube OAuth | `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, `GOOGLE_REFRESH_TOKEN_AGENTSSQUAD`, `GOOGLE_REFRESH_TOKEN_AI4MANAGERS`, `GOOGLE_REFRESH_TOKEN_THALX` |
| AWS / Remotion | `AWS_REGION`, `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `REMOTION_FUNCTION_NAME`, `REMOTION_BUCKET_NAME`, `REMOTION_SERVE_URL` |
| Notificaciones | `TELEGRAM_BOT_TOKEN`, `TG_BOT_TOKEN`, `TG_CHAT_ID` |

## 12. Dependencias externas

- **Binarios:** ffmpeg/ffprobe, Chromium (Remotion SSR), hunspell (+ diccionario ES LATAM).
- **Python:** `faster-whisper` (int8) y/o `whisper`, `insightface` + `opencv` + `numpy` (detección facial/ArcFace, CPU), `Pillow`, `requests`, `openai`, `google-api-python-client` + `google-auth`, `apify-client ≥1.7`, `sqlite-vec ≥0.1.6`, `Jinja2`, `jsonschema`.
- **Node:** `remotion` + `@remotion/lambda|bundler|renderer` (versión pineada), `react 19`, `@react-three/fiber 9.x` + `three` (solo bundle 3D).
- **LLM para guiones/briefs:** Claude CLI headless (`claude -p`) como primario; `gpt-4o-mini` (JSON mode) como fallback.
- **Servicios:** ElevenLabs (TTS), HeyGen (avatar, paso manual), MuAPI/Higgsfield/Sora-Seedance (b-roll generativo), Pikzels (thumbnails + score), Apify (scraping), Cloudflare Workers AI (embeddings) + Stream (entrega), YouTube Data v3 + Analytics v2, AWS Lambda + S3 (Remotion).
- **Fuentes tipográficas:** Inter Black (captions C1 + hookcard), DejaVu Sans (Box), PP Editorial New (Serif editorial).

## 13. Las 29 reglas duras (índice consolidado)

**Ideación (2):** checklist REX 8 criterios mínimo 6/8 · nunca re-proponer ángulos rechazados.
**Scout (2):** siempre `--dry-run` primero · no saltar fases (scout → library → briefing → guion).
**Guion (5):** POV obligatorio (nunca cerrar en "no lo sé") · LATAM neutro sin voseo · "inteligencia artificial" en VO, nunca la sigla · nunca cantidad de agentes ni tenure · nunca fabricar stats/montos/credenciales.
**VO (3):** receta lockeada (0.5/0.9/0.2, único knob speed, prohibido atempo) · voqa obligatorio en CADA VO · voqa es ES-only (no usar para VO en inglés).
**Avatar/B-roll (4):** rostro de persona = HeyGen siempre · cost gate antes de cada submit MuAPI · generación 1-by-1 nunca batch · no doble persona.
**Composición (6):** safe-zones x<936/y<1632 y captions nunca sobre la cara · wrap sin truncar palabras (0 drops) · loudnorm -14 LUFS por segmento + `+faststart` siempre · text-behind solo con palabras inferibles · lip-sync en UN comando FFmpeg (prohibido concat y `-ss` antes de `-i`) · entrega vía Cloudflare Stream.
**Packaging (4):** título = pregunta real + keyword + complementa el hook + ≤60c sin clickbait · thumbnail nunca exhibe duda · short de persona = contacto visual + hookcard sobre el pecho · nunca grid contable de agentes.
**Publicación (2):** Shorts ES al canal LATAM, canal EN solo inglés · no confundir el canal LATAM real con handles junk sin token.
**Métricas (1):** CTR/impresiones son Studio-only — prohibido fabricar el número.

---

## 14. Orden de implementación sugerido para la plataforma nueva

1. **Contratos primero:** `spec.json` + máquina de estados + estructura `queue/<slug>/` (§0, §6.1). Todo lo demás se cuelga de esto.
2. **Data layer scout** (§2): schema SQL + adapter de UNA plataforma + embeddings CF + similarity search. Verificable con un smoke test de ingesta.
3. **Guion** (§3): reglas horneadas como validadores deterministas (anti-voseo, título ≤60c, anti-cliché) + prompts con las reglas embebidas.
4. **VO + voqa** (§4): la receta y el gate son lo más transferible — puro API + faster-whisper.
5. **Composición** (§6): empezar por el compositor simple (push-in + dual-style + loudnorm), luego el C1 completo (cards + hookcard + outro + concat).
6. **Packaging + publish** (§7-8): gates de thumbnail/título + upload idempotente multi-canal.
7. **Métricas** (§9): cerrar el loop de aprendizaje.
8. **Remotion** (§10): solo si la plataforma nueva necesita piezas programáticas React; si no, FFmpeg cubre el ensamblaje completo.

Criterio de "hecho" por módulo: cada gate debe ser ejecutable como comando con exit code (0/1), cada contrato validable contra schema, y cada invariante cubierto por al menos un test (el sistema original acumula ~150 tests entre factory, VOC y gates).
