fix(C163): vídeo do acervo abre no início da fala (speechAt − sessão)

This commit is contained in:
Francisco Solla 2026-09-15 09:52:51 -03:00
parent 3e7f750141
commit d7d34b8e81
7 changed files with 73 additions and 27 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á na minutagem
do trecho — `youtubeOffsetSeconds` = época do trecho − início da sessão;
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` ·
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 ·
`11. Notícias e imprensa` ·
`12. Panorama regional` · `13. Abordagem sugerida (personas)` · `14. Fontes e
limites`.
- **Links clicáveis:** todo URL no PDF é um link (`<a href>`): células de

View file

@ -0,0 +1 @@
**C163 — vídeo do acervo no início da fala, não no trecho curado (2026-09-15):** o link do YouTube abria 2m45s dentro do discurso porque usava `excerptTMs` (época de um trecho curado, 14:10:45) em vez do início da fala (14:08). O dono `src/lib/speechVod.ts` ganhou `speechStartOffsetSeconds(speechAt, eventStartAt)` (mesma matemática de relógio de parede BRT do `excerptOffsetSeconds`, com testes de DST e virada de dia) e o relatório passou a usar `youtubeStartSeconds` = `speechAt` − início da sessão: na fala 641, **480s (8min)** em vez de 645s → `youtube.com/watch?v=2cX_gKkJH7Q&t=480s`. Unit 3121/3121, `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 { excerptOffsetSeconds } from '../src/lib/speechVod.ts'
import { speechStartOffsetSeconds } from '../src/lib/speechVod.ts'
import { campaignRoleLabels } from '../src/utilities/campaignUserProfile.ts'
import { loadMunicipalityDossierData } from '../src/utilities/municipality/municipalityDossierData.ts'
import {
@ -66,7 +66,6 @@ const loadSpeeches = async (payload, actor, municipalityID, municipalityName) =>
officialTextUrl: true,
youtubeUrl: true,
vodPlaybackUrl: true,
excerptTMs: true,
eventStartAt: true,
mentionedMunicipalities: true,
},
@ -96,7 +95,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). */
youtubeOffsetSeconds: excerptOffsetSeconds(doc.excerptTMs, doc.eventStartAt),
youtubeStartSeconds: speechStartOffsetSeconds(doc.speechAt, doc.eventStartAt),
mentionExcerpt: mentionSegment?.text ?? null,
mentionedMunicipalityCount: Array.isArray(doc.mentionedMunicipalities)
? doc.mentionedMunicipalities.length

View file

@ -902,7 +902,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.youtubeOffsetSeconds)
? withYoutubeTimestamp(row.youtubeUrl, row.youtubeStartSeconds)
: (row.vodPlaybackUrl ?? '—'),
transcript: row.officialTextUrl ?? '—',
})),

View file

@ -193,6 +193,23 @@ const wallClockSeconds = (epochMs: number): number => {
)
}
/** BRT wall-clock seconds-since-epoch of a naive Câmara datetime, or null. */
const naiveWallClockSeconds = (value: unknown): number | null => {
const match = NAIVE_DATETIME.exec(String(value ?? '').trim())
if (!match) return null
const [, year, month, day, hour, minute, second = '0'] = match
const [y, mo, d, h, mi, s] = [year, month, day, hour, minute, second].map(Number)
if (mo < 1 || mo > 12 || d < 1 || d > 31 || h > 23 || mi > 59 || s > 59) {
return null
}
const ms = Date.UTC(y, mo - 1, d, h, mi, s)
const date = new Date(ms)
if (date.getUTCFullYear() !== y || date.getUTCMonth() !== mo - 1 || date.getUTCDate() !== d) {
return null
}
return ms / 1000
}
/**
* Seconds from the session start (`eventStartAt`, naive Câmara wall clock) to
* the excerpt (`excerptTMs`, epoch ms). Both sides are read as BRT wall clock,
@ -203,23 +220,28 @@ export const excerptOffsetSeconds = (excerptTMs: unknown, eventStartAt: unknown)
const epochMs = Number(excerptTMs)
if (!Number.isFinite(epochMs) || epochMs <= 0 || epochMs > MAX_DATE_MS) return null
const match = NAIVE_DATETIME.exec(String(eventStartAt ?? '').trim())
if (!match) return null
const [, year, month, day, hour, minute, second = '0'] = match
const [y, mo, d, h, mi, s] = [year, month, day, hour, minute, second].map(Number)
if (mo < 1 || mo > 12 || d < 1 || d > 31 || h > 23 || mi > 59 || s > 59) {
return null
}
const startMs = Date.UTC(y, mo - 1, d, h, mi, s)
const startDate = new Date(startMs)
if (
startDate.getUTCFullYear() !== y ||
startDate.getUTCMonth() !== mo - 1 ||
startDate.getUTCDate() !== d
) {
return null
}
const startSeconds = naiveWallClockSeconds(eventStartAt)
if (startSeconds === null) return null
const offset = Math.floor(wallClockSeconds(epochMs) - startMs / 1000)
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',
youtubeOffsetSeconds: 125,
youtubeStartSeconds: 125,
mentionExcerpt: 'Trecho da fala que cita Feira de Santana',
mentionedMunicipalityCount: 3,
},

View file

@ -2,7 +2,11 @@
import { describe, expect, it } from 'vitest'
import { excerptOffsetSeconds, parseYoutubeVideoId } from '@/lib/speechVod'
import {
excerptOffsetSeconds,
parseYoutubeVideoId,
speechStartOffsetSeconds,
} from '@/lib/speechVod'
/**
* The VOD URL/status/duration contracts moved from `scripts/lib/camaraSpeeches.mjs`
@ -50,6 +54,25 @@ 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'