use bevy::{
input::mouse::{AccumulatedMouseMotion, AccumulatedMouseScroll},
prelude::*,
window::{CursorGrabMode, CursorOptions},
};
use crate::{
CineCamera, DirectorSet,
eval::{CameraSnapshot, CompiledSequence, EvalCtx, bake, collect_active_texts},
gizmos::{DirectorGizmoGroup, draw_playhead, draw_sequence},
player::{ActiveTexts, DirectorPhase, DirectorState, snapshot_of},
sequence::{FovSpec, Key, KeyInterp, Lens, Rig, ScalarKey, ScalarTrack, SequenceAsset, Shot},
};
const DEFAULT_SHOT_SECS: f32 = 5.0;
const REPLACE_WINDOW: f32 = 0.05;
const DELETE_RANGE: f32 = 2.0;
const MOUSE_SENSITIVITY: f32 = 0.0028;
#[derive(Resource)]
pub struct ViewfinderConfig {
pub toggle: KeyCode,
pub capture: KeyCode,
pub delete: KeyCode,
pub play_pause: KeyCode,
pub scrub_back: KeyCode,
pub scrub_forward: KeyCode,
pub scrub_step: f32,
pub fly_speed: f32,
pub save_dir: std::path::PathBuf,
pub capture_ease: EaseFunction,
}
impl Default for ViewfinderConfig {
fn default() -> Self {
Self {
toggle: KeyCode::Backquote,
capture: KeyCode::KeyK,
delete: KeyCode::Delete,
play_pause: KeyCode::KeyP,
scrub_back: KeyCode::BracketLeft,
scrub_forward: KeyCode::BracketRight,
scrub_step: 0.5,
fly_speed: 8.0,
save_dir: std::path::PathBuf::from("assets/sequences"),
capture_ease: EaseFunction::SmoothStep,
}
}
}
#[derive(Resource)]
pub struct ViewfinderSession {
pub sequence: SequenceAsset,
pub armed_shot: usize,
pub playhead: f32,
pub playing: bool,
yaw: f32,
pitch: f32,
pub(crate) fov_deg: f32,
speed: f32,
saved_cursor: Option<(bool, CursorGrabMode)>,
pub(crate) compiled: Option<CompiledSequence>,
}
impl Default for ViewfinderSession {
fn default() -> Self {
Self {
sequence: SequenceAsset::empty("untitled"),
armed_shot: 0,
playhead: 0.0,
playing: false,
yaw: 0.0,
pitch: 0.0,
fov_deg: 45.0,
speed: 8.0,
saved_cursor: None,
compiled: None,
}
}
}
impl ViewfinderSession {
pub(crate) fn rebake(&mut self) {
self.compiled = bake(&self.sequence).ok();
}
pub(crate) fn capture_frame(&mut self, pos: Vec3, rot: Quat, fov_deg: f32, ease: EaseFunction) {
let playhead = self.playhead;
if self.sequence.shots.is_empty() {
self.sequence.shots.push(Shot {
start: 0.0,
duration: DEFAULT_SHOT_SECS.max(playhead),
blend_in: None,
rig: Rig::Keys {
keys: Vec::new(),
interp: KeyInterp::Eased,
},
look: Default::default(),
lens: Lens {
fov: FovSpec::VerticalFovDeg(ScalarTrack { keys: Vec::new() }),
..Default::default()
},
shake: None,
});
self.armed_shot = 0;
}
let armed = self.armed_shot.min(self.sequence.shots.len() - 1);
self.armed_shot = armed;
let shot = &mut self.sequence.shots[armed];
let local = (playhead - shot.start).max(0.0);
if local > shot.duration {
shot.duration = local;
}
if let Rig::Keys { keys, .. } = &mut shot.rig {
let key = Key {
time: local,
pos,
rot: Some(rot),
ease,
};
match keys
.iter()
.position(|key| (key.time - local).abs() <= REPLACE_WINDOW)
{
Some(index) => keys[index] = key,
None => {
let at = keys.partition_point(|key| key.time < local);
keys.insert(at, key);
}
}
}
if let FovSpec::VerticalFovDeg(track) = &mut shot.lens.fov {
let key = ScalarKey {
time: local,
value: fov_deg,
ease,
};
match track
.keys
.iter()
.position(|key| (key.time - local).abs() <= REPLACE_WINDOW)
{
Some(index) => track.keys[index] = key,
None => {
let at = track.keys.partition_point(|key| key.time < local);
track.keys.insert(at, key);
}
}
}
self.rebake();
}
pub(crate) fn sync_pilot_from_pose(&mut self, rotation: Quat, fov_y: f32) {
let (yaw, pitch, _) = rotation.to_euler(EulerRot::YXZ);
self.yaw = yaw;
self.pitch = pitch;
self.fov_deg = fov_y.to_degrees();
}
}
pub struct ViewfinderPlugin;
impl Plugin for ViewfinderPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<ViewfinderConfig>()
.init_resource::<ViewfinderSession>()
.init_gizmo_group::<DirectorGizmoGroup>()
.add_systems(
Update,
(
toggle_viewfinder,
fly_camera,
scrub_and_preview,
capture_key,
delete_key,
save_session,
draw_overlay,
)
.chain(),
)
.add_systems(PostUpdate, update_session_texts.in_set(DirectorSet::Apply));
}
}
fn update_session_texts(
state: Res<DirectorState>,
session: Res<ViewfinderSession>,
mut active: ResMut<ActiveTexts>,
) {
if state.phase != DirectorPhase::Viewfinder {
return;
}
if session.sequence.texts.is_empty() && active.blocks.is_empty() {
return;
}
let blocks = &mut active.blocks;
collect_active_texts(&session.sequence.texts, session.playhead, blocks);
}
fn viewfinder_up(state: &DirectorState) -> bool {
state.phase == DirectorPhase::Viewfinder
}
#[allow(clippy::too_many_arguments)] fn toggle_viewfinder(
mut commands: Commands,
keys: Res<ButtonInput<KeyCode>>,
config: Res<ViewfinderConfig>,
mut session: ResMut<ViewfinderSession>,
mut state: ResMut<DirectorState>,
mut cameras: ParamSet<(
Query<
(Entity, &Camera, &Transform, Option<&Projection>),
(With<Camera3d>, Without<CineCamera>),
>,
Query<&mut Camera>,
)>,
cine_query: Query<Entity, With<CineCamera>>,
mut transforms: Query<(&mut Transform, &mut Projection), With<CineCamera>>,
mut cursor: Single<&mut CursorOptions>,
) {
if !keys.just_pressed(config.toggle) {
return;
}
if viewfinder_up(&state) {
if let Some((visible, grab)) = session.saved_cursor.take() {
cursor.visible = visible;
cursor.grab_mode = grab;
}
let mut writable = cameras.p1();
crate::player::restore_live(&mut commands, &mut state, &mut writable);
return;
}
if state.phase != DirectorPhase::Idle {
warn!("viewfinder only opens while the director is idle");
return;
}
let live = cameras
.p0()
.iter()
.find(|(_, cam, ..)| cam.is_active)
.map(|(e, _, t, p)| (e, snapshot_of(t, p)));
let live_snapshot = live.map(|(_, s)| s).unwrap_or(CameraSnapshot {
position: Vec3::new(0.0, 2.0, 8.0),
rotation: Quat::IDENTITY,
fov_y: 45f32.to_radians(),
});
let cine = cine_query.iter().next().unwrap_or_else(|| {
commands
.spawn((
Name::new("cine camera"),
CineCamera,
Camera3d::default(),
Camera {
is_active: true,
..Default::default()
},
Projection::Perspective(PerspectiveProjection {
fov: live_snapshot.fov_y,
..Default::default()
}),
Transform::from_translation(live_snapshot.position)
.with_rotation(live_snapshot.rotation),
))
.id()
});
if let Ok((mut transform, mut projection)) = transforms.get_mut(cine) {
transform.translation = live_snapshot.position;
transform.rotation = live_snapshot.rotation;
if let Projection::Perspective(p) = &mut *projection {
p.fov = live_snapshot.fov_y;
}
}
let (yaw, pitch, _) = live_snapshot.rotation.to_euler(EulerRot::YXZ);
session.yaw = yaw;
session.pitch = pitch;
session.fov_deg = live_snapshot.fov_y.to_degrees();
session.speed = config.fly_speed;
session.playing = false;
session.rebake();
let mut writable = cameras.p1();
if let Some((live_entity, _)) = live
&& let Ok(mut cam) = writable.get_mut(live_entity)
{
cam.is_active = false;
}
if let Ok(mut cam) = writable.get_mut(cine) {
cam.is_active = true;
}
session.saved_cursor = Some((cursor.visible, cursor.grab_mode));
cursor.visible = false;
cursor.grab_mode = CursorGrabMode::Locked;
state.phase = DirectorPhase::Viewfinder;
state.camera = Some(cine);
state.live_camera = live.map(|(e, _)| e);
info!(
"viewfinder up: fly WASD/QE, K captures, [ ] scrub, P previews, ctrl+S saves '{}'",
session.sequence.name
);
}
fn fly_camera(
keys: Res<ButtonInput<KeyCode>>,
state: Res<DirectorState>,
mut session: ResMut<ViewfinderSession>,
motion: Res<AccumulatedMouseMotion>,
scroll: Res<AccumulatedMouseScroll>,
time: Res<Time<Real>>,
mut cine: Query<(&mut Transform, &mut Projection), With<CineCamera>>,
#[cfg(feature = "editor")] editor: Option<Res<crate::editor::DirectorsCutState>>,
) {
if !viewfinder_up(&state) || session.playing {
return;
}
#[cfg(feature = "editor")]
if editor.is_some_and(|editor| editor.open) {
return;
}
let Some(camera) = state.camera else {
return;
};
let Ok((mut transform, mut projection)) = cine.get_mut(camera) else {
return;
};
let ctrl = keys.pressed(KeyCode::ControlLeft) || keys.pressed(KeyCode::ControlRight);
if ctrl {
session.fov_deg = (session.fov_deg - scroll.delta.y * 1.5).clamp(10.0, 120.0);
} else if scroll.delta.y.abs() > 0.0 {
session.speed = (session.speed * 1.1f32.powf(scroll.delta.y)).clamp(0.5, 200.0);
}
session.yaw -= motion.delta.x * MOUSE_SENSITIVITY;
session.pitch = (session.pitch - motion.delta.y * MOUSE_SENSITIVITY).clamp(-1.54, 1.54);
transform.rotation = Quat::from_euler(EulerRot::YXZ, session.yaw, session.pitch, 0.0);
let mut wish = Vec3::ZERO;
let flat_forward = transform.forward().as_vec3();
let right = transform.right().as_vec3();
if keys.pressed(KeyCode::KeyW) {
wish += flat_forward;
}
if keys.pressed(KeyCode::KeyS) {
wish -= flat_forward;
}
if keys.pressed(KeyCode::KeyD) {
wish += right;
}
if keys.pressed(KeyCode::KeyA) {
wish -= right;
}
if keys.pressed(KeyCode::KeyE) {
wish += Vec3::Y;
}
if keys.pressed(KeyCode::KeyQ) {
wish -= Vec3::Y;
}
let sprint = if keys.pressed(KeyCode::ShiftLeft) {
3.0
} else {
1.0
};
transform.translation += wish.normalize_or_zero() * session.speed * sprint * time.delta_secs();
if let Projection::Perspective(p) = &mut *projection {
p.fov = session.fov_deg.to_radians();
}
}
fn scrub_and_preview(
keys: Res<ButtonInput<KeyCode>>,
state: Res<DirectorState>,
config: Res<ViewfinderConfig>,
mut session: ResMut<ViewfinderSession>,
motion: Res<AccumulatedMouseMotion>,
time: Res<Time<Real>>,
mut cine: Query<(&mut Transform, &mut Projection), With<CineCamera>>,
#[cfg(feature = "editor")] editor: Option<Res<crate::editor::DirectorsCutState>>,
) {
if !viewfinder_up(&state) {
return;
}
#[cfg(feature = "editor")]
if editor.as_ref().is_some_and(|editor| editor.typing()) {
return;
}
let step = if keys.pressed(KeyCode::ShiftLeft) {
1.0 / 30.0
} else {
config.scrub_step
};
let mut moved = false;
if keys.just_pressed(config.scrub_back) {
session.playhead = (session.playhead - step).max(0.0);
moved = true;
}
if keys.just_pressed(config.scrub_forward) {
session.playhead += step;
moved = true;
}
if keys.just_pressed(config.play_pause) {
session.playing = !session.playing;
if session.playing && session.playhead >= session.sequence.duration() {
session.playhead = 0.0;
}
moved = true;
}
if session.playing {
session.playhead += time.delta_secs();
if session.playhead >= session.sequence.duration() {
session.playing = false;
}
#[cfg(feature = "editor")]
let editor_open = editor.is_some_and(|editor| editor.open);
#[cfg(not(feature = "editor"))]
let editor_open = false;
if motion.delta.length_squared() > 4.0 && !editor_open {
session.playing = false;
return;
}
moved = true;
}
if !moved {
return;
}
let duration = session.sequence.duration();
session.playhead = session.playhead.clamp(0.0, duration.max(0.0));
let Some(compiled) = &session.compiled else {
return;
};
let Some(camera) = state.camera else {
return;
};
let Ok((mut transform, mut projection)) = cine.get_mut(camera) else {
return;
};
let live = CameraSnapshot {
position: transform.translation,
rotation: transform.rotation,
fov_y: session.fov_deg.to_radians(),
};
let pose = compiled.pose_at(session.playhead, &EvalCtx::still(&live));
transform.translation = pose.position;
transform.rotation = pose.rotation;
if let Projection::Perspective(p) = &mut *projection {
p.fov = pose.fov_y;
}
session.sync_pilot_from_pose(pose.rotation, pose.fov_y);
}
fn capture_key(
keys: Res<ButtonInput<KeyCode>>,
state: Res<DirectorState>,
config: Res<ViewfinderConfig>,
mut session: ResMut<ViewfinderSession>,
cine: Query<&Transform, With<CineCamera>>,
#[cfg(feature = "editor")] editor: Option<Res<crate::editor::DirectorsCutState>>,
) {
if !viewfinder_up(&state) || !keys.just_pressed(config.capture) {
return;
}
#[cfg(feature = "editor")]
if editor.is_some_and(|editor| editor.typing()) {
return;
}
let Some(transform) = state.camera.and_then(|e| cine.get(e).ok()) else {
return;
};
let fov_deg = session.fov_deg;
session.capture_frame(
transform.translation,
transform.rotation,
fov_deg,
config.capture_ease,
);
let armed = session.armed_shot;
let local = (session.playhead - session.sequence.shots[armed].start).max(0.0);
info!(
"key at {:.2}s in shot {} ({} keys)",
local,
armed,
key_count(&session.sequence, armed)
);
}
fn key_count(sequence: &SequenceAsset, shot: usize) -> usize {
match &sequence.shots[shot].rig {
Rig::Keys { keys, .. } => keys.len(),
_ => 0,
}
}
fn delete_key(
keys: Res<ButtonInput<KeyCode>>,
state: Res<DirectorState>,
config: Res<ViewfinderConfig>,
mut session: ResMut<ViewfinderSession>,
cine: Query<&Transform, With<CineCamera>>,
#[cfg(feature = "editor")] editor: Option<Res<crate::editor::DirectorsCutState>>,
) {
if !viewfinder_up(&state) || !keys.just_pressed(config.delete) {
return;
}
#[cfg(feature = "editor")]
if editor.is_some_and(|editor| editor.typing()) {
return;
}
let Some(transform) = state.camera.and_then(|e| cine.get(e).ok()) else {
return;
};
let from = transform.translation;
let mut best: Option<(usize, usize, f32)> = None;
for (si, shot) in session.sequence.shots.iter().enumerate() {
let Rig::Keys { keys, .. } = &shot.rig else {
continue;
};
for (ki, key) in keys.iter().enumerate() {
let d = key.pos.distance(from);
if d <= DELETE_RANGE && best.is_none_or(|(_, _, bd)| d < bd) {
best = Some((si, ki, d));
}
}
}
let Some((si, ki, _)) = best else {
info!("no key within {DELETE_RANGE} m to delete");
return;
};
let Rig::Keys { keys, .. } = &mut session.sequence.shots[si].rig else {
return;
};
let removed = keys.remove(ki);
session.rebake();
info!("removed key at {:.2}s from shot {si}", removed.time);
}
fn save_session(
keys: Res<ButtonInput<KeyCode>>,
state: Res<DirectorState>,
config: Res<ViewfinderConfig>,
session: Res<ViewfinderSession>,
#[cfg(feature = "editor")] editor: Option<Res<crate::editor::DirectorsCutState>>,
) {
if !viewfinder_up(&state) || !keys.just_pressed(KeyCode::KeyS) {
return;
}
#[cfg(feature = "editor")]
if editor.is_some_and(|editor| editor.typing()) {
return;
}
if !(keys.pressed(KeyCode::ControlLeft) || keys.pressed(KeyCode::ControlRight)) {
return;
}
#[cfg(not(target_arch = "wasm32"))]
{
let path = config
.save_dir
.join(format!("{}.dir.ron", session.sequence.name));
match crate::loader::save_sequence(&session.sequence, &path) {
Ok(()) => info!("saved {}", path.display()),
Err(err) => error!("could not save sequence: {err}"),
}
}
#[cfg(target_arch = "wasm32")]
warn!("saving sequences is native-only");
}
fn draw_overlay(
state: Res<DirectorState>,
session: Res<ViewfinderSession>,
mut gizmos: Gizmos<DirectorGizmoGroup>,
) {
if !viewfinder_up(&state) {
return;
}
draw_sequence(&mut gizmos, &session.sequence);
if let Some(compiled) = &session.compiled {
let live = CameraSnapshot {
position: Vec3::ZERO,
rotation: Quat::IDENTITY,
fov_y: 45f32.to_radians(),
};
let pose = compiled.pose_at(session.playhead, &EvalCtx::still(&live));
draw_playhead(&mut gizmos, pose.position, pose.rotation);
}
}