/** * Subtitle validation — CPL, CPS, overlap, min duration checks. */ import type { SubtitleData } from "./event-bus"; export interface ValidationIssue { type: "cpl" | "cps" | "overlap" | "min_duration" | "gap"; severity: "error" | "warning"; message: string; value?: number; limit?: number; } export interface ValidationConfig { maxCPL: number; // Max characters per line (default 42) maxCPS: number; // Max characters per second (default 25) minDurationMs: number; // Min subtitle duration in ms (default 833 = 20 frames at 24fps) minGapMs: number; // Min gap between subtitles in ms (default 83 = 2 frames at 24fps) } export const DEFAULT_VALIDATION: ValidationConfig = { maxCPL: 42, maxCPS: 25, minDurationMs: 833, minGapMs: 83, }; /** Parse a timecode string (HH:MM:SS:FF or HH:MM:SS.mmm) to milliseconds */ export function tcToMs(tc: string, fps: number = 23.976): number { if (!tc) return 0; // Handle HH:MM:SS:FF (frame-based) const frameParts = tc.match(/^(\d{2}):(\d{2}):(\d{2}):(\d{2})$/); if (frameParts) { const h = parseInt(frameParts[1]); const m = parseInt(frameParts[2]); const s = parseInt(frameParts[3]); const f = parseInt(frameParts[4]); return Math.round((h * 3600 + m * 60 + s) * 1000 + (f / fps) * 1000); } // Handle HH:MM:SS.mmm (millisecond-based, SRT style) const msParts = tc.match(/^(\d{2}):(\d{2}):(\d{2})[.,](\d{3})$/); if (msParts) { const h = parseInt(msParts[1]); const m = parseInt(msParts[2]); const s = parseInt(msParts[3]); const ms = parseInt(msParts[4]); return h * 3600000 + m * 60000 + s * 1000 + ms; } return 0; } /** Get the longest line length in a subtitle text */ export function getMaxLineLength(text: string): number { return Math.max(...text.split("\n").map((line) => line.length)); } /** Get total visible character count (excluding tags) */ export function getCharCount(text: string): number { // Strip basic formatting tags return text .replace(/<[^>]+>/g, "") .replace(/\{[^}]+\}/g, "") .replace(/\n/g, "") .length; } /** Validate a single subtitle */ export function validateSubtitle( sub: SubtitleData, index: number, allSubs: SubtitleData[], config: ValidationConfig = DEFAULT_VALIDATION, fps: number = 23.976 ): ValidationIssue[] { const issues: ValidationIssue[] = []; // CPL check const maxLine = getMaxLineLength(sub.text); if (maxLine > config.maxCPL) { issues.push({ type: "cpl", severity: "error", message: `CPL ${maxLine}/${config.maxCPL}`, value: maxLine, limit: config.maxCPL, }); } // CPS check const durationMs = tcToMs(sub.tcOut, fps) - tcToMs(sub.tcIn, fps); if (durationMs > 0) { const charCount = getCharCount(sub.text); const cps = charCount / (durationMs / 1000); if (cps > config.maxCPS) { issues.push({ type: "cps", severity: "warning", message: `CPS ${cps.toFixed(1)}/${config.maxCPS}`, value: Math.round(cps * 10) / 10, limit: config.maxCPS, }); } } // Min duration check if (durationMs > 0 && durationMs < config.minDurationMs) { issues.push({ type: "min_duration", severity: "warning", message: `Duration ${durationMs}ms < ${config.minDurationMs}ms`, value: durationMs, limit: config.minDurationMs, }); } // Overlap with next subtitle if (index < allSubs.length - 1) { const nextSub = allSubs[index + 1]; const tcOut = tcToMs(sub.tcOut, fps); const nextTcIn = tcToMs(nextSub.tcIn, fps); if (tcOut > nextTcIn) { issues.push({ type: "overlap", severity: "error", message: `Overlaps next subtitle by ${tcOut - nextTcIn}ms`, value: tcOut - nextTcIn, }); } else if (nextTcIn - tcOut < config.minGapMs && nextTcIn - tcOut > 0) { issues.push({ type: "gap", severity: "warning", message: `Gap ${nextTcIn - tcOut}ms < ${config.minGapMs}ms`, value: nextTcIn - tcOut, limit: config.minGapMs, }); } } return issues; }