// ==UserScript== // @name 视频批量截图 // @name:en Video Frame Screenshot // @description 根据视频批量截图, 生成预览图. // @version 1.1.5 // @author Yiero // @namespace https://github.com/AliubYiero/TamperMonkeyScripts // @require https://cdn.bootcdn.net/ajax/libs/jszip/3.9.1/jszip.min.js // @grant GM_getValue // @grant GM_registerMenuCommand // @grant GM_notification // @match https://*/* // @license GPL-3 // ==/UserScript== /* ==UserConfig== 配置项: duration: title: "截图间隔(s)" description: "每隔多少秒, 截一次图" type: number default: 600 min: 1 showTime: title: "截图显示时间戳(左上角)" description: "显示时间戳" type: checkbox default: true selectors: title: "特定网站容器指定" description: "默认情况下, 是直接通过 video 获取, 但是当页面中存在多个视频容器的时候, 第一个 video 可能不是需要截图的容器, 这时候可以进行指定 selector\n\n格式为: <网站域名> -> \n示例: www.bilibili.com -> video" type: textarea default: 'www.douyin.com -> [data-e2e="feed-active-video"] video' ==/UserConfig== */ const drawImage = (videoNode, options = { showTime: true }) => { const canvas = document.createElement("canvas"); const ctx = canvas.getContext("2d"); canvas.width = videoNode.videoWidth; canvas.height = videoNode.videoHeight; canvas.dataset["targetId"] = videoNode.id || ""; canvas.dataset["targetClass"] = videoNode.className || ""; canvas.dataset["duration"] = Math.floor(videoNode.currentTime).toString(); canvas.dataset["showTime"] = options.showTime.toString(); ctx.drawImage(videoNode, 0, 0, videoNode.videoWidth, videoNode.videoHeight); if (options.showTime) { const currentTimeDate = new Date(videoNode.currentTime * 1e3); const parStart = (str) => str.toString().padStart(2, "0"); const currentTimeString = `${parStart(currentTimeDate.getUTCHours())}:${parStart(currentTimeDate.getUTCMinutes())}:${parStart(currentTimeDate.getUTCSeconds())}`; ctx.font = "bold 30px Arial"; ctx.fillStyle = "white"; ctx.fillText(currentTimeString, 10, 40); ctx.fillStyle = "black"; ctx.strokeText(currentTimeString, 10, 40); } return canvas; }; const sleep = (ms) => { return new Promise((resolve) => { setTimeout(resolve, ms); }); }; function sanitizeFileName(fileName) { const illegalChars = /[<>:"/\\|?*]/g; return fileName.replace(illegalChars, "_"); } const canvasToBlob = async (canvas) => { return await new Promise((resolve) => { canvas.toBlob(resolve, "image/png"); }); }; function downloadFile(blob, filename) { const downloadLink = URL.createObjectURL(blob); const node = document.createElement("a"); node.download = filename; node.href = downloadLink; node.click(); URL.revokeObjectURL(downloadLink); } const notify = (title, text) => { GM_notification({ title: `[Video Frame Screenshot] ${title}`, text, image: "" }); }; const zipFile = async (canvasList) => { console.info(`\u5DF2\u5B8C\u6210\u6240\u6709\u622A\u56FE, \u6B63\u5728\u751F\u6210\u56FE\u7247...`, canvasList); notify("\u622A\u56FE\u5B8C\u6210", `\u5DF2\u5B8C\u6210\u6240\u6709\u622A\u56FE, \u73B0\u5728\u53EF\u4EE5\u89C2\u770B\u89C6\u9891\u4E86. \u6B63\u5728\u5C06\u622A\u56FE\u4FDD\u5B58\u4E3A\u56FE\u7247, \u8BF7\u8010\u5FC3\u7B49\u5F85...`); const zip = new JSZip(); const title = sanitizeFileName(document.title); const zipFolder = zip.folder(title); await Promise.all(canvasList.map(async (canvas) => { const imageBlob = await canvasToBlob(canvas); zipFolder.file(`screenshot_${canvas.dataset.duration}.png`, imageBlob, { binary: true }); console.info(`\u751F\u6210\u56FE\u7247_${canvas.dataset.duration}...`, imageBlob, canvas); })); console.info(`\u6B63\u5728\u538B\u7F29\u6587\u4EF6_${title}...`, zip); const blob = await zip.generateAsync({ type: "blob" }); console.info(`\u4E0B\u8F7D\u6587\u4EF6...`, blob); notify(`\u4E0B\u8F7D\u5B8C\u6210`, `\u56FE\u7247\u4FDD\u5B58\u5B8C\u6BD5. \u538B\u7F29\u5305 [${title}.zip] \u5DF2\u4E0B\u8F7D\u5230\u672C\u5730. `); downloadFile(blob, `${title}.zip`); }; const batchScreenshot = async (selectSelector = "video") => { const delay = GM_getValue("\u914D\u7F6E\u9879.duration", 600); const showTime = GM_getValue("\u914D\u7F6E\u9879.showTime", true); const videoNode = document.querySelector(selectSelector); console.info("\u5F00\u59CB\u622A\u56FE...", videoNode); if (!videoNode) { notify("\u622A\u56FE\u5931\u8D25", `\u5728\u5F53\u524D\u9875\u9762\u641C\u7D22\u4E0D\u5230\u89C6\u9891\u5BB9\u5668 \u6307\u5B9A\u9009\u62E9\u5668: ${selectSelector}`); return; } notify("\u622A\u56FE\u5F00\u59CB", "\u8BF7\u4E0D\u8981\u64CD\u4F5C(\u64AD\u653E/\u6682\u505C)\u89C6\u9891\u76F4\u81F3\u622A\u56FE\u5B8C\u6210, \u9632\u6B62\u51FA\u73B0\u9519\u8BEF.\n\u622A\u56FE\u65F6\u957F\u53D6\u51B3\u4E8E\u89C6\u9891\u7F13\u5B58\u4EE5\u53CA\u8F7D\u5165\u901F\u5EA6, \u8BF7\u8010\u5FC3\u7B49\u5F85..."); videoNode.pause(); const delayMaxCounter = Math.floor(videoNode.duration / delay); const jumpTimeList = []; for (let i = 0; i <= delayMaxCounter; i++) { jumpTimeList.push(i * delay); } const lastSecond = Math.floor(videoNode.duration); jumpTimeList[jumpTimeList.length - 1] !== lastSecond && jumpTimeList.push(lastSecond); const canvasList = []; for (const currentTime of jumpTimeList) { console.info(`\u6B63\u5728\u622A\u56FE_${currentTime}...`); videoNode.currentTime = currentTime; let dataLoaded = false; videoNode.ontimeupdate = async () => { const canvas = drawImage(videoNode, { showTime }); await sleep(100); canvasList.push(canvas); dataLoaded = true; }; while (!dataLoaded) { await sleep(100); } } await zipFile(canvasList); }; const getVideoSelector = () => { const selectorsContent = GM_getValue("\u914D\u7F6E\u9879.selectors", 'www.douyin.com -> [data-e2e="feed-active-video"] video'); const selectorList = selectorsContent.split(/\n/).map((line) => line.split(/\s*->\s*/)); const selectSelectorList = selectorList.find(([host]) => { !host.startsWith("http") && (host = `https://${host}`); return new URL(host).host === window.location.host; }) || ["", "video"]; return selectSelectorList[1] || "video"; }; (() => { GM_registerMenuCommand("\u5F00\u59CB\u622A\u56FE", async () => { await batchScreenshot(getVideoSelector()); }); })();