rustmotion 0.8.0

A CLI tool that renders motion design videos from JSON scenarios. No browser, no Node.js — just a single Rust binary.
Documentation
use gpui_component::button::{Button, ButtonVariants as _};
use gpui_component::{h_flex, v_flex, ActiveTheme, IconName, Sizable as _, StyledExt as _};
use gpui_kit::prelude::FluentBuilder as _;
use gpui_kit::{
    div, px, App, Entity, Hsla, InteractiveElement, IntoElement, ParentElement, RenderOnce,
    SharedString, StatefulInteractiveElement, Styled, Window,
};

use crate::studio::app::state::{EditorState, Selection};
use crate::studio::scenario::{ChangeKind, ElementChange, Shared};

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum DiffSide {
    A,
    B,
}

pub fn frame_for_change(shared: &Shared, change: &ElementChange) -> Option<u32> {
    let segments: Vec<&str> = change.pointer.split('/').collect();
    let (view, scene) = match segments.as_slice() {
        ["", "scenes", n, ..] => (0usize, n.parse::<usize>().ok()?),
        ["", "composition", v, "scenes", n, ..] => {
            (v.parse::<usize>().ok()?, n.parse::<usize>().ok()?)
        }
        _ => return None,
    };
    let (fps, tasks) = {
        let m = shared.lock().unwrap_or_else(|e| e.into_inner());
        (m.scenario.video.fps.max(1), m.tasks.clone())
    };
    let scene_start = tasks.iter().position(|t| {
        matches!(
            t,
            rustmotion::encode::video::FrameTask::Normal { view_idx, scene_idx, .. }
                if *view_idx == view && *scene_idx == scene
        )
    })?;
    let offset = (change.start_at.unwrap_or(0.0).max(0.0) * fps as f64) as usize;
    let frame = (scene_start + offset).min(tasks.len().saturating_sub(1));
    Some(frame as u32)
}

fn kind_badge(kind: &ChangeKind, cx: &App) -> (&'static str, Hsla) {
    match kind {
        ChangeKind::Added => ("+", cx.theme().success),
        ChangeKind::Removed => ("\u{2212}", cx.theme().danger),
        ChangeKind::Modified => ("~", cx.theme().accent),
    }
}

fn endpoint(s: &str) -> SharedString {
    if s.is_empty() {
        "\u{2014}".into()
    } else {
        s.to_string().into()
    }
}

pub struct DiffPanel {
    shared: Shared,
    editor: Entity<EditorState>,
    changes: Vec<ElementChange>,
}

impl DiffPanel {
    pub fn new(shared: Shared, editor: Entity<EditorState>, changes: Vec<ElementChange>) -> Self {
        Self {
            shared,
            editor,
            changes,
        }
    }
}

impl RenderOnce for DiffPanel {
    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
        let DiffPanel {
            shared,
            editor,
            changes,
        } = self;

        let mut groups: Vec<String> = Vec::new();
        for change in &changes {
            if !groups.contains(&change.group) {
                groups.push(change.group.clone());
            }
        }

        let close_editor = editor.clone();
        let change_count = changes.len();

        let header = h_flex()
            .items_center()
            .justify_between()
            .p_3p5()
            .child(
                v_flex()
                    .child(
                        div()
                            .font_semibold()
                            .text_color(cx.theme().accent)
                            .child("Changes"),
                    )
                    .child(
                        div()
                            .text_xs()
                            .text_color(cx.theme().muted_foreground)
                            .child(format!("{change_count} since baseline")),
                    ),
            )
            .child(
                Button::new("close-diff-panel")
                    .icon(IconName::Close)
                    .ghost()
                    .xsmall()
                    .on_click(move |_, _, cx| {
                        close_editor.update(cx, |state, cx| {
                            state.diff_active = false;
                            cx.notify();
                        });
                    }),
            );

        let group_sections = groups.into_iter().map(|group| {
            let entries = changes
                .iter()
                .filter(|change| change.group == group)
                .cloned()
                .map(|change| change_entry(change, shared.clone(), editor.clone(), cx));
            v_flex()
                .gap_1p5()
                .px_3p5()
                .py_2()
                .border_t_1()
                .border_color(cx.theme().border)
                .child(
                    div()
                        .text_xs()
                        .font_semibold()
                        .text_color(cx.theme().muted_foreground)
                        .child(group.to_uppercase()),
                )
                .children(entries)
        });

        v_flex()
            .id("diff-panel")
            .flex_none()
            .w(px(300.))
            .h_full()
            .bg(cx.theme().sidebar)
            .border_l_1()
            .border_color(cx.theme().border)
            .overflow_y_scroll()
            .child(header)
            .when(changes.is_empty(), |this| {
                this.child(
                    div()
                        .px_3p5()
                        .text_color(cx.theme().muted_foreground)
                        .child("No changes since baseline."),
                )
            })
            .children(group_sections)
    }
}

