Greasy Fork

来自缓存

Greasy Fork is available in English.

通用视频截图拼接工具

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

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

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

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴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);
})();