//! Effect gallery — the development/preview rig for the VJ effect //! renderstack (apps/vj/src/effects). //! //! cargo build --release -p makepad-vj --example effect_gallery //! ./target/release/examples/effect_gallery --remote //! //! Loads every `.splash` document from `apps/vj/resources/effects/`, //! shows one at a time in a `VjFxView`, and cycles with Left/Right (or //! `VJFX_DOC=` to start on a given document). Documents that declare //! `input0: "test"` get a generated color test pattern bound to texture //! input 0 — the stand-in for a channel's live video frame in effect-pass //! mode. The beat is the widget's free-running clock (VJFX_BPM, default //! 122) so every effect pulses like it would on the VJ's beat bus. #![allow(dead_code)] // borrows the app's effects modules wholesale and uses a subset use makepad_widgets::*; #[path = "../src/effects/mod.rs"] mod effects; app_main!(App); script_mod! { use mod.prelude.widgets.* load_all_resources() do #(App::script_component(vm)){ ui: Root{ main_window := Window{ window.inner_size: vec2(1280, 800) body +: { app_view := SolidView{ width: Fill height: Fill flow: Down draw_bg +: {color: #x05060a} header := SolidView{ width: Fill height: 40.0 flow: Right align: Align{x: 0.0 y: 0.5} padding: Inset{left: 12.0 right: 12.0} spacing: 12.0 draw_bg +: {color: #x11141c} fx_name := H3{ text: "loading…" draw_text +: {color: #xf0f4ff} } fx_status := Label{ text: "" draw_text +: {color: #x8391a0} } hint := Label{ text: "left/right: switch effect" draw_text +: {color: #x5a6472} } } fx_view := mod.widgets.VjFxView{} } } } } } } #[derive(Script, ScriptHook)] pub struct App { #[live] ui: WidgetRef, #[rust] docs: Vec<(String, String)>, #[rust] current: usize, #[rust] started: bool, /// `VJFX_INPUT=`: decoded once, bound as REAL content on /// input 0 for every shown effect — the content-coupling verify lever /// (without it, effects run standalone on the animated fallback). #[rust] input_tex: Option, /// Deck stand-ins for transition docs: [deck A, deck B, premix]. A /// transition rendered with nothing bound is a BLACK frame — and a /// black gallery grab reads as a broken document. #[rust] trans_tex: [Option; 3], /// `VJFX_SWEEP=`: the parity sweep — grab every document once at /// the pinned clock, then quit. See [`Sweep`]. #[rust] sweep: Option, #[rust] frame: NextFrame, } /// THE PARITY SWEEP (`VJFX_SWEEP=`, with `VJFX_CAPTURE=`). /// /// A self-terminating capture run: load document i, let the widget advance /// its fixed frame budget and freeze, write one PNG, move on, quit after /// the last one. Every grab is then a pure function of the document, so /// two sweeps over two builds compare pixel for pixel — which is how a /// shader migration proves it changed no look. Existing PNGs are skipped, /// so an interrupted sweep resumes instead of restarting. #[derive(Default)] pub struct Sweep { dir: std::path::PathBuf, /// Frames still to let pass before asking for this document's grab. settle: u32, /// Frames spent waiting for the PNG file to appear (bounded). waited: u32, /// Where the current grab is being written (None = still settling). pending: Option, done: bool, } impl App { /// `VJFX_DIR=` points the gallery at another preset directory — /// how the migration measured its own cost, by running ONE binary over /// the pre- and post-migration document sets. fn docs_dir() -> std::path::PathBuf { match std::env::var("VJFX_DIR") { Ok(dir) if !dir.is_empty() => std::path::PathBuf::from(dir), _ => std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("resources/effects"), } } fn load_docs(&mut self) { let mut docs = Vec::new(); if let Ok(entries) = std::fs::read_dir(Self::docs_dir()) { for entry in entries.flatten() { let path = entry.path(); if path.extension().and_then(|e| e.to_str()) == Some("splash") { if let Ok(source) = std::fs::read_to_string(&path) { let name = path .file_stem() .and_then(|s| s.to_str()) .unwrap_or("effect") .to_string(); docs.push((name, source)); } } } } docs.sort_by(|a, b| a.0.cmp(&b.0)); // `VJFX_ONLY=a,b,c` keeps just the documents whose name contains one // of the fragments — a one-family sweep instead of the whole // library (and a much shorter capture run). if let Ok(only) = std::env::var("VJFX_ONLY") { let want: Vec = only.split(',').map(|s| s.trim().to_string()).collect(); docs.retain(|(n, _)| want.iter().any(|w| !w.is_empty() && n.contains(w.as_str()))); } self.docs = docs; } /// `VJFX_CAPTURE=` or `@
` (default step /// 1/60 s). Unset = the live free-running clock. fn capture_config() -> Option<(f64, u32)> { let spec = std::env::var("VJFX_CAPTURE").ok()?; let (frames, dt) = match spec.split_once('@') { Some((f, d)) => (f, d.parse::().ok()?), None => (spec.as_str(), 1.0 / 60.0), }; Some((dt.clamp(0.0001, 1.0), frames.parse::().ok()?.clamp(1, 100_000))) } /// One sweep step, driven off the app's own frame timer. Waits out the /// widget's capture budget, asks for the PNG, waits for it to land, /// advances — and quits when the library is exhausted. fn sweep_step(&mut self, cx: &mut Cx) { let Some(sweep) = &mut self.sweep else { return }; if sweep.done { return; } if let Some(path) = sweep.pending.clone() { sweep.waited += 1; if !path.exists() && sweep.waited < 240 { return; } if !path.exists() { log!("effect_gallery sweep: NO GRAB for {}", path.display()); } sweep.pending = None; sweep.waited = 0; let next = self.current + 1; if next >= self.docs.len() { if let Some(sweep) = &mut self.sweep { sweep.done = true; } log!("effect_gallery sweep: complete ({} documents)", self.docs.len()); cx.quit(); return; } self.sweep_show(cx, next); return; } if sweep.settle > 0 { sweep.settle -= 1; return; } // The widget has frozen on its last capture frame: whatever is on // screen now is the document's deterministic frame. let (name, _) = self.docs[self.current].clone(); let path = sweep.dir.join(format!("{name}.png")); sweep.pending = Some(path.clone()); cx.capture_next_frame_to_file(path); } /// Load document `index` for the sweep, skipping any already grabbed. fn sweep_show(&mut self, cx: &mut Cx, index: usize) { let mut index = index; let (budget, dir) = match &self.sweep { Some(s) => (Self::capture_config().map(|c| c.1).unwrap_or(90), s.dir.clone()), None => return, }; while index < self.docs.len() { let name = self.docs[index].0.clone(); if !dir.join(format!("{name}.png")).exists() { break; } index += 1; } if index >= self.docs.len() { if let Some(sweep) = &mut self.sweep { sweep.done = true; } log!("effect_gallery sweep: complete (nothing left to grab)"); cx.quit(); return; } self.show(cx, index); if let Some(sweep) = &mut self.sweep { // The widget freezes after `budget` frames; a small margin // covers the load frame and the compositor catching up. sweep.settle = budget + 12; sweep.waited = 0; sweep.pending = None; } } fn show(&mut self, cx: &mut Cx, index: usize) { if self.docs.is_empty() { self.ui .label(cx, ids!(fx_name)) .set_text(cx, "no documents in apps/vj/resources/effects"); return; } self.current = index % self.docs.len(); let (key, source) = self.docs[self.current].clone(); let widget = self.ui.widget(cx, ids!(fx_view)); let Some(mut view) = widget.borrow_mut::() else { return; }; let bpm = std::env::var("VJFX_BPM") .ok() .and_then(|v| v.parse::().ok()) .unwrap_or(122.0); view.set_bpm(bpm); // DETERMINISTIC CAPTURE (the parity harness): `VJFX_CAPTURE=` // — optionally `@
` — makes the widget advance by a fixed // step for exactly that many frames after the load and then freeze, // so a grab is a pure function of the document. This is what a // before/after migration sweep compares; without it the wall clock // decides what is on screen and no two grabs match. view.set_capture(Self::capture_config()); // LOAD COST: document eval + hook-object build + engine build. The // shader itself compiles lazily on first draw, so this is the CPU // half of "what does a document with its own shader cost?". let t0 = std::time::Instant::now(); let result = view.set_effect_source(cx, &key, &source); let load_ms = t0.elapsed().as_secs_f64() * 1000.0; // Content coupling verify lever: VJFX_INPUT= binds a real // texture to input 0 (a stand-in for the channel's live video). if let Ok(path) = std::env::var("VJFX_INPUT") { if self.input_tex.is_none() && !path.is_empty() { match std::fs::read(&path) { Ok(bytes) => match decode_image_from_data(&bytes) { Ok(buf) => self.input_tex = Some(buf.into_new_texture(cx)), Err(e) => log!("VJFX_INPUT {path}: decode failed: {e:?}"), }, Err(e) => log!("VJFX_INPUT {path}: {e}"), } } if let Some(tex) = &self.input_tex { view.set_input_texture(0, Some(tex.clone())); } } // THE DIAL LEVER (`VJFX_DIALS=p0,p1,p2,p3`, each a 0..1 float or // `-` to leave that dial at the document's default): pins the user // dials for this run, so a capture sweep can photograph one dial at // its extremes and JUDGE whether the range is a real creative // spectrum or a nothing-to-slow shrug. if let Ok(spec) = std::env::var("VJFX_DIALS") { let mut over = [None; 4]; for (i, part) in spec.split(',').take(4).enumerate() { if let Ok(v) = part.trim().parse::() { over[i] = Some(v.clamp(0.0, 1.0)); } } view.set_user_override(over); } // Transition docs render BLACK with nothing bound — give them the // same two distinct deck stand-ins the thumbnail renderer uses, so // the gallery (and any sweep grab) shows the transition working. // Two-deck docs get separate inputs and the default mid fader; // premix transitions get one mid-dissolve frame on input 0. if view.wants_deck_inputs() { let a = gallery_deck_pattern(cx, &mut self.trans_tex[0], 0.0); let b = gallery_deck_pattern(cx, &mut self.trans_tex[1], 1.0); view.set_input_texture(0, Some(a)); view.set_input_texture(1, Some(b)); } else if effects::seed::is_transition_preset(&key) && self.input_tex.is_none() { let premix = gallery_deck_pattern(cx, &mut self.trans_tex[2], 0.5); view.set_input_texture(0, Some(premix)); } // Otherwise texture-input docs get the runtime's built-in animated // fallback automatically — the gallery binds nothing. let (title, status) = match result { Ok(_) => ( format!("[{}/{}] {}", self.current + 1, self.docs.len(), key), view.status.clone(), ), Err(e) => (format!("[{}/{}] {} FAILED", self.current + 1, self.docs.len(), key), e), }; drop(view); log!("effect_gallery: {} — {} [load {:.2} ms]", title, status, load_ms); self.ui.label(cx, ids!(fx_name)).set_text(cx, &title); self.ui.label(cx, ids!(fx_status)).set_text(cx, &status); self.ui.redraw(cx); } } /// Two visibly different deck stand-ins, dissolved by `m`: a dim warm slate /// with a soft disc (m = 0, "deck A") and a dim cool slate ruled by a grid /// and a bar (m = 1, "deck B"). The pixel math is the SHARED one /// (`effects::deck_pattern`), so what the gallery previews is exactly what /// the thumbnail bank bakes; only the drift is dropped, so capture sweeps /// stay deterministic. fn gallery_deck_pattern(cx: &mut Cx, slot: &mut Option, m: f32) -> Texture { use effects::deck_pattern; const W: usize = deck_pattern::W; const H: usize = deck_pattern::H; if let Some(tex) = slot { return tex.clone(); } let mut data = vec![0u32; W * H]; for y in 0..H { let v = y as f32 / H as f32; for x in 0..W { let u = x as f32 / W as f32; // Frozen drift/bar: a still frame of the same two pictures. data[y * W + x] = deck_pattern::texel_bgra(u, v, m, (0.12, -0.10), 0.30); } } let tex = Texture::new_with_format( cx, TextureFormat::VecBGRAu8_32 { width: W, height: H, data: Some(data), updated: TextureUpdated::Full, }, ); *slot = Some(tex.clone()); tex } impl MatchEvent for App {} impl AppMain for App { fn script_mod(vm: &mut ScriptVm) -> ScriptValue { makepad_widgets::script_mod(vm); crate::effects::script_mod(vm); self::script_mod(vm) } fn handle_event(&mut self, cx: &mut Cx, event: &Event) { self.match_event(cx, event); self.ui.handle_event(cx, event, &mut Scope::empty()); if !self.started { // First event after the UI exists: load the library and show // the requested (or first) document. self.started = true; self.load_docs(); if let Ok(dir) = std::env::var("VJFX_SWEEP") { let dir = std::path::PathBuf::from(dir); let _ = std::fs::create_dir_all(&dir); self.sweep = Some(Sweep { dir, ..Default::default() }); self.frame = cx.new_next_frame(); self.sweep_show(cx, 0); return; } let start = std::env::var("VJFX_DOC") .ok() .and_then(|want| self.docs.iter().position(|(n, _)| *n == want)) .unwrap_or(0); self.show(cx, start); } if self.frame.is_event(event).is_some() { self.frame = cx.new_next_frame(); self.sweep_step(cx); } if let Event::KeyDown(ke) = event { match ke.key_code { KeyCode::ArrowRight => { let next = self.current + 1; self.show(cx, next); } KeyCode::ArrowLeft => { let prev = (self.current + self.docs.len().max(1)) - 1; self.show(cx, prev); } _ => {} } } // Scripted driving (the remote bridge's /t goes through the IME // path, immune to key repeat): "n" next, "p" prev, "g" jump. if let Event::TextInput(te) = event { let cmd = te.input.trim(); if cmd == "n" { let next = self.current + 1; self.show(cx, next); } else if cmd == "p" { let prev = (self.current + self.docs.len().max(1)) - 1; self.show(cx, prev); } else if cmd == "i" { // Perf info for the current effect (regen/tick costs). let widget = self.ui.widget(cx, ids!(fx_view)); let line = widget.borrow::().map(|view| { format!( "effect_gallery perf: regen {:.3} ms, tick {:.3} ms ({} instr), \ sim {:.3} ms{}", view.regen_ms, view.tick_ms, view.tick_instructions, view.sim_ms, view.tick_error .as_deref() .map(|e| format!(", tick error: {e}")) .unwrap_or_default() ) }); if let Some(line) = line { log!("{}", line); } } else if let Some(name) = cmd.strip_prefix("g") { if let Some(at) = self.docs.iter().position(|(n, _)| n == name) { self.show(cx, at); } } } } }