import fs from 'fs'; import path from 'path'; import { tempPath, escapeXml } from './helpers.js'; function secToAssTime(s) { const sign = s < 0 ? -1 : 1; s = Math.abs(s); const h = Math.floor(s / 3600); const m = Math.floor((s % 3600) / 60); const sec = Math.floor(s % 60); const cs = Math.floor((s - Math.floor(s)) * 100); return `${h}:${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}.${String(cs).padStart(2, '0')}`; } function assColorFromHex(hex) { if (!hex) return '&H00FFFFFF'; // support simple named colors and rgb/rgba strings const named = { red: '#FF0000', white: '#FFFFFF', black: '#000000', transparent: '#00000000', blue: '#0000FF', green: '#00FF00', yellow: '#FFFF00', gray: '#808080', grey: '#808080' }; if (typeof hex === 'string' && !hex.startsWith('#')) { const key = hex.toLowerCase(); if (named[key]) hex = named[key]; else { // try to parse rgb()/rgba() const m = hex.match(/rgba?\s*\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([0-9.]+))?\)/i); if (m) { const r = parseInt(m[1], 10); const g = parseInt(m[2], 10); const b = parseInt(m[3], 10); const a = m[4] !== undefined ? Math.round(parseFloat(m[4]) * 255) : 255; const aa = a.toString(16).padStart(2, '0').toUpperCase(); hex = `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}${aa}`; } } } let h = String(hex).replace(/^#/, ''); // support RRGGBB or RRGGBBAA or short 3-char if (h.length === 3) h = h.split('').map(c => c + c).join(''); if (h.length === 6) h = 'FF' + h; // add alpha FF (opaque) at front if (h.length === 8) { const a = h.slice(0, 2); const r = h.slice(2, 4); const g = h.slice(4, 6); const b = h.slice(6, 8); // ASS uses &HAABBGGRR where AA is alpha (00 opaque) // convert alpha from hex (FF opaque) to ASS alpha where 00 opaque -> ASSalpha = (255 - alpha) const parsed = parseInt(a, 16); const alpha = isNaN(parsed) ? 255 : 255 - parsed; const aa = alpha.toString(16).padStart(2, '0').toUpperCase(); return `&H${aa}${b}${g}${r}`; } return '&H00FFFFFF'; } export async function createAssSubtitle(text, opts = {}) { const { vw = 1080, vh = 1920, fontName = 'Arial', fontSize = 40, fontColor = '#FFFFFF', fontWeight, boxColor = null, x = vw / 2, y = vh / 2, from = 0, to = 3, alignment = 2 } = opts; const safeText = (text == null) ? '' : String(text).replace(/\r/g, '').replace(/\n/g, '\\N').replace(/([{}])/g, '\\$1'); const assPath = tempPath('bubble', 'ass'); const styleName = 'BubbleStyle'; const primary = assColorFromHex(fontColor); const back = assColorFromHex(boxColor); const bold = ('' + fontWeight).indexOf('7') === 0 || (String(fontWeight) === '700' || String(fontWeight).toLowerCase().includes('bold')) ? '1' : '0'; const fontSz = Math.round(fontSize); const header = `[Script Info]\nTitle: Bubble\nScriptType: v4.00+\nPlayResX: ${vw}\nPlayResY: ${vh}\nScaledBorderAndShadow: yes\n\n[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\n`; // Match public/test.ass: use BorderStyle=3 and reasonable Outline value so BackColour is drawn reliably const outlineVal = 6; const styleLine = `Style: ${styleName},${fontName},${fontSz},${primary},&H00FFFFFF,${back || '&H00000000'},&H00000000,${bold},0,0,0,100,100,0,0,3,${outlineVal},0,${alignment},10,10,10,1\n\n`; const eventsHeader = `[Events]\nFormat: Layer, Start, End, Style, Text\n`; const start = secToAssTime(from); const end = secToAssTime(to); const dialog = `Dialogue: 0,${start},${end},${styleName},{\\pos(${Math.round(x)},${Math.round(y)})}${safeText}\n`; const content = header + styleLine + eventsHeader + dialog; fs.writeFileSync(assPath, content, 'utf8'); const meta = { path: assPath }; return meta; } export default createAssSubtitle;