fork(fix): Clone AMLL 并修复 BUG

- 将AMLL Clone到本以地进行修复和优化(emm虽然这很不优雅但是暂时无时间做子模块和Fork)
- 修复在当前播放歌词行不可见的视口Seek会出现滚动偏移的问题
This commit is contained in:
lqtmcstudio
2026-06-07 00:02:14 +08:00
parent 783d2c3dee
commit 72f4510dc8
458 changed files with 86075 additions and 1665 deletions

View File

@@ -0,0 +1,96 @@
import { describe, expect, it } from "vitest";
import { stringifyAss } from "../src/formats/ass";
describe("ass", () => {
it("stringifies basic timed words into dialogue with k tags", () => {
const result = stringifyAss([
{
startTime: 0,
endTime: 0,
words: [
{ startTime: 1000, endTime: 1200, word: "Hello", romanWord: "" },
{ startTime: 0, endTime: 0, word: " ", romanWord: "" },
{ startTime: 1300, endTime: 1500, word: "World", romanWord: "" },
],
translatedLyric: "",
romanLyric: "",
isBG: false,
isDuet: false,
},
]);
expect(result).toBe(
"[Script Info]\n[Events]\nFormats: Marked, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\nDialogue: 0,0:00:01.00, 0:00:01.50, Default, v1,0,0,0,,{\\k20}Hello {\\k10}{\\k20}World\n",
);
});
it("stringifies duet/bg speaker and trans/roman lines", () => {
const result = stringifyAss([
{
startTime: 0,
endTime: 0,
words: [
{ startTime: 1000, endTime: 1500, word: "Hello", romanWord: "" },
],
translatedLyric: "你好",
romanLyric: "ni hao",
isBG: true,
isDuet: true,
},
]);
expect(result).toContain(
"Dialogue: 0,0:00:01.00, 0:00:01.50, Default, v2-bg,0,0,0,,{\\k50}Hello",
);
expect(result).toContain(
"Dialogue: 0,0:00:01.00, 0:00:01.50, Default, v2-bg-trans,0,0,0,,你好",
);
expect(result).toContain(
"Dialogue: 0,0:00:01.00, 0:00:01.50, Default, v2-bg-roman,0,0,0,,ni hao",
);
});
it("skips lines without any valid timed words", () => {
const result = stringifyAss([
{
startTime: 0,
endTime: 0,
words: [{ startTime: 0, endTime: 0, word: "NoTime", romanWord: "" }],
translatedLyric: "",
romanLyric: "",
isBG: false,
isDuet: false,
},
]);
expect(result).toBe(
"[Script Info]\n[Events]\nFormats: Marked, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n",
);
});
it("normalizes invalid timestamps when stringifying", () => {
const result = stringifyAss([
{
startTime: 0,
endTime: 0,
words: [
{
startTime: Number.NaN,
endTime: Number.POSITIVE_INFINITY,
word: "Bad",
romanWord: "",
},
{ startTime: -1, endTime: 100, word: "Ok", romanWord: "" },
],
translatedLyric: "",
romanLyric: "",
isBG: false,
isDuet: false,
},
]);
expect(result).toContain(
"Dialogue: 0,0:00:00.00, 0:00:00.10, Default, v1,0,0,0,,Bad{\\k10}Ok",
);
});
});

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,56 @@
import { createHash } from "node:crypto";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { describe, expect, it } from "vitest";
import { decryptQrcHex, encryptQrcHex } from "../src/formats/eqrc";
import { parseQrc } from "../src/formats/qrc";
function decodeXmlEntities(text: string): string {
return text
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&apos;/g, "'")
.replace(/&amp;/g, "&")
.replace(/&#10;/g, "\n")
.replace(/&#13;/g, "\r");
}
function extractQrcPayload(xmlLike: string): string {
const match = xmlLike.match(/<!\[CDATA\[([\s\S]*?)\]\]>/);
if (match?.[1]) return match[1].trim();
const attrMatch = xmlLike.match(/LyricContent="([^"]*)"/);
if (!attrMatch?.[1]) return "";
return decodeXmlEntities(attrMatch[1]).trim();
}
describe("eqrc", () => {
it("decrypts rust sample hex with stable output", () => {
const hexPath = resolve(__dirname, "eqrc.hex");
const hex = readFileSync(hexPath, "utf8").trim();
const decrypted = decryptQrcHex(hex);
const sha256 = createHash("sha256").update(decrypted).digest("hex");
const qrcText = extractQrcPayload(decrypted);
const lines = parseQrc(qrcText);
expect(hex.length).toBe(7136);
expect(decrypted.length).toBe(7188);
expect(sha256).toBe(
"f3bf2f3b5af01e9f21c5fc49e5ed9ab59370faa530f62b60a38df1881ea31f6a",
);
expect(decrypted).toContain("<QrcInfos>");
expect(decrypted).toContain('<LyricInfo LyricCount="1">');
expect(qrcText.length).toBeGreaterThan(0);
expect(lines.length).toBeGreaterThan(0);
});
it("keeps decrypt/encrypt/decrypt text stable for rust sample", () => {
const hexPath = resolve(__dirname, "eqrc.hex");
const hex = readFileSync(hexPath, "utf8").trim();
const decrypted = decryptQrcHex(hex);
const encryptedAgain = encryptQrcHex(decrypted);
const decryptedAgain = decryptQrcHex(encryptedAgain);
expect(decryptedAgain).toBe(decrypted);
});
});

View File

@@ -0,0 +1,112 @@
import { describe, expect, it } from "vitest";
import { parseEslrc, stringifyEslrc } from "../src/formats/eslrc";
describe("eslrc", () => {
it("parses basic word-ended-timestamp lines", () => {
const lines = parseEslrc(
"[00:10.82]Test[00:10.97] Word[00:12.62]\n[00:12.62]Next[00:13.20] line[00:14.10]",
);
expect(lines).toHaveLength(2);
expect(lines[0].startTime).toBe(10820);
expect(lines[0].endTime).toBe(12620);
expect(lines[0].words[0].word).toBe("Test");
expect(lines[0].words[0].startTime).toBe(10820);
expect(lines[0].words[0].endTime).toBe(10970);
expect(lines[0].words[1].word).toBe(" Word");
expect(lines[0].words[1].startTime).toBe(10970);
expect(lines[0].words[1].endTime).toBe(12620);
});
it("handles CRLF and ignores malformed lines", () => {
const lines = parseEslrc(
"[00:10.82]Ok[00:11.00]\r\nno timestamp\r\n[00:12.00]Broken no end\r\n[00:12.00]AlsoBroken[invalid]\r\n[00:13.00]Fine[00:13.50]",
);
expect(lines).toHaveLength(2);
expect(lines[0].words.map((w) => w.word).join("")).toBe("Ok");
expect(lines[1].words.map((w) => w.word).join("")).toBe("Fine");
});
it("sorts parsed lines by first word timestamp", () => {
const lines = parseEslrc(
"[00:20.00]Second[00:21.00]\n[00:10.00]First[00:11.00]",
);
expect(lines).toHaveLength(2);
expect(lines[0].words.map((w) => w.word).join("")).toBe("First");
expect(lines[1].words.map((w) => w.word).join("")).toBe("Second");
});
it("clamps parsed timestamps to max lrc time", () => {
const lines = parseEslrc("[999:99.999]Max[1000:40.000]");
expect(lines).toHaveLength(1);
expect(lines[0].startTime).toBe(60039999);
expect(lines[0].endTime).toBe(60039999);
expect(lines[0].words[0].startTime).toBe(60039999);
expect(lines[0].words[0].endTime).toBe(60039999);
});
it("ignores empty lines and lines with only whitespace", () => {
const lines = parseEslrc(
"[00:01.000] \n[00:02.000]\n[00:10.82]Test[00:10.97] Word[00:12.62]\n \n[00:12.62]Next[00:13.20] line[00:14.10]\n \n",
);
expect(lines).toHaveLength(2);
expect(lines[0].words[0].word).toBe("Test");
expect(lines[1].words[0].word).toBe("Next");
});
it("stringifies to expected eslrc text", () => {
const result = stringifyEslrc([
{
startTime: 0,
endTime: 0,
words: [
{ startTime: 10820, endTime: 10970, word: "Test", romanWord: "" },
{ startTime: 10970, endTime: 12620, word: " Word", romanWord: "" },
],
translatedLyric: "",
romanLyric: "",
isBG: false,
isDuet: false,
},
]);
expect(result).toBe("[00:10.820]Test[00:10.970] Word[00:12.620]");
});
it("normalizes invalid timestamps when stringifying", () => {
const result = stringifyEslrc([
{
startTime: 0,
endTime: 0,
words: [
{
startTime: Number.NaN,
endTime: Number.POSITIVE_INFINITY,
word: "Hello",
romanWord: "",
},
{ startTime: -1, endTime: -2, word: "World", romanWord: "" },
],
translatedLyric: "",
romanLyric: "",
isBG: false,
isDuet: false,
},
]);
expect(result).toBe("[00:00.000]Hello[00:00.000]World[00:00.000]");
});
it("keeps parse -> stringify -> parse stable for content and timing", () => {
const input =
"[00:10.82]Test[00:10.97] Word[00:12.62]\n[00:12.62]Next[00:13.20] line[00:14.10]";
const first = parseEslrc(input);
const text = stringifyEslrc(first);
const second = parseEslrc(text);
expect(second).toEqual(first);
});
});

View File

@@ -0,0 +1,201 @@
import { describe, expect, it } from "vitest";
import { parseLrc, stringifyLrc } from "../src/formats/lrc";
import { MAX_LRC_TIMESTAMP } from "../src/utils";
import { timeStampsTestCases } from "./timestampcase.fixture";
describe("lrc", () => {
it("parses basic timestamped lines", () => {
const lines = parseLrc("[00:01.120]Hello\n[00:03.000]World");
expect(lines).toHaveLength(2);
expect(lines[0].startTime).toBe(1120);
expect(lines[0].endTime).toBe(3000);
expect(lines[0].words[0].word).toBe("Hello");
expect(lines[1].startTime).toBe(3000);
expect(lines[1].words[0].word).toBe("World");
});
it("handles CRLF line breaks correctly", () => {
const lines = parseLrc("[00:01.120]Hello\r\n[00:03.000]World");
expect(lines).toHaveLength(2);
expect(lines[0].startTime).toBe(1120);
expect(lines[0].endTime).toBe(3000);
expect(lines[0].words[0].word).toBe("Hello");
expect(lines[1].startTime).toBe(3000);
expect(lines[1].words[0].word).toBe("World");
});
it("parses multiple timestamps for the same line", () => {
const lines = parseLrc("[00:01.120][00:02.000]Hello");
expect(lines).toHaveLength(2);
expect(lines[0].startTime).toBe(1120);
expect(lines[0].endTime).toBe(2000);
expect(lines[0].words[0].word).toBe("Hello");
expect(lines[1].startTime).toBe(2000);
expect(lines[1].words[0].word).toBe("Hello");
});
it("ignores lines without timestamps", () => {
const lines = parseLrc(
"This is a line without timestamp\n[ar: Artist]\n[00:01.120]Hello\n# This is a comment\n{ some: 'metadata' }\n\n[00:03.000]World",
);
expect(lines).toHaveLength(2);
expect(lines[0].startTime).toBe(1120);
expect(lines[0].endTime).toBe(3000);
expect(lines[0].words[0].word).toBe("Hello");
expect(lines[1].startTime).toBe(3000);
expect(lines[1].words[0].word).toBe("World");
});
it("ignores lines with bad timestamps", () => {
const lines = parseLrc(
"[00:01.120]Hello\n[invalid]Bad line\n[xx:yy.zzz]Bad line\n[-1:00.000]Bad line\n[NaN:NaN]Bad line\n[00:03.000]World",
);
expect(lines).toHaveLength(2);
expect(lines[0].startTime).toBe(1120);
expect(lines[0].endTime).toBe(3000);
expect(lines[0].words[0].word).toBe("Hello");
expect(lines[1].startTime).toBe(3000);
expect(lines[1].words[0].word).toBe("World");
});
it("sorts lines by timestamp and sets end times correctly", () => {
const lines = parseLrc("[00:03.000]World\n[00:01.120][00:05.000]Hello");
expect(lines).toHaveLength(3);
expect(lines[0].startTime).toBe(1120);
expect(lines[0].endTime).toBe(3000);
expect(lines[0].words[0].word).toBe("Hello");
expect(lines[1].startTime).toBe(3000);
expect(lines[1].endTime).toBe(5000);
expect(lines[1].words[0].word).toBe("World");
expect(lines[2].startTime).toBe(5000);
expect(lines[2].endTime).toBe(MAX_LRC_TIMESTAMP);
expect(lines[2].words[0].word).toBe("Hello");
});
it("parses all kinds of timestamps", () => {
const input = timeStampsTestCases
.map(([ts, ms]) => `[${ts}]Should be ${ts} = ${ms} ms`)
.join("\n");
const lines = parseLrc(input);
expect(lines).toHaveLength(timeStampsTestCases.length);
lines.forEach((line, i) => {
const [ts, ms] = timeStampsTestCases[i];
expect(line.words[0].word).toBe(`Should be ${ts} = ${ms} ms`);
expect(line.startTime).toBe(ms);
});
});
it("identifies background lines with parentheses", () => {
const lines = parseLrc(
"[00:01.120](Hello)\n[00:03.000]Hi\n[00:03.000]World",
);
expect(lines).toHaveLength(3);
expect(lines[0].isBG).toBe(true);
expect(lines[0].words).toHaveLength(1);
expect(lines[0].words[0].word).toBe("Hello");
expect(lines[1].isBG).toBe(true);
expect(lines[1].words).toHaveLength(1);
expect(lines[1].words[0].word).toBe("Hi");
expect(lines[2].isBG).toBe(false);
expect(lines[2].words).toHaveLength(1);
expect(lines[2].words[0].word).toBe("World");
});
it("trims whitespace from lines and ignore empty lines, while preserving end times", () => {
const lines = parseLrc(
"[00:00.000]\n[00:01.000] \n[00:01.120] Hello \n[00:02.333]\n[00:03.000] World \n[00:05.000] \n",
);
expect(lines).toHaveLength(2);
expect(lines[0].words[0].word).toBe("Hello");
expect(lines[0].endTime).toBe(2333);
expect(lines[1].words[0].word).toBe("World");
expect(lines[1].endTime).toBe(5000);
});
it("stringifies background lines with parentheses and normal lines without", () => {
const lines = [
{
startTime: 1120,
endTime: 3000,
words: [
{ startTime: 1120, endTime: 3000, word: "Hello", romanWord: "" },
],
translatedLyric: "",
romanLyric: "",
isBG: true,
isDuet: false,
},
{
startTime: 3000,
endTime: 3000,
words: [
{ startTime: 3000, endTime: 3000, word: "World", romanWord: "" },
],
translatedLyric: "",
romanLyric: "",
isBG: false,
isDuet: false,
},
];
const result = stringifyLrc(lines);
expect(result).toBe("[00:01.120](Hello)\n[00:03.000]World");
});
it("stringifies lines to expected lrc text", () => {
const result = stringifyLrc([
{
startTime: 1120,
endTime: 3000,
words: [
{ startTime: 1120, endTime: 3000, word: "Hello", romanWord: "" },
{ startTime: 0, endTime: 0, word: " ", romanWord: "" },
{ startTime: 1120, endTime: 3000, word: "world!", romanWord: "" },
],
translatedLyric: "",
romanLyric: "",
isBG: false,
isDuet: false,
},
]);
expect(result).toBe("[00:01.120]Hello world!");
});
it("normalizes invalid startTime when stringifying", () => {
const baseLine = {
endTime: 3000,
words: [{ startTime: 0, endTime: 0, word: "Hello", romanWord: "" }],
translatedLyric: "",
romanLyric: "",
isBG: false,
isDuet: false,
};
expect(stringifyLrc([{ ...baseLine, startTime: -1 }])).toBe(
"[00:00.000]Hello",
);
expect(stringifyLrc([{ ...baseLine, startTime: Number.NaN }])).toBe(
"[00:00.000]Hello",
);
expect(
stringifyLrc([{ ...baseLine, startTime: Number.POSITIVE_INFINITY }]),
).toBe("[00:00.000]Hello");
});
it("keeps parse -> stringify -> parse stable for content and timing", () => {
const input = "[00:01.120]Hello\n[00:03.000](World)";
const first = parseLrc(input);
const text = stringifyLrc(first);
const second = parseLrc(text);
expect(second).toEqual(first);
});
});

View File

@@ -0,0 +1,150 @@
import { describe, expect, it } from "vitest";
import { parseLrcA2, stringifyLrcA2 } from "../src/formats/lrca2";
import { timeStampsTestCases } from "./timestampcase.fixture";
describe("lrca2", () => {
it("parses basic word-timestamped line", () => {
const lines = parseLrcA2(
"[00:01.000]<00:01.000>Hello <00:01.500>World<00:02.000>",
);
expect(lines).toHaveLength(1);
expect(lines[0].startTime).toBe(1000);
expect(lines[0].endTime).toBe(2000);
expect(lines[0].words[0].word).toBe("Hello");
expect(lines[0].words[0].startTime).toBe(1000);
expect(lines[0].words[0].endTime).toBe(1500);
expect(lines[0].words[1].word).toBe(" ");
expect(lines[0].words[2].word).toBe("World");
expect(lines[0].words[2].startTime).toBe(1500);
expect(lines[0].words[2].endTime).toBe(2000);
});
it("handles CRLF and ignores non-lyric lines", () => {
const lines = parseLrcA2(
"[ar: Artist]\r\n#comment\r\n{meta:true}\r\n[00:01.000]<00:01.000>Hello<00:02.000>",
);
expect(lines).toHaveLength(1);
expect(lines[0].startTime).toBe(1000);
expect(lines[0].endTime).toBe(2000);
expect(lines[0].words[0].word).toBe("Hello");
});
it("ignores lines with bad timestamps", () => {
const lines = parseLrcA2(
"[00:01.000]<00:01.000>Hello<00:02.000>\n[invalid]<00:03.000>Bad<00:04.000>\n[-1:00.000]<00:03.000>Bad<00:04.000>\n[NaN:NaN]<00:03.000>Bad<00:04.000>\n[00:03.000]<00:03.000>World<00:04.000>",
);
expect(lines).toHaveLength(2);
expect(lines[0].startTime).toBe(1000);
expect(lines[0].endTime).toBe(2000);
expect(lines[0].words.map((w) => w.word).join("")).toBe("Hello");
expect(lines[1].startTime).toBe(3000);
expect(lines[1].endTime).toBe(4000);
expect(lines[1].words.map((w) => w.word).join("")).toBe("World");
});
it("parses all kinds of valid line timestamps", () => {
const input = timeStampsTestCases
.map(([ts]) => `[${ts}]<${ts}>Word<${ts}>`)
.join("\n");
const lines = parseLrcA2(input);
expect(lines).toHaveLength(timeStampsTestCases.length);
lines.forEach((line, i) => {
const [, ms] = timeStampsTestCases[i];
expect(line.startTime).toBe(ms);
});
});
it("ignores empty lines and lines with only whitespace", () => {
const lines = parseLrcA2(
"[00:00.000] \n[00:01.000]<00:01.000>Hello<00:02.000>\n \n\n[00:03.000]<00:03.000>World<00:04.000>\n \n",
);
expect(lines).toHaveLength(2);
expect(lines[0].words[0].word).toBe("Hello");
expect(lines[1].words[0].word).toBe("World");
});
it("stringifies words and preserves spaces", () => {
const result = stringifyLrcA2([
{
startTime: 1000,
endTime: 3000,
words: [
{ startTime: 1000, endTime: 2000, word: "Hello", romanWord: "" },
{ startTime: 0, endTime: 0, word: " ", romanWord: "" },
{ startTime: 2000, endTime: 3000, word: "World", romanWord: "" },
],
translatedLyric: "",
romanLyric: "",
isBG: false,
isDuet: false,
},
]);
expect(result).toBe(
"[00:01.000]<00:01.000>Hello <00:02.000>World<00:03.000>",
);
});
it("stringifies empty-word line as bare line timestamp", () => {
const result = stringifyLrcA2([
{
startTime: 1000,
endTime: 1000,
words: [],
translatedLyric: "",
romanLyric: "",
isBG: false,
isDuet: false,
},
]);
expect(result).toBe("[00:01.000]");
});
it("normalizes invalid timestamps when stringifying", () => {
const result = stringifyLrcA2([
{
startTime: Number.NaN,
endTime: 0,
words: [
{
startTime: -1,
endTime: Number.POSITIVE_INFINITY,
word: "Hello",
romanWord: "",
},
],
translatedLyric: "",
romanLyric: "",
isBG: false,
isDuet: false,
},
]);
expect(result).toBe("[00:00.000]<00:00.000>Hello<00:00.000>");
});
it("keeps parse -> stringify -> parse stable for content and timing", () => {
const input =
"[00:01.000]<00:01.000>Hello <00:01.500>World<00:02.000>\n[00:03.000]<00:03.000>Again<00:03.500>";
const first = parseLrcA2(input);
const text = stringifyLrcA2(first);
const second = parseLrcA2(text);
expect(second).toEqual(first);
});
it("keeps exactly one space between adjacent words in '<time> word <time> word' pattern", () => {
const input = "[00:01.000]<00:01.000> word <00:01.500> word<00:02.000>";
const output = stringifyLrcA2(parseLrcA2(input));
expect(output).toBe(
"[00:01.000]<00:01.000>word <00:01.500>word<00:02.000>",
);
expect(output).not.toContain(" ");
});
});

