WSL + 公司代理环境下打通 npm / HyperFrames / Chrome 的三层配置实战

WSL + 公司代理环境下打通 npm / HyperFrames / Chrome 的三层配置实战

TL;DR:npx hyperframes doctor 一句"timeout 5s"背后,其实藏着 机器代理配置、Node 子进程代理库、puppeteer 下载源、TLS MITM 拦截 四层完全独立的故障。本文把这一路从代码层到运维层的踩坑串成一条线,文末给出一份可直接复用的一键配置脚本。


一、背景

最近在 WSL2 里跑 OpenMontage 做视频合成项目,其中一条渲染管线依赖 HyperFrames 这个 npm 包。

npx hyperframes doctor 时,工具链报:

hyperframes 不通过,
原因:npm package 'hyperframes' not resolvable: timeout (5s) — offline or slow registry

一句话听上去像是"网络问题",但追下去之后,机器配置、Node 子进程代理、puppeteer 下载源、TLS MITM 拦截这四层全部中招。任何一层没修,doctor 都过不了,后面跑 render 命令是空谈。

本文把整个排查过程完整记录下来——既给当时的我自己留个档,也希望下次在 WSL 里用 npm 包的人少踩一两个坑。


二、环境起始状态

组件 情况
OS Ubuntu 22.04 (WSL2)
Node.js v22.23.1
环境变量 HTTPS_PROXY http://127.0.0.1:7897(活的)
~/.npmrc proxy http://127.0.0.1:7890(死端口,别人留的)
1.0/2.0 业务 ffmpeg、remotion、character-animation 都 ✅

故事从一个"看起来应该是网络问题"的报错开始。


三、第一层:工具链 —— npm view CLI 本身卡死

3.1 现象

OpenMontage 的检测代码大致长这样:

proc = subprocess.run(
    ["npm", "view", "hyperframes", "version"],
    capture_output=True, timeout=5,
)
if proc.returncode != 0 or not proc.stdout.strip():
    return {"error": "timeout — offline or slow registry"}

我一开始也跟着思路怀疑"npm registry 抽风",但打开终端一验:

$ curl -sS --max-time 5 https://registry.npmmirror.com/hyperframes
{"dist-tags":{"latest":"0.7.42"},"name":"hyperframes",...}
# 0.2s,丝滑

$ time npm view hyperframes version
# 60 秒,无输出,然后超时

诡异的是 —— npm view react version 也挂,npm view @hyperframes/cli 也挂,对任何包都一样

npm view 60s 卡死 vs curl 200ms 即回

3.2 根因

翻 GitHub 上相关 issue 才搞清楚:部分 npm 国内镜像(主要是 npmmirror 的 npm-compatible 端点)对 npm view 走的接口协议支持不全,会让整个 CLI hang,而不是报个清晰的错。registry.npmjs.org 自己没问题,但 npm view 通过某些镜像的兼容层就是会卡。

3.3 修复:绕过 npm view,直接 HTTP 探 registry

既然 curl 跑得通,我把检测逻辑改成自己发 HTTP:

import urllib.request
import urllib.error
import json


def _resolve_npm_package(name: str, registry: str) -> dict:
    """直接 GET https://<registry>/<name>,从 dist-tags.latest 取版本。
       任何异常都翻译成简短的错误信息,绝不抛。"""
    url = f"{registry.rstrip('/')}/{name}"
    try:
        req = urllib.request.Request(url, headers={"Accept": "application/json"})
        with urllib.request.urlopen(req, timeout=10) as resp:
            payload = json.loads(resp.read())
        if payload.get("error"):
            return {"error": f"registry reported: {payload['error']}"}
        version = (payload.get("dist-tags") or {}).get("latest")
        if not version:
            return {"error": "registry returned no `dist-tags.latest`"}
        return {"version": str(version), "registry": registry}
    except urllib.error.HTTPError as e:
        if e.code == 404:
            return {"error": f"npm package `{name}` not found (404)"}
        return {"error": f"registry HTTP {e.code} for {url}"}
    except urllib.error.URLError as e:
        reason = str(getattr(e, "reason", "")) or type(e).__name__
        return {"error": f"registry unreachable: {reason}"}
    except (TimeoutError, OSError) as e:
        return {"error": f"registry probe failed: {type(e).__name__}"}

外层再加一个三级 registry 来源,给运维留个 kill-switch:

registry = (
    os.environ.get("HYPERFRAMES_NPM_REGISTRY")          # 1. 手动覆盖
    or _read_npm_config_registry()                     # 2. `npm config get registry`
    or "https://registry.npmjs.org"                    # 3. 官方兜底
)

