/** * C167 — pure rules of a speech cut: the status/step vocabulary, the * deterministic metadata fallback, the ffmpeg command for the exact * [start, end] MP4 and the public view model. No I/O and no `server-only`: * the dialog (client) and the job (server) share this module. */ import { CAMPAIGN_COMMUNICATION_ACERVO } from '@/lib/campaignPaths' import { formatBahiaCivilDate } from '@/lib/campaignTime' import { buildWhatsAppTextShareUrl } from '@/lib/phone' import { SPEECH_VOD_INELIGIBLE_MESSAGE } from '@/lib/schemas/speechVod' import { formatSpeechDate, formatSpeechSpan } from '@/lib/speechClock' import { parseYoutubeVideoId } from '@/lib/speechVod' export const SPEECH_CUT_STATUSES = ['processing', 'published', 'unpublished', 'failed'] as const export type SpeechCutStatus = (typeof SPEECH_CUT_STATUSES)[number] /** One owner for the persisted field limits (collection, schema and dialog). */ export const SPEECH_CUT_TITLE_MAX_LENGTH = 200 export const SPEECH_CUT_DESCRIPTION_MAX_LENGTH = 2000 export const speechCutStatusLabels: Record = { processing: 'Processando', published: 'Publicado', unpublished: 'Despublicado', failed: 'Falhou', } const isSpeechCutStatus = (value: unknown): value is SpeechCutStatus => SPEECH_CUT_STATUSES.includes(value as SpeechCutStatus) export const SPEECH_CUT_STEPS = ['resolving', 'cutting', 'metadata', 'publishing'] as const export type SpeechCutStep = (typeof SPEECH_CUT_STEPS)[number] /** The four honest progress steps the dialog shows (draft scene 2). */ export const speechCutStepLabels: Record = { resolving: 'Localizando o trecho na Câmara', cutting: 'Cortando o trecho', metadata: 'Gerando título e descrição', publishing: 'Publicando a página do corte', } const isSpeechCutStep = (value: unknown): value is SpeechCutStep => SPEECH_CUT_STEPS.includes(value as SpeechCutStep) /** * C169 — the causes the job and the reaper store in `error` (the operator's * detail) and the honest pt-BR copy the dialog shows for each one. Only these * literals — or the failure step — ever reach the person; a raw transport * message (URL, stderr) stays in the admin. */ export const SPEECH_CUT_FAILURE_SPEECH_GONE = 'A fala deste corte não está mais disponível.' export const SPEECH_CUT_FAILURE_GENERATING = 'A Câmara ainda está gerando o vídeo deste trecho.' export const SPEECH_CUT_FAILURE_UNAVAILABLE = 'A Câmara não entregou o arquivo deste trecho.' export const SPEECH_CUT_FAILURE_UNPLAYABLE = 'A Câmara não entregou um arquivo jogável deste trecho.' export const SPEECH_CUT_FAILURE_INTERRUPTED = 'O corte foi interrompido antes de terminar.' const SPEECH_CUT_FAILURE_INELIGIBLE_COPY = 'Esta fala não tem trecho de vídeo para cortar.' const SPEECH_CUT_FAILURE_UNAVAILABLE_COPY = 'A Câmara não disponibiliza mais o arquivo deste trecho.' const SPEECH_CUT_FAILURE_UNPLAYABLE_COPY = 'A Câmara não disponibilizou um arquivo válido deste trecho.' const SPEECH_CUT_FAILURE_RESOLVING_COPY = 'Não foi possível localizar o trecho na Câmara.' const SPEECH_CUT_FAILURE_CUTTING_COPY = 'Não foi possível cortar o trecho.' const SPEECH_CUT_FAILURE_STORING_COPY = 'Não foi possível guardar o arquivo do corte.' const SPEECH_CUT_FAILURE_UNKNOWN_COPY = 'Não foi possível preparar o corte.' /** * Maps the stored cause of a failed cut to what the person reads: the exact * literal when the job named it, otherwise the failure step (cortar ≠ guardar), * otherwise a generic-but-honest line. Null when the row stored no cause at all. */ export const speechCutFailureMessage = ({ error, step, }: { error?: string | null step?: SpeechCutStep | null }): string | null => { const stored = error?.trim() if (!stored) return null if (stored === SPEECH_CUT_FAILURE_SPEECH_GONE) return SPEECH_CUT_FAILURE_SPEECH_GONE if (stored === SPEECH_VOD_INELIGIBLE_MESSAGE) return SPEECH_CUT_FAILURE_INELIGIBLE_COPY if (stored === SPEECH_CUT_FAILURE_GENERATING) return SPEECH_CUT_FAILURE_GENERATING if (stored === SPEECH_CUT_FAILURE_UNAVAILABLE) return SPEECH_CUT_FAILURE_UNAVAILABLE_COPY if (stored === SPEECH_CUT_FAILURE_UNPLAYABLE) return SPEECH_CUT_FAILURE_UNPLAYABLE_COPY if (stored === SPEECH_CUT_FAILURE_INTERRUPTED) return SPEECH_CUT_FAILURE_INTERRUPTED if (step === 'resolving') return SPEECH_CUT_FAILURE_RESOLVING_COPY if (step === 'cutting') return SPEECH_CUT_FAILURE_CUTTING_COPY if (step === 'metadata' || step === 'publishing') return SPEECH_CUT_FAILURE_STORING_COPY return SPEECH_CUT_FAILURE_UNKNOWN_COPY } type SpeechCutStepState = { step: SpeechCutStep label: string state: 'done' | 'current' | 'queued' } /** Step list of a running cut: everything before `current` is done, the rest queued. */ export const speechCutStepStates = (current: SpeechCutStep | null): SpeechCutStepState[] => { const currentIndex = current ? SPEECH_CUT_STEPS.indexOf(current) : -1 return SPEECH_CUT_STEPS.map((step, index) => ({ step, label: speechCutStepLabels[step], state: index < currentIndex ? 'done' : index === currentIndex ? 'current' : 'queued', })) } const FALLBACK_SPEECH_TYPE = 'fala' /** Trim + clip used by every metadata path (fallback and AI output). */ export const clipSpeechCutText = (value: string, maximum: number): string => value.slice(0, maximum).trim() /** * Deterministic fallback for title/description when the AI is unavailable: the * title always names the excerpt (`Trecho de — `) and the * description prefers the official summary. Never empty — both fields are * required on the row and on the public page. */ export const buildSpeechCutFallbackMetadata = ({ speechType, dateLabel, summary, }: { speechType: string | null dateLabel: string summary: string | null }): { title: string; description: string } => { const type = speechType?.trim() || FALLBACK_SPEECH_TYPE return { title: clipSpeechCutText(`Trecho de ${type} — ${dateLabel}`, SPEECH_CUT_TITLE_MAX_LENGTH), description: clipSpeechCutText( summary?.trim() || `Trecho de ${type} de ${dateLabel}, na Câmara dos Deputados.`, SPEECH_CUT_DESCRIPTION_MAX_LENGTH, ), } } type SpeechCutFfmpegInput = { inputPath: string outputPath: string startSeconds: number endSeconds: number } /** * Exact-cut command: `-ss` before `-i` seeks fast and the re-encode (no * `-c copy`) starts the output exactly at the picked second instead of the * previous keyframe. `-t` carries the duration so a timestamp offset in the * source cannot shift the end; `+faststart` lets the proxied MP4 play * progressively. Args are returned as an array — never a shell string. */ export const buildSpeechCutFfmpegArgs = ({ inputPath, outputPath, startSeconds, endSeconds, }: SpeechCutFfmpegInput): string[] => { const start = Math.max(0, Math.round(startSeconds)) const duration = Math.max(0, Math.round(endSeconds) - start) return [ '-nostdin', '-hide_banner', '-y', '-ss', String(start), '-i', inputPath, '-t', String(duration), '-map', '0:v:0', '-map', '0:a?', '-c:v', 'libx264', '-preset', 'veryfast', '-crf', '20', '-pix_fmt', 'yuv420p', '-c:a', 'aac', '-b:a', '128k', '-movflags', '+faststart', outputPath, ] } /** Public, unlisted page of a cut — the numeric id survives a title edit. */ export const speechCutPublicPath = (id: number): string => `/corte/${id}` type SpeechCutShare = { url: string message: string whatsAppUrl: string } /** Share payload of a published cut: the title followed by the public link. */ export const buildSpeechCutShare = ({ title, url, }: { title: string url: string }): SpeechCutShare => { const message = `${title.trim()} ${url}` return { url, message, whatsAppUrl: buildWhatsAppTextShareUrl(message) } } type CutMediaRecord = { url?: string | null; filename?: string | null } type CutSpeechRecord = { /** Populated at depth 1; absent when the relation was not expanded. */ id?: number youtubeUrl?: string | null type?: string | null phase?: string | null speechAt?: string | null } type CutDurationRecord = { durationSeconds?: number | null } export type SpeechCutRecordForView = CutDurationRecord & { id: number status: string step?: string | null title: string description: string startSeconds: number endSeconds: number media?: number | CutMediaRecord | null speech?: number | CutSpeechRecord | null publishedAt?: string | null createdAt?: string | null updatedAt?: string | null /** Raw internal cause of a failure; never handed to the client as-is. */ error?: string | null } export type SpeechCutViewModel = { id: number status: SpeechCutStatus step: SpeechCutStep | null title: string description: string startSeconds: number endSeconds: number /** Stored span, stable even if the speech row changes; falls back to end − start. */ durationSeconds: number durationLabel: string publicPath: string mediaUrl: string | null mediaFilename: string | null youtubeVideoId: string | null publishedAt: string | null /** C169 — honest cause of a failure; null unless `status === 'failed'`. */ failureMessage: string | null } /** * C180 — the narrow view of a cut for the discovery surfaces ("Cortes desta * fala" and the search nesting): everything they render, nothing they don't * (`media*`/`publicPath`/`youtubeVideoId`/`failureMessage`/`step` stay out). */ export type SpeechCutSummaryViewModel = { id: number status: SpeechCutStatus title: string description: string /** Stored span, stable even if the speech row changes; falls back to end − start. */ durationSeconds: number } /** * The shape the summary mapper needs. Payload does not reflect `select` in the * result type, so this must stay field-for-field with `speechCutSummarySelect` * in `utilities/speech/speechCutPageData.ts`. */ type SpeechCutSummaryRecord = Pick< SpeechCutRecordForView, 'id' | 'status' | 'title' | 'description' | 'startSeconds' | 'endSeconds' | 'durationSeconds' > const mediaOf = (media: SpeechCutRecordForView['media']): CutMediaRecord | null => typeof media === 'object' && media !== null ? media : null const youtubeUrlOf = (speech: SpeechCutRecordForView['speech']): string | null => typeof speech === 'object' && speech !== null ? (speech.youtubeUrl ?? null) : null /** Stored span when present, else the raw [start, end] fallback, floored at 0. */ const cutDurationSeconds = (record: SpeechCutSummaryRecord): number => typeof record.durationSeconds === 'number' && Number.isFinite(record.durationSeconds) ? Math.max(0, Math.round(record.durationSeconds)) : Math.max(0, Math.round(record.endSeconds) - Math.round(record.startSeconds)) /** * Internal/status wire view of a cut. `error` and `createdBy` never pass * through here: the public page and the dialog receive only what they render — * a failure becomes the mapped `failureMessage`, never the raw detail. */ export const toSpeechCutViewModel = (record: SpeechCutRecordForView): SpeechCutViewModel => { const media = mediaOf(record.media) const status = isSpeechCutStatus(record.status) ? record.status : 'failed' const step = isSpeechCutStep(record.step) ? record.step : null const durationSeconds = cutDurationSeconds(record) return { id: record.id, status, step, title: record.title, description: record.description, startSeconds: record.startSeconds, endSeconds: record.endSeconds, durationSeconds, durationLabel: formatSpeechSpan(durationSeconds), publicPath: speechCutPublicPath(record.id), mediaUrl: media?.url ?? null, mediaFilename: media?.filename ?? null, youtubeVideoId: parseYoutubeVideoId(youtubeUrlOf(record.speech)), publishedAt: record.publishedAt ?? null, failureMessage: status === 'failed' ? speechCutFailureMessage({ error: record.error, step }) : null, } } /** * C180 — narrow view of a cut for the discovery surfaces, reusing the same * duration fallback and status normalization as the full view (an unknown * status still degrades to `failed`, so the badge never receives a stray value). */ export const toSpeechCutSummaryViewModel = ( record: SpeechCutSummaryRecord, ): SpeechCutSummaryViewModel => ({ id: record.id, status: isSpeechCutStatus(record.status) ? record.status : 'failed', title: record.title, description: record.description, durationSeconds: cutDurationSeconds(record), }) /** * C180 — volume ceiling for the origin `id in` the acervo ORs into its textual * `where`. A common term can match thousands of cuts; the cap degrades the * "origin-only" surface (fewer origin speeches), never the result's correctness * — the "Fala de origem" flag comes from the predicate, not from these ids. * Revisit when the catalog approaches ~5–10k cuts (same trigger as C168). */ export const SPEECH_CUT_ORIGIN_SPEECH_ID_LIMIT = 200 /** * Deduped origin speech ids of a batch of cuts, capped at `limit`. Depth 0 * keeps an unresolved relation as the raw id; a populated relation contributes * its `.id`, and a missing/null relation is skipped. */ export const originSpeechIdsOfCuts = ( cuts: readonly { speech?: number | CutSpeechRecord | null }[], limit = SPEECH_CUT_ORIGIN_SPEECH_ID_LIMIT, ): number[] => { const ids = new Set() for (const cut of cuts) { if (ids.size >= limit) break const speechId = typeof cut.speech === 'number' ? cut.speech : cut.speech?.id if (typeof speechId === 'number') ids.add(speechId) } return [...ids] } /** Origin of a cut in the library: the speech it was cut from, with its link. */ export type SpeechCutOriginViewModel = { id: number /** "Fala · Breves Comunicações · 11/08/2026" — readiness degrades, never empty. */ label: string href: string } export type SpeechCutLibraryItemViewModel = SpeechCutViewModel & { /** "Criado em dd/mm/aaaa" date part; null when the row has no timestamp. */ createdAtLabel: string | null /** Null when the source speech was deleted (FK `SET NULL`) — degrade, don't break. */ origin: SpeechCutOriginViewModel | null } const formatCreatedAtLabel = (createdAt: string | null | undefined): string | null => { if (!createdAt) return null const instant = new Date(createdAt) if (Number.isNaN(instant.getTime())) return null return formatSpeechDate(formatBahiaCivilDate(instant)) } const originOf = (speech: SpeechCutRecordForView['speech']): SpeechCutOriginViewModel | null => { if (typeof speech !== 'object' || speech === null || typeof speech.id !== 'number') return null const dateLabel = speech.speechAt ? formatSpeechDate(speech.speechAt) : null const label = [speech.type ?? 'Fala', speech.phase, dateLabel].filter(Boolean).join(' · ') || 'Fala' return { id: speech.id, label, href: `${CAMPAIGN_COMMUNICATION_ACERVO}/${speech.id}` } } /** * C168 — library view of a cut: the base view plus the origin speech (label + * link to the acervo) and the creation date the list shows. `error` and * `createdBy` still never pass through (a failure travels as `failureMessage`). */ export const toSpeechCutLibraryItemViewModel = ( record: SpeechCutRecordForView, ): SpeechCutLibraryItemViewModel => ({ ...toSpeechCutViewModel(record), createdAtLabel: formatCreatedAtLabel(record.createdAt), origin: originOf(record.speech), })