跳到正文

浏览器复制 GIF 为什么总会丢动画:剪贴板格式和插件方案

最近又遇到一个很古早的问题:在新版 Edge 里右键复制一张 GIF,粘到聊天工具里,动画还是经常变成第一帧。

浏览器能播放 GIF,聊天工具也常常能发送 GIF,复制和粘贴之间却还隔着系统剪贴板。右键「复制图片」时,浏览器往剪贴板里写的未必是原始 GIF 文件,目标应用也未必会按网页里的 img src 去重新下载原图。动画能否保留,要看中间传了什么、粘贴方又读了什么。

同一张 GIF 有几种复制路径

同一句「复制 GIF」,背后可能是几条完全不同的路径:

动画能否保留,取决于中间传了什么

右键「复制图片」最容易丢动画。浏览器通常把当前图片写成位图或 PNG 一类的静态图片格式,GIF 的多帧信息在这一步就没了。

选中网页区域再复制,有时反而能动。这时起作用的是剪贴板里的 text/html,里面保留了 <img src="...">。目标应用如果愿意解析 HTML,就能拿到图片 URL,再自己下载一遍;浏览器并没有把 GIF 文件本身写进去。

下载 GIF 文件、从资源管理器复制文件、或者直接把文件拖到聊天窗口,通常最稳。此时目标应用拿到的是文件路径或文件对象,动画仍然在原始文件里。

剪贴板不是一个图片槽

系统剪贴板不是一个只能放图片的槽。一次复制可以同时放很多格式,粘贴方会挑自己能读、也愿意读的那一种。

在 Windows 里,普通位图、DIB、文件列表是不同格式。微软的 Shell Clipboard 文档里把 CF_HDROP 定义为传输一组既有文件位置的格式,里面放的是一个双 null 结尾的完整文件路径数组。这个格式很适合表达「我复制了一个文件」,但它不是普通网页 Clipboard API 随手能写进去的东西。

macOS 也类似。Pasteboard 里可以有 public.tiff、public.png、public.file-url 等不同类型;GIF 对应 UTType.gif,标识符为 com.compuserve.gif。目标应用是否保留动画,取决于它读到的是静态图片数据、GIF 数据,还是一个可以读取的文件 URL。

这也解释了一个老现象:早年 IE 能把网页缓存里的 GIF 文件路径塞到剪贴板里,QQ 之类的应用拿到文件路径后就能发送会动的图;Chrome / Firefox 只写静态图片时,粘贴结果就剩第一帧。

新版 Edge 为什么仍然没修好

排查时,Chromium 的 Async Clipboard: GIF support 仍是开放的 feature request。下面的支持探针与仓库问题都属于这次记录;浏览器或上游更新后,应重新测试,而不是把当时状态当成永久限制。问题也不只是「把 image/gif 放进白名单」,还要考虑:

  • 渲染进程和浏览器进程之间如何安全传输 GIF 容器。
  • 写入系统剪贴板前如何做解码、编码或安全清洗。
  • Windows、macOS、Linux 各自应该写什么系统格式。
  • Blink 里 selection copy、image copy、Clipboard API 写入是不是走同一套行为。
  • 目标应用是否真的识别新增格式。

在这次记录的 Edge 环境里,用 ClipboardItem.supports() 探了一下,结果也符合这个判断:

新版 Edge 的 ClipboardItem 支持状态

ClipboardItem.supports('text/plain')     // true
ClipboardItem.supports('text/html')      // true
ClipboardItem.supports('image/png')      // true
ClipboardItem.supports('image/svg+xml')  // true
ClipboardItem.supports('image/gif')      // false
ClipboardItem.supports('web image/gif')  // true

MDN 对 ClipboardItem.supports() 的说明也能对上:text/plain、text/html、image/png 是 Clipboard API 要求支持的类型;image/svg+xml 和以 web 开头的自定义 MIME 类型属于可能支持的扩展项。

这里最容易误会的是 web image/gif。Chrome 的 Web custom formats for the Async Clipboard API 区分了 Web 自定义格式与原生格式:web image/jpeg、web image/gif 这类格式不是普通桌面应用天然认识的格式。它们适合网页应用之间约定读写,平台原生应用通常仍然期待 image/jpeg、文件路径或系统原生格式。

因此,纯 Web Clipboard API 目前不能把一张 GIF 稳定写成「QQ、微信、Slack、图片编辑器都能当作动图文件读取」的系统剪贴板内容。

