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:
parent
26d972ce01
commit
8a7275c821
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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, ¶ms) && 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, ¶ms) {
|
||||
return r;
|
||||
}
|
||||
if let Some(r) = crate::panels::trim_monitor::route_transport(app, ctx, id) {
|
||||
if !targets_source(app, ¶ms)
|
||||
&& let Some(r) = crate::panels::trim_monitor::route_transport(app, ctx, id)
|
||||
{
|
||||
return r;
|
||||
}
|
||||
if let Some(r) = crate::panels::multicam::route(app, id, ¶ms) {
|
||||
|
|
@ -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, ¶ms) => {
|
||||
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, ¶ms) => {
|
||||
app.play_source()?;
|
||||
return Ok(json!({"playing": app.source_playback.clock.playing, "speed": 1.0}));
|
||||
}
|
||||
"playback.reverse" if targets_source(app, ¶ms) => {
|
||||
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, ¶ms) => {
|
||||
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, ¶ms) {
|
||||
app.stop_source();
|
||||
} else {
|
||||
app.stop();
|
||||
}
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
"playback.inToOut" if targets_source(app, ¶ms) => {
|
||||
app.play_source_range(false, false)?;
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
"playback.loop" if targets_source(app, ¶ms) => {
|
||||
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()));
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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"));
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in New Issue