import { mkdir } from 'node:fs/promises'; import { join } from 'node:path'; import sharp from 'sharp'; import type { TranscriptCue } from '../mograph/beats.js'; import type { ReframeCaptionProfile, ReframeTarget } from './types.js'; export interface ReframeCaptionLayout { index: number; startSeconds: number; endSeconds: number; text: string; lines: string[]; fontSizePx: number; lineHeightPx: number; safeMarginPx: number; bottomSafeMarginPx: number; bandHeightPx: number; } export interface RenderedReframeCaption extends ReframeCaptionLayout { path: string; } interface CaptionProfileSpec { fontSizePx: number; minimumFontSizePx: number; lineHeightPx: number; safeMarginPx: number; bottomSafeMarginPx: number; bandHeightPx: number; maxLines: number; } const PROFILE_SPECS: Record = { 'social-large': { fontSizePx: 64, minimumFontSizePx: 60, lineHeightPx: 80, safeMarginPx: 48, bottomSafeMarginPx: 96, bandHeightPx: 410, maxLines: 3, }, 'social-standard': { fontSizePx: 60, minimumFontSizePx: 60, lineHeightPx: 74, safeMarginPx: 48, bottomSafeMarginPx: 96, bandHeightPx: 380, maxLines: 3, }, }; function wrapWords(text: string, maxCharacters: number): string[] { const words = text.trim().split(/\s+/).filter(Boolean); const lines: string[] = []; let current = ''; for (const word of words) { const candidate = current ? `${current} ${word}` : word; if (candidate.length <= maxCharacters || current === '') { current = candidate; } else { lines.push(current); current = word; } } if (current) lines.push(current); return lines; } function maxCharactersFor(target: ReframeTarget, spec: CaptionProfileSpec, fontSize: number): number { const usableWidth = target.width - spec.safeMarginPx * 2; return Math.max(8, Math.floor(usableWidth / (fontSize * 0.58))); } function layoutLines(text: string, target: ReframeTarget, spec: CaptionProfileSpec): { lines: string[]; fontSize: number } { for (let fontSize = spec.fontSizePx; fontSize >= spec.minimumFontSizePx; fontSize -= 2) { const lines = wrapWords(text, maxCharactersFor(target, spec, fontSize)); if (lines.length <= spec.maxLines) return { lines, fontSize }; } throw new Error(`caption cannot fit within ${spec.maxLines} lines at ${spec.minimumFontSizePx}px minimum`); } export function layoutReframeCaptions( sourceCues: TranscriptCue[], target: ReframeTarget, profile: ReframeCaptionProfile, frameRate: number, ): ReframeCaptionLayout[] { if (!Number.isFinite(frameRate) || frameRate <= 0) throw new Error('caption frameRate must be positive'); const spec = PROFILE_SPECS[profile]; const sorted = [...sourceCues].sort((left, right) => left.t0Sec - right.t0Sec); for (let index = 0; index < sorted.length; index += 1) { const cue = sorted[index]; if (!cue || !Number.isFinite(cue.t0Sec) || !Number.isFinite(cue.t1Sec) || cue.t1Sec <= cue.t0Sec) { throw new Error(`caption cue ${index} has invalid timing`); } const previous = sorted[index - 1]; if (previous && cue.t0Sec < previous.t1Sec - 0.0001) { throw new Error(`caption cue ${index} overlaps the previous cue`); } } const layouts = sorted.map((cue, index): ReframeCaptionLayout => { const lineLayout = layoutLines(cue.text, target, spec); return { index, startSeconds: cue.t0Sec, endSeconds: cue.t1Sec, text: cue.text.trim(), lines: lineLayout.lines, fontSizePx: lineLayout.fontSize, lineHeightPx: Math.round(spec.lineHeightPx * (lineLayout.fontSize / spec.fontSizePx)), safeMarginPx: spec.safeMarginPx, bottomSafeMarginPx: spec.bottomSafeMarginPx, bandHeightPx: spec.bandHeightPx, }; }); const frameSeconds = 1 / frameRate; for (let index = 1; index < layouts.length; index += 1) { const previous = layouts[index - 1]; const current = layouts[index]; if (!previous || !current) continue; if (current.startSeconds - previous.endSeconds < frameSeconds) { const adjustedEnd = current.startSeconds - frameSeconds; if (adjustedEnd <= previous.startSeconds) { throw new Error(`caption cue ${index - 1} is too short to preserve a one-frame gap`); } previous.endSeconds = adjustedEnd; } } return layouts; } function escapeXml(value: string): string { return value .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } function captionSvg(cue: ReframeCaptionLayout, target: ReframeTarget): string { const bandTop = target.height - cue.bandHeightPx; const usableBandHeight = cue.bandHeightPx - cue.bottomSafeMarginPx; const textHeight = cue.lines.length * cue.lineHeightPx; const firstBaseline = bandTop + Math.max(0, (usableBandHeight - textHeight) / 2) + cue.fontSizePx; const textNodes = cue.lines.map((line, index) => ( `${escapeXml(line)}` )).join(''); return `` + `` + textNodes + ''; } export async function renderReframeCaptionImages( cues: ReframeCaptionLayout[], target: ReframeTarget, outputDir: string, ): Promise { await mkdir(outputDir, { recursive: true }); const rendered: RenderedReframeCaption[] = []; for (const cue of cues) { const path = join(outputDir, `caption-${String(cue.index).padStart(3, '0')}.png`); await sharp(Buffer.from(captionSvg(cue, target))).png().toFile(path); rendered.push({ ...cue, path }); } return rendered; }