架构概览
核心组件
| 组件 | 作用 | 配置 |
|---|
| Supabase Storage | 原图存储 | agent_message bucket |
| Redis (Dragonfly) | Signed URL 缓存 | TTL: 30 天 - 1 小时 |
| imgproxy | 动态图片处理 | 缩放、模糊、格式转换 |
| Cloudflare CDN | 边缘缓存 | max-age=2592000 (30 天) |
上传流程
Frontend (Web Worker)
前端使用 Web Worker 处理图片,避免阻塞主线程:
// 1. 修复图片方向 + 计算 blurhash
const { blob, width, height, blurhash } = await processImageWithWorker(file);
// 2. 上传到 Supabase Storage
const fileId = `${userId}/${threadId}/insert_${localFileId}`;
await supabase.storage.from("agent_message").upload(fileId, blob);
// 3. 调用后端处理接口
await dealUploadedImage(fileId, { blurhash, width, height });
Web Worker 使用 OffscreenCanvas 计算 blurhash,Safari 不支持时自动 fallback 到主线程。
Backend 处理
前端上传时带上 metadata 后,后端通过 GET /image/:imageId 读取:
// 从 storage metadata 读取(无需下载文件)
const { data } = await supabase.storage.from("agent_message").info(imageId);
const width = data.metadata?.width;
const height = data.metadata?.height;
const blurhash = data.metadata?.blurhash;
Blurhash
Blurhash 是一种将图片编码为 20-30 字符字符串的算法,用于生成模糊占位图。
计算方式
// 后端使用 sharp 获取原始像素数据
const { data, info } = await sharp(buffer)
.resize(32, 32, { fit: "inside" })
.ensureAlpha()
.raw()
.toBuffer({ resolveWithObject: true });
// 编码为 blurhash (4x4 components)
const hash = encodeBlurhash(
new Uint8ClampedArray(data),
info.width,
info.height,
4, 4
);
// 结果示例: "LEHV6nWB2yk8pyo0adR*.7kCMdnj"
Blurhash、width、height 存储在 Supabase Storage 文件的 metadata 中:
// 前端上传时直接带上 metadata
await supabase.storage.from("agent_message").upload(fileId, blob, {
metadata: {
width: width.toString(),
height: height.toString(),
blurhash: blurhash,
},
});
// 后端通过 info() 读取 metadata(无需下载文件)
const { data } = await supabase.storage.from("agent_message").info(fileId);
const blurhash = data.metadata?.blurhash ?? "";
API 响应
所有返回 Image 对象的接口都包含 blurhash 字段:
// GET /image/:imageId, GET /versions/:threadId, GET /feed, etc.
{
imageId: "user123/thread456/gen_abc.png",
url: "https://xxx.supabase.co/...",
blurUrl: "https://img.kira.art/...", // imgproxy 生成的模糊图
thumbUrl: "https://img.kira.art/...", // imgproxy 生成的缩略图
blurhash: "LEHV6nWB2yk8pyo0adR*.7kCMdnj", // 客户端即时解码
width: 1024,
height: 1024
}
解码显示
前端使用 blurhash 库解码并绘制到 canvas:
import { decode } from "blurhash";
const pixels = decode(blurhash, 32, 32);
const ctx = canvas.getContext("2d");
const imageData = ctx.createImageData(32, 32);
imageData.data.set(pixels);
ctx.putImageData(imageData, 0, 0);
PixiJS 集成
在 PixiJS 画布中使用 blurhash 作为即时占位符:
import { blurhashToDataURL } from "@/lib/utils";
// 1. 先异步加载 blurhash(data URL,无网络请求,极快)
if (currentImage.blurhash) {
const blurDataUrl = blurhashToDataURL(currentImage.blurhash);
Assets.load({ src: blurDataUrl, parser: "loadTextures" })
.then((blurTexture) => {
// 只有当原图还未加载时才显示 blur
if (!sprite.texture?.source?.resource) {
sprite.texture = blurTexture;
}
});
}
// 2. 异步加载原图(替换 blur)
Assets.load({ src: currentImage.url, parser: "loadTextures" })
.then((texture) => {
sprite.texture = texture;
});
Blurhash 比 blurUrl 的优势:无需额外网络请求,客户端即时解码生成占位图,用户体验更流畅。
imgproxy
imgproxy 提供动态图片处理,避免预生成多个版本。
URL 签名
function buildImgproxyUrl(sourceUrl: string, options: ImgproxyOptions) {
// 构建处理参数
const params = [];
if (options.width || options.height) {
params.push(`rs:fit:${options.width ?? 0}:${options.height ?? 0}`);
}
if (options.blur) {
params.push(`bl:${options.blur}`);
}
// 签名路径 (HMAC-SHA256)
const path = `/${params.join("/")}/plain/${encodeURIComponent(sourceUrl)}@webp`;
const signature = signImgproxyPath(path);
return `https://img.kira.art/${signature}${path}`;
}
生成的 URL 类型
| 类型 | 参数 | 用途 |
|---|
| originUrl | 无 | 原图 (webp 格式) |
| thumbUrl | rs:fit:400:0 | 400px 宽缩略图 |
| blurUrl | rs:fit:20:0, bl:5 | 20px 模糊图 |
缓存策略
Supabase signed URL 每次生成都不同(JWT 包含 iat/exp 时间戳),导致:
- imgproxy 签名变化
- Cloudflare 认为是不同 URL → 永远 MISS
解决方案
Redis 缓存 signed URL,确保同一文件返回相同 URL:
const SIGNED_URL_TTL = 30 * 24 * 60 * 60; // 30 天
async function getMessageFileUrl(fileId: string) {
const cacheKey = `signed_url:${fileId}`;
// 1. 查 Redis 缓存
const cached = await redis.get(cacheKey);
if (cached) return cached;
// 2. 生成新的 signed URL (30 天有效)
const { data } = await supabase.storage
.from("agent_message")
.createSignedUrl(fileId, SIGNED_URL_TTL);
// 3. 缓存 (比 URL 有效期短 1 小时)
await redis.set(cacheKey, data.signedUrl, "EX", SIGNED_URL_TTL - 3600);
return data.signedUrl;
}
TTL 对齐
| 层级 | TTL | 说明 |
|---|
| Supabase signed URL | 30 天 | URL 本身的有效期 |
| Redis 缓存 | 30 天 - 1 小时 | 避免返回快过期的 URL |
| imgproxy Cache-Control | 30 天 | max-age=2592000 |
| Cloudflare CDN | 30 天 | 遵循 Cache-Control |
缓存流程
批量操作
对于 Feed 列表等场景,使用批量函数减少 Redis 往返:
async function batchGetMessageFileUrls(fileIds: string[]) {
// 1. Redis MGET 批量查询
const cacheKeys = fileIds.map(id => `signed_url:${id}`);
const cached = await redis.mget(...cacheKeys);
// 2. 找出未命中的
const missed = fileIds.filter((_, i) => !cached[i]);
// 3. 批量生成 + 批量写入 Redis
const newUrls = await Promise.all(
missed.map(id => supabase.storage.createSignedUrl(id, TTL))
);
// 4. Pipeline 写入 Redis
await Promise.all(
newUrls.map(({ fileId, url }) =>
redis.set(`signed_url:${fileId}`, url, "EX", TTL - 3600)
)
);
}
通过 metrics 监控缓存命中率:
recordCacheHit("signed_url"); // 单个 URL 缓存命中
recordCacheMiss("signed_url"); // 单个 URL 缓存未命中
recordCacheHit("signed_url_batch"); // 批量查询命中
recordCacheMiss("signed_url_batch"); // 批量查询未命中
文件存储结构
agent_message/
├── {userId}/
│ ├── {threadId}/
│ │ ├── insert_{uuid} # 用户上传的原图
│ │ ├── gen_{uuid} # AI 生成的图片
│ │ ├── mask_{uuid} # 遮罩图
│ │ └── expand_{uuid} # 扩展图
│ └── temp/
│ └── insert_{uuid} # 临时上传
└── feed/
└── {uuid}/
├── insert_{uuid} # Feed 图片
└── watermark_{uuid} # 带水印版本
imgproxy 环境变量
IMGPROXY_URL=https://img.kira.art
IMGPROXY_KEY=<hex-encoded-key>
IMGPROXY_SALT=<hex-encoded-salt>
IMGPROXY_TTL=2592000 # 30 天 Cache-Control
Cloudflare Cache Rules
在 Cloudflare Dashboard 配置 img.kira.art 域名:
- Cache eligibility: Eligible for cache
- Edge TTL: Use origin Cache-Control header
- Browser TTL: Use origin Cache-Control header