深度解析

WebP 的诞生:VP8 如何变成图像格式

koboshiCo-founder
·11 分钟阅读
WebP 的诞生:VP8 如何变成图像格式
概述

WebP 是一种基于 RIFF 的容器,把 VP8 视频帧封装成静态图像。Google 在 2010 年发布它,宣称文件比 JPEG 和 PNG 小 25-34%。以下是完整的技术故事——从文件签名到现实的采用差距。

用十六进制编辑器打开一个 WebP 文件。前 12 个字节:

52 49 46 46 ?? ?? ?? ?? 57 45 42 50

这是 RIFF/WAVE 风格的容器头52 49 46 46 在 ASCII 中拼出 "RIFF"。接下来四个字节是小端序无符号32位整数的文件大小。57 45 42 50 拼出 "WEBP"。WebP 文件不是原始比特流,它是一个容器,就像 WAV 音频或 AVI 视频一样,基于 Microsoft 在 1991 年推出的同一套 RIFF 规范。容器内部是一个 VP8 视频帧,被重新定位为静态图像。这个设计选择——用视频编解码器处理照片——是理解 WebP 最重要的一点。

Google 在 2010 年 9 月 30 日发布 WebP。卖点很简单:与 JPEG 同等视觉质量,但文件小 25-34%。对 PNG 的说法更激进——无损图像小 26%。在图像流量约占全部传输数据一半的网络环境中,这些数字本足以引发大规模迁移,但事实并非如此。

催生 WebP 的收购

WebP 不是来自图像实验室,它来自一个 视频编解码器

2010 年 2 月,Google 收购 On2 Technologies,价格约 1.24 亿美元。On2 是一家视频压缩公司,历史悠久——他们的编解码器驱动了 Flash 视频、Skype 视频通话和 AOL 流媒体。旗舰产品是 VP8,一种旨在与 H.264 竞争且无需专利授权费的视频编解码器。

Google 在 2010 年 5 月将 VP8 开源,命名为 WebM,捆绑 Vorbis 音频编解码器和 Matroska 容器。目标很明确:构建一套免专利费的视频栈,挑战 MPEG-LA 的 H.264 授权池——后者当时开始向网络视频流媒体收取专利费。

但 Google 还有第二个用途。VP8 的帧内压缩——即不引用其他帧、对单帧视频进行压缩——本质上就是一个静态图像编解码器。同样的预测模式、变换编码和熵编码让 VP8 在视频上高效,也适用于照片。Google 提取了帧内模式,将其封装进 RIFF 容器,命名为 WebP。

这个名字是营销选择。"Web" 因为它是为网络设计的。"P" 因为图像格式都以 P 结尾——JPEG、PNG、BMP、TIFF。从技术上讲,它就是一个"视频帧伪装成静态图像"的格式。

为什么要造新格式?

到 2010 年,JPEG 已经十八岁,PNG 十四岁。两者都已根深蒂固。何必多此一举?

JPEG 的局限真实且众所周知:

  • 不支持透明度。JPEG 像素要么完全不透明,要么你需要单独制作遮罩。
  • 不支持动画。动画 JPEG 不存在标准。
  • 仅有有损模式。JPEG 基线规范没有无损模式。(JPEG-LS 和 JPEG 2000 存在,但都不兼容网络。)
  • 每通道 8 位色深。基线不支持广色域或 HDR。
  • 低质量下出现块效应。8 x 8 DCT 网格在质量设置低于 75 时肉眼可见。

PNG 的局限同样真实:

  • 没有有损模式。PNG 永远无损。一张 1200 万像素的照片以 PNG 存储可达 15-25 MB。
  • 照片文件体积巨大。PNG 的 DEFLATE 压缩无法与基于 DCT 的心理视觉丢弃机制相抗衡。
  • 基础规范没有动画。APNG 存在,但花了很多年才获得浏览器支持。

Google 发现了市场空白:一种格式能同时做到 有损和无损压缩、透明度和动画,而且文件比现有格式更小。这就是 WebP 的卖点。

WebP 实际如何工作

WebP 内部有两种根本不同的格式:有损 WebP(VP8 帧内)和 无损 WebP(一套独立编解码器,同样源自 VP8 研究)。