纯扩展只能缓解,不能真正补齐系统格式

浏览器扩展能做的事不少。它可以加右键菜单,可以拿到被右键的图片 URL,可以写 text/html,也可以在不支持 GIF 时退成首帧 PNG。对网页编辑器、邮件编辑器、部分在线聊天应用,这已经有价值。

但纯扩展有一道硬边界:它还在浏览器沙箱里。它不能随便把本地文件列表写成 Windows CF_HDROP,也不能像桌面程序一样操作 macOS Pasteboard 的文件 URL 和原生类型。

纯扩展和 Native Host 的能力边界

Native Messaging 可以把这部分工作交给本地程序。Microsoft Edge 的 Native Messaging 文档和 Chrome 的同类文档说明了 host manifest、allowed_origins、stdio 协议和消息大小限制:扩展通过标准输入输出和用户设备上已经安装的 native app host 通信。本机程序需要单独安装,Edge 不负责安装或管理它。

扩展能拿到网页上下文,本机 host 才能碰系统能力。关于这套「插件发结构化消息,host 分发本地能力」的机制,我单独整理在《让浏览器插件调用本地能力:Native Host 的边界和协议》里。GIF 复制正好卡在这条边界上。

现成项目可以参考,但不够稳

我看了两个现成项目:picasocro1/copy-gif 和 syanth/gifcopier。它们都证明了一个方向:浏览器扩展加本地程序确实能绕开纯 Clipboard API 的限制。但如果要做一个自己长期使用、还能继续加功能的工具,它们都不够稳。

copy-gif 的方向比较接近想要的样子。它有右键菜单、Native Host、Rust 代码、Windows/macOS/Linux 分支,也知道没有 Native Host 时只能退成 PNG。问题在工程一致性:仓库里 Native Host 已经是 Rust,host manifest 和 Windows 安装脚本仍然在找 copy-gif-host.py;Windows 分支把 GIF 下载到临时文件后写入文件剪贴板,但函数退出时 NamedTempFile 会清理文件,目标应用稍后读取这个路径时可能已经找不到文件;PowerShell 里构造 FileDropList 的方式也不够可靠。

安装脚本与 Host 实现不一致,缓存生命周期又可能导致稍后粘贴失败,因此它适合借鉴思路,直接作为长期使用的基础仍需修整。

gifcopier 更老一些,最后一次代码更新停在 2022 年。它的思路是把 GIF 或 GIFV 下载成 clip.mp4,然后把文件路径写进剪贴板。Windows 代码里可以看到这种形状:

std::string command = "curl -o clip.gif \"" + URL + "\"";
system(command.c_str());

这类实现问题更多。URL 被拼进 shell 命令,风险很高;clip.gif、clip.mp4 是固定文件名,多次复制会互相覆盖;依赖用户机器上的 curl 和 ffmpeg;Windows 安装脚本要求管理员权限,还会写 Chrome 扩展白名单策略。它能说明「文件路径剪贴板」是可行的,但不适合继续扩。

这两个项目留下的有用判断是:

  • 用 Native Host 是对的。
  • Windows 最稳的入口是文件列表,而不是普通图片位图。
  • macOS 可以走 file URL 或 GIF pasteboard 类型。
  • 原始 GIF 文件必须保留到目标应用读取完成,不能写完剪贴板就删。
  • 安装、注册、extension ID、浏览器差异,比右键菜单本身更容易踩坑。

自己做时的最小架构

这套能力放在已有的 Chrome Extension monorepo 里,复用 Vite、Vitest、扩展安装和 Native Messaging 协议。最初参考了 Rust 路线,当前实现以 Node.js Host 为主,细节见前面链接的 Native Host 文章。扩展已经能复制 GIF 文件,也能把动态 WebP 转成 GIF、静态 WebP 转成 PNG;实际入口叫「复制图片文件」。

方案里,剪贴板传的是文件路径,目标应用可能稍后才读取;缓存文件需要在 Host 退出后继续存在:

复制的是文件路径,文件必须继续存在

当前复制链路的主要文件如下:

apps/copy-gif-extension/
  src/background/
    gif-source-resolver.ts
    image-fetcher.ts
    native-client.ts
    copy-gif-flow.ts
  src/popup/
  vite.config.ts
  package.json

native-node/
  src/features/clipboard/index.ts
  src/features/media/image-converter.ts
  scripts/clipboard/

