pixelflow/
main.rs

1#![forbid(unsafe_code)]
2
3//! Command-line entry point for PixelFlow.
4
5#[cfg(test)]
6mod tests;
7
8use std::ffi::{OsStr, OsString};
9use std::io::{IsTerminal, Write};
10use std::path::{Path, PathBuf};
11use std::sync::Arc;
12
13use clap::{CommandFactory, Parser, Subcommand};
14use indicatif::{ProgressBar, ProgressStyle};
15
16#[derive(Debug, Parser, PartialEq)]
17#[command(
18    name = "pixelflow",
19    version,
20    about = "Render PixelFlow .pf scripts to video streams"
21)]
22struct CliArgs {
23    #[command(subcommand)]
24    command: Option<CliCommand>,
25
26    #[arg(value_name = "SCRIPT")]
27    script_path: Option<PathBuf>,
28
29    #[arg(short = 'o', long = "output", value_name = "PATH")]
30    output: Option<PathBuf>,
31
32    #[arg(long = "set", value_name = "NAME=VALUE", value_parser = parse_script_parameter)]
33    parameters: Vec<pixelflow_script::ScriptParameter>,
34
35    #[arg(long, default_value_t = 0)]
36    start: usize,
37
38    #[arg(long)]
39    end: Option<usize>,
40
41    #[arg(long, value_parser = parse_worker_threads)]
42    threads: Option<usize>,
43
44    #[arg(long)]
45    timings: bool,
46}
47
48#[derive(Debug, Subcommand, PartialEq)]
49enum CliCommand {
50    /// Print detailed PixelFlow version and plugin information.
51    Version {
52        /// Include core, feature, ABI, and loaded plugin details.
53        #[arg(long)]
54        verbose: bool,
55    },
56}
57
58#[derive(Clone, Debug, Eq, PartialEq)]
59enum OutputTarget {
60    Stdout,
61    File(PathBuf),
62}
63
64#[derive(Clone, Copy, Debug, Eq, PartialEq)]
65enum OutputFormatKind {
66    Y4m,
67    RawVideoRgb,
68}
69
70#[derive(Clone, Debug, Eq, PartialEq)]
71struct OutputMedia {
72    kind: OutputFormatKind,
73    format: pixelflow::FormatDescriptor,
74    width: usize,
75    height: usize,
76    frame_count: usize,
77    frame_rate: pixelflow::Rational,
78}
79
80#[derive(Clone, Copy, Debug, Eq, PartialEq)]
81struct TerminalState {
82    stderr: bool,
83}
84
85impl TerminalState {
86    fn current() -> Self {
87        Self {
88            stderr: std::io::stderr().is_terminal(),
89        }
90    }
91}
92
93const fn should_show_index_progress(terminal: TerminalState) -> bool {
94    terminal.stderr
95}
96
97const fn should_show_render_progress(target: &OutputTarget, terminal: TerminalState) -> bool {
98    terminal.stderr && matches!(target, OutputTarget::File(_))
99}
100
101fn main() {
102    let args = match parse_args(std::env::args_os()) {
103        Ok(args) => args,
104        Err(error) => error.exit(),
105    };
106
107    if let Err(error) = run(args) {
108        eprintln!("{error}");
109        std::process::exit(1);
110    }
111}
112
113fn run(args: CliArgs) -> pixelflow::Result<()> {
114    let terminal = TerminalState::current();
115    let logger = pixelflow::Logger::default();
116    let (core, loaded_plugins) = build_core(args.threads, &logger)?;
117
118    if let Some(CliCommand::Version { verbose }) = args.command {
119        print_version(verbose, &loaded_plugins);
120        return Ok(());
121    }
122
123    let path = args
124        .script_path
125        .as_deref()
126        .expect("clap validation requires script path for render");
127    let source = std::fs::read_to_string(path).map_err(|error| {
128        pixelflow::PixelFlowError::new(
129            pixelflow::ErrorCategory::Io,
130            pixelflow::ErrorCode::new("io.read_script"),
131            format!("failed to read script '{}': {error}", path.display()),
132        )
133    })?;
134    let script_dir = script_dir(path);
135    let prop_resolver = Arc::new(RuntimePropResolver::new(
136        script_dir.clone(),
137        logger.clone(),
138        core.config().worker_threads(),
139    ));
140    let (graph, metadata_schema) =
141        pixelflow_script::ScriptEngine::with_filter_registry(core.registry().clone())
142            .with_prop_resolver(prop_resolver)
143            .evaluate(&source, &args.parameters)?
144            .into_parts();
145    let mut index_progress = StderrIndexProgress::new(should_show_index_progress(terminal));
146    let indexed = pixelflow_filters::ffms2_source::index_reachable_sources(
147        graph,
148        &pixelflow_filters::ffms2_source::SourceIndexContext::new(script_dir.as_deref(), logger),
149        &mut index_progress,
150    )?;
151    let (graph, mut executors) = indexed.into_graph_and_executors();
152    prepare_graph_for_render(&graph, &metadata_schema, &mut executors)?;
153
154    let options = pixelflow::RenderOptions::new(args.start, args.end);
155    let target = output_target(
156        args.output
157            .expect("clap validation requires output for render"),
158    );
159    let show_render_progress = should_show_render_progress(&target, terminal);
160    match target {
161        OutputTarget::Stdout => {
162            let stdout = std::io::stdout();
163            let stderr = std::io::stderr();
164            let mut stdout = stdout.lock();
165            let mut stderr = stderr.lock();
166            render_output(
167                &core,
168                &graph,
169                executors,
170                options,
171                &mut stdout,
172                &mut stderr,
173                args.timings,
174                show_render_progress,
175            )
176        }
177        OutputTarget::File(path) => {
178            let file = std::fs::File::create(&path).map_err(|error| {
179                pixelflow::PixelFlowError::new(
180                    pixelflow::ErrorCategory::Io,
181                    pixelflow::ErrorCode::new("io.write_output"),
182                    format!("failed to create output '{}': {error}", path.display()),
183                )
184            })?;
185            let stderr = std::io::stderr();
186            let mut stderr = stderr.lock();
187            render_output(
188                &core,
189                &graph,
190                executors,
191                options,
192                std::io::BufWriter::new(file),
193                &mut stderr,
194                args.timings,
195                show_render_progress,
196            )
197        }
198    }
199}
200
201fn build_core(
202    worker_threads: Option<usize>,
203    logger: &pixelflow::Logger,
204) -> pixelflow::Result<(pixelflow::Core, Vec<pixelflow::LoadedPlugin>)> {
205    let mut config = pixelflow::CoreConfig::default()
206        .with_auto_load_plugins(false)
207        .with_logger(logger.clone());
208    if let Some(worker_threads) = worker_threads {
209        config = config.with_worker_threads(worker_threads);
210    }
211    let mut core = pixelflow::Core::with_config(config)?;
212    pixelflow_filters::register_builtin_filters(core.registry_mut())?;
213    let loaded_plugins = pixelflow::load_plugins_from_directories(
214        &pixelflow::platform_plugin_directories(),
215        core.registry_mut(),
216        logger,
217    );
218
219    Ok((core, loaded_plugins))
220}
221
222fn parse_args<I, S>(args: I) -> Result<CliArgs, clap::Error>
223where
224    I: IntoIterator<Item = S>,
225    S: Into<OsString> + Clone,
226{
227    let parsed = CliArgs::try_parse_from(args)?;
228    validate_cli_args(parsed)
229}
230
231fn validate_cli_args(args: CliArgs) -> Result<CliArgs, clap::Error> {
232    if args.command.is_none() {
233        if args.script_path.is_none() {
234            return Err(CliArgs::command().error(
235                clap::error::ErrorKind::MissingRequiredArgument,
236                "required argument 'SCRIPT' missing for render",
237            ));
238        }
239        if args.output.is_none() {
240            return Err(CliArgs::command().error(
241                clap::error::ErrorKind::MissingRequiredArgument,
242                "required option '-o <PATH>' missing for render",
243            ));
244        }
245    }
246
247    Ok(args)
248}
249
250fn parse_script_parameter(value: &str) -> Result<pixelflow_script::ScriptParameter, String> {
251    pixelflow_script::ScriptParameter::parse_set(value).map_err(|error| error.to_string())
252}
253
254fn parse_worker_threads(value: &str) -> Result<usize, String> {
255    let parsed = value
256        .parse::<usize>()
257        .map_err(|_| "--threads requires a positive integer".to_owned())?;
258    if parsed == 0 {
259        return Err("--threads requires a positive integer".to_owned());
260    }
261
262    Ok(parsed)
263}
264
265fn output_target(path: PathBuf) -> OutputTarget {
266    if path.as_os_str() == OsStr::new("-") {
267        OutputTarget::Stdout
268    } else {
269        OutputTarget::File(path)
270    }
271}
272
273fn print_version(verbose: bool, plugins: &[pixelflow::LoadedPlugin]) {
274    println!("pixelflow {}", pixelflow::version());
275    if !verbose {
276        return;
277    }
278
279    println!("core {}", pixelflow::version());
280    println!("features: cli, script, filters, ffms2-source");
281    println!("plugin ABI {}", pixelflow::PIXELFLOW_ABI_VERSION);
282    if plugins.is_empty() {
283        println!("plugins: none");
284    } else {
285        println!("plugins:");
286        for plugin in plugins {
287            println!("  {} (ABI v{})", plugin.name(), plugin.abi_version());
288        }
289    }
290}
291
292struct StderrIndexProgress {
293    enabled: bool,
294    bar: Option<ProgressBar>,
295}
296
297impl StderrIndexProgress {
298    const fn new(enabled: bool) -> Self {
299        Self { enabled, bar: None }
300    }
301}
302
303impl pixelflow_filters::ffms2_source::IndexProgressSink for StderrIndexProgress {
304    fn begin_source(&mut self, path: &Path) {
305        if !self.enabled {
306            return;
307        }
308
309        let bar = ProgressBar::new(0);
310        bar.set_style(index_progress_style());
311        bar.set_prefix("indexing");
312        bar.set_message(path.display().to_string());
313        bar.tick();
314        self.bar = Some(bar);
315    }
316
317    fn progress(&mut self, path: &Path, current: usize, total: usize) {
318        if !self.enabled {
319            return;
320        }
321
322        if self.bar.is_none() {
323            self.begin_source(path);
324        }
325
326        if let Some(bar) = &self.bar {
327            bar.set_length(progress_units(total));
328            bar.set_position(progress_units(current));
329        }
330    }
331
332    fn cache_hit(&mut self, path: &Path) {
333        if let Some(bar) = &self.bar {
334            bar.println(format!("index cache hit {}", path.display()));
335        }
336    }
337
338    fn finish_source(&mut self, path: &Path) {
339        let Some(bar) = self.bar.take() else {
340            return;
341        };
342
343        bar.println(format!("indexed {}", path.display()));
344        bar.finish_and_clear();
345    }
346}
347
348fn index_progress_style() -> ProgressStyle {
349    ProgressStyle::with_template("{prefix:.bold} [{wide_bar:.cyan/blue}] {pos}/{len} {msg}")
350        .unwrap_or_else(|_| ProgressStyle::default_bar())
351        .progress_chars("=> ")
352}
353
354fn progress_units(value: usize) -> u64 {
355    u64::try_from(value).unwrap_or(u64::MAX)
356}
357
358fn script_dir(path: &Path) -> Option<PathBuf> {
359    path.parent()
360        .filter(|parent| !parent.as_os_str().is_empty())
361        .map(Path::to_path_buf)
362}
363
364fn output_media(graph: &pixelflow::Graph) -> pixelflow::Result<OutputMedia> {
365    let output = graph.outputs().first().ok_or_else(|| {
366        pixelflow::PixelFlowError::new(
367            pixelflow::ErrorCategory::Graph,
368            pixelflow::ErrorCode::new("graph.missing_output"),
369            "graph does not have a final output",
370        )
371    })?;
372    let node = graph.node(output.node_id()).ok_or_else(|| {
373        pixelflow::PixelFlowError::new(
374            pixelflow::ErrorCategory::Graph,
375            pixelflow::ErrorCode::new("graph.invalid_clip"),
376            "final output node is missing",
377        )
378    })?;
379    let pixelflow::ClipFormat::Fixed(format) = node.media().format() else {
380        return Err(unsupported_output("final output has variable format"));
381    };
382    let pixelflow::ClipResolution::Fixed { width, height } = node.media().resolution() else {
383        return Err(unsupported_output("final output has variable resolution"));
384    };
385    let pixelflow::FrameCount::Finite(frame_count) = node.media().frame_count() else {
386        return Err(unsupported_output("final output has unknown frame count"));
387    };
388    let pixelflow::FrameRate::Cfr(frame_rate) = node.media().frame_rate() else {
389        return Err(unsupported_output("final output has unknown frame rate"));
390    };
391
392    let kind = if pixelflow::is_y4m_compatible_format(format) {
393        OutputFormatKind::Y4m
394    } else if pixelflow::is_rawvideo_rgb_compatible_format(format) {
395        OutputFormatKind::RawVideoRgb
396    } else {
397        return Err(unsupported_output(format!(
398            "final output format '{}' is not supported by the CLI output path; convert explicitly to integer YUV, integer Gray, or planar RGB",
399            format.name()
400        )));
401    };
402
403    Ok(OutputMedia {
404        kind,
405        format: format.clone(),
406        width: *width,
407        height: *height,
408        frame_count,
409        frame_rate,
410    })
411}
412
413fn unsupported_output(message: impl Into<String>) -> pixelflow::PixelFlowError {
414    pixelflow::PixelFlowError::new(
415        pixelflow::ErrorCategory::Format,
416        pixelflow::ErrorCode::new("format.unsupported_output"),
417        message,
418    )
419}
420
421enum OutputWriter<W: Write> {
422    Y4m(pixelflow::Y4mWriter<W>),
423    RawVideo(pixelflow::RawVideoWriter<W>),
424}
425
426impl<W: Write> OutputWriter<W> {
427    fn new(output: W, media: &OutputMedia) -> pixelflow::Result<Self> {
428        match media.kind {
429            OutputFormatKind::Y4m => Ok(Self::Y4m(pixelflow::Y4mWriter::new(
430                output,
431                media.format.clone(),
432                media.width,
433                media.height,
434                media.frame_rate,
435            )?)),
436            OutputFormatKind::RawVideoRgb => Ok(Self::RawVideo(pixelflow::RawVideoWriter::new(
437                output,
438                media.format.clone(),
439                media.width,
440                media.height,
441            )?)),
442        }
443    }
444
445    fn write_frame(&mut self, frame: &pixelflow::Frame) -> pixelflow::Result<()> {
446        match self {
447            Self::Y4m(writer) => writer.write_frame(frame),
448            Self::RawVideo(writer) => writer.write_frame(frame),
449        }
450    }
451
452    fn flush(self) -> pixelflow::Result<()> {
453        match self {
454            Self::Y4m(writer) => flush_output(writer.into_inner(), write_y4m_error),
455            Self::RawVideo(writer) => flush_output(writer.into_inner(), write_rawvideo_error),
456        }
457    }
458}
459
460fn render_output<W: Write, E: Write>(
461    core: &pixelflow::Core,
462    graph: &pixelflow::Graph,
463    executors: pixelflow::RenderExecutorMap,
464    options: pixelflow::RenderOptions,
465    output: W,
466    diagnostics: &mut E,
467    timings: bool,
468    show_progress: bool,
469) -> pixelflow::Result<()> {
470    let media = output_media(graph)?;
471    let range = options.validate(media.frame_count)?;
472    let mut render = core.render_ordered(graph.clone(), executors, options)?;
473    let mut writer = OutputWriter::new(output, &media)?;
474    let started = std::time::Instant::now();
475    let total = range.len();
476    let progress = render_progress_bar(total, show_progress);
477
478    for (index, frame) in render.by_ref().enumerate() {
479        let frame = frame?;
480        writer.write_frame(&frame)?;
481        update_render_progress(&progress, index + 1, total, started.elapsed());
482    }
483
484    progress.finish_and_clear();
485
486    if timings {
487        write_timing_report(diagnostics, graph, &render.timing_report())?;
488    }
489
490    writer.flush()
491}
492
493fn render_progress_bar(total: usize, enabled: bool) -> ProgressBar {
494    if !enabled {
495        return ProgressBar::hidden();
496    }
497
498    let bar = ProgressBar::new(progress_units(total));
499    bar.set_style(render_progress_style());
500    bar.set_prefix("rendering");
501    bar
502}
503
504fn update_render_progress(
505    bar: &ProgressBar,
506    rendered: usize,
507    total: usize,
508    elapsed: std::time::Duration,
509) {
510    let seconds = elapsed.as_secs_f64().max(0.001);
511    let fps = rendered as f64 / seconds;
512    let remaining = total.saturating_sub(rendered);
513    let eta = if fps > 0.0 {
514        remaining as f64 / fps
515    } else {
516        0.0
517    };
518
519    bar.set_message(format!("{fps:.2} fps, ETA {eta:.1}s"));
520    bar.set_position(progress_units(rendered));
521}
522
523fn render_progress_style() -> ProgressStyle {
524    ProgressStyle::with_template("{prefix:.bold} [{wide_bar:.cyan/blue}] {pos}/{len} frames {msg}")
525        .unwrap_or_else(|_| ProgressStyle::default_bar())
526        .progress_chars("=> ")
527}
528
529fn write_timing_report<W: Write>(
530    writer: &mut W,
531    graph: &pixelflow::Graph,
532    report: &pixelflow::TimingReport,
533) -> pixelflow::Result<()> {
534    writeln!(writer, "timings:").map_err(|error| stderr_error(&error))?;
535    for (node_id, timing) in report.iter() {
536        let Some(node) = graph.node(node_id) else {
537            continue;
538        };
539        let pixelflow::NodeKind::Filter { name, .. } = node.kind() else {
540            continue;
541        };
542        writeln!(
543            writer,
544            "  {name}#{}: {} frames, {:.3} ms total",
545            node_id.index(),
546            timing.frames(),
547            timing.total().as_secs_f64() * 1000.0
548        )
549        .map_err(|error| stderr_error(&error))?;
550    }
551    Ok(())
552}
553
554fn write_y4m_error(error: &std::io::Error) -> pixelflow::PixelFlowError {
555    pixelflow::PixelFlowError::new(
556        pixelflow::ErrorCategory::Io,
557        pixelflow::ErrorCode::new("io.write_y4m"),
558        format!("failed to write Y4M output: {error}"),
559    )
560}
561
562fn write_rawvideo_error(error: &std::io::Error) -> pixelflow::PixelFlowError {
563    pixelflow::PixelFlowError::new(
564        pixelflow::ErrorCategory::Io,
565        pixelflow::ErrorCode::new("io.write_rawvideo"),
566        format!("failed to write rawvideo output: {error}"),
567    )
568}
569
570fn flush_output<W: Write>(
571    mut output: W,
572    map_error: fn(&std::io::Error) -> pixelflow::PixelFlowError,
573) -> pixelflow::Result<()> {
574    output.flush().map_err(|error| map_error(&error))
575}
576
577fn stderr_error(error: &std::io::Error) -> pixelflow::PixelFlowError {
578    pixelflow::PixelFlowError::new(
579        pixelflow::ErrorCategory::Io,
580        pixelflow::ErrorCode::new("io.write_stderr"),
581        format!("failed to write diagnostic output: {error}"),
582    )
583}
584
585fn ensure_reachable_executors(
586    graph: &pixelflow::Graph,
587    executors: &pixelflow::RenderExecutorMap,
588) -> pixelflow::Result<()> {
589    for node_id in graph.validation_plan()?.reachable_nodes() {
590        if !executors.contains(*node_id) {
591            return Err(pixelflow::PixelFlowError::new(
592                pixelflow::ErrorCategory::Core,
593                pixelflow::ErrorCode::new("render.missing_executor"),
594                format!("render executor for node {} is missing", node_id.index()),
595            ));
596        }
597    }
598    Ok(())
599}
600
601fn prepare_graph_for_render(
602    graph: &pixelflow::Graph,
603    metadata_schema: &pixelflow::MetadataSchema,
604    executors: &mut pixelflow::RenderExecutorMap,
605) -> pixelflow::Result<()> {
606    graph.validate()?;
607    pixelflow_filters::build_builtin_executors(graph, metadata_schema, executors)?;
608    ensure_reachable_executors(graph, executors)
609}
610
611fn resolve_prop_from_prepared_graph(
612    graph: pixelflow::Graph,
613    mut executors: pixelflow::RenderExecutorMap,
614    metadata_schema: &pixelflow::MetadataSchema,
615    worker_threads: usize,
616    frame_number: usize,
617    key: &str,
618) -> pixelflow::Result<pixelflow::MetadataValue> {
619    prepare_graph_for_render(&graph, metadata_schema, &mut executors)?;
620
621    let end = frame_number.checked_add(1).ok_or_else(|| {
622        pixelflow::PixelFlowError::new(
623            pixelflow::ErrorCategory::Script,
624            pixelflow::ErrorCode::new("script.invalid_argument"),
625            "prop frame number is too large",
626        )
627    })?;
628    let options = pixelflow::RenderOptions::new(frame_number, Some(end));
629    let mut render = pixelflow::RenderEngine::new(pixelflow::WorkerPoolConfig::new(worker_threads))
630        .render_ordered(graph, executors, options)?;
631
632    let frame = render.next().ok_or_else(|| {
633        pixelflow::PixelFlowError::new(
634            pixelflow::ErrorCategory::Core,
635            pixelflow::ErrorCode::new("render.frame_out_of_range"),
636            "prop frame number is outside clip frame range",
637        )
638    })??;
639
640    Ok(frame
641        .metadata()
642        .get(key)
643        .cloned()
644        .unwrap_or(pixelflow::MetadataValue::None))
645}
646
647#[derive(Clone)]
648struct RuntimePropResolver {
649    script_dir: Option<PathBuf>,
650    logger: pixelflow::Logger,
651    worker_threads: usize,
652}
653
654impl RuntimePropResolver {
655    const fn new(
656        script_dir: Option<PathBuf>,
657        logger: pixelflow::Logger,
658        worker_threads: usize,
659    ) -> Self {
660        Self {
661            script_dir,
662            logger,
663            worker_threads,
664        }
665    }
666}
667
668impl pixelflow_script::ScriptPropResolver for RuntimePropResolver {
669    fn resolve_prop(
670        &self,
671        graph: pixelflow::Graph,
672        metadata_schema: pixelflow::MetadataSchema,
673        frame_number: usize,
674        key: &str,
675    ) -> pixelflow::Result<pixelflow::MetadataValue> {
676        let mut progress = pixelflow_filters::ffms2_source::NoopIndexProgressSink;
677        let indexed = pixelflow_filters::ffms2_source::index_reachable_sources(
678            graph,
679            &pixelflow_filters::ffms2_source::SourceIndexContext::new(
680                self.script_dir.as_deref(),
681                self.logger.clone(),
682            ),
683            &mut progress,
684        )?;
685        let (graph, executors) = indexed.into_graph_and_executors();
686        resolve_prop_from_prepared_graph(
687            graph,
688            executors,
689            &metadata_schema,
690            self.worker_threads,
691            frame_number,
692            key,
693        )
694    }
695}