有损 WebP:VP8 帧内

有损 WebP 在 RIFF 容器内存储 VP8 比特流。编码流水线在概念上与 JPEG 类似,但有关键差异:

阶段JPEG有损 WebP
变换8 x 8 DCT4 x 4 或 16 x 16 整数 DCT-like transform
预测无(仅帧内)每个 4 x 4 块 4 种帧内预测模式
色度子采样4:2:0 默认4:2:0 默认
熵编码HuffmanBinary arithmetic coding
位深8 位8 位

帧内预测 是最大的提升。JPEG 独立编码每个 8 x 8 块。WebP 从已编码的邻块——上方、左侧或两者——预测每个 4 x 4 块,然后只编码预测误差。对平滑渐变和大面积平坦区域,误差极小,压缩率显著提升。

算术编码器也比 JPEG 的霍夫曼编码更高效——同等质量下通常再省 5-10%。

Google 2010 年的官方基准测试:

指标WebP vs JPEG
平均文件体积缩减同等 SSIM 下 25-34%
编码速度比 libjpeg 慢约 8 倍
解码速度与 libjpeg 相当

编码速度是隐藏成本。生成 WebP 文件消耗的 CPU 显著高于 JPEG。对于批量导出数百张照片的摄影师而言,这一点至关重要。

无损 WebP

无损 WebP 使用完全不同的编解码器。它不是 VP8,而是一种自定义格式,组合了:

  • 预测编码:每个像素 14 种空间预测模式。
  • 颜色缓存:一个最近出现颜色的哈希表,利用局部重复。
  • LZ77 反向引用:类似 PNG 的 DEFLATE,但具备 2D 空间感知匹配。
  • 霍夫曼与算术混合:熵编码根据局部统计自适应。

Google 宣称平均比 PNG 小 26%。实际上,节省幅度差异很大——大面积平坦区域的简单图形收益有限,而带有精细纹理的照片可减少 30-40%。

扩展 WebP(VP8X)

VP8X 数据块 为 WebP 增加了额外功能:

  • 透明通道:8 位透明通道单独编码,用无损 WebP 的熵编码器压缩。
  • 动画:多帧携带时间元数据,本质上是一个精简版 VP8 视频。
  • EXIF 元数据:相机和地理位置数据。
  • XMP 元数据:Adobe 风格的处理指令。
  • ICC 色彩配置文件:广色域和 HDR 色彩管理。

VP8X 文件以 VP8X 数据块头开头,随后是标志位,指示哪些扩展存在。

文件格式

WebP 是 RIFF 容器。了解 RIFF 的话,字节布局很直接。

RIFF 容器结构

Bytes 0-3:   "RIFF" (0x52 0x49 0x46 0x46)
Bytes 4-7:   File size - 8 (little-endian uint32)
Bytes 8-11:  "WEBP" (0x57 0x45 0x42 0x50)
Bytes 12-15: Chunk FourCC — "VP8 ", "VP8L", or "VP8X"
Bytes 16-19: Chunk size (little-endian uint32)
Bytes 20+:   Chunk data

VP8X 扩展头

如果 bytes 12-15 的 FourCC 是 "VP8X"(0x56 0x50 0x38 0x58):

Bytes 20-23: Chunk size = 10 (little-endian uint32)
Bytes 24:    Flags byte
             Bit 0: Animation present
             Bit 1: XMP metadata present
             Bit 2: EXIF metadata present
             Bit 3: Alpha channel present
             Bit 4: ICC profile present
             Bits 5-7: Reserved
Bytes 25-27: Canvas width - 1 (little-endian uint24)
Bytes 28-30: Canvas height - 1 (little-endian uint24)

画布尺寸以 width - 1height - 1 存储,因此 1200 x 675 的图像存的是 1199674。最大画布尺寸为 16,777,215 x 16,777,215 像素。

数据块类型

FourCC内容压缩
VP8 VP8 比特流(有损)VP8 帧内
VP8LVP8L 比特流(无损)自定义无损
VP8X扩展头 + 标志位
ALPH透明通道数据无损 WebP 熵编码
ANMF动画帧每帧 VP8/VP8L
ICCPICC 色彩配置文件
EXIFEXIF 元数据
XMP XMP 元数据