扩展侧不直接碰系统剪贴板。它负责:

  • 从右键菜单拿到图片的 srcUrl、页面来源和 frame 信息。
  • 按当前渲染 URL 匹配同一个 <img>,从它的 src、srcset 和所属 <picture> 找 GIF 候选。
  • 优先调用 clipboard.copyFileFromUrl,让 Host 直接下载;下载失败时,在扩展上下文 fetch,再调用 clipboard.copyFile 传入 base64 字节。
  • 通过 convertWebp: true 为这次图片复制启用 WebP 转换,成功提示使用 Host 返回的实际缓存文件名。
  • Native Host 不可用或转换失败时明确报错;当前入口不会把首帧 PNG 当成动态图片复制成功。

Native Host 负责:

  • 下载公开图片,或者接收扩展传过来的 base64 字节。
  • 根据 magic bytes 判断真实格式,限制文件大小,并校验调用方要求的输入格式。
  • 对开启转换的 WebP 完整读取动画帧,生成 GIF 或 PNG,再校验输出格式和体积。
  • 把结果保存到受控持久缓存目录,Windows 写文件列表,macOS 写 file URL。
  • 回读 Windows 剪贴板,确认路径已经更新到本次文件;doctor 用于检查 Host、manifest、注册表和启动链路。

网页上的动图可能已经变成 WebP

文件剪贴板解决了「浏览器把动画变成位图」的问题,但目标应用还得认识这个文件。有些网页播放的是动态 WebP,直接把 .webp 文件放进剪贴板,聊天工具仍可能无法按动图发送。另一些 WebP 本来就是静图,统一转 GIF 又会增加体积,还可能损失颜色。

这里有两个独立判断:拿到的文件是什么格式,以及它包含几帧。URL 后缀和 HTTP Content-Type 只能作为线索,真实格式要检查文件字节;WebP 既能存静图,也能存动画,不能看到 .webp 就认定它会动。

同一张图有 GIF 兜底时,优先拿原文件

常见的页面写法是让浏览器优先显示 WebP,同时保留 GIF 资源:

<picture>
  <source type="image/webp" srcset="animation.webp">
  <img src="animation.gif">
</picture>

这种情况下,img.currentSrc 和右键菜单的 srcUrl 可能指向 animation.webp,而 img.src 仍指向 animation.gif。扩展应按当前资源匹配同一个 <img>,再读取它和所属 <picture> 的候选地址,避免抓到页面上另一张 GIF。

候选地址也需要校验。如果 .gif 地址实际返回 WebP、PNG 或 JPEG,就继续尝试其他候选,最后处理原 WebP。把后缀替换成 .gif 只是站点回源的启发式,不能把它当作格式转换。拿到真正的 GIF 后直接复制原字节,可以省掉重新量化颜色,也能保留原有动画编码。

没有 GIF 原图时,再按帧数转换

WebP 容器规范定义了 VP8X 动画标志、ANIM 动画控制块和 ANMF 帧块。动画可以包含不同帧时长、循环次数、局部更新和透明合成,解析这些信息比观察画面是否变化可靠。

Host 使用 Sharp 的图片元数据接口读取帧数。pages 表示帧数,delay 是逐帧时长,loop 是循环次数;不需要为了区分动静图先播放一遍。

同图 GIF 优先,WebP 按帧数选择 GIF 或 PNG

只有原 WebP 需要转换。GIF 候选会先按真实格式筛选;原图已经是 GIF、PNG 或 JPEG 时,保留原格式。

取得的资源 处理方式 原因
同一张图的有效 GIF 兜底 直接复制 GIF 原字节 避免重复编码
多帧 WebP 转为动态 GIF 提高目标应用对动图文件的兼容性
单帧 WebP 转为 PNG 保留透明背景,避免额外有损压缩
GIF、PNG、JPEG 等其他已支持格式 保持原格式 这些文件不需要经过 WebP 转换

转换逻辑放在 native-node/src/features/media/image-converter.ts,两条下载路径共用它。下面是其中的核心节选:

const image = sharp(bytes, { animated: true, limitInputPixels: MAX_INPUT_PIXELS });
const metadata = await image.metadata();
// 此前已确认输入确实是 WebP。
const animated = (metadata.pages ?? 1) > 1;
const output = animated
  ? image.gif({ keepDuplicateFrames: true, loop: metadata.loop, delay: metadata.delay })
  : image.autoOrient().png();

animated: true必须在读取时启用,它表示载入所有帧;默认只读一帧,即使最后调用 .gif(),也可能得到只含首帧的 GIF。帧的局部更新和合成交给解码器处理,再由 GIF 编码器生成完整动画。Sharp 官方输出示例也采用这条转换路径。

