teqo-backup/src/app/(frontend)/conteudos/[slug]/frame/route.ts
Francisco Solla dbe77f08cc
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
feat(C226): frame estático nos vídeos da Central antes do play
Peça de vídeo publicada e com arquivo passa a mostrar um frame estático
leve no card público antes do play, sem nenhum request do arquivo antes
do gesto (`preload="none"` do S27 intacto).

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

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

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

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

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

Design tier: central-conteudos-preview-frame-video
Closes #1352
2026-09-25 14:56:06 -03:00

98 lines
3.9 KiB
TypeScript

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()
}