From c010cf815bea1821def1069d45981768a8689ddb Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Tue, 15 Sep 2026 21:57:02 -0300 Subject: [PATCH] =?UTF-8?q?feat(C170):=20acervo=20sem=20teto=20de=20corte?= =?UTF-8?q?=20=E2=80=94=205=20s=20at=C3=A9=20o=20fim=20da=20fala=20(Closes?= =?UTF-8?q?=20#1046)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/changelog/2026-09-16-c170.md | 1 + docs/plans/c170-corte-sem-limite-impl.md | 91 +++++++++++++++++++ src/app/(campaign)/campanha/actions/speech.ts | 16 ++-- .../campaign/speech/SpeechCutDialog.tsx | 10 +- .../campaign/speech/SpeechExcerptControls.tsx | 13 +-- src/lib/schemas/speechCut.ts | 5 +- src/lib/speechExcerptSelection.ts | 16 ++-- src/utilities/speech/speechCutMetadata.ts | 2 +- tests/int/speechCut.int.spec.ts | 28 +++++- tests/unit/speechDetailPlayer.unit.spec.tsx | 14 +-- .../unit/speechExcerptSelection.unit.spec.ts | 15 +-- 11 files changed, 157 insertions(+), 54 deletions(-) create mode 100644 docs/changelog/2026-09-16-c170.md create mode 100644 docs/plans/c170-corte-sem-limite-impl.md diff --git a/docs/changelog/2026-09-16-c170.md b/docs/changelog/2026-09-16-c170.md new file mode 100644 index 00000000..d444d532 --- /dev/null +++ b/docs/changelog/2026-09-16-c170.md @@ -0,0 +1 @@ +**C170 — Acervo: cortar sem teto — do mínimo de 5 s até o fim da fala (2026-09-16):** o teto de produto de 3 min some do código: `MAX_EXCERPT_SECONDS` removido da geometria (`speechExcerptSelection`), do `resolveExcerptRange` da action, da barra, do diálogo, do schema e do prompt da IA — o único limite passa a ser a `durationSeconds` da fala (fala inteira `[00:00, duração]` permitida, mínimo de 5 s mantido). A barra mostra `Mínimo 5s · sem limite — até o fim da fala` com `aria-valuemin/max` em `0–duração`, o diálogo mostra ` · mínimo 5 s · até o fim da fala`, o erro do servidor diz `Selecione um trecho de 5 segundos até o fim da fala.` e o prompt da IA fala em `trecho da fala com 5 segundos ou mais`. Sem migration, sem Consent/collection/URL nova; gate do acervo, kill switch/publicação (C167/C168) e pipeline/ffmpeg (C169) intocados — corte longo que bater em timeout operacional falha honesto como hoje. Testes: units (`speechExcerptSelection`, `speechDetailPlayer` com `valuemax=duração`) + int (`speechCut`: mensagem nova, longo 0–240 aceito, <5 s rejeitado) atualizados; e2e `campaignSpeechAcervo`/`campaignSpeechCut` verdes sem spec novo. diff --git a/docs/plans/c170-corte-sem-limite-impl.md b/docs/plans/c170-corte-sem-limite-impl.md new file mode 100644 index 00000000..563ea8b3 --- /dev/null +++ b/docs/plans/c170-corte-sem-limite-impl.md @@ -0,0 +1,91 @@ +# Impl: Acervo: cortar sem teto — do mínimo de 5 s até o fim da fala + +Status: aprovado +Atualizado em: 2026-09-16 +Issue: #1046 +Intenção: docs/plans/c170-corte-sem-limite.md +Appetite restante: herdado (~0,5–1 dia eng; nenhum corte de escopo proposto) + +## Leitura da intenção + +- **Outcome:** remover o teto de 3 min do corte do acervo. Seleção `[início,fim]` de 5 s até a duração total da fala; corte publicado/baixado = intervalo exato escolhido (12 min, fala inteira). Mesma seleção do C166, mesmo corte do C167, sem teto. Robustez: corte longo conclui; se o pipeline falhar por limite operacional, falha honesta (causa no C169, não vira teto). +- **O que NÃO negociar:** mínimo de 5 s mantido; `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 C167/C168 intocados; sem editor de vídeo; sem trocar início/fim depois de cortado; copy diz a verdade. +- **O que reavaliar (hipóteses da intenção, a validar no gate):** + 1. "Mínimo continua 5 s" — assumido A) manter. Confirmar no gate; se produto pedir outro valor, é outra fatia. + 2. "Limite efetivo = `durationSeconds`" — a fala inteira `[00:00, duração]` é permitida; o teto real é a duração da fala, não um número mágico. + 3. "Teto espalhado em 6 pontos" — geometria, aria, action, schema-msg, prompt IA, testes. Remover só a UI deixaria o servidor recusando o corte longo; a mudança é atômica nas pontas. + +## Abordagem recomendada + +```mermaid +flowchart LR + A["SpeechDetailPlayer
fala durationSeconds"] -->|"Selecionar trecho"| B["SpeechExcerptControls
moveRangeEdge sem MAX"] + B -->|"normalizeExcerptRange
MIN=5, teto=duration"| B + B -->|"Compartilhar C166"| S["share exato"] + B -->|"Cortar vídeo C167"| C["SpeechCutDialog
copy sem 180s"] + C -->|"POST acervo/cortar"| D["resolveExcerptRange
guard só duração"] + D -->|ok| E["runSpeechCutJob C167/C169
intocado"] + D -->|"< 5s ou fora da fala"| F["erro honesto:
5 segundos até o fim da fala"] +``` + +**Opções consideradas:** A) manter `MAX_EXCERPT_SECONDS` mas subir para 86.400 s (1 dia); B) remover `MAX_EXCERPT_SECONDS` — `durationSeconds` é o único teto; C) `MAX` opcional/nulável com fallback para `durationSeconds`. +**Recomendação:** B — porque o teto de produto deixa de existir no código em vez de virar número grande mentiroso; um `grep MAX_EXCERPT` zerado prova a remoção; `normalizeExcerptRange`/`moveRangeEdge`/`resolveExcerptRange` passam a receber só `(range, durationSeconds)` e o mínimo continua nomeado. +**Rejeitadas:** A porque mantém o conceito de teto, a copy continuaria mentindo por omissão e um dia alguém "só ajusta o número" de volta; C porque cria dois caminhos de teto (config + duração) sem nenhum consumidor do configurável — camada sem volatilidade, complexidade sem dono. + +### Decisões de engenharia (caras de reverter — nenhuma nesta fatia) + +Zero decisão dura: sem collection nova, sem `Consent`/PII, sem multi-collection write, sem URL nova, sem fronteira de módulo. A única deliberação (representação do "sem teto", acima) é barata de reverter e foi explicitada para impedir remoção parcial. `speechCutJob.ts:37-44` (timeouts, 2 GiB, reaper) fica intocado por decisão — mexer no pipeline às cegas é o rabbit hole nomeado; falha operacional continua falha honesta do C167/C169. + +### Componentes / mudanças + +- **Migration:** nenhuma — sem mudança de schema/collection; `push:false` intocado; `payload-types.ts` não muda. +- **Access:** intocado — gate do acervo (`canReadSpeechCatalog`), `canReadSpeechCut` do C167 e leader lockdown seguem como estão; Local API segue `overrideAccess:false` no load da fala. +- **Geometria / limites (owner, sem twin):** + - **`src/lib/speechExcerptSelection.ts`**: remover `MAX_EXCERPT_SECONDS` (:21) e todo uso (:54 `normalizeExcerptRange`, :125,:134 `moveRangeEdge`); manter `MIN_EXCERPT_SECONDS = 5` (:20,:44,:50-52); limpar imports/label/JSDoc `5s..180s` (:4,:34,:68,:112-113). Assinaturas passam a clampar só em `[0, durationSeconds]` + mínimo. + - **`src/app/(campaign)/campanha/actions/speech.ts`**: `resolveExcerptRange` (:126-133, usado em :156 create e :338 sugestão) — remover guard `requested > MAX`; guardar só duração (`start >= 0`, `end <= durationSeconds`, `end - start >= MIN`); remover import (:31-33). `normalizeExcerptRange` em :136 herda a geometria sem teto. +- **UI (copy pt-BR exata, ids em inglês):** + - **`src/components/campaign/speech/SpeechExcerptControls.tsx`**: label :31 `Mínimo 5s · máximo 3min` → `Mínimo 5s · sem limite — até o fim da fala`; `aria-valuemin/max` (:114,:119) passam a `0–durationSeconds`; sem mudar tradução ponteiro/tecla → `moveRangeEdge`. + - **`src/components/campaign/speech/SpeechCutDialog.tsx`**: :242-243 `dentro da faixa de {MIN}–{MAX} s` → `mínimo 5 s · até o fim da fala` (linha ` · mínimo 5 s · até o fim da fala`); remover import de MAX (:36). + - **`SpeechDetailPlayer.tsx`**: sem toque — só usa MIN (:19,:146,:229). `SpeechExcerptShare`/`lib/speechShare`: sem toque, sem teto. +- **Servidor / schema / prompt:** + - **`src/lib/schemas/speechCut.ts`**: mensagem :17 `Selecione um trecho de 5 a 180 segundos.` → `Selecione um trecho de 5 segundos até o fim da fala.`; zod :31 (0–86400) intocado — o teto real era a action. + - **`src/utilities/speech/speechCutMetadata.ts`**: prompt :42 `…de um trecho de 5 a 180 segundos.` → `…de um trecho da fala com 5 segundos ou mais.` +- **Testes que pinam 180 (atualizar, não deletar cobertura):** + - `tests/unit/speechExcerptSelection.unit.spec.ts:41-42,:113-117,:130-133,:141-144` — trocar pinos de clamp em 180 por clamp em `durationSeconds` + caso longo (ex.: 720 s passa). + - `tests/unit/speechDetailPlayer.unit.spec.tsx:290` (`aria-valuemax` 180), `:306,:320-321,:442-444` — `valuemax = duração`. + - `tests/int/speechCut.int.spec.ts:300` (regex `/5 a 180/`) — nova mensagem; adicionar caso create longo (>180 s, dentro da duração) publica/encaminha e caso `< 5 s` rejeita. + +### Dados → forma (n/a) + +Duração/`mm:ss` é feedback de ação, não dado do acervo — nenhum KPI/gráfico novo, conforme a intenção. Sem telemetria de corte nesta fatia. + +## Fases verificáveis + +1. **Tracer — geometria + servidor + schema-msg (~50% do appetite).** `speechExcerptSelection.ts` sem MAX + `resolveExcerptRange` só-duração + mensagem do schema + atualização das units/int acima. Verificação: `pnpm test:unit -- speechExcerptSelection` e `pnpm test:int -- speechCut` verdes; smoke Local API: range de 720 s dentro da duração normaliza/aceita, range de 3 s rejeita com a mensagem nova. Confirma a hipótese #3 (nenhum guard residual: `grep -rn MAX_EXCERPT` vazio fora de changelog). +2. **UI + prompt (~30%).** Barra (label + aria), diálogo (copy + import), prompt IA; ajuste das units do player. Verificação: `pnpm test:unit -- speechDetailPlayer` verde; manual no dev: fim da seleção alcança o último segundo, início o primeiro, duração visível (ex.: 12 min); diálogo mostra a copy nova; sugestão IA retorna com o prompt novo. +3. **Gates e fechamento (~20%).** `pnpm gate:fast` → `pnpm gate:ci` → `docs/changelog/2026-09-16-c170.md` (uma entrada, sem editar o agregado) → `pnpm push`. Cobertura e2e existente (`campaignSpeechAcervo`, curado C166/C167) deve passar sem spec novo — sem URL/collection nova, nada a acrescentar ao manifest. Validação do corte longo real fica com o `deploy-staging` (OPS103) antes de produção, na dependência do pipeline C169. + +## Rabbit holes / Não escopo (engenharia) + +- **Aumentar timeouts/limites do pipeline** (`SOURCE_DOWNLOAD_TIMEOUT_MS`, `MAX_SOURCE_BYTES`, `FFMPEG_MAX_TIMEOUT_MS`, reaper `SPEECH_CUT_STALE_MS`) — defeito do C169, não teto de produto. +- **Editor/timeline/zoom/precisão de frame, segundo player/preview** — barra atual + ímã de frase, segundos inteiros. +- **Streaming/fila assíncrona, presets de duração, multi-trecho, legenda no corte.** +- **Mecanismo/resolver VOD (C169); publicação/kill switch/biblioteca (C167/C168).** +- **Papéis do acervo, `Consent`/collection novas, segundo cadastro.** +- **Redesign da barra/paleta fora de `campaign`; DRY <3 call sites; camadas novas pass-through** — editar o owner, não twinar. + +## Riscos e mitigação + +- **Remoção parcial (UI libera, servidor recusa — ou o inverso).** Mitigação: tracer cobre geometria+servidor juntos na Fase 1; `grep MAX_EXCERPT` vazio como critério de aceite; int com caso longo ponta-a-ponta na action. +- **Corte longo falha por limite operacional (download 180 s, 2 GiB, ffmpeg 5 min, reaper 15 min).** Mitigação: aceite de robustez — falha honesta como hoje, nada publicado; causa registrada no C169; nenhum timeout mexido aqui. +- **Regressão C166/C167 (share e corte curto).** Mitigação: `excerptTMs`/seleção exata intocados; mínimo e ímã de frase intocados; units/int existentes atualizados, não removidos; e2e `campaignSpeechAcervo` no gate. +- **Aria/leitores de tela mentindo o limite.** Mitigação: `aria-valuemax = durationSeconds` junto com o label, coberto por unit do player. +- **Prompt IA ainda sugerindo trecho curto.** Mitigação: prompt sem números de teto; sugestão continua editável e o job normaliza com fallback — sem bloqueio. + +## Aceite de engenharia + +- [ ] Outcome da intenção: seleção `[início,fim]` de 5 s até a duração total (fala inteira permitida), barra não trava em 180 s, corte publicado/baixado = intervalo exato, copy verdadeira (`Mínimo 5s · sem limite — até o fim da fala` / `mínimo 5 s · até o fim da fala` / mensagem do schema / prompt IA). +- [ ] `grep -rn MAX_EXCERPT_SECONDS src tests` vazio (fora changelog); `MIN_EXCERPT_SECONDS = 5` intacto em geometria, player, action e testes. +- [ ] Invariantes AGENTS/engineering-standards: sem migração (nada a commitar em `src/migrations`); `push:false` intocado; gate do acervo e leader lockdown intocados; sem `Consent`/`Contact` paralelos; strings pt-BR, identificadores inglês; `admin.group` intocado. +- [ ] Testes previstos: units (`speechExcerptSelection`, `speechDetailPlayer` com `valuemax=duração`) + int (`speechCut`: mensagem nova, longo aceito, <5 s rejeitado) verdes; e2e `campaignSpeechAcervo` verde sem entrada nova no manifest. +- [ ] `pnpm gate:fast` e `pnpm gate:ci` verdes; changelog `docs/changelog/2026-09-16-c170.md`; validação do corte longo real no `deploy-staging` antes de produção. diff --git a/src/app/(campaign)/campanha/actions/speech.ts b/src/app/(campaign)/campanha/actions/speech.ts index dc2c41ea..f5d0c800 100644 --- a/src/app/(campaign)/campanha/actions/speech.ts +++ b/src/app/(campaign)/campanha/actions/speech.ts @@ -27,11 +27,7 @@ import { } from '@/lib/schemas/speechVod' import { formatSpeechDate } from '@/lib/speechClock' import { toSpeechCutViewModel, type SpeechCutViewModel } from '@/lib/speechCut' -import { - MAX_EXCERPT_SECONDS, - MIN_EXCERPT_SECONDS, - normalizeExcerptRange, -} from '@/lib/speechExcerptSelection' +import { MIN_EXCERPT_SECONDS, normalizeExcerptRange } from '@/lib/speechExcerptSelection' import { speechVodCoordinates, type SpeechVodResolution } from '@/lib/speechVod' import type { CampaignUser } from '@/payload-types' import { getCampaignActionContext } from '@/utilities/campaignActionContext' @@ -118,10 +114,10 @@ const loadSpeechForCut = async (payload: Payload, actor: CampaignUser, speechId: } /** - * Validates the requested window against the C166 bounds and the speech - * duration, then normalizes it. Deterministic (the action and the suggestion - * must agree): a forged request fails closed instead of silently cutting a - * different interval. + * Validates the requested window against the 5 s minimum (C170: no upper cap — + * the speech duration is the limit), then normalizes it. Deterministic (the + * action and the suggestion must agree): a forged request fails closed instead + * of silently cutting a different interval. */ const resolveExcerptRange = ( durationSeconds: number | null | undefined, @@ -130,7 +126,7 @@ const resolveExcerptRange = ( ): { startSeconds: number; endSeconds: number } => { const duration = Math.floor(durationSeconds ?? 0) const requested = endSeconds - startSeconds - if (requested < MIN_EXCERPT_SECONDS || requested > MAX_EXCERPT_SECONDS || endSeconds > duration) { + if (requested < MIN_EXCERPT_SECONDS || endSeconds > duration) { throw new Error(SPEECH_CUT_INVALID_RANGE_MESSAGE) } const range = normalizeExcerptRange(startSeconds, endSeconds, duration) diff --git a/src/components/campaign/speech/SpeechCutDialog.tsx b/src/components/campaign/speech/SpeechCutDialog.tsx index 3e314249..5779e102 100644 --- a/src/components/campaign/speech/SpeechCutDialog.tsx +++ b/src/components/campaign/speech/SpeechCutDialog.tsx @@ -32,11 +32,7 @@ import { type SpeechCutStep, type SpeechCutViewModel, } from '@/lib/speechCut' -import { - MAX_EXCERPT_SECONDS, - MIN_EXCERPT_SECONDS, - type ExcerptRange, -} from '@/lib/speechExcerptSelection' +import { MIN_EXCERPT_SECONDS, type ExcerptRange } from '@/lib/speechExcerptSelection' import { cn } from '@/lib/utils' const SAVE_ENDPOINT = '/campanha/comunicacao/acervo/cortar' @@ -239,8 +235,8 @@ export const SpeechCutDialog = ({ {formatSpeechClock(range.startSeconds)} → {formatSpeechClock(range.endSeconds)} - {formatSpeechSpan(durationSeconds)} · dentro da faixa de {MIN_EXCERPT_SECONDS}– - {MAX_EXCERPT_SECONDS} s + {formatSpeechSpan(durationSeconds)} · mínimo {MIN_EXCERPT_SECONDS} s · até o fim + da fala diff --git a/src/components/campaign/speech/SpeechExcerptControls.tsx b/src/components/campaign/speech/SpeechExcerptControls.tsx index 968ecc7f..c6cf11a1 100644 --- a/src/components/campaign/speech/SpeechExcerptControls.tsx +++ b/src/components/campaign/speech/SpeechExcerptControls.tsx @@ -5,7 +5,6 @@ import { useRef } from 'react' import { formatSpeechClock, formatSpeechSpan } from '@/lib/speechClock' import { - MAX_EXCERPT_SECONDS, MIN_EXCERPT_SECONDS, moveRangeEdge, rangeDurationSeconds, @@ -28,7 +27,7 @@ const HANDLE_KEY_DELTAS: Record = { PageUp: 5, } -const excerptLimitsLabel = `Mínimo ${MIN_EXCERPT_SECONDS}s · máximo ${MAX_EXCERPT_SECONDS / 60}min` +const excerptLimitsLabel = `Mínimo ${MIN_EXCERPT_SECONDS}s · sem limite — até o fim da fala` type SpeechExcerptControlsProps = { segments: readonly ExcerptSegment[] @@ -109,14 +108,8 @@ export const SpeechExcerptControls = ({ role: 'slider' as const, tabIndex: 0, 'aria-label': edge === 'start' ? 'Início do trecho' : 'Fim do trecho', - 'aria-valuemin': - edge === 'start' - ? Math.max(0, range.endSeconds - MAX_EXCERPT_SECONDS) - : range.startSeconds + MIN_EXCERPT_SECONDS, - 'aria-valuemax': - edge === 'start' - ? range.endSeconds - MIN_EXCERPT_SECONDS - : Math.min(durationSeconds, range.startSeconds + MAX_EXCERPT_SECONDS), + 'aria-valuemin': edge === 'start' ? 0 : range.startSeconds + MIN_EXCERPT_SECONDS, + 'aria-valuemax': edge === 'start' ? range.endSeconds - MIN_EXCERPT_SECONDS : durationSeconds, 'aria-valuenow': seconds, 'aria-valuetext': formatSpeechClock(seconds), 'data-excerpt-edge': edge, diff --git a/src/lib/schemas/speechCut.ts b/src/lib/schemas/speechCut.ts index e2026bc7..c842515e 100644 --- a/src/lib/schemas/speechCut.ts +++ b/src/lib/schemas/speechCut.ts @@ -13,8 +13,9 @@ export const SPEECH_CUT_SPEECH_NOT_FOUND_MESSAGE = 'Fala não encontrada.' /** The cut does not exist (or the actor cannot read it). */ export const SPEECH_CUT_NOT_FOUND_MESSAGE = 'Corte não encontrado.' -/** Selection outside the C166 bounds (5–180 s) or outside the speech duration. */ -export const SPEECH_CUT_INVALID_RANGE_MESSAGE = 'Selecione um trecho de 5 a 180 segundos.' +/** Selection below the 5 s minimum or outside the speech duration. */ +export const SPEECH_CUT_INVALID_RANGE_MESSAGE = + 'Selecione um trecho de 5 segundos até o fim da fala.' /** Retry only applies to a failed cut — a published one is not re-cut. */ export const SPEECH_CUT_RETRY_NOT_FAILED_MESSAGE = diff --git a/src/lib/speechExcerptSelection.ts b/src/lib/speechExcerptSelection.ts index efd183b1..5fbb1ab4 100644 --- a/src/lib/speechExcerptSelection.ts +++ b/src/lib/speechExcerptSelection.ts @@ -1,7 +1,8 @@ /** * C166 — pure geometry for the acervo excerpt picker: whole-second * [start, end] ranges snapped to transcript segment boundaries, clamped to the - * excerpt limits (5 s..180 s) and to the speech duration. No DOM and no React: + * 5 s minimum and to the speech duration (C170: no upper cap — the speech + * itself is the limit). No DOM and no React: * the client controls only translate pointers/keys into these calls. */ @@ -18,7 +19,6 @@ export type ExcerptRange = { export type ExcerptEdge = 'start' | 'end' export const MIN_EXCERPT_SECONDS = 5 -export const MAX_EXCERPT_SECONDS = 180 export const rangeDurationSeconds = (range: ExcerptRange): number => range.endSeconds - range.startSeconds @@ -31,7 +31,7 @@ const wholeCeil = (seconds: number): number => (Number.isFinite(seconds) ? Math. /** * Clamp a raw [start, end] pair into a valid excerpt range: whole seconds, - * inside [0, duration], at least 5 s and at most 180 s long (a short pair + * inside [0, duration], at least 5 s long (a short pair * grows forward, and backwards when the speech ends first). Returns null when * the duration cannot hold the minimum — the caller then hides the picker. */ @@ -51,7 +51,6 @@ export const normalizeExcerptRange = ( end = Math.min(duration, start + MIN_EXCERPT_SECONDS) start = Math.max(0, end - MIN_EXCERPT_SECONDS) } - if (end - start > MAX_EXCERPT_SECONDS) end = start + MAX_EXCERPT_SECONDS return { startSeconds: start, endSeconds: end } } @@ -65,7 +64,7 @@ export const initialExcerptRange = ( ? selectSegmentRange(segments, 0, durationSeconds) : normalizeExcerptRange(0, MIN_EXCERPT_SECONDS, durationSeconds) -/** A whole phrase, clamped by the duration and by the 5 s/180 s limits. */ +/** A whole phrase, clamped by the duration and by the 5 s minimum. */ export const selectSegmentRange = ( segments: readonly ExcerptSegment[], index: number, @@ -110,7 +109,7 @@ export const extendRangeToSegment = ( /** * Handle/keyboard move of one edge: the other edge stays put, the span stays - * between 5 s and 180 s, and the range stays inside the speech. + * at least 5 s, and the range stays inside the speech (C170: no upper cap). */ export const moveRangeEdge = ( range: ExcerptRange, @@ -122,16 +121,15 @@ export const moveRangeEdge = ( const requested = Math.round(Number.isFinite(nextSeconds) ? nextSeconds : 0) if (edge === 'start') { - const minimum = Math.max(0, range.endSeconds - MAX_EXCERPT_SECONDS) const maximum = range.endSeconds - MIN_EXCERPT_SECONDS return { - startSeconds: Math.min(Math.max(requested, minimum), maximum), + startSeconds: Math.min(Math.max(requested, 0), maximum), endSeconds: range.endSeconds, } } const minimum = range.startSeconds + MIN_EXCERPT_SECONDS - const maximum = Math.min(duration, range.startSeconds + MAX_EXCERPT_SECONDS) + const maximum = duration return { startSeconds: range.startSeconds, endSeconds: Math.min(Math.max(requested, minimum), maximum), diff --git a/src/utilities/speech/speechCutMetadata.ts b/src/utilities/speech/speechCutMetadata.ts index 8e0b4556..b48bc219 100644 --- a/src/utilities/speech/speechCutMetadata.ts +++ b/src/utilities/speech/speechCutMetadata.ts @@ -39,7 +39,7 @@ const suggestionSchema = z.object({ const SYSTEM_PROMPT = 'Você prepara cortes de falas do deputado federal Jorge Solla no acervo da Câmara dos Deputados. ' + - 'Receberá tipo, data, resumo oficial e a transcrição automática (ASR) de um trecho 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. ' + 'Escreva um título curto e concreto (o assunto do trecho, não o nome do deputado) e uma descrição de uma ' + 'a duas frases, em português do Brasil, adequadas para compartilhar o vídeo no WhatsApp. ' + 'Use apenas o que está no trecho e no resumo; nunca invente números, acordos ou citações, e a transcrição ' + diff --git a/tests/int/speechCut.int.spec.ts b/tests/int/speechCut.int.spec.ts index f730d6f9..8e869e5e 100644 --- a/tests/int/speechCut.int.spec.ts +++ b/tests/int/speechCut.int.spec.ts @@ -297,7 +297,7 @@ describe('speech cuts (C167)', () => { title: 't', description: 'd', }), - ).rejects.toThrow(/5 a 180/) + ).rejects.toThrow(/5 segundos até o fim da fala/) for (const denied of [advisor, leader]) { asActor(denied) @@ -313,6 +313,32 @@ describe('speech cuts (C167)', () => { } }) + it('accepts a long selection up to the speech duration (C170, no 180 s cap)', async () => { + const speech = await createSpeech() + const { communicator } = await createUsers() + asActor(communicator) + + const long = await saveSpeechCutForActor({ + speechId: speech, + startSeconds: 0, + endSeconds: 240, + title: 'Discurso inteiro', + description: 'Trecho longo sem teto', + }) + expect(long.durationSeconds).toBe(240) + createdCutIds.add(long.id) + + await expect( + saveSpeechCutForActor({ + speechId: speech, + startSeconds: 0, + endSeconds: 3, + title: 't', + description: 'd', + }), + ).rejects.toThrow(/5 segundos até o fim da fala/) + }) + it('retries only a failed row, reusing the same id', async () => { const speech = await createSpeech() const { communicator } = await createUsers() diff --git a/tests/unit/speechDetailPlayer.unit.spec.tsx b/tests/unit/speechDetailPlayer.unit.spec.tsx index 03b1c17a..01bc0cf4 100644 --- a/tests/unit/speechDetailPlayer.unit.spec.tsx +++ b/tests/unit/speechDetailPlayer.unit.spec.tsx @@ -287,7 +287,7 @@ describe('SpeechDetailPlayer — C166 excerpt selection and share', () => { fireEvent.click(segmentButton(43)) expect(startSlider().getAttribute('aria-valuenow')).toBe('0') expect(endSlider().getAttribute('aria-valuenow')).toBe('50') - expect(endSlider().getAttribute('aria-valuemax')).toBe('180') + expect(endSlider().getAttribute('aria-valuemax')).toBe('300') // C162 contract: the selection mode does not seek the embed. expect(iframeElement()?.getAttribute('src')).toBe( 'https://www.youtube-nocookie.com/embed/lLhRDkSPw0A?playsinline=1&rel=0&start=2634', @@ -303,7 +303,7 @@ describe('SpeechDetailPlayer — C166 excerpt selection and share', () => { expect(iframeElement()?.getAttribute('src')).toContain('start=2677') }) - it('adjusts the edges by keyboard with the 5 s/180 s limits', () => { + it('adjusts the edges by keyboard with the 5 s minimum and no upper cap', () => { renderPlayer({ youtubeVideoId: 'lLhRDkSPw0A', youtubeOffsetSeconds: 2634 }) toggleSelection() @@ -317,11 +317,11 @@ describe('SpeechDetailPlayer — C166 excerpt selection and share', () => { expect(startSlider().getAttribute('aria-valuenow')).toBe('45') fireEvent.keyDown(endSlider(), { key: 'End' }) - expect(endSlider().getAttribute('aria-valuenow')).toBe('225') - expect(screen.getByText(/3min00s/)).toBeDefined() + expect(endSlider().getAttribute('aria-valuenow')).toBe('300') + expect(screen.getByText(/4min15s/)).toBeDefined() fireEvent.keyDown(endSlider(), { key: 'ArrowLeft' }) - expect(endSlider().getAttribute('aria-valuenow')).toBe('224') + expect(endSlider().getAttribute('aria-valuenow')).toBe('299') fireEvent.keyDown(endSlider(), { key: 'Home' }) expect(endSlider().getAttribute('aria-valuenow')).toBe('50') }) @@ -439,9 +439,9 @@ describe('SpeechDetailPlayer — C166 excerpt selection and share', () => { fireEvent.pointerMove(handle, { pointerId: 1, clientX: 150 }) expect(endSlider().getAttribute('aria-valuenow')).toBe('150') - // 250s would pass the 180 s span cap from the 0 s start. + // 250s is far from every boundary and within the speech, so the handle moves freely. fireEvent.pointerMove(handle, { pointerId: 1, clientX: 250 }) - expect(endSlider().getAttribute('aria-valuenow')).toBe('180') + expect(endSlider().getAttribute('aria-valuenow')).toBe('250') fireEvent.pointerUp(handle, { pointerId: 1, clientX: 250 }) expect(releasePointerCapture).toHaveBeenCalled() diff --git a/tests/unit/speechExcerptSelection.unit.spec.ts b/tests/unit/speechExcerptSelection.unit.spec.ts index f468b7fe..1406e89d 100644 --- a/tests/unit/speechExcerptSelection.unit.spec.ts +++ b/tests/unit/speechExcerptSelection.unit.spec.ts @@ -38,8 +38,9 @@ describe('normalizeExcerptRange', () => { expect(normalizeExcerptRange(248, 252, 252)).toEqual({ startSeconds: 247, endSeconds: 252 }) }) - it('caps at 180 s', () => { - expect(normalizeExcerptRange(0, 500, 600)).toEqual({ startSeconds: 0, endSeconds: 180 }) + it('has no upper cap — the speech duration is the limit', () => { + expect(normalizeExcerptRange(0, 500, 600)).toEqual({ startSeconds: 0, endSeconds: 500 }) + expect(normalizeExcerptRange(0, 600, 600)).toEqual({ startSeconds: 0, endSeconds: 600 }) expect(normalizeExcerptRange(500, 500, 600)).toEqual({ startSeconds: 500, endSeconds: 505 }) }) @@ -110,10 +111,10 @@ describe('extendRangeToSegment', () => { expect(extendRangeToSegment(range, SEGMENTS, 9, 252)).toEqual(range) }) - it('caps the extension at 180 s when the clicked phrase is long', () => { + it('extends to the end of a long phrase without a cap', () => { expect(extendRangeToSegment(range, SEGMENTS, 3, 252)).toEqual({ startSeconds: 43, - endSeconds: 223, + endSeconds: 252, }) }) }) @@ -127,9 +128,9 @@ describe('moveRangeEdge', () => { expect(moveRangeEdge(range, 'start', 20, 252)).toEqual({ startSeconds: 20, endSeconds: 51 }) }) - it('moves the end inside start + 5 s and the 180 s cap', () => { + it('moves the end between start + 5 s and the speech duration', () => { expect(moveRangeEdge(range, 'end', 0, 252)).toEqual({ startSeconds: 43, endSeconds: 48 }) - expect(moveRangeEdge(range, 'end', 500, 252)).toEqual({ startSeconds: 43, endSeconds: 223 }) + expect(moveRangeEdge(range, 'end', 500, 252)).toEqual({ startSeconds: 43, endSeconds: 252 }) expect(moveRangeEdge(range, 'end', 200, 252)).toEqual({ startSeconds: 43, endSeconds: 200 }) }) @@ -139,7 +140,7 @@ describe('moveRangeEdge', () => { endSeconds: 203, }) expect(moveRangeEdge({ startSeconds: 240, endSeconds: 252 }, 'start', 10, 252)).toEqual({ - startSeconds: 72, + startSeconds: 10, endSeconds: 252, }) })