M3.12: Source playback and monitor range editing

Play source video and audio, drag marked ranges as video, audio, or a linked pair, and route Source commands to clip marks and markers. Add shared draggable In/Out handles and range movement, preserving scrubber priority and explicit-only Program marks.

Include original code-drawn controls, documentation, attribution, and regression coverage for playback, placement, routing, undo, cancellation, and hostile parameters.

Assisted-by: Codex
This commit is contained in:
Cathy Shapiro 2026-10-08 18:58:46 -05:00
parent 26d972ce01
commit 8a7275c821
23 changed files with 2044 additions and 55 deletions

View File

@ -99,6 +99,7 @@ is traced or derived from Adobe artwork.
| Asset | Where |
|---|---|
| UI icons (tools, transport, panels, header) | `crates/ui-egui/src/icons.rs`: vector paths on a 16×16 grid |
| Source and Program range braces and trim hover cues | `crates/ui-egui/src/panels/monitor.rs` and `source_range.rs`: original generic vector shapes by FilmCraft contributors, MIT OR Apache-2.0; no third-party artwork |
| Align / distribute / paragraph-alignment glyphs in the graphics panel | `crates/ui-egui/src/panels/graphics.rs` (`align_glyph`): bars and lines drawn with the egui painter |
| Demo footage (ocean sunset, aurora, city night, dunes, forest, plasma), bars and tone, counting leader, colour matte | `crates/media/src/generators.rs` |
| Built-in graphics templates (Lower Third – Slab / Rule, Ticker – Crawl, Title – Centered / Boxed, End Credits – Roll, Callout – Pointer / Tag) and their placeholder text | `crates/project/src/gtemplate.rs` (`builtin_templates`): original designs defined in code with the bundled Inter font; thumbnails are rendered at runtime, no template, image or font files |

View File

@ -159,6 +159,9 @@ Status: ✅ done · 🟡 in progress · ⬜ not started. Estimates are remaining
## Log
- **2026-10-08 (Source range dragging):** implicit full-clip In/Out, video-only/audio-only/linked both controls, picture/waveform drag gestures and one-command undoable timeline placement. Shared Source/Program draggable frame-bounded handles and range translation, cancellation, Source marker/navigation routing and marked-range playback/looping.
- **2026-10-08 (Source playback):** normal forward playback for video/audio media and subclips in the Source monitor, an independent Source clock, Play/Pause and focused Space, frame stepping, device-clock audio with wall-clock fallback, seeking, and end-of-clip stopping. Windows desktop review verified a real video file and standalone WAV, including In/Out marking. Reverse/shuttle Source playback and source sequences remain unsupported; no parity percentage changed.
- **2026-10-06:** 21 community PRs landed (#19, #28, #38, #39, #41–#60). Highlights: hardware decoding is now actually on in the desktop app (#41: `register()` sat inside a `log::info!` that never ran, so #33's VideoToolbox path only worked in the CLI and benchmarks); decode memory on long timelines cut sharply (frame pool, shared GOP-cache budget, positional file reads); exports are byte-identical across machines (#19); crash fix for odd track names over MCP (#44); ripple trim, ripple delete and speed changes move split-edit clips once (#56–#58); Japanese interface using an installed system font (#38; no fonts bundled); MCP annotations, resources, export progress and cancellation (#28).
- **2026-10-05:** honest assessment added (checklist ~87% vs ready for real work ~50–60%). Hardware acceleration started (#30): blend modes on the GPU (#32); VideoToolbox decode + `platform` crate, the one crate allowed `unsafe` (#33). Community fixes landed: AIFF import, export start/end ranges, transitions following ripple trims, Slide moving linked audio (#5–#8).

View File

@ -449,6 +449,16 @@ pub struct SourceView {
}
impl SourceView {
/// The marked Source span. Unset marks mean the beginning/end of the source; Out is inclusive.
pub fn selected_range(&self) -> TimeRange {
if self.start.0 < 0 || self.end <= self.start {
return TimeRange::new(self.start, Tick::ZERO);
}
let start = self.mark_in.unwrap_or(self.start).clamp(self.start, self.end);
let end = self.mark_out.map(|o| Tick(o.0.saturating_add(self.rate.frame_duration().0))).unwrap_or(self.end).clamp(start, self.end);
TimeRange::from_bounds(start, end)
}
pub fn to_json(&self, playhead: Tick) -> Value {
json!({
"item": self.item.0, "media": self.media.0, "start": self.start.0, "end": self.end.0, "fps": self.rate.as_f64(),

View File

@ -1609,19 +1609,30 @@ fn build() -> Vec<CommandSpec> {
})?;
Ok(Value::Null)
}),
cmd!("markers.goToIn", "Go to In", ["Markers"], Some("Shift+I"), "{}", has_seq, |s, _| {
cmd!("markers.goToIn", "Go to In", ["Markers"], Some("Shift+I"), r#"{"target":"program|source"}"#, always, |s, p| {
if let Some(v) = crate::source_monitor::route(s, "markers.goToIn", p)? {
return Ok(v);
}
s.active_sequence().ok_or(EngineError::NoSequence)?;
if let Some(i) = s.active_sequence().and_then(|q| q.mark_in) {
s.set_playhead(i);
}
Ok(Value::Null)
}),
cmd!("markers.goToOut", "Go to Out", ["Markers"], Some("Shift+O"), "{}", has_seq, |s, _| {
cmd!("markers.goToOut", "Go to Out", ["Markers"], Some("Shift+O"), r#"{"target":"program|source"}"#, always, |s, p| {
if let Some(v) = crate::source_monitor::route(s, "markers.goToOut", p)? {
return Ok(v);
}
s.active_sequence().ok_or(EngineError::NoSequence)?;
if let Some(o) = s.active_sequence().and_then(|q| q.mark_out) {
s.set_playhead(o);
}
Ok(Value::Null)
}),
cmd!("markers.clearIn", "Clear In", ["Markers"], Some("Cmd+Shift+I"), "{}", has_seq, |s, _| {
cmd!("markers.clearIn", "Clear In", ["Markers"], Some("Cmd+Shift+I"), r#"{"target":"program|source"}"#, always, |s, p| {
if let Some(v) = crate::source_monitor::route(s, "markers.clearIn", p)? {
return Ok(v);
}
s.edit_sequence("Clear In", |q, _, _| {
q.mark_in = None;
q.split.video_in = None;
@ -1630,7 +1641,10 @@ fn build() -> Vec<CommandSpec> {
})?;
Ok(Value::Null)
}),
cmd!("markers.clearOut", "Clear Out", ["Markers"], Some("Cmd+Shift+O"), "{}", has_seq, |s, _| {
cmd!("markers.clearOut", "Clear Out", ["Markers"], Some("Cmd+Shift+O"), r#"{"target":"program|source"}"#, always, |s, p| {
if let Some(v) = crate::source_monitor::route(s, "markers.clearOut", p)? {
return Ok(v);
}
s.edit_sequence("Clear Out", |q, _, _| {
q.mark_out = None;
q.split.video_out = None;
@ -1639,7 +1653,10 @@ fn build() -> Vec<CommandSpec> {
})?;
Ok(Value::Null)
}),
cmd!("markers.clearInOut", "Clear In and Out", ["Markers"], Some("Cmd+Shift+X"), "{}", has_seq, |s, _| {
cmd!("markers.clearInOut", "Clear In and Out", ["Markers"], Some("Cmd+Shift+X"), r#"{"target":"program|source"}"#, always, |s, p| {
if let Some(v) = crate::source_monitor::route(s, "markers.clearInOut", p)? {
return Ok(v);
}
s.edit_sequence("Clear In and Out", |q, _, _| {
q.mark_in = None;
q.mark_out = None;
@ -1653,9 +1670,12 @@ fn build() -> Vec<CommandSpec> {
"Add Marker",
["Markers"],
Some("M"),
r#"{"time":ticks?,"name":str?,"comment":str?,"color":label?,"durationFrames":i64?}"#,
has_seq,
r#"{"target":"program|source","time":ticks?,"name":str?,"comment":str?,"color":label?,"durationFrames":i64?}"#,
always,
|s, p| {
if let Some(v) = crate::source_monitor::route(s, "markers.add", p)? {
return Ok(v);
}
let (_seq, ph) = marker_list_mut(s)?;
let t = time_p(s, p, "").unwrap_or(ph);
let name = str_p(p, "name").unwrap_or("").to_string();
@ -1671,21 +1691,32 @@ fn build() -> Vec<CommandSpec> {
Ok(json!({"marker": id.0}))
}
),
cmd!("markers.goNext", "Go to Next Marker", ["Markers"], Some("Shift+M"), "{}", has_seq, |s, _| {
cmd!("markers.goNext", "Go to Next Marker", ["Markers"], Some("Shift+M"), r#"{"target":"program|source"}"#, always, |s, p| {
if let Some(v) = crate::source_monitor::route(s, "markers.goNext", p)? {
return Ok(v);
}
let t = s.playhead();
s.active_sequence().ok_or(EngineError::NoSequence)?;
if let Some(m) = s.active_sequence().and_then(|q| q.markers.iter().map(|m| m.start).find(|m| *m > t)) {
s.set_playhead(m);
}
Ok(Value::Null)
}),
cmd!("markers.goPrev", "Go to Previous Marker", ["Markers"], Some("Cmd+Shift+M"), "{}", has_seq, |s, _| {
cmd!("markers.goPrev", "Go to Previous Marker", ["Markers"], Some("Cmd+Shift+M"), r#"{"target":"program|source"}"#, always, |s, p| {
if let Some(v) = crate::source_monitor::route(s, "markers.goPrev", p)? {
return Ok(v);
}
let t = s.playhead();
s.active_sequence().ok_or(EngineError::NoSequence)?;
if let Some(m) = s.active_sequence().and_then(|q| q.markers.iter().rev().map(|m| m.start).find(|m| *m < t)) {
s.set_playhead(m);
}
Ok(Value::Null)
}),
cmd!("markers.clearCurrent", "Clear Selected Marker", ["Markers"], Some("Cmd+Alt+M"), "{}", has_seq, |s, _| {
cmd!("markers.clearCurrent", "Clear Selected Marker", ["Markers"], Some("Cmd+Alt+M"), r#"{"target":"program|source"}"#, always, |s, p| {
if let Some(v) = crate::source_monitor::route(s, "markers.clearCurrent", p)? {
return Ok(v);
}
let t = s.playhead();
s.edit_sequence("Clear Marker", |q, _, _| {
q.markers.retain(|m| m.start != t);
@ -1693,7 +1724,10 @@ fn build() -> Vec<CommandSpec> {
})?;
Ok(Value::Null)
}),
cmd!("markers.clearAll", "Clear Markers", ["Markers"], Some("Cmd+Alt+Shift+M"), "{}", has_seq, |s, _| {
cmd!("markers.clearAll", "Clear Markers", ["Markers"], Some("Cmd+Alt+Shift+M"), r#"{"target":"program|source"}"#, always, |s, p| {
if let Some(v) = crate::source_monitor::route(s, "markers.clearAll", p)? {
return Ok(v);
}
s.edit_sequence("Clear All Markers", |q, _, _| {
q.markers.clear();
Ok(())
@ -1705,9 +1739,12 @@ fn build() -> Vec<CommandSpec> {
"Edit Marker…",
[],
None,
r#"{"marker":id,"name":str?,"comment":str?,"color":label?,"durationFrames":i64?}"#,
has_seq,
r#"{"target":"program|source","marker":id,"name":str?,"comment":str?,"color":label?,"durationFrames":i64?}"#,
always,
|s, p| {
if let Some(v) = crate::source_monitor::route(s, "markers.edit", p)? {
return Ok(v);
}
let id = u64_p(p, "marker").map(MarkerId).ok_or_else(|| bad("markers.edit", "need `marker`"))?;
let rate = s.sequence_rate();
let p = p.clone();
@ -1807,9 +1844,18 @@ fn build() -> Vec<CommandSpec> {
"Place Clip",
[],
None,
r#"{"item":id,"track":"V1"|id|"A1" (sound only)?,"audioTrack":"A1"|id?,"time":ticks|"frame":i64|"seconds":f64,"insert":bool,"sourceIn":ticks?,"duration":ticks?}"#,
r#"{"item":id,"track":"V1"|id|"A1" (sound only)?,"audioTrack":"A1"|id?,"time":ticks|"frame":i64|"seconds":f64,"insert":bool,"sourceIn":ticks?,"duration":ticks?,"video":bool=true,"audio":bool=true}"#,
has_seq,
|s, p| {
let stream_flag = |key: &str| match p.get(key) {
None => Ok(true),
Some(Value::Bool(v)) => Ok(*v),
_ => Err(bad("timeline.place", format!("`{key}` must be a boolean"))),
};
let (video, audio) = (stream_flag("video")?, stream_flag("audio")?);
if !video && !audio {
return Err(bad("timeline.place", "enable video, audio, or both"));
}
let item = item_p(p, "item").ok_or_else(|| bad("timeline.place", "need `item`"))?;
let at = time_p(s, p, "").unwrap_or(s.playhead());
let tg = s.targeting();
@ -1831,6 +1877,14 @@ fn build() -> Vec<CommandSpec> {
}
(v, a) => (v.or(tg.video_dest), a.or(tg.audio_dest)),
};
let v = v.filter(|_| video);
let a = a.filter(|_| audio);
if p.get("video").is_some() && video && pi.has_video() && v.is_none() {
return Err(bad("timeline.place", "no video destination: add or enable a video track"));
}
if p.get("audio").is_some() && audio && pi.has_audio() && a.is_none() {
return Err(bad("timeline.place", "no audio destination: add or enable an audio track"));
}
let full = match &pi.kind {
// Settings ▸ Timeline ▸ Still Image Default Duration
ItemKind::Media(m)

View File

@ -45,6 +45,7 @@ pub mod sequence_tools;
pub mod settings;
pub mod shortcut_presets;
pub mod shortcuts;
pub mod source_monitor;
pub mod sync;
pub mod transcript;
pub mod trim;
@ -1105,3 +1106,6 @@ mod trim_tests;
mod vfx_tests;
#[cfg(test)]
mod voiceover_tests;
#[cfg(test)]
mod source_placement_tests;

View File

@ -0,0 +1,309 @@
//! Source range arithmetic and Source-targeted marker commands. Edits use the session undo path.
use crate::clip_ops::source_view;
use crate::commands::bad;
use crate::{EngineError, Result, Session};
use filmcraft_project::{ItemId, Label, Marker, MarkerId, MarkerKind, Project};
use filmcraft_time::{FrameRate, TICKS_PER_SECOND, Tick, TimeRange};
use serde_json::{Value, json};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum RangeEdge {
In,
Out,
Move,
}
/// Adjust one boundary or translate the span, constrained to the source and source frame grid.
pub fn adjust_range(bounds: TimeRange, range: TimeRange, rate: FrameRate, edge: RangeEdge, delta: Tick) -> Result<TimeRange> {
let error = || EngineError::Other("invalid Source range or frame rate".into());
let end = bounds.start.0.checked_add(bounds.duration.0).ok_or_else(error)?;
let out = range.start.0.checked_add(range.duration.0).ok_or_else(error)?;
if bounds.start.0 < 0 || bounds.duration.0 <= 0 || range.duration.0 <= 0 || range.start < bounds.start || out > end || rate.num <= 0 || rate.den <= 0 {
return Err(error());
}
let scale = i128::from(TICKS_PER_SECOND).checked_mul(i128::from(rate.den)).ok_or_else(error)?;
let fd = scale / i128::from(rate.num);
if fd <= 0 || fd > i128::from(i64::MAX) {
return Err(error());
}
let floor = |t: i128| -> Result<i128> {
let f = t.checked_mul(i128::from(rate.num)).ok_or_else(error)?.div_euclid(scale);
Ok(f.checked_mul(scale).ok_or_else(error)?.div_euclid(i128::from(rate.num)))
};
let nearest = |t: i128| -> Result<i128> {
let a = floor(t)?;
let f = t.checked_mul(i128::from(rate.num)).ok_or_else(error)?.div_euclid(scale);
let b = f.checked_add(1).and_then(|f| f.checked_mul(scale)).ok_or_else(error)?.div_euclid(i128::from(rate.num));
Ok(if t - a <= b - t { a } else { b })
};
let (lo, hi, start, stop, delta) = (i128::from(bounds.start.0), i128::from(end), i128::from(range.start.0), i128::from(out), i128::from(delta.0));
let (a, b) = match edge {
RangeEdge::In => {
let max = floor((stop - fd.min(hi - lo)).max(lo))?.max(lo);
if max < lo {
return Err(error());
}
(nearest((start + delta).clamp(lo, max))?.clamp(lo, max), stop)
}
RangeEdge::Out => {
let last = floor((hi - fd).max(lo))?.max(lo);
if start > last {
return Err(error());
}
let o = nearest((stop - fd + delta).clamp(start, last))?.clamp(start, last);
(start, (o + fd).min(hi))
}
RangeEdge::Move => {
let min = -floor(-(lo - start))?;
let max = floor(hi - stop)?;
if min > max {
return Err(error());
}
let d = nearest(delta.clamp(min, max))?.clamp(min, max);
(start + d, stop + d)
}
};
let a = i64::try_from(a).map_err(|_| error())?;
let b = i64::try_from(b).map_err(|_| error())?;
Ok(TimeRange::from_bounds(Tick(a), Tick(b)))
}
pub fn source_command(id: &str) -> bool {
id.starts_with("markers.markSplit")
|| id.starts_with("markers.goToSplit")
|| matches!(
id,
"markers.markIn"
| "markers.markOut"
| "markers.goToIn"
| "markers.goToOut"
| "markers.clearIn"
| "markers.clearOut"
| "markers.clearInOut"
| "markers.add"
| "markers.goNext"
| "markers.goPrev"
| "markers.clearCurrent"
| "markers.clearAll"
| "markers.edit"
)
}
fn marker_list(p: &mut Project, item: ItemId) -> Result<&mut Vec<Marker>> {
let it = p.item_mut(item).ok_or_else(|| EngineError::Other("Source item is unavailable".into()))?;
match &mut it.kind {
filmcraft_project::ItemKind::Media(m) => Ok(&mut m.markers),
filmcraft_project::ItemKind::Sequence(q) => Ok(&mut q.markers),
_ => Err(EngineError::Other("Source marker editing requires media or a sequence".into())),
}
}
/// `None` leaves the existing Program command implementation in charge.
pub(crate) fn route(s: &mut Session, id: &str, p: &Value) -> Result<Option<Value>> {
match p.get("target") {
None => return Ok(None),
Some(Value::String(t)) if t == "program" => return Ok(None),
Some(Value::String(t)) if t == "source" => {}
_ => return Err(bad(id, "target must be program or source")),
}
let item = s.state.source_item.ok_or_else(|| bad(id, "no Source clip is open"))?;
let view = source_view(s, item).ok_or_else(|| bad(id, "Source item is unavailable"))?;
let cursor = s.state.source_playhead;
match id {
"markers.goToIn" | "markers.goToOut" => {
let time = if id.ends_with("In") {
view.mark_in.unwrap_or(view.start)
} else {
view.mark_out.unwrap_or(Tick(view.end.0.saturating_sub(view.rate.frame_duration().0)).max(view.start))
};
s.execute("source.setPlayhead", json!({"time":time.0}))?;
}
"markers.clearIn" | "markers.clearOut" | "markers.clearInOut" => {
let mut marks = json!({"item":item.0});
if id != "markers.clearOut" {
marks["in"] = Value::Null;
}
if id != "markers.clearIn" {
marks["out"] = Value::Null;
}
s.execute("project.setMarks", marks)?;
}
"markers.goNext" | "markers.goPrev" => {
let time = if id.ends_with("Next") {
view.markers.iter().map(|m| m.start).filter(|t| *t > cursor).min()
} else {
view.markers.iter().map(|m| m.start).filter(|t| *t < cursor).max()
};
if let Some(t) = time {
s.execute("source.setPlayhead", json!({"time":t.0}))?;
}
}
"markers.add" => {
let time = match p.get("time") {
None => cursor,
Some(v) => Tick(v.as_i64().ok_or_else(|| bad(id, "time must be integer ticks"))?),
};
if time < view.start || time >= view.end {
return Err(bad(id, "marker time is outside the Source clip"));
}
let duration = duration(p, &view, id)?.min(Tick(view.end.0.saturating_sub(time.0)));
let name = text(p, "name", id)?.unwrap_or_default().to_string();
let comment = text(p, "comment", id)?.unwrap_or_default().to_string();
let color = color(p, id)?.unwrap_or(Label::Green);
let root = view.media;
let marker = s.edit("Add Source Marker", |pr, _| {
if pr.next_id == u64::MAX {
return Err(bad(id, "project ids are exhausted"));
}
let marker = MarkerId(pr.alloc_id());
let list = marker_list(pr, root)?;
list.push(Marker { id: marker, start: time, duration, name, comment, color, kind: MarkerKind::Comment });
list.sort_by_key(|m| m.start);
Ok(marker)
})?;
return Ok(Some(json!({"marker":marker.0})));
}
"markers.clearCurrent" | "markers.clearAll" => {
s.edit("Clear Source Markers", |pr, _| {
let list = marker_list(pr, view.media)?;
if id.ends_with("All") {
list.clear();
} else {
list.retain(|m| m.start != cursor);
}
Ok(())
})?;
}
"markers.edit" => {
let marker = MarkerId(p.get("marker").and_then(Value::as_u64).ok_or_else(|| bad(id, "need marker id"))?);
let name = text(p, "name", id)?.map(str::to_string);
let comment = text(p, "comment", id)?.map(str::to_string);
let color = color(p, id)?;
let duration = if p.get("durationFrames").is_some() { Some(duration(p, &view, id)?) } else { None };
s.edit("Edit Source Marker", |pr, _| {
let m = marker_list(pr, view.media)?.iter_mut().find(|m| m.id == marker).ok_or_else(|| bad(id, "no such Source marker"))?;
if let Some(name) = name {
m.name = name;
}
if let Some(comment) = comment {
m.comment = comment;
}
if let Some(color) = color {
m.color = color;
}
if let Some(d) = duration {
m.duration = d.min(Tick(view.end.0.saturating_sub(m.start.0)));
}
Ok(())
})?;
}
_ => return Err(bad(id, "unsupported Source command")),
}
Ok(Some(Value::Null))
}
fn text<'a>(p: &'a Value, key: &str, id: &str) -> Result<Option<&'a str>> {
p.get(key).map(|v| v.as_str().ok_or_else(|| bad(id, format!("{key} must be text")))).transpose()
}
fn color(p: &Value, id: &str) -> Result<Option<Label>> {
text(p, "color", id)?.map(|t| Label::from_name(t).ok_or_else(|| bad(id, "unknown marker color"))).transpose()
}
fn duration(p: &Value, view: &crate::clip_ops::SourceView, id: &str) -> Result<Tick> {
let Some(v) = p.get("durationFrames") else { return Ok(Tick::ZERO) };
let f = v.as_i64().ok_or_else(|| bad(id, "durationFrames must be an integer"))?;
let span = view.end.0.saturating_sub(view.start.0).max(0);
if f < 0 || f > view.rate.frame_at(Tick(span)) {
return Err(bad(id, "marker duration is outside the Source clip"));
}
Ok(view.rate.tick_of(f))
}
#[cfg(test)]
mod tests {
use super::*;
fn fixture() -> Session {
let mut s = Session::default();
s.execute("file.openDemoProject", json!({})).unwrap();
s.execute("source.open", json!({"item":5})).unwrap();
s
}
#[test]
fn ranges_snap_and_clamp_without_crossing_or_changing_move_duration() {
for rate in FrameRate::COMMON {
let bounds = TimeRange::from_bounds(Tick::ZERO, rate.tick_of(240));
let range = TimeRange::from_bounds(rate.tick_of(48), rate.tick_of(96));
for delta in [Tick(i64::MIN), -rate.tick_of(200), -rate.tick_of(8), Tick::ZERO, rate.tick_of(8), rate.tick_of(200), Tick(i64::MAX)] {
for edge in [RangeEdge::In, RangeEdge::Out, RangeEdge::Move] {
let r = adjust_range(bounds, range, rate, edge, delta).unwrap();
assert!(r.start >= bounds.start && r.end() <= bounds.end() && r.duration >= rate.frame_duration());
if edge == RangeEdge::Move {
assert_eq!(r.duration, range.duration);
}
if edge == RangeEdge::In {
assert_eq!(r.end(), range.end());
}
if edge == RangeEdge::Out {
assert_eq!(r.start, range.start);
}
}
}
}
}
#[test]
fn hostile_ranges_and_rates_return_errors_without_panicking() {
let bounds = TimeRange::new(Tick::ZERO, Tick::from_seconds_f64(10.0));
let range = TimeRange::new(Tick::ZERO, Tick::from_seconds_f64(1.0));
for rate in [FrameRate { num: 0, den: 0 }, FrameRate { num: -1, den: 1 }, FrameRate { num: 1, den: i64::MAX }, FrameRate { num: i64::MAX, den: 1 }] {
assert!(adjust_range(bounds, range, rate, RangeEdge::Move, Tick(i64::MAX)).is_err());
}
for r in [
TimeRange::new(Tick(i64::MAX), Tick(2)),
TimeRange::new(Tick(-1), Tick(1)),
TimeRange::new(Tick::ZERO, Tick(-1)),
TimeRange::new(Tick::ZERO, Tick::ZERO),
] {
assert!(adjust_range(bounds, r, FrameRate::FPS_24, RangeEdge::In, Tick(i64::MIN)).is_err());
}
let small = TimeRange::new(Tick::ZERO, Tick(1));
assert_eq!(adjust_range(small, small, FrameRate::FPS_24, RangeEdge::Out, Tick(i64::MAX)).unwrap(), small);
}
#[test]
fn source_markers_are_undoable_and_independent_of_sequence_markers() {
let mut s = fixture();
let cursor = source_view(&s, ItemId(5)).unwrap().rate.tick_of(48);
s.execute("source.setPlayhead", json!({"time":cursor.0})).unwrap();
let before = s.project.to_json();
let qbefore = s.active_sequence().unwrap().clone();
let marker = s.execute("markers.add", json!({"target":"source","name":"Cue"})).unwrap()["marker"].as_u64().unwrap();
let v = source_view(&s, ItemId(5)).unwrap();
assert_eq!(v.markers.len(), 1);
assert_eq!(v.markers[0].start, cursor);
assert_eq!(s.active_sequence().unwrap(), &qbefore);
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(s.project.to_json(), before);
s.execute("edit.redo", json!({})).unwrap();
s.execute("markers.edit", json!({"target":"source","marker":marker,"name":"Changed"})).unwrap();
assert_eq!(source_view(&s, ItemId(5)).unwrap().markers[0].name, "Changed");
s.execute("markers.clearCurrent", json!({"target":"source"})).unwrap();
assert!(source_view(&s, ItemId(5)).unwrap().markers.is_empty());
}
#[test]
fn invalid_marker_parameters_do_not_edit_the_project() {
let mut s = fixture();
let before = s.project.to_json();
for p in [
json!({"target":true}),
json!({"target":"unknown"}),
json!({"target":"source","time":i64::MAX}),
json!({"target":"source","time":"bad"}),
json!({"target":"source","durationFrames":i64::MAX}),
json!({"target":"source","durationFrames":-1}),
json!({"target":"source","name":23}),
json!({"target":"source","color":"unknown"}),
] {
assert!(s.execute("markers.add", p).is_err());
assert_eq!(s.project.to_json(), before);
}
}
}

View File

@ -0,0 +1,98 @@
use super::*;
use filmcraft_project::ItemId;
use filmcraft_time::{Tick, TimeRange};
use serde_json::json;
fn setup() -> Session {
let mut s = Session::default();
s.execute("file.openDemoProject", json!({})).unwrap();
s.execute("file.newSequence", json!({"name":"Source test","width":1280,"height":720,"fps":24})).unwrap();
s.execute("source.open", json!({"item":5})).unwrap();
s
}
fn counts(s: &Session) -> (usize, usize) {
let q = s.active_sequence().unwrap();
(q.video_tracks.iter().map(|t| t.items.len()).sum(), q.audio_tracks.iter().map(|t| t.items.len()).sum())
}
#[test]
fn unmarked_source_defaults_to_full_span_without_writing_marks() {
let s = setup();
let view = clip_ops::source_view(&s, ItemId(5)).unwrap();
assert_eq!(view.selected_range(), TimeRange::from_bounds(view.start, view.end));
assert!(view.mark_in.is_none() && view.mark_out.is_none());
}
#[test]
fn marked_range_places_video_audio_or_linked_both_as_one_undo_step() {
for (video, audio, expected) in [(true, false, (1, 0)), (false, true, (0, 1)), (true, true, (1, 1))] {
let mut s = setup();
let start = Tick::from_seconds_f64(1.0);
let out = Tick::from_seconds_f64(3.0);
s.execute("project.setMarks", json!({"item":5,"in":start.0,"out":out.0})).unwrap();
let view = clip_ops::source_view(&s, ItemId(5)).unwrap();
let range = view.selected_range();
assert_eq!(range.end(), out + view.rate.frame_duration());
let before = s.project.to_json();
s.execute(
"timeline.place",
json!({"item":5,"track":"V1","audioTrack":"A1","time":0,"sourceIn":range.start.0,"duration":range.duration.0,"video":video,"audio":audio}),
)
.unwrap();
assert_eq!(counts(&s), expected);
let q = s.active_sequence().unwrap();
for clip in q.all_tracks().flat_map(|t| t.items.iter()) {
assert_eq!(clip.source_in, start);
assert_eq!(clip.duration, q.settings.frame_rate.snap_nearest(range.duration));
}
if video && audio {
assert!(q.video_tracks[0].items[0].link.is_some());
assert_eq!(q.video_tracks[0].items[0].link, q.audio_tracks[0].items[0].link);
}
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(s.project.to_json(), before);
s.execute("edit.redo", json!({})).unwrap();
assert_eq!(counts(&s), expected);
}
}
#[test]
fn invalid_stream_choices_and_locked_destinations_do_not_mutate_project() {
let mut s = setup();
let before = s.project.to_json();
for params in [json!({"video":false,"audio":false}), json!({"video":"yes"}), json!({"audio":2})] {
let mut p = json!({"item":5,"track":"V1","audioTrack":"A1","time":0});
for (k, v) in params.as_object().unwrap() {
p[k] = v.clone();
}
assert!(s.execute("timeline.place", p).is_err());
assert_eq!(s.project.to_json(), before);
}
let id = s.state.active_sequence.unwrap();
std::sync::Arc::make_mut(&mut s.project).sequence_mut(id).unwrap().audio_tracks[0].locked = true;
let locked = s.project.to_json();
assert!(s.execute("timeline.place", json!({"item":5,"track":"V1","audioTrack":"A1","time":0,"video":true,"audio":true})).is_err());
assert_eq!(s.project.to_json(), locked);
}
#[test]
fn audio_only_source_drops_only_sound() {
let mut s = setup();
s.execute("timeline.place", json!({"item":11,"track":"A2","time":0,"video":false,"audio":true})).unwrap();
assert_eq!(counts(&s), (0, 1));
assert_eq!(s.active_sequence().unwrap().audio_tracks[1].items.len(), 1);
}
#[test]
fn malformed_source_spans_are_bounded() {
let s = setup();
let mut view = clip_ops::source_view(&s, ItemId(5)).unwrap();
view.mark_in = Some(Tick::MAX);
view.mark_out = Some(Tick::ZERO);
let range = view.selected_range();
assert_eq!(range.start, view.end);
assert_eq!(range.duration, Tick::ZERO);
view.end = Tick::ZERO;
view.start = Tick::from_seconds_f64(1.0);
assert_eq!(view.selected_range().duration, Tick::ZERO);
}

22
crates/ui-egui/README.md Normal file
View File

@ -0,0 +1,22 @@
# filmcraft-ui-egui
FilmCraft's L5 egui frontend: panels, docking, commands, rendering and playback. Project edits
always dispatch engine commands; playback and monitor state belong to the frontend.
## Source playback
`source_playback.rs` auditions media independently of Program playback. It reuses MediaSource
decoding, output mapping, the device clock and the desktop play-ahead buffer. Only one monitor
owns the device at a time. Source frame jobs use the existing frame server and Source resolution.
The live project, undo stack and timeline playhead are unchanged. See [monitor documentation](../../docs/monitors.md)
for behavior, commands, limitations and regression tests. Source playback tests use synthetic
demo media and fake audio outputs; no third-party assets are introduced.
Source drag controls snapshot the selected span and dispatch `timeline.place` for video, audio,
or linked video/audio. Full-clip marks are implicit until set by the user. The engine owns edits,
range validation and undo; the UI owns the drag gesture. See `source_drag_ui` and the engine's
`source_placement_tests` for regression coverage.
Source range handles use transient gesture previews and the engine's integer-frame
`source_monitor::adjust_range`; release dispatches one mark edit. Monitor command routing
keeps Source navigation, markers and marked-range playback separate from Program.

View File

@ -417,6 +417,7 @@ pub fn inspect(app: &FilmcraftApp, ctx: &egui::Context) -> Value {
"pixelsPerPoint": ctx.pixels_per_point(),
"fps": app.fps,
"ui": app.ui,
"sourcePlayback": {"playing": app.source_playback.clock.playing, "audioClock": app.source_playback.clock.audio_clock, "playhead": app.session.state.source_playhead.0},
"playback": {"playing": app.playback.playing, "speed": app.playback.speed, "loop": app.playback.looping, "audioClock": app.playback.audio_clock, "dropped": app.playback.meter.counts().1, "shown": app.playback.meter.counts().0, "preroll": app.playback.preroll.is_some()},
"playhead": app.session.playhead().0,
"activeSequence": app.session.state.active_sequence.map(|i| i.0),

View File

@ -331,15 +331,55 @@ pub fn paint(painter: &Painter, rect: Rect, icon: Icon, color: Color32) {
pen.fill(&[(3.5, 3.5), (10.0, 8.0), (3.5, 12.5)]);
}
GoToIn => {
pen.line(&[(5.0, 3.0), (3.0, 3.0), (3.0, 13.0), (5.0, 13.0)]);
pen.line(&[(5.0, 3.0), (4.0, 3.0), (3.5, 3.5), (3.5, 6.0), (3.0, 7.0), (2.0, 8.0), (3.0, 9.0), (3.5, 10.0), (3.5, 12.5), (4.0, 13.0), (5.0, 13.0)]);
pen.fill(&[(13.0, 3.5), (6.0, 8.0), (13.0, 12.5)]);
}
GoToOut => {
pen.line(&[(11.0, 3.0), (13.0, 3.0), (13.0, 13.0), (11.0, 13.0)]);
pen.line(&[
(11.0, 3.0),
(12.0, 3.0),
(12.5, 3.5),
(12.5, 6.0),
(13.0, 7.0),
(14.0, 8.0),
(13.0, 9.0),
(12.5, 10.0),
(12.5, 12.5),
(12.0, 13.0),
(11.0, 13.0),
]);
pen.fill(&[(3.0, 3.5), (10.0, 8.0), (3.0, 12.5)]);
}
MarkIn => pen.line(&[(10.5, 2.5), (5.5, 2.5), (5.5, 13.5), (10.5, 13.5)]),
MarkOut => pen.line(&[(5.5, 2.5), (10.5, 2.5), (10.5, 13.5), (5.5, 13.5)]),
MarkIn => pen.line(&[
(10.5, 2.5),
(8.5, 2.5),
(7.5, 3.0),
(7.0, 4.0),
(7.0, 6.0),
(6.5, 7.0),
(5.0, 8.0),
(6.5, 9.0),
(7.0, 10.0),
(7.0, 12.0),
(7.5, 13.0),
(8.5, 13.5),
(10.5, 13.5),
]),
MarkOut => pen.line(&[
(5.5, 2.5),
(7.5, 2.5),
(8.5, 3.0),
(9.0, 4.0),
(9.0, 6.0),
(9.5, 7.0),
(11.0, 8.0),
(9.5, 9.0),
(9.0, 10.0),
(9.0, 12.0),
(8.5, 13.0),
(7.5, 13.5),
(5.5, 13.5),
]),
Marker => pen.fill(&[(4.0, 2.5), (12.0, 2.5), (12.0, 9.5), (8.0, 13.5), (4.0, 9.5)]),
Insert => {
pen.rect(2.0, 5.0, 14.0, 13.0);

View File

@ -22,6 +22,7 @@ pub mod panels;
pub mod perf;
#[cfg(not(target_arch = "wasm32"))]
pub mod play_ahead;
pub mod source_playback;
pub mod state;
pub mod theme;
pub mod widgets;
@ -178,6 +179,7 @@ pub struct FilmcraftApp {
pub tokens: Tokens,
pub frames: Arc<FrameServer>,
pub playback: Playback,
pub source_playback: source_playback::SourcePlayback,
pub audio: Option<Box<dyn AudioOut>>,
pub hooks: HostHooks,
pub dialog: Option<Dialog>,
@ -380,6 +382,7 @@ impl FilmcraftApp {
tokens: Tokens::for_kind(ThemeKind::Dark),
frames,
playback: Playback { speed: 1.0, ..Default::default() },
source_playback: Default::default(),
audio: None,
hooks: HostHooks::default(),
// Unsaved changes left by a session that died are offered first thing.
@ -667,6 +670,7 @@ impl FilmcraftApp {
}
pub fn play(&mut self, speed: f64) {
self.stop_source();
if self.session.active_sequence().is_none() {
return;
}
@ -1067,7 +1071,9 @@ impl FilmcraftApp {
}
// Mark In/Out in the Source monitor when it has focus.
let params = if self.ui.focused == PanelKind::Source
&& (matches!(id.as_str(), "markers.markIn" | "markers.markOut") || id.starts_with("markers.markSplit") || id.starts_with("markers.goToSplit"))
&& (matches!(id.as_str(), "markers.markIn" | "markers.markOut" | "markers.clearInOut")
|| id.starts_with("markers.markSplit")
|| id.starts_with("markers.goToSplit"))
{
json!({"target": "source"})
} else {
@ -1232,6 +1238,7 @@ impl FilmcraftApp {
}
self.handle_shortcuts(&ctx);
self.advance_playback(&ctx);
self.advance_source_playback(&ctx);
let t = self.tokens;
let full = ui.max_rect();
ui.painter().rect_filled(full, 0.0, t.app_bg);

View File

@ -26,6 +26,9 @@ pub const UI_COMMANDS: &[UiCommand] = &[
uic!("app.language.japanese", "日本語", ["Edit", "Language"], None),
uic!("app.language.spanish", "Español", ["Edit", "Language"], None),
uic!("app.language.portuguese", "Português (Brasil)", ["Edit", "Language"], None),
uic!("source.playback.toggle", "Source Play/Stop", [], None),
uic!("source.playback.play", "Play Source", [], None),
uic!("source.playback.stop", "Stop Source", [], None),
uic!("playback.toggle", "Play/Stop", [], Some("Space")),
uic!("playback.forward", "Shuttle Right", [], Some("L")),
uic!("playback.stop", "Shuttle Stop", [], Some("K")),
@ -150,8 +153,21 @@ pub fn panel_command_id(p: PanelKind) -> String {
format!("window.panel.{}", p.id())
}
/// Explicit monitor parameters override keyboard focus.
pub fn targets_source(app: &FilmcraftApp, params: &Value) -> bool {
match params.get("monitor").and_then(Value::as_str) {
Some("source") => true,
Some(_) => false,
None => app.ui.focused == PanelKind::Source,
}
}
/// Execute a UI or engine command by id.
pub fn invoke(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, params: Value) -> Result<Value, String> {
pub fn invoke(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, mut params: Value) -> Result<Value, String> {
if filmcraft_engine::source_monitor::source_command(id) && targets_source(app, &params) && params.get("target").is_none() {
let object = params.as_object_mut().ok_or("command parameters must be an object")?;
object.insert("target".into(), json!("source"));
}
if matches!(id, "app.language.english" | "app.language.japanese" | "app.language.spanish" | "app.language.portuguese") {
// Japanese needs the craft-fonts (built with CRAFT_FONTS_DIR) or a font installed on the system
if id == "app.language.japanese" && !crate::i18n::install_japanese_font(ctx) {
@ -190,7 +206,9 @@ pub fn invoke(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, params: Val
if let Some(r) = crate::panels::keyboard::route(app, ctx, id, &params) {
return r;
}
if let Some(r) = crate::panels::trim_monitor::route_transport(app, ctx, id) {
if !targets_source(app, &params)
&& let Some(r) = crate::panels::trim_monitor::route_transport(app, ctx, id)
{
return r;
}
if let Some(r) = crate::panels::multicam::route(app, id, &params) {
@ -233,14 +251,52 @@ pub fn invoke(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, params: Val
return r;
}
match id {
"playback.slowForward" | "playback.slowReverse" if targets_source(app, &params) => {
return Err("Source playback currently supports normal forward speed".into());
}
"playback.slowForward" | "playback.slowReverse" => {
app.play(if id == "playback.slowForward" { 0.25 } else { -0.25 });
return Ok(json!({"speed": app.playback.speed}));
}
"source.playback.toggle" => {
app.toggle_source_play()?;
return Ok(json!({"playing": app.source_playback.clock.playing}));
}
"source.playback.play" => {
app.play_source()?;
return Ok(json!({"playing": app.source_playback.clock.playing}));
}
"source.playback.stop" => {
app.stop_source();
return Ok(Value::Null);
}
"playback.toggle"
if params.get("monitor").and_then(Value::as_str) == Some("source") || (params.get("monitor").is_none() && app.ui.focused == PanelKind::Source) =>
{
app.toggle_source_play()?;
return Ok(json!({"playing": app.source_playback.clock.playing}));
}
"playback.toggle" => {
app.toggle_play(1.0);
return Ok(json!({"playing": app.playback.playing}));
}
"playback.forward" if targets_source(app, &params) => {
app.play_source()?;
return Ok(json!({"playing": app.source_playback.clock.playing, "speed": 1.0}));
}
"playback.reverse" if targets_source(app, &params) => {
return Err("Source playback currently supports normal forward speed; use Play/Space or frame stepping".into());
}
"playhead.stepBack" | "playhead.stepForward" | "playhead.stepBack5" | "playhead.stepForward5" if targets_source(app, &params) => {
app.stop_source();
let item = app.session.state.source_item.ok_or("Open a Source clip first")?;
let view = filmcraft_engine::clip_ops::source_view(&app.session, item).ok_or("Source clip is unavailable")?;
let count = if id.ends_with('5') { i64::from(app.session.prefs.playback.step_many_frames) } else { 1 };
let direction = if id.contains("Back") { -1 } else { 1 };
let delta = view.rate.frame_duration().0.saturating_mul(count).saturating_mul(direction);
let time = filmcraft_time::Tick(app.session.state.source_playhead.0.saturating_add(delta));
return app.session.execute("source.setPlayhead", json!({"time": time.0})).map_err(|e| e.to_string());
}
"playback.forward" => {
let s = if app.playback.playing && app.playback.speed > 0.0 { (app.playback.speed * 2.0).min(8.0) } else { 1.0 };
app.play(s);
@ -252,9 +308,21 @@ pub fn invoke(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, params: Val
return Ok(json!({"speed": app.playback.speed}));
}
"playback.stop" => {
app.stop();
if targets_source(app, &params) {
app.stop_source();
} else {
app.stop();
}
return Ok(Value::Null);
}
"playback.inToOut" if targets_source(app, &params) => {
app.play_source_range(false, false)?;
return Ok(Value::Null);
}
"playback.loop" if targets_source(app, &params) => {
app.source_playback.clock.looping = !app.source_playback.clock.looping;
return Ok(json!({"loop": app.source_playback.clock.looping}));
}
"playback.inToOut" => {
let seq = app.session.active_sequence();
let out = seq.and_then(|q| q.mark_out.map(|o| o + q.settings.frame_rate.frame_duration()));

View File

@ -178,8 +178,20 @@ pub fn route(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, params: &Val
v.pan = [0.0, 0.0];
Ok(json!({"zoom": v.zoom}))
}
"playback.inToOutPreroll" => play_range(app, true),
"playback.toOut" => play_range(app, false),
"playback.inToOutPreroll" => {
if crate::menus::targets_source(app, params) {
app.play_source_range(false, true).map(|_| Value::Null)
} else {
play_range(app, true)
}
}
"playback.toOut" => {
if crate::menus::targets_source(app, params) {
app.play_source_range(true, false).map(|_| Value::Null)
} else {
play_range(app, false)
}
}
"timeline.expandAllTracks" | "timeline.minimizeAllTracks" => Ok(all_heights(app, id == "timeline.expandAllTracks")),
"timeline.increaseVideoHeight" | "timeline.decreaseVideoHeight" | "timeline.increaseAudioHeight" | "timeline.decreaseAudioHeight" => {
let d = if id.contains("increase") { HEIGHT_STEP } else { -HEIGHT_STEP };

View File

@ -98,9 +98,13 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, p: PanelKind, rect: Rect)
}
}
pub mod source_drag;
pub mod source_range;
#[derive(Clone, Debug)]
enum DragPayload {
Item(ItemId),
Source(source_drag::SourceDrag),
Effect(String),
/// A graphics template (id, name) from Essential Graphics ▸ Browse.
Template(String, String),
@ -110,6 +114,15 @@ fn payload_id() -> egui::Id {
egui::Id::new("filmcraft-drag-payload")
}
pub fn start_drag_source(ui: &egui::Ui, source: source_drag::SourceDrag) {
ui.ctx().data_mut(|d| d.insert_temp(payload_id(), Some(DragPayloadBox(DragPayload::Source(source)))));
}
pub fn dragged_source(ui: &egui::Ui) -> Option<source_drag::SourceDrag> {
match payload(ui) {
Some(DragPayload::Source(s)) => Some(s),
_ => None,
}
}
pub fn start_drag_item(ui: &egui::Ui, item: ItemId) {
ui.ctx().data_mut(|d| d.insert_temp(payload_id(), Some(DragPayloadBox(DragPayload::Item(item)))));
}
@ -134,6 +147,7 @@ fn payload(ui: &egui::Ui) -> Option<DragPayload> {
pub fn dragged_project_item(ui: &egui::Ui) -> Option<ItemId> {
match payload(ui) {
Some(DragPayload::Item(i)) => Some(i),
Some(DragPayload::Source(s)) => Some(s.item),
_ => None,
}
}
@ -154,6 +168,15 @@ pub fn drag_ghost(app: &FilmcraftApp, ui: &egui::Ui) {
if let Some(p) = ctx.pointer_hover_pos() {
let label = match &pl {
DragPayload::Item(i) => app.session.project.item(*i).map(|x| x.name.clone()).unwrap_or_default(),
DragPayload::Source(s) => {
let name = app.session.project.item(s.item).map(|i| i.name.as_str()).unwrap_or("Source");
let mode = match (s.video, s.audio) {
(true, true) => "video + audio",
(true, false) => "video",
_ => "audio",
};
format!("{name} · {mode} · {:.2} s", s.range.duration.seconds())
}
DragPayload::Template(_, name) => name.clone(),
DragPayload::Effect(e) => match e.strip_prefix("preset:") {
Some(name) => name.to_string(),

View File

@ -66,10 +66,33 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, which: Which)
Some(ItemKind::Sequence(s)) => (s.settings.width, s.settings.height),
_ => (1920, 1080),
};
let mut view = view;
if let Some(range) = crate::panels::source_range::preview(app, &ctx) {
view.mark_in = Some(range.start);
view.mark_out = Some(Tick(range.end().0.saturating_sub(view.rate.frame_duration().0)).max(view.start));
}
origin = view.start;
(Target::Item(item), size, view.rate, app.session.state.source_playhead, view.end, false, view.mark_in, view.mark_out, pi.name.clone())
}
};
// Display implicit full-clip marks without writing them into the project on open.
let draft = crate::panels::source_range::preview_for(app, &ctx, which);
let (mark_in, mark_out) = if which == Which::Source {
(
Some(draft.map(|r| r.start).unwrap_or(mark_in.unwrap_or(origin))),
Some(
draft
.map(|r| Tick(r.end().0.saturating_sub(rate.frame_duration().0)))
.unwrap_or_else(|| mark_out.unwrap_or_else(|| (duration - rate.frame_duration()).max(origin))),
),
)
} else {
(
mark_in.map(|m| draft.map(|r| r.start).unwrap_or(m)),
mark_out.map(|m| draft.map(|r| Tick(r.end().0.saturating_sub(rate.frame_duration().0))).unwrap_or(m)),
)
};
let range_start = mark_in.unwrap_or(origin);
let prefix = if which == Which::Program { "program" } else { "source" };
let mv = monitor_view::view(app, which).clone();
let display = mv.display_mode().unwrap_or(DisplayMode::Composite);
@ -145,7 +168,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, which: Which)
ui.set_clip_rect(saved_clip.intersect(if mv.zoom.is_some() && !compare { video_area } else { picture_area.expand(12.0) }));
if show_picture {
ui.painter().rect_filled(pic, 0.0, t.monitor_bg);
let playing = which == Which::Program && app.playback.playing;
let playing = if which == Which::Program { app.playback.playing } else { app.source_playback.clock.playing };
let res = mv.effective_res(playing);
let screen_scale = (pic.width() * ppp / frame_size.0 as f32).min(1.0);
let scale = quantize_scale(res.scale().min(screen_scale.max(1.0 / 32.0)));
@ -167,8 +190,9 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, which: Which)
let key = FrameKey { target, frame, size: size_key, revision: rev, draft };
let project = app.session.project.clone();
if playing {
let preroll = app.playback.preroll.is_some();
app.frames.schedule_playback(key, rate, scale, &project, app.playback.speed, preroll);
let preroll = which == Which::Program && app.playback.preroll.is_some();
let speed = if which == Which::Program { app.playback.speed } else { app.source_playback.clock.speed };
app.frames.schedule_playback(key, rate, scale, &project, speed, preroll);
if preroll {
app.playback.preroll_ready = app.frames.preroll_ready(key, app.playback.speed, rate.frame_at(duration) - 1);
}
@ -191,10 +215,11 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, which: Which)
app.monitor_inexact = true;
}
if playing {
if std::mem::take(&mut app.playback.hidden) {
app.playback.meter.resync(frame, exact);
let clock = if which == Which::Program { &mut app.playback } else { &mut app.source_playback.clock };
if std::mem::take(&mut clock.hidden) {
clock.meter.resync(frame, exact);
} else {
app.playback.meter.refresh(frame, exact);
clock.meter.refresh(frame, exact);
}
}
if let Some(tex) = shown {
@ -234,7 +259,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, which: Which)
ui.painter().circle_filled(pos2(video_area.min.x + 10.0, video_area.min.y + 10.0), 4.0, c);
}
// Click/drag in the picture: the Hand tool pans a magnified picture, otherwise focus.
let pic_resp = ui.interact(pic, egui::Id::new((prefix, "pic")), Sense::click());
let pic_resp = ui.interact(pic, egui::Id::new((prefix, "pic")), if show_picture { Sense::click_and_drag() } else { Sense::hover() });
app.auto.add(&format!("{prefix}.picture"), pic.intersect(video_area), "picture");
if show_picture {
monitor_view::pan_input(app, ui, which, video_area, pic);
@ -250,6 +275,9 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, which: Which)
if let Some((top, left)) = ruler_rects {
monitor_view::rulers(app, ui, which, top, left, video_area, pic, frame_size);
}
if which == Which::Source && show_picture && pic_resp.drag_started() && app.ui.tool != crate::state::Tool::Hand {
crate::panels::source_drag::begin(app, ui, true, true);
}
if pic_resp.double_clicked() && which == Which::Source {
// (Premiere opens the clip's settings; we show info)
app.ui.status = name.clone();
@ -260,13 +288,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, which: Which)
let tc = format_time(time, rate, drop_frame, TimeDisplay::Timecode, 48000);
ui.painter().text(pos2(row1.min.x, row1.center().y), Align2::LEFT_CENTER, &tc, Tokens::timecode(), t.timecode);
app.auto.add(&format!("{prefix}.timecode"), Rect::from_min_size(row1.min, vec2(110.0, row1.height())), &tc);
let dur_tc = format_time(
mark_out.map(|o| o + rate.frame_duration()).unwrap_or(duration) - mark_in.unwrap_or(Tick::ZERO),
rate,
drop_frame,
TimeDisplay::Timecode,
48000,
);
let dur_tc = format_time(mark_out.map(|o| o + rate.frame_duration()).unwrap_or(duration) - range_start, rate, drop_frame, TimeDisplay::Timecode, 48000);
ui.painter().text(pos2(row1.max.x, row1.center().y), Align2::RIGHT_CENTER, &dur_tc, Tokens::timecode(), t.text_dim);
// zoom + resolution dropdowns centred-ish
let zr = Rect::from_min_size(pos2(row1.min.x + 116.0, row1.min.y), vec2(70.0, 24.0));
@ -279,6 +301,9 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, which: Which)
monitor_view::zoom_menu(app, &zresp, which);
let res = mv.res;
let rr = Rect::from_min_size(pos2(row1.max.x - 196.0, row1.min.y), vec2(62.0, 24.0));
if which == Which::Source && rr.min.x > zr.max.x + 12.0 {
crate::panels::source_drag::controls(app, ui, Rect::from_min_max(pos2(zr.max.x + 6.0, row1.min.y), pos2(rr.min.x - 6.0, row1.min.y + 24.0)));
}
let rresp = crate::widgets::dropdown_text(ui, rr, res.label(), &t, egui::Id::new((prefix, "res")));
app.auto.add(&format!("{prefix}.resolution"), rr, "Select Playback Resolution");
egui::Popup::menu(&rresp).show(|ui| {
@ -298,6 +323,16 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, which: Which)
let mv = monitor_view::view_mut(app, which);
ui.checkbox(&mut mv.safe_margins, "Safe Margins");
ui.checkbox(&mut mv.show_transport, "Show Transport Controls");
if which == Which::Source {
let mut looping = app.source_playback.clock.looping;
let response = ui.checkbox(&mut looping, "Loop marked range");
app.auto.add("source.settings.loop", response.rect, "Loop marked range");
if response.changed()
&& let Err(e) = crate::menus::invoke(app, &ctx, "playback.loop", json!({"monitor":"source"}))
{
app.ui.status = e;
}
}
if which == Which::Program {
ui.separator();
ui.checkbox(&mut app.ui.show_scopes, "Lumetri Scopes");
@ -383,10 +418,13 @@ fn mini_timeline(
mark_out: Option<Tick>,
) {
let t = app.tokens;
let p = ui.painter();
let duration = end;
let dur = (end - origin).0.max(1) as f64;
let xof = |tk: Tick| bar.min.x + (((tk - origin).0 as f64 / dur) as f32).clamp(0.0, 1.0) * bar.width();
let resp = ui.interact(bar, egui::Id::new((which as u8, "scrub")), Sense::click_and_drag());
app.auto.add(if which == Which::Program { "program.scrubBar" } else { "source.scrubBar" }, bar, "scrub bar");
let interaction = crate::panels::source_range::interact(app, ui, bar, which);
let p = ui.painter();
// ticks: minor 4 pt, major 10 pt, ~20 pt spacing
let n = ((bar.width() / 20.0) as i32).max(2);
for i in 0..=n {
@ -394,10 +432,40 @@ fn mini_timeline(
let h = if i % 5 == 0 { 8.0 } else { 3.5 };
p.line_segment([pos2(x, bar.max.y - h), pos2(x, bar.max.y)], Stroke::new(1.0, t.text_faint));
}
let a = xof(interaction.range.map(|r| r.start).unwrap_or(mark_in.unwrap_or(origin)));
// Out includes its frame; both the shading and its bracket end at the following boundary.
let b = xof(interaction.range.map(|r| r.end()).unwrap_or_else(|| mark_out.map(|o| Tick(o.0.saturating_add(rate.frame_duration().0))).unwrap_or(duration)))
.max(a);
if mark_in.is_some() || mark_out.is_some() {
let a = xof(mark_in.unwrap_or(origin));
let b = xof(mark_out.map(|o| o + rate.frame_duration()).unwrap_or(duration));
p.rect_filled(Rect::from_min_max(pos2(a, bar.min.y + 8.0), pos2(b, bar.max.y)), 0.0, Color32::from_rgb(0x5c, 0x5c, 0x5c));
p.rect_filled(Rect::from_min_max(pos2(a, bar.min.y + 8.0), pos2(b, bar.max.y)), 0.0, Color32::from_rgba_unmultiplied(0x5c, 0x5c, 0x5c, 128));
}
if mark_in.is_some() || mark_out.is_some() {
// Curly braces sit inside the gray band, with their waist at its exact range edge.
let top = bar.min.y + 8.0;
let bottom = bar.max.y;
let mid = (top + bottom) * 0.5;
for (x, direction, hot) in [(a, 1.0_f32, interaction.in_hot), (b, -1.0_f32, interaction.out_hot)] {
if hot || (direction > 0.0 && mark_in.is_none()) || (direction < 0.0 && mark_out.is_none()) {
continue;
}
let point = |offset: f32, y: f32| pos2(x + direction * offset, y);
p.line(
vec![
point(4.0, top),
point(2.8, top),
point(2.0, top + 1.0),
point(2.0, mid - 2.0),
point(1.4, mid - 1.0),
point(0.0, mid),
point(1.4, mid + 1.0),
point(2.0, mid + 2.0),
point(2.0, bottom - 1.0),
point(2.8, bottom),
point(4.0, bottom),
],
Stroke::new(1.0, t.playhead),
);
}
}
// markers: the sequence's (Program), the clip's or the subclip's inherited ones (Source)
let markers = match which {
@ -426,13 +494,23 @@ fn mini_timeline(
Stroke::NONE,
));
p.line_segment([pos2(x, hy + 8.0), pos2(x, bar.max.y)], Stroke::new(1.0, t.playhead));
let resp = ui.interact(bar, egui::Id::new((which as u8, "scrub")), Sense::click_and_drag());
app.auto.add(if which == Which::Program { "program.scrubBar" } else { "source.scrubBar" }, bar, "scrub bar");
if (resp.dragged() || resp.clicked())
&& let Some(pos) = resp.interact_pointer_pos()
{
let f = ((pos.x - bar.min.x) / bar.width()).clamp(0.0, 1.0) as f64;
let tk = rate.snap(origin + Tick((f * dur) as i64));
if interaction.in_hot {
crate::panels::source_range::trim_cue(p, a, bar, 4.0);
}
if interaction.out_hot {
crate::panels::source_range::trim_cue(p, b, bar, -4.0);
}
let scrub = interaction.scrub.or_else(|| {
if !interaction.handled && (resp.dragged() || resp.clicked()) {
resp.interact_pointer_pos().map(|pos| {
let f = ((pos.x - bar.min.x) / bar.width().max(1.0)).clamp(0.0, 1.0) as f64;
rate.snap(origin + Tick((f * dur) as i64))
})
} else {
None
}
});
if let Some(tk) = scrub {
match which {
Which::Program => {
app.stop();
@ -455,7 +533,7 @@ fn transport(app: &mut FilmcraftApp, ui: &mut egui::Ui, row: Rect, which: Which)
(Icon::MarkOut, "src.markOut", "Mark Out (O)"),
(Icon::GoToIn, "src.goIn", "Go to In (Shift+I)"),
(Icon::StepBack, "src.stepBack", "Step Back 1 Frame (Left)"),
(Icon::Play, "src.play", "Play-Stop Toggle (Space)"),
(if app.source_playback.clock.playing { Icon::Pause } else { Icon::Play }, "source.playback.toggle", "Play-Stop Toggle (Space)"),
(Icon::StepFwd, "src.stepFwd", "Step Forward 1 Frame (Right)"),
(Icon::GoToOut, "src.goOut", "Go to Out (Shift+O)"),
(Icon::Insert, "source.insert", "Insert (,)"),
@ -510,12 +588,12 @@ fn transport(app: &mut FilmcraftApp, ui: &mut egui::Ui, row: Rect, which: Which)
source_nav(app, cmd);
Ok(serde_json::Value::Null)
}
"src.play" => Ok(serde_json::Value::Null),
"exportFrame" => {
app.ui.status = "Export Frame: use Export mode (M6)".into();
Ok(serde_json::Value::Null)
}
c => crate::menus::invoke(app, &ctx, c, json!({})),
c => crate::menus::invoke(app, &ctx, c, json!({"monitor": if src { "source" } else { "program" }})),
};
if let Err(e) = r {
app.ui.status = e;

View File

@ -597,7 +597,13 @@ pub fn waveform(
p.line_segment([pos2(x, area.min.y), pos2(x, area.max.y)], Stroke::new(1.0, t.playhead));
let resp = ui.interact(area, egui::Id::new("source-waveform"), Sense::click_and_drag());
app.auto.add("source.waveform", area, "audio waveform");
// Horizontal waveform drags scrub; dragging vertically lifts the marked audio to the timeline.
let delta = resp.drag_delta();
if resp.dragged() && delta.y.abs() > delta.x.abs().max(6.0) && crate::panels::dragged_source(ui).is_none() {
crate::panels::source_drag::begin(app, ui, false, true);
}
if (resp.clicked() || resp.dragged())
&& crate::panels::dragged_source(ui).is_none()
&& let Some(pos) = resp.interact_pointer_pos()
{
let f = ((pos.x - area.min.x) / area.width()).clamp(0.0, 1.0) as f64;

View File

@ -0,0 +1,83 @@
//! Source monitor drag controls. The payload snapshots the marked span at drag start.
use egui::{Rect, Sense, pos2, vec2};
use filmcraft_engine::clip_ops::source_view;
use filmcraft_project::ItemId;
use filmcraft_time::TimeRange;
use crate::FilmcraftApp;
#[derive(Clone, Copy, Debug)]
pub struct SourceDrag {
pub item: ItemId,
pub range: TimeRange,
pub video: bool,
pub audio: bool,
}
pub fn begin(app: &mut FilmcraftApp, ui: &egui::Ui, video: bool, audio: bool) {
let Some(item) = app.session.state.source_item else { return };
let Some(view) = source_view(&app.session, item) else { return };
let Some(media) = app.session.project.item(view.media) else { return };
let video = video && media.has_video();
let audio = audio && media.has_audio();
let range = view.selected_range();
if (!video && !audio) || range.duration.0 <= 0 {
return;
}
app.stop_source();
super::start_drag_source(ui, SourceDrag { item: view.media, range, video, audio });
}
pub fn controls(app: &mut FilmcraftApp, ui: &mut egui::Ui, row: Rect) {
let media = app.session.state.source_item.and_then(|i| source_view(&app.session, i)).and_then(|v| app.session.project.item(v.media));
let (has_video, has_audio) = media.map(|i| (i.has_video(), i.has_audio())).unwrap_or_default();
let buttons = [
("source.drag.video", "Drag Video Only", true, false, has_video),
("source.drag.audio", "Drag Audio Only", false, true, has_audio),
("source.drag.both", "Drag Video and Audio", true, true, has_video && has_audio),
];
let gap = 4.0_f32.min(row.width() / 12.0);
let width = ((row.width() - gap * 2.0) / 3.0).clamp(1.0, 32.0);
let left = row.center().x - (width * 3.0 + gap * 2.0) * 0.5;
for (i, (id, label, video, audio, enabled)) in buttons.into_iter().enumerate() {
let rect = Rect::from_min_size(pos2(left + i as f32 * (width + gap), row.min.y), vec2(width, row.height()));
let response = ui.add_enabled_ui(enabled, |ui| ui.interact(rect, egui::Id::new(id), Sense::drag())).inner;
response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled, label));
let t = app.tokens;
if response.hovered() || response.dragged() {
ui.painter().rect_filled(rect, 2.0, t.hover);
}
let color = if !enabled {
t.text_faint
} else if response.hovered() {
t.tab_text_active
} else {
t.icon
};
let icon = rect.shrink(4.0);
if video && audio {
let half = icon.width() * 0.5;
crate::icons::paint(ui.painter(), Rect::from_min_size(icon.min, vec2(half, icon.height())), crate::icons::Icon::Film, color);
crate::icons::paint(
ui.painter(),
Rect::from_min_size(pos2(icon.min.x + half, icon.min.y), vec2(half, icon.height())),
crate::icons::Icon::Audio,
color,
);
} else {
crate::icons::paint(ui.painter(), icon, if video { crate::icons::Icon::Film } else { crate::icons::Icon::Audio }, color);
}
app.auto.add(id, rect, label);
// Windows maps Grab/Grabbing to crossed arrows; Pointer maps to its hand cursor.
let hover_cursor = if cfg!(windows) { egui::CursorIcon::PointingHand } else { egui::CursorIcon::Grab };
let drag_cursor = if cfg!(windows) { egui::CursorIcon::PointingHand } else { egui::CursorIcon::Grabbing };
let response = response.on_hover_cursor(hover_cursor).on_hover_text(label);
if response.dragged() {
ui.ctx().set_cursor_icon(drag_cursor);
}
if response.drag_started() {
begin(app, ui, video, audio);
}
}
}

View File

@ -0,0 +1,216 @@
//! Source and Program In/Out handles and range translation. Preview is transient; release is one engine edit.
use super::monitor::Which;
use crate::{FilmcraftApp, dock::PanelKind};
use egui::{Rect, Sense, Stroke, pos2, vec2};
use filmcraft_engine::{
clip_ops::source_view,
source_monitor::{RangeEdge, adjust_range},
};
use filmcraft_project::ItemId;
use filmcraft_time::{FrameRate, Tick, TimeRange};
use serde_json::json;
#[derive(Clone, Copy, Debug)]
struct Drag {
item: ItemId,
edge: RangeEdge,
bounds: TimeRange,
original: TimeRange,
preview: TimeRange,
rate: FrameRate,
pointer_x: f32,
revision: u64,
}
fn key(which: Which) -> egui::Id {
egui::Id::new(("monitor-range-edit", which as u8))
}
fn self_item(app: &FilmcraftApp, which: Which) -> Option<ItemId> {
if which == Which::Source { app.session.state.source_item } else { app.session.state.active_sequence }
}
fn drag(ctx: &egui::Context, which: Which) -> Option<Drag> {
ctx.data(|d| d.get_temp(key(which)))
}
fn clear(ctx: &egui::Context, which: Which) {
ctx.data_mut(|d| d.remove::<Drag>(key(which)));
}
pub fn preview(app: &FilmcraftApp, ctx: &egui::Context) -> Option<TimeRange> {
preview_for(app, ctx, Which::Source)
}
pub fn preview_for(app: &FilmcraftApp, ctx: &egui::Context, which: Which) -> Option<TimeRange> {
drag(ctx, which).filter(|d| Some(d.item) == self_item(app, which) && d.revision == app.session.revision).map(|d| d.preview)
}
#[derive(Default)]
pub struct Interaction {
pub range: Option<TimeRange>,
pub in_hot: bool,
pub out_hot: bool,
pub handled: bool,
pub scrub: Option<Tick>,
}
pub fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, bar: Rect, which: Which) -> Interaction {
let ctx = ui.ctx().clone();
let Some(item) = self_item(app, which) else {
clear(&ctx, which);
return Interaction::default();
};
let Some(view) = source_view(&app.session, item) else {
clear(&ctx, which);
return Interaction::default();
};
let enabled = view.subclip.is_none() && view.start.0 >= 0 && view.end > view.start;
if let Some(d) = drag(&ctx, which)
&& (d.item != item || d.revision != app.session.revision || ctx.input(|i| i.key_pressed(egui::Key::Escape)))
{
clear(&ctx, which);
}
let selected = preview_for(app, &ctx, which).unwrap_or_else(|| view.selected_range());
let span = view.end.0.saturating_sub(view.start.0).max(1) as f64;
let xof = |t: Tick| bar.min.x + (t.0.saturating_sub(view.start.0) as f64 / span).clamp(0.0, 1.0) as f32 * bar.width();
let (a, b) = (xof(selected.start), xof(selected.end()));
let mid = (a + b) * 0.5;
let band = Rect::from_min_max(pos2(a, bar.min.y + 8.0), pos2(b, bar.max.y));
let body = Rect::from_min_max(pos2((a + 6.0).min(mid), band.min.y), pos2((b - 6.0).max(mid), band.max.y));
let inside = Rect::from_min_max(pos2(a - 6.0, bar.min.y), pos2((a + 6.0).min(mid), bar.max.y)).intersect(bar.expand2(vec2(6.0, 0.0)));
let outside = Rect::from_min_max(pos2((b - 6.0).max(mid), bar.min.y), pos2(b + 6.0, bar.max.y)).intersect(bar.expand2(vec2(6.0, 0.0)));
let mut result = Interaction::default();
let ids = if which == Which::Source {
["source.range.body", "source.range.in", "source.range.out"]
} else {
["program.range.body", "program.range.in", "program.range.out"]
};
for (id, label, edge, rect) in [
(ids[0], "Move marked range", RangeEdge::Move, body),
(ids[1], "Drag In point", RangeEdge::In, inside),
(ids[2], "Drag Out point", RangeEdge::Out, outside),
] {
if which == Which::Program
&& match edge {
RangeEdge::In => view.mark_in.is_none(),
RangeEdge::Out => view.mark_out.is_none(),
RangeEdge::Move => view.mark_in.is_none() || view.mark_out.is_none(),
}
{
continue;
}
app.auto.add(id, rect, label);
let response =
ui.add_enabled_ui(enabled && rect.width() > 0.0, |ui| ui.interact(rect, egui::Id::new(id), Sense::click_and_drag())).inner.on_hover_text(label);
let cursor = if edge == RangeEdge::Move {
if cfg!(windows) { egui::CursorIcon::PointingHand } else { egui::CursorIcon::Grab }
} else {
egui::CursorIcon::ResizeHorizontal
};
let response = response.on_hover_cursor(cursor);
result.handled |= response.hovered() || response.dragged() || response.clicked();
if edge == RangeEdge::In {
result.in_hot = response.hovered();
}
if edge == RangeEdge::Out {
result.out_hot = response.hovered();
}
if response.drag_started() {
let Some(pointer) = ctx.input(|i| i.pointer.press_origin()) else { continue };
let bounds = TimeRange::from_bounds(view.start, view.end);
let original = view.selected_range();
if let Err(e) = adjust_range(bounds, original, view.rate, edge, Tick::ZERO) {
app.ui.status = e.to_string();
continue;
}
if which == Which::Source {
app.stop_source();
} else {
app.stop();
}
app.ui.focused = if which == Which::Source { PanelKind::Source } else { PanelKind::Program };
ctx.data_mut(|d| {
d.insert_temp(
key(which),
Drag { item, edge, bounds, original, preview: original, rate: view.rate, pointer_x: pointer.x, revision: app.session.revision },
)
});
}
if let Some(mut d) = drag(&ctx, which).filter(|d| d.edge == edge && d.item == item) {
result.handled = true;
ctx.set_cursor_icon(cursor);
if let Some(pos) = response.interact_pointer_pos() {
let raw = (f64::from(pos.x - d.pointer_x) / f64::from(bar.width().max(1.0)) * d.bounds.duration.0 as f64)
.clamp(-(d.bounds.duration.0 as f64), d.bounds.duration.0 as f64);
if raw.is_finite() {
match adjust_range(d.bounds, d.original, d.rate, d.edge, Tick(raw as i64)) {
Ok(r) => {
d.preview = r;
ctx.data_mut(|v| v.insert_temp(key(which), d));
}
Err(e) => {
app.ui.status = e.to_string();
clear(&ctx, which);
}
}
}
}
if edge == RangeEdge::In {
result.in_hot = true;
}
if edge == RangeEdge::Out {
result.out_hot = true;
}
if response.drag_stopped() {
clear(&ctx, which);
if d.preview != d.original && d.revision == app.session.revision && self_item(app, which) == Some(item) {
let mut params = json!({"item":item.0});
if edge != RangeEdge::Out {
params["in"] = json!(d.preview.start.0);
}
if edge != RangeEdge::In {
params["out"] = json!(d.preview.end().0.saturating_sub(d.rate.frame_duration().0).max(view.start.0));
}
if let Err(e) = app.session.execute("project.setMarks", params) {
app.ui.status = e.to_string();
}
}
} else if !ctx.input(|i| i.pointer.any_down()) {
clear(&ctx, which);
}
}
if response.clicked()
&& edge == RangeEdge::Move
&& let Some(pos) = response.interact_pointer_pos()
{
let time = view.start.0.saturating_add(((f64::from(pos.x - bar.min.x) / f64::from(bar.width().max(1.0))).clamp(0.0, 1.0) * span) as i64);
if let Err(e) = app.session.execute(if which == Which::Source { "source.setPlayhead" } else { "playhead.set" }, json!({"time":time})) {
app.ui.status = e.to_string();
}
}
}
let time = if which == Which::Source { app.session.state.source_playhead } else { app.session.playhead() };
let x = xof(time);
let rect = Rect::from_min_max(pos2(x - 7.0, bar.max.y - 12.0), pos2(x + 7.0, bar.max.y));
let id = if which == Which::Source { "source.playhead" } else { "program.playhead" };
app.auto.add(id, rect, "Scrub playhead");
let playhead = ui.interact(rect, egui::Id::new(id), Sense::click_and_drag()).on_hover_cursor(egui::CursorIcon::ResizeHorizontal);
if (playhead.dragged() || playhead.clicked())
&& let Some(pos) = playhead.interact_pointer_pos()
{
let f = (f64::from(pos.x - bar.min.x) / f64::from(bar.width().max(1.0))).clamp(0.0, 1.0);
result.scrub = Some(view.rate.snap(Tick(view.start.0.saturating_add((f * span) as i64))));
result.handled = true;
}
result.range = preview_for(app, &ctx, which);
result
}
/// Original generic red bracket and bidirectional arrows; no third-party artwork.
pub fn trim_cue(p: &egui::Painter, x: f32, bar: Rect, inward: f32) {
let red = crate::panels::trim_monitor::RIPPLE_RED;
let (top, bottom) = (bar.min.y + 8.0, bar.max.y);
let mid = (top + bottom) * 0.5;
let stroke = Stroke::new(1.2, red);
p.line(vec![pos2(x + inward, top), pos2(x, top), pos2(x, bottom), pos2(x + inward, bottom)], stroke);
p.line_segment([pos2(x - 9.0, mid), pos2(x + 9.0, mid)], stroke);
for direction in [-1.0_f32, 1.0] {
p.line(vec![pos2(x + direction * 6.0, mid - 2.5), pos2(x + direction * 9.0, mid), pos2(x + direction * 6.0, mid + 2.5)], stroke);
}
}

View File

@ -2122,11 +2122,19 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: &
&& let Some(p) = ctx.pointer_hover_pos()
&& layout.content.contains(p)
{
let row = layout.row_at(p.y).cloned();
let source = crate::panels::dragged_source(ui);
let row = layout.row_at(p.y).cloned().filter(|r| {
source.is_none_or(|s| match r.kind {
TrackKind::Video => s.video,
TrackKind::Audio => s.audio,
})
});
let t = snap(app, seq, layout, rate.snap_nearest(layout.tick_at(p.x).max(Tick::ZERO)), &[]);
let still = app.session.prefs.timeline.still_duration(rate);
let is_still = app.session.project.item(item).and_then(|i| i.as_media()).is_some_and(|m| m.info.kind == filmcraft_media::MediaKind::Still);
let dur = app.session.project.item(item).map(|i| i.duration()).filter(|d| d.0 > 0 && !is_still).unwrap_or(still);
let dur = source
.map(|s| s.range.duration)
.unwrap_or_else(|| app.session.project.item(item).map(|i| i.duration()).filter(|d| d.0 > 0 && !is_still).unwrap_or(still));
if let Some(row) = &row {
let r = Rect::from_min_max(pos2(layout.x_of(t), row.rect.min.y + 1.0), pos2(layout.x_of(t + dur), row.rect.max.y - 1.0));
ui.painter().rect_filled(r, 3.0, Color32::from_white_alpha(40));
@ -2142,7 +2150,14 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: &
TrackKind::Video => (Some(row.track.0), seq.audio_tracks.get(row.index).or(seq.audio_tracks.first()).map(|t| t.id.0)),
TrackKind::Audio => (seq.video_tracks.get(row.index).or(seq.video_tracks.first()).map(|t| t.id.0), Some(row.track.0)),
};
let r = app.session.execute("timeline.place", json!({"item": item.0, "track": vt, "audioTrack": at, "time": t.0, "insert": mods.command}));
let mut params = json!({"item": item.0, "track": vt, "audioTrack": at, "time": t.0, "insert": mods.command});
if let Some(source) = source {
params["sourceIn"] = json!(source.range.start.0);
params["duration"] = json!(source.range.duration.0);
params["video"] = json!(source.video);
params["audio"] = json!(source.audio);
}
let r = app.session.execute("timeline.place", params);
if let Err(e) = r {
app.ui.status = e.to_string();
}

View File

@ -0,0 +1,258 @@
//! Source-file auditioning. Playback is presentation state: it never edits the project.
use std::sync::{Arc, Mutex};
use filmcraft_engine::clip_ops::source_view;
use filmcraft_project::ItemId;
use filmcraft_render::SourceProvider;
use filmcraft_time::Tick;
use serde_json::json;
use crate::{AUDIO_STALL_S, FilmcraftApp, Playback};
#[derive(Default)]
pub struct SourcePlayback {
pub clock: Playback,
item: Option<ItemId>,
shown: Tick,
range_start: Option<Tick>,
failure: Arc<Mutex<Option<String>>>,
}
impl FilmcraftApp {
pub fn toggle_source_play(&mut self) -> Result<(), String> {
if self.source_playback.clock.playing {
self.stop_source();
Ok(())
} else {
self.play_source()
}
}
pub fn play_source(&mut self) -> Result<(), String> {
self.start_source_playback(None, None)
}
pub fn play_source_range(&mut self, from_playhead: bool, preroll: bool) -> Result<(), String> {
let item = self.session.state.source_item.ok_or("Open a Source clip first")?;
let view = source_view(&self.session, item).ok_or("Source item is unavailable")?;
let range = view.selected_range();
if range.duration.0 <= 0 {
return Err("Source marked range is empty".into());
}
let seconds = |v: f64| Tick::from_seconds_f64(if v.is_finite() { v.clamp(0.0, 3600.0) } else { 0.0 });
let start = if from_playhead {
self.session.state.source_playhead
} else if preroll {
Tick(range.start.0.saturating_sub(seconds(self.session.prefs.playback.preroll_seconds).0)).max(view.start)
} else {
range.start
};
let stop =
if preroll { Tick(range.end().0.saturating_add(seconds(self.session.prefs.playback.postroll_seconds).0)).min(view.end) } else { range.end() };
if start >= stop {
return Err("Source playhead is at or beyond the Out point".into());
}
self.start_source_playback(Some(start), Some(stop))
}
fn start_source_playback(&mut self, start: Option<Tick>, stop: Option<Tick>) -> Result<(), String> {
let item = self.session.state.source_item.ok_or("Open a media file in the Source monitor first")?;
let view = source_view(&self.session, item).ok_or("Source item is unavailable")?;
let media = self.session.project.item(view.media).and_then(|i| i.as_media()).ok_or("Source playback currently supports media files and subclips")?;
if media.offline {
return Err("Source media is offline: use Link Media first".into());
}
if view.end <= view.start {
return Err("Source clip has no playable duration".into());
}
let looping = self.source_playback.clock.looping;
let range = view.selected_range();
if looping && range.duration.0 <= 0 {
return Err("Source marked range is empty".into());
}
let first = if looping { range.start } else { view.start };
let end = stop.unwrap_or(if looping { range.end() } else { view.end });
self.stop();
self.stop_source();
let mut time = start.unwrap_or(self.session.state.source_playhead);
if start.is_none() && (time < first || time >= view.rate.snap(Tick(end.0.saturating_sub(view.rate.frame_duration().0))).max(first)) {
time = first;
}
self.session.execute("source.setPlayhead", json!({"time": time.0})).map_err(|e| e.to_string())?;
let time = self.session.state.source_playhead;
self.source_playback.range_start = start;
self.source_playback.item = Some(item);
self.source_playback.shown = time;
self.source_playback.clock = Playback { playing: true, speed: 1.0, anchor_time: -1.0, anchor_tick: time, looping, stop_at: stop, ..Default::default() };
self.source_playback.clock.meter.start(1.0);
self.start_source_audio();
Ok(())
}
pub fn stop_source(&mut self) {
if !self.source_playback.clock.playing {
return;
}
self.source_playback.clock.playing = false;
self.source_playback.clock.audio_clock = false;
self.source_playback.clock.meter.finish();
self.frames.stop_prefetch();
if let Some(audio) = self.audio.as_mut() {
audio.stop();
}
}
fn start_source_audio(&mut self) {
self.source_playback.clock.audio_clock = false;
let Some(item) = self.source_playback.item else { return };
let Some(view) = source_view(&self.session, item) else { return };
let Some(media) = self.session.project.item(view.media).and_then(|i| i.as_media()) else { return };
if media.info.audio.is_none() {
return;
}
let project = self.session.project.clone();
let provider = self.session.media.provider(project, self.session.services.clone());
let Some(source) = provider.source(view.media) else {
self.ui.status = "Source audio is unavailable: check Link Media".into();
return;
};
let Some(audio) = self.audio.as_mut() else { return };
let sr = audio.sample_rate();
if sr == 0 || audio.channels() == 0 {
self.ui.status = "Source audio output has an invalid sample rate or channel count".into();
return;
}
let map = [self.session.prefs.audio_hardware.map_left, self.session.prefs.audio_hardware.map_right];
let mixdown = filmcraft_audio_dsp::channels::Mixdown::from_id(&self.session.prefs.audio.mixdown_type).unwrap_or_default();
let channels = media.interpret.audio_channels.as_ref().and_then(|m| m.clips.first()).cloned().unwrap_or_default();
// A seek gets its own failure slot: a late callback from the old stream cannot stop it.
self.source_playback.failure = Arc::new(Mutex::new(None));
let failure = self.source_playback.failure.clone();
let end = self
.source_playback
.clock
.stop_at
.unwrap_or_else(|| if self.source_playback.clock.looping { view.selected_range().end() } else { view.end })
.to_units_floor(i64::from(sr));
let mix = move |cursor: i64, buf: &mut [f32], ch: usize| {
buf.fill(0.0);
if ch == 0 || cursor >= end {
return;
}
let frames = (buf.len() / ch).min(usize::try_from(end.saturating_sub(cursor)).unwrap_or(0));
match source.audio(cursor, frames, sr) {
Ok(mut sound) => {
if !channels.is_empty() {
sound.channels = channels.iter().filter_map(|c| sound.channels.get(usize::from(*c)).cloned()).collect();
}
let sound = filmcraft_render::audio::to_layout(sound, filmcraft_audio_dsp::channels::Layout::Stereo, mixdown);
let Some(left) = sound.channels.first() else { return };
let right = sound.channels.get(1).unwrap_or(left);
filmcraft_engine::settings::map_output(left, right, buf, ch, map);
}
Err(e) => {
*failure.lock().unwrap_or_else(std::sync::PoisonError::into_inner) = Some(format!("Source audio decoding failed: {e}"));
}
}
};
audio.stop();
let cursor = self.session.state.source_playhead.to_units_floor(i64::from(sr));
#[cfg(not(target_arch = "wasm32"))]
let fill = crate::play_ahead::spawn(mix, cursor, sr, audio.channels(), self.source_playback.clock.audio_stats.clone());
#[cfg(target_arch = "wasm32")]
let fill = {
let (mix, mut cursor) = (mix, cursor);
Box::new(move |buf: &mut [f32], ch: usize| {
mix(cursor, buf, ch);
cursor = cursor.saturating_add(i64::try_from(buf.len() / ch.max(1)).unwrap_or(0));
})
};
match audio.start(fill) {
Ok(_) => self.source_playback.clock.audio_clock = true,
Err(e) => self.ui.status = format!("Source audio output failed: {e}"),
}
}
pub(crate) fn advance_source_playback(&mut self, ctx: &egui::Context) {
if !self.source_playback.clock.playing {
return;
}
let item = self.source_playback.item;
let Some(view) = item.filter(|i| Some(*i) == self.session.state.source_item).and_then(|i| source_view(&self.session, i)) else {
self.stop_source();
return;
};
let error = self.source_playback.failure.lock().unwrap_or_else(std::sync::PoisonError::into_inner).take();
if let Some(error) = error {
self.stop_source();
self.ui.status = error;
return;
}
let now = ctx.input(|i| i.time);
let time = self.session.state.source_playhead;
if time != self.source_playback.shown {
self.source_playback.clock.anchor_tick = time;
self.source_playback.clock.anchor_time = now;
self.source_playback.clock.audio_seen = (0, now);
self.start_source_audio();
}
let clock = &mut self.source_playback.clock;
if clock.anchor_time < 0.0 {
clock.anchor_time = now;
clock.audio_seen = (0, now);
}
let reading = if clock.audio_clock { self.audio.as_ref().and_then(|a| a.played_frames().map(|n| (n, a.sample_rate()))) } else { None };
if let Some((frames, _)) = reading
&& frames != clock.audio_seen.0
{
clock.audio_seen = (frames, now);
}
if clock.audio_clock && (reading.is_none() || now - clock.audio_seen.1 >= AUDIO_STALL_S) {
clock.anchor_tick = time;
clock.anchor_time = now;
clock.audio_clock = false;
if let Some(audio) = self.audio.as_mut() {
audio.stop();
}
self.ui.status = "Source audio output stopped responding: playing without sound (check Settings ▸ Audio Hardware)".into();
}
let elapsed = match reading {
Some((frames, sr)) if clock.audio_clock && sr > 0 => frames as f64 / f64::from(sr),
_ => (now - clock.anchor_time).max(0.0),
};
let next = Tick(clock.anchor_tick.0.saturating_add(Tick::from_seconds_f64(elapsed).0));
let range = view.selected_range();
let end = clock.stop_at.unwrap_or_else(|| if clock.looping { range.end() } else { view.end }).min(view.end);
let ended = next >= end;
if ended && clock.looping {
let start = self.source_playback.range_start.unwrap_or(range.start).max(view.start);
if let Err(e) = self.session.execute("source.setPlayhead", json!({"time":start.0})) {
self.stop_source();
self.ui.status = e.to_string();
return;
}
let time = self.session.state.source_playhead;
self.source_playback.shown = time;
self.source_playback.clock.anchor_tick = time;
self.source_playback.clock.anchor_time = now;
self.source_playback.clock.audio_seen = (0, now);
self.start_source_audio();
ctx.request_repaint();
return;
}
let next = if ended { Tick(end.0.saturating_sub(view.rate.frame_duration().0)).max(view.start) } else { next.max(view.start) };
match self.session.execute("source.setPlayhead", json!({"time": next.0})) {
Ok(_) => self.source_playback.shown = self.session.state.source_playhead,
Err(e) => {
self.stop_source();
self.ui.status = e.to_string();
return;
}
}
if ended {
self.stop_source();
}
ctx.request_repaint();
}
}

View File

@ -0,0 +1,393 @@
use egui_kittest::Harness;
use filmcraft_engine::Session;
use filmcraft_time::Tick;
use filmcraft_ui_egui::FilmcraftApp;
use serde_json::json;
fn step(h: &mut Harness<'static, FilmcraftApp>) {
let ctx = h.ctx.clone();
let mut raw = std::mem::take(h.input_mut());
eframe::App::raw_input_hook(h.state_mut(), &ctx, &mut raw);
*h.input_mut() = raw;
ctx.request_repaint(); // Direct engine edits in a harness do not wake the native event loop.
h.step();
}
fn harness(item: u64) -> Harness<'static, FilmcraftApp> {
let mut s = Session::default();
s.execute("file.openDemoProject", json!({})).unwrap();
s.execute("file.newSequence", json!({"name":"Drop test","width":1280,"height":720,"fps":24})).unwrap();
s.execute("source.open", json!({"item":item})).unwrap();
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 980.0)).build_eframe(move |_cc| FilmcraftApp::new(s));
for _ in 0..4 {
step(&mut h);
}
h
}
fn pointer(h: &mut Harness<'static, FilmcraftApp>, pos: egui::Pos2, pressed: Option<bool>) {
h.input_mut().events.push(egui::Event::PointerMoved(pos));
if pressed == Some(true) {
step(h); // Establish hover before pressing, as the live control-channel drag does.
}
if let Some(pressed) = pressed {
h.input_mut().events.push(egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE });
}
step(h);
}
fn control_rect(h: &Harness<'static, FilmcraftApp>, id: &str) -> egui::Rect {
let [x, y, w, height] = h.state().auto.elements.iter().find(|e| e.id == id).or_else(|| h.state().auto.find(id)).unwrap().rect;
egui::Rect::from_min_size(egui::pos2(x, y), egui::vec2(w, height))
}
fn drag(h: &mut Harness<'static, FilmcraftApp>, control: &str, audio: bool, change_marks: bool) {
let from = control_rect(h, control).center();
let panel = control_rect(h, "panel.Timeline");
let row = control_rect(h, if audio { "timeline.track.A1.target" } else { "timeline.track.V1.target" });
let to = egui::pos2(panel.min.x + h.state().ui.timeline.header_w + 60.0, row.center().y);
pointer(h, from, Some(true));
pointer(h, from + egui::vec2(0.0, 9.0), None);
if change_marks {
h.state_mut().session.execute("project.setMarks", json!({"item":5,"in":Tick::from_seconds_f64(8.0).0})).unwrap();
}
pointer(h, to, None);
pointer(h, to, Some(false));
for _ in 0..2 {
step(h);
}
}
#[test]
fn source_drag_controls_place_each_stream_choice_and_preserve_range() {
for (control, audio, video_count, audio_count) in [("source.drag.video", false, 1, 0), ("source.drag.audio", true, 0, 1), ("source.drag.both", false, 1, 1)]
{
let mut h = harness(5);
let start = Tick::from_seconds_f64(1.0);
let out = Tick::from_seconds_f64(3.0);
h.state_mut().session.execute("project.setMarks", json!({"item":5,"in":start.0,"out":out.0})).unwrap();
step(&mut h);
drag(&mut h, control, audio, false);
let s = &h.state().session;
let q = s.active_sequence().unwrap();
assert_eq!(q.video_tracks.iter().map(|t| t.items.len()).sum::<usize>(), video_count, "{control}: {}", h.state().ui.status);
assert_eq!(q.audio_tracks.iter().map(|t| t.items.len()).sum::<usize>(), audio_count, "{control}: {}", h.state().ui.status);
for c in q.all_tracks().flat_map(|t| t.items.iter()) {
assert_eq!(c.source_in, start);
}
if video_count == 1 && audio_count == 1 {
assert!(q.video_tracks[0].items[0].link.is_some());
assert_eq!(q.video_tracks[0].items[0].link, q.audio_tracks[0].items[0].link);
}
}
}
#[test]
fn drag_captures_marked_range_before_later_marker_changes() {
let mut h = harness(5);
let start = Tick::from_seconds_f64(1.0);
h.state_mut().session.execute("project.setMarks", json!({"item":5,"in":start.0,"out":Tick::from_seconds_f64(3.0).0})).unwrap();
step(&mut h);
drag(&mut h, "source.drag.video", false, true);
assert_eq!(h.state().session.active_sequence().unwrap().video_tracks[0].items[0].source_in, start);
}
#[test]
fn unmarked_clip_drags_full_length_without_creating_marks() {
let mut h = harness(5);
drag(&mut h, "source.picture", false, false);
let s = &h.state().session;
let c = &s.active_sequence().unwrap().video_tracks[0].items[0];
assert_eq!(c.source_in, Tick::ZERO);
assert_eq!(c.duration, s.active_sequence().unwrap().settings.frame_rate.snap_nearest(Tick::from_seconds_f64(18.0)));
let m = s.project.item(filmcraft_engine::project::ItemId(5)).unwrap().as_media().unwrap();
assert!(m.mark_in.is_none() && m.mark_out.is_none());
}
#[test]
fn audio_file_drag_places_only_audio() {
let mut h = harness(11);
drag(&mut h, "source.drag.audio", true, false);
let q = h.state().session.active_sequence().unwrap();
assert_eq!(q.video_tracks.iter().map(|t| t.items.len()).sum::<usize>(), 0);
assert_eq!(q.audio_tracks[0].items.len(), 1);
}
#[test]
fn clear_in_out_shortcut_addresses_the_focused_monitor_and_is_undoable() {
use filmcraft_engine::clip_ops::source_view;
use filmcraft_engine::project::ItemId;
use filmcraft_ui_egui::dock::PanelKind;
let mut h = harness(5);
let start = Tick::from_seconds_f64(1.0);
let out = Tick::from_seconds_f64(3.0);
h.state_mut().session.execute("project.setMarks", json!({"item":5,"in":start.0,"out":out.0})).unwrap();
h.state_mut().session.execute("markers.markIn", json!({"time":start.0})).unwrap();
h.state_mut().session.execute("markers.markOut", json!({"time":out.0})).unwrap();
let before = h.state().session.project.to_json();
let modifiers = egui::Modifiers { shift: true, command: true, ctrl: !cfg!(target_os = "macos"), mac_cmd: cfg!(target_os = "macos"), ..Default::default() };
let press = |h: &mut Harness<'static, FilmcraftApp>| {
h.input_mut().events.push(egui::Event::Key { key: egui::Key::X, physical_key: Some(egui::Key::X), pressed: true, repeat: false, modifiers });
step(h);
h.input_mut().events.push(egui::Event::Key { key: egui::Key::X, physical_key: Some(egui::Key::X), pressed: false, repeat: false, modifiers });
step(h);
};
h.state_mut().ui.focused = PanelKind::Source;
press(&mut h);
let view = source_view(&h.state().session, ItemId(5)).unwrap();
assert!(view.mark_in.is_none() && view.mark_out.is_none());
assert_eq!(view.selected_range().duration, view.end - view.start);
assert_eq!(h.state().session.active_sequence().unwrap().mark_in, Some(start));
assert_eq!(h.state().session.active_sequence().unwrap().mark_out, Some(out));
h.state_mut().session.execute("edit.undo", json!({})).unwrap();
assert_eq!(h.state().session.project.to_json(), before);
h.state_mut().ui.focused = PanelKind::Program;
press(&mut h);
assert!(h.state().session.active_sequence().unwrap().mark_in.is_none());
assert!(h.state().session.active_sequence().unwrap().mark_out.is_none());
assert_eq!(source_view(&h.state().session, ItemId(5)).unwrap().mark_in, Some(start));
h.state_mut().session.state.active_sequence = None;
h.state_mut().ui.focused = PanelKind::Source;
press(&mut h);
assert!(source_view(&h.state().session, ItemId(5)).unwrap().mark_in.is_none());
let cleared = h.state().session.project.to_json();
for target in [json!("other"), json!(23), json!(null)] {
assert!(h.state_mut().session.execute("markers.clearInOut", json!({"target":target})).is_err());
assert_eq!(h.state().session.project.to_json(), cleared);
}
}
fn marked_harness() -> Harness<'static, FilmcraftApp> {
let mut h = harness(5);
let view = filmcraft_engine::clip_ops::source_view(&h.state().session, filmcraft_engine::project::ItemId(5)).unwrap();
h.state_mut().session.execute("project.setMarks", json!({"item":5,"in":view.rate.tick_of(48).0,"out":view.rate.tick_of(95).0})).unwrap();
step(&mut h);
h
}
fn marked_range(h: &Harness<'static, FilmcraftApp>) -> filmcraft_time::TimeRange {
filmcraft_engine::clip_ops::source_view(&h.state().session, filmcraft_engine::project::ItemId(5)).unwrap().selected_range()
}
#[test]
fn range_handles_preview_then_commit_one_undo_step() {
for id in ["source.range.in", "source.range.out", "source.range.body"] {
let mut h = marked_harness();
let original = marked_range(&h);
let before = h.state().session.project.to_json();
let from = control_rect(&h, id).center();
let to = from + egui::vec2(35.0, 0.0);
pointer(&mut h, from, Some(true));
pointer(&mut h, to, None);
assert_eq!(h.state().session.project.to_json(), before, "{id} must preview without editing");
assert!(
filmcraft_ui_egui::panels::source_range::preview(h.state(), &h.ctx).is_some(),
"{id}: from={from:?}, source_time={:?}, playhead={:?}",
h.state().session.state.source_playhead,
h.state().auto.find("source.playhead")
);
pointer(&mut h, to, Some(false));
let changed = marked_range(&h);
assert_ne!(changed, original, "{id}: {}", h.state().ui.status);
if id.ends_with("body") {
assert_eq!(changed.duration, original.duration);
}
if id.ends_with("in") {
assert_eq!(changed.end(), original.end());
}
if id.ends_with("out") {
assert_eq!(changed.start, original.start);
}
h.state_mut().session.execute("edit.undo", json!({})).unwrap();
assert_eq!(h.state().session.project.to_json(), before);
h.state_mut().session.execute("edit.redo", json!({})).unwrap();
assert_eq!(marked_range(&h), changed);
}
}
#[test]
fn range_drag_escape_and_concurrent_edits_cancel_without_overwriting_marks() {
for concurrent in [false, true] {
let mut h = marked_harness();
let before = h.state().session.project.to_json();
let from = control_rect(&h, "source.range.in").center();
let to = from + egui::vec2(35.0, 0.0);
pointer(&mut h, from, Some(true));
pointer(&mut h, to, None);
if concurrent {
h.state_mut().session.execute("project.setMarks", json!({"item":5,"in":null,"out":null})).unwrap();
} else {
h.input_mut().events.push(egui::Event::Key {
key: egui::Key::Escape,
physical_key: Some(egui::Key::Escape),
pressed: true,
repeat: false,
modifiers: egui::Modifiers::NONE,
});
}
step(&mut h);
let current = h.state().session.project.to_json();
pointer(&mut h, to, Some(false));
assert_eq!(h.state().session.project.to_json(), current);
if !concurrent {
assert_eq!(current, before);
}
}
}
#[test]
fn range_move_clamps_to_media_bounds_and_keeps_duration() {
for delta in [-3000.0, 3000.0] {
let mut h = marked_harness();
let before = marked_range(&h);
let from = control_rect(&h, "source.range.body").center();
let to = from + egui::vec2(delta, 0.0);
pointer(&mut h, from, Some(true));
pointer(&mut h, to, None);
pointer(&mut h, to, Some(false));
let after = marked_range(&h);
let view = filmcraft_engine::clip_ops::source_view(&h.state().session, filmcraft_engine::project::ItemId(5)).unwrap();
assert_eq!(after.duration, before.duration);
assert!(after.start >= view.start && after.end() <= view.end);
}
}
fn chord(h: &mut Harness<'static, FilmcraftApp>, key: egui::Key, shift: bool, primary: bool) {
let modifiers = egui::Modifiers {
shift,
command: primary,
ctrl: primary && !cfg!(target_os = "macos"),
mac_cmd: primary && cfg!(target_os = "macos"),
..Default::default()
};
for pressed in [true, false] {
h.input_mut().events.push(egui::Event::Key { key, physical_key: Some(key), pressed, repeat: false, modifiers });
step(h);
}
}
#[test]
fn source_marker_navigation_and_clear_shortcuts_leave_program_alone() {
use filmcraft_ui_egui::dock::PanelKind;
let mut h = marked_harness();
h.state_mut().ui.focused = PanelKind::Source;
let before = h.state().session.active_sequence().unwrap().clone();
let program_time = h.state().session.playhead();
chord(&mut h, egui::Key::I, true, false);
assert_eq!(h.state().session.state.source_playhead, marked_range(&h).start);
assert_eq!(h.state().session.playhead(), program_time);
chord(&mut h, egui::Key::M, false, false);
assert_eq!(filmcraft_engine::clip_ops::source_view(&h.state().session, filmcraft_engine::project::ItemId(5)).unwrap().markers.len(), 1);
assert_eq!(h.state().session.active_sequence().unwrap(), &before);
chord(&mut h, egui::Key::I, true, true);
assert!(filmcraft_engine::clip_ops::source_view(&h.state().session, filmcraft_engine::project::ItemId(5)).unwrap().mark_in.is_none());
chord(&mut h, egui::Key::O, true, true);
assert!(filmcraft_engine::clip_ops::source_view(&h.state().session, filmcraft_engine::project::ItemId(5)).unwrap().mark_out.is_none());
assert_eq!(h.state().session.active_sequence().unwrap(), &before);
assert_eq!(h.state().session.playhead(), program_time);
}
#[test]
fn program_range_handles_share_behavior_and_leave_source_marks_alone() {
use filmcraft_engine::{clip_ops::source_view, project::ItemId};
use filmcraft_ui_egui::dock::PanelKind;
for id in ["program.range.in", "program.range.out", "program.range.body"] {
let mut h = marked_harness();
h.state_mut()
.session
.execute("timeline.place", json!({"item":5,"sourceIn":0,"duration":Tick::from_seconds_f64(12.0).0,"video":true,"audio":true}))
.unwrap();
let seq = h.state().session.state.active_sequence.unwrap();
let rate = h.state().session.active_sequence().unwrap().settings.frame_rate;
h.state_mut().session.execute("project.setMarks", json!({"item":seq.0,"in":rate.tick_of(48).0,"out":rate.tick_of(95).0})).unwrap();
h.state_mut().ui.focused = PanelKind::Program;
step(&mut h);
let original = source_view(&h.state().session, seq).unwrap().selected_range();
let source = source_view(&h.state().session, ItemId(5)).unwrap().selected_range();
let before = h.state().session.project.to_json();
let source_time = h.state().session.state.source_playhead;
let program_time = h.state().session.playhead();
let from = control_rect(&h, id).center();
let to = from + egui::vec2(35.0, 0.0);
pointer(&mut h, from, Some(true));
pointer(&mut h, to, None);
assert_eq!(h.state().session.project.to_json(), before);
pointer(&mut h, to, Some(false));
let changed = source_view(&h.state().session, seq).unwrap().selected_range();
assert_ne!(changed, original, "{id}: {}", h.state().ui.status);
if id.ends_with("body") {
assert_eq!(changed.duration, original.duration);
}
if id.ends_with("in") {
assert_eq!(changed.end(), original.end());
}
if id.ends_with("out") {
assert_eq!(changed.start, original.start);
}
assert_eq!(source_view(&h.state().session, ItemId(5)).unwrap().selected_range(), source);
assert_eq!(h.state().session.state.source_playhead, source_time);
assert_eq!(h.state().session.playhead(), program_time);
h.state_mut().session.execute("edit.undo", json!({})).unwrap();
assert_eq!(h.state().session.project.to_json(), before);
h.state_mut().session.execute("edit.redo", json!({})).unwrap();
assert_eq!(source_view(&h.state().session, seq).unwrap().selected_range(), changed);
}
}
#[test]
fn program_range_draft_cancels_when_active_sequence_changes() {
let mut h = marked_harness();
h.state_mut().session.execute("timeline.place", json!({"item":5,"sourceIn":0,"duration":Tick::from_seconds_f64(12.0).0})).unwrap();
let seq = h.state().session.state.active_sequence.unwrap();
h.state_mut().session.execute("project.setMarks", json!({"item":seq.0,"in":Tick::from_seconds_f64(2.0).0,"out":Tick::from_seconds_f64(5.0).0})).unwrap();
step(&mut h);
let before = h.state().session.project.to_json();
let from = control_rect(&h, "program.range.in").center();
let to = from + egui::vec2(35.0, 0.0);
pointer(&mut h, from, Some(true));
pointer(&mut h, to, None);
h.state_mut().session.state.active_sequence = None;
step(&mut h);
pointer(&mut h, to, Some(false));
assert_eq!(h.state().session.project.to_json(), before);
}
#[test]
fn playhead_scrubs_above_full_range_and_trim_handles_in_both_monitors() {
use filmcraft_ui_egui::dock::PanelKind;
for source in [true, false] {
let mut h = harness(5);
if !source {
h.state_mut().session.execute("timeline.place", json!({"item":5})).unwrap();
}
h.state_mut().ui.focused = if source { PanelKind::Source } else { PanelKind::Program };
if source {
h.state_mut().session.execute("source.setPlayhead", json!({"time":0})).unwrap();
} else {
h.state_mut().session.execute("playhead.set", json!({"time":0})).unwrap();
}
step(&mut h);
let before = h.state().session.project.to_json();
let from = control_rect(&h, if source { "source.playhead" } else { "program.playhead" }).center();
let to = from + egui::vec2(110.0, 0.0);
pointer(&mut h, from, Some(true));
pointer(&mut h, to, None);
pointer(&mut h, to, Some(false));
assert_eq!(h.state().session.project.to_json(), before, "scrubbing must not edit range marks");
let time = if source { h.state().session.state.source_playhead } else { h.state().session.playhead() };
assert!(time > Tick::ZERO, "scrubber must win over the In handle");
}
}
#[test]
fn program_handles_exist_only_for_explicit_marks_and_clear_hides_them() {
let mut h = harness(5);
h.state_mut().session.execute("timeline.place", json!({"item":5})).unwrap();
let seq = h.state().session.state.active_sequence.unwrap();
step(&mut h);
let has = |h: &Harness<'static, FilmcraftApp>, id: &str| h.state().auto.elements.iter().any(|e| e.id == id);
assert!(!has(&h, "program.range.in") && !has(&h, "program.range.out") && !has(&h, "program.range.body"));
assert!(has(&h, "program.playhead"));
h.state_mut().session.execute("project.setMarks", json!({"item":seq.0,"in":Tick::from_seconds_f64(2.0).0})).unwrap();
step(&mut h);
assert!(has(&h, "program.range.in"));
assert!(!has(&h, "program.range.out") && !has(&h, "program.range.body"));
h.state_mut().session.execute("project.setMarks", json!({"item":seq.0,"out":Tick::from_seconds_f64(5.0).0})).unwrap();
step(&mut h);
assert!(has(&h, "program.range.in") && has(&h, "program.range.out") && has(&h, "program.range.body"));
h.state_mut().session.execute("markers.clearInOut", json!({"target":"program"})).unwrap();
step(&mut h);
assert!(!has(&h, "program.range.in") && !has(&h, "program.range.out") && !has(&h, "program.range.body"));
assert!(has(&h, "source.range.in") && has(&h, "source.range.out"));
}

View File

@ -0,0 +1,198 @@
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use egui_kittest::Harness;
use filmcraft_engine::Session;
use filmcraft_time::Tick;
use filmcraft_ui_egui::{AudioOut, FilmcraftApp, dock::PanelKind, menus};
use serde_json::json;
fn app(item: u64) -> FilmcraftApp {
let mut session = Session::default();
session.execute("file.openDemoProject", json!({})).unwrap();
session.execute("source.open", json!({"item":item})).unwrap();
FilmcraftApp::new(session)
}
#[test]
fn source_runs_pauses_seeks_and_does_not_edit_the_sequence() {
let mut h = Harness::builder().with_step_dt(0.25).with_size(egui::vec2(1280.0, 800.0)).build_eframe(move |_cc| app(5));
h.step();
let before = h.state().session.project.to_json();
let program = h.state().session.playhead();
h.state_mut().play_source().unwrap();
for _ in 0..8 {
h.step();
}
assert!(h.state().session.state.source_playhead > Tick::from_seconds_f64(1.0));
assert_eq!(h.state().session.playhead(), program);
assert_eq!(h.state().session.project.to_json(), before);
h.state_mut().session.execute("source.setPlayhead", json!({"seconds":8})).unwrap();
h.step();
h.step();
assert!(h.state().session.state.source_playhead.seconds() >= 8.0);
assert!(h.state().session.state.source_playhead.seconds() < 9.0);
h.state_mut().stop_source();
let stopped = h.state().session.state.source_playhead;
h.step();
h.step();
assert_eq!(h.state().session.state.source_playhead, stopped);
}
#[test]
fn source_stops_at_end_restarts_and_stops_when_item_changes() {
let mut h = Harness::builder().with_step_dt(0.25).with_size(egui::vec2(1280.0, 800.0)).build_eframe(move |_cc| app(5));
h.step();
h.state_mut().session.execute("source.setPlayhead", json!({"seconds":17.5})).unwrap();
h.state_mut().play_source().unwrap();
for _ in 0..8 {
h.step();
}
assert!(!h.state().source_playback.clock.playing);
assert!(h.state().session.state.source_playhead.seconds() < 18.0);
h.state_mut().play_source().unwrap();
assert_eq!(h.state().session.state.source_playhead, Tick::ZERO);
h.state_mut().session.execute("source.open", json!({"item":6})).unwrap();
h.step();
assert!(!h.state().source_playback.clock.playing);
}
#[test]
fn space_targets_the_focused_monitor_and_output_is_exclusive() {
let mut app = app(5);
let ctx = egui::Context::default();
app.ui.focused = PanelKind::Source;
menus::invoke(&mut app, &ctx, "playback.toggle", json!({})).unwrap();
assert!(app.source_playback.clock.playing);
assert!(!app.playback.playing);
menus::invoke(&mut app, &ctx, "playback.toggle", json!({"monitor":"program"})).unwrap();
assert!(app.playback.playing);
assert!(!app.source_playback.clock.playing);
app.play_source().unwrap();
assert!(!app.playback.playing);
menus::invoke(&mut app, &ctx, "playback.stop", json!({})).unwrap();
assert!(!app.source_playback.clock.playing);
}
#[test]
fn empty_source_returns_a_useful_error() {
let mut app = FilmcraftApp::new(Session::default());
assert!(app.play_source().unwrap_err().contains("Source monitor"));
assert!(!app.source_playback.clock.playing);
}
type Fill = Box<dyn FnMut(&mut [f32], usize) + Send>;
struct Out {
frames: Arc<AtomicU64>,
fill: Arc<Mutex<Option<Fill>>>,
stalled: bool,
}
impl AudioOut for Out {
fn start(&mut self, fill: Fill) -> Result<u32, String> {
self.frames.store(0, Ordering::SeqCst);
*self.fill.lock().unwrap() = Some(fill);
Ok(48_000)
}
fn stop(&mut self) {
*self.fill.lock().unwrap() = None;
}
fn sample_rate(&self) -> u32 {
48_000
}
fn played_frames(&self) -> Option<u64> {
Some(if self.stalled { 0 } else { self.frames.fetch_add(4_800, Ordering::SeqCst) + 4_800 })
}
}
#[test]
fn audio_source_outputs_sound_and_uses_the_device_clock() {
let frames = Arc::new(AtomicU64::new(0));
let fill = Arc::new(Mutex::new(None));
let mut app = app(11);
app.audio = Some(Box::new(Out { frames, fill: fill.clone(), stalled: false }));
let mut h = Harness::builder().with_step_dt(0.25).with_size(egui::vec2(1280.0, 800.0)).build_eframe(move |_cc| app);
h.step();
h.state_mut().play_source().unwrap();
for _ in 0..8 {
h.step();
}
assert!(h.state().source_playback.clock.audio_clock);
assert!(h.state().session.state.source_playhead.seconds() > 0.2);
let mut heard = false;
for _ in 0..100 {
std::thread::sleep(std::time::Duration::from_millis(5));
let mut buffer = vec![0.0; 960];
if let Some(f) = fill.lock().unwrap().as_mut() {
f(&mut buffer, 2);
}
heard |= buffer.iter().any(|x| x.abs() > 0.001);
if heard {
break;
}
}
assert!(heard, "the audio callback must produce non-silent source samples");
h.state_mut().stop_source();
}
#[test]
fn stalled_source_audio_falls_back_to_wall_clock() {
let mut app = app(11);
app.audio = Some(Box::new(Out { frames: Arc::new(AtomicU64::new(0)), fill: Arc::new(Mutex::new(None)), stalled: true }));
let mut h = Harness::builder().with_step_dt(0.25).with_size(egui::vec2(1280.0, 800.0)).build_eframe(move |_cc| app);
h.step();
h.state_mut().play_source().unwrap();
for _ in 0..16 {
h.step();
}
assert!(!h.state().source_playback.clock.audio_clock);
assert!(h.state().session.state.source_playhead.seconds() > 1.5);
h.state_mut().stop_source();
}
#[test]
fn source_range_playback_stops_at_out_and_loop_restarts_without_program_playback() {
let mut h = Harness::builder().with_step_dt(0.25).with_size(egui::vec2(1280.0, 800.0)).build_eframe(move |_cc| app(5));
h.step();
let rate = filmcraft_engine::clip_ops::source_view(&h.state().session, filmcraft_engine::project::ItemId(5)).unwrap().rate;
let (start, out) = (rate.tick_of(24), rate.tick_of(47));
h.state_mut().session.execute("project.setMarks", json!({"item":5,"in":start.0,"out":out.0})).unwrap();
h.state_mut().ui.focused = PanelKind::Source;
let before = h.state().session.project.to_json();
let program = h.state().session.playhead();
let ctx = h.ctx.clone();
menus::invoke(h.state_mut(), &ctx, "playback.inToOut", json!({})).unwrap();
assert_eq!(h.state().session.state.source_playhead, start);
for _ in 0..10 {
h.step();
}
assert!(!h.state().source_playback.clock.playing);
assert_eq!(h.state().session.state.source_playhead, out);
assert!(!h.state().playback.playing);
assert_eq!(h.state().session.playhead(), program);
menus::invoke(h.state_mut(), &ctx, "playback.loop", json!({})).unwrap();
menus::invoke(h.state_mut(), &ctx, "playback.inToOut", json!({})).unwrap();
for _ in 0..20 {
h.step();
assert!(h.state().source_playback.clock.playing);
let t = h.state().session.state.source_playhead;
assert!(t >= start && t <= out);
}
assert_eq!(h.state().session.project.to_json(), before);
assert!(!h.state().playback.playing);
h.state_mut().stop_source();
}
#[test]
fn a_single_frame_source_range_starts_at_the_marked_last_frame() {
let mut h = Harness::builder().with_step_dt(0.1).with_size(egui::vec2(1280.0, 800.0)).build_eframe(move |_cc| app(5));
h.step();
let v = filmcraft_engine::clip_ops::source_view(&h.state().session, filmcraft_engine::project::ItemId(5)).unwrap();
let last = v.rate.snap(v.end - v.rate.frame_duration());
h.state_mut().session.execute("project.setMarks", json!({"item":5,"in":last.0,"out":last.0})).unwrap();
h.state_mut().play_source_range(false, false).unwrap();
assert_eq!(h.state().session.state.source_playhead, last);
for _ in 0..4 {
h.step();
}
assert!(!h.state().source_playback.clock.playing);
assert_eq!(h.state().session.state.source_playhead, last);
}

View File

@ -30,3 +30,93 @@ Automation ids: `<monitor>.zoom`, `<monitor>.zoom.<level>`, `<monitor>.settings.
`<monitor>.guide.<n>`, `program.compare.reference|prev|next|set`, `source.waveform`, and in the
dialogs `guides.add.vertical|horizontal|position|ok|cancel`, `guides.save.name|ok|cancel`,
`guides.manage.row.<n>|apply|delete|close`.
## Source playback
Video files, audio files, generated media and media subclips play in the Source monitor with its
Play/Pause button or Space while Source has focus. The Source clock and playhead are independent
of the active sequence; auditioning does not create timeline clips or change the project. Starting
Source stops Program playback, and starting Program stops Source, so only one monitor owns audio.
Scrubbing while playing seeks the Source clock and restarts its audio at that position. Replacing
the open source stops playback. Playback stops at the last frame, and Play there starts again.
Play auditions the full source span by default. Play In to Out and Play to Out
address the marked Source range when Source has focus. The Source wrench menu can loop the
marked range; Program keeps its separate loop setting.
Normal forward playback has sound. L starts normal forward Source playback, K stops, and the
left/right frame-step keys address Source when it has focus. Reverse and shuttle-speed Source
playback and source sequences are not yet supported and report an explanatory error. A failed
audio device leaves the picture advancing with a status message; a decoding error stops Source.
Commands: `source.playback.play`, `source.playback.stop`, `source.playback.toggle` (no parameters),
available through the UI command registry, CLI bridge and MCP. `playback.toggle` also accepts
`monitor: "source"|"program"` to override focus. `ui.inspect.sourcePlayback` reports playing,
audioClock and playhead. Test: `cargo test --release -p filmcraft-ui-egui --test source_playback_ui`.
## Drag a marked Source range to the timeline
An unmarked Source clip shows In at its beginning and Out on its last frame. These are implicit
limits: opening a clip does not write marks or dirty the project. Set In/Out with I/O or the
transport buttons; the saved marks are reused when reopening that clip. Out includes its frame.
Drag the **Video only**, **Audio only**, or **Video + audio** icons between Fit and playback
resolution to an open
sequence. Drop video on a video track and audio on an audio track. Both uses the dropped track
and its corresponding track of the other kind, creating linked clips in one undoable edit.
Unavailable stream choices are disabled. Dragging the video picture also brings its available
streams; a vertical drag from an audio waveform brings sound, while a horizontal drag scrubs.
The drag captures the selected range at its start. Source playback pauses when lifting a clip.
A locked or unavailable destination reports an error without partially placing the pair.
Automation ids: `source.drag.video`, `source.drag.audio`, `source.drag.both`, `source.picture`,
and `source.waveform`. Placement dispatches `timeline.place` with `sourceIn`, `duration`, `video`
and `audio`; the stream flags default to true for existing callers. Engine tests cover range
bounds, stream choices, linked placement, invalid parameters and undo/redo. UI tests exercise
actual drags: `cargo test --release -p filmcraft-ui-egui --test source_drag_ui`.
Ctrl+Shift+X (Cmd+Shift+X on macOS) clears In/Out on the focused Source clip, including split
points, and restores the implicit full span. It is undoable and leaves Program marks unchanged.
The command `markers.clearInOut` accepts `target: "source"|"program"`; omitting it addresses Program.
Source drag icons use the Windows hand cursor because its native Grab mapping is crossed arrows.
Source range braces use the playhead's theme color and the gray selection band is translucent,
so the ruler ticks remain visible. This shared egui implementation applies to Windows, macOS
and Linux. The clear shortcut uses the primary modifier: Ctrl on Windows/Linux, Cmd on macOS;
I/O, Space and the three drag gestures are common to all three platforms. Runtime verification
for this contribution was performed on Windows; macOS/Linux have not been run locally.
## Adjust Source marks on the ruler
Drag either In/Out handle to trim the selected span, or drag the interior gray band to move
both marks while keeping the duration. Handles show an original red bracket and arrows on
hover. Edits snap to source frames, stay within the clip and retain at least one frame.
A gesture previews without editing the project and commits one undoable `project.setMarks`
command on release. Escape cancels; opening another source or a concurrent project edit
also cancels the draft. Subclip handles are currently disabled.
Automation ids are `source.range.in`, `source.range.out`, `source.range.body` and
`source.settings.loop`. Clicking the band's interior still scrubs.
Source-focused Go to In/Out, clear individual/both marks, clip-marker commands and
previous/next marker address the open Source clip. Shift+Left/Right step multiple Source
frames using the preference. UI callers can override focus with `monitor: "source"|"program"`;
engine marker/navigation commands accept `target: "source"|"program"` and default to Program.
Source marker edits leave sequence markers and the Program playhead unchanged.
Coverage: engine `source_monitor::tests` validates frame bounds and hostile inputs; UI
`source_drag_ui` covers release, cancellation, range movement and command routing;
`source_playback_ui` covers marked-range stopping, looping and the last-frame boundary.
## Program range handles
Program uses the same In/Out hover cues, frame-snapped trimming, duration-preserving range
movement and one-edit undo behavior. It targets the active sequence through `project.setMarks`
and leaves an independently opened Source clip's marks and playhead unchanged. Program shows only explicitly set marks; clearing them hides the corresponding handles.
Range movement requires both Program marks. Empty sequences disable the handles.
Changing the active sequence cancels an unfinished draft. Automation ids: `program.range.in`,
`program.range.out` and `program.range.body`. Both monitors share the original brace artwork,
playhead-blue color and translucent gray band. Runtime review remains Windows-only.
The playhead has drag priority above the range band and trim handles, including a full-span
selection. Drag it to scrub; the unoccupied upper ruler can also scrub. Drag the gray band away
from the playhead to translate the marked range.