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
@@ -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)@@ -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));+}