mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-12 22:13:05 +00:00
feat(C197): narração rascunho (TTS pt-BR) + transcrição sempre no pacote do reel (Closes #1156)
This commit is contained in:
parent
c0760f4234
commit
31ebb932af
20 changed files with 1487 additions and 48 deletions
|
|
@ -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
7
.gitignore
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
||||
|
|
|
|||
1
docs/changelog/2026-09-19-c197.md
Normal file
1
docs/changelog/2026-09-19-c197.md
Normal 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).
|
||||
157
docs/plans/reels-narracao-impl.md
Normal file
157
docs/plans/reels-narracao-impl.md
Normal 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).
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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
170
scripts/lib/reelScript.mjs
Normal 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
|
||||
}
|
||||
|
|
@ -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
228
scripts/lib/reelTts.mjs
Normal 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) }
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
22
scripts/reels-tts-setup.mjs
Normal file
22
scripts/reels-tts-setup.mjs
Normal 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))
|
||||
})
|
||||
|
|
@ -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."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
|
|
|
|||
174
tests/unit/reelScript.unit.spec.ts
Normal file
174
tests/unit/reelScript.unit.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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/)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
201
tests/unit/reelTts.unit.spec.ts
Normal file
201
tests/unit/reelTts.unit.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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)')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue