暗无天日

=============>DarkSun的个人博客

GIF不仅仅是一种图片格式——用GIF流做些奇怪的事

你有没有想过,有人用 GIF 动画在浏览器里玩游戏?

这不是玩笑。有人用 Haskell 写了一个贪吃蛇游戏,但它的画面输出不是通过窗口或 Canvas,而是通过 GIF 动画流 ——服务器不断向浏览器推送新的 GIF 帧,浏览器自动显示,就这样实现了一个"实时"游戏。打开浏览器,访问一个 URL,就能看到不断变化的 GIF 画面,按 WASD 键还能控制蛇的移动方向。

这听起来很奇怪,但仔细想想又很巧妙:GIF 是一种几乎所有浏览器都支持的图片格式,不需要 JavaScript,不需要 WebSocket,甚至不需要现代浏览器——只要能显示 GIF 就行。

那么,这种"GIF 流"的技术原理是什么?除了玩游戏,它还能做哪些奇怪的事?让我们一起来探索。

参考资料: https://hookrace.net/blog/haskell-game-programming-with-gif-streams/

GIF 流的原理

GIF 流的核心技术是 HTTP 的 multipart/x-mixed-replace Content-Type。这是一种 服务器推送 (Server Push)技术,诞生于 Netscape 时代。

普通的 HTTP 响应是这样的:客户端发请求,服务器返回一个完整的响应(一个 HTML 页面、一张图片等),然后连接关闭。

multipart/x-mixed-replace 允许服务器在 同一个 HTTP 连接 上连续发送多个"部分",每个部分都会 替换 前一个。浏览器收到新的部分后,会自动用新内容替换旧内容。

原始的 HTTP 响应看起来是这样的:

HTTP/1.1 200 OK
Content-Type: multipart/x-mixed-replace; boundary=frame

--frame
Content-Type: image/gif

<GIF 二进制数据:第 1 帧>
--frame
Content-Type: image/gif

<GIF 二进制数据:第 2 帧>
--frame
Content-Type: image/gif

<GIF 二进制数据:第 3 帧>
...

服务器 永远不会关闭这个连接 ,而是持续不断地发送新的 GIF 帧。浏览器收到每一帧后立即显示,然后再等待下一帧。就这样,一个静态的 <img> 标签就变成了"实时视频"。

这项技术最常见于 IP 摄像头的监控画面 (MJPEG 流),但用 GIF 来实现同样可行,而且 GIF 格式还有个优势:支持 256 色调色板和透明色,对于简单图形(如游戏画面、监控图表)来说文件体积可能比 JPEG 更小。注意 GIF 的 256 色限制可能导致颜色失真,但对于线条、文本等简单图形效果不错。

理解了原理后,接下来我们来看看 GIF 流能做哪些有趣的事。

用途 1:用 Python 实现一个 GIF 流服务器

理解了原理,我们来动手实现一个最简 GIF 流服务器。用 Python 标准库 + Pillow 就够了。

安装依赖

pip install --user Pillow

完整代码

#!/usr/bin/env python3
"""最简 GIF 流服务器 - 在浏览器中显示实时变化的颜色"""
from http.server import HTTPServer, BaseHTTPRequestHandler
from io import BytesIO
import time, math
from PIL import Image, ImageDraw

class GifStreamHandler(BaseHTTPRequestHandler):
    def do_GET(self):
        if self.path == '/':
            # 首页:返回 HTML 页面,用 <img> 嵌入 GIF 流
            self.send_response(200)
            self.send_header('Content-Type', 'text/html; charset=utf-8')
            self.end_headers()
            html = '''<!DOCTYPE html>
<html><head><title>GIF Stream Demo</title></head>
<body style="background:#111;color:#fff;text-align:center">
<h1>GIF Stream Demo</h1>
<img src="/stream" style="border:2px solid #555">
<p>Open this page to see live GIF stream</p>
</body></html>'''
            self.wfile.write(html.encode('utf-8'))
        elif self.path == '/stream':
            # GIF 流端点:通过 multipart/x-mixed-replace 推送帧
            self.send_response(200)
            self.send_header('Content-Type', 'multipart/x-mixed-replace; boundary=frame')
            self.send_header('Cache-Control', 'no-cache')
            self.end_headers()

            t = 0
            while True:
                try:
                    # 生成 200x200 的图像,颜色随时间变化
                    r = int((math.sin(t * 0.05) + 1) * 127)
                    g = int((math.sin(t * 0.05 + 2) + 1) * 127)
                    b = int((math.sin(t * 0.05 + 4) + 1) * 127)

                    img = Image.new('RGB', (200, 200), (r, g, b))
                    draw = ImageDraw.Draw(img)
                    # 在中间显示当前时间
                    draw.text((50, 80), time.strftime('%H:%M:%S'), fill=(255, 255, 255))

                    # 将图像编码为 GIF
                    buf = BytesIO()
                    img.save(buf, format='GIF')
                    gif_data = buf.getvalue()

                    # 按 multipart/x-mixed-replace 格式发送
                    self.wfile.write(b'--frame\r\n')
                    self.wfile.write(b'Content-Type: image/gif\r\n')
                    self.wfile.write(f'Content-Length: {len(gif_data)}\r\n'.encode())
                    self.wfile.write(b'\r\n')
                    self.wfile.write(gif_data)
                    self.wfile.write(b'\r\n')
                    self.wfile.flush()

                    t += 1
                    time.sleep(0.1)  # 10 FPS
                except (BrokenPipeError, ConnectionResetError):
                    break  # 客户端断开连接
        else:
            self.send_error(404)

