fix(C182): acervo mostra o frame do meio de cada fala, não a capa da sessão (Closes #1104)

This commit is contained in:
Francisco Solla 2026-09-16 21:22:02 -03:00
parent d5c6c285b7
commit 1133210c7c
19 changed files with 1213 additions and 166 deletions

View file

@ -0,0 +1 @@
**C182 — Acervo: o preview de cada fala mostra um frame do meio daquela fala (2026-09-16):** o defeito reabria o anti-goal do C175/#1085 (que declarara impossível extrair frame no servidor): todo resultado da busca do acervo mostrava a capa `hqdefault` do YouTube — o primeiro frame da sessão inteira —, então todas as falas de uma sessão exibiam a MESMA imagem e ela costumava mostrar outra pessoa. A miniatura da lista passa a apontar para uma rota interna `GET /campanha/comunicacao/acervo/<id>/poster`, que gera sob demanda o still no meio da fala (`excerptTMs + durationSeconds/2`, `lib/speechPoster.speechPosterTarget`) baixando o trecho da Câmara e rodando ffmpeg — pipeline extraído de `speechCutJob` para `utilities/speech/speechMediaPipeline` ("edit the owner") e reusado pelo novo `speechPosterJob`. O cache é uma linha `media` com nome determinístico `speech-poster-<id>.jpg` (zero migration: campo `poster` em `Speech` ou `imageSizes` em `Media` seriam hard-stop e ficaram fora), lida por filename e servida pelo proxy `/api/media/file/...`; o request de imagem espera um teto de 20s (`SPEECH_POSTER_WAIT_MS`) e a geração conclui em `after()`, de modo que uma fala lenta cai na capa naquela visita e se auto-aquece nas seguintes — nunca um request preso no limite de ~100s do Cloudflare Tunnel (o mesmo motivo do fluxo `cortar` + poll). Sem coordenadas da Câmara (ou quando ela não resolve o trecho), mantém-se a capa do YouTube como fallback honesto e nunca imagem quebrada; `leader`/`advisor` seguem fora do acervo e a rota é gated por `canReadSpeechCatalog`. Dedupe por single-flight in-process + gate de concorrência (2) + `acquireTextAdvisoryLocks(['speech-poster:<id>'])` com re-checagem na transação garantem uma linha só; cache negativo curto (5 min, cobrindo também os `null` de indisponível/gerando) evita repetir a Câmara a cada varredura; `throttle` de Câmara, `MAX_SOURCE_BYTES`/timeouts herdados do corte. Hardenings do `/simplify` no mesmo diff: `vod*` fora da query da lista (o VM decide pela coordenada), `speechCoverUrl` como dono único da capa (VM + fallback da rota), `Location` relativa no 302 (atrás do túnel `request.url` carrega host interno), `runFfmpeg` recebendo a copy de fallback do dono (o corte mantém a mensagem), poda do mapa de falhas. Entrega sem schema, sem Consent/access novos e sem URL pública nova; UI intocada (o slot `h-20 w-32` e o `Skeleton` do C175 — sem trigger de designer). Testes: unit do `speechPoster` (offset/target/args) e do `thumbnailUrl`; int novo `speechPoster.int.spec.ts` (geração + cache hit + single-flight com 1 chamada à API + cache negativo + JPEG real com ffmpeg quando disponível, stub da Câmara extraído para `tests/helpers/camaraVodStub.ts`) e `speechAcervo.int`/`speechCut.int` atualizados; e2e browserless do acervo com a lista apontando para a rota, o caso VOD-only e a própria rota (302 para a capa sem coordenadas e 404 fora do gate, sem tocar a Câmara). Débitos: **C185 (#1113)** — invalidação do cache quando `durationSeconds`/`excerptTMs` mudam e prova do estouro do teto — e defers com gatilho registrados no `*-impl.md` (primeira varredura parcial, ruído na biblioteca `media`, órfão S3, gate in-process; frame derivado publicamente legível aceito por design).

View file

@ -0,0 +1,130 @@
# Impl: Acervo: o preview de cada fala mostra um frame do meio daquela fala
Status: aprovado
Atualizado em: 2026-09-16
Issue: #1104
Intenção: docs/plans/acervo-preview-frame-do-meio-da-fala.md
Design UI: N/A — sem UI nova (o slot `h-20 w-32` e o `Skeleton` são os de C175; só muda a origem da imagem)
Appetite restante: ~1–1,5 dia (herdado, sem corte)
## Leitura da intenção
- **Outcome:** cada resultado da busca/lista do acervo mostra uma imagem da PRÓPRIA fala (um frame do meio, `excerptTMs + durationSeconds/2` extraído do VOD da Câmara), não a capa da sessão; falas da mesma sessão deixam de repetir a mesma imagem. Sem VOD, cai na capa do YouTube (fallback honesto) — nunca imagem quebrada.
- **O que NÃO negociar (guardrails):**
- Geração sob demanda e cacheada; **nunca bloqueia a listagem** (miniatura pequena, `lazy`, sem CLS, estática — nada de player/hover-play).
- Nada novo de Consent/LGPD e nenhuma exposição além do que a fala já expõe na lista (o VM não pode carregar a URL crua do VOD).
- O instante é `excerptTMs + durationSeconds/2`; sem curadoria de frame, sem heurística de “frame feio”.
- `corte/[id]` público, o helper `hqdefault` (C179/#1093) e a busca/excerto/highlight ficam intactos; `leader`/`advisor` seguem fora do acervo.
- **O que reavaliar (hipóteses do plano):**
- **Elegibilidade:** `speechVodCoordinates` exige um VOD **armazenado** (é o sinal do _player_, `speechVod.ts:58-65`). Para o poster, o critério honesto é ter as **coordenadas do trecho na Câmara** (`eventId`/`audioId`/`excerptTMs`) — senão a maioria das falas importadas (sem link cacheado) ficaria sem frame, contrariando o aceite. O VOD armazenado vira apenas `cachedUrls` (ajuda, não habilita).
- **Disparo:** geração síncrona num request de browser (imagem) não serve: o app está atrás do Cloudflare Tunnel (limite de ~100s por request) e o próprio repo trata trabalho longo com job em `after()` + poll (`cortar` + `cortar/status`, `speechCutScheduler.ts:15-19`). O desenho abaixo espera um **teto curto** e deixa a geração terminar em background.
- **Cache sem migration:** o precedente `speechCut.media` exige campo novo em `Speech` (migration → hard-stop do modo autônomo). O cache vai por **nome de arquivo determinístico na `media`** (zero schema).
## Abordagem recomendada
```mermaid
flowchart LR
A["speechListSelect<br/>+ eventId/audioId/excerptTMs"] --> B["toSpeechListItemViewModel<br/>speechPosterTarget (coordenadas + duration/2)"]
B -->|"coordenadas + duração"| C["thumbnailUrl =<br/>/campanha/comunicacao/acervo/&lt;id&gt;/poster"]
B -->|"sem coordenadas"| D["youtubeThumbnailUrl(videoId)<br/>ou null"]
C --> E["&lt;img loading=lazy&gt;<br/>SpeechResultThumbnail (inalterado)"]
E --> F["GET /[id]/poster<br/>gate canReadSpeechCatalog"]
F -->|"media cache hit"| G["302 media.url<br/>private, max-age=1d"]
F -->|"miss"| H["ensureSpeechPoster(id)<br/>single-flight + gate de concorrência"]
H -->|"pronto em ≤20s"| G
H -->|"ainda gerando"| I["after(): termina em background"]
F -->|"falha"| J["302 capa do YouTube<br/>no-store; senão 404"]
```
**Opções consideradas:** A) cache por nome determinístico em `media` + rota interna que gera sob demanda com espera curta e conclui em background (recomendada) | B) campo `poster` (relationship) em `Speech` + job | C) gerar no render do RSC ou job a cada listagem | D) manter `hqdefault` trocando só a variante.
**Recomendação:** A. Entrega o frame real da fala **sem migration** (hard-stop do modo), reusa `Media` + o proxy `/api/media/file/...`, `resolveSpeechVod`, `downloadSource`/`runFfmpeg` (extraídos do corte) e o slot C175 intacto. O `<img loading="lazy">` só dispara para linhas na viewport, e a listagem nunca espera além do teto curto.
**Rejeitadas:** B — campo novo em coleção → migration e Issue `blocked`; C — RSC síncrono baixa/roda ffmpeg no caminho da página ou dispara N jobs por varredura (viola “nunca bloqueia”); D — é exatamente o defeito reaberto.
### Decisões de engenharia
1. **Critério de elegibilidade** — Opções: (A) coordenadas da Câmara (`eventId`+`audioId`+`excerptTMs`) + `durationSeconds > 0`; (B) `speechVodCoordinates` (exige VOD armazenado); (C) só `youtubeUrl`. **Recomendação:** A — é o que o aceite pede (“fala com VOD da Câmara”); B deixa de fora falas resolvíveis mas nunca resolvidas, C é o defeito. **Rejeitadas:** B (sub-entrega silenciosa), C (defeito).
2. **Onde a geração é disparada** — Opções: (A) imagem aponta para uma rota interna que gera-e-cacheia; (B) no render da lista; (C) job a cada listagem. **Recomendação:** A — o RSC só monta a string da URL (puro); o browser pede só o que entra na viewport; uma geração por fala, cacheada. **Rejeitadas:** B (ffmpeg/download no caminho crítico), C (custo por varredura e miniatura neutra transitória).
3. **Latência do request de imagem** — Opções: (A) espera curta (teto literal `SPEECH_POSTER_WAIT_MS = 20_000`) e, se a geração não terminou, responde a capa enquanto ela continua em background via `after()`; (B) espera síncrona até completar; (C) sempre responder capa e só gerar em background. **Recomendação:** A — entrega o frame já na primeira varredura quando a geração é rápida, nunca encosta no teto do Cloudflare (~100s) e se auto-aquece quando é lenta. **Rejeitadas:** B (risco de 524 e request preso por minutos), C (a primeira varredura mostraria só a capa — o defeito).
4. **Instante do meio** — Opções: (A) baixar o MP4 do trecho (coordenadas em `excerptTMs`) e extrair o frame em `durationSeconds/2`; (B) pedir um trecho novo começando no meio e extrair em t=0. **Recomendação:** A — é literalmente `excerptTMs + durationSeconds/2` e reusa o pipeline do corte, cuja premissa (o trecho começa no início da fala) já é a de `speechCutJob`/C166. **Rejeitada:** B (transcodificação extra na Câmara e semântica do clipe menos previsível).
5. **Cache sem migration** — Opções: (A) `media` com nome determinístico `speech-poster-<id>.jpg`; (B) campo `poster` em `Speech`; (C) disco/tmp. **Recomendação:** A — `Media` já existe, `media.url` já é o proxy do bucket privado (ou disco em dev/test), e o nome é a chave de cache/idempotência. **Rejeitadas:** B (migration hard-stop), C (o container é recriado no deploy; prod é S3).
6. **Como o cache é lido** — Opções: (A) a rota procura `media` por `filename` e responde `302` para `media.url`; (B) expor o id da mídia no VM. **Recomendação:** A — zero schema e o proxy existente faz o streaming. **Rejeitada:** B (vazaria o interior do cache no VM da lista e exigiria achar a mídia no loader).
7. **Dedupe/corrida** — Opções: (A) single-flight em memória + gate de concorrência + `withPayloadTransaction` + `acquireTextAdvisoryLocks(['speech-poster:<id>'])` com re-checagem antes do `create`; (B) só pré-checagem; (C) lock advisory cobrindo download/ffmpeg. **Recomendação:** A — o lock + re-checagem garantem **nunca duas linhas `media`**; download/ffmpeg ficam **fora** da transação (o lock não fica preso por minutos). **Rejeitadas:** B (linhas duplicadas sob concorrência), C (transação longa).
8. **Proteção do homeserver (concorrência)** — Opções: (A) gate simples in-process (`MAX_CONCURRENT_GENERATIONS = 2`) + cache negativo curto (`FAILURE_TTL_MS = 5min`, que também memoriza os `null` de "indisponível/gerando") por fala; (B) sem limite; (C) fila persistente. **Recomendação:** A — a varredura pede várias imagens de uma vez e o homeserver (8c/16GB, link compartilhado) não deve baixar 8 trechos em paralelo; o cache negativo evita repetir uma geração que acabou de falhar. **Rejeitadas:** B (rajada de downloads), C (fila nova é rabbit hole de produto).
9. **Reuso do pipeline de mídia** — Opções: (A) extrair `downloadSource`/`runFfmpeg` de `speechCutJob.ts` para `src/utilities/speech/speechMediaPipeline.ts` e importar nos dois; (B) copiar; (C) importar `speechCutJob` no job do poster. **Recomendação:** A — mesma responsabilidade, dois call sites (“edit the owner, don't twin”). **Rejeitadas:** B (twin), C (acopla poster a corte).
10. **Tamanho do still** — Opções: (A) `-vf scale=480:-2 -frames:v 1 -q:v 2`; (B) resolução cheia; (C) `imageSizes` na `Media`. **Recomendação:** A — dezenas de KB, sem tocar em schema/config. **Rejeitadas:** B (peso na listagem), C (migration hard-stop + afeta todos os uploads).
11. **Contrato do VM** — Opções: (A) manter `thumbnailUrl: string | null` e nele escolher frame vs capa; (B) `posterUrl` ao lado de `thumbnailUrl`. **Recomendação:** A — o card tem um slot único; a escolha é do dono do link. **Rejeitada:** B (duas fontes concorrentes sem ganho).
12. **Fallback** — Opções: (A) falha → `302` para a capa do YouTube quando houver, senão `404` (o `onError` de `SpeechResultThumbnail` já assenta no slot neutro); (B) sempre `404`; (C) imagem quebrada. **Recomendação:** A — fallback honesto do aceite. **Rejeitadas:** B (perde reconhecimento parcial), C (proibida).
13. **Testes** — unit: helpers puros + VM; int: geração/cache e a fiação do loader; e2e: atualizar os pinos do acervo (o fixture tem coordenadas → passa a apontar para a rota). **Rejeitadas:** spec e2e novo; e2e buscar a rota (browserless — bateria na Câmara de verdade).
### Componentes / mudanças
- **`src/lib/speechPoster.ts` (novo, puro/client-safe):** `speechPosterOffsetSeconds(durationSeconds)` → `floor(d/2)` ou `null`; `speechPosterTarget(speech)` → `{ eventId, audioId, excerptTms, offsetSeconds } | null` (coordenadas da Câmara + duração num só dono, sem exigir VOD armazenado); `speechPosterFilename(id)` → `speech-poster-<id>.jpg`; `speechPosterHref(id)` → `${CAMPAIGN_COMMUNICATION_ACERVO}/${id}/poster`; `buildSpeechPosterFfmpegArgs({ inputPath, outputPath, atSeconds })` → `['-nostdin','-hide_banner','-y','-ss',<at>,'-i',input,'-frames:v','1','-vf','scale=480:-2','-q:v','2','output]`.
- **`src/lib/speechVod.ts`:** extrai `speechExcerptCoordinates(source)` (as 3 coordenadas) e mantém `speechVodCoordinates = armazenado ? speechExcerptCoordinates : null` — um dono para as duas leituras, sem mudar o contrato do player (`speechVod.ts:58-65`).
- **`src/utilities/speech/speechViewModels.ts`:** `SpeechListRecord` ganha só `eventId?`, `audioId?`, `excerptTMs?` (os links de VOD ficam fora da query da lista); `toSpeechListItemViewModel` monta `thumbnailUrl = speechPosterTarget(speech) ? speechPosterHref(speech.id) : speechCoverUrl(speech.youtubeUrl)`. Nenhuma URL crua do VOD entra no VM. O `speechCoverUrl` (novo em `lib/speechVod.ts`, composição de `parseYoutubeVideoId` + `youtubeThumbnailUrl`) é o mesmo usado pelo fallback da rota.
- **`src/utilities/speech/speechPageData.ts:33-46`:** `speechListSelect` ganha `eventId`, `audioId`, `excerptTMs`, `vodPlaybackUrl`, `vodDownloadUrl` (o loader já entrega a linha ao VM).
- **`src/utilities/speech/speechMediaPipeline.ts` (novo, `server-only`):** `ffmpegBinary`, `downloadSource(url, destination)`, `runFfmpeg(args, durationSeconds)` e os literais de teto/timeout, extraídos de `speechCutJob.ts:39-51,119-176` sem mudança de comportamento.
- **`src/utilities/speech/speechCutJob.ts`:** passa a importar o pipeline extraído; remove as cópias locais.
- **`src/utilities/speech/speechPosterJob.ts` (novo, `server-only`):** `findSpeechPosterMedia(payload, id)`; `ensureSpeechPoster(payload, id)` (single-flight por id + gate de concorrência + cache negativo; carrega a fala por `overrideAccess: true` lendo `eventId/audioId/excerptTMs/vod*/durationSeconds/youtubeUrl/speechAt`; `resolveSpeechVod(coords, { policy: SPEECH_VOD_PLAYER_POLICY, cachedUrls })`; `downloadSource` + `buildSpeechPosterFfmpegArgs` + `runFfmpeg` em `mkdtemp`; `withPayloadTransaction` com `acquireTextAdvisoryLocks(['speech-poster:<id>'])` + re-checagem + `payload.create({ collection:'media', data:{ alt }, filePath, overrideAccess:true, req })`; `finally` remove o temp). O slot de concorrência é adquirido **antes** de `resolveSpeechVod`, então o teto cobre também a rede. Literais: `SPEECH_POSTER_WAIT_MS = 20_000` (exportado), `MAX_CONCURRENT_GENERATIONS = 2`, `FAILURE_TTL_MS = 300_000`.
- **`src/app/(campaign)/campanha/(app)/comunicacao/acervo/[id]/poster/route.ts` (novo):** `export const dynamic = 'force-dynamic'`; GET lê `getCampaignUser()` e exige `canReadSpeechCatalog` (senão `404`); `ensureSpeechPoster` já cobre cache-hit (uma query) e miss; `after()` mantém o processo vivo e `Promise.race` aplica o teto; frame → `302` com `Location` **relativa** (`media.url` ou a capa absoluta) — não resolvida contra `request.url`, que atrás do túnel carrega host interno — e `Cache-Control: private, max-age=86400`; sem frame → `302` capa do YouTube (se houver) ou `404`, com `Cache-Control: no-store`. Rota **interna** — nenhum contrato de URL pública muda.
- **`src/components/campaign/speech/SpeechResultThumbnail.tsx`:** sem mudança funcional; só o comentário do `eslint-disable` deixa de dizer só “YouTube cover”.
- **Migration:** **sem migration.** (Campo `poster` em `Speech` ou `imageSizes` em `Media` seria hard-stop → Issue `blocked`.)
- **Access / Consent:** nenhum novo; a rota reusa `canReadSpeechCatalog`; a escrita em `media` é bypass admin intencional, como o job de corte (a linha nasce atrás do gate do acervo).
- **UI:** Impeccable A — **sem trigger de designer** (a/b/d): nenhuma superfície, layout, estado ou token novo; o slot e o `Skeleton` são os de C175.
### Dados → forma
Não se aplica. Não nasce agregado, contagem, gráfico ou tabela: é a origem de uma imagem derivada de campos existentes (`excerptTMs`, `durationSeconds`, coordenadas da Câmara). A intenção já fecha “Vou apresentar dados? Não”.
## Fases verificáveis
1. **Puro (unit-first):** `lib/speechPoster.ts` + o refactor `speechExcerptCoordinates`; testes `tests/unit/speechPoster.unit.spec.ts` (offset, filename, href, args com `-frames:v 1`/`scale=480:-2`, elegibilidade) e os casos de `thumbnailUrl` em `tests/unit/speechViewModels.unit.spec.ts`. Verificação: `pnpm test:unit -- speechPoster speechVod speechViewModels` verde.
2. **Pipeline + job + VM/select:** extrair `speechMediaPipeline`, ajustar `speechCutJob`, criar `speechPosterJob`, estender `speechListSelect`/VM. Verificação: `pnpm test:int -- speechCut speechAcervo speechPoster` verde (o corte segue idêntico após a extração).
3. **Rota + e2e + entrega:** rota `[id]/poster`, ajuste dos pinos do acervo, `pnpm gate:fast`, `pnpm test:e2e:affected` (`campaignSpeechAcervo` + `campaignSpeechCut`), entrega com `pnpm push`.
## Rabbit holes / Não escopo (engenharia)
- Backfill/mirroring de posters; gerar para falas sem coordenadas da Câmara.
- Curadoria manual do frame ou heurística de “frame ruim → capa” (a intenção adia; reavaliar com uso real).
- Tocar em `corte/[id]` público, no player ou na capa de corte; consolidar o helper `hqdefault` (C179/#1093).
- Player/hover-play, `next/image`, `blurDataURL`, `remotePatterns`.
- Collection/fila/worker novos; qualquer migration de schema.
## Riscos e mitigação
- **Timeout do Cloudflare (~100s):** o request de imagem nunca espera mais que `SPEECH_POSTER_WAIT_MS`; a geração continua em `after()`; nada de 524 preso.
- **Primeira varredura parcial:** falas cuja geração passa do teto mostram a capa **nesta** visita e o frame nas seguintes (o cache já foi gravado). É o custo de “sob demanda e cacheado”; revisitar com o gatilho abaixo se o produto exigir frame na primeira visita (caminho: retry no cliente ou aquecimento no loader).
- **Rajada no homeserver:** gate de concorrência (2) + `lazy` + cache negativo; no pior caso, trabalho duplicado uma vez, nunca linha/arquivo duplicado.
- **Download grande para uma miniatura:** teto de bytes/timeout herdados (`MAX_SOURCE_BYTES`, `SOURCE_DOWNLOAD_TIMEOUT_MS`), seek de entrada (`-ss` antes de `-i`, barato em arquivo local) e uma geração por fala.
- **Órfão no bucket S3:** se a transação falhar após o upload, o objeto pode ficar sem linha (mesmo comportamento aceito do C167); se o adapter renomear por colisão, a rota regenera — custo, não corrupção.
- **Poluição da biblioteca `media`:** cada poster cria uma linha derivada (`speech-poster-*`), visível no admin de mídia. Aceito nesta entrega (marcar `source`/collection própria exige migration); **gatilho:** a lista de mídia do admin ficar ruidosa.
- **Câmara indisponível/`GERANDO`:** fallback para a capa ou `404` (slot neutro); sem cache negativo eterno (TTL curto) e nova tentativa depois.
- **Vazamento do VOD cru no VM:** o job lê `vod*` só para `cachedUrls` (fora da query da lista); o VM de lista nunca os recebe — coberto por asserção no int.
- **Leitura pública do frame derivado:** `Media.read` é `() => true` e o nome é determinístico (`speech-poster-<id>.jpg`), então quem adivinhar o nome alcança o JPEG no proxy de mídia, fora do gate do acervo. Conteúdo = frame de sessão pública da Câmara, sem PII nem VOD cru; aceito nesta entrega e registrado como risco (a alternativa — rota gated streamando bytes — é mais código e não muda a natureza pública do vídeo-fonte).
- **`excerptTMs`/`durationSeconds` ausentes:** coordenadas ou offset `null` → capa/slot neutro.
## Aceite de engenharia
- [x] Aceite de produto da intenção coberto: fala com coordenadas da Câmara mostra frame do meio; falas da mesma sessão divergem; sem coordenadas mantém a capa (ou slot neutro); nunca imagem quebrada; miniatura `lazy`, sem CLS e sem bloquear a listagem.
- [x] Invariantes AGENTS/engineering-standards: identificadores em inglês, sem migration/Consent/access novos, rota interna (URL pública intacta), `overrideAccess:false` nos reads com `user`, bypass admin intencional documentado nos writes do sistema, transação + `req` na escrita.
- [x] Sem migration; se exigida, Issue `blocked` (hard-stop do modo).
- [x] Testes de domínio previstos: unit cobre os helpers puros + VM; int prova geração/cache/single-flight/cache negativo e o corte segue verde após a extração; e2e do acervo atualizado (lista + rota).
- [x] `pnpm gate:fast` verde; `pnpm test:e2e:affected` verde (`campaignSpeechAcervo` + `campaignSpeechCut`); entrega via `pnpm push`.
## Self-score (decision-quality)
4.5/5 — (1) as decisões caras (cache sem migration, elegibilidade, latência do request, dedupe/lock, reuso do pipeline) têm opções e rejeitadas explícitas; (2) cabe no appetite ~1–1,5 dia (um módulo puro, uma extração mecânica, um job e uma rota fina); (3) rabbit holes nomeados (backfill, curadoria de frame, `corte/[id]`, C179, migration) e o hard-stop tratado; (4) depth check: reusa `Media`/proxy, `resolveSpeechVod`, `withPayloadTransaction`/`acquireTextAdvisoryLocks` e o pipeline extraído do corte — sem collection/fila/worker novos; (5) a intenção permanece satisfeita — o frame do meio da fala sem reescrever o outcome nem o fallback honesto.
## Débitos deferidos (capture-review-debts)
- **Registrado — C185** (`docs/plans/escala-dry-pos-c182.md`, `depends: C182`): F1 poster obsoleto quando `durationSeconds`/`excerptTMs` mudam (nome do cache não carrega o offset) + F2 prova automatizada do estouro do teto e do `Cache-Control` do fallback.
- **Defer com gatilho — D1 (primeira varredura parcial):** fala lenta mostra a capa nesta visita e o frame na seguinte. Gatilho: produto exigir frame na 1ª visita (retry no cliente ou aquecimento no loader).
- **Defer com gatilho — D2 (poluição da `media`):** cada fala vista cria uma linha `speech-poster-*`. Gatilho: a lista de mídia do admin ficar ruidosa (marcador de origem exigiria migration).
- **Defer com gatilho — D5 (órfão S3 / rename por colisão):** objeto sem linha ou regeneração por custo. Gatilho: órfãos crescendo no bucket.
- **Defer com gatilho — D7 (gate in-process):** multiplicaria com múltiplas réplicas. Gatilho: scale-out acima de um container.
- **Descartado — D3 (frame derivado publicamente legível):** decisão travada (conteúdo de sessão pública, sem PII/VOD cru); a alternativa gated foi rejeitada no plano.
### Já resolvido no /simplify (não reabrir)
- `vod*` fora da query da lista; cache negativo cobrindo os `null` de indisponível/gerando; slot de concorrência antes de `resolveSpeechVod`; `rm` do temp antes de liberar o slot; `Location` relativa no 302 (host interno atrás do túnel); `speechPosterTarget` e `speechCoverUrl` como donos únicos; `runFfmpeg` com a copy de fallback do dono; poda do mapa de falhas; tipagem via `PayloadTransactionRequest`; testes de single-flight (1 chamada à API da Câmara) e do cache negativo.

View file

@ -0,0 +1,35 @@
# Escala DRY pós-C182: robustez do cache do poster do acervo
Status: rascunho
Atualizado em: 2026-09-16
Issue: a registrar — C185
Priority: P3
Impeccable: A — sem superfície nova (cache/rota interna; nada visual)
Appetite: ~3 h eng (fill-in); sem schema, sem URL pública, sem Consent
Depende de: #1104 (C182) — o cache e a rota do poster nasceram lá; destrava sozinha quando o C182 flipar `done`.
## Intenção
O C182 (`docs/plans/acervo-preview-frame-do-meio-da-fala-impl.md`) entregou o frame do meio da fala do acervo com um cache por nome determinístico (`speech-poster-<id>.jpg`) e uma rota interna que gera sob demanda. Dois achados dos revisores de `/simplify` ficaram fora do escopo da entrega por serem robustez/teste, não o defeito.
## Fases verificáveis
1. **F1 — poster obsoleto (achado D4).** O nome do arquivo não carrega o instante: se `durationSeconds`/`excerptTMs` da fala mudarem (o import faz `update` do bundle), o frame cacheado continua no meio antigo e nunca é invalidado. Opções: (A) incluir o offset no nome (`speech-poster-<id>-<offset>.jpg`) | (B) invalidar no `upsertSpeechBundle` quando os campos mudam. **Recomendação:** A — o hit continua sendo um lookup por filename determinístico e um re-import gera um nome novo sozinho; aceitar o objeto antigo órfão (mesmo balde do risco D5 do C182). Verificação: int de re-import com duração nova gera/prova o novo frame e o VM aponta para o href novo.
2. **F2 — teste do teto e do fallback (achado D6).** Provar que o estouro de `SPEECH_POSTER_WAIT_MS` responde o fallback (capa ou `404`) com `Cache-Control: no-store` enquanto a geração conclui em background e cacheia. Verificação: int da rota com geração segurável (stub de ffmpeg lento) + asserção do header.
## Já resolvido no simplify/critique (não reabrir)
- URLs cruas do VOD fora da query da lista; cache negativo cobrindo os `null` de indisponível/gerando; slot de concorrência antes de `resolveSpeechVod`; `rm` do temp antes de liberar o slot; `Location` relativa no 302; `speechPosterTarget`/`speechCoverUrl` como donos únicos; `runFfmpeg` recebendo a copy de fallback; poda do `failures`; testes de single-flight e cache negativo. Tudo no diff do C182.
## Explicitamente fora
- **D3 — frame derivado publicamente legível** (`Media.read = () => true`, nome adivinhável): decisão travada no C182 — o conteúdo é frame de sessão pública da Câmara, sem PII nem VOD cru; a alternativa gated adiciona código e não muda a natureza do vídeo-fonte. Só revisita se o conteúdo-fonte mudar de natureza.
- **D1 — primeira varredura parcial:** fala lenta mostra a capa nesta visita e o frame na seguinte (o cache se auto-aquece). Gatilho: produto exigir frame na 1ª visita (caminho: retry no cliente ou aquecimento no loader).
- **D2 — poluição da biblioteca `media`** com linhas derivadas `speech-poster-*`. Gatilho: a lista de mídia do admin ficar ruidosa (o marcador de origem exigiria migration).
- **D5 — órfão no bucket S3 / rename por colisão** (objeto sem linha; regeneração por custo). Gatilho: órfãos crescendo no bucket.
- **D7 — gate de concorrência in-process**: multiplicaria com múltiplas réplicas. Gatilho: scale-out acima de um container.
- Consolidar o literal `hqdefault` — escopo do **C179 (#1093)**, não deste lote.
## Self-score (decisão)
4/5 — decisão barata de reverter, escopo fechado (robustez do cache + prova do fallback), depth check: reusa o cache/rota do C182 sem nova collection/rota, e os defers têm gatilho nomeado.

View file

@ -0,0 +1,90 @@
import config from '@payload-config'
import { after, NextResponse } from 'next/server'
import { getPayload } from 'payload'
import { canReadSpeechCatalog } from '@/lib/campaignRoles'
import { speechCoverUrl } from '@/lib/speechVod'
import { getCampaignUser } from '@/utilities/campaignAuth'
import { ensureSpeechPoster, SPEECH_POSTER_WAIT_MS } from '@/utilities/speech/speechPosterJob'
export const dynamic = 'force-dynamic'
/**
* C182 — the acervo list frame. The list thumbnail points here; it answers a
* `302` to the generated file in `media` (served by the media proxy) or, when
* the frame is not ready yet, to the YouTube cover of the session (or `404` for
* the neutral slot). On a miss it starts the generation and waits a short
* budget; the generation keeps running after the response, so a slow speech
* heals on the next view. Internal route: never a public URL contract.
*/
const emptyResponse = (status: number): NextResponse =>
new NextResponse(null, { status, headers: { 'Cache-Control': 'no-store' } })
/**
* `Location` is emitted as received (relative media proxy path or absolute
* cover) instead of resolving it against `request.url`: behind the tunnel the
* request URL may carry the internal host, which must never reach the browser.
*/
const redirectTo = (location: string, cacheControl: string): NextResponse =>
new NextResponse(null, {
status: 302,
headers: { Location: location, 'Cache-Control': cacheControl },
})
/** Resolves the promise, or null when the budget runs out first. */
const settledWithin = async <Value>(
promise: Promise<Value>,
budgetMs: number,
): Promise<Value | null> => {
let timer: ReturnType<typeof setTimeout> | undefined
try {
return await Promise.race([
promise,
new Promise<null>((resolve) => {
timer = setTimeout(() => resolve(null), budgetMs)
}),
])
} finally {
if (timer) clearTimeout(timer)
}
}
export const GET = async (
_request: Request,
{ params }: { params: Promise<{ id: string }> },
): Promise<NextResponse> => {
const user = await getCampaignUser()
if (!user || !canReadSpeechCatalog(user.role)) return emptyResponse(404)
const { id } = await params
const speechId = Number(id)
if (!Number.isInteger(speechId) || speechId <= 0) return emptyResponse(404)
const payload = await getPayload({ config })
const generation = ensureSpeechPoster(payload, speechId)
// The response below may return before the frame is ready; `after` keeps the
// process alive until the generation settles (never a 524 from waiting).
after(async () => {
await generation
})
const poster = await settledWithin(generation, SPEECH_POSTER_WAIT_MS)
if (poster?.url) return redirectTo(poster.url, 'private, max-age=86400')
// Fallback: the session cover is honest about the missing frame; `no-store`
// lets a later view pick the frame once the background generation cached it.
const speech = await payload.find({
collection: 'speech',
where: { id: { equals: speechId } },
depth: 0,
limit: 1,
pagination: false,
select: { youtubeUrl: true },
user,
overrideAccess: false,
})
const cover = speechCoverUrl(speech.docs[0]?.youtubeUrl)
return cover ? redirectTo(cover, 'no-store') : emptyResponse(404)
}

View file

@ -6,10 +6,10 @@ import { useEffect, useRef, useState } from 'react'
import { Skeleton } from '@/components/ui/skeleton'
/**
* C175 — the trecho thumbnail on the acervo list: a fixed 128×80 slot so the
* lazy image never shifts the card, with the skeleton filling it until the
* cover paints (a missing cover settles into the neutral slot). The link
* reuses the card's watch target, with the same accessible name as the CTA.
* C175/C182 — the trecho thumbnail on the acervo list: a fixed 128×80 slot so
* the lazy image never shifts the card, with the skeleton filling it until the
* image paints (a missing image settles into the neutral slot). The link reuses
* the card's watch target, with the same accessible name as the CTA.
*/
export const SpeechResultThumbnail = ({
href,
@ -35,7 +35,7 @@ export const SpeechResultThumbnail = ({
className="relative block h-20 w-32 shrink-0 overflow-hidden rounded-lg border bg-muted"
>
{settled ? null : <Skeleton className="absolute inset-0 rounded-lg" />}
{/* eslint-disable-next-line @next/next/no-img-element -- YouTube cover is an external static image. */}
{/* eslint-disable-next-line @next/next/no-img-element -- external static image (poster route or YouTube cover). */}
<img
ref={imageRef}
src={src}

91
src/lib/speechPoster.ts Normal file
View file

@ -0,0 +1,91 @@
/**
* C182 — pure rules of the acervo list frame: the midpoint of a speech, the
* deterministic `media` cache name/href and the ffmpeg still command. No I/O
* and no `server-only`: the view model and the poster job share this module so
* the "middle of the speech" literal lives in one place.
*/
import { CAMPAIGN_COMMUNICATION_ACERVO } from '@/lib/campaignPaths'
import { speechExcerptCoordinates, type SpeechVodCoordinatesSource } from '@/lib/speechVod'
/**
* Offset of the middle of the speech inside its Câmara trecho (the trecho
* starts at the speech): `durationSeconds / 2`. Null without a usable duration.
*/
export const speechPosterOffsetSeconds = (
durationSeconds: number | null | undefined,
): number | null => {
if (typeof durationSeconds !== 'number' || !Number.isFinite(durationSeconds)) return null
if (durationSeconds <= 0) return null
return Math.floor(durationSeconds / 2)
}
/** Everything the poster job and the list need to place the frame. */
export type SpeechPosterTarget = {
eventId: number
audioId: number
excerptTms: number
offsetSeconds: number
}
type SpeechPosterTargetSource = SpeechVodCoordinatesSource & {
durationSeconds?: number | null
}
/**
* The speech has a frame when the Câmara can re-resolve the excerpt
* (`eventId`/`audioId`/`excerptTMs`) AND the midpoint is known. Unlike the
* player, a stored VOD link is not required — it is only a cache of a previous
* resolution. The view model and the job share this one decision.
*/
export const speechPosterTarget = (speech: SpeechPosterTargetSource): SpeechPosterTarget | null => {
const coordinates = speechExcerptCoordinates(speech)
if (!coordinates) return null
const offsetSeconds = speechPosterOffsetSeconds(speech.durationSeconds)
if (offsetSeconds === null) return null
return { ...coordinates, offsetSeconds }
}
/** Deterministic `media` filename: the cache key and the idempotence key. */
export const speechPosterFilename = (speechId: number): string => `speech-poster-${speechId}.jpg`
/** Internal route the list thumbnail points at (never a public contract). */
export const speechPosterHref = (speechId: number): string =>
`${CAMPAIGN_COMMUNICATION_ACERVO}/${speechId}/poster`
type SpeechPosterFfmpegInput = {
inputPath: string
outputPath: string
atSeconds: number
}
/**
* One still at `atSeconds`: `-ss` before `-i` seeks fast and `-frames:v 1`
* takes a single frame; `scale=480:-2` keeps it small (the card slot is
* 128×80, `object-cover` crops). Args as an array — never a shell string.
*/
export const buildSpeechPosterFfmpegArgs = ({
inputPath,
outputPath,
atSeconds,
}: SpeechPosterFfmpegInput): string[] => {
const at = Math.max(0, Math.round(atSeconds))
return [
'-nostdin',
'-hide_banner',
'-y',
'-ss',
String(at),
'-i',
inputPath,
'-frames:v',
'1',
'-vf',
'scale=480:-2',
'-q:v',
'2',
'-an',
outputPath,
]
}

View file

@ -49,19 +49,31 @@ export type SpeechVodCoordinatesSource = {
}
/**
* The Câmara coordinates of a speech whose excerpt can be re-resolved: a
* The Câmara coordinates of the excerpt, with no eligibility judgement: the
* speech needs `eventId`/`audioId`/`excerptTMs` and nothing else. Null when any
* of the three is missing; the two callers below decide what else to require.
*/
export const speechExcerptCoordinates = (
speech: SpeechVodCoordinatesSource,
): { eventId: number; audioId: number; excerptTms: number } | null => {
const { eventId, audioId, excerptTMs } = speech
if (!eventId || !audioId || !excerptTMs) return null
return { eventId, audioId, excerptTms: excerptTMs }
}
/**
* The Câmara coordinates of a speech whose excerpt the player can re-resolve: a
* stored VOD link (eligibility signal only — it is a cache and never a URL
* handed to the browser) plus `eventId`/`audioId`/`excerptTMs`. Null means the
* player must not offer the resolution; the view model and the action share
* this one decision.
* handed to the browser) plus the excerpt coordinates. Null means the player
* must not offer the resolution; the view model and the action share this one
* decision. The acervo frame (C182) does not need the stored link — it reads
* `speechExcerptCoordinates` directly.
*/
export const speechVodCoordinates = (
speech: SpeechVodCoordinatesSource,
): { eventId: number; audioId: number; excerptTms: number } | null => {
const hasStoredVod = Boolean(speech.vodPlaybackUrl || speech.vodDownloadUrl)
const { eventId, audioId, excerptTMs } = speech
if (!hasStoredVod || !eventId || !audioId || !excerptTMs) return null
return { eventId, audioId, excerptTms: excerptTMs }
return hasStoredVod ? speechExcerptCoordinates(speech) : null
}
/**
@ -168,6 +180,13 @@ export const parseYoutubeVideoId = (raw: unknown): string | null => {
export const youtubeThumbnailUrl = (videoId: string | null): string | null =>
videoId ? `https://i.ytimg.com/vi/${videoId}/hqdefault.jpg` : null
/**
* The session cover of a raw link: composition of the two rules above, so the
* list view model (C182 fallback) and the poster route build it the same way.
*/
export const speechCoverUrl = (youtubeUrl: unknown): string | null =>
youtubeThumbnailUrl(parseYoutubeVideoId(youtubeUrl))
const SAO_PAULO_TIME_ZONE = 'America/Sao_Paulo'
const NAIVE_DATETIME = /^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})(?::(\d{2}))?/
const brtWallClock = new Intl.DateTimeFormat('en-CA', {

View file

@ -1,12 +1,8 @@
import 'server-only'
import { execFile } from 'node:child_process'
import { createWriteStream } from 'node:fs'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { Readable, Transform } from 'node:stream'
import { pipeline } from 'node:stream/promises'
import type { Payload } from 'payload'
import { SPEECH_VOD_INELIGIBLE_MESSAGE } from '@/lib/schemas/speechVod'
@ -21,12 +17,13 @@ import {
SPEECH_CUT_FAILURE_UNPLAYABLE,
type SpeechCutStep,
} from '@/lib/speechCut'
import { CAMARA_USER_AGENT, speechVodCoordinates } from '@/lib/speechVod'
import { speechVodCoordinates } from '@/lib/speechVod'
import type { SpeechCut } from '@/payload-types'
import {
withPayloadTransaction,
type PayloadTransactionRequest,
} from '@/utilities/payloadTransaction'
import { downloadSource, messageOf, runFfmpeg } from '@/utilities/speech/speechMediaPipeline'
import { resolveSpeechVod, SPEECH_VOD_CUT_POLICY } from '@/utilities/speech/speechVodResolver'
/**
@ -36,22 +33,10 @@ import { resolveSpeechVod, SPEECH_VOD_CUT_POLICY } from '@/utilities/speech/spee
* every failure leaves the row `failed` and nothing published.
*/
/** `FFMPEG_PATH` lets a test/runtime point at a fake binary; the image ships `ffmpeg`. */
const ffmpegBinary = (): string => process.env.FFMPEG_PATH?.trim() || 'ffmpeg'
/** A cut stopped mid-flight (deploy/restart) is reaped to `failed` after this. */
export const SPEECH_CUT_STALE_MS = 15 * 60_000
/** Defensive ceiling for the source download (the VOD of a speech is minutes long). */
const MAX_SOURCE_BYTES = 2 * 1024 * 1024 * 1024
const SOURCE_DOWNLOAD_TIMEOUT_MS = 180_000
const FFMPEG_MIN_TIMEOUT_MS = 30_000
const FFMPEG_MAX_TIMEOUT_MS = 300_000
const FFMPEG_MAX_BUFFER_BYTES = 8 * 1024 * 1024
const messageOf = (error: unknown): string =>
error instanceof Error && error.message !== '' ? error.message : 'Falha ao processar o corte.'
const FAILURE_FALLBACK = 'Falha ao processar o corte.'
// The job runs after the create response, with no request actor: every write
// below is an intentional admin bypass, justified because the row was created
@ -112,69 +97,6 @@ const failCut = (
const updateStep = (payload: Payload, cutId: number, step: SpeechCutStep): Promise<unknown> =>
updateCutSystem(payload, cutId, { step })
/**
* Streams the resolved VOD to a temp file. A `text/html` body is the CDN's
* error page wearing a 200 (same rule as the C162 probe) and is refused.
*/
const downloadSource = async (url: string, destination: string): Promise<void> => {
const response = await fetch(url, {
headers: { 'User-Agent': CAMARA_USER_AGENT },
signal: AbortSignal.timeout(SOURCE_DOWNLOAD_TIMEOUT_MS),
})
const contentType = response.headers.get('content-type') ?? ''
if (!response.ok || contentType.includes('text/html')) {
throw new Error(`A Câmara não entregou o arquivo do trecho (HTTP ${response.status}).`)
}
const length = Number(response.headers.get('content-length'))
if (Number.isFinite(length) && length > MAX_SOURCE_BYTES) {
throw new Error('O arquivo do trecho na Câmara é grande demais para cortar.')
}
if (!response.body) throw new Error('A Câmara não entregou o corpo do arquivo do trecho.')
// The counter enforces the ceiling even when the CDN answers chunked (no
// content-length): the stream aborts instead of filling the disk.
let received = 0
const guard = new Transform({
transform(chunk: Buffer, _encoding, callback) {
received += chunk.length
if (received > MAX_SOURCE_BYTES) {
callback(new Error('O arquivo do trecho na Câmara é grande demais para cortar.'))
return
}
callback(null, chunk)
},
})
await pipeline(
Readable.fromWeb(response.body as unknown as import('node:stream/web').ReadableStream),
guard,
createWriteStream(destination),
)
}
const runFfmpeg = (args: string[], durationSeconds: number): Promise<void> =>
new Promise((resolve, reject) => {
const timeout = Math.min(
FFMPEG_MAX_TIMEOUT_MS,
Math.max(FFMPEG_MIN_TIMEOUT_MS, durationSeconds * 8_000),
)
execFile(
ffmpegBinary(),
args,
{ timeout, maxBuffer: FFMPEG_MAX_BUFFER_BYTES },
(error, _stdout, stderr) => {
if (!error) {
resolve()
return
}
const detail = String(stderr ?? '')
.trim()
.slice(-400)
reject(new Error(`${messageOf(error)}${detail ? ` — ${detail}` : ''}`))
},
)
})
/**
* The whole pipeline of one cut. Never throws: an orphan job (server restart)
* is covered by the lazy reaper, and any error marks the row `failed` so the
@ -242,6 +164,7 @@ export const runSpeechCutJob = async (payload: Payload, cutId: number): Promise<
endSeconds: cut.endSeconds,
}),
durationSeconds,
FAILURE_FALLBACK,
)
await markStep('metadata')
@ -273,7 +196,9 @@ export const runSpeechCutJob = async (payload: Payload, cutId: number): Promise<
)
})
} catch (error) {
await failCut(payload, cutId, messageOf(error), currentStep).catch(() => undefined)
await failCut(payload, cutId, messageOf(error, FAILURE_FALLBACK), currentStep).catch(
() => undefined,
)
} finally {
if (tempDir) await rm(tempDir, { recursive: true, force: true }).catch(() => undefined)
}

View file

@ -0,0 +1,98 @@
import 'server-only'
import { execFile } from 'node:child_process'
import { createWriteStream } from 'node:fs'
import { Readable, Transform } from 'node:stream'
import { pipeline } from 'node:stream/promises'
import { CAMARA_USER_AGENT } from '@/lib/speechVod'
/**
* C182 — the Câmara media pipeline shared by the cut job (C167) and the acervo
* frame job (C182): download one resolved VOD to a temp file and run ffmpeg on
* it. Extracted from `speechCutJob` so both jobs own a single implementation;
* the temp dir lifecycle and the failure copy stay with each job.
*/
/** `FFMPEG_PATH` lets a test/runtime point at a fake binary; the image ships `ffmpeg`. */
const ffmpegBinary = (): string => process.env.FFMPEG_PATH?.trim() || 'ffmpeg'
/** Defensive ceiling for the source download (the VOD of a speech is minutes long). */
const MAX_SOURCE_BYTES = 2 * 1024 * 1024 * 1024
const SOURCE_DOWNLOAD_TIMEOUT_MS = 180_000
const FFMPEG_MIN_TIMEOUT_MS = 30_000
const FFMPEG_MAX_TIMEOUT_MS = 300_000
const FFMPEG_MAX_BUFFER_BYTES = 8 * 1024 * 1024
/** Last-resort copy when an error carries no message; each job names its own. */
export const messageOf = (error: unknown, fallback: string): string =>
error instanceof Error && error.message !== '' ? error.message : fallback
/**
* Streams the resolved VOD to a temp file. A `text/html` body is the CDN's
* error page wearing a 200 (same rule as the C162 probe) and is refused.
*/
export const downloadSource = async (url: string, destination: string): Promise<void> => {
const response = await fetch(url, {
headers: { 'User-Agent': CAMARA_USER_AGENT },
signal: AbortSignal.timeout(SOURCE_DOWNLOAD_TIMEOUT_MS),
})
const contentType = response.headers.get('content-type') ?? ''
if (!response.ok || contentType.includes('text/html')) {
throw new Error(`A Câmara não entregou o arquivo do trecho (HTTP ${response.status}).`)
}
const length = Number(response.headers.get('content-length'))
if (Number.isFinite(length) && length > MAX_SOURCE_BYTES) {
throw new Error('O arquivo do trecho na Câmara é grande demais para cortar.')
}
if (!response.body) throw new Error('A Câmara não entregou o corpo do arquivo do trecho.')
// The counter enforces the ceiling even when the CDN answers chunked (no
// content-length): the stream aborts instead of filling the disk.
let received = 0
const guard = new Transform({
transform(chunk: Buffer, _encoding, callback) {
received += chunk.length
if (received > MAX_SOURCE_BYTES) {
callback(new Error('O arquivo do trecho na Câmara é grande demais para cortar.'))
return
}
callback(null, chunk)
},
})
await pipeline(
Readable.fromWeb(response.body as unknown as import('node:stream/web').ReadableStream),
guard,
createWriteStream(destination),
)
}
/** Runs ffmpeg with a timeout scaled by the source duration (clamped to a floor/ceiling). */
export const runFfmpeg = (
args: string[],
durationSeconds: number,
fallbackMessage = 'Falha ao processar o vídeo.',
): Promise<void> =>
new Promise((resolve, reject) => {
const timeout = Math.min(
FFMPEG_MAX_TIMEOUT_MS,
Math.max(FFMPEG_MIN_TIMEOUT_MS, durationSeconds * 8_000),
)
execFile(
ffmpegBinary(),
args,
{ timeout, maxBuffer: FFMPEG_MAX_BUFFER_BYTES },
(error, _stdout, stderr) => {
if (!error) {
resolve()
return
}
const detail = String(stderr ?? '')
.trim()
.slice(-400)
reject(new Error(`${messageOf(error, fallbackMessage)}${detail ? ` — ${detail}` : ''}`))
},
)
})

View file

@ -45,6 +45,11 @@ const speechListSelect = {
presidingOfficer: true,
officialTextUrl: true,
youtubeUrl: true,
// C182 — the list decides the frame from the Câmara coordinates alone; the
// stored VOD links stay out of the list query (the poster job reads them).
eventId: true,
audioId: true,
excerptTMs: true,
} as const
const speechDetailSelect = {
@ -52,9 +57,6 @@ const speechDetailSelect = {
phase: true,
vodPlaybackUrl: true,
vodDownloadUrl: true,
eventId: true,
audioId: true,
excerptTMs: true,
eventStartAt: true,
} as const

View file

@ -0,0 +1,189 @@
import 'server-only'
import { mkdtemp, rm, stat } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import type { Payload } from 'payload'
import { formatSpeechDate } from '@/lib/speechClock'
import {
buildSpeechPosterFfmpegArgs,
speechPosterFilename,
speechPosterTarget,
} from '@/lib/speechPoster'
import type { Media } from '@/payload-types'
import {
withPayloadTransaction,
type PayloadTransactionRequest,
} from '@/utilities/payloadTransaction'
import { acquireTextAdvisoryLocks } from '@/utilities/postgresTransactionLocks'
import { downloadSource, messageOf, runFfmpeg } from '@/utilities/speech/speechMediaPipeline'
import { resolveSpeechVod, SPEECH_VOD_PLAYER_POLICY } from '@/utilities/speech/speechVodResolver'
/**
* C182 — the acervo frame cache: build the middle-of-the-speech still of one
* speech once and store it as `media` under a deterministic filename. The route
* calls `ensureSpeechPoster` on a cache miss; the list thumbnail then resolves
* through the media proxy. The VOD download + ffmpeg stay out of any request's
* critical path (the route only waits a short budget) and out of the DB
* transaction (the advisory lock guards just the cache write).
*/
/** Budget a list image request may wait before answering with the fallback. */
export const SPEECH_POSTER_WAIT_MS = 20_000
/** Concurrent generations per process: one scan asks for several images at once. */
const MAX_CONCURRENT_GENERATIONS = 2
/** A failure (or an unresolvable excerpt) is not retried on every view. */
const FAILURE_TTL_MS = 5 * 60_000
const FAILURE_FALLBACK = 'Falha ao gerar o quadro da fala.'
/** The cached frame of a speech, or null when it was never generated. */
export const findSpeechPosterMedia = async (
payload: Payload,
speechId: number,
req?: PayloadTransactionRequest,
): Promise<Media | null> => {
const result = await payload.find({
collection: 'media',
where: { filename: { equals: speechPosterFilename(speechId) } },
limit: 1,
depth: 0,
// Intentional admin bypass: the lookup is the cache probe of a derived file.
overrideAccess: true,
...(req ? { req } : {}),
})
return result.docs[0] ?? null
}
// Process-wide state: one Node server owns the cache, so an in-memory slot gate
// and a short failure memo are enough to keep a scan from stampeding the Câmara.
let activeGenerations = 0
const waiting: Array<() => void> = []
const inFlight = new Map<number, Promise<Media | null>>()
const failures = new Map<number, number>()
const rememberFailure = (speechId: number): void => {
const now = Date.now()
for (const [id, at] of failures) {
if (now - at >= FAILURE_TTL_MS) failures.delete(id)
}
failures.set(speechId, now)
}
const acquireGenerationSlot = async (): Promise<() => void> => {
if (activeGenerations >= MAX_CONCURRENT_GENERATIONS) {
await new Promise<void>((resolve) => waiting.push(resolve))
} else {
activeGenerations += 1
}
let released = false
return () => {
if (released) return
released = true
const next = waiting.shift()
if (next) next()
else activeGenerations -= 1
}
}
const generateSpeechPoster = async (payload: Payload, speechId: number): Promise<Media | null> => {
const speech = await payload.findByID({
collection: 'speech',
id: speechId,
depth: 0,
// Intentional admin bypass: the frame is derived from a speech the actor
// already opened behind the acervo gate.
overrideAccess: true,
})
const target = speechPosterTarget(speech)
if (!target) return null
const release = await acquireGenerationSlot()
let tempDir: string | null = null
try {
const resolution = await resolveSpeechVod(
{ eventId: target.eventId, audioId: target.audioId, excerptTms: target.excerptTms },
{
policy: SPEECH_VOD_PLAYER_POLICY,
cachedUrls: { playbackUrl: speech.vodPlaybackUrl, downloadUrl: speech.vodDownloadUrl },
},
)
if (resolution.state !== 'pronto') return null
const sourceUrl = resolution.playbackUrl ?? resolution.downloadUrl
if (!sourceUrl) return null
tempDir = await mkdtemp(join(tmpdir(), 'speech-poster-'))
const inputPath = join(tempDir, 'source.mp4')
const outputPath = join(tempDir, speechPosterFilename(speechId))
await downloadSource(sourceUrl, inputPath)
await runFfmpeg(
buildSpeechPosterFfmpegArgs({ inputPath, outputPath, atSeconds: target.offsetSeconds }),
speech.durationSeconds ?? 0,
)
// A seek past the end leaves no file: that is a failure, never a broken row.
await stat(outputPath)
return await withPayloadTransaction(payload, async ({ req }) => {
await acquireTextAdvisoryLocks(payload, req, [`speech-poster:${speechId}`])
const existing = await findSpeechPosterMedia(payload, speechId, req)
if (existing) return existing
return await payload.create({
collection: 'media',
data: { alt: `Quadro do meio da fala de ${formatSpeechDate(speech.speechAt)}` },
filePath: outputPath,
// Intentional admin bypass: the stored file is the speech's cache entry.
overrideAccess: true,
req,
})
})
} finally {
if (tempDir) await rm(tempDir, { recursive: true, force: true }).catch(() => undefined)
release()
}
}
/**
* Returns the cached frame of a speech, generating it on the first miss. Never
* throws: a failure (or an excerpt the Câmara cannot resolve right now)
* degrades to the YouTube cover (the caller's fallback) and is memoized for a
* short while so one bad speech cannot retry on every view.
*/
export const ensureSpeechPoster = async (
payload: Payload,
speechId: number,
): Promise<Media | null> => {
if (!Number.isInteger(speechId) || speechId <= 0) return null
const failedAt = failures.get(speechId)
if (failedAt !== undefined && Date.now() - failedAt < FAILURE_TTL_MS) return null
const cached = await findSpeechPosterMedia(payload, speechId)
if (cached) return cached
const running = inFlight.get(speechId)
if (running) return running
const generation = generateSpeechPoster(payload, speechId)
.then((media) => {
if (!media) rememberFailure(speechId)
return media
})
.catch((error: unknown) => {
rememberFailure(speechId)
console.warn(`[speech-poster] fala ${speechId}: ${messageOf(error, FAILURE_FALLBACK)}`)
return null
})
.finally(() => {
inFlight.delete(speechId)
})
inFlight.set(speechId, generation)
return generation
}

View file

@ -16,6 +16,7 @@ import {
type SpeechHighlightedExcerpt,
type SpeechHighlightPart,
} from '@/lib/speechHighlight'
import { speechPosterHref, speechPosterTarget } from '@/lib/speechPoster'
import { normalizeForSearch, speechMatchesSearchQuery } from '@/lib/speechSearch'
import {
correctedExcerptOffsetSeconds,
@ -23,8 +24,8 @@ import {
measuredVideoLagSeconds,
parseYoutubeVideoId,
sessionLagSeconds,
speechCoverUrl,
speechVodCoordinates,
youtubeThumbnailUrl,
} from '@/lib/speechVod'
import type { Municipality } from '@/payload-types'
import { speechScopeLabels, speechTopicLabels } from '@/utilities/speech/speechListUrl'
@ -46,6 +47,10 @@ export type SpeechListRecord = {
presidingOfficer?: string | null
officialTextUrl?: string | null
youtubeUrl?: string | null
/** C182 — the Câmara coordinates that make the frame resolvable. */
eventId?: number | null
audioId?: number | null
excerptTMs?: number | null
/** C174 — normalized search text; lets the row tell whether it matched `q`. */
searchText?: string | null
}
@ -64,7 +69,11 @@ export type SpeechListItemViewModel = {
scopes: { value: SpeechScope; label: string }[]
keywords: string[]
municipalities: { id: number; name: string }[]
/** YouTube cover of the session link (C175); null when the speech has none. */
/**
* C182 — the frame of the middle of the speech when the Câmara can re-resolve
* the excerpt, else the YouTube cover of the session (C175); null when
* neither exists. The list never sees the raw VOD URLs.
*/
thumbnailUrl: string | null
watchHref: string
/**
@ -198,6 +207,9 @@ export const parseSpeechSeekSeconds = (raw: string | undefined): number | null =
type SpeechCuts = readonly SpeechCutSummaryViewModel[]
const thumbnailUrlOf = (speech: SpeechListRecord): string | null =>
speechPosterTarget(speech) ? speechPosterHref(speech.id) : speechCoverUrl(speech.youtubeUrl)
export const toSpeechListItemViewModel = ({
speech,
segments,
@ -235,7 +247,7 @@ export const toSpeechListItemViewModel = ({
scopes: scopeViewModels(speech),
keywords: speech.keywords ?? [],
municipalities: municipalityViewModels(speech, municipalityLabels),
thumbnailUrl: youtubeThumbnailUrl(parseYoutubeVideoId(speech.youtubeUrl)),
thumbnailUrl: thumbnailUrlOf(speech),
watchHref: buildWatchHref(speech.id, matchedSegment, q),
officialTextUrl: speech.officialTextUrl ?? null,
cuts: [...cuts],
@ -245,11 +257,6 @@ export const toSpeechListItemViewModel = ({
export type SpeechDetailRecord = SpeechListRecord & {
phase?: string | null
vodPlaybackUrl?: string | null
vodDownloadUrl?: string | null
eventId?: number | null
audioId?: number | null
excerptTMs?: number | null
eventStartAt?: string | null
}

View file

@ -35,6 +35,8 @@ const createSpeech = async (
keywords?: string[]
durationSeconds?: number
withVideo?: boolean
/** C182 — drop the Câmara coordinates so the frame is not resolvable. */
withCoordinates?: boolean
youtubeUrl?: string | null
eventStartAt?: string
/** C177 — set to null to create a speech without the official text. */
@ -42,6 +44,7 @@ const createSpeech = async (
},
) => {
const marker = input.marker
const withCoordinates = input.withCoordinates ?? true
const speech = await campaign.fixtures.payload.create({
collection: 'speech',
data: {
@ -63,9 +66,9 @@ const createSpeech = async (
input.officialTextUrl === undefined
? 'https://camara.leg.br/discurso'
: input.officialTextUrl,
eventId: 67091,
audioId: 558641,
excerptTMs: EXCERPT_TMS,
eventId: withCoordinates ? 67091 : null,
audioId: withCoordinates ? 558641 : null,
excerptTMs: withCoordinates ? EXCERPT_TMS : null,
eventStartAt: input.eventStartAt ?? EXCERPT_SESSION,
youtubeUrl: input.youtubeUrl ?? null,
...(input.withVideo === false
@ -157,9 +160,10 @@ test.describe('communication vertical (C154/C162)', () => {
expect(resultsHtml).toContain('<mark')
expect(resultsHtml).toContain('Assistir no trecho')
expect(resultsHtml).toContain('1 fala encontrada')
// C175 — the card carries the trecho thumbnail hotlinked from the YouTube
// CDN, linked to the same watch target the CTA owns.
expect(resultsHtml).toContain(`https://i.ytimg.com/vi/${YOUTUBE_VIDEO_ID}/hqdefault.jpg`)
// C182 — the card points at the frame of the speech (middle of the trecho),
// served by the internal poster route; the session cover does not take over.
expect(resultsHtml).toContain(`/campanha/comunicacao/acervo/${speech.id}/poster`)
expect(resultsHtml).not.toContain(`https://i.ytimg.com/vi/${YOUTUBE_VIDEO_ID}/hqdefault.jpg`)
// C162 — the direct download left the card, and the stored ephemeral link
// never reaches any list HTML.
expect(resultsHtml).not.toContain('Baixar')
@ -278,8 +282,11 @@ test.describe('communication vertical (C154/C162)', () => {
const results = await request.get(`/campanha/comunicacao/acervo?q=${marker}`)
expect(results.status()).toBe(200)
// C175 — no YouTube session link means no thumbnail and no reserved media.
expect(rendered(await results.text())).not.toContain('i.ytimg.com')
// C182 — a VOD-only speech still gets a frame at the middle of the trecho,
// with no YouTube cover anywhere.
const resultsHtml = rendered(await results.text())
expect(resultsHtml).toContain(`/campanha/comunicacao/acervo/${speech.id}/poster`)
expect(resultsHtml).not.toContain('i.ytimg.com')
const detail = await request.get(`/campanha/comunicacao/acervo/${speech.id}`)
expect(detail.status()).toBe(200)
@ -422,4 +429,53 @@ test.describe('communication vertical (C154/C162)', () => {
expect(await response.text()).toContain('não tem trecho de vídeo')
})
})
test.describe('GET /campanha/comunicacao/acervo/:id/poster (C182)', () => {
// Only the network-free paths are asserted here: a resolvable speech would
// ask the real Câmara, which the browserless suite must never do.
test('falls back to the YouTube cover without Câmara coordinates', async ({
campaign,
campaignRequest,
}) => {
const marker = campaign.fixtures.value('poster')
const speech = await createSpeech(campaign, {
marker,
withCoordinates: false,
youtubeUrl: `https://www.youtube.com/watch?v=${YOUTUBE_VIDEO_ID}`,
})
const user = await campaign.fixtures.createCampaignUser('communicator')
const request = await campaignRequest(user, user.password)
const response = await request.get(`/campanha/comunicacao/acervo/${speech.id}/poster`, {
maxRedirects: 0,
})
expect(response.status()).toBe(302)
expect(response.headers()['location']).toContain(
`i.ytimg.com/vi/${YOUTUBE_VIDEO_ID}/hqdefault.jpg`,
)
})
test('answers 404 without any media and refuses an actor outside the catalog', async ({
campaign,
campaignRequest,
}) => {
const marker = campaign.fixtures.value('poster404')
const bare = await createSpeech(campaign, { marker, withCoordinates: false })
const communicator = await campaign.fixtures.createCampaignUser('communicator')
const communicatorRequest = await campaignRequest(communicator, communicator.password)
const notFound = await communicatorRequest.get(
`/campanha/comunicacao/acervo/${bare.id}/poster`,
{ maxRedirects: 0 },
)
expect(notFound.status()).toBe(404)
const advisor = await campaign.fixtures.createCampaignUser('advisor')
const advisorRequest = await campaignRequest(advisor, advisor.password)
const denied = await advisorRequest.get(`/campanha/comunicacao/acervo/${bare.id}/poster`, {
maxRedirects: 0,
})
expect(denied.status()).toBe(404)
})
})
})

View file

@ -0,0 +1,53 @@
/**
* Shared Câmara VOD fetch double for the cut (C167) and frame (C182) int specs:
* answers `video-sob-demanda` with a status document and the media URLs with the
* requested bytes. Anything else throws, so a spec cannot silently hit the real
* Câmara. `deadUrls` forces a 403 on a specific media link.
*/
export const PLAYBACK_URL = 'https://cdn.camara.leg.br/trecho.mp4'
export const DOWNLOAD_URL = 'https://cdn.camara.leg.br/trecho-download.mp4'
export const MP4_BYTES = Buffer.from('camara-mp4-bytes')
export const camaraVodStub = ({
apiState = 'PRONTO',
apiPlaybackUrl = PLAYBACK_URL,
apiDownloadUrl = DOWNLOAD_URL,
mediaBytes = MP4_BYTES,
deadUrls = [],
}: {
apiState?: 'PRONTO' | 'INDISPONIVEL' | 'GERANDO'
apiPlaybackUrl?: string
apiDownloadUrl?: string
mediaBytes?: Buffer
deadUrls?: string[]
} = {}): typeof fetch =>
(async (input: RequestInfo | URL) => {
const url = String(input)
if (url.includes('video-sob-demanda')) {
return new Response(
JSON.stringify({
estado: apiState,
video:
apiState === 'PRONTO'
? { linkParaReproducao: apiPlaybackUrl, linkParaDownload: apiDownloadUrl }
: null,
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
)
}
if (deadUrls.includes(url)) return new Response('', { status: 403 })
if (
url === apiPlaybackUrl ||
url === apiDownloadUrl ||
url === PLAYBACK_URL ||
url === DOWNLOAD_URL
) {
return new Response(new Uint8Array(mediaBytes), {
status: 200,
headers: { 'Content-Type': 'video/mp4', 'Content-Length': String(mediaBytes.length) },
})
}
throw new Error(`Unexpected fetch to ${url}`)
}) as typeof fetch
export const camaraFetchStub = (bytes: Buffer): typeof fetch => camaraVodStub({ mediaBytes: bytes })

View file

@ -7,6 +7,7 @@ import { getPayload } from 'payload'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { matchMunicipalityMentions } from '@/lib/speechGazetteer'
import { speechPosterHref } from '@/lib/speechPoster'
import config from '@/payload.config'
import { upsertSpeechBundle, type SpeechImportBundle } from '@/utilities/speech/speechImport'
import {
@ -293,24 +294,36 @@ describe('speech acervo (C154)', () => {
expect(row).not.toHaveProperty('downloadUrl')
})
it('propagates the YouTube thumbnail to the list view model (C175)', async () => {
it('points the list thumbnail at the frame of the speech, or the YouTube cover (C182)', async () => {
const runId = randomUUID().slice(0, 8)
const marker = `miniatura${runId}`
const withVideo = await createSpeech({
const resolvable = await createSpeech({
youtubeUrl: 'https://www.youtube.com/watch?v=lLhRDkSPw0A',
segments: [{ startSeconds: 0, endSeconds: 3, text: `A fala ${marker} no plenário` }],
})
const youtubeOnly = await createSpeech({
eventId: null,
audioId: null,
excerptTMs: null,
youtubeUrl: 'https://www.youtube.com/watch?v=lLhRDkSPw0A',
segments: [{ startSeconds: 0, endSeconds: 3, text: `A fala ${marker} só no YouTube` }],
})
const withoutVideo = await createSpeech({
eventId: null,
audioId: null,
excerptTMs: null,
segments: [{ startSeconds: 0, endSeconds: 3, text: `A fala ${marker} sem vídeo` }],
})
const { communicator } = await createUsers()
const data = await loadSpeechAcervoPageData(payload, communicator, { q: marker })
const row = (id: number) => data.rows.find((item) => item.id === id)
expect(data.rows.find((row) => row.id === withVideo)?.thumbnailUrl).toBe(
'https://i.ytimg.com/vi/lLhRDkSPw0A/hqdefault.jpg',
)
expect(data.rows.find((row) => row.id === withoutVideo)?.thumbnailUrl).toBeNull()
expect(row(resolvable)?.thumbnailUrl).toBe(speechPosterHref(resolvable))
expect(row(youtubeOnly)?.thumbnailUrl).toBe('https://i.ytimg.com/vi/lLhRDkSPw0A/hqdefault.jpg')
expect(row(withoutVideo)?.thumbnailUrl).toBeNull()
// The raw VOD coordinates never reach the list view model.
expect(JSON.stringify(row(resolvable))).not.toContain('vod.camara.leg.br')
})
it('allows communicator/coordinator/candidate and denies advisor/leader', async () => {

View file

@ -65,13 +65,17 @@ import { startSpeechCutJobInBackground } from '@/utilities/speech/speechCutSched
import { upsertSpeechBundle, type SpeechImportBundle } from '@/utilities/speech/speechImport'
import { loadSpeechAcervoPageData } from '@/utilities/speech/speechPageData'
import {
camaraFetchStub,
camaraVodStub,
DOWNLOAD_URL,
MP4_BYTES,
PLAYBACK_URL,
} from '../helpers/camaraVodStub'
import { installCampaignFixtures } from '../helpers/campaignFixtures'
import { speechBundleFixture } from '../helpers/speechBundleFixture'
const FAKE_FFMPEG = fileURLToPath(new URL('../fixtures/fake-ffmpeg.mjs', import.meta.url))
const PLAYBACK_URL = 'https://cdn.camara.leg.br/trecho.mp4'
const DOWNLOAD_URL = 'https://cdn.camara.leg.br/trecho-download.mp4'
const MP4_BYTES = Buffer.from('camara-mp4-bytes')
const hasBinary = (name: string): boolean => spawnSync(name, ['-version']).status === 0
const hasFfmpeg = hasBinary('ffmpeg') && hasBinary('ffprobe')
@ -90,50 +94,6 @@ const campaignFixtures = installCampaignFixtures({
const mockedGetContext = vi.mocked(getCampaignActionContext)
const mockedStartJob = vi.mocked(startSpeechCutJobInBackground)
const camaraVodStub = ({
apiState = 'PRONTO',
apiPlaybackUrl = PLAYBACK_URL,
apiDownloadUrl = DOWNLOAD_URL,
mediaBytes = MP4_BYTES,
deadUrls = [],
}: {
apiState?: 'PRONTO' | 'INDISPONIVEL' | 'GERANDO'
apiPlaybackUrl?: string
apiDownloadUrl?: string
mediaBytes?: Buffer
deadUrls?: string[]
} = {}): typeof fetch =>
(async (input: RequestInfo | URL) => {
const url = String(input)
if (url.includes('video-sob-demanda')) {
return new Response(
JSON.stringify({
estado: apiState,
video:
apiState === 'PRONTO'
? { linkParaReproducao: apiPlaybackUrl, linkParaDownload: apiDownloadUrl }
: null,
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
)
}
if (deadUrls.includes(url)) return new Response('', { status: 403 })
if (
url === apiPlaybackUrl ||
url === apiDownloadUrl ||
url === PLAYBACK_URL ||
url === DOWNLOAD_URL
) {
return new Response(new Uint8Array(mediaBytes), {
status: 200,
headers: { 'Content-Type': 'video/mp4', 'Content-Length': String(mediaBytes.length) },
})
}
throw new Error(`Unexpected fetch to ${url}`)
}) as typeof fetch
const camaraFetchStub = (bytes: Buffer): typeof fetch => camaraVodStub({ mediaBytes: bytes })
const createSpeech = async (overrides: Partial<SpeechImportBundle> = {}): Promise<number> => {
const bundle = speechBundleFixture({
vodPlaybackUrl: PLAYBACK_URL,

View file

@ -0,0 +1,254 @@
// @vitest-environment node
import { spawnSync } from 'node:child_process'
import { mkdirSync, readFileSync, rmSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import type { Payload } from 'payload'
import { getPayload } from 'payload'
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
// The Media afterChange hook revalidates its document tag, which needs the Next
// runtime — neuter it (the revalidation itself is pinned elsewhere).
vi.mock('next/cache', () => ({
revalidatePath: () => undefined,
revalidateTag: () => undefined,
}))
import { buildSpeechPosterFfmpegArgs, speechPosterFilename } from '@/lib/speechPoster'
import config from '@/payload.config'
import type { SpeechImportBundle } from '@/utilities/speech/speechImport'
import { upsertSpeechBundle } from '@/utilities/speech/speechImport'
import { runFfmpeg } from '@/utilities/speech/speechMediaPipeline'
import { ensureSpeechPoster, findSpeechPosterMedia } from '@/utilities/speech/speechPosterJob'
import { camaraVodStub, PLAYBACK_URL } from '../helpers/camaraVodStub'
import { speechBundleFixture } from '../helpers/speechBundleFixture'
const FAKE_FFMPEG = fileURLToPath(new URL('../fixtures/fake-ffmpeg.mjs', import.meta.url))
const hasBinary = (name: string): boolean => spawnSync(name, ['-version']).status === 0
const hasFfmpeg = hasBinary('ffmpeg') && hasBinary('ffprobe')
let payload: Payload
const createdSourceKeys = new Set<string>()
const createdMediaIds = new Set<number>()
const withEnv = (key: string, value: string | undefined): (() => void) => {
const previous = process.env[key]
if (value === undefined) delete process.env[key]
else process.env[key] = value
return () => {
if (previous === undefined) delete process.env[key]
else process.env[key] = previous
}
}
/** Stub that records every URL it answered, so cache hits are observable. */
const countingCamaraStub = (options?: Parameters<typeof camaraVodStub>[0]) => {
const answered: string[] = []
const base = camaraVodStub(options)
const stub = (async (input: RequestInfo | URL, init?: RequestInit) => {
answered.push(String(input))
return base(input as RequestInfo, init)
}) as typeof fetch
return { stub, answered }
}
const createSpeech = async (overrides: Partial<SpeechImportBundle> = {}): Promise<number> => {
const bundle = speechBundleFixture(overrides)
createdSourceKeys.add(bundle.sourceKey)
await upsertSpeechBundle(payload, bundle)
const found = await payload.find({
collection: 'speech',
where: { sourceKey: { equals: bundle.sourceKey } },
depth: 0,
limit: 1,
overrideAccess: true,
})
const speech = found.docs[0]
if (!speech) throw new Error('fixture speech was not created')
return speech.id
}
describe('speech poster cache (C182)', () => {
beforeAll(async () => {
payload = await getPayload({ config: await config })
})
afterEach(() => {
vi.unstubAllGlobals()
})
afterAll(async () => {
for (const mediaId of createdMediaIds) {
await payload
.delete({ collection: 'media', id: mediaId, overrideAccess: true })
.catch(() => undefined)
}
for (const sourceKey of createdSourceKeys) {
const found = await payload.find({
collection: 'speech',
where: { sourceKey: { equals: sourceKey } },
depth: 0,
limit: 1,
overrideAccess: true,
})
const speech = found.docs[0]
if (speech) {
await payload
.delete({ collection: 'speech', id: speech.id, depth: 0, overrideAccess: true })
.catch(() => undefined)
}
}
})
it('generates the frame once, caches it in media and reuses it', async () => {
const restoreFfmpeg = withEnv('FFMPEG_PATH', FAKE_FFMPEG)
const { stub, answered } = countingCamaraStub()
vi.stubGlobal('fetch', stub)
try {
const speechId = await createSpeech({ durationSeconds: 252 })
const media = await ensureSpeechPoster(payload, speechId)
expect(media?.filename).toBe(speechPosterFilename(speechId))
expect(media?.alt).toContain('meio da fala')
if (!media) throw new Error('poster was not generated')
createdMediaIds.add(media.id)
// The stored file is the fake ffmpeg's copy of the downloaded source.
expect(readFileSync(resolve(process.cwd(), 'media', media.filename as string))).toEqual(
Buffer.from('camara-mp4-bytes'),
)
const sourceRequests = answered.filter((url) => url === PLAYBACK_URL)
expect(sourceRequests.length).toBeGreaterThan(0)
answered.length = 0
const again = await ensureSpeechPoster(payload, speechId)
expect(again?.id).toBe(media.id)
expect(answered).toEqual([])
const cached = await findSpeechPosterMedia(payload, speechId)
expect(cached?.id).toBe(media.id)
} finally {
restoreFfmpeg()
}
})
it('shares one generation between concurrent requests (single flight)', async () => {
const restoreFfmpeg = withEnv('FFMPEG_PATH', FAKE_FFMPEG)
const { stub, answered } = countingCamaraStub()
vi.stubGlobal('fetch', stub)
try {
const speechId = await createSpeech({ durationSeconds: 252 })
const [first, second] = await Promise.all([
ensureSpeechPoster(payload, speechId),
ensureSpeechPoster(payload, speechId),
])
if (!first) throw new Error('poster was not generated')
createdMediaIds.add(first.id)
// Single flight: both callers share the one resolution of the excerpt
// (without it the Câmara API would be asked twice).
expect(second?.id).toBe(first.id)
expect(answered.filter((url) => url.includes('video-sob-demanda'))).toHaveLength(1)
const rows = await payload.find({
collection: 'media',
where: { filename: { equals: speechPosterFilename(speechId) } },
depth: 0,
limit: 0,
pagination: false,
overrideAccess: true,
})
expect(rows.docs).toHaveLength(1)
} finally {
restoreFfmpeg()
}
})
it('memoizes an unresolvable excerpt instead of asking the Câmara on every view', async () => {
const restoreFfmpeg = withEnv('FFMPEG_PATH', FAKE_FFMPEG)
const { stub, answered } = countingCamaraStub({ apiState: 'INDISPONIVEL' })
vi.stubGlobal('fetch', stub)
try {
const speechId = await createSpeech({ durationSeconds: 252 })
expect(await ensureSpeechPoster(payload, speechId)).toBeNull()
expect(answered.length).toBeGreaterThan(0)
answered.length = 0
expect(await ensureSpeechPoster(payload, speechId)).toBeNull()
expect(answered).toEqual([])
} finally {
restoreFfmpeg()
}
})
it('returns null when the Câmara cannot resolve the excerpt or has no coordinates', async () => {
const restoreFfmpeg = withEnv('FFMPEG_PATH', FAKE_FFMPEG)
vi.stubGlobal('fetch', camaraVodStub({ apiState: 'INDISPONIVEL' }))
try {
const unavailable = await createSpeech({ durationSeconds: 252 })
expect(await ensureSpeechPoster(payload, unavailable)).toBeNull()
expect(await findSpeechPosterMedia(payload, unavailable)).toBeNull()
vi.stubGlobal('fetch', camaraVodStub())
const noCoordinates = await createSpeech({
eventId: null,
audioId: null,
excerptTMs: null,
})
expect(await ensureSpeechPoster(payload, noCoordinates)).toBeNull()
} finally {
restoreFfmpeg()
}
})
it('never caches a frame for an unknown speech id', async () => {
vi.stubGlobal('fetch', camaraVodStub())
expect(await ensureSpeechPoster(payload, -1)).toBeNull()
expect(await ensureSpeechPoster(payload, Number.NaN)).toBeNull()
})
it.skipIf(!hasFfmpeg)('extracts a real JPEG with ffmpeg at the speech midpoint', async () => {
const dir = resolve(process.cwd(), 'tests', '.tmp-poster')
const restoreFfmpeg = withEnv('FFMPEG_PATH', undefined)
try {
rmSync(dir, { recursive: true, force: true })
mkdirSync(dir, { recursive: true })
const source = join(dir, 'source.mp4')
const output = join(dir, 'poster.jpg')
const generated = spawnSync(
'ffmpeg',
[
'-nostdin',
'-hide_banner',
'-y',
'-f',
'lavfi',
'-i',
'testsrc=size=320x240:rate=10:duration=4',
source,
],
{ encoding: 'utf8' },
)
expect(generated.status).toBe(0)
await runFfmpeg(
buildSpeechPosterFfmpegArgs({ inputPath: source, outputPath: output, atSeconds: 2 }),
4,
)
const bytes = readFileSync(output)
expect(bytes.subarray(0, 2)).toEqual(Buffer.from([0xff, 0xd8]))
} finally {
restoreFfmpeg()
rmSync(dir, { recursive: true, force: true })
}
})
})

View file

@ -0,0 +1,105 @@
// @vitest-environment node
import { describe, expect, it } from 'vitest'
import {
buildSpeechPosterFfmpegArgs,
speechPosterFilename,
speechPosterHref,
speechPosterOffsetSeconds,
speechPosterTarget,
} from '@/lib/speechPoster'
describe('speechPosterOffsetSeconds', () => {
it('points at the middle of the speech (intent literal)', () => {
expect(speechPosterOffsetSeconds(252)).toBe(126)
expect(speechPosterOffsetSeconds(601)).toBe(300)
})
it('returns null without a usable duration', () => {
expect(speechPosterOffsetSeconds(0)).toBeNull()
expect(speechPosterOffsetSeconds(-5)).toBeNull()
expect(speechPosterOffsetSeconds(null)).toBeNull()
expect(speechPosterOffsetSeconds(undefined)).toBeNull()
expect(speechPosterOffsetSeconds(Number.NaN)).toBeNull()
})
})
describe('speechPosterTarget', () => {
const coordinates = { eventId: 67091, audioId: 558641, excerptTMs: 1675801808560 }
it('joins the Câmara coordinates with the midpoint, stored VOD or not', () => {
const expected = {
eventId: 67091,
audioId: 558641,
excerptTms: 1675801808560,
offsetSeconds: 126,
}
expect(speechPosterTarget({ ...coordinates, durationSeconds: 252 })).toEqual(expected)
expect(
speechPosterTarget({
...coordinates,
durationSeconds: 252,
vodPlaybackUrl: 'https://vod.camara.leg.br/x',
}),
).toEqual(expected)
})
it('is ineligible without any coordinate or without a duration', () => {
expect(speechPosterTarget({ ...coordinates, durationSeconds: 252, eventId: null })).toBeNull()
expect(speechPosterTarget({ ...coordinates, durationSeconds: 252, audioId: null })).toBeNull()
expect(
speechPosterTarget({ ...coordinates, durationSeconds: 252, excerptTMs: null }),
).toBeNull()
expect(speechPosterTarget({ ...coordinates, durationSeconds: 0 })).toBeNull()
expect(speechPosterTarget({ durationSeconds: 252 })).toBeNull()
})
})
describe('speechPosterFilename / speechPosterHref', () => {
it('builds the deterministic cache name and the internal route', () => {
expect(speechPosterFilename(42)).toBe('speech-poster-42.jpg')
expect(speechPosterHref(42)).toBe('/campanha/comunicacao/acervo/42/poster')
})
})
describe('buildSpeechPosterFfmpegArgs', () => {
it('seeks to the midpoint and takes a single scaled frame', () => {
const args = buildSpeechPosterFfmpegArgs({
inputPath: '/tmp/source.mp4',
outputPath: '/tmp/speech-poster-42.jpg',
atSeconds: 126.4,
})
expect(args).toEqual([
'-nostdin',
'-hide_banner',
'-y',
'-ss',
'126',
'-i',
'/tmp/source.mp4',
'-frames:v',
'1',
'-vf',
'scale=480:-2',
'-q:v',
'2',
'-an',
'/tmp/speech-poster-42.jpg',
])
// The seek is an input option: before `-i`, never after.
expect(args.indexOf('-ss')).toBeLessThan(args.indexOf('-i'))
})
it('never seeks to a negative instant', () => {
const args = buildSpeechPosterFfmpegArgs({
inputPath: 'in.mp4',
outputPath: 'out.jpg',
atSeconds: -3,
})
expect(args[args.indexOf('-ss') + 1]).toBe('0')
})
})

View file

@ -2,6 +2,7 @@
import { describe, expect, it } from 'vitest'
import { speechPosterHref } from '@/lib/speechPoster'
import {
toSpeechListItemViewModel,
type SpeechListRecord,
@ -45,3 +46,21 @@ describe('toSpeechListItemViewModel thumbnail (C175)', () => {
)
})
})
describe('toSpeechListItemViewModel thumbnail (C182 — frame of the speech)', () => {
const coordinates = { eventId: 67091, audioId: 558641, excerptTMs: 1675801808560 }
const cover = 'https://i.ytimg.com/vi/lLhRDkSPw0A/hqdefault.jpg'
const youtubeUrl = 'https://www.youtube.com/watch?v=lLhRDkSPw0A'
it('prefers the frame route over the session cover when the Câmara can resolve the excerpt', () => {
const row = viewModel({ id: 42, durationSeconds: 252, ...coordinates, youtubeUrl })
expect(row.thumbnailUrl).toBe(speechPosterHref(42))
})
it('falls back to the session cover without coordinates or without a duration', () => {
expect(viewModel({ durationSeconds: 252, youtubeUrl }).thumbnailUrl).toBe(cover)
expect(viewModel({ durationSeconds: 0, ...coordinates, youtubeUrl }).thumbnailUrl).toBe(cover)
expect(viewModel({ durationSeconds: 252, eventId: 67091 }).thumbnailUrl).toBeNull()
})
})