_read_npm_config_registry 仍然是调 npm config get registry —— 这个命令不会像 npm view 那样挂(只读配置、不发请求),10s 内必定返回。

3.4 教训 ①

npm view 的可用性 ≠ registry 的可用性。

排查 npm 相关问题第一步永远是 curl <registry>/<pkg>,再决定要不要怀疑 CLI。检测代码也不要写"调 npm view 然后相信它",自己在 Python 里发 HTTP 比启子进程稳得多,而且能拿到精准的 HTTP code / timeout / JSON 错误。


四、第二层:机器配置 —— ~/.npmrc 里的死代理

4.1 现象

代码层修了,重跑 doctor,还是 runtime_available: false。这次跟到 npm CLI 本身。

$ npm config list | head -10
registry = "https://registry.npmmirror.com"
proxy = "http://127.0.0.1:7890"        # ← 死端口
https-proxy = "http://127.0.0.1:7890"   # ← 死端口

ss 一看:

$ ss -tln | grep -E "7890|7897"
# (7890 不在监听)
# (7897 在监听 —— 是当前活跃的代理)

原来这台机器之前有另一个代理跑在 7890,后来改成了 7897,~/.npmrc 没跟着改curl 走环境变量绕过去了,但 npm~/.npmrc,所有出站流量全卡在 7890 上超时。

7890 死端口 vs 7897 活端口 — ss -tln 真相

4.2 修复

修改全局配置前,先备份。这种文件改错一个字符,后面 npm i -g / npx 全线出问题:

cp ~/.npmrc ~/.npmrc.bak.$(date +%Y%m%d_%H%M%S)
cat > ~/.npmrc <<'EOF'
registry=https://registry.npmmirror.com
proxy=http://127.0.0.1:7897
https-proxy=http://127.0.0.1:7897
EOF

验证:

$ time npm view hyperframes version
0.7.42
real    0m0.342s

对比之前 60s 超时。

4.3 教训 ②

~/.npmrc 是机器级全局配置,优先级高于环境变量,且不会"代理下线了自动失效"。

这台机器经历了一次 7890 → 7897 的端口迁移,但 ~/.npmrc 没自觉地跟上。在公司 / 实验室环境里特别常见——换代理工具 / 换 VPN 客户端 / 重新部署 GFW 工具,都不会主动帮你修复这个文件。


五、第三层:Node 子进程 —— global-agent 不认 HTTPS_PROXY

5.1 现象

npm view 通了,再跑 npx hyperframes doctor。npm 自己把 50+ 个 tarball 全拉下来,global-agent,esbuild,sharp,protobufjs 的 postinstall 也都过了,最后卡在 onnxruntime-node@1.27.0 的 postinstall

$ ps -ef | grep -E "onnx|install"
node ./script/install     # ← 0% CPU,不死不活

挂死。

5.2 根因

打开 onnxruntime-node/script/install.js 第一行:

const { bootstrap } = require("global-agent");
bootstrap();   // 读 GLOBAL_AGENT_HTTPS_PROXY

global-agent 是 node 包常用的全局代理拦截库,跟 curl 用的 HTTP_PROXY / HTTPS_PROXY两套独立的 env 名:

工具 读什么 env
curl / wget / 大多数 cli HTTP_PROXY / HTTPS_PROXY / http_proxy / https_proxy
node + global-agent GLOBAL_AGENT_HTTP_PROXY / GLOBAL_AGENT_HTTPS_PROXY / GLOBAL_AGENT_NO_PROXY
node + global-tunnel global_tunnel 配置对象
Python requests HTTP_PROXY / HTTPS_PROXY(同 curl)

我们环境里只设了前者,所以 onnxruntime-node 试着裸连 nuget.org / huggingface 拉 native binary,代理中间拦截一切入站,直接挂死。

node 生态代理 / 换源 env 速查

5.3 修复(还没完)

补齐 global-agent 的 env:

export GLOBAL_AGENT_HTTPS_PROXY=http://127.0.0.1:7897
export GLOBAL_AGENT_HTTP_PROXY=http://127.0.0.1:7897
export GLOBAL_AGENT_NO_PROXY=localhost,127.0.0.1

继续跑 —— 又报 unable to verify the first certificate

5.4 TLS MITM 拦截

打开 7897 验证证书:

$ openssl s_client -connect 127.0.0.1:7897 -servername nuget.org < /dev/null 2>&1 \
    | openssl x509 -subject -issuer -noout
subject=CN = localhost
issuer=CN = Some Local Proxy CA

7897 是 MITM 代理,给所有出站的 HTTPS 都换成了自己的证书node 默认严格校验 TLS,这种"上游签名但被换成人家的证书"是过不了的。

最直接的兜底:

export NODE_TLS_REJECT_UNAUTHORIZED=0

⚠️ 不建议长期开。 这相当于让 node 完全相信所有 HTTPS 证书,中间人可以假装任何网站。生产环境应该改成 export NODE_EXTRA_CA_CERT=/path/to/proxy-ca.pem,把代理根 CA 加进信任链。

5.5 教训 ③

node 生态里代理 env 不是统一的。

global-agentglobal-tunnelundicinode-fetch 各自有不同的代理 env。装一个 native binary 经常挂着卡死,先 ps 看一下 0% CPU 的子进程到底在调什么,再翻它 script/install.* 的源码而不是去搜"代理配置"。


六、第四层:下载源 —— puppeteer 默认去 googleapis.com

6.1 现象

到这一步 doctor 已经跑通,只剩一个红 ✗:

✗ Chrome    Chrome Headless Shell is required for local rendering.
            Run: npx hyperframes browser ensure

browser ensure,进度条凝在 4% 不动。1 分钟后 4%,5 分钟后 5%。

$ time curl -L -o /dev/null \
    "https://storage.googleapis.com/chrome-for-testing-public/131.0.6778.85/linux64/chrome-headless-shell-linux64.zip"
# 17 KB/s,107MB 完整下载要 100+ 分钟

7897 严重限制 googleapis 出站。

6.2 根因

好奇 npmmirror 啥都镜像,翻了一眼有惊喜:

$ curl -s "https://registry.npmmirror.com/-/binary/chrome-for-testing/"
[{"name":"113.0.5672.63/"}, {"name":"131.0.6724.0/"}, ...]

$ curl -s "https://registry.npmmirror.com/-/binary/chrome-for-testing/131.0.6778.85/linux64/"
[{"name":"chrome-headless-shell-linux64.zip"}, ...]

完全镜像,版本号精确匹配,测速:

$ time curl -L -o /dev/null \
    "https://registry.npmmirror.com/-/binary/chrome-for-testing/131.0.6778.85/linux64/chrome-headless-shell-linux64.zip"
real    0m22.219s

22 秒 vs 100+ 分钟。

6.3 修复:PUPPETEER_DOWNLOAD_BASE_URL

puppeteer 留了换源 env hook:

export PUPPETEER_DOWNLOAD_BASE_URL=https://registry.npmmirror.com/-/binary/chrome-for-testing

npx hyperframes browser ensure 3 分钟搞定,Chrome v131.0.6778.85 装到 ~/.cache/hyperframes/...

6.4 教训 ④

几乎每个用 Chromium / Electron 的 Node 工具都有"换下载源"的 hook。

速查:

工具 换源 env
puppeteer PUPPETEER_DOWNLOAD_BASE_URL
playwright PLAYWRIGHT_DOWNLOAD_HOST + PLAYWRIGHT_BROWSERS_PATH
electron ELECTRON_MIRROR
phantomjs PHANTOMJS_CDNURL
node-pre-gyp npm_config_<binary_name>_host / npm_config_<binary_name>_proxy

遇到下载慢,第一反应是查文档而不是干等


七、一键复用模板

把上面所有发现合并成两个文件,以后任何 WSL 里跑 npx hyperframes 前都先 source:

~/.npmrc(机器级,只这一次)

registry=https://registry.npmmirror.com
proxy=http://127.0.0.1:7897
https-proxy=http://127.0.0.1:7897

~/hf-env.sh(项目级,放进 repo 也行)

#!/usr/bin/env bash
# 7897-style MITM 代理 + 国内镜像 的标配组合。
# 在 WSL 里跑任何 npx 出来的 node 工具前,先 source 它。
#
#   $ source ./hf-env.sh
#   $ npx --yes hyperframes doctor
#   $ npx --yes hyperframes browser ensure

# MITM 拦截器临时关掉证书校验 (生产环境改 NODE_EXTRA_CA_CERT)
export NODE_TLS_REJECT_UNAUTHORIZED=0

# global-agent 不认 HTTP_PROXY,要用 GLOBAL_AGENT_*
export GLOBAL_AGENT_HTTPS_PROXY=http://127.0.0.1:7897
export GLOBAL_AGENT_HTTP_PROXY=http://127.0.0.1:7897
export GLOBAL_AGENT_NO_PROXY=localhost,127.0.0.1

# puppeteer 拉 Chrome 用国内镜像
export PUPPETEER_DOWNLOAD_BASE_URL=https://registry.npmmirror.com/-/binary/chrome-for-testing

# 顺便把 playwright / electron / phantomjs 一并兜底
export PLAYWRIGHT_DOWNLOAD_HOST=https://registry.npmmirror.com
export ELECTRON_MIRROR=https://registry.npmmirror.com/-/binary/electron/
export PHANTOMJS_CDNURL=https://registry.npmmirror.com/-/binary/phantomjs

一键安装

source ./hf-env.sh
npx --yes hyperframes doctor          # 验证环境(应该全 ✓)
npx --yes hyperframes browser ensure  # 安装 Chrome Headless Shell (3 分钟)

八、环境验证:Demo 测试(必做,不是可选)

doctor 全绿 ≠ 环境真能用。 静态检查不会真的跑渲染链路,也不会暴露 edit_decisions 字段的兼容性 bug —— 只有跑一次端到端 demo 才能发现。所以装完环境之后,跑一个最小 demo 是必要的一步,不是"想看看效果"的可选项。

8.1 最小 demo 的 4 个 artifact

不需要跑完整 8 阶段 pipeline(那要 API key + 30-60 分钟),只构造 4 个最小 JSON artifact 就能让 hyperframes_compose 跑通:

projects/hf-html-demo/
├── artifacts/
│   ├── script.json              4 sections,纯文字
│   ├── scene_plan.json          4 scenes, 全 text_card / hero_title
│   ├── asset_manifest.json      {"assets": []}   ← 纯文字 demo 不需要素材
│   └── edit_decisions.json      render_runtime="hyperframes"
└── renders/
    └── final.mp4                ← 渲出来

edit_decisions.json 关键字段:

{
  "version": "1.0",
  "render_runtime": "hyperframes",     ← 锁这个 runtime
  "composition_mode": "templated",     ← 走 stock 模板
  "cuts": [
    {"id": "cut-1", "type": "hero_title", "in_seconds": 0,   "out_seconds": 2.5, "text": "OpenMontage"},
    {"id": "cut-2", "type": "text_card",  "in_seconds": 2.5, "out_seconds": 4.5, "text": "is"},
    {"id": "cut-3", "type": "hero_title", "in_seconds": 4.5, "out_seconds": 7,   "text": "alive"},
    {"id": "cut-4", "type": "text_card",  "in_seconds": 7,   "out_seconds": 10,  "text": "end-to-end demo · 2026"}
  ],
  "audio": {"narration": {"segments": []}, "music": {}}      ← 注意 music: {},不是 null
}

8.2 跑 demo

source ./hf-env.sh
python3 -c "
from tools.tool_registry import registry
from tools.video.hyperframes_compose import HyperFramesCompose
import json

registry.discover()
hf = HyperFramesCompose()
edit = json.load(open('projects/hf-html-demo/artifacts/edit_decisions.json'))
am   = json.load(open('projects/hf-html-demo/artifacts/asset_manifest.json'))

result = hf.execute({
    'operation': 'render',
    'workspace_path': 'projects/hf-html-demo/hyperframes',
    'output_path':    'projects/hf-html-demo/renders/final.mp4',
    'edit_decisions': edit,
    'asset_manifest': am,
    'profile': 'tiktok',       # 1080x1920 竖屏
    'quality': 'draft',        # 快速出,标准要慢一些
    'fps': 30,
    'strict': False,
})
print('success =', result.success, '| error =', result.error)
print(json.dumps({k: v.get('exit_code') for k, v in result.data.get('steps', {}).items()
                   if isinstance(v, dict) and 'exit_code' in v}, indent=2))
"

8.3 期望输出

success = True | error = None
{
  "lint": 0,
  "validate": 0,
  "render": 0
}

4 步全 exit=0 才算真验证通过:

  • lint 静态检查过
  • validate 浏览器跑过(用到了 Chrome Headless Shell)
  • render ffmpeg 封装过
  • 最终 MP4 用 ffprobe 能解析
$ ffprobe -v error -show_entries format=duration,size:stream=codec_name,width,height,r_frame_rate \
    -of json projects/hf-html-demo/renders/final.mp4
{
  "streams": [{"codec_name":"h264","width":1080,"height":1920,"r_frame_rate":"30/1"}],
  "format": {"duration":"10.000000","size":"219528"}
}

完整跑通后,10 秒 1080×1920 竖屏 MP4 应该 200-300 KB 大小,这是纯文字动画的正常范围。

8.4 这次跑 demo 顺手发现的 1 个 bug

第一次跑时,edit_decisions.audio 我传的是 "music": null —— 当时觉得"没音乐"就该是 null。炸了:

AttributeError: 'NoneType' object has no attribute 'get'
  at _resolve_audio_refs line 917
   →  m_id = music.get("asset_id")

原因在源码里:

music = audio.get("music", {})   # 默认值 {} 只在 key 不存在时生效
m_id = music.get("asset_id")     # 如果 audio["music"] = None,这里 None.get 炸

修法:"music": {} 或者干脆不传这个 key。这种类型注解缺失 + 默认值不被 None 触发的 bug 静态检查不会发现,只有真的渲染才暴露 —— 这正是为什么"环境装好之后跑个 demo"是必要的一步。

8.5 真渲出来的样子(5.5s 一帧)

rendered 第五秒帧:"alive" + 副标题

仔细看这帧,会发现 .text-cardh1(白字) 和 subtitle(橙字) 同时显示了——这是因为 edit_decisions.cuts[].subtitletext 都设了同一个值,默认会全显示。

属于"demo 自己也有 bug"那一类问题,不影响"环境验证通过"的结论(MP4 出片了、像素对、4 步全 exit 0),但要上线产品 demo 时得修。


九、最终状态

$ source ./hf-env.sh && npx hyperframes doctor

  ✓ Version          0.7.42 (latest)
  ✓ Node.js          v22.23.1 (linux x64)
  ✓ CPU              14 cores · 13th Gen Intel(R) Core(TM) i7-13700H
  ✓ Memory           23.5 GB total · 20.5 GB available
  ✓ Disk             857.5 GB free
  ✓ /dev/shm         12016 MB
  ✓ Environment      WSL · non-TTY
  ✓ whisper-cpp      /home/stl/.local/bin/whisper
  ✗ TTS (Kokoro)     Not installed (optional — local voice fallback)
                     pip install kokoro-onnx soundfile
  ✗ BGM (MusicGen)   Not installed (optional — local music fallback)
                     pip install transformers torch soundfile numpy
  ✓ FFmpeg           ffmpeg 6.1.1-3ubuntu5 at /usr/bin/ffmpeg
  ✓ FFprobe          ffprobe 6.1.1-3ubuntu5 at /usr/bin/ffprobe
  ✓ Chrome           cache: /home/stl/.cache/hyperframes/chrome/chrome-headless-shell/linux-131.0.6778.85/chrome-headless-shell-linux64/chrome-headless-shell
  ✓ Docker           Docker version 29.5.3, build d1c06ef
  ✓ Docker running   Running

  ◇  Some checks failed — see hints above

最终状态:hyperframes doctor 14 项 12 绿

九个 ✓ 覆盖了渲染链路核心依赖(版本、Node、CPU/Mem、磁盘、FFmpeg、Chrome、Docker、whisper),剩下两个 ✗ 都是 可选的本地兜底(Kokoro TTS / MusicGen BGM),有 ElevenLabs / Suno API key 时根本不需要装。


十、踩坑共同模式与排查心法

这次连环故障的共同模式:每一层都让"某个"步骤看上去像超时,第一反应都是"是不是配置错了 / 网不行",但实际可能是检测方式本身不可靠。

通用排查顺序:

  1. curl -v <url> —— 网络通?证书对?DNS 解析正常?
  2. npm config list + ss -tln —— 配置和端口对得上?
  3. 换成直接 HTTP / 换个工具 / 换个源 —— 是"工具"问题还是"网速"问题?
  4. ps -ef 看子进程 CPU 时间 —— 0% CPU 挂死通常是"在等什么资源"而不是"在算"。看进程代码。
  5. 把每条报错的协议层显式化 —— "timeout" 这个词要翻译成"具体 timeout 在哪一步"(TCP 三次握手、TLS 握手、TLS 校验、HTTP 响应头、HTTP body)。

把这些套路沉淀成启动脚本,后人接手同样环境能省半天——这也是我一直推崇"环境即代码"的原因:写在文件里的不再只是一份 .npmrc,而是你对这套代理链路的整套理解。


十一、参考资料


题外话:这四层修复加起来不到 30 行代码/配置,但每一层都得手动测一遍才知道哪一层有坑。从最初一个 "5s timeout" 的报错,到最终 doctor 全绿、Chrome 装好,我大概花了三个小时。如果有人看完这篇能省下这三个小时中的一个小时,本文的目的就达到了。

(完)

posted @ 2026-07-08 19:20  suntl  阅读(25)  评论(0)    收藏  举报