makepad/apps/vj/examples/flow_warp_lab.rs
Admin 17b8a658fc makepad-vj: the live performance console — sweep-law transport, slots, and VFR import
A VJ console that is its own asset store: five surfaces (VIDEO program tiles,
MUSIC DJ decks, SFX, EFFECT slots, LIGHTS) over the asset server, driven by
APC40 hardware or the screen.

The transport is the centre of it. The SWEEP LAW: one direction, one beat
step, and a pacer that never hiccups — a loop wrap is never fast-forwarded and
the grid keeps no holes. On top of that: bracket-to-bracket scrub math so the
playhead learns the user's trim, SCRATCH as a sprung shuttle on every deck
transport, ping-pong and per-slot mute, hot-standby decks, one rate authority,
and beat sync that fits a whole clip into N bars at a musical rate while
cached loops keep real wrapping pts so the phase survives.

VARIABLE FRAMERATE VIDEO IMPORT measures the flow field a clip needs without a
model (libs/video_flow), which is what makes free-rate bounce-looping GPU
playback possible; the enhance pipe uprezes and tweens a deck clip in one
decode and one encode, with the motion vectors inside the mp4.

The console itself: makepad orange and the brand lockup, Blender-style BPM,
system tooltips, popover sliders, bracket trim handles, one button family and
a no-push band, doc-labeled slot dials with MIDI learn and strict typing, a
BEATS dropdown and slow-biased jog, an IMPORT panel that arms, runs and stops,
and BLAST — one press invents a visual for every parallel pipe. Generation
runs six jobs in flight across the fleet, and a job row names what it made.

The library grid fills as a relay — page, detail, manifest, blob, decode,
texture — and every hand-off used to be picked up by the 20Hz poll timer, so
each hop cost a tick however fast the store answered. The hand-offs now run on
the frame while anything is owed, manifests jump the queue like the details
that produced them, and resolve/decode/worker width all widen while nobody is
on stage and narrow together the moment the program window opens or a deck
plays. The visible page resolves first. Measured on the same warm cache: a
48-tile page 4.7s -> 1.9s, a tab's first visit 4.3s -> 1.1s, decode wait 48ms
-> 9ms, a second visit painted in the same frame with zero decodes, and no
UI hitches across the session.

Also here: wave analysis (tempo, grid, downbeat, tiles) with a judge suite
that scores the detector against click tracks and a human drummer, stem
separation, karaoke lyric display, Art-Net/DMX light control, and an output
window that projects without janking on Windows mouse movement.

466 tests, zero warnings.
2026-08-23 01:35:44 +02:00

322 lines
12 KiB
Rust

