fix(C169): corte do acervo resolve o trecho com a Câmara instável e falha dizendo o porquê (Closes #1045)

This commit is contained in:
Francisco Solla 2026-09-15 22:05:27 -03:00
parent 83f30dfc82
commit 046b51e38b
10 changed files with 790 additions and 68 deletions

View file

@ -0,0 +1 @@
**Corte do acervo resolve o trecho com a Câmara instável (C169, 2026-09-15):** a primeira etapa do "Cortar vídeo" deixou de morrer no primeiro timeout. O resolver do VOD (`src/utilities/speech/speechVodResolver.ts`) ganhou política por chamador — o player C162 mantém a janela curta (15 s, sem poll, pinada por unit) e o corte usa `SPEECH_VOD_CUT_POLICY` (45 s/req, 1 retry, 2 polls de 5 s cobrindo o primeiro request de ~30 s; teto ~4,7 min) — e um fallback opt-in que, quando a API não entrega URL verificado (`INDISPONIVEL`, `GERANDO` esgotado, throw ou hash morto), proba os links `vodPlaybackUrl`/`vodDownloadUrl` já gravados na fala com o mesmo probe do resolver (Range GET, `text/html` recusado); sem candidato verificado o erro original continua subindo. A falha ficou honesta: o job preserva o step em que morreu e grava o literal da causa, e `speechCutFailureMessage` (`src/lib/speechCut.ts`) mapeia causa+step para o texto pt-BR que o diálogo mostra (a fala sem trecho não culpa mais a Câmara; cortar ≠ guardar); o `error` cru segue só no admin, e "Tentar novamente" reusa a mesma row. Sem migration/collection/Consent; `excerptTMs` verbatim e o gate `canReadSpeechCatalog` intactos. Unit (políticas/poll/fallback/mapper/diálogo) + int (job publica pelo link gravado com API `INDISPONIVEL` e com hash morto; falha com step e causa) verdes.

View file

@ -0,0 +1,145 @@
# Impl: Corte do acervo: resolver o trecho com a Câmara instável e falhar dizendo o porquê
Status: em execução
Atualizado em: 2026-09-15
Issue: #1045
Intenção: docs/plans/c169-corte-vod-confiabilidade.md
Appetite restante: herdado (~1 dia eng)
## Leitura da intenção
- **Outcome:** o "Cortar vídeo" da assessoria não morre mais na primeira etapa ("Localizando o trecho na Câmara") enquanto existir arquivo verificável — a API real ganha a janela medida (~30 s no primeiro request, depois polls curtos) e o link VOD já gravado na fala é tentado e verificado quando a API não serve; sem arquivo, a falha nomeia a causa em pt-BR, nada é publicado e "Tentar novamente" reusa a mesma row.
- **O que NÃO negociar:** `excerptTMs` verbatim (nunca arredondar/deslocar o trecho); link morto/falso (403, timeout, `text/html`) nunca vira arquivo; nada publicado em falha; crédito `Fonte: Câmara dos Deputados · CC BY 4.0`; gate do acervo (`canReadSpeechCatalog`; `advisor`/`leader` negados fail-closed; `leader` lockdown); sem espelhar MP4/S3, sem upload externo, sem YouTube; sem novo `Consent`/collection/migration; URLs públicas do acervo intocadas.
- **O que reavaliar (hipóteses da intenção):**
1. "Ampliar a janela afeta também o clique do player" — verdade, mas resolve-se **parametrizando a política por chamador** (default = comportamento C162 de hoje), não com janela global nem caminho paralelo (D1).
2. "O fallback do link gravado vive no job" — reavaliado: o dono do probe é o resolver; o fallback vira **opt-in no resolver** (`cachedUrls`), com o job passando os candidatos (D2).
3. "A causa real fica só na coluna interna" — o discriminador (`error`) existe, mas `toSpeechCutViewModel` o descarta; a menor exposição é **um campo novo no VM** (`failureMessage`, mapeado no servidor), com o **step da falha preservado** para separar cortar de guardar; não é enum/migration (D3).
4. "`tests/int` só cobre o caminho feliz" e o unit do resolver pina a janela — o default do player preservado mantém o teste pinado verde; poll/fallback entram como **unit com fake timers** (D4).
## Abordagem recomendada
```mermaid
flowchart TD
A["Diálogo: Cortar e publicar"] --> B["POST acervo/cortar — gate speechCatalog"]
B --> C["row processing · step=resolving"]
C --> D["after(): runSpeechCutJob"]
D --> E["resolveSpeechVod(coords, CUT_POLICY, cachedUrls)"]
E --> F{"video-sob-demanda"}
F -->|GERANDO| G["poll 5 s (até 2)"]
G --> F
F -->|PRONTO| H["probe playback/download"]
H -->|verificado| J["sourceUrl"]
F -->|"INDISPONIVEL / esgotado / throw"| I["probe links gravados (cache verificado)"]
H -->|"nenhum verificado"| I
I -->|verificado| J
I -->|nada verificado| K["failed: step + error (cru, admin)"]
J --> L["ffmpeg → media → published"]
K --> M["toSpeechCutViewModel.failureMessage (mapeado)"]
M --> N["Diálogo: causa + Tentar novamente (mesma row)"]
```
**Opções consideradas:** A) endurecer o resolver dono (política por chamador + fallback opt-in do cache verificado) e levar a causa ao VM | B) só trocar as mensagens do job/diálogo, sem mexer na janela nem no fallback | C) fila/fluxo novo de resolução.
**Recomendação:** A — cobre os dois aceites (resiliência e falha honesta) sem schema e sem tocar o player; o resolver já é o dono do fetch/probe do VOD e o VM é o dono do que o diálogo renderiza.
**Rejeitadas:** B porque não atende o primeiro aceite (segue morrendo no timeout mesmo com link vivo); C porque é o rabbit hole nomeado (worker/fila/pendências) e o C167 já decidiu job in-process com `after()`.
### Decisões de engenharia (caras de reverter)
**D1 — Janela/poll do resolver sem regredir o player C162 (contrato compartilhado).**
Opções: A) `resolveSpeechVod(coords, { policy })` com default = política de hoje do player e `SPEECH_VOD_CUT_POLICY` passada só pelo job | B) uma janela única maior para todos os chamadores | C) resolver paralelo só do corte.
**Recomendação: A.** `SpeechVodPolicy = { statusTimeoutMs, statusRetries, retryDelayMs, pollAttempts, pollDelayMs }` em `src/utilities/speech/speechVodResolver.ts`: `SPEECH_VOD_PLAYER_POLICY = { 15_000, 1, 1_000, 0, 1_000 }` (idêntica ao comportamento atual: 2 requests no erro de transporte, resposta imediata em `GERANDO`) e `SPEECH_VOD_CUT_POLICY = { 45_000, 1, 1_000, 2, 5_000 }` (absorve o primeiro request de ~30 s, 2 polls de 5 s após `GERANDO`; teto declarado ~4,7 min no pior caso — 3 leituras × (2 × 45 s + 1 s) + 2 × 5 s, caso medido ~40 s). O loop só polla `GERANDO` enquanto houver budget; `PRONTO`/`INDISPONIVEL`/`DESCONHECIDO` retornam na hora; erro de transporte consome `statusRetries` e ao final relança (contrato atual do player intacto). `resolveSpeechVodForActor` não muda uma linha.
**Alternativas rejeitadas:** B porque transforma "Assistir/baixar" (clique síncrono e deliberadamente bounded do C162) num spinner de minutos sem aceite de produto; C porque fere o corte explícito da intenção ("não criar caminho de resolução paralelo ao C162 — editar o dono").
**D2 — Onde vive o fallback do link gravado.**
Opções: A) opt-in no resolver: `resolveSpeechVod(coords, { policy?, cachedUrls? })`; quando a API não entrega URL verificado (throw, `gerando`, `indisponivel`, `pronto` sem probe ok), proba os `cachedUrls` com o mesmo `probeSpeechMedia` (privado) e devolve `pronto` com o link verificado; sem `cachedUrls`, comportamento de hoje (inclusive relançar) | B) o job compõe a escada importando `probeSpeechMedia` exportado | C) fallback para todos os chamadores (player incluso).
**Recomendação: A** — um único ponto de entrada; o probe (Range GET, `text/html` = morto, HEAD só em 405/501) continua privado e com um dono; a precedência "API primeiro, cache depois" fica num lugar só; o job só passa `cachedUrls: { playbackUrl, downloadUrl }` (a fala já vem completa do `loadCutSystem`, depth 1) e consome `playbackUrl ?? downloadUrl`.
**Alternativas rejeitadas:** B porque exporta um interno usado uma vez e move a escada/precedência para o job (vazamento de conhecimento do resolver); C porque muda o contrato do C162 ("cache nunca é URL entregue ao browser") e o aceite do player sem produto. **Trigger de revisitação:** se os cortes salvos pelo cache provarem valor no uso real, reavaliar o player numa linha (passar `cachedUrls` na action) com aceite próprio.
**D3 — Como a causa chega à UI (wire do VM × semântica do step).**
Opções: A) `SpeechCutViewModel.failureMessage: string | null`, mapeado no servidor por `speechCutFailureMessage({ error, step })` (literais exatos + fallback por step); o job persiste o **step da falha** (`failCut(..., step)`), com `error` cru seguindo só no admin | B) campo `failureReason` enum na collection + `pnpm migrate:create` | C) expor `error` cru no status/VM e o diálogo mapear.
**Recomendação: A** — sem migration (`step` já é um select existente e nenhum consumidor o lê fora do diálogo em `running`); o operador segue vendo `HTTP 403 em <url>`/stderr no admin; a pessoa vê a causa; o diálogo só troca o texto (`setError(cut.failureMessage)` quando a poll vira `failed`; default atual mantido como defesa para payload sem o campo). O mapeamento é puro, client-safe em `src/lib/speechCut.ts`, e unit-testado.
**Alternativas rejeitadas:** B porque campo/migration estão explicitamente fora do escopo; C porque vaza URL/stderr no wire e viola o comentário contrato do VM ("`error` e `createdBy` nunca passam"). Também rejeitadas: classificar só por literal do `error` (ffmpeg/storage não têm literal estável e o aceite quer "cortar" ≠ "guardar" separados) e prefixar a fase no `error` (polui o detalhe do operador).
**D4 — Estratégia de teste e e2e.**
Opções: A) unit primeiro (fake timers para política/poll/fallback; mapper/VM), int para o job com estados imediatos, dialog unit para o texto; e2e existente **não muda** | B) e2e novo do job/poll | C) só int.
**Recomendação: A** — fake timers pinam a janela sem esperar (o unit atual que fixa "exatamente 2 fetches" do player continua verde, provando a não-regressão); o job é deliberadamente fora do HTTP (C167) e o int prova o fallback com `INDISPONIVEL`/`PRONTO`-hash-morto/throw curto; o manifest já mapeia `src/utilities/speech`, `src/components/campaign/speech`, `src/lib/speech*` e `src/lib/schemas` → `campaignSpeechCut` (+`campaignSpeechAcervo`), com `campaignSpeechCut` no `E2E_CURATED_SPECS` — o diff roda o spec sem editar manifest/curado.
**Alternativas rejeitadas:** B porque não há Câmara/ffmpeg dirigível no e2e e o custo não paga; C porque a política do player (risco de regressão) e o mapping puro precisam de pinagem barata.
### Componentes / mudanças
- **`resolveSpeechVod` + políticas** (`src/utilities/speech/speechVodResolver.ts`): `SpeechVodPolicy`, `SPEECH_VOD_PLAYER_POLICY`, `SPEECH_VOD_CUT_POLICY`, loop de poll em `GERANDO`, fallback opt-in via `cachedUrls` (reusa `probeSpeechMedia`/`probeMediaOnce` como estão); header do módulo atualizado (C162 + C169; player não passa `cachedUrls`). Reusa `buildVodUrl`, `parseVodStatus`, `CAMARA_USER_AGENT`.
- **`runSpeechCutJob`** (`src/utilities/speech/speechCutJob.ts`): primeira etapa chama `resolveSpeechVod(coordinates, { policy: SPEECH_VOD_CUT_POLICY, cachedUrls })`; `failCut(payload, cutId, message, step)` persiste o step da falha; um `currentStep` local acompanha `updateStep` para o catch; literais viram constantes compartilhadas; reaper importa a constante de interrompido.
- **`src/lib/speechCut.ts`**: constantes de causa (`SPEECH_CUT_FAILURE_SPEECH_GONE`, `..._GENERATING`, `..._UNAVAILABLE`, `..._UNPLAYABLE`, `..._INTERRUPTED` — a inelegível continua `SPEECH_VOD_INELIGIBLE_MESSAGE`), mapper `speechCutFailureMessage({ error, step })` e `SpeechCutRecordForView.error?: string | null` / `SpeechCutViewModel.failureMessage: string | null` (não-nulo só para `status='failed'`). Tabela de copy:
| `error` gravado (admin) | `failureMessage` (diálogo) |
| --- | --- |
| 'A fala deste corte não está mais disponível.' | idem |
| 'Esta fala não tem trecho de vídeo para resolver na Câmara.' | 'Esta fala não tem trecho de vídeo para cortar.' |
| 'A Câmara ainda está gerando o vídeo deste trecho.' | idem |
| 'A Câmara não entregou o arquivo deste trecho.' | 'A Câmara não disponibiliza mais o arquivo deste trecho.' |
| 'A Câmara não entregou um arquivo jogável deste trecho.' | 'A Câmara não disponibilizou um arquivo válido deste trecho.' |
| 'O corte foi interrompido antes de terminar.' | idem |
| desconhecido · step resolving | 'Não foi possível localizar o trecho na Câmara.' |
| desconhecido · step cutting | 'Não foi possível cortar o trecho.' |
| desconhecido · steps metadata/publishing | 'Não foi possível guardar o arquivo do corte.' |
| desconhecido · step null | 'Não foi possível preparar o corte.' |
| sem `error` | `null` (diálogo mantém o default atual) |
- **`SpeechCutDialog.tsx`**: na poll, `cut.status === 'failed'` passa a setar `error` com `cut.failureMessage` (sem tocar layout, título ou ações; `SPEECH_CUT_GENERIC_ERROR_MESSAGE` segue como defesa).
- **Migration:** nenhuma (declarado; `step`/`error` já existem).
- **Access / Consent:** nenhum. O job mantém o bypass admin documentado (`overrideAccess: true`); actions/rotas seguem com o gate fresco `canReadSpeechCatalog` e `user`/`overrideAccess: false`; sem `Consent`, sem collection nova.
- **UI:** Impeccable A — só texto da cena de falha existente; sem shell novo, sem lista, sem migration.
### Dados → forma (se aplicável)
- **Não se aplica** — a intenção fixou "Vou apresentar dados? Não": progresso e erro são feedback de ação. A "forma" é a mensagem única de causa (tabela da D3) + os botões atuais; nada de dashboard/métrica de falhas.
## Fases verificáveis
1. **Tracer / server — resolver (quota ~0,4 dia):** `SpeechVodPolicy` + constantes, loop de poll, fallback opt-in. Unit: default do player intacto (2 requests, 0 polls, `GERANDO`/`INDISPONIVEL` em 1 request), `CUT_POLICY` pollando `GERANDO`→`PRONTO` com fake timers, fallback verificando cache em `INDISPONIVEL`/throw/`pronto`-sem-URL e recusando 403/timeout/`text/html`. Prova: `pnpm test:unit` + `pnpm gate:fast`.
2. **Server — job + VM (quota ~0,3 dia):** job usa política/candidatos, `failCut` com step, constantes; mapper + `failureMessage`. Int: (a) API `INDISPONIVEL` e link gravado vivo → job publica com o arquivo; (b) API `PRONTO` com hash morto (links do stub distintos dos gravados) e gravado vivo → idem; (c) tudo morto → `failed` com `step='resolving'`, `error` cru no doc, `getSpeechCutStatusForActor` devolvendo a causa; happy path/ffmpeg-fail/retry-mesma-row atuais verdes. Prova: `pnpm test:int` + `pnpm gate:fast`.
3. **UI — diálogo (quota ~0,15 dia):** poll `failed` usa `cut.failureMessage`; unit do diálogo com `failureMessage` no fixture (e o retry `{retryOf}` inalterado); revisão de copy (Impeccable A; ajuste opcional do "menos de um minuto" da cena de running). Prova: `pnpm test:unit` + smoke local.
4. **Gates (quota ~0,15 dia):** `pnpm gate:fast`; conferir a seleção de e2e (sem edição de spec/manifest; o diff cai nos prefixes já mapeados e roda `campaignSpeechCut`); changelog `docs/changelog/2026-09-15-c169.md`; `pnpm push`.
## Rabbit holes / Não escopo (engenharia)
- **Extrair `resolveVod`/`probeLink` de `scripts/lib/camaraFetch.mjs` para `src/`:** proibido/desnecessário; editar o dono (`speechVodResolver`) como referência de política, sem import.
- **Fila/worker/cron** de regeneração, pré-resolução em lote ou "gerar novamente": fora (C162/C167 já cortaram).
- **Escrever o link recém-resolvido de volta em `speech`:** exigiria `canUpdateSpeech` (unrestricted) — fora (Q4 assumida).
- **Campo enum de causa / migration / collection nova:** fora; literal + step resolvem.
- **Expor `error` cru no wire** (URLs/stderr): fora; o mapper devolve só literais seguros.
- **Dar o fallback ao player agora:** fora (contrato/aceite C162); trigger registrado na D2.
- **Redesenhar a cena de falha/layout, dashboard de falhas, biblioteca mostrando a causa:** fora (texto da falha existente apenas).
- **Mexer em `excerptTMs`/vizinhança ou na elegibilidade (`speechVodCoordinates`):** fora (guardrail verbatim; fala sem VOD continua inelegível).
- **Abstração genérica de HTTP/backoff compartilhada com `scripts/`:** dois call sites; DRY não paga.
- **e2e novo do job/poll:** fora (job não é HTTP-dirigível).
## Riscos e mitigação
- **Regressão do player C162 (mesmo resolver):** default `SPEECH_VOD_PLAYER_POLICY` byte a byte; unit atual (2 fetches, `gerando`/`indisponivel` em 1 request) fica verde; `resolveSpeechVodForActor` e os testes do player não mudam.
- **Janela maior × reaper (15 min):** pior caso resolving ~4,7 min + probes 10 s; cada `updateStep` renova `updatedAt`; teto declarado e ajustável num único ponto (`SPEECH_VOD_CUT_POLICY`). O pior caso combinado (resolving + download 180 s + ffmpeg 300 s) segue abaixo do `SPEECH_CUT_STALE_MS`.
- **Link verificado que morre entre probe e download:** `downloadSource` revalida (`!ok || text/html`) e o job falha fechado — nada publicado; o retry refaz a escada.
- **Vazamento do cru para a pessoa:** mapper só devolve literais conhecidos/por step; unit pina `HTTP 403 em <url>`/stderr → mensagem segura; comentário do VM atualizado.
- **`step` não-nulo em `failed`:** nenhum consumidor além do diálogo em `running` lê `step` (grep); retry reseta para `resolving`; admin ganha o passo real da falha.
- **Int lento por delays reais:** o int usa estados imediatos (`INDISPONIVEL`, `PRONTO`, throw) e deixa poll puro para o unit com fake timers.
- **Copy otimista do diálogo ("menos de um minuto")** no caso poll: ajuste textual opcional na fase 3 (Impeccable A), sem layout.
## Aceite de engenharia
- [ ] Aceite de produto da intenção ainda coberto (tabela abaixo)
- [ ] Invariantes AGENTS/engineering-standards (Local API com `user`/`overrideAccess: false`; job com bypass documentado; sem multi-collection write; sem `Contact`/`Consent`/migration; Dependency Rule `lib → utilities`; copy pt-BR/identificadores em inglês)
- [ ] Testes de domínio previstos (unit/int) onde access/write paths mudam — sem mudança de access; write path do job (step/error) coberto por int + unit
| Aceite (intenção) | Evidência |
| ----------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
| API lenta/instável não morre no 1º timeout, dentro de um limite declarado | `SPEECH_VOD_CUT_POLICY` (45 s/req, 1 retry, 2 polls de 5 s; teto ~4,7 min) + unit fake timers `GERANDO`→`PRONTO` + pin da política do player |
| Hash morto com link gravado vivo → corta; morto/falso nunca | fallback opt-in com `probeSpeechMedia`; unit (403/timeout/html nunca retornam) + int do job (INDISPONIVEL e hash morto) |
| Sem arquivo recuperável → falha honesta e retomável, sem duplicar | `failureMessage` no VM + diálogo; `retryOf` mesma row (int/e2e C167 atuais) |
| Fala inelegível → mensagem direta | `SPEECH_VOD_INELIGIBLE_MESSAGE` compartilhado no create (rota) e no job; e2e atual pinado |
| `excerptTMs` verbatim, crédito CC BY, gate do acervo, sem migration/collection/Consent, URLs públicas | contratos intocados pelo diff (nenhuma mudança em elegibilidade, página `/corte/<id>` ou access) |
| Qualidade | `pnpm gate:fast` verde + `pnpm push`; changelog `docs/changelog/2026-09-15-c169.md` |
## Self-score (decision-quality ≥4)
1. **Decisões caras com rejeitadas — 5/5:** D1 (contrato do resolver compartilhado com o player), D2 (semântica de seleção de fonte/cache), D3 (wire do VM + semântica do `step` na falha) e D4 (testes/e2e) têm `Opções/Recomendação/Alternativas rejeitadas`, com as hipóteses da intenção reavaliadas explicitamente.
2. **Cabe no appetite — 5/5:** ~1 dia dividido em resolver (0,4), job/VM (0,3), diálogo (0,15) e gates (0,15); sem schema, sem UI nova, sem infra.
3. **Rabbit holes nomeados — 5/5:** extração dos scripts, fila/cron, write-back do link, enum/migration, `error` cru, fallback no player (com trigger), redesign/dashboard e mudança de trecho estão fora.
4. **Depth check reusa o dono — 5/5:** um único resolver parametrizado (sem caminho paralelo), `probeSpeechMedia` privado, VM/mapper puros no dono do vocabulário do corte, job com bypass documentado; nada de pass-through novo de cerimônia.
5. **Intenção de produto satisfeita — 5/5:** cada aceite literal tem evidência e os guardrails (verbatim, CC BY, gate, nada publicado, retry sem duplicar) permanecem; as duas assunções de produto (janela e uso do link gravado) ficam validadas pelos testes de aceite no gate.

