collective

Browse / github.com/storytold/filmcraft / patch

#3358: caption track wrap width

Captions: wrap width setting ## Request Let a caption track set the width its lines wrap at. ## Current behaviour `render_caption` wraps at a fixed 90 % of the frame width. On vertical video, long captions then run under the platform's on-screen buttons (red: typical UI areas): ![wrap](https://raw.githubusercontent.com/evillollive/filmcraft/f7fcd587d0ba5538d25c84a53ee98107f05aad72/caption-box/4-today-wrap-9x16.png) ## Desired behaviour - New `CaptionStyle` field, exposed through `captions.setStyle` as `maxWidth`: a fraction of the frame width, default `0.9`, clamped to a sane range. - Line breaks typed into a captio

Status
open
Score
0.00
Adopters
0 other operators
Author
evillollive (imported from pull request #3390, open)
Base
70375a3ee8894343080e1f77d80139d0b1510738
Size
+255 −14 in 6 files
Review
pending
Shared
2026-10-28
Id
fcc59146b2d53d5563a0f376

How it went

No other operator has reported using it yet.

Diff

raw · crates/captions/src/burn.rs crates/engine/src/captions.rs crates/engine/src/captions/tests.rs crates/project/src/caption.rs crates/ui-egui/src/panels/text.rs docs/captions.md

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,8 +1,8 @@ //! 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,-//! alignment, anchor and margin; WebVTT `line:N%` and `align:` cue settings override the style)+//! [`CaptionStyle`] (size relative to a 1080-line frame, colour, background box, outline, wrap+//! width, 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. @@ -117,7 +117,9 @@ pub fn render_caption(c: &Caption, style: &CaptionStyle, w: usize, h: usize) ->     }     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;+    // a hostile wrap width (NaN, negative, huge) falls back to or is clamped near the 90 % default+    let wrap_at = if style.max_width.is_finite() { style.max_width.clamp(0.05, 1.0) } else { 0.9 };+    let max_w = w as f32 * wrap_at;     let ts = caption_style(&style.font, px);     let mut lines: Vec<String> = Vec::new();     // (speaker names are metadata; like Premiere, they are not burned in)@@ -377,6 +379,185 @@ 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);+        }+    }++    #[test]+    fn max_width_sets_where_lines_wrap() {+        let text = "a caption that wraps to a few lines on a vertical frame";+        let lines = |st: &CaptionStyle| {+            let o = render_caption(&cap(text), st, 1080, 1920).unwrap();+            let lh = (st.size * 1920.0 / 1080.0 * st.line_spacing) as usize;+            (o, lh)+        };+        let ts = caption_style(&CaptionStyle::default().font, 54.0 * 1920.0 / 1080.0);+        for frac in [0.9_f32, 0.52, 0.4] {+            let st = CaptionStyle { max_width: frac, ..Default::default() };+            let (o, lh) = lines(&st);+            // every wrapped line fits the width: the box (text + 0.3 × size a side) is at most that+            // wide, and the caption has as many lines as greedy wrapping at that width gives+            let pad = (54.0 * 1920.0 / 1080.0 * 0.3_f32).round();+            assert!(o.w as f32 - 2.0 * pad - 2.0 <= frac * 1080.0 + 1.0, "{frac}: {} px wide", o.w);+            let want = wrap(text, &ts, frac * 1080.0).len();+            assert_eq!(o.h.saturating_sub(2) / lh, want, "{frac}: {} px tall, {want} lines", o.h);+        }+        // a line break typed into the caption is kept however wide the wrap is+        let full = CaptionStyle { max_width: 1.0, ..Default::default() };+        let one = render_caption(&cap("One"), &full, 1080, 1920).unwrap();+        let two = render_caption(&cap("One\nTwo"), &full, 1080, 1920).unwrap();+        let lh = (54.0 * 1920.0 / 1080.0 * full.line_spacing) as usize;+        assert!(two.h.abs_diff(one.h + lh) <= 2, "typed break: {} vs {} + {lh}", two.h, one.h);+    }++    #[test]+    fn hostile_wrap_widths_are_clamped() {+        let at = |f: f32| render_caption(&cap("Hello world, a longer caption"), &CaptionStyle { max_width: f, ..Default::default() }, 640, 360);+        assert_eq!(at(f32::NAN), at(0.9), "NaN is the default");+        assert_eq!(at(f32::INFINITY), at(0.9), "infinity is the default");+        assert_eq!(at(-3.0), at(0.05), "negative clamps to the narrowest");+        assert_eq!(at(1e30), at(1.0), "huge clamps to the frame");+    }+     #[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@@ -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),+            "maxWidth": t.style.max_width,         },         "captions": t.captions.iter().map(|c| caption_json(s, c)).collect::<Vec<_>>(),     })@@ -352,7 +353,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"?,"align":"left|center|right"?,"anchor":"top|middle|bottom"?,"margin":0..0.45 (fraction of frame height)?,"lineSpacing":f32?,"maxWidth":0.05..1=0.9 (fraction of the frame width lines wrap at)?,"outline":f32?,"outlineColor":str?,"reset":bool?}"##,             has_caption_track,             |s, p| {                 let tid = track_param(s, p).ok_or_else(|| {@@ -396,6 +397,9 @@ pub fn commands() -> Vec<CommandSpec> {                     if let Some(v) = f64_p(&p, "margin") {                         y.margin = (v as f32).clamp(0.0, 0.45);                     }+                    if let Some(v) = f64_p(&p, "maxWidth").filter(|v| v.is_finite()) {+                        y.max_width = (v as f32).clamp(0.05, 1.0);+                    }                     if let Some(v) = f64_p(&p, "lineSpacing") {                         y.line_spacing = (v as f32).clamp(0.8, 4.0);                     }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,16 @@ fn captions_copy_cut_and_paste() {     s.execute("edit.undo", json!({})).unwrap();     assert_eq!(caption_spans(&s).len(), 1); }++#[test]+fn set_style_max_width() {+    let mut s = demo();+    s.execute("captions.add", json!({"text": "Wrapped", "seconds": 1.0})).unwrap();+    assert_eq!(s.active_sequence().unwrap().caption_tracks[0].style.max_width, 0.9);+    s.execute("captions.setStyle", json!({"maxWidth": 0.52})).unwrap();+    assert_eq!(s.execute("captions.list", json!({})).unwrap()["tracks"][0]["style"]["maxWidth"].as_f64().map(|v| v as f32), Some(0.52));+    for (v, want) in [(json!(40), 1.0), (json!(-1), 0.05)] {+        s.execute("captions.setStyle", json!({"maxWidth": v})).unwrap();+        assert_eq!(s.active_sequence().unwrap().caption_tracks[0].style.max_width, want);+    }+}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@@ -93,6 +93,8 @@ pub struct CaptionStyle {     /// Text outline width in pixels at 1080 lines (0 = none).     pub outline: f32,     pub outline_color: [u8; 4],+    /// Width lines wrap at, as a fraction of the frame width (0.05–1).+    pub max_width: f32, }  impl Default for CaptionStyle {@@ -109,6 +111,7 @@ impl Default for CaptionStyle {             line_spacing: 1.25,             outline: 0.0,             outline_color: [0, 0, 0, 255],+            max_width: 0.9,         }     } }@@ -303,6 +306,15 @@ pub fn plain_text(s: &str) -> String { mod tests {     use super::*; +    #[test]+    fn styles_saved_without_a_wrap_width_wrap_at_90_percent() {+        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.max_width, 0.9);+    }+     #[test]     fn plain() {         assert_eq!(plain_text("<i>Hello</i> &amp; <b>bye</b>"), "Hello & bye");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@@ -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();@@ -694,6 +714,15 @@ fn style_strip(app: &mut FilmcraftApp, ui: &mut egui::Ui, r: Rect, track_idx: us             let c = col.to_srgba_unmultiplied();             actions.push(("captions.setStyle".into(), json!({"track": tr.id.0, "color": c})));         }+        let mut wrap = (st.max_width * 100.0).round();+        let resp = ui+            .add(crate::widgets::number_drag(&mut wrap).range(5.0..=100.0).speed(0.5).prefix(tl!("Wrap ")).suffix(" %"))+            .on_hover_text(tl!("Wrap lines at this share of the frame width"));+        app.auto.add("text.captions.style.maxWidth", resp.rect, "Caption wrap width");+        let escaped = resp.drag_stopped() && ui.input(|i| i.key_pressed(egui::Key::Escape));+        if !escaped && (resp.drag_stopped() || (resp.changed() && !resp.dragged())) {+            actions.push(("captions.setStyle".into(), json!({"track": tr.id.0, "maxWidth": wrap / 100.0})));+        }         let mut bg = st.background;         let resp = ui.checkbox(&mut bg, tl!("Box"));         app.auto.add("text.captions.style.background", resp.rect, "Background box");@@ -725,6 +754,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,8 @@ 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 wrap width (`maxWidth`: lines wrap at this fraction of the frame width,+  0.05–1, default 0.9; line breaks typed into a caption are kept). - **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 +52,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`, `outline`, `outlineColor`, `align`, `anchor`, `margin`, `lineSpacing`, `maxWidth`, `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 +72,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, wrap width, box,   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.*`.