diff --git a/docs/changelog/2026-09-24-c217.md b/docs/changelog/2026-09-24-c217.md new file mode 100644 index 00000000..40378e25 --- /dev/null +++ b/docs/changelog/2026-09-24-c217.md @@ -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/` mantém o contrato (unlisted, `noindex`) e o crédito segue a origem real — web usa `Fonte: · ` (`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. diff --git a/docs/plans/acervo-cortes-fonte-web-impl.md b/docs/plans/acervo-cortes-fonte-web-impl.md new file mode 100644 index 00000000..aef1ed74 --- /dev/null +++ b/docs/plans/acervo-cortes-fonte-web-impl.md @@ -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/` 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/` (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/` 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/` 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
/acervo/internet/[id]"] --> PICK["transcrição = picker
speechExcerptSelection + segmento marcado"] + PICK --> BTN["Cortar trecho"] + BTN --> DLG["SpeechCutDialog (o mesmo)
speechSource = web"] + DLG --> ACT["saveSpeechCutForActor
communicationCatalog + speechCutSourceKind"] + ACT -->|camara| JC["job: resolveSpeechVod → downloadSource
(intocado byte a byte)"] + ACT -->|web| JW["job: downloadPrivateMediaToFile do espelho
ffmpeg (áudio: sem 0:v:0 → AAC faststart)"] + JC --> MEDIA["media pública (MP4) + status/poll + retry"] + JW --> MEDIA + MEDIA --> LIB["biblioteca de cortes
origem visível no card"] + MEDIA --> PUB["/corte/
meta/crédito por origem"] + PURE["lib/speechCut (predicado + copy source-aware)
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/`, 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---.mp4`, AAC, `+faststart`): é o contrato do card da biblioteca e da página `/corte/` (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 — `), 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 #`) 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: ` 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/`: 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 · · · ` e crédito `Fonte: · ` (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/` — 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 `