mirror of
https://github.com/anidl/multi-downloader-nx.git
synced 2026-01-11 20:10:20 +00:00
548 lines
16 KiB
TypeScript
548 lines
16 KiB
TypeScript
// const
|
|
const cssPrefixRx = /\.rmp-container>\.rmp-content>\.rmp-cc-area>\.rmp-cc-container>\.rmp-cc-display>\.rmp-cc-cue /g;
|
|
|
|
import { console } from './log';
|
|
|
|
// colors
|
|
import colors from './module.colors.json';
|
|
const defaultStyleName = 'Default';
|
|
const defaultStyleFont = 'Arial';
|
|
|
|
// predefined
|
|
let relGroup = '';
|
|
let fontSize = 0;
|
|
let tmMrg = 0;
|
|
let rFont = '';
|
|
let doCombineLines = false;
|
|
|
|
type Css = Record<
|
|
string,
|
|
{
|
|
params: string;
|
|
list: string[];
|
|
}
|
|
>;
|
|
|
|
type Vtt = {
|
|
caption: string;
|
|
time: {
|
|
start: string;
|
|
end: string;
|
|
ext: unknown;
|
|
};
|
|
text?: string | undefined;
|
|
};
|
|
|
|
function loadCSS(cssStr: string): Css {
|
|
const css = cssStr
|
|
.replace(cssPrefixRx, '')
|
|
.replace(/[\r\n]+/g, '\n')
|
|
.split('\n');
|
|
const defaultSFont = rFont == '' ? defaultStyleFont : rFont;
|
|
let defaultStyle = `${defaultSFont},40,&H00FFFFFF,&H00FFFFFF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,1,0,2,20,20,20,1`; //base for nonDialog
|
|
const styles: Record<
|
|
string,
|
|
{
|
|
params: string;
|
|
list: string[];
|
|
}
|
|
> = { [defaultStyleName]: { params: defaultStyle, list: [] } };
|
|
const classList: Record<string, number> = { [defaultStyleName]: 1 };
|
|
for (const i in css) {
|
|
let clx, clz, clzx, rgx;
|
|
const l = css[i];
|
|
if (l === '') continue;
|
|
const m = l.match(/^(.*)\{(.*)\}$/);
|
|
if (!m) {
|
|
console.error(`VTT2ASS: Invalid css in line ${i}: ${l}`);
|
|
continue;
|
|
}
|
|
|
|
if (m[1] === '') {
|
|
const style = parseStyle(defaultStyleName, m[2], defaultStyle);
|
|
styles[defaultStyleName].params = style;
|
|
defaultStyle = style;
|
|
} else {
|
|
clx = m[1].replace(/\./g, '').split(',');
|
|
clz = clx[0].replace(/-C(\d+)_(\d+)$/i, '').replace(/-(\d+)$/i, '');
|
|
classList[clz] = (classList[clz] || 0) + 1;
|
|
rgx = classList[clz];
|
|
const classSubNum = rgx > 1 ? `-${rgx}` : '';
|
|
clzx = clz + classSubNum;
|
|
const style = parseStyle(clzx, m[2], defaultStyle);
|
|
styles[clzx] = { params: style, list: clx };
|
|
}
|
|
}
|
|
return styles;
|
|
}
|
|
|
|
function parseStyle(stylegroup: string, line: string, style: any) {
|
|
const defaultSFont = rFont == '' ? defaultStyleFont : rFont; //redeclare cause of let
|
|
|
|
if (stylegroup.startsWith('Subtitle') || stylegroup.startsWith('Song') || stylegroup.startsWith('Q') || stylegroup.startsWith('Default')) {
|
|
//base for dialog, everything else use defaultStyle
|
|
style = `${defaultSFont},${fontSize},&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2.6,0,2,20,20,46,1`;
|
|
}
|
|
|
|
// Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour,
|
|
// BackColour, Bold, Italic, Underline, StrikeOut,
|
|
// ScaleX, ScaleY, Spacing, Angle, BorderStyle,
|
|
// Outline, Shadow, Alignment, MarginL, MarginR,
|
|
// MarginV, Encoding
|
|
style = style.split(',');
|
|
for (const s of line.split(';')) {
|
|
if (s == '') continue;
|
|
const st = s.trim().split(':');
|
|
if (st[0]) st[0] = st[0].trim();
|
|
if (st[1]) st[1] = st[1].trim();
|
|
let cl, arr, transformed_str;
|
|
switch (st[0]) {
|
|
case 'font-family':
|
|
if (rFont != '') {
|
|
//do rewrite if rFont is specified
|
|
if (stylegroup.startsWith('Subtitle') || stylegroup.startsWith('Song')) {
|
|
style[0] = rFont; //dialog to rFont
|
|
} else {
|
|
style[0] = defaultStyleFont; //non-dialog to Arial
|
|
}
|
|
} else {
|
|
//otherwise keep default style
|
|
style[0] = st[1].match(/[\s"]*([^",]*)/)![1];
|
|
}
|
|
break;
|
|
case 'font-size':
|
|
style[1] = getPxSize(st[1], style[1]); //scale it based on input style size... so for dialog, this is the dialog font size set in config, for non dialog, it's 40 from default font size
|
|
break;
|
|
case 'color':
|
|
cl = getColor(st[1]);
|
|
if (cl !== null) {
|
|
if (cl == '&H0000FFFF') {
|
|
style[2] = style[3] = '&H00FFFFFF';
|
|
} else {
|
|
style[2] = style[3] = cl;
|
|
}
|
|
}
|
|
break;
|
|
case 'font-weight':
|
|
if (stylegroup.startsWith('Subtitle') || stylegroup.startsWith('Song')) {
|
|
//don't touch font-weight if dialog
|
|
break;
|
|
}
|
|
// console.info("Changing bold weight");
|
|
// console.info(stylegroup);
|
|
if (st[1] === 'bold') {
|
|
style[6] = -1;
|
|
break;
|
|
}
|
|
if (st[1] === 'normal') {
|
|
break;
|
|
}
|
|
break;
|
|
case 'text-decoration':
|
|
if (st[1] === 'underline') {
|
|
style[8] = -1;
|
|
} else {
|
|
console.warn(`vtt2ass: Unknown text-decoration value: ${st[1]}`);
|
|
}
|
|
break;
|
|
case 'right':
|
|
style[17] = 3;
|
|
break;
|
|
case 'left':
|
|
style[17] = 1;
|
|
break;
|
|
case 'font-style':
|
|
if (st[1] === 'italic') {
|
|
style[7] = -1;
|
|
break;
|
|
}
|
|
break;
|
|
case 'background-color':
|
|
case 'background':
|
|
if (st[1] === 'none') {
|
|
break;
|
|
}
|
|
break;
|
|
case 'text-shadow':
|
|
if (stylegroup.startsWith('Subtitle') || stylegroup.startsWith('Song')) {
|
|
//don't touch shadow if dialog
|
|
break;
|
|
}
|
|
arr = transformed_str = st[1].split(',').map((r) => r.trim());
|
|
arr = arr.map((r) => {
|
|
return (r.split(' ').length > 3 ? r.replace(/(\d+)px black$/, '') : r.replace(/black$/, '')).trim();
|
|
});
|
|
transformed_str[1] = arr
|
|
.map((r) =>
|
|
r
|
|
.replace(/-/g, '')
|
|
.replace(/px/g, '')
|
|
.replace(/(^| )0( |$)/g, ' ')
|
|
.trim()
|
|
)
|
|
.join(' ');
|
|
arr = transformed_str[1].split(' ');
|
|
if (arr.length != 10) {
|
|
console.warn(`VTT2ASS: Can't properly parse text-shadow: ${s.trim()}`);
|
|
break;
|
|
}
|
|
arr = [...new Set(arr)];
|
|
if (arr.length > 1) {
|
|
console.warn(`VTT2ASS: Can't properly parse text-shadow: ${s.trim()}`);
|
|
break;
|
|
}
|
|
style[16] = arr[0];
|
|
break;
|
|
default:
|
|
console.error(`VTT2ASS: Unknown style: ${s.trim()}`);
|
|
}
|
|
}
|
|
return style.join(',');
|
|
}
|
|
|
|
function getPxSize(size_line: string, font_size: number) {
|
|
const m = size_line.trim().match(/([\d.]+)(.*)/);
|
|
if (!m) {
|
|
console.error(`VTT2ASS: Unknown size: ${size_line}`);
|
|
return;
|
|
}
|
|
let size = parseFloat(m[1]);
|
|
if (m[2] === 'em') size *= font_size;
|
|
return Math.round(size);
|
|
}
|
|
|
|
function getColor(c: string) {
|
|
if (c[0] !== '#') {
|
|
c = colors[c as keyof typeof colors];
|
|
} else if (c.length < 7 || c.length > 7) {
|
|
c = `#${c[1]}${c[1]}${c[2]}${c[2]}${c[3]}${c[3]}`;
|
|
}
|
|
const m = c.match(/#(..)(..)(..)/);
|
|
if (!m) return null;
|
|
return `&H00${m[3]}${m[2]}${m[1]}`.toUpperCase();
|
|
}
|
|
|
|
function loadVTT(vttStr: string): Vtt[] {
|
|
const rx = /^([\d:.]*) --> ([\d:.]*)\s?(.*?)\s*$/;
|
|
const lines = vttStr.replace(/\r?\n/g, '\n').split('\n');
|
|
const data = [];
|
|
let record: null | Vtt = null;
|
|
let lineBuf = [];
|
|
for (const l of lines) {
|
|
const m = l.match(rx);
|
|
if (m) {
|
|
let caption = '';
|
|
if (lineBuf.length > 0) {
|
|
caption = lineBuf.pop()!;
|
|
}
|
|
if (caption !== '' && lineBuf.length > 0) {
|
|
lineBuf.pop();
|
|
}
|
|
if (record !== null) {
|
|
record.text = lineBuf.join('\n');
|
|
data.push(record);
|
|
}
|
|
record = {
|
|
caption,
|
|
time: {
|
|
start: m[1],
|
|
end: m[2],
|
|
ext: m[3]
|
|
.split(' ')
|
|
.map((x) => x.split(':'))
|
|
.reduce((p, c) => ((p as any)[c[0]] = c[1] ?? 'invalid-input') && p, {})
|
|
}
|
|
};
|
|
lineBuf = [];
|
|
continue;
|
|
}
|
|
lineBuf.push(l);
|
|
}
|
|
if (record !== null) {
|
|
if (lineBuf[lineBuf.length - 1] === '') {
|
|
lineBuf.pop();
|
|
}
|
|
record.text = lineBuf.join('\n');
|
|
data.push(record);
|
|
}
|
|
return data;
|
|
}
|
|
|
|
function timestampToCentiseconds(timestamp: string) {
|
|
const timestamp_split = timestamp.split(':');
|
|
const timestamp_sec_split = timestamp_split[2].split('.');
|
|
const hour = parseInt(timestamp_split[0]);
|
|
const minute = parseInt(timestamp_split[1]);
|
|
const second = parseInt(timestamp_sec_split[0]);
|
|
const centisecond = parseInt(timestamp_sec_split[1]);
|
|
|
|
return 360000 * hour + 6000 * minute + 100 * second + centisecond;
|
|
}
|
|
|
|
function combineLines(events: string[]): string[] {
|
|
if (!doCombineLines) {
|
|
return events;
|
|
}
|
|
// This function is for combining adjacent lines with same information
|
|
const newLines: string[] = [];
|
|
for (const currentLine of events) {
|
|
let hasCombined: boolean = false;
|
|
// Check previous 7 elements, arbritary lookback amount
|
|
for (let j = 1; j < 8 && j < newLines.length; j++) {
|
|
const checkLine = newLines[newLines.length - j];
|
|
const checkLineSplit = checkLine.split(',');
|
|
const currentLineSplit = currentLine.split(',');
|
|
// 1 = start, 2 = end, 3 = style, 9+ = text
|
|
if (checkLineSplit.slice(9).join(',') == currentLineSplit.slice(9).join(',') && checkLineSplit[3] == currentLineSplit[3] && checkLineSplit[2] == currentLineSplit[1]) {
|
|
checkLineSplit[2] = currentLineSplit[2];
|
|
newLines[newLines.length - j] = checkLineSplit.join(',');
|
|
hasCombined = true;
|
|
break;
|
|
}
|
|
}
|
|
if (!hasCombined) {
|
|
newLines.push(currentLine);
|
|
}
|
|
}
|
|
return newLines;
|
|
}
|
|
|
|
function pushBuffer(buffer: ReturnType<typeof convertLine>[], events: string[]) {
|
|
buffer.reverse();
|
|
const bufferStrings: string[] = buffer.map((line) => `Dialogue: 1,${line.start},${line.end},${line.style},,0,0,0,,${line.text}`);
|
|
events.push(...bufferStrings);
|
|
buffer.splice(0, buffer.length);
|
|
}
|
|
|
|
function convert(css: Css, vtt: Vtt[]) {
|
|
const stylesMap: Record<string, string> = {};
|
|
let ass = [
|
|
'\ufeff[Script Info]',
|
|
'Title: ' + relGroup,
|
|
'ScriptType: v4.00+',
|
|
'WrapStyle: 0',
|
|
'PlayResX: 1280',
|
|
'PlayResY: 720',
|
|
'ScaledBorderAndShadow: yes',
|
|
'',
|
|
'[V4+ Styles]',
|
|
'Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding'
|
|
];
|
|
for (const s in css) {
|
|
ass.push(`Style: ${s},${css[s].params}`);
|
|
css[s].list.forEach((x) => (stylesMap[x] = s));
|
|
}
|
|
ass = ass.concat(['', '[Events]', 'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text']);
|
|
const events: {
|
|
subtitle: string[];
|
|
caption: string[];
|
|
capt_pos: string[];
|
|
song_cap: string[];
|
|
} = {
|
|
subtitle: [],
|
|
caption: [],
|
|
capt_pos: [],
|
|
song_cap: []
|
|
};
|
|
const linesMap: Record<string, number> = {};
|
|
const buffer: ReturnType<typeof convertLine>[] = [];
|
|
const captionsBuffer: string[] = [];
|
|
for (const l in vtt) {
|
|
const x = convertLine(stylesMap, vtt[l]);
|
|
if (x.ind !== '' && linesMap[x.ind] !== undefined) {
|
|
if (x.subInd > 1) {
|
|
const fx = convertLine(stylesMap, vtt[parseInt(l) - x.subInd + 1]);
|
|
if (x.style != fx.style) {
|
|
x.text = `{\\r${x.style}}${x.text}{\\r}`;
|
|
}
|
|
}
|
|
events[x.type as keyof typeof events][linesMap[x.ind]] += '\\N' + x.text;
|
|
} else {
|
|
events[x.type as keyof typeof events].push(x.res);
|
|
if (x.ind !== '') {
|
|
linesMap[x.ind] = events[x.type as keyof typeof events].length - 1;
|
|
}
|
|
}
|
|
/**
|
|
* What cursed code have I brought upon this land?
|
|
* This handles making lines multi-line when neccesary and reverses
|
|
* order of subtitles so that they display correctly
|
|
*/
|
|
if (x.type != 'subtitle') {
|
|
// Do nothing
|
|
} else if (x.text.includes('\\pos')) {
|
|
events['subtitle'].pop();
|
|
captionsBuffer.push(x.res);
|
|
} else if (buffer.length > 0) {
|
|
const previousBufferLine = buffer[buffer.length - 1];
|
|
const previousStart = timestampToCentiseconds(previousBufferLine.start);
|
|
const currentStart = timestampToCentiseconds(x.start);
|
|
events['subtitle'].pop();
|
|
if (currentStart - previousStart <= 2) {
|
|
x.start = previousBufferLine.start;
|
|
if (previousBufferLine.style == x.style) {
|
|
buffer.pop();
|
|
x.text = previousBufferLine.text + '\\N' + x.text;
|
|
}
|
|
} else {
|
|
pushBuffer(buffer, events['subtitle']);
|
|
}
|
|
buffer.push(x);
|
|
} else {
|
|
events['subtitle'].pop();
|
|
buffer.push(x);
|
|
}
|
|
}
|
|
|
|
pushBuffer(buffer, events['subtitle']);
|
|
events['subtitle'].push(...captionsBuffer);
|
|
events['subtitle'] = combineLines(events['subtitle']);
|
|
|
|
if (events.subtitle.length > 0) {
|
|
ass = ass.concat(
|
|
//`Comment: 0,0:00:00.00,0:00:00.00,${defaultStyleName},,0,0,0,,** Subtitles **`,
|
|
events.subtitle
|
|
);
|
|
}
|
|
if (events.caption.length > 0) {
|
|
ass = ass.concat(
|
|
//`Comment: 0,0:00:00.00,0:00:00.00,${defaultStyleName},,0,0,0,,** Captions **`,
|
|
events.caption
|
|
);
|
|
}
|
|
if (events.capt_pos.length > 0) {
|
|
ass = ass.concat(
|
|
//`Comment: 0,0:00:00.00,0:00:00.00,${defaultStyleName},,0,0,0,,** Captions with position **`,
|
|
events.capt_pos
|
|
);
|
|
}
|
|
if (events.song_cap.length > 0) {
|
|
ass = ass.concat(
|
|
//`Comment: 0,0:00:00.00,0:00:00.00,${defaultStyleName},,0,0,0,,** Song captions **`,
|
|
events.song_cap
|
|
);
|
|
}
|
|
return ass.join('\r\n') + '\r\n';
|
|
}
|
|
|
|
function convertLine(css: Record<string, string>, l: Record<any, any>) {
|
|
const start = convertTime(l.time.start);
|
|
const end = convertTime(l.time.end);
|
|
const txt = convertText(l.text);
|
|
let type = txt.style.match(/Caption/i) ? 'caption' : txt.style.match(/SongCap/i) ? 'song_cap' : 'subtitle';
|
|
type = type == 'caption' && l.time.ext?.position !== undefined ? 'capt_pos' : type;
|
|
if (l.time.ext?.align === 'left') {
|
|
txt.text = `{\\an7}${txt.text}`;
|
|
}
|
|
let ind = '',
|
|
subInd = 1;
|
|
const sMinus = 0; // (19.2 * 2);
|
|
if (l.time.ext?.position !== undefined) {
|
|
const pos = parseInt(l.time.ext.position);
|
|
const PosX = pos < 0 ? (1280 / 100) * (100 - pos) : ((1280 - sMinus) / 100) * pos;
|
|
const line = parseInt(l.time.ext.line) || 0;
|
|
const PosY = line < 0 ? (720 / 100) * (100 - line) : ((720 - sMinus) / 100) * line;
|
|
txt.text = `{\\pos(${parseFloat(PosX.toFixed(3))},${parseFloat(PosY.toFixed(3))})}${txt.text}`;
|
|
} else if (l.time.ext?.line !== undefined && type == 'caption') {
|
|
const line = parseInt(l.time.ext.line);
|
|
const PosY = line < 0 ? (720 / 100) * (100 - line) : ((720 - sMinus) / 100) * line;
|
|
txt.text = `{\\pos(640,${parseFloat(PosY.toFixed(3))})}${txt.text}`;
|
|
} else {
|
|
const indregx = txt.style.match(/(.*)_(\d+)$/);
|
|
if (indregx !== null) {
|
|
ind = indregx[1];
|
|
subInd = parseInt(indregx[2]);
|
|
}
|
|
}
|
|
const style = css[txt.style as any] || defaultStyleName;
|
|
const res = `Dialogue: 0,${start},${end},${style},,0,0,0,,${txt.text}`;
|
|
return { type, ind, subInd, start, end, style, text: txt.text, res };
|
|
}
|
|
|
|
function convertText(text: string) {
|
|
//const m = text.match(/<c\.([^>]*)>([\S\s]*)<\/c>/);
|
|
const m = text.match(/<(?:c\.|)([^>]*)>([\S\s]*)<\/(?:c|Default)>/);
|
|
let style = '';
|
|
if (m) {
|
|
style = m[1];
|
|
text = m[2];
|
|
}
|
|
const xtext = text
|
|
// .replace(/<c[^>]*>[^<]*<\/c>/g, '')
|
|
// .replace(/<ruby[^>]*>[^<]*<\/ruby>/g, '')
|
|
.replace(/ \\N$/g, '\\N')
|
|
//.replace(/<[^>]>/g, '')
|
|
.replace(/\\N$/, '')
|
|
.replace(/\r/g, '')
|
|
.replace(/\n/g, '\\N')
|
|
.replace(/\\N +/g, '\\N')
|
|
.replace(/ +\\N/g, '\\N')
|
|
.replace(/(\\N)+/g, '\\N')
|
|
.replace(/<b[^>]*>([^<]*)<\/b>/g, '{\\b1}$1{\\b0}')
|
|
.replace(/<i[^>]*>([^<]*)<\/i>/g, '{\\i1}$1{\\i0}')
|
|
.replace(/<u[^>]*>([^<]*)<\/u>/g, '{\\u1}$1{\\u0}')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/&/g, '&')
|
|
.replace(/<[^>]>/g, '')
|
|
.replace(/\\N$/, '')
|
|
.replace(/ +$/, '');
|
|
text = xtext;
|
|
return { style, text };
|
|
}
|
|
|
|
function convertTime(tm: string) {
|
|
const m = tm.match(/([\d:]*)\.?(\d*)/);
|
|
if (!m) return '0:00:00.00';
|
|
return toSubTime(m[0]);
|
|
}
|
|
|
|
function toSubTime(str: string) {
|
|
const n = [];
|
|
let sx;
|
|
const x: any[] = str.split(/[:.]/).map((x) => Number(x));
|
|
x[3] = '0.' + ('00' + x[3]).slice(-3);
|
|
sx = (x[0] * 60 * 60 + x[1] * 60 + x[2] + Number(x[3]) - tmMrg).toFixed(2);
|
|
sx = sx.toString().split('.');
|
|
n.unshift(sx[1]);
|
|
sx = Number(sx[0]);
|
|
n.unshift(('0' + (sx % 60).toString()).slice(-2));
|
|
n.unshift(('0' + (Math.floor(sx / 60) % 60).toString()).slice(-2));
|
|
n.unshift((Math.floor(sx / 3600) % 60).toString());
|
|
return n.slice(0, 3).join(':') + '.' + n[3];
|
|
}
|
|
|
|
export default function vtt2ass(
|
|
group: string | undefined,
|
|
xFontSize: number | undefined,
|
|
vttStr: string,
|
|
cssStr: string,
|
|
timeMargin?: number,
|
|
replaceFont?: string,
|
|
combineLines?: boolean
|
|
) {
|
|
relGroup = group ?? '';
|
|
fontSize = xFontSize && xFontSize > 0 ? xFontSize : 34; // 1em to pix
|
|
tmMrg = timeMargin ? timeMargin : 0; //
|
|
rFont = replaceFont ? replaceFont : rFont;
|
|
doCombineLines = combineLines ? combineLines : doCombineLines;
|
|
if (vttStr.match(/::cue(?:.(.+)\) *)?{([^}]+)}/g)) {
|
|
const cssLines = [];
|
|
let defaultCss = '';
|
|
const cssGroups = vttStr.matchAll(/::cue(?:.(.+)\) *)?{([^}]+)}/g);
|
|
for (const cssGroup of cssGroups) {
|
|
//Below code will bulldoze defined sizes for custom ones
|
|
/*if (!options.originalFontSize) {
|
|
cssGroup[2] = cssGroup[2].replace(/( font-size:.+?;)/g, '').replace(/(font-size:.+?;)/g, '');
|
|
}*/
|
|
if (cssGroup[1]) {
|
|
cssLines.push(`${cssGroup[1]}{${defaultCss}${cssGroup[2].replace(/(\r\n|\n|\r)/gm, '')}}`);
|
|
} else {
|
|
defaultCss = cssGroup[2].replace(/(\r\n|\n|\r)/gm, '');
|
|
//cssLines.push(`{${defaultCss}}`);
|
|
}
|
|
}
|
|
cssStr += cssLines.join('\r\n');
|
|
}
|
|
return convert(loadCSS(cssStr), loadVTT(vttStr));
|
|
}
|