Merge main: keep both new Japanese rows

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Katt 2026-10-11 00:26:12 +05:30
commit 46b391957c
108 changed files with 5106 additions and 567 deletions

2
Cargo.lock generated
View File

@ -1667,6 +1667,7 @@ dependencies = [
name = "filmcraft"
version = "0.5.0"
dependencies = [
"arboard",
"chrono",
"cpal",
"eframe",
@ -1828,6 +1829,7 @@ dependencies = [
"filmcraft-vp9",
"image",
"log",
"rayon",
"serde_json",
"symphonia",
"thiserror 2.0.21",

View File

@ -60,6 +60,11 @@ impl Args {
self.opts.iter().any(|(n, _)| n == k)
}
/// 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))
}
/// Command params from positionals `from..`: one JSON object, or `key=value` pairs
/// (dotted keys nest: `color.r=1`).
pub fn params_from(&self, from: usize) -> Result<Value, String> {
@ -135,6 +140,14 @@ mod tests {
assert_eq!(a.params_from(2).unwrap(), json!({"a": {"b": [1]}}));
}
#[test]
fn unknown_opt_reports_first_unlisted_option() {
let allowed = ["--bridge", "--project"];
assert_eq!(args("mcp --automation-read-root X").unknown_opt(&allowed), Some("--automation-read-root"));
assert_eq!(args("mcp --bridge=a --typo=1").unknown_opt(&allowed), Some("--typo"));
assert_eq!(args("mcp --project p --bridge a").unknown_opt(&allowed), None);
}
#[test]
fn double_dash_ends_options() {
let a = args("import -- --weird-name.mov a.mov");

View File

@ -462,6 +462,9 @@ async fn cli() {
diag(format_args!("rendered {}x{} in {:.1} ms → {out}", img.w, img.h, dt.as_secs_f64() * 1000.0));
}
"mcp" => {
if let Some(opt) = a.unknown_opt(&["--bridge", "--project", "--demo", "--data-dir"]) {
usage(format!("unknown mcp option `{opt}`"));
}
let server = match a.opt("--bridge") {
Some(addr) => filmcraft_automation::FilmcraftMcp::bridge(addr).unwrap_or_else(|e| fail(e)),
None => match Backend::open(&a) {

View File

@ -0,0 +1,24 @@
//! Spacing of browser downloads (pure, so it is tested natively in `tests/download_pacing.rs`; the
//! anchor clicks live in [`crate::fs::download`]).
//!
//! Chrome drops anchor-click downloads that start in one tight burst, so an image-sequence export
//! (one file per frame) delivered only about half of its frames (#378). Each download is given a
//! slot [`GAP_MS`] after the previous one.
/// Milliseconds between two download clicks.
pub const GAP_MS: f64 = 125.0;
/// Hands out click times.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct Pacer {
next_free: f64,
}
impl Pacer {
/// Reserve the next slot at time `now` (ms); returns how long to wait before clicking.
pub fn reserve(&mut self, now: f64) -> f64 {
let start = now.max(self.next_free);
self.next_free = start + GAP_MS;
start - now
}
}

View File

@ -54,6 +54,7 @@ struct Fs {
thread_local! {
static FS: RefCell<Fs> = RefCell::new(Fs::default());
static DOWNLOADS: RefCell<crate::download_pacing::Pacer> = RefCell::new(crate::download_pacing::Pacer::default());
}
/// Wake-up hook for arriving data.
@ -405,7 +406,8 @@ impl Services for WebServices {
}
}
/// Offer bytes as a browser download named `name`.
/// Offer bytes as a browser download named `name`. Downloads are clicked at least
/// [`crate::download_pacing::GAP_MS`] apart: Chrome drops a burst of them (#378).
pub fn download(name: &str, data: &[u8]) -> Result<(), wasm_bindgen::JsValue> {
let doc = web_sys::window().and_then(|w| w.document()).ok_or("no document")?;
let blob = bytes_to_blob(data, "application/octet-stream")?;
@ -414,10 +416,20 @@ pub fn download(name: &str, data: &[u8]) -> Result<(), wasm_bindgen::JsValue> {
a.set_href(&url);
a.set_download(file_name(name));
a.style().set_property("display", "none")?;
doc.body().ok_or("no body")?.append_child(&a)?;
a.click();
a.remove();
let delay = DOWNLOADS.with(|d| d.borrow_mut().reserve(js_sys::Date::now()));
let name = name.to_owned();
wasm_bindgen_futures::spawn_local(async move {
if delay > 0.0 {
sleep(delay as i32).await;
}
let clicked = match doc.body() {
Some(body) => body.append_child(&a).map(|_| a.click()),
None => Err("no body".into()),
};
a.remove();
if let Err(e) = clicked {
log::warn!("download {name}: {e:?}");
}
sleep(60_000).await;
let _ = web_sys::Url::revoke_object_url(&url);
});

View File

@ -20,6 +20,7 @@
pub mod api;
pub mod audio;
pub mod download_pacing;
pub mod fs;
pub mod import;
pub mod opfs;

View File

@ -0,0 +1,26 @@
//! Download spacing. It is pure, so it runs natively even though the rest of the crate is
//! wasm32-only.
#[path = "../src/download_pacing.rs"]
mod download_pacing;
use download_pacing::{GAP_MS, Pacer};
/// #378: 20 downloads requested in the same instant are spread out, not clicked in one burst.
#[test]
fn burst_of_downloads_is_spaced_out() {
let mut p = Pacer::default();
let delays: Vec<f64> = (0..20).map(|_| p.reserve(1000.0)).collect();
assert_eq!(delays[0], 0.0);
for (i, d) in delays.iter().enumerate() {
assert_eq!(*d, i as f64 * GAP_MS);
}
}
/// A download after a quiet period is not delayed.
#[test]
fn download_after_a_pause_is_immediate() {
let mut p = Pacer::default();
assert_eq!(p.reserve(0.0), 0.0);
assert_eq!(p.reserve(10_000.0), 0.0);
}

View File

@ -32,6 +32,8 @@ serde_json = { workspace = true }
log = { workspace = true }
rfd = { workspace = true }
cpal = "0.16"
# Files and images on the system clipboard for Paste (#611); egui already uses it for text.
arboard = "3"
chrono = { version = "0.4", default-features = false, features = ["clock"] }
sys-locale = "0.3"

View File

@ -237,6 +237,7 @@ 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));
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));
app.hooks.system_languages = Some(Box::new(|| sys_locale::get_locales().collect()));
@ -283,6 +284,18 @@ fn main() -> eframe::Result {
started
}
/// Files copied in the file manager, else an image, on the system clipboard (Paste, #611).
fn clipboard_media() -> Option<filmcraft_ui_egui::ClipboardMedia> {
let mut clipboard = arboard::Clipboard::new().ok()?;
if let Ok(files) = clipboard.get().file_list()
&& !files.is_empty()
{
return Some(filmcraft_ui_egui::ClipboardMedia::Files(files.iter().map(|p| p.to_string_lossy().into_owned()).collect()));
}
let image = clipboard.get_image().ok()?;
Some(filmcraft_ui_egui::ClipboardMedia::Image { width: image.width, height: image.height, rgba: image.bytes.into_owned() })
}
/// Open a file in its default application, or reveal it in the file manager (Edit Original,
/// Reveal Log Files).
fn open_path(path: &str, reveal: bool) -> Result<(), String> {

View File

@ -56,3 +56,8 @@ sync_github_name = false
[people.liangforstudy]
sync_github_name = true
[people.maxm11]
real_name = "Max Murphy"
display_name = "Max Murphy"
sync_github_name = false

View File

@ -263,6 +263,8 @@ 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>,
@ -285,17 +287,21 @@ 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),
];
/// Fixed look-ahead time.
/// Default look-ahead when the parameter is left at its DSP default.
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 look = ((Self::LOOKAHEAD_MS * 0.001 * sample_rate).round() as usize).max(1);
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 det_delay = bank.delay();
let dlen = look + det_delay + 1;
let dlen = max_look + det_delay + 1;
let mut s = Limiter {
pv: ParamValues::new(Self::PARAMS),
sr: sample_rate,
@ -304,6 +310,7 @@ impl Limiter {
rel: 0.0,
true_peak: true,
look,
max_look,
det_delay,
bank,
interp: vec![Interpolator::default(); channels],
@ -326,6 +333,15 @@ 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();
}
@ -363,7 +379,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 + 1) % dlen; // oldest = look + det_delay samples ago
let out_pos = (self.wpos + dlen - (self.look + self.det_delay)) % dlen;
let mut det = 0.0f32;
for ch in 0..nch {
let x = channels[ch][i] * g_in;
@ -555,4 +571,24 @@ 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})");
}
}

View File

@ -84,7 +84,6 @@ impl<'a> CompoundFile<'a> {
let mini_cutoff = u32_at(data, 56) as u64;
let first_minifat = u32_at(data, 60);
let first_difat = u32_at(data, 68);
let n_difat = u32_at(data, 72) as usize;
let mut header_clsid = [0u8; 16];
header_clsid.copy_from_slice(&data[8..24]);
let max_sectors = data.len() / sector_size + 1;
@ -108,8 +107,10 @@ impl<'a> CompoundFile<'a> {
let mut seen = 0;
while d <= MAXREGSECT && fat_sectors.len() < n_fat {
seen += 1;
if seen > n_difat.max(1) + max_sectors {
return Err(invalid("DIFAT chain loops"));
// a DIFAT chain cannot have more sectors than the file holds; the header's DIFAT sector
// count (up to 2^32 - 1) is not trusted, or a self-pointing sector loops that often
if seen > max_sectors {
return Err(invalid(format!("DIFAT chain loops: more than {max_sectors} sectors in a {}-byte file", data.len())));
}
let s = cf.sector(d)?;
let per = sector_size / 4 - 1;
@ -169,13 +170,17 @@ impl<'a> CompoundFile<'a> {
let mut out = Vec::new();
let mut s = start;
let mut steps = 0usize;
// A chain cannot visit more sectors than the file holds. Not `self.fat.len()`: the FAT can
// be far longer (one FAT sector named by many header / DIFAT slots), and a looping chain
// would then append a whole sector per FAT entry (hundreds of MB, or an abort).
let max_steps = self.data.len() / self.sector_size + 1;
while s != ENDOFCHAIN {
if s > MAXREGSECT {
return Err(invalid(format!("bad sector {s:#x} in a chain")));
}
steps += 1;
if steps > self.fat.len().max(1) {
return Err(invalid("sector chain loops"));
if steps > max_steps {
return Err(invalid(format!("sector chain loops: more than {max_steps} sectors in a {}-byte file", self.data.len())));
}
let b = self.sector(s)?;
out.extend_from_slice(b);

View File

@ -199,3 +199,95 @@ fn mutation_never_panics_or_hangs() {
}
}
}
fn put_u32(b: &mut [u8], at: usize, v: u32) {
b[at..at + 4].copy_from_slice(&v.to_le_bytes());
}
/// A 512-byte header (§2.2) with sector shift `shift`: no directory, mini FAT or DIFAT chain, and
/// all 109 FAT slots free. Tests fill in the fields they attack.
fn bare_header(shift: u16) -> Vec<u8> {
let mut h = vec![0u8; 512];
h[..8].copy_from_slice(&SIGNATURE);
h[24..26].copy_from_slice(&0x3Eu16.to_le_bytes());
h[26..28].copy_from_slice(&(if shift == 9 { 3u16 } else { 4 }).to_le_bytes());
h[28..30].copy_from_slice(&0xFFFEu16.to_le_bytes());
h[30..32].copy_from_slice(&shift.to_le_bytes());
h[32..34].copy_from_slice(&6u16.to_le_bytes());
for at in [48, 60, 68] {
put_u32(&mut h, at, ENDOFCHAIN);
}
put_u32(&mut h, 56, MINI_STREAM_CUTOFF);
h[76..].fill(0xFF);
h
}
/// A DIFAT sector that names no FAT sector and points to itself. The loop guard used to be the
/// header's DIFAT sector count (up to 2^32 - 1), so this 1 KB file kept `open` (and so AAF
/// sniffing) busy for 14-16 s here (minutes with `u32::MAX`), reading a sector each pass. A DIFAT
/// chain cannot be longer than the file, so the walk now stops after as many passes as the file
/// has sectors (1024 / 512 + 1 = 3 here) and fails on the 4th.
#[test]
fn difat_self_loop_fails_fast() {
let mut b = bare_header(9);
put_u32(&mut b, 44, 1); // one FAT sector, to be found through the DIFAT
put_u32(&mut b, 68, 0); // first DIFAT sector
put_u32(&mut b, 72, 100_000_000); // claimed DIFAT sector count (inflates the old loop bound)
let mut difat = vec![0xFF; 512]; // no FAT sector numbers...
put_u32(&mut difat, 508, 0); // ...and the next DIFAT sector is this one
b.extend_from_slice(&difat);
// The message names the bound the walk stopped at: the file's 3 sectors, not the header's
// 100,000,000 (the old guard allowed 100,000,003 passes).
let expect = format!("DIFAT chain loops: more than 3 sectors in a {}-byte file", b.len());
assert_eq!(CompoundFile::open(&b).unwrap_err(), Error::Invalid(expect));
}
/// All 109 header slots name the same FAT sector, so the FAT is 109 copies of it, and the
/// directory chain loops (sector 0 → 0). The loop guard used to be the FAT length, so this 450 KB
/// file appended a sector per FAT entry before failing: a 457 MB buffer per open (117 GB with
/// 64 KB sectors in a 7 MB file). A chain cannot visit more sectors than the file has.
#[test]
fn duplicated_fat_sector_chain_loop_fails_fast() {
let ss = 4096;
let mut b = bare_header(12);
put_u32(&mut b, 44, 109); // FAT sectors: the 109 header slots...
for i in 0..109 {
put_u32(&mut b, 76 + i * 4, 0); // ...all naming sector 0
}
put_u32(&mut b, 48, 0); // the directory starts at sector 0
b.resize(ss, 0);
let mut fat = vec![0xFF; ss];
put_u32(&mut fat, 0, 0); // FAT[0] = 0: sector 0 is followed by itself
b.extend_from_slice(&fat);
b.resize(110 * ss, 0); // big enough for 109 FAT sectors to pass the size check
// The message names the bound the walk stopped at: 111, this file's 110 sectors + 1, not the
// 109 * 1024 = 111,616 entries of the inflated FAT that the old guard allowed (a 457 MB buffer).
let expect = format!("sector chain loops: more than 111 sectors in a {}-byte file", b.len());
assert_eq!(CompoundFile::open(&b).unwrap_err(), Error::Invalid(expect));
}
/// Positive control for the chain bound: the longest chains a file can hold still read back, in
/// both versions: a directory (read without a size limit) and a stream, each filling every sector
/// but the header and the FAT (and the directory, for the stream).
#[test]
fn chains_filling_the_file_still_read() {
for v in [Version::V3, Version::V4] {
let ss = v_sector(v);
let mut w = Writer::new(v);
for i in 0..1000 {
w.stream(Writer::ROOT, &format!("e{i}"), Vec::new()).unwrap();
}
let b = w.finish();
assert!(b.len() / ss <= (1001 * 128usize).div_ceil(ss) + 3, "header, directory, FAT");
let cf = CompoundFile::open(&b).unwrap();
assert_eq!(cf.root().children.len(), 1000);
assert_eq!(cf.read_path("e999").unwrap(), Vec::<u8>::new());
let mut w = Writer::new(v);
let data = pattern(64 * ss, 3);
w.stream(Writer::ROOT, "big", data.clone()).unwrap();
let b = w.finish();
assert_eq!(b.len(), (1 + 64 + 2) * ss, "header, stream, directory, FAT");
assert_eq!(CompoundFile::open(&b).unwrap().read_path("big").unwrap(), data);
}
}

View File

@ -31,6 +31,7 @@ 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.

View File

@ -109,8 +109,9 @@ impl std::ops::Sub for GopStats {
}
}
/// A container's video sample table, in decode (file) order.
pub trait VideoSamples {
/// A container's video sample table, in decode (file) order. `Sync`: a cache decodes on its own
/// threads ([`isolated`]).
pub trait VideoSamples: Sync {
fn count(&self) -> usize;
/// Presentation timestamp of sample `i` (track units).
fn pts(&self, i: usize) -> i64;
@ -425,6 +426,57 @@ 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
@ -482,7 +534,11 @@ impl GopCache {
}
fn store(&self, st: &mut State, pts: i64, f: VideoFrame, draft: bool) {
let f = self.finish(f);
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) {
st.last_used = Instant::now();
if draft {
st.drafts.insert(pts);
@ -604,6 +660,22 @@ 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
@ -656,14 +728,14 @@ impl GopCache {
let out = timed(|| decoder.decode(&data, s.pts(k)))?;
st.next += 1;
DECODED.fetch_add(1, Ordering::Relaxed);
self.store_output(&mut st, out);
self.store_output(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(&mut st, out);
self.store_output(st, out);
st.next = usize::MAX;
}
// nearest decoded frame at or before the wanted pts (robust to decoder pts quirks)
@ -748,14 +820,17 @@ 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);
}
let res = res?;
count_frames(&res);
for d in res {
self.store(&mut st, d.pts, d.frame, d.draft);
for (pts, frame, draft) in res? {
self.store_finished(&mut st, pts, frame, draft);
}
st.at_or_before(want_pts, true).ok_or_else(|| CodecError::Decode("frame not produced".into()))
}
@ -887,6 +962,74 @@ 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);

View File

@ -30,6 +30,7 @@ pub mod mxf;
pub mod ogg;
mod stream_color;
pub mod video;
mod webm_alpha;
use std::sync::{Arc, RwLock};

View File

@ -58,6 +58,8 @@ pub struct MkvSource {
/// Video codec as an ISO-BMFF sample entry (for the decoder factories).
ventry: Option<SampleEntry>,
video: GopCache,
/// The alpha layer of a VP9 track with `AlphaMode` 1 (WebM transparency, #402).
alpha: Option<Arc<crate::webm_alpha::AlphaLayer>>,
/// The playable audio tracks in file order: `info.audio_streams[k]` describes `audios[k]`.
audios: Vec<MkvAudio>,
}
@ -380,7 +382,12 @@ impl MkvSource {
Some(MkvAudio { track: i, state, starts, preroll })
})
.collect();
Ok(Self { info, bytes, file, vtrack, ventry, video: GopCache::new(explicit_color).with_rotation(rotation), audios })
// WebM transparency: VP9 alpha in each block's BlockAdditional (ID 1)
let alpha = vtrack.and_then(|i| file.tracks.get(i)).filter(|t| {
matches!(t.codec, Codec::Vp9 { .. }) && t.video.as_ref().is_some_and(|v| v.alpha_mode != 0) && t.samples.iter().any(|s| s.addition.is_some())
});
let alpha = alpha.map(|t| Arc::new(crate::webm_alpha::AlphaLayer::new(bytes.clone(), t.samples.iter().map(|s| (s.pts, s.addition)))));
Ok(Self { info, bytes, file, vtrack, ventry, video: GopCache::new(explicit_color).with_rotation(rotation), alpha, audios })
}
fn read(&self, track: usize, i: usize) -> crate::Result<Vec<u8>> {
@ -486,9 +493,15 @@ impl VideoSamples for MkvVideo<'_> {
self.src.read(self.track, i)
}
fn make_decoder(&self) -> crate::Result<Box<dyn VideoDecoder>> {
match &self.src.ventry {
Some(e) => make_video_decoder(e),
None => Err(CodecError::Unsupported(format!("no decoder for {} video", codec_label(&self.src.file.tracks[self.track].codec)))),
let Some(e) = &self.src.ventry else {
return Err(CodecError::Unsupported(format!("no decoder for {} video", codec_label(&self.src.file.tracks[self.track].codec))));
};
let color = make_video_decoder(e)?;
let Some(layer) = &self.src.alpha else { return Ok(color) };
// the alpha layer always goes through our own decoder (it is luma only)
match crate::software_video_decoder(e) {
Ok(alpha) => Ok(Box::new(crate::webm_alpha::AlphaDecoder::new(color, alpha, layer.clone()))),
Err(_) => Ok(color),
}
}
}

View File

@ -9,7 +9,7 @@ use std::sync::{Arc, Mutex};
use filmcraft_color::ColorInfo;
use filmcraft_frame::{AudioBuffer, Region, VideoFrame};
use filmcraft_isobmff::{CleanAperture, CodecConfig, Mp4File, TrackKind};
use filmcraft_isobmff::{ByteSource, CleanAperture, CodecConfig, Mp4File, TrackKind};
use filmcraft_media::{AudioStreamInfo, FrameRequest, MediaError, MediaInfo, MediaKind, MediaSource, SharedSource, VideoStreamInfo};
use filmcraft_time::{FrameRate, Tick};
@ -162,6 +162,48 @@ fn color_from(entry: &filmcraft_isobmff::SampleEntry, w: u32, h: u32) -> ColorIn
crate::stream_color::resolve(w, h, &sources)
}
/// First bytes of a Sony `rtmd` sample needed for the start-timecode block (`frames` is last).
const RTMD_HEADER_LEN: usize = 0x12;
/// Sony XAVC `rtmd` (real-time metadata) start timecode fields, read from the first sample: hours,
/// minutes, seconds, a drop-frame flag and frames. Sony stores them as raw decimal bytes in the
/// sample's fixed header. The layout is proprietary and undocumented, so it is reproduced here from
/// real footage; it is the same block ffmpeg reports as the `timecode` tag of an `rtmd` stream.
///
/// Fields outside SMPTE bounds — including the labels drop-frame counting skips — are rejected
/// (no fabricated start time from damaged metadata).
fn rtmd_fields(sample: &[u8], rate: FrameRate) -> Option<(i64, i64, i64, i64, bool)> {
let f = sample.get(0x0d..0x12)?;
let (h, m, s) = (f[0] as i64, f[1] as i64, f[2] as i64);
let frames = f[4] as i64;
let drop = f[3] != 0;
let base = rate.timecode_base();
if h > 23 || m > 59 || s > 59 || frames >= base {
return None;
}
// Drop-frame counting skips the first `base / 15` frame labels of every minute except each
// tenth minute, so those labels never occur in valid footage (SMPTE ST 12-1).
if drop && rate.supports_drop_frame() && s == 0 && m % 10 != 0 && frames < base / 15 {
return None;
}
Some((h, m, s, frames, drop))
}
/// Start timecode from a Sony `rtmd` track as a frame count at `rate` (issue #460), used when the
/// file has no `tmcd` track. `None` if there is no `rtmd` track or it has no usable first sample.
/// Only the fixed-size header is read (a damaged `stsz` size can't drive a huge allocation).
fn rtmd_start_timecode(file: &Mp4File, bytes: &crate::Src, rate: FrameRate) -> Option<i64> {
let t = file.tracks.iter().find(|t| t.entries.first().is_some_and(|e| e.format.0 == *b"rtmd"))?;
let s = t.samples.first()?;
if s.size < RTMD_HEADER_LEN as u32 {
return None;
}
let mut buf = [0u8; RTMD_HEADER_LEN];
bytes.read_at(s.offset, &mut buf).ok()?;
let (h, m, s, f, drop) = rtmd_fields(&buf, rate)?;
Some(filmcraft_time::fields_to_frames(h, m, s, f, rate, drop))
}
impl Mp4Source {
pub fn open(name: &str, bytes: Arc<[u8]>) -> crate::Result<Self> {
Self::open_reader(name, Arc::new(filmcraft_media::reader::MemReader(bytes)))
@ -271,6 +313,9 @@ impl Mp4Source {
Some(CodecConfig::Timecode(tc)) => tc.start_frame.map(|f| f as i64),
_ => None,
});
// Sony XAVC files with no `tmcd` track carry the start timecode in an `rtmd` metadata
// track; convert its fields to a frame count using the video frame rate.
let start_timecode = start_timecode.or_else(|| rtmd_start_timecode(&file, &bytes, video.as_ref()?.frame_rate));
let info = MediaInfo {
name: name.to_string(),
kind: if video.is_some() { MediaKind::Movie } else { MediaKind::AudioOnly },
@ -914,6 +959,48 @@ mod tests {
assert_eq!((v.pixel_format.as_str(), v.has_alpha), (want, alpha), "{}", String::from_utf8_lossy(fourcc));
}
}
#[test]
fn rtmd_fields_parse_and_reject_damaged_metadata() {
let rate = FrameRate::FPS_59_94;
// 03:37:12:34, non-drop (a Sony FX3/a6400 XAVC header).
let mut s = [0u8; 24];
s[0x0d..0x12].copy_from_slice(&[3, 37, 12, 0, 34]);
assert_eq!(rtmd_fields(&s, rate), Some((3, 37, 12, 34, false)));
s[0x10] = 1;
assert_eq!(rtmd_fields(&s, rate), Some((3, 37, 12, 34, true)));
// Too short to hold the block: no fields, no panic.
assert_eq!(rtmd_fields(&[0u8; 6], rate), None);
// Damaged fields are rejected rather than normalized into a plausible time.
for bad in [[3, 37, 12, 0, 60], [3, 60, 0, 0, 0], [24, 0, 0, 0, 0], [0, 0, 60, 0, 0]] {
let mut b = [0u8; 24];
b[0x0d..0x12].copy_from_slice(&bad);
assert_eq!(rtmd_fields(&b, rate), None, "{bad:?}");
}
}
#[test]
fn rtmd_fields_reject_drop_frame_skipped_labels() {
let df = |h, m, s, f| {
let mut b = [0u8; 24];
b[0x0d..0x12].copy_from_slice(&[h, m, s, 1, f]);
rtmd_fields(&b, FrameRate::FPS_59_94)
};
// At 59.94 DF, frames 00-03 of a non-tenth minute are skipped, so they never occur.
assert_eq!(df(0, 1, 0, 0), None);
assert_eq!(df(0, 1, 0, 3), None);
assert_eq!(df(0, 1, 0, 4), Some((0, 1, 0, 4, true)));
assert_eq!(df(0, 10, 0, 0), Some((0, 10, 0, 0, true))); // tenth minute: label exists
assert_eq!(df(0, 1, 1, 0), Some((0, 1, 1, 0, true))); // seconds > 0: label exists
// At 29.97 DF only frames 00-01 are skipped.
let df30 = |m, s, f| {
let mut b = [0u8; 24];
b[0x0d..0x12].copy_from_slice(&[0, m, s, 1, f]);
rtmd_fields(&b, FrameRate::FPS_29_97)
};
assert_eq!(df30(1, 0, 0), None);
assert_eq!(df30(1, 0, 2), Some((0, 1, 0, 2, true)));
}
}
#[cfg(test)]

View File

@ -387,6 +387,68 @@ fn mkv_vp9_422_seeks_bit_exact() {
check_vp9_seeks("noise_vp9_422.mkv", "Matroska", "yuv422p", &["-profile:v", "1", "-deadline", "realtime", "-speed", "8"], 30);
}
/// #402: a WebM whose VP9 track carries an alpha layer (`AlphaMode` 1, a second VP9 stream in
/// each block's `BlockAdditional`) decodes with that alpha. Frames requested out of order (seeks
/// across key frames, then forward) match libvpx's decode exactly, colour and alpha.
#[test]
fn vp9_webm_alpha_layer_matches_libvpx() {
let (w, h, frames) = (176usize, 144usize, 30usize);
// the alpha changes every frame, so a picture paired with the wrong alpha frame shows
let Some(path) = fixture_path(
"alpha_vp9.webm",
&[
"-f",
"lavfi",
"-i",
"testsrc2=s=176x144:r=25,format=yuva420p,geq=lum='lum(X,Y)':cb='cb(X,Y)':cr='cr(X,Y)':a='mod(X*2+N*7\\,256)'",
"-frames:v",
"30",
"-c:v",
"libvpx-vp9",
"-pix_fmt",
"yuva420p",
"-g",
"10",
"-deadline",
"realtime",
"-speed",
"8",
"-b:v",
"600k",
],
) else {
return;
};
// the reference: ffmpeg's libvpx decoder (its native VP9 decoder ignores the alpha layer)
let src_path = path.to_string_lossy().into_owned();
let Some(reference) =
fixture("alpha_vp9.webm.yuva420p.yuv", &["-c:v", "libvpx-vp9", "-i", &src_path, "-fps_mode", "passthrough", "-f", "rawvideo", "-pix_fmt", "yuva420p"])
else {
return;
};
let (luma, chroma) = (w * h, (w / 2) * (h / 2));
let frame_len = luma * 2 + chroma * 2;
if reference.len() != frame_len * frames {
eprintln!("libvpx reference has {} bytes, not {}; skipping", reference.len(), frame_len * frames);
return;
}
let bytes: Arc<[u8]> = std::fs::read(&path).unwrap().into();
let src = crate::open_bytes("alpha_vp9.webm", bytes).unwrap();
let info = src.info().clone();
assert_eq!(info.container, "WebM");
assert!(info.video.as_ref().unwrap().has_alpha);
let rate = info.frame_rate();
let order: Vec<usize> = [29, 3, 17, 12, 0, 25, 21, 9].into_iter().chain(10..frames).collect();
for k in order {
let f = src.video_frame(FrameRequest::full(rate.tick_of(k as i64))).unwrap();
let r = &reference[k * frame_len..(k + 1) * frame_len];
let filmcraft_frame::PixelData::Yuv8 { planes, alpha, .. } = &f.data else { panic!("frame {k}: not 8-bit YUV: {}", f.format_label()) };
assert_eq!(planes[0].as_slice(), &r[..luma], "frame {k}: luma");
let alpha = alpha.as_ref().unwrap_or_else(|| panic!("frame {k}: no alpha plane"));
assert_eq!(alpha.as_slice(), &r[luma + 2 * chroma..], "frame {k}: alpha");
}
}
/// Every sample flagged as sync (as in an MP4 without `stss`): the GOP cache must still start
/// decoding at a real VP9 key frame.
#[test]

View File

@ -0,0 +1,292 @@
//! WebM alpha (#402): a VP9 track with `AlphaMode` 1 carries, in each block's `BlockAdditional`
//! with `BlockAddID` 1, a second VP9 stream coded independently of the colour one. Its luma plane,
//! full range, is the frame's alpha (the WebM convention, as libvpx-based decoders read it).
//!
//! [`AlphaDecoder`] wraps the colour decoder: every sample it decodes, it also decodes that
//! sample's alpha frame with a second (software) VP9 decoder and attaches the luma plane to the
//! colour picture with the same timestamp, holding a colour picture back until its alpha is out. The alpha stream has key frames of its own, which need
//! not coincide with the colour ones: after a seek (or any other break in the order samples are
//! fed), the alpha decoder restarts from the nearest alpha key frame at or before the sample.
//!
//! Alpha is best effort: a missing, damaged or undecodable alpha frame leaves that picture opaque,
//! never fails the colour decode.
use std::collections::{HashMap, VecDeque};
use std::sync::Arc;
use filmcraft_frame::{PixelData, VideoFrame};
use crate::video::{DecodedFrame, VideoDecoder};
/// How far back the alpha decoder looks for a key frame when it resynchronises.
const MAX_RESYNC: usize = 600;
/// Decoded alpha pictures waiting for their colour picture, and colour pictures waiting for their
/// alpha (VP9 pictures come out in order, so a few are plenty; past that a picture goes out opaque
/// rather than stall).
const MAX_PENDING: usize = 8;
/// Where each sample's alpha frame is stored.
pub(crate) struct AlphaLayer {
src: crate::Src,
/// Per sample, in file (decode) order.
additions: Vec<Option<(u64, u32)>>,
pts: Vec<i64>,
/// First sample with each pts.
by_pts: HashMap<i64, usize>,
}
impl AlphaLayer {
/// `samples`: (pts, addition) of every sample of the track, in file order.
pub(crate) fn new(src: crate::Src, samples: impl Iterator<Item = (i64, Option<(u64, u32)>)>) -> Self {
let (mut additions, mut all_pts) = (Vec::new(), Vec::new());
let mut by_pts = HashMap::new();
for (i, (pts, add)) in samples.enumerate() {
additions.push(add);
all_pts.push(pts);
by_pts.entry(pts).or_insert(i);
}
Self { src, additions, pts: all_pts, by_pts }
}
fn read(&self, i: usize) -> Option<Vec<u8>> {
let (offset, size) = (*self.additions.get(i)?)?;
let end = offset.checked_add(size as u64)?;
if end > self.src.0.len() {
return None;
}
let mut data = vec![0; size as usize];
self.src.0.read_at(offset, &mut data).ok()?;
Some(data)
}
}
/// A colour decoder plus the decoder of its alpha layer.
///
/// Both decoders may hand pictures back later than the sample that made them (frame threading),
/// and not necessarily after the same call. A colour picture is therefore held until its alpha
/// picture has come out, or until it is certain that it won't: the alpha decoder has moved past
/// it (pictures come out in order), it failed, or the sample has no alpha frame.
pub(crate) struct AlphaDecoder {
color: Box<dyn VideoDecoder>,
alpha: Box<dyn VideoDecoder>,
layer: Arc<AlphaLayer>,
/// Sample the alpha decoder expects next; `None` after a reset or a failure.
next: Option<usize>,
/// Decoded alpha pictures not yet paired, by pts.
pending: VecDeque<(i64, VideoFrame)>,
/// Latest pts the alpha decoder has produced since the last reset.
alpha_max: Option<i64>,
/// Colour pictures waiting for their alpha, in output order.
held: VecDeque<DecodedFrame>,
}
impl AlphaDecoder {
pub(crate) fn new(color: Box<dyn VideoDecoder>, alpha: Box<dyn VideoDecoder>, layer: Arc<AlphaLayer>) -> Self {
Self { color, alpha, layer, next: None, pending: VecDeque::new(), alpha_max: None, held: VecDeque::new() }
}
fn keep(&mut self, frames: Vec<DecodedFrame>) {
for f in frames {
self.alpha_max = Some(self.alpha_max.map_or(f.pts, |m| m.max(f.pts)));
self.pending.push_back((f.pts, f.frame));
}
while self.pending.len() > MAX_PENDING {
self.pending.pop_front();
}
}
/// Decode the alpha frame of the sample with `pts`, first catching up from an alpha key frame
/// when the sample doesn't follow the previous one.
fn feed_alpha(&mut self, pts: i64) {
let Some(&i) = self.layer.by_pts.get(&pts) else {
self.next = None;
return;
};
if self.next != Some(i) {
self.alpha.reset();
self.pending.clear();
self.alpha_max = None;
let is_key = |k: usize| self.layer.read(k).is_some_and(|d| filmcraft_vp9::is_keyframe(&d));
let start = (i.saturating_sub(MAX_RESYNC)..=i).rev().find(|&k| is_key(k));
if let Some(start) = start {
for k in start..i {
let (Some(d), Some(&p)) = (self.layer.read(k), self.layer.pts.get(k)) else { continue };
match self.alpha.decode(&d, p) {
Ok(frames) => self.keep(frames),
Err(_) => break,
}
}
}
}
self.next = Some(i + 1);
let Some(d) = self.layer.read(i) else { return };
match self.alpha.decode(&d, pts) {
Ok(frames) => self.keep(frames),
Err(e) => {
log::debug!("WebM alpha frame at pts {pts}: {e}");
self.next = None;
}
}
}
/// Whether the alpha picture for `pts` can still come out of the alpha decoder.
fn alpha_may_come(&self, pts: i64) -> bool {
let has_frame = self.layer.by_pts.get(&pts).is_some_and(|&i| self.layer.additions.get(i).is_some_and(|a| a.is_some()));
has_frame && self.next.is_some() && self.alpha_max.is_none_or(|m| m < pts)
}
/// Release held colour pictures, in order, as far as their alpha is settled. `all`: release
/// every one (flush), with or without alpha.
fn release(&mut self, all: bool) -> Vec<DecodedFrame> {
let mut out = Vec::new();
while let Some(f) = self.held.front() {
let pts = f.pts;
let found = self.pending.iter().position(|(p, _)| *p == pts);
if found.is_none() && !all && self.held.len() <= MAX_PENDING && self.alpha_may_come(pts) {
break;
}
let Some(mut f) = self.held.pop_front() else { break };
if let Some(k) = found
&& let Some((_, a)) = self.pending.remove(k)
{
set_alpha(&mut f.frame, &a);
}
out.push(f);
}
out
}
}
impl VideoDecoder for AlphaDecoder {
fn decode(&mut self, sample: &[u8], pts: i64) -> crate::Result<Vec<DecodedFrame>> {
let out = self.color.decode(sample, pts)?;
self.held.extend(out);
self.feed_alpha(pts);
Ok(self.release(false))
}
fn flush(&mut self) -> Vec<DecodedFrame> {
let out = self.color.flush();
self.held.extend(out);
let rest = self.alpha.flush();
self.keep(rest);
self.release(true)
}
fn reset(&mut self) {
self.color.reset();
self.alpha.reset();
self.next = None;
self.pending.clear();
self.alpha_max = None;
self.held.clear();
}
fn name(&self) -> &str {
self.color.name()
}
fn intra_only(&self) -> bool {
self.color.intra_only()
}
fn is_random_access(&self, sample: &[u8]) -> Option<bool> {
self.color.is_random_access(sample)
}
fn is_disposable(&self, sample: &[u8]) -> bool {
self.color.is_disposable(sample)
}
fn set_draft(&mut self, on: bool) {
self.color.set_draft(on);
self.alpha.set_draft(on);
}
fn thread_limited(&self) -> bool {
self.color.thread_limited()
}
}
/// The luma plane of `a` (an alpha picture) as `frame`'s alpha, converted to `frame`'s bit depth.
/// Nothing happens when the sizes differ or either picture has no usable planes.
pub(crate) fn set_alpha(frame: &mut VideoFrame, a: &VideoFrame) {
if (frame.width, frame.height) != (a.width, a.height) {
return;
}
let n = frame.width as usize * frame.height as usize;
// alpha codes as 16-bit with their depth, so every pairing converts through one path
let (codes, abits): (Vec<u16>, u32) = match &a.data {
PixelData::Yuv8 { planes, .. } => (planes[0].iter().map(|&v| v as u16).collect(), 8),
PixelData::Yuv16 { planes, bits, .. } => (planes[0].to_vec(), (*bits).clamp(1, 16)),
_ => return,
};
if codes.len() != n {
return;
}
let rescale = |v: u16, to: u32| -> u32 {
let (from_max, to_max) = ((1u32 << abits) - 1, (1u32 << to.clamp(1, 16)) - 1);
(v as u32 * to_max + from_max / 2) / from_max
};
match &mut frame.data {
PixelData::Yuv8 { alpha, .. } => *alpha = Some(Arc::new(codes.iter().map(|&v| rescale(v, 8) as u8).collect())),
PixelData::Yuv16 { alpha, bits, .. } => {
let to = *bits;
*alpha = Some(Arc::new(codes.iter().map(|&v| rescale(v, to) as u16).collect()));
}
PixelData::Rgba8(rgba) => {
let px = Arc::make_mut(rgba);
for (p, &v) in px.chunks_exact_mut(4).zip(&codes) {
if let Some(slot) = p.get_mut(3) {
*slot = rescale(v, 8) as u8;
}
}
}
PixelData::RgbaF32(_) => {}
}
}
#[cfg(test)]
mod tests {
use super::*;
use filmcraft_frame::Chroma;
fn yuv8(w: u32, h: u32, luma: Vec<u8>) -> VideoFrame {
let n = (w * h) as usize;
let c = n / 4;
let data = PixelData::Yuv8 { planes: [Arc::new(luma), Arc::new(vec![128; c]), Arc::new(vec![128; c])], chroma: Chroma::C420, alpha: None };
VideoFrame { width: w, height: h, data, color: Default::default(), par: (1, 1), pts: filmcraft_time::Tick::ZERO }
}
fn yuv16(w: u32, h: u32, bits: u32) -> VideoFrame {
let n = (w * h) as usize;
let data =
PixelData::Yuv16 { planes: [Arc::new(vec![0; n]), Arc::new(vec![0; n / 4]), Arc::new(vec![0; n / 4])], chroma: Chroma::C420, bits, alpha: None };
VideoFrame { width: w, height: h, data, color: Default::default(), par: (1, 1), pts: filmcraft_time::Tick::ZERO }
}
#[test]
fn alpha_luma_becomes_the_alpha_plane_at_the_frame_depth() {
let a = yuv8(2, 2, vec![0, 128, 255, 64]);
// 8-bit picture: the luma as is
let mut f = yuv8(2, 2, vec![9; 4]);
set_alpha(&mut f, &a);
let PixelData::Yuv8 { alpha, .. } = &f.data else { panic!() };
assert_eq!(alpha.as_deref(), Some(&vec![0, 128, 255, 64]));
// 10-bit picture: full range kept (255 → 1023)
let mut f = yuv16(2, 2, 10);
set_alpha(&mut f, &a);
let PixelData::Yuv16 { alpha, .. } = &f.data else { panic!() };
assert_eq!(alpha.as_deref(), Some(&vec![0, 514, 1023, 257]));
// RGBA picture (VP9 RGB profiles): the A channel
let mut f = VideoFrame::rgba8(2, 2, vec![7; 16]);
set_alpha(&mut f, &a);
let PixelData::Rgba8(px) = &f.data else { panic!() };
assert_eq!(px.chunks(4).map(|p| p[3]).collect::<Vec<_>>(), [0, 128, 255, 64]);
}
#[test]
fn an_alpha_picture_of_another_size_is_ignored() {
let mut f = yuv8(4, 2, vec![9; 8]);
set_alpha(&mut f, &yuv8(2, 2, vec![0; 4]));
let PixelData::Yuv8 { alpha, .. } = &f.data else { panic!() };
assert!(alpha.is_none());
// a luma plane shorter than the picture (damaged) is ignored too
let mut f = yuv8(2, 2, vec![9; 4]);
set_alpha(&mut f, &yuv8(2, 2, vec![0; 3]));
let PixelData::Yuv8 { alpha, .. } = &f.data else { panic!() };
assert!(alpha.is_none());
}
}

View File

@ -0,0 +1,261 @@
//! Sony XAVC MP4 with no `tmcd` track: the start timecode lives in an `rtmd` metadata track
//! (issue #460). A hand-built file exercises the whole probe path; the byte layout of the `rtmd`
//! sample is the one real XAVC footage uses (checked against ffprobe's `timecode` tag).
use std::sync::Arc;
use filmcraft_time::FrameRate;
/// Minimal ISO-BMFF box writer (the demuxer only needs headers and sample tables here).
#[derive(Default)]
struct B(Vec<u8>);
impl B {
fn u16(&mut self, v: u16) {
self.0.extend_from_slice(&v.to_be_bytes());
}
fn u32(&mut self, v: u32) {
self.0.extend_from_slice(&v.to_be_bytes());
}
fn bytes(&mut self, b: &[u8]) {
self.0.extend_from_slice(b);
}
fn zeros(&mut self, n: usize) {
self.0.resize(self.0.len() + n, 0);
}
/// A container box (`size` + `kind` + children).
fn container(&mut self, kind: &[u8; 4], f: impl FnOnce(&mut B)) {
let at = self.0.len();
self.u32(0);
self.bytes(kind);
f(self);
let len = (self.0.len() - at) as u32;
self.0[at..at + 4].copy_from_slice(&len.to_be_bytes());
}
/// A full box (`size` + `kind` + version/flags + body).
fn full(&mut self, kind: &[u8; 4], f: impl FnOnce(&mut B)) {
self.container(kind, |b| {
b.zeros(4);
f(b);
});
}
fn leaf(&mut self, kind: &[u8; 4], payload: &[u8]) {
self.container(kind, |b| b.bytes(payload));
}
}
/// A `stsd` visual sample entry (JPEG keeps the video track decodable enough to open).
fn video_entry(b: &mut B) {
b.bytes(&[0; 6]);
b.u16(1);
b.u16(0);
b.u16(0);
b.bytes(&[0; 4]);
b.u32(0);
b.u32(0);
b.u16(1920);
b.u16(1080);
b.u32(0x0048_0000);
b.u32(0x0048_0000);
b.u32(0);
b.u16(1);
b.bytes(&[0; 32]);
b.u16(24);
b.u16(0xFFFF);
}
/// A video track at 59.94 fps, plus a Sony `rtmd` track whose first sample is `rtmd_sample`.
fn file_with_rtmd(rtmd_sample: &[u8]) -> Vec<u8> {
file_with_rtmd_claimed(rtmd_sample, rtmd_sample.len() as u32)
}
/// As [`file_with_rtmd`] but the `rtmd` sample's `stsz` size is `claimed` (which may exceed the
/// bytes actually present, as a corrupt file can).
fn file_with_rtmd_claimed(rtmd_sample: &[u8], claimed: u32) -> Vec<u8> {
let mut b = B::default();
b.leaf(b"ftyp", b"isom\x00\x00\x00\x00isom");
let mdat_start = b.0.len();
let mdat: Vec<u8> = [&[1, 2, 3, 4][..], rtmd_sample].concat();
b.leaf(b"mdat", &mdat);
let video_off = (mdat_start + 8) as u32;
let rtmd_off = video_off + 4;
b.container(b"moov", |b| {
b.full(b"mvhd", |b| {
b.zeros(8);
b.u32(1000);
b.u32(1001);
b.zeros(80);
});
// Video track (id 1): one sample of 1001/60000 s → 59.94 fps.
b.container(b"trak", |b| {
b.full(b"tkhd", |b| {
b.zeros(8);
b.u32(1);
b.zeros(4);
b.u32(1001);
b.zeros(52);
b.u32(1920 << 16);
b.u32(1080 << 16);
});
b.container(b"mdia", |b| {
b.full(b"mdhd", |b| {
b.zeros(8);
b.u32(60000);
b.u32(1001);
b.u16(0x55C4);
b.u16(0);
});
b.full(b"hdlr", |b| {
b.zeros(4);
b.bytes(b"vide");
b.zeros(12);
b.bytes(b"Video\0");
});
b.container(b"minf", |b| {
b.container(b"stbl", |b| {
b.full(b"stsd", |b| {
b.u32(1);
b.container(b"jpeg", video_entry);
});
b.full(b"stts", |b| {
b.u32(1);
b.u32(1);
b.u32(1001);
});
b.full(b"stsc", |b| {
b.u32(1);
b.u32(1);
b.u32(1);
b.u32(1);
});
b.full(b"stsz", |b| {
b.u32(0);
b.u32(1);
b.u32(4);
});
b.full(b"stco", |b| {
b.u32(1);
b.u32(video_off);
});
});
});
});
});
// Sony rtmd metadata track (id 2).
b.container(b"trak", |b| {
b.full(b"tkhd", |b| {
b.zeros(8);
b.u32(2);
b.zeros(4);
b.u32(1);
b.zeros(52);
b.zeros(8);
});
b.container(b"mdia", |b| {
b.full(b"mdhd", |b| {
b.zeros(8);
b.u32(1);
b.u32(1);
b.u16(0x55C4);
b.u16(0);
});
b.full(b"hdlr", |b| {
b.zeros(4);
b.bytes(b"meta");
b.zeros(12);
b.bytes(b"Timed Metadata\0");
});
b.container(b"minf", |b| {
b.container(b"stbl", |b| {
b.full(b"stsd", |b| {
b.u32(1);
b.leaf(b"rtmd", &[0, 0, 0, 0, 0, 0, 0, 1]);
});
b.full(b"stts", |b| {
b.u32(1);
b.u32(1);
b.u32(1);
});
b.full(b"stsc", |b| {
b.u32(1);
b.u32(1);
b.u32(1);
b.u32(1);
});
b.full(b"stsz", |b| {
b.u32(0);
b.u32(1);
b.u32(claimed);
});
b.full(b"stco", |b| {
b.u32(1);
b.u32(rtmd_off);
});
});
});
});
});
});
b.0
}
/// A Sony `rtmd` sample: hours, minutes, seconds at 0x0d–0x0f, a drop-frame flag at 0x10 and the
/// frame number at 0x11, then the start of the SMPTE KLV key.
fn rtmd_sample(h: u8, m: u8, s: u8, f: u8, drop: u8) -> Vec<u8> {
let mut v = vec![0u8; 24];
v[0x0d] = h;
v[0x0e] = m;
v[0x0f] = s;
v[0x10] = drop;
v[0x11] = f;
v[0x12..0x16].copy_from_slice(&[0x06, 0x0e, 0x2b, 0x34]);
v
}
#[test]
fn rtmd_start_timecode_is_read() {
let file = file_with_rtmd(&rtmd_sample(3, 37, 12, 34, 0));
let src = filmcraft_codecs::open_bytes("fx3.mp4", Arc::from(file.into_boxed_slice())).unwrap();
let rate = FrameRate::new(60000, 1001);
let tc = src.info().start_timecode.expect("rtmd start timecode");
assert_eq!(tc, 781_954);
assert_eq!(filmcraft_time::format_timecode_frames(tc, rate, false), "03:37:12:34");
}
#[test]
fn rtmd_without_usable_sample_is_ignored() {
// Too short to carry the timecode block: no start timecode, and no panic.
let file = file_with_rtmd(&[0u8; 6]);
let src = filmcraft_codecs::open_bytes("short.mp4", Arc::from(file.into_boxed_slice())).unwrap();
assert_eq!(src.info().start_timecode, None);
}
#[test]
fn huge_claimed_rtmd_sample_does_not_exhaust_memory() {
// The stsz size claims 16 MiB but only the header bytes exist: only the fixed-size header is
// read, so a corrupt size cannot drive a huge allocation during open (issue #460 review).
let file = file_with_rtmd_claimed(&rtmd_sample(1, 2, 3, 4, 0), 16 * 1024 * 1024);
let src = filmcraft_codecs::open_bytes("big.mp4", Arc::from(file.into_boxed_slice())).unwrap();
assert_eq!(src.info().start_timecode, Some(223_384)); // 01:02:03:04 at 59.94
}
#[test]
fn damaged_timecode_fields_are_not_published() {
// Frame 60 at 59.94 fps (valid range is 0–59): unusable metadata, not a fabricated time.
let file = file_with_rtmd(&rtmd_sample(3, 37, 12, 60, 0));
let src = filmcraft_codecs::open_bytes("bad.mp4", Arc::from(file.into_boxed_slice())).unwrap();
assert_eq!(src.info().start_timecode, None);
}
#[test]
fn drop_frame_skipped_labels_are_not_published() {
// 00:01:00;00 at 59.94 fps: frames 00-03 of a non-tenth minute don't exist in drop-frame
// counting, so this metadata is unusable rather than a start time.
let file = file_with_rtmd(&rtmd_sample(0, 1, 0, 0, 1));
let src = filmcraft_codecs::open_bytes("df_bad.mp4", Arc::from(file.into_boxed_slice())).unwrap();
assert_eq!(src.info().start_timecode, None);
// The first real label of that minute (frame 04) is accepted.
let file = file_with_rtmd(&rtmd_sample(0, 1, 0, 4, 1));
let src = filmcraft_codecs::open_bytes("df_ok.mp4", Arc::from(file.into_boxed_slice())).unwrap();
assert_eq!(src.info().start_timecode, Some(3600));
}

View File

@ -923,3 +923,30 @@ fn linked_slip_keeps_picture_and_sound_aligned_at_unequal_media_limits() {
s.execute("timeline.slip", json!({"clip": v, "deltaFrames": -10})).unwrap();
assert_eq!((clip(&s, v).source_in, clip(&s, a).source_in), (Tick(fr.0 * 10), Tick(fr.0 * 10)));
}
#[test]
fn keyboard_slip_keeps_linked_clips_in_sync_past_the_shorter_handle() {
let mut s = Session::default();
s.execute("file.newProject", json!({"name": "Slip"})).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());
// lengthen only the picture: it keeps 10 frames of tail handle, the sound keeps 20
s.execute("sequence.linkedSelection", json!({"on": false})).unwrap();
s.execute("timeline.trim", json!({"clip": v, "edge": "out", "deltaFrames": 10})).unwrap();
s.execute("sequence.linkedSelection", json!({"on": true})).unwrap();
s.execute("timeline.select", json!({"clips": [v, a]})).unwrap();
for _ in 0..3 {
s.execute("timeline.slipLeft5", json!({})).unwrap();
}
assert_eq!((clip(&s, v).source_in, clip(&s, a).source_in), (Tick(fr.0 * 30), Tick(fr.0 * 30)));
// with linked selection off each selected clip still slips on its own
s.execute("sequence.linkedSelection", json!({"on": false})).unwrap();
s.execute("timeline.select", json!({"clips": [v, a]})).unwrap();
s.execute("timeline.slipLeft5", json!({})).unwrap();
assert_eq!((clip(&s, v).source_in, clip(&s, a).source_in), (Tick(fr.0 * 30), Tick(fr.0 * 35)));
}

View File

@ -349,6 +349,24 @@ fn move_clips(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"moved": moves.iter().map(|m| m.0.0).collect::<Vec<_>>(), "overwritten": overwritten}))
}
/// Slip clips together: the media delta of each clip is scaled down by the same factor so that the
/// most constrained clip (media end or start) still moves, and no linked partner slips further.
pub(crate) fn slip_together(q: &mut filmcraft_project::Sequence, ctx: &mut edit::EditCtx<'_>, deltas: &[(ClipId, Tick)]) -> Result<()> {
let mut scale = 1.0_f64;
for (c, d) in deltas {
if d.0 == 0 {
continue;
}
let x = edit::slip(&mut q.clone(), *c, *d, ctx)?;
scale = scale.min(x.0.abs() as f64 / d.0.abs() as f64);
}
for (c, d) in deltas {
let dd = if scale < 1.0 { Tick((d.0 as f64 * scale).round() as i64) } else { *d };
edit::slip(q, *c, dd, ctx)?;
}
Ok(())
}
/// Expand a clip selection with linked partners (when linked selection is on).
pub fn with_links(s: &Session, clips: &[ClipId]) -> Vec<ClipId> {
let Some(seq) = s.active_sequence() else { return clips.to_vec() };
@ -688,6 +706,20 @@ pub(crate) fn place_item(
})
}
/// How long an item runs on the Timeline without In/Out marks: its duration, or for a still (and a
/// generator without a length) Settings ▸ Timeline ▸ Still Image Default Duration.
pub(crate) fn full_duration(s: &Session, pi: &filmcraft_project::ProjectItem) -> Tick {
match &pi.kind {
ItemKind::Media(m)
if matches!(m.info.kind, filmcraft_media::MediaKind::Still)
|| (matches!(m.info.kind, filmcraft_media::MediaKind::Synthetic) && m.info.duration.0 <= 0) =>
{
s.prefs.timeline.still_duration(s.sequence_rate())
}
_ => pi.duration(),
}
}
pub(crate) fn source_range(s: &Session) -> Option<(ItemId, TimeRange)> {
let item = s.state.source_item?;
let pi = s.project.item(item)?;
@ -913,7 +945,7 @@ fn build() -> Vec<CommandSpec> {
Ok(json!({"items": ids.iter().map(|i| i.0).collect::<Vec<_>>()}))
}
),
cmd!("file.import", "Import…", ["File"], Some("Cmd+I"), r#"{"paths":[str],"bin":binId?,"imageSequence":bool?}"#, always, |s, p| {
cmd!("file.import", "Import…", ["File"], Some("Cmd+I"), r#"{"paths":[str],"bin":binId?,"imageSequence":bool?,"replace":bool?}"#, always, |s, p| {
let bin = u64_p(p, "bin").map(filmcraft_project::BinId);
let paths: Vec<String> = match p.get("paths").and_then(Value::as_array) {
Some(a) => a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect(),
@ -922,6 +954,10 @@ fn build() -> Vec<CommandSpec> {
if paths.is_empty() {
return Err(bad("file.import", "need `paths`"));
}
if bool_p(p, "replace").unwrap_or(false) {
let name = s.project.name.clone();
s.execute("file.newProject", json!({ "name": name }))?;
}
let mut ids = Vec::new();
let mut errors = Vec::new();
let mut sequences = Vec::new();
@ -1180,7 +1216,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"?,"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"?,"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
),
@ -1221,7 +1257,7 @@ fn build() -> Vec<CommandSpec> {
let caps = s.state.caption_selection.clone();
return crate::captions::delete(s, &caps, false);
}
let sel = with_links(s, &clips_p(s, p));
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() };
s.edit_sequence("Clear", |q, _, st| {
edit::delete_items(q, &sel);
st.selection.clear();
@ -1234,7 +1270,7 @@ fn build() -> Vec<CommandSpec> {
let caps = s.state.caption_selection.clone();
return crate::captions::delete(s, &caps, true);
}
let sel = with_links(s, &clips_p(s, p));
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() };
s.edit_sequence("Ripple Delete", |q, _, st| {
let spans = edit::ripple_delete_items(q, &sel)?;
if st.ripple_sequence_markers {
@ -2115,16 +2151,7 @@ fn build() -> Vec<CommandSpec> {
if p.get("audio").is_some() && audio && pi.has_audio() && a.is_none() {
return Err(bad("timeline.place", "no audio destination: add or enable an audio track"));
}
let full = match &pi.kind {
// Settings ▸ Timeline ▸ Still Image Default Duration
ItemKind::Media(m)
if matches!(m.info.kind, filmcraft_media::MediaKind::Still)
|| (matches!(m.info.kind, filmcraft_media::MediaKind::Synthetic) && m.info.duration.0 <= 0) =>
{
s.prefs.timeline.still_duration(s.sequence_rate())
}
_ => pi.duration(),
};
let full = full_duration(s, pi);
let (mi, mo) = match &pi.kind {
ItemKind::Media(m) => (m.mark_in, m.mark_out.map(|o| o + pi.frame_rate().frame_duration())),
ItemKind::Subclip { range, .. } => (Some(range.start), Some(range.end())),
@ -2136,13 +2163,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}"#, has_seq, |s, p| {
cmd!("timeline.select", "Select Clips", [], None, r#"{"clips":[id],"transitions":[id]?,"add":bool,"toggle":bool,"linked":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 = with_links(s, &clips);
let clips = if bool_p(p, "linked").unwrap_or(true) { with_links(s, &clips) } else { 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();
@ -2326,17 +2353,8 @@ fn build() -> Vec<CommandSpec> {
let clips = with_links(s, &[c]);
s.edit_sequence("Slip", |q, ctx, _| {
// clamp across all linked partners, then slip them by the common delta
let mut dd = d;
for c in &clips {
let x = edit::slip(&mut q.clone(), *c, dd, ctx)?;
if x.abs() < dd.abs() {
dd = x;
}
}
for c in &clips {
edit::slip(q, *c, dd, ctx)?;
}
Ok(())
let deltas: Vec<(ClipId, Tick)> = clips.iter().map(|c| (*c, d)).collect();
slip_together(q, ctx, &deltas)
})?;
Ok(Value::Null)
}),
@ -2571,15 +2589,33 @@ fn build() -> Vec<CommandSpec> {
{
e.params.insert(pid.clone(), filmcraft_project::Param::new(d.default.clone()));
}
let prm = crate::masks::target_param(e, &pq, &pid).ok_or_else(|| bad("effects.setParam", format!("no param `{pid}`")))?;
let v = json_to_param(&prm.value, &val).ok_or_else(|| bad("effects.setParam", "value has the wrong type"))?;
if keyframe {
// the stopwatch and the value in one step
prm.put_keyframe(mt, v);
} else {
prm.set_at(mt, v);
let (keyframes, v) = {
let prm = crate::masks::target_param(e, &pq, &pid).ok_or_else(|| bad("effects.setParam", format!("no param `{pid}`")))?;
let v = json_to_param(&prm.value, &val).ok_or_else(|| bad("effects.setParam", "value has the wrong type"))?;
let keyframes = if keyframe {
// the stopwatch and the value in one step
prm.put_keyframe(mt, v.clone());
prm.keyframes.len()
} else {
prm.set_at(mt, v.clone());
prm.keyframes.len()
};
(keyframes, v)
};
if e.effect == "ultra_key" && pid == "setting"
&& let ParamValue::Choice(setting) = v
&& let Some(rows) = filmcraft_project::effect::ultra_key_setting(setting)
{
for (id, value) in rows {
if let Some(p) = e.param_mut(id) {
p.value = ParamValue::Float(*value);
p.keyframes.clear();
} else {
e.params.insert((*id).to_string(), filmcraft_project::Param::new(ParamValue::Float(*value)));
}
}
}
Ok(prm.keyframes.len())
Ok(keyframes)
})?;
// 0 keyframes: the value is static (`time` was not used)
Ok(json!({"keyframes": keyframes}))
@ -2834,6 +2870,7 @@ fn build() -> Vec<CommandSpec> {
v.extend(crate::keyboard::commands());
v.extend(crate::project_panel::commands());
v.extend(crate::media_browser::commands());
v.extend(crate::paste_media::commands());
// Edit ▸ Label ▸ <colour>, Paste Attributes, subclips, Video / Audio Options, Replace With Clip…
// and their menu order
crate::clip_ops::apply_layout(&mut v);

View File

@ -481,6 +481,20 @@ fn hardware_encoding_is_off_unless_asked_for() {
assert_eq!(old.hardware_encoding, Off);
}
#[test]
fn crf_param_picks_constant_quality() {
use filmcraft_export::BitrateMode::{Cbr, Crf, Vbr1Pass};
let s = demo();
let setting = |p: Value| crate::export_tools::settings_from_params(&s, &p, "file.exportMedia").map(|(_, st)| (st.bitrate_mode, st.crf));
assert_eq!(setting(json!({"path": "x.mp4"})).unwrap(), (Vbr1Pass, 23.0));
// a factor alone means CRF mode; the mode can also be named, or another one kept explicitly
assert_eq!(setting(json!({"path": "x.mp4", "crf": 18})).unwrap(), (Crf, 18.0));
assert_eq!(setting(json!({"path": "x.mp4", "bitrateMode": "crf"})).unwrap(), (Crf, 23.0));
assert_eq!(setting(json!({"path": "x.mp4", "bitrateMode": "cbr", "crf": 18})).unwrap(), (Cbr, 18.0));
let e = setting(json!({"path": "x.mp4", "bitrateMode": "abr"})).unwrap_err().to_string();
assert!(e.contains("crf"), "{e}");
}
#[test]
fn queue_exports_several_sequences_and_ranges() {
let mut s = demo();

View File

@ -26,7 +26,7 @@ use std::sync::Arc;
use std::sync::atomic::Ordering;
use filmcraft_export::presets::{DEFAULT_PRESET, preset_key};
use filmcraft_export::{ExportPreset, ExportSettings, Format, GpuRendering, HardwareEncoding};
use filmcraft_export::{BitrateMode, ExportPreset, ExportSettings, Format, GpuRendering, HardwareEncoding};
use filmcraft_project::{ItemId, Project};
use filmcraft_time::{FrameRate, Tick, TimeRange};
use serde::{Deserialize, Serialize};
@ -213,7 +213,13 @@ pub fn settings_from_params(s: &Session, p: &Value, cmd: &str) -> Result<(Option
settings.max_bitrate_kbps = Some(v);
}
if let Some(v) = str_p(p, "bitrateMode") {
settings.bitrate_mode = serde_json::from_value(json!(v)).map_err(|_| bad(cmd, "bitrateMode: cbr | vbr1Pass | vbr2Pass"))?;
settings.bitrate_mode = serde_json::from_value(json!(v)).map_err(|_| bad(cmd, "bitrateMode: cbr | vbr1Pass | vbr2Pass | crf"))?;
}
if let Some(v) = f64_p(p, "crf") {
settings.crf = v as f32;
if p.get("bitrateMode").is_none() {
settings.bitrate_mode = BitrateMode::Crf;
}
}
if let Some(v) = crate::commands::checked_u32_p(p, "keyframeDistance", cmd)? {
settings.keyframe_distance = Some(v);

View File

@ -11,6 +11,24 @@ use serde_json::{Value, json};
use crate::Session;
use crate::media_test_util::tmp_dir;
#[test]
fn import_replace_starts_from_an_empty_project() {
let dir = tmp_dir("import-replace");
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 = demo();
let demo_items = s.project.items.len();
assert!(demo_items > 1);
assert!(!s.state.open_sequences.is_empty());
s.execute("file.import", json!({"paths": [wav.to_string_lossy()], "replace": true})).unwrap();
assert!(s.state.open_sequences.is_empty());
assert_eq!(s.project.items.len(), 1);
assert!(s.project.items.values().any(|it| it.name == "tone.wav"));
let mut merge = demo();
merge.execute("file.import", json!({"paths": [wav.to_string_lossy()]})).unwrap();
assert!(merge.project.items.len() > demo_items);
}
fn demo() -> Session {
let mut s = Session::default();
s.execute("file.openDemoProject", json!({})).unwrap();

View File

@ -59,7 +59,8 @@ pub fn import(s: &mut Session, path: &str, bytes: &[u8], format: Format) -> Resu
proj.resolve_placed_auto_points(|seq| !before.contains(&seq), |source| !is_file_media(source));
Ok(seqs)
})?;
// Link media: probe each new file-backed item that exists.
// Link media: probe each new file-backed item that exists, through the host's reader as a
// direct import does (only the container index is read now, not the whole file: #461).
let new_media: Vec<(ItemId, String)> = s
.project
.items
@ -76,11 +77,7 @@ pub fn import(s: &mut Session, path: &str, bytes: &[u8], format: Format) -> Resu
let mut linked = 0;
let mut offline = Vec::new();
for (id, mpath) in new_media {
let opened = s.services.read_file(&mpath).ok().and_then(|b| {
let fname = std::path::Path::new(&mpath).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
s.media.open_bytes(&fname, b.into()).ok()
});
match opened {
match s.media.open_file(&mpath, &*s.services).ok() {
Some(src) => {
let info = src.info().clone();
let identity = crate::relink::identity_of(&*s.services, &mpath).ok();

View File

@ -1,6 +1,6 @@
//! Interchange imports and the "auto" (NaN) point defaults of the effects they add: the points
//! are resolved as placing a clip resolves them, the anchor once the media's real size is known,
//! and a project saved after an import opens again.
//! and a project saved after an import opens again. Also how the media they name are linked.
use filmcraft_geom::Vec2;
use filmcraft_media::DemoScene;
@ -216,3 +216,43 @@ fn relinking_later_resolves_the_anchor_that_waited_for_the_real_size() {
assert_eq!(motion_point(&first_clip(&t, seq), "anchor"), Vec2::new(160.0, 90.0));
let _ = std::fs::remove_dir_all(&d);
}
/// Linking the media an interchange document names opens each file through the host's reader, as
/// a direct import does, instead of reading it whole into memory (#461: minutes for 4K files).
#[cfg(any(unix, windows))]
#[test]
fn an_interchange_import_links_media_without_reading_the_files_whole() {
/// The desktop filesystem, except that reading a media file whole fails.
struct NoWholeMediaReads;
impl crate::Services for NoWholeMediaReads {
fn read_file(&self, path: &str) -> std::io::Result<Vec<u8>> {
if path.ends_with(".mov") {
return Err(std::io::Error::other(format!("{path}: read whole")));
}
crate::FsServices.read_file(path)
}
fn write_file(&self, path: &str, data: &[u8]) -> std::io::Result<()> {
crate::FsServices.write_file(path, data)
}
fn file_size(&self, path: &str) -> std::io::Result<u64> {
crate::FsServices.file_size(path)
}
fn read_range(&self, path: &str, offset: u64, len: usize) -> std::io::Result<Vec<u8>> {
crate::FsServices.read_range(path, offset, len)
}
fn reader(&self, path: &str) -> Option<std::io::Result<filmcraft_media::SharedReader>> {
crate::FsServices.reader(path)
}
}
let d = tmp_dir("ix-streamed-link");
std::fs::create_dir_all(d.join("media")).unwrap();
make_movie(&d.join("media/shot.mov"), DemoScene::OceanSunset, 320, 180, 24);
let doc = d.join("cut.xml");
std::fs::write(&doc, xml_with_a_scale_only_motion_filter()).unwrap();
let mut s = Session::new(std::sync::Arc::new(NoWholeMediaReads));
let (r, seq) = import(&mut s, &doc);
assert_eq!(r["linkedMedia"], 1, "{r}");
let clip = first_clip(&s, seq);
assert_eq!(s.project.source_size(clip.item), Some((320, 180)), "the file's real size, from its index");
let _ = std::fs::remove_dir_all(&d);
}

View File

@ -40,7 +40,7 @@ use filmcraft_project::{ClipId, ItemKind, ParamValue, Sequence, TrackId, TrackKi
use filmcraft_time::Tick;
use serde_json::{Value, json};
use crate::commands::{CommandSpec, always, bad, has_selection, has_seq, item_p, time_p, with_links};
use crate::commands::{CommandSpec, always, bad, has_selection, has_seq, item_p, slip_together, time_p, with_links};
use crate::{EngineError, Result, Session};
type Run = fn(&mut Session, &Value) -> Result<Value>;
@ -248,14 +248,21 @@ fn slip_selection(s: &mut Session, frames: i64) -> Result<Value> {
let rate = s.sequence_rate();
let clips = with_links(s, &s.state.selection.clone());
let seq = s.active_sequence().ok_or(EngineError::NoSequence)?;
// media delta per clip (source frames run at the clip's speed)
let deltas: Vec<(ClipId, Tick)> = clips
.iter()
.filter_map(|c| seq.find_item(*c).map(|(_, i)| (*c, Tick((rate.tick_of(frames).0 as f64 * i.speed.abs().max(1e-9)).round() as i64))))
.collect();
let linked = s.state.linked_selection;
// media delta per clip (source frames run at the clip's speed); linked partners slip as one group
let mut groups: Vec<(Option<u64>, Vec<(ClipId, Tick)>)> = Vec::new();
for c in &clips {
let Some((_, i)) = seq.find_item(*c) else { continue };
let d = Tick((rate.tick_of(frames).0 as f64 * i.speed.abs().max(1e-9)).round() as i64);
let link = i.link.filter(|_| linked);
match groups.iter_mut().find(|(l, _)| link.is_some() && *l == link) {
Some((_, g)) => g.push((*c, d)),
None => groups.push((link, vec![(*c, d)])),
}
}
s.edit_sequence("Slip", |q, ctx, _| {
for (c, d) in &deltas {
edit::slip(q, *c, *d, ctx)?;
for (_, g) in &groups {
slip_together(q, ctx, g)?;
}
Ok(())
})?;

View File

@ -32,6 +32,7 @@ pub mod mixer;
pub mod multicam;
pub mod narration;
pub mod panels;
pub mod paste_media;
pub mod perf;
pub mod presets;
pub mod previews;
@ -1098,6 +1099,8 @@ mod panels_tests;
#[cfg(test)]
mod par_tests;
#[cfg(test)]
mod paste_media_tests;
#[cfg(test)]
mod presets_tests;
#[cfg(test)]
mod previews_tests;

View File

@ -12,7 +12,7 @@
//! | `mediaBrowser.favorite` | Add to / Remove from Favorites |
//! | `mediaBrowser.clearRecent` | Clear Recent Directories |
//! | `mediaBrowser.settings` | file types, view (list / thumbnails), columns (Edit Columns…), Import as Image Sequence, Hover Scrub, thumbnail size |
//! | `mediaBrowser.import` | Import (the selection or `paths`; Project ▸ Ingest settings apply) |
//! | `mediaBrowser.import` | Import (the selection or `paths`; a folder becomes a bin, its sub-folders bins inside it; Project ▸ Ingest settings apply) |
//! | `mediaBrowser.openInSource` | Open In Source Monitor (Shift+O) |
//! | `mediaBrowser.probe` | a file's media properties (frame rate, duration, video / audio info), cached |
//!
@ -421,32 +421,134 @@ fn paths_p(s: &Session, p: &Value) -> Vec<String> {
}
}
/// `mediaBrowser.import {paths?, bin?, imageSequence?}`: imports files (directories import their
/// supported files, like dragging a folder in Premiere).
/// Most folders one import walks, and how deep it goes: a folder import mirrors the tree as bins,
/// and symlinked folders can loop.
const FOLDER_IMPORT_MAX_FOLDERS: usize = 4096;
const FOLDER_IMPORT_MAX_DEPTH: usize = 32;
/// A folder to import as a bin: its name, its files and its sub-folders that hold files.
struct Folder {
name: String,
files: Vec<String>,
subs: Vec<Folder>,
}
/// The files the browser shows in `dir` and its sub-folders, as a tree; `None` when the tree
/// holds none (or `budget` folders were walked already).
fn walk_folder(services: &dyn Services, dir: &str, depth: usize, budget: &mut usize) -> Option<Folder> {
*budget = budget.checked_sub(1)?;
let entries = list(services, dir, "all").ok()?;
let mut folder = Folder { name: base_name(dir), files: Vec::new(), subs: Vec::new() };
for e in entries {
if !e.is_dir {
folder.files.push(e.path);
} else if depth < FOLDER_IMPORT_MAX_DEPTH
&& let Some(sub) = walk_folder(services, &e.path, depth + 1, budget)
{
folder.subs.push(sub);
}
}
(!folder.files.is_empty() || !folder.subs.is_empty()).then_some(folder)
}
/// Add a `file.import` result to the merged one: arrays add up, other values (the Ingest result)
/// are listed.
fn merge_import(out: &mut serde_json::Map<String, Value>, r: Value) {
let Value::Object(r) = r else { return };
for (k, v) in r {
let slot = out.entry(k).or_insert_with(|| json!([]));
let Value::Array(all) = slot else { continue };
match v {
Value::Null => {}
Value::Array(a) => all.extend(a),
other => all.push(other),
}
}
}
/// Run `file.import` and merge its result; a failure becomes an error of the merged result.
fn import_into(s: &mut Session, files: &[String], bin: Option<u64>, out: &mut serde_json::Map<String, Value>) {
let mut q = json!({"paths": files, "imageSequence": false});
if let Some(b) = bin {
q["bin"] = json!(b);
}
match s.execute("file.import", q) {
Ok(r) => merge_import(out, r),
Err(e) => merge_import(out, json!({"errors": [e.to_string()]})),
}
}
/// Import a folder as a bin named after it, in `parent`: its files go into the bin, each
/// sub-folder becomes a bin inside it (Premiere's Media Browser keeps the folder structure).
fn import_folder(s: &mut Session, folder: &Folder, parent: Option<u64>, out: &mut serde_json::Map<String, Value>) -> Result<()> {
let name = folder.name.clone();
let bin = s.edit("New Bin", |pr, _| Ok(pr.add_bin(&name, parent.map(filmcraft_project::BinId))))?;
merge_import(out, json!({"bins": [bin.0]}));
if !folder.files.is_empty() {
import_into(s, &folder.files, Some(bin.0), out);
}
// recursion as deep as the walk went, which is bounded
for sub in &folder.subs {
import_folder(s, sub, Some(bin.0), out)?;
}
Ok(())
}
/// `mediaBrowser.import {paths?, bin?, imageSequence?}`: imports files. A folder becomes a bin
/// named after it (in `bin`) holding its files, and each sub-folder a bin inside that, as in
/// Premiere; the result then also lists the new `bins`. Import as Image Sequence takes the first
/// file instead.
fn import(s: &mut Session, p: &Value) -> Result<Value> {
let paths = paths_p(s, p);
if paths.is_empty() {
return Err(bad("mediaBrowser.import", "select files in the Media Browser"));
}
let seq = bool_p(p, "imageSequence").unwrap_or(s.prefs.media_browser.import_as_image_sequence);
let bin = u64_p(p, "bin");
let mut files = Vec::new();
let mut folders = Vec::new();
let mut empty = Vec::new();
let mut budget = FOLDER_IMPORT_MAX_FOLDERS;
for path in paths {
// a directory imports its media files (not recursive)
let is_dir = matches!(s.services.list_entries(&path), Some(Ok(_))) && kind_of(&path).is_none();
match list(&*s.services, &path, "all") {
Ok(entries) if is_dir => files.extend(entries.into_iter().filter(|e| !e.is_dir).map(|e| e.path)),
_ => files.push(path),
if !is_dir {
files.push(path);
} else if seq {
// an image sequence: the folder's frames
match list(&*s.services, &path, "all") {
Ok(entries) => files.extend(entries.into_iter().filter(|e| !e.is_dir).map(|e| e.path)),
Err(_) => files.push(path),
}
} else {
match walk_folder(&*s.services, &path, 0, &mut budget) {
Some(f) => folders.push(f),
None => empty.push(path),
}
}
}
// an image sequence imports from its first selected frame only
if seq {
files.truncate(1);
}
let mut q = json!({"paths": files, "imageSequence": seq});
if let Some(b) = u64_p(p, "bin") {
q["bin"] = json!(b);
if folders.is_empty() {
if files.is_empty() && !empty.is_empty() {
return Err(bad("mediaBrowser.import", format!("no media files in {}", empty.join(", "))));
}
let mut q = json!({"paths": files, "imageSequence": seq});
if let Some(b) = bin {
q["bin"] = json!(b);
}
return s.execute("file.import", q);
}
s.execute("file.import", q)
let mut out = serde_json::Map::new();
merge_import(&mut out, json!({"items": [], "errors": empty.iter().map(|e| format!("{e}: no media files")).collect::<Vec<_>>(), "bins": []}));
if !files.is_empty() {
import_into(s, &files, bin, &mut out);
}
for f in &folders {
import_folder(s, f, bin, &mut out)?;
}
Ok(Value::Object(out))
}
/// The project item that already references a file.

View File

@ -210,6 +210,47 @@ fn import_the_selection_and_open_in_source() {
assert_eq!(r["items"].as_array().unwrap().len(), 1);
}
/// Importing a folder keeps its structure (#411): the folder becomes a bin named after it, each
/// sub-folder with media a bin inside that, and every file lands in its folder's bin.
#[test]
fn importing_a_folder_mirrors_its_sub_folders_as_bins() {
let mut fs = FakeFs::new();
let wav = filmcraft_media::wav::write_wav16(&[0.0; 4800], 2, 48_000);
fs.dirs.extend(["/Shoot", "/Shoot/Day 1", "/Shoot/Day 1/Cam A", "/Shoot/Empty"].map(String::from));
for f in ["/Shoot/slate.wav", "/Shoot/Day 1/room.wav", "/Shoot/Day 1/Cam A/take1.wav", "/Shoot/Day 1/Cam A/take2.wav"] {
fs.files.get_mut().unwrap().insert(f.into(), wav.clone());
}
let mut s = Session::new(Arc::new(fs));
let r = s.execute("mediaBrowser.import", json!({"paths": ["/Shoot"]})).unwrap();
assert_eq!(r["items"].as_array().unwrap().len(), 4, "{r}");
assert_eq!(r["bins"].as_array().unwrap().len(), 3, "{r}");
let tree = |b: &filmcraft_project::Bin| -> Vec<String> {
b.children
.iter()
.map(|c| match c {
filmcraft_project::BinEntry::Bin(b) => format!("bin {}", b.name),
filmcraft_project::BinEntry::Item(i) => s.project.item(*i).unwrap().name.clone(),
})
.collect()
};
let sub = |b: &filmcraft_project::Bin, name: &str| -> filmcraft_project::Bin {
b.children
.iter()
.find_map(|c| match c {
filmcraft_project::BinEntry::Bin(b) if b.name == name => Some(b.clone()),
_ => None,
})
.unwrap()
};
assert_eq!(tree(&s.project.root), ["bin Shoot"]);
let shoot = sub(&s.project.root, "Shoot");
// a folder without media gets no bin
assert_eq!(tree(&shoot), ["slate.wav", "bin Day 1"]);
let day = sub(&shoot, "Day 1");
assert_eq!(tree(&day), ["room.wav", "bin Cam A"]);
assert_eq!(tree(&sub(&day, "Cam A")), ["take1.wav", "take2.wav"]);
}
#[test]
fn import_with_ingest_settings() {
let mut s = session();

View File

@ -0,0 +1,123 @@
//! Paste media from the system clipboard (#611). The desktop app reads the clipboard: copied files
//! paste by path, and an image is first saved by [`save_pasted_image`] as `Pasted Image <n>.png`
//! where voice-over takes and narrations go (Scratch Disks ▸ Captured Audio and Video, else next
//! to the project).
//!
//! `edit.pasteMedia` imports the files and puts them on the Timeline at the playhead, end to end,
//! without overwriting anything: each clip goes on the first free track from the targeted one
//! outward (up for video, down for audio), and a track is added when every track is busy there.
//! With `insert` they are inserted on the targeted tracks instead, as Paste Insert does.
use filmcraft_project::{BinId, ClipId, ItemId, TrackId, TrackKind};
use filmcraft_time::Tick;
use serde_json::{Value, json};
use crate::commands::{CommandSpec, always, bad, bool_p, u64_p};
use crate::{EngineError, Result, Session};
/// Write a pasted image (PNG bytes) to a new `Pasted Image <n>.png`; returns its path.
pub fn save_pasted_image(s: &mut Session, png: &[u8]) -> Result<String> {
let dir = crate::voiceover::media_dir(s, &Value::Null, "Pasted Images");
let names: std::collections::HashSet<String> = s.project.items.values().map(|i| i.name.clone()).collect();
// bounded like voiceover::unique_wav_path: `file_size` is a filesystem call per try
let path = (1..=99_999u32)
.map(|k| format!("Pasted Image {k}.png"))
.map(|file| (std::path::Path::new(&dir).join(&file).to_string_lossy().into_owned(), file))
.find(|(path, file)| !names.contains(file) && s.services.file_size(path).is_err())
.map(|(path, _)| path)
.ok_or_else(|| EngineError::Other(format!("{dir}: no free file name for \"Pasted Image <n>.png\"")))?;
if !cfg!(target_arch = "wasm32") {
std::fs::create_dir_all(&dir).map_err(|e| EngineError::Other(format!("{dir}: {e}")))?;
}
s.services.write_file(&path, png).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
Ok(path)
}
fn paste_media(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "edit.pasteMedia";
let paths: Vec<String> =
p.get("paths").and_then(Value::as_array).map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect()).unwrap_or_default();
if paths.is_empty() {
return Err(bad(cmd, "need `paths`"));
}
let place = bool_p(p, "place").unwrap_or(true);
if place && s.active_sequence().is_none() {
return Err(EngineError::NoSequence);
}
let insert = bool_p(p, "insert").unwrap_or(false);
let n0 = s.history.undo.len();
let r = import_and_place(s, &paths, u64_p(p, "bin").map(BinId), place, insert);
crate::clip_ops::collapse_history(s, n0, if insert { "Paste Insert" } else { "Paste" });
r
}
fn import_and_place(s: &mut Session, paths: &[String], bin: Option<BinId>, place: bool, insert: bool) -> Result<Value> {
let mut import = json!({"paths": paths});
if let Some(b) = bin {
import["bin"] = json!(b.0);
}
let imported = s.execute("file.import", import)?;
let items: Vec<ItemId> = imported["items"].as_array().map(|a| a.iter().filter_map(Value::as_u64).map(ItemId).collect()).unwrap_or_default();
if !place {
return Ok(json!({"items": items.iter().map(|i| i.0).collect::<Vec<_>>(), "clips": []}));
}
let mut at = s.playhead();
let mut clips: Vec<ClipId> = Vec::new();
for item in &items {
let Some(pi) = s.project.item(*item) else { continue };
let (dur, video, audio) = (crate::commands::full_duration(s, pi), pi.has_video(), pi.has_audio());
let mut params = json!({"item": item.0, "time": at.0, "insert": insert});
if !insert {
if video {
params["track"] = json!(free_track(s, TrackKind::Video, at, at + dur)?.0);
}
if audio {
params["audioTrack"] = json!(free_track(s, TrackKind::Audio, at, at + dur)?.0);
}
}
let placed = s.execute("timeline.place", params)?;
let ids: Vec<ClipId> = placed["clips"].as_array().map(|a| a.iter().filter_map(Value::as_u64).map(ClipId).collect()).unwrap_or_default();
let seq = s.active_sequence().ok_or(EngineError::NoSequence)?;
at = ids.iter().filter_map(|c| seq.find_item(*c)).map(|(_, it)| it.end()).max().unwrap_or(at + dur);
clips.extend(ids);
}
// the playhead goes to the end of what was pasted, as after Paste
s.set_playhead(at);
s.state.selection = clips.clone();
Ok(json!({"items": items.iter().map(|i| i.0).collect::<Vec<_>>(), "clips": clips.iter().map(|c| c.0).collect::<Vec<_>>()}))
}
/// The first unlocked track of `kind` from the targeted one outward with nothing in `start..end`.
/// When every track is busy there, a new one is added (on top for video, at the bottom for audio).
fn free_track(s: &mut Session, kind: TrackKind, start: Tick, end: Tick) -> Result<TrackId> {
let seq = s.active_sequence().ok_or(EngineError::NoSequence)?;
let tg = s.targeting();
let (tracks, dest) = match kind {
TrackKind::Video => (&seq.video_tracks, tg.video_dest),
TrackKind::Audio => (&seq.audio_tracks, tg.audio_dest),
};
let from = dest.and_then(|d| tracks.iter().position(|t| t.id == d)).unwrap_or(0);
let free = tracks.iter().skip(from).find(|t| !t.locked && !t.items.iter().any(|i| i.start < end && i.end() > start)).map(|t| t.id);
if let Some(t) = free {
return Ok(t);
}
let (params, key) = match kind {
TrackKind::Video => (json!({"video": 1}), "video"),
TrackKind::Audio => (json!({"video": 0, "audio": 1}), "audio"),
};
let added = s.execute("sequence.addTracks", params)?;
added[key][0].as_u64().map(TrackId).ok_or_else(|| EngineError::Other("no track was added".into()))
}
pub(crate) fn commands() -> Vec<CommandSpec> {
vec![CommandSpec {
id: "edit.pasteMedia",
label: "Paste Media",
menu: &[],
shortcut: None,
params: r#"{"paths":[str],"insert":bool?,"place":bool=true,"bin":binId?}"#,
enabled: always,
run: paste_media,
journal: true,
}]
}

View File

@ -0,0 +1,173 @@
//! Pasting files and images from the system clipboard (#611).
use std::path::Path;
use filmcraft_project::{BinEntry, BinId, ClipId, ItemId, TrackKind};
use filmcraft_time::Tick;
use serde_json::{Value, json};
use crate::Session;
use crate::media_test_util::tmp_dir;
fn demo() -> Session {
let mut s = Session::default();
s.execute("file.openDemoProject", json!({})).unwrap();
s
}
fn write_png(path: &Path) -> String {
image::RgbaImage::from_pixel(64, 36, image::Rgba([40, 120, 200, 255])).save(path).unwrap();
path.to_string_lossy().into_owned()
}
fn write_wav(path: &Path, seconds: f32) -> String {
std::fs::write(path, crate::voiceover::write_wav_f32(&vec![0.0; (48_000.0 * seconds) as usize], 48_000)).unwrap();
path.to_string_lossy().into_owned()
}
/// Every clip of the active sequence: (track kind, track index, clip id, start, end).
fn clips(s: &Session) -> Vec<(TrackKind, usize, u64, Tick, Tick)> {
let q = s.active_sequence().unwrap();
let mut v = Vec::new();
for (kind, tracks) in [(TrackKind::Video, &q.video_tracks), (TrackKind::Audio, &q.audio_tracks)] {
for (i, t) in tracks.iter().enumerate() {
v.extend(t.items.iter().map(|c| (kind, i, c.id.0, c.start, c.end())));
}
}
v
}
fn pasted(r: &Value) -> Vec<u64> {
r["clips"].as_array().unwrap().iter().map(|c| c.as_u64().unwrap()).collect()
}
/// An image pasted over footage goes on the first video track that is free there, from the
/// targeted one up; every clip already on the Timeline stays as it was. One undo step takes the
/// clip and the imported item back; the playhead moves to its end, as after Paste.
#[test]
fn a_pasted_image_sits_above_the_footage_without_overwriting_it() {
let dir = tmp_dir("paste-media-image");
let mut s = demo();
let png = write_png(&dir.join("shot.png"));
let at = s.playhead();
let (before, items, undo) = (clips(&s), s.project.items.len(), s.history.undo.len());
assert!(before.iter().any(|c| c.0 == TrackKind::Video && c.1 == 0 && c.3 <= at && c.4 > at), "the playhead is over a V1 clip");
let r = s.execute("edit.pasteMedia", json!({"paths": [png]})).unwrap();
let ids = pasted(&r);
assert_eq!(ids.len(), 1, "a still has picture only");
let after = clips(&s);
for c in &before {
assert!(after.contains(c), "{c:?} is untouched");
}
let new = *after.iter().find(|c| c.2 == ids[0]).unwrap();
assert_eq!((new.0, new.3), (TrackKind::Video, at));
assert!(new.1 > 0, "not on V1, where the footage is");
assert!(!before.iter().any(|c| c.0 == TrackKind::Video && c.1 == new.1 && c.3 < new.4 && c.4 > new.3), "on a track that was free there");
assert!(before.iter().any(|c| c.0 == TrackKind::Video && c.1 < new.1 && c.3 < new.4 && c.4 > new.3), "so it sits above a clip");
assert_eq!(new.4 - new.3, s.prefs.timeline.still_duration(s.sequence_rate()), "for the still image default duration");
assert_eq!(s.playhead(), new.4);
assert_eq!(s.state.selection, vec![ClipId(ids[0])]);
assert_eq!(s.history.undo.len(), undo + 1);
assert_eq!(s.undo().as_deref(), Some("Paste"));
assert_eq!((clips(&s), s.project.items.len()), (before, items), "undo takes back the clip and the import");
let _ = std::fs::remove_dir_all(&dir);
}
/// With every video track busy at the playhead a new one is added on top; undo removes it too.
/// Audio goes on the first free audio track from the targeted one down, or a new one at the bottom.
#[test]
fn a_track_is_added_when_every_track_is_busy() {
let dir = tmp_dir("paste-media-tracks");
let mut s = demo();
let png = write_png(&dir.join("a.png"));
let wav = write_wav(&dir.join("b.wav"), 2.0);
let at = s.playhead();
// fill every track at the playhead
for _ in 0..8 {
let q = s.active_sequence().unwrap();
let busy = |ts: &[filmcraft_project::Track]| ts.iter().all(|t| t.items.iter().any(|c| c.start <= at && c.end() > at + Tick::from_seconds_f64(5.0)));
if busy(&q.video_tracks) && busy(&q.audio_tracks) {
break;
}
s.set_playhead(at);
s.execute("edit.pasteMedia", json!({"paths": [png.clone(), wav.clone()]})).unwrap();
}
let (nv, na) = {
let q = s.active_sequence().unwrap();
(q.video_tracks.len(), q.audio_tracks.len())
};
let before = clips(&s);
s.set_playhead(at);
let r = s.execute("edit.pasteMedia", json!({"paths": [png, wav]})).unwrap();
let ids = pasted(&r);
let q = s.active_sequence().unwrap();
assert_eq!((q.video_tracks.len(), q.audio_tracks.len()), (nv + 1, na + 1), "one new track of each kind");
let after = clips(&s);
let image = after.iter().find(|c| c.2 == ids[0]).unwrap();
assert_eq!((image.0, image.1), (TrackKind::Video, nv), "the image on the new top track");
let sound = after.iter().find(|c| c.2 == ids[1]).unwrap();
assert_eq!((sound.0, sound.1, sound.3), (TrackKind::Audio, na, image.4), "the sound on the new bottom track, after the image");
for c in &before {
assert!(after.contains(c), "{c:?} is untouched");
}
s.undo();
let q = s.active_sequence().unwrap();
assert_eq!((q.video_tracks.len(), q.audio_tracks.len()), (nv, na), "undo removes the tracks it added");
let _ = std::fs::remove_dir_all(&dir);
}
/// `insert` is Paste Insert: on the targeted video track, pushing later clips right.
#[test]
fn paste_insert_inserts_on_the_targeted_track() {
let dir = tmp_dir("paste-media-insert");
let mut s = demo();
for t in ["V2", "V3", "A2", "A3"] {
let _ = s.execute("timeline.setTrack", json!({"track": t, "syncLock": false}));
}
let png = write_png(&dir.join("i.png"));
let at = s.playhead();
let last = s.active_sequence().unwrap().video_tracks[0].items.iter().map(|c| c.end()).max().unwrap();
let r = s.execute("edit.pasteMedia", json!({"paths": [png], "insert": true})).unwrap();
let new = *clips(&s).iter().find(|c| c.2 == pasted(&r)[0]).unwrap();
let still = s.prefs.timeline.still_duration(s.sequence_rate());
assert_eq!((new.0, new.1, new.3), (TrackKind::Video, 0, at), "on the targeted track, V1");
assert_eq!(s.active_sequence().unwrap().video_tracks[0].items.iter().map(|c| c.end()).max().unwrap(), last + still, "later clips moved right");
assert_eq!(s.undo().as_deref(), Some("Paste Insert"));
let _ = std::fs::remove_dir_all(&dir);
}
/// Without `place` (the Project panel) the files are only imported, into `bin`.
#[test]
fn without_place_files_are_only_imported() {
let dir = tmp_dir("paste-media-import");
let mut s = demo();
let png = write_png(&dir.join("p.png"));
let bin = s.execute("file.newBin", json!({"name": "Pasted"})).unwrap()["bin"].as_u64().unwrap();
let before = clips(&s);
let r = s.execute("edit.pasteMedia", json!({"paths": [png], "place": false, "bin": bin})).unwrap();
assert_eq!(clips(&s), before);
let item = ItemId(r["items"][0].as_u64().unwrap());
let pasted_bin = s.project.root.find_bin(BinId(bin)).unwrap();
assert!(pasted_bin.children.iter().any(|e| matches!(e, BinEntry::Item(i) if *i == item)), "imported into the bin");
assert!(s.execute("edit.pasteMedia", json!({"paths": []})).is_err(), "nothing to paste");
let _ = std::fs::remove_dir_all(&dir);
}
/// A pasted image is written as `Pasted Image <n>.png` next to the saved project, with the first
/// number no file or project item uses.
#[test]
fn pasted_images_are_saved_next_to_the_project() {
let dir = tmp_dir("paste-media-save");
let mut s = demo();
s.path = Some(dir.join("film.fcproj").to_string_lossy().into_owned());
let mut png = Vec::new();
image::RgbaImage::from_pixel(8, 8, image::Rgba([0, 0, 0, 255])).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
let first = crate::paste_media::save_pasted_image(&mut s, &png).unwrap();
assert_eq!(Path::new(&first), dir.join("Pasted Image 1.png"));
assert_eq!(std::fs::read(&first).unwrap(), png);
let second = crate::paste_media::save_pasted_image(&mut s, &png).unwrap();
assert_eq!(Path::new(&second), dir.join("Pasted Image 2.png"));
s.execute("edit.pasteMedia", json!({"paths": [second]})).unwrap();
assert!(s.project.items.values().any(|i| i.name == "Pasted Image 2.png"));
let _ = std::fs::remove_dir_all(&dir);
}

View File

@ -365,12 +365,24 @@ pub fn instantiate(p: &EffectPreset, target: &TrackItem, size: (u32, u32)) -> Ve
out
}
/// Put preset effects on a clip: intrinsic ones replace the clip's instance, others are inserted
/// before the intrinsic effects.
/// Keep `old`'s keyframes that lie outside the span `new` animates, so presets keyframing the same
/// parameter at different times (Fade In, then Fade Out) add up instead of replacing each other.
fn merge_keyframes(new: &mut EffectInstance, old: &EffectInstance) {
for (id, p) in new.params.iter_mut() {
let (Some(first), Some(last)) = (p.keyframes.first().map(|k| k.time), p.keyframes.last().map(|k| k.time)) else { continue };
let Some(o) = old.params.get(id) else { continue };
p.keyframes.extend(o.keyframes.iter().filter(|k| k.time < first || k.time > last).cloned());
p.keyframes.sort_by_key(|k| k.time);
}
}
/// Put preset effects on a clip: intrinsic ones replace the clip's instance (keeping its keyframes
/// outside the span the preset animates), others are inserted before the intrinsic effects.
pub fn put_effects(it: &mut TrackItem, effects: Vec<EffectInstance>) {
for e in effects {
for mut e in effects {
let intrinsic = e.def().is_some_and(|d| d.intrinsic);
if intrinsic && let Some(slot) = it.effects.iter_mut().find(|x| x.effect == e.effect) {
merge_keyframes(&mut e, slot);
*slot = e;
continue;
}

View File

@ -194,6 +194,25 @@ fn builtins_apply_and_user_presets_persist() {
let _ = std::fs::remove_dir_all(&dir);
}
/// #333: Fade In then Fade Out on one clip keeps both ramps (they keyframe the same Opacity).
#[test]
fn fade_in_and_fade_out_add_up_on_one_clip() {
let (mut s, clips) = demo();
let c = clips[0];
s.execute("presets.apply", json!({"preset": "Fade In", "clips": [c.0]})).unwrap();
s.execute("presets.apply", json!({"preset": "Fade Out", "clips": [c.0]})).unwrap();
let it = item(&s, c);
let op = it.effect("opacity").unwrap().param("opacity").unwrap();
let keys: Vec<(Tick, ParamValue)> = op.keyframes.iter().map(|k| (k.time, k.value.clone())).collect();
let (tin, tout) = (it.source_in, it.source_in + media_len(&it));
let f = ParamValue::Float;
assert_eq!(keys, vec![(tin, f(0.0)), (tin + secs(1.0), f(100.0)), (tout - secs(1.0), f(100.0)), (tout, f(0.0))], "both fades survive");
// re-applying Fade In replaces its own ramp, not the Fade Out one
s.execute("presets.apply", json!({"preset": "Fade In", "clips": [c.0]})).unwrap();
let it = item(&s, c);
assert_eq!(it.effect("opacity").unwrap().param("opacity").unwrap().keyframes.len(), 4);
}
#[test]
fn mask_geometry_scales_to_the_target_frame_size() {
let (s, clips) = demo();

View File

@ -17,9 +17,9 @@
//! | `project.freeform.saveArrangement` / `.restoreArrangement` / `.arrangements` / `.deleteArrangement` | saved arrangements |
//! | `project.freeform.options` | Freeform View Options… (grid, snap, names, durations) |
//! | `project.renameBin` | rename a bin (inline rename, undoable) |
//! | `project.selectAll` / `project.deselectAll` | select every item in the project / clear the Project panel selection (Cmd+A / Cmd+Shift+A with the panel focused) |
//! | `project.selectAll` / `project.deselectAll` | select the items a bin shows (`bin`, plus the sub-bins open in List view in `expanded`; without `bin` every item in the project) / clear the Project panel selection (Cmd+A / Cmd+Shift+A with the panel focused) |
use std::collections::BTreeMap;
use std::collections::{BTreeMap, BTreeSet};
use filmcraft_project::{Bin, BinEntry, BinId, ItemId, ItemKind, MediaRef, Project, ProjectItem, TrackKind};
use filmcraft_time::{Tick, TimeDisplay, format_time};
@ -1171,6 +1171,43 @@ fn rename_bin(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"bin": bin.0, "name": name}))
}
/// Select All in the Project panel. With `bin`, only what a panel showing that bin lists: its
/// items, plus the items of the sub-bins named in `expanded` (bins twirled open in List view),
/// never those of a closed bin. Without `bin`, every item in the project.
fn select_all(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "project.selectAll";
// strict: a negative or fractional `bin` is an error, not bin 0
let id = match p.get("bin") {
None | Some(Value::Null) => {
s.state.project_selection = s.project.items.keys().copied().collect();
return Ok(json!({"selected": s.state.project_selection.len()}));
}
Some(b) => BinId(b.as_u64().ok_or_else(|| bad(CMD, "`bin` must be a bin id"))?),
};
let bin = s.project.root.find_bin(id).ok_or_else(|| bad(CMD, format!("no bin {}", id.0)))?;
let expanded: BTreeSet<BinId> = match p.get("expanded") {
None | Some(Value::Null) => Default::default(),
Some(Value::Array(a)) => {
a.iter().map(|v| v.as_u64().map(BinId).ok_or_else(|| bad(CMD, "`expanded` must be a list of bin ids"))).collect::<Result<_>>()?
}
Some(_) => return Err(bad(CMD, "`expanded` must be a list of bin ids")),
};
// iterative walk: a hostile project file cannot overflow the stack with deeply nested bins
let mut sel = Vec::new();
let mut stack = vec![bin];
while let Some(b) = stack.pop() {
for e in &b.children {
match e {
BinEntry::Item(i) if s.project.item(*i).is_some_and(|it| listed(&s.project, it)) => sel.push(*i),
BinEntry::Bin(sub) if expanded.contains(&sub.id) => stack.push(sub),
_ => {}
}
}
}
s.state.project_selection = sel;
Ok(json!({"bin": id.0, "selected": s.state.project_selection.len()}))
}
// ------------------------------------------------------------------------------------ registry
pub(crate) fn commands() -> Vec<CommandSpec> {
@ -1223,10 +1260,7 @@ pub(crate) fn commands() -> Vec<CommandSpec> {
freeform_options,
),
c("project.renameBin", "Rename Bin", r#"{"bin":binId,"name":str}"#, always, rename_bin),
c("project.selectAll", "Select All Project Items", "{}", always, |s, _| {
s.state.project_selection = s.project.items.keys().copied().collect();
Ok(json!({"selected": s.state.project_selection.len()}))
}),
c("project.selectAll", "Select All Project Items", r#"{"bin":binId?,"expanded":[binId]?}"#, always, select_all),
c("project.deselectAll", "Deselect All Project Items", "{}", always, |s, _| {
s.state.project_selection.clear();
Ok(Value::Null)

View File

@ -2,7 +2,7 @@
//! sorting, view presets, Freeform positions / stacks / arrangements (undoable, saved with the
//! project) and bin renames.
use filmcraft_project::{ItemId, ItemKind};
use filmcraft_project::{BinEntry, ItemId, ItemKind};
use serde_json::{Value, json};
use crate::Session;
@ -305,3 +305,75 @@ fn select_all_selects_every_project_item() {
assert!(!s.state.selection.is_empty());
assert_eq!(s.state.project_selection, before);
}
#[test]
fn select_all_in_a_bin_skips_closed_bins_and_other_bins() {
// #456: Cmd+A in the Project panel selected every item in the project, closed bins included
let mut s = demo();
let outer = s.execute("file.newBin", json!({"name": "Footage A"})).unwrap()["bin"].as_u64().unwrap();
let inner = s.execute("file.newBin", json!({"name": "Day 1", "parent": outer})).unwrap()["bin"].as_u64().unwrap();
let ids = media(&s);
assert!(ids.len() >= 2);
s.execute("project.moveToBin", json!({"items": [ids[0].0], "bin": outer})).unwrap();
s.execute("project.moveToBin", json!({"items": [ids[1].0], "bin": inner})).unwrap();
s.state.project_selection.clear();
// only the bin's own items: the closed sub-bin's item stays unselected
let r = s.execute("project.selectAll", json!({"bin": outer})).unwrap();
assert_eq!(r["selected"].as_u64(), Some(1));
assert_eq!(s.state.project_selection, vec![ids[0]]);
// a sub-bin twirled open in List view counts
s.execute("project.selectAll", json!({"bin": outer, "expanded": [inner]})).unwrap();
let mut sel = s.state.project_selection.clone();
sel.sort();
assert_eq!(sel, vec![ids[0], ids[1]]);
// the root: its direct items only, nothing from the (closed) bins
let root = s.project.root.id;
let mut want: Vec<ItemId> = s
.project
.root
.children
.iter()
.filter_map(|e| if let BinEntry::Item(i) = e { Some(*i) } else { None })
.filter(|i| s.project.item(*i).is_some_and(|it| project_panel::listed(&s.project, it)))
.collect();
want.sort();
s.execute("project.selectAll", json!({"bin": root.0})).unwrap();
let mut sel = s.state.project_selection.clone();
sel.sort();
assert_eq!(sel, want);
assert!(!sel.contains(&ids[0]) && !sel.contains(&ids[1]));
assert!(sel.len() < s.project.items.len());
// an open bin inside a closed one is not shown, so it does not count either
s.execute("project.selectAll", json!({"bin": root.0, "expanded": [inner]})).unwrap();
assert!(!s.state.project_selection.contains(&ids[1]));
// ... but open all the way down, it does
s.execute("project.selectAll", json!({"bin": root.0, "expanded": [outer, inner]})).unwrap();
assert!(s.state.project_selection.contains(&ids[0]) && s.state.project_selection.contains(&ids[1]));
}
#[test]
fn select_all_rejects_bad_bins_without_touching_the_selection() {
let mut s = demo();
let ids = media(&s);
let root = s.project.root.id.0;
s.execute("project.select", json!({"items": [ids[0].0]})).unwrap();
for p in [
json!({"bin": 987_654_321u64}),
json!({"bin": "footage"}),
json!({"bin": -1}),
json!({"bin": root, "expanded": "all"}),
json!({"bin": root, "expanded": ["x"]}),
] {
assert!(s.execute("project.selectAll", p.clone()).is_err(), "{p}");
assert_eq!(s.state.project_selection, vec![ids[0]], "{p}");
}
// unknown ids in `expanded` are simply not open bins
assert!(s.execute("project.selectAll", json!({"bin": root, "expanded": [987_654_321u64]})).is_ok());
// `bin: null` is the same as no bin: the whole project
let r = s.execute("project.selectAll", json!({"bin": null})).unwrap();
assert_eq!(r["selected"].as_u64(), Some(s.project.items.len() as u64));
}

View File

@ -22,9 +22,9 @@
//! | `clip.automateToSequence` | Clip ▸ Automate to Sequence… |
//! | `help.systemReport` | query behind Help ▸ System Compatibility Report… |
use std::collections::BTreeSet;
use std::collections::{BTreeMap, BTreeSet};
use filmcraft_project::{BinEntry, BinId, FindOp, FindQuery, FindRow, ItemId, ItemKind, MarkerKind, MediaRef, Project, SearchBin, TrackKind};
use filmcraft_project::{BinEntry, FindOp, FindQuery, FindRow, ItemId, ItemKind, MarkerKind, MediaRef, Project, SearchBin, TrackKind};
use filmcraft_time::{Tick, TimeDisplay, TimeRange};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
@ -704,18 +704,42 @@ fn new_from_template(s: &mut Session, p: &Value) -> Result<Value> {
/// everything those items need), as `project.delete` does. The project's own top bin does not,
/// since no command acts on it (#244), and only ids that name a real item end up in the result.
pub fn closure(p: &Project, roots: &[ItemId]) -> BTreeSet<ItemId> {
// Index the bin tree once. Told apart from items with a set membership test (rather than a
// full `find_bin` per queued id, which is O(n²) when exporting many items from a flat
// project) and expanded from the indexed children (rather than a second walk per bin).
let mut bins: BTreeSet<u64> = BTreeSet::new();
let mut children: BTreeMap<u64, (Vec<ItemId>, Vec<u64>)> = BTreeMap::new();
let mut bin_stack = vec![&p.root];
while let Some(b) = bin_stack.pop() {
bins.insert(b.id.0);
let mut direct = (Vec::new(), Vec::new());
for c in &b.children {
match c {
BinEntry::Item(i) => direct.0.push(*i),
BinEntry::Bin(x) => {
direct.1.push(x.id.0);
bin_stack.push(x);
}
}
}
children.insert(b.id.0, direct);
}
let mut keep: BTreeSet<ItemId> = BTreeSet::new();
let mut todo: Vec<ItemId> = roots.to_vec();
let mut seen_bins: BTreeSet<u64> = BTreeSet::new();
let mut bin_todo: Vec<u64> = Vec::new();
while let Some(id) = todo.pop() {
let bin = BinId(id.0);
if bin != p.root.id && p.root.find_bin(bin).is_some() {
if seen_bins.insert(id.0)
&& let Some(b) = p.root.find_bin(bin)
{
let mut items = Vec::new();
b.all_items(&mut items);
todo.extend(items);
if bins.contains(&id.0) && id.0 != p.root.id.0 {
bin_todo.push(id.0);
while let Some(bid) = bin_todo.pop() {
if !seen_bins.insert(bid) {
continue;
}
if let Some((items, subs)) = children.get(&bid) {
todo.extend(items.iter().copied());
bin_todo.extend(subs.iter().copied());
}
}
continue;
}

View File

@ -104,6 +104,9 @@ pub struct AppearancePrefs {
pub accessible_contrast: bool,
/// Highlight (accent) colour of selections, focus and primary buttons (`#rrggbb`).
pub highlight_color: String,
/// "UI Scale": `auto` (default: the scale the operating system reports) or a fixed
/// percentage of [`UI_SCALES`] (`100` = one point per physical pixel).
pub ui_scale: String,
}
impl Default for AppearancePrefs {
@ -115,11 +118,19 @@ impl Default for AppearancePrefs {
light_theme: "light".into(),
accessible_contrast: false,
highlight_color: DEFAULT_HIGHLIGHT.into(),
ui_scale: "auto".into(),
}
}
}
impl AppearancePrefs {
/// The fixed UI scale (physical pixels per point) "UI Scale" asks for, or `None` for `auto`
/// (and for a value outside [`UI_SCALES`]), which keeps the operating system's scale.
pub fn ui_scale_factor(&self) -> Option<f32> {
// `auto` doesn't parse; every other listed value is a positive whole percentage
UI_SCALES.iter().any(|(k, _)| *k == self.ui_scale).then(|| self.ui_scale.parse::<f32>().ok()).flatten().map(|pct| pct / 100.0)
}
/// The theme to show (`darkest` | `dark` | `light`): the light or dark theme of the mode. Auto
/// follows `system_light` (the operating system's appearance) and is dark without an answer.
pub fn shown_theme(&self, system_light: Option<bool>) -> &str {
@ -669,6 +680,17 @@ const BIN_OPEN: &[(&str, &str)] = &[("openInPlace", "Open in place"), ("openNewT
const PROJECT_OPEN: &[(&str, &str)] = &[("openNewTab", "Open new tab"), ("openNewWindow", "Open in new window")];
const THEMES: &[(&str, &str)] = &[("darkest", "Darkest"), ("dark", "Dark"), ("light", "Light")];
pub const APPEARANCE_MODES: &[(&str, &str)] = &[("auto", "Sync with system"), ("dark", "Dark"), ("light", "Light")];
pub const UI_SCALES: &[(&str, &str)] = &[
("auto", "Auto (system)"),
("75", "75%"),
("100", "100%"),
("125", "125%"),
("150", "150%"),
("175", "175%"),
("200", "200%"),
("250", "250%"),
("300", "300%"),
];
pub const DARK_THEMES: &[(&str, &str)] = &[("darkest", "Darkest"), ("dark", "Dark")];
pub const LIGHT_THEMES: &[(&str, &str)] = &[("light", "Light")];
const MIXDOWN: &[(&str, &str)] = &[("front", "Front Only"), ("frontRear", "Front + Rear"), ("frontLfe", "Front + LFE"), ("frontRearLfe", "Front + Rear + LFE")];
@ -795,6 +817,7 @@ static CATEGORIES: &[Category] = &[
f("appearance.colorTheme", "Color Theme", Kind::Choice(THEMES), true),
b("appearance.accessibleContrast", "Accessible color contrast", true),
f("appearance.highlightColor", "Highlight Color", Kind::Color, true),
f("appearance.uiScale", "UI Scale", Kind::Choice(UI_SCALES), true),
],
},
Category {

View File

@ -139,6 +139,45 @@ 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();

View File

@ -115,3 +115,16 @@ fn new_effect_params_are_settable_and_keyframable() {
let img = s.render_program(0.125).unwrap();
assert!(img.px.iter().all(|v| v.is_finite()));
}
#[test]
fn ultra_key_setting_writes_aggressive_parameters() {
let mut s = demo();
let clip = first_v1_clip(&s);
s.execute("effects.apply", json!({"clips": [clip], "effect": "ultra_key"})).unwrap();
s.execute("effects.setParam", json!({"clip": clip, "effect": "ultra_key", "param": "setting", "value": 2})).unwrap();
let e = s.active_sequence().unwrap().find_item(filmcraft_project::ClipId(clip)).unwrap().1.effect("ultra_key").unwrap();
assert_eq!(e.param("tolerance").unwrap().value.as_f64(), Some(90.0));
assert_eq!(e.param("pedestal").unwrap().value.as_f64(), Some(50.0));
assert_eq!(e.param("choke").unwrap().value.as_f64(), Some(10.0));
assert_eq!(e.param("contrast").unwrap().value.as_f64(), Some(10.0));
}

View File

@ -145,6 +145,11 @@ impl AudioOut {
Some(buf)
}
/// Output samples still to come (per channel).
pub fn remaining(&self) -> u64 {
u64::try_from(self.end.saturating_sub(self.out)).unwrap_or(0)
}
/// Everything still to come (to the end of the range).
pub fn rest(&mut self, sources: &dyn SourceProvider) -> Option<Vec<Vec<f32>>> {
self.pull(self.end, sources)

View File

@ -274,6 +274,10 @@ pub struct ExportSettings {
/// stream needs is raised.
pub h264_level: Option<u8>,
pub bitrate_mode: BitrateMode,
/// Constant rate factor of [`BitrateMode::Crf`], 0 (best) to 51 (smallest); 23 by default,
/// 18 is visually near-lossless.
#[serde(default = "default_crf")]
pub crf: f32,
/// May H.264 be encoded by the system's hardware encoder (VideoToolbox on macOS, NVENC on
/// Windows)? Off unless asked for: hardware output depends on the machine, so it is not
/// byte-reproducible like the built-in encoder's (`determinism_tests`).
@ -471,6 +475,7 @@ impl Default for ExportSettings {
h264_profile: H264Profile::High,
h264_level: None,
bitrate_mode: BitrateMode::default(),
crf: DEFAULT_CRF,
hardware_encoding: HardwareEncoding::default(),
gpu_rendering: GpuRendering::Off,
max_bitrate_kbps: None,
@ -490,6 +495,13 @@ impl Default for ExportSettings {
}
}
/// [`ExportSettings::crf`] of new settings and of settings saved before it existed.
pub const DEFAULT_CRF: f32 = 23.0;
fn default_crf() -> f32 {
DEFAULT_CRF
}
impl ExportSettings {
/// Reject settings the encoders cannot honour.
pub fn validate(&self) -> Result<()> {
@ -524,6 +536,14 @@ impl ExportSettings {
if self.format == Format::Hevc && self.bitrate_mode == BitrateMode::Vbr2Pass {
return Err(ExportError::Unsupported("H.265 export has no two-pass mode: choose CBR or VBR, 1 pass".into()));
}
if self.bitrate_mode == BitrateMode::Crf {
if self.format == Format::Hevc {
return Err(ExportError::Unsupported("H.265 export has no CRF mode: choose CBR or VBR, 1 pass".into()));
}
if !self.crf.is_finite() || !(0.0..=51.0).contains(&self.crf) {
return Err(ExportError::Unsupported("CRF must be between 0 and 51".into()));
}
}
Ok(())
}
}
@ -1442,6 +1462,8 @@ fn h264_factory(format: Format, w: u32, h: u32, rate: FrameRate, s: &ExportSetti
let max = s.max_bitrate_kbps.filter(|m| *m >= kbps).unwrap_or_else(|| (u64::from(kbps) * 3 / 2).min(u64::from(u32::MAX)) as u32);
cfg.rate = match s.bitrate_mode {
BitrateMode::Cbr => filmcraft_h264enc::RateControl::Cbr { kbps },
// the encoder clamps to 0..=51; a NaN from a hand-edited preset becomes the default
BitrateMode::Crf => filmcraft_h264enc::RateControl::Crf(if s.crf.is_finite() { s.crf } else { DEFAULT_CRF }),
_ => filmcraft_h264enc::RateControl::Vbr { target_kbps: kbps, max_kbps: max },
};
cfg.pass = match &s.h264_pass {
@ -1572,20 +1594,41 @@ pub fn export(project: &Arc<Project>, seq: ItemId, settings: &ExportSettings, so
let batch = rayon::current_num_threads().clamp(2, 16) as i64;
let (bytes, nframes) = match settings.format {
Format::Wav | Format::Aiff => {
let mut a = audio_out::AudioOut::new(project.clone(), seq, settings, range)?;
let (ch, sr, bits) = (a.channels as u16, a.sr, settings.audio.bits);
let container = if settings.format == Format::Wav { pcm::PcmContainer::Wav } else { pcm::PcmContainer::Aiff };
let frames = a.remaining();
// the sizes are known up front: refuse an AIFF that cannot hold them before measuring
let head = pcm::header(container, ch, sr, bits, frames).map_err(ExportError::Unsupported)?;
// one second per chunk: memory stays flat however long the range is
let chunk = i64::from(sr.max(1));
if !settings.part_of_batch {
progress.total.store(1, Ordering::Relaxed);
progress.total.store(frames.div_ceil(chunk as u64).max(1), Ordering::Relaxed);
progress.set_status(format!("Exporting audio ({})", settings.format.label()));
}
let mut a = audio_out::AudioOut::new(project.clone(), seq, settings, range)?;
a.measure(settings, sources, &cancelled)?;
*progress.loudness.lock().unwrap_or_else(|e| e.into_inner()) = a.loudness;
let planar = a.rest(sources).unwrap_or_else(|| vec![Vec::new(); a.channels]);
let inter = audio_out::interleave(&planar);
let (ch, sr, bits) = (a.channels as u16, a.sr, settings.audio.bits);
let data = if settings.format == Format::Wav { pcm::write_wav(&inter, ch, sr, bits) } else { pcm::write_aiff(&inter, ch, sr, bits) };
let n = write_output(settings, &settings.path, data)?;
progress.done.store(1, Ordering::Relaxed);
(n, planar.first().map_or(0, Vec::len) as u64)
let mut out = Out::create(settings)?;
let io = |e: std::io::Error| ExportError::Io(e.to_string());
out.write_all(&head).map_err(io)?;
let mut buf = Vec::new();
while let Some(planar) = a.pull(a.out.saturating_add(chunk), sources) {
if cancelled() {
return Err(ExportError::Cancelled);
}
buf.clear();
pcm::encode(container, &audio_out::interleave(&planar), bits, &mut buf);
out.write_all(&buf).map_err(io)?;
if !settings.part_of_batch {
progress.done.fetch_add(1, Ordering::Relaxed);
}
}
out.write_all(pcm::pad(frames, ch, bits)).map_err(io)?;
let n = out.finish(settings)?;
if settings.part_of_batch {
progress.done.store(1, Ordering::Relaxed);
}
(n, frames)
}
Format::PngSequence | Format::TiffSequence | Format::BmpSequence | Format::Gif => {
let pipe = pipeline::Pipeline::new(project.clone(), seq, settings, false)?;

View File

@ -1,5 +1,16 @@
//! Audio-only files: WAV (RIFF, little-endian PCM) and AIFF (big-endian PCM), 16 or 24 bit, plus
//! image-sequence file naming.
//! Audio-only files: WAV (RIFF, little-endian PCM; RF64 past 4 GiB) and AIFF (big-endian PCM),
//! 16 or 24 bit, plus image-sequence file naming.
//!
//! The header is written first from the known length ([`header`]), then the samples in chunks
//! ([`encode`]) and the pad byte ([`pad`]), so an export streams to disk instead of building the
//! file in memory.
/// The container of an audio-only export.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum PcmContainer {
Wav,
Aiff,
}
/// Quantise one sample to a signed integer of `bits` (16 or 24).
fn quantise(s: f32, bits: u16) -> i32 {
@ -7,41 +18,128 @@ fn quantise(s: f32, bits: u16) -> i32 {
(s.clamp(-1.0, 1.0) * max).round() as i32
}
/// The sample size written for a requested `bits`: 24, else 16.
fn sample_bits(bits: u16) -> u16 {
if bits >= 24 { 24 } else { 16 }
}
/// Bytes of sample data for `frames` frames, or None when that does not fit 64 bits.
fn data_len(frames: u64, channels: u16, bits: u16) -> Option<u64> {
frames.checked_mul(u64::from(channels))?.checked_mul(u64::from(sample_bits(bits) / 8))
}
/// The pad byte after an odd-sized data chunk (RIFF and AIFF chunks are word-aligned).
pub fn pad(frames: u64, channels: u16, bits: u16) -> &'static [u8] {
if data_len(frames, channels, bits).is_some_and(|n| n & 1 == 1) { &[0] } else { &[] }
}
/// Everything before the samples of a file of `frames` frames. A WAV whose RIFF size would not
/// fit 32 bits (4 GiB: 24-bit 5.1 at 96 kHz after about 41 minutes) is written as RF64 (EBU Tech
/// 3306), with the 64-bit sizes in a `ds64` chunk. AIFF has no 64-bit form, so a longer AIFF is
/// an error naming WAV instead.
pub fn header(container: PcmContainer, channels: u16, sample_rate: u32, bits: u16, frames: u64) -> Result<Vec<u8>, String> {
let too_long = || "the audio is too long to write".to_string();
let bits = sample_bits(bits);
let bps = bits / 8;
let data = data_len(frames, channels, bits).ok_or_else(too_long)?;
let block_align = channels.checked_mul(bps).ok_or("too many audio channels")?;
match container {
PcmContainer::Wav => {
let byte_rate = u32::try_from(u64::from(sample_rate) * u64::from(block_align)).map_err(|_| "the audio sample rate is too high".to_string())?;
let ext = channels > 2;
let fmt_len: u32 = if ext { 40 } else { 16 };
let padded = data.checked_add(data & 1).ok_or_else(too_long)?;
// "WAVE" + fmt chunk + data chunk header + samples
let riff = (4 + 8 + u64::from(fmt_len) + 8).checked_add(padded).ok_or_else(too_long)?;
let rf64 = riff > u64::from(u32::MAX);
let mut v = Vec::with_capacity(80);
if rf64 {
const DS64: u64 = 8 + 28;
v.extend_from_slice(b"RF64");
v.extend_from_slice(&u32::MAX.to_le_bytes());
v.extend_from_slice(b"WAVEds64");
v.extend_from_slice(&28u32.to_le_bytes());
v.extend_from_slice(&riff.checked_add(DS64).ok_or_else(too_long)?.to_le_bytes());
v.extend_from_slice(&data.to_le_bytes());
v.extend_from_slice(&frames.to_le_bytes());
v.extend_from_slice(&0u32.to_le_bytes()); // no table entries
} else {
v.extend_from_slice(b"RIFF");
v.extend_from_slice(&(riff as u32).to_le_bytes());
v.extend_from_slice(b"WAVE");
}
v.extend_from_slice(b"fmt ");
v.extend_from_slice(&fmt_len.to_le_bytes());
v.extend_from_slice(&(if ext { 0xFFFEu16 } else { 1 }).to_le_bytes());
v.extend_from_slice(&channels.to_le_bytes());
v.extend_from_slice(&sample_rate.to_le_bytes());
v.extend_from_slice(&byte_rate.to_le_bytes());
v.extend_from_slice(&block_align.to_le_bytes());
v.extend_from_slice(&bits.to_le_bytes());
if ext {
// cbSize, valid bits, channel mask, KSDATAFORMAT_SUBTYPE_PCM
v.extend_from_slice(&22u16.to_le_bytes());
v.extend_from_slice(&bits.to_le_bytes());
let mask: u32 = if channels == 6 { 0x3F } else { 0 };
v.extend_from_slice(&mask.to_le_bytes());
v.extend_from_slice(&[0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10, 0x00, 0x80, 0x00, 0x00, 0xAA, 0x00, 0x38, 0x9B, 0x71]);
}
v.extend_from_slice(b"data");
v.extend_from_slice(&(if rf64 { u32::MAX } else { data as u32 }).to_le_bytes());
Ok(v)
}
PcmContainer::Aiff => {
let aiff_too_long = || "AIFF holds at most 4 GB of audio: export WAV, which has no such limit".to_string();
let frames32 = u32::try_from(frames).map_err(|_| aiff_too_long())?;
// SSND: offset + block size + samples
let ssnd = u32::try_from(data.checked_add(8).ok_or_else(aiff_too_long)?).map_err(|_| aiff_too_long())?;
let form = (4u32 + (8 + 18)).checked_add(8).and_then(|n| n.checked_add(ssnd)).and_then(|n| n.checked_add(ssnd & 1)).ok_or_else(aiff_too_long)?;
let mut v = Vec::with_capacity(54);
v.extend_from_slice(b"FORM");
v.extend_from_slice(&form.to_be_bytes());
v.extend_from_slice(b"AIFFCOMM");
v.extend_from_slice(&18u32.to_be_bytes());
v.extend_from_slice(&channels.to_be_bytes());
v.extend_from_slice(&frames32.to_be_bytes());
v.extend_from_slice(&bits.to_be_bytes());
v.extend_from_slice(&extended(sample_rate));
v.extend_from_slice(b"SSND");
v.extend_from_slice(&ssnd.to_be_bytes());
v.extend_from_slice(&0u32.to_be_bytes()); // offset
v.extend_from_slice(&0u32.to_be_bytes()); // block size
Ok(v)
}
}
}
/// Append interleaved samples as `bits`-bit PCM (little-endian for WAV, big-endian for AIFF).
pub fn encode(container: PcmContainer, interleaved: &[f32], bits: u16, out: &mut Vec<u8>) {
let bits = sample_bits(bits);
let bps = usize::from(bits / 8);
out.reserve(interleaved.len().saturating_mul(bps));
for &s in interleaved {
let q = quantise(s, bits);
match container {
PcmContainer::Wav => out.extend_from_slice(q.to_le_bytes().get(..bps).unwrap_or_default()),
PcmContainer::Aiff => out.extend_from_slice(q.to_be_bytes().get(4 - bps..).unwrap_or_default()),
}
}
}
/// A whole file in memory (short audio; exports stream with [`header`] and [`encode`]).
fn write_whole(container: PcmContainer, interleaved: &[f32], channels: u16, sample_rate: u32, bits: u16) -> Result<Vec<u8>, String> {
let frames = (interleaved.len() / usize::from(channels.max(1))) as u64;
let mut v = header(container, channels, sample_rate, bits, frames)?;
encode(container, interleaved, bits, &mut v);
v.extend_from_slice(pad(frames, channels, bits));
Ok(v)
}
/// A RIFF/WAVE file of interleaved samples. More than two channels are written as
/// `WAVE_FORMAT_EXTENSIBLE` with the speaker mask of the layout (6 channels: L, R, C, LFE, Ls, Rs
/// = `0x3F`).
pub fn write_wav(interleaved: &[f32], channels: u16, sample_rate: u32, bits: u16) -> Vec<u8> {
let bits: u16 = if bits >= 24 { 24 } else { 16 };
let bps = (bits / 8) as u32;
let data_len = interleaved.len() as u32 * bps;
let ext = channels > 2;
let fmt_len: u32 = if ext { 40 } else { 16 };
let mut v = Vec::with_capacity(28 + fmt_len as usize + data_len as usize);
v.extend_from_slice(b"RIFF");
v.extend_from_slice(&(20 + fmt_len + data_len).to_le_bytes());
v.extend_from_slice(b"WAVEfmt ");
v.extend_from_slice(&fmt_len.to_le_bytes());
v.extend_from_slice(&(if ext { 0xFFFEu16 } else { 1 }).to_le_bytes());
v.extend_from_slice(&channels.to_le_bytes());
v.extend_from_slice(&sample_rate.to_le_bytes());
v.extend_from_slice(&(sample_rate * channels as u32 * bps).to_le_bytes());
v.extend_from_slice(&(channels * bps as u16).to_le_bytes());
v.extend_from_slice(&bits.to_le_bytes());
if ext {
// cbSize, valid bits, channel mask, KSDATAFORMAT_SUBTYPE_PCM
v.extend_from_slice(&22u16.to_le_bytes());
v.extend_from_slice(&bits.to_le_bytes());
let mask: u32 = if channels == 6 { 0x3F } else { 0 };
v.extend_from_slice(&mask.to_le_bytes());
v.extend_from_slice(&[0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10, 0x00, 0x80, 0x00, 0x00, 0xAA, 0x00, 0x38, 0x9B, 0x71]);
}
v.extend_from_slice(b"data");
v.extend_from_slice(&data_len.to_le_bytes());
for &s in interleaved {
let q = quantise(s, bits).to_le_bytes();
v.extend_from_slice(&q[..bps as usize]);
}
v
pub fn write_wav(interleaved: &[f32], channels: u16, sample_rate: u32, bits: u16) -> Result<Vec<u8>, String> {
write_whole(PcmContainer::Wav, interleaved, channels, sample_rate, bits)
}
/// IEEE 754 80-bit extended encoding of a sample rate (AIFF `COMM`).
@ -59,34 +157,8 @@ fn extended(rate: u32) -> [u8; 10] {
}
/// An AIFF file (`FORM`/`AIFF`, `COMM` + `SSND`) of interleaved samples.
pub fn write_aiff(interleaved: &[f32], channels: u16, sample_rate: u32, bits: u16) -> Vec<u8> {
let bits: u16 = if bits >= 24 { 24 } else { 16 };
let bps = (bits / 8) as usize;
let frames = interleaved.len() / channels.max(1) as usize;
let data_len = (interleaved.len() * bps) as u32;
let ssnd_len = 8 + data_len;
let form_len = 4 + (8 + 18) + (8 + ssnd_len) + (ssnd_len & 1);
let mut v = Vec::with_capacity(form_len as usize + 8);
v.extend_from_slice(b"FORM");
v.extend_from_slice(&form_len.to_be_bytes());
v.extend_from_slice(b"AIFFCOMM");
v.extend_from_slice(&18u32.to_be_bytes());
v.extend_from_slice(&channels.to_be_bytes());
v.extend_from_slice(&(frames as u32).to_be_bytes());
v.extend_from_slice(&bits.to_be_bytes());
v.extend_from_slice(&extended(sample_rate));
v.extend_from_slice(b"SSND");
v.extend_from_slice(&ssnd_len.to_be_bytes());
v.extend_from_slice(&0u32.to_be_bytes()); // offset
v.extend_from_slice(&0u32.to_be_bytes()); // block size
for &s in interleaved {
let q = quantise(s, bits).to_be_bytes();
v.extend_from_slice(&q[4 - bps..]);
}
if ssnd_len & 1 == 1 {
v.push(0);
}
v
pub fn write_aiff(interleaved: &[f32], channels: u16, sample_rate: u32, bits: u16) -> Result<Vec<u8>, String> {
write_whole(PcmContainer::Aiff, interleaved, channels, sample_rate, bits)
}
/// File name of frame `index` of an image sequence of `count` frames exported to `path`:
@ -126,12 +198,98 @@ mod tests {
#[test]
fn wav_and_aiff_layout() {
let s = [0.5f32, -0.5, 1.0, -1.0];
let w = write_wav(&s, 2, 48_000, 24);
let w = write_wav(&s, 2, 48_000, 24).unwrap();
assert_eq!(w.len(), 44 + 12);
assert_eq!(&w[34..36], &24u16.to_le_bytes());
let a = write_aiff(&s, 2, 48_000, 16);
let a = write_aiff(&s, 2, 48_000, 16).unwrap();
assert_eq!(&a[..4], b"FORM");
assert_eq!(u32::from_be_bytes(a[4..8].try_into().unwrap()) as usize, a.len() - 8);
assert_eq!(&a[a.len() - 2..], &(-32767i16).to_be_bytes());
}
fn le32(b: &[u8], at: usize) -> u32 {
u32::from_le_bytes(b[at..at + 4].try_into().unwrap())
}
fn le64(b: &[u8], at: usize) -> u64 {
u64::from_le_bytes(b[at..at + 8].try_into().unwrap())
}
/// 24-bit 5.1 at 96 kHz passes 4 GiB after about 41 minutes; the sizes overflowed `u32`
/// (a panic in debug builds, a corrupt header in release).
#[test]
fn wav_past_4_gib_is_rf64() {
let frames = 96_000u64 * 60 * 60; // an hour: 6.2 GB
let h = header(PcmContainer::Wav, 6, 96_000, 24, frames).unwrap();
let data = frames * 6 * 3;
assert_eq!(&h[..4], b"RF64");
assert_eq!(le32(&h, 4), u32::MAX);
assert_eq!(&h[8..16], b"WAVEds64");
assert_eq!(le32(&h, 16), 28);
// the 64-bit RIFF size covers everything after the first 8 bytes
assert_eq!(le64(&h, 20), h.len() as u64 - 8 + data);
assert_eq!((le64(&h, 28), le64(&h, 36), le32(&h, 44)), (data, frames, 0));
assert_eq!(&h[48..52], b"fmt ");
assert_eq!(&h[h.len() - 8..h.len() - 4], b"data");
assert_eq!(le32(&h, h.len() - 4), u32::MAX);
}
#[test]
fn wav_up_to_4_gib_stays_riff() {
// the longest stereo 16-bit RIFF: 36 bytes of header after the size field
let max = (u64::from(u32::MAX) - 36) / 4;
let h = header(PcmContainer::Wav, 2, 48_000, 16, max).unwrap();
assert_eq!(&h[..4], b"RIFF");
assert_eq!(u64::from(le32(&h, 4)), 36 + max * 4);
assert_eq!(u64::from(le32(&h, 40)), max * 4);
assert_eq!(&header(PcmContainer::Wav, 2, 48_000, 16, max + 1).unwrap()[..4], b"RF64");
}
#[test]
fn aiff_past_4_gib_is_refused_not_wrapped() {
let e = header(PcmContainer::Aiff, 2, 48_000, 24, 48_000 * 60 * 60 * 8).unwrap_err();
assert!(e.contains("export WAV"), "{e}");
assert!(header(PcmContainer::Aiff, 2, 48_000, 24, 48_000 * 60 * 60).is_ok());
}
#[test]
fn hostile_sizes_are_errors() {
for c in [PcmContainer::Wav, PcmContainer::Aiff] {
assert!(header(c, 2, 48_000, 24, u64::MAX).is_err());
assert!(header(c, u16::MAX, 48_000, 24, 1).is_err());
}
assert!(header(PcmContainer::Wav, 6, u32::MAX, 24, 1).is_err());
}
#[test]
fn odd_data_chunks_are_padded() {
// one 24-bit mono frame: 3 bytes of data, one pad byte, counted in the RIFF size
let w = write_wav(&[0.25], 1, 48_000, 24).unwrap();
assert_eq!((w.len(), le32(&w, 4) as usize, le32(&w, 40)), (48, 40, 3));
let a = write_aiff(&[0.25], 1, 48_000, 24).unwrap();
assert_eq!(u32::from_be_bytes(a[4..8].try_into().unwrap()) as usize, a.len() - 8);
assert!(pad(2, 1, 24).is_empty());
}
/// A reader takes the RF64 sizes: ffprobe reports the duration from `ds64` (skipped without it).
#[test]
fn ffprobe_reads_rf64_duration() {
let dir = std::env::temp_dir().join(format!("filmcraft-rf64-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("long.wav");
let frames = 48_000u64 * 60 * 60 * 5; // 5 hours of 24-bit stereo: 5.2 GB
let mut f = header(PcmContainer::Wav, 2, 48_000, 24, frames).unwrap();
encode(PcmContainer::Wav, &[0.0; 9_600], 24, &mut f);
std::fs::write(&path, &f).unwrap();
let out = std::process::Command::new("ffprobe")
.args(["-v", "error", "-show_entries", "format=format_name,duration", "-of", "default=nw=1"])
.arg(&path)
.output();
let _ = std::fs::remove_dir_all(&dir);
let Ok(out) = out else { return };
let text = String::from_utf8_lossy(&out.stdout);
assert!(text.contains("format_name=wav"), "{text}");
let secs: f64 = text.lines().find_map(|l| l.strip_prefix("duration=")).and_then(|d| d.parse().ok()).unwrap_or(0.0);
assert!((secs - 18_000.0).abs() < 1.0, "{text}");
}
}

View File

@ -102,6 +102,9 @@ pub enum BitrateMode {
Vbr1Pass,
/// Variable bitrate, two passes (the picture is rendered and analysed first).
Vbr2Pass,
/// Constant quality: every frame is coded at [`ExportSettings::crf`] and the bitrate follows the
/// picture (H.264, built-in encoder only).
Crf,
}
impl BitrateMode {
@ -110,6 +113,7 @@ impl BitrateMode {
BitrateMode::Cbr => "CBR",
BitrateMode::Vbr1Pass => "VBR, 1 pass",
BitrateMode::Vbr2Pass => "VBR, 2 pass",
BitrateMode::Crf => "CRF (constant quality)",
}
}
}
@ -520,6 +524,7 @@ impl ExportSettings {
);
v += &match self.bitrate_mode {
BitrateMode::Cbr => format!(", {}", mbps(r.target_kbps)),
BitrateMode::Crf => format!(" {}", self.crf),
_ => format!(", Target {}, Max {}", mbps(r.target_kbps), mbps(r.max_kbps)),
};
v += &format!(", keyframe every {} frames", r.keyint);

View File

@ -324,6 +324,40 @@ fn two_pass_and_cbr_h264() {
}
}
#[test]
fn crf_h264() {
// settings saved before CRF existed read as the default; the range is checked
let old: ExportSettings = serde_json::from_value(serde_json::json!({"format": "h264", "bitrateMode": "vbr1Pass"})).unwrap();
assert_eq!(old.crf, DEFAULT_CRF);
let c: ExportSettings = serde_json::from_value(serde_json::json!({"bitrateMode": "crf", "crf": 18.0})).unwrap();
assert_eq!((c.bitrate_mode, c.crf), (BitrateMode::Crf, 18.0));
for bad in [-1.0, 51.5, f32::NAN, f32::INFINITY] {
assert!(ExportSettings { bitrate_mode: BitrateMode::Crf, crf: bad, ..Default::default() }.validate().is_err(), "{bad}");
}
// the value only matters in CRF mode
assert!(ExportSettings { crf: f32::NAN, ..Default::default() }.validate().is_ok());
let summary = c.summary(1920, 1080, FrameRate::FPS_25, 48_000, Tick(10 * TICKS_PER_SECOND));
assert!(summary.video.contains("CRF (constant quality) 18"), "{}", summary.video);
// one pass at constant quality: a lower factor spends more bits, and both decode to the picture
let (p, seq, m) = matte([0.2, 0.5, 0.8, 1.0], 320, 180, None);
let dir = Scratch::new("crf");
let mut sizes = Vec::new();
for crf in [10.0, 40.0] {
let path = dir.path(&format!("crf{crf}.mp4"));
let s = ExportSettings { format: Format::H264, path: path.clone(), bitrate_mode: BitrateMode::Crf, crf, include_audio: false, ..Default::default() };
let prog = Progress::default();
let r = export(&p, seq, &s, &m, &prog).unwrap();
assert_eq!((r.frames, prog.total.load(Ordering::Relaxed)), (24, 24));
let bytes: Arc<[u8]> = std::fs::read(&path).unwrap().into();
sizes.push(bytes.len());
let src = filmcraft_codecs::open_bytes("x.mp4", bytes).unwrap();
let f = src.video_frame(FrameRequest::full(Tick(TICKS_PER_SECOND / 2))).unwrap().to_rgba8();
assert!((f[2] as i32 - 204).abs() < 12 && (f[1] as i32 - 127).abs() < 12, "CRF {crf}: {:?}", &f[..4]);
}
assert!(sizes[0] > sizes[1], "CRF 10 {} bytes, CRF 40 {} bytes", sizes[0], sizes[1]);
}
#[test]
fn h264_profile_level_and_keyframes() {
let (p, seq, m) = matte([0.5, 0.5, 0.5, 1.0], 320, 180, None);
@ -516,6 +550,8 @@ fn h265_is_a_format_that_needs_a_registered_encoder() {
let e = ExportSettings { bitrate_mode: BitrateMode::Vbr2Pass, ..h.clone() }.validate().unwrap_err();
assert!(e.to_string().contains("two-pass"), "{e}");
assert!(ExportSettings { bitrate_mode: BitrateMode::Cbr, ..h.clone() }.validate().is_ok());
let e = ExportSettings { bitrate_mode: BitrateMode::Crf, ..h.clone() }.validate().unwrap_err();
assert!(e.to_string().contains("CRF"), "{e}");
// formats with a built-in encoder are always available; this one only when a registered probe says so
assert!(Format::ALL.iter().filter(|f| f.has_builtin_encoder()).all(|f| available(*f)));

View File

@ -297,7 +297,60 @@ pub struct Encoder {
const LOG2_MAX_FRAME_NUM: u32 = 8;
const LOG2_MAX_POC_LSB: u32 = 8;
/// The bitrate a level must allow for constant quality `crf`, which has no bitrate cap. It
/// assumes the worst footage: about 3 bits per pixel at CRF 0 (FilmCraft's CRF 0 exports of real
/// camera footage measured 1.7 on average and 2.4 in their busiest second), halving every 6 steps
/// (one doubling of the quantiser step). The default CRF 23 keeps 1080p30 at level 4.
fn crf_level_kbps(crf: f32, mbw: usize, mbh: usize, fps: f64) -> u32 {
let crf = if crf.is_finite() { f64::from(crf.clamp(0.0, 51.0)) } else { 23.0 };
let bpp = 3.0 * (-crf / 6.0).exp2();
let pixels = (mbw as f64) * (mbh as f64) * 256.0;
// a float-to-int `as` saturates, and NaN (a hostile fps) becomes 0
(pixels * fps * bpp / 1000.0).ceil() as u32
}
impl Encoder {
/// The settings that shaped the stream, x264-style (`key=value` separated by spaces), for the
/// user_data_unregistered SEI: a file says how it was made (`strings`, `ffprobe -show_data`).
fn options(&self) -> String {
let c = &self.cfg;
let rate = match c.rate {
RateControl::Qp(q) => format!("rc=cqp qp={q}"),
RateControl::Crf(f) => format!("rc=crf crf={:.1}", f.clamp(0.0, 51.0)),
RateControl::Cbr { kbps } => format!("rc=cbr bitrate={kbps}"),
RateControl::Vbr { target_kbps, max_kbps } => format!("rc=vbr bitrate={target_kbps} vbv_maxrate={max_kbps}"),
};
let pass = match c.pass {
Pass::Single => "",
Pass::First => " pass=1",
Pass::Second(_) => " pass=2",
};
let profile = match c.profile {
Profile::Baseline => "baseline",
Profile::Main => "main",
Profile::High => "high",
};
let preset = match c.preset {
Preset::Speed => "speed",
Preset::Balanced => "balanced",
Preset::Quality => "quality",
};
let level = self.sps.level_idc;
format!(
"{rate}{pass} profile={profile} level={}.{} preset={preset} keyint={} bframes={} refs={} cabac={} 8x8dct={} slices={} aq={:.2} scenecut={}",
level / 10,
level % 10,
c.keyint,
c.bframes,
self.max_refs,
u8::from(c.profile != Profile::Baseline),
u8::from(c.profile == Profile::High),
self.slices,
c.aq_strength,
u8::from(c.scenecut),
)
}
pub fn new(mut cfg: EncoderConfig) -> Result<Self, Error> {
if cfg.width == 0 || cfg.height == 0 || !cfg.width.is_multiple_of(2) || !cfg.height.is_multiple_of(2) || cfg.width > 16384 || cfg.height > 16384 {
return Err(Error::InvalidDimensions(cfg.width, cfg.height));
@ -376,7 +429,7 @@ impl Encoder {
let max_refs = if cfg.bframes > 0 { 2 } else { 1 };
let (kbps_for_level, rc_kind, vbv) = match (&cfg.rate, &cfg.pass) {
(RateControl::Qp(q), _) => (None, RcKind::Qp((*q).min(51)), (None, None)),
(RateControl::Crf(c), _) => (None, RcKind::Crf(c.clamp(0.0, 51.0)), (None, None)),
(RateControl::Crf(c), _) => (Some(crf_level_kbps(*c, mbw, mbh, fps)), RcKind::Crf(c.clamp(0.0, 51.0)), (None, None)),
(RateControl::Cbr { kbps }, Pass::Second(st)) => (Some(*kbps), ratecontrol::RateControl::plan_two_pass(st, *kbps), (Some(*kbps), Some(*kbps))),
(RateControl::Cbr { kbps }, Pass::First) => (Some(*kbps), RcKind::Abr { kbps: *kbps }, (None, None)),
(RateControl::Cbr { kbps }, Pass::Single) => (Some(*kbps), RcKind::Abr { kbps: *kbps }, (Some(*kbps), Some(*kbps))),
@ -832,7 +885,7 @@ impl Encoder {
}
if !self.sei_sent {
self.sei_sent = true;
let text = format!("filmcraft-h264enc {} - clean-room H.264 encoder", env!("CARGO_PKG_VERSION"));
let text = format!("filmcraft-h264enc {} - clean-room H.264 encoder - options: {}", env!("CARGO_PKG_VERSION"), self.options());
nals.push(nal::nal(0, NAL_SEI, &nal::sei_user_data_rbsp(&text)));
if self.cfg.mastering_display.is_some() || self.cfg.content_light.is_some() {
nals.push(nal::nal(0, NAL_SEI, &nal::sei_hdr_rbsp(self.cfg.mastering_display.as_ref(), self.cfg.content_light)));

View File

@ -0,0 +1,45 @@
//! Constant quality has no bitrate cap, so the level must allow what low factors spend; and every
//! stream says which settings made it.
mod common;
use common::synth;
use filmcraft_h264enc::*;
fn crf(w: u32, h: u32, fps: u32, f: f32) -> Encoder {
let mut c = EncoderConfig::new(w, h, fps, 1);
c.rate = RateControl::Crf(f);
c.format = PacketFormat::LengthPrefixed;
Encoder::new(c).unwrap()
}
#[test]
fn crf_level_allows_the_bitrate_of_the_factor() {
let level = |w, h, fps, f| crf(w, h, fps, f).avcc()[3];
// 1080p30: the default and visually lossless factors stay at 4; CRF 0 (measured at 105 Mb/s
// average, 147 Mb/s peak on camera footage) needs more than 4.2's 62.5 Mb/s
assert_eq!(level(1920, 1080, 30, 23.0), 40);
assert_eq!(level(1920, 1080, 30, 18.0), 40);
assert_eq!(level(1920, 1080, 30, 0.0), 51);
// never below what the picture size and rate need
assert_eq!(level(3840, 2160, 30, 51.0), 51);
// a hostile factor is treated as in range
assert_eq!(level(1920, 1080, 30, f32::NAN), 40);
assert_eq!(level(1920, 1080, 30, -100.0), 51);
}
#[test]
fn first_access_unit_carries_the_settings() {
let (w, h) = (64, 48);
let mut enc = crf(w as u32, h as u32, 30, 18.0);
let f = synth(w, h, 0);
let mut packets = enc.encode(&f.frame(), 0).unwrap();
packets.extend(enc.flush());
let text = |p: &Packet| String::from_utf8_lossy(&p.data).into_owned();
let first = text(&packets[0]);
for want in ["filmcraft-h264enc", "rc=crf crf=18.0", "profile=high", "level=", "bframes=2", "cabac=1"] {
assert!(first.contains(want), "{want} missing from {first:?}");
}
// once per stream
assert!(packets.iter().skip(1).all(|p| !text(p).contains("filmcraft-h264enc")));
}

View File

@ -0,0 +1,69 @@
//! Hostile object trees: a crafted file must give an error, never a crash, a hang or an
//! allocation sized by a count in the file (AGENTS.md §0).
use filmcraft_cfb::{Version, Writer};
use super::store::{self, SF_WEAK_SET, SF_WEAK_VECTOR, Value, utf16z};
const PID: u16 = 0x0007;
/// A root object whose one property `PID` is a weak reference vector or set (stored form `sf`)
/// named `Keys`, with `index` as its `Keys index` stream.
fn weak_collection_file(sf: u16, index: Vec<u8>) -> Vec<u8> {
let name = utf16z("Keys");
// byte order 'L', format version, 1 entry { pid, stored form, value length }, the value
let mut props = vec![0x4C, 0x20];
for x in [1, PID, sf, name.len() as u16] {
props.extend_from_slice(&x.to_le_bytes());
}
props.extend_from_slice(&name);
let mut w = Writer::new(Version::V3);
w.stream(Writer::ROOT, "properties", props).unwrap();
w.stream(Writer::ROOT, "Keys index", index).unwrap();
w.finish()
}
/// A weak collection index: u32 count, u16 tag, u16 key pid, u8 key size, then the keys.
fn index(count: u32, size: u8, keys: &[u8]) -> Vec<u8> {
let mut v = count.to_le_bytes().to_vec();
v.extend_from_slice(&0u16.to_le_bytes());
v.extend_from_slice(&0x1B01u16.to_le_bytes());
v.push(size);
v.extend_from_slice(keys);
v
}
fn read_keys(sf: u16, index: Vec<u8>) -> Result<Vec<Vec<u8>>, String> {
match store::read(&weak_collection_file(sf, index))?.get(PID) {
Some(Value::WeakVec(_, _, keys)) => Ok(keys.clone()),
other => panic!("not a weak collection: {other:?}"),
}
}
/// With a key size of 0 every key fits, so the count alone drove the loop: up to 4.29 billion
/// empty keys (about 100 GB) from a 9-byte index. A million is enough to show it, cheaply.
#[test]
fn zero_size_weak_collection_keys_are_an_error() {
for sf in [SF_WEAK_VECTOR, SF_WEAK_SET] {
match read_keys(sf, index(1_000_000, 0, &[])) {
Err(e) => assert!(e.contains("zero-size"), "{e}"),
Ok(keys) => panic!("read {} empty keys instead of an error", keys.len()),
}
}
}
#[test]
fn weak_collection_count_beyond_the_index_is_an_error() {
for sf in [SF_WEAK_VECTOR, SF_WEAK_SET] {
let e = read_keys(sf, index(u32::MAX, 4, &[1, 2, 3, 4, 5, 6, 7, 8])).unwrap_err();
assert!(e.contains("short weak collection index"), "{e}");
}
}
#[test]
fn weak_collections_within_the_index_are_read() {
for sf in [SF_WEAK_VECTOR, SF_WEAK_SET] {
assert_eq!(read_keys(sf, index(0, 0, &[])).unwrap(), Vec::<Vec<u8>>::new());
assert_eq!(read_keys(sf, index(2, 4, &[1, 2, 3, 4, 5, 6, 7, 8])).unwrap(), [vec![1, 2, 3, 4], vec![5, 6, 7, 8]]);
}
}

View File

@ -86,5 +86,7 @@ pub fn sniff(bytes: &[u8]) -> bool {
}
}
#[cfg(test)]
mod hostile_tests;
#[cfg(test)]
mod tests;

View File

@ -480,6 +480,13 @@ impl R<'_> {
return Err(bad("short weak collection index"));
};
let size = size as usize;
if count > 0 && size == 0 {
return Err(bad("zero-size weak collection key"));
}
// every key must lie in the index, so its length (not the count) bounds the loop
if (count as usize).checked_mul(size).and_then(|n| n.checked_add(9)).is_none_or(|end| end > idx.len()) {
return Err(bad("short weak collection index"));
}
let mut keys = Vec::new();
for k in 0..count as usize {
keys.push(idx.get(9 + k * size..9 + (k + 1) * size).ok_or_else(|| bad("short weak collection index"))?.to_vec());

View File

@ -301,6 +301,9 @@ struct BlockRef {
discardable: bool,
invisible: bool,
discard_padding: Option<i64>,
/// (offset, size) of the `BlockAdditional` with `BlockAddID` 1 (WebM: the VP8/VP9 alpha
/// layer), kept for unlaced blocks only.
addition: Option<(u64, u32)>,
}
#[derive(Clone, Copy, Debug)]
@ -318,6 +321,7 @@ struct FrameRef {
discard_padding: Option<i64>,
block_offset: u64,
lace: u16,
addition: Option<(u64, u32)>,
}
fn track_lookup(tracks: &[Track]) -> impl Fn(u64) -> Option<usize> + '_ {
@ -354,6 +358,36 @@ fn parse_block<S: ByteSource + ?Sized>(
Ok(Some((track, rel, flags, frames)))
}
/// The `BlockAdditional` with `BlockAddID` 1 (the default ID) in a `BlockAdditions` element:
/// (offset, size) of its data. Other IDs, empty additions and damaged children are skipped.
fn block_addition<S: ByteSource + ?Sized>(io: &mut Io<'_, S>, start: u64, end: u64) -> Result<Option<(u64, u32)>> {
let mut pos = start;
while pos < end {
let Some(more) = io.header(pos, end)? else { break };
let me = more.end(end);
if more.id == BLOCK_MORE {
let (mut id, mut data) = (1u64, None);
let mut p = more.data_start();
while p < me {
let Some(c) = io.header(p, me)? else { break };
let ce = c.end(me);
let len = ce.saturating_sub(c.data_start());
match c.id {
BLOCK_ADD_ID => id = ebml::uint(io.peek(c.data_start(), len.min(8) as usize)?),
BLOCK_ADDITIONAL => data = u32::try_from(len).ok().filter(|&n| n > 0).map(|n| (c.data_start(), n)),
_ => {}
}
p = ce;
}
if id == 1 && data.is_some() {
return Ok(data);
}
}
pos = me;
}
Ok(None)
}
/// Parse one SimpleBlock or BlockGroup element.
fn parse_block_elem<S: ByteSource + ?Sized>(
io: &mut Io<'_, S>,
@ -376,17 +410,19 @@ fn parse_block_elem<S: ByteSource + ?Sized>(
discardable: flags & 0x01 != 0,
invisible: flags & 0x08 != 0,
discard_padding: None,
addition: None,
}))
}
BLOCK_GROUP => {
let mut pos = ds;
let mut block = None;
let (mut dur, mut has_ref, mut pad) = (None, false, None);
let (mut dur, mut has_ref, mut pad, mut addition) = (None, false, None, None);
while pos < end {
let Some(c) = io.header(pos, end)? else { break };
let ce = c.end(end);
match c.id {
BLOCK => block = parse_block(io, c.data_start(), ce - c.data_start(), lookup)?,
BLOCK_ADDITIONS => addition = block_addition(io, c.data_start(), ce)?,
BLOCK_DURATION => dur = Some(ebml::uint(io.peek(c.data_start(), (ce - c.data_start()) as usize)?)),
REFERENCE_BLOCK => has_ref = true,
DISCARD_PADDING => pad = Some(ebml::int(io.peek(c.data_start(), (ce - c.data_start()) as usize)?)),
@ -395,6 +431,8 @@ fn parse_block_elem<S: ByteSource + ?Sized>(
pos = ce;
}
let Some((track, rel, flags, frames)) = block else { return Ok(None) };
// with lacing, an addition belongs to the block, not to one of its frames
let addition = addition.filter(|_| frames.len() == 1);
Ok(Some(BlockRef {
track,
block_offset: h.pos,
@ -405,6 +443,7 @@ fn parse_block_elem<S: ByteSource + ?Sized>(
discardable: false,
invisible: flags & 0x08 != 0,
discard_padding: pad,
addition,
}))
}
_ => Ok(None),
@ -450,6 +489,7 @@ fn expand(b: &BlockRef, t: &Track, scale: u64, skip: u16, out: &mut impl Extend<
discard_padding: b.discard_padding,
block_offset: b.block_offset,
lace: i as u16,
addition: b.addition,
}
}));
}
@ -780,6 +820,7 @@ fn add_blocks(file: &mut MkvFile, cluster: u32, blocks: &[BlockRef]) {
cluster,
block_offset: f.block_offset,
lace: f.lace,
addition: f.addition,
});
}
}
@ -838,6 +879,20 @@ impl MkvFile {
Ok(data)
}
/// The `BlockAdditional` (ID 1) of sample `index` of `track`, `None` when the block has none
/// (see [`Sample::addition`]). Stored as is: header stripping applies to frames only.
pub fn read_sample_addition<S: ByteSource + ?Sized>(&self, src: &S, track: usize, index: usize) -> Result<Option<Vec<u8>>> {
let t = self.tracks.get(track).ok_or(Error::NoSuchTrack(track))?;
let s = t.samples.get(index).ok_or(Error::NoSuchSample(index))?;
let Some((offset, size)) = s.addition else { return Ok(None) };
if offset.checked_add(size as u64).is_none_or(|end| end > src.len()) {
return Err(Error::Invalid("block addition past end of file".into()));
}
let mut data = vec![0; size as usize];
src.read_at(offset, &mut data)?;
Ok(Some(data))
}
/// Keyframe sample of `track` with the greatest pts ≤ `time_ns` (indexed files only).
pub fn keyframe_before(&self, track: usize, time_ns: i64) -> Option<usize> {
let t = self.tracks.get(track)?;

View File

@ -55,6 +55,9 @@ pub const ENCRYPTED_BLOCK: u32 = 0xAF;
pub const SILENT_TRACKS: u32 = 0x5854;
pub const BLOCK: u32 = 0xA1;
pub const BLOCK_ADDITIONS: u32 = 0x75A1;
pub const BLOCK_MORE: u32 = 0xA6;
pub const BLOCK_ADD_ID: u32 = 0xEE;
pub const BLOCK_ADDITIONAL: u32 = 0xA5;
pub const BLOCK_DURATION: u32 = 0x9B;
pub const REFERENCE_PRIORITY: u32 = 0xFA;
pub const REFERENCE_BLOCK: u32 = 0xFB;

View File

@ -191,6 +191,10 @@ pub struct Sample {
pub block_offset: u64,
/// Index of this frame within a laced block.
pub lace: u16,
/// (offset, size) of the block's `BlockAdditional` with `BlockAddID` 1, when it has one and
/// is not laced. In WebM this is the alpha layer of a VP8/VP9 track with `AlphaMode` 1: a
/// second, independently coded stream whose luma plane is the frame's alpha.
pub addition: Option<(u64, u32)>,
}
/// A Matroska track (`TrackEntry`) plus, once indexed, its sample table.

View File

@ -96,10 +96,64 @@ 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));

View File

@ -2,8 +2,9 @@
//!
//! A [`ByteReader`] serves reads at any offset (a native file, a web `Blob` read in chunks, an
//! in-memory buffer). Container openers that only need the index and then single samples (MP4/MOV,
//! Matroska) open from a reader ([`ReaderOpener`]); formats decoded in one go (stills, WAV,
//! standalone compressed audio) fall back to reading the whole file through it.
//! Matroska) open from a reader ([`ReaderOpener`]), as does WAV (header now, sample ranges on
//! demand); formats decoded in one go (stills, standalone compressed audio) fall back to reading
//! the whole file through it.
use std::io;
use std::sync::Arc;
@ -43,24 +44,131 @@ impl ByteReader for MemReader {
}
}
/// A file on disk, read in place: the file stays open and only the requested ranges are read, so
/// opening a clip costs its index rather than its size.
/// Files [`FileReader`]s keep open at most, together (#67). Each open media source used to hold
/// its own descriptor, so a project with hundreds of sources hit the soft descriptor limit (256 on
/// macOS, 1024 on Linux) and the rest showed as failed or offline. Past this many, the least
/// recently read file is closed and its reader reopens it on demand.
#[cfg(any(unix, windows))]
pub const MAX_OPEN_FILES: usize = 128;
/// The open files of a set of [`FileReader`]s, least recently used first.
#[cfg(any(unix, windows))]
#[derive(Debug)]
pub(crate) struct FilePool {
cap: usize,
state: std::sync::Mutex<PoolState>,
}
#[cfg(any(unix, windows))]
#[derive(Debug, Default)]
struct PoolState {
next_id: u64,
open: std::collections::VecDeque<(u64, Arc<std::fs::File>)>,
}
#[cfg(any(unix, windows))]
impl FilePool {
pub(crate) fn new(cap: usize) -> Self {
Self { cap: cap.max(1), state: Default::default() }
}
fn state(&self) -> std::sync::MutexGuard<'_, PoolState> {
self.state.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
}
fn next_id(&self) -> u64 {
let mut s = self.state();
s.next_id = s.next_id.wrapping_add(1);
s.next_id
}
/// The open file of reader `id`, now the most recently used.
fn get(&self, id: u64) -> Option<Arc<std::fs::File>> {
let mut s = self.state();
let at = s.open.iter().position(|(i, _)| *i == id)?;
let entry = s.open.remove(at)?;
let file = entry.1.clone();
s.open.push_back(entry);
Some(file)
}
/// Keep `file` open for reader `id`, closing the least recently used files past the cap. A
/// file another thread is reading from stays open until that read ends.
fn insert(&self, id: u64, file: std::fs::File) -> Arc<std::fs::File> {
let mut s = self.state();
if let Some((_, open)) = s.open.iter().find(|(i, _)| *i == id) {
return open.clone(); // another thread reopened it first
}
let file = Arc::new(file);
s.open.push_back((id, file.clone()));
while s.open.len() > self.cap {
s.open.pop_front();
}
file
}
fn remove(&self, id: u64) {
self.state().open.retain(|(i, _)| *i != id);
}
#[cfg(test)]
fn open_count(&self) -> usize {
self.state().open.len()
}
}
#[cfg(any(unix, windows))]
static FILE_POOL: std::sync::LazyLock<Arc<FilePool>> = std::sync::LazyLock::new(|| Arc::new(FilePool::new(MAX_OPEN_FILES)));
/// A file on disk, read in place: only the requested ranges are read, so opening a clip costs its
/// index rather than its size. The file is kept open in a shared pool of at most
/// [`MAX_OPEN_FILES`] and reopened by path when the pool closed it.
#[cfg(any(unix, windows))]
#[derive(Debug)]
pub struct FileReader {
file: std::fs::File,
path: std::path::PathBuf,
len: u64,
id: u64,
pool: Arc<FilePool>,
}
#[cfg(any(unix, windows))]
impl FileReader {
pub fn open(path: &std::path::Path) -> io::Result<Self> {
Self::open_in(path, &FILE_POOL)
}
pub(crate) fn open_in(path: &std::path::Path, pool: &Arc<FilePool>) -> io::Result<Self> {
let file = std::fs::File::open(path)?;
let m = file.metadata()?;
if !m.is_file() {
return Err(io::Error::new(io::ErrorKind::NotFound, format!("{} is not a file", path.display())));
}
Ok(Self { file, len: m.len() })
let id = pool.next_id();
pool.insert(id, file);
Ok(Self { path: path.to_path_buf(), len: m.len(), id, pool: pool.clone() })
}
/// The open file, reopened when the pool closed it. A file now shorter than when it was opened
/// (replaced or truncated) is refused rather than read as the old one; a longer one (still
/// being written) is read up to the opened length, as before.
fn file(&self) -> io::Result<Arc<std::fs::File>> {
if let Some(file) = self.pool.get(self.id) {
return Ok(file);
}
let file = std::fs::File::open(&self.path)?;
let m = file.metadata()?;
if !m.is_file() || m.len() < self.len {
return Err(io::Error::other(format!("{} changed since it was opened", self.path.display())));
}
Ok(self.pool.insert(self.id, file))
}
}
#[cfg(any(unix, windows))]
impl Drop for FileReader {
fn drop(&mut self) {
self.pool.remove(self.id);
}
}
@ -71,15 +179,16 @@ impl ByteReader for FileReader {
}
/// Positional reads: threads decoding different parts of the file do not share a cursor.
fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> {
let file = self.file()?;
#[cfg(unix)]
{
std::os::unix::fs::FileExt::read_exact_at(&self.file, buf, offset)
std::os::unix::fs::FileExt::read_exact_at(&*file, buf, offset)
}
#[cfg(windows)]
{
let (mut done, mut at) = (0usize, offset);
while let Some(rest) = buf.get_mut(done..).filter(|r| !r.is_empty()) {
match std::os::windows::fs::FileExt::seek_read(&self.file, rest, at) {
match std::os::windows::fs::FileExt::seek_read(&*file, rest, at) {
Ok(0) => return Err(eof()),
Ok(n) => {
done = done.saturating_add(n);
@ -109,15 +218,15 @@ pub type ReaderOpener = fn(name: &str, head: &[u8], reader: &SharedReader) -> Op
/// Bytes sniffed by [`open_reader`].
pub const HEAD_LEN: usize = 64 * 1024;
/// Open media from a reader: reader openers first; otherwise read the whole file and use the
/// byte openers (`extra`, then stills / WAV).
/// Open media from a reader: reader openers first, then WAV (read in place); otherwise read the
/// whole file and use the byte openers (`extra`, then stills).
pub fn open_reader(name: &str, reader: SharedReader, reader_openers: &[ReaderOpener], extra: &[Opener]) -> Result<SharedSource> {
open_reader_within(name, reader, reader_openers, extra, u64::MAX)
}
/// Like [`open_reader`], but a file no reader opener takes is read whole only up to `max_whole`
/// bytes; a larger one is refused instead. For looking at files rather than importing them (the
/// Media Browser's properties and thumbnails, #157): reading a multi-gigabyte AVI or WAV into
/// Media Browser's properties and thumbnails, #157): reading a multi-gigabyte AVI into
/// memory to show its duration, or to find out it isn't supported, thrashes the disk.
pub fn open_reader_within(name: &str, reader: SharedReader, reader_openers: &[ReaderOpener], extra: &[Opener], max_whole: u64) -> Result<SharedSource> {
let head = read_range(&*reader, 0, HEAD_LEN).map_err(|e| MediaError::Io(format!("{name}: {e}")))?;
@ -126,6 +235,9 @@ pub fn open_reader_within(name: &str, reader: SharedReader, reader_openers: &[Re
return r;
}
}
if crate::wav::sniff(&head) {
return Ok(Arc::new(crate::wav::WavSource::open(name, reader)?));
}
if reader.len() > max_whole {
return Err(MediaError::Unsupported(format!("{name}: no streaming reader for this format, and it is too large to read whole here")));
}
@ -169,6 +281,37 @@ mod tests {
std::fs::remove_dir_all(&dir).unwrap();
}
/// #67: more readers than the pool keeps open stay readable; past the cap the least recently
/// read file is closed and reopened on demand, and a file truncated meanwhile is refused.
#[cfg(any(unix, windows))]
#[test]
fn more_file_readers_than_the_pool_holds_stay_readable() {
let dir = std::env::temp_dir().join(format!("filmcraft-file-pool-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let pool = Arc::new(FilePool::new(2));
let paths: Vec<_> = (0..5).map(|i| dir.join(format!("{i}.bin"))).collect();
for (i, p) in paths.iter().enumerate() {
std::fs::write(p, format!("file{i}-0123456789")).unwrap();
}
let readers: Vec<_> = paths.iter().map(|p| FileReader::open_in(p, &pool).unwrap()).collect();
assert_eq!(pool.open_count(), 2);
for _ in 0..2 {
for (i, r) in readers.iter().enumerate() {
let mut b = [0u8; 5];
r.read_at(0, &mut b).unwrap();
assert_eq!(b, *format!("file{i}").as_bytes());
assert!(pool.open_count() <= 2);
}
}
// reader 0 was closed by the later reads; its file is now shorter than when opened
std::fs::write(&paths[0], b"short").unwrap();
let mut b = [0u8; 5];
assert!(readers[0].read_at(0, &mut b).is_err());
drop(readers);
assert_eq!(pool.open_count(), 0, "dropped readers close their files");
std::fs::remove_dir_all(&dir).unwrap();
}
/// A reader that counts the bytes read from it.
struct Counting(MemReader, std::sync::atomic::AtomicU64);
impl ByteReader for Counting {
@ -181,35 +324,58 @@ mod tests {
}
}
/// #157: looking at a file no streaming reader takes (an AVI, a large WAV) read all of it.
/// Within a limit it reads the head only and refuses; a small file still opens.
/// #157: looking at a file no streaming reader takes (an AVI) read all of it. Within a limit
/// it reads the head only and refuses.
#[test]
fn open_within_reads_only_the_head_of_large_unstreamable_files() {
let mut wav = crate::wav::write_wav16(&vec![0.25; 48_000 * 2], 2, 48_000);
let small = wav.len() as u64;
let r = Arc::new(Counting(MemReader(Arc::from(wav.clone())), Default::default()));
let src = open_reader_within("a.wav", r.clone(), &[], &[], small).unwrap();
assert_eq!(src.info().audio().unwrap().channels, 2);
// the same file over the limit: refused after reading the sniffing head only
let r = Arc::new(Counting(MemReader(Arc::from(wav.clone())), Default::default()));
let e = open_reader_within("a.wav", r.clone(), &[], &[], small - 1).err().unwrap();
let mut avi = vec![0u8; 4 * HEAD_LEN];
avi[..4].copy_from_slice(b"RIFF");
avi[8..12].copy_from_slice(b"AVI ");
let r = Arc::new(Counting(MemReader(Arc::from(avi)), Default::default()));
let e = open_reader_within("a.avi", r.clone(), &[], &[], HEAD_LEN as u64).err().unwrap();
assert!(matches!(e, MediaError::Unsupported(_)), "{e:?}");
assert!(r.1.load(std::sync::atomic::Ordering::SeqCst) <= HEAD_LEN as u64);
// an unsupported format is refused without being read whole either
wav.resize(4 * HEAD_LEN, 0);
wav[..4].copy_from_slice(b"RIFF");
wav[8..12].copy_from_slice(b"AVI ");
}
/// #279: a WAV is read in place, never whole: opening it reads the sniffing head and its chunk
/// headers, and audio reads only the requested samples, however large the file (and over the
/// Media Browser's whole-file limit too).
#[test]
fn wav_opens_in_place_and_reads_only_requested_samples() {
let rate = 48_000usize;
let samples: Vec<f32> = (0..rate * 2 * 20).map(|i| ((i / 2) % 100) as f32 / 200.0).collect();
let wav = crate::wav::write_wav16(&samples, 2, rate as u32);
let size = wav.len() as u64;
assert!(size > 3 * HEAD_LEN as u64);
let r = Arc::new(Counting(MemReader(Arc::from(wav)), Default::default()));
assert!(open_reader_within("a.avi", r.clone(), &[], &[], HEAD_LEN as u64).is_err());
assert!(r.1.load(std::sync::atomic::Ordering::SeqCst) <= HEAD_LEN as u64);
let read = || r.1.load(std::sync::atomic::Ordering::SeqCst);
let src = open_reader_within("a.wav", r.clone(), &[], &[], HEAD_LEN as u64).unwrap();
assert_eq!(src.info().audio().unwrap().channels, 2);
assert_eq!(src.info().duration, filmcraft_time::Tick::from_units(20, 1));
assert_eq!(src.info().file_size, Some(size));
let after_open = read();
assert!(after_open <= HEAD_LEN as u64 + 64, "{after_open}");
// 10 ms from the middle: about that much is read, and the samples are right
let a = src.audio(rate as i64 * 10 + 7, 480, rate as u32).unwrap();
assert!(read() - after_open <= 481 * 4, "{}", read() - after_open);
let frame = rate * 10 + 7 + 3;
assert!((a.channels[0][3] - samples[frame * 2]).abs() < 1e-4);
assert!((a.channels[1][3] - samples[frame * 2 + 1]).abs() < 1e-4);
// past the end: silence, nothing read
let before = read();
let a = src.audio(rate as i64 * 30, 16, rate as u32).unwrap();
assert!(a.channels[0].iter().all(|&s| s == 0.0));
assert_eq!(read(), before);
}
#[test]
fn falls_back_to_byte_openers() {
// a WAV is opened through the whole-file path
let wav = crate::wav::write_wav16(&[0.0, 0.5, -0.5, 0.25], 2, 48_000);
let r: SharedReader = Arc::new(MemReader(wav.into()));
let s = open_reader("a.wav", r, &[], &[]).unwrap();
assert!(s.info().has_audio());
// a format without a reader opener is read whole and handed to the byte openers
fn whole(_: &str, bytes: Arc<[u8]>) -> Option<Result<SharedSource>> {
Some(Err(MediaError::Unsupported(format!("got {} bytes", bytes.len()))))
}
let r: SharedReader = Arc::new(MemReader(Arc::from(&b"bytes"[..])));
let e = open_reader("a.xyz", r, &[], &[whole]).err().unwrap();
assert!(matches!(&e, MediaError::Unsupported(m) if m == "got 5 bytes"), "{e:?}");
}
}

View File

@ -7,17 +7,32 @@ use std::sync::Arc;
use filmcraft_frame::{AudioBuffer, VideoFrame};
use filmcraft_time::Tick;
use crate::reader::{MemReader, SharedReader, read_range};
use crate::{AudioStreamInfo, FrameRequest, MediaError, MediaInfo, MediaKind, MediaSource, Result};
pub fn sniff(b: &[u8]) -> bool {
b.len() >= 12 && (&b[0..4] == b"RIFF" || &b[0..4] == b"RF64") && &b[8..12] == b"WAVE"
}
/// Chunks walked looking for `fmt `/`data`/`bext`/`iXML` (real files have a handful).
const MAX_CHUNKS: usize = 1 << 16;
/// Bytes of an iXML chunk searched for the time reference.
const IXML_MAX: u64 = 1 << 20;
/// Output frames converted per read of the data chunk.
const BLOCK_FRAMES: usize = 4096;
/// Largest single read of the data chunk; a wider span (an extreme resampling ratio) reads the
/// two frames each output frame needs instead.
const MAX_SPAN_BYTES: u64 = 4 << 20;
/// A WAV file read in place: opening it reads its chunk headers (not its samples), and
/// [`MediaSource::audio`] reads only the sample range it is asked for, so a 90-minute recording
/// costs its header rather than its size (#279).
pub struct WavSource {
info: MediaInfo,
bytes: Arc<[u8]>,
data_off: usize,
data_len: usize,
reader: SharedReader,
data_off: u64,
/// Whole frames only.
frames: u64,
channels: usize,
bits: u16,
float: bool,
@ -26,63 +41,80 @@ pub struct WavSource {
}
fn le16(b: &[u8], o: usize) -> u16 {
u16::from_le_bytes([b[o], b[o + 1]])
b.get(o..o.saturating_add(2)).and_then(|s| s.try_into().ok()).map_or(0, u16::from_le_bytes)
}
fn le32(b: &[u8], o: usize) -> u32 {
u32::from_le_bytes([b[o], b[o + 1], b[o + 2], b[o + 3]])
b.get(o..o.saturating_add(4)).and_then(|s| s.try_into().ok()).map_or(0, u32::from_le_bytes)
}
impl WavSource {
/// Parse a WAV file held in memory.
pub fn parse(name: &str, bytes: Arc<[u8]>) -> Result<Self> {
let b = &bytes[..];
let mut pos = 12;
Self::open(name, Arc::new(MemReader(bytes)))
}
/// Open a WAV file through a random-access reader.
pub fn open(name: &str, reader: SharedReader) -> Result<Self> {
let io = |e: std::io::Error| MediaError::Io(format!("{name}: {e}"));
let len = reader.len();
let mut pos: u64 = 12;
let mut fmt = None;
let mut data = None;
let mut bext_tr = None;
let mut ixml_tr = None;
while pos + 8 <= b.len() {
let id = &b[pos..pos + 4];
let mut len = le32(b, pos + 4) as usize;
let body = pos + 8;
if id == b"data" && (len == 0xFFFF_FFFF || body + len > b.len()) {
len = b.len() - body;
for _ in 0..MAX_CHUNKS {
let Some(body) = pos.checked_add(8).filter(|&b| b <= len) else { break };
let mut hdr = [0u8; 8];
reader.read_at(pos, &mut hdr).map_err(io)?;
let id = &hdr[0..4];
let mut clen = u64::from(le32(&hdr, 4));
let rest = len - body;
if id == b"data" && (clen == 0xFFFF_FFFF || clen > rest) {
clen = rest;
}
if body + len > b.len() {
if clen > rest {
break;
}
match id {
b"fmt " if len >= 16 => {
let mut tag = le16(b, body);
let ch = le16(b, body + 2);
let rate = le32(b, body + 4);
let bits = le16(b, body + 14);
if tag == 0xFFFE && len >= 40 {
tag = le16(b, body + 24);
b"fmt " if clen >= 16 => {
let b = read_range(&*reader, body, clen.min(40) as usize).map_err(io)?;
let mut tag = le16(&b, 0);
let ch = le16(&b, 2);
let rate = le32(&b, 4);
let bits = le16(&b, 14);
if tag == 0xFFFE && clen >= 40 {
tag = le16(&b, 24);
}
fmt = Some((tag, ch, rate, bits));
}
b"data" => data = Some((body, len)),
b"data" => data = Some((body, clen)),
// bext: Description 256, Originator 32, OriginatorReference 32, OriginationDate 10,
// OriginationTime 8, then TimeReference (low u32, high u32)
b"bext" if len >= 346 => bext_tr = Some(le32(b, body + 338) as u64 | (le32(b, body + 342) as u64) << 32),
b"iXML" => ixml_tr = ixml_time_reference(&b[body..body + len]),
b"bext" if clen >= 346 => {
let b = read_range(&*reader, body + 338, 8).map_err(io)?;
bext_tr = Some(u64::from(le32(&b, 0)) | u64::from(le32(&b, 4)) << 32);
}
b"iXML" => {
let b = read_range(&*reader, body, clen.min(IXML_MAX) as usize).map_err(io)?;
ixml_tr = ixml_time_reference(&b);
}
_ => {}
}
pos = body + len + (len & 1);
pos = body + clen + (clen & 1);
}
let (tag, ch, rate, bits) = fmt.ok_or_else(|| MediaError::Decode(format!("{name}: missing fmt chunk")))?;
let (off, len) = data.ok_or_else(|| MediaError::Decode(format!("{name}: missing data chunk")))?;
let (off, dlen) = data.ok_or_else(|| MediaError::Decode(format!("{name}: missing data chunk")))?;
let float = tag == 3;
if !(tag == 1 || float) || ch == 0 || !matches!(bits, 8 | 16 | 24 | 32 | 64) {
return Err(MediaError::Unsupported(format!("{name}: WAV format tag {tag}, {bits} bits")));
}
let frame_bytes = ch as usize * bits as usize / 8;
let frames = len / frame_bytes;
let frame_bytes = u64::from(ch) * u64::from(bits / 8);
let frames = dlen / frame_bytes;
let time_reference = bext_tr.or(ixml_tr);
let mut info = MediaInfo {
name: name.into(),
kind: MediaKind::AudioOnly,
duration: Tick::from_units(frames as i64, rate as i64),
duration: Tick::from_units(i64::try_from(frames).unwrap_or(i64::MAX), rate as i64),
video: None,
audio_streams: vec![AudioStreamInfo {
sample_rate: rate,
@ -92,16 +124,16 @@ impl WavSource {
}],
container: if time_reference.is_some() { "Broadcast WAV".into() } else { "WAV".into() },
start_timecode: None,
file_size: Some(b.len() as u64),
file_size: Some(len),
};
// start timecode: frames at the media's frame rate (the default rate for audio-only media)
if let Some(tr) = time_reference.filter(|_| rate > 0) {
let r = info.frame_rate();
let n = tr as i128 * r.num as i128;
let d = rate as i128 * r.den as i128;
info.start_timecode = Some(((n + d / 2) / d) as i64);
info.start_timecode = (n + d / 2).checked_div(d).and_then(|t| i64::try_from(t).ok());
}
Ok(Self { info, data_off: off, data_len: frames * frame_bytes, channels: ch as usize, bits, float, rate, bytes, time_reference })
Ok(Self { info, reader, data_off: off, frames, channels: ch as usize, bits, float, rate, time_reference })
}
/// Broadcast Wave start time: samples since midnight.
@ -109,17 +141,32 @@ impl WavSource {
self.time_reference
}
fn sample(&self, frame: usize, ch: usize) -> f32 {
fn frame_bytes(&self) -> usize {
self.channels * (self.bits as usize / 8)
}
/// Read whole frames `first..=last` of the data chunk.
fn read_frames(&self, first: u64, last: u64) -> Result<Vec<u8>> {
let fb = self.frame_bytes() as u64;
let n = last.saturating_sub(first).saturating_add(1).saturating_mul(fb);
let off = self.data_off.saturating_add(first.saturating_mul(fb));
let mut buf = vec![0u8; usize::try_from(n).map_err(|_| MediaError::Decode(format!("{}: audio read too large", self.info.name)))?];
self.reader.read_at(off, &mut buf).map_err(|e| MediaError::Io(format!("{}: {e}", self.info.name)))?;
Ok(buf)
}
/// Sample `ch` of frame `frame` of `block`, a run of whole frames.
fn sample(&self, block: &[u8], frame: usize, ch: usize) -> f32 {
let bps = self.bits as usize / 8;
let o = self.data_off + (frame * self.channels + ch) * bps;
let b = &self.bytes;
match (self.bits, self.float) {
(8, _) => (b[o] as f32 - 128.0) / 128.0,
(16, _) => i16::from_le_bytes([b[o], b[o + 1]]) as f32 / 32768.0,
(24, _) => ((i32::from_le_bytes([0, b[o], b[o + 1], b[o + 2]]) >> 8) as f32) / 8_388_608.0,
(32, false) => i32::from_le_bytes([b[o], b[o + 1], b[o + 2], b[o + 3]]) as f32 / 2_147_483_648.0,
(32, true) => f32::from_le_bytes([b[o], b[o + 1], b[o + 2], b[o + 3]]),
(64, true) => f64::from_le_bytes(b[o..o + 8].try_into().unwrap_or([0; 8])) as f32,
let o = (frame * self.channels + ch) * bps;
let Some(b) = block.get(o..o.saturating_add(bps)) else { return 0.0 };
match (self.bits, self.float, b) {
(8, _, &[a]) => (a as f32 - 128.0) / 128.0,
(16, _, &[a, b]) => i16::from_le_bytes([a, b]) as f32 / 32768.0,
(24, _, &[a, b, c]) => ((i32::from_le_bytes([0, a, b, c]) >> 8) as f32) / 8_388_608.0,
(32, false, &[a, b, c, d]) => i32::from_le_bytes([a, b, c, d]) as f32 / 2_147_483_648.0,
(32, true, &[a, b, c, d]) => f32::from_le_bytes([a, b, c, d]),
(64, true, b) => f64::from_le_bytes(b.try_into().unwrap_or([0; 8])) as f32,
_ => 0.0,
}
}
@ -133,25 +180,54 @@ impl MediaSource for WavSource {
Err(MediaError::NoStream("video"))
}
fn audio(&self, start: i64, frames: usize, sample_rate: u32) -> Result<AudioBuffer> {
let total = self.data_len / (self.channels * self.bits as usize / 8);
let total = self.frames;
let mut out = AudioBuffer::silence(sample_rate, self.channels, frames);
if total == 0 || sample_rate == 0 {
return Ok(out);
}
// Linear-interpolating resample when rates differ (the audio crate provides sinc resampling).
let ratio = self.rate as f64 / sample_rate as f64;
for i in 0..frames {
let src = (start + i as i64) as f64 * ratio;
if src < 0.0 {
let src_of = |i: usize| start.saturating_add(i as i64) as f64 * ratio;
let mut i = 0;
while i < frames {
let end = frames.min(i + BLOCK_FRAMES);
let (lo, hi) = (src_of(i), src_of(end - 1));
if hi < 0.0 {
i = end;
continue;
}
let i0 = src.floor() as usize;
if i0 >= total {
let first = lo.max(0.0).floor() as u64;
if first >= total {
break;
}
let fr = (src - i0 as f64) as f32;
for c in 0..self.channels {
let a = self.sample(i0, c);
let b = if i0 + 1 < total { self.sample(i0 + 1, c) } else { a };
out.channels[c][i] = a + (b - a) * fr;
let last = (hi.floor() as u64).saturating_add(1).min(total - 1);
let span = last.saturating_sub(first).saturating_add(1).saturating_mul(self.frame_bytes() as u64);
let block = if span <= MAX_SPAN_BYTES { Some(self.read_frames(first, last)?) } else { None };
for slot in i..end {
let src = src_of(slot);
if src < 0.0 {
continue;
}
let i0 = src.floor() as u64;
if i0 >= total {
break;
}
let i1 = (i0 + 1).min(total - 1);
let fr = (src - i0 as f64) as f32;
let (buf, base) = match &block {
Some(b) => (std::borrow::Cow::Borrowed(b.as_slice()), first),
None => (std::borrow::Cow::Owned(self.read_frames(i0, i1)?), i0),
};
let (k0, k1) = ((i0 - base) as usize, (i1 - base) as usize);
for c in 0..self.channels {
let a = self.sample(&buf, k0, c);
let b = self.sample(&buf, k1, c);
if let Some(s) = out.channels.get_mut(c).and_then(|ch| ch.get_mut(slot)) {
*s = a + (b - a) * fr;
}
}
}
i = end;
}
Ok(out)
}

View File

@ -33,6 +33,9 @@ pub fn config_for(w: u32, h: u32, rate: FrameRate, s: &ExportSettings) -> Result
if s.bitrate_mode == BitrateMode::Vbr2Pass || !matches!(s.h264_pass, H264Pass::Single) {
return Err("two-pass encoding".into());
}
if s.bitrate_mode == BitrateMode::Crf {
return Err("CRF (constant quality)".into());
}
if s.signal.is_hdr() {
return Err("HDR".into());
}

View File

@ -162,6 +162,9 @@ fn config(format: Format, w: u32, h: u32, rate: FrameRate, s: &ExportSettings) -
if !matches!(s.h264_pass, H264Pass::Single) || (hevc && s.bitrate_mode == BitrateMode::Vbr2Pass) {
return Err("two-pass VBR".into());
}
if s.bitrate_mode == BitrateMode::Crf {
return Err("CRF (constant quality)".into());
}
// the hardware path writes the aspect ratio of H.264 only
if hevc && s.pixel_aspect.is_some_and(|(n, d)| n != d) {
return Err("non-square pixels".into());
@ -357,6 +360,7 @@ mod tests {
("an unwritable colour signal", ExportSettings { signal: ColorSignal { primaries: 9, transfer: 1, matrix: 1 }, ..ok.clone() }),
("HDR with BT.709 primaries", ExportSettings { signal: ColorSignal { primaries: 1, transfer: 16, matrix: 9 }, ..ok.clone() }),
("two-pass", ExportSettings { bitrate_mode: BitrateMode::Vbr2Pass, ..ok.clone() }),
("CRF", ExportSettings { bitrate_mode: BitrateMode::Crf, ..ok.clone() }),
("analysis pass", ExportSettings { h264_pass: H264Pass::First, ..ok.clone() }),
("interlaced", ExportSettings { field_order: FieldOrder::UpperFirst, ..ok.clone() }),
("non-square pixels", ExportSettings { pixel_aspect: Some((4, 3)), ..ok.clone() }),

View File

@ -19,7 +19,7 @@
//! for H.264, VPS / SPS / PPS and VideoToolbox's own `hvcC` record for HEVC) come with the first
//! compressed frame, and the muxer asks for them after the first group of frames.
use std::ffi::{c_int, c_void};
use std::ffi::{CStr, c_char, c_int, c_void};
use std::panic::AssertUnwindSafe;
use std::ptr::NonNull;
use std::sync::{Mutex, PoisonError};
@ -39,10 +39,10 @@ use objc2_core_video::{
use objc2_video_toolbox::{
VTCompressionSession, VTEncodeInfoFlags, VTSessionCopyProperty, VTSessionSetProperty, kVTCompressionPropertyKey_AllowFrameReordering,
kVTCompressionPropertyKey_AllowOpenGOP, kVTCompressionPropertyKey_AverageBitRate, kVTCompressionPropertyKey_ColorPrimaries,
kVTCompressionPropertyKey_ConstantBitRate, kVTCompressionPropertyKey_DataRateLimits, kVTCompressionPropertyKey_ExpectedFrameRate,
kVTCompressionPropertyKey_MaxKeyFrameInterval, kVTCompressionPropertyKey_ProfileLevel, kVTCompressionPropertyKey_RealTime,
kVTCompressionPropertyKey_TransferFunction, kVTCompressionPropertyKey_UsingHardwareAcceleratedVideoEncoder, kVTCompressionPropertyKey_YCbCrMatrix,
kVTProfileLevel_H264_Baseline_AutoLevel, kVTProfileLevel_H264_High_AutoLevel, kVTProfileLevel_H264_Main_AutoLevel, kVTProfileLevel_HEVC_Main_AutoLevel,
kVTCompressionPropertyKey_DataRateLimits, kVTCompressionPropertyKey_ExpectedFrameRate, kVTCompressionPropertyKey_MaxKeyFrameInterval,
kVTCompressionPropertyKey_ProfileLevel, kVTCompressionPropertyKey_RealTime, kVTCompressionPropertyKey_TransferFunction,
kVTCompressionPropertyKey_UsingHardwareAcceleratedVideoEncoder, kVTCompressionPropertyKey_YCbCrMatrix, kVTProfileLevel_H264_Baseline_AutoLevel,
kVTProfileLevel_H264_High_AutoLevel, kVTProfileLevel_H264_Main_AutoLevel, kVTProfileLevel_HEVC_Main_AutoLevel,
kVTVideoEncoderSpecification_RequireHardwareAcceleratedVideoEncoder,
};
@ -421,6 +421,28 @@ fn set_bool(session: &VTCompressionSession, key: &CFString, on: bool) -> i32 {
set_value(session, key, CFBoolean::new(on).as_ref())
}
unsafe extern "C" {
fn dlsym(handle: *mut c_void, symbol: *const c_char) -> *mut c_void;
}
/// `kVTCompressionPropertyKey_ConstantBitRate`, or `None` on a macOS that does not have it.
///
/// The key exists from macOS 13. Naming it as a linked symbol makes dyld refuse to start the whole
/// app on macOS 11 and 12 ("Symbol not found"), although the encoder already falls back to an
/// average bitrate when the key is unavailable. So it is looked up at run time instead.
fn constant_bitrate_key() -> Option<&'static CFString> {
// `RTLD_DEFAULT` on macOS: search every image loaded into the process, VideoToolbox included.
const RTLD_DEFAULT: *mut c_void = -2isize as *mut c_void;
const NAME: &CStr = c"kVTCompressionPropertyKey_ConstantBitRate";
// SAFETY: `NAME` is a valid NUL-terminated string; `dlsym` only reads it.
let symbol = unsafe { dlsym(RTLD_DEFAULT, NAME.as_ptr()) };
let slot = NonNull::new(symbol)?.cast::<*const CFString>();
// SAFETY: the symbol is a `CFStringRef` constant exported by VideoToolbox, so `slot` points at a
// pointer to an immutable CFString that lives as long as the framework stays loaded (the whole
// process); it is read once and never written.
unsafe { slot.read().as_ref() }
}
fn set_value(session: &VTCompressionSession, key: &CFString, value: &CFType) -> i32 {
// SAFETY: the session is valid, `key` is a framework constant and `value` a live CF object.
unsafe { VTSessionSetProperty(session.as_ref(), key, Some(value)) }
@ -527,14 +549,14 @@ impl VtEncoder {
let _ = set_value(session, fps_key, CFNumber::new_f64(fps).as_ref());
// SAFETY: reading immutable framework constants.
let (avg_key, limits_key, cbr_key) =
unsafe { (kVTCompressionPropertyKey_AverageBitRate, kVTCompressionPropertyKey_DataRateLimits, kVTCompressionPropertyKey_ConstantBitRate) };
let (avg_key, limits_key) = unsafe { (kVTCompressionPropertyKey_AverageBitRate, kVTCompressionPropertyKey_DataRateLimits) };
let cbr_key = constant_bitrate_key();
let bps = |kbps: u32| i32::try_from(u64::from(kbps).saturating_mul(1000)).unwrap_or(i32::MAX);
match c.rate {
VtRate::Cbr { kbps } => {
// true constant bitrate where the OS offers it (macOS 13+); otherwise an average
// with a one-second ceiling at the same rate
if set_value(session, cbr_key, CFNumber::new_i32(bps(kbps)).as_ref()) != 0 {
if !cbr_key.is_some_and(|key| set_value(session, key, CFNumber::new_i32(bps(kbps)).as_ref()) == 0) {
require(set_value(session, avg_key, CFNumber::new_i32(bps(kbps)).as_ref()), "the bitrate")?;
let limits = CFArray::<CFType>::from_objects(&[CFNumber::new_i32(bps(kbps) / 8).as_ref(), CFNumber::new_f64(1.0).as_ref()]);
let _ = set_value(session, limits_key, limits.as_ref());

View File

@ -106,6 +106,7 @@ fn declines_what_the_hardware_path_does_not_take() {
let declined = |s: &ExportSettings, what: &str| assert!(config_for(W, H, FPS, s).is_err(), "{what} must not use the hardware encoder");
declined(&ExportSettings { hardware_encoding: HardwareEncoding::Off, ..ok.clone() }, "hardware encoding off");
declined(&ExportSettings { bitrate_mode: BitrateMode::Vbr2Pass, ..ok.clone() }, "two-pass VBR");
declined(&ExportSettings { bitrate_mode: BitrateMode::Crf, ..ok.clone() }, "CRF");
declined(&ExportSettings { h264_pass: H264Pass::First, ..ok.clone() }, "an analysis pass");
declined(&ExportSettings { signal: filmcraft_export::ColorSignal::PQ, ..ok.clone() }, "HDR");
declined(&ExportSettings { pixel_aspect: Some((4, 3)), ..ok.clone() }, "non-square pixels");

View File

@ -17,7 +17,7 @@ use crate::mask::Mask;
pub use audio::{GEQ10_LABELS, GEQ20_LABELS, GEQ30_LABELS, PREMIERE_AUDIO_EFFECTS};
mod vfx;
pub use vfx::{EASINGS, ECHO_OPERATORS, FRAME_LAYOUTS, LIGHT_IDS, SIMPLE_BLEND, TRACK_CHOICES, auto_point};
pub use vfx::{EASINGS, ECHO_OPERATORS, FRAME_LAYOUTS, LIGHT_IDS, SIMPLE_BLEND, TRACK_CHOICES, auto_point, ultra_key_setting};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum EffectKind {
@ -619,7 +619,8 @@ fn build_effects() -> Vec<EffectDef> {
ang("skew_axis", "Skew Axis", 0.0),
ang("rotation", "Rotation", 0.0),
f("opacity", "Opacity", 100.0, 0.0, 100.0, ""),
f("shutter_angle", "Shutter Angle", 0.0, 0.0, 360.0, ""),
b("shutter_override", "Use Effect Shutter Angle", false),
f("shutter_angle", "Shutter Angle", 180.0, 0.0, 360.0, ""),
],
),
video("mirror", "Mirror", DISTORT, vec![pt("center", "Reflection Center", f64::NAN, f64::NAN), ang("angle", "Reflection Angle", 0.0)]),

View File

@ -1014,6 +1014,60 @@ pub(super) fn defs() -> Vec<EffectDef> {
v
}
/// Parameter values written when Ultra Key's Setting is Default, Relaxed or Aggressive.
/// Custom (index 3) leaves the current values. Aggressive matches the matte that cleans
/// light and dark clothing on a typical green screen.
pub fn ultra_key_setting(setting: u32) -> Option<&'static [(&'static str, f64)]> {
Some(match setting {
0 => &[
("transparency", 45.0),
("highlight", 10.0),
("shadow", 50.0),
("tolerance", 50.0),
("pedestal", 10.0),
("choke", 0.0),
("soften", 0.0),
("contrast", 0.0),
("mid_point", 50.0),
("desaturate", 25.0),
("range", 50.0),
("spill", 50.0),
("spill_luma", 50.0),
],
1 => &[
("transparency", 30.0),
("highlight", 15.0),
("shadow", 35.0),
("tolerance", 70.0),
("pedestal", 5.0),
("choke", 0.0),
("soften", 8.0),
("contrast", 0.0),
("mid_point", 50.0),
("desaturate", 15.0),
("range", 50.0),
("spill", 40.0),
("spill_luma", 50.0),
],
2 => &[
("transparency", 40.0),
("highlight", 10.0),
("shadow", 55.0),
("tolerance", 90.0),
("pedestal", 50.0),
("choke", 10.0),
("soften", 10.0),
("contrast", 10.0),
("mid_point", 50.0),
("desaturate", 50.0),
("range", 50.0),
("spill", 50.0),
("spill_luma", 50.0),
],
_ => return None,
})
}
/// Extra parameters added to core effects by the 26.x rebuilds (old instances fall back to the
/// defaults, so projects round-trip unchanged).
pub(super) fn extend_core(v: &mut [EffectDef]) {

View File

@ -44,11 +44,12 @@ pub(crate) fn mapping(id: &str) -> Option<Mapping> {
},
"hard_limiter" => Mapping {
dsp: "limiter",
preroll: |e, t| (f(e, "release", t) as f64 / 1000.0 * 5.0).clamp(0.05, 2.0),
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),
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 {

View File

@ -301,6 +301,16 @@ 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)])]);

View File

@ -161,17 +161,16 @@ pub fn ultra_key(img: &mut Image, e: &EffectInstance, cx: &FxCtx) {
let k = rgb_to_ycbcr(kc[0], kc[1], kc[2], Matrix::Bt709);
let kmag2 = (k[1] * k[1] + k[2] * k[2]).max(1e-6);
let setting = chv(e, "setting");
let (g_mul, ped_mul) = match setting {
1 => (0.85, 0.7),
2 => (1.2, 1.5),
_ => (1.0, 1.0),
let preset = filmcraft_project::effect::ultra_key_setting(setting);
let pf = |id: &str, fallback: f32| {
preset.and_then(|rows| rows.iter().find(|(k, _)| *k == id).map(|(_, v)| *v as f32)).unwrap_or(fallback)
};
let transparency = fv(e, "transparency", cx) / 100.0;
let highlight = fv(e, "highlight", cx) / 100.0;
let shadow = fv(e, "shadow", cx) / 100.0;
let tol = 0.08 + fv(e, "tolerance", cx) / 100.0 * 0.5;
let pedestal = (fv(e, "pedestal", cx) / 100.0 * 0.3 * ped_mul).min(0.9);
let gain = (1.0 + 2.0 * transparency) * g_mul;
let transparency = pf("transparency", fv(e, "transparency", cx)) / 100.0;
let highlight = pf("highlight", fv(e, "highlight", cx)) / 100.0;
let shadow = pf("shadow", fv(e, "shadow", cx)) / 100.0;
let tol = 0.08 + pf("tolerance", fv(e, "tolerance", cx)) / 100.0 * 0.5;
let pedestal = (pf("pedestal", fv(e, "pedestal", cx)) / 100.0 * 0.3).min(0.9);
let gain = 1.0 + 2.0 * transparency;
let output = chv(e, "output");
let dom = if kc[1] >= kc[0] && kc[1] >= kc[2] {
1
@ -199,10 +198,10 @@ pub fn ultra_key(img: &mut Image, e: &EffectInstance, cx: &FxCtx) {
*a = ((m.clamp(0.0, 1.0) - pedestal) / (1.0 - pedestal)).clamp(0.0, 1.0);
});
// cleanup
let choke = fv(e, "choke", cx) / 100.0;
let soften = fv(e, "soften", cx) / 100.0;
let contrast = fv(e, "contrast", cx) / 100.0;
let mid = fv(e, "mid_point", cx) / 100.0;
let choke = pf("choke", fv(e, "choke", cx)) / 100.0;
let soften = pf("soften", fv(e, "soften", cx)) / 100.0;
let contrast = pf("contrast", fv(e, "contrast", cx)) / 100.0;
let mid = pf("mid_point", fv(e, "mid_point", cx)) / 100.0;
if choke > 0.0 {
let r = (choke * 4.0 * cx.px_scale).max(0.5);
let b = blur_plane(&alpha, img.w, img.h, r);
@ -217,10 +216,10 @@ pub fn ultra_key(img: &mut Image, e: &EffectInstance, cx: &FxCtx) {
alpha.par_iter_mut().for_each(|a| *a = ((*a - mid) * g + mid).clamp(0.0, 1.0));
}
// spill suppression + colour correction on the foreground
let spill = fv(e, "spill", cx) / 100.0;
let desat = fv(e, "desaturate", cx) / 100.0;
let range = fv(e, "range", cx) / 100.0;
let sluma = fv(e, "spill_luma", cx) / 100.0;
let spill = pf("spill", fv(e, "spill", cx)) / 100.0;
let desat = pf("desaturate", fv(e, "desaturate", cx)) / 100.0;
let range = pf("range", fv(e, "range", cx)) / 100.0;
let sluma = pf("spill_luma", fv(e, "spill_luma", cx)) / 100.0;
let (cc_s, cc_h, cc_l) = (fv(e, "cc_saturation", cx) / 100.0, fv(e, "cc_hue", cx) / 360.0, fv(e, "cc_luminance", cx) / 100.0);
let cc = (cc_s - 1.0).abs() > 1e-4 || cc_h.abs() > 1e-6 || (cc_l - 1.0).abs() > 1e-4;
img.px.par_chunks_mut(4).zip(alpha.par_iter()).for_each(|(p, &a)| {

View File

@ -231,6 +231,51 @@ pub fn rotate_3d(img: &mut Image, e: &EffectInstance, cx: &FxCtx) {
});
}
/// Transform: affine place, with optional shutter-angle motion blur when the override is on.
pub fn transform_fx(img: &mut Image, e: &EffectInstance, cx: &FxCtx) {
let fps = cx.env.map_or(24.0, |env| env.frame_rate()).max(1.0);
let angle = fv(e, "shutter_angle", cx);
let n = if bv(e, "shutter_override") && angle > 0.5 { 7 } else { 1 };
let span = (angle as f64 / 360.0) / fps;
motion_blurred(img, n, |im, k| {
let t = cx.t + Tick::from_seconds_f64(k * span);
let cx2 = FxCtx {
t,
px_scale: cx.px_scale,
seconds: cx.seconds + k * span,
timecode: cx.timecode,
clip_name: cx.clip_name,
project: cx.project,
env: cx.env,
working: cx.working,
};
let anchor = crate::effects::point(e, "anchor", &cx2, im);
let pos = crate::effects::point(e, "position", &cx2, im);
let sh = crate::effects::f(e, "scale_height", &cx2) as f64 / 100.0;
let sw = if crate::effects::b(e, "uniform_scale") { sh } else { crate::effects::f(e, "scale_width", &cx2) as f64 / 100.0 };
let rot = crate::effects::f(e, "rotation", &cx2) as f64;
let skew = (crate::effects::f(e, "skew", &cx2) as f64).to_radians().tan();
let skew_axis = crate::effects::f(e, "skew_axis", &cx2) as f64;
let op = crate::effects::f(e, "opacity", &cx2) / 100.0;
let sk = Affine::rotate_deg(skew_axis)
.then_apply(&Affine { a: 1.0, b: 0.0, c: skew, d: 1.0, e: 0.0, f: 0.0 })
.then_apply(&Affine::rotate_deg(-skew_axis));
let m = Affine::translate(pos.x, pos.y)
.then_apply(&Affine::rotate_deg(rot))
.then_apply(&sk)
.then_apply(&Affine::scale(sw, sh))
.then_apply(&Affine::translate(-anchor.x, -anchor.y));
affine_warp(im, &m);
if op < 1.0 - 1e-5 {
im.px.par_chunks_mut(4).for_each(|p| {
for v in p.iter_mut() {
*v *= op;
}
});
}
});
}
/// Grow / Shrink: scale animated across the clip from Scale From to Scale To.
pub fn grow(img: &mut Image, e: &EffectInstance, cx: &FxCtx) {
let p = ease(chv(e, "easing"), clip_progress(cx));

View File

@ -484,6 +484,7 @@ pub fn apply(img: &mut Image, e: &EffectInstance, cx: &FxCtx) -> bool {
"spin" => distort::spin(img, e, cx),
"wiggle" => distort::wiggle(img, e, cx),
"camera_shake" => distort::camera_shake(img, e, cx),
"transform" if bv(e, "shutter_override") && fv(e, "shutter_angle", cx) > 0.5 => distort::transform_fx(img, e, cx),
"spacer" => distort::spacer(img, e, cx),
"clone" => distort::clone_fx(img, e, cx),
"auto_align" => distort::auto_align(img, e, cx),

View File

@ -4,7 +4,7 @@
use std::sync::Arc;
use filmcraft_project::{EffectKind, ParamKind, find_effect};
use filmcraft_project::{EffectKind, Interpolation, Keyframe, Param, ParamKind, find_effect};
use super::*;
@ -504,6 +504,12 @@ fn keyers_known_values() {
set(&mut e, "output", ParamValue::Choice(1));
let m = run("ultra_key", &e, &img, &cx());
assert!(m.get(1, 0)[0] < 0.05 && m.get(6, 0)[0] > 0.9 && m.get(1, 0)[3] == 1.0);
let mut aggressive = inst("ultra_key");
set(&mut aggressive, "setting", ParamValue::Choice(2));
let ag = run("ultra_key", &aggressive, &img, &cx());
let def = run("ultra_key", &inst("ultra_key"), &img, &cx());
let delta = (0..img.w * img.h).map(|i| (ag.px[i * 4 + 3] - def.px[i * 4 + 3]).abs()).fold(0.0f32, f32::max);
assert!(delta > 1e-4, "Aggressive Setting left the Default matte unchanged");
// Alpha Adjust: invert and opacity
let mut a = Image::filled(2, 2, [0.1, 0.1, 0.1, 0.25]);
let mut e = inst("alpha_adjust");
@ -759,6 +765,27 @@ fn transform_presets_animate_over_the_clip() {
assert_eq!(text::burnin_text(&e2, &mid), "FILE 24");
}
#[test]
fn transform_shutter_override_blurs_moving_position() {
let mut e = inst("transform");
set(&mut e, "shutter_override", ParamValue::Bool(true));
setf(&mut e, "shutter_angle", 180.0);
let a = Keyframe::new(Tick::ZERO, ParamValue::Vec2(filmcraft_geom::Vec2::new(8.0, 16.0)));
let mut b = Keyframe::new(Tick::from_seconds_f64(1.0), ParamValue::Vec2(filmcraft_geom::Vec2::new(40.0, 16.0)));
b.interp = Interpolation::Linear;
e.params.insert("position".into(), Param { value: ParamValue::Vec2(filmcraft_geom::Vec2::new(8.0, 16.0)), keyframes: vec![a, b] });
let img = picture(32, 32);
let cx = FxCtx { t: Tick::from_seconds_f64(0.5), seconds: 0.5, ..cx() };
let sharp = {
let mut off = e.clone();
set(&mut off, "shutter_override", ParamValue::Bool(false));
run("transform", &off, &img, &cx)
};
let blurred = run("transform", &e, &img, &cx);
let delta = max_diff(&sharp, &blurred);
assert!(delta > 1e-3, "shutter override left the Transform picture unchanged ({delta})");
}
#[test]
fn clone_auto_align_and_rounded_crop() {
let img = picture(40, 20);

View File

@ -27,7 +27,7 @@ use serde_json::{Value, json};
use crate::FilmcraftApp;
use crate::dock::PanelKind;
use crate::state::{Mode, PlaybackRes, Tool};
use crate::state::{Mode, PlaybackRes, ThumbnailMode, Tool};
/// Prefix marking errors that may resolve after another frame.
pub const RETRY: &str = "\u{1}";
@ -189,10 +189,30 @@ pub fn handle(app: &mut FilmcraftApp, ctx: &egui::Context, req: &ControlRequest)
if let Some(v) = tl.get("audioTrackHeight").and_then(Value::as_f64) {
app.ui.timeline.audio_track_h = v as f32;
}
// Show Video Thumbnails: "off" or a mode ("head", "headAndTail", "continuous")
if let Some(m) = tl.get("thumbnails").and_then(Value::as_str) {
if m.eq_ignore_ascii_case("off") {
app.ui.timeline.show_thumbnails = false;
} else {
let Some(mode) = ThumbnailMode::from_name(m) else {
return err(format!("unknown thumbnail mode `{m}` (off, head, headAndTail, continuous)"));
};
app.ui.timeline.show_thumbnails = true;
app.ui.timeline.thumbnail_mode = mode;
}
}
}
if let Some(q) = s("effectsSearch") {
app.ui.effects_search = q.to_string();
}
// Effect Controls keyframe selection (`[{clip, effect, param, mask?, time}]`), replaced;
// references to missing keyframes are dropped by the panel
if let Some(v) = p.get("keyframeSelection") {
match serde_json::from_value(v.clone()) {
Ok(sel) => app.ui.keyframe_selection = sel,
Err(e) => return err(format!("`keyframeSelection`: {e}")),
}
}
if let Some(v) = p.get("safeMargins").and_then(Value::as_bool) {
app.ui.program.safe_margins = v;
}

View File

@ -1216,6 +1216,9 @@
Caption track options Opciones de pista de subtítulos
Timeline Display Settings Ajustes de visualización de la línea de tiempo
Show Video Thumbnails Mostrar miniaturas de video
Head Cabeza
Head and Tail Cabeza y cola
Continuous Continuas
Show Audio Waveform Mostrar forma de onda de audio
Expand All Tracks Expandir todas las pistas
Minimize All Tracks Minimizar todas las pistas
@ -2896,6 +2899,7 @@
Target Bitrate Velocidad de bits objetivo
Adaptive ({bpp} bits per pixel) Adaptable ({bpp} bits por píxel)
Maximum Bitrate Velocidad de bits máxima
Quality (CRF) Calidad (CRF)
Key Frame Distance Distancia entre fotogramas clave
Quality Calidad
Render at Maximum Depth Procesar a la máxima profundidad
@ -2941,6 +2945,7 @@
File Name & Location Nombre y ubicación del archivo
Quick Export: {path} Exportación rápida: {path}
Graphics problem: switched to software compositing Problema de gráficos: se ha cambiado a la composición por software
GPU acceleration is unavailable; preview renders on the CPU La aceleración por GPU no está disponible; la vista previa se renderiza en la CPU
Exporting Exportando
Click to select, or click in empty space and drag to marquee select. Use Shift, Opt, and Cmd for other options. Haz clic para seleccionar, o haz clic en un espacio vacío y arrastra para seleccionar con un marco. Usa Mayús, Opción y Cmd para otras opciones.
Click to select all clips to the right in all tracks. Shift-click for a single track. Haz clic para seleccionar todos los clips a la derecha en todas las pistas. Mayús+clic para una sola pista.
@ -3300,6 +3305,7 @@
Upper First Superior primero
VBR, 1 pass VBR, 1 pasada
VBR, 2 pass VBR, 2 pasadas
CRF (constant quality) CRF (calidad constante)
Vimeo 1080p Full HD Vimeo 1080p Full HD
Vimeo 2160p 4K Ultra HD Vimeo 2160p 4K Ultra HD
Voice-over Record Grabación de voz en off
@ -3502,6 +3508,8 @@
Light Theme Tema claro
Next Appearance Mode Siguiente modo de apariencia
Sync with system Sincronizar con el sistema
UI Scale Escala de la interfaz
Auto (system) Automática (sistema)
In Entrada
Out Salida
Export Markers as CSV… Exportar marcadores como CSV…

Can't render this file because it contains an unexpected character in line 3 and column 95.

View File

@ -1215,6 +1215,9 @@
Caption track options キャプショントラックのオプション
Timeline Display Settings タイムライン表示設定
Show Video Thumbnails ビデオのサムネイルを表示
Head 先頭
Head and Tail 先頭と末尾
Continuous 連続
Show Audio Waveform オーディオ波形を表示
Expand All Tracks すべてのトラックを展開
Minimize All Tracks すべてのトラックを最小化
@ -2890,6 +2893,7 @@
Target Bitrate ターゲットビットレート
Adaptive ({bpp} bits per pixel) アダプティブ (1 ピクセルあたり {bpp} ビット)
Maximum Bitrate 最大ビットレート
Quality (CRF) 品質 (CRF)
Key Frame Distance キーフレーム間隔
Quality 画質
Render at Maximum Depth 最大深度でレンダリングする
@ -3294,6 +3298,7 @@
Upper First 上位フィールド優先
VBR, 1 pass VBR、1 パス
VBR, 2 pass VBR、2 パス
CRF (constant quality) CRF (品質一定)
Vimeo 1080p Full HD Vimeo 1080p Full HD
Vimeo 2160p 4K Ultra HD Vimeo 2160p 4K Ultra HD
Voice-over Record ナレーションを録音
@ -3446,6 +3451,8 @@
Light Theme ライトテーマ
Next Appearance Mode 次の外観モード
Sync with system システムと同期
UI Scale UI のスケール
Auto (system) 自動 (システム)
Go to previous keyframe 前のキーフレームへ移動
Go to next keyframe 次のキーフレームへ移動
Add keyframe キーフレームを追加
@ -3506,3 +3513,4 @@
Clear Transition トランジションを消去
Set Transition Duration トランジションのデュレーションを設定
Refresh Changed Media 変更されたメディアを更新
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.

View File

@ -250,21 +250,34 @@ pub fn craft_japanese_font() -> bool {
.any(|f| f.info.origin == filmcraft_text::fonts::CRAFT_ORIGIN && JAPANESE_SAMPLE.chars().all(|c| f.has_char(c)))
}
/// Put the system Japanese face before the Chinese fallback, but after the app's Latin faces.
/// Installing it repeatedly must not accumulate duplicate entries in any UI font family.
fn prioritize_system_japanese(fonts: &mut egui::FontDefinitions) {
for family in crate::theme::font_families() {
let stack = fonts.families.entry(family).or_default();
stack.retain(|name| name != JAPANESE_FONT);
let before_chinese = stack
.iter()
.position(|name| name == "system-chinese" || name.starts_with("craft:"))
.unwrap_or(stack.len());
stack.insert(before_chinese, JAPANESE_FONT.into());
}
}
/// Japanese for the interface: true when built with the craft-fonts (already installed by
/// `theme::install`). Otherwise add the system's Japanese font as the last fallback of every theme font family, from the next
/// pass on. Returns false (and changes nothing) when no Japanese font is installed. Call it again
/// after `theme::install`, which replaces the font definitions.
/// `theme::install`). Otherwise put the installed system Japanese face before the Chinese
/// fallbacks, preserving Latin fonts and the Chinese fallback for characters it alone covers.
/// Returns false when no Japanese font is installed; reapply after `theme::install` resets fonts.
pub fn install_japanese_font(ctx: &egui::Context) -> bool {
if craft_japanese_font() {
return true;
}
let Some(font) = system_japanese_font() else { return false };
let families = crate::theme::font_families()
.into_iter()
.map(|family| egui::epaint::text::InsertFontFamily { family, priority: egui::epaint::text::FontPriority::Lowest })
.collect();
// queued for the next pass (works before the first frame); a no-op when already installed
ctx.add_font(egui::epaint::text::FontInsert { name: JAPANESE_FONT.into(), data: (*font).clone(), families });
let mut fonts = ctx.fonts(|f| f.definitions().clone());
fonts.font_data.insert(JAPANESE_FONT.into(), font);
prioritize_system_japanese(&mut fonts);
// Apply on the next pass, including when a theme change reinstalled the Latin fonts.
ctx.set_fonts(fonts);
true
}
@ -279,6 +292,29 @@ pub fn chinese_font_available() -> bool {
mod tests {
use super::*;
#[test]
fn system_japanese_fallback_precedes_chinese_in_all_ui_families() {
let mut fonts = egui::FontDefinitions::default();
for family in crate::theme::font_families() {
let stack = fonts.families.entry(family).or_default();
stack.insert(0, "app-latin".into());
stack.push("craft:chinese fallback".into());
stack.push("system-chinese".into());
}
// Theme reinstalls and repeated language selections must not duplicate the font.
prioritize_system_japanese(&mut fonts);
prioritize_system_japanese(&mut fonts);
for family in crate::theme::font_families() {
let stack = &fonts.families[&family];
assert_eq!(stack.first().map(String::as_str), Some("app-latin"), "{family:?}");
let japanese = stack.iter().position(|name| name == JAPANESE_FONT).unwrap();
let craft_chinese = stack.iter().position(|name| name == "craft:chinese fallback").unwrap();
let system_chinese = stack.iter().position(|name| name == "system-chinese").unwrap();
assert!(japanese > 0 && japanese < craft_chinese && japanese < system_chinese, "{family:?}: {stack:?}");
assert_eq!(stack.iter().filter(|name| name.as_str() == JAPANESE_FONT).count(), 1);
}
}
#[test]
fn catalogs_are_well_formed() {
for (code, text) in [
@ -663,7 +699,7 @@ mod tests {
ex::MxfVideoCodec::ALL.iter().for_each(|c| push(&mut out, c.label()));
ex::Placement::ALL.iter().for_each(|p| push(&mut out, p.label()));
[ex::FieldOrder::Progressive, ex::FieldOrder::UpperFirst, ex::FieldOrder::LowerFirst].iter().for_each(|f| push(&mut out, f.label()));
[ex::BitrateMode::Cbr, ex::BitrateMode::Vbr1Pass, ex::BitrateMode::Vbr2Pass].iter().for_each(|m| push(&mut out, m.label()));
[ex::BitrateMode::Cbr, ex::BitrateMode::Vbr1Pass, ex::BitrateMode::Vbr2Pass, ex::BitrateMode::Crf].iter().for_each(|m| push(&mut out, m.label()));
[ex::H264Profile::Baseline, ex::H264Profile::Main, ex::H264Profile::High].iter().for_each(|p| push(&mut out, p.label()));
crate::panels::export_mode::RANGES.iter().for_each(|(_, l)| push(&mut out, l));
crate::panels::export_mode::PARS.iter().for_each(|(l, _)| push(&mut out, l));

View File

@ -1209,6 +1209,9 @@
Caption track options 字幕轨道选项
Timeline Display Settings 时间轴显示设置
Show Video Thumbnails 显示视频缩略图
Head 首帧
Head and Tail 首尾帧
Continuous 连续
Show Audio Waveform 显示音频波形
Expand All Tracks 展开所有轨道
Minimize All Tracks 最小化所有轨道
@ -2884,6 +2887,7 @@
Target Bitrate 目标码率
Adaptive ({bpp} bits per pixel) 自适应(每像素 {bpp} 位)
Maximum Bitrate 最大码率
Quality (CRF) 质量 (CRF)
Key Frame Distance 关键帧间隔
Quality 质量
Render at Maximum Depth 以最大深度渲染
@ -3288,6 +3292,7 @@
Upper First 上方优先
VBR, 1 pass VBR,1 遍
VBR, 2 pass VBR,2 遍
CRF (constant quality) CRF(恒定质量)
Vimeo 1080p Full HD Vimeo 1080p 全高清
Vimeo 2160p 4K Ultra HD Vimeo 2160p 4K 超高清
Voice-over Record 画外音录制
@ -3489,9 +3494,12 @@
Appearance Mode 外观模式
Next Appearance Mode 下一个外观模式
Sync with system 跟随系统
UI Scale 界面缩放
Auto (system) 自动(系统)
GPU Rendering GPU 渲染
Composite on the GPU when available 可用时在 GPU 上合成
Text to Speech 文字转语音
Edit Narration 编辑旁白
Clear Transition 清除过渡
Set Transition Duration 设置过渡持续时间
GPU acceleration is unavailable; preview renders on the CPU GPU 加速不可用;预览在 CPU 上渲染

Can't render this file because it contains an unexpected character in line 3 and column 95.

View File

@ -143,6 +143,17 @@ pub struct HostHooks {
/// whose Wayland compositor sends no theme to winit). Without it, or without an answer, Auto
/// uses `egui::Context::system_theme`.
pub system_theme: Option<SystemThemeFn>,
/// Files or an image on the system clipboard, for Paste in the Timeline and the Project panel
/// (#611). Without it Paste only pastes clips copied in FilmCraft.
pub clipboard_media: Option<Box<dyn FnMut() -> Option<ClipboardMedia>>>,
}
/// What [`HostHooks::clipboard_media`] found on the system clipboard.
pub enum ClipboardMedia {
/// Files copied in the file manager.
Files(Vec<String>),
/// An image (a screenshot, an image copied in a browser): 8-bit RGBA, row by row.
Image { width: usize, height: usize, rgba: Vec<u8> },
}
/// Reads the system appearance (Settings ▸ Appearance ▸ Appearance Mode ▸ Sync with system).
@ -302,12 +313,16 @@ pub struct FilmcraftApp {
pub tl: panels::timeline::TlState,
/// Commands from outside the UI (native menu bar), invoked on the UI thread.
pub command_inbox: Option<Receiver<String>>,
/// A V key press (or a Ctrl+V paste) reached the app and its release hasn't yet.
v_down: bool,
/// GPU compositor (when running on wgpu): device state + compositor + the egui texture it feeds.
pub gpu: Option<GpuState>,
/// Preview render job being watched (job id, where to start playing when it completes).
watched_render: Option<(u64, Tick)>,
/// Settings last applied to the UI (theme, tooltips, frame cache, audio device).
applied_prefs: Option<filmcraft_engine::autosave::Preferences>,
/// Settings ▸ Appearance ▸ UI Scale last applied, with the system scale it was applied for.
applied_ui_scale: Option<(Option<f32>, f32)>,
workspace_restored: bool,
/// Window ▸ Workspaces: saved layouts ([`dock::WORKSPACES_FILE`] in the data directory).
pub workspaces: dock::WorkspacePrefs,
@ -335,12 +350,31 @@ fn plan_side(plan: &filmcraft_render::plan::FramePlan) -> usize {
plan.max_side()
}
/// A software rasterizer (llvmpipe, SwiftShader, softpipe — VMs and containers without GPU device
/// access, missing or broken drivers) runs wgpu entirely on the CPU. Compositing through one is
/// slower than the native CPU compositor and hides the cause: GPU usage stays at 0 % while the
/// CPU saturates. Returns a description when the adapter is one.
pub fn software_rasterizer(info: &eframe::wgpu::AdapterInfo) -> Option<String> {
rasterizer_reason(&info.name, info.device_type)
}
fn rasterizer_reason(name: &str, device_type: eframe::wgpu::DeviceType) -> Option<String> {
let lower = name.to_ascii_lowercase();
if device_type == eframe::wgpu::DeviceType::Cpu || ["llvmpipe", "swiftshader", "softpipe"].iter().any(|s| lower.contains(s)) {
return Some(format!("software rasterizer ({name}, {device_type:?})"));
}
None
}
/// Whether the GPU compositor can run on this adapter: it renders and blends Rgba16Float
/// targets and uploads whole frames as textures. Older or OpenGL-backed GPUs (some Intel Macs,
/// VMs, Linux without Vulkan, WebGL) can't; those use the CPU compositor, which renders the same
/// frames. Returns the reason when unsupported.
pub fn gpu_compositor_unsupported(adapter: &eframe::wgpu::Adapter) -> Option<String> {
use eframe::wgpu::{TextureFormat, TextureFormatFeatureFlags as F, TextureUsages as U};
if let Some(why) = software_rasterizer(&adapter.get_info()) {
return Some(why);
}
let f = adapter.get_texture_format_features(TextureFormat::Rgba16Float);
if !f.allowed_usages.contains(U::RENDER_ATTACHMENT | U::TEXTURE_BINDING) {
return Some("Rgba16Float is not renderable".into());
@ -360,6 +394,9 @@ impl FilmcraftApp {
pub fn set_wgpu(&mut self, rs: eframe::egui_wgpu::RenderState) {
if let Some(why) = gpu_compositor_unsupported(&rs.adapter) {
log::warn!("GPU compositor disabled ({why}); compositing on the CPU");
// Say so instead of letting the preview quietly crawl: "GPU 0 %, CPU 100 %" with no
// explanation read like a renderer bug when the machine simply has no GPU access.
self.ui.status = format!("{} ({why})", tl!("GPU acceleration is unavailable; preview renders on the CPU"));
return;
}
// wgpu's default reaction to a validation error or lost device is to panic, which closed
@ -489,9 +526,11 @@ impl FilmcraftApp {
toast: None,
tl: Default::default(),
command_inbox: None,
v_down: false,
gpu: None,
watched_render: None,
applied_prefs: None,
applied_ui_scale: None,
workspace_restored: false,
workspaces,
menu_workspaces: Default::default(),
@ -551,9 +590,28 @@ impl FilmcraftApp {
ctx.global_style_mut(|s| s.interaction.tooltip_delay = delay);
}
/// Settings ▸ Appearance ▸ UI Scale: a fixed scale makes a point that many physical pixels,
/// whatever scale the system reports. It is applied only when the setting or the system scale
/// changes, so Ctrl+- / Ctrl++ still zoom the interface for the session; going back to Auto
/// restores the system's scale.
fn apply_ui_scale(&mut self, ctx: &egui::Context) {
let want = self.session.prefs.appearance.ui_scale_factor();
let native = ctx.native_pixels_per_point().filter(|v| v.is_finite() && *v > 0.0).unwrap_or(1.0);
if self.applied_ui_scale == Some((want, native)) {
return;
}
let prev = self.applied_ui_scale.replace((want, native));
match want {
Some(scale) => ctx.set_zoom_factor(scale / native),
None if prev.is_some_and(|(p, _)| p.is_some()) => ctx.set_zoom_factor(1.0),
None => {}
}
}
/// Make the UI follow the settings after they change (theme, tooltips, frame cache budget,
/// play after rendering, audio device).
pub fn apply_prefs(&mut self, ctx: &egui::Context) {
self.apply_ui_scale(ctx);
// Appearance Mode ▸ Sync with system: follow the system while running. The host reports
// changes as they happen (no polling) and wakes the UI only when the value changes.
if self.applied_prefs.is_some() {
@ -1285,15 +1343,44 @@ impl FilmcraftApp {
if self.session.trim_play.dynamic.is_some() && ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) {
let _ = self.session.execute("trim.cancelDynamic", json!({}));
}
// Panel shortcuts of the focused panel first: they override application shortcuts.
// Panel shortcuts of the focused panel override application shortcuts on the same chord.
// egui ignores extra Shift and Alt when matching, so the most specific chords go first
// across both scopes (the sort is stable, so the panel still wins a tie): otherwise the
// Timeline's `Delete` (Clear) swallows the app-wide `Shift+Delete` (Ripple Delete) (#680).
let focused = self.ui.focused.title();
let mut fire = Vec::new();
ctx.input_mut(|i| {
let modifiers = i.modifiers;
// With no text on the system clipboard, egui-winit drops the Ctrl+V press but still
// passes its release: put the press back, so Paste can paste files or an image (#611).
let mut swallowed = None;
for e in &i.events {
match e {
egui::Event::Key { key: egui::Key::V, pressed, modifiers: m, .. } => {
if !pressed && !self.v_down {
swallowed = Some(*m);
}
self.v_down = *pressed;
}
egui::Event::Paste(_) if modifiers.command => self.v_down = true,
_ => {}
}
}
if let Some(m) = swallowed {
i.events.push(egui::Event::Key {
key: egui::Key::V,
physical_key: Some(egui::Key::V),
pressed: true,
repeat: false,
modifiers: m | egui::Modifiers::COMMAND,
});
}
clipboard_events_as_keys(&mut i.events, modifiers);
let panel = self.bindings.iter().filter(|b| b.3.as_deref() == Some(focused));
let app_wide = self.bindings.iter().filter(|b| b.3.is_none());
for (m, k, id, _) in panel.chain(app_wide) {
let mut candidates: Vec<&menus::KeyBinding> = panel.chain(app_wide).collect();
candidates.sort_by_key(|b| std::cmp::Reverse(menus::specificity(b.0)));
for (m, k, id, _) in candidates {
if i.consume_key(*m, *k) {
fire.push(id.clone());
}
@ -1301,11 +1388,27 @@ impl FilmcraftApp {
});
for mut id in fire {
// Select All / Deselect All act on the Project panel's items when it has focus (#168).
if self.ui.focused == PanelKind::Project && matches!(id.as_str(), "edit.selectAll" | "edit.deselectAll") {
id = id.replacen("edit.", "project.", 1);
// Select All takes only what the shown bin lists, not the whole project (#456).
if self.ui.focused == PanelKind::Project && id == "edit.selectAll" {
let (cmd, params) = panels::project::select_all_command(self);
if let Err(e) = menus::invoke(self, ctx, &cmd, params) {
self.ui.status = e;
}
continue;
}
if self.ui.focused == PanelKind::Project && id == "edit.deselectAll" {
id = "project.deselectAll".into();
}
// Clear in the Project panel removes the selected bin when no item is selected (#587).
let clear = if self.ui.focused == PanelKind::Project && id == "project.delete" {
crate::panels::project_views::clear_params(&self.session.state.project_selection, self.ui.project_panel.selected_bin)
} else {
None
};
// Mark In/Out in the Source monitor when it has focus.
let params = if self.ui.focused == PanelKind::Source
let params = if let Some(p) = clear {
p
} else if self.ui.focused == PanelKind::Source
&& (matches!(id.as_str(), "markers.markIn" | "markers.markOut" | "markers.clearInOut")
|| id.starts_with("markers.markSplit")
|| id.starts_with("markers.goToSplit"))
@ -1772,7 +1875,21 @@ impl eframe::App for FilmcraftApp {
.position(|e| pointer(e) || matches!(e, egui::Event::Key { pressed: false, .. }))
.map_or(self.synthetic.len(), |i| if pointer(&self.synthetic[i]) { i.max(1) } else { i + 1 })
};
raw_input.events.extend(self.synthetic.drain(..n));
// A press's modifiers go down before it and up after its release, as real keys would,
// so a drag that egui recognises frames after the press still sees Shift / Ctrl.
for e in self.synthetic.drain(..n) {
match e {
egui::Event::PointerButton { pressed: true, modifiers, .. } if !modifiers.is_none() => {
raw_input.events.push(egui::Event::ModifiersChanged(modifiers));
raw_input.events.push(e);
}
egui::Event::PointerButton { pressed: false, modifiers, .. } if !modifiers.is_none() => {
raw_input.events.push(e);
raw_input.events.push(egui::Event::ModifiersChanged(egui::Modifiers::NONE));
}
_ => raw_input.events.push(e),
}
}
}
}
@ -1991,6 +2108,20 @@ mod clipboard_key_tests {
mod gpu_fallback_tests {
use filmcraft_render::plan::{FramePlan, PlanLayer};
/// Software rasterizers must not be treated as a GPU compositor: the preview crawled while
/// "GPU" usage read 0 % because wgpu had silently fallen back to llvmpipe.
#[test]
fn software_rasterizers_are_detected() {
use eframe::wgpu::DeviceType as D;
let r = super::rasterizer_reason;
assert!(r("llvmpipe (LLVM 23.1.1, 256 bits)", D::Cpu).is_some());
assert!(r("NVIDIA GeForce RTX 4080", D::Cpu).is_some());
assert!(r("SwiftShader Device", D::Other).is_some());
assert!(r("softpipe", D::Cpu).is_some());
assert!(r("AMD Radeon RX 5700 XT", D::DiscreteGpu).is_none());
assert!(r("Apple M3 Pro", D::IntegratedGpu).is_none());
}
#[test]
fn plan_side_covers_output_and_every_layer() {
let frame = |w: u32, h: u32| std::sync::Arc::new(filmcraft_frame::VideoFrame::rgba_f32(w, h, vec![0.0; (w * h * 4) as usize]));

View File

@ -287,7 +287,8 @@ pub fn invoke(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, mut params:
}
match id {
"playback.slowForward" | "playback.slowReverse" if targets_source(app, &params) => {
return Err("Source playback currently supports normal forward speed".into());
app.shuttle_source(if id == "playback.slowForward" { 0.25 } else { -0.25 })?;
return Ok(json!({"playing": app.source_playback.clock.playing, "speed": app.source_playback.clock.speed}));
}
"playback.slowForward" | "playback.slowReverse" => {
app.play(if id == "playback.slowForward" { 0.25 } else { -0.25 });
@ -315,12 +316,12 @@ pub fn invoke(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, mut params:
app.toggle_play(1.0);
return Ok(json!({"playing": app.playback.playing}));
}
"playback.forward" if targets_source(app, &params) => {
app.play_source()?;
return Ok(json!({"playing": app.source_playback.clock.playing, "speed": 1.0}));
}
"playback.reverse" if targets_source(app, &params) => {
return Err("Source playback currently supports normal forward speed; use Play/Space or frame stepping".into());
// L / J: pressing again in the same direction doubles the speed, up to 8×
"playback.forward" | "playback.reverse" if targets_source(app, &params) => {
let (clock, dir) = (&app.source_playback.clock, if id == "playback.forward" { 1.0 } else { -1.0 });
let speed = if clock.playing && clock.speed * dir > 0.0 { (clock.speed * 2.0).clamp(-8.0, 8.0) } else { dir };
app.shuttle_source(speed)?;
return Ok(json!({"playing": app.source_playback.clock.playing, "speed": app.source_playback.clock.speed}));
}
"playhead.stepBack" | "playhead.stepForward" | "playhead.stepBack5" | "playhead.stepForward5" if targets_source(app, &params) => {
app.stop_source();
@ -490,6 +491,16 @@ pub fn invoke(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, mut params:
app.dialog = Some(crate::Dialog::RevertConfirm);
return Ok(json!({"dialog": "revert"}));
}
// Files or an image on the system clipboard paste into the Timeline, or import into the
// Project panel (#611). Copying clips puts their names there as text, so media on the
// clipboard was copied after them; without media, Paste pastes the copied clips.
"edit.paste" | "edit.pasteInsert"
if params.as_object().is_none_or(|m| m.is_empty()) && matches!(app.ui.focused, PanelKind::Timeline | PanelKind::Project) =>
{
if let Some(r) = paste_clipboard_media(app, id == "edit.pasteInsert") {
return r;
}
}
"file.recover" if params.as_object().is_none_or(|m| m.is_empty()) => {
if app.session.recovery_candidates().is_empty() {
return Err("there are no unsaved changes to recover".into());
@ -545,6 +556,32 @@ fn put_clip_names_on_system_clipboard(app: &FilmcraftApp, ctx: &egui::Context) {
ctx.copy_text(if text.trim().is_empty() { format!("{} clips", names.len()) } else { text });
}
/// Paste the files or the image on the system clipboard: placed on the Timeline when it has focus,
/// imported into the shown bin in the Project panel. None when the clipboard holds neither.
fn paste_clipboard_media(app: &mut FilmcraftApp, insert: bool) -> Option<Result<Value, String>> {
let media = (app.hooks.clipboard_media.as_mut()?)()?;
let paths = match media {
crate::ClipboardMedia::Files(paths) => paths,
crate::ClipboardMedia::Image { width, height, rgba } => match encode_png(width, height, rgba)
.and_then(|png| filmcraft_engine::paste_media::save_pasted_image(&mut app.session, &png).map_err(|e| e.to_string()))
{
Ok(path) => vec![path],
Err(e) => return Some(Err(e)),
},
};
let params = json!({"paths": paths, "insert": insert, "place": app.ui.focused == PanelKind::Timeline, "bin": app.import_bin().0});
Some(app.session.execute("edit.pasteMedia", params).map_err(|e| e.to_string()))
}
/// A compressed PNG of a clipboard image; an error when its size and pixels disagree.
fn encode_png(width: usize, height: usize, rgba: Vec<u8>) -> Result<Vec<u8>, String> {
let (w, h) = (u32::try_from(width).map_err(|e| e.to_string())?, u32::try_from(height).map_err(|e| e.to_string())?);
let img = image::RgbaImage::from_raw(w, h, rgba).ok_or("the image on the clipboard is malformed")?;
let mut png = Vec::new();
img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).map_err(|e| e.to_string())?;
Ok(png)
}
/// A menu tree entry for display / `ui.menu.list`.
#[derive(Clone, Debug, serde::Serialize)]
pub struct MenuItem {
@ -683,10 +720,16 @@ pub fn bindings(app: &FilmcraftApp) -> Vec<KeyBinding> {
let shifted: Vec<KeyBinding> =
v.iter().filter(|b| b.0.shift).filter_map(|(m, k, id, p)| shifted_key(*k).map(|k2| (*m, k2, id.clone(), p.clone()))).collect();
v.extend(shifted);
v.sort_by_key(|(m, ..)| std::cmp::Reverse(m.command as u8 + m.shift as u8 + m.alt as u8 + m.ctrl as u8));
v.sort_by_key(|(m, ..)| std::cmp::Reverse(specificity(*m)));
v
}
/// How many modifiers a chord names: chords naming more are matched first, because egui ignores
/// extra Shift and Alt when matching a key press.
pub fn specificity(m: egui::Modifiers) -> u8 {
m.command as u8 + m.shift as u8 + m.alt as u8 + m.ctrl as u8
}
/// The key a US layout reports for `k` with Shift held, when it differs.
fn shifted_key(k: egui::Key) -> Option<egui::Key> {
use egui::Key;

View File

@ -9,6 +9,7 @@ use serde_json::{Value, json};
use crate::FilmcraftApp;
use crate::icons::{self, Icon};
use crate::state::KeyframeRef;
use crate::theme::Tokens;
const ROW_H: f32 = 22.0;
@ -17,6 +18,8 @@ const RULER_H: f32 = 24.0;
const CLIP_BAR_H: f32 = 18.0;
/// Distance between the keyframe navigator's buttons (◀ ◆ ▶).
const NAV_STEP: f32 = 16.0;
/// The Y curve of a point parameter's value graph (X uses the accent colour).
const Y_CURVE: Color32 = Color32::from_rgb(0x60, 0xc0, 0x80);
/// Properties panel: the keyframe diamonds' column, from a row's right edge (the arrows sit one
/// [`NAV_STEP`] to either side of it, and a section's reset button above it).
const PROPS_NAV_X: f32 = 26.0;
@ -41,6 +44,16 @@ fn graph_drag_offset(ui: &egui::Ui, response: &egui::Response, previous: egui::V
}
}
/// Whether `k` names a keyframe of the clip `it` (its effect, parameter and media time).
fn keyframe_exists(it: &TrackItem, k: &KeyframeRef) -> bool {
let Some(e) = it.effects.get(k.effect) else { return false };
let param = match k.mask {
Some(m) => e.masks.get(m).and_then(|m| m.param(&k.param)),
None => e.params.get(&k.param),
};
param.is_some_and(|p| p.keyframes.iter().any(|kf| kf.time == k.time))
}
fn selected_clip(app: &FilmcraftApp) -> Option<(ClipId, TrackItem, TrackKind)> {
selected_clips(app).into_iter().next()
}
@ -71,6 +84,8 @@ fn selected_clips(app: &FilmcraftApp) -> Vec<(ClipId, TrackItem, TrackKind)> {
pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
let t = app.tokens;
let clips = selected_clips(app);
// keep only selected keyframes that still exist on a clip shown here
app.ui.keyframe_selection.retain(|k| clips.iter().any(|(c, it, _)| c.0 == k.clip && keyframe_exists(it, k)));
let Some((clip, it, _)) = clips.first().cloned() else {
// a transition clicked in the Timeline (#430)
if let Some(id) = crate::panels::transition_controls::selected(app) {
@ -207,7 +222,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
if app.ui.expanded_fx.contains(&graph_key(clip, idx, pd.id))
&& let Some(param) = e.params.get(pd.id)
&& param.is_animated()
&& matches!(param.value, ParamValue::Float(_))
&& graphable(&param.value)
{
graph_rows(app, bui, body, clip, idx, None, pd, param, &lane, it, &mut actions);
}
@ -422,8 +437,8 @@ pub(crate) fn param_row(
};
let (r, _) = ui.allocate_exact_size(vec2(body.width(), ROW_H), Sense::hover());
let mut x = r.min.x + 26.0;
// twirl-down for the value/velocity graphs (animated scalar params)
if param.is_animated() && matches!(param.value, ParamValue::Float(_)) {
// twirl-down for the value/velocity graphs (animated scalar and point params)
if param.is_animated() && graphable(&param.value) {
let key = graph_key(clip, idx, pkey);
let open = app.ui.expanded_fx.contains(&key);
let tw = Rect::from_center_size(pos2(r.min.x + 12.0, r.center().y), vec2(12.0, 12.0));
@ -561,7 +576,13 @@ pub(crate) fn param_row(
let kr = Rect::from_center_size(pos2(kx, y), vec2(11.0, 11.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");
let sel = k.time == mt || resp.dragged();
// highlighted when selected (not merely under the playhead: that lit up every
// parameter's keyframe at the same time, #412)
let is_this = |s: &KeyframeRef| s.clip == clip.0 && s.effect == idx && s.mask == mask && s.param == pd.id && s.time == k.time;
if resp.clicked() || resp.drag_started() {
app.ui.keyframe_selection = vec![KeyframeRef { clip: clip.0, effect: idx, param: pd.id.to_string(), mask, time: k.time }];
}
let sel = resp.dragged() || app.ui.keyframe_selection.iter().any(is_this);
let col = if sel { t.hot_text } else { Color32::from_rgb(0xb0, 0xb0, 0xb0) };
match k.interp {
filmcraft_project::Interpolation::Hold => {
@ -583,6 +604,10 @@ pub(crate) fn param_row(
rate.snap_nearest(it.start + Tick(((f + off / lane.width()) as f64 * dur) as i64)).clamp(it.start, it.end() - rate.frame_duration());
let new_media = it.source_in + Tick(((new_tl - it.start).0 as f64 * it.speed.abs()) as i64);
if new_media != k.time {
// the moved keyframe stays selected
for s in app.ui.keyframe_selection.iter_mut().filter(|s| is_this(s)) {
s.time = new_media;
}
actions.push((
"effects.moveKeyframe".into(),
with_mask(json!({"clip": clip.0, "effect": eff_json, "param": pd.id, "mediaTime": k.time.0, "to": new_media.0})),
@ -887,7 +912,22 @@ pub(crate) fn graph_key(clip: ClipId, idx: usize, pid: &str) -> String {
format!("graph:{}:{}:{}", clip.0, idx, pid)
}
/// Value and velocity graphs of an animated scalar parameter, drawn across the keyframe lane.
/// Parameters with value/velocity graphs: scalars, and points such as Position (#239).
pub(crate) fn graphable(v: &ParamValue) -> bool {
matches!(v, ParamValue::Float(_) | ParamValue::Vec2(_))
}
/// The curves a graphed value draws: a scalar's one, or a point's X and Y.
fn graph_components(v: &ParamValue) -> Vec<f64> {
match v {
ParamValue::Float(x) => vec![*x],
ParamValue::Vec2(p) => vec![p.x, p.y],
_ => Vec::new(),
}
}
/// Value and velocity graphs of an animated scalar or point parameter, drawn across the keyframe
/// lane. A point draws an X and a Y curve, and its velocity graph is the speed along its path.
/// Keyframes drag vertically (value); Bezier influence handles drag horizontally.
#[allow(clippy::too_many_arguments)]
pub(crate) fn graph_rows(
@ -917,15 +957,14 @@ pub(crate) fn graph_rows(
let to_media = |f: f64| it.source_in + Tick((f * dur * speed) as i64);
let to_f = |m: Tick| ((m - it.source_in).0 as f64 / speed / dur) as f32;
let x_of = |f: f32| lane.min.x + f * lane.width();
// samples
// samples: one row of curve values per sample (a point's NaN "auto" coordinate is skipped)
let n = (lane.width() / 2.0).max(8.0) as usize;
let vals: Vec<f64> = (0..=n).map(|i| param.f64_at(to_media(i as f64 / n as f64))).collect();
let (mut lo, mut hi) = vals.iter().fold((f64::MAX, f64::MIN), |(a, b), v| (a.min(*v), b.max(*v)));
for k in &param.keyframes {
if let ParamValue::Float(v) = k.value {
lo = lo.min(v);
hi = hi.max(v);
}
let vals: Vec<Vec<f64>> = (0..=n).map(|i| graph_components(&param.value_at(to_media(i as f64 / n as f64)))).collect();
let curves = graph_components(&param.value).len();
let at_keys: Vec<Vec<f64>> = param.keyframes.iter().map(|k| graph_components(&k.value)).collect();
let (mut lo, mut hi) = vals.iter().chain(&at_keys).flatten().filter(|v| v.is_finite()).fold((f64::MAX, f64::MIN), |(a, b), v| (a.min(*v), b.max(*v)));
if lo > hi {
(lo, hi) = (0.0, 0.0);
}
if hi - lo < 1e-6 {
lo -= 1.0;
@ -951,17 +990,35 @@ pub(crate) fn graph_rows(
p.text(pos2(vr.max.x - 30.0, area.min.y + 6.0), Align2::RIGHT_CENTER, format!("{hi:.dec$}"), Tokens::ui(10.0), t.text_dim);
p.text(pos2(vr.max.x - 30.0, area.max.y - 6.0), Align2::RIGHT_CENTER, format!("{lo:.dec$}"), Tokens::ui(10.0), t.text_dim);
p.text(pos2(vr.min.x + 44.0, area.center().y), Align2::LEFT_CENTER, tl!("Value"), Tokens::ui(11.0), t.text_dim);
let line: Vec<Pos2> = vals.iter().enumerate().map(|(i, v)| pos2(x_of(i as f32 / n as f32), y_of(*v))).collect();
p.add(egui::Shape::line(line, Stroke::new(1.5, t.accent)));
// velocity (units per second, derivative of the sampled value)
let secs = dur * speed / filmcraft_time::TICKS_PER_SECOND as f64 / n as f64;
let vel: Vec<f64> = (0..n).map(|i| (vals[i + 1] - vals[i]) / secs.max(1e-9)).collect();
let vmax = vel.iter().fold(1e-6f64, |a, v| a.max(v.abs())) * 1.15;
let curve_color = |c: usize| if c == 0 { t.accent } else { Y_CURVE };
if curves > 1 {
p.text(pos2(vr.min.x + 44.0, area.center().y + 14.0), Align2::LEFT_CENTER, "X", Tokens::ui(10.5), curve_color(0));
p.text(pos2(vr.min.x + 58.0, area.center().y + 14.0), Align2::LEFT_CENTER, "Y", Tokens::ui(10.5), curve_color(1));
}
for c in 0..curves {
let line: Vec<Pos2> =
vals.iter().enumerate().filter_map(|(i, v)| v.get(c).filter(|v| v.is_finite()).map(|v| pos2(x_of(i as f32 / n as f32), y_of(*v)))).collect();
p.add(egui::Shape::line(line, Stroke::new(1.5, curve_color(c))));
}
// velocity (units per second, derivative of the sampled value; a point's speed along its path)
let secs = (dur * speed / filmcraft_time::TICKS_PER_SECOND as f64 / n as f64).max(1e-9);
let vel: Vec<f64> = vals
.windows(2)
.map(|w| {
let [a, b] = w else { return 0.0 };
let d: Vec<f64> = a.iter().zip(b).map(|(a, b)| (b - a) / secs).collect();
match d.as_slice() {
[v] => *v,
d => d.iter().map(|v| v * v).sum::<f64>().sqrt(),
}
})
.collect();
let vmax = vel.iter().filter(|v| v.is_finite()).fold(1e-6f64, |a, v| a.max(v.abs())) * 1.15;
let varea = Rect::from_min_max(pos2(lane.min.x, velr.min.y + 2.0), pos2(lane.max.x, velr.max.y - 4.0));
p.rect_filled(varea, 0.0, t.keyframe_plot_bg);
let vy = |v: f64| varea.center().y - (v / vmax) as f32 * varea.height() / 2.0;
p.line_segment([pos2(varea.min.x, varea.center().y), pos2(varea.max.x, varea.center().y)], Stroke::new(1.0, t.keyframe_plot_axis));
let vline: Vec<Pos2> = vel.iter().enumerate().map(|(i, v)| pos2(x_of((i as f32 + 0.5) / n as f32), vy(*v))).collect();
let vline: Vec<Pos2> = vel.iter().enumerate().filter(|(_, v)| v.is_finite()).map(|(i, v)| pos2(x_of((i as f32 + 0.5) / n as f32), vy(*v))).collect();
p.add(egui::Shape::line(vline, Stroke::new(1.2, Color32::from_rgb(0xd0, 0xa0, 0x40))));
p.text(pos2(velr.min.x + 44.0, varea.center().y), Align2::LEFT_CENTER, tl!("Velocity"), Tokens::ui(11.0), t.text_dim);
p.text(pos2(velr.max.x - 30.0, varea.min.y + 6.0), Align2::RIGHT_CENTER, format!("{vmax:.1}/s"), Tokens::ui(10.0), t.text_dim);
@ -969,55 +1026,83 @@ pub(crate) fn graph_rows(
// keyframes + handles
let ks = &param.keyframes;
for (i, k) in ks.iter().enumerate() {
let ParamValue::Float(v) = k.value else { continue };
let f = to_f(k.time);
if !(-0.01..=1.01).contains(&f) {
continue;
}
let id = egui::Id::new(("kfg", clip.0, idx, mask, pd.id, k.time.0));
let dy: f32 = ui.data(|d| d.get_temp(id)).unwrap_or(0.0);
let c = pos2(x_of(f), y_of(v) + dy);
let r = Rect::from_center_size(c, vec2(10.0, 10.0));
let resp = ui.interact(r.expand(2.0), id, Sense::drag());
app.auto.add(&format!("effectControls.{}.graph.keyframe.{}", pd.id, k.time.0), r, "keyframe value");
// influence handles: flat (ease) tangents with length ∝ influence × neighbouring segment
let eases_out =
!matches!(k.interp, filmcraft_project::Interpolation::Linear | filmcraft_project::Interpolation::Hold | filmcraft_project::Interpolation::EaseIn);
let eases_in =
!matches!(k.interp, filmcraft_project::Interpolation::Linear | filmcraft_project::Interpolation::Hold | filmcraft_project::Interpolation::EaseOut);
for (side, on, nb) in [(1.0f32, eases_out, ks.get(i + 1)), (-1.0f32, eases_in && i > 0, if i > 0 { ks.get(i - 1) } else { None })] {
let (true, Some(nb)) = (on, nb) else { continue };
let seg = (x_of(to_f(nb.time)) - c.x).abs();
let infl = if side > 0.0 { k.out_influence } else { k.in_influence } as f32;
let hid = id.with(if side > 0.0 { "out" } else { "in" });
let hdx: f32 = ui.data(|d| d.get_temp(hid)).unwrap_or(0.0);
let hx = c.x + side * (infl * seg + hdx * side).clamp(seg * 0.01, seg);
let hp = pos2(hx, c.y);
p.line_segment([c, hp], Stroke::new(1.0, t.plot_handle_dim));
p.circle_filled(hp, 3.5, t.keyframe_handle);
let hr = ui.interact(Rect::from_center_size(hp, vec2(10.0, 10.0)), hid.with("h"), Sense::drag());
if hr.dragged() {
let nx = graph_drag_offset(ui, &hr, vec2(hdx, 0.0)).x;
ui.data_mut(|d| d.insert_temp(hid, nx));
let comps = graph_components(&k.value);
for (ci, &v) in comps.iter().enumerate() {
if !v.is_finite() {
continue;
}
if hr.drag_stopped() {
ui.data_mut(|d| d.remove::<f32>(hid));
let ni = ((infl * seg + hdx * side) / seg.max(1.0)).clamp(0.01, 1.0);
let key = if side > 0.0 { "outInfluence" } else { "inInfluence" };
actions.push(("effects.setKeyframe".into(), with_mask(json!({"clip": clip.0, "effect": idx, "param": pd.id, "mediaTime": k.time.0, key: ni}))));
let id = egui::Id::new(("kfg", clip.0, idx, mask, pd.id, k.time.0));
let id = if ci == 0 { id } else { id.with(ci) };
let dy: f32 = ui.data(|d| d.get_temp(id)).unwrap_or(0.0);
let c = pos2(x_of(f), y_of(v) + dy);
let r = Rect::from_center_size(c, vec2(10.0, 10.0));
let resp = ui.interact(r.expand(2.0), id, Sense::drag());
let auto_id = match (comps.len(), ci) {
(1, _) => format!("effectControls.{}.graph.keyframe.{}", pd.id, k.time.0),
(_, 0) => format!("effectControls.{}.graph.keyframe.{}.x", pd.id, k.time.0),
_ => format!("effectControls.{}.graph.keyframe.{}.y", pd.id, k.time.0),
};
app.auto.add(&auto_id, r, "keyframe value");
// influence handles: flat (ease) tangents with length ∝ influence × neighbouring segment;
// a point's sit on its X curve only, as both coordinates share the influence
let (eases_out, eases_in) = (ci == 0 && eases_out, ci == 0 && eases_in);
for (side, on, nb) in [(1.0f32, eases_out, ks.get(i + 1)), (-1.0f32, eases_in && i > 0, if i > 0 { ks.get(i - 1) } else { None })] {
let (true, Some(nb)) = (on, nb) else { continue };
let seg = (x_of(to_f(nb.time)) - c.x).abs();
let infl = if side > 0.0 { k.out_influence } else { k.in_influence } as f32;
let hid = id.with(if side > 0.0 { "out" } else { "in" });
let hdx: f32 = ui.data(|d| d.get_temp(hid)).unwrap_or(0.0);
let hx = c.x + side * (infl * seg + hdx * side).clamp(seg * 0.01, seg);
let hp = pos2(hx, c.y);
p.line_segment([c, hp], Stroke::new(1.0, t.plot_handle_dim));
p.circle_filled(hp, 3.5, t.keyframe_handle);
let hr = ui.interact(Rect::from_center_size(hp, vec2(10.0, 10.0)), hid.with("h"), Sense::drag());
if hr.dragged() {
let nx = graph_drag_offset(ui, &hr, vec2(hdx, 0.0)).x;
ui.data_mut(|d| d.insert_temp(hid, nx));
}
if hr.drag_stopped() {
ui.data_mut(|d| d.remove::<f32>(hid));
let ni = ((infl * seg + hdx * side) / seg.max(1.0)).clamp(0.01, 1.0);
let key = if side > 0.0 { "outInfluence" } else { "inInfluence" };
actions.push((
"effects.setKeyframe".into(),
with_mask(json!({"clip": clip.0, "effect": idx, "param": pd.id, "mediaTime": k.time.0, key: ni})),
));
}
}
p.circle_filled(c, 4.5, if resp.dragged() { t.hot_text } else { t.plot_handle });
if resp.dragged() {
let ny = graph_drag_offset(ui, &resp, vec2(0.0, dy)).y;
ui.data_mut(|d| d.insert_temp(id, ny));
p.text(c + vec2(8.0, -10.0), Align2::LEFT_BOTTOM, format!("{:.dec$}", v_of(c.y)), Tokens::ui(10.5), t.hot_text);
}
if resp.drag_stopped() {
ui.data_mut(|d| d.remove::<f32>(id));
let nv = v_of(c.y);
let nv = if let ParamKind::Float { min, max, .. } = pd.kind { nv.clamp(min, max) } else { nv };
let value = if comps.len() == 1 {
json!(nv)
} else {
let mut moved = comps.clone();
if let Some(slot) = moved.get_mut(ci) {
*slot = nv;
}
json!(moved)
};
actions.push((
"effects.setKeyframe".into(),
with_mask(json!({"clip": clip.0, "effect": idx, "param": pd.id, "mediaTime": k.time.0, "value": value})),
));
}
}
p.circle_filled(c, 4.5, if resp.dragged() { t.hot_text } else { t.plot_handle });
if resp.dragged() {
let ny = graph_drag_offset(ui, &resp, vec2(0.0, dy)).y;
ui.data_mut(|d| d.insert_temp(id, ny));
p.text(c + vec2(8.0, -10.0), Align2::LEFT_BOTTOM, format!("{:.dec$}", v_of(c.y)), Tokens::ui(10.5), t.hot_text);
}
if resp.drag_stopped() {
ui.data_mut(|d| d.remove::<f32>(id));
let nv = v_of(c.y);
let nv = if let ParamKind::Float { min, max, .. } = pd.kind { nv.clamp(min, max) } else { nv };
actions.push(("effects.setKeyframe".into(), with_mask(json!({"clip": clip.0, "effect": idx, "param": pd.id, "mediaTime": k.time.0, "value": nv}))));
}
}
}
@ -1201,33 +1286,33 @@ mod graph_drag_tests {
ctx: egui::Context,
clip: ClipId,
time: f64,
param: &'static str,
}
impl Driver {
fn new() -> Self {
Self::with("scale", ParamValue::Float(40.0), ParamValue::Float(80.0))
}
fn with(param: &'static str, from: ParamValue, to: ParamValue) -> Self {
let mut session = filmcraft_engine::Session::default();
session.execute("file.openDemoProject", json!({})).unwrap();
let seq = session.state.active_sequence.unwrap();
let clip = session.active_sequence().unwrap().video_tracks[0].items[0].id;
let it = std::sync::Arc::make_mut(&mut session.project).sequence_mut(seq).unwrap().find_item_mut(clip).unwrap().1;
let mut a = Keyframe::new(it.source_in + Tick(it.duration.0 / 4), ParamValue::Float(40.0));
let mut b = Keyframe::new(it.source_in + Tick(it.duration.0 * 3 / 4), ParamValue::Float(80.0));
let mut a = Keyframe::new(it.source_in + Tick(it.duration.0 / 4), from.clone());
let mut b = Keyframe::new(it.source_in + Tick(it.duration.0 * 3 / 4), to);
a.interp = Interpolation::Bezier;
b.interp = Interpolation::Bezier;
it.effects
.iter_mut()
.find(|e| e.effect == "motion")
.unwrap()
.params
.insert("scale".into(), Param { value: ParamValue::Float(40.0), keyframes: vec![a, b] });
it.effects.iter_mut().find(|e| e.effect == "motion").unwrap().params.insert(param.into(), Param { value: from, keyframes: vec![a, b] });
let app = FilmcraftApp::new(session);
let ctx = egui::Context::default();
crate::theme::install(&ctx, &app.tokens);
Self { app, ctx, clip, time: 0.0 }
Self { app, ctx, clip, time: 0.0, param }
}
fn param(&self) -> Param {
self.app.session.active_sequence().unwrap().find_item(self.clip).unwrap().1.effect("motion").unwrap().params["scale"].clone()
self.app.session.active_sequence().unwrap().find_item(self.clip).unwrap().1.effect("motion").unwrap().params[self.param].clone()
}
fn undo_len(&mut self) -> usize {
@ -1243,11 +1328,11 @@ mod graph_drag_tests {
let it = self.app.session.active_sequence().unwrap().find_item(self.clip).unwrap().1.clone();
let index = it.effects.iter().position(|e| e.effect == "motion").unwrap();
let effect = &it.effects[index];
let pd = effect.def().unwrap().param("scale").unwrap();
let pd = effect.def().unwrap().param(self.param).unwrap();
let body = Rect::from_min_size(pos2(8.0, 8.0), vec2(320.0, 200.0));
let lane = Rect::from_min_max(pos2(360.0, 8.0), pos2(760.0, 220.0));
let mut actions = Vec::new();
graph_rows(&mut self.app, ui, body, self.clip, index, None, pd, &effect.params["scale"], &lane, &it, &mut actions);
graph_rows(&mut self.app, ui, body, self.clip, index, None, pd, &effect.params[self.param], &lane, &it, &mut actions);
run(&mut self.app, ui.ctx(), actions);
});
out.textures_delta.clear();
@ -1320,4 +1405,26 @@ mod graph_drag_tests {
}
}
}
/// #239: Position (a point) has graphs too; dragging its X keyframe moves only X, in one undo step.
#[test]
fn position_graph_drags_one_coordinate_of_a_point_keyframe() {
use filmcraft_geom::Vec2;
let mut d = Driver::with("position", ParamValue::Vec2(Vec2::new(400.0, 300.0)), ParamValue::Vec2(Vec2::new(800.0, 500.0)));
assert!(graphable(&d.param().value));
let before = d.param();
let undo = d.undo_len();
let start = d.frame(vec![])[0];
assert!(d.app.auto.find(&format!("effectControls.position.graph.keyframe.{}.y", before.keyframes[0].time.0)).is_some());
d.frame(vec![egui::Event::PointerMoved(start), button(start, true)]);
d.frame(vec![egui::Event::PointerMoved(start - vec2(0.0, 20.0))]);
d.frame(vec![button(start - vec2(0.0, 20.0), false)]);
d.frame(vec![]);
let changed = d.param();
assert_eq!(d.undo_len(), undo + 1);
let (old, new) = (before.keyframes[0].value.as_vec2().unwrap(), changed.keyframes[0].value.as_vec2().unwrap());
assert!(new.x > old.x, "dragging the X keyframe up raises X: {old:?} -> {new:?}");
assert_eq!(new.y, old.y);
assert_eq!(changed.keyframes[1], before.keyframes[1]);
}
}

View File

@ -642,14 +642,22 @@ fn video_section(ui: &mut egui::Ui, reg: &mut Reg, s: &mut ExportSettings, t: &T
});
}
row(ui, t, tl!("Bitrate Encoding"), |ui| {
// a hardware encoder has one pass: H.265 offers no two-pass mode
let o = [BitrateMode::Cbr, BitrateMode::Vbr1Pass, BitrateMode::Vbr2Pass];
let labels: Vec<(String, bool)> = o.iter().map(|m| (m.label().to_string(), !(hevc && *m == BitrateMode::Vbr2Pass))).collect();
// a hardware encoder has one pass and no constant-quality mode: H.265 offers neither
let o = [BitrateMode::Cbr, BitrateMode::Vbr1Pass, BitrateMode::Vbr2Pass, BitrateMode::Crf];
let labels: Vec<(String, bool)> =
o.iter().map(|m| (m.label().to_string(), !(hevc && matches!(m, BitrateMode::Vbr2Pass | BitrateMode::Crf)))).collect();
if let Some(i) = combo(ui, reg, "export.video.bitrateMode", s.bitrate_mode.label(), &labels, 140.0) {
s.bitrate_mode = o[i];
}
});
if let Some(bpp) = s.adaptive_bitrate {
if s.bitrate_mode == BitrateMode::Crf {
row(ui, t, tl!("Quality (CRF)"), |ui| {
let mut crf = if s.crf.is_finite() { f64::from(s.crf) } else { f64::from(filmcraft_engine::export::DEFAULT_CRF) };
if drag(ui, reg, "export.video.crf", &mut crf, 0.0..=51.0, 0.1, "", 0) {
s.crf = crf.round() as f32;
}
});
} else if let Some(bpp) = s.adaptive_bitrate {
row(ui, t, tl!("Target Bitrate"), |ui| {
ui.label(egui::RichText::new(tlf!("Adaptive ({bpp} bits per pixel)", bpp)).size(12.0));
let r = ui.small_button(tl!("Set"));

View File

@ -386,6 +386,19 @@ const TEXT_EDIT_ID: &str = "gfx-text-edit";
fn end_edit(app: &mut FilmcraftApp, ui: &egui::Ui) {
app.ui.gfx_edit = None;
ui.memory_mut(|m| m.surrender_focus(egui::Id::new(TEXT_EDIT_ID)));
ui.data_mut(|d| d.remove::<crate::dock::PanelKind>(egui::Id::new(TEXT_EDIT_ID).with("panel")));
}
/// A click on the Program monitor's empty space (the picture's, or around it): end the text edit
/// and let go of the selected graphic layers and mask (#683).
pub(crate) fn deselect_on_monitor(app: &mut FilmcraftApp, ui: &egui::Ui) {
end_edit(app, ui);
app.session.state.graphic_layers.clear();
if app.session.state.selected_mask.is_some()
&& let Err(e) = app.session.execute("masks.select", json!({"none": true}))
{
app.ui.status = e.to_string();
}
}
/// Draw the overlay and handle graphics tools on the Program monitor picture `pic`.
@ -678,10 +691,8 @@ pub fn monitor_overlay(app: &mut FilmcraftApp, ui: &mut egui::Ui, pic: Rect, fra
end_edit(app, ui);
}
actions.push(("graphics.selectLayer".into(), json!({"clip": v.clip.0, "layers": [v.layer]})));
} else if app.ui.gfx_edit.is_some() {
end_edit(app, ui);
} else if sel_clip.is_some() && !sel_layers.is_empty() {
app.session.state.graphic_layers.clear();
} else {
deselect_on_monitor(app, ui);
}
}
Tool::Pen => {
@ -806,10 +817,26 @@ fn start_edit_at(app: &mut FilmcraftApp, ui: &egui::Ui, v: &LayerView, p: Pos2,
/// Keyboard handling while a text layer is being edited.
fn text_edit(app: &mut FilmcraftApp, ui: &mut egui::Ui, v: &LayerView, mut ed: GfxEdit, resp: &egui::Response, actions: &mut Vec<(String, Value)>) {
let _ = resp;
// moving to another panel ends the edit; Properties and Essential Graphics style the text
// being edited, so working there keeps it (#683). Only a move counts: an edit started with
// another panel focused (an agent's `ui.set`) goes on until the focus moves.
use crate::dock::PanelKind;
let panel_id = egui::Id::new(TEXT_EDIT_ID).with("panel");
let before = ui.data(|d| d.get_temp::<PanelKind>(panel_id));
ui.data_mut(|d| d.insert_temp(panel_id, app.ui.focused));
if before.is_some_and(|b| b != app.ui.focused) && !matches!(app.ui.focused, PanelKind::Program | PanelKind::Properties | PanelKind::EssentialGraphics) {
end_edit(app, ui);
return;
}
let eid = egui::Id::new(TEXT_EDIT_ID);
// a real (non-clickable) widget holds keyboard focus so shortcuts pause while typing
let fr = ui.interact(Rect::from_points(&v.quad()), eid, Sense::focusable_noninteractive());
fr.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::TextEdit, true, "Text layer"));
// while another field has the keyboard (a value typed in Properties) the keys are its own;
// the text takes the keyboard back once nothing has it (#683)
if ui.memory(|m| m.focused().is_some_and(|f| f != eid)) {
return;
}
ui.memory_mut(|m| {
m.request_focus(eid);
m.set_focus_lock_filter(eid, egui::EventFilter { tab: true, horizontal_arrows: true, vertical_arrows: true, escape: true });

View File

@ -162,120 +162,125 @@ fn link_media(app: &mut FilmcraftApp, ctx: &egui::Context) {
let sel_path = d.candidate.and_then(|c| d.candidates.get(c)).map(|c| c.0.clone());
let preview = sel_path.as_deref().and_then(|p| preview_texture(app, ctx, p));
let rows_max_h = (ctx.content_rect().height() * 0.3).clamp(90.0, 320.0);
let width = (ctx.content_rect().width() - 40.0).clamp(1.0, 760.0);
egui::Window::new(tl!("Link Media"))
.id(egui::Id::new("Link Media"))
.collapsible(false)
.resizable(false)
.default_width(760.0)
.default_width(width)
.max_width(width)
.anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0])
.show(ctx, |ui| {
ui.label(tlf!("{n} clip(s) can't find their media. Locate them, search a folder, or leave them offline.", n = rows.len()));
ui.add_space(6.0);
egui::ScrollArea::vertical().id_salt("link-media-rows-scroll").max_height(rows_max_h).auto_shrink([false, true]).show(ui, |ui| {
egui::Grid::new("link-media-rows").num_columns(4).striped(true).spacing([14.0, 4.0]).show(ui, |ui| {
for h in [tl!("Clip Name"), tl!("File Name"), tl!("File Path"), tl!("Status")] {
ui.label(RichText::new(h).strong());
}
ui.end_row();
for (k, (_, name, fname, path, status)) in rows.iter().enumerate() {
let r = ui.selectable_label(d.row == k, name);
push(&mut elems, format!("linkMedia.row.{k}"), &r, name.clone());
if r.clicked() && d.row != k {
d.row = k;
d.candidates.clear();
d.candidate = None;
egui::ScrollArea::vertical().id_salt("link-media-body-scroll").max_height((ctx.content_rect().height() - 140.0).max(1.0)).show(ui, |ui| {
ui.label(tlf!("{n} clip(s) can't find their media. Locate them, search a folder, or leave them offline.", n = rows.len()));
ui.add_space(6.0);
egui::ScrollArea::both().id_salt("link-media-rows-scroll").max_height(rows_max_h).auto_shrink([false, true]).show(ui, |ui| {
egui::Grid::new("link-media-rows").num_columns(4).striped(true).spacing([14.0, 4.0]).show(ui, |ui| {
for h in [tl!("Clip Name"), tl!("File Name"), tl!("File Path"), tl!("Status")] {
ui.label(RichText::new(h).strong());
}
ui.label(fname);
ui.label(RichText::new(path).small());
ui.label(RichText::new(*status).color(Color32::from_rgb(0xe0, 0x5a, 0x5a)));
ui.end_row();
}
});
});
ui.separator();
ui.horizontal(|ui| {
ui.label(tl!("Match file properties:"));
for (id, label, v) in [
("fileName", tl!("File Name"), &mut d.file_name),
("extension", tl!("File Extension"), &mut d.extension),
("clipId", tl!("Clip ID (fingerprint)"), &mut d.clip_id),
("duration", tl!("Duration"), &mut d.duration),
("mediaStart", tl!("Media Start"), &mut d.media_start),
("metadata", tl!("Frame Size / Rate"), &mut d.metadata),
] {
let r = ui.checkbox(v, label);
push(&mut elems, format!("linkMedia.match.{id}"), &r, label);
}
});
ui.horizontal(|ui| {
let r = ui.checkbox(&mut d.align_timecode, tl!("Align Timecode"));
push(&mut elems, "linkMedia.alignTimecode", &r, "Align Timecode");
let r = ui.checkbox(&mut d.relink_others, tl!("Relink others automatically"));
push(&mut elems, "linkMedia.relinkOthers", &r, "Relink others automatically");
});
ui.separator();
ui.horizontal(|ui| {
ui.label(tl!("Search in:"));
let r = ui.add(egui::TextEdit::singleline(&mut d.folder).desired_width(360.0).hint_text(tl!("Folder")));
push(&mut elems, "linkMedia.folder", &r, "folder");
let r = ui.button(tl!("Browse…"));
push(&mut elems, "linkMedia.browse", &r, "Browse…");
if r.clicked() {
action = Some("browse");
}
let r = ui.checkbox(&mut d.exact_name, tl!("Exact name matches only"));
push(&mut elems, "linkMedia.exactName", &r, "Exact name matches only");
let r = ui.button(tl!("Search"));
push(&mut elems, "linkMedia.search", &r, "Search");
if r.clicked() {
action = Some("search");
}
});
ui.horizontal(|ui| {
ui.vertical(|ui| {
ui.set_width(520.0);
if d.candidates.is_empty() {
ui.label(RichText::new(tl!("No candidates yet — Search a folder or Locate the file.")).weak());
}
egui::ScrollArea::vertical().id_salt("link-media-candidates-scroll").max_height(108.0).auto_shrink([false, true]).show(ui, |ui| {
for (k, (path, ok, idm, problems)) in d.candidates.iter().enumerate() {
let mark = match (ok, idm) {
(true, Some(true)) => tl!("✔ same file"),
(true, _) => tl!("✔ matches"),
(false, Some(false)) => tl!("✖ different file"),
_ => "✖",
};
let text = format!("{mark} {path}");
let r = ui.selectable_label(
d.candidate == Some(k),
RichText::new(&text).color(if *ok { Color32::LIGHT_GREEN } else { Color32::from_rgb(0xe0, 0x8a, 0x6a) }),
);
let r = if problems.is_empty() { r } else { r.on_hover_text(problems) };
push(&mut elems, format!("linkMedia.candidate.{k}"), &r, text);
if r.clicked() {
d.candidate = Some(k);
for (k, (_, name, fname, path, status)) in rows.iter().enumerate() {
let r = ui.selectable_label(d.row == k, name);
push(&mut elems, format!("linkMedia.row.{k}"), &r, name.clone());
if r.clicked() && d.row != k {
d.row = k;
d.candidates.clear();
d.candidate = None;
}
ui.label(fname);
ui.label(RichText::new(path).small());
ui.label(RichText::new(*status).color(Color32::from_rgb(0xe0, 0x5a, 0x5a)));
ui.end_row();
}
});
});
let (rect, _) = ui.allocate_exact_size(egui::vec2(192.0, 108.0), egui::Sense::hover());
ui.painter().rect_filled(rect, 3.0, Color32::from_rgb(12, 12, 12));
match &preview {
Some(t) => {
let fitted = crate::panels::monitor::fit(rect, t.size()[0] as f32, t.size()[1] as f32);
ui.painter().image(t.id(), fitted, egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), Color32::WHITE);
ui.separator();
ui.horizontal_wrapped(|ui| {
ui.label(tl!("Match file properties:"));
for (id, label, v) in [
("fileName", tl!("File Name"), &mut d.file_name),
("extension", tl!("File Extension"), &mut d.extension),
("clipId", tl!("Clip ID (fingerprint)"), &mut d.clip_id),
("duration", tl!("Duration"), &mut d.duration),
("mediaStart", tl!("Media Start"), &mut d.media_start),
("metadata", tl!("Frame Size / Rate"), &mut d.metadata),
] {
let r = ui.checkbox(v, label);
push(&mut elems, format!("linkMedia.match.{id}"), &r, label);
}
None => {
ui.painter().text(rect.center(), egui::Align2::CENTER_CENTER, tl!("Preview"), crate::theme::Tokens::ui(11.0), Color32::GRAY);
});
ui.horizontal_wrapped(|ui| {
let r = ui.checkbox(&mut d.align_timecode, tl!("Align Timecode"));
push(&mut elems, "linkMedia.alignTimecode", &r, "Align Timecode");
let r = ui.checkbox(&mut d.relink_others, tl!("Relink others automatically"));
push(&mut elems, "linkMedia.relinkOthers", &r, "Relink others automatically");
});
ui.separator();
ui.horizontal_wrapped(|ui| {
ui.label(tl!("Search in:"));
let r = ui.add(egui::TextEdit::singleline(&mut d.folder).desired_width((ui.available_width() * 0.5).min(360.0)).hint_text(tl!("Folder")));
push(&mut elems, "linkMedia.folder", &r, "folder");
let r = ui.button(tl!("Browse…"));
push(&mut elems, "linkMedia.browse", &r, "Browse…");
if r.clicked() {
action = Some("browse");
}
let r = ui.checkbox(&mut d.exact_name, tl!("Exact name matches only"));
push(&mut elems, "linkMedia.exactName", &r, "Exact name matches only");
let r = ui.button(tl!("Search"));
push(&mut elems, "linkMedia.search", &r, "Search");
if r.clicked() {
action = Some("search");
}
});
ui.horizontal(|ui| {
let candidates_width = (ui.available_width() - 192.0 - ui.spacing().item_spacing.x).max(1.0);
ui.vertical(|ui| {
ui.set_width(candidates_width);
if d.candidates.is_empty() {
ui.label(RichText::new(tl!("No candidates yet — Search a folder or Locate the file.")).weak());
}
egui::ScrollArea::vertical().id_salt("link-media-candidates-scroll").max_height(108.0).auto_shrink([false, true]).show(ui, |ui| {
for (k, (path, ok, idm, problems)) in d.candidates.iter().enumerate() {
let mark = match (ok, idm) {
(true, Some(true)) => tl!("✔ same file"),
(true, _) => tl!("✔ matches"),
(false, Some(false)) => tl!("✖ different file"),
_ => "✖",
};
let text = format!("{mark} {path}");
let r = ui.selectable_label(
d.candidate == Some(k),
RichText::new(&text).color(if *ok { Color32::LIGHT_GREEN } else { Color32::from_rgb(0xe0, 0x8a, 0x6a) }),
);
let r = if problems.is_empty() { r } else { r.on_hover_text(problems) };
push(&mut elems, format!("linkMedia.candidate.{k}"), &r, text);
if r.clicked() {
d.candidate = Some(k);
}
}
});
});
let (rect, _) = ui.allocate_exact_size(egui::vec2(192.0, 108.0), egui::Sense::hover());
ui.painter().rect_filled(rect, 3.0, Color32::from_rgb(12, 12, 12));
match &preview {
Some(t) => {
let fitted = crate::panels::monitor::fit(rect, t.size()[0] as f32, t.size()[1] as f32);
ui.painter().image(t.id(), fitted, egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), Color32::WHITE);
}
None => {
ui.painter().text(rect.center(), egui::Align2::CENTER_CENTER, tl!("Preview"), crate::theme::Tokens::ui(11.0), Color32::GRAY);
}
}
elems.push(("linkMedia.preview".into(), rect, "preview".into()));
});
if !d.message.is_empty() {
ui.colored_label(Color32::from_rgb(0xe0, 0x8a, 0x6a), &d.message);
}
elems.push(("linkMedia.preview".into(), rect, "preview".into()));
});
if !d.message.is_empty() {
ui.colored_label(Color32::from_rgb(0xe0, 0x8a, 0x6a), &d.message);
}
ui.add_space(6.0);
ui.horizontal(|ui| {
ui.horizontal_wrapped(|ui| {
for (id, label) in [("offlineAll", tl!("Offline All")), ("offline", tl!("Offline")), ("cancel", tl!("Cancel")), ("locate", tl!("Locate…"))] {
let r = ui.button(label);
push(&mut elems, format!("linkMedia.{id}"), &r, label);

View File

@ -1010,8 +1010,23 @@ pub fn clip_mixer(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
let m = meters.get(&tr.id.0).cloned().unwrap_or_default();
draw_meters(ui, mrect, &m, if m.is_empty() { filmcraft_render::mixer::width_of(tr.channels) } else { m.len() }, &t);
}
small_text(ui, pos2(sr.min.x + 8.0, sr.max.y - 34.0), Align2::LEFT_CENTER, &db_text(vol), 11.5, dimc(t.hot_text));
app.auto.add(&format!("{ap}.value"), Rect::from_min_size(pos2(sr.min.x + 4.0, sr.max.y - 42.0), vec2(50.0, 16.0)), &db_text(vol));
// dB readout: drag to scrub, click to type a value (clamped to the fader range), like the track mixer
let vr = Rect::from_min_size(pos2(sr.min.x + 4.0, sr.max.y - 42.0), vec2(50.0, 16.0));
let mut vui = ui.new_child(egui::UiBuilder::new().max_rect(vr));
if !active {
vui.set_opacity(0.4);
}
let (vresp, nv) = crate::widgets::hot_number(&mut vui, egui::Id::new((&ap, "value")), vol.max(-96.0), 0.1, (FADER_MIN_DB, FADER_MAX_DB), 1, "", &t);
app.auto.add(&format!("{ap}.value"), vresp.rect, &db_text(vol));
if let (Some(v), true) = (nv, active) {
set(&mut acts, "volume", "level", v, !vresp.dragged() || vresp.drag_started());
if !vresp.dragged() {
release(&mut acts, "volume");
}
}
if vresp.drag_stopped() {
release(&mut acts, "volume");
}
small_text(ui, pos2(sr.min.x + 6.0, sr.max.y - 10.0), Align2::LEFT_CENTER, &label, 12.0, t.text);
small_text(ui, pos2(sr.min.x + 30.0, sr.max.y - 10.0), Align2::LEFT_CENTER, &tr.name, 12.0, t.text);
}

View File

@ -279,6 +279,11 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, which: Which)
let c = if app.playback.meter.counts().1 > 0 { t.render_yellow } else { t.render_green };
ui.painter().circle_filled(pos2(video_area.min.x + 10.0, video_area.min.y + 10.0), 4.0, c);
}
// A click on the Program monitor around the picture lets go of what is selected on it (#683;
// in the picture the overlays do that). Under the picture and the overlays, so they come first.
if which == Which::Program && show_picture && ui.interact(video_area, egui::Id::new((prefix, "background")), Sense::click()).clicked() {
crate::panels::graphics::deselect_on_monitor(app, ui);
}
// Click/drag in the picture: the Hand tool pans a magnified picture, otherwise focus.
let pic_resp = ui.interact(pic, egui::Id::new((prefix, "pic")), if show_picture { Sense::click_and_drag() } else { Sense::hover() });
app.auto.add(&format!("{prefix}.picture"), pic.intersect(video_area), "picture");
@ -692,6 +697,19 @@ fn mini_timeline(
}
}
// A hidden transport action is still available through its regular shortcut/menu.
fn transport_visible(hidden: &[String], command: &str) -> bool {
!hidden.iter().any(|id| id == command)
}
fn set_transport_visible(hidden: &mut Vec<String>, command: &str, visible: bool) {
if visible {
hidden.retain(|id| id != command);
} else if transport_visible(hidden, command) {
hidden.push(command.to_owned());
}
}
fn transport(app: &mut FilmcraftApp, ui: &mut egui::Ui, row: Rect, which: Which) {
let t = app.tokens;
let src = which == Which::Source;
@ -726,12 +744,21 @@ fn transport(app: &mut FilmcraftApp, ui: &mut egui::Ui, row: Rect, which: Which)
(Icon::Proxy, "media.toggleProxies", tl!("Toggle Proxies")),
]
};
let mut buttons = buttons;
if !src {
// Optional control, discoverable through the editor without changing existing layouts.
buttons.push((Icon::Loop, "playback.loop", tl!("Loop")));
}
let hidden = monitor_view::view(app, which).transport_hidden.clone();
let bw = 30.0;
let total = buttons.len() as f32 * bw;
let total = buttons.iter().filter(|(_, cmd, _)| transport_visible(&hidden, cmd)).count() as f32 * bw;
let mut x = row.center().x - total / 2.0;
let ctx = ui.ctx().clone();
let prefix = if src { "source" } else { "program" };
for (icon, cmd, tip) in buttons {
for &(icon, cmd, tip) in &buttons {
if !transport_visible(&hidden, cmd) {
continue;
}
let r = Rect::from_min_size(pos2(x, row.min.y + 2.0), vec2(bw - 2.0, 26.0));
let resp = ui.interact(r, egui::Id::new((prefix, cmd)), Sense::click()).on_hover_text(tip);
app.auto.add(&format!("{prefix}.transport.{cmd}"), r, tip);
@ -767,10 +794,32 @@ fn transport(app: &mut FilmcraftApp, ui: &mut egui::Ui, row: Rect, which: Which)
}
x += bw;
}
// button editor "+"
// Customize this monitor's transport separately from the other monitor.
let r = Rect::from_min_size(pos2(row.max.x - 30.0, row.min.y + 4.0), vec2(22.0, 22.0));
let resp = ui.interact(r, egui::Id::new((prefix, "btn-editor")), Sense::click()).on_hover_text(tl!("Button Editor"));
app.auto.add(&format!("{prefix}.transport.editor"), r, "Button Editor");
icons::paint(ui.painter(), r.shrink(5.0), Icon::Plus, if resp.hovered() { t.tab_text_active } else { t.text_dim });
let mut edited_hidden = hidden.clone();
egui::Popup::menu(&resp).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| {
ui.set_min_width(225.0);
for &(_, cmd, tip) in &buttons {
let mut visible = transport_visible(&edited_hidden, cmd);
let checkbox = ui.checkbox(&mut visible, tip);
app.auto.add(&format!("{prefix}.transport.editor.{cmd}"), checkbox.rect, tip);
if checkbox.changed() {
set_transport_visible(&mut edited_hidden, cmd, visible);
}
}
ui.separator();
let reset = ui.button(tl!("Reset"));
app.auto.add(&format!("{prefix}.transport.editor.reset"), reset.rect, "Reset");
if reset.clicked() {
edited_hidden = crate::state::MonitorView::default().transport_hidden;
}
});
if edited_hidden != hidden {
monitor_view::view_mut(app, which).transport_hidden = edited_hidden;
}
}
fn source_nav(app: &mut FilmcraftApp, cmd: &str) {
@ -797,6 +846,21 @@ mod tests {
Rgba { w, h, px: rgba.iter().copied().cycle().take(w * h * 4).collect() }
}
#[test]
fn button_editor_updates_visibility_without_affecting_other_monitor() {
let mut program = crate::state::MonitorView::default().transport_hidden;
let source = program.clone();
assert!(!transport_visible(&program, "playback.loop"));
set_transport_visible(&mut program, "playback.loop", true);
set_transport_visible(&mut program, "playback.loop", true);
assert!(transport_visible(&program, "playback.loop"));
assert!(program.is_empty(), "enabling twice must not leave hidden entries");
set_transport_visible(&mut program, "playhead.stepForward", false);
set_transport_visible(&mut program, "playhead.stepForward", false);
assert_eq!(program, vec!["playhead.stepForward"]);
assert!(transport_visible(&source, "playhead.stepForward"));
}
#[test]
fn eyedropper_averages_the_pixels_around_the_point() {
let mut img = flat(8, 8, [0, 200, 0, 255]);

View File

@ -254,6 +254,21 @@ pub fn visible_items(app: &FilmcraftApp, inst: Inst) -> Vec<ItemId> {
crate::panels::project_views::items_in_view(app, &v, &app.ui.project_search.to_ascii_lowercase())
}
/// The command Select All (Cmd+A) runs with the Project panel focused: the items of the shown
/// bin, plus those of sub-bins twirled open in List view (`project.selectAll {bin, expanded}`).
/// Items in closed bins and in bins of other tabs stay unselected, as in Premiere (#456). While a
/// search filters the panel, exactly the rows it shows (`project.select {items}`).
pub fn select_all_command(app: &FilmcraftApp) -> (String, Value) {
let inst = shown_inst(app);
if !app.ui.project_search.is_empty() {
let items: Vec<u64> = visible_items(app, inst).into_iter().map(|i| i.0).collect();
return ("project.select".into(), json!({"items": items}));
}
let v = view_of(app, inst);
let expanded: &[u64] = if v.mode == ViewMode::List { &app.ui.expanded_bins } else { &[] };
("project.selectAll".into(), json!({"bin": v.bin.0, "expanded": expanded}))
}
// ------------------------------------------------------------------------------------ panel
pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
@ -609,7 +624,10 @@ fn footer(app: &mut FilmcraftApp, ui: &mut egui::Ui, bar: Rect, v: &View, action
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})));
}
"project.delete" if app.session.state.project_selection.is_empty() => app.ui.status = tl!("Select items to clear").into(),
"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)),
None => app.ui.status = tl!("Select items to clear").into(),
},
_ => actions.push((id.into(), json!({}))),
}
}
@ -673,6 +691,7 @@ pub fn panel_menu(app: &mut FilmcraftApp, ui: &mut egui::Ui) -> bool {
let inst = shown_inst(app);
let v = view_of(app, inst);
let has_sel = !app.session.state.project_selection.is_empty() || app.ui.project_panel.selected_bin.is_some();
let clear = crate::panels::project_views::clear_params(&app.session.state.project_selection, app.ui.project_panel.selected_bin);
let has_project_path = app.session.path.is_some();
let sc = |id: &str| app.session.shortcuts.primary(id);
let (s_close, s_save, s_bin, s_find) = (sc("file.closeProject"), sc("file.save"), sc("file.newBin"), sc("edit.find"));
@ -712,8 +731,8 @@ pub fn panel_menu(app: &mut FilmcraftApp, ui: &mut egui::Ui) -> bool {
if item(ui, "rename", tl!("Rename"), has_sel, None) {
actions.push(("projectPanel.rename".into(), json!({})));
}
if item(ui, "delete", tl!("Delete"), !app.session.state.project_selection.is_empty(), Some("Backspace")) {
actions.push(("project.delete".into(), json!({})));
if item(ui, "delete", tl!("Delete"), clear.is_some(), Some("Backspace")) {
actions.push(("project.delete".into(), clear.unwrap_or_default()));
}
ui.separator();
if item(ui, "automateToSequence", tl!("Automate to Sequence…"), !app.session.state.project_selection.is_empty(), None) {

View File

@ -314,6 +314,20 @@ fn bin_menu(app: &mut FilmcraftApp, ui: &mut egui::Ui, v: &View, bin: u64, actio
actions.push(("file.newBin".into(), json!({"name": "New Bin", "parent": bin})));
ui.close();
}
ui.separator();
if ui.button(tl!("Clear")).clicked() {
actions.push(("project.delete".into(), json!({"items": [bin]})));
ui.close();
}
}
/// Parameters for `project.delete`: the selected items, else the selected bin by id (the engine
/// deletes a named bin with everything in it). `None` when nothing is selected.
pub fn clear_params(selection: &[filmcraft_project::ItemId], selected_bin: Option<u64>) -> Option<serde_json::Value> {
if !selection.is_empty() {
return Some(json!({}));
}
selected_bin.map(|b| json!({"items": [b]}))
}
/// Drop project items dragged onto a bin: move them (the selection when the dragged item is in it).
@ -422,6 +436,7 @@ pub fn list_view(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, v: &View
let root = app.session.project.root.find_bin(v.bin).cloned().unwrap_or_default();
let out = egui::ScrollArea::both().id_salt((&v.prefix, "list-scroll")).auto_shrink([false, false]).show(&mut bui, |ui| {
ui.set_min_width(lc.width);
let bg = background(ui, v);
let mut row = 0usize;
list_bin(app, ui, &root, 0, filter, &mut row, actions, v, &lc);
if v.inst == Inst::Main && v.bin == app.session.project.root.id {
@ -430,7 +445,7 @@ pub fn list_view(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, v: &View
};
crate::panels::menu_dialogs::search_bin_rows(app, ui, &mut row, actions, &mut draw);
}
empty_space(app, ui, v, actions);
empty_space(app, ui, v, &bg, actions);
});
// pinned header, scrolled horizontally with the rows
let hr = Rect::from_min_size(rect.min, vec2(rect.width(), header_h));
@ -491,21 +506,88 @@ pub fn list_view(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, v: &View
}
}
/// Empty space under the rows / cards: click deselects, double-click imports, right-click menu.
fn empty_space(app: &mut FilmcraftApp, ui: &mut egui::Ui, v: &View, actions: &mut Actions) {
/// The List / Icon view background, under the rows and cards (call it before drawing them, so they
/// sit on top). Starts the frame's [`note_hit`] list.
fn background(ui: &egui::Ui, v: &View) -> egui::Response {
ui.data_mut(|d| d.remove::<Vec<(u64, Rect)>>(hits_id(v)));
ui.interact(ui.clip_rect(), egui::Id::new((&v.prefix, "background")), Sense::click_and_drag())
}
/// Empty space under the rows / cards, and the gaps between them (`bg`): click deselects,
/// double-click imports, right-click menu, drag selects with a marquee.
fn empty_space(app: &mut FilmcraftApp, ui: &mut egui::Ui, v: &View, bg: &egui::Response, actions: &mut Actions) {
let rest = ui.available_rect_before_wrap();
let r = Rect::from_min_size(rest.min, vec2(rest.width(), rest.height().max(40.0)));
let resp = ui.allocate_rect(r, Sense::click());
ui.allocate_rect(r, Sense::hover());
app.auto.add(&format!("{}.empty", v.prefix), r.intersect(ui.clip_rect()), "Empty area");
if resp.clicked() {
marquee(app, ui, v, bg, ui.max_rect().min, actions);
if bg.clicked() {
app.ui.project_panel.selected_bin = None;
actions.push(("project.select".into(), json!({"items": []})));
}
if resp.double_clicked() {
if bg.double_clicked() {
actions.push(("file.import".into(), json!({})));
}
let bin = (v.bin != app.session.project.root.id).then_some(v.bin.0);
resp.context_menu(|ui| background_menu(app, ui, v, bin, actions));
bg.context_menu(|ui| background_menu(app, ui, v, bin, actions));
}
fn hits_id(v: &View) -> egui::Id {
egui::Id::new((&v.prefix, "marquee-hits"))
}
/// Remember where an item's row or card was drawn this frame, for [`marquee`].
fn note_hit(ui: &egui::Ui, v: &View, id: ItemId, r: Rect) {
ui.data_mut(|d| d.get_temp_mut_or_default::<Vec<(u64, Rect)>>(hits_id(v)).push((id.0, r)));
}
/// Marquee selection: dragging `bg` (empty space) selects every item drawn this frame whose rect
/// the rectangle touches, live. Shift/Ctrl add to the selection the drag started with; Escape
/// cancels and restores it. `origin` is the content's top-left, so scrolling keeps the anchor.
fn marquee(app: &mut FilmcraftApp, ui: &egui::Ui, v: &View, bg: &egui::Response, origin: egui::Pos2, actions: &mut Actions) {
// (press point relative to `origin`, selection when the drag began, adds to it)
type Drag = (egui::Vec2, Vec<u64>, bool);
let key = egui::Id::new((&v.prefix, "marquee"));
let hits: Vec<(u64, Rect)> = ui.data_mut(|d| d.remove_temp(hits_id(v))).unwrap_or_default();
let current: Vec<u64> = app.session.state.project_selection.iter().map(|i| i.0).collect();
// the left button only: right-click opens the menu, the middle button is not a selection
let primary = egui::PointerButton::Primary;
if bg.drag_started_by(primary) {
let press = ui.input(|i| i.pointer.press_origin()).unwrap_or(origin);
let m = ui.input(|i| i.modifiers);
let add = m.shift || m.command;
if !add {
app.ui.project_panel.selected_bin = None;
}
ui.data_mut(|d| d.insert_temp::<Drag>(key, (press - origin, current.clone(), add)));
}
let Some((anchor, before, add)) = ui.data(|d| d.get_temp::<Drag>(key)) else { return };
let cancel = ui.input(|i| i.key_pressed(egui::Key::Escape));
if cancel || !(bg.dragged_by(primary) || bg.drag_stopped_by(primary)) {
ui.data_mut(|d| d.remove::<Drag>(key));
if cancel && before != current {
actions.push(("project.select".into(), json!({"items": before})));
}
return;
}
let Some(now) = ui.ctx().pointer_latest_pos() else { return };
let r = Rect::from_two_pos(origin + anchor, now);
let mut sel = if add { before } else { Vec::new() };
for (id, hr) in &hits {
if hr.intersects(r) && !sel.contains(id) {
sel.push(*id);
}
}
if sel != current {
actions.push(("project.select".into(), json!({"items": sel})));
}
let t = app.tokens;
ui.painter().rect_filled(r, 0.0, t.accent.gamma_multiply(0.18));
ui.painter().rect_stroke(r, 0.0, Stroke::new(1.0, t.accent), StrokeKind::Inside);
app.auto.add(&format!("{}.marquee", v.prefix), r, "Marquee");
if bg.drag_stopped_by(primary) {
ui.data_mut(|d| d.remove::<Drag>(key));
}
}
fn background_menu(app: &mut FilmcraftApp, ui: &mut egui::Ui, v: &View, bin: Option<u64>, actions: &mut Actions) {
@ -580,6 +662,7 @@ fn list_bin(app: &mut FilmcraftApp, ui: &mut egui::Ui, bin: &Bin, depth: usize,
}
let Some(it) = app.session.project.item(id).cloned() else { continue };
let (r, resp) = ui.allocate_exact_size(vec2(lc.width, lc.row_h), Sense::click_and_drag());
note_hit(ui, v, id, r);
let selected = app.session.state.project_selection.contains(&id);
if selected {
ui.painter().rect_filled(r, 0.0, t.row_selected);
@ -806,6 +889,7 @@ pub fn icon_view(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, v: &View
};
let n = bins.len() + ids.len();
egui::ScrollArea::vertical().id_salt((&v.prefix, "icon-scroll")).auto_shrink([false, false]).show(ui, |ui| {
let bg = background(ui, v);
let per_row = ((rect.width() - 12.0) / cell.x).floor().max(1.0) as usize;
if v.inst == Inst::Main {
app.ui.keys.icon_columns = per_row;
@ -827,13 +911,14 @@ pub fn icon_view(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, v: &View
let Some(kind) = app.session.project.item(id).map(|x| x.kind.clone()) else { continue };
let was_selected = app.session.state.project_selection.contains(&id);
if let Some(resp) = card(app, ui, r, id, &v.prefix, true, true, Sense::click_and_drag()) {
note_hit(ui, v, id, r);
let name_r = Rect::from_min_max(pos2(r.min.x, r.min.y + th_h), r.max);
slow_click_rename(app, ui, &resp, name_r, id, was_selected);
item_interactions(app, ui, &resp, id, &kind, actions, true, true);
}
}
}
empty_space(app, ui, v, actions);
empty_space(app, ui, v, &bg, actions);
});
}
@ -856,7 +941,8 @@ pub fn freeform_view(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, v: &
};
let extent = cards.iter().fold(vec2(rect.width(), rect.height()), |e, c| vec2(e.x.max(c.x + c.size + 40.0), e.y.max(c.y + c.size * 9.0 / 16.0 + 60.0)));
egui::ScrollArea::both().id_salt((&v.prefix, "ff-scroll")).auto_shrink([false, false]).show(ui, |ui| {
let (canvas, bg) = ui.allocate_exact_size(extent, Sense::click());
ui.data_mut(|d| d.remove::<Vec<(u64, Rect)>>(hits_id(v)));
let (canvas, bg) = ui.allocate_exact_size(extent, Sense::click_and_drag());
app.auto.add(&format!("{}.freeform", v.prefix), canvas, "Freeform canvas");
if opts.snap {
let g = opts.grid.max(4.0);
@ -888,6 +974,7 @@ pub fn freeform_view(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, v: &
let r = Rect::from_min_size(canvas.min + vec2(c.x, c.y) + off, vec2(c.size, c.size * 9.0 / 16.0 + 20.0));
let Some(kind) = app.session.project.item(ItemId(c.item)).map(|x| x.kind.clone()) else { continue };
let Some(resp) = card(app, ui, r, ItemId(c.item), &v.prefix, opts.show_names, opts.show_durations, Sense::click_and_drag()) else { continue };
note_hit(ui, v, ItemId(c.item), r);
if let Some(s) = c.stack
&& s == c.item
&& let Some(n) = stack_n.get(&s)
@ -914,6 +1001,7 @@ pub fn freeform_view(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, v: &
}
resp.context_menu(|ui| card_menu(app, ui, v, c.item, actions));
}
marquee(app, ui, v, &bg, canvas.min, actions);
if let Some((item, off)) = released {
ui.ctx().data_mut(|d| d.remove::<(u64, egui::Vec2)>(drag_key));
let inside = ui.ctx().pointer_latest_pos().is_some_and(|p| rect.contains(p));
@ -1033,3 +1121,17 @@ fn canvas_menu(app: &mut FilmcraftApp, ui: &mut egui::Ui, v: &View, bin: Option<
ui.separator();
background_menu(app, ui, v, bin.filter(|b| *b != app.session.project.root.id.0), actions);
}
#[cfg(test)]
mod clear_tests {
use super::clear_params;
use filmcraft_project::ItemId;
use serde_json::json;
#[test]
fn clear_falls_back_to_the_selected_bin() {
assert_eq!(clear_params(&[], Some(7)), Some(json!({"items": [7]})));
assert_eq!(clear_params(&[ItemId(3)], Some(7)), Some(json!({})));
assert_eq!(clear_params(&[], None), None);
}
}

View File

@ -785,6 +785,30 @@ mod tests {
use super::*;
use crate::theme::ThemeKind;
/// #457: on a desktop that reports 150%, UI Scale 100% makes a point one physical pixel. It is
/// applied once, so Ctrl+- still zooms for the session, and Auto gives the system's scale back.
#[test]
fn ui_scale_overrides_the_system_scale_and_auto_restores_it() {
let ctx = egui::Context::default();
let mut app = FilmcraftApp::new(filmcraft_engine::Session::default());
// a frame applying the settings, then the next one, where a new zoom takes effect
let pass = |app: &mut FilmcraftApp| {
let mut input = egui::RawInput::default();
input.viewports.entry(egui::ViewportId::ROOT).or_default().native_pixels_per_point = Some(1.5);
ctx.run_ui(input.clone(), |ui| app.apply_prefs(ui.ctx())).textures_delta.clear();
ctx.run_ui(input, |_| {}).textures_delta.clear();
ctx.zoom_factor()
};
assert_eq!(pass(&mut app), 1.0, "Auto keeps the system's scale");
app.session.execute("prefs.set", json!({"key": "appearance.uiScale", "value": "100"})).unwrap();
assert_eq!(pass(&mut app), 1.0 / 1.5, "100% is one physical pixel per point");
ctx.set_zoom_factor(0.9 / 1.5); // Ctrl+-
assert_eq!(pass(&mut app), 0.9 / 1.5, "a session zoom isn't undone every frame");
assert!(app.session.execute("prefs.set", json!({"key": "appearance.uiScale", "value": "130"})).is_err());
app.session.execute("prefs.set", json!({"key": "appearance.uiScale", "value": "auto"})).unwrap();
assert_eq!(pass(&mut app), 1.0);
}
#[test]
fn appearance_button_cycles_modes_without_losing_theme_choices() {
let ctx = egui::Context::default();

View File

@ -19,7 +19,7 @@ use serde_json::{Value, json};
use crate::FilmcraftApp;
use crate::icons::{self, Icon};
use crate::state::{TimelineView, Tool};
use crate::state::{ThumbnailMode, TimelineView, Tool};
use crate::theme::Tokens;
use super::timeline_hit::{EdgeKind, Grab, edge_geometry, grab_at};
@ -604,6 +604,36 @@ fn in_range(app: &FilmcraftApp, it: &TrackItem) -> bool {
it.start < b && it.end() > a
}
/// Most thumbnails one clip draws: a long clip far zoomed in only shows the visible ones anyway.
const MAX_THUMBNAIL_TILES: usize = 256;
/// The video thumbnails of a clip whose picture area spans `x0..x1` (points), for frames `tile_w`
/// wide: the left edge of each tile and the offset into the clip (`dur` long, frames of `frame`)
/// whose picture it shows. Only tiles that meet `visible` (the panel's x range) are listed.
/// Continuous tiles sit on a grid anchored at the clip's head, so scrolling only reveals new tiles
/// and the others keep their frames. A clip too narrow for more than one tile shows its head.
pub fn thumbnail_tiles(mode: ThumbnailMode, x0: f32, x1: f32, tile_w: f32, visible: (f32, f32), dur: Tick, frame: Tick) -> Vec<(f32, Tick)> {
let width = x1 - x0;
if !(x0.is_finite() && width.is_finite() && width > 0.0 && tile_w.is_finite() && tile_w >= 1.0) {
return Vec::new();
}
let last = (dur - frame).max(Tick::ZERO);
let offset_at = |x: f32| Tick(((x - x0) as f64 / width as f64 * dur.0 as f64).round() as i64).clamp(Tick::ZERO, last);
let shown = |x: f32| x < visible.1 && x + tile_w > visible.0;
let head = (x0, Tick::ZERO);
let tiles = match mode {
ThumbnailMode::Head => vec![head],
ThumbnailMode::HeadAndTail if width >= 2.0 * tile_w => vec![head, (x1 - tile_w, last)],
ThumbnailMode::HeadAndTail => vec![head],
ThumbnailMode::Continuous => {
let first = ((visible.0 - x0).max(0.0) / tile_w).floor() as usize;
let end = ((visible.1.min(x1) - x0) / tile_w).ceil().max(1.0) as usize;
(first..end).take(MAX_THUMBNAIL_TILES).map(|k| x0 + k as f32 * tile_w).map(|x| (x, offset_at(x))).collect()
}
};
tiles.into_iter().filter(|(x, _)| shown(*x)).collect()
}
#[allow(clippy::too_many_arguments)]
fn draw_clip(
app: &mut FilmcraftApp,
@ -632,7 +662,8 @@ fn draw_clip(
p.line_segment([pos2(body.min.x, body.min.y + 0.5), pos2(body.max.x, body.min.y + 0.5)], Stroke::new(1.0, lighten(fill, 0.15)));
let name_h = 16.0;
let w = body.width();
// head thumbnail (video): left-aligned, aspect-correct, below the name band
// video thumbnails, aspect-correct, below the name band: the head frame, head and tail, or
// frames side by side across the clip (Show Video Thumbnails in the wrench menu)
if kind == TrackKind::Video && app.ui.timeline.show_thumbnails && body.height() > 28.0 && w > 20.0 && it.enabled {
let th = Rect::from_min_max(pos2(body.min.x + 1.0, body.min.y + name_h), pos2(body.max.x - 1.0, body.max.y - 1.0));
let aspect = app.session.project.item(it.item).and_then(|pi| match &pi.kind {
@ -640,24 +671,28 @@ fn draw_clip(
filmcraft_project::ItemKind::Sequence(s) => Some(s.settings.width as f32 / s.settings.height as f32),
_ => None,
});
if let Some(aspect) = aspect {
let tw = (th.height() * aspect).min(th.width());
let mt = rate.snap(it.source_in);
// a multi-camera clip shows its angle's clip
let (thumb_item, mt) = app
.session
.project
.sequence(it.item)
.and_then(|q| {
let ti = q.angle_video_track_index(it.multicam_angle(q)?)?;
let inner = q.video_tracks[ti].item_at(mt)?;
Some((inner.item, inner.source_time_at(mt)))
})
.unwrap_or((it.item, mt));
if let Some((tex, _)) = app.thumbnail(ctx, thumb_item, mt, 160) {
let r = Rect::from_min_size(th.min, vec2(tw, th.height()));
let cp = p.with_clip_rect(th.intersect(p.clip_rect()));
cp.image(tex, r, Rect::from_min_max(pos2(0.0, 0.0), pos2((tw / (th.height() * aspect)).min(1.0), 1.0)), Color32::WHITE);
if let Some(aspect) = aspect.filter(|a| a.is_finite() && *a > 0.0) {
let tw = th.height() * aspect;
let cp = p.with_clip_rect(th.intersect(p.clip_rect()));
let visible = (cp.clip_rect().min.x, cp.clip_rect().max.x);
let mode = app.ui.timeline.thumbnail_mode;
for (x, offset) in thumbnail_tiles(mode, th.min.x, th.max.x, tw, visible, it.duration, rate.frame_duration()) {
let mt = rate.snap(it.source_time_at(it.start + offset));
// a multi-camera clip shows its angle's clip
let (thumb_item, mt) = app
.session
.project
.sequence(it.item)
.and_then(|q| {
let ti = q.angle_video_track_index(it.multicam_angle(q)?)?;
let inner = q.video_tracks.get(ti)?.item_at(mt)?;
Some((inner.item, inner.source_time_at(mt)))
})
.unwrap_or((it.item, mt));
if let Some((tex, _)) = app.thumbnail(ctx, thumb_item, mt, 160) {
let r = Rect::from_min_size(pos2(x, th.min.y), vec2(tw, th.height()));
cp.image(tex, r, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
}
}
}
}
@ -1109,6 +1144,38 @@ 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 };
@ -1179,7 +1246,20 @@ fn draw_top(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, seq: &Sequenc
}
if key == "settings" {
egui::Popup::menu(&resp).show(|ui| {
ui.checkbox(&mut app.ui.timeline.show_thumbnails, tl!("Show Video Thumbnails"));
let c = ui.checkbox(&mut app.ui.timeline.show_thumbnails, tl!("Show Video Thumbnails"));
app.auto.add("timeline.settings.showThumbnails", c.rect, "Show Video Thumbnails");
let on = app.ui.timeline.show_thumbnails;
ui.indent("timeline.settings.thumbnails", |ui| {
for (mode, label) in
[(ThumbnailMode::Head, tl!("Head")), (ThumbnailMode::HeadAndTail, tl!("Head and Tail")), (ThumbnailMode::Continuous, tl!("Continuous"))]
{
let r = ui.add_enabled(on, egui::RadioButton::new(app.ui.timeline.thumbnail_mode == mode, label));
app.auto.add(&format!("timeline.settings.thumbnails.{}", mode.name()), r.rect, label);
if r.clicked() {
app.ui.timeline.thumbnail_mode = mode;
}
}
});
ui.checkbox(&mut app.ui.timeline.show_waveforms, tl!("Show Audio Waveform"));
ui.separator();
let mut te = app.session.state.show_through_edits;
@ -1963,14 +2043,9 @@ 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}));
} 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]}));
}
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}));
}
if resp.drag_started() {
let clips = app.session.state.selection.clone();
@ -1996,7 +2071,9 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: &
}
}
(_, Grab::Other(Hit::Transition { .. })) => None,
(_, Grab::Other(Hit::Empty { .. })) => {
// empty space: an empty stretch of a track, or no track at all (below the tracks, between
// the video and audio tracks, #683)
(_, Grab::Other(Hit::Empty { .. } | Hit::None)) if layout.content.contains(p) => {
if resp.drag_started() {
Some(Drag::Marquee { start: p })
} else {
@ -2006,7 +2083,7 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: &
None
}
}
(_, Grab::Other(Hit::None)) => None,
(_, Grab::Other(Hit::Empty { .. } | Hit::None)) => None,
};
if let Some(d) = started {
if matches!(d, Drag::Scrub) {
@ -2524,3 +2601,47 @@ mod chain_starts_tests {
assert_eq!(chain_starts(Tick(5), &[Tick(-3), Tick(4)]), vec![Tick(5), Tick(5)]);
}
}
#[cfg(test)]
mod thumbnail_tiles_tests {
use super::*;
const S: Tick = Tick(TICKS_PER_SECOND);
const FRAME: Tick = Tick(TICKS_PER_SECOND / 25);
#[test]
fn continuous_thumbnails_tile_the_visible_part_of_the_clip() {
// a 10 s clip 1000 pt wide, 80 pt tiles, the panel showing x 250..650
let tiles = thumbnail_tiles(ThumbnailMode::Continuous, 0.0, 1000.0, 80.0, (250.0, 650.0), Tick(10 * S.0), FRAME);
let xs: Vec<f32> = tiles.iter().map(|t| t.0).collect();
assert_eq!(xs, [240.0, 320.0, 400.0, 480.0, 560.0, 640.0]);
// each tile shows the clip time under its left edge (100 pt per second)
assert_eq!(tiles[0].1, Tick(24 * S.0 / 10));
assert_eq!(tiles[5].1, Tick(64 * S.0 / 10));
// scrolling 30 pt moves the tiles with the clip: the ones still shown keep their frames
let scrolled = thumbnail_tiles(ThumbnailMode::Continuous, -30.0, 970.0, 80.0, (250.0, 650.0), Tick(10 * S.0), FRAME);
assert_eq!(scrolled.first(), Some(&(210.0, tiles[0].1)));
assert!(scrolled.iter().all(|(x, t)| tiles.iter().any(|(x0, t0)| *x0 - 30.0 == *x && t0 == t)));
// the last tile never shows a time past the clip's last frame
let end = thumbnail_tiles(ThumbnailMode::Continuous, 0.0, 1000.0, 80.0, (0.0, 2000.0), Tick(10 * S.0), FRAME);
assert_eq!(end.len(), 13);
assert!(end.iter().all(|(_, t)| *t <= Tick(10 * S.0) - FRAME));
// a clip far zoomed in asks for a bounded number of frames
let huge = thumbnail_tiles(ThumbnailMode::Continuous, -1.0e9, 1.0e9, 1.0, (-2.0e9, 2.0e9), Tick(10 * S.0), FRAME);
assert_eq!(huge.len(), MAX_THUMBNAIL_TILES);
}
#[test]
fn head_and_tail_thumbnails_need_room_for_two_frames() {
let dur = Tick(4 * S.0);
assert_eq!(thumbnail_tiles(ThumbnailMode::Head, 100.0, 500.0, 80.0, (0.0, 800.0), dur, FRAME), [(100.0, Tick::ZERO)]);
assert_eq!(thumbnail_tiles(ThumbnailMode::HeadAndTail, 100.0, 500.0, 80.0, (0.0, 800.0), dur, FRAME), [(100.0, Tick::ZERO), (420.0, dur - FRAME)]);
// narrower than two tiles: the head only, as in Continuous narrower than one tile
assert_eq!(thumbnail_tiles(ThumbnailMode::HeadAndTail, 100.0, 250.0, 80.0, (0.0, 800.0), dur, FRAME), [(100.0, Tick::ZERO)]);
assert_eq!(thumbnail_tiles(ThumbnailMode::Continuous, 100.0, 150.0, 80.0, (0.0, 800.0), dur, FRAME), [(100.0, Tick::ZERO)]);
// off screen or degenerate: nothing
assert!(thumbnail_tiles(ThumbnailMode::Head, 900.0, 1300.0, 80.0, (0.0, 800.0), dur, FRAME).is_empty());
assert!(thumbnail_tiles(ThumbnailMode::Continuous, 0.0, 400.0, f32::NAN, (0.0, 800.0), dur, FRAME).is_empty());
assert!(thumbnail_tiles(ThumbnailMode::Continuous, 0.0, 0.0, 80.0, (0.0, 800.0), dur, FRAME).is_empty());
}
}

View File

@ -244,9 +244,12 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeColumn);
}
if resp.dragged() {
// read the delta before locking: drag_delta() takes the context's write lock,
// the same one data_mut() holds, and that lock is not reentrant (deadlock)
let dx = resp.drag_delta().x;
let total = ui.ctx().data_mut(|d| {
let v = d.get_temp_mut_or_default::<f32>(egui::Id::new("trim-monitor-dx"));
*v += resp.drag_delta().x;
*v += dx;
*v
});
let n = (total * fpp).round() as i64;

View File

@ -30,7 +30,16 @@ impl FilmcraftApp {
}
pub fn play_source(&mut self) -> Result<(), String> {
self.start_source_playback(None, None)
self.start_source_playback(None, None, 1.0)
}
/// J / K / L in the Source monitor: play at `speed` × real time (negative = backward) from the
/// Source playhead. Sound plays at normal forward speed only, as in the Program monitor.
pub fn shuttle_source(&mut self, speed: f64) -> Result<(), String> {
if !speed.is_finite() || speed == 0.0 {
return Err("Source shuttle speed must be a non-zero number".into());
}
self.start_source_playback(None, None, speed.clamp(-8.0, 8.0))
}
pub fn play_source_range(&mut self, from_playhead: bool, preroll: bool) -> Result<(), String> {
@ -53,10 +62,10 @@ impl FilmcraftApp {
if start >= stop {
return Err("Source playhead is at or beyond the Out point".into());
}
self.start_source_playback(Some(start), Some(stop))
self.start_source_playback(Some(start), Some(stop), 1.0)
}
fn start_source_playback(&mut self, start: Option<Tick>, stop: Option<Tick>) -> Result<(), String> {
fn start_source_playback(&mut self, start: Option<Tick>, stop: Option<Tick>, speed: f64) -> Result<(), String> {
let item = self.session.state.source_item.ok_or("Open a media file in the Source monitor first")?;
let view = source_view(&self.session, item).ok_or("Source item is unavailable")?;
let media = self.session.project.item(view.media).and_then(|i| i.as_media()).ok_or("Source playback currently supports media files and subclips")?;
@ -76,7 +85,8 @@ impl FilmcraftApp {
self.stop();
self.stop_source();
let mut time = start.unwrap_or(self.session.state.source_playhead);
if start.is_none() && (time < first || time >= view.rate.snap(Tick(end.0.saturating_sub(view.rate.frame_duration().0))).max(first)) {
// forward from the end (or before a looped range) starts over; backward runs from where it is
if start.is_none() && speed > 0.0 && (time < first || time >= view.rate.snap(Tick(end.0.saturating_sub(view.rate.frame_duration().0))).max(first)) {
time = first;
}
self.session.execute("source.setPlayhead", json!({"time": time.0})).map_err(|e| e.to_string())?;
@ -84,8 +94,8 @@ impl FilmcraftApp {
self.source_playback.range_start = start;
self.source_playback.item = Some(item);
self.source_playback.shown = time;
self.source_playback.clock = Playback { playing: true, speed: 1.0, anchor_time: -1.0, anchor_tick: time, looping, stop_at: stop, ..Default::default() };
self.source_playback.clock.meter.start(1.0);
self.source_playback.clock = Playback { playing: true, speed, anchor_time: -1.0, anchor_tick: time, looping, stop_at: stop, ..Default::default() };
self.source_playback.clock.meter.start(speed);
self.start_source_audio();
Ok(())
}
@ -105,6 +115,12 @@ impl FilmcraftApp {
fn start_source_audio(&mut self) {
self.source_playback.clock.audio_clock = false;
if (self.source_playback.clock.speed - 1.0).abs() > 1e-9 {
if let Some(audio) = self.audio.as_mut() {
audio.stop();
}
return;
}
let Some(item) = self.source_playback.item else { return };
let Some(view) = source_view(&self.session, item) else { return };
let Some(media) = self.session.project.item(view.media).and_then(|i| i.as_media()) else { return };
@ -221,7 +237,24 @@ impl FilmcraftApp {
Some((frames, sr)) if clock.audio_clock && sr > 0 => frames as f64 / f64::from(sr),
_ => (now - clock.anchor_time).max(0.0),
};
let next = Tick(clock.anchor_tick.0.saturating_add(Tick::from_seconds_f64(elapsed).0));
let next = Tick(clock.anchor_tick.0.saturating_add(Tick::from_seconds_f64(elapsed * clock.speed).0));
if clock.speed < 0.0 {
// backward: stops on the first frame (as the Program monitor stops at the start)
let ended = next <= view.start;
match self.session.execute("source.setPlayhead", json!({"time": next.max(view.start).0})) {
Ok(_) => self.source_playback.shown = self.session.state.source_playhead,
Err(e) => {
self.stop_source();
self.ui.status = e.to_string();
return;
}
}
if ended {
self.stop_source();
}
ctx.request_repaint();
return;
}
let range = view.selected_range();
let end = clock.stop_at.unwrap_or_else(|| if clock.looping { range.end() } else { view.end }).min(view.end);
let ended = next >= end;

View File

@ -190,6 +190,9 @@ pub struct TimelineView {
pub audio_track_h: f32,
pub header_w: f32,
pub show_thumbnails: bool,
/// Which frames a video clip shows while `show_thumbnails` is on (the wrench menu).
#[serde(default)]
pub thumbnail_mode: ThumbnailMode,
pub show_waveforms: bool,
/// Follow playhead during playback (page scroll).
pub follow: bool,
@ -225,6 +228,7 @@ impl Default for TimelineView {
audio_track_h: 56.0,
header_w: 204.0,
show_thumbnails: true,
thumbnail_mode: ThumbnailMode::Head,
show_waveforms: true,
follow: true,
fit_pending: true,
@ -234,6 +238,35 @@ impl Default for TimelineView {
}
}
/// Video thumbnails on timeline clips (Timeline wrench menu ▸ Show Video Thumbnails).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum ThumbnailMode {
/// The clip's first frame at its head.
#[default]
Head,
/// The first frame at the head and the last one at the tail.
HeadAndTail,
/// Frames side by side across the whole clip.
Continuous,
}
impl ThumbnailMode {
pub const ALL: [ThumbnailMode; 3] = [ThumbnailMode::Head, ThumbnailMode::HeadAndTail, ThumbnailMode::Continuous];
/// The name used by automation ids and `ui.set` (`timeline.thumbnails`).
pub fn name(self) -> &'static str {
match self {
ThumbnailMode::Head => "head",
ThumbnailMode::HeadAndTail => "headAndTail",
ThumbnailMode::Continuous => "continuous",
}
}
pub fn from_name(name: &str) -> Option<ThumbnailMode> {
Self::ALL.into_iter().find(|m| m.name().eq_ignore_ascii_case(name))
}
}
/// Monitor display mode (View ▸ Display Mode / the monitor's wrench menu). Multi-Camera is the
/// separate `MonitorView::multicam` switch.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
@ -277,6 +310,8 @@ pub struct MonitorView {
pub pan: [f32; 2],
pub safe_margins: bool,
pub show_transport: bool,
/// Transport buttons hidden through the monitor's Button Editor. Stored per monitor.
pub transport_hidden: Vec<String>,
/// Program monitor display mode Multi-Camera (angle grid + program).
pub multicam: bool,
pub display: DisplayMode,
@ -300,6 +335,8 @@ impl Default for MonitorView {
pan: [0.0, 0.0],
safe_margins: false,
show_transport: true,
// The optional Loop button is available from the editor without altering default layouts.
transport_hidden: vec!["playback.loop".into()],
multicam: false,
display: DisplayMode::Composite,
compare_ref: None,
@ -368,6 +405,19 @@ pub struct Eyedropper {
pub mask: Option<usize>,
}
/// A keyframe selected in the Effect Controls keyframe lane: clip, effect index, parameter (of an
/// effect mask when `mask` is set) and the keyframe's media time. The panel drops references that
/// no longer match a keyframe (deleted, moved, another clip selected).
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct KeyframeRef {
pub clip: u64,
pub effect: usize,
pub param: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mask: Option<usize>,
pub time: filmcraft_time::Tick,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct UiState {
#[serde(default)]
@ -408,6 +458,9 @@ pub struct UiState {
/// monitor (Esc or a click elsewhere disarms it). Never saved.
#[serde(skip)]
pub eyedropper: Option<Eyedropper>,
/// Effect Controls: the selected keyframes, highlighted in the keyframe lane (a click selects one).
#[serde(default)]
pub keyframe_selection: Vec<KeyframeRef>,
/// Essential Sound sub-tab: "Edit" or "Browse".
#[serde(default)]
pub essential_sound_tab: String,
@ -904,6 +957,7 @@ impl Default for UiState {
show_scopes: false,
status: String::new(),
eyedropper: None,
keyframe_selection: Vec::new(),
essential_sound_tab: "Edit".into(),
tts: Default::default(),
export: Default::default(),

View File

@ -0,0 +1,206 @@
//! Paste files and images from the system clipboard (#611): the real `FilmcraftApp` under
//! `egui_kittest`, with a stand-in for the desktop app's clipboard hook.
use std::cell::Cell;
use std::rc::Rc;
use std::sync::mpsc::{Sender, channel};
use egui_kittest::Harness;
use filmcraft_engine::Session;
use filmcraft_ui_egui::control::ControlRequest;
use filmcraft_ui_egui::{ClipboardMedia, FilmcraftApp};
use serde_json::{Value, json};
struct Driver {
harness: Harness<'static, FilmcraftApp>,
tx: Sender<ControlRequest>,
dir: std::path::PathBuf,
}
impl Driver {
/// The demo project saved (in name) in a fresh folder, so pasted images are written there.
fn demo(name: &str) -> Self {
let dir = std::env::temp_dir().join(format!("filmcraft-clipboard-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let mut session = Session::default();
session.execute("file.openDemoProject", json!({})).expect("demo project");
session.path = Some(dir.join("film.fcproj").to_string_lossy().into_owned());
let (tx, rx) = channel();
let app = FilmcraftApp::new(session).with_control(rx);
let harness = Harness::builder().with_size(egui::vec2(1600.0, 980.0)).with_max_steps(10_000).build_eframe(move |_cc| app);
let mut d = Driver { harness, tx, dir };
d.frames(4);
d
}
fn frames(&mut self, n: usize) {
for _ in 0..n {
let ctx = self.harness.ctx.clone();
let mut raw = std::mem::take(self.harness.input_mut());
eframe::App::raw_input_hook(self.harness.state_mut(), &ctx, &mut raw);
*self.harness.input_mut() = raw;
self.harness.step();
}
}
fn ok(&mut self, method: &str, params: Value) -> Value {
let (req, reply) = ControlRequest::new(method, params.clone());
self.tx.send(req).unwrap();
for _ in 0..600 {
self.frames(1);
if let Ok(v) = reply.try_recv() {
assert_eq!(v["ok"], json!(true), "{method} {params} failed: {v}");
return v["result"].clone();
}
}
panic!("no reply to {method} {params}");
}
fn app(&mut self) -> &mut FilmcraftApp {
self.harness.state_mut()
}
/// The clipboard hook hands out `media` (a copy each time); returns how often it was asked.
fn clipboard(&mut self, media: Option<fn() -> ClipboardMedia>) -> Rc<Cell<usize>> {
let asked = Rc::new(Cell::new(0));
let count = asked.clone();
self.app().hooks.clipboard_media = Some(Box::new(move || {
count.set(count.get() + 1);
media.map(|m| m())
}));
asked
}
/// Ctrl+V as egui-winit delivers it when the system clipboard holds no text: the press is
/// dropped, only the release arrives.
fn ctrl_v_without_text(&mut self, modifiers: egui::Modifiers) {
let key = |pressed| egui::Event::Key { key: egui::Key::V, physical_key: Some(egui::Key::V), pressed, repeat: false, modifiers };
self.harness.input_mut().events.extend([egui::Event::ModifiersChanged(modifiers), key(false)]);
self.frames(1);
self.harness.input_mut().events.push(egui::Event::ModifiersChanged(egui::Modifiers::NONE));
self.frames(2);
}
/// Ctrl+V with text on the clipboard: egui-winit sends `Event::Paste`, then the release.
fn ctrl_v_with_text(&mut self) {
let release =
egui::Event::Key { key: egui::Key::V, physical_key: Some(egui::Key::V), pressed: false, repeat: false, modifiers: egui::Modifiers::COMMAND };
self.harness.input_mut().events.extend([egui::Event::ModifiersChanged(egui::Modifiers::COMMAND), egui::Event::Paste("text".into()), release]);
self.frames(1);
self.harness.input_mut().events.push(egui::Event::ModifiersChanged(egui::Modifiers::NONE));
self.frames(2);
}
/// (track kind, track index, clip id, item name, start, end) of every clip, video first.
fn clips(&mut self) -> Vec<(char, usize, u64, String, i64, i64)> {
let q = self.app().session.active_sequence().unwrap().clone();
let mut v = Vec::new();
for (kind, tracks) in [('V', &q.video_tracks), ('A', &q.audio_tracks)] {
for (i, t) in tracks.iter().enumerate() {
v.extend(t.items.iter().map(|c| (kind, i, c.id.0, c.name.clone(), c.start.0, c.end().0)));
}
}
v
}
}
impl Drop for Driver {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.dir);
}
}
fn red_image() -> ClipboardMedia {
ClipboardMedia::Image { width: 32, height: 18, rgba: [220, 30, 30, 255].repeat(32 * 18) }
}
/// Ctrl+V in the Timeline with an image on the clipboard (no text, so only the key release
/// arrives) saves it next to the project and places it at the playhead above the footage,
/// overwriting nothing. One undo takes it back.
#[test]
fn ctrl_v_pastes_an_image_above_the_footage() {
let mut d = Driver::demo("image");
d.ok("ui.set", json!({"focused": "Timeline"}));
d.clipboard(Some(red_image));
let before = d.clips();
let at = d.app().session.playhead().0;
d.ctrl_v_without_text(egui::Modifiers::COMMAND);
let after = d.clips();
for c in &before {
assert!(after.contains(c), "{c:?} is untouched");
}
let new: Vec<_> = after.iter().filter(|c| !before.contains(c)).cloned().collect();
assert_eq!(new.len(), 1, "one clip pasted: {new:?}");
let (kind, track, _, name, start, end) = new[0].clone();
assert_eq!((kind, name.as_str(), start), ('V', "Pasted Image 1.png", at));
assert!(track > 0 && before.iter().any(|c| c.0 == 'V' && c.1 < track && c.4 < end && c.5 > start), "it sits above a clip");
assert!(d.dir.join("Pasted Image 1.png").is_file(), "saved next to the project");
d.ok("ui.menu.invoke", json!({"id": "edit.undo"}));
assert_eq!(d.clips(), before);
}
/// Files copied in the file manager paste the same way, even when the clipboard also holds text
/// (Ctrl+V then arrives as a paste event); Ctrl+Shift+V inserts them on the targeted track.
#[test]
fn copied_files_paste_and_paste_insert_inserts() {
let mut d = Driver::demo("files");
d.ok("ui.set", json!({"focused": "Timeline"}));
let png = d.dir.join("copied.png");
image::RgbaImage::from_pixel(16, 9, image::Rgba([0, 200, 0, 255])).save(&png).unwrap();
let path = png.to_string_lossy().into_owned();
d.app().hooks.clipboard_media = Some(Box::new(move || Some(ClipboardMedia::Files(vec![path.clone()]))));
let before = d.clips();
d.ctrl_v_with_text();
let new: Vec<_> = d.clips().into_iter().filter(|c| !before.contains(c)).collect();
assert_eq!(new.iter().map(|c| c.3.as_str()).collect::<Vec<_>>(), ["copied.png"]);
assert!(new[0].1 > 0, "not over the footage on V1");
// Paste Insert: on V1, pushing the clips after the playhead right
let v1_end = |d: &mut Driver| d.clips().iter().filter(|c| c.0 == 'V' && c.1 == 0).map(|c| c.5).max().unwrap();
let end = v1_end(&mut d);
d.ctrl_v_without_text(egui::Modifiers::COMMAND | egui::Modifiers::SHIFT);
assert!(v1_end(&mut d) > end, "Ctrl+Shift+V inserts on V1");
}
/// In the Project panel Ctrl+V imports into the shown bin and places nothing.
#[test]
fn ctrl_v_in_the_project_panel_imports() {
let mut d = Driver::demo("project");
d.ok("ui.set", json!({"focused": "Project"}));
d.clipboard(Some(red_image));
let (before, items) = (d.clips(), d.app().session.project.items.len());
d.ctrl_v_without_text(egui::Modifiers::COMMAND);
assert_eq!(d.clips(), before, "nothing on the Timeline");
assert_eq!(d.app().session.project.items.len(), items + 1);
assert!(d.app().session.project.items.values().any(|i| i.name == "Pasted Image 1.png"));
}
/// With nothing but clips copied in FilmCraft, Ctrl+V pastes the clips as before, also when the
/// system clipboard holds no text at all. A plain V press and release (the Selection tool) pastes
/// nothing and doesn't read the clipboard.
#[test]
fn without_media_ctrl_v_pastes_copied_clips() {
let mut d = Driver::demo("clips");
d.ok("ui.set", json!({"focused": "Timeline"}));
let asked = d.clipboard(None);
let first = d.clips().into_iter().find(|c| c.0 == 'V' && c.1 == 0).unwrap();
d.ok("engine.execute", json!({"command": "timeline.select", "params": {"clips": [first.2]}}));
d.ok("engine.execute", json!({"command": "edit.copy", "params": {}}));
let copies = |d: &mut Driver| d.clips().iter().filter(|c| c.0 == 'V' && c.3 == first.3).count();
let n = copies(&mut d);
d.ctrl_v_without_text(egui::Modifiers::COMMAND);
assert_eq!(copies(&mut d), n + 1, "the copied clip is pasted");
assert_eq!(asked.get(), 1);
for pressed in [true, false] {
d.harness.input_mut().events.push(egui::Event::Key {
key: egui::Key::V,
physical_key: Some(egui::Key::V),
pressed,
repeat: false,
modifiers: egui::Modifiers::NONE,
});
d.frames(1);
}
d.frames(2);
assert_eq!((copies(&mut d), asked.get()), (n + 1, 1), "V alone pastes nothing");
}

View File

@ -0,0 +1,154 @@
//! Clicking empty space deselects, consistently (#683): the Timeline's empty space below and
//! between the tracks, a text layer being edited when another panel is clicked, and the Program
//! monitor's empty space around the picture (graphic layers, masks).
use std::sync::mpsc::{Sender, channel};
use egui_kittest::Harness;
use filmcraft_engine::Session;
use filmcraft_ui_egui::FilmcraftApp;
use filmcraft_ui_egui::control::ControlRequest;
use filmcraft_ui_egui::state::GfxEdit;
use serde_json::{Value, json};
struct Driver {
harness: Harness<'static, FilmcraftApp>,
tx: Sender<ControlRequest>,
}
impl Driver {
fn demo() -> Self {
let mut session = Session::default();
session.execute("file.openDemoProject", json!({})).expect("demo project");
let (tx, rx) = channel();
let app = FilmcraftApp::new(session).with_control(rx);
let harness = Harness::builder().with_size(egui::vec2(1600.0, 980.0)).with_max_steps(10_000).build_eframe(move |_cc| app);
let mut d = Driver { harness, tx };
d.frames(4);
d
}
fn frames(&mut self, n: usize) {
for _ in 0..n {
let ctx = self.harness.ctx.clone();
let mut raw = std::mem::take(self.harness.input_mut());
eframe::App::raw_input_hook(self.harness.state_mut(), &ctx, &mut raw);
*self.harness.input_mut() = raw;
self.harness.step();
}
}
fn ok(&mut self, method: &str, params: Value) -> Value {
let (req, reply) = ControlRequest::new(method, params.clone());
self.tx.send(req).unwrap();
for _ in 0..600 {
self.frames(1);
if let Ok(v) = reply.try_recv() {
assert_eq!(v["ok"], json!(true), "{method} {params} failed: {v}");
return v["result"].clone();
}
}
panic!("no reply to {method} {params}");
}
fn exec(&mut self, command: &str, params: Value) -> Value {
self.ok("engine.execute", json!({"command": command, "params": params}))
}
fn rect(&mut self, id: &str) -> [f64; 4] {
let v = self.ok("ui.elements", json!({"prefix": id}));
let e = v.as_array().unwrap().iter().find(|e| e["id"] == id).unwrap_or_else(|| panic!("no element {id}")).clone();
let r: Vec<f64> = e["rect"].as_array().unwrap().iter().map(|x| x.as_f64().unwrap()).collect();
[r[0], r[1], r[2], r[3]]
}
fn click_at(&mut self, x: f64, y: f64) {
self.ok("ui.click", json!({"x": x, "y": y}));
self.frames(3);
}
fn app(&mut self) -> &mut FilmcraftApp {
self.harness.state_mut()
}
}
/// A click on the Timeline below the tracks deselects, as one on an empty stretch of a track does;
/// a drag from there draws a box selection.
#[test]
fn empty_timeline_space_below_the_tracks_deselects_and_starts_a_marquee() {
let mut d = Driver::demo();
d.ok("ui.set", json!({"focused": "Timeline"}));
let seq = d.exec("sequence.inspect", json!({}));
let clip = seq["video"][0]["items"][0]["clip"].as_u64().unwrap();
let area = d.rect("timeline.tracks");
// a point in the track area that is on no track at all: going up from the area's bottom
let x = area[0] + area[2] * 0.5;
let y = (0..60)
.map(|k| area[1] + area[3] - 3.0 - 4.0 * k as f64)
.find(|y| d.ok("ui.timeline.hit", json!({"x": x, "y": y}))["hit"] == "none")
.expect("the track area has space on no track");
let below = (x, y);
d.exec("timeline.select", json!({"clips": [clip]}));
d.frames(2);
d.click_at(below.0, below.1);
assert!(d.app().session.state.selection.is_empty(), "a click below the tracks deselects");
// a drag from below the tracks up over the clips selects them
let top = d.rect("timeline.track.V1.locked");
d.ok("ui.drag", json!({"from": {"x": area[0] + 4.0, "y": below.1}, "to": {"x": area[0] + area[2] * 0.6, "y": top[1] + top[3] / 2.0}, "steps": 8}));
d.frames(3);
assert!(!d.app().session.state.selection.is_empty(), "the box from below the tracks selected clips");
}
/// A text layer being edited stops being edited when another panel is clicked (its keys went into
/// the text before, #227); working in Properties, which styles the text, keeps the edit.
#[test]
fn clicking_another_panel_ends_text_editing() {
let mut d = Driver::demo();
let seq = d.exec("sequence.inspect", json!({}));
let at = seq["playhead"].as_i64().unwrap();
let clip = d.exec("graphics.newText", json!({"text": "Title", "time": at}))["clip"].as_u64().unwrap();
d.ok("ui.panel.show", json!({"panel": "Properties"}));
let edit = |d: &mut Driver| {
d.app().ui.gfx_edit = Some(GfxEdit { clip, layer: 0, caret: 0, anchor: 0 });
d.app().ui.focused = filmcraft_ui_egui::dock::PanelKind::Program;
d.frames(3);
};
edit(&mut d);
assert!(d.app().ui.gfx_edit.is_some());
let props = d.rect("panel.Properties");
d.click_at(props[0] + props[2] / 2.0, props[1] + props[3] - 10.0);
assert!(d.app().ui.gfx_edit.is_some(), "a click in Properties keeps the edit");
let tl = d.rect("timeline.tracks");
d.click_at(tl[0] + tl[2] / 2.0, tl[1] + tl[3] - 6.0);
assert!(d.app().ui.gfx_edit.is_none(), "a click in the Timeline ends it");
}
/// A click on the Program monitor around the picture lets go of the selected graphic layer and
/// mask; so does a click on the picture's empty space.
#[test]
fn empty_monitor_space_deselects_layers_and_masks() {
let mut d = Driver::demo();
let pic = d.rect("program.picture");
let panel = d.rect("panel.Program");
// the empty space beside or above the picture, a few points away from it
let outside = if pic[0] - panel[0] > 20.0 { (pic[0] - 8.0, pic[1] + pic[3] / 2.0) } else { (pic[0] + pic[2] / 2.0, pic[1] + pic[3] + 8.0) };
let seq = d.exec("sequence.inspect", json!({}));
let clip = seq["video"][0]["items"][0]["clip"].as_u64().unwrap();
// a graphic layer
let at = seq["playhead"].as_i64().unwrap();
let gclip = d.exec("graphics.newText", json!({"text": "Title", "time": at}))["clip"].as_u64().unwrap();
d.exec("graphics.selectLayer", json!({"clip": gclip, "layers": [0]}));
d.frames(2);
assert!(!d.app().session.state.graphic_layers.is_empty());
d.click_at(outside.0, outside.1);
assert!(d.app().session.state.graphic_layers.is_empty(), "a click around the picture deselects the layer");
// a mask: around the picture, and on the picture's empty space
d.exec("timeline.select", json!({"clips": [clip]}));
d.exec("effects.apply", json!({"clips": [clip], "effect": "gaussian_blur"}));
let fx = {
let s = &d.harness.state().session;
let it = s.active_sequence().unwrap().find_item(filmcraft_project::ClipId(clip)).unwrap().1;
it.effects.iter().position(|e| e.effect == "gaussian_blur").unwrap()
};
d.exec("masks.add", json!({"clip": clip, "effect": fx, "shape": "ellipse"}));
for (x, y) in [outside, (pic[0] + 6.0, pic[1] + 6.0)] {
d.exec("masks.select", json!({"clip": clip, "effect": fx, "mask": 0}));
d.frames(2);
d.click_at(x, y);
assert!(d.app().session.state.selected_mask.is_none(), "a click at ({x}, {y}) deselects the mask");
}
}

View File

@ -71,6 +71,31 @@ 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();
@ -238,3 +263,27 @@ fn delete_key_clears_the_selected_timeline_clip() {
d.key("Cmd+Z");
assert!(items(&mut d).iter().any(|i| i["clip"].as_u64() == Some(clip)), "and undo brings it back");
}
#[test]
fn shift_delete_ripple_deletes_with_the_timeline_focused() {
// #680: the Timeline's own `Delete` (Clear) was tried before the app-wide `Shift+Delete`
// (Ripple Delete), and egui ignores the extra Shift, so Shift+Delete left a gap.
let mut d = Driver::new();
// the demo's other tracks have clips in the way of a sync-locked ripple
for t in ["V2", "V3", "A2", "A3"] {
d.exec("timeline.setTrack", json!({"track": t, "syncLock": false}));
}
let items = |d: &mut Driver| d.exec("sequence.inspect", json!({}))["video"][0]["items"].as_array().unwrap().clone();
let v1 = items(&mut d);
let (clip, start) = (v1[1]["clip"].as_u64().unwrap(), v1[1]["start"].as_i64().unwrap());
let next = v1[2]["clip"].as_u64().unwrap();
d.exec("playhead.set", json!({"time": start + 1_000_000_000}));
d.focus("Timeline");
d.key("D");
assert!(d.app().session.state.selection.iter().any(|c| c.0 == clip), "D selects the clip under the playhead");
d.key("Shift+Delete");
let after = items(&mut d);
assert!(!after.iter().any(|i| i["clip"].as_u64() == Some(clip)), "Shift+Delete removed the selected clip");
let moved = after.iter().find(|i| i["clip"].as_u64() == Some(next)).map(|i| i["start"].as_i64().unwrap());
assert_eq!(moved, Some(start), "and closed the gap");
}

View File

@ -225,3 +225,36 @@ fn effect_controls_ruler_moves_the_playhead() {
assert!(d.find("effectControls.playhead").is_none(), "no handle while the playhead is past the clip");
d.rect("effectControls.ruler");
}
/// #412: Scale and Position both have a keyframe at the same time. A click on Scale's keyframe in
/// the Effect Controls lane selects that keyframe only (every keyframe under the playhead, which
/// the click moves there, used to light up); a keyframe that is gone drops out of the selection.
#[test]
fn clicking_a_lane_keyframe_selects_only_that_keyframe() {
let (mut d, clip) = Driver::demo();
let t0 = d.seek(1.0);
for param in ["scale", "position"] {
d.exec("effects.addKeyframe", json!({"clip": clip.id, "effect": "motion", "param": param}));
}
let time = d.scale(&clip).0[0];
let motion = {
let s = &d.harness.state().session;
let it = s.active_sequence().unwrap().find_item(ClipId(clip.id)).unwrap().1;
it.effects.iter().position(|e| e.effect == "motion").unwrap()
};
let selection = |d: &mut Driver| d.ok("ui.inspect", json!({}))["ui"]["keyframe_selection"].clone();
d.seek(2.0);
assert_eq!(selection(&mut d), json!([]), "nothing selected yet");
d.click(&format!("effectControls.motion.scale.keyframe.{time}"));
assert_eq!(selection(&mut d), json!([{"clip": clip.id, "effect": motion, "param": "scale", "time": time}]), "only Scale's keyframe");
assert_eq!(d.playhead(), t0, "the click still moves the playhead to the keyframe");
d.shot("keyframes-select-one");
d.click(&format!("effectControls.motion.position.keyframe.{time}"));
assert_eq!(selection(&mut d), json!([{"clip": clip.id, "effect": motion, "param": "position", "time": time}]), "a click replaces the selection");
// the diamond removes the keyframe at the playhead, the selected one: the selection empties
d.click("effectControls.motion.position.addKeyframe");
assert_eq!(selection(&mut d), json!([]), "a deleted keyframe is not selected");
}

View File

@ -351,6 +351,43 @@ fn source_menu_steps_preserve_zero_tick_frame_duration_cursor() {
}
}
#[test]
fn link_media_large_lists_and_long_paths_keep_actions_on_screen() {
for (width, height) in [(1600.0, 980.0), (1024.0, 768.0), (800.0, 600.0)] {
let mut s = Session::default();
s.execute("file.openDemoProject", json!({})).unwrap();
let mut p = (*s.project).clone();
let template = p.items.values().find_map(|i| i.as_media()).unwrap().clone();
for n in 0..100 {
let mut media = template.clone();
media.media = MediaRef::File { path: format!("/missing/{}/clip-{n}.mov", "long-project-directory/".repeat(20)) };
media.offline = true;
let id = p.add_item(&format!("Missing clip {n}"), Label::Iris, ItemKind::Media(media), None);
s.offline.missing.push(id);
}
s.project = Arc::new(p);
let mut app = FilmcraftApp::new(s);
app.ui.link_media = Some(Default::default());
let mut builder = Harness::builder().with_size(egui::vec2(width, height)).with_max_steps(10_000);
let snapshots = std::env::var_os("FILMCRAFT_UI_SNAPSHOT_DIR").map(PathBuf::from);
if snapshots.is_some() {
builder = builder.wgpu();
}
let mut h = builder.build_eframe(move |_| app);
for _ in 0..8 {
h.step();
}
for id in ["linkMedia.locate", "linkMedia.cancel", "linkMedia.offlineAll", "linkMedia.link"] {
let r = h.state().auto.find(id).unwrap().rect;
assert!(r[0] >= 0.0 && r[1] >= 0.0 && r[0] + r[2] <= width && r[1] + r[3] <= height, "{id} at {r:?} outside {width}x{height}");
}
if let Some(dir) = snapshots {
std::fs::create_dir_all(&dir).unwrap();
h.render().unwrap().save(dir.join(format!("media-long-paths-{width}x{height}.png"))).unwrap();
}
}
}
#[test]
fn link_media_dialog_relinks_moved_media() {
let root = tmp_dir("link");

View File

@ -269,6 +269,27 @@ fn clip_mixer_strips_follow_the_playhead() {
d.snapshot("mixer-clip-mixer", Some("clipMixer."));
}
#[test]
fn clip_mixer_db_value_can_be_typed() {
let mut d = Driver::demo();
d.ok("ui.panel.show", json!({"panel": "AudioClipMixer"}));
d.frames(3);
d.exec("playhead.set", json!({"seconds": 1.0}));
d.frames(2);
d.click("clipMixer.A1.value");
d.ok("ui.key", json!({"key": "Cmd+A"}));
d.frames(1);
d.ok("ui.type", json!({"text": "-20"}));
d.frames(2);
d.ok("ui.key", json!({"key": "Enter"}));
d.frames(3);
let seq = d.exec("sequence.inspect", json!({}));
let fx = &seq["audio"][0]["items"][0]["effects"];
let vol = fx.as_array().unwrap().iter().find(|e| e["effect"] == "volume").unwrap();
let level: f64 = vol["params"]["level"]["value"].as_str().and_then(|v| v.strip_prefix("Float(")?.strip_suffix(')')?.parse().ok()).unwrap();
assert!((level + 20.0).abs() < 1e-6, "typed -20 dB into the clip mixer readout: {level}");
}
#[test]
fn audio_gain_dialog_normalizes_the_selection() {
let mut d = Driver::demo();

View File

@ -447,6 +447,44 @@ fn bins_open_in_place_in_a_tab_or_in_a_window() {
assert_eq!(name, "Camera A");
}
#[test]
fn select_all_takes_only_the_shown_bin() {
// #456: Cmd+A selected every item in the project, closed bins and other tabs included
let mut d = project_driver();
d.exec("project.view.set", json!({"view": "list"}));
let (bin, items) = d.footage();
assert!(!items.is_empty());
let sorted = |v: &[ItemId]| {
let mut v: Vec<u64> = v.iter().map(|i| i.0).collect();
v.sort();
v
};
let mut footage = items.clone();
footage.sort();
// the root with the Footage bin closed: none of its items
d.app().ui.expanded_bins.clear();
d.frames(2);
d.key("Cmd+A");
let sel = d.app().session.state.project_selection.clone();
assert!(sel.iter().all(|i| !items.contains(&i.0)), "{sel:?}");
assert!(sel.iter().all(|i| d.app().session.project.root.children.contains(&BinEntry::Item(*i))), "{sel:?}");
// twirled open in List view, its items are shown and count
d.app().ui.expanded_bins.push(bin);
d.frames(2);
d.key("Cmd+A");
let sel = d.app().session.state.project_selection.clone();
assert!(items.iter().all(|i| sel.contains(&ItemId(*i))), "{sel:?}");
d.key("Cmd+Shift+A");
assert!(d.app().session.state.project_selection.is_empty());
// the bin opened in its own tab: exactly its items
d.app().ui.expanded_bins.clear();
d.app().session.prefs.general.bins_double_click = "openNewTab".into();
double_click_bin(&mut d, bin, false);
assert_eq!(d.app().ui.project_panel.active_tab, Some(0));
d.key("Cmd+A");
assert_eq!(sorted(&d.app().session.state.project_selection), footage);
}
#[test]
fn footer_buttons_are_wired() {
let mut d = project_driver();
@ -626,3 +664,129 @@ fn media_browser_drag_imports_into_the_project_panel() {
assert_eq!(d.app().session.project.items.len(), n0 + 1, "kept after a drop on the Timeline");
let _ = std::fs::remove_dir_all(&dir);
}
// ---- marquee selection (#578)
/// The Footage bin open in place in `view`, with its items' rects (row by row, left to right).
fn footage_in(d: &mut Driver, view: &str) -> Vec<(u64, [f32; 4])> {
let (bin, items) = d.footage();
d.exec("project.view.set", json!({"view": view}));
d.ok("ui.menu.invoke", json!({"id": "projectPanel.openBin", "params": {"bin": bin, "how": "inPlace"}}));
d.wait_for(&format!("project.item.{}", items[0]));
let mut rects: Vec<(u64, [f32; 4])> = items.iter().map(|i| (*i, d.rect(&format!("project.item.{i}")))).collect();
rects.sort_by(|a, b| (a.1[1], a.1[0]).partial_cmp(&(b.1[1], b.1[0])).unwrap());
rects
}
fn selection(d: &mut Driver) -> Vec<u64> {
let mut s: Vec<u64> = d.app().session.state.project_selection.iter().map(|i| i.0).collect();
s.sort_unstable();
s
}
fn sorted(mut v: Vec<u64>) -> Vec<u64> {
v.sort_unstable();
v
}
fn centre(r: [f32; 4]) -> (f32, f32) {
(r[0] + r[2] / 2.0, r[1] + r[3] / 2.0)
}
fn marquee(d: &mut Driver, from: (f32, f32), to: (f32, f32), shift: bool) {
d.ok("ui.drag", json!({"from": {"x": from.0, "y": from.1}, "to": {"x": to.0, "y": to.1}, "steps": 12, "modifiers": {"shift": shift}}));
d.frames(3);
}
#[test]
fn marquee_selects_cards_in_icon_view() {
let mut d = project_driver();
let cards = footage_in(&mut d, "icon");
let [(i0, a), (i1, b), (i2, c)] = [cards[0], cards[1], cards[2]];
assert!((a[1] - c[1]).abs() < 1.0, "the first three cards share a row");
// from the gap above-left of the first card to the middle of the second
marquee(&mut d, (a[0] - 4.0, a[1] - 4.0), centre(b), false);
assert_eq!(selection(&mut d), sorted(vec![i0, i1]));
// Shift adds
marquee(&mut d, (c[0] + 4.0, c[1] - 4.0), centre(c), true);
assert_eq!(selection(&mut d), sorted(vec![i0, i1, i2]));
// a plain drag replaces
marquee(&mut d, (a[0] - 4.0, a[1] - 4.0), centre(a), false);
assert_eq!(selection(&mut d), vec![i0]);
// the gaps between cards are empty space: a click there deselects
d.ok("ui.click", json!({"x": a[0] - 4.0, "y": a[1] - 4.0}));
d.frames(2);
assert!(selection(&mut d).is_empty());
}
#[test]
fn escape_cancels_a_marquee_and_keeps_the_selection() {
let mut d = project_driver();
let cards = footage_in(&mut d, "icon");
let [(i0, a), (i1, b), (i2, c)] = [cards[0], cards[1], cards[2]];
d.exec("project.select", json!({"items": [i2]}));
let send = |d: &mut Driver, e: egui::Event| {
d.harness.input_mut().events.push(e);
d.frames(1);
};
let (from, to) = (egui::pos2(a[0] - 4.0, a[1] - 4.0), egui::pos2(centre(b).0, centre(b).1));
send(&mut d, egui::Event::PointerMoved(from));
send(&mut d, egui::Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() });
for k in 1..=10 {
send(&mut d, egui::Event::PointerMoved(from.lerp(to, k as f32 / 10.0)));
}
assert_eq!(selection(&mut d), sorted(vec![i0, i1]), "the selection follows the rectangle");
send(&mut d, egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() });
let end = egui::pos2(centre(c).0, centre(c).1);
send(&mut d, egui::Event::PointerMoved(end));
send(&mut d, egui::Event::PointerButton { pos: end, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() });
d.frames(2);
assert_eq!(selection(&mut d), vec![i2], "Escape restored the selection the drag began with");
}
#[test]
fn only_the_left_button_draws_a_marquee() {
let mut d = project_driver();
let cards = footage_in(&mut d, "icon");
let [(i0, a), (_, b)] = [cards[0], cards[1]];
d.exec("project.select", json!({"items": [i0]}));
let (from, to) = (egui::pos2(a[0] - 4.0, a[1] - 4.0), egui::pos2(centre(b).0, centre(b).1));
for button in [egui::PointerButton::Middle, egui::PointerButton::Secondary] {
let mut send = |e: egui::Event| {
d.harness.input_mut().events.push(e);
d.frames(1);
};
send(egui::Event::PointerMoved(from));
send(egui::Event::PointerButton { pos: from, button, pressed: true, modifiers: Default::default() });
for k in 1..=10 {
send(egui::Event::PointerMoved(from.lerp(to, k as f32 / 10.0)));
}
send(egui::Event::PointerButton { pos: to, button, pressed: false, modifiers: Default::default() });
d.key("Escape");
assert_eq!(selection(&mut d), vec![i0], "{button:?} drag left the selection alone");
assert!(!d.has("project.marquee"));
}
}
#[test]
fn marquee_selects_rows_in_list_view() {
let mut d = project_driver();
let rows = footage_in(&mut d, "list");
let n = rows.len();
let (last, before_last) = (rows[n - 1], rows[n - 2]);
let empty = d.rect("project.empty");
// from the empty space under the rows up into the second-to-last row
marquee(&mut d, (empty[0] + 60.0, empty[1] + 20.0), (empty[0] + 60.0, centre(before_last.1).1), false);
assert_eq!(selection(&mut d), sorted(vec![last.0, before_last.0]));
}
#[test]
fn marquee_selects_cards_in_freeform_view() {
let mut d = project_driver();
let cards = footage_in(&mut d, "freeform");
let [(i0, a), (i1, b)] = [cards[0], cards[1]];
assert!((a[1] - b[1]).abs() < 1.0, "the first two cards share a row");
marquee(&mut d, (a[0] - 6.0, a[1] - 6.0), centre(b), false);
assert_eq!(selection(&mut d), sorted(vec![i0, i1]));
assert!(!d.app().session.project.item(ItemId(i0)).unwrap().metadata.contains_key(FREEFORM_POS), "the marquee moved no card");
}

View File

@ -196,3 +196,37 @@ fn a_single_frame_source_range_starts_at_the_marked_last_frame() {
assert!(!h.state().source_playback.clock.playing);
assert_eq!(h.state().session.state.source_playhead, last);
}
#[test]
fn j_and_l_shuttle_the_source_both_ways_and_double_the_speed() {
let mut h = Harness::builder().with_step_dt(0.25).with_size(egui::vec2(1280.0, 800.0)).build_eframe(move |_cc| app(5));
h.step();
h.state_mut().ui.focused = PanelKind::Source;
let before = h.state().session.project.to_json();
let program = h.state().session.playhead();
let ctx = h.ctx.clone();
for speed in [1.0, 2.0, 4.0, 8.0, 8.0] {
let r = menus::invoke(h.state_mut(), &ctx, "playback.forward", json!({})).unwrap();
assert_eq!(r["speed"], speed);
}
// J while running forward turns around at normal speed, then runs backward to the first frame
h.state_mut().session.execute("source.setPlayhead", json!({"seconds":2})).unwrap();
assert_eq!(menus::invoke(h.state_mut(), &ctx, "playback.reverse", json!({})).unwrap()["speed"], -1.0);
let at = h.state().session.state.source_playhead;
h.step();
h.step();
assert!(h.state().session.state.source_playhead < at, "J plays the Source backward");
assert!(!h.state().source_playback.clock.audio_clock, "shuttle speeds play without sound");
assert_eq!(menus::invoke(h.state_mut(), &ctx, "playback.reverse", json!({})).unwrap()["speed"], -2.0);
for _ in 0..8 {
h.step();
}
assert!(!h.state().source_playback.clock.playing);
assert_eq!(h.state().session.state.source_playhead, Tick::ZERO);
assert_eq!(menus::invoke(h.state_mut(), &ctx, "playback.slowForward", json!({})).unwrap()["speed"], 0.25);
menus::invoke(h.state_mut(), &ctx, "playback.stop", json!({})).unwrap();
assert!(!h.state().source_playback.clock.playing);
assert_eq!(h.state().session.playhead(), program);
assert!(!h.state().playback.playing);
assert_eq!(h.state().session.project.to_json(), before);
}

View File

@ -0,0 +1,44 @@
//! 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);
}

View File

@ -263,3 +263,25 @@ fn the_scroll_bar_thumb_follows_the_pointer_and_reaches_both_ends() {
let (scroll, pps, ..) = d.view();
assert!(pps < 50.0 && (scroll - at).abs() < 1e-6, "{pps} {scroll}");
}
/// Show Video Thumbnails ▸ Continuous asks for frames across each video clip, not only its head;
/// `ui.set` switches the mode (and Off) and refuses an unknown one.
#[test]
fn continuous_video_thumbnails_ask_for_frames_across_each_clip() {
let mut d = Driver::demo();
d.ok("ui.set", json!({"timeline": {"thumbnails": "head", "pps": 50.0, "scroll": 0.0}}));
d.view();
d.frames(10);
let head = d.app().frames.stats().request_misses;
d.ok("ui.set", json!({"timeline": {"thumbnails": "continuous"}}));
d.frames(3);
let continuous = d.app().frames.stats().request_misses;
assert!(continuous >= head + 5, "continuous thumbnails queued {} more frames", continuous - head);
let tl = d.ok("ui.inspect", none())["ui"]["timeline"].clone();
assert_eq!((tl["show_thumbnails"].clone(), tl["thumbnail_mode"].clone()), (json!(true), json!("Continuous")));
d.ok("ui.set", json!({"timeline": {"thumbnails": "off"}}));
assert!(!d.app().ui.timeline.show_thumbnails);
let v = d.call("ui.set", json!({"timeline": {"thumbnails": "filmstrip"}}));
assert_eq!(v["ok"], json!(false), "{v}");
assert!(!d.app().ui.timeline.show_thumbnails);
}

Some files were not shown because too many files have changed in this diff Show More