Universal Video Screenshot & Stitcher (Batch & Custom)

Capture, batch capture (by time range), stitch, and save. Modular architecture.

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 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);
})();