use std::{
collections::hash_map::DefaultHasher,
hash::{Hash, Hasher},
path::PathBuf,
};
use bevy::{
dev_tools::infinite_grid::{InfiniteGrid, InfiniteGridPlugin},
input::keyboard::{Key, KeyboardInput},
input::mouse::MouseScrollUnit,
input_focus::{AutoFocus, InputFocus},
picking::pointer::PointerButton,
prelude::*,
text::{EditableText, EditableTextFilter, TextCursorStyle},
ui::ScrollPosition,
ui_widgets::SelectAllOnFocus,
window::{CursorGrabMode, CursorOptions},
};
use crate::{
ActorCue, ActorTrack, CineCamera, DirectorPhase, DirectorPlugin, DirectorState, DofMode,
EvalCtx, FocusTrack, FovSpec, GradeTrack, KeyInterp, Lens, MotionBlurSpec, Rig, ScalarTrack,
SequenceAsset, Shake, Shot, TargetRef, TextAnchor, TextBlock, ViewfinderConfig,
ViewfinderPlugin, ViewfinderSession,
};
const SHOT_LANE_Y: f32 = 32.0;
const CAMERA_LANE_Y: f32 = 84.0;
const LENS_LANE_Y: f32 = 126.0;
const TEXT_LANE_Y: f32 = 168.0;
const ACTOR_LANE_Y: f32 = 210.0;
const LANE_HEIGHT: f32 = 34.0;
const KEY_SIZE: f32 = 18.0;
const KEY_HEIGHT: f32 = 24.0;
const MIN_SHOT_DURATION: f32 = 0.1;
const MIN_TEXT_DURATION: f32 = 0.1;
const MIN_ACTOR_CUE_DURATION: f32 = 0.1;
const MIN_ACTOR_CUE_SPEED: f32 = 0.1;
const MAX_ACTOR_CUE_SPEED: f32 = 4.0;
const HISTORY_LIMIT: usize = 64;
const PANEL: Color = Color::srgb(0.055, 0.063, 0.082);
const PANEL_2: Color = Color::srgb(0.075, 0.086, 0.11);
const TRACK: Color = Color::srgb(0.095, 0.11, 0.14);
const TEXT: Color = Color::srgb(0.86, 0.89, 0.94);
const MUTED: Color = Color::srgb(0.47, 0.53, 0.62);
const ACCENT: Color = Color::srgb(0.16, 0.62, 0.91);
const SELECTED: Color = Color::srgb(0.98, 0.58, 0.18);
const LENS: Color = Color::srgb(0.73, 0.39, 0.93);
const TITLE: Color = Color::srgb(0.92, 0.74, 0.25);
const ACTOR: Color = Color::srgb(0.30, 0.78, 0.47);
#[derive(Resource)]
pub struct DirectorsCutConfig {
pub toggle: KeyCode,
pub auto_open: bool,
pub dock_height: f32,
pub pixels_per_second: f32,
pub min_timeline_seconds: f32,
pub position_step: f32,
pub angle_step_deg: f32,
pub fov_step_deg: f32,
pub show_grid: bool,
pub sequence_path: Option<PathBuf>,
}
impl Default for DirectorsCutConfig {
fn default() -> Self {
Self {
toggle: KeyCode::Tab,
auto_open: true,
dock_height: 372.0,
pixels_per_second: 80.0,
min_timeline_seconds: 12.0,
position_step: 0.1,
angle_step_deg: 1.0,
fov_step_deg: 1.0,
show_grid: true,
sequence_path: None,
}
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum EditorSelection {
#[default]
None,
Shot(usize),
Key {
shot: usize,
track: KeyTrack,
key: usize,
},
Text(usize),
ActorCue {
track: usize,
cue: usize,
},
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum KeyTrack {
Camera,
Lens,
}
struct BrowseList {
dir: PathBuf,
entries: Vec<PathBuf>,
}
#[derive(Resource)]
pub struct DirectorsCutState {
pub open: bool,
pub selection: EditorSelection,
pub status: String,
undo: Vec<SequenceAsset>,
redo: Vec<SequenceAsset>,
pending: Vec<EditorCommand>,
dragging: Option<DragState>,
preview_requested: bool,
viewfinder_seen: bool,
last_playing: bool,
renaming: bool,
editing_text: bool,
editing_actor: Option<ActorEditField>,
editing_shot_camera: bool,
editing_value: Option<AdjustField>,
ui_dirty: bool,
inspector_scroll: f32,
fingerprint: u64,
saved_fingerprint: u64,
browsing: Option<BrowseList>,
froze_world: bool,
}
impl Default for DirectorsCutState {
fn default() -> Self {
Self {
open: false,
selection: EditorSelection::None,
status: "Backquote opens the viewfinder and Director's Cut".into(),
undo: Vec::new(),
redo: Vec::new(),
pending: Vec::new(),
dragging: None,
preview_requested: false,
viewfinder_seen: false,
last_playing: false,
renaming: false,
editing_text: false,
editing_actor: None,
editing_shot_camera: false,
editing_value: None,
ui_dirty: true,
inspector_scroll: 0.0,
fingerprint: 0,
saved_fingerprint: 0,
browsing: None,
froze_world: false,
}
}
}
impl DirectorsCutState {
pub fn typing(&self) -> bool {
self.renaming
|| self.editing_text
|| self.editing_actor.is_some()
|| self.editing_shot_camera
|| self.editing_value.is_some()
}
fn checkpoint(&mut self, sequence: &SequenceAsset) {
self.undo.push(sequence.clone());
if self.undo.len() > HISTORY_LIMIT {
self.undo.remove(0);
}
self.redo.clear();
}
fn changed(&mut self, sequence: &SequenceAsset, message: impl Into<String>) {
self.status = message.into();
self.fingerprint = sequence_fingerprint(sequence);
self.preview_requested = true;
self.ui_dirty = true;
}
}
#[derive(Clone, Copy)]
enum EditorCommand {
New,
Open,
OpenEntry(usize),
Save,
Undo,
Redo,
AddShot,
CaptureKey,
ToggleAutoKey,
AddText,
BeginTextEdit,
AddActorCue,
BeginActorAnimEdit,
BeginActorTargetEdit,
BeginShotCameraEdit,
Delete,
PlayPause,
FreezeWorld,
BeginRename,
Zoom(f32),
Adjust(AdjustField, Edit),
BeginValueEdit(AdjustField),
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum AdjustField {
ShotStart,
ShotDuration,
KeyTime,
PositionX,
PositionY,
PositionZ,
Yaw,
Pitch,
Roll,
Fov,
TextStart,
TextDuration,
TextFadeIn,
TextFadeOut,
TextSize,
TextAnchorCycle,
ActorStart,
ActorDuration,
ActorFadeIn,
ActorFadeOut,
ActorSpeed,
RigCycle,
Fx(FxField),
}
#[derive(Clone, Copy, PartialEq)]
enum Edit {
Step(f32),
Set(f32),
}
impl Edit {
fn apply(self, current: f32, step: f32) -> f32 {
match self {
Edit::Step(direction) => current + direction * step,
Edit::Set(value) => value,
}
}
fn forward(self) -> bool {
!matches!(self, Edit::Step(direction) if direction < 0.0)
}
}
impl AdjustField {
fn typable(self) -> bool {
!matches!(
self,
AdjustField::TextAnchorCycle
| AdjustField::RigCycle
| AdjustField::Fx(FxField::ShakeRamp)
| AdjustField::Fx(FxField::FocusMode)
| AdjustField::Fx(FxField::DofModeCycle)
)
}
}
fn numeric_seed(value: &str) -> String {
let number: String = value
.chars()
.skip_while(|c| !c.is_ascii_digit() && *c != '-' && *c != '+')
.take_while(|c| c.is_ascii_digit() || *c == '.' || *c == '-' || *c == '+')
.collect();
number
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum FxField {
ShakeEnable,
ShakeAmplitude,
ShakeFrequency,
ShakePosition,
ShakeSeed,
ShakeFovAmplitude,
ShakeFocusAmplitude,
ShakeRamp,
MountEase,
Vignette,
Distortion,
Aberration,
MotionBlurEnable,
ShutterAngle,
BlurSamples,
GradeExposure,
GradeTemperature,
GradeTint,
GradeSaturation,
FocusMode,
FocusValue,
Aperture,
ExposureEv100,
DofModeCycle,
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum ActorEditField {
Anim,
Target,
}
#[derive(Clone, Copy)]
enum EditorIcon {
Director,
New,
Open,
Save,
Undo,
Redo,
Shot,
Key,
Delete,
Play,
Pause,
Rename,
ZoomOut,
ZoomIn,
Minus,
Plus,
Freeze,
Text,
Actor,
}
#[derive(Clone, Copy)]
enum DragState {
Playhead {
origin: f32,
},
Shot {
shot: usize,
origin: f32,
},
ShotEnd {
shot: usize,
origin: f32,
},
Key {
shot: usize,
track: KeyTrack,
key: usize,
origin: f32,
},
Text {
index: usize,
origin: f32,
},
TextEnd {
index: usize,
origin: f32,
},
ActorCue {
track: usize,
cue: usize,
origin: f32,
},
ActorCueEnd {
track: usize,
cue: usize,
origin: f32,
},
}
#[derive(Component)]
struct DirectorsCutRoot;
#[derive(Component)]
struct PlayheadNode;
#[derive(Component)]
struct ShotNode(usize);
#[derive(Component)]
struct KeyNode {
shot: usize,
track: KeyTrack,
key: usize,
}
#[derive(Component)]
struct TextNode(usize);
#[derive(Component)]
struct ActorCueNode {
track: usize,
cue: usize,
}
#[derive(Component)]
struct SequenceNameInput;
#[derive(Component)]
struct TextBlockInput;
#[derive(Component)]
struct ActorAnimInput;
#[derive(Component)]
struct ActorTargetInput;
#[derive(Component)]
struct ShotCameraInput;
#[derive(Component)]
struct ValueInput;
#[derive(Component)]
struct InspectorPanel;
#[derive(Component)]
struct InspectorScrollThumb;
#[derive(Component)]
pub struct DirectorsCutGrid;
pub struct DirectorsCutPlugin;
impl Plugin for DirectorsCutPlugin {
fn build(&self, app: &mut App) {
if !app.is_plugin_added::<InfiniteGridPlugin>() {
app.add_plugins(InfiniteGridPlugin);
}
if !app.is_plugin_added::<DirectorPlugin>() {
app.add_plugins(DirectorPlugin);
}
if !app.is_plugin_added::<ViewfinderPlugin>() {
app.add_plugins(ViewfinderPlugin);
}
if !app.is_plugin_added::<crate::titles::TitlesPlugin>() {
app.add_plugins(crate::titles::TitlesPlugin);
}
app.init_resource::<DirectorsCutConfig>()
.init_resource::<DirectorsCutState>()
.add_systems(Startup, spawn_directors_cut_grid)
.add_systems(
Update,
(
toggle_editor,
keyboard_shortcuts,
apply_editor_commands,
detect_external_changes,
apply_editor_preview,
rebuild_editor_ui,
sync_timeline_nodes,
sync_inspector_scroll,
)
.chain(),
)
.add_systems(Update, sync_directors_cut_grid_visibility)
.add_systems(
Last,
(
handle_sequence_name_input,
handle_text_block_input,
handle_actor_anim_input,
handle_actor_target_input,
handle_shot_camera_input,
handle_value_input,
),
);
}
}
fn spawn_directors_cut_grid(mut commands: Commands) {
commands.spawn((
Name::new("Director's Cut Infinite Grid"),
DirectorsCutGrid,
InfiniteGrid,
Visibility::Hidden,
));
}
fn sync_directors_cut_grid_visibility(
config: Res<DirectorsCutConfig>,
director: Res<DirectorState>,
mut grids: Query<&mut Visibility, With<DirectorsCutGrid>>,
) {
let visibility = if config.show_grid && director.phase == DirectorPhase::Viewfinder {
Visibility::Visible
} else {
Visibility::Hidden
};
for mut current in &mut grids {
if *current != visibility {
*current = visibility;
}
}
}
fn toggle_editor(
keys: Res<ButtonInput<KeyCode>>,
config: Res<DirectorsCutConfig>,
director: Res<DirectorState>,
mut editor: ResMut<DirectorsCutState>,
mut virtual_time: ResMut<Time<Virtual>>,
mut cursor: Single<&mut CursorOptions>,
) {
if director.phase != DirectorPhase::Viewfinder {
editor.viewfinder_seen = false;
if editor.froze_world {
virtual_time.unpause();
editor.froze_world = false;
}
if editor.open {
editor.open = false;
editor.renaming = false;
editor.editing_text = false;
editor.editing_actor = None;
editor.browsing = None;
editor.preview_requested = false;
editor.ui_dirty = true;
}
return;
}
let entered_viewfinder = !editor.viewfinder_seen;
editor.viewfinder_seen = true;
let toggle_pressed = keys.just_pressed(config.toggle) && !editor.typing();
if entered_viewfinder && config.auto_open {
editor.open = true;
} else if toggle_pressed {
editor.open = !editor.open;
} else {
return;
}
editor.preview_requested = false;
if !editor.open {
editor.renaming = false;
editor.editing_text = false;
editor.editing_actor = None;
editor.browsing = None;
}
editor.ui_dirty = true;
cursor.visible = editor.open;
cursor.grab_mode = if editor.open {
CursorGrabMode::None
} else {
CursorGrabMode::Locked
};
editor.status = if editor.open {
"Director's Cut open - drag shots, edges, keys, or the playhead".into()
} else {
"Director's Cut closed".into()
};
}
fn keyboard_shortcuts(keys: Res<ButtonInput<KeyCode>>, mut editor: ResMut<DirectorsCutState>) {
if !editor.open || editor.typing() {
return;
}
let ctrl = keys.pressed(KeyCode::ControlLeft) || keys.pressed(KeyCode::ControlRight);
if ctrl && keys.just_pressed(KeyCode::KeyS) {
editor.pending.push(EditorCommand::Save);
}
if ctrl && keys.just_pressed(KeyCode::KeyO) {
editor.pending.push(EditorCommand::Open);
}
if keys.just_pressed(KeyCode::Escape) && editor.browsing.is_some() {
editor.browsing = None;
editor.status = "Open panel closed".into();
editor.ui_dirty = true;
}
if ctrl && keys.just_pressed(KeyCode::KeyZ) {
editor.pending.push(EditorCommand::Undo);
}
if ctrl && keys.just_pressed(KeyCode::KeyY) {
editor.pending.push(EditorCommand::Redo);
}
if keys.just_pressed(KeyCode::Space) {
editor.pending.push(EditorCommand::PlayPause);
}
}
fn handle_sequence_name_input(
mut keyboard_inputs: MessageReader<KeyboardInput>,
input_focus: Res<InputFocus>,
inputs: Query<&EditableText, With<SequenceNameInput>>,
config: Res<DirectorsCutConfig>,
mut editor: ResMut<DirectorsCutState>,
mut session: ResMut<ViewfinderSession>,
) {
let mut confirm = false;
let mut cancel = false;
for input in keyboard_inputs.read() {
if !input.state.is_pressed() {
continue;
}
match &input.logical_key {
Key::Enter => confirm = true,
Key::Escape => cancel = true,
_ => {}
}
}
if !editor.open || !editor.renaming {
return;
}
let Some(focused) = input_focus.get() else {
return;
};
let Ok(input) = inputs.get(focused) else {
return;
};
if cancel {
editor.renaming = false;
editor.status = "Filename edit cancelled".into();
editor.ui_dirty = true;
return;
}
if !confirm {
return;
}
let raw_name = input.value().to_string();
let name = match normalize_sequence_name(&raw_name) {
Ok(name) => name,
Err(message) => {
editor.status = message.into();
editor.ui_dirty = true;
return;
}
};
if name != session.sequence.name {
editor.checkpoint(&session.sequence);
session.sequence.name = name.clone();
}
editor.renaming = false;
editor.fingerprint = sequence_fingerprint(&session.sequence);
editor.status = if config.sequence_path.is_some() {
format!("Sequence renamed to {name}; the configured save path is unchanged")
} else {
format!("Filename set to {name}.dir.ron - press Save")
};
editor.ui_dirty = true;
}
fn handle_text_block_input(
mut keyboard_inputs: MessageReader<KeyboardInput>,
input_focus: Res<InputFocus>,
inputs: Query<&EditableText, With<TextBlockInput>>,
mut editor: ResMut<DirectorsCutState>,
mut session: ResMut<ViewfinderSession>,
) {
let mut confirm = false;
let mut cancel = false;
for input in keyboard_inputs.read() {
if !input.state.is_pressed() {
continue;
}
match &input.logical_key {
Key::Enter => confirm = true,
Key::Escape => cancel = true,
_ => {}
}
}
if !editor.open || !editor.editing_text {
return;
}
let Some(focused) = input_focus.get() else {
return;
};
let Ok(input) = inputs.get(focused) else {
return;
};
if cancel {
editor.editing_text = false;
editor.status = "Text edit cancelled".into();
editor.ui_dirty = true;
return;
}
if !confirm {
return;
}
let value = input.value().to_string();
let EditorSelection::Text(index) = editor.selection else {
editor.editing_text = false;
editor.ui_dirty = true;
return;
};
let current = session.sequence.texts.get(index).map(|block| &block.text);
match current {
None => {
editor.editing_text = false;
sanitize_selection(&mut editor, &session.sequence);
}
Some(current) if *current == value => {
editor.editing_text = false;
editor.status = "Text unchanged".into();
editor.ui_dirty = true;
}
Some(_) => {
editor.checkpoint(&session.sequence);
session.sequence.texts[index].text = value;
session.rebake();
editor.editing_text = false;
editor.changed(&session.sequence, "Text content set");
}
}
}
fn handle_actor_anim_input(
mut keyboard_inputs: MessageReader<KeyboardInput>,
input_focus: Res<InputFocus>,
inputs: Query<&EditableText, With<ActorAnimInput>>,
mut editor: ResMut<DirectorsCutState>,
mut session: ResMut<ViewfinderSession>,
) {
let (confirm, cancel) = read_confirm_cancel(&mut keyboard_inputs);
if !editor.open || editor.editing_actor != Some(ActorEditField::Anim) {
return;
}
let Some(focused) = input_focus.get() else {
return;
};
let Ok(input) = inputs.get(focused) else {
return;
};
if cancel {
editor.editing_actor = None;
editor.status = "Anim edit cancelled".into();
editor.ui_dirty = true;
return;
}
if !confirm {
return;
}
let value = input.value().to_string().trim().to_string();
let EditorSelection::ActorCue { track, cue } = editor.selection else {
editor.editing_actor = None;
editor.ui_dirty = true;
return;
};
let current = session
.sequence
.actors
.get(track)
.and_then(|t| t.cues.get(cue))
.map(|c| c.anim.clone());
match current {
None => {
editor.editing_actor = None;
sanitize_selection(&mut editor, &session.sequence);
}
Some(_) if value.is_empty() => {
editor.editing_actor = None;
editor.status = "Anim name cannot be empty".into();
editor.ui_dirty = true;
}
Some(current) if current == value => {
editor.editing_actor = None;
editor.status = "Anim unchanged".into();
editor.ui_dirty = true;
}
Some(_) => {
editor.checkpoint(&session.sequence);
session.sequence.actors[track].cues[cue].anim = value;
session.rebake();
editor.editing_actor = None;
editor.changed(&session.sequence, "Actor cue anim set");
}
}
}
fn handle_actor_target_input(
mut keyboard_inputs: MessageReader<KeyboardInput>,
input_focus: Res<InputFocus>,
inputs: Query<&EditableText, With<ActorTargetInput>>,
mut editor: ResMut<DirectorsCutState>,
mut session: ResMut<ViewfinderSession>,
) {
let (confirm, cancel) = read_confirm_cancel(&mut keyboard_inputs);
if !editor.open || editor.editing_actor != Some(ActorEditField::Target) {
return;
}
let Some(focused) = input_focus.get() else {
return;
};
let Ok(input) = inputs.get(focused) else {
return;
};
if cancel {
editor.editing_actor = None;
editor.status = "Target edit cancelled".into();
editor.ui_dirty = true;
return;
}
if !confirm {
return;
}
let value = input.value().to_string().trim().to_string();
let EditorSelection::ActorCue { track, .. } = editor.selection else {
editor.editing_actor = None;
editor.ui_dirty = true;
return;
};
let current = session
.sequence
.actors
.get(track)
.map(|t| target_display(&t.target));
match current {
None => {
editor.editing_actor = None;
sanitize_selection(&mut editor, &session.sequence);
}
Some(_) if value.is_empty() => {
editor.editing_actor = None;
editor.status = "Target cannot be empty".into();
editor.ui_dirty = true;
}
Some(current) if current == value => {
editor.editing_actor = None;
editor.status = "Target unchanged".into();
editor.ui_dirty = true;
}
Some(_) => {
editor.checkpoint(&session.sequence);
session.sequence.actors[track].target = TargetRef::Entity(value);
session.rebake();
editor.editing_actor = None;
editor.changed(&session.sequence, "Actor track target set");
}
}
}
fn handle_shot_camera_input(
mut keyboard_inputs: MessageReader<KeyboardInput>,
input_focus: Res<InputFocus>,
inputs: Query<&EditableText, With<ShotCameraInput>>,
mut editor: ResMut<DirectorsCutState>,
mut session: ResMut<ViewfinderSession>,
) {
let (confirm, cancel) = read_confirm_cancel(&mut keyboard_inputs);
if !editor.open || !editor.editing_shot_camera {
return;
}
let Some(focused) = input_focus.get() else {
return;
};
let Ok(input) = inputs.get(focused) else {
return;
};
if cancel {
editor.editing_shot_camera = false;
editor.status = "Shot camera edit cancelled".into();
editor.ui_dirty = true;
return;
}
if !confirm {
return;
}
let value = input.value().to_string().trim().to_string();
let EditorSelection::Shot(index) = editor.selection else {
editor.editing_shot_camera = false;
editor.ui_dirty = true;
return;
};
let Some(current) = session.sequence.shots.get(index).map(|s| s.camera.clone()) else {
editor.editing_shot_camera = false;
sanitize_selection(&mut editor, &session.sequence);
return;
};
let wanted = (!value.is_empty()).then_some(value);
editor.editing_shot_camera = false;
if current == wanted {
editor.status = "Shot camera unchanged".into();
editor.ui_dirty = true;
return;
}
editor.checkpoint(&session.sequence);
session.sequence.shots[index].camera = wanted.clone();
session.rebake();
let what = match wanted {
Some(name) => format!("Shot {} shoots on '{name}'", index + 1),
None => format!("Shot {} back on the take's camera", index + 1),
};
editor.changed(&session.sequence, what);
}
#[allow(clippy::too_many_arguments)] fn handle_value_input(
mut keyboard_inputs: MessageReader<KeyboardInput>,
input_focus: Res<InputFocus>,
inputs: Query<&EditableText, With<ValueInput>>,
config: Res<DirectorsCutConfig>,
director: Res<DirectorState>,
cine: Query<(&Transform, &Projection), With<CineCamera>>,
mut editor: ResMut<DirectorsCutState>,
mut session: ResMut<ViewfinderSession>,
) {
let (confirm, cancel) = read_confirm_cancel(&mut keyboard_inputs);
let Some(field) = editor.editing_value else {
return;
};
if !editor.open {
return;
}
let Some(focused) = input_focus.get() else {
return;
};
let Ok(input) = inputs.get(focused) else {
return;
};
if cancel {
editor.editing_value = None;
editor.status = "Value edit cancelled".into();
editor.ui_dirty = true;
return;
}
if !confirm {
return;
}
let typed = input.value().to_string();
editor.editing_value = None;
let Ok(value) = typed.trim().parse::<f32>() else {
editor.status = format!("'{}' is not a number", typed.trim());
editor.ui_dirty = true;
return;
};
let frame = current_frame(&director, &cine, session.fov_deg);
adjust_selection(
&mut editor,
&config,
&mut session,
field,
Edit::Set(value),
frame,
);
}
fn read_confirm_cancel(keyboard_inputs: &mut MessageReader<KeyboardInput>) -> (bool, bool) {
let mut confirm = false;
let mut cancel = false;
for input in keyboard_inputs.read() {
if !input.state.is_pressed() {
continue;
}
match &input.logical_key {
Key::Enter => confirm = true,
Key::Escape => cancel = true,
_ => {}
}
}
(confirm, cancel)
}
fn target_display(target: &TargetRef) -> String {
match target {
TargetRef::Entity(name) => name.clone(),
TargetRef::Point(_) => String::new(),
}
}
fn filename_character_allowed(character: char) -> bool {
!character.is_control()
&& !matches!(
character,
'/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|'
)
}
fn normalize_sequence_name(input: &str) -> Result<String, &'static str> {
let trimmed = input.trim();
let name = trimmed.strip_suffix(".dir.ron").unwrap_or(trimmed).trim();
if name.is_empty() {
return Err("Filename cannot be empty");
}
if name == "." || name == ".." || name.ends_with('.') {
return Err("Filename cannot end with a dot");
}
if !name.chars().all(filename_character_allowed) {
return Err("Filename contains a reserved path character");
}
if name.chars().count() > 64 {
return Err("Filename is limited to 64 characters");
}
Ok(name.to_owned())
}
#[allow(clippy::too_many_arguments)]
fn apply_editor_commands(
mut editor: ResMut<DirectorsCutState>,
mut config: ResMut<DirectorsCutConfig>,
mut viewfinder_config: ResMut<ViewfinderConfig>,
mut session: ResMut<ViewfinderSession>,
director: Res<DirectorState>,
mut virtual_time: ResMut<Time<Virtual>>,
cine: Query<(&Transform, &Projection), With<CineCamera>>,
) {
let commands = std::mem::take(&mut editor.pending);
for command in commands {
if matches!(
command,
EditorCommand::AddShot
| EditorCommand::CaptureKey
| EditorCommand::AddText
| EditorCommand::BeginTextEdit
| EditorCommand::AddActorCue
| EditorCommand::BeginActorAnimEdit
| EditorCommand::BeginActorTargetEdit
| EditorCommand::BeginShotCameraEdit
| EditorCommand::Delete
| EditorCommand::Undo
| EditorCommand::Redo
| EditorCommand::BeginRename
) {
editor.browsing = None;
}
match command {
EditorCommand::New => {
editor.checkpoint(&session.sequence);
editor.renaming = false;
editor.browsing = None;
session.sequence = SequenceAsset::empty("untitled");
session.armed_shot = 0;
session.playhead = 0.0;
session.playing = false;
editor.selection = EditorSelection::None;
session.rebake();
editor.changed(&session.sequence, "New sequence");
editor.saved_fingerprint = editor.fingerprint;
editor.preview_requested = false;
}
EditorCommand::Open => {
editor.renaming = false;
editor.editing_text = false;
editor.editing_actor = None;
toggle_browser(&mut editor, &config, &viewfinder_config);
}
EditorCommand::OpenEntry(index) => {
let path = editor
.browsing
.as_ref()
.and_then(|browse| browse.entries.get(index))
.cloned();
let Some(path) = path else {
editor.status = "That file is gone - reopen the list".into();
editor.ui_dirty = true;
continue;
};
editor.browsing = None;
load_from_disk(&mut editor, &config, &mut session, &path);
}
EditorCommand::Save => {
save_to_disk(&mut editor, &config, &viewfinder_config, &session);
if editor.browsing.is_some() {
refresh_browser(&mut editor, &config, &viewfinder_config);
}
}
EditorCommand::Undo => undo(&mut editor, &mut session),
EditorCommand::Redo => redo(&mut editor, &mut session),
EditorCommand::AddShot => {
editor.checkpoint(&session.sequence);
let (pos, rot, fov) = current_frame(&director, &cine, session.fov_deg);
let start = session.sequence.duration();
session.sequence.shots.push(Shot::keys(
start,
5.0,
vec![crate::Key::at(0.0).pos(pos).rot(rot)],
));
let shot = session.sequence.shots.len() - 1;
session.sequence.shots[shot].lens = Lens {
fov: FovSpec::VerticalFovDeg(ScalarTrack::constant(fov)),
..default()
};
session.armed_shot = shot;
session.playhead = start;
editor.selection = EditorSelection::Shot(shot);
session.rebake();
editor.changed(&session.sequence, format!("Added shot {}", shot + 1));
editor.preview_requested = false;
}
EditorCommand::CaptureKey => {
editor.checkpoint(&session.sequence);
let (pos, rot, fov) = current_frame(&director, &cine, session.fov_deg);
let (shot, key) = session.capture_frame(pos, rot, fov, EaseFunction::SmoothStep);
editor.selection = match key {
Some(key) => EditorSelection::Key {
shot,
track: KeyTrack::Camera,
key,
},
None => EditorSelection::Shot(shot),
};
editor.changed(&session.sequence, "Captured current camera");
editor.preview_requested = false;
}
EditorCommand::ToggleAutoKey => {
viewfinder_config.auto_key = !viewfinder_config.auto_key;
editor.status = if viewfinder_config.auto_key {
"Auto-key on - piloting the camera keys the shot under the playhead".into()
} else {
"Auto-key off - K or the Key button captures manually".into()
};
editor.ui_dirty = true;
}
EditorCommand::AddText => {
editor.checkpoint(&session.sequence);
let start = session.playhead;
session
.sequence
.texts
.push(TextBlock::at(start, 3.0, "Text").fades(0.3, 0.3));
let index = session.sequence.texts.len() - 1;
editor.selection = EditorSelection::Text(index);
session.rebake();
editor.changed(&session.sequence, format!("Added text block {}", index + 1));
editor.preview_requested = false;
}
EditorCommand::BeginTextEdit => {
let EditorSelection::Text(index) = editor.selection else {
editor.status = "Select a text block first".into();
editor.ui_dirty = true;
continue;
};
if session.sequence.texts.get(index).is_none() {
sanitize_selection(&mut editor, &session.sequence);
continue;
}
editor.renaming = false;
editor.editing_text = true;
editor.status = "Editing text - Enter confirms, Escape cancels".into();
editor.ui_dirty = true;
}
EditorCommand::AddActorCue => {
editor.checkpoint(&session.sequence);
let start = session.playhead;
let track = match editor.selection {
EditorSelection::ActorCue { track, .. }
if track < session.sequence.actors.len() =>
{
track
}
_ => 0,
};
if session.sequence.actors.is_empty() {
session.sequence.actors.push(ActorTrack::entity("player"));
}
let cue = session.sequence.actors[track].cues.len();
session.sequence.actors[track]
.cues
.push(ActorCue::at(start, 3.0, "clip").fades(0.2, 0.2));
editor.selection = EditorSelection::ActorCue { track, cue };
session.rebake();
editor.changed(&session.sequence, format!("Added actor cue {}", cue + 1));
editor.preview_requested = false;
}
EditorCommand::BeginActorAnimEdit => {
let EditorSelection::ActorCue { track, cue } = editor.selection else {
editor.status = "Select an actor cue first".into();
editor.ui_dirty = true;
continue;
};
if actor_cue(&session.sequence, track, cue).is_none() {
sanitize_selection(&mut editor, &session.sequence);
continue;
}
editor.renaming = false;
editor.editing_text = false;
editor.editing_actor = Some(ActorEditField::Anim);
editor.status = "Editing anim - Enter confirms, Escape cancels".into();
editor.ui_dirty = true;
}
EditorCommand::BeginActorTargetEdit => {
let EditorSelection::ActorCue { track, cue } = editor.selection else {
editor.status = "Select an actor cue first".into();
editor.ui_dirty = true;
continue;
};
if actor_cue(&session.sequence, track, cue).is_none() {
sanitize_selection(&mut editor, &session.sequence);
continue;
}
editor.renaming = false;
editor.editing_text = false;
editor.editing_actor = Some(ActorEditField::Target);
editor.status = "Editing target - Enter confirms, Escape cancels".into();
editor.ui_dirty = true;
}
EditorCommand::BeginValueEdit(field) => {
editor.renaming = false;
editor.editing_text = false;
editor.editing_actor = None;
editor.editing_shot_camera = false;
editor.editing_value = Some(field);
editor.status = "Type a value - Enter confirms, Escape cancels".into();
editor.ui_dirty = true;
}
EditorCommand::BeginShotCameraEdit => {
let EditorSelection::Shot(index) = editor.selection else {
editor.status = "Select a shot first".into();
editor.ui_dirty = true;
continue;
};
if session.sequence.shots.get(index).is_none() {
sanitize_selection(&mut editor, &session.sequence);
continue;
}
editor.renaming = false;
editor.editing_text = false;
editor.editing_actor = None;
editor.editing_shot_camera = true;
editor.status =
"Editing shot camera - Enter confirms, empty clears, Escape cancels".into();
editor.ui_dirty = true;
}
EditorCommand::Delete => delete_selection(&mut editor, &mut session),
EditorCommand::PlayPause => {
session.playing = !session.playing;
if session.playing {
session.preview_prev_rot = None;
if session.playhead >= session.sequence.duration() {
session.playhead = 0.0;
}
}
editor.status = if session.playing {
"Preview playing (Space pauses)".into()
} else {
"Preview paused".into()
};
editor.last_playing = session.playing;
editor.ui_dirty = true;
}
EditorCommand::FreezeWorld => {
if virtual_time.is_paused() {
virtual_time.unpause();
editor.froze_world = false;
editor.status = "World running".into();
} else {
virtual_time.pause();
editor.froze_world = true;
editor.status = "World frozen - the game clock is paused".into();
}
editor.ui_dirty = true;
}
EditorCommand::BeginRename => {
editor.renaming = true;
editor.editing_text = false;
editor.editing_actor = None;
editor.status = "Editing filename - Enter confirms, Escape cancels".into();
editor.ui_dirty = true;
}
EditorCommand::Zoom(factor) => {
config.pixels_per_second = (config.pixels_per_second * factor).clamp(24.0, 240.0);
editor.status = format!("Timeline zoom: {:.0}px/s", config.pixels_per_second);
editor.ui_dirty = true;
}
EditorCommand::Adjust(field, edit) => {
let frame = current_frame(&director, &cine, session.fov_deg);
adjust_selection(&mut editor, &config, &mut session, field, edit, frame);
}
}
}
}
fn current_frame(
director: &DirectorState,
cine: &Query<(&Transform, &Projection), With<CineCamera>>,
fallback_fov: f32,
) -> (Vec3, Quat, f32) {
let Some(camera) = director.camera else {
return (Vec3::ZERO, Quat::IDENTITY, fallback_fov);
};
let Ok((transform, projection)) = cine.get(camera) else {
return (Vec3::ZERO, Quat::IDENTITY, fallback_fov);
};
let fov = match projection {
Projection::Perspective(perspective) => perspective.fov.to_degrees(),
_ => fallback_fov,
};
(transform.translation, transform.rotation, fov)
}
fn sequence_path(
editor: &DirectorsCutConfig,
viewfinder: &ViewfinderConfig,
sequence: &SequenceAsset,
) -> PathBuf {
editor.sequence_path.clone().unwrap_or_else(|| {
viewfinder
.save_dir
.join(format!("{}.dir.ron", sequence.name))
})
}
fn browse_dir(config: &DirectorsCutConfig, viewfinder: &ViewfinderConfig) -> PathBuf {
config
.sequence_path
.as_ref()
.and_then(|path| path.parent())
.map(PathBuf::from)
.unwrap_or_else(|| viewfinder.save_dir.clone())
}
fn filter_sequence_entries(mut entries: Vec<PathBuf>) -> Vec<PathBuf> {
entries.retain(|path| {
path.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.ends_with(".dir.ron"))
});
entries.sort_by_key(|path| {
path.file_name()
.and_then(|name| name.to_str())
.map(str::to_lowercase)
.unwrap_or_default()
});
entries
}
#[cfg(not(target_arch = "wasm32"))]
fn list_sequences(dir: &std::path::Path) -> Result<Vec<PathBuf>, std::io::Error> {
let entries = std::fs::read_dir(dir)?
.filter_map(|entry| entry.ok())
.map(|entry| entry.path())
.collect();
Ok(filter_sequence_entries(entries))
}
fn toggle_browser(
editor: &mut DirectorsCutState,
config: &DirectorsCutConfig,
viewfinder: &ViewfinderConfig,
) {
if editor.browsing.take().is_some() {
editor.status = "Open panel closed".into();
editor.ui_dirty = true;
return;
}
refresh_browser(editor, config, viewfinder);
}
fn refresh_browser(
editor: &mut DirectorsCutState,
config: &DirectorsCutConfig,
viewfinder: &ViewfinderConfig,
) {
#[cfg(not(target_arch = "wasm32"))]
{
let dir = browse_dir(config, viewfinder);
match list_sequences(&dir) {
Ok(entries) => {
let dirty = editor.fingerprint != editor.saved_fingerprint;
editor.status = if dirty {
"Unsaved changes - opening another file checkpoints them (Ctrl+Z restores)"
.into()
} else {
"Open a sequence - click a file, Escape closes".into()
};
editor.browsing = Some(BrowseList { dir, entries });
}
Err(error) => {
editor.browsing = None;
editor.status = format!("Open failed: {error}");
}
}
editor.ui_dirty = true;
}
#[cfg(target_arch = "wasm32")]
{
let _ = (config, viewfinder);
editor.status = "Browsing sequences is native-only".into();
editor.ui_dirty = true;
}
}
fn save_to_disk(
editor: &mut DirectorsCutState,
config: &DirectorsCutConfig,
viewfinder: &ViewfinderConfig,
session: &ViewfinderSession,
) {
let path = sequence_path(config, viewfinder, &session.sequence);
#[cfg(not(target_arch = "wasm32"))]
match crate::loader::save_sequence(&session.sequence, &path) {
Ok(()) => {
editor.status = format!("Saved {}", path.display());
editor.saved_fingerprint = sequence_fingerprint(&session.sequence);
}
Err(error) => editor.status = format!("Save failed: {error}"),
}
#[cfg(target_arch = "wasm32")]
{
let _ = path;
editor.status = "Saving is native-only".into();
}
editor.ui_dirty = true;
}
fn load_from_disk(
editor: &mut DirectorsCutState,
config: &DirectorsCutConfig,
session: &mut ViewfinderSession,
path: &std::path::Path,
) {
#[cfg(not(target_arch = "wasm32"))]
match crate::loader::load_sequence(path) {
Ok(sequence) => {
editor.checkpoint(&session.sequence);
session.sequence = sequence;
if config.sequence_path.is_none()
&& let Some(stem) = path
.file_name()
.and_then(|name| name.to_str())
.and_then(|name| name.strip_suffix(".dir.ron"))
{
session.sequence.name = stem.to_owned();
}
session.armed_shot = 0;
session.playhead = 0.0;
session.playing = false;
session.rebake();
editor.selection = EditorSelection::None;
editor.changed(&session.sequence, format!("Opened {}", path.display()));
editor.saved_fingerprint = editor.fingerprint;
}
Err(error) => {
editor.status = format!("Open failed: {error}");
editor.ui_dirty = true;
}
}
#[cfg(target_arch = "wasm32")]
{
let _ = (config, path);
editor.status = "Opening files is native-only".into();
editor.ui_dirty = true;
}
}
fn undo(editor: &mut DirectorsCutState, session: &mut ViewfinderSession) {
let Some(previous) = editor.undo.pop() else {
editor.status = "Nothing to undo".into();
editor.ui_dirty = true;
return;
};
editor.redo.push(session.sequence.clone());
session.sequence = previous;
sanitize_selection(editor, &session.sequence);
session.rebake();
editor.changed(&session.sequence, "Undo");
}
fn redo(editor: &mut DirectorsCutState, session: &mut ViewfinderSession) {
let Some(next) = editor.redo.pop() else {
editor.status = "Nothing to redo".into();
editor.ui_dirty = true;
return;
};
editor.undo.push(session.sequence.clone());
session.sequence = next;
sanitize_selection(editor, &session.sequence);
session.rebake();
editor.changed(&session.sequence, "Redo");
}
fn delete_selection(editor: &mut DirectorsCutState, session: &mut ViewfinderSession) {
match editor.selection {
EditorSelection::None => {
editor.status = "Select a shot or key first".into();
editor.ui_dirty = true;
}
EditorSelection::Shot(shot) if shot < session.sequence.shots.len() => {
editor.checkpoint(&session.sequence);
session.sequence.shots.remove(shot);
session.armed_shot = session
.armed_shot
.min(session.sequence.shots.len().saturating_sub(1));
editor.selection = EditorSelection::None;
session.rebake();
editor.changed(&session.sequence, format!("Deleted shot {}", shot + 1));
}
EditorSelection::Key { shot, track, key } => {
editor.checkpoint(&session.sequence);
let removed = match session.sequence.shots.get_mut(shot) {
Some(shot) => match track {
KeyTrack::Camera => match &mut shot.rig {
Rig::Keys { keys, .. } if key < keys.len() && keys.len() > 1 => {
keys.remove(key);
true
}
_ => false,
},
KeyTrack::Lens => match &mut shot.lens.fov {
FovSpec::VerticalFovDeg(track)
if key < track.keys.len() && track.keys.len() > 1 =>
{
track.keys.remove(key);
true
}
FovSpec::FocalLengthMm { track, .. }
if key < track.keys.len() && track.keys.len() > 1 =>
{
track.keys.remove(key);
true
}
_ => false,
},
},
None => false,
};
editor.selection = EditorSelection::Shot(shot);
session.rebake();
editor.changed(
&session.sequence,
if removed {
"Deleted key"
} else {
"Key no longer exists"
},
);
}
EditorSelection::Text(index) if index < session.sequence.texts.len() => {
editor.checkpoint(&session.sequence);
session.sequence.texts.remove(index);
editor.selection = EditorSelection::None;
session.rebake();
editor.changed(
&session.sequence,
format!("Deleted text block {}", index + 1),
);
}
EditorSelection::ActorCue { track, cue }
if session
.sequence
.actors
.get(track)
.is_some_and(|t| cue < t.cues.len()) =>
{
editor.checkpoint(&session.sequence);
session.sequence.actors[track].cues.remove(cue);
if session.sequence.actors[track].cues.is_empty() {
session.sequence.actors.remove(track);
}
editor.selection = EditorSelection::None;
session.rebake();
editor.changed(&session.sequence, format!("Deleted actor cue {}", cue + 1));
}
_ => sanitize_selection(editor, &session.sequence),
}
}
fn adjust_selection(
editor: &mut DirectorsCutState,
config: &DirectorsCutConfig,
session: &mut ViewfinderSession,
field: AdjustField,
edit: Edit,
frame: (Vec3, Quat, f32),
) {
editor.checkpoint(&session.sequence);
let selection = editor.selection;
let changed = match selection {
EditorSelection::Shot(shot_index) => {
let min_start = if shot_index == 0 {
0.0
} else {
session.sequence.shots[shot_index - 1].start
};
let max_start = session
.sequence
.shots
.get(shot_index + 1)
.map_or(f32::INFINITY, |next| next.start);
let Some(shot) = session.sequence.shots.get_mut(shot_index) else {
return;
};
match field {
AdjustField::ShotStart => {
shot.start = edit.apply(shot.start, 0.1).clamp(min_start, max_start);
true
}
AdjustField::ShotDuration => {
shot.duration = edit.apply(shot.duration, 0.1).max(MIN_SHOT_DURATION);
true
}
AdjustField::RigCycle => cycle_rig(shot, frame),
AdjustField::Fx(fx) => adjust_shot_fx(shot, shot_index, fx, edit),
_ => false,
}
}
EditorSelection::Key { shot, track, key } => {
adjust_key(&mut session.sequence, shot, track, key, field, edit, config)
}
EditorSelection::Text(index) => {
adjust_text_block(&mut session.sequence, index, field, edit)
}
EditorSelection::ActorCue { track, cue } => {
adjust_actor_cue(&mut session.sequence, track, cue, field, edit)
}
EditorSelection::None => false,
};
if changed {
session.rebake();
editor.changed(&session.sequence, "Adjusted selected value");
} else {
editor.undo.pop();
editor.status = "That value is not available for this selection".into();
editor.ui_dirty = true;
}
}
#[allow(clippy::too_many_arguments)]
fn adjust_key(
sequence: &mut SequenceAsset,
shot_index: usize,
track: KeyTrack,
key_index: usize,
field: AdjustField,
edit: Edit,
config: &DirectorsCutConfig,
) -> bool {
let Some(shot) = sequence.shots.get_mut(shot_index) else {
return false;
};
match track {
KeyTrack::Lens => {
let keys = fov_keys_mut(&mut shot.lens.fov);
let min_time = key_index
.checked_sub(1)
.and_then(|index| keys.get(index))
.map_or(0.0, |key| key.time);
let max_time = keys
.get(key_index + 1)
.map_or(shot.duration, |key| key.time);
let Some(key) = keys.get_mut(key_index) else {
return false;
};
match field {
AdjustField::KeyTime => {
key.time = edit.apply(key.time, 0.1).clamp(min_time, max_time);
}
AdjustField::Fov => {
key.value = edit.apply(key.value, config.fov_step_deg).clamp(1.0, 179.0);
}
_ => return false,
}
}
KeyTrack::Camera => {
let Rig::Keys { keys, .. } = &mut shot.rig else {
return false;
};
let min_time = key_index
.checked_sub(1)
.and_then(|index| keys.get(index))
.map_or(0.0, |key| key.time);
let max_time = keys
.get(key_index + 1)
.map_or(shot.duration, |key| key.time);
let Some(key) = keys.get_mut(key_index) else {
return false;
};
match field {
AdjustField::KeyTime => {
key.time = edit.apply(key.time, 0.1).clamp(min_time, max_time);
}
AdjustField::PositionX => key.pos.x = edit.apply(key.pos.x, config.position_step),
AdjustField::PositionY => key.pos.y = edit.apply(key.pos.y, config.position_step),
AdjustField::PositionZ => key.pos.z = edit.apply(key.pos.z, config.position_step),
AdjustField::Yaw | AdjustField::Pitch | AdjustField::Roll => {
let (mut yaw, mut pitch, mut roll) =
key.rot.unwrap_or(Quat::IDENTITY).to_euler(EulerRot::YXZ);
let step = config.angle_step_deg;
match field {
AdjustField::Yaw => yaw = edit.apply(yaw.to_degrees(), step).to_radians(),
AdjustField::Pitch => {
pitch = edit.apply(pitch.to_degrees(), step).to_radians()
}
AdjustField::Roll => {
roll = edit.apply(roll.to_degrees(), step).to_radians()
}
_ => unreachable!(),
}
key.rot = Some(Quat::from_euler(EulerRot::YXZ, yaw, pitch, roll));
}
AdjustField::Fov => {
let time = key.time;
let fov_keys = fov_keys_mut(&mut shot.lens.fov);
let index = fov_keys
.iter()
.position(|lens_key| (lens_key.time - time).abs() < 0.05)
.unwrap_or_else(|| {
let value = fov_keys_at(fov_keys, time).unwrap_or(45.0);
let at = fov_keys.partition_point(|lens_key| lens_key.time < time);
fov_keys.insert(
at,
crate::ScalarKey {
time,
value,
ease: EaseFunction::SmoothStep,
},
);
at
});
fov_keys[index].value = edit
.apply(fov_keys[index].value, config.fov_step_deg)
.clamp(1.0, 179.0);
}
_ => return false,
}
}
}
true
}
fn adjust_text_block(
sequence: &mut SequenceAsset,
index: usize,
field: AdjustField,
edit: Edit,
) -> bool {
let Some(block) = sequence.texts.get_mut(index) else {
return false;
};
match field {
AdjustField::TextStart => {
block.start = edit.apply(block.start, 0.1).max(0.0);
true
}
AdjustField::TextDuration => {
block.duration = edit.apply(block.duration, 0.1).max(MIN_TEXT_DURATION);
true
}
AdjustField::TextFadeIn => {
block.fade_in = edit.apply(block.fade_in, 0.05).clamp(0.0, block.duration);
true
}
AdjustField::TextFadeOut => {
block.fade_out = edit.apply(block.fade_out, 0.05).clamp(0.0, block.duration);
true
}
AdjustField::TextSize => {
block.style.font_size = edit.apply(block.style.font_size, 2.0).clamp(6.0, 200.0);
true
}
AdjustField::TextAnchorCycle => {
block.anchor = cycle_anchor(block.anchor, edit.forward());
true
}
_ => false,
}
}
fn adjust_actor_cue(
sequence: &mut SequenceAsset,
track: usize,
cue: usize,
field: AdjustField,
edit: Edit,
) -> bool {
let Some(cue) = sequence
.actors
.get_mut(track)
.and_then(|track| track.cues.get_mut(cue))
else {
return false;
};
match field {
AdjustField::ActorStart => {
cue.start = edit.apply(cue.start, 0.1).max(0.0);
true
}
AdjustField::ActorDuration => {
cue.duration = edit.apply(cue.duration, 0.1).max(MIN_ACTOR_CUE_DURATION);
true
}
AdjustField::ActorFadeIn => {
cue.fade_in = edit.apply(cue.fade_in, 0.05).clamp(0.0, cue.duration);
true
}
AdjustField::ActorFadeOut => {
cue.fade_out = edit.apply(cue.fade_out, 0.05).clamp(0.0, cue.duration);
true
}
AdjustField::ActorSpeed => {
cue.speed = edit
.apply(cue.speed, 0.1)
.clamp(MIN_ACTOR_CUE_SPEED, MAX_ACTOR_CUE_SPEED);
true
}
_ => false,
}
}
fn actor_cue(sequence: &SequenceAsset, track: usize, cue: usize) -> Option<&ActorCue> {
sequence.actors.get(track).and_then(|t| t.cues.get(cue))
}
fn cycle_anchor(anchor: TextAnchor, forward: bool) -> TextAnchor {
match (anchor, forward) {
(TextAnchor::Center, true) => TextAnchor::LowerThird,
(TextAnchor::LowerThird, true) => TextAnchor::TopCenter,
(TextAnchor::TopCenter, true) => TextAnchor::Center,
(TextAnchor::Center, false) => TextAnchor::TopCenter,
(TextAnchor::TopCenter, false) => TextAnchor::LowerThird,
(TextAnchor::LowerThird, false) => TextAnchor::Center,
}
}
fn anchor_label(anchor: TextAnchor) -> &'static str {
match anchor {
TextAnchor::Center => "Center",
TextAnchor::LowerThird => "Lower third",
TextAnchor::TopCenter => "Top center",
}
}
fn cycle_rig(shot: &mut Shot, (pos, rot, fov): (Vec3, Quat, f32)) -> bool {
match &shot.rig {
Rig::Keys { .. } => {
shot.rig = Rig::Held { damping: None };
true
}
Rig::Held { .. } => {
shot.rig = Rig::Keys {
keys: vec![crate::Key::at(0.0).pos(pos).rot(rot)],
interp: KeyInterp::Eased,
};
if fov_keys_mut(&mut shot.lens.fov).is_empty() {
shot.lens.fov = FovSpec::VerticalFovDeg(ScalarTrack::constant(fov));
}
true
}
_ => false,
}
}
fn step_track(
track: &mut Option<ScalarTrack>,
neutral: f32,
step: f32,
edit: Edit,
range: std::ops::RangeInclusive<f32>,
) -> bool {
let current = track.as_ref().map_or(neutral, |t| t.sample(0.0));
let next = edit
.apply(current, step)
.clamp(*range.start(), *range.end());
*track = if (next - neutral).abs() < step * 0.5 {
None
} else {
Some(ScalarTrack::constant(next))
};
true
}
fn shake_default(shot_index: usize) -> Shake {
Shake {
amplitude_deg: 1.0,
frequency_hz: 8.0,
seed: shot_index as u32,
pos_amplitude: 0.02,
ramp: None,
fov_amplitude_deg: None,
focus_amplitude: None,
}
}
fn cycle_ramp(ramp: &mut Option<ScalarTrack>, duration: f32, forward: bool) {
let ends = |from: f32, to: f32| {
Some(ScalarTrack {
keys: vec![
crate::ScalarKey {
time: 0.0,
value: from,
ease: EaseFunction::SmoothStep,
},
crate::ScalarKey {
time: duration.max(MIN_SHOT_DURATION),
value: to,
ease: EaseFunction::Linear,
},
],
})
};
let rising = ramp
.as_ref()
.is_some_and(|t| t.sample(0.0) < t.sample(duration));
*ramp = match (ramp.is_some(), rising, forward) {
(false, _, true) => ends(0.0, 1.0),
(false, _, false) => ends(1.0, 0.0),
(true, true, true) => ends(1.0, 0.0),
(true, true, false) => None,
(true, false, true) => None,
(true, false, false) => ends(0.0, 1.0),
};
}
fn ramp_label(ramp: &Option<ScalarTrack>, duration: f32) -> &'static str {
match ramp {
None => "off",
Some(track) if track.sample(0.0) < track.sample(duration) => "build",
Some(_) => "fade",
}
}
fn adjust_shot_fx(shot: &mut Shot, shot_index: usize, field: FxField, edit: Edit) -> bool {
let forward = edit.forward();
let duration = shot.duration;
match field {
FxField::ShakeEnable => {
shot.shake = match shot.shake {
Some(_) => None,
None => Some(shake_default(shot_index)),
};
true
}
FxField::MotionBlurEnable => {
shot.lens.motion_blur = match shot.lens.motion_blur {
Some(_) => None,
None => Some(MotionBlurSpec::default()),
};
true
}
FxField::MountEase => {
let Rig::Held { damping } = &mut shot.rig else {
return false;
};
let current = damping.unwrap_or(0.0);
let next = edit.apply(current, 1.0).clamp(0.0, 40.0);
*damping = (next > 0.0).then_some(next);
true
}
FxField::ShakeAmplitude
| FxField::ShakeFrequency
| FxField::ShakePosition
| FxField::ShakeSeed
| FxField::ShakeFovAmplitude
| FxField::ShakeFocusAmplitude
| FxField::ShakeRamp => {
let Some(shake) = &mut shot.shake else {
return false;
};
match field {
FxField::ShakeAmplitude => {
shake.amplitude_deg = edit.apply(shake.amplitude_deg, 0.1).clamp(0.0, 30.0);
}
FxField::ShakeFrequency => {
shake.frequency_hz = edit.apply(shake.frequency_hz, 0.5).clamp(0.1, 40.0);
}
FxField::ShakePosition => {
shake.pos_amplitude = edit.apply(shake.pos_amplitude, 0.005).clamp(0.0, 1.0);
}
FxField::ShakeSeed => {
shake.seed =
(edit.apply(shake.seed as f32, 1.0).round() as i32).rem_euclid(1000) as u32;
}
FxField::ShakeFovAmplitude => {
let next = edit
.apply(shake.fov_amplitude_deg.unwrap_or(0.0), 0.1)
.clamp(0.0, 10.0);
shake.fov_amplitude_deg = (next > 0.05).then_some(next);
}
FxField::ShakeFocusAmplitude => {
let next = edit
.apply(shake.focus_amplitude.unwrap_or(0.0), 0.05)
.clamp(0.0, 10.0);
shake.focus_amplitude = (next > 0.025).then_some(next);
}
_ => cycle_ramp(&mut shake.ramp, duration, forward),
}
true
}
FxField::Vignette => step_track(&mut shot.lens.vignette, 0.0, 0.05, edit, 0.0..=1.0),
FxField::Distortion => step_track(&mut shot.lens.distortion, 0.0, 0.02, edit, -1.0..=1.0),
FxField::Aberration => step_track(&mut shot.lens.aberration, 0.0, 0.01, edit, 0.0..=0.5),
FxField::ShutterAngle => {
let Some(blur) = &mut shot.lens.motion_blur else {
return false;
};
blur.shutter_angle = edit.apply(blur.shutter_angle, 0.05).clamp(0.05, 1.0);
true
}
FxField::BlurSamples => {
let Some(blur) = &mut shot.lens.motion_blur else {
return false;
};
blur.samples =
(edit.apply(blur.samples as f32, 1.0).round() as i32).clamp(1, 16) as u32;
true
}
FxField::GradeExposure
| FxField::GradeTemperature
| FxField::GradeTint
| FxField::GradeSaturation => {
let grade = shot.lens.grading.get_or_insert_with(GradeTrack::default);
match field {
FxField::GradeExposure => {
step_track(&mut grade.exposure, 0.0, 0.1, edit, -5.0..=5.0)
}
FxField::GradeTemperature => {
step_track(&mut grade.temperature, 0.0, 0.05, edit, -1.0..=1.0)
}
FxField::GradeTint => step_track(&mut grade.tint, 0.0, 0.05, edit, -1.0..=1.0),
_ => step_track(&mut grade.saturation, 1.0, 0.05, edit, 0.0..=2.0),
};
if grade.exposure.is_none()
&& grade.temperature.is_none()
&& grade.tint.is_none()
&& grade.saturation.is_none()
{
shot.lens.grading = None;
}
true
}
FxField::FocusMode => {
shot.lens.focus = match &shot.lens.focus {
None => Some(FocusTrack::Distance(ScalarTrack::constant(5.0))),
Some(_) => None,
};
true
}
FxField::FocusValue => match &mut shot.lens.focus {
Some(FocusTrack::Distance(track)) => {
let next = edit.apply(track.sample(0.0), 0.1).max(0.05);
*track = ScalarTrack::constant(next);
true
}
Some(FocusTrack::Target { offset, .. }) => {
*offset = edit.apply(*offset, 0.1).clamp(-20.0, 20.0);
true
}
None => false,
},
FxField::Aperture => {
let current = shot
.lens
.aperture_f_stops
.as_ref()
.map_or(2.8, |t| t.sample(0.0));
let next = edit.apply(current, 0.1);
shot.lens.aperture_f_stops =
(next >= 0.1).then(|| ScalarTrack::constant(next.min(22.0)));
true
}
FxField::ExposureEv100 => {
let current = shot
.lens
.exposure_ev100
.as_ref()
.map_or(-6.0, |t| t.sample(0.0));
let next = edit.apply(current, 0.25);
shot.lens.exposure_ev100 = (next > -6.0).then(|| ScalarTrack::constant(next.min(20.0)));
true
}
FxField::DofModeCycle => {
shot.lens.dof_mode = match shot.lens.dof_mode {
DofMode::Gaussian => DofMode::Bokeh,
DofMode::Bokeh => DofMode::Gaussian,
};
true
}
}
}
fn fov_keys_mut(fov: &mut FovSpec) -> &mut Vec<crate::ScalarKey> {
match fov {
FovSpec::VerticalFovDeg(track) | FovSpec::FocalLengthMm { track, .. } => &mut track.keys,
}
}
fn fov_keys_at(keys: &[crate::ScalarKey], time: f32) -> Option<f32> {
if keys.is_empty() {
None
} else {
Some(
ScalarTrack {
keys: keys.to_vec(),
}
.sample(time),
)
}
}
fn sanitize_selection(editor: &mut DirectorsCutState, sequence: &SequenceAsset) {
let valid = match editor.selection {
EditorSelection::None => true,
EditorSelection::Shot(shot) => shot < sequence.shots.len(),
EditorSelection::Key { shot, track, key } => {
sequence.shots.get(shot).is_some_and(|shot| match track {
KeyTrack::Camera => match &shot.rig {
Rig::Keys { keys, .. } => key < keys.len(),
_ => false,
},
KeyTrack::Lens => match &shot.lens.fov {
FovSpec::VerticalFovDeg(track) | FovSpec::FocalLengthMm { track, .. } => {
key < track.keys.len()
}
},
})
}
EditorSelection::Text(index) => index < sequence.texts.len(),
EditorSelection::ActorCue { track, cue } => sequence
.actors
.get(track)
.is_some_and(|t| cue < t.cues.len()),
};
if !valid {
editor.selection = EditorSelection::None;
}
editor.ui_dirty = true;
}
fn detect_external_changes(session: Res<ViewfinderSession>, mut editor: ResMut<DirectorsCutState>) {
if !editor.open || editor.dragging.is_some() {
return;
}
let fingerprint = sequence_fingerprint(&session.sequence);
if fingerprint != editor.fingerprint {
editor.fingerprint = fingerprint;
sanitize_selection(&mut editor, &session.sequence);
}
if session.playing != editor.last_playing {
editor.last_playing = session.playing;
editor.status = if session.playing {
"Preview playing (Space pauses)".into()
} else {
"Preview stopped".into()
};
editor.ui_dirty = true;
}
}
fn apply_editor_preview(
mut commands: Commands,
editor: Res<DirectorsCutState>,
director: Res<DirectorState>,
mut session: ResMut<ViewfinderSession>,
names: Query<(&Name, &GlobalTransform)>,
mut cine: Query<(&mut Transform, &mut Projection), With<CineCamera>>,
pre_take: Query<&crate::player::PreTakeState>,
) {
if !editor.open || session.playing || !editor.preview_requested {
return;
}
let Some(camera) = director.camera else {
return;
};
let Ok((mut transform, mut projection)) = cine.get_mut(camera) else {
return;
};
let live = session.live_snapshot;
let resolve = |wanted: &str| {
names
.iter()
.find(|(name, _)| name.as_str() == wanted)
.map(|(_, gt)| gt.translation())
};
let ctx = EvalCtx {
live: &live,
dt: 0.0,
prev_rot: None,
resolve_entity: &resolve,
held: pre_take.get(camera).ok().map(|p| p.held_snapshot()),
};
let Some(compiled) = &session.compiled else {
return;
};
let pose = compiled.pose_at(session.playhead, &ctx);
transform.translation = pose.position;
transform.rotation = pose.rotation;
if let Projection::Perspective(perspective) = &mut *projection {
perspective.fov = pose.fov_y;
}
let touch = crate::player::LensTouch::of(&pose);
crate::player::apply_lens_components(
&mut commands,
camera,
&pose,
touch.union(session.preview_lens),
pre_take.get(camera).ok().and_then(|p| p.grading()),
);
session.preview_lens = touch;
session.sync_pilot_from_pose(pose.rotation, pose.fov_y);
}
fn sequence_fingerprint(sequence: &SequenceAsset) -> u64 {
let mut hasher = DefaultHasher::new();
if let Ok(ron) = crate::to_ron_string(sequence) {
ron.hash(&mut hasher);
}
hasher.finish()
}
fn queue_button(
parent: &mut ChildSpawnerCommands,
label: Option<&str>,
icon: EditorIcon,
command: EditorCommand,
) {
parent
.spawn((
Node {
height: px(28),
min_width: px(if label.is_some() { 0 } else { 28 }),
padding: UiRect::horizontal(px(if label.is_some() { 7 } else { 5 })),
margin: UiRect::right(px(4)),
align_items: AlignItems::Center,
justify_content: JustifyContent::Center,
border: px(1).all(),
border_radius: BorderRadius::all(px(4)),
..default()
},
BackgroundColor(PANEL_2),
BorderColor::all(Color::srgb(0.16, 0.19, 0.24)),
Pickable::default(),
))
.observe(
move |mut press: On<Pointer<Press>>, mut editor: ResMut<DirectorsCutState>| {
if press.button == PointerButton::Primary {
editor.pending.push(command);
press.propagate(false);
}
},
)
.observe(
|over: On<Pointer<Over>>,
mut buttons: Query<(&mut BackgroundColor, &mut BorderColor)>| {
if let Ok((mut background, mut border)) = buttons.get_mut(over.entity) {
background.0 = Color::srgb(0.11, 0.14, 0.19);
border.set_all(ACCENT);
}
},
)
.observe(
|out: On<Pointer<Out>>,
mut buttons: Query<(&mut BackgroundColor, &mut BorderColor)>| {
if let Ok((mut background, mut border)) = buttons.get_mut(out.entity) {
background.0 = PANEL_2;
border.set_all(Color::srgb(0.16, 0.19, 0.24));
}
},
)
.with_children(|button| {
spawn_icon(button, icon, label.is_some());
if let Some(label) = label {
button.spawn((
Text::new(label),
TextFont::from_font_size(13.0),
TextColor(TEXT),
Pickable::IGNORE,
));
}
});
}
fn spawn_icon(parent: &mut ChildSpawnerCommands, icon: EditorIcon, trailing_margin: bool) {
parent
.spawn((
Node {
position_type: PositionType::Relative,
width: px(16),
height: px(16),
margin: UiRect::right(px(if trailing_margin { 6 } else { 0 })),
..default()
},
Pickable::IGNORE,
))
.with_children(|canvas| match icon {
EditorIcon::Director => {
icon_outline(canvas, 1.0, 5.0, 14.0, 9.0, ACCENT, 2.0);
icon_rect(canvas, 2.0, 2.0, 12.0, 3.0, TEXT, 1.0, -8.0);
icon_rect(canvas, 4.0, 6.5, 2.0, 5.5, ACCENT, 0.0, 0.0);
icon_rect(canvas, 8.0, 6.5, 2.0, 5.5, ACCENT, 0.0, 0.0);
}
EditorIcon::Freeze => {
icon_rect(canvas, 1.5, 7.25, 13.0, 1.5, TEXT, 0.75, 0.0);
icon_rect(canvas, 1.5, 7.25, 13.0, 1.5, TEXT, 0.75, 60.0);
icon_rect(canvas, 1.5, 7.25, 13.0, 1.5, TEXT, 0.75, -60.0);
}
EditorIcon::New => {
icon_outline(canvas, 2.0, 1.0, 11.0, 14.0, TEXT, 1.5);
icon_rect(canvas, 4.0, 9.0, 7.0, 1.5, ACCENT, 0.5, 0.0);
icon_rect(canvas, 4.0, 12.0, 5.0, 1.5, ACCENT, 0.5, 0.0);
}
EditorIcon::Open => {
icon_rect(canvas, 1.0, 5.0, 14.0, 10.0, ACCENT, 2.0, 0.0);
icon_rect(canvas, 2.0, 2.0, 7.0, 5.0, ACCENT, 1.5, 0.0);
icon_rect(canvas, 3.0, 7.0, 11.0, 1.5, TEXT, 0.5, -8.0);
}
EditorIcon::Save => {
icon_outline(canvas, 1.0, 1.0, 14.0, 14.0, ACCENT, 2.0);
icon_rect(canvas, 4.0, 1.0, 8.0, 5.0, TEXT, 0.5, 0.0);
icon_rect(canvas, 4.0, 9.0, 8.0, 5.0, ACCENT, 1.0, 0.0);
}
EditorIcon::Undo => spawn_arrow_icon(canvas, false),
EditorIcon::Redo => spawn_arrow_icon(canvas, true),
EditorIcon::Shot => {
icon_outline(canvas, 1.0, 3.0, 14.0, 11.0, ACCENT, 2.0);
icon_rect(canvas, 4.0, 5.0, 1.5, 7.0, TEXT, 0.5, 0.0);
icon_rect(canvas, 8.0, 5.0, 1.5, 7.0, TEXT, 0.5, 0.0);
icon_rect(canvas, 12.0, 5.0, 1.5, 7.0, TEXT, 0.5, 0.0);
}
EditorIcon::Key => {
icon_rect(canvas, 4.0, 4.0, 8.0, 8.0, ACCENT, 1.5, 45.0);
icon_rect(canvas, 7.25, 1.0, 1.5, 14.0, TEXT, 0.5, 0.0);
icon_rect(canvas, 1.0, 7.25, 14.0, 1.5, TEXT, 0.5, 0.0);
}
EditorIcon::Delete => {
icon_rect(
canvas,
4.0,
5.0,
8.0,
10.0,
Color::srgb(0.87, 0.35, 0.35),
1.5,
0.0,
);
icon_rect(canvas, 2.0, 3.0, 12.0, 2.0, TEXT, 0.5, 0.0);
icon_rect(canvas, 6.0, 1.0, 4.0, 2.0, TEXT, 0.5, 0.0);
}
EditorIcon::Play => {
icon_rect(canvas, 4.0, 3.0, 8.0, 2.5, ACCENT, 1.0, 45.0);
icon_rect(canvas, 4.0, 10.0, 8.0, 2.5, ACCENT, 1.0, -45.0);
}
EditorIcon::Pause => {
icon_rect(canvas, 3.0, 2.0, 3.5, 12.0, ACCENT, 1.0, 0.0);
icon_rect(canvas, 9.5, 2.0, 3.5, 12.0, ACCENT, 1.0, 0.0);
}
EditorIcon::Rename => {
icon_rect(canvas, 2.5, 7.0, 12.0, 3.0, ACCENT, 1.0, -45.0);
icon_rect(canvas, 1.0, 12.0, 4.0, 2.0, TEXT, 0.5, -45.0);
}
EditorIcon::Text => {
icon_rect(canvas, 2.0, 2.0, 12.0, 2.5, TITLE, 0.5, 0.0);
icon_rect(canvas, 6.75, 2.0, 2.5, 12.0, TITLE, 0.5, 0.0);
}
EditorIcon::Actor => {
icon_rect(canvas, 6.0, 1.0, 4.0, 4.0, ACTOR, 2.0, 0.0);
icon_rect(canvas, 7.0, 5.0, 2.0, 6.0, ACTOR, 0.5, 0.0);
icon_rect(canvas, 6.0, 10.0, 2.0, 5.0, ACTOR, 0.5, 20.0);
icon_rect(canvas, 8.0, 10.0, 2.0, 5.0, ACTOR, 0.5, -20.0);
}
EditorIcon::ZoomOut => spawn_zoom_icon(canvas, false),
EditorIcon::ZoomIn => spawn_zoom_icon(canvas, true),
EditorIcon::Minus => {
icon_rect(canvas, 3.0, 7.0, 10.0, 2.0, TEXT, 1.0, 0.0);
}
EditorIcon::Plus => {
icon_rect(canvas, 3.0, 7.0, 10.0, 2.0, TEXT, 1.0, 0.0);
icon_rect(canvas, 7.0, 3.0, 2.0, 10.0, TEXT, 1.0, 0.0);
}
});
}
fn spawn_arrow_icon(parent: &mut ChildSpawnerCommands, right: bool) {
icon_rect(parent, 3.0, 7.0, 10.0, 2.0, ACCENT, 1.0, 0.0);
let x = if right { 8.0 } else { 1.0 };
let angle = if right { 45.0 } else { -45.0 };
icon_rect(parent, x, 4.0, 7.0, 2.0, TEXT, 1.0, angle);
icon_rect(parent, x, 10.0, 7.0, 2.0, TEXT, 1.0, -angle);
}
fn spawn_zoom_icon(parent: &mut ChildSpawnerCommands, plus: bool) {
icon_outline(parent, 1.0, 1.0, 10.0, 10.0, TEXT, 5.0);
icon_rect(parent, 10.0, 10.0, 6.0, 2.0, ACCENT, 1.0, 45.0);
icon_rect(parent, 3.0, 5.0, 6.0, 1.5, TEXT, 0.5, 0.0);
if plus {
icon_rect(parent, 5.25, 2.75, 1.5, 6.0, TEXT, 0.5, 0.0);
}
}
#[allow(clippy::too_many_arguments)]
fn icon_rect(
parent: &mut ChildSpawnerCommands,
left: f32,
top: f32,
width: f32,
height: f32,
color: Color,
radius: f32,
rotation_deg: f32,
) {
parent.spawn((
Node {
position_type: PositionType::Absolute,
left: px(left),
top: px(top),
width: px(width),
height: px(height),
border_radius: BorderRadius::all(px(radius)),
..default()
},
BackgroundColor(color),
UiTransform::from_rotation(Rot2::degrees(rotation_deg)),
Pickable::IGNORE,
));
}
fn icon_outline(
parent: &mut ChildSpawnerCommands,
left: f32,
top: f32,
width: f32,
height: f32,
color: Color,
radius: f32,
) {
parent.spawn((
Node {
position_type: PositionType::Absolute,
left: px(left),
top: px(top),
width: px(width),
height: px(height),
border: px(1.5).all(),
border_radius: BorderRadius::all(px(radius)),
..default()
},
BorderColor::all(color),
Pickable::IGNORE,
));
}
fn spawn_sequence_name_input(parent: &mut ChildSpawnerCommands, initial_name: &str) {
parent.spawn((
Node {
width: px(190),
height: px(28),
padding: UiRect::horizontal(px(7)),
margin: UiRect::right(px(4)),
border: px(1).all(),
border_radius: BorderRadius::all(px(4)),
align_items: AlignItems::Center,
overflow: Overflow::clip_x(),
..default()
},
EditableText::new(initial_name),
EditableTextFilter::new(filename_character_allowed),
TextCursorStyle {
color: ACCENT,
selected_text_color: Some(Color::WHITE),
unfocused_selection_color: Color::NONE,
..default()
},
TextFont::from_font_size(13.0),
TextColor(TEXT),
BackgroundColor(Color::srgb(0.04, 0.048, 0.063)),
BorderColor::all(ACCENT),
SequenceNameInput,
SelectAllOnFocus,
AutoFocus,
Pickable::default(),
));
}
fn spawn_text_block_input(parent: &mut ChildSpawnerCommands, initial: &str) {
parent.spawn((
Node {
width: percent(100),
height: px(28),
padding: UiRect::horizontal(px(7)),
margin: UiRect::top(px(4)),
border: px(1).all(),
border_radius: BorderRadius::all(px(4)),
align_items: AlignItems::Center,
overflow: Overflow::clip_x(),
..default()
},
EditableText::new(initial),
TextCursorStyle {
color: TITLE,
selected_text_color: Some(Color::WHITE),
unfocused_selection_color: Color::NONE,
..default()
},
TextFont::from_font_size(13.0),
TextColor(TEXT),
BackgroundColor(Color::srgb(0.04, 0.048, 0.063)),
BorderColor::all(TITLE),
TextBlockInput,
SelectAllOnFocus,
AutoFocus,
Pickable::default(),
));
}
fn spawn_actor_field_input(
parent: &mut ChildSpawnerCommands,
initial: &str,
field: ActorEditField,
) {
let mut input = parent.spawn((
Node {
width: percent(100),
height: px(28),
padding: UiRect::horizontal(px(7)),
margin: UiRect::top(px(4)),
border: px(1).all(),
border_radius: BorderRadius::all(px(4)),
align_items: AlignItems::Center,
overflow: Overflow::clip_x(),
..default()
},
EditableText::new(initial),
TextCursorStyle {
color: ACTOR,
selected_text_color: Some(Color::WHITE),
unfocused_selection_color: Color::NONE,
..default()
},
TextFont::from_font_size(13.0),
TextColor(TEXT),
BackgroundColor(Color::srgb(0.04, 0.048, 0.063)),
BorderColor::all(ACTOR),
SelectAllOnFocus,
AutoFocus,
Pickable::default(),
));
match field {
ActorEditField::Anim => input.insert(ActorAnimInput),
ActorEditField::Target => input.insert(ActorTargetInput),
};
}
fn spawn_shot_camera_input(parent: &mut ChildSpawnerCommands, initial: &str) {
parent.spawn((
Node {
width: percent(100),
height: px(28),
padding: UiRect::horizontal(px(7)),
margin: UiRect::top(px(4)),
border: px(1).all(),
border_radius: BorderRadius::all(px(4)),
align_items: AlignItems::Center,
overflow: Overflow::clip_x(),
..default()
},
EditableText::new(initial),
TextCursorStyle {
color: ACCENT,
selected_text_color: Some(Color::WHITE),
unfocused_selection_color: Color::NONE,
..default()
},
TextFont::from_font_size(13.0),
TextColor(TEXT),
BackgroundColor(Color::srgb(0.04, 0.048, 0.063)),
BorderColor::all(ACCENT),
ShotCameraInput,
SelectAllOnFocus,
AutoFocus,
Pickable::default(),
));
}
fn spawn_value_input(parent: &mut ChildSpawnerCommands, value: &str) {
parent.spawn((
Node {
width: px(74),
height: px(21),
padding: UiRect::horizontal(px(4)),
border: px(1).all(),
border_radius: BorderRadius::all(px(3)),
align_items: AlignItems::Center,
overflow: Overflow::clip_x(),
..default()
},
EditableText::new(numeric_seed(value)),
EditableTextFilter::new(|character: char| {
character.is_ascii_digit() || matches!(character, '.' | '-' | '+')
}),
TextCursorStyle {
color: ACCENT,
selected_text_color: Some(Color::WHITE),
unfocused_selection_color: Color::NONE,
..default()
},
TextFont::from_font_size(12.0),
TextColor(TEXT),
BackgroundColor(Color::srgb(0.04, 0.048, 0.063)),
BorderColor::all(ACCENT),
ValueInput,
SelectAllOnFocus,
AutoFocus,
Pickable::default(),
));
}
fn text(parent: &mut ChildSpawnerCommands, value: impl Into<String>, size: f32, color: Color) {
parent.spawn((
Text::new(value),
TextFont::from_font_size(size),
TextColor(color),
Pickable::IGNORE,
));
}
#[allow(clippy::too_many_arguments)]
fn rebuild_editor_ui(
mut commands: Commands,
roots: Query<Entity, With<DirectorsCutRoot>>,
mut editor: ResMut<DirectorsCutState>,
config: Res<DirectorsCutConfig>,
viewfinder_config: Res<ViewfinderConfig>,
session: Res<ViewfinderSession>,
director: Res<DirectorState>,
virtual_time: Res<Time<Virtual>>,
) {
if !editor.ui_dirty {
return;
}
for root in roots.iter() {
commands.entity(root).despawn();
}
editor.ui_dirty = false;
editor.fingerprint = sequence_fingerprint(&session.sequence);
if editor.saved_fingerprint == 0 {
editor.saved_fingerprint = editor.fingerprint;
}
if !editor.open {
return;
}
let max_text_end = session
.sequence
.texts
.iter()
.map(TextBlock::end)
.fold(0.0, f32::max);
let max_actor_end = session
.sequence
.actors
.iter()
.flat_map(|track| track.cues.iter())
.map(ActorCue::end)
.fold(0.0, f32::max);
let width_seconds = config
.min_timeline_seconds
.max(session.sequence.duration() + 2.0)
.max(max_text_end + 2.0)
.max(max_actor_end + 2.0);
let canvas_width = width_seconds * config.pixels_per_second;
let selection = editor.selection;
let status = editor.status.clone();
let renaming = editor.renaming;
let editing_text = editor.editing_text;
let editing_actor = editor.editing_actor;
let editing_shot_camera = editor.editing_shot_camera;
let editing_value = editor.editing_value;
let inspector_scroll = editor.inspector_scroll;
let frozen = virtual_time.is_paused();
let auto_key = viewfinder_config.auto_key;
let sequence = session.sequence.clone();
let browse_panel = editor.browsing.as_ref().map(|browse| {
let current = format!("{}.dir.ron", sequence.name);
let names: Vec<(String, bool)> = browse
.entries
.iter()
.map(|path| {
let name = path
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("<unreadable>")
.to_owned();
let is_current = name == current;
(name, is_current)
})
.collect();
(browse.dir.display().to_string(), names)
});
let mut root_entity = commands.spawn((
DirectorsCutRoot,
Node {
position_type: PositionType::Absolute,
left: px(0),
right: px(0),
bottom: px(0),
width: percent(100),
height: px(config.dock_height),
flex_direction: FlexDirection::Column,
border: UiRect::top(px(1)),
..default()
},
BackgroundColor(PANEL),
BorderColor::all(Color::srgb(0.18, 0.22, 0.28)),
GlobalZIndex(1000),
Pickable::default(),
));
if let Some(camera) = director.camera {
root_entity.insert(UiTargetCamera(camera));
}
root_entity.with_children(|root| {
root.spawn((
Node {
height: px(42),
min_height: px(42),
padding: UiRect::horizontal(px(10)),
align_items: AlignItems::Center,
..default()
},
BackgroundColor(PANEL_2),
))
.with_children(|toolbar| {
spawn_icon(toolbar, EditorIcon::Director, true);
text(toolbar, "DIRECTOR'S CUT", 15.0, Color::WHITE);
toolbar.spawn(Node {
width: px(14),
..default()
});
queue_button(toolbar, Some("New"), EditorIcon::New, EditorCommand::New);
queue_button(toolbar, Some("Open"), EditorIcon::Open, EditorCommand::Open);
queue_button(toolbar, Some("Save"), EditorIcon::Save, EditorCommand::Save);
queue_button(toolbar, Some("Undo"), EditorIcon::Undo, EditorCommand::Undo);
queue_button(toolbar, Some("Redo"), EditorIcon::Redo, EditorCommand::Redo);
queue_button(
toolbar,
Some("Shot"),
EditorIcon::Shot,
EditorCommand::AddShot,
);
queue_button(
toolbar,
Some("Key"),
EditorIcon::Key,
EditorCommand::CaptureKey,
);
queue_button(
toolbar,
Some(if auto_key { "Auto on" } else { "Auto off" }),
EditorIcon::Key,
EditorCommand::ToggleAutoKey,
);
queue_button(
toolbar,
Some("Text"),
EditorIcon::Text,
EditorCommand::AddText,
);
queue_button(
toolbar,
Some("Actor"),
EditorIcon::Actor,
EditorCommand::AddActorCue,
);
queue_button(
toolbar,
Some("Delete"),
EditorIcon::Delete,
EditorCommand::Delete,
);
queue_button(
toolbar,
Some(if session.playing { "Pause" } else { "Play" }),
if session.playing {
EditorIcon::Pause
} else {
EditorIcon::Play
},
EditorCommand::PlayPause,
);
queue_button(
toolbar,
Some(if frozen { "Resume" } else { "Freeze" }),
EditorIcon::Freeze,
EditorCommand::FreezeWorld,
);
queue_button(toolbar, None, EditorIcon::ZoomOut, EditorCommand::Zoom(0.8));
queue_button(toolbar, None, EditorIcon::ZoomIn, EditorCommand::Zoom(1.25));
toolbar.spawn(Node {
flex_grow: 1.0,
..default()
});
if let Some(path) = config.sequence_path.as_ref() {
text(
toolbar,
path.file_name()
.and_then(|name| name.to_str())
.unwrap_or("configured sequence path"),
13.0,
TEXT,
);
} else if renaming {
spawn_sequence_name_input(toolbar, &sequence.name);
text(toolbar, ".dir.ron", 12.0, MUTED);
} else {
let filename = format!("{}.dir.ron", sequence.name);
queue_button(
toolbar,
Some(&filename),
EditorIcon::Rename,
EditorCommand::BeginRename,
);
}
text(toolbar, format!(" | {:.2}s", session.playhead), 13.0, TEXT);
});
root.spawn(Node {
flex_grow: 1.0,
flex_direction: FlexDirection::Row,
min_height: px(0),
..default()
})
.with_children(|body| {
body.spawn((
Node {
width: px(292),
min_width: px(292),
padding: UiRect::all(px(10)),
flex_direction: FlexDirection::Column,
overflow: Overflow::scroll_y(),
..default()
},
ScrollPosition(Vec2::new(0.0, inspector_scroll)),
InspectorPanel,
BackgroundColor(PANEL),
Pickable::default(),
))
.observe(
|scroll: On<Pointer<Scroll>>,
mut nodes: Query<(&mut ScrollPosition, &ComputedNode)>,
mut editor: ResMut<DirectorsCutState>| {
if let Ok((mut position, node)) = nodes.get_mut(scroll.entity) {
let amount = match scroll.unit {
MouseScrollUnit::Line => scroll.y * 32.0,
MouseScrollUnit::Pixel => scroll.y,
};
let range = (node.content_size.y - node.size.y).max(0.0)
* node.inverse_scale_factor;
position.y = (position.y - amount).clamp(0.0, range);
editor.inspector_scroll = position.y;
}
},
)
.with_children(|inspector| {
spawn_inspector(
inspector,
&sequence,
selection,
editing_text,
editing_actor,
editing_shot_camera,
editing_value,
);
});
body.spawn((
Node {
position_type: PositionType::Absolute,
left: px(284),
top: px(4),
bottom: px(4),
width: px(4),
..default()
},
Pickable::IGNORE,
))
.with_children(|track| {
track.spawn((
InspectorScrollThumb,
Node {
position_type: PositionType::Absolute,
width: px(4),
height: percent(0),
border_radius: BorderRadius::all(px(2)),
..default()
},
BackgroundColor(MUTED.with_alpha(0.5)),
Pickable::IGNORE,
));
});
body.spawn((
Node {
flex_grow: 1.0,
min_width: px(0),
overflow: Overflow::scroll_x(),
..default()
},
ScrollPosition::default(),
BackgroundColor(TRACK),
Pickable::default(),
))
.observe(
|scroll: On<Pointer<Scroll>>,
mut nodes: Query<(&mut ScrollPosition, &ComputedNode)>| {
if let Ok((mut position, node)) = nodes.get_mut(scroll.entity) {
let amount = match scroll.unit {
MouseScrollUnit::Line => (scroll.x + scroll.y) * 32.0,
MouseScrollUnit::Pixel => scroll.x + scroll.y,
};
let range = (node.content_size.x - node.size.x).max(0.0)
* node.inverse_scale_factor;
position.x = (position.x - amount).clamp(0.0, range);
}
},
)
.with_children(|scroller| {
spawn_timeline(
scroller,
&sequence,
selection,
session.playhead,
canvas_width,
width_seconds,
config.pixels_per_second,
);
});
});
root.spawn((
Node {
height: px(24),
min_height: px(24),
padding: UiRect::horizontal(px(10)),
align_items: AlignItems::Center,
..default()
},
BackgroundColor(Color::srgb(0.045, 0.052, 0.067)),
))
.with_children(|bar| {
text(
bar,
format!(
"{} | Ctrl+S save | Ctrl+O open | Ctrl+Z/Y undo/redo | Tab pilot/editor",
status
),
11.0,
MUTED,
);
});
if let Some((dir, names)) = browse_panel {
spawn_open_panel(root, &dir, &names);
}
});
}
fn spawn_open_panel(root: &mut ChildSpawnerCommands, dir: &str, names: &[(String, bool)]) {
root.spawn((
Node {
position_type: PositionType::Absolute,
left: px(300),
top: px(46),
width: px(340),
max_height: px(220),
flex_direction: FlexDirection::Column,
padding: UiRect::all(px(8)),
row_gap: px(2),
overflow: Overflow::scroll_y(),
border: UiRect::all(px(1)),
border_radius: BorderRadius::all(px(4)),
..default()
},
ScrollPosition::default(),
BackgroundColor(PANEL_2),
BorderColor::all(ACCENT),
ZIndex(20),
Pickable::default(),
))
.observe(
|scroll: On<Pointer<Scroll>>, mut nodes: Query<(&mut ScrollPosition, &ComputedNode)>| {
if let Ok((mut position, node)) = nodes.get_mut(scroll.entity) {
let amount = match scroll.unit {
MouseScrollUnit::Line => scroll.y * 32.0,
MouseScrollUnit::Pixel => scroll.y,
};
let range =
(node.content_size.y - node.size.y).max(0.0) * node.inverse_scale_factor;
position.y = (position.y - amount).clamp(0.0, range);
}
},
)
.with_children(|panel| {
text(panel, format!("OPEN - {dir}"), 11.0, MUTED);
if names.is_empty() {
text(panel, "No .dir.ron files here yet - press Save", 12.0, TEXT);
}
for (index, (name, is_current)) in names.iter().enumerate() {
let row_border = if *is_current {
SELECTED
} else {
Color::srgb(0.16, 0.19, 0.24)
};
panel
.spawn((
Node {
min_height: px(24),
padding: UiRect::horizontal(px(7)),
align_items: AlignItems::Center,
border: px(1).all(),
border_radius: BorderRadius::all(px(4)),
..default()
},
BackgroundColor(PANEL_2),
BorderColor::all(row_border),
Pickable::default(),
))
.observe(
move |mut press: On<Pointer<Press>>, mut editor: ResMut<DirectorsCutState>| {
if press.button == PointerButton::Primary {
editor.pending.push(EditorCommand::OpenEntry(index));
press.propagate(false);
}
},
)
.observe(
|over: On<Pointer<Over>>, mut rows: Query<&mut BackgroundColor>| {
if let Ok(mut background) = rows.get_mut(over.entity) {
background.0 = Color::srgb(0.11, 0.14, 0.19);
}
},
)
.observe(
|out: On<Pointer<Out>>, mut rows: Query<&mut BackgroundColor>| {
if let Ok(mut background) = rows.get_mut(out.entity) {
background.0 = PANEL_2;
}
},
)
.with_children(|row| {
text(row, name.clone(), 13.0, TEXT);
});
}
});
}
fn spawn_inspector(
parent: &mut ChildSpawnerCommands,
sequence: &SequenceAsset,
selection: EditorSelection,
editing_text: bool,
editing_actor: Option<ActorEditField>,
editing_shot_camera: bool,
editing: Option<AdjustField>,
) {
text(parent, "INSPECTOR", 12.0, MUTED);
parent.spawn(Node {
height: px(7),
..default()
});
match selection {
EditorSelection::None => {
text(
parent,
"Select a shot, key, text block, or actor cue on the timeline.",
13.0,
TEXT,
);
text(
parent,
"Drag a shot to move it, drag its right edge to resize. Drag keys to retime.",
11.0,
MUTED,
);
}
EditorSelection::Text(index) => {
let Some(block) = sequence.texts.get(index) else {
return;
};
text(parent, format!("Text {}", index + 1), 16.0, Color::WHITE);
adjustment_row(
parent,
"Start",
format!("{:.2} s", block.start),
AdjustField::TextStart,
editing,
);
adjustment_row(
parent,
"Duration",
format!("{:.2} s", block.duration),
AdjustField::TextDuration,
editing,
);
adjustment_row(
parent,
"Fade in",
format!("{:.2} s", block.fade_in),
AdjustField::TextFadeIn,
editing,
);
adjustment_row(
parent,
"Fade out",
format!("{:.2} s", block.fade_out),
AdjustField::TextFadeOut,
editing,
);
adjustment_row(
parent,
"Size",
format!("{:.0}", block.style.font_size),
AdjustField::TextSize,
editing,
);
adjustment_row(
parent,
"Anchor",
anchor_label(block.anchor).into(),
AdjustField::TextAnchorCycle,
editing,
);
if editing_text {
spawn_text_block_input(parent, &block.text);
} else {
let mut content = block.text.clone();
if content.chars().count() > 24 {
content = content.chars().take(23).collect();
content.push('\u{2026}');
}
queue_button(
parent,
Some(&content),
EditorIcon::Rename,
EditorCommand::BeginTextEdit,
);
}
}
EditorSelection::Shot(index) => {
let Some(shot) = sequence.shots.get(index) else {
return;
};
text(parent, format!("Shot {}", index + 1), 16.0, Color::WHITE);
adjustment_row(
parent,
"Start",
format!("{:.2} s", shot.start),
AdjustField::ShotStart,
editing,
);
adjustment_row(
parent,
"Duration",
format!("{:.2} s", shot.duration),
AdjustField::ShotDuration,
editing,
);
let rig = match &shot.rig {
Rig::Keys { interp, .. } => match interp {
KeyInterp::Eased => "keys",
KeyInterp::CatmullRom => "spline",
},
Rig::Rail { .. } => "rail",
Rig::Orbit { .. } => "orbit",
Rig::Held { .. } => "held",
};
match &shot.rig {
Rig::Keys { .. } | Rig::Held { .. } => {
adjustment_row(parent, "Rig", rig.into(), AdjustField::RigCycle, editing);
}
_ => text(parent, format!("Rig {rig}"), 12.0, MUTED),
}
if let Rig::Held { damping } = &shot.rig {
adjustment_row(
parent,
"Mount ease",
match damping {
Some(decay) => format!("{decay:.0}"),
None => "welded".into(),
},
AdjustField::Fx(FxField::MountEase),
editing,
);
}
if editing_shot_camera {
spawn_shot_camera_input(parent, shot.camera.as_deref().unwrap_or_default());
} else {
let camera = match &shot.camera {
Some(name) => format!("Camera {name}"),
None => "Camera (take's own)".to_string(),
};
queue_button(
parent,
Some(&camera),
EditorIcon::Rename,
EditorCommand::BeginShotCameraEdit,
);
}
spawn_shake_section(parent, shot, editing);
spawn_fx_section(parent, shot, editing);
spawn_lens_section(parent, shot, editing);
}
EditorSelection::Key { shot, track, key } => {
let Some(shot_data) = sequence.shots.get(shot) else {
return;
};
text(
parent,
format!("Shot {} / {:?} key {}", shot + 1, track, key + 1),
15.0,
Color::WHITE,
);
match track {
KeyTrack::Camera => {
let Rig::Keys { keys, .. } = &shot_data.rig else {
return;
};
let Some(camera_key) = keys.get(key) else {
return;
};
let (yaw, pitch, roll) = camera_key
.rot
.unwrap_or(Quat::IDENTITY)
.to_euler(EulerRot::YXZ);
adjustment_row(
parent,
"Time",
format!("{:.2} s", camera_key.time),
AdjustField::KeyTime,
editing,
);
adjustment_row(
parent,
"Position X",
format!("{:.2}", camera_key.pos.x),
AdjustField::PositionX,
editing,
);
adjustment_row(
parent,
"Position Y",
format!("{:.2}", camera_key.pos.y),
AdjustField::PositionY,
editing,
);
adjustment_row(
parent,
"Position Z",
format!("{:.2}", camera_key.pos.z),
AdjustField::PositionZ,
editing,
);
adjustment_row(
parent,
"Yaw",
format!("{:.1} deg", yaw.to_degrees()),
AdjustField::Yaw,
editing,
);
adjustment_row(
parent,
"Pitch",
format!("{:.1} deg", pitch.to_degrees()),
AdjustField::Pitch,
editing,
);
adjustment_row(
parent,
"Roll",
format!("{:.1} deg", roll.to_degrees()),
AdjustField::Roll,
editing,
);
let fov = match &shot_data.lens.fov {
FovSpec::VerticalFovDeg(track) => track.sample(camera_key.time),
FovSpec::FocalLengthMm { track, .. } => track.sample(camera_key.time),
};
adjustment_row(
parent,
"FOV / focal",
format!("{fov:.1}"),
AdjustField::Fov,
editing,
);
}
KeyTrack::Lens => {
let keys = match &shot_data.lens.fov {
FovSpec::VerticalFovDeg(track) | FovSpec::FocalLengthMm { track, .. } => {
&track.keys
}
};
let Some(lens_key) = keys.get(key) else {
return;
};
adjustment_row(
parent,
"Time",
format!("{:.2} s", lens_key.time),
AdjustField::KeyTime,
editing,
);
adjustment_row(
parent,
"FOV / focal",
format!("{:.1}", lens_key.value),
AdjustField::Fov,
editing,
);
}
}
}
EditorSelection::ActorCue { track, cue } => {
let Some(track_data) = sequence.actors.get(track) else {
return;
};
let Some(cue_data) = track_data.cues.get(cue) else {
return;
};
text(
parent,
format!("Actor {} / cue {}", track + 1, cue + 1),
15.0,
Color::WHITE,
);
if editing_actor == Some(ActorEditField::Target) {
spawn_actor_field_input(
parent,
&target_display(&track_data.target),
ActorEditField::Target,
);
} else {
let target = match &track_data.target {
TargetRef::Entity(name) => format!("Target {name}"),
TargetRef::Point(p) => format!("Target point {p:?}"),
};
queue_button(
parent,
Some(&target),
EditorIcon::Rename,
EditorCommand::BeginActorTargetEdit,
);
}
adjustment_row(
parent,
"Start",
format!("{:.2} s", cue_data.start),
AdjustField::ActorStart,
editing,
);
adjustment_row(
parent,
"Duration",
format!("{:.2} s", cue_data.duration),
AdjustField::ActorDuration,
editing,
);
adjustment_row(
parent,
"Fade in",
format!("{:.2} s", cue_data.fade_in),
AdjustField::ActorFadeIn,
editing,
);
adjustment_row(
parent,
"Fade out",
format!("{:.2} s", cue_data.fade_out),
AdjustField::ActorFadeOut,
editing,
);
adjustment_row(
parent,
"Speed",
format!("{:.2}x", cue_data.speed),
AdjustField::ActorSpeed,
editing,
);
if editing_actor == Some(ActorEditField::Anim) {
spawn_actor_field_input(parent, &cue_data.anim, ActorEditField::Anim);
} else {
let mut name = cue_data.anim.clone();
if name.chars().count() > 24 {
name = name.chars().take(23).collect();
name.push('\u{2026}');
}
queue_button(
parent,
Some(&name),
EditorIcon::Rename,
EditorCommand::BeginActorAnimEdit,
);
}
}
}
}
fn section(parent: &mut ChildSpawnerCommands, title: &str) {
parent.spawn(Node {
height: px(10),
min_height: px(10),
..default()
});
text(parent, title, 11.0, MUTED);
}
fn track_label(track: Option<&ScalarTrack>, unit: &str, signed: bool) -> String {
match track {
Some(track) if signed => format!("{:+.2}{unit}", track.sample(0.0)),
Some(track) => format!("{:.2}{unit}", track.sample(0.0)),
None => "off".into(),
}
}
fn amount_label(amount: Option<f32>, unit: &str) -> String {
match amount {
Some(value) => format!("{value:.2}{unit}"),
None => "off".into(),
}
}
fn spawn_shake_section(
parent: &mut ChildSpawnerCommands,
shot: &Shot,
editing: Option<AdjustField>,
) {
section(parent, "SHAKE");
queue_button(
parent,
Some(if shot.shake.is_some() {
"Handheld on"
} else {
"Handheld off"
}),
EditorIcon::Key,
EditorCommand::Adjust(AdjustField::Fx(FxField::ShakeEnable), Edit::Step(1.0)),
);
let Some(shake) = &shot.shake else {
return;
};
adjustment_row(
parent,
"Amplitude",
format!("{:.2} deg", shake.amplitude_deg),
AdjustField::Fx(FxField::ShakeAmplitude),
editing,
);
adjustment_row(
parent,
"Frequency",
format!("{:.1} Hz", shake.frequency_hz),
AdjustField::Fx(FxField::ShakeFrequency),
editing,
);
adjustment_row(
parent,
"Position",
format!("{:.3} m", shake.pos_amplitude),
AdjustField::Fx(FxField::ShakePosition),
editing,
);
adjustment_row(
parent,
"Seed",
format!("{}", shake.seed),
AdjustField::Fx(FxField::ShakeSeed),
editing,
);
adjustment_row(
parent,
"Fov wobble",
amount_label(shake.fov_amplitude_deg, " deg"),
AdjustField::Fx(FxField::ShakeFovAmplitude),
editing,
);
adjustment_row(
parent,
"Focus hunt",
amount_label(shake.focus_amplitude, " m"),
AdjustField::Fx(FxField::ShakeFocusAmplitude),
editing,
);
adjustment_row(
parent,
"Ramp",
ramp_label(&shake.ramp, shot.duration).into(),
AdjustField::Fx(FxField::ShakeRamp),
editing,
);
}
fn spawn_fx_section(parent: &mut ChildSpawnerCommands, shot: &Shot, editing: Option<AdjustField>) {
section(parent, "FX");
adjustment_row(
parent,
"Vignette",
track_label(shot.lens.vignette.as_ref(), "", false),
AdjustField::Fx(FxField::Vignette),
editing,
);
adjustment_row(
parent,
"Distortion",
track_label(shot.lens.distortion.as_ref(), "", true),
AdjustField::Fx(FxField::Distortion),
editing,
);
adjustment_row(
parent,
"Aberration",
track_label(shot.lens.aberration.as_ref(), "", false),
AdjustField::Fx(FxField::Aberration),
editing,
);
queue_button(
parent,
Some(if shot.lens.motion_blur.is_some() {
"Motion blur on"
} else {
"Motion blur off"
}),
EditorIcon::Key,
EditorCommand::Adjust(AdjustField::Fx(FxField::MotionBlurEnable), Edit::Step(1.0)),
);
if let Some(blur) = &shot.lens.motion_blur {
adjustment_row(
parent,
"Shutter",
format!("{:.2}", blur.shutter_angle),
AdjustField::Fx(FxField::ShutterAngle),
editing,
);
adjustment_row(
parent,
"Samples",
format!("{}", blur.samples),
AdjustField::Fx(FxField::BlurSamples),
editing,
);
}
let grade = shot.lens.grading.as_ref();
adjustment_row(
parent,
"Exposure",
track_label(grade.and_then(|g| g.exposure.as_ref()), " EV", true),
AdjustField::Fx(FxField::GradeExposure),
editing,
);
adjustment_row(
parent,
"Temperature",
track_label(grade.and_then(|g| g.temperature.as_ref()), "", true),
AdjustField::Fx(FxField::GradeTemperature),
editing,
);
adjustment_row(
parent,
"Tint",
track_label(grade.and_then(|g| g.tint.as_ref()), "", true),
AdjustField::Fx(FxField::GradeTint),
editing,
);
adjustment_row(
parent,
"Saturation",
track_label(grade.and_then(|g| g.saturation.as_ref()), "", false),
AdjustField::Fx(FxField::GradeSaturation),
editing,
);
}
fn spawn_lens_section(
parent: &mut ChildSpawnerCommands,
shot: &Shot,
editing: Option<AdjustField>,
) {
section(parent, "LENS");
adjustment_row(
parent,
"Focus",
match &shot.lens.focus {
None => "off",
Some(FocusTrack::Distance(_)) => "distance",
Some(FocusTrack::Target { .. }) => "target",
}
.into(),
AdjustField::Fx(FxField::FocusMode),
editing,
);
match &shot.lens.focus {
Some(FocusTrack::Distance(track)) => adjustment_row(
parent,
"Distance",
format!("{:.2} m", track.sample(0.0)),
AdjustField::Fx(FxField::FocusValue),
editing,
),
Some(FocusTrack::Target { target, offset }) => {
text(
parent,
format!(" on {}", target_display(target)),
11.0,
MUTED,
);
adjustment_row(
parent,
"Offset",
format!("{offset:.2} m"),
AdjustField::Fx(FxField::FocusValue),
editing,
);
}
None => {}
}
adjustment_row(
parent,
"Aperture",
match &shot.lens.aperture_f_stops {
Some(track) => format!("f/{:.1}", track.sample(0.0)),
None => "off".into(),
},
AdjustField::Fx(FxField::Aperture),
editing,
);
adjustment_row(
parent,
"EV100",
track_label(shot.lens.exposure_ev100.as_ref(), "", true),
AdjustField::Fx(FxField::ExposureEv100),
editing,
);
adjustment_row(
parent,
"DoF",
match shot.lens.dof_mode {
DofMode::Gaussian => "gaussian",
DofMode::Bokeh => "bokeh",
}
.into(),
AdjustField::Fx(FxField::DofModeCycle),
editing,
);
}
fn adjustment_row(
parent: &mut ChildSpawnerCommands,
label: &str,
value: String,
field: AdjustField,
editing: Option<AdjustField>,
) {
parent
.spawn(Node {
height: px(27),
min_height: px(27),
align_items: AlignItems::Center,
..default()
})
.with_children(|row| {
row.spawn(Node {
width: px(92),
..default()
})
.with_child((
Text::new(label),
TextFont::from_font_size(11.0),
TextColor(MUTED),
Pickable::IGNORE,
));
queue_button(
row,
None,
EditorIcon::Minus,
EditorCommand::Adjust(field, Edit::Step(-1.0)),
);
if editing == Some(field) {
spawn_value_input(row, &value);
} else if field.typable() {
row.spawn((
Node {
width: px(74),
height: px(21),
align_items: AlignItems::Center,
justify_content: JustifyContent::Center,
border_radius: BorderRadius::all(px(3)),
..default()
},
Pickable::default(),
))
.observe(
move |mut press: On<Pointer<Press>>, mut editor: ResMut<DirectorsCutState>| {
editor.pending.push(EditorCommand::BeginValueEdit(field));
press.propagate(false);
},
)
.observe(
|over: On<Pointer<Over>>, mut colors: Query<&mut BackgroundColor>| {
if let Ok(mut color) = colors.get_mut(over.entity) {
*color = BackgroundColor(PANEL_2);
}
},
)
.observe(
|out: On<Pointer<Out>>, mut colors: Query<&mut BackgroundColor>| {
if let Ok(mut color) = colors.get_mut(out.entity) {
*color = BackgroundColor(Color::NONE);
}
},
)
.with_child((
Text::new(value),
TextFont::from_font_size(12.0),
TextColor(TEXT),
Pickable::IGNORE,
));
} else {
row.spawn(Node {
width: px(74),
align_items: AlignItems::Center,
justify_content: JustifyContent::Center,
..default()
})
.with_child((
Text::new(value),
TextFont::from_font_size(12.0),
TextColor(TEXT),
Pickable::IGNORE,
));
}
queue_button(
row,
None,
EditorIcon::Plus,
EditorCommand::Adjust(field, Edit::Step(1.0)),
);
});
}
#[allow(clippy::too_many_arguments)]
fn spawn_timeline(
parent: &mut ChildSpawnerCommands,
sequence: &SequenceAsset,
selection: EditorSelection,
playhead: f32,
width: f32,
width_seconds: f32,
pixels_per_second: f32,
) {
parent
.spawn((
Node {
position_type: PositionType::Relative,
width: px(width),
min_width: px(width),
height: percent(100),
..default()
},
BackgroundColor(TRACK),
Pickable::default(),
))
.observe(
move |mut press: On<Pointer<Press>>,
mut session: ResMut<ViewfinderSession>,
mut editor: ResMut<DirectorsCutState>| {
if press.button != PointerButton::Primary {
return;
}
if let Some(position) = press.hit.position {
session.playhead = ((position.x + 0.5) * width_seconds)
.clamp(0.0, session.sequence.duration());
session.playing = false;
editor.dragging = Some(DragState::Playhead {
origin: session.playhead,
});
editor.preview_requested = true;
editor.status = format!("Playhead {:.2}s", session.playhead);
}
press.propagate(false);
},
)
.observe(
|mut click: On<Pointer<Click>>, mut editor: ResMut<DirectorsCutState>| {
if matches!(editor.dragging, Some(DragState::Playhead { .. })) {
editor.dragging = None;
}
editor.ui_dirty = true;
click.propagate(false);
},
)
.observe(
move |mut drag: On<Pointer<Drag>>,
mut session: ResMut<ViewfinderSession>,
mut editor: ResMut<DirectorsCutState>| {
if let Some(DragState::Playhead { origin }) = editor.dragging {
session.playhead = (origin + drag.distance.x / pixels_per_second)
.clamp(0.0, session.sequence.duration());
session.playing = false;
editor.preview_requested = true;
}
drag.propagate(false);
},
)
.observe(
|mut drag: On<Pointer<DragEnd>>, mut editor: ResMut<DirectorsCutState>| {
editor.dragging = None;
editor.ui_dirty = true;
drag.propagate(false);
},
)
.with_children(|canvas| {
for second in 0..=width_seconds.ceil() as usize {
let x = second as f32 * pixels_per_second;
canvas.spawn((
Node {
position_type: PositionType::Absolute,
left: px(x),
top: px(0),
width: px(1),
bottom: px(0),
..default()
},
BackgroundColor(Color::srgba(0.42, 0.47, 0.55, 0.22)),
Pickable::IGNORE,
));
canvas.spawn((
Text::new(format!("{second}s")),
TextFont::from_font_size(10.0),
TextColor(MUTED),
Node {
position_type: PositionType::Absolute,
left: px(x + 4.0),
top: px(4),
..default()
},
Pickable::IGNORE,
));
}
lane(canvas, SHOT_LANE_Y, "CAMERA CUTS");
lane(canvas, CAMERA_LANE_Y, "TRANSFORM");
lane(canvas, LENS_LANE_Y, "LENS");
lane(canvas, TEXT_LANE_Y, "TEXT");
lane(canvas, ACTOR_LANE_Y, "ACTORS");
for (text_index, block) in sequence.texts.iter().enumerate() {
spawn_text_block(canvas, text_index, block, selection, pixels_per_second);
}
for (track_index, track) in sequence.actors.iter().enumerate() {
for (cue_index, cue) in track.cues.iter().enumerate() {
spawn_actor_cue(
canvas,
track_index,
cue_index,
cue,
selection,
pixels_per_second,
);
}
}
for (shot_index, shot) in sequence.shots.iter().enumerate() {
spawn_shot(canvas, shot_index, shot, selection, pixels_per_second);
if let Rig::Keys { keys, .. } = &shot.rig {
for (key_index, key) in keys.iter().enumerate() {
spawn_key(
canvas,
shot_index,
key_index,
KeyTrack::Camera,
shot.start + key.time,
selection,
pixels_per_second,
);
}
}
let lens_keys = match &shot.lens.fov {
FovSpec::VerticalFovDeg(track) | FovSpec::FocalLengthMm { track, .. } => {
&track.keys
}
};
for (key_index, key) in lens_keys.iter().enumerate() {
spawn_key(
canvas,
shot_index,
key_index,
KeyTrack::Lens,
shot.start + key.time,
selection,
pixels_per_second,
);
}
}
canvas.spawn((
PlayheadNode,
Node {
position_type: PositionType::Absolute,
left: px(playhead * pixels_per_second),
top: px(0),
width: px(2),
bottom: px(0),
..default()
},
BackgroundColor(Color::srgb(0.98, 0.25, 0.22)),
ZIndex(8),
Pickable::IGNORE,
));
});
}
fn lane(parent: &mut ChildSpawnerCommands, y: f32, label: &str) {
parent
.spawn((
Node {
position_type: PositionType::Absolute,
left: px(0),
right: px(0),
top: px(y),
height: px(LANE_HEIGHT),
border: UiRect::vertical(px(1)),
..default()
},
BackgroundColor(Color::srgba(0.025, 0.03, 0.04, 0.45)),
BorderColor::all(Color::srgba(0.35, 0.4, 0.48, 0.16)),
Pickable::IGNORE,
))
.with_child((
Text::new(label),
TextFont::from_font_size(9.0),
TextColor(Color::srgba(0.7, 0.74, 0.81, 0.4)),
Node {
margin: UiRect::left(px(5)),
..default()
},
Pickable::IGNORE,
));
}
fn spawn_shot(
parent: &mut ChildSpawnerCommands,
shot_index: usize,
shot: &Shot,
selection: EditorSelection,
pixels_per_second: f32,
) {
let selected = matches!(selection, EditorSelection::Shot(index) if index == shot_index)
|| matches!(selection, EditorSelection::Key { shot, .. } if shot == shot_index);
parent
.spawn((
ShotNode(shot_index),
Node {
position_type: PositionType::Absolute,
left: px(shot.start * pixels_per_second),
top: px(SHOT_LANE_Y + 4.0),
width: px((shot.duration * pixels_per_second).max(18.0)),
height: px(LANE_HEIGHT - 8.0),
padding: UiRect::left(px(7)),
align_items: AlignItems::Center,
border: px(if selected { 2 } else { 1 }).all(),
border_radius: BorderRadius::all(px(3)),
..default()
},
BackgroundColor(if selected {
ACCENT
} else {
Color::srgb(0.11, 0.38, 0.59)
}),
BorderColor::all(if selected { SELECTED } else { ACCENT }),
Pickable::default(),
ZIndex(3),
))
.observe(
move |mut press: On<Pointer<Press>>,
mut session: ResMut<ViewfinderSession>,
mut editor: ResMut<DirectorsCutState>| {
if press.button == PointerButton::Primary {
editor.selection = EditorSelection::Shot(shot_index);
session.armed_shot = shot_index;
press.propagate(false);
}
},
)
.observe(
|mut click: On<Pointer<Click>>, mut editor: ResMut<DirectorsCutState>| {
editor.ui_dirty = true;
click.propagate(false);
},
)
.observe(
move |mut drag: On<Pointer<DragStart>>,
session: Res<ViewfinderSession>,
mut editor: ResMut<DirectorsCutState>| {
if let Some(shot) = session.sequence.shots.get(shot_index) {
editor.checkpoint(&session.sequence);
editor.dragging = Some(DragState::Shot {
shot: shot_index,
origin: shot.start,
});
}
drag.propagate(false);
},
)
.observe(
move |mut drag: On<Pointer<Drag>>,
mut session: ResMut<ViewfinderSession>,
editor: Res<DirectorsCutState>| {
if let Some(DragState::Shot { shot, origin }) = editor.dragging
&& shot == shot_index
{
let min = if shot_index == 0 {
0.0
} else {
session.sequence.shots[shot_index - 1].start
};
let max = session
.sequence
.shots
.get(shot_index + 1)
.map_or(f32::INFINITY, |next| next.start);
session.sequence.shots[shot_index].start =
(origin + drag.distance.x / pixels_per_second).clamp(min, max);
session.rebake();
}
drag.propagate(false);
},
)
.observe(
|mut drag: On<Pointer<DragEnd>>,
session: Res<ViewfinderSession>,
mut editor: ResMut<DirectorsCutState>| {
editor.dragging = None;
editor.changed(&session.sequence, "Moved shot");
drag.propagate(false);
},
)
.with_children(|block| {
block.spawn((
Text::new(match &shot.camera {
Some(name) => format!("Shot {} · {name}", shot_index + 1),
None => format!("Shot {}", shot_index + 1),
}),
TextFont::from_font_size(11.0),
TextColor(Color::WHITE),
Pickable::IGNORE,
));
block
.spawn((
Node {
position_type: PositionType::Absolute,
right: px(0),
top: px(0),
width: px(8),
bottom: px(0),
..default()
},
BackgroundColor(if selected { SELECTED } else { Color::WHITE }),
Pickable::default(),
))
.observe(
move |mut drag: On<Pointer<DragStart>>,
session: Res<ViewfinderSession>,
mut editor: ResMut<DirectorsCutState>| {
if let Some(shot) = session.sequence.shots.get(shot_index) {
editor.checkpoint(&session.sequence);
editor.dragging = Some(DragState::ShotEnd {
shot: shot_index,
origin: shot.duration,
});
}
drag.propagate(false);
},
)
.observe(
move |mut drag: On<Pointer<Drag>>,
mut session: ResMut<ViewfinderSession>,
editor: Res<DirectorsCutState>| {
if let Some(DragState::ShotEnd { shot, origin }) = editor.dragging
&& shot == shot_index
{
session.sequence.shots[shot_index].duration = (origin
+ drag.distance.x / pixels_per_second)
.max(MIN_SHOT_DURATION);
session.rebake();
}
drag.propagate(false);
},
)
.observe(
|mut drag: On<Pointer<DragEnd>>,
session: Res<ViewfinderSession>,
mut editor: ResMut<DirectorsCutState>| {
editor.dragging = None;
editor.changed(&session.sequence, "Resized shot");
drag.propagate(false);
},
);
});
}
fn spawn_key(
parent: &mut ChildSpawnerCommands,
shot_index: usize,
key_index: usize,
track: KeyTrack,
absolute_time: f32,
selection: EditorSelection,
pixels_per_second: f32,
) {
let selected = selection
== (EditorSelection::Key {
shot: shot_index,
track,
key: key_index,
});
let y = match track {
KeyTrack::Camera => CAMERA_LANE_Y + (LANE_HEIGHT - KEY_HEIGHT) * 0.5,
KeyTrack::Lens => LENS_LANE_Y + (LANE_HEIGHT - KEY_HEIGHT) * 0.5,
};
parent
.spawn((
KeyNode {
shot: shot_index,
track,
key: key_index,
},
Node {
position_type: PositionType::Absolute,
left: px((absolute_time * pixels_per_second - KEY_SIZE * 0.5).max(0.0)),
top: px(y),
width: px(KEY_SIZE),
height: px(KEY_HEIGHT),
border: px(if selected { 3 } else { 2 }).all(),
border_radius: BorderRadius::all(px(3)),
align_items: AlignItems::Center,
justify_content: JustifyContent::Center,
..default()
},
BackgroundColor(match track {
KeyTrack::Camera => ACCENT,
KeyTrack::Lens => LENS,
}),
BorderColor::all(if selected { SELECTED } else { Color::WHITE }),
Outline {
width: px(1),
offset: px(1),
color: Color::srgba(0.0, 0.0, 0.0, 0.8),
},
Pickable::default(),
ZIndex(5),
))
.observe(
move |mut press: On<Pointer<Press>>,
mut session: ResMut<ViewfinderSession>,
mut editor: ResMut<DirectorsCutState>| {
if press.button == PointerButton::Primary {
editor.selection = EditorSelection::Key {
shot: shot_index,
track,
key: key_index,
};
session.armed_shot = shot_index;
press.propagate(false);
}
},
)
.observe(
|mut click: On<Pointer<Click>>, mut editor: ResMut<DirectorsCutState>| {
editor.ui_dirty = true;
click.propagate(false);
},
)
.observe(
move |mut drag: On<Pointer<DragStart>>,
session: Res<ViewfinderSession>,
mut editor: ResMut<DirectorsCutState>| {
let Some(shot) = session.sequence.shots.get(shot_index) else {
return;
};
let origin = match track {
KeyTrack::Camera => match &shot.rig {
Rig::Keys { keys, .. } => keys.get(key_index).map(|key| key.time),
_ => None,
},
KeyTrack::Lens => match &shot.lens.fov {
FovSpec::VerticalFovDeg(track) | FovSpec::FocalLengthMm { track, .. } => {
track.keys.get(key_index).map(|key| key.time)
}
},
};
if let Some(origin) = origin {
editor.checkpoint(&session.sequence);
editor.dragging = Some(DragState::Key {
shot: shot_index,
track,
key: key_index,
origin,
});
}
drag.propagate(false);
},
)
.observe(
move |mut drag: On<Pointer<Drag>>,
mut session: ResMut<ViewfinderSession>,
editor: Res<DirectorsCutState>| {
if let Some(DragState::Key {
shot,
track: drag_track,
key,
origin,
}) = editor.dragging
&& shot == shot_index
&& drag_track == track
&& key == key_index
{
let shot = &mut session.sequence.shots[shot_index];
match track {
KeyTrack::Camera => {
if let Rig::Keys { keys, .. } = &mut shot.rig {
let min = key_index
.checked_sub(1)
.and_then(|index| keys.get(index))
.map_or(0.0, |key| key.time);
let max = keys
.get(key_index + 1)
.map_or(shot.duration, |key| key.time);
let time =
(origin + drag.distance.x / pixels_per_second).clamp(min, max);
keys[key_index].time = time;
}
}
KeyTrack::Lens => {
let keys = fov_keys_mut(&mut shot.lens.fov);
let min = key_index
.checked_sub(1)
.and_then(|index| keys.get(index))
.map_or(0.0, |key| key.time);
let max = keys
.get(key_index + 1)
.map_or(shot.duration, |key| key.time);
let time =
(origin + drag.distance.x / pixels_per_second).clamp(min, max);
keys[key_index].time = time;
}
}
session.rebake();
}
drag.propagate(false);
},
)
.observe(
|mut drag: On<Pointer<DragEnd>>,
session: Res<ViewfinderSession>,
mut editor: ResMut<DirectorsCutState>| {
editor.dragging = None;
editor.changed(&session.sequence, "Retimed key");
drag.propagate(false);
},
)
.with_child((
Text::new(match track {
KeyTrack::Camera => "T",
KeyTrack::Lens => "L",
}),
TextFont::from_font_size(9.0),
TextColor(Color::WHITE),
Pickable::IGNORE,
));
}
fn spawn_text_block(
parent: &mut ChildSpawnerCommands,
text_index: usize,
block: &TextBlock,
selection: EditorSelection,
pixels_per_second: f32,
) {
let selected = matches!(selection, EditorSelection::Text(index) if index == text_index);
let label: String = if block.text.chars().count() > 14 {
let mut short: String = block.text.chars().take(13).collect();
short.push('\u{2026}');
short
} else {
block.text.clone()
};
parent
.spawn((
TextNode(text_index),
Node {
position_type: PositionType::Absolute,
left: px(block.start * pixels_per_second),
top: px(TEXT_LANE_Y + 4.0),
width: px((block.duration * pixels_per_second).max(18.0)),
height: px(LANE_HEIGHT - 8.0),
padding: UiRect::left(px(7)),
align_items: AlignItems::Center,
border: px(if selected { 2 } else { 1 }).all(),
border_radius: BorderRadius::all(px(3)),
..default()
},
BackgroundColor(if selected {
TITLE
} else {
Color::srgb(0.45, 0.35, 0.1)
}),
BorderColor::all(if selected { SELECTED } else { TITLE }),
Pickable::default(),
ZIndex(3),
))
.observe(
move |mut press: On<Pointer<Press>>, mut editor: ResMut<DirectorsCutState>| {
if press.button == PointerButton::Primary {
editor.selection = EditorSelection::Text(text_index);
press.propagate(false);
}
},
)
.observe(
|mut click: On<Pointer<Click>>, mut editor: ResMut<DirectorsCutState>| {
editor.ui_dirty = true;
click.propagate(false);
},
)
.observe(
move |mut drag: On<Pointer<DragStart>>,
session: Res<ViewfinderSession>,
mut editor: ResMut<DirectorsCutState>| {
if let Some(block) = session.sequence.texts.get(text_index) {
editor.checkpoint(&session.sequence);
editor.dragging = Some(DragState::Text {
index: text_index,
origin: block.start,
});
}
drag.propagate(false);
},
)
.observe(
move |mut drag: On<Pointer<Drag>>,
mut session: ResMut<ViewfinderSession>,
editor: Res<DirectorsCutState>| {
if let Some(DragState::Text { index, origin }) = editor.dragging
&& index == text_index
&& let Some(block) = session.sequence.texts.get_mut(text_index)
{
block.start = (origin + drag.distance.x / pixels_per_second).max(0.0);
session.rebake();
}
drag.propagate(false);
},
)
.observe(
|mut drag: On<Pointer<DragEnd>>,
session: Res<ViewfinderSession>,
mut editor: ResMut<DirectorsCutState>| {
editor.dragging = None;
editor.changed(&session.sequence, "Moved text block");
drag.propagate(false);
},
)
.with_children(|body| {
body.spawn((
Text::new(label),
TextFont::from_font_size(11.0),
TextColor(Color::WHITE),
Pickable::IGNORE,
));
body.spawn((
Node {
position_type: PositionType::Absolute,
right: px(0),
top: px(0),
width: px(8),
bottom: px(0),
..default()
},
BackgroundColor(if selected { SELECTED } else { Color::WHITE }),
Pickable::default(),
))
.observe(
move |mut drag: On<Pointer<DragStart>>,
session: Res<ViewfinderSession>,
mut editor: ResMut<DirectorsCutState>| {
if let Some(block) = session.sequence.texts.get(text_index) {
editor.checkpoint(&session.sequence);
editor.dragging = Some(DragState::TextEnd {
index: text_index,
origin: block.duration,
});
}
drag.propagate(false);
},
)
.observe(
move |mut drag: On<Pointer<Drag>>,
mut session: ResMut<ViewfinderSession>,
editor: Res<DirectorsCutState>| {
if let Some(DragState::TextEnd { index, origin }) = editor.dragging
&& index == text_index
&& let Some(block) = session.sequence.texts.get_mut(text_index)
{
block.duration =
(origin + drag.distance.x / pixels_per_second).max(MIN_TEXT_DURATION);
session.rebake();
}
drag.propagate(false);
},
)
.observe(
|mut drag: On<Pointer<DragEnd>>,
session: Res<ViewfinderSession>,
mut editor: ResMut<DirectorsCutState>| {
editor.dragging = None;
editor.changed(&session.sequence, "Resized text block");
drag.propagate(false);
},
);
});
}
fn spawn_actor_cue(
parent: &mut ChildSpawnerCommands,
track_index: usize,
cue_index: usize,
cue: &ActorCue,
selection: EditorSelection,
pixels_per_second: f32,
) {
let selected = matches!(
selection,
EditorSelection::ActorCue { track, cue } if track == track_index && cue == cue_index
);
let label: String = if cue.anim.chars().count() > 14 {
let mut short: String = cue.anim.chars().take(13).collect();
short.push('\u{2026}');
short
} else {
cue.anim.clone()
};
parent
.spawn((
ActorCueNode {
track: track_index,
cue: cue_index,
},
Node {
position_type: PositionType::Absolute,
left: px(cue.start * pixels_per_second),
top: px(ACTOR_LANE_Y + 4.0),
width: px((cue.duration * pixels_per_second).max(18.0)),
height: px(LANE_HEIGHT - 8.0),
padding: UiRect::left(px(7)),
align_items: AlignItems::Center,
border: px(if selected { 2 } else { 1 }).all(),
border_radius: BorderRadius::all(px(3)),
..default()
},
BackgroundColor(if selected {
ACTOR
} else {
Color::srgb(0.12, 0.34, 0.22)
}),
BorderColor::all(if selected { SELECTED } else { ACTOR }),
Pickable::default(),
ZIndex(3),
))
.observe(
move |mut press: On<Pointer<Press>>, mut editor: ResMut<DirectorsCutState>| {
if press.button == PointerButton::Primary {
editor.selection = EditorSelection::ActorCue {
track: track_index,
cue: cue_index,
};
press.propagate(false);
}
},
)
.observe(
|mut click: On<Pointer<Click>>, mut editor: ResMut<DirectorsCutState>| {
editor.ui_dirty = true;
click.propagate(false);
},
)
.observe(
move |mut drag: On<Pointer<DragStart>>,
session: Res<ViewfinderSession>,
mut editor: ResMut<DirectorsCutState>| {
if let Some(cue) = session
.sequence
.actors
.get(track_index)
.and_then(|t| t.cues.get(cue_index))
{
editor.checkpoint(&session.sequence);
editor.dragging = Some(DragState::ActorCue {
track: track_index,
cue: cue_index,
origin: cue.start,
});
}
drag.propagate(false);
},
)
.observe(
move |mut drag: On<Pointer<Drag>>,
mut session: ResMut<ViewfinderSession>,
editor: Res<DirectorsCutState>| {
if let Some(DragState::ActorCue { track, cue, origin }) = editor.dragging
&& track == track_index
&& cue == cue_index
&& let Some(cue) = session
.sequence
.actors
.get_mut(track_index)
.and_then(|t| t.cues.get_mut(cue_index))
{
cue.start = (origin + drag.distance.x / pixels_per_second).max(0.0);
session.rebake();
}
drag.propagate(false);
},
)
.observe(
|mut drag: On<Pointer<DragEnd>>,
session: Res<ViewfinderSession>,
mut editor: ResMut<DirectorsCutState>| {
editor.dragging = None;
editor.changed(&session.sequence, "Moved actor cue");
drag.propagate(false);
},
)
.with_children(|body| {
body.spawn((
Text::new(label),
TextFont::from_font_size(11.0),
TextColor(Color::WHITE),
Pickable::IGNORE,
));
body.spawn((
Node {
position_type: PositionType::Absolute,
right: px(0),
top: px(0),
width: px(8),
bottom: px(0),
..default()
},
BackgroundColor(if selected { SELECTED } else { Color::WHITE }),
Pickable::default(),
))
.observe(
move |mut drag: On<Pointer<DragStart>>,
session: Res<ViewfinderSession>,
mut editor: ResMut<DirectorsCutState>| {
if let Some(cue) = session
.sequence
.actors
.get(track_index)
.and_then(|t| t.cues.get(cue_index))
{
editor.checkpoint(&session.sequence);
editor.dragging = Some(DragState::ActorCueEnd {
track: track_index,
cue: cue_index,
origin: cue.duration,
});
}
drag.propagate(false);
},
)
.observe(
move |mut drag: On<Pointer<Drag>>,
mut session: ResMut<ViewfinderSession>,
editor: Res<DirectorsCutState>| {
if let Some(DragState::ActorCueEnd { track, cue, origin }) = editor.dragging
&& track == track_index
&& cue == cue_index
&& let Some(cue) = session
.sequence
.actors
.get_mut(track_index)
.and_then(|t| t.cues.get_mut(cue_index))
{
cue.duration = (origin + drag.distance.x / pixels_per_second)
.max(MIN_ACTOR_CUE_DURATION);
session.rebake();
}
drag.propagate(false);
},
)
.observe(
|mut drag: On<Pointer<DragEnd>>,
session: Res<ViewfinderSession>,
mut editor: ResMut<DirectorsCutState>| {
editor.dragging = None;
editor.changed(&session.sequence, "Resized actor cue");
drag.propagate(false);
},
);
});
}
#[allow(clippy::type_complexity)]
fn sync_inspector_scroll(
mut editor: ResMut<DirectorsCutState>,
mut panels: Query<(&mut ScrollPosition, &ComputedNode), With<InspectorPanel>>,
mut thumbs: Query<&mut Node, With<InspectorScrollThumb>>,
) {
let Ok((mut position, node)) = panels.single_mut() else {
return;
};
let viewport = node.size.y * node.inverse_scale_factor;
let content = node.content_size.y * node.inverse_scale_factor;
if viewport <= 0.0 || content <= 0.0 {
return;
}
let range = (content - viewport).max(0.0);
let settled = position.y.clamp(0.0, range);
if position.y != settled {
position.y = settled;
}
if editor.inspector_scroll != settled {
editor.inspector_scroll = settled;
}
let Ok(mut thumb) = thumbs.single_mut() else {
return;
};
if range <= 0.5 || content <= 0.0 {
thumb.height = percent(0);
return;
}
let visible = (viewport / content).clamp(0.08, 1.0);
thumb.height = percent(visible * 100.0);
thumb.top = percent((settled / content) * 100.0);
}
fn sync_timeline_nodes(
config: Res<DirectorsCutConfig>,
session: Res<ViewfinderSession>,
mut playheads: Query<&mut Node, With<PlayheadNode>>,
mut shots: Query<(&ShotNode, &mut Node), Without<PlayheadNode>>,
mut keys: Query<(&KeyNode, &mut Node), (Without<PlayheadNode>, Without<ShotNode>)>,
mut texts: Query<
(&TextNode, &mut Node),
(Without<PlayheadNode>, Without<ShotNode>, Without<KeyNode>),
>,
mut actor_cues: Query<
(&ActorCueNode, &mut Node),
(
Without<PlayheadNode>,
Without<ShotNode>,
Without<KeyNode>,
Without<TextNode>,
),
>,
) {
let scale = config.pixels_per_second;
for mut node in &mut playheads {
node.left = px(session.playhead * scale);
}
for (marker, mut node) in &mut shots {
if let Some(shot) = session.sequence.shots.get(marker.0) {
node.left = px(shot.start * scale);
node.width = px((shot.duration * scale).max(18.0));
}
}
for (marker, mut node) in &mut texts {
if let Some(block) = session.sequence.texts.get(marker.0) {
node.left = px(block.start * scale);
node.width = px((block.duration * scale).max(18.0));
}
}
for (marker, mut node) in &mut actor_cues {
if let Some(cue) = session
.sequence
.actors
.get(marker.track)
.and_then(|t| t.cues.get(marker.cue))
{
node.left = px(cue.start * scale);
node.width = px((cue.duration * scale).max(18.0));
}
}
for (marker, mut node) in &mut keys {
let Some(shot) = session.sequence.shots.get(marker.shot) else {
continue;
};
let local = match marker.track {
KeyTrack::Camera => match &shot.rig {
Rig::Keys { keys, .. } => keys.get(marker.key).map(|key| key.time),
_ => None,
},
KeyTrack::Lens => match &shot.lens.fov {
FovSpec::VerticalFovDeg(track) | FovSpec::FocalLengthMm { track, .. } => {
track.keys.get(marker.key).map(|key| key.time)
}
},
};
if let Some(local) = local {
node.left = px(((shot.start + local) * scale - KEY_SIZE * 0.5).max(0.0));
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{Key, ScalarKey};
fn sequence() -> SequenceAsset {
let mut shot = Shot::keys(
0.0,
4.0,
vec![Key::at(1.0).pos(Vec3::ONE).rot(Quat::IDENTITY)],
);
shot.lens.fov = FovSpec::VerticalFovDeg(ScalarTrack {
keys: vec![ScalarKey {
time: 1.0,
value: 45.0,
ease: EaseFunction::Linear,
}],
});
SequenceAsset::single_shot("edit", shot)
}
fn step(shot: &mut Shot, field: FxField, times: i32) {
let edit = Edit::Step(if times < 0 { -1.0 } else { 1.0 });
for _ in 0..times.abs() {
assert!(adjust_shot_fx(shot, 0, field, edit));
}
}
#[test]
fn a_typed_value_lands_exactly_and_still_clamps() {
let mut shot = sequence().shots.remove(0);
assert!(adjust_shot_fx(
&mut shot,
0,
FxField::GradeExposure,
Edit::Set(2.35)
));
let grade = shot.lens.grading.as_ref().unwrap();
assert_eq!(grade.exposure.as_ref().unwrap().sample(0.0), 2.35);
adjust_shot_fx(&mut shot, 0, FxField::GradeExposure, Edit::Set(99.0));
let grade = shot.lens.grading.as_ref().unwrap();
assert_eq!(grade.exposure.as_ref().unwrap().sample(0.0), 5.0);
adjust_shot_fx(&mut shot, 0, FxField::Vignette, Edit::Set(0.4));
assert!(shot.lens.vignette.is_some());
adjust_shot_fx(&mut shot, 0, FxField::Vignette, Edit::Set(0.0));
assert!(shot.lens.vignette.is_none());
}
#[test]
fn typed_angles_are_degrees_like_the_row_shows() {
let mut sequence = sequence();
let config = DirectorsCutConfig::default();
assert!(adjust_key(
&mut sequence,
0,
KeyTrack::Camera,
0,
AdjustField::Yaw,
Edit::Set(90.0),
&config,
));
let Rig::Keys { keys, .. } = &sequence.shots[0].rig else {
panic!("keys expected");
};
let (yaw, ..) = keys[0].rot.unwrap().to_euler(EulerRot::YXZ);
assert!((yaw.to_degrees() - 90.0).abs() < 1e-3, "{yaw}");
}
#[test]
fn cycles_are_not_typable_and_units_are_stripped() {
assert!(!AdjustField::Fx(FxField::DofModeCycle).typable());
assert!(!AdjustField::RigCycle.typable());
assert!(AdjustField::Fx(FxField::GradeExposure).typable());
assert_eq!(numeric_seed("+0.20 EV"), "+0.20");
assert_eq!(numeric_seed("f/2.8"), "2.8");
assert_eq!(numeric_seed("1.25 s"), "1.25");
assert_eq!(numeric_seed("-0.35"), "-0.35");
assert_eq!(numeric_seed("off"), "");
}
#[test]
fn enabling_shake_seeds_something_playable() {
let mut sequence = sequence();
step(&mut sequence.shots[0], FxField::ShakeEnable, 1);
let shake = sequence.shots[0].shake.as_ref().expect("shake");
assert!(shake.amplitude_deg > 0.0 && shake.frequency_hz > 0.0);
assert!(crate::bake(&sequence).is_ok());
step(&mut sequence.shots[0], FxField::ShakeEnable, 1);
assert!(sequence.shots[0].shake.is_none());
}
#[test]
fn fx_values_clamp_where_the_runtime_would() {
let mut shot = sequence().shots.remove(0);
step(&mut shot, FxField::Vignette, 60);
assert_eq!(shot.lens.vignette.as_ref().unwrap().sample(0.0), 1.0);
step(&mut shot, FxField::Aberration, 60);
assert_eq!(shot.lens.aberration.as_ref().unwrap().sample(0.0), 0.5);
step(&mut shot, FxField::Distortion, -10);
assert!(shot.lens.distortion.as_ref().unwrap().sample(0.0) < 0.0);
step(&mut shot, FxField::Aperture, 3);
assert!(shot.lens.aperture_f_stops.as_ref().unwrap().sample(0.0) >= 0.1);
}
#[test]
fn dialing_an_effect_to_nothing_drops_it_from_the_file() {
let mut sequence = sequence();
step(&mut sequence.shots[0], FxField::Vignette, 4);
assert!(sequence.shots[0].lens.vignette.is_some());
step(&mut sequence.shots[0], FxField::Vignette, -4);
assert!(sequence.shots[0].lens.vignette.is_none());
assert!(
!crate::to_ron_string(&sequence)
.unwrap()
.contains("vignette")
);
step(&mut sequence.shots[0], FxField::GradeExposure, 3);
assert!(sequence.shots[0].lens.grading.is_some());
step(&mut sequence.shots[0], FxField::GradeExposure, -3);
assert!(sequence.shots[0].lens.grading.is_none());
}
#[test]
fn created_tracks_are_never_left_empty() {
let mut shot = sequence().shots.remove(0);
step(&mut shot, FxField::Vignette, 2);
step(&mut shot, FxField::ExposureEv100, 2);
step(&mut shot, FxField::ShakeEnable, 1);
for _ in 0..3 {
step(&mut shot, FxField::ShakeRamp, 1);
if let Some(ramp) = &shot.shake.as_ref().unwrap().ramp {
assert!(!ramp.keys.is_empty());
}
}
assert!(!shot.lens.vignette.as_ref().unwrap().keys.is_empty());
assert!(!shot.lens.exposure_ev100.as_ref().unwrap().keys.is_empty());
}
#[test]
fn the_rig_cycle_never_breaks_the_bake() {
let mut sequence = sequence();
let frame = (Vec3::new(1.0, 2.0, 3.0), Quat::IDENTITY, 45.0);
for _ in 0..6 {
assert!(cycle_rig(&mut sequence.shots[0], frame));
assert!(
crate::bake(&sequence).is_ok(),
"the cycle left a sequence that will not bake"
);
}
if let Rig::Keys { keys, .. } = &sequence.shots[0].rig {
assert!(keys[0].rot.is_some());
}
sequence.shots[0].rig = Rig::Rail {
points: vec![Vec3::ZERO, Vec3::X, Vec3::Y, Vec3::Z],
kind: crate::RailKind::CatmullRom,
constant_speed: true,
progress: ScalarTrack::default(),
};
assert!(!cycle_rig(&mut sequence.shots[0], frame));
}
#[test]
fn mount_ease_only_answers_for_held_shots() {
let mut shot = sequence().shots.remove(0);
assert!(!adjust_shot_fx(
&mut shot,
0,
FxField::MountEase,
Edit::Step(1.0)
));
shot.rig = Rig::Held { damping: None };
assert!(adjust_shot_fx(
&mut shot,
0,
FxField::MountEase,
Edit::Step(1.0)
));
assert!(matches!(shot.rig, Rig::Held { damping: Some(_) }));
assert!(adjust_shot_fx(
&mut shot,
0,
FxField::MountEase,
Edit::Step(-1.0)
));
assert!(matches!(shot.rig, Rig::Held { damping: None }));
}
#[test]
fn adjustment_changes_camera_and_lens_values() {
let mut sequence = sequence();
let config = DirectorsCutConfig::default();
assert!(adjust_key(
&mut sequence,
0,
KeyTrack::Camera,
0,
AdjustField::PositionX,
Edit::Step(1.0),
&config,
));
assert!(adjust_key(
&mut sequence,
0,
KeyTrack::Camera,
0,
AdjustField::Fov,
Edit::Step(1.0),
&config,
));
let Rig::Keys { keys, .. } = &sequence.shots[0].rig else {
panic!("expected key rig")
};
assert_eq!(keys[0].pos.x, 1.1);
let FovSpec::VerticalFovDeg(track) = &sequence.shots[0].lens.fov else {
panic!("expected degrees")
};
assert_eq!(track.keys[0].value, 46.0);
}
#[test]
fn history_round_trip_restores_sequence() {
let mut session = ViewfinderSession::default();
session.sequence = sequence();
let mut editor = DirectorsCutState::default();
editor.checkpoint(&session.sequence);
session.sequence.shots[0].duration = 9.0;
undo(&mut editor, &mut session);
assert_eq!(session.sequence.shots[0].duration, 4.0);
redo(&mut editor, &mut session);
assert_eq!(session.sequence.shots[0].duration, 9.0);
}
#[test]
fn text_block_adjustments_step_and_clamp() {
let mut sequence = sequence();
sequence.texts = vec![TextBlock::at(1.0, 2.0, "caption").fades(0.5, 0.5)];
assert!(adjust_text_block(
&mut sequence,
0,
AdjustField::TextStart,
Edit::Step(-1.0)
));
assert!((sequence.texts[0].start - 0.9).abs() < 1e-6);
for _ in 0..40 {
adjust_text_block(
&mut sequence,
0,
AdjustField::TextDuration,
Edit::Step(-1.0),
);
}
assert_eq!(sequence.texts[0].duration, MIN_TEXT_DURATION);
assert!(adjust_text_block(
&mut sequence,
0,
AdjustField::TextFadeIn,
Edit::Step(1.0)
));
assert!(sequence.texts[0].fade_in <= sequence.texts[0].duration);
assert!(!adjust_text_block(
&mut sequence,
0,
AdjustField::PositionX,
Edit::Step(1.0)
));
assert!(!adjust_text_block(
&mut sequence,
7,
AdjustField::TextStart,
Edit::Step(1.0)
));
}
#[test]
fn anchor_cycle_wraps_both_directions() {
let mut anchor = TextAnchor::Center;
for _ in 0..3 {
anchor = cycle_anchor(anchor, true);
}
assert_eq!(anchor, TextAnchor::Center);
for _ in 0..3 {
anchor = cycle_anchor(anchor, false);
}
assert_eq!(anchor, TextAnchor::Center);
assert_eq!(
cycle_anchor(TextAnchor::Center, true),
TextAnchor::LowerThird
);
assert_eq!(
cycle_anchor(TextAnchor::Center, false),
TextAnchor::TopCenter
);
}
#[test]
fn deleting_a_text_block_clears_and_sanitizes_selection() {
let mut session = ViewfinderSession::default();
session.sequence = sequence();
session.sequence.texts = vec![TextBlock::at(0.0, 2.0, "one")];
let mut editor = DirectorsCutState::default();
editor.selection = EditorSelection::Text(0);
delete_selection(&mut editor, &mut session);
assert!(session.sequence.texts.is_empty());
assert_eq!(editor.selection, EditorSelection::None);
editor.selection = EditorSelection::Text(3);
sanitize_selection(&mut editor, &session.sequence);
assert_eq!(editor.selection, EditorSelection::None);
undo(&mut editor, &mut session);
assert_eq!(session.sequence.texts.len(), 1);
assert_eq!(session.sequence.texts[0].text, "one");
}
#[test]
fn typing_flag_covers_both_inputs() {
let mut editor = DirectorsCutState::default();
assert!(!editor.typing());
editor.renaming = true;
assert!(editor.typing());
editor.renaming = false;
editor.editing_text = true;
assert!(editor.typing());
editor.editing_text = false;
editor.editing_actor = Some(ActorEditField::Anim);
assert!(editor.typing());
}
#[test]
fn actor_cue_adjustments_step_and_clamp() {
let mut sequence = sequence();
sequence.actors =
vec![ActorTrack::entity("player").cue(ActorCue::at(1.0, 2.0, "sleep").fades(0.3, 0.3))];
assert!(adjust_actor_cue(
&mut sequence,
0,
0,
AdjustField::ActorStart,
Edit::Step(-1.0)
));
assert!((sequence.actors[0].cues[0].start - 0.9).abs() < 1e-6);
for _ in 0..40 {
adjust_actor_cue(
&mut sequence,
0,
0,
AdjustField::ActorDuration,
Edit::Step(-1.0),
);
}
assert_eq!(sequence.actors[0].cues[0].duration, MIN_ACTOR_CUE_DURATION);
for _ in 0..80 {
adjust_actor_cue(
&mut sequence,
0,
0,
AdjustField::ActorSpeed,
Edit::Step(-1.0),
);
}
assert_eq!(sequence.actors[0].cues[0].speed, MIN_ACTOR_CUE_SPEED);
assert!(!adjust_actor_cue(
&mut sequence,
0,
0,
AdjustField::TextStart,
Edit::Step(1.0)
));
assert!(!adjust_actor_cue(
&mut sequence,
3,
0,
AdjustField::ActorStart,
Edit::Step(1.0)
));
}
#[test]
fn add_actor_cue_creates_track_and_selects() {
let mut app = App::new();
let mut session = ViewfinderSession::default();
session.sequence = sequence();
session.playhead = 2.5;
app.insert_resource(session)
.init_resource::<DirectorsCutState>()
.init_resource::<DirectorsCutConfig>()
.init_resource::<ViewfinderConfig>()
.init_resource::<DirectorState>()
.init_resource::<Time<Virtual>>()
.add_systems(Update, apply_editor_commands);
app.world_mut()
.resource_mut::<DirectorsCutState>()
.pending
.push(EditorCommand::AddActorCue);
app.update();
let session = app.world().resource::<ViewfinderSession>();
assert_eq!(session.sequence.actors.len(), 1);
assert!(matches!(
&session.sequence.actors[0].target,
TargetRef::Entity(name) if name == "player"
));
assert_eq!(session.sequence.actors[0].cues.len(), 1);
assert_eq!(session.sequence.actors[0].cues[0].start, 2.5);
assert_eq!(
app.world().resource::<DirectorsCutState>().selection,
EditorSelection::ActorCue { track: 0, cue: 0 }
);
}
#[test]
fn capture_key_selects_the_key_it_landed_on() {
let mut app = App::new();
let mut session = ViewfinderSession::default();
session.sequence = sequence();
session.playhead = 3.0;
app.insert_resource(session)
.init_resource::<DirectorsCutState>()
.init_resource::<DirectorsCutConfig>()
.init_resource::<ViewfinderConfig>()
.init_resource::<DirectorState>()
.init_resource::<Time<Virtual>>()
.add_systems(Update, apply_editor_commands);
app.world_mut()
.resource_mut::<DirectorsCutState>()
.pending
.push(EditorCommand::CaptureKey);
app.update();
assert_eq!(
app.world().resource::<DirectorsCutState>().selection,
EditorSelection::Key {
shot: 0,
track: KeyTrack::Camera,
key: 1
}
);
let session = app.world().resource::<ViewfinderSession>();
let Rig::Keys { keys, .. } = &session.sequence.shots[0].rig else {
panic!("expected key rig");
};
assert_eq!(keys.len(), 2);
assert!((keys[1].time - 3.0).abs() < 1e-6);
}
#[test]
fn toggle_auto_key_flips_the_viewfinder_config() {
let mut app = App::new();
app.init_resource::<DirectorsCutState>()
.init_resource::<DirectorsCutConfig>()
.init_resource::<ViewfinderConfig>()
.init_resource::<ViewfinderSession>()
.init_resource::<DirectorState>()
.init_resource::<Time<Virtual>>()
.add_systems(Update, apply_editor_commands);
assert!(app.world().resource::<ViewfinderConfig>().auto_key);
app.world_mut()
.resource_mut::<DirectorsCutState>()
.pending
.push(EditorCommand::ToggleAutoKey);
app.update();
assert!(!app.world().resource::<ViewfinderConfig>().auto_key);
}
#[test]
fn deleting_last_cue_drops_the_track_and_sanitizes_selection() {
let mut session = ViewfinderSession::default();
session.sequence = sequence();
session.sequence.actors =
vec![ActorTrack::entity("player").cue(ActorCue::at(0.0, 2.0, "sleep"))];
let mut editor = DirectorsCutState::default();
editor.selection = EditorSelection::ActorCue { track: 0, cue: 0 };
delete_selection(&mut editor, &mut session);
assert!(session.sequence.actors.is_empty());
assert_eq!(editor.selection, EditorSelection::None);
editor.selection = EditorSelection::ActorCue { track: 2, cue: 0 };
sanitize_selection(&mut editor, &session.sequence);
assert_eq!(editor.selection, EditorSelection::None);
undo(&mut editor, &mut session);
assert_eq!(session.sequence.actors.len(), 1);
assert_eq!(session.sequence.actors[0].cues[0].anim, "sleep");
}
#[test]
fn sequence_entry_filter_keeps_and_sorts_dir_ron() {
let entries = vec![
PathBuf::from("assets/sequences/Zeta.dir.ron"),
PathBuf::from("assets/sequences/notes.txt"),
PathBuf::from("assets/sequences/alpha.dir.ron"),
PathBuf::from("assets/sequences/beta.ron"),
];
let names: Vec<String> = filter_sequence_entries(entries)
.iter()
.filter_map(|path| path.file_name().and_then(|name| name.to_str()))
.map(str::to_owned)
.collect();
assert_eq!(names, ["alpha.dir.ron", "Zeta.dir.ron"]);
}
#[test]
fn browse_dir_prefers_the_pinned_paths_parent() {
let viewfinder = ViewfinderConfig::default();
let mut config = DirectorsCutConfig::default();
assert_eq!(
browse_dir(&config, &viewfinder),
PathBuf::from("assets/sequences")
);
config.sequence_path = Some(PathBuf::from("cinematics/intro.dir.ron"));
assert_eq!(
browse_dir(&config, &viewfinder),
PathBuf::from("cinematics")
);
}
#[test]
fn open_panel_toggles_and_loads_by_entry() {
let dir =
std::env::temp_dir().join(format!("bevy_director_open_test_{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let take = sequence();
crate::loader::save_sequence(&take, &dir.join("stage.dir.ron")).unwrap();
let mut app = App::new();
app.init_resource::<DirectorsCutState>()
.init_resource::<ViewfinderConfig>()
.init_resource::<ViewfinderSession>()
.init_resource::<DirectorState>()
.init_resource::<Time<Virtual>>()
.insert_resource(DirectorsCutConfig {
sequence_path: Some(dir.join("pinned.dir.ron")),
..Default::default()
})
.add_systems(Update, apply_editor_commands);
let push = |app: &mut App, command: EditorCommand| {
app.world_mut()
.resource_mut::<DirectorsCutState>()
.pending
.push(command);
app.update();
};
push(&mut app, EditorCommand::Open);
{
let editor = app.world().resource::<DirectorsCutState>();
let browse = editor.browsing.as_ref().expect("panel should be up");
assert_eq!(browse.entries.len(), 1);
}
push(&mut app, EditorCommand::OpenEntry(5));
{
let editor = app.world().resource::<DirectorsCutState>();
assert!(editor.browsing.is_some());
let session = app.world().resource::<ViewfinderSession>();
assert_eq!(session.sequence.name, "untitled");
}
push(&mut app, EditorCommand::OpenEntry(0));
{
let editor = app.world().resource::<DirectorsCutState>();
assert!(editor.browsing.is_none(), "loading closes the panel");
let session = app.world().resource::<ViewfinderSession>();
assert_eq!(session.sequence.name, "edit");
assert_eq!(session.sequence.shots.len(), 1);
}
push(&mut app, EditorCommand::Open);
assert!(
app.world()
.resource::<DirectorsCutState>()
.browsing
.is_some()
);
push(&mut app, EditorCommand::Open);
assert!(
app.world()
.resource::<DirectorsCutState>()
.browsing
.is_none()
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn freeze_command_toggles_the_game_clock() {
let mut app = App::new();
app.init_resource::<DirectorsCutState>()
.init_resource::<DirectorsCutConfig>()
.init_resource::<ViewfinderConfig>()
.init_resource::<ViewfinderSession>()
.init_resource::<DirectorState>()
.init_resource::<Time<Virtual>>()
.add_systems(Update, apply_editor_commands);
app.world_mut()
.resource_mut::<DirectorsCutState>()
.pending
.push(EditorCommand::FreezeWorld);
app.update();
assert!(app.world().resource::<Time<Virtual>>().is_paused());
app.world_mut()
.resource_mut::<DirectorsCutState>()
.pending
.push(EditorCommand::FreezeWorld);
app.update();
assert!(!app.world().resource::<Time<Virtual>>().is_paused());
}
#[test]
fn eject_resumes_a_frozen_world() {
let mut app = App::new();
app.init_resource::<ButtonInput<KeyCode>>()
.init_resource::<DirectorsCutConfig>()
.init_resource::<DirectorState>()
.init_resource::<DirectorsCutState>()
.init_resource::<Time<Virtual>>()
.add_systems(Update, toggle_editor);
app.world_mut().spawn(CursorOptions::default());
app.world_mut().resource_mut::<Time<Virtual>>().pause();
app.world_mut()
.resource_mut::<DirectorsCutState>()
.froze_world = true;
app.update();
assert!(!app.world().resource::<Time<Virtual>>().is_paused());
}
#[test]
fn grid_is_visible_only_in_the_viewfinder_when_enabled() {
let mut app = App::new();
app.init_resource::<DirectorsCutConfig>()
.init_resource::<DirectorState>()
.add_systems(Update, sync_directors_cut_grid_visibility);
let grid = app
.world_mut()
.spawn((DirectorsCutGrid, Visibility::Hidden))
.id();
app.update();
assert_eq!(
app.world().get::<Visibility>(grid),
Some(&Visibility::Hidden)
);
app.world_mut().resource_mut::<DirectorState>().phase = DirectorPhase::Viewfinder;
app.update();
assert_eq!(
app.world().get::<Visibility>(grid),
Some(&Visibility::Visible)
);
app.world_mut()
.resource_mut::<DirectorsCutConfig>()
.show_grid = false;
app.update();
assert_eq!(
app.world().get::<Visibility>(grid),
Some(&Visibility::Hidden)
);
}
#[test]
fn sequence_name_becomes_the_default_filename() {
let config = DirectorsCutConfig::default();
let viewfinder = ViewfinderConfig::default();
let sequence = SequenceAsset::empty("opening_shot");
assert_eq!(
sequence_path(&config, &viewfinder, &sequence),
PathBuf::from("assets/sequences/opening_shot.dir.ron")
);
}
#[test]
fn filename_input_accepts_a_stem_or_full_extension() {
assert_eq!(
normalize_sequence_name(" opening shot "),
Ok("opening shot".into())
);
assert_eq!(
normalize_sequence_name("opening_shot.dir.ron"),
Ok("opening_shot".into())
);
assert!(normalize_sequence_name("../opening_shot").is_err());
assert!(normalize_sequence_name(".dir.ron").is_err());
}
}