mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 06:43:06 +00:00
fix(C163): vídeo do acervo ancorado no trecho da Câmara, com folga
This commit is contained in:
parent
d7d34b8e81
commit
6e46f86e22
7 changed files with 25 additions and 55 deletions
|
|
@ -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`.
|
||||
|
|
|
|||
1
docs/changelog/2026-09-15-c163-falas-ancora-trecho.md
Normal file
1
docs/changelog/2026-09-15-c163-falas-ancora-trecho.md
Normal 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.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 ?? '—',
|
||||
})),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue