浏览器复制 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() 探了一下,结果也符合这个判断:
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 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 是循环次数;不需要为了区分动静图先播放一遍。
只有原 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.hellonative.doctorclipboard.copyFileFromUrlclipboard.copyFilecache.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 等问题。