Browse / github.com/storytold/filmcraft / patch
#3356: caption track box corner radius
Captions: background box corner radius ## Request Let a caption track round the corners of its background box, as graphic text backgrounds already can. ## Current behaviour Caption background boxes always have square corners; graphic text backgrounds have a corner radius, but caption tracks don't. ## Desired behaviour  - New `CaptionStyle` field, exposed through `captions.setStyle` as `cornerRadius`: px at 1080 lines, default `0`, capped at half the box height. - Same in the
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- evillollive (imported from pull request #3392, open)
- Base
70375a3ee8894343080e1f77d80139d0b1510738- Size
- +522 −35 in 9 files
- Review
- pending
- Shared
- 2026-10-28
- Id
9bc4a1325a15a78237a7f376
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/engine/src/graphic_templates.rs crates/engine/src/graphic_templates/tests.rs crates/project/src/caption.rs crates/ui-egui/src/panels/text.rs docs/captions.md docs/graphics.md
@@ -1,7 +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,+//! [`CaptionStyle`] (size relative to a 1080-line frame, colour, background box with its padding+//! and corner radius, 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.@@ -66,6 +67,44 @@ 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] } +/// Coverage of pixel `(x, y)` by the box `x0..x1` × `y0..y1` with corners rounded by `r`.+///+/// Square boxes use exact area coverage (what captions always drew); rounded ones the distance+/// from the pixel centre to the rounded outline, anti-aliased over one pixel.+fn box_cover(x: i32, y: i32, (x0, y0, x1, y1): (f32, f32, f32, f32), r: f32) -> f32 {+ let (fx, fy) = (x as f32, y as f32);+ let ax = ((fx + 1.0).min(x1) - fx.max(x0)).clamp(0.0, 1.0);+ let ay = ((fy + 1.0).min(y1) - fy.max(y0)).clamp(0.0, 1.0);+ let area = ax * ay;+ let r = r.min((x1 - x0) / 2.0).min((y1 - y0) / 2.0);+ if r <= 0.0 || area <= 0.0 {+ return area;+ }+ // signed distance from the pixel centre to the rounded rectangle+ let (cx, cy) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0);+ let (hx, hy) = ((x1 - x0) / 2.0 - r, (y1 - y0) / 2.0 - r);+ let qx = (fx + 0.5 - cx).abs() - hx;+ let qy = (fy + 0.5 - cy).abs() - hy;+ let d = (qx.max(0.0).powi(2) + qy.max(0.0).powi(2)).sqrt() + qx.max(qy).min(0.0) - r;+ area.min((0.5 - d).clamp(0.0, 1.0))+}++/// Add the box `x0..x1` × `y0..y1` with corners rounded by `r` 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), r: 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) {+ for x in (x0.floor() as i32).max(ox)..(x1.ceil() as i32).min(x_end) {+ if let Some(v) = bg.get_mut((y - oy) as usize * ow + (x - ox) as usize) {+ *v = (*v + box_cover(x, y, (x0, y0, x1, y1), r)).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 +170,26 @@ 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 radius = if style.corner_radius.is_finite() { style.corner_radius.clamp(0.0, 500.0) * scale } else { 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 +207,29 @@ 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]; 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);- }- }+ 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), radius); } let baseline = lt + (lh - (asc + desc)) / 2.0 + asc; let l = layout(line, &ts, &ParagraphStyle::default());@@ -377,6 +420,265 @@ 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> & 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 corner_radius_rounds_the_box() {+ let st =+ CaptionStyle { size: 58.0, background_color: [0, 0, 0, 255], background_padding: Some([20.0, 20.0]), corner_radius: 18.0, ..Default::default() };+ let o = render_caption(&cap("Hello world"), &st, 1920, 1080).unwrap();+ let (x0, x1) = row_span(&o, o.y + o.h as i32 / 2).unwrap();+ let (y0, y1) = col_span(&o, (x0 + x1) / 2).unwrap();+ // the corner pixels are empty; the edges between the corners are solid+ for (cx, cy) in [(x0, y0), (x1, y0), (x0, y1), (x1, y1)] {+ assert!(alpha(&o, cx, cy) < 0.05, "corner ({cx},{cy}) alpha {}", alpha(&o, cx, cy));+ }+ assert!(alpha(&o, x0 + 18, y0) > 0.95 && alpha(&o, x0, y0 + 18) > 0.95);+ // along the diagonal the outline is r·(1 − 1/√2) ≈ 5.3 px in from the corner+ let k = (0..18).find(|&k| alpha(&o, x0 + k, y0 + k) > 0.5).unwrap();+ assert!((k as f32 - 18.0 * (1.0 - std::f32::consts::FRAC_1_SQRT_2)).abs() <= 1.5, "diagonal inset {k}");+ // the radius scales with the frame: 18 at 1080 lines is 36 px at 2160+ let o4 = render_caption(&cap("Hello world"), &st, 3840, 2160).unwrap();+ let (a0, a1) = row_span(&o4, o4.y + o4.h as i32 / 2).unwrap();+ let (b0, _) = col_span(&o4, (a0 + a1) / 2).unwrap();+ let k4 = (0..36).find(|&k| alpha(&o4, a0 + k, b0 + k) > 0.5).unwrap();+ assert!((k4 as f32 - 36.0 * (1.0 - std::f32::consts::FRAC_1_SQRT_2)).abs() <= 1.5, "diagonal inset at 2160: {k4}");+ // radius 0 keeps square corners+ let square = CaptionStyle { corner_radius: 0.0, ..st.clone() };+ let o = render_caption(&cap("Hello world"), &square, 1920, 1080).unwrap();+ assert!(alpha(&o, x0, y0) > 0.95, "square corner alpha {}", alpha(&o, x0, y0));+ // a radius larger than the box is capped at half its height: a pill, still solid in the middle+ let pill = CaptionStyle { corner_radius: 400.0, ..st };+ let o = render_caption(&cap("Hello world"), &pill, 1920, 1080).unwrap();+ assert!(alpha(&o, (x0 + x1) / 2, (y0 + y1) / 2) > 0.95);+ assert!(alpha(&o, x0, y0) < 0.05);+ }++ #[test]+ fn hostile_corner_radius_is_clamped() {+ for r in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, -5.0, 1e30] {+ let st = CaptionStyle { corner_radius: r, background_padding: Some([1e30, -3.0]), ..Default::default() };+ let res = std::panic::catch_unwind(|| render_caption(&cap("Hello world"), &st, 640, 360));+ assert!(res.is_ok(), "{r}");+ }+ let square = render_caption(&cap("Hi"), &CaptionStyle::default(), 640, 360);+ for r in [f32::NAN, -5.0] {+ assert_eq!(render_caption(&cap("Hi"), &CaptionStyle { corner_radius: r, ..Default::default() }, 640, 360), square, "{r}");+ }+ }++ #[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);@@ -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, "cornerRadius": t.style.corner_radius, }, "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)?,"cornerRadius":px at 1080 lines=0?,"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) = f64_p(&p, "cornerRadius").filter(|v| v.is_finite()) {+ y.corner_radius = (v as f32).clamp(0.0, 500.0);+ }+ 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; }@@ -224,3 +224,38 @@ 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_corner_radius() {+ let mut s = demo();+ s.execute("captions.add", json!({"text": "Rounded", "seconds": 1.0})).unwrap();+ let r = |s: &Session| s.active_sequence().unwrap().caption_tracks[0].style.corner_radius;+ assert_eq!(r(&s), 0.0);+ s.execute("captions.setStyle", json!({"cornerRadius": 18})).unwrap();+ assert_eq!(r(&s), 18.0);+ assert_eq!(s.execute("captions.list", json!({})).unwrap()["tracks"][0]["style"]["cornerRadius"], 18.0);+ for (v, want) in [(-3.0, 0.0), (1e9, 500.0)] {+ s.execute("captions.setStyle", json!({"cornerRadius": v})).unwrap();+ assert_eq!(r(&s), want);+ }+}@@ -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,9 @@ 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)));+ let radius = if st.corner_radius.is_finite() { st.corner_radius.clamp(0.0, 500.0) as f64 * k } else { 0.0 };+ set(&mut layer, "background_radius", ParamValue::Float(radius)); } if st.outline > 0.0 { set(&mut layer, "stroke", ParamValue::Bool(true));@@ -308,6 +308,47 @@ 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 upgrade_caption_carries_the_corner_radius() {+ let mut s = demo("gt-caption-radius");+ s.execute("captions.add", json!({"text": "Rounded", "seconds": 600, "durationSeconds": 2})).unwrap();+ s.execute("captions.setStyle", json!({"cornerRadius": 18})).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_radius").unwrap().value, ParamValue::Float(18.0 * k));+}+ #[test] fn source_graphics_share_edits() { let mut s = demo("gt-source");@@ -93,6 +93,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]>,+ /// Background corner radius in pixels at 1080 lines (0 = square), capped at half the box.+ pub corner_radius: f32, } impl Default for CaptionStyle {@@ -109,6 +115,8 @@ impl Default for CaptionStyle { line_spacing: 1.25, outline: 0.0, outline_color: [0, 0, 0, 255],+ background_padding: None,+ corner_radius: 0.0, } } }@@ -303,6 +311,26 @@ 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_corner_radius_have_square_corners() {+ 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.corner_radius, 0.0);+ }+ #[test] fn plain() { assert_eq!(plain_text("<i>Hello</i> & <b>bye</b>"), "Hello & bye");@@ -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}))); }+ // 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})));+ }+ }+ let mut radius = st.corner_radius;+ let resp = ui+ // the full range captions.setStyle accepts, so a stored radius is never clamped on display+ .add(crate::widgets::number_drag(&mut radius).range(0.0..=500.0).speed(0.5).prefix(tl!("Radius ")).suffix(" px"))+ .on_hover_text(tl!("Box corner radius (px at 1080 lines)"));+ app.auto.add("text.captions.style.cornerRadius", resp.rect, "Caption box corner radius");+ 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, "cornerRadius": radius})));+ } 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)>) {@@ -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 corner radius (`cornerRadius`: pixels at 1080 lines, default 0 for square corners, capped at+ half the box height or width). - **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), `cornerRadius`, `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 with its padding and corner radius, 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.*`.@@ -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 and corner radius as the text background's size and radius, 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 |