@openanime/ass2esl
Version:
Utilities to convert Advanced SubStation Alpha (ASS) to Expressive Subtitle Language (ESL)
241 lines (203 loc) • 10.2 kB
text/typescript
import { Dialogue } from 'ass-compiler';
import { CUE_PROPERTY_MAP, TAG_PROPERTY_MAP, TEXT_STYLE_TAGS, VALID_CUE_PROPS } from './constants';
import { IdGenerator } from './id-generator';
import { ProcessedStyle } from './interfaces';
import {
diffObj,
formatHclValue,
quoteString,
secondsToMilliseconds,
transformAssTextToEsl,
} from './utils';
interface EslCueMargins {
left?: number;
right?: number;
top?: number;
bottom?: number;
}
function calculateEffectiveMargins(
cueMarginData: Dialogue['margin'] | undefined,
styleMargins: ProcessedStyle['margins'],
): EslCueMargins {
const effective: EslCueMargins = {};
const styleL = styleMargins.MarginL ?? 0;
const styleR = styleMargins.MarginR ?? 0;
const styleV = styleMargins.MarginV ?? 0;
const effL =
cueMarginData?.left !== null && cueMarginData?.left !== undefined
? cueMarginData.left
: styleL;
const effR =
cueMarginData?.right !== null && cueMarginData?.right !== undefined
? cueMarginData.right
: styleR;
const effV =
cueMarginData?.vertical !== null && cueMarginData?.vertical !== undefined
? cueMarginData.vertical
: styleV;
if (effL !== 0) effective.left = effL;
if (effR !== 0) effective.right = effR;
if (effV !== 0) {
effective.top = effV;
effective.bottom = effV;
}
return effective;
}
export function processDialogues(
dialogues: Dialogue[],
processedStylesMap: Map<string, ProcessedStyle>,
motionIdGenerator: IdGenerator,
): { cuesHCL: string; motionsHCL: string; autoGeneratedStylesHCL: string } {
const cuesHCLParts: string[] = [];
const motionsHCLParts: string[] = [];
let autoGeneratedStylesHCL = '';
for (const cue of dialogues) {
const fragments = cue.slices?.[0]?.fragments || [];
const firstFragment = cue.slices?.[0]?.fragments?.[0];
if (!firstFragment?.text) {
continue;
}
let textContent = firstFragment?.text || '';
// {\an8\fs48\shad0\fnTimes New Roman\c&H3A3A3A&\3c&HE7ECE9&\move(963,211.4,963,111,18,2061)}Hachimitsu Kültür Festivali\N{\fs60}Ödül Sahipleri
if (fragments.length > 1) {
for (let index = fragments.length - 1; index > 0; index--) {
const fragmentBefore = fragments[index - 1];
const fragment = fragments[index];
const autoGenerateStyleDiff = diffObj(fragmentBefore.tag, fragment.tag);
let generatedName = false;
for (const [key, value] of Object.entries(autoGenerateStyleDiff)) {
const getCorresponding = TAG_PROPERTY_MAP[key];
if (!getCorresponding) continue;
if (!generatedName) {
const autoGenerateStyleName = `auto-generated-${motionIdGenerator.next()}`;
autoGeneratedStylesHCL += `style ${quoteString(autoGenerateStyleName)} {\n`;
generatedName = true;
fragment.text = `<style name="${autoGenerateStyleName}">${fragment.text}</style>`;
}
autoGeneratedStylesHCL += ` ${getCorresponding} = ${value}\n`;
}
if (generatedName) {
autoGeneratedStylesHCL += `}`;
}
textContent = textContent + fragment.text;
}
}
const cueHCLProps: string[] = [];
const activeTextStyles = new Set<string>();
const motionNamesForCue: string[] = [];
const motionOutNamesForCue: string[] = [];
let currentPosition: [number, number] | null = null;
const styleName = cue.style;
const associatedStyle = processedStylesMap.get(styleName);
associatedStyle?.features.forEach((feature) => activeTextStyles.add(feature));
const margins = calculateEffectiveMargins(cue.margin, associatedStyle?.margins || {});
if (margins.bottom !== undefined) cueHCLProps.push(` margin_bottom = ${margins.bottom}`);
if (margins.top !== undefined) cueHCLProps.push(` margin_top = ${margins.top}`);
if (margins.left !== undefined) cueHCLProps.push(` margin_left = ${margins.left}`);
if (margins.right !== undefined) cueHCLProps.push(` margin_right = ${margins.right}`);
for (const [assPropName, assPropValue] of Object.entries(cue)) {
if (
!VALID_CUE_PROPS.includes(assPropName) ||
assPropValue === null ||
assPropValue === undefined
)
continue;
const eslPropName = CUE_PROPERTY_MAP[assPropName];
if (eslPropName === 'slices' && fragments.length === 1) {
const tag = firstFragment.tag;
if (tag) {
if (tag.t) {
// \t
for (const transform of tag.t) {
const motionName = `auto-generated-${motionIdGenerator.next()}`;
motionNamesForCue.push(motionName);
const delay = secondsToMilliseconds(cue.start) + (transform.t1 || 0);
const duration = Math.max(0, (transform.t2 || 0) - (transform.t1 || 0));
motionsHCLParts.push(`motion ${quoteString(motionName)} {`);
motionsHCLParts.push(` delay = ${delay}`);
motionsHCLParts.push(` duration = ${duration}`);
motionsHCLParts.push(` easing = "linear"`); // currently hardcoded, fix in the future
motionsHCLParts.push(`}`);
}
}
if (tag.i === 1) activeTextStyles.add('Italic');
else if (tag.i === 0) activeTextStyles.delete('Italic');
if (tag.b === 1) activeTextStyles.add('Bold');
else if (tag.b === 0) activeTextStyles.delete('Bold');
}
} else if (eslPropName === 'position' && cue.pos) {
// \pos
currentPosition = [cue.pos.x, cue.pos.y];
} else if (eslPropName === 'move' && cue.move) {
// \move
currentPosition = [cue.move.x1, cue.move.y1]; // \move start position overrides \pos as per ASS spec
const motionName = `auto-generated-${motionIdGenerator.next()}`;
motionNamesForCue.push(motionName);
//const delay = secondsToMilliseconds(cue.start) + (cue.move.t1 || 0);
const duration = Math.max(0, (cue.move.t2 || 0) - (cue.move.t1 || 0));
motionsHCLParts.push(`motion ${quoteString(motionName)} {`);
motionsHCLParts.push(` duration = ${duration}`);
motionsHCLParts.push(` easing = "linear"`);
motionsHCLParts.push(` position = [${cue.move.x2}, ${cue.move.y2}]`);
motionsHCLParts.push(`}`);
} else if (eslPropName === 'fade') {
if (cue.fade.type === 'fad') {
const motionName = `auto-generated-${motionIdGenerator.next()}`;
cueHCLProps.push(` opacity = 0`);
motionNamesForCue.push(motionName);
motionsHCLParts.push(`motion ${quoteString(motionName)} {`);
motionsHCLParts.push(` duration = ${cue.fade.t1}`);
motionsHCLParts.push(` easing = "linear"`);
motionsHCLParts.push(` opacity = 1`);
motionsHCLParts.push(`}`);
if (cue.fade.t2 > 1) {
// some people just want in-fade but not out-fade so they just set t2 to 1. It will be instantaneous so we don't need to add a motion for it.
const motionOutName = `auto-generated-${motionIdGenerator.next()}`;
motionOutNamesForCue.push(motionOutName);
motionsHCLParts.push(`motion ${quoteString(motionOutName)} {`);
motionsHCLParts.push(` duration = ${cue.fade.t2}`);
motionsHCLParts.push(` easing = "linear"`); // Default easing
motionsHCLParts.push(` opacity = 0`);
motionsHCLParts.push(`}`);
}
}
} else if (
eslPropName !== 'slices' &&
eslPropName !== 'position' &&
eslPropName !== 'move'
) {
cueHCLProps.push(
` ${eslPropName} = ${formatHclValue(eslPropName, assPropValue)}`,
);
}
}
if (motionNamesForCue.length > 0) {
const motionRefs = motionNamesForCue.map((name) => quoteString(name)).join(', ');
cueHCLProps.push(` motion_in = [${motionRefs}]`);
}
if (motionOutNamesForCue.length > 0) {
const motionRefs = motionOutNamesForCue.map((name) => quoteString(name)).join(', ');
cueHCLProps.push(` motion_out = [${motionRefs}]`);
}
if (currentPosition) {
cueHCLProps.push(` position = [${currentPosition[0]}, ${currentPosition[1]}]`);
}
if (textContent) {
let eslText = transformAssTextToEsl(textContent);
const sortedStyles = Array.from(activeTextStyles).sort();
for (const styleFeature of sortedStyles) {
const tagName = TEXT_STYLE_TAGS[styleFeature];
if (tagName) {
eslText = `<${tagName}>${eslText}</${tagName}>`;
}
}
cueHCLProps.push(` text = ${formatHclValue('text', eslText)}`);
}
cuesHCLParts.push(`cue {`, ...cueHCLProps.sort(), `}`); // todo: sort by priority
}
return {
cuesHCL: cuesHCLParts.join('\n'),
motionsHCL: motionsHCLParts.join('\n'),
autoGeneratedStylesHCL,
};
}