// ==UserScript== // @name Universal Video Screenshot & Stitcher (Ultimate) // @name:zh-CN 通用视频截图拼接工具 (终极版) // @namespace http://tampermonkey.net/ // @version 3.0 // @description Capture, edit frame time, stitch, and download. Pure DOM, Safe Mode. // @description:zh-CN 捕捉帧、修改时间重摄、拼接长图。支持收缩面板,代码精简,无惧CSP。 // @author You // @match *://*/* // @grant GM_setValue // @grant GM_getValue // @run-at document-end // @license MIT // @downloadURL none // ==/UserScript== (function() { 'use strict'; // --- 常量与配置 --- const I18N = { zh: { title: "截图拼接", cap: "捕捉", gen: "生成", clr: "清空", set: "设置", mode: "模式", mSeq: "长图", mSub: "字幕", sel: "选择器", noVid: "未找到视频", cors: "跨域限制(CORS)", tip: "回车跳转时间" }, en: { title: "Stitcher", cap: "Capture", gen: "Generate", clr: "Clear", set: "Settings", mode: "Mode", mSeq: "Long", mSub: "Sub", sel: "Selector", noVid: "No Video", cors: "CORS Error", tip: "Enter to seek" } }; const T = navigator.language.startsWith('zh') ? I18N.zh : I18N.en; const CFG = { sel: GM_getValue('sel', 'video'), mode: GM_getValue('mode', 'sub'), // sub(overlap) or seq(parallel) pct: GM_getValue('pct', 20), collapsed: false }; let frames = []; // Array of { canvas, time } let els = {}; // UI Elements cache // --- 工具函数 (DOM & Time) --- const el = (tag, attrs = {}, kids = []) => { const d = document.createElement(tag); for (let k in attrs) k === 'style' ? Object.assign(d.style, attrs[k]) : d[k] = attrs[k]; kids.forEach(k => d.appendChild(typeof k === 'string' ? document.createTextNode(k) : k)); return d; }; const fmtTime = (s) => { const m = Math.floor(s / 60), sec = Math.floor(s % 60); return `${m.toString().padStart(2,'0')}:${sec.toString().padStart(2,'0')}`; }; const parseTime = (str) => { const p = str.split(':'); return p.length === 2 ? parseInt(p[0])*60 + parseInt(p[1]) : 0; }; // --- 核心逻辑 --- const getVideo = () => document.querySelector(CFG.sel) || document.querySelector('video'); const capture = (targetIndex = -1) => { const vid = getVideo(); if (!vid) return alert(T.noVid); try { vid.setAttribute('crossOrigin', 'anonymous'); } catch {} const cvs = document.createElement('canvas'); cvs.width = vid.videoWidth; cvs.height = vid.videoHeight; cvs.getContext('2d').drawImage(vid, 0, 0); const frameData = { canvas: cvs, time: vid.currentTime }; if (targetIndex >= 0) { frames[targetIndex] = frameData; // 替换 } else { frames.push(frameData); // 新增 } renderList(); }; const generate = () => { if (!frames.length) return; const w = frames[0].canvas.width; let h = 0, y = 0; // 计算总高度 if (CFG.mode === 'seq') { frames.forEach(f => h += f.canvas.height); } else { h = frames[0].canvas.height; const slice = h * (CFG.pct / 100); if (frames.length > 1) h += (frames.length - 1) * slice; } const res = document.createElement('canvas'); res.width = w; res.height = h; const ctx = res.getContext('2d'); frames.forEach((f, i) => { const fh = f.canvas.height; if (CFG.mode === 'seq') { ctx.drawImage(f.canvas, 0, y); y += fh; } else { if (i === 0) { ctx.drawImage(f.canvas, 0, 0); y += fh; } else { const sh = fh * (CFG.pct / 100); ctx.drawImage(f.canvas, 0, fh - sh, w, sh, 0, y, w, sh); y += sh; } } }); try { res.toBlob(b => { const a = el('a', { href: URL.createObjectURL(b), download: `stitch_${Date.now()}.png` }); document.body.appendChild(a); a.click(); a.remove(); }); } catch { alert(T.cors); } }; // --- UI 渲染 --- const renderList = () => { els.list.innerHTML = ''; // Safe here because we use createEl for children frames.forEach((f, i) => { // 缩略图 const img = el('img', { src: f.canvas.toDataURL('image/jpeg', 0.1), className: 'vss-th' }); // 时间输入框 const inp = el('input', { type: 'text', className: 'vss-tm', value: fmtTime(f.time), title: T.tip }); inp.onkeydown = (e) => { if(e.key === 'Enter') { const vid = getVideo(); if(vid) vid.currentTime = parseTime(inp.value); } }; // 按钮组 const btnRetake = el('button', { className: 'vss-b-ic vss-retake', title: 'Retake/Replace', onclick: () => capture(i) }, ['📷']); const btnUp = el('button', { className: 'vss-b-ic', onclick: () => swap(i, -1) }, ['↑']); const btnDn = el('button', { className: 'vss-b-ic', onclick: () => swap(i, 1) }, ['↓']); const btnDel = el('button', { className: 'vss-b-ic vss-del', onclick: () => { frames.splice(i,1); renderList(); } }, ['✕']); const row = el('div', { className: 'vss-row vss-item' }, [ img, el('div', { className: 'vss-col' }, [inp, el('div', {className:'vss-acts'}, [btnRetake, btnUp, btnDn, btnDel])]) ]); els.list.appendChild(row); }); // 更新计数 els.count.textContent = frames.length; // 自动滚动 els.list.scrollTop = els.list.scrollHeight; }; const swap = (i, dir) => { if (i+dir < 0 || i+dir >= frames.length) return; [frames[i], frames[i+dir]] = [frames[i+dir], frames[i]]; renderList(); }; const toggleCollapse = () => { CFG.collapsed = !CFG.collapsed; els.body.style.display = CFG.collapsed ? 'none' : 'block'; els.toggleBtn.textContent = CFG.collapsed ? '+' : '_'; }; // --- 初始化 UI --- const initUI = () => { if (document.getElementById('vss-root')) return; // 1. CSS const css = ` #vss-root { position: fixed; bottom: 20px; left: 20px; width: 240px; background: #222; color: #fff; z-index: 999999; border-radius: 6px; font: 12px sans-serif; box-shadow: 0 5px 15px rgba(0,0,0,0.5); user-select: none; } .vss-hd { padding: 8px; border-bottom: 1px solid #444; display: flex; justify-content: space-between; cursor: move; background: #333; border-radius: 6px 6px 0 0; align-items: center; } .vss-bd { padding: 8px; display: block; } .vss-btn { width: 100%; padding: 6px; border: none; border-radius: 3px; cursor: pointer; color: #fff; margin-bottom: 5px; background: #007bff; } .vss-btn:hover { opacity: 0.9; } .vss-g { background: #28a745; } .vss-r { background: #dc3545; } .vss-gy { background: #6c757d; } .vss-list { max-height: 200px; overflow-y: auto; background: #1a1a1a; margin-bottom: 5px; border: 1px solid #444; } .vss-row { display: flex; padding: 4px; border-bottom: 1px solid #333; } .vss-item:hover { background: #2a2a2a; } .vss-th { width: 60px; height: 34px; object-fit: cover; margin-right: 5px; background: #000; } .vss-col { flex: 1; display: flex; flex-direction: column; justify-content: space-between; } .vss-tm { background: #333; border: 1px solid #555; color: #ddd; width: 100%; font-size: 10px; text-align: center; border-radius: 2px; } .vss-acts { display: flex; gap: 2px; justify-content: flex-end; } .vss-b-ic { padding: 1px 5px; font-size: 10px; cursor: pointer; border: none; border-radius: 2px; background: #555; color: #fff; } .vss-retake { background: #17a2b8; } .vss-del { background: #dc3545; } .vss-set { display: none; padding-top: 5px; border-top: 1px solid #444; } .vss-inp { width: 100%; background: #333; border: 1px solid #555; color: #fff; padding: 2px; margin-bottom: 3px; } .vss-flex { display: flex; gap: 5px; } `; document.head.appendChild(el('style', {}, [css])); // 2. 构建组件 els.toggleBtn = el('span', { className: 'vss-cursor', style: {padding:'0 5px', cursor:'pointer'}, onclick: toggleCollapse }, ['_']); els.count = el('span', {}, ['0']); els.list = el('div', { className: 'vss-list' }); // 头部 const header = el('div', { className: 'vss-hd', ondblclick: toggleCollapse }, [ el('span', {}, [`${T.title} (`, els.count, `)`]), el('div', {}, [ els.toggleBtn, el('span', { style: {cursor:'pointer', marginLeft:'8px'}, onclick: () => root.remove() }, ['✕']) ]) ]); // 控制区 const btnCap = el('button', { className: 'vss-btn', onclick: () => capture() }, [T.cap]); const btnGen = el('button', { className: 'vss-btn vss-g', style: {flex:2} }, [T.gen]); const btnClr = el('button', { className: 'vss-btn vss-r', style: {flex:1} }, [T.clr]); btnGen.onclick = generate; btnClr.onclick = () => { if(confirm('?')) { frames=[]; renderList(); }}; // 设置区 const setPanel = el('div', { className: 'vss-set' }); // 模式单选 const mkRadio = (val, lbl) => { const r = el('input', { type: 'radio', name: 'vm', checked: CFG.mode === val }); r.onchange = () => { CFG.mode = val; GM_setValue('mode', val); }; return el('label', { style: {marginRight:'10px'} }, [r, lbl]); }; setPanel.appendChild(el('div', { style:{marginBottom:'5px'} }, [mkRadio('sub', T.mSub), mkRadio('seq', T.mSeq)])); // 切片高度与选择器 const inpPct = el('input', { type:'number', className:'vss-inp', value: CFG.pct, placeholder: '%', onchange: e => { CFG.pct=e.target.value; GM_setValue('pct', e.target.value); } }); const inpSel = el('input', { type:'text', className:'vss-inp', value: CFG.sel, placeholder: T.sel, onchange: e => { CFG.sel=e.target.value; GM_setValue('sel', e.target.value); } }); setPanel.appendChild(inpPct); setPanel.appendChild(inpSel); const btnSet = el('button', { className: 'vss-btn vss-gy', style: {fontSize:'10px', padding:'2px'} }, [T.set]); btnSet.onclick = () => setPanel.style.display = setPanel.style.display === 'none' ? 'block' : 'none'; // 组装 Body els.body = el('div', { className: 'vss-bd' }, [ btnCap, els.list, el('div', { className: 'vss-flex' }, [btnGen, btnClr]), btnSet, setPanel ]); const root = el('div', { id: 'vss-root' }, [header, els.body]); document.body.appendChild(root); // 拖拽逻辑 let isD = false, dx, dy; header.onmousedown = e => { isD = true; dx = e.clientX - root.offsetLeft; dy = e.clientY - root.offsetTop; }; document.onmousemove = e => { if(isD) { root.style.left = (e.clientX - dx)+'px'; root.style.top = (e.clientY - dy)+'px'; }}; document.onmouseup = () => isD = false; } // --- 启动 --- const check = () => { if (document.querySelector('video') || document.querySelector(CFG.sel)) { initUI(); obs.disconnect(); } }; const obs = new MutationObserver(check); obs.observe(document.body, { childList: true, subtree: true }); setTimeout(check, 1000); })();