feat(C226): frame estático nos vídeos da Central antes do play

Peça de vídeo publicada e com arquivo passa a mostrar um frame estático
leve no card público antes do play, sem nenhum request do arquivo antes
do gesto (`preload="none"` do S27 intacto).

O frame é um derivado privado em `contentMedia` (nome determinístico),
gerado por um dono só — `ensureContentPieceFrame` — chamado pelo job
C211 (que já tem o arquivo em disco: a extração sai de graça) e pela
rota `GET /conteudos/<slug>/frame` em miss, que cura o acervo anterior
sem backfill, fila, collection nova ou migration.

A porta reusa o gate de slug publicado e o proxy de mídia privada do
`/midia`: o kill switch fecha as duas de uma vez. Sem frame possível, o
slot mostra o estado neutro honesto com play e o marcador — nunca
imagem quebrada, nunca retry.

A projeção declara o frame por elegibilidade (vídeo com arquivo), não
pela existência do derivado, então a cura é invisível para o cache da
listagem e não há tag para bustar. O frame também aparece na página da
peça e no card da home (variante compacta de 124px, cena 05).

A passagem de design (tier primário) achou dois bugs que só o browser
mostra: o glyph de imagem quebrada numa requisição que falha, e — ao
esconder a imagem até decodificar — o frame sumindo para sempre no
caminho mais rápido (uma imagem que carrega antes da hidratação não
dispara `load` nem `error` de novo; o `ref` decide os dois desfechos).

Também: o guard de falha de e2e aceita `RegExp` e confere os paths
declarados antes do caso do favicon (todo 404 do browser traz a mesma
mensagem), o manifest de e2e mapeia os módulos novos, e o job C211
passa o arquivo que já tem em disco em vez de rebaixar o vídeo.

Design tier: central-conteudos-preview-frame-video
Closes #1352
This commit is contained in:
Francisco Solla 2026-09-25 14:35:22 -03:00
parent 0ee0a415c1
commit a2b1d7d3eb
22 changed files with 1963 additions and 31 deletions

View file

