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.
322 lines
12 KiB
Rust
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}");
|
|
}
|
|
}
|
|
}
|
|
}
|