Browse / github.com/storytold/filmcraft / patch
#3357: caption track font style
Captions: font style setting for caption text ## Request Let a caption track choose the font style within its family (Regular, Medium, SemiBold, Bold…). ## Current behaviour `caption_style` in `crates/captions/src/burn.rs` hard-codes `style: "SemiBold"`, so caption text is always the family's SemiBold (or its nearest weight). A Regular or Medium caption isn't possible. ## Desired behaviour - New `CaptionStyle` field, exposed through `captions.setStyle` as `fontStyle`, default `"SemiBold"`. - Resolved the same way as graphic text styles, with the same fallback when the style is missing. - Saved in `.fcproj`; offered in the Text panel'
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- evillollive (imported from pull request #3389, open)
- Base
70375a3ee8894343080e1f77d80139d0b1510738- Size
- +275 −21 in 9 files
- Review
- pending
- Shared
- 2026-10-28
- Id
78e54d2c964db73bf61370a9
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,7 @@ //! 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`] (font style, 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) //! and rasterises it into a small premultiplied linear-light RGBA [`Overlay`] positioned in the //! frame. Renderers composite overlays over the finished picture.@@ -55,10 +55,12 @@ impl Overlay { } } -/// The caption face: the track's font family (Inter when empty or not installed) in SemiBold at `px`.-fn caption_style(font: &str, px: f32) -> TextStyle {+/// The caption face: the track's font family (Inter when empty or not installed) in the track's+/// font style (SemiBold when empty) at `px`.+fn caption_style(font: &str, font_style: &str, px: f32) -> TextStyle { let family = if font.trim().is_empty() { filmcraft_text::fonts::DEFAULT_FAMILY } else { font.trim() };- TextStyle { family: family.into(), style: "SemiBold".into(), size: px, ..Default::default() }+ let style = if font_style.trim().is_empty() { "SemiBold" } else { font_style.trim() };+ TextStyle { family: family.into(), style: style.into(), size: px, ..Default::default() } } fn lin(c: [u8; 4]) -> [f32; 4] {@@ -118,7 +120,7 @@ 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;- let ts = caption_style(&style.font, px);+ let ts = caption_style(&style.font, &style.font_style, px); let mut lines: Vec<String> = Vec::new(); // (speaker names are metadata; like Premiere, they are not burned in) for l in c.plain_lines() {@@ -377,6 +379,165 @@ 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);+ }+ }++ #[test]+ fn font_style_is_used() {+ // two styles of one family differ; an empty style is the default SemiBold+ let semi = render_caption(&cap("Hello world"), &CaptionStyle::default(), 1920, 1080).unwrap();+ for style in ["Regular", "Bold"] {+ let st = CaptionStyle { font_style: style.into(), ..Default::default() };+ let o = render_caption(&cap("Hello world"), &st, 1920, 1080).unwrap();+ assert_ne!((o.w, &o.px), (semi.w, &semi.px), "{style} renders like SemiBold");+ }+ let empty = CaptionStyle { font_style: " ".into(), ..Default::default() };+ assert_eq!(render_caption(&cap("Hello world"), &empty, 1920, 1080), Some(semi.clone()));+ // a style the family lacks falls back to the nearest weight, like graphic text+ let missing = CaptionStyle { font_style: "No Such Style".into(), ..Default::default() };+ assert!(render_caption(&cap("Hello world"), &missing, 1920, 1080).is_some());+ }+ #[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),+ "fontStyle": t.style.font_style, }, "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?,"fontStyle":str="SemiBold" (Regular, Medium, Bold…)?,"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?}"##, has_caption_track, |s, p| { let tid = track_param(s, p).ok_or_else(|| {@@ -375,6 +376,9 @@ pub fn commands() -> Vec<CommandSpec> { if let Some(v) = str_p(&p, "font") { y.font = v.to_string(); }+ if let Some(v) = str_p(&p, "fontStyle") {+ y.font_style = v.trim().to_string();+ } if let Some(v) = f64_p(&p, "size") { y.size = (v as f32).clamp(4.0, 400.0); }@@ -224,3 +224,15 @@ fn captions_copy_cut_and_paste() { s.execute("edit.undo", json!({})).unwrap(); assert_eq!(caption_spans(&s).len(), 1); }++#[test]+fn set_style_font_style() {+ let mut s = demo();+ s.execute("captions.add", json!({"text": "Styled", "seconds": 1.0})).unwrap();+ assert_eq!(s.active_sequence().unwrap().caption_tracks[0].style.font_style, "SemiBold");+ s.execute("captions.setStyle", json!({"fontStyle": " Medium "})).unwrap();+ assert_eq!(s.active_sequence().unwrap().caption_tracks[0].style.font_style, "Medium");+ assert_eq!(s.execute("captions.list", json!({})).unwrap()["tracks"][0]["style"]["fontStyle"], "Medium");+ s.execute("captions.setStyle", json!({"reset": true})).unwrap();+ assert_eq!(s.active_sequence().unwrap().caption_tracks[0].style.font_style, "SemiBold");+}@@ -909,7 +909,8 @@ fn upgrade_caption(s: &mut Session, p: &Value) -> Result<Value> { }; let col = |c: [u8; 4]| [c[0] as f32 / 255.0, c[1] as f32 / 255.0, c[2] as f32 / 255.0, 1.0]; set(&mut layer, "font", ParamValue::Text(st.font.clone()));- set(&mut layer, "font_style", ParamValue::Text("SemiBold".into()));+ let font_style = if st.font_style.trim().is_empty() { "SemiBold" } else { st.font_style.trim() };+ set(&mut layer, "font_style", ParamValue::Text(font_style.into())); set(&mut layer, "align", ParamValue::Choice(st.align as u32)); set(&mut layer, "fill_color", ParamValue::Color(col(st.color))); set(&mut layer, "leading", ParamValue::Float(line_h - size * 1.21));@@ -1066,7 +1067,8 @@ pub fn fonts_used(s: &Session) -> Vec<(String, String, usize)> { for q in s.project.sequences() { if let ItemKind::Sequence(sq) = &q.kind { for t in &sq.caption_tracks {- add(t.style.font.clone(), "SemiBold".into());+ let st = if t.style.font_style.trim().is_empty() { "SemiBold" } else { t.style.font_style.trim() };+ add(t.style.font.clone(), st.into()); } } }@@ -1122,8 +1124,12 @@ fn replace_fonts(s: &mut Session, p: &Value) -> Result<Value> { for id in ids { if let Some(sq) = pr.sequence_mut(id) { for t in &mut sq.caption_tracks {- if t.style.font.eq_ignore_ascii_case(&from) && from_style.is_none() {+ let st = if t.style.font_style.trim().is_empty() { "SemiBold" } else { t.style.font_style.trim() };+ if matches(&t.style.font, st) { t.style.font = to.clone();+ if let Some(ts) = &to_style {+ t.style.font_style = ts.clone();+ } n += 1; } }@@ -308,6 +308,27 @@ fn upgrade_caption_to_graphic() { assert_eq!(s.execute("captions.list", json!({})).unwrap()["tracks"][0]["captions"].as_array().unwrap().len(), before); } +#[test]+fn caption_font_style_is_upgraded_listed_and_replaced() {+ let mut s = demo("gt-caption-font");+ s.execute("captions.add", json!({"text": "Styled", "seconds": 600, "durationSeconds": 2})).unwrap();+ s.execute("captions.setStyle", json!({"font": "Inter", "fontStyle": "Medium"})).unwrap();+ let used = s.execute("graphics.fonts.used", json!({})).unwrap().to_string();+ assert!(used.contains("Medium"), "{used}");+ // a style-specific replacement skips other styles and moves this one+ s.execute("file.replaceFonts", json!({"from": {"family": "Inter", "style": "Bold"}, "to": "Noto Serif"})).unwrap();+ assert_eq!(s.active_sequence().unwrap().caption_tracks[0].style.font, "Inter");+ // upgrading carries the style onto the graphic's text+ 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();+ assert_eq!(e.param("font_style").unwrap().value, ParamValue::Text("Medium".into()));+ s.execute("edit.undo", json!({})).unwrap();+ s.execute("file.replaceFonts", json!({"from": {"family": "Inter", "style": "Medium"}, "to": {"family": "Noto Serif", "style": "Bold"}})).unwrap();+ let st = &s.active_sequence().unwrap().caption_tracks[0].style;+ assert_eq!((st.font.as_str(), st.font_style.as_str()), ("Noto Serif", "Bold"));+}+ #[test] fn source_graphics_share_edits() { let mut s = demo("gt-source");@@ -93,6 +93,9 @@ pub struct CaptionStyle { /// Text outline width in pixels at 1080 lines (0 = none). pub outline: f32, pub outline_color: [u8; 4],+ /// Font style within the family (`Regular`, `Medium`, `SemiBold`, `Bold Italic`…), resolved+ /// like graphic text: the nearest weight when the family has no face of that name.+ pub font_style: String, } impl Default for CaptionStyle {@@ -109,6 +112,7 @@ impl Default for CaptionStyle { line_spacing: 1.25, outline: 0.0, outline_color: [0, 0, 0, 255],+ font_style: "SemiBold".into(), } } }@@ -303,6 +307,15 @@ pub fn plain_text(s: &str) -> String { mod tests { use super::*; + #[test]+ fn styles_saved_without_a_font_style_load_semibold() {+ 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.font_style, "SemiBold");+ }+ #[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();@@ -679,6 +699,21 @@ fn style_strip(app: &mut FilmcraftApp, ui: &mut egui::Ui, r: Rect, track_idx: us } }); app.auto.add("text.captions.style.font", r.response.rect, "Caption font");+ // the styles of the track's family, next to the font+ let styles = filmcraft_text::families().into_iter().find(|(f, _)| f.eq_ignore_ascii_case(&st.font)).map(|(_, v)| v).unwrap_or_default();+ let r = craft_ui::ComboBox::from_id_salt(("caption-font-style", tr.id.0)).selected_text(&st.font_style).width(100.0).height(300.0).show_ui(ui, |ui| {+ for name in &styles {+ let resp = ui.selectable_label(name.eq_ignore_ascii_case(&st.font_style), name);+ let visible = resp.rect.intersect(ui.clip_rect());+ if visible.is_positive() {+ app.auto.add(&format!("text.captions.style.fontStyle.option.{name}"), visible, name);+ }+ if resp.clicked() {+ actions.push(("captions.setStyle".into(), json!({"track": tr.id.0, "fontStyle": name})));+ }+ }+ });+ app.auto.add("text.captions.style.fontStyle", r.response.rect, "Caption font style"); let mut size = st.size; let resp = ui.add(crate::widgets::number_drag(&mut size).range(8.0..=200.0).speed(0.5).suffix(" px")); app.auto.add("text.captions.style.size", resp.rect, "Caption size");@@ -725,6 +760,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)>) {@@ -9,7 +9,8 @@ version 2; version 1 projects open with no caption tracks). - **Caption track** (`CaptionTrack`, `crates/project/src/caption.rs`): name, format (`Subtitle`, `CEA-608`, `CEA-708`, `Teletext`), language, output eye (shown in the Program monitor and burned in on export), lock, sync lock, and a **track style**:- font size (pixels at 1080 lines, scaled to the frame), text colour, background box and its colour,+ font family and font style (`fontStyle`, default `SemiBold`, resolved like graphic text: the+ nearest weight when the family has no face of that name), 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.@@ -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`, `font`, `fontStyle`, `size`, `color`, `background`, `backgroundColor`, `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 +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 (font, font style, size, colour, 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.*`.@@ -80,7 +81,7 @@ also splits captions. The Program monitor shows the visible caption tracks. On export, `file.exportMedia` `{"burnCaptions": true}` (Export mode: **Burn Captions Into Video**) draws them into every frame.-Captions are set in Inter SemiBold (OFL, bundled) by the text engine (`crates/text`: shaping,+Captions are set in the track's font and font style (Inter SemiBold by default, OFL, bundled) by the text engine (`crates/text`: shaping, kerning, bidi); italics/bold markup is not yet drawn differently. The captions of a nested sequence are part of the nest's picture: they are drawn wherever the nest is shown, in the Program monitor and in every export, whether or not the outer sequence shows or burns in its own.@@ -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 and font style, size, colour, background, 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 |