@ -0,0 +1 @@
**Frame estático para vídeos com arquivo na Central (C226, Issue #1352):** toda peça de vídeo publicada e com arquivo no acervo passa a exibir um frame estático leve no card público **antes** do play, sem nenhum request do arquivo de vídeo antes do gesto (`preload="none"` intacto, S27 preservado). O frame é um derivado privado na coleção `contentMedia` (nome determinístico `content-piece-frame-<id>.jpg`), gerado por um dono só (`ensureContentPieceFrame`) chamado pelo job C211 — que já tem o arquivo em disco, então a extração sai de graça — e pela rota `GET /conteudos/<slug>/frame` em miss, que cura o acervo anterior peça a peça, sem backfill, fila ou collection nova e **sem migration**. A porta reusa o gate de slug publicado e o proxy de mídia privada do `/midia`: o kill switch fecha as duas de uma vez. Sem frame possível (falha de extração, arquivo acima do cap de 256 MB, peça sem arquivo), o slot mostra o estado neutro honesto com play e o marcador "Frame indisponível" — nunca imagem quebrada, nunca retry. A projeção declara o frame por **elegibilidade** (vídeo com arquivo) e não pela existência do derivado, então a cura é invisível para o cache da listagem e não há tag para bustar; a mesma peça também ganha o frame na página da peça e no card da home (variante compacta de 124px, cena 05 do artefato). O guard de falha de e2e passou a aceitar `RegExp` e a checar os paths declarados antes do caso do favicon (todo 404 do browser traz a mesma mensagem do favicon). Débitos registrados: teto agregado da cura pública (#1356) e paridade de cromo pré-existente do slot (#1358).

View file

@ -0,0 +1,207 @@
# Impl: C226 — Central de Conteúdos: frame estático para vídeos com arquivo
Status: aprovado
Atualizado em: 2026-09-25
Issue: #1352
Intenção: docs/plans/central-conteudos-preview-frame-video.md
Appetite restante: herdado (~1–2 dias eng) — sem corte novo: o plano reusa o gate público do S27, o proxy de mídia privada do C193/C199, o `runFfmpeg` dono (C199), a linha privada `contentMedia` e o slot de mídia compartilhado; **sem migration, sem fila, sem backfill e sem collection nova**. Editor de frame, backfill, player novo e o acervo de falas (C182) seguem fora (intenção).
> Modo `--auto` (work-issue): plano nasce aprovado pelo agente. Design UI do gate: `docs/plans/central-conteudos-preview-frame-video-ui-design.html` — **não redesenhar**. Trigger de designer declarado em **D5: non-trigger** — o markup aprovado (CENA 01/02 com frame, CENA 04 fallback neutro) recebe só o `<img>` e o marcador; qualquer estado novo que a execução precise para além disso é **gatilho de designer antes do markup**, não improviso.
## Leitura da intenção
- **Outcome:** toda peça de vídeo publicada e com arquivo no acervo mostra, no catálogo público, pelo menos **um frame estático** antes do play — imagem leve, carregada como imagem, **sem nenhum request do arquivo de vídeo** antes do gesto; sem frame possível, o slot neutro desenhado com play honesto.
- **O que NÃO negociar:**
- **Play sob demanda:** nada de elemento de mídia antes do gesto, `preload="none"` preservado, o arquivo de vídeo só entra no play (S27).
- **Kill switch:** peça despublicada **não** expõe sua mídia privada pelo novo preview. Derivado em coleção pública é proibido; a porta pública do frame usa o mesmo gate de slug publicado do `/midia`.
- **Arquivo privado continua privado:** `ContentMedia` segue `canReadContentPiece`; o frame nunca entra em `media` (anonymous-read por contrato, `Media.ts:16-20`).
- **Falha isolada:** frame que falha **não** marca a peça `falhou`, não escreve `error`, não invalida `step`; e o card não tenta repetir a busca sem limite.
- **Sem frame de terceiro:** peça-link (Instagram/YouTube sem arquivo) continua sem frame local; thumbnail de plataforma não conta.
- **Superfícies:** alvo obrigatório é `/conteudos`; a lista interna `/campanha/comunicacao/conteudos` **não** ganha player nem segunda visualização de frame (o player dela é separado, `preload="metadata"`, e fica intocado).
- **C182 proibido:** não reusar nem alterar o preview do acervo de falas (`speechPoster*.ts`, rota `/acervo/[id]/poster`).
- **Escopo de produto:** sem backfill/reprocessamento em massa, sem editor de timestamp, sem analytics nova, sem Consent, sem alteração do kill switch.
- **O que reavaliar (hipóteses da intenção, confirmadas ou resolvidas no código):**
- "o card só monta `<video>` quando `playing`, sem `poster`/imagem/`onError`" — confirmado (`ContentPieceMedia.tsx:147-198`); o estado pré-play hoje é gradiente + chip de tipo + play + duração (`:183-197`).
- "a projeção pública não tem campo de frame" — confirmado: `ContentPiecePublicItem` (`:355-387`) carrega `file { id, path, mimeType, downloadFilename }` (`:389-394`), e `mediaOf` (`:655-663`) descarta `url`/`thumbnailURL`/dimensões. **Consequência de projeto:** o slot de foto já consome `item.file?.path` sem dimensões — o frame segue o mesmo contrato de _path_.
- "`isLink` = `media === null && sourceUrl !== null`" — confirmado (`:704`); peça-link nunca tem arquivo, logo nunca tem frame.
- "`file.path` é sempre `/conteudos/<slug>/midia`" — confirmado (`:731-737`, `contentPieceMediaPath` em `lib/contentPieceCatalog.ts:43-44`): a URL pública do frame é uma irmã desse path, no mesmo vocabulário.
- "o job baixa o privado, extrai áudio, transcreve e cataloga, sem passo de still" — confirmado (`contentPieceJob.ts:116-193,200-310`): o arquivo **já está em disco** no temp do job quando o áudio é extraído, ou seja, um still é praticamente de graça nesse ponto.
- "o C182 gera sob demanda com cache, single-flight, limite de concorrência e cache negativo" — confirmado (`speechPosterJob.ts:25-31,45-59,94-189`): é o molde, não a dependência.
- "o proxy público atual valida publicado" — confirmado (`conteudos/[slug]/midia/route.ts:15-24,37-63`): a **única** forma de não furar o kill switch é uma rota que reuse `getPublishedContentPieceBySlug` + `buildPrivateMediaResponse`.
- "a listagem pública é `unstable_cache` sob a tag `contentPieces` e só o `ContentPiece` a busta" — confirmado (`contentPieceReads.ts:20-65`, `ContentPiece.ts:92-106`): por isso o `frame` da projeção **não** pode depender da existência da linha derivada (D3) — assim a cura nem precisa invalidar tag.
- **Assumidas da intenção (recomendações A, validadas no gate):** (1) **frame = primeiro frame decodificável** → **divergência registrada em D4** (offset determinístico curto, mesmo custo; o primeiro frame de material editorial é quase sempre leader/fade preto e seria indistinguível do fallback desenhado); (2) **falha ⇒ fallback neutro com play** (CENA 04) — mantido; (3) **só o catálogo é obrigatório nesta fatia** → D5: o frame viaja no slot compartilhado, então também aparece na peça pública e no card da home (mesmo markup, nenhuma estrutura nova), sem criar um segundo outcome.
## Abordagem recomendada
```mermaid
flowchart LR
U["Envio / link oficial"] --> J["C211 job<br/>baixa o privado → extrai áudio → transcreve"]
J -->|"arquivo já em disco"| F{"kind = video?"}
F -- não --> NP["sem frame"]
F -- sim --> E["ensureContentPieceFrame<br/>single-flight · conc. 2 · falha isolada"]
E -->|"ffmpeg -ss 1s -frames:v 1 scale=640"| ROW["contentMedia<br/>content-piece-frame-&lt;id&gt;.jpg"]
E -.->|"falha"| LOG["log [content-piece-frame]<br/>peça segue pronto"]
R["GET /conteudos/&lt;slug&gt;/frame"] --> G{"getPublishedContentPieceBySlug<br/>(mesmo gate do /midia)"}
G -- "sem slug publicado / sem vídeo" --> N404["404 silencioso"]
G -- "publicado + vídeo" --> P{"linha derivada existe?"}
P -- sim --> S["buildPrivateMediaResponse<br/>image/jpeg · inline · private no-store"]
P -- não --> B{"≤ 256 MB e passo local?"}
B -- sim --> E
B -- não --> N404
S --> IMG["<img loading=lazy decoding=async alt=''><br/>camada sobre o slot neutro"]
IMG -->|"onError"| M["marcador 'FRAME INDISPONÍVEL' + play + duração<br/>(CENA 04, sem retry)"]
IMG -->|"play"| V["<video preload='none' src=/midia><br/>mesmo slot 16:9"]
```
**Opções consideradas (geral):** A) frame como derivado privado em `contentMedia` com nome determinístico, servido por uma rota pública nova que reusa o gate e o proxy de mídia privada, gerado por **um dono** (`ensureContentPieceFrame`) chamado pelo job e pela rota; B) frame em `media` (coleção pública) servido pelo proxy `/api/media/file`, gerado só no job; C) campo novo `ContentPiece.frameMedia` + migration, gerado só no job.
**Recomendação:** **A** — mantém o derivado tão privado quanto o vídeo (o kill switch sobrevive por construção, sem depender de disciplina), não cobra migration nem campo novo, não cria fila nem collection, e satisfaz o aceite "toda peça" sem backfill porque **a cura é disparada pela própria visita** (uma linha por peça, uma vez).
**Rejeitadas:** **B** porque `media.read: () => true` (`Media.ts:16-20`) tornaria o frame público **mesmo com a peça despublicada** — viola o inegociável do kill switch, e o nome determinístico é enumerável (`/api/media/file/content-piece-frame-12.jpg`); **C** porque a relação seria um ponteiro denormalizado do que o nome determinístico já diz, custando migration, `select` da leitura pública, escrita transacional e um ponto de invalidação a mais, sem mudar um único comportamento observável.
### D1 — Onde o frame vive e por onde ele sai (kill switch por construção)
**Opções:** A) linha derivada em `contentMedia` sob nome determinístico (`content-piece-frame-<id>.jpg`), servida por `GET /conteudos/[slug]/frame` que reusa `getPublishedContentPieceBySlug` + `buildPrivateMediaResponse` + `resolvePrivateMediaStaticDir`; B) `media` + URL do proxy; C) `ContentPiece.frameMedia` (relation) + rota.
**Recomendação:** **A** — o nome determinístico é ao mesmo tempo chave de cache e chave de idempotência (precedente C182), o probe é um `find` indexado de `limit: 1`, e **a rota é a única porta**: rascunho, slug desconhecido, peça-link e peça sem arquivo respondem o mesmo 404 silencioso do `/midia` (`midia/route.ts:26-28`), sem revelar o que existe. O S3/Garage já cobre `contentMedia` (`payload.config.ts:182-208`) e sem `S3_*` cai no disco local — **nenhuma mudança de storage**. A linha entra em `contentMedia` (não em `media`), que segue `canReadContentPiece`; `contentMedia.alt` é obrigatório, então o frame grava `alt = Frame do vídeo «<título>»`.
**Rejeitadas:** **B** — anula o kill switch (ver acima); **C** — migration + ponteiro denormalizado sem comportamento novo. Também rejeitada, dentro de A: **reaproveitar `/midia` com `?frame=1`** — uma rota que responde dois ativos com gatilhos de geração diferentes, e pior: o regex de pin do e2e (`mediaRequests`, `/\/conteudos\/.+\/midia/`) passaria a contar o frame e a enshrinar "nada é buscado antes do play" como mentira. Caminho irmão (`/frame`) não casa com o regex e mantém o pin verde.
**Migration: nenhuma.** `ContentPiece`, `ContentMedia` e `content_piece` ficam intocados; `pnpm generate:types` não é necessário. Se a equipe quiser a ligação explícita peça↔frame no admin, é uma coluna + hook (item próprio, gatilho: alguém precisar consultar "quais peças têm frame" por SQL).
### D2 — Onde gerar, e o que significa invalidar
**Opções:** A) só como passo do job; B) só sob demanda na rota; C) **um dono só** (`ensureContentPieceFrame`) invocado pelo job **e** pela rota em miss, com single-flight, gate de concorrência, memo de falha e **zero escrita em `contentPiece`**; D) backfill em massa / reconciliador / fila.
**Recomendação:** **C** — o job cobre toda peça nova com o arquivo **já em disco** (custo de um `-ss` rápido, sem download), e a rota cobre o acervo anterior que nenhum job vai reprocessar. "Invalidar" **deixa de existir**: a projeção declara `framePath` pela _elegibilidade_ (vídeo com arquivo), não pela existência da linha (D3), então a cura é invisível para o cache da listagem e não há tag para bustar; a cura só escreve a linha `contentMedia`, nunca a peça. Falha isolada em dois níveis: no job, a chamada fica num `try/catch` próprio com log `[content-piece-frame]` — nunca `falhou`, nunca `error`, nunca muda `currentStep`; na rota, `ensureContentPieceFrame` **não lança** e a resposta é 404, o slot neutro do desenho. Idempotência: advisory lock `content-piece-frame:<id>` + probe antes do create, dentro de `withPayloadTransaction` (mesmo desenho de `speechPosterJob.ts:94-189`).
**Rejeitadas:** **A** — o acervo publicado antes desta entrega nunca receberia frame (não existe "Reprocessar" para peça `pronto`; `canRetry` só cobre `falhou`), então o aceite "toda peça de vídeo publicada" ficaria falso no dia um; **B** — a primeira visita de uma peça nova mostraria o slot neutro e a imagem dependeria de uma requisição que falha por design (ruim para a peça que acabamos de processar, onde o frame já poderia existir); **D** — anti-goal explícito da intenção ("fila, backfill, armazenamento e reconciliação de todos os vídeos"), e cria superfície de reconciliação que ninguém pediu.
**Ordem no job:** depois do bloco de transcrição (é ali que o arquivo está local e o `media` conhecido), antes de `markStep('catalogando')` — o `tempDir` só é removido no `finally` (`:308-310`).
**Observabilidade:** a rota **não** grava `contentEvent` (C213 conta `abertura`/`download`; ver um frame não é abrir nem baixar a peça — somar aí inflaria métrica existente). Só uma linha de log no job e no cache negativo.
### D3 — Como o frame chega ao card
**Opções:** A) campo novo na projeção pública (`framePath: string | null` no `ContentPiecePublicItem`) + `<img>` no slot, com `onError` trocando para o estado desenhado; B) `poster` do `<video>`; C) URL composta dentro do componente; D) o campo só quando a linha derivada existe.
**Recomendação:** **A** — espelha o contrato de `file.path` (o slot de foto já consome path sem dimensões) e mantém a composição de URL pública no dono (`contentPieceFramePath(slug)` em `lib/contentPieceFrame.ts`, irmão de `contentPieceMediaPath`). `<img>` cru com `loading="lazy" decoding="async"` e `alt=""` por cima do slot neutro (que é a camada base — sem _flicker_ quando a imagem entra), `object-cover`, o play e a duração continuam por cima (`z-10`, como no design). `onError` marca o frame como indisponível **para a vida do componente**: o `<img>` sai do DOM, entra o marcador da CENA 04, e **não há novo `src`** (a intenção proíbe repetir a busca sem limite; o servidor já memoiza a falha por 5 min).
**Rejeitadas:** **B** — `poster` só existe em elemento de mídia, e elemento de mídia antes do gesto é exatamente o que o S27 proíbe; além disso a CENA 01/02 desenham o frame como **imagem** ocupando o slot e o player como peça seguinte ("O player substitui apenas o frame dentro do mesmo aspecto 16:9"); **C** — duplica o vocabulário de URL pública no componente, vazaria path para peça-link/sem arquivo (404 garantido por card) e transformaria o fallback desenhado (estado **conhecido** do servidor) em falha de rede; **D** — mataria a própria rota-cura: o `<img>` só existe quando o path existe, então uma peça sem frame nunca geraria o primeiro frame; e amarraria a projeção a uma escrita.
### D4 — Qual frame extrair (divergência registrada)
**Opções:** A) primeiro frame decodificável (`-ss 0`); B) primeiro frame após um **offset determinístico curto** (`-ss 1`), com **uma** retentativa limitada em `-ss 0` se a primeira não produzir arquivo; C) meio da duração (regra do C182); D) seleção manual.
**Recomendação:** **B** — **divergência em relação à opção A assumida na intenção**, e o motivo é visual, não técnico: vídeo de material eleitoral quase sempre abre com leader/fade preto, e um JPEG preto válido é indistinguível do estado "FRAME INDISPONÍVEL" da CENA 04 — ou seja, a regra A destruiria o sinal que o desenho usa para dizer "isto aqui ainda não tem frame". O custo é idêntico (um `-ss` antes de `-i`, um `-frames:v 1`, um `stat()`), a decisão é determinística e não promete momento algum. A retentativa única em `-ss 0` cobre vídeo curtíssimo, cabeçalho mentiroso ou stream sem keyframe em 1 s; se as duas falharem, é o fallback neutro, sem laço. Escala `640:-2`, `-q:v 3`, `-an`, JPEG (o slot chega a ~600 px de largura na peça pública). `stat(outputPath)` depois do ffmpeg: seek além do fim não deixa arquivo — é falha, nunca linha quebrada.
**Rejeitadas:** **A** — ver acima (e continua sendo a **primeira** tentativa da retentativa, então nada se perde); **C** — a duração do C211 é medida pelo **ASR do áudio** (`contentPieceJob.ts:250`), não pelo stream de vídeo: um desvio seeks para o frame errado ou para nada, e ainda copia a regra de outro domínio (o C182 usa offset do _meio_ porque a fala é o conteúdo; aqui o conteúdo é a imagem); **D** — rabbit hole de produto, cortado na intenção.
**Por que JPEG e não WebP:** um passe só de ffmpeg, sem `sharp` no job, e `image/jpeg` já está na lista de inline de `PRIVATE_MEDIA_INLINE_MIME_TYPES` (`lib/privateMedia.ts`); a economia de um WebP (~30%) não paga um segundo encoder nem um passo de pós-processamento.
### D5 — Onde o frame aparece (superfícies que dividem o slot)
**Opções:** A) o slot compartilhado `ContentPieceMedia` — o frame aparece no catálogo, na peça pública (`/conteudos/[slug]`, `variant="detail"`) e no card da home; o player da lista interna fica intocado; B) prop `showFrame` por superfície; C) só o catálogo.
**Recomendação:** **A** — a pergunta em aberto da intenção (3) era sobre o preview na página individual, e a resposta lá é "sem criar um segundo outcome": o componente é um só, o slot é 16:9 nas três superfícies e o markup é o mesmo `<img>` + `object-cover`; nenhum layout novo, nenhuma tela nova, nenhuma decisão visual. A lista interna **não** entra: o player dela é outro (`preload="metadata"`, `conteudos/[id]/page.tsx:142-168`) e continua sem frame, como a intenção exige.
**Rejeitadas:** **B** — flag com um único valor possível = cerimônia (e o compilador já força a propagação do campo, o que torna a prop redundante); **C** — exige o prop de B e deixa a home — onde o reconhecimento mais importa, em 124 px de largura no celular — sem frame.
**Trigger de designer: `non-trigger` (declarado).** Tudo que o código exige já está desenhado: CENA 01/02 (frame ocupando o slot, play no centro, duração embaixo à direita) e CENA 04 (superfície neutra + marcador "FRAME INDISPONÍVEL" + play + duração). Duas **adaptações de copy/estrutura dentro do desenho**, registradas: (i) no estado **sem frame** o marcador da CENA 04 ocupa o lugar do chip `typeLabel` que hoje mora dentro do slot (`ContentPieceMedia.tsx:191-193`) — o tipo continua visível na tag do corpo do card (`CardTag`, `ContentPieceCard.tsx:33-36`); (ii) o estado de **áudio** mantém o chip `typeLabel` (o design não cobre áudio e a intenção mantém áudio intacto). Se a execução precisar de um estado além disso (ex.: um "carregando o frame" visível), **para e o designer estende o artefato antes do markup**.
### D6 — Cache e privacidade do frame
**Opções:** A) `private, no-store` via `PRIVATE_MEDIA_CACHE_CONTROL` (o mesmo header do `/midia`) + `inline`/`nosniff` de `privateMediaHeaders`; B) `private, max-age=300`; C) `public, immutable` com nome versionado.
**Recomendação:** **A** — o derivado é tão privado quanto o vídeo: despublicar precisa ser imediato, e o `no-store` do `/midia` é exatamente esse contrato. O custo é uma imagem pequena re-baixada a cada visita ao catálogo, com `loading="lazy"` fora do caminho crítico.
**Rejeitadas:** **B** — abriria uma janela de 5 minutos em que a peça despublicada continua visível no cache do navegador: quebra o inegociável, e **não** se resolve sozinho (mexer nisso é conversa com o usuário, não gatilho de execução); **C** — o nome é determinístico, então `immutable` mentiria depois de um reprocessamento, e um cache compartilhado sobrevive ao kill switch.
### Micro-decisões (baratas, com gatilho de revisitação)
- **`alt=""` no `<img>`:** o nome acessível do slot já é o botão "Reproduzir <título>"; `alt={<título>}` duplicaria o título vizinho no leitor de tela. **Gatilho:** revisão de a11y que peça imagem descritiva.
- **Cap de download no caminho-cura:** `CONTENT_PIECE_FRAME_MAX_SOURCE_BYTES = 256 MB` — o custo do heal é **baixar o vídeo inteiro** para extrair um still; acima disso, o slot neutro é a resposta honesta. O caminho do job não paga isso (o arquivo já está local) e por isso não aplica o cap: a regra é sobre custo de I/O, não sobre elegibilidade.
- **Budget de espera da rota:** `CONTENT_PIECE_FRAME_WAIT_MS = 5_000` (o C182 usa 20 s, atrás de login e com still de fala pequena; aqui a cura pode custar o download inteiro e o fallback é um estado desenhado). Passou o budget, a resposta é 404 e a geração segue em `after`.
- **Concorrência 2 e memo de falha 5 min:** mesmos números do C182 (`speechPosterJob.ts:28-31`), copiados por simetria de risco.
- **`Content-Disposition` sem override:** o nome interno `content-piece-frame-<id>.jpg` só é visto em download acidental; nobody faz download de frame.
- **Forma do campo:** `framePath: string | null` (string, não objeto de uma chave — `file` é objeto porque carrega quatro campos; um objeto-fantasma aqui é cerimônia).
### Componentes / mudanças
- **`src/lib/contentPieceFrame.ts`** (novo; puro, client-safe, sem I/O — espelha `src/lib/speechPoster.ts`): `contentPieceFrameFilename(pieceId)`, `contentPieceFramePath(slug)` (irmã de `contentPieceMediaPath`), `CONTENT_PIECE_FRAME_MAX_SOURCE_BYTES`, `CONTENT_PIECE_FRAME_WAIT_MS`, `contentPieceFrameAlt(title)`, `buildContentPieceFrameFfmpegArgs({ inputPath, outputPath, atSeconds })` (args como array, `-ss` antes de `-i`, `-frames:v 1`, `-vf scale=640:-2`, `-q:v 3`, `-an`, `-nostdin`; **nunca** string de shell). O predicado de elegibilidade **não mora aqui**: é o `contentPieceMediaKind` que já existe (`lib/contentPieceCatalog.ts:527-544`) — a projeção e o job usam o mesmo dono, sem gêmeo.
- **`src/utilities/content/contentPieceFrameJob.ts`** (novo; `import 'server-only'`, dentro da subpasta de domínio existente — sem novo módulo top-level, logo sem tocar o pin de `codebaseConventions.unit.spec.ts`): `ensureContentPieceFrame({ payload, pieceId, localPath?, alt? })` — probe por nome determinístico, single-flight em memória, gate de concorrência, memo de falha, `downloadPrivateMediaToFile` + `resolvePrivateMediaStaticDir` quando não há `localPath` (respeitando o cap), `runFfmpeg` (dono C199) com `stat(outputPath)`, create em `withPayloadTransaction` + `acquireTextAdvisoryLocks(['content-piece-frame:<id>'])` + probe antes do create. **Nunca lança.**
- **`src/app/(frontend)/conteudos/[slug]/frame/route.ts`** (novo): `dynamic = 'force-dynamic'`, gate `getPublishedContentPieceBySlug` (404 silencioso idêntico ao `/midia`), elegibilidade pelo `framePath` da projeção, probe da linha derivada, `after()` na geração + espera até o budget, resposta por `buildPrivateMediaResponse` (mesmo `staticDir`/headers do `/midia`). Sem `contentEvent`, sem `?download`, sem query string.
- **`src/utilities/content/contentPieceJob.ts`**: uma chamada a `ensureContentPieceFrame` após o bloco de transcrição, em `try/catch` próprio com prefixo `[content-piece-frame]`; só para `source.media` presente; **nada mais muda** (a passos, curadoria, transação e bypass admin seguem como estão).
- **`src/lib/contentPieceCatalog.ts`**: `ContentPiecePublicItem.framePath: string | null` (derivado de `contentPieceMediaKind(...) === 'video'` e `file !== null`) preenchido em `toContentPiecePublicItem`. `ContentPiecePublicSource` e o `select` de `contentPieceReads` **não mudam** (o `mimeType` e o tipo já vêm na leitura) — é isso que mantém a cura invisível para o cache.
- **`src/lib/contentPieceHomeSelection.ts`**: `'framePath'` entra no `Pick` **e** no `toContentPieceHomeItem` (o compilador exige os dois; o mapa campo-a-campo é o que impede o campo de vazar sozinho).
- **`src/components/conteudos/ContentPieceMedia.tsx`**: `'framePath'` no `ContentPieceMediaItem`; estado de frame indisponível **no componente pai** (dono do `playing`), para que o ciclo play/stop não re-peça o frame; camada `<img>` (lazy, `alt=""`, `onError`) sobre o slot neutro; marcador `×` + "FRAME INDISPONÍVEL" no lugar do chip de tipo no estado de vídeo sem frame; estado de **áudio** intacto; branch `playing` inalterado (`<video preload="none">` sem `poster`, e o `<img>` não é renderizado durante a reprodução).
- **`src/collections/`**: **intocado** — sem campo novo, sem migration, sem `pnpm generate:types`.
- **Access / Consent:** nada novo. A leitura pública do frame é o gate já existente do slug publicado; a escrita da linha derivada é `overrideAccess: true` com o comentário justificando (mesmo padrão de `speechPosterJob.ts`/`contentPieceJob.ts`) — o job roda sem ator e a rota só age sobre peça publicada; `ContentMedia` continua `canReadContentPiece`. **Sem Consent** (não há PII de eleitor: o frame é um derivado de material de campanha).
- **Changelog:** `docs/changelog/2026-09-25-c226.md` (entrada curta; nunca editar o agregado nem o HISTORY).
### Dados → forma (se aplicável)
N/A — a intenção já declarou "não" (nenhum número, série, ranking ou métrica) e o plano mantém isso: o frame é **imagem**, o slot neutro é o estado honesto, e o catálogo não ganha contador, barra de progresso nem rótulo de "frame indisponível" por peça. Nenhuma forma nova é introduzida; a única "forma" que muda é a camada de imagem sobre um slot que já existe.
## Fases verificáveis
1. **Tracer / server — quota ~40%:** `lib/contentPieceFrame.ts` puro (unit verde) + `contentPieceFrameJob.ts` + rota `/frame`. **Tracer bullet cedo (int, ffmpeg real quando disponível):** peça `publicado` de vídeo com arquivo real → `GET /conteudos/<slug>/frame` responde `200 image/jpeg` na primeira chamada, uma **segunda** chamada não gera de novo; a mesma peça `rascunho` responde 404 nos dois casos. Só então a UI.
2. **Job + UI — quota ~40%:** chamada isolada no `contentPieceJob` (frame que falha ⇒ peça continua `pronto`, sem `error`); `framePath` na projeção e no `Pick` da home; `<img>` + marcador em `ContentPieceMedia` conforme CENA 01/02/04.
3. **Gates — quota ~20%:** `pnpm gate:fast` (lint + typecheck + unit), `pnpm test:int` (suíte cheia), e2e curado `frontendConteudos` (a família já é mapeada no manifesto), `pnpm push` via GitHub.
### Testes previstos
- **`tests/unit/contentPieceFrame.unit.spec.ts`** (novo): nome determinístico e unicidade por id; `contentPieceFramePath(slug)` = `/conteudos/<slug>/frame`; args do ffmpeg (ordem de `-ss` antes de `-i`, `-frames:v 1`, `scale=640:-2`, `-q:v 3`, `-an`, `-nostdin`, array e nunca string de shell); seek de 1 s; `contentPieceFrameAlt` com título; cap e budget declarados.
- **`tests/unit/contentPieceCatalog.unit.spec.ts`** (estende `:463-554`): `framePath` preenchido para vídeo com arquivo; `null` para áudio, foto, texto, `card`, peça-link e peça sem arquivo; `null` para MIME desconhecido com tipo editorial não-vídeo; a peça-link nunca ganha frame.
- **`tests/unit/contentPieceHomeSelection.unit.spec.ts`** (estende `:83-120`): o `Pick` da home carrega `framePath` e `toContentPieceHomeItem` copia o valor (o compilador já exige o campo; o teste fixa a cópia).
- **`tests/unit/contentPieceMedia.unit.spec.tsx`** (novo, no padrão de `contentPieceHomeBoard.unit.spec.tsx:216-241`): com frame e `playing=false` → um `<img src="/conteudos/<slug>/frame">` e **zero** `<video>`/`<audio>`; sem frame → nenhum `<img>` e o marcador presente; `onError` → marcador presente, `<img>` removido e **nenhum** novo `src` (sem retry); `playing=true` → só `<video preload="none" src=…/midia>` e nenhum `<img>`; áudio nunca recebe frame.
- **`tests/int/contentPiece.int.spec.ts`** (estende, padrão C182 com `FFMPEG_PATH` apontando para `tests/fixtures/fake-ffmpeg.mjs`): gera uma vez e reaproveita a linha pelo nome; duas chamadas concorrentes = **uma** geração (single-flight); falha memoizada (a segunda chamada não roda ffmpeg); `stat` sem arquivo (seek além do fim) = falha e **nada criado**; peça sem arquivo ou não-vídeo = `null` sem invocar ffmpeg; fonte acima do cap = `null` sem download; `it.skipIf(!hasFfmpeg)` extrai um JPEG real a 1 s com `mimeType: image/jpeg`; **o job com ffmpeg quebrado deixa a peça `pronto`/`error: null`** (o pin da falha isolada).
- **e2e (`tests/e2e/frontendConteudos.e2e.spec.ts`, família `frontendConteudos`):** no teste "keeps the media lazy", o card de vídeo **pede** `/frame` e **não** pede `/midia` antes do play (o helper `mediaRequests` casa `/\/conteudos\/.+\/midia/`, então o pin existente continua verde; a nova asserção torna a distinção explícita); a peça-foto **não** pede `/frame`; como o fixture de vídeo é um buffer não decodificável, o 404 do frame leva ao slot neutro com o marcador e **sem imagem quebrada** (nenhum `<img>` no DOM), e o play continua montando o vídeo. Kill switch: com a peça despublicada, `GET /conteudos/<slug>/frame` responde 404.
- **Pins:** `scripts/lib/e2e-affected-manifest.mjs` **ganhou duas entradas** em `frontendConteudos` (`:170-176`): `src/lib/contentPieceFrame` e `src/utilities/content/contentPieceFrameJob.ts`. A primeira versão deste plano dizia "sem mudança" e estava errada — o prefixo amplo `src/lib/contentPiece` da família da **campanha** (`:507`) casa `src/lib/contentPieceFrame.ts` e a mandaria para a família errada, e o job novo não casava com nada. Sem as duas entradas, um diff só do módulo puro cai em seleção vazia. Nenhum spec novo de e2e (a família existente cobre o benefício real).
### Divergências da execução (o texto acima foi corrigido pelo código)
Decisões que a execução tomou **contra** a redação original deste plano, com o motivo. O texto acima está corrigido; estas linhas são o porquê de ter mudado.
- **Estado do frame no host, não no slot** (`ContentPieceMedia.tsx`): `frameUnavailable` vive no componente pai, dono do `playing`. Com estado local da camada da imagem, o ciclo play → `onEnded` remontava a camada e **re-pedia** o frame — quebrando a garantia "sem retry" que o plano comprou.
- **Upload do derivado por buffer, não por `filePath`**: com `filePath` o Payload **não deriva MIME** e a linha saía `mimeType: null` → `application/octet-stream` → o `privateMediaContentType` forçava **attachment**, e o frame chegaria ao card como download em vez de imagem. O pin está no int (`mimeType: image/jpeg` + `Content-Type` na rota).
- **`ensureContentPieceFrame` nunca lança, de verdade**: o probe do cache (a primeira `find`) ficou dentro do guard. Fora dele, uma indisponibilidade do DB respondia **500** numa rota pública anônima em vez do slot neutro.
- **O job C211 passa o `inputPath` que já tem em disco** (`transcribeContentPieceMedia` agora o devolve). A premissa do D2 ("o arquivo já está em disco, um `-ss` sem download") era falsa para toda peça de upload: `source.localPath` é `null` e o frame job rebaixava o vídeo. O cap de tamanho é do **custo de I/O da cura**, então a isenção do caminho do job finalmente tem lastro (e o int pina os dois lados).
- **Fake-ffmpeg sem gêmeo**: a falha só do still virou env `FAKE_FFMPEG_FAIL_STILL=1` no fixture dono (`-frames:v 1`); o pin "ffmpeg nunca é spawnado para peça sem arquivo" usa `FAKE_FFMPEG_LOG`.
- **Nomes expostos**: `contentPieceFrameMediaAlt`, `contentPieceFrameSizeAllowed` (é teto de **tamanho**, não "budget" de tempo — o outro budget é `CONTENT_PIECE_FRAME_WAIT_MS`), `CONTENT_PIECE_FRAME_SEEK_ATTEMPTS` (a escada `[1, 0]` _é_ a decisão D4, então o offset mora na constante). `contentPieceFramePath` ficou no **catálogo**, dono do vocabulário de URL, ao lado de `contentPieceMediaPath`.
- **Guard de e2e**: `expectedRequestFailurePaths` agora aceita `RegExp` (o path do frame carrega o slug) e os paths declarados são conferidos **antes** do caso especial do favicon — todo 404 do browser traz **exatamente** a mesma mensagem do favicon, então a ordem antiga engolia o 404 que o spec declarava.
### Passagem de design (trigger c — crítica contra o app renderizado)
`designer` (tier primário `openai/gpt-5.6-sol`)\_renderizou o app em 1280 e 390 e confrontou com o artefato. Veredito: **cenas 03/04/05 boas**, 01/02 **parciais**. O que saiu disso:
- **CENA 05 (estado novo) → artefato estendido pelo próprio `designer`**: o thumb de 124px da home não era coberto e o marcador quebrava, era cortado e disputava o play. O `designer` acrescentou a cena ao artefato e decidiu a prescrição: **abaixo de `sm`, o thumb da home mostra só superfície neutra + play de 44px + duração compacta, sem marcador**; de `sm` para cima (o card vira bloco cheio) a marcação completa volta. Implementado com um sinal explícito — `variant="home-thumb"` em `ContentPieceHomeCard` — em vez de o componente adivinhar o breakpoint.
- **Ajustes de paridade dentro do escopo do frame, aplicados:** marcador com glifo de 28px na página da peça (`detail`) e 24px no catálogo, exatamente como a cena 04 desenha.
- **Dois bugs de renderização que só o browser real mostrou** (nenhum dos dois apareceria nos testes de DOM):
1. **Ícone de imagem quebrada.** Um `<img>` que falha pinta o glyph do browser — a intenção proíbe imagem quebrada. Agora a imagem só fica visível **depois** de decodificar (`opacity-0` → `opacity-100`), e o estado neutro é a camada base.
2. **O opacidade sozinha escondeu o frame para sempre.** Uma imagem que carrega **antes** da hidratação não dispara `load` **nem** `error` de novo — exatamente o caminho mais rápido (cache quente, SSR). O `ref` do `<img>` é quem decide os dois desfechos (`complete && naturalWidth > 0` → pronta; `complete && naturalWidth === 0` → falha). Sem ele, o frame sumia e o marcador nunca aparecia: os dois estados quebrados eram os mais rápidos.
- **Ajustes de paridade fora do escopo, registrados como Issue #1358 (C228):** play de 68px no slot cheio mobile, sombra aprovada, acentos radiais da superfície neutra, `font-bold` na duração e o badge "Vídeo · em reprodução". São cromo **pré-existente do S27** que muda todos os cards (foto, texto, áudio, link) — um PR de frame não carrega uma mudança visual alheia.
### Débitos empurrados (com gatilho)
- **Teto agregado da cura** — a rota `/frame` é anônima e só tem limite _por forma_ (concorrência 2, cap por arquivo, memo 5 min), não _por volume_. Issue própria: **C227**. **Gatilho:** primeiro incidente real de banda/CPU no homeserver por tráfego anônimo no `/frame`.
- **Escopo do delete no `afterAll` do int** — hoje só este spec int gera linhas de frame, então apagar `content-piece-frame-%` é seguro; com um **segundo** spec int que chame `ensureContentPieceFrame`, o delete passa a ser por id criado (o `payload` é o mesmo `teqo_test` dos 8 forks).
- **Escopo da declaração do guard de e2e** — hoje em escopo de arquivo nos dois specs públicos, porque qualquer spec que renderize um card de vídeo dispara o 404. **Gatilho:** um **terceiro** spec precisar da declaração → mover para o `describe` do card de vídeo.
## Rabbit holes / Não escopo (engenharia)
- **Reuso/generalização do C182** (um `derivedImageJob` abstrato para acervo + Central) — proibido pela intenção e pela semântica: coleções, gates e contratos de leitura são diferentes. Aqui se **espelha** o molde (puro em `lib/`, I/O em `utilities/`) sem tocar `speechPoster*.ts` nem a rota do acervo.
- **Backfill/reconciliador/script de cura em massa, fila nova, cron, worker** — anti-goal explícito; a cura é uma linha por peça, disparada por quem já está vendo a peça.
- **Campo novo / migration / `next/image` com optimizer** — o derivado mora em `contentMedia` com nome determinístico (D1) e o `<img>` cru segue o padrão da foto (`ContentPieceMedia.tsx:114-121`), sem passar pelo optimizer por request.
- **Miniatura de plataforma como frame, peça-link com frame, áudio/foto/texto/card com frame** — fora; `isLink` nunca tem arquivo e a elegibilidade é `contentPieceMediaKind === 'video'`.
- **Analytics do frame, contadores, "X frames pendentes" no admin, botão "gerar frame" na ficha, segunda visualização na lista interna** — fora. **Gatilho:** a assessoria pedir revisão do frame na ficha interna → item curto + designer.
- **Trocar/substituir o arquivo de uma peça `pronto`** (o attach rejeita `media` existente, `contentPieceUpload.ts:249-253`) — não existe fluxo de substituição hoje; se existir, o frame tem de ser invalidado junto, porque hoje ele é derivado do arquivo. **Gatilho:** item de substituição de vídeo → decidir entre apagar a linha `content-piece-frame-<id>.jpg` na troca ou versionar o nome.
- **Hiperprecisão do still** (frame em 1080p, WebP, dois tamanhos para srcset) — não; 640 px JPEG cobre o slot e o `object-cover` faz o resto. **Gatilho:** reclamação de imagem borrada em tela grande → aí sim `srcset`.
- **Mudar o player, autoplay, hover-play, preview em movimento, editor de timestamp, peça substituta de vídeo, qualquer coisa no acervo de falas** — fora.
## Riscos e mitigação
- **Custo da cura no acervo anterior:** a primeira visita a uma peça sem frame paga o download do vídeo privado (até o cap de 256 MB) para extrair um still. Mitigação: só o que a pessoa **vê** cura (`loading="lazy"`, gate de concorrência 2, single-flight), budget de 5 s, a geração segue em `after`, e falha memoizada por 5 min. O job cobre toda peça nova, então essa janela é única e restrita ao acervo anterior.
- **ffmpeg em rota pública:** a rota só age sobre peça **publicada** (gate do slug) e o trabalho é um still do próprio material público; nunca lança, nunca escreve na peça, nunca devolve erro ao visitante (404 idêntico ao `/midia`). Nenhum detrito: `tempDir` removido no `finally` (precedente `contentPieceJob.ts:308-310`).
- **Frame preto/ilegível:** um JPEG válido e preto **não** dispara `onError`. Mitigação: offset de 1 s (D4) e escala 640 (frame minúsculo também não reconheceria). Nenhuma dessas duas garantias é prometida ao produto — o frame é pista visual, não momento editorial.
- **Kill switch:** garantido por construção (derivado em `contentMedia`, porta única com o gate do slug publicado, `no-store`); o nome determinístico da linha nunca é exposto fora da rota — a projeção publica só o path `/conteudos/<slug>/frame`.
- **Peça sem frame na primeira visita:** o slot neutro é a CENA 04, desenhado; a cura é automática e a próxima visita já mostra o frame. Registrado como limite honesto do mecanismo, não como defeito — e o job evita essa janela para toda peça nova.
- **Regressão no contrato S27:** o pin "nenhum request de mídia antes do play" continua verificável porque o frame mora em `/frame` e o regex do helper casa só `/midia`; o novo pin torna a distinção explícita para o futuro (alguém pode "otimizar" movendo o frame para `/midia` e o e2e falha).
- **`ContentPieceMedia` é compartilhado:** o campo novo é `string | null` (sem shape), a propagação para a home é compiler-enforced pelo `Pick`, e o estado do player continua atrás de `playing` — nenhuma superfície muda de comportamento fora do slot.
- **CI:** diff mapeado para `frontendConteudos` (nunca zero); rodar `pnpm gate:fast` e `pnpm test:int` local antes do push. O int que exige ffmpeg real fica `skipIf` (mesmo pin do C182) — o fake-ffmpeg cobre o resto.
## Aceite de engenharia
- [ ] Aceite de produto da intenção ainda coberto: frame estático antes do play para toda peça de vídeo publicada com arquivo (job para toda peça nova, cura por visita para o acervo anterior, slot neutro desenhado quando não há frame); nenhum request do arquivo de vídeo antes do gesto e `preload="none"` preservado; sem imagem quebrada e sem retry sem limite; peça-link/áudio/foto/texto/card intocados; kill switch intacto; lista interna sem player novo nem segunda visualização; C182 não reutilizado nem alterado; sem frame de terceiro.
- [ ] Invariantes AGENTS/engineering-standards: sem migration (nada a editar retroativamente) e sem `generate:types`; escrita nova só com `overrideAccess: true` comentado (o job não tem ator; a rota só age sobre peça publicada) e sem `user` no Local API; escrita da linha derivada em `withPayloadTransaction` + advisory lock, sem write em `contentPiece`; `lib/` puro sem `server-only` e `utilities/` marcado; `lib/` não importa `utilities/`; sem `Consent` novo; sem helper de access novo; identificadores em inglês e copy pt-BR (o literal "FRAME INDISPONÍVEL" é o do design); nenhum módulo novo top-level em `src/utilities/` (o job nasce dentro de `utilities/content/`).
- [ ] Testes de domínio previstos (unit/int) onde o write path e a rota mudam; e2e do frame (pede `/frame`, não pede `/midia`, slot neutro sem `<img>`, 404 com a peça despublicada); manifest de e2e **atualizado** com `src/lib/contentPieceFrame` + `contentPieceFrameJob.ts` (o prefixo amplo da família da campanha os engolia).
- [ ] `pnpm gate:fast` verde; `pnpm test:int` verde; e2e curado `frontendConteudos` verde; `pnpm push` via GitHub; changelog curto em `docs/changelog/2026-09-25-c226.md`.
## Self-score (decision-quality) 4,5/5
1. **Decisões caras com rejeitadas (5/5):** onde o frame vive e por onde sai respeitando o kill switch (D1), onde gerar e o que significa invalidar (D2), como o frame chega ao card (D3), qual frame extrair (D4), em quais superfícies ele aparece (D5) e com que cache/privacidade ele sai (D6) — todas em `Opções A|B|C / Recomendação / Rejeitadas`, com o porquê do C182 e do contrato S27 explicitado. As caras de verdade — storage, kill switch, superfície, cache — não foram decididas em silêncio.
2. **Cabe no appetite herdado (4,5/5):** zero migration, zero collection nova, zero fila, zero backfill; 3 arquivos novos (um puro, um `server-only`, uma rota) e 4 tocados, com o ffmpeg, o proxy de mídia privada, o gate público, a linha privada e o slot de mídia reaproveitados. O custo é o job de extração + a UI fina + os testes. Não é 5/5 porque a cura sob demanda adiciona uma superfície (o budget da rota) que o item não exigia e que a execução precisa medir de verdade.
3. **Rabbit holes nomeados (5/5):** generalizar o C182, backfill/fila/cron, campo novo + migration, `next/image`, miniatura de terceiro, analytics/contadores, botão na ficha interna, substituição de vídeo (com o frame órfão como dívida declarada), hiperprecisão do still, player/autoplay/hover-play.
4. **Depth check (5/5):** reusados `contentPieceMediaKind` (o predicado de elegibilidade já existia — não criei gêmeo), `runFfmpeg` (dono único do ffmpeg), `downloadPrivateMediaToFile`/`resolvePrivateMediaStaticDir`/`buildPrivateMediaResponse` (dono único de mídia privada), `getPublishedContentPieceBySlug` (o gate já existia), `withPayloadTransaction` + `acquireTextAdvisoryLocks`, o `Pick` explícito da home e o pin de e2e da família. O único módulo novo é o puro de regras do frame; o único trabalho novo é a geração, que é o que o item pede.
5. **Intenção preservada (5/5):** o outcome é o mesmo ("reconhecer o vídeo antes do play"); a única tensão deliberada com a intenção é a **divergência de D4** (offset determinístico de 1 s em vez do primeiro frame), que não promete momento algum — ela existe para o frame não se confundir com o estado "sem frame" que o próprio design desenhou, e está registrada como tal, com a opção A mantida como retentativa. As três questões em aberto da intenção entram como **assumidas** e foram validadas no gate.

