1#![forbid(unsafe_code)]
2
3#[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 Version {
52 #[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}