通过读取文件签名检测 WebP

不要信任 .webp 扩展名。读取前 16 个字节并解析 RIFF header。

简单有损 WebP 的精确字节布局:

Bytes 0-3:   "RIFF"
Bytes 4-7:   File size(little-endian uint32)
Bytes 8-11:  "WEBP"
Bytes 12-15: "VP8 "(lossy)或 "VP8L"(lossless)或 "VP8X"(extended)

浏览器中的 TypeScript:

interface WebPInfo {
  valid: boolean
  type: "lossy" | "lossless" | "extended" | "unknown"
  width?: number
  height?: number
  hasAlpha?: boolean
  isAnimated?: boolean
}

async function inspectWebP(file: File): Promise<WebPInfo> {
  const buffer = await file.slice(0, 30).arrayBuffer()
  const bytes = new Uint8Array(buffer)

  if (bytes.length < 12) return { valid: false, type: "unknown" }

  const riff = String.fromCharCode(...bytes.slice(0, 4))
  const webp = String.fromCharCode(...bytes.slice(8, 12))
  if (riff !== "RIFF" || webp !== "WEBP") {
    return { valid: false, type: "unknown" }
  }

  const type = String.fromCharCode(...bytes.slice(12, 16))

  if (type === "VP8 ") {
    // Lossy: width/height 在 bytes 26-29
    const w = bytes[26] | (bytes[27] << 8)
    const h = bytes[28] | (bytes[29] << 8)
    return { valid: true, type: "lossy", width: w, height: h, hasAlpha: false }
  }

  if (type === "VP8L") {
    // Lossless: 尺寸打包在 bytes 21-24 的位中
    const bits =
      bytes[21] | (bytes[22] << 8) | (bytes[23] << 16) | (bytes[24] << 24)
    const w = (bits & 0x3fff) + 1
    const h = ((bits >> 14) & 0x3fff) + 1
    const alpha = ((bits >> 28) & 0x01) !== 0
    return {
      valid: true,
      type: "lossless",
      width: w,
      height: h,
      hasAlpha: alpha,
    }
  }

  if (type === "VP8X") {
    const flags = bytes[20]
    const w = (bytes[24] | (bytes[25] << 8) | (bytes[26] << 16)) + 1
    const h = (bytes[27] | (bytes[28] << 8) | (bytes[29] << 16)) + 1
    return {
      valid: true,
      type: "extended",
      width: w,
      height: h,
      hasAlpha: (flags & 0x10) !== 0,
      isAnimated: (flags & 0x02) !== 0,
    }
  }

  return { valid: false, type: "unknown" }
}

Python

import struct
from typing import TypedDict

class WebPInfo(TypedDict):
    valid: bool
    type: str
    width: int | None
    height: int | None
    has_alpha: bool | None
    is_animated: bool | None

def inspect_webp(path: str) -> WebPInfo:
    with open(path, "rb") as f:
        header = f.read(30)

    if len(header) < 12:
        return {"valid": False, "type": "unknown"}

    if header[:4] != b"RIFF" or header[8:12] != b"WEBP":
        return {"valid": False, "type": "unknown"}

    chunk_type = header[12:16]

    if chunk_type == b"VP8 ":
        w, h = struct.unpack("<HH", header[26:30])
        return {"valid": True, "type": "lossy", "width": w, "height": h,
                "has_alpha": False, "is_animated": None}

    if chunk_type == b"VP8L":
        bits = struct.unpack("<I", header[21:25])[0]
        w = (bits & 0x3FFF) + 1
        h = ((bits >> 14) & 0x3FFF) + 1
        alpha = ((bits >> 28) & 0x01) != 0
        return {"valid": True, "type": "lossless", "width": w, "height": h,
                "has_alpha": alpha, "is_animated": None}

    if chunk_type == b"VP8X":
        flags = header[20]
        w = struct.unpack("<I", header[24:27] + b"\x00")[0] + 1
        h = struct.unpack("<I", header[27:30] + b"\x00")[0] + 1
        return {"valid": True, "type": "extended", "width": w, "height": h,
                "has_alpha": bool(flags & 0x10), "is_animated": bool(flags & 0x02)}

    return {"valid": False, "type": "unknown"}