View file

@ -576,6 +576,203 @@
</div>
</div>
</section>
<section aria-labelledby="home-thumb-title">
<div class="scene-heading mb-3 flex items-center gap-3 text-xs font-bold text-[#6c615e]">
<span>CENA 05</span>
<h2 id="home-thumb-title">Home 390 + 1280 · o fallback se adapta ao thumb estreito</h2>
</div>
<div class="overflow-x-auto pb-5">
<div class="desktop artboard mx-auto overflow-hidden rounded-xl bg-white">
<div class="border-b border-black/10 bg-[#fff8f2] px-10 py-4">
<p class="text-[10px] font-black tracking-[0.12em] text-[#a21c1c]">DESKTOP · 1280</p>
<p class="mt-1 text-sm font-bold">
Na grade da home, o slot volta a ter largura suficiente para a marcação completa.
</p>
</div>
<div class="relative overflow-hidden px-10 py-10">
<span
class="absolute -top-24 -right-10 size-64 rounded-full bg-[#ffeb00]/35"
aria-hidden="true"
></span>
<div class="relative mx-auto max-w-3xl text-center">
<p class="text-xs font-black tracking-[0.1em] text-[#a21c1c]">
CENTRAL DE CONTEÚDOS
</p>
<h3 class="display mt-2 text-3xl font-black">Peça voto pra Solla 1313</h3>
<p class="mt-2 text-sm text-[#6c615e]">
Veja as peças mais recentes e escolha uma para compartilhar.
</p>
<span class="tag mt-3">Seleção recente</span>
<div class="mt-8 grid grid-cols-2 gap-6 text-left">
<article class="card">
<div class="generic-frame relative aspect-video">
<button
class="play-button absolute inset-0 z-10 m-auto"
aria-label="Reproduzir Título da peça com frame"
>
<span class="play-triangle" aria-hidden="true"></span>
</button>
<span
class="absolute right-3 bottom-3 z-10 rounded bg-black/70 px-2 py-1 text-xs font-bold text-white"
>Duração</span
>
</div>
<div class="p-4">
<div class="flex flex-wrap gap-1">
<span class="tag">Vídeo</span><span class="tag">Mais recente</span>
</div>
<h4 class="mt-2 text-base font-extrabold">Título da peça com frame</h4>
<a class="mt-3 inline-block text-xs font-bold text-[#a21c1c]" href="#"
>Ver esta peça →</a
>
</div>
</article>
<article class="card">
<div class="neutral-slot relative grid aspect-video place-items-center">
<div class="absolute top-3 left-3 flex items-center gap-2">
<span
class="grid size-6 place-items-center rounded-full border border-black/10 bg-white text-[11px] font-bold text-[#6c615e]"
aria-hidden="true"
>×</span
>
<span class="text-[10px] font-black tracking-[0.1em] text-[#6c615e]"
>FRAME INDISPONÍVEL</span
>
</div>
<button class="play-button" aria-label="Reproduzir Título da peça sem frame">
<span class="play-triangle" aria-hidden="true"></span>
</button>
<span
class="absolute right-3 bottom-3 rounded bg-black/70 px-2 py-1 text-xs font-bold text-white"
>Duração</span
>
</div>
<div class="p-4">
<div class="flex flex-wrap gap-1">
<span class="tag">Vídeo</span><span class="tag">Mais recente</span>
</div>
<h4 class="mt-2 text-base font-extrabold">Título da peça sem frame</h4>
<a class="mt-3 inline-block text-xs font-bold text-[#a21c1c]" href="#"
>Ver esta peça →</a
>
</div>
</article>
</div>
</div>
</div>
</div>
</div>
<div class="mt-8 grid gap-8 lg:grid-cols-[390px_1fr] lg:items-start lg:justify-center">
<div class="phone artboard mx-auto overflow-hidden bg-white lg:mx-0">
<div class="border-b border-black/10 bg-[#fff8f2] px-4 py-4">
<p class="text-[10px] font-black tracking-[0.12em] text-[#a21c1c]">
MOBILE · 390 · THUMB 124PX
</p>
<p class="mt-1 text-sm font-bold">Frame e fallback continuam legíveis lado a lado.</p>
</div>
<div class="relative px-5 py-7">
<span
class="absolute -top-12 -right-12 size-36 rounded-full bg-[#ffeb00]/35"
aria-hidden="true"
></span>
<div class="relative text-center">
<p class="text-[11px] font-black tracking-[0.1em] text-[#a21c1c]">
CENTRAL DE CONTEÚDOS
</p>
<h3 class="display mt-2 text-2xl font-black leading-7">
Peça voto pra<br />Solla 1313
</h3>
<span class="tag mt-3">Seleção recente</span>
</div>
<div class="relative mt-6 space-y-4">
<article class="card grid grid-cols-[124px_1fr]">
<div class="generic-frame relative aspect-video self-start">
<button
class="absolute inset-0 z-10 m-auto grid size-11 place-items-center rounded-full bg-[#ffe607] text-black shadow-[0_4px_0_#cfb900,0_7px_14px_rgb(0_0_0/18%)]"
aria-label="Reproduzir Título da peça com frame"
>
<span
class="h-0 w-0 translate-x-px border-y-[7px] border-y-transparent border-l-[11px] border-l-current"
aria-hidden="true"
></span>
</button>
<span
class="absolute right-1.5 bottom-1.5 z-10 rounded bg-black/70 px-1.5 py-0.5 text-[10px] font-bold text-white"
>2:14</span
>
</div>
<div class="min-w-0 p-4">
<div class="flex flex-wrap gap-1">
<span class="tag">Vídeo</span><span class="tag">Mais recente</span>
</div>
<h4 class="mt-2 text-sm font-extrabold leading-5">Peça com frame</h4>
<a class="mt-3 inline-block text-xs font-bold text-[#a21c1c]" href="#"
>Ver esta peça →</a
>
</div>
</article>
<article class="card grid grid-cols-[124px_1fr]">
<div
class="neutral-slot relative grid aspect-video place-items-center self-start"
>
<button
class="relative z-10 grid size-11 place-items-center rounded-full bg-[#ffe607] text-black shadow-[0_4px_0_#cfb900,0_7px_14px_rgb(0_0_0/18%)]"
aria-label="Reproduzir Título da peça sem frame"
>
<span
class="h-0 w-0 translate-x-px border-y-[7px] border-y-transparent border-l-[11px] border-l-current"
aria-hidden="true"
></span>
</button>
<span
class="absolute right-1.5 bottom-1.5 z-10 rounded bg-black/70 px-1.5 py-0.5 text-[10px] font-bold text-white"
>2:14</span
>
</div>
<div class="min-w-0 p-4">
<div class="flex flex-wrap gap-1">
<span class="tag">Vídeo</span><span class="tag">Mais recente</span>
</div>
<h4 class="mt-2 text-sm font-extrabold leading-5">Peça sem frame</h4>
<a class="mt-3 inline-block text-xs font-bold text-[#a21c1c]" href="#"
>Ver esta peça →</a
>
</div>
</article>
</div>
</div>
</div>
<aside class="artboard max-w-xl rounded-xl bg-white p-6 lg:mt-14">
<p class="text-xs font-black tracking-[0.12em] text-[#184e92]">
PRESCRIÇÃO DO VARIANT COMPACTO
</p>
<h3 class="display mt-2 text-2xl font-black">
No thumb de 124px, o neutro já é a marca.
</h3>
<p class="mt-3 text-sm leading-6 text-[#514945]">
Abaixo de <b>sm</b>, não renderizar “Frame indisponível” nem o ícone ×. Manter apenas
a superfície neutra, o play e a duração: a semântica continua no estado visual sem
disputar a ação principal.
</p>
<div class="mt-5 rounded-xl border border-[#184e92]/15 bg-[#f9faff] p-4">
<p class="text-xs font-black tracking-[0.1em] text-[#184e92]">TAMANHOS A PORTAR</p>
<p class="mt-1 text-sm leading-6 text-[#514945]">
Variant compacto: play <b>44px</b>; duração <b>text-[10px]</b>,
<b>right-1.5 bottom-1.5</b>, <b>px-1.5 py-0.5</b>. Em <b>sm+</b>, conservar o
marcador completo e os tamanhos das cenas 01–04.
</p>
</div>
</aside>
</div>
</section>
</main>
</body>
</html>

