mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-14 17:43:04 +00:00
feat(C217): cortes e links compartilhaveis na fonte "Falas na internet" (Closes #1293)
This commit is contained in:
parent
b119d203aa
commit
4ec95b0503
33 changed files with 1954 additions and 308 deletions
1
docs/changelog/2026-09-24-c217.md
Normal file
1
docs/changelog/2026-09-24-c217.md
Normal 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.
|
||||
245
docs/plans/acervo-cortes-fonte-web-impl.md
Normal file
245
docs/plans/acervo-cortes-fonte-web-impl.md
Normal 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.
|
||||
|
|
@ -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,
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.'
|
||||
|
||||
|
|
|
|||
|
|
@ -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}`,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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".
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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 ({
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
51
tests/helpers/webSpeechMediaFixture.ts
Normal file
51
tests/helpers/webSpeechMediaFixture.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
|
|
@ -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)', () => {
|
||||
|
|
|
|||
|
|
@ -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({})
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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' })
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
141
tests/unit/webSpeechDetailPlayer.unit.spec.tsx
Normal file
141
tests/unit/webSpeechDetailPlayer.unit.spec.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue