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

@@ -95,33 +95,18 @@ export const DEFAULT_TRANBOX_SETTING = {
enSug: OPT_SUG_YOUDAO, // 英文建议
};
const SUBTITLE_WINDOW_STYLE = `container-type: inline-size;
position: absolute;
bottom: 10%;
left: 50%;
transform: translateX(-50%);
width: 80%;
padding: 10px;
background-color: rgba(0, 0, 0, 0.7);
const SUBTITLE_WINDOW_STYLE = `padding: 0.5em 1em;
background-color: rgba(0, 0, 0, 0.5);
color: white;
text-align: center;
line-height: 1.2;
text-shadow: 1px 1px 2px black;
pointer-events: none;
z-index: 2147483647;
opacity: 0;
cursor: grab;
transition: opacity 0.2s ease-in-out;`;
transition: opacity 0.2s ease-in-out;
display: inline-block`;
const SUBTITLE_ORIGIN_STYLE = `margin:0;
padding: 0;
opacity: 0.8;
font-size: clamp(1.5rem, 3cqw, 3rem);`;
const SUBTITLE_ORIGIN_STYLE = `font-size: clamp(1.5rem, 3cqw, 3rem);`;
const SUBTITLE_TRANSLATION_STYLE = `margin:0;
padding: 0;
opacity: 1;
font-size: clamp(1.5rem, 3cqw, 3rem);`;
const SUBTITLE_TRANSLATION_STYLE = `font-size: clamp(1.5rem, 3cqw, 3rem);`;
export const DEFAULT_SUBTITLE_SETTING = {
enabled: true, // 是否开启

View File

@@ -21,7 +21,8 @@ export const loadingSvg = `
export const createLogoSvg = ({
width = "100%",
height = "100%",
viewBox = "-13 -14 60 60",
viewBox = "-20 -20 70 70",
isSelected = false,
} = {}) => {
const svgNS = "http://www.w3.org/2000/svg";
const svgElement = document.createElementNS(svgNS, "svg");
@@ -51,5 +52,14 @@ export const createLogoSvg = ({
svgElement.appendChild(path1);
svgElement.appendChild(path2);
if (isSelected) {
const redLine = document.createElementNS(svgNS, "path");
redLine.setAttribute("d", "M0 36 L32 36");
redLine.setAttribute("stroke", "red");
redLine.setAttribute("stroke-width", "3");
redLine.setAttribute("stroke-linecap", "round");
svgElement.appendChild(redLine);
}
return svgElement;
};

View File

@@ -362,3 +362,15 @@ export const truncateWords = (str, maxLength) => {
const truncated = str.slice(0, maxLength);
return truncated.slice(0, truncated.lastIndexOf(" ")) + " …";
};
/**
* 生成随机数
* @param {*} min
* @param {*} max
* @param {*} integer
* @returns
*/
export const randomBetween = (min, max, integer = true) => {
const value = Math.random() * (max - min) + min;
return integer ? Math.floor(value) : value;
};

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,
});
}

View File

@@ -104,16 +104,6 @@ export default function SubtitleSetting() {
</Grid>
</Box>
<TextField
size="small"
label={i18n("background_styles")}
name="windowStyle"
value={windowStyle}
onChange={handleChange}
maxRows={10}
multiline
fullWidth
/>
<TextField
size="small"
label={i18n("origin_styles")}
@@ -134,6 +124,16 @@ export default function SubtitleSetting() {
multiline
fullWidth
/>
<TextField
size="small"
label={i18n("background_styles")}
name="windowStyle"
value={windowStyle}
onChange={handleChange}
maxRows={10}
multiline
fullWidth
/>
</Stack>
</Box>
);