// Oak Video Editor - Non-Linear Video Editor
// Copyright (C) 2026 Oak Team
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see .
//! Effect shader translation: the nodes' embedded GLSL fragment shaders
//! (the C++ `:/shaders/*.frag` corpus, kept verbatim in oak-node) are
//! converted to WGSL at runtime through naga and run as wgpu fullscreen
//! passes.
//!
//! The conversion mirrors the C++ Vulkan backend's mechanical rewrite
//! (`vulkanrenderer.cpp` `ConvertGlslToVulkan` + `ExtractUniforms`):
//!
//! - a `#version 450 core` prelude is prepended;
//! - legacy `texture2D(`/`texture3D(` calls are renamed to `texture(`;
//! - the pipeline I/O globals get explicit locations
//! (`layout(location = 0) in vec2 ove_texcoord;`,
//! `layout(location = 0) out vec4 frag_color;`);
//! - loose `uniform ;` declarations are extracted: samplers
//! get explicit `set`/`binding` qualifiers, and value uniforms are
//! collected into one anonymous `std140` uniform block (GLSL 450
//! anonymous block members stay accessible by their bare names, so the
//! shader body needs no rewriting).
//!
//! Uniform values are packed by the caller following std140 rules
//! (float/int/bool 4/4, vec2 8/8, vec3 12/16, vec4 16/16, mat4 64/16 —
//! the same table the C++ `GetStd140Size/Alignment` used).
use crate::error::{Error, Result};
/// A value uniform's GLSL type (std140 packing + `NodeValue` mapping).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum UniformType {
/// `float`.
Float,
/// `int`.
Int,
/// `bool` (stored as `int` in the block; WGSL has no shareable bool).
Bool,
/// `vec2`.
Vec2,
/// `vec3`.
Vec3,
/// `vec4`.
Vec4,
/// `mat4`.
Mat4,
/// `vec4[N]` (an array uniform; the polygon generator's bezier point
/// table is the only consumer, indexed by `[i]` in the shader body).
Vec4Array(usize),
}
impl UniformType {
/// The GLSL type keyword, or `None` when it is not a value uniform
/// (samplers, arrays and unknown types are not packable).
fn from_keyword(kw: &str) -> Option {
Some(match kw {
"float" => UniformType::Float,
"int" => UniformType::Int,
"bool" => UniformType::Bool,
"vec2" => UniformType::Vec2,
"vec3" => UniformType::Vec3,
"vec4" => UniformType::Vec4,
"mat4" => UniformType::Mat4,
_ => return None,
})
}
/// The GLSL keyword back (block re-emission). Array types have their
/// count appended (`vec4{name}[{count}]`), matching C++ uniform-array
/// declarations.
fn keyword(self) -> &'static str {
match self {
UniformType::Float => "float",
UniformType::Int => "int",
UniformType::Bool => "bool",
UniformType::Vec2 => "vec2",
UniformType::Vec3 => "vec3",
UniformType::Vec4 => "vec4",
UniformType::Mat4 => "mat4",
UniformType::Vec4Array(_) => "vec4",
}
}
/// std140 base alignment in bytes (C++ `GetStd140Alignment`).
pub fn align(self) -> usize {
match self {
UniformType::Float | UniformType::Int | UniformType::Bool => 4,
UniformType::Vec2 => 8,
UniformType::Vec3 | UniformType::Vec4 | UniformType::Mat4 => 16,
UniformType::Vec4Array(_) => 16,
}
}
/// std140 storage size in bytes (C++ `GetStd140Size`).
pub fn size(self) -> usize {
match self {
UniformType::Float | UniformType::Int | UniformType::Bool => 4,
UniformType::Vec2 => 8,
UniformType::Vec3 => 12,
UniformType::Vec4 => 16,
UniformType::Mat4 => 64,
UniformType::Vec4Array(n) => 16 * n.max(1),
}
}
}
/// A value uniform (std140-packed into the uniform block).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct UniformDecl {
/// Uniform name (= the node input id, the Olive convention).
pub name: String,
/// Its type.
pub ty: UniformType,
/// Byte offset in the packed block (assigned by [`translate`]).
pub offset: usize,
}
/// The result of translating one effect fragment shader.
#[derive(Clone, Debug)]
pub struct TranslatedShader {
/// The WGSL fragment module (entry point `main`).
pub wgsl: String,
/// Value uniforms in block order (offsets assigned, block tail-padded
/// to 16).
pub uniforms: Vec,
/// The packed uniform block size in bytes (0 = no value uniforms).
pub uniform_block_bytes: usize,
/// Texture input names in binding order (combined `sampler2D` etc.;
/// the first one is the effect's main input by Olive convention).
pub textures: Vec,
/// Fragment input varyings in location order (`ove_texcoord` first,
/// then any effect-specific varyings like cornerpin's perspective
/// helpers). The runner's vertex stage must produce all of them.
pub varyings: Vec,
}
// ---------------------------------------------------------------------------
// The effect runner
// ---------------------------------------------------------------------------
/// A compiled effect: the translated shader plus its cached pipeline.
pub struct CompiledEffect {
/// The translation result (uniform layout + texture bindings).
pub translated: TranslatedShader,
/// The compiled pipeline in the context cache.
pub program: std::sync::Arc,
}
/// Translate `glsl` and compile the pipeline on `ctx`. `key` is the
/// pipeline cache key (the effect type id plus any shader-variant id).
pub fn compile_effect(
ctx: &oak_core::backend::GpuContext,
key: &str,
glsl: &str,
filtering: bool,
) -> Result {
let translated = translate(glsl)?;
let program = ctx.compile_shader_pass(
key,
&translated.wgsl,
translated.textures.len() as u32,
!translated.uniforms.is_empty(),
filtering,
)?;
Ok(CompiledEffect { translated, program })
}
/// Run an effect: shade `dst` from the input textures with `params` as
/// the uniform values.
///
/// - `inputs` maps the shader's texture names to context texture tokens;
/// declared textures without an input bind the context's 1×1
/// placeholder and get `_enabled = 0` (C++ Blit's texture
/// binding + enable-flag convention). The first declared texture is
/// the main input — and the iterative one when `iterations` > 1
/// (C++ `ShaderJob::SetIterations` with `tex_in`).
/// - `iterations` runs the shader that many times, feeding each pass's
/// output back as the main input (C++ `OpenGLRenderer::Blit`'s
/// ping-pong; the `ove_iteration` uniform tracks the pass index).
/// `iterative_input` (C++ `ShaderJob::iterative_input`) names the
/// texture the feedback lands in — e.g. the drop shadow's
/// `previous_iteration_in` — while the other samplers keep their
/// original bindings; empty/`None` feeds back into the main input.
/// - Well-known uniforms are auto-filled when declared but absent from
/// `params`: `resolution_in` (the frame size), `ove_iteration`,
/// `ove_mvpmat` (identity).
pub fn run_effect(
ctx: &oak_core::backend::GpuContext,
effect: &CompiledEffect,
params: &oak_node::value::NodeValueRow,
inputs: &[(String, u64)],
dst: u64,
size: (i32, i32),
iterations: u32,
iterative_input: Option<&str>,
) -> Result<()> {
use oak_node::value::NodeValue;
let declares = |name: &str| effect.translated.uniforms.iter().any(|u| u.name == name);
// Resolve every declared texture to a token (placeholder when the
// effect's input is unconnected).
let mut tokens: Vec = Vec::with_capacity(effect.translated.textures.len());
let mut row = params.clone();
for (i, name) in effect.translated.textures.iter().enumerate() {
let token = inputs
.iter()
.find(|(n, _)| n == name)
.map(|(_, t)| *t)
.or_else(|| if i == 0 { inputs.first().map(|(_, t)| *t) } else { None });
match token {
Some(t) => {
tokens.push(t);
let flag = format!("{name}_enabled");
if declares(&flag) && !row.contains_key(&flag) {
row.insert(flag, NodeValue::Boolean(true));
}
}
None => tokens.push(ctx.placeholder_texture()?),
}
}
// Well-known uniforms (C++ inserts resolution_in at job-build time;
// ove_mvpmat defaults to identity in Blit).
if declares("resolution_in") && !row.contains_key("resolution_in") {
row.insert(
"resolution_in".to_string(),
NodeValue::Vec2([size.0 as f64, size.1 as f64]),
);
}
if declares("ove_mvpmat") && !row.contains_key("ove_mvpmat") {
let mut m = [0.0f64; 16];
for i in 0..4 {
m[i * 4 + i] = 1.0;
}
row.insert("ove_mvpmat".to_string(), NodeValue::Matrix(m));
}
let iterations = iterations.max(1);
if iterations == 1 {
let uniforms = pack_uniforms(&effect.translated, &row);
return ctx.run_shader_pass(&effect.program, &uniforms, &tokens, dst);
}
// Ping-pong (C++ Blit): one scratch texture for two passes, two for
// longer chains; the last pass always lands in `dst`. Each pass feeds
// back into the iterative input (C++ `ShaderJob::iterative_input`),
// defaulting to the first (main) texture.
let feedback = iterative_input
.and_then(|name| effect.translated.textures.iter().position(|t| t == name))
.unwrap_or(0);
let scratch_a = ctx.create_texture(size.0, size.1)?;
let scratch_b = if iterations > 2 {
Some(ctx.create_texture(size.0, size.1)?)
} else {
None
};
let result = (|| -> Result<()> {
let mut input_tokens = tokens.clone();
for i in 0..iterations {
let mut pass_row = row.clone();
if declares("ove_iteration") {
pass_row.insert("ove_iteration".to_string(), NodeValue::Int(i as i64));
}
let target = if i == iterations - 1 {
dst
} else if i % 2 == 0 {
scratch_a
} else {
scratch_b.unwrap()
};
let uniforms = pack_uniforms(&effect.translated, &pass_row);
ctx.run_shader_pass(&effect.program, &uniforms, &input_tokens, target)?;
if !input_tokens.is_empty() {
let slot = feedback.min(input_tokens.len() - 1);
input_tokens[slot] = target;
}
}
Ok(())
})();
ctx.destroy_texture(scratch_a);
if let Some(b) = scratch_b {
ctx.destroy_texture(b);
}
result
}
/// Replace whole-word occurrences of `name` in `s` with `replacement`
/// (identifier boundaries: alphanumerics and `_`). The node-shader
/// corpus uses plain identifiers, so this simple scan suffices — no
/// regex dependency.
fn replace_ident(s: &str, name: &str, replacement: &str) -> String {
fn is_ident_char(c: u8) -> bool {
c.is_ascii_alphanumeric() || c == b'_'
}
let bytes = s.as_bytes();
let name = name.as_bytes();
let mut out = String::with_capacity(s.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i..].starts_with(name)
&& (i == 0 || !is_ident_char(bytes[i - 1]))
&& !bytes
.get(i + name.len())
.is_some_and(|&c| is_ident_char(c))
{
out.push_str(replacement);
i += name.len();
} else {
out.push(bytes[i] as char);
i += 1;
}
}
out
}
/// Parse a standalone `in`/`out` varying declaration line body (after
/// the direction keyword), e.g. `vec2 ove_texcoord;` → `(name, type)`.
/// Function parameter lists and anything else are rejected.
fn parse_plain_global(rest: &str) -> Option<(String, String)> {
let rest = rest.trim().strip_suffix(';')?.trim();
let (ty, name) = rest.split_once(char::is_whitespace)?;
let name = name.trim();
if name.is_empty() || !name.chars().all(|c| c.is_alphanumeric() || c == '_') {
return None;
}
Some((name.to_string(), ty.to_string()))
}
/// Binding 0 is the uniform block; textures/samplers follow.
const UNIFORM_BLOCK_BINDING: u32 = 0;
/// Pack the uniform block for `shader` from `params` (the effect's
/// parameter row; uniform names are the node input ids by Olive
/// convention). Packing follows the declared uniform types, converting
/// from whatever `NodeValue` shape arrived (the C++ Blit dispatched on
/// the value type with GL's implicit conversions; here the declared
/// type wins). Undeclared params are skipped; missing values stay zero.
pub fn pack_uniforms(
shader: &TranslatedShader,
params: &oak_node::value::NodeValueRow,
) -> Vec {
use oak_node::value::NodeValue;
let mut buf = vec![0u8; shader.uniform_block_bytes];
for decl in &shader.uniforms {
let Some(value) = params.get(&decl.name) else {
continue;
};
let f32s: Vec = match (decl.ty, value) {
(UniformType::Float, NodeValue::Float(v)) => vec![*v as f32],
(UniformType::Float, NodeValue::Int(v) | NodeValue::Combo(v)) => vec![*v as f32],
(UniformType::Float, NodeValue::Boolean(v)) => vec![f32::from(u8::from(*v))],
(UniformType::Int, NodeValue::Int(v) | NodeValue::Combo(v)) => {
write_i32(&mut buf, decl.offset, *v as i32);
continue;
}
(UniformType::Int, NodeValue::Float(v)) => {
write_i32(&mut buf, decl.offset, *v as i32);
continue;
}
(UniformType::Bool, NodeValue::Boolean(v)) => {
write_i32(&mut buf, decl.offset, i32::from(*v));
continue;
}
(UniformType::Bool, NodeValue::Int(v) | NodeValue::Combo(v)) => {
write_i32(&mut buf, decl.offset, i32::from(*v != 0));
continue;
}
(UniformType::Vec2, NodeValue::Vec2(v)) => v.iter().map(|x| *x as f32).collect(),
(UniformType::Vec3, NodeValue::Vec3(v)) => v.iter().map(|x| *x as f32).collect(),
// A color packs into a vec3 slot as its RGB (C++ Color →
// glUniform4f only for vec4; a vec3 target takes rgb).
(UniformType::Vec3, NodeValue::Color(v)) => {
v[..3].iter().map(|x| *x as f32).collect()
}
(UniformType::Vec4, NodeValue::Vec4(v) | NodeValue::Color(v)) => {
v.iter().map(|x| *x as f32).collect()
}
(UniformType::Vec4Array(n), NodeValue::Vec4Array(v)) => {
let mut out = Vec::with_capacity(n * 4);
for el in v.iter().take(n) {
out.extend(el.iter().map(|x| *x as f32));
}
// Pad to the declared count (each element is 16 bytes; the
// buffer is sized n*16 regardless).
let have = out.len();
out.resize((n * 4).max(have), 0.0f32);
out
}
// Matrices: GLSL mat4 is column-major; the NodeValue comment
// marks the layout row-major, so transpose on the way in.
(UniformType::Mat4, NodeValue::Matrix(m)) => {
let mut cols = Vec::with_capacity(16);
for c in 0..4 {
for r in 0..4 {
cols.push(m[r * 4 + c] as f32);
}
}
cols
}
_ => continue,
};
for (i, v) in f32s.iter().enumerate() {
let at = decl.offset + i * 4;
if at + 4 <= buf.len() {
buf[at..at + 4].copy_from_slice(&v.to_le_bytes());
}
}
}
buf
}
fn write_i32(buf: &mut [u8], offset: usize, v: i32) {
if offset + 4 <= buf.len() {
buf[offset..offset + 4].copy_from_slice(&v.to_le_bytes());
}
}
/// Whether a GLSL type keyword is a combined sampler (C++
/// `IsSamplerType`: sampler\*D / samplerCube / sampler2DArray).
fn is_sampler_type(kw: &str) -> bool {
kw.starts_with("sampler")
}
/// Parse a `uniform ;` declaration line (the constrained
/// style of the node shader corpus: one declaration per line, no layout
/// qualifiers, no initializers). Arrays ARE supported:
/// `uniform [];` (the polygon generator's point
/// table). `None` for any other line (samplers are handled by the
/// caller). Returns `(base keyword, name, array count)`.
fn parse_uniform_line(line: &str) -> Option<(&str, &str, usize)> {
let t = line.trim_start();
let rest = t.strip_prefix("uniform")?;
if !rest.starts_with(char::is_whitespace) {
return None;
}
let rest = rest.trim_start();
let (ty, rest) = rest.split_once(char::is_whitespace)?;
let rest = rest.trim_start().trim_end_matches(';');
let (name, count) = if let Some(idx) = rest.find('[') {
let name = rest[..idx].trim();
let end = rest[idx..].find(']')?;
let count: usize = rest[idx + 1..idx + end].trim().parse().ok()?;
(name, count.max(1))
} else {
(rest.trim(), 1)
};
if name.is_empty() || !name.chars().all(|c| c.is_alphanumeric() || c == '_') {
return None;
}
Some((ty, name, count))
}
/// Translate one GLSL fragment shader to WGSL (naga glsl-in → wgsl-out).
/// The source keeps the Olive node-shader conventions; see the module
/// docs for the rewrite steps.
pub fn translate(glsl: &str) -> Result {
let mut body_lines: Vec = Vec::new();
let mut uniforms: Vec<(UniformType, String, usize)> = Vec::new();
let mut textures: Vec = Vec::new();
for line in glsl.lines() {
if let Some((ty, name, count)) = parse_uniform_line(line) {
if is_sampler_type(ty) {
for _ in 0..count {
textures.push(name.to_string());
}
continue;
}
let base = UniformType::from_keyword(ty);
let uniform = match (base, count) {
(Some(UniformType::Vec4), n) if n > 1 => UniformType::Vec4Array(n),
(Some(t), n) if n == 1 => t,
_ => {
return Err(Error::Failed(format!(
"unsupported array uniform type in shader: {ty} {name}[{count}]"
)));
}
};
uniforms.push((uniform, name.to_string(), count));
continue;
}
body_lines.push(line.to_string());
}
let mut src = String::from("#version 450 core\n");
let mut in_loc = 0u32;
let mut out_loc = 0u32;
let mut varyings: Vec = Vec::new();
// Re-emit the extracted uniforms BEFORE the body (GLSL requires
// declarations to precede use): the value block first (binding 0),
// then the samplers. The block is anonymous — GLSL 450 anonymous
// block members stay accessible by their bare names, so the shader
// body needs no rewriting.
if !uniforms.is_empty() {
src.push_str("layout(std140, set = 0, binding = ");
src.push_str(&UNIFORM_BLOCK_BINDING.to_string());
src.push_str(") uniform OakParams {\n");
for (ty, name, count) in &uniforms {
// bools are declared as int (WGSL has no host-shareable
// bool); the body's uses were rewritten to `bool(x)`.
let kw = if *ty == UniformType::Bool {
"int"
} else {
ty.keyword()
};
if *count > 1 {
src.push_str(&format!(" {kw} {name}[{}];\n", count));
} else {
src.push_str(&format!(" {kw} {name};\n"));
}
}
src.push_str("};\n");
}
for (i, name) in textures.iter().enumerate() {
// Split the combined sampler2D: texture at an odd binding, its
// sampler right after (the binding map is reported through
// [`TranslatedShader::textures`] in declaration order).
src.push_str(&format!(
"layout(set = 0, binding = {}) uniform texture2D {};\n\
layout(set = 0, binding = {}) uniform sampler {}_s;\n",
1 + 2 * i,
name,
2 + 2 * i,
name
));
}
for line in &body_lines {
let mut l = line.clone();
// Strip a pre-existing #version (the prelude pins 450 core).
if l.trim_start().starts_with("#version") {
continue;
}
// Legacy sampling entry points.
l = l.replace("texture2D(", "texture(");
l = l.replace("texture3D(", "texture(");
l = l.replace("textureCube(", "texture(");
// naga's GLSL frontend has no combined sampler2D uniforms: split
// each into (texture2D, sampler) and combine at the call site
// (`texture(sampler2D(tex, tex_s), uv)` — the same style naga's
// own GLSL tests use).
for name in &textures {
l = l.replace(
&format!("texture({name},"),
&format!("texture(sampler2D({name}, {name}_s),"),
);
}
// WGSL has no host-shareable bool: bool uniforms live in the
// block as `int`, so their uses become `bool(x)` (nonzero test).
for (ty, name, _count) in &uniforms {
if *ty == UniformType::Bool {
l = replace_ident(&l, name, &format!("bool({name})"));
}
}
// Explicit interface locations (C++ ConvertGlslToVulkan did this
// for the two known globals; shaders with extra varyings — e.g.
// cornerpin's perspective helpers — get sequential locations so
// nothing collides at location 0).
let trimmed = l.trim_start();
if let Some(rest) = trimmed.strip_prefix("in ") {
if let Some((name, _ty)) = parse_plain_global(rest) {
let indent = &l[..l.len() - trimmed.len()];
l = format!("{indent}layout(location = {in_loc}) in {}", rest.trim());
varyings.push(name);
in_loc += 1;
}
} else if let Some(rest) = trimmed.strip_prefix("out ") {
if parse_plain_global(rest).is_some() {
let indent = &l[..l.len() - trimmed.len()];
l = format!("{indent}layout(location = {out_loc}) out {}", rest.trim());
out_loc += 1;
}
}
src.push_str(&l);
src.push('\n');
}
let module = naga::front::glsl::Frontend::default()
.parse(&naga::front::glsl::Options::from(naga::ShaderStage::Fragment), &src)
.map_err(|e| Error::Failed(format!("GLSL parse failed: {e:?}")))?;
let info = naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::all(),
)
.validate(&module)
.map_err(|e| Error::Failed(format!("translated shader failed validation: {e:?}")))?;
let wgsl = naga::back::wgsl::write_string(&module, &info, naga::back::wgsl::WriterFlags::empty())
.map_err(|e| Error::Failed(format!("WGSL emission failed: {e:?}")))?;
// std140 offsets (declaration order; the block tail pads to 16).
let mut offset = 0usize;
let mut decls = Vec::with_capacity(uniforms.len());
for (ty, name, _count) in uniforms {
let align = ty.align();
offset = offset.next_multiple_of(align);
decls.push(UniformDecl { name, ty, offset });
offset += ty.size();
}
let uniform_block_bytes = if decls.is_empty() {
0
} else {
offset.next_multiple_of(16)
};
Ok(TranslatedShader {
wgsl,
uniforms: decls,
uniform_block_bytes,
textures,
varyings,
})
}
#[cfg(test)]
mod tests {
use super::*;
/// The minimal node-shader shape: texture input + one float uniform.
#[test]
fn translates_minimal_effect_shader() {
let glsl = r#"
uniform sampler2D tex_in;
uniform float gain_in;
in vec2 ove_texcoord;
out vec4 frag_color;
void main() {
frag_color = texture(tex_in, ove_texcoord) * gain_in;
}
"#;
let out = translate(glsl).expect("translate");
assert_eq!(out.textures, vec!["tex_in"]);
assert_eq!(out.uniforms.len(), 1);
assert_eq!(out.uniforms[0].name, "gain_in");
assert_eq!(out.uniforms[0].ty, UniformType::Float);
assert_eq!(out.uniforms[0].offset, 0);
assert_eq!(out.uniform_block_bytes, 16);
assert!(out.wgsl.contains("fn main"), "WGSL entry point: {}", out.wgsl);
}
/// bool/int/vec/color-shaped uniforms pack with std140 offsets.
#[test]
fn std140_offsets_match_the_cpp_table() {
let glsl = r#"
uniform sampler2D tex_in;
uniform bool flag_in;
uniform vec2 center_in;
uniform float radius_in;
uniform vec4 color_in;
in vec2 ove_texcoord;
out vec4 frag_color;
void main() {
vec4 c = texture(tex_in, ove_texcoord);
frag_color = flag_in ? color_in * radius_in : vec4(c.xy + center_in, c.zw);
}
"#;
let out = translate(glsl).expect("translate");
let offsets: Vec<(&str, usize)> = out
.uniforms
.iter()
.map(|u| (u.name.as_str(), u.offset))
.collect();
// bool 4/4 @0; vec2 align 8 @8; float 4 @16; vec4 align 16 @32.
assert_eq!(
offsets,
vec![
("flag_in", 0),
("center_in", 8),
("radius_in", 16),
("color_in", 32)
]
);
assert_eq!(out.uniform_block_bytes, 48);
}
/// Array uniforms translate into the block as packed std140 arrays
/// (polygon's `points_in[64]`; the declaration order determines the
/// offsets: `int` @0, the vec4 array aligned to 16 @16, the trailing
/// vec2 aligned to 8).
#[test]
fn array_uniforms_translate_and_pack() {
use oak_node::value::{NodeValue, NodeValueRow};
let glsl = r#"
uniform int point_count;
uniform vec4 points_in[64];
uniform vec2 resolution_in;
in vec2 ove_texcoord;
out vec4 frag_color;
void main() { frag_color = vec4(0.0); }
"#;
let out = translate(glsl).expect("translate array uniforms");
let decls: Vec<(&str, &UniformType, usize)> = out
.uniforms
.iter()
.map(|u| (u.name.as_str(), &u.ty, u.offset))
.collect();
assert_eq!(
decls,
vec![
("point_count", &UniformType::Int, 0),
("points_in", &UniformType::Vec4Array(64), 16),
("resolution_in", &UniformType::Vec2, 16 + 64 * 16),
]
);
assert_eq!(
out.uniform_block_bytes,
(16 + 64 * 16 + 8usize).next_multiple_of(16)
);
let mut row = NodeValueRow::new();
row.insert("point_count".into(), NodeValue::Int(3));
row.insert(
"points_in".into(),
NodeValue::Vec4Array(vec![[1.0, 2.0, 0.0, 0.0], [3.0, 4.0, 0.0, 0.0]]),
);
let buf = pack_uniforms(&out, &row);
assert_eq!(buf.len(), out.uniform_block_bytes);
let count = out.uniforms.iter().find(|u| u.name == "point_count").unwrap();
assert_eq!(
i32::from_le_bytes(buf[count.offset..count.offset + 4].try_into().unwrap()),
3
);
let points = out.uniforms.iter().find(|u| u.name == "points_in").unwrap();
let at = |i: usize, c: usize| points.offset + i * 16 + c * 4;
assert_eq!(f32::from_le_bytes(buf[at(0, 0)..at(0, 1)].try_into().unwrap()), 1.0);
assert_eq!(f32::from_le_bytes(buf[at(0, 1)..at(0, 2)].try_into().unwrap()), 2.0);
assert_eq!(f32::from_le_bytes(buf[at(1, 0)..at(1, 1)].try_into().unwrap()), 3.0);
// Short arrays pad the remaining slots to zero.
assert_eq!(
f32::from_le_bytes(buf[at(63, 0)..at(63, 1)].try_into().unwrap()),
0.0
);
}
/// Uniform packing follows the declared types and std140 offsets.
#[test]
fn pack_uniforms_maps_node_values() {
use oak_node::value::{NodeValue, NodeValueRow};
let glsl = r#"
uniform sampler2D tex_in;
uniform float gain_in;
uniform bool flag_in;
uniform vec4 color_in;
in vec2 ove_texcoord;
out vec4 frag_color;
void main() { frag_color = texture(tex_in, ove_texcoord); }
"#;
let out = translate(glsl).unwrap();
let mut row = NodeValueRow::new();
row.insert("gain_in".into(), NodeValue::Float(0.5));
row.insert("flag_in".into(), NodeValue::Boolean(true));
row.insert("color_in".into(), NodeValue::Color([0.1, 0.2, 0.3, 0.4]));
let buf = pack_uniforms(&out, &row);
assert_eq!(buf.len(), out.uniform_block_bytes);
let gain = out.uniforms.iter().find(|u| u.name == "gain_in").unwrap();
assert_eq!(
f32::from_le_bytes(buf[gain.offset..gain.offset + 4].try_into().unwrap()),
0.5
);
let flag = out.uniforms.iter().find(|u| u.name == "flag_in").unwrap();
assert_eq!(
i32::from_le_bytes(buf[flag.offset..flag.offset + 4].try_into().unwrap()),
1
);
let color = out.uniforms.iter().find(|u| u.name == "color_in").unwrap();
for (i, want) in [0.1f32, 0.2, 0.3, 0.4].iter().enumerate() {
let at = color.offset + i * 4;
assert_eq!(f32::from_le_bytes(buf[at..at + 4].try_into().unwrap()), *want);
}
}
// ---- GPU runner tests (skipped without an adapter) -------------------
fn gpu() -> Option> {
oak_core::backend::GpuContext::create(oak_core::backend::BackendKind::Auto)
}
fn f32_frame(w: i32, h: i32, fill: impl Fn(usize) -> [f32; 4]) -> oak_core::texture::Frame {
use oak_core::texture::Frame;
let mut frame = Frame::new();
let mut pod = oak_core::frame::VideoParamsPod::default();
pod.width = w;
pod.height = h;
pod.format = oak_core::PixelFormat::F32 as i32;
frame.set_video_params(pod);
frame.allocate();
for px in 0..(w * h) as usize {
let rgba = fill(px);
for (c, v) in rgba.iter().enumerate() {
frame.data[(px * 4 + c) * 4..(px * 4 + c) * 4 + 4]
.copy_from_slice(&v.to_le_bytes());
}
}
frame
}
fn pixel(out: &oak_core::texture::Frame, x: usize) -> [f32; 4] {
let mut rgba = [0.0f32; 4];
for (c, v) in rgba.iter_mut().enumerate() {
*v = f32::from_le_bytes(out.data[(x * 4 + c) * 4..(x * 4 + c) * 4 + 4].try_into().unwrap());
}
rgba
}
/// End-to-end effect pass: a translated node shader (gain multiply)
/// runs through `compile_shader_pass`/`run_shader_pass` and the
/// readback matches the expected pixels exactly. (Moved from
/// oak-core's backend tests — the GLSL→WGSL translation and uniform
/// packing live in this module.)
#[test]
fn gpu_effect_pass_runs_translated_shader() {
use oak_core::frame::VideoParamsPod;
use oak_core::texture::Frame;
let Some(ctx) = gpu() else {
eprintln!("no adapter; skipping effect pass");
return;
};
let glsl = r#"
uniform sampler2D tex_in;
uniform float gain_in;
in vec2 ove_texcoord;
out vec4 frag_color;
void main() {
frag_color = texture(tex_in, ove_texcoord) * gain_in;
}
"#;
let translated = translate(glsl).unwrap();
let program = ctx
.compile_shader_pass(
"test-gain",
&translated.wgsl,
translated.textures.len() as u32,
!translated.uniforms.is_empty(),
false,
)
.unwrap();
let mut row = oak_node::value::NodeValueRow::new();
row.insert("gain_in".into(), oak_node::value::NodeValue::Float(0.5));
let uniforms = pack_uniforms(&translated, &row);
let w = 4;
let h = 2;
let src = ctx.create_texture(w, h).unwrap();
let dst = ctx.create_texture(w, h).unwrap();
let mut frame = Frame::new();
let mut pod = VideoParamsPod::default();
pod.width = w;
pod.height = h;
frame.set_video_params(pod);
frame.allocate();
// Distinct values per pixel (F32 RGBA): 0.2/0.4/0.6/1.0 shifted
// per pixel, so a UV mixup would be visible.
for px in 0..(w * h) as usize {
for c in 0..4 {
let v = 0.2 + 0.1 * (px + c) as f32;
frame.data[(px * 4 + c) * 4..(px * 4 + c) * 4 + 4]
.copy_from_slice(&v.to_le_bytes());
}
}
ctx.upload(src, &frame).unwrap();
ctx.run_shader_pass(&program, &uniforms, &[src], dst).unwrap();
let out = ctx.download(dst).unwrap();
for px in 0..(w * h) as usize {
for c in 0..4 {
let at = (px * 4 + c) * 4;
let got = f32::from_le_bytes(out.data[at..at + 4].try_into().unwrap());
let want = (0.2 + 0.1 * (px + c) as f32) * 0.5;
assert!(
(got - want).abs() < 1e-6,
"px {px} ch {c}: got {got}, want {want}"
);
}
}
ctx.destroy_texture(src);
ctx.destroy_texture(dst);
}
/// The real opacity shader through the full runner: pixels are
/// multiplied by the factor (and the UV convention is identity —
/// a flip would move the non-uniform pixels around).
#[test]
fn gpu_opacity_effect_scales_pixels() {
let Some(ctx) = gpu() else {
eprintln!("no adapter; skipping");
return;
};
let (_core, behavior) = oak_node::factory::Factory::global()
.create_any("org.olivevideoeditor.Olive.opacity")
.unwrap();
let glsl = behavior.shader_code("").unwrap();
let effect = compile_effect(&ctx, "test/opacity", &glsl, false).unwrap();
let frame = f32_frame(16, 4, |px| {
[0.2 + 0.01 * px as f32, 0.4, 0.6, 1.0]
});
let src = ctx.create_texture(16, 4).unwrap();
ctx.upload(src, &frame).unwrap();
let dst = ctx.create_texture(16, 4).unwrap();
let mut row = oak_node::value::NodeValueRow::new();
row.insert("opacity_in".into(), oak_node::value::NodeValue::Float(0.5));
run_effect(&ctx, &effect, &row, &[("tex_in".to_string(), src)], dst, (16, 4), 1, None).unwrap();
let out = ctx.download(dst).unwrap();
for px in 0..16usize {
let want = (0.2 + 0.01 * px as f32) * 0.5;
let got = pixel(&out, px)[0];
assert!((got - want).abs() < 1e-4, "px {px}: got {got}, want {want}");
}
ctx.destroy_texture(src);
ctx.destroy_texture(dst);
}
/// The real blur shader: radius 0 is an exact passthrough (the
/// shader's MODE_NONE branch), and a 2px horizontal box blur on a
/// step edge lands exactly half-and-half at the boundary pixels.
#[test]
fn gpu_blur_effect_passthrough_and_step() {
let Some(ctx) = gpu() else {
eprintln!("no adapter; skipping");
return;
};
let (_core, behavior) = oak_node::factory::Factory::global()
.create_any("org.olivevideoeditor.Olive.blur")
.unwrap();
let glsl = behavior.shader_code("").unwrap();
let effect = compile_effect(&ctx, "test/blur", &glsl, false).unwrap();
let src = ctx.create_texture(16, 1).unwrap();
let dst = ctx.create_texture(16, 1).unwrap();
let step = f32_frame(16, 1, |px| {
if px < 8 {
[0.0, 0.0, 0.0, 1.0]
} else {
[1.0, 1.0, 1.0, 1.0]
}
});
ctx.upload(src, &step).unwrap();
// radius 0: passthrough.
let mut row = oak_node::value::NodeValueRow::new();
row.insert("method_in".into(), oak_node::value::NodeValue::Combo(0));
row.insert("radius_in".into(), oak_node::value::NodeValue::Float(0.0));
row.insert("horiz_in".into(), oak_node::value::NodeValue::Boolean(true));
row.insert("vert_in".into(), oak_node::value::NodeValue::Boolean(false));
run_effect(&ctx, &effect, &row, &[("tex_in".to_string(), src)], dst, (16, 1), 1, None).unwrap();
let out = ctx.download(dst).unwrap();
assert_eq!(out.data, step.data, "radius 0 is a passthrough");
// radius 2 horizontal box: out(x) = 0.5 * (in[x-1] + in[x+1]).
row.insert("radius_in".into(), oak_node::value::NodeValue::Float(2.0));
run_effect(&ctx, &effect, &row, &[("tex_in".to_string(), src)], dst, (16, 1), 1, None).unwrap();
let out = ctx.download(dst).unwrap();
for x in 0..16usize {
let got = pixel(&out, x)[0];
let want = match x {
0 => 0.0, // first tap out of bounds (repeat_edge off)
7 | 8 => 0.5,
15 => 0.5, // second tap out of bounds
_ if x < 7 => 0.0,
_ => 1.0,
};
assert!(
(got - want).abs() < 1e-4,
"px {x}: got {got}, want {want}"
);
}
ctx.destroy_texture(src);
ctx.destroy_texture(dst);
}
/// The polygon generator's shader rasterizes the default pentagon on
/// the GPU: the center texel inside the closed point loop is opaque
/// white, the frame corner is transparent, and the 1-point fallback
/// renders nothing.
#[test]
fn gpu_polygon_rasterizes_pentagon() {
let Some(ctx) = gpu() else {
eprintln!("no adapter; skipping");
return;
};
let (_core, behavior) = oak_node::factory::Factory::global()
.create_any("org.olivevideoeditor.Olive.polygon")
.unwrap();
let glsl = behavior.shader_code("rgb").unwrap();
let effect = compile_effect(&ctx, "test/polygon", &glsl, false).unwrap();
let dst = ctx.create_texture(512, 512).unwrap();
let mut row = oak_node::value::NodeValueRow::new();
row.insert(
"points_in".into(),
oak_node::value::NodeValue::Vec4Array(vec![
[0.0, -135.0, 0.0, 0.0],
[135.0, -45.0, 0.0, 0.0],
[90.0, 120.0, 0.0, 0.0],
[-90.0, 120.0, 0.0, 0.0],
[-135.0, -45.0, 0.0, 0.0],
]),
);
row.insert("point_count".into(), oak_node::value::NodeValue::Int(5));
row.insert(
"color_in".into(),
oak_node::value::NodeValue::Color([1.0, 1.0, 1.0, 1.0]),
);
run_effect(&ctx, &effect, &row, &[], dst, (512, 512), 1, None).unwrap();
let out = ctx.download(dst).unwrap();
let center = pixel(&out, 256 * 512 + 256);
assert_eq!(center, [1.0, 1.0, 1.0, 1.0], "center is inside the pentagon");
let corner = pixel(&out, 0);
assert_eq!(corner, [0.0, 0.0, 0.0, 0.0], "corner is outside");
// Degenerate: a single point draws nothing.
row.insert("point_count".into(), oak_node::value::NodeValue::Int(1));
run_effect(&ctx, &effect, &row, &[], dst, (512, 512), 1, None).unwrap();
let out = ctx.download(dst).unwrap();
assert_eq!(
pixel(&out, 256 * 512 + 256),
[0.0, 0.0, 0.0, 0.0],
"single point draws nothing"
);
ctx.destroy_texture(dst);
}
/// The mask effect's shader multiplies the base texture by the
/// pentagon matte on the GPU: the center texel keeps the base value,
/// the corner is cleared, and with `invert_in` the result flips.
#[test]
fn gpu_mask_multiplies_base_by_pentagon() {
let Some(ctx) = gpu() else {
eprintln!("no adapter; skipping");
return;
};
let (_core, behavior) = oak_node::factory::Factory::global()
.create_any("org.olivevideoeditor.Olive.mask")
.unwrap();
let glsl = behavior.shader_code("mask").unwrap();
let effect = compile_effect(&ctx, "test/mask", &glsl, false).unwrap();
let src = ctx.create_texture(512, 512).unwrap();
let dst = ctx.create_texture(512, 512).unwrap();
let gray = crate::shaderfx::tests::f32_frame(512, 512, |_| {
[0.5, 0.5, 0.5, 1.0]
});
ctx.upload(src, &gray).unwrap();
let mut row = oak_node::value::NodeValueRow::new();
row.insert(
"points_in".into(),
oak_node::value::NodeValue::Vec4Array(vec![
[0.0, -135.0, 0.0, 0.0],
[135.0, -45.0, 0.0, 0.0],
[90.0, 120.0, 0.0, 0.0],
[-90.0, 120.0, 0.0, 0.0],
[-135.0, -45.0, 0.0, 0.0],
]),
);
row.insert("point_count".into(), oak_node::value::NodeValue::Int(5));
row.insert("feather_in".into(), oak_node::value::NodeValue::Float(0.0));
row.insert("invert_in".into(), oak_node::value::NodeValue::Boolean(false));
run_effect(
&ctx,
&effect,
&row,
&[("base_in".to_string(), src)],
dst,
(512, 512),
1,
None,
)
.unwrap();
let out = ctx.download(dst).unwrap();
let center = pixel(&out, 256 * 512 + 256);
assert_eq!(center, [0.5, 0.5, 0.5, 1.0], "center keeps the base");
assert_eq!(pixel(&out, 0), [0.0, 0.0, 0.0, 0.0], "corner is masked out");
// Inverted: the corner keeps the base, the center is cleared.
row.insert("invert_in".into(), oak_node::value::NodeValue::Boolean(true));
run_effect(
&ctx,
&effect,
&row,
&[("base_in".to_string(), src)],
dst,
(512, 512),
1,
None,
)
.unwrap();
let out = ctx.download(dst).unwrap();
assert_eq!(pixel(&out, 0), [0.5, 0.5, 0.5, 1.0], "inverted corner keeps base");
assert_eq!(
pixel(&out, 256 * 512 + 256),
[0.0, 0.0, 0.0, 0.0],
"inverted center cleared"
);
// Feather: a square polygon with radius 4 softens the edge — the
// pixel right outside the crisp edge becomes partially visible.
row.insert("invert_in".into(), oak_node::value::NodeValue::Boolean(false));
row.insert("feather_in".into(), oak_node::value::NodeValue::Float(4.0));
row.insert(
"points_in".into(),
oak_node::value::NodeValue::Vec4Array(vec![
[-150.0, -150.0, 0.0, 0.0],
[150.0, -150.0, 0.0, 0.0],
[150.0, 150.0, 0.0, 0.0],
[-150.0, 150.0, 0.0, 0.0],
]),
);
row.insert("point_count".into(), oak_node::value::NodeValue::Int(4));
run_effect(
&ctx,
&effect,
&row,
&[("base_in".to_string(), src)],
dst,
(512, 512),
1,
None,
)
.unwrap();
let out = ctx.download(dst).unwrap();
let soft = pixel(&out, 408 * 512 + 256)[0];
assert!(
soft > 0.02 && soft < 0.6,
"just outside the feathered edge is partially visible: {soft}"
);
ctx.destroy_texture(src);
ctx.destroy_texture(dst);
}
/// The linear grading node's OCIO-spliced shader doubles gray under
/// +1 stop of master exposure (2^1 * 0.2 = 0.4) and gates the
/// transform behind the localBypass uniform off.
#[test]
fn gpu_grading_linear_applies_exposure() {
let Some(ctx) = gpu() else {
eprintln!("no adapter; skipping");
return;
};
let Some(stub) = crate::eval::grading_stub_for(
"org.olivevideoeditor.Olive.ociogradingtransformlinear",
) else {
eprintln!("no OCIO config; skipping");
return;
};
let (_core, behavior) = oak_node::factory::Factory::global()
.create_any("org.olivevideoeditor.Olive.ociogradingtransformlinear")
.unwrap();
let glsl = behavior.shader_code(&stub).unwrap();
let effect = compile_effect(&ctx, "test/grading-lin", &glsl, false).unwrap();
let src = ctx.create_texture(8, 4).unwrap();
let dst = ctx.create_texture(8, 4).unwrap();
let gray = crate::shaderfx::tests::f32_frame(8, 4, |_| [0.2, 0.2, 0.2, 1.0]);
ctx.upload(src, &gray).unwrap();
let mut row = oak_node::value::NodeValueRow::new();
row.insert(
"ocio_grading_primary_exposure".into(),
oak_node::value::NodeValue::Vec3([2.0, 2.0, 2.0]),
);
row.insert(
"ocio_grading_primary_contrast".into(),
oak_node::value::NodeValue::Vec3([1.0, 1.0, 1.0]),
);
row.insert(
"ocio_grading_primary_offset".into(),
oak_node::value::NodeValue::Vec3([0.0, 0.0, 0.0]),
);
row.insert(
"ocio_grading_primary_saturation".into(),
oak_node::value::NodeValue::Float(1.0),
);
row.insert(
"ocio_grading_primary_pivot".into(),
oak_node::value::NodeValue::Float(0.18),
);
row.insert(
"ocio_grading_primary_clampBlack".into(),
oak_node::value::NodeValue::Float(-1.0),
);
row.insert(
"ocio_grading_primary_clampWhite".into(),
oak_node::value::NodeValue::Float(2.0),
);
row.insert(
"ocio_grading_primary_localBypass".into(),
oak_node::value::NodeValue::Boolean(false),
);
run_effect(
&ctx,
&effect,
&row,
&[("tex_in".to_string(), src)],
dst,
(8, 4),
1,
None,
)
.unwrap();
let out = ctx.download(dst).unwrap();
for px in 0..8usize {
let got = pixel(&out, px);
let want = [0.4f32, 0.4, 0.4, 1.0];
assert!(
(got[0] - want[0]).abs() < 1e-3,
"px {px}: exposure must double gray, got {got:?}"
);
}
ctx.destroy_texture(src);
ctx.destroy_texture(dst);
}
/// The log grading node's OCIO-spliced shader: brightness (lift) of
/// +0.1 shifts 0.2 gray to 0.3 with the identity gain/gamma/pivot
/// defaults.
#[test]
fn gpu_grading_log_applies_lift() {
let Some(ctx) = gpu() else {
eprintln!("no adapter; skipping");
return;
};
let Some(stub) = crate::eval::grading_stub_for(
"org.olivevideoeditor.Olive.OCIO_NAMESPACEgradingtransformlog",
) else {
eprintln!("no OCIO config; skipping");
return;
};
let (_core, behavior) = oak_node::factory::Factory::global()
.create_any("org.olivevideoeditor.Olive.OCIO_NAMESPACEgradingtransformlog")
.unwrap();
let glsl = behavior.shader_code(&stub).unwrap();
let effect = compile_effect(&ctx, "test/grading-log", &glsl, false).unwrap();
let src = ctx.create_texture(8, 4).unwrap();
let dst = ctx.create_texture(8, 4).unwrap();
let gray = crate::shaderfx::tests::f32_frame(8, 4, |_| [0.2, 0.2, 0.2, 1.0]);
ctx.upload(src, &gray).unwrap();
let mut row = oak_node::value::NodeValueRow::new();
row.insert(
"ocio_grading_primary_brightness".into(),
oak_node::value::NodeValue::Vec3([0.1, 0.1, 0.1]),
);
row.insert(
"ocio_grading_primary_contrast".into(),
oak_node::value::NodeValue::Vec3([1.0, 1.0, 1.0]),
);
row.insert(
"ocio_grading_primary_gamma".into(),
oak_node::value::NodeValue::Vec3([1.0, 1.0, 1.0]),
);
row.insert(
"ocio_grading_primary_saturation".into(),
oak_node::value::NodeValue::Float(1.0),
);
row.insert(
"ocio_grading_primary_pivot".into(),
oak_node::value::NodeValue::Float(-0.2),
);
row.insert(
"ocio_grading_primary_pivotBlack".into(),
oak_node::value::NodeValue::Float(0.0),
);
row.insert(
"ocio_grading_primary_pivotWhite".into(),
oak_node::value::NodeValue::Float(1.0),
);
row.insert(
"ocio_grading_primary_clampBlack".into(),
oak_node::value::NodeValue::Float(-1.0),
);
row.insert(
"ocio_grading_primary_clampWhite".into(),
oak_node::value::NodeValue::Float(2.0),
);
row.insert(
"ocio_grading_primary_localBypass".into(),
oak_node::value::NodeValue::Boolean(false),
);
run_effect(
&ctx,
&effect,
&row,
&[("tex_in".to_string(), src)],
dst,
(8, 4),
1,
None,
)
.unwrap();
let out = ctx.download(dst).unwrap();
for px in 0..8usize {
let got = pixel(&out, px);
assert!(
(got[0] - 0.3).abs() < 1e-3,
"px {px}: lift must shift gray, got {got:?}"
);
}
ctx.destroy_texture(src);
ctx.destroy_texture(dst);
}
/// Every registered node type that ships a shader must translate (the
/// all-shaders sweep). OCIO-stubbed shaders (`%1` markers needing the
/// OCIO-generated function text) retry with the real OCIO stub first;
/// only nodes whose stub is unavailable *and* unknown fail outright.
#[test]
fn all_registered_shaders_translate() {
let mut ok = Vec::new();
let mut ocio_stubbed = Vec::new();
let mut failed = Vec::new();
for meta in oak_node::factory::Factory::global().entries() {
let (_core, behavior) = (meta.create)();
let Some(glsl) = behavior.shader_code("") else {
continue;
};
match translate(&glsl) {
Ok(_) => ok.push(meta.type_id),
Err(e) => {
let msg = format!("{e:?}");
// The unresolved OCIO stub is the one sanctioned
// failure mode (chromakey & co. call into
// OCIO-generated functions). Retry with the real OCIO
// stub: the wiring must make these translate. When no
// OCIO config is available (stub build), fall back to
// a pass-through function so the sweep still covers
// the node's own shader body.
let retried = crate::eval::ocio_stub_for(meta.type_id)
.or_else(|| {
crate::eval::OCIO_SHADER_STUBS
.iter()
.find(|(id, ..)| *id == meta.type_id)
.map(|(_, fn_name, ..)| {
format!("vec4 {fn_name}(vec4 c) {{ return c; }}")
})
})
.or_else(|| crate::eval::grading_stub_for(meta.type_id))
.or_else(|| {
crate::eval::OCIO_GRADING_STUBS
.iter()
.find(|(id, _)| *id == meta.type_id)
.map(|_| "vec4 ove_grading_primary(vec4 c) { return c; }".to_string())
});
match retried {
Some(stub) => match translate(&behavior.shader_code(&stub).unwrap()) {
Ok(_) => ok.push(meta.type_id),
Err(e) => {
failed.push((meta.type_id, format!("with OCIO stub: {e:?}")))
}
},
None if msg.contains("SceneLinear")
|| msg.contains("UnknownFunction")
|| msg.contains("ove_grading_primary") => {
// Not in the stub table but still OCIO-shaped:
// report separately, not as a regression.
ocio_stubbed.push(meta.type_id);
}
None => failed.push((meta.type_id, msg)),
}
}
}
}
eprintln!("shader sweep: {} ok, {} ocio-stubbed", ok.len(), ocio_stubbed.len());
for id in &ocio_stubbed {
eprintln!(" ocio-stubbed: {id}");
}
for (id, msg) in &failed {
eprintln!(" FAILED: {id}: {}", &msg[..msg.len().min(200)]);
}
assert!(failed.is_empty(), "{} shaders failed to translate", failed.len());
assert!(!ok.is_empty(), "no shaders translated at all");
}
}