Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
0f97971
fix(audio): honor Time Remap in the shared mixer
TimotheusG Oct 18, 2026
f5e73e8
test(audio): cover remap jumps holds bounds and expressions
TimotheusG Oct 19, 2026
0ca3e0f
fix(script): bound and cancel socket writes (#1248)
TimotheusG Oct 19, 2026
771e6f4
test(text): isolate metric font fallback coverage (#1677)
TimotheusG Oct 19, 2026
b7f0cf4
fix(audio): bound remap work and prepare exact scalar expressions (#1…
TimotheusG Oct 19, 2026
254b9d5
fix(audio): bound scrub expressions and recover failed exports (#1827)
TimotheusG Oct 19, 2026
156071e
test(render): use controlled costs for Auto backend checks
TimotheusG Oct 19, 2026
f36eaf5
test(ui): serialize Windows viewer screenshot harnesses
TimotheusG Oct 19, 2026
d01b521
Audio: bound parameter scripts and stage exports before publication
TimotheusG Oct 19, 2026
6e0294a
test(render): document automatic backend fixtures
TimotheusG Oct 19, 2026
f910e04
test(ui): verify lazy GPU setup and release drag harnesses
TimotheusG Oct 19, 2026
a826f12
fix(export): retain ordinary Unix creation permissions
TimotheusG Oct 19, 2026
cb9e2cc
fix(script): retire streams after failed mode transitions
TimotheusG Oct 19, 2026
491f335
fix(export): keep staging private and retain Unix access modes
TimotheusG Oct 19, 2026
419797a
Audio: narrow Time Remap fix and defer unrelated work (#1827)
TimotheusG Oct 20, 2026
fde9868
Audio: page stretched reads and propagate pass limits (#1827)
TimotheusG Oct 20, 2026
48dfd22
Audio: identify time stretching in work-limit errors (#1827)
TimotheusG Oct 20, 2026
2229962
Audio: drain native output before natural completion (#1827)
TimotheusG Oct 20, 2026
ac28755
G3: clear queued scrub audio when mixing fails
TimotheusG Oct 20, 2026
2175376
G3: isolate scrub gesture error regression state
TimotheusG Oct 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions apps/effectcraft/src/audio_out.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,14 @@ fn preferred_buffer_frames(rate: u32) -> u32 {
(rate / 100).clamp(256, 2048)
}

/// The native output can retain samples beyond cpal's callback timestamp estimate
/// (Windows loopback measured a truncated tail with a reported 20 ms latency).
/// Allow at least 100 ms at natural completion; this does not delay explicit Stop
/// or change the A/V sync clock. Larger reported latencies keep their full allowance.
fn natural_tail_frames(rate: u32, latency: u64) -> u64 {
latency.max(u64::from(rate).div_ceil(10))
}

/// Wait until the feeder has mixed a block, or until preview is stopped. Starting the stream
/// before that fills the device with silence, so the first real samples are heard late (#554).
fn wait_to_play(feed: &AudioFeed, stop: &mpsc::Receiver<()>) -> bool {
Expand Down Expand Up @@ -211,6 +219,10 @@ impl AudioDevice for CpalOut {
fn latency_frames(&self) -> u64 {
self.latency.load(Ordering::Relaxed)
}

fn drain_frames(&self) -> u64 {
natural_tail_frames(self.rate, self.latency_frames())
}
}

impl Drop for CpalOut {
Expand All @@ -230,6 +242,13 @@ mod tests {
assert_eq!(preferred_buffer_frames(192_000), 1920);
}

#[test]
fn natural_drain_allows_output_processing_beyond_the_sync_clock() {
assert_eq!(natural_tail_frames(48_000, 960), 4800);
assert_eq!(natural_tail_frames(48_000, 9600), 9600);
assert_eq!(natural_tail_frames(44_100, 0), 4410);
}

#[test]
fn wait_to_play_returns_once_the_feeder_has_samples() {
let feed = Arc::new(AudioFeed::default());
Expand Down
3 changes: 2 additions & 1 deletion crates/engine/src/commands/keys_more.rs
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,8 @@ fn audio_to_keys(s: &mut Session, p: &Value) -> Result<Value> {
let mut rows = vec![];
for f in f0..f1 {
let t = fr.tick_of(f);
let buf = effectcraft_render::audio::mix_comp(&s.project, s.footage.as_ref(), s.expr.as_deref(), cid, t, per, RATE);
let buf = effectcraft_render::audio::mix_comp(&s.project, s.footage.as_ref(), s.expr.as_deref(), cid, t, per, RATE)
.map_err(|e| EngineError::Other(e.to_string()))?;
rows.push((t, amplitudes(&buf)));
}
let spec = effectcraft_effects::lookup("ec.control.slider").ok_or_else(|| EngineError::Other("Slider Control is missing".into()))?;
Expand Down
1 change: 1 addition & 0 deletions crates/engine/src/commands/render_queue.rs
Original file line number Diff line number Diff line change
Expand Up @@ -796,6 +796,7 @@ fn save_current_preview(s: &mut Session, p: &Value) -> Result<Value> {
item.output = OutputModule::for_format(format);
item.output.output = path.clone();
let job = crate::ExportJob {
cancel: None,
project: &s.project,
footage: s.footage.as_ref(),
expr: s.expr.as_deref(),
Expand Down
1 change: 1 addition & 0 deletions crates/engine/src/commands/timeline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ fn prerender(s: &Session, cid: ItemId, lid: LayerId, path: &str) -> std::result:
item.output.audio = AudioOutput::Off;
item.output.output = path.to_string();
let job = crate::ExportJob {
cancel: None,
project: &q,
footage: s.footage.as_ref(),
expr: s.expr.as_deref(),
Expand Down
5 changes: 4 additions & 1 deletion crates/engine/src/render_queue.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ use crate::{Event, Session};

/// One export handed to the [`Exporter`].
pub struct ExportJob<'a> {
/// Shared cancellation token, including work inside an audio block.
pub cancel: Option<Arc<AtomicBool>>,
pub project: &'a Project,
pub footage: &'a dyn FootageSource,
pub expr: Option<&'a dyn ExprHost>,
Expand Down Expand Up @@ -104,7 +106,7 @@ pub enum ItemUpdate {

#[derive(Default)]
pub struct JobShared {
pub cancel: AtomicBool,
pub cancel: Arc<AtomicBool>,
pub state: Mutex<JobState>,
pub updates: Mutex<Vec<ItemUpdate>>,
}
Expand Down Expand Up @@ -272,6 +274,7 @@ async fn run_work(w: Work, shared: &JobShared, notify: &mut dyn FnMut(&JobShared
let name = w.project.item(mitem.comp).map(|i| i.name.clone()).unwrap_or_default();
let index = w.project.render_queue.iter().position(|i| i.id == mitem.id).map_or(0, |i| i + 1);
let job = ExportJob {
cancel: Some(shared.cancel.clone()),
project: &w.project,
footage: w.footage.as_ref(),
expr: w.expr.as_deref(),
Expand Down
111 changes: 107 additions & 4 deletions crates/export/src/encode.rs
Original file line number Diff line number Diff line change
Expand Up @@ -215,9 +215,33 @@ fn deinterleave(buf: &[f32], channels: usize) -> Vec<Vec<f32>> {

/// The comp's audio over `n` samples from `start`, interleaved with the module's channel count
/// (mono = the average of left and right).
pub(crate) fn mix(cx: &Cx, start: effectcraft_time::Tick, n: usize, sr: u32) -> Vec<f32> {
let st = effectcraft_render::audio::mix_comp(&cx.project, cx.footage, cx.expr, cx.comp, start, n, sr);
if cx.output.audio_channels == 1 { st.as_chunks::<2>().0.iter().map(|p| (p[0] + p[1]) * 0.5).collect() } else { st }
pub(crate) fn mix(cx: &Cx, start: effectcraft_time::Tick, n: usize, sr: u32) -> Result<Vec<f32>> {
let control = effectcraft_render::audio::MixControl { cancel: cx.options.cancel.clone(), ..Default::default() };
// Video batches can span many seconds at low frame rates. Keep each mixer request bounded,
// retaining sample-aligned ticks even at rates that do not divide the tick clock (44.1 kHz).
const BLOCK: usize = 16_384;
let channels = if cx.output.audio_channels == 1 { 1 } else { 2 };
let len = n.checked_mul(channels).ok_or_else(|| enc("audio output is too large"))?;
let mut out = Vec::new();
out.try_reserve_exact(len).map_err(|_| enc("audio output allocation failed"))?;
for done in (0..n).step_by(BLOCK) {
let offset = i64::try_from(done).map_err(|_| enc("audio sample offset is too large"))?;
let sample = start.to_units_floor(i64::from(sr)).checked_add(offset).ok_or_else(|| enc("audio sample offset overflow"))?;
let tick = Tick::from_units(sample, i64::from(sr));
let tick = if tick.to_units_floor(i64::from(sr)) < sample { Tick(tick.0.saturating_add(1)) } else { tick };
let st = effectcraft_render::audio::mix_comp_with_control(&cx.project, cx.footage, cx.expr, cx.comp, tick, BLOCK.min(n - done), sr, &control).map_err(
|e| match e {
effectcraft_render::audio::AudioError::Cancelled => ExportError::Cancelled,
_ => ExportError::Encode(e.to_string()),
},
)?;
if channels == 1 {
out.extend(st.as_chunks::<2>().0.iter().map(|p| (p[0] + p[1]) * 0.5));
} else {
out.extend(st);
}
}
Ok(out)
}

/// PCM bytes of samples in `fmt` (little or big endian).
Expand Down Expand Up @@ -334,7 +358,7 @@ pub(crate) async fn movie(job: &Cx<'_>, comp: &Comp, w: u32, h: u32, st: &mut St
let n = (end - *cursor) as usize;
let start = Tick::from_units(*cursor, i64::from(sr));
let start = if start.to_units_floor(i64::from(sr)) < *cursor { Tick(start.0.saturating_add(1)) } else { start };
let buf = mix(job, start, n, sr);
let buf = mix(job, start, n, sr)?;
*cursor = end;
match a {
Audio::Aac(e) => {
Expand Down Expand Up @@ -384,6 +408,85 @@ pub(crate) async fn movie(job: &Cx<'_>, comp: &Comp, w: u32, h: u32, st: &mut St
mod tests {
use super::*;

/// Chunked mixing preserves every sample at non-dividing rates, stereo/mono, and cancellation.
#[test]
fn large_audio_mix_preserves_samples_and_cancellation() {
use effectcraft_project::{Footage, ItemId, ItemKind, LayerSource, Project, build};
use effectcraft_render::{FootageSource, Image};
use std::sync::{
Arc,
atomic::{AtomicBool, AtomicUsize, Ordering},
};
struct Ramp {
reads: AtomicUsize,
cancel: Option<Arc<AtomicBool>>,
}
impl FootageSource for Ramp {
fn frame(&self, _: ItemId, _: &Footage, _: Tick) -> Option<Arc<Image>> {
None
}
fn audio(&self, _: ItemId, _: &Footage, start: Tick, frames: usize, rate: u32) -> Option<Vec<f32>> {
if self.reads.fetch_add(1, Ordering::Relaxed) == 1
&& let Some(c) = &self.cancel
{
c.store(true, Ordering::Relaxed);
}
let first = start.to_units_floor(i64::from(rate));
Some(
(0..frames)
.flat_map(|i| {
let x = ((first + i as i64).rem_euclid(4096) as f32) / 4096.0;
[x, x * 0.5]
})
.collect(),
)
}
}
let mut p = Project::default();
let mut comp = Comp::new(16, 16, FrameRate::new(1, 1), Tick::from_seconds_f64(100.0));
let item = p.add_item("ramp", effectcraft_color::Label::SeaFoam, None, ItemKind::Footage(Footage { has_audio: true, ..Default::default() }));
comp.layers.push(build::layer(&mut p, &comp, "ramp", LayerSource::Footage { item }, (0, 0), None));
let cid = p.add_item("audio", effectcraft_color::Label::Sandstone, None, ItemKind::Comp(comp.into()));
for rate in [44100, 192000] {
for channels in [1, 2] {
let mut output = effectcraft_project::render_queue::OutputModule::for_format(OutputFormat::Wav);
output.audio_channels = channels;
let cancel = Arc::new(AtomicBool::new(false));
let source = Ramp { reads: AtomicUsize::new(0), cancel: None };
let job = crate::Job {
project: &p,
footage: &source,
expr: None,
accel: None,
comp: cid,
settings: &Default::default(),
output: &output,
path: "unused.wav",
sink: None,
options: crate::JobOptions { cancel: Some(cancel.clone()), ..Default::default() },
nested_switches: true,
};
let cx = Cx::new(&job);
let start = Tick(Tick::from_units(73, rate as i64).0 + 1);
let count = 1_100_003;
let actual = mix(&cx, start, count, rate).unwrap();
assert_eq!(actual.len(), count * channels as usize);
for i in 0..count {
let x = ((73 + i) % 4096) as f32 / 4096.0;
if channels == 1 {
assert_eq!(actual[i], (x + x * 0.5) * 0.5);
} else {
assert_eq!(&actual[i * 2..i * 2 + 2], &[x, x * 0.5]);
}
}
let source = Ramp { reads: AtomicUsize::new(0), cancel: Some(cancel) };
let job = crate::Job { footage: &source, ..job };
assert!(matches!(mix(&Cx::new(&job), start, count, rate), Err(ExportError::Cancelled)));
assert_eq!(source.reads.load(Ordering::Relaxed), 2, "stop during the second block");
}
}
}

#[test]
fn yuv_of_primaries() {
let rgba = [255u8, 255, 255, 255, 0, 0, 0, 255, 255, 0, 0, 255, 0, 255, 0, 255];
Expand Down
2 changes: 2 additions & 0 deletions crates/export/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,8 @@ pub struct Job<'a> {
/// Job options beyond the Render Settings and Output Module.
#[derive(Default)]
pub struct JobOptions<'a> {
/// Interrupt audio mixing within a batch; callback cancellation still works between batches.
pub cancel: Option<std::sync::Arc<std::sync::atomic::AtomicBool>>,
/// The render log written next to the output.
pub log: RenderLog,
/// What the log calls the job (e.g. "#2 Main Comp").
Expand Down
13 changes: 7 additions & 6 deletions crates/export/src/webm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -229,13 +229,13 @@ pub(crate) async fn webm(job: &Cx<'_>, comp: &Comp, w: u32, h: u32, st: &mut Sta
let mut cursor = span_start.to_units_floor(OPUS_RATE as i64);
let mut pcm_left: Vec<f32> = vec![];
let mut packets = 0u64;
let mut audio_until = |until: Tick, frames: &mut Vec<Frame>, finish: bool| {
let Some(enc) = opus.as_mut() else { return };
let mut audio_until = |until: Tick, frames: &mut Vec<Frame>, finish: bool| -> Result<()> {
let Some(enc) = opus.as_mut() else { return Ok(()) };
let end = until.to_units_floor(OPUS_RATE as i64);
if end > cursor {
let n = (end - cursor) as usize;
let start = Tick(((cursor as i128 * TICKS_PER_SECOND as i128) / OPUS_RATE as i128) as i64);
pcm_left.extend(mix(job, start, n, OPUS_RATE));
pcm_left.extend(mix(job, start, n, OPUS_RATE)?);
cursor = end;
}
let fs = effectcraft_opusenc::OpusEncoder::FRAME_SIZE * opus_channels;
Expand All @@ -250,6 +250,7 @@ pub(crate) async fn webm(job: &Cx<'_>, comp: &Comp, w: u32, h: u32, st: &mut Sta
frames.push(Frame { ms: packets as i64 * 20, track: 2, key: true, prev_ms: None, data: pkt, alpha: None });
packets += 1;
}
Ok(())
};

let batch = batch_size();
Expand Down Expand Up @@ -279,7 +280,7 @@ pub(crate) async fn webm(job: &Cx<'_>, comp: &Comp, w: u32, h: u32, st: &mut Sta
}
drop(pixels);
let t_end = if end >= total { span_end } else { job.settings.frame_time(comp, end) };
audio_until(t_end, &mut frames, end >= total);
audio_until(t_end, &mut frames, end >= total)?;
let c = cluster(&mut frames);
file.write_all(&c).map_err(io)?;
seg_len += c.len() as u64;
Expand All @@ -288,7 +289,7 @@ pub(crate) async fn webm(job: &Cx<'_>, comp: &Comp, w: u32, h: u32, st: &mut Sta
}
if total == 0 {
let mut frames = vec![];
audio_until(span_end, &mut frames, true);
audio_until(span_end, &mut frames, true)?;
if !frames.is_empty() {
let c = cluster(&mut frames);
file.write_all(&c).map_err(io)?;
Expand Down Expand Up @@ -347,7 +348,7 @@ pub(crate) fn audio_file(job: &Cx, comp: &Comp, aiff: bool, st: &mut State) -> R
let sample = start + done as i64;
let t = Tick::from_units(sample, i64::from(sr));
let t = if t.to_units_floor(i64::from(sr)) < sample { Tick(t.0.saturating_add(1)) } else { t };
pcm.extend(pcm_bytes(&mix(job, t, k, sr), fmt, aiff));
pcm.extend(pcm_bytes(&mix(job, t, k, sr)?, fmt, aiff));
done += k;
let now = (done as u64 * total_frames / n.max(1) as u64).min(total_frames);
if now > reported && st.total > 0 {
Expand Down
11 changes: 6 additions & 5 deletions crates/export/src/webm_av1.rs
Original file line number Diff line number Diff line change
Expand Up @@ -110,13 +110,13 @@ pub(crate) async fn webm_av1(job: &Cx<'_>, comp: &Comp, w: u32, h: u32, st: &mut
let mut cursor = span_start.to_units_floor(OPUS_RATE as i64);
let mut pcm_left: Vec<f32> = vec![];
let mut packets = 0u64;
let mut audio_until = |until: Tick, blocks: &mut Vec<Block>, finish: bool| {
let Some(enc) = opus.as_mut() else { return };
let mut audio_until = |until: Tick, blocks: &mut Vec<Block>, finish: bool| -> Result<()> {
let Some(enc) = opus.as_mut() else { return Ok(()) };
let end = until.to_units_floor(OPUS_RATE as i64);
if end > cursor {
let n = (end - cursor) as usize;
let start = Tick(((cursor as i128 * TICKS_PER_SECOND as i128) / OPUS_RATE as i128) as i64);
pcm_left.extend(mix(job, start, n, OPUS_RATE));
pcm_left.extend(mix(job, start, n, OPUS_RATE)?);
cursor = end;
}
let fs = effectcraft_opusenc::OpusEncoder::FRAME_SIZE * opus_channels;
Expand All @@ -130,6 +130,7 @@ pub(crate) async fn webm_av1(job: &Cx<'_>, comp: &Comp, w: u32, h: u32, st: &mut
blocks.push(Block { ms: packets as i64 * 20, track: 2, key: true, data: pkt });
packets += 1;
}
Ok(())
};

let batch = batch_size();
Expand All @@ -156,13 +157,13 @@ pub(crate) async fn webm_av1(job: &Cx<'_>, comp: &Comp, w: u32, h: u32, st: &mut
}
}
let t_end = if end >= total { span_end } else { job.settings.frame_time(comp, end) };
audio_until(t_end, &mut blocks, end >= total);
audio_until(t_end, &mut blocks, end >= total)?;
flush_blocks(&mut blocks, &mut file, &mut seg_len)?;
st.advance(end - i)?;
i = end;
}
if total == 0 {
audio_until(span_end, &mut blocks, true);
audio_until(span_end, &mut blocks, true)?;
flush_blocks(&mut blocks, &mut file, &mut seg_len)?;
}
let mut size = [0u8; 8];
Expand Down
40 changes: 40 additions & 0 deletions crates/export/tests/render_settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,12 @@ fn project(rate: FrameRate, extra: impl FnOnce(&mut Project, &mut Comp)) -> (Pro

type Files = Arc<Mutex<Vec<(String, Vec<u8>)>>>;

/// Select an exact export span without changing the project's timing.
fn custom(start: f64, end: f64) -> RenderSettings {
RenderSettings { time_span: TimeSpan::Custom { start: Tick::from_seconds_f64(start), end: Tick::from_seconds_f64(end) }, ..Default::default() }
}

/// Collect finished in-memory outputs alongside the export result.
fn run_with(p: &Project, cid: ItemId, s: &RenderSettings, om: &OutputModule, path: &str, options: JobOptions, cancel: bool) -> (Result<Report, String>, Files) {
run_on(p, cid, s, om, path, options, None, cancel)
}
Expand Down Expand Up @@ -361,6 +363,7 @@ fn wav_and_aiff_channels_and_formats() {
assert_eq!(u16::from_be_bytes([d[26], d[27]]), 24, "sample size");
}

/// Cancellation preserves unrelated and skipped files, including overflow destinations.
#[test]
fn cancelled_export_removes_only_files_it_opened() {
use effectcraft_export::ExportError;
Expand Down Expand Up @@ -476,3 +479,40 @@ fn a_job_renders_on_one_compositor() {
assert_eq!(cpu.frames.load(Ordering::SeqCst), 0);
assert_eq!(c[0].get_pixel(10, 44).0, [255, 0, 0, 255], "the CPU drew the red block");
}

/// Cancellation and excessive audio work propagate through the encoder instead of producing a report.
#[test]
fn audio_mix_failures_abort_export() {
let (p, cid) = project(FrameRate::new(25, 1), |_, _| {});
let om = OutputModule::for_format(OutputFormat::Wav);
let options = JobOptions { cancel: Some(Arc::new(std::sync::atomic::AtomicBool::new(true))), ..Default::default() };
let (result, _) = run_with(&p, cid, &custom(0.0, 0.5), &om, "/cancel.wav", options, false);
assert_eq!(result.unwrap_err(), "cancelled");
let (p, cid) = project(FrameRate::new(25, 1), |p, c| {
let f = effectcraft_project::Footage { has_audio: true, ..Default::default() };
let id = p.add_item("audio", Label::SeaFoam, None, ItemKind::Footage(f));
let mut layer = build::layer(p, c, "audio", LayerSource::Footage { item: id }, (0, 0), None);
layer.stretch = 0.000001;
c.layers.push(layer);
});
let (result, _) = run_with(&p, cid, &custom(0.0, 0.5), &om, "/limited.wav", JobOptions::default(), false);
assert!(result.unwrap_err().contains("audio mixing exceeded its work limit"));
}

/// Valid low-frame-rate movie exports must split large audio requests into bounded mixes.
#[test]
fn low_frame_rate_audio_exports_are_not_rejected_by_the_mix_limit() {
let (mut p, cid) = project(FrameRate::new(1, 30), |_, _| {});
p.comp_mut(cid).unwrap().duration = Tick::from_seconds_f64(30.0);
for (format, codec) in
[(OutputFormat::Uncompressed, WebmVideoCodec::Vp9), (OutputFormat::WebM, WebmVideoCodec::Vp9), (OutputFormat::WebM, WebmVideoCodec::Av1)]
{
let mut om = OutputModule::for_format(format);
om.webm_codec = codec;
om.audio = AudioOutput::On;
om.audio_sample_rate = 48000;
let (result, files) = run_with(&p, cid, &custom(0.0, 30.0), &om, "/slow.mov", JobOptions::default(), false);
assert!(result.is_ok(), "{format:?}/{codec:?}: {result:?}");
assert_eq!(files.lock().unwrap().len(), 1);
}
}
Loading