1#![warn(missing_docs)]
2#![forbid(unsafe_code)]
3
4use std::cell::RefCell;
7use std::collections::{BTreeMap, BTreeSet};
8use std::rc::Rc;
9use std::sync::Arc;
10
11use semisafe::slice::get as semisafe_get;
12
13use pixelflow_core::{
14 Clip, ClipFormat, ClipMedia, ClipResolution, ErrorCategory, ErrorCode, FilterOptionValue,
15 FilterOptions, FilterRegistry, FrameCount, Graph, GraphBuilder, Logger, MetadataKind,
16 MetadataSchema, MetadataValue, NodeId, PixelFlowError, Rational, Result, SourceOptionValue,
17 SourceRequest,
18};
19use rhai::{Array, Dynamic, Engine, EvalAltResult, Map, ParseError, Position, Scope};
20
21#[derive(Clone, Debug, PartialEq)]
23pub enum ScriptValue {
24 String(String),
26 Bool(bool),
28 Int(i64),
30 Float(f64),
32 Rational(Rational),
34}
35
36#[derive(Clone, Debug, PartialEq)]
38pub struct ScriptParameter {
39 name: String,
40 value: ScriptValue,
41}
42
43impl ScriptParameter {
44 pub fn parse_set(argument: &str) -> Result<Self> {
46 let Some((name, raw_value)) = argument.split_once('=') else {
47 return invalid_parameter("script parameter must use name=value syntax");
48 };
49
50 if !is_script_identifier(name) {
51 return invalid_parameter(format!("invalid script parameter name '{name}'"));
52 }
53
54 Ok(Self {
55 name: name.to_owned(),
56 value: parse_script_value(raw_value)?,
57 })
58 }
59
60 #[must_use]
62 pub fn name(&self) -> &str {
63 &self.name
64 }
65
66 #[must_use]
68 pub const fn value(&self) -> &ScriptValue {
69 &self.value
70 }
71}
72
73#[derive(Clone, Default)]
75pub struct ScriptEngine {
76 logger: Logger,
77 filters: FilterRegistry,
78 prop_resolver: Option<Arc<dyn ScriptPropResolver>>,
79}
80
81pub trait ScriptPropResolver: Send + Sync {
83 fn resolve_prop(
85 &self,
86 graph: Graph,
87 metadata_schema: MetadataSchema,
88 frame_number: usize,
89 key: &str,
90 ) -> Result<MetadataValue>;
91}
92
93impl ScriptEngine {
94 #[must_use]
96 pub fn new() -> Self {
97 Self::default()
98 }
99
100 #[must_use]
102 pub fn with_logger(logger: Logger) -> Self {
103 Self::with_logger_and_filter_registry(logger, FilterRegistry::new())
104 }
105
106 #[must_use]
108 pub fn with_filter_registry(filters: FilterRegistry) -> Self {
109 Self::with_logger_and_filter_registry(Logger::default(), filters)
110 }
111
112 #[must_use]
114 pub const fn with_logger_and_filter_registry(logger: Logger, filters: FilterRegistry) -> Self {
115 Self {
116 logger,
117 filters,
118 prop_resolver: None,
119 }
120 }
121
122 #[must_use]
124 pub fn with_filters(mut self, filters: FilterRegistry) -> Self {
125 self.filters = filters;
126 self
127 }
128
129 #[must_use]
131 pub fn with_prop_resolver(mut self, resolver: Arc<dyn ScriptPropResolver>) -> Self {
132 self.prop_resolver = Some(resolver);
133 self
134 }
135
136 pub fn evaluate(&self, source: &str, parameters: &[ScriptParameter]) -> Result<ScriptGraph> {
138 evaluate_script(
139 &self.logger,
140 &self.filters,
141 self.prop_resolver.clone(),
142 source,
143 parameters,
144 )
145 }
146}
147
148#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
149struct PropCacheKey {
150 node_id: NodeId,
151 frame_number: usize,
152 key: String,
153}
154
155#[derive(Clone, Default)]
156struct ScriptGraphState {
157 builder: GraphBuilder,
158 media: Vec<ClipMedia>,
159 filters: FilterRegistry,
160 prop_resolver: Option<Arc<dyn ScriptPropResolver>>,
161 prop_cache: BTreeMap<PropCacheKey, MetadataValue>,
162}
163
164#[derive(Clone, Debug, Eq, PartialEq)]
165struct ScriptBlob {
166 bytes: Arc<[u8]>,
167}
168
169impl ScriptBlob {
170 fn into_arc_bytes(self) -> Arc<[u8]> {
171 self.bytes
172 }
173}
174
175impl ScriptGraphState {
176 fn media_for(&self, clip: Clip) -> Result<ClipMedia> {
177 self.media
178 .get(clip.node_id().index())
179 .cloned()
180 .ok_or_else(|| {
181 PixelFlowError::new(
182 ErrorCategory::Graph,
183 ErrorCode::new("graph.invalid_clip"),
184 format!("clip references missing node {}", clip.node_id().index()),
185 )
186 })
187 }
188}
189
190#[derive(Clone, Debug, PartialEq)]
192pub struct ScriptGraph {
193 graph: Graph,
194 metadata_schema: MetadataSchema,
195}
196
197impl ScriptGraph {
198 #[must_use]
200 pub const fn graph(&self) -> &Graph {
201 &self.graph
202 }
203
204 #[must_use]
206 pub const fn metadata_schema(&self) -> &MetadataSchema {
207 &self.metadata_schema
208 }
209
210 #[must_use]
212 pub fn into_graph(self) -> Graph {
213 self.graph
214 }
215
216 #[must_use]
218 pub fn into_parts(self) -> (Graph, MetadataSchema) {
219 (self.graph, self.metadata_schema)
220 }
221}
222
223fn evaluate_script(
224 logger: &Logger,
225 filters: &FilterRegistry,
226 prop_resolver: Option<Arc<dyn ScriptPropResolver>>,
227 source: &str,
228 parameters: &[ScriptParameter],
229) -> Result<ScriptGraph> {
230 if source.trim().is_empty() {
231 return Err(PixelFlowError::new(
232 ErrorCategory::Script,
233 ErrorCode::new("script.empty"),
234 "script source is empty",
235 ));
236 }
237
238 let state = Rc::new(RefCell::new(ScriptGraphState {
239 filters: filters.clone(),
240 prop_resolver,
241 ..ScriptGraphState::default()
242 }));
243 let engine = build_engine(state.clone());
244 let mut scope = Scope::new();
245 let source = normalize_source(source);
246 let source = rewrite_filter_syntax(&source, filters)?;
247
248 if count_output_assignments(&source) > 1 {
249 return Err(PixelFlowError::new(
250 ErrorCategory::Graph,
251 ErrorCode::new("graph.multiple_outputs"),
252 "script assigns final output more than once",
253 ));
254 }
255
256 push_parameters(&mut scope, parameters);
257 declare_assigned_variables(&mut scope, &source);
258
259 let ast = engine
260 .compile_with_scope(&scope, &source)
261 .map_err(|error| parse_error(&error))?;
262 engine
263 .run_ast_with_scope(&mut scope, &ast)
264 .map_err(|error| eval_error(&error))?;
265
266 let output = scope.get_value::<Clip>("output").ok_or_else(|| {
267 if scope.contains("output") {
268 PixelFlowError::new(
269 ErrorCategory::Graph,
270 ErrorCode::new("graph.invalid_output"),
271 "script final output must be a clip",
272 )
273 } else {
274 PixelFlowError::new(
275 ErrorCategory::Graph,
276 ErrorCode::new("graph.missing_output"),
277 "script does not assign final output",
278 )
279 }
280 })?;
281
282 let (graph, metadata_schema) = {
283 let mut state = state.borrow_mut();
284 state.builder.set_output(output);
285 (
286 state.builder.clone().build(),
287 state.filters.metadata_schema().clone(),
288 )
289 };
290
291 logger.log(
292 pixelflow_core::LogLevel::Debug,
293 "pixelflow_script",
294 "script graph constructed",
295 );
296
297 Ok(ScriptGraph {
298 graph,
299 metadata_schema,
300 })
301}
302
303fn build_engine(state: Rc<RefCell<ScriptGraphState>>) -> Engine {
304 let mut engine = Engine::new_raw();
305 engine.set_max_operations(50_000);
306 engine.set_max_call_levels(32);
307 engine.set_max_variables(256);
308 engine.set_max_functions(64);
309 engine.set_max_modules(0);
310 engine.set_max_string_size(1_048_576);
311 engine.set_max_array_size(4096);
312 engine.set_max_map_size(4096);
313 engine.set_strict_variables(true);
314 engine.set_fail_on_invalid_map_property(true);
315
316 engine.register_type_with_name::<Clip>("Clip");
317 engine.register_type_with_name::<Rational>("Rational");
318 engine.register_type_with_name::<ScriptBlob>("Blob");
319 engine.register_fn("none", || Dynamic::UNIT);
320 engine.register_fn("is_none", |value: Dynamic| value.is_unit());
321 engine.register_fn(
322 "blob",
323 |values: Array| -> std::result::Result<ScriptBlob, Box<EvalAltResult>> {
324 script_blob(values).map_err(to_eval_error)
325 },
326 );
327
328 register_graph_api(&mut engine, state);
329 engine
330}
331
332fn register_graph_api(engine: &mut Engine, state: Rc<RefCell<ScriptGraphState>>) {
333 let register_state = state.clone();
334 engine.register_fn(
335 "register_prop",
336 move |key: &str, kind: &str| -> std::result::Result<(), Box<EvalAltResult>> {
337 register_prop(®ister_state, key, kind).map_err(to_eval_error)
338 },
339 );
340
341 let prop_state = state.clone();
342 engine.register_fn(
343 "prop",
344 move |clip: Clip, key: &str| -> std::result::Result<Dynamic, Box<EvalAltResult>> {
345 resolve_script_prop(&prop_state, clip, 0, key).map_err(to_eval_error)
346 },
347 );
348
349 let prop_state = state.clone();
350 engine.register_fn(
351 "prop",
352 move |clip: Clip,
353 frame_number: i64,
354 key: &str|
355 -> std::result::Result<Dynamic, Box<EvalAltResult>> {
356 let frame_number = usize::try_from(frame_number).map_err(|_| {
357 to_eval_error(invalid_argument_error(
358 "prop frame number must be non-negative".to_owned(),
359 ))
360 })?;
361 resolve_script_prop(&prop_state, clip, frame_number, key).map_err(to_eval_error)
362 },
363 );
364
365 let source_state = state.clone();
366 engine.register_fn(
367 "source",
368 move |path: &str| -> std::result::Result<Clip, Box<EvalAltResult>> {
369 source_from_options(&source_state, path, &Map::new()).map_err(to_eval_error)
370 },
371 );
372
373 let source_state = state.clone();
374 engine.register_fn(
375 "source",
376 move |path: &str, options: Map| -> std::result::Result<Clip, Box<EvalAltResult>> {
377 source_from_options(&source_state, path, &options).map_err(to_eval_error)
378 },
379 );
380
381 let filter_state = state.clone();
382 engine.register_fn(
383 "filter",
384 move |clip: Clip,
385 name: &str,
386 options: Map|
387 -> std::result::Result<Clip, Box<EvalAltResult>> {
388 filter_from_options(&filter_state, clip, name, &options).map_err(to_eval_error)
389 },
390 );
391
392 let filter_state = state.clone();
393 engine.register_fn(
394 "filter",
395 move |clip: Clip, name: &str| -> std::result::Result<Clip, Box<EvalAltResult>> {
396 filter_from_options(&filter_state, clip, name, &Map::new()).map_err(to_eval_error)
397 },
398 );
399
400 let filter_state = state.clone();
401 engine.register_fn(
402 "filter",
403 move |clips: Array,
404 name: &str,
405 options: Map|
406 -> std::result::Result<Clip, Box<EvalAltResult>> {
407 filter_array_from_options(&filter_state, clips, name, &options).map_err(to_eval_error)
408 },
409 );
410
411 engine.register_fn(
412 "filter",
413 move |clips: Array, name: &str| -> std::result::Result<Clip, Box<EvalAltResult>> {
414 filter_array_from_options(&state, clips, name, &Map::new()).map_err(to_eval_error)
415 },
416 );
417}
418
419fn register_prop(state: &Rc<RefCell<ScriptGraphState>>, key: &str, kind: &str) -> Result<()> {
420 state
421 .borrow_mut()
422 .filters
423 .register_metadata_key(key, parse_metadata_kind(kind)?)
424}
425
426fn resolve_script_prop(
427 state: &Rc<RefCell<ScriptGraphState>>,
428 clip: Clip,
429 frame_number: usize,
430 key: &str,
431) -> Result<Dynamic> {
432 let cache_key = PropCacheKey {
433 node_id: clip.node_id(),
434 frame_number,
435 key: key.to_owned(),
436 };
437
438 let (resolver, graph, metadata_schema) = {
439 let state_ref = state.borrow_mut();
440 if let Some(value) = state_ref.prop_cache.get(&cache_key).cloned() {
441 return Ok(metadata_value_to_dynamic(value));
442 }
443
444 if !state_ref.filters.metadata_schema().contains_key(key) {
445 return Err(PixelFlowError::new(
446 ErrorCategory::Plugin,
447 ErrorCode::new("metadata.unregistered_key"),
448 format!("metadata key '{key}' is not registered"),
449 ));
450 }
451
452 let resolver = state_ref.prop_resolver.clone().ok_or_else(|| {
453 PixelFlowError::new(
454 ErrorCategory::Script,
455 ErrorCode::new("script.prop_unavailable"),
456 "script prop retrieval requires a runtime resolver",
457 )
458 })?;
459
460 let mut builder = state_ref.builder.clone();
461 builder.set_output(clip);
462 (
463 resolver,
464 builder.build(),
465 state_ref.filters.metadata_schema().clone(),
466 )
467 };
468
469 let value = resolver.resolve_prop(graph, metadata_schema, frame_number, key)?;
470 state
471 .borrow_mut()
472 .prop_cache
473 .insert(cache_key, value.clone());
474 Ok(metadata_value_to_dynamic(value))
475}
476
477fn metadata_value_to_dynamic(value: MetadataValue) -> Dynamic {
478 match value {
479 MetadataValue::None => Dynamic::UNIT,
480 MetadataValue::Bool(value) => Dynamic::from(value),
481 MetadataValue::Int(value) => Dynamic::from(value),
482 MetadataValue::Float(value) => Dynamic::from(value),
483 MetadataValue::String(value) => Dynamic::from(value),
484 MetadataValue::Array(values) => {
485 Dynamic::from_array(values.into_iter().map(metadata_value_to_dynamic).collect())
486 }
487 MetadataValue::Rational(value) => Dynamic::from(value),
488 MetadataValue::Blob(value) => Dynamic::from(ScriptBlob { bytes: value }),
489 }
490}
491
492fn script_blob(values: Array) -> Result<ScriptBlob> {
493 let mut bytes = Vec::with_capacity(values.len());
494 for (index, value) in values.into_iter().enumerate() {
495 let Ok(byte) = value.as_int() else {
496 return invalid_argument(format!(
497 "blob byte at index {index} must be between 0 and 255"
498 ));
499 };
500 let Ok(byte) = u8::try_from(byte) else {
501 return invalid_argument(format!(
502 "blob byte at index {index} must be between 0 and 255"
503 ));
504 };
505 bytes.push(byte);
506 }
507
508 Ok(ScriptBlob {
509 bytes: bytes.into(),
510 })
511}
512
513fn parse_metadata_kind(kind: &str) -> Result<MetadataKind> {
514 match kind {
515 "bool" => Ok(MetadataKind::Bool),
516 "int" => Ok(MetadataKind::Int),
517 "float" => Ok(MetadataKind::Float),
518 "string" => Ok(MetadataKind::String),
519 "array" => Ok(MetadataKind::Array),
520 "rational" => Ok(MetadataKind::Rational),
521 "blob" => Ok(MetadataKind::Blob),
522 _ => invalid_argument(format!(
523 "metadata kind '{kind}' must be bool, int, float, string, array, rational, or blob"
524 )),
525 }
526}
527
528fn source_from_options(
529 state: &Rc<RefCell<ScriptGraphState>>,
530 path: &str,
531 options: &Map,
532) -> Result<Clip> {
533 let mut request = SourceRequest::new(path);
534 for (name, value) in options {
535 request =
536 request.try_with_option(name.as_str(), source_option_value(name.as_str(), value)?)?;
537 }
538
539 let media = ClipMedia::new(
540 ClipFormat::Fixed(pixelflow_core::resolve_format_alias("yuv420p8")?),
541 ClipResolution::Fixed {
542 width: 1,
543 height: 1,
544 },
545 FrameCount::Unknown,
546 pixelflow_core::FrameRate::Unknown,
547 );
548
549 let mut state = state.borrow_mut();
550 let clip = state.builder.source_with_request(request, media.clone());
551 state.media.push(media);
552 Ok(clip)
553}
554
555fn source_option_value(name: &str, value: &Dynamic) -> Result<SourceOptionValue> {
556 if value.is::<Rational>() {
557 return Ok(SourceOptionValue::Rational(value.clone_cast::<Rational>()));
558 }
559
560 if let Ok(string) = value.as_immutable_string_ref() {
561 if name == "fps" {
562 return parse_argument_rational(string.as_str()).map(SourceOptionValue::Rational);
563 }
564 return Ok(SourceOptionValue::String(string.as_str().to_owned()));
565 }
566
567 if let Ok(boolean) = value.as_bool() {
568 return Ok(SourceOptionValue::Bool(boolean));
569 }
570
571 if let Ok(integer) = value.as_int() {
572 return Ok(SourceOptionValue::Int(integer));
573 }
574
575 invalid_argument(format!(
576 "source option '{name}' must be string, bool, integer, or rational"
577 ))
578}
579
580fn filter_from_options(
581 state: &Rc<RefCell<ScriptGraphState>>,
582 clip: Clip,
583 name: &str,
584 options: &Map,
585) -> Result<Clip> {
586 filter_clips_from_options(state, &[clip], name, options)
587}
588
589fn filter_array_from_options(
590 state: &Rc<RefCell<ScriptGraphState>>,
591 clips: Array,
592 name: &str,
593 options: &Map,
594) -> Result<Clip> {
595 let mut parsed = Vec::with_capacity(clips.len());
596 for (index, value) in clips.into_iter().enumerate() {
597 if !value.is::<Clip>() {
598 return invalid_argument(format!("filter input {index} must be Clip"));
599 }
600 parsed.push(value.clone_cast::<Clip>());
601 }
602
603 filter_clips_from_options(state, &parsed, name, options)
604}
605
606fn filter_clips_from_options(
607 state: &Rc<RefCell<ScriptGraphState>>,
608 clips: &[Clip],
609 name: &str,
610 options: &Map,
611) -> Result<Clip> {
612 if !is_filter_name(name) {
613 return invalid_argument(format!("invalid filter name '{name}'"));
614 }
615
616 let options = filter_options(options)?;
617 let plan = {
618 let state = state.borrow();
619 let input_media: Vec<_> = clips
620 .iter()
621 .map(|clip| state.media_for(*clip))
622 .collect::<Result<_>>()?;
623 state.filters.plan_filter(name, &input_media, &options)?
624 };
625
626 let (media, compatibility, dependencies, concurrency) = plan.into_parts();
627 let mut state = state.borrow_mut();
628 let output = state.builder.filter_with_schedule_and_options(
629 name,
630 clips,
631 media.clone(),
632 compatibility,
633 dependencies,
634 concurrency,
635 options,
636 )?;
637 state.media.push(media);
638 Ok(output)
639}
640
641fn filter_options(options: &Map) -> Result<FilterOptions> {
642 let mut converted = FilterOptions::new();
643 for (name, value) in options {
644 if !is_script_identifier(name.as_str()) {
645 return invalid_argument(format!("invalid filter option name '{name}'"));
646 }
647 converted.insert(name.to_string(), filter_option_value(name.as_str(), value)?);
648 }
649 Ok(converted)
650}
651
652fn filter_option_value(name: &str, value: &Dynamic) -> Result<FilterOptionValue> {
653 if value.is_unit() {
654 return Ok(FilterOptionValue::None);
655 }
656 if value.is::<ScriptBlob>() {
657 return Ok(FilterOptionValue::Blob(
658 value.clone_cast::<ScriptBlob>().into_arc_bytes(),
659 ));
660 }
661 if value.is::<Array>() {
662 let values = value.clone_cast::<Array>();
663 let mut converted = Vec::with_capacity(values.len());
664 for entry in values {
665 converted.push(filter_option_value(name, &entry)?);
666 }
667 return Ok(FilterOptionValue::Array(converted));
668 }
669 if value.is::<Rational>() {
670 return Ok(FilterOptionValue::Rational(value.clone_cast::<Rational>()));
671 }
672 if let Ok(string) = value.as_immutable_string_ref() {
673 return Ok(FilterOptionValue::String(string.as_str().to_owned()));
674 }
675 if let Ok(boolean) = value.as_bool() {
676 return Ok(FilterOptionValue::Bool(boolean));
677 }
678 if let Ok(integer) = value.as_int() {
679 return Ok(FilterOptionValue::Int(integer));
680 }
681 if let Ok(float) = value.as_float() {
682 if float.is_finite() {
683 return Ok(FilterOptionValue::Float(float));
684 }
685 return invalid_argument(format!("filter option '{name}' float must be finite"));
686 }
687
688 invalid_argument(format!(
689 "filter option '{name}' must be none, string, bool, integer, float, array, rational, or blob"
690 ))
691}
692
693fn push_parameters(scope: &mut Scope<'_>, parameters: &[ScriptParameter]) {
694 for parameter in parameters {
695 match parameter.value() {
696 ScriptValue::String(value) => {
697 scope.push(parameter.name(), value.clone());
698 }
699 ScriptValue::Bool(value) => {
700 scope.push(parameter.name(), *value);
701 }
702 ScriptValue::Int(value) => {
703 scope.push(parameter.name(), *value);
704 }
705 ScriptValue::Float(value) => {
706 scope.push(parameter.name(), *value);
707 }
708 ScriptValue::Rational(value) => {
709 scope.push(parameter.name(), *value);
710 }
711 }
712 }
713}
714
715fn declare_assigned_variables(scope: &mut Scope<'_>, source: &str) {
716 for line in source.lines() {
717 let trimmed = line.trim_start();
718 let Some((name, _)) = trimmed.split_once('=') else {
719 continue;
720 };
721
722 let name = name.trim();
723 if is_script_identifier(name) && !scope.contains(name) {
724 scope.push_dynamic(name, Dynamic::UNIT);
725 }
726 }
727}
728
729fn normalize_source(source: &str) -> String {
730 let mut normalized = String::new();
731 let mut nesting = 0usize;
732
733 for line in source.lines() {
734 let trimmed = line.trim();
735 if trimmed.is_empty() {
736 continue;
737 }
738
739 if normalized.is_empty() {
740 normalized.push_str(trimmed);
741 update_nesting(&mut nesting, trimmed);
742 continue;
743 }
744
745 if trimmed.starts_with('.') || nesting > 0 {
746 normalized.push(' ');
747 normalized.push_str(trimmed);
748 } else {
749 if !normalized.ends_with(';') {
750 normalized.push(';');
751 }
752 normalized.push('\n');
753 normalized.push_str(trimmed);
754 }
755
756 update_nesting(&mut nesting, trimmed);
757 }
758
759 if !normalized.is_empty() && !normalized.ends_with(';') {
760 normalized.push(';');
761 }
762
763 normalized
764}
765
766#[derive(Clone, Copy, Debug, Default)]
767struct SourceScanState {
768 in_string: bool,
769 escaped: bool,
770 line_comment: bool,
771 block_comment_depth: usize,
772 nesting: usize,
773}
774
775fn rewrite_filter_syntax(source: &str, filters: &FilterRegistry) -> Result<String> {
776 let mut known_clip_variables = BTreeSet::new();
777 let mut rewritten = String::with_capacity(source.len());
778 let mut state = SourceScanState::default();
779 let mut index = 0usize;
780 let mut statement_start = 0usize;
781
782 while index < source.len() {
783 if is_code_position(state)
784 && state.nesting == 0
785 && *semisafe_get(source.as_bytes(), index) == b';'
786 {
787 rewritten.push_str(&rewrite_filter_syntax_in_statement(
788 slice_range(source, statement_start, index),
789 &mut known_clip_variables,
790 filters,
791 )?);
792 rewritten.push(';');
793 statement_start = index + 1;
794 index += 1;
795 continue;
796 }
797
798 index = advance_scan_state(source, index, &mut state);
799 }
800
801 rewritten.push_str(&rewrite_filter_syntax_in_statement(
802 slice_from(source, statement_start),
803 &mut known_clip_variables,
804 filters,
805 )?);
806 Ok(rewritten)
807}
808
809fn rewrite_filter_syntax_in_statement(
810 statement: &str,
811 known_clip_variables: &mut BTreeSet<String>,
812 filters: &FilterRegistry,
813) -> Result<String> {
814 let Some((name_start, name_end, rhs_start)) = top_level_assignment_at(statement) else {
815 return rewrite_namespaced_filter_functions(statement, filters);
816 };
817
818 let name = slice_range(statement, name_start, name_end);
819 let rhs = rewrite_namespaced_filter_functions(slice_from(statement, rhs_start), filters)?;
820
821 let Some(rewritten_rhs) = rewrite_clip_chain_rhs(&rhs, known_clip_variables, filters)? else {
822 known_clip_variables.remove(name);
823 let mut rewritten = String::with_capacity(statement.len());
824 rewritten.push_str(slice_range(statement, 0, rhs_start));
825 rewritten.push_str(&rhs);
826 return Ok(rewritten);
827 };
828
829 known_clip_variables.insert(name.to_owned());
830
831 let mut rewritten = String::with_capacity(statement.len());
832 rewritten.push_str(slice_range(statement, 0, rhs_start));
833 rewritten.push_str(&rewritten_rhs);
834 Ok(rewritten)
835}
836
837fn top_level_assignment_at(source: &str) -> Option<(usize, usize, usize)> {
838 let name_start = skip_trivia(source, 0);
839 let name_end = identifier_end_at(source, name_start)?;
840 let operator_index = skip_trivia(source, name_end);
841 let bytes = source.as_bytes();
842 if !starts_with_token(bytes, operator_index, b"=")
843 || starts_with_token(bytes, operator_index, b"==")
844 {
845 return None;
846 }
847
848 Some((name_start, name_end, operator_index + 1))
849}
850
851fn rewrite_clip_chain_rhs(
852 rhs: &str,
853 known_clip_variables: &BTreeSet<String>,
854 filters: &FilterRegistry,
855) -> Result<Option<String>> {
856 let Some(mut chain_end) = clip_root_end(rhs, skip_trivia(rhs, 0), known_clip_variables) else {
857 return Ok(None);
858 };
859 let mut rewritten = String::with_capacity(rhs.len());
860 let mut segment_start = 0usize;
861
862 loop {
863 let method_start = skip_trivia(rhs, chain_end);
864 if !starts_with_token(rhs.as_bytes(), method_start, b".") {
865 break;
866 }
867
868 let Some(method_call) = method_call_at(rhs, method_start) else {
869 break;
870 };
871
872 if method_call.segments.as_slice() == ["prop"] {
873 break;
874 }
875
876 if method_call.segments.as_slice() != ["filter"] {
877 let filter_name = resolve_method_filter_name(filters, &method_call.segments)?;
878 rewritten.push_str(slice_range(rhs, segment_start, method_start));
879 rewritten.push_str(".filter(\"");
880 rewritten.push_str(filter_name);
881 rewritten.push('"');
882 if method_call.has_arguments {
883 rewritten.push_str(", ");
884 }
885 segment_start = method_call.after_open_paren;
886 }
887
888 chain_end = method_call.end;
889 }
890
891 rewritten.push_str(slice_from(rhs, segment_start));
892 Ok(Some(rewritten))
893}
894
895fn clip_root_end(
896 source: &str,
897 index: usize,
898 known_clip_variables: &BTreeSet<String>,
899) -> Option<usize> {
900 let name_end = identifier_end_at(source, index)?;
901 let name = slice_range(source, index, name_end);
902 if name == "source" || name == "filter" {
903 return call_expression_end(source, name_end);
904 }
905 if known_clip_variables.contains(name) {
906 return Some(name_end);
907 }
908
909 None
910}
911
912fn identifier_end_at(source: &str, index: usize) -> Option<usize> {
913 let bytes = source.as_bytes();
914 let first = *bytes.get(index)?;
915 if !(first.is_ascii_alphabetic() || first == b'_') {
916 return None;
917 }
918
919 let mut name_end = index + 1;
920 while let Some(byte) = bytes.get(name_end) {
921 if byte.is_ascii_alphanumeric() || *byte == b'_' {
922 name_end += 1;
923 } else {
924 break;
925 }
926 }
927
928 Some(name_end)
929}
930
931fn call_expression_end(source: &str, name_end: usize) -> Option<usize> {
932 let open_paren = skip_whitespace(source, name_end);
933 if !starts_with_token(source.as_bytes(), open_paren, b"(") {
934 return None;
935 }
936
937 matching_paren_end(source, open_paren)
938}
939
940struct MethodCall<'a> {
941 after_open_paren: usize,
942 end: usize,
943 segments: Vec<&'a str>,
944 has_arguments: bool,
945}
946
947fn method_call_at(source: &str, index: usize) -> Option<MethodCall<'_>> {
948 let call = call_path_at(source, index + 1)?;
949
950 Some(MethodCall {
951 after_open_paren: call.after_open_paren,
952 end: call.end,
953 segments: call.segments,
954 has_arguments: call.has_arguments,
955 })
956}
957
958struct CallPath<'a> {
959 after_open_paren: usize,
960 close_paren: usize,
961 end: usize,
962 segments: Vec<&'a str>,
963 has_arguments: bool,
964}
965
966fn call_path_at(source: &str, start: usize) -> Option<CallPath<'_>> {
967 let bytes = source.as_bytes();
968 let mut cursor = start;
969 let mut segments = Vec::new();
970
971 loop {
972 let segment_end = identifier_end_at(source, cursor)?;
973 segments.push(slice_range(source, cursor, segment_end));
974
975 let next = skip_whitespace(source, segment_end);
976 if starts_with_token(bytes, next, b".") {
977 cursor = skip_whitespace(source, next + 1);
978 continue;
979 }
980 if !starts_with_token(bytes, next, b"(") {
981 return None;
982 }
983
984 let end = matching_paren_end(source, next)?;
985 let after_open_paren = next + 1;
986 let has_arguments = !starts_with_token(bytes, skip_trivia(source, after_open_paren), b")");
987 return Some(CallPath {
988 after_open_paren,
989 close_paren: end.saturating_sub(1),
990 end,
991 segments,
992 has_arguments,
993 });
994 }
995}
996
997fn namespaced_filter_call_at(source: &str, index: usize) -> Option<CallPath<'_>> {
998 if !can_start_namespaced_call(source, index) {
999 return None;
1000 }
1001
1002 let call = call_path_at(source, index)?;
1003 match call.segments.as_slice() {
1004 ["std", _] => Some(call),
1005 ["plugin", _, rest @ ..] if !rest.is_empty() => Some(call),
1006 _ => None,
1007 }
1008}
1009
1010fn can_start_namespaced_call(source: &str, index: usize) -> bool {
1011 if index == 0 {
1012 return true;
1013 }
1014
1015 !matches!(
1016 source.as_bytes().get(index - 1),
1017 Some(byte) if byte.is_ascii_alphanumeric() || *byte == b'_' || *byte == b'.'
1018 )
1019}
1020
1021fn rewrite_namespaced_filter_functions(source: &str, filters: &FilterRegistry) -> Result<String> {
1022 let mut rewritten = String::with_capacity(source.len());
1023 let mut state = SourceScanState::default();
1024 let mut index = 0usize;
1025 let mut segment_start = 0usize;
1026
1027 while index < source.len() {
1028 if is_code_position(state)
1029 && let Some(call) = namespaced_filter_call_at(source, index)
1030 {
1031 let filter_name = resolve_function_filter_name(filters, &call.segments)?;
1032 let Some(first_arg_end) =
1033 first_argument_end(source, call.after_open_paren, call.close_paren)
1034 else {
1035 return invalid_argument(format!(
1036 "namespaced filter call '{}' requires an input clip or clip array as first argument",
1037 call.segments.join(".")
1038 ));
1039 };
1040
1041 rewritten.push_str(slice_range(source, segment_start, index));
1042 rewritten.push_str("filter(");
1043 rewritten.push_str(slice_range(source, call.after_open_paren, first_arg_end).trim());
1044 rewritten.push_str(", \"");
1045 rewritten.push_str(filter_name);
1046 rewritten.push('"');
1047
1048 let rest = slice_range(source, first_arg_end, call.close_paren).trim_start();
1049 if let Some(rest) = rest.strip_prefix(',') {
1050 rewritten.push_str(", ");
1051 rewritten.push_str(rest.trim_start());
1052 }
1053 rewritten.push(')');
1054 segment_start = call.end;
1055 index = call.end;
1056 continue;
1057 }
1058
1059 index = advance_scan_state(source, index, &mut state);
1060 }
1061
1062 rewritten.push_str(slice_from(source, segment_start));
1063 Ok(rewritten)
1064}
1065
1066fn resolve_function_filter_name<'a>(
1067 filters: &'a FilterRegistry,
1068 segments: &[&'a str],
1069) -> Result<&'a str> {
1070 match segments {
1071 ["std", name] => filters.resolve_filter_name_for_plugin_call("std", name),
1072 ["plugin", plugin, rest @ ..] if !rest.is_empty() => {
1073 let name = rest.join(".");
1074 filters.resolve_filter_name_for_plugin_call(plugin, &name)
1075 }
1076 _ => invalid_argument(format!(
1077 "invalid filter namespace '{}'; use std.<filter>(...) or plugin.<namespace>.<filter>(...)",
1078 segments.join(".")
1079 )),
1080 }
1081}
1082
1083fn resolve_method_filter_name<'a>(
1084 filters: &'a FilterRegistry,
1085 segments: &[&'a str],
1086) -> Result<&'a str> {
1087 match segments {
1088 [name] => Ok(*name),
1089 ["std", name] => filters.resolve_filter_name_for_plugin_call("std", name),
1090 ["plugin", plugin, rest @ ..] if !rest.is_empty() => {
1091 let name = rest.join(".");
1092 filters.resolve_filter_name_for_plugin_call(plugin, &name)
1093 }
1094 _ => invalid_argument(format!(
1095 "invalid filter method namespace '{}'; use .filter(...), .std.<filter>(...), or .plugin.<namespace>.<filter>(...)",
1096 segments.join(".")
1097 )),
1098 }
1099}
1100
1101fn first_argument_end(source: &str, start: usize, close_paren: usize) -> Option<usize> {
1102 let mut index = skip_trivia(source, start);
1103 if index >= close_paren {
1104 return None;
1105 }
1106
1107 let mut state = SourceScanState::default();
1108 while index < close_paren {
1109 if is_code_position(state)
1110 && state.nesting == 0
1111 && *semisafe_get(source.as_bytes(), index) == b','
1112 {
1113 return Some(index);
1114 }
1115
1116 index = advance_scan_state(source, index, &mut state);
1117 }
1118
1119 Some(close_paren)
1120}
1121
1122fn matching_paren_end(source: &str, open_paren: usize) -> Option<usize> {
1123 let mut state = SourceScanState::default();
1124 let mut index = open_paren;
1125
1126 while index < source.len() {
1127 index = advance_scan_state(source, index, &mut state);
1128 if state.nesting == 0 {
1129 return Some(index);
1130 }
1131 }
1132
1133 None
1134}
1135
1136fn advance_scan_state(source: &str, index: usize, state: &mut SourceScanState) -> usize {
1137 let bytes = source.as_bytes();
1138 let current_byte = *semisafe_get(bytes, index);
1139 let step = next_char_len(source, index);
1140
1141 if state.line_comment {
1142 if current_byte == b'\n' {
1143 state.line_comment = false;
1144 }
1145 return index + step;
1146 }
1147
1148 if state.block_comment_depth > 0 {
1149 if starts_with_token(bytes, index, b"/*") {
1150 state.block_comment_depth += 1;
1151 return index + 2;
1152 }
1153 if starts_with_token(bytes, index, b"*/") {
1154 state.block_comment_depth -= 1;
1155 return index + 2;
1156 }
1157 return index + step;
1158 }
1159
1160 if state.in_string {
1161 if state.escaped {
1162 state.escaped = false;
1163 } else if current_byte == b'\\' {
1164 state.escaped = true;
1165 } else if current_byte == b'"' {
1166 state.in_string = false;
1167 }
1168 return index + step;
1169 }
1170
1171 if starts_with_token(bytes, index, b"//") {
1172 state.line_comment = true;
1173 return index + 2;
1174 }
1175 if starts_with_token(bytes, index, b"/*") {
1176 state.block_comment_depth = 1;
1177 return index + 2;
1178 }
1179
1180 match current_byte {
1181 b'"' => state.in_string = true,
1182 b'(' | b'[' | b'{' => state.nesting += 1,
1183 b')' | b']' | b'}' => state.nesting = state.nesting.saturating_sub(1),
1184 _ => {}
1185 }
1186
1187 index + step
1188}
1189
1190fn skip_trivia(source: &str, mut index: usize) -> usize {
1191 let bytes = source.as_bytes();
1192
1193 while index < source.len() {
1194 if starts_with_token(bytes, index, b"//") {
1195 index += 2;
1196 while index < source.len() && *semisafe_get(bytes, index) != b'\n' {
1197 index += next_char_len(source, index);
1198 }
1199 continue;
1200 }
1201
1202 if starts_with_token(bytes, index, b"/*") {
1203 let mut depth = 1usize;
1204 index += 2;
1205 while index < source.len() && depth > 0 {
1206 if starts_with_token(bytes, index, b"/*") {
1207 depth += 1;
1208 index += 2;
1209 } else if starts_with_token(bytes, index, b"*/") {
1210 depth -= 1;
1211 index += 2;
1212 } else {
1213 index += next_char_len(source, index);
1214 }
1215 }
1216 continue;
1217 }
1218
1219 let ch = slice_from(source, index)
1220 .chars()
1221 .next()
1222 .expect("index should stay on char boundary");
1223 if ch.is_whitespace() {
1224 index += ch.len_utf8();
1225 continue;
1226 }
1227
1228 break;
1229 }
1230
1231 index
1232}
1233
1234fn skip_whitespace(source: &str, mut index: usize) -> usize {
1235 while index < source.len() {
1236 let ch = slice_from(source, index)
1237 .chars()
1238 .next()
1239 .expect("index should stay on char boundary");
1240 if ch.is_whitespace() {
1241 index += ch.len_utf8();
1242 continue;
1243 }
1244
1245 break;
1246 }
1247
1248 index
1249}
1250
1251fn next_char_len(source: &str, index: usize) -> usize {
1252 slice_from(source, index)
1253 .chars()
1254 .next()
1255 .expect("index should stay on char boundary")
1256 .len_utf8()
1257}
1258
1259fn slice_range(source: &str, start: usize, end: usize) -> &str {
1260 source
1261 .get(start..end)
1262 .expect("range should stay on char boundary")
1263}
1264
1265fn slice_from(source: &str, index: usize) -> &str {
1266 source
1267 .get(index..)
1268 .expect("index should stay on char boundary")
1269}
1270
1271fn starts_with_token(bytes: &[u8], index: usize, token: &[u8]) -> bool {
1272 bytes
1273 .get(index..index.saturating_add(token.len()))
1274 .is_some_and(|slice| slice == token)
1275}
1276
1277const fn is_code_position(state: SourceScanState) -> bool {
1278 !state.in_string && !state.line_comment && state.block_comment_depth == 0
1279}
1280
1281fn update_nesting(nesting: &mut usize, line: &str) {
1282 let mut in_string = false;
1283 let mut escaped = false;
1284
1285 for ch in line.chars() {
1286 if in_string {
1287 if escaped {
1288 escaped = false;
1289 continue;
1290 }
1291
1292 match ch {
1293 '\\' => escaped = true,
1294 '"' => in_string = false,
1295 _ => {}
1296 }
1297 continue;
1298 }
1299
1300 match ch {
1301 '"' => in_string = true,
1302 '(' | '[' | '{' => *nesting += 1,
1303 ')' | ']' | '}' => *nesting = nesting.saturating_sub(1),
1304 _ => {}
1305 }
1306 }
1307}
1308
1309fn count_output_assignments(source: &str) -> usize {
1310 let mut count = 0usize;
1311 let mut state = SourceScanState::default();
1312 let mut index = 0usize;
1313 let mut statement_start = true;
1314
1315 while index < source.len() {
1316 if statement_start && is_code_position(state) && state.nesting == 0 {
1317 index = skip_trivia(source, index);
1318 if index >= source.len() {
1319 break;
1320 }
1321 }
1322
1323 if is_code_position(state) {
1324 let byte = *semisafe_get(source.as_bytes(), index);
1325
1326 if state.nesting == 0 && byte == b';' {
1327 statement_start = true;
1328 index += 1;
1329 continue;
1330 }
1331
1332 if state.nesting == 0 && statement_start {
1333 if let Some(next_index) = output_assignment_at(source, index) {
1334 count += 1;
1335 index = next_index;
1336 }
1337 statement_start = false;
1338 continue;
1339 }
1340 }
1341
1342 index = advance_scan_state(source, index, &mut state);
1343 }
1344
1345 count
1346}
1347
1348fn output_assignment_at(source: &str, index: usize) -> Option<usize> {
1349 let bytes = source.as_bytes();
1350 if !starts_with_token(bytes, index, b"output") {
1351 return None;
1352 }
1353
1354 let name_end = index + "output".len();
1355 if bytes
1356 .get(name_end)
1357 .is_some_and(|byte| byte.is_ascii_alphanumeric() || *byte == b'_')
1358 {
1359 return None;
1360 }
1361
1362 let operator_index = skip_trivia(source, name_end);
1363 if !starts_with_token(bytes, operator_index, b"=")
1364 || starts_with_token(bytes, operator_index, b"==")
1365 {
1366 return None;
1367 }
1368
1369 Some(operator_index + 1)
1370}
1371
1372fn parse_script_value(raw: &str) -> Result<ScriptValue> {
1373 if raw == "true" {
1374 return Ok(ScriptValue::Bool(true));
1375 }
1376 if raw == "false" {
1377 return Ok(ScriptValue::Bool(false));
1378 }
1379
1380 if looks_like_rational_literal(raw) {
1381 return parse_parameter_rational(raw).map(ScriptValue::Rational);
1382 }
1383
1384 if let Ok(value) = raw.parse::<i64>() {
1385 return Ok(ScriptValue::Int(value));
1386 }
1387
1388 if raw.contains('.') || raw.contains('e') || raw.contains('E') {
1389 match raw.parse::<f64>() {
1390 Ok(value) if value.is_finite() => return Ok(ScriptValue::Float(value)),
1391 Ok(_) => return invalid_parameter("float parameter must be finite"),
1392 Err(_) => {}
1393 }
1394 }
1395
1396 Ok(ScriptValue::String(raw.to_owned()))
1397}
1398
1399fn parse_parameter_rational(raw: &str) -> Result<Rational> {
1400 parse_rational(raw, invalid_parameter_error)
1401}
1402
1403fn parse_argument_rational(raw: &str) -> Result<Rational> {
1404 parse_rational(raw, invalid_argument_error)
1405}
1406
1407fn parse_rational(raw: &str, make_error: fn(String) -> PixelFlowError) -> Result<Rational> {
1408 let Some((numerator, denominator)) = raw.split_once('/') else {
1409 return Err(make_error(
1410 "rational must use numerator/denominator syntax".to_owned(),
1411 ));
1412 };
1413 let numerator = numerator
1414 .parse::<i64>()
1415 .map_err(|_| make_error("invalid rational numerator".to_owned()))?;
1416 let denominator = denominator
1417 .parse::<i64>()
1418 .map_err(|_| make_error("invalid rational denominator".to_owned()))?;
1419
1420 if denominator == 0 {
1421 return Err(make_error(
1422 "rational denominator must not be zero".to_owned(),
1423 ));
1424 }
1425
1426 Ok(Rational {
1427 numerator,
1428 denominator,
1429 })
1430}
1431
1432fn looks_like_rational_literal(raw: &str) -> bool {
1433 let Some((numerator, denominator)) = raw.split_once('/') else {
1434 return false;
1435 };
1436
1437 numerator.parse::<i64>().is_ok() && denominator.parse::<i64>().is_ok()
1438}
1439
1440fn is_script_identifier(name: &str) -> bool {
1441 let mut bytes = name.bytes();
1442
1443 matches!(bytes.next(), Some(first) if first.is_ascii_alphabetic() || first == b'_')
1444 && bytes.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
1445}
1446
1447fn is_filter_name(name: &str) -> bool {
1448 !name.is_empty() && name.split('.').all(is_script_identifier)
1449}
1450
1451fn parse_error(error: &ParseError) -> PixelFlowError {
1452 PixelFlowError::new(
1453 ErrorCategory::Script,
1454 ErrorCode::new("script.parse"),
1455 format!("{}: {error}", format_position(error.position())),
1456 )
1457}
1458
1459fn eval_error(error: &EvalAltResult) -> PixelFlowError {
1460 if let rhai::EvalAltResult::ErrorSystem(_, inner) = error.unwrap_inner()
1461 && let Some(error) = inner.downcast_ref::<PixelFlowError>()
1462 {
1463 return PixelFlowError::new(error.category(), error.code(), error.message());
1464 }
1465
1466 PixelFlowError::new(
1467 ErrorCategory::Script,
1468 ErrorCode::new("script.eval"),
1469 format!("{}: {error}", format_position(error.position())),
1470 )
1471}
1472
1473fn format_position(position: Position) -> String {
1474 if position.is_none() {
1475 "unknown source position".to_owned()
1476 } else {
1477 format!(
1478 "line {}, position {}",
1479 position.line().unwrap_or(0),
1480 position.position().unwrap_or(0)
1481 )
1482 }
1483}
1484
1485#[expect(
1486 clippy::unnecessary_box_returns,
1487 reason = "Rhai host functions return Box<EvalAltResult>"
1488)]
1489fn to_eval_error(error: PixelFlowError) -> Box<EvalAltResult> {
1490 Box::new(EvalAltResult::ErrorSystem(
1491 error.to_string(),
1492 Box::new(error),
1493 ))
1494}
1495
1496fn invalid_parameter<T>(message: impl Into<String>) -> Result<T> {
1497 Err(invalid_parameter_error(message))
1498}
1499
1500fn invalid_parameter_error(message: impl Into<String>) -> PixelFlowError {
1501 PixelFlowError::new(
1502 ErrorCategory::Script,
1503 ErrorCode::new("script.invalid_parameter"),
1504 message,
1505 )
1506}
1507
1508fn invalid_argument<T>(message: impl Into<String>) -> Result<T> {
1509 Err(invalid_argument_error(message))
1510}
1511
1512fn invalid_argument_error(message: impl Into<String>) -> PixelFlowError {
1513 PixelFlowError::new(
1514 ErrorCategory::Script,
1515 ErrorCode::new("script.invalid_argument"),
1516 message,
1517 )
1518}
1519
1520#[cfg(test)]
1521mod tests {
1522 #![expect(clippy::indexing_slicing, reason = "allow in tests")]
1523
1524 use std::sync::atomic::{AtomicUsize, Ordering};
1525 use std::sync::{Arc, Mutex};
1526
1527 use pixelflow_core::{
1528 ClipFormat, ClipMedia, ClipResolution, ErrorCategory, ErrorCode, FilterChangeSet,
1529 FilterCompatibility, FilterDescriptor, FilterOptionValue, FilterPlan, FilterPlanRequest,
1530 FilterRegistry, FrameCount, FrameRate, Graph, LogLevel, LogRecord, LogSink, Logger,
1531 Metadata, MetadataKind, MetadataValue, NodeKind, PixelFlowError, Rational,
1532 };
1533
1534 use super::{ScriptEngine, ScriptParameter, ScriptValue};
1535
1536 #[derive(Default)]
1537 struct FakePropResolver {
1538 calls: AtomicUsize,
1539 }
1540
1541 impl super::ScriptPropResolver for FakePropResolver {
1542 fn resolve_prop(
1543 &self,
1544 graph: Graph,
1545 _metadata_schema: super::MetadataSchema,
1546 frame_number: usize,
1547 key: &str,
1548 ) -> pixelflow_core::Result<MetadataValue> {
1549 self.calls.fetch_add(1, Ordering::SeqCst);
1550 assert_eq!(graph.outputs().len(), 1);
1551 assert_eq!(frame_number, 3);
1552
1553 match key {
1554 "core:matrix" => Ok(MetadataValue::String("bt709".to_owned())),
1555 "core:frame_number" => Ok(MetadataValue::Int(3)),
1556 "core:duration" => Ok(MetadataValue::Rational(Rational {
1557 numerator: 1001,
1558 denominator: 30000,
1559 })),
1560 "core:source_path" => Ok(MetadataValue::None),
1561 _ => Err(PixelFlowError::new(
1562 ErrorCategory::Core,
1563 ErrorCode::new("metadata.unregistered_key"),
1564 format!("metadata key '{key}' is not registered"),
1565 )),
1566 }
1567 }
1568 }
1569
1570 fn fake_filter_registry() -> FilterRegistry {
1571 fn passthrough(request: FilterPlanRequest<'_>) -> pixelflow_core::Result<FilterPlan> {
1572 Ok(FilterPlan::new(
1573 request.input_media()[0].clone(),
1574 FilterCompatibility::Preserve,
1575 ))
1576 }
1577
1578 fn resize_like(request: FilterPlanRequest<'_>) -> pixelflow_core::Result<FilterPlan> {
1579 let width = match request.options().get("width") {
1580 Some(FilterOptionValue::Int(width)) => {
1581 usize::try_from(*width).expect("test width fits")
1582 }
1583 _ => panic!("test planner expected integer width"),
1584 };
1585 let height = match request.options().get("height") {
1586 Some(FilterOptionValue::Int(height)) => {
1587 usize::try_from(*height).expect("test height fits")
1588 }
1589 _ => panic!("test planner expected integer height"),
1590 };
1591 let input = &request.input_media()[0];
1592 let media = ClipMedia::new(
1593 input.format().clone(),
1594 ClipResolution::Fixed { width, height },
1595 input.frame_count(),
1596 input.frame_rate(),
1597 );
1598 Ok(FilterPlan::new(
1599 media,
1600 FilterCompatibility::AllowChanges(FilterChangeSet {
1601 format: false,
1602 resolution: true,
1603 frame_count: false,
1604 frame_rate: false,
1605 }),
1606 ))
1607 }
1608
1609 fn set_prop(request: FilterPlanRequest<'_>) -> pixelflow_core::Result<FilterPlan> {
1610 assert_eq!(
1611 request.options().get("key"),
1612 Some(&FilterOptionValue::String("acme/filter:enabled".to_owned(),))
1613 );
1614 assert_eq!(
1615 request.options().get("value"),
1616 Some(&FilterOptionValue::Bool(true))
1617 );
1618 assert_eq!(
1619 request.metadata_schema().kind("acme/filter:enabled"),
1620 Some(MetadataKind::Bool)
1621 );
1622
1623 Ok(FilterPlan::new(
1624 request.input_media()[0].clone(),
1625 FilterCompatibility::Preserve,
1626 ))
1627 }
1628
1629 fn set_prop_array(request: FilterPlanRequest<'_>) -> pixelflow_core::Result<FilterPlan> {
1630 assert_eq!(
1631 request.options().get("key"),
1632 Some(&FilterOptionValue::String("acme/filter:ratios".to_owned(),))
1633 );
1634 assert_eq!(
1635 request.options().get("value"),
1636 Some(&FilterOptionValue::Array(vec![
1637 FilterOptionValue::Int(1),
1638 FilterOptionValue::String("x".to_owned()),
1639 FilterOptionValue::None,
1640 ]))
1641 );
1642 assert_eq!(
1643 request.metadata_schema().kind("acme/filter:ratios"),
1644 Some(MetadataKind::Array)
1645 );
1646
1647 Ok(FilterPlan::new(
1648 request.input_media()[0].clone(),
1649 FilterCompatibility::Preserve,
1650 ))
1651 }
1652
1653 fn set_prop_blob(request: FilterPlanRequest<'_>) -> pixelflow_core::Result<FilterPlan> {
1654 assert_eq!(
1655 request.options().get("key"),
1656 Some(&FilterOptionValue::String("acme/filter:payload".to_owned(),))
1657 );
1658 assert_eq!(
1659 request.options().get("value"),
1660 Some(&FilterOptionValue::Blob(vec![0_u8, 127, 255].into()))
1661 );
1662 assert_eq!(
1663 request.metadata_schema().kind("acme/filter:payload"),
1664 Some(MetadataKind::Blob)
1665 );
1666
1667 Ok(FilterPlan::new(
1668 request.input_media()[0].clone(),
1669 FilterCompatibility::Preserve,
1670 ))
1671 }
1672
1673 fn set_prop_checked(request: FilterPlanRequest<'_>) -> pixelflow_core::Result<FilterPlan> {
1674 let Some(FilterOptionValue::String(key)) = request.options().get("key") else {
1675 panic!("test planner expected string key");
1676 };
1677 let Some(FilterOptionValue::Bool(value)) = request.options().get("value") else {
1678 panic!("test planner expected bool value");
1679 };
1680
1681 let mut metadata = Metadata::new(request.metadata_schema());
1682 metadata.set(request.metadata_schema(), key, MetadataValue::Bool(*value))?;
1683
1684 Ok(FilterPlan::new(
1685 request.input_media()[0].clone(),
1686 FilterCompatibility::Preserve,
1687 ))
1688 }
1689
1690 fn expect_bool(request: FilterPlanRequest<'_>) -> pixelflow_core::Result<FilterPlan> {
1691 assert_eq!(
1692 request.options().get("value"),
1693 Some(&FilterOptionValue::Bool(true))
1694 );
1695
1696 Ok(FilterPlan::new(
1697 request.input_media()[0].clone(),
1698 FilterCompatibility::Preserve,
1699 ))
1700 }
1701
1702 fn expect_rational(request: FilterPlanRequest<'_>) -> pixelflow_core::Result<FilterPlan> {
1703 assert_eq!(
1704 request.options().get("value"),
1705 Some(&FilterOptionValue::Rational(Rational {
1706 numerator: 1001,
1707 denominator: 30000,
1708 }))
1709 );
1710
1711 Ok(FilterPlan::new(
1712 request.input_media()[0].clone(),
1713 FilterCompatibility::Preserve,
1714 ))
1715 }
1716
1717 let mut registry = FilterRegistry::new();
1718 registry
1719 .register_filter_planner(
1720 FilterDescriptor::new("custom_filter", "test", "test"),
1721 passthrough,
1722 )
1723 .expect("custom filter registers");
1724 registry
1725 .register_filter_planner(
1726 FilterDescriptor::new("resize_like", "test", "test"),
1727 resize_like,
1728 )
1729 .expect("resize-like filter registers");
1730 registry
1731 .register_filter_planner(FilterDescriptor::new("set_prop", "test", "test"), set_prop)
1732 .expect("set-prop filter registers");
1733 registry
1734 .register_filter_planner(
1735 FilterDescriptor::new("set_prop_array", "test", "test"),
1736 set_prop_array,
1737 )
1738 .expect("set-prop-array filter registers");
1739 registry
1740 .register_filter_planner(
1741 FilterDescriptor::new("set_prop_blob", "test", "test"),
1742 set_prop_blob,
1743 )
1744 .expect("set-prop-blob filter registers");
1745 registry
1746 .register_filter_planner(
1747 FilterDescriptor::new("set_prop_checked", "test", "test"),
1748 set_prop_checked,
1749 )
1750 .expect("set-prop-checked filter registers");
1751 registry
1752 .register_filter_planner(
1753 FilterDescriptor::new("expect_bool", "test", "test"),
1754 expect_bool,
1755 )
1756 .expect("expect-bool filter registers");
1757 registry
1758 .register_filter_planner(
1759 FilterDescriptor::new("expect_rational", "test", "test"),
1760 expect_rational,
1761 )
1762 .expect("expect-rational filter registers");
1763 registry
1764 }
1765
1766 fn script_engine_with_fake_filters() -> ScriptEngine {
1767 ScriptEngine::with_filter_registry(fake_filter_registry())
1768 }
1769
1770 fn namespaced_filter_registry() -> FilterRegistry {
1771 fn passthrough(request: FilterPlanRequest<'_>) -> pixelflow_core::Result<FilterPlan> {
1772 Ok(FilterPlan::new(
1773 request.input_media()[0].clone(),
1774 FilterCompatibility::Preserve,
1775 ))
1776 }
1777
1778 fn resize_like(request: FilterPlanRequest<'_>) -> pixelflow_core::Result<FilterPlan> {
1779 let width = match request.options().get("width") {
1780 Some(FilterOptionValue::Int(width)) => usize::try_from(*width).expect("width fits"),
1781 _ => panic!("test planner expected integer width"),
1782 };
1783 let height = match request.options().get("height") {
1784 Some(FilterOptionValue::Int(height)) => {
1785 usize::try_from(*height).expect("height fits")
1786 }
1787 _ => panic!("test planner expected integer height"),
1788 };
1789 let input = &request.input_media()[0];
1790 Ok(FilterPlan::new(
1791 ClipMedia::new(
1792 input.format().clone(),
1793 ClipResolution::Fixed { width, height },
1794 input.frame_count(),
1795 input.frame_rate(),
1796 ),
1797 FilterCompatibility::AllowChanges(FilterChangeSet {
1798 format: false,
1799 resolution: true,
1800 frame_count: false,
1801 frame_rate: false,
1802 }),
1803 ))
1804 }
1805
1806 let mut registry = FilterRegistry::new();
1807 registry
1808 .register_filter(FilterDescriptor::new("acme.blur", "acme", "blur"))
1809 .expect("third-party descriptor-only filter registers");
1810 registry
1811 .register_filter_planner(
1812 FilterDescriptor::new("merge_planes", "pixelflow", "std"),
1813 passthrough,
1814 )
1815 .expect("std merge-like filter registers");
1816 registry
1817 .register_filter_planner(
1818 FilterDescriptor::new("resize", "pixelflow", "std"),
1819 resize_like,
1820 )
1821 .expect("std resize-like filter registers");
1822 registry
1823 }
1824
1825 #[derive(Debug, Default)]
1826 struct CaptureSink {
1827 records: Mutex<Vec<LogRecord>>,
1828 }
1829
1830 impl LogSink for CaptureSink {
1831 fn log(&self, record: &LogRecord) {
1832 self.records
1833 .lock()
1834 .expect("capture sink lock should succeed")
1835 .push(record.clone());
1836 }
1837 }
1838
1839 #[test]
1840 fn set_argument_parser_coerces_supported_values() {
1841 assert_eq!(
1842 ScriptParameter::parse_set("enabled=true")
1843 .expect("bool parameter should parse")
1844 .value(),
1845 &ScriptValue::Bool(true)
1846 );
1847 assert_eq!(
1848 ScriptParameter::parse_set("count=42")
1849 .expect("int parameter should parse")
1850 .value(),
1851 &ScriptValue::Int(42)
1852 );
1853 assert_eq!(
1854 ScriptParameter::parse_set("scale=1.25")
1855 .expect("float parameter should parse")
1856 .value(),
1857 &ScriptValue::Float(1.25)
1858 );
1859 assert_eq!(
1860 ScriptParameter::parse_set("rate=30000/1001")
1861 .expect("rational parameter should parse")
1862 .value(),
1863 &ScriptValue::Rational(Rational {
1864 numerator: 30000,
1865 denominator: 1001,
1866 })
1867 );
1868 assert_eq!(
1869 ScriptParameter::parse_set("path=input.mkv")
1870 .expect("string parameter should parse")
1871 .value(),
1872 &ScriptValue::String("input.mkv".to_owned())
1873 );
1874 assert_eq!(
1875 ScriptParameter::parse_set("path=/tmp/input.mkv")
1876 .expect("slash-containing string parameter should parse")
1877 .value(),
1878 &ScriptValue::String("/tmp/input.mkv".to_owned())
1879 );
1880 }
1881
1882 #[test]
1883 fn set_argument_parser_rejects_invalid_names_and_rationals() {
1884 let bad_name =
1885 ScriptParameter::parse_set("9bad=value").expect_err("invalid name should fail");
1886 assert_eq!(bad_name.category(), ErrorCategory::Script);
1887 assert_eq!(bad_name.code(), ErrorCode::new("script.invalid_parameter"));
1888
1889 let bad_rate =
1890 ScriptParameter::parse_set("rate=1/0").expect_err("zero denominator should fail");
1891 assert_eq!(bad_rate.category(), ErrorCategory::Script);
1892 assert_eq!(bad_rate.code(), ErrorCode::new("script.invalid_parameter"));
1893 }
1894
1895 #[test]
1896 fn evaluate_rejects_empty_source() {
1897 let error = ScriptEngine::new()
1898 .evaluate(" \n", &[])
1899 .expect_err("empty script should fail");
1900
1901 assert_eq!(error.category(), ErrorCategory::Script);
1902 assert_eq!(error.code(), ErrorCode::new("script.empty"));
1903 }
1904
1905 #[test]
1906 fn evaluate_exposes_none_helpers() {
1907 let graph = ScriptEngine::new()
1908 .evaluate(
1909 "flag = is_none(none())\noutput = source(\"input.mkv\")",
1910 &[],
1911 )
1912 .expect("script should evaluate");
1913
1914 assert_eq!(graph.graph().outputs().len(), 1);
1915 }
1916
1917 #[test]
1918 fn sandbox_rejects_file_process_and_network_like_apis() {
1919 let engine = ScriptEngine::new();
1920
1921 for script in [
1922 "output = read_file(\"secret.txt\")",
1923 "output = write_file(\"x\", \"y\")",
1924 "output = command(\"echo\")",
1925 "import \"other.pf\" as other; output = source(\"input.mkv\")",
1926 ] {
1927 let error = engine
1928 .evaluate(script, &[])
1929 .expect_err("unregistered API should fail");
1930 assert_eq!(error.category(), ErrorCategory::Script);
1931 }
1932 }
1933
1934 #[test]
1935 fn syntax_errors_include_source_position() {
1936 let error = ScriptEngine::new()
1937 .evaluate("output = source(\"input.mkv\"", &[])
1938 .expect_err("invalid syntax should fail");
1939
1940 assert_eq!(error.category(), ErrorCategory::Script);
1941 assert_eq!(error.code(), ErrorCode::new("script.parse"));
1942 assert!(error.message().contains("line"));
1943 }
1944
1945 #[test]
1946 fn method_chain_dispatches_identifier_filters_through_registry() {
1947 let script = r#"
1948 output = source("input.mkv")
1949 .custom_filter(#{ enabled: true })
1950 .resize_like(#{ width: 320, height: 180 })
1951 "#;
1952
1953 let graph = script_engine_with_fake_filters()
1954 .evaluate(script, &[])
1955 .expect("generic registered filters should evaluate")
1956 .into_graph();
1957
1958 assert_eq!(graph.nodes().len(), 3);
1959 let output = graph
1960 .node(graph.outputs()[0].node_id())
1961 .expect("output node exists");
1962 let NodeKind::Filter { name, .. } = output.kind() else {
1963 panic!("output should be filter node");
1964 };
1965 assert_eq!(name, "resize_like");
1966 assert!(matches!(
1967 output.media().resolution(),
1968 ClipResolution::Fixed {
1969 width: 320,
1970 height: 180
1971 }
1972 ));
1973 }
1974
1975 #[test]
1976 fn array_filter_dispatches_multi_input_filters_through_registry() {
1977 fn merge_like(request: FilterPlanRequest<'_>) -> pixelflow_core::Result<FilterPlan> {
1978 assert_eq!(request.input_media().len(), 3);
1979 Ok(FilterPlan::new(
1980 ClipMedia::new(
1981 ClipFormat::Fixed(pixelflow_core::resolve_format_alias("yuv420p8")?),
1982 ClipResolution::Fixed {
1983 width: 1,
1984 height: 1,
1985 },
1986 FrameCount::Unknown,
1987 FrameRate::Unknown,
1988 ),
1989 FilterCompatibility::Custom,
1990 ))
1991 }
1992
1993 let mut registry = FilterRegistry::new();
1994 registry
1995 .register_filter_planner(
1996 FilterDescriptor::new("merge_like", "acme", "filters"),
1997 merge_like,
1998 )
1999 .expect("filter registers");
2000
2001 let graph = ScriptEngine::with_filter_registry(registry)
2002 .evaluate(
2003 r#"
2004 y = source("input.mkv")
2005 u = source("input.mkv")
2006 v = source("input.mkv")
2007 output = filter([y, u, v], "merge_like", #{ format: "yuv420p8" })
2008 "#,
2009 &[],
2010 )
2011 .expect("multi-input filter should evaluate")
2012 .into_graph();
2013
2014 let output = graph
2015 .node(graph.outputs()[0].node_id())
2016 .expect("output exists");
2017 let NodeKind::Filter {
2018 name,
2019 inputs,
2020 compatibility,
2021 ..
2022 } = output.kind()
2023 else {
2024 panic!("output should be filter node");
2025 };
2026 assert_eq!(name, "merge_like");
2027 assert_eq!(inputs.len(), 3);
2028 assert_eq!(*compatibility, FilterCompatibility::Custom);
2029 }
2030
2031 #[test]
2032 fn array_filter_rejects_non_clip_entries() {
2033 let error = script_engine_with_fake_filters()
2034 .evaluate(
2035 r#"
2036 y = source("input.mkv")
2037 output = filter([y, 7], "custom_filter")
2038 "#,
2039 &[],
2040 )
2041 .expect_err("non-clip array entry should fail");
2042
2043 assert_eq!(error.category(), ErrorCategory::Script);
2044 assert_eq!(error.code(), ErrorCode::new("script.invalid_argument"));
2045 assert!(error.message().contains("filter input 1 must be Clip"));
2046 }
2047
2048 #[test]
2049 fn string_named_filter_dispatch_supports_non_identifier_names() {
2050 fn passthrough(request: FilterPlanRequest<'_>) -> pixelflow_core::Result<FilterPlan> {
2051 Ok(FilterPlan::new(
2052 request.input_media()[0].clone(),
2053 FilterCompatibility::Preserve,
2054 ))
2055 }
2056
2057 let mut registry = FilterRegistry::new();
2058 registry
2059 .register_filter_planner(
2060 FilterDescriptor::new("acme.blur", "acme", "blur"),
2061 passthrough,
2062 )
2063 .expect("filter registers");
2064
2065 let graph = ScriptEngine::with_filter_registry(registry)
2066 .evaluate(
2067 r#"output = filter(source("input.mkv"), "acme.blur", #{ radius: 2 })"#,
2068 &[],
2069 )
2070 .expect("string-named filter should evaluate")
2071 .into_graph();
2072
2073 let NodeKind::Filter { name, .. } = graph
2074 .node(graph.outputs()[0].node_id())
2075 .expect("output node exists")
2076 .kind()
2077 else {
2078 panic!("expected filter node");
2079 };
2080 assert_eq!(name, "acme.blur");
2081 }
2082
2083 #[test]
2084 fn register_prop_registers_metadata_key_for_filter_planner() {
2085 let graph = script_engine_with_fake_filters()
2086 .evaluate(
2087 r#"
2088 register_prop("acme/filter:enabled", "bool")
2089 output = source("input.mkv").set_prop(#{ key: "acme/filter:enabled", value: true })
2090 "#,
2091 &[],
2092 )
2093 .expect("registered metadata key should reach filter planner")
2094 .into_graph();
2095
2096 let NodeKind::Filter { name, .. } = graph
2097 .node(graph.outputs()[0].node_id())
2098 .expect("output node exists")
2099 .kind()
2100 else {
2101 panic!("expected filter node");
2102 };
2103 assert_eq!(name, "set_prop");
2104 }
2105
2106 #[test]
2107 fn filter_options_convert_rhai_arrays_to_metadata_arrays() {
2108 let graph = script_engine_with_fake_filters()
2109 .evaluate(
2110 r#"
2111 register_prop("acme/filter:ratios", "array")
2112 output = source("input.mkv").set_prop_array(#{ key: "acme/filter:ratios", value: [1, "x", none()] })
2113 "#,
2114 &[],
2115 )
2116 .expect("array metadata value should reach filter planner")
2117 .into_graph();
2118
2119 let NodeKind::Filter { name, .. } = graph
2120 .node(graph.outputs()[0].node_id())
2121 .expect("output node exists")
2122 .kind()
2123 else {
2124 panic!("expected filter node");
2125 };
2126 assert_eq!(name, "set_prop_array");
2127 }
2128
2129 #[test]
2130 fn script_filter_call_preserves_options_for_runtime_executor() {
2131 let graph = script_engine_with_fake_filters()
2132 .evaluate(
2133 r#"
2134 clip = source("input.mkv")
2135 output = clip.resize_like(#{ width: 320, height: 180 })
2136 "#,
2137 &[],
2138 )
2139 .expect("script should evaluate")
2140 .into_graph();
2141
2142 let output = graph
2143 .node(graph.outputs()[0].node_id())
2144 .expect("output exists");
2145 let options = output.filter_options().expect("output is filter");
2146
2147 assert_eq!(
2148 options.get("width"),
2149 Some(&pixelflow_core::FilterOptionValue::Int(320))
2150 );
2151 assert_eq!(
2152 options.get("height"),
2153 Some(&pixelflow_core::FilterOptionValue::Int(180))
2154 );
2155 }
2156
2157 #[test]
2158 fn filter_options_convert_blob_helper_to_metadata_blobs() {
2159 let graph = script_engine_with_fake_filters()
2160 .evaluate(
2161 r#"
2162 register_prop("acme/filter:payload", "blob")
2163 output = source("input.mkv").set_prop_blob(#{ key: "acme/filter:payload", value: blob([0, 127, 255]) })
2164 "#,
2165 &[],
2166 )
2167 .expect("blob metadata value should reach filter planner")
2168 .into_graph();
2169
2170 let NodeKind::Filter { name, .. } = graph
2171 .node(graph.outputs()[0].node_id())
2172 .expect("output node exists")
2173 .kind()
2174 else {
2175 panic!("expected filter node");
2176 };
2177 assert_eq!(name, "set_prop_blob");
2178 }
2179
2180 #[test]
2181 fn blob_helper_rejects_bytes_outside_u8_range() {
2182 let error = script_engine_with_fake_filters()
2183 .evaluate(
2184 r#"
2185 register_prop("acme/filter:payload", "blob")
2186 output = source("input.mkv").set_prop_blob(#{ key: "acme/filter:payload", value: blob([256]) })
2187 "#,
2188 &[],
2189 )
2190 .expect_err("out-of-range blob byte should fail");
2191
2192 assert_eq!(error.category(), ErrorCategory::Script);
2193 assert_eq!(error.code(), ErrorCode::new("script.invalid_argument"));
2194 assert_eq!(
2195 error.message(),
2196 "blob byte at index 0 must be between 0 and 255"
2197 );
2198 }
2199
2200 #[test]
2201 fn unsupported_filter_option_error_lists_array_and_blob_types() {
2202 let error = script_engine_with_fake_filters()
2203 .evaluate(
2204 r#"output = source("input.mkv").custom_filter(#{ payload: #{ nested: true } })"#,
2205 &[],
2206 )
2207 .expect_err("nested map option should fail");
2208
2209 assert_eq!(error.category(), ErrorCategory::Script);
2210 assert_eq!(error.code(), ErrorCode::new("script.invalid_argument"));
2211 assert_eq!(
2212 error.message(),
2213 "filter option 'payload' must be none, string, bool, integer, float, array, rational, or blob"
2214 );
2215 }
2216
2217 #[test]
2218 fn unregistered_prop_key_reports_structured_failure() {
2219 let error = script_engine_with_fake_filters()
2220 .evaluate(
2221 r#"output = source("input.mkv").set_prop_checked(#{ key: "acme/filter:enabled", value: true })"#,
2222 &[],
2223 )
2224 .expect_err("unregistered metadata key should fail");
2225
2226 assert_eq!(error.category(), ErrorCategory::Plugin);
2227 assert_eq!(error.code(), ErrorCode::new("metadata.unregistered_key"));
2228 assert_eq!(
2229 error.message(),
2230 "metadata key 'acme/filter:enabled' is not registered"
2231 );
2232 }
2233
2234 #[test]
2235 fn registered_prop_key_allows_metadata_validated_property_flow() {
2236 let graph = script_engine_with_fake_filters()
2237 .evaluate(
2238 r#"
2239 register_prop("acme/filter:enabled", "bool")
2240 output = source("input.mkv").set_prop_checked(#{ key: "acme/filter:enabled", value: true })
2241 "#,
2242 &[],
2243 )
2244 .expect("registered metadata key should allow property flow")
2245 .into_graph();
2246
2247 let NodeKind::Filter { name, .. } = graph
2248 .node(graph.outputs()[0].node_id())
2249 .expect("output node exists")
2250 .kind()
2251 else {
2252 panic!("expected filter node");
2253 };
2254 assert_eq!(name, "set_prop_checked");
2255 }
2256
2257 #[test]
2258 fn prop_without_resolver_reports_structured_error() {
2259 let error = ScriptEngine::new()
2260 .evaluate(
2261 r#"
2262 clip = source("input.mkv")
2263 value = prop(clip, 0, "core:matrix")
2264 output = clip
2265 "#,
2266 &[],
2267 )
2268 .expect_err("prop calls require a runtime resolver");
2269
2270 assert_eq!(error.category(), ErrorCategory::Script);
2271 assert_eq!(error.code(), ErrorCode::new("script.prop_unavailable"));
2272 }
2273
2274 #[test]
2275 fn prop_converts_metadata_values_to_rhai_values() {
2276 let resolver = Arc::new(FakePropResolver::default());
2277 let graph = script_engine_with_fake_filters()
2278 .with_prop_resolver(resolver)
2279 .evaluate(
2280 r#"
2281 clip = source("input.mkv")
2282 matrix = prop(clip, 3, "core:matrix")
2283 frame = clip.prop(3, "core:frame_number")
2284 duration = prop(clip, 3, "core:duration")
2285 missing = clip.prop(3, "core:source_path")
2286 checked = clip.expect_bool(#{ value: matrix == "bt709" && frame == 3 && is_none(missing) })
2287 rated = checked.expect_rational(#{ value: duration })
2288 output = rated.resize_like(#{ width: frame * 100 + 20, height: 180 })
2289 "#,
2290 &[],
2291 )
2292 .expect("prop values should be usable from script")
2293 .into_graph();
2294
2295 let NodeKind::Filter { name, .. } = graph
2296 .node(graph.outputs()[0].node_id())
2297 .expect("output node exists")
2298 .kind()
2299 else {
2300 panic!("expected filter output when prop branch matches");
2301 };
2302 assert_eq!(name, "resize_like");
2303 }
2304
2305 #[test]
2306 fn prop_rejects_negative_frame_numbers() {
2307 let resolver = Arc::new(FakePropResolver::default());
2308 let error = ScriptEngine::new()
2309 .with_prop_resolver(resolver)
2310 .evaluate(
2311 r#"
2312 clip = source("input.mkv")
2313 value = prop(clip, -1, "core:matrix")
2314 output = clip
2315 "#,
2316 &[],
2317 )
2318 .expect_err("negative frame should fail");
2319
2320 assert_eq!(error.category(), ErrorCategory::Script);
2321 assert_eq!(error.code(), ErrorCode::new("script.invalid_argument"));
2322 assert_eq!(error.message(), "prop frame number must be non-negative");
2323 }
2324
2325 #[test]
2326 fn repeated_prop_requests_are_cached_per_script_evaluation() {
2327 let resolver = Arc::new(FakePropResolver::default());
2328 let counter = Arc::clone(&resolver);
2329
2330 script_engine_with_fake_filters()
2331 .with_prop_resolver(resolver)
2332 .evaluate(
2333 r#"
2334 clip = source("input.mkv")
2335 a = prop(clip, 3, "core:matrix")
2336 b = clip.prop(3, "core:matrix")
2337 output = clip
2338 "#,
2339 &[],
2340 )
2341 .expect("duplicate prop calls should evaluate");
2342
2343 assert_eq!(counter.calls.load(Ordering::SeqCst), 1);
2344 }
2345
2346 #[test]
2347 fn unregistered_filter_reports_graph_diagnostic() {
2348 let error = ScriptEngine::new()
2349 .evaluate(r#"output = source("input.mkv").missing(#{})"#, &[])
2350 .expect_err("missing filter should fail");
2351
2352 assert_eq!(error.category(), ErrorCategory::Graph);
2353 assert_eq!(error.code(), ErrorCode::new("graph.unknown_filter"));
2354 }
2355
2356 #[test]
2357 fn plugin_namespace_function_dispatches_descriptor_only_filter() {
2358 let graph = ScriptEngine::with_filter_registry(namespaced_filter_registry())
2359 .evaluate(
2360 r#"output = plugin.acme.blur(source("input.mkv"), #{ radius: 2 })"#,
2361 &[],
2362 )
2363 .expect("plugin namespace function should evaluate")
2364 .into_graph();
2365
2366 let NodeKind::Filter {
2367 name,
2368 compatibility,
2369 ..
2370 } = graph
2371 .node(graph.outputs()[0].node_id())
2372 .expect("output node exists")
2373 .kind()
2374 else {
2375 panic!("expected filter node");
2376 };
2377 assert_eq!(name, "acme.blur");
2378 assert_eq!(*compatibility, FilterCompatibility::Custom);
2379 }
2380
2381 #[test]
2382 fn plugin_namespace_method_dispatches_descriptor_only_filter() {
2383 let graph = ScriptEngine::with_filter_registry(namespaced_filter_registry())
2384 .evaluate(
2385 r#"output = source("input.mkv").plugin.acme.blur(#{ radius: 2 })"#,
2386 &[],
2387 )
2388 .expect("plugin namespace method should evaluate")
2389 .into_graph();
2390
2391 let NodeKind::Filter { name, .. } = graph
2392 .node(graph.outputs()[0].node_id())
2393 .expect("output node exists")
2394 .kind()
2395 else {
2396 panic!("expected filter node");
2397 };
2398 assert_eq!(name, "acme.blur");
2399 }
2400
2401 #[test]
2402 fn std_namespace_function_dispatches_std_filter() {
2403 let graph = ScriptEngine::with_filter_registry(namespaced_filter_registry())
2404 .evaluate(
2405 r#"
2406 y = source("input.mkv")
2407 u = source("input.mkv")
2408 v = source("input.mkv")
2409 output = std.merge_planes([y, u, v], #{ format: "yuv420p8" })
2410 "#,
2411 &[],
2412 )
2413 .expect("std namespace function should evaluate")
2414 .into_graph();
2415
2416 let NodeKind::Filter { name, inputs, .. } = graph
2417 .node(graph.outputs()[0].node_id())
2418 .expect("output node exists")
2419 .kind()
2420 else {
2421 panic!("expected filter node");
2422 };
2423 assert_eq!(name, "merge_planes");
2424 assert_eq!(inputs.len(), 3);
2425 }
2426
2427 #[test]
2428 fn plugin_std_namespace_function_dispatches_std_filter() {
2429 let graph = ScriptEngine::with_filter_registry(namespaced_filter_registry())
2430 .evaluate(
2431 r#"
2432 y = source("input.mkv")
2433 u = source("input.mkv")
2434 v = source("input.mkv")
2435 output = plugin.std.merge_planes([y, u, v])
2436 "#,
2437 &[],
2438 )
2439 .expect("plugin.std namespace function should evaluate")
2440 .into_graph();
2441
2442 let NodeKind::Filter { name, inputs, .. } = graph
2443 .node(graph.outputs()[0].node_id())
2444 .expect("output node exists")
2445 .kind()
2446 else {
2447 panic!("expected filter node");
2448 };
2449 assert_eq!(name, "merge_planes");
2450 assert_eq!(inputs.len(), 3);
2451 }
2452
2453 #[test]
2454 fn std_namespace_method_dispatches_std_filter() {
2455 let graph = ScriptEngine::with_filter_registry(namespaced_filter_registry())
2456 .evaluate(
2457 r#"output = source("input.mkv").std.resize(#{ width: 320, height: 180 })"#,
2458 &[],
2459 )
2460 .expect("std namespace method should evaluate")
2461 .into_graph();
2462
2463 let output = graph
2464 .node(graph.outputs()[0].node_id())
2465 .expect("output node exists");
2466 let NodeKind::Filter { name, .. } = output.kind() else {
2467 panic!("expected filter node");
2468 };
2469 assert_eq!(name, "resize");
2470 assert!(matches!(
2471 output.media().resolution(),
2472 ClipResolution::Fixed {
2473 width: 320,
2474 height: 180,
2475 }
2476 ));
2477 }
2478
2479 #[test]
2480 fn unknown_plugin_namespace_reports_structured_graph_error() {
2481 let error = ScriptEngine::with_filter_registry(namespaced_filter_registry())
2482 .evaluate(
2483 r#"output = plugin.missing.blur(source("input.mkv"), #{ radius: 2 })"#,
2484 &[],
2485 )
2486 .expect_err("unknown namespace should fail");
2487
2488 assert_eq!(error.category(), ErrorCategory::Graph);
2489 assert_eq!(error.code(), ErrorCode::new("graph.unknown_filter"));
2490 }
2491
2492 #[test]
2493 fn std_namespace_function_rewrites_even_when_statement_result_is_unused() {
2494 ScriptEngine::with_filter_registry(namespaced_filter_registry())
2495 .evaluate(
2496 r#"
2497 std.resize(source("input.mkv"), #{ width: 320, height: 180 })
2498 output = source("input.mkv")
2499 "#,
2500 &[],
2501 )
2502 .expect("unused namespaced statement should still parse and evaluate");
2503 }
2504
2505 #[test]
2506 fn filter_method_rewrite_preserves_strings_and_explicit_filter_calls() {
2507 assert_eq!(
2508 super::rewrite_filter_syntax(
2509 r#"output = source("a.b").sample_filter(#{ width: 1, height: 1 })"#,
2510 &fake_filter_registry(),
2511 )
2512 .expect("rewrite should succeed"),
2513 r#"output = source("a.b").filter("sample_filter", #{ width: 1, height: 1 })"#
2514 );
2515 assert_eq!(
2516 super::rewrite_filter_syntax(
2517 r#"output = clip.filter("acme.blur", #{ radius: 2 })"#,
2518 &fake_filter_registry(),
2519 )
2520 .expect("rewrite should succeed"),
2521 r#"output = clip.filter("acme.blur", #{ radius: 2 })"#
2522 );
2523 }
2524
2525 #[test]
2526 fn filter_method_rewrite_ignores_comments_and_supports_spacing_and_no_arg_calls() {
2527 let source = super::normalize_source(concat!(
2528 "// clip.sample_filter()\n",
2529 "/* clip.resize_like(#{ width: 1, height: 1 }) */\n",
2530 "text = \"clip.no_args_filter()\";\n",
2531 "clip = source(\"input.mkv\")\n",
2532 "output = clip.resize_like (#{ width: 320, height: 180 })\n",
2533 "done = clip.no_args_filter ()\n",
2534 "kept = clip.filter(\"acme.blur\", #{ radius: 2 })\n",
2535 ));
2536
2537 let expected = super::normalize_source(concat!(
2538 "// clip.sample_filter()\n",
2539 "/* clip.resize_like(#{ width: 1, height: 1 }) */\n",
2540 "text = \"clip.no_args_filter()\";\n",
2541 "clip = source(\"input.mkv\")\n",
2542 "output = clip.filter(\"resize_like\", #{ width: 320, height: 180 })\n",
2543 "done = clip.filter(\"no_args_filter\")\n",
2544 "kept = clip.filter(\"acme.blur\", #{ radius: 2 })\n",
2545 ));
2546
2547 assert_eq!(
2548 super::rewrite_filter_syntax(&source, &fake_filter_registry())
2549 .expect("rewrite should succeed"),
2550 expected
2551 );
2552 }
2553
2554 #[test]
2555 fn filter_method_rewrite_only_targets_clip_producing_rhs_chains() {
2556 let source = super::normalize_source(
2557 r#"
2558 clip = source("input.mkv")
2559 text = "abc".len()
2560 output = clip.resize_like(#{ width: "xy".len(), height: 180 })
2561 "#,
2562 );
2563
2564 let expected = concat!(
2565 "clip = source(\"input.mkv\");\n",
2566 "text = \"abc\".len();\n",
2567 "output = clip.filter(\"resize_like\", #{ width: \"xy\".len(), height: 180 });",
2568 );
2569
2570 assert_eq!(
2571 super::rewrite_filter_syntax(&source, &fake_filter_registry())
2572 .expect("rewrite should succeed"),
2573 expected
2574 );
2575 }
2576
2577 #[test]
2578 fn logger_and_filter_registry_constructor_preserves_custom_logger() {
2579 let sink = Arc::new(CaptureSink::default());
2580 let engine = ScriptEngine::with_logger_and_filter_registry(
2581 Logger::new(sink.clone()),
2582 fake_filter_registry(),
2583 );
2584
2585 engine
2586 .evaluate(
2587 r#"output = source("input.mkv").custom_filter(#{ enabled: true })"#,
2588 &[],
2589 )
2590 .expect("script should evaluate with combined logger and registry");
2591
2592 let records = sink
2593 .records
2594 .lock()
2595 .expect("capture sink lock should succeed");
2596 assert!(records.iter().any(|record| {
2597 record.level() == LogLevel::Debug
2598 && record.target() == "pixelflow_script"
2599 && record.message() == "script graph constructed"
2600 }));
2601 }
2602
2603 #[test]
2604 fn count_output_assignments_ignores_strings_comments_comparisons_and_nesting() {
2605 let source = concat!(
2606 "output = source(\"real.mkv\");\n",
2607 "text = \"output = fake\";\n",
2608 "// output = fake;\n",
2609 "/* output = fake; */\n",
2610 "output == none();\n",
2611 "check = output == none();\n",
2612 "other = output != none();\n",
2613 "compare = output >= 0;\n",
2614 "limit = output <= 10;\n",
2615 "if cond { output = source(\"nested.mkv\"); }\n",
2616 "config = #{ nested: \"output = fake\" };\n",
2617 "text = \"skip; output = fake\";\n",
2618 "/* skip; output = fake; */\n",
2619 );
2620
2621 assert_eq!(super::count_output_assignments(source), 1);
2622 }
2623
2624 #[test]
2625 fn source_options_are_preserved_for_ffms2_indexing() {
2626 let graph = ScriptEngine::new()
2627 .evaluate(
2628 r#"output = source("input.mkv", #{ cache: "cache.pfidx", fps: "30000/1001", vfr: "normalize", format: "yuv420p10" })"#,
2629 &[],
2630 )
2631 .expect("script should evaluate")
2632 .into_graph();
2633 let node = graph
2634 .node(graph.outputs()[0].node_id())
2635 .expect("source node exists");
2636
2637 let pixelflow_core::NodeKind::Source { request, .. } = node.kind() else {
2638 panic!("expected source node");
2639 };
2640
2641 assert_eq!(request.path(), "input.mkv");
2642 assert_eq!(
2643 request.options().get("cache"),
2644 Some(&pixelflow_core::SourceOptionValue::String(
2645 "cache.pfidx".to_owned(),
2646 ))
2647 );
2648 assert_eq!(
2649 request.options().get("fps"),
2650 Some(&pixelflow_core::SourceOptionValue::Rational(Rational {
2651 numerator: 30_000,
2652 denominator: 1_001,
2653 }))
2654 );
2655 }
2656
2657 #[test]
2658 fn source_graph_validation_plan_works_before_indexing() {
2659 let graph = ScriptEngine::new()
2660 .evaluate(
2661 "unused = source(\"unused.mkv\")\noutput = source(\"used.mkv\")",
2662 &[],
2663 )
2664 .expect("script should evaluate")
2665 .into_graph();
2666
2667 let plan = graph
2668 .validation_plan()
2669 .expect("reachability works before media validation");
2670
2671 assert_eq!(plan.reachable_sources().len(), 1);
2672 let node = graph
2673 .node(plan.reachable_sources()[0])
2674 .expect("source exists");
2675 let pixelflow_core::NodeKind::Source { request, .. } = node.kind() else {
2676 panic!("expected source");
2677 };
2678 assert_eq!(request.path(), "used.mkv");
2679 }
2680
2681 #[test]
2682 fn multiline_option_blocks_build_valid_graph() {
2683 let script = r#"
2684 output = source(
2685 "input.mkv",
2686 #{
2687 width: 640,
2688 height: 360,
2689 frames: 2
2690 }
2691 )
2692 "#;
2693
2694 let graph = ScriptEngine::new()
2695 .evaluate(script, &[])
2696 .expect("multiline options should evaluate");
2697
2698 graph
2699 .graph()
2700 .validation_plan()
2701 .expect("multiline source graph should have validation plan");
2702 }
2703
2704 #[test]
2705 fn missing_output_reports_graph_diagnostic() {
2706 let error = ScriptEngine::new()
2707 .evaluate("clip = source(\"input.mkv\")", &[])
2708 .expect_err("missing output should fail");
2709
2710 assert_eq!(error.category(), ErrorCategory::Graph);
2711 assert_eq!(error.code(), ErrorCode::new("graph.missing_output"));
2712 assert_eq!(error.message(), "script does not assign final output");
2713 }
2714
2715 #[test]
2716 fn output_must_be_clip() {
2717 let error = ScriptEngine::new()
2718 .evaluate("output = 42", &[])
2719 .expect_err("non-clip output should fail");
2720
2721 assert_eq!(error.category(), ErrorCategory::Graph);
2722 assert_eq!(error.code(), ErrorCode::new("graph.invalid_output"));
2723 }
2724
2725 #[test]
2726 fn duplicate_output_assignments_are_rejected() {
2727 let error = ScriptEngine::new()
2728 .evaluate(
2729 "output = source(\"first.mkv\")\noutput = source(\"second.mkv\")",
2730 &[],
2731 )
2732 .expect_err("duplicate output assignment should fail");
2733
2734 assert_eq!(error.category(), ErrorCategory::Graph);
2735 assert_eq!(error.code(), ErrorCode::new("graph.multiple_outputs"));
2736 }
2737
2738 #[test]
2739 fn set_parameters_are_available_in_script_scope() {
2740 let params = [
2741 ScriptParameter::parse_set("width=640").expect("width should parse"),
2742 ScriptParameter::parse_set("height=360").expect("height should parse"),
2743 ScriptParameter::parse_set("frames=12").expect("frames should parse"),
2744 ];
2745 let script =
2746 "output = source(\"input.mkv\", #{ width: width, height: height, frames: frames })";
2747
2748 let graph = ScriptEngine::new()
2749 .evaluate(script, ¶ms)
2750 .expect("params should inject");
2751 graph
2752 .graph()
2753 .validation_plan()
2754 .expect("parameterized graph should have validation plan");
2755 }
2756}