通用视频截图拼接工具

捕捉、批量截图(按时间段平均分割)、拼接并以自定义文件名保存。支持 2:00-5:00;10 语法。

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         Universal Video Screenshot & Stitcher (Batch & Custom)
// @name:zh-CN   通用视频截图拼接工具
// @namespace    http://tampermonkey.net/
// @version      5.0
// @description  Capture, batch capture (by time range), stitch, and save. Modular architecture.
// @description:zh-CN 捕捉、批量截图(按时间段平均分割)、拼接并以自定义文件名保存。支持 2:00-5:00;10 语法。
// @author       You
// @match        *://*/*
// @grant        GM_setValue
// @grant        GM_getValue
// @run-at       document-end
// @license      MIT
// ==/UserScript==

(function() {
    'use strict';

    // ==========================================
    // 1. 常量与配置 (Constants & Config)
    // ==========================================
    const I18N = {
        zh: {
            title: "截图拼接助手",
            cap: "捕捉当前帧",
            gen: "生成长图",
            clr: "清空列表",
            set: "设置 / 批量",
            mode: "拼接模式",
            mSeq: "长图 (平行)",
            mSub: "字幕 (重叠)",
            pct: "重叠高度 (%)",
            fname: "文件名模板",
            batch: "批量初始化 (格式: 开始-结束;张数)",
            batchPh: "例: 2:00-5:00;10",
            batchBtn: "开始批量截图",
            batching: "正在批量处理: $current / $total",
            noVid: "未检测到视频",
            invFmt: "格式错误!正确示例: 1:30-2:00;5",
            cors: "CORS 跨域限制,无法读取画面",
            done: "批量完成"
        },
        en: {
            title: "Stitcher Pro",
            cap: "Capture Frame",
            gen: "Generate",
            clr: "Clear",
            set: "Settings / Batch",
            mode: "Stitch Mode",
            mSeq: "Parallel",
            mSub: "Overlap",
            pct: "Overlap (%)",
            fname: "Filename Template",
            batch: "Batch Init (Start-End;Count)",
            batchPh: "Ex: 2:00-5:00;10",
            batchBtn: "Start Batch",
            batching: "Processing: $current / $total",
            noVid: "No Video Found",
            invFmt: "Invalid Format! Ex: 1:30-2:00;5",
            cors: "CORS Restricted",
            done: "Batch Done"
        }
    };

    const T = navigator.language.startsWith('zh') ? I18N.zh : I18N.en;

    const Store = {
        get: (key, def) => GM_getValue(key, def),
        set: (key, val) => GM_setValue(key, val)
    };

    const State = {
        config: {
            selector: Store.get('selector', 'video'),
            mode: Store.get('mode', 'overlap'),
            overlap: Store.get('overlap', 20),
            fileName: Store.get('fileName', 'Capture_$title_$time'),
            batchStr: Store.get('batchStr', ''),
            isCollapsed: false
        },
        frames: [],
        videoEl: null,
        isBatching: false
    };

    // ==========================================
    // 2. 核心逻辑层 (Core Logic)
    // ==========================================
    const Core = {
        findVideo: () => {
            let v = document.querySelector(State.config.selector);
            if (!v && State.config.selector !== 'video') v = document.querySelector('video');
            State.videoEl = v;
            return v;
        },

        // 解析时间字符串 (MM:SS -> Seconds)
        parseTime: (str) => {
            if (!str) return 0;
            const p = str.toString().split(':');
            return p.length === 2 ? parseInt(p[0])*60 + parseFloat(p[1]) : parseFloat(p[0]);
        },

        // 计算批量时间点
        calcBatchTimes: (inputStr) => {
            // Regex: Time-Time;Count (e.g., 2:00-5:00;10 or 120-300;10)
            const regex = /^([\d:.]+)-([\d:.]+);(\d+)$/;
            const match = inputStr.trim().match(regex);
            if (!match) return null;

            const start = Core.parseTime(match[1]);
            const end = Core.parseTime(match[2]);
            const count = parseInt(match[3]);

            if (count <= 0 || end <= start) return null;

            const duration = end - start;
            const segment = duration / count;
            const times = [];

            // 取每个分段的中间时刻
            for (let i = 0; i < count; i++) {
                const t = start + (segment * i) + (segment / 2);
                times.push(t);
            }
            return times;
        },

        // 等待视频跳转完成 (Promise wrapper)
        waitSeek: (video, time) => {
            return new Promise((resolve) => {
                const onSeeked = () => {
                    video.removeEventListener('seeked', onSeeked);
                    // 额外延迟,确保画面渲染完成(防止黑屏)
                    setTimeout(resolve, 250); 
                };
                // 设置超时防止卡死
                setTimeout(() => { 
                    video.removeEventListener('seeked', onSeeked); 
                    resolve(); 
                }, 3000); 

                video.addEventListener('seeked', onSeeked);
                video.currentTime = time;
            });
        },

        capture: (video) => {
            try { video.setAttribute('crossOrigin', 'anonymous'); } catch(e){}
            const cvs = document.createElement('canvas');
            cvs.width = video.videoWidth;
            cvs.height = video.videoHeight;
            cvs.getContext('2d').drawImage(video, 0, 0);
            return {
                id: Date.now() + Math.random(),
                canvas: cvs,
                time: video.currentTime,
                thumb: cvs.toDataURL('image/jpeg', 0.15)
            };
        },

        stitch: (frames, config) => {
            if (!frames.length) return null;
            const w = frames[0].canvas.width;
            let totalH = 0;
            if (config.mode === 'parallel') {
                frames.forEach(f => totalH += f.canvas.height);
            } else {
                totalH = frames[0].canvas.height;
                const sliceH = frames[0].canvas.height * (config.overlap / 100);
                if (frames.length > 1) totalH += (frames.length - 1) * sliceH;
            }
            const resCvs = document.createElement('canvas');
            resCvs.width = w; resCvs.height = totalH;
            const ctx = resCvs.getContext('2d');
            let currY = 0;
            frames.forEach((f, i) => {
                const h = f.canvas.height;
                if (config.mode === 'parallel') {
                    ctx.drawImage(f.canvas, 0, currY);
                    currY += h;
                } else {
                    if (i === 0) { ctx.drawImage(f.canvas, 0, 0); currY += h; }
                    else {
                        const sH = h * (config.overlap / 100);
                        ctx.drawImage(f.canvas, 0, h - sH, w, sH, 0, currY, width, sH);
                        currY += sH;
                    }
                }
            });
            return resCvs;
        },

        formatName: (template) => {
            const now = new Date();
            const timeStr = `${now.getFullYear()}${now.getMonth()+1}${now.getDate()}_${now.getHours()}${now.getMinutes()}`;
            const safeTitle = document.title.replace(/[<>:"/\\|?*]/g, '').trim().substring(0, 50);
            return template.replace('$title', safeTitle).replace('$domain', location.hostname)
                           .replace('$date', Date.now()).replace('$time', timeStr) + '.png';
        }
    };

    // ==========================================
    // 3. UI 视图层 (DOM)
    // ==========================================
    const Dom = {
        el: (tag, attrs = {}, children = []) => {
            const d = document.createElement(tag);
            for (let k in attrs) {
                if (k === 'style') Object.assign(d.style, attrs[k]);
                else if (k.startsWith('on')) d[k] = attrs[k];
                else d[k] = attrs[k];
            }
            children.forEach(c => d.appendChild(typeof c !== 'object' ? document.createTextNode(c) : c));
            return d;
        },
        fmtTime: s => {
            const m = Math.floor(s/60), sec = Math.floor(s%60);
            return `${m.toString().padStart(2,'0')}:${sec.toString().padStart(2,'0')}`;
        },
        download: (blob, name) => {
            const u = URL.createObjectURL(blob);
            const a = Dom.el('a', {href:u, download:name});
            document.body.appendChild(a); a.click(); a.remove();
            setTimeout(()=>URL.revokeObjectURL(u),1000);
        },
        injectCss: () => {
            if (document.getElementById('vss-css')) return;
            const css = `
                #vss-app { position: fixed; bottom: 20px; left: 20px; width: 270px; background: #1b1b1b; color: #ddd; z-index: 9999999; font: 12px sans-serif; border-radius: 6px; box-shadow: 0 4px 15px rgba(0,0,0,0.7); border: 1px solid #333; }
                .vss-hd { padding: 8px 10px; background: #2a2a2a; border-bottom: 1px solid #333; display: flex; justify-content: space-between; border-radius: 6px 6px 0 0; cursor: move; font-weight: bold; }
                .vss-bd { padding: 10px; }
                .vss-btn { width: 100%; padding: 7px; border: none; border-radius: 3px; cursor: pointer; color: #fff; margin-bottom: 5px; background: #333; transition: 0.2s; }
                .vss-btn:hover { background: #444; } .vss-btn:disabled { opacity: 0.5; cursor: not-allowed; }
                .vss-pri { background: #007acc; } .vss-pri:hover { background: #0062a3; }
                .vss-suc { background: #2ea043; } .vss-suc:hover { background: #238636; }
                .vss-dan { background: #da3633; } .vss-dan:hover { background: #b62324; }
                .vss-list { max-height: 200px; overflow-y: auto; background: #111; border: 1px solid #333; margin-bottom: 8px; border-radius: 3px; }
                .vss-item { display: flex; padding: 4px; border-bottom: 1px solid #222; align-items: center; }
                .vss-th { width: 60px; height: 34px; object-fit: cover; background: #000; margin-right: 5px; }
                .vss-meta { flex: 1; display: flex; flex-direction: column; gap: 2px; }
                .vss-row { display: flex; gap: 5px; }
                .vss-inp { width: 100%; background: #222; border: 1px solid #444; color: #fff; padding: 4px; border-radius: 2px; box-sizing: border-box; }
                .vss-tm { background: #222; border: 1px solid #444; color: #aaa; width: 100%; font-size: 10px; text-align: center; }
                .vss-ic { padding: 1px 5px; font-size: 10px; cursor: pointer; border: none; border-radius: 2px; background: #444; color: #fff; }
                .vss-set { margin-top: 8px; padding-top: 8px; border-top: 1px solid #333; display: none; }
                .vss-field { margin-bottom: 8px; }
                .vss-lbl { display: block; color: #888; font-size: 10px; margin-bottom: 3px; }
                .vss-overlay { position: absolute; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.8); display: flex; align-items: center; justify-content: center; z-index: 10; border-radius: 6px; }
            `;
            document.head.appendChild(Dom.el('style', {id:'vss-css'}, [css]));
        }
    };

    // ==========================================
    // 4. 应用控制器 (App Controller)
    // ==========================================
    const App = {
        root: null,
        els: {},

        init: () => {
            if (document.getElementById('vss-app')) return;
            Dom.injectCss();
            App.render();
            App.bindDrag();
        },

        // --- 动作 ---

        actionCapture: (replaceIdx = -1) => {
            const v = Core.findVideo();
            if (!v) return alert(T.noVid);
            const f = Core.capture(v);
            if (replaceIdx >= 0) State.frames[replaceIdx] = f;
            else State.frames.push(f);
            App.refreshList();
        },

        // 核心:批量处理
        actionBatch: async () => {
            const v = Core.findVideo();
            if (!v) return alert(T.noVid);

            const times = Core.calcBatchTimes(State.config.batchStr);
            if (!times) return alert(T.invFmt);

            // 锁定 UI
            State.isBatching = true;
            App.toggleOverlay(true, T.batching.replace('$current', 0).replace('$total', times.length));

            const originalTime = v.currentTime;
            const wasPaused = v.paused;
            v.pause(); // 强制暂停

            try {
                for (let i = 0; i < times.length; i++) {
                    App.toggleOverlay(true, T.batching.replace('$current', i + 1).replace('$total', times.length));
                    await Core.waitSeek(v, times[i]); // 等待跳转
                    const f = Core.capture(v);
                    State.frames.push(f);
                    App.refreshList(); // 实时更新列表
                }
            } catch (e) {
                console.error(e);
            } finally {
                // 恢复状态
                v.currentTime = originalTime;
                if (!wasPaused) v.play(); // 恢复播放
                State.isBatching = false;
                App.toggleOverlay(false);
                alert(T.done);
            }
        },

        actionGenerate: () => {
            if (!State.frames.length) return;
            try {
                const cvs = Core.stitch(State.frames, State.config);
                cvs.toBlob(b => Dom.download(b, Core.formatName(State.config.fileName)));
            } catch(e) { alert(T.cors); }
        },

        // --- 渲染 ---

        render: () => {
            const h = Dom.el('div', { className:'vss-hd', ondblclick: App.toggleCollapse }, [
                Dom.el('span', {}, [T.title]),
                Dom.el('div', {}, [
                    Dom.el('span', {onclick: App.toggleCollapse, style:{cursor:'pointer', padding:'0 5px'}}, ['_']),
                    Dom.el('span', {onclick:()=>App.root.remove(), style:{cursor:'pointer'}}, ['✕'])
                ])
            ]);

            App.els.list = Dom.el('div', { className: 'vss-list' });
            App.els.count = Dom.el('span', {}, ['0']);
            App.els.overlay = Dom.el('div', { className: 'vss-overlay', style:{display:'none'} }, ['Processing...']);

            // Settings Fields
            const mkInp = (lbl, key, type='text', ph='') => {
                const inp = Dom.el('input', {className:'vss-inp', type:type, value:State.config[key], placeholder:ph});
                inp.onchange = e => { State.config[key]=e.target.value; Store.set(key, e.target.value); };
                return Dom.el('div', {className:'vss-field'}, [Dom.el('span', {className:'vss-lbl'}, [lbl]), inp]);
            };

            const batchPanel = Dom.el('div', {className:'vss-field', style:{borderTop:'1px dashed #444', paddingTop:'8px'}}, [
                Dom.el('span', {className:'vss-lbl', style:{color:'#4da6ff'}}, [T.batch]),
                Dom.el('input', {
                    className:'vss-inp', type:'text', placeholder:T.batchPh, value:State.config.batchStr,
                    onchange: e => { State.config.batchStr=e.target.value; Store.set('batchStr', e.target.value); }
                }),
                Dom.el('button', {className:'vss-btn vss-pri', style:{marginTop:'5px', fontSize:'11px'}, onclick: App.actionBatch}, [T.batchBtn])
            ]);

            const setPanel = Dom.el('div', {className:'vss-set', id:'vss-set'}, [
                Dom.el('div', {className:'vss-field'}, [
                    Dom.el('span', {className:'vss-lbl'}, [T.mode]),
                    Dom.el('label', {style:{marginRight:'10px'}}, [
                        Dom.el('input', {type:'radio', name:'vm', checked:State.config.mode==='overlap', onchange:()=>{State.config.mode='overlap';Store.set('mode','overlap');}}), T.mSub
                    ]),
                    Dom.el('label', {}, [
                        Dom.el('input', {type:'radio', name:'vm', checked:State.config.mode==='parallel', onchange:()=>{State.config.mode='parallel';Store.set('mode','parallel');}}), T.mSeq
                    ])
                ]),
                mkInp(T.fname, 'fileName', 'text', 'Capture_$date'),
                mkInp(T.pct, 'overlap', 'number'),
                mkInp(T.sel, 'selector', 'text'),
                batchPanel
            ]);

            const btnSet = Dom.el('button', {className:'vss-btn', onclick:()=>{
                const s = document.getElementById('vss-set'); s.style.display = s.style.display==='block'?'none':'block';
            }}, [T.set]);

            App.els.body = Dom.el('div', { className:'vss-bd' }, [
                App.els.overlay,
                Dom.el('button', {className:'vss-btn vss-pri', onclick:()=>App.actionCapture()}, [T.cap]),
                Dom.el('div', {style:{fontSize:'10px', color:'#888', marginBottom:'3px'}}, ['Count: ', App.els.count]),
                App.els.list,
                Dom.el('div', {className:'vss-row'}, [
                    Dom.el('button', {className:'vss-btn vss-suc', onclick:App.actionGenerate}, [T.gen]),
                    Dom.el('button', {className:'vss-btn vss-dan', onclick:()=>{if(confirm('?')){State.frames=[];App.refreshList();}}}, [T.clr])
                ]),
                btnSet,
                setPanel
            ]);

            App.root = Dom.el('div', { id:'vss-app' }, [h, App.els.body]);
            document.body.appendChild(App.root);
        },

        refreshList: () => {
            App.els.list.textContent = '';
            App.els.count.textContent = State.frames.length;
            State.frames.forEach((f, i) => {
                const img = Dom.el('img', {className:'vss-th', src:f.thumb});
                const tm = Dom.el('input', {className:'vss-tm', value:Dom.fmtTime(f.time)});
                tm.onkeydown = e => {
                    if(e.key==='Enter') {
                        const t = Core.parseTime(e.target.value);
                        if(State.videoEl && isFinite(t)) State.videoEl.currentTime = t;
                    }
                };
                const row = Dom.el('div', {className:'vss-item'}, [
                    img,
                    Dom.el('div', {className:'vss-meta'}, [
                        tm,
                        Dom.el('div', {style:{display:'flex', gap:'2px', justifyContent:'flex-end'}}, [
                            Dom.el('button', {className:'vss-ic vss-pri', onclick:()=>App.actionCapture(i)}, ['📷']),
                            Dom.el('button', {className:'vss-ic', onclick:()=>{
                                if(i>0) {[State.frames[i],State.frames[i-1]]=[State.frames[i-1],State.frames[i]];App.refreshList();}
                            }}, ['↑']),
                            Dom.el('button', {className:'vss-ic vss-dan', onclick:()=>{State.frames.splice(i,1);App.refreshList();}}, ['✕'])
                        ])
                    ])
                ]);
                App.els.list.appendChild(row);
            });
            App.els.list.scrollTop = App.els.list.scrollHeight;
        },

        toggleOverlay: (show, text) => {
            App.els.overlay.style.display = show ? 'flex' : 'none';
            if(text) App.els.overlay.textContent = text;
        },
        toggleCollapse: () => {
            State.config.isCollapsed = !State.config.isCollapsed;
            App.els.body.style.display = State.config.isCollapsed ? 'none' : 'block';
        },
        bindDrag: () => {
            let isD = false, dx, dy;
            const h = App.root.querySelector('.vss-hd');
            h.onmousedown = e => { isD=true; dx=e.clientX-App.root.offsetLeft; dy=e.clientY-App.root.offsetTop; };
            document.onmousemove = e => { if(isD){App.root.style.left=(e.clientX-dx)+'px';App.root.style.top=(e.clientY-dy)+'px';}};
            document.onmouseup = () => isD=false;
        }
    };

    const Main = () => {
        const obs = new MutationObserver(() => {
            if(document.querySelector('video') || document.querySelector(State.config.selector)) {
                Core.findVideo(); App.init(); obs.disconnect();
            }
        });
        obs.observe(document.body, {childList:true, subtree:true});
    };
    setTimeout(Main, 1000);
})();