View file

@ -173,7 +173,10 @@ export const SpeechCutDialog = ({
return
}
if (cut.status === 'failed') {
// C169 — the row carries the mapped cause; null keeps the fallback
// line of the failure scene (a failure the server did not name).
setPhase('failed')
setError(cut.failureMessage)
return
}
} catch {

View file

@ -7,6 +7,7 @@
import { CAMPAIGN_COMMUNICATION_ACERVO } from '@/lib/campaignPaths'
import { formatBahiaCivilDate } from '@/lib/campaignTime'
import { buildWhatsAppTextShareUrl } from '@/lib/phone'
import { SPEECH_VOD_INELIGIBLE_MESSAGE } from '@/lib/schemas/speechVod'
import { formatSpeechDate, formatSpeechSpan } from '@/lib/speechClock'
import { parseYoutubeVideoId } from '@/lib/speechVod'
@ -41,6 +42,57 @@ export const speechCutStepLabels: Record<SpeechCutStep, string> = {
const isSpeechCutStep = (value: unknown): value is SpeechCutStep =>
SPEECH_CUT_STEPS.includes(value as SpeechCutStep)
/**
* C169 — the causes the job and the reaper store in `error` (the operator's
* detail) and the honest pt-BR copy the dialog shows for each one. Only these
* literals — or the failure step — ever reach the person; a raw transport
* message (URL, stderr) stays in the admin.
*/
export const SPEECH_CUT_FAILURE_SPEECH_GONE = 'A fala deste corte não está mais disponível.'
export const SPEECH_CUT_FAILURE_GENERATING = 'A Câmara ainda está gerando o vídeo deste trecho.'
export const SPEECH_CUT_FAILURE_UNAVAILABLE = 'A Câmara não entregou o arquivo deste trecho.'
export const SPEECH_CUT_FAILURE_UNPLAYABLE =
'A Câmara não entregou um arquivo jogável deste trecho.'
export const SPEECH_CUT_FAILURE_INTERRUPTED = 'O corte foi interrompido antes de terminar.'
const SPEECH_CUT_FAILURE_INELIGIBLE_COPY = 'Esta fala não tem trecho de vídeo para cortar.'
const SPEECH_CUT_FAILURE_UNAVAILABLE_COPY =
'A Câmara não disponibiliza mais o arquivo deste trecho.'
const SPEECH_CUT_FAILURE_UNPLAYABLE_COPY =
'A Câmara não disponibilizou um arquivo válido deste trecho.'
const SPEECH_CUT_FAILURE_RESOLVING_COPY = 'Não foi possível localizar o trecho na Câmara.'
const SPEECH_CUT_FAILURE_CUTTING_COPY = 'Não foi possível cortar o trecho.'
const SPEECH_CUT_FAILURE_STORING_COPY = 'Não foi possível guardar o arquivo do corte.'
const SPEECH_CUT_FAILURE_UNKNOWN_COPY = 'Não foi possível preparar o corte.'
/**
* Maps the stored cause of a failed cut to what the person reads: the exact
* literal when the job named it, otherwise the failure step (cortar ≠ guardar),
* otherwise a generic-but-honest line. Null when the row stored no cause at all.
*/
export const speechCutFailureMessage = ({
error,
step,
}: {
error?: string | null
step?: SpeechCutStep | null
}): string | null => {
const stored = error?.trim()
if (!stored) return null
if (stored === SPEECH_CUT_FAILURE_SPEECH_GONE) return SPEECH_CUT_FAILURE_SPEECH_GONE
if (stored === SPEECH_VOD_INELIGIBLE_MESSAGE) return SPEECH_CUT_FAILURE_INELIGIBLE_COPY
if (stored === SPEECH_CUT_FAILURE_GENERATING) return SPEECH_CUT_FAILURE_GENERATING
if (stored === SPEECH_CUT_FAILURE_UNAVAILABLE) return SPEECH_CUT_FAILURE_UNAVAILABLE_COPY
if (stored === SPEECH_CUT_FAILURE_UNPLAYABLE) return SPEECH_CUT_FAILURE_UNPLAYABLE_COPY
if (stored === SPEECH_CUT_FAILURE_INTERRUPTED) return SPEECH_CUT_FAILURE_INTERRUPTED
if (step === 'resolving') return SPEECH_CUT_FAILURE_RESOLVING_COPY
if (step === 'cutting') return SPEECH_CUT_FAILURE_CUTTING_COPY
if (step === 'metadata' || step === 'publishing') return SPEECH_CUT_FAILURE_STORING_COPY
return SPEECH_CUT_FAILURE_UNKNOWN_COPY
}
type SpeechCutStepState = {
step: SpeechCutStep
label: string
@ -187,6 +239,8 @@ export type SpeechCutRecordForView = CutDurationRecord & {
publishedAt?: string | null
createdAt?: string | null
updatedAt?: string | null
/** Raw internal cause of a failure; never handed to the client as-is. */
error?: string | null
}
export type SpeechCutViewModel = {
@ -205,6 +259,8 @@ export type SpeechCutViewModel = {
mediaFilename: string | null
youtubeVideoId: string | null
publishedAt: string | null
/** C169 — honest cause of a failure; null unless `status === 'failed'`. */
failureMessage: string | null
}
const mediaOf = (media: SpeechCutRecordForView['media']): CutMediaRecord | null =>
@ -215,7 +271,8 @@ const youtubeUrlOf = (speech: SpeechCutRecordForView['speech']): string | null =
/**
* Internal/status wire view of a cut. `error` and `createdBy` never pass
* through here: the public page and the dialog receive only what they render.
* through here: the public page and the dialog receive only what they render —
* a failure becomes the mapped `failureMessage`, never the raw detail.
*/
export const toSpeechCutViewModel = (record: SpeechCutRecordForView): SpeechCutViewModel => {
const media = mediaOf(record.media)
@ -241,6 +298,8 @@ export const toSpeechCutViewModel = (record: SpeechCutRecordForView): SpeechCutV
mediaFilename: media?.filename ?? null,
youtubeVideoId: parseYoutubeVideoId(youtubeUrlOf(record.speech)),
publishedAt: record.publishedAt ?? null,
failureMessage:
status === 'failed' ? speechCutFailureMessage({ error: record.error, step }) : null,
}
}
@ -277,8 +336,8 @@ const originOf = (speech: SpeechCutRecordForView['speech']): SpeechCutOriginView
/**
* C168 — library view of a cut: the base view plus the origin speech (label +
* link to the acervo) and the creation date the list shows. `error`, `step`
* and `createdBy` still never pass through.
* link to the acervo) and the creation date the list shows. `error` and
* `createdBy` still never pass through (a failure travels as `failureMessage`).
*/
export const toSpeechCutLibraryItemViewModel = (
record: SpeechCutRecordForView,

View file

@ -9,10 +9,16 @@ 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'
import { formatSpeechDate } from '@/lib/speechClock'
import {
buildSpeechCutFallbackMetadata,
buildSpeechCutFfmpegArgs,
SPEECH_CUT_FAILURE_GENERATING,
SPEECH_CUT_FAILURE_INTERRUPTED,
SPEECH_CUT_FAILURE_SPEECH_GONE,
SPEECH_CUT_FAILURE_UNAVAILABLE,
SPEECH_CUT_FAILURE_UNPLAYABLE,
type SpeechCutStep,
} from '@/lib/speechCut'
import { CAMARA_USER_AGENT, speechVodCoordinates } from '@/lib/speechVod'
@ -21,7 +27,7 @@ import {
withPayloadTransaction,
type PayloadTransactionRequest,
} from '@/utilities/payloadTransaction'
import { resolveSpeechVod } from '@/utilities/speech/speechVodResolver'
import { resolveSpeechVod, SPEECH_VOD_CUT_POLICY } from '@/utilities/speech/speechVodResolver'
/**
* C167 — the cut job: resolve the Câmara VOD, cut the exact [start, end] with
@ -91,8 +97,17 @@ const createMediaSystem = (
...(req ? { req } : {}),
})
const failCut = (payload: Payload, cutId: number, message: string): Promise<unknown> =>
updateCutSystem(payload, cutId, { status: 'failed', step: null, error: message })
/**
* Fails the row and keeps the step it died on (the first stage is `resolving`
* by default): the dialog maps the step and the known literals into the honest
* message, while `error` keeps the raw detail for the admin.
*/
const failCut = (
payload: Payload,
cutId: number,
message: string,
step: SpeechCutStep | null = 'resolving',
): Promise<unknown> => updateCutSystem(payload, cutId, { status: 'failed', step, error: message })
const updateStep = (payload: Payload, cutId: number, step: SpeechCutStep): Promise<unknown> =>
updateCutSystem(payload, cutId, { step })
@ -167,39 +182,51 @@ const runFfmpeg = (args: string[], durationSeconds: number): Promise<void> =>
*/
export const runSpeechCutJob = async (payload: Payload, cutId: number): Promise<void> => {
let tempDir: string | null = null
// The step the row would be showing if the job died right now: the catch
// records it so the failure message separates cortar from guardar.
let currentStep: SpeechCutStep = 'resolving'
const markStep = async (step: SpeechCutStep): Promise<void> => {
currentStep = step
await updateStep(payload, cutId, step)
}
try {
const cut = await loadCutSystem(payload, cutId)
if (cut.status !== 'processing') return
await updateStep(payload, cutId, 'resolving')
await markStep('resolving')
const speech = typeof cut.speech === 'object' && cut.speech !== null ? cut.speech : null
if (!speech) {
await failCut(payload, cutId, 'A fala deste corte não está mais disponível.')
await failCut(payload, cutId, SPEECH_CUT_FAILURE_SPEECH_GONE)
return
}
const coordinates = speechVodCoordinates(speech)
if (!coordinates) {
await failCut(payload, cutId, 'Esta fala não tem trecho de vídeo para resolver na Câmara.')
await failCut(payload, cutId, SPEECH_VOD_INELIGIBLE_MESSAGE)
return
}
const resolution = await resolveSpeechVod(coordinates)
const resolution = await resolveSpeechVod(coordinates, {
policy: SPEECH_VOD_CUT_POLICY,
// The stored links are the Câmara's own cache: tried only when the API
// left no verified URL, and probed exactly like the API links.
cachedUrls: { playbackUrl: speech.vodPlaybackUrl, downloadUrl: speech.vodDownloadUrl },
})
if (resolution.state === 'gerando') {
await failCut(payload, cutId, 'A Câmara ainda está gerando o vídeo deste trecho.')
await failCut(payload, cutId, SPEECH_CUT_FAILURE_GENERATING)
return
}
if (resolution.state !== 'pronto') {
await failCut(payload, cutId, 'A Câmara não entregou o arquivo deste trecho.')
await failCut(payload, cutId, SPEECH_CUT_FAILURE_UNAVAILABLE)
return
}
const sourceUrl = resolution.playbackUrl ?? resolution.downloadUrl
if (!sourceUrl) {
await failCut(payload, cutId, 'A Câmara não entregou um arquivo jogável deste trecho.')
await failCut(payload, cutId, SPEECH_CUT_FAILURE_UNPLAYABLE)
return
}
await updateStep(payload, cutId, 'cutting')
await markStep('cutting')
tempDir = await mkdtemp(join(tmpdir(), 'speech-cut-'))
const inputPath = join(tempDir, 'source.mp4')
const outputName = `corte-${cut.id}-${Math.round(cut.startSeconds)}-${Math.round(cut.endSeconds)}.mp4`
@ -217,7 +244,7 @@ export const runSpeechCutJob = async (payload: Payload, cutId: number): Promise<
durationSeconds,
)
await updateStep(payload, cutId, 'metadata')
await markStep('metadata')
const fallback = buildSpeechCutFallbackMetadata({
speechType: speech.type ?? null,
dateLabel: formatSpeechDate(speech.speechAt),
@ -229,7 +256,7 @@ export const runSpeechCutJob = async (payload: Payload, cutId: number): Promise<
await updateCutSystem(payload, cutId, { title, description })
}
await updateStep(payload, cutId, 'publishing')
await markStep('publishing')
await withPayloadTransaction(payload, async ({ req }) => {
const media = await createMediaSystem(payload, title, outputPath, req)
await updateCutSystem(
@ -246,7 +273,7 @@ export const runSpeechCutJob = async (payload: Payload, cutId: number): Promise<
)
})
} catch (error) {
await failCut(payload, cutId, messageOf(error)).catch(() => undefined)
await failCut(payload, cutId, messageOf(error), currentStep).catch(() => undefined)
} finally {
if (tempDir) await rm(tempDir, { recursive: true, force: true }).catch(() => undefined)
}
@ -269,7 +296,7 @@ export const reapStaleSpeechCut = async (
const result = await payload.update({
collection: 'speechCut',
where: { and: [{ id: { equals: cut.id } }, { status: { equals: 'processing' } }] },
data: { status: 'failed', step: null, error: 'O corte foi interrompido antes de terminar.' },
data: { status: 'failed', step: null, error: SPEECH_CUT_FAILURE_INTERRUPTED },
// Intentional admin bypass: the reaper repairs a row the system owns.
overrideAccess: true,
})

View file

@ -4,38 +4,79 @@ import {
buildVodUrl,
CAMARA_USER_AGENT,
parseVodStatus,
type CamaraVodStatus,
type SpeechVodResolution,
} from '@/lib/speechVod'
/**
* C162 — interactive VOD resolution for the acervo detail. Deliberately NOT a
* reuse of `scripts/lib/camaraFetch.mjs` (whose `resolveVod` polls for ~200s
* and whose `probeLink` reports instead of deciding): the player's contract is
* a bounded click, so this module owns its own short retry, timeout and the
* media verification that decides what may reach the browser.
* C162/C169 — interactive VOD resolution for the acervo detail. Deliberately
* NOT a reuse of `scripts/lib/camaraFetch.mjs` (whose `resolveVod` polls for
* ~200s and whose `probeLink` reports instead of deciding): whoever calls this
* owns the wait budget through a `SpeechVodPolicy`, and the media verification
* (not the policy) decides what may reach the browser.
*
* The player keeps the bounded click of C162 (`SPEECH_VOD_PLAYER_POLICY`); the
* cut job (C169) needs the measured first-request window (~30s, then short
* polls) plus the stored links as a last verified candidate, and passes
* `SPEECH_VOD_CUT_POLICY` + `cachedUrls`.
*/
const VOD_STATUS_ATTEMPTS = 2
const VOD_STATUS_TIMEOUT_MS = 15_000
const VOD_STATUS_RETRY_DELAY_MS = 1_000
export type SpeechVodPolicy = {
/** Abort timeout of each `video-sob-demanda` read. */
statusTimeoutMs: number
/** Extra reads after a transport failure (total attempts = 1 + retries). */
statusRetries: number
/** Delay before retrying a transport failure. */
retryDelayMs: number
/** Extra reads after a `GERANDO` answer (the Câmara only transcodes once). */
pollAttempts: number
/** Delay between `GERANDO` polls. */
pollDelayMs: number
}
/** C162's player click: short, no poll, immediate answer. */
export const SPEECH_VOD_PLAYER_POLICY: SpeechVodPolicy = {
statusTimeoutMs: 15_000,
statusRetries: 1,
retryDelayMs: 1_000,
pollAttempts: 0,
pollDelayMs: 1_000,
}
/** C169's cut job: covers the measured ~30s first request and 1–2 short polls. */
export const SPEECH_VOD_CUT_POLICY: SpeechVodPolicy = {
statusTimeoutMs: 45_000,
statusRetries: 1,
retryDelayMs: 1_000,
pollAttempts: 2,
pollDelayMs: 5_000,
}
/** Stored links of the speech, tried (and probed) only after the API fails. */
export type SpeechVodCachedUrls = {
playbackUrl?: string | null
downloadUrl?: string | null
}
const MEDIA_PROBE_TIMEOUT_MS = 10_000
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
/** One bounded fetch of the `video-sob-demanda` JSON; throws after the retries. */
const fetchVodStatus = async (url: string): Promise<unknown> => {
/** One bounded read of the `video-sob-demanda` JSON; throws after the retries. */
const fetchVodStatus = async (url: string, policy: SpeechVodPolicy): Promise<CamaraVodStatus> => {
let lastError: unknown
for (let attempt = 1; attempt <= VOD_STATUS_ATTEMPTS; attempt += 1) {
const attempts = policy.statusRetries + 1
for (let attempt = 1; attempt <= attempts; attempt += 1) {
try {
const response = await fetch(url, {
headers: { 'User-Agent': CAMARA_USER_AGENT, Accept: 'application/json' },
signal: AbortSignal.timeout(VOD_STATUS_TIMEOUT_MS),
signal: AbortSignal.timeout(policy.statusTimeoutMs),
})
if (!response.ok) throw new Error(`HTTP ${response.status} em ${url}`)
return await response.json()
return parseVodStatus(await response.json())
} catch (error) {
lastError = error
if (attempt < VOD_STATUS_ATTEMPTS) await sleep(VOD_STATUS_RETRY_DELAY_MS)
if (attempt < attempts) await sleep(policy.retryDelayMs)
}
}
throw lastError
@ -75,28 +116,89 @@ const probeSpeechMedia = async (url: string | null): Promise<boolean> => {
}
}
export const resolveSpeechVod = async ({
eventId,
audioId,
excerptTms,
}: {
type SpeechVodCoordinates = {
eventId: number
audioId: number
excerptTms: number
}): Promise<SpeechVodResolution> => {
const status = parseVodStatus(await fetchVodStatus(buildVodUrl(eventId, audioId, excerptTms)))
if (status.state === 'GERANDO') return { state: 'gerando' }
if (status.state !== 'PRONTO' || !status.video) return { state: 'indisponivel' }
}
/**
* Probes a pair of candidate URLs in parallel and keeps only the ones whose
* probe verified a real media body — a dead hash and the CDN's HTML error page
* both come out as null.
*/
const probeUrls = async (
playbackUrl: string | null,
downloadUrl: string | null,
): Promise<SpeechVodResolution> => {
const [playback, download] = await Promise.all([
probeSpeechMedia(status.video.playbackUrl),
probeSpeechMedia(status.video.downloadUrl),
probeSpeechMedia(playbackUrl),
probeSpeechMedia(downloadUrl),
])
return {
state: 'pronto',
playbackUrl: playback ? status.video.playbackUrl : null,
downloadUrl: download ? status.video.downloadUrl : null,
playbackUrl: playback ? playbackUrl : null,
downloadUrl: download ? downloadUrl : null,
}
}
const hasVerifiedUrl = (resolution: SpeechVodResolution): boolean =>
resolution.state === 'pronto' &&
(resolution.playbackUrl !== null || resolution.downloadUrl !== null)
/** The stored links, probed like the API URLs; null when none verifies. */
const fallbackToCachedUrls = async (
cachedUrls: SpeechVodCachedUrls,
): Promise<SpeechVodResolution | null> => {
const cached = await probeUrls(cachedUrls.playbackUrl ?? null, cachedUrls.downloadUrl ?? null)
return hasVerifiedUrl(cached) ? cached : null
}
/** The API answer: polls `GERANDO` while the budget lasts, probes what is PRONTO. */
const resolveFromVodApi = async (
{ eventId, audioId, excerptTms }: SpeechVodCoordinates,
policy: SpeechVodPolicy,
): Promise<SpeechVodResolution> => {
const url = buildVodUrl(eventId, audioId, excerptTms)
let status = await fetchVodStatus(url, policy)
for (let poll = 0; status.state === 'GERANDO' && poll < policy.pollAttempts; poll += 1) {
await sleep(policy.pollDelayMs)
status = await fetchVodStatus(url, policy)
}
if (status.state === 'GERANDO') return { state: 'gerando' }
if (status.state !== 'PRONTO' || !status.video) return { state: 'indisponivel' }
return probeUrls(status.video.playbackUrl, status.video.downloadUrl)
}
/**
* Resolves one excerpt: the Câmara API first (polls while it says `GERANDO`),
* then — when the caller passed `cachedUrls` and the API left no verified URL
* — the links already stored on the speech. Without `cachedUrls` the C162
* behavior is preserved down to the transport error rethrown to the caller.
*/
export const resolveSpeechVod = async (
coordinates: SpeechVodCoordinates,
options: { policy?: SpeechVodPolicy; cachedUrls?: SpeechVodCachedUrls } = {},
): Promise<SpeechVodResolution> => {
const { policy = SPEECH_VOD_PLAYER_POLICY, cachedUrls } = options
let resolution: SpeechVodResolution
try {
resolution = await resolveFromVodApi(coordinates, policy)
} catch (error) {
if (!cachedUrls) throw error
const cached = await fallbackToCachedUrls(cachedUrls)
if (cached) return cached
throw error
}
if (cachedUrls && !hasVerifiedUrl(resolution)) {
const cached = await fallbackToCachedUrls(cachedUrls)
if (cached) return cached
}
return resolution
}

View file

@ -38,6 +38,7 @@ import {
SPEECH_CUT_FORBIDDEN_MESSAGE,
SPEECH_CUT_PUBLISH_NOT_READY_MESSAGE,
} from '@/lib/schemas/speechCut'
import { SPEECH_CUT_FAILURE_INTERRUPTED, SPEECH_CUT_FAILURE_UNAVAILABLE } from '@/lib/speechCut'
import type { CampaignUser } from '@/payload-types'
import config from '@/payload.config'
import { getCampaignActionContext } from '@/utilities/campaignActionContext'
@ -79,27 +80,50 @@ const campaignFixtures = installCampaignFixtures({
const mockedGetContext = vi.mocked(getCampaignActionContext)
const mockedStartJob = vi.mocked(startSpeechCutJobInBackground)
const camaraFetchStub = (bytes: Buffer): typeof fetch =>
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: 'PRONTO',
video: { linkParaReproducao: PLAYBACK_URL, linkParaDownload: DOWNLOAD_URL },
estado: apiState,
video:
apiState === 'PRONTO'
? { linkParaReproducao: apiPlaybackUrl, linkParaDownload: apiDownloadUrl }
: null,
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
)
}
if (url === PLAYBACK_URL || url === DOWNLOAD_URL) {
return new Response(new Uint8Array(bytes), {
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(bytes.length) },
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,
@ -148,6 +172,22 @@ const createCut = async (data: {
return cut.id
}
/** Asserts the job published the cut with the fake ffmpeg's copy of the source. */
const expectPublishedWithFakeFfmpeg = async (cutId: number) => {
const cut = await payload.findByID({
collection: 'speechCut',
id: cutId,
depth: 1,
overrideAccess: true,
})
expect(cut.status).toBe('published')
const media = typeof cut.media === 'object' && cut.media !== null ? cut.media : null
if (!media) throw new Error('media was not attached')
createdMediaIds.add(media.id)
expect(readFileSync(resolve(process.cwd(), 'media', media.filename as string))).toEqual(MP4_BYTES)
return { cut, media }
}
const createUsers = async () => {
const fixtures = campaignFixtures()
const [communicator, coordinator, candidate, advisor, leader] = await Promise.all([
@ -384,6 +424,7 @@ describe('speech cuts (C167)', () => {
const after = await getSpeechCutStatusForActor({ cutId })
expect(after.status).toBe('failed')
expect(after.failureMessage).toBe(SPEECH_CUT_FAILURE_INTERRUPTED)
})
it('suggests the deterministic fallback when the AI is unavailable', async () => {
@ -416,24 +457,11 @@ describe('speech cuts (C167)', () => {
try {
await runSpeechCutJob(payload, cutId)
const cut = await payload.findByID({
collection: 'speechCut',
id: cutId,
depth: 1,
overrideAccess: true,
})
expect(cut.status).toBe('published')
const { cut, media } = await expectPublishedWithFakeFfmpeg(cutId)
expect(cut.step).toBeNull()
expect(cut.publishedAt).toBeTruthy()
const media = typeof cut.media === 'object' && cut.media !== null ? cut.media : null
expect(media).toBeTruthy()
if (!media) throw new Error('media was not attached')
createdMediaIds.add(media.id)
expect(media.filename).toBe(`corte-${cutId}-43-118.mp4`)
expect(media.alt).toBe(cut.title)
expect(readFileSync(resolve(process.cwd(), 'media', media.filename as string))).toEqual(
MP4_BYTES,
)
} finally {
restoreFfmpeg()
vi.unstubAllGlobals()
@ -461,6 +489,8 @@ describe('speech cuts (C167)', () => {
expect(cut.media ?? null).toBeNull()
expect(cut.publishedAt ?? null).toBeNull()
expect(cut.error).toBeTruthy()
// C169 — the failing step survives so the dialog can say "cortar", not a generic line.
expect(cut.step).toBe('cutting')
} finally {
if (previousFail === undefined) delete process.env.FAKE_FFMPEG_FAIL
else process.env.FAKE_FFMPEG_FAIL = previousFail
@ -469,6 +499,83 @@ describe('speech cuts (C167)', () => {
}
})
it('publishes with the stored link when the API answers INDISPONIVEL', async () => {
const speech = await createSpeech()
const cutId = await createCut({ speech, startSeconds: 43, endSeconds: 118 })
vi.stubGlobal('fetch', camaraVodStub({ apiState: 'INDISPONIVEL' }))
const restoreFfmpeg = withFfmpegPath(FAKE_FFMPEG)
try {
await runSpeechCutJob(payload, cutId)
await expectPublishedWithFakeFfmpeg(cutId)
} finally {
restoreFfmpeg()
vi.unstubAllGlobals()
}
})
it('publishes with the stored link when the API PRONTO hash is dead', async () => {
const speech = await createSpeech()
const cutId = await createCut({ speech, startSeconds: 43, endSeconds: 118 })
const deadApiPlayback = 'https://cdn.camara.leg.br/hash-velho.mp4'
const deadApiDownload = 'https://cdn.camara.leg.br/hash-velho-download.mp4'
const fetched: string[] = []
const stub = camaraVodStub({
apiPlaybackUrl: deadApiPlayback,
apiDownloadUrl: deadApiDownload,
deadUrls: [deadApiPlayback, deadApiDownload],
})
vi.stubGlobal('fetch', (async (input: RequestInfo | URL, init?: RequestInit) => {
fetched.push(String(input))
return stub(input, init)
}) as typeof fetch)
const restoreFfmpeg = withFfmpegPath(FAKE_FFMPEG)
try {
await runSpeechCutJob(payload, cutId)
await expectPublishedWithFakeFfmpeg(cutId)
// The dead hash was tried and refused; the stored cache fed the cut.
expect(fetched).toContain(deadApiPlayback)
expect(fetched).toContain(PLAYBACK_URL)
} finally {
restoreFfmpeg()
vi.unstubAllGlobals()
}
})
it('fails with the honest cause and keeps the step when nothing is recoverable', async () => {
const speech = await createSpeech()
const cutId = await createCut({ speech, startSeconds: 0, endSeconds: 30 })
vi.stubGlobal(
'fetch',
camaraVodStub({ apiState: 'INDISPONIVEL', deadUrls: [PLAYBACK_URL, DOWNLOAD_URL] }),
)
try {
await runSpeechCutJob(payload, cutId)
const cut = await payload.findByID({
collection: 'speechCut',
id: cutId,
depth: 0,
overrideAccess: true,
})
expect(cut.status).toBe('failed')
expect(cut.step).toBe('resolving')
expect(cut.error).toBe(SPEECH_CUT_FAILURE_UNAVAILABLE)
const { communicator } = await createUsers()
asActor(communicator)
const poll = await getSpeechCutStatusForActor({ cutId })
expect(poll.status).toBe('failed')
expect(poll.failureMessage).toBe('A Câmara não disponibiliza mais o arquivo deste trecho.')
} finally {
vi.unstubAllGlobals()
}
})
describe.skipIf(!hasFfmpeg)('with the real ffmpeg (CI)', () => {
it('cuts exactly the picked [start, end] window', async () => {
const workdir = mkdtempSync(join(tmpdir(), 'speech-cut-real-'))

View file

@ -1,10 +1,17 @@
import { describe, expect, it } from 'vitest'
import { SPEECH_VOD_INELIGIBLE_MESSAGE } from '@/lib/schemas/speechVod'
import { formatSpeechDate } from '@/lib/speechClock'
import {
buildSpeechCutFallbackMetadata,
buildSpeechCutFfmpegArgs,
buildSpeechCutShare,
SPEECH_CUT_FAILURE_GENERATING,
SPEECH_CUT_FAILURE_INTERRUPTED,
SPEECH_CUT_FAILURE_SPEECH_GONE,
SPEECH_CUT_FAILURE_UNAVAILABLE,
SPEECH_CUT_FAILURE_UNPLAYABLE,
speechCutFailureMessage,
speechCutPublicPath,
speechCutStepStates,
toSpeechCutViewModel,
@ -149,6 +156,72 @@ describe('toSpeechCutViewModel', () => {
expect(unknown.mediaUrl).toBeNull()
expect(unknown.youtubeVideoId).toBeNull()
})
it('hands a failed cut its mapped cause and never the raw error', () => {
const failed = toSpeechCutViewModel({
id: 9,
status: 'failed',
step: 'cutting',
title: 't',
description: 'd',
startSeconds: 0,
endSeconds: 20,
error: 'HTTP 403 em https://cdn.camara.leg.br/trecho.mp4',
})
expect(failed.failureMessage).toBe('Não foi possível cortar o trecho.')
expect(JSON.stringify(failed)).not.toContain('HTTP 403')
expect(view.failureMessage).toBeNull()
})
})
describe('speechCutFailureMessage', () => {
it('maps the named causes of the first stage to honest pt-BR copy', () => {
expect(speechCutFailureMessage({ error: SPEECH_CUT_FAILURE_SPEECH_GONE })).toBe(
SPEECH_CUT_FAILURE_SPEECH_GONE,
)
expect(speechCutFailureMessage({ error: SPEECH_VOD_INELIGIBLE_MESSAGE })).toBe(
'Esta fala não tem trecho de vídeo para cortar.',
)
expect(speechCutFailureMessage({ error: SPEECH_CUT_FAILURE_GENERATING })).toBe(
SPEECH_CUT_FAILURE_GENERATING,
)
expect(speechCutFailureMessage({ error: SPEECH_CUT_FAILURE_UNAVAILABLE })).toBe(
'A Câmara não disponibiliza mais o arquivo deste trecho.',
)
expect(speechCutFailureMessage({ error: SPEECH_CUT_FAILURE_UNPLAYABLE })).toBe(
'A Câmara não disponibilizou um arquivo válido deste trecho.',
)
expect(speechCutFailureMessage({ error: SPEECH_CUT_FAILURE_INTERRUPTED })).toBe(
SPEECH_CUT_FAILURE_INTERRUPTED,
)
})
it('maps an unnamed cause by the step where the job died', () => {
const raw = 'HTTP 403 em https://cdn.camara.leg.br/trecho.mp4'
expect(speechCutFailureMessage({ error: raw, step: 'resolving' })).toBe(
'Não foi possível localizar o trecho na Câmara.',
)
expect(speechCutFailureMessage({ error: 'ffmpeg failed', step: 'cutting' })).toBe(
'Não foi possível cortar o trecho.',
)
expect(speechCutFailureMessage({ error: 'storage down', step: 'metadata' })).toBe(
'Não foi possível guardar o arquivo do corte.',
)
expect(speechCutFailureMessage({ error: 'storage down', step: 'publishing' })).toBe(
'Não foi possível guardar o arquivo do corte.',
)
expect(speechCutFailureMessage({ error: 'boom', step: null })).toBe(
'Não foi possível preparar o corte.',
)
expect(speechCutFailureMessage({ error: raw, step: 'resolving' })).not.toContain('https://')
})
it('stays null when the row stored no cause', () => {
expect(speechCutFailureMessage({ error: null, step: 'resolving' })).toBeNull()
expect(speechCutFailureMessage({ error: ' ', step: 'resolving' })).toBeNull()
expect(speechCutFailureMessage({})).toBeNull()
})
})
describe('buildSpeechCutShare', () => {

View file

@ -25,6 +25,7 @@ const cutView = (overrides: Partial<SpeechCutViewModel> = {}): SpeechCutViewMode
mediaFilename: null,
youtubeVideoId: null,
publishedAt: null,
failureMessage: null,
...overrides,
})
@ -154,6 +155,43 @@ describe('SpeechCutDialog (C167)', () => {
expect(onOpenChange).toHaveBeenCalledWith(false)
})
it('shows the cause the row mapped instead of the generic failure line', async () => {
fetchMock.mockImplementation(async (input: RequestInfo | URL) => {
const url = String(input)
if (url.endsWith(SUGGESTION_ENDPOINT)) {
return jsonResponse({
status: 'success',
suggestion: { title: 'Fallback', description: 'Fallback', source: 'fallback' },
})
}
if (url.endsWith(SAVE_ENDPOINT)) {
return jsonResponse({ status: 'success', cut: cutView() })
}
if (url.endsWith(STATUS_ENDPOINT)) {
return jsonResponse({
status: 'success',
cut: cutView({
status: 'failed',
step: null,
failureMessage: 'A Câmara ainda está gerando o vídeo deste trecho.',
}),
})
}
throw new Error(`unexpected fetch: ${url}`)
})
renderDialog()
await screen.findByDisplayValue(/Trecho de BREVES COMUNICAÇÕES/)
fireEvent.click(screen.getByRole('button', { name: /Cortar e publicar/ }))
await screen.findByText(
'A Câmara ainda está gerando o vídeo deste trecho.',
{},
{ timeout: 4000 },
)
expect(screen.queryByText(/A Câmara não está entregando o vídeo desta fala agora/)).toBeNull()
})
it('shows the honest failure and retries the same row (no duplicate create)', async () => {
const retryBodies: unknown[] = []
fetchMock.mockImplementation(async (input: RequestInfo | URL, init?: RequestInit) => {

View file

@ -2,10 +2,16 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { resolveSpeechVod } from '@/utilities/speech/speechVodResolver'
import {
resolveSpeechVod,
SPEECH_VOD_CUT_POLICY,
SPEECH_VOD_PLAYER_POLICY,
} from '@/utilities/speech/speechVodResolver'
const VOD_PLAYBACK = 'https://vod.camara.leg.br/trecho.mp4'
const VOD_DOWNLOAD = 'https://vod.camara.leg.br/trecho-download.mp4'
const CACHED_PLAYBACK = 'https://cdn.camara.leg.br/fala-gravada.mp4'
const CACHED_DOWNLOAD = 'https://cdn.camara.leg.br/fala-gravada-download.mp4'
const jsonResponse = (body: unknown, status = 200): Response =>
new Response(JSON.stringify(body), {
@ -146,11 +152,172 @@ describe('resolveSpeechVod', () => {
const assertion = expect(
resolveSpeechVod({ eventId: 1, audioId: 2, excerptTms: 3 }),
).rejects.toThrow('connect ETIMEDOUT')
await vi.advanceTimersByTimeAsync(1_100)
await vi.advanceTimersByTimeAsync(SPEECH_VOD_PLAYER_POLICY.retryDelayMs + 100)
await assertion
expect(calls).toHaveLength(2)
} finally {
vi.useRealTimers()
}
})
it('keeps the C162 player click pinned to the short policy', () => {
expect(SPEECH_VOD_PLAYER_POLICY).toEqual({
statusTimeoutMs: 15_000,
statusRetries: 1,
retryDelayMs: 1_000,
pollAttempts: 0,
pollDelayMs: 1_000,
})
})
})
describe('resolveSpeechVod under the C169 cut policy', () => {
const coordinates = { eventId: 1, audioId: 2, excerptTms: 3 }
it('polls a GERANDO answer until PRONTO instead of failing on the first read', async () => {
vi.useFakeTimers()
try {
let statusReads = 0
const calls = stubFetch(async (url) => {
if (url.includes('video-sob-demanda')) {
statusReads += 1
return statusReads === 1
? jsonResponse({ estado: 'GERANDO', video: null })
: jsonResponse(readyBody)
}
return mediaResponse(206, 'video/mp4')
})
const assertion = resolveSpeechVod(coordinates, { policy: SPEECH_VOD_CUT_POLICY })
await vi.advanceTimersByTimeAsync(SPEECH_VOD_CUT_POLICY.pollDelayMs + 100)
await expect(assertion).resolves.toEqual({
state: 'pronto',
playbackUrl: VOD_PLAYBACK,
downloadUrl: VOD_DOWNLOAD,
})
expect(statusReads).toBe(2)
expect(calls[0]?.url).toContain('trecho=3')
} finally {
vi.useRealTimers()
}
})
it('gives up on GERANDO only after the declared poll budget', async () => {
vi.useFakeTimers()
try {
const calls = stubFetch(async () => jsonResponse({ estado: 'GERANDO', video: null }))
const assertion = resolveSpeechVod(coordinates, { policy: SPEECH_VOD_CUT_POLICY })
await vi.advanceTimersByTimeAsync(
SPEECH_VOD_CUT_POLICY.pollDelayMs * SPEECH_VOD_CUT_POLICY.pollAttempts + 100,
)
await expect(assertion).resolves.toEqual({ state: 'gerando' })
expect(calls).toHaveLength(1 + SPEECH_VOD_CUT_POLICY.pollAttempts)
} finally {
vi.useRealTimers()
}
})
it('resolves with the stored link when the API answers INDISPONIVEL', async () => {
stubFetch(async (url) =>
url.includes('video-sob-demanda')
? jsonResponse({ estado: 'INDISPONIVEL', video: null })
: mediaResponse(206, 'video/mp4'),
)
await expect(
resolveSpeechVod(coordinates, {
policy: SPEECH_VOD_CUT_POLICY,
cachedUrls: { playbackUrl: CACHED_PLAYBACK, downloadUrl: CACHED_DOWNLOAD },
}),
).resolves.toEqual({
state: 'pronto',
playbackUrl: CACHED_PLAYBACK,
downloadUrl: CACHED_DOWNLOAD,
})
})
it('resolves with the stored link when the API transport keeps failing', async () => {
vi.useFakeTimers()
try {
stubFetch(async (url) => {
if (url.includes('video-sob-demanda')) throw new Error('connect ETIMEDOUT')
return url === CACHED_PLAYBACK ? mediaResponse(206, 'video/mp4') : mediaResponse(403, null)
})
const assertion = resolveSpeechVod(coordinates, {
policy: SPEECH_VOD_CUT_POLICY,
cachedUrls: { playbackUrl: CACHED_PLAYBACK },
})
await vi.advanceTimersByTimeAsync(SPEECH_VOD_CUT_POLICY.retryDelayMs + 100)
await expect(assertion).resolves.toEqual({
state: 'pronto',
playbackUrl: CACHED_PLAYBACK,
downloadUrl: null,
})
} finally {
vi.useRealTimers()
}
})
it('prefers the live API link and never probes the stored cache', async () => {
const calls = stubFetch(async (url) =>
url.includes('video-sob-demanda') ? jsonResponse(readyBody) : mediaResponse(206, 'video/mp4'),
)
await expect(
resolveSpeechVod(coordinates, {
policy: SPEECH_VOD_CUT_POLICY,
cachedUrls: { playbackUrl: CACHED_PLAYBACK, downloadUrl: CACHED_DOWNLOAD },
}),
).resolves.toEqual({
state: 'pronto',
playbackUrl: VOD_PLAYBACK,
downloadUrl: VOD_DOWNLOAD,
})
expect(calls.some((call) => call.url.startsWith('https://cdn.camara.leg.br/'))).toBe(false)
})
it('tries the stored link when the PRONTO hash is dead and refuses a dead cache', async () => {
stubFetch(async (url) =>
url.includes('video-sob-demanda')
? jsonResponse(readyBody)
: url === VOD_PLAYBACK || url === VOD_DOWNLOAD
? mediaResponse(403, null)
: url === CACHED_PLAYBACK
? mediaResponse(200, 'text/html; charset=utf-8')
: mediaResponse(200, 'video/mp4'),
)
await expect(
resolveSpeechVod(coordinates, {
policy: SPEECH_VOD_CUT_POLICY,
cachedUrls: { playbackUrl: CACHED_PLAYBACK },
}),
).resolves.toEqual({ state: 'pronto', playbackUrl: null, downloadUrl: null })
})
it('still throws with cachedUrls when neither source has a verified link', async () => {
vi.useFakeTimers()
try {
stubFetch(async (url) => {
if (url.includes('video-sob-demanda')) throw new Error('connect ETIMEDOUT')
return mediaResponse(200, 'text/html; charset=utf-8')
})
const assertion = expect(
resolveSpeechVod(coordinates, {
policy: SPEECH_VOD_CUT_POLICY,
cachedUrls: { playbackUrl: CACHED_PLAYBACK, downloadUrl: CACHED_DOWNLOAD },
}),
).rejects.toThrow('connect ETIMEDOUT')
await vi.advanceTimersByTimeAsync(SPEECH_VOD_CUT_POLICY.retryDelayMs + 100)
await assertion
} finally {
vi.useRealTimers()
}
})
})