View file

@ -170,6 +170,11 @@ export const E2E_AFFECTED_MANIFEST = [
'src/app/(frontend)/api/content-events',
'src/components/conteudos',
'src/lib/contentPieceCatalog',
// C226 — the still of a video piece: the public route, the generation job
// and these pure rules. (`src/lib/contentPiece` below is a campaign prefix
// that also matches this file, so the public entry names it explicitly.)
'src/lib/contentPieceFrame',
'src/utilities/content/contentPieceFrameJob.ts',
'src/lib/contentPieceHomeSelection.ts',
'src/lib/contentPieceShare',
// S37 — the catalog behind the public "Lideranças" names.

View file

@ -0,0 +1,98 @@
import config from '@payload-config'
import { after, NextResponse } from 'next/server'
import { getPayload } from 'payload'
import { CONTENT_MEDIA_SLUG } from '@/lib/contentPiece'
import { CONTENT_PIECE_FRAME_WAIT_MS } from '@/lib/contentPieceFrame'
import { PRIVATE_MEDIA_CACHE_CONTROL } from '@/lib/privateMedia'
import {
ensureContentPieceFrame,
findContentPieceFrameMedia,
} from '@/utilities/content/contentPieceFrameJob'
import { getPublishedContentPieceBySlug } from '@/utilities/content/contentPieceReads'
import {
buildPrivateMediaResponse,
resolvePrivateMediaStaticDir,
} from '@/utilities/privateMedia/privateMediaResponse'
export const dynamic = 'force-dynamic'
/**
* C226 — the still of a published video piece. Sibling of `/midia` with the very
* same gate: the cached public read (published + archived file), so a draft, an
* unpublished piece, an unknown slug, a link piece and a non-video piece all
* answer the same silent 404. The frame therefore stops being visible exactly
* when the video does — the kill switch survives by construction, because the
* derived file lives in the PRIVATE `contentMedia`, never in the public `media`.
*
* On a miss the still is generated and awaited for a short budget; the work
* keeps running after the response, so the archive published before this slice
* heals one piece per view without any backfill. Nothing here records a
* circulation event: seeing a frame is not opening or downloading the piece.
*/
const notFound = (): NextResponse =>
new NextResponse(null, {
status: 404,
headers: { 'Cache-Control': PRIVATE_MEDIA_CACHE_CONTROL },
})
/**
* Resolves the promise, or null when the budget runs out first. A deliberate
* twin of the C182 poster route's helper: the acervo route is behind the
* campaign gate and must not be edited by this slice, and 15 lines of promise
* racing is not worth a shared module for two call sites. Revisit if a third
* door ever needs the same race.
*/
const settledWithin = async <Value>(
promise: Promise<Value>,
budgetMs: number,
): Promise<Value | null> => {
let timer: ReturnType<typeof setTimeout> | undefined
try {
return await Promise.race([
promise,
new Promise<null>((resolve) => {
timer = setTimeout(() => resolve(null), budgetMs)
}),
])
} finally {
if (timer) clearTimeout(timer)
}
}
export const GET = async (
_request: Request,
{ params }: { params: Promise<{ slug: string }> },
): Promise<Response> => {
const { slug } = await params
const piece = await getPublishedContentPieceBySlug(slug)
// `framePath` is the eligibility decision (a video with an archived file),
// so a photo, an audio or a link piece never reaches the generation.
if (!piece?.framePath) return notFound()
const payload = await getPayload({ config })
const generation = ensureContentPieceFrame(payload, piece.id)
// The response below may answer the neutral slot before the still is ready;
// `after` keeps the process alive until the generation settles (never a 524).
after(async () => {
await generation
})
const generated = await settledWithin(generation, CONTENT_PIECE_FRAME_WAIT_MS)
// The probe after the wait closes the race with a generation that committed
// its row while this request was waiting (the `Promise.race` resolves the
// moment the budget is out, not the moment the row lands).
const media = generated ?? (await findContentPieceFrameMedia(payload, piece.id).catch(() => null))
if (!media?.filename) return notFound()
// Intentional admin bypass: the public gate is the cached published read
// above; the media row only supplies the stored object to stream.
const response = await buildPrivateMediaResponse({
media,
staticDir: resolvePrivateMediaStaticDir(payload, CONTENT_MEDIA_SLUG),
rangeHeader: null,
download: false,
})
return response.status === 200 ? response : notFound()
}

View file

@ -47,6 +47,11 @@ export const ContentPieceHomeCard = ({
playing={playing}
onToggle={onToggle}
onEnded={onEnded}
// C226 — below `sm` this card's media slot is the 124px thumb of the
// design's cena 05 (the compact play/duration, no marker); from `sm` up
// the card becomes a full block and the media slot goes back to the
// catalogue treatment.
variant="home-thumb"
className="aspect-video self-start"
/>
<div className="p-4">

View file

@ -1,5 +1,6 @@
import { contentPieceMediaKind, type ContentPiecePublicItem } from '@/lib/contentPieceCatalog'
import { cn } from '@/lib/utils'
import { useCallback, useState } from 'react'
/**
* The media states read this subset of the public item, so the S39 home
@ -9,6 +10,7 @@ type ContentPieceMediaItem = Pick<
ContentPiecePublicItem,
| 'isLink'
| 'file'
| 'framePath'
| 'type'
| 'origin'
| 'originLabel'
@ -32,24 +34,79 @@ const PlayIcon = () => (
</svg>
)
const PlayButton = ({ label, onToggle }: { label: string; onToggle: () => void }) => (
const PlayButton = ({
label,
onToggle,
compact = false,
}: {
label: string
onToggle: () => void
/** C226 — the 124px home thumb (artefato cena 05) has room for a 44px play only. */
compact?: boolean
}) => (
<button
type="button"
onClick={onToggle}
aria-label={label}
className="absolute grid size-14 place-items-center rounded-full bg-(--pt-yellow) text-(--pt-yellow-ink) shadow-[0_6px_0_#cfb900,0_10px_22px_rgb(0_0_0/15%)] transition-[transform,box-shadow] duration-150 ease-out hover:-translate-y-0.5 active:translate-y-[3px] active:shadow-[0_3px_0_#cfb900] motion-reduce:transition-none sm:size-16"
className={cn(
'absolute grid place-items-center rounded-full bg-(--pt-yellow) text-(--pt-yellow-ink) shadow-[0_6px_0_#cfb900,0_10px_22px_rgb(0_0_0/15%)] transition-[transform,box-shadow] duration-150 ease-out hover:-translate-y-0.5 active:translate-y-[3px] active:shadow-[0_3px_0_#cfb900] motion-reduce:transition-none',
compact ? 'size-11 sm:size-16' : 'size-14 sm:size-16',
)}
>
<PlayIcon />
</button>
)
const DurationBadge = ({ label }: { label: string | null }) =>
const DurationBadge = ({ label, compact = false }: { label: string | null; compact?: boolean }) =>
label ? (
<span className="absolute right-3 bottom-3 rounded bg-black/70 px-2 py-1 text-xs text-white">
<span
className={cn(
'absolute rounded bg-black/70 text-white',
compact
? 'right-1.5 bottom-1.5 px-1.5 py-0.5 text-[10px] sm:right-3 sm:bottom-3 sm:px-2 sm:py-1 sm:text-xs'
: 'right-3 bottom-3 px-2 py-1 text-xs',
)}
>
{label}
</span>
) : null
/**
* C226 — the neutral marker of a piece with no still (artefato cena 04). The
* piece type keeps reading on the card body tag, so nothing is lost.
*/
/**
* C226 — the honest marker of a piece with no still (artefato cenas 04/05). The
* `compact` form is the 124px home thumb, where the neutral surface itself is
* the message and the marker would crowd the play; from `sm` up, where that
* card becomes a full block, the complete marker returns (cena 05).
*/
const FrameUnavailableMarker = ({
detail = false,
compact = false,
}: {
detail?: boolean
compact?: boolean
}) => (
<span
className={cn(
'absolute top-3 left-3 z-10 items-center gap-2 text-[10px] font-black tracking-[0.1em] text-(--campaign-muted) uppercase',
compact ? 'hidden sm:flex' : 'flex',
)}
>
<span
aria-hidden="true"
className={cn(
'grid place-items-center rounded-full border border-black/10 bg-white',
detail ? 'size-7' : 'size-6',
)}
>
×
</span>
Frame indisponível
</span>
)
export const ContentPieceMedia = ({
item,
playing,
@ -62,11 +119,24 @@ export const ContentPieceMedia = ({
playing: boolean
onToggle: () => void
onEnded: () => void
variant?: 'card' | 'thumb' | 'detail'
variant?: 'card' | 'thumb' | 'detail' | 'home-thumb'
className?: string
}) => {
const kind = contentPieceMediaKind(item)
const base = cn('relative overflow-hidden bg-(--campaign-band)', className)
// C226 — the still lives in the slot the play state shares, so the knowledge
// that this piece has no frame is held by the host, not by the layer that
// remounts on every play: `failed` is terminal for the card, never retried (a
// broken image would be a lie and the retry would hammer the route).
const [frameState, setFrameState] = useState<'loading' | 'ready' | 'failed'>('loading')
// A still whose load settled BEFORE React attached the handlers (a warm
// cache, an SSR page, a 404) fires NEITHER `load` nor `error` again — the
// ref is the only place that can still tell a decoded image from a failed
// one, and without it the fastest paths are exactly the broken ones.
const settleLoadedFrame = useCallback((node: HTMLImageElement | null) => {
if (!node?.complete) return
setFrameState(node.naturalWidth > 0 ? 'ready' : 'failed')
}, [])
if (kind === null) {
return (
@ -188,11 +258,46 @@ export const ContentPieceMedia = ({
variant === 'detail' && 'rounded-2xl border border-black/10',
)}
>
<span className="absolute top-3 left-3 text-[10px] font-black tracking-[0.14em] text-(--campaign-muted) uppercase">
{item.typeLabel}
</span>
<PlayButton label={`Reproduzir ${item.title}`} onToggle={onToggle} />
<DurationBadge label={item.durationLabel} />
{kind === 'video' && item.framePath && frameState !== 'failed' && (
// eslint-disable-next-line @next/next/no-img-element -- private proxy path with on-demand headers
<img
src={item.framePath}
// Decorative on purpose: the play button already names the slot
// ("Reproduzir <título>") and the title sits right below it.
alt=""
loading="lazy"
decoding="async"
ref={settleLoadedFrame}
onLoad={() => setFrameState('ready')}
onError={() => setFrameState('failed')}
// Invisible until it decodes: a failed image would otherwise paint
// the browser's broken-image glyph over the slot for a moment, and
// the design's honest state is the neutral surface, never a glyph.
className={cn(
'absolute inset-0 h-full w-full object-cover transition-opacity duration-200',
frameState !== 'ready' && 'opacity-0',
)}
/>
)}
{/* A piece whose projection declares no frame, and one whose still the
route could not produce: the same honest slot, no broken image. */}
{kind === 'video' && (!item.framePath || frameState === 'failed') && (
<FrameUnavailableMarker
detail={variant === 'detail'}
compact={variant === 'home-thumb'}
/>
)}
{kind === 'audio' && (
<span className="absolute top-3 left-3 text-[10px] font-black tracking-[0.14em] text-(--campaign-muted) uppercase">
{item.typeLabel}
</span>
)}
<PlayButton
label={`Reproduzir ${item.title}`}
onToggle={onToggle}
compact={variant === 'home-thumb'}
/>
<DurationBadge label={item.durationLabel} compact={variant === 'home-thumb'} />
</div>
)
}

View file

@ -43,6 +43,15 @@ export const contentPiecePublicPath = (slug: string): string =>
export const contentPieceMediaPath = (slug: string): string =>
`${contentPiecePublicPath(slug)}/midia`
/**
* C226 — the still of a video piece, served as an image by the same gate as the
* file (`/conteudos/<slug>/midia` is its sibling). It is a request for a
* lightweight preview, never for the video: the element that would fetch the
* file only mounts after the play gesture.
*/
export const contentPieceFramePath = (slug: string): string =>
`${contentPiecePublicPath(slug)}/frame`
export const CONTENT_PIECE_CATALOG_FACETS = [
'tipo',
'cidade',
@ -384,6 +393,14 @@ export type ContentPiecePublicItem = {
publicPath: string
/** The archived file, or null on a link piece — never partially populated. */
file: ContentPiecePublicFile | null
/**
* C226 — where the still of a video piece is asked for, or null when the
* piece has no archived file to take one from (audio, photo, text, card,
* link piece). Declared by ELIGIBILITY, not by the existence of the derived
* row: the frame heals itself on the first request, so the cached listing
* never depends on a write and no tag has to be busted when one lands.
*/
framePath: string | null
}
type ContentPiecePublicFile = {
@ -702,6 +719,14 @@ export const toContentPiecePublicItem = (
.filter(Boolean)
.join(' · ')
const isLink = media === null && sourceUrl !== null
const file: ContentPiecePublicFile | null = media
? {
id: media.id,
path: contentPieceMediaPath(slug),
mimeType: media.mimeType,
downloadFilename: contentPieceDownloadFilename(slug, media.filename),
}
: null
return {
id: record.id,
@ -728,13 +753,13 @@ export const toContentPiecePublicItem = (
searchText: record.searchText ?? '',
themeMatch: contentPieceThemeMatch(record, themeTerms),
publicPath: contentPiecePublicPath(slug),
file: media
? {
id: media.id,
path: contentPieceMediaPath(slug),
mimeType: media.mimeType,
downloadFilename: contentPieceDownloadFilename(slug, media.filename),
}
: null,
file,
// C226 — only a video with an archived file has a still to take. The same
// `contentPieceMediaKind` that decides how the card renders decides it, so
// the card can never ask for a frame the piece cannot produce.
framePath:
file && contentPieceMediaKind({ isLink, file, type }) === 'video'
? contentPieceFramePath(slug)
: null,
}
}

View file

@ -0,0 +1,108 @@
/**
* C226 — pure rules of the content piece frame: the deterministic
* `contentMedia` cache name, the ffmpeg still command and the two limits of the
* self-heal. No I/O and no `server-only`: the frame job, the public route and
* the unit tests share this module, so the "which second of the video" literal
* lives in one place.
*
* The frame is a derived artifact of the archived file, never a second copy of
* it: it lives in the PRIVATE `contentMedia` collection (the public `media` is
* anonymous-read by contract) and is served only by the published-slug route.
* A piece that is unpublished therefore stops exposing its frame exactly like it
* stops exposing the video — by construction, not by discipline.
*
* C182 (the acervo list frame) is the shape this mirrors — pure rules here, I/O
* in `utilities/` — but the two previews are different domains with different
* collections, gates and reading contracts, so nothing is shared but the mold.
* The public path is not here: the catalogue module owns the Central's URL
* vocabulary (`contentPieceFramePath`, next to `contentPieceMediaPath`).
*/
/**
* The still is taken one second in, not at zero: campaign footage almost always
* opens on a leader/fade to black, and a valid BLACK jpeg is indistinguishable
* from the neutral "frame indisponível" slot the design uses to say "there is
* no frame here" — the first frame would destroy the very signal the fallback
* depends on. The decision stays deterministic (no editorial moment is ever
* promised) and costs exactly the same single `-ss`.
*
* The retry at zero covers what a 1s seek cannot reach: a clip shorter than a
* second, a lying header, a stream with no keyframe there. One retry, no loop —
* after that the piece keeps the neutral slot.
*/
export const CONTENT_PIECE_FRAME_SEEK_ATTEMPTS = [1, 0] as const
/**
* C226 — a frame request may wait this long before answering the neutral slot,
* while the generation keeps running (the same `after` heal as the C182 poster).
* Shorter than the acervo's 20s on purpose: here the heal can mean downloading
* the whole video, and the fallback is a designed state, not an error.
*/
export const CONTENT_PIECE_FRAME_WAIT_MS = 5_000
/**
* Ceiling of the self-heal: the route has to download the archived file to
* extract a still, and a full-length recording is not worth a visitor's
* connection. The job path pays none of this (the file is already on disk), so
* the cap is a rule about the cost of I/O, never about eligibility.
*/
export const CONTENT_PIECE_FRAME_MAX_SOURCE_BYTES = 256 * 1024 * 1024
/** The single ffmpeg pass writes a JPEG — no `sharp` step, no second encoder. */
export const CONTENT_PIECE_FRAME_MIME_TYPE = 'image/jpeg'
/** Deterministic `contentMedia` filename: the cache key and the idempotence key. */
export const contentPieceFrameFilename = (pieceId: number): string =>
`content-piece-frame-${pieceId}.jpg`
/** `contentMedia.alt` is required, so the derived row carries a real description. */
export const contentPieceFrameMediaAlt = (title: string): string => `Frame do vídeo «${title}»`
/**
* The self-heal only touches a source small enough to be worth downloading
* (256 MB is a size, not a time — `CONTENT_PIECE_FRAME_WAIT_MS` is the other
* budget). An unknown size is allowed through: the piece row has been ingested
* already, so the missing number is a projection limit, not a licence to skip.
*/
export const contentPieceFrameSizeAllowed = (filesize?: number | null): boolean =>
typeof filesize !== 'number' || !Number.isFinite(filesize)
? true
: filesize <= CONTENT_PIECE_FRAME_MAX_SOURCE_BYTES
type ContentPieceFrameFfmpegInput = {
inputPath: string
outputPath: string
atSeconds: number
}
/**
* One still at `atSeconds`: `-ss` before `-i` seeks fast and `-frames:v 1` takes
* a single frame; `scale=640:-2` keeps it small (the public piece page renders
* the slot at ~600px, `object-cover` crops the rest). JPEG in a single ffmpeg
* pass — no `sharp` step, and `image/jpeg` is already inline-safe in the private
* media contract. Args as an array — never a shell string.
*/
export const buildContentPieceFrameFfmpegArgs = ({
inputPath,
outputPath,
atSeconds,
}: ContentPieceFrameFfmpegInput): string[] => {
const at = Math.max(0, Math.round(atSeconds))
return [
'-nostdin',
'-hide_banner',
'-y',
'-ss',
String(at),
'-i',
inputPath,
'-frames:v',
'1',
'-vf',
'scale=640:-2',
'-q:v',
'3',
'-an',
outputPath,
]
}

View file

@ -39,6 +39,7 @@ export type ContentPieceHomeItem = Pick<
| 'metaLabel'
| 'publicPath'
| 'file'
| 'framePath'
>
/** Field-by-field on purpose: a new public-item field never leaks by spread. */
@ -58,6 +59,7 @@ export const toContentPieceHomeItem = (item: ContentPiecePublicItem): ContentPie
metaLabel: item.metaLabel,
publicPath: item.publicPath,
file: item.file,
framePath: item.framePath,
})
/**

View file

@ -0,0 +1,283 @@
import 'server-only'
import { mkdtemp, readFile, rm, stat } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import type { Payload } from 'payload'
import { CONTENT_MEDIA_SLUG } from '@/lib/contentPiece'
import {
buildContentPieceFrameFfmpegArgs,
CONTENT_PIECE_FRAME_MIME_TYPE,
CONTENT_PIECE_FRAME_SEEK_ATTEMPTS,
contentPieceFrameFilename,
contentPieceFrameMediaAlt,
contentPieceFrameSizeAllowed,
} from '@/lib/contentPieceFrame'
import type { ContentMedia } from '@/payload-types'
import { messageOf, runFfmpeg } from '@/utilities/media/ffmpeg'
import {
withPayloadTransaction,
type PayloadTransactionRequest,
} from '@/utilities/payloadTransaction'
import { acquireTextAdvisoryLocks } from '@/utilities/postgresTransactionLocks'
import {
downloadPrivateMediaToFile,
resolvePrivateMediaStaticDir,
} from '@/utilities/privateMedia/privateMediaResponse'
/**
* C226 — the still of a content piece video: one JPEG of the archived file,
* stored in the PRIVATE `contentMedia` under a deterministic filename. The
* public card asks for it by piece slug; on a miss this heals the row and the
* route answers it (or the neutral slot when the budget runs out first).
*
* ONE owner, two callers: the C211 job passes the local file it already has (a
* still is then nearly free) and covers every newly ingested piece; the route
* covers the archive published before this slice, which no job will reprocess.
* There is no backfill, no queue and no reaper — "invalidar" does not even
* exist, because the public projection declares the frame by ELIGIBILITY and not
* by the existence of this row.
*
* Never throws and never writes the piece: a broken ffmpeg, a missing file or a
* full bucket must leave the piece `pronto` with a neutral slot, not a failed
* ingestion. The failure is memoized for a short while so one bad video cannot
* be re-extracted on every view.
*/
/** Concurrent generations per process: a catalogue page asks for several at once. */
const MAX_CONCURRENT_GENERATIONS = 2
/** A failure is not retried on every view. */
const FAILURE_TTL_MS = 5 * 60_000
const FAILURE_FALLBACK = 'Falha ao gerar o frame da peça.'
/** The stored frame of a piece, or null when it was never generated. */
export const findContentPieceFrameMedia = async (
payload: Payload,
pieceId: number,
req?: PayloadTransactionRequest,
): Promise<ContentMedia | null> => {
const result = await payload.find({
collection: CONTENT_MEDIA_SLUG,
where: { filename: { equals: contentPieceFrameFilename(pieceId) } },
limit: 1,
depth: 0,
// Intentional admin bypass: the lookup is the cache probe of a derived file.
overrideAccess: true,
...(req ? { req } : {}),
})
return result.docs[0] ?? null
}
// Process-wide state: one Node server owns the cache, so an in-memory slot gate
// and a short failure memo are enough to keep a catalogue page from stampeding
// ffmpeg.
let activeGenerations = 0
const waiting: Array<() => void> = []
const inFlight = new Map<number, Promise<ContentMedia | null>>()
const failures = new Map<number, number>()
const rememberFailure = (pieceId: number): void => {
const now = Date.now()
for (const [id, at] of failures) {
if (now - at >= FAILURE_TTL_MS) failures.delete(id)
}
failures.set(pieceId, now)
}
const acquireGenerationSlot = async (): Promise<() => void> => {
if (activeGenerations >= MAX_CONCURRENT_GENERATIONS) {
await new Promise<void>((resolve) => waiting.push(resolve))
} else {
activeGenerations += 1
}
let released = false
return () => {
if (released) return
released = true
const next = waiting.shift()
if (next) next()
else activeGenerations -= 1
}
}
/**
* One still, with the seek retried once at zero. A seek past the end leaves no
* file — that is a failure, never a broken row.
*/
const extractContentPieceFrame = async ({
inputPath,
outputPath,
durationSeconds,
}: {
inputPath: string
outputPath: string
durationSeconds: number | null
}): Promise<void> => {
let lastError: unknown = null
for (const atSeconds of CONTENT_PIECE_FRAME_SEEK_ATTEMPTS) {
try {
await runFfmpeg(
buildContentPieceFrameFfmpegArgs({ inputPath, outputPath, atSeconds }),
durationSeconds ?? 0,
FAILURE_FALLBACK,
)
await stat(outputPath)
return
} catch (error) {
lastError = error
}
}
throw lastError instanceof Error ? lastError : new Error(FAILURE_FALLBACK)
}
/**
* Everything the extraction needs, or null when the piece cannot have a still:
* a link piece, a row with no stored file, and — on the self-heal path only —
* a source too large to be worth a visitor's connection.
*/
type FrameSource = {
media: { filename: string; filesize?: number | null }
title: string
durationSeconds: number | null
/** The caller's own copy of the file; null makes the job download it. */
localPath: string | null
}
const resolveFrameSource = async (
payload: Payload,
pieceId: number,
localPath: string | null,
): Promise<FrameSource | null> => {
const piece = await payload.findByID({
collection: 'contentPiece',
id: pieceId,
depth: 1,
// Intentional admin bypass: the frame is derived from a piece whose file
// the pipeline (or the published-slug gate) already resolved.
overrideAccess: true,
})
const media = piece.media
// C211: a missing stored file can never yield a still.
if (!media || typeof media === 'number' || !media.filename) return null
// The heal has to download the whole file; the job path (localPath) pays none
// of that and is therefore never capped.
if (!localPath && !contentPieceFrameSizeAllowed(media.filesize)) return null
return {
media: { filename: media.filename, filesize: media.filesize },
title: piece.title,
durationSeconds: piece.durationSeconds ?? null,
localPath,
}
}
const generateContentPieceFrame = async (
payload: Payload,
pieceId: number,
localPath: string | null,
): Promise<ContentMedia | null> => {
const source = await resolveFrameSource(payload, pieceId, localPath)
if (!source) return null
const release = await acquireGenerationSlot()
const tempDir = await mkdtemp(join(tmpdir(), 'content-piece-frame-'))
try {
// The job hands over the file it already downloaded; the route has to copy
// it here first. Either way the still is written inside our own temp dir,
// so the cleanup below never touches the source.
const inputPath = source.localPath ?? join(tempDir, 'source')
if (!source.localPath) {
await downloadPrivateMediaToFile({
media: source.media,
staticDir: resolvePrivateMediaStaticDir(payload, CONTENT_MEDIA_SLUG),
destinationPath: inputPath,
})
}
const outputPath = join(tempDir, contentPieceFrameFilename(pieceId))
await extractContentPieceFrame({
inputPath,
outputPath,
durationSeconds: source.durationSeconds,
})
return await withPayloadTransaction(payload, async ({ req }) => {
await acquireTextAdvisoryLocks(payload, req, [`content-piece-frame:${pieceId}`])
const existing = await findContentPieceFrameMedia(payload, pieceId, req)
if (existing) return existing
// Uploaded from the buffer, not from `filePath`: Payload derives no MIME
// from a local path, and a derived row with no `mimeType` would degrade
// to `application/octet-stream` and force a DOWNLOAD instead of rendering
// as the image the card asked for. A 640px still is a few dozen kB.
const bytes = await readFile(outputPath)
return await payload.create({
collection: CONTENT_MEDIA_SLUG,
data: { alt: contentPieceFrameMediaAlt(source.title) },
file: {
data: bytes,
mimetype: CONTENT_PIECE_FRAME_MIME_TYPE,
name: contentPieceFrameFilename(pieceId),
size: bytes.length,
},
// Intentional admin bypass: the stored file is the piece's cache entry.
overrideAccess: true,
req,
})
})
} finally {
await rm(tempDir, { recursive: true, force: true }).catch(() => undefined)
release()
}
}
/**
* Returns the stored still of a piece video, generating it on the first miss.
* `localPath` is the job's own temp copy of the file (a free extraction); the
* route omits it and the job's file is downloaded to a temp dir instead.
*
* Never throws: a failure degrades to the card's neutral slot and is memoized
* for a short while. Never writes the piece — a frame is a cache entry, not
* part of the ingestion result.
*/
export const ensureContentPieceFrame = async (
payload: Payload,
pieceId: number,
{ localPath = null }: { localPath?: string | null } = {},
): Promise<ContentMedia | null> => {
if (!Number.isInteger(pieceId) || pieceId <= 0) return null
const failedAt = failures.get(pieceId)
if (failedAt !== undefined && Date.now() - failedAt < FAILURE_TTL_MS) return null
// The probe lives inside the same guard as the generation: a database hiccup
// is a missing frame (the card's neutral slot), never a 500 on a public route.
const cached = await findContentPieceFrameMedia(payload, pieceId).catch(() => null)
if (cached) return cached
const running = inFlight.get(pieceId)
if (running) return running
const generation = generateContentPieceFrame(payload, pieceId, localPath)
.then((media) => {
if (!media) rememberFailure(pieceId)
return media
})
.catch((error: unknown) => {
rememberFailure(pieceId)
console.warn(`[content-piece-frame] peça ${pieceId}: ${messageOf(error, FAILURE_FALLBACK)}`)
return null
})
.finally(() => {
inFlight.delete(pieceId)
})
inFlight.set(pieceId, generation)
return generation
}

View file

@ -24,6 +24,7 @@ import {
type TranscribeSegmentsResult,
} from '@/utilities/ai/deepInfraTranscribe'
import { catalogContentPiece } from '@/utilities/content/contentPieceCataloging'
import { ensureContentPieceFrame } from '@/utilities/content/contentPieceFrameJob'
import { resolveContentPieceSource } from '@/utilities/content/contentPieceLink'
import { messageOf, runFfmpeg } from '@/utilities/media/ffmpeg'
import {
@ -110,8 +111,11 @@ const chunkFilesIn = async (tempDir: string): Promise<string[]> => {
/**
* The audio half of the pipeline: extract/split the archived media and
* transcribe every chunk. Returns the transcript and the measured duration, or
* a failure message the caller stores.
* transcribe every chunk. Returns the transcript, the measured duration and the
* local file ffmpeg actually read — or a failure message the caller stores.
*
* C226 hands that `inputPath` on: the still is extracted from the same bytes,
* so the frame never re-downloads a file this pass already has on disk.
*/
const transcribeContentPieceMedia = async ({
payload,
@ -128,7 +132,7 @@ const transcribeContentPieceMedia = async ({
tempDir: string
transcribe: ChunkTranscriber
}): Promise<
| { transcript: string; durationSeconds: number }
| { transcript: string; durationSeconds: number; inputPath: string }
| { failure: { message: string; step: ContentPieceStep } }
> => {
const inputPath = localPath ?? join(tempDir, 'source')
@ -189,6 +193,7 @@ const transcribeContentPieceMedia = async ({
.join(' ')
.trim(),
durationSeconds: Math.round(measured),
inputPath,
}
}
@ -228,6 +233,9 @@ export const runContentPieceJob = async (
const effectiveType = suggestedType ?? piece.type
let transcript = piece.transcript?.trim() ?? ''
let durationSeconds: number | null = null
// The local file ffmpeg read (downloaded here when the piece arrived by
// link), so the C226 still is extracted from the same bytes.
let mediaInputPath: string | null = null
if (!transcript && source.caption) transcript = source.caption.trim()
@ -245,11 +253,26 @@ export const runContentPieceJob = async (
return
}
transcript = result.transcript
mediaInputPath = result.inputPath
// Only what the provider measured: a zero/absent measurement never
// becomes a duration the ficha would present as real.
durationSeconds = result.durationSeconds > 0 ? result.durationSeconds : null
}
// C226 — the still of a video piece, taken here because the file is already
// on disk (a free `-ss`). Deliberately isolated in its own try/catch: a
// missing ffmpeg or an unreadable frame must never turn a `pronto` piece
// into `falhou` — the card's neutral slot is the honest answer.
if (effectiveType === 'video' && source.media) {
try {
await ensureContentPieceFrame(payload, contentPieceId, { localPath: mediaInputPath })
} catch (error) {
console.warn(
`[content-piece-frame] peça ${contentPieceId}: ${messageOf(error, 'Frame indisponível.')}`,
)
}
}
await markStep('catalogando')
const catalogued = await catalog({
payload,

View file

@ -27,8 +27,13 @@ type E2EFailureGuardFixtures = {
* failure in any other spec a failure. It is an option so a spec declares it
* once with `test.use`, instead of pushing at exactly the right moment
* mid-test.
*
* A `RegExp` is accepted for a door whose path carries an id (C226: the still
* of a piece is `/conteudos/<slug>/frame` and answers 404 until the self-heal
* lands, so any spec rendering a video card would trip the guard). A literal
* is still the default, and a pattern still has to name one surface.
*/
expectedRequestFailurePaths: string[]
expectedRequestFailurePaths: (string | RegExp)[]
}
const loadFailurePrefix = 'Failed to load resource'
@ -50,7 +55,7 @@ const consoleDetail = (message: ConsoleMessage): string => {
const isAllowedConsoleError = (
message: ConsoleMessage,
origin: string | null,
expectedRequestFailurePaths: string[],
expectedRequestFailurePaths: (string | RegExp)[],
): boolean => {
const { url } = message.location()
if (!url || origin === null) return false
@ -58,13 +63,22 @@ const isAllowedConsoleError = (
const location = new URL(url)
if (location.origin !== origin) return false
if (message.text().startsWith(loadFailurePrefix)) {
// The declared paths come FIRST: every 404 carries the exact same browser
// message as the missing favicon, so a favicon-shaped early return would
// swallow a refusal the spec declared on purpose.
const declared = expectedRequestFailurePaths.some((expected) =>
expected instanceof RegExp
? expected.test(location.pathname)
: expected === location.pathname,
)
if (declared) return true
}
// The app intentionally has no /favicon.ico asset yet.
if (message.text() === missingFaviconConsoleError) return location.pathname === '/favicon.ico'
return (
message.text().startsWith(loadFailurePrefix) &&
expectedRequestFailurePaths.includes(location.pathname)
)
return false
}
export const test = base.extend<E2EFailureGuardFixtures>({

View file

@ -76,6 +76,13 @@ const expectCardNameLeftAligned = async (canvas: Locator) => {
.toBe(true)
}
// C226 — the home sample renders the same card as the catalogue, so a video
// piece asks the Central for its still before the play; the door answers 404
// until the self-heal lands and the browser logs that refusal. Declared here
// because the sample is conditional (the serial frontendConteudos spec may
// publish a video piece mid-run) and this spec must not depend on it.
test.use({ expectedRequestFailurePaths: [/\/conteudos\/.+\/frame$/] })
test.describe('Frontend', () => {
test('can go on homepage', async ({ page }) => {
await page.goto('/')

View file

@ -190,6 +190,40 @@ const mediaRequests = (page: Page) => {
return requests
}
/**
* C226 — the still is a separate door (`/frame`, a sibling of `/midia`), so the
* S27 pin above keeps meaning "no file before the play" while this one means
* "the image is asked for before the play".
*/
const frameRequests = (page: Page) => {
const requests: string[] = []
page.on('request', (request) => {
if (/\/conteudos\/.+\/frame/.test(request.url())) requests.push(request.url())
})
return requests
}
/**
* C226 — the still door is stubbed like the other third-party routes: what
* depends on ffmpeg (the extraction) would otherwise decide whether this spec
* sees a rendered frame or the honest neutral slot, differently on a developer
* machine and in CI. The server contract (200 vs 404, the kill switch) is
* pinned where it belongs, in the int spec; here the card's own behavior —
* asking for the still before the play and dropping it after — is the subject.
*/
const stubContentPieceFrames = async (page: Page): Promise<void> => {
// A real 16:9 JPEG (the browser rejects a truncated one, and the card's own
// fallback would then hide a bug this spec is here to catch).
const still = await sharp({
create: { width: 320, height: 180, channels: 3, background: { r: 24, g: 78, b: 146 } },
})
.jpeg()
.toBuffer()
await page.route('**/conteudos/*/frame', (route) =>
route.fulfill({ body: still, contentType: 'image/jpeg' }),
)
}
/**
* C106/S28 — dynamic pages stream a transient hidden `S:` copy of the shell in
* the production build (the theme path reads `next/headers`, and any heavy
@ -224,6 +258,13 @@ const waitForHomeSection = async (
test.describe.configure({ mode: 'serial' })
// C226 — the still of a video piece is asked for before the play and answers
// 404 until the self-heal lands (the fixtures here are not decodifiable video,
// and the extraction needs ffmpeg). The browser logs that refusal, so the spec
// declares the door: the frame's own 200/404 contract is pinned in the int spec
// and the rendered still in the lazy test below.
test.use({ expectedRequestFailurePaths: [/\/conteudos\/.+\/frame$/] })
test.afterAll(async ({ request }) => {
const headers = await adminHeaders(request, BASE_URL).catch(() => null)
if (!headers) return
@ -260,9 +301,11 @@ test.describe('Frontend Central de Conteúdos (S27)', () => {
const videoTitle = `Fim da escala ${uniqueMarker()}`
const photoTitle = `Solla no SUS ${uniqueMarker()}`
const video = await createPiece(request, headers, { title: videoTitle, type: 'video' })
await createPiece(request, headers, { title: photoTitle, type: 'foto' })
const photo = await createPiece(request, headers, { title: photoTitle, type: 'foto' })
const media = mediaRequests(page)
const frames = frameRequests(page)
await stubContentPieceFrames(page)
const response = await page.goto('/conteudos')
expect(response?.status()).toBe(200)
@ -294,10 +337,27 @@ test.describe('Frontend Central de Conteúdos (S27)', () => {
await expect(page.locator('audio')).toHaveCount(0)
expect(media.filter((url) => url.includes(video.slug))).toHaveLength(0)
// Play mounts only the tapped piece and fetches only its media.
// C226 — the still is a lightweight image asked for BEFORE the play, and it
// is a different door from the file: a photo piece never asks for one.
const still = page.locator('article[data-content-piece] img[src$="/frame"]')
await expect(still).toHaveCount(1)
await expect
.poll(() => frames.filter((url) => url.includes(video.slug)).length)
.toBeGreaterThan(0)
expect(frames.filter((url) => url.includes(photo.slug))).toHaveLength(0)
// The still really rendered (a card never shows a broken image) and the
// play button is still the point of highest contrast over it.
await expect
.poll(() => still.evaluate((image: HTMLImageElement) => image.naturalWidth))
.toBeGreaterThan(0)
await expect(page.getByRole('button', { name: `Reproduzir ${videoTitle}` })).toBeVisible()
// Play mounts only the tapped piece and fetches only its media. The still
// leaves the slot: the player takes the same 16:9 box (cena 03).
await page.getByRole('button', { name: `Reproduzir ${videoTitle}` }).click()
await expect(page.locator('video')).toHaveCount(1)
await expect.poll(() => media.filter((url) => url.includes(video.slug)).length).toBe(1)
await expect(page.locator('article[data-content-piece] img[src$="/frame"]')).toHaveCount(0)
// The download carries the legible name built from the slug.
await expect(page.getByRole('link', { name: `Baixar ${videoTitle}` })).toHaveAttribute(
@ -657,6 +717,11 @@ test.describe('Frontend Central de Conteúdos (S27)', () => {
expect(unpublished.status()).toBe(404)
expect(await unpublished.text()).toContain('Esta peça não está disponível')
// C226 — the still door closes with the file door: an unpublished piece
// stops exposing its derived frame exactly the same way.
expect((await request.get(`/conteudos/${piece.slug}/midia`)).status()).toBe(404)
expect((await request.get(`/conteudos/${piece.slug}/frame`)).status()).toBe(404)
await setPieceStatus(request, headers, piece.id, 'publicado')
await page.goto('/conteudos')
await waitForSettledPage(page)

View file

@ -7,6 +7,11 @@
* command writes. `FAKE_FFMPEG_FAIL=1` makes it exit 1; `FAKE_FFMPEG_LOG=<path>`
* records argv. The real binary is exercised by the `skipIf(!hasFfmpeg())`
* block (CI).
*
* `FAKE_FFMPEG_FAIL_STILL=1` (C226) fails only the still command — the one
* carrying `-frames:v 1` — so a spec can pin a pipeline that ingests perfectly
* and then fails to produce a frame, which `FAKE_FFMPEG_FAIL` cannot express
* (it would fail the audio extraction too).
*/
import { copyFileSync, writeFileSync } from 'node:fs'
import { argv, env, exit } from 'node:process'
@ -22,6 +27,11 @@ if (env.FAKE_FFMPEG_FAIL === '1') {
exit(1)
}
if (env.FAKE_FFMPEG_FAIL_STILL === '1' && args.includes('-frames:v')) {
process.stderr.write('fake-ffmpeg: forced still failure\n')
exit(1)
}
const inputIndex = args.indexOf('-i')
const input = inputIndex >= 0 ? args[inputIndex + 1] : undefined
const output = args.at(-1)

View file

@ -1,6 +1,8 @@
// @vitest-environment node
import { mkdtemp, rm } from 'node:fs/promises'
import { spawnSync } from 'node:child_process'
import { existsSync, readFileSync, rmSync } from 'node:fs'
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
@ -39,6 +41,7 @@ import {
setContentPiecePublishedForActor,
updateContentPieceForActor,
} from '@/app/(campaign)/campanha/actions/contentPieces'
import { GET as getPublicPieceFrame } from '@/app/(frontend)/conteudos/[slug]/frame/route'
import { GET as getPublicPieceMedia } from '@/app/(frontend)/conteudos/[slug]/midia/route'
import type { ContentPieceCuratedField } from '@/lib/contentPiece'
import {
@ -46,6 +49,12 @@ import {
type ContentCatalogItem,
type ContentPiecePublicItem,
} from '@/lib/contentPieceCatalog'
import {
buildContentPieceFrameFfmpegArgs,
CONTENT_PIECE_FRAME_MAX_SOURCE_BYTES,
CONTENT_PIECE_FRAME_SEEK_ATTEMPTS,
contentPieceFrameFilename,
} from '@/lib/contentPieceFrame'
import {
CONTENT_PIECE_FORBIDDEN_MESSAGE,
CONTENT_PIECE_LINK_DUPLICATE_MESSAGE,
@ -57,6 +66,10 @@ import type { SpeechTopic } from '@/lib/speechFacets'
import { normalizeForSearch } from '@/lib/speechSearch'
import type { CampaignUser, ContentMedia, User } from '@/payload-types'
import config from '@/payload.config'
import {
ensureContentPieceFrame,
findContentPieceFrameMedia,
} from '@/utilities/content/contentPieceFrameJob'
import {
CONTENT_PIECE_STALE_MS,
reapStaleContentPiece,
@ -80,14 +93,29 @@ import {
receiveContentPieceUpload,
} from '@/utilities/content/contentPieceUpload'
import { getCollectionListingTag } from '@/utilities/documents'
import { runFfmpeg } from '@/utilities/media/ffmpeg'
import type { InstagramPost } from '@/utilities/socialFeed/instagramFeed'
import { installCampaignFixtures } from '../helpers/campaignFixtures'
const FAKE_FFMPEG = fileURLToPath(new URL('../fixtures/fake-ffmpeg.mjs', import.meta.url))
const hasFfmpeg = spawnSync('ffmpeg', ['-version']).status === 0
const VIDEO_BYTES = Buffer.from('fixture-content-piece-bytes')
/**
* C226 — the fake ffmpeg copies the source to the still, and Payload measures
* the dimensions of an image upload, so the source of a frame test has to be a
* real (1x1) JPEG. Production always extracts one; the double cannot.
*/
const FRAME_SOURCE_BYTES = Buffer.from(
'/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0a' +
'HBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/wAALCAABAAEBAREA/8QAFAABAAAAAAAA' +
'AAAAAAAAAAAAACf/EABQQAQAAAAAAAAAAAAAAAAAAAAD/2gAIAQEAAD8AKp//2Q==',
'base64',
)
let payload: Payload
const createdPieceIds = new Set<number>()
const createdMediaIds = new Set<number>()
@ -109,6 +137,23 @@ const withEnv = (key: string, value: string | undefined): (() => void) => {
}
}
/**
* C226 — every derived still row, by the deterministic name the job uses. The
* suite owns the whole set (the job generates on its own), so both the
* assertions and the cleanup read it from here.
*/
const frameRows = async (): Promise<ContentMedia[]> => {
const found = await payload.find({
collection: 'contentMedia',
where: { filename: { like: 'content-piece-frame-%' } },
depth: 0,
limit: 0,
pagination: false,
overrideAccess: true,
})
return found.docs
}
const createMedia = async (
name: string,
data: Buffer = VIDEO_BYTES,
@ -131,6 +176,8 @@ const createPiece = async ({
processingStatus = 'pronto',
origin = 'arquivo',
withMedia = true,
mediaMimetype,
mediaData,
transcript,
topics,
institution,
@ -143,13 +190,24 @@ const createPiece = async ({
processingStatus?: 'processando' | 'pronto' | 'falhou'
origin?: 'arquivo' | 'instagram' | 'youtube'
withMedia?: boolean
/** The stored MIME decides how the card renders, so a photo piece needs a real one. */
mediaMimetype?: string
/** The uploaded bytes (a frame fixture needs a real image, see FRAME_SOURCE_BYTES). */
mediaData?: Buffer
transcript?: string
topics?: SpeechTopic[]
institution?: string
sourceUrl?: string
curatedFields?: ContentPieceCuratedField[]
} = {}) => {
const media = withMedia ? await createMedia(`${title}.mp4`) : null
const extension = mediaMimetype === 'image/png' ? 'png' : 'mp4'
const media = withMedia
? await createMedia(
`${title}.${extension}`,
mediaData ?? VIDEO_BYTES,
mediaMimetype ?? 'video/mp4',
)
: null
const piece = await payload.create({
collection: 'contentPiece',
data: {
@ -300,6 +358,13 @@ describe('content pieces (C211)', () => {
.delete({ collection: 'contentPiece', id, overrideAccess: true })
.catch(() => undefined)
}
// C226 — the job generates the still on its own, so those rows are not in
// `createdMediaIds`; the derived cache never outlives the suite.
for (const frame of await frameRows()) {
await payload
.delete({ collection: 'contentMedia', id: frame.id, overrideAccess: true })
.catch(() => undefined)
}
for (const id of createdMediaIds) {
await payload
.delete({ collection: 'contentMedia', id, overrideAccess: true })
@ -1981,4 +2046,320 @@ describe('content pieces (C211)', () => {
const byType = await loadContentPieceCatalogSearch({ rawSearchParams: { tipo: 'card' } })
expect(byType.items.filter(isCardCatalogItem)).toHaveLength(6)
})
describe('C226 — the video still before the play', () => {
const publish = async (pieceId: number): Promise<string> => {
await payload.update({
collection: 'contentPiece',
id: pieceId,
data: { status: 'publicado' },
overrideAccess: true,
})
return (
await payload.findByID({
collection: 'contentPiece',
id: pieceId,
depth: 0,
overrideAccess: true,
})
).slug!
}
const callFrame = (slug: string): Promise<Response> =>
getPublicPieceFrame(new Request(`http://localhost/conteudos/${slug}/frame`), {
params: Promise.resolve({ slug }),
})
it('generates the still once, stores it privately and reuses the row', async () => {
const restoreFfmpeg = withEnv('FFMPEG_PATH', FAKE_FFMPEG)
try {
const { piece } = await createPiece({
title: `Frame ${Date.now()}`,
mediaData: FRAME_SOURCE_BYTES,
})
const generated = await ensureContentPieceFrame(payload, piece.id)
if (!generated) throw new Error('the still was not generated')
expect(generated.filename).toBe(contentPieceFrameFilename(piece.id))
// The row has to carry the MIME: without it the private-media contract
// degrades to `application/octet-stream` and forces a download.
expect(generated.mimeType).toBe('image/jpeg')
expect(generated.alt).toContain(piece.title)
const before = (await frameRows()).length
// The deterministic name is the cache key: a second call probes, it does
// not generate again.
const again = await ensureContentPieceFrame(payload, piece.id)
expect(again?.id).toBe(generated.id)
expect(await frameRows()).toHaveLength(before)
} finally {
restoreFfmpeg()
}
})
it('shares one generation between concurrent requests (single flight)', async () => {
const restoreFfmpeg = withEnv('FFMPEG_PATH', FAKE_FFMPEG)
try {
const { piece } = await createPiece({
title: `Frame concorrência ${Date.now()}`,
mediaData: FRAME_SOURCE_BYTES,
})
const [first, second] = await Promise.all([
ensureContentPieceFrame(payload, piece.id),
ensureContentPieceFrame(payload, piece.id),
])
expect(second?.id).toBe(first?.id)
const rows = (await frameRows()).filter(
(row) => row.filename === contentPieceFrameFilename(piece.id),
)
expect(rows).toHaveLength(1)
} finally {
restoreFfmpeg()
}
})
it('memoizes a failure instead of extracting the still on every view', async () => {
const restoreFfmpeg = withEnv('FFMPEG_PATH', FAKE_FFMPEG)
const restoreFail = withEnv('FAKE_FFMPEG_FAIL', '1')
try {
const { piece } = await createPiece({
title: `Frame falha ${Date.now()}`,
mediaData: FRAME_SOURCE_BYTES,
})
expect(await ensureContentPieceFrame(payload, piece.id)).toBeNull()
expect(await findContentPieceFrameMedia(payload, piece.id)).toBeNull()
// Second view: the failure is remembered, so nothing is created and the
// card keeps its neutral slot.
expect(await ensureContentPieceFrame(payload, piece.id)).toBeNull()
expect(await findContentPieceFrameMedia(payload, piece.id)).toBeNull()
} finally {
restoreFail()
restoreFfmpeg()
}
})
it('never caches a still for a piece with no archived file or an unknown id', async () => {
const restoreFfmpeg = withEnv('FFMPEG_PATH', FAKE_FFMPEG)
const argvLog = join(tmpdir(), `frame-argv-${Date.now()}.json`)
const restoreLog = withEnv('FAKE_FFMPEG_LOG', argvLog)
try {
const { piece } = await createPiece({
title: `Frame sem arquivo ${Date.now()}`,
withMedia: false,
sourceUrl: 'https://www.instagram.com/reel/SEMARQUIVO/',
})
const before = (await frameRows()).length
expect(await ensureContentPieceFrame(payload, piece.id)).toBeNull()
expect(await ensureContentPieceFrame(payload, -1)).toBeNull()
expect(await ensureContentPieceFrame(payload, Number.NaN)).toBeNull()
expect(await frameRows()).toHaveLength(before)
// A peça-link has no file to read: ffmpeg is never even spawned.
expect(existsSync(argvLog)).toBe(false)
} finally {
rmSync(argvLog, { force: true })
restoreLog()
restoreFfmpeg()
}
})
it('skips a source the self-heal would have to download whole', async () => {
const restoreFfmpeg = withEnv('FFMPEG_PATH', FAKE_FFMPEG)
try {
const { piece, media } = await createPiece({
title: `Frame gigante ${Date.now()}`,
mediaData: FRAME_SOURCE_BYTES,
})
if (!media) throw new Error('fixture media was not created')
await payload.update({
collection: 'contentMedia',
id: media.id,
data: { filesize: CONTENT_PIECE_FRAME_MAX_SOURCE_BYTES + 1 },
overrideAccess: true,
})
// The heal path is capped: a full-length recording is not worth a
// visitor's connection. The refusal is remembered like a failure, so a
// later view does not re-download it either.
expect(await ensureContentPieceFrame(payload, piece.id)).toBeNull()
expect(await ensureContentPieceFrame(payload, piece.id)).toBeNull()
expect(await findContentPieceFrameMedia(payload, piece.id)).toBeNull()
} finally {
restoreFfmpeg()
}
})
it('extracts the still from the file the job already has, whatever its size', async () => {
const restoreFfmpeg = withEnv('FFMPEG_PATH', FAKE_FFMPEG)
try {
const { piece, media } = await createPiece({
title: `Frame local ${Date.now()}`,
mediaData: FRAME_SOURCE_BYTES,
})
if (!media) throw new Error('fixture media was not created')
await payload.update({
collection: 'contentMedia',
id: media.id,
data: { filesize: CONTENT_PIECE_FRAME_MAX_SOURCE_BYTES + 1 },
overrideAccess: true,
})
// The cap is about the cost of the DOWNLOAD, and the job path has
// nothing to download: the C211 pipeline already wrote that file.
await withTempDir(async (tempDir) => {
const local = join(tempDir, 'source.mp4')
await writeFile(local, FRAME_SOURCE_BYTES)
const generated = await ensureContentPieceFrame(payload, piece.id, { localPath: local })
expect(generated?.filename).toBe(contentPieceFrameFilename(piece.id))
})
} finally {
restoreFfmpeg()
}
})
it('serves the still of a published video and 404s everything else', async () => {
const restoreFfmpeg = withEnv('FFMPEG_PATH', FAKE_FFMPEG)
try {
const marker = `frame-rota-${Date.now()}`
const published = await createPiece({
title: `Frame pública ${marker}`,
mediaData: FRAME_SOURCE_BYTES,
})
const photo = await createPiece({
title: `Frame foto ${marker}`,
type: 'foto',
mediaMimetype: 'image/png',
mediaData: FRAME_SOURCE_BYTES,
})
const link = await createPiece({
title: `Frame link ${marker}`,
withMedia: false,
origin: 'instagram',
sourceUrl: `https://www.instagram.com/reel/ROTA${Date.now()}/`,
})
const publishedSlug = await publish(published.piece.id)
const photoSlug = await publish(photo.piece.id)
const linkSlug = await publish(link.piece.id)
const served = await callFrame(publishedSlug)
expect(served.status).toBe(200)
expect(served.headers.get('Content-Type')).toBe('image/jpeg')
// The derived object is as private as the video it comes from.
expect(served.headers.get('Cache-Control')).toBe('private, no-store')
expect(Buffer.from(await served.arrayBuffer())).toEqual(FRAME_SOURCE_BYTES)
// A photo, a link piece and an unknown slug never reach the generation.
expect((await callFrame(photoSlug)).status).toBe(404)
expect((await callFrame(linkSlug)).status).toBe(404)
expect((await callFrame(`nao-existe-${marker}`)).status).toBe(404)
// The kill switch closes the frame door with the video door.
await payload.update({
collection: 'contentPiece',
id: published.piece.id,
data: { status: 'rascunho' },
overrideAccess: true,
})
expect((await callFrame(publishedSlug)).status).toBe(404)
} finally {
restoreFfmpeg()
}
})
it('declares the frame in the public projection only for a video with a file', async () => {
const video = await createPiece({
title: `Frame projeção vídeo ${Date.now()}`,
mediaData: FRAME_SOURCE_BYTES,
})
const photo = await createPiece({
title: `Frame projeção foto ${Date.now()}`,
type: 'foto',
mediaMimetype: 'image/png',
mediaData: FRAME_SOURCE_BYTES,
})
await publish(video.piece.id)
await publish(photo.piece.id)
const byId = async (pieceId: number) =>
(await getPublishedContentPieceItems()).find((item) => item.id === pieceId)
expect((await byId(video.piece.id))?.framePath).toBe(
`/conteudos/${(await byId(video.piece.id))?.slug}/frame`,
)
expect((await byId(photo.piece.id))?.framePath).toBeNull()
})
it('leaves the piece ready when the still cannot be extracted (failure isolation)', async () => {
const { piece } = await createPiece({
processingStatus: 'processando',
title: 'Frame com ffmpeg quebrado',
mediaData: FRAME_SOURCE_BYTES,
})
// The encoder works; only the still extraction fails.
const restoreFfmpeg = withEnv('FFMPEG_PATH', FAKE_FFMPEG)
const restoreSegments = withEnv('FAKE_FFMPEG_SEGMENTS', '1')
const restoreStill = withEnv('FAKE_FFMPEG_FAIL_STILL', '1')
try {
await runContentPieceJob(payload, piece.id, {
transcribe: transcribeOk,
catalog: catalogStub,
})
} finally {
restoreStill()
restoreSegments()
restoreFfmpeg()
}
const updated = await payload.findByID({
collection: 'contentPiece',
id: piece.id,
depth: 0,
overrideAccess: true,
})
expect(updated.processingStatus).toBe('pronto')
expect(updated.error).toBeNull()
expect(updated.step).toBeNull()
// A frame is a cache entry: the ingestion result never depends on it.
expect(await findContentPieceFrameMedia(payload, piece.id)).toBeNull()
})
it.skipIf(!hasFfmpeg)('extracts a real JPEG one second into the video', async () => {
const dir = join(tmpdir(), 'content-piece-frame-real')
const restoreFfmpeg = withEnv('FFMPEG_PATH', undefined)
try {
await rm(dir, { recursive: true, force: true })
await mkdir(dir, { recursive: true })
const source = join(dir, 'source.mp4')
const output = join(dir, contentPieceFrameFilename(1))
const encoded = spawnSync('ffmpeg', [
'-nostdin',
'-hide_banner',
'-y',
'-f',
'lavfi',
'-i',
'testsrc=size=320x240:rate=10:duration=4',
source,
])
expect(encoded.status).toBe(0)
for (const atSeconds of CONTENT_PIECE_FRAME_SEEK_ATTEMPTS) {
await runFfmpeg(
buildContentPieceFrameFfmpegArgs({ inputPath: source, outputPath: output, atSeconds }),
4,
)
expect(readFileSync(output).subarray(0, 2)).toEqual(Buffer.from([0xff, 0xd8]))
await rm(output, { force: true })
}
} finally {
restoreFfmpeg()
await rm(dir, { recursive: true, force: true })
}
})
})
})

View file

@ -9,6 +9,7 @@ import {
contentPieceCatalogActiveFilters,
contentPieceCatalogFacets,
contentPieceDownloadFilename,
contentPieceFramePath,
contentPieceMediaKind,
contentPieceMediaPath,
contentPiecePublicPath,
@ -487,6 +488,29 @@ describe('content piece public view model', () => {
expect(view.file).toBeNull()
})
it('declares a frame only for a video with an archived file (C226)', () => {
expect(item().framePath).toBe('/conteudos/fim-da-escala-6x1/frame')
// Same decision as the card's own media kind, so a piece can never be asked
// for a frame it cannot produce.
expect(item({ media: { id: 7, mimeType: 'video/mp4' }, type: 'texto' }).framePath).toBe(
'/conteudos/fim-da-escala-6x1/frame',
)
// Everything else keeps the neutral slot.
expect(item({ media: { id: 7, mimeType: 'audio/mpeg' }, type: 'audio' }).framePath).toBeNull()
expect(item({ media: { id: 7, mimeType: 'image/png' }, type: 'foto' }).framePath).toBeNull()
expect(item({ media: { id: 7, mimeType: 'text/plain' }, type: 'texto' }).framePath).toBeNull()
expect(
item({ media: { id: 7, mimeType: 'application/pdf' }, type: 'card' }).framePath,
).toBeNull()
// Unknown MIME with a non-video editorial type: the type decides, as everywhere.
expect(item({ media: { id: 7 }, type: 'foto' }).framePath).toBeNull()
// A link piece never has an archived file, so it never has a frame.
expect(
item({ media: null, sourceUrl: 'https://www.instagram.com/reel/ABC/', type: 'video' })
.framePath,
).toBeNull()
})
it('resolves the media kind from the stored MIME with a type fallback', () => {
expect(contentPieceMediaKind(item({ media: { id: 7, mimeType: 'video/mp4' } }))).toBe('video')
expect(contentPieceMediaKind(item({ media: { id: 7, mimeType: 'audio/mpeg' } }))).toBe('audio')
@ -550,5 +574,6 @@ describe('content piece public view model', () => {
)
expect(contentPiecePublicPath('x')).toBe('/conteudos/x')
expect(contentPieceMediaPath('x')).toBe('/conteudos/x/midia')
expect(contentPieceFramePath('x')).toBe('/conteudos/x/frame')
})
})

View file

@ -0,0 +1,98 @@
// @vitest-environment node
import { describe, expect, it } from 'vitest'
import { contentPieceFramePath } from '@/lib/contentPieceCatalog'
import {
buildContentPieceFrameFfmpegArgs,
CONTENT_PIECE_FRAME_MAX_SOURCE_BYTES,
CONTENT_PIECE_FRAME_SEEK_ATTEMPTS,
CONTENT_PIECE_FRAME_WAIT_MS,
contentPieceFrameFilename,
contentPieceFrameMediaAlt,
contentPieceFrameSizeAllowed,
} from '@/lib/contentPieceFrame'
describe('contentPieceFrameFilename / contentPieceFramePath', () => {
it('is deterministic and unique per piece (the idempotence key)', () => {
expect(contentPieceFrameFilename(42)).toBe('content-piece-frame-42.jpg')
expect(contentPieceFrameFilename(43)).toBe('content-piece-frame-43.jpg')
})
it('lives beside the media route of the same public piece', () => {
expect(contentPieceFramePath('peca-de-video')).toBe('/conteudos/peca-de-video/frame')
})
})
describe('contentPieceFrameMediaAlt', () => {
it('describes the derived row (contentMedia.alt is required)', () => {
expect(contentPieceFrameMediaAlt('Voto 1313')).toBe('Frame do vídeo «Voto 1313»')
})
})
describe('contentPieceFrameSizeAllowed', () => {
it('refuses a source the self-heal would have to download whole', () => {
expect(contentPieceFrameSizeAllowed(1024)).toBe(true)
expect(contentPieceFrameSizeAllowed(CONTENT_PIECE_FRAME_MAX_SOURCE_BYTES)).toBe(true)
expect(contentPieceFrameSizeAllowed(CONTENT_PIECE_FRAME_MAX_SOURCE_BYTES + 1)).toBe(false)
})
it('does not skip on an unknown size (a projection limit is not a licence)', () => {
expect(contentPieceFrameSizeAllowed(null)).toBe(true)
expect(contentPieceFrameSizeAllowed(undefined)).toBe(true)
expect(contentPieceFrameSizeAllowed(Number.NaN)).toBe(true)
})
})
describe('buildContentPieceFrameFfmpegArgs', () => {
it('seeks to the second and takes a single scaled jpeg frame', () => {
const args = buildContentPieceFrameFfmpegArgs({
inputPath: '/tmp/source.mp4',
outputPath: '/tmp/content-piece-frame-42.jpg',
atSeconds: 1,
})
expect(args).toEqual([
'-nostdin',
'-hide_banner',
'-y',
'-ss',
'1',
'-i',
'/tmp/source.mp4',
'-frames:v',
'1',
'-vf',
'scale=640:-2',
'-q:v',
'3',
'-an',
'/tmp/content-piece-frame-42.jpg',
])
// The seek is an input option: before `-i`, never after — and the command
// is an argv array, never a shell string (the `toEqual` above is the pin).
expect(args.indexOf('-ss')).toBeLessThan(args.indexOf('-i'))
})
it('never seeks to a negative instant', () => {
const args = buildContentPieceFrameFfmpegArgs({
inputPath: 'in.mp4',
outputPath: 'out.jpg',
atSeconds: -3,
})
expect(args[args.indexOf('-ss') + 1]).toBe('0')
})
})
describe('CONTENT_PIECE_FRAME_SEEK_ATTEMPTS', () => {
it('avoids the black opening frame and retries once at zero', () => {
expect([...CONTENT_PIECE_FRAME_SEEK_ATTEMPTS]).toEqual([1, 0])
})
})
describe('CONTENT_PIECE_FRAME_WAIT_MS', () => {
it('is shorter than the acervo poster wait (here the heal may download the file)', () => {
expect(CONTENT_PIECE_FRAME_WAIT_MS).toBeLessThan(20_000)
})
})

View file

@ -68,6 +68,7 @@ const item = (id: number, patch: Partial<ContentPieceHomeItem> = {}): ContentPie
mimeType: 'image/png',
downloadFilename: `peca-${id}.png`,
},
framePath: null,
...patch,
})

View file

@ -89,6 +89,7 @@ describe('content piece home selection', () => {
'durationLabel',
'excerpt',
'file',
'framePath',
'id',
'isLink',
'metaLabel',
@ -106,6 +107,27 @@ describe('content piece home selection', () => {
expect('topics' in item).toBe(false)
})
it('carries the frame of a video piece into the home card (C226)', () => {
// The field-by-field map is what stops a new public field from leaking by
// spread, so the copy is pinned here on purpose.
expect(
homeItem({
id: 3,
slug: 'peca-video',
type: 'video',
media: { id: 7, filename: 'video.mp4', mimeType: 'video/mp4' },
}).framePath,
).toBe('/conteudos/peca-video/frame')
expect(
homeItem({
id: 4,
slug: 'peca-foto',
type: 'foto',
media: { id: 8, filename: 'foto.png', mimeType: 'image/png' },
}).framePath,
).toBeNull()
})
it('keeps a link piece with no archived file — the platform handoff', () => {
const item = homeItem({
id: 9,

View file

@ -0,0 +1,250 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { ContentPieceMedia } from '@/components/conteudos/ContentPieceMedia'
import type { ContentPiecePublicItem } from '@/lib/contentPieceCatalog'
const item = (patch: Partial<ContentPiecePublicItem> = {}): ContentPiecePublicItem => ({
id: 7,
slug: 'peca-7',
title: 'Vídeo da peça',
type: 'video',
typeLabel: 'Vídeo',
origin: 'arquivo',
originLabel: 'Arquivo',
isLink: false,
sourceUrl: null,
topics: [],
topicLabels: [],
cityLabel: null,
regionLabel: null,
institution: null,
leaderNames: [],
publicFigures: [],
description: null,
excerpt: null,
durationLabel: '02:14',
pieceDateLabel: null,
metaLabel: 'Tema · Local',
searchText: '',
themeMatch: null,
publicPath: '/conteudos/peca-7',
file: {
id: 107,
path: '/conteudos/peca-7/midia',
mimeType: 'video/mp4',
downloadFilename: 'peca-7.mp4',
},
framePath: '/conteudos/peca-7/frame',
...patch,
})
const renderSlot = (
overrides: Partial<ContentPiecePublicItem>,
playing = false,
variant: 'card' | 'thumb' | 'detail' | 'home-thumb' = 'card',
) =>
render(
<ContentPieceMedia
item={item(overrides)}
playing={playing}
onToggle={vi.fn()}
onEnded={vi.fn()}
variant={variant}
/>,
)
/** The play state belongs to the host, so the slot is driven by re-rendering. */
const playSlot = (view: { rerender: (ui: React.ReactElement) => void }, playing: boolean) =>
view.rerender(
<ContentPieceMedia item={item()} playing={playing} onToggle={vi.fn()} onEnded={vi.fn()} />,
)
const frameImage = () =>
document.querySelector<HTMLImageElement>('img[src="/conteudos/peca-7/frame"]')
afterEach(cleanup)
describe('ContentPieceMedia — the video still before the play (C226)', () => {
it('asks for the frame, never for the video file, before the gesture', () => {
renderSlot({})
expect(frameImage()).not.toBeNull()
expect(document.querySelector('video')).toBeNull()
expect(document.querySelector('audio')).toBeNull()
// S27: the element that would fetch the file only exists after the tap.
expect(document.querySelector('[src="/conteudos/peca-7/midia"]')).toBeNull()
})
it('keeps the frame out of the slot once the piece is playing', () => {
renderSlot({}, true)
expect(frameImage()).toBeNull()
const video = document.querySelector('video')
expect(video).not.toBeNull()
expect(video?.getAttribute('preload')).toBe('none')
expect(video?.getAttribute('src')).toBe('/conteudos/peca-7/midia')
})
it('falls back to the neutral slot with the designed marker when there is no frame', () => {
renderSlot({ framePath: null })
expect(frameImage()).toBeNull()
expect(screen.getByText('Frame indisponível')).toBeDefined()
// The piece type still reads on the card body tag, not inside the slot.
expect(document.querySelector('video')).toBeNull()
})
it('never retries a broken frame: the marker replaces it for the life of the card', () => {
renderSlot({})
const image = frameImage()
if (!image) throw new Error('the still was not rendered')
fireEvent.error(image)
expect(frameImage()).toBeNull()
expect(screen.getByText('Frame indisponível')).toBeDefined()
// No broken image is left in the DOM and no second request was made.
expect(document.querySelector('img')).toBeNull()
})
it('keeps the still invisible until it decodes (no broken-image glyph ever)', () => {
renderSlot({})
const image = frameImage()
if (!image) throw new Error('the still was not rendered')
// Loading: the neutral surface is what the visitor sees — the image never
// paints over it before the browser decodes it.
expect(image.className).toContain('opacity-0')
expect(screen.queryByText('Frame indisponível')).toBeNull()
fireEvent.load(image)
expect(image.className).not.toContain('opacity-0')
})
/**
* The browser fires neither `load` nor `error` again for an image that
* already settled before hydration — the ref is the only thing that can tell
* a decoded still from a failed one, and both outcomes are the fast path.
*/
const withSettledImage = (naturalWidth: number, run: () => void) => {
const complete = Object.getOwnPropertyDescriptor(HTMLImageElement.prototype, 'complete')
Object.defineProperty(HTMLImageElement.prototype, 'complete', {
configurable: true,
get: () => true,
})
Object.defineProperty(HTMLImageElement.prototype, 'naturalWidth', {
configurable: true,
get: () => naturalWidth,
})
try {
run()
} finally {
if (complete) {
Object.defineProperty(HTMLImageElement.prototype, 'complete', complete)
} else {
delete (HTMLImageElement.prototype as unknown as Record<string, unknown>).complete
}
delete (HTMLImageElement.prototype as unknown as Record<string, unknown>).naturalWidth
}
}
it('shows a still that was already decoded before hydration (warm cache)', () => {
withSettledImage(640, () => {
renderSlot({})
const image = frameImage()
if (!image) throw new Error('the still was not rendered')
expect(image.className).not.toContain('opacity-0')
})
})
it('falls back when the still had already failed before hydration', () => {
withSettledImage(0, () => {
renderSlot({})
expect(frameImage()).toBeNull()
expect(screen.getByText('Frame indisponível')).toBeDefined()
})
})
it('never asks again after a play/stop cycle (the state outlives the layer)', () => {
const view = renderSlot({})
const still = frameImage()
if (!still) throw new Error('the still was not rendered')
fireEvent.error(still)
playSlot(view, true)
playSlot(view, false)
// The knowledge that this piece has no frame belongs to the card, not to
// the layer that remounts on every play — so the still is never requested
// a second time.
expect(document.querySelector('img')).toBeNull()
expect(screen.getByText('Frame indisponível')).toBeDefined()
})
it('keeps an audio piece exactly as it was (no frame, no marker)', () => {
renderSlot({
type: 'audio',
typeLabel: 'Áudio',
framePath: null,
file: {
id: 107,
path: '/conteudos/peca-7/midia',
mimeType: 'audio/mpeg',
downloadFilename: 'peca-7.mp3',
},
})
expect(document.querySelector('img')).toBeNull()
expect(screen.queryByText('Frame indisponível')).toBeNull()
expect(screen.getByText('Áudio')).toBeDefined()
})
it('drops the marker on the 124px home thumb and shrinks the play (CENA 05)', () => {
renderSlot({ framePath: null }, false, 'home-thumb')
// The neutral surface IS the message there: the marker would crowd the play.
// The compact classes stay in the DOM for the `sm` breakpoint, where that
// card becomes a full block and the full treatment returns.
const marker = screen.getByText('Frame indisponível').closest('span')
expect(marker?.className).toContain('hidden')
expect(marker?.className).toContain('sm:flex')
expect(document.querySelector('button[aria-label^="Reproduzir"]')?.className).toContain(
'size-11',
)
expect(document.querySelector('span.rounded.bg-black\\/70')?.className).toContain('text-[10px]')
})
it('keeps the full marker on the catalogue card', () => {
renderSlot({ framePath: null })
const marker = screen.getByText('Frame indisponível').closest('span')
expect(marker?.className).toContain('flex')
expect(marker?.className).not.toContain('hidden')
})
it('keeps the marker and its 28px glyph on the piece page', () => {
renderSlot({ framePath: null }, false, 'detail')
expect(screen.getByText('Frame indisponível')).toBeDefined()
expect(document.querySelector('span.grid.size-7')).not.toBeNull()
})
it('gives a photo piece no frame at all', () => {
renderSlot({
type: 'foto',
typeLabel: 'Foto',
framePath: null,
file: {
id: 107,
path: '/conteudos/peca-7/midia',
mimeType: 'image/png',
downloadFilename: 'peca-7.png',
},
})
expect(document.querySelectorAll('img')).toHaveLength(1)
expect(document.querySelector('img')?.getAttribute('src')).toBe('/conteudos/peca-7/midia')
})
})