Greasy Fork

Greasy Fork is available in English.

通用视频截图拼接工具 (终极版)

捕捉帧、修改时间重摄、拼接长图。支持收缩面板,代码精简,无惧CSP。

当前为 2025-12-02 提交的版本,查看 最新版本

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==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);

})();