feat(C167): corta o trecho [início,fim] do acervo e publica o arquivo numa página compartilhável

Seleção do C166 ganha "Cortar vídeo": título/descrição por IA (editáveis, com fallback determinístico), job assíncrono com ffmpeg por re-encode exato e polling dos quatro passos, e página pública unlisted /corte/<id> (noindex, OG, crédito CC BY) servindo o mesmo MP4. Collection speechCut com access canReadSpeechCut (advisor/leader negados) e kill switch por status; ffmpeg entra só no runner da imagem com FFMPEG_PATH para teste.
This commit is contained in:
Francisco Solla 2026-09-15 09:59:45 -03:00
parent ab9c164615
commit 04ac01d0f3
42 changed files with 19129 additions and 57 deletions

View file

@ -76,3 +76,9 @@ GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY=
# via `GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY`.
GOOGLE_CALENDAR_OAUTH_CLIENT_ID=
GOOGLE_CALENDAR_OAUTH_CLIENT_SECRET=
# C167 — binary used to cut the exact [início,fim] MP4 of the acervo. Empty
# falls back to `ffmpeg` from PATH (the production image installs it); tests
# point it at a fake binary, and a host without ffmpeg can install it or set
# this to a wrapper.
FFMPEG_PATH=

View file

@ -66,6 +66,11 @@ RUN --mount=type=secret,id=database_url,env=DATABASE_URL \
FROM base AS runner
WORKDIR /app
# C167 — `ffmpeg` cuts the exact [início,fim] excerpt of an acervo speech
# (speechCutJob). Only the runner stage pays for it: the deps/builder/migrator
# stages never run it. `FFMPEG_PATH` can still override the binary.
RUN apk add --no-cache ffmpeg
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1

View file

@ -0,0 +1 @@
**C167 — Acervo: cortar o trecho [início,fim] e publicar o arquivo numa página compartilhável (2026-09-15):** da seleção do C166, o botão **"Cortar vídeo"** abre o formulário com **título e descrição pré-preenchidos** (IA DeepSeek sobre a janela de transcrição do trecho, editáveis; IA indisponível cai no determinístico `Trecho de <tipo> — <data>` + sumário oficial, sem bloquear) e **"Cortar e publicar"** cria o corte e acompanha quatro passos honestos ("Localizando o trecho na Câmara" → "Cortando o trecho" → "Gerando título e descrição" → "Publicando a página do corte") até o card de sucesso com link, Copiar, WhatsApp e Baixar MP4. O arquivo é o MP4 EXATO [início,fim]: o job (`speechCutJob`) re-resolve o VOD da Câmara pelo C162, baixa, corta com **ffmpeg por re-encode** (`-ss` antes do `-i`, `-t` = duração, x264/aac/`+faststart` — nunca `-c copy`, que aproximaria keyframe), grava em `media` e publica a row; **ffmpeg entra só no estágio `runner` do Dockerfile** (`apk add ffmpeg`, com `FFMPEG_PATH` para fake em teste). O request não espera o render: a row nasce `processing`, o trabalho roda em `after()` e o cliente faz poll (~1,5 s) do passo; um reaper lazy marca `processing` parado há 15 min como `failed` (restart/deploy) e o retry reusa a MESMA row — falhou, nada publica. Nova collection `speechCut` (migration `add_speech_cut`; `speech` opcional para o FK `set null` sobreviver ao delete da fala; `status` `processing|published|unpublished|failed` é o kill switch; `media`/`publishedAt`/`error` internos; `createdBy` por `systemStampedActorField`) com access `canReadSpeechCut`: leitor do acervo vê tudo, o público lê só `published` no `where` (REST/GraphQL protegidos) e `advisor`/`leader` são negados fail-closed nas rotas e na página. A página pública **`/corte/<id>`** (unlisted, `robots noindex`, sem transcrição/internals) toca o MESMO arquivo via proxy Range do Payload, baixa com `download`, mostra contexto mínimo (tipo/data/duração + sessão no YouTube), crédito **CC BY 4.0** e OG com a capa do YouTube (senão a imagem global do site); despublicado e id inexistente respondem o mesmo 404 amigável, sem revelar se o corte existiu, com bust de cache no `afterChange` do kill switch. O transporte reusa o padrão das rotas JSON do `/campanha` (`campaignJsonMutationRoute`, same-origin + zod) em `cortar`, `cortar/status` e `cortar/sugestao`. Testes: unit do módulo puro (args do ffmpeg, fallback, passos, view model, share), RTL do diálogo (prefill/IA, dirty flag, poll → publicado, falha → retry sem duplicar), int (matriz de access, create/retry/dedupe in-flight/reaper, job feliz com **fake ffmpeg** e bloco real com `ffprobe` sob `skipIf`) e e2e browserless `campaignSpeechCut` (página pública 200/404/noindex, gate das rotas) — spec novo entra no set curado (diff high-risk por migration) com manifest atualizado. Sem `Consent`/segundo cadastro, sem upload externo, sem editor/legenda/fila (C168 é a biblioteca).

View file

