docs(plan): C169 + C170 + C171 + OPS112 — acervo (corte confiável, sem teto, player) e modelos do worktree (Related #1045, #1046, #1047, #1048)

This commit is contained in:
Francisco Solla 2026-09-15 21:19:10 -03:00
parent ee9c9ca068
commit 2e389365a3
6 changed files with 1338 additions and 0 deletions

View file

@ -0,0 +1,93 @@
# Corte do acervo: resolver o trecho com a Câmara instável e falhar dizendo o porquê
Status: rascunho
Atualizado em: 2026-09-15
Issue: #1045
Priority: P1
Impeccable: A — N/A (sem UI nova: só o texto de uma falha existente muda)
Rascunho UI: N/A — sem UI nova
Appetite: ~1 dia eng; um outcome verificável — o "Cortar vídeo" não morre mais na primeira etapa quando o arquivo é recuperável, e quando não há arquivo a falha explica o que aconteceu e deixa tentar de novo
Responsável: —
## Intenção
Em produção (acervo de falas, id 997), a assessoria clica em "Cortar vídeo" e o corte morre na PRIMEIRA etapa — "Localizando o trecho na Câmara" — com um aviso de que a API da Câmara não está entregando o vídeo; mas o trecho está lá, baixável pelo link VOD, e a Câmara costuma gerar/entregar em dezenas de segundos. A janela de espera atual é curta demais para o comportamento real da API, o link já gravado na fala é ignorado como candidato e a causa real fica só numa coluna interna — a pessoa vê um texto genérico e um beco. Este item torna a primeira etapa resiliente (API lenta/instável ou hash morto não é o fim, enquanto existir arquivo verificável) e a falha honesta (diz o que houve e permite tentar de novo sem duplicar o corte).
## Persona e fluxo
- **Persona / contexto:** assessoria de comunicação (`communicator`, `coordinator`, `candidate`) na mesa, prazo curto, já com a seleção [início,fim] feita no detalhe da fala (C166) e o diálogo de corte aberto.
- **Job principal:** cortar o trecho exato sem saber — nem precisar saber — o que é CDN, hash ou timeout; se não der, entender o motivo e poder tentar de novo.
- **Fluxo desejado:** "Cortar e publicar" → "Localizando o trecho na Câmara" com espera suficiente para a API real → se vier hash morto, o link VOD já gravado da fala é tentado e verificado → arquivo encontrado: corta → se realmente não houver arquivo recuperável: falha que nomeia a causa ("ainda gerando", "não existe mais", "não foi possível cortar", "não deu para guardar") + "Tentar novamente" (mesma fala, mesmo trecho, sem corte duplicado) + "Voltar ao acervo".
- **Anti-goals de produto:** espelhar/re-hospedar MP4 (S3), baixar do YouTube, regenerar o acervo em lote, segundo fluxo/editor de corte, novo `Consent`/collection, upload externo, expor o acervo fora de `/campanha`.
## Objetivo e aceite
- **API lenta/instável:** a primeira etapa espera de acordo com o comportamento medido da Câmara (~30 s no primeiro request; depois consultas curtas) dentro de um limite declarado — não morre no primeiro timeout com "a Câmara não está entregando".
- **Hash morto com link ainda vivo:** se a resolução sob demanda falhar mas o link VOD gravado na fala responder arquivo real, o corte conclui com esse arquivo; link morto/falso (403, timeout, `text/html`) nunca é usado.
- **Sem arquivo recuperável:** a falha é honesta — a causa real chega à pessoa em linguagem clara (e o operador segue vendo o detalhe interno); nada é publicado e o fluxo não fica preso: "Tentar novamente" reusa a mesma row, sem duplicar.
- **Fala inelegível:** quando a fala nunca teve vídeo para cortar, a mensagem diz isso de forma direta (não culpa a API da Câmara).
- **Guardrails:** `excerptTMs` verbatim (nunca arredondar/deslocar o trecho); crédito `Fonte: Câmara dos Deputados · CC BY 4.0`; gate do acervo (`canReadSpeechCatalog`; `advisor`/`leader` negados fail-closed); sem espelhar MP4, sem upload externo, sem novo `Consent`/collection/migration; URLs públicas do acervo intocadas.
## Dados (intenção)
- **Vou apresentar dados?** Não — progresso e erro são feedback de ação, não dado de acervo.
- **Decisões desbloqueadas:** a assessoria decide tentar de novo ou buscar outra fonte sabendo a causa; a coordenação decide se um link morto recorrente justifica reprocessar a fala.
- **Forma:** _adiada ao plano de implementação_ — restrição de produto: nada de dashboard/métrica de falhas; a causa é uma mensagem, não um relatório.
## Dados da decisão (literais)
- **API (resolução sob demanda):** `GET https://www.camara.leg.br/evento-legislativo/{eventId}/video-sob-demanda?idAudio={audioId}&trecho={excerptTMs}` → `{ estado: 'GERANDO' | 'PRONTO' | 'INDISPONIVEL', video: { linkParaReproducao, linkParaDownload, duracao, titulo } }`.
- **Trecho:** `trecho` = `excerptTMs` do banco (epoch ms), verbatim; valor vizinho cai em outro orador — nunca alterar.
- **Cadência medida (2026-09-12/14):** o primeiro request de um trecho novo pode levar ~30 s e devolver `GERANDO`; consultas seguintes (~5 s) devolvem `PRONTO` em 1–2 polls; o importador usa 40 polls × 5 s com timeout de 60 s. O resolver do player/corte hoje faz 2 tentativas × 15 s **sem poll** — janela insuficiente para o primeiro request.
- **Links gravados na fala (`vodPlaybackUrl`/`vodDownloadUrl`):** cache — candidatos a tentar quando a API falhar, **sempre verificados** (resposta real, não `text/html`) antes de virar arquivo; nunca requisito para tentar a Câmara.
- **Mensagens da 1ª etapa hoje (todas → `failed`):** fala ausente — "A fala deste corte não está mais disponível."; sem coordenadas — "Esta fala não tem trecho de vídeo para resolver na Câmara."; `GERANDO` — "A Câmara ainda está gerando o vídeo deste trecho."; não `PRONTO`/hash morto — "A Câmara não entregou o arquivo deste trecho."; `PRONTO` sem URL jogável — "A Câmara não entregou um arquivo jogável deste trecho."; transporte no catch — mensagem crua (`HTTP 403 em <url>`, `fetch failed`, timeout).
- **Fallback visível hoje (diálogo):** `SPEECH_CUT_GENERIC_ERROR_MESSAGE` = 'Não foi possível preparar o corte. Verifique seu acesso e tente novamente.' — o aceite substitui/complementa por um vocabulário de causas.
- **Discriminador interno:** a coluna `error` da row `speechCut` (admin) registra a causa exata; `toSpeechCutViewModel` hoje **não** a expõe.
- **Evidência dos quadrantes (C162, 2026-09-14, 997 falas):** 773 ambos · 151 só VOD · 29 só YouTube · 44 nenhum; o CDN apagou os 924 MP4s entre 04:35 e 08:05; `PRONTO` velho pode devolver hash morto (403/timeout) e não aceita forçar regeneração (id 996) → tratar como indisponível.
- **Crédito:** `Fonte: Câmara dos Deputados · CC BY 4.0`.
- **Acesso/guardrails:** corta quem lê o acervo (`canReadSpeechCatalog`); `advisor` e `leader` negados fail-closed; leader lockdown intacto.
## Direção no codebase (hipótese)
- **Áreas prováveis:** `src/utilities/speech/speechVodResolver.ts` (janela, poll e verificação dos links), `src/utilities/speech/speechCutJob.ts` (1ª etapa, candidatos e mensagens), `src/lib/speechVod.ts` (contrato de coordenadas/cache), `src/components/campaign/speech/SpeechCutDialog.tsx` (texto da cena de falha), `src/lib/schemas/speechCut.ts` (literais de erro) e a ação/rota do corte (`src/app/(campaign)/campanha/actions/speech.ts`, `.../acervo/cortar/`).
- **Precedente a olhar:** `scripts/lib/camaraFetch.mjs` (`resolveVod`: 40×5 s, timeout 60 s; `probeLink` decide por content-type) — referência de política, **não importável** de `src/`; `docs/plans/acervo-vod-sob-demanda.md` (C162, janela curta deliberada para o clique do player) e `docs/plans/c167-cortar-trecho-publicar-impl.md` (D2/D3: background, retry por row, reaper). O resolver é compartilhado com o player C162 — ampliar a janela afeta também o clique "assistir/baixar".
- **Testes/risco:** `tests/int/speechCut.int.spec.ts` hoje só cobre o caminho feliz stubado (nada pina a janela real, o poll ou o fallback de link); `tests/e2e/campaignSpeechCut.e2e.spec.ts` e o manifest de e2e mapeiam `src/utilities/speech`/`src/components/campaign/speech`/comunicacao. Não criar caminho de resolução paralelo ao C162 (editar o dono).
## Dependências
- Entregues (duras, satisfeitas): C162 (#999, resolução sob demanda), C167 (#1014, corte [início,fim] + retry sem duplicar); C168 (#1015, biblioteca de cortes) é superfície irmã.
- Irmão sem dependência dura: C170 (teto de duração da seleção) — não muda resolução nem tratamento de falha.
## Fora de escopo
- Espelhar/re-hospedar MP4 (S3/Garage), transcodificar, baixar do YouTube.
- Fila assíncrona/worker/cron, regenerar os 924 em lote, botão "gerar novamente".
- Redesenhar a cena de falha (o texto muda; o layout não), tela de pendências ou dashboard de falhas.
- Migration/collection/`Consent` novos; tornar o link do banco requisito; escrever o link novo resolvido de volta na fala (cache pode ser atualizado depois, fora desta fatia).
## Rabbit holes de produto
- **Re-hospedar o MP4 para "nunca mais falhar".** Se alguém "só completar": storage, custo, retenção e direitos. **Corte neste item:** resolver sob demanda com o que existir (API ou link gravado verificado).
- **Sincronizar todo link novo no banco (cron/reconciliation).** Se alguém "só completar": novas escritas e dono de permissão. **Corte neste item:** o banco segue cache; sem escrita nova obrigatória.
- **Mudar o trecho por vizinhança quando o exato falha.** Se alguém "só completar": muda o orador (fato medido). **Corte neste item:** `excerptTMs` verbatim; falha honesta.
- **Virar fila de suporte ("caso o erro persista, fale com…").** Se alguém "só completar": tickets, SLA, tela de acompanhamento. **Corte neste item:** causa + tentar de novo + voltar ao acervo.
## Questões em aberto (produto)
- **Quanto esperar na 1ª etapa?** **Opções:** A) alinhar ao comportamento medido (primeiro request ~30 s, depois consultas curtas) com limite declarado | B) manter curto e falhar rápido. **Recomendação:** A — esperar menos do que o medido é a causa provável do defeito; o limite continua bounded para não travar o job. _(assumido — validar no gate)_
- **Usar o link VOD gravado?** **Opções:** A) só como candidato verificado depois de a API não servir | B) não usar (só API). **Recomendação:** A — foi pedido explícito ("o trecho é baixável pelo link VOD"); verificar antes de cortar e nunca entregar arquivo morto. _(assumido)_
- **Até onde vai a mensagem honesta?** **Opções:** A) só a 1ª etapa (fatia mínima) | B) toda falha do corte, incluindo cortar/guardar (débito deferido no C167). **Recomendação:** B mínima — a causa já é gravada na row; expor um vocabulário curto de causas cobre a 1ª etapa e o débito sem virar tela nova. _(assumido)_
- **Persistir o link recém-resolvido na fala?** **Opções:** A) não agora | B) sim. **Recomendação:** A — escrita nova exigiria permissão ampliada; fica como dívida registrada se o link morto se repetir. _(assumido)_
## Referências
- GitHub Issue: — (após `pnpm agent:register`)
- Rascunho UI (gate): N/A — sem UI nova
- Planos irmãos: [`acervo-vod-sob-demanda.md`](acervo-vod-sob-demanda.md) (C162/#999), [`c167-cortar-trecho-publicar.md`](c167-cortar-trecho-publicar.md) (C167/#1014) + [`c167-cortar-trecho-publicar-impl.md`](c167-cortar-trecho-publicar-impl.md), [`c168-biblioteca-cortes.md`](c168-biblioteca-cortes.md) (C168/#1015)
- Pesquisa (cadência medida do VOD): `docs/research/piloto-fonte-videos-camara.md:39-42`
- Arquivos-chave (pista, não contrato): `src/utilities/speech/speechVodResolver.ts`, `src/utilities/speech/speechCutJob.ts`, `src/lib/speechVod.ts`, `src/lib/schemas/speechCut.ts`, `src/components/campaign/speech/SpeechCutDialog.tsx`, `src/app/(campaign)/campanha/actions/speech.ts`, `scripts/lib/camaraFetch.mjs`, `tests/int/speechCut.int.spec.ts`, `tests/e2e/campaignSpeechCut.e2e.spec.ts`
- `AGENTS.md` — vertical Comunicação, gate do acervo e crédito CC BY.
## Self-score (shaping)
5/5 — (1) fatia = um outcome verificável (a 1ª etapa resolve com a Câmara instável/link morto verificado; sem arquivo, a falha é honesta e retomável); (2) appetite de ~1 dia comporta janela/poll, candidato verificado, vocabulário de causas e testes, sem schema; (3) persona, job e aceite legíveis sem jargão de stack; (4) direção no codebase é hipótese (áreas, precedente não importável e o alerta de que o resolver é compartilhado com o player); (5) zero decisão dura de engenharia (rota, signatures, persistência e extração de helper ficam para o plano de implementação).

View file

@ -0,0 +1,586 @@
<!doctype html>
<html lang="pt-BR">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
<title>UI draft — C170: corte sem teto (5 s até o fim da fala)</title>
</head>
<body class="bg-zinc-950 text-zinc-100 antialiased">
<main class="mx-auto max-w-[1400px] p-6 sm:p-8">
<h1 class="text-lg font-medium text-zinc-100">
UI draft — Acervo: cortar sem teto — do mínimo de 5 s até o fim da fala (C170)
</h1>
<p class="mt-1 max-w-3xl text-sm text-zinc-400">
A assessoria seleciona um trecho longo (discurso inteiro, fala de vários minutos) no mesmo
player do acervo: o teto de 3 min sai dos textos e do servidor, o mínimo de 5 s permanece, e
o corte publicado/baixado corresponde exatamente ao [início,fim] escolhido.
</p>
<p class="mt-2 text-sm text-zinc-500">
Cinza = estrutura existente ·
<span class="text-amber-400">acento</span> = CTA primário e o que muda neste item (copy e
valores sem teto) · barras cinza = conteúdo não especificado. Cenas estáticas: sem
interação, sem dados reais.
</p>
<!-- ============================================================ -->
<!-- Cena 1 · desktop (~1280px) — detalhe com seleção de 12 min -->
<!-- ============================================================ -->
<section class="mt-8">
<p class="text-xs tracking-wide text-zinc-500 uppercase">
Cena 1 · desktop (~1280px) — seleção longa (12 min) na barra de trecho, sem travar em 3
min
</p>
<div
class="mt-3 w-[1280px] max-w-full overflow-hidden rounded-xl border border-zinc-800 bg-zinc-900"
>
<div class="px-6 py-5">
<p class="text-xs text-zinc-500">← Voltar ao acervo</p>
<h2 class="mt-2 text-base font-medium text-zinc-100">DISCURSO EM PLENÁRIO</h2>
<p class="mt-1 text-xs text-zinc-500">
11/08/2026 · 14:32 · Discurso em plenário · 21min07s
</p>
<div class="mt-4 grid grid-cols-[1fr_260px] gap-5">
<div>
<div
class="relative flex aspect-video items-center justify-center rounded-lg border border-zinc-800 bg-zinc-950"
>
<div
class="flex h-12 w-12 items-center justify-center rounded-full bg-zinc-800 text-zinc-300"
>
▶
</div>
<p class="absolute bottom-3 left-3 text-xs text-zinc-400">
Vídeo da sessão no YouTube
</p>
</div>
<!-- SpeechExcerptControls com o limite novo -->
<div class="mt-3 rounded-lg border border-zinc-800 bg-zinc-950 p-3">
<div class="flex flex-wrap items-center justify-between gap-2">
<span class="text-xs font-medium text-amber-400">Trecho selecionado</span>
<span class="text-xs text-zinc-400 tabular-nums">
<span class="text-zinc-100">02:14</span> →
<span class="text-zinc-100">14:14</span> ·
<span class="text-amber-400">12min0s</span>
</span>
</div>
<div class="relative mt-4 h-9">
<div
class="absolute inset-x-0 top-1/2 h-1.5 -translate-y-1/2 rounded-full bg-zinc-800"
></div>
<div
class="absolute top-1/2 left-[10.6%] right-[32.6%] h-1.5 -translate-y-1/2 rounded-full bg-amber-500/70"
></div>
<div
class="absolute top-1/2 left-[10.6%] h-7 w-3 -translate-x-1/2 -translate-y-1/2 rounded-sm border border-amber-400 bg-zinc-900"
></div>
<div
class="absolute top-1/2 right-[32.6%] h-7 w-3 translate-x-1/2 -translate-y-1/2 rounded-sm border border-amber-400 bg-zinc-900"
></div>
</div>
<div class="mt-1 flex justify-between text-[10px] text-zinc-500 tabular-nums">
<span>00:00</span>
<span class="text-amber-400">02:14</span>
<span class="text-amber-400">14:14</span>
<span>21:07</span>
</div>
<p class="mt-2 text-[11px] leading-relaxed text-zinc-500">
Clique nas frases: o limite encaixa no segmento (ímã). Arraste as alças para o
ajuste fino.
<span class="text-amber-400">Mínimo 5s · sem limite — até o fim da fala</span>
— a duração aparece enquanto seleciona.
</p>
</div>
<div class="mt-3 flex flex-wrap items-center gap-2">
<button
type="button"
class="min-h-11 rounded-lg border border-zinc-600 bg-zinc-800 px-4 text-sm text-zinc-100"
>
Selecionar trecho
</button>
<button
type="button"
class="min-h-11 rounded-lg bg-amber-500 px-4 text-sm font-medium text-zinc-950"
>
Cortar vídeo
</button>
<button
type="button"
class="min-h-11 rounded-lg border border-zinc-700 px-4 text-sm text-zinc-300"
>
Compartilhar
</button>
<button
type="button"
class="min-h-11 rounded-lg border border-zinc-700 px-4 text-sm text-zinc-300"
>
Baixar vídeo (MP4)
</button>
<button
type="button"
class="min-h-11 rounded-lg border border-zinc-700 px-4 text-sm text-zinc-300"
>
Abrir fonte
</button>
</div>
<p class="mt-5 text-xs tracking-wide text-zinc-500 uppercase">
Transcrição (clique nas frases para marcar o trecho)
</p>
<div class="mt-2 space-y-1">
<div class="flex gap-3 rounded-lg px-2 py-1.5">
<span class="w-12 shrink-0 text-xs text-zinc-500">00:00</span>
<span class="text-sm text-zinc-400"
>… senhor Presidente, colegas deputados …</span
>
</div>
<div class="flex gap-3 rounded-lg px-2 py-1.5">
<span class="w-12 shrink-0 text-xs text-zinc-500">01:10</span>
<span class="text-sm text-zinc-400">… agradeço a presença de todos …</span>
</div>
<div
class="flex gap-3 rounded-lg border-l-2 border-amber-500/60 bg-amber-500/10 px-2 py-1.5"
>
<span class="w-12 shrink-0 text-xs text-amber-400">02:14</span>
<span class="text-sm text-zinc-100"
>… a saúde pública na Bahia vive um momento decisivo …</span
>
</div>
<div
class="flex gap-3 rounded-lg border-l-2 border-amber-500/60 bg-amber-500/10 px-2 py-1.5"
>
<span class="w-12 shrink-0 text-xs text-amber-400">05:30</span>
<span class="text-sm text-zinc-100"
>… o financiamento tripartite precisa ser cumprido …</span
>
</div>
<div
class="flex gap-3 rounded-lg border-l-2 border-amber-500/60 bg-amber-500/10 px-2 py-1.5"
>
<span class="w-12 shrink-0 text-xs text-amber-400">08:10</span>
<span class="text-sm text-zinc-100"
>… municípios do interior não conseguem manter o atendimento …</span
>
</div>
<div
class="flex gap-3 rounded-lg border-l-2 border-amber-500/60 bg-amber-500/10 px-2 py-1.5"
>
<span class="w-12 shrink-0 text-xs text-amber-400">13:45</span>
<span class="text-sm text-zinc-100"
>… defendo um compromisso com quem mora longe das capitais …</span
>
</div>
<div class="flex gap-3 rounded-lg px-2 py-1.5">
<span class="w-12 shrink-0 text-xs text-zinc-500">14:38</span>
<span class="text-sm text-zinc-400">… muito obrigado, Presidente. …</span>
</div>
<div class="flex gap-3 rounded-lg px-2 py-1.5">
<span class="w-12 shrink-0 text-xs text-zinc-500">16:20</span>
<span class="text-sm text-zinc-400"
>… encerro pedindo apoio ao requerimento …</span
>
</div>
</div>
</div>
<aside class="space-y-4">
<div>
<p class="text-xs tracking-wide text-zinc-500 uppercase">Sumário oficial</p>
<div class="mt-2 space-y-1.5">
<div class="h-3 w-full rounded bg-zinc-800"></div>
<div class="h-3 w-5/6 rounded bg-zinc-800"></div>
</div>
</div>
<div>
<p class="text-xs tracking-wide text-zinc-500 uppercase">Temas</p>
<div class="mt-1.5 flex flex-wrap gap-1.5">
<span class="rounded-full bg-zinc-800 px-2 py-0.5 text-xs text-zinc-300"
>Saúde</span
>
</div>
</div>
<div class="border-t border-zinc-800 pt-3">
<p class="text-xs text-zinc-500">Fonte: Câmara dos Deputados · CC BY 4.0</p>
</div>
</aside>
</div>
</div>
</div>
<p class="mt-2 text-xs text-zinc-500">
O teto de 3 min sai dos textos da barra; o teto passa a ser o fim da fala (21:07). O
mínimo de 5 s permanece — e a duração do trecho (12min0s) é o feedback que já existe.
</p>
</section>
<!-- ============================================================ -->
<!-- Cena 2 · desktop — diálogo "Cortar vídeo" com a copy nova -->
<!-- ============================================================ -->
<section class="mt-8">
<p class="text-xs tracking-wide text-zinc-500 uppercase">
Cena 2 · diálogo "Cortar vídeo" — duração longa com a copy nova (sem faixa de 5–180 s)
</p>
<div class="mt-3 rounded-xl border border-zinc-800 bg-zinc-950/70 p-6">
<div
class="mx-auto w-[560px] max-w-full rounded-xl border border-zinc-700 bg-zinc-900 p-6"
>
<div>
<h2 class="text-base font-medium text-zinc-100">Cortar vídeo</h2>
<p class="mt-0.5 text-xs text-zinc-500">
O arquivo sai com o trecho exato — nada antes, nada depois.
</p>
</div>
<div class="mt-4 rounded-lg border border-zinc-700 bg-zinc-950 p-3">
<div class="flex items-center justify-between gap-3">
<span class="font-mono text-sm text-zinc-100">02:14 → 14:14</span>
<span class="text-xs text-amber-400">
12min0s · mínimo 5 s · até o fim da fala
</span>
</div>
<p class="mt-2 text-xs text-zinc-400">
"… a saúde pública na Bahia vive um momento decisivo …"
</p>
</div>
<div class="mt-4">
<label class="text-xs tracking-wide text-zinc-500 uppercase">Título</label>
<div
class="mt-1.5 flex min-h-11 w-full items-center rounded-lg border border-zinc-700 bg-zinc-950 px-3 text-sm text-zinc-100"
>
Saúde pública: financiamento e acesso no interior da Bahia
</div>
</div>
<div class="mt-4">
<label class="text-xs tracking-wide text-zinc-500 uppercase">Descrição</label>
<div
class="mt-1.5 min-h-[88px] w-full rounded-lg border border-zinc-700 bg-zinc-950 px-3 py-2.5 text-sm text-zinc-300"
>
No pronunciamento, o deputado cobra o financiamento tripartite da saúde e defende
mais recursos para o atendimento no interior baiano.
</div>
</div>
<div
class="mt-4 rounded-lg border border-amber-500/40 bg-amber-500/10 p-3 text-xs text-amber-200/90"
>
Sugerido por IA — revise antes de publicar. Os dois campos são editáveis.
</div>
<div class="mt-5 flex items-center justify-end gap-2">
<button
type="button"
class="min-h-11 rounded-lg border border-zinc-700 px-4 text-sm text-zinc-300"
>
Cancelar
</button>
<button
type="button"
class="min-h-11 rounded-lg bg-amber-500 px-4 text-sm font-medium text-zinc-950"
>
Cortar e publicar
</button>
</div>
<p class="mt-3 text-xs text-zinc-500">
Nada é publicado antes de confirmar. No fim, o corte ganha o link
<span class="font-mono">/corte/123</span> e o arquivo fica na biblioteca de cortes
(C168).
</p>
</div>
</div>
<p class="mt-2 text-xs text-zinc-500">
Só a linha do trecho muda: "dentro da faixa de 5–180 s" vira "mínimo 5 s · até o fim da
fala". A mensagem de erro do servidor e o prompt da IA também perdem o 180 (fora da tela).
</p>
</section>
<!-- ============================================================ -->
<!-- Cena 3 · variantes da barra — extremos que o teto bloqueava -->
<!-- ============================================================ -->
<section class="mt-8">
<p class="text-xs tracking-wide text-zinc-500 uppercase">
Cena 3 · variantes da seleção — trecho de 12 min (A), até o fim da fala (B) e fala inteira
(C)
</p>
<div class="mt-3 flex flex-wrap gap-4">
<div class="w-[380px] rounded-lg border border-zinc-800 bg-zinc-900 p-4">
<p class="text-xs text-zinc-500">A · trecho longo comum</p>
<p class="mt-1 text-sm text-zinc-100 tabular-nums">
02:14 → 14:14 · <span class="text-amber-400">12min0s</span>
</p>
<div class="relative mt-3 h-8">
<div
class="absolute inset-x-0 top-1/2 h-1.5 -translate-y-1/2 rounded-full bg-zinc-800"
></div>
<div
class="absolute top-1/2 left-[10.6%] right-[32.6%] h-1.5 -translate-y-1/2 rounded-full bg-amber-500/70"
></div>
<div
class="absolute top-1/2 left-[10.6%] h-6 w-2.5 -translate-x-1/2 -translate-y-1/2 rounded-sm border border-amber-400 bg-zinc-900"
></div>
<div
class="absolute top-1/2 right-[32.6%] h-6 w-2.5 translate-x-1/2 -translate-y-1/2 rounded-sm border border-amber-400 bg-zinc-900"
></div>
</div>
<p class="mt-2 text-[11px] text-zinc-500">
Antes: a barra travava o fim em 3 min. Agora vai até onde precisa.
</p>
</div>
<div class="w-[380px] rounded-lg border border-zinc-800 bg-zinc-900 p-4">
<p class="text-xs text-zinc-500">B · até o fim da fala (fim = duração)</p>
<p class="mt-1 text-sm text-zinc-100 tabular-nums">
18:32 → 21:07 · <span class="text-amber-400">2min35s</span>
</p>
<div class="relative mt-3 h-8">
<div
class="absolute inset-x-0 top-1/2 h-1.5 -translate-y-1/2 rounded-full bg-zinc-800"
></div>
<div
class="absolute top-1/2 left-[87.8%] right-0 h-1.5 -translate-y-1/2 rounded-full bg-amber-500/70"
></div>
<div
class="absolute top-1/2 left-[87.8%] h-6 w-2.5 -translate-x-1/2 -translate-y-1/2 rounded-sm border border-amber-400 bg-zinc-900"
></div>
<div
class="absolute top-1/2 right-0 h-6 w-2.5 translate-x-1/2 -translate-y-1/2 rounded-sm border border-amber-400 bg-zinc-900"
></div>
</div>
<p class="mt-2 text-[11px] text-zinc-500">
O fim encosta no último segundo da fala; o rótulo diz "até o fim da fala".
</p>
</div>
<div class="w-[380px] rounded-lg border border-zinc-800 bg-zinc-900 p-4">
<p class="text-xs text-zinc-500">C · fala inteira</p>
<p class="mt-1 text-sm text-zinc-100 tabular-nums">
00:00 → 21:07 · <span class="text-amber-400">21min07s</span>
</p>
<div class="relative mt-3 h-8">
<div
class="absolute inset-x-0 top-1/2 h-1.5 -translate-y-1/2 rounded-full bg-amber-500/70"
></div>
<div
class="absolute top-1/2 left-0 h-6 w-2.5 -translate-x-1/2 -translate-y-1/2 rounded-sm border border-amber-400 bg-zinc-900"
></div>
<div
class="absolute top-1/2 right-0 h-6 w-2.5 translate-x-1/2 -translate-y-1/2 rounded-sm border border-amber-400 bg-zinc-900"
></div>
</div>
<p class="mt-2 text-[11px] text-zinc-500">
Permitido: o teto é a duração da fala. O mínimo de 5 s não some — seleções curtas
seguem bloqueadas.
</p>
</div>
</div>
</section>
<!-- ============================================================ -->
<!-- Cena 4 · mobile (~390px) — seleção longa no detalhe -->
<!-- ============================================================ -->
<section class="mt-8">
<p class="text-xs tracking-wide text-zinc-500 uppercase">
Cena 4 · mobile (~390px) — mesma seleção longa, ações empilhadas
</p>
<div class="mt-3 w-[390px] overflow-hidden rounded-xl border border-zinc-800 bg-zinc-900">
<div class="flex items-center justify-between border-b border-zinc-800 px-4 py-3">
<span class="text-zinc-400">←</span>
<span class="text-sm font-medium text-zinc-200">Comunicação</span>
<span class="h-6 w-6 rounded-full bg-zinc-800"></span>
</div>
<div class="px-4 py-4">
<p class="text-xs text-zinc-500">Acervo · fala</p>
<h2 class="mt-1 text-sm font-medium text-zinc-100">DISCURSO EM PLENÁRIO</h2>
<p class="mt-0.5 text-xs text-zinc-500">11/08/2026 · 21min07s</p>
<div
class="mt-3 flex aspect-video items-center justify-center rounded-lg border border-zinc-800 bg-zinc-950"
>
<div
class="flex h-10 w-10 items-center justify-center rounded-full bg-zinc-800 text-zinc-300"
>
▶
</div>
</div>
<div class="mt-3 rounded-lg border border-zinc-800 bg-zinc-950 p-3">
<div class="flex flex-wrap items-center justify-between gap-1 text-xs">
<span class="font-medium text-amber-400">Trecho selecionado</span>
<span class="text-zinc-100 tabular-nums">02:14 → 14:14 · 12min0s</span>
</div>
<div class="relative mt-3 h-8">
<div
class="absolute inset-x-0 top-1/2 h-1.5 -translate-y-1/2 rounded-full bg-zinc-800"
></div>
<div
class="absolute top-1/2 left-[10.6%] right-[32.6%] h-1.5 -translate-y-1/2 rounded-full bg-amber-500/70"
></div>
<div
class="absolute top-1/2 left-[10.6%] h-6 w-3 -translate-x-1/2 -translate-y-1/2 rounded-sm border border-amber-400 bg-zinc-900"
></div>
<div
class="absolute top-1/2 right-[32.6%] h-6 w-3 translate-x-1/2 -translate-y-1/2 rounded-sm border border-amber-400 bg-zinc-900"
></div>
</div>
<div class="mt-2 flex justify-between text-[10px] text-zinc-500 tabular-nums">
<span>00:00</span>
<span class="text-amber-400">02:14</span>
<span class="text-amber-400">14:14</span>
<span>21:07</span>
</div>
<p class="mt-2 text-[10px] text-zinc-500">
<span class="text-amber-400">Mínimo 5s · sem limite — até o fim da fala</span> — a
duração aparece enquanto seleciona.
</p>
</div>
<button
type="button"
class="mt-3 h-11 w-full rounded-lg bg-amber-500 text-sm font-medium text-zinc-950"
>
Cortar vídeo
</button>
<button
type="button"
class="mt-2 h-11 w-full rounded-lg border border-zinc-700 text-sm text-zinc-300"
>
Selecionar trecho
</button>
<button
type="button"
class="mt-2 h-11 w-full rounded-lg border border-zinc-700 text-sm text-zinc-300"
>
Compartilhar
</button>
<button
type="button"
class="mt-2 h-11 w-full rounded-lg border border-zinc-700 text-sm text-zinc-300"
>
Baixar vídeo (MP4)
</button>
<p class="mt-4 text-xs tracking-wide text-zinc-500 uppercase">Transcrição</p>
<div class="mt-2 space-y-0.5">
<div
class="flex gap-3 rounded-lg border-l-2 border-amber-500/60 bg-amber-500/10 px-2 py-1.5"
>
<span class="w-10 shrink-0 text-xs text-amber-400">02:14</span>
<span class="text-sm text-zinc-100">… a saúde pública na Bahia …</span>
</div>
<div
class="flex gap-3 rounded-lg border-l-2 border-amber-500/60 bg-amber-500/10 px-2 py-1.5"
>
<span class="w-10 shrink-0 text-xs text-amber-400">08:10</span>
<span class="text-sm text-zinc-100">… o atendimento no interior …</span>
</div>
<div class="flex gap-3 rounded-lg px-2 py-1.5">
<span class="w-10 shrink-0 text-xs text-zinc-500">14:38</span>
<span class="text-sm text-zinc-400">… muito obrigado, Presidente. …</span>
</div>
</div>
<p class="mt-4 border-t border-zinc-800 pt-3 text-xs text-zinc-500">
Fonte: Câmara dos Deputados · CC BY 4.0
</p>
</div>
</div>
</section>
<!-- ============================================================ -->
<!-- Cena 5 · mobile (~390px) — diálogo de corte -->
<!-- ============================================================ -->
<section class="mt-8">
<p class="text-xs tracking-wide text-zinc-500 uppercase">
Cena 5 · mobile (~390px) — diálogo de corte com a copy nova
</p>
<div class="mt-3 w-[390px] rounded-xl border border-zinc-800 bg-zinc-950/70 p-4">
<div class="rounded-xl border border-zinc-700 bg-zinc-900 p-4">
<h2 class="text-base font-medium text-zinc-100">Cortar vídeo</h2>
<p class="mt-0.5 text-xs text-zinc-500">
O arquivo sai com o trecho exato — nada antes, nada depois.
</p>
<div class="mt-3 rounded-lg border border-zinc-700 bg-zinc-950 p-3">
<div class="flex flex-wrap items-center justify-between gap-1">
<span class="font-mono text-sm text-zinc-100">02:14 → 14:14</span>
<span class="text-xs text-amber-400">12min0s · mínimo 5 s · até o fim da fala</span>
</div>
</div>
<div class="mt-3">
<label class="text-xs tracking-wide text-zinc-500 uppercase">Título</label>
<div
class="mt-1.5 flex min-h-11 w-full items-center rounded-lg border border-zinc-700 bg-zinc-950 px-3 text-sm text-zinc-100"
>
Saúde pública: financiamento e acesso no interior
</div>
</div>
<div class="mt-3">
<label class="text-xs tracking-wide text-zinc-500 uppercase">Descrição</label>
<div
class="mt-1.5 min-h-24 w-full rounded-lg border border-zinc-700 bg-zinc-950 px-3 py-2.5 text-sm text-zinc-300"
>
No pronunciamento, o deputado cobra o financiamento tripartite da saúde e defende
mais recursos para o interior baiano.
</div>
</div>
<div
class="mt-3 rounded-lg border border-amber-500/40 bg-amber-500/10 p-3 text-xs text-amber-200/90"
>
Sugerido por IA — revise antes de publicar. Os dois campos são editáveis.
</div>
<button
type="button"
class="mt-4 h-11 w-full rounded-lg bg-amber-500 text-sm font-medium text-zinc-950"
>
Cortar e publicar
</button>
<button
type="button"
class="mt-2 h-11 w-full rounded-lg border border-zinc-700 text-sm text-zinc-300"
>
Cancelar
</button>
<p class="mt-3 text-xs text-zinc-500">
Nada é publicado antes de confirmar. O corte longo segue o mesmo fluxo — espera com
progresso honesto (C167).
</p>
</div>
</div>
</section>
<!-- ============================================================ -->
<!-- Notas para o gate -->
<!-- ============================================================ -->
<section class="mt-8 rounded-lg border border-zinc-800 bg-zinc-900 p-5">
<p class="text-xs tracking-wide text-zinc-500 uppercase">O que o gate valida aqui</p>
<ul class="mt-2 space-y-1 text-sm text-zinc-400">
<li>
1. O rótulo novo da barra ("Mínimo 5s · sem limite — até o fim da fala") e a ausência de
qualquer menção a 3 min/180 s nos textos visíveis.
</li>
<li>
2. A seleção longa (12 min) cabe na barra e o fim pode encostar no fim da fala (cenas 1,
3B e 4).
</li>
<li>
3. O diálogo de corte mantém a hierarquia atual, só trocando a faixa de duração pela
copy nova (cenas 2 e 5).
</li>
<li>
4. O resto do detalhe (player, transcrição com ímã, compartilhar, baixar, crédito CC BY)
fica intacto — a fatia não redesenha nada.
</li>
</ul>
</section>
</main>
</body>
</html>

View file

@ -0,0 +1,106 @@
# Acervo: cortar sem teto — do mínimo de 5 s até o fim da fala
Status: rascunho
Atualizado em: 2026-09-15
Issue: #1046
Priority: P2
Impeccable: B — encaixe no player/detalhe do acervo
Rascunho UI: docs/plans/c170-corte-sem-limite-ui-draft.html
Appetite: ~0,5–1 dia eng; um outcome verificável — qualquer trecho de 5 s até o fim da fala corta, publica e baixa exatamente o intervalo escolhido
Responsável: —
## Intenção
O corte do acervo trava em 3 min (180 s). A assessoria precisa cortar trechos longos — um discurso inteiro, uma fala de vários minutos — e esbarra num teto que não é do material, é da ferramenta: acima de 3 min a barra de seleção não anda e o servidor recusa o corte, então o trecho longo sai por fora (baixar a sessão, editar em outra ferramenta). Esta fatia remove o teto de produto: o limite passa a ser a própria fala — do mínimo de 5 s até o fim — e o corte publicado/baixado corresponde exatamente ao trecho escolhido. Sem superfície nova, sem editor: é a mesma seleção do C166 e o mesmo corte do C167, sem teto.
## Persona e fluxo
- **Persona / contexto:** assessoria de comunicação (communicator; coordenação e candidatura também), na mesa ou em campo, montando peça com o discurso inteiro ou uma fala de vários minutos.
- **Job principal:** selecionar e cortar o trecho exato que a peça precisa, inclusive um trecho longo, sem teto artificial de duração.
- **Fluxo desejado:** abre a fala → "Selecionar trecho" → marca o início nas frases (ímã) e leva o fim até onde precisar — até o fim da fala; a barra não trava mais em 3 min e a duração aparece enquanto seleciona → "Compartilhar" (C166) ou "Cortar vídeo" (C167) → o corte publicado/baixado começa e termina nos marcos escolhidos, mesmo com 12 min ou a fala inteira.
- **Anti-goals de produto:** não é editor de vídeo nem timeline com zoom; não muda o mínimo de 5 s; não mexe no mecanismo de corte nem no resolver (item irmão de pipeline, C169) nem na publicação/kill switch (C167/C168); não cria presets de duração nem seleção multi-trecho; não muda o gate do acervo nem o crédito CC BY.
### Esboço de fluxo (B/C/D)
```text
[detalhe da fala] → "Selecionar trecho" → [início nas frases] + [fim até onde precisar: ≥5s, até o fim da fala]
→ duração visível (ex.: 12min) → [Compartilhar (C166) | Cortar vídeo (C167)]
→ corte publicado/baixado = [início,fim] exatos (sem teto de 3 min)
```
### Rascunho UI (B/C/D)
- Rascunho UI (gate): `docs/plans/c170-corte-sem-limite-ui-draft.html` — cenas: detalhe com seleção longa (12 min) e o rótulo novo do limite; diálogo "Cortar vídeo" com a copy nova; variantes (até o fim da fala; fala inteira); mobile com seleção longa e diálogo.
## Objetivo e aceite
- Selecionar [início,fim] de 5 s até a duração total da fala: o fim alcança o último segundo da fala e o início, o primeiro; a barra não trava mais em 180 s.
- O corte publicado/baixado corresponde exatamente ao trecho escolhido — começa e termina nos marcos escolhidos, inclusive trechos longos (12 min, fala inteira).
- Robustez: um corte longo acima dos ~3 min atuais conclui — o arquivo toca e baixa; se o pipeline falhar por limite operacional (download do VOD, tempo do ffmpeg), falha honesta como hoje e nada é publicado (causa no item de pipeline, não vira teto de produto).
- A copy diz a verdade: limite visível = "Mínimo 5s · sem limite — até o fim da fala"; a duração aparece durante a seleção.
- Mínimo de 5 s mantido (decisão de produto: evita corte de 1 s sem uso). _(assumido — validar no gate)_
- Guardrails: `excerptTMs`/seleção exata [início,fim] intocados; crédito "Fonte: Câmara dos Deputados · CC BY 4.0"; gate do acervo intocado (communicator/coordinator/candidate; advisor/leader negados fail-closed); kill switch/publicação do C167/C168 intocados; sem editor de vídeo; sem trocar início/fim depois de cortado.
## Dados (intenção)
- **Vou apresentar dados?** Não — a superfície é a barra de seleção e o diálogo de corte; duração/`mm:ss` é feedback de ação, não dado do acervo.
- **Decisões desbloqueadas:** a assessoria decide cortar um trecho longo (discurso inteiro, fala de vários minutos) sem sair da ferramenta; a coordenação decide o que fica no ar (C168).
- **Forma:** _adiada ao plano de implementação_ — restrição: sem KPI/telemetria de corte nesta fatia.
## Dados da decisão (literais)
- Teto atual **removido**: `MAX_EXCERPT_SECONDS = 180` (`src/lib/speechExcerptSelection.ts:21`), aplicado em `normalizeExcerptRange` (:54) e `moveRangeEdge` (:125,134).
- Mínimo **mantido**: `MIN_EXCERPT_SECONDS = 5` (`src/lib/speechExcerptSelection.ts:44-53`).
- Limite efetivo: `durationSeconds` da fala — [início,fim] pode ir de 5 s até a duração total (fala inteira permitida).
- Copy nova (exata):
- Barra de seleção (`SpeechExcerptControls.tsx:31`): `Mínimo 5s · máximo 3min` → `Mínimo 5s · sem limite — até o fim da fala`; `aria-valuemin/max` (:114,119) passam a refletir 0–duração.
- Diálogo de corte (`SpeechCutDialog.tsx:242-244`): `dentro da faixa de 5–180 s` → `mínimo 5 s · até o fim da fala` (linha vira `<duração> · mínimo 5 s · até o fim da fala`).
- Mensagem de erro (`src/lib/schemas/speechCut.ts:17`): `Selecione um trecho de 5 a 180 segundos.` → `Selecione um trecho de 5 segundos até o fim da fala.`
- Prompt da IA (`src/utilities/speech/speechCutMetadata.ts:42`): `Receberá tipo, data, resumo oficial e a transcrição automática (ASR) de um trecho de 5 a 180 segundos.` → `Receberá tipo, data, resumo oficial e a transcrição automática (ASR) de um trecho da fala com 5 segundos ou mais.`
- Servidor: `src/app/(campaign)/campanha/actions/speech.ts:133` (`resolveExcerptRange`, também usado na sugestão de IA em :338) — o guard de máximo passa a ser só a duração da fala; `src/lib/schemas/speechCut.ts:16-17` — zod já limita 0–86.400 (`excerptBoundarySeconds`), o teto real é a action.
- Testes que pinam 180 a atualizar: `tests/unit/speechExcerptSelection.unit.spec.ts:41-42,113,130`; `tests/unit/speechDetailPlayer.unit.spec.tsx:290,306,321,444`; `tests/int/speechCut.int.spec.ts:300` (regex `/5 a 180/`).
- Limites operacionais que um corte longo encosta (aceite de robustez, sem engenharia aqui): `SOURCE_DOWNLOAD_TIMEOUT_MS=180_000`, `MAX_SOURCE_BYTES=2GiB`, `FFMPEG_MAX_TIMEOUT_MS=300_000`, reaper `SPEECH_CUT_STALE_MS=15min` (`src/utilities/speech/speechCutJob.ts:37-44`); qualquer corte >~37,5 s já roda no teto de 5 min do ffmpeg.
## Direção no codebase (hipótese)
- **Áreas prováveis:** `src/lib/speechExcerptSelection.ts` (geometria/limites), `src/components/campaign/speech/` (`SpeechExcerptControls`, `SpeechCutDialog`), `src/app/(campaign)/campanha/actions/speech.ts` + `src/lib/schemas/speechCut.ts` (validação/servidor), `src/utilities/speech/speechCutMetadata.ts` (prompt da IA).
- **Precedente a olhar:** C166 (`c166-compartilhar-trecho-link.md`) e C167 (`c167-cortar-trecho-publicar.md`) — a barra e a validação nasceram com o teto; os testes acima são o mapa da mudança.
- **Risco de acoplamento:** o teto está espalhado (geometria, aria, action, schema, prompt, testes) — remover só a UI deixaria o servidor recusando o corte longo; `campaignSpeechAcervo` (e2e) e as units de `speechDetailPlayer`/`speechExcerptSelection` cobrem o caminho.
## Dependências
- Nenhuma dura. Observação: C169 (defeito do resolver) toca o pipeline de corte — validar um corte longo real depende do pipeline funcionar; risco registrado, sem bloquear.
## Fora de escopo
- Mudar o mínimo de 5 s; editor/timeline/zoom/precisão de frame; segundo player/preview do corte.
- Mecanismo de corte e resolver VOD (C169); publicação, kill switch e biblioteca (C167/C168).
- Corte em partes/streaming/fila assíncrona; presets de duração; seleção multi-trecho; legenda no corte.
- Ampliar papéis com acesso ao acervo; `Consent`/collection novas.
## Rabbit holes de produto
- **"Sem teto = cortar 2 h de sessão de uma vez."** Se alguém "só completar": ffmpeg em partes, fila, storage fragmentado. **Corte neste item:** o teto é a duração da fala; robustez mínima para um corte longo real concluir; infra de mídia não muda.
- **"Já que destravou, tira o mínimo também."** Se alguém "só completar": cortes de 1 s, dezenas por fala, biblioteca poluída. **Corte neste item:** mínimo de 5 s permanece.
- **"Aproveitar e refazer a barra com zoom."** Se alguém "só completar": timeline, precisão de frame, preview. **Corte neste item:** barra atual + ímã de frase, segundos inteiros.
- **"Preview do corte longo antes de publicar."** Se alguém "só completar": render extra, segundo player. **Corte neste item:** o progresso honesto do C167 já basta.
- **"Aumentar timeouts para garantir o corte longo."** Se alguém "só completar": mexer no pipeline às cegas. **Corte neste item:** se o pipeline não aguentar, é defeito do pipeline (C169) — não teto de produto.
## Questões em aberto (produto)
- **O mínimo continua 5 s?** **Opções:** A) manter | B) reduzir junto com o teto. **Recomendação:** A — 5 s evita corte de 1 s sem uso e não é o que a assessoria pediu. _(assumido — validar no gate)_
- **Texto do limite?** **Opções:** A) `Mínimo 5s · sem limite — até o fim da fala` | B) só `até o fim da fala`. **Recomendação:** A — "sem limite" mata a memória do teto de 3 min e "até o fim da fala" diz qual é o teto real. _(assumido)_
- **Selecionar a fala inteira é permitido?** **Opções:** A) sim, [00:00, duração] | B) exigir deixar algo de fora. **Recomendação:** A — o teto é a duração; a peça decide o recorte.
- **Corte longo que falha por limite operacional?** **Opções:** A) tratar como defeito do pipeline (C169), com aceite de robustez aqui | B) reintroduzir teto técnico. **Recomendação:** A — teto de produto não se justifica por timeout; a falha honesta já existe (C167).
## Referências
- GitHub Issue: — (após `pnpm agent:register`)
- Rascunho UI (gate): `docs/plans/c170-corte-sem-limite-ui-draft.html`
- Planos irmãos: [`c166-compartilhar-trecho-link.md`](c166-compartilhar-trecho-link.md), [`c167-cortar-trecho-publicar.md`](c167-cortar-trecho-publicar.md)
- Arquivos-chave (pista, não contrato): `src/lib/speechExcerptSelection.ts`, `src/components/campaign/speech/SpeechExcerptControls.tsx`, `src/components/campaign/speech/SpeechCutDialog.tsx`, `src/app/(campaign)/campanha/actions/speech.ts`, `src/lib/schemas/speechCut.ts`, `src/utilities/speech/speechCutMetadata.ts`, `src/utilities/speech/speechCutJob.ts`
- `AGENTS.md` — vertical Comunicação, gate do acervo e crédito CC BY.
## Self-score (shaping)
5/5 — (1) fatia = um outcome verificável (selecionar de 5 s até o fim da fala e cortar exato) sem nova superfície; (2) appetite de ~0,5–1 dia para remover o teto nas pontas + robustez do corte longo, com os limites operacionais declarados; (3) persona, job e aceite em linguagem de produto, com a copy literal separada; (4) direção no codebase é hipótese (áreas, testes que pinam 180 e o risco C169); (5) zero decisão dura de engenharia — mecanismo, schema e infra ficam na implementação.