fn change_entry(
    change: ElementChange,
    shared: Shared,
    editor: Entity<EditorState>,
    cx: &App,
) -> impl IntoElement {
    let (glyph, tone) = kind_badge(&change.kind, cx);
    let has_current_counterpart = change.kind != ChangeKind::Removed;
    let element_type = change.element_type.clone();
    let label = change.label.clone();
    let fields = change.fields.clone();
    let row_id: SharedString = format!("diff-entry-{}", change.pointer).into();
    let click_target = change.clone();

    v_flex()
        .id(row_id)
        .cursor_pointer()
        .gap_1p5()
        .p_2()
        .rounded(cx.theme().radius)
        .border_1()
        .border_color(cx.theme().border)
        .on_click(move |_, _, cx| {
            if let Some(frame) = frame_for_change(&shared, &click_target) {
                editor.update(cx, |state, cx| {
                    crate::studio::editor::playback::seek_from_user(state, frame);
                    cx.notify();
                });
            }
            if has_current_counterpart {
                editor.update(cx, |state, cx| {
                    state.selected = Some(Selection {
                        node_id: u32::MAX,
                        pointer: click_target.pointer.clone(),
                        kind: click_target.element_type.clone(),
                    });
                    cx.notify();
                });
            }
        })
        .child(
            h_flex()
                .items_center()
                .gap_2()
                .child(
                    div()
                        .flex_none()
                        .w(px(16.))
                        .h(px(16.))
                        .rounded(px(4.))
                        .border_1()
                        .border_color(tone)
                        .text_color(tone)
                        .text_sm()
                        .font_semibold()
                        .flex()
                        .items_center()
                        .justify_center()
                        .child(glyph),
                )
                .child(
                    div()
                        .flex_1()
                        .truncate()
                        .text_color(cx.theme().foreground)
                        .child(label),
                )
                .child(
                    div()
                        .text_xs()
                        .text_color(cx.theme().muted_foreground)
                        .child(element_type),
                ),
        )
        .children(fields.into_iter().map(|field| {
            h_flex()
                .gap_1p5()
                .text_xs()
                .items_baseline()
                .child(
                    div()
                        .flex_none()
                        .text_color(cx.theme().muted_foreground)
                        .child(field.field),
                )
                .child(
                    h_flex()
                        .gap_1()
                        .overflow_hidden()
                        .child(
                            div()
                                .text_color(cx.theme().danger)
                                .child(endpoint(&field.before)),
                        )
                        .child(
                            div()
                                .text_color(cx.theme().muted_foreground)
                                .child("\u{2192}"),
                        )
                        .child(
                            div()
                                .text_color(cx.theme().success)
                                .child(endpoint(&field.after)),
                        ),
                )
        }))
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::sync::{Arc, Mutex};

    use crate::studio::scenario::StudioModel;

    const TWO_SCENES: &str = r##"{
        "video": { "width": 10, "height": 10, "fps": 10 },
        "scenes": [ { "duration": 1.0 }, { "duration": 2.0 } ]
    }"##;

    const TWO_VIEWS: &str = r##"{
        "video": { "width": 10, "height": 10, "fps": 10 },
        "composition": [
            { "type": "slide", "scenes": [ { "duration": 1.0 } ] },
            { "type": "slide", "scenes": [ { "duration": 1.0 } ] }
        ]
    }"##;

    fn shared_for(json: &str) -> Shared {
        let scenario = rustmotion::loader::load_scenario_from_source(None, Some(json)).unwrap();
        Arc::new(Mutex::new(StudioModel::new(scenario, None, None)))
    }

    fn change_at(pointer: &str, start_at: Option<f64>) -> ElementChange {
        ElementChange {
            pointer: pointer.to_string(),
            kind: ChangeKind::Modified,
            element_type: "text".to_string(),
            label: "label".to_string(),
            group: "Scene".to_string(),
            fields: Vec::new(),
            start_at,
        }
    }

    #[test]
    fn frame_for_change_finds_the_scene_start() {
        let shared = shared_for(TWO_SCENES);
        let change = change_at("/scenes/1/children/0", None);
        assert_eq!(frame_for_change(&shared, &change), Some(10));
    }

    #[test]
    fn frame_for_change_applies_the_start_at_offset() {
        let shared = shared_for(TWO_SCENES);
        let change = change_at("/scenes/1/children/0", Some(0.5));
        assert_eq!(frame_for_change(&shared, &change), Some(15));
    }

    #[test]
    fn frame_for_change_clamps_to_the_last_frame() {
        let shared = shared_for(TWO_SCENES);
        let change = change_at("/scenes/1/children/0", Some(100.0));
        assert_eq!(frame_for_change(&shared, &change), Some(29));
    }

    #[test]
    fn frame_for_change_resolves_composition_pointers() {
        let shared = shared_for(TWO_VIEWS);
        let change = change_at("/composition/1/scenes/0/children/0", None);
        assert_eq!(frame_for_change(&shared, &change), Some(10));
    }

    #[test]
    fn frame_for_change_rejects_non_scene_pointers() {
        let shared = shared_for(TWO_SCENES);
        let change = change_at("/video", None);
        assert_eq!(frame_for_change(&shared, &change), None);
    }

    #[test]
    fn endpoint_shows_an_em_dash_for_an_absent_field() {
        assert_eq!(endpoint(""), SharedString::from("\u{2014}"));
        assert_eq!(endpoint("48"), SharedString::from("48"));
    }
}