@ -0,0 +1,245 @@
# Impl: Acervo: cortar o trecho [início,fim] e publicar o arquivo numa página compartilhável
Status: aprovado
Atualizado em: 2026-09-15
Issue: #1014
Intenção: docs/plans/c167-cortar-trecho-publicar.md
Appetite restante: herdado (~2–3 dias eng; nenhum corte de escopo proposto)
## Leitura da intenção
- **Outcome:** da seleção [início,fim] do C166 (5–180 s), "Cortar vídeo" gera e salva o MP4 do trecho exato — sem aproximar keyframe —, com título/descrição IA pt-BR pré-preenchidos e editáveis, publica `/corte/<id>` (unlisted, `noindex`, só por link) tocando o mesmo arquivo e baixável; falhou o corte, nada publica.
- **O que NÃO negociar:** corte quem lê o acervo (`communicator`/`coordinator`/`candidate`; advisor/leader negados fail-closed — `canReadSpeechCatalog`); sem transcrição/internals no público; crédito "Fonte: Câmara dos Deputados · CC BY 4.0"; sem upload externo, sem `Consent`, sem segundo cadastro; kill switch despublica na hora; IA indisponível não bloqueia (fallback determinístico `Trecho de <tipo> — <data>` + sumário oficial); a página toca o **mesmo** arquivo do download.
- **O que reavaliar (hipóteses da intenção):**
1. "O arquivo exato [início,fim] sai da seleção C166" pressupõe um arquivo fonte no servidor. A única fonte possível é o VOD da Câmara já re-resolvido pelo C162 — o YouTube não é baixável dentro do aceite. Logo o corte só existe quando `speechVodCoordinates(speech) !== null` (mesmo predicado do botão C162), e o job usa `resolveSpeechVod` + o mesmo sistema de coordenadas da seleção (segundos do arquivo que o player toca).
2. "Página pública" no `(frontend)` herda `robots: {index:true}` do layout — `/corte/<id>` precisa de override próprio (não há precedente de página pública unlisted).
3. O request síncrono não cabe: Cloudflare → 524 com o Node ainda trabalhando = estado ambíguo. Persistência + background + polling vira decisão dura (D2).
4. O placeholder "Gerando título e descrição" do draft exige um passo real no job (garantia de fallback/normalização), já que a sugestão IA acontece no modal (D8).
## Abordagem recomendada
```mermaid
flowchart LR
A["SpeechDetailPlayer<br/>seleção C166 5–180s"] -->|"Cortar vídeo"| B["SpeechCutDialog<br/>(cliente)"]
B -->|"abre: prefill fallback"| S["POST acervo/cortar/sugestao<br/>IA DeepSeek → fallback"]
S --> B
B -->|"Cortar e publicar"| C["POST acervo/cortar<br/>gate speechCatalog + zod"]
C -->|"cria processing"| D[("speechCut")]
C -->|"after()"| E["runSpeechCutJob"]
E --> F["resolveSpeechVod (C162)"]
F -->|pronto| G["ffmpeg exato → MP4 temp"]
F -->|gerando/indisponível| X["failed — nada publicado"]
G --> H["media + publish<br/>withPayloadTransaction"]
H --> I[("published")]
B -->|"poll ~1,5s"| P["POST acervo/cortar/status"]
P -->|processing/step| B
P -->|published| U["Result card: /corte/id + Copiar + WhatsApp + Baixar"]
P -->|failed| R["Erro honesto; retry = mesma row"]
R --> C
I --> Q["/corte/id público<br/>player + OG + noindex"]
Q -.->|"despublicar (C168/admin)"| V["not-found amigável (404)"]
```
**Opções consideradas:** A) ffmpeg no servidor da imagem + job assíncrono in-process com `after()` e polling; B) ffmpeg.wasm no navegador; C) serviço externo de render; D) `ffmpeg -c copy`.
**Recomendação:** A — o repo já tem o padrão de mutação JSON em `/campanha`, proxy S3 com Range para tocar/baixar, e a produção é um container único com CPU ociosa; o custo é `apk add ffmpeg` só no estágio `runner` e um runner de job pequeno e testável com fake.
**Rejeitadas:** B porque exige COOP/COEP globais (quebraria o embed do YouTube do C162), ~30 MB de wasm e teto de RAM do browser; C porque não existe serviço e criar um é o rabbit hole declarado; D porque `-c copy` só corta em keyframe e viola o aceite ("sem aproximar keyframe").
## Decisões de engenharia (caras de reverter)
### D1 — Mecanismo de corte: ffmpeg na imagem, comando exato por re-encode
- **Opções:** A) `apk add --no-cache ffmpeg` no estágio `runner` + `child_process.execFile` com `FFMPEG_PATH` (default `ffmpeg`); B) ffmpeg.wasm no browser; C) serviço externo; D) `-c copy`.
- **Recomendação:** A. Comando (array de args, sem shell — sem interpolação):
```
ffmpeg -nostdin -hide_banner -y
-ss <startSeconds> -i <input>
-t <endSeconds-startSeconds>
-map 0:v:0 -map 0:a?
-c:v libx264 -preset veryfast -crf 20 -pix_fmt yuv420p
-c:a aac -b:a 128k
-movflags +faststart
<output>
```
`-ss` **antes** de `-i` (seek rápido) **e re-encode** ⇒ o decoder descarta tudo antes do marco e o MP4 começa exatamente nele; `-t` (duração) evita a ambiguidade de `-to` com timestamps deslocados; `+faststart` para tocar progressivo no proxy; `execFile` com `timeout` (8× a duração, teto 5 min) e `maxBuffer` para o stderr.
- **Rejeitadas:** B (COOP/COEP global + RAM + asset grande); C (sem infra — rabbit hole); D (keyframe, viola o aceite).
### D2 — Síncrono vs assíncrono: `processing` persistido + `after()` + polling + reaper
- **Opções:** A) request síncrono; B) estado persistido `processing` + trabalho em background no processo (`after()` de `next/server`, Next 15.4) + polling de status + reaper lazy; C) fila externa/worker.
- **Recomendação:** B. O POST cria a row (`processing`, `step=resolving`) e responde em <1s; `after(() => runSpeechCutJob(...))` roda depois da resposta (não estoura o túnel ~100s); o cliente faz poll de ~1,5s em `POST acervo/cortar/status` e renderiza o `step`. Reaper lazy: `status=processing` com `updatedAt` > 10 min (`SPEECH_CUT_STALE_MS`) → `failed` ("interrompido"), executado no início do status e do retry — cobre restart/deploy sem cron nem sweep.
- **Rejeitadas:** A porque 524 com o servidor continuando = UI diz falhou mas o corte pode publicar (estado ambíguo, viola "nada publicado pela metade"); C porque é o rabbit hole nomeado na intenção (worker/retry/tela de pendências) e não há infra.
### D3 — Idempotência: retry reusa a row `failed`; create deduplica in-flight
- **Opções:** A) cada tentativa cria row nova; B) retry reusa a mesma row `failed` e o create devolve a row `processing` idêntica quando já existe uma para `createdBy+speech+range`; C) só guarda de UI (botão desabilitado).
- **Recomendação:** B. `POST cortar` checa antes uma row `processing` do mesmo autor+speech+[start,end] e devolve a existente (id estável, sem duplicata em duplo clique/retry de rede); `POST cortar` de retry (`cortar` com `retryOf`) só aceita `status=failed`, reseta `step/error/publishedAt` e reusa o mesmo id — a cena 3 promete "tentar de novo não cria corte duplicado".
- **Rejeitadas:** A (lixo na futura biblioteca C168 e risco de dois publicados para o mesmo trecho); C (rede/refresh burla o cliente; o servidor é o dono).
### D4 — Collection nova `speechCut` (não reusar `Speech`/`Media`)
- **Opções:** A) collection `speechCut` com status; B) campos no `Speech`; C) um doc por mídia com status só implícito (published = existe media).
- **Recomendação:** A. Migração `pnpm migrate:create add_speech_cut` (aditiva, sem backfill). Campos: `speech` (rel, required, index), `startSeconds`/`endSeconds` (number, required), `durationSeconds` (number; `end-start`, estável para a página), `title` (text, required), `description` (textarea, required), `status` (`select`: `processing|published|unpublished|failed`, default `processing`, required, index), `step` (`select`: `resolving|cutting|metadata|publishing`, opcional), `media` (rel `media`, opcional), `error` (textarea, admin readOnly, interno), `publishedAt` (date, admin readOnly), `createdBy` (rel `campaignUser`, admin readOnly, derivado no hook — padrão `CampaignDemand.ts:164-176`). Admin group **"Comunicação"** (mesmo da `Speech`), `useAsTitle: 'title'`. `admin.description` com o crédito CC BY. `status` **editável no Payload admin** (kill switch manual antes do C168, bust imediato pelo afterChange); o resto readOnly.
- **Rejeitadas:** B (poluiria o registro de fonte; N cortes por fala e a fala é read-only do import); C (perde `failed`/`processing` e a página não teria como esconder rascunho).
- **Revisão na execução (2026-09-15):** `speech` ficou **opcional**, não `required` — o FK gerado é `ON DELETE SET NULL` e a coluna `NOT NULL` fazia o delete de uma fala com cortes estourar no Postgres (o mesmo caso que obrigou o cascade de `CampaignUser`). Com a coluna anulável, excluir a fala preserva os links publicados (a página degrada tipo/data e some o link do YouTube) e o cleanup de e2e não quebra.
### D5 — Access: leitura pública só `published` no where; escrita = leitor do acervo
- **Opções:** A) `read: () => true` + filtro na página; B) `read` que devolve onde `{status: published}` para quem não é leitor e `true` para leitor/admin; C) rota pública lendo `overrideAccess:true` e mais nada.
- **Recomendação:** B — nova `canReadSpeechCut` em `src/utilities/access/speeches.ts` (owner do access de Speech), re-exportada por `src/utilities/campaignAccess.ts`:
```ts
export const canReadSpeechCut: Access = async ({ req }) => {
if (isPayloadAdmin(req.user)) return true
const currentUser = await getFreshCampaignUser(req)
return currentUser && canReadSpeechCatalog(currentUser.role)
? true
: { status: { equals: 'published' } }
}
```
`create`/`update`: `canReadSpeech` (leitor do catálogo; o job e a action usam `overrideAccess: true` para o passo de sistema); `delete`: `payloadAdminOnly`. A action ainda repete o gate fresco (`canReadSpeechCatalog(actor.role)`) antes de qualquer leitura/escrita, como `resolveSpeechVodForActor` (C162).
- **Rejeitadas:** A porque a REST/GraphQL pública listaria `failed`/`unpublished` com `error` e `step` (internals); C porque deixa o REST sem proteção e o C168 dependente de um filtro que alguém esquece.
### D6 — Página pública cacheada por tag `document_speechCut:<id>` com bust no afterChange
- **Opções:** A) dinâmica a cada request; B) `getCachedDocumentById('speechCut', id, 1)` + `afterChange → revalidateDocumentById` (padrão Media/Petition); C) ISR por tempo.
- **Recomendação:** B. O `afterChange` do `SpeechCut.ts` chama `revalidateDocumentById('speechCut', doc.id)` em toda mudança — despublicar reflete na hora (kill switch) e republicar restaura o link. Sem `generateStaticParams` (unlisted: enumerar ids no build seria vazamento); a página renderiza on-demand. O read cacheado usa `overrideAccess` default (true) — por isso a página filtra `status === 'published'` explicitamente, exatamente como `abaixo-assinado/[id]/page.tsx:105-108` filtra `enabled`; o access D5 segue protegendo REST/GraphQL.
- **Rejeitadas:** A (custo de DB por hit sem ganho); C (janela de kill switch lenta).
### D7 — Indisponível = `notFound()` + `not-found.tsx` do segmento (404 amigável)
- **Opções:** A) `notFound()` com `src/app/(frontend)/corte/not-found.tsx`; B) página própria em 200 com o estado "Este corte não está disponível"; C) 404 default do Next; D) `not-found.tsx` no `(frontend)` inteiro.
- **Recomendação:** A. Cena 7 idêntica para despublicado e id inexistente (não revela existência), status HTTP 404 correto e sem tocar o 404 das outras páginas públicas. `generateMetadata` do caso indisponível devolve só `{ title: 'Este corte não está disponível', robots: { index:false, follow:false } }` (sem vazar título real).
- **Rejeitadas:** B (200 para recurso removido confunde crawler/monitoração e o kill switch perde semântica); C (página crua, não a cena 7); D (blast radius em todo 404 público sem necessidade).
### D8 — IA: sugestão no modal (ai | fallback) + fallback determinístico garantido no job
- **Opções:** A) ação `sugestao` ao abrir o modal devolve `{title, description, source:'ai'|'fallback'}`; o cliente pré-preenche com fallback **na hora** (já tem tipo/data/summary) e troca pelo texto IA se o usuário não tiver digitado (dirty flag); o passo `metadata` do job normaliza/garante fallback; B) IA só dentro do job; C) IA obrigatória (bloqueia sem provedor).
- **Recomendação:** A. Módulo `src/utilities/speech/speechCutMetadata.ts` no molde de `campaignDemandTitle.ts`/`rerankSpeechExcerpts.ts`: `generateObject` (zod `{title ≤ 120, description ≤ 600}`), DeepSeek `deepseek-flash`, `DEEPSEEK_API_KEY`, timeout ~6s, **nunca lança → null**; sem chave/erro → fallback. O fallback puro `buildSpeechCutFallbackMetadata` vive em `src/lib/speechCut.ts` (client-safe, unit-testável) e usa `Trecho de <tipo> — <data>` + `summary` oficial. Aviso "Sugerido por IA — revise" só quando `source === 'ai'`; sem IA, nota discreta de revisão.
- **Rejeitadas:** B (não pré-preenche o formulário — o aceite pede editar antes de confirmar); C (viola "sem bloquear a criação").
### D9 — Download: `<a download>` same-origin no proxy do Payload
- **Opções:** A) `<a href={media.url} download>` (mesma origem); B) rota dedicada com `Content-Disposition`; C) query `?download=1`.
- **Recomendação:** A. Verificado no `staticHandler` do `@payloadcms/storage-s3` (v3.82): ele streama com `Content-Type`/`ETag`/Range e **não** seta `Content-Disposition`; `media.url` é relativa e same-origin (`/api/media/file/<filename>`), então o atributo `download` do `<a>` é honrado pelo browser sem mudar storage.
- **Rejeitadas:** B (rota nova só para um header, com risco de furar o contrato de Range); C (`?download=1` não existe no handler).
### D10 — OG: capa do YouTube quando a sessão tiver; senão a imagem global do Metadata
- **Opções:** A) `https://i.ytimg.com/vi/<id>/hqdefault.jpg` via `parseYoutubeVideoId(speech.youtubeUrl)`; senão a imagem do global `metadata` (padrão de post/petição); B) extrair poster do MP4 com ffmpeg e subir como mídia; C) sem imagem quando não houver YouTube.
- **Recomendação:** A. Absoluta por natureza; o fallback global já é o padrão do site (`resolveSiteMetadata`/`toAbsoluteUrl`). O `<video>` ainda usa a capa do YouTube como `poster` quando existir.
- **Rejeitadas:** B (segundo artefato/step — capa automática é polish; gatilho de revisitação: se o preview sem YouTube incomodar no C168); C (preview pior que o padrão do site, que já tem imagem).
### D11 — Docker: ffmpeg só no estágio `runner`, caminho por `FFMPEG_PATH`
- **Opções:** A) `RUN apk add --no-cache ffmpeg` no `runner` (depois de `FROM base AS runner`, antes de `USER nextjs`), leitura preguiçosa `process.env.FFMPEG_PATH ?? 'ffmpeg'`; B) no `base` (todos os estágios); C) binário estático vendorizado no repo.
- **Recomendação:** A — só a imagem de produção paga (~+80 MB); `deps`/`builder`/`migrator` ficam como estão; `FFMPEG_PATH` permite o fake nos testes sem ffmpeg local. `.env.example` ganha `FFMPEG_PATH=` comentado (vazio = `ffmpeg` do PATH).
- **Rejeitadas:** B (infla builder/migrator sem uso); C (supply-chain/arquitetura pinada sem ganho).
### D12 — Cobertura e2e: spec browserless novo + entradas no manifest + curated
- **Opções:** A) novo `tests/e2e/campaignSpeechCut.e2e.spec.ts` (gates das rotas + página pública 200/404/noindex) com entradas no `E2E_AFFECTED_MANIFEST` e `campaignSpeechCut` no `E2E_CURATED_SPECS`; B) estender só o `campaignSpeechAcervo`; C) cobertura só em int.
- **Recomendação:** A. O manifest ganha `campaignSpeechCut` na entrada de `comunicacao`/`src/components/campaign/speech`/`src/utilities/speech`/`src/lib/speech` e uma entrada própria `{ prefixes: ['src/app/(frontend)/corte'], specs: ['campaignSpeechCut'] }` (a entrada ampla `src/app/(frontend)` continua acordando `frontend` — união, sem `unmapped-risk`). `campaignSpeechCut` entra no curado (o diff desta entrega é high-risk por collection/migration/payload.config: sem isso o PR não roda a cobertura nova) — a pinagem congelada em `tests/unit/e2eAffectedManifest.unit.spec.ts:54-67` é editada **de propósito**, com o custo declarado (spec browserless, segundos). O e2e não exercita render real: cria `media`+`speechCut` publicados via fixtures e afirma o contrato HTTP; advisor/leader negados nas rotas de corte.
- **Rejeitadas:** B (o contrato novo da URL pública ficaria sem spec próprio); C (status 200/404, `noindex` e gate HTTP pertencem ao e2e; int não vê a resposta da rota).
## Componentes / mudanças
**Domínio / schema**
- **`src/collections/SpeechCut.ts`** (novo, `speechCut`): config D4; `access` D5; `hooks.beforeChange` deriva `createdBy` do `req.user` (`campaignUser`); `hooks.afterChange` → `revalidateDocumentById('speechCut', doc.id)`.
- **`src/utilities/access/speeches.ts`**: + `canReadSpeechCut` (D5); **`src/utilities/campaignAccess.ts`**: re-export.
- **`src/payload.config.ts`**: import + `SpeechCut` na lista `collections` (junto de `Speech`).
- **Migration:** `pnpm migrate:create add_speech_cut` → `src/migrations/<ts>_add_speech_cut.ts|.json` + `src/migrations/index.ts`; rodar `pnpm migrate` local e `pnpm generate:types` (commitar `src/payload-types.ts`, que é trackeado).
- **`Dockerfile`**: `RUN apk add --no-cache ffmpeg` no `runner`; `.env.example`: `FFMPEG_PATH=`.
**Lógica pura e contratos**
- **`src/lib/speechCut.ts`** (novo, client-safe): `SpeechCutStep = 'resolving'|'cutting'|'metadata'|'publishing'`; `SPEECH_CUT_STEPS`/labels pt; `speechCutStepStates(currentStep)` (`done|current|queued`); `buildSpeechCutFallbackMetadata({speechType,dateLabel,summary})`; `buildSpeechCutFfmpegArgs({inputPath,outputPath,startSeconds,endSeconds})`; `speechCutPublicPath(id)`; `toSpeechCutViewModel(record)` (status/step/link/media/título sem `error`/`createdBy`).
- **`src/lib/schemas/speechCut.ts`** (novo): `speechCutRequestSchema` (create: `speechId`, `startSeconds`/`endSeconds` inteiros, `title` 1–200, `description` 1–2000, refine 5–180 s via `MIN_EXCERPT_SECONDS`/`MAX_EXCERPT_SECONDS` de `@/lib/speechExcerptSelection`), `speechCutRetryRequestSchema`, `speechCutStatusRequestSchema`, `speechCutSuggestionRequestSchema`; mensagens seguras `SPEECH_CUT_FORBIDDEN_MESSAGE` (re-export nomeado de `SPEECH_VOD_FORBIDDEN_MESSAGE` — um literal só), `SPEECH_CUT_NOT_FOUND_MESSAGE`, `SPEECH_CUT_INVALID_RANGE_MESSAGE`, `SPEECH_CUT_RETRY_NOT_FAILED_MESSAGE`, `SPEECH_CUT_GENERIC_ERROR_MESSAGE`.
- **`src/utilities/speech/speechCutMetadata.ts`** (novo, `server-only`): `suggestSpeechCutMetadata(...)` (D8), monta a janela do trecho dos `speechSegment` que intersectam [início,fim] (cap ~1200 chars) + `summary` + tipo/data; nunca lança.
- **`src/utilities/speech/speechCutJob.ts`** (novo, `server-only`, **sem** import de `next/server`): `runSpeechCutJob(payload, cutId)`; `reapStaleSpeechCut(payload, cut)`; download do VOD (`fetch` com `CAMARA_USER_AGENT` e `AbortSignal.timeout(180_000)`, rejeita `text/html`, guarda de tamanho, `Readable.fromWeb` → arquivo em `fs.mkdtemp`), `execFile` do ffmpeg, `finally` apagando o temp. Fluxo:
1. `step=resolving`: `resolveSpeechVod(coordinates)`; `gerando`/`indisponivel`/sem URL jogável → `failed` com mensagem interna (ex.: "A Câmara ainda está gerando o vídeo" / "A Câmara não entregou o arquivo");
2. `step=cutting`: baixa para `input.mp4`, monta args com `buildSpeechCutFfmpegArgs` e roda o binário; exit ≠ 0 → `failed`;
3. `step=metadata`: normaliza/garante `title`/`description` (fallback D8) — update de sistema (`overrideAccess: true`);
4. `step=publishing`: `withPayloadTransaction` → `payload.create media` (`filePath` do MP4, `alt: title`, `req`) + `payload.update speechCut` (`status: 'published'`, `media`, `publishedAt`, `step: null`, `error: null`, `req`) — upload fora de transação de S3 é o mesmo contrato de `profile.ts:58-85`/`demand.ts:210-260`; falha ⇒ rollback + `failed`, nunca publicado; sobra eventual de objeto no bucket é aceita (sem row órfã).
- **`src/utilities/speech/speechCutScheduler.ts`** (novo, `server-only`): `startSpeechCutJobInBackground(cutId)` com `after` de `next/server` (o único módulo que importa o scheduler; mantém o job testável em node puro).
**Ações e rotas**
- **`src/app/(campaign)/campanha/actions/speech.ts`** (estender o owner, sem twin): `createSpeechCutForActor` (gate fresco → load da fala com `overrideAccess:false` → `normalizeExcerptRange` contra `durationSeconds` → dedupe in-flight D3 → `payload.create` com `createdBy` do hook → view), `retrySpeechCutForActor` (só `failed`), `getSpeechCutStatusForActor` (reaper + view), `suggestSpeechCutMetadataForActor` (gate + segmentos + D8).
- **Rotas** (padrão `resolver-vod/route.ts`: `campaignJsonMutationRoute`, `dynamic = 'force-dynamic'`, `types.ts` local):
- `src/app/(campaign)/campanha/(app)/comunicacao/acervo/cortar/route.ts` + `types.ts`;
- `.../cortar/status/route.ts`;
- `.../cortar/sugestao/route.ts`.
Nomes de URL em pt (precedente `resolver-vod`); identificadores/selects em inglês.
**UI**
- **`src/components/campaign/speech/SpeechDetailPlayer.tsx`** (editar o owner): nova prop obrigatória `speechSummary: string | null`; com `selecting && selection && vodResolvable`, CTA primário **"Cortar vídeo"** (`ScissorsIcon`) ao lado do "Compartilhar" C166 (que vira secundário); estado `publishedCut` renderiza `SpeechCutResultCard` abaixo das ações. O detalhe `acervo/[id]/page.tsx` passa `speechSummary={view.summary}`.
- **`src/components/campaign/speech/SpeechCutDialog.tsx`** (novo, cliente; padrão `Dialog`/`Sheet` coarse via `useCoarsePointer` como o `SpeechExcerptShare`): cena 1 (range, inputs editáveis, badge "Sugerido por IA — revise" quando `source='ai'`, nota de fallback quando não), cena 2 (lista dos 4 passos com `step` do poll, sem porcentagem), cena 3 (erro + "Tentar novamente" = retry do mesmo id + "Voltar ao acervo"), sucesso → callback com o corte. Poll em `postCampaignJson` com `AbortSignal` e limpeza no unmount.
- **`src/components/campaign/speech/SpeechCutResultCard.tsx`** (novo): link `/corte/<id>` (absoluto via `window.location.origin`), "Copiar link", "WhatsApp", "Baixar arquivo (MP4)" (`download`), "Abrir página" e preview OG mínimo (capa YouTube + título + descrição + domínio).
- **`src/components/SpeechCutShareActions.tsx`** (novo, cliente, compartilhado acervo/público): copiar (feedback com reset, padrão `SpeechExcerptShare.tsx:19-33`), `buildWhatsAppTextShareUrl` de `@/lib/phone`, `<a download>` — um dono para o trio em vez de duas cópias.
- **`src/app/(frontend)/corte/[id]/page.tsx`** (novo): `getCachedDocumentById('speechCut', id, 1)`, `status !== 'published'` → `notFound()`; `<SiteHeader />`; `<video controls preload="metadata" playsInline src={cut.media.url} poster={youtubeCover}>`; título, contexto `Discurso em plenário · <data> · <duração> · Ver sessão no YouTube ↗` (reusa `formatSpeechAt` de `speechViewModels.ts`, `formatSpeechSpan` de `speechClock.ts`, `speech.youtubeUrl` cru), descrição, ações compartilhadas, crédito CC BY. Sem transcrição/JSON-LD (gate: revisitar o JSON-LD só se a página algum dia for listada).
- **`src/app/(frontend)/corte/not-found.tsx`** (novo): cena 7 (mesma resposta para despublicado/inexistente).
**Testes/infra**
- `tests/unit/speechCut.unit.spec.ts` (args ffmpeg — `-ss` antes de `-i`, `-t`, x264/aac/faststart —, fallback, passos, view model, share), `tests/unit/speechCutDialog.unit.spec.tsx` (RTL, fetch mockado: prefill fallback, IA substitui campo intocado, dirty flag, poll→sucesso/erro→retry), ajuste de `tests/unit/speechDetailPlayer.unit.spec.tsx` (nova prop) e de `tests/unit/e2eAffectedManifest.unit.spec.ts` (pin do curado).
- `tests/int/speechCut.int.spec.ts`: matriz de access; create normaliza range/rejeita inválido/deduplica; retry reusa row e rejeita não-`failed`; job feliz com `fetch` stubado (VOD `PRONTO` + bytes) e **fake ffmpeg** (`FFMPEG_PATH=tests/fixtures/fake-ffmpeg.mjs`, log de argv em env) → media + published; falhas (indisponível, ffmpeg exit≠0) → `failed` sem media; reaper; bloco `describe.skipIf(!hasFfmpeg())` com ffprobe validando a duração exata (roda no CI; skip visível, nunca silencioso).
- `tests/fixtures/fake-ffmpeg.mjs` (executável, copia input→output, `FAKE_FFMPEG_FAIL`/`FAKE_FFMPEG_LOG`).
- `tests/e2e/campaignSpeechCut.e2e.spec.ts` (D12) + `tests/e2e/fixtures/campaignE2EFixtures.ts`: `'speechCut'` e `'media'` no union `OwnedCollection`/`deletionOrder` (cut antes de media e de speech).
- `scripts/lib/e2e-affected-manifest.mjs` + curated (D12).
- `docs/changelog/2026-09-15-c167.md` (uma entrada, não editar o agregado).
### Dados → forma (n/a)
Progresso/erro são feedback de ação (não dado de acervo) — nenhum KPI/gráfico novo, conforme a intenção. A página pública mostra contexto mínimo (tipo/data/duração/YouTube) e ações; sem transcrição.
## Fases verificáveis
1. **Tracer — schema + ação + corte fake (~40% do appetite).** Migration `add_speech_cut` + `SpeechCut.ts` + access + `src/lib/speechCut.ts` + schemas + job/scheduler + actions/rotas + fake ffmpeg. Verificação: `pnpm migrate` local, `pnpm generate:types`, `pnpm test:unit -- speechCut` e `pnpm test:int -- speechCut` verdes (access fail-closed, job fake publish/failed, reaper, retry); smoke manual com `FFMPEG_PATH` fake no dev (POST create→status publica). **Confirmação do contrato de coordenadas** (rebater a hipótese #1): segmentos ASR são relativos ao MP4 do VOD importado (`speechBundleFixture.ts:44` e `upsertSpeechBundle`), o mesmo arquivo que o C162 devolve; se o tracer mostrar offset de lead-in da Câmara, o marco continua o do arquivo tocado e a limitação é registrada (não silenciada).
2. **UI (~30%).** `SpeechCutDialog`, CTA/result no `SpeechDetailPlayer`, `SpeechCutResultCard`, `SpeechCutShareActions`; unit RTL. Verificação: `pnpm test:unit -- speechCutDialog` e player verdes; fluxo manual no dev com fake (feedback dos 4 passos, erro+retry sem duplicar, card de sucesso).
3. **Página pública + deploy-prep (~20%).** `/corte/[id]`, `not-found.tsx`, OG/noindex, `Dockerfile` (runner ffmpeg), manifest/curado, spec e2e novo + fixture ownership. Verificação: `pnpm test:e2e -- campaignSpeechCut` (browserless) verde; build local (`pnpm build`) funciona sem S3.
4. **Gates e fechamento (~10%).** `pnpm gate:fast` → `pnpm gate:ci` → changelog → `pnpm push`. Verificação final de produção fica com o `deploy-staging` (OPS103) antes de produção.
## Rabbit holes / Não escopo (engenharia)
- **Editor de vídeo/ajuste de marcos no servidor.** O corte é o [início,fim] do C166; nada de timeline, filtros ou re-render.
- **Fila externa/worker/notificação.** `after()` + polling + reaper é o teto desta fatia.
- **Legenda/transcrição no arquivo gerado, `.srt`, burn-in.** O MP4 sai mudo de texto; a transcrição segue só no acervo interno.
- **Poster/capa automática por frame e imageSizes.** OG = YouTube ou imagem global (D10); revisitar no C168 se necessário.
- **Biblioteca/listagem/toggle de despublicar na UI interna.** É o C168; aqui só o campo `status='unpublished'` e o kill switch manual no admin.
- **Upload externo de vídeo, publicação em redes via API, Consent/segundo cadastro, indexação/SEO.** Fora do aceite.
- **Enumerar ids no build (`generateStaticParams`).** Unlisted é só por link; nada de pré-render de todos os cortes.
## Débitos triados (simplify 2026-09-15)
- **Dedupe de corte já publicado** (recortar a mesma janela cria um segundo `published`) e **field access dos campos de sistema** (`step`/`media`/`publishedAt`/`error`/`durationSeconds` hoje só `admin.readOnly`): defer com gatilho **C168** — a biblioteca é quem decide dedupe/curadoria e é quem edita `status`. O dedupe in-flight (mesmo `processing`) está implementado.
- **Causa específica da falha no diálogo** (hoje a mensagem genérica "A Câmara não está entregando…" cobre ffmpeg/storage): defer — expor o `error` interno exigiria sanitizar stderr (paths) e o C168 vai rever o painel de estados.
- **Variante Sheet/coarse do diálogo**: defer com gatilho: primeiro feedback de uso no mobile (o Dialog com scroll atende).
- **E2E**: o cleanup de mídia/corte é best-effort por REST no `afterAll` (DB de e2e é efêmero); descartado o net de resíduo que as fixtures usam para rows criadas via Local API.
## Riscos e mitigação
- **524 do Cloudflare/túnel.** POST responde com a row `processing`; o trabalho vai no `after()`; a UI acompanha por poll (D2). Nenhum request longo.
- **ffmpeg ausente no dev local.** Fake ffmpeg nos unit/int (sempre) + bloco real `skipIf(!hasFfmpeg())` explícito (roda no CI/ubuntu e no verify do deploy); a validação real do arquivo acontece no staging.
- **Restart/deploy no meio do job.** Job órfão fica `processing`; o reaper lazy (10 min) marca `failed` e o retry reusa a row — nada publicado.
- **Câmara `gerando`/CDN fora/HTML disfarçado de 200/arquivo grande.** `resolveSpeechVod` já rejeita texto e faz probe; o job falha fail-closed com mensagem honesta e sem publicar; guarda de tamanho/tempo no download.
- **Falha no S3/Garage ou na transação de publicação.** `withPayloadTransaction` no passo `publishing`; falha ⇒ `failed`, sem media anexada, sem página; eventual objeto órfão no bucket é o mesmo trade-off já aceito em avatar/comprovante.
- **Banda/CPU do homeserver.** O download é server→Câmara e o upload é server→Garage local (não passa pelo túnel); a reprodução usa o proxy Range já existente; `veryfast` no ffmpeg limita CPU; duas execuções simultâneas são raras no time — sem lock nesta fatia (gatilho: primeiro relato de travamento em corte concorrente).
- **Estado ambíguo do cliente.** Erro de rede no create/retry não cria lixo: dedupe in-flight no servidor + retry por id; a seleção permanece no modal (cena 3).
- **Vazamento de internals.** `error`/`step`/`createdBy` nunca vão ao público; status route devolve só estado/passo/link; metadata do indisponível não expõe título.
- **Cache do kill switch.** `afterChange` busta a tag a cada update; despublicar reflete imediatamente (mesmo se o admin usar o Payload admin).
## Aceite de engenharia
- [ ] Aceite de produto da intenção coberto: MP4 exato [início,fim] salvo, `/corte/<id>` toca e baixa o mesmo arquivo, OG (título/descrição/imagem), `noindex`, crédito CC BY, fallback de IA, falha não publica, gate do acervo fail-closed.
- [ ] Invariantes AGENTS/engineering-standards: migração commitada (`.ts`+`.json`+`index.ts`) e `push:false` intocado; multi-collection write em transação com `req.transactionID`; sem `Consent`/`Contact` paralelos; `payload-types.ts` regenerado e commitado; strings de UI em pt, identificadores em inglês; `admin.group` consistente.
- [ ] Testes previstos: unit (ffmpeg args, fallback, passos, view model, dialog RTL), int (matriz de access, create/retry/dedupe/reaper, job fake + ffmpeg real sob `skipIf` explícito), e2e `campaignSpeechCut` (gates + página 200/404/`noindex`); manifest atualizado e pin do curado editado de propósito.
- [ ] `pnpm gate:fast` e `pnpm gate:ci` verdes; `Dockerfile` com ffmpeg no runner validado no `deploy-staging` antes de produção; changelog `docs/changelog/2026-09-15-c167.md`.
## Self-score (decision-quality ≥4)
1. **Decisões caras têm rejeitadas?** 5/5 — D1–D12 com Opções/Recomendação/Rejeitadas explícitas (mecanismo, execução, idempotência, schema, access, cache, 404, IA, download, OG, Docker, e2e).
2. **Abordagem cabe no appetite?** 4/5 — ~2–3 dias com tracer cobrindo a maior incerteza (ffmpeg) cedo; UI e página são reuso de shells/precedentes; a margem é apertada, mas nenhum rabbit hole entrou.
3. **Rabbit holes nomeados?** 5/5 — editor, fila/worker, legenda, poster, biblioteca C168 e SEO listados com corte.
4. **Depth check (reusa shells/helpers)?** 5/5 — `resolveSpeechVod`/`speechVodCoordinates` (C162), `campaignJsonMutationRoute`, `withPayloadTransaction`, `speechExcerptSelection`, `getCachedDocumentById`/`revalidateDocumentById`, `SpeechExcerptShare` (pattern), `campaignDemandTitle` (pattern), `buildWhatsAppTextShareUrl`; nenhum módulo pass-through novo.
5. **Intenção permanece satisfeita?** 5/5 — a engenharia não reescreveu o outcome; a única restrição nova (corte exige VOD resolvível, D1/hipótese #1) é a condição factual para existir arquivo para cortar e foi registrada.

View file

@ -18,7 +18,7 @@
"paths": {
"server-only": ["tests/helpers/serverOnly.ts"]
},
"ignoreBinaries": ["opencode"],
"ignoreBinaries": ["opencode", "ffmpeg", "ffprobe"],
"ignoreDependencies": [
"tailwindcss-animate",
"tw-animate-css",

View file

@ -29,6 +29,10 @@ export const E2E_CURATED_SPECS = [
'campaignAiTranscribe',
'campaignAgendaFeed',
'campaignNewsletter',
// C167 — deliberate: the cut public page/route gates are the only e2e
// covering the new URL contract, and a migration always classifies the PR
// as high-risk (curated only).
'campaignSpeechCut',
]
/**
@ -245,9 +249,10 @@ export const E2E_AFFECTED_MANIFEST = [
},
{
// Zod input schemas surface in the browser through form flows; the
// public-site and newsletter specs exercise those flows end to end.
// public-site and newsletter specs exercise those flows end to end. C167
// adds the cut schemas, whose HTTP contract is the cut spec.
prefixes: ['src/lib/schemas'],
specs: ['frontend', 'campaignNewsletter'],
specs: ['frontend', 'campaignNewsletter', 'campaignSpeechCut'],
},
{
// Web Push client — the opt-in toast mounts on the campaign shell, so a
@ -283,7 +288,8 @@ export const E2E_AFFECTED_MANIFEST = [
{
// C154 — the communication vertical: the acervo search/filters/detail and
// the `speechCatalog` role gate. `src/lib/speech*` carries the pure
// search/highlight modules the RSC list renders with.
// search/highlight modules the RSC list renders with. C167 adds the cut
// routes/player/dialog and the public cut page below.
prefixes: [
`${CAMPAIGN_APP}/comunicacao`,
'src/components/campaign/speech',
@ -292,7 +298,14 @@ export const E2E_AFFECTED_MANIFEST = [
// The role predicates drive the vertical gate and the assistant surfaces.
'src/lib/campaignRoles',
],
specs: ['campaignSpeechAcervo'],
specs: ['campaignSpeechAcervo', 'campaignSpeechCut'],
},
{
// C167 — the unlisted public page of a cut: 200 with the stored file and
// 404 for unpublished/unknown ids are the HTTP contract this spec owns.
// The root share kit is rendered by both the cut card and that page.
prefixes: ['src/app/(frontend)/corte', 'src/components/SpeechCutShareActions.tsx'],
specs: ['campaignSpeechCut'],
},
{
prefixes: [

View file

@ -113,6 +113,7 @@ export default async function SpeechDetailPage({ params, searchParams }: SpeechD
durationSeconds={view.durationSeconds}
speechType={view.type}
speechDateLabel={view.speechDateLabel}
speechSummary={view.summary}
/>
</div>

View file

@ -0,0 +1,40 @@
import { NextResponse } from 'next/server'
import { saveSpeechCutForActor } from '@/app/(campaign)/campanha/actions/speech'
import {
SPEECH_CUT_FORBIDDEN_MESSAGE,
SPEECH_CUT_GENERIC_ERROR_MESSAGE,
SPEECH_CUT_INVALID_RANGE_MESSAGE,
SPEECH_CUT_NOT_FOUND_MESSAGE,
SPEECH_CUT_RETRY_NOT_FAILED_MESSAGE,
SPEECH_CUT_SPEECH_NOT_FOUND_MESSAGE,
speechCutRequestSchema,
} from '@/lib/schemas/speechCut'
import { SPEECH_VOD_INELIGIBLE_MESSAGE } from '@/lib/schemas/speechVod'
import { campaignJsonMutationRoute } from '@/utilities/campaignJsonMutationRoute'
import type { SpeechCutSaveResponse } from './types'
export type { SpeechCutSaveResponse } from './types'
export const dynamic = 'force-dynamic'
export const POST = campaignJsonMutationRoute(
{
bodySchema: speechCutRequestSchema,
safeMessages: [
SPEECH_CUT_FORBIDDEN_MESSAGE,
SPEECH_CUT_SPEECH_NOT_FOUND_MESSAGE,
SPEECH_CUT_NOT_FOUND_MESSAGE,
SPEECH_CUT_INVALID_RANGE_MESSAGE,
SPEECH_CUT_RETRY_NOT_FAILED_MESSAGE,
SPEECH_VOD_INELIGIBLE_MESSAGE,
],
genericMessage: SPEECH_CUT_GENERIC_ERROR_MESSAGE,
},
async (body) => {
const cut = await saveSpeechCutForActor(body)
return NextResponse.json<SpeechCutSaveResponse>({ status: 'success', cut })
},
)

View file

@ -0,0 +1,29 @@
import { NextResponse } from 'next/server'
import { getSpeechCutStatusForActor } from '@/app/(campaign)/campanha/actions/speech'
import {
SPEECH_CUT_FORBIDDEN_MESSAGE,
SPEECH_CUT_GENERIC_ERROR_MESSAGE,
SPEECH_CUT_NOT_FOUND_MESSAGE,
speechCutStatusRequestSchema,
} from '@/lib/schemas/speechCut'
import { campaignJsonMutationRoute } from '@/utilities/campaignJsonMutationRoute'
import type { SpeechCutStatusResponse } from '../types'
export type { SpeechCutStatusResponse } from '../types'
export const dynamic = 'force-dynamic'
export const POST = campaignJsonMutationRoute(
{
bodySchema: speechCutStatusRequestSchema,
safeMessages: [SPEECH_CUT_FORBIDDEN_MESSAGE, SPEECH_CUT_NOT_FOUND_MESSAGE],
genericMessage: SPEECH_CUT_GENERIC_ERROR_MESSAGE,
},
async (body) => {
const cut = await getSpeechCutStatusForActor(body)
return NextResponse.json<SpeechCutStatusResponse>({ status: 'success', cut })
},
)

View file

@ -0,0 +1,34 @@
import { NextResponse } from 'next/server'
import { suggestSpeechCutMetadataForActor } from '@/app/(campaign)/campanha/actions/speech'
import {
SPEECH_CUT_FORBIDDEN_MESSAGE,
SPEECH_CUT_GENERIC_ERROR_MESSAGE,
SPEECH_CUT_INVALID_RANGE_MESSAGE,
SPEECH_CUT_SPEECH_NOT_FOUND_MESSAGE,
speechCutSuggestionRequestSchema,
} from '@/lib/schemas/speechCut'
import { campaignJsonMutationRoute } from '@/utilities/campaignJsonMutationRoute'
import type { SpeechCutSuggestionResponse } from '../types'
export type { SpeechCutSuggestionResponse } from '../types'
export const dynamic = 'force-dynamic'
export const POST = campaignJsonMutationRoute(
{
bodySchema: speechCutSuggestionRequestSchema,
safeMessages: [
SPEECH_CUT_FORBIDDEN_MESSAGE,
SPEECH_CUT_SPEECH_NOT_FOUND_MESSAGE,
SPEECH_CUT_INVALID_RANGE_MESSAGE,
],
genericMessage: SPEECH_CUT_GENERIC_ERROR_MESSAGE,
},
async (body) => {
const suggestion = await suggestSpeechCutMetadataForActor(body)
return NextResponse.json<SpeechCutSuggestionResponse>({ status: 'success', suggestion })
},
)

View file

@ -0,0 +1,17 @@
import type { SpeechCutViewModel } from '@/lib/speechCut'
import type { SpeechCutMetadataSuggestion } from '@/utilities/speech/speechCutMetadata'
/** Wire contract of `POST /campanha/comunicacao/acervo/cortar` (C167). */
export type SpeechCutSaveResponse =
| { status: 'success'; cut: SpeechCutViewModel }
| { status: 'error'; message: string }
/** Wire contract of `POST /campanha/comunicacao/acervo/cortar/status` (C167). */
export type SpeechCutStatusResponse =
| { status: 'success'; cut: SpeechCutViewModel }
| { status: 'error'; message: string }
/** Wire contract of `POST /campanha/comunicacao/acervo/cortar/sugestao` (C167). */
export type SpeechCutSuggestionResponse =
| { status: 'success'; suggestion: SpeechCutMetadataSuggestion }
| { status: 'error'; message: string }

View file

@ -1,14 +1,45 @@
'use server'
import type { Payload } from 'payload'
import { canReadSpeechCatalog } from '@/lib/campaignRoles'
import {
SPEECH_CUT_FORBIDDEN_MESSAGE,
SPEECH_CUT_INVALID_RANGE_MESSAGE,
SPEECH_CUT_NOT_FOUND_MESSAGE,
SPEECH_CUT_RETRY_NOT_FAILED_MESSAGE,
SPEECH_CUT_SPEECH_NOT_FOUND_MESSAGE,
speechCutRequestSchema,
speechCutStatusRequestSchema,
speechCutSuggestionRequestSchema,
type SpeechCutRequest,
} from '@/lib/schemas/speechCut'
import {
SPEECH_VOD_FORBIDDEN_MESSAGE,
SPEECH_VOD_INELIGIBLE_MESSAGE,
SPEECH_VOD_NOT_FOUND_MESSAGE,
speechVodRequestSchema,
} from '@/lib/schemas/speechVod'
import { formatSpeechDate } from '@/lib/speechClock'
import {
toSpeechCutViewModel,
type SpeechCutRecordForView,
type SpeechCutViewModel,
} from '@/lib/speechCut'
import {
MAX_EXCERPT_SECONDS,
MIN_EXCERPT_SECONDS,
normalizeExcerptRange,
} from '@/lib/speechExcerptSelection'
import { speechVodCoordinates, type SpeechVodResolution } from '@/lib/speechVod'
import type { CampaignUser } from '@/payload-types'
import { getCampaignActionContext } from '@/utilities/campaignActionContext'
import { reapStaleSpeechCut } from '@/utilities/speech/speechCutJob'
import {
suggestSpeechCutMetadata,
type SpeechCutMetadataSuggestion,
} from '@/utilities/speech/speechCutMetadata'
import { startSpeechCutJobInBackground } from '@/utilities/speech/speechCutScheduler'
import { resolveSpeechVod } from '@/utilities/speech/speechVodResolver'
/**
@ -51,3 +82,245 @@ export const resolveSpeechVodForActor = async (input: {
return resolveSpeechVod(coordinates)
}
// ---------------------------------------------------------------------------
// C167 — cuts: create/retry the exact [start, end] MP4, poll it and suggest
// the AI metadata. The catalog gate is repeated here, fresh, before any read or
// write (same contract as the C162 resolution above).
// ---------------------------------------------------------------------------
const speechCutSpeechSelect = {
speechAt: true,
type: true,
summary: true,
durationSeconds: true,
vodPlaybackUrl: true,
vodDownloadUrl: true,
eventId: true,
audioId: true,
excerptTMs: true,
} as const
const loadSpeechCutForActor = async (
payload: Payload,
actor: CampaignUser,
cutId: number,
): Promise<SpeechCutRecordForView | null> => {
const result = await payload.find({
collection: 'speechCut',
where: { id: { equals: cutId } },
depth: 1,
limit: 1,
pagination: false,
user: actor,
overrideAccess: false,
})
return result.docs[0] ?? null
}
const loadSpeechForCut = async (payload: Payload, actor: CampaignUser, speechId: number) => {
const result = await payload.find({
collection: 'speech',
where: { id: { equals: speechId } },
depth: 0,
limit: 1,
pagination: false,
select: speechCutSpeechSelect,
user: actor,
overrideAccess: false,
})
return result.docs[0] ?? null
}
/**
* Validates the requested window against the C166 bounds and the speech
* duration, then normalizes it. Deterministic (the action and the suggestion
* must agree): a forged request fails closed instead of silently cutting a
* different interval.
*/
const resolveExcerptRange = (
durationSeconds: number | null | undefined,
startSeconds: number,
endSeconds: number,
): { startSeconds: number; endSeconds: number } => {
const duration = Math.floor(durationSeconds ?? 0)
const requested = endSeconds - startSeconds
if (requested < MIN_EXCERPT_SECONDS || requested > MAX_EXCERPT_SECONDS || endSeconds > duration) {
throw new Error(SPEECH_CUT_INVALID_RANGE_MESSAGE)
}
const range = normalizeExcerptRange(startSeconds, endSeconds, duration)
if (!range) throw new Error(SPEECH_CUT_INVALID_RANGE_MESSAGE)
return range
}
const createSpeechCut = async (
payload: Payload,
actor: CampaignUser,
input: {
speechId: number
startSeconds: number
endSeconds: number
title: string
description: string
},
): Promise<SpeechCutViewModel> => {
const speech = await loadSpeechForCut(payload, actor, input.speechId)
if (!speech) throw new Error(SPEECH_CUT_SPEECH_NOT_FOUND_MESSAGE)
if (!speechVodCoordinates(speech)) throw new Error(SPEECH_VOD_INELIGIBLE_MESSAGE)
const range = resolveExcerptRange(speech.durationSeconds, input.startSeconds, input.endSeconds)
// Dedupe in-flight: a double click or a retried POST gets the same row (and
// the same public id) instead of two cuts of one excerpt.
const inFlight = await payload.find({
collection: 'speechCut',
where: {
and: [
{ createdBy: { equals: actor.id } },
{ speech: { equals: speech.id } },
{ startSeconds: { equals: range.startSeconds } },
{ endSeconds: { equals: range.endSeconds } },
{ status: { equals: 'processing' } },
],
},
depth: 1,
limit: 1,
pagination: false,
user: actor,
overrideAccess: false,
})
const existing = inFlight.docs[0]
if (existing) return toSpeechCutViewModel(existing)
const cut = await payload.create({
collection: 'speechCut',
data: {
speech: speech.id,
startSeconds: range.startSeconds,
endSeconds: range.endSeconds,
durationSeconds: range.endSeconds - range.startSeconds,
title: input.title,
description: input.description,
status: 'processing',
step: 'resolving',
},
depth: 1,
user: actor,
overrideAccess: false,
})
startSpeechCutJobInBackground(cut.id)
return toSpeechCutViewModel(cut)
}
const retrySpeechCut = async (
payload: Payload,
actor: CampaignUser,
cutId: number,
): Promise<SpeechCutViewModel> => {
const current = await loadSpeechCutForActor(payload, actor, cutId)
if (!current) throw new Error(SPEECH_CUT_NOT_FOUND_MESSAGE)
if (current.status !== 'failed') throw new Error(SPEECH_CUT_RETRY_NOT_FAILED_MESSAGE)
const cut = await payload.update({
collection: 'speechCut',
id: cutId,
data: { status: 'processing', step: 'resolving', error: null, media: null, publishedAt: null },
depth: 1,
user: actor,
overrideAccess: false,
})
startSpeechCutJobInBackground(cut.id)
return toSpeechCutViewModel(cut)
}
/**
* Creates one cut (`speechId` + range + title/description) or retries the
* failed row (`retryOf`) — the dialog reuses the selection and never duplicates
* a cut on retry.
*/
export const saveSpeechCutForActor = async (
input: SpeechCutRequest,
): Promise<SpeechCutViewModel> => {
const parsed = speechCutRequestSchema.parse(input)
const { payload, actor } = await getCampaignActionContext()
if (!canReadSpeechCatalog(actor.role)) throw new Error(SPEECH_CUT_FORBIDDEN_MESSAGE)
return 'retryOf' in parsed
? retrySpeechCut(payload, actor, parsed.retryOf)
: createSpeechCut(payload, actor, parsed)
}
/** Polls one cut; a stale `processing` row is reaped to `failed` on the way. */
export const getSpeechCutStatusForActor = async (input: {
cutId: number
}): Promise<SpeechCutViewModel> => {
const { cutId } = speechCutStatusRequestSchema.parse(input)
const { payload, actor } = await getCampaignActionContext()
if (!canReadSpeechCatalog(actor.role)) throw new Error(SPEECH_CUT_FORBIDDEN_MESSAGE)
let cut = await loadSpeechCutForActor(payload, actor, cutId)
if (!cut) throw new Error(SPEECH_CUT_NOT_FOUND_MESSAGE)
if (
await reapStaleSpeechCut(payload, {
id: cut.id,
status: cut.status,
updatedAt: cut.updatedAt ?? '',
})
) {
cut = await loadSpeechCutForActor(payload, actor, cutId)
if (!cut) throw new Error(SPEECH_CUT_NOT_FOUND_MESSAGE)
}
return toSpeechCutViewModel(cut)
}
/** AI suggestion for the picked window; falls back deterministically, never blocks. */
export const suggestSpeechCutMetadataForActor = async (input: {
speechId: number
startSeconds: number
endSeconds: number
}): Promise<SpeechCutMetadataSuggestion> => {
const parsed = speechCutSuggestionRequestSchema.parse(input)
const { payload, actor } = await getCampaignActionContext()
if (!canReadSpeechCatalog(actor.role)) throw new Error(SPEECH_CUT_FORBIDDEN_MESSAGE)
const speech = await loadSpeechForCut(payload, actor, parsed.speechId)
if (!speech) throw new Error(SPEECH_CUT_SPEECH_NOT_FOUND_MESSAGE)
const range = resolveExcerptRange(speech.durationSeconds, parsed.startSeconds, parsed.endSeconds)
const segments = await payload.find({
collection: 'speechSegment',
where: {
and: [
{ speech: { equals: speech.id } },
{ endSeconds: { greater_than: range.startSeconds } },
{ startSeconds: { less_than: range.endSeconds } },
],
},
depth: 0,
limit: 0,
pagination: false,
sort: 'order',
select: { startSeconds: true, endSeconds: true, text: true },
user: actor,
overrideAccess: false,
})
return suggestSpeechCutMetadata({
speechType: speech.type ?? null,
dateLabel: formatSpeechDate(speech.speechAt),
summary: speech.summary ?? null,
segments: segments.docs.map((segment) => ({
startSeconds: segment.startSeconds,
endSeconds: segment.endSeconds,
text: segment.text,
})),
startSeconds: range.startSeconds,
endSeconds: range.endSeconds,
})
}

View file

@ -0,0 +1,189 @@
import { SiteHeader } from '@/components/SiteHeader'
import { SpeechCutShareActions } from '@/components/SpeechCutShareActions'
import { formatSpeechDate, formatSpeechSpan } from '@/lib/speechClock'
import { speechCutPublicPath } from '@/lib/speechCut'
import { parseYoutubeVideoId } from '@/lib/speechVod'
import type { Media, Speech, SpeechCut } from '@/payload-types'
import { getCachedDocumentById } from '@/utilities/documentReads'
import { getCachedGlobal } from '@/utilities/globalReads'
import { absoluteSitePath, resolveSiteMetadata, toAbsoluteUrl, truncate } from '@/utilities/seo'
import type { Metadata } from 'next'
import { notFound } from 'next/navigation'
import { APIError } from 'payload'
const MAX_DESCRIPTION_LENGTH = 200
const CREDIT = 'Fonte: Câmara dos Deputados · CC BY 4.0'
const parseCutId = (raw: string): number | null => {
if (!/^\d+$/.test(raw)) return null
const id = Number(raw)
return Number.isSafeInteger(id) && id > 0 ? id : null
}
const mediaOf = (cut: SpeechCut): Media | null =>
typeof cut.media === 'object' && cut.media !== null ? cut.media : null
const speechOf = (cut: SpeechCut): Speech | null =>
typeof cut.speech === 'object' && cut.speech !== null ? cut.speech : null
const youtubeCoverUrl = (cut: SpeechCut): string | null => {
const speech = speechOf(cut)
const videoId = parseYoutubeVideoId(speech?.youtubeUrl)
return videoId ? `https://i.ytimg.com/vi/${videoId}/hqdefault.jpg` : null
}
const loadPublishedCut = async (id: string): Promise<SpeechCut | null> => {
const cutId = parseCutId(id)
if (cutId === null) return null
try {
const cut = await getCachedDocumentById('speechCut', cutId, 1)()
if (!cut || cut.status !== 'published' || !mediaOf(cut)?.url) return null
return cut
} catch (error) {
// `findByID` throws NotFound for an unknown id; both that and an
// unpublished cut resolve to the same not-found screen (never reveal
// whether it existed). A real read failure keeps failing loudly.
if (error instanceof APIError && error.status === 404) return null
throw error
}
}
/**
* C167 — the unlisted public page of a cut: the stored MP4 (never the
* YouTube/VOD source), the AI/human title and description, the credit and the
* share kit. `noindex` on purpose: the link circulates, the page is not listed.
* An unpublished or unknown id resolves to the same not-found screen, so the
* kill switch does not reveal whether the cut existed.
*/
export async function generateMetadata({
params,
}: {
params: Promise<{ id: string }>
}): Promise<Metadata> {
const { id } = await params
const cut = await loadPublishedCut(id)
if (!cut) {
return { title: 'Este corte não está disponível', robots: { index: false, follow: false } }
}
const globalMetadata = await getCachedGlobal('metadata')()
const { siteUrl, siteName, twitterCreator } = resolveSiteMetadata(globalMetadata)
const canonicalUrl = absoluteSitePath(siteUrl, speechCutPublicPath(cut.id))
const youtubeCover = youtubeCoverUrl(cut)
let fallbackImage: Media | null = null
if (!youtubeCover && globalMetadata.image) {
fallbackImage =
typeof globalMetadata.image === 'number'
? await getCachedDocumentById('media', String(globalMetadata.image))()
: globalMetadata.image
}
const imageUrl =
youtubeCover ??
(fallbackImage?.url && siteUrl ? toAbsoluteUrl(fallbackImage.url, siteUrl) : undefined)
const title = `${cut.title} | ${siteName}`
const description = truncate(cut.description, MAX_DESCRIPTION_LENGTH)
const images = imageUrl ? [{ url: imageUrl, alt: cut.title }] : []
return {
title,
description,
robots: { index: false, follow: false },
...(canonicalUrl ? { alternates: { canonical: canonicalUrl } } : {}),
openGraph: {
type: 'video.other',
locale: 'pt-BR',
...(canonicalUrl ? { url: canonicalUrl } : {}),
siteName,
title,
description,
images,
},
twitter: {
card: 'summary_large_image',
title,
description,
creator: twitterCreator,
images: images.map((image) => image.url),
},
}
}
export default async function SpeechCutPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params
const cut = await loadPublishedCut(id)
if (!cut) notFound()
const media = mediaOf(cut)
const speech = speechOf(cut)
const videoUrl = media?.url ?? null
const poster = youtubeCoverUrl(cut)
const globalMetadata = await getCachedGlobal('metadata')()
const { siteUrl } = resolveSiteMetadata(globalMetadata)
const publicUrl =
absoluteSitePath(siteUrl, speechCutPublicPath(cut.id)) ?? speechCutPublicPath(cut.id)
const durationLabel = formatSpeechSpan(
cut.durationSeconds ?? Math.max(0, cut.endSeconds - cut.startSeconds),
)
return (
<>
<SiteHeader />
<main className="w-full">
<div className="mx-auto w-full max-w-3xl px-4 py-10 sm:px-6 lg:px-8 lg:py-14">
{videoUrl ? (
<video
controls
preload="metadata"
playsInline
poster={poster ?? undefined}
src={videoUrl}
className="aspect-video w-full rounded-xl border bg-black"
/>
) : null}
<h1 className="mt-6 text-2xl font-bold leading-tight tracking-tight text-balance sm:text-3xl">
{cut.title}
</h1>
<p className="mt-2 text-sm text-muted-foreground">
{speech?.type ?? 'Fala'}
{speech ? <> · {formatSpeechDate(speech.speechAt)}</> : null} · {durationLabel}
{speech?.youtubeUrl ? (
<>
{' · '}
<a
href={speech.youtubeUrl}
target="_blank"
rel="noopener noreferrer"
className="underline underline-offset-4"
>
Ver sessão no YouTube ↗
</a>
</>
) : null}
</p>
<p className="mt-5 text-base leading-relaxed text-foreground/90">{cut.description}</p>
<SpeechCutShareActions
className="mt-7"
url={publicUrl}
title={cut.title}
downloadUrl={videoUrl}
downloadFilename={media?.filename}
primary="whatsapp"
/>
<div className="mt-10 border-t pt-4">
<p className="text-xs text-muted-foreground">{CREDIT}</p>
<p className="mt-1 text-xs text-muted-foreground">
Página não listada — o link circula, mas não é indexada nem aparece em buscas.
</p>
</div>
</div>
</main>
</>
)
}

View file

@ -0,0 +1,28 @@
import { SiteHeader } from '@/components/SiteHeader'
import Link from 'next/link'
/**
* C167 — the same friendly screen for an unpublished cut and an unknown id:
* the link is gone, and the page does not reveal whether the cut existed.
*/
export default function SpeechCutNotFound() {
return (
<>
<SiteHeader />
<main className="flex w-full items-center justify-center px-4 py-24">
<div className="max-w-md text-center">
<h1 className="text-lg font-semibold">Este corte não está disponível</h1>
<p className="mt-2 text-sm text-muted-foreground">
O link pode ter sido despublicado ou o endereço está incorreto.
</p>
<Link
href="/"
className="mt-6 inline-flex min-h-10 items-center justify-center rounded-lg border px-4 text-sm"
>
Ir para jorgesolla1313.com.br
</Link>
</div>
</main>
</>
)
}

View file

@ -0,0 +1,160 @@
import type { CollectionConfig } from 'payload'
import {
SPEECH_CUT_DESCRIPTION_MAX_LENGTH,
SPEECH_CUT_STATUSES,
SPEECH_CUT_STEPS,
SPEECH_CUT_TITLE_MAX_LENGTH,
speechCutStatusLabels,
speechCutStepLabels,
} from '@/lib/speechCut'
import {
canReadSpeech,
canReadSpeechCut,
canSetCampaignSystemField,
payloadAdminOnly,
} from '@/utilities/campaignAccess'
import { stampCampaignCreatedBy, systemStampedActorField } from '@/utilities/campaignAuditFields'
import { revalidateDocumentById } from '@/utilities/documents'
/**
* C167 — published cuts of the speech acervo: the exact [start, end] MP4 with
* AI-suggested (human-editable) title/description and the unlisted public page
* `/corte/<id>`. Media is the stored file the public page plays and downloads;
* `status` is the kill switch (`published` → `unpublished`). Source data is the
* Câmara VOD — keep the CC BY credit.
*/
const STATUS_OPTIONS = SPEECH_CUT_STATUSES.map((value) => ({
value,
label: speechCutStatusLabels[value],
}))
const STEP_OPTIONS = SPEECH_CUT_STEPS.map((value) => ({
value,
label: speechCutStepLabels[value],
}))
export const SpeechCut: CollectionConfig = {
slug: 'speechCut',
labels: {
singular: 'Corte',
plural: 'Cortes',
},
admin: {
group: 'Comunicação',
useAsTitle: 'title',
defaultColumns: ['title', 'speech', 'startSeconds', 'endSeconds', 'status', 'publishedAt'],
description:
'Cortes do acervo de falas publicados em /corte/<id>. Dados e vídeos da Câmara dos Deputados (CC BY 4.0).',
},
access: {
create: canReadSpeech,
read: canReadSpeechCut,
update: canReadSpeech,
delete: payloadAdminOnly,
},
hooks: {
beforeChange: [stampCampaignCreatedBy],
afterChange: [
({ doc }) => {
revalidateDocumentById('speechCut', doc.id)
},
],
afterDelete: [
({ doc }) => {
revalidateDocumentById('speechCut', doc.id)
},
],
},
fields: [
{
name: 'speech',
type: 'relationship',
relationTo: 'speech',
label: 'Discurso',
index: true,
admin: {
description:
'Fala de origem do corte. Preenchida pelo fluxo; fica vazia se a fala for excluída (o link público continua válido).',
},
},
{
name: 'startSeconds',
type: 'number',
label: 'Início (s)',
required: true,
},
{
name: 'endSeconds',
type: 'number',
label: 'Fim (s)',
required: true,
},
{
name: 'durationSeconds',
type: 'number',
label: 'Duração (s)',
admin: { readOnly: true, description: 'Derivada do trecho escolhido.' },
},
{
name: 'title',
type: 'text',
label: 'Título',
required: true,
maxLength: SPEECH_CUT_TITLE_MAX_LENGTH,
},
{
name: 'description',
type: 'textarea',
label: 'Descrição',
required: true,
maxLength: SPEECH_CUT_DESCRIPTION_MAX_LENGTH,
},
{
name: 'status',
type: 'select',
label: 'Estado',
required: true,
defaultValue: 'processing',
index: true,
options: STATUS_OPTIONS,
admin: {
description: 'Despublicar tira o corte do ar na hora (kill switch).',
},
},
{
name: 'step',
type: 'select',
label: 'Passo',
options: STEP_OPTIONS,
admin: {
readOnly: true,
description: 'Progresso honesto do corte em andamento.',
},
},
{
name: 'media',
type: 'relationship',
relationTo: 'media',
label: 'Arquivo (MP4)',
admin: { readOnly: true, description: 'O arquivo que a página pública toca e baixa.' },
},
{
name: 'error',
type: 'textarea',
label: 'Erro',
admin: {
readOnly: true,
description: 'Motivo interno da falha; nunca vai ao público.',
},
},
{
name: 'publishedAt',
type: 'date',
label: 'Publicado em',
admin: { readOnly: true },
},
systemStampedActorField({ setAccess: canSetCampaignSystemField }),
],
}

View file

@ -0,0 +1,87 @@
'use client'
import { CheckIcon, CopyIcon, DownloadIcon } from 'lucide-react'
import { WhatsAppIcon } from '@/components/socialIcons'
import { Button } from '@/components/ui/button'
import { copyFeedbackLabels, copyFeedbackLiveMessages, useCopyFeedback } from '@/lib/copyFeedback'
import { buildSpeechCutShare } from '@/lib/speechCut'
import { cn } from '@/lib/utils'
type SpeechCutShareActionsProps = {
/** Absolute public URL of the cut. */
url: string
title: string
/** Stored MP4; absent while a cut is still being processed. */
downloadUrl?: string | null
downloadFilename?: string | null
/** Which action leads on this surface (acervo: copy; public page: WhatsApp). */
primary?: 'copy' | 'whatsapp'
className?: string
}
/**
* C167 — the share kit of a published cut, shared by the acervo result card and
* the public page: copy the link, open the sender's WhatsApp (`wa.me`) and
* download the same MP4 the page plays. The copy feedback lives next to the
* button and resets on its own.
*/
export const SpeechCutShareActions = ({
url,
title,
downloadUrl = null,
downloadFilename = null,
primary = 'copy',
className,
}: SpeechCutShareActionsProps) => {
const { feedback, copy } = useCopyFeedback()
const share = buildSpeechCutShare({ title, url })
return (
<div
className={cn('flex flex-wrap items-center gap-2', className)}
data-slot="speech-cut-share"
>
{primary === 'whatsapp' ? (
<Button asChild className="min-h-10">
<a href={share.whatsAppUrl} target="_blank" rel="noopener noreferrer">
<WhatsAppIcon data-icon="inline-start" />
Compartilhar no WhatsApp
</a>
</Button>
) : null}
<Button
type="button"
variant={primary === 'copy' ? 'default' : 'outline'}
className="min-h-10"
onClick={() => void copy(url)}
>
{feedback === 'copied' ? (
<CheckIcon data-icon="inline-start" aria-hidden="true" />
) : (
<CopyIcon data-icon="inline-start" aria-hidden="true" />
)}
{copyFeedbackLabels[feedback]}
</Button>
{primary === 'copy' ? (
<Button asChild variant="outline" className="min-h-10">
<a href={share.whatsAppUrl} target="_blank" rel="noopener noreferrer">
<WhatsAppIcon data-icon="inline-start" />
Compartilhar no WhatsApp
</a>
</Button>
) : null}
{downloadUrl ? (
<Button asChild variant="outline" className="min-h-10">
<a href={downloadUrl} download={downloadFilename ?? ''}>
<DownloadIcon data-icon="inline-start" aria-hidden="true" />
Baixar arquivo (MP4)
</a>
</Button>
) : null}
<span aria-live="polite" className="sr-only">
{copyFeedbackLiveMessages[feedback]}
</span>
</div>
)
}

View file

@ -0,0 +1,421 @@
'use client'
import { AlertCircleIcon, CheckIcon, ScissorsIcon } from 'lucide-react'
import { useEffect, useMemo, useRef, useState } from 'react'
import type {
SpeechCutSaveResponse,
SpeechCutStatusResponse,
SpeechCutSuggestionResponse,
} from '@/app/(campaign)/campanha/(app)/comunicacao/acervo/cortar/types'
import { Alert, AlertDescription } from '@/components/ui/Alert'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Field, FieldLabel } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { Spinner } from '@/components/ui/Spinner'
import { Textarea } from '@/components/ui/textarea'
import { postCampaignJson } from '@/lib/campaignJsonRequest'
import { SPEECH_CUT_GENERIC_ERROR_MESSAGE, type SpeechCutRequest } from '@/lib/schemas/speechCut'
import { formatSpeechClock, formatSpeechSpan } from '@/lib/speechClock'
import {
buildSpeechCutFallbackMetadata,
SPEECH_CUT_DESCRIPTION_MAX_LENGTH,
SPEECH_CUT_TITLE_MAX_LENGTH,
speechCutStepStates,
type SpeechCutStep,
type SpeechCutViewModel,
} from '@/lib/speechCut'
import {
MAX_EXCERPT_SECONDS,
MIN_EXCERPT_SECONDS,
type ExcerptRange,
} from '@/lib/speechExcerptSelection'
import { cn } from '@/lib/utils'
const SAVE_ENDPOINT = '/campanha/comunicacao/acervo/cortar'
const STATUS_ENDPOINT = `${SAVE_ENDPOINT}/status`
const SUGGESTION_ENDPOINT = `${SAVE_ENDPOINT}/sugestao`
const POLL_INTERVAL_MS = 1500
type CutPhase = 'form' | 'running' | 'failed'
type SpeechCutDialogProps = {
open: boolean
onOpenChange: (open: boolean) => void
speechId: number
speechType: string | null
dateLabel: string
summary: string | null
range: ExcerptRange
onPublished: (cut: SpeechCutViewModel) => void
}
const StepMark = ({ state }: { state: 'done' | 'current' | 'queued' }) =>
state === 'done' ? (
<CheckIcon className="size-4 shrink-0 text-emerald-600" aria-hidden="true" />
) : state === 'current' ? (
<Spinner className="size-4 shrink-0" aria-label="Em andamento" />
) : (
<span className="size-2 shrink-0 rounded-full bg-muted-foreground/40" aria-hidden="true" />
)
/**
* C167 — the "Cortar vídeo" flow: the C166 selection is fixed, title and
* description arrive pre-filled (AI suggestion swapped in while the fields are
* untouched, deterministic fallback otherwise) and confirming creates the cut
* and follows the four honest steps until the public page exists. A failed cut
* retries the same row — never a duplicate.
*/
export const SpeechCutDialog = ({
open,
onOpenChange,
speechId,
speechType,
dateLabel,
summary,
range,
onPublished,
}: SpeechCutDialogProps) => {
const fallback = useMemo(
() => buildSpeechCutFallbackMetadata({ speechType, dateLabel, summary }),
[speechType, dateLabel, summary],
)
const [phase, setPhase] = useState<CutPhase>('form')
const [cutId, setCutId] = useState<number | null>(null)
const [step, setStep] = useState<SpeechCutStep | null>(null)
const [title, setTitle] = useState(fallback.title)
const [description, setDescription] = useState(fallback.description)
const [source, setSource] = useState<'ai' | 'fallback' | null>(null)
const [error, setError] = useState<string | null>(null)
const [submitting, setSubmitting] = useState(false)
const titleDirty = useRef(false)
const descriptionDirty = useRef(false)
const phaseRef = useRef<CutPhase>(phase)
useEffect(() => {
phaseRef.current = phase
}, [phase])
const durationSeconds = range.endSeconds - range.startSeconds
// Opening resets the form to the deterministic fallback and asks the server
// for the AI suggestion; the fallback is already on screen, so a slow or
// unavailable provider never blocks the fields. A running cut resumes its
// progress list instead of resetting (closing while it runs is allowed).
useEffect(() => {
if (!open || phaseRef.current === 'running') return
titleDirty.current = false
descriptionDirty.current = false
setPhase('form')
setCutId(null)
setStep(null)
setError(null)
setSource(null)
setTitle(fallback.title)
setDescription(fallback.description)
const controller = new AbortController()
void (async () => {
try {
const { ok, payload } = await postCampaignJson<SpeechCutSuggestionResponse>(
SUGGESTION_ENDPOINT,
{ speechId, startSeconds: range.startSeconds, endSeconds: range.endSeconds },
controller.signal,
)
if (!ok || payload.status !== 'success') return
setSource(payload.suggestion.source)
if (payload.suggestion.source !== 'ai') return
if (!titleDirty.current) setTitle(payload.suggestion.title)
if (!descriptionDirty.current) setDescription(payload.suggestion.description)
} catch {
// The fallback already filled the form; the suggestion is an enhancement.
}
})()
return () => controller.abort()
}, [open, speechId, range.startSeconds, range.endSeconds, fallback])
// While the cut runs, poll the row: `step` drives the honest progress list and
// a terminal status closes the dialog into the result card or the error state.
useEffect(() => {
if (!open || phase !== 'running' || cutId === null) return
let cancelled = false
let timer: ReturnType<typeof setTimeout> | null = null
const controller = new AbortController()
const tick = async () => {
try {
const { ok, payload } = await postCampaignJson<SpeechCutStatusResponse>(
STATUS_ENDPOINT,
{ cutId },
controller.signal,
)
if (cancelled) return
if (!ok || payload.status !== 'success') {
setPhase('failed')
setError(payload.status === 'error' ? payload.message : SPEECH_CUT_GENERIC_ERROR_MESSAGE)
return
}
const cut = payload.cut
setStep(cut.step)
if (cut.status === 'published') {
// Back to the form so reopening starts a fresh cut instead of
// replaying this one (the ref guard would otherwise keep `running`).
setPhase('form')
setCutId(null)
onPublished(cut)
onOpenChange(false)
return
}
if (cut.status === 'failed') {
setPhase('failed')
return
}
} catch {
// A transient poll failure is retried on the next tick.
}
if (!cancelled) timer = setTimeout(tick, POLL_INTERVAL_MS)
}
timer = setTimeout(tick, POLL_INTERVAL_MS)
return () => {
cancelled = true
controller.abort()
if (timer) clearTimeout(timer)
}
}, [open, phase, cutId, onPublished, onOpenChange])
const submit = async (body: SpeechCutRequest) => {
setSubmitting(true)
setError(null)
try {
const { ok, payload } = await postCampaignJson<SpeechCutSaveResponse>(SAVE_ENDPOINT, body)
if (!ok || payload.status !== 'success') {
setError(payload.status === 'error' ? payload.message : SPEECH_CUT_GENERIC_ERROR_MESSAGE)
return
}
setCutId(payload.cut.id)
setStep(payload.cut.step ?? 'resolving')
setPhase('running')
} catch {
setError(SPEECH_CUT_GENERIC_ERROR_MESSAGE)
} finally {
setSubmitting(false)
}
}
const steps = speechCutStepStates(step)
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[85dvh] overflow-y-auto sm:max-w-lg">
<DialogHeader>
<DialogTitle>
{phase === 'failed' ? 'Não foi possível cortar o trecho' : 'Cortar vídeo'}
</DialogTitle>
<DialogDescription>
{phase === 'form'
? 'O arquivo sai com o trecho exato — nada antes, nada depois.'
: phase === 'running'
? 'Isso costuma levar menos de um minuto. Não feche esta página.'
: 'Nada foi publicado — o acervo e o trecho selecionado continuam como estavam.'}
</DialogDescription>
</DialogHeader>
{phase === 'form' ? (
<div className="flex flex-col gap-4">
<div className="rounded-lg border bg-muted/40 p-3">
<div className="flex items-center justify-between gap-3">
<span className="font-mono text-sm">
{formatSpeechClock(range.startSeconds)} → {formatSpeechClock(range.endSeconds)}
</span>
<span className="text-xs text-muted-foreground">
{formatSpeechSpan(durationSeconds)} · dentro da faixa de {MIN_EXCERPT_SECONDS}–
{MAX_EXCERPT_SECONDS} s
</span>
</div>
</div>
<Field>
<FieldLabel htmlFor="speech-cut-title">Título</FieldLabel>
<Input
id="speech-cut-title"
value={title}
maxLength={SPEECH_CUT_TITLE_MAX_LENGTH}
onChange={(event) => {
titleDirty.current = true
setTitle(event.target.value)
}}
disabled={submitting}
/>
</Field>
<Field>
<FieldLabel htmlFor="speech-cut-description">Descrição</FieldLabel>
<Textarea
id="speech-cut-description"
value={description}
maxLength={SPEECH_CUT_DESCRIPTION_MAX_LENGTH}
rows={4}
className="min-h-24 resize-y"
onChange={(event) => {
descriptionDirty.current = true
setDescription(event.target.value)
}}
disabled={submitting}
/>
</Field>
<p
className={cn(
'rounded-lg border px-3 py-2 text-xs',
source === 'ai'
? 'border-primary/40 bg-primary/10 text-foreground/90'
: 'text-muted-foreground',
)}
>
{source === 'ai'
? 'Sugerido por IA — revise antes de publicar. Os dois campos são editáveis.'
: 'Título e descrição podem ser editados antes de publicar.'}
</p>
{error ? (
<Alert variant="destructive" className="py-2">
<AlertDescription className="text-xs">{error}</AlertDescription>
</Alert>
) : null}
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<Button
type="button"
variant="outline"
className="min-h-10"
disabled={submitting}
onClick={() => onOpenChange(false)}
>
Cancelar
</Button>
<Button
type="button"
className="min-h-10"
disabled={submitting || title.trim() === '' || description.trim() === ''}
onClick={() =>
void submit({
speechId,
startSeconds: range.startSeconds,
endSeconds: range.endSeconds,
title: title.trim(),
description: description.trim(),
})
}
>
{submitting ? (
<Spinner data-icon="inline-start" aria-hidden="true" />
) : (
<ScissorsIcon data-icon="inline-start" aria-hidden="true" />
)}
Cortar e publicar
</Button>
</div>
<p className="text-xs text-muted-foreground">
Nada é publicado antes de confirmar. No fim, o corte ganha o link{' '}
<span className="font-mono">/corte/&lt;id&gt;</span> e o arquivo fica na biblioteca de
cortes.
</p>
</div>
) : null}
{phase === 'running' ? (
<div className="flex flex-col gap-4">
<div className="flex items-center gap-3">
<Spinner className="size-5 text-muted-foreground" aria-label="Preparando o corte" />
<p className="text-sm font-medium">Preparando o corte…</p>
</div>
<ol className="space-y-2">
{steps.map((entry) => (
<li
key={entry.step}
data-step-state={entry.state}
className={cn(
'flex items-center gap-3 rounded-lg border px-3 py-2.5',
entry.state === 'current' && 'border-primary/40 bg-primary/10',
)}
>
<StepMark state={entry.state} />
<span
className={cn(
'text-sm',
entry.state === 'current' ? 'text-foreground' : 'text-muted-foreground',
)}
>
{entry.label}
</span>
<span className="ml-auto text-xs text-muted-foreground">
{entry.state === 'done'
? 'Concluído'
: entry.state === 'current'
? 'Em andamento'
: 'Na fila'}
</span>
</li>
))}
</ol>
<p className="border-t pt-3 text-xs text-muted-foreground">
Nada foi publicado ainda. Se algo falhar, você volta ao acervo sem corte — nada fica
no ar pela metade.
</p>
</div>
) : null}
{phase === 'failed' ? (
<div className="flex flex-col gap-4">
<div className="flex items-start gap-3">
<AlertCircleIcon
className="mt-0.5 size-5 shrink-0 text-muted-foreground"
aria-hidden="true"
/>
<p className="text-sm text-muted-foreground">
{error ??
'A Câmara não está entregando o vídeo desta fala agora. Tente novamente em alguns minutos.'}
</p>
</div>
<div className="flex flex-wrap gap-2">
<Button
type="button"
className="min-h-10"
disabled={submitting || cutId === null}
onClick={() => {
if (cutId === null) return
void submit({ retryOf: cutId })
}}
>
{submitting ? <Spinner data-icon="inline-start" aria-hidden="true" /> : null}
Tentar novamente
</Button>
<Button
type="button"
variant="outline"
className="min-h-10"
onClick={() => onOpenChange(false)}
>
Voltar ao acervo
</Button>
</div>
<p className="text-xs text-muted-foreground">
O mesmo trecho continua selecionado; tentar de novo não cria corte duplicado.
</p>
</div>
) : null}
</DialogContent>
</Dialog>
)
}

