← 视频设计 03 / 03

视频制作 · 结构主义经验

「结构主义」在本项目里指构成主义(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 三条最值得记的教训

  1. 判别口诀:离线逐帧渲染出来的视频仍然卡 → 不是渲染/时序问题,是数值里写了台阶。 去查 Math.floor(p*n)/n、steps()、离散取整。这条一句话就能省下几轮性能优化。
  2. rAF 帧间隔指标看不见两类问题:① 逐帧重栅格化导致的光栅滞后;② 数值台阶。 它们主线程都正常。用户的眼睛在这两类问题上是比指标更可靠的仪器。
  3. 诊断顺序固定下来(照这个顺序走,不要跳步): ① 看节奏:有没有"完全冻结"的帧对(逐帧像素差) ② 查属性:正在动的元素有没有动非合成属性(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

编码参数选择:


3. 验收清单(出片前必跑)

3.1 静态检查

# 内联 JS 语法(用 node 按 UTF-8 提取,别用 Get-Content -Raw,见 §4)
node tools/extract-and-check.cjs <页面.html>   # 或自己写 8 行:regex 取 <script> → node --check

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 视听检查(指标测不到的那部分)


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. 下次做一个新动效视频的清单

  1. 定画幅、时长、帧率 → 总帧数 = fps × 时长;最后一帧是 (n-1)/fps,不含整点(否则循环会重复一帧)
  2. 画面上留好 window.__seek 钩子,并把界面元素做成可关(?pure=1 一套)
  3. 动笔时先定数值连续性:连续运动绝不量化;机械感靠缓动曲线 + 硬切 + 时长节奏
  4. 每幕设计完,检查两端:入场结束到落幕之间不许定格
  5. 每帧在动的元素,只许动 transform / opacity;揭示用 overflow:hidden + translate,不用 clip-path
  6. 抓 3~5 个关键时刻的静帧看构图,不要等渲染完 600 帧才发现问题
  7. 跑 §3.1 静态检查 → §3.2 三条动态指标 → §3.3 真实窗口连续播放
  8. 逐帧渲染 → 编码 → ffprobe 核对 nb_frames / duration / r_frame_rate
  9. 留档:帧母版 + 编码命令 + 本页清单

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 成片 — 暂缓(按站主决定先不做视频;管线已就绪,随时可出)