teqo-backup/src/lib/reel.ts
2026-09-18 22:01:32 -03:00

372 lines
13 KiB
TypeScript

/**
* C193 — pure rules of a reel: the status vocabulary (kill switch), the
* feature catalog, the artifact `kind` → upload-field map and the private
* media path. No I/O and no `server-only`: the collection config, the
* authenticated media route and the C194 library share this module. Since C195
* it also owns the ingestion package contract (`REEL_PACKAGE_*`) that the
* production skill (C196/C197) writes and `pnpm reels:ingest` validates.
*/
import { campaignReelDetailHref } from '@/lib/campaignPaths'
import { formatBahiaCivilDate } from '@/lib/campaignTime'
/** Upload collection of the artifacts; also its local disk directory in dev. */
export const REEL_MEDIA_SLUG = 'reelMedia'
export const REEL_STATUSES = ['draft', 'published', 'unpublished'] as const
export type ReelStatus = (typeof REEL_STATUSES)[number]
/** One owner for the persisted field limit (collection, schema and C194). */
export const REEL_TITLE_MAX_LENGTH = 200
export const reelStatusLabels: Record<ReelStatus, string> = {
draft: 'Rascunho',
published: 'Publicado',
unpublished: 'Despublicado',
}
export const isReelStatus = (value: unknown): value is ReelStatus =>
REEL_STATUSES.includes(value as ReelStatus)
/**
* The closed feature vocabulary. Starts with `cards` (the home support cards at
* `#cards`); a new tutorial feature appends here — never free text.
*/
export const REEL_FEATURES = ['cards'] as const
export type ReelFeature = (typeof REEL_FEATURES)[number]
/** Admin/select wording: keeps the public anchor the tutorial teaches. */
export const reelFeatureLabels: Record<ReelFeature, string> = {
cards: 'Cards de apoio (#cards)',
}
/** Library UI wording: the anchor belongs to the admin context, not the card. */
const reelFeatureDisplayLabels: Record<ReelFeature, string> = {
cards: 'Cards de apoio',
}
/**
* The artifacts a reel can carry, each mapping to exactly one upload field.
* `video` is the primary file (silent); the others are optional variants.
*/
export const REEL_MEDIA_KINDS = ['video', 'video-audio', 'narration', 'captions', 'cover'] as const
export type ReelMediaKind = (typeof REEL_MEDIA_KINDS)[number]
export type ReelMediaField = 'video' | 'videoWithAudio' | 'narrationAudio' | 'captions' | 'cover'
export const reelMediaFieldByKind: Record<ReelMediaKind, ReelMediaField> = {
video: 'video',
'video-audio': 'videoWithAudio',
narration: 'narrationAudio',
captions: 'captions',
cover: 'cover',
}
export const isReelMediaKind = (value: unknown): value is ReelMediaKind =>
REEL_MEDIA_KINDS.includes(value as ReelMediaKind)
/**
* The authenticated serving path of one artifact. Lives under `/campanha` on
* purpose: the `campaign-token` cookie is scoped to that path, so `<video>`/
* `<img>` reach it without an `Authorization` header.
*/
export const reelMediaPath = (reelId: number, kind: ReelMediaKind): string =>
`/campanha/comunicacao/reels/${reelId}/media/${kind}`
/** Only a published reel serves its files; `draft`/`unpublished` answer 404. */
export const canServeReelMedia = (status: ReelStatus): boolean => status === 'published'
/** Manifest file of the ingestion package (C195). */
export const REEL_PACKAGE_METADATA_FILENAME = 'metadata.json'
/** Human-readable script of the package; becomes the reel `transcript`. */
export const REEL_PACKAGE_TRANSCRIPT_FILENAME = 'roteiro.md'
/**
* Keys of `metadata.json`. `shotListHash` is the identity of the reel (the
* ingest updates the same entry for the same hash); `coverAlt` is the required
* alt text of the cover. `durationSeconds`/`createdAt` are validated when
* present but deliberately not persisted (no consumer yet).
*/
export const REEL_PACKAGE_METADATA_KEYS = {
title: 'title',
feature: 'feature',
shotListHash: 'shotListHash',
coverAlt: 'coverAlt',
durationSeconds: 'durationSeconds',
createdAt: 'createdAt',
} as const
export type ReelPackageArtifact = {
/** File name inside the package directory. */
filename: string
kind: ReelMediaKind
/** Required artifacts must be present; optional ones are ingested when there. */
required: boolean
}
/** The package files, in the order the ingest echoes them. */
export const REEL_PACKAGE_ARTIFACTS: readonly ReelPackageArtifact[] = [
{ filename: 'reel.mp4', kind: 'video', required: true },
{ filename: 'capa.png', kind: 'cover', required: true },
{ filename: 'narracao.srt', kind: 'captions', required: true },
{ filename: 'reel-audio.mp4', kind: 'video-audio', required: false },
{ filename: 'narracao.mp3', kind: 'narration', required: false },
]
/** Content type per artifact kind — the package filenames are fixed, so is this. */
const REEL_PACKAGE_MIMETYPES: Record<ReelMediaKind, string> = {
video: 'video/mp4',
'video-audio': 'video/mp4',
narration: 'audio/mpeg',
captions: 'application/x-subrip',
cover: 'image/png',
}
export const reelArtifactMimetype = (kind: ReelMediaKind): string => REEL_PACKAGE_MIMETYPES[kind]
/**
* Deterministic stored file name: re-ingesting the same reel (same shot list
* hash) overwrites the same object key, and the full hash makes two reels
* collide only if their shot lists do.
*/
export const reelArtifactStorageFilename = (
shotListHash: string,
artifact: Pick<ReelPackageArtifact, 'filename' | 'kind'>,
): string => {
const extension = artifact.filename.match(/\.[^.]+$/)?.[0] ?? ''
return `reel-${shotListHash}-${artifact.kind}${extension}`
}
/** One artifact entry of a read package: contract + where it lives on disk. */
export type ReelPackageArtifactEntry = ReelPackageArtifact & {
field: ReelMediaField
present: boolean
path: string
}
/**
* The shape `readReelPackage` returns and `ingestReelPackage` consumes. Lives
* next to the package contract because it is the same contract: the producer
* writes these files, the CLI validates them and the writer persists them.
*/
export type ReelPackage = {
directory: string
metadata: {
title: string
feature: ReelFeature
shotListHash: string
coverAlt: string
}
artifacts: ReelPackageArtifactEntry[]
transcriptPath: string | null
}
/** Alt text: the manifest alt for the cover, derived from the title otherwise. */
export const reelArtifactAlt = ({
title,
kind,
coverAlt,
}: {
title: string
kind: ReelMediaKind
coverAlt: string
}): string => {
if (kind === 'cover') return coverAlt
const labels: Record<Exclude<ReelMediaKind, 'cover'>, string> = {
video: 'Vídeo',
'video-audio': 'Vídeo com áudio',
narration: 'Narração',
captions: 'Legendas',
}
return `${labels[kind]} do reel "${title}"`
}
const isReelFeature = (value: unknown): value is ReelFeature =>
REEL_FEATURES.includes(value as ReelFeature)
// ---------------------------------------------------------------------------
// C194 — the library view models. Same shape as `lib/speechCut.ts`: the pure
// builders take the lean record the loader selected and return exactly what
// the list/detail render — the artifact URLs only exist when the kill switch
// says the file can be served.
// ---------------------------------------------------------------------------
type ReelMediaRef = number | { alt?: string | null } | null | undefined
/**
* What the view models read. Structurally satisfied by a `Reel` document with
* depth 1 (and by the lean `select` of the list loader) — defined here so
* `lib/` stays free of `@/payload-types`, same reason `speechCut.ts` has its
* own record type.
*/
export type ReelRecordForView = {
id: number
title: string
feature: string
status: string
publishedAt?: string | null
transcript?: string | null
video?: ReelMediaRef
videoWithAudio?: ReelMediaRef
narrationAudio?: ReelMediaRef
captions?: ReelMediaRef
cover?: ReelMediaRef
}
/** Every artifact a person can take out of the library (the cover is not one). */
export type ReelDownloadKind = Exclude<ReelMediaKind, 'cover'>
const REEL_DOWNLOAD_KINDS = [
'video',
'video-audio',
'narration',
'captions',
] as const satisfies readonly ReelDownloadKind[]
const reelDownloadLabels: Record<ReelDownloadKind, { label: string; description: string }> = {
video: { label: 'Vídeo sem áudio', description: 'MP4 · arquivo principal' },
'video-audio': { label: 'Vídeo com áudio', description: 'Rascunho · MP4' },
narration: { label: 'Narração', description: 'MP3' },
captions: { label: 'Legendas', description: 'Arquivo .srt' },
}
/** Shown in place of the link when the artifact was never produced. */
export const reelDownloadUnavailableLabels: Record<ReelDownloadKind, string> = {
video: 'Ainda não disponível.',
'video-audio': 'Ainda não disponível.',
narration: 'A narração será adicionada depois.',
captions: 'A legenda ainda não foi gerada.',
}
/** Every file is withheld while the reel is unpublished (C193 kill switch). */
export const REEL_MEDIA_BLOCKED_LABEL = 'Disponível após republicar.'
/** A draft was never published; its files appear with the first publication. */
export const REEL_MEDIA_DRAFT_LABEL = 'Disponível após publicar.'
export type ReelLibraryItemViewModel = {
id: number
title: string
featureLabel: string
status: ReelStatus
statusLabel: string
coverUrl: string
coverAlt: string
publishedAtLabel: string | null
detailHref: string
}
export type ReelDownloadItemViewModel = {
kind: ReelDownloadKind
label: string
description: string
/** The primary file the design's CTA leads with (always the silent video). */
primary: boolean
/** Null when the artifact is missing or the kill switch withholds the file. */
href: string | null
/** What the row says instead of a link when `href` is null. */
unavailableLabel: string | null
}
export type ReelDetailViewModel = ReelLibraryItemViewModel & {
/** Script/narration text: DB content, not a served file — kept when unpublished. */
transcript: string | null
/** False when the reel is `draft`/`unpublished` — no player, no download links. */
canServeMedia: boolean
/** Why the media is withheld while `canServeMedia` is false (draft ≠ unpublished). */
mediaBlockedLabel: string
/** `video-audio` when the rough cut with narration exists, else the silent video. */
videoSourceUrl: string | null
videoPosterUrl: string
downloads: ReelDownloadItemViewModel[]
}
const normalizeReelStatus = (status: string): ReelStatus =>
isReelStatus(status) ? status : 'draft'
const hasMedia = (value: ReelMediaRef): boolean => value !== null && value !== undefined
const mediaAlt = (value: ReelMediaRef, fallback: string): string => {
if (typeof value !== 'object' || value === null) return fallback
const alt = value.alt?.trim()
return alt ? alt : fallback
}
/** Date-only (`dd/mm/aaaa`) publication label — the library never shows the hour. */
const publishedAtLabel = (iso: string | null | undefined): string | null => {
if (!iso) return null
const instant = new Date(iso)
if (Number.isNaN(instant.getTime())) return null
const [year, month, day] = formatBahiaCivilDate(instant).split('-')
return `${day}/${month}/${year}`
}
const reelCover = (record: ReelRecordForView): { coverUrl: string; coverAlt: string } => ({
coverUrl: reelMediaPath(record.id, 'cover'),
coverAlt: mediaAlt(record.cover, record.title),
})
/** One card of the library list (published rows only — the loader filters). */
export const toReelLibraryItemViewModel = (record: ReelRecordForView): ReelLibraryItemViewModel => {
const status = normalizeReelStatus(record.status)
return {
id: record.id,
title: record.title,
featureLabel: isReelFeature(record.feature)
? reelFeatureDisplayLabels[record.feature]
: record.feature,
status,
statusLabel: reelStatusLabels[status],
...reelCover(record),
publishedAtLabel: publishedAtLabel(record.publishedAt),
detailHref: campaignReelDetailHref(record.id),
}
}
/**
* One reel with its artifacts. The media URLs are null unless the reel is
* published: the kill switch withholds the player and every download, while
* the title/feature/transcript stay readable (C193 decision B).
*/
export const toReelDetailViewModel = (record: ReelRecordForView): ReelDetailViewModel => {
const base = toReelLibraryItemViewModel(record)
const canServeMedia = canServeReelMedia(base.status)
const transcript = record.transcript?.trim()
const mediaBlockedLabel =
base.status === 'draft' ? REEL_MEDIA_DRAFT_LABEL : REEL_MEDIA_BLOCKED_LABEL
const unavailableLabelFor = (kind: ReelDownloadKind): string | null => {
if (!canServeMedia) return mediaBlockedLabel
return hasMedia(record[reelMediaFieldByKind[kind]]) ? null : reelDownloadUnavailableLabels[kind]
}
const downloads = REEL_DOWNLOAD_KINDS.map((kind): ReelDownloadItemViewModel => {
const { label, description } = reelDownloadLabels[kind]
return {
kind,
label,
description,
primary: kind === 'video',
href:
canServeMedia && hasMedia(record[reelMediaFieldByKind[kind]])
? `${reelMediaPath(record.id, kind)}?download=1`
: null,
unavailableLabel: unavailableLabelFor(kind),
}
})
return {
...base,
transcript: transcript ? transcript : null,
canServeMedia,
mediaBlockedLabel,
videoSourceUrl: canServeMedia
? reelMediaPath(record.id, hasMedia(record.videoWithAudio) ? 'video-audio' : 'video')
: null,
videoPosterUrl: base.coverUrl,
downloads,
}
}