UNPKG

subkit

Version:

The subtitles toolkit for converting between SRT, WebVTT, and FCPXML

162 lines (161 loc) 8.29 kB
import { XMLBuilder, XMLParser } from 'fast-xml-parser'; const msToFrame = (ms, fps) => Math.floor(ms / (1000 / fps)); export const fcpxmlToData = (text) => { const parser = new XMLParser({ ignoreAttributes: false }); const data = parser.parse(text); const formatName = data.fcpxml.resources.format['@_name']; const { sequence } = data.fcpxml.library.event.project; const fps = Number(formatName.split('p').at(-1)) / 100; const items = data.fcpxml.library.event.project.sequence.spine.gap.title.map((sub) => { const offsetFrame = Number(sub['@_offset'].split('/').at(0)) / 100; const durationFrame = Number(sub['@_duration'].split('/').at(0)) / 100; const endFrame = offsetFrame + durationFrame; const from = offsetFrame * (1000 / fps); const to = endFrame * (1000 / fps); const duration = to - from; const textStyle = sub['text-style-def']['text-style']; return { from, to, duration, text: sub.text['text-style']['#text'], font: textStyle['@_font'], fontSize: textStyle['@_fontSize'], fontFace: textStyle['@_fontFace'], fontColor: textStyle['@_fontColor'], bold: textStyle['@_bold'], shadowColor: textStyle['@_shadowColor'], shadowOffset: textStyle['@_shadowOffset'], alignment: textStyle['@_alignment'], titleOffset: sub['@_offset'], titleDuration: sub['@_duration'], }; }); return { name: data.fcpxml.library.event.project['@_name'], fps, sequenceDuration: sequence['@_duration'], gapDuration: sequence.spine.gap['@_duration'], items, }; }; export const dataToFcpxml = (data, fps, options = {}) => { const { name = 'subtitle', font = 'Helvetica', fontSize = '45', fontFace = 'Regular', fontColor = '1 1 1 1', bold = '1', shadowColor = '0 0 0 0.75', shadowOffset = '4 315', alignment = 'center', } = options; const builder = new XMLBuilder({ ignoreAttributes: false }); const x100Fps = (100 * fps).toString(); const totalFrame = msToFrame(data.items.at(-1)?.to /* c8 ignore next */ ?? 0, fps); const x100TotalFrame = String(100 * totalFrame); const sequenceDuration = `${x100TotalFrame}/${x100Fps}s`; const gapDuration = `${x100TotalFrame}/${x100Fps}s`; /* eslint-disable @typescript-eslint/naming-convention */ const xmlData = { '?xml': { '@_version': '1.0', // eslint-disable-next-line unicorn/text-encoding-identifier-case '@_encoding': 'UTF-8', }, fcpxml: { resources: { format: { '@_id': 'r1', '@_name': `FFVideoFormat1080p${x100Fps}`, '@_frameDuration': `100/${x100Fps}`, '@_width': '1920', '@_height': '1080', '@_colorSpace': '1-1-1 (Rec. 709)', }, effect: { '@_id': 'r2', '@_name': 'Basic Title', '@_uid': '.../Titles.localized/Bumper:Opener.localized/Basic Title.localized/Basic Title.moti', }, }, library: { event: { project: { sequence: { spine: { gap: { title: data.items.map((sub, index) => { const offsetFrame = msToFrame(sub.from, fps); const endFrame = msToFrame(sub.to, fps); const durationFrame = endFrame - offsetFrame; const x100OffsetFrame = (100 * offsetFrame).toString(); const x100DurationFrame = (100 * durationFrame).toString(); const subText = sub.text; const tsIndex = `ts${index}`; const basicTitle = `${subText} - Basic Title`; const titleOffset = `${x100OffsetFrame}/${x100Fps}s`; const titleDuration = `${x100DurationFrame}/${x100Fps}s`; return { param: [ { '@_name': 'Position', '@_key': '9999/999166631/999166633/1/100/101', '@_value': '0 -465', }, { '@_name': 'Flatten', '@_key': '999/999166631/999166633/2/351', '@_value': '1', }, { '@_name': 'Alignment', '@_key': '9999/999166631/999166633/2/354/999169573/401', '@_value': '1 (Center)', }, ], text: { 'text-style': { '#text': subText, '@_ref': tsIndex, }, }, 'text-style-def': { 'text-style': { '@_font': font, '@_fontSize': fontSize, '@_fontFace': fontFace, '@_fontColor': fontColor, '@_bold': bold, '@_shadowColor': shadowColor, '@_shadowOffset': shadowOffset, '@_alignment': alignment, }, '@_id': tsIndex, }, '@_ref': 'r2', '@_lane': '1', '@_offset': titleOffset, '@_duration': titleDuration, '@_name': basicTitle, }; }), '@_name': 'Gap', '@_offset': '0s', '@_duration': gapDuration, }, }, '@_format': 'r1', '@_tcStart': '0s', '@_tcFormat': 'NDF', '@_audioLayout': 'stereo', '@_audioRate': '48k', '@_duration': sequenceDuration, }, '@_name': name, }, '@_name': 'subkit', }, }, '@_version': '1.9', }, }; /* eslint-enable @typescript-eslint/naming-convention */ const xml = builder.build(xmlData); if (typeof xml === 'string') return xml; /* c8 ignore start */ throw new Error('Failed to build XML'); /* c8 ignore stop */ };