import type { SubtitleSegment } from "../asr/types.js"; /** * Options for customising the ASS [V4+ Styles] section. * Every field is optional — omitted values fall back to the built-in defaults. * * Colour strings use the ASS `&HAABBGGRR` hex format, e.g. `&H00FFFFFF` for white. */ export interface AssStyleOptions { /** Font name (default: `"Arial"`). */ fontName?: string; /** Font size in points (default: `14`). */ fontSize?: number; /** Primary (fill) colour in `&HAABBGGRR` format (default: `&H00FFFFFF` — white). */ primaryColour?: string; /** Secondary colour / karaoke fill (default: `&H000000FF` — red). */ secondaryColour?: string; /** Outline colour (default: `&H00000000` — black). */ outlineColour?: string; /** Back/shadow colour (default: `&H80000000` — semi-transparent black). */ backColour?: string; /** Bold: `-1` = true, `0` = false (default: `-1`). */ bold?: number; /** Italic: `-1` = true, `0` = false (default: `0`). */ italic?: number; /** Underline: `-1` = true, `0` = false (default: `0`). */ underline?: number; /** StrikeOut: `-1` = true, `0` = false (default: `0`). */ strikeOut?: number; /** Horizontal scale % (default: `100`). */ scaleX?: number; /** Vertical scale % (default: `100`). */ scaleY?: number; /** Letter spacing in pixels (default: `0`). */ spacing?: number; /** Rotation angle in degrees (default: `0`). */ angle?: number; /** Border style: `1` = outline + drop shadow, `3` = opaque box (default: `1`). */ borderStyle?: number; /** Outline width in pixels (default: `2`). */ outline?: number; /** Shadow depth in pixels (default: `2`). */ shadow?: number; /** * Alignment: 1=bottom-left … 3=bottom-right, * 4=mid-left … 6=mid-right, 7=top-left … 9=top-right (default: `2` = bottom-centre). */ alignment?: number; /** Left margin in pixels (default: `20`). */ marginL?: number; /** Right margin in pixels (default: `20`). */ marginR?: number; /** Vertical margin in pixels (default: `20`). */ marginV?: number; } /** Built-in defaults applied when no style options are provided. */ const DEFAULT_STYLE: Required = { fontName: "Arial", fontSize: 14, primaryColour: "&H00FFFFFF", secondaryColour: "&H000000FF", outlineColour: "&H00000000", backColour: "&H80000000", bold: -1, italic: 0, underline: 0, strikeOut: 0, scaleX: 100, scaleY: 100, spacing: 0, angle: 0, borderStyle: 1, outline: 2, shadow: 2, alignment: 2, marginL: 20, marginR: 20, marginV: 20, }; function msToAssTime(ms: number): string { const h = Math.floor(ms / 3_600_000); const m = Math.floor((ms % 3_600_000) / 60_000); const s = Math.floor((ms % 60_000) / 1_000); const cs = Math.floor((ms % 1_000) / 10); return [ String(h), String(m).padStart(2, "0"), String(s).padStart(2, "0"), ].join(":") + "." + String(cs).padStart(2, "0"); } /** Build the `[V4+ Styles]` section from partial options merged with defaults. */ function buildStyleSection(opts?: AssStyleOptions): string { const s = { ...DEFAULT_STYLE, ...opts }; const formatLine = [ "Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour,", "OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut,", "ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow,", "Alignment, MarginL, MarginR, MarginV, Encoding", ].join(""); const styleLine = [ "Style: Default,", s.fontName, ",", s.fontSize, ",", s.primaryColour, ",", s.secondaryColour, ",", s.outlineColour, ",", s.backColour, ",", s.bold, ",", s.italic, ",", s.underline, ",", s.strikeOut, ",", s.scaleX, ",", s.scaleY, ",", s.spacing, ",", s.angle, ",", s.borderStyle, ",", s.outline, ",", s.shadow, ",", s.alignment, ",", s.marginL, ",", s.marginR, ",", s.marginV, ",", "1", ].join(""); return ["[V4+ Styles]", formatLine, styleLine].join("\n"); } /** * Convert segments to ASS (Advanced SubStation Alpha) format. * * By default a built-in style is used (Arial/14pt white-on-black). * Pass {@link AssStyleOptions} to customise font, colours, layout, etc. */ export function toAss( segments: SubtitleSegment[], assStyle?: AssStyleOptions, ): string { const header = [ "[Script Info]", "Title: Generated by jianying-subtitle", "ScriptType: v4.00+", "WrapStyle: 0", "ScaledBorderAndShadow: yes", "YCbCr Matrix: None", "", buildStyleSection(assStyle), "", "[Events]", [ "Format: Layer, Start, End, Style, Name, MarginL,", "MarginR, MarginV, Effect, Text", ].join(""), ].join("\n"); const events = segments .map((seg) => { return [ "Dialogue: 0,", msToAssTime(seg.startMs), ",", msToAssTime(seg.endMs), ",Default,,0,0,0,,", seg.text, ].join(""); }) .join("\n"); return header + "\n" + events + "\n"; }