feat(C217): cortes e links compartilhaveis na fonte "Falas na internet" (Closes #1293)

This commit is contained in:
Francisco Solla 2026-09-24 19:54:59 -03:00
parent b119d203aa
commit 4ec95b0503
33 changed files with 1954 additions and 308 deletions

View file

@ -0,0 +1 @@
**C217 — Cortes e links compartilháveis na fonte "Falas na internet" (2026-09-24):** o corte do acervo deixa de ser só da Câmara e passa a valer para as falas da fonte web do C215/C216, sem mecanismo novo e **sem migration**: o `SpeechCut.speech` já era relação genérica. A entrada ganhou um predicado puro no dono (`speechCutSourceKind` em `lib/speechCut` — `web` + `mirroredMedia` → web, senão VOD da Câmara; web sem espelho → `null`, fail-closed, nunca cai no VOD) com o vocabulário de copy/prompt separado (`speechCutOriginKind`), usado pela action e pelo job; o `runSpeechCutJob` ganha o branch web — lê o espelho privado com `downloadPrivateMediaToFile`/`resolvePrivateMediaStaticDir` (linha sumida via `disableErrors` e objeto ausente viram `SPEECH_CUT_FAILURE_MIRROR_MISSING`, o literal honesto da tela), corta a MESMA janela com ffmpeg na variante de áudio (`buildSpeechCutAudioFfmpegArgs`: `0:a:0` + AAC + faststart num MP4 só-áudio, escolhida pelo mime via o dono `webSpeechMediaKind`) e publica na `media`/transação/retry/reaper de sempre — o caminho Câmara fica byte a byte (args de vídeo, labels default, `originOf`, crédito). Copy source-aware com default Câmara: fallback de título/descrição web sem "na Câmara dos Deputados", passo `resolving` ("Localizando o arquivo da fala"), mensagens de falha e prompt de IA em tail único (Câmara idêntico); o `SpeechCutDialog` ganha `speechSource` (renomeado de `source` para não colidir com a sugestão) e `speechDurationSeconds`, portando a anatomia da janela (trilho, alças e campos Início/Fim). No detalhe web (`WebSpeechDetailPlayer`) a transcrição vira o picker do corte — clique posiciona (C216) e marca/estende a frase com a máquina pura `speechExcerptSelection` (mínimo 5 s, sem teto), com `segmentInExcerpt` extraído para o dono (os dois players não podem divergir) e o segmento **em reprodução** separado do **selecionado** (neutro × borda vermelha, `aria-pressed`); "Cortar trecho" (CTA primário, "Baixar" outline, "Abrir na origem" ghost) abre a janela existente com `selection ?? initialExcerptRange` e o corte publicado mostra o result card in-session. A biblioteca ganha a origem visível no card (`SpeechCutOriginViewModel` com `source`/`platform`/`label`/`href`: pill "Origem: YouTube/Rádio/…" e link para o detalhe web), o kit produtivo do artefato (Copiar link primário + Baixar/WhatsApp com rótulos compactos no mobile) e o `SpeechCutOriginCard` source-aware ("Fala de origem"/"Ver fala na internet" + plataforma); a página pública `/corte/<id>` mantém o contrato (unlisted, `noindex`) e o crédito segue a origem real — web usa `Fonte: <plataforma> · <canal>` (`webSpeechCreditLabel`), fala sem plataforma/ausente omite a linha em vez de afirmar a Câmara. Verificação: unit (`speechCut` com predicado/args de áudio/fallback web/failure/origin VM, `webSpeech` mime/título/crédito, VM web com `durationSeconds`, diálogo web-sem-Câmara, `webSpeechDetailPlayer` novo provando gesto/`data-selected`/diálogo), int `speechCut` estendido (corte web publica do espelho sem tocar na Câmara, web sem espelho falha fechado na action e no job, variante de áudio logada pelo fake ffmpeg, proveniência na lista; fixture de bytes extraída para `tests/helpers/webSpeechMediaFixture.ts`, fechando o trigger D2 do C216), e2e (crédito web na página pública, recusa sem espelho na rota, card com origem/detalhe web, detalhe web com o gesto; pins da Câmara verdes). Crítica final do `designer` (trigger c) **certificada em tier primário** (`openai/gpt-5.6-sol`) após três rodadas — aplicados CTA, ativo×selecionado, anatomia da janela, kit do card e a copy do estado sem espelho; o header público global (`SiteHeader`) e o FAB/nav do shell ficam registrados como desvios pré-existentes fora do diff.

View file

@ -0,0 +1,245 @@
# Impl: C217 — Cortes e links compartilháveis na fonte "Falas na internet"
Status: aprovado
Atualizado em: 2026-09-24
Issue: #1293
Intenção: docs/plans/acervo-cortes-fonte-web.md
Appetite restante: herdado (~2–3 dias eng; um outcome verificável — a assessoria corta um trecho de uma fala da internet, o corte aparece na biblioteca com a origem visível e o link `/corte/<id>` abre no ponto certo, com download e crédito da origem real)
## Leitura da intenção
- **Outcome:** no detalhe da fala web (C216), "Cortar trecho" abre a janela de corte existente e gera o corte a partir do arquivo espelhado (sem resolver VOD); o corte entra na mesma biblioteca de cortes com a origem visível (YouTube/Rádio/Instagram/…), com a paridade C168/C183 (editar, apagar, "Tentar novamente", kill switch) e o mesmo contrato `/corte/<id>` (unlisted, `noindex`) com player, download, "Copiar link" e "Compartilhar no WhatsApp"; o crédito da página acompanha a origem real — nunca o crédito da Câmara para fala de terceiro.
- **O que NÃO negociar:**
- **Contrato da Câmara congelado em bytes**: o gate de `createSpeechCut` (`speechVodCoordinates`, `actions/speech.ts:150-152`), os args de vídeo (`buildSpeechCutFfmpegArgs`, `lib/speechCut.ts:157-195`), os labels/fallback/mensagens default (`lib/speechCut.ts:35-40,73-94,124-141`), o `originOf` Câmara (`lib/speechCut.ts:397-404`), o crédito `Fonte: Câmara dos Deputados · CC BY 4.0` (`corte/[id]/page.tsx:20`, e2e pinado em `tests/e2e/campaignSpeechCut.e2e.spec.ts:152`) e o `/corte/<id>` como contrato — só conteúdo/crédito mudam, por origem.
- **Fala web sem arquivo espelhado não oferece o corte** (questão 3 do gate): sem `mirroredMedia`, sem gesto.
- **Gate fail-closed**: `requireCampaignPageActor({ gate: 'communicationCatalog' })` na página, `canReadCommunicationCatalog` nas ações, `user` + `overrideAccess: false` em toda leitura. `advisor`/`leader` negados; a rota de mídia/página já fecha em 404/redirect.
- **Mídia privada nunca pública**: o espelho (`internetSpeechMedia`) só é lido pelo job e pelas rotas autenticadas; o que vira público é o MP4 derivado na `media`, como na Câmara.
- **Mesma janela, mesmo diálogo, mesma biblioteca, mesmo link**: mínimo 5 s, sem teto (C170); `/corte/<id>` continua não listado e `noindex`; sem segundo mecanismo de mídia, corte ou compartilhamento; sem galeria pública; sem item de nav novo.
- **Sem `Consent` novo, sem PII, sem segundo cadastro.**
- **O que reavaliar (hipóteses da intenção × código):**
- A intenção cita o gate como `speechCatalog`; o nome real é `communicationCatalog` (renomeado no C194, `campaignPageActor.ts:41-42`) com o mesmo predicado `canReadCommunicationCatalog` — nada muda na prática.
- A intenção fala "mesmo diálogo" sem dizer que o detalhe web hoje **não tem picker**: `WebSpeechDetailPlayer` só tem "Baixar"/"Abrir na origem" + transcrição click-to-seek (`WebSpeechDetailPlayer.tsx:124-141,165-190`). Esta entrega põe o picker na transcrição (D4) — sem toggle e sem `SpeechExcerptControls`/preview no v1 web.
- `buildSpeechCutFfmpegArgs` mapeia `0:v:0` fixo (`lib/speechCut.ts:176`) — falha para fonte só-áudio (radio/Áudio), que a taxonomia do C215 exige cortar (D2).
- `SpeechCut.speech` é relationship genérica (`SpeechCut.ts:71-81`) e nenhum campo novo é necessário ⇒ **sem migration** (D9).
- `originOf`/`CutSpeechRecord` só conhecem o vocabulário da Câmara (`lib/speechCut.ts:219-226,397-404`) ⇒ fala web degrada para "Fala · data" e link errado na biblioteca (D5).
- Os fallbacks/copy citam a Câmara em pontos que a fala web alcança: `buildSpeechCutFallbackMetadata` (`lib/speechCut.ts:137`), `speechCutStepLabels.resolving` (`:36`), `speechCutFailureMessage` (`:51-66,84-88`), fallback do diálogo (`SpeechCutDialog.tsx:385-388`), prompt do sistema (`speechCutMetadata.ts:40-46`) (D3/D6).
- O job lê o espelho privado pelo dono de I/O já existente (`downloadPrivateMediaToFile`/`resolvePrivateMediaStaticDir`, `privateMediaResponse.ts:43-51,219-249`; precedente `recordingJob.ts:112-166`) e o VM web já tem `mediaKind` derivado do mime (`speechViewModels.ts:516-519`) — a regra só precisa ser extraída para o dono (D2).
## Abordagem recomendada
```mermaid
flowchart LR
DET["detalhe web C216<br/>/acervo/internet/[id]"] --> PICK["transcrição = picker<br/>speechExcerptSelection + segmento marcado"]
PICK --> BTN["Cortar trecho"]
BTN --> DLG["SpeechCutDialog (o mesmo)<br/>speechSource = web"]
DLG --> ACT["saveSpeechCutForActor<br/>communicationCatalog + speechCutSourceKind"]
ACT -->|camara| JC["job: resolveSpeechVod → downloadSource<br/>(intocado byte a byte)"]
ACT -->|web| JW["job: downloadPrivateMediaToFile do espelho<br/>ffmpeg (áudio: sem 0:v:0 → AAC faststart)"]
JC --> MEDIA["media pública (MP4) + status/poll + retry"]
JW --> MEDIA
MEDIA --> LIB["biblioteca de cortes<br/>origem visível no card"]
MEDIA --> PUB["/corte/<id><br/>meta/crédito por origem"]
PURE["lib/speechCut (predicado + copy source-aware)<br/>lib/webSpeech (mime/título/crédito)"] -.-> ACT
PURE -.-> JW
PURE -.-> DLG
PURE -.-> LIB
PURE -.-> PUB
```
**Opções consideradas:** A) **estender o dono**: predicado puro de fonte em `lib/speechCut.ts`, branch `web` no job existente, copy source-aware com default Câmara e o picker reusando a máquina C166 no detalhe web | B) um pipeline de corte web gêmeo (`webSpeechCutJob`, `WebSpeechCutDialog`, `WebSpeechCutLibraryCard…`) | C) um resolvedor genérico de "fonte de corte" (interface `CutSource`) com adaptadores Câmara/espelho.
**Recomendação:** A — o corte já é a mesma unidade (`speechCut`, mesma biblioteca, mesmo `/corte/<id>`, mesmo job/retry/reaper); o que muda é só **como obter o arquivo de entrada** e **como nomear a origem**. O predicado puro no dono (`lib/speechCut.ts`) mantém a decisão em um único ponto; o branch do job é a variante mínima (baixar o espelho em vez do VOD) e os nomes de mime/crédito vão para `lib/webSpeech.ts` (dono da fonte web). `SpeechCut.speech` genérico (relação) evita migration.
**Rejeitadas:** B porque duplica transação, retry, reaper, poll, biblioteca, kill switch e página pública — a doutrina "edit the owner, don't twin" e a paridade pedida pela intenção não justificam um segundo pipeline; C porque abstrai para um 2º caso que é uma **variante de aquisição de arquivo** (baixar de outro lugar com mime diferente), não um domínio novo — a interface só adicionaria cerimônia sem volatilidade real.
### Decisões de engenharia
**D1 — A fonte do corte é um predicado no dono; o job ganha o branch `web` sem tocar no caminho Câmara.**
`Opções:` A — `speechCutSourceKind(speech): 'camara' | 'web' | null` em `lib/speechCut.ts` (`origin === 'web'` + `mirroredMedia` presente → `'web'`; `web` sem espelho → `null` (fail-closed, nunca cai no VOD da Câmara); senão `speechVodCoordinates` → `'camara'`; senão `null`) + `speechCutOriginKind(speech): 'camara' | 'web'` — o vocabulário de copy/prompt vem da fala, nunca do gate de aquisição, usado pelo gate da ação e pelo job | B — job web próprio | C — resolvedor genérico com adaptadores.
`Recomendação:` A — um predicado puro, unit-testável, que os dois lados (ação e job) consultam; o job reusa a transação, o retry, o reaper, os steps e o poll existentes.
`Rejeitadas:` B (twin de pipeline; duplicaria `withPayloadTransaction`/media pública/steps); C (interface sem 2º domínio real; a diferença é "de onde o input vem", não um serviço novo).
- `loadSpeechForCut` (`actions/speech.ts:92-102`) ganha `origin` e `mirroredMedia` no select; `createSpeechCut` troca o gate `speechVodCoordinates(speech)` (`:152`) por `speechCutSourceKind(speech)`; o dedupe in-flight e o schema do request ficam intocados.
- Recusa honesta por fonte: `origin === 'web'` sem espelho → **novo literal** `SPEECH_CUT_MIRROR_MISSING_MESSAGE = 'Esta fala não tem arquivo espelhado para cortar.'` (em `lib/schemas/speechCut.ts`, junto das outras mensagens de ação); Câmara sem VOD continua `SPEECH_VOD_INELIGIBLE_MESSAGE` (e2e pina "não tem trecho de vídeo", `campaignSpeechCut.e2e.spec.ts:240-242`).
- `speechCutJob.runSpeechCutJob` (`:116-198`): após carregar a fala, `const sourceKind = speechCutSourceKind(speech)`; `null` → falha com o literal por origem (`SPEECH_CUT_FAILURE_MIRROR_MISSING` na web, `SPEECH_VOD_INELIGIBLE_MESSAGE` na Câmara); `'web'` → **não chama** `resolveSpeechVod`/`downloadSource` (`:132-159`): carrega a linha privada por id (`payload.findByID({ collection: INTERNET_SPEECH_MEDIA_SLUG, id, depth: 0, overrideAccess: true })`, bypass documentado como os demais do job — o espelho é uma relação a 2 níveis, o depth 1 do corte só dá o id), baixa com `downloadPrivateMediaToFile({ media, staticDir: resolvePrivateMediaStaticDir(payload, INTERNET_SPEECH_MEDIA_SLUG), destinationPath })` e escolhe os args pelo mime (D2). Steps/transação/media pública/erros Câmara ficam idênticos.
- Espelho sumido no meio do job (arquivo/objeto ausente ou linha privada deletada) → falha fechado com `SPEECH_CUT_FAILURE_MIRROR_MISSING = 'O arquivo espelhado desta fala não está mais disponível.'` (novo literal em `lib/speechCut.ts`, sem citar Câmara).
- `retrySpeechCut`/reaper/poll não mudam: o retry reusa a mesma linha e o job redescobre a fonte pelo registro.
- **Câmara intocada byte a byte:** o branch `web` é novo; o fluxo `'camara'` continua exatamente o de hoje (`resolveSpeechVod`, `cachedUrls`, `downloadSource`, args de vídeo).
**D2 — ffmpeg para fonte só-áudio: variante de args + a regra de mime no dono web.**
`Opções:` A — `buildSpeechCutAudioFfmpegArgs` irmão puro em `lib/speechCut.ts` (sem `-map 0:v:0`; `-map 0:a:0` + AAC + `+faststart`; mesmo `-ss` antes do `-i` e `-t` de duração) e `webSpeechMediaKind(mimeType): 'video' | 'audio'` em `lib/webSpeech.ts`, reusada por `speechViewModels.mediaKindOf` e pelo job | B — parâmetro `mediaKind` na função de vídeo | C — transcode o áudio para um vídeo com capa gerada.
`Recomendação:` A — cada comando tem uma forma só, os pins de vídeo (`tests/unit/speechCut.unit.spec.ts:25-70`) ficam intocados e a regra "mime `audio/*` → áudio" passa a ter um dono único (hoje privada em `speechViewModels.ts:516-519`).
`Rejeitadas:` B (mistura dois comandos na mesma função e arrisca os pins); C (encode extra + capa inexistente; o produto quer o áudio como está).
- Corte de áudio continua **MP4** (`corte-<id>-<start>-<end>.mp4`, AAC, `+faststart`): é o contrato do card da biblioteca e da página `/corte/<id>` (D6) — nada de `.m4a`/segundo formato.
- `mediaKindOf` (`speechViewModels.ts:516-519`) passa a delegar: extrai o `mimeType` e chama `webSpeechMediaKind`; o resultado do detalhe web não muda (`media/` → `audio`, resto → `video`).
- O job escolhe os args por `webSpeechMediaKind(media.mimeType)`: `video` → `buildSpeechCutFfmpegArgs` (hoje), `audio` → `buildSpeechCutAudioFfmpegArgs` (novo).
**D3 — Copy source-aware, com default Câmara e bytes preservados.**
`Opções:` A — `source?: SpeechCutSourceKind` (default `'camara'`) nos builders puros + variante web dos literais, mantendo os literais da Câmara como default | B — dois conjuntos gêmeos de literais (`webSpeechCutCopy`) | C — não mexer na copy (hardcoded Câmara).
`Recomendação:` A — o parâmetro opcional preserva o comportamento atual (Câmara continua lendo exatamente os mesmos literais) e a web ganha copy honesta; a variante vive no mesmo dono, testada por unit.
`Rejeitadas:` B (twin de conhecimento que divergiria em silêncio); C (desonesto: fala de terceiro não pode dizer "na Câmara").
- `buildSpeechCutFallbackMetadata({ speechType, dateLabel, summary, source? })` (`lib/speechCut.ts:124-141`): Câmara inalterada; web → descrição `Trecho de ${type} de ${dateLabel}, publicado na internet.` (sem "na Câmara dos Deputados"); título igual (`Trecho de fala — <data>`), pois o tipo da linha web é nulo.
- `speechCutStepLabels` continua o record default (o select do admin em `SpeechCut.ts:33-36` não muda); novo `speechCutStepLabel(step, source)` com a variante web `resolving: 'Localizando o arquivo da fala'`; `speechCutStepStates(current, source = 'camara')` usa o helper (chamadas existentes seguem válidas).
- `speechCutFailureMessage({ error, step, source? })` (`:73-94`): mapeia `SPEECH_CUT_FAILURE_MIRROR_MISSING` para o próprio literal; com `source === 'web'`, o fallback do passo `resolving` vira `Não foi possível localizar o arquivo da fala.`; os demais fallbacks já são neutros; os causes nomeados da Câmara continuam no mapa. `toSpeechCutViewModel` deriva a fonte do próprio registro (`record.speech.origin === 'web' → 'web'`) para que o `failureMessage` de um corte web já saia honesto no poll/card.
- `SpeechCutDialog` ganha `speechSource?: SpeechCutSourceKind` (default `'camara'`; renomeado de `source` para não colidir com o estado da sugestão de IA): usa no fallback, nos labels dos steps e no texto do estado falhou; web → `Não foi possível preparar o corte desta fala agora. Tente novamente em alguns minutos.` (sem Câmara). `SpeechDetailPlayer` (Câmara) continua sem passar nada.
- `speechCutMetadata.suggestSpeechCutMetadata` ganha `source?`, `speechTitle?`, `platformLabel?`: Câmara mantém system prompt/prompt atuais; web troca a primeira frase do system prompt para "falas do deputado federal Jorge Solla publicadas na internet", compõe o restante do prompt com o MESMO tail (parâmetro só no vocabulário do âncora) e acrescenta as linhas `Título:`/`Plataforma:` ao prompt — sem inventar fatos, o fallback determinístico segue cobrindo key/timeout/saída vazia. `suggestSpeechCutMetadataForActor` passa os dados da fala (`origin`/`platform`/`title` entram no select).
- Copy administrativa: `SpeechCut.admin.description` e o comentário do topo da collection passam a citar as duas origens ("Câmara (CC BY 4.0) e falas espelhadas da internet") — descrição de admin não é schema (sem migration).
**D4 — Seleção/markup no detalhe web: a transcrição é o picker, fiel ao artefato.**
`Opções:` A — transcrição clicável acumulando o que o C216 já faz (seek) + a máquina pura C166 (`initialExcerptRange`/`extendRangeToSegment`, `speechExcerptSelection.ts:99-148`) marcando o trecho, e "Cortar trecho" abrindo o `SpeechCutDialog` existente | B — os dois botões da Câmara ("Selecionar trecho" + controles de início/fim + preview) no detalhe web | C — "Cortar trecho" abrindo o diálogo com um range default sem picker.
`Recomendação:` A — o artefato (`acervo-cortes-fonte-web-ui-design.html` cenas 01/02/06) mostra **um** botão primário e a transcrição já marcada; a geometria pura já existe e é testada; o clique duplo (seek + marca) mantém o comportamento C216.
`Rejeitadas:` B (o artefato não pede; `SpeechExcerptControls`/preview é maquinário da Câmara, mais superfície e mais risco de colisão); C (não escolhe o trecho — o job precisaria de um range arbitrário).
- `WebSpeechDetailPlayer`: estado `selection: ExcerptRange | null` começa **nulo** (nada marcado ao abrir a página); o picker é oferecido quando há espelho e `isExcerptSelectionAvailable(speech.durationSeconds, speech.segments)` (Q3: sem espelho não há gesto). Clique no segmento: `seekTo(segment.startSeconds)` (comportamento C216) **e** `setSelection((current) => current ? extendRangeToSegment(current, segments, index, duration) : selectSegmentRange(segments, index, duration))` — o primeiro clique marca a frase sozinha, os seguintes estendem como na Câmara. "Cortar trecho" abre com `selection ?? initialExcerptRange(segments, duration)`.
- Segmento marcado: `selected={segment.endSeconds > selection.startSeconds && segment.startSeconds < selection.endSeconds}` (mesmo overlap do player da Câmara, `SpeechDetailPlayer.tsx:871-874`); `CampaignTranscriptSegmentButton` ganha `selected?: boolean` (+ `data-selected` para o e2e) que **OR**a nas classes atuais de `active` (`CampaignTranscriptSegmentButton.tsx:40-44`) — dono único; Câmara e gravações não passam a prop e ficam intactas.
- "Cortar trecho" (`Button` default, primeiro da linha de ações, só com picker disponível) abre o `SpeechCutDialog` existente com `speechId={speech.id}`, `speechType={null}`, `dateLabel={speech.dateLabel}`, `summary={null}`, `range={selection}` e `speechSource="web"`; `onPublished` guarda o corte e o `SpeechCutResultCard` in-session renderiza como hoje (thumb YouTube é condicional e some na web).
- Hint sob os botões: `Selecione na transcrição as frases do trecho.` — o `ou ajuste início e fim na janela de corte` do artefato promete um ajuste que o web v1 não tem (o diálogo mostra o intervalo, não o edita); a copy fiel ao que existe fica sujeita à crítica do `designer` no fechamento; a linha da transcrição vira `Transcrição · clique nas frases` (desktop) / `Transcrição · toque para selecionar` (mobile, cena 06) — só no detalhe web.
- Ações do detalhe web ficam na ordem/ênfase do artefato: "Cortar trecho" (primário), "Baixar" (`outline`), "Abrir na origem" (ghost). Sem "Selecionar trecho"/toggle e sem preview nesta fase — **decisão barata com gatilho** (revisitar se a assessoria pedir preview do trecho ou na paridade C219).
- Sem segmentos: o picker não aparece (nada clicável); sem ASR mas com duração ≥ 5 s, o botão abre o diálogo com `0..5` (`initialExcerptRange` sem segmentos).
**D5 — Origem visível na biblioteca e no detalhe do corte, no dono atual.**
`Opções:` A — `CutSpeechRecord`/`SpeechCutOriginViewModel` ganham os campos da origem e `originOf` (`lib/speechCut.ts:397-404`) ramifica (Câmara = bytes; web = título/plataforma/href do detalhe web) | B — um módulo/VM gêmeo só para cortes web | C — resolver o título da fala no componente (fetch no RSC).
`Recomendação:` A — a biblioteca continua uma lista só; o ramo é do próprio `originOf`, com o href correto (`campaignInternetSpeechHref`) e o rótulo de plataforma do dono (`webSpeechPlatformLabel`).
`Rejeitadas:` B (twin do mesmo view model/lista); C (I/O no componente, sem camada testável e repetido por card).
- `CutSpeechRecord` (`lib/speechCut.ts:219-226`) ganha `origin?: 'camara' | 'web' | null`, `platform?: WebSpeechPlatform | null`, `title?: string | null`; `SpeechCutOriginViewModel` ganha `source: 'camara' | 'web'`, `platform: WebSpeechPlatform | null` (o pill deriva o rótulo do dono); `originOf` Câmara mantém `label`/`href` byte a byte e a web produz `href = campaignInternetSpeechHref(id)`, `platform = platform`, `label` = título de exibição da fala.
- Título de exibição da fala web: extrair `webSpeechTitle` (hoje privado em `speechViewModels.ts:460-461`, fallback `Fala da internet #<id>`) para `webSpeechDisplayTitle({ id, title })` em `lib/webSpeech.ts` e reusar nos dois lados — sem mudar bytes do VM atual.
- `SpeechCutLibraryCard` (cena 04/06): quando `origin.source === 'web'`, badge `Origem: <plataforma>` na linha do status (o mesmo prefixo em todos os breakpoints; o pill reusa `WebSpeechPlatformPill`) e a linha da origem vira link para o **detalhe web** com o título da fala + duração do corte (como hoje); Câmara sem badge e com o link de sempre.
- `SpeechCutOriginCard` (detalhe do corte): heading/link source-aware — Câmara `Discurso de origem`/`Ver fala no acervo` (bytes); web `Fala de origem`/`Ver fala na internet` com a plataforma visível.
- Sem alteração de loader: `loadSpeechCutAcervoPageData`/`findSpeechCutForActor` já leem depth 1 sem select (`speechCutPageData.ts:72-81`, `speechCutData.ts:19-27`) — os campos de origem já vêm no objeto; só o tipo e o mapper mudam.
**D6 — Página pública `/corte/<id>`: meta e crédito por origem (cenas 04/05).**
`Opções:` A — a mesma página ramifica pela origem da fala (`speech.origin === 'web'`), com meta `Fala na internet · <data> · <duração> · <plataforma>` e crédito `Fonte: <plataforma> · <canal>` (canal ausente → só a plataforma); fala de origem ausente (FK null) → omitir a linha de crédito | B — página pública separada para cortes web | C — manter o crédito da Câmara.
`Recomendação:` A — o link compartilhado é o mesmo contrato (a intenção confirmou a paridade); o que muda é conteúdo/crédito, derivado da fala — nunca afirmar a Câmara para terceiro nem para origem desconhecida.
`Rejeitadas:` B (quebraria o link `/corte/<id>` — STOP condition); C (desonesto).
- `corte/[id]/page.tsx`: a meta da linha (`:152-168`) passa a escolher por `speech?.origin === 'web'`; o link "Ver sessão no YouTube" continua condicionado a `youtubeUrl` (null na web); o rodapé (`:216-221`) usa o crédito do ramo. Câmara → bytes atuais (e2e pina).
- Crédito web por helper puro em `lib/webSpeech.ts`: `webSpeechCreditLabel({ platform, channel }): string | null` → `null` sem plataforma; senão `<label>` ou `<label> · <channel>`; a página renderiza `Fonte: <label>` e **omite a linha** quando o helper é null (fala de origem deletada / sem plataforma) — registrar como decisão barata + gatilho.
- Poster/OG: `youtubeCoverUrl` (`:34-37`) só existe para Câmara; a web cai no fallback global de OG (`resolveOgImage(null)`) — nada a mudar.
- O corte de áudio continua servido no `<video>` (`:136-145`): um MP4 AAC toca normalmente; a página não muda de estrutura.
**D7 — O VM do detalhe web expõe `durationSeconds` (o loader já o seleciona).**
`Opções:` A — `WebSpeechDetailViewModel` ganha `durationSeconds: number | null` (o `select` já traz `durationSeconds`, `speechPageData.ts:291-300`; o builder só não o copiava) | B — derivar do último segmento no client | C — endpoint/loader novo.
`Recomendação:` A — é o valor armazenado que o picker/o diálogo usam (`excerptSelectionDuration` já tem o fallback para o fim do último segmento); sem I/O novo.
`Rejeitadas:` B (regra duplicada no client; pode divergir do armazenado); C (cerimônia).
**D8 — Prova por camada: unit + int (fake ffmpeg) + CI com ffmpeg real; o e2e não dirige o job.**
`Opções:` A — unit para os puros (predicado, args de áudio, copy, origin VM, crédito, VM), int para o job com espelho em disco + fake ffmpeg (byte a byte, sem tocar na Câmara) e o bloco CI com ffmpeg real, e2e HTTP para markup/rota/crédito/badge sem rodar o job | B — e2e dirigindo o corte real | C — só unit.
`Recomendação:` A — segue o precedente do C167 (`tests/int/speechCut.int.spec.ts:432-450` fake; `:560-622` real no CI) e o e2e continua barato/estável (o C167 já deliberou não dirigir o job por HTTP).
`Rejeitadas:` B (flaky, depende de ffmpeg/bytes válidos no servidor e de timing do `after`); C (não prova branch/transação/proveniência).
- **Trigger do C216 vencido:** o simplify do C216 deixou D2 "bytes MP4/MP3 de fixture copiados entre os int specs de fala web" com gatilho "3º spec de fala web precisar dos bytes" — este é o 3º (`speechCut.int`). Extrair para `tests/helpers/webSpeechMediaFixture.ts` os bytes mágicos (MP4/MP3/JPEG) e um `createInternetSpeechMedia(payload, bytes, filename)` (o padrão de `webSpeechAcervo.int.spec.ts:48-65`), atualizando os imports dos 2 specs existentes (mecânico, sem mudança de comportamento).
**D9 — Sem migration, sem Consent, sem escrita multi-coleção nova; invariantes mantidas.**
`Opções:` A — nenhuma collection/campo novo (a relação `speechCut.speech` já é genérica; a origem se lê da fala) | B — persistir a origem (`origin`) no `SpeechCut` | C — collection/join nova.
`Recomendação:` A — nada do outcome exige schema novo; o job mantém a única transação existente (media pública + update do corte) e o retry segue por linha.
`Rejeitadas:` B (só teria valor se o crédito precisasse sobreviver à exclusão da fala — gatilho da decisão barata do crédito); C (twin).
- Access: gate `communicationCatalog` na página e nas ações; `user` + `overrideAccess: false` em toda leitura/escrita de ator; o job usa os bypasses **intencionais** já documentados (leitura do corte/fala/espelho e escrita de estado); a collection `SpeechCut` e `Speech.access` não mudam.
- Sem `Consent`/PII/segundo cadastro; identificadores em inglês, copy pt-BR.
### Decisões baratas (com gatilho de revisitação)
- **Sem toggle "Selecionar trecho" nem `SpeechExcerptControls`/preview no web v1:** o picker é a transcrição; revisitar se a assessoria pedir ajuste fino/preview no web ou na paridade C219.
- **Crédito omitido quando a fala de origem sumiu (FK `SET NULL`):** não afirmar a Câmara; se um dia o crédito precisar sobreviver à exclusão da fala, persistir a origem no próprio corte.
- **Corte de áudio continua MP4 no `<video>` da página pública:** sem capa/poster de áudio; revisitar se algum player tratar mal (o `preload="metadata"` já cobre).
- **Rótulo do passo `resolving` no admin segue "Localizando o trecho na Câmara":** admin-only e default do record; revisitar se um operador estranhar.
- **Heading/link do `SpeechCutOriginCard` web** ("Fala de origem"/"Ver fala na internet") é craft sujeito à crítica do `designer` no fechamento.
- **`webSpeechDisplayTitle` extraído para `lib/webSpeech`:** sem mudança de bytes; se a extração incomodar o diff, manter o fallback local ao `originOf` (mesmo texto).
### Componentes / mudanças
- **`src/lib/speechCut.ts`** (editar): `SpeechCutSourceKind`, `speechCutSourceKind`, `SPEECH_CUT_FAILURE_MIRROR_MISSING`, `buildSpeechCutAudioFfmpegArgs`, `speechCutStepLabel`/`speechCutStepStates(current, source?)`, `buildSpeechCutFallbackMetadata({…, source?})`, `speechCutFailureMessage({…, source?})`, `CutSpeechRecord`/`SpeechCutOriginViewModel` com os campos de origem, `originOf` ramificado e `toSpeechCutViewModel` derivando a fonte do registro. `buildSpeechCutFfmpegArgs` e o ramo Câmara intocados.
- **`src/lib/webSpeech.ts`** (editar): `webSpeechMediaKind`, `webSpeechDisplayTitle`, `webSpeechCreditLabel` (puros; o módulo segue sem importar `@/utilities`).
- **`src/lib/schemas/speechCut.ts`** (editar): `SPEECH_CUT_MIRROR_MISSING_MESSAGE`.
- **`src/utilities/speech/speechViewModels.ts`** (editar): `mediaKindOf` delega a `webSpeechMediaKind`; `webSpeechTitle` delega a `webSpeechDisplayTitle`; `WebSpeechDetailViewModel.durationSeconds`.
- **`src/utilities/speech/speechCutMetadata.ts`** (editar): prompt/prompt line source-aware (Câmara default).
- **`src/utilities/speech/speechCutJob.ts`** (editar): branch `'mirrored'` (fonte privada via `findByID` + `downloadPrivateMediaToFile` + args de áudio), falha honesta do espelho; Câmara intocado.
- **`src/app/(campaign)/campanha/actions/speech.ts`** (editar): select com `origin`/`platform`/`title`/`mirroredMedia`; gate por `speechCutSourceKind`; mensagem de recusa por origem; sugestão com os dados da fala.
- **`src/components/campaign/shared/CampaignTranscriptSegmentButton.tsx`** (editar): `selected?: boolean` + `data-selected` (OR nas classes de `active`).
- **`src/components/campaign/speech/WebSpeechDetailPlayer.tsx`** (editar): picker (estado + clique seek/marca), botão "Cortar trecho", hint do artefato, `SpeechCutDialog` com `source`, `SpeechCutResultCard` in-session, transcrição com `selected`.
- **`src/components/campaign/speech/SpeechCutDialog.tsx`** (editar): prop `speechSource?` (default Câmara) nos labels/fallback/erro.
- **`src/components/campaign/speech/SpeechCutLibraryCard.tsx`** / **`SpeechCutOriginCard.tsx`** (editar): badge de origem e heading/link source-aware.
- **`src/app/(frontend)/corte/[id]/page.tsx`** (editar): meta e crédito por origem; linha de crédito omitida sem origem.
- **`src/collections/SpeechCut.ts`** (editar): só `admin.description`/comentário (sem schema).
- **Testes** (editar/novos): `tests/unit/speechCut.unit.spec.ts`, `tests/unit/webSpeech.unit.spec.ts`, `tests/unit/speechViewModels.unit.spec.ts`, `tests/unit/speechCutDialog.unit.spec.tsx`, `tests/unit/speechExcerptSelection.unit.spec.ts` (se algum caso novo de integração com o picker surgir), `tests/int/speechCut.int.spec.ts`, `tests/e2e/campaignSpeechCut.e2e.spec.ts`, `tests/e2e/campaignSpeechAcervo.e2e.spec.ts`, `tests/helpers/webSpeechMediaFixture.ts` (trigger do C216 D2).
- **Migration:** nenhuma (D9) — se qualquer etapa concluir que precisa de schema, **parar** e registrar (STOP condition).
- **Access / Consent:** nada muda; o gate e os bypasses intencionais do job são os já existentes.
- **UI:** Impeccable C — port classe-a-classe do artefato (`acervo-cortes-fonte-web-ui-design.html`, cenas 01–06, já aprovado no gate); sem trigger (a) novo (o artefato cobre web video/áudio, seleção, diálogo, biblioteca e pública); a crítica final do `designer` (trigger c) fecha a entrega com `Design tier` no PR.
### Dados → forma (se aplicável)
- **N/A por decisão da intenção** (`acervo-cortes-fonte-web.md:53-55`): superfície de ação sobre a lista paginada existente; sem métricas de vaidade nem contadores. Nada a apresentar.
## Fases verificáveis
1. **Tracer / contrato + servidor + job (~1 dia).**
- `lib/speechCut.ts` (predicado, args de áudio, copy/step/failure source-aware, origin VM), `lib/webSpeech.ts` (mime/título/crédito), `lib/schemas/speechCut.ts` (recusa web), `speechViewModels` (delegações + `durationSeconds`), `speechCutMetadata`, `speechCutJob` (branch), `actions/speech.ts` (select/gate/sugestão).
- Prova: extensões de `tests/unit/speechCut.unit.spec.ts` (predicado, args áudio, fallback web sem Câmara, failure/step web, origin VM Câmara×web), `tests/unit/webSpeech.unit.spec.ts` (mime/título/crédito), `tests/unit/speechViewModels.unit.spec.ts` (`durationSeconds` + mediaKind), `tests/unit/speechCutDialog.unit.spec.tsx` (source nos labels/fallback); `tests/int/speechCut.int.spec.ts`: corte web com espelho em disco + fake ffmpeg publica e **não toca no fetch da Câmara** (`fetch` stub que lança), web sem espelho falha fechado (ação e job), áudio escolhe args sem `0:v:0` (via `FAKE_FFMPEG_LOG`), biblioteca com proveniência web×Câmara, pins da Câmara verdes; extração do `tests/helpers/webSpeechMediaFixture.ts` (C216 D2).
2. **UI port (~1–1,5 dia).**
- `WebSpeechDetailPlayer` com picker e "Cortar trecho"; `SpeechCutDialog` com `source`; `CampaignTranscriptSegmentButton` com `selected`; `SpeechCutLibraryCard` com badge; `SpeechCutOriginCard` source-aware; `corte/[id]/page.tsx` com meta/crédito por origem.
- Prova: e2e estendido — `campaignSpeechAcervo`: detalhe web com "Cortar trecho", transcrição marcada (`data-selected`, hint do artefato) e sem "Selecionar trecho"; `campaignSpeechCut`: corte web publicado (seed REST) com "Fala na internet", crédito `Fonte: <plataforma> · <canal>`, sem "Ver sessão no YouTube"/crédito da Câmara, card da biblioteca com a origem, e recusa honesta de fala web sem espelho; pins da Câmara (crédito `/corte`, "Selecionar trecho" no detalhe Câmara) intactos; `pnpm dev` manual contra as cenas 01–06.
3. **Gates / fechamento (~0,5 dia).**
- `pnpm gate:fast`; cascata cheia (`pnpm lint`, `pnpm format:check`, `tsc --noEmit`, `pnpm exec knip`, `pnpm check:cycles`, `pnpm test`, `pnpm build`); `pnpm test:e2e:affected`; conferir o manifest (`src/lib/speech*`, `src/components/campaign/speech`, `comunicacao`, `/corte` já cobrem; `src/lib/schemas` é risk prefix curado — sem entry nova); crítica do `designer` (trigger c) com `Design tier` no PR; entrada `docs/changelog/2026-09-24-c217.md`; `pnpm push` + PR.
Quota: ~2–3 dias eng, dentro do appetite herdado. Se apertar, os cortes são o badge mobile curto e o heading source-aware do `SpeechCutOriginCard` (craft; o título/link com href correto permanece) — **nunca** os pins/bytes da Câmara, o gate, o crédito honesto ou o picker.
## Rabbit holes / Não escopo (engenharia)
- Editor de corte/timeline/legendas/re-render; preview do trecho e ajuste fino no web (decisão barata com gatilho).
- Segundo player, segundo diálogo, segunda biblioteca ou segunda página de compartilhamento — tudo reusa os donos.
- Filtro por origem na biblioteca (questão 2 do produto: só o rótulo no card nesta fase) e resolução/download sob demanda de fala sem espelho (questão 3).
- Campo/collection novos no `SpeechCut` ou em `Speech` (migration desnecessária).
- Ingestão/espelhamento (C215), fonte/lista/detalhe além do gesto de corte (C216), skill (C218), paridade de filtros (C219).
- Publicação em rede social, galeria pública, embed/SDK; contadores de views/downloads.
- Mexer no VOD/player/cortes/cobertura/Sollinha da Câmara; abrir a web no player da Câmara ou vice-versa.
- Consent/PII/segundo cadastro; item de nav novo.
- Rodar o job real no e2e; tocar no `/api/media/file` (público por contrato só para o corte derivado, como na Câmara).
## Riscos e mitigação
- **Regressão da Câmara por estender donos compartilhados.** Defaults explicitamente Câmara (`source = 'camara'`, `speechCutStepLabels` intacto, `buildSpeechCutFfmpegArgs` intocado, `originOf` Câmara byte a byte); unit pins (`speechCut.unit.spec.ts:25-70,243-290`) e e2e pin do crédito (`campaignSpeechCut.e2e.spec.ts:152`) verdes sem adaptação; qualquer diferença de bytes no caminho Câmara é bug de PR.
- **Colisão seek × seleção no player web.** Um handler só (seek + máquina pura), estado único de `selection`, overlap na marcação igual ao da Câmara e nenhum toggle; `speechExcerptSelection` já é unit-testada (mínimo 5 s, sem teto, extensão por frase).
- **MP4 só-áudio em players/página pública.** AAC em MP4 com `+faststart`; o `<video>`/`<audio>` e o card continuam com o mesmo contrato `.mp4`; unit pina a variante de args e o bloco CI com ffmpeg real pode provar a reprodução do contêiner.
- **Crédito quando a fala de origem some (FK `SET NULL`).** A linha é omitida (nunca afirmar Câmara); teste unit do helper + e2e futuro se um dia o crédito persistir no corte (gatilho registrado).
- **Job lendo mídia privada a 2 níveis.** Leitura explícita `findByID` no `internetSpeechMedia` com bypass documentado (precedente `recordingJob.ts:112-166`); a transação existente não muda.
- **Fixture de mídia real no e2e/int.** Bytes mágicos válidos (padrão C215/C216) e fake ffmpeg no int; o e2e não dirige o job (só HTTP/markup), evitando flakiness de encode.
- **Concorrência de seed no e2e (OPS72).** Se rodar em paralelo, `--workers=1`; os fixtures de fala já namespaced por `marker`/UUID.
- **Fala web sem duração/segmentos.** O picker só aparece quando `isExcerptSelectionAvailable` e há espelho; sem segmentos não há clique (o diálogo abre com o default 0..5); a ação continua recusando range inválido (`resolveExcerptRange`) — fail-closed.
- **Prompt de IA para fala web.** Variação só na primeira frase + linha de origem/título; sem fatos novos; o fallback determinístico cobre key/timeout/saída vazia (int do fallback).
## Aceite de engenharia
- [ ] Aceite de produto da intenção coberto: "Cortar trecho" no detalhe web gera o corte do arquivo espelhado (sem VOD/plataforma); paridade de biblioteca (editar/apagar/retry/kill switch) e `/corte/<id>` unlisted com player/download/copiar/WhatsApp; janela de 5 s sem teto; crédito por origem (nunca Câmara para terceiro, omitido quando a origem sumiu).
- [ ] Guardrails: gate `communicationCatalog` em página/rotas/ações; `advisor`/`leader` fail-closed; fala web sem espelho sem gesto; sem segundo mecanismo de mídia/corte/compartilhamento; `/corte/<id>` continua não listado/`noindex`.
- [ ] Câmara congelada: args de vídeo, labels/fallback default, `originOf` Câmara, crédito `Fonte: Câmara dos Deputados · CC BY 4.0` e pins unit/int/e2e verdes sem adaptação.
- [ ] Sem migration (nenhuma collection/campo; `SpeechCut` só com descrição de admin) — se alguma surgir, o fluxo para e registra.
- [ ] Invariantes AGENTS/engineering-standards: `user` + `overrideAccess: false` nas leituras/ações; bypasses do job intencionais e documentados; mídia privada nunca pública (só o MP4 derivado); sem `Consent`/PII; copy pt-BR/identificadores em inglês; sem `openai/*` fora do `designer` (OPS123).
- [ ] Testes previstos: unit (predicado/args áudio/copy/failure/origin VM/crédito/`durationSeconds`), int (job espelho publica sem tocar na Câmara, web sem espelho falha fechado, variante áudio, proveniência na lista), e2e (detalhe com o gesto e a transcrição marcada, corte web na biblioteca e na página pública com crédito, recusa sem espelho) com o pin da Câmara intacto; trigger do C216 D2 extraído.
- [ ] Gates: `pnpm gate:fast` e a cascata cheia; `pnpm test:e2e:affected`; manifest conferido (sem entry nova); crítica do `designer` (trigger c) registrada como `Design tier`; `docs/changelog/2026-09-24-c217.md`; `pnpm push`/PR.
## Self-score de decision-quality
**4,5/5.**
1. **Decisões caras com rejeitadas:** as 9 decisões (predicado/job, ffmpeg áudio, copy, picker, biblioteca, página pública, VM, prova por camada, sem migration) têm opções, recomendação e rejeitadas ancoradas no código (`actions/speech.ts:150-152`, `lib/speechCut.ts:157-195,397-404`, `speechViewModels.ts:516-519`, `SpeechCut.ts:71-81`, `privateMediaResponse.ts:219-249`, precedente `recordingJob.ts:112-166`, deliberação do C167 sobre não dirigir o job no e2e).
2. **Cabe no appetite:** ~2–3 dias herdados; tracer no contrato+job antes do port de UI; sem infra/migration/segundo pipeline; reusa diálogo, biblioteca, página, transação, retry/reaper, máquina C166, dono de I/O privado e shells. Cortes de contenção nomeados.
3. **Rabbit holes nomeados:** preview/timeline no web, twin de pipeline/VM, filtro por origem, resolução sob demanda, campo novo no schema, publicação/galeria, mexer na Câmara.
4. **Depth check:** reusa `speechCutSourceKind` no dono, `downloadPrivateMediaToFile`/`resolvePrivateMediaStaticDir`, `speechExcerptSelection`, `CampaignTranscriptSegmentButton`, `SpeechCutDialog`, `originOf`, e extrai para lib pura só as regras de mídia/título/crédito da fonte web (e o fixture do trigger vencido do C216), sem gêmeos.
5. **Intenção preservada:** a engenharia não reescreveu o produto — mesmo diálogo, mesma biblioteca, mesmo link, mínimo 5 s sem teto, crédito honesto por origem; o que ficou de fora é exatamente o que a intenção cortou.
Perde 0,5 porque duas superfícies de copy (recusa/job do espelho e o heading/link do `SpeechCutOriginCard` web) são literais de engenharia propostos — honestos e sem Câmara, mas sujeitos ao ajuste da crítica do `designer` no fechamento; e o visual "ativo × selecionado" (mesmo tratamento no segmento) é uma simplificação deliberada com gatilho, não um desenho novo do artefato.
## Simplify — triage dos revisores (2026-09-24)
Dois revisores paralelos (estrutural + qualidade) sobre o diff. Aplicados na sessão (não reabrir): `speechCutOriginKind` para separar o vocabulário de copy/prompt do gate de aquisição (a sugestão de fala web sem espelho não cai mais no prompt da Câmara); `disableErrors` na leitura do espelho + catch da cópia do arquivo → o literal `SPEECH_CUT_FAILURE_MIRROR_MISSING` cobre linha e objeto sumidos (guard antes morto); prefixo de janela compartilhado entre os dois comandos ffmpeg (pins de vídeo verdes); prompt de IA composto por um tail único (Câmara byte a byte); `mediaIdOf` privado removido em favor de `relationshipId`; `segmentInExcerpt` no dono da geometria (os dois players não podem divergir); picker gateado por espelho + `durationSeconds` (o servidor recusa o que o client oferecia com fallback de segmento); hint só com segmentos; plataforma visível no `SpeechCutOriginCard`; `aria-pressed` no segmento marcado; unit novo `webSpeechDetailPlayer.unit.spec.tsx` provando o gesto (`data-selected`, extensão por frase, diálogo com a janela). Descartados: unificar os dois `createWebSpeech` dos e2e (atores diferentes: REST × Local API), dedup do JPEG do e2e C216 (o trigger do C216 D2 é dos int specs; o runner e2e não resolve o alias), guards defensivos do `openCutDialog`/`if (sourceUrl)` e um helper para o ternário da página pública (churn > ganho).
## Crítica final do designer (trigger c, 2026-09-24)
`certificado (tier primário — openai/gpt-5.6-sol)`, após três rodadas. A primeira crítica encontrou 8 bloqueantes; aplicados os de escopo (hierarquia do CTA — "Cortar trecho" primário, "Baixar" outline, "Abrir na origem" ghost; ativo × selecionado separados no segmento, com o trecho em reprodução neutro; anatomia da janela com trilho, alças e campos Início/Fim; kit produtivo no card da biblioteca com "Copiar link" primário e rótulos compactos "Baixar"/"WhatsApp" no mobile; copy honesta do estado sem espelho) e recapturada a evidência de "Processando" com a progressão real de passos (o label web do passo 1 não cita a Câmara). Dois bloqueantes foram devolvidos como desvios pré-existentes fora do diff proprietário, aceitos pelo designer: o **header público global** (`SiteHeader` compartilhado, não tocado) e o **FAB/nav inferior do shell** (chrome de todas as páginas `/campanha`). Registro no PR: `Design tier: openai/gpt-5.6-sol` + os desvios aceitos.
## Adiado com gatilho
- **Fixtures de fala web nos e2e** (triage do simplify, P1+P2): os dois `createWebSpeech` dos e2e divergem por ator (REST para o espelho × Local API para a fala) e o `JPEG_BYTES` do spec C216 segue copiado apesar de `WEB_SPEECH_JPEG_BYTES` (o runner e2e importa `src/` por caminho relativo). Gatilho: **3º spec e2e de fala web** precisar criar espelho/fala ou reusar os bytes — extrair o helper compartilhado então.

