概述
kira-cdn 的媒体处理分四块,分别由lib/ 下的模块负责:
| 能力 | 模块 | 引擎 | 落盘? |
|---|---|---|---|
| 图片元数据(width/height/blurhash) | lib/image-meta.ts | Sharp + blurhash | 否(写 DB meta) |
| thumb / preview 变体 | lib/imgproxy.ts | imgproxy(HMAC URL) | 否(CF edge cache) |
| 图片水印 | lib/watermark.ts | Sharp composite | 是 {srcKey}.wm.jpg |
| 视频水印 + ending | lib/watermark-video.ts | ffmpeg(Bun.spawn) | 是 {srcKey}.wm.mp4 |
| CSAM 检测 | lib/csam.ts | PhotoDNA + OpenAI(并行) | — |
图片元数据(Sharp)
源:lib/image-meta.ts,在 POST /v1/assets 对 image mime 调用。
cdn 保证每个 image asset 都有完整 metadata —— caller 传的 width/height/blurhash 会被重算覆盖(设计原则:image meta 是 cdn 的职责)。video/audio 没有 Sharp 处理(无 ffprobe 在 meta 路径,由 worker 算好传 body)。
提取:
width/height/format—sharp(buffer).metadata()blurhash— 缩到 32×32 RGBA raw →blurhash.encode(data, w, h, 4, 4)(componentX=4, componentY=4)- 失败时 width/height=0、blurhash=""(只 warn,不阻断注册)
limitInputPixels: 50_000_000(~7000×7000,防恶意 PNG header 攻击)、failOn: "warning"。
检测出的 format 会覆盖 caller 传的 mime(如上传 .jpg 实际是 PNG → 存 image/png)。
imgproxy 变体签名
源:lib/imgproxy.ts。变体走 imgproxy URL 签名,客户端直接 GET,imgproxy 现算,Cloudflare edge cache 兜 LRU —— 零落盘、零 ops。
变体规格
buildVariantUrls() 在 /v1/sign/image 与 /v1/sign/video(thumb)生成:
| 变体 | imgproxy 参数 | 出处 |
|---|---|---|
| origin | 不走 imgproxy(R2/Supabase signed URL 原字节) | sign 响应 url |
| thumb | s:400:0 fit,q:80 | sign 响应 thumbUrl |
| preview | s:1024:0 fit,q:85 | sign 响应 previewUrl |
| blur 占位 | 不签 URL —— 用 DB 里 blurhash 字符串前端解码 | sign 响应 blurhash |
/v1/fit 额外支持 caller 指定 width / height / resizingType(fit\|fill) / enlarge / maxBytes(mb: 自动迭代质量压字节) / format / resizingAlgorithm(ra:)。
CSAM 检测也用 imgproxy 出 s:512 fit + f:jpeg 小图 URL 喂给检测引擎。
HMAC 签名算法
URL 格式:createHmac("sha256", key),key/salt 都是 hex 解码):
+→-、/→_、去尾 =)。IMGPROXY_URL = https://img.kira.art,IMGPROXY_KEY/IMGPROXY_SALT 是 hex(env 用正则 ^[0-9a-f]+$ 校验)。
源 signed URL 必须缓存复用(
resolveSourceUrl),否则每次签新 token → imgproxy URL 变化 → CF 缓存全 miss。kira-image(预定方案,未部署)
kira-image 是 thumb/preview/blur 变体的预定替代(Rust 内容寻址变体生成器,落共享 R2)。当前未部署、未接入 kira-cdn —— 现行变体路径仍是 imgproxy(img.kira.art)。接入前 imgproxy 是唯一变体来源。
水印
水印只在下载场景调用一次,不进 sign 响应路径。物理文件跟 source 同 backend、同目录,文件名加后缀。不靠 lifecycle,owen 自写 cron 按热度清。删 source 时同前缀的水印变体一并级联清。 每个端点用getOrSetJson 把整响应 Redis 缓存 24h(资源三元组 key),底层 ensureWatermark* 又是 file-based parallel cache(HEAD 命中直接签)。
图片水印(Sharp)
源:lib/watermark.ts,POST /v1/watermark。落 {srcKey}.wm.jpg。
- HEAD
{srcKey}.wm.jpg命中 → 直接 signed URL(7d) - miss → fetch 源 + Sharp composite +
putObject+ signed URL - logo PNG 从
cdn.kira.art/public/watermark/Kira_art_logo_watermark1.png取(进程内 promise cache 跨请求复用) - logo 缩到源图短边的 25%(最低 32px),贴右下角(offset 24px),
blend: over - JPEG 不支持透明 →
flatten({ background: "#ffffff" })压白底,jpeg({ quality: 88 }) - Sharp 限制
limitInputPixels: 150_000_000(~12000×12000,对齐 imgproxyMAX_SRC_RESOLUTION)
watermark only supports image assets)。
视频水印(ffmpeg)
源:lib/watermark-video.ts,POST /v1/watermark-video。落 {srcKey}.wm.mp4。
ffmpeg 在 cdn 容器内通过
Bun.spawn 跑(Dockerfile apt-get install ffmpeg),不是外部服务。CPU 密集,子进程隔离主线程。- HEAD 命中 → 直接 signed URL;miss → fetch 源 + ffmpeg composite +
putObject - 素材:logo PNG +
endingv2.mp4(Kira 品牌片段,从cdn.kira.art/public/watermark/取,进程内 promise cache) - ffprobe 取源视频 dimensions / 是否有音轨 / ending 时长(
Bun.spawn ffprobe) - logo 按源短边 /
BASE_WATERMARK_SIZE(4096)缩放,叠加在视频上 buildWatermarkConcatPlan()生成 filter_complex:logo overlay + 末尾 concat ending 片段(处理主/ending 音轨有无)- 输出
libx264/preset medium/crf 23/yuv420p/+faststart - tmp 目录
/tmp/wv_*,finally里rm -rf清理(失败也清)
watermark-video only supports video assets)。
CSAM 检测
源:lib/csam.ts,在 POST /v1/assets 对 image mime 触发(register 之后查)。
- 双引擎并行:PhotoDNA(NCMEC/CCA hash 匹配)+ OpenAI
omni-moderation-latest(sexual/minors分类器),Promise.allSettled并行 —— 一边抛错不丢另一边命中 - 任一命中即阻断:
hitOpenAI || hitPhotoDna→ asset 删除 +451 { errorCode: "csam_blocked" } - 阈值
CSAM_THRESHOLD = 0.01(ultra-conservative,刻意如此) - 输入 caller 传 imgproxy
s:512fit +f:jpeg小图 URL:512px 够检测,payload 小,绕开 PhotoDNA Inline ~4MB 上限;PhotoDNA 只认 JPEG/PNG 故强制 jpeg。两个引擎各自去拉这个公开 URL(URL 模式,cdn 不做 resize/base64) - fail-open:网络/API 错误不阻断正常上传(记 ERROR log +
recordCsamErrormetric → Dash0);两个 key 都没配 = 完全不检查 - API:PhotoDNA
https://api.microsoftmoderator.com/photodna/v1.0/Match(headerOcp-Apim-Subscription-Key,body{ DataRepresentation: "Url", Value: url },Status.Code === 3000为 OK,IsMatch === true命中);OpenAI moderations API。各 60s 超时
Tracing
CSAM 每个 provider 包一个 INTERNAL span(csam.openai.check / csam.photodna.check),便于在 trace 上看两边并行耗时;出站 HTTP 由 installFetchTracing() 自动起 CLIENT span(api.openai.com / api.microsoftmoderator.com)。fail-open 路径不把 wrapper INTERNAL span 标 ERROR(避免与 CLIENT span ERROR 双重计数)。指标:recordCsamCheck / recordCsamBlocked / recordCsamError、recordWatermarkComposite。详见 可观测性。