if __name__ == '__main__':
    server = HTTPServer(('0.0.0.0', 8080), GifStreamHandler)
    print('Listening on http://0.0.0.0:8080/')
    server.serve_forever()

运行效果

python3 /tmp/gif_stream_server.py

然后在浏览器中打开 http://localhost:8080/ ,你会看到一个 HTML 页面,其中的 GIF 图像不断变色,中间显示着实时时钟。

注意: multipart/x-mixed-replace 需要 <img> 标签来承载,不能直接在浏览器地址栏访问流地址。这是因为现代浏览器(特别是 Chrome )对直接导航到 multipart 流的支持有限。这也是 IP 摄像头监控画面的标准用法——通过 HTML 页面嵌入 <img src"/stream">= 来显示 MJPEG/GIF 流。

整个过程:

  • 服务器每 100ms 生成一帧 GIF 图像
  • 通过 multipart/x-mixed-replace 协议推送给浏览器
  • 浏览器自动替换显示,无需任何 JavaScript

你还可以用 curl 查看 GIF 流的原始数据,感受 multipart/x-mixed-replace 的格式:

# 查看 GIF 流的前几个字节(Ctrl+C 停止)
curl -s http://localhost:8080/stream | xxd

你会看到 GIF 文件头( GIF87aGIF89a )以及边界标记 --frame 在二进制数据中交替出现。Pillow 对于简单的单帧图像默认输出 GIF87a (1987 年的原始格式),对于包含动画或透明色的图像则使用 GIF89a (1989 年增加了动画、透明色等特性)。两者在 GIF 流中都能正常工作。

用途 2:零依赖的实时系统监控仪表盘

回到 GIF 流的话题。既然我们可以用 Python 生成 GIF 帧并通过 HTTP 推送,那就可以做一个 不需要 WebSocket 、不需要 JavaScript 的实时监控仪表盘

核心思路:每隔一段时间采集系统状态(CPU、内存),用 Python 画一个柱状图,编码为 GIF 帧,通过 multipart/x-mixed-replace 推送给浏览器。

需要额外安装 psutil 来获取系统状态:

sudo pacman -S python-psutil
#!/usr/bin/env python3
"""GIF 流系统监控仪表盘 - 纯 GIF,无需 JavaScript"""
from http.server import HTTPServer, BaseHTTPRequestHandler
from io import BytesIO
import psutil
from PIL import Image, ImageDraw
import time

WIDTH, HEIGHT = 400, 200

def get_stats():
    """获取系统状态"""
    return {
        'cpu': psutil.cpu_percent(interval=0),
        'mem': psutil.virtual_memory().percent,
        'disk': psutil.disk_usage('/').percent,
    }

def draw_chart(stats):
    """绘制柱状图"""
    img = Image.new('RGB', (WIDTH, HEIGHT), (20, 20, 20))
    draw = ImageDraw.Draw(img)

    labels = [('CPU', stats['cpu'], (0, 180, 0)),
              ('MEM', stats['mem'], (0, 0, 180)),
              ('DISK', stats['disk'], (180, 0, 0))]

    for i, (label, value, color) in enumerate(labels):
        x = 50 + i * 120
        bar_height = int(value / 100 * 150)
        # 画柱子
        draw.rectangle([x, HEIGHT - 30 - bar_height, x + 60, HEIGHT - 30], fill=color)
        # 画标签
        draw.text((x + 10, HEIGHT - 25), f'{label} {value}%', fill=(200, 200, 200))

    return img

class MonitorHandler(BaseHTTPRequestHandler):
    def do_GET(self):
        if self.path == '/':
            self.send_response(200)
            self.send_header('Content-Type', 'text/html; charset=utf-8')
            self.end_headers()
            html = '''<!DOCTYPE html>
<html><head><title>System Monitor</title></head>
<body style="background:#111;color:#fff;text-align:center">
<h1>GIF Stream System Monitor</h1>
<img src="/stream" style="border:2px solid #555">
</body></html>'''
            self.wfile.write(html.encode('utf-8'))
        elif self.path == '/stream':
            self.send_response(200)
            self.send_header('Content-Type', 'multipart/x-mixed-replace; boundary=frame')
            self.send_header('Cache-Control', 'no-cache')
            self.end_headers()

            while True:
                try:
                    stats = get_stats()
                    img = draw_chart(stats)
                    buf = BytesIO()
                    img.save(buf, format='GIF')
                    gif_data = buf.getvalue()

                    self.wfile.write(b'--frame\r\n')
                    self.wfile.write(b'Content-Type: image/gif\r\n')
                    self.wfile.write(f'Content-Length: {len(gif_data)}\r\n'.encode())
                    self.wfile.write(b'\r\n')
                    self.wfile.write(gif_data)
                    self.wfile.write(b'\r\n')
                    self.wfile.flush()

                    time.sleep(1)  # 每秒更新一次
                except (BrokenPipeError, ConnectionResetError):
                    break  # 客户端断开连接

if __name__ == '__main__':
    server = HTTPServer(('0.0.0.0', 8080), MonitorHandler)
    print('监控仪表盘: http://0.0.0.0:8080/')
    server.serve_forever()

运行后在浏览器中打开,你会看到一个实时更新的柱状图,显示 CPU、内存和磁盘使用率。

这个方案的特点:

  • 不需要前端框架 :没有 React、Vue,甚至没有 JavaScript
  • 不需要 WebSocket :纯 HTTP 长连接
  • 兼容性极强 :任何能显示 GIF 的浏览器都支持,包括 w3m 这类文本浏览器配合图片渲染器
  • 但带宽消耗大 :每秒 1 帧 GIF 的数据量虽然不大,但如果需要更高帧率就会显著增加

用途 3:在 GIF 帧延迟中藏数据

GIF 格式的每一帧都有一个 延迟时间 (Delay Time),以 1/100 秒为单位。正常情况下这个值是 10(即 100ms),但我们可以用它来编码秘密信息。

比如,延迟 20 表示二进制 1 ,延迟 10 表示二进制 0

#!/usr/bin/env python3
"""在 GIF 帧延迟中隐藏消息"""
from PIL import Image
import random

def encode_message(message, output_path, width=10, height=10):
    """将消息编码到 GIF 帧延迟中"""
    binary = ''.join(format(ord(c), '08b') for c in message)
    binary += '00000000'  # 结束标记

    frames = []
    for bit in binary:
        # 生成随机噪点帧
        pixels = [(random.randint(0, 255),) * 3 for _ in range(width * height)]
        img = Image.new('RGB', (width, height))
        img.putdata(pixels)
        frames.append(img)

    # 保存为 GIF,每帧延迟编码一个 bit
    # 20 = 二进制 1, 10 = 二进制 0(单位:1/100 秒)
    delays = [20 if b == '1' else 10 for b in binary]
    frames[0].save(
        output_path,
        save_all=True,
        append_images=frames[1:],
        duration=delays,
        loop=0,
    )
    print(f'已将 "{message}" 编码到 {output_path}{len(binary)} 帧)')

def decode_message(gif_path):
    """从 GIF 帧延迟中解码消息"""
    img = Image.open(gif_path)
    binary = ''
    try:
        while True:
            delay = img.info.get('duration', 10)
            binary += '1' if delay >= 20 else '0'
            img.seek(img.tell() + 1)
    except EOFError:
        pass

    message = ''
    for i in range(0, len(binary), 8):
        byte = binary[i:i+8]
        if byte == '00000000':
            break
        message += chr(int(byte, 2))
    return message

if __name__ == '__main__':
    encode_message('Hello GIF!', 'secret.gif')
    decoded = decode_message('secret.gif')
    print(f'从 secret.gif 解码出: "{decoded}"')

运行后你会得到一个看起来像随机噪点的 GIF 动画,但帧延迟中隐藏了秘密消息。肉眼完全看不出区别。

当然,这种隐写术很容易被分析——只要检查 GIF 帧的时间间隔分布就能发现异常(正常动画的帧延迟应该是均匀的,而编码后的会出现双峰分布)。但作为一个 GIF 的奇怪用途 ,它足够有趣了。

小结

GIF 流是一项"复古"的技术,诞生于上世纪 90 年代。在今天 WebSocket 、WebRTC 、Server-Sent Events 大行其道的时代,它看起来已经过时了。但正是这种简洁性赋予了它独特的价值:

  • 零前端依赖 :不需要 JavaScript,一个 <img> 标签就够了
  • 极简协议 :基于 HTTP 长连接 + multipart/x-mixed-replace ,实现起来只需要几十行代码
  • 广泛兼容 :几乎所有浏览器都支持,甚至包括一些非主流的浏览方式

GIF 证明了"老技术"也可以玩出新花样。下次当你看到一张 GIF 图片时,也许可以想想:它还能做什么更奇怪的事?

如果你也发现了 GIF 的其他有趣用途,欢迎在评论区分享。

无主之地