【免费下载链接】filmcraft
An open-source, clean-room reimplementation of Adobe Premiere Pro built in pure Rust.
本篇围绕filmcraft-scopes这一无 UI 的示波器计算 crate,完整讲解它如何从一帧 R'G'B' 信号出发,计算出波形图(Waveform)、Parade、直方图(Histogram)与 YUV/HLS 矢量示波器(Vectorscope)五类示波器,以及 HDR 场景下 BT.2408/PQ 亮度轴的换算方式。读完后你能掌握示波器"计数网格"这一中间表示的设计动机、各示波器的数学定义、Clamp Signal 与色彩矩阵的取值影响,以及同一套计算如何同时服务 egui 面板绘制与 Agent 自动化的scopes.read数值接口。
分层定位:只有 math,没有 UI
crates/scopes/README.md 对 crate 的定位一句话即可概括:"Video scope maths for the Lumetri Scopes panel (layer L3; depends only onfilmcraft-colorandserde; no UI, builds for wasm32)"。依赖声明在 crates/scopes/Cargo.toml 中得到印证:[dependencies]里只有filmcraft-color与serde两项,没有任何图形、窗口或渲染依赖。
从源码结构看,这套"计算与呈现分离"的架构有三类消费者:
- egui UI 面板:crates/ui-egui/src/panels/scopes.rs 负责把 crate 算出的计数网格转成纹理并绘制刻度线、色标与面板外框,README 原文即"the UI draws what this crate computes";
- Agent 自动化 API:crates/engine/src/scopes.rs 中的
scopes.read命令把示波器结果以数值(百分比电平、直方图 bin、矢量示波器最密集点)返回,"returns it as numbers for agents"; - 单元测试:crates/scopes/src/tests.rs 用已知取值的生成帧做像素级断言。
这种分层意味着 Web 端(wasm32 目标)也能复用同一套数学实现,而 UI 只需关心"怎么画"。
核心数据模型:Signal 与参数
Signal:降采样到 480×270 的 R'G'B' 码值
一切计算的输入是Signal:帧的 R'G'B' 显示域码值(display-encoded,0…1 标称范围,越界值代表 float/HDR 帧的超白/亚黑),以行主序存放。README 强调降采样采用最近邻采样(nearest-sample picking),上限为 480 × 270,对应源码中的两个常量:
/// Largest decimated signal (a 1080p frame is sampled every 4th pixel both ways). pub const MAX_W: usize = 480; pub const MAX_H: usize = 270;(crates/scopes/src/lib.rs#L34-L36)
之所以不用平均降采样,lib.rs模块注释给出了理由:"no averaging, so flat colours and test patterns land on exact cells"——平坦色块与测试图样必须落在精确的网格单元上,这是后面测试能断言"代码值 c 落在第 c 行"的前提。
Signal的构造入口有三个(对应 README 表格中的from_rgba8、from_rgba_f32_with+linear_to_pq、from_fn):
| 构造函数 | 用途 |
|---|---|
from_rgba8/from_rgba8_max | 8-bit 码值(/255 归一化),按MAX_W × MAX_H降采样,见 crates/scopes/src/lib.rs#L312-L334 |
from_rgba_f32_with | float 像素经自定义映射(如 linear → PQ)后降采样,HDR 通道使用 |
from_fn | 合成信号,测试与生成图案用 |
另有一个Signal::map方法对整个信号做逐样本变换,例如把 SDR 码值转到 PQ 轴(见下文sdr_to_pq)。降采样的步长由steps()用div_ceil计算:1920×1080 的帧双向每 4 像素取 1 个,得到恰好 480×270,这一点被测试decimation_keeps_whole_samples直接验证(crates/scopes/src/tests.rs#L173-L182)。
Params 与全套设置枚举
计算参数集中在Params:
pub struct Params { pub matrix: Matrix, // BT.601 / BT.709 / BT.2020 NCL pub clamp: bool, // Clamp Signal:先钳到 0..1 再绘图 pub rows: usize, // 波形/Parade 的行数(电平数) pub vector_size: usize, // 矢量示波器网格边长(奇数,保证中心是单元) } impl Default for Params { fn default() -> Self { Params { matrix: Matrix::Bt709, clamp: true, rows: 256, vector_size: 255 } } }配合Params::range()可以直接读到电平轴的取值范围:Clamp Signal 开启时行覆盖[0, 1](值先被钳制),关闭时覆盖[-0.1, 1.1](越界值被丢弃而非钳制)。README 特别指出一个精确对齐的性质:256 行跨[0, 1]时,8-bit 代码值c恰好落在第c行。
README 中列出的全部设置类型(均 serde、camelCase)在源码中一一对应,且每个枚举都提供了from_name宽松解析(忽略大小写、空格与连字符),便于 UI 菜单、JSON 参数与命令行互通:
| 类型 | 取值 | 说明(源码位置) |
|---|---|---|
ScopeKind | vectorscopeYuv/vectorscopeHls/histogram/parade/waveform(默认) | 五种示波器;"vectorscope"、"yuv"都解析为 YUV 矢量示波器,见 crates/scopes/src/lib.rs#L49-L90 |
WaveformType | rgb(默认)/luma/yc/ycNoChroma | crates/scopes/src/lib.rs#L97-L121 |
ParadeType | rgb(默认)/yuv/rgbWhite | crates/scopes/src/lib.rs#L124-L146 |
ColorSpace | auto(默认)/rec601/rec709/rec2100 | Auto按序列解析:HDR 工作空间 →Rec2100,否则Rec709;矩阵映射为 BT.601 / BT.709 / BT.2020 NCL,见resolve与matrix(),crates/scopes/src/lib.rs#L148-L189 |
Scale | bits8(默认)/float/hdr | 电平轴的刻度标签:0–255 代码值 / 0.0–1.0 / cd/m²(PQ 轴 0–10 000) |
Brightness | dimmed/normal(默认)/bright | 增益分别为 0.55 / 1.0 / 1.6,作用于轨迹亮度,见 crates/scopes/src/lib.rs#L220-L246 |
Targets | 75(默认)/100 | YUV 矢量示波器画哪组彩条目标,amplitude()返回 0.75 / 1.0 |
from_name的健壮性由names_round_trip测试覆盖:"709"→Rec709、"8 bit"→Bits8、"rgb-white"→RgbWhite、Targets::Percent100序列化后是字符串"100"等(crates/scopes/src/tests.rs#L267-L282)。
各示波器的数学定义
统一表示:计数网格 Grid
README 表格中反复出现"count grid"这一概念:每种示波器最终都是一组计数网格——"有多少个样本落在该单元"。核心结构是Grid:data[row * cols + col],row 0 = 最低电平,u32计数。波形与 Parade 是"每条轨迹一张网格"(Waveform持有traces: Vec<Grid>,lo/hi记录电平轴范围,per_column记录每列样本数即信号高度)。
波形/Parade 共用同一个build()内核(crates/scopes/src/lib.rs#L458-L479):逐样本调用采样函数得到至多 4 个(轨迹, 值)对,经row_of()映射到行号后累加计数;越界值的处理由clamp开关控制(钳制进轴内,或按[-0.1, 1.1]判定丢弃)。
Waveform:四种轨迹组合
waveform()(crates/scopes/src/lib.rs#L482-L495)按类型产出不同轨迹集:
- RGB:R、G、B 三条网格;
- Luma / YC no Chroma:仅 Y 一条(亮度 = 矩阵 Y'CbCr 的 Y' 分量);
- YC:Y 一条 + C 一条,其中 C 的轨迹在同一列上同时记录
Y + C与Y − C两个位置(C = √(Cb² + Cr²)),即色度幅度"围绕亮度上下展开"。测试yc_waveform_draws_chroma_around_luma用纯红帧验证:Y 轨迹落在 Y' 行,C 轨迹落在Y' ± C两行(crates/scopes/src/tests.rs#L151-L162)。
Parade:并排轨迹
parade()(crates/scopes/src/lib.rs#L497-L509)与波形同构,但轨迹是并排绘制的(README:"drawn side by side"):
- RGB:R、G、B;
- RGB-White:R、G、B、Y 四条;
- YUV:Y 原样、Cb 与 Cr 各加 0.5 偏移(把 −0.5…0.5 的色度平移到 0…1 轴上),测试
yuv_parade_offsets_chroma_to_mid_scale验证灰色帧三条轨迹都落在行 128。
UI 侧的并排拼接(含gap)在 crates/ui-egui/src/panels/scopes.rs 的parade_image()中完成。
Histogram:256 bin 与越界统计
直方图结构Histogram持有 R'、G'、B'、Y' 各 256 个 bin,另有below/above两组按通道计数的越界样本。bin_of()的映射规则是(v * 255).round()(0 与 1 归入 0 与 255),因此平坦色必然落在精确 bin——测试single_colour_lands_in_exact_histogram_bins中[200, 100, 50]的帧使h.r[200] == h.g[100] == h.b[50] == 样本总数,Y 通道则落在按矩阵公式算出的 Y' 对应 bin(crates/scopes/src/tests.rs#L33-L44)。
Clamp Signal 对直方图的影响值得注意:越界样本始终计入below/above统计;只有在 clamp 开启时才额外计入首/末 bin(crates/scopes/src/lib.rs#L546-L560)。测试clamp_signal_controls_out_of_range_values用含 1.2 / −0.05 的帧验证:clamp 开时h.r[255] == 8且h.above[0] == 8,clamp 关时h.r[255] == 0(crates/scopes/src/tests.rs#L184-L201)。
Vectorscope YUV:Cb 向右、Cr 向上、±0.6
YUV 矢量示波器把每个样本的 (Cb, Cr) 画到vector_size²平面上。平面范围由常量VECTOR_EXTENT = 0.6界定(README 与 crates/scopes/src/lib.rs#L38-L39),方向为Cb 向右、Cr 向上(cell_of()中 fy 用0.5 - cr/extent*0.5反转 y 轴)。该取值保证了所有矩阵的 100% 彩条目标都能落在平面内。
彩条目标由targets(m, amplitude)生成:六色 R、Mg、B、Cy、G、Yl,在amplitude(0.75 或 1.0)下的码值过rgb_to_ycbcr得到 (Cb, Cr)(crates/scopes/src/lib.rs#L685-L693)。UI 用它画目标方框,引擎测试则用它断言 75% 彩条帧(代码值 191)在 BT.601/709/2020 三个矩阵下,每条彩条的样本恰好全部落在对应目标单元,中心单元(白 + 黑)持有 2 倍样本数(crates/scopes/src/tests.rs#L70-L105)。
两个与"肤色线"相关的常量:
SKIN_TONE_DEG = 123.0:NTSC "I 轴"方向,自 +Cb 逆时针 123°(crates/scopes/src/lib.rs#L41-L42),UI 的vector_graticule()沿此方向画一条肤色参考线;red_angle(m):纯红在该矩阵矢量示波器上的角度,测试断言 BT.709 为102.91°、BT.601 为108.65°(crates/scopes/src/tests.rs#L108-L116),且 123° 落在红色与黄色目标之间。
网格落点的精确性也有已知答案:255² 网格上,BT.709 纯红峰位于(103, 21),这正是 README "BT.709 red sits at (103, 21) on the 255² grid" 一条的出处(对应vectorscope_positions_of_primaries_are_known测试)。
Vectorscope HLS:色相为角度、饱和度为半径
HLS 矢量示波器是另一个坐标系(crates/scopes/src/lib.rs#L639-L674):角度= 该矩阵 YUV 红色角 + HSL 色相(红 → 黄 → 绿 → 青 → 蓝 → 品红逆时针);半径= HSL 饱和度 × 0.5(饱和度 1 落在平面外缘 0.5 处)。
一个工程细节值得注意:每样本的三角函数是热点,实现预先对 0.1° 步长建了 3601 个方向向量表(dirs),按色相索引查表,lib.rs注释明确写道 "trigonometry per sample is the slow part"。测试验证六种纯色恰好落在红 + 60° 步进的外环上,半饱和红 [191,64,64] 落在半径约 50% 处,灰色落在中心(crates/scopes/src/tests.rs#L130-L149)。hls_targets(m)则给出外环六色标签位置供 UI 标注。
HDR 与 PQ 亮度轴
README 的 Definitions 一节定义了 HDR 换算,源码实现为三个函数(crates/scopes/src/lib.rs#L377-L393):
linear_to_pq:linear 工作值(1.0 = 203 cd/m²,即 BT.2408;输入是预乘 RGBA,先除以 alpha)→ SMPTE ST 2084 PQ 码值。轴上 0…1 对应0…10 000 cd/m²;sdr_to_pq:SDR 码值按BT.1886(γ 2.4,100 cd/m² 白)换算到 PQ 轴——即 SDR 帧挂到 HDR 刻度时使用的路径;nits_to_pq:cd/m² → PQ 轴高度,UI 画 cd/m² 刻度线(0 / 10 / 100 / 203 / 1000 / 4000 / 10 000)时逐点调用。
测试hdr_axis_helpers给出三个锚点:nits_to_pq(0) ≈ 0、nits_to_pq(10000) = 1、参考白 203 cd/m² 对应 PQ 轴约58%;半透明白(预乘 0.5)与不透明白换算结果相同;sdr_to_pq(1.0)等于nits_to_pq(100)(crates/scopes/src/tests.rs#L203-L213)。
Matrix枚举与 Kr/Kb 系数来自 crates/color/src/lib.rs:BT.601 (0.299, 0.114)、BT.709 (0.2126, 0.0722)、BT.2020 NCL (0.2627, 0.0593),rgb_to_ycbcr按标准公式输出 Y' ∈ 0…1、Cb/Cr ∈ −0.5…0.5。这就是 README 所说 "Y'CbCr from R'G'B' with the matrix's Kr/Kb … Cb and Cr in −0.5…0.5" 的底层实现。
从计数网格到像素:paint 模块
paint子模块(crates/scopes/src/paint.rs)把计数网格变成 UI 直接上传的 premultiplied RGBA8 纹理,核心是intensity()的对数强度模型:
let r = (1.0 + reference.max(1.0)).ln(); (gain * (0.12 + 0.88 * (1.0 + count as f32).ln() / r)).clamp(0.0, 1.0)reference(参考计数)是"一个满单元"的样本数:波形取半列(waveform_reference(per_column) = per_column / 2,平坦色块会占满整列),矢量示波器取samples / 64。对数曲线让稀疏细节与平坦区域同时可见,"like a phosphor trace"。多条轨迹加色叠加——R、G、B 轨迹重合处读出白色,测试paint_adds_traces_and_leaves_empty_cells_transparent验证了 [128,128,128] 灰帧在行 128 输出[255,255,255,255](crates/scopes/src/tests.rs#L246-L264)。
矢量示波器另有vectorscope()光栅化:每个点向3×3 邻域扩散(中心权重 1.0、正交 0.6、对角 0.35,取加权最大值),保证小尺寸绘制时孤立点仍可见;colorize模式下按该色度坐标反推颜色(中亮度 Y=0.6 的ycbcr_to_rgb,再向白色去饱和),测试断言 7 个不接触的彩条斑点恰好产生 7×9 个不透明像素。
数值摘要:给 Agent 的 summary 模块
summary子模块(crates/scopes/src/summary.rs)回答"不看图也能检查色彩"的需求,README 表格将其概括为"levels / densest cells / coarse vectorscope":
channel_stats:直接对信号(而非网格单元)统计 R'、G'、B'、Y' 的 min/max/mean(百分数,注释类比 IRE:8-bit 即 code/255×100),外加平均色度 (Cb, Cr)(−50…50)、色相角与饱和度(0.5 归一化)。测试断言[200,100,50]的 BT.709 均值为 (78.43, 39.22, 19.61);trace_columns:把一条轨迹按列切成buckets段,各段输出所持有的电平区间(如左右半黑白帧切成 4 段后得到 0/0、0–100、0–100、100/100);peaks:矢量示波器中样本占比 ≥min_share的最密集n个单元,输出单元格坐标、(Cb, Cr) 百分数、角度、幅度(100% 红在 BT.709 ≈ 102.6)与占比;彩条测试断言中心峰占比 0.25、六个目标各 0.125;coarse:把稀疏网格重分箱到size²(如 16×16),输出[x, y, count],提供"粗略数值图"。
两个真实接口:scopes.read 与 UI 面板
scopes.read:引擎自动化命令
crates/engine/src/scopes.rs 把整个 crate 包成自动化命令scopes.read,参数 schema 直接内嵌在命令注册处:
{"scopes":["waveform","parade","histogram","vectorscopeYuv","vectorscopeHls"]?, "waveformType":"rgb|luma|yc|ycNoChroma"?,"paradeType":"rgb|yuv|rgbWhite"?, "colorSpace":"auto|601|709|2100"?,"clamp":bool=true,"columns":n=8, "peaks":n=8,"bins":bool=true,"scale":0.5,"time":ticks?|"frame"|"seconds"|"timecode"}关键实现细节(scope_signal,crates/engine/src/scopes.rs#L15-L26):
- SDR 路径:以
scale(默认 0.5,钳制在 1/32…1)渲染监视器画面(含字幕合成),取 RGBA8 构造Signal; - HDR 路径(
colorSpace解析为Rec2100且序列工作空间为 HDR 时):改用working_output: true的小比例工作空间渲染,float 像素经scopes::linear_to_pq映射,输出单位标记为pq%而非%; - 时间参数
time支持 ticks / frame 号 / 秒 / 时间码四种写法,缺省取播放头并按帧率 snap; - 输出 JSON 含帧坐标、
samples: [w, h]、色彩空间/矩阵/clamp 回显、stats(channel_stats),以及各示波器结果:波形/Parade 为按列分桶的电平区间,直方图含below/above/peakBin(bins: false可只留摘要),矢量示波器为samples/mean/meanAngleDeg/peaks(peaks1…64,默认 8)。
UI 面板:纹理缓存与刻度
crates/ui-egui/src/panels/scopes.rs 展示了消费侧的完整闭环:
- 帧信号来自 ¼ 分辨率(250px)的程序监视帧缓存,HDR 场景改走
scope_signal(..., 0.125, true);frame_signal()以 (序列, 帧号, 修订号, pq 标志) 为键缓存,新帧未渲染完时沿用上一帧; - 纹理按 (帧, 设置, 示波器, 行数) 哈希缓存(
cached_texture),只在帧或设置变化时重算——这与 README 末句 "the UI recomputes only when the frame or a setting changes" 对应; - 电平刻度(
level_graticule):SDR 画 0…100(右侧按 Scale 显示 0.0–1.0 或 0–255),cd/m² 轴画 0/10/100/203/1000/4000/10000 七条线,203 cd/m² 参考白用琥珀色高亮; - 矢量刻度(
vector_graticule):双环 + 十字轴 + 每 10° 刻度线,YUV 模式画肤色线(SKIN_TONE_DEG)与 75%/100% 两组彩条目标,HLS 模式画六色相标签(hls_targets)。
正确性验证与性能预算
测试套件(crates/scopes/src/tests.rs)全部基于"已知取值的生成帧",README 的 Tests 一节逐条对应:平坦色精确 bin、256 级斜坡填满全部 bin 且波形列 x 落在行 x、75% 彩条在三个矩阵下同时验证 Parade 代码值与矢量目标单元、BT.709 红 (103, 21)/102.91° 与 BT.601 108.65°、HLS 外环 60° 步进、YC 色度包络亮度、Clamp Signal 双向行为、降采样整样本保持、NaN 与空帧不 panic(nan_and_empty_signals_do_not_panic对 5 种示波器 × 2 种 clamp 组合跑全量compute)、摘要数值与光栅化。
性能方面,README 给出cargo test --release -p filmcraft-scopes perf -- --ignored --nocapture(1920×1080 RGBA8 噪声,Apple M4 Pro,单线程,机器同时有其他构建在跑)的实测数据:
| 步骤 | ms |
|---|---|
| 1080p → 480×270 降采样 | 0.30 |
| 波形 RGB / YC | 0.75 / 0.88 |
| Parade RGB-White | 0.67 |
| 直方图 | 0.35 |
| 矢量示波器 YUV / HLS | 0.67 / 0.81 |
每个示波器都远低于 3 ms 预算,配合 UI 的"帧/设置不变不重算"策略,示波器可以在播放中保持实时。性能测试本体perf_scopes_at_1080p使用 xorshift 伪随机噪声帧、20 次重复取均值(crates/scopes/src/tests.rs#L284-L317)。
小结
filmcraft-scopes展示了 NLE 示波器面板的一种干净实现方式:把全部数学收敛到一个只依赖filmcraft-color与serde的纯计算层,用"计数网格"这一与显示解耦的中间表示统一五种示波器,用最近邻降采样保住 8-bit 精确对齐,用对数强度 + 加色叠加还原荧光轨迹质感,再用summary与paint分别服务"数值接口"和"像素接口"。其工程约束(L3 层、wasm32 可构建、3 ms 预算)都写进了 crates/scopes/README.md,并逐条被 crates/scopes/src/tests.rs 的像素级断言与 ignored 性能测试所锚定,是阅读该项目时理解"面板 UI 从哪里拿到数据"的最佳入口之一。
【免费下载链接】filmcraft
An open-source, clean-room reimplementation of Adobe Premiere Pro built in pure Rust.
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考