mirror of
https://github.com/storytold/filmcraft.git
synced 2026-11-01 22:19:33 -06:00
Merge origin/main into #704 (gap and marker context menus side by side; contributors)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Generated
+30
-1
@@ -1686,6 +1686,7 @@ dependencies = [
|
||||
"objc2 0.6.4",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"objc2-foundation 0.3.2",
|
||||
"open",
|
||||
"rfd",
|
||||
"serde_json",
|
||||
"sys-locale",
|
||||
@@ -1829,7 +1830,6 @@ dependencies = [
|
||||
"filmcraft-vp9",
|
||||
"image",
|
||||
"log",
|
||||
"rayon",
|
||||
"serde_json",
|
||||
"symphonia",
|
||||
"thiserror 2.0.21",
|
||||
@@ -3233,6 +3233,25 @@ dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "is-docker"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "is-wsl"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5"
|
||||
dependencies = [
|
||||
"is-docker",
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itertools"
|
||||
version = "0.15.0"
|
||||
@@ -4158,6 +4177,16 @@ version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
|
||||
|
||||
[[package]]
|
||||
name = "open"
|
||||
version = "5.4.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aa576c76302b7b808eecc68061e67336c47833ef9d22caa74dda10fa9675eebc"
|
||||
dependencies = [
|
||||
"is-wsl",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl-probe"
|
||||
version = "0.2.1"
|
||||
|
||||
@@ -61,6 +61,12 @@ impl Args {
|
||||
self.opts.iter().any(|(n, _)| n == k)
|
||||
}
|
||||
|
||||
/// A version query: `--version` anywhere, or `-V` in the subcommand position, so global
|
||||
/// options may come first (`--demo --version`, `--compact -V`).
|
||||
pub fn wants_version(&self) -> bool {
|
||||
self.flag("--version") || self.pos(0) == Some("-V")
|
||||
}
|
||||
|
||||
/// The first option that is not in `allowed`, if any.
|
||||
pub fn unknown_opt(&self, allowed: &[&str]) -> Option<&str> {
|
||||
self.opts.iter().map(|(n, _)| n.as_str()).find(|n| !allowed.contains(n))
|
||||
@@ -141,6 +147,16 @@ mod tests {
|
||||
assert_eq!(a.params_from(2).unwrap(), json!({"a": {"b": [1]}}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_after_global_options() {
|
||||
for s in ["--version", "-V", "--demo --version", "--compact -V", "--data-dir d --version", "export --version"] {
|
||||
assert!(args(s).wants_version(), "{s}");
|
||||
}
|
||||
for s in ["version", "--data-dir -V", "exec x -V", "-- --version"] {
|
||||
assert!(!args(s).wants_version(), "{s}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_opt_reports_first_unlisted_option() {
|
||||
let allowed = ["--bridge", "--project"];
|
||||
|
||||
@@ -252,12 +252,6 @@ async fn main() {
|
||||
}
|
||||
|
||||
async fn cli() {
|
||||
if matches!(std::env::args().nth(1).as_deref(), Some("--version" | "-V")) {
|
||||
outln!("filmcraft-cli {}", env!("CARGO_PKG_VERSION"));
|
||||
return;
|
||||
}
|
||||
register_hardware_decoders();
|
||||
register_gpu_frame_renderer();
|
||||
let a = Args::parse(std::env::args().skip(1));
|
||||
// `--help` anywhere (`filmcraft-cli --help`, `filmcraft-cli export --help`) prints the reference:
|
||||
// the parser takes it as a flag option, so it never reaches the subcommand match.
|
||||
@@ -265,6 +259,13 @@ async fn cli() {
|
||||
out!("{HELP}");
|
||||
return;
|
||||
}
|
||||
// `--version` anywhere and `-V` as the subcommand, also after global options (`--demo --version`, #710).
|
||||
if a.wants_version() {
|
||||
outln!("filmcraft-cli {}", env!("CARGO_PKG_VERSION"));
|
||||
return;
|
||||
}
|
||||
register_hardware_decoders();
|
||||
register_gpu_frame_renderer();
|
||||
let Some(cmd) = a.pos(0) else { usage("missing subcommand") };
|
||||
match cmd {
|
||||
"help" | "-h" => out!("{HELP}"),
|
||||
|
||||
@@ -24,6 +24,16 @@ fn help_and_usage_errors() {
|
||||
assert_eq!(cli(&["exec", "file.newBin", "notkv"]).status.code(), Some(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_after_global_options() {
|
||||
let want = format!("filmcraft-cli {}\n", env!("CARGO_PKG_VERSION"));
|
||||
for args in [&["--version"][..], &["-V"], &["version"], &["--demo", "--version"], &["--compact", "-V"], &["--demo", "version"]] {
|
||||
let o = cli(args);
|
||||
assert!(o.status.success(), "{args:?}: {}", String::from_utf8_lossy(&o.stderr));
|
||||
assert_eq!(String::from_utf8_lossy(&o.stdout), want, "{args:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_help_matches_help_command() {
|
||||
let reference = cli(&["help"]);
|
||||
|
||||
@@ -36,6 +36,8 @@ cpal = "0.16"
|
||||
arboard = "3"
|
||||
chrono = { version = "0.4", default-features = false, features = ["clock"] }
|
||||
sys-locale = "0.3"
|
||||
# Help menu / About / Discord links in the system browser (MIT; as PhotoCraft).
|
||||
open = "5"
|
||||
|
||||
[dev-dependencies]
|
||||
glob = "0.3"
|
||||
|
||||
@@ -237,6 +237,10 @@ fn main() -> eframe::Result {
|
||||
app.hooks.pick_folder_at =
|
||||
Some(Box::new(|dir: &str| rfd::FileDialog::new().set_directory(dir).pick_folder().map(|p| p.to_string_lossy().into_owned())));
|
||||
app.hooks.open_path = Some(Box::new(open_path));
|
||||
// Help menu, About and Discord links (#642): eframe is built without its `links`
|
||||
// feature, so `ctx.open_url` does nothing on the desktop. Detached, so a launcher
|
||||
// that waits for the browser never freezes the UI.
|
||||
app.hooks.open_url = Some(Box::new(|url: &str| open::that_detached(url).map_err(|e| e.to_string())));
|
||||
app.hooks.clipboard_media = Some(Box::new(clipboard_media));
|
||||
// Settings ▸ General ▸ Interface Language ▸ System Language (#218).
|
||||
app.hooks.cursor_screen_position = Some(Box::new(filmcraft_platform::cursor::cursor_screen_position));
|
||||
|
||||
@@ -64,3 +64,8 @@ sync_github_name = false
|
||||
|
||||
[people.ossini]
|
||||
sync_github_name = true
|
||||
|
||||
[people.vangel76]
|
||||
real_name = "Valentin Klein"
|
||||
display_name = "Valentin Klein"
|
||||
sync_github_name = false
|
||||
|
||||
@@ -263,8 +263,6 @@ pub struct Limiter {
|
||||
true_peak: bool,
|
||||
/// Look-ahead (samples) and detector alignment delay.
|
||||
look: usize,
|
||||
/// Delay-line capacity (samples of look-ahead) allocated at construction for 0…30 ms.
|
||||
max_look: usize,
|
||||
det_delay: usize,
|
||||
bank: PolyphaseBank,
|
||||
interp: Vec<Interpolator>,
|
||||
@@ -287,21 +285,17 @@ impl Limiter {
|
||||
ParamSpec::new("input_gain", "Input Boost", 0.0, 24.0, 0.0, Unit::Decibels),
|
||||
ParamSpec::new("ceiling", "Ceiling", -24.0, 0.0, -1.0, Unit::Decibels),
|
||||
ParamSpec::log("release", "Release", 1.0, 1000.0, 60.0, Unit::Milliseconds),
|
||||
ParamSpec::new("lookahead", "Look-Ahead", 0.0, 30.0, 5.0, Unit::Milliseconds),
|
||||
ParamSpec::toggle("true_peak", "True Peak", true),
|
||||
];
|
||||
/// Default look-ahead when the parameter is left at its DSP default.
|
||||
/// Fixed look-ahead time.
|
||||
pub const LOOKAHEAD_MS: f32 = 5.0;
|
||||
/// Longest look-ahead the delay line is sized for (Hard Limiter's 0…30 ms range).
|
||||
pub const MAX_LOOKAHEAD_MS: f32 = 30.0;
|
||||
|
||||
pub fn new(sample_rate: f32, channels: usize) -> Self {
|
||||
let channels = channels.max(1);
|
||||
let bank = PolyphaseBank::new(true_peak_factor(sample_rate as f64).max(2));
|
||||
let max_look = ((Self::MAX_LOOKAHEAD_MS * 0.001 * sample_rate).round() as usize).max(1);
|
||||
let look = ((Self::LOOKAHEAD_MS * 0.001 * sample_rate).round() as usize).max(1).min(max_look);
|
||||
let look = ((Self::LOOKAHEAD_MS * 0.001 * sample_rate).round() as usize).max(1);
|
||||
let det_delay = bank.delay();
|
||||
let dlen = max_look + det_delay + 1;
|
||||
let dlen = look + det_delay + 1;
|
||||
let mut s = Limiter {
|
||||
pv: ParamValues::new(Self::PARAMS),
|
||||
sr: sample_rate,
|
||||
@@ -310,7 +304,6 @@ impl Limiter {
|
||||
rel: 0.0,
|
||||
true_peak: true,
|
||||
look,
|
||||
max_look,
|
||||
det_delay,
|
||||
bank,
|
||||
interp: vec![Interpolator::default(); channels],
|
||||
@@ -333,15 +326,6 @@ impl Limiter {
|
||||
self.ceiling = db_to_gain(self.pv.v("ceiling"));
|
||||
self.rel = coef(self.pv.v("release"), self.sr);
|
||||
self.true_peak = self.pv.on("true_peak");
|
||||
let look = ((self.pv.v("lookahead") * 0.001 * self.sr).round() as usize).max(1).min(self.max_look);
|
||||
if look != self.look {
|
||||
self.look = look;
|
||||
self.minq.clear();
|
||||
self.box_ring = vec![1.0; look + 1];
|
||||
self.box_pos = 0;
|
||||
self.box_sum = (look + 1) as f64;
|
||||
self.rel_state = 1.0;
|
||||
}
|
||||
if snap {
|
||||
self.in_gain.snap();
|
||||
}
|
||||
@@ -379,7 +363,7 @@ impl AudioEffect for Limiter {
|
||||
for i in 0..n {
|
||||
let g_in = self.in_gain.tick();
|
||||
let det_pos = (self.wpos + dlen - self.det_delay) % dlen;
|
||||
let out_pos = (self.wpos + dlen - (self.look + self.det_delay)) % dlen;
|
||||
let out_pos = (self.wpos + 1) % dlen; // oldest = look + det_delay samples ago
|
||||
let mut det = 0.0f32;
|
||||
for ch in 0..nch {
|
||||
let x = channels[ch][i] * g_in;
|
||||
@@ -571,24 +555,4 @@ mod tests {
|
||||
let peak = tail.iter().fold(0.0f32, |a, v| a.max(v.abs()));
|
||||
assert!((lin_to_db(peak) + 6.0).abs() < 0.3, "{}", lin_to_db(peak));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn limiter_lookahead_changes_latency_and_transient_shape() {
|
||||
let mut a = Limiter::new(SR, 1);
|
||||
let mut b = Limiter::new(SR, 1);
|
||||
a.set_param("lookahead", 0.0);
|
||||
b.set_param("lookahead", 30.0);
|
||||
a.set_param("ceiling", -6.0);
|
||||
b.set_param("ceiling", -6.0);
|
||||
assert!(b.latency() > a.latency(), "{} vs {}", b.latency(), a.latency());
|
||||
let mut xa: Vec<f32> = (0..4800).map(|i| 0.08 * (2.0 * std::f32::consts::PI * 997.0 * i as f32 / SR).sin()).collect();
|
||||
for s in &mut xa[1200..1248] {
|
||||
*s = 0.9;
|
||||
}
|
||||
let mut xb = xa.clone();
|
||||
a.process(&mut [&mut xa]);
|
||||
b.process(&mut [&mut xb]);
|
||||
let max = xa.iter().zip(&xb).map(|(x, y)| (x - y).abs()).fold(0.0f32, f32::max);
|
||||
assert!(max > 1e-4, "look-ahead of 0 ms and 30 ms produced the same waveform (max Δ {max})");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -287,8 +287,12 @@ fn parse_time(s: &str, tm: Timing) -> Option<Tick> {
|
||||
"f" => {
|
||||
let n: i128 = vi.parse().ok()?;
|
||||
let fd = tm.rate.tick_of(1).0 as i128;
|
||||
let whole = tm.rate.tick_of(n as i64).0 as i128;
|
||||
whole + decimal_ticks("0", vf, fd)?
|
||||
let whole = tm.rate.tick_of(i64::try_from(n).ok()?);
|
||||
// tick_of saturates; a saturated count overflowed rather than named a real time
|
||||
if whole == Tick(i64::MAX) {
|
||||
return None;
|
||||
}
|
||||
whole.0 as i128 + decimal_ticks("0", vf, fd)?
|
||||
}
|
||||
"t" => {
|
||||
let n: i128 = vi.parse().ok()?;
|
||||
|
||||
@@ -31,7 +31,6 @@ filmcraft-av1 = { workspace = true }
|
||||
filmcraft-apv = { workspace = true }
|
||||
filmcraft-aac = { workspace = true }
|
||||
filmcraft-opus = { workspace = true }
|
||||
rayon = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
log = { workspace = true }
|
||||
# Decode timing counters (`gop_stats`); std's Instant panics on wasm32.
|
||||
|
||||
+9
-152
@@ -109,9 +109,8 @@ impl std::ops::Sub for GopStats {
|
||||
}
|
||||
}
|
||||
|
||||
/// A container's video sample table, in decode (file) order. `Sync`: a cache decodes on its own
|
||||
/// threads ([`isolated`]).
|
||||
pub trait VideoSamples: Sync {
|
||||
/// A container's video sample table, in decode (file) order.
|
||||
pub trait VideoSamples {
|
||||
fn count(&self) -> usize;
|
||||
/// Presentation timestamp of sample `i` (track units).
|
||||
fn pts(&self, i: usize) -> i64;
|
||||
@@ -426,57 +425,6 @@ const MIN_FRAMES: usize = 64;
|
||||
/// rather than restarting at the sync sample.
|
||||
const CONTINUE_THROUGH: usize = 48;
|
||||
|
||||
/// Threads that run the caches' decoding (see [`isolated`]); `None` where none can be made.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn decode_pool() -> Option<&'static rayon::ThreadPool> {
|
||||
static POOL: std::sync::OnceLock<Option<rayon::ThreadPool>> = std::sync::OnceLock::new();
|
||||
POOL.get_or_init(|| {
|
||||
let n = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(1);
|
||||
rayon::ThreadPoolBuilder::new().num_threads(n).thread_name(|i| format!("decode-{i}")).build().ok()
|
||||
})
|
||||
.as_ref()
|
||||
}
|
||||
|
||||
/// Run a cache's decoding, its lock held by the caller, on [`decode_pool`], the caller waiting
|
||||
/// without doing other rayon work meanwhile (#480).
|
||||
///
|
||||
/// That decoding starts rayon work (a decoder's slices, cropping and rotating the frames). On the
|
||||
/// pool of the render jobs that ask the cache for frames, a worker that took part of it could,
|
||||
/// waiting inside it, pick up another of those jobs and block on the lock: the lock holder then
|
||||
/// waits for its work on the blocked worker's stack, and that worker for the lock, for good (an
|
||||
/// export of rotated iPhone clips stopped making frames). On the decode pool that work only ever
|
||||
/// runs beside other caches' decoding, which never waits for a cache's lock. A caller that is a
|
||||
/// rayon worker would also run other jobs of its pool while it waits for the decode pool, some
|
||||
/// of them asking for a cache's lock while it holds this one: it waits on a plain thread instead.
|
||||
fn isolated<T: Send>(f: impl FnOnce() -> crate::Result<T> + Send) -> crate::Result<T> {
|
||||
let ctx = filmcraft_media::cancel::Context::current();
|
||||
let job = move || ctx.run(f);
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if let Some(pool) = decode_pool() {
|
||||
if rayon::current_thread_index().is_none() {
|
||||
return pool.install(job);
|
||||
}
|
||||
let slot = Mutex::new(Some(job));
|
||||
let waited = std::thread::scope(|scope| {
|
||||
let spawned = std::thread::Builder::new().name("decode-wait".into()).spawn_scoped(scope, || {
|
||||
let job = slot.lock().unwrap_or_else(PoisonError::into_inner).take();
|
||||
job.map(|job| pool.install(job))
|
||||
});
|
||||
spawned.map(|h| h.join())
|
||||
});
|
||||
return match waited {
|
||||
Ok(Ok(Some(r))) => r,
|
||||
Ok(Err(_)) => Err(CodecError::Decode("internal error while decoding".into())),
|
||||
// no thread to wait on: decode here, as before
|
||||
Ok(Ok(None)) | Err(_) => match slot.into_inner().unwrap_or_else(PoisonError::into_inner) {
|
||||
Some(job) => job(),
|
||||
None => Err(CodecError::Decode("internal error while decoding".into())),
|
||||
},
|
||||
};
|
||||
}
|
||||
job()
|
||||
}
|
||||
|
||||
impl GopCache {
|
||||
pub fn new(explicit_color: Option<ColorInfo>) -> Self {
|
||||
// unit tests count decoder restarts: each of their caches gets a pool of its own
|
||||
@@ -534,11 +482,7 @@ impl GopCache {
|
||||
}
|
||||
|
||||
fn store(&self, st: &mut State, pts: i64, f: VideoFrame, draft: bool) {
|
||||
self.store_finished(st, pts, self.finish(f), draft);
|
||||
}
|
||||
|
||||
/// Cache a frame [`Self::finish`] has been applied to.
|
||||
fn store_finished(&self, st: &mut State, pts: i64, f: VideoFrame, draft: bool) {
|
||||
let f = self.finish(f);
|
||||
st.last_used = Instant::now();
|
||||
if draft {
|
||||
st.drafts.insert(pts);
|
||||
@@ -660,22 +604,6 @@ impl GopCache {
|
||||
if st.intra {
|
||||
return self.intra_frame(st, s, i, want_pts);
|
||||
}
|
||||
// with the lock held: see `isolated`
|
||||
let st = &mut *st;
|
||||
isolated(|| self.decode_until(st, s, i, want_pts, late_before, draft))
|
||||
}
|
||||
|
||||
/// Decode (the lock held) until the frame with `want_pts` (sample `i`) is cached; return it.
|
||||
fn decode_until(
|
||||
&self,
|
||||
st: &mut State,
|
||||
s: &dyn VideoSamples,
|
||||
i: usize,
|
||||
want_pts: i64,
|
||||
late_before: Option<i64>,
|
||||
draft: bool,
|
||||
) -> crate::Result<Arc<VideoFrame>> {
|
||||
let n = s.count();
|
||||
let mut key = s.sync_before(i);
|
||||
// Continue the running decoder when it has passed the wanted sample's sync sample and
|
||||
// either has not reached the sample yet or has been fed it without outputting it yet
|
||||
@@ -728,14 +656,14 @@ impl GopCache {
|
||||
let out = timed(|| decoder.decode(&data, s.pts(k)))?;
|
||||
st.next += 1;
|
||||
DECODED.fetch_add(1, Ordering::Relaxed);
|
||||
self.store_output(st, out);
|
||||
self.store_output(&mut st, out);
|
||||
if st.cached(want_pts, draft).is_some() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if st.cached(want_pts, draft).is_none() {
|
||||
let out = st.decoder.as_mut().map(|d| timed(|| d.flush())).unwrap_or_default();
|
||||
self.store_output(st, out);
|
||||
self.store_output(&mut st, out);
|
||||
st.next = usize::MAX;
|
||||
}
|
||||
// nearest decoded frame at or before the wanted pts (robust to decoder pts quirks)
|
||||
@@ -820,17 +748,14 @@ impl GopCache {
|
||||
DECODED.fetch_add(1, Ordering::Relaxed);
|
||||
timed(|| dec.decode(&data, want_pts))
|
||||
});
|
||||
// crop and rotate before taking the lock: that work runs on rayon (see `isolated`)
|
||||
let res = res.map(|out| {
|
||||
count_frames(&out);
|
||||
out.into_iter().map(|d| (d.pts, self.finish(d.frame), d.draft)).collect::<Vec<_>>()
|
||||
});
|
||||
let mut st = self.shared.state.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if st.spare.len() < 16 {
|
||||
st.spare.push(dec);
|
||||
}
|
||||
for (pts, frame, draft) in res? {
|
||||
self.store_finished(&mut st, pts, frame, draft);
|
||||
let res = res?;
|
||||
count_frames(&res);
|
||||
for d in res {
|
||||
self.store(&mut st, d.pts, d.frame, d.draft);
|
||||
}
|
||||
st.at_or_before(want_pts, true).ok_or_else(|| CodecError::Decode("frame not produced".into()))
|
||||
}
|
||||
@@ -962,74 +887,6 @@ mod tests {
|
||||
assert!(s.decodes.load(Ordering::Relaxed) <= 60 + 24 + 1);
|
||||
}
|
||||
|
||||
/// A decoder that, like a slice-threaded one, splits each picture's work over rayon.
|
||||
struct ParDec(Box<dyn VideoDecoder>);
|
||||
|
||||
impl VideoDecoder for ParDec {
|
||||
fn decode(&mut self, sample: &[u8], pts: i64) -> crate::Result<Vec<DecodedFrame>> {
|
||||
use rayon::prelude::*;
|
||||
let rows: u64 =
|
||||
(0..64u32).into_par_iter().with_max_len(1).map(|r| (0..20_000u64).fold(u64::from(r), |a, x| a.wrapping_mul(31).wrapping_add(x))).sum();
|
||||
std::hint::black_box(rows);
|
||||
self.0.decode(sample, pts)
|
||||
}
|
||||
fn flush(&mut self) -> Vec<DecodedFrame> {
|
||||
self.0.flush()
|
||||
}
|
||||
fn reset(&mut self) {
|
||||
self.0.reset()
|
||||
}
|
||||
fn name(&self) -> &str {
|
||||
"par"
|
||||
}
|
||||
}
|
||||
|
||||
struct ParSamples(Samples);
|
||||
|
||||
impl VideoSamples for ParSamples {
|
||||
fn count(&self) -> usize {
|
||||
self.0.count()
|
||||
}
|
||||
fn pts(&self, i: usize) -> i64 {
|
||||
self.0.pts(i)
|
||||
}
|
||||
fn sync_before(&self, i: usize) -> usize {
|
||||
self.0.sync_before(i)
|
||||
}
|
||||
fn sample_at(&self, t: i64) -> Option<usize> {
|
||||
self.0.sample_at(t)
|
||||
}
|
||||
fn read(&self, i: usize) -> crate::Result<Vec<u8>> {
|
||||
self.0.read(i)
|
||||
}
|
||||
fn make_decoder(&self) -> crate::Result<Box<dyn VideoDecoder>> {
|
||||
Ok(Box::new(ParDec(self.0.make_decoder()?)))
|
||||
}
|
||||
}
|
||||
|
||||
/// #480: render jobs on rayon asking one source for frames while its decoding (a decoder's
|
||||
/// slices, rotating the frames) runs on rayon too. A worker that took part of that work and,
|
||||
/// waiting inside it, picked up another frame request blocked on the cache's lock for good:
|
||||
/// an export of rotated iPhone clips stopped making frames.
|
||||
#[test]
|
||||
fn rayon_frame_requests_while_decoding_on_rayon_never_deadlock() {
|
||||
use rayon::prelude::*;
|
||||
let s = Arc::new(ParSamples(samples(240, 30, 4, false)));
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
std::thread::spawn(move || {
|
||||
for round in 0..40usize {
|
||||
// export-like: many frame workers, frames of several GOPs of a fresh source at once
|
||||
let c = GopCache::new(None).with_rotation(1);
|
||||
(0..96usize).into_par_iter().with_max_len(1).for_each(|k| {
|
||||
let i = (k * 37 + round * 53) % 240;
|
||||
assert_eq!(index_of(&c.frame(&*s, i as i64 * 1000).expect("frame")), i);
|
||||
});
|
||||
}
|
||||
let _ = tx.send(());
|
||||
});
|
||||
assert!(rx.recv_timeout(Duration::from_secs(120)).is_ok(), "frame requests deadlocked");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn running_decoder_continues_through_a_nearby_sync_sample() {
|
||||
let s = samples(200, 30, 0, false);
|
||||
|
||||
@@ -82,6 +82,13 @@ pub(crate) fn obus(data: &[u8]) -> Vec<(u8, &[u8])> {
|
||||
out
|
||||
}
|
||||
|
||||
/// What an AV1 sequence header fixes about the pictures: profile, maximum size and the colour
|
||||
/// configuration (bit depth, chroma format, colour description). `None` when it does not parse.
|
||||
fn av1_format(seq: &[u8]) -> Option<(u8, u32, u32, filmcraft_av1::ColorConfig)> {
|
||||
let h = filmcraft_av1::SequenceHeader::parse(seq).ok()?;
|
||||
Some((h.profile, h.max_frame_width, h.max_frame_height, h.color))
|
||||
}
|
||||
|
||||
/// VP9 colour as our decoder reports it: `color_space` and range from the bitstream, transfer and
|
||||
/// primaries from the container when it says. (Shared with `Vp9Decoder`.)
|
||||
pub(crate) fn vp9_color(width: u32, height: u32, color_space: u8, full_range: bool, transfer: Option<Transfer>, primaries: Option<Primaries>) -> ColorInfo {
|
||||
@@ -243,14 +250,58 @@ impl FrameStreamInfo {
|
||||
}
|
||||
|
||||
/// Whether `sample` declares parameters the hardware session was not set up for: an AV1
|
||||
/// sequence header that differs from `av1C`'s, a VP9 key frame of another size, bit depth or
|
||||
/// chroma format. The decoder then hands the stream to our software decoder.
|
||||
/// sequence header whose picture format (profile, size, colour configuration) differs from
|
||||
/// `av1C`'s, a VP9 key frame of another size, bit depth or chroma format. The decoder then
|
||||
/// hands the stream to our software decoder.
|
||||
pub fn parameters_changed(&self, sample: &[u8]) -> bool {
|
||||
match self.codec {
|
||||
FrameCodec::Av1 => obus(sample).into_iter().any(|(t, p)| t == 1 && self.sequence_header.as_deref() != Some(p)),
|
||||
// Only what the picture format depends on counts: encoders such as SVT-AV1 put a
|
||||
// provisional sequence header in `av1C` whose coding tool flags (CDEF, restoration,
|
||||
// warped motion…) differ from the in-band one, and decoders follow the in-band one.
|
||||
FrameCodec::Av1 => obus(sample).into_iter().any(|(t, p)| t == 1 && self.sequence_header.as_deref().is_none_or(|s| av1_format(s) != av1_format(p))),
|
||||
FrameCodec::Vp9 => {
|
||||
self.picture_params(sample).is_some_and(|p| p.size != self.size || p.bit_depth != self.bit_depth || p.subsampling != self.subsampling)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// An `av1C` sequence header from SVT-AV1 (1280x720 10-bit) and the in-band one of the same
|
||||
/// stream: they differ only in coding tool flags.
|
||||
const AV1C: [u8; 14] = [0x0a, 0x0c, 0x02, 0x00, 0x00, 0x2d, 0x6a, 0x67, 0xfd, 0x9e, 0x01, 0x7c, 0x20, 0x20];
|
||||
const IN_BAND: [u8; 14] = [0x0a, 0x0c, 0x02, 0x00, 0x00, 0x2d, 0x6a, 0x67, 0xfd, 0x9e, 0x35, 0x7c, 0xe0, 0x20];
|
||||
|
||||
fn info() -> FrameStreamInfo {
|
||||
let c = filmcraft_isobmff::Av1Config {
|
||||
seq_profile: 0,
|
||||
seq_level_idx_0: 0,
|
||||
seq_tier_0: false,
|
||||
high_bitdepth: true,
|
||||
twelve_bit: false,
|
||||
monochrome: false,
|
||||
chroma_subsampling_x: true,
|
||||
chroma_subsampling_y: true,
|
||||
chroma_sample_position: 0,
|
||||
initial_presentation_delay_minus_one: None,
|
||||
config_obus: AV1C.to_vec(),
|
||||
};
|
||||
FrameStreamInfo::from_av1(&c, (0, 0)).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn av1_sequence_headers_compare_by_picture_format() {
|
||||
let i = info();
|
||||
assert_eq!((i.size, i.bit_depth), ((1280, 720), 10));
|
||||
assert!(!i.parameters_changed(&AV1C), "the same header");
|
||||
// used to send every SVT-AV1 MP4 to the software decoder from its first frame
|
||||
assert!(!i.parameters_changed(&IN_BAND), "only coding tool flags differ");
|
||||
// a header that does not parse is a change
|
||||
assert!(i.parameters_changed(&[0x0a, 0x02, 0xe0, 0x00]));
|
||||
// no sequence header in the sample: nothing changed
|
||||
assert!(!i.parameters_changed(&[0x12, 0x00]));
|
||||
}
|
||||
}
|
||||
|
||||
+112
-17
@@ -104,10 +104,9 @@ fn audio_starts(file: &MkvFile, bytes: &crate::Src, ti: usize, rate: i64) -> Vec
|
||||
fn opus_starts(file: &MkvFile, bytes: &crate::Src, ti: usize, head: &filmcraft_opus::OpusHead, rate: i64) -> Vec<i64> {
|
||||
let Some(t) = file.tracks.get(ti) else { return Vec::new() };
|
||||
let (n, d) = tb(t);
|
||||
let scale_ns = (n as i128 * 1_000_000_000 / d as i128).max(1);
|
||||
let delay_ns = t.codec_delay_ns as i128;
|
||||
// The demuxer's rounding of CodecDelay to ticks (half away from zero), undone here.
|
||||
let delay_ticks = ((delay_ns + scale_ns / 2) / scale_ns) as i64;
|
||||
// The demuxer's rounding of CodecDelay to ticks, undone here.
|
||||
let delay_ticks = codec_delay_ticks(t);
|
||||
let skip = if t.codec_delay_ns > 0 { (delay_ns * rate as i128 / 1_000_000_000) as i64 } else { head.pre_skip as i64 };
|
||||
let at = |pts: i64| ((pts.saturating_add(delay_ticks) as i128 * n as i128 * rate as i128 / d as i128) as i64).saturating_sub(skip);
|
||||
let packets = t.samples.iter().enumerate().map(|(i, s)| crate::audio::PacketTime {
|
||||
@@ -118,6 +117,46 @@ fn opus_starts(file: &MkvFile, bytes: &crate::Src, ti: usize, head: &filmcraft_o
|
||||
crate::audio::contiguous_starts(packets, stamp_tolerance(t, rate))
|
||||
}
|
||||
|
||||
/// `CodecDelay` in timestamp ticks, rounded as the demuxer rounds it (half away from zero) before
|
||||
/// subtracting it from every timestamp of the track.
|
||||
fn codec_delay_ticks(t: &filmcraft_matroska::Track) -> i64 {
|
||||
let (n, d) = tb(t);
|
||||
let scale_ns = (n as i128 * 1_000_000_000 / d as i128).max(1);
|
||||
i64::try_from((t.codec_delay_ns as i128 + scale_ns / 2) / scale_ns).unwrap_or(i64::MAX)
|
||||
}
|
||||
|
||||
/// The track timestamp that media time zero stands for (#715): the earliest start among the
|
||||
/// playable tracks. Block timestamps count from the start of the Segment, and a remux that keeps
|
||||
/// its source's timestamps (`ffmpeg -copyts`, a cut from a recording) starts its first picture
|
||||
/// seconds in; taken as media time, that repeated the opening picture until then. An audio track
|
||||
/// starts where its first packet does before `CodecDelay` was taken off (the delay only hides
|
||||
/// priming), and never before zero.
|
||||
fn media_origin(file: &MkvFile, vtrack: Option<usize>, atracks: &[usize]) -> i64 {
|
||||
let start = |i: usize| {
|
||||
let t = file.tracks.get(i)?;
|
||||
let first = t.samples.iter().map(|s| s.pts).min()?;
|
||||
Some(if t.kind == TrackKind::Audio { first.saturating_add(codec_delay_ticks(t)).max(0) } else { first })
|
||||
};
|
||||
vtrack.into_iter().chain(atracks.iter().copied()).filter_map(start).min().unwrap_or(0).max(0)
|
||||
}
|
||||
|
||||
/// Move every track back by `origin` timestamp ticks, so the streams keep their relative timing
|
||||
/// (a delay between picture and sound survives).
|
||||
fn rebase(file: &mut MkvFile, origin: i64) {
|
||||
if origin <= 0 {
|
||||
return;
|
||||
}
|
||||
for s in file.tracks.iter_mut().flat_map(|t| t.samples.iter_mut()) {
|
||||
s.pts = s.pts.saturating_sub(origin);
|
||||
}
|
||||
}
|
||||
|
||||
/// End of the last sample of track `i`, in its timebase.
|
||||
fn track_end(file: &MkvFile, i: usize) -> Option<(i64, &filmcraft_matroska::Track)> {
|
||||
let t = file.tracks.get(i)?;
|
||||
Some((t.samples.iter().map(|s| s.pts.saturating_add(i64::try_from(s.duration).unwrap_or(i64::MAX))).max().unwrap_or(0), t))
|
||||
}
|
||||
|
||||
/// Two timestamp ticks in sample frames at `rate`, plus one: how far a packet's Matroska timestamp
|
||||
/// can be from its exact start.
|
||||
fn stamp_tolerance(t: &filmcraft_matroska::Track, rate: i64) -> i64 {
|
||||
@@ -146,7 +185,7 @@ fn tb(t: &filmcraft_matroska::Track) -> (i64, i64) {
|
||||
|
||||
fn to_tick(t: &filmcraft_matroska::Track, pts: i64) -> Tick {
|
||||
let (n, d) = tb(t);
|
||||
Tick::from_rational(pts * n, 1, d)
|
||||
Tick::from_rational(pts, n, d)
|
||||
}
|
||||
|
||||
/// A time as the track timestamp to look a frame up by: rounded to the nearest tick, as muxers
|
||||
@@ -275,7 +314,7 @@ impl MkvSource {
|
||||
/// Open from a random-access reader: only the index is read now, samples on demand.
|
||||
pub fn open_reader(name: &str, reader: filmcraft_media::SharedReader) -> crate::Result<Self> {
|
||||
let bytes = crate::Src(reader);
|
||||
let file = filmcraft_matroska::open(&bytes).map_err(|e| CodecError::Container(e.to_string()))?;
|
||||
let mut file = filmcraft_matroska::open(&bytes).map_err(|e| CodecError::Container(e.to_string()))?;
|
||||
let vtrack = file.tracks.iter().position(|t| t.kind == TrackKind::Video && !t.samples.is_empty());
|
||||
// every audio track is a stream (the track count comes from the file: capped)
|
||||
let atracks: Vec<usize> = file
|
||||
@@ -289,6 +328,24 @@ impl MkvSource {
|
||||
if vtrack.is_none() && atracks.is_empty() {
|
||||
return Err(CodecError::Unsupported("no playable tracks".into()));
|
||||
}
|
||||
let main = vtrack.or(atracks.first().copied());
|
||||
// The Segment's end (`Info/Duration` or the last sample), in Segment time.
|
||||
let segment_end = match file.duration_ns() {
|
||||
Some(ns) => Tick::from_rational(i64::try_from(ns).unwrap_or(i64::MAX), 1, 1_000_000_000),
|
||||
None => main.and_then(|i| track_end(&file, i)).map(|(end, t)| to_tick(t, end)).unwrap_or_default(),
|
||||
};
|
||||
// Media time zero is the first picture (or sound): the streams start at their common origin.
|
||||
let origin = media_origin(&file, vtrack, &atracks);
|
||||
rebase(&mut file, origin);
|
||||
// `Info/Duration` is the Segment's end: the media lasts from the origin to there (never less
|
||||
// than its main track's samples).
|
||||
let duration = match main.and_then(|i| track_end(&file, i)) {
|
||||
Some((end, t)) if origin > 0 => {
|
||||
let (n, d) = tb(t);
|
||||
(segment_end - Tick::from_rational(origin, n, d)).max(to_tick(t, end))
|
||||
}
|
||||
_ => segment_end,
|
||||
};
|
||||
let mut explicit_color = None;
|
||||
let mut ventry = None;
|
||||
// display rotation from the track's Projection (portrait phone video is stored landscape)
|
||||
@@ -312,7 +369,7 @@ impl MkvSource {
|
||||
if explicit {
|
||||
explicit_color = Some(color);
|
||||
}
|
||||
let secs = file.duration_ns().unwrap_or(0) as f64 / 1e9;
|
||||
let secs = duration.seconds();
|
||||
let bitrate = (secs > 0.0).then(|| (t.samples.iter().map(|s| s.size as u64).sum::<u64>() as f64 * 8.0 / secs) as u64);
|
||||
let ((w, h), par) = if rotation % 2 == 1 { ((h, w), (v.pixel_aspect().1, v.pixel_aspect().0)) } else { ((w, h), v.pixel_aspect()) };
|
||||
VideoStreamInfo {
|
||||
@@ -342,17 +399,6 @@ impl MkvSource {
|
||||
AudioStreamInfo { sample_rate: rate, channels, codec: codec_label(&t.codec), bits_per_sample: a.bit_depth.map(|b| b as u32) }
|
||||
})
|
||||
.collect();
|
||||
let duration = match file.duration_ns() {
|
||||
Some(ns) => Tick::from_rational(ns as i64, 1, 1_000_000_000),
|
||||
None => vtrack
|
||||
.or(atracks.first().copied())
|
||||
.map(|i| {
|
||||
let t = &file.tracks[i];
|
||||
let end = t.samples.iter().map(|s| s.pts + s.duration as i64).max().unwrap_or(0);
|
||||
to_tick(t, end)
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
};
|
||||
let info = MediaInfo {
|
||||
name: name.to_string(),
|
||||
kind: if video.is_some() { MediaKind::Movie } else { MediaKind::AudioOnly },
|
||||
@@ -614,3 +660,52 @@ mod multi_audio_tests {
|
||||
assert!(source.audio_stream(2, 0, 50, 48_000).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod origin_tests {
|
||||
use filmcraft_frame::PixelData;
|
||||
use filmcraft_matroska::{MkvWriter, MuxOptions, TrackKind, TrackSpec};
|
||||
use filmcraft_media::{FrameRequest, MediaSource};
|
||||
use filmcraft_time::Tick;
|
||||
|
||||
/// A flat grey Motion-JPEG picture: its level says which frame it is.
|
||||
fn picture(k: u8) -> Vec<u8> {
|
||||
let img = image::GrayImage::from_pixel(16, 16, image::Luma([20 + 20 * k]));
|
||||
let mut out = Vec::new();
|
||||
image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, 95).encode_image(&img).unwrap();
|
||||
out
|
||||
}
|
||||
|
||||
/// #715: a remux that keeps its source's timestamps (first picture at 1.48 s, sound 0.2 s after
|
||||
/// it) starts at its first picture, lasts as long as its pictures, and keeps the sound's delay.
|
||||
/// It repeated picture 0 until 1.48 s, and the clip ran to the Segment's absolute end.
|
||||
#[test]
|
||||
fn timestamp_preserving_remux_starts_at_the_first_picture() {
|
||||
let mut v = TrackSpec::new(TrackKind::Video, "V_MJPEG");
|
||||
v.video_size = Some((16, 16));
|
||||
v.default_duration_ns = Some(40_000_000);
|
||||
let mut a = TrackSpec::new(TrackKind::Audio, "A_PCM/INT/LIT");
|
||||
a.audio = Some((48_000.0, 1, Some(16)));
|
||||
let mut w = MkvWriter::new(std::io::Cursor::new(Vec::new()), vec![v, a], MuxOptions::default()).unwrap();
|
||||
for k in 0..10u8 {
|
||||
let pts = 1_480_000_000 + i64::from(k) * 40_000_000;
|
||||
w.write_frame(0, pts, true, &picture(k), None).unwrap();
|
||||
if k == 5 {
|
||||
let pcm: Vec<u8> = (0..960).flat_map(|_| 16_384i16.to_le_bytes()).collect();
|
||||
w.write_frame(1, 1_680_000_000, true, &pcm, Some(20_000_000)).unwrap();
|
||||
}
|
||||
}
|
||||
let src = super::MkvSource::open("shifted.mkv", w.finish().unwrap().into_inner().into()).unwrap();
|
||||
assert_eq!(src.info().duration, Tick::from_rational(10, 1, 25), "ten pictures at 25 fps");
|
||||
let level = |k: i64| {
|
||||
let f = src.video_frame(FrameRequest::full(Tick::from_rational(k, 1, 25))).unwrap();
|
||||
let PixelData::Rgba8(px) = &f.data else { panic!("MJPEG decodes to RGBA8") };
|
||||
px[0]
|
||||
};
|
||||
let seen: Vec<u8> = (0..10).map(level).collect();
|
||||
assert!(seen.windows(2).all(|p| p[0] < p[1]), "one picture per frame, in order: {seen:?}");
|
||||
let sound = src.audio(0, 48_000 * 2 / 5, 48_000).unwrap();
|
||||
assert!(sound.channels[0][9_590].abs() < 0.001, "silent before the delay");
|
||||
assert!((sound.channels[0][9_610] - 0.5).abs() < 0.001, "sound 0.2 s after the first picture");
|
||||
}
|
||||
}
|
||||
|
||||
+77
-34
@@ -491,17 +491,45 @@ pub fn move_items(seq: &mut Sequence, moves: &[(ClipId, TrackId, Tick)], insert_
|
||||
it.start = (*start).max(Tick::ZERO);
|
||||
placed.push((*dest, it));
|
||||
}
|
||||
// carry transitions with moved clips? Premiere drops transitions whose partner is not moved.
|
||||
let carried = carried_transitions(&work, &placed);
|
||||
// a transition whose partner stays behind is dropped, as in Premiere
|
||||
delete_items(&mut work, &moves.iter().map(|m| m.0).collect::<Vec<_>>());
|
||||
if insert_mode {
|
||||
insert(&mut work, placed, ctx)?;
|
||||
} else {
|
||||
overwrite(&mut work, placed, ctx)?;
|
||||
}
|
||||
for (dest, tr) in carried {
|
||||
let t = track_mut(&mut work, dest)?;
|
||||
if [tr.from, tr.to].into_iter().flatten().all(|c| t.item(c).is_some()) {
|
||||
t.transitions.push(tr);
|
||||
t.sort();
|
||||
}
|
||||
}
|
||||
*seq = work;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The transitions that travel with a move (#227, #374): those whose clips all move, onto one
|
||||
/// track and by one offset (a fade at a moved clip's edge, or the cut between two clips that move
|
||||
/// together), shifted to where those clips land.
|
||||
fn carried_transitions(seq: &Sequence, placed: &[(TrackId, TrackItem)]) -> Vec<(TrackId, Transition)> {
|
||||
let landing = |c: ClipId| {
|
||||
let (dest, it) = placed.iter().find(|(_, it)| it.id == c)?;
|
||||
let (_, old) = seq.find_item(c)?;
|
||||
Some((*dest, it.start - old.start))
|
||||
};
|
||||
let mut carried = Vec::new();
|
||||
for tr in seq.all_tracks().filter(|t| !t.locked).flat_map(|t| t.transitions.iter()) {
|
||||
let lands: Option<Vec<(TrackId, Tick)>> = [tr.from, tr.to].into_iter().flatten().map(landing).collect();
|
||||
let Some((&(dest, delta), rest)) = lands.as_deref().and_then(<[_]>::split_first) else { continue };
|
||||
if rest.iter().all(|l| *l == (dest, delta)) {
|
||||
carried.push((dest, Transition { start: tr.start + delta, ..tr.clone() }));
|
||||
}
|
||||
}
|
||||
carried
|
||||
}
|
||||
|
||||
fn media_len(ctx: &EditCtx, item: &TrackItem) -> Option<Tick> {
|
||||
if item.frame_hold.is_some() {
|
||||
return None;
|
||||
@@ -573,6 +601,31 @@ pub fn clamp_trim(seq: &Sequence, clip: ClipId, edge: Edge, mode: TrimMode, delt
|
||||
Ok(d)
|
||||
}
|
||||
|
||||
/// What moving one edge by `d` (already clamped) does to the item itself: its length, the media
|
||||
/// it starts at (a reversed clip consumes media from its other end; a frame hold has none to
|
||||
/// move), and, for the In edge with `move_start`, where it starts. The trims and the timeline's
|
||||
/// live trim preview share it, so the preview shows the media the edit will keep (#374).
|
||||
pub fn move_edge(it: &mut TrackItem, edge: Edge, d: Tick, move_start: bool) {
|
||||
let speed = it.speed.abs();
|
||||
match edge {
|
||||
Edge::In => {
|
||||
if !it.reverse && it.frame_hold.is_none() {
|
||||
it.source_in += src_of(d, speed);
|
||||
}
|
||||
it.duration -= d;
|
||||
if move_start {
|
||||
it.start += d;
|
||||
}
|
||||
}
|
||||
Edge::Out => {
|
||||
if it.reverse {
|
||||
it.source_in -= src_of(d, speed);
|
||||
}
|
||||
it.duration += d;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Trim one edge of an item (Selection-tool edge drag = Regular; Ripple tool = Ripple).
|
||||
/// Linked partners should be trimmed by the caller with the same delta.
|
||||
pub fn trim(seq: &mut Sequence, clip: ClipId, edge: Edge, mode: TrimMode, delta: Tick, ctx: &mut EditCtx) -> Result<Tick> {
|
||||
@@ -584,27 +637,10 @@ pub fn trim(seq: &mut Sequence, clip: ClipId, edge: Edge, mode: TrimMode, delta:
|
||||
let old_end = it.end();
|
||||
let old_start = it.start;
|
||||
let own_link = it.link;
|
||||
let speed = it.speed.abs();
|
||||
let mut work = seq.clone();
|
||||
{
|
||||
let (_, it) = work.find_item_mut(clip).ok_or(EditError::NoItem(clip))?;
|
||||
match edge {
|
||||
Edge::In => {
|
||||
if !it.reverse && it.frame_hold.is_none() {
|
||||
it.source_in += src_of(d, speed);
|
||||
}
|
||||
it.duration -= d;
|
||||
if mode == TrimMode::Regular {
|
||||
it.start += d;
|
||||
}
|
||||
}
|
||||
Edge::Out => {
|
||||
if it.reverse {
|
||||
it.source_in -= src_of(d, speed);
|
||||
}
|
||||
it.duration += d;
|
||||
}
|
||||
}
|
||||
move_edge(it, edge, d, mode == TrimMode::Regular);
|
||||
}
|
||||
if mode == TrimMode::Ripple {
|
||||
let (at, shift) = match edge {
|
||||
@@ -662,25 +698,11 @@ pub fn ripple_trim_group(seq: &mut Sequence, clips: &[ClipId], edge: Edge, delta
|
||||
let mut origins: Vec<(TrackId, Tick)> = Vec::new();
|
||||
for c in clips {
|
||||
let (tid, it) = work.find_item_mut(*c).ok_or(EditError::NoItem(*c))?;
|
||||
let speed = it.speed.abs();
|
||||
let from = match edge {
|
||||
Edge::In => it.start + Tick(1),
|
||||
Edge::Out => it.end(),
|
||||
};
|
||||
match edge {
|
||||
Edge::In => {
|
||||
if !it.reverse && it.frame_hold.is_none() {
|
||||
it.source_in += src_of(d, speed);
|
||||
}
|
||||
it.duration -= d;
|
||||
}
|
||||
Edge::Out => {
|
||||
if it.reverse {
|
||||
it.source_in -= src_of(d, speed);
|
||||
}
|
||||
it.duration += d;
|
||||
}
|
||||
}
|
||||
move_edge(it, edge, d, false);
|
||||
if !origins.iter().any(|(t, _)| *t == tid) {
|
||||
origins.push((tid, from));
|
||||
}
|
||||
@@ -880,6 +902,27 @@ pub fn slide(seq: &mut Sequence, clip: ClipId, delta: Tick, ctx: &mut EditCtx) -
|
||||
Ok(d)
|
||||
}
|
||||
|
||||
/// Slide several items (a linked picture/sound pair, a selection) by one common delta: the most
|
||||
/// restrictive item's neighbour limit clamps them all, so their relative timing is kept.
|
||||
/// Returns the delta applied.
|
||||
pub fn slide_items(seq: &mut Sequence, clips: &[ClipId], delta: Tick, ctx: &mut EditCtx) -> Result<Tick> {
|
||||
let mut d = delta;
|
||||
for c in clips {
|
||||
let x = slide(&mut seq.clone(), *c, d, ctx)?;
|
||||
if x.0.signum() != d.0.signum() {
|
||||
d = Tick::ZERO;
|
||||
} else if x.abs() < d.abs() {
|
||||
d = x;
|
||||
}
|
||||
}
|
||||
if d != Tick::ZERO {
|
||||
for c in clips {
|
||||
slide(seq, *c, d, ctx)?;
|
||||
}
|
||||
}
|
||||
Ok(d)
|
||||
}
|
||||
|
||||
/// Rate stretch: change an item's duration by dragging an edge, adjusting speed to keep the same media.
|
||||
pub fn rate_stretch(seq: &mut Sequence, clip: ClipId, edge: Edge, delta: Tick, ctx: &mut EditCtx) -> Result<f64> {
|
||||
let (tid, it) = seq.find_item(clip).ok_or(EditError::NoItem(clip))?;
|
||||
|
||||
@@ -464,6 +464,39 @@ fn transitions_stay_on_their_cuts() {
|
||||
fx.seq.check().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transitions_travel_with_moved_clips() {
|
||||
// V1: a [0,10) fading in, b [10,20), c [20,30); b→c crossfade, c fading out
|
||||
let mut fx = Fx::new();
|
||||
let (v1, v2) = (fx.v(0), fx.v(1));
|
||||
let a = fx.put(v1, 0, 10, 10);
|
||||
let b = fx.put(v1, 10, 10, 10);
|
||||
let c = fx.put(v1, 20, 10, 10);
|
||||
let tr = |id, start, from, to| Transition {
|
||||
id: TransitionId(id),
|
||||
effect: filmcraft_project::find_effect("cross_dissolve").unwrap().instance(),
|
||||
start: f(start),
|
||||
duration: f(4),
|
||||
from,
|
||||
to,
|
||||
align: Default::default(),
|
||||
reverse: false,
|
||||
};
|
||||
fx.seq.track_mut(v1).unwrap().transitions = vec![tr(1, 0, None, Some(a)), tr(2, 18, Some(b), Some(c)), tr(3, 26, Some(c), None)];
|
||||
let at = |fx: &Fx, track: TrackId| -> Vec<(u64, i64)> { fx.seq.track(track).unwrap().transitions.iter().map(|t| (t.id.0, R.frame_at(t.start))).collect() };
|
||||
let mut n = fx.next;
|
||||
// a fade at a clip's edge goes with it, to another track too
|
||||
move_items(&mut fx.seq, &[(a, v2, f(40))], false, &mut Fx::ctx(&mut n)).unwrap();
|
||||
assert_eq!((at(&fx, v1), at(&fx, v2)), (vec![(2, 18), (3, 26)], vec![(1, 40)]));
|
||||
// a cut whose two clips move together keeps its transition, in an insert move as well
|
||||
move_items(&mut fx.seq, &[(b, v1, f(60)), (c, v1, f(70))], true, &mut Fx::ctx(&mut n)).unwrap();
|
||||
assert_eq!(at(&fx, v1), vec![(2, 68), (3, 76)]);
|
||||
// a crossfade whose partner stays behind is dropped; the other clip's fade stays with it
|
||||
move_items(&mut fx.seq, &[(c, v1, f(90))], false, &mut Fx::ctx(&mut n)).unwrap();
|
||||
assert_eq!(at(&fx, v1), vec![(3, 96)]);
|
||||
fx.seq.check().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rate_stretch_and_speed() {
|
||||
let mut fx = Fx::new();
|
||||
@@ -749,3 +782,40 @@ fn roll_reversed_clips_keeps_media_mapping() {
|
||||
let (_, r) = fx.seq.find_item(ib).unwrap();
|
||||
assert_eq!((r.start, r.duration, r.source_in), (f(40), f(20), f(50)));
|
||||
}
|
||||
|
||||
/// #374: moving an edge must not change which media sits under any timeline time the clip still
|
||||
/// covers (the timeline's live trim preview draws its waveform from this item), and must leave the
|
||||
/// item as the trim itself does.
|
||||
#[test]
|
||||
fn moving_an_edge_keeps_the_media_under_the_timeline() {
|
||||
let fx = Fx::new();
|
||||
let v1 = fx.v(0);
|
||||
for (speed, reverse, hold) in [(1.0, false, None), (2.0, false, None), (0.5, false, None), (1.0, true, None), (2.0, true, None), (1.0, false, Some(f(50)))]
|
||||
{
|
||||
for (edge, d) in [(Edge::In, f(7)), (Edge::In, -f(4)), (Edge::Out, -f(9)), (Edge::Out, f(3))] {
|
||||
let mut fresh = Fx::new();
|
||||
let c = fresh.put(v1, 100, 40, 200);
|
||||
{
|
||||
let it = fresh.seq.find_item_mut(c).unwrap().1;
|
||||
(it.speed, it.reverse, it.frame_hold) = (speed, reverse, hold);
|
||||
}
|
||||
let before = fresh.seq.find_item(c).unwrap().1.clone();
|
||||
let mut moved = before.clone();
|
||||
move_edge(&mut moved, edge, d, true);
|
||||
let label = format!("speed {speed} reverse {reverse} hold {hold:?} {edge:?} {d:?}");
|
||||
// every frame still covered shows the same media as before the move
|
||||
let mut t = moved.start.max(before.start);
|
||||
while t < moved.end().min(before.end()) {
|
||||
let (a, b) = (before.source_time_at(t), moved.source_time_at(t));
|
||||
assert!((a - b).0.abs() <= 1, "{label}: at {t:?} {a:?} became {b:?}");
|
||||
t += f(1);
|
||||
}
|
||||
// and the item is what the real (unclamped) trim makes of it
|
||||
let mut n = fresh.next;
|
||||
let got = trim(&mut fresh.seq, c, edge, TrimMode::Regular, d, &mut Fx::ctx(&mut n)).unwrap();
|
||||
assert_eq!(got, d, "{label}: not clamped");
|
||||
let trimmed = fresh.seq.find_item(c).unwrap().1;
|
||||
assert_eq!((trimmed.start, trimmed.duration, trimmed.source_in), (moved.start, moved.duration, moved.source_in), "{label}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -924,6 +924,40 @@ fn linked_slip_keeps_picture_and_sound_aligned_at_unequal_media_limits() {
|
||||
assert_eq!((clip(&s, v).source_in, clip(&s, a).source_in), (Tick(fr.0 * 10), Tick(fr.0 * 10)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_keeps_keyframes_and_resets_one_parameter() {
|
||||
// #284: Reset Effect used to replace the effect with a fresh one, deleting every keyframe
|
||||
let mut s = demo();
|
||||
let a = v1(&s)[0].clone();
|
||||
let sec = Tick(TICKS_PER_SECOND);
|
||||
let (t0, t1, mid) = (a.start, a.start + sec, a.start + Tick(TICKS_PER_SECOND / 2));
|
||||
for (t, v) in [(t0, 150.0), (t1, 50.0)] {
|
||||
s.execute("effects.setParam", json!({"clip": a.id.0, "effect": "motion", "param": "scale", "value": v, "keyframe": true, "time": t.0})).unwrap();
|
||||
}
|
||||
s.execute("effects.setParam", json!({"clip": a.id.0, "effect": "motion", "param": "rotation", "value": 30.0})).unwrap();
|
||||
let idx = clip(&s, a.id.0).effects.iter().position(|e| e.effect == "motion").unwrap();
|
||||
s.execute("playhead.set", json!({"time": mid.0})).unwrap();
|
||||
s.execute("effects.reset", json!({"clip": a.id.0, "index": idx})).unwrap();
|
||||
let it = clip(&s, a.id.0);
|
||||
let scale = it.effect("motion").unwrap().param("scale").unwrap();
|
||||
// (the playhead snaps to a frame)
|
||||
let mid_media = it.source_time_at(s.playhead());
|
||||
let kf: Vec<(Tick, f64)> = scale.keyframes.iter().map(|k| (k.time, k.value.as_f64().unwrap())).collect();
|
||||
assert_eq!(kf, vec![(it.source_time_at(t0), 150.0), (mid_media, 100.0), (it.source_time_at(t1), 50.0)], "keyframes kept, default at the playhead");
|
||||
assert_eq!(f64_param(&it, "motion", "rotation"), 0.0);
|
||||
// one parameter: only it goes back to its default
|
||||
s.execute("effects.setParam", json!({"clip": a.id.0, "effect": "motion", "param": "rotation", "value": 45.0})).unwrap();
|
||||
s.execute("effects.setParam", json!({"clip": a.id.0, "effect": "opacity", "param": "opacity", "value": 40.0})).unwrap();
|
||||
s.execute("effects.resetParam", json!({"clip": a.id.0, "effect": "motion", "param": "rotation"})).unwrap();
|
||||
let it = clip(&s, a.id.0);
|
||||
assert_eq!(f64_param(&it, "motion", "rotation"), 0.0);
|
||||
assert_eq!(f64_param(&it, "opacity", "opacity"), 40.0);
|
||||
assert_eq!(it.effect("motion").unwrap().param("scale").unwrap().keyframes.len(), 3);
|
||||
assert!(s.execute("effects.resetParam", json!({"clip": a.id.0, "effect": "motion", "param": "nope"})).is_err());
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(f64_param(&clip(&s, a.id.0), "motion", "rotation"), 45.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keyboard_slip_keeps_linked_clips_in_sync_past_the_shorter_handle() {
|
||||
let mut s = Session::default();
|
||||
|
||||
+112
-35
@@ -144,6 +144,29 @@ pub(crate) fn named_clips(s: &Session, spec: &CommandSpec, p: &Value) -> Option<
|
||||
let clips: Vec<ClipId> = named_ids(spec, p, "clips", "clip")?.into_iter().map(ClipId).filter(|c| seq.find_item(*c).is_some()).collect();
|
||||
(!clips.is_empty()).then_some(clips)
|
||||
}
|
||||
/// Why the clips a caller named in `clips` / `clip` stand in for no selection (#591): a clip of
|
||||
/// another sequence, an id of no clip (a track, a project item…) or a value that is not an id.
|
||||
/// `None` when the caller named nothing or no sequence is open, so the selection's reason stands.
|
||||
pub(crate) fn named_clips_missing(s: &Session, spec: &CommandSpec, p: &Value) -> Option<String> {
|
||||
s.active_sequence()?;
|
||||
let first = if spec.params.contains("\"clips\":[id]")
|
||||
&& let Some(a) = p.get("clips").and_then(Value::as_array)
|
||||
{
|
||||
a.first()?
|
||||
} else if spec.params.contains("\"clip\":id") {
|
||||
p.get("clip").filter(|v| !v.is_null())?
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
let Some(id) = first.as_u64() else {
|
||||
return Some(format!("{first} is not a clip id (clip ids are the `clip` numbers of the items in sequence.inspect)"));
|
||||
};
|
||||
let elsewhere = s.project.sequences().find(|i| i.as_sequence().is_some_and(|q| q.find_item(ClipId(id)).is_some()));
|
||||
Some(match elsewhere {
|
||||
Some(i) => format!("clip {id} is in sequence \"{}\", not the active one: open it first with sequence.open {{\"item\":{}}}", i.name, i.id.0),
|
||||
None => format!("no clip {id} in the active sequence (clip ids are the `clip` numbers of the items in sequence.inspect)"),
|
||||
})
|
||||
}
|
||||
/// Project items named explicitly in `items` / `item`, likewise. The Project panel's selection
|
||||
/// holds bins beside items (one id space, see `project.select`), so a bin named here counts too;
|
||||
/// the project's own top bin does not, since no command acts on it (#244).
|
||||
@@ -388,6 +411,31 @@ pub fn with_links(s: &Session, clips: &[ClipId]) -> Vec<ClipId> {
|
||||
out
|
||||
}
|
||||
|
||||
/// The delta a trim of `clips` (a clip and its linked partners) can apply together: `delta`
|
||||
/// clamped by each one's media and neighbours.
|
||||
fn clamp_trim_linked(q: &filmcraft_project::Sequence, clips: &[ClipId], edge: Edge, mode: TrimMode, delta: Tick, ctx: &edit::EditCtx) -> Result<Tick> {
|
||||
let mut d = delta;
|
||||
for c in clips {
|
||||
let x = edit::clamp_trim(q, *c, edge, mode, d, ctx)?;
|
||||
if x.abs() < d.abs() {
|
||||
d = x;
|
||||
}
|
||||
}
|
||||
Ok(d)
|
||||
}
|
||||
|
||||
/// The delta `timeline.trim` would apply for `delta`, without editing anything: the Timeline's
|
||||
/// trim drag shows this, so the edge stops at the end of the media or the neighbouring clip
|
||||
/// instead of following the pointer past it (#653).
|
||||
pub fn trim_delta(s: &Session, clip: ClipId, edge: Edge, mode: TrimMode, delta: Tick) -> Result<Tick> {
|
||||
let q = s.active_sequence().ok_or(EngineError::NoSequence)?;
|
||||
let durations = |id: ItemId| crate::media_duration(&s.project, &s.media, id);
|
||||
let starts = |id: ItemId| crate::media_start(&s.project, id);
|
||||
let mut next = s.project.next_id;
|
||||
let ctx = edit::EditCtx { next_id: &mut next, media_duration: &durations, media_start: &starts, min_duration: q.settings.frame_rate.frame_duration() };
|
||||
clamp_trim_linked(q, &with_links(s, &[clip]), edge, mode, delta, &ctx)
|
||||
}
|
||||
|
||||
/// Sequence settings matching a media clip (New Sequence From Clip, New Sequence from an item):
|
||||
/// its frame size, rate and pixel aspect ratio (Interpret Footage's, else the file's), so a
|
||||
/// 1440 x 1080 clip with 4:3 pixels makes a 1440 x 1080 sequence that displays 16:9.
|
||||
@@ -1220,7 +1268,7 @@ fn build() -> Vec<CommandSpec> {
|
||||
"Media…",
|
||||
["File", "Export"],
|
||||
None,
|
||||
r#"{"path":str,"preset":str?,"settings":ExportSettings?,"format":"h264|hevc|prores|dnxhr|apv|mjpeg|mxf-op1a|mxf-opatom|png|tiff|bmp|gif|wav|aiff"?,"width":u32?,"height":u32?,"fps":f64?,"bitrateKbps":u32?,"bitrateMode":"cbr|vbr1Pass|vbr2Pass|crf"?,"crf":0..51=23,"hardwareEncoding":"off|auto"?,"scale":f32=1,"audio":bool=true,"quality":0..100,"burnCaptions":bool=false,"alpha":bool=false,"captionSidecar":"srt|vtt"?,"loudnessLufs":f64?,"proresProfile":"proxy|lt|standard|hq"?,"dnxProfile":"lb|sq|hq|hqx"?,"apvProfile":"422-10|422-12|444-10|444-12"?,"mxfVideoCodec":"dnxhr|proRes|h264"?,"sequence":id?,"range":"entire|inOut|workArea|custom"?,"startSeconds":f64?,"endSeconds":f64?,"wait":bool=false}"#,
|
||||
r#"{"path":str,"preset":str?,"settings":ExportSettings?,"format":"h264|hevc|prores|dnxhr|apv|mjpeg|mxf-op1a|mxf-opatom|png|tiff|bmp|gif|wav|aiff"?,"width":u32?,"height":u32?,"fps":f64?,"bitrateKbps":u32?,"bitrateMode":"cbr|vbr1Pass|vbr2Pass|crf"?,"crf":0..51=23,"hardwareEncoding":"off|auto"?,"scale":f32=1,"audio":bool=true,"quality":0..100,"burnCaptions":bool=false,"alpha":bool=false,"captionSidecar":"srt|vtt"?,"loudnessLufs":f64?,"proresProfile":"proxy|lt|standard|hq|4444|4444xq"?,"dnxProfile":"lb|sq|hq|hqx"?,"apvProfile":"422-10|422-12|444-10|444-12"?,"mxfVideoCodec":"dnxhr|proRes|h264"?,"sequence":id?,"range":"entire|inOut|workArea|custom"?,"startSeconds":f64?,"endSeconds":f64?,"wait":bool=false}"#,
|
||||
has_seq,
|
||||
crate::export_tools::export_media
|
||||
),
|
||||
@@ -1267,7 +1315,7 @@ fn build() -> Vec<CommandSpec> {
|
||||
let caps = s.state.caption_selection.clone();
|
||||
return crate::captions::delete(s, &caps, false);
|
||||
}
|
||||
let sel = if p.get("clips").is_some() || p.get("clip").is_some() { with_links(s, &clips_p(s, p)) } else { s.state.selection.clone() };
|
||||
let sel = with_links(s, &clips_p(s, p));
|
||||
s.edit_sequence("Clear", |q, _, st| {
|
||||
edit::delete_items(q, &sel);
|
||||
st.selection.clear();
|
||||
@@ -1285,7 +1333,7 @@ fn build() -> Vec<CommandSpec> {
|
||||
let caps = s.state.caption_selection.clone();
|
||||
return crate::captions::delete(s, &caps, true);
|
||||
}
|
||||
let sel = if p.get("clips").is_some() || p.get("clip").is_some() { with_links(s, &clips_p(s, p)) } else { s.state.selection.clone() };
|
||||
let sel = with_links(s, &clips_p(s, p));
|
||||
s.edit_sequence("Ripple Delete", |q, _, st| {
|
||||
let spans = edit::ripple_delete_items(q, &sel)?;
|
||||
if st.ripple_sequence_markers {
|
||||
@@ -2113,9 +2161,12 @@ 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?,"video":bool=true,"audio":bool=true}"#,
|
||||
r#"{"item":id,"track":"V1"|id|"A1" (sound only)|"new" (a video track after the last)?,"audioTrack":"A1"|id|"new"?,"time":ticks|"frame":i64|"seconds":f64,"insert":bool,"sourceIn":ticks?,"duration":ticks?,"video":bool=true,"audio":bool=true}"#,
|
||||
has_seq,
|
||||
|s, p| {
|
||||
if crate::sequence_tools::wants_new_track(p) {
|
||||
return crate::sequence_tools::place_on_new_tracks(s, p);
|
||||
}
|
||||
let stream_flag = |key: &str| match p.get(key) {
|
||||
None => Ok(true),
|
||||
Some(Value::Bool(v)) => Ok(*v),
|
||||
@@ -2166,13 +2217,13 @@ fn build() -> Vec<CommandSpec> {
|
||||
Ok(json!({"clips": ids.iter().map(|c| c.0).collect::<Vec<_>>()}))
|
||||
}
|
||||
),
|
||||
cmd!("timeline.select", "Select Clips", [], None, r#"{"clips":[id],"transitions":[id]?,"add":bool,"toggle":bool,"linked":bool?}"#, has_seq, |s, p| {
|
||||
cmd!("timeline.select", "Select Clips", [], None, r#"{"clips":[id],"transitions":[id]?,"add":bool,"toggle":bool}"#, has_seq, |s, p| {
|
||||
if let Some(ids) = p.get("transitions") {
|
||||
return select_transitions(s, ids, bool_p(p, "add").unwrap_or(false), bool_p(p, "toggle").unwrap_or(false));
|
||||
}
|
||||
let clips: Vec<ClipId> =
|
||||
p.get("clips").and_then(Value::as_array).map(|a| a.iter().filter_map(|v| v.as_u64().map(ClipId)).collect()).unwrap_or_default();
|
||||
let clips = if bool_p(p, "linked").unwrap_or(true) { with_links(s, &clips) } else { clips };
|
||||
let clips = with_links(s, &clips);
|
||||
// selecting clips leaves trim mode (Premiere: clip and edit point selections are exclusive)
|
||||
s.state.edit_points.clear();
|
||||
s.state.trim_shift = Default::default();
|
||||
@@ -2230,13 +2281,7 @@ fn build() -> Vec<CommandSpec> {
|
||||
let applied = s.edit_sequence(if mode == TrimMode::Ripple { "Ripple Trim" } else { "Trim" }, |q, ctx, st| {
|
||||
let before = q.find_item(c).map(|(_, i)| i.range());
|
||||
// clamp across all linked partners, then apply the common delta
|
||||
let mut dd = d;
|
||||
for c in &clips {
|
||||
let x = edit::clamp_trim(q, *c, edge, mode, dd, ctx)?;
|
||||
if x.abs() < dd.abs() {
|
||||
dd = x;
|
||||
}
|
||||
}
|
||||
let mut dd = clamp_trim_linked(q, &clips, edge, mode, d, ctx)?;
|
||||
if mode == TrimMode::Ripple {
|
||||
// the clip and its linked partners ripple as one edit
|
||||
let mut group = vec![c];
|
||||
@@ -2372,20 +2417,8 @@ fn build() -> Vec<CommandSpec> {
|
||||
.or_else(|| p.get("deltaFrames").and_then(Value::as_i64).map(|f| s.sequence_rate().tick_of(f)))
|
||||
.unwrap_or_default();
|
||||
let clips = with_links(s, &[c]);
|
||||
s.edit_sequence("Slide", |q, ctx, _| {
|
||||
// clamp across all linked partners, then slide them by the common delta
|
||||
let mut dd = d;
|
||||
for c in &clips {
|
||||
let x = edit::slide(&mut q.clone(), *c, dd, ctx)?;
|
||||
if x.abs() < dd.abs() {
|
||||
dd = x;
|
||||
}
|
||||
}
|
||||
for c in &clips {
|
||||
edit::slide(q, *c, dd, ctx)?;
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
// clamp across all linked partners, then slide them by the common delta
|
||||
s.edit_sequence("Slide", |q, ctx, _| Ok(edit::slide_items(q, &clips, d, ctx)?))?;
|
||||
Ok(Value::Null)
|
||||
}),
|
||||
cmd!("timeline.rateStretch", "Rate Stretch", [], None, r#"{"clip":id,"edge":"in|out","delta":ticks}"#, has_seq, |s, p| {
|
||||
@@ -2661,15 +2694,35 @@ fn build() -> Vec<CommandSpec> {
|
||||
cmd!("effects.reset", "Reset Effect", [], None, r#"{"clip":id,"index":n}"#, has_seq, |s, p| {
|
||||
let c = clip_p(p, "clip").ok_or_else(|| bad("effects.reset", "need `clip`"))?;
|
||||
let idx = u64_p(p, "index").unwrap_or(0) as usize;
|
||||
let (fw, fh) = s.active_sequence().map(|q| (q.settings.width, q.settings.height)).unwrap_or((1920, 1080));
|
||||
let frame = s.active_sequence().map(|q| (q.settings.width, q.settings.height)).unwrap_or((1920, 1080));
|
||||
let ph = s.playhead();
|
||||
s.edit_sequence("Reset Effect", |q, _, _| {
|
||||
let (_, it) = q.find_item_mut(c).ok_or(filmcraft_edit::EditError::NoItem(c))?;
|
||||
if let Some(e) = it.effects.get_mut(idx)
|
||||
&& let Some(d) = e.def()
|
||||
{
|
||||
let mut fresh = d.instance();
|
||||
resolve_auto_points(&mut fresh, (fw, fh), (fw, fh));
|
||||
*e = fresh;
|
||||
let mt = it.source_time_at(ph.clamp(it.start, (it.end() - Tick(1)).max(it.start)));
|
||||
if let Some(e) = it.effects.get_mut(idx) {
|
||||
reset_params(e, None, frame, mt);
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(Value::Null)
|
||||
}),
|
||||
cmd!("effects.resetParam", "Reset Parameter", [], None, r#"{"clip":id,"effect":str|index,"param":str}"#, has_seq, |s, p| {
|
||||
let c = clip_p(p, "clip").ok_or_else(|| bad("effects.resetParam", "need `clip`"))?;
|
||||
let pid = str_p(p, "param").ok_or_else(|| bad("effects.resetParam", "need `param`"))?.to_string();
|
||||
let eff = p.get("effect").cloned().unwrap_or(json!("motion"));
|
||||
let frame = s.active_sequence().map(|q| (q.settings.width, q.settings.height)).unwrap_or((1920, 1080));
|
||||
let ph = s.playhead();
|
||||
s.edit_sequence("Reset Parameter", |q, _, _| {
|
||||
let (_, it) = q.find_item_mut(c).ok_or(filmcraft_edit::EditError::NoItem(c))?;
|
||||
let mt = it.source_time_at(ph.clamp(it.start, (it.end() - Tick(1)).max(it.start)));
|
||||
let e = match &eff {
|
||||
Value::Number(n) => it.effects.get_mut(n.as_u64().unwrap_or(0) as usize),
|
||||
Value::String(sid) => it.effects.iter_mut().find(|e| &e.effect == sid),
|
||||
_ => None,
|
||||
}
|
||||
.ok_or_else(|| bad("effects.resetParam", "no such effect on clip"))?;
|
||||
if !reset_params(e, Some(&pid), frame, mt) {
|
||||
return Err(bad("effects.resetParam", format!("no param `{pid}`")));
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
@@ -2771,7 +2824,7 @@ fn build() -> Vec<CommandSpec> {
|
||||
s.fix_state();
|
||||
Ok(json!({"items": items.len(), "bins": bins.len()}))
|
||||
}),
|
||||
cmd!("project.moveToBin", "Move to Bin", [], None, r#"{"items":[id]?,"bin":binId|null}"#, always, |s, p| {
|
||||
cmd!("project.moveToBin", "Move to Bin", [], None, r#"{"items":[id|binId]?,"bin":binId|null}"#, always, |s, p| {
|
||||
let items: Vec<ItemId> = match p.get("items").and_then(Value::as_array) {
|
||||
Some(a) => a.iter().filter_map(|v| v.as_u64().map(ItemId)).collect(),
|
||||
None => s.state.project_selection.clone(),
|
||||
@@ -2886,6 +2939,30 @@ fn build() -> Vec<CommandSpec> {
|
||||
v
|
||||
}
|
||||
|
||||
/// Put `e`'s parameters (all of them, or only `only`) back to their defaults, as Premiere's reset
|
||||
/// buttons do: a parameter that is not animated gets its default as its static value, and an
|
||||
/// animated one keeps its keyframes and gets the default as a keyframe at media time `mt`.
|
||||
/// Masks are left alone. Returns whether any parameter was reset.
|
||||
fn reset_params(e: &mut filmcraft_project::EffectInstance, only: Option<&str>, frame: (u32, u32), mt: Tick) -> bool {
|
||||
let Some(d) = e.def() else { return false };
|
||||
let mut fresh = d.instance();
|
||||
resolve_auto_points(&mut fresh, frame, frame);
|
||||
let mut any = false;
|
||||
for (id, prm) in fresh.params {
|
||||
if only.is_some_and(|o| o != id) {
|
||||
continue;
|
||||
}
|
||||
any = true;
|
||||
match e.params.get_mut(&id) {
|
||||
Some(old) if old.is_animated() => old.set_at(mt, prm.value),
|
||||
_ => {
|
||||
e.params.insert(id, prm);
|
||||
}
|
||||
}
|
||||
}
|
||||
any
|
||||
}
|
||||
|
||||
fn keyframe_op(s: &mut Session, p: &Value, op: &str) -> Result<Value> {
|
||||
let c = clip_p(p, "clip").ok_or_else(|| bad("keyframe", "need `clip`"))?;
|
||||
let pid = str_p(p, "param").ok_or_else(|| bad("keyframe", "need `param`"))?.to_string();
|
||||
|
||||
@@ -119,10 +119,15 @@ fn clip_commands_take_explicit_clips_without_a_selection() {
|
||||
fn naming_nothing_usable_does_not_enable_a_command() {
|
||||
let mut s = demo();
|
||||
let before = s.project.clone();
|
||||
// no ids, ids of no clip, the wrong type, and a key the command does not take
|
||||
for p in [json!({"clips": []}), json!({"clips": [987_654_321u64]}), json!({"clips": "all"}), json!({"clips": [-1, 1.5, null]}), json!({"items": [1]})] {
|
||||
// no ids, the wrong type, and a key the command does not take
|
||||
for p in [json!({"clips": []}), json!({"clips": "all"}), json!({"items": [1]})] {
|
||||
assert_eq!(disabled(s.execute("edit.rippleDelete", p.clone())), "nothing selected", "{p}");
|
||||
}
|
||||
// ids of no clip and values that are not ids: still disabled, and the reason names them (#591)
|
||||
let why = disabled(s.execute("edit.rippleDelete", json!({"clips": [987_654_321u64]})));
|
||||
assert!(why.starts_with("no clip 987654321 in the active sequence"), "{why}");
|
||||
let why = disabled(s.execute("edit.rippleDelete", json!({"clips": [-1, 1.5, null]})));
|
||||
assert!(why.starts_with("-1 is not a clip id"), "{why}");
|
||||
assert_eq!(disabled(s.execute("clip.replaceFromBin", json!({"clips": [v1(&s)[0].id.0], "item": 987_654_321u64}))), "select a clip in the Project panel");
|
||||
// `edit.cut` takes no `clips`: it works on the selection only
|
||||
assert_eq!(disabled(s.execute("edit.cut", json!({"clips": [v1(&s)[0].id.0]}))), "no clips selected");
|
||||
@@ -159,6 +164,44 @@ fn link_and_speed_take_explicit_clips_without_a_selection() {
|
||||
assert!(s.state.selection.is_empty() && !s.is_enabled("clip.speedDuration"), "menu enablement still follows the selection");
|
||||
}
|
||||
|
||||
/// #591: a caller that names clips which cannot stand in for the selection hears why, instead of
|
||||
/// "no clips selected": a clip of another sequence, an id of something else, a string id.
|
||||
#[test]
|
||||
fn named_clips_that_are_not_usable_say_why() {
|
||||
let mut s = demo();
|
||||
let demo_seq = s.state.active_sequence.unwrap();
|
||||
let demo_name = s.project.item(demo_seq).unwrap().name.clone();
|
||||
let last = v1(&s).last().unwrap().clone();
|
||||
// a track id and a project item id are not clip ids
|
||||
let track = s.active_sequence().unwrap().video_tracks[0].id.0;
|
||||
let why = disabled(s.execute("clip.speedDuration", json!({"clips": [track], "speed": 50})));
|
||||
assert_eq!(why, format!("no clip {track} in the active sequence (clip ids are the `clip` numbers of the items in sequence.inspect)"));
|
||||
let why = disabled(s.execute("clip.speedDuration", json!({"clips": [last.item.0], "speed": 50})));
|
||||
assert!(why.starts_with(&format!("no clip {} in the active sequence", last.item.0)), "{why}");
|
||||
// a string is not an id
|
||||
let why = disabled(s.execute("clip.speedDuration", json!({"clips": [last.id.0.to_string()], "speed": 50})));
|
||||
assert!(why.starts_with(&format!("\"{}\" is not a clip id", last.id.0)), "{why}");
|
||||
// `clip`, where the command documents it
|
||||
let why = disabled(s.execute("graphics.setText", json!({"clip": "x", "text": "x"})));
|
||||
assert!(why.starts_with("\"x\" is not a clip id"), "{why}");
|
||||
// a clip of a sequence that is not the active one
|
||||
s.execute("file.newSequence", json!({"name": "Other"})).unwrap();
|
||||
assert_ne!(s.state.active_sequence, Some(demo_seq));
|
||||
let why = disabled(s.execute("clip.speedDuration", json!({"clips": [last.id.0], "speed": 200})));
|
||||
assert_eq!(
|
||||
why,
|
||||
format!("clip {} is in sequence \"{demo_name}\", not the active one: open it first with sequence.open {{\"item\":{}}}", last.id.0, demo_seq.0)
|
||||
);
|
||||
// and doing what it says works
|
||||
s.execute("sequence.open", json!({"item": demo_seq.0})).unwrap();
|
||||
s.execute("clip.speedDuration", json!({"clips": [last.id.0], "speed": 200})).unwrap();
|
||||
assert!(clip(&s, last.id).unwrap().duration < last.duration);
|
||||
// naming nothing keeps the selection's reason, for menus and callers alike
|
||||
s.state.selection.clear();
|
||||
assert_eq!(disabled(s.execute("clip.speedDuration", json!({"speed": 50}))), "no clips selected");
|
||||
assert_eq!(disabled(s.execute("clip.speedDuration", json!({"clips": [], "speed": 50}))), "no clips selected");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn naming_targets_lifts_only_the_selection_condition() {
|
||||
// no sequence: named clips cannot stand in for one
|
||||
|
||||
@@ -217,9 +217,16 @@ fn every_builtin_preset_exports_and_ffprobe_confirms() {
|
||||
"proxy" => ("Proxy", 45.0),
|
||||
"lt" => ("LT", 102.0),
|
||||
"standard" => ("Standard", 147.0),
|
||||
"4444" => ("4444", 330.0),
|
||||
"4444xq" => ("XQ", 500.0),
|
||||
_ => ("HQ", 220.0),
|
||||
};
|
||||
assert_eq!(v["profile"], profile, "{name}");
|
||||
if matches!(profile, "4444" | "XQ") {
|
||||
let pix = v["pix_fmt"].as_str().unwrap_or_default();
|
||||
let alpha = settings["alpha"].as_bool().unwrap_or(false);
|
||||
assert!(pix.contains("444") && pix.starts_with("yuva") == alpha, "{name}: {pix}");
|
||||
}
|
||||
assert!(vbr <= mbps * px * 1.5, "{name}: {vbr:.1} Mb/s vs nominal {:.1}", mbps * px);
|
||||
assert_eq!(a["codec_name"], "pcm_s24le", "{name}");
|
||||
}
|
||||
@@ -318,7 +325,7 @@ fn user_presets_persist_with_favourites_import_and_export() {
|
||||
let favs = s.execute("export.presets.list", json!({"favorites": true})).unwrap()["presets"].as_array().unwrap().len();
|
||||
assert_eq!(favs, 2);
|
||||
let hits = s.execute("export.presets.list", json!({"query": "prores"})).unwrap()["presets"].as_array().unwrap().len();
|
||||
assert_eq!(hits, 5, "four QuickTime ProRes presets and MXF OP1a ProRes 422 HQ");
|
||||
assert_eq!(hits, 8, "seven QuickTime ProRes presets and MXF OP1a ProRes 422 HQ");
|
||||
// persisted: a fresh session with the same data directory sees both
|
||||
let mut s2 = demo();
|
||||
s2.export_presets.set_dir(&dir.0);
|
||||
|
||||
@@ -11,6 +11,17 @@ use serde_json::{Value, json};
|
||||
use crate::Session;
|
||||
use crate::media_test_util::tmp_dir;
|
||||
|
||||
#[test]
|
||||
fn import_accepts_uppercase_extensions() {
|
||||
let dir = tmp_dir("import-uppercase");
|
||||
let wav = dir.join("TONE.WAV");
|
||||
std::fs::write(&wav, filmcraft_media::wav::write_wav16(&[0.1; 4800], 2, 48_000)).unwrap();
|
||||
let mut s = Session::default();
|
||||
let r = s.execute("file.import", json!({"paths": [wav.to_string_lossy()]})).unwrap();
|
||||
assert_eq!(r["items"].as_array().unwrap().len(), 1);
|
||||
assert!(s.project.items.values().any(|it| it.name == "TONE.WAV"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_replace_starts_from_an_empty_project() {
|
||||
let dir = tmp_dir("import-replace");
|
||||
|
||||
@@ -272,12 +272,8 @@ fn slip_selection(s: &mut Session, frames: i64) -> Result<Value> {
|
||||
fn slide_selection(s: &mut Session, frames: i64) -> Result<Value> {
|
||||
let d = s.sequence_rate().tick_of(frames);
|
||||
let clips = with_links(s, &s.state.selection.clone());
|
||||
s.edit_sequence("Slide", |q, ctx, _| {
|
||||
for c in &clips {
|
||||
edit::slide(q, *c, d, ctx)?;
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
// one common delta, clamped by the most restrictive member, keeps linked partners in sync
|
||||
s.edit_sequence("Slide", |q, ctx, _| Ok(edit::slide_items(q, &clips, d, ctx)?))?;
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
|
||||
@@ -376,3 +376,29 @@ fn reveal_nested_sequence_opens_it_at_the_matching_frame() {
|
||||
assert_ne!(s.state.active_sequence, Some(outer));
|
||||
assert_eq!(s.playhead(), nest_clip.source_in + rate.tick_of(7), "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keyboard_slide_keeps_linked_partners_aligned_at_a_neighbour_limit() {
|
||||
// #712: a three-frame sound-only follower limits only the sound partner
|
||||
let mut s = Session::default();
|
||||
s.execute("file.newProject", json!({"name": "Slide"})).unwrap();
|
||||
s.execute("file.newSequence", json!({"name": "Sequence", "fps": 25, "video": 1, "audio": 1, "width": 16, "height": 16})).unwrap();
|
||||
let r = s.execute("file.newOfflineFile", json!({"name": "Media", "seconds": 4, "fps": 25, "video": true, "audio": true})).unwrap();
|
||||
let item = r["item"].as_u64().unwrap();
|
||||
let fr = s.sequence_rate().tick_of(1);
|
||||
let r =
|
||||
s.execute("timeline.place", json!({"item": item, "track": "V1", "audioTrack": "A1", "time": 0, "sourceIn": fr.0 * 20, "duration": fr.0 * 60})).unwrap();
|
||||
let (v, a) = (r["clips"][0].as_u64().unwrap(), r["clips"][1].as_u64().unwrap());
|
||||
let r = s.execute("timeline.place", json!({"item": item, "track": "A1", "time": fr.0 * 60, "sourceIn": 0, "duration": fr.0 * 3})).unwrap();
|
||||
let f = r["clips"][0].as_u64().unwrap();
|
||||
s.execute("sequence.linkedSelection", json!({"on": true})).unwrap();
|
||||
s.execute("timeline.select", json!({"clips": [v]})).unwrap();
|
||||
s.execute("timeline.slideRight5", json!({})).unwrap();
|
||||
assert_eq!(clip(&s, v).start, Tick(fr.0 * 2), "the pair moves only as far as the sound may");
|
||||
assert_eq!(clip(&s, a).start, clip(&s, v).start, "picture and sound stay aligned");
|
||||
let fl = clip(&s, f);
|
||||
assert_eq!((fl.start, fl.duration, fl.source_in), (Tick(fr.0 * 62), fr, Tick(fr.0 * 2)));
|
||||
// nothing more to give: a further slide right moves neither partner
|
||||
s.execute("timeline.slideRight", json!({})).unwrap();
|
||||
assert_eq!((clip(&s, v).start, clip(&s, a).start), (Tick(fr.0 * 2), Tick(fr.0 * 2)));
|
||||
}
|
||||
|
||||
@@ -708,8 +708,11 @@ impl Session {
|
||||
}
|
||||
let clips = commands::named_clips(self, spec, params);
|
||||
let items = commands::named_items(self, spec, params);
|
||||
// clips were named but none is one of the active sequence: say which and why, rather
|
||||
// than "no clips selected" to a caller that did not mean the selection (#591)
|
||||
let missing = if clips.is_none() { commands::named_clips_missing(self, spec, params) } else { None };
|
||||
if clips.is_none() && items.is_none() {
|
||||
return by_selection;
|
||||
return by_selection.map_err(|why| missing.unwrap_or(why));
|
||||
}
|
||||
let mut clips = clips.unwrap_or_else(|| self.state.selection.clone());
|
||||
let mut items = items.unwrap_or_else(|| self.state.project_selection.clone());
|
||||
@@ -718,7 +721,7 @@ impl Session {
|
||||
let by_params = (spec.enabled)(self);
|
||||
self.state.selection = clips;
|
||||
self.state.project_selection = items;
|
||||
by_params
|
||||
by_params.map_err(|why| missing.unwrap_or(why))
|
||||
}
|
||||
|
||||
/// Whether the command can run on the current selection (what the menus show).
|
||||
|
||||
@@ -29,6 +29,7 @@ impl FakeFs {
|
||||
"/home/me/Movies/edit.edl",
|
||||
"/home/me/Movies/subs.srt",
|
||||
"/home/me/Movies/clip.mov",
|
||||
"/home/me/Movies/CLIP.MOV",
|
||||
] {
|
||||
files.insert(f.to_string(), vec![0; 16]);
|
||||
}
|
||||
@@ -100,7 +101,7 @@ fn listing_puts_folders_first_and_hides_unsupported_files() {
|
||||
assert_eq!(l["dir"], "/home/me");
|
||||
assert_eq!(names(&l), ["Movies"]);
|
||||
let l = s.execute("mediaBrowser.list", json!({"path": "/home/me/Movies"})).unwrap();
|
||||
assert_eq!(names(&l), ["Selects", "A-interview.wav", "b-roll.wav", "clip.mov", "edit.edl", "shot_0001.png", "shot_0002.png", "subs.srt"]);
|
||||
assert_eq!(names(&l), ["Selects", "A-interview.wav", "b-roll.wav", "CLIP.MOV", "clip.mov", "edit.edl", "shot_0001.png", "shot_0002.png", "subs.srt"]);
|
||||
let e = &l["entries"][0];
|
||||
assert_eq!((e["isDir"].as_bool(), e["kind"].as_str()), (Some(true), Some("folder")));
|
||||
let shot = l["entries"].as_array().unwrap().iter().find(|e| e["name"] == "shot_0001.png").unwrap();
|
||||
@@ -117,7 +118,7 @@ fn file_type_filter() {
|
||||
let l = s.execute("mediaBrowser.list", json!({"path": "/home/me/Movies", "fileTypes": "image"})).unwrap();
|
||||
assert_eq!(names(&l), ["Selects", "shot_0001.png", "shot_0002.png"]);
|
||||
let l = s.execute("mediaBrowser.list", json!({"path": "/home/me/Movies", "fileTypes": "mov"})).unwrap();
|
||||
assert_eq!(names(&l), ["Selects", "clip.mov"]);
|
||||
assert_eq!(names(&l), ["Selects", "CLIP.MOV", "clip.mov"]);
|
||||
// the setting persists and applies by default
|
||||
s.execute("mediaBrowser.settings", json!({"fileTypes": "project"})).unwrap();
|
||||
let l = s.execute("mediaBrowser.list", json!({"path": "/home/me/Movies"})).unwrap();
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
//! |---|---|
|
||||
//! | `events.list` / `events.clear` | the Events panel: warnings and errors of commands, background jobs and the auto-save worker ([`EventLog`]) |
|
||||
//! | `metadata.get` / `metadata.set` | the Metadata panel: an item's clip and file properties; editable log fields (undoable) |
|
||||
//! | `project.notes` / `project.setNotes` | the Project Notes panel: free plain text saved in the project (undoable) |
|
||||
//!
|
||||
//! The Progress panel uses `jobs.list` / `jobs.cancel`; the Timecode panel and the Reference
|
||||
//! Monitor are frontend state only.
|
||||
@@ -339,6 +340,38 @@ fn metadata_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"item": id.0, "name": it.name, "label": it.label.name(), "metadata": it.metadata, "changed": !unchanged}))
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------------ project notes
|
||||
|
||||
/// Longest project notes accepted (bytes of UTF-8), so a script or a pasted file cannot grow the
|
||||
/// project (and every undo step) without bound.
|
||||
pub const MAX_NOTES_BYTES: usize = 1 << 20;
|
||||
|
||||
/// `project.notes`: the project notes.
|
||||
fn notes_get(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
Ok(json!({"text": s.project.notes}))
|
||||
}
|
||||
|
||||
/// `project.setNotes {text, merge?}`: replace the project notes as one undo step. Calls with the
|
||||
/// same `merge` key and no other edit in between share that step (typing in the panel).
|
||||
fn notes_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let text = p.get("text").and_then(Value::as_str).ok_or_else(|| bad("project.setNotes", "need `text` (a string)"))?;
|
||||
if text.len() > MAX_NOTES_BYTES {
|
||||
return Err(bad("project.setNotes", format!("the notes are too long ({} bytes; at most {MAX_NOTES_BYTES})", text.len())));
|
||||
}
|
||||
let changed = s.project.notes != text;
|
||||
if changed {
|
||||
let body = |pr: &mut Project, _: &mut crate::EditorState| {
|
||||
pr.notes = text.to_string();
|
||||
Ok(())
|
||||
};
|
||||
match str_p(p, "merge") {
|
||||
Some(key) => s.edit_merged("Edit Project Notes", &format!("project.notes:{key}"), body)?,
|
||||
None => s.edit("Edit Project Notes", body)?,
|
||||
}
|
||||
}
|
||||
Ok(json!({"text": s.project.notes, "changed": changed}))
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------------ registry
|
||||
|
||||
pub(crate) fn commands() -> Vec<CommandSpec> {
|
||||
@@ -370,5 +403,16 @@ pub(crate) fn commands() -> Vec<CommandSpec> {
|
||||
run: metadata_set,
|
||||
journal: true,
|
||||
},
|
||||
q("project.notes", "Get Project Notes", "{}", notes_get),
|
||||
CommandSpec {
|
||||
id: "project.setNotes",
|
||||
label: "Edit Project Notes",
|
||||
menu: &[],
|
||||
shortcut: None,
|
||||
params: r#"{"text":str,"merge":str?}"#,
|
||||
enabled: always,
|
||||
run: notes_set,
|
||||
journal: true,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
@@ -156,3 +156,40 @@ fn events_report_background_jobs() {
|
||||
s.poll_persistence();
|
||||
assert_eq!(s.log.entries.len(), 6);
|
||||
}
|
||||
|
||||
/// #620: project notes are saved in the project file; typing (one `merge` key) is one undo step.
|
||||
#[test]
|
||||
fn project_notes_are_saved_and_typing_is_one_undo_step() {
|
||||
let mut s = demo();
|
||||
assert_eq!(s.execute("project.notes", json!({})).unwrap()["text"], "");
|
||||
let undo0 = s.history.undo.len();
|
||||
for text in ["T", "To", "To do: fix the logo at 00:01:02:03"] {
|
||||
s.execute("project.setNotes", json!({"text": text, "merge": "1"})).unwrap();
|
||||
}
|
||||
assert_eq!(s.history.undo.len(), undo0 + 1, "one typing session, one undo step");
|
||||
assert_eq!(s.history.undo.last().unwrap().0, "Edit Project Notes");
|
||||
// the same text again changes nothing and adds no step
|
||||
let r = s.execute("project.setNotes", json!({"text": "To do: fix the logo at 00:01:02:03"})).unwrap();
|
||||
assert_eq!(r["changed"], json!(false));
|
||||
assert_eq!(s.history.undo.len(), undo0 + 1);
|
||||
|
||||
let loaded = filmcraft_format::decode(&filmcraft_format::encode(&s.project, false)).unwrap();
|
||||
assert_eq!(loaded.project.notes, "To do: fix the logo at 00:01:02:03");
|
||||
// an older file without notes still loads, with none
|
||||
let mut doc: Value = serde_json::from_slice(&filmcraft_format::encode(&filmcraft_project::Project::new("old"), false)).unwrap();
|
||||
doc["schema_version"] = json!(14);
|
||||
assert_eq!(filmcraft_format::decode(&serde_json::to_vec(&doc).unwrap()).unwrap().project.notes, "");
|
||||
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(s.project.notes, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_notes_refuse_bad_input() {
|
||||
let mut s = demo();
|
||||
assert!(s.execute("project.setNotes", json!({})).is_err());
|
||||
assert!(s.execute("project.setNotes", json!({"text": 5})).is_err());
|
||||
let huge = "x".repeat(crate::panels::MAX_NOTES_BYTES + 1);
|
||||
assert!(s.execute("project.setNotes", json!({"text": huge})).is_err());
|
||||
assert_eq!(s.project.notes, "");
|
||||
}
|
||||
|
||||
@@ -723,6 +723,54 @@ pub(crate) fn add_tracks(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"video": video, "audio": audio, "submix": submix}))
|
||||
}
|
||||
|
||||
/// `timeline.place` names `"new"` as its `track` or `audioTrack`.
|
||||
pub(crate) fn wants_new_track(p: &Value) -> bool {
|
||||
["track", "audioTrack"].iter().any(|k| p.get(*k).and_then(Value::as_str).is_some_and(|v| v.eq_ignore_ascii_case("new")))
|
||||
}
|
||||
|
||||
/// `timeline.place` with `"track":"new"` and/or `"audioTrack":"new"` (#483: media dropped in the
|
||||
/// empty space above the video tracks or below the audio tracks): a track is added after the
|
||||
/// last one of its kind for each stream the item carries (and the call enables), then the clip
|
||||
/// goes on it. One undo step; when the placement fails, the new tracks go away again.
|
||||
pub(crate) fn place_on_new_tracks(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "timeline.place";
|
||||
let item = crate::commands::item_p(p, "item").ok_or_else(|| bad(cmd, "need `item`"))?;
|
||||
let pi = s.project.item(item).ok_or_else(|| bad(cmd, "no such item"))?;
|
||||
let is_new = |k: &str| p.get(k).and_then(Value::as_str).is_some_and(|v| v.eq_ignore_ascii_case("new"));
|
||||
let enabled = |k: &str| p.get(k).and_then(Value::as_bool).unwrap_or(true);
|
||||
let new_v = is_new("track") && pi.has_video() && enabled("video");
|
||||
let new_a = is_new("audioTrack") && pi.has_audio() && enabled("audio");
|
||||
if !new_v && !new_a {
|
||||
return Err(bad(cmd, "no new track to add: the item has no picture or sound for it"));
|
||||
}
|
||||
let added = add_tracks(s, &json!({"video": u64::from(new_v), "audio": u64::from(new_a)}))?;
|
||||
let mark = s.history.undo.len();
|
||||
let mut q = p.clone();
|
||||
for (key, kind) in [("track", "video"), ("audioTrack", "audio")] {
|
||||
if is_new(key) {
|
||||
q[key] = added.get(kind).and_then(|ids| ids.get(0)).cloned().unwrap_or(Value::Null);
|
||||
}
|
||||
}
|
||||
let place = crate::commands::find(cmd).ok_or_else(|| EngineError::UnknownCommand(cmd.into()))?.run;
|
||||
match place(s, &q) {
|
||||
Ok(v) => {
|
||||
// the new tracks and the clip are one undo step
|
||||
if mark >= 1
|
||||
&& s.history.undo.len() == mark + 1
|
||||
&& let (Some((label, _)), Some((_, before))) = (s.history.undo.pop(), s.history.undo.pop())
|
||||
{
|
||||
s.history.undo.push((label, before));
|
||||
}
|
||||
Ok(v)
|
||||
}
|
||||
Err(e) => {
|
||||
s.undo();
|
||||
s.history.redo.pop();
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn delete_tracks(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let seq_id = s.state.active_sequence.ok_or(EngineError::NoSequence)?;
|
||||
let q = s.active_sequence().ok_or(EngineError::NoSequence)?;
|
||||
|
||||
@@ -575,3 +575,35 @@ fn go_to_in_out_without_marks_go_to_the_sequence_ends() {
|
||||
s.execute("markers.goToIn", json!({})).unwrap();
|
||||
assert_eq!(s.playhead(), s.active_sequence().unwrap().mark_in.unwrap());
|
||||
}
|
||||
|
||||
/// #483: media dropped in the empty space past the last track goes on a new track (with
|
||||
/// `"new"`), one undo step; a placement that fails leaves no new track behind.
|
||||
#[test]
|
||||
fn place_on_new_tracks_adds_them_in_one_undo_step() {
|
||||
let (mut s, item) = fresh();
|
||||
let before = s.active_sequence().unwrap().clone();
|
||||
let (nv, na) = (before.video_tracks.len(), before.audio_tracks.len());
|
||||
let undo = s.history.undo.len();
|
||||
let r = s.execute("timeline.place", json!({"item": item, "track": "new", "audioTrack": "new", "time": 0})).unwrap();
|
||||
let q = s.active_sequence().unwrap();
|
||||
assert_eq!((q.video_tracks.len(), q.audio_tracks.len()), (nv + 1, na + 1));
|
||||
let clips: Vec<ClipId> = r["clips"].as_array().unwrap().iter().map(|c| ClipId(c.as_u64().unwrap())).collect();
|
||||
assert_eq!(clips.len(), 2);
|
||||
assert_eq!(q.video_tracks[nv].items.iter().map(|i| i.id).collect::<Vec<_>>(), [clips[0]], "the picture is on the new top video track");
|
||||
assert_eq!(q.audio_tracks[na].items.iter().map(|i| i.id).collect::<Vec<_>>(), [clips[1]], "the sound is on the new last audio track");
|
||||
assert_eq!(s.history.undo.len(), undo + 1, "one undo step");
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(*s.active_sequence().unwrap(), before);
|
||||
|
||||
// only the picture gets a new track; the sound follows the given track
|
||||
s.execute("timeline.place", json!({"item": item, "track": "new", "audioTrack": "A1", "time": 0})).unwrap();
|
||||
let q = s.active_sequence().unwrap();
|
||||
assert_eq!((q.video_tracks.len(), q.audio_tracks.len()), (nv + 1, na));
|
||||
assert_eq!((q.video_tracks[nv].items.len(), q.audio_tracks[0].items.len()), (1, 1));
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
|
||||
// a placement that fails after the tracks were added: nothing changes
|
||||
assert!(s.execute("timeline.place", json!({"item": item, "track": "new", "audioTrack": "A99", "time": 0})).is_err());
|
||||
assert_eq!(*s.active_sequence().unwrap(), before);
|
||||
assert_eq!(s.history.undo.len(), undo);
|
||||
}
|
||||
|
||||
@@ -1097,7 +1097,7 @@ static CATEGORIES: &[Category] = &[
|
||||
f("timeline.multichannelMonoMediaTracks", "Multichannel Mono Media", Kind::Choice(TRACKS), false),
|
||||
],
|
||||
),
|
||||
b("timeline.focusTimelineOnEdit", "Set focus on the Timeline when performing Insert/Overwrite edits", true),
|
||||
b("timeline.focusTimelineOnEdit", "Set focus on the Timeline when performing Insert/Overwrite edits", false),
|
||||
b("timeline.snapPlayhead", "Snap playhead in Timeline when Snap is enabled", true),
|
||||
b("timeline.returnToBeginning", "At playback end, return to beginning when restarting playback", true),
|
||||
b("timeline.outOfSyncUnlinked", "Display out of sync indicators for unlinked clips", false),
|
||||
|
||||
@@ -251,6 +251,18 @@ fn schema_command_describes_every_category() {
|
||||
assert_eq!(auto["value"], "pageScroll");
|
||||
assert_eq!(auto["wired"], true);
|
||||
assert!(auto["kind"]["choices"].as_array().unwrap().iter().any(|c| c["value"] == "smoothScroll"));
|
||||
|
||||
// This option is stored, but Insert/Overwrite does not yet implement focus transfer.
|
||||
let focus = tl["fields"].as_array().unwrap().iter().find(|f| f["key"] == "timeline.focusTimelineOnEdit").unwrap();
|
||||
assert_eq!(focus["kind"], json!({"type": "bool"}));
|
||||
assert_eq!(focus["wired"], false);
|
||||
assert_eq!(focus["value"], false);
|
||||
set(&mut s, "timeline.focusTimelineOnEdit", json!(true));
|
||||
let updated = s.execute("prefs.schema", json!({"category": "timeline"})).unwrap();
|
||||
let focus = updated["categories"][0]["fields"].as_array().unwrap().iter().find(|f| f["key"] == "timeline.focusTimelineOnEdit").unwrap();
|
||||
assert_eq!(focus["value"], true);
|
||||
assert_eq!(focus["wired"], false);
|
||||
|
||||
let one = s.execute("prefs.schema", json!({"category": "trim"})).unwrap();
|
||||
assert_eq!(one["categories"].as_array().unwrap().len(), 1);
|
||||
assert!(s.execute("prefs.schema", json!({"category": "nope"})).is_err());
|
||||
|
||||
@@ -139,45 +139,6 @@ fn trim_linked_and_ripple_delete() {
|
||||
assert_eq!(q.video_tracks[0].items[0].start, s.sequence_rate().tick_of(12), "the trim gap remains");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_respects_individually_selected_linked_clips_and_undo() {
|
||||
for audio in [false, true] {
|
||||
let mut s = demo();
|
||||
let video = s.active_sequence().unwrap().video_tracks[0].items[0].clone();
|
||||
let sound = s.active_sequence().unwrap().audio_tracks[0].items.iter().find(|i| i.link == video.link).unwrap().clone();
|
||||
let (selected, partner) = if audio { (sound.id, video.id) } else { (video.id, sound.id) };
|
||||
s.execute("timeline.select", json!({"clips": [selected.0], "linked": false})).unwrap();
|
||||
assert_eq!(s.state.selection, vec![selected]);
|
||||
s.execute("edit.clear", json!({})).unwrap();
|
||||
assert!(s.active_sequence().unwrap().find_item(selected).is_none());
|
||||
assert!(s.active_sequence().unwrap().find_item(partner).is_some());
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(s.active_sequence().unwrap().find_item(selected).is_some());
|
||||
s.execute("timeline.select", json!({"clips": [selected.0]})).unwrap();
|
||||
assert_eq!(s.state.selection.len(), 2);
|
||||
s.execute("edit.clear", json!({})).unwrap();
|
||||
assert!(s.active_sequence().unwrap().find_item(partner).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ripple_delete_respects_individual_selection_and_explicit_clear_keeps_link_behavior() {
|
||||
let mut s = demo();
|
||||
let video = s.active_sequence().unwrap().video_tracks[0].items[0].clone();
|
||||
let sound = s.active_sequence().unwrap().audio_tracks[0].items.iter().find(|i| i.link == video.link).unwrap().id;
|
||||
let tracks: Vec<_> = s.active_sequence().unwrap().all_tracks().map(|t| t.id.0).collect();
|
||||
for track in tracks {
|
||||
s.execute("timeline.setTrack", json!({"track": track, "syncLock": false})).unwrap();
|
||||
}
|
||||
s.execute("timeline.select", json!({"clips": [video.id.0], "linked": false})).unwrap();
|
||||
s.execute("edit.rippleDelete", json!({})).unwrap();
|
||||
assert!(s.active_sequence().unwrap().find_item(video.id).is_none());
|
||||
assert!(s.active_sequence().unwrap().find_item(sound).is_some());
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
s.execute("edit.clear", json!({"clips": [video.id.0]})).unwrap();
|
||||
assert!(s.active_sequence().unwrap().find_item(sound).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slide_moves_linked_audio_with_the_video() {
|
||||
let mut s = demo();
|
||||
|
||||
@@ -236,3 +236,21 @@ fn extend_to_playhead_moves_each_unequal_out_point_to_the_playhead() {
|
||||
assert_eq!(q.find_item(a).unwrap().1.end(), ph);
|
||||
assert_eq!(q.find_item(b).unwrap().1.end(), ph);
|
||||
}
|
||||
|
||||
/// #653: the delta the Timeline previews while an edge is dragged is the one `timeline.trim`
|
||||
/// applies on release: dragging the last clip's Out edge far past the end of its media stops at
|
||||
/// the media's end instead of following the pointer.
|
||||
#[test]
|
||||
fn trim_delta_stops_at_the_end_of_the_media() {
|
||||
let mut s = demo();
|
||||
let last = *v1(&s).last().unwrap();
|
||||
let clip = ClipId(last.0);
|
||||
let far = Tick(3600 * filmcraft_time::TICKS_PER_SECOND);
|
||||
let shown = commands::trim_delta(&s, clip, filmcraft_edit::Edge::Out, filmcraft_edit::TrimMode::Regular, far).unwrap();
|
||||
assert!(Tick::ZERO < shown && shown < far, "clamped to the media left after the clip: {shown:?}");
|
||||
let r = s.execute("timeline.trim", json!({"clip": last.0, "edge": "out", "mode": "regular", "delta": far.0})).unwrap();
|
||||
assert_eq!(r["delta"], json!(shown.0), "the preview and the trim agree");
|
||||
let small = s.sequence_rate().frame_duration();
|
||||
let back = commands::trim_delta(&s, clip, filmcraft_edit::Edge::Out, filmcraft_edit::TrimMode::Regular, -small).unwrap();
|
||||
assert_eq!(back, -small, "a trim within the media is not clamped");
|
||||
}
|
||||
|
||||
+65
-10
@@ -239,7 +239,7 @@ pub struct ExportSettings {
|
||||
/// `progress.total`, `finished` and the final status; this call only adds to `done`.
|
||||
#[serde(default)]
|
||||
pub part_of_batch: bool,
|
||||
/// ProRes flavour: `proxy`, `lt`, `standard` or `hq` (empty = HQ).
|
||||
/// ProRes flavour: `proxy`, `lt`, `standard`, `hq`, `4444` or `4444xq` (empty = HQ).
|
||||
#[serde(default)]
|
||||
pub prores_profile: String,
|
||||
/// DNxHR profile: `lb`, `sq`, `hq` or `hqx` (empty = HQ).
|
||||
@@ -256,8 +256,9 @@ pub struct ExportSettings {
|
||||
/// sequence's HDR space and signal it (VUI / `colr` / `mdcv` / `clli` / SEI).
|
||||
#[serde(default)]
|
||||
pub sdr: bool,
|
||||
/// Keep the alpha channel in PNG and TIFF sequences (straight alpha, Premiere's "Include
|
||||
/// Alpha Channel"). Off, every frame is flattened over black (#160). Other formats ignore it.
|
||||
/// Keep the alpha channel in PNG and TIFF sequences and ProRes 4444 / 4444 XQ QuickTime movies
|
||||
/// (straight alpha, Premiere's "Include Alpha Channel"). Off, every frame is flattened over
|
||||
/// black (#160). Other formats ignore it.
|
||||
#[serde(default)]
|
||||
pub alpha: bool,
|
||||
/// Output frame size (None = Match Source: the sequence size times `scale`).
|
||||
@@ -1006,6 +1007,8 @@ fn aac_factory(_format: Format, sample_rate: u32, channels: u32, s: &ExportSetti
|
||||
struct ProResEncoder {
|
||||
enc: filmcraft_prores::Encoder,
|
||||
profile: filmcraft_prores::Profile,
|
||||
/// Code the frames' alpha channel (4444 profiles with Include Alpha Channel).
|
||||
alpha: bool,
|
||||
w: u32,
|
||||
h: u32,
|
||||
rate: FrameRate,
|
||||
@@ -1022,13 +1025,19 @@ impl VideoEncoder for ProResEncoder {
|
||||
self.rate.num as u32
|
||||
}
|
||||
fn encode(&mut self, f: &EncoderFrame) -> Result<Vec<EncodedPacket>> {
|
||||
let mut fr = filmcraft_prores::Frame::new(f.width, f.height, filmcraft_prores::ChromaFormat::Yuv422, 10, false);
|
||||
timed(Stage::Convert, || match f.hdr {
|
||||
Some(rgb) => {
|
||||
let (kr, kb) = self.signal.kr_kb();
|
||||
rgbf_to_yuv422_10(rgb, f.width as usize, f.height as usize, kr, kb, &mut fr.y, &mut fr.cb, &mut fr.cr)
|
||||
let chroma = self.profile.chroma();
|
||||
let mut fr = filmcraft_prores::Frame::new(f.width, f.height, chroma, 10, self.alpha);
|
||||
let (w, h) = (f.width as usize, f.height as usize);
|
||||
timed(Stage::Convert, || match (chroma, f.hdr) {
|
||||
(filmcraft_prores::ChromaFormat::Yuv444, hdr) => {
|
||||
let (kr, kb) = if hdr.is_some() { self.signal.kr_kb() } else { (0.2126, 0.0722) };
|
||||
to_yuv444_10(f.rgba, hdr, w, h, kr, kb, &mut fr.y, &mut fr.cb, &mut fr.cr, fr.alpha.as_deref_mut())
|
||||
}
|
||||
None => rgba_to_yuv422_10(f.rgba, f.width as usize, f.height as usize, &mut fr.y, &mut fr.cb, &mut fr.cr),
|
||||
(_, Some(rgb)) => {
|
||||
let (kr, kb) = self.signal.kr_kb();
|
||||
rgbf_to_yuv422_10(rgb, w, h, kr, kb, &mut fr.y, &mut fr.cb, &mut fr.cr)
|
||||
}
|
||||
(_, None) => rgba_to_yuv422_10(f.rgba, w, h, &mut fr.y, &mut fr.cb, &mut fr.cr),
|
||||
});
|
||||
let data = self.enc.encode(&fr).map_err(|e| ExportError::Encode(e.to_string()))?;
|
||||
Ok(vec![EncodedPacket { data, key: true, duration: self.rate.den as u32, composition_offset: 0 }])
|
||||
@@ -1038,6 +1047,47 @@ impl VideoEncoder for ProResEncoder {
|
||||
}
|
||||
}
|
||||
|
||||
/// Limited-range 10-bit 4:4:4 with matrix (Kr, Kb) from straight RGBA8, or from encoded R'G'B'
|
||||
/// floats (3 per pixel) when `hdr` is given. `alpha` (full range, 0 = transparent) takes the
|
||||
/// RGBA8 alpha; it stays opaque for float input, which carries none.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn to_yuv444_10(
|
||||
rgba: &[u8],
|
||||
hdr: Option<&[f32]>,
|
||||
w: usize,
|
||||
h: usize,
|
||||
kr: f32,
|
||||
kb: f32,
|
||||
y: &mut [u16],
|
||||
cb: &mut [u16],
|
||||
cr: &mut [u16],
|
||||
alpha: Option<&mut [u16]>,
|
||||
) {
|
||||
let (w, n) = (w.max(1), w.saturating_mul(h));
|
||||
let kg = 1.0 - kr - kb;
|
||||
let (sb, sr) = (2.0 * (1.0 - kb), 2.0 * (1.0 - kr));
|
||||
let rgb_at = |i: usize| -> [f32; 3] {
|
||||
match hdr {
|
||||
Some(f) => f.get(i * 3..i * 3 + 3).map_or([0.0; 3], |p| [p[0], p[1], p[2]]),
|
||||
None => rgba.get(i * 4..i * 4 + 3).map_or([0.0; 3], |p| [p[0] as f32 / 255.0, p[1] as f32 / 255.0, p[2] as f32 / 255.0]),
|
||||
}
|
||||
};
|
||||
y.par_chunks_mut(w).zip(cb.par_chunks_mut(w).zip(cr.par_chunks_mut(w))).enumerate().for_each(|(row, (yr, (cbr, crr)))| {
|
||||
for (x, (yv, (cbv, crv))) in yr.iter_mut().zip(cbr.iter_mut().zip(crr.iter_mut())).enumerate() {
|
||||
let [r, g, b] = rgb_at(row * w + x);
|
||||
let yy = kr * r + kg * g + kb * b;
|
||||
*yv = (64.0 + 876.0 * yy).round().clamp(4.0, 1019.0) as u16;
|
||||
*cbv = (512.0 + 896.0 * (b - yy) / sb).round().clamp(4.0, 1019.0) as u16;
|
||||
*crv = (512.0 + 896.0 * (r - yy) / sr).round().clamp(4.0, 1019.0) as u16;
|
||||
}
|
||||
});
|
||||
if let (Some(a), None) = (alpha, hdr) {
|
||||
for (i, dst) in a.iter_mut().enumerate().take(n) {
|
||||
*dst = rgba.get(i * 4 + 3).map_or(1023, |&v| ((v as u32 * 1023 + 127) / 255) as u16);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// BT.709 limited-range 10-bit 4:2:2 from straight RGBA8 (chroma averaged horizontally).
|
||||
pub fn rgba_to_yuv422_10(rgba: &[u8], w: usize, h: usize, y: &mut [u16], cb: &mut [u16], cr: &mut [u16]) {
|
||||
let cw = w.div_ceil(2);
|
||||
@@ -1071,6 +1121,8 @@ pub fn prores_profile(name: &str) -> filmcraft_prores::Profile {
|
||||
"proxy" => Profile::Proxy,
|
||||
"lt" => Profile::Lt,
|
||||
"standard" | "422" => Profile::Standard,
|
||||
"4444" => Profile::P4444,
|
||||
"4444xq" | "4444 xq" | "4444-xq" | "xq" => Profile::P4444Xq,
|
||||
_ => Profile::Hq,
|
||||
}
|
||||
}
|
||||
@@ -1109,7 +1161,10 @@ fn prores_factory(format: Format, w: u32, h: u32, rate: FrameRate, s: &ExportSet
|
||||
if s.signal.is_hdr() {
|
||||
cfg.color = filmcraft_prores::ColorInfo { primaries: s.signal.primaries, transfer: s.signal.transfer, matrix: s.signal.matrix };
|
||||
}
|
||||
Ok(Box::new(ProResEncoder { enc: filmcraft_prores::Encoder::with_config(cfg), profile, w, h, rate, signal: s.signal }) as Box<dyn VideoEncoder>)
|
||||
let alpha = s.keeps_alpha();
|
||||
cfg.encode_alpha = alpha;
|
||||
let enc = filmcraft_prores::Encoder::with_config(cfg);
|
||||
Ok(Box::new(ProResEncoder { enc, profile, alpha, w, h, rate, signal: s.signal }) as Box<dyn VideoEncoder>)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -38,7 +38,9 @@ fn picture(settings: &ExportSettings, w: u32, h: u32) -> PictureDesc {
|
||||
Profile::Proxy => 1,
|
||||
Profile::Lt => 2,
|
||||
Profile::Standard => 3,
|
||||
_ => 4,
|
||||
Profile::Hq => 4,
|
||||
Profile::P4444 => 5,
|
||||
Profile::P4444Xq => 6,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -53,9 +55,6 @@ fn picture(settings: &ExportSettings, w: u32, h: u32) -> PictureDesc {
|
||||
_ => PictureCoding::Vc3 { cid: crate::dnx_profile(&settings.dnx_profile).cid() },
|
||||
};
|
||||
let mut d = PictureDesc::new(coding, w, h);
|
||||
if matches!(coding, PictureCoding::ProRes { .. }) {
|
||||
d.depth = 10;
|
||||
}
|
||||
d.color = match settings.signal {
|
||||
s if s == ColorSignal::PQ => ColorSpace::Rec2020Pq,
|
||||
s if s == ColorSignal::HLG => ColorSpace::Rec2020Hlg,
|
||||
|
||||
@@ -12,7 +12,7 @@ use filmcraft_time::FrameRate;
|
||||
use rayon::prelude::*;
|
||||
|
||||
use crate::settings::{ExportEffects, Placement, Scaling, TextOverlay};
|
||||
use crate::{ExportError, ExportSettings, Format, FrameRenderer, GpuRendering, Result, build_frame_renderer, note_gpu_fallback, note_gpu_frame};
|
||||
use crate::{ExportError, ExportSettings, FrameRenderer, GpuRendering, Result, build_frame_renderer, note_gpu_fallback, note_gpu_frame};
|
||||
|
||||
/// Where the rendered picture lands in the output frame.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
@@ -201,7 +201,7 @@ impl Pipeline {
|
||||
geom,
|
||||
opts,
|
||||
hdr_out,
|
||||
alpha: settings.alpha && matches!(settings.format, Format::PngSequence | Format::TiffSequence),
|
||||
alpha: settings.keeps_alpha(),
|
||||
out_tf,
|
||||
effects,
|
||||
overlay,
|
||||
|
||||
@@ -122,6 +122,14 @@ pub fn builtin_presets() -> Vec<ExportPreset> {
|
||||
p("Apple ProRes 422", bc, "QuickTime, ProRes 422, 24-bit 48 kHz PCM", mov(Format::ProRes, "standard")),
|
||||
p("Apple ProRes 422 LT", bc, "QuickTime, ProRes 422 LT, 24-bit 48 kHz PCM", mov(Format::ProRes, "lt")),
|
||||
p("Apple ProRes 422 Proxy", bc, "QuickTime, ProRes 422 Proxy, 24-bit 48 kHz PCM", mov(Format::ProRes, "proxy")),
|
||||
p("Apple ProRes 4444", bc, "QuickTime, ProRes 4444 (4:4:4), 24-bit 48 kHz PCM", mov(Format::ProRes, "4444")),
|
||||
p(
|
||||
"Apple ProRes 4444 with Alpha",
|
||||
bc,
|
||||
"QuickTime, ProRes 4444 (4:4:4) with alpha channel, 24-bit 48 kHz PCM",
|
||||
ExportSettings { alpha: true, ..mov(Format::ProRes, "4444") },
|
||||
),
|
||||
p("Apple ProRes 4444 XQ", bc, "QuickTime, ProRes 4444 XQ (4:4:4), 24-bit 48 kHz PCM", mov(Format::ProRes, "4444xq")),
|
||||
p("Avid DNxHR HQ", bc, "QuickTime, DNxHR HQ (8-bit 4:2:2), 24-bit 48 kHz PCM", mov(Format::DnxHr, "hq")),
|
||||
p("Avid DNxHR SQ", bc, "QuickTime, DNxHR SQ (8-bit 4:2:2), 24-bit 48 kHz PCM", mov(Format::DnxHr, "sq")),
|
||||
p("Avid DNxHR LB", bc, "QuickTime, DNxHR LB (8-bit 4:2:2), 24-bit 48 kHz PCM", mov(Format::DnxHr, "lb")),
|
||||
|
||||
@@ -378,6 +378,21 @@ impl ExportSettings {
|
||||
if self.format.is_mxf() { self.mxf_video_codec.encoder_format() } else { self.format }
|
||||
}
|
||||
|
||||
/// Whether the output can carry an alpha channel ([`ExportSettings::alpha`]): PNG and TIFF
|
||||
/// sequences and ProRes 4444 / 4444 XQ QuickTime movies.
|
||||
pub fn supports_alpha(&self) -> bool {
|
||||
match self.format {
|
||||
Format::PngSequence | Format::TiffSequence => true,
|
||||
Format::ProRes => crate::prores_profile(&self.prores_profile).chroma() == filmcraft_prores::ChromaFormat::Yuv444,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the exported frames keep their alpha channel instead of being flattened over black.
|
||||
pub fn keeps_alpha(&self) -> bool {
|
||||
self.alpha && self.supports_alpha()
|
||||
}
|
||||
|
||||
/// Whether the output is a numbered image sequence.
|
||||
pub fn is_image_sequence(&self) -> bool {
|
||||
matches!(self.format, Format::PngSequence | Format::TiffSequence | Format::BmpSequence)
|
||||
@@ -536,7 +551,9 @@ impl ExportSettings {
|
||||
Profile::Proxy => ", ProRes 422 Proxy",
|
||||
Profile::Lt => ", ProRes 422 LT",
|
||||
Profile::Standard => ", ProRes 422",
|
||||
_ => ", ProRes 422 HQ",
|
||||
Profile::Hq => ", ProRes 422 HQ",
|
||||
Profile::P4444 => ", ProRes 4444",
|
||||
Profile::P4444Xq => ", ProRes 4444 XQ",
|
||||
}
|
||||
}
|
||||
Format::DnxHr => v += &format!(", DNxHR {}", if self.dnx_profile.is_empty() { "HQ".into() } else { self.dnx_profile.to_ascii_uppercase() }),
|
||||
|
||||
@@ -101,6 +101,51 @@ fn prores_export_roundtrip() {
|
||||
assert!(f[0] > 240 && f[1] < 15 && f[2] < 15, "{:?}", &f[..4]);
|
||||
}
|
||||
|
||||
/// ProRes 4444 / 4444 XQ export (#342): 4:4:4 frames under the `ap4h` / `ap4x` codes, and with
|
||||
/// Include Alpha Channel the picture's alpha survives instead of being flattened over black.
|
||||
#[test]
|
||||
fn prores_4444_export_roundtrip() {
|
||||
let (p, seq, m) = project();
|
||||
for (profile, label) in [("4444", "ProRes 4444"), ("4444xq", "ProRes 4444 XQ")] {
|
||||
let path = tmp(&format!("pr_{profile}.mov"));
|
||||
let s = ExportSettings { format: Format::ProRes, path: path.clone(), prores_profile: profile.into(), ..Default::default() };
|
||||
export(&p, seq, &s, &m, &Progress::default()).unwrap();
|
||||
let bytes: Arc<[u8]> = std::fs::read(&path).unwrap().into();
|
||||
let src = filmcraft_codecs::open_bytes("pr.mov", bytes).unwrap();
|
||||
let codec = src.info().video.as_ref().unwrap().codec.clone();
|
||||
assert!(codec.contains(label), "{profile}: {codec}");
|
||||
let f = src.video_frame(FrameRequest::full(Tick(TICKS_PER_SECOND / 3))).unwrap();
|
||||
assert!(f.format_label().contains("4:4:4"), "{profile}: {}", f.format_label());
|
||||
let px = f.to_rgba8();
|
||||
assert!(px[0] > 240 && px[1] < 15 && px[2] < 15 && px[3] == 255, "{profile}: {:?}", &px[..4]);
|
||||
}
|
||||
|
||||
// a half-transparent matte keeps its alpha with Include Alpha Channel, and only then
|
||||
let mut p = (*p).clone();
|
||||
let half = GeneratorSource::new(Generator::ColorMatte { color: [1.0, 0.0, 0.0, 0.5] }, 320, 180, FrameRate::FPS_24, Tick(2 * TICKS_PER_SECOND));
|
||||
let item = p.sequence(seq).unwrap().video_tracks[0].items[0].item;
|
||||
if let ItemKind::Media(mc) = &mut p.item_mut(item).unwrap().kind {
|
||||
mc.media = MediaRef::Generator(half.generator.clone());
|
||||
}
|
||||
let mut m = m;
|
||||
m.0.insert(item, Arc::new(half));
|
||||
let p = Arc::new(p);
|
||||
for alpha in [true, false] {
|
||||
let path = tmp(&format!("pr_4444_alpha_{alpha}.mov"));
|
||||
let s = ExportSettings { format: Format::ProRes, path: path.clone(), prores_profile: "4444".into(), alpha, ..Default::default() };
|
||||
assert!(s.keeps_alpha() == alpha);
|
||||
export(&p, seq, &s, &m, &Progress::default()).unwrap();
|
||||
let bytes: Arc<[u8]> = std::fs::read(&path).unwrap().into();
|
||||
let src = filmcraft_codecs::open_bytes("pr.mov", bytes).unwrap();
|
||||
let px = src.video_frame(FrameRequest::full(Tick(TICKS_PER_SECOND / 3))).unwrap().to_rgba8();
|
||||
if alpha {
|
||||
assert!((px[3] as i32 - 128).abs() <= 3, "alpha kept: {:?}", &px[..4]);
|
||||
} else {
|
||||
assert_eq!(px[3], 255, "flattened: {:?}", &px[..4]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dnxhr_export_roundtrip() {
|
||||
let (p, seq, m) = project();
|
||||
|
||||
@@ -37,6 +37,8 @@
|
||||
//! `audio` objects read as stream zero; older builds must refuse new multi-stream projects.
|
||||
//! - **v14** (M7.9): narrations (`Project::narrations`, Text to Speech). No-op step; older builds
|
||||
//! would drop them when saving.
|
||||
//! - **v15**: project notes (`Project::notes`, Window ▸ Project Notes). No-op step; older builds
|
||||
//! would drop them when saving.
|
||||
|
||||
#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))]
|
||||
|
||||
@@ -58,7 +60,7 @@ pub type Migration = fn(Value) -> Result<Value, String>;
|
||||
/// `MIGRATIONS[i]` upgrades schema `i + 1` to `i + 2`. Append one function per schema bump; never
|
||||
/// edit a shipped one.
|
||||
pub const MIGRATIONS: &[Migration] =
|
||||
&[v1_to_v2, v2_to_v3, v3_to_v4, v4_to_v5, v5_to_v6, v6_to_v7, v7_to_v8, v8_to_v9, v9_to_v10, v10_to_v11, v11_to_v12, v12_to_v13, v13_to_v14];
|
||||
&[v1_to_v2, v2_to_v3, v3_to_v4, v4_to_v5, v5_to_v6, v6_to_v7, v7_to_v8, v8_to_v9, v9_to_v10, v10_to_v11, v11_to_v12, v12_to_v13, v13_to_v14, v14_to_v15];
|
||||
|
||||
/// The schema version this build writes (and the newest it reads).
|
||||
pub const SCHEMA_VERSION: u32 = MIGRATIONS.len() as u32 + 1;
|
||||
@@ -354,13 +356,18 @@ fn v13_to_v14(doc: Value) -> Result<Value, String> {
|
||||
Ok(doc)
|
||||
}
|
||||
|
||||
/// v14 → v15: project notes. Existing data needs no change.
|
||||
fn v14_to_v15(doc: Value) -> Result<Value, String> {
|
||||
Ok(doc)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn schema_version_matches_table() {
|
||||
assert_eq!(SCHEMA_VERSION, 14);
|
||||
assert_eq!(SCHEMA_VERSION, 15);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -213,7 +213,7 @@ fn steps(op: &FxOp) -> Vec<Step> {
|
||||
let w0 = 1.0 - wh * 0.15;
|
||||
let sh_k = sh * 0.35;
|
||||
let hl_k = hl * 0.35;
|
||||
let vround_aspect = if *vround < 0.0 { aspect.powf(-vround) } else { 1.0 };
|
||||
let vround_aspect = filmcraft_render::gpufx::lumetri_vignette_aspect(*aspect, *vround);
|
||||
let st_k = [(st[0] - 0.5) * 0.3, (st[1] - 0.5) * 0.3, (st[2] - 0.5) * 0.3];
|
||||
let ht_k = [(ht[0] - 0.5) * 0.3, (ht[1] - 0.5) * 0.3, (ht[2] - 0.5) * 0.3];
|
||||
|
||||
|
||||
@@ -96,64 +96,10 @@ pub fn cancelled() -> bool {
|
||||
CURRENT.with(|c| c.borrow().as_ref().is_some_and(|f| f.load(Ordering::Relaxed)))
|
||||
}
|
||||
|
||||
/// The hints of the work running on this thread ([`with_cancel`], [`with_catch_up`],
|
||||
/// [`with_draft`], [`with_background`]), to carry them to another thread doing part of that work.
|
||||
#[derive(Clone, Default)]
|
||||
pub struct Context {
|
||||
cancel: Option<Arc<AtomicBool>>,
|
||||
catch_up: Option<Tick>,
|
||||
draft: bool,
|
||||
background: bool,
|
||||
}
|
||||
|
||||
impl Context {
|
||||
/// This thread's hints.
|
||||
pub fn current() -> Self {
|
||||
Context { cancel: CURRENT.with(|c| c.borrow().clone()), catch_up: catch_up(), draft: draft(), background: background() }
|
||||
}
|
||||
|
||||
/// Run `f` with these hints, restoring this thread's however `f` ends (a pool thread must
|
||||
/// not keep another job's hints after a panic).
|
||||
pub fn run<R>(self, f: impl FnOnce() -> R) -> R {
|
||||
struct Restore(Context);
|
||||
impl Drop for Restore {
|
||||
fn drop(&mut self) {
|
||||
let prev = std::mem::take(&mut self.0);
|
||||
CURRENT.with(|c| *c.borrow_mut() = prev.cancel);
|
||||
CATCH_UP.with(|c| c.set(prev.catch_up));
|
||||
DRAFT.with(|c| c.set(prev.draft));
|
||||
BACKGROUND.with(|c| c.set(prev.background));
|
||||
}
|
||||
}
|
||||
let _restore = Restore(Context::current());
|
||||
CURRENT.with(|c| *c.borrow_mut() = self.cancel);
|
||||
CATCH_UP.with(|c| c.set(self.catch_up));
|
||||
DRAFT.with(|c| c.set(self.draft));
|
||||
BACKGROUND.with(|c| c.set(self.background));
|
||||
f()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn context_carries_the_hints_to_another_thread_and_restores_its_own() {
|
||||
let flag = Arc::new(AtomicBool::new(true));
|
||||
let ctx = with_cancel(&flag, || with_draft(true, || with_background(true, || with_catch_up(Some(Tick(5)), Context::current))));
|
||||
std::thread::scope(|s| {
|
||||
s.spawn(|| {
|
||||
let seen = with_draft(false, || ctx.clone().run(|| (cancelled(), draft(), background(), catch_up())));
|
||||
assert_eq!(seen, (true, true, true, Some(Tick(5))));
|
||||
// a panic inside leaves this thread's own hints in place
|
||||
let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| ctx.clone().run(|| panic!("job"))));
|
||||
assert!(r.is_err());
|
||||
assert_eq!((cancelled(), draft(), background(), catch_up()), (false, false, false, None));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flag_is_scoped_to_the_closure() {
|
||||
let flag = Arc::new(AtomicBool::new(false));
|
||||
|
||||
@@ -230,3 +230,17 @@ impl MediaSource for OfflineSource {
|
||||
Ok(AudioBuffer::silence(sample_rate, self.info.audio().map_or(2, |a| a.channels as usize), frames))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod importable_tests {
|
||||
use super::*;
|
||||
use std::path::Path;
|
||||
|
||||
#[test]
|
||||
fn camera_extensions_match_regardless_of_case() {
|
||||
for name in ["clip.mov", "clip.MOV", "clip.Mov", "shot.MP4", "IMG_0001.JPG", "sound.WAV", "take.M2TS"] {
|
||||
assert!(is_importable(Path::new(name)), "{name}");
|
||||
}
|
||||
assert!(!is_importable(Path::new("notes.txt")));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,7 +75,15 @@ impl Numbered {
|
||||
|
||||
/// The frame number of a sibling file name of this sequence (same prefix, suffix and padding).
|
||||
pub fn number_of(&self, file_name: &str) -> Option<u64> {
|
||||
let mid = file_name.strip_prefix(self.prefix.as_str())?.strip_suffix(self.suffix.as_str())?;
|
||||
let rest = file_name.strip_prefix(self.prefix.as_str())?;
|
||||
let mid = {
|
||||
let n = rest.as_bytes();
|
||||
let s = self.suffix.as_bytes();
|
||||
if n.len() < s.len() || !n[n.len() - s.len()..].eq_ignore_ascii_case(s) {
|
||||
return None;
|
||||
}
|
||||
&rest[..n.len() - s.len()]
|
||||
};
|
||||
if mid.is_empty() || !mid.bytes().all(|b| b.is_ascii_digit()) || mid.len() > 18 {
|
||||
return None;
|
||||
}
|
||||
@@ -231,6 +239,7 @@ mod tests {
|
||||
assert!(!u.padded());
|
||||
assert_eq!(u.file_name(10), "frame10.TIF");
|
||||
assert_eq!(u.number_of("frame10.TIF"), Some(10));
|
||||
assert_eq!(u.number_of("frame10.tif"), Some(10), "Linux stills often mix .TIF and .tif");
|
||||
assert_eq!(Numbered::parse("clip.mov"), None, "not a still");
|
||||
assert_eq!(Numbered::parse("nodigits.png"), None);
|
||||
let w = Numbered::parse("C:\\renders\\out.0010.exr.png").unwrap();
|
||||
|
||||
@@ -1253,6 +1253,10 @@ pub struct Project {
|
||||
/// made from, keyed by that item. Schema v14.
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub narrations: BTreeMap<ItemId, Narration>,
|
||||
/// Project notes (Window ▸ Project Notes): free plain text kept with the project, for to-dos,
|
||||
/// instructions for whoever opens it next, frame numbers and the like. Schema v15.
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub notes: String,
|
||||
}
|
||||
|
||||
/// How a sequence is shown in the Timeline panel: its zoom, scroll position and track heights.
|
||||
@@ -1348,6 +1352,7 @@ impl Project {
|
||||
search_bins: Vec::new(),
|
||||
source_graphics: BTreeMap::new(),
|
||||
narrations: BTreeMap::new(),
|
||||
notes: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1380,20 +1385,35 @@ impl Project {
|
||||
id
|
||||
}
|
||||
|
||||
/// Move items into a bin (the root when `bin` is None). Returns how many moved; unknown items
|
||||
/// are skipped. Fails (returns None) when the bin does not exist.
|
||||
/// Move items, and bins named by id, into a bin (the root when `bin` is None). A bin goes with
|
||||
/// everything in it. Returns how many moved; unknown ids are skipped, and so is a bin that
|
||||
/// would land in itself or in one of its own sub-bins. Fails (returns None) when the target
|
||||
/// bin does not exist.
|
||||
pub fn move_to_bin(&mut self, items: &[ItemId], bin: Option<BinId>) -> Option<usize> {
|
||||
let target = bin.unwrap_or(self.root.id);
|
||||
self.root.find_bin(target)?;
|
||||
let mut moved = 0;
|
||||
for &id in items {
|
||||
if !self.items.contains_key(&id) || !self.root.remove_item(id) {
|
||||
continue;
|
||||
}
|
||||
if let Some(b) = self.root.find_bin_mut(target) {
|
||||
b.children.push(BinEntry::Item(id));
|
||||
moved += 1;
|
||||
let entry = if self.items.contains_key(&id) {
|
||||
if !self.root.remove_item(id) {
|
||||
continue;
|
||||
}
|
||||
BinEntry::Item(id)
|
||||
} else {
|
||||
let b = BinId(id.0);
|
||||
// never the root, nor a bin that holds the target (or is it)
|
||||
if b == self.root.id || self.root.find_bin(b).is_none_or(|x| x.find_bin(target).is_some()) {
|
||||
continue;
|
||||
}
|
||||
let Some(sub) = self.root.remove_bin(b) else { continue };
|
||||
BinEntry::Bin(sub)
|
||||
};
|
||||
match self.root.find_bin_mut(target) {
|
||||
Some(t) => t.children.push(entry),
|
||||
// cannot happen (the target is outside what was taken out), but never lose it
|
||||
None => self.root.children.push(entry),
|
||||
}
|
||||
moved += 1;
|
||||
}
|
||||
Some(moved)
|
||||
}
|
||||
@@ -1994,6 +2014,29 @@ mod tests {
|
||||
assert_eq!(p.root.parent_of(i), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bin_moves_into_another_bin_with_its_contents() {
|
||||
let mut p = Project::new("x");
|
||||
let outer = p.add_bin("Footage", None);
|
||||
let inner = p.add_bin("B-Roll", None);
|
||||
let deep = p.add_bin("Drone", Some(inner));
|
||||
let i = p.add_item("a", Label::Iris, ItemKind::AdjustmentLayer { width: 10, height: 10, rate: FrameRate::FPS_24, duration: Tick(1) }, Some(deep));
|
||||
assert_eq!(p.move_to_bin(&[ItemId(inner.0)], Some(outer)), Some(1));
|
||||
let o = p.root.find_bin(outer).unwrap();
|
||||
assert!(o.children.iter().any(|c| matches!(c, BinEntry::Bin(b) if b.id == inner)));
|
||||
assert!(!p.root.children.iter().any(|c| matches!(c, BinEntry::Bin(b) if b.id == inner)));
|
||||
assert_eq!(p.root.parent_of(i), Some(deep));
|
||||
assert!(o.find_bin(deep).is_some());
|
||||
// never into itself or its own sub-bin, and the root never moves
|
||||
assert_eq!(p.move_to_bin(&[ItemId(outer.0)], Some(outer)), Some(0));
|
||||
assert_eq!(p.move_to_bin(&[ItemId(outer.0)], Some(deep)), Some(0));
|
||||
assert_eq!(p.move_to_bin(&[ItemId(p.root.id.0)], Some(outer)), Some(0));
|
||||
assert!(p.root.find_bin(outer).unwrap().find_bin(deep).is_some());
|
||||
// and back out to the root
|
||||
assert_eq!(p.move_to_bin(&[ItemId(inner.0)], None), Some(1));
|
||||
assert!(p.root.children.iter().any(|c| matches!(c, BinEntry::Bin(b) if b.id == inner)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_clone_shares_sequences_until_mutated() {
|
||||
fn sequence_arc(p: &Project, id: ItemId) -> &std::sync::Arc<Sequence> {
|
||||
|
||||
@@ -44,12 +44,11 @@ pub(crate) fn mapping(id: &str) -> Option<Mapping> {
|
||||
},
|
||||
"hard_limiter" => Mapping {
|
||||
dsp: "limiter",
|
||||
preroll: |e, t| (f(e, "lookahead", t) as f64 / 1000.0 + f(e, "release", t) as f64 / 1000.0 * 5.0).clamp(0.05, 2.0),
|
||||
preroll: |e, t| (f(e, "release", t) as f64 / 1000.0 * 5.0).clamp(0.05, 2.0),
|
||||
apply: |d, e, t| {
|
||||
d.set_param("ceiling", f(e, "max", t));
|
||||
d.set_param("input_gain", f(e, "boost", t).max(0.0));
|
||||
d.set_param("release", f(e, "release", t));
|
||||
d.set_param("lookahead", f(e, "lookahead", t));
|
||||
},
|
||||
},
|
||||
"delay" => Mapping {
|
||||
|
||||
@@ -679,7 +679,7 @@ fn lumetri(img: &mut Image, e: &EffectInstance, cx: &FxCtx) {
|
||||
v = v.map(|q| l3 + (q - l3) * s);
|
||||
// vignette
|
||||
if va.abs() > 1e-4 {
|
||||
let nx = (x as f32 / w - 0.5) * 2.0 * (1.0 + vround * 0.0) * if vround < 0.0 { aspect.powf(-vround) } else { 1.0 };
|
||||
let nx = (x as f32 / w - 0.5) * 2.0 * crate::gpufx::lumetri_vignette_aspect(aspect, vround);
|
||||
let ny = (y as f32 / h - 0.5) * 2.0;
|
||||
let d = (nx * nx + ny * ny).sqrt() / std::f32::consts::SQRT_2;
|
||||
let edge = ((d - vmid * 0.9) / (vfeather.max(0.01) * 0.9)).clamp(0.0, 1.0);
|
||||
@@ -1515,4 +1515,57 @@ mod lumetri_cpu_parity_tests {
|
||||
eprintln!("{name}: max sample diff vs fn lumetri: {max_diff:.2e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #719: positive Roundness rounds the Lumetri vignette (it used to be ignored), +100
|
||||
/// is a circle and negative values widen the oval, on the CPU path and the GPU op alike.
|
||||
#[test]
|
||||
fn lumetri_vignette_roundness_shapes_the_vignette() {
|
||||
let (w, h) = (80usize, 40usize);
|
||||
let cx = FxCtx {
|
||||
t: Tick::ZERO,
|
||||
px_scale: 1.0,
|
||||
seconds: 0.0,
|
||||
timecode: "",
|
||||
clip_name: "",
|
||||
project: None,
|
||||
env: None,
|
||||
working: filmcraft_color::WorkingSpace::Rec709,
|
||||
};
|
||||
let render = |roundness: f64| {
|
||||
let e = effect(&[
|
||||
("basic_on", ParamValue::Bool(false)),
|
||||
("creative_on", ParamValue::Bool(false)),
|
||||
("vignette_amount", fl(-3.0)),
|
||||
("vignette_roundness", fl(roundness)),
|
||||
]);
|
||||
let grey = Image { w, h, px: [0.5, 0.5, 0.5, 1.0].repeat(w * h) };
|
||||
let mut cpu = grey.clone();
|
||||
lumetri(&mut cpu, &e, &cx);
|
||||
let mut op_img = grey;
|
||||
let op = FxOp::eval(&e, &cx, w, h).expect("lumetri op");
|
||||
op.apply(&mut op_img);
|
||||
for (a, b) in cpu.px.iter().zip(&op_img.px) {
|
||||
assert!((a - b).abs() <= 1e-6, "roundness {roundness}: CPU and GPU op disagree");
|
||||
}
|
||||
cpu
|
||||
};
|
||||
let red = |img: &Image, x: usize, y: usize| img.px[(y * w + x) * 4];
|
||||
// 16 px right of and 16 px below the centre (40, 20)
|
||||
let (right, below) = ((56, 20), (40, 36));
|
||||
|
||||
let ellipse = render(0.0);
|
||||
assert!(red(&ellipse, right.0, right.1) > red(&ellipse, below.0, below.1) + 0.01, "0 follows the frame");
|
||||
|
||||
let circle = render(100.0);
|
||||
assert_ne!(circle.px, ellipse.px, "positive roundness changes the vignette");
|
||||
let (r, b) = (red(&circle, right.0, right.1), red(&circle, below.0, below.1));
|
||||
assert!((r - b).abs() < 1e-4, "+100 is a circle: {r} vs {b}");
|
||||
assert!(r < red(&ellipse, right.0, right.1), "+100 darkens the sides more than 0");
|
||||
let half = render(50.0);
|
||||
assert!(red(&half, 70, 20) < red(&ellipse, 70, 20), "+50 darkens the sides more than 0");
|
||||
assert!(red(&half, 70, 20) > red(&circle, 70, 20), "+50 darkens the sides less than +100");
|
||||
|
||||
let oval = render(-100.0);
|
||||
assert!(red(&oval, 79, 20) > red(&ellipse, 79, 20), "negative values widen the oval");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -981,7 +981,7 @@ impl FxOp {
|
||||
v = v.map(|q| l3 + (q - l3) * s);
|
||||
// vignette
|
||||
if vignette_on && va.abs() > 1e-4 {
|
||||
let nx = (x as f32 / w - 0.5) * 2.0 * (1.0 + vround * 0.0) * if vround < 0.0 { aspect.powf(-vround) } else { 1.0 };
|
||||
let nx = (x as f32 / w - 0.5) * 2.0 * lumetri_vignette_aspect(aspect, vround);
|
||||
let ny = (y as f32 / h - 0.5) * 2.0;
|
||||
let d = (nx * nx + ny * ny).sqrt() / std::f32::consts::SQRT_2;
|
||||
let edge = ((d - vmid * 0.9) / (vfeather.max(0.01) * 0.9)).clamp(0.0, 1.0);
|
||||
@@ -996,6 +996,17 @@ impl FxOp {
|
||||
}
|
||||
}
|
||||
|
||||
/// Horizontal scale of the Lumetri vignette's coordinates for a Roundness of `vround`
|
||||
/// (−1 … 1) on a frame `aspect` wide. At 0 the vignette is an ellipse that follows the
|
||||
/// frame; positive values move it towards a circle (1 = a true circle), negative values
|
||||
/// stretch it into a wider oval. Non-finite or out-of-range input never yields NaN.
|
||||
pub fn lumetri_vignette_aspect(aspect: f32, vround: f32) -> f32 {
|
||||
if !aspect.is_finite() || aspect <= 0.0 || !vround.is_finite() {
|
||||
return 1.0;
|
||||
}
|
||||
aspect.powf(vround.clamp(-1.0, 1.0))
|
||||
}
|
||||
|
||||
/// sRGB encoding that keeps values above 1 (super-whites) instead of clamping them.
|
||||
#[inline]
|
||||
fn linear_to_enc(v: f32) -> f32 {
|
||||
|
||||
@@ -301,16 +301,6 @@ fn audio_fx_limiter_holds_ceiling() {
|
||||
assert!(peak <= ceiling * 1.01 && peak > ceiling * 0.7, "peak {peak} vs ceiling {ceiling}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_fx_limiter_lookahead_changes_the_mix() {
|
||||
let (p0, seq0, map0) = tone_with(&[("amplify", &[("gain", 30.0)]), ("hard_limiter", &[("max", -6.0), ("lookahead", 0.0)])]);
|
||||
let (p1, seq1, map1) = tone_with(&[("amplify", &[("gain", 30.0)]), ("hard_limiter", &[("max", -6.0), ("lookahead", 30.0)])]);
|
||||
let a = mix(&p0, seq0, &map0, 0, 48_000);
|
||||
let b = mix(&p1, seq1, &map1, 0, 48_000);
|
||||
let max = a.iter().zip(&b).map(|(x, y)| (x - y).abs()).fold(0f32, f32::max);
|
||||
assert!(max > 1e-4, "look-ahead of 0 ms and 30 ms mixed the same clip");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_fx_chain_is_continuous_and_random_access_matches() {
|
||||
let (p, seq, map) = tone_with(&[("delay", &[("delay", 0.05), ("feedback", 50.0), ("mix", 50.0)])]);
|
||||
|
||||
@@ -37,10 +37,11 @@ pub enum PanelKind {
|
||||
Progress,
|
||||
ReferenceMonitor,
|
||||
Timecode,
|
||||
ProjectNotes,
|
||||
}
|
||||
|
||||
impl PanelKind {
|
||||
pub const ALL: [PanelKind; 27] = [
|
||||
pub const ALL: [PanelKind; 28] = [
|
||||
PanelKind::Project,
|
||||
PanelKind::MediaBrowser,
|
||||
PanelKind::Libraries,
|
||||
@@ -68,6 +69,7 @@ impl PanelKind {
|
||||
PanelKind::Progress,
|
||||
PanelKind::ReferenceMonitor,
|
||||
PanelKind::Timecode,
|
||||
PanelKind::ProjectNotes,
|
||||
];
|
||||
pub fn title(self) -> &'static str {
|
||||
match self {
|
||||
@@ -98,6 +100,7 @@ impl PanelKind {
|
||||
PanelKind::Progress => "Progress",
|
||||
PanelKind::ReferenceMonitor => "Reference Monitor",
|
||||
PanelKind::Timecode => "Timecode",
|
||||
PanelKind::ProjectNotes => "Project Notes",
|
||||
}
|
||||
}
|
||||
pub fn id(self) -> String {
|
||||
|
||||
@@ -147,7 +147,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
}
|
||||
|
||||
if resp.clicked() {
|
||||
crate::links::open(ui.ctx(), crate::links::DISCORD);
|
||||
crate::links::open(app, ui.ctx(), crate::links::DISCORD);
|
||||
}
|
||||
}
|
||||
let popup_id = egui::Id::new("workspaces-popup");
|
||||
|
||||
@@ -972,6 +972,7 @@
|
||||
Reset… Restablecer…
|
||||
Source · {name} Origen · {name}
|
||||
Reset Effect Restablecer efecto
|
||||
Reset Parameter Restablecer parámetro
|
||||
Save Preset… Guardar ajuste preestablecido…
|
||||
Custom Setup Configuración personalizada
|
||||
Edit… Editar…
|
||||
@@ -2984,6 +2985,9 @@
|
||||
Apple ProRes 422 HQ Apple ProRes 422 HQ
|
||||
Apple ProRes 422 LT Apple ProRes 422 LT
|
||||
Apple ProRes 422 Proxy Apple ProRes 422 Proxy
|
||||
Apple ProRes 4444 Apple ProRes 4444
|
||||
Apple ProRes 4444 with Alpha Apple ProRes 4444 con alfa
|
||||
Apple ProRes 4444 XQ Apple ProRes 4444 XQ
|
||||
Application Aplicación
|
||||
Apply Guide Template Aplicar plantilla de guías
|
||||
Auto Save Now Guardar automáticamente ahora
|
||||
@@ -3193,6 +3197,9 @@
|
||||
QuickTime, ProRes 422 HQ, 24-bit 48 kHz PCM QuickTime, ProRes 422 HQ, PCM de 24 bits y 48 kHz
|
||||
QuickTime, ProRes 422 LT, 24-bit 48 kHz PCM QuickTime, ProRes 422 LT, PCM de 24 bits y 48 kHz
|
||||
QuickTime, ProRes 422 Proxy, 24-bit 48 kHz PCM QuickTime, ProRes 422 Proxy, PCM de 24 bits y 48 kHz
|
||||
QuickTime, ProRes 4444 (4:4:4), 24-bit 48 kHz PCM QuickTime, ProRes 4444 (4:4:4), PCM de 24 bits y 48 kHz
|
||||
QuickTime, ProRes 4444 (4:4:4) with alpha channel, 24-bit 48 kHz PCM QuickTime, ProRes 4444 (4:4:4) con canal alfa, PCM de 24 bits y 48 kHz
|
||||
QuickTime, ProRes 4444 XQ (4:4:4), 24-bit 48 kHz PCM QuickTime, ProRes 4444 XQ (4:4:4), PCM de 24 bits y 48 kHz
|
||||
QuickTime, ProRes 422, 24-bit 48 kHz PCM QuickTime, ProRes 422, PCM de 24 bits y 48 kHz
|
||||
Quit FilmCraft Salir de FilmCraft
|
||||
Rate Stretch Tool Herramienta Ajuste de velocidad
|
||||
@@ -3524,4 +3531,8 @@
|
||||
Composite on the GPU when available Componer en la GPU cuando esté disponible
|
||||
Text to Speech Texto a voz
|
||||
Edit Narration Editar narración
|
||||
Project Notes Notas del proyecto
|
||||
Get Project Notes Obtener notas del proyecto
|
||||
Edit Project Notes Editar notas del proyecto
|
||||
Notes for this project: to-dos, instructions, frame numbers… Saved with the project. Notas de este proyecto: tareas pendientes, instrucciones, números de fotograma… Se guardan con el proyecto.
|
||||
Use Effect Shutter Angle Usar el ángulo de obturación del efecto
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 3 and column 95.
|
@@ -970,6 +970,7 @@
|
||||
Reset… リセット…
|
||||
Source · {name} ソース · {name}
|
||||
Reset Effect エフェクトをリセット
|
||||
Reset Parameter パラメーターをリセット
|
||||
Save Preset… プリセットを保存…
|
||||
Custom Setup カスタム設定
|
||||
Edit… 編集…
|
||||
@@ -2974,6 +2975,9 @@
|
||||
Apple ProRes 422 HQ Apple ProRes 422 HQ
|
||||
Apple ProRes 422 LT Apple ProRes 422 LT
|
||||
Apple ProRes 422 Proxy Apple ProRes 422 Proxy
|
||||
Apple ProRes 4444 Apple ProRes 4444
|
||||
Apple ProRes 4444 with Alpha Apple ProRes 4444(アルファ付き)
|
||||
Apple ProRes 4444 XQ Apple ProRes 4444 XQ
|
||||
Application アプリケーション
|
||||
Apply Guide Template ガイドテンプレートを適用
|
||||
Auto Save Now 今すぐ自動保存
|
||||
@@ -3183,6 +3187,9 @@
|
||||
QuickTime, ProRes 422 HQ, 24-bit 48 kHz PCM QuickTime、ProRes 422 HQ、24-bit 48 kHz PCM
|
||||
QuickTime, ProRes 422 LT, 24-bit 48 kHz PCM QuickTime、ProRes 422 LT、24-bit 48 kHz PCM
|
||||
QuickTime, ProRes 422 Proxy, 24-bit 48 kHz PCM QuickTime、ProRes 422 Proxy、24-bit 48 kHz PCM
|
||||
QuickTime, ProRes 4444 (4:4:4), 24-bit 48 kHz PCM QuickTime、ProRes 4444 (4:4:4)、24-bit 48 kHz PCM
|
||||
QuickTime, ProRes 4444 (4:4:4) with alpha channel, 24-bit 48 kHz PCM QuickTime、ProRes 4444 (4:4:4) アルファチャンネル付き、24-bit 48 kHz PCM
|
||||
QuickTime, ProRes 4444 XQ (4:4:4), 24-bit 48 kHz PCM QuickTime、ProRes 4444 XQ (4:4:4)、24-bit 48 kHz PCM
|
||||
QuickTime, ProRes 422, 24-bit 48 kHz PCM QuickTime、ProRes 422、24-bit 48 kHz PCM
|
||||
Quit FilmCraft FilmCraft を終了
|
||||
Rate Stretch Tool レートストレッチツール
|
||||
@@ -3512,4 +3519,8 @@
|
||||
Edit Narration ナレーションを編集
|
||||
Clear Transition トランジションを消去
|
||||
Set Transition Duration トランジションのデュレーションを設定
|
||||
Project Notes プロジェクトメモ
|
||||
Get Project Notes プロジェクトメモを取得
|
||||
Edit Project Notes プロジェクトメモを編集
|
||||
Notes for this project: to-dos, instructions, frame numbers… Saved with the project. このプロジェクトのメモ: やること、指示、フレーム番号など… プロジェクトと一緒に保存されます。
|
||||
GPU acceleration is unavailable; preview renders on the CPU GPU アクセラレーションを利用できません。プレビューは CPU でレンダリングされます
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 11 and column 59.
|
@@ -355,3 +355,4 @@
|
||||
|
||||
# System appearance mode menu command.
|
||||
Next Appearance Mode Próximo modo de aparência
|
||||
Project Notes Notas do projeto
|
||||
|
||||
|
@@ -355,3 +355,4 @@
|
||||
Zoom In Збільшити
|
||||
Zoom Out Зменшити
|
||||
Zoom to Sequence Умістити секвенцію
|
||||
Project Notes Нотатки проєкту
|
||||
|
||||
|
@@ -963,6 +963,7 @@
|
||||
Reset… 重置…
|
||||
Source · {name} 源 · {name}
|
||||
Reset Effect 重置效果
|
||||
Reset Parameter 重置参数
|
||||
Save Preset… 保存预设…
|
||||
Custom Setup 自定义设置
|
||||
Edit… 编辑…
|
||||
@@ -2967,6 +2968,9 @@
|
||||
Apple ProRes 422 HQ Apple ProRes 422 HQ
|
||||
Apple ProRes 422 LT Apple ProRes 422 LT
|
||||
Apple ProRes 422 Proxy Apple ProRes 422 Proxy
|
||||
Apple ProRes 4444 Apple ProRes 4444
|
||||
Apple ProRes 4444 with Alpha Apple ProRes 4444(带 Alpha)
|
||||
Apple ProRes 4444 XQ Apple ProRes 4444 XQ
|
||||
Application 应用程序
|
||||
Apply Guide Template 应用参考线模板
|
||||
Auto Save Now 立即自动保存
|
||||
@@ -3176,6 +3180,9 @@
|
||||
QuickTime, ProRes 422 HQ, 24-bit 48 kHz PCM QuickTime,ProRes 422 HQ,24 位 48 kHz PCM
|
||||
QuickTime, ProRes 422 LT, 24-bit 48 kHz PCM QuickTime,ProRes 422 LT,24 位 48 kHz PCM
|
||||
QuickTime, ProRes 422 Proxy, 24-bit 48 kHz PCM QuickTime,ProRes 422 Proxy,24 位 48 kHz PCM
|
||||
QuickTime, ProRes 4444 (4:4:4), 24-bit 48 kHz PCM QuickTime,ProRes 4444 (4:4:4),24 位 48 kHz PCM
|
||||
QuickTime, ProRes 4444 (4:4:4) with alpha channel, 24-bit 48 kHz PCM QuickTime,ProRes 4444 (4:4:4),带 Alpha 通道,24 位 48 kHz PCM
|
||||
QuickTime, ProRes 4444 XQ (4:4:4), 24-bit 48 kHz PCM QuickTime,ProRes 4444 XQ (4:4:4),24 位 48 kHz PCM
|
||||
QuickTime, ProRes 422, 24-bit 48 kHz PCM QuickTime,ProRes 422,24 位 48 kHz PCM
|
||||
Quit FilmCraft 退出 FilmCraft
|
||||
Rate Stretch Tool 速率伸缩工具
|
||||
@@ -3501,6 +3508,10 @@
|
||||
Edit Narration 编辑旁白
|
||||
Clear Transition 清除过渡
|
||||
Set Transition Duration 设置过渡持续时间
|
||||
Project Notes 项目备注
|
||||
Get Project Notes 获取项目备注
|
||||
Edit Project Notes 编辑项目备注
|
||||
Notes for this project: to-dos, instructions, frame numbers… Saved with the project. 此项目的备注:待办事项、说明、帧号等… 随项目一起保存。
|
||||
GPU acceleration is unavailable; preview renders on the CPU GPU 加速不可用;预览在 CPU 上渲染
|
||||
Caption Tracks 字幕轨道
|
||||
Delete Caption Track 删除字幕轨道
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 3 and column 95.
|
@@ -139,6 +139,10 @@ pub struct HostHooks {
|
||||
/// Open a file in its default application, or (`true`) reveal it in the file manager (Edit ▸
|
||||
/// Edit Original, Help ▸ Reveal Log Files).
|
||||
pub open_path: Option<Box<dyn FnMut(&str, bool) -> Result<(), String>>>,
|
||||
/// Open a URL in the system browser (Help menu links, About dialog, Discord button, Import
|
||||
/// screen). Without it, or when it fails, links go through `egui::Context::open_url`, which
|
||||
/// opens a new tab on the web (the native egui backend is built without its `links` feature).
|
||||
pub open_url: Option<Box<dyn FnMut(&str) -> Result<(), String>>>,
|
||||
/// The operating system's light or dark appearance when egui cannot report it (Linux desktops
|
||||
/// whose Wayland compositor sends no theme to winit). Without it, or without an answer, Auto
|
||||
/// uses `egui::Context::system_theme`.
|
||||
|
||||
@@ -31,8 +31,17 @@ pub fn url_for(command: &str) -> Option<&'static str> {
|
||||
ALL.iter().find(|(id, _, _)| *id == command).map(|(_, _, u)| *u)
|
||||
}
|
||||
|
||||
/// Open `url` in the system browser (a new tab on the web).
|
||||
pub fn open(ctx: &egui::Context, url: &str) {
|
||||
/// Open `url` in the system browser (a new tab on the web). The desktop host's
|
||||
/// [`HostHooks::open_url`](crate::HostHooks::open_url) opens it; without the hook, or when it
|
||||
/// fails, it goes through `ctx.open_url`, which only does something on the web: the native egui
|
||||
/// backend is built without its `links` feature, so there it would only log (#642).
|
||||
pub fn open(app: &mut crate::FilmcraftApp, ctx: &egui::Context, url: &str) {
|
||||
if let Some(f) = app.hooks.open_url.as_mut() {
|
||||
match f(url) {
|
||||
Ok(()) => return,
|
||||
Err(e) => log::warn!("can't open {url} in the browser: {e}"),
|
||||
}
|
||||
}
|
||||
ctx.open_url(egui::OpenUrl::new_tab(url));
|
||||
}
|
||||
|
||||
@@ -48,4 +57,35 @@ mod tests {
|
||||
assert_eq!(url_for("help.discord"), Some(DISCORD));
|
||||
assert_eq!(url_for("help.nope"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn links_open_through_the_host_hook() {
|
||||
// #642: on the desktop every link (Help menu, About, Discord button, Import screen) must
|
||||
// reach the host's browser opener; `ctx.open_url` alone is a no-op natively.
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
let opened: Rc<RefCell<Vec<String>>> = Rc::default();
|
||||
let rec = opened.clone();
|
||||
let mut app = crate::FilmcraftApp::new(filmcraft_engine::Session::default());
|
||||
app.hooks.open_url = Some(Box::new(move |u: &str| {
|
||||
rec.borrow_mut().push(u.to_string());
|
||||
Ok(())
|
||||
}));
|
||||
let ctx = egui::Context::default();
|
||||
for (id, _, url) in ALL {
|
||||
let r = crate::menus::invoke(&mut app, &ctx, id, serde_json::json!({})).unwrap();
|
||||
assert_eq!(r["url"], url, "{id}");
|
||||
assert_eq!(opened.borrow().last().map(String::as_str), Some(url), "{id}");
|
||||
}
|
||||
open(&mut app, &ctx, DISCORD);
|
||||
assert_eq!(opened.borrow().last().map(String::as_str), Some(DISCORD));
|
||||
assert_eq!(opened.borrow().len(), ALL.len() + 1);
|
||||
|
||||
// A failing opener falls back to `ctx.open_url` instead of losing the click.
|
||||
app.hooks.open_url = Some(Box::new(|_: &str| Err("no browser".into())));
|
||||
open(&mut app, &ctx, ISSUES);
|
||||
let fallback: Vec<String> =
|
||||
ctx.output(|o| o.commands.iter().filter_map(|c| if let egui::OutputCommand::OpenUrl(u) = c { Some(u.url.clone()) } else { None }).collect());
|
||||
assert_eq!(fallback, [ISSUES]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -405,7 +405,7 @@ pub fn invoke(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, mut params:
|
||||
}
|
||||
id if crate::links::url_for(id).is_some() => {
|
||||
let url = crate::links::url_for(id).unwrap_or_default();
|
||||
crate::links::open(ctx, url);
|
||||
crate::links::open(app, ctx, url);
|
||||
app.ui.status = tlf!("Opened {url}", url);
|
||||
return Ok(json!({"url": url}));
|
||||
}
|
||||
@@ -483,6 +483,15 @@ pub fn invoke(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, mut params:
|
||||
crate::panels::color_dialogs::open_sequence(app);
|
||||
return Ok(json!({"dialog": "sequenceColor"}));
|
||||
}
|
||||
// New Bin from menus, shortcuts and the Project panel asks for the name (inline rename);
|
||||
// agents pass `name` to name the bin directly
|
||||
"file.newBin" if params.get("name").is_none() => {
|
||||
let r = crate::panels::project::new_bin(app, ¶ms);
|
||||
if let Err(e) = &r {
|
||||
app.ui.status = e.clone();
|
||||
}
|
||||
return r;
|
||||
}
|
||||
// From menus/shortcuts (no params) these ask first; agents pass params to act directly.
|
||||
"file.revert" if params.as_object().is_none_or(|m| m.is_empty()) && app.session.is_dirty() => {
|
||||
if app.session.path.is_none() {
|
||||
|
||||
@@ -479,7 +479,7 @@ fn about_tab(app: &mut FilmcraftApp, ui: &mut egui::Ui) {
|
||||
fg,
|
||||
);
|
||||
if resp.clicked() {
|
||||
links::open(ui.ctx(), url);
|
||||
links::open(app, ui.ctx(), url);
|
||||
}
|
||||
};
|
||||
link(ui, "discord", Icon::Chat, tl!("Join the ArtCraft Discord"), links::DISCORD, true);
|
||||
|
||||
@@ -13,6 +13,10 @@ use crate::state::KeyframeRef;
|
||||
use crate::theme::Tokens;
|
||||
|
||||
const ROW_H: f32 = 22.0;
|
||||
/// The narrowest the effect list and the keyframe area can be dragged (#643): the list's rows are
|
||||
/// laid out for at least 260 points (values from 150 points in, the reset button at the end).
|
||||
const MIN_LIST_W: f32 = 260.0;
|
||||
const MIN_LANE_W: f32 = 60.0;
|
||||
/// Keyframe lane header: the time ruler (beside the Source / Sequence pills) and the clip's bar.
|
||||
const RULER_H: f32 = 24.0;
|
||||
const CLIP_BAR_H: f32 = 18.0;
|
||||
@@ -99,7 +103,13 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
crate::dock::placeholder(ui, rect, &t, tl!("(no sequences)"));
|
||||
return;
|
||||
};
|
||||
let split = rect.min.x + (rect.width() * 0.58).max(260.0).min(rect.width() - 60.0);
|
||||
// the divider between the effect list and the keyframe area; dragging it sets the list's width
|
||||
// (#643), each side keeping a minimum width
|
||||
// (the keyframe area has 4 points of margin left of it and 6 right)
|
||||
let max_list = (rect.width() - MIN_LANE_W - 10.0).max(MIN_LIST_W);
|
||||
let list_w = if app.ui.effect_controls_split > 0.0 { app.ui.effect_controls_split } else { (rect.width() * 0.58).max(260.0) };
|
||||
let list_w = list_w.clamp(MIN_LIST_W, max_list);
|
||||
let split = rect.min.x + list_w;
|
||||
let head = Rect::from_min_size(rect.min + vec2(8.0, 4.0), vec2(split - rect.min.x - 12.0, 24.0));
|
||||
// Premiere: two pill tabs — "Source · clip" and "Sequence · clip" (active)
|
||||
let pill = |ui: &mut egui::Ui, r: Rect, text: &str, active: bool| {
|
||||
@@ -122,26 +132,15 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let lx = |tk: Tick| -> f32 { lane.min.x + (((tk - it.start).0 as f64 / dur) as f32).clamp(0.0, 1.0) * lane.width() };
|
||||
let rate = seq.settings.frame_rate;
|
||||
let ruler = Rect::from_min_max(lane.min, pos2(lane.max.x, lane.min.y + RULER_H));
|
||||
paint_ruler(ui.painter(), ruler, &it, rate, seq.settings.drop_frame, &t);
|
||||
let bar = Rect::from_min_max(pos2(lane.min.x, ruler.max.y + 4.0), pos2(lane.max.x, ruler.max.y + 4.0 + CLIP_BAR_H));
|
||||
ui.painter().rect_filled(bar.shrink2(vec2(0.0, 2.0)), 2.0, t.clip_bar_bg);
|
||||
ui.painter().with_clip_rect(bar).text(pos2(bar.min.x + 4.0, bar.center().y), Align2::LEFT_CENTER, &it.name, Tokens::ui(10.0), t.text);
|
||||
// click or drag anywhere on the ruler (or the bar) to move the playhead
|
||||
let scrub = Rect::from_min_max(ruler.min, bar.max);
|
||||
let sresp = ui.interact(scrub, egui::Id::new(("ec-scrub", clip.0)), Sense::click_and_drag());
|
||||
app.auto.add("effectControls.lane", lane, "keyframe lane");
|
||||
app.auto.add("effectControls.ruler", scrub, "time ruler");
|
||||
if (sresp.dragged() || sresp.clicked())
|
||||
&& let Some(pos) = sresp.interact_pointer_pos()
|
||||
{
|
||||
let f = ((pos.x - lane.min.x) / lane.width()).clamp(0.0, 1.0) as f64;
|
||||
let tk = rate.snap_nearest(it.start + Tick((f * it.duration.0 as f64) as i64));
|
||||
app.stop();
|
||||
app.session.set_playhead(tk);
|
||||
}
|
||||
let body = Rect::from_min_max(pos2(rect.min.x, head.max.y + 4.0), pos2(split, rect.max.y - 26.0));
|
||||
let mut actions: Vec<(String, Value)> = Vec::new();
|
||||
let mut bui = ui.new_child(egui::UiBuilder::new().max_rect(body).id_salt("ec-body"));
|
||||
// the rows span the list and the keyframe area: the wheel scrolls anywhere over them and the
|
||||
// scrollbar sits at the panel's right edge (#643)
|
||||
let mut bui = ui.new_child(egui::UiBuilder::new().max_rect(Rect::from_min_max(body.min, pos2(lane.max.x, body.max.y))).id_salt("ec-body"));
|
||||
bui.set_clip_rect(Rect::from_min_max(body.min, pos2(rect.max.x, body.max.y)));
|
||||
let scroll_out = egui::ScrollArea::vertical().id_salt("ec-scroll").auto_shrink([false, false]).show(&mut bui, |bui| {
|
||||
for (clip, it, kind) in &clips {
|
||||
@@ -162,6 +161,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
if resp.hovered() {
|
||||
bui.painter().rect_filled(r, 0.0, t.hover);
|
||||
}
|
||||
row_line(bui, r, &lane, &t);
|
||||
icons::paint(
|
||||
bui.painter(),
|
||||
Rect::from_center_size(pos2(r.min.x + 10.0, r.center().y), vec2(10.0, 10.0)),
|
||||
@@ -178,7 +178,8 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
if fxresp.clicked() {
|
||||
actions.push(("effects.toggleEnabled".into(), json!({"clip": clip.0, "index": idx})));
|
||||
}
|
||||
bui.painter().text(pos2(r.min.x + 42.0, r.center().y), Align2::LEFT_CENTER, crate::i18n::t(def.name), Tokens::ui(12.0), t.text);
|
||||
// (the reset button sits at the row's right end)
|
||||
row_label(bui, pos2(r.min.x + 42.0, r.center().y), crate::i18n::t(def.name), r.max.x - 26.0 - (r.min.x + 42.0), Tokens::ui(12.0), t.text);
|
||||
// reset button
|
||||
let rr = Rect::from_center_size(pos2(r.max.x - 14.0, r.center().y), vec2(16.0, 16.0));
|
||||
let rresp = bui.interact(rr, egui::Id::new(("fxreset", clip.0, idx)), Sense::click()).on_hover_text(tl!("Reset Effect"));
|
||||
@@ -215,7 +216,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
continue;
|
||||
}
|
||||
if crate::panels::audio_fx_editor::has_editor(&e.effect) {
|
||||
custom_setup_row(app, bui, body, clip, idx, &e.effect);
|
||||
custom_setup_row(app, bui, body, &lane, clip, idx, &e.effect);
|
||||
}
|
||||
for pd in &def.params {
|
||||
param_row(app, bui, body, clip, idx, e, None, pd, mt_now, &mut actions, &lane, &lx, it);
|
||||
@@ -234,6 +235,32 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
}
|
||||
});
|
||||
let _ = scroll_out;
|
||||
// the ruler and the clip's bar, painted after the rows so rows scrolled up under them (their
|
||||
// lines and keyframes) stay hidden
|
||||
ui.painter().rect_filled(Rect::from_min_max(lane.min, pos2(lane.max.x, bar.max.y)), 0.0, t.tl_bg);
|
||||
paint_ruler(ui.painter(), ruler, &it, rate, seq.settings.drop_frame, &t);
|
||||
ui.painter().rect_filled(bar.shrink2(vec2(0.0, 2.0)), 2.0, t.clip_bar_bg);
|
||||
ui.painter().with_clip_rect(bar).text(pos2(bar.min.x + 4.0, bar.center().y), Align2::LEFT_CENTER, &it.name, Tokens::ui(10.0), t.text);
|
||||
// click or drag anywhere on the ruler (or the bar) to move the playhead (after the rows, which
|
||||
// reach under it, so it gets the pointer first)
|
||||
let sresp = ui.interact(scrub, egui::Id::new(("ec-scrub", clip.0)), Sense::click_and_drag());
|
||||
if (sresp.dragged() || sresp.clicked())
|
||||
&& let Some(pos) = sresp.interact_pointer_pos()
|
||||
{
|
||||
let f = ((pos.x - lane.min.x) / lane.width()).clamp(0.0, 1.0) as f64;
|
||||
let tk = rate.snap_nearest(it.start + Tick((f * it.duration.0 as f64) as i64));
|
||||
app.stop();
|
||||
app.session.set_playhead(tk);
|
||||
}
|
||||
// the divider: a line the height of the panel, a resize cursor and a wider grab area
|
||||
let divider = Rect::from_min_max(pos2(split - 2.0, rect.min.y), pos2(split + 4.0, rect.max.y - 26.0));
|
||||
let dresp = ui.interact(divider, egui::Id::new("ec-divider"), Sense::drag()).on_hover_cursor(egui::CursorIcon::ResizeHorizontal);
|
||||
if dresp.dragged() {
|
||||
app.ui.effect_controls_split = (list_w + dresp.drag_delta().x).clamp(MIN_LIST_W, max_list);
|
||||
}
|
||||
let dcol = if dresp.hovered() || dresp.dragged() { t.accent } else { t.separator };
|
||||
ui.painter().line_segment([pos2(split + 1.5, rect.min.y), pos2(split + 1.5, rect.max.y - 26.0)], Stroke::new(1.0, dcol));
|
||||
app.auto.add("effectControls.divider", divider, "divider");
|
||||
// playhead: a handle on the ruler and a line down the lane, while it is on the clip
|
||||
if ph >= it.start && ph <= it.end() {
|
||||
let px = lx(ph);
|
||||
@@ -249,10 +276,37 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
run(app, ui.ctx(), actions);
|
||||
}
|
||||
|
||||
/// The thin line under a row, across the names, the values and the keyframe lane, so a row can be
|
||||
/// followed across the panel, as in Premiere (#640). It runs through the middle of the spacing
|
||||
/// between rows, so each row sits centred between its two lines.
|
||||
pub(crate) fn row_line(ui: &egui::Ui, r: Rect, lane: &Rect, t: &Tokens) {
|
||||
let y = r.max.y + ui.spacing().item_spacing.y / 2.0;
|
||||
ui.painter().line_segment([pos2(r.min.x, y), pos2(lane.max.x, y)], Stroke::new(1.0, t.separator));
|
||||
}
|
||||
|
||||
/// A row's label, cut off with "…" where it would run into what follows it, as Premiere does when
|
||||
/// the effect list is narrow (#643).
|
||||
pub(crate) fn row_label(ui: &egui::Ui, left_center: Pos2, text: &str, max_w: f32, font: egui::FontId, color: Color32) {
|
||||
let mut job = egui::text::LayoutJob::single_section(text.to_owned(), egui::TextFormat::simple(font, color));
|
||||
job.wrap.max_width = max_w.max(0.0);
|
||||
job.wrap.max_rows = 1;
|
||||
job.wrap.break_anywhere = true;
|
||||
job.wrap.overflow_character = Some('…');
|
||||
let g = ui.fonts_mut(|f| f.layout_job(job));
|
||||
ui.painter().galley(pos2(left_center.x, left_center.y - g.size().y / 2.0), g, color);
|
||||
}
|
||||
|
||||
/// Make a dropdown or a checkbox in a row fit inside the row, centred between its row lines.
|
||||
pub(crate) fn fit_to_row(ui: &mut egui::Ui) {
|
||||
ui.spacing_mut().interact_size.y = ROW_H - 4.0;
|
||||
ui.spacing_mut().button_padding.y = 1.0;
|
||||
}
|
||||
|
||||
/// Premiere's "Custom Setup ▸ Edit…" row: opens the effect's Clip Fx Editor window.
|
||||
fn custom_setup_row(app: &mut FilmcraftApp, ui: &mut egui::Ui, body: Rect, clip: ClipId, idx: usize, effect: &str) {
|
||||
fn custom_setup_row(app: &mut FilmcraftApp, ui: &mut egui::Ui, body: Rect, lane: &Rect, clip: ClipId, idx: usize, effect: &str) {
|
||||
let t = app.tokens;
|
||||
let (r, _) = ui.allocate_exact_size(vec2(body.width(), ROW_H), Sense::hover());
|
||||
row_line(ui, r, lane, &t);
|
||||
ui.painter().text(pos2(r.min.x + 42.0, r.center().y), Align2::LEFT_CENTER, tl!("Custom Setup"), Tokens::ui(12.0), t.text_dim);
|
||||
let br = Rect::from_min_size(pos2(r.min.x + 160.0, r.min.y + 2.0), vec2(60.0, ROW_H - 4.0));
|
||||
let resp = ui.interact(br, egui::Id::new(("fx-custom-setup", clip.0, idx)), Sense::click());
|
||||
@@ -342,10 +396,11 @@ fn keyframe_nav(
|
||||
for (d, target, name, tip) in
|
||||
[(-1.0, param.prev_keyframe(mt), "prevKeyframe", "Go to previous keyframe"), (1.0, param.next_keyframe(mt), "nextKeyframe", "Go to next keyframe")]
|
||||
{
|
||||
let r = Rect::from_center_size(at + vec2(d * NAV_STEP, 0.0), vec2(12.0, 14.0));
|
||||
// the arrows almost the row's height to click, as Premiere's
|
||||
let r = Rect::from_center_size(at + vec2(d * NAV_STEP, 0.0), vec2(14.0, 20.0));
|
||||
let resp = ui.interact(r, id.with(name), Sense::click()).on_hover_text(crate::i18n::t(tip));
|
||||
let c = r.center();
|
||||
let pts = vec![c + vec2(-3.0 * d, -4.0), c + vec2(-3.0 * d, 4.0), c + vec2(3.0 * d, 0.0)];
|
||||
let pts = vec![c + vec2(-3.5 * d, -5.5), c + vec2(-3.5 * d, 5.5), c + vec2(3.5 * d, 0.0)];
|
||||
ui.painter().add(egui::Shape::convex_polygon(pts, if target.is_some() { t.text } else { t.text_faint }, Stroke::NONE));
|
||||
app.auto.add(&format!("{auto}.{name}"), r, tip);
|
||||
if resp.clicked()
|
||||
@@ -355,12 +410,12 @@ fn keyframe_nav(
|
||||
}
|
||||
}
|
||||
}
|
||||
let r = Rect::from_center_size(at, vec2(13.0, 13.0));
|
||||
let r = Rect::from_center_size(at, vec2(15.0, 15.0));
|
||||
let resp = ui.interact(r.expand(1.5), id.with("key"), Sense::click()).on_hover_text(crate::i18n::t(tip));
|
||||
if param.is_some_and(|p| p.keyframes.iter().any(|k| k.time == mt)) {
|
||||
icons::paint(ui.painter(), r, Icon::Keyframe, t.hot_text);
|
||||
} else {
|
||||
let s = 4.0;
|
||||
let s = 4.7;
|
||||
let outline = vec![at + vec2(0.0, -s), at + vec2(s, 0.0), at + vec2(0.0, s), at + vec2(-s, 0.0)];
|
||||
ui.painter().add(egui::Shape::closed_line(outline, Stroke::new(1.2, if resp.hovered() { t.text } else { t.text_dim })));
|
||||
}
|
||||
@@ -436,6 +491,7 @@ pub(crate) fn param_row(
|
||||
v
|
||||
};
|
||||
let (r, _) = ui.allocate_exact_size(vec2(body.width(), ROW_H), Sense::hover());
|
||||
row_line(ui, r, lane, &t);
|
||||
let mut x = r.min.x + 26.0;
|
||||
// twirl-down for the value/velocity graphs (animated scalar and point params)
|
||||
if param.is_animated() && graphable(¶m.value) {
|
||||
@@ -463,8 +519,8 @@ pub(crate) fn param_row(
|
||||
app.auto.add(&format!("effectControls.{}.{}.stopwatch", e.effect, pkey), sw, "Toggle animation");
|
||||
}
|
||||
x += 14.0;
|
||||
ui.painter().text(pos2(x, r.center().y), Align2::LEFT_CENTER, crate::i18n::t(pd.label), Tokens::ui(12.0), t.text);
|
||||
let vx = r.min.x + (r.width() * 0.5).max(150.0);
|
||||
row_label(ui, pos2(x, r.center().y), crate::i18n::t(pd.label), vx - 6.0 - x, Tokens::ui(12.0), t.text);
|
||||
let value = param.value_at(mt);
|
||||
let mut vui = ui.new_child(
|
||||
egui::UiBuilder::new()
|
||||
@@ -496,13 +552,17 @@ pub(crate) fn param_row(
|
||||
}
|
||||
(ParamKind::Bool, ParamValue::Bool(b)) => {
|
||||
let mut v = *b;
|
||||
fit_to_row(&mut vui);
|
||||
if vui.checkbox(&mut v, "").changed() {
|
||||
set = Some(json!(v));
|
||||
}
|
||||
}
|
||||
(ParamKind::Choice(opts), ParamValue::Choice(c)) => {
|
||||
let mut sel = *c as usize;
|
||||
egui::ComboBox::from_id_salt(id).selected_text(opts.get(sel).map_or("", |o| crate::i18n::t(o))).width(130.0).show_ui(&mut vui, |ui| {
|
||||
fit_to_row(&mut vui);
|
||||
// (narrower when the effect list is narrow, so it stays left of the divider)
|
||||
let w = vui.available_width().min(130.0);
|
||||
egui::ComboBox::from_id_salt(id).selected_text(opts.get(sel).map_or("", |o| crate::i18n::t(o))).width(w).show_ui(&mut vui, |ui| {
|
||||
for (i, o) in opts.iter().enumerate() {
|
||||
if ui.selectable_value(&mut sel, i, crate::i18n::t(o)).changed() {
|
||||
set = Some(json!(i));
|
||||
@@ -559,6 +619,17 @@ pub(crate) fn param_row(
|
||||
actions.push(("effects.addKeyframe".into(), with_mask(json!({"clip": clip.0, "effect": eff_json, "param": pd.id}))));
|
||||
}
|
||||
}
|
||||
// reset to the default, under the effect's own reset button (an animated parameter keeps its
|
||||
// keyframes and gets one at the playhead)
|
||||
if mask.is_none() {
|
||||
let rr = Rect::from_center_size(pos2(r.max.x - 14.0, r.center().y), vec2(12.0, 12.0));
|
||||
let rresp = ui.interact(rr, egui::Id::new(("param-reset", clip.0, idx, pkey)), Sense::click()).on_hover_text(tl!("Reset Parameter"));
|
||||
icons::paint(ui.painter(), rr, Icon::Reset, if rresp.hovered() { t.text } else { t.text_dim });
|
||||
app.auto.add(&format!("effectControls.{}.{}.reset", e.effect, pkey), rr, "Reset Parameter");
|
||||
if rresp.clicked() {
|
||||
actions.push(("effects.resetParam".into(), json!({"clip": clip.0, "effect": eff_json, "param": pd.id})));
|
||||
}
|
||||
}
|
||||
// keyframes in the lane: draggable diamonds; right-click for interpolation
|
||||
if param.is_animated() {
|
||||
let y = r.center().y;
|
||||
@@ -573,7 +644,8 @@ pub(crate) fn param_row(
|
||||
let id = egui::Id::new(("kf", clip.0, idx, pkey, k.time.0));
|
||||
let drag_off: Option<f32> = ui.data(|d| d.get_temp(id));
|
||||
let kx = lane.min.x + f * lane.width() + drag_off.unwrap_or(0.0);
|
||||
let kr = Rect::from_center_size(pos2(kx, y), vec2(11.0, 11.0));
|
||||
// about Premiere's size: a diamond ~9 points wide
|
||||
let kr = Rect::from_center_size(pos2(kx, y), vec2(15.0, 15.0));
|
||||
let resp = ui.interact(kr.expand(2.0), id, Sense::click_and_drag());
|
||||
app.auto.add(&format!("effectControls.{}.{}.keyframe.{}", e.effect, pkey, k.time.0), kr, "keyframe");
|
||||
// highlighted when selected (not merely under the playhead: that lit up every
|
||||
@@ -586,11 +658,11 @@ pub(crate) fn param_row(
|
||||
let col = if sel { t.hot_text } else { Color32::from_rgb(0xb0, 0xb0, 0xb0) };
|
||||
match k.interp {
|
||||
filmcraft_project::Interpolation::Hold => {
|
||||
ui.painter().rect_filled(Rect::from_center_size(kr.center(), vec2(8.0, 8.0)), 0.0, col);
|
||||
ui.painter().rect_filled(Rect::from_center_size(kr.center(), vec2(9.0, 9.0)), 0.0, col);
|
||||
}
|
||||
filmcraft_project::Interpolation::Linear => icons::paint(ui.painter(), kr, Icon::Keyframe, col),
|
||||
_ => {
|
||||
ui.painter().circle_filled(kr.center(), 4.5, col);
|
||||
ui.painter().circle_filled(kr.center(), 5.5, col);
|
||||
}
|
||||
}
|
||||
if resp.dragged() {
|
||||
@@ -952,6 +1024,7 @@ pub(crate) fn graph_rows(
|
||||
let t = app.tokens;
|
||||
let (vr, _) = ui.allocate_exact_size(vec2(body.width(), 110.0), Sense::hover());
|
||||
let (velr, _) = ui.allocate_exact_size(vec2(body.width(), 64.0), Sense::hover());
|
||||
row_line(ui, velr, lane, &t);
|
||||
let speed = it.speed.abs().max(1e-6);
|
||||
let dur = it.duration.0.max(1) as f64;
|
||||
let to_media = |f: f64| it.source_in + Tick((f * dur * speed) as i64);
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
//!
|
||||
//! Lumetri Presets: each preset row has a thumbnail (our Lumetri on the procedural preview
|
||||
//! picture, `filmcraft_render::lumetri_presets`); double-click (or right-click ▸ Apply to Selected
|
||||
//! Clips, `effects.presetMenu.apply`) applies it (`lumetri.applyPreset`).
|
||||
//! Clips, `effects.presetMenu.apply`) applies it (`lumetri.applyPreset`), and dragging it onto a
|
||||
//! Timeline clip applies it to that clip (rows and grid cells alike).
|
||||
//! When the panel is wide (maximized), clicking a Lumetri Presets folder shows its presets as a
|
||||
//! thumbnail grid to the right of the tree. Automation ids: `effects.lumetriPreset.<name>`,
|
||||
//! `effects.presetGrid` (the grid area), `effects.presetGrid.<name>`.
|
||||
@@ -232,7 +233,7 @@ fn lumetri_rows(app: &mut FilmcraftApp, ui: &mut egui::Ui, filter: &str) -> Opti
|
||||
continue;
|
||||
}
|
||||
for p in items {
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 24.0), Sense::click());
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 24.0), Sense::click_and_drag());
|
||||
if resp.hovered() {
|
||||
ui.painter().rect_filled(r, 0.0, t.hover);
|
||||
}
|
||||
@@ -242,6 +243,9 @@ fn lumetri_rows(app: &mut FilmcraftApp, ui: &mut egui::Ui, filter: &str) -> Opti
|
||||
ui.painter().text(pos2(tr.max.x + 8.0, r.center().y), Align2::LEFT_CENTER, crate::i18n::t(p.name), Tokens::ui(12.0), t.text);
|
||||
app.auto.add(&format!("effects.lumetriPreset.{}", p.name), r, p.name);
|
||||
let resp = resp.on_hover_text(crate::i18n::t(p.description));
|
||||
if resp.drag_started() {
|
||||
crate::panels::start_drag_lumetri_preset(ui, p.name);
|
||||
}
|
||||
if apply_menu(app, &resp) {
|
||||
apply = Some(p.name.to_string());
|
||||
}
|
||||
@@ -273,12 +277,15 @@ fn preset_grid(app: &mut FilmcraftApp, ui: &mut egui::Ui, area: Rect) -> Option<
|
||||
}
|
||||
let pic = Rect::from_min_size(cell.min, vec2(cw, ch));
|
||||
let tex = preset_texture(app, ui.ctx(), p, 160);
|
||||
let resp = ui.interact(cell, egui::Id::new(("lumetri-grid", p.name)), Sense::click()).on_hover_text(crate::i18n::t(p.description));
|
||||
let resp = ui.interact(cell, egui::Id::new(("lumetri-grid", p.name)), Sense::click_and_drag()).on_hover_text(crate::i18n::t(p.description));
|
||||
ui.painter().rect_filled(cell, 3.0, if resp.hovered() { t.hover } else { t.field_bg });
|
||||
ui.painter().image(tex, pic.shrink(2.0), Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
|
||||
let label = ui.painter().layout(crate::i18n::t(p.name).to_string(), Tokens::ui(11.0), t.text, cw - 6.0);
|
||||
ui.painter().galley(pos2(cell.min.x + 4.0, pic.max.y + 2.0), label, t.text);
|
||||
app.auto.add(&format!("effects.presetGrid.{}", p.name), cell, p.name);
|
||||
if resp.drag_started() {
|
||||
crate::panels::start_drag_lumetri_preset(ui, p.name);
|
||||
}
|
||||
if apply_menu(app, &resp) {
|
||||
apply = Some(p.name.to_string());
|
||||
}
|
||||
|
||||
@@ -606,7 +606,7 @@ fn video_section(ui: &mut egui::Ui, reg: &mut Reg, s: &mut ExportSettings, t: &T
|
||||
}
|
||||
});
|
||||
}
|
||||
if matches!(s.format, Format::PngSequence | Format::TiffSequence) {
|
||||
if s.supports_alpha() {
|
||||
row(ui, t, tl!("Alpha"), |ui| {
|
||||
check(ui, reg, "export.video.alpha", &mut s.alpha, tl!("Include Alpha Channel"));
|
||||
});
|
||||
@@ -708,8 +708,9 @@ fn video_section(ui: &mut egui::Ui, reg: &mut Reg, s: &mut ExportSettings, t: &T
|
||||
}
|
||||
Format::ProRes => {
|
||||
row(ui, t, tl!("Profile"), |ui| {
|
||||
let keys = ["proxy", "lt", "standard", "hq"];
|
||||
let labels = ["Apple ProRes 422 Proxy", "Apple ProRes 422 LT", "Apple ProRes 422", "Apple ProRes 422 HQ"];
|
||||
let keys = ["proxy", "lt", "standard", "hq", "4444", "4444xq"];
|
||||
let labels =
|
||||
["Apple ProRes 422 Proxy", "Apple ProRes 422 LT", "Apple ProRes 422", "Apple ProRes 422 HQ", "Apple ProRes 4444", "Apple ProRes 4444 XQ"];
|
||||
let cur = keys.iter().position(|k| *k == s.prores_profile).unwrap_or(3);
|
||||
if let Some(i) = combo(ui, reg, "export.video.proresProfile", labels[cur], &opts(&labels), 220.0) {
|
||||
s.prores_profile = keys[i].into();
|
||||
|
||||
@@ -67,7 +67,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
crate::icons::paint(ui.painter(), Rect::from_center_size(pos2(r.min.x + 20.0, r.center().y), vec2(16.0, 16.0)), icon, fg);
|
||||
ui.painter().galley(pos2(r.min.x + 34.0, r.center().y - g.size().y / 2.0), g, fg);
|
||||
if resp.clicked() {
|
||||
crate::links::open(&ctx, url);
|
||||
crate::links::open(app, &ctx, url);
|
||||
}
|
||||
x = r.max.x + 12.0;
|
||||
}
|
||||
|
||||
@@ -210,7 +210,7 @@ pub fn route(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, params: &Val
|
||||
}
|
||||
"graphics.beginTextEditing" => begin_text_editing(app),
|
||||
"help.filmcraftHelp" => {
|
||||
crate::links::open(ctx, HELP_URL);
|
||||
crate::links::open(app, ctx, HELP_URL);
|
||||
Ok(json!({"url": HELP_URL}))
|
||||
}
|
||||
"app.quit" => {
|
||||
|
||||
@@ -45,6 +45,7 @@ pub fn effect_rows(
|
||||
let t = app.tokens;
|
||||
// creation tools: ellipse, 4-point polygon, free-draw Bézier
|
||||
let (r, _) = ui.allocate_exact_size(vec2(body.width(), ROW_H), Sense::hover());
|
||||
crate::panels::effect_controls::row_line(ui, r, lane, &t);
|
||||
let pen_on = app.ui.mask_pen.as_ref().is_some_and(|p| p.clip == clip.0 && p.effect == idx);
|
||||
for (i, (icon, kind, tip)) in [
|
||||
(Icon::Ellipse, "ellipse", tl!("Create ellipse mask")),
|
||||
@@ -85,6 +86,7 @@ pub fn effect_rows(
|
||||
} else if resp.hovered() {
|
||||
ui.painter().rect_filled(r, 0.0, t.hover);
|
||||
}
|
||||
crate::panels::effect_controls::row_line(ui, r, lane, &t);
|
||||
let tw = Rect::from_center_size(pos2(r.min.x + 26.0, r.center().y), vec2(10.0, 10.0));
|
||||
icons::paint(ui.painter(), tw, if open { Icon::ChevronDown } else { Icon::ChevronRight }, t.text_dim);
|
||||
let twresp = ui.interact(tw.expand(3.0), egui::Id::new(("mask-twirl", clip.0, idx, k)), Sense::click());
|
||||
@@ -99,13 +101,22 @@ pub fn effect_rows(
|
||||
Icon::Pen
|
||||
};
|
||||
icons::paint(ui.painter(), glyph, gi, if selected { t.accent } else { t.text_dim });
|
||||
ui.painter().text(pos2(r.min.x + 54.0, r.center().y), Align2::LEFT_CENTER, &m.name, Tokens::ui(12.0), t.text);
|
||||
// (the mode dropdown starts 118 points from the row's right end)
|
||||
crate::panels::effect_controls::row_label(
|
||||
ui,
|
||||
pos2(r.min.x + 54.0, r.center().y),
|
||||
&m.name,
|
||||
r.max.x - 122.0 - (r.min.x + 54.0),
|
||||
Tokens::ui(12.0),
|
||||
t.text,
|
||||
);
|
||||
let base = format!("effectControls.{}.mask{k}", e.effect);
|
||||
app.auto.add(&base, r, &m.name);
|
||||
// mode
|
||||
let mr = Rect::from_min_size(pos2(r.max.x - 118.0, r.min.y + 2.0), vec2(96.0, ROW_H - 4.0));
|
||||
let mut mui = ui.new_child(egui::UiBuilder::new().max_rect(mr).layout(egui::Layout::left_to_right(egui::Align::Center)));
|
||||
let mut mode = m.mode;
|
||||
crate::panels::effect_controls::fit_to_row(&mut mui);
|
||||
egui::ComboBox::from_id_salt(("mask-mode", clip.0, idx, k)).selected_text(crate::i18n::t(mode.label())).width(88.0).show_ui(&mut mui, |ui| {
|
||||
for md in MaskMode::ALL {
|
||||
if ui.selectable_value(&mut mode, md, crate::i18n::t(md.label())).changed() {
|
||||
@@ -148,9 +159,11 @@ pub fn effect_rows(
|
||||
}
|
||||
// Inverted
|
||||
let (r, _) = ui.allocate_exact_size(vec2(body.width(), ROW_H), Sense::hover());
|
||||
crate::panels::effect_controls::row_line(ui, r, lane, &t);
|
||||
ui.painter().text(pos2(r.min.x + 40.0, r.center().y), Align2::LEFT_CENTER, tl!("Inverted"), Tokens::ui(12.0), t.text);
|
||||
let cr = Rect::from_min_size(pos2(r.min.x + (r.width() * 0.5).max(150.0), r.min.y + 2.0), vec2(20.0, ROW_H - 4.0));
|
||||
let mut cui = ui.new_child(egui::UiBuilder::new().max_rect(cr));
|
||||
let mut cui = ui.new_child(egui::UiBuilder::new().max_rect(cr).layout(egui::Layout::left_to_right(egui::Align::Center)));
|
||||
crate::panels::effect_controls::fit_to_row(&mut cui);
|
||||
let mut inv = m.inverted;
|
||||
if cui.checkbox(&mut inv, "").changed() {
|
||||
actions.push(("masks.set".into(), json!({"clip": clip.0, "effect": idx, "mask": k, "inverted": inv})));
|
||||
|
||||
@@ -29,6 +29,7 @@ pub mod mixer;
|
||||
pub mod monitor;
|
||||
pub mod monitor_view;
|
||||
pub mod multicam;
|
||||
pub mod notes;
|
||||
pub mod panel_state;
|
||||
pub mod presets;
|
||||
pub mod project;
|
||||
@@ -101,6 +102,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, p: PanelKind, rect: Rect)
|
||||
PanelKind::Text => text::show(app, ui, rect),
|
||||
PanelKind::EssentialSound => essential_sound::show(app, ui, rect),
|
||||
PanelKind::TextToSpeech => tts::show(app, ui, rect),
|
||||
PanelKind::ProjectNotes => notes::show(app, ui, rect),
|
||||
other => crate::dock::placeholder(ui, rect, &app.tokens, &tlf!("{panel} — coming in a later milestone", panel = crate::i18n::t(other.title()))),
|
||||
}
|
||||
}
|
||||
@@ -111,6 +113,8 @@ pub mod source_range;
|
||||
#[derive(Clone, Debug)]
|
||||
enum DragPayload {
|
||||
Item(ItemId),
|
||||
/// A bin (by id) dragged in the Project panel.
|
||||
Bin(u64),
|
||||
Source(source_drag::SourceDrag),
|
||||
Effect(String),
|
||||
/// A graphics template (id, name) from Essential Graphics ▸ Browse.
|
||||
@@ -133,6 +137,15 @@ pub fn dragged_source(ui: &egui::Ui) -> Option<source_drag::SourceDrag> {
|
||||
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)))));
|
||||
}
|
||||
pub fn start_drag_bin(ui: &egui::Ui, bin: u64) {
|
||||
ui.ctx().data_mut(|d| d.insert_temp(payload_id(), Some(DragPayloadBox(DragPayload::Bin(bin)))));
|
||||
}
|
||||
pub fn dragged_bin(ui: &egui::Ui) -> Option<u64> {
|
||||
match payload(ui) {
|
||||
Some(DragPayload::Bin(b)) => Some(b),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
pub fn start_drag_template(ui: &egui::Ui, id: &str, name: &str) {
|
||||
ui.ctx().data_mut(|d| d.insert_temp(payload_id(), Some(DragPayloadBox(DragPayload::Template(id.to_string(), name.to_string())))));
|
||||
}
|
||||
@@ -145,6 +158,13 @@ pub fn dragged_template(ui: &egui::Ui) -> Option<String> {
|
||||
pub fn start_drag_effect(ui: &egui::Ui, id: &str) {
|
||||
ui.ctx().data_mut(|d| d.insert_temp(payload_id(), Some(DragPayloadBox(DragPayload::Effect(id.to_string())))));
|
||||
}
|
||||
/// Prefix of a dragged effect payload that names a Lumetri preset rather than an effect id.
|
||||
pub const LUMETRI_PRESET_PREFIX: &str = "lumetri:";
|
||||
/// A Lumetri preset (Effects ▸ Lumetri Presets) dragged onto a clip: an effect payload
|
||||
/// `lumetri:<name>` the Timeline applies with `lumetri.applyPreset`.
|
||||
pub fn start_drag_lumetri_preset(ui: &egui::Ui, name: &str) {
|
||||
start_drag_effect(ui, &format!("{LUMETRI_PRESET_PREFIX}{name}"));
|
||||
}
|
||||
#[derive(Clone, Debug)]
|
||||
struct DragPayloadBox(DragPayload);
|
||||
|
||||
@@ -175,6 +195,7 @@ 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::Bin(b) => app.session.project.root.find_bin(filmcraft_project::BinId(*b)).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) {
|
||||
@@ -185,9 +206,10 @@ pub fn drag_ghost(app: &FilmcraftApp, ui: &egui::Ui) {
|
||||
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(),
|
||||
None => filmcraft_project::find_effect(e).map(|d| d.name.to_string()).unwrap_or_default(),
|
||||
DragPayload::Effect(e) => match (e.strip_prefix("preset:"), e.strip_prefix(LUMETRI_PRESET_PREFIX)) {
|
||||
(Some(name), _) => name.to_string(),
|
||||
(_, Some(name)) => crate::i18n::t(name).to_string(),
|
||||
_ => filmcraft_project::find_effect(e).map(|d| d.name.to_string()).unwrap_or_default(),
|
||||
},
|
||||
};
|
||||
let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::new("drag-ghost")));
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
//! Project Notes panel (#620): free plain text kept in the project file, for to-dos,
|
||||
//! instructions for whoever opens the project next, frame numbers and the like. Every change is
|
||||
//! a `project.setNotes`; one typing session (from focusing the editor until it loses focus) is
|
||||
//! one undo step.
|
||||
//!
|
||||
//! Automation ids: `notes.text` (the editor; its label is the start of the notes). Agents read
|
||||
//! and write the notes with `project.notes` / `project.setNotes`.
|
||||
|
||||
use egui::{Rect, vec2};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::FilmcraftApp;
|
||||
|
||||
/// Longest automation label (characters): the full notes are `project.notes`.
|
||||
const LABEL_CHARS: usize = 200;
|
||||
|
||||
pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let t = app.tokens;
|
||||
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(rect.shrink2(vec2(8.0, 6.0))));
|
||||
let ui = &mut child;
|
||||
let mut text = app.session.project.notes.clone();
|
||||
let height = ui.available_height();
|
||||
let r = egui::ScrollArea::vertical()
|
||||
.id_salt("project-notes-scroll")
|
||||
.auto_shrink([false, false])
|
||||
.show(ui, |ui| {
|
||||
ui.add(
|
||||
egui::TextEdit::multiline(&mut text)
|
||||
.id_salt("project-notes")
|
||||
.hint_text(tl!("Notes for this project: to-dos, instructions, frame numbers… Saved with the project."))
|
||||
.text_color(t.text)
|
||||
.desired_width(f32::INFINITY)
|
||||
.min_size(vec2(0.0, height)),
|
||||
)
|
||||
})
|
||||
.inner;
|
||||
// each focus of the editor starts a new undo step; the edits while it keeps focus join it
|
||||
let typing = egui::Id::new("project-notes-typing");
|
||||
if r.gained_focus() {
|
||||
ui.data_mut(|d| {
|
||||
let n = d.get_temp::<u64>(typing).unwrap_or(0).wrapping_add(1);
|
||||
d.insert_temp(typing, n);
|
||||
});
|
||||
}
|
||||
if r.changed() {
|
||||
let key = ui.data(|d| d.get_temp::<u64>(typing)).unwrap_or(0);
|
||||
if let Err(e) = app.session.execute("project.setNotes", json!({"text": text, "merge": key.to_string()})) {
|
||||
app.ui.status = e.to_string();
|
||||
}
|
||||
}
|
||||
let label: String = app.session.project.notes.chars().take(LABEL_CHARS).collect();
|
||||
app.auto.add("notes.text", r.rect, &label);
|
||||
}
|
||||
@@ -622,7 +622,7 @@ fn footer(app: &mut FilmcraftApp, ui: &mut egui::Ui, bar: Rect, v: &View, action
|
||||
"find" => actions.push(("edit.find".into(), json!({}))),
|
||||
"file.newBin" => {
|
||||
let parent = (v.bin != app.session.project.root.id).then_some(v.bin.0);
|
||||
actions.push(("file.newBin".into(), json!({"name": "New Bin", "parent": parent})));
|
||||
actions.push(("file.newBin".into(), json!({"parent": parent, "panel": pre})));
|
||||
}
|
||||
"project.delete" => match crate::panels::project_views::clear_params(&app.session.state.project_selection, app.ui.project_panel.selected_bin) {
|
||||
Some(p) => actions.push((id.into(), p)),
|
||||
@@ -719,7 +719,7 @@ pub fn panel_menu(app: &mut FilmcraftApp, ui: &mut egui::Ui) -> bool {
|
||||
ui.separator();
|
||||
if item(ui, "newBin", tl!("New Bin"), true, s_bin.as_deref()) {
|
||||
let parent = (v.bin != app.session.project.root.id).then_some(v.bin.0);
|
||||
actions.push(("file.newBin".into(), json!({"name": "New Bin", "parent": parent})));
|
||||
actions.push(("file.newBin".into(), json!({"parent": parent})));
|
||||
}
|
||||
if item(ui, "newBinFromSelection", tl!("New Bin From Selection"), !app.session.state.project_selection.is_empty(), Some("Shift+B")) {
|
||||
actions.push(("file.newBinFromSelection".into(), json!({})));
|
||||
@@ -958,6 +958,32 @@ fn start_rename(app: &mut FilmcraftApp, params: &Value) -> Result<Value, String>
|
||||
Ok(json!({"item": item, "bin": bin}))
|
||||
}
|
||||
|
||||
/// New Bin from a menu, a shortcut or the panel's buttons (no `name`): make a "New Bin" in `parent`
|
||||
/// (left out: the bin the Project panel shows; null: the project's root) and start renaming it, so
|
||||
/// its name can be typed right away (#456). `panel` names the panel instance that edits the name.
|
||||
/// Agents pass `name` to `file.newBin` to name the bin directly.
|
||||
pub fn new_bin(app: &mut FilmcraftApp, params: &Value) -> Result<Value, String> {
|
||||
let shown = view_of(app, shown_inst(app));
|
||||
let parent = match params.get("parent") {
|
||||
None => (shown.bin != app.session.project.root.id).then_some(shown.bin.0),
|
||||
Some(Value::Null) => None,
|
||||
Some(p) => Some(p.as_u64().ok_or("`parent` must be a bin id")?),
|
||||
};
|
||||
let r = app.session.execute("file.newBin", json!({"name": tl!("New Bin"), "parent": parent})).map_err(|e| e.to_string())?;
|
||||
let bin = r.get("bin").and_then(Value::as_u64).ok_or("the new bin has no id")?;
|
||||
let text = app.session.project.root.find_bin(BinId(bin)).map(|b| b.name.clone()).ok_or("no such bin")?;
|
||||
// a bin made inside a collapsed bin of the list opens it, so the name field is visible
|
||||
if let Some(p) = parent
|
||||
&& !app.ui.expanded_bins.contains(&p)
|
||||
{
|
||||
app.ui.expanded_bins.push(p);
|
||||
}
|
||||
let panel = params.get("panel").and_then(Value::as_str).map_or(shown.prefix, str::to_string);
|
||||
app.ui.project_panel.selected_bin = Some(bin);
|
||||
app.ui.project_panel.rename = Some(Rename { item: None, bin: Some(bin), text, panel });
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// Commit the inline rename (Enter / focus lost).
|
||||
pub fn commit_rename(app: &mut FilmcraftApp) {
|
||||
let Some(r) = app.ui.project_panel.rename.take() else { return };
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
use egui::{Align2, Color32, Rect, Sense, Stroke, StrokeKind, pos2, vec2};
|
||||
use filmcraft_engine::project_panel::{self as pp, Card, SortSpec, ViewMode};
|
||||
use filmcraft_project::{Bin, BinEntry, ItemId, ItemKind};
|
||||
use filmcraft_project::{Bin, BinEntry, BinId, ItemId, ItemKind};
|
||||
use filmcraft_time::Tick;
|
||||
use serde_json::json;
|
||||
|
||||
@@ -311,7 +311,7 @@ fn bin_menu(app: &mut FilmcraftApp, ui: &mut egui::Ui, v: &View, bin: u64, actio
|
||||
ui.close();
|
||||
}
|
||||
if ui.button(tl!("New Bin")).clicked() {
|
||||
actions.push(("file.newBin".into(), json!({"name": "New Bin", "parent": bin})));
|
||||
actions.push(("file.newBin".into(), json!({"parent": bin, "panel": v.prefix})));
|
||||
ui.close();
|
||||
}
|
||||
ui.separator();
|
||||
@@ -331,21 +331,46 @@ pub fn clear_params(selection: &[filmcraft_project::ItemId], selected_bin: Optio
|
||||
}
|
||||
|
||||
/// Drop project items dragged onto a bin: move them (the selection when the dragged item is in it).
|
||||
/// A dragged bin nests in it, unless that would put the bin inside itself.
|
||||
fn accept_bin_drop(app: &FilmcraftApp, ui: &egui::Ui, r: Rect, bin: u64, actions: &mut Actions) -> bool {
|
||||
let Some(item) = crate::panels::dragged_project_item(ui) else { return false };
|
||||
let items: Vec<u64> = if let Some(item) = crate::panels::dragged_project_item(ui) {
|
||||
let sel = &app.session.state.project_selection;
|
||||
if sel.contains(&item) { sel.iter().map(|i| i.0).collect() } else { vec![item.0] }
|
||||
} else if let Some(dragged) = crate::panels::dragged_bin(ui) {
|
||||
let root = &app.session.project.root;
|
||||
if root.find_bin(BinId(dragged)).is_none_or(|d| d.find_bin(BinId(bin)).is_some()) {
|
||||
return false;
|
||||
}
|
||||
vec![dragged]
|
||||
} else {
|
||||
return false;
|
||||
};
|
||||
if !ui.rect_contains_pointer(r) {
|
||||
return false;
|
||||
}
|
||||
ui.painter().rect_stroke(r, 3.0, Stroke::new(1.5, app.tokens.accent), StrokeKind::Inside);
|
||||
if ui.input(|i| i.pointer.any_released()) {
|
||||
let sel = &app.session.state.project_selection;
|
||||
let items: Vec<u64> = if sel.contains(&item) { sel.iter().map(|i| i.0).collect() } else { vec![item.0] };
|
||||
actions.push(("project.moveToBin".into(), json!({"items": items, "bin": bin})));
|
||||
crate::panels::clear_drag(ui);
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// A bin dropped on empty space moves into the bin the view shows (out of the bin it was nested in).
|
||||
fn accept_view_drop(app: &FilmcraftApp, ui: &egui::Ui, r: Rect, v: &View, actions: &mut Actions) {
|
||||
let Some(dragged) = crate::panels::dragged_bin(ui) else { return };
|
||||
let root = &app.session.project.root;
|
||||
// already shown here, or the view is inside the dragged bin
|
||||
let here = root.find_bin(v.bin).is_some_and(|b| b.children.iter().any(|c| matches!(c, BinEntry::Bin(x) if x.id.0 == dragged)));
|
||||
let inside = root.find_bin(BinId(dragged)).is_none_or(|d| d.find_bin(v.bin).is_some());
|
||||
if here || inside || !ui.rect_contains_pointer(r) || !ui.input(|i| i.pointer.any_released()) {
|
||||
return;
|
||||
}
|
||||
let bin = (v.bin != root.id).then_some(v.bin.0);
|
||||
actions.push(("project.moveToBin".into(), json!({"items": [dragged], "bin": bin})));
|
||||
crate::panels::clear_drag(ui);
|
||||
}
|
||||
|
||||
/// The inline rename field at `r`; true while it is shown.
|
||||
fn rename_field(app: &mut FilmcraftApp, ui: &mut egui::Ui, r: Rect, item: Option<u64>, bin: Option<u64>, pre: &str) -> bool {
|
||||
let Some(rn) = app.ui.project_panel.rename.clone() else { return false };
|
||||
@@ -521,6 +546,7 @@ fn empty_space(app: &mut FilmcraftApp, ui: &mut egui::Ui, v: &View, bg: &egui::R
|
||||
ui.allocate_rect(r, Sense::hover());
|
||||
app.auto.add(&format!("{}.empty", v.prefix), r.intersect(ui.clip_rect()), "Empty area");
|
||||
marquee(app, ui, v, bg, ui.max_rect().min, actions);
|
||||
accept_view_drop(app, ui, r, v, actions);
|
||||
if bg.clicked() {
|
||||
app.ui.project_panel.selected_bin = None;
|
||||
actions.push(("project.select".into(), json!({"items": []})));
|
||||
@@ -599,7 +625,7 @@ fn background_menu(app: &mut FilmcraftApp, ui: &mut egui::Ui, v: &View, bin: Opt
|
||||
(tl!("Import…"), "file.import"),
|
||||
] {
|
||||
if ui.button(label).clicked() {
|
||||
let p = if cmd == "file.newBin" { json!({"name": tl!("New Bin"), "parent": bin}) } else { json!({}) };
|
||||
let p = if cmd == "file.newBin" { json!({"parent": bin, "panel": v.prefix}) } else { json!({}) };
|
||||
actions.push((cmd.into(), p));
|
||||
ui.close();
|
||||
}
|
||||
@@ -614,7 +640,7 @@ fn list_bin(app: &mut FilmcraftApp, ui: &mut egui::Ui, bin: &Bin, depth: usize,
|
||||
for bid in bins {
|
||||
let Some(b) = app.session.project.root.find_bin(bid).cloned() else { continue };
|
||||
let open = app.ui.expanded_bins.contains(&b.id.0) || !filter.is_empty();
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(lc.width, lc.row_h), Sense::click());
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(lc.width, lc.row_h), Sense::click_and_drag());
|
||||
let selected = app.ui.project_panel.selected_bin == Some(b.id.0);
|
||||
if selected {
|
||||
ui.painter().rect_filled(r, 0.0, t.row_selected);
|
||||
@@ -633,6 +659,9 @@ fn list_bin(app: &mut FilmcraftApp, ui: &mut egui::Ui, bin: &Bin, depth: usize,
|
||||
app.auto.add(&format!("{}.bin.{}", v.prefix, b.id.0), r.intersect(ui.clip_rect()), &b.name);
|
||||
app.auto.add(&format!("{}.bin.{}.toggle", v.prefix, b.id.0), tri, "Expand");
|
||||
accept_bin_drop(app, ui, r, b.id.0, actions);
|
||||
if resp.drag_started() {
|
||||
crate::panels::start_drag_bin(ui, b.id.0);
|
||||
}
|
||||
if resp.clicked() {
|
||||
let on_tri = resp.interact_pointer_pos().is_some_and(|p| p.x < x + 12.0);
|
||||
if on_tri || app.ui.project_panel.selected_bin == Some(b.id.0) && !resp.double_clicked() {
|
||||
@@ -844,7 +873,7 @@ fn card(app: &mut FilmcraftApp, ui: &mut egui::Ui, r: Rect, id: ItemId, pre: &st
|
||||
fn bin_card(app: &mut FilmcraftApp, ui: &mut egui::Ui, r: Rect, b: &Bin, v: &View, actions: &mut Actions) {
|
||||
let t = app.tokens;
|
||||
let th = Rect::from_min_size(r.min, vec2(r.width(), r.width() * 9.0 / 16.0));
|
||||
let resp = ui.interact(r.expand(2.0), egui::Id::new((&v.prefix, "bincard", b.id.0)), Sense::click());
|
||||
let resp = ui.interact(r.expand(2.0), egui::Id::new((&v.prefix, "bincard", b.id.0)), Sense::click_and_drag());
|
||||
let selected = app.ui.project_panel.selected_bin == Some(b.id.0);
|
||||
let bg = Rect::from_min_max(th.min - vec2(6.0, 6.0), pos2(th.max.x + 6.0, r.max.y + 2.0));
|
||||
ui.painter().rect_filled(bg, 4.0, if selected { t.tl_header_bg } else { t.panel_bg });
|
||||
@@ -863,6 +892,9 @@ fn bin_card(app: &mut FilmcraftApp, ui: &mut egui::Ui, r: Rect, b: &Bin, v: &Vie
|
||||
}
|
||||
app.auto.add(&format!("{}.bin.{}", v.prefix, b.id.0), th, &b.name);
|
||||
accept_bin_drop(app, ui, th, b.id.0, actions);
|
||||
if resp.drag_started() {
|
||||
crate::panels::start_drag_bin(ui, b.id.0);
|
||||
}
|
||||
if resp.clicked() {
|
||||
app.ui.project_panel.selected_bin = Some(b.id.0);
|
||||
actions.push(("project.select".into(), json!({"items": []})));
|
||||
|
||||
@@ -749,7 +749,7 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) -> bool {
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_space(20.0);
|
||||
if button(app, ui, "settings.help", tl!("Help"), false) {
|
||||
crate::links::open(ctx, &format!("{}/blob/main/docs/project-files.md#settings", crate::links::GITHUB));
|
||||
crate::links::open(app, ctx, &format!("{}/blob/main/docs/project-files.md#settings", crate::links::GITHUB));
|
||||
}
|
||||
if button(app, ui, "settings.reset", tl!("Reset…"), false) {
|
||||
// this category back to its defaults (OK applies it)
|
||||
|
||||
@@ -22,7 +22,7 @@ use crate::icons::{self, Icon};
|
||||
use crate::state::{ThumbnailMode, TimelineView, Tool};
|
||||
use crate::theme::Tokens;
|
||||
|
||||
use super::timeline_hit::{EdgeKind, Grab, edge_geometry, grab_at};
|
||||
use super::timeline_hit::{EdgeKind, Grab, edge_geometry, grab_at, marker_at, marker_rect};
|
||||
pub use super::timeline_hit::{Hit, ROLL_PX, hit, selection_trim_kind};
|
||||
|
||||
const TOP_H: f32 = 58.0; // timecode + toolbar (left) / ruler (right)
|
||||
@@ -191,6 +191,26 @@ impl Layout {
|
||||
}
|
||||
}
|
||||
|
||||
/// #483: where a drop at `y` adds a track: the empty space above the top video track (a video
|
||||
/// track) or below the last audio track (an audio track), never over a track, the caption tracks
|
||||
/// or the Mix row. Gives the kind and the band the new track would take (`vh` / `ah` high).
|
||||
fn new_track_at(seq: &Sequence, layout: &Layout, y: f32, vh: f32, ah: f32) -> Option<(TrackKind, Rect)> {
|
||||
if layout.row_at(y).is_some() {
|
||||
return None;
|
||||
}
|
||||
let cap_n = seq.caption_tracks.len();
|
||||
let tracks_top = layout.content.min.y + if cap_n > 0 { cap_n as f32 * super::timeline_captions::ROW_H + DIVIDER_H } else { 0.0 };
|
||||
let band = |y0: f32, y1: f32| Rect::from_min_max(pos2(layout.content.min.x, y0), pos2(layout.content.max.x, y1));
|
||||
if y < layout.split_y {
|
||||
let top = layout.rows.iter().filter(|r| r.kind == TrackKind::Video).map(|r| r.rect.min.y).fold(layout.split_y, f32::min);
|
||||
(y >= tracks_top && y < top).then(|| (TrackKind::Video, band((top - vh).max(tracks_top), top)))
|
||||
} else {
|
||||
let bottom = layout.rows.iter().filter(|r| r.kind == TrackKind::Audio).map(|r| r.rect.max.y).fold(layout.split_y + DIVIDER_H, f32::max);
|
||||
let mix = layout.content.max.y - MASTER_H;
|
||||
(y >= bottom && y < mix).then(|| (TrackKind::Audio, band(bottom, (bottom + ah).min(mix))))
|
||||
}
|
||||
}
|
||||
|
||||
/// How far the Timeline reaches, in seconds: ten minutes past the end of the sequence. Premiere
|
||||
/// Pro's Timeline does the same (on a 6 s sequence its view stops with 00:10:06 at the right
|
||||
/// edge). The view cannot be scrolled past it and the scroll bar spans it.
|
||||
@@ -376,7 +396,10 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let visible = (layout.tick_at(content.min.x - 2.0), layout.tick_at(content.max.x + 2.0));
|
||||
let selection: Vec<ClipId> = app.session.state.selection.clone();
|
||||
let mut previews: HashMap<ClipId, (Tick, Tick, Option<TrackId>)> = HashMap::new(); // live drag preview: (start, dur, track)
|
||||
preview_drag(app, &seq, &layout, &mut previews);
|
||||
// clips whose media changes in the preview (a trimmed or rolled edge): drawn from the edited
|
||||
// item, so the waveform and thumbnail stay where the media is instead of squeezing (#374)
|
||||
let mut edited: HashMap<ClipId, TrackItem> = HashMap::new();
|
||||
preview_drag(app, &seq, &mut previews, &mut edited);
|
||||
for r in &rows {
|
||||
let Some(tr) = seq.track(r.track) else { continue };
|
||||
let clip_rect = if r.kind == TrackKind::Video { vclip } else { aclip };
|
||||
@@ -397,6 +420,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
}
|
||||
}
|
||||
for it in &tr.items {
|
||||
let it = edited.get(&it.id).unwrap_or(it);
|
||||
let (start, dur, moved_track) = previews.get(&it.id).copied().unwrap_or((it.start, it.duration, None));
|
||||
if moved_track.is_some_and(|m| m != r.track) {
|
||||
continue;
|
||||
@@ -1001,12 +1025,12 @@ fn draw_transition(p: &egui::Painter, r: Rect, trn: &filmcraft_project::Transiti
|
||||
}
|
||||
}
|
||||
|
||||
/// A Premiere track-header button (patch / target): blue fill when on, full track height.
|
||||
/// A Premiere track-header button (patch / target): highlight-blue fill when on, full track height.
|
||||
fn patch_button(ui: &mut egui::Ui, r: Rect, clip: Rect, label: &str, on: bool, show_off: bool, id: egui::Id, t: &Tokens) -> egui::Response {
|
||||
let resp = ui.interact(r.intersect(clip), id, Sense::click());
|
||||
let p = ui.painter().with_clip_rect(clip);
|
||||
if on {
|
||||
p.rect_filled(r, 2.0, Color32::from_rgb(0x26, 0x5b, 0xc1));
|
||||
p.rect_filled(r, 2.0, t.track_target);
|
||||
p.text(r.center(), Align2::CENTER_CENTER, label, Tokens::semibold(10.0), Color32::from_rgb(0xeb, 0xeb, 0xeb));
|
||||
} else if show_off {
|
||||
if resp.hovered() {
|
||||
@@ -1159,38 +1183,6 @@ fn draw_headers(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, rows:
|
||||
egui::Id::new(("hdr", r.track.0)),
|
||||
Sense::click(),
|
||||
);
|
||||
app.auto.add(&format!("timeline.track.{label}.name"), resp.rect, "Track name (right-click to rename)");
|
||||
let rename_id = egui::Id::new(("timeline-track-rename", r.track.0));
|
||||
if resp.secondary_clicked() {
|
||||
// Start each menu opening from the committed name, not a cancelled draft.
|
||||
ui.data_mut(|d| d.insert_temp(rename_id, tr.name.clone()));
|
||||
}
|
||||
egui::Popup::context_menu(&resp).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| {
|
||||
ui.set_min_width(220.0);
|
||||
ui.label(tl!("Rename"));
|
||||
let mut name: String = ui.data(|d| d.get_temp(rename_id)).unwrap_or_else(|| tr.name.clone());
|
||||
let input = ui.add(egui::TextEdit::singleline(&mut name).desired_width(210.0));
|
||||
app.auto.add(&format!("timeline.track.{label}.renameField"), input.rect, "New track name");
|
||||
if resp.secondary_clicked() {
|
||||
input.request_focus();
|
||||
}
|
||||
let save_on_enter = input.has_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
|
||||
let valid = !name.trim().is_empty();
|
||||
ui.horizontal(|ui| {
|
||||
let apply = ui.add_enabled(valid, egui::Button::new(tl!("Apply")));
|
||||
app.auto.add(&format!("timeline.track.{label}.renameApply"), apply.rect, "Apply track name");
|
||||
if (apply.clicked() || (valid && save_on_enter)) && name.trim() != tr.name {
|
||||
actions.push(("timeline.setTrack".into(), json!({"track": r.track.0, "name": name.trim()})));
|
||||
ui.close();
|
||||
} else if apply.clicked() || (valid && save_on_enter) {
|
||||
ui.close();
|
||||
}
|
||||
if ui.button(tl!("Cancel")).clicked() {
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
ui.data_mut(|d| d.insert_temp(rename_id, name));
|
||||
});
|
||||
if resp.double_clicked() {
|
||||
let h = if r.kind == TrackKind::Video { &mut app.ui.timeline.video_track_h } else { &mut app.ui.timeline.audio_track_h };
|
||||
*h = if *h < 50.0 { 64.0 } else { 30.0 };
|
||||
@@ -1399,7 +1391,7 @@ fn draw_top(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, seq: &Sequenc
|
||||
clip.rect_filled(Rect::from_min_max(pos2(x, marker_y), pos2(layout.x_of(m.start + m.duration), marker_y + 8.0)), 0.0, c.gamma_multiply(0.6));
|
||||
}
|
||||
clip.add(egui::Shape::convex_polygon(shape, c, Stroke::NONE));
|
||||
let mr = Rect::from_center_size(pos2(x, marker_y + 6.0), vec2(10.0, 13.0));
|
||||
let mr = marker_rect(layout, m.start);
|
||||
app.auto.add(&format!("timeline.marker.{}", m.id.0), mr, &m.name);
|
||||
if ui.rect_contains_pointer(mr) && !m.name.is_empty() {
|
||||
egui::Tooltip::always_open(ui.ctx().clone(), ui.layer_id(), egui::Id::new(("mk", m.id.0)), egui::PopupAnchor::Pointer).show(|ui| {
|
||||
@@ -1613,7 +1605,14 @@ pub fn locate(app: &FilmcraftApp, clip: u64, edge: Option<&str>) -> Option<(f32,
|
||||
Some((x, y))
|
||||
}
|
||||
|
||||
fn preview_drag(app: &FilmcraftApp, seq: &Sequence, _layout: &Layout, out: &mut HashMap<ClipId, (Tick, Tick, Option<TrackId>)>) {
|
||||
fn preview_drag(app: &FilmcraftApp, seq: &Sequence, out: &mut HashMap<ClipId, (Tick, Tick, Option<TrackId>)>, edited: &mut HashMap<ClipId, TrackItem>) {
|
||||
// the item as the edge move leaves it, and its place in the preview
|
||||
let mut moved = |it: &TrackItem, edge: filmcraft_edit::Edge, delta: Tick| {
|
||||
let mut e = it.clone();
|
||||
filmcraft_edit::move_edge(&mut e, edge, delta, true);
|
||||
out.insert(e.id, (e.start, e.duration, None));
|
||||
edited.insert(e.id, e);
|
||||
};
|
||||
let Some(d) = &app.tl.drag else { return };
|
||||
match d {
|
||||
Drag::Move { clips, offset, track_delta, .. } => {
|
||||
@@ -1629,7 +1628,15 @@ fn preview_drag(app: &FilmcraftApp, seq: &Sequence, _layout: &Layout, out: &mut
|
||||
out.insert(*clip, (it.start, it.duration + *delta, None));
|
||||
}
|
||||
}
|
||||
Drag::Trim { clip, edge, delta, .. } | Drag::Stretch { clip, edge, delta, .. } => {
|
||||
Drag::Trim { clip, edge, delta, .. } => {
|
||||
for c in filmcraft_engine::commands::with_links(&app.session, &[*clip]) {
|
||||
if let Some((_, it)) = seq.find_item(c) {
|
||||
moved(it, *edge, *delta);
|
||||
}
|
||||
}
|
||||
}
|
||||
// a rate stretch keeps the same media in a new length: that one does squeeze
|
||||
Drag::Stretch { clip, edge, delta, .. } => {
|
||||
let ids = filmcraft_engine::commands::with_links(&app.session, &[*clip]);
|
||||
for c in ids {
|
||||
if let Some((_, it)) = seq.find_item(c) {
|
||||
@@ -1643,10 +1650,10 @@ fn preview_drag(app: &FilmcraftApp, seq: &Sequence, _layout: &Layout, out: &mut
|
||||
}
|
||||
Drag::Roll { left, right, delta, .. } => {
|
||||
if let Some((_, l)) = seq.find_item(*left) {
|
||||
out.insert(*left, (l.start, l.duration + *delta, None));
|
||||
moved(l, filmcraft_edit::Edge::Out, *delta);
|
||||
}
|
||||
if let Some((_, r)) = seq.find_item(*right) {
|
||||
out.insert(*right, (r.start + *delta, r.duration - *delta, None));
|
||||
moved(r, filmcraft_edit::Edge::In, *delta);
|
||||
}
|
||||
}
|
||||
Drag::Slide { clip, delta } => {
|
||||
@@ -1673,6 +1680,22 @@ fn shift_track(seq: &Sequence, tid: TrackId, delta: i32) -> Option<TrackId> {
|
||||
|
||||
/// The clip context menu: groups (separated by rules) of (label, command id). Entries marked `…`
|
||||
/// open their dialog through `menus::invoke`, like the same item in the Clip menu.
|
||||
/// The menu of a right-clicked Timeline marker (automation ids `timeline.markerMenu.<command>`);
|
||||
/// the marker is under the playhead, where Clear Selected Marker looks for it (#695).
|
||||
fn marker_menu(app: &mut FilmcraftApp, ctx: &egui::Context, ui: &mut egui::Ui) {
|
||||
for (label, cmd) in [("Clear Selected Marker", "markers.clearCurrent"), ("Clear Markers", "markers.clearAll")] {
|
||||
let label = crate::i18n::t(label);
|
||||
let r = ui.button(label);
|
||||
app.auto.add(&format!("timeline.markerMenu.{cmd}"), r.rect, label);
|
||||
if r.clicked() {
|
||||
if let Err(e) = crate::menus::invoke(app, ctx, cmd, json!({})) {
|
||||
app.ui.status = e;
|
||||
}
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) const CLIP_MENU: &[&[(&str, &str)]] = &[
|
||||
&[
|
||||
("Cut", "edit.cut"),
|
||||
@@ -2071,9 +2094,14 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: &
|
||||
// select (shift toggles; alt selects one side of a link)
|
||||
let sel = &app.session.state.selection;
|
||||
if mods.shift {
|
||||
let _ = app.session.execute("timeline.select", json!({"clips": [clip.0], "toggle": true, "linked": !mods.alt}));
|
||||
} else if mods.alt || !sel.contains(&clip) {
|
||||
let _ = app.session.execute("timeline.select", json!({"clips": [clip.0], "linked": !mods.alt}));
|
||||
let _ = app.session.execute("timeline.select", json!({"clips": [clip.0], "toggle": true}));
|
||||
} else if !sel.contains(&clip) {
|
||||
if mods.alt {
|
||||
app.session.state.selection = vec![clip];
|
||||
app.session.state.transition_selection.clear();
|
||||
} else {
|
||||
let _ = app.session.execute("timeline.select", json!({"clips": [clip.0]}));
|
||||
}
|
||||
}
|
||||
if resp.drag_started() {
|
||||
let clips = app.session.state.selection.clone();
|
||||
@@ -2128,6 +2156,10 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: &
|
||||
if mods.shift || app.session.prefs.timeline.snap_playhead {
|
||||
tt = snap_playhead(app, seq, layout, tt);
|
||||
}
|
||||
// a press on a marker selects it: the playhead goes exactly onto it (#695)
|
||||
if let Some(m) = marker_at(seq, layout, p) {
|
||||
tt = m.max(Tick::ZERO);
|
||||
}
|
||||
app.session.set_playhead(tt);
|
||||
}
|
||||
if resp.drag_started() {
|
||||
@@ -2179,7 +2211,9 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: &
|
||||
Drag::Trim { clip, edge, mode, from, .. } => seq.find_item(clip).map(|(_, it)| {
|
||||
let base = if edge == filmcraft_edit::Edge::In { it.start } else { it.end() };
|
||||
let target = snap(app, seq, layout, rate.snap_nearest(base + (t_here - from)), &[clip]);
|
||||
Drag::Trim { clip, edge, mode, delta: target - base, from }
|
||||
// stop at the end of the media (or the neighbour), as the trim on release will (#653)
|
||||
let delta = filmcraft_engine::commands::trim_delta(&app.session, clip, edge, mode, target - base).unwrap_or(target - base);
|
||||
Drag::Trim { clip, edge, mode, delta, from }
|
||||
}),
|
||||
Drag::Remix { clip, from, .. } => seq.find_item(clip).map(|(_, it)| {
|
||||
let target = rate.snap_nearest(it.end() + (t_here - from)).max(it.start + rate.frame_duration());
|
||||
@@ -2327,6 +2361,17 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: &
|
||||
ctx.data_mut(|d| d.insert_temp::<Option<EditPointTarget>>(edit_point_menu_id, None));
|
||||
}
|
||||
}
|
||||
// on a marker of the ruler, the marker menu instead (right-clicking a marker selects it first)
|
||||
let marker_menu_id = egui::Id::new("timeline.markerMenu.open");
|
||||
if resp.secondary_clicked()
|
||||
&& let Some(p) = resp.interact_pointer_pos()
|
||||
{
|
||||
let marker = marker_at(seq, layout, p);
|
||||
if let Some(m) = marker {
|
||||
app.session.set_playhead(m.max(Tick::ZERO));
|
||||
}
|
||||
ctx.data_mut(|d| d.insert_temp(marker_menu_id, marker.is_some()));
|
||||
}
|
||||
if resp.secondary_clicked()
|
||||
&& let Some(p) = resp.interact_pointer_pos()
|
||||
&& let Hit::Clip { clip, edge, track } = hit(seq, layout, p)
|
||||
@@ -2354,6 +2399,10 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: &
|
||||
}
|
||||
resp.context_menu(|ui| {
|
||||
ui.set_min_width(220.0);
|
||||
if ctx.data(|d| d.get_temp::<bool>(marker_menu_id)).unwrap_or(false) {
|
||||
marker_menu(app, &ctx, ui);
|
||||
return;
|
||||
}
|
||||
if let Some(target) = ctx.data(|d| d.get_temp::<Option<EditPointTarget>>(edit_point_menu_id)).flatten() {
|
||||
edit_point_menu(app, &ctx, ui, seq, target);
|
||||
return;
|
||||
@@ -2432,21 +2481,34 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: &
|
||||
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));
|
||||
// #483: in the empty space above the top video track or below the last audio track, a new track
|
||||
let new_track = if row.is_some() { None } else { new_track_at(seq, layout, p.y, app.ui.timeline.video_track_h, app.ui.timeline.audio_track_h) }.filter(
|
||||
|(kind, _)| {
|
||||
let carries = app.session.project.item(item).is_some_and(|i| if *kind == TrackKind::Video { i.has_video() } else { i.has_audio() });
|
||||
carries && source.is_none_or(|s| if *kind == TrackKind::Video { s.video } else { s.audio })
|
||||
},
|
||||
);
|
||||
if let Some(band) = row.as_ref().map(|r| r.rect).or(new_track.map(|(_, band)| band)) {
|
||||
let r = Rect::from_min_max(pos2(layout.x_of(t), band.min.y + 1.0), pos2(layout.x_of(t + dur), band.max.y - 1.0));
|
||||
ui.painter().rect_filled(r, 3.0, Color32::from_white_alpha(40));
|
||||
ui.painter().rect_stroke(r, 3.0, Stroke::new(1.5, Color32::WHITE), StrokeKind::Inside);
|
||||
if mods.command {
|
||||
ui.painter().text(r.left_top() + vec2(4.0, -2.0), Align2::LEFT_BOTTOM, tl!("Insert"), Tokens::ui(10.0), Color32::WHITE);
|
||||
}
|
||||
}
|
||||
let target = match (&row, new_track) {
|
||||
(Some(row), _) => Some(match row.kind {
|
||||
TrackKind::Video => (json!(row.track.0), json!(seq.audio_tracks.get(row.index).or(seq.audio_tracks.first()).map(|t| t.id.0))),
|
||||
TrackKind::Audio => (json!(seq.video_tracks.get(row.index).or(seq.video_tracks.first()).map(|t| t.id.0)), json!(row.track.0)),
|
||||
}),
|
||||
// the other stream goes on the track of the same number, or on a new one as well
|
||||
(None, Some((TrackKind::Video, _))) => Some((json!("new"), seq.audio_tracks.get(seq.video_tracks.len()).map_or(json!("new"), |t| json!(t.id.0)))),
|
||||
(None, Some((TrackKind::Audio, _))) => Some((seq.video_tracks.get(seq.audio_tracks.len()).map_or(json!("new"), |t| json!(t.id.0)), json!("new"))),
|
||||
(None, None) => None,
|
||||
};
|
||||
if ctx.input(|i| i.pointer.any_released())
|
||||
&& let Some(row) = row
|
||||
&& let Some((vt, at)) = target
|
||||
{
|
||||
let (vt, at) = match row.kind {
|
||||
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 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);
|
||||
@@ -2523,6 +2585,8 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: &
|
||||
if ctx.input(|i| i.pointer.any_released()) {
|
||||
let r = if let Some(name) = effect.strip_prefix("preset:") {
|
||||
app.session.execute("presets.apply", json!({"preset": name, "clips": [clip.0]}))
|
||||
} else if let Some(name) = effect.strip_prefix(crate::panels::LUMETRI_PRESET_PREFIX) {
|
||||
app.session.execute("lumetri.applyPreset", json!({"name": name, "clips": [clip.0]}))
|
||||
} else if transition_kind.is_some() {
|
||||
app.session.execute("effects.apply", params)
|
||||
} else {
|
||||
@@ -2596,6 +2660,72 @@ pub(crate) fn chain_starts(start: Tick, durations: &[Tick]) -> Vec<Tick> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// #374: trimming an audio clip must cut its waveform off at the moving edge, not squeeze the whole
|
||||
/// waveform into the shorter clip: the preview draws from the item the trim will leave, which shows
|
||||
/// the same media at every timeline time it still covers.
|
||||
#[cfg(test)]
|
||||
mod trim_preview_tests {
|
||||
use super::*;
|
||||
|
||||
fn demo() -> (FilmcraftApp, Sequence) {
|
||||
let mut s = filmcraft_engine::Session::default();
|
||||
s.execute("file.openDemoProject", serde_json::json!({})).unwrap();
|
||||
let seq = s.active_sequence().unwrap().clone();
|
||||
(FilmcraftApp::new(s), seq)
|
||||
}
|
||||
|
||||
fn assert_same_media(before: &TrackItem, after: &TrackItem) {
|
||||
let mut t = after.start.max(before.start);
|
||||
while t < after.end().min(before.end()) {
|
||||
assert!((before.source_time_at(t) - after.source_time_at(t)).0.abs() <= 1, "{}: media moved at {t:?}", before.name);
|
||||
t += Tick(filmcraft_time::TICKS_PER_SECOND / 10);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trimming_the_music_keeps_its_waveform_in_place() {
|
||||
let (mut app, seq) = demo();
|
||||
let music = seq.audio_tracks.iter().flat_map(|t| &t.items).find(|i| i.name.contains("Ambient")).unwrap().clone();
|
||||
for (edge, delta) in [(filmcraft_edit::Edge::In, 5), (filmcraft_edit::Edge::Out, -5), (filmcraft_edit::Edge::In, -1)] {
|
||||
let delta = Tick(delta * filmcraft_time::TICKS_PER_SECOND);
|
||||
app.tl.drag = Some(Drag::Trim { clip: music.id, edge, mode: filmcraft_edit::TrimMode::Regular, delta, from: Tick::ZERO });
|
||||
let (mut out, mut edited) = (HashMap::new(), HashMap::new());
|
||||
preview_drag(&app, &seq, &mut out, &mut edited);
|
||||
let shown = edited.get(&music.id).expect("the trimmed clip is drawn from its edited item");
|
||||
// where the preview places it is unchanged
|
||||
let want = match edge {
|
||||
filmcraft_edit::Edge::In => (music.start + delta, music.duration - delta, None),
|
||||
filmcraft_edit::Edge::Out => (music.start, music.duration + delta, None),
|
||||
};
|
||||
assert_eq!(out.get(&music.id).copied(), Some(want));
|
||||
assert_eq!((shown.start, shown.duration), (want.0, want.1));
|
||||
// the music under every remaining timeline time is the same: the waveform stays put
|
||||
assert_same_media(&music, shown);
|
||||
if edge == filmcraft_edit::Edge::In {
|
||||
assert_eq!(shown.source_in, music.source_in + delta, "5 s into the music, not the music squeezed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rolling_an_edit_keeps_both_sides_in_place_but_a_stretch_still_squeezes() {
|
||||
let (mut app, seq) = demo();
|
||||
let (left, right) = seq.audio_tracks[0].items.windows(2).find(|w| w[0].end() == w[1].start).map(|w| (w[0].clone(), w[1].clone())).unwrap();
|
||||
let delta = Tick(filmcraft_time::TICKS_PER_SECOND / 2);
|
||||
app.tl.drag = Some(Drag::Roll { left: left.id, right: right.id, delta, from: Tick::ZERO });
|
||||
let (mut out, mut edited) = (HashMap::new(), HashMap::new());
|
||||
preview_drag(&app, &seq, &mut out, &mut edited);
|
||||
assert_same_media(&left, &edited[&left.id]);
|
||||
assert_same_media(&right, &edited[&right.id]);
|
||||
assert_eq!(edited[&right.id].start, right.start + delta);
|
||||
// Rate Stretch keeps the same media in a new length: drawn from the original item
|
||||
app.tl.drag = Some(Drag::Stretch { clip: left.id, edge: filmcraft_edit::Edge::Out, delta, from: Tick::ZERO });
|
||||
let (mut out, mut edited) = (HashMap::new(), HashMap::new());
|
||||
preview_drag(&app, &seq, &mut out, &mut edited);
|
||||
assert!(edited.is_empty() && out.contains_key(&left.id));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod waveform_tests {
|
||||
use super::waveform_display_gain;
|
||||
@@ -2700,3 +2830,40 @@ mod thumbnail_tiles_tests {
|
||||
assert!(thumbnail_tiles(ThumbnailMode::Continuous, 0.0, 0.0, 80.0, (0.0, 800.0), dur, FRAME).is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod new_track_tests {
|
||||
use super::*;
|
||||
use filmcraft_project::{Project, SequenceSettings};
|
||||
|
||||
/// #483: a drop above the top video track adds a video track, one below the last audio track an
|
||||
/// audio track; over a track, between the areas or on the Mix row it adds none.
|
||||
#[test]
|
||||
fn empty_space_past_the_tracks_adds_a_track() {
|
||||
let mut p = Project::new("t");
|
||||
let id = p.new_sequence("s", SequenceSettings::default(), 1, 1, None);
|
||||
let seq = p.sequence(id).cloned().expect("sequence");
|
||||
// video area 0..200 (V1 at 150..200), divider, audio area 205..466 (A1 at 205..255), Mix 466..500
|
||||
let row = |kind, track: &filmcraft_project::Track, y0: f32| Row {
|
||||
track: track.id,
|
||||
kind,
|
||||
index: 0,
|
||||
rect: Rect::from_min_max(pos2(0.0, y0), pos2(800.0, y0 + 50.0)),
|
||||
lane: false,
|
||||
};
|
||||
let layout = Layout {
|
||||
content: Rect::from_min_max(pos2(0.0, 0.0), pos2(800.0, 500.0)),
|
||||
ruler: Rect::from_min_max(pos2(0.0, -20.0), pos2(800.0, 0.0)),
|
||||
rows: vec![row(TrackKind::Video, &seq.video_tracks[0], 150.0), row(TrackKind::Audio, &seq.audio_tracks[0], 205.0)],
|
||||
pps: 100.0,
|
||||
scroll: 0.0,
|
||||
split_y: 200.0,
|
||||
};
|
||||
let at = |y| new_track_at(&seq, &layout, y, 50.0, 50.0);
|
||||
assert_eq!(at(40.0), Some((TrackKind::Video, Rect::from_min_max(pos2(0.0, 100.0), pos2(800.0, 150.0)))));
|
||||
assert_eq!(at(300.0), Some((TrackKind::Audio, Rect::from_min_max(pos2(0.0, 255.0), pos2(800.0, 305.0)))));
|
||||
for y in [160.0, 202.0, 230.0, 480.0] {
|
||||
assert_eq!(at(y), None, "y = {y}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,9 +5,10 @@
|
||||
|
||||
use std::cmp::Ordering;
|
||||
|
||||
use egui::{Modifiers, Pos2};
|
||||
use egui::{Modifiers, Pos2, Rect, pos2, vec2};
|
||||
use filmcraft_edit::Edge;
|
||||
use filmcraft_project::{ClipId, Sequence, Track, TrackId, TransitionId};
|
||||
use filmcraft_time::Tick;
|
||||
|
||||
use super::timeline::Layout;
|
||||
use crate::state::Tool;
|
||||
@@ -68,6 +69,18 @@ pub enum Hit {
|
||||
None,
|
||||
}
|
||||
|
||||
/// Where the Timeline ruler draws (and hit-tests) the pentagon of a marker at `start`.
|
||||
pub fn marker_rect(layout: &Layout, start: Tick) -> Rect {
|
||||
Rect::from_center_size(pos2(layout.x_of(start), layout.ruler.min.y + 8.0), vec2(10.0, 13.0))
|
||||
}
|
||||
|
||||
/// The start of the marker whose pentagon is under `pos` on the ruler (the topmost, i.e. last
|
||||
/// drawn, when several overlap). A press there puts the playhead on the marker, so Markers ▸ Clear
|
||||
/// Selected Marker removes it (#695).
|
||||
pub fn marker_at(seq: &Sequence, layout: &Layout, pos: Pos2) -> Option<Tick> {
|
||||
seq.markers.iter().rev().map(|m| m.start).find(|t| marker_rect(layout, *t).contains(pos))
|
||||
}
|
||||
|
||||
pub fn hit(seq: &Sequence, layout: &Layout, pos: Pos2) -> Hit {
|
||||
if layout.ruler.contains(pos) {
|
||||
return Hit::Ruler;
|
||||
@@ -404,6 +417,26 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_press_on_a_marker_lands_on_its_start() {
|
||||
let (mut seq, l, _) = fixture(clips());
|
||||
let off = Tick::from_seconds_f64(3.017);
|
||||
seq.markers.push(filmcraft_project::Marker {
|
||||
id: filmcraft_project::MarkerId(9),
|
||||
start: off,
|
||||
duration: Tick::ZERO,
|
||||
name: String::new(),
|
||||
comment: String::new(),
|
||||
kind: filmcraft_project::MarkerKind::Comment,
|
||||
color: Label::Green,
|
||||
});
|
||||
// 3 px beside the pentagon's tip, where the nearest frame is not the marker's time
|
||||
assert_eq!(marker_at(&seq, &l, pos2(304.7, 10.0)), Some(off));
|
||||
assert_eq!(marker_at(&seq, &l, pos2(310.0, 10.0)), None);
|
||||
assert_eq!(marker_at(&seq, &l, pos2(301.7, 30.0)), None);
|
||||
assert_eq!(marker_at(&seq, &l, pos2(f32::NAN, 10.0)), None);
|
||||
}
|
||||
|
||||
use egui::Modifiers;
|
||||
|
||||
use crate::state::Tool;
|
||||
|
||||
@@ -74,6 +74,11 @@ pub fn effect_controls(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, id
|
||||
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(rect.shrink2(vec2(10.0, 6.0))).id_salt(("tr-ec", id.0)));
|
||||
child.label(RichText::new(format!("{seq_name} · {name}")).color(t.text_dim));
|
||||
child.add_space(4.0);
|
||||
// the line under each row, as for a clip's effects (#640): halfway into the row spacing
|
||||
let row_line = |ui: &egui::Ui| {
|
||||
let y = ui.cursor().top() - 4.0;
|
||||
ui.painter().line_segment([egui::pos2(rect.min.x, y), egui::pos2(rect.max.x, y)], egui::Stroke::new(1.0, t.separator));
|
||||
};
|
||||
egui::ScrollArea::vertical().id_salt(("tr-ec-scroll", id.0)).auto_shrink([false, false]).show(&mut child, |ui| {
|
||||
egui::Grid::new(("tr-ec-grid", id.0)).num_columns(2).spacing([14.0, 8.0]).show(ui, |ui| {
|
||||
ui.label(tl!("Duration"));
|
||||
@@ -84,6 +89,7 @@ pub fn effect_controls(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, id
|
||||
changes.push(json!({"frames": frames, "merge": true, "begin": r.drag_started() || !r.dragged()}));
|
||||
}
|
||||
ui.end_row();
|
||||
row_line(ui);
|
||||
|
||||
ui.label(tl!("Alignment"));
|
||||
let shown = align.label();
|
||||
@@ -106,6 +112,7 @@ pub fn effect_controls(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, id
|
||||
.inner;
|
||||
elems.push(("effectControls.transition.alignment".into(), r.rect, shown.to_string()));
|
||||
ui.end_row();
|
||||
row_line(ui);
|
||||
|
||||
ui.label(tl!("Reverse"));
|
||||
let mut rev = x.reverse;
|
||||
@@ -115,6 +122,7 @@ pub fn effect_controls(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, id
|
||||
changes.push(json!({"reverse": rev}));
|
||||
}
|
||||
ui.end_row();
|
||||
row_line(ui);
|
||||
|
||||
for pd in def.map(|d| d.params.as_slice()).unwrap_or(&[]) {
|
||||
let value = x.effect.params.get(pd.id).map_or(&pd.default, |p| &p.value);
|
||||
@@ -123,6 +131,7 @@ pub fn effect_controls(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, id
|
||||
changes.push(v);
|
||||
}
|
||||
ui.end_row();
|
||||
row_line(ui);
|
||||
}
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
|
||||
@@ -442,6 +442,10 @@ pub struct UiState {
|
||||
pub lumetri_grid_folder: Option<String>,
|
||||
/// Collapsed effect sections in Effect Controls ("clip:index").
|
||||
pub collapsed_fx: Vec<String>,
|
||||
/// Width of Effect Controls' effect list, left of the keyframe area (points; 0 = the default
|
||||
/// share of the panel). Dragging the divider sets it (#643).
|
||||
#[serde(default)]
|
||||
pub effect_controls_split: f32,
|
||||
pub show_menu_bar: bool,
|
||||
/// The header bar (Home, Import, Edit, Export, workspaces). An app that embeds FilmCraft can hide it.
|
||||
#[serde(default = "shown")]
|
||||
@@ -952,6 +956,7 @@ impl Default for UiState {
|
||||
expanded_fx: vec!["Video Transitions".into(), "Video Transitions/Dissolve".into()],
|
||||
lumetri_grid_folder: None,
|
||||
collapsed_fx: vec![],
|
||||
effect_controls_split: 0.0,
|
||||
show_menu_bar: true,
|
||||
show_header: true,
|
||||
show_status_bar: true,
|
||||
|
||||
@@ -133,6 +133,8 @@ pub struct Tokens {
|
||||
pub tl_ruler_tick: Color32,
|
||||
pub tl_ruler_text: Color32,
|
||||
pub playhead: Color32,
|
||||
/// Fill of a track header's source-patch / track-targeting button while it is on.
|
||||
pub track_target: Color32,
|
||||
pub in_out_shade: Color32,
|
||||
pub clip_selected_border: Color32,
|
||||
pub render_red: Color32,
|
||||
@@ -225,6 +227,7 @@ impl Tokens {
|
||||
tl_ruler_tick: Color32::from_rgb(0x8d, 0x8d, 0x8d),
|
||||
tl_ruler_text: Color32::from_rgb(0xb0, 0xb0, 0xb0),
|
||||
playhead: Color32::from_rgb(0x58, 0x95, 0xec),
|
||||
track_target: Color32::from_rgb(0x26, 0x5b, 0xc1),
|
||||
in_out_shade: Color32::from_rgb(0x3f, 0x3f, 0x3f),
|
||||
clip_selected_border: Color32::from_rgb(0xeb, 0xeb, 0xeb),
|
||||
render_red: Color32::from_rgb(0xe3, 0x48, 0x50),
|
||||
@@ -330,11 +333,28 @@ impl Tokens {
|
||||
}
|
||||
}
|
||||
|
||||
/// Settings ▸ Appearance on top of a theme: the highlight colour (selections, focus, primary
|
||||
/// buttons) and accessible colour contrast (brighter secondary text and borders).
|
||||
/// Settings ▸ Appearance on top of a theme: the highlight colour (selections, primary buttons,
|
||||
/// the focus outline, the playhead, timecodes, drag values and track targeting) and accessible
|
||||
/// colour contrast (brighter secondary text and borders).
|
||||
pub fn with_appearance(mut self, highlight: Option<[u8; 3]>, accessible_contrast: bool) -> Self {
|
||||
if let Some([r, g, b]) = highlight {
|
||||
let c = Color32::from_rgb(r, g, b);
|
||||
// A highlight of the user's own also colours the theme's other blues: the same hue, as
|
||||
// much more or less saturated and bright as each of them is than the theme's highlight.
|
||||
// The theme's own highlight leaves them exactly as designed.
|
||||
if c != self.accent {
|
||||
let tint = |s: f32, v: f32| {
|
||||
let mut h = egui::ecolor::HsvaGamma::from(c);
|
||||
h.s = (h.s * s).clamp(0.0, 1.0);
|
||||
h.v = (h.v * v).clamp(0.0, 1.0);
|
||||
Color32::from(h)
|
||||
};
|
||||
self.focus = tint(0.92, 1.06);
|
||||
self.playhead = self.focus;
|
||||
self.timecode = tint(0.98, 1.09);
|
||||
self.hot_text = self.timecode;
|
||||
self.track_target = tint(1.0, 0.865);
|
||||
}
|
||||
let lift = |v: u8| v.saturating_add(16);
|
||||
self.accent = c;
|
||||
self.accent_hover = Color32::from_rgb(lift(r), lift(g), lift(b));
|
||||
@@ -550,6 +570,32 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The colours the highlight drives besides the accent (#707).
|
||||
fn highlight_blues(t: &Tokens) -> [Color32; 5] {
|
||||
[t.focus, t.playhead, t.timecode, t.hot_text, t.track_target]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_default_highlight_leaves_the_theme_blues_as_designed() {
|
||||
let default = filmcraft_engine::settings::parse_hex(filmcraft_engine::settings::DEFAULT_HIGHLIGHT);
|
||||
for k in [ThemeKind::Dark, ThemeKind::Medium, ThemeKind::Light] {
|
||||
assert_eq!(highlight_blues(&Tokens::for_kind(k).with_appearance(default, false)), highlight_blues(&Tokens::for_kind(k)), "{k:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_highlight_of_ones_own_colours_the_playhead_timecodes_focus_and_targeting() {
|
||||
let pink = Color32::from_rgb(0xe0, 0x45, 0x7b);
|
||||
let t = Tokens::for_kind(ThemeKind::Dark).with_appearance(Some([0xe0, 0x45, 0x7b]), false);
|
||||
let hue = |c: Color32| egui::ecolor::HsvaGamma::from(c).h;
|
||||
for (c, was) in highlight_blues(&t).into_iter().zip(highlight_blues(&Tokens::for_kind(ThemeKind::Dark))) {
|
||||
assert_ne!(c, was);
|
||||
assert!((hue(c) - hue(pink)).abs() < 0.01, "{c:?} has the highlight's hue");
|
||||
}
|
||||
let v = |c: Color32| egui::ecolor::HsvaGamma::from(c).v;
|
||||
assert!(v(t.playhead) > v(pink) && v(t.timecode) > v(pink) && v(t.track_target) < v(pink), "lighter and darker as the default blues are");
|
||||
}
|
||||
|
||||
/// Chinese media and track names must render even when the interface stays in English.
|
||||
/// Compare the rasterized glyphs with the missing-glyph box, including custom weight families.
|
||||
#[test]
|
||||
|
||||
@@ -324,3 +324,26 @@ fn lumetri_presets_in_the_effects_panel() {
|
||||
let e = lumetri(&mut d, clip);
|
||||
assert_eq!(e["params"]["temperature"]["value"], json!("Float(35.0)"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lumetri_presets_drag_onto_a_timeline_clip() {
|
||||
let mut d = Driver::demo();
|
||||
d.ok("ui.panel.show", json!({"panel": "Effects"}));
|
||||
d.frames(3);
|
||||
// the first clip is selected; the preset is dropped on the second, which gets it alone
|
||||
let selected = select_first_clip(&mut d);
|
||||
let seq = d.exec("sequence.inspect", json!({}));
|
||||
let target = seq["video"][0]["items"][1]["clip"].as_u64().unwrap_or_else(|| panic!("no second clip: {seq}"));
|
||||
d.click("effects.folder.Lumetri Presets");
|
||||
d.click("effects.folder.Lumetri Presets/Monochrome");
|
||||
d.frames(2);
|
||||
assert!(d.element(&format!("timeline.clip.{target}")).is_some(), "target clip not on screen");
|
||||
d.ok("ui.drag", json!({"from": {"id": "effects.lumetriPreset.Neutral Mono"}, "to": {"id": format!("timeline.clip.{target}")}, "steps": 8}));
|
||||
d.frames(3);
|
||||
let e = lumetri(&mut d, target);
|
||||
assert_eq!(e["params"]["saturation"]["value"], json!("Float(0.0)"), "dropped preset on the target clip: {e}");
|
||||
assert_eq!(lumetri(&mut d, selected), Value::Null, "the selected clip it was not dropped on is untouched");
|
||||
// one undo step removes it again
|
||||
d.exec("edit.undo", json!({}));
|
||||
assert_eq!(lumetri(&mut d, target), Value::Null);
|
||||
}
|
||||
|
||||
@@ -71,31 +71,6 @@ impl Driver {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alt_click_isolates_already_selected_linked_clip_before_delete() {
|
||||
for audio in [false, true] {
|
||||
let mut d = Driver::new();
|
||||
let video = d.app().session.active_sequence().unwrap().video_tracks[0].items[0].clone();
|
||||
let sound = d.app().session.active_sequence().unwrap().audio_tracks[0].items.iter().find(|i| i.link == video.link).unwrap().clone();
|
||||
let (selected, partner) = if audio { (sound.id, video.id) } else { (video.id, sound.id) };
|
||||
d.exec("timeline.select", json!({"clips": [selected.0]}));
|
||||
assert_eq!(d.app().session.state.selection.len(), 2);
|
||||
let r = d.app().auto.find(&format!("timeline.clip.{}", selected.0)).unwrap().rect;
|
||||
d.harness.input_mut().events.push(egui::Event::ModifiersChanged(egui::Modifiers::ALT));
|
||||
d.frames(1);
|
||||
d.ok("ui.click", json!({"x": r[0] + r[2] * 0.5, "y": r[1] + r[3] * 0.3, "modifiers": {"alt": true}}));
|
||||
d.frames(3);
|
||||
assert_eq!(d.app().session.state.selection, vec![selected]);
|
||||
d.harness.input_mut().events.push(egui::Event::ModifiersChanged(egui::Modifiers::NONE));
|
||||
d.frames(1);
|
||||
d.key("Delete");
|
||||
assert!(d.app().session.active_sequence().unwrap().find_item(selected).is_none());
|
||||
assert!(d.app().session.active_sequence().unwrap().find_item(partner).is_some());
|
||||
d.exec("edit.undo", json!({}));
|
||||
assert!(d.app().session.active_sequence().unwrap().find_item(selected).is_some());
|
||||
}
|
||||
}
|
||||
|
||||
/// Ids added for keyboard parity (engine + UI).
|
||||
fn new_ids() -> Vec<String> {
|
||||
let mut v: Vec<String> = filmcraft_engine::keyboard::commands().iter().map(|c| c.id.to_string()).collect();
|
||||
|
||||
@@ -184,6 +184,22 @@ fn properties_diamond_adds_and_removes_the_keyframe_at_the_playhead() {
|
||||
assert!(d.find(&format!("{SCALE}.prevKeyframe")).is_none());
|
||||
}
|
||||
|
||||
/// #284: every Effect Controls property has a reset button; on an animated property it puts the
|
||||
/// default in as a keyframe at the playhead and keeps the other keyframes.
|
||||
#[test]
|
||||
fn effect_controls_reset_button_keeps_keyframes() {
|
||||
let (mut d, clip) = Driver::demo();
|
||||
let t0 = d.seek(0.5);
|
||||
d.click(&format!("{SCALE}.addKeyframe"));
|
||||
d.exec("effects.setParam", json!({"clip": clip.id, "effect": "motion", "param": "scale", "value": 150.0, "time": t0}));
|
||||
d.seek(2.5);
|
||||
d.click("effectControls.motion.scale.reset");
|
||||
d.shot("effect-controls-reset");
|
||||
let (keys, value) = d.scale(&clip);
|
||||
assert_eq!(keys.len(), 2, "the keyframe stays and the default is keyed at the playhead: {keys:?}");
|
||||
assert_eq!(value, 100.0);
|
||||
}
|
||||
|
||||
/// Crop is not on a clip until it is used: its diamond applies the effect, then adds the keyframe.
|
||||
#[test]
|
||||
fn properties_diamond_applies_crop_first() {
|
||||
@@ -258,3 +274,42 @@ fn clicking_a_lane_keyframe_selects_only_that_keyframe() {
|
||||
d.click("effectControls.motion.position.addKeyframe");
|
||||
assert_eq!(selection(&mut d), json!([]), "a deleted keyframe is not selected");
|
||||
}
|
||||
|
||||
/// Effect Controls' divider (#643): dragging it widens the effect list or the keyframe area, each
|
||||
/// keeping a minimum width, and the width stays. The mouse wheel scrolls the rows over the
|
||||
/// keyframe area too, not only over the effect list.
|
||||
#[test]
|
||||
fn effect_controls_divider_and_scrolling_anywhere() {
|
||||
let (mut d, clip) = Driver::demo();
|
||||
let lane = d.rect("effectControls.lane");
|
||||
let div = d.rect("effectControls.divider");
|
||||
let (x, y) = (div[0] + div[2] / 2.0, div[1] + div[3] / 2.0);
|
||||
d.ok("ui.drag", json!({"from": {"x": x, "y": y}, "to": {"x": x + 80.0, "y": y}, "steps": 6}));
|
||||
let narrower = d.rect("effectControls.lane");
|
||||
assert!((narrower[0] - (lane[0] + 80.0)).abs() <= 1.0, "the keyframe area starts 80 points further right: {narrower:?} vs {lane:?}");
|
||||
assert!((narrower[0] + narrower[2] - (lane[0] + lane[2])).abs() <= 1.0, "and still ends at the panel's edge");
|
||||
d.frames(3);
|
||||
assert_eq!(d.rect("effectControls.lane"), narrower, "the width stays");
|
||||
// dragged all the way right, the keyframe area keeps its minimum width
|
||||
let drag_divider = |d: &mut Driver, by: f64| {
|
||||
let div = d.rect("effectControls.divider");
|
||||
let (x, y) = (div[0] + div[2] / 2.0, div[1] + div[3] / 2.0);
|
||||
d.ok("ui.drag", json!({"from": {"x": x, "y": y}, "to": {"x": x + by, "y": y}, "steps": 6}));
|
||||
};
|
||||
drag_divider(&mut d, 3000.0);
|
||||
assert!(d.rect("effectControls.lane")[2] >= 59.0, "the keyframe area can't vanish");
|
||||
// and all the way left, the effect list keeps the width its rows are laid out for
|
||||
drag_divider(&mut d, -3000.0);
|
||||
assert!(d.rect("effectControls.effect.motion")[2] >= 259.0, "the effect list stays wide enough for its values");
|
||||
|
||||
// enough effects to scroll; the wheel over the keyframe area scrolls the rows
|
||||
for fx in ["gaussian_blur", "transform", "crop", "tint"] {
|
||||
d.exec("effects.apply", json!({"clips": [clip.id], "effect": fx}));
|
||||
}
|
||||
let before = d.rect("effectControls.motion.scale.stopwatch");
|
||||
let lane = d.rect("effectControls.lane");
|
||||
d.ok("ui.scroll", json!({"x": lane[0] + lane[2] / 2.0, "y": lane[1] + lane[3] * 0.7, "dx": 0.0, "dy": -80.0}));
|
||||
d.frames(4);
|
||||
let after = d.find("effectControls.motion.scale.stopwatch");
|
||||
assert!(after.is_none_or(|r| r[1] < before[1] - 20.0), "the rows scrolled up: {before:?} → {after:?}");
|
||||
}
|
||||
|
||||
@@ -158,7 +158,7 @@ fn every_panel_opens_from_the_window_menu() {
|
||||
let menu = d.ok("ui.menu.list", json!({}));
|
||||
let window: Vec<String> =
|
||||
menu.as_array().unwrap().iter().filter(|m| m["path"] == json!(["Window"])).filter_map(|m| m["label"].as_str().map(str::to_string)).collect();
|
||||
for p in ["Events", "Lumetri Scopes", "Metadata", "Progress", "Reference Monitor", "Timecode"] {
|
||||
for p in ["Events", "Lumetri Scopes", "Metadata", "Progress", "Project Notes", "Reference Monitor", "Timecode"] {
|
||||
assert!(window.iter().any(|w| w == p), "{p} not in Window: {window:?}");
|
||||
}
|
||||
for (panel, key) in [
|
||||
@@ -168,6 +168,7 @@ fn every_panel_opens_from_the_window_menu() {
|
||||
("Events", "events.clearAll"),
|
||||
("Progress", "progress.showFinished"),
|
||||
("ReferenceMonitor", "reference.gang"),
|
||||
("ProjectNotes", "notes.text"),
|
||||
] {
|
||||
d.ok("ui.menu.invoke", json!({"id": format!("window.panel.{panel}")}));
|
||||
d.frames(3);
|
||||
@@ -267,6 +268,31 @@ fn metadata_panel_edits_a_field_with_undo() {
|
||||
assert_eq!(d.label("metadata.field.TapeName"), "A001");
|
||||
}
|
||||
|
||||
/// #620: notes typed into Project Notes land in the project; one typing session is one undo step.
|
||||
#[test]
|
||||
fn project_notes_panel_types_into_the_project_with_undo() {
|
||||
let mut d = Driver::demo();
|
||||
d.ok("ui.menu.invoke", json!({"id": "window.panel.ProjectNotes"}));
|
||||
d.frames(3);
|
||||
let undo0 = d.app().session.history.undo.len();
|
||||
d.click("notes.text");
|
||||
d.ok("ui.type", json!({"text": "Fix logo"}));
|
||||
d.frames(2);
|
||||
d.ok("ui.key", json!({"key": "Enter"}));
|
||||
d.ok("ui.type", json!({"text": "at 1:02"}));
|
||||
d.frames(3);
|
||||
assert_eq!(d.app().session.project.notes, "Fix logo\nat 1:02");
|
||||
assert_eq!(d.app().session.history.undo.len(), undo0 + 1);
|
||||
assert_eq!(d.label("notes.text"), "Fix logo\nat 1:02");
|
||||
d.exec("edit.undo", json!({}));
|
||||
d.frames(2);
|
||||
assert_eq!(d.app().session.project.notes, "");
|
||||
// an agent writes them, the panel shows them
|
||||
d.exec("project.setNotes", json!({"text": "From the script"}));
|
||||
d.frames(2);
|
||||
assert_eq!(d.label("notes.text"), "From the script");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timecode_panel_rows_and_modes() {
|
||||
let mut d = Driver::demo();
|
||||
|
||||
@@ -485,6 +485,41 @@ fn select_all_takes_only_the_shown_bin() {
|
||||
assert_eq!(sorted(&d.app().session.state.project_selection), footage);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dragged_bin_nests_in_another_and_back_out() {
|
||||
// #456: a bin stayed wherever it was created; dragging it onto another bin did nothing
|
||||
let mut d = project_driver();
|
||||
d.exec("project.view.set", json!({"view": "list"}));
|
||||
let outer = d.exec("file.newBin", json!({"name": "Outer"}))["bin"].as_u64().unwrap();
|
||||
let inner = d.exec("file.newBin", json!({"name": "Inner"}))["bin"].as_u64().unwrap();
|
||||
d.app().ui.expanded_bins.clear();
|
||||
d.frames(3);
|
||||
let parent =
|
||||
|d: &mut Driver, b: u64| filmcraft_ui_egui::panels::project::parent_bin(&d.app().session.project.root, filmcraft_project::BinId(b)).map(|p| p.0);
|
||||
let root = d.app().session.project.root.id.0;
|
||||
let drag = |d: &mut Driver, from: [f32; 4], to: (f32, f32)| {
|
||||
let start = (from[0] + 80.0, from[1] + from[3] / 2.0);
|
||||
d.ok("ui.drag", json!({"from": {"x": start.0, "y": start.1}, "to": {"x": to.0, "y": to.1}, "steps": 12}));
|
||||
d.frames(3);
|
||||
};
|
||||
// onto the Outer row: Inner nests in it
|
||||
let (from, to) = (d.rect(&format!("project.bin.{inner}")), d.rect(&format!("project.bin.{outer}")));
|
||||
drag(&mut d, from, (to[0] + 80.0, to[1] + to[3] / 2.0));
|
||||
assert_eq!(parent(&mut d, inner), Some(outer));
|
||||
// a bin never goes into itself
|
||||
d.app().ui.expanded_bins.push(outer);
|
||||
d.frames(3);
|
||||
let (from, to) = (d.rect(&format!("project.bin.{outer}")), d.rect(&format!("project.bin.{inner}")));
|
||||
drag(&mut d, from, (to[0] + 80.0, to[1] + to[3] / 2.0));
|
||||
assert_eq!(parent(&mut d, outer), Some(root));
|
||||
assert_eq!(parent(&mut d, inner), Some(outer));
|
||||
// onto empty space: back to the bin the view shows
|
||||
let from = d.rect(&format!("project.bin.{inner}"));
|
||||
let to = centre(d.rect("project.empty"));
|
||||
drag(&mut d, from, to);
|
||||
assert_eq!(parent(&mut d, inner), Some(root));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn footer_buttons_are_wired() {
|
||||
let mut d = project_driver();
|
||||
@@ -515,6 +550,26 @@ fn footer_buttons_are_wired() {
|
||||
assert_eq!(d.app().session.prefs.project_panel.view.icon_sort.column, "Name");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_bin_asks_for_its_name() {
|
||||
// #456: New Bin opens the name field of the new bin instead of leaving it "New Bin"
|
||||
let mut d = project_driver();
|
||||
d.exec("project.view.set", json!({"view": "list"}));
|
||||
d.click("project.button.file.newBin");
|
||||
d.frames(2);
|
||||
let bin = d.app().ui.project_panel.rename.as_ref().and_then(|r| r.bin).expect("the new bin's name is being edited");
|
||||
assert!(d.has("project.rename"), "the name field is shown");
|
||||
d.key("Cmd+A");
|
||||
d.ok("ui.type", json!({"text": "Interviews"}));
|
||||
d.key("Enter");
|
||||
let name = d.app().session.project.root.find_bin(filmcraft_project::BinId(bin)).unwrap().name.clone();
|
||||
assert_eq!(name, "Interviews");
|
||||
// with a name (agents) the bin is named directly and nothing is edited
|
||||
let r = d.ok("ui.menu.invoke", json!({"id": "file.newBin", "params": {"name": "B-roll"}}));
|
||||
assert!(r["bin"].as_u64().is_some(), "{r}");
|
||||
assert!(d.app().ui.project_panel.rename.is_none());
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------------ Media Browser
|
||||
|
||||
fn media_dir(tag: &str) -> std::path::PathBuf {
|
||||
@@ -665,6 +720,123 @@ fn media_browser_drag_imports_into_the_project_panel() {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Double-clicking empty space in the Project panel opens Import, also between and beside the
|
||||
/// Icon view's cards once the project has some. Only the strip under the last row used to answer,
|
||||
/// so with a project loaded a double-click in the visible blank space did nothing. Real pointer
|
||||
/// input at 60 frames per second, so the two clicks are a real double-click.
|
||||
#[test]
|
||||
fn double_click_empty_area_imports_with_a_project_loaded() {
|
||||
fn driver(demo: bool) -> (Driver, std::sync::Arc<std::sync::atomic::AtomicUsize>) {
|
||||
let mut session = Session::default();
|
||||
if demo {
|
||||
session.execute("file.openDemoProject", json!({})).expect("demo project");
|
||||
}
|
||||
let (tx, rx) = channel();
|
||||
let mut app = FilmcraftApp::new(session).with_control(rx);
|
||||
let picks = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
let seen = picks.clone();
|
||||
app.hooks.pick_files = Some(Box::new(move |_| {
|
||||
seen.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
Vec::new()
|
||||
}));
|
||||
let harness = Harness::builder().with_size(egui::vec2(1600.0, 980.0)).with_step_dt(1.0 / 60.0).with_max_steps(10_000).build_eframe(move |_cc| app);
|
||||
let mut d = Driver { harness, tx, snapshots: None };
|
||||
d.frames(10);
|
||||
(d, picks)
|
||||
}
|
||||
fn double_click(d: &mut Driver, at: egui::Pos2) {
|
||||
let push = |d: &mut Driver, e: egui::Event| d.harness.input_mut().events.push(e);
|
||||
// a pause first, as a person makes: clicks closer together continue the last gesture
|
||||
// (a triple click) instead of starting a double-click
|
||||
d.frames(60);
|
||||
push(d, egui::Event::PointerMoved(at));
|
||||
d.frames(1);
|
||||
for _ in 0..2 {
|
||||
for pressed in [true, false] {
|
||||
push(d, egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() });
|
||||
d.frames(1);
|
||||
}
|
||||
}
|
||||
d.frames(3);
|
||||
}
|
||||
let count = |p: &std::sync::Arc<std::sync::atomic::AtomicUsize>| p.load(std::sync::atomic::Ordering::SeqCst);
|
||||
|
||||
// an empty project: the whole panel is the empty area
|
||||
let (mut d, picks) = driver(false);
|
||||
let r = d.rect("project.empty");
|
||||
double_click(&mut d, egui::pos2(r[0] + r[2] / 2.0, r[1] + r[3] / 2.0));
|
||||
assert_eq!(count(&picks), 1, "empty project");
|
||||
|
||||
// the demo project, Icon view: the gap between the first two bins, and the space right of the
|
||||
// last card in the first row
|
||||
let (mut d, picks) = driver(true);
|
||||
d.ok("ui.set", json!({}));
|
||||
let (a, b) = (d.rect("project.bin.1"), d.rect("project.bin.2"));
|
||||
assert!((a[1] - b[1]).abs() < 1.0 && b[0] > a[0] + a[2], "bins 1 and 2 share a row: {a:?} {b:?}");
|
||||
double_click(&mut d, egui::pos2((a[0] + a[2] + b[0]) / 2.0, a[1] + a[3] / 2.0));
|
||||
assert_eq!(count(&picks), 1, "between two bins");
|
||||
let row: Vec<[f32; 4]> = d.ids("project.bin.").iter().map(|id| d.rect(id)).filter(|r| (r[1] - a[1]).abs() < 1.0).collect();
|
||||
let last = row.iter().fold(a, |m, r| if r[0] > m[0] { *r } else { m });
|
||||
let panel = d.rect("project.count");
|
||||
if last[0] + last[2] + 30.0 < panel[0] + panel[2] {
|
||||
double_click(&mut d, egui::pos2(last[0] + last[2] + 20.0, last[1] + last[3] / 2.0));
|
||||
assert_eq!(count(&picks), 2, "right of the last card");
|
||||
}
|
||||
// a card still takes its own double-click: the bin opens, Import does not
|
||||
double_click(&mut d, egui::pos2(a[0] + a[2] / 2.0, a[1] + a[3] / 2.0));
|
||||
let picked = count(&picks);
|
||||
assert!(picked <= 2, "double-clicking a bin must not open Import");
|
||||
}
|
||||
|
||||
/// The report: with audio placed in the timeline, double-clicking the Project panel's empty space
|
||||
/// no longer opened Import. A new sequence with a tone on A1 (so the panel shows the sequence and
|
||||
/// the audio clip as cards); every empty spot opens Import: the strip under the cards and the space
|
||||
/// beside them.
|
||||
#[test]
|
||||
fn double_click_imports_with_audio_in_the_timeline() {
|
||||
let mut session = Session::default();
|
||||
session.execute("file.newSequence", json!({"name": "Mix", "audio": 2, "video": 1})).unwrap();
|
||||
let inter: Vec<f32> = (0..48_000).flat_map(|i| [(i as f32 * 0.0576).sin() * 0.5; 2]).collect();
|
||||
let bytes: std::sync::Arc<[u8]> = filmcraft_engine::previews::write_wav_f32(&inter, 48_000).into();
|
||||
let item = filmcraft_engine::commands::import_bytes(&mut session, "/tone.wav", bytes, None).unwrap();
|
||||
session.execute("timeline.place", json!({"item": item.0, "audioTrack": "A1", "seconds": 0.0})).unwrap();
|
||||
let (tx, rx) = channel();
|
||||
let mut app = FilmcraftApp::new(session).with_control(rx);
|
||||
let picks = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
let seen = picks.clone();
|
||||
app.hooks.pick_files = Some(Box::new(move |_| {
|
||||
seen.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
Vec::new()
|
||||
}));
|
||||
let harness = Harness::builder().with_size(egui::vec2(1600.0, 980.0)).with_step_dt(1.0 / 60.0).with_max_steps(10_000).build_eframe(move |_cc| app);
|
||||
let mut d = Driver { harness, tx, snapshots: None };
|
||||
d.frames(10);
|
||||
let double_click = |d: &mut Driver, at: egui::Pos2| {
|
||||
let push = |d: &mut Driver, e: egui::Event| d.harness.input_mut().events.push(e);
|
||||
d.frames(60);
|
||||
push(d, egui::Event::PointerMoved(at));
|
||||
d.frames(1);
|
||||
for _ in 0..2 {
|
||||
for pressed in [true, false] {
|
||||
push(d, egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() });
|
||||
d.frames(1);
|
||||
}
|
||||
}
|
||||
d.frames(3);
|
||||
};
|
||||
let count = || picks.load(std::sync::atomic::Ordering::SeqCst);
|
||||
let cards: Vec<[f32; 4]> = d.ids("project.item.").iter().filter(|id| id.matches('.').count() == 2).map(|id| d.rect(id)).collect();
|
||||
assert!(!cards.is_empty(), "the sequence and the tone show as cards");
|
||||
let empty = d.rect("project.empty");
|
||||
eprintln!("cards {cards:?}, empty strip {empty:?}");
|
||||
double_click(&mut d, egui::pos2(empty[0] + empty[2] / 2.0, empty[1] + empty[3] / 2.0));
|
||||
let strip = count();
|
||||
let last = cards.iter().fold(cards[0], |m, r| if r[0] > m[0] { *r } else { m });
|
||||
double_click(&mut d, egui::pos2(last[0] + last[2] + 40.0, last[1] + last[3] / 2.0));
|
||||
let beside = count() - strip;
|
||||
assert_eq!((strip, beside), (1, 1), "double-click on (the strip under the cards, the space beside them)");
|
||||
}
|
||||
|
||||
// ---- marquee selection (#578)
|
||||
|
||||
/// The Footage bin open in place in `view`, with its items' rects (row by row, left to right).
|
||||
|
||||
@@ -321,6 +321,32 @@ fn markers_panel_colour_filter() {
|
||||
assert_eq!(d.ids("markers.row.").len(), 2);
|
||||
}
|
||||
|
||||
/// #695: a marker on the Timeline could not be cleared: with snapping off a click on it left the
|
||||
/// playhead on the frame under the pointer, not on the marker Clear Selected Marker looks for, and
|
||||
/// right-clicking it opened the clip menu.
|
||||
#[test]
|
||||
fn a_clicked_timeline_marker_can_be_cleared() {
|
||||
let mut d = Driver::demo();
|
||||
d.exec("markers.clearAll", json!({}));
|
||||
let first = d.exec("markers.add", json!({"frame": 30}))["marker"].as_u64().unwrap();
|
||||
let second = d.exec("markers.add", json!({"frame": 90}))["marker"].as_u64().unwrap();
|
||||
d.exec("sequence.snap", json!({"on": false}));
|
||||
d.exec("playhead.set", json!({"frame": 0}));
|
||||
// zoomed out so that a few px beside a marker is another frame
|
||||
let v = &mut d.app().ui.timeline;
|
||||
(v.pps, v.target_pps, v.scroll, v.target_scroll) = (60.0, 60.0, 0.0, 0.0);
|
||||
d.frames(4);
|
||||
let markers = |d: &mut Driver| d.app().session.active_sequence().unwrap().markers.iter().map(|m| m.id.0).collect::<Vec<_>>();
|
||||
d.ok("ui.click", json!({"id": format!("timeline.marker.{first}"), "fx": 0.95}));
|
||||
d.frames(2);
|
||||
d.exec("markers.clearCurrent", json!({}));
|
||||
assert_eq!(markers(&mut d), vec![second], "the clicked marker is cleared");
|
||||
d.ok("ui.click", json!({"id": format!("timeline.marker.{second}"), "fx": 0.95, "button": "right"}));
|
||||
d.frames(3);
|
||||
d.click("timeline.markerMenu.markers.clearCurrent");
|
||||
assert!(markers(&mut d).is_empty(), "cleared from its context menu");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_semicolon_goes_to_the_next_gap() {
|
||||
let mut d = Driver::demo();
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
//! Right-click renaming a Timeline track uses timeline.setTrack, which is undoable.
|
||||
|
||||
use egui_kittest::Harness;
|
||||
use filmcraft_engine::Session;
|
||||
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();
|
||||
h.step();
|
||||
}
|
||||
|
||||
fn click(h: &mut Harness<'static, FilmcraftApp>, id: &str, button: egui::PointerButton) {
|
||||
let [x, y, w, height] = h.state().auto.find(id).expect("visible control").rect;
|
||||
let at = egui::pos2(x + w * 0.5, y + height * 0.5);
|
||||
h.input_mut().events.push(egui::Event::PointerMoved(at));
|
||||
step(h);
|
||||
h.input_mut().events.push(egui::Event::PointerButton { pos: at, button, pressed: true, modifiers: Default::default() });
|
||||
step(h);
|
||||
h.input_mut().events.push(egui::Event::PointerButton { pos: at, button, pressed: false, modifiers: Default::default() });
|
||||
step(h);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_from_video_track_header_is_undoable() {
|
||||
let mut s = Session::default();
|
||||
s.execute("file.openDemoProject", json!({})).expect("demo project");
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 980.0)).with_step_dt(1.0 / 60.0).build_eframe(move |_cc| FilmcraftApp::new(s));
|
||||
for _ in 0..5 {
|
||||
step(&mut h);
|
||||
}
|
||||
let original = h.state().session.active_sequence().expect("active sequence").video_tracks[0].name.clone();
|
||||
let id = h.state().session.active_sequence().expect("active sequence").video_tracks[0].id.0;
|
||||
click(&mut h, "timeline.track.V1.name", egui::PointerButton::Secondary);
|
||||
assert!(h.state().auto.find("timeline.track.V1.renameField").is_some(), "track rename popup should open");
|
||||
h.ctx.data_mut(|d| d.insert_temp(egui::Id::new(("timeline-track-rename", id)), "B-roll".to_string()));
|
||||
step(&mut h);
|
||||
click(&mut h, "timeline.track.V1.renameApply", egui::PointerButton::Primary);
|
||||
assert_eq!(h.state().session.active_sequence().expect("active sequence").video_tracks[0].name, "B-roll");
|
||||
h.state_mut().session.execute("edit.undo", json!({})).expect("undo rename");
|
||||
assert_eq!(h.state().session.active_sequence().expect("active sequence").video_tracks[0].name, original);
|
||||
}
|
||||
@@ -299,6 +299,72 @@ fn dragging_a_caption_edge_inward_trims_it() {
|
||||
assert!(e1 < e0, "the caption's end moved in: {e0} → {e1}");
|
||||
}
|
||||
|
||||
/// #374: renders the Ambient_Score music clip mid-way through a trim of its In edge, with the
|
||||
/// mouse button still down, for visual review (needs a GPU): `cargo test -p filmcraft-ui-egui
|
||||
/// --test timeline_trim_ui -- --ignored trim_waveform_screenshots`. The waveform stays where it was
|
||||
/// and is cut off at the moving edge; it used to squeeze the whole clip's waveform into the shorter
|
||||
/// clip, so the sound under the pointer moved while dragging.
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn trim_waveform_screenshots() {
|
||||
let mut d = Driver::demo();
|
||||
let seq = d.app().session.active_sequence().expect("sequence");
|
||||
let music = seq.audio_tracks.iter().flat_map(|t| &t.items).find(|i| i.name.contains("Ambient")).expect("music clip").id.0;
|
||||
let (x0, _, y) = d.edges(music);
|
||||
// waveform peaks are read on a background thread
|
||||
for _ in 0..40 {
|
||||
std::thread::sleep(std::time::Duration::from_millis(50));
|
||||
d.frames(1);
|
||||
}
|
||||
d.screenshot("trim-waveform-before");
|
||||
let push = |d: &mut Driver, e: egui::Event| d.harness.input_mut().events.push(e);
|
||||
let to = pos2(x0 + 160.0, y);
|
||||
push(&mut d, egui::Event::PointerMoved(pos2(x0, y)));
|
||||
d.frames(1);
|
||||
push(&mut d, egui::Event::PointerButton { pos: pos2(x0, y), button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() });
|
||||
d.frames(1);
|
||||
for i in 1..=20 {
|
||||
push(&mut d, egui::Event::PointerMoved(pos2(x0 + 160.0 * i as f32 / 20.0, y)));
|
||||
d.frames(1);
|
||||
}
|
||||
d.screenshot("trim-waveform-dragging");
|
||||
push(&mut d, egui::Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() });
|
||||
d.frames(2);
|
||||
d.screenshot("trim-waveform-released");
|
||||
}
|
||||
|
||||
/// #653: an Out edge dragged far past the end of the clip's media stops there while the drag is
|
||||
/// still in progress, at the same place the trim lands on release. It used to follow the pointer
|
||||
/// past the media and then jump back when the button came up.
|
||||
#[test]
|
||||
fn an_edge_dragged_past_the_media_stops_at_its_end() {
|
||||
use filmcraft_ui_egui::panels::timeline::Drag;
|
||||
let mut d = Driver::demo();
|
||||
let last = d.app().session.active_sequence().expect("sequence").video_tracks[0].items.last().expect("a clip on V1").id.0;
|
||||
let (_, x1, y) = d.edges(last);
|
||||
let (_, e0) = d.span(last);
|
||||
let far = filmcraft_time::Tick(3600 * filmcraft_time::TICKS_PER_SECOND);
|
||||
let limit = filmcraft_engine::commands::trim_delta(&d.app().session, ClipId(last), filmcraft_edit::Edge::Out, filmcraft_edit::TrimMode::Regular, far)
|
||||
.expect("limit");
|
||||
let (from, to) = (pos2(x1 - 3.0, y), pos2(x1 + 300.0, y));
|
||||
let push = |d: &mut Driver, e: egui::Event| d.harness.input_mut().events.push(e);
|
||||
push(&mut d, egui::Event::PointerMoved(from));
|
||||
d.frames(1);
|
||||
push(&mut d, egui::Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() });
|
||||
d.frames(1);
|
||||
for i in 1..=60 {
|
||||
push(&mut d, egui::Event::PointerMoved(from + (to - from) * (i as f32 / 60.0)));
|
||||
d.frames(1);
|
||||
}
|
||||
match &d.app().tl.drag {
|
||||
Some(Drag::Trim { delta, .. }) => assert_eq!(*delta, limit, "the dragged edge stops at the end of the media"),
|
||||
other => panic!("not trimming: {other:?}"),
|
||||
}
|
||||
push(&mut d, egui::Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() });
|
||||
d.frames(2);
|
||||
assert_eq!(d.span(last).1, e0 + limit.0, "released where it was shown");
|
||||
}
|
||||
|
||||
/// Dragging a picture in the Trim Monitor must not deadlock: the shift bookkeeping used to call
|
||||
/// `drag_delta()` inside `data_mut()`, and both take egui's non-reentrant context write-lock, so
|
||||
/// the drag froze the whole app on its first recognised frame (the delta is read before locking
|
||||
|
||||
@@ -731,7 +731,8 @@ All of these dispatch the same command ids.
|
||||
- **Panels with engine data** (M8.9 / M12.6): Lumetri Scopes (`scopes.read` returns the same
|
||||
scopes as numbers), Metadata (`metadata.get` / `metadata.set`, one undo step per edit, stored in
|
||||
`ProjectItem::metadata`), Events (`events.list` / `events.clear`), Progress (`jobs.list` /
|
||||
`jobs.cancel`). Timecode and Reference Monitor are frontend-only views. Their settings are
|
||||
`jobs.cancel`), Project Notes (`project.notes` / `project.setNotes`, plain text in
|
||||
`Project::notes`, schema v15; one typing session is one undo step). Timecode and Reference Monitor are frontend-only views. Their settings are
|
||||
`UiState::panels` (`ui.set {"panels": {...}}`).
|
||||
- **Project panel and Media Browser** (M12.7). View settings (List / Icon / Freeform, thumbnail
|
||||
and font size, Preview Area, Hover Scrub), the List view's columns, widths and sort, and ten view
|
||||
|
||||
Reference in New Issue
Block a user