View File

@@ -0,0 +1,129 @@
import { describe, expect, it } from "vitest";
import { parseLyl, stringifyLyl } from "../src/formats/lyl";
describe("lyl", () => {
it("parses basic line-timestamped lines", () => {
const lines = parseLyl("[1000,2000]Hello\n[3000,4000]World");
expect(lines).toHaveLength(2);
expect(lines[0].startTime).toBe(1000);
expect(lines[0].endTime).toBe(2000);
expect(lines[0].words[0].word).toBe("Hello");
expect(lines[1].startTime).toBe(3000);
expect(lines[1].endTime).toBe(4000);
expect(lines[1].words[0].word).toBe("World");
});
it("handles CRLF and ignores non-lyric lines", () => {
const lines = parseLyl(
"[type:LyricifyLines]\r\n#comment\r\n{meta:true}\r\nno timestamp\r\n[1000,2000]Hello",
);
expect(lines).toHaveLength(1);
expect(lines[0].startTime).toBe(1000);
expect(lines[0].endTime).toBe(2000);
expect(lines[0].words[0].word).toBe("Hello");
});
it("ignores lines with bad timestamps", () => {
const lines = parseLyl(
"[1000,2000]Hello\n[invalid,2000]Bad\n[-1,2000]Bad\n[NaN,NaN]Bad\n[3000,4000]World",
);
expect(lines).toHaveLength(2);
expect(lines[0].startTime).toBe(1000);
expect(lines[0].endTime).toBe(2000);
expect(lines[0].words[0].word).toBe("Hello");
expect(lines[1].startTime).toBe(3000);
expect(lines[1].endTime).toBe(4000);
expect(lines[1].words[0].word).toBe("World");
});
it("identifies background lines with parentheses", () => {
const lines = parseLyl(
"[1000,2000](Hello)\n[3000,4000]Hi\n[5000,6000]World",
);
expect(lines).toHaveLength(3);
expect(lines[0].isBG).toBe(true);
expect(lines[0].words[0].word).toBe("Hello");
expect(lines[1].isBG).toBe(true);
expect(lines[1].words[0].word).toBe("Hi");
expect(lines[2].isBG).toBe(false);
expect(lines[2].words[0].word).toBe("World");
});
it("trims whitespace from lines and ignore empty lines", () => {
const lines = parseLyl(
"[0,500]\n[600,1000] \n[1000,2000] Hello \n\n[3000,4000] World \n[5000,6000] \n",
);
expect(lines).toHaveLength(2);
expect(lines[0].words[0].word).toBe("Hello");
expect(lines[1].words[0].word).toBe("World");
});
it("stringifies with header and bg markers", () => {
const result = stringifyLyl([
{
startTime: 1000,
endTime: 2000,
words: [
{ startTime: 1000, endTime: 2000, word: "Hello", romanWord: "" },
],
translatedLyric: "",
romanLyric: "",
isBG: true,
isDuet: false,
},
{
startTime: 3000,
endTime: 4000,
words: [
{ startTime: 3000, endTime: 4000, word: "World", romanWord: "" },
],
translatedLyric: "",
romanLyric: "",
isBG: false,
isDuet: false,
},
]);
expect(result).toBe(
"[type:LyricifyLines]\n[1000,2000](Hello)\n[3000,4000]World",
);
});
it("normalizes invalid timestamps when stringifying", () => {
const result = stringifyLyl([
{
startTime: Number.NaN,
endTime: Number.POSITIVE_INFINITY,
words: [{ startTime: 0, endTime: 0, word: "Hello", romanWord: "" }],
translatedLyric: "",
romanLyric: "",
isBG: false,
isDuet: false,
},
{
startTime: -1,
endTime: -2,
words: [{ startTime: 0, endTime: 0, word: "World", romanWord: "" }],
translatedLyric: "",
romanLyric: "",
isBG: false,
isDuet: false,
},
]);
expect(result).toBe("[type:LyricifyLines]\n[0,0]Hello\n[0,0]World");
});
it("keeps parse -> stringify -> parse stable for content and timing", () => {
const input = "[1000,2000]Hello\n[3000,4000](World)";
const first = parseLyl(input);
const text = stringifyLyl(first);
const second = parseLyl(text);
expect(second).toEqual(first);
});
});

