255 lines
7.6 KiB
TypeScript
255 lines
7.6 KiB
TypeScript
import type { LyricLine, LyricWord, TTMLLyric } from "./ttml-types";
|
|
|
|
function msToTimestamp(timeMS: number): string {
|
|
let time = timeMS;
|
|
if (!Number.isSafeInteger(time) || time < 0) {
|
|
return "00:00.000";
|
|
}
|
|
if (time === Infinity) {
|
|
return "99:99.999";
|
|
}
|
|
time = time / 1000;
|
|
const secs = time % 60;
|
|
time = (time - secs) / 60;
|
|
const mins = time % 60;
|
|
const hrs = (time - mins) / 60;
|
|
|
|
const h = hrs.toString().padStart(2, "0");
|
|
const m = mins.toString().padStart(2, "0");
|
|
const s = secs.toFixed(3).padStart(6, "0");
|
|
|
|
if (hrs > 0) {
|
|
return `${h}:${m}:${s}`;
|
|
}
|
|
return `${m}:${s}`;
|
|
}
|
|
|
|
export function exportTTML(ttmlLyric: TTMLLyric, pretty = false): string {
|
|
const params: LyricLine[][] = [];
|
|
const lyric = ttmlLyric.lyricLines;
|
|
|
|
let tmp: LyricLine[] = [];
|
|
for (const line of lyric) {
|
|
if (line.words.length === 0 && tmp.length > 0) {
|
|
params.push(tmp);
|
|
tmp = [];
|
|
} else {
|
|
tmp.push(line);
|
|
}
|
|
}
|
|
|
|
if (tmp.length > 0) {
|
|
params.push(tmp);
|
|
}
|
|
|
|
const doc = new Document();
|
|
|
|
function createWordElement(word: LyricWord): Element {
|
|
const span = doc.createElement("span");
|
|
span.setAttribute("begin", msToTimestamp(word.startTime));
|
|
span.setAttribute("end", msToTimestamp(word.endTime));
|
|
if (word.emptyBeat) {
|
|
span.setAttribute("amll:empty-beat", `${word.emptyBeat}`);
|
|
}
|
|
span.appendChild(doc.createTextNode(word.word));
|
|
return span;
|
|
}
|
|
|
|
const ttRoot = doc.createElement("code");
|
|
|
|
ttRoot.setAttribute("xmlns", "http://www.w3.org/ns/ttml");
|
|
ttRoot.setAttribute("xmlns:ttm", "http://www.w3.org/ns/ttml#metadata");
|
|
ttRoot.setAttribute("xmlns:amll", "http://www.example.com/ns/amll");
|
|
ttRoot.setAttribute(
|
|
"xmlns:itunes",
|
|
"http://music.apple.com/lyric-ttml-internal",
|
|
);
|
|
|
|
doc.appendChild(ttRoot);
|
|
|
|
const head = doc.createElement("head");
|
|
|
|
ttRoot.appendChild(head);
|
|
|
|
const body = doc.createElement("body");
|
|
const hasOtherPerson = !!lyric.find((v) => v.isDuet);
|
|
|
|
const metadataEl = doc.createElement("metadata");
|
|
const mainPersonAgent = doc.createElement("ttm:agent");
|
|
mainPersonAgent.setAttribute("type", "person");
|
|
mainPersonAgent.setAttribute("xml:id", "v1");
|
|
|
|
metadataEl.appendChild(mainPersonAgent);
|
|
|
|
if (hasOtherPerson) {
|
|
const otherPersonAgent = doc.createElement("ttm:agent");
|
|
otherPersonAgent.setAttribute("type", "other");
|
|
otherPersonAgent.setAttribute("xml:id", "v2");
|
|
|
|
metadataEl.appendChild(otherPersonAgent);
|
|
}
|
|
|
|
for (const metadata of ttmlLyric.metadata) {
|
|
for (const value of metadata.value) {
|
|
const metaEl = doc.createElement("amll:meta");
|
|
metaEl.setAttribute("key", metadata.key);
|
|
metaEl.setAttribute("value", value);
|
|
metadataEl.appendChild(metaEl);
|
|
}
|
|
}
|
|
|
|
head.appendChild(metadataEl);
|
|
|
|
let i = 0;
|
|
|
|
const guessDuration = lyric[lyric.length - 1]?.endTime ?? 0;
|
|
body.setAttribute("dur", msToTimestamp(guessDuration));
|
|
|
|
for (const param of params) {
|
|
const paramDiv = doc.createElement("div");
|
|
const beginTime = param[0]?.startTime ?? 0;
|
|
const endTime = param[param.length - 1]?.endTime ?? 0;
|
|
|
|
paramDiv.setAttribute("begin", msToTimestamp(beginTime));
|
|
paramDiv.setAttribute("end", msToTimestamp(endTime));
|
|
|
|
for (let lineIndex = 0; lineIndex < param.length; lineIndex++) {
|
|
const line = param[lineIndex];
|
|
const lineP = doc.createElement("p");
|
|
const beginTime = line.startTime ?? 0;
|
|
const endTime = line.endTime;
|
|
|
|
lineP.setAttribute("begin", msToTimestamp(beginTime));
|
|
lineP.setAttribute("end", msToTimestamp(endTime));
|
|
|
|
lineP.setAttribute("ttm:agent", line.isDuet ? "v2" : "v1");
|
|
lineP.setAttribute("itunes:key", `L${++i}`);
|
|
|
|
if (line.words.length > 1) {
|
|
let beginTime = Infinity;
|
|
let endTime = 0;
|
|
for (const word of line.words) {
|
|
if (word.word.trim().length === 0) {
|
|
lineP.appendChild(doc.createTextNode(word.word));
|
|
} else {
|
|
const span = createWordElement(word);
|
|
lineP.appendChild(span);
|
|
beginTime = Math.min(beginTime, word.startTime);
|
|
endTime = Math.max(endTime, word.endTime);
|
|
}
|
|
}
|
|
lineP.setAttribute("begin", msToTimestamp(line.startTime));
|
|
lineP.setAttribute("end", msToTimestamp(line.endTime));
|
|
} else if (line.words.length === 1) {
|
|
const word = line.words[0];
|
|
lineP.appendChild(doc.createTextNode(word.word));
|
|
lineP.setAttribute("begin", msToTimestamp(word.startTime));
|
|
lineP.setAttribute("end", msToTimestamp(word.endTime));
|
|
}
|
|
|
|
const nextLine = param[lineIndex + 1];
|
|
if (nextLine?.isBG) {
|
|
lineIndex++;
|
|
const bgLine = nextLine;
|
|
const bgLineSpan = doc.createElement("span");
|
|
bgLineSpan.setAttribute("ttm:role", "x-bg");
|
|
|
|
if (bgLine.words.length > 1) {
|
|
let beginTime = Infinity;
|
|
let endTime = 0;
|
|
for (
|
|
let wordIndex = 0;
|
|
wordIndex < bgLine.words.length;
|
|
wordIndex++
|
|
) {
|
|
const word = bgLine.words[wordIndex];
|
|
if (word.word.trim().length === 0) {
|
|
bgLineSpan.appendChild(doc.createTextNode(word.word));
|
|
} else {
|
|
const span = createWordElement(word);
|
|
if (wordIndex === 0) {
|
|
span.prepend(doc.createTextNode("("));
|
|
} else if (wordIndex === bgLine.words.length - 1) {
|
|
span.appendChild(doc.createTextNode(")"));
|
|
}
|
|
bgLineSpan.appendChild(span);
|
|
beginTime = Math.min(beginTime, word.startTime);
|
|
endTime = Math.max(endTime, word.endTime);
|
|
}
|
|
}
|
|
bgLineSpan.setAttribute("begin", msToTimestamp(beginTime));
|
|
bgLineSpan.setAttribute("end", msToTimestamp(endTime));
|
|
} else if (bgLine.words.length === 1) {
|
|
const word = bgLine.words[0];
|
|
bgLineSpan.appendChild(doc.createTextNode(`(${word.word})`));
|
|
bgLineSpan.setAttribute("begin", msToTimestamp(word.startTime));
|
|
bgLineSpan.setAttribute("end", msToTimestamp(word.endTime));
|
|
}
|
|
|
|
if (bgLine.translatedLyric) {
|
|
const span = doc.createElement("span");
|
|
span.setAttribute("ttm:role", "x-translation");
|
|
span.setAttribute("xml:lang", "zh-CN");
|
|
span.appendChild(doc.createTextNode(bgLine.translatedLyric));
|
|
bgLineSpan.appendChild(span);
|
|
}
|
|
|
|
if (bgLine.romanLyric) {
|
|
const span = doc.createElement("span");
|
|
span.setAttribute("ttm:role", "x-roman");
|
|
span.appendChild(doc.createTextNode(bgLine.romanLyric));
|
|
bgLineSpan.appendChild(span);
|
|
}
|
|
|
|
lineP.appendChild(bgLineSpan);
|
|
}
|
|
|
|
if (line.translatedLyric) {
|
|
const span = doc.createElement("span");
|
|
span.setAttribute("ttm:role", "x-translation");
|
|
span.setAttribute("xml:lang", "zh-CN");
|
|
span.appendChild(doc.createTextNode(line.translatedLyric));
|
|
lineP.appendChild(span);
|
|
}
|
|
|
|
if (line.romanLyric) {
|
|
const span = doc.createElement("span");
|
|
span.setAttribute("ttm:role", "x-roman");
|
|
span.appendChild(doc.createTextNode(line.romanLyric));
|
|
lineP.appendChild(span);
|
|
}
|
|
|
|
paramDiv.appendChild(lineP);
|
|
}
|
|
|
|
body.appendChild(paramDiv);
|
|
}
|
|
|
|
ttRoot.appendChild(body);
|
|
|
|
if (pretty) {
|
|
const xsltDoc = new DOMParser().parseFromString(
|
|
[
|
|
'<xsl:stylesheet xmlns:xsl="http://www.w3.org/1999/XSL/Transform" version="1.0">',
|
|
' <xsl:strip-space elements="*"/>',
|
|
' <xsl:template match="para[content-style][not(text())]">',
|
|
' <xsl:value-of select="normalize-space(.)"/>',
|
|
" </xsl:template>",
|
|
' <xsl:template match="node()|@*">',
|
|
' <xsl:copy><xsl:apply-templates select="node()|@*"/></xsl:copy>',
|
|
" </xsl:template>",
|
|
' <xsl:output indent="yes"/>',
|
|
"</xsl:stylesheet>",
|
|
].join("\n"),
|
|
"application/xml",
|
|
);
|
|
|
|
const xsltProcessor = new XSLTProcessor();
|
|
xsltProcessor.importStylesheet(xsltDoc);
|
|
const resultDoc = xsltProcessor.transformToDocument(doc);
|
|
|
|
return new XMLSerializer().serializeToString(resultDoc);
|
|
}
|
|
return new XMLSerializer().serializeToString(doc);
|
|
}
|