diff --git a/.env.example b/.env.example index 5e688ba3..1199a445 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/Dockerfile b/Dockerfile index 798ce06a..6890250e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/docs/changelog/2026-09-15-c167.md b/docs/changelog/2026-09-15-c167.md new file mode 100644 index 00000000..a3761f6d --- /dev/null +++ b/docs/changelog/2026-09-15-c167.md @@ -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 — ` + 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/`** (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). diff --git a/docs/plans/c167-cortar-trecho-publicar-impl.md b/docs/plans/c167-cortar-trecho-publicar-impl.md new file mode 100644 index 00000000..061f0e95 --- /dev/null +++ b/docs/plans/c167-cortar-trecho-publicar-impl.md @@ -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/` (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 — ` + 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/` 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
seleção C166 5–180s"] -->|"Cortar vídeo"| B["SpeechCutDialog
(cliente)"] + B -->|"abre: prefill fallback"| S["POST acervo/cortar/sugestao
IA DeepSeek → fallback"] + S --> B + B -->|"Cortar e publicar"| C["POST acervo/cortar
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
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
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 -i + -t + -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 + + ``` + `-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:` 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 — ` + `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: `` same-origin no proxy do Payload + +- **Opções:** A) `` (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/`), então o atributo `download` do `` é 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//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 `` — 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()`; ``; `
+ +
+ + ) +} diff --git a/src/app/(frontend)/corte/not-found.tsx b/src/app/(frontend)/corte/not-found.tsx new file mode 100644 index 00000000..e8d85ad3 --- /dev/null +++ b/src/app/(frontend)/corte/not-found.tsx @@ -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 ( + <> + +
+
+

Este corte não está disponível

+

+ O link pode ter sido despublicado ou o endereço está incorreto. +

+ + Ir para jorgesolla1313.com.br + +
+
+ + ) +} diff --git a/src/collections/SpeechCut.ts b/src/collections/SpeechCut.ts new file mode 100644 index 00000000..23bd9bc4 --- /dev/null +++ b/src/collections/SpeechCut.ts @@ -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/`. 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/. 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 }), + ], +} diff --git a/src/components/SpeechCutShareActions.tsx b/src/components/SpeechCutShareActions.tsx new file mode 100644 index 00000000..98f23197 --- /dev/null +++ b/src/components/SpeechCutShareActions.tsx @@ -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 ( +
+ {primary === 'whatsapp' ? ( + + ) : null} + + {primary === 'copy' ? ( + + ) : null} + {downloadUrl ? ( + + ) : null} + + {copyFeedbackLiveMessages[feedback]} + +
+ ) +} diff --git a/src/components/campaign/speech/SpeechCutDialog.tsx b/src/components/campaign/speech/SpeechCutDialog.tsx new file mode 100644 index 00000000..3e314249 --- /dev/null +++ b/src/components/campaign/speech/SpeechCutDialog.tsx @@ -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' ? ( +