View File

@@ -0,0 +1,136 @@
import { describe, expect, it } from "vitest";
import { parseLys, stringifyLys } from "../src/formats/lys";
describe("lys", () => {
it("parses basic word-timestamped line", () => {
const lines = parseLys("[0]Hello(1000,500) World(1500,500)");
expect(lines).toHaveLength(1);
expect(lines[0].startTime).toBe(1000);
expect(lines[0].endTime).toBe(2000);
expect(lines[0].isBG).toBe(false);
expect(lines[0].isDuet).toBe(false);
expect(lines[0].words.map((w) => w.word)).toEqual(["Hello", " World"]);
});
it("parses bg + duet flags from prop and strips bg wrappers", () => {
const lines = parseLys("[8](Hello(1000,500)World(1500,500))");
expect(lines).toHaveLength(1);
expect(lines[0].isBG).toBe(true);
expect(lines[0].isDuet).toBe(true);
expect(lines[0].words.map((w) => w.word).join("")).toBe("HelloWorld");
});
it("handles CRLF and ignores lines without valid prop prefix", () => {
const lines = parseLys(
"no prop\r\n#comment\r\n{meta:true}\r\n[4]Hello(1000,500)World(1500,500)",
);
expect(lines).toHaveLength(1);
expect(lines[0].startTime).toBe(1000);
expect(lines[0].endTime).toBe(2000);
});
it("ignores lines with bad word timestamps", () => {
const lines = parseLys(
"[0]Hello(1000,500)\n[0]Bad(a,b)\n[0]AlsoBad(1000,-10)\n[0]World(2000,500)",
);
expect(lines).toHaveLength(2);
expect(lines[0].words.map((w) => w.word).join("")).toBe("Hello");
expect(lines[1].words.map((w) => w.word).join("")).toBe("World");
});
it("ignores empty lines and lines with only whitespace", () => {
const lines = parseLys(
"[0] \n[3]\n[0]Hello(1000,500) World(1500,500)\n \n\n[0]Next(2000,500) line(2500,500)\n \n",
);
expect(lines).toHaveLength(2);
expect(lines[0].words[0].word).toBe("Hello");
expect(lines[1].words[0].word).toBe("Next");
});
it("stringifies words and preserves spaces", () => {
const result = stringifyLys([
{
startTime: 1000,
endTime: 2000,
words: [
{ startTime: 1000, endTime: 1500, word: "Hello", romanWord: "" },
{ startTime: 0, endTime: 0, word: " ", romanWord: "" },
{ startTime: 1500, endTime: 2000, word: "World", romanWord: "" },
],
translatedLyric: "",
romanLyric: "",
isBG: false,
isDuet: false,
},
]);
expect(result).toBe("[4]Hello (1000,500)World(1500,500)");
});
it("stringifies props according to duet/background presence", () => {
const result = stringifyLys([
{
startTime: 0,
endTime: 0,
words: [{ startTime: 1000, endTime: 1500, word: "A", romanWord: "" }],
translatedLyric: "",
romanLyric: "",
isBG: true,
isDuet: true,
},
{
startTime: 0,
endTime: 0,
words: [{ startTime: 2000, endTime: 2500, word: "B", romanWord: "" }],
translatedLyric: "",
romanLyric: "",
isBG: false,
isDuet: false,
},
]);
expect(result).toBe("[8]A(1000,500)\n[4]B(2000,500)");
});
it("normalizes invalid timestamps when stringifying", () => {
const result = stringifyLys([
{
startTime: 0,
endTime: 0,
words: [
{
startTime: Number.NaN,
endTime: Number.POSITIVE_INFINITY,
word: "Hello",
romanWord: "",
},
{
startTime: -1,
endTime: -2,
word: "World",
romanWord: "",
},
],
translatedLyric: "",
romanLyric: "",
isBG: false,
isDuet: false,
},
]);
expect(result).toBe("[4]Hello(0,0)World(0,0)");
});
it("keeps parse -> stringify -> parse stable for content and timing", () => {
const input = "[4]Hello(1000,500) World(1500,500)\n[8](Again(3000,500))";
const first = parseLys(input);
const text = stringifyLys(first);
const second = parseLys(text);
expect(second).toEqual(first);
});
});

