gts-x / src /lib /editor /validation.ts
JAMES HAN
GTS-X: Full HITL editor + QC + file converter for HuggingFace Spaces
c273232
Raw
History Blame Contribute Delete
4.14 kB
/**
* 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;
}