View file

@ -5,6 +5,7 @@ import {
SPEECH_CUT_FORBIDDEN_MESSAGE,
SPEECH_CUT_GENERIC_ERROR_MESSAGE,
SPEECH_CUT_INVALID_RANGE_MESSAGE,
SPEECH_CUT_MIRROR_MISSING_MESSAGE,
SPEECH_CUT_NOT_FOUND_MESSAGE,
SPEECH_CUT_RETRY_NOT_FAILED_MESSAGE,
SPEECH_CUT_SPEECH_NOT_FOUND_MESSAGE,
@ -27,6 +28,7 @@ export const POST = campaignJsonMutationRoute(
SPEECH_CUT_SPEECH_NOT_FOUND_MESSAGE,
SPEECH_CUT_NOT_FOUND_MESSAGE,
SPEECH_CUT_INVALID_RANGE_MESSAGE,
SPEECH_CUT_MIRROR_MISSING_MESSAGE,
SPEECH_CUT_RETRY_NOT_FAILED_MESSAGE,
SPEECH_VOD_INELIGIBLE_MESSAGE,
],

View file

@ -6,6 +6,7 @@ import { canReadCommunicationCatalog } from '@/lib/campaignRoles'
import {
SPEECH_CUT_FORBIDDEN_MESSAGE,
SPEECH_CUT_INVALID_RANGE_MESSAGE,
SPEECH_CUT_MIRROR_MISSING_MESSAGE,
SPEECH_CUT_NOT_FOUND_MESSAGE,
SPEECH_CUT_PUBLISH_NOT_READY_MESSAGE,
SPEECH_CUT_RETRY_NOT_FAILED_MESSAGE,
@ -28,9 +29,15 @@ import {
speechVodRequestSchema,
} from '@/lib/schemas/speechVod'
import { formatSpeechDate } from '@/lib/speechClock'
import { toSpeechCutViewModel, type SpeechCutViewModel } from '@/lib/speechCut'
import {
speechCutOriginKind,
speechCutSourceKind,
toSpeechCutViewModel,
type SpeechCutViewModel,
} from '@/lib/speechCut'
import { MIN_EXCERPT_SECONDS, normalizeExcerptRange } from '@/lib/speechExcerptSelection'
import { speechVodCoordinates, type SpeechVodResolution } from '@/lib/speechVod'
import { webSpeechPlatformLabel } from '@/lib/webSpeech'
import type { CampaignUser } from '@/payload-types'
import { getCampaignActionContext } from '@/utilities/campaignActionContext'
import { findSpeechCutForActor as loadSpeechCutForActor } from '@/utilities/speech/speechCutData'
@ -99,6 +106,11 @@ const speechCutSpeechSelect = {
eventId: true,
audioId: true,
excerptTMs: true,
// C217 — the web source: the origin vocabulary and the private mirror.
origin: true,
platform: true,
title: true,
mirroredMedia: true,
} as const
const loadSpeechForCut = async (payload: Payload, actor: CampaignUser, speechId: number) => {
@ -149,7 +161,13 @@ const createSpeechCut = async (
): 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)
if (!speechCutSourceKind(speech)) {
throw new Error(
speechCutOriginKind(speech) === 'web'
? SPEECH_CUT_MIRROR_MISSING_MESSAGE
: SPEECH_VOD_INELIGIBLE_MESSAGE,
)
}
const range = resolveExcerptRange(speech.durationSeconds, input.startSeconds, input.endSeconds)
@ -401,6 +419,11 @@ export const suggestSpeechCutMetadataForActor = async (input: {
speechType: speech.type ?? null,
dateLabel: formatSpeechDate(speech.speechAt),
summary: speech.summary ?? null,
// C217 — a web speech anchors the suggestion on its own title/platform;
// the vocabulary comes from the speech, never from the acquisition gate.
source: speechCutOriginKind(speech),
speechTitle: speech.title ?? null,
platformLabel: speech.platform ? webSpeechPlatformLabel(speech.platform) : null,
segments: segments.docs.map((segment) => ({
startSeconds: segment.startSeconds,
endSeconds: segment.endSeconds,

View file

@ -8,6 +8,7 @@ import {
import { formatSpeechClock, formatSpeechDate } from '@/lib/speechClock'
import { speechCutPublicPath } from '@/lib/speechCut'
import { speechCoverUrl } from '@/lib/speechVod'
import { webSpeechCreditLabel, webSpeechPlatformLabel } from '@/lib/webSpeech'
import type { Media, Speech, SpeechCut } from '@/payload-types'
import { getCachedDocumentById, isNotFoundError } from '@/utilities/documentReads'
import { getCachedGlobal } from '@/utilities/globalReads'
@ -17,7 +18,7 @@ import type { Metadata } from 'next'
import { notFound } from 'next/navigation'
const MAX_DESCRIPTION_LENGTH = 200
const CREDIT = 'Fonte: Câmara dos Deputados · CC BY 4.0'
const CAMARA_CREDIT = 'Fonte: Câmara dos Deputados · CC BY 4.0'
const parseCutId = (raw: string): number | null => {
if (!/^\d+$/.test(raw)) return null
@ -128,6 +129,15 @@ export default async function SpeechCutPage({ params }: { params: Promise<{ id:
cut.durationSeconds ?? Math.max(0, cut.endSeconds - cut.startSeconds),
)
// C217 — the credit follows the real origin: the Câmara bytes are untouched,
// a web speech names its platform/channel and an unknown origin (deleted
// speech) claims no source at all.
const isWeb = speech?.origin === 'web'
const webCredit = isWeb
? webSpeechCreditLabel({ platform: speech?.platform, channel: speech?.channel })
: null
const credit = !speech ? null : isWeb ? (webCredit ? `Fonte: ${webCredit}` : null) : CAMARA_CREDIT
return (
<>
<SiteHeader />
@ -150,21 +160,31 @@ export default async function SpeechCutPage({ params }: { params: Promise<{ id:
</h1>
<p className="mt-2 text-sm text-muted-foreground">
{speech?.type ?? 'Fala'}
{speech ? <> · {formatSpeechDate(speech.speechAt)}</> : null} · {durationLabel}
{youtubeUrl ? (
<span className="hidden sm:inline">
{' · '}
<a
href={youtubeUrl}
target="_blank"
rel="noopener noreferrer"
className="underline underline-offset-4"
>
Ver sessão no YouTube ↗
</a>
</span>
) : null}
{isWeb ? (
<>
Fala na internet
{speech ? <> · {formatSpeechDate(speech.speechAt)}</> : null} · {durationLabel}
{speech?.platform ? <> · {webSpeechPlatformLabel(speech.platform)}</> : null}
</>
) : (
<>
{speech?.type ?? 'Fala'}
{speech ? <> · {formatSpeechDate(speech.speechAt)}</> : null} · {durationLabel}
{youtubeUrl ? (
<span className="hidden sm:inline">
{' · '}
<a
href={youtubeUrl}
target="_blank"
rel="noopener noreferrer"
className="underline underline-offset-4"
>
Ver sessão no YouTube ↗
</a>
</span>
) : null}
</>
)}
</p>
<SpeechCutWhatsAppButton
@ -214,8 +234,8 @@ export default async function SpeechCutPage({ params }: { params: Promise<{ id:
) : null}
<footer className="mt-8 border-t border-border pt-4 text-xs leading-5 text-muted-foreground sm:mt-10">
<p>{CREDIT}</p>
<p className="mt-1">
{credit ? <p>{credit}</p> : null}
<p className={credit ? 'mt-1' : undefined}>
Página não listada — o link circula, mas não é indexada nem aparece em buscas.
</p>
</footer>

View file

@ -22,7 +22,9 @@ import { revalidateDocumentById } from '@/utilities/documents'
* 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.
* Câmara VOD (keep the CC BY credit) or, since C217, the private mirror of a
* web speech (credit follows the real origin) — the credit is never the
* Câmara's on third-party material.
*/
const STATUS_OPTIONS = SPEECH_CUT_STATUSES.map((value) => ({
@ -46,7 +48,7 @@ export const SpeechCut: CollectionConfig = {
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).',
'Cortes do acervo de falas publicados em /corte/<id>. Dados e vídeos da Câmara dos Deputados (CC BY 4.0) ou falas espelhadas da internet.',
},
access: {
create: canReadSpeech,

View file

@ -14,6 +14,8 @@ type SpeechCutWhatsAppButtonProps = {
title: string
variant?: 'default' | 'outline'
className?: string
/** C217 — the library card uses the artifact's short label ("WhatsApp"). */
compact?: boolean
}
/**
@ -26,6 +28,7 @@ export const SpeechCutWhatsAppButton = ({
title,
variant = 'default',
className,
compact = false,
}: SpeechCutWhatsAppButtonProps) => {
const share = buildSpeechCutShare({ title, url })
@ -33,7 +36,7 @@ export const SpeechCutWhatsAppButton = ({
<Button asChild variant={variant} className={cn('min-h-10', className)}>
<a href={share.whatsAppUrl} target="_blank" rel="noopener noreferrer">
<WhatsAppIcon data-icon="inline-start" />
Compartilhar no WhatsApp
{compact ? 'WhatsApp' : 'Compartilhar no WhatsApp'}
</a>
</Button>
)
@ -44,6 +47,8 @@ type SpeechCutDownloadButtonProps = {
url: string
filename?: string | null
className?: string
/** C217 — the library card uses the artifact's short label ("Baixar"). */
compact?: boolean
}
/** C176 — the MP4 download control, extracted for the same reason as the WhatsApp one. */
@ -51,11 +56,12 @@ export const SpeechCutDownloadButton = ({
url,
filename = null,
className,
compact = false,
}: SpeechCutDownloadButtonProps) => (
<Button asChild variant="outline" className={cn('min-h-10', className)}>
<a href={url} download={filename ?? ''}>
<DownloadIcon data-icon="inline-start" aria-hidden="true" />
Baixar arquivo (MP4)
{compact ? 'Baixar' : 'Baixar arquivo (MP4)'}
</a>
</Button>
)

View file

@ -19,46 +19,64 @@ type TranscriptSegment = {
* C200 — the clickable transcript line shared by the plain C199 list, the
* grouped C200 blocks and the C216 web detail: seeks the player to the segment
* and renders the search highlight. `variant` keeps the approved
* paddings/sizes without a twin — the web artifact marks the active segment
* with the red left border while the recordings keep the neutral fill.
* paddings/sizes without a twin.
*
* C217 — `selected` marks the cut picker's excerpt on the web detail (the
* artifact's red left border); on the web the playing segment keeps a neutral
* fill, so "picked" and "playing" never share the same treatment. The
* Câmara/recordings call sites never pass `selected`.
*/
export const CampaignTranscriptSegmentButton = ({
segment,
active,
selected = false,
variant = 'plain',
onSeek,
}: {
segment: TranscriptSegment
active: boolean
selected?: boolean
variant?: 'plain' | 'grouped' | 'web'
onSeek: (seconds: number) => void
}) => (
<button
type="button"
data-start-seconds={segment.startSeconds}
onClick={() => onSeek(segment.startSeconds)}
className={cn(
'grid w-full grid-cols-[3.25rem_1fr] gap-3 text-left transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
variant === 'grouped' ? 'px-3 py-2.5' : 'rounded-lg px-2 py-1.5',
active && (variant === 'web' ? 'border-l-2 border-primary bg-primary/[0.08]' : 'bg-muted'),
)}
>
<span
}) => {
const isWeb = variant === 'web'
const emphasized = isWeb ? selected : active || selected
return (
<button
type="button"
data-start-seconds={segment.startSeconds}
data-selected={selected || undefined}
aria-pressed={selected || undefined}
onClick={() => onSeek(segment.startSeconds)}
className={cn(
'text-xs tabular-nums',
variant === 'plain' && 'pt-0.5',
active ? 'text-primary' : 'text-muted-foreground',
'grid w-full grid-cols-[3.25rem_1fr] gap-3 text-left transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
variant === 'grouped' ? 'px-3 py-2.5' : 'rounded-lg px-2 py-1.5',
isWeb
? cn(
selected && 'border-l-2 border-primary bg-primary/[0.08]',
active && !selected && 'bg-muted',
)
: (active || selected) && 'bg-muted',
)}
>
{segment.startLabel}
</span>
<span
className={cn(
'text-sm text-foreground/90',
variant === 'grouped' ? 'leading-5' : 'leading-relaxed',
)}
>
<SpeechHighlightParts parts={segment.parts} />
</span>
</button>
)
<span
className={cn(
'text-xs tabular-nums',
variant === 'plain' && 'pt-0.5',
emphasized ? 'text-primary' : 'text-muted-foreground',
)}
>
{segment.startLabel}
</span>
<span
className={cn(
'text-sm text-foreground/90',
variant === 'grouped' ? 'leading-5' : 'leading-relaxed',
)}
>
<SpeechHighlightParts parts={segment.parts} />
</span>
</button>
)
}

View file

@ -29,6 +29,7 @@ import {
SPEECH_CUT_DESCRIPTION_MAX_LENGTH,
SPEECH_CUT_TITLE_MAX_LENGTH,
speechCutStepStates,
type SpeechCutSourceKind,
type SpeechCutStep,
type SpeechCutViewModel,
} from '@/lib/speechCut'
@ -50,6 +51,10 @@ type SpeechCutDialogProps = {
dateLabel: string
summary: string | null
range: ExcerptRange
/** C217 — the speech duration positions the range track (scene 02). */
speechDurationSeconds?: number | null
/** C217 — default Câmara; `web` renames the resolving step and drops the credit. */
speechSource?: SpeechCutSourceKind
onPublished: (cut: SpeechCutViewModel) => void
}
@ -77,11 +82,13 @@ export const SpeechCutDialog = ({
dateLabel,
summary,
range,
speechDurationSeconds = null,
speechSource = 'camara',
onPublished,
}: SpeechCutDialogProps) => {
const fallback = useMemo(
() => buildSpeechCutFallbackMetadata({ speechType, dateLabel, summary }),
[speechType, dateLabel, summary],
() => buildSpeechCutFallbackMetadata({ speechType, dateLabel, summary, source: speechSource }),
[speechType, dateLabel, summary, speechSource],
)
const [phase, setPhase] = useState<CutPhase>('form')
@ -101,6 +108,14 @@ export const SpeechCutDialog = ({
}, [phase])
const durationSeconds = range.endSeconds - range.startSeconds
// The track of the window inside the whole speech (scene 02): handles at the
// picked edges, only when the speech duration is known.
const trackDuration =
speechDurationSeconds && speechDurationSeconds > 0 ? speechDurationSeconds : null
const percentOf = (seconds: number): number =>
trackDuration ? Math.min(100, Math.max(0, (seconds / trackDuration) * 100)) : 0
const startPercent = percentOf(range.startSeconds)
const endPercent = trackDuration ? 100 - percentOf(range.endSeconds) : 0
// 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
@ -212,7 +227,7 @@ export const SpeechCutDialog = ({
}
}
const steps = speechCutStepStates(step)
const steps = speechCutStepStates(step, speechSource)
return (
<Dialog open={open} onOpenChange={onOpenChange}>
@ -242,6 +257,40 @@ export const SpeechCutDialog = ({
da fala
</span>
</div>
{trackDuration ? (
<div className="relative mt-4 h-2 rounded-full bg-stone-200" aria-hidden="true">
<div
className="absolute h-2 rounded-full bg-primary"
style={{ left: `${startPercent}%`, right: `${endPercent}%` }}
/>
<span
className="absolute -top-1.5 size-5 -translate-x-1/2 rounded-full border-2 border-primary bg-white"
style={{ left: `${startPercent}%` }}
/>
<span
className="absolute -top-1.5 size-5 translate-x-1/2 rounded-full border-2 border-primary bg-white"
style={{ right: `${endPercent}%` }}
/>
</div>
) : null}
<div className="mt-3 grid grid-cols-2 gap-3">
<label className="text-xs font-medium">
Início
<Input
className="mt-1 font-mono"
value={formatSpeechClock(range.startSeconds)}
readOnly
/>
</label>
<label className="text-xs font-medium">
Fim
<Input
className="mt-1 font-mono"
value={formatSpeechClock(range.endSeconds)}
readOnly
/>
</label>
</div>
</div>
<Field>
@ -384,7 +433,9 @@ export const SpeechCutDialog = ({
/>
<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.'}
(speechSource === 'web'
? 'Não foi possível preparar o corte desta fala agora. Tente novamente em alguns minutos.'
: '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">

View file

@ -2,12 +2,17 @@ import Link from 'next/link'
import { SpeechCutLibraryCardActions } from '@/components/campaign/speech/SpeechCutLibraryCardActions'
import { SpeechCutStatusBadge } from '@/components/campaign/speech/SpeechCutStatusBadge'
import { WebSpeechPlatformPill } from '@/components/campaign/speech/WebSpeechPlatformPill'
import type { SpeechCutLibraryItemViewModel } from '@/lib/speechCut'
import { webSpeechPlatformLabel } from '@/lib/webSpeech'
/**
* C168 — one row of the cut library: status + creation date, the editable title,
* the origin speech (label + link to the acervo) with the stored duration, and
* the actions (open, copy when live, retry a failure, delete) as a client island.
*
* C217 — a cut of a web speech shows the platform ("Origem: YouTube") and links
* back to its own detail; the Câmara row is byte for byte the same.
*/
export const SpeechCutLibraryCard = ({ cut }: { cut: SpeechCutLibraryItemViewModel }) => (
<article
@ -19,6 +24,12 @@ export const SpeechCutLibraryCard = ({ cut }: { cut: SpeechCutLibraryItemViewMod
>
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<SpeechCutStatusBadge status={cut.status} />
{cut.origin?.source === 'web' && cut.origin.platform ? (
<WebSpeechPlatformPill
platform={cut.origin.platform}
label={`Origem: ${webSpeechPlatformLabel(cut.origin.platform)}`}
/>
) : null}
{cut.createdAtLabel ? (
<span className="text-xs text-muted-foreground">Criado em {cut.createdAtLabel}</span>
) : null}

View file

@ -2,10 +2,15 @@
import { PlayIcon } from 'lucide-react'
import Link from 'next/link'
import { useEffect, useState } from 'react'
import { SpeechCutDeleteDialog } from '@/components/campaign/speech/SpeechCutDeleteDialog'
import { SpeechCutRetryButton } from '@/components/campaign/speech/SpeechCutRetryButton'
import { CopyLinkButton } from '@/components/CopyLinkButton'
import {
SpeechCutDownloadButton,
SpeechCutWhatsAppButton,
} from '@/components/SpeechCutShareActions'
import { Button } from '@/components/ui/button'
import { campaignSpeechCutDetailHref } from '@/lib/campaignPaths'
import type { SpeechCutLibraryItemViewModel, SpeechCutStatus } from '@/lib/speechCut'
@ -14,29 +19,52 @@ import type { SpeechCutLibraryItemViewModel, SpeechCutStatus } from '@/lib/speec
* C183 — the library card's action row: the recovery of a failed cut comes
* first and full width on mobile (Cena D), then open/copy, with delete
* isolated at the right (desktop) or full width (mobile published).
*
* C217 — a published cut leads with the productive kit the artifact shows
* (copy link primary, download, WhatsApp, scene 04/06); opening the cut and
* deleting stay as the management row below.
*/
const deleteTriggerClassName = (status: SpeechCutStatus): string =>
status === 'published' ? 'w-full sm:ml-auto sm:w-auto' : 'flex-1 sm:ml-auto sm:flex-none'
export const SpeechCutLibraryCardActions = ({ cut }: { cut: SpeechCutLibraryItemViewModel }) => (
<div className="mt-3 flex flex-wrap items-center gap-2">
{cut.status === 'failed' ? (
<SpeechCutRetryButton cutId={cut.id} className="min-h-11 w-full sm:min-h-10 sm:w-auto" />
) : null}
<Button asChild variant="outline" className="min-h-10 flex-1 sm:flex-none">
<Link href={campaignSpeechCutDetailHref(cut.id)}>
<PlayIcon data-icon="inline-start" aria-hidden="true" />
Abrir corte
</Link>
</Button>
{cut.status === 'published' ? (
<CopyLinkButton url={cut.publicPath} variant="ghost" className="flex-1 sm:flex-none" />
) : null}
<SpeechCutDeleteDialog
cutId={cut.id}
status={cut.status}
publicPath={cut.publicPath}
triggerClassName={deleteTriggerClassName(cut.status)}
/>
</div>
)
export const SpeechCutLibraryCardActions = ({ cut }: { cut: SpeechCutLibraryItemViewModel }) => {
const [absoluteUrl, setAbsoluteUrl] = useState('')
useEffect(() => {
setAbsoluteUrl(new URL(cut.publicPath, window.location.origin).toString())
}, [cut.publicPath])
const shareUrl = absoluteUrl || cut.publicPath
return (
<div className="mt-3 flex flex-col gap-2">
{cut.status === 'published' ? (
<div className="grid grid-cols-2 gap-2 sm:flex sm:flex-wrap sm:items-center">
<CopyLinkButton url={shareUrl} variant="default" className="col-span-2 sm:col-auto" />
{cut.mediaUrl ? (
<SpeechCutDownloadButton url={cut.mediaUrl} filename={cut.mediaFilename} compact />
) : null}
<SpeechCutWhatsAppButton url={shareUrl} title={cut.title} variant="outline" compact />
</div>
) : null}
<div className="flex flex-wrap items-center gap-2">
{cut.status === 'failed' ? (
<SpeechCutRetryButton cutId={cut.id} className="min-h-11 w-full sm:min-h-10 sm:w-auto" />
) : null}
<Button asChild variant="outline" className="min-h-10 flex-1 sm:flex-none">
<Link href={campaignSpeechCutDetailHref(cut.id)}>
<PlayIcon data-icon="inline-start" aria-hidden="true" />
Abrir corte
</Link>
</Button>
<SpeechCutDeleteDialog
cutId={cut.id}
status={cut.status}
publicPath={cut.publicPath}
triggerClassName={deleteTriggerClassName(cut.status)}
/>
</div>
</div>
)
}

View file

@ -2,32 +2,46 @@ import Link from 'next/link'
import { formatSpeechClock } from '@/lib/speechClock'
import type { SpeechCutLibraryItemViewModel } from '@/lib/speechCut'
import { webSpeechPlatformLabel } from '@/lib/webSpeech'
/**
* C168 — context of the cut: which speech it came from and the stored excerpt
* span, with the link back to the acervo. Degrades honestly when the source
* speech was deleted (the FK is `SET NULL`) — the cut itself stays usable.
*
* C217 — a web origin reads "Fala de origem" and links to its own detail.
*/
export const SpeechCutOriginCard = ({ cut }: { cut: SpeechCutLibraryItemViewModel }) => (
<section className="rounded-xl border p-4">
<h2 className="text-xs tracking-wide text-muted-foreground uppercase">Discurso de origem</h2>
{cut.origin ? (
<>
<p className="mt-1.5 text-sm text-foreground/90">{cut.origin.label}</p>
<p className="mt-1 text-xs text-muted-foreground">
Trecho {formatSpeechClock(cut.startSeconds)}–{formatSpeechClock(cut.endSeconds)} da fala
export const SpeechCutOriginCard = ({ cut }: { cut: SpeechCutLibraryItemViewModel }) => {
const isWeb = cut.origin?.source === 'web'
return (
<section className="rounded-xl border p-4">
<h2 className="text-xs tracking-wide text-muted-foreground uppercase">
{isWeb ? 'Fala de origem' : 'Discurso de origem'}
</h2>
{cut.origin ? (
<>
<p className="mt-1.5 text-sm text-foreground/90">{cut.origin.label}</p>
{isWeb && cut.origin.platform ? (
<p className="mt-1 text-xs font-medium text-muted-foreground">
{webSpeechPlatformLabel(cut.origin.platform)}
</p>
) : null}
<p className="mt-1 text-xs text-muted-foreground">
Trecho {formatSpeechClock(cut.startSeconds)}–{formatSpeechClock(cut.endSeconds)} da fala
</p>
<Link
href={cut.origin.href}
className="mt-2 inline-flex min-h-11 items-center text-sm underline underline-offset-4 hover:text-foreground"
>
{isWeb ? 'Ver fala na internet' : 'Ver fala no acervo'}
</Link>
</>
) : (
<p className="mt-1.5 text-sm text-muted-foreground">
A fala de origem não está mais disponível. O corte continua salvo e compartilhável.
</p>
<Link
href={cut.origin.href}
className="mt-2 inline-flex min-h-11 items-center text-sm underline underline-offset-4 hover:text-foreground"
>
Ver fala no acervo
</Link>
</>
) : (
<p className="mt-1.5 text-sm text-muted-foreground">
A fala de origem não está mais disponível. O corte continua salvo e compartilhável.
</p>
)}
</section>
)
)}
</section>
)
}

View file

@ -28,6 +28,7 @@ import {
initialExcerptRange,
isExcerptSelectionAvailable,
MIN_EXCERPT_SECONDS,
segmentInExcerpt,
SPEECH_EXCERPT_REQUEST_EVENT,
type ExcerptPreviewPhase,
type ExcerptRange,
@ -839,6 +840,7 @@ export const SpeechDetailPlayer = ({
dateLabel={speechDateLabel}
summary={speechSummary}
range={selection}
speechDurationSeconds={durationSeconds}
onPublished={setPublishedCut}
/>
) : null}
@ -868,10 +870,7 @@ export const SpeechDetailPlayer = ({
<ol className="mt-2 space-y-0.5">
{segments.map((segment, index) => {
const active = activeStart === segment.startSeconds
const inSelection =
selection !== null &&
segment.endSeconds > selection.startSeconds &&
segment.startSeconds < selection.endSeconds
const inSelection = selection !== null && segmentInExcerpt(segment, selection)
return (
<li key={segment.startSeconds}>
<button

View file

@ -1,11 +1,23 @@
'use client'
import { DownloadIcon, ExternalLinkIcon } from 'lucide-react'
import { DownloadIcon, ExternalLinkIcon, ScissorsIcon } from 'lucide-react'
import { useRef, useState } from 'react'
import { CampaignTranscriptSegmentButton } from '@/components/campaign/shared/CampaignTranscriptSegmentButton'
import { SpeechCutDialog } from '@/components/campaign/speech/SpeechCutDialog'
import { SpeechCutResultCard } from '@/components/campaign/speech/SpeechCutResultCard'
import { WebSpeechPlatformPill } from '@/components/campaign/speech/WebSpeechPlatformPill'
import { Button } from '@/components/ui/button'
import type { SpeechCutViewModel } from '@/lib/speechCut'
import {
excerptSelectionDuration,
extendRangeToSegment,
initialExcerptRange,
isExcerptSelectionAvailable,
segmentInExcerpt,
selectSegmentRange,
type ExcerptRange,
} from '@/lib/speechExcerptSelection'
import type { WebSpeechDetailViewModel } from '@/utilities/speech/speechViewModels'
type WebSpeechDetailPlayerProps = {
@ -19,12 +31,29 @@ type WebSpeechDetailPlayerProps = {
* media (native video or audio control — the artifact's audio variant), the
* download, "Abrir na origem", the origin attribution and the clickable
* transcript that seeks the player. Playback itself is browser territory.
*
* C217 — the transcript is also the cut picker: clicking a phrase seeks the
* player and marks/extends the excerpt, and "Cortar trecho" opens the existing
* cut dialog with the picked window (design scenes 01/06).
*/
export const WebSpeechDetailPlayer = ({ speech, initialSeconds }: WebSpeechDetailPlayerProps) => {
const mediaRef = useRef<HTMLMediaElement | null>(null)
const [activeStart, setActiveStart] = useState<number | null>(
initialSeconds ?? speech.segments[0]?.startSeconds ?? null,
)
// C217 — null until the first phrase click: nothing is marked on page load.
const [selection, setSelection] = useState<ExcerptRange | null>(null)
const [cutDialogOpen, setCutDialogOpen] = useState(false)
const [publishedCut, setPublishedCut] = useState<SpeechCutViewModel | null>(null)
const selectionDuration = excerptSelectionDuration(speech.durationSeconds, speech.segments)
// The server validates the window against the stored duration, so the picker
// only exists when both the mirror and that duration do (no segment fallback
// the action would refuse).
const selectionAvailable =
speech.fileHref !== null &&
speech.durationSeconds !== null &&
isExcerptSelectionAvailable(speech.durationSeconds, speech.segments)
const seekTo = (seconds: number) => {
const media = mediaRef.current
@ -52,6 +81,28 @@ export const WebSpeechDetailPlayer = ({ speech, initialSeconds }: WebSpeechDetai
}
}
/** C217 — one gesture: the click seeks (C216) and marks/extends the excerpt. */
const onSegmentActivate = (index: number) => {
const segment = speech.segments[index]
if (!segment) return
seekTo(segment.startSeconds)
if (!selectionAvailable || selectionDuration === null) return
setSelection((current) =>
current
? extendRangeToSegment(current, speech.segments, index, selectionDuration)
: selectSegmentRange(speech.segments, index, selectionDuration),
)
}
/** Opens the dialog on the picked window, defaulting to the first phrase. */
const openCutDialog = () => {
if (!selectionAvailable || selectionDuration === null) return
const range = selection ?? initialExcerptRange(speech.segments, selectionDuration)
if (!range) return
setSelection(range)
setCutDialogOpen(true)
}
const setMediaRef = (element: HTMLVideoElement | HTMLAudioElement | null) => {
mediaRef.current = element
}
@ -117,78 +168,139 @@ export const WebSpeechDetailPlayer = ({ speech, initialSeconds }: WebSpeechDetai
)
return (
<div className="grid gap-7 lg:grid-cols-[1.2fr_.8fr]">
<div className="min-w-0">
{mediaBox}
<>
<div className="grid gap-7 lg:grid-cols-[1.2fr_.8fr]">
<div className="min-w-0">
{mediaBox}
<div className="mt-3 flex flex-wrap gap-2 max-md:grid max-md:grid-cols-2">
{speech.downloadHref ? (
<Button asChild className="min-h-11">
<a href={speech.downloadHref}>
<DownloadIcon data-icon="inline-start" aria-hidden="true" />
Baixar
</a>
</Button>
) : null}
{speech.sourceUrl ? (
<Button asChild variant="outline" className="min-h-11">
<a href={speech.sourceUrl} target="_blank" rel="noreferrer">
<ExternalLinkIcon data-icon="inline-start" aria-hidden="true" />
Abrir na origem
</a>
</Button>
<div className="mt-3 flex flex-wrap gap-2 max-md:grid max-md:grid-cols-2">
{selectionAvailable ? (
<Button
type="button"
className="min-h-11 max-md:col-span-2"
data-slot="web-speech-cut-open"
onClick={openCutDialog}
>
<ScissorsIcon data-icon="inline-start" aria-hidden="true" />
Cortar trecho
</Button>
) : null}
{speech.downloadHref ? (
// The cut CTA leads when the picker exists; the download steps
// back so the two primary-red controls never compete (scene 01).
<Button
asChild
variant={selectionAvailable ? 'outline' : 'default'}
className="min-h-11"
>
<a href={speech.downloadHref}>
<DownloadIcon data-icon="inline-start" aria-hidden="true" />
Baixar
</a>
</Button>
) : null}
{speech.sourceUrl ? (
<Button
asChild
variant={selectionAvailable ? 'ghost' : 'outline'}
className="min-h-11"
>
<a href={speech.sourceUrl} target="_blank" rel="noreferrer">
<ExternalLinkIcon data-icon="inline-start" aria-hidden="true" />
Abrir na origem
</a>
</Button>
) : null}
</div>
{selectionAvailable && speech.segments.length > 0 ? (
<p className="mt-2 text-xs text-muted-foreground">
Selecione na transcrição as frases do trecho.
</p>
) : null}
{/* Mobile shows the attribution inline under the title (scene 05). */}
<div className="mt-4 rounded-lg border border-border bg-muted/40 p-4 max-md:hidden">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Origem
</p>
<p className="mt-1 text-sm font-medium">
{[speech.platform.label, speech.channel].filter(Boolean).join(' · ')}
</p>
<p className="mt-1 text-xs text-muted-foreground">Publicado em {speech.dateLabel}</p>
</div>
</div>
{/* Mobile shows the attribution inline under the title (scene 05). */}
<div className="mt-4 rounded-lg border border-border bg-muted/40 p-4 max-md:hidden">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Origem
<div className="min-w-0">
<WebSpeechPlatformPill platform={speech.platform.value} label={speech.platform.label} />
<h1 className="mt-3 text-2xl font-semibold tracking-tight">{speech.title}</h1>
{metaLine ? (
<p className="mt-2 text-sm text-muted-foreground max-md:hidden">{metaLine}</p>
) : null}
<p className="mt-2 text-xs leading-5 text-muted-foreground md:hidden">
{[speech.platform.label, speech.channel, speech.dateLabel].filter(Boolean).join(' · ')}
</p>
<p className="mt-1 text-sm font-medium">
{[speech.platform.label, speech.channel].filter(Boolean).join(' · ')}
</p>
<p className="mt-1 text-xs text-muted-foreground">Publicado em {speech.dateLabel}</p>
{speech.segments.length > 0 ? (
<section aria-label="Transcrição" className="mt-6">
<div className="flex items-center justify-between">
<p className="text-xs font-medium tracking-wide text-muted-foreground uppercase">
Transcrição
</p>
<span className="text-[11px] text-muted-foreground">
{selectionAvailable ? (
<>
<span className="max-md:hidden">Clique nas frases</span>
<span className="md:hidden">Toque para selecionar</span>
</>
) : (
'Clique para posicionar'
)}
</span>
</div>
<ol className="mt-2 max-h-[28rem] space-y-0.5 overflow-y-auto">
{speech.segments.map((segment, index) => {
const inSelection = selection !== null && segmentInExcerpt(segment, selection)
return (
<li key={`${segment.startSeconds}-${segment.startLabel}`}>
<CampaignTranscriptSegmentButton
segment={segment}
active={activeStart === segment.startSeconds}
selected={inSelection}
variant="web"
onSeek={() => onSegmentActivate(index)}
/>
</li>
)
})}
</ol>
</section>
) : (
<p className="mt-6 text-xs text-muted-foreground">
{speech.fileHref
? 'A transcrição desta fala está vazia — o arquivo continua disponível para download.'
: 'A transcrição desta fala está vazia.'}
</p>
)}
</div>
</div>
<div className="min-w-0">
<WebSpeechPlatformPill platform={speech.platform.value} label={speech.platform.label} />
<h1 className="mt-3 text-2xl font-semibold tracking-tight">{speech.title}</h1>
{metaLine ? (
<p className="mt-2 text-sm text-muted-foreground max-md:hidden">{metaLine}</p>
) : null}
<p className="mt-2 text-xs leading-5 text-muted-foreground md:hidden">
{[speech.platform.label, speech.channel, speech.dateLabel].filter(Boolean).join(' · ')}
</p>
{publishedCut ? <SpeechCutResultCard cut={publishedCut} /> : null}
{speech.segments.length > 0 ? (
<section aria-label="Transcrição" className="mt-6">
<div className="flex items-center justify-between">
<p className="text-xs font-medium tracking-wide text-muted-foreground uppercase">
Transcrição
</p>
<span className="text-[11px] text-muted-foreground">Clique para posicionar</span>
</div>
<ol className="mt-2 max-h-[28rem] space-y-0.5 overflow-y-auto">
{speech.segments.map((segment) => (
<li key={`${segment.startSeconds}-${segment.startLabel}`}>
<CampaignTranscriptSegmentButton
segment={segment}
active={activeStart === segment.startSeconds}
variant="web"
onSeek={seekTo}
/>
</li>
))}
</ol>
</section>
) : (
<p className="mt-6 text-xs text-muted-foreground">
A transcrição desta fala está vazia — o arquivo continua disponível para download.
</p>
)}
</div>
</div>
{selection ? (
<SpeechCutDialog
open={cutDialogOpen}
onOpenChange={setCutDialogOpen}
speechId={speech.id}
speechType={null}
dateLabel={speech.dateLabel}
summary={null}
range={selection}
speechDurationSeconds={speech.durationSeconds}
speechSource="web"
onPublished={setPublishedCut}
/>
) : null}
</>
)
}

View file

@ -10,6 +10,9 @@ 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.'
/** C217 — a web speech without the mirrored file cannot be cut (question 3). */
export const SPEECH_CUT_MIRROR_MISSING_MESSAGE = 'Esta fala não tem arquivo espelhado para cortar.'
/** The cut does not exist (or the actor cannot read it). */
export const SPEECH_CUT_NOT_FOUND_MESSAGE = 'Corte não encontrado.'

View file

@ -4,12 +4,18 @@
* [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 { CAMPAIGN_COMMUNICATION_ACERVO } from '@/lib/campaignPaths'
import { CAMPAIGN_COMMUNICATION_ACERVO, campaignInternetSpeechHref } from '@/lib/campaignPaths'
import { formatBahiaCivilDate } from '@/lib/campaignTime'
import { buildWhatsAppTextShareUrl } from '@/lib/phone'
import { relationshipId } from '@/lib/relationship'
import { SPEECH_VOD_INELIGIBLE_MESSAGE } from '@/lib/schemas/speechVod'
import { formatSpeechDate, formatSpeechSpan } from '@/lib/speechClock'
import { parseYoutubeVideoId } from '@/lib/speechVod'
import {
parseYoutubeVideoId,
speechVodCoordinates,
type SpeechVodCoordinatesSource,
} from '@/lib/speechVod'
import { webSpeechDisplayTitle, type WebSpeechPlatform } from '@/lib/webSpeech'
export const SPEECH_CUT_STATUSES = ['processing', 'published', 'unpublished', 'failed'] as const
export type SpeechCutStatus = (typeof SPEECH_CUT_STATUSES)[number]
@ -39,9 +45,49 @@ export const speechCutStepLabels: Record<SpeechCutStep, string> = {
publishing: 'Publicando a página do corte',
}
/** C217 — the web speech has no Câmara to locate; only this step renames. */
const WEB_SPEECH_CUT_STEP_LABELS: Record<SpeechCutStep, string> = {
...speechCutStepLabels,
resolving: 'Localizando o arquivo da fala',
}
/**
* C217 — which acquisition path a cut uses: the Câmara VOD or the private
* mirrored file of a web speech. The predicate is the single owner of the
* decision (the action refuses and the job branches through it); `null` means
* the speech cannot be cut at all — a web speech without a mirror never falls
* back to the Câmara VOD.
*
* `speechCutOriginKind` is the narrower vocabulary question ("whose speech is
* this?") that the copy/prompt paths ask: a web speech without a mirror is
* still a web speech, never a Câmara one.
*/
export type SpeechCutSourceKind = 'camara' | 'web'
type SpeechCutSourceRecord = SpeechVodCoordinatesSource & {
origin?: string | null
mirroredMedia?: number | { id: number } | null
}
export const speechCutOriginKind = (speech: { origin?: string | null }): SpeechCutSourceKind =>
speech.origin === 'web' ? 'web' : 'camara'
export const speechCutSourceKind = (speech: SpeechCutSourceRecord): SpeechCutSourceKind | null => {
if (speechCutOriginKind(speech) === 'web') {
return relationshipId(speech.mirroredMedia) === null ? null : 'web'
}
return speechVodCoordinates(speech) ? 'camara' : null
}
const isSpeechCutStep = (value: unknown): value is SpeechCutStep =>
SPEECH_CUT_STEPS.includes(value as SpeechCutStep)
/** C217 — the step label by source; the Câmara keeps the historical literals. */
export const speechCutStepLabel = (
step: SpeechCutStep,
source: SpeechCutSourceKind = 'camara',
): string => (source === 'web' ? WEB_SPEECH_CUT_STEP_LABELS[step] : speechCutStepLabels[step])
/**
* C169 — the causes the job and the reaper store in `error` (the operator's
* detail) and the honest pt-BR copy the dialog shows for each one. Only these
@ -54,6 +100,9 @@ export const SPEECH_CUT_FAILURE_UNAVAILABLE = 'A Câmara não entregou o arquivo
export const SPEECH_CUT_FAILURE_UNPLAYABLE =
'A Câmara não entregou um arquivo jogável deste trecho.'
export const SPEECH_CUT_FAILURE_INTERRUPTED = 'O corte foi interrompido antes de terminar.'
/** C217 — the web cut died because the private mirror is gone. */
export const SPEECH_CUT_FAILURE_MIRROR_MISSING =
'O arquivo espelhado desta fala não está mais disponível.'
const SPEECH_CUT_FAILURE_INELIGIBLE_COPY = 'Esta fala não tem trecho de vídeo para cortar.'
const SPEECH_CUT_FAILURE_UNAVAILABLE_COPY =
@ -61,6 +110,7 @@ const SPEECH_CUT_FAILURE_UNAVAILABLE_COPY =
const SPEECH_CUT_FAILURE_UNPLAYABLE_COPY =
'A Câmara não disponibilizou um arquivo válido deste trecho.'
const SPEECH_CUT_FAILURE_RESOLVING_COPY = 'Não foi possível localizar o trecho na Câmara.'
const SPEECH_CUT_FAILURE_MIRROR_RESOLVING_COPY = 'Não foi possível localizar o arquivo da fala.'
const SPEECH_CUT_FAILURE_CUTTING_COPY = 'Não foi possível cortar o trecho.'
const SPEECH_CUT_FAILURE_STORING_COPY = 'Não foi possível guardar o arquivo do corte.'
const SPEECH_CUT_FAILURE_UNKNOWN_COPY = 'Não foi possível preparar o corte.'
@ -73,9 +123,11 @@ const SPEECH_CUT_FAILURE_UNKNOWN_COPY = 'Não foi possível preparar o corte.'
export const speechCutFailureMessage = ({
error,
step,
source = 'camara',
}: {
error?: string | null
step?: SpeechCutStep | null
source?: SpeechCutSourceKind
}): string | null => {
const stored = error?.trim()
if (!stored) return null
@ -86,8 +138,13 @@ export const speechCutFailureMessage = ({
if (stored === SPEECH_CUT_FAILURE_UNAVAILABLE) return SPEECH_CUT_FAILURE_UNAVAILABLE_COPY
if (stored === SPEECH_CUT_FAILURE_UNPLAYABLE) return SPEECH_CUT_FAILURE_UNPLAYABLE_COPY
if (stored === SPEECH_CUT_FAILURE_INTERRUPTED) return SPEECH_CUT_FAILURE_INTERRUPTED
if (stored === SPEECH_CUT_FAILURE_MIRROR_MISSING) return SPEECH_CUT_FAILURE_MIRROR_MISSING
if (step === 'resolving') return SPEECH_CUT_FAILURE_RESOLVING_COPY
if (step === 'resolving') {
return source === 'web'
? SPEECH_CUT_FAILURE_MIRROR_RESOLVING_COPY
: SPEECH_CUT_FAILURE_RESOLVING_COPY
}
if (step === 'cutting') return SPEECH_CUT_FAILURE_CUTTING_COPY
if (step === 'metadata' || step === 'publishing') return SPEECH_CUT_FAILURE_STORING_COPY
return SPEECH_CUT_FAILURE_UNKNOWN_COPY
@ -100,11 +157,14 @@ type SpeechCutStepState = {
}
/** Step list of a running cut: everything before `current` is done, the rest queued. */
export const speechCutStepStates = (current: SpeechCutStep | null): SpeechCutStepState[] => {
export const speechCutStepStates = (
current: SpeechCutStep | null,
source: SpeechCutSourceKind = 'camara',
): SpeechCutStepState[] => {
const currentIndex = current ? SPEECH_CUT_STEPS.indexOf(current) : -1
return SPEECH_CUT_STEPS.map((step, index) => ({
step,
label: speechCutStepLabels[step],
label: speechCutStepLabel(step, source),
state: index < currentIndex ? 'done' : index === currentIndex ? 'current' : 'queued',
}))
}
@ -125,16 +185,22 @@ export const buildSpeechCutFallbackMetadata = ({
speechType,
dateLabel,
summary,
source = 'camara',
}: {
speechType: string | null
dateLabel: string
summary: string | null
/** C217 — the web source never claims the Câmara in the description. */
source?: SpeechCutSourceKind
}): { 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.`,
summary?.trim() ||
(source === 'web'
? `Trecho de ${type} de ${dateLabel}, publicado na internet.`
: `Trecho de ${type} de ${dateLabel}, na Câmara dos Deputados.`),
SPEECH_CUT_DESCRIPTION_MAX_LENGTH,
),
}
@ -148,20 +214,13 @@ type SpeechCutFfmpegInput = {
}
/**
* 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.
* The exact-window prefix both cut commands share: `-ss` before `-i` seeks fast
* and `-t` carries the duration so a timestamp offset in the source cannot
* shift the end. 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)
const cutWindowArgs = (input: SpeechCutFfmpegInput): string[] => {
const start = Math.max(0, Math.round(input.startSeconds))
const duration = Math.max(0, Math.round(input.endSeconds) - start)
return [
'-nostdin',
'-hide_banner',
@ -169,31 +228,69 @@ export const buildSpeechCutFfmpegArgs = ({
'-ss',
String(start),
'-i',
inputPath,
input.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,
]
}
/**
* Exact-cut command: the re-encode (no `-c copy`) starts the output exactly at
* the picked second instead of the previous keyframe; `+faststart` lets the
* proxied MP4 play progressively.
*/
export const buildSpeechCutFfmpegArgs = ({
inputPath,
outputPath,
startSeconds,
endSeconds,
}: SpeechCutFfmpegInput): string[] => [
...cutWindowArgs({ inputPath, outputPath, startSeconds, endSeconds }),
'-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,
]
/**
* C217 — the audio-only variant of the exact cut (radio/direct-audio mirrors):
* the same `-ss`/`-t` window, but the input has no video stream to map, so the
* output is an audio-only MP4 (AAC + `+faststart`) — the same `.mp4` contract
* the library card and the public page already play.
*/
export const buildSpeechCutAudioFfmpegArgs = ({
inputPath,
outputPath,
startSeconds,
endSeconds,
}: SpeechCutFfmpegInput): string[] => [
...cutWindowArgs({ inputPath, outputPath, startSeconds, endSeconds }),
'-map',
'0:a:0',
'-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}`
@ -223,6 +320,10 @@ type CutSpeechRecord = {
type?: string | null
phase?: string | null
speechAt?: string | null
/** C217 — web rows carry their own origin vocabulary and display title. */
origin?: string | null
platform?: WebSpeechPlatform | null
title?: string | null
}
type CutDurationRecord = { durationSeconds?: number | null }
@ -293,6 +394,10 @@ const mediaOf = (media: SpeechCutRecordForView['media']): CutMediaRecord | null
const youtubeUrlOf = (speech: SpeechCutRecordForView['speech']): string | null =>
typeof speech === 'object' && speech !== null ? (speech.youtubeUrl ?? null) : null
/** C217 — the source of a cut, read from its origin speech (absent → Câmara). */
const speechSourceOf = (speech: SpeechCutRecordForView['speech']): SpeechCutSourceKind =>
typeof speech === 'object' && speech !== null ? speechCutOriginKind(speech) : 'camara'
/** Stored span when present, else the raw [start, end] fallback, floored at 0. */
const cutDurationSeconds = (record: SpeechCutSummaryRecord): number =>
typeof record.durationSeconds === 'number' && Number.isFinite(record.durationSeconds)
@ -326,7 +431,13 @@ export const toSpeechCutViewModel = (record: SpeechCutRecordForView): SpeechCutV
youtubeVideoId: parseYoutubeVideoId(youtubeUrlOf(record.speech)),
publishedAt: record.publishedAt ?? null,
failureMessage:
status === 'failed' ? speechCutFailureMessage({ error: record.error, step }) : null,
status === 'failed'
? speechCutFailureMessage({
error: record.error,
step,
source: speechSourceOf(record.speech),
})
: null,
}
}
@ -375,8 +486,12 @@ export const originSpeechIdsOfCuts = (
/** Origin of a cut in the library: the speech it was cut from, with its link. */
export type SpeechCutOriginViewModel = {
id: number
/** "Fala · Breves Comunicações · 11/08/2026" — readiness degrades, never empty. */
/** C217 — the Câmara link and label stay as they were; the web has its own. */
source: SpeechCutSourceKind
/** "Fala · Breves Comunicações · 11/08/2026" (Câmara) or the web speech title. */
label: string
/** C217 — the web platform of the origin (drives the pill); null on Câmara rows. */
platform: WebSpeechPlatform | null
href: string
}
@ -397,10 +512,28 @@ const formatCreatedAtLabel = (createdAt: string | null | undefined): string | nu
const originOf = (speech: SpeechCutRecordForView['speech']): SpeechCutOriginViewModel | null => {
if (typeof speech !== 'object' || speech === null || typeof speech.id !== 'number') return null
// C217 — a web speech links back to its own detail and shows the platform;
// the Câmara composition is unchanged byte for byte.
if (speech.origin === 'web') {
return {
id: speech.id,
source: 'web',
label: webSpeechDisplayTitle({ id: speech.id, title: speech.title }),
platform: speech.platform ?? null,
href: campaignInternetSpeechHref(speech.id),
}
}
const dateLabel = speech.speechAt ? formatSpeechDate(speech.speechAt) : null
const label =
[speech.type ?? 'Fala', speech.phase, dateLabel].filter(Boolean).join(' · ') || 'Fala'
return { id: speech.id, label, href: `${CAMPAIGN_COMMUNICATION_ACERVO}/${speech.id}` }
return {
id: speech.id,
source: 'camara',
label,
platform: null,
href: `${CAMPAIGN_COMMUNICATION_ACERVO}/${speech.id}`,
}
}
/**

View file

@ -53,6 +53,14 @@ export const isExcerptSelectionAvailable = (
export const rangeDurationSeconds = (range: ExcerptRange): number =>
range.endSeconds - range.startSeconds
/**
* C217 — whether a transcript segment is inside the picked window. Shared by
* the Câmara and the web players so the marked phrases can never disagree
* (ASR timestamps are fractional; the overlap is strict on both edges).
*/
export const segmentInExcerpt = (segment: ExcerptSegment, range: ExcerptRange): boolean =>
segment.endSeconds > range.startSeconds && segment.startSeconds < range.endSeconds
/**
* C173 — the stop target of the excerpt preview: the range end once
* `currentSeconds` reaches it (inclusive). The `<video>` `timeupdate` fires at

View file

@ -30,6 +30,44 @@ export const webSpeechPlatformLabel = (value: WebSpeechPlatform | null | undefin
return WEB_SPEECH_PLATFORMS.find((platform) => platform.value === value)?.label ?? value
}
/**
* C217 — which player control the mirrored file needs: the single owner of the
* rule (`<audio>` for `audio/*`, `<video>` for everything else), shared by the
* detail view model and the cut job's ffmpeg variant.
*/
export const webSpeechMediaKind = (mimeType: string | null | undefined): 'video' | 'audio' =>
mimeType?.startsWith('audio/') ? 'audio' : 'video'
/**
* C217 — display title of a web speech: the platform title, falling back to the
* row id so a card/origin line never renders empty.
*/
export const webSpeechDisplayTitle = ({
id,
title,
}: {
id: number
title?: string | null
}): string => title?.trim() || `Fala da internet #${id}`
/**
* C217 — the credit of a cut whose origin is a web speech ("Fonte: YouTube ·
* Canal X"). Null when the row carries no platform: the caller omits the
* credit line instead of claiming a source.
*/
export const webSpeechCreditLabel = ({
platform,
channel,
}: {
platform?: WebSpeechPlatform | null
channel?: string | null
}): string | null => {
if (!platform) return null
const label = webSpeechPlatformLabel(platform)
const who = channel?.trim()
return who ? `${label} · ${who}` : label
}
/** Owner of the private upload collection slug (collection, config and S3). */
export const INTERNET_SPEECH_MEDIA_SLUG = 'internetSpeechMedia' as const

View file

@ -1099,7 +1099,7 @@ export interface SpeechSegment {
createdAt: string;
}
/**
* Cortes do acervo de falas publicados em /corte/<id>. Dados e vídeos da Câmara dos Deputados (CC BY 4.0).
* Cortes do acervo de falas publicados em /corte/<id>. Dados e vídeos da Câmara dos Deputados (CC BY 4.0) ou falas espelhadas da internet.
*
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "speechCut".

View file

@ -2,28 +2,38 @@ import 'server-only'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { extname, join } from 'node:path'
import type { Payload } from 'payload'
import { relationshipId } from '@/lib/relationship'
import { SPEECH_VOD_INELIGIBLE_MESSAGE } from '@/lib/schemas/speechVod'
import { formatSpeechDate } from '@/lib/speechClock'
import {
buildSpeechCutAudioFfmpegArgs,
buildSpeechCutFallbackMetadata,
buildSpeechCutFfmpegArgs,
SPEECH_CUT_FAILURE_GENERATING,
SPEECH_CUT_FAILURE_INTERRUPTED,
SPEECH_CUT_FAILURE_MIRROR_MISSING,
SPEECH_CUT_FAILURE_SPEECH_GONE,
SPEECH_CUT_FAILURE_UNAVAILABLE,
SPEECH_CUT_FAILURE_UNPLAYABLE,
speechCutOriginKind,
speechCutSourceKind,
type SpeechCutStep,
} from '@/lib/speechCut'
import { speechVodCoordinates } from '@/lib/speechVod'
import type { SpeechCut } from '@/payload-types'
import { INTERNET_SPEECH_MEDIA_SLUG, webSpeechMediaKind } from '@/lib/webSpeech'
import type { InternetSpeechMedia, SpeechCut } from '@/payload-types'
import { messageOf, runFfmpeg } from '@/utilities/media/ffmpeg'
import {
withPayloadTransaction,
type PayloadTransactionRequest,
} from '@/utilities/payloadTransaction'
import {
downloadPrivateMediaToFile,
resolvePrivateMediaStaticDir,
} from '@/utilities/privateMedia/privateMediaResponse'
import { downloadSource } from '@/utilities/speech/speechMediaPipeline'
import { resolveSpeechVod, SPEECH_VOD_CUT_POLICY } from '@/utilities/speech/speechVodResolver'
@ -32,6 +42,11 @@ import { resolveSpeechVod, SPEECH_VOD_CUT_POLICY } from '@/utilities/speech/spee
* 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.
*
* C217 — a web speech has no VOD: the job reads the private mirrored file the
* ingestion stored (`downloadPrivateMediaToFile`) and cuts the same exact
* window (audio-only mirrors get the audio ffmpeg variant). The Câmara path is
* unchanged.
*/
/** A cut stopped mid-flight (deploy/restart) is reaped to `failed` after this. */
@ -83,6 +98,35 @@ const createMediaSystem = (
...(req ? { req } : {}),
})
/**
* C217 — the private mirror of a web speech. The cut's depth-1 speech keeps
* `mirroredMedia` as an id (the upload lives at depth 2), so the job reads the
* row itself — intentional admin bypass, like the other system reads above.
* `disableErrors` turns a row deleted mid-flight into `null`, which the job
* maps to its honest "mirror gone" failure instead of a raw not-found error.
*/
const loadMirrorMedia = async (
payload: Payload,
mirroredMedia: number | { id: number } | null | undefined,
): Promise<InternetSpeechMedia | null> => {
const mediaId = relationshipId(mirroredMedia)
if (mediaId === null) return null
return payload.findByID({
collection: INTERNET_SPEECH_MEDIA_SLUG,
id: mediaId,
depth: 0,
disableErrors: true,
// Intentional admin bypass: the job reads the private file it owns.
overrideAccess: true,
})
}
/** Temp input extension from the stored name (sanitized; ffmpeg probes anyway). */
const inputExtensionOf = (filename: string | null | undefined): string => {
const extension = extname(filename ?? '').toLowerCase()
return /^\.[a-z0-9]{1,5}$/.test(extension) ? extension : '.mp4'
}
/**
* Fails the row and keeps the step it died on (the first stage is `resolving`
* by default): the dialog maps the step and the known literals into the honest
@ -123,42 +167,85 @@ export const runSpeechCutJob = async (payload: Payload, cutId: number): Promise<
await failCut(payload, cutId, SPEECH_CUT_FAILURE_SPEECH_GONE)
return
}
const coordinates = speechVodCoordinates(speech)
if (!coordinates) {
await failCut(payload, cutId, SPEECH_VOD_INELIGIBLE_MESSAGE)
const sourceKind = speechCutSourceKind(speech)
if (!sourceKind) {
await failCut(
payload,
cutId,
speechCutOriginKind(speech) === 'web'
? SPEECH_CUT_FAILURE_MIRROR_MISSING
: SPEECH_VOD_INELIGIBLE_MESSAGE,
)
return
}
const resolution = await resolveSpeechVod(coordinates, {
policy: SPEECH_VOD_CUT_POLICY,
// The stored links are the Câmara's own cache: tried only when the API
// left no verified URL, and probed exactly like the API links.
cachedUrls: { playbackUrl: speech.vodPlaybackUrl, downloadUrl: speech.vodDownloadUrl },
})
if (resolution.state === 'gerando') {
await failCut(payload, cutId, SPEECH_CUT_FAILURE_GENERATING)
return
}
if (resolution.state !== 'pronto') {
await failCut(payload, cutId, SPEECH_CUT_FAILURE_UNAVAILABLE)
return
}
const sourceUrl = resolution.playbackUrl ?? resolution.downloadUrl
if (!sourceUrl) {
await failCut(payload, cutId, SPEECH_CUT_FAILURE_UNPLAYABLE)
return
// C217 — the web branch reads the private mirror; the Câmara branch keeps
// the C167 resolution (stored links are its own cache, probed like the API).
let mirror: InternetSpeechMedia | null = null
let sourceUrl: string | null = null
if (sourceKind === 'web') {
mirror = await loadMirrorMedia(payload, speech.mirroredMedia)
if (!mirror) {
await failCut(payload, cutId, SPEECH_CUT_FAILURE_MIRROR_MISSING)
return
}
} else {
const coordinates = speechVodCoordinates(speech)
if (!coordinates) {
await failCut(payload, cutId, SPEECH_VOD_INELIGIBLE_MESSAGE)
return
}
const resolution = await resolveSpeechVod(coordinates, {
policy: SPEECH_VOD_CUT_POLICY,
// The stored links are the Câmara's own cache: tried only when the API
// left no verified URL, and probed exactly like the API links.
cachedUrls: { playbackUrl: speech.vodPlaybackUrl, downloadUrl: speech.vodDownloadUrl },
})
if (resolution.state === 'gerando') {
await failCut(payload, cutId, SPEECH_CUT_FAILURE_GENERATING)
return
}
if (resolution.state !== 'pronto') {
await failCut(payload, cutId, SPEECH_CUT_FAILURE_UNAVAILABLE)
return
}
sourceUrl = resolution.playbackUrl ?? resolution.downloadUrl
if (!sourceUrl) {
await failCut(payload, cutId, SPEECH_CUT_FAILURE_UNPLAYABLE)
return
}
}
await markStep('cutting')
tempDir = await mkdtemp(join(tmpdir(), 'speech-cut-'))
const inputPath = join(tempDir, 'source.mp4')
const inputPath = join(tempDir, `source${mirror ? inputExtensionOf(mirror.filename) : '.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)
if (mirror) {
try {
await downloadPrivateMediaToFile({
media: mirror,
staticDir: resolvePrivateMediaStaticDir(payload, INTERNET_SPEECH_MEDIA_SLUG),
destinationPath: inputPath,
})
} catch {
// The row exists but the stored object does not (+ any read failure):
// nothing to cut, and the retry can find the file again if it returns.
await failCut(payload, cutId, SPEECH_CUT_FAILURE_MIRROR_MISSING, 'cutting')
return
}
} else if (sourceUrl) {
await downloadSource(sourceUrl, inputPath)
}
// C217 — an audio-only mirror has no video stream to map.
const audioOnly = mirror !== null && webSpeechMediaKind(mirror.mimeType) === 'audio'
await runFfmpeg(
buildSpeechCutFfmpegArgs({
(audioOnly ? buildSpeechCutAudioFfmpegArgs : buildSpeechCutFfmpegArgs)({
inputPath,
outputPath,
startSeconds: cut.startSeconds,
@ -173,6 +260,7 @@ export const runSpeechCutJob = async (payload: Payload, cutId: number): Promise<
speechType: speech.type ?? null,
dateLabel: formatSpeechDate(speech.speechAt),
summary: speech.summary ?? null,
source: sourceKind,
})
const title = cut.title?.trim() || fallback.title
const description = cut.description?.trim() || fallback.description

View file

@ -4,7 +4,11 @@ import { deepSeek } from '@ai-sdk/deepseek'
import { generateObject } from 'ai'
import { z } from 'zod'
import { buildSpeechCutFallbackMetadata, clipSpeechCutText } from '@/lib/speechCut'
import {
buildSpeechCutFallbackMetadata,
clipSpeechCutText,
type SpeechCutSourceKind,
} from '@/lib/speechCut'
const SUGGESTION_TIMEOUT_MS = 6000
const MAX_EXCERPT_CHARS = 1200
@ -37,13 +41,30 @@ const suggestionSchema = z.object({
),
})
/**
* The rules every suggestion shares; only the first line (which catalog this
* is), the evidence sentence and the word naming the anchor change per source —
* composing keeps the two from drifting apart.
*/
const systemPromptTail = (evidence: string): string =>
'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 ${evidence}; nunca invente números, acordos ou citações, e a ` +
'transcrição pode ter ruído.'
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 da fala com 5 segundos ou mais. ' +
'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.'
systemPromptTail('resumo')
/** C217 — the web speech has no official type/summary; the title is the anchor. */
const WEB_SYSTEM_PROMPT =
'Você prepara cortes de falas do deputado federal Jorge Solla publicadas na internet. ' +
'Receberá título, plataforma, data e a transcrição automática (ASR) de um trecho da fala com 5 segundos ou mais. ' +
systemPromptTail('título')
const promptFrom = (lines: readonly (string | null)[]): string =>
lines.filter((line): line is string => line !== null).join('\n')
const excerptOf = (
segments: readonly SpeechCutMetadataSegment[],
@ -64,11 +85,18 @@ const excerptOf = (
* 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.
*
* C217 — `source` (default Câmara, bytes preserved) swaps the system prompt and
* the anchor line for a web speech: title/platform instead of official
* type/summary, still never inventing facts.
*/
export const suggestSpeechCutMetadata = async ({
speechType,
dateLabel,
summary,
source = 'camara',
speechTitle = null,
platformLabel = null,
segments,
startSeconds,
endSeconds,
@ -76,11 +104,16 @@ export const suggestSpeechCutMetadata = async ({
speechType: string | null
dateLabel: string
summary: string | null
source?: SpeechCutSourceKind
/** Web only — the publication title anchors the suggestion. */
speechTitle?: string | null
/** Web only — the pt-BR platform label. */
platformLabel?: string | null
segments: readonly SpeechCutMetadataSegment[]
startSeconds: number
endSeconds: number
}): Promise<SpeechCutMetadataSuggestion> => {
const fallback = buildSpeechCutFallbackMetadata({ speechType, dateLabel, summary })
const fallback = buildSpeechCutFallbackMetadata({ speechType, dateLabel, summary, source })
if (!process.env.DEEPSEEK_API_KEY) return { ...fallback, source: 'fallback' }
const excerpt = excerptOf(segments, startSeconds, endSeconds)
@ -90,15 +123,21 @@ export const suggestSpeechCutMetadata = async ({
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'),
system: source === 'web' ? WEB_SYSTEM_PROMPT : SYSTEM_PROMPT,
prompt:
source === 'web'
? promptFrom([
speechTitle?.trim() ? `Título: ${speechTitle.trim()}` : null,
platformLabel?.trim() ? `Plataforma: ${platformLabel.trim()}` : null,
`Data: ${dateLabel}`,
`Trecho (transcrição automática): "${excerpt}"`,
])
: promptFrom([
`Tipo: ${speechType?.trim() || 'fala'}`,
`Data: ${dateLabel}`,
summary?.trim() ? `Resumo oficial: ${summary.trim()}` : null,
`Trecho (transcrição automática): "${excerpt}"`,
]),
temperature: 0.3,
maxOutputTokens: MAX_OUTPUT_TOKENS,
abortSignal: AbortSignal.timeout(SUGGESTION_TIMEOUT_MS),

View file

@ -9,6 +9,7 @@ import {
campaignInternetSpeechFileHref,
campaignInternetSpeechHref,
} from '@/lib/campaignPaths'
import { relationshipId } from '@/lib/relationship'
import { formatSpeechClock, formatSpeechDate, formatSpeechSpan } from '@/lib/speechClock'
import type { SpeechCutSummaryViewModel } from '@/lib/speechCut'
import type { SpeechExcerptSegment } from '@/lib/speechExcerpt'
@ -36,7 +37,12 @@ import {
speechCoverUrl,
speechVodCoordinates,
} from '@/lib/speechVod'
import { webSpeechPlatformLabel, type WebSpeechPlatform } from '@/lib/webSpeech'
import {
webSpeechDisplayTitle,
webSpeechMediaKind,
webSpeechPlatformLabel,
type WebSpeechPlatform,
} from '@/lib/webSpeech'
import type { Municipality } from '@/payload-types'
import { speechScopeLabels, speechTopicLabels } from '@/utilities/speech/speechListUrl'
@ -432,6 +438,8 @@ export type WebSpeechDetailViewModel = {
platform: WebSpeechPlatformViewModel
dateLabel: string
durationLabel: string | null
/** C217 — the raw stored duration the excerpt picker/cut dialog work with. */
durationSeconds: number | null
/** Who published (channel/radio/profile) — origin text, never a Contact. */
channel: string | null
/** Original post URL ("Abrir na origem"); null when the row has none. */
@ -451,14 +459,8 @@ const webPlatformViewModel = (platform: WebSpeechPlatform | null | undefined) =>
label: webSpeechPlatformLabel(platform),
})
const mediaIdOf = (value: number | { id: number } | null | undefined): number | null => {
if (typeof value === 'number') return value
if (value && typeof value === 'object' && typeof value.id === 'number') return value.id
return null
}
const webSpeechTitle = (speech: WebSpeechListRecord): string =>
speech.title?.trim() || `Fala da internet #${speech.id}`
webSpeechDisplayTitle({ id: speech.id, title: speech.title })
const buildWebSpeechWatchHref = (
speechId: number,
@ -507,16 +509,14 @@ export const toWebSpeechListItemViewModel = ({
topics: topicViewModels(speech),
scopes: scopeViewModels(speech),
thumbnailUrl:
mediaIdOf(speech.thumbnail) === null ? null : campaignInternetSpeechCoverHref(speech.id),
relationshipId(speech.thumbnail) === null ? null : campaignInternetSpeechCoverHref(speech.id),
watchHref: buildWebSpeechWatchHref(speech.id, matchedSegment ?? themeMatch?.segment, q),
}
}
/** Audio artifacts get the native audio control; everything else is video. */
const mediaKindOf = (media: WebSpeechDetailRecord['mirroredMedia']): 'video' | 'audio' => {
const mimeType = media && typeof media === 'object' ? media.mimeType : null
return mimeType?.startsWith('audio/') ? 'audio' : 'video'
}
const mediaKindOf = (media: WebSpeechDetailRecord['mirroredMedia']): 'video' | 'audio' =>
webSpeechMediaKind(media && typeof media === 'object' ? media.mimeType : null)
/**
* Detail of one web speech: the private media routes, the origin attribution
@ -533,7 +533,7 @@ export const toWebSpeechDetailViewModel = ({
segments: readonly SpeechSegmentRecord[]
query?: string
}): WebSpeechDetailViewModel => {
const hasMedia = mediaIdOf(speech.mirroredMedia) !== null
const hasMedia = relationshipId(speech.mirroredMedia) !== null
return {
id: speech.id,
@ -541,6 +541,7 @@ export const toWebSpeechDetailViewModel = ({
platform: webPlatformViewModel(speech.platform),
dateLabel: formatSpeechDate(speech.speechAt),
durationLabel: formatSpeechDuration(speech.durationSeconds),
durationSeconds: speech.durationSeconds ?? null,
channel: speech.channel ?? null,
sourceUrl: speech.sourceUrl ?? null,
mediaKind: mediaKindOf(speech.mirroredMedia),

View file

@ -1283,6 +1283,29 @@ test.describe('communication vertical (C154/C162)', () => {
expect(html).toContain(`/campanha/comunicacao/acervo/internet/${speech.id}/arquivo`)
// The audio artifact gets the native audio control (design scene 05).
expect(html).toContain('<audio')
// C217 — the transcript is also the cut picker and the dialog opens from it.
expect(html).toContain('Cortar trecho')
expect(html).toContain('Selecione na transcrição as frases do trecho.')
expect(html).toContain('Toque para selecionar')
expect(html).not.toContain('Selecionar trecho')
})
test('a web speech without the mirror offers no cut gesture (C217)', async ({
campaign,
campaignRequest,
}) => {
const marker = campaign.fixtures.value('internetsemcorte')
const { speech } = await createWebSpeech(campaign, { marker, withMedia: false })
const user = await campaign.fixtures.createCampaignUser('communicator')
const request = await campaignRequest(user, user.password)
const response = await request.get(`/campanha/comunicacao/acervo/internet/${speech.id}`)
expect(response.status()).toBe(200)
const html = rendered(await response.text())
expect(html).toContain('O arquivo espelhado desta fala não está disponível.')
expect(html).not.toContain('Cortar trecho')
expect(html).not.toContain('Selecione na transcrição')
})
test('the private file and cover answer only the acervo roles, with range and download', async ({

View file

@ -26,6 +26,7 @@ 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 createdMirrorIds: number[] = []
const createSpeech = async (
campaign: { fixtures: CampaignE2EOwnership },
@ -110,6 +111,55 @@ const createCut = async (
return cutId
}
/**
* C217 — a web speech with its private mirror. The mirror goes through REST
* (admin session) so the server runs the real upload hooks; the speech itself
* has no revalidate hook, so it is created like `createSpeech`.
*/
const createWebSpeech = async (
campaign: { fixtures: CampaignE2EOwnership },
request: APIRequestContext,
headers: Record<string, string>,
input: {
platform?: 'youtube' | 'radio'
withMirror?: boolean
title?: string
channel?: string
} = {},
) => {
let mirrorId: number | null = null
if (input.withMirror !== false) {
const mirror = await request.post(`${BASE_URL}/api/internetSpeechMedia`, {
headers,
multipart: {
_payload: JSON.stringify({ alt: 'Espelho de teste' }),
file: { name: 'source.mp4', mimeType: 'video/mp4', buffer: FAKE_MP4 },
},
})
expect(mirror.ok(), await mirror.text()).toBeTruthy()
mirrorId = ((await mirror.json()) as { doc: { id: number } }).doc.id
createdMirrorIds.push(mirrorId)
}
return campaign.fixtures.payload.create({
collection: 'speech',
data: {
sourceKey: campaign.fixtures.value('web-speech'),
origin: 'web',
platform: input.platform ?? 'youtube',
sourceUrl: 'https://www.youtube.com/watch?v=abc123',
title: input.title ?? 'Fala publicada na internet',
channel: input.channel ?? 'Canal do teste',
speechAt: '2026-09-20T10:00',
durationSeconds: 120,
classifiedBy: 'gazetteer',
searchText: 'saude publica na internet',
...(mirrorId ? { mirroredMedia: mirrorId } : {}),
},
depth: 0,
})
}
test.afterAll(async ({ request }) => {
const headers = await adminHeaders(request, BASE_URL).catch(() => null)
if (!headers) return
@ -119,6 +169,11 @@ test.afterAll(async ({ request }) => {
for (const id of createdMediaIds.splice(0)) {
await request.delete(`${BASE_URL}/api/media/${id}`, { headers }).catch(() => undefined)
}
for (const id of createdMirrorIds.splice(0)) {
await request
.delete(`${BASE_URL}/api/internetSpeechMedia/${id}`, { headers })
.catch(() => undefined)
}
})
test.describe('Acervo speech cuts (C167)', () => {
@ -166,6 +221,62 @@ test.describe('Acervo speech cuts (C167)', () => {
}
})
test('serves a web cut with the origin credit and no Câmara copy (C217)', async ({
campaign,
request,
}) => {
const headers = await adminHeaders(request, BASE_URL)
const speech = await createWebSpeech(campaign, request, headers, { platform: 'youtube' })
const title = 'Corte da fala na internet'
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('Fala na internet')
expect(html).toContain('Fonte: YouTube · Canal do teste')
expect(html).not.toContain('Fonte: Câmara dos Deputados')
expect(html).not.toContain('Ver sessão no YouTube')
expect(html).toContain('noindex')
} finally {
await anonymous.dispose()
}
})
test('refuses a web speech without a mirror over the cut route (C217)', async ({
campaign,
campaignRequest,
request,
}) => {
const speech = await createWebSpeech(campaign, request, await adminHeaders(request, BASE_URL), {
withMirror: false,
})
const communicator = await campaign.fixtures.createCampaignUser('communicator')
const communicatorRequest = await campaignRequest(communicator, communicator.password)
const response = await communicatorRequest.post('/campanha/comunicacao/acervo/cortar', {
data: {
speechId: speech.id,
startSeconds: 0,
endSeconds: 30,
title: 'Título',
description: 'Descrição',
},
})
expect(response.status()).toBe(400)
expect(((await response.json()) as { message: string }).message).toContain(
'não tem arquivo espelhado',
)
})
test('answers the same not-found screen for an unpublished cut and an unknown id', async ({
campaign,
request,
@ -317,6 +428,39 @@ test.describe('Acervo cut library (C168)', () => {
}
})
test('shows the web origin badge and links the row to the web detail (C217)', async ({
campaign,
campaignRequest,
request,
}) => {
const headers = await adminHeaders(request, BASE_URL)
const speech = await createWebSpeech(campaign, request, headers, {
platform: 'radio',
title: 'Entrevista na rádio Metrópole',
})
const cutId = await createCut(request, headers, {
speechId: speech.id,
status: 'published',
title: 'Corte da entrevista',
})
const communicator = await campaign.fixtures.createCampaignUser('communicator')
const communicatorRequest = await campaignRequest(communicator, communicator.password)
const list = await communicatorRequest.get('/campanha/comunicacao/acervo/cortes')
expect(list.status()).toBe(200)
const html = rendered(await list.text())
expect(html).toContain('Origem: Rádio')
expect(html).toContain('Entrevista na rádio Metrópole')
expect(html).toContain(`/campanha/comunicacao/acervo/internet/${speech.id}`)
const detail = await communicatorRequest.get(`/campanha/comunicacao/acervo/cortes/${cutId}`)
expect(detail.status()).toBe(200)
const detailHtml = rendered(await detail.text())
expect(detailHtml).toContain('Fala de origem')
expect(detailHtml).toContain('Ver fala na internet')
})
test('denies advisor/leader on the library page and on the edit route', async ({
campaign,
campaignRequest,

View file

@ -0,0 +1,51 @@
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import type { Payload } from 'payload'
import { INTERNET_SPEECH_MEDIA_SLUG } from '@/lib/webSpeech'
/**
* C217 — shared media fixture of the web-speech int specs (the trigger the C216
* simplify left: the third spec needing the magic bytes). Real bytes so
* Payload's own detection derives the mime type from the upload — the detail
* picks the native control and the cut job the ffmpeg variant from it.
*/
export const WEB_SPEECH_MP4_BYTES = Buffer.from(
'00000018667479706d703432000000006d70343269736f6d',
'hex',
)
export const WEB_SPEECH_MP3_BYTES = Buffer.from('fffb900000000000000000000000000000000000', 'hex')
// Real 2x2 JPEG: Payload runs sharp over image uploads (focal point), so a
// thumbnail fixture must decode like a real one.
export const WEB_SPEECH_JPEG_BYTES = Buffer.from(
'/9j/2wBDAA0JCgsKCA0LCgsODg0PEyAVExISEyccHhcgLikxMC4pLSwzOko+MzZGNywtQFdBRkxOUlNSMj5aYVpQYEpRUk//2wBDAQ4ODhMREyYVFSZPNS01T09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT0//wAARCAACAAIDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAX/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFQEBAQAAAAAAAAAAAAAAAAAABAb/xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwCGACsH/9k=',
'base64',
)
/**
* Uploads one mirror artifact to `internetSpeechMedia` and returns its id. The
* caller owns the cleanup of the row; the temp file is removed here (Payload
* already copied it into its own static dir/S3).
*/
export const createInternetSpeechMedia = async (
payload: Payload,
bytes: Buffer,
filename: string,
): Promise<number> => {
const dir = mkdtempSync(join(tmpdir(), 'web-speech-media-'))
try {
const filePath = join(dir, filename)
writeFileSync(filePath, bytes)
const media = await payload.create({
collection: INTERNET_SPEECH_MEDIA_SLUG,
data: { alt: `Mídia de teste ${filename}` },
filePath,
// Intentional bypass: fixtures are a trusted actor with no session.
overrideAccess: true,
})
return media.id
} finally {
rmSync(dir, { recursive: true, force: true })
}
}

View file

@ -41,10 +41,16 @@ import {
} from '@/app/(campaign)/campanha/actions/speech'
import {
SPEECH_CUT_FORBIDDEN_MESSAGE,
SPEECH_CUT_MIRROR_MISSING_MESSAGE,
SPEECH_CUT_PUBLISH_NOT_READY_MESSAGE,
SPEECH_CUT_RETRY_NOT_FAILED_MESSAGE,
} from '@/lib/schemas/speechCut'
import { SPEECH_CUT_FAILURE_INTERRUPTED, SPEECH_CUT_FAILURE_UNAVAILABLE } from '@/lib/speechCut'
import {
SPEECH_CUT_FAILURE_INTERRUPTED,
SPEECH_CUT_FAILURE_MIRROR_MISSING,
SPEECH_CUT_FAILURE_UNAVAILABLE,
} from '@/lib/speechCut'
import { INTERNET_SPEECH_MEDIA_SLUG } from '@/lib/webSpeech'
import type { CampaignUser } from '@/payload-types'
import config from '@/payload.config'
import { getCampaignActionContext } from '@/utilities/campaignActionContext'
@ -74,6 +80,11 @@ import {
} from '../helpers/camaraVodStub'
import { installCampaignFixtures } from '../helpers/campaignFixtures'
import { speechBundleFixture } from '../helpers/speechBundleFixture'
import {
createInternetSpeechMedia,
WEB_SPEECH_MP3_BYTES,
WEB_SPEECH_MP4_BYTES,
} from '../helpers/webSpeechMediaFixture'
const FAKE_FFMPEG = fileURLToPath(new URL('../fixtures/fake-ffmpeg.mjs', import.meta.url))
@ -84,6 +95,7 @@ let payload: Payload
const createdSourceKeys = new Set<string>()
const createdCutIds = new Set<number>()
const createdMediaIds = new Set<number>()
const createdMirrorMediaIds = new Set<number>()
const campaignFixtures = installCampaignFixtures({
getPayload: () => payload,
setPayload: (nextPayload) => {
@ -142,8 +154,59 @@ const createCut = async (data: {
return cut.id
}
/** C217 — one mirror artifact of a web speech (caller may reuse across cuts). */
const createMirrorMedia = async (bytes: Buffer, filename: string): Promise<number> => {
const mediaId = await createInternetSpeechMedia(payload, bytes, filename)
createdMirrorMediaIds.add(mediaId)
return mediaId
}
/** C217 — a web speech bundle: no Câmara coordinates, optional private mirror. */
const createWebSpeech = async ({
mirrorMedia = null,
...overrides
}: Partial<SpeechImportBundle> & { mirrorMedia?: number | null } = {}): Promise<number> => {
const sourceKey = `web:test:${randomUUID()}`
const bundle = speechBundleFixture({
sourceKey,
origin: 'web',
platform: 'youtube',
externalId: sourceKey,
sourceUrl: `https://www.youtube.com/watch?v=${encodeURIComponent(sourceKey)}`,
title: 'Fala publicada na internet',
channel: 'Canal do teste',
mirroredMedia: mirrorMedia,
// Câmara-only coordinates never travel on a web row.
legislature: null,
type: null,
phase: null,
eventId: null,
audioId: null,
excerptTMs: null,
vodPlaybackUrl: null,
vodDownloadUrl: null,
officialTextUrl: null,
officialTranscript: null,
summary: null,
presidingOfficer: null,
...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('web speech fixture was not created')
return speech.id
}
/** Asserts the job published the cut with the fake ffmpeg's copy of the source. */
const expectPublishedWithFakeFfmpeg = async (cutId: number) => {
const expectPublishedWithFakeFfmpeg = async (cutId: number, sourceBytes: Buffer = MP4_BYTES) => {
const cut = await payload.findByID({
collection: 'speechCut',
id: cutId,
@ -154,7 +217,9 @@ const expectPublishedWithFakeFfmpeg = async (cutId: number) => {
const media = typeof cut.media === 'object' && cut.media !== null ? cut.media : null
if (!media) throw new Error('media was not attached')
createdMediaIds.add(media.id)
expect(readFileSync(resolve(process.cwd(), 'media', media.filename as string))).toEqual(MP4_BYTES)
expect(readFileSync(resolve(process.cwd(), 'media', media.filename as string))).toEqual(
sourceBytes,
)
return { cut, media }
}
@ -240,6 +305,17 @@ describe('speech cuts (C167)', () => {
.catch(() => undefined)
}
}
// The speech cascade already removes its mirror; this catches leftovers.
for (const mirrorId of createdMirrorMediaIds) {
await payload
.delete({
collection: INTERNET_SPEECH_MEDIA_SLUG,
id: mirrorId,
depth: 0,
overrideAccess: true,
})
.catch(() => undefined)
}
})
it('exposes published cuts to anonymous readers and every status to the acervo gate', async () => {
@ -621,6 +697,134 @@ describe('speech cuts (C167)', () => {
}
})
})
// C217 — the web source: the cut reads the private mirror, never the Câmara.
describe('web speech cuts (C217)', () => {
it('publishes from the private mirror without ever touching the Câmara', async () => {
const mirror = await createMirrorMedia(WEB_SPEECH_MP4_BYTES, 'source.web.mp4')
const speech = await createWebSpeech({ mirrorMedia: mirror })
const cutId = await createCut({ speech, startSeconds: 43, endSeconds: 118 })
const fetchSpy = vi.fn(() => {
throw new Error('a web cut must not reach the Câmara')
})
vi.stubGlobal('fetch', fetchSpy)
const restoreFfmpeg = withFfmpegPath(FAKE_FFMPEG)
try {
await runSpeechCutJob(payload, cutId)
const { cut, media } = await expectPublishedWithFakeFfmpeg(cutId, WEB_SPEECH_MP4_BYTES)
expect(cut.step).toBeNull()
expect(cut.publishedAt).toBeTruthy()
expect(media.filename).toBe(`corte-${cutId}-43-118.mp4`)
expect(fetchSpy).not.toHaveBeenCalled()
} finally {
restoreFfmpeg()
vi.unstubAllGlobals()
}
})
it('refuses a web speech without a mirror and fails the row honestly', async () => {
const speech = await createWebSpeech()
const actors = await createUsers()
await asActor(actors.communicator)
await expect(
saveSpeechCutForActor({
speechId: speech,
startSeconds: 0,
endSeconds: 30,
title: 'Corte',
description: 'Descrição',
}),
).rejects.toThrow(SPEECH_CUT_MIRROR_MISSING_MESSAGE)
// A row created out-of-band still fails honestly in the job.
const cutId = await createCut({ speech, startSeconds: 0, endSeconds: 30 })
vi.stubGlobal('fetch', () => {
throw new Error('a web cut must not reach the Câmara')
})
const restoreFfmpeg = withFfmpegPath(FAKE_FFMPEG)
try {
await runSpeechCutJob(payload, cutId)
const cut = await payload.findByID({
collection: 'speechCut',
id: cutId,
depth: 0,
overrideAccess: true,
})
expect(cut.status).toBe('failed')
expect(cut.error).toBe(SPEECH_CUT_FAILURE_MIRROR_MISSING)
expect(cut.media ?? null).toBeNull()
expect(cut.publishedAt ?? null).toBeNull()
} finally {
restoreFfmpeg()
vi.unstubAllGlobals()
}
})
it('cuts an audio mirror with the audio ffmpeg variant (no video stream)', async () => {
const logDir = mkdtempSync(join(tmpdir(), 'web-cut-log-'))
const logPath = join(logDir, 'ffmpeg-args.json')
const mirror = await createMirrorMedia(WEB_SPEECH_MP3_BYTES, 'source.web.mp3')
const speech = await createWebSpeech({ mirrorMedia: mirror, platform: 'radio' })
const cutId = await createCut({ speech, startSeconds: 10, endSeconds: 40 })
const previousLog = process.env.FAKE_FFMPEG_LOG
process.env.FAKE_FFMPEG_LOG = logPath
const restoreFfmpeg = withFfmpegPath(FAKE_FFMPEG)
vi.stubGlobal('fetch', () => {
throw new Error('a web cut must not reach the Câmara')
})
try {
await runSpeechCutJob(payload, cutId)
const args = JSON.parse(readFileSync(logPath, 'utf8')) as string[]
expect(args).toContain('0:a:0')
expect(args).not.toContain('0:v:0')
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)
} finally {
if (previousLog === undefined) delete process.env.FAKE_FFMPEG_LOG
else process.env.FAKE_FFMPEG_LOG = previousLog
restoreFfmpeg()
vi.unstubAllGlobals()
rmSync(logDir, { recursive: true, force: true })
}
})
it('shows the web origin (platform and detail link) on the cut', async () => {
const mirror = await createMirrorMedia(WEB_SPEECH_MP4_BYTES, 'source.web.mp4')
const speech = await createWebSpeech({
mirrorMedia: mirror,
platform: 'radio',
title: 'Entrevista na rádio Metrópole',
})
const cutId = await createCut({ speech, startSeconds: 0, endSeconds: 30 })
const actors = await createUsers()
const view = await loadSpeechCutDetailPageData(payload, actors.communicator, cutId)
expect(view.origin).toEqual({
id: speech,
source: 'web',
label: 'Entrevista na rádio Metrópole',
platform: 'radio',
href: `/campanha/comunicacao/acervo/internet/${speech}`,
})
})
})
})
describe('speech cut library (C168)', () => {

View file

@ -1,10 +1,6 @@
// @vitest-environment node
import { randomUUID } from 'node:crypto'
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import type { Payload } from 'payload'
import { getPayload } from 'payload'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
@ -24,6 +20,11 @@ import {
import { installCampaignFixtures } from '../helpers/campaignFixtures'
import { speechBundleFixture } from '../helpers/speechBundleFixture'
import {
createInternetSpeechMedia,
WEB_SPEECH_MP3_BYTES,
WEB_SPEECH_MP4_BYTES,
} from '../helpers/webSpeechMediaFixture'
// C216 — the "Falas na internet" loaders over the real Payload boundary: the
// list reads only web rows, the facets answer over the web catalog, the detail
@ -34,7 +35,6 @@ import { speechBundleFixture } from '../helpers/speechBundleFixture'
let payload: Payload
const createdSourceKeys = new Set<string>()
const createdMediaIds = new Set<number>()
const tempDirs: string[] = []
const campaignFixtures = installCampaignFixtures({
getPayload: () => payload,
@ -43,25 +43,10 @@ const campaignFixtures = installCampaignFixtures({
},
})
// Real magic bytes so Payload derives the mime type from the upload itself (the
// detail picks the native control from it).
const MP4_BYTES = Buffer.from('00000018667479706d703432000000006d70343269736f6d', 'hex')
const MP3_BYTES = Buffer.from('fffb900000000000000000000000000000000000', 'hex')
const createMedia = async (bytes: Buffer, filename: string): Promise<number> => {
const dir = await mkdtemp(join(tmpdir(), 'web-speech-acervo-'))
tempDirs.push(dir)
const filePath = join(dir, filename)
await writeFile(filePath, bytes)
const media = await payload.create({
collection: INTERNET_SPEECH_MEDIA_SLUG,
data: { alt: `Mídia de teste ${filename}` },
filePath,
// Intentional bypass: fixtures are a trusted actor with no session.
overrideAccess: true,
})
createdMediaIds.add(media.id)
return media.id
const mediaId = await createInternetSpeechMedia(payload, bytes, filename)
createdMediaIds.add(mediaId)
return mediaId
}
const webBundle = (
@ -172,7 +157,6 @@ describe('web speech acervo (C216)', () => {
})
.catch(() => undefined)
}
for (const dir of tempDirs) await rm(dir, { recursive: true, force: true })
})
it('lists only web rows: the Câmara speech with the same marker stays invisible', async () => {
@ -299,7 +283,7 @@ describe('web speech acervo (C216)', () => {
})
it('carries the private routes, the platform and the origin attribution in the detail', async () => {
const mediaId = await createMedia(MP3_BYTES, 'source.mp3')
const mediaId = await createMedia(WEB_SPEECH_MP3_BYTES, 'source.mp3')
const id = await createWebSpeech({
platform: 'radio',
title: 'Entrevista na rádio',
@ -325,7 +309,7 @@ describe('web speech acervo (C216)', () => {
})
it('points the cover at the private route only when a thumbnail was captured', async () => {
const thumbnailId = await createMedia(MP4_BYTES, 'thumb.mp4')
const thumbnailId = await createMedia(WEB_SPEECH_MP4_BYTES, 'thumb.mp4')
const withCover = await createWebSpeech({ thumbnail: thumbnailId })
const withoutCover = await createWebSpeech({})

View file

@ -18,6 +18,12 @@ import {
type WebSpeechTranscriber,
} from '@/utilities/speech/webSpeechIngest'
import {
WEB_SPEECH_JPEG_BYTES as JPEG_BYTES,
WEB_SPEECH_MP3_BYTES as MP3_BYTES,
WEB_SPEECH_MP4_BYTES as MP4_BYTES,
} from '../helpers/webSpeechMediaFixture'
// C215 — the ingestion pipeline with injected seams: acquire → transcribe →
// classify → mirror → upsert. The fakes keep it offline; the assertions pin
// idempotency (second run only refreshes metadata), --reprocess, the mirrored
@ -26,17 +32,6 @@ import {
let payload: Payload
const acquireCalls: string[] = []
// Real magic bytes so Payload's own detection derives the mimeType (the fake
// pipeline writes files, but the upload must behave like production).
const MP4_BYTES = Buffer.from('00000018667479706d703432000000006d70343269736f6d', 'hex')
const MP3_BYTES = Buffer.from('fffb900000000000000000000000000000000000', 'hex')
// Real 2x2 JPEG: Payload runs sharp over image uploads (focal point), so the
// fake thumbnail must decode like a real one.
const JPEG_BYTES = Buffer.from(
'/9j/2wBDAA0JCgsKCA0LCgsODg0PEyAVExISEyccHhcgLikxMC4pLSwzOko+MzZGNywtQFdBRkxOUlNSMj5aYVpQYEpRUk//2wBDAQ4ODhMREyYVFSZPNS01T09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT0//wAARCAACAAIDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAX/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFQEBAQAAAAAAAAAAAAAAAAAABAb/xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwCGACsH/9k=',
'base64',
)
// Every key these specs may touch, in both the external-id and the URL form:
// a failed attempt must never leak a row into the retry.
const TEST_SOURCE_KEYS = [

View file

@ -3,18 +3,23 @@ import { describe, expect, it } from 'vitest'
import { SPEECH_VOD_INELIGIBLE_MESSAGE } from '@/lib/schemas/speechVod'
import { formatSpeechDate } from '@/lib/speechClock'
import {
buildSpeechCutAudioFfmpegArgs,
buildSpeechCutFallbackMetadata,
buildSpeechCutFfmpegArgs,
buildSpeechCutShare,
originSpeechIdsOfCuts,
SPEECH_CUT_FAILURE_GENERATING,
SPEECH_CUT_FAILURE_INTERRUPTED,
SPEECH_CUT_FAILURE_MIRROR_MISSING,
SPEECH_CUT_FAILURE_SPEECH_GONE,
SPEECH_CUT_FAILURE_UNAVAILABLE,
SPEECH_CUT_FAILURE_UNPLAYABLE,
speechCutFailureMessage,
speechCutPublicPath,
speechCutSourceKind,
speechCutStepLabel,
speechCutStepStates,
toSpeechCutLibraryItemViewModel,
toSpeechCutSummaryViewModel,
toSpeechCutViewModel,
} from '@/lib/speechCut'
@ -69,6 +74,76 @@ describe('buildSpeechCutFfmpegArgs', () => {
})
})
describe('buildSpeechCutAudioFfmpegArgs', () => {
const args = buildSpeechCutAudioFfmpegArgs({
inputPath: '/tmp/source.mp3',
outputPath: '/tmp/corte-1-43-130.mp4',
startSeconds: 43,
endSeconds: 130,
})
it('maps only the audio stream (an audio-only mirror has no video)', () => {
expect(args[args.indexOf('-map') + 1]).toBe('0:a:0')
expect(args).not.toContain('0:v:0')
expect(args).not.toContain('libx264')
})
it('keeps the exact window and the AAC MP4 contract', () => {
expect(args.indexOf('-ss')).toBeLessThan(args.indexOf('-i'))
expect(args[args.indexOf('-ss') + 1]).toBe('43')
expect(args[args.indexOf('-t') + 1]).toBe('87')
expect(args).toContain('aac')
expect(args).toContain('+faststart')
expect(args.at(-1)).toBe('/tmp/corte-1-43-130.mp4')
})
it('rounds seconds and never builds a negative duration', () => {
const reversed = buildSpeechCutAudioFfmpegArgs({
inputPath: 'in',
outputPath: 'out',
startSeconds: 130,
endSeconds: 43,
})
expect(reversed[reversed.indexOf('-t') + 1]).toBe('0')
})
})
describe('speechCutSourceKind', () => {
it('reads a web speech from its mirrored file', () => {
expect(speechCutSourceKind({ origin: 'web', mirroredMedia: 9 })).toBe('web')
expect(speechCutSourceKind({ origin: 'web', mirroredMedia: { id: 9 } })).toBe('web')
})
it('never falls back to the Câmara VOD for a web speech without a mirror', () => {
expect(
speechCutSourceKind({
origin: 'web',
mirroredMedia: null,
vodPlaybackUrl: 'https://cdn.camara.leg.br/trecho.mp4',
eventId: 1,
audioId: 2,
excerptTMs: 3,
}),
).toBeNull()
})
it('reads a Câmara speech from the stored VOD coordinates', () => {
expect(
speechCutSourceKind({
origin: 'camara',
vodPlaybackUrl: 'https://cdn.camara.leg.br/trecho.mp4',
eventId: 1,
audioId: 2,
excerptTMs: 3,
}),
).toBe('camara')
expect(speechCutSourceKind({ eventId: 1, audioId: 2, excerptTMs: 3 })).toBeNull()
expect(
speechCutSourceKind({ vodPlaybackUrl: 'https://cdn.camara.leg.br/trecho.mp4' }),
).toBeNull()
})
})
describe('buildSpeechCutFallbackMetadata', () => {
it('names the excerpt with the type and the day, and uses the official summary', () => {
expect(
@ -100,6 +175,29 @@ describe('buildSpeechCutFallbackMetadata', () => {
}).description
expect(description).toHaveLength(2000)
})
it('never claims the Câmara when the source is a web speech (C217)', () => {
expect(
buildSpeechCutFallbackMetadata({
speechType: null,
dateLabel: '24/09/2026',
summary: null,
source: 'web',
}),
).toEqual({
title: 'Trecho de fala — 24/09/2026',
description: 'Trecho de fala de 24/09/2026, publicado na internet.',
})
})
})
describe('speechCutStepLabel', () => {
it('renames only the resolving step on the web source', () => {
expect(speechCutStepLabel('resolving')).toBe('Localizando o trecho na Câmara')
expect(speechCutStepLabel('resolving', 'camara')).toBe('Localizando o trecho na Câmara')
expect(speechCutStepLabel('resolving', 'web')).toBe('Localizando o arquivo da fala')
expect(speechCutStepLabel('cutting', 'web')).toBe('Cortando o trecho')
})
})
describe('speechCutStepStates', () => {
@ -115,6 +213,15 @@ describe('speechCutStepStates', () => {
it('queues every step when there is no current step', () => {
expect(speechCutStepStates(null).every((entry) => entry.state === 'queued')).toBe(true)
})
it('uses the web label for the resolving step only', () => {
expect(speechCutStepStates('cutting', 'web').map((entry) => entry.label)).toEqual([
'Localizando o arquivo da fala',
'Cortando o trecho',
'Gerando título e descrição',
'Publicando a página do corte',
])
})
})
describe('toSpeechCutViewModel', () => {
@ -218,6 +325,78 @@ describe('toSpeechCutSummaryViewModel', () => {
})
})
describe('toSpeechCutLibraryItemViewModel origin', () => {
const base = {
id: 5,
status: 'published',
title: 'Corte',
description: 'Descrição',
startSeconds: 10,
endSeconds: 40,
durationSeconds: 30,
createdAt: '2026-09-24T12:00:00.000Z',
} as const
it('keeps the Câmara label and href byte for byte', () => {
const view = toSpeechCutLibraryItemViewModel({
...base,
speech: {
id: 7,
type: 'BREVES COMUNICAÇÕES',
phase: 'Ordem do Dia',
speechAt: '2026-08-11T18:48',
origin: 'camara',
},
})
expect(view.origin).toEqual({
id: 7,
source: 'camara',
label: 'BREVES COMUNICAÇÕES · Ordem do Dia · 11/08/2026',
platform: null,
href: '/campanha/comunicacao/acervo/7',
})
})
it('links a web origin to its own detail and carries the platform (C217)', () => {
const view = toSpeechCutLibraryItemViewModel({
...base,
speech: {
id: 42,
origin: 'web',
platform: 'youtube',
title: 'Entrevista na rádio Metrópole',
speechAt: '2026-09-20T10:00',
},
})
expect(view.origin).toEqual({
id: 42,
source: 'web',
label: 'Entrevista na rádio Metrópole',
platform: 'youtube',
href: '/campanha/comunicacao/acervo/internet/42',
})
})
it('degrades a web speech without title or platform without breaking the link', () => {
const view = toSpeechCutLibraryItemViewModel({
...base,
speech: { id: 43, origin: 'web', title: ' ', platform: null },
})
expect(view.origin).toEqual({
id: 43,
source: 'web',
label: 'Fala da internet #43',
platform: null,
href: '/campanha/comunicacao/acervo/internet/43',
})
})
it('degrades to a null origin when the source speech was deleted', () => {
expect(toSpeechCutLibraryItemViewModel({ ...base, speech: null }).origin).toBeNull()
expect(toSpeechCutLibraryItemViewModel({ ...base, speech: 7 }).origin).toBeNull()
})
})
describe('originSpeechIdsOfCuts', () => {
it('dedupes resolved ids and skips null/missing relations', () => {
expect(
@ -267,6 +446,9 @@ describe('speechCutFailureMessage', () => {
expect(speechCutFailureMessage({ error: raw, step: 'resolving' })).toBe(
'Não foi possível localizar o trecho na Câmara.',
)
expect(speechCutFailureMessage({ error: raw, step: 'resolving', source: 'web' })).toBe(
'Não foi possível localizar o arquivo da fala.',
)
expect(speechCutFailureMessage({ error: 'ffmpeg failed', step: 'cutting' })).toBe(
'Não foi possível cortar o trecho.',
)
@ -282,6 +464,13 @@ describe('speechCutFailureMessage', () => {
expect(speechCutFailureMessage({ error: raw, step: 'resolving' })).not.toContain('https://')
})
it('maps the gone mirror literal to itself (C217)', () => {
expect(speechCutFailureMessage({ error: SPEECH_CUT_FAILURE_MIRROR_MISSING })).toBe(
SPEECH_CUT_FAILURE_MIRROR_MISSING,
)
expect(SPEECH_CUT_FAILURE_MIRROR_MISSING).not.toContain('Câmara')
})
it('stays null when the row stored no cause', () => {
expect(speechCutFailureMessage({ error: null, step: 'resolving' })).toBeNull()
expect(speechCutFailureMessage({ error: ' ', step: 'resolving' })).toBeNull()

View file

@ -192,6 +192,43 @@ describe('SpeechCutDialog (C167)', () => {
expect(screen.queryByText(/A Câmara não está entregando o vídeo desta fala agora/)).toBeNull()
})
it('drops the Câmara copy when the source is a web speech (C217)', async () => {
fetchMock.mockImplementation(async (input: RequestInfo | URL) => {
const url = String(input)
if (url.endsWith(SUGGESTION_ENDPOINT)) {
return jsonResponse({
status: 'success',
suggestion: { title: '', description: '', source: 'fallback' },
})
}
if (url.endsWith(SAVE_ENDPOINT)) {
return jsonResponse({ status: 'success', cut: cutView({ step: 'resolving' }) })
}
if (url.endsWith(STATUS_ENDPOINT)) {
return jsonResponse({
status: 'success',
cut: cutView({ status: 'failed', step: 'resolving', failureMessage: null }),
})
}
throw new Error(`unexpected fetch: ${url}`)
})
renderDialog({ speechSource: 'web', speechType: null, summary: null, dateLabel: '24/09/2026' })
// The deterministic fallback never claims the Câmara.
await screen.findByDisplayValue('Trecho de fala de 24/09/2026, publicado na internet.')
expect(screen.queryByDisplayValue(/na Câmara dos Deputados/)).toBeNull()
fireEvent.click(screen.getByRole('button', { name: /Cortar e publicar/ }))
await screen.findByText(
'Não foi possível preparar o corte desta fala agora. Tente novamente em alguns minutos.',
{},
{ timeout: 4000 },
)
expect(screen.queryByText(/A Câmara não está entregando o vídeo desta fala agora/)).toBeNull()
})
it('shows the honest failure and retries the same row (no duplicate create)', async () => {
const retryBodies: unknown[] = []
fetchMock.mockImplementation(async (input: RequestInfo | URL, init?: RequestInit) => {

View file

@ -247,6 +247,7 @@ describe('web speech view models (C216)', () => {
platform: 'radio',
channel: 'Rádio Metrópole',
sourceUrl: 'https://radio.example/entrevista',
durationSeconds: 87,
mirroredMedia,
},
segments: [{ startSeconds: 0, endSeconds: 4, text: 'A saúde pública' }],
@ -259,6 +260,8 @@ describe('web speech view models (C216)', () => {
expect(audio.channel).toBe('Rádio Metrópole')
expect(audio.sourceUrl).toBe('https://radio.example/entrevista')
expect(audio.dateLabel).toBe('20/09/2026')
expect(audio.durationSeconds).toBe(87)
expect(audio.durationLabel).toBe('1min27s')
expect(audio.segments[0]?.startLabel).toBe('00:00')
const video = detail({ id: 12, mimeType: 'video/mp4', filename: 'source.mp4' })

View file

@ -5,7 +5,10 @@ import {
parseWebSpeechBatch,
parseWebSpeechFinding,
webSpeechAtFromPublishedAt,
webSpeechCreditLabel,
webSpeechDisplayTitle,
webSpeechDownloadFilename,
webSpeechMediaKind,
webSpeechPlatformLabel,
webSpeechSourceKey,
} from '@/lib/webSpeech'
@ -188,3 +191,33 @@ describe('webSpeechDownloadFilename', () => {
expect(webSpeechDownloadFilename({ title: 'Fala', storedFilename: null })).toBeNull()
})
})
// C217 — the pure web-speech rules the cut shares with the detail/list VMs.
describe('webSpeechMediaKind', () => {
it('reads the mirrored mime: audio gets the native audio control', () => {
expect(webSpeechMediaKind('audio/mpeg')).toBe('audio')
expect(webSpeechMediaKind('audio/mp4')).toBe('audio')
expect(webSpeechMediaKind('video/mp4')).toBe('video')
expect(webSpeechMediaKind(null)).toBe('video')
expect(webSpeechMediaKind(undefined)).toBe('video')
})
})
describe('webSpeechDisplayTitle', () => {
it('prefers the platform title and degrades to the row id', () => {
expect(webSpeechDisplayTitle({ id: 9, title: ' Entrevista ' })).toBe('Entrevista')
expect(webSpeechDisplayTitle({ id: 9, title: ' ' })).toBe('Fala da internet #9')
expect(webSpeechDisplayTitle({ id: 9 })).toBe('Fala da internet #9')
})
})
describe('webSpeechCreditLabel', () => {
it('composes the platform with the channel and never claims a missing source', () => {
expect(webSpeechCreditLabel({ platform: 'youtube', channel: 'Canal do Solla' })).toBe(
'YouTube · Canal do Solla',
)
expect(webSpeechCreditLabel({ platform: 'radio', channel: ' ' })).toBe('Rádio')
expect(webSpeechCreditLabel({ platform: null, channel: 'Canal' })).toBeNull()
expect(webSpeechCreditLabel({})).toBeNull()
})
})

View file

@ -0,0 +1,141 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { WebSpeechDetailPlayer } from '@/components/campaign/speech/WebSpeechDetailPlayer'
import type {
SpeechDetailSegmentViewModel,
WebSpeechDetailViewModel,
} from '@/utilities/speech/speechViewModels'
/**
* C217 — the web detail's cut picker: the transcript marks/extend the excerpt
* (data-selected) and "Cortar trecho" opens the existing cut dialog with the
* picked window; without the private mirror there is no gesture at all.
*/
const SEGMENTS: readonly SpeechDetailSegmentViewModel[] = [
{
startSeconds: 12,
endSeconds: 20,
startLabel: '00:12',
parts: [{ text: 'A saúde pública baiana', highlighted: false }],
},
{
startSeconds: 21,
endSeconds: 30,
startLabel: '00:21',
parts: [{ text: 'e a educação no interior', highlighted: false }],
},
]
const webSpeech = (
overrides: Partial<WebSpeechDetailViewModel> = {},
): WebSpeechDetailViewModel => ({
id: 42,
title: 'Entrevista na rádio Metrópole',
platform: { value: 'radio', label: 'Rádio' },
dateLabel: '20/09/2026',
durationLabel: '2min',
durationSeconds: 120,
channel: 'Rádio Metrópole',
sourceUrl: 'https://radio.example/entrevista',
mediaKind: 'audio',
fileHref: '/campanha/comunicacao/acervo/internet/42/arquivo',
downloadHref: '/campanha/comunicacao/acervo/internet/42/arquivo?download=1',
segments: [...SEGMENTS],
topics: [],
scopes: [],
...overrides,
})
const fetchMock = vi.fn()
const renderPlayer = (overrides: Partial<WebSpeechDetailViewModel> = {}) =>
render(<WebSpeechDetailPlayer speech={webSpeech(overrides)} initialSeconds={null} />)
const segmentButton = (seconds: number) =>
document.querySelector(`button[data-start-seconds="${seconds}"]`) as HTMLButtonElement
beforeAll(() => {
// jsdom does not implement media playback; the seek/mark contract is what matters.
Object.defineProperty(HTMLMediaElement.prototype, 'play', {
configurable: true,
value: () => Promise.resolve(),
})
Object.defineProperty(HTMLMediaElement.prototype, 'currentTime', {
configurable: true,
writable: true,
value: 0,
})
})
beforeEach(() => {
vi.stubGlobal(
'ResizeObserver',
class {
observe() {}
unobserve() {}
disconnect() {}
},
)
Element.prototype.scrollIntoView = () => {}
fetchMock.mockReset()
fetchMock.mockResolvedValue({
ok: true,
json: async () => ({
status: 'success',
suggestion: { title: 'Título sugerido', description: 'Descrição sugerida', source: 'ai' },
}),
})
vi.stubGlobal('fetch', fetchMock)
})
afterEach(() => {
cleanup()
vi.unstubAllGlobals()
Reflect.deleteProperty(Element.prototype, 'scrollIntoView')
})
describe('WebSpeechDetailPlayer — cut picker (C217)', () => {
it('offers "Cortar trecho" with the picker hint and no Câmara toggle', () => {
renderPlayer()
expect(screen.getByRole('button', { name: /Cortar trecho/ })).toBeTruthy()
expect(screen.getByText('Selecione na transcrição as frases do trecho.')).toBeTruthy()
expect(screen.getByText('Clique nas frases')).toBeTruthy()
expect(screen.getByText('Toque para selecionar')).toBeTruthy()
expect(screen.queryByText('Selecionar trecho')).toBeNull()
expect(segmentButton(12).getAttribute('data-selected')).toBeNull()
})
it('offers no gesture when the mirrored file is missing', () => {
renderPlayer({ fileHref: null, downloadHref: null })
expect(screen.queryByRole('button', { name: /Cortar trecho/ })).toBeNull()
expect(screen.queryByText('Selecione na transcrição as frases do trecho.')).toBeNull()
fireEvent.click(segmentButton(21))
expect(segmentButton(21).getAttribute('data-selected')).toBeNull()
})
it('marks the clicked phrase, extends on the next click and opens the dialog with the window', async () => {
renderPlayer()
fireEvent.click(segmentButton(21))
expect(segmentButton(21).getAttribute('data-selected')).toBe('true')
expect(segmentButton(12).getAttribute('data-selected')).toBeNull()
// A phrase before the picked window moves the start back (adjacent magnet).
fireEvent.click(segmentButton(12))
expect(segmentButton(12).getAttribute('data-selected')).toBe('true')
expect(segmentButton(21).getAttribute('data-selected')).toBe('true')
fireEvent.click(screen.getByRole('button', { name: /Cortar trecho/ }))
await screen.findByRole('dialog')
expect(screen.getByText('Cortar vídeo')).toBeTruthy()
const range = document.body.textContent ?? ''
expect(range).toContain('00:12')
expect(range).toContain('00:30')
})
})