Spaces:
Running
Running
| 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; | |