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