mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 15:13:07 +00:00
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:
parent
d5c6c285b7
commit
1133210c7c
19 changed files with 1213 additions and 166 deletions
1
docs/changelog/2026-09-16-c182.md
Normal file
1
docs/changelog/2026-09-16-c182.md
Normal 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).
|
||||
130
docs/plans/acervo-preview-frame-do-meio-da-fala-impl.md
Normal file
130
docs/plans/acervo-preview-frame-do-meio-da-fala-impl.md
Normal 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/<id>/poster"]
|
||||
B -->|"sem coordenadas"| D["youtubeThumbnailUrl(videoId)<br/>ou null"]
|
||||
C --> E["<img loading=lazy><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.
|
||||
35
docs/plans/escala-dry-pos-c182.md
Normal file
35
docs/plans/escala-dry-pos-c182.md
Normal 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.
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
91
src/lib/speechPoster.ts
Normal 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,
|
||||
]
|
||||
}
|
||||
|
|
@ -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', {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
98
src/utilities/speech/speechMediaPipeline.ts
Normal file
98
src/utilities/speech/speechMediaPipeline.ts
Normal 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}` : ''}`))
|
||||
},
|
||||
)
|
||||
})
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
189
src/utilities/speech/speechPosterJob.ts
Normal file
189
src/utilities/speech/speechPosterJob.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
53
tests/helpers/camaraVodStub.ts
Normal file
53
tests/helpers/camaraVodStub.ts
Normal 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 })
|
||||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
254
tests/int/speechPoster.int.spec.ts
Normal file
254
tests/int/speechPoster.int.spec.ts
Normal 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 })
|
||||
}
|
||||
})
|
||||
})
|
||||
105
tests/unit/speechPoster.unit.spec.ts
Normal file
105
tests/unit/speechPoster.unit.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue