remote-rdr / utils /bubble /ass-utils.js
shiveshnavin's picture
F
057da11
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;