collective

Browse / github.com/storytold/filmcraft / patch

security(render): bound nested sequence renders per frame

security(render): bound nested sequence renders per frame ## Problem `MAX_NEST_DEPTH` limits how deep nested sequences go, but not how often one sequence is drawn for a frame. A sequence that holds the next one on `k` video tracks, eight levels deep, asks for `k^8` nested renders per frame, while the project file only grows linearly (about `k × 8` clips). Measured on `main` with a 16×16 matte at the bottom: - `k = 4`: 65,536 nested renders, 7.8 s for one frame - `k = 8`: 16.7M renders, roughly half an hour per frame The Program monitor, thumbnails, playback and export all render the active sequence, so a small `.fcproj` sent to someone pins their

Status
open
Score
0.00
Adopters
0 other operators
Author
Silvertree2010 (imported from pull request #3377, open)
Base
8fda2f232198c4c79ff9cfe5b6a347bb1375fa9e
Size
+82 −0 in 2 files
Review
pending
Shared
2026-10-28
Id
6c882dce730b04f189c45807

How it went

No other operator has reported using it yet.

Diff

raw · crates/render/src/lib.rs crates/render/src/nest_tests.rs

diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs--- a/crates/render/src/lib.rs+++ b/crates/render/src/lib.rs@@ -61,6 +61,33 @@ impl<F: Fn(ItemId) -> Option<SharedSource> + Sync> SourceProvider for F { /// has one: the editor refuses the edit). pub const MAX_NEST_DEPTH: u32 = 8; +/// How many nested sequence pictures one frame may draw, all levels together. A sequence can+/// hold the next one on several tracks, so a small project file could otherwise ask for an+/// exponential number of renders per frame; past the budget a nest draws nothing, like one past+/// [`MAX_NEST_DEPTH`].+pub const MAX_NEST_RENDERS: usize = 4096;++thread_local! {+    /// (inside a frame of the outermost sequence, nested pictures drawn so far)+    static NEST_RENDERS: std::cell::Cell<(bool, usize)> = const { std::cell::Cell::new((false, 0)) };+}++/// Starts the nest budget of one frame of the outermost sequence and ends it when dropped.+struct NestFrame;++impl NestFrame {+    fn begin() -> NestFrame {+        NEST_RENDERS.with(|c| c.set((true, 0)));+        NestFrame+    }+}++impl Drop for NestFrame {+    fn drop(&mut self) {+        NEST_RENDERS.with(|c| c.set((false, 0)));+    }+}+ #[derive(Clone, Copy, Debug)] pub struct RenderOptions {     /// Output scale relative to the sequence frame size (1.0 = full, 0.5 = ½ resolution…).@@ -177,6 +204,7 @@ impl Layer { pub(crate) fn render_seq_tracks(project: &Project, seq: &Sequence, t: Tick, opts: RenderOptions, sources: &dyn SourceProvider, only: Option<usize>) -> Image {     let (w, h) = output_size(seq, opts.scale);     let mut canvas = Canvas::new(w, h);+    let _frame = (opts.depth == 0).then(NestFrame::begin);     if opts.depth > MAX_NEST_DEPTH {         return canvas.into_image();     }@@ -615,6 +643,17 @@ pub(crate) fn base_layer(             media_image(project, seq, item, t, &src, &frame, n, want)         }         ItemKind::Sequence(nested) => {+            // a single clip rendered on its own (Apply Match) starts its own budget+            let used = NEST_RENDERS.with(|c| {+                let (frame, n) = c.get();+                let n = if frame || opts.depth > 0 { n + 1 } else { 1 };+                c.set((frame, n));+                n+            });+            if used > MAX_NEST_RENDERS {+                let (nw, nh) = output_size(nested, want);+                return Some(Image::new(nw, nh));+            }             let sub = RenderOptions { scale: want, effects: opts.effects, depth: opts.depth + 1, captions: false, working_output: true };             match item.multicam_angle(nested) {                 // a multi-camera clip shows its angle's track only (nothing for an audio-only angle)diff --git a/crates/render/src/nest_tests.rs b/crates/render/src/nest_tests.rs--- a/crates/render/src/nest_tests.rs+++ b/crates/render/src/nest_tests.rs@@ -360,3 +360,46 @@ fn a_nest_that_is_not_plain_is_still_drawn_right() {         assert!(w < 0.01, "{what}: plan and reference differ by {w}");     } }++#[test]+fn a_nest_fanned_out_on_every_level_stops_at_the_render_budget() {+    use std::sync::atomic::{AtomicUsize, Ordering};+    struct Count<'a>(&'a SourceMap, AtomicUsize);+    impl SourceProvider for Count<'_> {+        fn source(&self, item: ItemId) -> Option<SharedSource> {+            self.1.fetch_add(1, Ordering::Relaxed);+            self.0.source(item)+        }+    }+    // S0 holds S1 on k tracks, S1 holds S2 on k tracks … S8 holds a matte: k^8 pictures of S8+    // for one frame of S0, from a project that grows only linearly with k+    let build = |k: usize| {+        let mut r = Rig::new();+        let red = r.matte(RED, 16, 16);+        let mut inner = r.seq("S8", 16, 16, FrameRate::FPS_24);+        r.put(inner, red, 48);+        for i in (0..8).rev() {+            let s =+                r.p.new_sequence(&format!("S{i}"), SequenceSettings { width: 16, height: 16, frame_rate: FrameRate::FPS_24, ..Default::default() }, k, 1, None);+            for t in 0..k {+                let ti =+                    r.p.make_track_item(inner, TrackKind::Video, Tick::ZERO, TimeRange::new(Tick::ZERO, FrameRate::FPS_24.tick_of(48)), FrameRate::FPS_24)+                        .unwrap();+                r.p.sequence_mut(s).unwrap().video_tracks[t].items.push(ti);+            }+            inner = s;+        }+        (r, inner)+    };+    // a modest fan-out is drawn in full+    let (r, top) = build(2);+    let c = Count(&r.map, AtomicUsize::new(0));+    let img = render_sequence(&r.p, top, Tick(1000), RenderOptions::default(), &c);+    assert_eq!(c.1.load(Ordering::Relaxed), 1 << 8);+    assert!(close(img.get(8, 8), RED));+    // a wide one stops at the budget instead of asking for 4^8 = 65,536 pictures+    let (r, top) = build(4);+    let c = Count(&r.map, AtomicUsize::new(0));+    render_sequence(&r.p, top, Tick(1000), RenderOptions::default(), &c);+    assert!(c.1.load(Ordering::Relaxed) <= MAX_NEST_RENDERS, "{} sources read", c.1.load(Ordering::Relaxed));+}