View file

@ -0,0 +1,357 @@
<!doctype html>
<html lang="pt-BR">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
<title>UI draft — Acervo: player do YouTube sem beco de signin (C171)</title>
</head>
<body class="bg-zinc-950 text-zinc-100 antialiased">
<main class="mx-auto max-w-5xl p-6 sm:p-8">
<h1 class="text-lg font-medium text-zinc-100">
UI draft — Acervo: player do YouTube sem beco de signin (C171)
</h1>
<p class="mt-1 text-sm text-zinc-400">
A assessoria abre a fala com vídeo no YouTube e o player pede login; a outra janela já está
logada e nada muda. Este rascunho valida a saída: o vídeo toca quando o YouTube deixa e,
quando não deixa, a página oferece em um clique uma superfície que toca ("Assistir na
Câmara") ou "Abrir no YouTube" no ponto exato.
</p>
<p class="mt-2 text-sm text-zinc-500">
Cinza = estrutura existente · <span class="text-amber-400">âmbar</span> = novo neste item ·
barras cinza = conteúdo não especificado. Cenas estáticas: sem interação, sem dados reais.
</p>
<!-- ============================================================ -->
<!-- Cena 1 · desktop — embed bloqueado pelo YouTube + saída -->
<!-- ============================================================ -->
<section class="mt-8">
<p class="text-xs tracking-wide text-zinc-500 uppercase">
Cena 1 · desktop (~1280px) — o estado que a assessoria vê hoje + superfície alternativa
</p>
<div class="mt-3 overflow-hidden rounded-lg border border-zinc-800 bg-zinc-900">
<div class="px-6 py-5">
<p class="text-xs text-zinc-500">← Voltar ao acervo</p>
<h2 class="mt-2 text-base font-medium text-zinc-100">
Título da fala — tipo de sessão
</h2>
<p class="mt-1 text-xs text-zinc-500">
dd/mm/aaaa · hh:mm · Tipo de sessão · duração · Presidiu: [placeholder]
</p>
<div class="mt-4 grid grid-cols-[1fr_260px] gap-5">
<div>
<!-- Dentro do iframe: o YouTube renderiza o pedido de login -->
<div
class="relative flex aspect-video w-full flex-col items-center justify-center gap-2 rounded-lg border border-zinc-800 bg-zinc-950 px-6 text-center"
>
<span
class="absolute top-3 left-3 rounded border border-zinc-700 px-2 py-0.5 text-[10px] tracking-wide text-zinc-400 uppercase"
>
renderizado pelo YouTube (iframe)
</span>
<div
class="flex h-12 w-12 items-center justify-center rounded-full bg-zinc-800 text-zinc-300"
>
▶
</div>
<p class="text-sm font-medium text-zinc-100">
Faça login para confirmar que você não é um robô
</p>
<button
type="button"
class="mt-1 min-h-9 rounded border border-zinc-600 px-3 text-sm text-zinc-300"
>
Fazer login
</button>
<p class="max-w-md text-xs text-zinc-500">
Abre outra janela — a página do acervo continua no mesmo estado.
</p>
</div>
<!-- Saída da página: sempre visível, um clique -->
<div class="mt-3 rounded-lg border border-amber-500/50 bg-amber-500/5 px-4 py-3">
<p class="text-sm font-medium text-zinc-100">
Se o vídeo não abrir aqui, assista por outro caminho:
</p>
<div class="mt-2 flex flex-wrap items-center gap-2">
<button
type="button"
class="min-h-11 rounded-lg bg-amber-500 px-4 text-sm font-medium text-zinc-950"
>
Assistir na Câmara
</button>
<button
type="button"
class="min-h-11 rounded-lg border border-zinc-700 px-4 text-sm text-zinc-200"
>
Abrir no YouTube
</button>
</div>
<p class="mt-2 text-xs text-zinc-400">
O trecho da Câmara toca nesta página; o YouTube abre no ponto da fala. Sem
trecho da Câmara, fica só "Abrir no YouTube".
</p>
</div>
<!-- Ações que já existem no detalhe -->
<div class="mt-3 flex flex-wrap items-center gap-2">
<button
type="button"
class="min-h-11 rounded-lg border border-zinc-700 px-4 text-sm text-zinc-200"
>
Baixar vídeo (MP4)
</button>
<button
type="button"
class="min-h-11 rounded-lg border border-zinc-700 px-4 text-sm text-zinc-200"
>
Abrir fonte
</button>
<button
type="button"
class="min-h-11 rounded-lg border border-zinc-700 px-4 text-sm text-zinc-200"
>
Selecionar trecho
</button>
</div>
<p class="mt-5 text-xs tracking-wide text-zinc-500 uppercase">
Transcrição (clique para posicionar)
</p>
<div class="mt-2 space-y-1">
<div class="flex gap-3 rounded-lg px-2 py-1.5">
<span class="w-12 shrink-0 pt-0.5 text-xs text-zinc-500">00:00</span>
<div class="h-4 w-full rounded bg-zinc-800"></div>
</div>
<div class="flex gap-3 rounded-lg bg-zinc-800/60 px-2 py-1.5">
<span class="w-12 shrink-0 pt-0.5 text-xs text-amber-400">00:43</span>
<div class="h-4 w-11/12 rounded bg-zinc-700"></div>
</div>
<div class="flex gap-3 rounded-lg px-2 py-1.5">
<span class="w-12 shrink-0 pt-0.5 text-xs text-zinc-500">02:10</span>
<div class="h-4 w-5/6 rounded bg-zinc-800"></div>
</div>
<div class="flex gap-3 rounded-lg px-2 py-1.5">
<span class="w-12 shrink-0 pt-0.5 text-xs text-zinc-500">04:02</span>
<div class="h-4 w-2/3 rounded bg-zinc-800"></div>
</div>
</div>
</div>
<aside class="space-y-4">
<div>
<p class="text-xs tracking-wide text-zinc-500 uppercase">Sumário oficial</p>
<div class="mt-2 space-y-1.5">
<div class="h-3 w-full rounded bg-zinc-800"></div>
<div class="h-3 w-5/6 rounded bg-zinc-800"></div>
<div class="h-3 w-4/6 rounded bg-zinc-800"></div>
</div>
</div>
<div>
<p class="text-xs tracking-wide text-zinc-500 uppercase">Temas</p>
<div class="mt-1.5 flex flex-wrap gap-1.5">
<span class="rounded-full bg-zinc-800 px-2 py-0.5 text-xs text-zinc-300"
>[tema]</span
>
</div>
</div>
<div>
<p class="text-xs tracking-wide text-zinc-500 uppercase">Crédito</p>
<p class="mt-1.5 text-xs text-zinc-400">
Fonte: Câmara dos Deputados · CC BY 4.0
</p>
</div>
</aside>
</div>
</div>
</div>
</section>
<!-- ============================================================ -->
<!-- Cena 2 · mobile — mesmo estado bloqueado -->
<!-- ============================================================ -->
<section class="mt-8">
<p class="text-xs tracking-wide text-zinc-500 uppercase">
Cena 2 · mobile (~390px) — embed bloqueado + saídas empilhadas
</p>
<div class="mt-3 w-[390px] overflow-hidden rounded-lg border border-zinc-800 bg-zinc-900">
<div class="px-4 py-4">
<p class="text-xs text-zinc-500">← Voltar ao acervo</p>
<h2 class="mt-2 text-sm font-medium text-zinc-100">Título da fala</h2>
<p class="mt-1 text-xs text-zinc-500">dd/mm/aaaa · Tipo de sessão · duração</p>
<div
class="relative mt-3 flex aspect-video w-full flex-col items-center justify-center gap-1.5 rounded-lg border border-zinc-800 bg-zinc-950 px-4 text-center"
>
<span
class="absolute top-2 left-2 rounded border border-zinc-700 px-1.5 py-0.5 text-[9px] tracking-wide text-zinc-400 uppercase"
>
renderizado pelo YouTube
</span>
<div
class="flex h-9 w-9 items-center justify-center rounded-full bg-zinc-800 text-sm text-zinc-300"
>
▶
</div>
<p class="text-xs font-medium text-zinc-100">
Faça login para confirmar que você não é um robô
</p>
<button
type="button"
class="min-h-8 rounded border border-zinc-600 px-2.5 text-xs text-zinc-300"
>
Fazer login
</button>
</div>
<div class="mt-3 rounded-lg border border-amber-500/50 bg-amber-500/5 px-3 py-3">
<p class="text-xs font-medium text-zinc-100">
Se o vídeo não abrir aqui, assista por outro caminho:
</p>
<div class="mt-2 flex flex-col gap-2">
<button
type="button"
class="min-h-11 w-full rounded-lg bg-amber-500 text-sm font-medium text-zinc-950"
>
Assistir na Câmara
</button>
<button
type="button"
class="min-h-11 w-full rounded-lg border border-zinc-700 text-sm text-zinc-200"
>
Abrir no YouTube
</button>
</div>
</div>
<div class="mt-3 flex flex-col gap-2">
<button
type="button"
class="min-h-11 w-full rounded-lg border border-zinc-700 text-sm text-zinc-200"
>
Baixar vídeo (MP4)
</button>
<button
type="button"
class="min-h-11 w-full rounded-lg border border-zinc-700 text-sm text-zinc-200"
>
Abrir fonte
</button>
</div>
<p class="mt-4 text-xs tracking-wide text-zinc-500 uppercase">
Transcrição (clique para posicionar)
</p>
<div class="mt-2 space-y-1">
<div class="flex gap-3 rounded-lg px-2 py-1.5">
<span class="w-10 shrink-0 pt-0.5 text-xs text-zinc-500">00:43</span>
<div class="h-4 w-full rounded bg-zinc-800"></div>
</div>
<div class="flex gap-3 rounded-lg px-2 py-1.5">
<span class="w-10 shrink-0 pt-0.5 text-xs text-zinc-500">02:10</span>
<div class="h-4 w-4/5 rounded bg-zinc-800"></div>
</div>
</div>
<p class="mt-3 text-[11px] text-zinc-500">Fonte: Câmara dos Deputados · CC BY 4.0</p>
</div>
</div>
</section>
<!-- ============================================================ -->
<!-- Cena 3 · desktop — YouTube tocando: saídas também visíveis -->
<!-- ============================================================ -->
<section class="mt-8">
<p class="text-xs tracking-wide text-zinc-500 uppercase">
Cena 3 · desktop (~1280px) — YouTube tocando: as saídas continuam visíveis (discretas)
</p>
<div class="mt-3 overflow-hidden rounded-lg border border-zinc-800 bg-zinc-900">
<div class="px-6 py-5">
<p class="text-xs text-zinc-500">← Voltar ao acervo</p>
<h2 class="mt-2 text-base font-medium text-zinc-100">
Título da fala — tipo de sessão
</h2>
<p class="mt-1 text-xs text-zinc-500">
dd/mm/aaaa · hh:mm · Tipo de sessão · duração · Presidiu: [placeholder]
</p>
<div class="mt-4">
<div
class="relative flex aspect-video w-full items-center justify-center rounded-lg border border-zinc-800 bg-zinc-950"
>
<div
class="flex h-12 w-12 items-center justify-center rounded-full bg-zinc-800 text-zinc-300"
>
▶
</div>
<span
class="absolute top-3 right-3 rounded border border-zinc-700 px-2 py-0.5 text-[10px] tracking-wide text-zinc-400 uppercase"
>
Vídeo da sessão no YouTube — tocando
</span>
</div>
<div
class="mt-3 flex flex-wrap items-center gap-2 rounded-lg border border-zinc-800 bg-zinc-900/60 px-4 py-2.5"
>
<span class="text-xs text-zinc-400">Se o vídeo não abrir aqui:</span>
<button
type="button"
class="min-h-9 rounded-lg border border-zinc-700 px-3 text-sm text-zinc-200"
>
Assistir na Câmara
</button>
<button
type="button"
class="min-h-9 rounded-lg border border-zinc-700 px-3 text-sm text-zinc-200"
>
Abrir no YouTube
</button>
</div>
<p class="mt-2 text-xs text-zinc-500">
O app não vê o erro interno do iframe: a saída fica ao alcance sem depender de
detectar o bloqueio. A hierarquia (quem fica âmbar) se decide no gate.
</p>
<div class="mt-3 flex flex-wrap items-center gap-2">
<button
type="button"
class="min-h-11 rounded-lg border border-zinc-700 px-4 text-sm text-zinc-200"
>
Baixar vídeo (MP4)
</button>
<button
type="button"
class="min-h-11 rounded-lg border border-zinc-700 px-4 text-sm text-zinc-200"
>
Abrir fonte
</button>
<button
type="button"
class="min-h-11 rounded-lg border border-zinc-700 px-4 text-sm text-zinc-200"
>
Selecionar trecho
</button>
</div>
<p class="mt-5 text-xs tracking-wide text-zinc-500 uppercase">
Transcrição (clique para posicionar)
</p>
<div class="mt-2 space-y-1">
<div class="flex gap-3 rounded-lg px-2 py-1.5">
<span class="w-12 shrink-0 pt-0.5 text-xs text-zinc-500">00:00</span>
<div class="h-4 w-full rounded bg-zinc-800"></div>
</div>
<div class="flex gap-3 rounded-lg px-2 py-1.5">
<span class="w-12 shrink-0 pt-0.5 text-xs text-zinc-500">00:43</span>
<div class="h-4 w-3/4 rounded bg-zinc-800"></div>
</div>
</div>
</div>
</div>
</div>
</section>
</main>
</body>
</html>

View file

@ -0,0 +1,102 @@
# Acervo: o player do YouTube nunca termina num beco de signin — toca ou oferece a saída em um clique
Status: rascunho
Atualizado em: 2026-09-15
Issue: #1047
Priority: P1
Impeccable: B — encaixe no player do detalhe do acervo
Rascunho UI: docs/plans/c171-player-youtube-sem-signin-ui-draft.html
Appetite: ~0,5–1 dia eng; um outcome verificável — abrir uma fala com YouTube toca na página ou dá saída em um clique, nunca um beco de login
Responsável: —
## Intenção
A assessoria abre uma fala do acervo com vídeo no YouTube e o player pede "faça signin para exibir o vídeo"; clicar em signin abre outra janela (onde a pessoa já está logada) e nada muda na página — o pedido de login continua. Quem monta peça com prazo curto fica sem assistir a fala na página. Este item garante a saída: o vídeo toca embutido quando o YouTube deixa e, quando ele não deixa, a página nunca termina no beco de signin — oferece em um clique uma superfície que toca (o trecho da Câmara sob demanda, quando elegível) e/ou "Abrir no YouTube" no ponto exato. O erro interno do embed é invisível ao app, então a saída não pode depender de detectar o bloqueio: ela fica sempre ao alcance de um clique.
## Persona e fluxo
- **Persona / contexto:** assessoria de comunicação (communicator, coordenação e candidatura) em `/campanha/comunicacao`, na mesa ou em campo, produzindo peça.
- **Job principal:** assistir a fala na própria página, sem depender de login no YouTube e sem ficar presa num aviso de signin.
- **Fluxo desejado:** abre a fala com YouTube → o vídeo toca embutido → se o YouTube não liberar (login/idade/embed desabilitado), a pessoa vê que tem saída e clica "Assistir na Câmara" (toca o trecho exato nesta página) ou "Abrir no YouTube" (abre no ponto, na conta dela) → transcrição e clique para posicionar seguem funcionando.
- **Anti-goals de produto:** segundo player/editor; espelhar/baixar/transcodificar vídeo; autenticar no YouTube; detectar/inferir a causa do bloqueio; novo `Consent`/collection/migration; expor o acervo fora de `/campanha`; mexer nas URLs públicas.
### Esboço de fluxo (B)
```text
[abre fala com YouTube] → embed tenta tocar
→ YouTube libera → toca; saídas em 1 clique seguem visíveis ("Assistir na Câmara" | "Abrir no YouTube")
→ YouTube bloqueia (signin/idade/embed) → mesmas saídas logo abaixo do aviso
→ nunca um beco: sempre há um caminho que toca
```
### Rascunho UI (B)
- Rascunho UI (gate): `docs/plans/c171-player-youtube-sem-signin-ui-draft.html` — cenas desktop (~1280px) e mobile (~390px) com o embed bloqueado + superfície alternativa; desktop com o YouTube tocando e as saídas visíveis.
## Objetivo e aceite
- Abrir uma fala com YouTube: se o YouTube permitir, o vídeo toca na página sem exigir login nosso e sem CTA de signin nossa.
- Se o YouTube não liberar o embed, a página NUNCA termina num beco: há sempre, em um clique, uma superfície que toca — "Assistir na Câmara" (trecho exato, quando a fala tem VOD) e/ou "Abrir no YouTube" (link `watch?v=<id>&t=<segundos>`, abre no ponto, na conta de quem já está logado).
- A saída fica ao alcance também no caminho feliz (o app não vê o erro interno do iframe): não é preciso saber que o YouTube bloqueou para se salvar.
- Hoje, com YouTube presente, o caminho da Câmara é inalcançável (o iframe ocupa o lugar e a CTA "Assistir o trecho" nunca aparece); a alternativa passa a conviver com o embed.
- Transcrição (clique para posicionar) e seleção de trecho (C166/C167) intactos; quando a superfície ativa for a da Câmara, o clique na transcrição posiciona nela.
- **Guardrails:** gate do acervo (communicator/coordinator/candidate; advisor/leader negados); crédito "Fonte: Câmara dos Deputados · CC BY 4.0"; `excerptTMs` verbatim (nunca aproxima); sem segundo player/editor; sem `Consent`/collection/migration; URLs públicas intocadas; a postura LGPD do embed na área interna é decisão de produto (questão em aberto).
## Dados (intenção)
- **Vou apresentar dados?** Não — a superfície é player + ações; nenhum KPI/gráfico novo.
- **Decisões desbloqueadas:** a assessoria decide assistir a fala na página sem depender do login do YouTube; a coordenação decide, pelo uso, se a postura `youtube-nocookie` na área interna vale manter.
- **Forma:** _adiada ao plano de implementação_ — restrição: o estado de bloqueio é feedback de ação (o que fazer agora), não dado de acervo.
## Dados da decisão (literais)
- **Embed atual:** `https://www.youtube-nocookie.com/embed/<id>?playsinline=1&rel=0&start=<segundos>` (params `playsinline=1`, `rel=0`; `start` só quando >0) — `src/components/campaign/speech/SpeechDetailPlayer.tsx:29-35`; iframe com `title="Vídeo da sessão no YouTube"`, `allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share; compute-pressure"`, `allowFullScreen`, `loading="lazy"` (`:250-257`); sem `onError`, sem `enablejsapi`, sem fallback.
- **Se a recomendação A/C valer (trocar domínio):** `https://www.youtube.com/embed/<id>?playsinline=1&rel=0&start=<segundos>` — mesmos params, host sem `-nocookie`.
- **Link de "Abrir no YouTube" (já existe):** `https://www.youtube.com/watch?v=<id>&t=<segundos>` (`t=` omitido quando o offset é desconhecido) — `src/lib/speechShare.ts:29-35`.
- **Postura LGPD atual (site público, referência):** `https://www.youtube-nocookie.com/embed/i_fbclWWC5o?playsinline=1&rel=0` com comentário "youtube-nocookie keeps the embed LGPD-friendly" — `src/components/CampaignStorySection.tsx:9,54-61`.
- **Evidência das hipóteses:** o app não vê o erro do player (sem `onError`/API) e nenhum spec carrega o embed real — e2e aborta `https://www.youtube-nocookie.com/**` globalmente (`tests/e2e/fixtures/e2eTest.ts:108`); pins do domínio: `tests/unit/speechDetailPlayer.unit.spec.tsx:183,201,293`, `tests/e2e/campaignSpeechAcervo.e2e.spec.ts:163`, `tests/e2e/frontend.e2e.spec.ts:513`. Hipóteses do defeito (nenhuma confirmada): (a) bot-check do YouTube a embeds anônimos do nocookie ("Sign in to confirm you're not a bot"); (b) cookies de terceiros bloqueados — a sessão logada não chega ao iframe (explica a outra janela que "não muda nada"); (c) vídeo restrito por idade ou embed desabilitado; (d) restrição de embed de live archive da Câmara.
- **Copy pt-BR das saídas:** "Assistir na Câmara" (resolve e toca o trecho exato nesta página); "Abrir no YouTube" (abre no ponto); aviso: "Se o vídeo não abrir aqui, assista por outro caminho:" e apoio "O trecho da Câmara toca nesta página; o YouTube abre no ponto da fala." Sem fala não elegível ao VOD, fica só "Abrir no YouTube".
- **Hoje (inalcançável com YouTube):** `renderMedia` devolve o iframe primeiro e a CTA "Assistir o trecho" nunca aparece; sobra "Baixar vídeo (MP4)", que baixa mas não toca na página (`:248-258`, `:319-331`, `:405-429`).
## Direção no codebase (hipótese)
- **Áreas prováveis:** `src/components/campaign/speech/SpeechDetailPlayer.tsx` (embed + bloco de ações); `src/lib/speechShare.ts` (link de share); `src/utilities/speech/speechVodResolver.ts` + rota `acervo/resolver-vod` (reuso do resolver C162 — nenhum segundo player).
- **Precedente a olhar:** `src/components/CampaignStorySection.tsx` (embed nocookie do site público); estados honestos do próprio player (`StatusPanel`, "Não foi possível carregar o vídeo deste trecho.").
- **Risco de acoplamento:** C162/C166/C167/C169 tocam o mesmo arquivo — encaixe cirúrgico, sem reescrever quadrantes; gate do acervo e crédito CC BY valem; não há CSP/middleware/`headers()` no repo (a postura LGPD é convenção, não configuração).
## Dependências
- Suave: C169 (defeito do resolver no corte) — a superfície "Assistir na Câmara" fica mais confiável com o resolver corrigido; sem dependência dura.
- Entregues: C162 (player VOD sob demanda + YouTube default), C154/C166/C167 (detalhe, seleção, corte).
## Fora de escopo
- Fazer o YouTube tocar quando ele mesmo bloqueia o embed (login/idade/região) e autenticar/SSO no YouTube.
- Detectar ou classificar o motivo do bloqueio (o app não vê o erro interno do iframe).
- Baixar/espelhar/transcodificar vídeo; re-hospedar no S3 (C162/C167); redesenhar o detalhe/lista além do encaixe do player.
- Exposição pública do acervo; segundo player/editor; novo `Consent`/collection/migration.
## Rabbit holes de produto
- **"Fazer o YouTube sempre tocar".** Se alguém "só completar": OAuth, player próprio, proxy de mídia. **Corte neste item:** se o YouTube bloquear, a página oferece a alternativa em um clique.
- **Detectar o bloqueio no iframe.** Se alguém "só completar": IFrame API + eventos + polling + estados condicionais. **Corte neste item:** a saída é independente de detecção — sempre visível; sem prometer aviso automático.
- **"Login resolve".** Se alguém "só completar": round-trip de sessão para dentro do iframe (impossível com cookies de terceiros). **Corte neste item:** o embed é anônimo; o login é problema do YouTube, a saída é nossa.
- **Reescrever o player inteiro.** Se alguém "só completar": novos quadrantes, novo layout, tocar C162. **Corte neste item:** encaixe cirúrgico conforme rascunho; comportamento existente (transcrição, seleção, download) intacto.
## Questões em aberto (produto)
- **Trocar o domínio do embed de `youtube-nocookie.com` para `youtube.com`?** **Opções:** A) sim (mesmos params, só o host) — ataca as hipóteses (a)/(b) de bot-check/sessão e é o caminho direto para "toca sem login" | B) manter `nocookie` e apostar só nas saídas | C) ambos (trocar o host E manter as saídas). **Recomendação:** C — a troca é o remédio barato para embeds anônimos recusados e as saídas cobrem o que o YouTube bloquear de fato (idade, embed desabilitado); a área é interna e autenticada, então o custo LGPD é menor que no site público, mas como mexe na postura do repo, validar no gate. _(assumido — validar com produto)_
- **As saídas ficam sempre visíveis ou só quando o YouTube falha?** **Opções:** A) sempre (secundárias, junto das ações do detalhe) | B) só no estado de bloqueio (exige detecção). **Recomendação:** A — o app não vê o erro interno do iframe; esperar a detecção é o que produz o beco de hoje. _(assumido)_
- **"Abrir no YouTube" abre na aba nova ou embutido?** **Opções:** A) aba nova com o link de share no ponto | B) trocar o embed para `youtube.com` na mesma página. **Recomendação:** A como secundária (link já validado do C166); B é efeito da troca de host, não um botão. _(assumido)_
- **O que o aviso diz?** **Opções:** A) explica e oferece as saídas ("Se o vídeo não abrir aqui, assista por outro caminho:") | B) só oferece as saídas, sem explicar. **Recomendação:** A, sem culpar quem usa. _(assumido)_
## Referências
- GitHub Issue: — (após `pnpm agent:register`)
- Rascunho UI (gate): `docs/plans/c171-player-youtube-sem-signin-ui-draft.html`
- Planos irmãos: `acervo-vod-sob-demanda.md` (C162/#999), `c166-compartilhar-trecho-link.md`, `c167-cortar-trecho-publicar.md`, `c168-biblioteca-cortes.md`
- Arquivos-chave (pista, não contrato): `src/components/campaign/speech/SpeechDetailPlayer.tsx`, `src/lib/speechShare.ts`, `src/utilities/speech/speechVodResolver.ts`, `tests/unit/speechDetailPlayer.unit.spec.tsx`, `tests/e2e/campaignSpeechAcervo.e2e.spec.ts`, `tests/e2e/fixtures/e2eTest.ts`; `AGENTS.md` / `AGENTS-campaign.md` — vertical Comunicação, gate do acervo e crédito CC BY.
## Self-score (shaping)
5/5 — (1) fatia = um outcome verificável (tocar na página ou sair em um clique, nunca beco de signin); (2) appetite de ~0,5–1 dia comporta embed + saídas + pins de teste, sem schema; (3) persona, job e aceite legíveis sem jargão de stack; (4) direção no codebase é hipótese (áreas, precedente e pins como pista); (5) zero decisão dura de engenharia — host do embed, props e layout ficam como questão de produto/implementação.

View file

@ -0,0 +1,94 @@
# Modelos padrão do worktree: `--go` → DeepSeek V4.1 Flash e `--zen` → Muse Spark 1.3 Free
Status: rascunho
Atualizado em: 2026-09-15
Issue: #1048
Priority: P2
Impeccable: A — N/A sem UI
Rascunho UI: N/A — sem UI
Appetite: ~0,5 dia eng — 2 valores do mapa + textos + pin
Responsável: —
## Intenção
Com o lançamento do DeepSeek V4.1 Flash (2026-09-10), ele virou também o modelo mais vantajoso no OpenCode Go — hoje `pnpm worktree … --go` abre a sessão em `opencode-go/hy3`, e o humano quer a flag apontando para o V4.1 Flash. Na mesma passada, `--zen` deve abrir no "Muse Spark 1.3 Free" em vez do atual `opencode-go/ox-alpha-free`. São dois valores do mapa fixo de flags: nada de mecanismo, cardápio ou preset.
## Persona e fluxo
- **Persona / contexto:** mantenedor/agente abrindo worktrees de Issue no terminal, escolhendo o modelo com uma flag no momento da invocação.
- **Job principal:** abrir a sessão já no modelo desejado com um único flag, sem editar config nem decorar ID.
- **Fluxo desejado:** `pnpm worktree next|plan|new|fix --go` (ou `--zen`) → claim/provisionamento como hoje → diretiva `launch … --model <novo ID>` → sessão nasce no modelo certo; footer confirma modelo/provider.
- **Anti-goals de produto:** não vira seletor de modelos nem flag nova; não muda o preset sem flag; não mexe nas outras flags; não persiste preferência.
## Objetivo e aceite
- `pnpm worktree next|plan|new|fix --go` abre a sessão em `opencode-go/deepseek-v4.1-flash`; `--zen` abre em `opencode/muse-spark-1.3-contributor-free`.
- Superfícies que citam o mapa saem sincronizadas na mesma entrega (código, docblocks/help, shell, command, skill, pin unitário) — sem contrato divergente.
- Sem flag, o preset `deepseek/deepseek-flash` permanece byte a byte; `cheap/pro/alibaba/glm/free` e o fail-high at-most-one intactos.
- Guardrail: nenhuma menção a `--variant` volta à diretiva; config global da máquina fora do repo.
## Dados (intenção)
- **Vou apresentar dados?** Não — tooling de dev, sem métrica de produto.
- **Decisões desbloqueadas:** dev/agente confia que `--go`/`--zen` apontam para os modelos correntes, sem consultar o catálogo antes de cada invocação.
- **Forma:** N/A — item config-only.
## Dados da decisão (literais)
| Flag | Valor hoje | Valor novo |
| ------- | --------------------------- | ------------------------------------------ |
| `--go` | `opencode-go/hy3` | `opencode-go/deepseek-v4.1-flash` |
| `--zen` | `opencode-go/ox-alpha-free` | `opencode/muse-spark-1.3-contributor-free` |
- Preset sem flag (intocado): `deepseek/deepseek-flash` (`OPENCODE_PRESET_MODEL`; override `OPENCODE_WORKTREE_MODEL`).
- IDs verificados no catálogo local (`opencode models`, 2026-09-15): `opencode-go` tem `deepseek-v4.1-flash` ("DeepSeek V4.1 Flash"); o `-free` do Muse Spark só existe no provider `opencode` (OpenCode Zen) — `opencode-go` só tem `muse-spark-1.3-contributor` (sem free); logo `--zen` passa a apontar para o provider `opencode`, coerente com o nome da flag.
- Chaves intocadas do mapa: `cheap=cheapestinference/deepseek-v4-flash`, `pro=deepseek/deepseek-v4-pro`, `alibaba=alibaba-token-plan/deepseek-v4-flash`, `glm=opencode-go/glm-5.3-flash`, `free=openrouter/openrouter/free`.
## Superfícies a sincronizar (caminho, não linha)
- `scripts/lib/worktree.mjs` — `WORKTREE_MODEL_MAP` (dono do mapa) + docblock do mapa (nota OPS112).
- `scripts/worktree.mjs` — docblock (o help interpola o mapa e se atualiza sozinho).
- `.agents/shell/worktree.sh` — docblock da função (lista de IDs e nota histórica do `--go`).
- `.opencode/commands/worktree.md` — bloco do `next`.
- `.agents/skills/worktree-next-issue/SKILL.md` — prosa do default-go e do launch (manter a história OPS93/95 e ajustar os valores correntes).
- `tests/unit/worktree.unit.spec.ts` — pin `toEqual` do mapa.
- `.agents/skills/local-database/SKILL.md` — cita o mapa sem IDs: confirmar que nada muda.
- Nova entrada `docs/changelog/2026-09-15-ops112.md` (histórico existente não se edita).
## Direção no codebase (hipótese)
- **Áreas prováveis:** `scripts/lib/worktree.mjs` (fonte única), textos que o citam (acima) e o pin unitário.
- **Precedente a olhar:** OPS95 (`docs/plans/worktree-flags-modelo-correcao.md` — values-only em 2 chaves + sync + pin), OPS100 (`--glm`/`--free`), OPS101 (preset canônico).
- **Risco de acoplamento:** `scripts/worktree.mjs` está em `HIGH_RISK_EXACT` (`scripts/lib/test-affected-core.mjs`) → diff nele dispara unit/int full + e2e curado no PR; `scripts/lib/worktree.mjs` não é high-risk. Sem DB, migration, access, UI ou e2e novo.
## Dependências
- Nenhuma.
## Fora de escopo
- Preset sem flag (`deepseek/deepseek-flash`) e override `OPENCODE_WORKTREE_MODEL`.
- Demais flags do mapa (`cheap/pro/alibaba/glm/free`) e o mecanismo fail-high at-most-one.
- Reintroduzir `--variant`/variantes na diretiva (OPS95) ou mexer na config global da máquina.
- Editar docs históricos/congelados (`docs/plans/*` anteriores, changelogs, HISTORY).
## Rabbit holes de produto
- **Revisar o cardápio inteiro.** Se alguém "só completar": re-checar preço/capacidade e reescrever todas as chaves. **Corte neste item:** só `go` e `zen`.
- **Sincronização parcial.** Atualizar código e esquecer shell/command/skill/pin → contrato divergente (histórico OPS24/OPS95). **Corte:** todas as superfícies acima na mesma entrega.
- **Trocar o preset "de brinde".** O V4.1 Flash já é o preset; qualquer mudança nele fica fora.
## Questões em aberto (produto)
- Nenhuma — a única dúvida (o `--zen` apontar para o provider `opencode`/Zen, não `opencode-go`) foi resolvida na exploração: o ID do catálogo com o nome "Muse Spark 1.3 Free" é `opencode/muse-spark-1.3-contributor-free`, coerente com o nome da flag.
## Referências
- `docs/plans/worktree-flags-modelo-correcao.md` (OPS95 — precedente de correção values-only + sync)
- `docs/plans/worktree-modelo-canonico-deepseek-flash.md` (OPS101 — preset canônico; não mexer)
- `scripts/lib/worktree.mjs` (mapa/preset — fonte única), `tests/unit/worktree.unit.spec.ts` (pin)
- `scripts/lib/test-affected-core.mjs` (HIGH_RISK_EXACT de `scripts/worktree.mjs`)
## Self-score (shaping ≥4)
1. Um outcome verificável? **Sim** — flags resolvem para os IDs novos. 2. Appetite declarado e cabe? **Sim** — 2 valores + textos + pin. 3. Persona/job/aceite claros? **Sim**. 4. Direção é hipótese? **Sim** — caminhos sem contrato técnico. 5. Zero decisão dura de engenharia? **Sim** — valores literais são produto (IDs pedidos). **Total: 5/5.**