/** * C199 — pure rules of an uploaded recording: the status/step vocabulary and * the honest failure copy. No I/O and no `server-only`: the client dialog, the * JSON routes, the job and the unit tests share this module. */ import { CAMPAIGN_COMMUNICATION_ACERVO_GRAVACOES, campaignRecordingDeleteHref, campaignRecordingFileHref, campaignRecordingRetryHref, } from '@/lib/campaignPaths' import { formatSpeechClock, formatSpeechDate } from '@/lib/speechClock' /** Owner of the private upload collection slug (collection, config and routes). */ export const RECORDING_MEDIA_SLUG = 'recordingMedia' as const export const RECORDING_STATUSES = ['uploading', 'processing', 'ready', 'failed'] as const export type RecordingStatus = (typeof RECORDING_STATUSES)[number] export const recordingStatusLabels: Record = { uploading: 'Enviando', processing: 'Processando', ready: 'Pronto', failed: 'Falhou', } export const isRecordingStatus = (value: unknown): value is RecordingStatus => RECORDING_STATUSES.includes(value as RecordingStatus) /** One owner for the persisted field limits (collection, schema and dialog). */ export const RECORDING_TITLE_MAX_LENGTH = 200 /** * Every request body that crosses the production edge (Cloudflare Tunnel) must * stay under the edge's request ceiling — a bigger single request is refused * with `413 Payload Too Large` BEFORE the app sees it (probed against prod: * 150 MB answered 413 after ~3 MB). The dialog therefore slices the file into * parts of this size; 16 MiB keeps a wide margin under the ~100 MB edge limit * and still moves hours of video in few requests. */ export const RECORDING_UPLOAD_CHUNK_BYTES = 16 * 1024 * 1024 /** The edge ceiling the chunk size must stay under (Cloudflare: ~100 MB). */ export const RECORDING_UPLOAD_EDGE_LIMIT_BYTES = 100 * 1024 * 1024 /** * Free-space margin the upload start demands beyond the declared file size: * the temp copy must fit with room for the post-upload job (which downloads * the same file again) and the running container. Failing here is honest and * immediate; failing mid-way wastes hours of transfer. */ const RECORDING_UPLOAD_DISK_MARGIN_BYTES = 512 * 1024 * 1024 /** Pure budget check of the upload start (the fs call lives in the session owner). */ export const recordingUploadFitsInDisk = ({ freeBytes, expectedBytes, marginBytes = RECORDING_UPLOAD_DISK_MARGIN_BYTES, }: { freeBytes: number expectedBytes: number marginBytes?: number }): boolean => Number.isFinite(freeBytes) && freeBytes - expectedBytes >= marginBytes /** A proxy/edge refused the request body (413) before the app ever saw it. */ export const RECORDING_UPLOAD_REJECTED_MESSAGE = 'O servidor recusou uma parte do envio por tamanho. Tente novamente; se persistir, avise a equipe.' /** C200 — the human label the team types for one speaker cluster. */ export const RECORDING_SPEAKER_LABEL_MAX_LENGTH = 120 export const RECORDING_SPEAKER_LABEL_REQUIRED_MESSAGE = 'Informe o nome do falante.' export const RECORDING_SPEAKER_LABEL_LONG_MESSAGE = `O nome do falante deve ter até ${RECORDING_SPEAKER_LABEL_MAX_LENGTH} caracteres.` /** Accepted upload type: any video container (`.mkv` often arrives with no type). */ const RECORDING_ACCEPTED_MIME_PREFIX = 'video/' export const RECORDING_STEPS = ['extracting', 'transcribing', 'saving'] as const export type RecordingStep = (typeof RECORDING_STEPS)[number] export const recordingStepLabels: Record = { extracting: 'Preparando o áudio da gravação', transcribing: 'Transcrevendo a gravação', saving: 'Guardando a transcrição', } const isRecordingStep = (value: unknown): value is RecordingStep => RECORDING_STEPS.includes(value as RecordingStep) /** * C199 — the causes the job and the reaper store in `error` (the operator's * detail) and the honest pt-BR copy the detail shows. A raw transport message * (URL, stderr) stays in the admin. */ export const RECORDING_FAILURE_INTERRUPTED = 'O processamento foi interrompido antes de terminar.' const RECORDING_FAILURE_EXTRACTING_COPY = 'Não foi possível preparar o áudio da gravação.' const RECORDING_FAILURE_TRANSCRIBING_COPY = 'Não foi possível transcrever a gravação.' const RECORDING_FAILURE_SAVING_COPY = 'Não foi possível guardar a transcrição.' const RECORDING_FAILURE_UNKNOWN_COPY = 'Não foi possível transcrever a gravação.' /** * Maps the stored cause of a failed recording to what the person reads: the * exact literal when the job named it, otherwise the failure step, otherwise a * generic-but-honest line. Null when the row stored no cause at all. */ export const recordingFailureMessage = ({ error, step, }: { error?: string | null step?: RecordingStep | null }): string | null => { const stored = error?.trim() if (!stored) return null if (stored === RECORDING_FAILURE_INTERRUPTED) return RECORDING_FAILURE_INTERRUPTED if (step === 'extracting') return RECORDING_FAILURE_EXTRACTING_COPY if (step === 'transcribing') return RECORDING_FAILURE_TRANSCRIBING_COPY if (step === 'saving') return RECORDING_FAILURE_SAVING_COPY return RECORDING_FAILURE_UNKNOWN_COPY } /** * The recording file (player/download) only exists once the upload stream * finished: `uploading` has no stored object yet, every other status serves it. */ export const canServeRecordingMedia = (status: RecordingStatus): boolean => status !== 'uploading' /** The retry button only applies to a failed transcription. */ export const canRetryRecording = (status: RecordingStatus): boolean => status === 'failed' /** C200 — dialog copy of the human identification step (design scenes 2/5). */ export const RECORDING_SPEAKER_DIALOG_TITLE = 'Identificar falante' export const RECORDING_SPEAKER_DIALOG_INTRO = 'Ouça um trecho e informe quem é a pessoa. O acervo não sugere nem identifica pessoas automaticamente.' export const RECORDING_SPEAKER_DIALOG_HELP = 'O nome será aplicado a todas as falas deste agrupamento nesta gravação.' export const RECORDING_SPEAKER_LABEL_FIELD_LABEL = 'Nome do falante' export const RECORDING_SPEAKER_LABEL_PLACEHOLDER = 'Digite o nome do falante' export const RECORDING_SPEAKER_SAVE_LABEL = 'Salvar identificação' /** C200 — the transcript banner of the grouped detail (design scenes 1/4/5). */ export const RECORDING_SPEAKER_INFO_BANNER = 'Os falantes são agrupados automaticamente pelo áudio. A identificação de cada pessoa é feita pela equipe.' export const RECORDING_SPEAKER_INFO_BANNER_MOBILE = 'Agrupamento automático e aproximado. A identificação é feita pela equipe.' export const RECORDING_SPEAKER_BANNER_TITLE = 'A separação por falante pode estar imprecisa' export const RECORDING_SPEAKER_BANNER_BODY = 'O agrupamento é automático e aproximado. Ruído e falas sobrepostas podem colocar trechos no agrupamento errado. A equipe continua responsável por identificar cada falante.' export const RECORDING_SPEAKER_BANNER_DROPPED = 'Esta gravação foi reprocessada e algumas identificações não puderam ser mantidas. Confira os falantes.' export const RECORDING_SPEAKER_GROUP_UNIDENTIFIED = 'Agrupamento sem identificação' export const RECORDING_SPEAKER_GROUP_IDENTIFIED = 'Identificação feita pela equipe' export const RECORDING_SPEAKER_IDENTIFY_ACTION = 'Identificar falante' export const RECORDING_SPEAKER_EDIT_ACTION = 'Editar identificação' /** Errors of the upload form (client validation). Wire messages live in schemas. */ export const RECORDING_FILE_REQUIRED_MESSAGE = 'Escolha um arquivo de vídeo para enviar.' export const RECORDING_TITLE_REQUIRED_MESSAGE = 'Informe um título para a gravação.' export const RECORDING_FILE_TYPE_MESSAGE = 'Envie um arquivo de vídeo (MP4, MOV, MKV e formatos compatíveis).' /** A missing type is accepted: `.mkv` often arrives with no `type` from the OS. */ export const recordingFileTypeAllowed = (mimeType: string | null | undefined): boolean => !mimeType || mimeType.startsWith(RECORDING_ACCEPTED_MIME_PREFIX) /** Basename without path parts or unsafe characters, keeping the extension. */ export const sanitizeRecordingFilename = (filename: string): string => { const base = filename.split(/[\\/]/).pop() ?? '' const safe = base.replace(/[^\w.-]+/g, '_').replace(/^[._]+/, '') return safe || 'gravacao' } /** Human-readable file size in pt-BR (`4,6 GB`, `812 MB`, `900 KB`, `12 B`). */ export const formatRecordingFileSize = (bytes: number): string => { if (!Number.isFinite(bytes) || bytes < 0) return '—' if (bytes >= 1024 ** 3) return `${(bytes / 1024 ** 3).toLocaleString('pt-BR', { maximumFractionDigits: 1 })} GB` if (bytes >= 1024 ** 2) return `${Math.round(bytes / 1024 ** 2).toLocaleString('pt-BR')} MB` if (bytes >= 1024) return `${Math.round(bytes / 1024).toLocaleString('pt-BR')} KB` return `${Math.round(bytes)} B` } export type RecordingViewModel = { id: number title: string status: RecordingStatus step: RecordingStep | null /** `dd/mm/aaaa` when the row has a date; null otherwise. */ recordedAtLabel: string | null durationLabel: string | null /** Raw duration for the detail player; null while it is unknown. */ durationSeconds: number | null detailHref: string fileHref: string downloadHref: string retryHref: string deleteHref: string } type RecordingViewRecord = { id: number title?: string | null status: string step?: string | null recordedAt?: string | null durationSeconds?: number | null } const dateLabelOf = (value: string | null | undefined): string | null => /^(\d{4})-(\d{2})-(\d{2})/.test(value ?? '') && value ? formatSpeechDate(value) : null const durationLabelOf = (seconds: number | null | undefined): string | null => typeof seconds === 'number' && Number.isFinite(seconds) && seconds > 0 ? formatSpeechClock(seconds) : null const normalizedStatus = (value: string): RecordingStatus => isRecordingStatus(value) ? value : 'failed' /** * Internal/status wire view of a recording. `error` and `createdBy` never pass * through here: the detail receives only what it renders — a failure becomes * the mapped `failureMessage`, never the raw detail. */ export const toRecordingViewModel = (record: RecordingViewRecord): RecordingViewModel => { const status = normalizedStatus(record.status) return { id: record.id, title: record.title?.trim() || `Gravação ${record.id}`, status, step: isRecordingStep(record.step) ? record.step : null, recordedAtLabel: dateLabelOf(record.recordedAt), durationLabel: durationLabelOf(record.durationSeconds), durationSeconds: typeof record.durationSeconds === 'number' && Number.isFinite(record.durationSeconds) ? Math.max(0, record.durationSeconds) : null, detailHref: `${CAMPAIGN_COMMUNICATION_ACERVO_GRAVACOES}/${record.id}`, fileHref: campaignRecordingFileHref(record.id), downloadHref: campaignRecordingFileHref(record.id, true), retryHref: campaignRecordingRetryHref(record.id), deleteHref: campaignRecordingDeleteHref(record.id), } }