用 Pillow 的更高级做法:

from PIL import Image

with Image.open("image.webp") as img:
    print(img.format)      # WEBP
    print(img.mode)        # RGB or RGBA
    print(img.size)        # (width, height)
    print(img.is_animated) # True if animated

或者用 webp 库:

import webp

info = webp.WebPInfo("image.webp")
print(info.width, info.height, info.has_alpha)

Go

package main

import (
	"encoding/binary"
	"fmt"
	"os"
)

type WebPInfo struct {
	Valid       bool
	Type        string
	Width       int
	Height      int
	HasAlpha    bool
	IsAnimated  bool
}

func inspectWebP(path string) (WebPInfo, error) {
	f, err := os.Open(path)
	if err != nil {
		return WebPInfo{}, err
	}
	defer f.Close()

	buf := make([]byte, 30)
	if _, err := f.Read(buf); err != nil {
		return WebPInfo{}, err
	}

	if string(buf[:4]) != "RIFF" || string(buf[8:12]) != "WEBP" {
		return WebPInfo{Valid: false, Type: "unknown"}, nil
	}

	typeStr := string(buf[12:16])
	switch typeStr {
	case "VP8 ":
		w := int(binary.LittleEndian.Uint16(buf[26:28]))
		h := int(binary.LittleEndian.Uint16(buf[28:30]))
		return WebPInfo{Valid: true, Type: "lossy", Width: w, Height: h, HasAlpha: false}, nil
	case "VP8L":
		bits := binary.LittleEndian.Uint32(buf[21:25])
		w := int(bits&0x3FFF) + 1
		h := int((bits>>14)&0x3FFF) + 1
		alpha := ((bits >> 28) & 0x01) != 0
		return WebPInfo{Valid: true, Type: "lossless", Width: w, Height: h, HasAlpha: alpha}, nil
	case "VP8X":
		flags := buf[20]
		w := int(binary.LittleEndian.Uint32(append(buf[24:27], 0))) + 1
		h := int(binary.LittleEndian.Uint32(append(buf[27:30], 0))) + 1
		return WebPInfo{
			Valid: true, Type: "extended", Width: w, Height: h,
			HasAlpha: (flags & 0x10) != 0, IsAnimated: (flags & 0x02) != 0,
		}, nil
	}

	return WebPInfo{Valid: false, Type: "unknown"}, nil
}

PHP

function inspectWebP(string $path): array {
    $header = file_get_contents($path, false, null, 0, 30);
    if (strlen($header) < 12) {
        return ["valid" => false, "type" => "unknown"];
    }

    if (substr($header, 0, 4) !== "RIFF" || substr($header, 8, 4) !== "WEBP") {
        return ["valid" => false, "type" => "unknown"];
    }

    $type = substr($header, 12, 4);

    if ($type === "VP8 ") {
        $w = unpack("v", substr($header, 26, 2))[1];
        $h = unpack("v", substr($header, 28, 2))[1];
        return ["valid" => true, "type" => "lossy", "width" => $w, "height" => $h, "has_alpha" => false];
    }

    if ($type === "VP8L") {
        $bits = unpack("V", substr($header, 21, 4))[1];
        $w = ($bits & 0x3FFF) + 1;
        $h = (($bits >> 14) & 0x3FFF) + 1;
        $alpha = (($bits >> 28) & 0x01) !== 0;
        return ["valid" => true, "type" => "lossless", "width" => $w, "height" => $h, "has_alpha" => $alpha];
    }

    if ($type === "VP8X") {
        $flags = ord($header[20]);
        $w = unpack("V", substr($header, 24, 3) . "\x00")[1] + 1;
        $h = unpack("V", substr($header, 27, 3) . "\x00")[1] + 1;
        return [
            "valid" => true, "type" => "extended",
            "width" => $w, "height" => $h,
            "has_alpha" => (bool)($flags & 0x10),
            "is_animated" => (bool)($flags & 0x02),
        ];
    }

    return ["valid" => false, "type" => "unknown"];
}

