diff --git a/crates/captions/src/burn.rs b/crates/captions/src/burn.rs
--- a/crates/captions/src/burn.rs
+++ b/crates/captions/src/burn.rs
@@ -1,13 +1,14 @@
 //! Caption layout and burn-in.
 //!
 //! [`overlay`] lays out the caption showing on a track at a time with the track's
-//! [`CaptionStyle`] (size relative to a 1080-line frame, colour, background box, outline,
+//! [`CaptionStyle`] (size relative to a 1080-line frame, colour, background box per line or around
+//! the whole caption and its padding, outline,
 //! alignment, anchor and margin; WebVTT `line:N%` and `align:` cue settings override the style)
 //! and rasterises it into a small premultiplied linear-light RGBA [`Overlay`] positioned in the
 //! frame. Renderers composite overlays over the finished picture.
 
 use filmcraft_color::srgb_to_linear;
-use filmcraft_project::{Caption, CaptionAlign, CaptionAnchor, CaptionStyle, CaptionTrack, Sequence};
+use filmcraft_project::{Caption, CaptionAlign, CaptionAnchor, CaptionBoxMode, CaptionStyle, CaptionTrack, Sequence};
 use filmcraft_time::Tick;
 
 use filmcraft_text::{ParagraphStyle, TextStyle, layout, render};
@@ -66,6 +67,23 @@ fn lin(c: [u8; 4]) -> [f32; 4] {
     [srgb_to_linear(c[0] as f32 / 255.0) * a, srgb_to_linear(c[1] as f32 / 255.0) * a, srgb_to_linear(c[2] as f32 / 255.0) * a, a]
 }
 
+/// Add the box `x0..x1` × `y0..y1` (by area coverage) to the `ow`×`oh` mask at `(ox, oy)` in the frame.
+fn fill_box(bg: &mut [f32], (ox, oy, ow, oh): (i32, i32, usize, usize), (x0, y0, x1, y1): (f32, f32, f32, f32)) {
+    if !(x1 > x0 && y1 > y0) {
+        return;
+    }
+    let (x_end, y_end) = (ox.saturating_add(i32::try_from(ow).unwrap_or(i32::MAX)), oy.saturating_add(i32::try_from(oh).unwrap_or(i32::MAX)));
+    for y in (y0.floor() as i32).max(oy)..(y1.ceil() as i32).min(y_end) {
+        let fy = ((y as f32 + 1.0).min(y1) - (y as f32).max(y0)).clamp(0.0, 1.0);
+        for x in (x0.floor() as i32).max(ox)..(x1.ceil() as i32).min(x_end) {
+            let fx = ((x as f32 + 1.0).min(x1) - (x as f32).max(x0)).clamp(0.0, 1.0);
+            if let Some(v) = bg.get_mut((y - oy) as usize * ow + (x - ox) as usize) {
+                *v = (*v + fx * fy).min(1.0);
+            }
+        }
+    }
+}
+
 /// Word-wrap one line to `max_w` pixels.
 fn wrap(line: &str, ts: &TextStyle, max_w: f32) -> Vec<String> {
     let mut out = Vec::new();
@@ -131,16 +149,25 @@ pub fn render_caption(c: &Caption, style: &CaptionStyle, w: usize, h: usize) ->
     let vm = filmcraft_text::fonts::face(filmcraft_text::resolve(&ts.family, &ts.style).face).metrics(px);
     let (asc, desc) = (vm.ascent, vm.descent);
     let lh = px * style.line_spacing.clamp(0.8, 4.0);
-    let pad_x = (px * 0.3).round();
+    // automatic padding: 0.3 × size left and right, none above or below; set padding scales with
+    // the frame like the size (hostile values are clamped)
+    let (pad_x, pad_y) = match style.background_padding {
+        Some([x, y]) => {
+            let px_at = |v: f32| if v.is_finite() { (v.clamp(0.0, 500.0) * scale).round() } else { 0.0 };
+            (px_at(x), px_at(y))
+        }
+        None => ((px * 0.3).round(), 0.0),
+    };
     let block_h = lh * lines.len() as f32;
     let (line_override, align_override) = vtt_overrides(&c.settings);
     let align = align_override.unwrap_or(style.align);
     let margin = style.margin.clamp(0.0, 0.45) * h as f32;
     let top = match (line_override, style.anchor) {
         (Some(f), _) => (f * h as f32).min(h as f32 - block_h),
-        (None, CaptionAnchor::Top) => margin,
+        // the margin is measured to the box edge, padding included
+        (None, CaptionAnchor::Top) => margin + pad_y,
         (None, CaptionAnchor::Middle) => (h as f32 - block_h) / 2.0,
-        (None, CaptionAnchor::Bottom) => h as f32 - margin - block_h,
+        (None, CaptionAnchor::Bottom) => h as f32 - margin - pad_y - block_h,
     }
     .max(0.0)
     .round();
@@ -158,35 +185,36 @@ pub fn render_caption(c: &Caption, style: &CaptionStyle, w: usize, h: usize) ->
     let grow = outline.ceil() + 1.0;
     let bx0 = xs.iter().zip(&widths).map(|(x, _)| x - pad_x - grow).fold(f32::MAX, f32::min).floor();
     let bx1 = xs.iter().zip(&widths).map(|(x, lw)| x + lw + pad_x + grow).fold(f32::MIN, f32::max).ceil();
-    let by0 = (top - grow).floor();
-    let by1 = (top + block_h + grow).ceil();
+    let by0 = (top - pad_y - grow).floor();
+    let by1 = (top + block_h + pad_y + grow).ceil();
     // Text can be wider than the frame (an unbroken imported WebVTT word, for example). Raster
     // only the visible intersection; `draw` receives the original coordinates and clips into it.
-    let (left, top) = (bx0.clamp(0.0, w as f32), by0.clamp(0.0, h as f32));
+    let (left, clip_top) = (bx0.clamp(0.0, w as f32), by0.clamp(0.0, h as f32));
     let (right, bottom) = (bx1.clamp(0.0, w as f32), by1.clamp(0.0, h as f32));
-    if right <= left || bottom <= top {
+    if right <= left || bottom <= clip_top {
         return None;
     }
-    let (ox, oy) = (left as i32, top as i32);
-    let (ow, oh) = ((right - left) as usize, (bottom - top) as usize);
+    let (ox, oy) = (left as i32, clip_top as i32);
+    let (ow, oh) = ((right - left) as usize, (bottom - clip_top) as usize);
     if ow.checked_mul(oh).is_none_or(|pixels| pixels > MAX_OVERLAY_PIXELS) {
         return None;
     }
+    // the lines are laid out from the overlay's top edge inside the padding, as they always were,
+    // except that padding pushed above the frame is clipped rather than moving the text down
+    let top = (by0 + pad_y).max(0.0);
     let mut cover = vec![0.0f32; ow * oh];
     let mut bg = vec![0.0f32; ow * oh];
+    let draw_box = style.background && style.background_color[3] > 0;
+    if draw_box && style.box_mode == CaptionBoxMode::Block {
+        // one box: the widest line ± padding across, all lines ± padding down
+        let text_l = xs.iter().fold(f32::MAX, |a, &x| a.min(x));
+        let text_r = xs.iter().zip(&widths).fold(f32::MIN, |a, (x, lw)| a.max(x + lw));
+        fill_box(&mut bg, (ox, oy, ow, oh), (text_l - pad_x, top - pad_y, text_r + pad_x, top + block_h + pad_y));
+    }
     for (i, line) in lines.iter().enumerate() {
         let lt = top + lh * i as f32;
-        if style.background && style.background_color[3] > 0 {
-            let (x0, x1) = (xs[i] - pad_x, xs[i] + widths[i] + pad_x);
-            let (y0, y1) = (lt, lt + lh);
-            for y in (y0.floor() as i32).max(oy)..(y1.ceil() as i32).min(oy + oh as i32) {
-                let fy = ((y as f32 + 1.0).min(y1) - (y as f32).max(y0)).clamp(0.0, 1.0);
-                for x in (x0.floor() as i32).max(ox)..(x1.ceil() as i32).min(ox + ow as i32) {
-                    let fx = ((x as f32 + 1.0).min(x1) - (x as f32).max(x0)).clamp(0.0, 1.0);
-                    let j = (y - oy) as usize * ow + (x - ox) as usize;
-                    bg[j] = (bg[j] + fx * fy).min(1.0);
-                }
-            }
+        if draw_box && style.box_mode == CaptionBoxMode::Line {
+            fill_box(&mut bg, (ox, oy, ow, oh), (xs[i] - pad_x, lt - pad_y, xs[i] + widths[i] + pad_x, lt + lh + pad_y));
         }
         let baseline = lt + (lh - (asc + desc)) / 2.0 + asc;
         let l = layout(line, &ts, &ParagraphStyle::default());
@@ -377,6 +405,255 @@ mod tests {
         }
     }
 
+    /// `render_caption` as it was before the caption box settings (#3354-#3358), kept verbatim
+    /// (with the SemiBold face inlined) so every new style field can prove its default leaves
+    /// existing projects pixel-identical.
+    #[allow(clippy::needless_range_loop)]
+    fn reference_render_caption(c: &Caption, style: &CaptionStyle, w: usize, h: usize) -> Option<Overlay> {
+        if w == 0 || h == 0 {
+            return None;
+        }
+        let scale = h as f32 / 1080.0;
+        let px = (style.size.clamp(4.0, MAX_CAPTION_SIZE_1080) * scale).clamp(4.0, h as f32);
+        let max_w = w as f32 * 0.9;
+        let family = if style.font.trim().is_empty() { filmcraft_text::fonts::DEFAULT_FAMILY } else { style.font.trim() };
+        let ts = TextStyle { family: family.into(), style: "SemiBold".into(), size: px, ..Default::default() };
+        let mut lines: Vec<String> = Vec::new();
+        // (speaker names are metadata; like Premiere, they are not burned in)
+        for l in c.plain_lines() {
+            lines.extend(wrap(l.trim(), &ts, max_w));
+        }
+        lines.retain(|l| !l.is_empty());
+        if lines.is_empty() {
+            return None;
+        }
+        let vm = filmcraft_text::fonts::face(filmcraft_text::resolve(&ts.family, &ts.style).face).metrics(px);
+        let (asc, desc) = (vm.ascent, vm.descent);
+        let lh = px * style.line_spacing.clamp(0.8, 4.0);
+        let pad_x = (px * 0.3).round();
+        let block_h = lh * lines.len() as f32;
+        let (line_override, align_override) = vtt_overrides(&c.settings);
+        let align = align_override.unwrap_or(style.align);
+        let margin = style.margin.clamp(0.0, 0.45) * h as f32;
+        let top = match (line_override, style.anchor) {
+            (Some(f), _) => (f * h as f32).min(h as f32 - block_h),
+            (None, CaptionAnchor::Top) => margin,
+            (None, CaptionAnchor::Middle) => (h as f32 - block_h) / 2.0,
+            (None, CaptionAnchor::Bottom) => h as f32 - margin - block_h,
+        }
+        .max(0.0)
+        .round();
+        let side = w as f32 * 0.05;
+        let widths: Vec<f32> = lines.iter().map(|l| filmcraft_text::measure(l, &ts)).collect();
+        let xs: Vec<f32> = widths
+            .iter()
+            .map(|&lw| match align {
+                CaptionAlign::Left => side + pad_x,
+                CaptionAlign::Right => w as f32 - side - pad_x - lw,
+                CaptionAlign::Center => (w as f32 - lw) / 2.0,
+            })
+            .collect();
+        let outline = (style.outline * scale).clamp(0.0, MAX_OUTLINE_RADIUS);
+        let grow = outline.ceil() + 1.0;
+        let bx0 = xs.iter().zip(&widths).map(|(x, _)| x - pad_x - grow).fold(f32::MAX, f32::min).floor();
+        let bx1 = xs.iter().zip(&widths).map(|(x, lw)| x + lw + pad_x + grow).fold(f32::MIN, f32::max).ceil();
+        let by0 = (top - grow).floor();
+        let by1 = (top + block_h + grow).ceil();
+        // Text can be wider than the frame (an unbroken imported WebVTT word, for example). Raster
+        // only the visible intersection; `draw` receives the original coordinates and clips into it.
+        let (left, top) = (bx0.clamp(0.0, w as f32), by0.clamp(0.0, h as f32));
+        let (right, bottom) = (bx1.clamp(0.0, w as f32), by1.clamp(0.0, h as f32));
+        if right <= left || bottom <= top {
+            return None;
+        }
+        let (ox, oy) = (left as i32, top as i32);
+        let (ow, oh) = ((right - left) as usize, (bottom - top) as usize);
+        if ow.checked_mul(oh).is_none_or(|pixels| pixels > MAX_OVERLAY_PIXELS) {
+            return None;
+        }
+        let mut cover = vec![0.0f32; ow * oh];
+        let mut bg = vec![0.0f32; ow * oh];
+        for (i, line) in lines.iter().enumerate() {
+            let lt = top + lh * i as f32;
+            if style.background && style.background_color[3] > 0 {
+                let (x0, x1) = (xs[i] - pad_x, xs[i] + widths[i] + pad_x);
+                let (y0, y1) = (lt, lt + lh);
+                for y in (y0.floor() as i32).max(oy)..(y1.ceil() as i32).min(oy + oh as i32) {
+                    let fy = ((y as f32 + 1.0).min(y1) - (y as f32).max(y0)).clamp(0.0, 1.0);
+                    for x in (x0.floor() as i32).max(ox)..(x1.ceil() as i32).min(ox + ow as i32) {
+                        let fx = ((x as f32 + 1.0).min(x1) - (x as f32).max(x0)).clamp(0.0, 1.0);
+                        let j = (y - oy) as usize * ow + (x - ox) as usize;
+                        bg[j] = (bg[j] + fx * fy).min(1.0);
+                    }
+                }
+            }
+            let baseline = lt + (lh - (asc + desc)) / 2.0 + asc;
+            let l = layout(line, &ts, &ParagraphStyle::default());
+            let mut m = filmcraft_text::Mask { w: ow, h: oh, a: std::mem::take(&mut cover) };
+            render::draw(&l, &render::at(xs[i], baseline.round()), &mut m, (ox, oy));
+            cover = m.a;
+        }
+        let stroke = if outline > 0.0 { dilate(&cover, ow, oh, outline) } else { Vec::new() };
+        let (tc, bc, oc) = (lin(style.color), lin(style.background_color), lin(style.outline_color));
+        let mut out = vec![0.0f32; ow * oh * 4];
+        for j in 0..ow * oh {
+            let mut p = [0.0f32; 4];
+            let over = |p: &mut [f32; 4], c: [f32; 4], a: f32| {
+                if a > 0.0 {
+                    for k in 0..4 {
+                        p[k] = c[k] * a + p[k] * (1.0 - c[3] * a);
+                    }
+                }
+            };
+            over(&mut p, bc, bg[j]);
+            if !stroke.is_empty() {
+                over(&mut p, oc, stroke[j]);
+            }
+            over(&mut p, tc, cover[j]);
+            out[j * 4..j * 4 + 4].copy_from_slice(&p);
+        }
+        Some(Overlay { x: ox, y: oy, w: ow, h: oh, px: out })
+    }
+
+    /// Styles and captions existing projects use, at the frame sizes they are drawn at.
+    fn reference_cases() -> Vec<(Caption, CaptionStyle, usize, usize)> {
+        let (long, unbroken) = ("word ".repeat(40), "x".repeat(300));
+        let texts = ["Hello world", "Two\nlines here", long.as_str(), unbroken.as_str(), "<i>Styled</i> &amp; done"];
+        let styles = [
+            CaptionStyle::default(),
+            CaptionStyle { anchor: CaptionAnchor::Top, align: CaptionAlign::Left, size: 40.0, ..Default::default() },
+            CaptionStyle { anchor: CaptionAnchor::Middle, align: CaptionAlign::Right, line_spacing: 1.6, ..Default::default() },
+            CaptionStyle { outline: 3.0, background_color: [20, 40, 200, 128], margin: 0.31, ..Default::default() },
+            CaptionStyle { background: false, font: "Noto Serif".into(), ..Default::default() },
+            CaptionStyle { size: 200.0, margin: 0.0, anchor: CaptionAnchor::Top, ..Default::default() },
+        ];
+        let mut out = Vec::new();
+        for (w, h) in [(1920, 1080), (1280, 720), (1080, 1920), (640, 360)] {
+            for st in &styles {
+                for t in texts {
+                    let mut c = cap(t);
+                    out.push((c.clone(), st.clone(), w, h));
+                    c.settings = "line:30% align:end".into();
+                    out.push((c, st.clone(), w, h));
+                }
+            }
+        }
+        out
+    }
+
+    #[test]
+    fn default_style_renders_like_the_reference() {
+        for (c, st, w, h) in reference_cases() {
+            assert_eq!(render_caption(&c, &st, w, h), reference_render_caption(&c, &st, w, h), "{:?} {w}x{h} {st:?}", c.text);
+        }
+    }
+
+    /// Alpha at frame pixel `(x, y)` of an overlay (0 outside it).
+    fn alpha(o: &Overlay, x: i32, y: i32) -> f32 {
+        let (lx, ly) = (x - o.x, y - o.y);
+        if lx < 0 || ly < 0 || lx as usize >= o.w || ly as usize >= o.h {
+            return 0.0;
+        }
+        o.px.get((ly as usize * o.w + lx as usize) * 4 + 3).copied().unwrap_or(0.0)
+    }
+
+    /// Frame columns `[first, last]` with alpha > 0.5 on row `y`.
+    fn row_span(o: &Overlay, y: i32) -> Option<(i32, i32)> {
+        let xs: Vec<i32> = (o.x..o.x + o.w as i32).filter(|&x| alpha(o, x, y) > 0.5).collect();
+        Some((*xs.first()?, *xs.last()?))
+    }
+
+    /// Frame rows `[first, last]` with alpha > 0.5 in column `x`.
+    fn col_span(o: &Overlay, x: i32) -> Option<(i32, i32)> {
+        let ys: Vec<i32> = (o.y..o.y + o.h as i32).filter(|&y| alpha(o, x, y) > 0.5).collect();
+        Some((*ys.first()?, *ys.last()?))
+    }
+
+    #[test]
+    fn background_padding_sets_the_box_edges() {
+        let ts = caption_style("Inter", 58.0);
+        let text_w = filmcraft_text::measure("Hello world", &ts);
+        for (pad, scale_h) in [([20.0_f32, 20.0], 1080), ([30.0, 10.0], 1080), ([30.0, 10.0], 2160)] {
+            let st = CaptionStyle { size: 58.0, background_color: [0, 0, 0, 255], background_padding: Some(pad), ..Default::default() };
+            let (w, h) = (scale_h * 16 / 9, scale_h);
+            let k = h as f32 / 1080.0;
+            let o = render_caption(&cap("Hello world"), &st, w, h).unwrap();
+            let (x0, x1) = row_span(&o, o.y + o.h as i32 / 2).unwrap();
+            let (y0, y1) = col_span(&o, w as i32 / 2).unwrap();
+            let box_w = (x1 - x0 + 1) as f32;
+            let want_w = text_w * k + 2.0 * (pad[0] * k).round();
+            assert!((box_w - want_w).abs() <= 2.0, "{pad:?}@{h}: box width {box_w}, expected {want_w}");
+            let box_h = (y1 - y0 + 1) as f32;
+            let want_h = 58.0 * k * st.line_spacing + 2.0 * (pad[1] * k).round();
+            assert!((box_h - want_h).abs() <= 2.0, "{pad:?}@{h}: box height {box_h}, expected {want_h}");
+            // the bottom margin is measured to the box edge, padding included
+            let bottom = (y1 + 1) as f32;
+            assert!((bottom - (h as f32 - 0.08 * h as f32)).abs() <= 2.0, "{pad:?}@{h}: box bottom {bottom}");
+        }
+    }
+
+    #[test]
+    fn block_box_wraps_the_whole_caption() {
+        let st = CaptionStyle {
+            size: 58.0,
+            background_color: [0, 0, 0, 255],
+            background_padding: Some([20.0, 20.0]),
+            box_mode: CaptionBoxMode::Block,
+            ..Default::default()
+        };
+        let c = cap("Hi\nA much longer second line");
+        let o = render_caption(&c, &st, 1920, 1080).unwrap();
+        let ts = caption_style("Inter", 58.0);
+        let long = filmcraft_text::measure("A much longer second line", &ts);
+        let lh = 58.0 * st.line_spacing;
+        // the box spans the widest line ± padding, centred, and both lines ± padding
+        let (x0, x1) = row_span(&o, o.y + o.h as i32 / 2).unwrap();
+        assert!(((x1 - x0 + 1) as f32 - (long + 40.0)).abs() <= 2.0, "box width {}..{}", x0, x1);
+        assert!(((x0 + x1 + 1) as f32 / 2.0 - 960.0).abs() <= 1.5, "box centre {x0}..{x1}");
+        let (y0, y1) = col_span(&o, 960).unwrap();
+        assert!(((y1 - y0 + 1) as f32 - (2.0 * lh + 40.0)).abs() <= 2.0, "box height {y0}..{y1}");
+        assert!(((y1 + 1) as f32 - 0.92 * 1080.0).abs() <= 2.0, "bottom edge {y1}");
+        // the short first line's row is as wide as the block; a box per line would hug "Hi"
+        let (a0, a1) = row_span(&o, y0 + 25).unwrap();
+        assert!((a0 - x0).abs() <= 1 && (a1 - x1).abs() <= 1, "first-line row {a0}..{a1} vs block {x0}..{x1}");
+        let lines = CaptionStyle { box_mode: CaptionBoxMode::Line, ..st.clone() };
+        let o = render_caption(&c, &lines, 1920, 1080).unwrap();
+        let (l0, l1) = row_span(&o, y0 + 25).unwrap();
+        assert!(l1 - l0 < x1 - x0 - 100, "per-line box {l0}..{l1} as wide as the block");
+        // with the automatic padding the block is as wide as the widest line's box would be
+        let auto = CaptionStyle { background_padding: None, ..st };
+        let o = render_caption(&c, &auto, 1920, 1080).unwrap();
+        let (b0, b1) = row_span(&o, o.y + o.h as i32 / 2).unwrap();
+        assert!(((b1 - b0 + 1) as f32 - (long + 2.0 * (58.0_f32 * 0.3).round())).abs() <= 2.0, "automatic padding {b0}..{b1}");
+    }
+
+    #[test]
+    fn padding_above_the_frame_is_clipped_without_moving_the_text() {
+        // a WebVTT cue at line:1% with 100 px of vertical padding: the box runs off the top of the
+        // frame, but the text stays where it is without padding
+        let first_text_row = |pad: f32| {
+            let st = CaptionStyle { background: false, background_padding: Some([0.0, pad]), ..Default::default() };
+            let mut c = cap("Hi");
+            c.settings = "line:1%".into();
+            let o = render_caption(&c, &st, 1920, 1080).unwrap();
+            (o.y..o.y + o.h as i32).find(|&y| (o.x..o.x + o.w as i32).any(|x| alpha(&o, x, y) > 0.5)).unwrap()
+        };
+        assert_eq!(first_text_row(100.0), first_text_row(0.0), "rows: pad 100 {} vs pad 0 {}", first_text_row(100.0), first_text_row(0.0));
+    }
+
+    #[test]
+    fn hostile_background_padding_is_clamped() {
+        for pad in [[f32::NAN, f32::INFINITY], [-4.0, -1e30], [1e30, 1e9], [f32::NEG_INFINITY, 0.0]] {
+            let st = CaptionStyle { background_padding: Some(pad), ..Default::default() };
+            let r = std::panic::catch_unwind(|| render_caption(&cap("Hello world"), &st, 640, 360));
+            assert!(r.is_ok(), "{pad:?}");
+        }
+        // negative and non-finite padding is none
+        let none = CaptionStyle { background_padding: Some([0.0, 0.0]), ..Default::default() };
+        let neg = CaptionStyle { background_padding: Some([-4.0, f32::NAN]), ..Default::default() };
+        assert_eq!(render_caption(&cap("Hi"), &neg, 640, 360), render_caption(&cap("Hi"), &none, 640, 360));
+    }
+
     #[test]
     fn hidden_track_draws_nothing() {
         let mut t = CaptionTrack::new(TrackId(1), "S".into(), CaptionFormat::Subtitle);
diff --git a/crates/engine/src/captions.rs b/crates/engine/src/captions.rs
--- a/crates/engine/src/captions.rs
+++ b/crates/engine/src/captions.rs
@@ -10,7 +10,7 @@ use serde_json::{Value, json};
 use filmcraft_captions::{Document, Format, WriteOptions};
 use filmcraft_edit as edit;
 use filmcraft_edit::Edge;
-use filmcraft_project::{CaptionAlign, CaptionAnchor, CaptionFormat, CaptionStyle, CaptionTrack, ClipId, SequenceSettings, TrackId};
+use filmcraft_project::{CaptionAlign, CaptionAnchor, CaptionBoxMode, CaptionFormat, CaptionStyle, CaptionTrack, ClipId, SequenceSettings, TrackId};
 use filmcraft_time::{Tick, TimeDisplay, format_time};
 
 use crate::commands::{CommandSpec, always, bad, bool_p, f64_p, has_seq, str_p, time_p, u64_p};
@@ -248,6 +248,7 @@ fn track_json(s: &Session, t: &CaptionTrack, index: usize) -> Value {
             "background": t.style.background, "backgroundColor": hex(t.style.background_color),
             "align": format!("{:?}", t.style.align).to_lowercase(), "anchor": format!("{:?}", t.style.anchor).to_lowercase(),
             "margin": t.style.margin, "lineSpacing": t.style.line_spacing, "outline": t.style.outline, "outlineColor": hex(t.style.outline_color),
+            "backgroundPadding": t.style.background_padding, "boxMode": t.style.box_mode.name(),
         },
         "captions": t.captions.iter().map(|c| caption_json(s, c)).collect::<Vec<_>>(),
     })
@@ -268,6 +269,20 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
     Ok(json!({"path": path, "format": format.name(), "captions": n, "bytes": bytes.len()}))
 }
 
+/// `backgroundPadding`: one number for all sides, `[x, y]`, or null (automatic); clamped to
+/// 0–500 px at 1080 lines. `None` when the value is malformed.
+fn padding_p(v: &Value) -> Option<Option<[f32; 2]>> {
+    let num = |v: &Value| v.as_f64().filter(|v| v.is_finite()).map(|v| (v as f32).clamp(0.0, 500.0));
+    match v {
+        Value::Null => Some(None),
+        Value::Array(a) => match a.as_slice() {
+            [x, y] => Some(Some([num(x)?, num(y)?])),
+            _ => None,
+        },
+        _ => num(v).map(|p| Some([p, p])),
+    }
+}
+
 fn delta_p(s: &Session, p: &Value) -> Option<Tick> {
     if let Some(d) = p.get("delta").and_then(Value::as_i64) {
         return Some(Tick(d));
@@ -352,7 +367,7 @@ pub fn commands() -> Vec<CommandSpec> {
             "Caption Track Style",
             &[],
             None,
-            r##"{"track":id|"C1","font":str?,"size":f32?,"color":"#rrggbb[aa]"?,"background":bool?,"backgroundColor":"#rrggbbaa"?,"align":"left|center|right"?,"anchor":"top|middle|bottom"?,"margin":0..0.45 (fraction of frame height)?,"lineSpacing":f32?,"outline":f32?,"outlineColor":str?,"reset":bool?}"##,
+            r##"{"track":id|"C1","font":str?,"size":f32?,"color":"#rrggbb[aa]"?,"background":bool?,"backgroundColor":"#rrggbbaa"?,"backgroundPadding":px at 1080 lines|[x,y]|null (null = automatic: 0.3 × size left/right, none above/below)?,"boxMode":"line|block"="line"?,"align":"left|center|right"?,"anchor":"top|middle|bottom"?,"margin":0..0.45 (fraction of frame height)?,"lineSpacing":f32?,"outline":f32?,"outlineColor":str?,"reset":bool?}"##,
             has_caption_track,
             |s, p| {
                 let tid = track_param(s, p).ok_or_else(|| {
@@ -387,6 +402,12 @@ pub fn commands() -> Vec<CommandSpec> {
                     if let Some(c) = p.get("backgroundColor").and_then(parse_color) {
                         y.background_color = c;
                     }
+                    if let Some(v) = str_p(&p, "boxMode") {
+                        y.box_mode = CaptionBoxMode::from_name(v).ok_or_else(|| bad("captions.setStyle", format!("unknown boxMode `{v}` (line or block)")))?;
+                    }
+                    if let Some(v) = p.get("backgroundPadding") {
+                        y.background_padding = padding_p(v).ok_or_else(|| bad("captions.setStyle", "`backgroundPadding` must be a number, [x, y] or null"))?;
+                    }
                     if let Some(c) = p.get("outlineColor").and_then(parse_color) {
                         y.outline_color = c;
                     }
diff --git a/crates/engine/src/captions/tests.rs b/crates/engine/src/captions/tests.rs
--- a/crates/engine/src/captions/tests.rs
+++ b/crates/engine/src/captions/tests.rs
@@ -224,3 +224,37 @@ fn captions_copy_cut_and_paste() {
     s.execute("edit.undo", json!({})).unwrap();
     assert_eq!(caption_spans(&s).len(), 1);
 }
+
+#[test]
+fn set_style_background_padding() {
+    let mut s = demo();
+    s.execute("captions.add", json!({"text": "Padded", "seconds": 1.0})).unwrap();
+    let pad = |s: &Session| s.active_sequence().unwrap().caption_tracks[0].style.background_padding;
+    assert_eq!(pad(&s), None, "automatic by default");
+    s.execute("captions.setStyle", json!({"backgroundPadding": 20})).unwrap();
+    assert_eq!(pad(&s), Some([20.0, 20.0]));
+    s.execute("captions.setStyle", json!({"backgroundPadding": [24, -8]})).unwrap();
+    assert_eq!(pad(&s), Some([24.0, 0.0]), "negative clamps to 0");
+    assert_eq!(s.execute("captions.list", json!({})).unwrap()["tracks"][0]["style"]["backgroundPadding"], json!([24.0, 0.0]));
+    s.execute("captions.setStyle", json!({"backgroundPadding": 1e9})).unwrap();
+    assert_eq!(pad(&s), Some([500.0, 500.0]), "huge clamps to 500");
+    for bad in [json!("wide"), json!([1, 2, 3]), json!([1, "x"])] {
+        assert!(s.execute("captions.setStyle", json!({"backgroundPadding": bad})).is_err(), "{bad}");
+    }
+    s.execute("captions.setStyle", json!({"backgroundPadding": null})).unwrap();
+    assert_eq!(pad(&s), None, "null goes back to automatic");
+}
+
+#[test]
+fn set_style_box_mode() {
+    let mut s = demo();
+    s.execute("captions.add", json!({"text": "Boxed", "seconds": 1.0})).unwrap();
+    let mode = |s: &Session| s.active_sequence().unwrap().caption_tracks[0].style.box_mode;
+    assert_eq!(mode(&s), filmcraft_project::CaptionBoxMode::Line);
+    s.execute("captions.setStyle", json!({"boxMode": "block"})).unwrap();
+    assert_eq!(mode(&s), filmcraft_project::CaptionBoxMode::Block);
+    assert_eq!(s.execute("captions.list", json!({})).unwrap()["tracks"][0]["style"]["boxMode"], "block");
+    assert!(s.execute("captions.setStyle", json!({"boxMode": "bubble"})).is_err());
+    s.execute("captions.setStyle", json!({"reset": true})).unwrap();
+    assert_eq!(mode(&s), filmcraft_project::CaptionBoxMode::Line);
+}
diff --git a/crates/engine/src/graphic_templates.rs b/crates/engine/src/graphic_templates.rs
--- a/crates/engine/src/graphic_templates.rs
+++ b/crates/engine/src/graphic_templates.rs
@@ -898,9 +898,14 @@ fn upgrade_caption(s: &mut Session, p: &Value) -> Result<Value> {
             filmcraft_project::CaptionAlign::Right => w as f64 * 0.9,
         };
         let margin = st.margin as f64 * h as f64;
+        // set padding sits between the margin and the text (the margin measures to the box edge);
+        // a graphic's text background has one padding for all sides: the horizontal one
+        let pad_of = |v: f32| if v.is_finite() { v.clamp(0.0, 500.0) as f64 * k } else { 0.0 };
+        let pad = st.background_padding.map(|[x, _]| pad_of(x));
+        let pad_y = st.background_padding.map_or(0.0, |[_, y]| pad_of(y));
         let first_baseline = match st.anchor {
-            filmcraft_project::CaptionAnchor::Bottom => h as f64 - margin - (lines - 1.0) * line_h - size * 0.25,
-            filmcraft_project::CaptionAnchor::Top => margin + size,
+            filmcraft_project::CaptionAnchor::Bottom => h as f64 - margin - pad_y - (lines - 1.0) * line_h - size * 0.25,
+            filmcraft_project::CaptionAnchor::Top => margin + pad_y + size,
             filmcraft_project::CaptionAnchor::Middle => h as f64 / 2.0 - (lines - 1.0) * line_h / 2.0 + size * 0.35,
         };
         let mut layer = graphic::new_text_layer(&text, Vec2::new(x, first_baseline), size);
@@ -917,7 +922,7 @@ fn upgrade_caption(s: &mut Session, p: &Value) -> Result<Value> {
             set(&mut layer, "background", ParamValue::Bool(true));
             set(&mut layer, "background_color", ParamValue::Color(col(st.background_color)));
             set(&mut layer, "background_opacity", ParamValue::Float(st.background_color[3] as f64 / 2.55));
-            set(&mut layer, "background_size", ParamValue::Float(size * 0.2));
+            set(&mut layer, "background_size", ParamValue::Float(pad.unwrap_or(size * 0.2)));
         }
         if st.outline > 0.0 {
             set(&mut layer, "stroke", ParamValue::Bool(true));
diff --git a/crates/engine/src/graphic_templates/tests.rs b/crates/engine/src/graphic_templates/tests.rs
--- a/crates/engine/src/graphic_templates/tests.rs
+++ b/crates/engine/src/graphic_templates/tests.rs
@@ -308,6 +308,35 @@ fn upgrade_caption_to_graphic() {
     assert_eq!(s.execute("captions.list", json!({})).unwrap()["tracks"][0]["captions"].as_array().unwrap().len(), before);
 }
 
+#[test]
+fn upgrade_caption_carries_the_background_padding() {
+    let mut s = demo("gt-caption-pad");
+    s.execute("captions.add", json!({"text": "Padded", "seconds": 600, "durationSeconds": 2})).unwrap();
+    s.execute("captions.setStyle", json!({"backgroundPadding": [20, 10]})).unwrap();
+    let r = s.execute("graphics.upgradeCaption", json!({})).unwrap();
+    let it = item(&s, ClipId(r["clips"][0].as_u64().unwrap()));
+    let e = it.effects.iter().find(|e| e.effect == "graphic_text").unwrap();
+    let k = canvas(&s).1 as f64 / 1080.0;
+    assert_eq!(e.param("background_size").unwrap().value, ParamValue::Float(20.0 * k));
+    s.execute("edit.undo", json!({})).unwrap();
+    // the vertical padding sits between the margin and the text, for either anchor
+    let baseline = |s: &mut Session, anchor: &str, pad: Value| {
+        s.execute("captions.setStyle", json!({"anchor": anchor, "backgroundPadding": pad})).unwrap();
+        let r = s.execute("graphics.upgradeCaption", json!({})).unwrap();
+        let it = item(s, ClipId(r["clips"][0].as_u64().unwrap()));
+        let e = it.effects.iter().find(|e| e.effect == "graphic_text").unwrap().clone();
+        s.execute("edit.undo", json!({})).unwrap();
+        match e.param("position").unwrap().value {
+            ParamValue::Vec2(p) => p.y,
+            ref v => panic!("position is {v:?}"),
+        }
+    };
+    let (bottom, bottom_padded) = (baseline(&mut s, "bottom", Value::Null), baseline(&mut s, "bottom", json!([20, 10])));
+    assert!((bottom - bottom_padded - 10.0 * k).abs() < 1e-6, "bottom: {bottom} -> {bottom_padded}");
+    let (top, top_padded) = (baseline(&mut s, "top", Value::Null), baseline(&mut s, "top", json!([20, 10])));
+    assert!((top_padded - top - 10.0 * k).abs() < 1e-6, "top: {top} -> {top_padded}");
+}
+
 #[test]
 fn source_graphics_share_edits() {
     let mut s = demo("gt-source");
diff --git a/crates/project/src/caption.rs b/crates/project/src/caption.rs
--- a/crates/project/src/caption.rs
+++ b/crates/project/src/caption.rs
@@ -70,6 +70,32 @@ pub enum CaptionAnchor {
     Bottom,
 }
 
+/// How the caption background box is drawn.
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
+pub enum CaptionBoxMode {
+    /// One box behind each line, as wide as that line.
+    #[default]
+    Line,
+    /// One box around the whole caption, as wide as its longest line.
+    Block,
+}
+
+impl CaptionBoxMode {
+    pub fn name(self) -> &'static str {
+        match self {
+            CaptionBoxMode::Line => "line",
+            CaptionBoxMode::Block => "block",
+        }
+    }
+    pub fn from_name(s: &str) -> Option<CaptionBoxMode> {
+        match s.trim().to_ascii_lowercase().as_str() {
+            "line" | "lines" => Some(CaptionBoxMode::Line),
+            "block" => Some(CaptionBoxMode::Block),
+            _ => None,
+        }
+    }
+}
+
 /// Track-level caption style (Premiere's caption track style / Essential Graphics text settings).
 #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
 #[serde(default)]
@@ -93,6 +119,12 @@ pub struct CaptionStyle {
     /// Text outline width in pixels at 1080 lines (0 = none).
     pub outline: f32,
     pub outline_color: [u8; 4],
+    /// Background padding around the text in pixels at 1080 lines, `[left and right, top and
+    /// bottom]`. `None` keeps the automatic padding: 0.3 × the font size left and right, none above
+    /// or below.
+    pub background_padding: Option<[f32; 2]>,
+    /// One background box per line, or one around the whole caption.
+    pub box_mode: CaptionBoxMode,
 }
 
 impl Default for CaptionStyle {
@@ -109,6 +141,8 @@ impl Default for CaptionStyle {
             line_spacing: 1.25,
             outline: 0.0,
             outline_color: [0, 0, 0, 255],
+            background_padding: None,
+            box_mode: CaptionBoxMode::Line,
         }
     }
 }
@@ -303,6 +337,28 @@ pub fn plain_text(s: &str) -> String {
 mod tests {
     use super::*;
 
+    #[test]
+    fn styles_saved_without_padding_keep_the_automatic_padding() {
+        let old = r#"{"font":"Inter","size":54.0,"color":[255,255,255,255],"background":true,"background_color":[0,0,0,191],
+            "align":"Center","anchor":"Bottom","margin":0.08,"line_spacing":1.25,"outline":0.0,"outline_color":[0,0,0,255]}"#;
+        let st: CaptionStyle = serde_json::from_str(old).unwrap();
+        assert_eq!(st, CaptionStyle::default());
+        assert_eq!(st.background_padding, None);
+        let padded = CaptionStyle { background_padding: Some([20.0, 12.0]), ..Default::default() };
+        assert_eq!(serde_json::from_str::<CaptionStyle>(&serde_json::to_string(&padded).unwrap()).unwrap(), padded);
+    }
+
+    #[test]
+    fn styles_saved_without_a_box_mode_keep_a_box_per_line() {
+        let old = r#"{"font":"Inter","size":54.0,"color":[255,255,255,255],"background":true,"background_color":[0,0,0,191],
+            "align":"Center","anchor":"Bottom","margin":0.08,"line_spacing":1.25,"outline":0.0,"outline_color":[0,0,0,255]}"#;
+        let st: CaptionStyle = serde_json::from_str(old).unwrap();
+        assert_eq!(st, CaptionStyle::default());
+        assert_eq!(st.box_mode, CaptionBoxMode::Line);
+        assert_eq!(CaptionBoxMode::from_name(" Block "), Some(CaptionBoxMode::Block));
+        assert_eq!(CaptionBoxMode::from_name("bubble"), None);
+    }
+
     #[test]
     fn plain() {
         assert_eq!(plain_text("<i>Hello</i> &amp; <b>bye</b>"), "Hello & bye");
diff --git a/crates/project/src/lib.rs b/crates/project/src/lib.rs
--- a/crates/project/src/lib.rs
+++ b/crates/project/src/lib.rs
@@ -31,7 +31,7 @@ use filmcraft_media::{Generator, MediaInfo};
 use filmcraft_time::{FrameRate, TICKS_PER_SECOND, Tick, TimeRange};
 use serde::{Deserialize, Serialize};
 
-pub use caption::{Caption, CaptionAlign, CaptionAnchor, CaptionFormat, CaptionStyle, CaptionTrack, plain_text};
+pub use caption::{Caption, CaptionAlign, CaptionAnchor, CaptionBoxMode, CaptionFormat, CaptionStyle, CaptionTrack, plain_text};
 pub use effect::{EffectDef, EffectInstance, EffectKind, ParamDef, ParamKind, effect_defs, find_effect};
 pub use essential::{AudioType, EssentialSound};
 pub use find::{FindOp, FindQuery, FindRow, SearchBin};
diff --git a/crates/ui-egui/src/panels/text.rs b/crates/ui-egui/src/panels/text.rs
--- a/crates/ui-egui/src/panels/text.rs
+++ b/crates/ui-egui/src/panels/text.rs
@@ -8,7 +8,7 @@
 //! the selection marks In/Out and can be extracted or lifted (`transcript.*` commands).
 
 use egui::{Align2, Color32, Rect, Sense, Stroke, StrokeKind, pos2, vec2};
-use filmcraft_project::{CaptionAlign, CaptionAnchor, CaptionFormat};
+use filmcraft_project::{CaptionAlign, CaptionAnchor, CaptionBoxMode, CaptionFormat};
 use filmcraft_time::{TimeDisplay, format_time};
 use serde_json::{Value, json};
 
@@ -167,10 +167,28 @@ fn captions(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
         x += 28.0;
     }
 
-    // ---- style strip
-    let style_h = 30.0;
+    // ---- style strip: its controls wrap onto more rows in a narrow panel, so the strip is as tall
+    // as they were on the last frame (one 30 px row at least, half the panel at most)
+    let strip_id = egui::Id::unique(("caption-style-height", track.id.0));
+    let max_h = (rect.height() * 0.5).max(30.0);
+    let style_h = ui.ctx().data(|d| d.get_temp::<f32>(strip_id)).unwrap_or(30.0).clamp(30.0, max_h);
     let style_rect = Rect::from_min_max(pos2(rect.min.x + 10.0, rect.max.y - style_h), pos2(rect.max.x - 10.0, rect.max.y - 2.0));
-    style_strip(app, ui, style_rect, track_idx, &mut actions);
+    // when the wrapped controls are taller than the cap, the strip scrolls so every row stays reachable
+    let mut strip_ui = ui.new_child(egui::UiBuilder::new().max_rect(style_rect).id_salt("caption-style-strip"));
+    let scroll = egui::ScrollArea::vertical().id_salt(("caption-style-scroll", track.id.0)).auto_shrink([false, false]).show(&mut strip_ui, |ui| {
+        let r = Rect::from_min_size(ui.cursor().min, vec2(ui.available_width(), style_h));
+        let used = style_strip(app, ui, r, track_idx, &mut actions);
+        // the strip's child doesn't advance this ui's cursor: claim its height so the scroll area knows it
+        ui.allocate_rect(Rect::from_min_size(r.min, vec2(r.width(), used.max(1.0))), Sense::hover());
+        used
+    });
+    let used = scroll.inner;
+    // capped like style_h, so controls taller than the cap don't request a repaint every frame
+    let wanted = (used + 6.0).clamp(30.0, max_h);
+    if (wanted - style_h).abs() > 0.5 {
+        ui.ctx().data_mut(|d| d.insert_temp(strip_id, wanted));
+        ui.ctx().request_repaint();
+    }
 
     // ---- segment list
     let list = Rect::from_min_max(pos2(rect.min.x + 6.0, bar.max.y + 8.0), pos2(rect.max.x - 6.0, style_rect.min.y - 6.0));
@@ -655,12 +673,14 @@ fn filmcraft_speech_available(app: &FilmcraftApp) -> bool {
     app.session.transcriber.is_some() || filmcraft_engine::transcript::speech_available()
 }
 
-fn style_strip(app: &mut FilmcraftApp, ui: &mut egui::Ui, r: Rect, track_idx: usize, actions: &mut Vec<(String, Value)>) {
-    let Some(tr) = app.session.active_sequence().and_then(|q| q.caption_tracks.get(track_idx)).cloned() else { return };
+/// The track style controls in `r`, wrapping onto more rows when they don't fit; returns the
+/// height they took.
+fn style_strip(app: &mut FilmcraftApp, ui: &mut egui::Ui, r: Rect, track_idx: usize, actions: &mut Vec<(String, Value)>) -> f32 {
+    let Some(tr) = app.session.active_sequence().and_then(|q| q.caption_tracks.get(track_idx)).cloned() else { return 0.0 };
     let st = tr.style.clone();
     let mut child = ui.new_child(egui::UiBuilder::new().max_rect(r).id_salt("caption-style"));
-    child.horizontal_centered(|ui| {
-        ui.spacing_mut().item_spacing.x = 6.0;
+    child.horizontal_wrapped(|ui| {
+        ui.spacing_mut().item_spacing = egui::vec2(6.0, 4.0);
         let r = craft_ui::ComboBox::from_id_salt(("caption-font", tr.id.0)).selected_text(&st.font).width(130.0).height(400.0).show_ui(ui, |ui| {
             if !filmcraft_text::fonts::system_scanned() {
                 filmcraft_text::fonts::scan_system();
@@ -700,6 +720,33 @@ fn style_strip(app: &mut FilmcraftApp, ui: &mut egui::Ui, r: Rect, track_idx: us
         if resp.changed() {
             actions.push(("captions.setStyle".into(), json!({"track": tr.id.0, "background": bg})));
         }
+        for (m, label, tip) in [
+            (CaptionBoxMode::Line, tl!("Lines"), tl!("One box behind each line")),
+            (CaptionBoxMode::Block, tl!("Block"), tl!("One box around the whole caption")),
+        ] {
+            let resp = ui.selectable_label(st.box_mode == m, label).on_hover_text(tip);
+            app.auto.add(&format!("text.captions.style.boxMode.{}", m.name()), resp.rect, m.name());
+            if resp.clicked() {
+                actions.push(("captions.setStyle".into(), json!({"track": tr.id.0, "boxMode": m.name()})));
+            }
+        }
+        // the automatic padding shows what it draws: 0.3 × size at the sides, none above or below
+        let [pad_x, pad_y] = st.background_padding.unwrap_or([(st.size * 0.3).round(), 0.0]);
+        for (axis, label, tip, value) in [
+            ("x", tl!("Pad X "), tl!("Box padding left and right (px at 1080 lines)"), pad_x),
+            ("y", tl!("Pad Y "), tl!("Box padding above and below (px at 1080 lines)"), pad_y),
+        ] {
+            let mut v = value;
+            // the full range captions.setStyle accepts: a narrower one would clamp a stored value
+            // (and report it changed) as soon as the panel shows it
+            let resp = ui.add(crate::widgets::number_drag(&mut v).range(0.0..=500.0).speed(0.5).prefix(label).suffix(" px")).on_hover_text(tip);
+            app.auto.add(&format!("text.captions.style.backgroundPadding.{axis}"), resp.rect, "Caption box padding");
+            let escaped = resp.drag_stopped() && ui.input(|i| i.key_pressed(egui::Key::Escape));
+            if !escaped && (resp.drag_stopped() || (resp.changed() && !resp.dragged())) {
+                let pad = if axis == "x" { [v, pad_y] } else { [pad_x, v] };
+                actions.push(("captions.setStyle".into(), json!({"track": tr.id.0, "backgroundPadding": pad})));
+            }
+        }
         ui.label(egui::RichText::new(tl!("Align")).size(11.0).color(app.tokens.text_dim));
         for (a, icon_txt, name, tip) in [
             (CaptionAlign::Left, "L", "left", tl!("Align left")),
@@ -725,6 +772,7 @@ fn style_strip(app: &mut FilmcraftApp, ui: &mut egui::Ui, r: Rect, track_idx: us
             }
         }
     });
+    child.min_rect().height()
 }
 
 fn run(app: &mut FilmcraftApp, ui: &egui::Ui, actions: Vec<(String, Value)>) {
diff --git a/docs/captions.md b/docs/captions.md
--- a/docs/captions.md
+++ b/docs/captions.md
@@ -12,7 +12,12 @@ version 2; version 1 projects open with no caption tracks).
   font size (pixels at 1080 lines, scaled to the frame), text colour, background box and its colour,
   outline width and colour, alignment (left/centre/right), position (top/middle/bottom) and
   margin (a fraction of the frame height, default 0.08; `captions.setStyle` clamps it to 0–0.45),
-  line spacing.
+  line spacing, and the background padding (`backgroundPadding`: pixels at 1080 lines, scaled with
+  the frame like the size, one number for all sides or `[x, y]`, clamped to 0–500; unset (`null`,
+  the default) keeps the automatic padding of 0.3 × the font size left and right and none above or
+  below; with top/bottom anchors the margin is measured to the box edge, padding included), and the
+  box mode (`boxMode`: `line`, the default, draws a box behind each line as wide as that line;
+  `block` draws one box around the whole caption, as wide as its widest line plus the padding).
 - **Caption**: in/out in exact `Tick`s, text (lines separated by `\n`; `<i>`, `<b>`, `<u>` kept
   as written), optional speaker, and the WebVTT cue id and cue settings, kept for round trips.
   Captions on a track never overlap.
@@ -51,7 +56,7 @@ Details of each format and the SCC encoder are in [`crates/captions/README.md`](
 | `edit.copy` / `edit.cut` / `edit.paste` / `edit.pasteInsert` | ⌘C / ⌘X / ⌘V / ⇧⌘V with only captions selected | captions paste at the playhead on the tracks they came from, overwriting (Paste Insert pushes later captions on); Paste places whichever of clips or captions was copied last |
 | `captions.select`, `captions.goTo` | click / number button | `captions`, `add` / `caption` |
 | `captions.setTrack` | caption track header (eye, lock) | `track`, `name`, `format`, `language`, `enabled`, `locked`, `syncLock` |
-| `captions.setStyle` | Text panel style strip | `track`, `size`, `color`, `background`, `backgroundColor`, `outline`, `outlineColor`, `align`, `anchor`, `margin`, `lineSpacing`, `reset` |
+| `captions.setStyle` | Text panel style strip | `track`, `size`, `color`, `background`, `backgroundColor`, `backgroundPadding` (px at 1080 lines, `[x, y]`, or `null` for automatic), `boxMode` (`line`/`block`), `outline`, `outlineColor`, `align`, `anchor`, `margin`, `lineSpacing`, `reset` |
 | `captions.deleteTrack` | | `track` |
 | `captions.import` / `captions.export` | File ▸ Export ▸ Captions… | `path`, `format`, `track`, `dropFrame` |
 | `captions.list` | (query) | `track` — tracks, styles and captions with timecodes (a `track` that names no caption track is an error) |
@@ -71,7 +76,7 @@ also splits captions.
 - **Text panel ▸ Captions**: search, track picker (also creates tracks of each format), add /
   split / merge / delete / export buttons, the list of segments with editable in/out timecodes
   and text (committed when the field loses focus; Arabic and Hebrew show in reading order and are
-  edited in logical order while the field has focus), and the track style strip (size, colour, box,
+  edited in logical order while the field has focus), and the track style strip (size, colour, box per line or block and its padding,
   alignment, position). The list keeps the caption under the playhead in view (centred as the
   playhead reaches it, not while a field is being edited). Automation ids: `text.tab.Captions`, `text.captions.add|split|merge|delete|export|search|track`,
   `text.captions.<id>.in|out|text|goto|row`, `text.captions.style.*`.
diff --git a/docs/graphics.md b/docs/graphics.md
--- a/docs/graphics.md
+++ b/docs/graphics.md
@@ -246,7 +246,7 @@ panel state and the Type-tool selection.
 
 | Command | Menu | Params |
 |---|---|---|
-| `graphics.upgradeCaption` | Graphics and Titles ▸ Upgrade Caption to Graphic | `captions` (default: the selection, else the one under the playhead): each caption becomes a graphic clip on a video track with the track style (font, size, colour, background, outline, alignment, position); `<i>`/`<b>`/`<u>` become character styles; one undo step |
+| `graphics.upgradeCaption` | Graphics and Titles ▸ Upgrade Caption to Graphic | `captions` (default: the selection, else the one under the playhead): each caption becomes a graphic clip on a video track with the track style (font, size, colour, background with its horizontal padding as the text background's size (a graphic's text background is one box around the text, like `boxMode: "block"`), outline, alignment, position); `<i>`/`<b>`/`<u>` become character styles; one undo step |
 | `graphics.upgradeToSourceGraphic` | Graphics and Titles ▸ Upgrade to Source Graphic | `clip`: the graphic gets its own project item (shown in the Project panel); every clip of that item shares its layers — editing one updates the others in the same undo step (`project.source_graphics`) |
 | `file.replaceFonts` | Graphics and Titles ▸ Replace Fonts in Projects… | `from` (family or {family, style}), `to`, `toStyle`: graphic layers, character styles, source graphics and caption tracks |
 | `graphics.fonts.used` | (query) | fonts in use with counts and whether they are missing |