静态 WebP 默认转 PNG,而不是根据「有没有透明通道」直接选 JPEG。不透明图片也可能是文字截图、图标或色块,转 JPEG 会额外引入有损压缩;照片是否适合 JPEG,属于内容和体积取舍,文件头不能直接决定。PNG 保留的是 WebP 解码后的像素,无法恢复有损 WebP 编码前已经丢失的细节。

完整读取动画帧,转换仍有格式代价

动态 WebP 转 GIF 是重新编码。GIF 只有有限的调色板,透明度和时间精度也更受限:半透明边缘可能变化,逐帧时长需要适应 GIF 的百分之一秒单位,文件体积也可能明显增加。因此,有原 GIF 时仍优先使用原文件。

这条转换路径不主动缩图、降帧率或截断时长。输入有损坏、总解码像素超过 64 Mi 像素、转换超过 60 秒,或者输出超过 Host 的 64 MiB 文件上限时,复制失败并提示原因,避免把缺帧结果写成成功。普通文件复制默认不启用转换,调用方必须显式传入 convertWebp: true。

回归测试用三帧纯色 WebP 检查输出帧数、顺序、不同帧时长和循环次数,还覆盖重复帧、静图半透明像素、EXIF 方向和截断文件。真实粘贴验证需要继续检查系统文件列表、缓存文件内容和目标应用读到的 MIME 类型,不能只看扩展提示「已复制」。

在 Windows 的独立 Chromium 测试 profile 中加载生产扩展,通过已注册的 Node Host 复制,再把系统剪贴板粘进浏览器页面,得到下面的结果。这里验证的是文件剪贴板和浏览器文件粘贴,不代表所有聊天工具都已验收:

输入样本 验证路径 粘贴读到的类型 输出检查
20 × 20、三帧 WebP URL 和字节传输都通过 image/gif 三帧,时长分别为 100 / 230 / 470 ms,循环 3 次
2 × 1、含半透明像素的静态 WebP URL 和字节传输都通过 image/png 单帧,透明通道保留
原 GIF URL 直接复制通过 image/gif 输出字节与输入一致
损坏 WebP URL 复制明确失败 未粘贴新文件 剪贴板仍指向上一次成功复制的文件

文件生命周期比想象中重要

写文件路径到剪贴板时,剪贴板里只有路径,不一定有文件内容。目标应用什么时候读取文件,不由源应用控制。它可能在你按下粘贴键时读,也可能先看格式列表,等用户确认发送时才读。

因此,临时文件不能跟随一次 native 调用立刻删除。更稳的做法是把 GIF 放进受控缓存目录:

copy-gif-cache/
  2026-09-27/
    8d3f...a2.gif
    91c0...fe.gif

文件名可以用内容 hash 避免覆盖;URL hash 只能标识地址,不能证明地址里的内容没有更新。清理策略可按时间和总大小制定,比如 7 天或 512 MB,但这是容量策略,不是目标应用必定及时读取的保证。应避免立即清理当前复制项,超期文件被删除后的再次粘贴也可能失败。缓存保留期间,即使源进程退出,路径仍可供目标应用读取。

右键菜单很容易写,文件何时清理却直接影响后续粘贴。自己实现时,需要把这部分生命周期一并管好。

安全边界要比功能先定下来

Native Host 是浏览器沙箱外的本地程序,不能把它做成「扩展让我跑什么,我就跑什么」。这次只需要受控能力,不需要 shell。

请求形状固定为方法调用。让 Host 直接下载公开 GIF 时,可以使用下面的请求:

{
  "id": "copy-1",
  "method": "clipboard.copyFileFromUrl",
  "params": {
    "sourceUrl": "https://example.com/a.gif",
    "referrer": "https://example.com/post/1",
    "fileName": "a.gif"
  }
}

host 的能力表里只出现可审核的方法,例如:

  • native.hello
  • native.doctor
  • clipboard.copyFileFromUrl
  • clipboard.copyFile
  • cache.cleanup

不要出现 command.request 这种默认能执行任意命令的接口。即使以后要扩成本机工具平台,也应使用结构化命令,经过风险审核和用户授权后执行,不能把 shell 字符串从扩展直接透传到本机。

