Merge branch 'main' into worktree-agent-ac059806d5abf3b44

This commit is contained in:
Brandon Thomas committed 2026-10-01 12:46:50 -04:00
commit 44f93d615c
21 files changed
+339 -32

No files matched your search

+1 -1
View File
@@ -16,7 +16,7 @@ iconography, images or other Adobe assets; open licences only; every asset attri
| `assets/fonts/Inter-Italic.ttf` | The Inter Project Authors | https://github.com/rsms/inter | OFL-1.1 (`assets/fonts/OFL-Inter.txt`) |
| `assets/fonts/NotoSerif-Regular.ttf` | The Noto Project Authors | https://github.com/notofonts/latin-greek-cyrillic | OFL-1.1 (`assets/fonts/OFL-NotoSerif.txt`) |
| `assets/fonts/JetBrainsMono-Regular.ttf` | The JetBrains Mono Project Authors | https://github.com/JetBrains/JetBrainsMono | OFL-1.1 (`assets/fonts/OFL-JetBrainsMono.txt`) |
| `docs/images/filmcraft-hero.png` | FilmCraft contributors | Original work: FilmCraft screenshot; frames from public-domain films (Night of the Living Dead 1968, Carnival of Souls 1962), CC0 Chopin (Musopen) | MIT OR Apache-2.0; film frames public domain |
| `docs/images/filmcraft-hero.png` | FilmCraft contributors | Original work: FilmCraft screenshot; NASA Apollo 11 film and TV footage (images.nasa.gov; US Government work, public domain; NASA does not endorse this project), subtitles from the NASA Apollo 11 air-to-ground transcript (public domain), CC0 Chopin (Musopen) | MIT OR Apache-2.0; footage and transcript public domain |
| `docs/images/filmcraft-color.png` | FilmCraft contributors | Original work: FilmCraft screenshot; frames from Charade (1963, public domain) | MIT OR Apache-2.0; film frames public domain |
| `docs/images/filmcraft-keyframes.png` | FilmCraft contributors | Original work: FilmCraft screenshot; frames from Night of the Living Dead (1968, public domain) | MIT OR Apache-2.0; film frames public domain |
| `docs/images/filmcraft-export.png` | FilmCraft contributors | Original work: FilmCraft screenshot; frame from Night of the Living Dead (1968, public domain) | MIT OR Apache-2.0; film frames public domain |
Generated
+3
View File
@@ -1314,9 +1314,12 @@ dependencies = [
"filmcraft-ui-egui",
"log",
"muda",
"objc2 0.6.4",
"objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2",
"rfd",
"serde_json",
"winit",
]
[[package]]
+3 -3
View File
@@ -19,10 +19,10 @@
</p>
<p align="center">
<img src="docs/images/filmcraft-hero.png" alt="FilmCraft cutting a trailer for Night of the Living Dead (1968): Effect Controls, the Program monitor on the ghouls advancing, the Project bin of public-domain films, and a multi-track timeline with dissolves, markers, a Carnival of Souls insert, a Chopin score and loudness meters" width="100%">
<img src="docs/images/filmcraft-hero.png" alt="FilmCraft in the Color workspace, mid-way through an Apollo 11 documentary cut from NASA footage: the Program monitor on the Saturn V clearing the launch tower with a Launch Complex 39A lower third and an air-to-ground subtitle, Effect Controls with Lumetri and Scale keyframes on the shot, the Lumetri Color panel, bins of NASA selects, and a timeline with 49 picture cuts, B-roll, titles, a caption track, mission audio, a ducked music bed, named markers, a rendered section and live loudness meters" width="100%">
</p>
<p align="center"><sub>Cutting a trailer for <i>Night of the Living Dead</i> (1968). Every frame in these screenshots comes from public-domain footage, decoded, composited and graded by FilmCraft's own code.</sub></p>
<p align="center"><sub><i>Apollo 11 &mdash; Tranquility</i>: a three-minute documentary edit of NASA's 1969 launch, landing and moonwalk film, with subtitles from the mission transcript. Every frame in these screenshots comes from public-domain footage, decoded, composited and graded by FilmCraft's own code.</sub></p>
<br>
@@ -259,7 +259,7 @@ Open-source, clean-room, pure-Rust creative tools. Each runs natively on macOS,
## Credits and clean room
**Footage and music in the screenshots:** *Night of the Living Dead* (1968), *Carnival of Souls* (1962) and *Charade* (1963), all in the US public domain; *Earth Views from the ISS* by NASA (a US Government work; NASA does not endorse this project); Chopin's Nocturne Op. 48 No. 1 and Ballade No. 1, performed for Musopen and released under CC0. The media itself is not in this repository. Sources and details for every asset are in [ATTRIBUTION.md](ATTRIBUTION.md).
**Footage and music in the screenshots:** NASA's Apollo 11 film and television footage from [images.nasa.gov](https://images.nasa.gov) (launch, Launch Control Center, lunar surface and recovery) and the Apollo 11 air-to-ground voice transcript, all US Government works in the public domain (NASA does not endorse this project); *Night of the Living Dead* (1968), *Carnival of Souls* (1962) and *Charade* (1963), all in the US public domain; *Earth Views from the ISS* by NASA; Chopin's Nocturne Op. 48 No. 1 and Ballade No. 1, performed for Musopen and released under CC0. The media itself is not in this repository. Sources and details for every asset are in [ATTRIBUTION.md](ATTRIBUTION.md).
FilmCraft is an independent implementation. It contains no Adobe code, icons, images, presets or LUTs, and no GPL or LGPL code; every icon is drawn in code and every asset is openly licensed and attributed ([AGENTS.md](AGENTS.md)). ffmpeg is used only as an external test oracle. Adobe and Premiere Pro are trademarks of Adobe Inc.; FilmCraft is not affiliated with Adobe.
+5 -1
View File
@@ -22,4 +22,8 @@ chrono = { version = "0.4", default-features = false, features = ["clock"] }
[target.'cfg(target_os = "macos")'.dependencies]
muda = "0.21"
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSProcessInfo", "NSString"] }
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSProcessInfo", "NSString", "NSArray"] }
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSApplication", "NSResponder", "NSWindow"] }
objc2 = "0.6"
# Same winit as eframe: macOS event-loop options (no app activation when driven by an agent).
winit = { version = "0.30", default-features = false }
+22 -1
View File
@@ -17,6 +17,7 @@ mod audio;
mod control_server;
#[cfg(target_os = "macos")]
mod native_menu;
mod window_raise;
use chrono::TimeZone;
use filmcraft_engine::Session;
@@ -55,7 +56,10 @@ fn main() -> eframe::Result {
.with_drag_and_drop(true)
.with_fullsize_content_view(true)
.with_titlebar_shown(false)
.with_title_shown(false),
.with_title_shown(false)
// Started for an agent (`--control`): open without taking the user's keyboard focus.
.with_active(control_port.is_none()),
event_loop_builder: agent_event_loop(control_port.is_some()),
..Default::default()
};
eframe::run_native(
@@ -112,6 +116,9 @@ fn main() -> eframe::Result {
rfd::FileDialog::new().add_filter(filter, exts).set_file_name(name).save_file().map(|p| p.to_string_lossy().to_string())
}));
app.hooks.pick_folder = Some(Box::new(|| rfd::FileDialog::new().pick_folder().map(|p| p.to_string_lossy().to_string())));
app.hooks.raise_without_focus = Some(Box::new(|| {
window_raise::raise_without_focus();
}));
app.hooks.pick_open_file = Some(Box::new(|filter: &str, exts: &[&str]| {
rfd::FileDialog::new().add_filter(filter, exts).pick_file().map(|p| p.to_string_lossy().to_string())
}));
@@ -137,3 +144,17 @@ fn main() -> eframe::Result {
fn local_offset(unix: i64) -> i32 {
chrono::Local.timestamp_opt(unix, 0).single().map(|d| d.offset().local_minus_utc()).unwrap_or(0)
}
/// Driven by an agent (`--control`): don't activate the app on launch, so the user's keyboard
/// focus stays where it is (macOS; winit activates ignoring other apps by default).
fn agent_event_loop(agent: bool) -> Option<eframe::EventLoopBuilderHook> {
#[cfg(target_os = "macos")]
if agent {
return Some(Box::new(|b| {
use winit::platform::macos::EventLoopBuilderExtMacOS;
b.with_activate_ignoring_other_apps(false);
}));
}
let _ = agent;
None
}
+28
View File
@@ -0,0 +1,28 @@
//! Bring the window forward for the control channel without taking keyboard focus.
//!
//! Occluded macOS windows stop running egui's `ui` pass, so UI-level control requests (synthetic
//! input, screenshots) need the window on screen. Activating the app for that
//! (`ViewportCommand::Focus`) steals keyboard focus from whatever the user is typing in, and their
//! keystrokes then land in FilmCraft as shortcuts. `orderFrontRegardless` orders the window to the
//! front without activating the app, so the key window (and the user's focus) stays where it is.
/// Order the app's windows to the front without activating the app (macOS). Returns whether it
/// did anything; elsewhere the caller only requests a repaint.
pub fn raise_without_focus() -> bool {
#[cfg(target_os = "macos")]
{
let Some(mtm) = objc2::MainThreadMarker::new() else { return false };
let app = objc2_app_kit::NSApplication::sharedApplication(mtm);
let windows = app.windows();
for w in windows.iter() {
if w.isVisible() || w.isMiniaturized() {
w.orderFrontRegardless();
}
}
true
}
#[cfg(not(target_os = "macos"))]
{
false
}
}
+1 -1
View File
@@ -86,7 +86,7 @@ fn crash_recovery_end_to_end() {
start(&mut c, &d);
assert_eq!(c.recovery_candidates().len(), 1);
let list = c.execute("file.recoveryList", json!({})).unwrap();
assert_eq!(list[0]["projectName"], "FilmCraft Demo");
assert_eq!(list[0]["projectName"], "film", "named after the file it was saved as");
assert_eq!(list[0]["projectPath"], proj.as_str());
assert_eq!(list[0]["cleanExit"], false);
assert_eq!(list[0]["savedAt"].as_str().unwrap().len(), 19);
+1 -1
View File
@@ -296,7 +296,7 @@ pub fn commands() -> Vec<CommandSpec> {
"Caption Track Style",
&[],
None,
r##"{"track":id|"C1","font":str?,"size":f32?,"color":"#rrggbb[aa]"?,"background":bool?,"backgroundColor":"#rrggbbaa"?,"align":"left|center|right"?,"anchor":"top|middle|bottom"?,"margin":f32?,"lineSpacing":f32?,"outline":f32?,"outlineColor":str?,"reset":bool?}"##,
r##"{"track":id|"C1","font":str?,"size":f32?,"color":"#rrggbb[aa]"?,"background":bool?,"backgroundColor":"#rrggbbaa"?,"align":"left|center|right"?,"anchor":"top|middle|bottom"?,"margin":0..0.45 (fraction of frame height)?,"lineSpacing":f32?,"outline":f32?,"outlineColor":str?,"reset":bool?}"##,
has_caption_track,
|s, p| {
let tid = track_param(s, p).ok_or_else(|| bad("captions.setStyle", "no such caption track"))?;
+33 -2
View File
@@ -502,7 +502,8 @@ 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]}"#, always, |s, p| {
cmd!("file.import", "Import…", ["File"], Some("Cmd+I"), r#"{"paths":[str],"bin":binId?}"#, 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(),
None => str_p(p, "path").map(|x| vec![x.to_string()]).unwrap_or_default(),
@@ -533,7 +534,7 @@ fn build() -> Vec<CommandSpec> {
Err(e) => errors.push(format!("{path}: {e}")),
}
}
Ok(b) => match import_bytes(s, &path, b.into(), None) {
Ok(b) => match import_bytes(s, &path, b.into(), bin) {
Ok(id) => ids.push(id.0),
Err(e) => errors.push(format!("{path}: {e}")),
},
@@ -1831,6 +1832,18 @@ fn build() -> Vec<CommandSpec> {
s.fix_state();
Ok(Value::Null)
}),
cmd!("project.moveToBin", "Move to Bin", [], None, r#"{"items":[id]?,"bin":binId|null}"#, always, |s, p| {
let items: Vec<ItemId> = match p.get("items").and_then(Value::as_array) {
Some(a) => a.iter().filter_map(|v| v.as_u64().map(ItemId)).collect(),
None => s.state.project_selection.clone(),
};
if items.is_empty() {
return Err(bad("project.moveToBin", "need `items` (or a Project panel selection)"));
}
let bin = u64_p(p, "bin").map(filmcraft_project::BinId);
let moved = s.edit("Move to Bin", |pr, _| pr.move_to_bin(&items, bin).ok_or_else(|| bad("project.moveToBin", "no such bin")))?;
Ok(json!({"moved": moved}))
}),
cmd!("project.setMarks", "Set Source In/Out", [], None, r#"{"item":id,"in":ticks?|null,"out":ticks?|null}"#, always, |s, p| {
let id = item_p(p, "item").or(s.state.source_item).ok_or_else(|| bad("project.setMarks", "need `item`"))?;
let p = p.clone();
@@ -2316,8 +2329,26 @@ pub fn secs(s: f64) -> Tick {
/// Save / Save As (the file becomes the project's path and the project is clean); otherwise Save a
/// Copy. The first save over a file upgraded from an older schema keeps the original as
/// `<name> (schema vN backup).fcproj`.
/// A project is named after its file (as in Premiere): `/a/b/Trailer v2.fcproj` → `Trailer v2`.
fn project_name_for(path: &str) -> Option<String> {
let stem = std::path::Path::new(path).file_stem()?.to_string_lossy().to_string();
(!stem.is_empty()).then_some(stem)
}
fn name_project(p: &mut filmcraft_project::Project, name: String) {
p.root.name.clone_from(&name);
p.name = name;
}
fn write_project(s: &mut Session, path: &str, adopt: bool) -> Result<Value> {
let t0 = std::time::Instant::now();
// Save As renames the project after its new file (not an edit: no undo step, not dirty).
if adopt
&& let Some(name) = project_name_for(path)
&& s.project.name != name
{
name_project(std::sync::Arc::make_mut(&mut s.project), name);
}
let bytes = filmcraft_format::encode(&s.project, false);
let mut backup = None;
if adopt && s.path.as_deref() == Some(path) && s.loaded_schema < filmcraft_format::SCHEMA_VERSION {
+18
View File
@@ -75,3 +75,21 @@ fn legacy_project_opens_and_first_save_keeps_a_backup() {
assert!(e.contains("newer version of FilmCraft"), "{e}");
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn project_is_named_after_its_file() {
let d = temp_dir("name");
let mut s = demo();
let a = d.join("Apollo 11 - Tranquility.fcproj").to_string_lossy().to_string();
s.execute("file.saveAs", json!({"path": a})).unwrap();
assert_eq!(s.project.name, "Apollo 11 - Tranquility");
assert_eq!(s.project.root.name, "Apollo 11 - Tranquility");
assert!(!s.is_dirty(), "renaming on Save As is not an edit");
// Save a Copy keeps the current name.
let c = d.join("Backup copy.fcproj").to_string_lossy().to_string();
s.execute("file.saveCopy", json!({"path": c})).unwrap();
assert_eq!(s.project.name, "Apollo 11 - Tranquility");
s.execute("file.open", json!({"path": c})).unwrap();
assert_eq!(s.project.name, "Apollo 11 - Tranquility");
let _ = std::fs::remove_dir_all(&d);
}
+28
View File
@@ -226,3 +226,31 @@ fn interchange_roundtrip_through_files() {
}
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn move_items_to_bin_and_back_with_undo() {
let mut s = demo();
let bin = s.execute("file.newBin", json!({"name": "Selects"})).unwrap()["bin"].as_u64().unwrap();
let mut items = Vec::new();
s.project.root.all_items(&mut items);
let item = items[0];
let home = s.project.root.parent_of(item);
let r = s.execute("project.moveToBin", json!({"items": [item.0], "bin": bin})).unwrap();
assert_eq!(r["moved"], 1);
assert_eq!(s.project.root.parent_of(item), Some(filmcraft_project::BinId(bin)));
// the item is in the tree exactly once
let mut all = Vec::new();
s.project.root.all_items(&mut all);
assert_eq!(all.iter().filter(|i| **i == item).count(), 1);
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(s.project.root.parent_of(item), home);
s.execute("edit.redo", json!({})).unwrap();
assert_eq!(s.project.root.parent_of(item), Some(filmcraft_project::BinId(bin)));
// back to the root with `bin: null`
s.execute("project.moveToBin", json!({"items": [item.0], "bin": null})).unwrap();
assert_eq!(s.project.root.parent_of(item), Some(s.project.root.id));
// an unknown bin is an error, and so is no items and no selection
assert!(s.execute("project.moveToBin", json!({"items": [item.0], "bin": 999_999})).is_err());
s.state.project_selection.clear();
assert!(s.execute("project.moveToBin", json!({})).is_err());
}
+16
View File
@@ -942,6 +942,22 @@ impl Project {
id
}
/// Move items into a bin (the root when `bin` is None). Returns how many moved; unknown items
/// are skipped. Fails (returns None) when the bin does not exist.
pub fn move_to_bin(&mut self, items: &[ItemId], bin: Option<BinId>) -> Option<usize> {
let target = bin.unwrap_or(self.root.id);
self.root.find_bin(target)?;
let mut moved = 0;
for &id in items {
if !self.items.contains_key(&id) || !self.root.remove_item(id) {
continue;
}
self.root.find_bin_mut(target).expect("checked above").children.push(BinEntry::Item(id));
moved += 1;
}
Some(moved)
}
pub fn item(&self, id: ItemId) -> Option<&ProjectItem> {
self.items.get(&id)
}
+3 -2
View File
@@ -271,8 +271,8 @@ pub fn handle(app: &mut FilmcraftApp, ctx: &egui::Context, req: &ControlRequest)
"ui.screenshot" => {
let crop = s("panel").and_then(PanelKind::from_name).and_then(|pk| app.auto.find(&format!("panel.{}", pk.id())).map(|e| e.rect));
let crop = crop.or_else(|| s("id").and_then(|id| app.auto.find(id).map(|e| e.rect)));
ctx.send_viewport_cmd(egui::ViewportCommand::Focus);
ctx.request_repaint();
// Bring the window on screen so a frame is presented, without taking keyboard focus.
app.raise_for_control(ctx);
Outcome::Screenshot { path: s("path").map(str::to_string), crop }
}
"ui.resize" => {
@@ -282,6 +282,7 @@ pub fn handle(app: &mut FilmcraftApp, ctx: &egui::Context, req: &ControlRequest)
ok(Value::Null)
}
"ui.focus" => {
// Explicit request: activate the app and take keyboard focus.
ctx.send_viewport_cmd(egui::ViewportCommand::Focus);
ok(Value::Null)
}
+17 -3
View File
@@ -56,6 +56,10 @@ pub struct HostHooks {
pub pick_open_file: Option<Box<dyn FnMut(&str, &[&str]) -> Option<String>>>,
/// Folder picker (Link Media search, proxy and Project Manager destinations).
pub pick_folder: Option<Box<dyn FnMut() -> Option<String>>>,
/// Bring the window on screen for control-channel UI requests *without* taking keyboard focus
/// (macOS: `orderFrontRegardless`). Without it the app only requests a repaint: it never
/// activates itself for an agent, because the user's keystrokes would land here.
pub raise_without_focus: Option<Box<dyn FnMut()>>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -252,6 +256,7 @@ impl FilmcraftApp {
FrameServer::default_workers(),
));
self.textures.clear();
self.tl.reset_media_caches();
}
}
@@ -647,6 +652,14 @@ impl FilmcraftApp {
// ---------------------------------------------------------------- control channel
/// Make the UI pass run for a control request without stealing the user's keyboard focus.
pub(crate) fn raise_for_control(&mut self, ctx: &egui::Context) {
if let Some(raise) = self.hooks.raise_without_focus.as_mut() {
raise();
}
ctx.request_repaint();
}
fn drain_control(&mut self, ctx: &egui::Context) {
let Some(rx) = self.control_rx.take() else { return };
let now = ctx.input(|i| i.time);
@@ -655,7 +668,7 @@ impl FilmcraftApp {
// UI requests need rendered frames: raise the window if `ui` hasn't run recently
// (occluded macOS windows stop running `ui`).
if req.method.starts_with("ui.") && now - self.last_ui_time > 0.25 {
ctx.send_viewport_cmd(egui::ViewportCommand::Focus);
self.raise_for_control(ctx);
}
reqs.push((req, now + 3.0));
}
@@ -950,8 +963,9 @@ impl eframe::App for FilmcraftApp {
let had_synthetic = !self.synthetic.is_empty();
self.drain_control(ctx);
if !self.synthetic.is_empty() && !had_synthetic {
// Occluded macOS windows stop running `ui`; raise the window so input is processed.
ctx.send_viewport_cmd(egui::ViewportCommand::Focus);
// Occluded macOS windows stop running `ui`; bring the window forward (without taking
// keyboard focus) so the input is processed.
self.raise_for_control(ctx);
}
if !self.synthetic.is_empty() {
ctx.request_repaint();
+30 -1
View File
@@ -294,7 +294,7 @@ pub(crate) fn param_row(
crate::panels::masks::path_value(app, &mut vui, clip, idx, mask, actions);
}
_ => {
vui.label(format!("{value:?}"));
vui.label(param_text(&app.session.project, pd.id, &value));
}
}
if let Some(v) = set {
@@ -817,3 +817,32 @@ pub(crate) fn graph_rows(
}
}
}
/// Read-only text for parameters without an inline editor (LUT references, free text, curves).
fn param_text(project: &filmcraft_project::Project, id: &str, value: &ParamValue) -> String {
match value {
ParamValue::Text(s) if id.ends_with("_lut") => filmcraft_render::luts::label(Some(project), s),
ParamValue::Text(s) if s.is_empty() => "None".into(),
ParamValue::Text(s) => s.clone(),
ParamValue::Curve(pts) if pts.is_empty() || *pts == [[0.0, 0.0], [1.0, 1.0]] => "Default".into(),
ParamValue::Curve(pts) => format!("Custom ({} points)", pts.len()),
other => format!("{other:?}"),
}
}
#[cfg(test)]
mod param_text_tests {
use super::*;
#[test]
fn readable_values_for_text_lut_and_curve_params() {
let p = filmcraft_project::Project::default();
assert_eq!(param_text(&p, "input_lut", &ParamValue::Text(String::new())), "None");
assert_eq!(param_text(&p, "look_lut", &ParamValue::Text("builtin:look-teal-orange".into())), "Teal & Orange");
assert_eq!(param_text(&p, "label", &ParamValue::Text(String::new())), "None");
assert_eq!(param_text(&p, "label", &ParamValue::Text("Reel 3".into())), "Reel 3");
assert_eq!(param_text(&p, "curve_luma", &ParamValue::Curve(vec![[0.0, 0.0], [1.0, 1.0]])), "Default");
assert_eq!(param_text(&p, "hue_vs_sat", &ParamValue::Curve(vec![])), "Default");
assert_eq!(param_text(&p, "curve_luma", &ParamValue::Curve(vec![[0.0, 0.0], [0.5, 0.6], [1.0, 1.0]])), "Custom (3 points)");
}
}
+46 -3
View File
@@ -42,6 +42,16 @@ pub struct TlState {
zoom_anchor: Option<(f64, f32)>,
}
impl TlState {
/// Forget cached waveform peaks: they are keyed by item id, and ids repeat across projects
/// (opening another project would otherwise show the old project's waveforms). Peak jobs
/// still running finish into the old maps and are dropped.
pub fn reset_media_caches(&mut self) {
self.peaks = Default::default();
self.peaks_pending = Default::default();
}
}
#[derive(Clone, Debug)]
pub enum Drag {
Scrub,
@@ -514,8 +524,8 @@ fn draw_waveform(app: &mut FilmcraftApp, p: &egui::Painter, body: Rect, it: &Tra
if clip.width() <= 0.0 {
return;
}
let peak = peaks.iter().fold(0f32, |m, (a, b)| m.max(a.abs()).max(b.abs())).max(1e-4);
let gain = filmcraft_render::audio::db_to_gain(it.gain_db) / peak;
let peak = peaks.iter().fold(0f32, |m, (a, b)| m.max(a.abs()).max(b.abs()));
let gain = waveform_display_gain(peak, it.gain_db);
let mut mesh = egui::Mesh::default();
let dur_px = body.width().max(1.0);
for x in (clip.min.x.floor() as i32)..(clip.max.x.ceil() as i32) {
@@ -555,10 +565,21 @@ fn draw_waveform(app: &mut FilmcraftApp, p: &egui::Painter, body: Rect, it: &Tra
}
}
/// Display gain for a clip's waveform: clip gain, plus normalisation of the source to full scale
/// so quiet recordings stay readable, but by at most +12 dB, so a near-silent track (room tone at
/// −50 dBFS, a silent film's empty audio stream) does not look like a loud one.
fn waveform_display_gain(source_peak: f32, clip_gain_db: f64) -> f32 {
const MAX_BOOST: f32 = 3.981_072; // +12 dB
let boost = (1.0 / source_peak.max(1e-9)).min(MAX_BOOST);
filmcraft_render::audio::db_to_gain(clip_gain_db) * boost
}
fn request_peaks(app: &mut FilmcraftApp, item: ItemId) -> Option<Arc<Vec<(f32, f32)>>> {
if let Some(p) = app.tl.peaks.lock().unwrap_or_else(|e| e.into_inner()).get(&item) {
return Some(p.clone());
}
// No source: try again on a later frame instead of leaving the item pending forever.
let src = app.session.source(item)?;
{
let mut pend = app.tl.peaks_pending.lock().unwrap_or_else(|e| e.into_inner());
if pend.contains(&item) {
@@ -566,7 +587,6 @@ fn request_peaks(app: &mut FilmcraftApp, item: ItemId) -> Option<Arc<Vec<(f32, f
}
pend.push(item);
}
let src = app.session.source(item)?;
let peaks = app.tl.peaks.clone();
let pending = app.tl.peaks_pending.clone();
let dur = src.info().duration;
@@ -1617,3 +1637,26 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: &
}
app.auto.add("timeline.tracks", layout.content, "tracks");
}
#[cfg(test)]
mod waveform_tests {
use super::waveform_display_gain;
fn db(g: f32) -> f32 {
20.0 * g.log10()
}
#[test]
fn normalisation_is_capped_for_near_silent_sources() {
// a −14 dBFS recording is normalised to full scale (+12 dB cap reached: −2 dBFS)
assert!((db(0.2 * waveform_display_gain(0.2, 0.0)) - (-2.0)).abs() < 0.1);
// a −3 dBFS recording is normalised exactly
assert!((db(0.708 * waveform_display_gain(0.708, 0.0))).abs() < 0.1);
// a −52 dBFS "silent" stream stays near the floor of the −48 dB display range
assert!(db(0.0025 * waveform_display_gain(0.0025, 0.0)) < -39.0);
// digital silence does not blow up
assert!(waveform_display_gain(0.0, 0.0).is_finite());
// clip gain still applies
assert!((db(waveform_display_gain(1.0, -6.0)) - (-6.0)).abs() < 0.1);
}
}
+27
View File
@@ -442,3 +442,30 @@ fn keyboard_shortcuts_dialog_assigns_live_and_cancel_restores() {
assert!(d.sequence()["playhead"].as_i64().unwrap() < ph, "Timeline ▸ Left steps back");
assert_eq!(track_clips(&d.sequence(), 0).len(), n0 + 1);
}
/// Waveform peaks are cached per item id; ids repeat across projects, so opening another project
/// must not reuse the previous project's peaks (it showed a long mission-audio clip as silent).
#[test]
fn waveform_peaks_do_not_survive_opening_another_project() {
let mut d = Driver::demo();
let mut first = None;
for _ in 0..2000 {
d.frames(1);
let peaks = d.harness.state().tl.peaks.lock().unwrap().iter().map(|(k, v)| (*k, v.clone())).next();
if peaks.is_some() {
first = peaks;
break;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
let (item, old) = first.expect("the demo timeline computes waveform peaks");
// Reopen the same project from disk: same item ids, new media pool.
let path = std::env::temp_dir().join(format!("fc-peaks-{}.fcproj", std::process::id()));
let path = path.to_string_lossy().to_string();
d.exec("file.saveAs", json!({"path": path}));
d.exec("file.open", json!({"path": path}));
d.frames(2);
let _ = std::fs::remove_file(&path);
let now = d.harness.state().tl.peaks.lock().unwrap().get(&item).cloned();
assert!(now.is_none_or(|p| !std::sync::Arc::ptr_eq(&p, &old)), "peaks from the previous project were reused");
}
+2 -1
View File
@@ -11,7 +11,8 @@ version 2; version 1 projects open with no caption tracks).
burned in on export), lock, sync lock, and a **track style**:
font size (pixels at 1080 lines, scaled to the frame), text colour, background box and its colour,
outline width and colour, alignment (left/centre/right), position (top/middle/bottom) and
margin, line spacing.
margin (a fraction of the frame height, default 0.08; `captions.setStyle` clamps it to 0–0.45),
line spacing.
- **Caption**: in/out in exact `Tick`s, text (lines separated by `\n`; `<i>`, `<b>`, `<u>` kept
as written), optional speaker, and the WebVTT cue id and cue settings, kept for round trips.
Captions on a track never overlap.
+6 -1
View File
@@ -28,7 +28,12 @@ Methods (handlers in `crates/ui-egui/src/control.rs`):
| `ui.timeline.hit` / `ui.timeline.locate` | `{x,y}` / `{clip, edge?}` | timeline hit-testing |
| `ui.playback` | `{action: play|stop|toggle, speed?}` | |
| `ui.screenshot` | `{path?, panel?}` | PNG of the window or one panel; fails after 10 s when no frame is presented (window hidden, display asleep) |
| `ui.resize`, `ui.focus`, `app.quit` | | |
| `ui.resize`, `ui.focus`, `app.quit` | | `ui.focus` is the only request that activates the app and takes keyboard focus |
**Focus.** Driving the app never steals the user's keyboard. Started with `--control`, the app opens
without activating itself. UI requests and screenshots that need a rendered frame bring the window
forward without making it key (macOS `orderFrontRegardless`), so the user's typing keeps going to
the app they are in. Only an explicit `ui.focus` activates FilmCraft.
Element ids are stable, e.g. `timeline.clip.<id>`, `timeline.track.V1.lock`, `tools.Razor`,
`project.item.<id>`, `effects.item.gaussian_blur`, `panel.tab.Timeline`, `program.transport.playback.toggle`.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1005 KiB

After

Width:  |  Height:  |  Size: 1.2 MiB

+49 -11
View File
@@ -1,14 +1,52 @@
asset: filmcraft-hero.png
title: README hero: Editing workspace, Night of the Living Dead trailer
author: FilmCraft contributors (screenshot); film/footage/music credits below
title: README hero: Color workspace, "Apollo 11 - Tranquility" documentary edit
author: FilmCraft contributors (screenshot); footage, transcript and music credits below
source: original work (screenshot of FilmCraft captured with its ui.screenshot control command)
license: MIT OR Apache-2.0 (screenshot); embedded film frames are public domain, music CC0-1.0
license: MIT OR Apache-2.0 (screenshot); embedded footage and transcript text are US Government works (public domain), music CC0-1.0
license-file:
added: 2026-09-30 by Claude (agent) for Brandon Thomas
notes: Editing workspace; Program monitor on Night of the Living Dead (1968); timeline with NotLD shots, a Carnival of Souls insert on V2 and Chopin on A2. Downscaled to 2400 px. All UI icons are drawn in code; fonts are Inter and JetBrains Mono (OFL-1.1). Contains no Adobe artwork.
Media visible in the screenshot (none of it is committed to the repository):
- Night of the Living Dead (1968), dir. George A. Romero, Image Ten. US public domain (released without a copyright notice). Source: https://archive.org/details/night-of-the-living-dead-1968-english (Public Domain Mark).
- Carnival of Souls (1962), dir. Herk Harvey, Harcourt Productions. US public domain. Source: https://archive.org/details/CarnivalOfSoulsVideoQualityUpgrade (Public Domain Mark).
- Charade (1963), dir. Stanley Donen, Universal. US public domain (defective copyright notice). Source: https://archive.org/details/charade-1963-cary-grant-audrey-hepburn-comedy-mystery-romance-thriller-full-movie (Public Domain Mark).
- Earth Views from the ISS (NASA, NHQ_2020_1221, no-music edition). US Government work, public domain; NASA does not endorse this project. Source: https://images.nasa.gov/details/NHQ_2020_1221_Earth%20Views
- Chopin, Nocturne Op. 48 No. 1 and Ballade No. 1, performed for Musopen. CC0-1.0. Source: https://archive.org/details/musopen-chopin (shown only as waveforms).
added: 2026-10-01 by Claude (agent) for Brandon Thomas
notes: A 3:10 documentary cut of the Apollo 11 mission, built entirely through FilmCraft's control channel, captured
mid-playback in the Color workspace at 2400x1285 logical px (2x) and downscaled to 2400 px wide.
Shown: Effect Controls of the selected liftoff shot (Motion Scale push-in and Lumetri Exposure keyframes,
a Basic Correction grade with the Warm Film look); the Program monitor on the Saturn V clearing the
tail-service mast at Launch Complex 39A, with a lower-third title (Inter Bold/Regular) and a subtitle from the
NASA air-to-ground transcript; the Lumetri Color panel; Project bins of NASA selects, mission audio, music and
the sequence; a timeline with 49 picture cuts and dissolves on V1, B-roll cutaways on V2, titles on V3, a
caption track, nat sound, mission audio and a ducked music bed, named markers, In/Out around the launch and
a rendered (green) section; live loudness meters. All UI icons are drawn in code; fonts are Inter and
JetBrains Mono (OFL-1.1). Contains no Adobe artwork.
NASA does not endorse this project. NASA insignia visible inside the 1969 footage (e.g. on the crew van) are
incidental to the historical record and are not used as logos.
Media visible or used in the project (none of it is committed to the repository):
- "Apollo 11 25th Anniversary 'B' Roll Footage" (NASA KSC, NASA ID KSC-19690716-MH-NAS01-0001-Apollo_11_Historical_Footage_and_Broll-DVC_1560;
1969 NASA film). US Government work, public domain (credited "NASA", no secondary creator).
Source: https://images.nasa.gov/details/KSC-19690716-MH-NAS01-0001-Apollo_11_Historical_Footage_and_Broll-DVC_1560
- "Apollo 11 Activities: breakfast, C-7 exit, convoy, white room, hatch closing, launch count and launch" (NASA KSC, NASA ID
KSC-19690716-MH-NAS01-0001-Apollo_11_Breakfast_C7Exit_Convoy_WhiteRmHatchClosing_Launch-HM_0083; launch-pad camera feeds and
countdown audio). US Government work, public domain.
Source: https://images.nasa.gov/details/KSC-19690716-MH-NAS01-0001-Apollo_11_Breakfast_C7Exit_Convoy_WhiteRmHatchClosing_Launch-HM_0083
- "Apollo 11 Mission Control Center - Film Rolls" (NASA KSC, Launch Control Center Firing Room film, no audio; NASA ID
KSC-19690716-MH-NAS01-0001-Apollo_11_Mission_Control_Center_Film_Rolls1). US Government work, public domain.
Source: https://images.nasa.gov/details/KSC-19690716-MH-NAS01-0001-Apollo_11_Mission_Control_Center_Film_Rolls1
- "Apollo 11 moonwalk montage" (NASA HQ, NASA ID Apollo_11_moonwalk_montage_720p; 1969 lunar-surface TV with
air-to-ground audio). US Government work, public domain.
Source: https://images.nasa.gov/details/Apollo_11_moonwalk_montage_720p
- Subtitle text: "Apollo 11 Technical Air-to-Ground Voice Transcription" (NASA Manned Spacecraft Center, July 1969).
US Government work, public domain. Source: https://www.nasa.gov/wp-content/uploads/static/history/alsj/a11/a11transcript_tec.pdf
(lines at GET 00 00 00 04, 00 00 00 13, 00 00 01 02, 04 06 38 30, 04 06 45 40, 04 06 45 59, 04 06 46 04, 04 06 46 06,
04 13 24 48, 04 13 43 16, 04 13 43 24; speakers named from the transcript's CDR/LMP/CC codes).
- Chopin, Nocturne Op. 48 No. 1, performed for Musopen. CC0-1.0. Source: https://archive.org/details/musopen-chopin
(shown only as a waveform).
Reproduction: download the four NASA files (the "~medium" MP4 renditions; the Film Rolls file as "~large") into
target/demo-media/apollo11/src/; cut them into reels with ffmpeg used only as a fixture generator (crop the active picture,
restore 4:3 from the horizontally stretched transfers, 1440x1080 30p H.264): R01 = B-roll 120 s +456 s, R02 = 660 s +320 s,
R03 = 980 s +270 s, R04 = 1300 s +420 s, R05 = 1740 s +230 s, Firing Room = 0 s +420 s, OTV countdown = HM_0083 2340 s +426 s,
EVA montage = whole file. Run `filmcraft --control <port>` and drive it with the scripts kept (not committed) in
target/demo-media/apollo11/scripts/: build.py (bins, imports, 1920x1080 30p sequence, 49 V1 cuts), build2.py (V2 cutaways,
A2 mission audio, A3 music, track names, dissolves and audio fades), build3.py (titles with graphics.newText/graphics.set,
caption track from the transcript, markers), build_fixups.py A, build4.py (Lumetri grades, motion/exposure keyframes,
Essential Sound types, essentialSound.generateDucking on the music), build_fixups.py B, build5.py (liftoff shot as hero),
build_fixups.py C (In/Out 00:00:46:15-00:01:34:00, Render In to Out). The resulting project is
target/demo-media/Apollo 11 - Tranquility.fcproj. Capture: setup_ui.py 77.5 (Color workspace, Effect Controls tab, bins
expanded, timeline fit, Full playback resolution), then final_shot.py <out.png> 76.9 0.6 (plays from 00:01:16:27 and grabs
the window 0.6 s in, so the meters are live), then downscale to 2400 px wide.