fix(image): 支持 HEIC/HEIF 上传,并堵住静态图被误当视频处理的静默路径 - #505
Conversation
iPhone 相机默认存 HEIC,而 cv2.imdecode 解不了它:住户把照片 AirDrop 到电脑再上传、或 Agent 走 pet observe --images 读本地文件,都会被 400 拒掉(已在部署机复现两个端点)。 - 新增 identity/_image_utils.decode_image():cv2 快路径(jpg/png/webp/bmp/tiff/gif/avif)→ Pillow(pi-heif) 回退。接到 6 处「用户原字节」入口:pet observe 的主路径与 _first_decodable(必须成对,否则主路径解得开而回退解不开,会谎报「画面确无动物」)、pet 头像/参考图端点、person 的 _decode_image_upload / _decode_b64_image / extract / register-preview / select。 - 方向:不能用 pi_heif 的 info["original_orientation"] 自己转——libheif 已按 irot 转好时该键仍带原值,拿它转会把 iPhone 竖拍 HEIC 再转一次变成横图(实测踩到并修掉)。已用 sips 导出 JPEG 作参照做像素级比对,相关系数 0.9998。 - 新增 _avatar.normalize_for_storage():jpg/png/webp 原字节直通(「存储层零转码」这条既有承诺有测试钉住),其余解码后重编——头像走无损 WebP,宠物参考图走 JPEG q90(唯一消费者 omni 恒收 JPEG q85,且 ref_crop_N.jpg 后缀是硬编码、不宜与内容脱钩)。带 imencode 的 ok 判定:WebP 边长 >16383 时它返回 ok=False 且只往 stderr 打一行,不判就会把空字节当图落盘。 - 行为扩面:BMP/TIFF/GIF/AVIF 原先能过 observe 却被头像/参考图端点 400,现一并接受(归一化成 webp)。这条不对称正是本轮一半文案要改的根源。相应改 test_person_router_avatar 里那条断言 400 的 BMP 用例。 - 堵一条今天就在损坏的自伤闭环:CLI 的 _looks_like_video_bytes 只看「字节 4..8 == ftyp」,把 HEIC/HEIF/AVIF 全判成视频,报错文案又指示 agent 改用 --video;而 person register/preview 无条件相信客户端自报的 media_kind,于是 HEIC 进 extract_from_video → ffmpeg 不拼 HEIC 的 tile grid、只暴露 512x512 瓦片 → 200「no valid subject」,零提示。新增 is_still_image_container() 按 brand 区分,接到 CLI 判据、pet observe 读字节后复核、person register/preview 复核三处;两个 SKILL.md 的话术同步(Agent 照它告诉住户能传什么)。 - 解码后像素量加上限(_MAX_DECODE_PIXELS):字节闸挡不住解码炸弹——HEIF 的网格容器能让小文件解出上亿像素。 - 依赖选 pi-heif 而非 pillow-heif:两者同作者、同样封装 libheif,但 pi-heif 只带解码器(libde265),不捆绑 x265 编码器,PyPI 分类器因此是 LGPLv3 而非 GPLv2,而我们只需要解码。已核实 1.4.0 在 py3.11-3.14 × darwin/linux/windows 五个平台均有 wheel、不会退化成源码编译(部署机的 tool env 是 py3.14),且注册后不影响 Pillow 12 自带的 AVIF 插件。ffmpeg 走不通:它把 HEIC 的 tile grid 当成几十条独立 512x512 视频流暴露、不做拼接。 - 测试 30 例:HEIC 解码、既有 7 种格式不回归、白名单逐字节直通、webp 超限退 JPEG、像素炸弹、brand 判定表(heic/heix/mif1/msf1/avif 判图,isom/mp42/qt/3gp 判视频)、两侧误路由回归。均已负向验证(退回旧实现会红)。
|
👋 感谢提交 PR @ExWang!维护者会尽快 review。 提交前请确认:
|
[PR #505]: fix(image): 支持 HEIC/HEIF 上传,并堵住静态图被误当视频处理的静默路径作者: ExWang 修改方案要解决的问题 iPhone 自 iOS 11 起相机默认存 HEIC,而后端所有图片入口都只走 OpenCV 解码,解不了这个格式——照片经 AirDrop / 数据线 / iCloud 落到电脑再上传(以及 Agent 走 CLI 读本地文件)时全线 400。修这个问题时顺带查出一条独立成立的静默损坏路径:HEIF 与 mp4/mov 共用同一套 ISO BMFF 容器,只看「第 4~8 字节是 整体方案 — 6 条正交主线 主线 1:解码收成单一入口,四层防线兜住「只返 None、绝不抛」
主线 2:按容器品牌判形,三处复核客户端自报的类型
主线 3:落盘前先验真,再按用途归一化
主线 4:解码一律离开事件循环,并用一条扫源码的测试钉住
主线 5:抹平「观测宽、存储窄」的不对称,文案随之收口
主线 6:依赖选 pi-heif 而非 pillow-heif
主线之间的依赖
关键设计原则
测试覆盖
净新增 37 个测试函数(38 增 1 删)、7 处参数化展开,与 PR 描述所声明的数字一致。 问题本轮未发现新问题。上一轮 ci-bot review 的两条 🔵 对账如下:
代码部分自上轮 review 后无改动(HEAD 仍是
结论LGTM — HEIC 支持与误路由修复各自独立成立、边界(异常、炸弹、编码失败、缺依赖、体积闸重过)都有代码级交代,三处「必须同步」的隐性约定各配了钉死它的测试;上一轮两条 🔵 均已在 PR 描述中处理。 由 review-pr skill v1.6 生成 |
上一版把落盘验真委托给 normalize_for_storage,但该函数先嗅魔数、命中即原样返回,**不经过解码**——于是 b"\xff\xd8\xff" + 垃圾字节(传输截断、拷了一半的照片)会被当成合法 jpg 落盘并计入 reference_crop_count,识别侧解不出再静默跳过,正好复现参考图端点注释点名要防的「显示 3 张、实际注入 0 张」。改前三个端点本来就先 cv2.imdecode 验真,所以把解码提到魔数嗅探之前不是新增成本;非直通格式复用这次解码结果去重编,全程只解一次。 - _MAX_DECODE_PIXELS 原先只在 Pillow 回退分支里检查,而注释点名的 PNG 炸弹走的正是 cv2 快路径。快路径补上同一道闸;cv2 无懒加载,那一次分配躲不掉,拦的是它继续进 YOLO / omni / 编码链路被反复复制放大——注释口径相应从「挡住解码炸弹」改为「挡住炸弹进入下游」。 - person extract_samples 补上字节级复核:pet observe 与 register_preview 都已按 ftyp brand 掰正被谎报成视频的静态图,这个端点漏了。三处必须同口径,缺一处就留一个同形状的洞。 - _HEIF_OK 落到实处(CodeQL py/unused-global-variable #610/#611 报得对,此前只赋值不读):decode_image 在 cv2 失败后若认出是 HEIF 家族容器而解码器不可用,就地打 event=heif_upload_without_decoder。原先只在 import 期打一次 warning,等住户几天后传 HEIC 失败时那条早已滚走,现场只剩一句笼统的「打不开」。 - CLI pet avatar 的 --image help 仍写着旧白名单 jpg/jpeg/png/webp——Agent 读 --help 决定能传什么,功能通了入口话术没通。 - 测试:新增「合法魔数 + 坏 body」三例(原有用例只测了无魔数字节,拦不住这条回归)、cv2 快路径像素闸三例、缺解码器时现场留日志、解码器缺失不牵连既有格式;把「webp 超限退 JPEG」拆成尺寸预检与 imencode ok=False 两条,后者用 mock 才真正覆盖到。均已负向验证。 订正上一个 commit 的一处计数:那条 message 写「接到 6 处用户原始字节入口」,实际列举已是 7 项且漏列 upload_person_avatar,diff 里的调用点约 12 处。历史不改,在此备注。
- web i18n 的 partial_decode_failed 仍写着「仅支持 jpg/png/webp」。上一版改了 SKILL.md 里的同一句却漏了这份,而 PetAutoGenFlow 用 t(key, {defaultValue: w.message}) 渲染——key 存在时 i18n 覆盖后端 message,住户看到的就是过期文案,会被引去做无用的格式转换。zh/en 两份同步。
- 缺 pi-heif 时的短路收窄到真正需要 libheif 的 brand:AVIF 同为 ftyp 容器但由 Pillow 自带插件解,一并短路会凭空砍掉一种本可用的格式。拆出 _HEIF_ONLY_BRANDS 与 _AVIF_BRANDS,判形仍用二者并集(都不得进视频抽帧路径)。普通 AVIF 走 cv2 快路径、到不了这个分支,所以测试里把 cv2 打成解不出来逼它走回退,否则这条修复没有任何用例能触发。
- pet observe 掰正后按图片档重卡体积:上面按视频档放过了 100MB,改判成图片却不重卡,「谎报成 video」就成了绕过 15MB 图片闸的口子(像素闸能兜住内存,请求体上限该由这里守)。
- brand 表测试改成遍历表本身全覆盖(原先两侧各只抽查 6~7 个 / 共 14 个),并新增一条「CLI 与后端两份手工表必须一致」的结构性护栏——两份表在不同进程里无法共享常量,错位会造成「CLI 放行、后端当视频」。
- normalize_for_storage 的 docstring 写明 prefer 只管重编那一支:白名单恒原字节直通,故 prefer="jpg" 下一张 PNG 参考图仍以 PNG 字节存进 ref_crop_N.jpg。读取方都按内容而非后缀判型,这是刻意保留的既有行为。
_prepare_crops 的返回值里 _first_decodable(medias) 是无条件求值的,而这个兜底帧只在「检测器一个目标都没框到」时才会被用上。改动前那多出来的一次是 OpenCV 解 JPEG、可忽略;引入 HEIC 支持后它走 Pillow + libheif,一张 12MP 的 iPhone 照单次解码是几百毫秒量级,而这段跑在 asyncio.to_thread 里、同进程还并行着直播转码与感知推理。改为在既有循环里顺手记下第一张解得开的画面:语义等价(batch 与 medias 同序,且「全解不出」在此之前已抛 MediaDecodeError),实测 3 张 HEIC 的 decode_image 调用从 4 次降到 3 次。 _first_decodable 随之失去生产调用方,连同测试里 6 处已失效的 monkeypatch 一并删除——那些 patch 现在打在没人调的函数上,留着会让后来人以为兜底帧还从那里来。 另补强 brand 表的回归钉子:上一版只有「遍历表本身」+「CLI 与后端两份表必须相等」,抓得住单边漂移,抓不住「两边同时删掉同一个 brand」——那时循环变短、交叉检查仍相等,两侧全绿。两侧各加一条整表字面量快照,已负向验证(同时删掉 mia1 后两边都红)。
325e927 把兜底帧从「return 时重解第一张」改成「循环里留存」,是这个 PR 里唯一没配回归钉子的改动。补的用例同时覆盖两点: - 语义:兜底帧是循环内**第一个解得开的**画面,不是 medias[0]。既有用例的 imdecode 替身是「第 1 次成功、第 2 次 None」,first_ok 仍与下标 0 重合——恰好绕开了不重合的那一半,新用例让首图解不开、断言兜底帧取第二张。 - 次数:3 张图就该解 3 次。改回 decode_image(medias[0]) 那种写法整套测试仍会全绿,而省下的那次解码在 HEIC 下是几百毫秒量级(libheif,且跑在 to_thread 里与直播转码抢 CPU)。已负向验证:改回旧写法该用例变红。 normalize_for_storage 是本 PR 新增的公开 API、已被三个端点调用,却漏在 __all__ 外。三处都是属性访问,行为不受影响,只是这份「本模块对外提供什么」的索引开始漏项。
对整个 PR 做了一次多视角对抗审查(正确性/安全/一致性/测试/模拟 reviewer 五路并行,每条发现再单独证伪),41 条发现里 16 条经验证成立,去重为下面几组。 - **cv2.imdecode 会抛而不是返 None**:OpenCV 对「像素 > 1<<30 或边长 > 1<<20」是 CV_Assert。一个 29 字节的 GIF 头就能声明 40000x40000 触发它,字节闸放行、异常穿透全部 9 个接入点,把本该 400 的请求变成 500——而 decode_image 的 docstring 承诺「解不出返回 None(不抛)」,同函数的 Pillow 回退分支也确实守着这条纪律,只有快路径没守。落成 img=None 而非 return None:后面的 HEIF 短路与 Pillow 回退还要跑。 - **重编产物没有上界**:改前非白名单格式一律 400,落盘物件被 5MB 上传闸隐含夹住;本 PR 放开了格式却没补回这个上界。实测 24MP HEIC 无损 WebP 是 15.6MB / 9.7s,而这些字节的唯一消费者是 28~48px 的圆头像(前端整个 blob fetch、cache: no-store)与 omni 的 320px 拼图。重编前长边封顶 1024:12MP 那张从 5.26MB / 2.6s 降到 0.56MB / 0.37s。封顶只作用于重编支,白名单直通仍逐字节不动。顺带:封顶让 _WEBP_MAX_SIDE 预检变成走不到的死分支,删掉(imencode 的 ok 判定保留,它还兜编码器 OOM)。 - **归一化在 async 端点里同步直调**:解码 + 重编是纯 CPU 活,本仓别处对这类活儿一律包 asyncio.to_thread(person extract 那几处),这三个端点是例外。补上。 - **四条关键路径零钉子**:HEIC 回退的 BGR 通道序(删掉 cvtColor 后 132 条用例全绿——红蓝调换无人发现,而毛色是识别核心特征)、observe 是否真走 decode_image(既有用例全 mock cv2.imdecode,改回裸 cv2 照样全绿)、超大图不抛、preview 用例别裸吞 HTTPException(原先连「HEIC 没解开」也吞)。四条均已负向验证。 - Pillow 的 DecompressionBombError 归入预期拒绝、不再打整段 traceback;_MAX_DECODE_PIXELS 的注释写清它是事后闸而非内存峰值上界(真实天花板是 OpenCV 的 1<<30 px)。 - 六处文案与新行为对齐:SKILL 异常表、人侧两处 OpenAPI description、两处测试注释、以及把 b"heic-bytes" 这个已失真的桩数据名改掉。
上一版把重编产物封到 1024 是对的方向但数字太松:一张 12MP HEIC 仍产出 560KB,而消费端是 28~48px 的圆头像、前端把整个 blob fetch 下来且 cache: no-store,比 web 主路径产出的常规头像(256x256 JPEG,20-50KB)高一个量级。 改为按用途分档,两个数字都锚在真实消费端而非拍脑袋: - 头像 256 —— 正是 web 裁剪器 AvatarCropEditor 的 OUT。让 CLI/API 直传这条路与 web 主路径产出同规格的东西,而不是各走各的。实测 12MP HEIC 从 560KB 降到 50,572 字节,与 web 那条同量级。新增一条跨端对齐测试:改任一侧而不改另一侧就会红,「头像该多大」以后有唯一答案。 - 参考图 640 —— 它喂 omni 的横拼图(pet_refs 统一缩到高 320),640 留一倍余量给非方形 crop;不能与头像同档,否则拼图会被放大糊掉。 顺带订正一处自相矛盾的论证:既然已经缩放丢掉 94% 的像素,再以「避免叠加第二代损失」为由坚持无损就说不通了。无损保留,但理由改成站得住的那个——这一档体积已落在 web 主路径同量级,无损换来的是两条入口口径统一。
371a7b9 把重编封顶拆成头像 256 / 参考图 640,但有两处文字没跟上:WebP 边长那段仍写「下面的长边封顶(1024)」,normalize_for_storage 的 docstring 也没提两档取值。补齐并指向常量处的取值依据(web 裁剪器的 OUT / omni 拼图的统一高度),免得下一个人以为这两个数字是随手定的。纯注释,零行为变更。
上一版只在三个存储端点上把「解码 + 重编」挪进工作线程,却在注释里写「同 person extract_samples 的处理」、commit message 写「本仓别处一律包 to_thread」——而被引用的那处恰恰只包了推理、解码是裸的同步直调。注释和事实不符,且漏掉的正是本 PR 让它变贵的那一环:改前这些路径只解 jpg/png(几十毫秒),格式放开后 HEIC 走 libheif 是几百毫秒量级;register/preview 的多图批量分支逐张同步解,而 media_b64_list 只有 docstring 一句「建议 ≤10」、代码不校验,也没有请求体体积闸——10 张 12MP HEIC 能把事件循环连续占住约 3 秒,期间直播画面卡住、感知事件积压。 - 人像侧 7 处解码统一 await asyncio.to_thread:_decode_image_upload、_decode_b64_image、extract_samples、register/preview 的单图与多图批量、extract 端点、select 端点。其中 _decode_b64_image 原为同步函数,唯一调用方 register_sample_batch 本就是 async,整体改 async 而非在调用点包一层,免得留下同步孔。 - 三处 to_thread 注释的措辞订正为事实。 - 新增 test_no_sync_decode_image_in_routers:扫 router 源码,出现未经 to_thread 的 decode_image( 直接红。靠人读注释守不住这条口径——上一轮就是这么漏的。已负向验证:改回任一处裸调该用例失败。
两条都是上一轮 review 挑出的、我自己留下的问题: - 护栏有个真漏洞:它只盯 decode_image,而三个存储端点丢进工作线程的其实是 normalize_for_storage(解码 + 重编两步,比裸解码更贵)。将来新加一个头像类端点裸调它,护栏照样全绿——而那正是这条护栏写来防的事。两个符号并入同一条正则,字符类里带 . 以覆盖 `_avatar.normalize_for_storage(` 这种带模块前缀的写法。已负向验证:把 pet 头像端点改回裸调,护栏现在会红(旧版抓不到)。 - pet/observe.py 原先"扫完再把结果整文件丢掉"来表达豁免。豁免本身没错(唯一解码点在 _prepare_crops,整段已由 observe_pet 包在 to_thread 里,逐行扫全是假阳性),错的是这个形状——读代码的人会以为它也在覆盖范围内。改成直接不进扫描列表,理由写在注释里。 - 头像常量的论据是错的:注释写「展示端是 28~48px 的圆」,实测调用点是 24~96(FamilyStrip 34 / HeroNow 28 / ProfilePanel 48 / Drawer 96),按注释自己的 3x 算法 96×3=288 > 256,结论悬空。论据换成站得住的那个:256 的依据不是「展示多大」,而是「与 web 裁剪器 AvatarCropEditor.OUT 逐字对齐」——web 上传的每张头像本来就恒为 256×256,那点 3x 屏欠采样是既有行为、两条入口一视同仁;真要提高得两侧一起调,有对齐测试守着。 - 同段「让 CLI/API 直传这条路与 web 产出同规格」措辞偏宽:只有重编支产出 256,白名单直通的长边完全不受限。docstring 里限定清楚。
上一轮给三处调用加 to_thread 注释时,参考图那处整段抄了头像的措辞,说这里做「无损 WebP 编码」——而它传的是 prefer="jpg"、从不走 WebP。同一段上面两行还写着「存 webp 只是多一道转换」,自相矛盾。改一句话,两处注释现与各自的 prefer 实参一致。
|
/allow-dependencies-change 08256fd |
|
/review |
要解决的问题
iPhone 相机自 iOS 11 起默认存 HEIC,而
cv2.imdecode解不了它。已在部署机复现,两个端点都是硬失败:pet observe --images x.HEIC(Agent 注册主流程)400 无法解码上传的图片(仅支持常见 jpg/png/webp 格式)pet avatar --image x.HEIC400 无法识别的图片命中面是「照片先落到电脑再上传」——AirDrop 到 Mac、数据线/iCloud 下载、以及 Agent 走 CLI 读本地文件,这三条都保留 HEIC。在 iPhone 上直接用 web 传通常不受影响(iOS 相册选择器一般会转 JPEG)。
另一件事:一条今天就在损坏的静默路径
这条与 HEIC 支持没有因果关系,是修上面那个问题时顺着查出来的,独立成立:
HEIF/AVIF 与 mp4/mov 共用同一套 ISO BMFF 容器,只靠「字节 4..8 ==
ftyp」判不出图与视频。而这个误判会被 CLI 的报错文案和 SKILL 的纠错表放大成一次「自我纠正」,把照片送进视频抽帧路径。ffmpeg 把一张 3024×4032 的 HEIC 暴露成 61 条独立 hevc 流(60 块 512×512 瓦片 + 1 张 320×240 缩略图),不做 grid 拼接,于是流水线在一块瓦片上跑完全程、不报任何错。Agent 于是告诉住户「没识别到人,换张素材」——这是假话,真实原因是格式被误路由。
修法是按容器品牌区分,接在三处:CLI 判据、
pet observe读到字节后复核、person register/preview复核客户端自报的media_kind。两个 SKILL.md 的话术同步更新(Agent 照它告诉住户能传什么)。改动
解码单一入口 — 新增
identity/_image_utils.decode_image():cv2快路径(jpg/png/webp/bmp/tiff/gif/avif)→ Pillow(pi-heif) 回退。接到全部 9 处「用户原始字节」入口(person/router.py7 处 +pet/observe.py1 处 +_avatar.normalize_for_storage1 处,后者被三个存储端点共用)。它对外的契约是「解不出返回
None、不抛」,两条支都守住:cv2.imdecode对超出 OpenCV 自身尺寸上限的图是CV_Assert抛cv2.error(一个 29 字节的 GIF 头就能声明 40000×40000 触发),不接住会让这 9 个入口把本该 400 的请求变成 500。解码本身一律经
asyncio.to_thread:格式放开后 HEIC 走 libheif 是几百毫秒量级(改前只解 jpg/png 是几十毫秒),而后端是单进程 asyncio、同一个循环上还跑着直播转码 / 录制切片 / MQTT 感知推理。这条口径由一条扫源码的测试钉住,新增裸调直接红。落盘归一化 — 新增
_avatar.normalize_for_storage():jpg/png/webp 原字节直通(先完整解码验真——只查魔数不够,\xff\xd8\xff后接垃圾同样命中前 3 字节,直通落盘后识别侧解不出、界面会显示「3 张参考图」而实际注入 0 张)。其余格式解码后重编,并按用途封顶长边:AvatarCropEditor.OUT,让 CLI/API 的重编支与 web 主路径产出同规格;有跨端对齐测试守着,改一侧不改另一侧就红。实测 12MP HEIC:不封顶 5.26MB、封到 1024 是 0.56MB、封到 256 是 50KB,与 web 裁剪器产物的 20–50KB 同量级。改前非白名单格式一律 400,落盘物件被 5MB 上传闸隐含夹住;本 PR 放开了格式,就得自己补回这个上界。pet_refs拼图时恒缩到高 320 并重编 JPEG q85——640 留了一倍余量,存 WebP 只是多一道转换;且ref_crop_N.jpg的硬编码后缀牵动 glob 与下标解析,不宜与内容脱钩。封顶只作用于重编支,白名单直通仍逐字节不动。
行为扩面 — BMP/TIFF/GIF/AVIF 原先能过
observe却被头像/参考图端点 400。这条「observe 宽、存储窄」的不对称正是本轮一半文案要改的根源,一并消掉(归一化成 webp)。相应改了test_person_router_avatar里那条用真 BMP 断言 400 的用例。资源与安全 — 解码后像素量加上限:字节闸挡不住解码炸弹,HEIF 的网格容器能让小文件解出上亿像素。
imencode加ok判定:WebP 边长 >16383 时它返回ok=False且只往 stderr 打一行,不判就会把空字节当图落盘。依赖选型
选 pi-heif 而非更常见的 pillow-heif:
我们只需要解码,拿不到编码器零损失。已核实:1.4.0 在 py3.11–3.14 × darwin/linux/windows 五个平台均有 wheel、不会退化成源码编译(部署机的 tool env 是 py3.14);
register_heif_opener()不影响 Pillow 12 自带的 AVIF 插件。绕不开这个依赖:ffmpeg 只暴露瓦片(见上一节),Pillow 12 的
features.check("heif")是False。一个副作用值得记:pi-heif 不能编码 HEIF(正是它 LGPLv3 的原因),所以测试无法现造 HEIC fixture,用了 466 字节的 base64 常量内嵌,不引二进制资产。
验证
部署机端到端复测(改动已装到 tool env,
pi_heif+decode_image都在已装 wheel 里):pet observe --images x.HEICcode: 0,warnings: [](图确实被解开)pet avatar --image x.HEICavatar_ext: "webp"identity register preview --image x.HEICcode: 0,走图片路径同一只狗的照片,HEIC 与 JPEG 各跑一遍完整链路(解码 → YOLO → 门控 → omni):
检测置信度两边都是 0.957,crop 尺寸一致;描述末尾用词差异来自模型生成的随机性。
朝向做了像素级比对:以
sips导出的 JPEG 走 cv2 作参照,相关系数 0.9998,三个旋转对照都趋近 0。这里踩过一个坑并修掉——起初按 pi-heif 的info["original_orientation"]自己转向,结果把苹果 HEIC 转了两次变成横图(libheif 已按irot转好,而该键仍带原值)。已把「为什么不能信这个键」写进 docstring。测试 新增 37 个测试函数(含 7 处
parametrize展开):HEIC 解码与 BGR 通道序、既有 7 种格式不回归、白名单逐字节直通、合法魔数 + 坏 body 必拒、超大图不抛、像素炸弹(快路径与回退各一组)、重编封顶分档、brand 判定表全覆盖 + CLI 与后端两份手工表的字面量快照与交叉一致性、两侧误路由回归、以及「解码不得留在事件循环上」的结构性护栏。均已负向验证——退回对应实现后各自会红。后端 3284 passed(4 个失败已确认在未改动的
main上同样红)、CLI 579 passed、ruff check全绿。已知限制
AVIF 竖拍可能仍是歪的:cv2 能解 AVIF 但不应用它的 EXIF Orientation(实测 4.13),而 AVIF 走 cv2 快路径、回退不触发。属既有行为,本 PR 不改快路径判据以免动到所有既有格式的解码结果。
非苹果 HEIF 的 EXIF-only 方向不支持:若某个编码器只用 EXIF 记方向、不写
irot,pi-heif 的无条件重置会让它解出来是歪的,且无法与「已转好」区分。取舍上保住绝对多数的苹果 HEIC。Web 手动换头像仍不支持 HEIC:那条路是
AvatarCropEditor用<img>+ canvas 渲染源文件,Chrome/Firefox 解不了 HEIC(Safari 17+ 可以),且它恒导出 256×256 JPEG 才发给后端——所以后端改动对它无效,需要前端配合,不在本 PR 范围。Web 上传 HEIC 做观测:能传通,但看不到本地缩略图。
PetAutoGenFlow对图片走FileReader.readAsDataURL后直接把data:image/heic;base64,…交给<img>(不经 canvas),Chrome/Firefox 渲染成空白,且onerror已resolve("")、不出提示。这条是本 PR 带来的观感变化:改前这条路后端 400、住户至少看得到一句错误;现在字节能完整送达、观测正常返回,唯一残留的可见异常就变成了那张空白图。后端分析不受影响(候选 crop 由后端回传并正常显示),修它需要前端补一层解码或兜底文案,不在本 PR 范围。给维护者
dependency-guard会因pyproject.toml+uv.lock变更而阻塞,需要评论放行:(放行口令须由维护者以评论形式发出,写在本正文里
dependency-guard读不到——它只扫 issue comments。当前 head 的口令见下方评论。)另:仓库目前没有 NOTICE / 第三方许可清单,而
LICENSE.md写的是「entire content ... exclusively owned by Xiaomi」。这句话相对已捆绑的第三方原生库本来就不够准确——av的 wheel 里带着编入 libx264/libx265 的 FFmpeg,opencv-python-headless也自带一份。pi-heif 的 LGPLv3 不构成新的合规类别(比现状更轻),且平台归档里只有 miloco 自己的 3 个 wheel、不分发第三方二进制。补 NOTICE 属既有事项,没塞进本 PR。