View File

@@ -0,0 +1,134 @@
import { describe, expect, it } from "vitest";
import { parseQrc, stringifyQrc } from "../src/formats/qrc";
describe("qrc", () => {
it("parses basic word-timestamped line", () => {
const lines = parseQrc(
"[1000,1000]Hello(1000,500)World(1500,500)\n[3000,1000]Again(3000,1000)",
);
expect(lines).toHaveLength(2);
expect(lines[0].startTime).toBe(1000);
expect(lines[0].endTime).toBe(2000);
expect(lines[0].words[0].word).toBe("Hello");
expect(lines[0].words[0].startTime).toBe(1000);
expect(lines[0].words[0].endTime).toBe(1500);
expect(lines[0].words[1].word).toBe("World");
expect(lines[0].words[1].startTime).toBe(1500);
expect(lines[0].words[1].endTime).toBe(2000);
expect(lines[1].words[0].word).toBe("Again");
});
it("handles CRLF and ignores lines without valid line timestamp", () => {
const lines = parseQrc(
"no timestamp\r\n[invalid,100]Bad(0,100)\r\n[-1,100]Bad(0,100)\r\n[1000,1000]Hello(1000,1000)",
);
expect(lines).toHaveLength(1);
expect(lines[0].startTime).toBe(1000);
expect(lines[0].endTime).toBe(2000);
expect(lines[0].words[0].word).toBe("Hello");
});
it("ignores empty lines and lines with only whitespace", () => {
const lines = parseQrc(
"[0,20] \n[0,20]\n[0,20]Test(10,10)\n \n\n[0,20] \n",
);
expect(lines).toHaveLength(1);
expect(lines[0].words[0].word).toBe("Test");
});
it("stringifies words and preserves spaces", () => {
const result = stringifyQrc([
{
startTime: 1000,
endTime: 3000,
words: [
{ startTime: 1000, endTime: 2000, word: "Hello", romanWord: "" },
{ startTime: 0, endTime: 0, word: " ", romanWord: "" },
{ startTime: 2000, endTime: 3000, word: "World", romanWord: "" },
],
translatedLyric: "",
romanLyric: "",
isBG: false,
isDuet: false,
},
]);
expect(result).toBe("[1000,2000]Hello(1000,1000) World(2000,1000)");
});
it("stringifies bg lines with full-width wrapping parentheses", () => {
const result = stringifyQrc([
{
startTime: 1000,
endTime: 2000,
words: [
{ startTime: 1000, endTime: 2000, word: "Hello", romanWord: "" },
],
translatedLyric: "",
romanLyric: "",
isBG: true,
isDuet: false,
},
]);
expect(result).toBe("[1000,1000]Hello(1000,1000)");
});
it("parses and keeps bg wrapper lines stable", () => {
const input = "[1000,1000]Hello,(1000,1000) world(2000,2000)";
const lines = parseQrc(input);
expect(lines).toHaveLength(1);
expect(lines[0].isBG).toBe(true);
expect(lines[0].words).toHaveLength(2);
expect(lines[0].words[0].word).toBe("Hello,");
expect(lines[0].words[1].word).toBe(" world");
expect(stringifyQrc(lines)).toBe(input);
});
it("keeps parenthesized fragments in plain word text", () => {
const lines = parseQrc("[0,20]A(x)B(0,10) C(10,10)");
expect(lines).toHaveLength(1);
expect(lines[0].words.map((w) => w.word)).toEqual(["A(x)B", " C"]);
expect(lines[0].words[0].startTime).toBe(0);
expect(lines[0].words[0].endTime).toBe(10);
expect(lines[0].words[1].startTime).toBe(10);
expect(lines[0].words[1].endTime).toBe(20);
});
it("normalizes invalid timestamps when stringifying", () => {
const result = stringifyQrc([
{
startTime: Number.NaN,
endTime: Number.POSITIVE_INFINITY,
words: [
{
startTime: -1,
endTime: Number.POSITIVE_INFINITY,
word: "Hello",
romanWord: "",
},
],
translatedLyric: "",
romanLyric: "",
isBG: false,
isDuet: false,
},
]);
expect(result).toBe("[0,0]Hello(0,0)");
});
it("keeps parse -> stringify -> parse stable for content and timing", () => {
const input =
"[1000,1000]Hello (1000,500)World(1500,500)\n[3000,1000]Again(3000,1000)";
const first = parseQrc(input);
const text = stringifyQrc(first);
const second = parseQrc(text);
expect(second).toEqual(first);
});
});

View File

