From d80d939651d97aa5ca2aa07c503e84c15ab29e2d Mon Sep 17 00:00:00 2001 From: Xie Zhihao Date: Fri, 5 Aug 2022 11:44:34 +0800 Subject: [PATCH] Postpone ASS generation (#64) --- config/example.jsonc | 10 ++ server/components/downloader.js | 7 +- server/components/index.js | 1 + server/components/subtitler.js | 151 +++++++++++++++++++++ server/models/lyrics/common/lyrics.js | 50 ++----- server/models/lyrics/index.js | 2 + server/models/lyrics/petit-lyrics/entry.js | 5 +- server/models/lyrics/utils/compile.js | 43 ------ server/models/lyrics/utils/header.js | 16 --- server/models/lyrics/utils/index.js | 2 - server/server.js | 17 ++- server/utils/index.js | 1 + server/utils/number.js | 5 + src/components/SettingsWindow.js | 8 ++ src/locales/zh.js | 4 + 15 files changed, 217 insertions(+), 105 deletions(-) create mode 100644 server/components/subtitler.js delete mode 100644 server/models/lyrics/utils/compile.js delete mode 100644 server/models/lyrics/utils/header.js delete mode 100644 server/models/lyrics/utils/index.js create mode 100644 server/utils/number.js diff --git a/config/example.jsonc b/config/example.jsonc index 36acf8f..7ba85be 100644 --- a/config/example.jsonc +++ b/config/example.jsonc @@ -48,6 +48,16 @@ // -ac 1 -y "${output}"`. "script": "" }, + "subtitle": { + // The style of subtitles. It can be one of the `traditional` or `karaoke`. + // + // * `traditional`: render subtitles by lines. + // * `karaoke`: render subtitles by words. Certain lyrics without karaoke + // format support will fallback to `traditional` rendering. + // + // If it is unspecified, `karaoke` will be used by default. + "style": "karaoke" + }, "providers": { "mv": { "bilibili": { diff --git a/server/components/downloader.js b/server/components/downloader.js index 41668aa..69a623b 100644 --- a/server/components/downloader.js +++ b/server/components/downloader.js @@ -76,10 +76,13 @@ class Downloader { this.downloading = true; entry.onDownload(); const lyrics = entry.lyrics(); - const lyricsPath = `${this.location}/${lyrics.id()}.ass`; + const lyricsPath = `${this.location}/${lyrics.id()}.json`; if (!fs.existsSync(lyricsPath)) { try { - fs.writeFileSync(lyricsPath, await lyrics.formattedLyrics()); + fs.writeFileSync( + lyricsPath, + JSON.stringify(await lyrics.formattedLyrics()) + ); } catch (e) { console.error(e); // Clean up. diff --git a/server/components/index.js b/server/components/index.js index 5b640bb..aa20f16 100644 --- a/server/components/index.js +++ b/server/components/index.js @@ -2,3 +2,4 @@ export { default as Database } from "./database"; export { default as Downloader } from "./downloader"; export { default as Encoder } from "./encoder"; export { default as Player } from "./player"; +export { default as Subtitler } from "./subtitler"; diff --git a/server/components/subtitler.js b/server/components/subtitler.js new file mode 100644 index 0000000..4d627a2 --- /dev/null +++ b/server/components/subtitler.js @@ -0,0 +1,151 @@ +import { Style } from "../models/lyrics/common/lyrics"; +import { padStart } from "../utils"; + +const compileWord = (word) => { + const duration = Math.round((word["endTime"] - word["startTime"]) * 100); + return `{\\K${duration}}${word["word"]}`; +}; + +const formatTime = (time) => { + const hour = parseInt(String(time / 3600)); + const min = parseInt(String((time - 3600 * hour) / 60)); + const sec = parseInt(String(time - 3600 * hour - 60 * min)); + const mil = Math.min( + Math.round((time - 3600 * hour - 60 * min - sec) * 100), + 99 + ); + return `${hour}:${padStart(min, 2)}:${padStart(sec, 2)}.${padStart(mil, 2)}`; +}; + +const formatColor = (color) => { + return color.toUpperCase().replace("#", "&H"); +}; + +class Subtitler { + ASS_STYLES = ["K1", "K2"]; + + style = Style.Karaoke; + // TODO: These constants should be configurable. + ADVANCE = 5; + DELAY = 1; + FONTSIZE = 24; + PRIMARY_COLOR = "#000000FF"; + SECONDARY_COLOR = "#00FFFFFF"; + OUTLINE_COLOR = "#00000000"; + BACKGROUND_COLOR = "#00000000"; + BOLD = false; + OUTLINE = 2; + SHADOW = 0; + + constructor(style) { + this.style = style; + } + + compile = (lines, lyrics) => { + const style = this.bestStyle(lyrics.style()); + const dialogues = this.dialogues(lines, style); + const header = this.header(lyrics.title()); + return `${header}\n${dialogues}`; + }; + + header = (title) => { + // prettier-ignore + return `[Script Info] +Title: ${title} +ScriptType: v4.00+ + +[V4+ Styles] +Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding +Style: K1,Source Han Serif,${this.FONTSIZE},${formatColor(this.PRIMARY_COLOR)},${formatColor(this.SECONDARY_COLOR)},${formatColor(this.OUTLINE_COLOR)},${formatColor(this.BACKGROUND_COLOR)},${this.BOLD ? 1 : 0},0,0,0,100,100,0,0,1,${this.OUTLINE},${this.SHADOW},1,60,30,80,1 +Style: K2,Source Han Serif,${this.FONTSIZE},${formatColor(this.PRIMARY_COLOR)},${formatColor(this.SECONDARY_COLOR)},${formatColor(this.OUTLINE_COLOR)},${formatColor(this.BACKGROUND_COLOR)},${this.BOLD ? 1 : 0},0,0,0,100,100,0,0,1,${this.OUTLINE},${this.SHADOW},3,30,60,40,1 +[Events] +Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text +`; + }; + + dialogue = (line, style, assStyle, advance) => { + let words; + switch (style) { + case Style.Traditional: + words = `{\\K${Math.round(advance * 100)}}{\\K0}`; + words += line["line"]; + break; + case Style.Karaoke: + words = `{\\K${Math.round(advance * 100)}}`; + words += line["words"].map(compileWord).join(""); + break; + default: + throw new Error(`unexpected style "${style}"`); + } + return `Dialogue: 0,${formatTime(line["startTime"] - advance)},${formatTime( + line["endTime"] + this.DELAY + )},${assStyle},,0,0,0,,${words}`; + }; + + dialogues = (lines, style) => { + let result = []; + let displays = new Array(this.ASS_STYLES.length).fill(0); + for (const line of lines) { + // Escape empty lines. + if (!line["line"]) { + continue; + } + + // Identify new paragraphs. + let newParagraph = false; + const lastEndTime = Math.max(...displays); + if (lastEndTime < line["startTime"] - this.ADVANCE) { + // Assert there is a new paragraph if there are more than `advance` + // blank. + newParagraph = true; + } + + // Calculate lyrics show in advance time. + const priorEndTime = Math.min(...displays); + let index = 0; + if (!newParagraph) { + index = displays.indexOf(priorEndTime); + } + let advance = Math.max(line["startTime"] - priorEndTime, 0); + if (newParagraph) { + advance = Math.min(advance, this.ADVANCE); + } + + const assStyle = this.ASS_STYLES[index]; + result.push(this.dialogue(line, style, assStyle, advance)); + + // Clean up. + if (newParagraph) { + displays.fill(line["startTime"] - advance); + } + displays[index] = line["endTime"] + this.DELAY; + } + return result.join("\n"); + }; + + bestStyle = (style) => { + switch (style) { + case Style.Traditional: + switch (this.style) { + case Style.Traditional: + case Style.Karaoke: + return Style.Traditional; + default: + throw new Error(`unexpected style "${this.style}"`); + } + case Style.Karaoke: + switch (this.style) { + case Style.Traditional: + return Style.Traditional; + case Style.Karaoke: + return Style.Karaoke; + default: + throw new Error(`unexpected style "${this.style}"`); + } + default: + throw new Error(`unexpected style "${style}"`); + } + }; +} + +export default Subtitler; diff --git a/server/models/lyrics/common/lyrics.js b/server/models/lyrics/common/lyrics.js index 37b56ce..6c670b0 100644 --- a/server/models/lyrics/common/lyrics.js +++ b/server/models/lyrics/common/lyrics.js @@ -14,27 +14,15 @@ class Word { this.endTime = endTime; } - compile = () => { - const duration = Math.round((this.endTime - this.startTime) * 100); - return `{\\K${duration}}${this.word}`; + format = () => { + return { + word: this.word, + startTime: this.startTime, + endTime: this.endTime, + }; }; } -const padTime = (timeComponent) => { - return String(timeComponent).padStart(2, "0"); -}; - -const convertTime = (time) => { - const hour = parseInt(String(time / 3600)); - const min = parseInt(String((time - 3600 * hour) / 60)); - const sec = parseInt(String(time - 3600 * hour - 60 * min)); - const mil = Math.min( - Math.round((time - 3600 * hour - 60 * min - sec) * 100), - 99 - ); - return `${hour}:${padTime(min)}:${padTime(sec)}.${padTime(mil)}`; -}; - class Line { line; startTime; @@ -47,25 +35,13 @@ class Line { this.endTime = endTime; } - isEmpty = () => { - return this.line.trim().length === 0; - }; - - compile = (style, assStyle, advance, delay) => { - let words = this.line; - switch (style) { - case Style.Traditional: - break; - case Style.Karaoke: - words = `{\\K${Math.round(advance * 100)}}`; - words += this.words.map((value) => value.compile()).join(""); - break; - default: - throw new Error(`unexpected style "${style}"`); - } - return `Dialogue: 0,${convertTime(this.startTime - advance)},${convertTime( - this.endTime + delay - )},${assStyle},,0,0,0,,${words}`; + format = () => { + return { + line: this.line, + startTime: this.startTime, + endTime: this.endTime, + words: this.words.map((value) => value.format()), + }; }; } diff --git a/server/models/lyrics/index.js b/server/models/lyrics/index.js index 4d2af61..1784e5f 100644 --- a/server/models/lyrics/index.js +++ b/server/models/lyrics/index.js @@ -1 +1,3 @@ +export { Style } from "./common"; + export { Provider as PetitLyricsProvider } from "./petit-lyrics"; diff --git a/server/models/lyrics/petit-lyrics/entry.js b/server/models/lyrics/petit-lyrics/entry.js index 85accef..6eedfa3 100644 --- a/server/models/lyrics/petit-lyrics/entry.js +++ b/server/models/lyrics/petit-lyrics/entry.js @@ -1,7 +1,6 @@ import fetch from "node-fetch"; import { parseStringPromise as parseXMLString } from "xml2js"; import { Line, Style, Word } from "../common"; -import { compile, header } from "../utils"; const NAME = "petit_lyrics"; @@ -74,7 +73,6 @@ class Entry { }; formattedLyrics = async () => { - const h = header(this.title_); const rawLyrics = await this.rawLyrics(); const text = await parseXMLString(rawLyrics); @@ -96,8 +94,7 @@ class Entry { } ls.push(l); } - const result = compile(this.style(), ls); - return `${h}${result}`; + return ls.map((value) => value.format()); }; rawLyrics = async () => { diff --git a/server/models/lyrics/utils/compile.js b/server/models/lyrics/utils/compile.js deleted file mode 100644 index 25f3f09..0000000 --- a/server/models/lyrics/utils/compile.js +++ /dev/null @@ -1,43 +0,0 @@ -const compile = (style, lines) => { - const ASS_STYLES = ["K1", "K2"]; - const ADVANCE = 5; - const DELAY = 1; - - let result = []; - let displays = new Array(ASS_STYLES.length).fill(0); - for (const line of lines) { - if (line.isEmpty()) { - continue; - } - - // Calculate lyrics show in advance time. - let newParagraph = false; - const lastEndTime = Math.max(...displays); - if (lastEndTime < line.startTime - 5) { - // Assert there is a new paragraph since there are more than `ADVANCE` - // seconds blank. - newParagraph = true; - } - - const priorEndTime = Math.min(...displays); - let index = displays.indexOf(priorEndTime); - if (newParagraph) { - index = 0; - } - let advance = Math.max(line.startTime - priorEndTime, 0); - if (newParagraph) { - advance = Math.min(advance, ADVANCE); - } - - const assStyle = ASS_STYLES[index]; - result.push(line.compile(style, assStyle, advance, DELAY)); - - if (newParagraph) { - displays.fill(line.startTime - advance); - } - displays[index] = line.endTime + DELAY; - } - return result.join("\n"); -}; - -export default compile; diff --git a/server/models/lyrics/utils/header.js b/server/models/lyrics/utils/header.js deleted file mode 100644 index 9a6e1f6..0000000 --- a/server/models/lyrics/utils/header.js +++ /dev/null @@ -1,16 +0,0 @@ -const header = (title) => { - return `[Script Info] -Title: ${title} -ScriptType: v4.00+ - -[V4+ Styles] -Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding -Style: K1,Source Han Serif,24,&H000000FF,&H00FFFFFF,&H00000000,&H00000000,1,0,0,0,100,100,0,0,1,2,0,1,60,30,80,1 -Style: K2,Source Han Serif,24,&H000000FF,&H00FFFFFF,&H00000000,&H00000000,1,0,0,0,100,100,0,0,1,2,0,3,30,60,40,1 - -[Events] -Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text -`; -}; - -export default header; diff --git a/server/models/lyrics/utils/index.js b/server/models/lyrics/utils/index.js deleted file mode 100644 index c02ad2c..0000000 --- a/server/models/lyrics/utils/index.js +++ /dev/null @@ -1,2 +0,0 @@ -export { default as compile } from "./compile"; -export { default as header } from "./header"; diff --git a/server/server.js b/server/server.js index f3b9fa2..8cec896 100644 --- a/server/server.js +++ b/server/server.js @@ -10,7 +10,7 @@ import { PetitLyricsLyricsProvider, YoutubeMVProvider, } from "./models"; -import { Database, Downloader, Encoder, Player } from "./components"; +import { Database, Downloader, Encoder, Player, Subtitler } from "./components"; const defaultConfig = { server: { @@ -28,6 +28,9 @@ const defaultConfig = { method: "remove_center_channel", script: "", }, + subtitle: { + style: "karaoke", + }, providers: { mv: { bilibili: { @@ -54,6 +57,7 @@ class Server { database; downloader; encoder; + subtitler; player; server = express(); listener; @@ -134,6 +138,10 @@ class Server { this.handleEncodeComplete ); + // Setup subtitler. + const subtitleConfig = this.config["subtitle"]; + this.subtitler = new Subtitler(subtitleConfig["style"] || "karaoke"); + // Setup player. this.player = new Player( (entry) => { @@ -327,6 +335,13 @@ class Server { }; handleEncodeComplete = (entry) => { + // Compile lyrics. + const lyricsPath = entry.lyricsPath(); + const lyrics = fs.readFileSync(lyricsPath.replace(/.ass$/, ".json")); + const lines = JSON.parse(lyrics); + const ass = this.subtitler.compile(lines, entry.lyrics()); + fs.writeFileSync(lyricsPath, ass); + this.player.add(entry); }; diff --git a/server/utils/index.js b/server/utils/index.js index 088b01c..e98bcdf 100644 --- a/server/utils/index.js +++ b/server/utils/index.js @@ -1,2 +1,3 @@ export { default as exec } from "./exec"; +export * from "./number"; export { default as shuffle } from "./shuffle"; diff --git a/server/utils/number.js b/server/utils/number.js new file mode 100644 index 0000000..bbaf81b --- /dev/null +++ b/server/utils/number.js @@ -0,0 +1,5 @@ +const padStart = (n, digit) => { + return String(n).padStart(digit, "0"); +}; + +export { padStart }; diff --git a/src/components/SettingsWindow.js b/src/components/SettingsWindow.js index 301930e..070ff9d 100644 --- a/src/components/SettingsWindow.js +++ b/src/components/SettingsWindow.js @@ -94,6 +94,14 @@ const SettingsWindow = (props) => {