View file

@ -0,0 +1,89 @@
'use client'
import { CheckCircle2Icon, ExternalLinkIcon } from 'lucide-react'
import Link from 'next/link'
import { useEffect, useState } from 'react'
import { SpeechCutShareActions } from '@/components/SpeechCutShareActions'
import type { SpeechCutViewModel } from '@/lib/speechCut'
/**
* C167 — the acervo result of a published cut: the public link with the share
* kit (copy/WhatsApp/download), an Open Graph preview and the pointer to the
* cut library (C168). Lives in session: after a reload the cut is reached
* through its public link or, later, the library.
*/
export const SpeechCutResultCard = ({ cut }: { cut: SpeechCutViewModel }) => {
const [absoluteUrl, setAbsoluteUrl] = useState('')
useEffect(() => {
setAbsoluteUrl(new URL(cut.publicPath, window.location.origin).toString())
}, [cut.publicPath])
const host = absoluteUrl ? new URL(absoluteUrl).host : null
return (
<section
data-slot="speech-cut-result"
className="mt-4 rounded-xl border bg-card p-4 text-card-foreground sm:p-5"
>
<div className="flex items-center gap-3">
<CheckCircle2Icon className="size-5 shrink-0 text-emerald-600" aria-hidden="true" />
<div>
<p className="text-sm font-medium">Corte publicado</p>
<p className="text-xs text-muted-foreground">
O arquivo do trecho está salvo e a página pública já está no ar.
</p>
</div>
</div>
<p className="mt-4 text-xs tracking-wide text-muted-foreground uppercase">
Link público (funciona no WhatsApp)
</p>
<div className="mt-1.5 flex items-stretch gap-2">
<div className="flex min-h-10 min-w-0 flex-1 items-center rounded-lg border bg-muted/40 px-3 font-mono text-xs sm:text-sm">
<span className="truncate">{absoluteUrl || cut.publicPath}</span>
</div>
</div>
<SpeechCutShareActions
className="mt-3"
url={absoluteUrl || cut.publicPath}
title={cut.title}
downloadUrl={cut.mediaUrl}
downloadFilename={cut.mediaFilename}
/>
<Link
href={cut.publicPath}
target="_blank"
className="mt-3 inline-flex items-center gap-1 text-sm text-muted-foreground underline underline-offset-4"
>
Abrir página do corte
<ExternalLinkIcon className="size-3.5" aria-hidden="true" />
</Link>
<div className="mt-4 flex flex-wrap items-start gap-4 border-t pt-3">
{cut.youtubeVideoId ? (
// eslint-disable-next-line @next/next/no-img-element -- YouTube cover is an external OG image.
<img
src={`https://i.ytimg.com/vi/${cut.youtubeVideoId}/hqdefault.jpg`}
alt=""
className="h-20 w-32 rounded-lg border object-cover"
/>
) : null}
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">{cut.title}</p>
<p className="mt-1 line-clamp-2 text-xs text-muted-foreground">{cut.description}</p>
{host ? (
<p className="mt-1 text-[10px] tracking-wide text-muted-foreground uppercase">{host}</p>
) : null}
</div>
</div>
<p className="mt-3 border-t pt-3 text-xs text-muted-foreground">
O arquivo também fica disponível na biblioteca de cortes — é lá que se despublica o corte.
</p>
</section>
)
}

