subforge
Version:
High-performance subtitle toolkit for parsing, converting, and authoring across 20+ formats.
106 lines (88 loc) • 3.42 kB
text/typescript
import type { SubtitleDocument } from '../../../core/types.ts'
import { encodeCEA608Text, CONTROL_CODES } from './cea608.ts'
const FRAME_RATE = 29.97
// Pre-computed hex digits for fast lookup
const HEX_CHARS = '0123456789abcdef'
// Pre-computed control codes as hex strings
const RCL_HEX = formatHexPairStatic((CONTROL_CODES.RCL >> 8) & 0xff, CONTROL_CODES.RCL & 0xff)
const EOC_HEX = formatHexPairStatic((CONTROL_CODES.EOC >> 8) & 0xff, CONTROL_CODES.EOC & 0xff)
/**
* Formats two bytes as a 4-digit hexadecimal pair for SCC encoding.
*/
function formatHexPairStatic(b1: number, b2: number): string {
return HEX_CHARS[(b1 >> 4) & 0xf] + HEX_CHARS[b1 & 0xf] + HEX_CHARS[(b2 >> 4) & 0xf] + HEX_CHARS[b2 & 0xf]
}
/**
* Formats milliseconds to SCC drop-frame timecode format (HH:MM:SS;FF).
*
* Uses 29.97 fps frame rate and semicolon separator to indicate drop-frame format.
*
* @param ms - Time in milliseconds
* @returns Formatted timecode string in HH:MM:SS;FF format
*/
function formatTimecode(ms: number): string {
const totalSeconds = Math.floor(ms / 1000)
const hours = Math.floor(totalSeconds / 3600)
const minutes = Math.floor((totalSeconds % 3600) / 60)
const seconds = totalSeconds % 60
// Calculate frames from remaining milliseconds
const remainingMs = ms % 1000
const frames = Math.floor(remainingMs * FRAME_RATE / 1000)
// Use semicolon for drop-frame (29.97 fps)
return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')};${String(frames).padStart(2, '0')}`
}
/**
* Converts a subtitle document to SCC (Scenarist Closed Caption) format.
*
* Generates SCC file with proper header and CEA-608 encoded captions.
* Each caption is wrapped with RCL (Resume Caption Loading) and EOC (End of Caption) commands.
* Uses 29.97 fps drop-frame timecode format.
*
* @param doc - Subtitle document to convert
* @returns SCC formatted string with header and caption data
*
* @example
* ```ts
* const doc = createDocument();
* doc.events.push({
* start: 0,
* end: 3000,
* text: 'Hello, World!',
* // ... other event properties
* });
* const sccContent = toSCC(doc);
* ```
*/
export function toSCC(doc: SubtitleDocument): string {
const events = doc.events
const len = events.length
// Handle empty document
if (len === 0) {
return 'Scenarist_SCC V1.0\n\n'
}
// Pre-allocate output array (2 lines per event + header)
const lines: string[] = ['Scenarist_SCC V1.0', '']
for (let i = 0; i < len; i++) {
const event = events[i]!
const text = event.text
// Start caption at event start time
const startTime = formatTimecode(event.start)
// Build hex pairs string directly
let hexData = RCL_HEX + ' ' + RCL_HEX
// Add text characters
const textBytes = encodeCEA608Text(text)
const bytesLen = textBytes.length
for (let j = 0; j < bytesLen; j += 2) {
const b1 = textBytes[j]!
const b2 = textBytes[j + 1] ?? 0
hexData += ' ' + HEX_CHARS[(b1 >> 4) & 0xf] + HEX_CHARS[b1 & 0xf] + HEX_CHARS[(b2 >> 4) & 0xf] + HEX_CHARS[b2 & 0xf]
}
lines[lines.length] = startTime + '\t' + hexData
lines[lines.length] = ''
// End caption at event end time
const endTime = formatTimecode(event.end)
lines[lines.length] = endTime + '\t' + EOC_HEX + ' ' + EOC_HEX
lines[lines.length] = ''
}
return lines.join('\n')
}