M14.5: wire the remaining settings; keyframe assistants and labels; AE menu entries

Settings: 56 of 59 not-yet-wired settings now change behaviour (nested-comp switches,
constant vertex count, related-item time sync, match-name pick whip, split-layer order,
Home on open, motion path span, internal wireframes, zoom quality, label-coloured tabs,
mask colour cycling, gradients, grid/guide styles, text engine, font preview/recent/native
names, missing frames, unlabeled alpha, drag import, default output folder, segmenting,
bit depth names, preview sample rate, disk/media/conformed-audio caches, RAM reserve and
low-memory reduction, Video Preview window, 3D reference axes/extended viewer/draft
shadows, script file gate + File.execute warning, expression editor options and error
banner). Three stay display-only and are documented.

Features: Convert Audio to Keyframes, RPF Camera Import (JSON/CSV camera data),
keyframe colour labels + Select Keyframe Label Group, Recent Animation Presets.
Menus: Edit > History, File > Import Recent Footage, Replace Footage > With Layered
Comp, Save a Copy As XML (.ecprojx, opens again), Composition > VR (Create VR
Environment, Extract Cubemap), Assign Shortcut to 3D view / workspace, Window viewer
labels + open viewers, Help > System Compatibility Report / Enable Logging / Reveal
Logging File. Dynamic menu nodes (@dynamic:…) shared by the egui and native menus.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-03 02:45:44 -04:00
co-authored by Claude Opus 5.5
parent 5df98d0982
commit f855a4b55b
56 changed files with 4378 additions and 284 deletions
+124 -6
View File
@@ -10,7 +10,7 @@
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use effectcraft_project::render_queue::{OutputFormat, PostRenderAction, RenderQueueItem, RenderStatus, TemplateVars, expand_template};
use effectcraft_project::render_queue::{OutputFormat, OutputModule, PostRenderAction, RenderQueueItem, RenderStatus, TemplateVars, expand_template};
use effectcraft_project::{ItemId, ItemKind, LayerSource, Project};
use effectcraft_render::{ExprHost, FootageSource};
use serde::Serialize;
@@ -27,6 +27,8 @@ pub struct ExportJob<'a> {
pub accel: Option<&'a dyn effectcraft_render::Accelerator>,
/// Resolved output path (templates expanded except the `#` frame-number run).
pub path: &'a str,
/// Settings ▸ General ▸ Switches Affect Nested Comps.
pub nested_switches: bool,
}
/// What an export wrote.
@@ -124,8 +126,56 @@ struct Work {
expr: Option<Arc<dyn ExprHost>>,
accel: Option<Arc<dyn effectcraft_render::Accelerator>>,
exporter: Arc<dyn Exporter>,
/// Per queue item: one (item with that output module, resolved path) per output module.
/// Per queue item: one (item with that output module, resolved path) per output module
/// (and per segment, see [`Session::segments`]).
items: Vec<Vec<(RenderQueueItem, String)>>,
/// Settings ▸ General ▸ Switches Affect Nested Comps.
nested_switches: bool,
}
/// Bits per channel an output format writes for a project bit depth (named in the file when
/// Settings ▸ Export ▸ Append Bit Depth to File Name is on).
pub fn output_bits(format: OutputFormat, depth: effectcraft_project::BitDepth) -> u32 {
use effectcraft_project::BitDepth;
let project = match depth {
BitDepth::Bpc8 => 8,
BitDepth::Bpc16 => 16,
BitDepth::Bpc32 => 32,
};
match format {
OutputFormat::ExrSequence => 32,
OutputFormat::PngSequence | OutputFormat::TiffSequence => project.min(16),
OutputFormat::ProRes => 10,
OutputFormat::Wav | OutputFormat::Aiff => 16,
_ => 8,
}
}
/// The bytes an output writes per frame, estimated from its data rate (H.264 bitrate, Apple's
/// published ProRes target rates at 1080p29.97 scaled by frame size, typical rates otherwise).
pub fn bytes_per_frame(om: &OutputModule, w: u32, h: u32, fps: f64) -> f64 {
use effectcraft_project::render_queue::ProResProfile::*;
let fps = fps.max(1.0);
let px = (w as f64 * h as f64) / (1920.0 * 1080.0);
let mbps = |m: f64| m * 1_000_000.0 / 8.0;
match om.format {
OutputFormat::H264 => om.bitrate_kbps as f64 * 1000.0 / 8.0 / fps,
OutputFormat::ProRes => {
let rate = match om.prores_profile {
Proxy => 45.0,
Lt => 102.0,
Standard => 147.0,
Hq => 220.0,
P4444 => 330.0,
P4444Xq => 500.0,
};
mbps(rate) * px / 29.97
}
OutputFormat::WebM => mbps(8.0) * px / fps,
OutputFormat::Gif => w as f64 * h as f64 * 0.5,
OutputFormat::Wav | OutputFormat::Aiff => om.audio_sample_rate as f64 * 4.0 / fps,
_ => w as f64 * h as f64 * 3.0,
}
}
fn unix_now() -> u64 {
@@ -158,7 +208,15 @@ fn run_work(w: Work, shared: &JobShared) {
// first module's file is the item's output.
let mut r: Result<ExportResult, String> = Err("no output module".into());
for (mi, (mitem, path)) in modules.iter().enumerate() {
let job = ExportJob { project: &w.project, footage: w.footage.as_ref(), expr: w.expr.as_deref(), accel: w.accel.as_deref(), item: mitem, path };
let job = ExportJob {
project: &w.project,
footage: w.footage.as_ref(),
expr: w.expr.as_deref(),
accel: w.accel.as_deref(),
item: mitem,
path,
nested_switches: w.nested_switches,
};
let rr = w.exporter.export(&job, &mut |done, total| {
let mut s = lock(&shared.state);
s.done = done;
@@ -228,12 +286,58 @@ impl Session {
if path.is_absolute() {
return Some(p);
}
let base = self.path.as_deref().and_then(|s| std::path::Path::new(s).parent().map(|d| d.to_path_buf())).filter(|d| !d.as_os_str().is_empty());
// Settings ▸ Export ▸ Default Output Folder, else the project's folder.
let folder = self.prefs.export.default_output_folder.trim();
let base = (!folder.is_empty()).then(|| std::path::PathBuf::from(folder));
let base =
base.or_else(|| self.path.as_deref().and_then(|s| std::path::Path::new(s).parent().map(|d| d.to_path_buf())).filter(|d| !d.as_os_str().is_empty()));
// (the web has no working directory: relative outputs land in its virtual root)
let base = base.or_else(|| std::env::current_dir().ok()).or_else(|| cfg!(target_arch = "wasm32").then(|| "/".into()))?;
Some(base.join(path).to_string_lossy().to_string())
}
/// Settings ▸ Export ▸ Segment Sequences / Segment Movie Files: the (item, path) outputs one
/// output module writes. Sequences go into numbered folders of Files per Segment frames each
/// (`out/Comp 1_001/Comp 1_00000.png`…); movies into numbered files of about Segment Size MB
/// each (`Comp 1_001.mp4`…), cut at the frame count the output's data rate gives.
pub fn segments(&self, item: &RenderQueueItem, path: &str) -> Vec<(RenderQueueItem, String)> {
let Some(comp) = self.project.comp(item.comp) else { return vec![(item.clone(), path.to_string())] };
let fmt = item.output.format;
let frames = item.settings.frame_count(comp);
let (w, h) = item.settings.output_size(comp);
let bpf = bytes_per_frame(&item.output, w, h, item.settings.rate(comp).as_f64());
let Some(per) = self.prefs.segment_frames(frames, fmt.is_sequence(), bpf) else { return vec![(item.clone(), path.to_string())] };
let p = std::path::Path::new(path);
let dir = p.parent().map(|d| d.to_path_buf()).unwrap_or_default();
let stem = p.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_default();
let ext = p.extension().map(|s| s.to_string_lossy().to_string());
let rate = item.settings.rate(comp);
let f0 = item.settings.first_frame(comp);
let mut out = vec![];
let mut i = 0;
let mut k = 1;
while i < frames {
let n = per.min(frames - i);
let mut m = item.clone();
let (start, end) = (rate.tick_of(f0 + i as i64), rate.tick_of(f0 + (i + n) as i64));
m.settings.time_span = effectcraft_project::render_queue::TimeSpan::Custom { start, end };
let seg = if fmt.is_sequence() {
let base = stem.trim_end_matches(['#', '[', ']', '_']).to_string();
let folder = dir.join(format!("{base}_{k:03}"));
folder.join(p.file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_default())
} else {
dir.join(match &ext {
Some(e) => format!("{stem}_{k:03}.{e}"),
None => format!("{stem}_{k:03}"),
})
};
out.push((m, seg.to_string_lossy().to_string()));
i += n;
k += 1;
}
out
}
/// Whether a render job is running.
pub fn is_rendering(&self) -> bool {
self.render_job.as_ref().is_some_and(|j| !j.is_finished())
@@ -264,7 +368,13 @@ impl Session {
let mut m = it.clone();
m.output = om.clone();
match self.resolve_output(&m) {
Some(p) if formats.contains(&om.format) => modules.push((m, p)),
Some(p) if formats.contains(&om.format) => {
// Settings ▸ Export: Append Bit Depth to File Name, Segment Sequences /
// Movie Files.
let bits = output_bits(om.format, self.project.settings.bit_depth);
let p = self.prefs.output_name(&p, bits);
modules.extend(self.segments(&m, &p));
}
Some(_) => problem = Some(RenderStatus::Failed(format!("{} export is not available", om.format.label()))),
None if self.project.comp(it.comp).is_none() => problem = Some(RenderStatus::Failed("composition missing".into())),
None => problem = Some(RenderStatus::NeedsOutput),
@@ -291,7 +401,15 @@ impl Session {
return Err("nothing is queued: add a composition (Composition ▸ Add to Render Queue) and tick Render".into());
}
let ids: Vec<u64> = items.iter().filter_map(|m| m.first().map(|(i, _)| i.id)).collect();
let work = Work { project: self.project.clone(), footage: self.footage.clone(), expr: self.expr.clone(), accel: self.accel.clone(), exporter, items };
let work = Work {
project: self.project.clone(),
footage: self.footage.clone(),
expr: self.expr.clone(),
accel: self.accel.clone(),
exporter,
items,
nested_switches: self.prefs.general.switches_affect_nested_comps,
};
let shared = Arc::new(JobShared::default());
if wait {
run_work(work, &shared);