Skip to main content

概述

kira-cdn 的媒体处理分四块,分别由 lib/ 下的模块负责:
能力模块引擎落盘?
图片元数据(width/height/blurhash)lib/image-meta.tsSharp + blurhash否(写 DB meta)
thumb / preview 变体lib/imgproxy.tsimgproxy(HMAC URL)否(CF edge cache)
图片水印lib/watermark.tsSharp composite{srcKey}.wm.jpg
视频水印 + endinglib/watermark-video.tsffmpeg(Bun.spawn){srcKey}.wm.mp4
CSAM 检测lib/csam.tsPhotoDNA + 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 / formatsharp(buffer).metadata()
  • blurhash — 缩到 32×32 RGBA raw → blurhash.encode(data, w, h, 4, 4)(componentX=4, componentY=4)
  • 失败时 width/height=0、blurhash=""(只 warn,不阻断注册)
Sharp 限制: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
thumbs:400:0 fit,q:80sign 响应 thumbUrl
previews:1024:0 fit,q:85sign 响应 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 格式:
{IMGPROXY_URL}/{signature}/{processing_options}/{base64url_source_url}{.ext}
签名(createHmac("sha256", key),key/salt 都是 hex 解码):
function sign(path: string): string {
  const key  = Buffer.from(IMGPROXY_KEY, "hex");
  const salt = Buffer.from(IMGPROXY_SALT, "hex");
  const hmac = createHmac("sha256", key);
  hmac.update(salt);
  hmac.update(path);          // path = /{processing}/{encoded}{.ext}
  return urlSafeB64(hmac.digest());   // base64 → 去 padding, +→- /→_
}
source URL 用 url-safe base64 编码(+-/_、去尾 =)。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,对齐 imgproxy MAX_SRC_RESOLUTION)
非 image mime → 400(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_*,finallyrm -rf 清理(失败也清)
非 video mime → 400(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:512 fit + f:jpeg 小图 URL:512px 够检测,payload 小,绕开 PhotoDNA Inline ~4MB 上限;PhotoDNA 只认 JPEG/PNG 故强制 jpeg。两个引擎各自去拉这个公开 URL(URL 模式,cdn 不做 resize/base64)
  • fail-open:网络/API 错误不阻断正常上传(记 ERROR log + recordCsamError metric → Dash0);两个 key 都没配 = 完全不检查
  • API:PhotoDNA https://api.microsoftmoderator.com/photodna/v1.0/Match(header Ocp-Apim-Subscription-Key,body { DataRepresentation: "Url", Value: url },Status.Code === 3000 为 OK,IsMatch === true 命中);OpenAI moderations API。各 60s 超时
CSAM 阈值 0.01 与 fail-open 策略是刻意的产品决策。register 后才检测,命中窗口内 row 短暂在 DB,但仅上传者本人可 sign(跨用户 owner 越权拦),删除失败整请求失败让 caller 重试,CSAM row 不残留。

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 / recordCsamErrorrecordWatermarkComposite。详见 可观测性