feat(C197): narração rascunho (TTS pt-BR) + transcrição sempre no pacote do reel (Closes #1156)

This commit is contained in:
Francisco Solla 2026-09-19 00:16:16 -03:00
parent c0760f4234
commit 31ebb932af
20 changed files with 1487 additions and 48 deletions

View file

@ -3,7 +3,7 @@ name: reels-tutoriais
description: 'Gera um Reel tutorial vertical (MP4 1080×1920, legenda queimada, zoom/cursor) de uma funcionalidade do site de campanha, dirigindo o site real em viewport de celular e compondo com ffmpeg; mostra o pacote no gate e regenera a partir de ajustes em linguagem natural (o shot list é a fonte única).'
---
# Reels tutoriais do site (C196)
# Reels tutoriais do site (C196/C197)
Entrega, numa invocação, um **pacote publicável de Reel vertical** ensinando uma
funcionalidade do site (`jorgesolla1313.com.br`) — o primeiro é o tutorial dos
@ -14,6 +14,13 @@ mostra o resultado no gate: **aprovar** (o pacote segue para a biblioteca
privada, C195) ou **ajustar** em linguagem natural — o ajuste edita o **shot
list** e regenera; o arquivo renderizado nunca é editado.
O pacote sai **sempre com a transcrição do roteiro** (`narracao.srt` +
`roteiro.md`) para a assessoria regravar; o gate pergunta ainda **com ou sem
áudio** (padrão: **sem áudio**). Com áudio, saem também um rascunho de narração
em **TTS pt-BR** (`narracao.mp3`) e a variante `reel-audio.mp4` (mesma trilha de
vídeo, áudio muxado). A voz do rascunho **nunca clona a do Solla** — a decisão
da voz final é humana.
**Nada é publicado em rede social por esta skill.** O Instagram/canais são da
assessoria; aqui o produto é o arquivo no disco.
@ -30,33 +37,45 @@ assessoria; aqui o produto é o arquivo no disco.
sozinha, nesta ordem: `FFMPEG_PATH` → `ffmpeg` do PATH →
`@ffmpeg-installer/ffmpeg` (o binário empacotado do registry, padrão da
workstation). Se nenhum servir, o build **falha fechado** com a mensagem
acionável — não há fallback silencioso.
acionável — não há fallback silencioso. Com `--audio`, o probe exige também o
encoder `libmp3lame` e o filtro `atempo`.
- **Site no ar** e alcançável: a captura navega o site de produção por padrão.
Para outro ambiente: `--base-url=` ou `REELS_BASE_URL` (ex.: staging/local).
- Chromium do `@playwright/test` (já instalado com as dependências do repo).
- **Só com `--audio`:** o provider TTS `edge-tts` em venv dedicado — prepare com
`pnpm reels:tts:setup` (idempotente). A skill resolve, nesta ordem:
`EDGE_TTS_PATH` → venv `scripts/reels/.venv` → `edge-tts` do PATH; sem
provider, o build **falha fechado antes da captura**, com a mensagem de setup.
- O build é local (workstation): **não toca banco, não escreve no site** — o
visitante é anônimo e o download do card é client-side.
## Pipeline (o que o build faz)
```text
pnpm reels:build <slug>
pnpm reels:build <slug> [--audio]
shot list (scripts/reels/shot-lists/<slug>.json) ← fonte única da verdade
└─ captura: Chromium mobile (360×640 @3x = 1080×1920 nativo)
├─ cursor sintético + halo amarelo no alvo do clique
└─ screencast com timestamps de parede (sem drift) → frames JPEG
└─ assets: legendas/etiqueta em PNG transparente + capa.png
└─ clipes por cena: zoompan (do log de cliques) + overlay
└─ concat H.264/yuv420p 30fps → pacote
└─ concat H.264/yuv420p 30fps
├─ transcrição SEMPRE: narracao.srt + roteiro.md
└─ com --audio: edge-tts por beat → PCM medido
(atempo ≤1.5 + silêncio na janela da cena)
→ narracao.mp3 + reel-audio.mp4 (-c:v copy)
└─ pacote + metadata.json
```
Comandos:
```bash
pnpm reels:build cards # pacote completo em data/reels/cards/
pnpm reels:build cards # pacote em data/reels/cards/ (transcrição sempre)
pnpm reels:build cards --audio # + rascunho narrado (TTS pt-BR)
pnpm reels:build cards --dry-run # valida o shot list e imprime cenas + fala
pnpm reels:build cards --capture-only # só a captura (debug do roteiro)
pnpm reels:build cards --dry-run # valida o shot list e imprime as cenas
pnpm reels:build cards --base-url=http://127.0.0.1:3100
pnpm reels:tts:setup # venv do edge-tts (só para --audio)
```
## Contrato do shot list
@ -69,6 +88,13 @@ queimada), `setup` (preparo invisível, ex. `scrollIntoView`) e `steps`
(zero PII de terceiros). Qualquer ajuste de roteiro, texto, ordem, ritmo ou
alvo de clique é feito **aqui** — nunca no MP4.
A fala de cada cena vive **no shot list**, no campo `narration` (opcional, até
600 caracteres): as cenas de captura caem no texto da própria `caption` quando
ele falta (os espaços autorais são preservados), e cenas gráficas sem
`narration` ficam em silêncio. O `coverAlt` (opcional; fallback: o `title`) é o
texto alternativo da capa no manifesto. A skill **não parafraseia**: o texto
narrado é o roteiro aprovado aqui.
O hash do shot list vai no `metadata.json` e identifica a versão do reel:
re-render com o mesmo hash é a mesma versão; hash novo é reel novo (a ingestão
do C195 usa essa chave).
@ -80,20 +106,26 @@ O build escreve em `data/reels/<slug>/` (gitignored):
- `reel.mp4` — 1080×1920, 30fps, H.264, **sem áudio** (o primário);
- `capa.png` — composição própria (não frame do vídeo), título dentro do
recorte central 1080×1350 (sobrevive ao feed 4:5 e à grade do perfil);
- `metadata.json` — título, funcionalidade, duração, hash do shot list, data.
- `narracao.srt` — transcrição com um bloco por beat (timecodes das cenas);
- `roteiro.md` — fala legível por cena/beat, para a assessoria regravar;
- `metadata.json` — título, funcionalidade, duração, hash do shot list, data,
`coverAlt`, voz (quando houver áudio) e a lista de artefatos do pacote;
- com `--audio`: `narracao.mp3` (rascunho TTS) + `reel-audio.mp4` (trilha de
vídeo idêntica ao `reel.mp4`, áudio AAC muxado).
Intermediários ficam em `data/reels/.work/<slug>/` (frames, log de cliques,
capturas gráficas) e podem ser apagados a qualquer momento.
capturas gráficas, áudio por cena) e podem ser apagados a qualquer momento.
**Gate (apresente no chat):** mostre os três artefatos e o resumo (duração,
cenas, hash). Então:
**Gate (apresente no chat):** pergunte primeiro **com ou sem áudio** (padrão:
sem) e então mostre os artefatos e o resumo (duração, cenas, hash). Depois:
- **Aprovar** → o pacote está pronto para a biblioteca privada (C195). Enquanto
o C195 não estiver disponível, o pacote fica no disco e o caminho é o
contrato de entrega; **nada sobe para o Instagram**.
- **Ajustar** → traduza o pedido ("hook mais curto", "a legenda some antes",
"clicar em outro botão") para o **shot list** (`scripts/reels/shot-lists/`),
mostre o que mudou e rode `pnpm reels:build <slug>` de novo. Sempre.
mostre o que mudou e rode `pnpm reels:build <slug>` de novo. Sempre. Mudar de
ideia sobre o áudio é só re-rodar com (ou sem) `--audio`.
## Limites e troubleshooting
@ -105,6 +137,12 @@ cenas, hash). Então:
build avisa e usa a fallback do sistema (nunca em silêncio).
- **ffmpeg ausente/sem libx264** → mensagem com o que instalar/configurar;
não tente compor sem o probe.
- **edge-tts ausente/offline** → `--audio` falha **antes da captura** com a
mensagem de `pnpm reels:tts:setup` (ou `EDGE_TTS_PATH`); sem provider/rede não
existe pacote "quase pronto" — regenere depois.
- **Fala não cabe na cena** → o build falha dizendo a cena e a duração; encurte
a fala no shot list (ou divida a cena) — o `atempo` só acelera até 1.5× e a
transcrição sai mesmo assim.
- **Ajuste nunca é no render** — se alguém pedir para "cortar direto no vídeo",
a resposta é editar o shot list e regenerar.
@ -136,6 +174,8 @@ Os ativos oficiais da campanha vivem em `public/campaign-kit/` (mapa de uso no
## Fora de escopo
- Áudio, narração e trilha (C197); publicação no Instagram; outras
funcionalidades além da do shot list corrente; editar o MP4 renderizado;
idiomas além de pt-BR.
- Clonagem de voz (referência/áudio do Solla), trilha/música licenciada e
publicação no Instagram — o rascunho TTS é opcional e a voz final é decisão
humana; STT/legenda a partir de fala; edição de áudio (loudness/mixagem);
outras funcionalidades além da do shot list corrente; editar o MP4
renderizado; idiomas além de pt-BR.

7
.gitignore vendored
View file

@ -103,9 +103,14 @@ next-env.d.ts
/docs/research/graficos-instagram/
# C196 — reels tutoriais: o shot list é versionado (scripts/reels/shot-lists);
# o pacote (reel.mp4/capa.png/metadata.json) e os intermediários são regeneráveis.
# o pacote (reel.mp4/capa.png/narracao.srt/roteiro.md/metadata.json) e os
# intermediários são regeneráveis.
/data/reels/
# C197 — venv do provider TTS (edge-tts) do rascunho de narração: local da
# workstation, nunca versionado (o deploy npm não o toca).
/scripts/reels/.venv/
# Local-only sensitive material (agents may read; never commit content)
/private/**
!/private/README.md

View file

@ -1,8 +1,8 @@
---
description: Gera um Reel tutorial vertical (MP4 1080×1920 com legenda queimada, zoom e cursor) de uma funcionalidade do site — captura o site real em viewport de celular, compõe o pacote e abre o gate de aprovar/ajustar
description: Gera um Reel tutorial vertical (MP4 1080×1920 com legenda queimada, zoom e cursor) de uma funcionalidade do site — captura o site real em viewport de celular, compõe o pacote com transcrição sempre e áudio TTS opcional, e abre o gate de aprovar/ajustar
---
Carregue a skill `reels-tutoriais` (ferramenta de skills, nome exato) e siga o fluxo dela de ponta a ponta: leia/edite o shot list versionado, rode `pnpm reels:build <slug>` (captura mobile do site real + composição ffmpeg) e apresente no gate o pacote (`reel.mp4` + `capa.png` + `metadata.json`) para aprovar ou ajustar em linguagem natural — ajuste edita o shot list e regenera; nada é publicado em rede social. A fonte canônica é `.agents/skills/reels-tutoriais/SKILL.md` — não a transcreva nem recrie o fluxo.
Carregue a skill `reels-tutoriais` (ferramenta de skills, nome exato) e siga o fluxo dela de ponta a ponta: leia/edite o shot list versionado, pergunte no gate com ou sem áudio (padrão sem), rode `pnpm reels:build <slug> [--audio]` (captura mobile do site real + composição ffmpeg + transcrição/`--audio`) e apresente no gate o pacote (`reel.mp4` + `capa.png` + `narracao.srt` + `roteiro.md` + `metadata.json`, e com áudio `narracao.mp3` + `reel-audio.mp4`) para aprovar ou ajustar em linguagem natural — ajuste edita o shot list e regenera; nada é publicado em rede social. A fonte canônica é `.agents/skills/reels-tutoriais/SKILL.md` — não a transcreva nem recrie o fluxo.
Uso: `/reels-tutoriais <funcionalidade>` — ex.: `/reels-tutoriais cards`.

View file

@ -0,0 +1 @@
**C197 — narração rascunho (TTS pt-BR) + transcrição sempre no pacote do reel (2026-09-19):** o motor de reels do C196 passou a fechar o contrato da biblioteca privada: **todo** pacote sai com `narracao.srt` (um bloco por beat, timecodes das cenas) + `roteiro.md` (fala por cena, para a assessoria regravar) e o `metadata.json` ganhou `coverAlt` (obrigatório no ingest C195; fallback do `title`), `durationSeconds`, `voice` e a lista `artifacts` — antes o pacote não era ingerível (faltavam transcrição e alt). O gate da skill pergunta **com ou sem áudio** (padrão sem) e `--audio` gera o rascunho narrado: `edge-tts` (TTS neural pt-BR, grátis e sem key) em venv dedicado (`pnpm reels:tts:setup`, `EDGE_TTS_PATH` → `scripts/reels/.venv` → PATH, fail-closed antes da captura), síntese por beat com o texto passado por arquivo (nunca argv), medição exata por PCM decodificado (sem `ffprobe`), `atempo` ≤1.5× + padding de silêncio para a fala caber na janela da cena (estouro falha com a cena e a duração) e mux `-c:v copy` → `narracao.mp3` + `reel-audio.mp4` com a trilha de vídeo idêntica. A narração é o roteiro aprovado no shot list (campo `narration`, fallback na `caption` com espaços autorais preservados); sem clonagem de voz, sem música, sem publicação. Hash do `cards` muda (nova versão C197). Unit novos: `reelScript` (SRT/roteiro/fit/PCM) e `reelTts` (resolução/setup/síntese/trilha), mais probe de áudio (libmp3lame + atempo) e args de decode/encode/mux em `reelFfmpeg`; verificação real com `pnpm reels:build cards [--audio]` + `pnpm reels:ingest` (dry) aceitando o pacote. Sem schema/migration; TTS fora do deploy npm (`--frozen-lockfile` intacto).

View file

@ -0,0 +1,157 @@
# Impl: Skill de reels: narração rascunho (TTS pt-BR) + transcrição sempre
Status: aprovado
Atualizado em: 2026-09-18
Issue: #1156
Intenção: docs/plans/reels-narracao.md
Appetite restante: ~1 dia eng (herdado). Transcrição sempre + áudio `--audio` de ponta a ponta; se estourar, corta `voice` no metadata e o override de voz — nunca a transcrição sempre nem o fit fail-closed da fala na cena.
## Leitura da intenção
- **Outcome:** todo pacote do motor C196 sai com transcrição do roteiro (`narracao.srt` + `roteiro.md`) para a assessoria regravar, e, quando o gate pedir, sai também `narracao.mp3` (TTS pt-BR de rascunho) + `reel-audio.mp4` (mesma trilha de vídeo com áudio muxado); a decisão da voz final é humana.
- **O que NÃO negociar:** nada de clonagem de voz/áudio do Solla; sem música licenciada; sem publicação automática/`is_ai_generated`; o texto falado é o roteiro aprovado no gate (a skill não parafraseia); a trilha de vídeo de `reel.mp4` e `reel-audio.mp4` é idêntica; o áudio é rascunho e cabe na duração do vídeo; o `.srt`/`roteiro.md` saem **sempre**.
- **O que reavaliar:** (1) a costura real é maior que "produzir arquivos" — o pacote C196 hoje **não** tem `coverAlt`/`durationSeconds`/`narracao.srt`/`roteiro.md`, então o ingest C195 o recusaria; fechar essa lacuna é a fase 1; (2) adicionar `narration`/`coverAlt` normalizados **muda o hash** do `cards` (nova versão C197 do reel, registrada); (3) o binário ffmpeg empacotado não tem `ffprobe` — a medição de fala tem de ser por PCM decodificado (verificado: o 4.1 empacotado tem `libmp3lame`, `atempo`, `aresample` e muxer `mp3`).
## Abordagem recomendada
```mermaid
flowchart LR
A["shot list + narration<br/>scripts/reels/shot-lists/cards.json"] --> B["build C196<br/>captura → clipes → concat"]
B --> C["reelScript (puro)<br/>beats · narracao.srt · roteiro.md"]
C --> D["pacote<br/>reel.mp4 + capa.png + transcrição"]
D --> E{"--audio?"}
E -->|não| F["metadata.json"]
E -->|sim| G["edge-tts por beat → PCM<br/>mede · atempo ≤1.5 · pad"]
G --> H["narracao.mp3 + reel-audio.mp4<br/>-c:v copy"]
H --> F
F --> I["biblioteca (C195/C194)<br/>downloads + transcrição"]
```
**Opções consideradas:** 8 decisões caras (texto narrado | provider/instalação do TTS | encaixe na duração | SRT e roteiro | mux | metadata | flag/gate | módulos) — cada uma com recomendação ancorada e rejeitadas abaixo.
**Recomendação:** fechar primeiro a costura de transcrição (sempre, sem TTS) — tracer que já entrega o aceite duro e torna o pacote ingerível pelo C195 — e só então plugar o TTS atrás de `--audio`, com resolução fail-closed antes da captura e fit por cena com teto.
### Decisão 1 — Texto narrado
Opções: A) `narration` opcional por cena, com fallback: capture = `caption.parts` concatenados (o `accent` é só estilo), graphic = silêncio | B) `narration` obrigatório em toda cena | C) sem campo novo, narração = caption sempre (hook/CTA mudas).
Recomendação: **A** — o campo é copiado explicitamente na normalização (`normalizeCaptureScene`/`normalizeGraphicScene` em `scripts/lib/reelShotList.mjs:137-180`), entra no hash e mantém o shot list C196 válido (as capturas seguem falando exatamente a legenda aprovada); o `cards.json` ganha `narration` explícita no `hook` e no `cta`, cuja copy vive no template HTML (`reelTemplates.mjs`) e não no shot list — sem isso o roteiro teria buracos no começo e no fim. `narration` ausente/`null` → fallback; string não vazia (trim, ≤600 chars) → fala.
Rejeitadas: B porque obriga a duplicar a legenda em todas as cenas sem ganho de produto e atrasa o tracer; C porque hook/CTA nunca teriam fala e a assessoria não poderia ajustar a fala independente da legenda.
### Decisão 2 — Provider TTS, instalação e descoberta
Opções: A) `edge-tts` (Microsoft, grátis, sem key) em venv dedicado `scripts/reels/.venv`, com setup por `pnpm reels:tts:setup` e descoberta `EDGE_TTS_PATH` → venv → PATH, fail-closed | B) cloud com key (Google/Azure) | C) `edge-tts` global (`pip --user`/`pipx`/sistema) | D) OpenAI TTS.
Recomendação: **A** — a máquina tem Python 3.12 e `venv`+`ensurepip` (pip 24.0) funcionam sem `pip`/`pipx` de sistema; o venv isola o provider do deploy (`pnpm install --frozen-lockfile` intacto, nenhuma dep npm nova; TTS é script local) e não exige credencial. `pnpm reels:tts:setup` cria o venv e instala/atualiza o `edge-tts`; a build resolve e faz probe (`--version`, exit 0) com mensagem pt-BR acionável; voz default `pt-BR-AntonioNeural` (masculina, institucional/rascunho), override `--voice=`/`REELS_TTS_VOICE`.
Rejeitadas: B porque exige credencial, billing e manda o roteiro para a nuvem por um rascunho (upgrade futuro se a qualidade incomodar); C porque não há `pip`/`pipx` de sistema e instalação global contamina a workstation; D porque a OPS123 reserva `openai/*` ao design.
### Decisão 3 — Encaixe do áudio na duração do vídeo
Opções: A) sintetizar **por beat** (cena), medir a duração exata do PCM decodificado, aplicar `atempo` local com teto 1.5, preencher com silêncio até a duração exata da cena e concatenar o PCM = duração do vídeo | B) sintetizar o roteiro inteiro de uma vez e muxar com `-shortest` | C) esticar sem teto ou truncar.
Recomendação: **A** — cada beat fica preso à janela da cena (`plan.durationMs`, cuja soma já é o `totalDurationMs`); decodificar para PCM `s16le` 24 kHz mono e medir por `bytes / (24000 × 2)` dispensa `ffprobe` (ausente no binário empacotado) e é matemática pura unit-testável; o padding em Node (`Buffer.alloc`) fecha a trilha no tamanho exato; `atempo` só acelera (nunca desacelera: fala curta é pad), e estourar 1.5 vira erro com cena, falado e disponível — fala inteligível em vez de robotizada.
Rejeitadas: B porque o drift acumula cena a cena e o `-shortest` corta fala ou vídeo em silêncio (exatamente o "corta cedo demais"); C porque além de 1.5 perde a inteligibilidade e truncar joga fora o roteiro aprovado.
### Decisão 4 — `narracao.srt` e `roteiro.md` (puros)
Opções: A) um bloco por cena com fala, timecodes cumulativos na ordem de `shotList.scenes`, derivados dos planos; `roteiro.md` com duração/hash e cada cena com janela e fala (cenas mudas marcadas); UTF-8, LF, sem quebra interna | B) SRT por palavra via `--write-subtitles` do edge-tts | C) transcrição só no `roteiro.md`.
Recomendação: **A** — casa com o aceite "um bloco por beat" e com o corte declarado ("timestamps perfeitos: um bloco por beat, simples"); nada de heurística de fala, e a transcrição é gerada pelo mesmo caminho com ou sem áudio (o `.srt` nunca depende de rede/TTS). Formatação `HH:MM:SS,mmm --> HH:MM:SS,mmm`, índice crescente, cenas sem fala fora do `.srt`.
Rejeitadas: B porque acopla a transcrição (aceite duro, sempre) ao provider/rede e produz timestamps de fala, não de cena; C porque a intenção e o contrato C195 pedem `.srt` (kind `captions`).
### Decisão 5 — Mux de `reel-audio.mp4`
Opções: A) `-i reel.mp4 -i narracao.mp3 -map 0:v -map 1:a -c:v copy -c:a aac -b:a 128k -shortest -movflags +faststart` | B) re-encodar o vídeo junto | C) só `narracao.mp3`, sem variante de vídeo.
Recomendação: **A** — `-c:v copy` garante a trilha de vídeo **idêntica** entre as duas variantes (aceite literal); AAC 128 kbps mono basta para rascunho; `-shortest` é cinto sobre o padding exato (a granularidade de frame do MP3 pode somar ~26 ms de priming). Roda imediatamente depois do concat, quando `reel.mp4` já está no pacote.
Rejeitadas: B porque quebra o aceite da trilha idêntica e custa minutos de CPU; C porque o contrato C195 já define `reel-audio.mp4` (kind `video-audio`).
### Decisão 6 — Metadata/manifesto
Opções: A) manter o metadata C196 e acrescentar `coverAlt`, `durationSeconds`, `audio`, `voice` (quando houver) e `artifacts` com os nomes escritos | B) mínimo: só `coverAlt` + `durationSeconds` (o que o ingest valida) | C) derivar a lista de `REEL_PACKAGE_ARTIFACTS` de `src/lib/reel.ts`.
Recomendação: **A** — fecha a lacuna que hoje recusaria o pacote (`coverAlt` é obrigatório no ingest, `scripts/lib/reel-ingest.mjs:216-219`) e cumpre o "manifesto lista todos os arquivos gerados" da intenção; `artifacts` é derivado do que o build **escreveu** (`reel.mp4`, `capa.png`, `narracao.srt`, `roteiro.md`, + `narracao.mp3`/`reel-audio.mp4` com áudio), e chaves extras são ignoradas pelo ingest (forward-compat, `reelPackageIssues`). `coverAlt` vem de campo top-level opcional do shot list com fallback `title`; `durationSeconds = seconds(durationMs)`.
Rejeitadas: B porque não cumpre o manifesto da intenção; C porque `build-reel.mjs` roda em `node` puro (sem loader tsx) e não importa o `.ts` — listar o que foi escrito é mais honesto que o catálogo do servidor (que é lido, não editado).
### Decisão 7 — Flag e gate
Opções: A) `--audio` bare, default sem áudio, com a skill perguntando "com ou sem áudio?" no gate **antes** de gerar | B) `--with-audio` | C) `--no-audio` (default com áudio).
Recomendação: **A** — o padrão é sem áudio (a legenda queimada é o formato primário) e o flag marca o desvio; `parseEqualsFlags` (`scripts/lib/cli.mjs:34-48`) devolve `true` no bare e a build **rejeita** `--audio=<valor>` (nada de `--audio=false` truthy) e `--audio` + `--capture-only` (erro explícito). Mudar de ideia depois do gate = re-rodar com `--audio` (recaptura aceita; revisitar se doer).
Rejeitadas: B por ser o mesmo flag com nome maior; C porque contraria a intenção (áudio draft não é o padrão) e encareceria todo build.
### Decisão 8 — Estrutura de módulos
Opções: A) `scripts/lib/reelScript.mjs` (puro: fala/fallback, beats, SRT, roteiro, fator de tempo e samples) + `scripts/lib/reelTts.mjs` (resolução/setup/síntese/trilha), com os args de áudio e mux no `reelFfmpeg.mjs` (dono do ffmpeg) | B) um módulo só `reelNarration.mjs` | C) espalhar nos módulos C196 existentes.
Recomendação: **A** — separa matemática pura (unit sem processo) de provider/rede (`run` injetável como em `probeFfmpeg`), espelhando a divisão `reelTimeline` (puro) / `reelFfmpeg` (I/O); os comandos de áudio ficam no dono do ffmpeg em vez de um segundo montador de args; `build-reel.mjs` só orquestra; `--dry-run` usa só o `reelScript` (imprime a fala por cena sem tocar em TTS).
Rejeitadas: B porque mistura pureza com rede e obriga mock de processo nos testes de formatação; C porque transcrição pura e provider TTS não pertencem a timeline/captura/ffmpeg.
### Componentes / mudanças
- **`scripts/lib/reelScript.mjs`** (novo, puro): `resolveSceneNarration` (fallback da Decisão 1), `buildScriptBeats({ shotList, plans })`, `formatSrtTime`, `buildReelSrt`, `buildReelRoteiro`, `fitBeat` (fator `tempo` com teto e flag de estouro) e `padPcmToSamples`; sem I/O.
- **`scripts/lib/reelTts.mjs`** (novo, I/O com `run` injetável): `TTS_VENV_DIR` (`scripts/reels/.venv`), `DEFAULT_TTS_VOICE`, `resolveEdgeTts` (`EDGE_TTS_PATH` estrito → venv → PATH, probe `--version`), `setupEdgeTts` (`python3 -m venv` + `pip install --upgrade edge-tts`), `synthesizeBeat` (texto por **arquivo**, `--file`, nunca no argv) e `buildNarrationTrack` (por beat: decodifica → mede PCM → `atempo`/pad → concat em Node → um encode `narracao.mp3`); reusa `runFfmpeg`, `sha256Hex`, `dieWithLabel`.
- **`scripts/reels-tts-setup.mjs`** (novo, entry fino): chama `setupEdgeTts` e imprime caminho/versão; exposto como `pnpm reels:tts:setup`.
- **`scripts/lib/reelFfmpeg.mjs`** (edição no dono): `probeFfmpeg(bin, { run, audio })` — com `audio: true` exige também encoder `libmp3lame` e filtro `atempo` (fail-closed pt-BR); `resolveFfmpeg({ …, audio })` repassa; `buildAudioDecodeArgs` (mp3 → `s16le` 24k mono, `atempo` opcional), `buildNarrationEncodeArgs` (PCM → mp3 `-b:a 128k`) e `buildMuxAudioVideoArgs` (Decisão 5).
- **`scripts/lib/reelShotList.mjs`** (edição no dono): normaliza `narration` (capture/gráfica) e `coverAlt` top-level (fallback `title`); único dono do schema/hash.
- **`scripts/lib/reelPackage.mjs`** (edição no dono): `buildReelMetadata({ …, audio = false, voice, artifacts })` com `coverAlt`, `durationSeconds`, `audio`, `artifacts`.
- **`scripts/build-reel.mjs`** (orquestração): flags `--audio`/`--voice=`; resolve TTS **antes** do browser quando `--audio` (falha cedo); escreve `narracao.srt`/`roteiro.md` em todo build; com áudio, gera trilha + mux + metadata completo.
- **`scripts/reels/shot-lists/cards.json`**: `coverAlt` + `narration` no hook/CTA; capturas usam o fallback da legenda. **Hash muda** — nova versão C197 do reel (registrado).
- **`.agents/skills/reels-tutoriais/SKILL.md`**: gate pergunta "com ou sem áudio?" (default sem), pacote com transcrição sempre (+ mp3/mp4 com áudio), pré-requisitos/setup do TTS (`pnpm reels:tts:setup`, `EDGE_TTS_PATH`), troubleshooting (rede/probe/atempo) e áudio fora do "Fora de escopo" (clonagem/música/publicação seguem fora).
- **`.gitignore`**: `scripts/reels/.venv/`. **`package.json`**: só o script `reels:tts:setup` (nenhuma dep npm nova).
- **Testes**: novos `tests/unit/reelScript.unit.spec.ts` e `reelTts.unit.spec.ts`; extensão de `reelShotList.unit.spec.ts`, `reelFfmpeg.unit.spec.ts` e atualização do pin exato de metadata/skill em `reelsTutoriaisSkill.unit.spec.ts:15-76`; `tests/fixtures/fake-ffmpeg.mjs` e runners injetados como molde.
- **`docs/changelog/2026-09-18-c197.md`**: entrega + mudança de hash.
- **Migration:** sem migration. **Access/Consent:** não se aplica (script local; o site é visitado anônimo; nada escreve no banco). **UI:** sem UI nova — a superfície gráfica do reel é do C196 e a transcrição/áudio não mudam layout; declarar **non-trigger** do designer (decisão registrada).
### Dados → forma (se aplicável)
Não se aplica: a intenção declara "não vou apresentar dados"; aqui só áudio/texto derivados do shot list.
## Fases verificáveis
1. **Tracer — transcrição sempre (fecha a costura C195, sem TTS):** `narration`/`coverAlt` no `reelShotList`; `reelScript.mjs`; build escreve `narracao.srt` + `roteiro.md`; `reelPackage` com `coverAlt`/`durationSeconds`/`artifacts`; `cards.json` com fala no hook/CTA; units. Verificação real: `pnpm reels:build cards` gera o pacote e `pnpm reels:ingest data/reels/cards` (sem `--apply`, alvo local) **aceita** — a lacuna da intenção fecha aqui, antes de qualquer TTS.
2. **Áudio TTS (`--audio`):** setup + `reelTts.mjs` + probe/args de áudio no `reelFfmpeg` + fluxo no build + metadata `audio`/`voice`; units com `run` injetado e fake-ffmpeg. Verificação: `pnpm reels:tts:setup`; `pnpm reels:build cards --audio`; `narracao.mp3` com duração = vídeo; `reel-audio.mp4` com stream de vídeo copiado e áudio AAC; estouro de `atempo` falha com mensagem acionável.
3. **Skill/gate + docs:** `SKILL.md`, pins do teste de skill/command, `.gitignore`, script do `package.json`, changelog.
4. **Gates:** `pnpm gate:fast` na iteração; entrega via `pnpm push` (CI/deploy cuidam do resto; sem schema, o deploy não muda comportamento).
## Rabbit holes / Não escopo (engenharia)
- Clonagem de voz/áudio do Solla; música/trilha; publicação e `is_ai_generated` (humano).
- Loudness/limiter/mixagem, efeitos e "naturalidade" além do rascunho inteligível.
- STT/legenda automática; SRT por palavra; quebra de linha perfeita.
- Múltiplas takes, voz por cena, editar a fala no gate (edita-se o shot list).
- `--audio-only`/reuso da captura via `plans.json` — recaptura é o caminho (revisitar se o custo no gate doer).
- Waveform/player/preview de áudio na biblioteca (C194) ou no gate.
- Providers cloud (Google/Azure/OpenAI TTS) — revisável se a qualidade incomodar.
- Tocar schema/servidor/site público: o contrato C195/C194 é consumido como está (nenhuma edição em `src/lib/reel.ts`).
## Débitos da triage do simplify (não reabrir)
- **S1 `RunFn` local no `reelTts`** — defer: gatilho na próxima edição do módulo (typedef JSDoc local não muda contrato nem runtime).
- **S2 `createdAt` sem produtor / `artifacts` write-only no contrato C195** — defer cross-item: gatilho no 2º consumidor do metadata (o ingest ignora chaves extras de propósito).
- **S3 cast redundante em teste / S5 `atempo` só em beat que estoura** — descartados (pureza de teste / comportamento intencional).
- **S4 decode de 0 bytes viraria beat mudo** — defer teórico: o ffmpeg falha fechado numa síntese vazia; gatilho na primeira ocorrência real.
## Riscos e mitigação
- **edge-tts indisponível/offline:** resolução + probe **antes** da captura quando `--audio`; falha fechada com `pnpm reels:tts:setup`/`EDGE_TTS_PATH`; rede cai no meio → erro com stderr e re-run (pacote anterior não é declarado válido).
- **Fala estoura a cena:** `atempo` ≤1.5 + erro com cena, falado e disponível; acima disso a saída é encurtar a fala no shot list ou dividir a cena — nunca esticar mais.
- **Hash muda:** `cards` vira nova versão; C195 identifica por `shotListHash` (reel C196 já ingerido ficaria como versão antiga; não há reel C196 ingerido hoje); registrar no changelog.
- **venv/deploy:** nenhuma dep npm; `scripts/reels/.venv` gitignored; Docker/`--frozen-lockfile` intocados; knip não varre o venv (project glob é `scripts/**/*.mjs`).
- **Tmp de áudio:** `data/reels/.work/<slug>/audio/` (já coberto por `/data/reels/`).
- **Aspas/emoji/newline na fala:** texto vai por arquivo (`--file`), argv só tem caminho; `execFile` com array; unit cobre string com aspas e emoji.
- **Duração sem ffprobe:** PCM `s16le` 24 kHz mono medido por bytes; puro e unit-testado; fake-ffmpeg/runner injetado cobre a orquestração.
- **Pin exato do metadata quebra:** atualização intencional do `toEqual` de `reelsTutoriaisSkill.unit.spec.ts` com as chaves novas listadas.
- **Binário empacotado antigo (4.1):** verificado localmente que tem `libmp3lame`, `atempo`, `aresample` e muxer `mp3`; o probe de áudio falha fechado em qualquer binário que não tenha.
- **Priming do MP3 (~26 ms):** `-shortest` no mux; o mp3 standalone pode exceder o vídeo em milissegundos — aceito no rascunho.
## Aceite de engenharia
- [ ] Aceite de produto da intenção ainda coberto (transcrição sempre; áudio opcional; trilha idêntica; TTS pt-BR draft sem clonagem/música/publicação; downloads e transcrição na biblioteca já servidos pelo C195/C194).
- [ ] Invariantes AGENTS/engineering-standards: identificadores em inglês/strings pt-BR; `scripts/**` em `.mjs`; knip `exports:error` sem export órfão; command/skill prettier-formatados; zero schema/migration/site público; nenhuma dep npm nova; `src/lib/reel.ts` intocado.
- [ ] Testes de domínio previstos: unit de `reelScript` (fallback/beats/SRT/roteiro/fit/pad), `reelShotList` (narration/coverAlt e rejeições), `reelFfmpeg` (probe de áudio e args de decode/encode/mux) e `reelTts` (ordem de resolução/setup com run injetado), pin de skill/command atualizado; sem int/e2e novos; verificação real no gate (build + `reels:ingest` dry).
- [ ] `pnpm gate:fast` verde e entrega por `pnpm push`.
## Self-score decision-quality
| # | Critério | Nota | Por quê |
| --- | ----------------------------- | ---- | -------------------------------------------------------------------------------------------------------------------------------- |
| 1 | Decisões caras com rejeitadas | 5 | As 8 decisões caras têm opções, recomendação ancorada em verificação local (venv, endpoint, capacidades do ffmpeg) e rejeitadas. |
| 2 | Cabe no appetite | 5 | ~1 dia; tracer fecha o aceite duro sem TTS; cortes declarados (`voice`/override). |
| 3 | Rabbit holes nomeados | 5 | Clonagem, música, loudness, STT, takes, preview, `--audio-only`, providers cloud. |
| 4 | Depth check | 4 | Reusa `cli.mjs`, `runFfmpeg`, probe injetável e fake-ffmpeg; módulos novos separam puro de I/O; nada gêmeo. |
| 5 | Intenção preservada | 5 | Outcome, gate, anti-goals e "roteiro aprovado" intactos; engenharia não reescreveu o produto. |
Média: **4,8** (gate ≥4 satisfeito).

View file

@ -53,6 +53,7 @@
"build:municipality-zone-geometries": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm\" node scripts/build-municipality-zone-geometries.mjs",
"build:tse-city-codes": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm\" node scripts/build-bahia-tse-city-codes.mjs",
"reels:build": "node scripts/build-reel.mjs",
"reels:tts:setup": "node scripts/reels-tts-setup.mjs",
"dev": "node scripts/dev.mjs",
"devsafe": "rm -rf .next && node scripts/dev.mjs",
"predev": "node scripts/predev-importmap.mjs",

View file

@ -1,17 +1,19 @@
/**
* C196 — reel builder: one shot list in, one publishable package out
* (`reel.mp4` + `capa.png` + `metadata.json`).
* C196/C197 — reel builder: one shot list in, one publishable package out
* (`reel.mp4` + `capa.png` + `narracao.srt` + `roteiro.md` + `metadata.json`,
* plus `narracao.mp3` + `reel-audio.mp4` with `--audio`).
*
* Pipeline: capture (real site, mobile viewport, timestamped screencast) →
* overlays/cover/graphic scenes (Playwright + the site's own fonts) → one
* H.264 clip per scene (zoompan + burned captions) → concat → package.
* H.264 clip per scene (zoompan + burned captions) → concat → transcription
* (always) → optional TTS draft → package.
*
* Runs on the workstation: the database is never touched and the site is
* visited as an anonymous reader (the card download is client-side).
*
* Usage:
* node scripts/build-reel.mjs cards [--base-url=…] [--out-dir=…] [--work-dir=…]
* [--dry-run] [--capture-only]
* [--dry-run] [--capture-only] [--audio] [--voice=pt-BR-AntonioNeural]
*/
import { mkdir, writeFile } from 'node:fs/promises'
@ -30,6 +32,7 @@ import {
} from './lib/reelCapture.mjs'
import {
buildConcatArgs,
buildMuxAudioVideoArgs,
buildSceneClipArgs,
concatListContent,
encodeFrames,
@ -50,6 +53,12 @@ import {
renderCover,
renderOverlay,
} from './lib/reelRender.mjs'
import {
buildReelRoteiro,
buildReelSrt,
buildScriptBeats,
resolveSceneNarration,
} from './lib/reelScript.mjs'
import { captureScenes, graphicScenes, loadShotList } from './lib/reelShotList.mjs'
import {
captionOverlayHtml,
@ -59,6 +68,7 @@ import {
hookHtml,
} from './lib/reelTemplates.mjs'
import { captureScenePlan, graphicScenePlan, REEL, totalDurationMs } from './lib/reelTimeline.mjs'
import { buildNarrationTrack, DEFAULT_TTS_VOICE, resolveEdgeTts } from './lib/reelTts.mjs'
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
const LABEL = 'build-reel'
@ -75,7 +85,7 @@ const main = async () => {
const slug = positional[0]
if (!slug) {
die(
'Uso: node scripts/build-reel.mjs <slug> [--base-url=…] [--out-dir=…] [--work-dir=…] [--dry-run] [--capture-only]',
'Uso: node scripts/build-reel.mjs <slug> [--base-url=…] [--out-dir=…] [--work-dir=…] [--dry-run] [--capture-only] [--audio] [--voice=pt-BR-AntonioNeural]',
)
}
const outRoot = resolve(
@ -92,6 +102,22 @@ const main = async () => {
: process.env.REELS_BASE_URL?.trim()) || DEFAULT_BASE_URL
const dryRun = flags['dry-run'] === true
const captureOnly = flags['capture-only'] === true
if (flags.audio !== undefined && flags.audio !== true) {
die('--audio não aceita valor (use --audio puro).')
}
const audio = flags.audio === true
if (audio && captureOnly) die('--audio não combina com --capture-only.')
const voiceFlag = flags.voice
if (voiceFlag !== undefined && typeof voiceFlag !== 'string') {
die('--voice exige um valor: --voice=pt-BR-AntonioNeural.')
}
if (typeof voiceFlag === 'string' && voiceFlag.trim() === '') {
die('--voice exige um valor: --voice=pt-BR-AntonioNeural.')
}
if (voiceFlag !== undefined && !audio) die('--voice exige --audio.')
const voice =
(typeof voiceFlag === 'string' ? voiceFlag.trim() : process.env.REELS_TTS_VOICE?.trim()) ||
DEFAULT_TTS_VOICE
const { shotList, hash } = await loadShotList({ slug, root: ROOT })
const outDir = join(outRoot, slug)
@ -104,12 +130,15 @@ const main = async () => {
scene.kind === 'capture'
? `captura · ${scene.steps.length} passo(s)`
: `gráfica · ${scene.template}`
console.log(` - ${scene.id}: ${detail}`)
const speech = resolveSceneNarration(scene)
console.log(` - ${scene.id}: ${detail} · ${speech ? `fala: ${speech}` : 'sem fala'}`)
}
return
}
const ffmpeg = await resolveFfmpeg()
const ffmpeg = await resolveFfmpeg({ audio })
console.log(`[${LABEL}] ffmpeg: ${ffmpeg.source} (${ffmpeg.version || ffmpeg.bin})`)
const tts = audio ? await resolveEdgeTts({ root: ROOT }) : null
if (tts) console.log(`[${LABEL}] TTS: ${tts.source} (${tts.bin}) — voz ${voice}`)
await mkdir(workDir, { recursive: true })
await mkdir(outDir, { recursive: true })
@ -276,15 +305,53 @@ const main = async () => {
await runFfmpeg(ffmpeg.bin, buildConcatArgs({ listPath, output: reelPath }), { label: 'concat' })
const durationMs = totalDurationMs([...plans.values()])
const beats = buildScriptBeats({ shotList, plans })
await writeFile(join(outDir, 'narracao.srt'), buildReelSrt(beats))
await writeFile(
join(outDir, 'roteiro.md'),
buildReelRoteiro({ shotList, hash, beats, durationMs }),
)
console.log(`[${LABEL}] transcrição: narracao.srt · roteiro.md`)
const artifacts = ['reel.mp4', 'capa.png', 'narracao.srt', 'roteiro.md']
if (audio) {
const mp3Path = join(outDir, 'narracao.mp3')
await buildNarrationTrack({
ffmpegBin: ffmpeg.bin,
edgeTtsBin: tts.bin,
voice,
beats,
workDir,
mp3Path,
})
await runFfmpeg(
ffmpeg.bin,
buildMuxAudioVideoArgs({
video: reelPath,
audio: mp3Path,
output: join(outDir, 'reel-audio.mp4'),
}),
{ label: 'mux do áudio' },
)
artifacts.push('narracao.mp3', 'reel-audio.mp4')
console.log(`[${LABEL}] áudio: narracao.mp3 + reel-audio.mp4 (voz ${voice})`)
}
const metadata = buildReelMetadata({
shotList,
hash,
durationMs,
ffmpeg: ffmpeg.version || ffmpeg.bin,
audio,
voice: audio ? voice : null,
artifacts,
})
await writeFile(join(outDir, 'metadata.json'), `${JSON.stringify(metadata, null, 2)}\n`)
const audioFiles = audio ? ' · narracao.mp3 · reel-audio.mp4' : ''
console.log(`[${LABEL}] pacote: ${outDir}`)
console.log(` reel.mp4 (${(durationMs / 1000).toFixed(1)}s) · capa.png · metadata.json`)
console.log(
` reel.mp4 (${(durationMs / 1000).toFixed(1)}s) · capa.png · narracao.srt · roteiro.md${audioFiles} · metadata.json`,
)
}
await main().catch((error) => {

View file

@ -16,12 +16,15 @@ import { execFile } from 'node:child_process'
import { writeFile } from 'node:fs/promises'
import { promisify } from 'node:util'
import { NARRATION_PCM } from './reelScript.mjs'
import { seconds } from './reelTimeline.mjs'
const execFileAsync = promisify(execFile)
const MAX_BUFFER_BYTES = 16 * 1024 * 1024
const REQUIRED_FILTERS = ['fade', 'overlay', 'scale', 'zoompan']
const AUDIO_REQUIRED_ENCODER = 'libmp3lame'
const AUDIO_REQUIRED_FILTERS = ['atempo']
export const REEL_PRESET = 'medium'
export const REEL_CRF = 18
export const PREVIEW_PRESET = 'ultrafast'
@ -35,7 +38,13 @@ export const PREVIEW_CRF = 14
* @typedef {(bin: string, args: string[]) => Promise<{ ok: boolean, stdout: string, stderr: string, error?: { code?: string } }>} RunFn
*/
const defaultRun = async (bin, args) => {
/**
* Generic process runner shared by the reel tools (ffmpeg and edge-tts): never
* throws, returns the exit outcome.
*
* @type {RunFn}
*/
export const runProcess = async (bin, args) => {
try {
const { stdout, stderr } = await execFileAsync(bin, args, { maxBuffer: MAX_BUFFER_BYTES })
return { ok: true, stdout, stderr }
@ -44,6 +53,8 @@ const defaultRun = async (bin, args) => {
}
}
const defaultRun = runProcess
const versionLine = (stdout) =>
stdout.split('\n').find((line) => line.startsWith('ffmpeg version')) ?? ''
@ -52,10 +63,10 @@ const versionLine = (stdout) =>
* filters the composer uses. Missing capabilities are reported, never assumed.
*
* @param {string} bin
* @param {{ run?: RunFn }} [options]
* @param {{ run?: RunFn, audio?: boolean }} [options]
* @returns {Promise<{ ok: true, bin: string, version: string } | { ok: false, bin: string, reason: string }>}
*/
export const probeFfmpeg = async (bin, { run = defaultRun } = {}) => {
export const probeFfmpeg = async (bin, { run = defaultRun, audio = false } = {}) => {
const version = await run(bin, ['-hide_banner', '-version'])
if (!version.ok)
return { ok: false, bin, reason: `não executou (${version.error?.code ?? 'erro'})` }
@ -63,9 +74,15 @@ export const probeFfmpeg = async (bin, { run = defaultRun } = {}) => {
if (!encoders.ok || !/\blibx264\b/.test(encoders.stdout)) {
return { ok: false, bin, reason: 'sem encoder libx264' }
}
if (audio && !new RegExp(`\\b${AUDIO_REQUIRED_ENCODER}\\b`).test(encoders.stdout)) {
return { ok: false, bin, reason: `sem encoder ${AUDIO_REQUIRED_ENCODER}` }
}
const filters = await run(bin, ['-hide_banner', '-filters'])
if (!filters.ok) return { ok: false, bin, reason: 'não listou filtros' }
const missing = REQUIRED_FILTERS.filter(
const requiredFilters = audio
? [...REQUIRED_FILTERS, ...AUDIO_REQUIRED_FILTERS]
: REQUIRED_FILTERS
const missing = requiredFilters.filter(
(filter) => !new RegExp(`\\b${filter}\\b`).test(filters.stdout),
)
if (missing.length > 0) return { ok: false, bin, reason: `sem filtro(s): ${missing.join(', ')}` }
@ -78,10 +95,15 @@ const installHint =
/**
* Resolves the first candidate that passes the probe. Explicit `FFMPEG_PATH` is strict.
*
* @param {{ env?: Record<string, string | undefined>, run?: RunFn, installer?: { path?: string } | null }} [options]
* @param {{ env?: Record<string, string | undefined>, run?: RunFn, installer?: { path?: string } | null, audio?: boolean }} [options]
* @returns {Promise<{ ok: true, bin: string, version: string, source: string }>}
*/
export const resolveFfmpeg = async ({ env = process.env, run = defaultRun, installer } = {}) => {
export const resolveFfmpeg = async ({
env = process.env,
run = defaultRun,
installer,
audio = false,
} = {}) => {
let packaged = installer
if (packaged === undefined) {
try {
@ -99,7 +121,7 @@ export const resolveFfmpeg = async ({ env = process.env, run = defaultRun, insta
if (packaged?.path) candidates.push({ bin: packaged.path, source: 'empacotado', strict: false })
const failures = []
for (const candidate of candidates) {
const probe = await probeFfmpeg(candidate.bin, { run })
const probe = await probeFfmpeg(candidate.bin, { run, audio })
if (probe.ok) {
return { ok: true, bin: probe.bin, version: probe.version, source: candidate.source }
}
@ -107,18 +129,20 @@ export const resolveFfmpeg = async ({ env = process.env, run = defaultRun, insta
if (candidate.strict) break
}
throw new Error(
`Nenhum ffmpeg utilizável (libx264 + filtros do reel). ${failures.join('; ')}. ${installHint}`,
`Nenhum ffmpeg utilizável (libx264 + filtros do reel${audio ? ' + áudio' : ''}). ${failures.join('; ')}. ${installHint}`,
)
}
/**
* Runs ffmpeg and fails with the stderr tail — never swallows the exit code.
* Runs a process and fails with the stderr tail — never swallows the exit
* code. Shared by the reel tools (ffmpeg and edge-tts); `runFfmpeg` is the
* ffmpeg-labelled alias kept for the historical call sites.
*
* @param {string} bin
* @param {string[]} args
* @param {{ run?: RunFn, label?: string }} [options]
*/
export const runFfmpeg = async (bin, args, { run = defaultRun, label = 'ffmpeg' } = {}) => {
export const runTool = async (bin, args, { run = defaultRun, label = 'processo' } = {}) => {
const result = await run(bin, args)
if (!result.ok) {
const tail = (result.stderr || result.error?.message || '').trim().slice(-400)
@ -127,6 +151,10 @@ export const runFfmpeg = async (bin, args, { run = defaultRun, label = 'ffmpeg'
return result
}
/** @type {typeof runTool} */
export const runFfmpeg = (bin, args, options = {}) =>
runTool(bin, args, { label: 'ffmpeg', ...options })
/**
* One scene clip: seek the source, apply the zoom camera (when the scene has
* clicks), burn the caption overlays with their timelines and encode H.264.
@ -308,3 +336,82 @@ export const buildConcatArgs = ({ listPath, output }) => [
'-y',
output,
]
/**
* C197 — decode one TTS beat (mp3) to the raw narration PCM, optionally
* applying the `atempo` fit in the same pass.
*
* @param {{ input: string, output: string, tempo?: number | null }} options
* @returns {string[]}
*/
export const buildAudioDecodeArgs = ({ input, output, tempo = null }) => [
'-hide_banner',
'-loglevel',
'error',
'-i',
input,
...(tempo && tempo !== 1 ? ['-filter:a', `atempo=${tempo}`] : []),
'-ac',
String(NARRATION_PCM.channels),
'-ar',
String(NARRATION_PCM.sampleRate),
'-c:a',
'pcm_s16le',
'-f',
's16le',
'-y',
output,
]
/** Encode the padded narration PCM track into `narracao.mp3` (mono 128 kbps). */
export const buildNarrationEncodeArgs = ({ input, output }) => [
'-hide_banner',
'-loglevel',
'error',
'-f',
's16le',
'-ar',
String(NARRATION_PCM.sampleRate),
'-ac',
String(NARRATION_PCM.channels),
'-i',
input,
'-c:a',
'libmp3lame',
'-b:a',
'128k',
'-y',
output,
]
/**
* Mux `narracao.mp3` into a copy of `reel.mp4` — the video stream is copied
* bit-for-bit (both variants share the same track) and the audio becomes AAC.
*
* @param {{ video: string, audio: string, output: string }} options
* @returns {string[]}
*/
export const buildMuxAudioVideoArgs = ({ video, audio, output }) => [
'-hide_banner',
'-loglevel',
'error',
'-i',
video,
'-i',
audio,
'-map',
'0:v:0',
'-map',
'1:a:0',
'-c:v',
'copy',
'-c:a',
'aac',
'-b:a',
'128k',
'-shortest',
'-movflags',
'+faststart',
'-y',
output,
]

View file

@ -1,28 +1,41 @@
/**
* C196 — the reel package metadata: the contract the private library (C195)
* ingests. `shotListHash` identifies the reel version; re-rendering the same
* shot list updates the same entry.
* C196/C197 — the reel package metadata: the contract the private library
* (C195) ingests. `shotListHash` identifies the reel version; re-rendering the
* same shot list updates the same entry. C197 added `coverAlt` (required by
* the ingest), `durationSeconds`, the `voice` of the TTS draft and the
* `artifacts` the package actually ships (the transcription always; the
* audio files when `--audio`).
*/
import { REEL } from './reelTimeline.mjs'
import { REEL, seconds } from './reelTimeline.mjs'
/**
* @param {{ shotList: { slug: string, title: string, feature: string, coverAlt?: string }, hash: string, durationMs: number, ffmpeg: string, generatedAt?: Date, audio?: boolean, voice?: string | null, artifacts?: string[] }} options
*/
export const buildReelMetadata = ({
shotList,
hash,
durationMs,
ffmpeg,
generatedAt = new Date(),
audio = false,
voice = null,
artifacts = [],
}) => ({
slug: shotList.slug,
title: shotList.title,
feature: shotList.feature,
shotListHash: hash,
coverAlt: shotList.coverAlt,
durationMs: Math.round(durationMs),
durationSeconds: seconds(durationMs),
width: REEL.width,
height: REEL.height,
fps: REEL.fps,
codec: 'h264',
audio: false,
audio,
...(audio && voice ? { voice } : {}),
artifacts: [...artifacts],
generatedAt: generatedAt.toISOString(),
ffmpeg,
})

170
scripts/lib/reelScript.mjs Normal file
View file

@ -0,0 +1,170 @@
/**
* C197 — the script layer of the reel: the spoken text of each beat, the
* `narracao.srt` + `roteiro.md` the assessoria re-records from, and the PCM
* math that fits the TTS draft into the exact video duration.
*
* The transcription is generated for every package (with or without TTS), so
* this module never touches ffmpeg, the browser or the network: it is pure and
* unit-tested. The narration text is the approved shot list text — the skill
* never paraphrases.
*/
/** PCM format the narration beat is decoded to: s16le, 24 kHz, mono. */
export const NARRATION_PCM = {
sampleRate: 24000,
channels: 1,
bytesPerSample: 2,
}
/** `atempo` ceiling: a draft sped up further loses intelligibility. */
export const MAX_BEAT_TEMPO = 1.5
/**
* Spoken text of one scene: the explicit `narration` when declared, the burned
* caption of a capture scene as fallback, silence otherwise (graphic scenes
* without narration).
*
* @param {{ kind: string, template?: string, narration?: string | null, caption?: { parts: Array<{ text: string }> } | null }} scene
* @returns {string | null}
*/
export const resolveSceneNarration = (scene) => {
if (typeof scene.narration === 'string' && scene.narration.trim() !== '') {
return scene.narration.trim()
}
if (scene.kind === 'capture' && scene.caption) {
const text = scene.caption.parts
.map((part) => part.text)
.join('')
.trim()
return text === '' ? null : text
}
return null
}
/**
* One beat per scene, in `shotList.scenes` order (the concat order), with the
* cumulative window of the final video. Scenes without speech stay in the list
* with `text: null` — their window is filled with silence on the audio track.
*
* @param {{ shotList: { scenes: Array<Record<string, unknown>> }, plans: Map<string, { durationMs: number }> }} options
* @returns {Array<{ id: string, kind: string, label: string, startMs: number, endMs: number, durationMs: number, text: string | null }>}
*/
export const buildScriptBeats = ({ shotList, plans }) => {
let cursorMs = 0
return shotList.scenes.map((scene) => {
const plan = plans.get(scene.id)
if (!plan) throw new Error(`Roteiro: cena "${scene.id}" sem plano de duração.`)
const durationMs = Math.max(0, Math.round(plan.durationMs))
const beat = {
id: scene.id,
kind: scene.kind,
label: scene.kind === 'capture' ? scene.badge.label : scene.template,
startMs: cursorMs,
endMs: cursorMs + durationMs,
durationMs,
text: resolveSceneNarration(scene),
}
cursorMs += durationMs
return beat
})
}
const pad = (value, size) => String(value).padStart(size, '0')
/** SRT timecode: `HH:MM:SS,mmm` (rounded to the millisecond). */
export const formatSrtTime = (ms) => {
const total = Math.max(0, Math.round(ms))
const hours = Math.floor(total / 3_600_000)
const minutes = Math.floor((total % 3_600_000) / 60_000)
const seconds = Math.floor((total % 60_000) / 1_000)
const millis = total % 1_000
return `${pad(hours, 2)}:${pad(minutes, 2)}:${pad(seconds, 2)},${pad(millis, 3)}`
}
/**
* `narracao.srt` content: one block per beat that has speech, timecodes from
* the scene window (simple by design — no word-level timing).
*
* @param {Array<{ startMs: number, endMs: number, text: string | null }>} beats
* @returns {string}
*/
export const buildReelSrt = (beats) => {
const blocks = []
for (const beat of beats) {
if (!beat.text) continue
blocks.push(
`${blocks.length + 1}\n${formatSrtTime(beat.startMs)} --> ${formatSrtTime(beat.endMs)}\n${beat.text}`,
)
}
return blocks.length === 0 ? '' : `${blocks.join('\n\n')}\n`
}
/** Readable pt-BR seconds label (`19,4s`) shared by the roteiro and the TTS errors. */
export const secondsLabel = (ms) => `${(ms / 1000).toFixed(1).replace('.', ',')}s`
/**
* `roteiro.md` content: read aloud by the assessoria, with beat/scene marks and
* the window of each scene. Mute scenes are marked, never dropped.
*
* @param {{ shotList: { slug: string, title: string }, hash: string, beats: Array<{ id: string, label: string, startMs: number, endMs: number, text: string | null }>, durationMs: number }} options
* @returns {string}
*/
export const buildReelRoteiro = ({ shotList, hash, beats, durationMs }) => {
const lines = [
`# Roteiro — ${shotList.title}`,
'',
`- Reel: \`${shotList.slug}\``,
`- Shot list: \`${hash}\``,
`- Duração: ${secondsLabel(durationMs)} (${beats.length} cenas)`,
'',
'Transcrição do roteiro aprovado no gate. A assessoria pode regravar com a própria voz.',
'',
]
beats.forEach((beat, index) => {
lines.push(
`## ${index + 1}. ${beat.id} — ${secondsLabel(beat.startMs)}–${secondsLabel(beat.endMs)} · ${beat.label}`,
)
lines.push('')
lines.push(beat.text ?? '_Sem fala nesta cena._')
lines.push('')
})
return `${lines.join('\n').trimEnd()}\n`
}
/** Sample count of a duration in the narration PCM format (exact, no ffprobe). */
export const pcmSampleCount = (durationMs, pcm = NARRATION_PCM) =>
Math.max(0, Math.round((durationMs / 1000) * pcm.sampleRate))
/** Byte length of one PCM sample count. */
export const pcmByteLength = (samples, pcm = NARRATION_PCM) =>
samples * pcm.channels * pcm.bytesPerSample
/** Exact duration (ms) of a decoded PCM buffer — the ffprobe substitute. */
export const pcmDurationMs = (byteLength, pcm = NARRATION_PCM) =>
(byteLength / (pcm.sampleRate * pcm.channels * pcm.bytesPerSample)) * 1000
/**
* Speed factor that fits a synthesized beat into its scene window. Never slows
* down (a short line is padded with silence); beyond `maxTempo` the fit is
* impossible and the caller fails closed.
*
* @param {{ durationMs: number, targetMs: number, maxTempo?: number }} options
* @returns {{ tempo: number, overflow: boolean }}
*/
export const fitBeat = ({ durationMs, targetMs, maxTempo = MAX_BEAT_TEMPO }) => {
if (!(targetMs > 0)) throw new Error(`Fit da fala: janela inválida (${targetMs}ms).`)
const ratio = durationMs / targetMs
if (ratio <= 1) return { tempo: 1, overflow: false }
const tempo = Math.round(Math.min(ratio, maxTempo) * 10_000) / 10_000
return { tempo, overflow: ratio > maxTempo + 1e-9 }
}
/** PCM buffer exactly `samples` long: zero-padded or trimmed to the boundary. */
export const padPcmToSamples = (pcm, samples) => {
const targetBytes = pcmByteLength(samples)
if (pcm.length === targetBytes) return pcm
if (pcm.length > targetBytes) return pcm.subarray(0, targetBytes)
const padded = Buffer.alloc(targetBytes)
pcm.copy(padded)
return padded
}

View file

@ -9,6 +9,11 @@
* (paced clicks/typing that the viewer sees), plus the burned caption and the
* step badge of the design artifact.
*
* C197 adds the spoken script: an optional `narration` per scene (capture
* scenes fall back to the burned caption, graphic scenes are silent without
* one) and the top-level `coverAlt` of the cover. Both enter the normalized
* object, so the shot list hash identifies them too.
*
* Pure module: no browser, no ffmpeg, no I/O beyond reading the JSON file.
*/
@ -18,16 +23,19 @@ import { resolve } from 'node:path'
import { sha256Hex } from './cli.mjs'
const SHOT_LIST_DIR = 'scripts/reels/shot-lists'
const NARRATION_MAX_LENGTH = 600
/**
* @typedef {{
* slug: string, title: string, feature: string, route: string,
* coverAlt: string,
* fixture: Record<string, unknown>,
* scenes: Array<{
* id: string, kind: string,
* template?: string, durationMs?: number,
* badge?: { number: number, total: number, label: string },
* caption?: { parts: Array<{ text: string, accent?: boolean }> } | null,
* narration?: string | null,
* setup: Array<{ action: string, selector: string }>,
* steps: Array<{ action: string, selector: string, value?: string, pauseMs?: number, zoom?: boolean }>,
* leadInMs: number, trailOutMs: number, captionDelayMs: number, captionDurationMs: number | null,
@ -50,9 +58,9 @@ const assertObject = (value, label) => {
}
}
const assertString = (value, label) => {
const assertString = (value, label, { trim = true } = {}) => {
if (typeof value !== 'string' || value.trim() === '') fail(`${label} deve ser string não vazia.`)
return value.trim()
return trim ? value.trim() : value
}
const assertPositiveInt = (value, label, { max = 120000 } = {}) => {
@ -74,12 +82,23 @@ const normalizeCaption = (raw, label) => {
if (!Array.isArray(raw.parts) || raw.parts.length === 0) fail(`${label}.parts deve ter itens.`)
const parts = raw.parts.map((part, index) => {
assertObject(part, `${label}.parts[${index}]`)
const text = assertString(part.text, `${label}.parts[${index}].text`)
// The authored spacing between parts is part of the caption AND of the
// narration fallback — validated, never trimmed away.
const text = assertString(part.text, `${label}.parts[${index}].text`, { trim: false })
return part.accent === true ? { text, accent: true } : { text }
})
return { parts }
}
const normalizeNarration = (raw, label) => {
if (raw === undefined || raw === null) return null
const text = assertString(raw, label)
if (text.length > NARRATION_MAX_LENGTH) {
fail(`${label} excede ${NARRATION_MAX_LENGTH} caracteres.`)
}
return text
}
const normalizeBadge = (raw, label) => {
assertObject(raw, label)
const number = assertPositiveInt(raw.number, `${label}.number`, { max: 99 })
@ -142,6 +161,7 @@ const normalizeCaptureScene = (scene, index) => {
kind: 'capture',
badge: normalizeBadge(scene.badge, `${label}.badge`),
caption: normalizeCaption(scene.caption, `${label}.caption`),
narration: normalizeNarration(scene.narration, `${label}.narration`),
setup: normalizeSetup(scene.setup, `${label}.setup`),
steps: normalizeSteps(scene.steps, `${label}.steps`),
leadInMs:
@ -176,6 +196,7 @@ const normalizeGraphicScene = (scene, index) => {
kind: 'graphic',
template,
durationMs: assertPositiveInt(scene.durationMs, `${label}.durationMs`, { max: 15000 }),
narration: normalizeNarration(scene.narration, `${label}.narration`),
}
}
@ -217,11 +238,16 @@ export const normalizeShotList = (raw) => {
fail('a primeira cena deve ser o hook.')
const last = scenes[scenes.length - 1]
if (last.kind !== 'graphic' || last.template !== 'cta') fail('a última cena deve ser o CTA.')
const title = assertString(raw.title ?? slug, 'title')
return {
slug,
title: assertString(raw.title ?? slug, 'title'),
title,
feature: assertString(raw.feature ?? slug, 'feature'),
route,
coverAlt:
raw.coverAlt === undefined || raw.coverAlt === null
? title
: assertString(raw.coverAlt, 'coverAlt'),
fixture,
scenes,
}

228
scripts/lib/reelTts.mjs Normal file
View file

@ -0,0 +1,228 @@
/**
* C197 — the TTS draft provider of the reel: resolves the `edge-tts`
* executable fail-closed (explicit path → dedicated venv → PATH), synthesizes
* one beat per scene and builds the narration track at the exact duration of
* the video (decode → measure PCM → `atempo` fit → silence pad → concat).
*
* `edge-tts` is Microsoft's free neural TTS, used here **only** for an
* institutional draft: the voice is never cloned and the final decision is
* human. The provider lives outside the npm tree (dedicated Python venv,
* `pnpm reels:tts:setup`) so the Docker deploy's `--frozen-lockfile` install
* never touches it.
*
* I/O is injectable (`run`, `readFileImpl`, `writeFileImpl`, `synthesize`) so
* the orchestration is unit-tested without processes or network.
*/
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import {
buildAudioDecodeArgs,
buildNarrationEncodeArgs,
runFfmpeg,
runProcess,
runTool,
} from './reelFfmpeg.mjs'
import {
fitBeat,
MAX_BEAT_TEMPO,
padPcmToSamples,
pcmDurationMs,
pcmSampleCount,
secondsLabel,
} from './reelScript.mjs'
/**
* @typedef {(bin: string, args: string[]) => Promise<{ ok: boolean, stdout: string, stderr: string, error?: { code?: string } }>} RunFn
*/
/**
* @typedef {(path: string) => Promise<Buffer>} ReadFileFn
* @typedef {(path: string, data: string | Buffer) => Promise<unknown>} WriteFileFn
*/
/** Dedicated venv of the TTS provider (gitignored; never part of the deploy). */
export const TTS_VENV_DIR = 'scripts/reels/.venv'
/** Institutional draft voice; override with `--voice=` or `REELS_TTS_VOICE`. */
export const DEFAULT_TTS_VOICE = 'pt-BR-AntonioNeural'
const venvBin = (root) => join(root, TTS_VENV_DIR, 'bin', 'edge-tts')
/**
* First `edge-tts` candidate that answers `--help`: explicit `EDGE_TTS_PATH`
* (strict), the dedicated venv, then PATH. Fails with the setup hint.
*
* @param {{ env?: Record<string, string | undefined>, root?: string, run?: RunFn }} [options]
* @returns {Promise<{ bin: string, source: string }>}
*/
export const resolveEdgeTts = async ({
env = process.env,
root = process.cwd(),
run = runProcess,
} = {}) => {
const candidates = []
const explicit = env.EDGE_TTS_PATH?.trim()
if (explicit) candidates.push({ bin: explicit, source: 'EDGE_TTS_PATH', strict: true })
candidates.push({ bin: venvBin(root), source: 'venv', strict: false })
candidates.push({ bin: 'edge-tts', source: 'PATH', strict: false })
const failures = []
for (const candidate of candidates) {
const probe = await run(candidate.bin, ['--help'])
if (probe.ok) return { bin: candidate.bin, source: candidate.source }
failures.push(`${candidate.source}: ${probe.error?.code ?? 'erro'}`)
if (candidate.strict) break
}
throw new Error(
`Nenhum edge-tts utilizável (${failures.join('; ')}). ` +
'Rode `pnpm reels:tts:setup` para criar o venv do provider ou defina EDGE_TTS_PATH.',
)
}
/**
* Creates the dedicated venv and installs/updates `edge-tts` (idempotent).
*
* @param {{ root?: string, run?: RunFn, python?: string }} [options]
* @returns {Promise<{ bin: string, source: string }>}
*/
export const setupEdgeTts = async ({
root = process.cwd(),
run = runProcess,
python = 'python3',
} = {}) => {
const venvDir = join(root, TTS_VENV_DIR)
await runTool(python, ['-m', 'venv', venvDir], { run, label: 'python -m venv' })
await runTool(join(venvDir, 'bin', 'pip'), ['install', '--upgrade', 'edge-tts'], {
run,
label: 'pip install edge-tts',
})
return resolveEdgeTts({ root, run })
}
/**
* Synthesizes one beat to mp3. The spoken text goes through a file (`--file`),
* never through argv: quotes/emoji/newlines in the script are data, not shell.
*
* @param {{ edgeTtsBin: string, text: string, voice: string, textPath: string, output: string, run?: RunFn, writeFileImpl?: WriteFileFn }} options
*/
export const synthesizeBeat = async ({
edgeTtsBin,
text,
voice,
textPath,
output,
run = runProcess,
writeFileImpl = writeFile,
}) => {
await writeFileImpl(textPath, `${text}\n`)
await runTool(edgeTtsBin, ['--voice', voice, '--file', textPath, '--write-media', output], {
run,
label: `edge-tts (${voice})`,
})
}
/**
* One beat as PCM exactly as long as its scene window: decode the mp3, measure
* it (PCM bytes — the packaged ffmpeg has no ffprobe), speed it up with
* `atempo` when needed and pad/trim to the window. Fails closed when the line
* does not fit under the intelligibility ceiling.
*
* @param {{ ffmpegBin: string, mp3Path: string, id: string, targetMs: number, workDir: string, run?: RunFn, readFileImpl?: ReadFileFn }} options
* @returns {Promise<Buffer>}
*/
export const buildBeatPcm = async ({
ffmpegBin,
mp3Path,
id,
targetMs,
workDir,
run = runProcess,
readFileImpl = readFile,
}) => {
const pcmPath = join(workDir, `${id}.pcm`)
await runFfmpeg(ffmpegBin, buildAudioDecodeArgs({ input: mp3Path, output: pcmPath }), {
run,
label: `decodificar fala ${id}`,
})
const raw = await readFileImpl(pcmPath)
const durationMs = pcmDurationMs(raw.length)
const { tempo, overflow } = fitBeat({ durationMs, targetMs })
if (overflow) {
throw new Error(
`A fala da cena "${id}" dura ${secondsLabel(durationMs)} e não cabe na cena ` +
`(${secondsLabel(targetMs)}, máximo ${secondsLabel(targetMs * MAX_BEAT_TEMPO)} com o atempo). ` +
'Encurte a fala desta cena no shot list (ou divida a cena) e regenere.',
)
}
let pcm = raw
if (tempo !== 1) {
const fitPath = join(workDir, `${id}-fit.pcm`)
await runFfmpeg(ffmpegBin, buildAudioDecodeArgs({ input: mp3Path, output: fitPath, tempo }), {
run,
label: `ajustar fala ${id} (atempo ${tempo})`,
})
pcm = await readFileImpl(fitPath)
}
return padPcmToSamples(pcm, pcmSampleCount(targetMs))
}
/**
* The whole narration track: one synthesized beat per scene, silence for mute
* scenes, concatenated to the exact video duration and encoded to
* `narracao.mp3`.
*
* @param {{ ffmpegBin: string, edgeTtsBin: string, voice: string, beats: Array<{ id: string, durationMs: number, text: string | null }>, workDir: string, mp3Path: string, run?: RunFn, readFileImpl?: ReadFileFn, writeFileImpl?: WriteFileFn, synthesize?: typeof synthesizeBeat }} options
* @returns {Promise<{ durationMs: number }>}
*/
export const buildNarrationTrack = async ({
ffmpegBin,
edgeTtsBin,
voice,
beats,
workDir,
mp3Path,
run = runProcess,
readFileImpl = readFile,
writeFileImpl = writeFile,
synthesize = synthesizeBeat,
}) => {
const audioDir = join(workDir, 'audio')
await mkdir(audioDir, { recursive: true })
const parts = []
for (const beat of beats) {
if (beat.text === null) {
parts.push(padPcmToSamples(Buffer.alloc(0), pcmSampleCount(beat.durationMs)))
continue
}
const beatMp3 = join(audioDir, `${beat.id}.mp3`)
await synthesize({
edgeTtsBin,
text: beat.text,
voice,
textPath: join(audioDir, `${beat.id}.txt`),
output: beatMp3,
run,
writeFileImpl,
})
parts.push(
await buildBeatPcm({
ffmpegBin,
mp3Path: beatMp3,
id: beat.id,
targetMs: beat.durationMs,
workDir: audioDir,
run,
readFileImpl,
}),
)
}
const track = Buffer.concat(parts)
const pcmPath = join(audioDir, 'narracao.pcm')
await writeFileImpl(pcmPath, track)
await runFfmpeg(ffmpegBin, buildNarrationEncodeArgs({ input: pcmPath, output: mp3Path }), {
run,
label: 'encode da narração',
})
return { durationMs: pcmDurationMs(track.length) }
}

View file

@ -86,8 +86,10 @@ export const SCRIPTS_SPEC_PINNED = [
'scripts/lib/reel-ingest.mjs',
'scripts/lib/reelFfmpeg.mjs',
'scripts/lib/reelPackage.mjs',
'scripts/lib/reelScript.mjs',
'scripts/lib/reelShotList.mjs',
'scripts/lib/reelTimeline.mjs',
'scripts/lib/reelTts.mjs',
'scripts/lib/reportText.mjs',
'scripts/lib/seed-minimal-manifest.mjs',
'scripts/lib/sollaCeuciSalvadorMetrics.mjs',

View file

@ -0,0 +1,22 @@
/**
* C197 — `pnpm reels:tts:setup`: prepares the dedicated venv of the TTS draft
* provider (edge-tts). Idempotent: re-running upgrades into the same venv.
*/
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { dieWithLabel } from './lib/cli.mjs'
import { setupEdgeTts } from './lib/reelTts.mjs'
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
const die = dieWithLabel('reels:tts:setup')
setupEdgeTts({ root: ROOT })
.then((result) => {
console.log(`[reels:tts:setup] edge-tts pronto: ${result.bin}`)
console.log('[reels:tts:setup] rascunho narrado: pnpm reels:build <slug> --audio')
})
.catch((error) => {
die(error instanceof Error ? error.message : String(error))
})

View file

@ -3,6 +3,7 @@
"title": "Crie seu card de apoio",
"feature": "cards",
"route": "/",
"coverAlt": "Capa do tutorial com o título “Crie seu card de apoio” sobre o fundo vermelho da campanha",
"fixture": {
"cardName": "Joana"
},
@ -11,7 +12,8 @@
"id": "hook",
"kind": "graphic",
"template": "hook",
"durationMs": 2400
"durationMs": 2400,
"narration": "Seu apoio vira card em segundos."
},
{
"id": "step-1",
@ -80,7 +82,8 @@
"id": "cta",
"kind": "graphic",
"template": "cta",
"durationMs": 3200
"durationMs": 3200,
"narration": "Acesse o site pelo link na bio e crie o seu."
}
]
}

View file

@ -1,8 +1,11 @@
import { describe, expect, it } from 'vitest'
import {
buildAudioDecodeArgs,
buildConcatArgs,
buildFrameListContent,
buildMuxAudioVideoArgs,
buildNarrationEncodeArgs,
buildSceneClipArgs,
concatListContent,
encodeFrames,
@ -74,6 +77,39 @@ describe('reelFfmpeg', () => {
})
})
it('requires libmp3lame and atempo only when probing for audio', async () => {
await expect(probeFfmpeg('/ffmpeg', { run: okRun, audio: true })).resolves.toMatchObject({
ok: false,
reason: 'sem encoder libmp3lame',
})
const audioRun = async (_bin: string, args: string[]): Promise<RunResult> => {
if (args.includes('-version'))
return { ok: true, stdout: 'ffmpeg version 7.0.2-test\n', stderr: '' }
if (args.includes('-encoders'))
return { ok: true, stdout: ' V....D libx264 H.264\n A....D libmp3lame MP3\n', stderr: '' }
return {
ok: true,
stdout: 'overlay\nfade\nscale\nzoompan\natempo A->A\n',
stderr: '',
}
}
await expect(probeFfmpeg('/ffmpeg', { run: audioRun, audio: true })).resolves.toMatchObject({
ok: true,
})
const noAtempo = async (_bin: string, args: string[]): Promise<RunResult> => {
if (args.includes('-version')) return { ok: true, stdout: 'ffmpeg version x\n', stderr: '' }
if (args.includes('-encoders'))
return { ok: true, stdout: 'libx264\nlibmp3lame\n', stderr: '' }
return { ok: true, stdout: 'overlay\nfade\nscale\nzoompan\n', stderr: '' }
}
await expect(probeFfmpeg('/ffmpeg', { run: noAtempo, audio: true })).resolves.toMatchObject({
ok: false,
reason: 'sem filtro(s): atempo',
})
})
it('resolves FFMPEG_PATH strictly, then PATH, then the packaged binary', async () => {
const explicit = await resolveFfmpeg({
env: { FFMPEG_PATH: '/custom/ffmpeg' },
@ -180,6 +216,80 @@ describe('reelFfmpeg', () => {
])
})
it('builds the decode, encode and mux commands of the narration track', () => {
expect(buildAudioDecodeArgs({ input: 'in.mp3', output: 'out.pcm' })).toEqual([
'-hide_banner',
'-loglevel',
'error',
'-i',
'in.mp3',
'-ac',
'1',
'-ar',
'24000',
'-c:a',
'pcm_s16le',
'-f',
's16le',
'-y',
'out.pcm',
])
const fitted = buildAudioDecodeArgs({ input: 'in.mp3', output: 'fit.pcm', tempo: 1.5 })
expect(fitted).toContain('atempo=1.5')
expect(fitted[fitted.indexOf('-filter:a') - 1]).toBe('in.mp3')
expect(buildNarrationEncodeArgs({ input: 'narracao.pcm', output: 'narracao.mp3' })).toEqual([
'-hide_banner',
'-loglevel',
'error',
'-f',
's16le',
'-ar',
'24000',
'-ac',
'1',
'-i',
'narracao.pcm',
'-c:a',
'libmp3lame',
'-b:a',
'128k',
'-y',
'narracao.mp3',
])
expect(
buildMuxAudioVideoArgs({
video: 'reel.mp4',
audio: 'narracao.mp3',
output: 'reel-audio.mp4',
}),
).toEqual([
'-hide_banner',
'-loglevel',
'error',
'-i',
'reel.mp4',
'-i',
'narracao.mp3',
'-map',
'0:v:0',
'-map',
'1:a:0',
'-c:v',
'copy',
'-c:a',
'aac',
'-b:a',
'128k',
'-shortest',
'-movflags',
'+faststart',
'-y',
'reel-audio.mp4',
])
})
it('writes frame durations from capture deltas and repeats the last frame', () => {
const content = buildFrameListContent({
frames: [

View file

@ -0,0 +1,174 @@
import { describe, expect, it } from 'vitest'
import {
buildReelRoteiro,
buildReelSrt,
buildScriptBeats,
fitBeat,
formatSrtTime,
MAX_BEAT_TEMPO,
NARRATION_PCM,
padPcmToSamples,
pcmByteLength,
pcmDurationMs,
pcmSampleCount,
resolveSceneNarration,
} from '../../scripts/lib/reelScript.mjs'
// C197 — the spoken script is pure: the capture fallback is the burned caption
// (authored spacing preserved), the windows follow the concat order, the SRT is
// one block per beat and the PCM fit never truncates below the scene window.
const capture = (overrides: Record<string, unknown> = {}) => ({
id: 'step-1',
kind: 'capture',
badge: { number: 1, total: 3, label: 'Escolha um modelo' },
narration: null,
caption: { parts: [{ text: 'Toque em ' }, { text: '“Card”', accent: true }, { text: '.' }] },
...overrides,
})
describe('resolveSceneNarration', () => {
it('prefers the explicit narration', () => {
expect(resolveSceneNarration(capture({ narration: ' Fale assim. ' }))).toBe('Fale assim.')
})
it('falls back to the burned caption of a capture scene', () => {
expect(resolveSceneNarration(capture())).toBe('Toque em “Card”.')
})
it('keeps graphic scenes silent without narration', () => {
expect(resolveSceneNarration({ kind: 'graphic', template: 'hook' })).toBeNull()
expect(resolveSceneNarration({ kind: 'graphic', template: 'hook', narration: 'Olá.' })).toBe(
'Olá.',
)
})
})
describe('buildScriptBeats', () => {
it('walks the scenes in concat order with cumulative windows', () => {
const shotList = {
scenes: [
{ id: 'hook', kind: 'graphic', template: 'hook', narration: 'Abertura.' },
capture(),
{ id: 'cta', kind: 'graphic', template: 'cta', narration: null },
],
}
const plans = new Map([
['cta', { durationMs: 3_200 }],
['hook', { durationMs: 2_400 }],
['step-1', { durationMs: 5_000 }],
])
expect(buildScriptBeats({ shotList, plans })).toEqual([
{
id: 'hook',
kind: 'graphic',
label: 'hook',
startMs: 0,
endMs: 2_400,
durationMs: 2_400,
text: 'Abertura.',
},
{
id: 'step-1',
kind: 'capture',
label: 'Escolha um modelo',
startMs: 2_400,
endMs: 7_400,
durationMs: 5_000,
text: 'Toque em “Card”.',
},
{
id: 'cta',
kind: 'graphic',
label: 'cta',
startMs: 7_400,
endMs: 10_600,
durationMs: 3_200,
text: null,
},
])
})
it('fails closed when a scene has no plan', () => {
const shotList = { scenes: [{ id: 'hook', kind: 'graphic', template: 'hook' }] }
expect(() => buildScriptBeats({ shotList, plans: new Map() })).toThrow(/sem plano de duração/)
})
})
describe('SRT', () => {
it('formats timecodes as HH:MM:SS,mmm', () => {
expect(formatSrtTime(0)).toBe('00:00:00,000')
expect(formatSrtTime(999)).toBe('00:00:00,999')
expect(formatSrtTime(1_000)).toBe('00:00:01,000')
expect(formatSrtTime(3_661_007)).toBe('01:01:01,007')
expect(formatSrtTime(-5)).toBe('00:00:00,000')
})
it('emits one block per spoken beat, skipping mute scenes', () => {
const beats = [
{ startMs: 0, endMs: 2_400, text: 'Abertura.' },
{ startMs: 2_400, endMs: 7_400, text: 'Toque em “Card”.' },
{ startMs: 7_400, endMs: 10_600, text: null },
]
expect(buildReelSrt(beats)).toBe(
'1\n00:00:00,000 --> 00:00:02,400\nAbertura.\n\n2\n00:00:02,400 --> 00:00:07,400\nToque em “Card”.\n',
)
})
it('is empty when no scene has speech', () => {
expect(buildReelSrt([{ startMs: 0, endMs: 1_000, text: null }])).toBe('')
})
})
describe('roteiro.md', () => {
it('marks every scene with its window and speech', () => {
const roteiro = buildReelRoteiro({
shotList: { slug: 'cards', title: 'Crie seu card de apoio' },
hash: 'a'.repeat(64),
durationMs: 10_600,
beats: [
{ id: 'hook', label: 'hook', startMs: 0, endMs: 2_400, text: 'Abertura.' },
{ id: 'cta', label: 'cta', startMs: 2_400, endMs: 10_600, text: null },
],
})
expect(roteiro).toContain('# Roteiro — Crie seu card de apoio')
expect(roteiro).toContain(`- Shot list: \`${'a'.repeat(64)}\``)
expect(roteiro).toContain('- Duração: 10,6s (2 cenas)')
expect(roteiro).toContain('## 1. hook — 0,0s–2,4s · hook')
expect(roteiro).toContain('## 2. cta — 2,4s–10,6s · cta')
expect(roteiro).toContain('_Sem fala nesta cena._')
})
})
describe('PCM fit', () => {
it('converts between ms, samples and bytes exactly', () => {
expect(NARRATION_PCM).toEqual({ sampleRate: 24000, channels: 1, bytesPerSample: 2 })
expect(pcmSampleCount(1_000)).toBe(24_000)
expect(pcmByteLength(24_000)).toBe(48_000)
expect(pcmDurationMs(48_000)).toBe(1_000)
})
it('never slows a short line and caps the speed-up', () => {
expect(fitBeat({ durationMs: 2_000, targetMs: 3_000 })).toEqual({ tempo: 1, overflow: false })
expect(fitBeat({ durationMs: 3_000, targetMs: 2_000 })).toEqual({ tempo: 1.5, overflow: false })
expect(fitBeat({ durationMs: 4_000, targetMs: 2_000 })).toEqual({
tempo: MAX_BEAT_TEMPO,
overflow: true,
})
})
it('refuses an invalid scene window', () => {
expect(() => fitBeat({ durationMs: 1_000, targetMs: 0 })).toThrow(/janela inválida/)
})
it('pads and trims the PCM to the exact sample boundary', () => {
const samples = pcmSampleCount(1_000)
const shorter = Buffer.alloc(24_000)
expect(padPcmToSamples(shorter, samples)).toHaveLength(48_000)
const longer = Buffer.alloc(60_000)
expect(padPcmToSamples(longer, samples)).toHaveLength(48_000)
const exact = Buffer.alloc(48_000)
expect(padPcmToSamples(exact, samples)).toBe(exact)
})
})

View file

@ -118,4 +118,60 @@ describe('reelShotList', () => {
}
expect(() => normalizeShotList({ ...withoutFixture, scenes })).toThrow(/fixture.cardName/)
})
it('normalizes the narration and preserves the authored caption spacing', () => {
const shotList = normalizeShotList(
rawShotList({
coverAlt: 'Alt próprio da capa',
scenes: [
{ id: 'hook', kind: 'graphic', template: 'hook', durationMs: 1000, narration: ' Olá.' },
{
id: 'step-1',
kind: 'capture',
badge: { number: 1, total: 3, label: 'Escolha' },
caption: { parts: [{ text: 'Toque em ' }, { text: '“Card”', accent: true }] },
narration: 'Fale por cima.',
steps: [{ action: 'click', selector: '#a' }],
},
{ id: 'cta', kind: 'graphic', template: 'cta', durationMs: 1000 },
],
}),
)
expect(shotList.coverAlt).toBe('Alt próprio da capa')
expect(shotList.scenes[0]).toMatchObject({ narration: 'Olá.' })
expect(captureScenes(shotList)[0]).toMatchObject({ narration: 'Fale por cima.' })
expect(shotList.scenes[2]).toMatchObject({ narration: null })
expect(captureScenes(shotList)[0].caption?.parts[0].text).toBe('Toque em ')
})
it('falls back coverAlt to the title and hashes the narration', () => {
const shotList = normalizeShotList(rawShotList())
expect(shotList.coverAlt).toBe(shotList.title)
const scenes = (rawShotList().scenes as Array<Record<string, unknown>>).map((scene, index) =>
index === 0 ? { ...scene, narration: 'Nova fala.' } : scene,
)
const withSpeech = normalizeShotList(rawShotList({ coverAlt: 'x', scenes }))
expect(shotListHash(withSpeech)).not.toBe(shotListHash(shotList))
})
it('rejects an over-long narration', () => {
expect(() =>
normalizeShotList(
rawShotList({
scenes: [
{
id: 'hook',
kind: 'graphic',
template: 'hook',
durationMs: 1000,
narration: 'a'.repeat(601),
},
rawShotList().scenes[1],
rawShotList().scenes[2],
],
}),
),
).toThrow(/narration excede 600 caracteres/)
})
})

View file

@ -0,0 +1,201 @@
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import {
buildBeatPcm,
buildNarrationTrack,
DEFAULT_TTS_VOICE,
resolveEdgeTts,
setupEdgeTts,
synthesizeBeat,
TTS_VENV_DIR,
} from '../../scripts/lib/reelTts.mjs'
// C197 — the TTS draft provider: resolution is fail-closed (explicit path →
// dedicated venv → PATH), the spoken text never rides argv, and the track is
// padded/trimmed to the exact video duration (silence for mute scenes).
type RunResult = { ok: boolean; stdout: string; stderr: string; error?: { code?: string } }
const venvBin = join('/repo', TTS_VENV_DIR, 'bin', 'edge-tts')
describe('resolveEdgeTts', () => {
it('uses EDGE_TTS_PATH strictly: a broken explicit path never falls back', async () => {
const run = async (bin: string): Promise<RunResult> =>
bin === '/custom/edge-tts'
? { ok: false, stdout: '', stderr: '', error: { code: 'EACCES' } }
: { ok: true, stdout: '', stderr: '' }
await expect(
resolveEdgeTts({ env: { EDGE_TTS_PATH: '/custom/edge-tts' }, root: '/repo', run }),
).rejects.toThrow(/Nenhum edge-tts utilizável/)
})
it('falls back to the dedicated venv, then to PATH', async () => {
const venv = await resolveEdgeTts({
env: {},
root: '/repo',
run: async (bin: string): Promise<RunResult> =>
bin === venvBin
? { ok: true, stdout: '', stderr: '' }
: { ok: false, stdout: '', stderr: '', error: { code: 'ENOENT' } },
})
expect(venv).toEqual({ bin: venvBin, source: 'venv' })
const path = await resolveEdgeTts({
env: {},
root: '/repo',
run: async (bin: string): Promise<RunResult> =>
bin === 'edge-tts'
? { ok: true, stdout: '', stderr: '' }
: { ok: false, stdout: '', stderr: '', error: { code: 'ENOENT' } },
})
expect(path).toEqual({ bin: 'edge-tts', source: 'PATH' })
})
it('fails with the setup hint when nothing is usable', async () => {
await expect(
resolveEdgeTts({
env: {},
root: '/repo',
run: async (): Promise<RunResult> => ({
ok: false,
stdout: '',
stderr: '',
error: { code: 'ENOENT' },
}),
}),
).rejects.toThrow(/pnpm reels:tts:setup/)
})
})
describe('setupEdgeTts', () => {
it('creates the venv, installs edge-tts and returns the resolved binary', async () => {
const calls: Array<{ bin: string; args: string[] }> = []
const run = async (bin: string, args: string[]): Promise<RunResult> => {
calls.push({ bin, args })
return { ok: true, stdout: '', stderr: '' }
}
const result = await setupEdgeTts({ root: '/repo', run })
expect(calls[0]).toEqual({ bin: 'python3', args: ['-m', 'venv', join('/repo', TTS_VENV_DIR)] })
expect(calls[1]).toEqual({
bin: join('/repo', TTS_VENV_DIR, 'bin', 'pip'),
args: ['install', '--upgrade', 'edge-tts'],
})
expect(result).toEqual({ bin: venvBin, source: 'venv' })
})
})
describe('synthesizeBeat', () => {
it('passes the text through a file, never through argv', async () => {
const calls: Array<{ bin: string; args: string[] }> = []
const writes: Array<{ path: string; content: string | Buffer }> = []
const text = 'Frase com "aspas", emoji 🎯 e\nquebra de linha.'
await synthesizeBeat({
edgeTtsBin: '/venv/edge-tts',
text,
voice: DEFAULT_TTS_VOICE,
textPath: '/w/audio/beat.txt',
output: '/w/audio/beat.mp3',
run: async (bin: string, args: string[]): Promise<RunResult> => {
calls.push({ bin, args })
return { ok: true, stdout: '', stderr: '' }
},
writeFileImpl: async (path: string, content: string | Buffer) => {
writes.push({ path, content })
},
})
expect(writes).toEqual([{ path: '/w/audio/beat.txt', content: `${text}\n` }])
expect(calls).toEqual([
{
bin: '/venv/edge-tts',
args: [
'--voice',
DEFAULT_TTS_VOICE,
'--file',
'/w/audio/beat.txt',
'--write-media',
'/w/audio/beat.mp3',
],
},
])
expect(calls[0].args.join(' ')).not.toContain('aspas')
})
})
describe('buildBeatPcm', () => {
const run = async (): Promise<RunResult> => ({ ok: true, stdout: '', stderr: '' })
it('speeds the line up with atempo and pads to the exact window', async () => {
const calls: Array<{ bin: string; args: string[] }> = []
const pcm = await buildBeatPcm({
ffmpegBin: '/ffmpeg',
mp3Path: '/w/audio/step-1.mp3',
id: 'step-1',
targetMs: 2_000,
workDir: '/w/audio',
run: async (bin: string, args: string[]): Promise<RunResult> => {
calls.push({ bin, args })
return run()
},
readFileImpl: async (path: string) =>
path.includes('-fit') ? Buffer.alloc(96_000) : Buffer.alloc(144_000),
})
expect(pcm).toHaveLength(96_000)
const atempo = calls.find((call) => call.args.includes('-filter:a'))
expect(atempo?.args).toContain('atempo=1.5')
})
it('fails closed when the line does not fit under the ceiling', async () => {
await expect(
buildBeatPcm({
ffmpegBin: '/ffmpeg',
mp3Path: '/w/audio/step-2.mp3',
id: 'step-2',
targetMs: 2_000,
workDir: '/w/audio',
run,
readFileImpl: async () => Buffer.alloc(192_000),
}),
).rejects.toThrow(/não cabe na cena[\s\S]*atempo/)
})
})
describe('buildNarrationTrack', () => {
it('concatenates spoken beats and silence to the exact video duration', async () => {
const synthesized: string[] = []
const runs: string[][] = []
const writes: Array<{ path: string; content: string | Buffer }> = []
const workDir = join(tmpdir(), 'teqo-reel-tts-unit')
const result = await buildNarrationTrack({
ffmpegBin: '/ffmpeg',
edgeTtsBin: '/venv/edge-tts',
voice: DEFAULT_TTS_VOICE,
beats: [
{ id: 'hook', durationMs: 1_000, text: 'Abertura.' },
{ id: 'cta', durationMs: 2_000, text: null },
],
workDir,
mp3Path: '/out/narracao.mp3',
run: async (_bin: string, args: string[]): Promise<RunResult> => {
runs.push(args)
return { ok: true, stdout: '', stderr: '' }
},
readFileImpl: async () => Buffer.alloc(48_000),
writeFileImpl: async (path: string, content: string | Buffer) => {
writes.push({ path, content })
},
synthesize: async ({ text }: { text: string }) => {
synthesized.push(text)
},
})
expect(synthesized).toEqual(['Abertura.'])
expect(result.durationMs).toBe(3_000)
expect(writes).toHaveLength(1)
expect(writes[0].path).toBe(join(workDir, 'audio', 'narracao.pcm'))
expect(writes[0].content).toHaveLength(48_000 + 96_000)
const encode = runs[runs.length - 1]
expect(encode).toContain('libmp3lame')
expect(encode).toContain('/out/narracao.mp3')
})
})

View file

@ -13,29 +13,72 @@ const repoRoot = process.cwd()
const read = (path: string): string => readFileSync(resolve(repoRoot, path), 'utf8')
describe('reel package metadata', () => {
it('carries the identity the library ingests', () => {
const shotList = {
slug: 'cards',
title: 'Crie seu card de apoio',
feature: 'cards',
coverAlt: 'Capa do tutorial dos cards',
}
it('carries the identity the library ingests, with the always-on transcription', () => {
const metadata = buildReelMetadata({
shotList: { slug: 'cards', title: 'Crie seu card de apoio', feature: 'cards' },
shotList,
hash: 'a'.repeat(64),
durationMs: 18_418.6,
ffmpeg: 'ffmpeg version test',
generatedAt: new Date('2026-09-18T12:00:00.000Z'),
artifacts: ['reel.mp4', 'capa.png', 'narracao.srt', 'roteiro.md'],
})
expect(metadata).toEqual({
slug: 'cards',
title: 'Crie seu card de apoio',
feature: 'cards',
shotListHash: 'a'.repeat(64),
coverAlt: 'Capa do tutorial dos cards',
durationMs: 18_419,
durationSeconds: 18.419,
width: 1_080,
height: 1_920,
fps: 30,
codec: 'h264',
audio: false,
artifacts: ['reel.mp4', 'capa.png', 'narracao.srt', 'roteiro.md'],
generatedAt: '2026-09-18T12:00:00.000Z',
ffmpeg: 'ffmpeg version test',
})
})
it('declares the TTS voice and the audio artifacts when the gate asked for audio', () => {
const metadata = buildReelMetadata({
shotList,
hash: 'b'.repeat(64),
durationMs: 10_000,
ffmpeg: 'ffmpeg version test',
audio: true,
voice: 'pt-BR-AntonioNeural',
artifacts: [
'reel.mp4',
'capa.png',
'narracao.srt',
'roteiro.md',
'narracao.mp3',
'reel-audio.mp4',
],
})
expect(metadata).toMatchObject({
audio: true,
voice: 'pt-BR-AntonioNeural',
durationSeconds: 10,
artifacts: [
'reel.mp4',
'capa.png',
'narracao.srt',
'roteiro.md',
'narracao.mp3',
'reel-audio.mp4',
],
})
})
})
describe('shot list of the first reel', () => {
@ -74,4 +117,17 @@ describe('skill /reels-tutoriais contract', () => {
expect(skill).toContain('metadata.json')
expect(skill).toContain('Nada é publicado')
})
it('keeps the C197 narration contract: transcription always, audio opt-in', () => {
const skill = read('.agents/skills/reels-tutoriais/SKILL.md')
expect(skill).toContain('com ou sem áudio')
expect(skill).toContain('narracao.srt')
expect(skill).toContain('roteiro.md')
expect(skill).toContain('--audio')
expect(skill).toContain('narracao.mp3')
expect(skill).toContain('reel-audio.mp4')
expect(skill).toContain('pnpm reels:tts:setup')
expect(skill).toContain('edge-tts')
expect(skill).not.toContain('Áudio, narração e trilha (C197)')
})
})