视频制作 · 结构主义经验
「结构主义」在本项目里指构成主义(Constructivism,俄语 Конструктивизм); 站内笔记与 look4end 上的文档统一用「构成主义」,是同一件事。
这份文档回答一件事:要做一支结构主义动效视频,照什么做。 分工:01 讲「为什么」(设计理念、媒介差异、三类卡顿的原理); 本文讲「怎么做 + 踩过什么」(速查经验、可复用管线、命令、验收脚本、本机环境的坑)。
全部结论来自本次实做(2026-10-12/13),实测数字原样保留,没有凭印象写的部分。
0. 一页速查:结构主义视频的 8 条硬经验
下次直接照这张表做。每条都标了实测依据与原理出处。
| # | 经验 | 实测依据 | 原理详见 |
|---|---|---|---|
| 1 | 红是"色场",不是点缀——整幅朱红铺底,不按单帧面积克制 | 四幕单帧红占 62–69%,全片平均 ≈45%;对照版按网页法则压到 5–15% 后力量感直接消失 | 01 §1 决策 1、§3 |
| 2 | 按时间平均管红色,不按单帧构图管 | 红/纸交替本身就是节拍器:红 → 纸 → 红 → 红 → 纸 → 红 | 01 §1 |
| 3 | 动效时长按"镜头级"给:400–1200ms | 网页那档 120–320ms 是交互反馈阈值,放视频里读作卡帧 | 01 §3 |
| 4 | 机械化 ≠ 数值量化:靠连续缓动 + 硬切 + 时长节奏,不靠把进度切成台阶 | 量化 6 段时,某段 21 帧里 14 帧画面完全没变(67% 静止) | 01 §8.4 |
| 5 | 每一幕从入场结束到落幕,不许定格——必须有持续动势 | 四幕保持段相邻帧像素差 = 0(完全冻结);唯一整段在动的第 04 幕反被反馈"不卡" | 01 §8.2 |
| 6 | 每帧在动的元素只许动 transform / opacity |
clip-path / width / background-position 逐帧变化会让该元素每帧重新栅格化(主线程不掉帧,帧率指标查不出来) |
01 §8.1 |
| 7 | 循环点留 ≥0.4s 纯黑(30fps 下 12 帧),且首末帧像素一致 | 最初只有 50ms(1.5 帧),成片里读作故障闪帧 | 01 §4 |
| 8 | 巨字必须实测宽高 | КОНСТРУКТИВИЗМ 在 188px 时溢出画框切掉末字;收到 164px 仍占画幅宽 86% |
01 §1 决策 3 |
1. 五轮排查复盘:一次"感觉卡"的完整诊断
结论先放:我们花了四轮去优化"怎么画得更快",真正的元凶是"数值本来就在跳"。 下表按实际发生的顺序,把误判也一起记下来——这张表比结论更有用。
| 轮 | 症状 | 当时的判断 | 实测证据 | 结论 |
|---|---|---|---|---|
| 1 | "感觉卡一卡的" | 怀疑性能掉帧 | 真实窗口(RTX 5060 + Intel 核显,165Hz):p50=6.1ms / p95=6.2ms,垂直同步本身就是 6.06ms,长帧 ~1%,render() JS 仅 0.10ms |
❌ 不是掉帧 |
| 2 | 依旧卡 | 怀疑颗粒层:每 50ms 平移同一张噪点贴图 | 改为 4 张独立噪点贴图轮换(12.5Hz),并把内联 feTurbulence 换成 canvas 预生成位图 |
⚠️ 是缺陷之一,但不是主因 |
| 3 | 依旧卡(用户:"红色格子那幕不卡") | 怀疑节奏:动作做完就定格、然后硬切 | 逐帧像素差:四幕保持段 changed = 0 px(画面完全冻结);唯一整段在动的第 04 幕(红格子波次)反而不卡 |
✅ 真因之一:任何一幕都不许定格 |
| 4 | 依旧卡(用户:"移动中自身也在变化的元素卡,变化完成后就顺") | 怀疑光栅滞后:元素每帧重新栅格化 | clip-path 揭示 / width / background-position 都是逐帧重绘,主线程不掉帧所以帧率指标查不出来 |
✅ 真因之二:换成合成器友好写法 |
| 5 | 渲染成 mp4 后仍然卡(用户:"说明不是渲染的问题") | —— | 从成品 mp4 逐帧量 S1 红斜带 21 帧:14 帧画面完全没变(67% 静止),其余帧一次跳 12 万~25 万像素 | ✅ 真凶:量化跳步写进了数值 |
1.1 三条最值得记的教训
- 判别口诀:离线逐帧渲染出来的视频仍然卡 → 不是渲染/时序问题,是数值里写了台阶。
去查
Math.floor(p*n)/n、steps()、离散取整。这条一句话就能省下几轮性能优化。 - rAF 帧间隔指标看不见两类问题:① 逐帧重栅格化导致的光栅滞后;② 数值台阶。 它们主线程都正常。用户的眼睛在这两类问题上是比指标更可靠的仪器。
- 诊断顺序固定下来(照这个顺序走,不要跳步):
① 看节奏:有没有"完全冻结"的帧对(逐帧像素差) ② 查属性:正在动的元素有没有动非合成属性(clip-path / width / height / background-position) ③ 查数值:进度有没有被量化成台阶 ④ 以上都不成立,才去怀疑性能(掉帧)
2. 出片管线:离线逐帧渲染(可复用)
在线播放要同时跟合成器、光栅线程、垂直同步赛跑,损失永远不可能归零。
离线逐帧渲染把"时间"变成参数:每帧按精确 t 单独渲染,编码器按固定帧率输出。
2.1 本机实测的工具路径
| 用途 | 路径 |
|---|---|
| Node | C:\Program Files\nodejs\node.exe(v24,自带全局 WebSocket / fetch,够写 CDP 客户端) |
| Chrome | C:\Program Files\Google\Chrome\Application\chrome.exe |
| ffmpeg / ffprobe | C:\...\WinGet\...\ffmpeg-9.0.2-full_build\bin\ |
| Python(Pillow / numpy) | C:\...\.dsh\...\python\python.exe |
| 抓帧脚本 | src/tools/render-frames.cjs |
ffmpeg 不在 PATH 上,本机另有多处副本(剪映自带、
D:\...\google_download\、E:\...\ai-movie\node_modules\ffmpeg-static\)。 Windows 下可用Get-ChildItem <根> -Filter ffmpeg.exe -Recurse -Depth 4找。
2.2 四步流程
# ① 把要渲染的页面复制到纯 ASCII 临时目录(中文路径传给 Chrome 会被破坏,见 §4)
$tmp = "$env:TEMP\cstv-render"
Copy-Item <页面.html> "$tmp\base.html" -Force
# ② 启动带调试端口的无头 Chrome(注意:--window-size 是窗口不是视口,见 §4)
# 窗口开起来后不用管它,脚本通过 CDP 驱动
Start-Process chrome.exe -ArgumentList @(
"--headless=new","--no-sandbox","--disable-breakpad","--disable-crashpad","--disable-dev-shm-usage",
"--hide-scrollbars","--no-first-run","--window-size=1920,1080","--force-device-scale-factor=1",
"--remote-debugging-port=9223","--user-data-dir=$tmp\prof",
"file:///$tmp/base.html?pure=1&hud=0")
# ③ 逐帧渲染(帧 i 对应 t = i/fps;30fps × 20s = 600 帧)
node src/tools/render-frames.cjs 9223 "$tmp\frames" 30 600 0 600 1920 1080
# ④ 编码
$ff = "<ffmpeg 路径>\ffmpeg.exe"
& $ff -y -framerate 30 -i "$tmp\frames\frame_%04d.png" `
-c:v libx264 -preset medium -crf 14 -pix_fmt yuv420p -tune animation -g 15 `
-movflags +faststart out.mp4
2.3 实测性能与参数
| 指标 | 实测值 |
|---|---|
| 渲染速度 | 0.43 s/帧(600 帧 ≈ 4.3 分钟,1920×1080 PNG) |
| 帧序列体积 | 600 帧 ≈ 0.45 GB PNG |
| 编码耗时 | 6.2 秒(libx264 medium / crf 14) |
| 成片体积 | 1920×1080 / 30fps / 20.000s ≈ 16.9 MB |
编码参数选择:
- 纯色为主的画面(构成主义大红/黑/纸)用
-tune animation -crf 14;担心纯朱红渐层色带可降到-crf 12。 -g 15(每 0.5 秒一个关键帧)便于拖动预览。-pix_fmt yuv420p保证兼容性;代价是纯红边缘有轻微色度抽样,可接受。- 帧率选 30fps 就够:屏幕即使是 165Hz,播放器也会把每帧稳定显示约 5.5 次刷新。
3. 验收清单(出片前必跑)
3.1 静态检查
# 内联 JS 语法(用 node 按 UTF-8 提取,别用 Get-Content -Raw,见 §4)
node tools/extract-and-check.cjs <页面.html> # 或自己写 8 行:regex 取 <script> → node --check
- [ ]
node --check退出码 0 - [ ] 逐帧非合成属性残留扫描:
clip-path/width/height/background-position有没有出现在每帧都在更新的语句里 - [ ] 量化台阶残留扫描:
Math.floor(p*n)/n/steps(有没有用在连续运动上
3.2 动态检查(三条硬指标)
| 检查 | 方法 | 本次实测结果 |
|---|---|---|
| 定格检查 | 相邻帧像素差,任一幕不得出现静止帧对 | 连续化后五幕静止帧对 = 0(改前 67% 静止) |
| 循环无缝 | 首帧 vs 末帧像素差 | changed(>8) = 0 px, max = 0 |
| 帧尺寸 | 单帧必须等于目标画幅 | 1920×1080(修掉 CDP 视口陷阱前是 1898×926) |
可直接复用的检查脚本(Python + Pillow + numpy):
import glob, numpy as np
from PIL import Image
def load(p): return np.asarray(Image.open(p).convert("RGB")).astype(np.int16)
fs = sorted(glob.glob("frames/frame_*.png"))
prev, stalls = None, 0
for f in fs:
a = load(f)
if prev is not None:
changed = int((np.abs(a - prev).max(axis=2) > 8).sum())
if changed <= 300: # 近似"这一帧没动"
stalls += 1
prev = a
print("静止帧对 =", stalls, "/", len(fs) - 1, " 单帧尺寸 =", a.shape[:2])
first, last = load(fs[0]), load(fs[-1])
d = np.abs(first - last).max(axis=2)
print("循环首末帧差异:changed(>8) =", int((d > 8).sum()), " max =", d.max())
判定标准:静止帧对必须为 0;循环首末帧「差异>30 的像素」必须为 0(残差只允许来自有意保留的颗粒)。
3.3 视听检查(指标测不到的那部分)
- [ ] 真实窗口连续播放一整轮(无头环境测不出光栅滞后)
- [ ] 逐幕确认"正在变化的元素"不顿挫
- [ ] 把成片放大到 200%(或 4K 上采样)看纯红区域有没有色带
4. 本机环境的坑(都踩过,别重踩)
| 坑 | 现象 | 正确做法 |
|---|---|---|
| PowerShell 读 UTF-8 文件 | Get-Content -Raw 按本地码页解码,中文/西里尔全成乱码,连行号都错位(曾误报"JS 括号不平衡") |
用 node 读:fs.readFileSync(p,'utf8');或 [System.IO.File]::ReadAllText($p,[Text.Encoding]::UTF8) |
| 自动变量 | $home = ... 报"无法覆盖变量 HOME"(PowerShell 变量名不区分大小写) |
别用 $home/$args/$pid 等;换名 $pg/$cmdArgs |
| 调用 GUI 程序 | & chrome.exe ... 不等待就返回,$LASTEXITCODE 为空,截图还没写出来 |
Start-Process -Wait -PassThru,用返回值的 .ExitCode |
| Chrome 被已有实例接管 | 启动瞬间退出、不截图 | 每次给独立 --user-data-dir;必要时按命令行里的 profile 关键字 Kill 残留进程 |
| 中文路径传给 Chrome | file:///C:/Users/.../视频工作流/... 被破坏 → ERR_NAME_NOT_RESOLVED / 页面加载失败 |
复制到纯 ASCII 临时目录再传 |
--window-size 不是视口 |
设 1920×1080 只得到 1898×926 的视口,页面等比缩放变成 0.857 并留黑边 | 用 CDP Emulation.setDeviceMetricsOverride{width,height,deviceScaleFactor:1} 强制视口 |
| 无头环境测帧率 | 没有垂直同步,rAF 可以跑到 164fps,测不出合成/光栅开销 | 帧率必须真实窗口测;并且加 --disable-background-timer-throttling --disable-renderer-backgrounding --disable-backgrounding-occluded-windows,否则窗口失焦会被节流(曾出现 654ms 的假长帧) |
--virtual-time-budget |
虚拟时间会破坏一切基于真实时间的测量 | 测性能时不要带它;只在"加载完就截图"时用 |
| webfont 未就绪 | 抓到的帧掉字体 | 抓帧前等 document.fonts.ready;抓帧时机在页面里用 window.__seek(t) 等两个 rAF |
| git push 失败 | Recv failure: Connection was reset |
本机直连 GitHub 不通,走本地代理:git -c http.proxy=http://127.0.0.1:7890 push(本仓库已写入 .git/config 本地配置) |
5. 页面里要留的两个钩子(做视频的页面必备)
/* ① 精确寻址:设定时刻 → 渲染 → 等两帧绘制完成再返回 */
window.__seek = function (t) {
playing = false; t = t; render(t);
return new Promise(res => requestAnimationFrame(() => requestAnimationFrame(() => res(t))));
};
/* ② 逐幕帧间隔统计(真实窗口里用):?perf=1,pms=窗口毫秒,pskip=跳过启动 */
另外建议留这些 URL 参数(渲染与排查都用得上):
?pure=1 隐藏全部界面 · &hud=0 关场记 · &guides=0 关构造网格线 · ?quant=1 回到量化顿挫 · &grain=0 关颗粒。
6. 下次做一个新动效视频的清单
- 定画幅、时长、帧率 → 总帧数 = fps × 时长;最后一帧是
(n-1)/fps,不含整点(否则循环会重复一帧) - 画面上留好
window.__seek钩子,并把界面元素做成可关(?pure=1一套) - 动笔时先定数值连续性:连续运动绝不量化;机械感靠缓动曲线 + 硬切 + 时长节奏
- 每幕设计完,检查两端:入场结束到落幕之间不许定格
- 每帧在动的元素,只许动
transform/opacity;揭示用overflow:hidden + translate,不用clip-path - 抓 3~5 个关键时刻的静帧看构图,不要等渲染完 600 帧才发现问题
- 跑 §3.1 静态检查 → §3.2 三条动态指标 → §3.3 真实窗口连续播放
- 逐帧渲染 → 编码 → ffprobe 核对
nb_frames/duration/r_frame_rate - 留档:帧母版 + 编码命令 + 本页清单
7. 当前资产与状态(2026-10-13)
| 资产 | 位置 | 状态 |
|---|---|---|
| 成片母版 | .scratch/frames-30fps/ |
600 帧 / 1920×1080 / 30fps / ≈0.45 GB(不需要可直接删,编码只是 6 秒的事) |
| 抓帧脚本 | src/tools/render-frames.cjs |
可用,支持 fps / 总帧数 / 起始帧 / 帧数 / 画幅 参数 |
| 动效页面 | constructivism-motion-20s.html |
已上线 look4end.com/motion/ |
| 设计理念 | 01-动效设计理念.md |
§8 卡顿三重诊断、§8.4 数值台阶、§9.3 离线渲染 |
| mp4 成片 | — | 暂缓(按站主决定先不做视频;管线已就绪,随时可出) |