← 返回 Skill 列表
extension
分类: 开发与工程API Key 暂未确认

利用本地WebP动画更新在线页面

Use when deploying or debugging an asset/animation gallery on a ModelScope 创空间 (gradio + git persistence) — 部署后素材不显示、页面空白、卡片信息正常但图全黑、持久化提示报警、can_pull/can_push/MS_TOKEN、.gitignore 放行 src 成品、build context 大小对不上、gradio_api/file= 404、allowed_paths 403。Also covers the directory contract (src/ 投料 vs gallery/ 出料), deadlock that hangs demo.load, and BOM breaking SKILL.md frontmatter.

person作者: montherlandhubModelScope

创空间素材墙:部署与持久化

把「拖素材 → 转化 → 陈列 → 容器重建后还在」这条路走通。参考实现: 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 "🔴 产物未持久化:" + ...     # 真的没保障

写反了的两种典型症状

  1. 把「不能写回」说成「现有资产会丢」,于是公开空间(用户的工作流)长期挂黄灯/警告。 本次原始文案:🟡 只能读:产物能拉回,但容器重建后新转的会丢(需 MS_TOKEN 或公开空间) ——「或公开空间」尤其误导:当前已经是公开空间。
  2. 让用户去申请 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 只提供它自己管的文件(上传目录、缓存),项目目录默认一律不给。

三条硬规矩

  1. 真实路由是 /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}', ...]
    
  2. 必须放行:demo.launch(allowed_paths=[str(ROOT)])。不放行 → 403。 顺带确认 allowed_paths 是 Blocks.launch 的参数(本项目用 _supported() 按签名过滤参数,写错位置会被静默丢弃——又是一个「不报错就没了」的坑)。

  3. 用相对 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/队列下发。所以去 grep GET / 找自己的 <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. 自检纪律

  1. 只依赖标准库的模块要直接导入。 selftest.py 里写 G = A.git_sync, 而 import app 需要 gradio——本机没装 gradio 时,整个后半段(含持久化测试)静默不执行, 看上去「跑通了」。改成 import git_sync as G,这段就永远跑得到。
  2. 断言契约,不断言实现。 「gallery 优先」「.gitignore 放行 webp」 是契约;「git add 第三个参数」是实现,改实现就误报。
  3. 外部依赖(ffmpeg / 无头浏览器)测降级路径:标 unconvertible、 报告点名缺什么、不静默跳过。有边界的失败要能说清。
  4. 数维度守恒: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 队列)挂成静默长挂。

三条硬规矩

  1. 不可重入锁不能嵌套。把「各自会拿锁的函数」收进同一把锁里必然自死锁。 要么让它们各管各的锁,要么把整组操作抽成一个持锁的内部函数。

  2. 页面渲染绝不能依赖网络,同步完还要自动重渲染一次。

    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)     # 慢同步下第一帧也不能被拖
    
  3. 同步期间头部不能说「未持久化」。后台同步还没跑时 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 布局、滚动条根数)必须说「没在浏览器里目视确认」, 让用户复看并给回截图的路径。