//! flow_warp_lab — standalone rig for the FLOW-WARP playback path
//! (apps/vj/src/flow_warp.rs): loads one enhance-service mp4 (with its
//! `mkfl` motion payload), runs the GPU warp pass full-window, and exposes
//! the pair-space transport to the remote bridge.
//!
//! cargo build --release -p makepad-vj --example flow_warp_lab
//! VJ_FLOW_CLIP=/path/clip.mp4 ./target/release/examples/flow_warp_lab --remote
//!
//! Text commands (send via the bridge's `/t?t=...`, the IME path):
//!
//! rate<f> set playback rate (any float, negative runs backwards)
//! pos<f> park the clock at pair-space position <f> (pauses)
//! b toggle bounce p toggle play/pause
//! i log perf line (pass ms, measured fps)
//!
//! `VJ_FLOW_DUMP_PAIR=<k>` additionally decodes pair k's endpoint frames and
//! the BAKED in-between (video frame k·stride + stride/2 — RIFE's own t=0.5
//! answer on a tweened clip) as raw BGRA files into `VJ_FLOW_DUMP_DIR`, for
//! pixel comparison against a grab of the warp parked at `pos<k>.5`.
use makepad_widgets::makepad_platform::video_file::{nv12, VideoFileDecoder};
use makepad_widgets::*;
use std::sync::mpsc::{channel, Receiver};
// The lab compiles the flow modules standalone; host-facing API the lab
// does not exercise (clear/output/seek — main.rs's surface) stays quiet.
#[path = "../src/flow.rs"]
#[allow(dead_code)]
mod flow;
#[path = "../src/flow_warp.rs"]
#[allow(dead_code)]
mod flow_warp;
use flow_warp::{FlowClipData, FlowWarpView};
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(640, 396)
body +: {
SolidView{
width: Fill
height: Fill
flow: Down
draw_bg +: {color: #x05060a}
header := View{
width: Fill
height: 44
flow: Down
padding: Inset{left: 8.0 top: 4.0 right: 8.0}
hud := Label{
text: "flow_warp_lab: waiting for clip…"
draw_text +: {color: #xd0e0f0}
draw_text.text_style.font_size: 9
}
hud2 := Label{
text: "rate<f> pos<f> b p i"
draw_text +: {color: #x8391a0}
draw_text.text_style.font_size: 9
}
}
warp := mod.widgets.FlowWarpView{
composite: true
width: Fill
height: Fill
}
}
}
}
}
}
}
type PreparedMsg = Result<Option<Box<FlowClipData>>, String>;
#[derive(Script, ScriptHook)]
pub struct App {
#[live]
ui: WidgetRef,
#[rust]
started: bool,
#[rust]
rx: Option<Receiver<PreparedMsg>>,
#[rust]
next_frame: NextFrame,
#[rust]
last_time: Option<f64>,
/// Rolling display-frame intervals for the fps line.
#[rust]
frame_dts: Vec<f64>,
}
fn clip_path() -> Option<String> {
std::env::var("VJ_FLOW_CLIP").ok().or_else(|| {
std::env::args().skip(1).find(|a| a.ends_with(".mp4"))
})
}
/// Decode specific video frame indices to raw BGRA files (worker side).
fn dump_frames(path: &str, dir: &str, wanted: &[u64]) {
let mut decoder = match VideoFileDecoder::open(path) {
Ok(d) => d,
Err(e) => {
eprintln!("flow_warp_lab dump: open failed: {e}");
return;
}
};
let _ = std::fs::create_dir_all(dir);
let mut rgb = Vec::new();
let mut index: u64 = 0;
let max = wanted.iter().copied().max().unwrap_or(0);
loop {
match decoder.next_frame() {
Ok(Some(frame)) => {
if wanted.contains(&index) {
nv12::nv12_to_rgb8(&frame.nv12, frame.width, frame.height, &mut rgb);
let mut bgra =
Vec::with_capacity((frame.width * frame.height) as usize * 4);
for px in rgb.chunks_exact(3) {
bgra.extend_from_slice(&[px[2], px[1], px[0], 0xff]);
}
let file = format!(
"{dir}/frame-{index}-{}x{}.bgra",
frame.width, frame.height
);
match std::fs::write(&file, &bgra) {
Ok(_) => eprintln!("flow_warp_lab dump: wrote {file}"),
Err(e) => eprintln!("flow_warp_lab dump: {file}: {e}"),
}
}
if index >= max {
return;
}
index += 1;
}
Ok(None) => return,
Err(e) => {
eprintln!("flow_warp_lab dump: decode failed: {e}");
return;
}
}
}
}
impl App {
fn start_load(&mut self) {
let Some(path) = clip_path() else {
log!("flow_warp_lab: set VJ_FLOW_CLIP=/path/clip.mp4");
return;
};
let (tx, rx) = channel();
self.rx = Some(rx);
std::thread::spawn(move || {
let prepared = flow_warp::prepare_flow_clip(std::path::Path::new(&path));
if let (Ok(Some(data)), Ok(pair)) = (
&prepared,
std::env::var("VJ_FLOW_DUMP_PAIR").map(|v| v.parse::<u64>().unwrap_or(0)),
) {
let dir = std::env::var("VJ_FLOW_DUMP_DIR")
.unwrap_or_else(|_| "/tmp/flow_warp_lab".into());
let s = data.stride as u64;
// Pair endpoints and — on a tweened clip — the baked mid.
let mut wanted = vec![pair * s, (pair + 1) * s];
if s >= 2 {
wanted.push(pair * s + s / 2);
}
dump_frames(&path, &dir, &wanted);
}
let _ = tx.send(prepared);
});
}
fn with_warp<R>(
&mut self,
cx: &mut Cx,
f: impl FnOnce(&mut Cx, &mut FlowWarpView) -> R,
) -> Option<R> {
let widget = self.ui.widget(cx, ids!(warp));
let mut view = widget.borrow_mut::<FlowWarpView>()?;
Some(f(cx, &mut view))
}
fn hud_line(&mut self, cx: &mut Cx) -> String {
let fps = if self.frame_dts.is_empty() {
0.0
} else {
self.frame_dts.len() as f64 / self.frame_dts.iter().sum::<f64>()
};
self.with_warp(cx, |_cx, view| {
let pos = view.position_pairs();
let pair = pos.floor().min((view.pairs().max(1) - 1) as f64);
format!(
"pair {:.0} t {:.3} pos {:.2}/{} {:.2}s rate {:+.3} bounce {} {} | {:.0} fps pass {:.2} ms",
pair,
pos - pair,
pos,
view.pairs(),
view.position_secs(),
view.rate(),
if view.bounce() { "ON" } else { "off" },
if view.is_playing() { "PLAY" } else { "PAUSED" },
fps,
view.last_pass_ms,
)
})
.unwrap_or_else(|| "no warp view".into())
}
}
impl MatchEvent for App {}
impl AppMain for App {
fn script_mod(vm: &mut ScriptVm) -> ScriptValue {
makepad_widgets::script_mod(vm);
crate::flow_warp::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 {
self.started = true;
self.start_load();
self.next_frame = cx.new_next_frame();
}
if self.next_frame.is_event(event).is_some() {
// Adopt a finished load.
if let Some(msg) = self.rx.as_ref().and_then(|rx| rx.try_recv().ok()) {
self.rx = None;
match msg {
Ok(Some(data)) => {
log!(
"flow_warp_lab: clip ready — {} pairs, {}x{}, stride {}, {:.2} pairs/s",
data.pairs,
data.width,
data.height,
data.stride,
data.pairs_per_sec
);
self.with_warp(cx, |cx, view| {
view.set_clip(cx, data);
view.set_playing(true);
});
}
Ok(None) => {
log!("flow_warp_lab: clip has no usable flow map (see stderr)");
self.ui.label(cx, ids!(hud)).set_text(cx, "NO FLOW MAP");
}
Err(e) => {
log!("flow_warp_lab: prepare failed: {e}");
self.ui.label(cx, ids!(hud)).set_text(cx, &format!("FAILED: {e}"));
}
}
}
let time = cx.seconds_since_app_start();
let last = self.last_time.replace(time).unwrap_or(time);
let dt = (time - last).clamp(0.0, 0.25);
self.frame_dts.push(dt.max(1e-6));
if self.frame_dts.len() > 120 {
self.frame_dts.remove(0);
}
let has_clip = self
.with_warp(cx, |cx, view| {
view.advance(cx, dt);
view.has_clip()
})
.unwrap_or(false);
if has_clip {
let line = self.hud_line(cx);
self.ui.label(cx, ids!(hud)).set_text(cx, &line);
}
self.next_frame = cx.new_next_frame();
}
// Remote-scriptable transport (the bridge's /t goes through the IME
// path): rate<f>, pos<f>, b, p, i.
if let Event::TextInput(te) = event {
let cmd = te.input.trim().to_string();
if let Some(v) = cmd.strip_prefix("rate").and_then(|v| v.parse::<f64>().ok()) {
self.with_warp(cx, |_cx, view| view.set_rate(v));
log!("flow_warp_lab: rate {v}");
} else if let Some(v) = cmd.strip_prefix("pos").and_then(|v| v.parse::<f64>().ok())
{
self.with_warp(cx, |cx, view| {
view.set_playing(false);
view.set_position_pairs(cx, v);
});
log!("flow_warp_lab: parked at {v}");
} else if cmd == "b" {
self.with_warp(cx, |_cx, view| {
let on = !view.bounce();
view.set_bounce(on);
on
})
.map(|on| log!("flow_warp_lab: bounce {on}"));
} else if cmd == "p" {
self.with_warp(cx, |_cx, view| {
let on = !view.is_playing();
view.set_playing(on);
on
})
.map(|on| log!("flow_warp_lab: playing {on}"));
} else if cmd == "x" {
self.with_warp(cx, |_cx, view| {
view.debug_show_frame = !view.debug_show_frame;
view.debug_show_frame
})
.map(|on| log!("flow_warp_lab: debug_show_frame {on}"));
} else if cmd == "i" {
let line = self.hud_line(cx);
log!("flow_warp_lab: {line}");
}
}
}
}