1647 字
8 分钟
用 DeepSeek 协助开发 UserScript
2025-02-21

前几天看到一个视频,我觉得其中一段很有意思,就打算分享给群友,链接发出去附上秒数,结果没多少人看,这时候我就在想手头上要有个 gif 录制工具就好了,毕竟搬屎的时候发动图,群友不得不看。

本着一颗折腾的心,我打算整个脚本来实现,这功能做起来我心里有点数,但不确定相关 API 能不能支持我这样做,理论上很简单,就是在视频流中得到一系列的帧图像数据,然后用这些图像构建出一个 gif 文件。

如果要调整分辨率的话,可能还需要把图像画到 canvas 里面去做处理,再写到 gif。

这里我不确定的是:

  1. H5 API 中的 video,能不能让我拿到当前帧,或者指定某一帧的图像,毕竟可能会有跨域问题,比如像把没有跨域的图片写到 canvas 后,无法 getImageData 那样
  2. 如果能拿到,那么图像数据的格式不确定,能不能直接放进 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.jsffmpeg.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();

  1. 跨域限制(CORS)

    • 需在 manifest.json 中声明权限:
      "permissions": ["activeTab", "downloads", "scripting", "storage"]
    • 对跨域视频可能需要用户手动启用权限(如点击插件图标授权)。
  2. DRM 保护视频

    • 无法处理受保护的流媒体(如 Netflix、YouTube Premium),需提示用户不支持。
  3. 性能优化

    • 降低 GIF 分辨率(如 320x180)。
    • 限制帧率(如 10 FPS)。
    • 使用 Web Worker 避免主线程阻塞。
  4. 兼容性

    • 仅支持 <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 存在的问题,有点小震惊。

用 DeepSeek 协助开发 UserScript
https://blog.erio.work/posts/用deepseek协助开发userscript/
作者
Dupfioire
发布于
2025-02-21
许可协议
CC BY-NC-SA 4.0