diff --git a/docs/changelog/2026-09-16-c182.md b/docs/changelog/2026-09-16-c182.md new file mode 100644 index 00000000..97dccd6d --- /dev/null +++ b/docs/changelog/2026-09-16-c182.md @@ -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//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-.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:'])` 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). diff --git a/docs/plans/acervo-preview-frame-do-meio-da-fala-impl.md b/docs/plans/acervo-preview-frame-do-meio-da-fala-impl.md new file mode 100644 index 00000000..dd255404 --- /dev/null +++ b/docs/plans/acervo-preview-frame-do-meio-da-fala-impl.md @@ -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
+ eventId/audioId/excerptTMs"] --> B["toSpeechListItemViewModel
speechPosterTarget (coordenadas + duration/2)"] + B -->|"coordenadas + duração"| C["thumbnailUrl =
/campanha/comunicacao/acervo/<id>/poster"] + B -->|"sem coordenadas"| D["youtubeThumbnailUrl(videoId)
ou null"] + C --> E["<img loading=lazy>
SpeechResultThumbnail (inalterado)"] + E --> F["GET /[id]/poster
gate canReadSpeechCatalog"] + F -->|"media cache hit"| G["302 media.url
private, max-age=1d"] + F -->|"miss"| H["ensureSpeechPoster(id)
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
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 `` 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-.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:'])` 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-.jpg`; `speechPosterHref(id)` → `${CAMPAIGN_COMMUNICATION_ACERVO}/${id}/poster`; `buildSpeechPosterFfmpegArgs({ inputPath, outputPath, atSeconds })` → `['-nostdin','-hide_banner','-y','-ss',,'-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:'])` + 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-.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. diff --git a/docs/plans/escala-dry-pos-c182.md b/docs/plans/escala-dry-pos-c182.md new file mode 100644 index 00000000..bb3f5b0b --- /dev/null +++ b/docs/plans/escala-dry-pos-c182.md @@ -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-.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--.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. diff --git a/src/app/(campaign)/campanha/(app)/comunicacao/acervo/[id]/poster/route.ts b/src/app/(campaign)/campanha/(app)/comunicacao/acervo/[id]/poster/route.ts new file mode 100644 index 00000000..e32ba6a6 --- /dev/null +++ b/src/app/(campaign)/campanha/(app)/comunicacao/acervo/[id]/poster/route.ts @@ -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 ( + promise: Promise, + budgetMs: number, +): Promise => { + let timer: ReturnType | undefined + try { + return await Promise.race([ + promise, + new Promise((resolve) => { + timer = setTimeout(() => resolve(null), budgetMs) + }), + ]) + } finally { + if (timer) clearTimeout(timer) + } +} + +export const GET = async ( + _request: Request, + { params }: { params: Promise<{ id: string }> }, +): Promise => { + 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) +} diff --git a/src/components/campaign/speech/SpeechResultThumbnail.tsx b/src/components/campaign/speech/SpeechResultThumbnail.tsx index 52e6975c..2c7dfee4 100644 --- a/src/components/campaign/speech/SpeechResultThumbnail.tsx +++ b/src/components/campaign/speech/SpeechResultThumbnail.tsx @@ -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 : } - {/* 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). */} { + 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, + ] +} diff --git a/src/lib/speechVod.ts b/src/lib/speechVod.ts index d9d0c3dd..a1101b65 100644 --- a/src/lib/speechVod.ts +++ b/src/lib/speechVod.ts @@ -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', { diff --git a/src/utilities/speech/speechCutJob.ts b/src/utilities/speech/speechCutJob.ts index e0137e89..243696c6 100644 --- a/src/utilities/speech/speechCutJob.ts +++ b/src/utilities/speech/speechCutJob.ts @@ -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 => 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 => { - 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 => - 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) } diff --git a/src/utilities/speech/speechMediaPipeline.ts b/src/utilities/speech/speechMediaPipeline.ts new file mode 100644 index 00000000..9df56d69 --- /dev/null +++ b/src/utilities/speech/speechMediaPipeline.ts @@ -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 => { + 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 => + 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}` : ''}`)) + }, + ) + }) diff --git a/src/utilities/speech/speechPageData.ts b/src/utilities/speech/speechPageData.ts index d9248d8d..743df116 100644 --- a/src/utilities/speech/speechPageData.ts +++ b/src/utilities/speech/speechPageData.ts @@ -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 diff --git a/src/utilities/speech/speechPosterJob.ts b/src/utilities/speech/speechPosterJob.ts new file mode 100644 index 00000000..756da6cf --- /dev/null +++ b/src/utilities/speech/speechPosterJob.ts @@ -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 => { + 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>() +const failures = new Map() + +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((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 => { + 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 => { + 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 +} diff --git a/src/utilities/speech/speechViewModels.ts b/src/utilities/speech/speechViewModels.ts index 69f3d5a6..f5d9e22e 100644 --- a/src/utilities/speech/speechViewModels.ts +++ b/src/utilities/speech/speechViewModels.ts @@ -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 } diff --git a/tests/e2e/campaignSpeechAcervo.e2e.spec.ts b/tests/e2e/campaignSpeechAcervo.e2e.spec.ts index 79cfcad7..b2b30346 100644 --- a/tests/e2e/campaignSpeechAcervo.e2e.spec.ts +++ b/tests/e2e/campaignSpeechAcervo.e2e.spec.ts @@ -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(' { 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) + }) + }) }) diff --git a/tests/helpers/camaraVodStub.ts b/tests/helpers/camaraVodStub.ts new file mode 100644 index 00000000..adfc3b24 --- /dev/null +++ b/tests/helpers/camaraVodStub.ts @@ -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 }) diff --git a/tests/int/speechAcervo.int.spec.ts b/tests/int/speechAcervo.int.spec.ts index dbc51b5c..e51359cb 100644 --- a/tests/int/speechAcervo.int.spec.ts +++ b/tests/int/speechAcervo.int.spec.ts @@ -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 () => { diff --git a/tests/int/speechCut.int.spec.ts b/tests/int/speechCut.int.spec.ts index 81be18b3..b5e6b259 100644 --- a/tests/int/speechCut.int.spec.ts +++ b/tests/int/speechCut.int.spec.ts @@ -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 = {}): Promise => { const bundle = speechBundleFixture({ vodPlaybackUrl: PLAYBACK_URL, diff --git a/tests/int/speechPoster.int.spec.ts b/tests/int/speechPoster.int.spec.ts new file mode 100644 index 00000000..b0a53e61 --- /dev/null +++ b/tests/int/speechPoster.int.spec.ts @@ -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() +const createdMediaIds = new Set() + +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[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 = {}): Promise => { + 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 }) + } + }) +}) diff --git a/tests/unit/speechPoster.unit.spec.ts b/tests/unit/speechPoster.unit.spec.ts new file mode 100644 index 00000000..ed2fb2fe --- /dev/null +++ b/tests/unit/speechPoster.unit.spec.ts @@ -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') + }) +}) diff --git a/tests/unit/speechViewModels.unit.spec.ts b/tests/unit/speechViewModels.unit.spec.ts index 50ea6f97..e5cb9d57 100644 --- a/tests/unit/speechViewModels.unit.spec.ts +++ b/tests/unit/speechViewModels.unit.spec.ts @@ -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() + }) +})