fileinfo

$finfo = new finfo(FILEINFO_MIME_TYPE);
$mime = $finfo->file('image.webp');
// image/webp

ImageMagick CLI

magick identify -verbose image.webp | grep "Format:"
# Format: WEBP (WebP Image Format)

完整元数据提取:

magick identify -verbose image.webp

这会输出宽度、高度、色深、透明通道存在性、压缩类型和 ICC 配置文件信息。

或者简单点:

file image.webp
# image.webp: RIFF (little-endian) data, Web/P image

优势

WebP 在特定维度上技术过硬:

文件更小:在 Google 的参考语料库上,同等 SSIM 下有损 WebP 平均比 JPEG 小 25-34%。无损 WebP 平均比 PNG 小 26%。对高流量站点,这些节省直接转化为带宽成本和更快的页面加载。

功能整合:一种格式替代 JPEG 和 PNG 的大部分场景。有损模式用于照片,无损模式用于图形,透明通道做透明,动画处理短序列。Web 开发者只需要懂一种格式,而不是三种。

浏览器原生解码:Chrome、Firefox、Safari 和 Edge 都内置硬件加速或高度优化的软件 WebP 解码器。解码速度在桌面端与 JPEG 相当,移动端差距在 10-20% 以内。

渐进式解码:WebP 支持数据到达时渐进显示,类似 JPEG 的 progressive 模式。对慢速连接,收到约 30% 文件后就能呈现可辨识的图像。

动画:同等视觉质量下,动画 WebP 文件通常比动画 GIF 小 60-80%,每帧具备完整 24 位颜色和 8 位透明通道。

劣势

WebP 的问题不是技术,而是生态。

编码速度:2010 年,WebP 编码大约比 libjpeg 慢 8 倍。差距已经缩小——2026 年的 libwebp 比 libjpeg-turbo 慢约 2-3 倍——但对批量工作流仍有影响。批量导出 1000 张照片的摄影师会明显感到等待时间更长。

不支持 16 位或 HDR:WebP 严格限制在每通道 8 位。对广色域摄影、医学影像或 HDR 内容,WebP 无法使用。HEIC、AVIF 和 JPEG XL 都支持更高的位深。

不支持无损 JPEG 再压缩:JPEG XL 可以拿现有 JPEG 做无损再压缩,省约 20%。WebP 不行。将 JPEG 转换为 WebP 需要完全重新编码,会引入代际损失。

工具链缺口:Photoshop 直到 2022 年才原生支持 WebP。ImageMagick 的 WebP 支持需要编译 libwebp 插件,很多发行版默认没装。许多内容管理系统仍然默认生成 JPEG/PNG。

VP8 专利阴云:Google 发布 VP8 时附带专利赔偿承诺,但该编解码器的专利格局从未像 PNG 或 JPEG 那样清晰。一些组织专门回避 WebP,因为他们不信任 Google 的法律盾牌能在法庭上站住脚。

为什么"更差"的格式赢了

JPEG 三十四岁了。没有透明度,没有动画,没有无损模式,质量 75 就有可见伪影。WebP 几乎每项指标都胜过它。然而 2025 年 Web Almanac 的数据显示,JPEG 约占全部网络图像的 46%,WebP 只有 19%

原因不是技术,是 网络效应和转换成本

JPEG 就是图像格式的 QWERTY 键盘布局。每台相机默认存 JPEG。每部手机原生显示 JPEG。每台打印机都接受 JPEG。每个社交网络、CMS、CDN 和邮件客户端都无需插件、编解码器或转换就能处理 JPEG。这种通用性使得"图像"与"JPEG"在大多数用户眼中几乎等同。

WebP 的采用曲线说明了问题:

年份里程碑
2010Google 发布 WebP(Chrome 8)
2012Chrome 23 增加 lossless 和 alpha 支持
2013Chrome 增加 animated WebP
2014Android 4.0+ 增加原生 WebP 支持
2015Facebook 把所有移动端照片转成 WebP
2016Safari 14 增加 WebP 支持
2020实现全浏览器支持
2022Photoshop 增加原生 WebP 导出
2025Web Almanac 显示 WebP 占 web 图像 19%

