mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 15:13:07 +00:00
fix(C163): vídeo do acervo abre no início da fala (speechAt − sessão)
This commit is contained in:
parent
3e7f750141
commit
d7d34b8e81
7 changed files with 73 additions and 27 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á 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
|
||||
|
|
|
|||
1
docs/changelog/2026-09-15-c163-falas-inicio-da-fala.md
Normal file
1
docs/changelog/2026-09-15-c163-falas-inicio-da-fala.md
Normal 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.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 ?? '—',
|
||||
})),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue