export type SubtitleEraseMode = "smart" | "region"; export type SubtitleEraseRegion = { height: number; id: string; width: number; x: number; y: number; }; export type SubtitleEraseRegionHandle = "nw" | "ne" | "se" | "sw"; export type VideoCanvasSubtitleEraseConfig = { blurRadius: number; edgeThreshold: number; enabled: boolean; frameRate: number; minBandHeightRatio: number; model: string; paddingRatio: number; smartSearchEndRatio: number; smartSearchStartRatio: number; }; export const defaultVideoCanvasSubtitleEraseConfig: VideoCanvasSubtitleEraseConfig = { blurRadius: 10, edgeThreshold: 44, enabled: true, frameRate: 30, minBandHeightRatio: 0.018, model: "volcano-subtitle-eraser", paddingRatio: 0.012, smartSearchEndRatio: 0.96, smartSearchStartRatio: 0.5, }; function clamp(value: number, min: number, max: number) { return Math.min(max, Math.max(min, value)); } function finiteNumber(value: unknown, fallback: number, min: number, max: number) { const parsed = Number(value); return Number.isFinite(parsed) ? clamp(parsed, min, max) : fallback; } export function normalizeVideoCanvasSubtitleEraseConfig( value: unknown, ): VideoCanvasSubtitleEraseConfig { const incoming = value && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; const start = finiteNumber( incoming.smartSearchStartRatio, defaultVideoCanvasSubtitleEraseConfig.smartSearchStartRatio, 0, 0.94, ); const end = finiteNumber( incoming.smartSearchEndRatio, defaultVideoCanvasSubtitleEraseConfig.smartSearchEndRatio, start + 0.04, 1, ); return { blurRadius: finiteNumber( incoming.blurRadius, defaultVideoCanvasSubtitleEraseConfig.blurRadius, 0, 40, ), edgeThreshold: finiteNumber( incoming.edgeThreshold, defaultVideoCanvasSubtitleEraseConfig.edgeThreshold, 1, 255, ), enabled: typeof incoming.enabled === "boolean" ? incoming.enabled : defaultVideoCanvasSubtitleEraseConfig.enabled, frameRate: Math.round(finiteNumber( incoming.frameRate, defaultVideoCanvasSubtitleEraseConfig.frameRate, 12, 60, )), minBandHeightRatio: finiteNumber( incoming.minBandHeightRatio, defaultVideoCanvasSubtitleEraseConfig.minBandHeightRatio, 0.005, 0.2, ), model: typeof incoming.model === "string" && incoming.model.trim() ? incoming.model.trim().slice(0, 100) : defaultVideoCanvasSubtitleEraseConfig.model, paddingRatio: finiteNumber( incoming.paddingRatio, defaultVideoCanvasSubtitleEraseConfig.paddingRatio, 0, 0.1, ), smartSearchEndRatio: Number(Math.max(start + 0.04, end).toFixed(4)), smartSearchStartRatio: start, }; } export function normalizeSubtitleEraseRegion( region: SubtitleEraseRegion, ): SubtitleEraseRegion { const x = clamp(Number(region.x) || 0, 0, 1); const y = clamp(Number(region.y) || 0, 0, 1); const normalizedWidth = clamp(Number(region.width) || 0, 0, 1 - x); const normalizedHeight = clamp(Number(region.height) || 0, 0, 1 - y); return { height: Number(normalizedHeight.toFixed(6)), id: region.id, width: Number(normalizedWidth.toFixed(6)), x: Number(x.toFixed(6)), y: Number(y.toFixed(6)), }; } export function moveSubtitleEraseRegion( region: SubtitleEraseRegion, deltaX: number, deltaY: number, ) { const normalized = normalizeSubtitleEraseRegion(region); return { ...normalized, x: clamp(normalized.x + deltaX, 0, 1 - normalized.width), y: clamp(normalized.y + deltaY, 0, 1 - normalized.height), }; } export function resizeSubtitleEraseRegion( region: SubtitleEraseRegion, handle: SubtitleEraseRegionHandle, pointX: number, pointY: number, minimumSize = 0.015, ) { const normalized = normalizeSubtitleEraseRegion(region); let left = normalized.x; let top = normalized.y; let right = normalized.x + normalized.width; let bottom = normalized.y + normalized.height; const x = clamp(pointX, 0, 1); const y = clamp(pointY, 0, 1); if (handle.includes("w")) left = Math.min(x, right - minimumSize); if (handle.includes("e")) right = Math.max(x, left + minimumSize); if (handle.includes("n")) top = Math.min(y, bottom - minimumSize); if (handle.includes("s")) bottom = Math.max(y, top + minimumSize); return normalizeSubtitleEraseRegion({ height: bottom - top, id: normalized.id, width: right - left, x: left, y: top, }); } function pixelLuma(data: Uint8ClampedArray, index: number) { return data[index] * 0.299 + data[index + 1] * 0.587 + data[index + 2] * 0.114; } export function detectSubtitleEraseRegions( imageData: ImageData, configValue: VideoCanvasSubtitleEraseConfig, ): SubtitleEraseRegion[] { const config = normalizeVideoCanvasSubtitleEraseConfig(configValue); const { data, height, width } = imageData; if (width < 8 || height < 8) return []; const startRow = Math.floor(height * config.smartSearchStartRatio); const endRow = Math.max(startRow + 1, Math.ceil(height * config.smartSearchEndRatio)); const rowScores = Array.from({ length: endRow - startRow }, () => 0); const stride = width > 900 ? 3 : 2; for (let y = startRow; y < Math.min(height - 1, endRow); y += 1) { let edges = 0; let samples = 0; for (let x = 1; x < width - 1; x += stride) { const index = (y * width + x) * 4; const horizontal = Math.abs(pixelLuma(data, index - 4) - pixelLuma(data, index + 4)); const vertical = Math.abs(pixelLuma(data, index) - pixelLuma(data, index + width * 4)); if (Math.max(horizontal, vertical) >= config.edgeThreshold) edges += 1; samples += 1; } rowScores[y - startRow] = samples > 0 ? edges / samples : 0; } const smoothed = rowScores.map((_, index) => { let total = 0; let count = 0; for (let offset = -2; offset <= 2; offset += 1) { const score = rowScores[index + offset]; if (score === undefined) continue; total += score; count += 1; } return count > 0 ? total / count : 0; }); const mean = smoothed.reduce((total, score) => total + score, 0) / smoothed.length; const variance = smoothed.reduce((total, score) => total + (score - mean) ** 2, 0) / smoothed.length; const activeThreshold = Math.max(0.025, mean + Math.sqrt(variance) * 0.55); const minimumRows = Math.max(2, Math.round(height * config.minBandHeightRatio)); const groups: Array<{ end: number; peak: number; start: number }> = []; let group: { end: number; peak: number; start: number } | null = null; for (let index = 0; index < smoothed.length; index += 1) { const score = smoothed[index]; if (score >= activeThreshold) { if (!group) group = { end: index, peak: score, start: index }; group.end = index; group.peak = Math.max(group.peak, score); } else if (group) { if (group.end - group.start + 1 >= minimumRows) groups.push(group); group = null; } } if (group && group.end - group.start + 1 >= minimumRows) groups.push(group); return groups .sort((left, right) => right.peak - left.peak) .slice(0, 3) .sort((left, right) => left.start - right.start) .map((band, index) => { const padding = Math.round(height * config.paddingRatio); const top = clamp(startRow + band.start - padding, 0, height - 1); const bottom = clamp(startRow + band.end + padding + 1, top + 1, height); return { height: (bottom - top) / height, id: `smart-subtitle-${index + 1}`, width: 0.94, x: 0.03, y: top / height, }; }); }