test: demo 渲染冒烟测试——渲染每个可运行 demo 首帧断言不崩 (#43)

现有 CI 只做 tsc 编译,堵不住'能编译但不能运行'的 demo(如组合
id 非法、运行时 import 缺失、纹理缺失导致的渲染崩溃)。新增
assets/scripts/smoke-render-demos.py:

- 扫描 demos/ 所有带时长导出(_DURATION/_DUR)的 demo,自动生成
  template/src/smoke-root.tsx 注册全部(组件名==文件名约定)
- 逐个 remotion still 渲染首帧,断言输出非空、进程退出码 0
- 自动把 demos/_textures 的图片纹理复制到 template/public/textures/live
  (跳过 layout.json 数据文件,不污染 git)
- 需要真实 mp4 素材的 demo(如 ClipCardLooping)标记跳过并报告
- 幂等:用完清理 smoke-root,可重复跑

CI 新增 demo-smoke job(独立于 verify),路径过滤 demos/** 触发。

本地验证:57 个 demo 全部首帧渲染通过;子集/失败/跳过路径均正确。
首次验证还暴露了两个真问题——composition id 不能含下划线
(已用连字符风格解决)、纹理复制误带 json(已修复只复制图片)。

Co-authored-by: zhaotian1 <zhaotian1@wps.cn>
Co-authored-by: Wei Yihao <weiyihao@amazon.com>
This commit is contained in:
SKY ZHAO
2026-08-22 02:55:03 +08:00
committed by GitHub
parent d54dd41dc1
commit 368792f2a4
3 changed files with 188 additions and 1 deletions
+20
View File
@@ -12,6 +12,7 @@ on:
- 'gallery/**'
- 'demos/**'
- 'assets/lib/**'
- 'assets/scripts/**'
- 'package.json'
- 'package-lock.json'
- '.github/workflows/pr-checks.yml'
@@ -127,3 +128,22 @@ jobs:
--module esnext --moduleResolution bundler --lib dom,es2022 \
--resolveJsonModule \
$(cat /tmp/demo-files.txt)
demo-smoke:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
cache-dependency-path: template/package-lock.json
- name: Render first frame of every runnable demo
run: |
cd template && npm ci
npm i --no-save "@remotion/motion-blur@$(node -p "require('./package.json').dependencies.remotion")"
cd ..
# Generated smoke roots import demos outside template/. Make the
# template dependencies resolvable from those source directories.
ln -s template/node_modules node_modules
python3 assets/scripts/smoke-render-demos.py
+1 -1
View File
@@ -124,7 +124,7 @@ describe('dampedSettle', () => {
it('oscillates: sign flips every half period', () => {
const freq = 0.1; // period 10 frames
const s1 = dampedSettle(2.5, freq, 0.15); // quarter period → +
const s1 = dampedSettle(2.5, freq, 0.15); // quarter period → +
const s2 = dampedSettle(7.5, freq, 0.15); // three-quarter →
expect(s1).toBeGreaterThan(0);
expect(s2).toBeLessThan(0);
+167
View File
@@ -0,0 +1,167 @@
#!/usr/bin/env python3
"""smoke-render-demos.py — demo 渲染冒烟测试。
堵住"能编译不能运行"的漏洞:现有 CI 只做 tsc 编译,不验证 demo 能否
真的渲染。本脚本扫描所有带时长导出(`_DURATION` / `_DUR`)的 demo
自动生成一个临时 Root 注册全部,然后逐个渲染首帧,断言输出文件非空、
渲染不崩。
用法(从仓库根):
python3 assets/scripts/smoke-render-demos.py [--subset a,b,c] [--list]
前置条件:
- template/ 已 `npm install`remotion 4.0.484
- demos/ 用到的 @remotion/motion-blur 已装到 templateCI 里临时装)
- 需要纹理的 demodemos/_textures/ 的文件已复制到
template/public/textures/live/(脚本会自动复制缺失的)
- 需要素材 mp4 的 demo如 clipcard-looping跳过并报告不失败
退出码:全通过 0任一 demo 渲染失败 1列出失败清单
"""
import argparse
import os
import re
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent.parent
TEMPLATE = ROOT / 'template'
TEMPLATE_SRC = TEMPLATE / 'src'
TEXTURES_SRC = ROOT / 'demos' / '_textures'
TEXTURES_DST = TEMPLATE / 'public' / 'textures' / 'live'
DUR_PAT = re.compile(r'export const (\w+_DURATION|\w+_DUR)\s*=')
# demo 需要真实 mp4 素材、无法自动生成的(跳过,脚本只报告)
MATERIAL_REQUIRED = {'ClipCardLooping'}
def find_demos():
"""返回 [(路径, 时长导出名, 组件名)], 按路径排序。"""
out = []
for f in sorted((ROOT / 'demos').rglob('*.tsx')):
src = f.read_text(encoding='utf-8')
m = DUR_PAT.search(src)
if not m:
continue
durname = m.group(1)
stem = f.stem
# 组件导出必须真实存在(`export const <stem>` 且是组件而非常量)。
# 正则精确匹配,避免文件名出现在注释/字符串里造成的误判。
if not re.search(rf'export const {re.escape(stem)}\s*:\s*React\.FC\s*=', src):
continue
out.append((f, durname, stem))
return out
def write_smoke_root(demos):
"""生成 template/src/smoke-root.tsx 注册所有 demo。"""
lines = ["import { Composition, registerRoot } from 'remotion';"]
regs = []
for f, durname, stem in demos:
rel = os.path.relpath(f, TEMPLATE_SRC).replace('.tsx', '')
lines.append(f"import {{ {stem}, {durname} }} from '{rel}';")
regs.append(
f' <Composition id="{stem}" component={{{stem}}} '
f'durationInFrames={{{durname}}} fps={{30}} width={{1920}} '
f'height={{1080}} />'
)
lines.append('export const Root: React.FC = () => (<>')
lines += regs
lines.append('</>);')
lines.append('registerRoot(Root);')
(TEMPLATE_SRC / 'smoke-root.tsx').write_text('\n'.join(lines) + '\n', encoding='utf-8')
def ensure_textures():
"""把 demos/_textures 的图片纹理复制到 template/public/textures/live。
只复制图片(.png/.jpg/.webp跳过 layout.json 等数据文件——它们是
import 进 TS 的,不是运行时纹理。"""
if not TEXTURES_SRC.exists():
return 0
os.makedirs(TEXTURES_DST, exist_ok=True)
IMG_EXT = {'.png', '.jpg', '.jpeg', '.webp'}
copied = 0
for f in TEXTURES_SRC.iterdir():
if f.is_file() and f.suffix.lower() in IMG_EXT and not (TEXTURES_DST / f.name).exists():
shutil.copy2(f, TEXTURES_DST / f.name)
copied += 1
return copied
def render_one(stem, out_dir):
"""渲染单个 demo 首帧, 返回 (ok, 输出路径或错误信息)。"""
out = out_dir / f'{stem}.png'
r = subprocess.run(
['npx', 'remotion', 'still', 'src/smoke-root.tsx', stem,
'--frame=0', str(out), '--log=error'],
cwd=TEMPLATE,
capture_output=True,
text=True,
)
if r.returncode != 0:
return False, r.stderr[-400:]
if not out.exists() or out.stat().st_size == 0:
return False, 'rendered but empty output'
return True, None
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument('--subset', help='逗号分隔的组件名, 只渲染这些')
ap.add_argument('--list', action='store_true', help='只列出可渲染 demo')
args = ap.parse_args()
demos = find_demos()
if args.list:
for _, _, stem in demos:
print(stem)
return 0
if args.subset:
wanted = set(args.subset.split(','))
available = {s for _, _, s in demos}
unknown = wanted - available
if unknown:
print(f'error: subset 含未找到的 demo: {sorted(unknown)}')
return 1
demos = [(f, d, s) for f, d, s in demos if s in wanted]
# 过滤需要素材的 demo
skip = [(f, d, s) for f, d, s in demos if s in MATERIAL_REQUIRED]
run = [(f, d, s) for f, d, s in demos if s not in MATERIAL_REQUIRED]
print(f'扫描到 {len(demos)} 个带时长导出的 demo; '
f'跳过(需素材) {len(skip)}; 将渲染 {len(run)}')
if skip:
print(f' 跳过: {", ".join(s for _, _, s in skip)}')
write_smoke_root(run)
ensure_textures()
with tempfile.TemporaryDirectory() as tmp:
out_dir = Path(tmp)
failed = []
for f, durname, stem in run:
ok, err = render_one(stem, out_dir)
if ok:
print(f'{stem}')
else:
print(f'{stem}: {err}')
failed.append(stem)
# 清理生成的 smoke-root
(TEMPLATE_SRC / 'smoke-root.tsx').unlink(missing_ok=True)
if failed:
print(f'\n失败 {len(failed)} 个: {failed}')
return 1
print(f'\n全部 {len(run)} 个 demo 首帧渲染通过 ✓')
return 0
if __name__ == '__main__':
sys.exit(main())