fix(C163): vídeo do acervo ancorado no trecho da Câmara, com folga

This commit is contained in:
Francisco Solla 2026-09-15 09:57:45 -03:00
parent d7d34b8e81
commit 6e46f86e22
7 changed files with 25 additions and 55 deletions

View file

@ -178,10 +178,11 @@ cacheado em `data/relatorios-cidade/<base>.emendas.json` para replay.
econômica (pesquisa)` · `9. Transporte e conexões (pesquisa)` · `10. Acervo
de falas` — cada fala com "O que é" (sumário oficial) e "Menção ao município"
(passagem que cita a cidade ou, se o nome não aparece nos trechos, a marcação
do acervo com o nº de municípios), mais **Vídeo** (YouTube já no **início da
fala** — `youtubeStartSeconds` = `speechAt` − início da sessão, via
`speechStartOffsetSeconds`; fallback para o VOD da Câmara, que é o clipe do
próprio trecho) e **Transcrição** (PDF do Diário) quando existirem ·
do acervo com o nº de municípios), mais **Vídeo** (YouTube no trecho da fala
— `youtubeExcerptStartSeconds` = época do trecho (`excerptTMs`) − início da
sessão, com folga de 20s no link; o `speechAt` da API é o horário do slot,
não o início real; fallback para o VOD da Câmara, que é o clipe do próprio
trecho) e **Transcrição** (PDF do Diário) quando existirem ·
`11. Notícias e imprensa` ·
`12. Panorama regional` · `13. Abordagem sugerida (personas)` · `14. Fontes e
limites`.

View file

@ -0,0 +1 @@
**C163 — vídeo ancorado no trecho da Câmara, com folga (2026-09-15):** o `speechAt` da API (14:08) é o horário do _slot_, não o início real da fala — os segmentos do trecho mostram o orador anterior nesse momento, e o 1º segmento do clipe é o presidente concedendo a palavra (o trecho gerado a partir do `excerptTMs` começa às 14:10:45). O relatório volta a ancorar no trecho oficial (`youtubeExcerptStartSeconds` = `excerptTMs` − início da sessão) e aplica **folga de 20s** no link (`YOUTUBE_LEAD_IN_SECONDS`) para não abrir já dentro da fala; na fala 641: `t=625s`. A função `speechStartOffsetSeconds` (hipótese errada de que `speechAt` fosse o início) foi removida do dono `src/lib/speechVod.ts`. Unit 3118/3118, `tsc`/eslint verdes; snapshot reextraído e PDF regerado.

View file

@ -14,7 +14,7 @@ import { FEDERAL_DEPUTY_OFFICE, HISTORICAL_SERIES_YEARS } from '../src/lib/elect
import { getMunicipalityCatalogEntry } from '../src/lib/municipalityCatalog.ts'
import { getMunicipalityVoteRank } from '../src/lib/municipalityVoteRank.ts'
import { normalizeForSearch } from '../src/lib/speechSearch.ts'
import { speechStartOffsetSeconds } from '../src/lib/speechVod.ts'
import { excerptOffsetSeconds } from '../src/lib/speechVod.ts'
import { campaignRoleLabels } from '../src/utilities/campaignUserProfile.ts'
import { loadMunicipalityDossierData } from '../src/utilities/municipality/municipalityDossierData.ts'
import {
@ -66,6 +66,7 @@ const loadSpeeches = async (payload, actor, municipalityID, municipalityName) =>
officialTextUrl: true,
youtubeUrl: true,
vodPlaybackUrl: true,
excerptTMs: true,
eventStartAt: true,
mentionedMunicipalities: true,
},
@ -95,7 +96,7 @@ const loadSpeeches = async (payload, actor, municipalityID, municipalityName) =>
youtubeUrl: doc.youtubeUrl ?? null,
vodPlaybackUrl: doc.vodPlaybackUrl ?? null,
/** Offset of the excerpt inside the session video (YouTube start). */
youtubeStartSeconds: speechStartOffsetSeconds(doc.speechAt, doc.eventStartAt),
youtubeExcerptStartSeconds: excerptOffsetSeconds(doc.excerptTMs, doc.eventStartAt),
mentionExcerpt: mentionSegment?.text ?? null,
mentionedMunicipalityCount: Array.isArray(doc.mentionedMunicipalities)
? doc.mentionedMunicipalities.length

View file

@ -853,10 +853,14 @@ const buildTransportSection = ({ research }) =>
})
/**
* YouTube link starts where the excerpt sits in the session video
* (`youtubeOffsetSeconds`, from the Câmara excerpt epoch − session start).
* The VOD clip is the excerpt itself, so it needs no timestamp.
* YouTube link starts at the Câmara excerpt of the speech (`excerptTMs` −
* session start): `speechAt` is the API slot clock, not when the deputy
* actually speaks (for speech 641 the previous speaker is still on at
* `speechAt`). A short lead-in keeps the link from starting already inside the
* speech when the session video drifts a few seconds.
*/
const YOUTUBE_LEAD_IN_SECONDS = 20
const withYoutubeTimestamp = (url, offsetSeconds) => {
if (!url) return null
if (!Number.isFinite(offsetSeconds) || offsetSeconds <= 0) return url
@ -864,6 +868,11 @@ const withYoutubeTimestamp = (url, offsetSeconds) => {
return `${url}${separator}t=${Math.floor(offsetSeconds)}s`
}
const youtubeStartSeconds = (row) =>
Number.isFinite(row.youtubeExcerptStartSeconds)
? Math.max(0, Math.floor(row.youtubeExcerptStartSeconds) - YOUTUBE_LEAD_IN_SECONDS)
: null
const buildSpeechesSection = ({ snapshot }) => {
const { speeches, municipality } = snapshot
if (!speeches.rows.length) {
@ -902,7 +911,7 @@ const buildSpeechesSection = ({ snapshot }) => {
? `Marcada no acervo — nome não localizado nos trechos (a fala cita ${formatInteger(row.mentionedMunicipalityCount)} municípios).`
: 'Marcada no acervo — trecho não localizado.',
video: row.youtubeUrl
? withYoutubeTimestamp(row.youtubeUrl, row.youtubeStartSeconds)
? withYoutubeTimestamp(row.youtubeUrl, youtubeStartSeconds(row))
: (row.vodPlaybackUrl ?? '—'),
transcript: row.officialTextUrl ?? '—',
})),

View file

@ -226,22 +226,3 @@ export const excerptOffsetSeconds = (excerptTMs: unknown, eventStartAt: unknown)
const offset = Math.floor(wallClockSeconds(epochMs) - startSeconds)
return offset >= 0 ? offset : null
}
/**
* Seconds from the session start (`eventStartAt`) to the speech itself
* (`speechAt`, the Câmara wall clock of the speech) — where a video link must
* start to open at the beginning of the deputy's speech, not at a curated
* excerpt inside it. Null when either side is unparseable or the speech
* precedes the session start.
*/
export const speechStartOffsetSeconds = (
speechAt: unknown,
eventStartAt: unknown,
): number | null => {
const speechSeconds = naiveWallClockSeconds(speechAt)
const startSeconds = naiveWallClockSeconds(eventStartAt)
if (speechSeconds === null || startSeconds === null) return null
const offset = Math.floor(speechSeconds - startSeconds)
return offset >= 0 ? offset : null
}

View file

@ -278,7 +278,7 @@ const snapshot = {
officialTextUrl: 'https://camara.test/discurso',
youtubeUrl: 'https://youtube.test/video',
vodPlaybackUrl: 'https://vod.test/video.mp4',
youtubeStartSeconds: 125,
youtubeExcerptStartSeconds: 645,
mentionExcerpt: 'Trecho da fala que cita Feira de Santana',
mentionedMunicipalityCount: 3,
},
@ -445,7 +445,7 @@ describe('buildCityReport', () => {
expect(falas.columns!.find((column) => column.key === 'mention')!.width).toBe(27)
expect(rows[0].description).toBe('Resumo da fala')
expect(rows[0].mention).toContain('cita Feira de Santana')
expect(rows[0].video).toBe('https://youtube.test/video?t=125s')
expect(rows[0].video).toBe('https://youtube.test/video?t=625s')
expect(rows[0].transcript).toBe('https://camara.test/discurso')
expect(rows[1].description).toBe('Saúde indígena na Bahia')
expect(rows[1].mention).toContain('nome não localizado nos trechos')

View file

@ -2,11 +2,7 @@
import { describe, expect, it } from 'vitest'
import {
excerptOffsetSeconds,
parseYoutubeVideoId,
speechStartOffsetSeconds,
} from '@/lib/speechVod'
import { excerptOffsetSeconds, parseYoutubeVideoId } from '@/lib/speechVod'
/**
* The VOD URL/status/duration contracts moved from `scripts/lib/camaraSpeeches.mjs`
@ -54,25 +50,6 @@ describe('excerptOffsetSeconds', () => {
})
})
describe('speechStartOffsetSeconds', () => {
it('measures the speech start against the session start', () => {
expect(speechStartOffsetSeconds('2018-03-13T14:08', '2018-03-13T14:00')).toBe(480)
expect(speechStartOffsetSeconds('2026-08-11T15:12:30', '2026-08-11T15:00')).toBe(750)
})
it('reads both sides as BRT wall clock across an old DST transition', () => {
// 2018-11-04: clocks move to -02:00 — the naive difference stays local.
expect(speechStartOffsetSeconds('2018-11-04T12:00', '2018-11-04T11:00')).toBe(3600)
})
it('returns null when the speech precedes the session or input is unparseable', () => {
expect(speechStartOffsetSeconds('2018-03-13T13:59', '2018-03-13T14:00')).toBeNull()
expect(speechStartOffsetSeconds(null, '2018-03-13T14:00')).toBeNull()
expect(speechStartOffsetSeconds('2018-03-13T14:08', null)).toBeNull()
expect(speechStartOffsetSeconds('2018-03-13T25:00', '2018-03-13T14:00')).toBeNull()
})
})
describe('parseYoutubeVideoId', () => {
const videoId = 'lLhRDkSPw0A'