@@ -0,0 +1,24 @@
const timeStampsTestCasesReg: [string, number][] = [
["1", 1_000],
["2.1", 2_100],
["3.12", 3_120],
["4.123", 4_123],
["5.1234", 5_123],
["6.12354", 6_124],
["7.123456", 7_123],
["8.00100000", 8_001],
["61", 61_000],
["2:01", 2 * 60_000 + 1_000],
["3:1", 3 * 60_000 + 1_000],
["4:1.1", 4 * 60_000 + 1_100],
["5:1.12", 5 * 60_000 + 1_120],
["06:1.12", 6 * 60_000 + 1_120],
["07:01.012", 7 * 60_000 + 1_012],
["61:21", 61 * 60_000 + 21_000],
["1:2:3.123", 1 * 3600_000 + 2 * 60_000 + 3_123],
["01:05:03.123", 1 * 3600_000 + 5 * 60_000 + 3_123],
];
export const timeStampsTestCases = timeStampsTestCasesReg.toSorted(
([, t1], [, t2]) => t1 - t2,
) as readonly [string, number][];

View File

@@ -0,0 +1,134 @@
import { describe, expect, it } from "vitest";
import { parseYrc, stringifyYrc } from "../src/formats/yrc";
describe("yrc", () => {
it("parses basic word-timestamped line", () => {
const lines = parseYrc(
"[1000,1000](1000,500,0)Hello(1500,500,0)World\n[3000,1000](3000,1000,0)Again",
);
expect(lines).toHaveLength(2);
expect(lines[0].startTime).toBe(1000);
expect(lines[0].endTime).toBe(2000);
expect(lines[0].words[0].word).toBe("Hello");
expect(lines[0].words[0].startTime).toBe(1000);
expect(lines[0].words[0].endTime).toBe(1500);
expect(lines[0].words[1].word).toBe("World");
expect(lines[0].words[1].startTime).toBe(1500);
expect(lines[0].words[1].endTime).toBe(2000);
expect(lines[1].words[0].word).toBe("Again");
});
it("handles CRLF and ignores lines without valid line timestamp", () => {
const lines = parseYrc(
"no timestamp\r\n[invalid,100](0,100,0)Bad\r\n[-1,100](0,100,0)Bad\r\n[1000,1000](1000,1000,0)Hello",
);
expect(lines).toHaveLength(1);
expect(lines[0].startTime).toBe(1000);
expect(lines[0].endTime).toBe(2000);
expect(lines[0].words[0].word).toBe("Hello");
});
it("ignores empty lines and lines with only whitespace", () => {
const lines = parseYrc(
"[0,20] \n[0,20]\n[0,20](10,10,0)Test\n \n\n[0,20] \n",
);
expect(lines).toHaveLength(1);
expect(lines[0].words[0].word).toBe("Test");
});
it("stringifies words and preserves spaces", () => {
const result = stringifyYrc([
{
startTime: 1000,
endTime: 3000,
words: [
{ startTime: 1000, endTime: 2000, word: "Hello", romanWord: "" },
{ startTime: 0, endTime: 0, word: " ", romanWord: "" },
{ startTime: 2000, endTime: 3000, word: "World", romanWord: "" },
],
translatedLyric: "",
romanLyric: "",
isBG: false,
isDuet: false,
},
]);
expect(result).toBe("[1000,2000](1000,1000,0)Hello (2000,1000,0)World");
});
it("stringifies bg lines with full-width wrapping parentheses", () => {
const result = stringifyYrc([
{
startTime: 1000,
endTime: 2000,
words: [
{ startTime: 1000, endTime: 2000, word: "Hello", romanWord: "" },
],
translatedLyric: "",
romanLyric: "",
isBG: true,
isDuet: false,
},
]);
expect(result).toBe("[1000,1000](1000,1000,0)Hello");
});
it("parses and keeps bg wrapper lines stable", () => {
const input = "[1000,1000](1000,1000,0)Hello, (2000,2000,0)world";
const lines = parseYrc(input);
expect(lines).toHaveLength(1);
expect(lines[0].isBG).toBe(true);
expect(lines[0].words).toHaveLength(2);
expect(lines[0].words[0].word).toBe("Hello, ");
expect(lines[0].words[1].word).toBe("world");
expect(stringifyYrc(lines)).toBe(input);
});
it("keeps parenthesized fragments in plain word text", () => {
const lines = parseYrc("[0,20](0,10,0)A(x)(10,10,0) B");
expect(lines).toHaveLength(1);
expect(lines[0].words.map((w) => w.word)).toEqual(["A(x)", " B"]);
expect(lines[0].words[0].startTime).toBe(0);
expect(lines[0].words[0].endTime).toBe(10);
expect(lines[0].words[1].startTime).toBe(10);
expect(lines[0].words[1].endTime).toBe(20);
});
it("normalizes invalid timestamps when stringifying", () => {
const result = stringifyYrc([
{
startTime: Number.NaN,
endTime: Number.POSITIVE_INFINITY,
words: [
{
startTime: -1,
endTime: Number.POSITIVE_INFINITY,
word: "Hello",
romanWord: "",
},
],
translatedLyric: "",
romanLyric: "",
isBG: false,
isDuet: false,
},
]);
expect(result).toBe("[0,0](0,0,0)Hello");
});
it("keeps parse -> stringify -> parse stable for content and timing", () => {
const input =
"[1000,1000](1000,500,0)Hello (1500,500,0)World\n[3000,1000](3000,1000,0)Again";
const first = parseYrc(input);
const text = stringifyYrc(first);
const second = parseYrc(text);
expect(second).toEqual(first);
});
});