前几天看到一个视频,我觉得其中一段很有意思,就打算分享给群友,链接发出去附上秒数,结果没多少人看,这时候我就在想手头上要有个 gif 录制工具就好了,毕竟搬屎的时候发动图,群友不得不看。
本着一颗折腾的心,我打算整个脚本来实现,这功能做起来我心里有点数,但不确定相关 API 能不能支持我这样做,理论上很简单,就是在视频流中得到一系列的帧图像数据,然后用这些图像构建出一个 gif 文件。
如果要调整分辨率的话,可能还需要把图像画到 canvas 里面去做处理,再写到 gif。
这里我不确定的是:
- H5 API 中的 video,能不能让我拿到当前帧,或者指定某一帧的图像,毕竟可能会有跨域问题,比如像把没有跨域的图片写到 canvas 后,无法
getImageData
那样 - 如果能拿到,那么图像数据的格式不确定,能不能直接放进 canvas 也不好说,毕竟我没做过
想到这些不确定的地方,又开始懒起来了,怕做到一半发现做不下去了,浪费时间,干脆用 DeepSeek 先做吧,之后自己再改改。
回应
DeepSeek 给出的方案是这样的,他似乎没有理解 UserScript 的意思,不过问题不大。
a. 注入内容脚本获取视频元素
// content-script.js
const videoElements = document.querySelectorAll('video');
// 向插件后台发送视频信息
chrome.runtime.sendMessage({ type: 'VIDEO_FOUND', videos: Array.from(videoElements).map(v => v.src) });
b. 用户界面(UI)
- 在页面右上角添加浮动控件(Shadow DOM 避免样式污染)。
- 用户输入或拖动时间轴选择起止时间(需监听视频的
timeupdate
事件)。
c. 捕获视频帧
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
// 逐帧捕获(需处理性能优化)
video.currentTime = startTime;
await new Promise(r => video.onseeked = r);
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
d. 生成 GIF 推荐使用 gif.js 或 ffmpeg.wasm:
const gif = new GIF({
workers: 2,
quality: 10,
width: canvas.width,
height: canvas.height
});
gif.addFrame(imageData, { delay: 100 }); // 每帧延迟
gif.on('finished', blob => {
const url = URL.createObjectURL(blob);
// 触发下载
chrome.downloads.download({ url, filename: 'clip.gif' });
});
gif.render();
跨域限制(CORS)
- 需在
manifest.json
中声明权限:"permissions": ["activeTab", "downloads", "scripting", "storage"]
- 对跨域视频可能需要用户手动启用权限(如点击插件图标授权)。
- 需在
DRM 保护视频
- 无法处理受保护的流媒体(如 Netflix、YouTube Premium),需提示用户不支持。
性能优化
- 降低 GIF 分辨率(如 320x180)。
- 限制帧率(如 10 FPS)。
- 使用 Web Worker 避免主线程阻塞。
兼容性
- 仅支持
<video>
标签,部分网站的自定义播放器需额外适配(如通过截图模拟)。
- 仅支持
- 用户隐私: 明确声明插件不会上传数据。
- 性能提示: 长时间视频需警告用户可能卡顿。
- 错误处理: 捕获
SecurityError
(跨域)和NotAllowedError
(DRM)。
代码与调整
我也去查了一下 drawImage
,第一个参数可以是 video element,也不用特别指定某一帧,把 currentTime
设置好,canvas 就能自动通过 element 去拿到当前帧,以下是具体实现:
// ==UserScript==
// @name Video to GIF Converter
// @namespace http://tampermonkey.net/
// @version 0.1
// @description Convert video clips to GIF directly in the browser
// @author Your Name
// @match *://*/*
// @grant GM_addStyle
// @grant GM_getResourceURL
// @grant GM_download
// @require https://cdnjs.cloudflare.com/ajax/libs/gif.js/0.2.0/gif.js
// @resource gifWorker https://cdnjs.cloudflare.com/ajax/libs/gif.js/0.2.0/gif.worker.js
// @run-at document-end
// ==/UserScript==
(function() {
'use strict';
// 全局样式
GM_addStyle(`
.gif-converter-wrapper {
position: fixed;
bottom: 20px;
right: 20px;
z-index: 9999;
font-family: Arial, sans-serif;
}
#gifTriggerBtn {
padding: 10px 20px;
background: #2196F3;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
.config-panel {
background: white;
border: 1px solid #ccc;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
width: 300px;
margin-top: 10px;
}
.config-row {
margin: 10px 0;
}
label {
display: block;
margin-bottom: 5px;
}
input[type="number"] {
width: 100%;
padding: 5px;
}
`);
// 主界面
const createUI = () => {
const wrapper = document.createElement('div');
wrapper.className = 'gif-converter-wrapper';
const triggerBtn = document.createElement('button');
triggerBtn.id = 'gifTriggerBtn';
triggerBtn.textContent = '🎬 创建 GIF';
const panel = document.createElement('div');
panel.className = 'config-panel';
panel.style.display = 'none';
wrapper.append(triggerBtn, panel);
document.body.appendChild(wrapper);
return { triggerBtn, panel };
};
// 配置表单
const createConfigForm = (video, panel) => {
panel.innerHTML = `
<div class="config-row">
<label>开始时间(秒):</label>
<input type="number" id="startTime" step="0.1" value="${video.currentTime}" min="0" max="${video.duration}">
</div>
<div class="config-row">
<label>GIF 长度(秒):</label>
<input type="number" id="gifDuration" step="0.1" value="5" min="0" max="${video.duration - video.currentTime}">
</div>
<div class="config-row">
<label>FPS (10-30):</label>
<input type="number" id="fps" value="15" min="10" max="30">
</div>
<div class="config-row">
<label>宽度 (像素):</label>
<input type="number" id="width" value="${video.clientWidth}" min="1" max="${video.videoWidth}">
</div>
<div class="config-row">
<label>高度 (像素):</label>
<input type="number" id="height" value="${video.clientHeight}" min="1" max="${video.videoHeight}">
</div>
<button id="generateBtn" style="margin-top:15px;width:100%">生成 GIF</button>
`;
};
// 生成 GIF
const generateGIF = async (video, config) => {
console.debug(config)
const gif = new GIF({
workers: 2,
quality: 10,
width: config.width,
height: config.height,
workerScript: GM_getResourceURL('gifWorker')
});
const frameDelay = 1000 / config.fps;
let currentTime = parseFloat(config.startTime);
let endTime = currentTime + parseFloat(config.gifDuration)
const canvas = document.createElement('canvas');
canvas.width = config.width;
canvas.height = config.height;
const ctx = canvas.getContext('2d');
ctx.imageSmoothingEnabled = true; // 启用抗锯齿
ctx.imageSmoothingQuality = 'high'; // 设置插值质量为高
while (currentTime <= endTime) {
video.currentTime = currentTime;
await new Promise(resolve => {
video.onseeked = () => {
ctx.drawImage(video, 0, 0, config.width, config.height);
const imageData = ctx.getImageData(0, 0, config.width, config.height);
gif.addFrame(imageData, { delay: frameDelay });
resolve();
};
});
currentTime += (1 / config.fps);
}
gif.on('finished', blob => {
const url = URL.createObjectURL(blob);
GM_download(url, `video-clip-${Date.now()}.gif`);
});
gif.render();
};
// 主逻辑
const main = () => {
const { triggerBtn, panel } = createUI();
triggerBtn.addEventListener('click', () => {
const videos = document.querySelectorAll('video');
if (videos.length === 0) {
console.debug('没找到视频');
return;
}
const video = videos[0]; // 默认选择第一个视频
panel.style.display = panel.style.display === 'none' ? 'block' : 'none';
createConfigForm(video, panel);
});
panel.addEventListener('click', async (e) => {
if (e.target.id === 'generateBtn') {
const videos = document.querySelectorAll('video');
if (videos.length === 0) {
console.debug('No video elements found!');
return;
}
const video = videos[0]; // 默认选择第一个视频
const config = {
startTime: parseFloat(panel.querySelector('#startTime').value),
gifDuration: parseFloat(panel.querySelector('#gifDuration').value),
fps: parseInt(panel.querySelector('#fps').value),
width: parseInt(panel.querySelector('#width').value),
height: parseInt(panel.querySelector('#height').value),
};
panel.style.display = 'none';
await generateGIF(video, config);
}
});
};
// 等待页面加载完成后初始化
window.addEventListener('load', main, false);
})();
这部分代码主要是 DeepSeek 完成的,刚开始的版本在 B 站不适用,后面我稍微改了一下,因为 B 站的 video element 不是直接写在 html 中,而是通过脚本去初始化的,所以在 document-end
的时候不一定能找到。
接着是创建 canvas,DeepSeek 直接在每一次 while 循环中都创建了一个 canvas,我一开始还觉得他这样做效率很低,于是把 cavans 的创建提出去了,但这样修改出问题了。
问题的表现是 gif 变成了静态图片,只有最后一帧的图像,后面我去查了一下 gif.js 的源码,发现他每一帧都是一个对象,对象里只保存引用,而不是具体的图像数据,只有在 render
的时候才去收集具体图像,所以提出 canvas 后也导致了所有帧对象指向的都是同一个 canvas,在 render
的那一刻,canvas 的图案就是最后一帧的。
我不知道这一段是 DeepSeek 误打误撞写出来的,还是他真考虑到了 gif.js 存在的问题,有点小震惊。