Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
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}");
|
|
}
|
|
}
|
|
}
|
|
}
|