Spaces:
Sleeping
Sleeping
| /** | |
| * 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; | |
| } | |