` de hoje. **Recomendação:** com miniatura usa o flex (mobile empilha — cena 02, desktop em linha — cenas 01/03) com `min-w-0 flex-1` no wrapper do excerto; sem miniatura mantém exatamente o `
` atual (`SpeechResultCard.tsx:83`) — nenhum espaço de mídia reservado. **Rejeitada:** reservar o slot sempre (viola o guardrail do aceite).
+7. **Testes** — unit: `youtubeThumbnailUrl` em `tests/unit/speechVod.unit.spec.ts` e novo `tests/unit/speechViewModels.unit.spec.ts` cobrindo `thumbnailUrl` (presente / `null` sem vídeo / URL não-YouTube / `sourceUrl` intocado); int: 1 asserção em `tests/int/speechAcervo.int.spec.ts` de que o loader propaga o campo (o `speechBundleFixture` já aceita `youtubeUrl`, `speechBundleFixture.ts:29`); e2e: asserções no teste de busca existente de `campaignSpeechAcervo.e2e.spec.ts:132-183` (HTML do card contém `i.ytimg.com/vi/
/hqdefault.jpg`; fala sem YouTube não contém miniatura). **Rejeitadas:** spec e2e novo (o fluxo já é coberto); snapshot de imagem.
+8. **E2E local (OPS72)** — Opções: rodar `pnpm test:e2e:affected` | e2e full. **Recomendação:** affected — o diff toca `src/lib/speechVod.ts`, `src/utilities/speech` e `src/components/campaign/speech`, que o manifesto mapeia para `campaignSpeechAcervo` + `campaignSpeechCut` (`e2e-affected-manifest.mjs:305-314`). **Rejeitada:** e2e full (o `verify` do deploy já o roda).
+
+### Componentes / mudanças
+
+- **`youtubeThumbnailUrl(videoId: string | null | undefined): string | null`** (`src/lib/speechVod.ts`): nova função pura ao lado de `parseYoutubeVideoId` (`speechVod.ts:135`); retorna `https://i.ytimg.com/vi//hqdefault.jpg` ou `null`. Reusa o contrato de id já testado; não faz I/O.
+- **`SpeechListItemViewModel` / `toSpeechListItemViewModel`** (`src/utilities/speech/speechViewModels.ts:44-58`, `:172-209`): novo campo `thumbnailUrl: string | null`, montado como `youtubeThumbnailUrl(parseYoutubeVideoId(speech.youtubeUrl))`; derivado do que `speechListSelect` já traz (`speechPageData.ts:29-42`), sem query nova.
+- **`SpeechResultThumbnail`** (`src/components/campaign/speech/SpeechResultThumbnail.tsx`, novo): ilha client com `'use client'`; recebe `href` e `src`; renderiza `Link` (`h-20 w-32 shrink-0`) envolvendo `Skeleton` absoluto (cena 03, `animate-pulse`) + `
` raw, encerrando o skeleton em `onLoad`/`onError` (vídeo removido degrada para o slot neutro). Imagem em cache é coberta checando `img.complete` no mount (o `onLoad` não dispara de novo); `prefers-reduced-motion` já zera o pulse.
+- **`SpeechResultCard`** (`src/components/campaign/speech/SpeechResultCard.tsx:83-85`): troca o bloco do excerto pelo flex condicional (decisão 6), renderizando `SpeechResultThumbnail` só quando `speech.thumbnailUrl` existe. Nenhuma mudança em CTAs (`:112-125`), chips ou excerto.
+- **Migration:** sem migration (nenhum campo de coleção/global novo).
+- **Access / Consent:** sem mudança — `youtubeUrl` já é selecionado e já é exposto no detail VM (`speechViewModels.ts:259`) e no fallback de `sourceUrl`.
+- **UI:** Impeccable B; port classe-a-classe do artefato aprovado (cenas 01/02/03), sem inventar variante de estilo.
+
+### Dados → forma
+
+Não se aplica. A entrega não cria agregado, contagem, gráfico ou tabela: é uma URL de imagem derivada de um campo existente, para reconhecimento visual. Nenhuma decisão numérica nasce aqui (a intenção já fecha "Vou apresentar dados? Não").
+
+## Fases verificáveis
+
+1. **Domínio/VM (unit-first):** adicionar `youtubeThumbnailUrl` e o campo `thumbnailUrl`; escrever os casos unit em `tests/unit/speechVod.unit.spec.ts` e o novo `tests/unit/speechViewModels.unit.spec.ts` junto. Verificação: `pnpm test:unit -- speechVod speechViewModels` verde.
+2. **UI (encaixe no card):** `SpeechResultThumbnail` + layout condicional em `SpeechResultCard`, port do artefato. Verificação: `speechDetailPlayer`/`speechCutDialog` seguem verdes.
+3. **Integração e entrega:** `pnpm gate:fast`; e2e local afetado via `pnpm test:e2e:affected` (`campaignSpeechAcervo` + `campaignSpeechCut`); entrega com `pnpm push`.
+
+## Rabbit holes / Não escopo (engenharia)
+
+- **Refatorar as 3 ocorrências existentes** (`SpeechCutResultCard.tsx:70`, `SpeechCutPlayer.tsx:17`, `corte/[id]/page.tsx:40`) para o helper novo: blast radius em `corte/[id]` e no player, e o e2e pina o literal (`campaignSpeechCut.e2e.spec.ts:152`) — vira débito com gatilho (o próximo toque em capa de corte extrai o helper compartilhado e atualiza o pino).
+- Backfill/mirroring de thumbnails no S3, extração de poster do MP4 com ffmpeg, frame exato "do meio do trecho", player/hover-play na lista — todos já cortados na intenção; nenhum reabre aqui.
+- Miniatura do VOD da Câmara quando não há YouTube: fica para avaliação pós-uso real; sem YouTube → sem mídia.
+- Trocar `next/image`, `blurDataURL`, placeholder local ou `next.config.mjs` `remotePatterns` (já contém `i.ytimg.com`, mas o e2e não o alcança — irrelevante aqui).
+
+## Riscos e mitigação
+
+- **CLS / pulo de layout:** dimensões fixas `h-20 w-32` no slot e no Skeleton; `shrink-0` no mobile; o excerto recebe `min-w-0 flex-1`.
+- **Imagem externa hotlinkada:** sem mirroring (fora de escopo declarado); `loading="lazy"` + `decoding="async"`; `onError` degrada para slot neutro silencioso.
+- **Skeleton preso:** imagem em cache pode não disparar `onLoad` após hidratar — mitigado checando `img.complete` no mount.
+- **Duplicação do contrato `i.ytimg.com` (3 lugares):** aceita nesta entrega como débito com gatilho (a próxima ocorrência extrai o helper compartilhado).
+- **e2e browserless não busca a imagem:** o teste lê o HTML e assere a URL (`i.ytimg.com/.../hqdefault.jpg`); por isso raw `
` é seguro e `next/image` seria instável.
+- **Regressão de acessibilidade:** `alt=""` decorativo + `aria-label` no `Link` preservam o nome acessível do CTA já existente.
+
+## Aceite de engenharia
+
+- [x] Aceite de produto da intenção ainda coberto: card mostra miniatura quando há vídeo, sem espaço reservado quando não há; a miniatura reflete a fala do resultado; guardrails de performance e LGPD intactos.
+- [x] Invariantes AGENTS/engineering-standards: identificadores em inglês, UI da campanha com `data-theme='campaign'`, sem migration/Consent/access novos, precedente de raw `
` respeitado.
+- [x] Testes de domínio previstos: unit cobre `youtubeThumbnailUrl`/`thumbnailUrl`, int prova a propagação no loader e e2e cobre o HTML do card.
+- [x] `pnpm gate:fast` verde; e2e local afetado verde com `campaignSpeechAcervo` + `campaignSpeechCut`; entrega via `pnpm push`.
+
+## Débitos deferidos (capture-review-debts)
+
+- **Consolidar o contrato `i.ytimg.com`** (4ª ocorrência criada aqui, via `youtubeThumbnailUrl`): migrar `SpeechCutResultCard.tsx:70`, `SpeechCutPlayer.tsx:17` e `corte/[id]/page.tsx:40` para o helper. **Gatilho:** próximo toque em qualquer superfície de capa de corte ou um 4º consumidor de thumbnail; a extração deve manter a URL idêntica (o e2e `campaignSpeechCut.e2e.spec.ts:152` pina o literal).
+- **Reavaliar a ilha client `SpeechResultThumbnail`** (uma fronteira de hidratação por linha da lista) se o custo de hidratação de 25 cards aparecer. **Gatilho:** medição/uso real da lista; o fallback é manter o slot fixo `bg-muted` sem o pulse (perde só a cena 03).
+
+### Já resolvido no /simplify (não reabrir)
+
+- `aria-label` da miniatura igual ao label do CTA (não contraditório); `` sem duplicação nos dois ramos; parâmetro de `youtubeThumbnailUrl` restrito a `string | null`; `aria-hidden` redundante removido do `Skeleton`; docblock encurtado.
+
+### Descartado
+
+- **Extrair a miniatura de `SpeechCutResultCard` num componente único** (twin apontado pelo revisor): os dois têm contratos distintos (o de cortes não tem skeleton/`Link`); o que é compartilhável — a URL — já tem dono em `youtubeThumbnailUrl`. Fica no débito acima.
+- **Inline do helper com 1 call site:** mantido porque nomeia o contrato do CDN (o dono da consolidação do débito) e é unit-testável isolado.
+
+## Self-score (decision-quality)
+
+5/5 — (1) decisões caras com rejeitadas explícitas (as 8 nomeiam alternativas e o motivo do descarte); (2) a abordagem cabe no appetite ~0,5–1 dia (mudança aditiva de VM + um componente); (3) rabbit holes nomeados (as 3 ocorrências existentes, backfill, ffmpeg); (4) depth check: reusa `parseYoutubeVideoId`, o `Skeleton` da casa e o precedente de raw `
`, sem query/access novos; (5) a intenção permanece satisfeita — a engenharia não reescreveu o outcome.
diff --git a/src/components/campaign/speech/SpeechResultCard.tsx b/src/components/campaign/speech/SpeechResultCard.tsx
index 12ac6661..2e49172c 100644
--- a/src/components/campaign/speech/SpeechResultCard.tsx
+++ b/src/components/campaign/speech/SpeechResultCard.tsx
@@ -3,6 +3,7 @@ import Link from 'next/link'
import type { ReactNode } from 'react'
import { SpeechHighlightParts } from '@/components/campaign/speech/SpeechHighlightParts'
+import { SpeechResultThumbnail } from '@/components/campaign/speech/SpeechResultThumbnail'
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/button'
import type { SpeechHighlightedExcerpt } from '@/lib/speechHighlight'
@@ -55,6 +56,8 @@ export const SpeechResultCard = ({ speech }: { speech: SpeechListItemViewModel }
visibleTopics.length +
(speech.scopes.length - visibleScopes.length) +
(speech.keywords.length - visibleKeywords.length)
+ const watchLabel = speech.matchKind === 'segment' ? 'Assistir no trecho' : 'Ver fala'
+ const excerptNode =
return (
@@ -81,7 +84,18 @@ export const SpeechResultCard = ({ speech }: { speech: SpeechListItemViewModel }
-
+ {speech.thumbnailUrl ? (
+
+ ) : (
+ excerptNode
+ )}
{speech.topics.length || speech.scopes.length || speech.keywords.length ? (
@@ -113,7 +127,7 @@ export const SpeechResultCard = ({ speech }: { speech: SpeechListItemViewModel }
{speech.sourceUrl ? (
diff --git a/src/components/campaign/speech/SpeechResultThumbnail.tsx b/src/components/campaign/speech/SpeechResultThumbnail.tsx
new file mode 100644
index 00000000..52e6975c
--- /dev/null
+++ b/src/components/campaign/speech/SpeechResultThumbnail.tsx
@@ -0,0 +1,51 @@
+'use client'
+
+import Link from 'next/link'
+import { useEffect, useRef, useState } from 'react'
+
+import { Skeleton } from '@/components/ui/skeleton'
+
+/**
+ * C175 — the trecho thumbnail on the acervo list: a fixed 128×80 slot so the
+ * lazy image never shifts the card, with the skeleton filling it until the
+ * cover paints (a missing cover settles into the neutral slot). The link
+ * reuses the card's watch target, with the same accessible name as the CTA.
+ */
+export const SpeechResultThumbnail = ({
+ href,
+ src,
+ label,
+}: {
+ href: string
+ src: string
+ label: string
+}) => {
+ const [settled, setSettled] = useState(false)
+ const imageRef = useRef
(null)
+
+ // A cached image can finish before hydration, when `onLoad` never fires.
+ useEffect(() => {
+ if (imageRef.current?.complete) setSettled(true)
+ }, [])
+
+ return (
+
+ {settled ? null : }
+ {/* eslint-disable-next-line @next/next/no-img-element -- YouTube cover is an external static image. */}
+
setSettled(true)}
+ onError={() => setSettled(true)}
+ className="h-full w-full object-cover"
+ />
+
+ )
+}
diff --git a/src/lib/speechVod.ts b/src/lib/speechVod.ts
index c621b490..ccba3f10 100644
--- a/src/lib/speechVod.ts
+++ b/src/lib/speechVod.ts
@@ -160,6 +160,14 @@ export const parseYoutubeVideoId = (raw: unknown): string | null => {
return candidate && YOUTUBE_VIDEO_ID.test(candidate) ? candidate : null
}
+/**
+ * The YouTube default cover (C175) of an already-parsed video id, or null when
+ * there is none. `hqdefault` is the variant the whole repo uses; `object-cover`
+ * crops the 4:3 letterbox bars the YouTube player frame adds to 16:9 video.
+ */
+export const youtubeThumbnailUrl = (videoId: string | null): string | null =>
+ videoId ? `https://i.ytimg.com/vi/${videoId}/hqdefault.jpg` : null
+
const SAO_PAULO_TIME_ZONE = 'America/Sao_Paulo'
const NAIVE_DATETIME = /^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})(?::(\d{2}))?/
const brtWallClock = new Intl.DateTimeFormat('en-CA', {
diff --git a/src/utilities/speech/speechViewModels.ts b/src/utilities/speech/speechViewModels.ts
index 5a3546d2..9caed4bd 100644
--- a/src/utilities/speech/speechViewModels.ts
+++ b/src/utilities/speech/speechViewModels.ts
@@ -16,7 +16,12 @@ import {
type SpeechHighlightPart,
} from '@/lib/speechHighlight'
import { normalizeForSearch } from '@/lib/speechSearch'
-import { excerptOffsetSeconds, parseYoutubeVideoId, speechVodCoordinates } from '@/lib/speechVod'
+import {
+ excerptOffsetSeconds,
+ parseYoutubeVideoId,
+ speechVodCoordinates,
+ youtubeThumbnailUrl,
+} from '@/lib/speechVod'
import type { Municipality } from '@/payload-types'
import { speechScopeLabels, speechTopicLabels } from '@/utilities/speech/speechListUrl'
@@ -53,6 +58,8 @@ export type SpeechListItemViewModel = {
scopes: { value: SpeechScope; label: string }[]
keywords: string[]
municipalities: { id: number; name: string }[]
+ /** YouTube cover of the session link (C175); null when the speech has none. */
+ thumbnailUrl: string | null
watchHref: string
sourceUrl: string | null
}
@@ -203,6 +210,7 @@ export const toSpeechListItemViewModel = ({
scopes: scopeViewModels(speech),
keywords: speech.keywords ?? [],
municipalities: municipalityViewModels(speech, municipalityLabels),
+ thumbnailUrl: youtubeThumbnailUrl(parseYoutubeVideoId(speech.youtubeUrl)),
watchHref: buildWatchHref(speech.id, matchedSegment, q),
sourceUrl: speech.officialTextUrl ?? speech.youtubeUrl ?? null,
}
diff --git a/tests/e2e/campaignSpeechAcervo.e2e.spec.ts b/tests/e2e/campaignSpeechAcervo.e2e.spec.ts
index fc4f8fb6..6df78d13 100644
--- a/tests/e2e/campaignSpeechAcervo.e2e.spec.ts
+++ b/tests/e2e/campaignSpeechAcervo.e2e.spec.ts
@@ -149,6 +149,9 @@ test.describe('communication vertical (C154/C162)', () => {
expect(resultsHtml).toContain(' {
const user = await campaign.fixtures.createCampaignUser('communicator')
const request = await campaignRequest(user, user.password)
+ const results = await request.get(`/campanha/comunicacao/acervo?q=${marker}`)
+ expect(results.status()).toBe(200)
+ // C175 — no YouTube session link means no thumbnail and no reserved media.
+ expect(rendered(await results.text())).not.toContain('i.ytimg.com')
+
const detail = await request.get(`/campanha/comunicacao/acervo/${speech.id}`)
expect(detail.status()).toBe(200)
const html = rendered(await detail.text())
diff --git a/tests/int/speechAcervo.int.spec.ts b/tests/int/speechAcervo.int.spec.ts
index bb7b5c9b..a78b9722 100644
--- a/tests/int/speechAcervo.int.spec.ts
+++ b/tests/int/speechAcervo.int.spec.ts
@@ -231,6 +231,26 @@ describe('speech acervo (C154)', () => {
expect(row).not.toHaveProperty('downloadUrl')
})
+ it('propagates the YouTube thumbnail to the list view model (C175)', async () => {
+ const runId = randomUUID().slice(0, 8)
+ const marker = `miniatura${runId}`
+ const withVideo = await createSpeech({
+ youtubeUrl: 'https://www.youtube.com/watch?v=lLhRDkSPw0A',
+ segments: [{ startSeconds: 0, endSeconds: 3, text: `A fala ${marker} no plenário` }],
+ })
+ const withoutVideo = await createSpeech({
+ segments: [{ startSeconds: 0, endSeconds: 3, text: `A fala ${marker} sem vídeo` }],
+ })
+
+ const { communicator } = await createUsers()
+ const data = await loadSpeechAcervoPageData(payload, communicator, { q: marker })
+
+ expect(data.rows.find((row) => row.id === withVideo)?.thumbnailUrl).toBe(
+ 'https://i.ytimg.com/vi/lLhRDkSPw0A/hqdefault.jpg',
+ )
+ expect(data.rows.find((row) => row.id === withoutVideo)?.thumbnailUrl).toBeNull()
+ })
+
it('allows communicator/coordinator/candidate and denies advisor/leader', async () => {
const id = await createSpeech()
const { communicator, coordinator, candidate, advisor, leader } = await createUsers()
diff --git a/tests/unit/speechViewModels.unit.spec.ts b/tests/unit/speechViewModels.unit.spec.ts
new file mode 100644
index 00000000..1ccf4cb1
--- /dev/null
+++ b/tests/unit/speechViewModels.unit.spec.ts
@@ -0,0 +1,39 @@
+// @vitest-environment node
+
+import { describe, expect, it } from 'vitest'
+
+import {
+ toSpeechListItemViewModel,
+ type SpeechListRecord,
+} from '@/utilities/speech/speechViewModels'
+
+const viewModel = (overrides: Partial = {}) =>
+ toSpeechListItemViewModel({
+ speech: { id: 1, speechAt: '2026-08-11T18:48', ...overrides },
+ segments: [],
+ municipalityLabels: new Map(),
+ })
+
+describe('toSpeechListItemViewModel thumbnail (C175)', () => {
+ it('carries the YouTube cover when the session link parses', () => {
+ const row = viewModel({ youtubeUrl: 'https://www.youtube.com/watch?v=lLhRDkSPw0A' })
+
+ expect(row.thumbnailUrl).toBe('https://i.ytimg.com/vi/lLhRDkSPw0A/hqdefault.jpg')
+ })
+
+ it('has no thumbnail without a YouTube link', () => {
+ expect(viewModel().thumbnailUrl).toBeNull()
+ expect(viewModel({ youtubeUrl: null }).thumbnailUrl).toBeNull()
+ expect(viewModel({ youtubeUrl: 'https://camara.leg.br/discurso' }).thumbnailUrl).toBeNull()
+ })
+
+ it('keeps the source link independent from the thumbnail', () => {
+ const row = viewModel({
+ youtubeUrl: 'https://youtu.be/lLhRDkSPw0A',
+ officialTextUrl: 'https://camara.leg.br/discurso',
+ })
+
+ expect(row.thumbnailUrl).toBe('https://i.ytimg.com/vi/lLhRDkSPw0A/hqdefault.jpg')
+ expect(row.sourceUrl).toBe('https://camara.leg.br/discurso')
+ })
+})
diff --git a/tests/unit/speechVod.unit.spec.ts b/tests/unit/speechVod.unit.spec.ts
index b73c56d8..d1e59eaf 100644
--- a/tests/unit/speechVod.unit.spec.ts
+++ b/tests/unit/speechVod.unit.spec.ts
@@ -2,7 +2,7 @@
import { describe, expect, it } from 'vitest'
-import { excerptOffsetSeconds, parseYoutubeVideoId } from '@/lib/speechVod'
+import { excerptOffsetSeconds, parseYoutubeVideoId, youtubeThumbnailUrl } from '@/lib/speechVod'
/**
* The VOD URL/status/duration contracts moved from `scripts/lib/camaraSpeeches.mjs`
@@ -82,3 +82,16 @@ describe('parseYoutubeVideoId', () => {
expect(parseYoutubeVideoId('https://www.youtube.com/watch?v=../etc/passwd')).toBeNull()
})
})
+
+describe('youtubeThumbnailUrl', () => {
+ it('builds the hqdefault cover for a parsed video id (intent literal)', () => {
+ expect(youtubeThumbnailUrl('lLhRDkSPw0A')).toBe(
+ 'https://i.ytimg.com/vi/lLhRDkSPw0A/hqdefault.jpg',
+ )
+ })
+
+ it('returns null when there is no video id', () => {
+ expect(youtubeThumbnailUrl(null)).toBeNull()
+ expect(youtubeThumbnailUrl('')).toBeNull()
+ })
+})