Skip to main content

pixelflow_script/
lib.rs

1#![warn(missing_docs)]
2#![forbid(unsafe_code)]
3
4//! Script integration layer for PixelFlow graph construction.
5
6use 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/// Script value injected from CLI or embedding APIs.
22#[derive(Clone, Debug, PartialEq)]
23pub enum ScriptValue {
24    /// String parameter.
25    String(String),
26    /// Boolean parameter.
27    Bool(bool),
28    /// Integer parameter.
29    Int(i64),
30    /// Floating-point parameter.
31    Float(f64),
32    /// Rational parameter.
33    Rational(Rational),
34}
35
36/// One validated `--set name=value` script parameter.
37#[derive(Clone, Debug, PartialEq)]
38pub struct ScriptParameter {
39    name: String,
40    value: ScriptValue,
41}
42
43impl ScriptParameter {
44    /// Parses one `--set name=value` argument.
45    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    /// Returns parameter name.
61    #[must_use]
62    pub fn name(&self) -> &str {
63        &self.name
64    }
65
66    /// Returns parameter value.
67    #[must_use]
68    pub const fn value(&self) -> &ScriptValue {
69        &self.value
70    }
71}
72
73/// Script engine shell configured with PixelFlow logging hooks.
74#[derive(Clone, Default)]
75pub struct ScriptEngine {
76    logger: Logger,
77    filters: FilterRegistry,
78    prop_resolver: Option<Arc<dyn ScriptPropResolver>>,
79}
80
81/// Resolves one frame metadata value for script-side `prop(...)` calls.
82pub trait ScriptPropResolver: Send + Sync {
83    /// Renders or otherwise inspects `graph` and returns `key` from `frame_number`.
84    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    /// Creates a script engine using a no-op logger.
95    #[must_use]
96    pub fn new() -> Self {
97        Self::default()
98    }
99
100    /// Creates a script engine using the provided logger.
101    #[must_use]
102    pub fn with_logger(logger: Logger) -> Self {
103        Self::with_logger_and_filter_registry(logger, FilterRegistry::new())
104    }
105
106    /// Creates a script engine using the provided filter registry.
107    #[must_use]
108    pub fn with_filter_registry(filters: FilterRegistry) -> Self {
109        Self::with_logger_and_filter_registry(Logger::default(), filters)
110    }
111
112    /// Creates a script engine using the provided logger and filter registry.
113    #[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    /// Returns this script engine with replacement filter registry.
123    #[must_use]
124    pub fn with_filters(mut self, filters: FilterRegistry) -> Self {
125        self.filters = filters;
126        self
127    }
128
129    /// Returns this script engine with a runtime metadata resolver for `prop(...)` calls.
130    #[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    /// Evaluates script source into a graph handle.
137    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/// Graph handle returned by script evaluation.
191#[derive(Clone, Debug, PartialEq)]
192pub struct ScriptGraph {
193    graph: Graph,
194    metadata_schema: MetadataSchema,
195}
196
197impl ScriptGraph {
198    /// Returns constructed graph.
199    #[must_use]
200    pub const fn graph(&self) -> &Graph {
201        &self.graph
202    }
203
204    /// Returns metadata schema active during script evaluation.
205    #[must_use]
206    pub const fn metadata_schema(&self) -> &MetadataSchema {
207        &self.metadata_schema
208    }
209
210    /// Consumes this wrapper and returns constructed graph.
211    #[must_use]
212    pub fn into_graph(self) -> Graph {
213        self.graph
214    }
215
216    /// Consumes this wrapper and returns constructed graph plus active metadata schema.
217    #[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(&register_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, &params)
2750            .expect("params should inject");
2751        graph
2752            .graph()
2753            .validation_plan()
2754            .expect("parameterized graph should have validation plan");
2755    }
2756}