视频批量截图
// ==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格式为: <网站域名> -> <selector>\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());
});
})();