use glam::DVec2;
use renamite_animation::{FrameRate, LoopMode, PlayState, Playback};
use renamite_io_ren::RenFile;
use renamite_machine::{InputValue, MachineId, MachineInstance, PointerEventKind};
use renamite_model::{CompId, NodeId, Overrides, PropPath, Scene, Value, evaluate_with, pick};
pub use renamite_model::{nodes_bounds, pick_box};
#[derive(Debug, thiserror::Error)]
pub enum PlayerError {
#[error(transparent)]
Ren(#[from] renamite_io_ren::RenError),
#[error("composition not found in project")]
MissingComp,
#[error("binary .renb support requires the `binary` feature")]
BinaryDisabled,
#[error("input is not valid UTF-8 .ren text")]
InvalidUtf8,
}
pub enum PlayMode {
Machine {
id: MachineId,
instance: MachineInstance,
background: Playback,
},
Timeline { playback: Playback },
Scrub { frame: f64 },
}
pub struct Engine {
comp: CompId,
rate: FrameRate,
pub mode: PlayMode,
paused: bool,
ov: Overrides, host_ov: Overrides, scene: Scene, hover: Option<NodeId>, pressed: Option<NodeId>, events: Vec<String>, }
impl Engine {
pub fn new(project: &RenFile) -> Result<Self, PlayerError> {
let comp = project.document.main;
let c = project
.document
.compositions
.get(comp)
.ok_or(PlayerError::MissingComp)?;
let playing = Playback {
state: PlayState::Playing,
head: c.range.0.0 as f64,
loop_mode: LoopMode::Loop,
range: c.range,
dir: 1.0,
};
let mode = match project.start_machine {
Some(id) if project.machines.contains_key(id) => PlayMode::Machine {
id,
instance: MachineInstance::new(&project.machines[id]),
background: playing,
},
_ => PlayMode::Timeline { playback: playing },
};
let mut e = Self {
comp,
rate: c.rate,
mode,
paused: false,
ov: Overrides::default(),
host_ov: Overrides::default(),
scene: Scene::default(),
hover: None,
pressed: None,
events: Vec::new(),
};
e.reevaluate(project);
Ok(e)
}
pub fn tick(&mut self, project: &RenFile, dt_secs: f64) -> &[String] {
self.events.clear();
if self.paused {
return &self.events;
}
self.ov.clear();
let dt_frames = dt_secs * self.rate.fps();
let frame = match &mut self.mode {
PlayMode::Machine {
id,
instance,
background,
} => {
background.advance(dt_secs, self.rate);
if let Some(m) = project.machines.get(*id) {
let out = instance.tick(m, &project.clips, dt_frames, &mut self.ov);
self.events.extend(out.events);
}
background.head
}
PlayMode::Timeline { playback } => {
playback.advance(dt_secs, self.rate);
playback.head
}
PlayMode::Scrub { frame } => *frame,
};
self.apply_host_overrides();
self.scene = evaluate_with(&project.document, self.comp, frame, &self.ov);
&self.events
}
fn apply_host_overrides(&mut self) {
for ((id, prop), value) in self.host_ov.values.iter() {
self.ov.set(*id, prop.clone(), value.clone());
}
}
pub fn reevaluate(&mut self, project: &RenFile) {
self.apply_host_overrides();
self.scene = evaluate_with(&project.document, self.comp, self.head(), &self.ov);
}
pub fn set_host_override(&mut self, id: NodeId, prop: PropPath, v: Value) {
self.host_ov.set(id, prop, v);
}
pub fn clear_host_overrides(&mut self) {
self.host_ov.clear();
}
pub fn bake(&mut self, project: &RenFile, frames: usize, dt_secs: f64) -> Vec<Scene> {
(0..frames)
.map(|_| {
self.tick(project, dt_secs);
self.scene.clone()
})
.collect()
}
pub fn play_machine(&mut self, project: &RenFile, id: MachineId) -> bool {
if !project.machines.contains_key(id) {
return false;
}
let background = self.playing_playback(project);
self.mode = PlayMode::Machine {
id,
instance: MachineInstance::new(&project.machines[id]),
background,
};
self.hover = None;
self.pressed = None;
true
}
pub fn play_timeline(&mut self, project: &RenFile, loop_mode: LoopMode) {
let mut playback = self.playing_playback(project);
playback.loop_mode = loop_mode;
self.mode = PlayMode::Timeline { playback };
}
pub fn set_timeline_playback(&mut self, pb: Playback) {
match &mut self.mode {
PlayMode::Timeline { playback } => *playback = pb,
PlayMode::Scrub { .. } => self.mode = PlayMode::Timeline { playback: pb },
PlayMode::Machine { .. } => {}
}
}
pub fn scrub(&mut self, project: &RenFile, frame: f64) {
self.mode = PlayMode::Scrub { frame };
self.reevaluate(project);
}
pub fn set_composition(&mut self, project: &RenFile, comp: CompId) -> bool {
let Some(c) = project.document.compositions.get(comp) else {
return false;
};
self.comp = comp;
self.rate = c.rate;
self.play_timeline(project, LoopMode::Loop);
self.reevaluate(project);
true
}
pub fn pause(&mut self) {
self.paused = true;
}
pub fn resume(&mut self) {
self.paused = false;
}
pub fn is_paused(&self) -> bool {
self.paused
}
fn playing_playback(&self, project: &RenFile) -> Playback {
let range = project.document.compositions[self.comp].range;
Playback {
state: PlayState::Playing,
head: range.0.0 as f64,
loop_mode: LoopMode::Loop,
range,
dir: 1.0,
}
}
pub fn scene(&self) -> &Scene {
&self.scene
}
pub fn events(&self) -> &[String] {
&self.events
}
pub fn rate(&self) -> FrameRate {
self.rate
}
pub fn composition(&self) -> CompId {
self.comp
}
pub fn head(&self) -> f64 {
match &self.mode {
PlayMode::Machine { background, .. } => background.head,
PlayMode::Timeline { playback } => playback.head,
PlayMode::Scrub { frame } => *frame,
}
}
pub fn active_machine_states(&self) -> Option<Vec<usize>> {
let PlayMode::Machine { instance, .. } = &self.mode else {
return None;
};
Some(instance.layer_states().collect())
}
pub fn machine_inputs(&self) -> Option<&[InputValue]> {
match &self.mode {
PlayMode::Machine { instance, .. } => Some(&instance.inputs),
_ => None,
}
}
pub fn apply_machine_inputs(&mut self, inputs: &[InputValue]) {
let PlayMode::Machine { instance, .. } = &mut self.mode else {
return;
};
for (dst, src) in instance.inputs.iter_mut().zip(inputs.iter()) {
match (dst, src) {
(InputValue::Bool(a), InputValue::Bool(b)) => *a = *b,
(InputValue::Number(a), InputValue::Number(b)) => *a = *b,
(InputValue::Trigger { fired: a }, InputValue::Trigger { fired: b }) => *a = *b,
_ => {}
}
}
}
pub fn playing_machine_id(&self) -> Option<MachineId> {
match &self.mode {
PlayMode::Machine { id, .. } => Some(*id),
_ => None,
}
}
pub fn set_bool(&mut self, project: &RenFile, name: &str, v: bool) -> bool {
self.with_input(project, name, |inst, i| inst.set_bool(i, v))
}
pub fn set_number(&mut self, project: &RenFile, name: &str, v: f64) -> bool {
self.with_input(project, name, |inst, i| inst.set_number(i, v))
}
pub fn fire(&mut self, project: &RenFile, name: &str) -> bool {
self.with_input(project, name, |inst, i| inst.fire(i))
}
fn with_input(
&mut self,
project: &RenFile,
name: &str,
f: impl FnOnce(&mut MachineInstance, usize),
) -> bool {
if let PlayMode::Machine { id, instance, .. } = &mut self.mode
&& let Some(m) = project.machines.get(*id)
&& let Some(i) = MachineInstance::input_index(m, name)
{
f(instance, i);
return true;
}
false
}
pub fn pointer_move(&mut self, project: &RenFile, pt: DVec2) {
let hit = pick(&self.scene, pt);
if hit != self.hover {
if let Some(prev) = self.hover {
self.route(project, prev, PointerEventKind::Exit);
}
if let Some(now) = hit {
self.route(project, now, PointerEventKind::Enter);
}
self.hover = hit;
}
}
pub fn pointer_down(&mut self, project: &RenFile, pt: DVec2) {
self.pressed = pick(&self.scene, pt);
if let Some(n) = self.pressed {
self.route(project, n, PointerEventKind::Down);
}
}
pub fn pointer_up(&mut self, project: &RenFile, pt: DVec2) {
if let Some(n) = pick(&self.scene, pt) {
self.route(project, n, PointerEventKind::Up);
if self.pressed == Some(n) {
self.route(project, n, PointerEventKind::Click);
}
}
self.pressed = None;
}
pub fn pointer_leave(&mut self, project: &RenFile) {
if let Some(prev) = self.hover.take() {
self.route(project, prev, PointerEventKind::Exit);
}
self.pressed = None;
}
fn route(&mut self, project: &RenFile, node: NodeId, kind: PointerEventKind) {
if let PlayMode::Machine { id, instance, .. } = &mut self.mode
&& let Some(m) = project.machines.get(*id)
{
instance.pointer_event(m, node, kind);
}
}
}
pub struct Player {
pub project: RenFile,
pub engine: Engine,
}
impl Player {
pub fn new(project: RenFile) -> Result<Self, PlayerError> {
let engine = Engine::new(&project)?;
Ok(Self { project, engine })
}
pub fn from_ren_str(text: &str) -> Result<Self, PlayerError> {
Self::new(renamite_io_ren::open(text)?)
}
#[cfg(feature = "binary")]
pub fn from_ren_bytes(bytes: &[u8]) -> Result<Self, PlayerError> {
Self::new(renamite_io_ren::open_binary(bytes)?)
}
pub fn from_bytes(bytes: &[u8]) -> Result<Self, PlayerError> {
if renamite_io_ren::is_binary(bytes) {
#[cfg(feature = "binary")]
{
return Self::from_ren_bytes(bytes);
}
#[cfg(not(feature = "binary"))]
{
return Err(PlayerError::BinaryDisabled);
}
}
let text = std::str::from_utf8(bytes).map_err(|_| PlayerError::InvalidUtf8)?;
Self::from_ren_str(text)
}
pub fn tick(&mut self, dt_secs: f64) -> &[String] {
self.engine.tick(&self.project, dt_secs)
}
pub fn bake(&mut self, frames: usize, dt_secs: f64) -> Vec<Scene> {
self.engine.bake(&self.project, frames, dt_secs)
}
pub fn scene(&self) -> &Scene {
self.engine.scene()
}
pub fn events(&self) -> &[String] {
self.engine.events()
}
pub fn head(&self) -> f64 {
self.engine.head()
}
pub fn rate(&self) -> FrameRate {
self.engine.rate()
}
pub fn composition(&self) -> CompId {
self.engine.composition()
}
pub fn active_machine_states(&self) -> Option<Vec<usize>> {
self.engine.active_machine_states()
}
pub fn artboard_size(&self) -> (u32, u32) {
self.project.document.compositions[self.engine.composition()].size
}
pub fn reevaluate(&mut self) {
self.engine.reevaluate(&self.project);
}
pub fn pause(&mut self) {
self.engine.pause();
}
pub fn resume(&mut self) {
self.engine.resume();
}
pub fn is_paused(&self) -> bool {
self.engine.is_paused()
}
pub fn scrub(&mut self, frame: f64) {
self.engine.scrub(&self.project, frame);
}
pub fn set_composition(&mut self, comp: CompId) -> bool {
self.engine.set_composition(&self.project, comp)
}
pub fn set_host_override(&mut self, id: NodeId, prop: PropPath, v: Value) {
self.engine.set_host_override(id, prop, v);
self.engine.reevaluate(&self.project);
}
pub fn clear_host_overrides(&mut self) {
self.engine.clear_host_overrides();
self.engine.reevaluate(&self.project);
}
pub fn set_bool(&mut self, name: &str, v: bool) -> bool {
self.engine.set_bool(&self.project, name, v)
}
pub fn set_number(&mut self, name: &str, v: f64) -> bool {
self.engine.set_number(&self.project, name, v)
}
pub fn fire(&mut self, name: &str) -> bool {
self.engine.fire(&self.project, name)
}
pub fn pointer_move(&mut self, pt: DVec2) {
self.engine.pointer_move(&self.project, pt);
let _ = self.engine.tick(&self.project, 0.0);
}
pub fn pointer_down(&mut self, pt: DVec2) {
self.engine.pointer_down(&self.project, pt);
let _ = self.engine.tick(&self.project, 0.0);
}
pub fn pointer_up(&mut self, pt: DVec2) {
self.engine.pointer_up(&self.project, pt);
let _ = self.engine.tick(&self.project, 0.0);
}
pub fn pointer_leave(&mut self) {
self.engine.pointer_leave(&self.project);
let _ = self.engine.tick(&self.project, 0.0);
}
pub fn play_machine(&mut self, id: MachineId) -> bool {
self.engine.play_machine(&self.project, id)
}
pub fn play_timeline(&mut self, loop_mode: LoopMode) {
self.engine.play_timeline(&self.project, loop_mode)
}
pub fn set_timeline_playback(&mut self, pb: Playback) {
self.engine.set_timeline_playback(pb)
}
}
#[cfg(test)]
mod tests {
use super::*;
use kurbo::Shape;
use renamite_animation::{Animated, EasingHandle, Frame, Interpolation};
use renamite_machine::{
Clip, ClipMap, Condition, InputDef, InputKind, Listener, ListenerAction, Machine,
MachineLayer, MachineMap, State, StateKind, Track, Transition,
};
use renamite_model::{
BlendMode, ClipPath, Color, Document, FillRule, KeyframeData, Node, NodeKind, PaintKind,
Parent, PropPath, SceneItem, ScenePaint, ShapeKind, StyleKind, StylePaint, Value,
};
fn rect_kind(size: f64) -> NodeKind {
NodeKind::Shape(ShapeKind::Rect {
pos: Animated::new(DVec2::ZERO),
size: Animated::new(DVec2::splat(size)),
rounded: Animated::new(0.0),
})
}
fn fill_kind() -> NodeKind {
NodeKind::Style(StyleKind::Fill {
paint: StylePaint::solid(Color::BLACK),
rule: FillRule::NonZero,
})
}
fn key_v2(f: i64, x: f64, y: f64) -> KeyframeData {
KeyframeData {
frame: Frame(f),
value: Value::DVec2(DVec2::new(x, y)),
interpolation: Interpolation::Linear,
ease_out: EasingHandle::LINEAR_OUT,
ease_in: EasingHandle::LINEAR_IN,
}
}
fn center_x(s: &Scene) -> f64 {
s.items[0].path.bounding_box().center().x
}
fn static_box() -> (RenFile, NodeId, NodeId) {
let mut doc = Document::empty();
let comp = doc.main;
let shape = doc.create_node(Node::new("box", rect_kind(100.0)));
doc.attach(shape, Parent::Comp(comp), 0).unwrap();
let fill = doc.create_node(Node::new("fill", fill_kind()));
doc.attach(fill, Parent::Comp(comp), 1).unwrap();
(RenFile::new(doc, "static"), shape, fill)
}
fn moving_box() -> (RenFile, NodeId, NodeId) {
let (mut f, shape, fill) = static_box();
let mut clips = ClipMap::default();
let mv = clips.insert(Clip {
name: "move".into(),
range: (Frame(0), Frame(10)),
tracks: vec![Track {
node: shape,
prop: PropPath::new("transform.position"),
keys: vec![key_v2(0, 0.0, 0.0), key_v2(10, 50.0, 0.0)],
}],
events: vec![],
});
let machine = Machine {
name: "hover".into(),
inputs: vec![InputDef {
name: "go".into(),
kind: InputKind::Bool { default: false },
}],
layers: vec![MachineLayer {
name: "base".into(),
entry: 0,
any_transitions: vec![],
states: vec![
State {
name: "Idle".into(),
kind: StateKind::Empty,
transitions: vec![Transition {
to: 1,
duration: 0.0,
exit_time: None,
conditions: vec![Condition::BoolIs {
input: 0,
value: true,
}],
}],
graph_pos: None,
},
State {
name: "Move".into(),
kind: StateKind::Clip {
clip: mv,
speed: 1.0,
loop_mode: LoopMode::Once,
},
transitions: vec![],
graph_pos: None,
},
],
}],
listeners: vec![Listener {
node: shape,
event: PointerEventKind::Enter,
action: ListenerAction::SetBool {
input: 0,
value: true,
},
}],
};
let mut machines = MachineMap::default();
let id = machines.insert(machine);
f.clips = clips;
f.machines = machines;
f.start_machine = Some(id);
(f, shape, fill)
}
#[test]
fn timeline_plays_and_renders() {
let (proj, _, _) = static_box();
let mut p = Player::new(proj).unwrap();
assert_eq!(p.scene().items.len(), 1);
let h0 = p.head();
p.tick(0.5);
assert!(p.head() > h0);
}
#[test]
fn machine_composites_over_background_timeline() {
let (mut proj, _, fill) = moving_box();
let prop = PropPath::new("opacity");
proj.document
.add_keyframe(fill, &prop, Frame(0), &Value::F64(0.0))
.unwrap();
proj.document
.add_keyframe(fill, &prop, Frame(60), &Value::F64(1.0))
.unwrap();
let mut p = Player::new(proj).unwrap();
assert!(p.set_bool("go", true));
p.tick(0.25); p.tick(0.25);
let item = &p.scene().items[0];
assert!(
(center_x(p.scene()) - 50.0).abs() < 1.0,
"machine override applied"
);
assert!(
(item.opacity - 0.5).abs() < 0.02,
"background timeline still animating (opacity={})",
item.opacity
);
}
#[test]
fn host_override_wins_over_machine_and_survives_tick() {
let (proj, shape, _) = moving_box();
let mut p = Player::new(proj).unwrap();
assert!(p.set_bool("go", true));
p.tick(0.5); p.tick(0.5); assert!((center_x(p.scene()) - 50.0).abs() < 1.0);
p.set_host_override(
shape,
PropPath::new("transform.position"),
Value::DVec2(DVec2::new(120.0, 0.0)),
);
assert!(
(center_x(p.scene()) - 120.0).abs() < 1e-6,
"set_host_override re-evaluates immediately"
);
p.tick(0.25);
assert!(
(center_x(p.scene()) - 120.0).abs() < 1e-6,
"host override survives tick"
);
p.clear_host_overrides();
p.tick(0.25);
assert!(
(center_x(p.scene()) - 50.0).abs() < 1.0,
"machine override returns after clear"
);
}
#[test]
fn scrub_locks_frame() {
let (mut proj, _, fill) = static_box();
let prop = PropPath::new("opacity");
proj.document
.add_keyframe(fill, &prop, Frame(0), &Value::F64(0.0))
.unwrap();
proj.document
.add_keyframe(fill, &prop, Frame(60), &Value::F64(1.0))
.unwrap();
let mut e = Engine::new(&proj).unwrap();
e.scrub(&proj, 30.0);
e.tick(&proj, 1.0); assert_eq!(e.head(), 30.0);
assert!((e.scene().items[0].opacity - 0.5).abs() < 1e-9);
}
#[test]
fn range_edit_is_reflected_in_loop_bounds() {
let (mut proj, _, _) = static_box();
let mut e = Engine::new(&proj).unwrap();
assert_eq!(e.head(), 0.0);
proj.document.compositions[proj.document.main].range = (Frame(0), Frame(300));
e.set_timeline_playback(Playback {
state: PlayState::Playing,
head: 0.0,
loop_mode: LoopMode::Loop,
range: (Frame(0), Frame(300)),
dir: 1.0,
});
e.tick(&proj, 3.1); assert!(
e.head() > 180.0,
"loop must use the new end frame, head={}",
e.head()
);
assert!(e.head() <= 300.0);
e.tick(&proj, 2.0); assert!(
e.head() < 180.0,
"loop must wrap at the new end frame, head={}",
e.head()
);
}
#[test]
fn scrub_unlocks_on_play() {
let (mut proj, _, fill) = static_box();
let prop = PropPath::new("opacity");
proj.document
.add_keyframe(fill, &prop, Frame(0), &Value::F64(0.0))
.unwrap();
proj.document
.add_keyframe(fill, &prop, Frame(60), &Value::F64(1.0))
.unwrap();
let mut e = Engine::new(&proj).unwrap();
e.scrub(&proj, 30.0);
e.tick(&proj, 1.0); assert_eq!(e.head(), 30.0);
let mut e2 = e;
e2.set_timeline_playback(Playback {
state: PlayState::Playing,
head: 30.0,
loop_mode: LoopMode::Loop,
range: (Frame(0), Frame(180)),
dir: 1.0,
});
e2.tick(&proj, 1.0); assert!(
e2.head() > 30.0,
"play after scrub must advance, head={}",
e2.head()
);
}
#[test]
fn pick_hits_topmost_and_misses_outside() {
let (proj, shape, _) = static_box();
let p = Player::new(proj).unwrap();
assert_eq!(pick(p.scene(), DVec2::ZERO), Some(shape));
assert_eq!(pick(p.scene(), DVec2::new(500.0, 500.0)), None);
}
#[test]
fn pick_respects_clip_mask() {
let (_, shape, _) = static_box();
let big = kurbo::Rect::new(-100.0, -100.0, 100.0, 100.0).to_path(0.1);
let small = kurbo::Rect::new(-10.0, -10.0, 10.0, 10.0).to_path(0.1);
let scene = Scene {
clips: vec![ClipPath {
path: small,
rule: FillRule::NonZero,
}],
items: vec![SceneItem {
path: big,
node: shape,
style: shape,
paint: ScenePaint::Solid(Color::BLACK),
kind: PaintKind::Fill(FillRule::NonZero),
opacity: 1.0,
clips: vec![0],
blend: BlendMode::Normal,
}],
};
assert_eq!(pick(&scene, DVec2::ZERO), Some(shape));
assert_eq!(pick(&scene, DVec2::new(50.0, 50.0)), None);
}
#[test]
fn hover_enter_drives_listener_and_leave_exits() {
let (proj, _, _) = moving_box();
let mut p = Player::new(proj).unwrap();
p.tick(0.1);
assert!(center_x(p.scene()).abs() < 1e-6);
p.pointer_move(DVec2::ZERO); for _ in 0..5 {
p.tick(0.1);
}
assert!(center_x(p.scene()) > 10.0);
p.pointer_leave(); }
#[test]
fn live_edit_between_ticks_preserves_machine_state() {
let (mut proj, _, fill) = moving_box();
let mut e = Engine::new(&proj).unwrap();
e.set_bool(&proj, "go", true);
e.tick(&proj, 0.25);
e.tick(&proj, 0.25);
assert!((center_x(e.scene()) - 50.0).abs() < 1.0);
let red = Color::rgba(1.0, 0.0, 0.0, 1.0);
proj.document
.set_static(fill, &PropPath::new("fill.color"), &Value::Color(red))
.unwrap();
e.tick(&proj, 0.01);
let item = &e.scene().items[0];
assert_eq!(
item.paint,
ScenePaint::Solid(red),
"edit visible immediately"
);
assert!(
(center_x(e.scene()) - 50.0).abs() < 1.0,
"machine override survived the edit"
);
}
#[test]
fn bake_is_deterministic() {
let make = || {
let (proj, _, _) = moving_box();
let mut p = Player::new(proj).unwrap();
p.set_bool("go", true);
p.bake(20, 1.0 / 60.0)
};
assert_eq!(make(), make());
}
}