From a34fe83790fd218ae43d2d3be9d701bbf559a36c Mon Sep 17 00:00:00 2001 From: airhunter Date: Wed, 1 Apr 2026 16:49:50 +0800 Subject: [PATCH 01/15] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E8=A7=86?= =?UTF-8?q?=E9=A2=91=E5=AD=97=E5=B9=95=E7=BF=BB=E8=AF=91=E5=8A=9F=E8=83=BD?= =?UTF-8?q?=EF=BC=8C=E6=94=AF=E6=8C=81=20YouTube=20=E5=B9=B3=E5=8F=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加视频字幕翻译开关(默认开启) - 在 content.ts 中初始化视频字幕翻译模块 - 在 Config 中新增 enableVideoSubtitle 配置项 - 通过 manifest content_scripts 注入 MAIN world 脚本以绕过 YouTube CSP 限制 Co-Authored-By: Claude Sonnet 4.6 --- components/Main.vue | 17 +++ entrypoints/content.ts | 6 +- entrypoints/utils/model.ts | 2 + entrypoints/video/manager.ts | 259 ++++++++++++++++++++++++++++++++ entrypoints/video/overlay.ts | 134 +++++++++++++++++ entrypoints/video/parser.ts | 112 ++++++++++++++ entrypoints/video/platforms.ts | 55 +++++++ public/video-subtitle-inject.js | 92 ++++++++++++ wxt.config.ts | 11 ++ 9 files changed, 687 insertions(+), 1 deletion(-) create mode 100644 entrypoints/video/manager.ts create mode 100644 entrypoints/video/overlay.ts create mode 100644 entrypoints/video/parser.ts create mode 100644 entrypoints/video/platforms.ts create mode 100644 public/video-subtitle-inject.js diff --git a/components/Main.vue b/components/Main.vue index a4f1bce..befb990 100644 --- a/components/Main.vue +++ b/components/Main.vue @@ -167,6 +167,23 @@ + + + + + + 视频字幕翻译 + + + + + + + + + diff --git a/entrypoints/content.ts b/entrypoints/content.ts index 21fb805..05edbea 100644 --- a/entrypoints/content.ts +++ b/entrypoints/content.ts @@ -9,7 +9,8 @@ import { mountSelectionTranslator, unmountSelectionTranslator } from "@/entrypoi import { cancelAllTranslations, translateText } from "@/entrypoints/utils/translateApi"; import { createApp } from 'vue'; import TranslationStatus from '@/components/TranslationStatus.vue'; -import { mountNewApiComponent } from "@/entrypoints/utils/newApi"; +import { mountNewApiComponent } from "@/entrypoints/utils/newApi" +import { initVideoSubtitle } from "@/entrypoints/video/manager"; export default defineContentScript({ matches: [''], // 匹配所有页面 @@ -55,6 +56,9 @@ export default defineContentScript({ mountNewApiComponent(); + // 初始化视频字幕翻译(在支持的平台上注入拦截脚本) + initVideoSubtitle(); + cache.cleaner(); // 检测是否清理缓存 // background.ts diff --git a/entrypoints/utils/model.ts b/entrypoints/utils/model.ts index 915e58c..b1e2b2e 100644 --- a/entrypoints/utils/model.ts +++ b/entrypoints/utils/model.ts @@ -53,6 +53,7 @@ export class Config { translationStatus: boolean; // 是否启用全文翻译进度面板 inputBoxTranslationTrigger: string; // 输入框翻译触发方式 inputBoxTranslationTarget: string; // 输入框翻译目标语言 + enableVideoSubtitle: boolean; // 是否启用视频字幕翻译 constructor() { this.on = true; @@ -98,6 +99,7 @@ export class Config { this.translationStatus = true; // 默认启用翻译进度面板 this.inputBoxTranslationTrigger = 'disabled'; // 默认关闭输入框翻译 this.inputBoxTranslationTarget = 'en'; // 默认翻译成英文 + this.enableVideoSubtitle = true; // 默认启用视频字幕翻译 } } diff --git a/entrypoints/video/manager.ts b/entrypoints/video/manager.ts new file mode 100644 index 0000000..34164d4 --- /dev/null +++ b/entrypoints/video/manager.ts @@ -0,0 +1,259 @@ +import { detectPlatform, getAllSubtitlePatterns } from './platforms' +import { detectSubtitleFormat, parseYouTubeXML, parseYouTubeJSON3, parseVTT, type SubtitleCue } from './parser' +import { SubtitleOverlay } from './overlay' +import { translateText } from '@/entrypoints/utils/translateApi' +import { config } from '@/entrypoints/utils/config' + +// ── 常量 ────────────────────────────────────────────────────────────────────── +const EVENT_TYPE = 'fr-subtitle-inject' +const BATCH_SIZE = 15 // 每批翻译的字幕条数(合并成一次 API 请求) +const SEP = '\n⌿\n' // 字幕批量翻译分隔符(选用生僻字符减少歧义) +const QUICK_BTN_ID = 'fr-subtitle-quick-btn' + +// ── 模块状态 ────────────────────────────────────────────────────────────────── +const overlay = new SubtitleOverlay() +let listenerAttached = false +let processingUrl = '' // 去重:同一字幕 URL 只翻译一次 +let subtitleEnabled = true + +// ── 公开入口 ────────────────────────────────────────────────────────────────── + +/** 由 content.ts 调用,初始化视频字幕翻译 */ +export function initVideoSubtitle() { + console.log('[FR] initVideoSubtitle called, enableVideoSubtitle=', config.enableVideoSubtitle, 'hostname=', window.location.hostname) + if (!config.enableVideoSubtitle) return + // 拦截脚本已由 WXT 以 MAIN world content script 形式在 document_start 注入, + // 此处只需推送动态配置并开始监听消息。 + sendConfig() + attachMessageListener() + watchNavigation() + // 在 YouTube 上立即挂载快捷按钮,无需等待字幕捕获 + if (window.location.hostname.includes('youtube.com')) { + console.log('[FR] on YouTube, calling mountQuickButton') + mountQuickButton() + } +} + +// ── 私有实现 ────────────────────────────────────────────────────────────────── + +/** 向注入脚本发送字幕 URL 正则列表(动态更新,覆盖注入脚本内置的默认规则) */ +function sendConfig() { + window.postMessage({ + eventType: EVENT_TYPE, + type: 'config', + patterns: getAllSubtitlePatterns(), + }, '*') +} + +/** 监听来自注入脚本的 postMessage */ +function attachMessageListener() { + if (listenerAttached) return + listenerAttached = true + + window.addEventListener('message', async (event) => { + if (event.source !== window) return + const msg = event.data + if (!msg || msg.eventType !== EVENT_TYPE) return + + if (msg.type === 'subtitle-captured') { + const { url, data } = msg + if (!url || !data) return + if (!subtitleEnabled) return + // 同一 URL 不重复处理 + if (url === processingUrl) return + processingUrl = url + try { + await handleSubtitleData(url, data) + } finally { + processingUrl = '' + } + } + }) +} + +/** 解析字幕 → 初始化 overlay → 批量翻译 */ +async function handleSubtitleData(url: string, rawData: string) { + const format = detectSubtitleFormat(url, rawData) + if (!format) return + + const cues: SubtitleCue[] = + format === 'youtube-xml' ? parseYouTubeXML(rawData) : + format === 'youtube-json3' ? parseYouTubeJSON3(rawData) : + parseVTT(rawData) + + if (!cues.length) return + + const video = findVideo() + if (!video) return + + const mountTarget = findMountTarget(video) + overlay.mount(video, mountTarget) + overlay.setCues([...cues]) // 先用原文渲染,避免空白等待 + + hideNativeSubtitle() + mountQuickButton() + + // 分批翻译,边翻译边更新 overlay + await translateCuesBatched(cues, () => overlay.setCues([...cues])) +} + +/** + * 将字幕分批(每批 BATCH_SIZE 条),合并成一个字符串发给翻译服务, + * 再按分隔符拆分回逐条结果,大幅减少 API 调用次数。 + */ +async function translateCuesBatched(cues: SubtitleCue[], onProgress: () => void) { + for (let i = 0; i < cues.length; i += BATCH_SIZE) { + const batch = cues.slice(i, i + BATCH_SIZE) + const joined = batch.map(c => c.text).join(SEP) + + try { + const translated = await translateText(joined, document.title) + const parts = translated.split(SEP) + batch.forEach((cue, j) => { + cue.translatedText = parts[j]?.trim() || cue.text + }) + } catch { + batch.forEach(cue => { cue.translatedText = cue.text }) + } + + onProgress() + } +} + +// ── YouTube 工具栏快捷按钮 ───────────────────────────────────────────────────── + +/** + * 在 YouTube 播放器右侧控制栏注入一个翻译开关按钮。 + * 点击可切换字幕翻译的显示/隐藏,不影响原生字幕。 + */ +function mountQuickButton() { + console.log('[FR] mountQuickButton called, existing=', !!document.getElementById(QUICK_BTN_ID)) + if (document.getElementById(QUICK_BTN_ID)) return + + // YouTube 右侧控制栏,等待其出现 + console.log('[FR] waiting for .ytp-right-controls, current=', document.querySelector('.ytp-right-controls')) + waitForElement('.ytp-right-controls', (controls) => { + console.log('[FR] .ytp-right-controls found, inserting button') + if (document.getElementById(QUICK_BTN_ID)) return + + const btn = document.createElement('button') + btn.id = QUICK_BTN_ID + btn.title = '流畅阅读:字幕翻译' + btn.setAttribute('aria-label', '字幕翻译') + btn.style.cssText = [ + 'background:transparent', + 'border:none', + 'cursor:pointer', + 'padding:0 6px', + 'height:100%', + 'display:inline-flex', + 'align-items:center', + 'opacity:0.9', + 'vertical-align:top', + ].join(';') + + btn.innerHTML = buildBtnSvg(subtitleEnabled) + + btn.addEventListener('click', () => { + subtitleEnabled = !subtitleEnabled + btn.innerHTML = buildBtnSvg(subtitleEnabled) + btn.title = subtitleEnabled ? '流畅阅读:字幕翻译(开)' : '流畅阅读:字幕翻译(关)' + if (subtitleEnabled) { + overlay.show() + } else { + overlay.hide() + } + }) + + // 插入到右侧控制栏最左边 + controls.prepend(btn) + }) +} + +function buildBtnSvg(active: boolean): string { + const color = active ? '#fff' : 'rgba(255,255,255,0.4)' + return ` + + ` +} + +/** 轮询等待目标元素出现 */ +function waitForElement(selector: string, callback: (el: Element) => void, maxMs = 10000) { + const el = document.querySelector(selector) + if (el) { callback(el); return } + + const start = Date.now() + const timer = setInterval(() => { + const found = document.querySelector(selector) + if (found) { + clearInterval(timer) + callback(found) + } else if (Date.now() - start > maxMs) { + clearInterval(timer) + } + }, 300) +} + +// ── DOM 工具 ────────────────────────────────────────────────────────────────── + +function findVideo(): HTMLVideoElement | null { + const platform = detectPlatform(window.location.hostname) + if (platform.videoSelector) { + const v = document.querySelector(platform.videoSelector) + if (v) return v + } + return document.querySelector('video') +} + +function findMountTarget(video: HTMLVideoElement): HTMLElement { + const platform = detectPlatform(window.location.hostname) + if (platform.containerSelector) { + const el = document.querySelector(platform.containerSelector) + if (el) return el + } + return (video.parentElement as HTMLElement) || document.body +} + +function hideNativeSubtitle() { + const platform = detectPlatform(window.location.hostname) + if (!platform.hideNativeSelector) return + // 用 display:none 彻底隐藏,visibility:hidden 仍占位且有时被 YouTube 重置 + document.querySelectorAll(platform.hideNativeSelector) + .forEach(el => el.style.setProperty('display', 'none', 'important')) +} + +function restoreNativeSubtitle() { + const platform = detectPlatform(window.location.hostname) + if (!platform.hideNativeSelector) return + document.querySelectorAll(platform.hideNativeSelector) + .forEach(el => el.style.removeProperty('display')) +} + +// ── SPA 导航监听 ────────────────────────────────────────────────────────────── + +function watchNavigation() { + let lastUrl = location.href + + const onUrlChange = () => { + const cur = location.href + if (cur !== lastUrl) { + lastUrl = cur + overlay.cleanup() + document.getElementById(QUICK_BTN_ID)?.remove() + processingUrl = '' + subtitleEnabled = true + restoreNativeSubtitle() + // SPA 导航到视频页时重新挂载按钮 + if (window.location.hostname.includes('youtube.com')) { + mountQuickButton() + } + } + } + + window.addEventListener('yt-navigate-finish', onUrlChange) + + const titleEl = document.querySelector('title') + if (titleEl) { + new MutationObserver(onUrlChange).observe(titleEl, { childList: true }) + } +} diff --git a/entrypoints/video/overlay.ts b/entrypoints/video/overlay.ts new file mode 100644 index 0000000..708bc12 --- /dev/null +++ b/entrypoints/video/overlay.ts @@ -0,0 +1,134 @@ +import type { SubtitleCue } from './parser' +import { config } from '@/entrypoints/utils/config' + +const OVERLAY_ID = 'fr-subtitle-overlay' + +export class SubtitleOverlay { + private container: HTMLElement | null = null + private video: HTMLVideoElement | null = null + private cues: SubtitleCue[] = [] + private rafId: number | null = null + private lastCueKey: string | null = null // 避免每帧重复 DOM 操作 + + /** 在视频容器内创建字幕层,开始时间轴循环 */ + mount(video: HTMLVideoElement, mountTarget: HTMLElement) { + this.video = video + this.cleanup() + + const overlay = document.createElement('div') + overlay.id = OVERLAY_ID + overlay.style.cssText = [ + 'position:absolute', + 'bottom:8%', + 'left:50%', + 'transform:translateX(-50%)', + 'z-index:2147483640', + 'text-align:center', + 'pointer-events:none', + 'width:max-content', + 'max-width:84%', + ].join(';') + + // 确保挂载目标有定位上下文 + const pos = window.getComputedStyle(mountTarget).position + if (pos === 'static') mountTarget.style.position = 'relative' + + mountTarget.appendChild(overlay) + this.container = overlay + this.startLoop() + } + + /** 更新全部字幕数据(解析完成 / 翻译进度更新时调用) */ + setCues(cues: SubtitleCue[]) { + this.cues = cues + this.lastCueKey = null // 强制刷新一次显示 + } + + show() { + if (this.container) this.container.style.display = '' + } + + hide() { + if (this.container) this.container.style.display = 'none' + } + + /** 停止渲染并移除 DOM */ + cleanup() { + if (this.rafId !== null) { + cancelAnimationFrame(this.rafId) + this.rafId = null + } + document.getElementById(OVERLAY_ID)?.remove() + this.container = null + this.cues = [] + this.lastCueKey = null + } + + // ── 内部方法 ────────────────────────────────────────────────────────────── + + private startLoop() { + const tick = () => { + if (!this.video || !this.container) return + const t = this.video.currentTime + const cue = this.findCue(t) + const key = cue ? `${cue.start}~${cue.end}` : '' + + if (key !== this.lastCueKey) { + this.lastCueKey = key + this.render(cue) + } + this.rafId = requestAnimationFrame(tick) + } + this.rafId = requestAnimationFrame(tick) + } + + private findCue(time: number): SubtitleCue | null { + for (const cue of this.cues) { + if (time >= cue.start && time < cue.end) return cue + } + return null + } + + private render(cue: SubtitleCue | null) { + if (!this.container) return + if (!cue) { this.container.innerHTML = ''; return } + + const isBilingual = config.display === 1 // 1 = 双语对照 + const hasTranslation = !!cue.translatedText + + const lineStyle = [ + 'display:block', + 'background:rgba(8,8,8,0.80)', + 'color:#fff', + 'padding:4px 14px', + 'border-radius:4px', + 'font-size:18px', + 'line-height:1.65', + 'white-space:pre-wrap', + 'word-break:break-word', + 'text-align:center', + 'margin-bottom:4px', + ].join(';') + + const originalStyle = lineStyle + ';opacity:0.75;font-size:15px' + + let html = '' + if (isBilingual && cue.text) { + html += `
${esc(cue.text)}
` + } + if (hasTranslation) { + html += `
${esc(cue.translatedText!)}
` + } else { + // 翻译尚未完成时,仍显示原文(避免空白) + html += `
${esc(cue.text)}
` + } + this.container.innerHTML = html + } +} + +function esc(s: string): string { + return s.replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') +} diff --git a/entrypoints/video/parser.ts b/entrypoints/video/parser.ts new file mode 100644 index 0000000..4cd2dd8 --- /dev/null +++ b/entrypoints/video/parser.ts @@ -0,0 +1,112 @@ +export interface SubtitleCue { + start: number // 开始时间(秒) + end: number // 结束时间(秒) + text: string // 原文 + translatedText?: string // 译文(翻译后填入) +} + +// ── YouTube timedtext XML ───────────────────────────────────────────────────── +// 格式:Hello +export function parseYouTubeXML(xmlText: string): SubtitleCue[] { + try { + const doc = new DOMParser().parseFromString(xmlText, 'text/xml') + if (doc.querySelector('parsererror')) return [] + const cues: SubtitleCue[] = [] + doc.querySelectorAll('text').forEach(node => { + const start = parseFloat(node.getAttribute('start') || '0') + const dur = parseFloat(node.getAttribute('dur') || '0') + const text = decodeEntities(node.textContent || '').trim() + if (text) cues.push({ start, end: start + dur, text }) + }) + return cues + } catch { + return [] + } +} + +// ── WebVTT ──────────────────────────────────────────────────────────────────── +export function parseVTT(vttText: string): SubtitleCue[] { + const cues: SubtitleCue[] = [] + // 统一换行符,按空行分割 cue 块 + const blocks = vttText.replace(/\r\n/g, '\n').replace(/\r/g, '\n').split(/\n\n+/) + + for (const block of blocks) { + const lines = block.trim().split('\n') + // 找到包含 --> 的时间行 + const timeLine = lines.find(l => l.includes('-->')) + if (!timeLine) continue + + const [startStr, endStr] = timeLine.split('-->').map(s => s.trim().split(/\s/)[0]) + const start = vttTimeToSeconds(startStr) + const end = vttTimeToSeconds(endStr) + + // 时间行之后的行为字幕文本(去掉 VTT 内联标签) + const textLines = lines + .slice(lines.indexOf(timeLine) + 1) + .map(l => stripVttTags(l)) + .filter(l => l.trim()) + + const text = textLines.join(' ').trim() + if (text) cues.push({ start, end, text }) + } + return cues +} + +// ── 辅助函数 ────────────────────────────────────────────────────────────────── +function vttTimeToSeconds(t: string): number { + if (!t) return 0 + const parts = t.split(':') + if (parts.length === 3) { + return parseInt(parts[0]) * 3600 + parseInt(parts[1]) * 60 + parseFloat(parts[2]) + } else if (parts.length === 2) { + return parseInt(parts[0]) * 60 + parseFloat(parts[1]) + } + return parseFloat(t) || 0 +} + +function stripVttTags(text: string): string { + // 去掉 , <00:00:00.000>, , 等 VTT 标签 + return text.replace(/<[^>]+>/g, '').replace(/&/g, '&') +} + +function decodeEntities(text: string): string { + try { + const doc = new DOMParser().parseFromString(text, 'text/html') + return doc.documentElement.textContent || text + } catch { + return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/'/g, "'").replace(/"/g, '"') + } +} + +// ── YouTube timedtext JSON3 ─────────────────────────────────────────────────── +// 格式:{"events":[{"tStartMs":0,"dDurationMs":5000,"segs":[{"utf8":"Hello"}]},...]} +export function parseYouTubeJSON3(jsonText: string): SubtitleCue[] { + try { + const data = JSON.parse(jsonText) + if (!data.events) return [] + const cues: SubtitleCue[] = [] + for (const event of data.events) { + if (!event.segs) continue + const text = event.segs.map((s: { utf8?: string }) => s.utf8 || '').join('').trim() + if (!text || text === '\n') continue + const start = (event.tStartMs || 0) / 1000 + const end = start + (event.dDurationMs || 0) / 1000 + cues.push({ start, end, text }) + } + return cues + } catch { + return [] + } +} + +/** + * 自动检测字幕格式 + */ +export function detectSubtitleFormat(url: string, data: string): 'youtube-xml' | 'youtube-json3' | 'vtt' | null { + if (data.trimStart().startsWith('WEBVTT')) return 'vtt' + if (url.match(/\.vtt(\?|#|$)/i)) return 'vtt' + if (data.trimStart().startsWith('{')) return 'youtube-json3' + if (data.includes(' 元素选择器 + containerSelector: string // 字幕层挂载目标选择器 + hideNativeSelector?: string // 需要隐藏的原生字幕容器 +} + +export const platforms: PlatformConfig[] = [ + { + id: 'youtube', + matches: ['youtube.com', 'youtubekids.com'], + subtitleUrlPatterns: ['/api/timedtext'], + format: 'youtube-xml', + videoSelector: '.html5-video-player video', + containerSelector: '.html5-video-player', + hideNativeSelector: '.ytp-caption-window-container', + }, + { + // 通用 WebVTT:覆盖 Udemy / Coursera / Khan Academy 等大多数学习平台 + id: 'generic-vtt', + matches: ['*'], + subtitleUrlPatterns: ['\\.vtt(\\?|#|$)', 'subtitles?.*\\.vtt', '/captions/'], + format: 'vtt', + videoSelector: 'video', + containerSelector: '', // 动态确定:video.parentElement + }, +] + +/** + * 根据当前 hostname 匹配平台配置。 + * 优先返回精确平台,没有则返回通用 VTT 兜底。 + */ +export function detectPlatform(hostname: string): PlatformConfig { + for (const platform of platforms) { + if (platform.id === 'generic-vtt') continue + if (platform.matches.some(m => hostname.includes(m))) { + return platform + } + } + return platforms.find(p => p.id === 'generic-vtt')! +} + +/** 返回所有平台的字幕 URL 正则列表(去重),发送给注入脚本 */ +export function getAllSubtitlePatterns(): string[] { + const set = new Set() + for (const platform of platforms) { + for (const pattern of platform.subtitleUrlPatterns) { + set.add(pattern) + } + } + return Array.from(set) +} diff --git a/public/video-subtitle-inject.js b/public/video-subtitle-inject.js new file mode 100644 index 0000000..a63b766 --- /dev/null +++ b/public/video-subtitle-inject.js @@ -0,0 +1,92 @@ +(function () { + const EVENT_TYPE = 'fr-subtitle-inject'; + // 默认 patterns,在 content script 发送配置前就能拦截常见字幕请求 + let subtitlePatterns = ['/api/timedtext', '\\.vtt(\\?|#|$)', 'subtitles?.*\\.vtt', '/captions/']; + + // 缓存最近一次捕获,用于内容脚本晚于字幕请求就绪时补发 + var lastCapture = null; + + // ── 工具函数 ────────────────────────────────────────────── + function isSubtitleUrl(url) { + if (!url || !subtitlePatterns.length) return false; + return subtitlePatterns.some(function (pattern) { + try { return new RegExp(pattern).test(url); } catch (_) { return false; } + }); + } + + function getUrl(input) { + if (!input) return ''; + if (typeof input === 'string') return input; + if (input instanceof URL) return input.href; + if (input instanceof Request) return input.url; + return String(input); + } + + function sendToContent(payload) { + window.postMessage(Object.assign({ eventType: EVENT_TYPE }, payload), '*'); + } + + // ── 接收 Content Script 发来的配置 ─────────────────────── + window.addEventListener('message', function (event) { + if (event.source !== window) return; + var data = event.data; + if (!data || data.eventType !== EVENT_TYPE) return; + if (data.type === 'config') { + subtitlePatterns = data.patterns || []; + // 内容脚本刚就绪,把已缓存的字幕补发过去 + if (lastCapture) { + sendToContent({ type: 'subtitle-captured', url: lastCapture.url, data: lastCapture.data }); + } + } + }); + + // ── Hook XMLHttpRequest ─────────────────────────────────── + var originalOpen = XMLHttpRequest.prototype.open; + var originalSend = XMLHttpRequest.prototype.send; + + XMLHttpRequest.prototype.open = function () { + this._fr_url = typeof arguments[1] === 'string' + ? arguments[1] + : (arguments[1] && arguments[1].href) || ''; + return originalOpen.apply(this, arguments); + }; + + XMLHttpRequest.prototype.send = function () { + var url = this._fr_url; + if (url && isSubtitleUrl(url)) { + var self = this; + self.addEventListener('load', function () { + if (self.status === 200 && self.responseText) { + lastCapture = { url: url, data: self.responseText }; + sendToContent({ type: 'subtitle-captured', url: url, data: self.responseText }); + } + }); + } + return originalSend.apply(this, arguments); + }; + + // ── Hook fetch ──────────────────────────────────────────── + var originalFetch = window.fetch; + if (originalFetch) { + window.fetch = function (input, init) { + var url = getUrl(input); + if (url && isSubtitleUrl(url)) { + return originalFetch.call(this, input, init).then(function (response) { + if (response.ok) { + response.clone().text().then(function (text) { + if (text) { + lastCapture = { url: url, data: text }; + sendToContent({ type: 'subtitle-captured', url: url, data: text }); + } + }).catch(function () {}); + } + return response; + }); + } + return originalFetch.apply(this, arguments); + }; + } + + // ── 通知 Content Script 注入脚本已就绪 ──────────────────── + sendToContent({ type: 'ready' }); +})(); diff --git a/wxt.config.ts b/wxt.config.ts index 4835c97..413d780 100644 --- a/wxt.config.ts +++ b/wxt.config.ts @@ -23,6 +23,17 @@ export default defineConfig({ }), manifest: { permissions: ['storage', 'contextMenus', 'offscreen'], + // 直接在 manifest 中声明 MAIN world 脚本,绕开 WXT entrypoint 命名体系。 + // public/video-subtitle-inject.js 会被 WXT 原样复制到扩展根目录。 + // Chrome 加载 manifest content_scripts 时会绕过页面 CSP,不受 YouTube 等限制。 + content_scripts: [ + { + matches: [''], + js: ['video-subtitle-inject.js'], + world: 'MAIN', + run_at: 'document_start', + } as any, + ], }, }); \ No newline at end of file From 710404ebc959d7ed5edbcabd1882ab82acf30357 Mon Sep 17 00:00:00 2001 From: airhunter Date: Wed, 1 Apr 2026 17:01:16 +0800 Subject: [PATCH 02/15] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E8=A7=86?= =?UTF-8?q?=E9=A2=91=E5=AD=97=E5=B9=95=E6=A8=A1=E5=9D=97=E7=9A=84=E5=AE=89?= =?UTF-8?q?=E5=85=A8=E4=B8=8E=E6=80=A7=E8=83=BD=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 安全修复(XSS): - overlay.ts:用 createElement + textContent 替换 innerHTML,彻底消除 XSS 风险 - manager.ts:buildBtnSvg 改为返回 SVGElement(createElementNS),不再使用 innerHTML 性能优化: - overlay.ts:findCue 由线性扫描改为二分搜索,大幅降低长视频每帧开销 注入范围收窄: - wxt.config.ts:MAIN world 脚本的 matches 从 缩小到已支持的具体平台域名 Co-Authored-By: Claude Sonnet 4.6 --- entrypoints/video/manager.ts | 20 ++++++++++++------ entrypoints/video/overlay.ts | 41 +++++++++++++++++++----------------- wxt.config.ts | 10 ++++++++- 3 files changed, 44 insertions(+), 27 deletions(-) diff --git a/entrypoints/video/manager.ts b/entrypoints/video/manager.ts index 34164d4..13b905c 100644 --- a/entrypoints/video/manager.ts +++ b/entrypoints/video/manager.ts @@ -152,11 +152,11 @@ function mountQuickButton() { 'vertical-align:top', ].join(';') - btn.innerHTML = buildBtnSvg(subtitleEnabled) + btn.appendChild(buildBtnSvg(subtitleEnabled)) btn.addEventListener('click', () => { subtitleEnabled = !subtitleEnabled - btn.innerHTML = buildBtnSvg(subtitleEnabled) + btn.replaceChildren(buildBtnSvg(subtitleEnabled)) btn.title = subtitleEnabled ? '流畅阅读:字幕翻译(开)' : '流畅阅读:字幕翻译(关)' if (subtitleEnabled) { overlay.show() @@ -170,11 +170,17 @@ function mountQuickButton() { }) } -function buildBtnSvg(active: boolean): string { - const color = active ? '#fff' : 'rgba(255,255,255,0.4)' - return ` - - ` +function buildBtnSvg(active: boolean): SVGElement { + const ns = 'http://www.w3.org/2000/svg' + const svg = document.createElementNS(ns, 'svg') + svg.setAttribute('viewBox', '0 0 24 24') + svg.setAttribute('width', '22') + svg.setAttribute('height', '22') + svg.setAttribute('fill', active ? '#fff' : 'rgba(255,255,255,0.4)') + const path = document.createElementNS(ns, 'path') + path.setAttribute('d', 'M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 14H4V6h16v12zM6 10h2v2H6zm0 4h8v2H6zm10 0h2v2h-2zm-6-4h8v2h-8z') + svg.appendChild(path) + return svg } /** 轮询等待目标元素出现 */ diff --git a/entrypoints/video/overlay.ts b/entrypoints/video/overlay.ts index 708bc12..5a77a01 100644 --- a/entrypoints/video/overlay.ts +++ b/entrypoints/video/overlay.ts @@ -83,15 +83,25 @@ export class SubtitleOverlay { } private findCue(time: number): SubtitleCue | null { - for (const cue of this.cues) { - if (time >= cue.start && time < cue.end) return cue + let lo = 0, hi = this.cues.length - 1 + while (lo <= hi) { + const mid = (lo + hi) >>> 1 + const cue = this.cues[mid] + if (time < cue.start) { + hi = mid - 1 + } else if (time >= cue.end) { + lo = mid + 1 + } else { + return cue + } } return null } private render(cue: SubtitleCue | null) { if (!this.container) return - if (!cue) { this.container.innerHTML = ''; return } + this.container.replaceChildren() + if (!cue) return const isBilingual = config.display === 1 // 1 = 双语对照 const hasTranslation = !!cue.translatedText @@ -112,23 +122,16 @@ export class SubtitleOverlay { const originalStyle = lineStyle + ';opacity:0.75;font-size:15px' - let html = '' if (isBilingual && cue.text) { - html += `
${esc(cue.text)}
` - } - if (hasTranslation) { - html += `
${esc(cue.translatedText!)}
` - } else { - // 翻译尚未完成时,仍显示原文(避免空白) - html += `
${esc(cue.text)}
` + const div = document.createElement('div') + div.style.cssText = originalStyle + div.textContent = cue.text + this.container.appendChild(div) } - this.container.innerHTML = html - } -} -function esc(s: string): string { - return s.replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') + const mainDiv = document.createElement('div') + mainDiv.style.cssText = lineStyle + mainDiv.textContent = hasTranslation ? cue.translatedText! : cue.text + this.container.appendChild(mainDiv) + } } diff --git a/wxt.config.ts b/wxt.config.ts index 413d780..72055a4 100644 --- a/wxt.config.ts +++ b/wxt.config.ts @@ -28,7 +28,15 @@ export default defineConfig({ // Chrome 加载 manifest content_scripts 时会绕过页面 CSP,不受 YouTube 等限制。 content_scripts: [ { - matches: [''], + // 仅注入已支持的视频平台,避免在无关站点执行 XHR/fetch hook。 + // 如需新增平台,在此同步添加对应域名。 + matches: [ + '*://*.youtube.com/*', + '*://*.youtubekids.com/*', + '*://*.udemy.com/*', + '*://*.coursera.org/*', + '*://*.khanacademy.org/*', + ], js: ['video-subtitle-inject.js'], world: 'MAIN', run_at: 'document_start', From d48d29b6b0db52ba1f5d1f08b784479d09f78662 Mon Sep 17 00:00:00 2001 From: airhunter Date: Wed, 1 Apr 2026 17:04:24 +0800 Subject: [PATCH 03/15] =?UTF-8?q?fix:=20=E5=88=87=E6=8D=A2=E5=AD=97?= =?UTF-8?q?=E5=B9=95=E7=BF=BB=E8=AF=91=E5=BC=80=E5=85=B3=E6=97=B6=E5=90=8C?= =?UTF-8?q?=E6=AD=A5=E6=81=A2=E5=A4=8D/=E9=9A=90=E8=97=8F=E5=8E=9F?= =?UTF-8?q?=E7=94=9F=E5=AD=97=E5=B9=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 关闭翻译时调用 restoreNativeSubtitle(),开启时调用 hideNativeSubtitle(), 避免用户关闭翻译后原生字幕仍处于 display:none 导致无字幕可看。 Co-Authored-By: Claude Sonnet 4.6 --- entrypoints/video/manager.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/entrypoints/video/manager.ts b/entrypoints/video/manager.ts index 13b905c..ca16133 100644 --- a/entrypoints/video/manager.ts +++ b/entrypoints/video/manager.ts @@ -159,9 +159,11 @@ function mountQuickButton() { btn.replaceChildren(buildBtnSvg(subtitleEnabled)) btn.title = subtitleEnabled ? '流畅阅读:字幕翻译(开)' : '流畅阅读:字幕翻译(关)' if (subtitleEnabled) { + hideNativeSubtitle() overlay.show() } else { overlay.hide() + restoreNativeSubtitle() } }) From 1e4ad68129814c48bcdbbd871d4a2ec110bb8aa3 Mon Sep 17 00:00:00 2001 From: airhunter Date: Wed, 1 Apr 2026 17:05:37 +0800 Subject: [PATCH 04/15] =?UTF-8?q?fix:=20overlay=20=E5=8D=B8=E8=BD=BD?= =?UTF-8?q?=E6=97=B6=E8=BF=98=E5=8E=9F=E6=8C=82=E8=BD=BD=E7=9B=AE=E6=A0=87?= =?UTF-8?q?=E7=9A=84=E5=8E=9F=E5=A7=8B=20position=20=E6=A0=B7=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mount() 修改 static 元素为 position:relative 前,先保存其原始内联 position 值;cleanup() 时还原,避免残留样式影响宿主页面布局。 Co-Authored-By: Claude Sonnet 4.6 --- entrypoints/video/overlay.ts | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/entrypoints/video/overlay.ts b/entrypoints/video/overlay.ts index 5a77a01..053657f 100644 --- a/entrypoints/video/overlay.ts +++ b/entrypoints/video/overlay.ts @@ -9,6 +9,8 @@ export class SubtitleOverlay { private cues: SubtitleCue[] = [] private rafId: number | null = null private lastCueKey: string | null = null // 避免每帧重复 DOM 操作 + private mountTarget: HTMLElement | undefined + private originalMountPosition: string | undefined // undefined = 未修改过 /** 在视频容器内创建字幕层,开始时间轴循环 */ mount(video: HTMLVideoElement, mountTarget: HTMLElement) { @@ -29,11 +31,15 @@ export class SubtitleOverlay { 'max-width:84%', ].join(';') - // 确保挂载目标有定位上下文 - const pos = window.getComputedStyle(mountTarget).position - if (pos === 'static') mountTarget.style.position = 'relative' + // 确保挂载目标有定位上下文,记录原始内联 position 以便 cleanup 还原 + const computedPos = window.getComputedStyle(mountTarget).position + if (computedPos === 'static') { + this.originalMountPosition = mountTarget.style.position + mountTarget.style.position = 'relative' + } mountTarget.appendChild(overlay) + this.mountTarget = mountTarget this.container = overlay this.startLoop() } @@ -52,16 +58,21 @@ export class SubtitleOverlay { if (this.container) this.container.style.display = 'none' } - /** 停止渲染并移除 DOM */ + /** 停止渲染并移除 DOM,还原挂载目标的原始 position */ cleanup() { if (this.rafId !== null) { cancelAnimationFrame(this.rafId) this.rafId = null } document.getElementById(OVERLAY_ID)?.remove() + if (this.mountTarget !== undefined && this.originalMountPosition !== undefined) { + this.mountTarget.style.position = this.originalMountPosition + } this.container = null this.cues = [] this.lastCueKey = null + this.mountTarget = undefined + this.originalMountPosition = undefined } // ── 内部方法 ────────────────────────────────────────────────────────────── From 1ffc746ad58c6f9e54dffcd13efe6b4b957702a3 Mon Sep 17 00:00:00 2001 From: airhunter Date: Wed, 1 Apr 2026 17:37:48 +0800 Subject: [PATCH 05/15] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E5=AD=97?= =?UTF-8?q?=E5=B9=95=E7=BF=BB=E8=AF=91=E4=B8=8D=E5=85=A8=E5=92=8C=E5=8E=9F?= =?UTF-8?q?=E6=96=87=E6=98=BE=E7=A4=BA=E4=B8=8D=E5=85=A8=E7=9A=84=E9=97=AE?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - manager.ts:批量翻译改用 [N] 编号标记替代 ⌿ 分隔符, 避免翻译 API 破坏分隔符导致拆分错位或翻译缺失 - overlay.ts:findCue 支持 overlap cue, 同一时间点有多条时取最后开始的一条, 兼容 YouTube 滚动字幕(前一行残留 + 新行同时存在)的场景 Co-Authored-By: Claude Sonnet 4.6 --- entrypoints/video/manager.ts | 13 +++++++++---- entrypoints/video/overlay.ts | 9 ++++++--- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/entrypoints/video/manager.ts b/entrypoints/video/manager.ts index ca16133..1c9b67d 100644 --- a/entrypoints/video/manager.ts +++ b/entrypoints/video/manager.ts @@ -7,7 +7,6 @@ import { config } from '@/entrypoints/utils/config' // ── 常量 ────────────────────────────────────────────────────────────────────── const EVENT_TYPE = 'fr-subtitle-inject' const BATCH_SIZE = 15 // 每批翻译的字幕条数(合并成一次 API 请求) -const SEP = '\n⌿\n' // 字幕批量翻译分隔符(选用生僻字符减少歧义) const QUICK_BTN_ID = 'fr-subtitle-quick-btn' // ── 模块状态 ────────────────────────────────────────────────────────────────── @@ -104,13 +103,19 @@ async function handleSubtitleData(url: string, rawData: string) { async function translateCuesBatched(cues: SubtitleCue[], onProgress: () => void) { for (let i = 0; i < cues.length; i += BATCH_SIZE) { const batch = cues.slice(i, i + BATCH_SIZE) - const joined = batch.map(c => c.text).join(SEP) + // 用 [N] 编号标记每条字幕,翻译 API 通常会原样保留数字标记 + const joined = batch.map((c, j) => `[${j + 1}] ${c.text}`).join('\n') try { const translated = await translateText(joined, document.title) - const parts = translated.split(SEP) + // 按 [N] 标记拆分,兼容 API 在标记前后增减空白的情况 + const map = new Map() + for (const line of translated.split('\n')) { + const m = line.match(/^\[(\d+)\]\s*(.*)/) + if (m) map.set(parseInt(m[1]), m[2].trim()) + } batch.forEach((cue, j) => { - cue.translatedText = parts[j]?.trim() || cue.text + cue.translatedText = map.get(j + 1) || cue.text }) } catch { batch.forEach(cue => { cue.translatedText = cue.text }) diff --git a/entrypoints/video/overlay.ts b/entrypoints/video/overlay.ts index 053657f..3e4ee7f 100644 --- a/entrypoints/video/overlay.ts +++ b/entrypoints/video/overlay.ts @@ -94,7 +94,9 @@ export class SubtitleOverlay { } private findCue(time: number): SubtitleCue | null { - let lo = 0, hi = this.cues.length - 1 + // 二分找到第一个 start <= time 且 end > time 的 cue, + // 继续向后扫描以处理 YouTube 滚动字幕的 overlap cue(取最后一条) + let lo = 0, hi = this.cues.length - 1, result: SubtitleCue | null = null while (lo <= hi) { const mid = (lo + hi) >>> 1 const cue = this.cues[mid] @@ -103,10 +105,11 @@ export class SubtitleOverlay { } else if (time >= cue.end) { lo = mid + 1 } else { - return cue + result = cue + lo = mid + 1 // 继续往后找是否有更晚开始的 overlap cue } } - return null + return result } private render(cue: SubtitleCue | null) { From c95e161af8e6e65347cc5cf5ce4e1e7769bbd7fd Mon Sep 17 00:00:00 2001 From: airhunter Date: Wed, 1 Apr 2026 18:06:40 +0800 Subject: [PATCH 06/15] =?UTF-8?q?style:=20=E5=AD=97=E5=B9=95=E6=98=BE?= =?UTF-8?q?=E7=A4=BA=E5=8C=BA=E5=9F=9F=E6=8B=89=E5=AE=BD=E8=87=B3=2094%?= =?UTF-8?q?=EF=BC=8C=E5=AD=97=E5=8F=B7=E5=A2=9E=E5=A4=A7=E8=87=B3=2022px/1?= =?UTF-8?q?8px?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- entrypoints/video/overlay.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/entrypoints/video/overlay.ts b/entrypoints/video/overlay.ts index 3e4ee7f..0d56649 100644 --- a/entrypoints/video/overlay.ts +++ b/entrypoints/video/overlay.ts @@ -28,7 +28,7 @@ export class SubtitleOverlay { 'text-align:center', 'pointer-events:none', 'width:max-content', - 'max-width:84%', + 'max-width:94%', ].join(';') // 确保挂载目标有定位上下文,记录原始内联 position 以便 cleanup 还原 @@ -126,7 +126,7 @@ export class SubtitleOverlay { 'color:#fff', 'padding:4px 14px', 'border-radius:4px', - 'font-size:18px', + 'font-size:22px', 'line-height:1.65', 'white-space:pre-wrap', 'word-break:break-word', @@ -134,7 +134,7 @@ export class SubtitleOverlay { 'margin-bottom:4px', ].join(';') - const originalStyle = lineStyle + ';opacity:0.75;font-size:15px' + const originalStyle = lineStyle + ';opacity:0.75;font-size:18px' if (isBilingual && cue.text) { const div = document.createElement('div') From 647e6225abae0ef7cf2bd8c0f86eb1e74ae0da7e Mon Sep 17 00:00:00 2001 From: airhunter Date: Wed, 1 Apr 2026 19:30:49 +0800 Subject: [PATCH 07/15] =?UTF-8?q?fix:=20=E5=90=88=E5=B9=B6=E7=A2=8E?= =?UTF-8?q?=E7=89=87=E5=AD=97=E5=B9=95=E5=86=8D=E7=BF=BB=E8=AF=91=EF=BC=8C?= =?UTF-8?q?=E8=A7=A3=E5=86=B3=E5=8D=8A=E5=8F=A5=E7=BF=BB=E8=AF=91=E4=B8=8D?= =?UTF-8?q?=E9=80=9A=E9=A1=BA=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 mergeSentenceGroups():将连续碎片 cue(不以句末标点结尾、 下一条首字母小写)合并为完整句子后再送翻译,译文回填到组内 所有 cue,使碎片时间段也能显示完整句子的译文。 判断逻辑: - 以 .!?。!?… 结尾 → 完整句,断开 - 下一条首字母大写 → 新句,断开 - 超过 MAX_GROUP_SIZE(6) 条 → 强制断开 Co-Authored-By: Claude Sonnet 4.6 --- entrypoints/video/manager.ts | 70 ++++++++++++++++++++++++++++++------ 1 file changed, 59 insertions(+), 11 deletions(-) diff --git a/entrypoints/video/manager.ts b/entrypoints/video/manager.ts index 1c9b67d..1986f9d 100644 --- a/entrypoints/video/manager.ts +++ b/entrypoints/video/manager.ts @@ -6,9 +6,16 @@ import { config } from '@/entrypoints/utils/config' // ── 常量 ────────────────────────────────────────────────────────────────────── const EVENT_TYPE = 'fr-subtitle-inject' -const BATCH_SIZE = 15 // 每批翻译的字幕条数(合并成一次 API 请求) +const BATCH_SIZE = 15 // 每批翻译的句子组数 +const MAX_GROUP_SIZE = 6 // 单个句子组最多合并的 cue 数,防止过长 const QUICK_BTN_ID = 'fr-subtitle-quick-btn' +// ── 类型 ────────────────────────────────────────────────────────────────────── +interface SentenceGroup { + cues: SubtitleCue[] // 原始 cue(可能是多个碎片) + text: string // 合并后的完整句子文本 +} + // ── 模块状态 ────────────────────────────────────────────────────────────────── const overlay = new SubtitleOverlay() let listenerAttached = false @@ -97,28 +104,69 @@ async function handleSubtitleData(url: string, rawData: string) { } /** - * 将字幕分批(每批 BATCH_SIZE 条),合并成一个字符串发给翻译服务, - * 再按分隔符拆分回逐条结果,大幅减少 API 调用次数。 + * 将连续的碎片 cue 合并为完整句子组: + * - 不以句末标点结尾,且下一条以小写开头 → 视为碎片,继续合并 + * - 超过 MAX_GROUP_SIZE 强制断开,避免过长 + * 组内所有 cue 共享同一译文,显示时呈现完整句子 + */ +function mergeSentenceGroups(cues: SubtitleCue[]): SentenceGroup[] { + const groups: SentenceGroup[] = [] + let current: SubtitleCue[] = [] + + const isFragment = (cue: SubtitleCue, next: SubtitleCue | undefined): boolean => { + if (!next || current.length >= MAX_GROUP_SIZE) return false + const text = cue.text.trimEnd() + if (/[.!?。!?…]$/.test(text)) return false // 句末标点 → 完整句 + if (/^[A-Z\u4e00-\u9fff]/.test(next.text.trimStart())) return false // 下一条首字母大写 → 新句 + return true + } + + for (let i = 0; i < cues.length; i++) { + current.push(cues[i]) + if (!isFragment(cues[i], cues[i + 1])) { + groups.push({ + cues: current, + text: current.map(c => c.text).join(' ').replace(/\s+/g, ' ').trim(), + }) + current = [] + } + } + if (current.length) { + groups.push({ + cues: current, + text: current.map(c => c.text).join(' ').replace(/\s+/g, ' ').trim(), + }) + } + return groups +} + +/** + * 先将碎片 cue 合并为句子组,再分批翻译。 + * 每组只发一次翻译请求,译文回填到组内所有 cue, + * 这样碎片 cue 显示的是完整句子的译文,而非孤立片段的译文。 */ async function translateCuesBatched(cues: SubtitleCue[], onProgress: () => void) { - for (let i = 0; i < cues.length; i += BATCH_SIZE) { - const batch = cues.slice(i, i + BATCH_SIZE) - // 用 [N] 编号标记每条字幕,翻译 API 通常会原样保留数字标记 - const joined = batch.map((c, j) => `[${j + 1}] ${c.text}`).join('\n') + const groups = mergeSentenceGroups(cues) + + for (let i = 0; i < groups.length; i += BATCH_SIZE) { + const batch = groups.slice(i, i + BATCH_SIZE) + const joined = batch.map((g, j) => `[${j + 1}] ${g.text}`).join('\n') try { const translated = await translateText(joined, document.title) - // 按 [N] 标记拆分,兼容 API 在标记前后增减空白的情况 const map = new Map() for (const line of translated.split('\n')) { const m = line.match(/^\[(\d+)\]\s*(.*)/) if (m) map.set(parseInt(m[1]), m[2].trim()) } - batch.forEach((cue, j) => { - cue.translatedText = map.get(j + 1) || cue.text + batch.forEach((group, j) => { + const translation = map.get(j + 1) || group.text + group.cues.forEach(cue => { cue.translatedText = translation }) }) } catch { - batch.forEach(cue => { cue.translatedText = cue.text }) + batch.forEach(group => { + group.cues.forEach(cue => { cue.translatedText = cue.text }) + }) } onProgress() From c3bb181b4528b4f46682be76ad83ca609fb3e731 Mon Sep 17 00:00:00 2001 From: airhunter Date: Wed, 1 Apr 2026 20:14:17 +0800 Subject: [PATCH 08/15] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E6=BB=A1?= =?UTF-8?q?=E7=BB=84=E6=88=AA=E6=96=AD=E5=AF=BC=E8=87=B4=E7=A2=8E=E7=89=87?= =?UTF-8?q?=E5=8F=A5=E8=B7=A8=E7=BB=84=E7=BF=BB=E8=AF=91=E9=94=99=E8=AF=AF?= =?UTF-8?q?=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 达到 MAX_GROUP_SIZE 时若末尾 cue 仍是碎片(无句末标点), 将其"进位"到下一组开头,确保与续句合并后一起翻译。 修复前:["...but the seeds"] | ["of animosity..."] 各自翻译 修复后:["..."] | ["but the seeds" + "of animosity..."] 合并翻译 Co-Authored-By: Claude Sonnet 4.6 --- entrypoints/video/manager.ts | 46 +++++++++++++++++++++--------------- 1 file changed, 27 insertions(+), 19 deletions(-) diff --git a/entrypoints/video/manager.ts b/entrypoints/video/manager.ts index 1986f9d..a1e7d4d 100644 --- a/entrypoints/video/manager.ts +++ b/entrypoints/video/manager.ts @@ -106,37 +106,45 @@ async function handleSubtitleData(url: string, rawData: string) { /** * 将连续的碎片 cue 合并为完整句子组: * - 不以句末标点结尾,且下一条以小写开头 → 视为碎片,继续合并 - * - 超过 MAX_GROUP_SIZE 强制断开,避免过长 - * 组内所有 cue 共享同一译文,显示时呈现完整句子 + * - 达到 MAX_GROUP_SIZE 时,若最后一条仍是碎片,将其"进位"到下一组, + * 确保碎片始终与续句在同一组内翻译 */ function mergeSentenceGroups(cues: SubtitleCue[]): SentenceGroup[] { const groups: SentenceGroup[] = [] let current: SubtitleCue[] = [] - const isFragment = (cue: SubtitleCue, next: SubtitleCue | undefined): boolean => { - if (!next || current.length >= MAX_GROUP_SIZE) return false + const isSentenceEnd = (cue: SubtitleCue, next: SubtitleCue | undefined): boolean => { + if (!next) return true const text = cue.text.trimEnd() - if (/[.!?。!?…]$/.test(text)) return false // 句末标点 → 完整句 - if (/^[A-Z\u4e00-\u9fff]/.test(next.text.trimStart())) return false // 下一条首字母大写 → 新句 - return true + if (/[.!?。!?…]$/.test(text)) return true + if (/^[A-Z\u4e00-\u9fff]/.test(next.text.trimStart())) return true + return false } + const flush = (arr: SubtitleCue[]) => groups.push({ + cues: [...arr], + text: arr.map(c => c.text).join(' ').replace(/\s+/g, ' ').trim(), + }) + for (let i = 0; i < cues.length; i++) { current.push(cues[i]) - if (!isFragment(cues[i], cues[i + 1])) { - groups.push({ - cues: current, - text: current.map(c => c.text).join(' ').replace(/\s+/g, ' ').trim(), - }) - current = [] + const sentenceEnds = isSentenceEnd(cues[i], cues[i + 1]) + const maxed = current.length >= MAX_GROUP_SIZE + + if (sentenceEnds || maxed) { + if (maxed && !sentenceEnds && current.length > 1) { + // 满组但末尾是碎片 → 末尾 cue 进位到下一组 + const carryOver = current.pop()! + flush(current) + current = [carryOver] + } else { + flush(current) + current = [] + } } } - if (current.length) { - groups.push({ - cues: current, - text: current.map(c => c.text).join(' ').replace(/\s+/g, ' ').trim(), - }) - } + if (current.length) flush(current) + return groups } From 61a341c8de816ade7869ec6157b1b8b52468e24c Mon Sep 17 00:00:00 2001 From: airhunter Date: Wed, 1 Apr 2026 20:25:42 +0800 Subject: [PATCH 09/15] =?UTF-8?q?fix:=20=E5=AD=97=E5=B9=95=E7=BF=BB?= =?UTF-8?q?=E8=AF=91=E5=8A=A0=E5=85=A5=E4=B8=93=E7=94=A8=E6=8F=90=E7=A4=BA?= =?UTF-8?q?=E8=AF=8D=E5=92=8C=E8=B7=A8=E6=89=B9=E4=B8=8A=E4=B8=8B=E6=96=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 每批翻译前加 subtitle 专用指令,告知模型这是字幕碎片、 需结合相邻行保持语义连贯、行数必须一一对应 - 带入上一批最后一组的结尾 12 个词作为 [previous context], 让模型理解跨批边界的句子续接,避免首行翻译因缺乏前文而语序错乱 Co-Authored-By: Claude Sonnet 4.6 --- entrypoints/video/manager.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/entrypoints/video/manager.ts b/entrypoints/video/manager.ts index a1e7d4d..25e0a4f 100644 --- a/entrypoints/video/manager.ts +++ b/entrypoints/video/manager.ts @@ -158,7 +158,16 @@ async function translateCuesBatched(cues: SubtitleCue[], onProgress: () => void) for (let i = 0; i < groups.length; i += BATCH_SIZE) { const batch = groups.slice(i, i + BATCH_SIZE) - const joined = batch.map((g, j) => `[${j + 1}] ${g.text}`).join('\n') + + // 带上前一批的最后一组作为上下文,帮助模型理解跨批边界的句子续接 + const prevContext = i > 0 + ? `[previous context: ...${groups[i - 1].text.split(' ').slice(-12).join(' ')}]\n` + : '' + + // 字幕专用指令:告知模型这是视频字幕碎片,需结合相邻行翻译,保持行数一致 + const instruction = 'Video subtitle segments. Lines may be sentence fragments — use adjacent lines for context to produce natural translations. Return the same number of [N] lines, no extra explanation.\n\n' + + const joined = instruction + prevContext + batch.map((g, j) => `[${j + 1}] ${g.text}`).join('\n') try { const translated = await translateText(joined, document.title) From f6ca4f587c3344bad2b210adcf0d1671124f3db2 Mon Sep 17 00:00:00 2001 From: airhunter Date: Wed, 1 Apr 2026 20:32:28 +0800 Subject: [PATCH 10/15] =?UTF-8?q?refactor:=20=E7=A7=BB=E9=99=A4=E5=8F=A5?= =?UTF-8?q?=E5=AD=90=E5=90=88=E5=B9=B6=E9=80=BB=E8=BE=91=EF=BC=8C=E6=94=B9?= =?UTF-8?q?=E7=94=B1=E6=8F=90=E7=A4=BA=E8=AF=8D=E4=BF=9D=E8=AF=81=E7=BF=BB?= =?UTF-8?q?=E8=AF=91=E8=BF=9E=E8=B4=AF=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 移除 SentenceGroup、mergeSentenceGroups 及相关常量, 每条 cue 直接对应一条译文,英中字幕严格一一对应。 跨批边界的连贯性由 [previous context] 前缀和字幕专用 指令交给模型处理,代码逻辑大幅简化。 Co-Authored-By: Claude Sonnet 4.6 --- entrypoints/video/manager.ts | 83 ++++++------------------------------ 1 file changed, 12 insertions(+), 71 deletions(-) diff --git a/entrypoints/video/manager.ts b/entrypoints/video/manager.ts index 25e0a4f..b1adecd 100644 --- a/entrypoints/video/manager.ts +++ b/entrypoints/video/manager.ts @@ -6,16 +6,9 @@ import { config } from '@/entrypoints/utils/config' // ── 常量 ────────────────────────────────────────────────────────────────────── const EVENT_TYPE = 'fr-subtitle-inject' -const BATCH_SIZE = 15 // 每批翻译的句子组数 -const MAX_GROUP_SIZE = 6 // 单个句子组最多合并的 cue 数,防止过长 +const BATCH_SIZE = 15 // 每批翻译的 cue 条数 const QUICK_BTN_ID = 'fr-subtitle-quick-btn' -// ── 类型 ────────────────────────────────────────────────────────────────────── -interface SentenceGroup { - cues: SubtitleCue[] // 原始 cue(可能是多个碎片) - text: string // 合并后的完整句子文本 -} - // ── 模块状态 ────────────────────────────────────────────────────────────────── const overlay = new SubtitleOverlay() let listenerAttached = false @@ -104,70 +97,21 @@ async function handleSubtitleData(url: string, rawData: string) { } /** - * 将连续的碎片 cue 合并为完整句子组: - * - 不以句末标点结尾,且下一条以小写开头 → 视为碎片,继续合并 - * - 达到 MAX_GROUP_SIZE 时,若最后一条仍是碎片,将其"进位"到下一组, - * 确保碎片始终与续句在同一组内翻译 - */ -function mergeSentenceGroups(cues: SubtitleCue[]): SentenceGroup[] { - const groups: SentenceGroup[] = [] - let current: SubtitleCue[] = [] - - const isSentenceEnd = (cue: SubtitleCue, next: SubtitleCue | undefined): boolean => { - if (!next) return true - const text = cue.text.trimEnd() - if (/[.!?。!?…]$/.test(text)) return true - if (/^[A-Z\u4e00-\u9fff]/.test(next.text.trimStart())) return true - return false - } - - const flush = (arr: SubtitleCue[]) => groups.push({ - cues: [...arr], - text: arr.map(c => c.text).join(' ').replace(/\s+/g, ' ').trim(), - }) - - for (let i = 0; i < cues.length; i++) { - current.push(cues[i]) - const sentenceEnds = isSentenceEnd(cues[i], cues[i + 1]) - const maxed = current.length >= MAX_GROUP_SIZE - - if (sentenceEnds || maxed) { - if (maxed && !sentenceEnds && current.length > 1) { - // 满组但末尾是碎片 → 末尾 cue 进位到下一组 - const carryOver = current.pop()! - flush(current) - current = [carryOver] - } else { - flush(current) - current = [] - } - } - } - if (current.length) flush(current) - - return groups -} - -/** - * 先将碎片 cue 合并为句子组,再分批翻译。 - * 每组只发一次翻译请求,译文回填到组内所有 cue, - * 这样碎片 cue 显示的是完整句子的译文,而非孤立片段的译文。 + * 每批 BATCH_SIZE 条 cue,加上字幕专用指令和前批末尾上下文后发给翻译服务。 + * 英文 cue 与译文严格一一对应,由模型自行利用相邻行处理碎片句的连贯性。 */ async function translateCuesBatched(cues: SubtitleCue[], onProgress: () => void) { - const groups = mergeSentenceGroups(cues) + const instruction = 'Video subtitle segments. Lines may be sentence fragments — use adjacent lines for context to produce natural translations. Return the same number of [N] lines, no extra explanation.\n\n' - for (let i = 0; i < groups.length; i += BATCH_SIZE) { - const batch = groups.slice(i, i + BATCH_SIZE) + for (let i = 0; i < cues.length; i += BATCH_SIZE) { + const batch = cues.slice(i, i + BATCH_SIZE) - // 带上前一批的最后一组作为上下文,帮助模型理解跨批边界的句子续接 + // 带上前一批最后 12 个词作为上下文,帮助模型理解跨批边界的句子续接 const prevContext = i > 0 - ? `[previous context: ...${groups[i - 1].text.split(' ').slice(-12).join(' ')}]\n` + ? `[previous context: ...${cues.slice(Math.max(0, i - 3), i).map(c => c.text).join(' ').split(' ').slice(-12).join(' ')}]\n` : '' - // 字幕专用指令:告知模型这是视频字幕碎片,需结合相邻行翻译,保持行数一致 - const instruction = 'Video subtitle segments. Lines may be sentence fragments — use adjacent lines for context to produce natural translations. Return the same number of [N] lines, no extra explanation.\n\n' - - const joined = instruction + prevContext + batch.map((g, j) => `[${j + 1}] ${g.text}`).join('\n') + const joined = instruction + prevContext + batch.map((c, j) => `[${j + 1}] ${c.text}`).join('\n') try { const translated = await translateText(joined, document.title) @@ -176,14 +120,11 @@ async function translateCuesBatched(cues: SubtitleCue[], onProgress: () => void) const m = line.match(/^\[(\d+)\]\s*(.*)/) if (m) map.set(parseInt(m[1]), m[2].trim()) } - batch.forEach((group, j) => { - const translation = map.get(j + 1) || group.text - group.cues.forEach(cue => { cue.translatedText = translation }) + batch.forEach((cue, j) => { + cue.translatedText = map.get(j + 1) || cue.text }) } catch { - batch.forEach(group => { - group.cues.forEach(cue => { cue.translatedText = cue.text }) - }) + batch.forEach(cue => { cue.translatedText = cue.text }) } onProgress() From 1a1dd42c8d2448f2340837a9a78cd3e357da4416 Mon Sep 17 00:00:00 2001 From: airhunter Date: Wed, 1 Apr 2026 21:00:22 +0800 Subject: [PATCH 11/15] =?UTF-8?q?fix:=20=E4=B8=89=E7=AE=A1=E9=BD=90?= =?UTF-8?q?=E4=B8=8B=E6=94=B9=E5=96=84=E7=A2=8E=E7=89=87=E5=AD=97=E5=B9=95?= =?UTF-8?q?=E7=BF=BB=E8=AF=91=E8=B4=A8=E9=87=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. parser.ts:parseYouTubeXML 新增 mergeOverlappingCues(), 合并时间重叠的相邻 cue(YouTube 滚动字幕特性), 使碎片句在解析阶段就拼成完整句子 2. manager.ts:恢复 mergeSentenceGroups() 句子合并逻辑, MAX_GROUP_SIZE 由 6 调大至 8,减少强制截断频率 3. manager.ts:BATCH_SIZE 由 15 降至 5, 每批上下文更集中,翻译连贯性更好 Co-Authored-By: Claude Sonnet 4.6 --- entrypoints/video/manager.ts | 70 ++++++++++++++++++++++++++++++------ entrypoints/video/parser.ts | 26 ++++++++++++-- 2 files changed, 82 insertions(+), 14 deletions(-) diff --git a/entrypoints/video/manager.ts b/entrypoints/video/manager.ts index b1adecd..9529876 100644 --- a/entrypoints/video/manager.ts +++ b/entrypoints/video/manager.ts @@ -6,9 +6,16 @@ import { config } from '@/entrypoints/utils/config' // ── 常量 ────────────────────────────────────────────────────────────────────── const EVENT_TYPE = 'fr-subtitle-inject' -const BATCH_SIZE = 15 // 每批翻译的 cue 条数 +const BATCH_SIZE = 5 // 每批翻译的句子组数(小批量保证上下文集中) +const MAX_GROUP_SIZE = 8 // 单组最多合并 cue 数 const QUICK_BTN_ID = 'fr-subtitle-quick-btn' +// ── 类型 ────────────────────────────────────────────────────────────────────── +interface SentenceGroup { + cues: SubtitleCue[] + text: string +} + // ── 模块状态 ────────────────────────────────────────────────────────────────── const overlay = new SubtitleOverlay() let listenerAttached = false @@ -97,21 +104,59 @@ async function handleSubtitleData(url: string, rawData: string) { } /** - * 每批 BATCH_SIZE 条 cue,加上字幕专用指令和前批末尾上下文后发给翻译服务。 - * 英文 cue 与译文严格一一对应,由模型自行利用相邻行处理碎片句的连贯性。 + * 将连续碎片 cue 合并为句子组后批量翻译。 + * 组内所有 cue 共享同一译文,跨组边界由 [previous context] + 字幕专用指令处理。 */ +function mergeSentenceGroups(cues: SubtitleCue[]): SentenceGroup[] { + const groups: SentenceGroup[] = [] + let current: SubtitleCue[] = [] + + const isSentenceEnd = (cue: SubtitleCue, next: SubtitleCue | undefined): boolean => { + if (!next) return true + const text = cue.text.trimEnd() + if (/[.!?。!?…]$/.test(text)) return true + if (/^[A-Z\u4e00-\u9fff]/.test(next.text.trimStart())) return true + return false + } + + const flush = (arr: SubtitleCue[]) => groups.push({ + cues: [...arr], + text: arr.map(c => c.text).join(' ').replace(/\s+/g, ' ').trim(), + }) + + for (let i = 0; i < cues.length; i++) { + current.push(cues[i]) + const sentenceEnds = isSentenceEnd(cues[i], cues[i + 1]) + const maxed = current.length >= MAX_GROUP_SIZE + + if (sentenceEnds || maxed) { + if (maxed && !sentenceEnds && current.length > 1) { + // 满组但末尾是碎片 → 末尾 cue 进位到下一组 + const carryOver = current.pop()! + flush(current) + current = [carryOver] + } else { + flush(current) + current = [] + } + } + } + if (current.length) flush(current) + return groups +} + async function translateCuesBatched(cues: SubtitleCue[], onProgress: () => void) { + const groups = mergeSentenceGroups(cues) const instruction = 'Video subtitle segments. Lines may be sentence fragments — use adjacent lines for context to produce natural translations. Return the same number of [N] lines, no extra explanation.\n\n' - for (let i = 0; i < cues.length; i += BATCH_SIZE) { - const batch = cues.slice(i, i + BATCH_SIZE) + for (let i = 0; i < groups.length; i += BATCH_SIZE) { + const batch = groups.slice(i, i + BATCH_SIZE) - // 带上前一批最后 12 个词作为上下文,帮助模型理解跨批边界的句子续接 const prevContext = i > 0 - ? `[previous context: ...${cues.slice(Math.max(0, i - 3), i).map(c => c.text).join(' ').split(' ').slice(-12).join(' ')}]\n` + ? `[previous context: ...${groups[i - 1].text.split(' ').slice(-12).join(' ')}]\n` : '' - const joined = instruction + prevContext + batch.map((c, j) => `[${j + 1}] ${c.text}`).join('\n') + const joined = instruction + prevContext + batch.map((g, j) => `[${j + 1}] ${g.text}`).join('\n') try { const translated = await translateText(joined, document.title) @@ -120,11 +165,14 @@ async function translateCuesBatched(cues: SubtitleCue[], onProgress: () => void) const m = line.match(/^\[(\d+)\]\s*(.*)/) if (m) map.set(parseInt(m[1]), m[2].trim()) } - batch.forEach((cue, j) => { - cue.translatedText = map.get(j + 1) || cue.text + batch.forEach((group, j) => { + const translation = map.get(j + 1) || group.text + group.cues.forEach(cue => { cue.translatedText = translation }) }) } catch { - batch.forEach(cue => { cue.translatedText = cue.text }) + batch.forEach(group => { + group.cues.forEach(cue => { cue.translatedText = cue.text }) + }) } onProgress() diff --git a/entrypoints/video/parser.ts b/entrypoints/video/parser.ts index 4cd2dd8..7f667ac 100644 --- a/entrypoints/video/parser.ts +++ b/entrypoints/video/parser.ts @@ -11,19 +11,39 @@ export function parseYouTubeXML(xmlText: string): SubtitleCue[] { try { const doc = new DOMParser().parseFromString(xmlText, 'text/xml') if (doc.querySelector('parsererror')) return [] - const cues: SubtitleCue[] = [] + const raw: SubtitleCue[] = [] doc.querySelectorAll('text').forEach(node => { const start = parseFloat(node.getAttribute('start') || '0') const dur = parseFloat(node.getAttribute('dur') || '0') const text = decodeEntities(node.textContent || '').trim() - if (text) cues.push({ start, end: start + dur, text }) + if (text) raw.push({ start, end: start + dur, text }) }) - return cues + // YouTube timedtext 会产生时间重叠的 cue(滚动字幕效果), + // 合并重叠 cue 使碎片句回归完整,减少翻译时的上下文割裂 + return mergeOverlappingCues(raw) } catch { return [] } } +/** 合并时间上有重叠的相邻 cue,文本以空格拼接,时间取并集 */ +function mergeOverlappingCues(cues: SubtitleCue[]): SubtitleCue[] { + if (!cues.length) return cues + const merged: SubtitleCue[] = [{ ...cues[0] }] + for (let i = 1; i < cues.length; i++) { + const prev = merged[merged.length - 1] + const cur = cues[i] + if (cur.start < prev.end) { + // 重叠:合并文本,延伸结束时间 + prev.text = prev.text + ' ' + cur.text + prev.end = Math.max(prev.end, cur.end) + } else { + merged.push({ ...cur }) + } + } + return merged +} + // ── WebVTT ──────────────────────────────────────────────────────────────────── export function parseVTT(vttText: string): SubtitleCue[] { const cues: SubtitleCue[] = [] From 23298886e0f4b2cafcad46cf0b39bcf21a1c8e68 Mon Sep 17 00:00:00 2001 From: airhunter Date: Wed, 1 Apr 2026 21:12:39 +0800 Subject: [PATCH 12/15] =?UTF-8?q?refactor:=20=E9=80=90=E6=9D=A1=E7=BF=BB?= =?UTF-8?q?=E8=AF=91=20+=20=E5=89=8D=E5=90=8E=E4=B8=8A=E4=B8=8B=E6=96=87?= =?UTF-8?q?=E7=AA=97=E5=8F=A3=EF=BC=8C=E4=BF=9D=E6=8C=81=E8=8B=B1=E4=B8=AD?= =?UTF-8?q?=E4=B8=80=E4=B8=80=E5=AF=B9=E5=BA=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 移除 sentence merging,改为每批 5 条逐条翻译: - 每批在正文前后各附 CONTEXT_SIZE=2 条 [context before/after] - 提示词明确允许模型跨行借用语义处理碎片句 - 英中 cue 严格一一对应,无对齐问题 Co-Authored-By: Claude Sonnet 4.6 --- entrypoints/video/manager.ts | 92 +++++++++++------------------------- 1 file changed, 27 insertions(+), 65 deletions(-) diff --git a/entrypoints/video/manager.ts b/entrypoints/video/manager.ts index 9529876..3c83185 100644 --- a/entrypoints/video/manager.ts +++ b/entrypoints/video/manager.ts @@ -6,16 +6,10 @@ import { config } from '@/entrypoints/utils/config' // ── 常量 ────────────────────────────────────────────────────────────────────── const EVENT_TYPE = 'fr-subtitle-inject' -const BATCH_SIZE = 5 // 每批翻译的句子组数(小批量保证上下文集中) -const MAX_GROUP_SIZE = 8 // 单组最多合并 cue 数 +const BATCH_SIZE = 5 // 每批翻译的 cue 条数 +const CONTEXT_SIZE = 2 // 批次前后各取几条作为上下文 const QUICK_BTN_ID = 'fr-subtitle-quick-btn' -// ── 类型 ────────────────────────────────────────────────────────────────────── -interface SentenceGroup { - cues: SubtitleCue[] - text: string -} - // ── 模块状态 ────────────────────────────────────────────────────────────────── const overlay = new SubtitleOverlay() let listenerAttached = false @@ -104,59 +98,30 @@ async function handleSubtitleData(url: string, rawData: string) { } /** - * 将连续碎片 cue 合并为句子组后批量翻译。 - * 组内所有 cue 共享同一译文,跨组边界由 [previous context] + 字幕专用指令处理。 + * 逐条翻译,英中 cue 严格一一对应。 + * 每批在正文 [N] 前后各附 CONTEXT_SIZE 条原文作为上下文(不要求翻译), + * 提示词允许模型跨行借用语义,使碎片句的译文自然连贯。 */ -function mergeSentenceGroups(cues: SubtitleCue[]): SentenceGroup[] { - const groups: SentenceGroup[] = [] - let current: SubtitleCue[] = [] - - const isSentenceEnd = (cue: SubtitleCue, next: SubtitleCue | undefined): boolean => { - if (!next) return true - const text = cue.text.trimEnd() - if (/[.!?。!?…]$/.test(text)) return true - if (/^[A-Z\u4e00-\u9fff]/.test(next.text.trimStart())) return true - return false - } - - const flush = (arr: SubtitleCue[]) => groups.push({ - cues: [...arr], - text: arr.map(c => c.text).join(' ').replace(/\s+/g, ' ').trim(), - }) - - for (let i = 0; i < cues.length; i++) { - current.push(cues[i]) - const sentenceEnds = isSentenceEnd(cues[i], cues[i + 1]) - const maxed = current.length >= MAX_GROUP_SIZE - - if (sentenceEnds || maxed) { - if (maxed && !sentenceEnds && current.length > 1) { - // 满组但末尾是碎片 → 末尾 cue 进位到下一组 - const carryOver = current.pop()! - flush(current) - current = [carryOver] - } else { - flush(current) - current = [] - } - } - } - if (current.length) flush(current) - return groups -} - async function translateCuesBatched(cues: SubtitleCue[], onProgress: () => void) { - const groups = mergeSentenceGroups(cues) - const instruction = 'Video subtitle segments. Lines may be sentence fragments — use adjacent lines for context to produce natural translations. Return the same number of [N] lines, no extra explanation.\n\n' - - for (let i = 0; i < groups.length; i += BATCH_SIZE) { - const batch = groups.slice(i, i + BATCH_SIZE) - - const prevContext = i > 0 - ? `[previous context: ...${groups[i - 1].text.split(' ').slice(-12).join(' ')}]\n` - : '' - - const joined = instruction + prevContext + batch.map((g, j) => `[${j + 1}] ${g.text}`).join('\n') + const instruction = + 'These are timed video subtitle lines. Each [N] line is a separate segment and may be a sentence fragment. ' + + 'Translate ONLY the [N] lines. The [context before] and [context after] lines are provided for reference only — do NOT translate them. ' + + 'For natural flow, you may borrow meaning from adjacent lines (e.g. a fragment can incorporate the sense of the next line). ' + + 'Return exactly the same number of [N] translations, no extra explanation.\n\n' + + for (let i = 0; i < cues.length; i += BATCH_SIZE) { + const batch = cues.slice(i, i + BATCH_SIZE) + + const before = cues.slice(Math.max(0, i - CONTEXT_SIZE), i) + .map(c => `[context before: ${c.text}]`).join('\n') + const after = cues.slice(i + BATCH_SIZE, i + BATCH_SIZE + CONTEXT_SIZE) + .map(c => `[context after: ${c.text}]`).join('\n') + + const numbered = batch.map((c, j) => `[${j + 1}] ${c.text}`).join('\n') + const joined = instruction + + (before ? before + '\n' : '') + + numbered + + (after ? '\n' + after : '') try { const translated = await translateText(joined, document.title) @@ -165,14 +130,11 @@ async function translateCuesBatched(cues: SubtitleCue[], onProgress: () => void) const m = line.match(/^\[(\d+)\]\s*(.*)/) if (m) map.set(parseInt(m[1]), m[2].trim()) } - batch.forEach((group, j) => { - const translation = map.get(j + 1) || group.text - group.cues.forEach(cue => { cue.translatedText = translation }) + batch.forEach((cue, j) => { + cue.translatedText = map.get(j + 1) || cue.text }) } catch { - batch.forEach(group => { - group.cues.forEach(cue => { cue.translatedText = cue.text }) - }) + batch.forEach(cue => { cue.translatedText = cue.text }) } onProgress() From 55894c1b17c3ee7c5ef423446c87c0d5dbb92507 Mon Sep 17 00:00:00 2001 From: airhunter Date: Wed, 1 Apr 2026 21:38:43 +0800 Subject: [PATCH 13/15] =?UTF-8?q?feat:=20=E6=94=B9=E7=94=A8=E6=97=B6?= =?UTF-8?q?=E9=97=B4=E9=97=B4=E9=9A=94=E5=90=88=E5=B9=B6=E5=AD=97=E5=B9=95?= =?UTF-8?q?=EF=BC=8C=E8=A7=A3=E5=86=B3=E8=AF=8D=E7=BA=A7=E5=88=87=E6=96=AD?= =?UTF-8?q?=E7=BF=BB=E8=AF=91=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 mergeByTimeGap(),按说话停顿时长决定句子边界: - 相邻 cue 间隔 < 1500ms → 同一句,合并 - 间隔 ≥ 1500ms 或超过 19 词 → 新句,断开 相比文本特征(标点/大写),时间信号对无标点全小写的 YouTube ASR 字幕同样有效,且能正确合并跨 cue 词组 (如 "the united" + "states")。 参考:沉浸式翻译 ytAsrConfig mergeConfig 同款策略。 Co-Authored-By: Claude Sonnet 4.6 --- entrypoints/video/manager.ts | 77 +++++++++++++++++++++++++----------- 1 file changed, 54 insertions(+), 23 deletions(-) diff --git a/entrypoints/video/manager.ts b/entrypoints/video/manager.ts index 3c83185..b48c860 100644 --- a/entrypoints/video/manager.ts +++ b/entrypoints/video/manager.ts @@ -6,10 +6,17 @@ import { config } from '@/entrypoints/utils/config' // ── 常量 ────────────────────────────────────────────────────────────────────── const EVENT_TYPE = 'fr-subtitle-inject' -const BATCH_SIZE = 5 // 每批翻译的 cue 条数 -const CONTEXT_SIZE = 2 // 批次前后各取几条作为上下文 +const BATCH_SIZE = 5 // 每批翻译的句子组数 +const MERGE_GAP_MS = 1500 // 相邻 cue 间隔 < 此值(毫秒)则合并为同一句 +const MAX_WORDS = 19 // 单组超过此词数强制断开 const QUICK_BTN_ID = 'fr-subtitle-quick-btn' +// ── 类型 ────────────────────────────────────────────────────────────────────── +interface SentenceGroup { + cues: SubtitleCue[] + text: string +} + // ── 模块状态 ────────────────────────────────────────────────────────────────── const overlay = new SubtitleOverlay() let listenerAttached = false @@ -98,30 +105,51 @@ async function handleSubtitleData(url: string, rawData: string) { } /** - * 逐条翻译,英中 cue 严格一一对应。 - * 每批在正文 [N] 前后各附 CONTEXT_SIZE 条原文作为上下文(不要求翻译), - * 提示词允许模型跨行借用语义,使碎片句的译文自然连贯。 + * 按时间间隔合并相邻 cue: + * - 相邻两条 cue 的间隔 < MERGE_GAP_MS → 合并为同一句(说话中的正常停顿) + * - 间隔 ≥ MERGE_GAP_MS 或词数超过 MAX_WORDS → 断开(句子之间的自然停顿) + * 这样"united states"等跨 cue 短语能被合并进同一组,避免词级切断的翻译错误。 + */ +function mergeByTimeGap(cues: SubtitleCue[]): SentenceGroup[] { + const groups: SentenceGroup[] = [] + let current: SubtitleCue[] = [] + + for (let i = 0; i < cues.length; i++) { + current.push(cues[i]) + const next = cues[i + 1] + const wordCount = current.reduce((n, c) => n + c.text.split(/\s+/).length, 0) + const gapMs = next ? (next.start - cues[i].end) * 1000 : Infinity + + if (!next || gapMs >= MERGE_GAP_MS || wordCount >= MAX_WORDS) { + groups.push({ + cues: [...current], + text: current.map(c => c.text).join(' ').replace(/\s+/g, ' ').trim(), + }) + current = [] + } + } + return groups +} + +/** + * 将 cue 按时间间隔合并为句子组后批量翻译。 + * 组内所有 cue 共享同一译文,消除跨 cue 词级切断问题。 */ async function translateCuesBatched(cues: SubtitleCue[], onProgress: () => void) { + const groups = mergeByTimeGap(cues) const instruction = - 'These are timed video subtitle lines. Each [N] line is a separate segment and may be a sentence fragment. ' + - 'Translate ONLY the [N] lines. The [context before] and [context after] lines are provided for reference only — do NOT translate them. ' + - 'For natural flow, you may borrow meaning from adjacent lines (e.g. a fragment can incorporate the sense of the next line). ' + - 'Return exactly the same number of [N] translations, no extra explanation.\n\n' + 'Video subtitle segments. Translate each [N] line. ' + + 'Return the same number of [N] lines, no extra explanation.\n\n' - for (let i = 0; i < cues.length; i += BATCH_SIZE) { - const batch = cues.slice(i, i + BATCH_SIZE) + for (let i = 0; i < groups.length; i += BATCH_SIZE) { + const batch = groups.slice(i, i + BATCH_SIZE) - const before = cues.slice(Math.max(0, i - CONTEXT_SIZE), i) - .map(c => `[context before: ${c.text}]`).join('\n') - const after = cues.slice(i + BATCH_SIZE, i + BATCH_SIZE + CONTEXT_SIZE) - .map(c => `[context after: ${c.text}]`).join('\n') + const prevContext = i > 0 + ? `[context: ...${groups[i - 1].text.split(' ').slice(-8).join(' ')}]\n` + : '' - const numbered = batch.map((c, j) => `[${j + 1}] ${c.text}`).join('\n') - const joined = instruction - + (before ? before + '\n' : '') - + numbered - + (after ? '\n' + after : '') + const joined = instruction + prevContext + + batch.map((g, j) => `[${j + 1}] ${g.text}`).join('\n') try { const translated = await translateText(joined, document.title) @@ -130,11 +158,14 @@ async function translateCuesBatched(cues: SubtitleCue[], onProgress: () => void) const m = line.match(/^\[(\d+)\]\s*(.*)/) if (m) map.set(parseInt(m[1]), m[2].trim()) } - batch.forEach((cue, j) => { - cue.translatedText = map.get(j + 1) || cue.text + batch.forEach((group, j) => { + const translation = map.get(j + 1) || group.text + group.cues.forEach(cue => { cue.translatedText = translation }) }) } catch { - batch.forEach(cue => { cue.translatedText = cue.text }) + batch.forEach(group => { + group.cues.forEach(cue => { cue.translatedText = cue.text }) + }) } onProgress() From 5f62f58975e3066ce90acdb30771e0d44c0edd0a Mon Sep 17 00:00:00 2001 From: airhunter Date: Wed, 1 Apr 2026 21:50:22 +0800 Subject: [PATCH 14/15] =?UTF-8?q?fix:=20=E4=BF=AE=E6=AD=A3=E6=97=B6?= =?UTF-8?q?=E9=97=B4=E9=97=B4=E9=9A=94=E9=98=88=E5=80=BC=E8=BF=87=E5=A4=A7?= =?UTF-8?q?=E5=92=8C=20MAX=5FWORDS=20=E6=97=A0=20carry-over=20=E7=9A=84?= =?UTF-8?q?=E4=B8=A4=E4=B8=AA=20bug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MERGE_GAP_MS 从 1500 降至 600ms,避免不同句子因短暂停顿 被合并成一大段导致翻译语义错乱 - MAX_WORDS 触发时若下一条仍是小间隔,将末尾 cue 进位到下一组, 防止碎片句(如 "but the seeds")被孤立翻译 Co-Authored-By: Claude Sonnet 4.6 --- entrypoints/video/manager.ts | 31 ++++++++++++++++++++++--------- 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/entrypoints/video/manager.ts b/entrypoints/video/manager.ts index b48c860..ab01290 100644 --- a/entrypoints/video/manager.ts +++ b/entrypoints/video/manager.ts @@ -7,8 +7,8 @@ import { config } from '@/entrypoints/utils/config' // ── 常量 ────────────────────────────────────────────────────────────────────── const EVENT_TYPE = 'fr-subtitle-inject' const BATCH_SIZE = 5 // 每批翻译的句子组数 -const MERGE_GAP_MS = 1500 // 相邻 cue 间隔 < 此值(毫秒)则合并为同一句 -const MAX_WORDS = 19 // 单组超过此词数强制断开 +const MERGE_GAP_MS = 600 // 相邻 cue 间隔 < 此值(毫秒)则合并为同一句 +const MAX_WORDS = 20 // 单组超过此词数强制断开 const QUICK_BTN_ID = 'fr-subtitle-quick-btn' // ── 类型 ────────────────────────────────────────────────────────────────────── @@ -114,20 +114,33 @@ function mergeByTimeGap(cues: SubtitleCue[]): SentenceGroup[] { const groups: SentenceGroup[] = [] let current: SubtitleCue[] = [] + const flush = (arr: SubtitleCue[]) => groups.push({ + cues: [...arr], + text: arr.map(c => c.text).join(' ').replace(/\s+/g, ' ').trim(), + }) + for (let i = 0; i < cues.length; i++) { current.push(cues[i]) const next = cues[i + 1] const wordCount = current.reduce((n, c) => n + c.text.split(/\s+/).length, 0) const gapMs = next ? (next.start - cues[i].end) * 1000 : Infinity - - if (!next || gapMs >= MERGE_GAP_MS || wordCount >= MAX_WORDS) { - groups.push({ - cues: [...current], - text: current.map(c => c.text).join(' ').replace(/\s+/g, ' ').trim(), - }) - current = [] + const bigGap = !next || gapMs >= MERGE_GAP_MS + const tooLong = wordCount >= MAX_WORDS + + if (bigGap || tooLong) { + if (tooLong && !bigGap && current.length > 1) { + // 词数超限但下一条紧跟(小间隔)→ 末尾 cue 进位到下一组, + // 避免碎片句(如 "but the seeds")因超限被孤立 + const carryOver = current.pop()! + flush(current) + current = [carryOver] + } else { + flush(current) + current = [] + } } } + if (current.length) flush(current) return groups } From a42c24ebecbe3b705c62680b6458aabc046d2273 Mon Sep 17 00:00:00 2001 From: airhunter Date: Wed, 1 Apr 2026 22:16:20 +0800 Subject: [PATCH 15/15] =?UTF-8?q?fix:=20=E4=BF=9D=E5=AD=98=E5=8E=9F?= =?UTF-8?q?=E7=94=9F=E5=AD=97=E5=B9=95=20display=20=E5=80=BC=E5=B9=B6?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20overlay=20cleanup=20=E4=BD=9C=E7=94=A8?= =?UTF-8?q?=E5=9F=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - manager.ts:hideNativeSubtitle 隐藏前将原始内联 display 存入 data-fr-orig-display,restoreNativeSubtitle 从中还原, 避免覆盖宿主页面预设的 display 样式 - overlay.ts:cleanup 改用 this.container?.remove() 替代 document.getElementById,避免误删宿主页面中同名元素 Co-Authored-By: Claude Sonnet 4.6 --- entrypoints/video/manager.ts | 16 ++++++++++++++-- entrypoints/video/overlay.ts | 2 +- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/entrypoints/video/manager.ts b/entrypoints/video/manager.ts index ab01290..31c2a96 100644 --- a/entrypoints/video/manager.ts +++ b/entrypoints/video/manager.ts @@ -291,15 +291,27 @@ function hideNativeSubtitle() { const platform = detectPlatform(window.location.hostname) if (!platform.hideNativeSelector) return // 用 display:none 彻底隐藏,visibility:hidden 仍占位且有时被 YouTube 重置 + // 隐藏前将原始内联 display 值存入 data- 属性,供还原时使用 document.querySelectorAll(platform.hideNativeSelector) - .forEach(el => el.style.setProperty('display', 'none', 'important')) + .forEach(el => { + el.dataset.frOrigDisplay = el.style.display + el.style.setProperty('display', 'none', 'important') + }) } function restoreNativeSubtitle() { const platform = detectPlatform(window.location.hostname) if (!platform.hideNativeSelector) return document.querySelectorAll(platform.hideNativeSelector) - .forEach(el => el.style.removeProperty('display')) + .forEach(el => { + const orig = el.dataset.frOrigDisplay + if (orig !== undefined) { + el.style.display = orig + delete el.dataset.frOrigDisplay + } else { + el.style.removeProperty('display') + } + }) } // ── SPA 导航监听 ────────────────────────────────────────────────────────────── diff --git a/entrypoints/video/overlay.ts b/entrypoints/video/overlay.ts index 0d56649..871388a 100644 --- a/entrypoints/video/overlay.ts +++ b/entrypoints/video/overlay.ts @@ -64,7 +64,7 @@ export class SubtitleOverlay { cancelAnimationFrame(this.rafId) this.rafId = null } - document.getElementById(OVERLAY_ID)?.remove() + this.container?.remove() if (this.mountTarget !== undefined && this.originalMountPosition !== undefined) { this.mountTarget.style.position = this.originalMountPosition }