feat: format subtitle

This commit is contained in:
Gabe
2025-10-09 02:15:58 +08:00
parent 40b3072e5f
commit 71b2d62c9f
7 changed files with 299 additions and 67 deletions

View File

@@ -51,7 +51,7 @@ export class BilingualSubtitleManager {
destroy() {
logger.info("Bilingual Subtitle Manager: Destroying...");
this.#removeEventListeners();
this.#captionWindowEl?.parentElement?.remove();
this.#captionWindowEl?.parentElement?.parentElement?.remove();
this.#formattedSubtitles = [];
}
@@ -60,14 +60,36 @@ export class BilingualSubtitleManager {
*/
#createCaptionWindow() {
const container = document.createElement("div");
container.className = `kiss-caption-window-container notranslate`;
container.style.cssText = `position:absolute; width:100%; height:100%; left:0; top:0;`;
container.className = `kiss-caption-container notranslate`;
Object.assign(container.style, {
position: "absolute",
width: "100%",
height: "100%",
left: "0",
top: "0",
});
const paper = document.createElement("div");
paper.className = `kiss-caption-paper`;
Object.assign(paper.style, {
position: "absolute",
width: "80%",
left: "50%",
bottom: "10%",
transform: "translateX(-50%)",
textAlign: "center",
cursor: "grab",
containerType: "inline-size",
pointerEvents: "none",
zIndex: "2147483647",
});
this.#captionWindowEl = document.createElement("div");
this.#captionWindowEl.className = `kiss-caption-window`;
this.#captionWindowEl.style.cssText = this.#setting.windowStyle;
container.appendChild(this.#captionWindowEl);
paper.appendChild(this.#captionWindowEl);
container.appendChild(paper);
const videoContainer = this.#videoEl.parentElement?.parentElement;
if (!videoContainer) {

View File

@@ -2,9 +2,10 @@ import { logger } from "../libs/log.js";
import { apiTranslate } from "../apis/index.js";
import { BilingualSubtitleManager } from "./BilingualSubtitleManager.js";
import { getGlobalVariable } from "./globalVariable.js";
import { MSG_XHR_DATA_YOUTUBE } from "../config";
import { truncateWords } from "../libs/utils.js";
import { MSG_XHR_DATA_YOUTUBE, APP_NAME } from "../config";
import { truncateWords, sleep } from "../libs/utils.js";
import { createLogoSvg } from "../libs/svg.js";
import { randomBetween } from "../libs/utils.js";
const VIDEO_SELECT = "#container video";
const CONTORLS_SELECT = ".ytp-right-controls";
@@ -15,6 +16,9 @@ class YouTubeCaptionProvider {
#videoId = "";
#subtitles = [];
#managerInstance = null;
#toggleButton = null;
#enabled = false;
#ytControls = null;
constructor(setting = {}) {
this.#setting = setting;
@@ -52,9 +56,21 @@ class YouTubeCaptionProvider {
});
}
async #doubleClick() {
const button = this.#ytControls.querySelector(
"button.ytp-subtitles-button"
);
if (button) {
await sleep(randomBetween(50, 100));
button.click();
await sleep(randomBetween(500, 1000));
button.click();
}
}
#injectToggleButton() {
const controls = document.querySelector(CONTORLS_SELECT);
if (!controls) {
this.#ytControls = document.querySelector(CONTORLS_SELECT);
if (!this.#ytControls) {
logger.warn("Youtube Provider: Could not find YouTube player controls.");
return;
}
@@ -68,30 +84,28 @@ class YouTubeCaptionProvider {
const toggleButton = document.createElement("button");
toggleButton.className =
"ytp-button notranslate kiss-bilingual-subtitle-button";
toggleButton.title = "Toggle Bilingual Subtitles";
toggleButton.title = APP_NAME;
Object.assign(toggleButton.style, {
color: "white",
opacity: "0.8",
opacity: "0.5",
});
toggleButton.appendChild(createLogoSvg());
kissControls.appendChild(toggleButton);
toggleButton.onclick = () => {
if (!this.#managerInstance) {
if (!this.#enabled) {
logger.info(`Youtube Provider: Feature toggled ON.`);
toggleButton.style.opacity = "1";
this.#setting.enabled = true;
this.#startManager();
} else {
logger.info(`Youtube Provider: Feature toggled OFF.`);
toggleButton.style.opacity = "0.5";
this.#setting.enabled = false;
this.#destroyManager();
}
};
this.#toggleButton = toggleButton;
this.#ytControls.before(kissControls);
controls.before(kissControls);
this.#doubleClick();
}
#findCaptionTrack(ytPlayer) {
@@ -144,6 +158,10 @@ class YouTubeCaptionProvider {
async #handleInterceptedRequest(url, responseText) {
try {
if (!responseText) {
return;
}
const ytPlayer = await getGlobalVariable("ytInitialPlayerResponse");
const captionTrack = this.#findCaptionTrack(ytPlayer);
if (!captionTrack) {
@@ -169,13 +187,13 @@ class YouTubeCaptionProvider {
responseText
);
if (!subtitleEvents) {
logger.warn("Youtube Provider: SubtitleEvents not got.");
logger.info("Youtube Provider: SubtitleEvents not got.");
return;
}
const subtitles = this.#formatSubtitles(subtitleEvents);
if (subtitles.length === 0) {
logger.warn("Youtube Provider: No subtitles after format.");
logger.info("Youtube Provider: No subtitles after format.");
return;
}
@@ -189,17 +207,22 @@ class YouTubeCaptionProvider {
this.#subtitles = subtitles;
this.#videoId = videoId;
this.#destroyManager();
if (this.#toggleButton) {
this.#toggleButton.style.opacity = subtitles.length ? "1" : "0.5";
}
if (this.#setting.enabled) {
if (this.#enabled) {
this.#destroyManager();
this.#startManager();
}
}
#startManager() {
if (this.#managerInstance) {
if (this.#enabled || this.#managerInstance) {
return;
}
this.#enabled = true;
this.#toggleButton?.replaceChildren(createLogoSvg({ isSelected: true }));
const videoEl = document.querySelector(VIDEO_SELECT);
if (!videoEl) {
@@ -210,14 +233,12 @@ class YouTubeCaptionProvider {
if (this.#subtitles?.length === 0) {
// todo: 等待并给出用户提示
logger.info("Youtube Provider: No subtitles");
this.#doubleClick();
return;
}
logger.info("Youtube Provider: Starting manager...");
const ytCaption = document.querySelector(YT_CAPTION_SELECT);
ytCaption && (ytCaption.style.display = "none");
this.#managerInstance = new BilingualSubtitleManager({
videoEl,
formattedSubtitles: this.#subtitles,
@@ -225,21 +246,29 @@ class YouTubeCaptionProvider {
setting: this.#setting,
});
this.#managerInstance.start();
const ytCaption = document.querySelector(YT_CAPTION_SELECT);
ytCaption && (ytCaption.style.display = "none");
}
#destroyManager() {
if (!this.#enabled) {
return;
}
this.#enabled = false;
this.#toggleButton?.replaceChildren(createLogoSvg());
logger.info("Youtube Provider: Destroying manager...");
const ytCaption = document.querySelector(YT_CAPTION_SELECT);
ytCaption && (ytCaption.style.display = "block");
if (this.#managerInstance) {
logger.info("Youtube Provider: Destroying manager...");
const ytCaption = document.querySelector(YT_CAPTION_SELECT);
ytCaption && (ytCaption.style.display = "block");
this.#managerInstance.destroy();
this.#managerInstance = null;
}
}
// todo: 没有标点断句的处理
#formatSubtitles(data) {
const events = data?.events;
if (!Array.isArray(events)) return [];
@@ -300,10 +329,179 @@ class YouTubeCaptionProvider {
}
}
return lines.map((line) => ({
...line,
duration: Math.max(0, line.end - line.start),
text: truncateWords(line.text.trim().replace(/\s+/g, " "), 300),
const isPoor = this.#isQualityPoor(lines);
if (isPoor) {
return this.#processSubtitles(data);
}
return lines.map((item) => ({
...item,
duration: Math.max(0, item.end - item.start),
text: truncateWords(item.text.trim().replace(/\s+/g, " "), 250),
}));
}
#isQualityPoor(lines, lengthThreshold = 250, percentageThreshold = 0.1) {
if (lines.length === 0) return false;
const longLinesCount = lines.filter(
(line) => line.text.length > lengthThreshold
).length;
return longLinesCount / lines.length > percentageThreshold;
}
#processSubtitles(data, { timeout = 1500, maxWords = 15 } = {}) {
const groupedPauseWords = {
1: new Set([
"actually",
"also",
"although",
"and",
"anyway",
"as",
"basically",
"because",
"but",
"eventually",
"frankly",
"honestly",
"hopefully",
"however",
"if",
"instead",
"it's",
"just",
"let's",
"like",
"literally",
"maybe",
"meanwhile",
"nevertheless",
"nonetheless",
"now",
"okay",
"or",
"otherwise",
"perhaps",
"personally",
"probably",
"right",
"since",
"so",
"suddenly",
"that's",
"then",
"there's",
"therefore",
"though",
"thus",
"unless",
"until",
"well",
"while",
]),
2: new Set([
"after all",
"at first",
"at least",
"even if",
"even though",
"for example",
"for instance",
"i believe",
"i guess",
"i mean",
"i suppose",
"i think",
"in fact",
"in the end",
"of course",
"then again",
"to be fair",
"you know",
"you see",
]),
3: new Set([
"as a result",
"by the way",
"in other words",
"in that case",
"in this case",
"to be clear",
"to be honest",
]),
};
const sentences = [];
let currentBuffer = [];
let bufferWordCount = 0;
const joinSegs = (segs) => ({
text: segs
.map((s) => s.text)
.join(" ")
.trim(),
start: segs[0].start,
end: segs[segs.length - 1].end,
});
const flushBuffer = () => {
if (currentBuffer.length > 0) {
sentences.push(joinSegs(currentBuffer));
}
currentBuffer = [];
bufferWordCount = 0;
};
data.events?.forEach((event) => {
event.segs?.forEach((seg, j) => {
const text = seg.utf8?.trim() || "";
if (!text) return;
const start = event.tStartMs + (seg.tOffsetMs ?? 0);
const lastSegment = currentBuffer[currentBuffer.length - 1];
if (lastSegment) {
if (!lastSegment.end) {
lastSegment.end = start;
}
const isEndOfSentence = /[.?!\]]$/.test(lastSegment.text);
const isTimeout = start - lastSegment.end > timeout;
const isWordLimitExceeded = bufferWordCount >= maxWords;
const startsWithPauseWord = groupedPauseWords["1"].has(
text.toLowerCase().split(" ")[0]
);
// todo: 考虑连词开头
const isNewClause =
(startsWithPauseWord && currentBuffer.length > 1) ||
text.startsWith("[");
if (
isEndOfSentence ||
isTimeout ||
isWordLimitExceeded ||
isNewClause
) {
flushBuffer();
}
}
const currentSegment = { text, start };
if (j === event.segs.length - 1) {
currentSegment.end = event.tStartMs + event.dDurationMs;
}
currentBuffer.push(currentSegment);
bufferWordCount += text.split(/\s+/).length;
});
});
flushBuffer();
return sentences.map((item) => ({
...item,
duration: item.end - item.start,
}));
}
}

View File

@@ -12,6 +12,11 @@ const providers = [
export function runSubtitle({ href, setting, rule }) {
try {
const subtitleSetting = setting.subtitleSetting || DEFAULT_SUBTITLE_SETTING;
if (!subtitleSetting.enabled) {
return;
}
const provider = providers.find((item) => isMatch(href, item.pattern));
if (provider) {
const id = "kiss-translator-injector";
@@ -22,7 +27,7 @@ export function runSubtitle({ href, setting, rule }) {
setting.transApis.find((api) => api.apiSlug === rule.apiSlug) ||
DEFAULT_API_SETTING;
provider.start({
...(setting.subtitleSetting || DEFAULT_SUBTITLE_SETTING),
...subtitleSetting,
apiSetting,
});
}