From 31ebb932af6b00939b01e0a5128a42bb2b88cfe1 Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Sat, 19 Sep 2026 00:16:16 -0300 Subject: [PATCH] =?UTF-8?q?feat(C197):=20narra=C3=A7=C3=A3o=20rascunho=20(?= =?UTF-8?q?TTS=20pt-BR)=20+=20transcri=C3=A7=C3=A3o=20sempre=20no=20pacote?= =?UTF-8?q?=20do=20reel=20(Closes=20#1156)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .agents/skills/reels-tutoriais/SKILL.md | 68 ++++-- .gitignore | 7 +- .opencode/commands/reels-tutoriais.md | 4 +- docs/changelog/2026-09-19-c197.md | 1 + docs/plans/reels-narracao-impl.md | 157 ++++++++++++++ package.json | 1 + scripts/build-reel.mjs | 83 ++++++- scripts/lib/reelFfmpeg.mjs | 127 ++++++++++- scripts/lib/reelPackage.mjs | 23 +- scripts/lib/reelScript.mjs | 170 +++++++++++++++ scripts/lib/reelShotList.mjs | 34 ++- scripts/lib/reelTts.mjs | 228 ++++++++++++++++++++ scripts/lib/test-affected-core.mjs | 2 + scripts/reels-tts-setup.mjs | 22 ++ scripts/reels/shot-lists/cards.json | 7 +- tests/unit/reelFfmpeg.unit.spec.ts | 110 ++++++++++ tests/unit/reelScript.unit.spec.ts | 174 +++++++++++++++ tests/unit/reelShotList.unit.spec.ts | 56 +++++ tests/unit/reelTts.unit.spec.ts | 201 +++++++++++++++++ tests/unit/reelsTutoriaisSkill.unit.spec.ts | 60 +++++- 20 files changed, 1487 insertions(+), 48 deletions(-) create mode 100644 docs/changelog/2026-09-19-c197.md create mode 100644 docs/plans/reels-narracao-impl.md create mode 100644 scripts/lib/reelScript.mjs create mode 100644 scripts/lib/reelTts.mjs create mode 100644 scripts/reels-tts-setup.mjs create mode 100644 tests/unit/reelScript.unit.spec.ts create mode 100644 tests/unit/reelTts.unit.spec.ts diff --git a/.agents/skills/reels-tutoriais/SKILL.md b/.agents/skills/reels-tutoriais/SKILL.md index 8d04fb97..c6499f9c 100644 --- a/.agents/skills/reels-tutoriais/SKILL.md +++ b/.agents/skills/reels-tutoriais/SKILL.md @@ -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 +pnpm reels:build [--audio] shot list (scripts/reels/shot-lists/.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//` (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//` (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 ` de novo. Sempre. + mostre o que mudou e rode `pnpm reels:build ` 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. diff --git a/.gitignore b/.gitignore index d260ac82..90929486 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/.opencode/commands/reels-tutoriais.md b/.opencode/commands/reels-tutoriais.md index 643d262a..6d1a482d 100644 --- a/.opencode/commands/reels-tutoriais.md +++ b/.opencode/commands/reels-tutoriais.md @@ -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 ` (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 [--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 ` — ex.: `/reels-tutoriais cards`. diff --git a/docs/changelog/2026-09-19-c197.md b/docs/changelog/2026-09-19-c197.md new file mode 100644 index 00000000..97e681d2 --- /dev/null +++ b/docs/changelog/2026-09-19-c197.md @@ -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). diff --git a/docs/plans/reels-narracao-impl.md b/docs/plans/reels-narracao-impl.md new file mode 100644 index 00000000..aba3683a --- /dev/null +++ b/docs/plans/reels-narracao-impl.md @@ -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
scripts/reels/shot-lists/cards.json"] --> B["build C196
captura → clipes → concat"] + B --> C["reelScript (puro)
beats · narracao.srt · roteiro.md"] + C --> D["pacote
reel.mp4 + capa.png + transcrição"] + D --> E{"--audio?"} + E -->|não| F["metadata.json"] + E -->|sim| G["edge-tts por beat → PCM
mede · atempo ≤1.5 · pad"] + G --> H["narracao.mp3 + reel-audio.mp4
-c:v copy"] + H --> F + F --> I["biblioteca (C195/C194)
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=` (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//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). diff --git a/package.json b/package.json index 0d210ab1..d144082d 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/build-reel.mjs b/scripts/build-reel.mjs index aab31bf0..a8d7cd07 100644 --- a/scripts/build-reel.mjs +++ b/scripts/build-reel.mjs @@ -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 [--base-url=…] [--out-dir=…] [--work-dir=…] [--dry-run] [--capture-only]', + 'Uso: node scripts/build-reel.mjs [--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) => { diff --git a/scripts/lib/reelFfmpeg.mjs b/scripts/lib/reelFfmpeg.mjs index ebce2aa2..d098b125 100644 --- a/scripts/lib/reelFfmpeg.mjs +++ b/scripts/lib/reelFfmpeg.mjs @@ -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, run?: RunFn, installer?: { path?: string } | null }} [options] + * @param {{ env?: Record, 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, +] diff --git a/scripts/lib/reelPackage.mjs b/scripts/lib/reelPackage.mjs index ac210f39..575c33f9 100644 --- a/scripts/lib/reelPackage.mjs +++ b/scripts/lib/reelPackage.mjs @@ -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, }) diff --git a/scripts/lib/reelScript.mjs b/scripts/lib/reelScript.mjs new file mode 100644 index 00000000..f8880f5b --- /dev/null +++ b/scripts/lib/reelScript.mjs @@ -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> }, plans: Map }} 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 +} diff --git a/scripts/lib/reelShotList.mjs b/scripts/lib/reelShotList.mjs index 66742afd..10129042 100644 --- a/scripts/lib/reelShotList.mjs +++ b/scripts/lib/reelShotList.mjs @@ -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, * 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, } diff --git a/scripts/lib/reelTts.mjs b/scripts/lib/reelTts.mjs new file mode 100644 index 00000000..5b641d5c --- /dev/null +++ b/scripts/lib/reelTts.mjs @@ -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} ReadFileFn + * @typedef {(path: string, data: string | Buffer) => Promise} 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, 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} + */ +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) } +} diff --git a/scripts/lib/test-affected-core.mjs b/scripts/lib/test-affected-core.mjs index 9f8d7a3f..ba3c34f2 100644 --- a/scripts/lib/test-affected-core.mjs +++ b/scripts/lib/test-affected-core.mjs @@ -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', diff --git a/scripts/reels-tts-setup.mjs b/scripts/reels-tts-setup.mjs new file mode 100644 index 00000000..030449a2 --- /dev/null +++ b/scripts/reels-tts-setup.mjs @@ -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 --audio') + }) + .catch((error) => { + die(error instanceof Error ? error.message : String(error)) + }) diff --git a/scripts/reels/shot-lists/cards.json b/scripts/reels/shot-lists/cards.json index 4e1098e9..add012fa 100644 --- a/scripts/reels/shot-lists/cards.json +++ b/scripts/reels/shot-lists/cards.json @@ -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." } ] } diff --git a/tests/unit/reelFfmpeg.unit.spec.ts b/tests/unit/reelFfmpeg.unit.spec.ts index 881279ec..b5da9fc8 100644 --- a/tests/unit/reelFfmpeg.unit.spec.ts +++ b/tests/unit/reelFfmpeg.unit.spec.ts @@ -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 => { + 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 => { + 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: [ diff --git a/tests/unit/reelScript.unit.spec.ts b/tests/unit/reelScript.unit.spec.ts new file mode 100644 index 00000000..ea30f108 --- /dev/null +++ b/tests/unit/reelScript.unit.spec.ts @@ -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 = {}) => ({ + 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) + }) +}) diff --git a/tests/unit/reelShotList.unit.spec.ts b/tests/unit/reelShotList.unit.spec.ts index 8f48ac85..2f16c653 100644 --- a/tests/unit/reelShotList.unit.spec.ts +++ b/tests/unit/reelShotList.unit.spec.ts @@ -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>).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/) + }) }) diff --git a/tests/unit/reelTts.unit.spec.ts b/tests/unit/reelTts.unit.spec.ts new file mode 100644 index 00000000..86039055 --- /dev/null +++ b/tests/unit/reelTts.unit.spec.ts @@ -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 => + 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 => + 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 => + 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 => ({ + 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 => { + 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 => { + 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 => ({ 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 => { + 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 => { + 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') + }) +}) diff --git a/tests/unit/reelsTutoriaisSkill.unit.spec.ts b/tests/unit/reelsTutoriaisSkill.unit.spec.ts index 70af6209..e991c627 100644 --- a/tests/unit/reelsTutoriaisSkill.unit.spec.ts +++ b/tests/unit/reelsTutoriaisSkill.unit.spec.ts @@ -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)') + }) })