Chrome 大力推广 WebP,因为 Google 同时控制浏览器和格式。Facebook 采用它,因为节省了 PB 级带宽。但互联网的长尾——WordPress 博客、小型电商站点、企业 CMS 部署、邮件通讯——迁移缓慢,甚至根本没有迁移。

关键的短板在于 Apple 生态系统。iPhone 默认存 HEIC,不是 WebP。macOS Preview 直到 macOS 11 Big Sur(2020)才支持 WebP。iOS 分享菜单不提供 WebP 导出。对于主要在 Apple 设备上工作的摄影师、设计师和社交媒体创作者而言,WebP 几乎不存在。

与此同时,AVIF 于 2019 年问世,压缩率比 WebP 更好,由 Alliance for Open Media 提供免专利费授权。Chrome、Firefox 和 Safari 都已支持 AVIF。Cloudflare 和 Cloudinary 自动提供 AVIF。WebP 沦为垫脚石——比 JPEG 好,但已经被下一代超越。

WebP 今天的位置

WebP 不是失败,它是 部分成功

对 2026 年做新项目的 web 开发者,WebP 是需要透明度或动画的图像的务实默认选择。它比 PNG 无损图形更小,比 JPEG 照片更小。浏览器支持全覆盖。编码工具已成熟。

但 WebP 并未取代 JPEG。它只是在旁边切下了一块利基市场——原本就属于 PNG 的那块,只不过文件更小。"一种格式通吃所有图像"的愿景没有实现。

2026 年的实际选择:

使用场景最佳格式原因
照片(遗留)JPEG通用,编码快,足够小
照片(新建)AVIF比 WebP 小 30%,免专利费
照片(兜底)WebP比 JPEG 小 25%,支持全覆盖
无损图形WebP 或 PNGWebP 更小;PNG 是安全兜底
透明度WebP 或 PNGWebP 文件更小;PNG 是安全兜底
动画WebP 或 AVIF都比 GIF 小 60-80%;AVIF 更新
Wide-gamut / HDRAVIF 或 JPEG XL10+ bit depth,ICC/ICC v4 支持
印刷工作流TIFF 或 JPEG XLCMYK、16-bit、无损 JPEG 再压缩

WebP 真正的遗产在于证明了:当主流浏览器厂商大力推动时,网络确实能够接纳新的图像格式。它为 AVIF 铺了路。它迫使 Apple 原生支持 JPEG/PNG 以外的格式。它证明透明度和动画应该放在同一个容器里。

但它也证明了:技术优越性并不足够。普及率、惯性和生态对齐比压缩率更重要。JPEG 会比 WebP 存活更久,不是因为它更优秀,而是因为它已经无处不在。

总结

WebP 脱胎于视频编解码器,初衷是为了解决带宽问题。它确实解决了——对 Google、对 Facebook、对任何愿意改造图像管线的站点。压缩效果真实存在,功能实用,浏览器支持完善。

但网络不会因为白皮书宣称新格式体积缩减 25% 就切换格式。只有在两种情况下才会切换:新格式比旧格式更容易使用,或者旧格式糟糕到非迁移不可。JPEG 并未糟糕到那个地步,WebP 也没有容易到那个程度。而等到 WebP 变得易于使用时,AVIF 已经带着规格表上更漂亮的数字登场了。

WebP 就是图像格式的 Betamax——技术扎实,支持良好,最终被稍晚问世、营销更佳、背书更强的东西所超越。它不会消失。它会与 JPEG、PNG、AVIF 以及未来的某种格式共存,扮演如今 PNG 的角色:安全、够用、到处都能工作的兜底格式。

如果你有需要在 web 上缩减体积的 PNG 文件,PNG to WebP 可在浏览器本地转换——不上传、不经服务器处理。JPEG 需要透明度或动画时,JPG to WebP 支持质量控制的转换。而当你需要通用兜底格式时,WebP to PNGWebP to JPG 能把 WebP 文件还原成所有查看器都能打开的格式。

更多推荐阅读