#![warn(missing_docs)]
#![forbid(unsafe_code)]
use std::cell::RefCell;
use std::collections::{BTreeMap, BTreeSet};
use std::rc::Rc;
use std::sync::Arc;
use semisafe::slice::get as semisafe_get;
use pixelflow_core::{
Clip, ClipFormat, ClipMedia, ClipResolution, ErrorCategory, ErrorCode, FilterOptionValue,
FilterOptions, FilterRegistry, FrameCount, Graph, GraphBuilder, Logger, MetadataKind,
MetadataSchema, MetadataValue, NodeId, PixelFlowError, Rational, Result, SourceOptionValue,
SourceRequest,
};
use rhai::{Array, Dynamic, Engine, EvalAltResult, Map, ParseError, Position, Scope};
#[derive(Clone, Debug, PartialEq)]
pub enum ScriptValue {
String(String),
Bool(bool),
Int(i64),
Float(f64),
Rational(Rational),
}
#[derive(Clone, Debug, PartialEq)]
pub struct ScriptParameter {
name: String,
value: ScriptValue,
}
impl ScriptParameter {
pub fn parse_set(argument: &str) -> Result<Self> {
let Some((name, raw_value)) = argument.split_once('=') else {
return invalid_parameter("script parameter must use name=value syntax");
};
if !is_script_identifier(name) {
return invalid_parameter(format!("invalid script parameter name '{name}'"));
}
Ok(Self {
name: name.to_owned(),
value: parse_script_value(raw_value)?,
})
}
#[must_use]
pub fn name(&self) -> &str {
&self.name
}
#[must_use]
pub const fn value(&self) -> &ScriptValue {
&self.value
}
}
#[derive(Clone, Default)]
pub struct ScriptEngine {
logger: Logger,
filters: FilterRegistry,
prop_resolver: Option<Arc<dyn ScriptPropResolver>>,
}
pub trait ScriptPropResolver: Send + Sync {
fn resolve_prop(
&self,
graph: Graph,
metadata_schema: MetadataSchema,
frame_number: usize,
key: &str,
) -> Result<MetadataValue>;
}
impl ScriptEngine {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn with_logger(logger: Logger) -> Self {
Self::with_logger_and_filter_registry(logger, FilterRegistry::new())
}
#[must_use]
pub fn with_filter_registry(filters: FilterRegistry) -> Self {
Self::with_logger_and_filter_registry(Logger::default(), filters)
}
#[must_use]
pub const fn with_logger_and_filter_registry(logger: Logger, filters: FilterRegistry) -> Self {
Self {
logger,
filters,
prop_resolver: None,
}
}
#[must_use]
pub fn with_filters(mut self, filters: FilterRegistry) -> Self {
self.filters = filters;
self
}
#[must_use]
pub fn with_prop_resolver(mut self, resolver: Arc<dyn ScriptPropResolver>) -> Self {
self.prop_resolver = Some(resolver);
self
}
pub fn evaluate(&self, source: &str, parameters: &[ScriptParameter]) -> Result<ScriptGraph> {
evaluate_script(
&self.logger,
&self.filters,
self.prop_resolver.clone(),
source,
parameters,
)
}
}
#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
struct PropCacheKey {
node_id: NodeId,
frame_number: usize,
key: String,
}
#[derive(Clone, Default)]
struct ScriptGraphState {
builder: GraphBuilder,
media: Vec<ClipMedia>,
filters: FilterRegistry,
prop_resolver: Option<Arc<dyn ScriptPropResolver>>,
prop_cache: BTreeMap<PropCacheKey, MetadataValue>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
struct ScriptBlob {
bytes: Arc<[u8]>,
}
impl ScriptBlob {
fn into_arc_bytes(self) -> Arc<[u8]> {
self.bytes
}
}
impl ScriptGraphState {
fn media_for(&self, clip: Clip) -> Result<ClipMedia> {
self.media
.get(clip.node_id().index())
.cloned()
.ok_or_else(|| {
PixelFlowError::new(
ErrorCategory::Graph,
ErrorCode::new("graph.invalid_clip"),
format!("clip references missing node {}", clip.node_id().index()),
)
})
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct ScriptGraph {
graph: Graph,
metadata_schema: MetadataSchema,
}
impl ScriptGraph {
#[must_use]
pub const fn graph(&self) -> &Graph {
&self.graph
}
#[must_use]
pub const fn metadata_schema(&self) -> &MetadataSchema {
&self.metadata_schema
}
#[must_use]
pub fn into_graph(self) -> Graph {
self.graph
}
#[must_use]
pub fn into_parts(self) -> (Graph, MetadataSchema) {
(self.graph, self.metadata_schema)
}
}
fn evaluate_script(
logger: &Logger,
filters: &FilterRegistry,
prop_resolver: Option<Arc<dyn ScriptPropResolver>>,
source: &str,
parameters: &[ScriptParameter],
) -> Result<ScriptGraph> {
if source.trim().is_empty() {
return Err(PixelFlowError::new(
ErrorCategory::Script,
ErrorCode::new("script.empty"),
"script source is empty",
));
}
let state = Rc::new(RefCell::new(ScriptGraphState {
filters: filters.clone(),
prop_resolver,
..ScriptGraphState::default()
}));
let engine = build_engine(state.clone());
let mut scope = Scope::new();
let source = normalize_source(source);
let source = rewrite_filter_syntax(&source, filters)?;
if count_output_assignments(&source) > 1 {
return Err(PixelFlowError::new(
ErrorCategory::Graph,
ErrorCode::new("graph.multiple_outputs"),
"script assigns final output more than once",
));
}
push_parameters(&mut scope, parameters);
declare_assigned_variables(&mut scope, &source);
let ast = engine
.compile_with_scope(&scope, &source)
.map_err(|error| parse_error(&error))?;
engine
.run_ast_with_scope(&mut scope, &ast)
.map_err(|error| eval_error(&error))?;
let output = scope.get_value::<Clip>("output").ok_or_else(|| {
if scope.contains("output") {
PixelFlowError::new(
ErrorCategory::Graph,
ErrorCode::new("graph.invalid_output"),
"script final output must be a clip",
)
} else {
PixelFlowError::new(
ErrorCategory::Graph,
ErrorCode::new("graph.missing_output"),
"script does not assign final output",
)
}
})?;
let (graph, metadata_schema) = {
let mut state = state.borrow_mut();
state.builder.set_output(output);
(
state.builder.clone().build(),
state.filters.metadata_schema().clone(),
)
};
logger.log(
pixelflow_core::LogLevel::Debug,
"pixelflow_script",
"script graph constructed",
);
Ok(ScriptGraph {
graph,
metadata_schema,
})
}
fn build_engine(state: Rc<RefCell<ScriptGraphState>>) -> Engine {
let mut engine = Engine::new_raw();
engine.set_max_operations(50_000);
engine.set_max_call_levels(32);
engine.set_max_variables(256);
engine.set_max_functions(64);
engine.set_max_modules(0);
engine.set_max_string_size(1_048_576);
engine.set_max_array_size(4096);
engine.set_max_map_size(4096);
engine.set_strict_variables(true);
engine.set_fail_on_invalid_map_property(true);
engine.register_type_with_name::<Clip>("Clip");
engine.register_type_with_name::<Rational>("Rational");
engine.register_type_with_name::<ScriptBlob>("Blob");
engine.register_fn("none", || Dynamic::UNIT);
engine.register_fn("is_none", |value: Dynamic| value.is_unit());
engine.register_fn(
"blob",
|values: Array| -> std::result::Result<ScriptBlob, Box<EvalAltResult>> {
script_blob(values).map_err(to_eval_error)
},
);
register_graph_api(&mut engine, state);
engine
}
fn register_graph_api(engine: &mut Engine, state: Rc<RefCell<ScriptGraphState>>) {
let register_state = state.clone();
engine.register_fn(
"register_prop",
move |key: &str, kind: &str| -> std::result::Result<(), Box<EvalAltResult>> {
register_prop(®ister_state, key, kind).map_err(to_eval_error)
},
);
let prop_state = state.clone();
engine.register_fn(
"prop",
move |clip: Clip, key: &str| -> std::result::Result<Dynamic, Box<EvalAltResult>> {
resolve_script_prop(&prop_state, clip, 0, key).map_err(to_eval_error)
},
);
let prop_state = state.clone();
engine.register_fn(
"prop",
move |clip: Clip,
frame_number: i64,
key: &str|
-> std::result::Result<Dynamic, Box<EvalAltResult>> {
let frame_number = usize::try_from(frame_number).map_err(|_| {
to_eval_error(invalid_argument_error(
"prop frame number must be non-negative".to_owned(),
))
})?;
resolve_script_prop(&prop_state, clip, frame_number, key).map_err(to_eval_error)
},
);
let source_state = state.clone();
engine.register_fn(
"source",
move |path: &str| -> std::result::Result<Clip, Box<EvalAltResult>> {
source_from_options(&source_state, path, &Map::new()).map_err(to_eval_error)
},
);
let source_state = state.clone();
engine.register_fn(
"source",
move |path: &str, options: Map| -> std::result::Result<Clip, Box<EvalAltResult>> {
source_from_options(&source_state, path, &options).map_err(to_eval_error)
},
);
let filter_state = state.clone();
engine.register_fn(
"filter",
move |clip: Clip,
name: &str,
options: Map|
-> std::result::Result<Clip, Box<EvalAltResult>> {
filter_from_options(&filter_state, clip, name, &options).map_err(to_eval_error)
},
);
let filter_state = state.clone();
engine.register_fn(
"filter",
move |clip: Clip, name: &str| -> std::result::Result<Clip, Box<EvalAltResult>> {
filter_from_options(&filter_state, clip, name, &Map::new()).map_err(to_eval_error)
},
);
let filter_state = state.clone();
engine.register_fn(
"filter",
move |clips: Array,
name: &str,
options: Map|
-> std::result::Result<Clip, Box<EvalAltResult>> {
filter_array_from_options(&filter_state, clips, name, &options).map_err(to_eval_error)
},
);
engine.register_fn(
"filter",
move |clips: Array, name: &str| -> std::result::Result<Clip, Box<EvalAltResult>> {
filter_array_from_options(&state, clips, name, &Map::new()).map_err(to_eval_error)
},
);
}
fn register_prop(state: &Rc<RefCell<ScriptGraphState>>, key: &str, kind: &str) -> Result<()> {
state
.borrow_mut()
.filters
.register_metadata_key(key, parse_metadata_kind(kind)?)
}
fn resolve_script_prop(
state: &Rc<RefCell<ScriptGraphState>>,
clip: Clip,
frame_number: usize,
key: &str,
) -> Result<Dynamic> {
let cache_key = PropCacheKey {
node_id: clip.node_id(),
frame_number,
key: key.to_owned(),
};
let (resolver, graph, metadata_schema) = {
let state_ref = state.borrow_mut();
if let Some(value) = state_ref.prop_cache.get(&cache_key).cloned() {
return Ok(metadata_value_to_dynamic(value));
}
if !state_ref.filters.metadata_schema().contains_key(key) {
return Err(PixelFlowError::new(
ErrorCategory::Plugin,
ErrorCode::new("metadata.unregistered_key"),
format!("metadata key '{key}' is not registered"),
));
}
let resolver = state_ref.prop_resolver.clone().ok_or_else(|| {
PixelFlowError::new(
ErrorCategory::Script,
ErrorCode::new("script.prop_unavailable"),
"script prop retrieval requires a runtime resolver",
)
})?;
let mut builder = state_ref.builder.clone();
builder.set_output(clip);
(
resolver,
builder.build(),
state_ref.filters.metadata_schema().clone(),
)
};
let value = resolver.resolve_prop(graph, metadata_schema, frame_number, key)?;
state
.borrow_mut()
.prop_cache
.insert(cache_key, value.clone());
Ok(metadata_value_to_dynamic(value))
}
fn metadata_value_to_dynamic(value: MetadataValue) -> Dynamic {
match value {
MetadataValue::None => Dynamic::UNIT,
MetadataValue::Bool(value) => Dynamic::from(value),
MetadataValue::Int(value) => Dynamic::from(value),
MetadataValue::Float(value) => Dynamic::from(value),
MetadataValue::String(value) => Dynamic::from(value),
MetadataValue::Array(values) => {
Dynamic::from_array(values.into_iter().map(metadata_value_to_dynamic).collect())
}
MetadataValue::Rational(value) => Dynamic::from(value),
MetadataValue::Blob(value) => Dynamic::from(ScriptBlob { bytes: value }),
}
}
fn script_blob(values: Array) -> Result<ScriptBlob> {
let mut bytes = Vec::with_capacity(values.len());
for (index, value) in values.into_iter().enumerate() {
let Ok(byte) = value.as_int() else {
return invalid_argument(format!(
"blob byte at index {index} must be between 0 and 255"
));
};
let Ok(byte) = u8::try_from(byte) else {
return invalid_argument(format!(
"blob byte at index {index} must be between 0 and 255"
));
};
bytes.push(byte);
}
Ok(ScriptBlob {
bytes: bytes.into(),
})
}
fn parse_metadata_kind(kind: &str) -> Result<MetadataKind> {
match kind {
"bool" => Ok(MetadataKind::Bool),
"int" => Ok(MetadataKind::Int),
"float" => Ok(MetadataKind::Float),
"string" => Ok(MetadataKind::String),
"array" => Ok(MetadataKind::Array),
"rational" => Ok(MetadataKind::Rational),
"blob" => Ok(MetadataKind::Blob),
_ => invalid_argument(format!(
"metadata kind '{kind}' must be bool, int, float, string, array, rational, or blob"
)),
}
}
fn source_from_options(
state: &Rc<RefCell<ScriptGraphState>>,
path: &str,
options: &Map,
) -> Result<Clip> {
let mut request = SourceRequest::new(path);
for (name, value) in options {
request =
request.try_with_option(name.as_str(), source_option_value(name.as_str(), value)?)?;
}
let media = ClipMedia::new(
ClipFormat::Fixed(pixelflow_core::resolve_format_alias("yuv420p8")?),
ClipResolution::Fixed {
width: 1,
height: 1,
},
FrameCount::Unknown,
pixelflow_core::FrameRate::Unknown,
);
let mut state = state.borrow_mut();
let clip = state.builder.source_with_request(request, media.clone());
state.media.push(media);
Ok(clip)
}
fn source_option_value(name: &str, value: &Dynamic) -> Result<SourceOptionValue> {
if value.is::<Rational>() {
return Ok(SourceOptionValue::Rational(value.clone_cast::<Rational>()));
}
if let Ok(string) = value.as_immutable_string_ref() {
if name == "fps" {
return parse_argument_rational(string.as_str()).map(SourceOptionValue::Rational);
}
return Ok(SourceOptionValue::String(string.as_str().to_owned()));
}
if let Ok(boolean) = value.as_bool() {
return Ok(SourceOptionValue::Bool(boolean));
}
if let Ok(integer) = value.as_int() {
return Ok(SourceOptionValue::Int(integer));
}
invalid_argument(format!(
"source option '{name}' must be string, bool, integer, or rational"
))
}
fn filter_from_options(
state: &Rc<RefCell<ScriptGraphState>>,
clip: Clip,
name: &str,
options: &Map,
) -> Result<Clip> {
filter_clips_from_options(state, &[clip], name, options)
}
fn filter_array_from_options(
state: &Rc<RefCell<ScriptGraphState>>,
clips: Array,
name: &str,
options: &Map,
) -> Result<Clip> {
let mut parsed = Vec::with_capacity(clips.len());
for (index, value) in clips.into_iter().enumerate() {
if !value.is::<Clip>() {
return invalid_argument(format!("filter input {index} must be Clip"));
}
parsed.push(value.clone_cast::<Clip>());
}
filter_clips_from_options(state, &parsed, name, options)
}
fn filter_clips_from_options(
state: &Rc<RefCell<ScriptGraphState>>,
clips: &[Clip],
name: &str,
options: &Map,
) -> Result<Clip> {
if !is_filter_name(name) {
return invalid_argument(format!("invalid filter name '{name}'"));
}
let options = filter_options(options)?;
let plan = {
let state = state.borrow();
let input_media: Vec<_> = clips
.iter()
.map(|clip| state.media_for(*clip))
.collect::<Result<_>>()?;
state.filters.plan_filter(name, &input_media, &options)?
};
let (media, compatibility, dependencies, concurrency) = plan.into_parts();
let mut state = state.borrow_mut();
let output = state.builder.filter_with_schedule_and_options(
name,
clips,
media.clone(),
compatibility,
dependencies,
concurrency,
options,
)?;
state.media.push(media);
Ok(output)
}
fn filter_options(options: &Map) -> Result<FilterOptions> {
let mut converted = FilterOptions::new();
for (name, value) in options {
if !is_script_identifier(name.as_str()) {
return invalid_argument(format!("invalid filter option name '{name}'"));
}
converted.insert(name.to_string(), filter_option_value(name.as_str(), value)?);
}
Ok(converted)
}
fn filter_option_value(name: &str, value: &Dynamic) -> Result<FilterOptionValue> {
if value.is_unit() {
return Ok(FilterOptionValue::None);
}
if value.is::<ScriptBlob>() {
return Ok(FilterOptionValue::Blob(
value.clone_cast::<ScriptBlob>().into_arc_bytes(),
));
}
if value.is::<Array>() {
let values = value.clone_cast::<Array>();
let mut converted = Vec::with_capacity(values.len());
for entry in values {
converted.push(filter_option_value(name, &entry)?);
}
return Ok(FilterOptionValue::Array(converted));
}
if value.is::<Rational>() {
return Ok(FilterOptionValue::Rational(value.clone_cast::<Rational>()));
}
if let Ok(string) = value.as_immutable_string_ref() {
return Ok(FilterOptionValue::String(string.as_str().to_owned()));
}
if let Ok(boolean) = value.as_bool() {
return Ok(FilterOptionValue::Bool(boolean));
}
if let Ok(integer) = value.as_int() {
return Ok(FilterOptionValue::Int(integer));
}
if let Ok(float) = value.as_float() {
if float.is_finite() {
return Ok(FilterOptionValue::Float(float));
}
return invalid_argument(format!("filter option '{name}' float must be finite"));
}
invalid_argument(format!(
"filter option '{name}' must be none, string, bool, integer, float, array, rational, or blob"
))
}
fn push_parameters(scope: &mut Scope<'_>, parameters: &[ScriptParameter]) {
for parameter in parameters {
match parameter.value() {
ScriptValue::String(value) => {
scope.push(parameter.name(), value.clone());
}
ScriptValue::Bool(value) => {
scope.push(parameter.name(), *value);
}
ScriptValue::Int(value) => {
scope.push(parameter.name(), *value);
}
ScriptValue::Float(value) => {
scope.push(parameter.name(), *value);
}
ScriptValue::Rational(value) => {
scope.push(parameter.name(), *value);
}
}
}
}
fn declare_assigned_variables(scope: &mut Scope<'_>, source: &str) {
for line in source.lines() {
let trimmed = line.trim_start();
let Some((name, _)) = trimmed.split_once('=') else {
continue;
};
let name = name.trim();
if is_script_identifier(name) && !scope.contains(name) {
scope.push_dynamic(name, Dynamic::UNIT);
}
}
}
fn normalize_source(source: &str) -> String {
let mut normalized = String::new();
let mut nesting = 0usize;
for line in source.lines() {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
if normalized.is_empty() {
normalized.push_str(trimmed);
update_nesting(&mut nesting, trimmed);
continue;
}
if trimmed.starts_with('.') || nesting > 0 {
normalized.push(' ');
normalized.push_str(trimmed);
} else {
if !normalized.ends_with(';') {
normalized.push(';');
}
normalized.push('\n');
normalized.push_str(trimmed);
}
update_nesting(&mut nesting, trimmed);
}
if !normalized.is_empty() && !normalized.ends_with(';') {
normalized.push(';');
}
normalized
}
#[derive(Clone, Copy, Debug, Default)]
struct SourceScanState {
in_string: bool,
escaped: bool,
line_comment: bool,
block_comment_depth: usize,
nesting: usize,
}
fn rewrite_filter_syntax(source: &str, filters: &FilterRegistry) -> Result<String> {
let mut known_clip_variables = BTreeSet::new();
let mut rewritten = String::with_capacity(source.len());
let mut state = SourceScanState::default();
let mut index = 0usize;
let mut statement_start = 0usize;
while index < source.len() {
if is_code_position(state)
&& state.nesting == 0
&& *semisafe_get(source.as_bytes(), index) == b';'
{
rewritten.push_str(&rewrite_filter_syntax_in_statement(
slice_range(source, statement_start, index),
&mut known_clip_variables,
filters,
)?);
rewritten.push(';');
statement_start = index + 1;
index += 1;
continue;
}
index = advance_scan_state(source, index, &mut state);
}
rewritten.push_str(&rewrite_filter_syntax_in_statement(
slice_from(source, statement_start),
&mut known_clip_variables,
filters,
)?);
Ok(rewritten)
}
fn rewrite_filter_syntax_in_statement(
statement: &str,
known_clip_variables: &mut BTreeSet<String>,
filters: &FilterRegistry,
) -> Result<String> {
let Some((name_start, name_end, rhs_start)) = top_level_assignment_at(statement) else {
return rewrite_namespaced_filter_functions(statement, filters);
};
let name = slice_range(statement, name_start, name_end);
let rhs = rewrite_namespaced_filter_functions(slice_from(statement, rhs_start), filters)?;
let Some(rewritten_rhs) = rewrite_clip_chain_rhs(&rhs, known_clip_variables, filters)? else {
known_clip_variables.remove(name);
let mut rewritten = String::with_capacity(statement.len());
rewritten.push_str(slice_range(statement, 0, rhs_start));
rewritten.push_str(&rhs);
return Ok(rewritten);
};
known_clip_variables.insert(name.to_owned());
let mut rewritten = String::with_capacity(statement.len());
rewritten.push_str(slice_range(statement, 0, rhs_start));
rewritten.push_str(&rewritten_rhs);
Ok(rewritten)
}
fn top_level_assignment_at(source: &str) -> Option<(usize, usize, usize)> {
let name_start = skip_trivia(source, 0);
let name_end = identifier_end_at(source, name_start)?;
let operator_index = skip_trivia(source, name_end);
let bytes = source.as_bytes();
if !starts_with_token(bytes, operator_index, b"=")
|| starts_with_token(bytes, operator_index, b"==")
{
return None;
}
Some((name_start, name_end, operator_index + 1))
}
fn rewrite_clip_chain_rhs(
rhs: &str,
known_clip_variables: &BTreeSet<String>,
filters: &FilterRegistry,
) -> Result<Option<String>> {
let Some(mut chain_end) = clip_root_end(rhs, skip_trivia(rhs, 0), known_clip_variables) else {
return Ok(None);
};
let mut rewritten = String::with_capacity(rhs.len());
let mut segment_start = 0usize;
loop {
let method_start = skip_trivia(rhs, chain_end);
if !starts_with_token(rhs.as_bytes(), method_start, b".") {
break;
}
let Some(method_call) = method_call_at(rhs, method_start) else {
break;
};
if method_call.segments.as_slice() == ["prop"] {
break;
}
if method_call.segments.as_slice() != ["filter"] {
let filter_name = resolve_method_filter_name(filters, &method_call.segments)?;
rewritten.push_str(slice_range(rhs, segment_start, method_start));
rewritten.push_str(".filter(\"");
rewritten.push_str(filter_name);
rewritten.push('"');
if method_call.has_arguments {
rewritten.push_str(", ");
}
segment_start = method_call.after_open_paren;
}
chain_end = method_call.end;
}
rewritten.push_str(slice_from(rhs, segment_start));
Ok(Some(rewritten))
}
fn clip_root_end(
source: &str,
index: usize,
known_clip_variables: &BTreeSet<String>,
) -> Option<usize> {
let name_end = identifier_end_at(source, index)?;
let name = slice_range(source, index, name_end);
if name == "source" || name == "filter" {
return call_expression_end(source, name_end);
}
if known_clip_variables.contains(name) {
return Some(name_end);
}
None
}
fn identifier_end_at(source: &str, index: usize) -> Option<usize> {
let bytes = source.as_bytes();
let first = *bytes.get(index)?;
if !(first.is_ascii_alphabetic() || first == b'_') {
return None;
}
let mut name_end = index + 1;
while let Some(byte) = bytes.get(name_end) {
if byte.is_ascii_alphanumeric() || *byte == b'_' {
name_end += 1;
} else {
break;
}
}
Some(name_end)
}
fn call_expression_end(source: &str, name_end: usize) -> Option<usize> {
let open_paren = skip_whitespace(source, name_end);
if !starts_with_token(source.as_bytes(), open_paren, b"(") {
return None;
}
matching_paren_end(source, open_paren)
}
struct MethodCall<'a> {
after_open_paren: usize,
end: usize,
segments: Vec<&'a str>,
has_arguments: bool,
}
fn method_call_at(source: &str, index: usize) -> Option<MethodCall<'_>> {
let call = call_path_at(source, index + 1)?;
Some(MethodCall {
after_open_paren: call.after_open_paren,
end: call.end,
segments: call.segments,
has_arguments: call.has_arguments,
})
}
struct CallPath<'a> {
after_open_paren: usize,
close_paren: usize,
end: usize,
segments: Vec<&'a str>,
has_arguments: bool,
}
fn call_path_at(source: &str, start: usize) -> Option<CallPath<'_>> {
let bytes = source.as_bytes();
let mut cursor = start;
let mut segments = Vec::new();
loop {
let segment_end = identifier_end_at(source, cursor)?;
segments.push(slice_range(source, cursor, segment_end));
let next = skip_whitespace(source, segment_end);
if starts_with_token(bytes, next, b".") {
cursor = skip_whitespace(source, next + 1);
continue;
}
if !starts_with_token(bytes, next, b"(") {
return None;
}
let end = matching_paren_end(source, next)?;
let after_open_paren = next + 1;
let has_arguments = !starts_with_token(bytes, skip_trivia(source, after_open_paren), b")");
return Some(CallPath {
after_open_paren,
close_paren: end.saturating_sub(1),
end,
segments,
has_arguments,
});
}
}
fn namespaced_filter_call_at(source: &str, index: usize) -> Option<CallPath<'_>> {
if !can_start_namespaced_call(source, index) {
return None;
}
let call = call_path_at(source, index)?;
match call.segments.as_slice() {
["std", _] => Some(call),
["plugin", _, rest @ ..] if !rest.is_empty() => Some(call),
_ => None,
}
}
fn can_start_namespaced_call(source: &str, index: usize) -> bool {
if index == 0 {
return true;
}
!matches!(
source.as_bytes().get(index - 1),
Some(byte) if byte.is_ascii_alphanumeric() || *byte == b'_' || *byte == b'.'
)
}
fn rewrite_namespaced_filter_functions(source: &str, filters: &FilterRegistry) -> Result<String> {
let mut rewritten = String::with_capacity(source.len());
let mut state = SourceScanState::default();
let mut index = 0usize;
let mut segment_start = 0usize;
while index < source.len() {
if is_code_position(state)
&& let Some(call) = namespaced_filter_call_at(source, index)
{
let filter_name = resolve_function_filter_name(filters, &call.segments)?;
let Some(first_arg_end) =
first_argument_end(source, call.after_open_paren, call.close_paren)
else {
return invalid_argument(format!(
"namespaced filter call '{}' requires an input clip or clip array as first argument",
call.segments.join(".")
));
};
rewritten.push_str(slice_range(source, segment_start, index));
rewritten.push_str("filter(");
rewritten.push_str(slice_range(source, call.after_open_paren, first_arg_end).trim());
rewritten.push_str(", \"");
rewritten.push_str(filter_name);
rewritten.push('"');
let rest = slice_range(source, first_arg_end, call.close_paren).trim_start();
if let Some(rest) = rest.strip_prefix(',') {
rewritten.push_str(", ");
rewritten.push_str(rest.trim_start());
}
rewritten.push(')');
segment_start = call.end;
index = call.end;
continue;
}
index = advance_scan_state(source, index, &mut state);
}
rewritten.push_str(slice_from(source, segment_start));
Ok(rewritten)
}
fn resolve_function_filter_name<'a>(
filters: &'a FilterRegistry,
segments: &[&'a str],
) -> Result<&'a str> {
match segments {
["std", name] => filters.resolve_filter_name_for_plugin_call("std", name),
["plugin", plugin, rest @ ..] if !rest.is_empty() => {
let name = rest.join(".");
filters.resolve_filter_name_for_plugin_call(plugin, &name)
}
_ => invalid_argument(format!(
"invalid filter namespace '{}'; use std.<filter>(...) or plugin.<namespace>.<filter>(...)",
segments.join(".")
)),
}
}
fn resolve_method_filter_name<'a>(
filters: &'a FilterRegistry,
segments: &[&'a str],
) -> Result<&'a str> {
match segments {
[name] => Ok(*name),
["std", name] => filters.resolve_filter_name_for_plugin_call("std", name),
["plugin", plugin, rest @ ..] if !rest.is_empty() => {
let name = rest.join(".");
filters.resolve_filter_name_for_plugin_call(plugin, &name)
}
_ => invalid_argument(format!(
"invalid filter method namespace '{}'; use .filter(...), .std.<filter>(...), or .plugin.<namespace>.<filter>(...)",
segments.join(".")
)),
}
}
fn first_argument_end(source: &str, start: usize, close_paren: usize) -> Option<usize> {
let mut index = skip_trivia(source, start);
if index >= close_paren {
return None;
}
let mut state = SourceScanState::default();
while index < close_paren {
if is_code_position(state)
&& state.nesting == 0
&& *semisafe_get(source.as_bytes(), index) == b','
{
return Some(index);
}
index = advance_scan_state(source, index, &mut state);
}
Some(close_paren)
}
fn matching_paren_end(source: &str, open_paren: usize) -> Option<usize> {
let mut state = SourceScanState::default();
let mut index = open_paren;
while index < source.len() {
index = advance_scan_state(source, index, &mut state);
if state.nesting == 0 {
return Some(index);
}
}
None
}
fn advance_scan_state(source: &str, index: usize, state: &mut SourceScanState) -> usize {
let bytes = source.as_bytes();
let current_byte = *semisafe_get(bytes, index);
let step = next_char_len(source, index);
if state.line_comment {
if current_byte == b'\n' {
state.line_comment = false;
}
return index + step;
}
if state.block_comment_depth > 0 {
if starts_with_token(bytes, index, b"/*") {
state.block_comment_depth += 1;
return index + 2;
}
if starts_with_token(bytes, index, b"*/") {
state.block_comment_depth -= 1;
return index + 2;
}
return index + step;
}
if state.in_string {
if state.escaped {
state.escaped = false;
} else if current_byte == b'\\' {
state.escaped = true;
} else if current_byte == b'"' {
state.in_string = false;
}
return index + step;
}
if starts_with_token(bytes, index, b"//") {
state.line_comment = true;
return index + 2;
}
if starts_with_token(bytes, index, b"/*") {
state.block_comment_depth = 1;
return index + 2;
}
match current_byte {
b'"' => state.in_string = true,
b'(' | b'[' | b'{' => state.nesting += 1,
b')' | b']' | b'}' => state.nesting = state.nesting.saturating_sub(1),
_ => {}
}
index + step
}
fn skip_trivia(source: &str, mut index: usize) -> usize {
let bytes = source.as_bytes();
while index < source.len() {
if starts_with_token(bytes, index, b"//") {
index += 2;
while index < source.len() && *semisafe_get(bytes, index) != b'\n' {
index += next_char_len(source, index);
}
continue;
}
if starts_with_token(bytes, index, b"/*") {
let mut depth = 1usize;
index += 2;
while index < source.len() && depth > 0 {
if starts_with_token(bytes, index, b"/*") {
depth += 1;
index += 2;
} else if starts_with_token(bytes, index, b"*/") {
depth -= 1;
index += 2;
} else {
index += next_char_len(source, index);
}
}
continue;
}
let ch = slice_from(source, index)
.chars()
.next()
.expect("index should stay on char boundary");
if ch.is_whitespace() {
index += ch.len_utf8();
continue;
}
break;
}
index
}
fn skip_whitespace(source: &str, mut index: usize) -> usize {
while index < source.len() {
let ch = slice_from(source, index)
.chars()
.next()
.expect("index should stay on char boundary");
if ch.is_whitespace() {
index += ch.len_utf8();
continue;
}
break;
}
index
}
fn next_char_len(source: &str, index: usize) -> usize {
slice_from(source, index)
.chars()
.next()
.expect("index should stay on char boundary")
.len_utf8()
}
fn slice_range(source: &str, start: usize, end: usize) -> &str {
source
.get(start..end)
.expect("range should stay on char boundary")
}
fn slice_from(source: &str, index: usize) -> &str {
source
.get(index..)
.expect("index should stay on char boundary")
}
fn starts_with_token(bytes: &[u8], index: usize, token: &[u8]) -> bool {
bytes
.get(index..index.saturating_add(token.len()))
.is_some_and(|slice| slice == token)
}
const fn is_code_position(state: SourceScanState) -> bool {
!state.in_string && !state.line_comment && state.block_comment_depth == 0
}
fn update_nesting(nesting: &mut usize, line: &str) {
let mut in_string = false;
let mut escaped = false;
for ch in line.chars() {
if in_string {
if escaped {
escaped = false;
continue;
}
match ch {
'\\' => escaped = true,
'"' => in_string = false,
_ => {}
}
continue;
}
match ch {
'"' => in_string = true,
'(' | '[' | '{' => *nesting += 1,
')' | ']' | '}' => *nesting = nesting.saturating_sub(1),
_ => {}
}
}
}
fn count_output_assignments(source: &str) -> usize {
let mut count = 0usize;
let mut state = SourceScanState::default();
let mut index = 0usize;
let mut statement_start = true;
while index < source.len() {
if statement_start && is_code_position(state) && state.nesting == 0 {
index = skip_trivia(source, index);
if index >= source.len() {
break;
}
}
if is_code_position(state) {
let byte = *semisafe_get(source.as_bytes(), index);
if state.nesting == 0 && byte == b';' {
statement_start = true;
index += 1;
continue;
}
if state.nesting == 0 && statement_start {
if let Some(next_index) = output_assignment_at(source, index) {
count += 1;
index = next_index;
}
statement_start = false;
continue;
}
}
index = advance_scan_state(source, index, &mut state);
}
count
}
fn output_assignment_at(source: &str, index: usize) -> Option<usize> {
let bytes = source.as_bytes();
if !starts_with_token(bytes, index, b"output") {
return None;
}
let name_end = index + "output".len();
if bytes
.get(name_end)
.is_some_and(|byte| byte.is_ascii_alphanumeric() || *byte == b'_')
{
return None;
}
let operator_index = skip_trivia(source, name_end);
if !starts_with_token(bytes, operator_index, b"=")
|| starts_with_token(bytes, operator_index, b"==")
{
return None;
}
Some(operator_index + 1)
}
fn parse_script_value(raw: &str) -> Result<ScriptValue> {
if raw == "true" {
return Ok(ScriptValue::Bool(true));
}
if raw == "false" {
return Ok(ScriptValue::Bool(false));
}
if looks_like_rational_literal(raw) {
return parse_parameter_rational(raw).map(ScriptValue::Rational);
}
if let Ok(value) = raw.parse::<i64>() {
return Ok(ScriptValue::Int(value));
}
if raw.contains('.') || raw.contains('e') || raw.contains('E') {
match raw.parse::<f64>() {
Ok(value) if value.is_finite() => return Ok(ScriptValue::Float(value)),
Ok(_) => return invalid_parameter("float parameter must be finite"),
Err(_) => {}
}
}
Ok(ScriptValue::String(raw.to_owned()))
}
fn parse_parameter_rational(raw: &str) -> Result<Rational> {
parse_rational(raw, invalid_parameter_error)
}
fn parse_argument_rational(raw: &str) -> Result<Rational> {
parse_rational(raw, invalid_argument_error)
}
fn parse_rational(raw: &str, make_error: fn(String) -> PixelFlowError) -> Result<Rational> {
let Some((numerator, denominator)) = raw.split_once('/') else {
return Err(make_error(
"rational must use numerator/denominator syntax".to_owned(),
));
};
let numerator = numerator
.parse::<i64>()
.map_err(|_| make_error("invalid rational numerator".to_owned()))?;
let denominator = denominator
.parse::<i64>()
.map_err(|_| make_error("invalid rational denominator".to_owned()))?;
if denominator == 0 {
return Err(make_error(
"rational denominator must not be zero".to_owned(),
));
}
Ok(Rational {
numerator,
denominator,
})
}
fn looks_like_rational_literal(raw: &str) -> bool {
let Some((numerator, denominator)) = raw.split_once('/') else {
return false;
};
numerator.parse::<i64>().is_ok() && denominator.parse::<i64>().is_ok()
}
fn is_script_identifier(name: &str) -> bool {
let mut bytes = name.bytes();
matches!(bytes.next(), Some(first) if first.is_ascii_alphabetic() || first == b'_')
&& bytes.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
}
fn is_filter_name(name: &str) -> bool {
!name.is_empty() && name.split('.').all(is_script_identifier)
}
fn parse_error(error: &ParseError) -> PixelFlowError {
PixelFlowError::new(
ErrorCategory::Script,
ErrorCode::new("script.parse"),
format!("{}: {error}", format_position(error.position())),
)
}
fn eval_error(error: &EvalAltResult) -> PixelFlowError {
if let rhai::EvalAltResult::ErrorSystem(_, inner) = error.unwrap_inner()
&& let Some(error) = inner.downcast_ref::<PixelFlowError>()
{
return PixelFlowError::new(error.category(), error.code(), error.message());
}
PixelFlowError::new(
ErrorCategory::Script,
ErrorCode::new("script.eval"),
format!("{}: {error}", format_position(error.position())),
)
}
fn format_position(position: Position) -> String {
if position.is_none() {
"unknown source position".to_owned()
} else {
format!(
"line {}, position {}",
position.line().unwrap_or(0),
position.position().unwrap_or(0)
)
}
}
#[expect(
clippy::unnecessary_box_returns,
reason = "Rhai host functions return Box<EvalAltResult>"
)]
fn to_eval_error(error: PixelFlowError) -> Box<EvalAltResult> {
Box::new(EvalAltResult::ErrorSystem(
error.to_string(),
Box::new(error),
))
}
fn invalid_parameter<T>(message: impl Into<String>) -> Result<T> {
Err(invalid_parameter_error(message))
}
fn invalid_parameter_error(message: impl Into<String>) -> PixelFlowError {
PixelFlowError::new(
ErrorCategory::Script,
ErrorCode::new("script.invalid_parameter"),
message,
)
}
fn invalid_argument<T>(message: impl Into<String>) -> Result<T> {
Err(invalid_argument_error(message))
}
fn invalid_argument_error(message: impl Into<String>) -> PixelFlowError {
PixelFlowError::new(
ErrorCategory::Script,
ErrorCode::new("script.invalid_argument"),
message,
)
}
#[cfg(test)]
mod tests {
#![expect(clippy::indexing_slicing, reason = "allow in tests")]
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use pixelflow_core::{
ClipFormat, ClipMedia, ClipResolution, ErrorCategory, ErrorCode, FilterChangeSet,
FilterCompatibility, FilterDescriptor, FilterOptionValue, FilterPlan, FilterPlanRequest,
FilterRegistry, FrameCount, FrameRate, Graph, LogLevel, LogRecord, LogSink, Logger,
Metadata, MetadataKind, MetadataValue, NodeKind, PixelFlowError, Rational,
};
use super::{ScriptEngine, ScriptParameter, ScriptValue};
#[derive(Default)]
struct FakePropResolver {
calls: AtomicUsize,
}
impl super::ScriptPropResolver for FakePropResolver {
fn resolve_prop(
&self,
graph: Graph,
_metadata_schema: super::MetadataSchema,
frame_number: usize,
key: &str,
) -> pixelflow_core::Result<MetadataValue> {
self.calls.fetch_add(1, Ordering::SeqCst);
assert_eq!(graph.outputs().len(), 1);
assert_eq!(frame_number, 3);
match key {
"core:matrix" => Ok(MetadataValue::String("bt709".to_owned())),
"core:frame_number" => Ok(MetadataValue::Int(3)),
"core:duration" => Ok(MetadataValue::Rational(Rational {
numerator: 1001,
denominator: 30000,
})),
"core:source_path" => Ok(MetadataValue::None),
_ => Err(PixelFlowError::new(
ErrorCategory::Core,
ErrorCode::new("metadata.unregistered_key"),
format!("metadata key '{key}' is not registered"),
)),
}
}
}
fn fake_filter_registry() -> FilterRegistry {
fn passthrough(request: FilterPlanRequest<'_>) -> pixelflow_core::Result<FilterPlan> {
Ok(FilterPlan::new(
request.input_media()[0].clone(),
FilterCompatibility::Preserve,
))
}
fn resize_like(request: FilterPlanRequest<'_>) -> pixelflow_core::Result<FilterPlan> {
let width = match request.options().get("width") {
Some(FilterOptionValue::Int(width)) => {
usize::try_from(*width).expect("test width fits")
}
_ => panic!("test planner expected integer width"),
};
let height = match request.options().get("height") {
Some(FilterOptionValue::Int(height)) => {
usize::try_from(*height).expect("test height fits")
}
_ => panic!("test planner expected integer height"),
};
let input = &request.input_media()[0];
let media = ClipMedia::new(
input.format().clone(),
ClipResolution::Fixed { width, height },
input.frame_count(),
input.frame_rate(),
);
Ok(FilterPlan::new(
media,
FilterCompatibility::AllowChanges(FilterChangeSet {
format: false,
resolution: true,
frame_count: false,
frame_rate: false,
}),
))
}
fn set_prop(request: FilterPlanRequest<'_>) -> pixelflow_core::Result<FilterPlan> {
assert_eq!(
request.options().get("key"),
Some(&FilterOptionValue::String("acme/filter:enabled".to_owned(),))
);
assert_eq!(
request.options().get("value"),
Some(&FilterOptionValue::Bool(true))
);
assert_eq!(
request.metadata_schema().kind("acme/filter:enabled"),
Some(MetadataKind::Bool)
);
Ok(FilterPlan::new(
request.input_media()[0].clone(),
FilterCompatibility::Preserve,
))
}
fn set_prop_array(request: FilterPlanRequest<'_>) -> pixelflow_core::Result<FilterPlan> {
assert_eq!(
request.options().get("key"),
Some(&FilterOptionValue::String("acme/filter:ratios".to_owned(),))
);
assert_eq!(
request.options().get("value"),
Some(&FilterOptionValue::Array(vec![
FilterOptionValue::Int(1),
FilterOptionValue::String("x".to_owned()),
FilterOptionValue::None,
]))
);
assert_eq!(
request.metadata_schema().kind("acme/filter:ratios"),
Some(MetadataKind::Array)
);
Ok(FilterPlan::new(
request.input_media()[0].clone(),
FilterCompatibility::Preserve,
))
}
fn set_prop_blob(request: FilterPlanRequest<'_>) -> pixelflow_core::Result<FilterPlan> {
assert_eq!(
request.options().get("key"),
Some(&FilterOptionValue::String("acme/filter:payload".to_owned(),))
);
assert_eq!(
request.options().get("value"),
Some(&FilterOptionValue::Blob(vec![0_u8, 127, 255].into()))
);
assert_eq!(
request.metadata_schema().kind("acme/filter:payload"),
Some(MetadataKind::Blob)
);
Ok(FilterPlan::new(
request.input_media()[0].clone(),
FilterCompatibility::Preserve,
))
}
fn set_prop_checked(request: FilterPlanRequest<'_>) -> pixelflow_core::Result<FilterPlan> {
let Some(FilterOptionValue::String(key)) = request.options().get("key") else {
panic!("test planner expected string key");
};
let Some(FilterOptionValue::Bool(value)) = request.options().get("value") else {
panic!("test planner expected bool value");
};
let mut metadata = Metadata::new(request.metadata_schema());
metadata.set(request.metadata_schema(), key, MetadataValue::Bool(*value))?;
Ok(FilterPlan::new(
request.input_media()[0].clone(),
FilterCompatibility::Preserve,
))
}
fn expect_bool(request: FilterPlanRequest<'_>) -> pixelflow_core::Result<FilterPlan> {
assert_eq!(
request.options().get("value"),
Some(&FilterOptionValue::Bool(true))
);
Ok(FilterPlan::new(
request.input_media()[0].clone(),
FilterCompatibility::Preserve,
))
}
fn expect_rational(request: FilterPlanRequest<'_>) -> pixelflow_core::Result<FilterPlan> {
assert_eq!(
request.options().get("value"),
Some(&FilterOptionValue::Rational(Rational {
numerator: 1001,
denominator: 30000,
}))
);
Ok(FilterPlan::new(
request.input_media()[0].clone(),
FilterCompatibility::Preserve,
))
}
let mut registry = FilterRegistry::new();
registry
.register_filter_planner(
FilterDescriptor::new("custom_filter", "test", "test"),
passthrough,
)
.expect("custom filter registers");
registry
.register_filter_planner(
FilterDescriptor::new("resize_like", "test", "test"),
resize_like,
)
.expect("resize-like filter registers");
registry
.register_filter_planner(FilterDescriptor::new("set_prop", "test", "test"), set_prop)
.expect("set-prop filter registers");
registry
.register_filter_planner(
FilterDescriptor::new("set_prop_array", "test", "test"),
set_prop_array,
)
.expect("set-prop-array filter registers");
registry
.register_filter_planner(
FilterDescriptor::new("set_prop_blob", "test", "test"),
set_prop_blob,
)
.expect("set-prop-blob filter registers");
registry
.register_filter_planner(
FilterDescriptor::new("set_prop_checked", "test", "test"),
set_prop_checked,
)
.expect("set-prop-checked filter registers");
registry
.register_filter_planner(
FilterDescriptor::new("expect_bool", "test", "test"),
expect_bool,
)
.expect("expect-bool filter registers");
registry
.register_filter_planner(
FilterDescriptor::new("expect_rational", "test", "test"),
expect_rational,
)
.expect("expect-rational filter registers");
registry
}
fn script_engine_with_fake_filters() -> ScriptEngine {
ScriptEngine::with_filter_registry(fake_filter_registry())
}
fn namespaced_filter_registry() -> FilterRegistry {
fn passthrough(request: FilterPlanRequest<'_>) -> pixelflow_core::Result<FilterPlan> {
Ok(FilterPlan::new(
request.input_media()[0].clone(),
FilterCompatibility::Preserve,
))
}
fn resize_like(request: FilterPlanRequest<'_>) -> pixelflow_core::Result<FilterPlan> {
let width = match request.options().get("width") {
Some(FilterOptionValue::Int(width)) => usize::try_from(*width).expect("width fits"),
_ => panic!("test planner expected integer width"),
};
let height = match request.options().get("height") {
Some(FilterOptionValue::Int(height)) => {
usize::try_from(*height).expect("height fits")
}
_ => panic!("test planner expected integer height"),
};
let input = &request.input_media()[0];
Ok(FilterPlan::new(
ClipMedia::new(
input.format().clone(),
ClipResolution::Fixed { width, height },
input.frame_count(),
input.frame_rate(),
),
FilterCompatibility::AllowChanges(FilterChangeSet {
format: false,
resolution: true,
frame_count: false,
frame_rate: false,
}),
))
}
let mut registry = FilterRegistry::new();
registry
.register_filter(FilterDescriptor::new("acme.blur", "acme", "blur"))
.expect("third-party descriptor-only filter registers");
registry
.register_filter_planner(
FilterDescriptor::new("merge_planes", "pixelflow", "std"),
passthrough,
)
.expect("std merge-like filter registers");
registry
.register_filter_planner(
FilterDescriptor::new("resize", "pixelflow", "std"),
resize_like,
)
.expect("std resize-like filter registers");
registry
}
#[derive(Debug, Default)]
struct CaptureSink {
records: Mutex<Vec<LogRecord>>,
}
impl LogSink for CaptureSink {
fn log(&self, record: &LogRecord) {
self.records
.lock()
.expect("capture sink lock should succeed")
.push(record.clone());
}
}
#[test]
fn set_argument_parser_coerces_supported_values() {
assert_eq!(
ScriptParameter::parse_set("enabled=true")
.expect("bool parameter should parse")
.value(),
&ScriptValue::Bool(true)
);
assert_eq!(
ScriptParameter::parse_set("count=42")
.expect("int parameter should parse")
.value(),
&ScriptValue::Int(42)
);
assert_eq!(
ScriptParameter::parse_set("scale=1.25")
.expect("float parameter should parse")
.value(),
&ScriptValue::Float(1.25)
);
assert_eq!(
ScriptParameter::parse_set("rate=30000/1001")
.expect("rational parameter should parse")
.value(),
&ScriptValue::Rational(Rational {
numerator: 30000,
denominator: 1001,
})
);
assert_eq!(
ScriptParameter::parse_set("path=input.mkv")
.expect("string parameter should parse")
.value(),
&ScriptValue::String("input.mkv".to_owned())
);
assert_eq!(
ScriptParameter::parse_set("path=/tmp/input.mkv")
.expect("slash-containing string parameter should parse")
.value(),
&ScriptValue::String("/tmp/input.mkv".to_owned())
);
}
#[test]
fn set_argument_parser_rejects_invalid_names_and_rationals() {
let bad_name =
ScriptParameter::parse_set("9bad=value").expect_err("invalid name should fail");
assert_eq!(bad_name.category(), ErrorCategory::Script);
assert_eq!(bad_name.code(), ErrorCode::new("script.invalid_parameter"));
let bad_rate =
ScriptParameter::parse_set("rate=1/0").expect_err("zero denominator should fail");
assert_eq!(bad_rate.category(), ErrorCategory::Script);
assert_eq!(bad_rate.code(), ErrorCode::new("script.invalid_parameter"));
}
#[test]
fn evaluate_rejects_empty_source() {
let error = ScriptEngine::new()
.evaluate(" \n", &[])
.expect_err("empty script should fail");
assert_eq!(error.category(), ErrorCategory::Script);
assert_eq!(error.code(), ErrorCode::new("script.empty"));
}
#[test]
fn evaluate_exposes_none_helpers() {
let graph = ScriptEngine::new()
.evaluate(
"flag = is_none(none())\noutput = source(\"input.mkv\")",
&[],
)
.expect("script should evaluate");
assert_eq!(graph.graph().outputs().len(), 1);
}
#[test]
fn sandbox_rejects_file_process_and_network_like_apis() {
let engine = ScriptEngine::new();
for script in [
"output = read_file(\"secret.txt\")",
"output = write_file(\"x\", \"y\")",
"output = command(\"echo\")",
"import \"other.pf\" as other; output = source(\"input.mkv\")",
] {
let error = engine
.evaluate(script, &[])
.expect_err("unregistered API should fail");
assert_eq!(error.category(), ErrorCategory::Script);
}
}
#[test]
fn syntax_errors_include_source_position() {
let error = ScriptEngine::new()
.evaluate("output = source(\"input.mkv\"", &[])
.expect_err("invalid syntax should fail");
assert_eq!(error.category(), ErrorCategory::Script);
assert_eq!(error.code(), ErrorCode::new("script.parse"));
assert!(error.message().contains("line"));
}
#[test]
fn method_chain_dispatches_identifier_filters_through_registry() {
let script = r#"
output = source("input.mkv")
.custom_filter(#{ enabled: true })
.resize_like(#{ width: 320, height: 180 })
"#;
let graph = script_engine_with_fake_filters()
.evaluate(script, &[])
.expect("generic registered filters should evaluate")
.into_graph();
assert_eq!(graph.nodes().len(), 3);
let output = graph
.node(graph.outputs()[0].node_id())
.expect("output node exists");
let NodeKind::Filter { name, .. } = output.kind() else {
panic!("output should be filter node");
};
assert_eq!(name, "resize_like");
assert!(matches!(
output.media().resolution(),
ClipResolution::Fixed {
width: 320,
height: 180
}
));
}
#[test]
fn array_filter_dispatches_multi_input_filters_through_registry() {
fn merge_like(request: FilterPlanRequest<'_>) -> pixelflow_core::Result<FilterPlan> {
assert_eq!(request.input_media().len(), 3);
Ok(FilterPlan::new(
ClipMedia::new(
ClipFormat::Fixed(pixelflow_core::resolve_format_alias("yuv420p8")?),
ClipResolution::Fixed {
width: 1,
height: 1,
},
FrameCount::Unknown,
FrameRate::Unknown,
),
FilterCompatibility::Custom,
))
}
let mut registry = FilterRegistry::new();
registry
.register_filter_planner(
FilterDescriptor::new("merge_like", "acme", "filters"),
merge_like,
)
.expect("filter registers");
let graph = ScriptEngine::with_filter_registry(registry)
.evaluate(
r#"
y = source("input.mkv")
u = source("input.mkv")
v = source("input.mkv")
output = filter([y, u, v], "merge_like", #{ format: "yuv420p8" })
"#,
&[],
)
.expect("multi-input filter should evaluate")
.into_graph();
let output = graph
.node(graph.outputs()[0].node_id())
.expect("output exists");
let NodeKind::Filter {
name,
inputs,
compatibility,
..
} = output.kind()
else {
panic!("output should be filter node");
};
assert_eq!(name, "merge_like");
assert_eq!(inputs.len(), 3);
assert_eq!(*compatibility, FilterCompatibility::Custom);
}
#[test]
fn array_filter_rejects_non_clip_entries() {
let error = script_engine_with_fake_filters()
.evaluate(
r#"
y = source("input.mkv")
output = filter([y, 7], "custom_filter")
"#,
&[],
)
.expect_err("non-clip array entry should fail");
assert_eq!(error.category(), ErrorCategory::Script);
assert_eq!(error.code(), ErrorCode::new("script.invalid_argument"));
assert!(error.message().contains("filter input 1 must be Clip"));
}
#[test]
fn string_named_filter_dispatch_supports_non_identifier_names() {
fn passthrough(request: FilterPlanRequest<'_>) -> pixelflow_core::Result<FilterPlan> {
Ok(FilterPlan::new(
request.input_media()[0].clone(),
FilterCompatibility::Preserve,
))
}
let mut registry = FilterRegistry::new();
registry
.register_filter_planner(
FilterDescriptor::new("acme.blur", "acme", "blur"),
passthrough,
)
.expect("filter registers");
let graph = ScriptEngine::with_filter_registry(registry)
.evaluate(
r#"output = filter(source("input.mkv"), "acme.blur", #{ radius: 2 })"#,
&[],
)
.expect("string-named filter should evaluate")
.into_graph();
let NodeKind::Filter { name, .. } = graph
.node(graph.outputs()[0].node_id())
.expect("output node exists")
.kind()
else {
panic!("expected filter node");
};
assert_eq!(name, "acme.blur");
}
#[test]
fn register_prop_registers_metadata_key_for_filter_planner() {
let graph = script_engine_with_fake_filters()
.evaluate(
r#"
register_prop("acme/filter:enabled", "bool")
output = source("input.mkv").set_prop(#{ key: "acme/filter:enabled", value: true })
"#,
&[],
)
.expect("registered metadata key should reach filter planner")
.into_graph();
let NodeKind::Filter { name, .. } = graph
.node(graph.outputs()[0].node_id())
.expect("output node exists")
.kind()
else {
panic!("expected filter node");
};
assert_eq!(name, "set_prop");
}
#[test]
fn filter_options_convert_rhai_arrays_to_metadata_arrays() {
let graph = script_engine_with_fake_filters()
.evaluate(
r#"
register_prop("acme/filter:ratios", "array")
output = source("input.mkv").set_prop_array(#{ key: "acme/filter:ratios", value: [1, "x", none()] })
"#,
&[],
)
.expect("array metadata value should reach filter planner")
.into_graph();
let NodeKind::Filter { name, .. } = graph
.node(graph.outputs()[0].node_id())
.expect("output node exists")
.kind()
else {
panic!("expected filter node");
};
assert_eq!(name, "set_prop_array");
}
#[test]
fn script_filter_call_preserves_options_for_runtime_executor() {
let graph = script_engine_with_fake_filters()
.evaluate(
r#"
clip = source("input.mkv")
output = clip.resize_like(#{ width: 320, height: 180 })
"#,
&[],
)
.expect("script should evaluate")
.into_graph();
let output = graph
.node(graph.outputs()[0].node_id())
.expect("output exists");
let options = output.filter_options().expect("output is filter");
assert_eq!(
options.get("width"),
Some(&pixelflow_core::FilterOptionValue::Int(320))
);
assert_eq!(
options.get("height"),
Some(&pixelflow_core::FilterOptionValue::Int(180))
);
}
#[test]
fn filter_options_convert_blob_helper_to_metadata_blobs() {
let graph = script_engine_with_fake_filters()
.evaluate(
r#"
register_prop("acme/filter:payload", "blob")
output = source("input.mkv").set_prop_blob(#{ key: "acme/filter:payload", value: blob([0, 127, 255]) })
"#,
&[],
)
.expect("blob metadata value should reach filter planner")
.into_graph();
let NodeKind::Filter { name, .. } = graph
.node(graph.outputs()[0].node_id())
.expect("output node exists")
.kind()
else {
panic!("expected filter node");
};
assert_eq!(name, "set_prop_blob");
}
#[test]
fn blob_helper_rejects_bytes_outside_u8_range() {
let error = script_engine_with_fake_filters()
.evaluate(
r#"
register_prop("acme/filter:payload", "blob")
output = source("input.mkv").set_prop_blob(#{ key: "acme/filter:payload", value: blob([256]) })
"#,
&[],
)
.expect_err("out-of-range blob byte should fail");
assert_eq!(error.category(), ErrorCategory::Script);
assert_eq!(error.code(), ErrorCode::new("script.invalid_argument"));
assert_eq!(
error.message(),
"blob byte at index 0 must be between 0 and 255"
);
}
#[test]
fn unsupported_filter_option_error_lists_array_and_blob_types() {
let error = script_engine_with_fake_filters()
.evaluate(
r#"output = source("input.mkv").custom_filter(#{ payload: #{ nested: true } })"#,
&[],
)
.expect_err("nested map option should fail");
assert_eq!(error.category(), ErrorCategory::Script);
assert_eq!(error.code(), ErrorCode::new("script.invalid_argument"));
assert_eq!(
error.message(),
"filter option 'payload' must be none, string, bool, integer, float, array, rational, or blob"
);
}
#[test]
fn unregistered_prop_key_reports_structured_failure() {
let error = script_engine_with_fake_filters()
.evaluate(
r#"output = source("input.mkv").set_prop_checked(#{ key: "acme/filter:enabled", value: true })"#,
&[],
)
.expect_err("unregistered metadata key should fail");
assert_eq!(error.category(), ErrorCategory::Plugin);
assert_eq!(error.code(), ErrorCode::new("metadata.unregistered_key"));
assert_eq!(
error.message(),
"metadata key 'acme/filter:enabled' is not registered"
);
}
#[test]
fn registered_prop_key_allows_metadata_validated_property_flow() {
let graph = script_engine_with_fake_filters()
.evaluate(
r#"
register_prop("acme/filter:enabled", "bool")
output = source("input.mkv").set_prop_checked(#{ key: "acme/filter:enabled", value: true })
"#,
&[],
)
.expect("registered metadata key should allow property flow")
.into_graph();
let NodeKind::Filter { name, .. } = graph
.node(graph.outputs()[0].node_id())
.expect("output node exists")
.kind()
else {
panic!("expected filter node");
};
assert_eq!(name, "set_prop_checked");
}
#[test]
fn prop_without_resolver_reports_structured_error() {
let error = ScriptEngine::new()
.evaluate(
r#"
clip = source("input.mkv")
value = prop(clip, 0, "core:matrix")
output = clip
"#,
&[],
)
.expect_err("prop calls require a runtime resolver");
assert_eq!(error.category(), ErrorCategory::Script);
assert_eq!(error.code(), ErrorCode::new("script.prop_unavailable"));
}
#[test]
fn prop_converts_metadata_values_to_rhai_values() {
let resolver = Arc::new(FakePropResolver::default());
let graph = script_engine_with_fake_filters()
.with_prop_resolver(resolver)
.evaluate(
r#"
clip = source("input.mkv")
matrix = prop(clip, 3, "core:matrix")
frame = clip.prop(3, "core:frame_number")
duration = prop(clip, 3, "core:duration")
missing = clip.prop(3, "core:source_path")
checked = clip.expect_bool(#{ value: matrix == "bt709" && frame == 3 && is_none(missing) })
rated = checked.expect_rational(#{ value: duration })
output = rated.resize_like(#{ width: frame * 100 + 20, height: 180 })
"#,
&[],
)
.expect("prop values should be usable from script")
.into_graph();
let NodeKind::Filter { name, .. } = graph
.node(graph.outputs()[0].node_id())
.expect("output node exists")
.kind()
else {
panic!("expected filter output when prop branch matches");
};
assert_eq!(name, "resize_like");
}
#[test]
fn prop_rejects_negative_frame_numbers() {
let resolver = Arc::new(FakePropResolver::default());
let error = ScriptEngine::new()
.with_prop_resolver(resolver)
.evaluate(
r#"
clip = source("input.mkv")
value = prop(clip, -1, "core:matrix")
output = clip
"#,
&[],
)
.expect_err("negative frame should fail");
assert_eq!(error.category(), ErrorCategory::Script);
assert_eq!(error.code(), ErrorCode::new("script.invalid_argument"));
assert_eq!(error.message(), "prop frame number must be non-negative");
}
#[test]
fn repeated_prop_requests_are_cached_per_script_evaluation() {
let resolver = Arc::new(FakePropResolver::default());
let counter = Arc::clone(&resolver);
script_engine_with_fake_filters()
.with_prop_resolver(resolver)
.evaluate(
r#"
clip = source("input.mkv")
a = prop(clip, 3, "core:matrix")
b = clip.prop(3, "core:matrix")
output = clip
"#,
&[],
)
.expect("duplicate prop calls should evaluate");
assert_eq!(counter.calls.load(Ordering::SeqCst), 1);
}
#[test]
fn unregistered_filter_reports_graph_diagnostic() {
let error = ScriptEngine::new()
.evaluate(r#"output = source("input.mkv").missing(#{})"#, &[])
.expect_err("missing filter should fail");
assert_eq!(error.category(), ErrorCategory::Graph);
assert_eq!(error.code(), ErrorCode::new("graph.unknown_filter"));
}
#[test]
fn plugin_namespace_function_dispatches_descriptor_only_filter() {
let graph = ScriptEngine::with_filter_registry(namespaced_filter_registry())
.evaluate(
r#"output = plugin.acme.blur(source("input.mkv"), #{ radius: 2 })"#,
&[],
)
.expect("plugin namespace function should evaluate")
.into_graph();
let NodeKind::Filter {
name,
compatibility,
..
} = graph
.node(graph.outputs()[0].node_id())
.expect("output node exists")
.kind()
else {
panic!("expected filter node");
};
assert_eq!(name, "acme.blur");
assert_eq!(*compatibility, FilterCompatibility::Custom);
}
#[test]
fn plugin_namespace_method_dispatches_descriptor_only_filter() {
let graph = ScriptEngine::with_filter_registry(namespaced_filter_registry())
.evaluate(
r#"output = source("input.mkv").plugin.acme.blur(#{ radius: 2 })"#,
&[],
)
.expect("plugin namespace method should evaluate")
.into_graph();
let NodeKind::Filter { name, .. } = graph
.node(graph.outputs()[0].node_id())
.expect("output node exists")
.kind()
else {
panic!("expected filter node");
};
assert_eq!(name, "acme.blur");
}
#[test]
fn std_namespace_function_dispatches_std_filter() {
let graph = ScriptEngine::with_filter_registry(namespaced_filter_registry())
.evaluate(
r#"
y = source("input.mkv")
u = source("input.mkv")
v = source("input.mkv")
output = std.merge_planes([y, u, v], #{ format: "yuv420p8" })
"#,
&[],
)
.expect("std namespace function should evaluate")
.into_graph();
let NodeKind::Filter { name, inputs, .. } = graph
.node(graph.outputs()[0].node_id())
.expect("output node exists")
.kind()
else {
panic!("expected filter node");
};
assert_eq!(name, "merge_planes");
assert_eq!(inputs.len(), 3);
}
#[test]
fn plugin_std_namespace_function_dispatches_std_filter() {
let graph = ScriptEngine::with_filter_registry(namespaced_filter_registry())
.evaluate(
r#"
y = source("input.mkv")
u = source("input.mkv")
v = source("input.mkv")
output = plugin.std.merge_planes([y, u, v])
"#,
&[],
)
.expect("plugin.std namespace function should evaluate")
.into_graph();
let NodeKind::Filter { name, inputs, .. } = graph
.node(graph.outputs()[0].node_id())
.expect("output node exists")
.kind()
else {
panic!("expected filter node");
};
assert_eq!(name, "merge_planes");
assert_eq!(inputs.len(), 3);
}
#[test]
fn std_namespace_method_dispatches_std_filter() {
let graph = ScriptEngine::with_filter_registry(namespaced_filter_registry())
.evaluate(
r#"output = source("input.mkv").std.resize(#{ width: 320, height: 180 })"#,
&[],
)
.expect("std namespace method should evaluate")
.into_graph();
let output = graph
.node(graph.outputs()[0].node_id())
.expect("output node exists");
let NodeKind::Filter { name, .. } = output.kind() else {
panic!("expected filter node");
};
assert_eq!(name, "resize");
assert!(matches!(
output.media().resolution(),
ClipResolution::Fixed {
width: 320,
height: 180,
}
));
}
#[test]
fn unknown_plugin_namespace_reports_structured_graph_error() {
let error = ScriptEngine::with_filter_registry(namespaced_filter_registry())
.evaluate(
r#"output = plugin.missing.blur(source("input.mkv"), #{ radius: 2 })"#,
&[],
)
.expect_err("unknown namespace should fail");
assert_eq!(error.category(), ErrorCategory::Graph);
assert_eq!(error.code(), ErrorCode::new("graph.unknown_filter"));
}
#[test]
fn std_namespace_function_rewrites_even_when_statement_result_is_unused() {
ScriptEngine::with_filter_registry(namespaced_filter_registry())
.evaluate(
r#"
std.resize(source("input.mkv"), #{ width: 320, height: 180 })
output = source("input.mkv")
"#,
&[],
)
.expect("unused namespaced statement should still parse and evaluate");
}
#[test]
fn filter_method_rewrite_preserves_strings_and_explicit_filter_calls() {
assert_eq!(
super::rewrite_filter_syntax(
r#"output = source("a.b").sample_filter(#{ width: 1, height: 1 })"#,
&fake_filter_registry(),
)
.expect("rewrite should succeed"),
r#"output = source("a.b").filter("sample_filter", #{ width: 1, height: 1 })"#
);
assert_eq!(
super::rewrite_filter_syntax(
r#"output = clip.filter("acme.blur", #{ radius: 2 })"#,
&fake_filter_registry(),
)
.expect("rewrite should succeed"),
r#"output = clip.filter("acme.blur", #{ radius: 2 })"#
);
}
#[test]
fn filter_method_rewrite_ignores_comments_and_supports_spacing_and_no_arg_calls() {
let source = super::normalize_source(concat!(
"// clip.sample_filter()\n",
"/* clip.resize_like(#{ width: 1, height: 1 }) */\n",
"text = \"clip.no_args_filter()\";\n",
"clip = source(\"input.mkv\")\n",
"output = clip.resize_like (#{ width: 320, height: 180 })\n",
"done = clip.no_args_filter ()\n",
"kept = clip.filter(\"acme.blur\", #{ radius: 2 })\n",
));
let expected = super::normalize_source(concat!(
"// clip.sample_filter()\n",
"/* clip.resize_like(#{ width: 1, height: 1 }) */\n",
"text = \"clip.no_args_filter()\";\n",
"clip = source(\"input.mkv\")\n",
"output = clip.filter(\"resize_like\", #{ width: 320, height: 180 })\n",
"done = clip.filter(\"no_args_filter\")\n",
"kept = clip.filter(\"acme.blur\", #{ radius: 2 })\n",
));
assert_eq!(
super::rewrite_filter_syntax(&source, &fake_filter_registry())
.expect("rewrite should succeed"),
expected
);
}
#[test]
fn filter_method_rewrite_only_targets_clip_producing_rhs_chains() {
let source = super::normalize_source(
r#"
clip = source("input.mkv")
text = "abc".len()
output = clip.resize_like(#{ width: "xy".len(), height: 180 })
"#,
);
let expected = concat!(
"clip = source(\"input.mkv\");\n",
"text = \"abc\".len();\n",
"output = clip.filter(\"resize_like\", #{ width: \"xy\".len(), height: 180 });",
);
assert_eq!(
super::rewrite_filter_syntax(&source, &fake_filter_registry())
.expect("rewrite should succeed"),
expected
);
}
#[test]
fn logger_and_filter_registry_constructor_preserves_custom_logger() {
let sink = Arc::new(CaptureSink::default());
let engine = ScriptEngine::with_logger_and_filter_registry(
Logger::new(sink.clone()),
fake_filter_registry(),
);
engine
.evaluate(
r#"output = source("input.mkv").custom_filter(#{ enabled: true })"#,
&[],
)
.expect("script should evaluate with combined logger and registry");
let records = sink
.records
.lock()
.expect("capture sink lock should succeed");
assert!(records.iter().any(|record| {
record.level() == LogLevel::Debug
&& record.target() == "pixelflow_script"
&& record.message() == "script graph constructed"
}));
}
#[test]
fn count_output_assignments_ignores_strings_comments_comparisons_and_nesting() {
let source = concat!(
"output = source(\"real.mkv\");\n",
"text = \"output = fake\";\n",
"// output = fake;\n",
"/* output = fake; */\n",
"output == none();\n",
"check = output == none();\n",
"other = output != none();\n",
"compare = output >= 0;\n",
"limit = output <= 10;\n",
"if cond { output = source(\"nested.mkv\"); }\n",
"config = #{ nested: \"output = fake\" };\n",
"text = \"skip; output = fake\";\n",
"/* skip; output = fake; */\n",
);
assert_eq!(super::count_output_assignments(source), 1);
}
#[test]
fn source_options_are_preserved_for_ffms2_indexing() {
let graph = ScriptEngine::new()
.evaluate(
r#"output = source("input.mkv", #{ cache: "cache.pfidx", fps: "30000/1001", vfr: "normalize", format: "yuv420p10" })"#,
&[],
)
.expect("script should evaluate")
.into_graph();
let node = graph
.node(graph.outputs()[0].node_id())
.expect("source node exists");
let pixelflow_core::NodeKind::Source { request, .. } = node.kind() else {
panic!("expected source node");
};
assert_eq!(request.path(), "input.mkv");
assert_eq!(
request.options().get("cache"),
Some(&pixelflow_core::SourceOptionValue::String(
"cache.pfidx".to_owned(),
))
);
assert_eq!(
request.options().get("fps"),
Some(&pixelflow_core::SourceOptionValue::Rational(Rational {
numerator: 30_000,
denominator: 1_001,
}))
);
}
#[test]
fn source_graph_validation_plan_works_before_indexing() {
let graph = ScriptEngine::new()
.evaluate(
"unused = source(\"unused.mkv\")\noutput = source(\"used.mkv\")",
&[],
)
.expect("script should evaluate")
.into_graph();
let plan = graph
.validation_plan()
.expect("reachability works before media validation");
assert_eq!(plan.reachable_sources().len(), 1);
let node = graph
.node(plan.reachable_sources()[0])
.expect("source exists");
let pixelflow_core::NodeKind::Source { request, .. } = node.kind() else {
panic!("expected source");
};
assert_eq!(request.path(), "used.mkv");
}
#[test]
fn multiline_option_blocks_build_valid_graph() {
let script = r#"
output = source(
"input.mkv",
#{
width: 640,
height: 360,
frames: 2
}
)
"#;
let graph = ScriptEngine::new()
.evaluate(script, &[])
.expect("multiline options should evaluate");
graph
.graph()
.validation_plan()
.expect("multiline source graph should have validation plan");
}
#[test]
fn missing_output_reports_graph_diagnostic() {
let error = ScriptEngine::new()
.evaluate("clip = source(\"input.mkv\")", &[])
.expect_err("missing output should fail");
assert_eq!(error.category(), ErrorCategory::Graph);
assert_eq!(error.code(), ErrorCode::new("graph.missing_output"));
assert_eq!(error.message(), "script does not assign final output");
}
#[test]
fn output_must_be_clip() {
let error = ScriptEngine::new()
.evaluate("output = 42", &[])
.expect_err("non-clip output should fail");
assert_eq!(error.category(), ErrorCategory::Graph);
assert_eq!(error.code(), ErrorCode::new("graph.invalid_output"));
}
#[test]
fn duplicate_output_assignments_are_rejected() {
let error = ScriptEngine::new()
.evaluate(
"output = source(\"first.mkv\")\noutput = source(\"second.mkv\")",
&[],
)
.expect_err("duplicate output assignment should fail");
assert_eq!(error.category(), ErrorCategory::Graph);
assert_eq!(error.code(), ErrorCode::new("graph.multiple_outputs"));
}
#[test]
fn set_parameters_are_available_in_script_scope() {
let params = [
ScriptParameter::parse_set("width=640").expect("width should parse"),
ScriptParameter::parse_set("height=360").expect("height should parse"),
ScriptParameter::parse_set("frames=12").expect("frames should parse"),
];
let script =
"output = source(\"input.mkv\", #{ width: width, height: height, frames: frames })";
let graph = ScriptEngine::new()
.evaluate(script, ¶ms)
.expect("params should inject");
graph
.graph()
.validation_plan()
.expect("parameterized graph should have validation plan");
}
}