mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 06:43:06 +00:00
feat(C170): acervo sem teto de corte — 5 s até o fim da fala (Closes #1046)
This commit is contained in:
parent
2e389365a3
commit
dc5d7a0020
11 changed files with 157 additions and 54 deletions
1
docs/changelog/2026-09-16-c170.md
Normal file
1
docs/changelog/2026-09-16-c170.md
Normal file
|
|
@ -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 `<duração> · 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.
|
||||
91
docs/plans/c170-corte-sem-limite-impl.md
Normal file
91
docs/plans/c170-corte-sem-limite-impl.md
Normal file
|
|
@ -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<br/>fala durationSeconds"] -->|"Selecionar trecho"| B["SpeechExcerptControls<br/>moveRangeEdge sem MAX"]
|
||||
B -->|"normalizeExcerptRange<br/>MIN=5, teto=duration"| B
|
||||
B -->|"Compartilhar C166"| S["share exato"]
|
||||
B -->|"Cortar vídeo C167"| C["SpeechCutDialog<br/>copy sem 180s"]
|
||||
C -->|"POST acervo/cortar"| D["resolveExcerptRange<br/>guard só duração"]
|
||||
D -->|ok| E["runSpeechCutJob C167/C169<br/>intocado"]
|
||||
D -->|"< 5s ou fora da fala"| F["erro honesto:<br/>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 `<duração> · 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.
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{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
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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<string, number> = {
|
|||
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,
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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 ' +
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue