mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 06:43:06 +00:00
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:
parent
ab9c164615
commit
04ac01d0f3
42 changed files with 19129 additions and 57 deletions
|
|
@ -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=
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
1
docs/changelog/2026-09-15-c167.md
Normal file
1
docs/changelog/2026-09-15-c167.md
Normal 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).
|
||||
245
docs/plans/c167-cortar-trecho-publicar-impl.md
Normal file
245
docs/plans/c167-cortar-trecho-publicar-impl.md
Normal 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.
|
||||
|
|
@ -18,7 +18,7 @@
|
|||
"paths": {
|
||||
"server-only": ["tests/helpers/serverOnly.ts"]
|
||||
},
|
||||
"ignoreBinaries": ["opencode"],
|
||||
"ignoreBinaries": ["opencode", "ffmpeg", "ffprobe"],
|
||||
"ignoreDependencies": [
|
||||
"tailwindcss-animate",
|
||||
"tw-animate-css",
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
},
|
||||
)
|
||||
|
|
@ -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 })
|
||||
},
|
||||
)
|
||||
|
|
@ -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 })
|
||||
},
|
||||
)
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
189
src/app/(frontend)/corte/[id]/page.tsx
Normal file
189
src/app/(frontend)/corte/[id]/page.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
28
src/app/(frontend)/corte/not-found.tsx
Normal file
28
src/app/(frontend)/corte/not-found.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
160
src/collections/SpeechCut.ts
Normal file
160
src/collections/SpeechCut.ts
Normal 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 }),
|
||||
],
|
||||
}
|
||||
87
src/components/SpeechCutShareActions.tsx
Normal file
87
src/components/SpeechCutShareActions.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
421
src/components/campaign/speech/SpeechCutDialog.tsx
Normal file
421
src/components/campaign/speech/SpeechCutDialog.tsx
Normal 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/<id></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>
|
||||
)
|
||||
}
|
||||
89
src/components/campaign/speech/SpeechCutResultCard.tsx
Normal file
89
src/components/campaign/speech/SpeechCutResultCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
46
src/lib/copyFeedback.ts
Normal 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 }
|
||||
}
|
||||
53
src/lib/schemas/speechCut.ts
Normal file
53
src/lib/schemas/speechCut.ts
Normal 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,
|
||||
})
|
||||
|
|
@ -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
235
src/lib/speechCut.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
15396
src/migrations/20260915_122356_add_speech_cut.json
Normal file
15396
src/migrations/20260915_122356_add_speech_cut.json
Normal file
File diff suppressed because it is too large
Load diff
49
src/migrations/20260915_122356_add_speech_cut.ts
Normal file
49
src/migrations/20260915_122356_add_speech_cut.ts
Normal 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";`)
|
||||
}
|
||||
|
|
@ -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'
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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' } }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
277
src/utilities/speech/speechCutJob.ts
Normal file
277
src/utilities/speech/speechCutJob.ts
Normal 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
|
||||
}
|
||||
114
src/utilities/speech/speechCutMetadata.ts
Normal file
114
src/utilities/speech/speechCutMetadata.ts
Normal 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' }
|
||||
}
|
||||
}
|
||||
20
src/utilities/speech/speechCutScheduler.ts
Normal file
20
src/utilities/speech/speechCutScheduler.ts
Normal 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)
|
||||
})
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
246
tests/e2e/campaignSpeechCut.e2e.spec.ts
Normal file
246
tests/e2e/campaignSpeechCut.e2e.spec.ts
Normal 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
31
tests/fixtures/fake-ffmpeg.mjs
vendored
Executable 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)
|
||||
498
tests/int/speechCut.int.spec.ts
Normal file
498
tests/int/speechCut.int.spec.ts
Normal 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 })
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
175
tests/unit/speechCut.unit.spec.ts
Normal file
175
tests/unit/speechCut.unit.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
187
tests/unit/speechCutDialog.unit.spec.tsx
Normal file
187
tests/unit/speechCutDialog.unit.spec.tsx
Normal 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 })
|
||||
})
|
||||
})
|
||||
|
|
@ -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}
|
||||
/>,
|
||||
)
|
||||
|
|
|
|||
Loading…
Reference in a new issue