-
Notifications
You must be signed in to change notification settings - Fork 344
feat: 新增视频字幕翻译功能,支持 YouTube 平台 #204
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
airhunter
wants to merge
15
commits into
Bistutu:main
Choose a base branch
from
airhunter:feature/youtube-support
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from 14 commits
Commits
Show all changes
15 commits
Select commit
Hold shift + click to select a range
a34fe83
feat: 新增视频字幕翻译功能,支持 YouTube 平台
airhunter 710404e
fix: 修复视频字幕模块的安全与性能问题
airhunter d48d29b
fix: 切换字幕翻译开关时同步恢复/隐藏原生字幕
airhunter 1e4ad68
fix: overlay 卸载时还原挂载目标的原始 position 样式
airhunter 1ffc746
fix: 修复字幕翻译不全和原文显示不全的问题
airhunter c95e161
style: 字幕显示区域拉宽至 94%,字号增大至 22px/18px
airhunter 647e622
fix: 合并碎片字幕再翻译,解决半句翻译不通顺的问题
airhunter c3bb181
fix: 修复满组截断导致碎片句跨组翻译错误的问题
airhunter 61a341c
fix: 字幕翻译加入专用提示词和跨批上下文
airhunter f6ca4f5
refactor: 移除句子合并逻辑,改由提示词保证翻译连贯性
airhunter 1a1dd42
fix: 三管齐下改善碎片字幕翻译质量
airhunter 2329888
refactor: 逐条翻译 + 前后上下文窗口,保持英中一一对应
airhunter 55894c1
feat: 改用时间间隔合并字幕,解决词级切断翻译错误
airhunter 5f62f58
fix: 修正时间间隔阈值过大和 MAX_WORDS 无 carry-over 的两个 bug
airhunter a42c24e
fix: 保存原生字幕 display 值并修复 overlay cleanup 作用域
airhunter File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,332 @@ | ||
| 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 = 5 // 每批翻译的句子组数 | ||
| const MERGE_GAP_MS = 600 // 相邻 cue 间隔 < 此值(毫秒)则合并为同一句 | ||
| const MAX_WORDS = 20 // 单组超过此词数强制断开 | ||
| const QUICK_BTN_ID = 'fr-subtitle-quick-btn' | ||
|
|
||
| // ── 类型 ────────────────────────────────────────────────────────────────────── | ||
| interface SentenceGroup { | ||
| cues: SubtitleCue[] | ||
| text: string | ||
| } | ||
|
|
||
| // ── 模块状态 ────────────────────────────────────────────────────────────────── | ||
| 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])) | ||
| } | ||
|
|
||
| /** | ||
| * 按时间间隔合并相邻 cue: | ||
| * - 相邻两条 cue 的间隔 < MERGE_GAP_MS → 合并为同一句(说话中的正常停顿) | ||
| * - 间隔 ≥ MERGE_GAP_MS 或词数超过 MAX_WORDS → 断开(句子之间的自然停顿) | ||
| * 这样"united states"等跨 cue 短语能被合并进同一组,避免词级切断的翻译错误。 | ||
| */ | ||
| 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 | ||
| 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 | ||
| } | ||
|
|
||
| /** | ||
| * 将 cue 按时间间隔合并为句子组后批量翻译。 | ||
| * 组内所有 cue 共享同一译文,消除跨 cue 词级切断问题。 | ||
| */ | ||
| async function translateCuesBatched(cues: SubtitleCue[], onProgress: () => void) { | ||
| const groups = mergeByTimeGap(cues) | ||
| const instruction = | ||
| 'Video subtitle segments. Translate each [N] line. ' + | ||
| '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 | ||
| ? `[context: ...${groups[i - 1].text.split(' ').slice(-8).join(' ')}]\n` | ||
| : '' | ||
|
|
||
| const joined = instruction + prevContext | ||
| + batch.map((g, j) => `[${j + 1}] ${g.text}`).join('\n') | ||
|
|
||
| try { | ||
| const translated = await translateText(joined, document.title) | ||
| const map = new Map<number, string>() | ||
| 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((group, j) => { | ||
| const translation = map.get(j + 1) || group.text | ||
| group.cues.forEach(cue => { cue.translatedText = translation }) | ||
| }) | ||
| } catch { | ||
| batch.forEach(group => { | ||
| group.cues.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.appendChild(buildBtnSvg(subtitleEnabled)) | ||
|
|
||
| btn.addEventListener('click', () => { | ||
| subtitleEnabled = !subtitleEnabled | ||
| btn.replaceChildren(buildBtnSvg(subtitleEnabled)) | ||
| btn.title = subtitleEnabled ? '流畅阅读:字幕翻译(开)' : '流畅阅读:字幕翻译(关)' | ||
| if (subtitleEnabled) { | ||
| hideNativeSubtitle() | ||
| overlay.show() | ||
| } else { | ||
| overlay.hide() | ||
| restoreNativeSubtitle() | ||
| } | ||
| }) | ||
|
|
||
| // 插入到右侧控制栏最左边 | ||
| controls.prepend(btn) | ||
| }) | ||
| } | ||
|
|
||
| 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 | ||
| } | ||
|
|
||
| /** 轮询等待目标元素出现 */ | ||
| 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<HTMLVideoElement>(platform.videoSelector) | ||
| if (v) return v | ||
| } | ||
| return document.querySelector<HTMLVideoElement>('video') | ||
| } | ||
|
|
||
| function findMountTarget(video: HTMLVideoElement): HTMLElement { | ||
| const platform = detectPlatform(window.location.hostname) | ||
| if (platform.containerSelector) { | ||
| const el = document.querySelector<HTMLElement>(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<HTMLElement>(platform.hideNativeSelector) | ||
| .forEach(el => el.style.setProperty('display', 'none', 'important')) | ||
| } | ||
|
|
||
| function restoreNativeSubtitle() { | ||
| const platform = detectPlatform(window.location.hostname) | ||
| if (!platform.hideNativeSelector) return | ||
| document.querySelectorAll<HTMLElement>(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 }) | ||
| } | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.