Greasy Fork is available in English.
捕捉帧、修改时间重摄、拼接长图。支持收缩面板,代码精简,无惧CSP。
当前为
// ==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
// ==/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);
})();