# Batch-07 外部顾问审阅资料包 — 3 篇已上线但缺审核记录的工程文章
> **致顾问**:本轮不是新一轮选题策划,而是**补审**。这 3 篇已经在 blog.hoelee.com 上线(其中 2 篇在 Batch-06 资料包里已给过全文,1 篇是 2026-10-28 新增),但审阅结论回执一直没落到台账里。请您按同一套铁律与评分维度给出结论即可;如需撤回或重写,我会照办,线上可随时下线。
>
> **审阅日期**:2026-10-28 | **资料包版本**:v1 | **文章语言**:英中双语(英文为线上主版本,中文为同 slug 镜像)
---
## 一、本轮范围与状态
| # | slug | 分类 | 上线日 | 字数(EN / ZH) | 台账状态 | 本轮需要您给的结论 |
|---|---|---|---|---|---|---|
| **A** | `when-a-proxy-timeout-returns-html-instead-of-json` | engineering | 2026-10-28 | 15,520 / 8,813 | pending(本轮新增) | **全文审阅**(资料包第五章) |
| **B** | `when-lowering-crf-does-nothing-maxrate-deadlock` | devops | 2026-10-27 | 8,906 / 5,042 | pending | 全文在 Batch-06 包,本轮附录重贴,**请给最终结论** |
| **C** | `why-ffmpeg-map-0-breaks-your-mp4-and-mkv-remux` | engineering | 2026-10-27 | 10,877 / 7,332 | pending | 同上 |
**为什么会出现「已上线却未审」**:Batch-06 那批 5 篇里,A/B/C 中的 B、C 按排期先发了(周二主打 + 周四深水区),审阅回执此前一直只看 Batch-01~05 的报告文件;A 是 2026-10-28 当天故障复盘当场写完并直接上线的(业主授权「按建议执行」),没有走 draft 预审闸门。这件事本身是我流程上的缺口,本轮补齐台账(3 条 `status: pending`)。
**未在本轮、但仍挂着的 Batch-06 剩余 3 篇**(都是 `draft: true`,未上线,等同一轮结论):`traefik-yaegi-plugin-package-name-trap`、`architecting-sea-payment-and-offline-licensing-pipelines`、`batch-generating-80-technical-covers-with-comfyui`。
---
## 二、战略任务与定位(与前几轮一致,未变)
1. **双轨目标**:① 技术求职证明(Senior/Staff 全栈 · DevOps · AI 平台架构,US/EU/SG 远程或 hybrid);② 马来西亚中小企业 B2B 获客(网站设计与开发为主业,邮箱托管为次)。
2. **No-Shopkeeper / No-Founder 红线**:商业交付一律写成高可用生产系统工程,不写成店铺经营、副业、电商小生意。
3. **A 篇在这条线上的作用**:这是"能把别人的线上事故从一句错误的报错定位到正确层级"的证据。它不涉及任何自有产品,纯排障与架构判断,对求职线价值最高;对 B2B 线的作用是"我管理的基础设施出问题时,我是这样收尾的"。
---
## 三、铁律清单(自检结果,逐条给证据)
| 铁律 | 自检方式 | 结果 |
|---|---|---|
| 禁用词 `homelab` / `home lab` / `家用` / `小主机` / `折腾` | 全文正则,EN+ZH 各跑一遍 | **0 命中** |
| 第一人称单数,禁 `we` / `our` / `我们` | 全文正则计数 | **0 命中**(EN 与 ZH 均为 0) |
| 技术正文禁第二人称(`you`/`your`/`你`),仅结尾 CTA 可用 | 全文正则 | 初稿有 **5 处**(`Your timeout budget`、`the worst case you have not measured`、`telling you / your code`,中文 2 处「你」),已全部改为第一人称/无人称,**当前 0 命中**,且已重新构建部署、线上实测生效 |
| 英中 100% 镜像(同 slug、同 commit) | 文件存在性 + 构建产物 | 两处 `dist/posts/`、`dist/posts/zh/` 均生成,`llms.txt` 各 2 条 |
| 标题 Title Case(EN) | 人工核对 | A/B/C 均符合 |
| OPSEC:无绝对路径、无内网 IP、无 token/密钥 | 人工逐段过 + 正则扫 `192.168`、`/volume1`、`D:\`、`token` | **0 命中**;文中出现的 `nginx` / `FastAPI` / `authentik` / `Docker` / `cgroups` 均为通用技术名词,非客户标识 |
| 数字一致(前后文不打架) | 逐条重算 | 13.6÷0.259 = **52.5×**(文中"中位 52×");13.6÷0.244~0.361 = **38~56×**(文中"38–57×");2507÷13.6 = **184.3×**(文中"184×");0.24–0.36 s 五连测中位 **0.259 s** |
| 长代码进 ``,`` 写明产物与行数 | 人工核对 | A 篇两处:`52 行` 缓存实现、`22 行` 前端读取器;标题均写明产物 |
| 不手写 CTA(由 `dynamicCta: true` 注入) | frontmatter + 构建产物 | `dynamic-cta` 类在线上页面各 1 处,正文无手写表单 |
---
## 四、技术背景(供您审"技术是不是真的")
### 4.1 A 篇的触发:一句指向错误层级的报障
业主 2026-10-28 报障原文只有一行:
```
读取自动处理状态失败:SyntaxError: Unexpected token '<', "= vbps × SIZE_CEIL × 0.97`),此时调 CRF 数学上无效,调度器应直接取消重试并记录原因。
- **C(`-map 0` 跨容器)**:`-map 0` 在 Matroska 输出上遇到 QuickTime 章节(`bin_data`)与手机时间码轨(`tmcd`)会直接 fatal;MP4 输出遇到 ASS/PGS/字体附件同样 fatal;iOS 的轨名只读 `udta/name` 而 ffprobe 只回读 `hdlr`,二次混流会静默丢标签。修法是显式轨道规划 + 包级 MD5 校验(`-f data -` 管道哈希 payload)。
---
## 五、我预判您会追问的两处(并附我的答案与证据边界)
> 这一节是我主动交底的,不是让您放水。若您认为证据不足以支撑文中表述,我会改标题与结论,而不是改数字。
**Q-A:把"缓存读 0.26 s"和"全量审计 13.6 s"放在一起比,是不是苹果比橘子?**
- 文中表述为"对照它所替代的 13.6 秒审计",指的是**同一端点、同一份计算**在改造前后的对照,不是把它包装成"接口优化 52 倍"。
- 我手上有:同函数三种条件下的耗时(13.6 / 17.7 / 98.5 / 35.8 s)、缓存读 5 连测(0.244 / 0.257 / 0.259 / 0.262 / 0.361 s)、后台自记的 8 次审计耗时。
- **证据缺口(可补)**:我没有从访问日志里取该端点改造前后的 p50/p95 分布,因此文中不写"p95 下降多少"。若您要求,我可以补一次基于 access log 的分位统计再回填。
**Q-B:2507 s 里到底有多少是单核限流、多少是锁竞争/IO?**
- 该数字是**同一函数从进入到返回的墙钟时间**,由应用自己打印,不是我推算的;但它**没有拆段归因**(单核争用 / 磁盘 stat / 哈希 CPU 各占多少)。
- 我能确定的因果:容器 1 核 + 1/4 权重、同宿主有 HEVC 转码任务、数据量 341 篇;我**没有**做过的:分段埋点、per-file IO 统计。
- 因此文中只说"耗时是被允许借用多少 CPU 的函数",没有声称具体的归因比例。若您要求,我会加一次分段埋点实验再给比例。
---
## 六、全文双语 — A 篇(本轮待审主角)
### 6.1 英文版(线上主版本)
````markdown
---
ogImage: "/og/when-a-proxy-timeout-returns-html-instead-of-json.jpg"
banner: "/banners/when-a-proxy-timeout-returns-html-instead-of-json.jpg"
title: "When a Proxy Timeout Returns HTML Instead of JSON: A 499 Post-Mortem"
description: "Why a self-hosted admin card failed with 'Unexpected token <': an O(n) library audit running inside a GET request, three reverse-proxy hops, and the 499 status no monitor watches — measured from 13.6 s on an idle box to 2,507 s on a busy one."
pubDate: 2026-10-28
category: engineering
blueprint: self-hosted-infrastructure
blueprintLayer: L2-edge-proxy-runtime
tags: ["nginx", "fastapi", "reverse-proxy", "timeouts", "caching", "observability", "stale-while-revalidate"]
draft: false
summary:
audience: "Full-Stack Engineers, SREs & Platform Architects Who Own Their Own Edge"
painPoint: "An admin dashboard card died with a JavaScript syntax error, which pointed every investigation at the frontend — while the real failure was an O(n) audit burning one pinned CPU core behind three proxy hops that silently abort at their own timeouts."
impact:
- metric: "Endpoint Latency −98%"
detail: "Moved a 289-to-341-note library audit off the request thread into a single-flight background refresher; the cached read path now answers in 0.24–0.36 s over five consecutive calls instead of 13.6 s idle and 2,507 s under load."
- metric: "Failure Now Names Its Layer"
detail: "Replaced a blind res.json() with a text-first reader that preserves the HTTP status and the first 80 bytes of an HTML error page, so a proxy abort reports 'HTTP 503 ·
🔍 View the stale-while-revalidate implementation — cache, single-flight refresh thread, startup warm (52 lines)
```python
_AUTOMATION_TTL_SEC = float(os.environ.get("AUTOMATION_COUNTS_TTL_SEC", "300") or 300)
_automation_cache: Dict[str, Any] = {"data": None, "at": 0.0}
_automation_refreshing_flag = [False]
_automation_lock = threading.RLock()
@app.get("/api/automation", tags=["ops"])
def automation_get(refresh: bool = False):
"""Panels must never block on an audit."""
flags = autoflow.settings(db=db)
now = time.time()
cached = dict(_automation_cache)
fresh = cached["data"] is not None and (now - cached["at"]) < _AUTOMATION_TTL_SEC
if refresh or not fresh:
_start_automation_refresh()
return {
"flags": flags,
"counts": (cached["data"] or {}).get("counts")
or {"notes": 0, "missing_cover": 0, "tts_stale": 0},
"refreshing": _automation_refreshing() or not fresh,
"cached_at": (datetime.fromtimestamp(cached["at"]).isoformat(timespec="seconds")
if cached["at"] else None),
}
def _automation_counts() -> Dict[str, Any]:
"""The slow half: cover gap + stale TTS over the whole library."""
notes = [p for p in sorted(CONTENT_DIR.glob("*.md")) if not p.stem.endswith(".en")]
missing_cover = [...]
stale = tts_mod.stale_targets(content_dir=CONTENT_DIR, audio_root=AUDIO_DIR)
return {"counts": {"notes": len(notes), "missing_cover": len(missing_cover),
"tts_stale": len(stale)},
"missing_cover": missing_cover[:20]}
def _start_automation_refresh() -> None:
with _automation_lock:
if _automation_refreshing_flag[0]:
return
_automation_refreshing_flag[0] = True
def _run() -> None:
started = time.time()
try:
data = _automation_counts()
_automation_cache.update({"data": data, "at": time.time()})
except Exception as exc: # a broken audit must not 500 the card
logging.warning("automation counts failed: %s", exc)
finally:
_automation_refreshing_flag[0] = False
logging.info("automation counts refreshed in %.1fs", time.time() - started)
threading.Thread(target=_run, name="automation-counts", daemon=True).start()
```
**2. A stale value must announce itself.** The response carries `refreshing` and `cached_at`, and
the card renders `计算中…` next to the numbers while a recompute is in flight, then retries in three
seconds. The alternative — showing `0` until data arrives — is worse than being slow, because a
zero on a backlog counter is indistinguishable from "nothing to do".
**3. The client never parses blindly.** `res.json()` was the actual defect on the browser side: it
inferred "malformed JSON" from "HTML error page" and discarded both the status code and the body
that would have named the failing hop.
🔍 View the text-first response reader and the retry contract (22 lines)
```javascript
function autoReadJson(res) {
// A proxy timeout / 502 / 503 returns an HTML error page. Blind res.json() turns that into
// "Unexpected token '<'" and hides which layer failed — keep the status and the head instead.
return res.text().then(function (text) {
try {
return { data: JSON.parse(text) };
} catch (e) {
var head = String(text || '').trim().slice(0, 80).replace(/\s+/g, ' ');
return { error: 'HTTP ' + res.status + ' · ' + (head || 'empty response') };
}
});
}
// refreshing: show the last known numbers, label them, come back in three seconds.
document.getElementById('auto-counts').textContent =
'笔记 ' + counts.notes + ' · 缺封面 ' + counts.missing_cover
+ ' · 待朗读 ' + counts.tts_stale + (data.refreshing ? ' · 计算中…' : '');
if (data.refreshing) { autoRetryTimer = setTimeout(window.autoReload, 3000); }
```
A fourth change is smaller but load-bearing: `?refresh=1` forces a recompute. Tests and humans both
need a way to demand fresh numbers without waiting out a TTL, and without that affordance the test
suite would have had to sleep for minutes.
The test contract changed with the endpoint, which is the part most people skip: the suite now
polls until the background thread lands instead of asserting numbers on the first response. 28 tests
pass; the DOM contract check still confirms 16 cards, 68 buttons and 124 element ids, so the frontend
rewrite did not quietly drop a control.
---
## Reflection: five rules for panel endpoints
1. **A dashboard endpoint is not a query interface.** It is the most frequently called and least
cached surface in an admin UI. Anything O(n) over user data belongs in a background job with a
publish step, not in the GET that a browser polls on focus.
2. **The timeout budget is the smallest hop, not the sum.** Four proxies each "allowing" 120 s do
not add up; the request dies at whichever clock expires first, and the application cannot see any
of them. The application-side budget has to be an order of magnitude under the smallest one.
3. **`499` deserves an alert.** It is the only signal that reports "the caller gave up", and it
bypasses every 5xx-based monitor. A ratio of 499s to 200s on a single path, scraped from the
access log, detects this entire class of failure for almost no cost.
4. **Never trust `res.json()` on a proxied route.** Read text, keep the status, keep the first line.
The error message is the only artefact the next engineer will have.
5. **Stale-while-revalidate beats a bigger number.** Raising a timeout moves the wall; decoupling
compute from read removes it — and it removes it for the worst case I have not measured yet
(2,507 s here), not just the one I did (13.6 s).
---
## The result
- **Cached read path: 200 in 0.24–0.36 s** across five consecutive calls measured inside the
container — a median of 0.26 s against the 13.6 s audit it replaced, **52× faster at the median
and 38–57× across the spread**.
- **Worst observed case eliminated as a user-facing risk.** The audit still takes 2,507 s when the
box is saturated; it now costs a background thread instead of a broken card, and the panel keeps
answering throughout.
- **Correctness preserved, not masked.** Counts at the time of writing: 341 notes, 1 missing cover,
47 notes pending audio — the same computation, moved.
- **The failure can no longer masquerade.** If any hop aborts again, the card prints
`HTTP 503 · …` and the investigation starts at the right layer.
The single most useful line in this whole exercise was the one that lied: `Unexpected token '<'`.
An HTML error page handed to a JSON caller is not a syntax error — it is a proxy telling me, in the
only language it has, that it stopped waiting before my code ever woke up.
````
### 6.2 中文版(同 slug 镜像,`posts/zh/`)
````markdown
---
ogImage: "/og/zh-when-a-proxy-timeout-returns-html-instead-of-json.jpg"
banner: "/banners/when-a-proxy-timeout-returns-html-instead-of-json.jpg"
title: "反代超时返回 HTML,前端报 SyntaxError:一次 499 事故复盘"
description: "后台卡片报「Unexpected token '<'」,所有线索都指向前端,真正的故障却是一个跑在 GET 请求线程里的全库审计:289 到 341 篇笔记逐一切章、算哈希、stat 音频,穿过三层反代,实测从空闲时的 13.6 秒恶化到满载时的 2507 秒。"
pubDate: 2026-10-28
category: engineering
blueprint: self-hosted-infrastructure
blueprintLayer: L2-edge-proxy-runtime
tags: ["nginx", "fastapi", "反向代理", "超时", "缓存", "可观测性", "stale-while-revalidate"]
draft: false
summary:
audience: "自建边缘层的全栈工程师、SRE 与平台架构师"
painPoint: "一张后台卡片被一句 JavaScript 语法错误判了死刑,排查方向全被带到前端;真实死因是一个 O(n) 全库审计占住单核、被三层反代各自的超时闸门无声掐断。"
impact:
- metric: "端点延迟 −98%"
detail: "把 289 至 341 篇笔记的全库审计挪出请求线程,改由单飞后台线程刷新;缓存读路径连续五次实测 0.24–0.36 秒,替代空闲时 13.6 秒、满载时 2507 秒的同步审计。"
- metric: "故障自动指明层级"
detail: "用「先读文本、保留状态码与首 80 字节」的读取器替代裸 res.json(),反代中断时面板直接打印「HTTP 503 ·
线上面板报上来只有一行字:
```
读取自动处理状态失败:SyntaxError: Unexpected token '<', "
出问题的路由是发布控制台的一张状态卡片:
```
GET /api/automation
```
它返回三个开关和三个计数。两个计数很便宜,第三个叫 `tts_stale`,为了算它要跑一遍全库审计:枚举所有 markdown 笔记、逐篇切章节、对每章内容算哈希、再对每个目标音频文件 `stat` 一次。而这一切**就跑在请求线程里**,卡片每轮询一次就重算一次。
```python
# 出问题的写法:一个 GET,顺手把整个库量一遍
@app.get("/api/automation")
def automation_get():
notes = [p for p in sorted(CONTENT_DIR.glob("*.md")) if not p.stem.endswith(".en")]
missing_cover = [...]
stale = tts_mod.stale_targets(content_dir=CONTENT_DIR, audio_root=AUDIO_DIR) # O(n) 审计
return {"counts": {"notes": len(notes), "tts_stale": len(stale)}, ...}
```
289 篇笔记时这是一次 13.6 秒的请求。光这个数字就已经足够取消它在轮询卡片上的资格,但让它从「慢」变成「坏」的是另一半原因:这个请求要穿过三层反向代理才摸到这个函数——一层 SSO 门禁、一层 portal nginx、再进容器网络——每一层都有自己的超时,而应用既设不了、也看不见。
portal 的 `/api/` 位置在现行配置里是 `proxy_read_timeout 120s`,而它前面的 SSO 门禁放弃得更早。任何一层的计时器一到,它就按 nginx 的本分处理:停止等待、断开上游连接、把**自己的 HTML 错误页**交给浏览器。前端接着对这份文档执行 `await res.json()`,于是就有了那行 `SyntaxError`。
整条链上没有任何一环返回 500。应用从头到尾都不知道这个请求存在过。
---
## 三条证据钉死「其实什么都没坏」
三个产物合起来就能定位故障,不需要猜。单独看每一条都是歧义的,其中一条还会主动误导。
| 证据 | 观测值 | 排除了什么 |
|---|---|---|
| portal 访问日志 | 该路径 7 次请求,**全是 `499`、响应体 0 字节**,无 4xx、无 5xx | 排除应用报错与鉴权拒绝 |
| 应用日志(uvicorn) | 故障窗口内该路径**一条记录都没有** | 证明请求根本没到应用,是代理先放弃的 |
| 容器内直连打点 | 同一调用直连应用:连续三次 **17.7 秒 / 98.5 秒 / 35.8 秒** | 确认慢是真的、且慢在审计内部,不是网络抖动 |
`499` 是 nginx 在**客户端**先断开连接时写下的状态码。它不是服务端承认的错误,不是 5xx,FastAPI 的访问日志也永远看不到它——所以这张卡片可以坏上几个星期,而所有存活探测、错误率看板、「服务健康吗」的问答统统是绿的。真正描述这次故障的状态码,由放弃等待的那一层写下,指向的是一个应用从未收到的请求。
而最响的那条证据恰好在误导:`SyntaxError`。它对文档的描述是准确的,对原因的判断是错的。
---
## 隐藏变量:空闲时 13.6 秒,满载时 2507 秒
修之前,审计的耗时看起来像个常数。它不是。控制台容器被钉在单核(`cpuset "4"`),CPU 权重只有默认值的四分之一(`cpu_shares 256`),而宿主机上还跑着 HEVC 转码闸门和内容流水线。这场审计要跟别的任务抢那一个核。
把审计挪出请求路径之后,应用开始自己记录耗时,假设才变成测量:
```
automation counts refreshed in 76.1s
automation counts refreshed in 82.3s
automation counts refreshed in 93.5s
automation counts refreshed in 124.2s
automation counts refreshed in 165.1s
automation counts refreshed in 633.6s
automation counts refreshed in 1282.5s
automation counts refreshed in 2507.2s
```
同一份代码、同一个 341 篇的库,一个数量级的跨度,最坏值比空闲机上量到的数字**慢 184 倍**。这一条就足以否定那个「朴素修法」:把反代超时从 120 秒抬到 300 秒,解决不了一个 2507 秒的计算,只是把 120 秒失败改成 300 秒失败,晚五分钟、多占五分钟容量。
这就是我在边缘层反复吃到的教训:**端点延迟不是代码的属性,而是它被允许借用多少 CPU 的属性。** 单核 cgroup 限流会把一次 O(n) 文件审计变成耗时由邻居决定的无界操作。
---
## 修法:把读路径和计算路径彻底拆开
三处契约改动,每一处都小到可以独立验证。
**一、读路径绝不计算。** 计数放进 TTL 缓存(300 秒,`AUTOMATION_COUNTS_TTL_SEC` 可调)。请求要么拿到新鲜缓存,要么立刻拿到上一次的值并带 `refreshing: true`,同时由一个后台 daemon 线程重算。单飞用一把锁加一个标记守住,成串的轮询不会变成成串的审计。缓存还会在应用 lifespan 里预热一次,第一个打开面板的人不用替所有人付这笔账。
🔍 查看 stale-while-revalidate 实现 —— 缓存、单飞刷新线程与启动预热(52 行)
```python
_AUTOMATION_TTL_SEC = float(os.environ.get("AUTOMATION_COUNTS_TTL_SEC", "300") or 300)
_automation_cache: Dict[str, Any] = {"data": None, "at": 0.0}
_automation_refreshing_flag = [False]
_automation_lock = threading.RLock()
@app.get("/api/automation", tags=["ops"])
def automation_get(refresh: bool = False):
"""面板绝不在审计上阻塞。"""
flags = autoflow.settings(db=db)
now = time.time()
cached = dict(_automation_cache)
fresh = cached["data"] is not None and (now - cached["at"]) < _AUTOMATION_TTL_SEC
if refresh or not fresh:
_start_automation_refresh()
return {
"flags": flags,
"counts": (cached["data"] or {}).get("counts")
or {"notes": 0, "missing_cover": 0, "tts_stale": 0},
"refreshing": _automation_refreshing() or not fresh,
"cached_at": (datetime.fromtimestamp(cached["at"]).isoformat(timespec="seconds")
if cached["at"] else None),
}
def _automation_counts() -> Dict[str, Any]:
"""慢的那一半:全库缺封面 + 过期朗读目标。"""
notes = [p for p in sorted(CONTENT_DIR.glob("*.md")) if not p.stem.endswith(".en")]
missing_cover = [...]
stale = tts_mod.stale_targets(content_dir=CONTENT_DIR, audio_root=AUDIO_DIR)
return {"counts": {"notes": len(notes), "missing_cover": len(missing_cover),
"tts_stale": len(stale)},
"missing_cover": missing_cover[:20]}
def _start_automation_refresh() -> None:
with _automation_lock:
if _automation_refreshing_flag[0]:
return
_automation_refreshing_flag[0] = True
def _run() -> None:
started = time.time()
try:
data = _automation_counts()
_automation_cache.update({"data": data, "at": time.time()})
except Exception as exc: # 审计坏了也不能把卡片打成 500
logging.warning("automation counts failed: %s", exc)
finally:
_automation_refreshing_flag[0] = False
logging.info("automation counts refreshed in %.1fs", time.time() - started)
threading.Thread(target=_run, name="automation-counts", daemon=True).start()
```
**二、陈旧值必须自己说出来。** 响应里带 `refreshing` 与 `cached_at`,卡片在重算期间把「计算中…」挂在数字后面,3 秒后自动补取一次。反面做法——在数据到达前先显示 `0`——比慢更糟,因为积压计数器上的 0 与「确实没事可做」长得一模一样。
**三、客户端绝不盲解析。** 真正躺在浏览器侧的缺陷是 `res.json()`:它把「收到了 HTML 错误页」推断成「JSON 格式错误」,顺手丢掉了状态码和本该指出故障层级的那段正文。
🔍 查看「先读文本」的响应读取器与重试契约(22 行)
```javascript
function autoReadJson(res) {
// 反代超时 / 502 / 503 回的是 HTML 错误页;裸 res.json() 会把它变成
// "Unexpected token '<'",并把「哪一层挂了」这条信息彻底吞掉。
return res.text().then(function (text) {
try {
return { data: JSON.parse(text) };
} catch (e) {
var head = String(text || '').trim().slice(0, 80).replace(/\s+/g, ' ');
return { error: 'HTTP ' + res.status + ' · ' + (head || '空响应') };
}
});
}
// refreshing 时:先显示上一次的数字并标注状态,3 秒后再来一次
document.getElementById('auto-counts').textContent =
'笔记 ' + counts.notes + ' · 缺封面 ' + counts.missing_cover
+ ' · 待朗读 ' + counts.tts_stale + (data.refreshing ? ' · 计算中…' : '');
if (data.refreshing) { autoRetryTimer = setTimeout(window.autoReload, 3000); }
```
第四处改动更小但很关键:`?refresh=1` 强制重算。测试和人一样,都需要一条「立刻要新数字」的通道,否则整套测试只能靠 sleep 等 TTL 过期。
测试契约跟着端点一起改了,这是大多数人会跳过的一步:用例现在轮询到后台线程落地为止,而不是对第一次响应就断言数字。28 个测试通过;DOM 契约检查仍然确认 16 张卡片、68 个按钮、124 个元素 id,说明前端那轮改写没有悄悄丢掉控件。
---
## 复盘:我此后对每个面板端点都套用的五条规矩
1. **看板端点不是查询接口。** 它是后台里调用最频繁、缓存最少的表面,任何对用户数据的 O(n) 计算都属于后台任务加一次发布,不属于浏览器聚焦时轮询的那个 GET。
2. **超时预算等于最小那一跳,不是各跳之和。** 四层反代各自「允许」120 秒并不叠加:请求死在最先到期的那个时钟上,而应用一个都看不见。应用侧的预算必须比最小的那个还低一个数量级。
3. **`499` 值得配一条告警。** 它是唯一一个表达「调用方放弃了」的信号,且天然绕开所有基于 5xx 的监控。从访问日志抓单条路径上 499 与 200 的比值,几乎零成本覆盖这一整类故障。
4. **在反代后面永远不要信 `res.json()`。** 先读文本,留下状态码,留下第一行。下一个接手的人手里只有这条错误信息。
5. **stale-while-revalidate 优于一个更大的数字。** 抬高超时只是把墙往后推;把计算与读拆开才是把墙拆掉——而且拆掉的是我还没量到的那个最坏值(这里的 2507 秒),不只是量到的那个(13.6 秒)。
---
## 交付结果
- **缓存读路径:连续五次容器内实测 200,0.24–0.36 秒**,中位 0.26 秒,对照它所替代的 13.6 秒审计——**中位快 52 倍,整个区间快 38–57 倍**。
- **最坏情况不再暴露给使用者。** 宿主机饱和时审计仍要 2507 秒,但它现在只花掉一个后台线程,而不是一张坏掉的卡片,面板全程照常应答。
- **正确性保留而非掩盖。** 撰写时的计数:341 篇笔记、1 个缺封面、47 篇待生成朗读——同一个计算,只是搬了位置。
- **故障再也无法伪装。** 任何一跳再次中断,卡片会打印 `HTTP 503 · …`,排查从正确的层级开始。
整件事里最有用的一行,恰恰是那行说谎的 `Unexpected token '<'`。把 HTML 错误页递给一个 JSON 调用方,不是语法错误——那是反代用它唯一会说的语言在告诉我:我的代码还没醒,它就已经不等了。
````
---
## 附录一:B 篇全文 `when-lowering-crf-does-nothing-maxrate-deadlock`
### EN
````markdown
---
ogImage: "/og/when-lowering-crf-does-nothing-maxrate-deadlock.jpg"
banner: "/banners/when-lowering-crf-does-nothing-maxrate-deadlock.jpg"
title: "When Lowering CRF Does Nothing: The -maxrate Ceiling and the SSIM Quality Deadlock"
description: "Why dropping CRF by five steps improved SSIM by only 0.00003: discovering the mathematical conflict between -maxrate bitrate ceilings and quality gates in high-throughput video re-encoding."
pubDate: 2026-10-27
category: devops
blueprint: self-hosted-infrastructure
blueprintLayer: L1-compute-storage-network
tags: ["ffmpeg", "transcoding", "hevc", "ssim", "video-engineering", "quality-gate"]
draft: false
summary:
audience: "Video Infrastructure Engineers, SREs & Platform Architects"
painPoint: "A high-throughput video space-reclamation pipeline repeatedly burned 106 hours across 487 encoding rounds because quality-gate retries (crf -2) produced zero metric improvement on capped bitrates."
impact:
- metric: "Exposed Deadlock"
detail: "Proved that crf 19 to crf 14 under an identical -maxrate ceiling yielded an SSIM shift of only +0.00003, rendering lower CRF retries mathematically inert."
- metric: "Two-Tier Quality Gate"
detail: "Introduced a format-aware gate accepting already-compatible streams (H.264+AAC) at 0.990 SSIM while reserving 0.995 for format-remediation encodes."
- metric: "Eliminated Wasted Compute"
detail: "Bypassed 100% of pointless re-encode cycles on capped files, cutting retry overhead from 106+ hours to zero on boundary video files."
stack: ["FFmpeg", "libx265", "SSIM", "Python", "Linux Dedicated Nodes"]
jumpLinks:
business: "#the-batch-that-burned-106-hours-doing-nothing"
architecture: "#the-architectural-solution-two-tier-gates-and-cap-awareness"
result: "#the-result"
dynamicCta: true
---
## The batch that burned 106 hours doing nothing
In a high-throughput transcoding pipeline running across dedicated on-premise infrastructure,
one of my automated ingestion queues had a strict rule: **the output must never exceed the size
of the source** (`SIZE_CEIL = 1.0`).
The goal was automated space reclamation across tens of thousands of archived video files. If an
HEVC transcode came out larger than the H.264 original, the pipeline rejected it as counterproductive.
To enforce this, every encode passed `-maxrate ` and `-bufsize `
to FFmpeg.
At the same time, the pipeline guarded visual fidelity with an automated quality gate:
every transcoded file had its structural similarity (SSIM) measured against the source frame by frame.
If the mean SSIM fell below `0.995`, the item failed the quality gate. To recover borderline files,
the supervisor triggered an automatic retry: `quality+2` (dropping CRF from 29 to 27 or 19 to 17).
Then the production telemetry came in.
Over a two-week run, **487 quality rounds failed**, consuming **106.2 hours of dedicated compute**.
Worse: of 202 files that went through two consecutive rounds, **194 showed an SSIM movement of
less than 0.0015**. The second round burned full CPU cycles to produce a file almost byte-for-byte
identical to the one rejected ten minutes earlier.
---
## The anomaly in the lab
To isolate why the quality ladder was failing to move the needle, I isolated a representative test
video: a 720p@30 clip (`1280x720`, 96 seconds) with an original video bitrate of 1832 kbps.
I ran three controlled encoding passes using `libx265`:
```text
Run 1 (Baseline):
Command: -crf 19 -maxrate 1832k -bufsize 4580k
Size: 20.6 MB (87.5% of source)
SSIM Mean: 0.99192 --> REJECTED (below 0.995)
Run 2 (The Aggressive Quality Retry):
Command: -crf 14 -maxrate 1832k -bufsize 4580k (5 quality steps lower!)
Size: 22.9 MB (97.3% of source)
SSIM Mean: 0.99195 --> REJECTED (below 0.995)
Delta: +0.00003 SSIM improvement
Run 3 (Uncapped Ceiling):
Command: -crf 19 (no -maxrate, no -bufsize)
Size: 40.6 MB (172.5% of source)
SSIM Mean: 0.99648 --> PASS (above 0.995)
```
Look at the difference between Run 1 and Run 2: **dropping CRF by five full steps moved the mean
SSIM by exactly 0.00003**.
Yet in Run 3, the exact same CRF 19 sailed past the 0.995 bar to 0.99648—at the cost of expanding
the file to **172%** of its original size.
---
## The mathematical trap between CRF and -maxrate
The culprit was not the video encoder, the preset, or the content complexity. It was a fundamental
contradiction in the pipeline's control architecture:
1. **CRF is a quality target**, not a rate controller. It calculates the quantization parameter needed to maintain uniform visual fidelity across frames.
2. **`-maxrate` is a hard bitrate ceiling enforced by the VBV (Video Buffer Verifier)** buffer.
3. When complex scenes demand more bits to meet a lower CRF, the VBV governor intervenes and drops quality to honor the `-maxrate` ceiling.
In high-motion, high-entropy scenes, reaching an SSIM mean of 0.995 required **at least 125% to
170%** of the source's native bitrate. But the `SIZE_CEIL = 1.0` contract I enforced forbade giving
the encoder those bits.
Consequently, when an encode ran close to the ceiling (spending >= 97% of the allowable bits),
lowering the CRF did not buy more detail—it simply forced the encoder to hit the ceiling earlier in
the GOP and throw the extra bits away. The quality ladder stopped being a ladder.
---
## The architectural solution: Two-tier gates and cap awareness
To eliminate the 106 hours of wasted compute, I refactored the supervisor's decision engine in three ways:
### 1. Identifying the true business objective (`space_reclaim_source`)
Why was a file being transcoded in the first place?
* **Case A: Format Remediation.** If the source was VP9, AV1, or inside an MKV container with AC-3 audio, it could not play natively in mobile or web browsers without server-side transcoding. Here, fidelity is paramount: I enforce the strict `0.995` threshold.
* **Case B: Pure Space Reclamation.** If the source was already an H.264 baseline MP4 with AAC audio, it already played everywhere natively. The *only* purpose of re-encoding was saving disk space. Holding a compatible file to a strict 0.995 bar that forces bitrate expansion is counterproductive.
For Case B, the pipeline now accepts a secondary tier: `SSIM_MEAN_MIN_FALLBACK = 0.990`.
### 2. Detecting the ceiling wall (`CAP_BOUND_SHARE`)
If an encode used >= 97% of the allowable source bitrate (`obps >= vbps * SIZE_CEIL * 0.97`),
the file was capped. Retrying with a lower CRF would mathematically yield zero benefit. The pipeline
now detects this condition and cancels the retry immediately.
View pipeline quality-gate logic (Python excerpt)
```python
SIZE_CEIL = 1.0 # Output video bitrate must not exceed the source's
CAP_BOUND_SHARE = 0.97 # Spent >= 97% of ceiling -> rate-limited, not gear-limited
QUALITY_STEPS = 2 # Retry offset (crf - 2)
def space_reclaim_source(info, src):
"""True if the video and audio streams already decode natively on mobile/web targets."""
if info.get("vcodec") not in ("h264", "avc1"):
return False
if info.get("acodec") not in (None, "aac", "mp4a"):
return False
return os.path.splitext(src)[1].lower() in (".mp4", ".m4v", ".mov")
def evaluate_quality_gate(info, src, mean_ssim, bad_share, vbps, obps):
capped = bool(vbps) and obps >= (vbps * SIZE_CEIL * CAP_BOUND_SHARE)
systematic = bad_share > MAX_BAD_SHARE or (mean_ssim < SSIM_TARGET)
# Second-tier relaxation for files that already play everywhere
if systematic and bad_share <= MAX_BAD_SHARE and mean_ssim >= SSIM_MEAN_MIN_FALLBACK:
if space_reclaim_source(info, src):
return "PASS_SECOND_TIER", "Accepted at secondary 0.990 threshold for space reclamation"
if systematic:
# If output is already hitting the bitrate ceiling, retry is mathematically inert
if capped:
return "FAIL_CAPPED", f"SSIM {mean_ssim:.4f} below bar, but output is capped ({obps/vbps*100:.1f}%) - retry skipped"
return "RETRY", f"Retry with crf - {QUALITY_STEPS}"
return "PASS", "Clean pass"
```
---
## The result
After deploying this change to the production nodes:
* The test file that previously bounced between `crf 19` and `crf 14` with a `FAIL quality 0.99192` now cleared on its first pass as `PASS_SECOND_TIER` (87.5% of original size, zero retries, 120s encode time).
* The 106-hour wasted compute overhead on boundary files dropped to **zero**.
* Pipeline throughput increased by **31%** across the archival queue because the supervisor stopped running provably inert second passes.
When building automated transcoding pipelines, never evaluate quality metrics in isolation from your rate-control constraints. When CRF meets `-maxrate`, the ceiling always wins.
````
### ZH
````markdown
---
ogImage: "/og/zh-when-lowering-crf-does-nothing-maxrate-deadlock.jpg"
banner: "/banners/when-lowering-crf-does-nothing-maxrate-deadlock.jpg"
title: "当降低 CRF 毫无效果:视频转码中 -maxrate 封顶与 SSIM 质量门禁的死锁"
description: "为什么将 CRF 下调 5 档仅让 SSIM 微升 0.00003:深度拆解高吞吐视频转码管线中 -maxrate 码率天花板与客观质量门禁之间的数学死锁与两档放行解法。"
pubDate: 2026-10-27
category: devops
blueprint: self-hosted-infrastructure
blueprintLayer: L1-compute-storage-network
tags: ["ffmpeg", "transcoding", "hevc", "ssim", "video-engineering", "quality-gate"]
draft: false
summary:
audience: "视频基础设施工程师、SRE 与平台架构师"
painPoint: "私有高吞吐转码管线在 487 轮转码中空转了 106 小时算力,原因是质量门禁的自动重试(CRF -2)在硬性码率上限拦截下完全失效。"
impact:
- metric: "揭露死锁根因"
detail: "实测证明在相同 -maxrate 上限下,CRF 从 19 降至 14 仅让 SSIM 提升 0.00003,重试在数学上完全惰性。"
- metric: "二档差异化门禁"
detail: "引入业务目标分流:全平台可播的原生 H.264+AAC 源按 0.990 空间回收档放行,非兼容格式保持 0.995 严格修复档。"
- metric: "消除无效算力损耗"
detail: "彻底旁路封顶文件的无效重试,将临界视频的重试开销从 106 小时直接清零。"
stack: ["FFmpeg", "libx265", "SSIM", "Python", "Linux 生产节点"]
jumpLinks:
business: "#消耗了-106-小时却毫无进展的转码批次"
architecture: "#架构重构二档门禁与封顶自适应旁路"
result: "#交付结果"
dynamicCta: true
---
## 消耗了 106 小时却毫无进展的转码批次
在我所维护的私有高吞吐媒体转码基础设施中,自动化入库队列设定了一条铁律:**转码输出文件体积绝对不可超过原文件**(`SIZE_CEIL = 1.0`)。
这套管线的核心业务目标是自动化磁盘空间瘦身。如果一次 HEVC(H.265)转码输出的文件比原来的 H.264 原档还要大,系统会直接将其判定为负收益并废弃。为了在编码期严格约束体积,调度器在调用 FFmpeg 时强制注入了 `-maxrate <原视频码率>` 与 `-bufsize <原视频码率 * 2.5>`。
与此同时,管线引入了端到端客观画质门禁:每一个转码产物都必须与原视频逐帧比对结构相似性(SSIM)。如果整片 SSIM 均值低于 `0.995`,该文件就会触发质量拦截。为了抢救临界画质文件,系统设计了自动化重试梯队:`quality+2`(将 CRF 调低 2 档重新编码)。
然而随后的生产监控日志揭露了一个反常现象。
在连续两周的运行中,**系统记录了 487 次质量门禁拦截**,白白消耗了 **106.2 小时的高性能算力**。更严重的是:在经历连续两次编码的 202 个文件中,**有 194 个文件的 SSIM 变动幅度低于 0.0015**。第二次重试在消耗了完全相同的 CPU 周期后,产出了一个与 10 分钟前被拒收的文件近乎完全相同的结果。
---
## 实验室受控排查与数据断点
为了彻底查清为什么质量梯队无法拉动指标,我从队列中抽取了一个典型测试样本:一段 720p@30 规格的视频(`1280x720`,时长 96 秒,原视频码率为 1832 kbps)。
在相同的硬件与 `libx265` 环境下,我进行了三组对照实验:
```text
第 1 组(基线配置):
参数: -crf 19 -maxrate 1832k -bufsize 4580k
体积: 20.6 MB(原片 87.5%)
SSIM 均值:0.99192 --> 拦截拒收(低于 0.995)
第 2 组(激进画质重试):
参数: -crf 14 -maxrate 1832k -bufsize 4580k (整整调优 5 档!)
体积: 22.9 MB(原片 97.3%)
SSIM 均值:0.99195 --> 拦截拒收(低于 0.995)
指标增量: +0.00003 SSIM 提升
第 3 组(解除码率天花板):
参数: -crf 19(不设置 -maxrate,不设置 -bufsize)
体积: 40.6 MB(原片 172.5%)
SSIM 均值:0.99648 --> 顺利通过(高于 0.995)
```
仔细对比第 1 组与第 2 组:**CRF 整整下调了 5 档,但测得的 SSIM 均值仅仅前进了 0.00003**。
而在第 3 组中,完全相同的 CRF 19 参数毫无压力地达到了 0.99648——代价是生成的文件膨胀到了原文件的 **172%**。
---
## CRF 与 -maxrate 之间的数学死锁
导致这 106 个小时算力空转的根因不是编码器 Bug,也不是视频场景过于复杂,而是管线控制架构上的内在逻辑冲突:
1. **CRF 是画质目标控制器**,而非码率分配器。它根据帧内复杂度动态计算量化步长,试图维持恒定的视觉保真度。
2. **`-maxrate` 是硬性码率天花板**,由 VBV(Video Buffer Verifier)缓冲队列物理强制执行。
3. 当复杂的运动场景需要更多比特来达成更低的 CRF 时,VBV 机制强制介入削减码率,以确保不突破 `-maxrate` 上限。
在纹理密集的高熵场景中,想要达到 0.995 的极端高保真度,物理上需要消耗原片 **125% 到 170%** 的码率。但我设定的 `SIZE_CEIL = 1.0` 契约明令禁止给编码器分配这些比特。
因此,当一次编码输出的码率已经逼近上限(消耗了超过 97% 的天花板容量)时,继续调低 CRF 根本无法换来画质细节——它只会让编码器在 GOP 的开头更快撞墙,然后把多余的比特全部丢弃。所谓的质量梯阶在上限面前彻底失去了调节能力。
---
## 架构重构:二档门禁与封顶自适应旁路
为了根除算力浪费,我对调度器的裁决引擎进行了三重重构:
### 1. 拆解真实业务目标(`space_reclaim_source`)
一个视频为什么要被送入转码队列?
* **场景 A:格式兼容性修复**。如果原片是 VP9、AV1 编码,或是封装在 MKV 容器中带 AC-3 音轨,它无法直接在移动端或浏览器内硬解播放。此时兼容性是第一要务:我坚持死守 `0.995` 门禁。
* **场景 B:纯粹的磁盘瘦身**。如果原片本身已经是标准的 H.264+AAC MP4 封装,在所有设备上都能原生硬解,转码的唯一目标就是省空间。如果对本来就能播的文件死守 0.995,最终只会逼迫码率膨胀,本末倒置。
针对场景 B,管线引入了二档放行标准:`SSIM_MEAN_MIN_FALLBACK = 0.990`。
### 2. 封顶阻断判定(`CAP_BOUND_SHARE`)
如果编码输出已经消耗了源视频码率的 97%(`obps >= vbps * SIZE_CEIL * 0.97`),说明该视频已经撞死在码率墙上。此时调低 CRF 重试在数学上注定无效。调度器检测到此状态后,直接取消重试并记录原因。
点击查看质量门禁自适应裁决核心代码(Python 关键片段)
```python
SIZE_CEIL = 1.0 # 输出码率严禁超过源视频
CAP_BOUND_SHARE = 0.97 # 达到上限 97% 表明已触顶,调节阶梯失效
QUALITY_STEPS = 2 # 重试步长 (crf - 2)
def space_reclaim_source(info, src):
"""判定原视频图像与音频层是否均已具备原生硬件解码能力"""
if info.get("vcodec") not in ("h264", "avc1"):
return False
if info.get("acodec") not in (None, "aac", "mp4a"):
return False
return os.path.splitext(src)[1].lower() in (".mp4", ".m4v", ".mov")
def evaluate_quality_gate(info, src, mean_ssim, bad_share, vbps, obps):
capped = bool(vbps) and obps >= (vbps * SIZE_CEIL * CAP_BOUND_SHARE)
systematic = bad_share > MAX_BAD_SHARE or (mean_ssim < SSIM_TARGET)
# 针对原生兼容格式启用二档空间回收门禁 (0.990)
if systematic and bad_share <= MAX_BAD_SHARE and mean_ssim >= SSIM_MEAN_MIN_FALLBACK:
if space_reclaim_source(info, src):
return "PASS_SECOND_TIER", "原生全兼容文件:按二档 0.990 空间回收门禁放行"
if systematic:
# 如果输出已触碰码率封顶,重试在数学上完全惰性,立即阻断
if capped:
return "FAIL_CAPPED", f"SSIM {mean_ssim:.4f} 未达标,但码率已触顶 ({obps/vbps*100:.1f}%) - 跳过无效重试"
return "RETRY", f"启动画质补偿重试 (CRF - {QUALITY_STEPS})"
return "PASS", "完美通过"
```
---
## 交付结果
将新策略部署至生产节点后:
* 此前在 `crf 19` 与 `crf 14` 之间死循环报错 `FAIL quality 0.99192` 的测试文件,在首轮直接以 `PASS_SECOND_TIER` 顺利入库(体积为原片的 87.5%,零重试,单次编码用时 120 秒)。
* 临界文件上的 106 小时无效算力浪费被**彻底清零**。
* 由于不再对封顶文件发起注定失败的第二轮重转,长队列的整体处理吞吐量提升了 **31%**。
在构建大规模自动化转码管线时,切忌脱离码率控制策略去孤立考核客观画质指标。当 CRF 遇上 `-maxrate`,退让的永远是画质,而不是天花板。
````
---
## 附录二:C 篇全文 `why-ffmpeg-map-0-breaks-your-mp4-and-mkv-remux`
### EN
````markdown
---
ogImage: "/og/why-ffmpeg-map-0-breaks-your-mp4-and-mkv-remux.jpg"
banner: "/banners/why-ffmpeg-map-0-breaks-your-mp4-and-mkv-remux.jpg"
title: "Why ffmpeg -map 0 Breaks Your Remux: The Hidden Matrix of Multi-Track MP4 and Matroska"
description: "Why using -map 0 silently aborts Matroska muxers, drops subtitle fonts, and corrupts Apple playback: an empirical teardown of container constraints across multi-track media pipelines."
pubDate: 2026-10-27
category: engineering
blueprint: self-hosted-infrastructure
blueprintLayer: L1-compute-storage-network
tags: ["ffmpeg", "containers", "mp4", "matroska", "subtitles", "media-pipeline"]
draft: false
summary:
audience: "Media Systems Engineers, Full-Stack Architects & Streaming DevOps"
painPoint: "A media remuxing daemon using naive -map 0 silently crashed on container conversions, dropped stylised subtitles, and broke track selection on iOS clients."
impact:
- metric: "Zero Muxer Aborts"
detail: "Isolated incompatible QuickTime chapters and mobile timecode streams (tmcd) that caused Matroska muxers to fatal-error on generic -map 0 invocations."
- metric: "100% Subtitle Survival"
detail: "Architected deterministic conversion from ASS/SRT into mov_text for MP4 while preserving bit-exact font attachments in Matroska."
- metric: "Cross-Platform Metadata"
detail: "Dual-wrote track titles to both udta/name (Apple) and hdlr (ffprobe/VLC), solving the silent loss of track names during secondary remux passes."
stack: ["FFmpeg", "ffprobe", "Python", "Matroska/MP4 Specifications"]
jumpLinks:
business: "#the-naive-map-0-that-breaks-in-production"
architecture: "#the-hidden-matrix-of-container-behavior"
result: "#the-result"
dynamicCta: true
---
## The naive `-map 0` that breaks in production
Every developer automating FFmpeg reaches for the same shortcut when preserving secondary
tracks during a transcode:
```bash
ffmpeg -i source.mkv -map 0 -c:v libx265 -c:a copy output.mp4
```
The logic seems bulletproof: map all streams (`-map 0`), transcode the video stream to save bits,
and copy every accompanying audio track, subtitle, and chapter without losing data.
In production across a large, diverse video archive, this command fails catastrophically in three
distinct ways:
1. **It hard-crashes on Matroska output**: If an MP4 source contains Apple QuickTime text chapters (`bin_data`) or iPhone timecode tracks (`tmcd`), FFmpeg aborts instantly:
`Only audio, video, and subtitles are supported for Matroska.`
2. **It hard-crashes on MP4 output**: In standard stream copy mode (`-c:s copy`), any ASS, SSA, SRT, or graphical Bitmap (PGS) subtitle triggers fatal aborts:
`Could not find tag for codec ass in stream #2, codec not currently supported in container.`
Even when enabling subtitle transcoding to `-c:s mov_text`, graphical bitmap subtitles (`hdmv_pgs_subtitle` or `dvd_subtitle`) cannot be converted into text atoms and cause identical muxer crashes.
3. **It silently strips font attachments**: When remuxing into MP4, attached `.ttf` or `.otf` styling fonts cause fatal errors: `Could not find tag for codec ttf`.
4. **It silently wipes audio track names on iOS**: Apple devices read track labels from `udta/name`, whereas VLC, ExoPlayer, and ffprobe read from `hdlr/handler_name`. FFmpeg's demuxer only reads `handler_name` back, so a secondary remux silently drops the Apple tag.
To build an automated, crash-proof media pipeline, you cannot treat containers as transparent
envelopes. You need an explicit, policy-driven stream planner.
---
## The hidden matrix of container behavior
Through automated test fixtures and packet-level MD5 hashing across thousands of source files,
I mapped the exact boundaries between MP4 and Matroska (`.mkv`):
| Feature / Track Type | MP4 (`.mp4`) Constraints | Matroska (`.mkv`) Behavior |
|---|---|---|
| **Audio Codecs** | Officially supports AAC. AC-3/E-AC-3 work on Apple, but Android player matrices break. | Accepts anything bit-exact (FLAC, DTS-HD, TrueHD, Opus, AAC). |
| **Text Subtitles** | **Only accepts `mov_text` (tx3g)**. ASS, SRT, WebVTT cause immediate muxer fatal aborts. | Copies ASS, SSA, SRT verbatim without re-encoding. |
| **Subtitle Styling** | `mov_text` strips fonts, positioning, shadows, and karaoke fades. Only basic bold/italic survives. | Preserves all Aegisub styling, override tags, and positioning. |
| **Font Attachments** | **Refused outright**. `Could not find tag for codec ttf`. | Carried verbatim bit-for-bit via `0:t` stream mapping. |
| **Timecode / Chapter Data** | Holds QuickTime text and `tmcd` streams natively. | **Fatal error** if passed raw QuickTime/timecode data tracks. |
| **Metadata Preservation** | Drops `creation_time` without `-map_metadata 0`; drops custom tags without `-movflags +use_metadata_tags`. | Preserves arbitrary key-value tags natively. |
Notice the asymmetry: **MP4 is an end-user distribution container with strict parser gates,
while Matroska is a high-fidelity archival container.** Treating them identically with `-map 0`
guarantees pipeline crashes.
**Why not standardize exclusively on MKV?** Because container selection is dictated by downstream client decoding contracts. Native Apple playback engines (`AVPlayer` on iOS/macOS and WebKit `