View file

@ -4,12 +4,15 @@ import { DownloadIcon, ExternalLinkIcon, FilmIcon, PlayIcon, ScissorsIcon } from
import { useEffect, useRef, useState, type ReactNode } from 'react'
import type { SpeechVodResolveResponse } from '@/app/(campaign)/campanha/(app)/comunicacao/acervo/resolver-vod/types'
import { SpeechCutDialog } from '@/components/campaign/speech/SpeechCutDialog'
import { SpeechCutResultCard } from '@/components/campaign/speech/SpeechCutResultCard'
import { SpeechExcerptControls } from '@/components/campaign/speech/SpeechExcerptControls'
import { SpeechExcerptShare } from '@/components/campaign/speech/SpeechExcerptShare'
import { SpeechHighlightParts } from '@/components/campaign/speech/SpeechHighlightParts'
import { Button } from '@/components/ui/button'
import { Spinner } from '@/components/ui/Spinner'
import { postCampaignJson } from '@/lib/campaignJsonRequest'
import type { SpeechCutViewModel } from '@/lib/speechCut'
import {
extendRangeToSegment,
initialExcerptRange,
@ -66,6 +69,8 @@ type SpeechDetailPlayerProps = {
speechType: string | null
/** C166 — day label (`dd/mm/aaaa`) for the share message. */
speechDateLabel: string
/** C167 — official summary, the deterministic fallback for the cut metadata. */
speechSummary: string | null
}
const StatusPanel = ({
@ -117,6 +122,7 @@ export const SpeechDetailPlayer = ({
durationSeconds,
speechType,
speechDateLabel,
speechSummary,
}: SpeechDetailPlayerProps) => {
const videoRef = useRef<HTMLVideoElement>(null)
const [resolution, setResolution] = useState<ResolutionState>({ kind: 'idle' })
@ -124,6 +130,10 @@ export const SpeechDetailPlayer = ({
// C166: one state for the explicit selection mode — a non-null range means
// the mode is on, so "selecting" cannot drift from "has a range".
const [selection, setSelection] = useState<ExcerptRange | null>(null)
// C167: the dialog opens from the current selection; the published cut is kept
// in session so the share kit stays visible after the dialog closes.
const [cutDialogOpen, setCutDialogOpen] = useState(false)
const [publishedCut, setPublishedCut] = useState<SpeechCutViewModel | null>(null)
const [youtubeStart, setYoutubeStart] = useState<number | null>(() =>
youtubeVideoId && youtubeOffsetSeconds !== null
? youtubeOffsetSeconds + (initialSeconds ?? 0)
@ -136,6 +146,7 @@ export const SpeechDetailPlayer = ({
const selectionAvailable = selectionDuration !== null && selectionDuration >= MIN_EXCERPT_SECONDS
const selecting = selection !== null
const showExcerptShare = selecting && Boolean(youtubeVideoId)
const showCutAction = selecting && vodResolvable
useEffect(() => {
const video = videoRef.current
@ -370,6 +381,17 @@ export const SpeechDetailPlayer = ({
Selecionar trecho
</Button>
) : null}
{showCutAction && selection ? (
<Button
type="button"
className="min-h-10"
data-slot="speech-cut-open"
onClick={() => setCutDialogOpen(true)}
>
<ScissorsIcon data-icon="inline-start" aria-hidden="true" />
Cortar vídeo
</Button>
) : null}
{showExcerptShare && selection && youtubeVideoId ? (
<SpeechExcerptShare
videoId={youtubeVideoId}
@ -382,9 +404,10 @@ export const SpeechDetailPlayer = ({
) : null}
{vodResolvable ? (
<Button
// While the excerpt share is on screen it is the primary action;
// the download steps back to outline instead of splitting the CTA.
variant={showExcerptShare ? 'outline' : 'default'}
// While the excerpt share or the cut action is on screen it is the
// primary action; the download steps back to outline instead of
// splitting the CTA.
variant={showExcerptShare || showCutAction ? 'outline' : 'default'}
className="min-h-10"
disabled={resolving}
aria-busy={resolving || undefined}
@ -416,6 +439,21 @@ export const SpeechDetailPlayer = ({
{inlineNotice}
{publishedCut ? <SpeechCutResultCard cut={publishedCut} /> : null}
{selection ? (
<SpeechCutDialog
open={cutDialogOpen}
onOpenChange={setCutDialogOpen}
speechId={speechId}
speechType={speechType}
dateLabel={speechDateLabel}
summary={speechSummary}
range={selection}
onPublished={setPublishedCut}
/>
) : null}
{!youtubeVideoId && selectionAvailable ? (
<div className="mt-3 rounded-lg border bg-muted/40 px-4 py-3">
<p className="text-xs font-medium text-foreground/90">

View file

@ -1,7 +1,7 @@
'use client'
import { CheckIcon, CopyIcon, Share2Icon } from 'lucide-react'
import { useEffect, useState } from 'react'
import { useState } from 'react'
import { WhatsAppIcon } from '@/components/socialIcons'
import { Button } from '@/components/ui/button'
@ -14,24 +14,9 @@ import {
SheetTitle,
} from '@/components/ui/Sheet'
import { useCoarsePointer } from '@/lib/campaignCoarsePointer'
import { copyFeedbackLabels, copyFeedbackLiveMessages, useCopyFeedback } from '@/lib/copyFeedback'
import { buildSpeechExcerptShare } from '@/lib/speechShare'
const COPY_FEEDBACK_RESET_MS = 2000
type CopyFeedback = 'idle' | 'copied' | 'error'
const COPY_LABELS: Record<CopyFeedback, string> = {
idle: 'Copiar link',
copied: 'Link copiado',
error: 'Não foi possível copiar',
}
const COPY_LIVE_MESSAGES: Record<CopyFeedback, string> = {
idle: '',
copied: 'Link copiado.',
error: 'Não foi possível copiar o link.',
}
type SpeechExcerptShareProps = {
videoId: string
/** Session offset in seconds; null makes the link open at the session start. */
@ -63,13 +48,7 @@ export const SpeechExcerptShare = ({
}: SpeechExcerptShareProps) => {
const isCoarsePointer = useCoarsePointer()
const [open, setOpen] = useState(false)
const [copyFeedback, setCopyFeedback] = useState<CopyFeedback>('idle')
useEffect(() => {
if (copyFeedback === 'idle') return
const reset = setTimeout(() => setCopyFeedback('idle'), COPY_FEEDBACK_RESET_MS)
return () => clearTimeout(reset)
}, [copyFeedback])
const { feedback, copy } = useCopyFeedback()
const share = buildSpeechExcerptShare({
videoId,
@ -80,15 +59,6 @@ export const SpeechExcerptShare = ({
dateLabel,
})
const copyMessage = async () => {
try {
await navigator.clipboard.writeText(share.message)
setCopyFeedback('copied')
} catch {
setCopyFeedback('error')
}
}
const trigger = (onClick?: () => void, expanded?: boolean) => (
<Button
type="button"
@ -104,13 +74,13 @@ export const SpeechExcerptShare = ({
const options = (
<div data-slot="speech-excerpt-share-options">
<button type="button" onClick={copyMessage} className={OPTION_CLASS}>
{copyFeedback === 'copied' ? (
<button type="button" onClick={() => void copy(share.message)} className={OPTION_CLASS}>
{feedback === 'copied' ? (
<CheckIcon className="size-4 text-emerald-600" aria-hidden="true" />
) : (
<CopyIcon className="size-4" aria-hidden="true" />
)}
{COPY_LABELS[copyFeedback]}
{copyFeedbackLabels[feedback]}
</button>
<a
href={share.whatsAppUrl}
@ -131,7 +101,7 @@ export const SpeechExcerptShare = ({
</p>
) : null}
<span aria-live="polite" className="sr-only">
{COPY_LIVE_MESSAGES[copyFeedback]}
{copyFeedbackLiveMessages[feedback]}
</span>
</div>
)

46
src/lib/copyFeedback.ts Normal file
View file

@ -0,0 +1,46 @@
'use client'
import { useEffect, useState } from 'react'
const COPY_FEEDBACK_RESET_MS = 2000
export type CopyFeedback = 'idle' | 'copied' | 'error'
export const copyFeedbackLabels: Record<CopyFeedback, string> = {
idle: 'Copiar link',
copied: 'Link copiado',
error: 'Não foi possível copiar',
}
export const copyFeedbackLiveMessages: Record<CopyFeedback, string> = {
idle: '',
copied: 'Link copiado.',
error: 'Não foi possível copiar o link.',
}
/**
* The one copy-to-clipboard feedback contract of the share controls (C166 share
* sheet, C167 cut share kit): the label per state, the screen-reader live
* message and the auto-reset. `copy` never throws — a denied clipboard is the
* `error` state.
*/
export const useCopyFeedback = () => {
const [feedback, setFeedback] = useState<CopyFeedback>('idle')
useEffect(() => {
if (feedback === 'idle') return
const reset = setTimeout(() => setFeedback('idle'), COPY_FEEDBACK_RESET_MS)
return () => clearTimeout(reset)
}, [feedback])
const copy = async (text: string): Promise<void> => {
try {
await navigator.clipboard.writeText(text)
setFeedback('copied')
} catch {
setFeedback('error')
}
}
return { feedback, copy }
}

View file

@ -0,0 +1,53 @@
import { z } from 'zod'
import { positiveRelationshipId } from '@/lib/schemas/primitives'
import { SPEECH_VOD_FORBIDDEN_MESSAGE } from '@/lib/schemas/speechVod'
import { SPEECH_CUT_DESCRIPTION_MAX_LENGTH, SPEECH_CUT_TITLE_MAX_LENGTH } from '@/lib/speechCut'
/** The acervo gate refused the actor — one literal shared with C162/C166. */
export const SPEECH_CUT_FORBIDDEN_MESSAGE = SPEECH_VOD_FORBIDDEN_MESSAGE
/** The speech does not exist (or the actor cannot read it). */
export const SPEECH_CUT_SPEECH_NOT_FOUND_MESSAGE = 'Fala não encontrada.'
/** The cut does not exist (or the actor cannot read it). */
export const SPEECH_CUT_NOT_FOUND_MESSAGE = 'Corte não encontrado.'
/** Selection outside the C166 bounds (5–180 s) or outside the speech duration. */
export const SPEECH_CUT_INVALID_RANGE_MESSAGE = 'Selecione um trecho de 5 a 180 segundos.'
/** Retry only applies to a failed cut — a published one is not re-cut. */
export const SPEECH_CUT_RETRY_NOT_FAILED_MESSAGE =
'Só é possível tentar novamente um corte que falhou.'
/** Transport/ffmpeg/storage failure (or anything unmapped). */
export const SPEECH_CUT_GENERIC_ERROR_MESSAGE =
'Não foi possível preparar o corte. Verifique seu acesso e tente novamente.'
const excerptBoundarySeconds = z.number().int().min(0).max(86_400)
/** Create one cut from a C166 selection, or retry the failed row (`retryOf`). */
export const speechCutRequestSchema = z.union([
z.object({ retryOf: positiveRelationshipId }),
z.object({
speechId: positiveRelationshipId,
startSeconds: excerptBoundarySeconds,
endSeconds: excerptBoundarySeconds,
title: z.string().trim().min(1).max(SPEECH_CUT_TITLE_MAX_LENGTH),
description: z.string().trim().min(1).max(SPEECH_CUT_DESCRIPTION_MAX_LENGTH),
}),
])
export type SpeechCutRequest = z.infer<typeof speechCutRequestSchema>
/** Poll one running cut. */
export const speechCutStatusRequestSchema = z.object({
cutId: positiveRelationshipId,
})
/** AI title/description suggestion for the picked window. */
export const speechCutSuggestionRequestSchema = z.object({
speechId: positiveRelationshipId,
startSeconds: excerptBoundarySeconds,
endSeconds: excerptBoundarySeconds,
})

View file

@ -25,3 +25,14 @@ export const formatSpeechSpan = (seconds: number): string => {
if (minutes > 0) return `${minutes}min${pad(secs)}s`
return `${secs}s`
}
/**
* Day-only label (`dd/mm/aaaa`) of a Câmara wall-clock `speechAt`. Slicing the
* string keeps the local reading, the same reason the clock never uses `Date`.
*/
export const formatSpeechDate = (speechAt: string): string => {
const match = /^(\d{4})-(\d{2})-(\d{2})/.exec(speechAt)
if (!match) return speechAt
const [, year, month, day] = match
return `${day}/${month}/${year}`
}

235
src/lib/speechCut.ts Normal file
View file

@ -0,0 +1,235 @@
/**
* C167 — pure rules of a speech cut: the status/step vocabulary, the
* deterministic metadata fallback, the ffmpeg command for the exact
* [start, end] MP4 and the public view model. No I/O and no `server-only`:
* the dialog (client) and the job (server) share this module.
*/
import { buildWhatsAppTextShareUrl } from '@/lib/phone'
import { formatSpeechSpan } from '@/lib/speechClock'
import { parseYoutubeVideoId } from '@/lib/speechVod'
export const SPEECH_CUT_STATUSES = ['processing', 'published', 'unpublished', 'failed'] as const
export type SpeechCutStatus = (typeof SPEECH_CUT_STATUSES)[number]
/** One owner for the persisted field limits (collection, schema and dialog). */
export const SPEECH_CUT_TITLE_MAX_LENGTH = 200
export const SPEECH_CUT_DESCRIPTION_MAX_LENGTH = 2000
export const speechCutStatusLabels: Record<SpeechCutStatus, string> = {
processing: 'Processando',
published: 'Publicado',
unpublished: 'Despublicado',
failed: 'Falhou',
}
const isSpeechCutStatus = (value: unknown): value is SpeechCutStatus =>
SPEECH_CUT_STATUSES.includes(value as SpeechCutStatus)
export const SPEECH_CUT_STEPS = ['resolving', 'cutting', 'metadata', 'publishing'] as const
export type SpeechCutStep = (typeof SPEECH_CUT_STEPS)[number]
/** The four honest progress steps the dialog shows (draft scene 2). */
export const speechCutStepLabels: Record<SpeechCutStep, string> = {
resolving: 'Localizando o trecho na Câmara',
cutting: 'Cortando o trecho',
metadata: 'Gerando título e descrição',
publishing: 'Publicando a página do corte',
}
const isSpeechCutStep = (value: unknown): value is SpeechCutStep =>
SPEECH_CUT_STEPS.includes(value as SpeechCutStep)
type SpeechCutStepState = {
step: SpeechCutStep
label: string
state: 'done' | 'current' | 'queued'
}
/** Step list of a running cut: everything before `current` is done, the rest queued. */
export const speechCutStepStates = (current: SpeechCutStep | null): SpeechCutStepState[] => {
const currentIndex = current ? SPEECH_CUT_STEPS.indexOf(current) : -1
return SPEECH_CUT_STEPS.map((step, index) => ({
step,
label: speechCutStepLabels[step],
state: index < currentIndex ? 'done' : index === currentIndex ? 'current' : 'queued',
}))
}
const FALLBACK_SPEECH_TYPE = 'fala'
/** Trim + clip used by every metadata path (fallback and AI output). */
export const clipSpeechCutText = (value: string, maximum: number): string =>
value.slice(0, maximum).trim()
/**
* Deterministic fallback for title/description when the AI is unavailable: the
* title always names the excerpt (`Trecho de <tipo> — <data>`) and the
* description prefers the official summary. Never empty — both fields are
* required on the row and on the public page.
*/
export const buildSpeechCutFallbackMetadata = ({
speechType,
dateLabel,
summary,
}: {
speechType: string | null
dateLabel: string
summary: string | null
}): { title: string; description: string } => {
const type = speechType?.trim() || FALLBACK_SPEECH_TYPE
return {
title: clipSpeechCutText(`Trecho de ${type} — ${dateLabel}`, SPEECH_CUT_TITLE_MAX_LENGTH),
description: clipSpeechCutText(
summary?.trim() || `Trecho de ${type} de ${dateLabel}, na Câmara dos Deputados.`,
SPEECH_CUT_DESCRIPTION_MAX_LENGTH,
),
}
}
type SpeechCutFfmpegInput = {
inputPath: string
outputPath: string
startSeconds: number
endSeconds: number
}
/**
* Exact-cut command: `-ss` before `-i` seeks fast and the re-encode (no
* `-c copy`) starts the output exactly at the picked second instead of the
* previous keyframe. `-t` carries the duration so a timestamp offset in the
* source cannot shift the end; `+faststart` lets the proxied MP4 play
* progressively. Args are returned as an array — never a shell string.
*/
export const buildSpeechCutFfmpegArgs = ({
inputPath,
outputPath,
startSeconds,
endSeconds,
}: SpeechCutFfmpegInput): string[] => {
const start = Math.max(0, Math.round(startSeconds))
const duration = Math.max(0, Math.round(endSeconds) - start)
return [
'-nostdin',
'-hide_banner',
'-y',
'-ss',
String(start),
'-i',
inputPath,
'-t',
String(duration),
'-map',
'0:v:0',
'-map',
'0:a?',
'-c:v',
'libx264',
'-preset',
'veryfast',
'-crf',
'20',
'-pix_fmt',
'yuv420p',
'-c:a',
'aac',
'-b:a',
'128k',
'-movflags',
'+faststart',
outputPath,
]
}
/** Public, unlisted page of a cut — the numeric id survives a title edit. */
export const speechCutPublicPath = (id: number): string => `/corte/${id}`
type SpeechCutShare = {
url: string
message: string
whatsAppUrl: string
}
/** Share payload of a published cut: the title followed by the public link. */
export const buildSpeechCutShare = ({
title,
url,
}: {
title: string
url: string
}): SpeechCutShare => {
const message = `${title.trim()} ${url}`
return { url, message, whatsAppUrl: buildWhatsAppTextShareUrl(message) }
}
type CutMediaRecord = { url?: string | null; filename?: string | null }
type CutSpeechRecord = { youtubeUrl?: string | null }
type CutDurationRecord = { durationSeconds?: number | null }
export type SpeechCutRecordForView = CutDurationRecord & {
id: number
status: string
step?: string | null
title: string
description: string
startSeconds: number
endSeconds: number
media?: number | CutMediaRecord | null
speech?: number | CutSpeechRecord | null
publishedAt?: string | null
updatedAt?: string | null
}
export type SpeechCutViewModel = {
id: number
status: SpeechCutStatus
step: SpeechCutStep | null
title: string
description: string
startSeconds: number
endSeconds: number
/** Stored span, stable even if the speech row changes; falls back to end − start. */
durationSeconds: number
durationLabel: string
publicPath: string
mediaUrl: string | null
mediaFilename: string | null
youtubeVideoId: string | null
publishedAt: string | null
}
const mediaOf = (media: SpeechCutRecordForView['media']): CutMediaRecord | null =>
typeof media === 'object' && media !== null ? media : null
const youtubeUrlOf = (speech: SpeechCutRecordForView['speech']): string | null =>
typeof speech === 'object' && speech !== null ? (speech.youtubeUrl ?? null) : 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.
*/
export const toSpeechCutViewModel = (record: SpeechCutRecordForView): SpeechCutViewModel => {
const media = mediaOf(record.media)
const status = isSpeechCutStatus(record.status) ? record.status : 'failed'
const step = isSpeechCutStep(record.step) ? record.step : null
const durationSeconds =
typeof record.durationSeconds === 'number' && Number.isFinite(record.durationSeconds)
? Math.max(0, Math.round(record.durationSeconds))
: Math.max(0, Math.round(record.endSeconds) - Math.round(record.startSeconds))
return {
id: record.id,
status,
step,
title: record.title,
description: record.description,
startSeconds: record.startSeconds,
endSeconds: record.endSeconds,
durationSeconds,
durationLabel: formatSpeechSpan(durationSeconds),
publicPath: speechCutPublicPath(record.id),
mediaUrl: media?.url ?? null,
mediaFilename: media?.filename ?? null,
youtubeVideoId: parseYoutubeVideoId(youtubeUrlOf(record.speech)),
publishedAt: record.publishedAt ?? null,
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,49 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
CREATE TYPE "public"."enum_speech_cut_status" AS ENUM('processing', 'published', 'unpublished', 'failed');
CREATE TYPE "public"."enum_speech_cut_step" AS ENUM('resolving', 'cutting', 'metadata', 'publishing');
CREATE TABLE "speech_cut" (
"id" serial PRIMARY KEY NOT NULL,
"speech_id" integer,
"start_seconds" numeric NOT NULL,
"end_seconds" numeric NOT NULL,
"duration_seconds" numeric,
"title" varchar NOT NULL,
"description" varchar NOT NULL,
"status" "enum_speech_cut_status" DEFAULT 'processing' NOT NULL,
"step" "enum_speech_cut_step",
"media_id" integer,
"error" varchar,
"published_at" timestamp(3) with time zone,
"created_by_id" integer,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
);
ALTER TABLE "payload_locked_documents_rels" ADD COLUMN "speech_cut_id" integer;
ALTER TABLE "speech_cut" ADD CONSTRAINT "speech_cut_speech_id_speech_id_fk" FOREIGN KEY ("speech_id") REFERENCES "public"."speech"("id") ON DELETE set null ON UPDATE no action;
ALTER TABLE "speech_cut" ADD CONSTRAINT "speech_cut_media_id_media_id_fk" FOREIGN KEY ("media_id") REFERENCES "public"."media"("id") ON DELETE set null ON UPDATE no action;
ALTER TABLE "speech_cut" ADD CONSTRAINT "speech_cut_created_by_id_campaign_user_id_fk" FOREIGN KEY ("created_by_id") REFERENCES "public"."campaign_user"("id") ON DELETE set null ON UPDATE no action;
CREATE INDEX "speech_cut_speech_idx" ON "speech_cut" USING btree ("speech_id");
CREATE INDEX "speech_cut_status_idx" ON "speech_cut" USING btree ("status");
CREATE INDEX "speech_cut_media_idx" ON "speech_cut" USING btree ("media_id");
CREATE INDEX "speech_cut_created_by_idx" ON "speech_cut" USING btree ("created_by_id");
CREATE INDEX "speech_cut_updated_at_idx" ON "speech_cut" USING btree ("updated_at");
CREATE INDEX "speech_cut_created_at_idx" ON "speech_cut" USING btree ("created_at");
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_speech_cut_fk" FOREIGN KEY ("speech_cut_id") REFERENCES "public"."speech_cut"("id") ON DELETE cascade ON UPDATE no action;
CREATE INDEX "payload_locked_documents_rels_speech_cut_id_idx" ON "payload_locked_documents_rels" USING btree ("speech_cut_id");`)
}
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "speech_cut" DISABLE ROW LEVEL SECURITY;
DROP TABLE "speech_cut" CASCADE;
ALTER TABLE "payload_locked_documents_rels" DROP CONSTRAINT "payload_locked_documents_rels_speech_cut_fk";
DROP INDEX "payload_locked_documents_rels_speech_cut_id_idx";
ALTER TABLE "payload_locked_documents_rels" DROP COLUMN "speech_cut_id";
DROP TYPE "public"."enum_speech_cut_status";
DROP TYPE "public"."enum_speech_cut_step";`)
}

View file

@ -68,8 +68,9 @@ import * as migration_20260912_052226_add_google_calendar_oauth_connection from
import * as migration_20260912_224719_add_activity_public_event from './20260912_224719_add_activity_public_event';
import * as migration_20260913_001112_add_speech_catalog from './20260913_001112_add_speech_catalog';
import * as migration_20260913_001200_add_speech_segment_trgm_index from './20260913_001200_add_speech_segment_trgm_index';
import * as migration_20260913_150000_backfill_speech_search_text_trgm_index from './20260913_150000_backfill_speech_search_text_trgm_index';
import * as migration_20260913_145820_add_speech_search_text from './20260913_145820_add_speech_search_text';
import * as migration_20260913_150000_backfill_speech_search_text_trgm_index from './20260913_150000_backfill_speech_search_text_trgm_index';
import * as migration_20260915_122356_add_speech_cut from './20260915_122356_add_speech_cut';
export const migrations = [
{
@ -425,11 +426,16 @@ export const migrations = [
{
up: migration_20260913_145820_add_speech_search_text.up,
down: migration_20260913_145820_add_speech_search_text.down,
name: '20260913_145820_add_speech_search_text'
name: '20260913_145820_add_speech_search_text',
},
{
up: migration_20260913_150000_backfill_speech_search_text_trgm_index.up,
down: migration_20260913_150000_backfill_speech_search_text_trgm_index.down,
name: '20260913_150000_backfill_speech_search_text_trgm_index',
},
{
up: migration_20260915_122356_add_speech_cut.up,
down: migration_20260915_122356_add_speech_cut.down,
name: '20260915_122356_add_speech_cut'
},
];

View file

@ -88,6 +88,7 @@ export interface Config {
activity: Activity;
speech: Speech;
speechSegment: SpeechSegment;
speechCut: SpeechCut;
calendarFeed: CalendarFeed;
googleCalendarSync: GoogleCalendarSync;
electionTally: ElectionTally;
@ -131,6 +132,7 @@ export interface Config {
activity: ActivitySelect<false> | ActivitySelect<true>;
speech: SpeechSelect<false> | SpeechSelect<true>;
speechSegment: SpeechSegmentSelect<false> | SpeechSegmentSelect<true>;
speechCut: SpeechCutSelect<false> | SpeechCutSelect<true>;
calendarFeed: CalendarFeedSelect<false> | CalendarFeedSelect<true>;
googleCalendarSync: GoogleCalendarSyncSelect<false> | GoogleCalendarSyncSelect<true>;
electionTally: ElectionTallySelect<false> | ElectionTallySelect<true>;
@ -1016,6 +1018,47 @@ export interface SpeechSegment {
updatedAt: string;
createdAt: string;
}
/**
* Cortes do acervo de falas publicados em /corte/<id>. Dados e vídeos da Câmara dos Deputados (CC BY 4.0).
*
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "speechCut".
*/
export interface SpeechCut {
id: number;
/**
* Fala de origem do corte. Preenchida pelo fluxo; fica vazia se a fala for excluída (o link público continua válido).
*/
speech?: (number | null) | Speech;
startSeconds: number;
endSeconds: number;
/**
* Derivada do trecho escolhido.
*/
durationSeconds?: number | null;
title: string;
description: string;
/**
* Despublicar tira o corte do ar na hora (kill switch).
*/
status: 'processing' | 'published' | 'unpublished' | 'failed';
/**
* Progresso honesto do corte em andamento.
*/
step?: ('resolving' | 'cutting' | 'metadata' | 'publishing') | null;
/**
* O arquivo que a página pública toca e baixa.
*/
media?: (number | null) | Media;
/**
* Motivo interno da falha; nunca vai ao público.
*/
error?: string | null;
publishedAt?: string | null;
createdBy?: (number | null) | CampaignUser;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "calendarFeed".
@ -1561,6 +1604,10 @@ export interface PayloadLockedDocument {
relationTo: 'speechSegment';
value: number | SpeechSegment;
} | null)
| ({
relationTo: 'speechCut';
value: number | SpeechCut;
} | null)
| ({
relationTo: 'calendarFeed';
value: number | CalendarFeed;
@ -2120,6 +2167,26 @@ export interface SpeechSegmentSelect<T extends boolean = true> {
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "speechCut_select".
*/
export interface SpeechCutSelect<T extends boolean = true> {
speech?: T;
startSeconds?: T;
endSeconds?: T;
durationSeconds?: T;
title?: T;
description?: T;
status?: T;
step?: T;
media?: T;
error?: T;
publishedAt?: T;
createdBy?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "calendarFeed_select".
@ -2852,6 +2919,7 @@ export interface TaskCreateCollectionExport {
| 'activity'
| 'speech'
| 'speechSegment'
| 'speechCut'
| 'calendarFeed'
| 'googleCalendarSync'
| 'electionTally'

View file

@ -34,6 +34,7 @@ import { Post } from './collections/Post'
import { PushSubscription } from './collections/PushSubscription'
import { Signature } from './collections/Signature'
import { Speech } from './collections/Speech'
import { SpeechCut } from './collections/SpeechCut'
import { SpeechSegment } from './collections/SpeechSegment'
import { StateDeputy } from './collections/StateDeputy'
import { Subscription } from './collections/Subscription'
@ -115,6 +116,7 @@ export default buildConfig({
Activity,
Speech,
SpeechSegment,
SpeechCut,
CalendarFeed,
GoogleCalendarSync,
ElectionTally,

View file

@ -29,3 +29,19 @@ export const canUpdateSpeech: Access = async ({ req }) => {
return isCampaignUnrestricted(await getFreshCampaignUser(req))
}
/**
* C167 — a cut is public by link when `published`; every other status (and the
* internal `error`/`step`) stays behind the acervo gate. Returning a `where`
* for the anonymous reader keeps REST/GraphQL from listing drafts, while the
* public page reads with the default override and filters `published` itself,
* the same contract as `Petition.enabled`.
*/
export const canReadSpeechCut: Access = async ({ req }) => {
if (isPayloadAdmin(req.user)) return true
const currentUser = await getFreshCampaignUser(req)
return currentUser && canReadSpeechCatalog(currentUser.role)
? true
: { status: { equals: 'published' } }
}

View file

@ -169,4 +169,4 @@ export {
canUpdateCalendarFeed,
} from '@/utilities/access/calendarFeeds'
export { canReadSpeech, canUpdateSpeech } from '@/utilities/access/speeches'
export { canReadSpeech, canReadSpeechCut, canUpdateSpeech } from '@/utilities/access/speeches'

View file

@ -0,0 +1,277 @@
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 { formatSpeechDate } from '@/lib/speechClock'
import {
buildSpeechCutFallbackMetadata,
buildSpeechCutFfmpegArgs,
type SpeechCutStep,
} from '@/lib/speechCut'
import { CAMARA_USER_AGENT, speechVodCoordinates } from '@/lib/speechVod'
import type { SpeechCut } from '@/payload-types'
import {
withPayloadTransaction,
type PayloadTransactionRequest,
} from '@/utilities/payloadTransaction'
import { resolveSpeechVod } from '@/utilities/speech/speechVodResolver'
/**
* C167 — the cut job: resolve the Câmara VOD, cut the exact [start, end] with
* ffmpeg, store the MP4 as `media` and publish the `speechCut` row. Runs after
* the create response (`speechCutScheduler`) so no request waits on ffmpeg;
* 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.'
// 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
// behind the acervo gate and the pipeline owns its state. Keeping the writes in
// these helpers keeps the bypass deliberate (and documented for the guard).
const loadCutSystem = (payload: Payload, cutId: number) =>
payload.findByID({
collection: 'speechCut',
id: cutId,
depth: 1,
// Intentional admin bypass: the job reads the row it owns.
overrideAccess: true,
})
const updateCutSystem = (
payload: Payload,
cutId: number,
data: Partial<SpeechCut>,
req?: PayloadTransactionRequest,
) =>
payload.update({
collection: 'speechCut',
id: cutId,
data,
// Intentional admin bypass: the pipeline owns the row state.
overrideAccess: true,
...(req ? { req } : {}),
})
const createMediaSystem = (
payload: Payload,
alt: string,
filePath: string,
req?: PayloadTransactionRequest,
) =>
payload.create({
collection: 'media',
data: { alt },
filePath,
// Intentional admin bypass: the stored file belongs to the cut row.
overrideAccess: true,
...(req ? { req } : {}),
})
const failCut = (payload: Payload, cutId: number, message: string): Promise<unknown> =>
updateCutSystem(payload, cutId, { status: 'failed', step: null, error: message })
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
* retry reuses it.
*/
export const runSpeechCutJob = async (payload: Payload, cutId: number): Promise<void> => {
let tempDir: string | null = null
try {
const cut = await loadCutSystem(payload, cutId)
if (cut.status !== 'processing') return
await updateStep(payload, cutId, '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.')
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.')
return
}
const resolution = await resolveSpeechVod(coordinates)
if (resolution.state === 'gerando') {
await failCut(payload, cutId, 'A Câmara ainda está gerando o vídeo deste trecho.')
return
}
if (resolution.state !== 'pronto') {
await failCut(payload, cutId, 'A Câmara não entregou o arquivo deste trecho.')
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.')
return
}
await updateStep(payload, cutId, '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`
const outputPath = join(tempDir, outputName)
const durationSeconds = Math.max(0, Math.round(cut.endSeconds) - Math.round(cut.startSeconds))
await downloadSource(sourceUrl, inputPath)
await runFfmpeg(
buildSpeechCutFfmpegArgs({
inputPath,
outputPath,
startSeconds: cut.startSeconds,
endSeconds: cut.endSeconds,
}),
durationSeconds,
)
await updateStep(payload, cutId, 'metadata')
const fallback = buildSpeechCutFallbackMetadata({
speechType: speech.type ?? null,
dateLabel: formatSpeechDate(speech.speechAt),
summary: speech.summary ?? null,
})
const title = cut.title?.trim() || fallback.title
const description = cut.description?.trim() || fallback.description
if (title !== cut.title || description !== cut.description) {
await updateCutSystem(payload, cutId, { title, description })
}
await updateStep(payload, cutId, 'publishing')
await withPayloadTransaction(payload, async ({ req }) => {
const media = await createMediaSystem(payload, title, outputPath, req)
await updateCutSystem(
payload,
cutId,
{
status: 'published',
media: media.id,
publishedAt: new Date().toISOString(),
step: null,
error: null,
},
req,
)
})
} catch (error) {
await failCut(payload, cutId, messageOf(error)).catch(() => undefined)
} finally {
if (tempDir) await rm(tempDir, { recursive: true, force: true }).catch(() => undefined)
}
}
/**
* Lazy reaper: a `processing` row untouched for `SPEECH_CUT_STALE_MS` was lost
* to a restart (there is no queue to ask). The conditional `where` never
* clobbers a job that just finished — intentional admin bypass, like the other
* system writes above.
*/
export const reapStaleSpeechCut = async (
payload: Payload,
cut: { id: number; status: string; updatedAt: string },
): Promise<boolean> => {
if (cut.status !== 'processing') return false
const updatedAt = Date.parse(cut.updatedAt)
if (!Number.isFinite(updatedAt) || Date.now() - updatedAt < SPEECH_CUT_STALE_MS) return false
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.' },
// Intentional admin bypass: the reaper repairs a row the system owns.
overrideAccess: true,
})
return result.docs.length > 0
}

View file

@ -0,0 +1,114 @@
import 'server-only'
import { deepSeek } from '@ai-sdk/deepseek'
import { generateObject } from 'ai'
import { z } from 'zod'
import { buildSpeechCutFallbackMetadata, clipSpeechCutText } from '@/lib/speechCut'
const SUGGESTION_TIMEOUT_MS = 6000
const MAX_EXCERPT_CHARS = 1200
const MAX_TITLE_LENGTH = 120
const MAX_DESCRIPTION_LENGTH = 600
const MAX_OUTPUT_TOKENS = 400
type SpeechCutMetadataSource = 'ai' | 'fallback'
export type SpeechCutMetadataSuggestion = {
title: string
description: string
source: SpeechCutMetadataSource
}
export type SpeechCutMetadataSegment = {
startSeconds: number
endSeconds: number
text: string
}
const suggestionSchema = z.object({
title: z
.string()
.describe('Título curto em português (no máximo 120 caracteres), sem aspas nem prefixos.'),
description: z
.string()
.describe(
'Descrição factual em português (no máximo 600 caracteres) do que o trecho diz, sem inventar fatos.',
),
})
const SYSTEM_PROMPT =
'Você prepara cortes de falas do deputado federal Jorge Solla no acervo da Câmara dos Deputados. ' +
'Receberá tipo, data, resumo oficial e a transcrição automática (ASR) de um trecho de 5 a 180 segundos. ' +
'Escreva um título curto e concreto (o assunto do trecho, não o nome do deputado) e uma descrição de uma ' +
'a duas frases, em português do Brasil, adequadas para compartilhar o vídeo no WhatsApp. ' +
'Use apenas o que está no trecho e no resumo; nunca invente números, acordos ou citações, e a transcrição ' +
'pode ter ruído.'
const excerptOf = (
segments: readonly SpeechCutMetadataSegment[],
startSeconds: number,
endSeconds: number,
): string =>
clipSpeechCutText(
segments
.filter((segment) => segment.endSeconds > startSeconds && segment.startSeconds < endSeconds)
.map((segment) => segment.text.trim())
.filter((text) => text !== '')
.join(' '),
MAX_EXCERPT_CHARS,
)
/**
* C167 — one-shot AI title/description for the picked window, on the same
* DeepSeek path as the other acervo AI (C158/B195): never throws and never
* blocks the cut. Missing key, empty excerpt, timeout or unusable output all
* resolve to the deterministic fallback.
*/
export const suggestSpeechCutMetadata = async ({
speechType,
dateLabel,
summary,
segments,
startSeconds,
endSeconds,
}: {
speechType: string | null
dateLabel: string
summary: string | null
segments: readonly SpeechCutMetadataSegment[]
startSeconds: number
endSeconds: number
}): Promise<SpeechCutMetadataSuggestion> => {
const fallback = buildSpeechCutFallbackMetadata({ speechType, dateLabel, summary })
if (!process.env.DEEPSEEK_API_KEY) return { ...fallback, source: 'fallback' }
const excerpt = excerptOf(segments, startSeconds, endSeconds)
if (excerpt === '') return { ...fallback, source: 'fallback' }
try {
const { object } = await generateObject({
model: deepSeek('deepseek-flash'),
schema: suggestionSchema,
system: SYSTEM_PROMPT,
prompt: [
`Tipo: ${speechType?.trim() || 'fala'}`,
`Data: ${dateLabel}`,
summary?.trim() ? `Resumo oficial: ${summary.trim()}` : null,
`Trecho (transcrição automática): "${excerpt}"`,
]
.filter((line): line is string => line !== null)
.join('\n'),
temperature: 0.3,
maxOutputTokens: MAX_OUTPUT_TOKENS,
abortSignal: AbortSignal.timeout(SUGGESTION_TIMEOUT_MS),
})
const title = clipSpeechCutText(object.title, MAX_TITLE_LENGTH)
const description = clipSpeechCutText(object.description, MAX_DESCRIPTION_LENGTH)
if (title === '' || description === '') return { ...fallback, source: 'fallback' }
return { title, description, source: 'ai' }
} catch {
return { ...fallback, source: 'fallback' }
}
}

View file

@ -0,0 +1,20 @@
import 'server-only'
import config from '@payload-config'
import { after } from 'next/server'
import { getPayload } from 'payload'
import { runSpeechCutJob } from '@/utilities/speech/speechCutJob'
/**
* C167 — starts the cut after the create/retry response has been sent
* (`after` from `next/server`), so the POST never waits on the Câmara download
* or ffmpeg. Kept apart from the job so `speechCutJob` stays importable in a
* plain Node test without the request lifecycle.
*/
export const startSpeechCutJobInBackground = (cutId: number): void => {
after(async () => {
const payload = await getPayload({ config })
await runSpeechCutJob(payload, cutId)
})
}

View file

@ -4,7 +4,7 @@
* list/detail render.
*/
import { CAMPAIGN_COMMUNICATION_ACERVO } from '@/lib/campaignPaths'
import { formatSpeechClock, formatSpeechSpan } from '@/lib/speechClock'
import { formatSpeechClock, formatSpeechDate, formatSpeechSpan } from '@/lib/speechClock'
import type { SpeechExcerptSegment } from '@/lib/speechExcerpt'
import type { SpeechScope, SpeechTopic } from '@/lib/speechFacets'
import {
@ -103,14 +103,6 @@ export const formatSpeechAt = (speechAt: string): string => {
return `${day}/${month}/${year} · ${hour}:${minute}`
}
/** Day-only label (`dd/mm/aaaa`) for share messages; the clock stays out of it. */
const formatSpeechDate = (speechAt: string): string => {
const match = /^(\d{4})-(\d{2})-(\d{2})/.exec(speechAt)
if (!match) return speechAt
const [, year, month, day] = match
return `${day}/${month}/${year}`
}
const formatSpeechDuration = (seconds: number | null | undefined): string | null =>
seconds === null || seconds === undefined || !Number.isFinite(seconds) || seconds <= 0
? null

View file

@ -0,0 +1,246 @@
import { randomUUID } from 'node:crypto'
import type { APIRequestContext } from '@playwright/test'
import { request as playwrightRequest } from '@playwright/test'
import { adminHeaders } from '../helpers/adminApi'
import { seedTestUser } from '../helpers/seedUser'
import type { CampaignE2EOwnership } from './fixtures/campaignE2EFixtures.js'
import { expect, rendered, test } from './fixtures/campaignHttpTest.js'
/**
* C167 — speech cuts over real HTTP (browserless): the unlisted public page of
* a published cut (same stored file, OG, credit, noindex), the identical
* not-found screen for unpublished and unknown ids, and the cut routes' gate
* (communicator/coordinator/candidate pass, advisor/leader fail closed). The
* cut job itself (ffmpeg, Câmara download, media publish) is unit/int
* territory — it is deliberately not driven through the HTTP server here.
*
* Media and cuts are seeded through the deployed REST API (admin session) so
* the server process runs the real cache hooks; a Local API call from the
* runner would throw on `revalidateTag` (same reason as the S1 posts spec).
*/
const BASE_URL = process.env.PLAYWRIGHT_BASE_URL ?? 'http://localhost:3000'
const FAKE_MP4 = Buffer.from('camara-corte-mp4')
const createdCutIds: number[] = []
const createdMediaIds: number[] = []
const createSpeech = async (
campaign: { fixtures: CampaignE2EOwnership },
input: { youtubeUrl?: string | null; withVideo?: boolean } = {},
) => {
const speech = await campaign.fixtures.payload.create({
collection: 'speech',
data: {
sourceKey: campaign.fixtures.value('speech'),
speechAt: '2026-08-11T18:48',
type: 'BREVES COMUNICAÇÕES',
phase: 'Breves Comunicações',
durationSeconds: 252,
summary: 'Saúde e educação em Feira de Santana.',
officialTranscript: 'O SR. JORGE SOLLA (Bloco/PT - BA) - Sr. Presidente...',
keywords: ['Farmácia Popular'],
topics: ['saude'],
scopes: ['bahia'],
classifiedBy: 'gazetteer',
searchText: 'a retomada da saude na bahia',
presidingOfficer: 'Pompeo de Mattos',
eventId: 67091,
audioId: 558641,
excerptTMs: 1786473834650,
eventStartAt: '2026-08-11T15:00',
youtubeUrl: input.youtubeUrl ?? null,
...(input.withVideo === false
? {}
: {
vodPlaybackUrl: 'https://vod.camara.leg.br/excerpt.mp4',
vodDownloadUrl: 'https://vod.camara.leg.br/excerpt.mp4',
}),
},
depth: 0,
})
return speech
}
const createCut = async (
request: APIRequestContext,
headers: Record<string, string>,
input: {
speechId: number
status: 'published' | 'unpublished' | 'processing' | 'failed'
title: string
},
): Promise<number> => {
const media = await request.post(`${BASE_URL}/api/media`, {
headers,
// Payload multipart carries the document fields in `_payload` (JSON).
multipart: {
_payload: JSON.stringify({ alt: input.title }),
file: {
name: `corte-${randomUUID().slice(0, 8)}.mp4`,
mimeType: 'video/mp4',
buffer: FAKE_MP4,
},
},
})
expect(media.ok(), await media.text()).toBeTruthy()
const mediaId = ((await media.json()) as { doc: { id: number } }).doc.id
createdMediaIds.push(mediaId)
const cut = await request.post(`${BASE_URL}/api/speechCut`, {
headers,
data: {
speech: input.speechId,
startSeconds: 43,
endSeconds: 118,
durationSeconds: 75,
title: input.title,
description: 'Em pronunciamento no plenário, o deputado cobra o financiamento da saúde.',
status: input.status,
media: mediaId,
...(input.status === 'published' ? { publishedAt: new Date().toISOString() } : {}),
},
})
expect(cut.ok(), await cut.text()).toBeTruthy()
const cutId = ((await cut.json()) as { doc: { id: number } }).doc.id
createdCutIds.push(cutId)
return cutId
}
test.afterAll(async ({ request }) => {
const headers = await adminHeaders(request, BASE_URL).catch(() => null)
if (!headers) return
for (const id of createdCutIds.splice(0)) {
await request.delete(`${BASE_URL}/api/speechCut/${id}`, { headers }).catch(() => undefined)
}
for (const id of createdMediaIds.splice(0)) {
await request.delete(`${BASE_URL}/api/media/${id}`, { headers }).catch(() => undefined)
}
})
test.describe('Acervo speech cuts (C167)', () => {
test.beforeAll(async () => {
await seedTestUser()
})
test('serves the published cut with the stored file, OG image, credit and noindex', async ({
campaign,
request,
}) => {
const headers = await adminHeaders(request, BASE_URL)
const speech = await createSpeech(campaign, {
youtubeUrl: 'https://www.youtube.com/watch?v=lLhRDkSPw0A',
})
const title = 'Acesso a medicamentos na Bahia'
const cutId = await createCut(request, headers, {
speechId: speech.id,
status: 'published',
title,
})
const anonymous = await playwrightRequest.newContext({ baseURL: campaign.baseURL })
try {
const response = await anonymous.get(`/corte/${cutId}`)
expect(response.status()).toBe(200)
const html = rendered(await response.text())
expect(html).toContain(title)
expect(html).toContain('/api/media/file/')
expect(html).toContain('Fonte: Câmara dos Deputados · CC BY 4.0')
expect(html).toContain('noindex')
expect(html).toContain('https://i.ytimg.com/vi/lLhRDkSPw0A/hqdefault.jpg')
expect(html).toContain('Ver sessão no YouTube')
} finally {
await anonymous.dispose()
}
})
test('answers the same not-found screen for an unpublished cut and an unknown id', async ({
campaign,
request,
}) => {
const headers = await adminHeaders(request, BASE_URL)
const speech = await createSpeech(campaign)
const cutId = await createCut(request, headers, {
speechId: speech.id,
status: 'unpublished',
title: 'Corte despublicado',
})
const anonymous = await playwrightRequest.newContext({ baseURL: campaign.baseURL })
try {
const unpublished = await anonymous.get(`/corte/${cutId}`)
const unknown = await anonymous.get('/corte/999999999')
expect(unpublished.status()).toBe(404)
expect(unknown.status()).toBe(404)
const unpublishedHtml = rendered(await unpublished.text())
const unknownHtml = rendered(await unknown.text())
expect(unpublishedHtml).toContain('Este corte não está disponível')
expect(unknownHtml).toContain('Este corte não está disponível')
expect(unpublishedHtml).not.toContain('Corte despublicado')
} finally {
await anonymous.dispose()
}
})
test('serves the cut status to a communicator and denies advisor/leader fail-closed', async ({
campaign,
campaignRequest,
request,
}) => {
const headers = await adminHeaders(request, BASE_URL)
const speech = await createSpeech(campaign)
const cutId = await createCut(request, headers, {
speechId: speech.id,
status: 'published',
title: 'Corte do acervo',
})
const communicator = await campaign.fixtures.createCampaignUser('communicator')
const communicatorRequest = await campaignRequest(communicator, communicator.password)
const communicatorPoll = await communicatorRequest.post(
'/campanha/comunicacao/acervo/cortar/status',
{ data: { cutId } },
)
expect(communicatorPoll.status()).toBe(200)
const pollBody = (await communicatorPoll.json()) as {
status: string
cut: { status: string; publicPath: string; mediaUrl: string | null }
}
expect(pollBody.status).toBe('success')
expect(pollBody.cut.status).toBe('published')
expect(pollBody.cut.publicPath).toBe(`/corte/${cutId}`)
expect(pollBody.cut.mediaUrl).toContain('/api/media/file/')
// A VOD-less speech cannot be cut: the route answers the domain message
// instead of creating a row that could never publish.
const withoutVideo = await createSpeech(campaign, { withVideo: false })
const ineligible = await communicatorRequest.post('/campanha/comunicacao/acervo/cortar', {
data: {
speechId: withoutVideo.id,
startSeconds: 0,
endSeconds: 30,
title: 'Título',
description: 'Descrição',
},
})
expect(ineligible.status()).toBe(400)
expect(((await ineligible.json()) as { message: string }).message).toContain(
'não tem trecho de vídeo',
)
for (const role of ['advisor', 'leader'] as const) {
const denied = await campaign.fixtures.createCampaignUser(role)
const deniedRequest = await campaignRequest(denied, denied.password)
const response = await deniedRequest.post('/campanha/comunicacao/acervo/cortar/status', {
data: { cutId },
})
expect(response.status()).toBe(400)
expect(((await response.json()) as { message: string }).message).toContain(
'não tem acesso ao acervo',
)
}
})
})

31
tests/fixtures/fake-ffmpeg.mjs vendored Executable file
View file

@ -0,0 +1,31 @@
#!/usr/bin/env node
/**
* C167 test double for ffmpeg: copies the `-i` input to the output path so the
* cut pipeline can run without a real encoder (the developer machine has none).
* `FAKE_FFMPEG_FAIL=1` makes it exit 1; `FAKE_FFMPEG_LOG=<path>` records argv.
* The real binary is exercised by the `skipIf(!hasFfmpeg())` block (CI).
*/
import { copyFileSync, writeFileSync } from 'node:fs'
import { argv, env, exit } from 'node:process'
const args = argv.slice(2)
if (env.FAKE_FFMPEG_LOG) {
writeFileSync(env.FAKE_FFMPEG_LOG, JSON.stringify(args))
}
if (env.FAKE_FFMPEG_FAIL === '1') {
process.stderr.write('fake-ffmpeg: forced failure\n')
exit(1)
}
const inputIndex = args.indexOf('-i')
const input = inputIndex >= 0 ? args[inputIndex + 1] : undefined
const output = args.at(-1)
if (!input || !output) {
process.stderr.write('fake-ffmpeg: missing -i input or output path\n')
exit(2)
}
copyFileSync(input, output)

View file

@ -0,0 +1,498 @@
// @vitest-environment node
import { spawnSync } from 'node:child_process'
import { randomUUID } from 'node:crypto'
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
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'
vi.mock('@/utilities/speech/speechCutScheduler', () => ({
startSpeechCutJobInBackground: vi.fn(),
}))
// The SpeechCut 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,
}))
vi.mock('@/utilities/campaignActionContext', async (importOriginal) => {
const original = await importOriginal<typeof import('@/utilities/campaignActionContext')>()
return { ...original, getCampaignActionContext: vi.fn() }
})
import {
getSpeechCutStatusForActor,
saveSpeechCutForActor,
suggestSpeechCutMetadataForActor,
} from '@/app/(campaign)/campanha/actions/speech'
import { SPEECH_CUT_FORBIDDEN_MESSAGE } from '@/lib/schemas/speechCut'
import type { CampaignUser } from '@/payload-types'
import config from '@/payload.config'
import { getCampaignActionContext } from '@/utilities/campaignActionContext'
import {
reapStaleSpeechCut,
runSpeechCutJob,
SPEECH_CUT_STALE_MS,
} from '@/utilities/speech/speechCutJob'
import { startSpeechCutJobInBackground } from '@/utilities/speech/speechCutScheduler'
import { upsertSpeechBundle, type SpeechImportBundle } from '@/utilities/speech/speechImport'
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')
let payload: Payload
const createdSourceKeys = new Set<string>()
const createdCutIds = new Set<number>()
const createdMediaIds = new Set<number>()
const campaignFixtures = installCampaignFixtures({
getPayload: () => payload,
setPayload: (nextPayload) => {
payload = nextPayload
},
})
const mockedGetContext = vi.mocked(getCampaignActionContext)
const mockedStartJob = vi.mocked(startSpeechCutJobInBackground)
const camaraFetchStub = (bytes: Buffer): 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 },
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
)
}
if (url === PLAYBACK_URL || url === DOWNLOAD_URL) {
return new Response(new Uint8Array(bytes), {
status: 200,
headers: { 'Content-Type': 'video/mp4', 'Content-Length': String(bytes.length) },
})
}
throw new Error(`Unexpected fetch to ${url}`)
}) as typeof fetch
const createSpeech = async (overrides: Partial<SpeechImportBundle> = {}): Promise<number> => {
const bundle = speechBundleFixture({
vodPlaybackUrl: PLAYBACK_URL,
vodDownloadUrl: DOWNLOAD_URL,
...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
}
const createCut = async (data: {
speech: number
startSeconds: number
endSeconds: number
status?: 'processing' | 'published' | 'unpublished' | 'failed'
createdBy?: number
}): Promise<number> => {
const cut = await payload.create({
collection: 'speechCut',
data: {
speech: data.speech,
startSeconds: data.startSeconds,
endSeconds: data.endSeconds,
durationSeconds: data.endSeconds - data.startSeconds,
title: `Corte ${randomUUID().slice(0, 8)}`,
description: 'Descrição do corte',
status: data.status ?? 'processing',
...(data.createdBy ? { createdBy: data.createdBy } : {}),
},
depth: 0,
overrideAccess: true,
})
createdCutIds.add(cut.id)
return cut.id
}
const createUsers = async () => {
const fixtures = campaignFixtures()
const [communicator, coordinator, candidate, advisor, leader] = await Promise.all([
fixtures.createCampaignUser('communicator'),
fixtures.createCampaignUser('coordinator'),
fixtures.createCampaignUser('candidate'),
fixtures.createCampaignUser('advisor'),
fixtures.createCampaignUser('leader'),
])
return { communicator, coordinator, candidate, advisor, leader }
}
const findCutsAs = (user: CampaignUser | undefined, ids: readonly number[]) =>
payload.find({
collection: 'speechCut',
where: { id: { in: [...ids] } },
depth: 0,
limit: 0,
pagination: false,
...(user ? { user } : {}),
overrideAccess: false,
})
const asActor = (actor: CampaignUser) => mockedGetContext.mockResolvedValue({ payload, actor })
const withFfmpegPath = (value: string | undefined): (() => void) => {
const previous = process.env.FFMPEG_PATH
if (value === undefined) delete process.env.FFMPEG_PATH
else process.env.FFMPEG_PATH = value
return () => {
if (previous === undefined) delete process.env.FFMPEG_PATH
else process.env.FFMPEG_PATH = previous
}
}
describe('speech cuts (C167)', () => {
beforeAll(async () => {
payload = await getPayload({ config: await config })
})
afterEach(() => {
mockedGetContext.mockReset()
mockedStartJob.mockReset()
})
afterAll(async () => {
for (const mediaId of createdMediaIds) {
await payload
.delete({ collection: 'media', id: mediaId, overrideAccess: true })
.catch(() => undefined)
}
for (const cutId of createdCutIds) {
await payload
.delete({ collection: 'speechCut', id: cutId, 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('exposes published cuts to anonymous readers and every status to the acervo gate', async () => {
const speech = await createSpeech()
const published = await createCut({
speech,
startSeconds: 0,
endSeconds: 20,
status: 'published',
})
const unpublished = await createCut({
speech,
startSeconds: 30,
endSeconds: 50,
status: 'unpublished',
})
const failed = await createCut({ speech, startSeconds: 60, endSeconds: 80, status: 'failed' })
const ids = [published, unpublished, failed]
const { communicator, advisor, leader } = await createUsers()
const anonymous = await findCutsAs(undefined, ids)
expect(anonymous.docs.map((doc) => doc.id)).toEqual([published])
const asAdvisor = await findCutsAs(advisor, ids)
expect(asAdvisor.docs.map((doc) => doc.id)).toEqual([published])
const asLeader = await findCutsAs(leader, ids)
expect(asLeader.docs.map((doc) => doc.id)).toEqual([published])
const asCommunicator = await findCutsAs(communicator, ids)
expect(asCommunicator.docs).toHaveLength(3)
})
it('creates the cut, normalizes the range and dedupes a double submit', async () => {
const speech = await createSpeech()
const { communicator } = await createUsers()
asActor(communicator)
const first = await saveSpeechCutForActor({
speechId: speech,
startSeconds: 43,
endSeconds: 118,
title: 'Acesso a medicamentos',
description: 'Trecho do pronunciamento',
})
expect(first.status).toBe('processing')
expect(first.step).toBe('resolving')
expect(first.publicPath).toBe(`/corte/${first.id}`)
expect(first.durationSeconds).toBe(75)
createdCutIds.add(first.id)
expect(mockedStartJob).toHaveBeenCalledTimes(1)
const second = await saveSpeechCutForActor({
speechId: speech,
startSeconds: 43,
endSeconds: 118,
title: 'Outro título',
description: 'Outra descrição',
})
expect(second.id).toBe(first.id)
expect(mockedStartJob).toHaveBeenCalledTimes(1)
})
it('rejects a selection on a speech too short and denies advisor/leader fail-closed', async () => {
const shortSpeech = await createSpeech({ durationSeconds: 3 })
const speech = await createSpeech()
const { communicator, advisor, leader } = await createUsers()
asActor(communicator)
await expect(
saveSpeechCutForActor({
speechId: shortSpeech,
startSeconds: 0,
endSeconds: 5,
title: 't',
description: 'd',
}),
).rejects.toThrow(/5 a 180/)
for (const denied of [advisor, leader]) {
asActor(denied)
await expect(
saveSpeechCutForActor({
speechId: speech,
startSeconds: 0,
endSeconds: 30,
title: 't',
description: 'd',
}),
).rejects.toThrow(SPEECH_CUT_FORBIDDEN_MESSAGE)
}
})
it('retries only a failed row, reusing the same id', async () => {
const speech = await createSpeech()
const { communicator } = await createUsers()
const failed = await createCut({
speech,
startSeconds: 10,
endSeconds: 40,
status: 'failed',
createdBy: communicator.id,
})
const published = await createCut({
speech,
startSeconds: 60,
endSeconds: 90,
status: 'published',
})
asActor(communicator)
const retried = await saveSpeechCutForActor({ retryOf: failed })
expect(retried.id).toBe(failed)
expect(retried.status).toBe('processing')
expect(retried.step).toBe('resolving')
expect(mockedStartJob).toHaveBeenCalledTimes(1)
await expect(saveSpeechCutForActor({ retryOf: published })).rejects.toThrow(/corte que falhou/)
})
it('reports the status through the poll and reaps a stale processing row', async () => {
const speech = await createSpeech()
const { communicator } = await createUsers()
const cutId = await createCut({ speech, startSeconds: 0, endSeconds: 30 })
asActor(communicator)
const current = await getSpeechCutStatusForActor({ cutId })
expect(current.status).toBe('processing')
const reaped = await reapStaleSpeechCut(payload, {
id: cutId,
status: 'processing',
updatedAt: new Date(Date.now() - SPEECH_CUT_STALE_MS - 60_000).toISOString(),
})
expect(reaped).toBe(true)
const after = await getSpeechCutStatusForActor({ cutId })
expect(after.status).toBe('failed')
})
it('suggests the deterministic fallback when the AI is unavailable', async () => {
const speech = await createSpeech()
const { communicator } = await createUsers()
asActor(communicator)
const previousKey = process.env.DEEPSEEK_API_KEY
delete process.env.DEEPSEEK_API_KEY
try {
const suggestion = await suggestSpeechCutMetadataForActor({
speechId: speech,
startSeconds: 0,
endSeconds: 30,
})
expect(suggestion.source).toBe('fallback')
expect(suggestion.title).toBe('Trecho de BREVES COMUNICAÇÕES — 07/02/2023')
expect(suggestion.description).toBe('Saúde e educação em Feira de Santana.')
} finally {
if (previousKey !== undefined) process.env.DEEPSEEK_API_KEY = previousKey
}
})
it('publishes the cut with a fake ffmpeg: media stored, same title, same file', async () => {
const speech = await createSpeech()
const cutId = await createCut({ speech, startSeconds: 43, endSeconds: 118 })
vi.stubGlobal('fetch', camaraFetchStub(MP4_BYTES))
const restoreFfmpeg = withFfmpegPath(FAKE_FFMPEG)
try {
await runSpeechCutJob(payload, cutId)
const cut = await payload.findByID({
collection: 'speechCut',
id: cutId,
depth: 1,
overrideAccess: true,
})
expect(cut.status).toBe('published')
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()
}
})
it('fails closed when ffmpeg fails: nothing published, no media', async () => {
const speech = await createSpeech()
const cutId = await createCut({ speech, startSeconds: 0, endSeconds: 30 })
vi.stubGlobal('fetch', camaraFetchStub(MP4_BYTES))
const restoreFfmpeg = withFfmpegPath(FAKE_FFMPEG)
const previousFail = process.env.FAKE_FFMPEG_FAIL
process.env.FAKE_FFMPEG_FAIL = '1'
try {
await runSpeechCutJob(payload, cutId)
const cut = await payload.findByID({
collection: 'speechCut',
id: cutId,
depth: 1,
overrideAccess: true,
})
expect(cut.status).toBe('failed')
expect(cut.media ?? null).toBeNull()
expect(cut.publishedAt ?? null).toBeNull()
expect(cut.error).toBeTruthy()
} finally {
if (previousFail === undefined) delete process.env.FAKE_FFMPEG_FAIL
else process.env.FAKE_FFMPEG_FAIL = previousFail
restoreFfmpeg()
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-'))
const source = join(workdir, 'source.mp4')
const generated = spawnSync(
'ffmpeg',
[
'-nostdin',
'-hide_banner',
'-y',
'-f',
'lavfi',
'-i',
'testsrc=duration=30:size=128x72:rate=10',
'-pix_fmt',
'yuv420p',
source,
],
{ encoding: 'utf8' },
)
expect(generated.status).toBe(0)
const speech = await createSpeech()
const cutId = await createCut({ speech, startSeconds: 10, endSeconds: 16 })
vi.stubGlobal('fetch', camaraFetchStub(readFileSync(source)))
const restoreFfmpeg = withFfmpegPath(undefined)
try {
await runSpeechCutJob(payload, cutId)
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(media.mimeType).toBe('video/mp4')
const probe = spawnSync(
'ffprobe',
[
'-v',
'error',
'-show_entries',
'format=duration',
'-of',
'default=noprint_wrappers=1:nokey=1',
resolve(process.cwd(), 'media', media.filename as string),
],
{ encoding: 'utf8' },
)
expect(probe.status).toBe(0)
expect(Number(probe.stdout.trim())).toBeCloseTo(6, 1)
} finally {
restoreFfmpeg()
vi.unstubAllGlobals()
rmSync(workdir, { recursive: true, force: true })
}
})
})
})

View file

@ -59,6 +59,9 @@ describe('E2E_AFFECTED_MANIFEST (OPS5)', () => {
'campaignAiTranscribe',
'campaignAgendaFeed',
'campaignNewsletter',
// C167 — deliberate: the cut public page/route contract is new and the
// migration makes every PR of this delivery high-risk (curated only).
'campaignSpeechCut',
])
const onDisk = new Set(specNamesOnDisk())
for (const spec of E2E_CURATED_SPECS) {

View file

@ -0,0 +1,175 @@
import { describe, expect, it } from 'vitest'
import { formatSpeechDate } from '@/lib/speechClock'
import {
buildSpeechCutFallbackMetadata,
buildSpeechCutFfmpegArgs,
buildSpeechCutShare,
speechCutPublicPath,
speechCutStepStates,
toSpeechCutViewModel,
} from '@/lib/speechCut'
const decodeWaText = (url: string): string =>
decodeURIComponent(url.slice('https://wa.me/?text='.length)).replace(/\+/g, ' ')
describe('buildSpeechCutFfmpegArgs', () => {
const args = buildSpeechCutFfmpegArgs({
inputPath: '/tmp/source.mp4',
outputPath: '/tmp/corte-1-43-130.mp4',
startSeconds: 43,
endSeconds: 130,
})
it('seeks before the input so the re-encode starts exactly at the picked second', () => {
expect(args.indexOf('-ss')).toBeLessThan(args.indexOf('-i'))
expect(args[args.indexOf('-ss') + 1]).toBe('43')
})
it('carries the selection duration and re-encodes (never -c copy)', () => {
expect(args[args.indexOf('-t') + 1]).toBe('87')
expect(args).toContain('libx264')
expect(args).toContain('aac')
expect(args).toContain('+faststart')
expect(args).not.toContain('copy')
})
it('maps video always and audio when present, and ends with the output path', () => {
expect(args[args.indexOf('-map') + 1]).toBe('0:v:0')
expect(args).toContain('0:a?')
expect(args.at(-1)).toBe('/tmp/corte-1-43-130.mp4')
})
it('rounds seconds and never builds a negative duration', () => {
const rounded = buildSpeechCutFfmpegArgs({
inputPath: 'in',
outputPath: 'out',
startSeconds: 42.6,
endSeconds: 130.2,
})
expect(rounded[rounded.indexOf('-ss') + 1]).toBe('43')
expect(rounded[rounded.indexOf('-t') + 1]).toBe('87')
const reversed = buildSpeechCutFfmpegArgs({
inputPath: 'in',
outputPath: 'out',
startSeconds: 130,
endSeconds: 43,
})
expect(reversed[reversed.indexOf('-t') + 1]).toBe('0')
})
})
describe('buildSpeechCutFallbackMetadata', () => {
it('names the excerpt with the type and the day, and uses the official summary', () => {
expect(
buildSpeechCutFallbackMetadata({
speechType: 'BREVES COMUNICAÇÕES',
dateLabel: '11/08/2026',
summary: 'Saúde e educação em Feira de Santana.',
}),
).toEqual({
title: 'Trecho de BREVES COMUNICAÇÕES — 11/08/2026',
description: 'Saúde e educação em Feira de Santana.',
})
})
it('falls back to "fala" and to a dated sentence when there is no type or summary', () => {
expect(
buildSpeechCutFallbackMetadata({ speechType: ' ', dateLabel: '11/08/2026', summary: null }),
).toEqual({
title: 'Trecho de fala — 11/08/2026',
description: 'Trecho de fala de 11/08/2026, na Câmara dos Deputados.',
})
})
it('clips an oversized summary to the field limit', () => {
const description = buildSpeechCutFallbackMetadata({
speechType: null,
dateLabel: '11/08/2026',
summary: 'a'.repeat(5000),
}).description
expect(description).toHaveLength(2000)
})
})
describe('speechCutStepStates', () => {
it('marks the previous steps done, the current one and the rest queued', () => {
expect(speechCutStepStates('metadata').map((entry) => [entry.step, entry.state])).toEqual([
['resolving', 'done'],
['cutting', 'done'],
['metadata', 'current'],
['publishing', 'queued'],
])
})
it('queues every step when there is no current step', () => {
expect(speechCutStepStates(null).every((entry) => entry.state === 'queued')).toBe(true)
})
})
describe('toSpeechCutViewModel', () => {
const view = toSpeechCutViewModel({
id: 123,
status: 'published',
step: 'publishing',
title: 'Acesso a medicamentos',
description: 'Descrição',
startSeconds: 43,
endSeconds: 118,
durationSeconds: 75,
media: { url: '/api/media/file/corte-123-43-118.mp4', filename: 'corte-123-43-118.mp4' },
speech: { youtubeUrl: 'https://www.youtube.com/watch?v=lLhRDkSPw0A' },
publishedAt: '2026-09-15T12:00:00.000Z',
})
it('exposes the public path, the stored file and the YouTube cover id', () => {
expect(view.publicPath).toBe('/corte/123')
expect(view.mediaUrl).toBe('/api/media/file/corte-123-43-118.mp4')
expect(view.mediaFilename).toBe('corte-123-43-118.mp4')
expect(view.youtubeVideoId).toBe('lLhRDkSPw0A')
expect(view.durationLabel).toBe('1min15s')
})
it('never lets an unknown status/step through, and falls back to end − start', () => {
const unknown = toSpeechCutViewModel({
id: 1,
status: 'weird',
step: 'weird',
title: 't',
description: 'd',
startSeconds: 10,
endSeconds: 30,
media: null,
speech: 7,
})
expect(unknown.status).toBe('failed')
expect(unknown.step).toBeNull()
expect(unknown.durationSeconds).toBe(20)
expect(unknown.mediaUrl).toBeNull()
expect(unknown.youtubeVideoId).toBeNull()
})
})
describe('buildSpeechCutShare', () => {
it('bundles the title with the public link and reuses the wa.me builder', () => {
const share = buildSpeechCutShare({
title: 'Acesso a medicamentos',
url: 'https://jorgesolla1313.com.br/corte/123',
})
expect(share.message).toBe('Acesso a medicamentos https://jorgesolla1313.com.br/corte/123')
expect(share.whatsAppUrl.startsWith('https://wa.me/?text=')).toBe(true)
expect(decodeWaText(share.whatsAppUrl)).toBe(share.message)
})
it('builds the public path from the numeric id', () => {
expect(speechCutPublicPath(42)).toBe('/corte/42')
})
})
describe('formatSpeechDate', () => {
it('reads the Câmara wall clock as a day-only label', () => {
expect(formatSpeechDate('2026-08-11T18:48')).toBe('11/08/2026')
expect(formatSpeechDate('not-a-date')).toBe('not-a-date')
})
})

View file

@ -0,0 +1,187 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { SpeechCutDialog } from '@/components/campaign/speech/SpeechCutDialog'
import type { SpeechCutViewModel } from '@/lib/speechCut'
const SAVE_ENDPOINT = '/campanha/comunicacao/acervo/cortar'
const STATUS_ENDPOINT = `${SAVE_ENDPOINT}/status`
const SUGGESTION_ENDPOINT = `${SAVE_ENDPOINT}/sugestao`
const RANGE = { startSeconds: 43, endSeconds: 118 }
const cutView = (overrides: Partial<SpeechCutViewModel> = {}): SpeechCutViewModel => ({
id: 123,
status: 'processing',
step: 'resolving',
title: 'Acesso a medicamentos',
description: 'Descrição do trecho',
startSeconds: 43,
endSeconds: 118,
durationSeconds: 75,
durationLabel: '1min15s',
publicPath: '/corte/123',
mediaUrl: null,
mediaFilename: null,
youtubeVideoId: null,
publishedAt: null,
...overrides,
})
const jsonResponse = (payload: unknown, ok = true) => ({ ok, json: async () => payload })
const fetchMock = vi.fn()
const renderDialog = (overrides: Partial<Parameters<typeof SpeechCutDialog>[0]> = {}) => {
const onPublished = vi.fn()
const onOpenChange = vi.fn()
render(
<SpeechCutDialog
open
onOpenChange={onOpenChange}
speechId={42}
speechType="BREVES COMUNICAÇÕES"
dateLabel="11/08/2026"
summary="Saúde e educação em Feira de Santana."
range={RANGE}
onPublished={onPublished}
{...overrides}
/>,
)
return { onPublished, onOpenChange }
}
beforeEach(() => {
vi.stubGlobal(
'ResizeObserver',
class {
observe() {}
unobserve() {}
disconnect() {}
},
)
Element.prototype.scrollIntoView = () => {}
vi.stubGlobal('fetch', fetchMock)
})
afterEach(() => {
cleanup()
fetchMock.mockReset()
vi.unstubAllGlobals()
Reflect.deleteProperty(Element.prototype, 'scrollIntoView')
})
describe('SpeechCutDialog (C167)', () => {
it('pre-fills the deterministic fallback, then swaps in the AI suggestion on untouched fields', async () => {
fetchMock.mockResolvedValue(
jsonResponse({
status: 'success',
suggestion: { title: 'Acesso a medicamentos', description: 'Sugestão IA', source: 'ai' },
}),
)
renderDialog()
await screen.findByDisplayValue('Sugestão IA')
expect(screen.getByDisplayValue('Acesso a medicamentos')).toBeTruthy()
expect(screen.getByText(/Sugerido por IA/)).toBeTruthy()
})
it('never overwrites a field the user has already typed in', async () => {
let resolveSuggestion!: (response: unknown) => void
const suggestion = new Promise((resolve) => {
resolveSuggestion = resolve
})
fetchMock.mockImplementation(async (input: RequestInfo | URL) => {
if (String(input).endsWith(SUGGESTION_ENDPOINT)) return suggestion
throw new Error(`unexpected fetch: ${String(input)}`)
})
renderDialog()
fireEvent.change(screen.getByLabelText('Título'), { target: { value: 'Meu título' } })
resolveSuggestion(
jsonResponse({
status: 'success',
suggestion: { title: 'Título da IA', description: 'Descrição da IA', source: 'ai' },
}),
)
await screen.findByDisplayValue('Descrição da IA')
expect(screen.getByDisplayValue('Meu título')).toBeTruthy()
expect(screen.queryByDisplayValue('Título da IA')).toBeNull()
})
it('creates, follows the steps and hands the published cut to the caller', async () => {
fetchMock.mockImplementation(async (input: RequestInfo | URL, init?: RequestInit) => {
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)) {
expect(JSON.parse(String(init?.body))).toMatchObject({
speechId: 42,
startSeconds: 43,
endSeconds: 118,
})
return jsonResponse({ status: 'success', cut: cutView() })
}
if (url.endsWith(STATUS_ENDPOINT)) {
return jsonResponse({
status: 'success',
cut: cutView({
status: 'published',
step: null,
mediaUrl: '/api/media/file/corte-123-43-118.mp4',
mediaFilename: 'corte-123-43-118.mp4',
}),
})
}
throw new Error(`unexpected fetch: ${url}`)
})
const { onPublished, onOpenChange } = renderDialog()
await screen.findByDisplayValue(/Trecho de BREVES COMUNICAÇÕES/)
fireEvent.click(screen.getByRole('button', { name: /Cortar e publicar/ }))
await screen.findByText('Preparando o corte…')
await waitFor(() => expect(onPublished).toHaveBeenCalledTimes(1), { timeout: 4000 })
expect(onPublished.mock.calls[0][0].publicPath).toBe('/corte/123')
expect(onOpenChange).toHaveBeenCalledWith(false)
})
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) => {
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)) {
retryBodies.push(JSON.parse(String(init?.body)))
return jsonResponse({ status: 'success', cut: cutView() })
}
if (url.endsWith(STATUS_ENDPOINT)) {
return jsonResponse({ status: 'success', cut: cutView({ status: 'failed', step: null }) })
}
throw new Error(`unexpected fetch: ${url}`)
})
renderDialog()
await screen.findByDisplayValue(/Trecho de BREVES COMUNICAÇÕES/)
fireEvent.click(screen.getByRole('button', { name: /Cortar e publicar/ }))
const retry = await screen.findByRole('button', { name: 'Tentar novamente' }, { timeout: 4000 })
fireEvent.click(retry)
await waitFor(() => expect(retryBodies).toHaveLength(2))
expect(retryBodies[1]).toEqual({ retryOf: 123 })
})
})

View file

@ -59,6 +59,7 @@ const renderPlayer = (props: Partial<Parameters<typeof SpeechDetailPlayer>[0]> =
durationSeconds={300}
speechType="BREVES COMUNICAÇÕES"
speechDateLabel="11/08/2026"
speechSummary={null}
{...props}
/>,
)