下载也要收紧。最初版本可以只支持用户右键触发的当前 URL,不做后台批量抓取;不要读取 cookie,不绕登录态,不保存网页敏感内容。需要处理登录态图片时,优先让扩展在浏览器权限内 fetch,再把已拿到的字节分片交给 host,host 只负责校验和写剪贴板。

「用户右键的 URL」仍是外部输入。Host 自己下载时应限制协议和大小,逐次检查重定向,并拒绝未授权的本机、内网与文件地址,不能只检查最初的 URL。clipboard.copyFile 也应限制在 host 管理的缓存内,不接受任意本地路径;一个扩展请求中的 userAction 字段,不能独自证明已经得到授权。

文件列表剪贴板的实现需要按系统分别处理。本机 Host 的安装、浏览器 manifest、扩展 ID 与更新流程也属于分发工作,不能由扩展商店安装替代。完整内容见跨平台剪贴板与分发弹窗。

总结

GIF 丢动画,常发生在浏览器把原图写成静态位图、目标应用又没有取得原始文件的那一步。HTML 图片引用、Web 自定义格式和系统文件列表是不同的复制路径,浏览器能播放也不能证明粘贴方会保留动画。日常临时解决,下载文件再拖拽或从文件管理器复制仍然最稳;要做成右键菜单,本文选择扩展获取网页资源、Native Host 写系统文件剪贴板。

这条链路还要处理真实格式和文件生命周期:同图有有效 GIF 时保留原字节,否则将多帧 WebP 转 GIF、单帧 WebP 转 PNG,并接受重编码的格式代价。剪贴板里传的是路径,缓存不能随 Host 调用结束就删除;下载、输入校验、受控目录和安装诊断也要由本机能力层负责。

目前验证通过的是 Windows 文件剪贴板与浏览器文件粘贴,常用聊天工具和 macOS 仍需分别验收。最终能否复制成功,要看目标应用是否接受这份文件格式,以及它真正读取时文件是否仍然存在。

跨平台剪贴板与分发

跨平台实现口径

当前 Node.js Host 在 Windows 上通过很薄的 PowerShell helper 构造文件列表,文件路径作为 JSON 参数传递,不拼接成 shell 命令。若改用 Rust 原生实现,可以评估 windows crate 或 clipboard-win,直接写 CF_HDROP。两条路线都要确保:

  • 文件路径是绝对路径。
  • 使用 Unicode 路径。
  • 写入双 null 结尾的文件列表。
  • SetClipboardData 后内存所有权交给系统。
  • 目标文件在缓存目录里持续存在。

macOS 上优先写文件 URL。当前 Node.js Host 使用 JXA helper 调用 AppKit,其他实现也可以使用 Objective-C / AppKit 桥接。只写普通文本路径不够,目标应用需要看到的是 file URL 或 GIF 数据类型。当前开发环境的真实剪贴板验证在 Windows 上完成,macOS 仍需要真机粘贴验收。

Linux 先不急着作为第一版目标。X11、Wayland、Flatpak / Snap 沙箱、xclip / wl-copy 行为差异都比较多。等 Windows 和 macOS 稳了,再把 Linux 做成单独平台分支,避免第一版被桌面环境差异拖住。

功能完成后怎样分发

本地扩展和 Node.js Host 已经具备 GIF 文件复制与 WebP 转换能力,正式分发前仍需要完成常用目标应用的粘贴验证。下载包的 README 应明确它支持哪些复制路径,以及哪些目标应用经过测试。

如果后续发布独立原生二进制安装包,可以按平台分开组织;下面是分发目录的设计示例,不是本文已经提供的下载链接:

source/files/2026-09-27 - 浏览器复制 GIF 为什么总会丢动画:剪贴板格式和插件方案/files/
  m-chrome-native-host-0.2.0-x86_64-pc-windows-msvc.zip
  m-chrome-native-host-0.2.0-aarch64-apple-darwin.zip
  copy-gif-extension-0.1.0.zip

Windows zip 里放:

bin/m-chrome-native-host.exe
install.ps1
README.md

macOS zip 里放:

bin/m-chrome-native-host
install.sh
install.command
README.md

安装脚本负责把 host 放到用户级应用数据目录,注册 Chrome / Edge 的 Native Messaging manifest,并把当前扩展 ID 写进 allowed_origins。插件启动时再通过 native.doctor 给用户一个明确状态:host 没装、extension ID 不匹配、浏览器 manifest 路径不对、host 版本过低,分别给不同提示。

这些诊断要能帮助用户区分安装失败、浏览器选错、ID 不匹配,以及更新后仍调用旧 host 等问题。