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::{
CameraSnapshot, CineCamera, DirectorPhase, DirectorPlugin, DirectorState, EvalCtx, FovSpec,
KeyInterp, Lens, Rig, ScalarTrack, SequenceAsset, Shot, 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 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 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);
#[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: 330.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,
},
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum KeyTrack {
Camera,
Lens,
}
#[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_live: Option<CameraSnapshot>,
preview_requested: bool,
viewfinder_seen: bool,
last_playing: bool,
renaming: bool,
ui_dirty: bool,
fingerprint: u64,
}
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_live: None,
preview_requested: false,
viewfinder_seen: false,
last_playing: false,
renaming: false,
ui_dirty: true,
fingerprint: 0,
}
}
}
impl DirectorsCutState {
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,
Save,
Undo,
Redo,
AddShot,
CaptureKey,
Delete,
PlayPause,
BeginRename,
Zoom(f32),
Adjust(AdjustField, f32),
}
#[derive(Clone, Copy)]
enum AdjustField {
ShotStart,
ShotDuration,
KeyTime,
PositionX,
PositionY,
PositionZ,
Yaw,
Pitch,
Roll,
Fov,
}
#[derive(Clone, Copy)]
enum EditorIcon {
Director,
New,
Open,
Save,
Undo,
Redo,
Shot,
Key,
Delete,
Play,
Pause,
Rename,
ZoomOut,
ZoomIn,
Minus,
Plus,
}
#[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,
},
}
#[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 SequenceNameInput;
#[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);
}
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,
)
.chain(),
)
.add_systems(Update, sync_directors_cut_grid_visibility)
.add_systems(Last, handle_sequence_name_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 cursor: Single<&mut CursorOptions>,
) {
if director.phase != DirectorPhase::Viewfinder {
editor.viewfinder_seen = false;
if editor.open {
editor.open = false;
editor.renaming = false;
editor.preview_live = 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.renaming;
if entered_viewfinder && config.auto_open {
editor.open = true;
} else if toggle_pressed {
editor.open = !editor.open;
} else {
return;
}
editor.preview_live = None;
editor.preview_requested = false;
if !editor.open {
editor.renaming = false;
}
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.renaming {
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 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 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>,
viewfinder_config: Res<ViewfinderConfig>,
mut session: ResMut<ViewfinderSession>,
director: Res<DirectorState>,
cine: Query<(&Transform, &Projection), With<CineCamera>>,
) {
let commands = std::mem::take(&mut editor.pending);
for command in commands {
match command {
EditorCommand::New => {
editor.checkpoint(&session.sequence);
editor.renaming = false;
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.preview_requested = false;
}
EditorCommand::Open => {
editor.renaming = false;
load_from_disk(&mut editor, &config, &viewfinder_config, &mut session)
}
EditorCommand::Save => save_to_disk(&mut editor, &config, &viewfinder_config, &session),
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);
session.capture_frame(pos, rot, fov, EaseFunction::SmoothStep);
let shot = session.armed_shot;
let key = match &session.sequence.shots[shot].rig {
Rig::Keys { keys, .. } => keys
.iter()
.position(|key| {
(key.time - (session.playhead - session.sequence.shots[shot].start))
.abs()
< 0.06
})
.unwrap_or(0),
_ => 0,
};
editor.selection = EditorSelection::Key {
shot,
track: KeyTrack::Camera,
key,
};
editor.changed(&session.sequence, "Captured current camera");
editor.preview_requested = false;
}
EditorCommand::Delete => delete_selection(&mut editor, &mut session),
EditorCommand::PlayPause => {
session.playing = !session.playing;
if session.playing && 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::BeginRename => {
editor.renaming = true;
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, direction) => {
adjust_selection(&mut editor, &config, &mut session, field, direction);
}
}
}
}
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 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()),
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,
viewfinder: &ViewfinderConfig,
session: &mut ViewfinderSession,
) {
let path = sequence_path(config, viewfinder, &session.sequence);
#[cfg(not(target_arch = "wasm32"))]
match crate::loader::load_sequence(&path) {
Ok(sequence) => {
editor.checkpoint(&session.sequence);
session.sequence = sequence;
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()));
}
Err(error) => {
editor.status = format!("Open failed: {error}");
editor.ui_dirty = true;
}
}
#[cfg(target_arch = "wasm32")]
{
let _ = 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"
},
);
}
_ => sanitize_selection(editor, &session.sequence),
}
}
fn adjust_selection(
editor: &mut DirectorsCutState,
config: &DirectorsCutConfig,
session: &mut ViewfinderSession,
field: AdjustField,
direction: 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 = (shot.start + direction * 0.1).clamp(min_start, max_start);
true
}
AdjustField::ShotDuration => {
shot.duration = (shot.duration + direction * 0.1).max(MIN_SHOT_DURATION);
true
}
_ => false,
}
}
EditorSelection::Key { shot, track, key } => adjust_key(
&mut session.sequence,
shot,
track,
key,
field,
direction,
config,
),
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,
direction: f32,
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 = (key.time + direction * 0.1).clamp(min_time, max_time);
}
AdjustField::Fov => {
key.value = (key.value + direction * 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 = (key.time + direction * 0.1).clamp(min_time, max_time);
}
AdjustField::PositionX => key.pos.x += direction * config.position_step,
AdjustField::PositionY => key.pos.y += direction * config.position_step,
AdjustField::PositionZ => key.pos.z += direction * 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 = direction * config.angle_step_deg.to_radians();
match field {
AdjustField::Yaw => yaw += step,
AdjustField::Pitch => pitch += step,
AdjustField::Roll => roll += step,
_ => 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 =
(fov_keys[index].value + direction * config.fov_step_deg).clamp(1.0, 179.0);
}
_ => return false,
}
}
}
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()
}
},
})
}
};
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 editor: ResMut<DirectorsCutState>,
director: Res<DirectorState>,
mut session: ResMut<ViewfinderSession>,
mut cine: Query<(&mut Transform, &mut Projection), With<CineCamera>>,
) {
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 fov_y = match &*projection {
Projection::Perspective(perspective) => perspective.fov,
_ => session.fov_deg.to_radians(),
};
let current = CameraSnapshot {
position: transform.translation,
rotation: transform.rotation,
fov_y,
};
let live = *editor.preview_live.get_or_insert(current);
let Some(compiled) = &session.compiled else {
return;
};
let pose = compiled.pose_at(session.playhead, &EvalCtx::still(&live));
transform.translation = pose.position;
transform.rotation = pose.rotation;
if let Projection::Perspective(perspective) = &mut *projection {
perspective.fov = pose.fov_y;
}
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::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::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 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,
));
}
fn rebuild_editor_ui(
mut commands: Commands,
roots: Query<Entity, With<DirectorsCutRoot>>,
mut editor: ResMut<DirectorsCutState>,
config: Res<DirectorsCutConfig>,
session: Res<ViewfinderSession>,
director: Res<DirectorState>,
) {
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.open {
return;
}
let width_seconds = config
.min_timeline_seconds
.max(session.sequence.duration() + 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 sequence = session.sequence.clone();
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("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, 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::clip_y(),
..default()
},
BackgroundColor(PANEL),
))
.with_children(|inspector| {
spawn_inspector(inspector, &sequence, selection);
});
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,
);
});
});
}
fn spawn_inspector(
parent: &mut ChildSpawnerCommands,
sequence: &SequenceAsset,
selection: EditorSelection,
) {
text(parent, "INSPECTOR", 12.0, MUTED);
parent.spawn(Node {
height: px(7),
..default()
});
match selection {
EditorSelection::None => {
text(parent, "Select a shot or key 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::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,
);
adjustment_row(
parent,
"Duration",
format!("{:.2} s", shot.duration),
AdjustField::ShotDuration,
);
let rig = match &shot.rig {
Rig::Keys { interp, .. } => match interp {
KeyInterp::Eased => "Keyframed / eased",
KeyInterp::CatmullRom => "Keyframed / spline",
},
Rig::Rail { .. } => "Rail",
Rig::Orbit { .. } => "Orbit",
};
text(parent, format!("Rig {rig}"), 12.0, MUTED);
}
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,
);
adjustment_row(
parent,
"Position X",
format!("{:.2}", camera_key.pos.x),
AdjustField::PositionX,
);
adjustment_row(
parent,
"Position Y",
format!("{:.2}", camera_key.pos.y),
AdjustField::PositionY,
);
adjustment_row(
parent,
"Position Z",
format!("{:.2}", camera_key.pos.z),
AdjustField::PositionZ,
);
adjustment_row(
parent,
"Yaw",
format!("{:.1} deg", yaw.to_degrees()),
AdjustField::Yaw,
);
adjustment_row(
parent,
"Pitch",
format!("{:.1} deg", pitch.to_degrees()),
AdjustField::Pitch,
);
adjustment_row(
parent,
"Roll",
format!("{:.1} deg", roll.to_degrees()),
AdjustField::Roll,
);
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);
}
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,
);
adjustment_row(
parent,
"FOV / focal",
format!("{:.1}", lens_key.value),
AdjustField::Fov,
);
}
}
}
}
}
fn adjustment_row(
parent: &mut ChildSpawnerCommands,
label: &str,
value: String,
field: AdjustField,
) {
parent
.spawn(Node {
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, -1.0),
);
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, 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");
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(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 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>)>,
) {
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 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)
}
#[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,
1.0,
&config,
));
assert!(adjust_key(
&mut sequence,
0,
KeyTrack::Camera,
0,
AdjustField::Fov,
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 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());
}
}