// ==UserScript== // @name Video Barpic Maker // @name:zh-CN 视频字幕截图制作工具 // @namespace ckylin-script-video-barpic-maker // @version 0.5.3 // @description A simple script to create video barpics. // @description:zh-CN 一个可以制作视频字幕截图的工具。 // @author CKylinMC // @match https://*/* // @grant unsafeWindow // @grant GM_addStyle // @grant GM_getValue // @grant GM_setValue // @grant GM_deleteValue // @grant GM_listValues // @grant GM_registerMenuCommand // @grant GM_info // @license Apache-2.0 // @run-at document-end // @require https://update.greasyfork.icu/scripts/564901/1754426/CKUI.js // @require https://unpkg.com/@zumer/snapdom/dist/snapdom.js // @downloadURL none // ==/UserScript== if (typeof unsafeWindow === 'undefined' || !unsafeWindow) { window.unsafeWindow = window; } (function (unsafeWindow, document) { if (typeof (GM_addStyle) === 'undefined') { unsafeWindow.GM_addStyle = function (css) { const style = document.createElement('style'); style.textContent = css; document.head.appendChild(style); } } const logger = { log(...args) { console.log('[VideoBarpicMaker]', ...args); }, error(...args) { console.error('[VideoBarpicMaker]', ...args); }, warn(...args) { console.warn('[VideoBarpicMaker]', ...args); }, } class Utils{ static wait(ms = 0) { return new Promise(resolve => setTimeout(resolve, ms)); } static $(selector, root = document) { return root.querySelector(selector); } static $all(selector, root = document) { return Array.from(root.querySelectorAll(selector)); } static $child(parent, selector) { if (typeof parent === 'string') { return document.querySelector(parent+' '+selector); } return parent.querySelector(selector); } static $childAll(parent, selector) { if (typeof parent === 'string') { return Array.from(document.querySelectorAll(parent+' '+selector)); } return Array.from(parent.querySelectorAll(selector)); } static removeTailingSlash(str) { return str.replace(/\/+$/, ''); } static fixUrlProtocol(url) { if (url.startsWith('http://') || url.startsWith('https://')) { return url; } else if (url.startsWith('//')) { return unsafeWindow.location.protocol + url; } else if (url.startsWith('data:')) { return url; } else if (url.startsWith('/')) { return unsafeWindow.location.origin + url; } else { return unsafeWindow.location.origin + Utils.removeTailingSlash(unsafeWindow.location.pathname) + '/' + url; } } static waitForElementFirstAppearForever(selector, root = document) { return new Promise(resolve => { const element = root.querySelector(selector); if (element) { resolve(element); return; } const observer = new MutationObserver(mutations => { for (const mutation of mutations) { for (const node of mutation.addedNodes) { if (!(node instanceof HTMLElement)) continue; const el = node.matches(selector) ? node : node.querySelector(selector); if (el) { resolve(el); observer.disconnect(); return; } } } }); observer.observe(root, { childList: true, subtree: true }); }); } static waitForElementFirstAppearForeverWithTimeout(selector, root = document, timeout = 5000) { return new Promise(resolve => { const element = root.querySelector(selector); if (element) { resolve(element); return; } let done = false; const observer = new MutationObserver(mutations => { if (done) return; for (const mutation of mutations) { for (const node of mutation.addedNodes) { if (!(node instanceof HTMLElement)) continue; const el = node.matches(selector) ? node : node.querySelector(selector); if (el) { done = true; resolve(el); observer.disconnect(); return; } } } }); observer.observe(root, { childList: true, subtree: true }); if (timeout > 0) { setTimeout(() => { if (done) return; done = true; observer.disconnect(); resolve(null); }, timeout); } }); } static registerOnElementAttrChange(element, attr, callback) { const observer = new MutationObserver(mutations => { mutations.forEach(mutation => { if (mutation.type === 'attributes' && mutation.attributeName === attr) { callback(mutation); } }); }); observer.observe(element, { attributes: true }); return observer; } static registerOnElementContentChange(element, callback) { const observer = new MutationObserver(mutations => { mutations.forEach(mutation => { if (mutation.type === 'characterData') { callback(mutation); } }); }); observer.observe(element, { characterData: true, subtree: true }); return observer; } static registerOnceElementRemoved(element, callback, root = null) { if (!element) return null; if (!element.isConnected) { callback?.(element); return null; } const parent = root || element.parentNode || element.getRootNode?.(); if (!parent) { callback?.(element); return null; } let done = false; const observer = new MutationObserver(mutations => { if (done) return; if (!element.isConnected) { done = true; observer.disconnect(); callback?.(element); return; } }); observer.observe(parent, { childList: true }); return observer; } static formatDate(timestamp) { return (Intl.DateTimeFormat('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false, }).format(new Date(+timestamp))).replace(/\//g, '-').replace(',', ''); } static daysBefore(timestamp) { const target = new Date(+timestamp); const now = Date.now(); const diff = now - target.getTime(); return Math.floor(diff / (1000 * 60 * 60 * 24)); } static download(filename, text) { const element = document.createElement('a'); element.setAttribute('href', 'data:text/plain;charset=utf-8,' + encodeURIComponent(text)); element.setAttribute('download', filename); element.style.display = 'none'; document.body.appendChild(element); element.click(); document.body.removeChild(element); } static downloadBlob(filename, blob) { const url = URL.createObjectURL(blob); const element = document.createElement('a'); element.setAttribute('href', url); element.setAttribute('download', filename); element.style.display = 'none'; document.body.appendChild(element); element.click(); document.body.removeChild(element); URL.revokeObjectURL(url); } static get ui() { return unsafeWindow.ckui; } } const Icons = { video: '', capture: '', captureDown: '', captureUp: '', settings: '', copy: '', save: '', trash: '', undo: '', redo: '', image: '', }; class SettingsManager { constructor() { this.defaults = { captureMode: 'adaptive', // 'fixed' or 'adaptive' fixedWidth: 1280, minWidth: 640, maxWidth: 1920, topRange: 50, topRangeUnit: 'percent', // 'percent' or 'pixel' bottomRange: 50, bottomRangeUnit: 'percent', previewImageWidth: 260, useLayerCapture: false, manualOffsetLeft: 0, manualOffsetTop: 0, enableFloatButton: true, showImageInfo: false, saveFormat: 'jpeg', // 'png', 'jpeg', 'webp' saveQuality: 0.75, // 0.0 - 1.0 enabled: true, content: '使用 Barpic Maker 制作', fontSize: 16, textColor: '#333333', textAlign: 'right', backgroundColor: '#f5f5f5', padding: 20, containerHeight: 0, containerWidth: 0, watermarkApplyMode: 'always', // 'copy', 'save', 'always' captureDanmakuOnBilibili: false, captureSubtitleOnBilibili: false, captureSubtitleOnYoutube: false, bypassCSP: false }; this.settings = this.load(); } load() { try { const saved = GM_getValue('vbm_settings', null); return saved ? { ...this.defaults, ...JSON.parse(saved) } : { ...this.defaults }; } catch (e) { logger.error('Failed to load settings:', e); return { ...this.defaults }; } } save() { try { GM_setValue('vbm_settings', JSON.stringify(this.settings)); } catch (e) { logger.error('Failed to save settings:', e); } } get(key) { return this.settings[key]; } set(key, value) { this.settings[key] = value; this.save(); } } class CanvasManager { constructor() { this.canvas = null; this.ctx = null; this.history = []; this.historyIndex = -1; this.firstWidth = null; } init(width, height) { if (!this.canvas) { this.canvas = document.createElement('canvas'); this.ctx = this.canvas.getContext('2d'); } this.canvas.width = width; this.canvas.height = height; this.firstWidth = width; } appendImage(imageData, targetWidth) { if (!this.canvas) { this.init(targetWidth, imageData.height); this.ctx.putImageData(imageData, 0, 0); } else { const oldHeight = this.canvas.height; const newHeight = oldHeight + imageData.height; const tempCanvas = document.createElement('canvas'); const tempCtx = tempCanvas.getContext('2d'); tempCanvas.width = imageData.width; tempCanvas.height = imageData.height; tempCtx.putImageData(imageData, 0, 0); const oldImageData = this.ctx.getImageData(0, 0, this.canvas.width, oldHeight); this.canvas.height = newHeight; this.ctx.putImageData(oldImageData, 0, 0); this.ctx.drawImage(tempCanvas, 0, oldHeight, this.canvas.width, imageData.height); } this.saveState(); } saveState() { if (this.historyIndex < this.history.length - 1) { this.history = this.history.slice(0, this.historyIndex + 1); } const imageData = this.ctx.getImageData(0, 0, this.canvas.width, this.canvas.height); this.history.push({ width: this.canvas.width, height: this.canvas.height, data: imageData }); this.historyIndex++; if (this.history.length > 20) { this.history.shift(); this.historyIndex--; } } undo() { if (this.historyIndex > 0) { this.historyIndex--; const state = this.history[this.historyIndex]; this.canvas.width = state.width; this.canvas.height = state.height; this.ctx.putImageData(state.data, 0, 0); return true; } return false; } redo() { if (this.historyIndex < this.history.length - 1) { this.historyIndex++; const state = this.history[this.historyIndex]; this.canvas.width = state.width; this.canvas.height = state.height; this.ctx.putImageData(state.data, 0, 0); return true; } return false; } canUndo() { return this.historyIndex > 0; } canRedo() { return this.historyIndex < this.history.length - 1; } clear() { this.canvas = null; this.ctx = null; this.history = []; this.historyIndex = -1; this.firstWidth = null; } toBlob(format = 'png', quality = 0.95) { return new Promise(resolve => { const mimeType = `image/${format}`; this.canvas.toBlob(resolve, mimeType, quality); }); } toDataURL(format = 'png', quality = 0.95) { const mimeType = `image/${format}`; return this.canvas.toDataURL(mimeType, quality); } async calculateSize(format = 'png', quality = 0.95) { if (!this.canvas) return 0; const blob = await this.toBlob(format, quality); return blob ? blob.size : 0; } getImageInfo() { if (!this.canvas) return null; return { width: this.canvas.width, height: this.canvas.height }; } } class VideoBarpicMaker { constructor() { this.settings = new SettingsManager(); this.canvas = new CanvasManager(); this.toolbarWindow = null; this.toolbarContainer = null; this.previewWindow = null; this.previewContainer = null; this.selectedVideo = null; this.isSelectingVideo = false; this.highlightOverlay = null; this.rangeOverlay = null; this.settingsExpanded = false; this.displayMediaStream = null; this.infoExpanded = false; this.imageInfo = { memorySize: 0, copySize: 0, saveSize: 0, width: 0, height: 0 }; this.previewDebounceTimer = null; } init() { logger.log('Initializing Video Barpic Maker...'); GM_registerMenuCommand('📷 打开视频截图工具', () => this.showToolbar()); if (this.settings.get('bypassCSP')) { this.tryBypassCSP(); } if (this.settings.get('enableFloatButton')) { this.initFloatButton(); } } tryBypassCSP() { try { const testEl = document.createElement('div'); testEl.innerHTML = ''; logger.log('CSP check: innerHTML is allowed, bypass not needed.'); } catch (e) { logger.warn('CSP check: innerHTML blocked, attempting TrustedTypes policy injection...', e); try { const win = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window; if (win.trustedTypes && win.trustedTypes.createPolicy) { if (!win.trustedTypes.defaultPolicy) { win.trustedTypes.createPolicy('default', { createHTML: (string) => string, }); logger.log('CSP bypass: TrustedTypes default policy injected successfully.'); } else { logger.log('CSP bypass: TrustedTypes default policy already exists.'); } } else { logger.warn('CSP bypass: TrustedTypes API not available, cannot inject policy.'); } } catch (policyErr) { logger.error('CSP bypass: Failed to inject TrustedTypes policy:', policyErr); } } } async initFloatButton() { if(document.getElementById('CKVIDBARPIC-floatbtn')) return; const videoElement = await Utils.waitForElementFirstAppearForeverWithTimeout('video', document, 10000); if (!videoElement) { logger.log('No video element found within 10 seconds, float button will not be shown'); return; } logger.log('Video element detected, showing float button'); GM_addStyle(` #CKVIDBARPIC-floatbtn{ display: flex; justify-content: center; align-items: center; box-sizing: border-box; z-index: 9999; position: fixed; left: -15px; width: 30px; height: 30px; background: black; opacity: 0.8; color: white; cursor: pointer; border-radius: 50%; text-align: right; line-height: 24px; border: solid 3px #00000000; transition: opacity .3s 1s, background .3s, color .3s, left .3s, border .3s; top: 120px; top: 30vh; } #CKVIDBARPIC-floatbtn::after,#CKVIDBARPIC-floatbtn::before{ z-index: 9990; content: "视频截图工具"; pointer-events: none; position: fixed; left: -20px; height: 30px; background: black; opacity: 0; color: white; cursor: pointer; border-radius: 8px; padding: 0 12px; text-align: right; line-height: 30px; transition: all .3s; top: 123px; top: 30vh; } #CKVIDBARPIC-floatbtn::after{ content: "← 视频截图工具"; /*animation: CKVIDBARPIC-tipsOut forwards 5s 3.5s;*/ } #CKVIDBARPIC-floatbtn:hover::before{ left: 30px; opacity: 1; } #CKVIDBARPIC-floatbtn:hover{ border: solid 3px black; transition: opacity .3s 0s, background .3s, color .3s, left .3s, border .3s; background: white; color: black; opacity: 1; left: -5px; } #CKVIDBARPIC-floatbtn.hide{ left: -40px; } @keyframes CKVIDBARPIC-tipsOut{ 5%,95%{ opacity: 1; left: 20px; } 0%,100%{ left: -20px; opacity: 0; } } `,); const toggle = document.createElement("div"); toggle.id = "CKVIDBARPIC-floatbtn"; toggle.innerHTML = ``; toggle.onclick = () => this.showToolbar(); document.body.appendChild(toggle); } checkLayerCaptureSupport() { return !!(navigator.mediaDevices && navigator.mediaDevices.getDisplayMedia && window.ImageCapture); } detectBrowserUIOffset() { const dpr = window.devicePixelRatio || 1; const manualLeft = this.settings.get('manualOffsetLeft') || 0; const manualTop = this.settings.get('manualOffsetTop') || 0; if (manualLeft !== 0 || manualTop !== 0) { logger.log('Using manual offset:', { left: manualLeft, top: manualTop }); return { left: manualLeft, top: manualTop, hasSignificantOffset: true, isManual: true }; } const widthDiff = window.outerWidth - window.innerWidth; const heightDiff = window.outerHeight - window.innerHeight; const leftOffset = Math.max(0, widthDiff); const topOffset = Math.max(0, heightDiff - 100); // Subtract typical title bar const hasSignificantOffset = leftOffset > 10 || topOffset > 50; logger.log('Browser UI offset detected:', { widthDiff, heightDiff, leftOffset, topOffset, hasSignificantOffset, dpr }); return { left: leftOffset, top: topOffset, hasSignificantOffset, isManual: false }; } showToolbar() { if (this.toolbarWindow) { return; } this.toolbarContainer = this.createToolbar(); this.toolbarWindow = Utils.ui.floatWindow({ title: '视频截图工具', content: this.toolbarContainer, width: "500px", position: { x: 100, y: 100 }, shadow: true, onClose: () => { this.cleanup(); this.toolbarWindow = null; this.toolbarContainer = null; } }); this.toolbarWindow.show(); logger.log('Toolbar shown'); } createToolbar() { const container = document.createElement('div'); container.style.cssText = 'display: flex; flex-direction: column; gap: 12px; min-width: 400px'; const videoSection = document.createElement('div'); videoSection.innerHTML = `
未选择视频
`; container.appendChild(videoSection); const captureSection = document.createElement('div'); captureSection.id = 'vbm-capture-section'; captureSection.style.display = 'none'; captureSection.innerHTML = `
`; container.appendChild(captureSection); const settingsBtn = document.createElement('button'); settingsBtn.className = 'ckui-btn'; settingsBtn.id = 'vbm-settings-toggle'; settingsBtn.innerHTML = `${Icons.settings} 设置`; settingsBtn.style.width = '100%'; container.appendChild(settingsBtn); const settingsPanel = document.createElement('div'); settingsPanel.id = 'vbm-settings-panel'; settingsPanel.style.display = 'none'; settingsPanel.appendChild(this.createSettingsPanel()); container.appendChild(settingsPanel); const divider = document.createElement('div'); divider.className = 'ckui-divider'; container.appendChild(divider); const actionsSection = document.createElement('div'); actionsSection.id = 'vbm-actions-section'; actionsSection.style.display = 'none'; actionsSection.innerHTML = `
0 0
`; container.appendChild(actionsSection); if (this.settings.get('showImageInfo')) { const infoBtn = document.createElement('button'); infoBtn.className = 'ckui-btn'; infoBtn.id = 'vbm-info-toggle'; infoBtn.innerHTML = `${Icons.image} 图片信息`; infoBtn.style.width = '100%'; infoBtn.style.display = 'none'; container.appendChild(infoBtn); const infoPanel = document.createElement('div'); infoPanel.id = 'vbm-info-panel'; infoPanel.style.display = 'none'; infoPanel.innerHTML = `
尺寸:-
内存格式:PNG | 大小:-
复制:PNG | 大小:-
保存格式:- | 大小:-
`; container.appendChild(infoPanel); } setTimeout(() => this.bindToolbarEvents(container), 0); return container; } createCaptureSettings() { const settings = this.settings; const div = document.createElement('div'); div.style.cssText = 'padding: 12px;'; div.innerHTML = `
第一张截图宽度在此范围内时使用原宽度,否则限制到边界
`; return div; } createSaveSettings() { const settings = this.settings; const div = document.createElement('div'); div.style.cssText = 'padding: 12px;'; div.innerHTML = `
PNG 格式质量参数无效,JPEG 和 WebP 格式范围为 1-100
`; return div; } createExperimentalSettings() { const settings = this.settings; const div = document.createElement('div'); div.style.cssText = 'padding: 12px;'; div.innerHTML = `
启用后将使用屏幕捕获API,可以截取视频上的弹幕、控制栏等浮层内容。首次使用时需要授权。(方案已废弃,不推荐使用)
部分网站具有默认策略拦截,尝试绕过(可能导致安全性降级)。启用后将在页面加载时自动执行。
手动设置偏移值以修正 DisplayMedia 截图位置偏差
`; return div; } createSpecialSettings() { const settings = this.settings; const div = document.createElement('div'); div.style.cssText = 'padding: 12px;'; const description = document.createElement('div'); description.style.cssText = 'margin-bottom: 16px; padding: 12px; background: var(--ckui-bg-tertiary); border-radius: var(--ckui-radius); font-size: 13px; color: var(--ckui-text-secondary); line-height: 1.5;'; description.textContent = '这里是针对特定网站的特殊适配选项。这些功能仅在对应网站上生效。'; div.appendChild(description); const bilibiliSection = document.createElement('div'); bilibiliSection.style.cssText = 'margin-bottom: 12px; border: 1px solid var(--ckui-border-color); border-radius: var(--ckui-radius); overflow: hidden;'; const bilibiliHeader = document.createElement('div'); bilibiliHeader.style.cssText = 'padding: 12px; background: var(--ckui-bg-tertiary); cursor: pointer; display: flex; align-items: center; justify-content: space-between; user-select: none;'; bilibiliHeader.innerHTML = ` 📺 哔哩哔哩 `; const bilibiliContent = document.createElement('div'); bilibiliContent.id = 'vbm-bilibili-content'; bilibiliContent.style.cssText = 'display: none; padding: 12px; border-top: 1px solid var(--ckui-border-color);'; bilibiliContent.innerHTML = `
截图时也尝试截取弹幕层
截图时也尝试包含字幕
`; bilibiliSection.appendChild(bilibiliHeader); bilibiliSection.appendChild(bilibiliContent); div.appendChild(bilibiliSection); const youtubeSection = document.createElement('div'); youtubeSection.style.cssText = 'margin-bottom: 12px; border: 1px solid var(--ckui-border-color); border-radius: var(--ckui-radius); overflow: hidden;'; const youtubeHeader = document.createElement('div'); youtubeHeader.style.cssText = 'padding: 12px; background: var(--ckui-bg-tertiary); cursor: pointer; display: flex; align-items: center; justify-content: space-between; user-select: none;'; youtubeHeader.innerHTML = ` ▶️ YouTube `; const youtubeContent = document.createElement('div'); youtubeContent.id = 'vbm-youtube-content'; youtubeContent.style.cssText = 'display: none; padding: 12px; border-top: 1px solid var(--ckui-border-color);'; youtubeContent.innerHTML = `
截图时也截取字幕(不支持第三方插件字幕)
`; youtubeSection.appendChild(youtubeHeader); youtubeSection.appendChild(youtubeContent); div.appendChild(youtubeSection); setTimeout(() => { bilibiliHeader.addEventListener('click', () => { const content = bilibiliContent; const icon = bilibiliHeader.querySelector('#vbm-bilibili-toggle-icon'); const isExpanded = content.style.display !== 'none'; if (isExpanded) { content.style.display = 'none'; icon.style.transform = 'rotate(0deg)'; } else { content.style.display = 'block'; icon.style.transform = 'rotate(180deg)'; } }); const checkbox = div.querySelector('#vbm-capture-danmaku-bilibili'); checkbox?.addEventListener('change', (e) => { settings.set('captureDanmakuOnBilibili', e.target.checked); logger.log('Capture danmaku on Bilibili:', e.target.checked); }); const subtitleCheckbox = div.querySelector('#vbm-capture-subtitle-bilibili'); subtitleCheckbox?.addEventListener('change', (e) => { settings.set('captureSubtitleOnBilibili', e.target.checked); logger.log('Capture subtitle on Bilibili:', e.target.checked); }); youtubeHeader.addEventListener('click', () => { const content = youtubeContent; const icon = youtubeHeader.querySelector('#vbm-youtube-toggle-icon'); const isExpanded = content.style.display !== 'none'; if (isExpanded) { content.style.display = 'none'; icon.style.transform = 'rotate(0deg)'; } else { content.style.display = 'block'; icon.style.transform = 'rotate(180deg)'; } }); const youtubeSubtitleCheckbox = div.querySelector('#vbm-capture-subtitle-youtube'); youtubeSubtitleCheckbox?.addEventListener('change', (e) => { settings.set('captureSubtitleOnYoutube', e.target.checked); logger.log('Capture subtitle on YouTube:', e.target.checked); }); }, 0); return div; } createOtherSettings() { const settings = this.settings; const div = document.createElement('div'); div.style.cssText = 'padding: 12px;'; div.innerHTML = `
在页面上显示一个浮动按钮,方便快速打开工具
显示图片信息按钮并计算截图大小和分辨率(关闭可提升截图速度)
在图片末尾添加自定义文字内容
选择在何种操作时添加水印到图片中
水印预览区域
`; return div; } escapeHtml(text) { const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } createAboutSettings() { const version = (typeof GM_info !== 'undefined' && GM_info?.script?.version) || '未知'; const div = document.createElement('div'); div.style.cssText = 'padding: 16px;'; div.innerHTML = `
视频字幕截图制作工具
版本 ${version}
开源致谢
snapdom.js
Fast and Accurate HTML Conversion
https://github.com/zumerlab/snapdom
MIT License
`; return div; } createSettingsPanel() { const tabs = Utils.ui.tabs({ tabs: [ { label: '📷 截图', content: this.createCaptureSettings() }, { label: '💾 保存', content: this.createSaveSettings() }, { label: '🧪 实验', content: this.createExperimentalSettings() }, { label: '🎯 特调', content: this.createSpecialSettings() }, { label: '⚙️ 其他', content: this.createOtherSettings() }, { label: 'ℹ️ 关于', content: this.createAboutSettings() } ], style: 'pills' }); const container = document.createElement('div'); container.style.cssText = 'background: var(--ckui-bg-secondary); border-radius: var(--ckui-radius); margin-top: 8px;'; container.appendChild(tabs.render()); return container; } bindToolbarEvents(container) { const selectBtn = container.querySelector('#vbm-select-video'); selectBtn?.addEventListener('click', () => this.startVideoSelection()); const captureFull = container.querySelector('#vbm-capture-full'); const captureBottom = container.querySelector('#vbm-capture-bottom'); const captureTop = container.querySelector('#vbm-capture-top'); captureFull?.addEventListener('click', () => this.captureVideo('full')); captureBottom?.addEventListener('click', () => this.captureVideo('bottom')); captureTop?.addEventListener('click', () => this.captureVideo('top')); const settingsToggle = container.querySelector('#vbm-settings-toggle'); const settingsPanel = container.querySelector('#vbm-settings-panel'); settingsToggle?.addEventListener('click', () => { this.settingsExpanded = !this.settingsExpanded; settingsPanel.style.display = this.settingsExpanded ? 'block' : 'none'; }); const captureModeSelect = container.querySelector('#vbm-capture-mode'); const fixedWidthContainer = container.querySelector('#vbm-fixed-width-container'); const adaptiveWidthContainer = container.querySelector('#vbm-adaptive-width-container'); const fixedWidthInput = container.querySelector('#vbm-fixed-width'); const minWidthInput = container.querySelector('#vbm-min-width'); const maxWidthInput = container.querySelector('#vbm-max-width'); const topRangeInput = container.querySelector('#vbm-top-range'); const topRangeUnit = container.querySelector('#vbm-top-range-unit'); const bottomRangeInput = container.querySelector('#vbm-bottom-range'); const bottomRangeUnit = container.querySelector('#vbm-bottom-range-unit'); captureModeSelect?.addEventListener('change', (e) => { this.settings.set('captureMode', e.target.value); fixedWidthContainer.style.display = e.target.value === 'fixed' ? 'block' : 'none'; adaptiveWidthContainer.style.display = e.target.value === 'adaptive' ? 'block' : 'none'; }); fixedWidthInput?.addEventListener('change', (e) => { this.settings.set('fixedWidth', parseInt(e.target.value) || 1280); }); minWidthInput?.addEventListener('change', (e) => { this.settings.set('minWidth', parseInt(e.target.value) || 640); }); maxWidthInput?.addEventListener('change', (e) => { this.settings.set('maxWidth', parseInt(e.target.value) || 1920); }); const previewWidthInput = container.querySelector('#vbm-preview-width'); previewWidthInput?.addEventListener('change', (e) => { this.settings.set('previewImageWidth', parseInt(e.target.value) || 260); this.updatePreview(); // Update preview with new width }); topRangeInput?.addEventListener('input', (e) => { this.settings.set('topRange', parseInt(e.target.value) || 50); this.showRangePreview('top'); }); topRangeUnit?.addEventListener('change', (e) => { this.settings.set('topRangeUnit', e.target.value); this.showRangePreview('top'); }); bottomRangeInput?.addEventListener('input', (e) => { this.settings.set('bottomRange', parseInt(e.target.value) || 50); this.showRangePreview('bottom'); }); bottomRangeUnit?.addEventListener('change', (e) => { this.settings.set('bottomRangeUnit', e.target.value); this.showRangePreview('bottom'); }); const bypassCSPCheckbox = container.querySelector('#vbm-bypass-csp'); bypassCSPCheckbox?.addEventListener('change', (e) => { const enabled = e.target.checked; this.settings.set('bypassCSP', enabled); if (enabled) { this.tryBypassCSP(); Utils.ui.notify({ type: 'warning', title: 'CSP 绕过已启用', message: '已尝试注入 TrustedTypes 策略,下次页面加载时会自动执行', shadow: true, duration: 4000 }); } }); const layerCaptureCheckbox = container.querySelector('#vbm-use-layer-capture'); const manualOffsetContainer = container.querySelector('#vbm-manual-offset-container'); const offsetLeftInput = container.querySelector('#vbm-offset-left'); const offsetTopInput = container.querySelector('#vbm-offset-top'); layerCaptureCheckbox?.addEventListener('change', (e) => { const enabled = e.target.checked; if (enabled && !this.checkLayerCaptureSupport()) { e.target.checked = false; Utils.ui.notify({ type: 'error', title: '不支持', message: '您的浏览器不支持叠层截图功能,需要 Chrome/Edge 90+ 或 Firefox 90+', shadow: true, duration: 5000 }); return; } this.settings.set('useLayerCapture', enabled); if (manualOffsetContainer) { manualOffsetContainer.style.display = enabled ? 'block' : 'none'; } if (offsetLeftInput) { offsetLeftInput.disabled = !enabled; } if (offsetTopInput) { offsetTopInput.disabled = !enabled; } if (enabled) { Utils.ui.notify({ type: 'info', title: '叠层截图已启用', message: '下次截图时会弹出授权窗口,请选择当前标签页', shadow: true, duration: 4000 }); } }); offsetLeftInput?.addEventListener('change', (e) => { this.settings.set('manualOffsetLeft', parseInt(e.target.value) || 0); }); offsetTopInput?.addEventListener('change', (e) => { this.settings.set('manualOffsetTop', parseInt(e.target.value) || 0); }); const floatButtonCheckbox = container.querySelector('#vbm-enable-float-button'); floatButtonCheckbox?.addEventListener('change', (e) => { this.settings.set('enableFloatButton', e.target.checked); if (e.target.checked) { Utils.ui.notify({ type: 'info', title: '浮动按钮已启用', message: '刷新页面后生效', shadow: true, duration: 3000 }); } else { Utils.ui.notify({ type: 'info', title: '浮动按钮已禁用', message: '刷新页面后生效', shadow: true, duration: 3000 }); } }); const showImageInfoCheckbox = container.querySelector('#vbm-show-image-info'); showImageInfoCheckbox?.addEventListener('change', (e) => { this.settings.set('showImageInfo', e.target.checked); if (e.target.checked) { Utils.ui.notify({ type: 'info', title: '截图信息已启用', message: '重新打开工具窗口后生效', shadow: true, duration: 3000 }); } else { Utils.ui.notify({ type: 'info', title: '截图信息已禁用', message: '重新打开工具窗口后生效', shadow: true, duration: 3000 }); } }); const watermarkCheckbox = container.querySelector('#vbm-enable-watermark'); const watermarkSettings = container.querySelector('#vbm-watermark-settings'); watermarkCheckbox?.addEventListener('change', (e) => { this.settings.set('enabled', e.target.checked); watermarkSettings.style.display = e.target.checked ? 'block' : 'none'; if (e.target.checked) { this.debouncedPreviewWatermark(); } }); const watermarkContent = container.querySelector('#vbm-watermark-content'); watermarkContent?.addEventListener('input', (e) => { this.settings.set('content', e.target.value); this.debouncedPreviewWatermark(); }); const watermarkApplyMode = container.querySelector('#vbm-watermark-apply-mode'); watermarkApplyMode?.addEventListener('change', (e) => { this.settings.set('watermarkApplyMode', e.target.value); }); const fontSize = container.querySelector('#vbm-watermark-fontsize'); fontSize?.addEventListener('input', (e) => { this.settings.set('fontSize', parseInt(e.target.value) || 16); this.debouncedPreviewWatermark(); }); const textColor = container.querySelector('#vbm-watermark-color'); textColor?.addEventListener('input', (e) => { this.settings.set('textColor', e.target.value); this.debouncedPreviewWatermark(); }); const textAlign = container.querySelector('#vbm-watermark-align'); textAlign?.addEventListener('change', (e) => { this.settings.set('textAlign', e.target.value); this.debouncedPreviewWatermark(); }); const bgColor = container.querySelector('#vbm-watermark-bgcolor'); bgColor?.addEventListener('input', (e) => { this.settings.set('backgroundColor', e.target.value); this.debouncedPreviewWatermark(); }); const padding = container.querySelector('#vbm-watermark-padding'); padding?.addEventListener('input', (e) => { this.settings.set('padding', parseInt(e.target.value) || 20); this.debouncedPreviewWatermark(); }); const height = container.querySelector('#vbm-watermark-height'); height?.addEventListener('input', (e) => { this.settings.set('containerHeight', parseInt(e.target.value) || 0); this.debouncedPreviewWatermark(); }); const width = container.querySelector('#vbm-watermark-width'); width?.addEventListener('input', (e) => { this.settings.set('containerWidth', parseInt(e.target.value) || 0); this.debouncedPreviewWatermark(); }); if (this.settings.get('enabled')) { setTimeout(() => this.previewWatermark(), 100); } const saveFormatSelect = container.querySelector('#vbm-save-format'); saveFormatSelect?.addEventListener('change', (e) => { this.settings.set('saveFormat', e.target.value); if (this.settings.get('showImageInfo')) { this.updateImageInfo(); } }); const saveQualityInput = container.querySelector('#vbm-save-quality'); saveQualityInput?.addEventListener('change', (e) => { const quality = Math.max(1, Math.min(100, parseInt(e.target.value) || 95)); e.target.value = quality; this.settings.set('saveQuality', quality / 100); if (this.settings.get('showImageInfo')) { this.updateImageInfo(); } }); const undoBtn = container.querySelector('#vbm-undo'); const redoBtn = container.querySelector('#vbm-redo'); const copyBtn = container.querySelector('#vbm-copy'); const saveBtn = container.querySelector('#vbm-save'); const clearBtn = container.querySelector('#vbm-clear'); undoBtn?.addEventListener('click', () => this.undo()); redoBtn?.addEventListener('click', () => this.redo()); copyBtn?.addEventListener('click', () => this.copyToClipboard()); saveBtn?.addEventListener('click', () => this.saveToFile()); clearBtn?.addEventListener('click', () => this.clearCanvas()); const infoToggleBtn = container.querySelector('#vbm-info-toggle'); infoToggleBtn?.addEventListener('click', () => { this.infoExpanded = !this.infoExpanded; const infoPanel = container.querySelector('#vbm-info-panel'); if (infoPanel) { infoPanel.style.display = this.infoExpanded ? 'block' : 'none'; } if (this.infoExpanded && this.settings.get('showImageInfo')) { this.updateImageInfo(); } }); } startVideoSelection() { if (this.isSelectingVideo) return; this.isSelectingVideo = true; Utils.ui.notify({ type: 'info', title: '选择视频', message: '请将鼠标悬停在视频上,然后点击选择', shadow: true }); this.createHighlightOverlay(); document.addEventListener('mouseover', this.handleMouseOver); document.addEventListener('click', this.handleVideoClick, true); } handleMouseOver = (e) => { if (!this.isSelectingVideo) return; const target = e.target; if (target.tagName === 'VIDEO') { const rect = target.getBoundingClientRect(); this.highlightOverlay.style.cssText = ` position: fixed; left: ${rect.left}px; top: ${rect.top}px; width: ${rect.width}px; height: ${rect.height}px; border: 3px solid #3b82f6; background: rgba(59, 130, 246, 0.1); pointer-events: none; z-index: 999999; box-sizing: border-box; `; } else { this.highlightOverlay.style.display = 'none'; } }; handleVideoClick = (e) => { if (!this.isSelectingVideo) return; const target = e.target; if (target.tagName === 'VIDEO') { e.preventDefault(); e.stopPropagation(); this.selectedVideo = target; this.isSelectingVideo = false; document.removeEventListener('mouseover', this.handleMouseOver); document.removeEventListener('click', this.handleVideoClick, true); this.removeHighlightOverlay(); if (this.toolbarContainer) { const statusEl = this.toolbarContainer.querySelector('#vbm-video-status'); if (statusEl) { statusEl.textContent = '✓ 已选择视频'; statusEl.style.color = 'var(--ckui-success)'; } const captureSection = this.toolbarContainer.querySelector('#vbm-capture-section'); if (captureSection) { captureSection.style.display = 'block'; } } Utils.ui.notify({ type: 'success', title: '视频已选择', message: '现在可以开始截图了', shadow: true }); } }; createHighlightOverlay() { if (this.highlightOverlay) return; this.highlightOverlay = document.createElement('div'); document.body.appendChild(this.highlightOverlay); } removeHighlightOverlay() { if (this.highlightOverlay) { this.highlightOverlay.remove(); this.highlightOverlay = null; } } showRangePreview(type) { if (!this.selectedVideo) return; this.removeRangeOverlay(); const video = this.selectedVideo; const rect = video.getBoundingClientRect(); let rangeValue = this.settings.get(`${type}Range`); let rangeUnit = this.settings.get(`${type}RangeUnit`); let height; if (rangeUnit === 'percent') { height = rect.height * (rangeValue / 100); } else { height = rangeValue; } height = Math.min(height, rect.height); this.rangeOverlay = document.createElement('div'); this.rangeOverlay.style.cssText = ` position: fixed; left: ${rect.left}px; top: ${type === 'top' ? rect.top : rect.bottom - height}px; width: ${rect.width}px; height: ${height}px; background: rgba(59, 130, 246, 0.3); border: 2px solid #3b82f6; pointer-events: none; z-index: 999999; box-sizing: border-box; `; document.body.appendChild(this.rangeOverlay); setTimeout(() => this.removeRangeOverlay(), 1000); } removeRangeOverlay() { if (this.rangeOverlay) { this.rangeOverlay.remove(); this.rangeOverlay = null; } } debouncedPreviewWatermark() { if (this.previewDebounceTimer) { clearTimeout(this.previewDebounceTimer); } this.previewDebounceTimer = setTimeout(() => { this.previewWatermark(); }, 300); } async previewWatermark() { try { const previewContainer = this.toolbarContainer?.querySelector('#vbm-watermark-preview-container'); if (!previewContainer) return; previewContainer.innerHTML = ''; previewContainer.style.display = 'flex'; previewContainer.style.alignItems = 'center'; previewContainer.style.justifyContent = 'center'; const canvasWidth = this.canvas.canvas?.width || this.canvas.firstWidth || 1280; const watermarkCanvas = this.drawTextWatermarkToCanvas(canvasWidth); const img = document.createElement('img'); img.src = watermarkCanvas.toDataURL(); img.style.cssText = 'width: 100%; height: auto; display: block;'; previewContainer.appendChild(img); } catch (error) { logger.error('Preview watermark failed:', error); const previewContainer = this.toolbarContainer?.querySelector('#vbm-watermark-preview-container'); if (previewContainer) { previewContainer.innerHTML = '预览失败'; } } } async generateFinalCanvas(action = 'always') { if (!this.canvas.canvas) { throw new Error('No canvas available'); } const enabled = this.settings.get('enabled'); const watermarkApplyMode = this.settings.get('watermarkApplyMode'); const shouldApplyWatermark = enabled && ( watermarkApplyMode === 'always' || (watermarkApplyMode === 'copy' && action === 'copy') || (watermarkApplyMode === 'save' && action === 'save') ); if (!shouldApplyWatermark) { return this.canvas.canvas; } try { const originalCanvas = this.canvas.canvas; const watermarkCanvas = this.drawTextWatermarkToCanvas(originalCanvas.width); const finalCanvas = document.createElement('canvas'); finalCanvas.width = originalCanvas.width; finalCanvas.height = originalCanvas.height + watermarkCanvas.height; const ctx = finalCanvas.getContext('2d'); ctx.drawImage(originalCanvas, 0, 0); ctx.drawImage(watermarkCanvas, 0, originalCanvas.height); return finalCanvas; } catch (error) { logger.error('Generate final canvas with watermark failed:', error); Utils.ui.notify({ type: 'warning', title: '水印添加失败', message: '将使用原图进行操作', shadow: true }); return this.canvas.canvas; } } drawTextWatermarkToCanvas(width) { const settings = this.settings; const content = settings.get('content'); const fontSize = settings.get('fontSize'); const textColor = settings.get('textColor'); const textAlign = settings.get('textAlign'); const backgroundColor = settings.get('backgroundColor'); const padding = settings.get('padding'); const containerWidth = settings.get('containerWidth') || width; const containerHeight = settings.get('containerHeight'); const lines = content.split('\n'); const tempCanvas = document.createElement('canvas'); const tempCtx = tempCanvas.getContext('2d'); tempCtx.font = `${fontSize}px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif`; const lineHeight = fontSize * 1.5; let maxLineWidth = 0; const measuredLines = lines.map(line => { const metrics = tempCtx.measureText(line); maxLineWidth = Math.max(maxLineWidth, metrics.width); return { text: line, width: metrics.width }; }); const contentHeight = lines.length * lineHeight; const actualHeight = containerHeight || (contentHeight + padding * 2); const canvas = document.createElement('canvas'); canvas.width = containerWidth; canvas.height = actualHeight; const ctx = canvas.getContext('2d'); ctx.fillStyle = backgroundColor; ctx.fillRect(0, 0, canvas.width, canvas.height); ctx.font = `${fontSize}px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif`; ctx.fillStyle = textColor; ctx.textBaseline = 'top'; let x; if (textAlign === 'left') { ctx.textAlign = 'left'; x = padding; } else if (textAlign === 'center') { ctx.textAlign = 'center'; x = canvas.width / 2; } else if (textAlign === 'right') { ctx.textAlign = 'right'; x = canvas.width - padding; } const startY = padding; measuredLines.forEach((line, index) => { const y = startY + index * lineHeight; ctx.fillText(line.text, x, y); }); return canvas; } async captureYoutubeSubtitleLayer(videoRect) { try { const subtitleElement = document.querySelector('.ytp-caption-window-container') || document.querySelector('#ytp-caption-window-container'); if (!subtitleElement) { logger.log('YouTube subtitle element not found'); return null; } logger.log('Capturing YouTube subtitle layer...'); if (typeof snapdom === 'undefined') { logger.warn('snapdom library not available'); return null; } const result = await snapdom(subtitleElement, { fast: true }); const img = await result.toPng({ backgroundColor: '#00000000' }); logger.log('YouTube subtitle layer captured'); return img; } catch (error) { logger.error('Failed to capture YouTube subtitle layer:', error); return null; } } async captureSubtitleLayer(videoRect) { try { const subtitleElement = document.querySelector('.bili-subtitle-x-subtitle-panel'); if (!subtitleElement) { logger.log('Subtitle element not found'); return null; } logger.log('Capturing subtitle layer...'); if (typeof snapdom === 'undefined') { logger.warn('snapdom library not available'); return null; } const result = await snapdom(subtitleElement, { fast: true }); const img = await result.toPng({ backgroundColor: '#00000000' }); logger.log('Subtitle layer captured'); return img; } catch (error) { logger.error('Failed to capture subtitle layer:', error); return null; } } async overlaySubtitleOnCanvas(canvasWidth, height, subtitleImg, srcYRatio, srcHeightRatio) { try { if (!subtitleImg) return; const imgW = subtitleImg.naturalWidth || subtitleImg.width; const imgH = subtitleImg.naturalHeight || subtitleImg.height; const srcY = srcYRatio * imgH; const srcH = srcHeightRatio * imgH; const yPos = this.canvas.canvas.height - height; this.canvas.ctx.drawImage( subtitleImg, 0, srcY, imgW, srcH, 0, yPos, canvasWidth, height ); logger.log('Subtitle layer overlaid successfully'); } catch (error) { logger.error('Failed to overlay subtitle:', error); } } async captureDanmakuLayer(videoRect) { try { const danmakuElement = document.querySelector('.bpx-player-dm-mask-wrap'); if (!danmakuElement) { logger.log('Danmaku element not found'); return null; } logger.log('Capturing danmaku layer...'); if (typeof snapdom === 'undefined') { logger.warn('snapdom library not available'); return null; } const result = await snapdom(danmakuElement, { fast: true }); const img = await result.toPng({ backgroundColor: '#00000000' }); logger.log('Danmaku layer captured'); return img; } catch (error) { logger.error('Failed to capture danmaku layer:', error); return null; } } async overlayDanmakuOnCanvas(canvasWidth, height, danmakuImg, srcYRatio, srcHeightRatio) { try { if (!danmakuImg) return; const imgW = danmakuImg.naturalWidth || danmakuImg.width; const imgH = danmakuImg.naturalHeight || danmakuImg.height; const srcY = srcYRatio * imgH; const srcH = srcHeightRatio * imgH; const yPos = this.canvas.canvas.height - height; this.canvas.ctx.drawImage( danmakuImg, 0, srcY, imgW, srcH, 0, yPos, canvasWidth, height ); logger.log('Danmaku layer overlaid successfully'); } catch (error) { logger.error('Failed to overlay danmaku:', error); } } async captureVideoWithLayers(mode) { const video = this.selectedVideo; const rect = video.getBoundingClientRect(); const dpr = window.devicePixelRatio || 1; const uiOffset = this.detectBrowserUIOffset(); try { const toolbarShowing = !!this.toolbarWindow; const previewShowing = !!this.previewWindow; if (this.toolbarWindow && this.toolbarWindow.hide) { this.toolbarWindow.hide(); } if (this.previewWindow && this.previewWindow.hide) { this.previewWindow.hide(); } await Utils.wait(500); const stream = await navigator.mediaDevices.getDisplayMedia({ video: { displaySurface: "browser", width: { ideal: 3840 }, height: { ideal: 2160 } }, audio: false, preferCurrentTab: true }); const videoTrack = stream.getVideoTracks()[0]; const imageCapture = new ImageCapture(videoTrack); const bitmap = await imageCapture.grabFrame(); videoTrack.stop(); stream.getTracks().forEach(track => track.stop()); if (toolbarShowing && this.toolbarWindow && this.toolbarWindow.show) { this.toolbarWindow.show(); } if (previewShowing && this.previewWindow && this.previewWindow.show) { this.previewWindow.show(); } const fullCanvas = document.createElement('canvas'); const fullCtx = fullCanvas.getContext('2d'); fullCanvas.width = bitmap.width; fullCanvas.height = bitmap.height; fullCtx.drawImage(bitmap, 0, 0); let cropX = rect.left * dpr; let cropY = rect.top * dpr; const cropWidth = rect.width * dpr; const cropHeight = rect.height * dpr; if (uiOffset.hasSignificantOffset) { logger.log('Applying UI offset compensation:', uiOffset); cropX += uiOffset.left * dpr; cropY += uiOffset.top * dpr; } if (cropX < 0 || cropY < 0 || cropX + cropWidth > fullCanvas.width || cropY + cropHeight > fullCanvas.height) { logger.warn('Crop area out of bounds', { cropX, cropY, cropWidth, cropHeight, canvasWidth: fullCanvas.width, canvasHeight: fullCanvas.height }); if (toolbarShowing && this.toolbarWindow && this.toolbarWindow.show) { this.toolbarWindow.show(); } if (previewShowing && this.previewWindow && this.previewWindow.show) { this.previewWindow.show(); } Utils.ui.notify({ type: 'error', title: '截图失败', message: '裁剪区域超出边界,请尝试调整偏移设置', shadow: true, duration: 5000 }); return; } let finalCropY = cropY; let finalCropHeight = cropHeight; if (mode === 'top') { const rangeValue = this.settings.get('topRange'); const rangeUnit = this.settings.get('topRangeUnit'); if (rangeUnit === 'percent') { finalCropHeight = cropHeight * (rangeValue / 100); } else { finalCropHeight = Math.min(rangeValue * dpr, cropHeight); } } else if (mode === 'bottom') { const rangeValue = this.settings.get('bottomRange'); const rangeUnit = this.settings.get('bottomRangeUnit'); let height; if (rangeUnit === 'percent') { height = cropHeight * (rangeValue / 100); } else { height = Math.min(rangeValue * dpr, cropHeight); } finalCropY = cropY + cropHeight - height; finalCropHeight = height; } const croppedCanvas = document.createElement('canvas'); const croppedCtx = croppedCanvas.getContext('2d'); croppedCanvas.width = cropWidth; croppedCanvas.height = finalCropHeight; croppedCtx.drawImage( fullCanvas, cropX, finalCropY, cropWidth, finalCropHeight, 0, 0, cropWidth, finalCropHeight ); const imageData = croppedCtx.getImageData(0, 0, croppedCanvas.width, croppedCanvas.height); let targetWidth; const captureMode = this.settings.get('captureMode'); if (captureMode === 'fixed') { targetWidth = this.settings.get('fixedWidth'); } else if (captureMode === 'adaptive') { const firstWidth = this.canvas.firstWidth || imageData.width; const minWidth = this.settings.get('minWidth'); const maxWidth = this.settings.get('maxWidth'); if (firstWidth < minWidth) { targetWidth = minWidth; } else if (firstWidth > maxWidth) { targetWidth = maxWidth; } else { targetWidth = firstWidth; } } else { targetWidth = this.canvas.firstWidth || imageData.width; } this.canvas.appendImage(imageData, targetWidth); if (this.settings.get('captureDanmakuOnBilibili') && window.location.hostname.includes('bilibili.com')) { try { const danmakuImg = await this.captureDanmakuLayer(rect); if (danmakuImg) { const srcYRatio = (finalCropY - cropY) / cropHeight; const srcHeightRatio = finalCropHeight / cropHeight; await this.overlayDanmakuOnCanvas( targetWidth, imageData.height, danmakuImg, srcYRatio, srcHeightRatio ); } } catch (danmakuError) { logger.warn('Danmaku overlay failed (layers mode), continuing without it:', danmakuError); } } if (this.settings.get('captureSubtitleOnBilibili') && window.location.hostname.includes('bilibili.com')) { try { const subtitleImg = await this.captureSubtitleLayer(rect); if (subtitleImg) { const srcYRatio = (finalCropY - cropY) / cropHeight; const srcHeightRatio = finalCropHeight / cropHeight; await this.overlaySubtitleOnCanvas( targetWidth, imageData.height, subtitleImg, srcYRatio, srcHeightRatio ); } } catch (subtitleError) { logger.warn('Subtitle overlay failed (layers mode), continuing without it:', subtitleError); } } if (this.settings.get('captureSubtitleOnYoutube') && window.location.hostname.includes('youtube.com')) { try { const subtitleImg = await this.captureYoutubeSubtitleLayer(rect); if (subtitleImg) { const srcYRatio = (finalCropY - cropY) / cropHeight; const srcHeightRatio = finalCropHeight / cropHeight; await this.overlaySubtitleOnCanvas( targetWidth, imageData.height, subtitleImg, srcYRatio, srcHeightRatio ); } } catch (subtitleError) { logger.warn('YouTube subtitle overlay failed (layers mode), continuing without it:', subtitleError); } } if (this.toolbarContainer) { const actionsSection = this.toolbarContainer.querySelector('#vbm-actions-section'); if (actionsSection) { actionsSection.style.display = 'block'; } if (this.settings.get('showImageInfo')) { const infoBtn = this.toolbarContainer.querySelector('#vbm-info-toggle'); if (infoBtn) { infoBtn.style.display = 'block'; } } } this.updatePreview(); this.updateActionButtons(); this.scrollPreviewToBottom(); } catch (error) { if (this.toolbarWindow && this.toolbarWindow.show) { this.toolbarWindow.show(); } if (this.previewWindow && this.previewWindow.show) { this.previewWindow.show(); } logger.error('Layer capture failed:', error); if (error.name === 'NotAllowedError') { Utils.ui.notify({ type: 'warning', title: '未授权', message: '您拒绝了屏幕捕获授权,已切换回普通截图模式', shadow: true, duration: 4000 }); this.settings.set('useLayerCapture', false); if (this.toolbarContainer) { const checkbox = this.toolbarContainer.querySelector('#vbm-use-layer-capture'); if (checkbox) checkbox.checked = false; } } else if (error.name === 'NotSupportedError') { Utils.ui.notify({ type: 'error', title: '不支持', message: '您的浏览器不支持此功能', shadow: true }); } else { Utils.ui.notify({ type: 'error', title: '截图失败', message: `发生错误: ${error.message}`, shadow: true, duration: 5000 }); } } } async captureVideo(mode) { if (!this.selectedVideo) { Utils.ui.notify({ type: 'error', title: '错误', message: '请先选择视频', shadow: true }); return; } try { if (this.settings.get('useLayerCapture')) { await this.captureVideoWithLayers(mode); return; } const video = this.selectedVideo; const rect = video.getBoundingClientRect(); const tempCanvas = document.createElement('canvas'); const tempCtx = tempCanvas.getContext('2d'); tempCanvas.width = video.videoWidth || video.clientWidth; tempCanvas.height = video.videoHeight || video.clientHeight; tempCtx.drawImage(video, 0, 0, tempCanvas.width, tempCanvas.height); let imageData; let cropStartY = 0; if (mode === 'full') { imageData = tempCtx.getImageData(0, 0, tempCanvas.width, tempCanvas.height); } else if (mode === 'top') { let height; const rangeValue = this.settings.get('topRange'); const rangeUnit = this.settings.get('topRangeUnit'); if (rangeUnit === 'percent') { height = Math.floor(tempCanvas.height * (rangeValue / 100)); } else { height = Math.min(rangeValue, tempCanvas.height); } imageData = tempCtx.getImageData(0, 0, tempCanvas.width, height); } else if (mode === 'bottom') { let height; const rangeValue = this.settings.get('bottomRange'); const rangeUnit = this.settings.get('bottomRangeUnit'); if (rangeUnit === 'percent') { height = Math.floor(tempCanvas.height * (rangeValue / 100)); } else { height = Math.min(rangeValue, tempCanvas.height); } const startY = tempCanvas.height - height; cropStartY = startY; imageData = tempCtx.getImageData(0, startY, tempCanvas.width, height); } let targetWidth; const captureMode = this.settings.get('captureMode'); if (captureMode === 'fixed') { targetWidth = this.settings.get('fixedWidth'); } else if (captureMode === 'adaptive') { const firstWidth = this.canvas.firstWidth || imageData.width; const minWidth = this.settings.get('minWidth'); const maxWidth = this.settings.get('maxWidth'); if (firstWidth < minWidth) { targetWidth = minWidth; } else if (firstWidth > maxWidth) { targetWidth = maxWidth; } else { targetWidth = firstWidth; } } else { targetWidth = this.canvas.firstWidth || imageData.width; } this.canvas.appendImage(imageData, targetWidth); if (this.settings.get('captureDanmakuOnBilibili') && window.location.hostname.includes('bilibili.com')) { try { const danmakuImg = await this.captureDanmakuLayer(rect); if (danmakuImg) { const srcYRatio = cropStartY / tempCanvas.height; const srcHeightRatio = imageData.height / tempCanvas.height; await this.overlayDanmakuOnCanvas( targetWidth, imageData.height, danmakuImg, srcYRatio, srcHeightRatio ); } } catch (danmakuError) { logger.warn('Danmaku overlay failed (normal mode), continuing without it:', danmakuError); } } if (this.settings.get('captureSubtitleOnBilibili') && window.location.hostname.includes('bilibili.com')) { try { const subtitleImg = await this.captureSubtitleLayer(rect); if (subtitleImg) { const srcYRatio = cropStartY / tempCanvas.height; const srcHeightRatio = imageData.height / tempCanvas.height; await this.overlaySubtitleOnCanvas( targetWidth, imageData.height, subtitleImg, srcYRatio, srcHeightRatio ); } } catch (subtitleError) { logger.warn('Subtitle overlay failed (normal mode), continuing without it:', subtitleError); } } if (this.settings.get('captureSubtitleOnYoutube') && window.location.hostname.includes('youtube.com')) { try { const subtitleImg = await this.captureYoutubeSubtitleLayer(rect); if (subtitleImg) { const srcYRatio = cropStartY / tempCanvas.height; const srcHeightRatio = imageData.height / tempCanvas.height; await this.overlaySubtitleOnCanvas( targetWidth, imageData.height, subtitleImg, srcYRatio, srcHeightRatio ); } } catch (subtitleError) { logger.warn('YouTube subtitle overlay failed (normal mode), continuing without it:', subtitleError); } } if (this.toolbarContainer) { const actionsSection = this.toolbarContainer.querySelector('#vbm-actions-section'); if (actionsSection) { actionsSection.style.display = 'block'; } if (this.settings.get('showImageInfo')) { const infoBtn = this.toolbarContainer.querySelector('#vbm-info-toggle'); if (infoBtn) { infoBtn.style.display = 'block'; } } } this.updatePreview(); this.updateActionButtons(); this.scrollPreviewToBottom(); } catch (error) { logger.error('Capture failed:', error); Utils.ui.notify({ type: 'error', title: '截图失败', message: error.message, shadow: true }); } } updatePreview() { if (!this.previewWindow && this.canvas.canvas) { this.createPreviewWindow(); } if (this.previewContainer && this.canvas.canvas) { this.previewContainer.innerHTML = ''; const img = document.createElement('img'); img.src = this.canvas.toDataURL(); const previewWidth = this.settings.get('previewImageWidth'); img.style.cssText = `width: ${previewWidth}px; height: auto; display: block;`; this.previewContainer.appendChild(img); this.scrollPreviewToBottom(); if (this.settings.get('showImageInfo')) { this.updateImageInfo(); } } } scrollPreviewToBottom() { if (this.previewContainer) { setTimeout(() => { this.previewContainer.scrollTop = this.previewContainer.scrollHeight; }, 50); } } async updateImageInfo() { if (!this.canvas.canvas || !this.infoExpanded) return; const info = this.canvas.getImageInfo(); if (!info) return; const saveFormat = this.settings.get('saveFormat'); const saveQuality = this.settings.get('saveQuality'); const memorySize = await this.canvas.calculateSize('png', 1.0); const copySize = await this.canvas.calculateSize('png', 1.0); const saveSize = await this.canvas.calculateSize(saveFormat, saveQuality); this.imageInfo = { width: info.width, height: info.height, memorySize, copySize, saveSize }; if (this.toolbarContainer) { const dimensionsEl = this.toolbarContainer.querySelector('#vbm-info-dimensions'); const memoryEl = this.toolbarContainer.querySelector('#vbm-info-memory'); const copyEl = this.toolbarContainer.querySelector('#vbm-info-copy'); const saveEl = this.toolbarContainer.querySelector('#vbm-info-save'); const saveFormatEl = this.toolbarContainer.querySelector('#vbm-info-save-format'); if (dimensionsEl) dimensionsEl.textContent = `${info.width} × ${info.height}`; if (memoryEl) memoryEl.textContent = this.formatFileSize(memorySize); if (copyEl) copyEl.textContent = this.formatFileSize(copySize); if (saveEl) saveEl.textContent = this.formatFileSize(saveSize); if (saveFormatEl) saveFormatEl.textContent = `${saveFormat.toUpperCase()}${saveFormat !== 'png' ? ` (${Math.round(saveQuality * 100)}%)` : ''}`; } } formatFileSize(bytes) { if (bytes === 0) return '0 B'; const k = 1024; const sizes = ['B', 'KB', 'MB', 'GB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i]; } createPreviewWindow() { this.previewContainer = document.createElement('div'); this.previewContainer.id = 'vbm-preview-container'; this.previewContainer.style.cssText = 'max-height: 80vh; overflow-y: auto; display: flex; flex-direction: column; align-items: center;'; this.previewWindow = Utils.ui.floatWindow({ title: '预览', content: this.previewContainer, width: 280, position: { x: 520, y: 100 }, shadow: true, onClose: () => { this.previewWindow = null; this.previewContainer = null; } }); this.previewWindow.show(); } updateActionButtons() { if (!this.toolbarContainer) return; const undoBtn = this.toolbarContainer.querySelector('#vbm-undo'); const redoBtn = this.toolbarContainer.querySelector('#vbm-redo'); const undoCount = this.toolbarContainer.querySelector('#vbm-undo-count'); const redoCount = this.toolbarContainer.querySelector('#vbm-redo-count'); const copyBtn = this.toolbarContainer.querySelector('#vbm-copy'); const saveBtn = this.toolbarContainer.querySelector('#vbm-save'); const clearBtn = this.toolbarContainer.querySelector('#vbm-clear'); const hasCanvas = !!this.canvas.canvas; const canUndoSteps = this.canvas.historyIndex; const canRedoSteps = this.canvas.history.length - 1 - this.canvas.historyIndex; if (undoBtn) { undoBtn.disabled = !this.canvas.canUndo(); } if (redoBtn) { redoBtn.disabled = !this.canvas.canRedo(); } if (undoCount) { undoCount.textContent = canUndoSteps > 0 ? canUndoSteps : ''; undoCount.style.color = canUndoSteps > 0 ? 'var(--ckui-text-secondary)' : 'var(--ckui-text-muted)'; } if (redoCount) { redoCount.textContent = canRedoSteps > 0 ? canRedoSteps : ''; redoCount.style.color = canRedoSteps > 0 ? 'var(--ckui-text-secondary)' : 'var(--ckui-text-muted)'; } if (copyBtn) copyBtn.disabled = !hasCanvas; if (saveBtn) saveBtn.disabled = !hasCanvas; if (clearBtn) clearBtn.disabled = !hasCanvas; } undo() { if (this.canvas.undo()) { this.updatePreview(); this.updateActionButtons(); this.scrollPreviewToBottom(); } } redo() { if (this.canvas.redo()) { this.updatePreview(); this.updateActionButtons(); this.scrollPreviewToBottom(); } } async copyToClipboard() { try { const finalCanvas = await this.generateFinalCanvas('copy'); const blob = await new Promise((resolve) => { finalCanvas.toBlob((blob) => { resolve(blob); }, 'image/png', 1.0); }); const mimeType = 'image/png'; await navigator.clipboard.write([ new ClipboardItem({ [mimeType]: blob }) ]); Utils.ui.notify({ type: 'success', title: '复制成功', message: '图片已复制到剪贴板 (PNG)', shadow: true }); } catch (error) { logger.error('Copy failed:', error); Utils.ui.notify({ type: 'error', title: '复制失败', message: error.message, shadow: true }); } } async saveToFile() { try { const format = this.settings.get('saveFormat'); const quality = this.settings.get('saveQuality'); const finalCanvas = await this.generateFinalCanvas('save'); const blob = await new Promise((resolve) => { finalCanvas.toBlob((blob) => { resolve(blob); }, `image/${format}`, quality); }); const filename = `video-barpic-${Date.now()}.${format}`; Utils.downloadBlob(filename, blob); Utils.ui.notify({ type: 'success', title: '保存成功', message: `文件已保存: ${filename}`, shadow: true }); } catch (error) { logger.error('Save failed:', error); Utils.ui.notify({ type: 'error', title: '保存失败', message: error.message, shadow: true }); } } async clearCanvas() { const confirmed = await Utils.ui.confirm({ title: '确认清空', content: '确定要清空当前的所有截图吗?此操作不可恢复。', shadow: true }); if (!confirmed) { return; } this.canvas.clear(); if (this.previewWindow) { this.previewWindow.close(); this.previewWindow = null; this.previewContainer = null; } if (this.toolbarContainer) { const actionsSection = this.toolbarContainer.querySelector('#vbm-actions-section'); if (actionsSection) { actionsSection.style.display = 'none'; } const infoBtn = this.toolbarContainer.querySelector('#vbm-info-toggle'); if (infoBtn) { infoBtn.style.display = 'none'; } const infoPanel = this.toolbarContainer.querySelector('#vbm-info-panel'); if (infoPanel) { infoPanel.style.display = 'none'; } } this.infoExpanded = false; this.imageInfo = { memorySize: 0, copySize: 0, saveSize: 0, width: 0, height: 0 }; this.updateActionButtons(); logger.log('Canvas cleared successfully', { canvasExists: !!this.canvas.canvas, historyLength: this.canvas.history.length, historyIndex: this.canvas.historyIndex }); Utils.ui.notify({ type: 'success', title: '已清空', message: '画布已清空,可以重新开始截图', shadow: true }); } cleanup() { this.removeHighlightOverlay(); this.removeRangeOverlay(); this.isSelectingVideo = false; document.removeEventListener('mouseover', this.handleMouseOver); document.removeEventListener('click', this.handleVideoClick, true); if (this.displayMediaStream) { this.displayMediaStream.getTracks().forEach(track => track.stop()); this.displayMediaStream = null; } } } const app = new VideoBarpicMaker(); app.init(); })(unsafeWindow, document);