UNPKG

@openanime/ass2esl

Version:

Utilities to convert Advanced SubStation Alpha (ASS) to Expressive Subtitle Language (ESL)

241 lines (203 loc) 10.2 kB
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, }; }