mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-12 22:13:05 +00:00
feat(C226): frame estático nos vídeos da Central antes do play
Some checks are pending
Deploy / preflight (push) Waiting to run
Deploy / verify (push) Blocked by required conditions
Deploy / deploy staging (teqo-staging) (push) Blocked by required conditions
Deploy / requeue when main advanced (push) Blocked by required conditions
Deploy / deploy production (teqo-1313) (push) Blocked by required conditions
Some checks are pending
Deploy / preflight (push) Waiting to run
Deploy / verify (push) Blocked by required conditions
Deploy / deploy staging (teqo-staging) (push) Blocked by required conditions
Deploy / requeue when main advanced (push) Blocked by required conditions
Deploy / deploy production (teqo-1313) (push) Blocked by required conditions
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:
parent
2e5ac5d290
commit
dbe77f08cc
22 changed files with 1963 additions and 31 deletions
1
docs/changelog/2026-09-25-c226.md
Normal file
1
docs/changelog/2026-09-25-c226.md
Normal 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).
|
||||
207
docs/plans/central-conteudos-preview-frame-video-impl.md
Normal file
207
docs/plans/central-conteudos-preview-frame-video-impl.md
Normal 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-<id>.jpg"]
|
||||
E -.->|"falha"| LOG["log [content-piece-frame]<br/>peça segue pronto"]
|
||||
R["GET /conteudos/<slug>/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.
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
98
src/app/(frontend)/conteudos/[slug]/frame/route.ts
Normal file
98
src/app/(frontend)/conteudos/[slug]/frame/route.ts
Normal 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()
|
||||
}
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
108
src/lib/contentPieceFrame.ts
Normal file
108
src/lib/contentPieceFrame.ts
Normal 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,
|
||||
]
|
||||
}
|
||||
|
|
@ -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,
|
||||
})
|
||||
|
||||
/**
|
||||
|
|
|
|||
283
src/utilities/content/contentPieceFrameJob.ts
Normal file
283
src/utilities/content/contentPieceFrameJob.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>({
|
||||
|
|
|
|||
|
|
@ -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('/')
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
10
tests/fixtures/fake-ffmpeg.mjs
vendored
10
tests/fixtures/fake-ffmpeg.mjs
vendored
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
98
tests/unit/contentPieceFrame.unit.spec.ts
Normal file
98
tests/unit/contentPieceFrame.unit.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
|
|
@ -68,6 +68,7 @@ const item = (id: number, patch: Partial<ContentPieceHomeItem> = {}): ContentPie
|
|||
mimeType: 'image/png',
|
||||
downloadFilename: `peca-${id}.png`,
|
||||
},
|
||||
framePath: null,
|
||||
...patch,
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
250
tests/unit/contentPieceMedia.unit.spec.tsx
Normal file
250
tests/unit/contentPieceMedia.unit.spec.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue