创空间素材墙:部署与持久化
把「拖素材 → 转化 → 陈列 → 容器重建后还在」这条路走通。参考实现:
C:\Users\2300G\Downloads\ms-studio-anim(app.py / gallery.py / convert.py / git_sync.py)。
0. 第一动作:量体积,别读代码
素材在部署后不显示时,九成是文件根本没进仓库,不是代码 bug。 先证明这一点,再动代码。
在构建日志里找 transferring context:,和素材实际体积对账:
#9 transferring context: 149.62kB <- 本次
#5 transferring context: 34.92MB <- 正常对照
src/ 里 11 个 webp 合计: 2318 KB
149.62 kB ≈ 只有代码(73.9 KB)——2.3 MB 素材一个都没进去。此时改任何代码都不会让素材出现,必须先传文件。
对账脚本(本地先跑一遍,确认素材体积):
Get-ChildItem src -File | Measure-Object Length -Sum
别跳过这步。 本次就是靠它 5 分钟定位,跳过就会在 scan() 里空转半天。
1. 持久化分三档,别混为一谈
这是最容易讲错、也最容易吓到用户的地方。
| 档位 | 手段 | 需要 token? | 容器重建后 |
|------|------|-------------|-----------|
| A 平台提交 | 素材拖进空间文件页,平台自己 commit | 不需要 | 还在(git pull 匿名拉回) |
| B 应用写回 | 容器内点「转换并入库」,app 自己 git push | 需要 MS_TOKEN | 还在 |
| C 都没走 | 只在容器本地生成 | 不需要 | 丢 |
公开空间能匿名 pull,不能 push。 can_pull=True, can_push=False 属于档位 A 完全可用的正常状态。
对应文案(本项目 git_sync.guard() 的正确写法):
if STATE.can_push:
head = "🟢 产物已持久化" # 档位 B
elif STATE.can_pull:
head = "🟢 已从仓库载入(写回未启用)" # 档位 A:正常!不是黄灯
else:
return "🔴 产物未持久化:" + ... # 真的没保障
写反了的两种典型症状
- 把「不能写回」说成「现有资产会丢」,于是公开空间(用户的工作流)长期挂黄灯/警告。
本次原始文案:
🟡 只能读:产物能拉回,但容器重建后新转的会丢(需 MS_TOKEN 或公开空间)——「或公开空间」尤其误导:当前已经是公开空间。 - 让用户去申请 token 解决一个 token 根本解决不了的问题。用户明确说过: 「实际上不需要 token 也可以持久化」——因为他们走文件页(档位 A)。信用户的实际工作流,别按代码里的假设写文案。
2. 目录契约
src/ 投料。两种身份,要分开
├─ 成品 .webp / .gif → 直接显示,不必转换,**必须入库**
└─ 源素材 .mp4/.html → 待转换,出料到 gallery/,**不入库**
gallery/ 出料。app 转换时自动写;手工丢进去也能显示并持久化,但别这么做
同一目录下混着两种身份是本项目的核心设计,别为了「干净」把它们拆开——投料口只有一个, 用户不用记去哪儿传。
坑:src/*.gif 身份是双重的。 它既是可直接显示的成品,又是转换输入。
所以 src/logo.gif + 转出 gallery/logo.gif 会撞卡片名 → 必须按卡片名去重(见 §6)。
「成品直显」这条路径同时是依赖问题的逃生舱——它让创空间装不上浏览器也无所谓(见 §11)。
3. .gitignore 反向规则的坑(踩过)
要放行 src/ 里的成品,不能写 src/:
# 错:整个目录被排除,后面的反向规则永远不生效
src/
# 对:src/* 匹配「目录下的条目」而非目录本身,反向规则才有机会生效
src/*
!src/.gitkeep
!src/*.webp
!src/*.gif
gitignore 规则:逐条匹配,最后一条命中者胜;* 不跨 /;! 表示取消忽略;
父目录被排除则子文件不可救(这也是必须用 src/* 的原因)。
更狠的坑:git rm --cached
# 错:这会在下次 push 时把已上传的成品从版本库摘掉=删掉用户的素材
_run(["rm", "--cached", "-r", "--quiet", "src"], 30)
「启动时确保规则生效」这个直觉很自然,但它单向不可逆——解除跟踪后, 下次提交就是一条删除。忽略规则已经够了,运行时不要再动索引。
验证忽略语义(本地没装 git 时,用规则引擎模拟而不是 fnmatch):
def to_re(pat):
p, out, i = pat.rstrip('/'), '', 0
while i < len(p):
if p.startswith('**', i): out += '.*'; i += 2
elif p[i] == '*': out += '[^/]*'; i += 1 # 不跨 /,关键
elif p[i] == '?': out += '[^/]'; i += 1
else: out += re.escape(p[i]); i += 1
return re.compile('^' + out + r'(/.*)?$')
自测这段模拟器时踩过一次:直接用
fnmatch会让*跨/, 于是src/nested/e.webp被判成「未忽略」——错的是模拟器,不是规则。 gitignore 的*不跨/,src/*只匹配src下直接一层并排掉整个子目录。 写模拟器务必按 gitignore 语义实现,别图省事用fnmatch。
4. 让空态会自诊
页面空白不给用户任何可执行信息,是「素材没传」变成悬案的原因。两处都要给:
启动日志(git_sync.inventory(),pull() 之后再统计,否则量的是拉取前):
[git] src/ 成品 11 个 / 2318 KB,源素材 0 个
[git] ⚠ src/ 有 3 个源素材但没有成品,页面会显示为空:a.mp4、b.html、c.mp4
有素材、显示却为空 → 直接喊出来,别只留一句「0 个」。
空态文案要点名最可能的原因,而不是只说「请投料」:
如果文件明明传过却还是空的,多半是拖成了文件夹、或没传到
src/根下—— 逐个文件拖进目录,或看部署日志里的[git] src/ 成品 N 个。
文件页拖文件夹会产生 src/项目名/src/… 的嵌套层级,app 扫不到——这是「传了却不显示」
的第二大原因,仅次于「压根没传」。
5. Gradio 不提供项目里的文件(媒体全 404)
症状:卡片文字、标签、尺寸全都渲染出来了,只有画面是黑的/空的。日志里一条错误都没有。 这跟 §8 的「卡住」是同一种病——静默失败——但根因完全不同,别混。
在 gradio 6.17.3 上实测:
| URL 形式 | 结果 |
|----------|------|
| /src/x.webp(项目相对路径) | 404 |
| /file=x.webp(gradio 3/4 的老写法) | 404 |
| gradio_api/file=src/x.webp | 200 image/webp ✅ |
Gradio 只提供它自己管的文件(上传目录、缓存),项目目录默认一律不给。
三条硬规矩
-
真实路由是
/gradio_api/file=,别猜。 从 openapi 里查:spec = json.load(urllib.request.urlopen('http://127.0.0.1:PORT/openapi.json')) print([p for p in spec['paths'] if 'file' in p]) # -> ['/static/{path}', '/gradio_api/file={path_or_url}', ...] -
必须放行:
demo.launch(allowed_paths=[str(ROOT)])。不放行 → 403。 顺带确认allowed_paths是Blocks.launch的参数(本项目用_supported()按签名过滤参数,写错位置会被静默丢弃——又是一个「不报错就没了」的坑)。 -
用相对 URL,不带前导
/,这样挂在带路径前缀的反代后面也不会失效; 非 ASCII 文件名要quote()。
403 和 404 含义不同,别诊断反了
| 状态 | 含义 |
|------|------|
| 404 | 路由没匹配上 → URL 形式写错了 |
| 403 | 路由对了,但文件不允许/不存在 → 查 allowed_paths、查文件是否真在 |
端到端验证(别只断言字符串)
起真实 app,按页面实际生成的 URL 原样去取:
src = f"gradio_api/file={quote(it.rel)}?v={int(it.mtime.timestamp())}"
# 期望:200 + content-type=image/webp + 字节数与本地文件一致
三个我自己踩过的坑,写测试时一并避开:
- 别在测试里编文件名。 我写了
src/把风装进小花篮.webp(早前上下文里的旧名, 当前目录里没有),拿到 403 一度以为是allowed_paths配错。从Path('src').glob('*.webp')取真实名字。 gr.HTML的内容不在首屏 HTML 里,它走 API/队列下发。所以去 grepGET /找自己的<img src>永远找不到,别据此判断「URL 没生成」。直接调render_grid()拿 HTML。- 只断言
"gradio_api/file=" in html挡不住路由写错;要真的发一次请求。
附:只留一个滚动条
用户会直接圈出两根滚动条问「合并成一个就行」——这是内层容器又套了一层滚动区:
.an-scroll { max-height:72vh; overflow-y:auto } 和页面自身滚动条叠在一起。
/* 内层不再截断高度、不再自滚,整页滚即可 */
.an-scroll { overflow: visible; }
html { scrollbar-width: thin; scrollbar-color: #22d3ee #111; }
html::-webkit-scrollbar { width: 12px; }
断言时记得先剥掉 CSS 注释(re.sub(r"/\*.*?\*/", "", css, flags=re.S)),
不然注释里写的旧值 max-height:72vh 会让自己的断言命中。
6. 展示清单:优先级与去重要分开
同一卡片名可能有多份(src/ 成品 + gallery/ 产物)。先排优先级,再发名字。
本次真 bug:文档写「gallery 优先」,实现是「先扫到的占正名」,
而 scan() 先扫 src —— 结果 gallery 被降级成 甲-2,与文档相反。是自检抓出来的。
# 对:排序即优先级,别让遍历顺序隐式决定谁赢
ranked = sorted(cands,
key=lambda i: (i.rel.startswith("gallery/"), i.mtime),
reverse=True)
picked: dict[str, Item] = {}
for it in ranked:
if it.name not in picked:
picked[it.name] = it
continue
n = 2 # 落选的加序号,不藏文件、不撞名
while f"{name}-{n}" in picked: n += 1
it.name = f"{name}-{n}"
picked[it.name] = it
- gallery/ 优先(产物是归一化后的权威版本)
- 落选的加序号而不是丢弃(与
target_for()撞名规矩一致;丢弃=用户以为传了却没显示) - 每份都带
rel相对路径;渲染时quote()编码——中文文件名必须 URL 编码, 拼f"gallery/{name}"会拿到 404
自检必须写能抓住这类反转的用例:断言「同名时 gallery 拿正名」, 而不是只断言「名字不重复」——后者在反转时依然通过。
7. 自检纪律
- 只依赖标准库的模块要直接导入。
selftest.py里写G = A.git_sync, 而import app需要 gradio——本机没装 gradio 时,整个后半段(含持久化测试)静默不执行, 看上去「跑通了」。改成import git_sync as G,这段就永远跑得到。 - 断言契约,不断言实现。 「gallery 优先」「
.gitignore放行 webp」 是契约;「git add第三个参数」是实现,改实现就误报。 - 外部依赖(ffmpeg / 无头浏览器)测降级路径:标
unconvertible、 报告点名缺什么、不静默跳过。有边界的失败要能说清。 - 数维度守恒:
len(items) == len({i.name for i in items})这类断言很便宜, 每次改去重逻辑都该有。
本次结果:141 PASS / 0 FAIL(python selftest.py)。
8. 阻塞与静默挂起(本项目踩过的最隐蔽的一类)
真实事故:boot() 里 with _lock: 包住了 detect() 和 pull(),而这两个函数各自也要 with _lock:。
_lock = threading.Lock() 不可重入 → 自死锁。表现是页面永远停在「处理中」,
没有 traceback、没有报错、一条自己的日志都打不出来(卡在拿锁,还没走到 log())。
为什么难查
| 现象 | 误导方向 |
|------|---------|
| 没有异常、没有错误日志 | 以为代码没问题 |
| app 正常监听端口、HTTP 200 | 以为服务活着 |
| 浏览器显示「处理中」 | 以为在加载 / 以为网络慢 |
| 日志只有框架的 queue_join_helper 告警 | 以为只是 gradio 版本告警 |
卡在锁 / 卡在 socket 上的代码不抛异常,只把下游(HTTP 请求、gradio 队列)挂成静默长挂。
三条硬规矩
-
不可重入锁不能嵌套。把「各自会拿锁的函数」收进同一把锁里必然自死锁。 要么让它们各管各的锁,要么把整组操作抽成一个持锁的内部函数。
-
页面渲染绝不能依赖网络,同步完还要自动重渲染一次。
demo.load(on_load)这类回调一旦阻塞,浏览器永远「处理中」。所以要两段式: 先用本地文件立刻出一帧,git 同步丢后台线程,同步落地后自动再出一帧。 Gradio 的做法是把on_load写成生成器,每yield一次推一帧出去:BOOT_WAIT_S = 25.0 # 等后台同步的上限 def on_load(): threading.Thread(target=_boot_bg, daemon=True).start() # 第一帧:不等 git,用本地已有的素材出画面 yield (render_header("⏳ 正在从仓库同步…"), render_grid(gl.scan()), ...) # 第二帧:等同步完(**有上限**),自动刷新,用户不用手点 deadline = time.time() + BOOT_WAIT_S while time.time() < deadline and not BOOT["done"]: time.sleep(0.4) yield (render_header(git_sync.guard()), render_grid(gl.scan()), ...)三个别省的细节:
- 等待必须有上限。 死循环等同步 = 把刚修好的死锁又造回来一遍, 页面会永远停在「同步中」。到点就出第二帧,并在页面上提示可以手动刷新。
- 别让用户手点「刷新」。 修完死锁后我第一版是「先渲染 + 提示用户点刷新」, 用户直接回「同步后不会重新载入」——同步完本来就能自己刷, 把能自动做的事推给用户是设计缺陷。
- 同步期间头部别报红。 后台同步还没跑时
STATE全 False,guard()会凭空返回 🔴。给中性态⏳(下一条展开)。
对应的自检(生成器要真的取第一帧,别只测「调用没报错」):
G.boot = lambda: time.sleep(30) # 模拟 git 卡死 t0 = time.time(); gen = A.on_load() # 调用本身必须立刻返回 check("on_load 不等 git", time.time() - t0 < 5) check("第一帧就位", len(next(gen)) == 5) # 慢同步下第一帧也不能被拖 -
同步期间头部不能说「未持久化」。后台同步还没跑时
STATE全 False,guard()会返回 🔴,等于凭空吓用户一遍。同步未完成时给中性态(⏳)。
诊断指纹
症状:一直「处理中」/ 队列里
├─ 有 traceback → 正常报错,按栈修
├─ 有自己的日志 → 按最后一条日志往下推
└─ 零自有日志 + 只有框架告警 -> 怀疑卡在锁/socket/网络
→ 在关键入口加超时探测:起线程跑,超时即判失败
测试纪律(这条能挡住这类 bug)
自检里没调到的函数,等于没测。 事故前 123 项全绿,却从没调用过 boot()
——持久化用例把 _run mock 掉了,看起来覆盖了,实际没走到锁。
所以对「启动/初始化路径」要专门加:
bt = threading.Thread(target=lambda: (G.boot(), done.append(1)), daemon=True)
bt.start(); bt.join(timeout=10)
check("boot() 不会自死锁", bool(done), "10 秒未返回:boot 里嵌套了 with _lock")
以及「页面不被拖死」:
G.boot = lambda: time.sleep(30) # 模拟 git 卡死
t0 = time.time(); gen = A.on_load() # 生成器:调用本身必须立刻返回
check("on_load 不等 git", time.time() - t0 < 5)
check("第一帧就位", len(next(gen)) == 5)
推论:重构初始化函数时,锁的归属最容易改坏。 动 _lock 附近的代码,
先确认「谁持锁、谁要持锁」,再跑自检。
9. 文件编码与工具链卫生(BOM 会静默毁掉配置)
症状:配置明明写了,却「完全没生效」,而且没有任何报错。
根因在 Windows PowerShell 5.1:Set-Content -Encoding UTF8 和
Out-File -Encoding UTF8 会写 UTF-8 BOM(PS 7 才提供不带 BOM 的
utf8NoBOM)。文件开头因此多了 EF BB BF 三个字节。同一命令还会把
原本的 LF 全部改成 CRLF。
破坏面:
| 文件 | 后果 |
|------|------|
| SKILL.md | 加载器按字节找首行 ---,BOM 挡在前面 → frontmatter 解析失败 → 技能被过滤,永不加载 |
| YAML / TOML / JSON | 解析器直接报「格式不对」或静默忽略整份配置 |
| Python 源码 | 不会坏(PEP 263 明确允许带 BOM),但属于不该留的杂质 |
| 行尾 | LF 全变 CRLF,diff 噪音满屏 |
规矩:
-
在本仓库改文件不要用
Set-Content -Encoding UTF8/Out-File -Encoding UTF8。 -
要么用编辑工具的写文件,要么显式指定无 BOM:
[IO.File]::WriteAllText($p, $s, (New-Object System.Text.UTF8Encoding $false)) -
改完立刻验,别等到「技能怎么没反应」才发现:
b = Path(f).read_bytes() assert b[:3] != b'\xef\xbb\xbf', f'BOM! {f}' -
批量体检:
for f in Path('.').rglob('*'): if f.is_file() and f.suffix in ('.md', '.py', '.json', '.yaml', '.yml'): if f.read_bytes()[:3] == b'\xef\xbb\xbf': print('BOM!', f)
更一般的教训:静默失败比崩溃难查得多。 BOM 不抛异常、不留痕迹, 技能就是「不工作」。所以配置文件除了功能测试,还要加一条格式体检—— 和 §8 的死锁是同一类问题:不报错 ≠ 没问题。
10. 排障决策树
页面没有素材
├─ 构建日志 transferring context 远小于素材体积? → 素材没进仓库,先传文件
│ └─ 传了还不显示? → 看日志 [git] src/ 成品 N 个
│ ├─ N=0 → 拖成文件夹了 / 层级错了(src/项目名/src/…)→ 逐个文件拖进 src/ 根
│ └─ N 正常 → scan() 逻辑问题(扩展名?rel?URL 编码?)
└─ 上下文正常但仍空 → 看 scan() 认不认这个扩展名;.webp 不在 HANDLERS/OUT_EXT 就只当源素材
文字/标签都出来了,只有画面是黑的 ← 关键分叉:素材到位,但图没加载
└─ 见 §5:Gradio 不提供项目文件,每个 <img> 都 404,且不报错
├─ 404 → URL 形式错(真实路由是 gradio_api/file=)
└─ 403 → 路由对了但没放行 / 文件真不在 → 查 launch(allowed_paths=)
页面卡在「处理中」/ 队列里
└─ 见 §8:零自有日志 + 只有框架告警 = 怀疑死锁/网络阻塞,不是加载慢
页面出来了,但同步完不会自己刷新,得手点「刷新」
└─ 见 §8:on_load 没写成生成器两段 yield
├─ 只有一次 yield / 返回 tuple → 同步完没人推第二帧
├─ 等同步没有时间上限 → 页面永远停在「同步中」
└─ 同步期间头部报 🔴 → STATE 全 False,给中性态 ⏳
配置写了却完全没生效,且没有任何报错
└─ 见 §9:文件开头是不是多了 EF BB BF(BOM)→ frontmatter 解析失败
持久化报警
├─ can_pull + can_push → 🟢 正常
├─ can_pull only → 🟢 正常(档位 A:文件页提交即可,**不要**报警、不要劝人配 token)
└─ 都不能 → 🔴 空间非公开且无 token:匿名 pull 拉不回,仓库关掉或加 token
11. 转化环境与依赖
完整展开见 references/conversion-deps.md。要点:
| 投料 | 需要 | 创空间现状 |
|------|------|-----------|
| .mp4 / .webm | imageio-ffmpeg(pip 静态二进制,不碰 apt) | ✅ 可用,且基础镜像已自带(日志 Requirement already satisfied) |
| .gif | 只有 Pillow | ✅ 可用,零外部依赖 |
| .webp | 不转化,直接显示 | ✅ 可用 |
| .html / .htm | 无头浏览器 | ❌ 未配置,会降级成 unconvertible |
HTML 是唯一缺口,且是刻意的:
requirements.txt里 playwright 被注释掉——pip install playwright只下驱动, 二进制要另跑playwright install chromium --with-deps,在 requirements.txt 里表达不了。- 不要提交
packages.txt。 平台 Dockerfile 是if [ -f /tmp/deps/packages.txt ]; then apt-get update && ... xargs -r apt-get install -y。 启用的代价:apt 单线程很慢;ubuntu 22.04 源里没有 chromium → 构建直接失败;chromium-browser是 snap 过渡包(装上没有真 chromium);firefox-esr没有--screenshot。 所以本项目只提交packages.txt.example。
正确路径:HTML 本地转,再拖成品。
python tools/html2webp.py 本地.html # 产出等尺寸动图 webp
把 .webp 拖进 src/ → 走 §2「成品直显」,不需要 token,容器里也不需要浏览器。
依赖缺失正是被这条路径化解的,所以两节要一起看。
依赖缺失必须是可诊断的失败。 MP4 缺 ffmpeg / HTML 无浏览器 → 标 unconvertible
- 报告点名「缺什么、去哪装」,不许静默跳过。新增投料格式时,先在
selftest.py的「缺依赖时的降级」一节补上对应断言。
日志判据:运行日志里 [convert] 出现次数为 0 = 转化按钮从没被点过。
本次两份日志该计数为 0,所以当时无法判断 MP4/HTML 在创空间里通不通——
追转化问题前先确认这个标记出现过。
构建慢,先本地过一遍:一次创空间构建约 7 分钟,上传前跑 python selftest.py。
12. 本地改 → 上传创空间(分步手册)
本地项目根:C:\Users\2300G\Downloads\ms-studio-anim
先记住一件事:代码和素材是两条独立通道,改代码不会把素材带上去。 本地改完只是改了你自己的硬盘,创空间上什么都没变——这是本项目反复出现 「我明明改了怎么没变」的根源。
第 1 步:本地改
| 要改什么 | 改哪个文件 |
|---------|-----------|
| 页面长相 / 按钮 / 接线 | app.py |
| 转化逻辑(mp4/html/gif/ffmpeg/浏览器) | convert.py |
| 扫描、命名、去重 | gallery.py |
| 持久化、git 读写 | git_sync.py |
| 新增展示用动画 | 把 .webp / .gif 放进 src/ |
素材去向只有两个选择,别搞混(见 §2):
- 已经做好的
.webp/.gif→ 拖进src/,页面直接显示,不用点转换 - 还没做的
.mp4/.html→ 拖进src/,然后在页面上点「⚡ 转换并入库」
第 2 步:本地自检(必做,别跳)
cd C:\Users\2300G\Downloads\ms-studio-anim
python selftest.py
要看到 141 PASS / 0 FAIL 和末行 ALL PASS — 可以上传创空间了。
构建一次约 7 分钟,自检几秒——不跑自检就是拿 7 分钟赌一次 3 秒能查出来的问题。
首次运行需要 pip install "gradio==6.17.3",否则 import app 失败、
后半段测试静默不执行(见 §7)。
第 3 步:确认改动没写坏文件
自检通过 ≠ 格式没问题。改过配置/源码后顺手看一眼(理由见 §9):
python -c "from pathlib import Path; [print('BOM!', f) for f in Path('.').rglob('*') if f.is_file() and f.suffix in ('.md','.py','.json','.yaml','.yml') and f.read_bytes()[:3]==bytes([239,187,191])]"
第 4 步:上传——逐个文件拖,别拖文件夹
打开创空间的文件页:
- ✅ 逐个把文件拖进目标目录(
app.py拖到根、x.webp拖到src/) - ❌ 不要拖文件夹——会变成
src/ms-studio-anim/src/x.webp, app 只扫src/一层,直接扫不到(§4 记的第二大原因) - ❌ 不要拖
__pycache__/、.runtime/
需要一起传的:app.py(或你改过的其它 .py)、selftest.py、以及新增的素材。
第 5 步:看部署日志验三件事
构建日志:
#6 transferring context: 4.95MB <- 体积对得上素材量
18:37:31 [SUCCESS] run step successfully!
运行日志:
[git] 仓库可匿名拉取(空间是公开的):文件页已提交的产物会持久化
[git] src/ 成品 11 个 / 2318 KB,源素材 0 个
- 上下文体积远小于素材量 → 素材没进去,回第 4 步
src/ 成品 0 个→ 层级错了或拖成文件夹了,回第 4 步成品 11 个→ 到位了,进第 6 步
第 6 步:页面点「⟳ 刷新」
首屏会先显示本地已有素材并显示 ⏳ 正在从仓库同步…,
后台 git 同步完自动再渲染一次(§8),一般不用手动点。
想立刻强制拉取就点「⟳ 刷新」。
常见误操作对照表
| 现象 | 真实原因 | 怎么办 |
|------|---------|--------|
| 「我改了代码怎么没变」 | 本地改 ≠ 线上改,没传 | 第 4 步 |
| 「我传了素材怎么没有」 | 拖成了文件夹,层级多了一层 | 第 4 步 |
| 「卡片信息都对,就是图全黑」 | Gradio 不提供项目文件,URL 404 | §5 |
| 「一直转圈/处理中」 | demo.load 被 git 同步阻塞 | §8 |
| 「横幅/配置不生效」 | front matter 首行坏了(如 \---) | 自检里 仓库里的 link/ 配置真的能生效 |
| 「点按钮弹错误」 | 用了 gradio 5/6 已删除的 API | 自检里 mode_buttons 不抛错 |
| 「新转的产物重启后没了」 | 那是档位 B,需要 MS_TOKEN 写回 | §1 |
本次实战记录(可作为参照)
| 轮次 | 上下文 | 现象 | 根因 |
|------|--------|------|------|
| 1 | 149.62 kB | 素材完全没显示 | 素材压根没进仓库(§0) |
| 2 | 4.94 MB | 一直「处理中」 | boot() 自死锁(§8) |
| 3 | 4.95 MB | 卡片全黑 | 图片 URL 404(§5) |
| 4 | 4.95 MB | 两根滚动条 | 内层再套滚动区(§5 附注) |
每次都是先看日志、再看体积、最后才读代码——顺序反了会白折腾很久。
13. 别做的事
- 别在「不能 push」时把既有资产说成会丢——那是已经提交过的资产。
- 别为了「保证 src 不入库」而忽略整个目录——成品恰恰要入库。
- 别在启动时改 git 索引(
rm --cached):单向不可逆,等于删用户素材。 - 别在渲染前对中文文件名不编码。
- 别把「遍历顺序」当「优先级」——两者会在多来源场景下悄悄矛盾。
- 别在自检里经由重量级模块(gradio/torch)间接取轻量模块。
- 别把各自会拿锁的函数收进同一把不可重入锁里(§8)。
- 别让页面首屏依赖网络——git 一卡就是永久「处理中」,而且不报错(§8)。
- 别让用户手点「刷新」来弥补能自动做的事——同步完本来就该自己重渲染(§8)。
- 别无上限地等后台同步——那等于把刚修好的死锁又造回来(§8)。
- 别因为「自检全绿」就认为启动路径被覆盖了——先确认自检真的调用了它(§8)。
- 别在
gr.HTML里拿项目相对路径当图片 URL——Gradio 一律 404,图全黑且不报错(§5)。 - 别猜 Gradio 的文件路由;也别只断言 URL 字符串,发一次真实请求才算数(§5)。
- 别在测试里编素材文件名——403 会被误读成配置问题(§5)。
- 别让内层容器再套一个滚动区——和页面滚动条叠成两根,用户会圈出来问(§5 附注)。
- 别用 PS 5.1 的
Set-Content -Encoding UTF8改任何配置或源码(§9)。 - 别在改文件前不做备份——一次失败的写入就可能把内容清空(见下)。
14. 交付后必须提醒用户的事
- 素材要用户自己传。 改代码传不上去,这是物理限制,要讲清楚并给出验证方法
(部署日志里的
[git] src/ 成品 N 个)和逐个拖文件的正确姿势。 - 新增/修改 opencode 配置(
opencode.json、agent、skill、plugin)后 需退出并重启 opencode 才生效。 - 如实区分「已验证」和「未验证」:端到端请求过的(图片 200、页面 200)可以说验证过; 只做了字符串断言的(CSS 布局、滚动条根数)必须说「没在浏览器里目视确认」, 让用户复看并给回截图的路径。
Scan to join WeChat group