From c1fb929d8a18fa6a8af2c28eb49bac48bb0f2cac Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Sat, 22 Aug 2026 15:14:44 +0200 Subject: [PATCH 1/9] feat(compositor): let a text region pin an edge instead of centring its block MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds one optional field to the text payload, `verticalAlign`, threaded through the scene into all three rasterizers. Absent means centred — so annotations, which never emit it, render byte for byte as before. Centring is what made caption placement incoherent: a centred block moves BOTH its edges when it grows, so a caption drifted vertically whenever the text wrapped to another line, and no setting could hold it still. An anchored block keeps its anchored edge exactly where it was put, at any line count. The Linux test asserts precisely that, which is the assertion the old geometry could not express. Option and not an enum, for the same reason as `space`: serde rejects an unknown unit variant, so a future value would cost the whole scene on an older binary rather than one misplaced caption. Windows needed the layout box inset vertically by the plate margin and the draw origin offset to match, or a bottom anchor puts the glyphs flush against the box and the plate's lower margin gets clipped. That arithmetic cancels exactly for the centred case; it is now a pure function with a test pinning it to where it was, because it was the one calculation on the Windows path no test covered. Nothing emits the field yet. --- crates/compositor/src/compositor_linux.rs | 3 + crates/compositor/src/compositor_macos.rs | 3 + crates/compositor/src/compositor_windows.rs | 3 + crates/compositor/src/scene.rs | 12 ++ crates/compositor/src/text_linux.rs | 93 +++++++++++- crates/compositor/src/text_macos.rs | 67 ++++++++- crates/compositor/src/text_windows.rs | 154 ++++++++++++++++++-- 7 files changed, 314 insertions(+), 21 deletions(-) diff --git a/crates/compositor/src/compositor_linux.rs b/crates/compositor/src/compositor_linux.rs index 8b950f753..03a0aa6b9 100644 --- a/crates/compositor/src/compositor_linux.rs +++ b/crates/compositor/src/compositor_linux.rs @@ -1497,6 +1497,9 @@ impl Compositor { italic: text.font_style == "italic", underline: text.text_decoration == "underline", align: text.text_align.clone(), + // Absent = "center", le comportement historique : les + // annotations ne changent pas d'un pixel. + valign: text.vertical_align.clone().unwrap_or_default(), box_px: [ quad_px[0].round().max(1.0) as u32, quad_px[1].round().max(1.0) as u32, diff --git a/crates/compositor/src/compositor_macos.rs b/crates/compositor/src/compositor_macos.rs index 2cee70e30..cffc9395d 100644 --- a/crates/compositor/src/compositor_macos.rs +++ b/crates/compositor/src/compositor_macos.rs @@ -1146,6 +1146,9 @@ impl Compositor { italic: text.font_style == "italic", underline: text.text_decoration == "underline", align: text.text_align.clone(), + // Absent = "center", le comportement historique : les + // annotations ne changent pas d'un pixel. + valign: text.vertical_align.clone().unwrap_or_default(), box_px: [quad_px[0].round() as u32, quad_px[1].round() as u32], }; let key = spec.cache_key(); diff --git a/crates/compositor/src/compositor_windows.rs b/crates/compositor/src/compositor_windows.rs index dbaf6f855..51c3a3a28 100644 --- a/crates/compositor/src/compositor_windows.rs +++ b/crates/compositor/src/compositor_windows.rs @@ -1777,6 +1777,9 @@ impl Compositor { italic: text.font_style == "italic", underline: text.text_decoration == "underline", align: text.text_align.clone(), + // Absent = "center", le comportement historique : les + // annotations ne changent pas d'un pixel. + valign: text.vertical_align.clone().unwrap_or_default(), box_px: [quad_px[0].round() as u32, quad_px[1].round() as u32], }; let key = spec.cache_key(); diff --git a/crates/compositor/src/scene.rs b/crates/compositor/src/scene.rs index bf9260bef..2d20e233b 100644 --- a/crates/compositor/src/scene.rs +++ b/crates/compositor/src/scene.rs @@ -261,6 +261,18 @@ pub struct SceneAnnotationText { pub font_style: String, pub text_decoration: String, pub text_align: String, + /// Quelle arête du bloc de texte est épinglée à sa boîte : `"top"` / `"center"` + /// / `"bottom"`. Absent = `"center"`, le comportement historique — les + /// annotations n'émettent jamais la clé et ne bougent donc pas d'un pixel. + /// Les sous-titres l'émettent pour que l'arête ancrée tienne quand le texte + /// gagne une ligne (un bloc centré voit ses deux arêtes se déplacer). + /// + /// `Option` et pas une enum, pour la même raison que `space` : serde + /// rejette une variante d'unité inconnue, donc une valeur future ferait + /// échouer `Scene::from_json` *en entier* sur un binaire plus ancien, au lieu + /// de coûter un seul sous-titre mal placé. + #[serde(default)] + pub vertical_align: Option, #[serde(default)] pub animation: Option, } diff --git a/crates/compositor/src/text_linux.rs b/crates/compositor/src/text_linux.rs index 3e7b45b2b..d990c3dbe 100644 --- a/crates/compositor/src/text_linux.rs +++ b/crates/compositor/src/text_linux.rs @@ -37,6 +37,12 @@ pub struct TextSpec { pub underline: bool, /// "left" | "center" | "right". pub align: String, + /// "top" | "center" | "bottom" -- quelle arete du bloc de texte est epinglee + /// a la boite. "center" est le comportement historique (et celui des + /// annotations, qui reproduisent `alignItems: center` de l'overlay web) ; les + /// sous-titres passent "bottom" ou "top" pour que l'arete ancree ne bouge pas + /// quand le texte gagne une ligne. + pub valign: String, /// Taille de la boite en px de sortie. pub box_px: [u32; 2], } @@ -61,6 +67,10 @@ impl TextSpec { } mix(&[self.bold as u8, self.italic as u8, self.underline as u8]); mix(self.align.as_bytes()); + // Juste apres `align`, memes octets et meme position que sur les deux + // autres backends : deux specs ne differant que par l'alignement vertical + // rendraient sinon les pixels l'une de l'autre depuis le cache. + mix(self.valign.as_bytes()); mix(&self.box_px[0].to_le_bytes()); mix(&self.box_px[1].to_le_bytes()); h @@ -194,7 +204,18 @@ impl TextRasterizer { .fold(0.0f32, f32::max); // `max(0)` : un texte plus haut que sa boite reste ancre en haut plutot // que de sortir par le dessus, ou il serait entierement rogne. - let y_offset = (((h as f32) - text_h) * 0.5).max(0.0).round() as i32; + // + // ANCRAGE. `center` est le comportement historique, et reste celui des + // annotations. Les sous-titres epinglent une arete : c'est la seule facon + // que l'arete ancree ne bouge pas quand le texte gagne une ligne, parce + // qu'un bloc centre voit ses DEUX aretes se deplacer. + let slack_y = ((h as f32) - text_h).max(0.0); + let y_offset = match spec.valign.as_str() { + "top" | "start" => 0.0, + "bottom" | "end" => slack_y, + _ => slack_y * 0.5, + } + .round() as i32; // LA PLAQUE EPOUSE LE BLOC, PAS LA BOITE. Miroir de // `text_macos::block_layout` (en coordonnees descendantes ici, CoreText @@ -385,6 +406,7 @@ mod tests { italic: false, underline: false, align: align.to_owned(), + valign: "center".to_owned(), box_px: [400, 200], } } @@ -566,6 +588,75 @@ mod tests { ); } + #[test] + fn the_anchored_edge_holds_still_when_the_text_gains_a_line() { + // L'INVARIANT de la refonte du placement des sous-titres, en une + // assertion — et celle que l'ancienne architecture ne pouvait pas ecrire. + // + // Un bloc centre voit ses DEUX aretes bouger quand il grandit : c'est + // exactement pourquoi elargir la bande deplacait verticalement le + // sous-titre. Ancre en bas, l'arete basse ne doit pas bouger d'un pixel, + // que le texte tienne sur une ligne ou en reclame trois. + let raster = TextRasterizer::new().expect("rasterizer"); + let (w, h) = (400usize, 200usize); + let long = "un texte assez long pour devoir se replier sur plusieurs lignes"; + + let one = |valign: &str, content: &str| { + let mut s = spec(content, "center"); + s.valign = valign.to_owned(); + let atlas = raster.build_atlas(&s).expect("atlas").pixels; + let rows = ink_rows(&atlas, w, 0, w); + assert!(!rows.is_empty(), "aucune encre pour {valign:?}"); + (rows[0], *rows.last().unwrap()) + }; + + let (_, short_bottom) = one("bottom", "Hx"); + let (_, long_bottom) = one("bottom", long); + assert!( + (short_bottom as i32 - long_bottom as i32).abs() <= 1, + "ancrage bas : l'arete basse a bouge de {short_bottom} a {long_bottom} \ + en passant d'une ligne a plusieurs" + ); + + // Et le miroir, pour que « haut » ne soit pas juste « pas bas ». + let (short_top, _) = one("top", "Hx"); + let (long_top, _) = one("top", long); + assert!( + (short_top as i32 - long_top as i32).abs() <= 1, + "ancrage haut : l'arete haute a bouge de {short_top} a {long_top}" + ); + + // Le texte long doit vraiment occuper plus de hauteur, sinon les deux + // assertions ci-dessus passeraient sur deux rendus identiques. + let (lt, lb) = one("bottom", long); + let (st, sb) = one("bottom", "Hx"); + assert!( + (lb - lt) > (sb - st), + "le texte « long » ne s'est pas replie : le test ne prouve rien" + ); + + // Enfin, les trois ancrages doivent poser l'encre a trois endroits + // differents dans la boite — sinon `valign` n'est pas applique du tout. + let (top_t, _) = one("top", "Hx"); + let (ctr_t, _) = one("center", "Hx"); + let (bot_t, _) = one("bottom", "Hx"); + assert!( + top_t < ctr_t && ctr_t < bot_t, + "les trois ancrages ne se distinguent pas : haut={top_t} centre={ctr_t} bas={bot_t}" + ); + assert!(bot_t > h / 2, "l'ancrage bas laisse l'encre dans la moitie haute"); + } + + #[test] + fn the_vertical_anchor_changes_the_cache_key() { + // Le piege du cache : la cle est partagee entre plateformes, et deux specs + // ne differant que par `valign` rendraient les pixels l'une de l'autre si + // le champ n'y entrait pas. + let mut bottom = spec("Hx", "center"); + bottom.valign = "bottom".to_owned(); + assert_ne!(bottom.cache_key(), spec("Hx", "center").cache_key()); + } + #[test] fn centering_moves_the_ink_off_the_left_edge() { // `spec.align` n'etait jamais applique : tout sortait ferre a gauche diff --git a/crates/compositor/src/text_macos.rs b/crates/compositor/src/text_macos.rs index 57385efe0..7e076f348 100644 --- a/crates/compositor/src/text_macos.rs +++ b/crates/compositor/src/text_macos.rs @@ -52,6 +52,11 @@ pub struct TextSpec { pub underline: bool, /// "left" | "center" | "right". pub align: String, + /// "top" | "center" | "bottom" — quelle arête du bloc est épinglée à la boîte. + /// "center" est le comportement historique (et celui des annotations) ; les + /// sous-titres passent "bottom" ou "top" pour que l'arête ancrée ne bouge pas + /// quand le texte gagne une ligne. + pub valign: String, /// Taille de la boîte en px de sortie — la mise en page en dépend (retours à la ligne). pub box_px: [u32; 2], } @@ -79,6 +84,10 @@ impl TextSpec { } mix(&[self.bold as u8, self.italic as u8, self.underline as u8]); mix(self.align.as_bytes()); + // Juste après `align`, mêmes octets et même position que sur les deux + // autres backends : deux specs ne différant que par l'alignement vertical + // rendraient sinon les pixels l'une de l'autre depuis le cache. + mix(self.valign.as_bytes()); mix(&self.box_px[0].to_le_bytes()); mix(&self.box_px[1].to_le_bytes()); h @@ -308,6 +317,7 @@ fn block_layout( text_w: CGFloat, text_h: CGFloat, align: u8, + valign: &str, font_px: CGFloat, ) -> (CGRect, CGRect) { let (pad_x, pad_y) = plate_padding(font_px); @@ -317,7 +327,17 @@ fn block_layout( // la mesure. On l'étend d'un pixel vers le BAS — donc en abaissant l'origine `y`, pas // en montant le sommet — pour que le haut du texte ne bouge pas d'un poil. const GUARD: CGFloat = 1.0; - let top = ((box_h - text_h) * 0.5).max(0.0); + // ANCRAGE. `center` reste le comportement historique (et celui des annotations, + // qui reproduisent `alignItems: center` de l'overlay web). Les sous-titres + // épinglent une arête : un bloc centré voit ses DEUX arêtes bouger quand il + // gagne une ligne, ce qui déplaçait le sous-titre. `top` est ici une distance + // depuis le HAUT de la boîte, en coordonnées descendantes. + let slack_y = (box_h - text_h).max(0.0); + let top = match valign { + "top" | "start" => 0.0, + "bottom" | "end" => slack_y, + _ => slack_y * 0.5, + }; let frame_x = (box_w - avail_w) * 0.5; let frame = CGRect { origin: CGPoint { @@ -593,7 +613,7 @@ impl TextRasterizer { let text_h = measured.height.ceil().max(0.0); let (frame_rect, plate_rect) = - block_layout(box_w, box_h, text_w, text_h, alignment, font_px); + block_layout(box_w, box_h, text_w, text_h, alignment, &spec.valign, font_px); // --- plaque de fond, sous le texte --- if spec.background[3] > 0.0 && plate_rect.size.width > 0.0 && plate_rect.size.height > 0.0 @@ -651,6 +671,7 @@ mod tests { italic: false, underline: false, align: "center".into(), + valign: "center".into(), box_px: [256, 256], } } @@ -828,7 +849,7 @@ mod tests { /// Géométrie pure — pas de GPU, pas de CoreText. #[test] fn block_layout_centres_the_frame_and_sizes_the_plate() { - let (frame, plate) = block_layout(1536.0, 238.0, 500.0, 56.0, 2, 48.0); + let (frame, plate) = block_layout(1536.0, 238.0, 500.0, 56.0, 2, "center", 48.0); // Cadre centré : autant de vide au-dessus qu'en dessous (repère CG, y vers le haut). let above = 238.0 - (frame.origin.y + frame.size.height); let below = frame.origin.y; @@ -843,11 +864,49 @@ mod tests { fn block_layout_never_lets_the_plate_leave_the_box() { for align in [0u8, 1, 2] { // Bloc plus large et plus haut que la boîte : la plaque doit se contenter d'elle. - let (_, plate) = block_layout(200.0, 60.0, 400.0, 200.0, align, 48.0); + let (_, plate) = block_layout(200.0, 60.0, 400.0, 200.0, align, "center", 48.0); assert!(plate.origin.x >= 0.0, "align={align} : x={}", plate.origin.x); assert!(plate.origin.y >= 0.0, "align={align} : y={}", plate.origin.y); assert!(plate.origin.x + plate.size.width <= 200.0 + 0.01, "align={align}"); assert!(plate.origin.y + plate.size.height <= 60.0 + 0.01, "align={align}"); } } + + /// L'invariant de la refonte du placement des sous-titres, en géométrie pure. + /// Un bloc centré voit ses DEUX arêtes bouger quand il grandit ; ancré, l'arête + /// ancrée ne bouge pas. Repère CoreGraphics : `y` monte. + #[test] + fn block_layout_pins_the_anchored_edge_whatever_the_block_height() { + let (box_w, box_h) = (1536.0, 238.0); + let edges = |valign: &str, text_h: f64| { + let (frame, _) = block_layout(box_w, box_h, 500.0, text_h, 2, valign, 48.0); + // (bas, haut) en distance depuis le bas de la boîte. + (frame.origin.y, frame.origin.y + frame.size.height) + }; + + // Ancrage bas : l'arête basse est la même à une et à trois lignes. + let (one_bottom, _) = edges("bottom", 56.0); + let (three_bottom, _) = edges("bottom", 168.0); + assert!( + (one_bottom - three_bottom).abs() < 0.01, + "ancrage bas : l'arête basse a bougé de {one_bottom} à {three_bottom}" + ); + + // Ancrage haut : l'arête haute est la même. + let (_, one_top) = edges("top", 56.0); + let (_, three_top) = edges("top", 168.0); + assert!( + (one_top - three_top).abs() < 0.01, + "ancrage haut : l'arête haute a bougé de {one_top} à {three_top}" + ); + + // Et le centrage, lui, fait bien bouger les deux — c'est le comportement + // historique qu'on préserve pour les annotations. + let (c1_bottom, c1_top) = edges("center", 56.0); + let (c3_bottom, c3_top) = edges("center", 168.0); + assert!( + (c1_bottom - c3_bottom).abs() > 1.0 && (c1_top - c3_top).abs() > 1.0, + "le centrage devrait déplacer les deux arêtes" + ); + } } diff --git a/crates/compositor/src/text_windows.rs b/crates/compositor/src/text_windows.rs index 93a5f6df8..b9f6f9e10 100644 --- a/crates/compositor/src/text_windows.rs +++ b/crates/compositor/src/text_windows.rs @@ -33,8 +33,9 @@ use windows::Win32::Graphics::DirectWrite::{ DWriteCreateFactory, IDWriteFactory, DWRITE_FACTORY_TYPE_SHARED, DWRITE_FONT_STRETCH_NORMAL, DWRITE_FONT_STYLE_ITALIC, DWRITE_FONT_STYLE_NORMAL, DWRITE_FONT_WEIGHT_BOLD, DWRITE_FONT_WEIGHT_NORMAL, DWRITE_PARAGRAPH_ALIGNMENT_CENTER, - DWRITE_TEXT_ALIGNMENT_CENTER, DWRITE_TEXT_ALIGNMENT_LEADING, DWRITE_TEXT_ALIGNMENT_TRAILING, - DWRITE_TEXT_METRICS, DWRITE_TEXT_RANGE, + DWRITE_PARAGRAPH_ALIGNMENT_FAR, DWRITE_PARAGRAPH_ALIGNMENT_NEAR, DWRITE_TEXT_ALIGNMENT_CENTER, + DWRITE_TEXT_ALIGNMENT_LEADING, DWRITE_TEXT_ALIGNMENT_TRAILING, DWRITE_TEXT_METRICS, + DWRITE_TEXT_RANGE, }; use windows::Win32::Graphics::Dxgi::Common::DXGI_FORMAT_B8G8R8A8_UNORM; use windows::Win32::Graphics::Dxgi::Common::DXGI_SAMPLE_DESC; @@ -56,6 +57,11 @@ pub struct TextSpec { pub underline: bool, /// "left" | "center" | "right". pub align: String, + /// "top" | "center" | "bottom" — quelle arête du bloc est épinglée à la boîte. + /// "center" est le comportement historique (et celui des annotations) ; les + /// sous-titres passent "bottom" ou "top" pour que l'arête ancrée ne bouge pas + /// quand le texte gagne une ligne. + pub valign: String, /// Taille de la boîte en px de sortie — la mise en page en dépend (retours à la ligne). pub box_px: [u32; 2], } @@ -79,6 +85,10 @@ impl TextSpec { } mix(&[self.bold as u8, self.italic as u8, self.underline as u8]); mix(self.align.as_bytes()); + // Juste après `align`, mêmes octets et même position que sur les deux + // autres backends : deux specs ne différant que par l'alignement vertical + // rendraient sinon les pixels l'une de l'autre depuis le cache. + mix(self.valign.as_bytes()); mix(&self.box_px[0].to_le_bytes()); mix(&self.box_px[1].to_le_bytes()); h @@ -90,6 +100,37 @@ fn wide(s: &str) -> Vec { s.encode_utf16().chain(std::iter::once(0)).collect() } +/// La plaque de fond, en `[left, top, right, bottom]` px dans la boîte, à partir des +/// métriques DirectWrite du bloc mis en page. +/// +/// Fonction pure — et volontairement extraite du chemin de dessin : le rasteriseur +/// Windows exige un device D3D, donc tout ce qui reste inline dans `rasterize` n'est +/// couvert par aucun test. macOS a `block_layout` pour la même raison ; ceci met les +/// deux backends au même niveau, sur le calcul qui décide si la plaque se fait rogner. +/// +/// Les deux annulations qui portent tout : +/// * horizontalement, le texte est dessiné à `pad_x` et commence donc à `pad_x + m.left` : +/// la plaque part de `m.left`, l'inset de la boîte de mise en page et la marge de +/// plaque se compensent exactement, quel que soit l'alignement ; +/// * verticalement, le texte est dessiné à `pad_y` dans une boîte de mise en page rentrée +/// de `2*pad_y`, donc son haut réel vaut `pad_y + m.top` et la plaque va de `m.top` à +/// `m.top + m.height + 2*pad_y`. Ancré en bas (`DWRITE_PARAGRAPH_ALIGNMENT_FAR`), ce +/// second terme tombe pile sur `box_h` : la marge basse tient tout juste au lieu d'être +/// rognée par le `.min()`. +/// +/// Le bornage à la boîte est ce qui empêche la plaque d'être coupée net par le bord de +/// la texture, où elle perdrait ses coins arrondis. +fn plate_rect(metrics: [f32; 4], box_px: [f32; 2], pad_x: f32, pad_y: f32) -> [f32; 4] { + let [m_left, m_top, m_width, m_height] = metrics; + let [box_w, box_h] = box_px; + [ + m_left.max(0.0), + m_top.max(0.0), + (m_left + m_width + pad_x * 2.0).min(box_w), + (m_top + m_height + pad_y * 2.0).min(box_h), + ] +} + pub struct TextRasterizer { d2d: ID2D1Factory, dwrite: IDWriteFactory, @@ -173,8 +214,14 @@ impl TextRasterizer { "right" => DWRITE_TEXT_ALIGNMENT_TRAILING, _ => DWRITE_TEXT_ALIGNMENT_CENTER, })?; - // Centrage vertical : l'overlay web met `alignItems: center` sur le conteneur. - format.SetParagraphAlignment(DWRITE_PARAGRAPH_ALIGNMENT_CENTER)?; + // ANCRAGE vertical. `center` reproduit `alignItems: center` de l'overlay web et + // reste le comportement des annotations ; les sous-titres épinglent une arête, + // parce qu'un bloc centré voit ses DEUX arêtes bouger quand il gagne une ligne. + format.SetParagraphAlignment(match spec.valign.as_str() { + "top" | "start" => DWRITE_PARAGRAPH_ALIGNMENT_NEAR, + "bottom" | "end" => DWRITE_PARAGRAPH_ALIGNMENT_FAR, + _ => DWRITE_PARAGRAPH_ALIGNMENT_CENTER, + })?; let text: Vec = spec.content.encode_utf16().collect(); // La boîte de mise en page est rentrée de la marge de plaque (cf. `text_plate`), et @@ -184,9 +231,15 @@ impl TextRasterizer { let font_px = spec.font_size_px.max(1.0); let (pad_x, pad_y) = crate::text_plate::padding(font_px); let layout_w = crate::text_plate::layout_width(w as f32, font_px); + // La boîte de mise en page est aussi rentrée VERTICALEMENT de la marge de plaque, + // et le texte se dessine à `pad_y`. Sans ça, un ancrage bas colle les glyphes au + // bord de la boîte et le `.min(h)` de la plaque, plus bas, rogne net sa marge + // basse. Le centrage est rigoureusement inchangé par cette paire (l'inset et le + // décalage s'annulent), donc les annotations ne bougent pas d'un pixel. + let layout_h = ((h as f32) - pad_y * 2.0).max(1.0); let layout = self .dwrite - .CreateTextLayout(&text, &format, layout_w, h as f32)?; + .CreateTextLayout(&text, &format, layout_w, layout_h)?; if spec.underline { layout.SetUnderline( true, @@ -214,16 +267,9 @@ impl TextRasterizer { a: spec.background[3], }; let bg_brush = rt.CreateSolidColorBrush(&bg, None)?; - // Le texte commence à `pad_x + m.left`, donc la plaque à `m.left` — l'inset de la - // boîte de mise en page et la marge de plaque s'annulent exactement, quel que soit - // l'alignement. Elle est ensuite bornée à la boîte : au-delà, elle serait coupée - // net par le bord de la texture et perdrait ses coins arrondis. - let rect = D2D_RECT_F { - left: m.left.max(0.0), - top: (m.top - pad_y).max(0.0), - right: (m.left + m.width + pad_x * 2.0).min(w as f32), - bottom: (m.top + m.height + pad_y).min(h as f32), - }; + let [pl, pt, pr, pb] = + plate_rect([m.left, m.top, m.width, m.height], [w as f32, h as f32], pad_x, pad_y); + let rect = D2D_RECT_F { left: pl, top: pt, right: pr, bottom: pb }; let radius = crate::text_plate::radius( font_px, (rect.right - rect.left).max(0.0), @@ -239,7 +285,7 @@ impl TextRasterizer { ); } rt.DrawTextLayout( - D2D_POINT_2F { x: pad_x, y: 0.0 }, + D2D_POINT_2F { x: pad_x, y: pad_y }, &layout, &brush, D2D1_DRAW_TEXT_OPTIONS_NONE, @@ -269,10 +315,81 @@ mod tests { italic: false, underline: false, align: "center".into(), + valign: "center".into(), box_px: [400, 120], } } + /// Métriques DirectWrite telles que `SetParagraphAlignment` les produit, pour un + /// bloc de `text_h` px dans une boîte de `box_h` : la mise en page se fait dans + /// `box_h - 2*pad_y` (cf. `rasterize`), et l'alignement décide de `m.top` dedans. + fn metrics_for(valign: &str, box_h: f32, text_h: f32, pad_y: f32) -> [f32; 4] { + let layout_h = (box_h - pad_y * 2.0).max(1.0); + let slack = (layout_h - text_h).max(0.0); + let top = match valign { + "top" => 0.0, + "bottom" => slack, + _ => slack * 0.5, + }; + [0.0, top, 200.0, text_h] + } + + #[test] + fn the_plate_survives_the_bottom_anchor_instead_of_being_clipped() { + // LE risque de la bascule d'ancrage sous Windows. Avec l'ancienne mise en page + // (boîte pleine hauteur, dessin à y=0), `FAR` collait les glyphes au bord et le + // `.min(box_h)` rognait net la marge basse de la plaque. Ici elle doit tomber + // pile sur le bord, marge comprise. + let (box_w, box_h, pad_y) = (400.0f32, 120.0f32, 4.8f32); + let m = metrics_for("bottom", box_h, 56.0, pad_y); + let [_, top, _, bottom] = plate_rect(m, [box_w, box_h], 9.6, pad_y); + + assert!( + (bottom - box_h).abs() < 0.01, + "la plaque ancrée en bas devrait finir sur le bord de la boîte, pas à {bottom}" + ); + assert!(top >= 0.0, "plaque hors boîte par le haut : {top}"); + // Et elle fait bien la hauteur du bloc plus ses deux marges — donc rien n'a été rogné. + assert!( + ((bottom - top) - (56.0 + pad_y * 2.0)).abs() < 0.01, + "la marge de la plaque a été rognée : {}px pour un bloc de 56 + 2*{pad_y}", + bottom - top + ); + } + + #[test] + fn the_centred_plate_is_exactly_where_it_was_before_the_anchor_landed() { + // La bascule d'ancrage a rentré la boîte de mise en page de 2*pad_y ET décalé le + // dessin de pad_y. Les deux DOIVENT s'annuler pour le centrage, sinon toutes les + // annotations existantes bougent. Référence : l'ancien calcul, boîte pleine + // hauteur, `top = m.top - pad_y`, `bottom = m.top + m.height + pad_y`. + let (box_w, box_h, pad_y, text_h) = (400.0f32, 120.0f32, 4.8f32, 56.0f32); + + let legacy_top = (box_h - text_h) * 0.5 - pad_y; + let legacy_bottom = (box_h - text_h) * 0.5 + text_h + pad_y; + + let m = metrics_for("center", box_h, text_h, pad_y); + let [_, top, _, bottom] = plate_rect(m, [box_w, box_h], 9.6, pad_y); + + assert!( + (top - legacy_top).abs() < 0.01 && (bottom - legacy_bottom).abs() < 0.01, + "le centrage a bougé : ({top}, {bottom}) au lieu de ({legacy_top}, {legacy_bottom})" + ); + } + + #[test] + fn the_plate_never_leaves_the_box() { + // Un bloc plus grand que sa boîte : la plaque se contente de la boîte plutôt que + // d'être coupée net par le bord de la texture (elle y perdrait ses coins arrondis). + let (box_w, box_h) = (200.0f32, 60.0f32); + for valign in ["top", "center", "bottom"] { + let m = metrics_for(valign, box_h, 400.0, 4.8); + let [l, t, r, b] = plate_rect(m, [box_w, box_h], 9.6, 4.8); + assert!(l >= 0.0 && t >= 0.0, "{valign} : coin haut-gauche hors boîte ({l}, {t})"); + assert!(r <= box_w + 0.01 && b <= box_h + 0.01, "{valign} : plaque hors boîte"); + } + } + #[test] fn identical_specs_share_a_cache_key() { assert_eq!(spec("Bonjour").cache_key(), spec("Bonjour").cache_key()); @@ -296,6 +413,11 @@ mod tests { other.align = "left".into(); assert_ne!(other.cache_key(), base, "alignement"); other = spec("Bonjour"); + // Sans ça, deux sous-titres ne différant que par l'ancrage se partageraient + // une texture et rendraient les pixels l'un de l'autre. + other.valign = "bottom".into(); + assert_ne!(other.cache_key(), base, "ancrage vertical"); + other = spec("Bonjour"); // La taille de boîte compte : elle décide des retours à la ligne, donc des pixels. other.box_px = [401, 120]; assert_ne!(other.cache_key(), base, "boîte"); From b4810d14e798fb0e6445d8d2e1256c7c71e570b5 Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Sat, 22 Aug 2026 15:37:25 +0200 Subject: [PATCH 2/9] fix(captions): place captions by anchor and margin, not by an invisible band MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the whole placement model. Every control now names the edge it measures from, and there is exactly one per axis: anchorV + insetY bottom | top, and a distance from that edge anchorH + insetX left | center | right, and a distance from that edge Deleted: verticalPosition, offsetY, offsetX, width, textAlign — and the machinery that existed only to compensate for the old geometry (the fixed 22% band, the ink-height estimate, the overhang, the reachable-offset range, the preset-vs-slider epsilon). The old model drew every caption inside an invisible fixed-height box and let each rasterizer centre the ink in it, while the only thing on screen — the background plate — hugs the text. So `width` changed nothing visible until the text happened to wrap; the horizontal offset moved a band the text floated inside; text-align fought that offset for the same outcome; wrapping grew a centred block from both edges, which moved the caption vertically when nothing vertical had been touched; and the vertical offset had to be signed and clamped against an estimate, which is where "-7.3%" came from. All five are the same decision, so this replaces the decision rather than the controls. `width` becomes a derived column instead of a control (BBC's line-length table: 68% landscape, 90% vertical). How much text is on screen is already a legible question elsewhere — min/max words per line. The default inset follows the output aspect, because 5% on a 9:16 export is under the platform's own chrome. Migration reproduces the PIXELS, not the fields: the old band's geometry is known, so the drawn block's edges are recoverable, and the nearer one becomes the anchor. A migrated project does not move on screen. Line breaks do change for a project with a non-default width, since that WAS the wrap column. Tests assert the invariant as a property — the anchored edge lands at 100−insetY (or insetY) for every font size, background state and inset — rather than pinning numbers a future change would just have to update. --- .../CaptionsPane.placement.test.tsx | 180 +++--- src/components/ai-edition/CaptionsPane.tsx | 208 +++---- src/i18n/locales/ar/settings.json | 18 +- src/i18n/locales/en/settings.json | 18 +- src/i18n/locales/es/settings.json | 18 +- src/i18n/locales/fr/settings.json | 18 +- src/i18n/locales/it/settings.json | 18 +- src/i18n/locales/ja-JP/settings.json | 18 +- src/i18n/locales/ko-KR/settings.json | 18 +- src/i18n/locales/pt-BR/settings.json | 18 +- src/i18n/locales/ru/settings.json | 18 +- src/i18n/locales/tr/settings.json | 18 +- src/i18n/locales/vi/settings.json | 18 +- src/i18n/locales/zh-CN/settings.json | 18 +- src/i18n/locales/zh-TW/settings.json | 18 +- src/lib/ai-edition/captions/captions.test.ts | 340 ++++++----- src/lib/ai-edition/captions/cues.ts | 28 +- src/lib/ai-edition/captions/index.ts | 22 +- src/lib/ai-edition/captions/settings.ts | 532 ++++++++++-------- src/lib/ai-edition/store/useCaptions.test.ts | 4 +- src/native/sceneDescription.ts | 21 +- 21 files changed, 767 insertions(+), 802 deletions(-) diff --git a/src/components/ai-edition/CaptionsPane.placement.test.tsx b/src/components/ai-edition/CaptionsPane.placement.test.tsx index b7d41cec1..f1ab095ac 100644 --- a/src/components/ai-edition/CaptionsPane.placement.test.tsx +++ b/src/components/ai-edition/CaptionsPane.placement.test.tsx @@ -1,21 +1,18 @@ // @vitest-environment jsdom -// The placement sliders take their bounds from `captionOffsetRange`, the same -// function the geometry clamps with. That shared range is the fix for the dead -// travel in #396 — the vertical slider used to advertise ±45 while the bottom -// anchor could only honour −45…+3 — so what these tests pin is the agreement -// between what the slider offers and what the band can do, not any one number. +// The placement controls after the anchor redesign. What these pin is the property +// the previous UI could not hold: every control names the edge it measures from, and +// nothing it can produce is a signed number or a dead affordance. +// +// The pane it replaced had four controls that overlapped — a band width nothing drew, +// an offset measured against that invisible band, and a text alignment fighting the +// offset for the same visual outcome — so the tests here are as much about what is +// ABSENT as about what is present. import "@testing-library/jest-dom"; import { cleanup, fireEvent, render, screen } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { I18nProvider } from "@/contexts/I18nContext"; -import { - captionBandRect, - captionInkHeightPct, - captionOffsetRange, - DEFAULT_CAPTION_SETTINGS, - getCaptionSettings, -} from "@/lib/ai-edition/captions"; +import { getCaptionSettings } from "@/lib/ai-edition/captions"; import type { AxcutAsset, AxcutDocument } from "@/lib/ai-edition/schema"; import { useProjectStore } from "@/lib/ai-edition/store/projectStore"; import { useTranscriptionStore } from "@/lib/ai-edition/store/transcriptionStore"; @@ -81,6 +78,8 @@ function sliderFor(label: string): HTMLInputElement { return input; } +const button = (name: string) => screen.getByRole("button", { name }); + function show(captions: Record) { const document = documentWith(captions); useProjectStore.setState({ @@ -108,119 +107,96 @@ afterEach(() => { }); describe("caption placement controls", () => { - it("offers both axes", () => { + it("offers one anchor and one distance per axis", () => { show({}); - expect(sliderFor("Vertical position")).toBeInTheDocument(); - expect(sliderFor("Horizontal position")).toBeInTheDocument(); + expect(button("Bottom")).toHaveAttribute("aria-pressed", "true"); + expect(button("Top")).toHaveAttribute("aria-pressed", "false"); + expect(button("Center")).toHaveAttribute("aria-pressed", "true"); + expect(sliderFor("Distance from bottom")).toBeInTheDocument(); }); - it.each([ - "top", - "middle", - "bottom", - ] as const)("bounds the %s anchor's slider by what the band can actually reach", (verticalPosition) => { - const settings = show({ verticalPosition }); - const range = captionOffsetRange(settings); - const slider = sliderFor("Vertical position"); - expect(Number(slider.min)).toBeCloseTo(range.y.min, 6); - expect(Number(slider.max)).toBeCloseTo(range.y.max, 6); - }); + it("names the edge the distance is measured from, and follows the anchor", () => { + // The old label said "Vertical offset" and the value could read "-7.3%", which + // corresponds to nothing in any subtitle format and to nothing a user can see. + show({ anchorV: "bottom" }); + expect(screen.getByText("Distance from bottom")).toBeInTheDocument(); + expect(screen.queryByText("Distance from top")).not.toBeInTheDocument(); - it("puts both ends of the range on a step, so the edges stay reachable", () => { - // A fixed step of 1 would leave `max` off-grid for these fractional bounds and - // the caption would stop just short of the frame edge — the #396 complaint. - const settings = show({ verticalPosition: "bottom" }); - const slider = sliderFor("Vertical position"); - const [min, max, step] = [slider.min, slider.max, slider.step].map(Number); - const steps = (max - min) / step; - expect(steps).toBeCloseTo(Math.round(steps), 6); - - // And landing on `max` really does put the ink on the frame's bottom edge — - // with the empty part of the band hanging off it, which is what buys the reach. - const band = captionBandRect({ ...settings, offsetY: max }); - expect(band.y + band.height / 2 + captionInkHeightPct(settings) / 2).toBeCloseTo(100, 6); - expect(band.y + band.height).toBeGreaterThan(100); + fireEvent.click(button("Top")); + expect(screen.getByText("Distance from top")).toBeInTheDocument(); + expect(screen.queryByText("Distance from bottom")).not.toBeInTheDocument(); }); - it("disables the horizontal slider only when the band fills the frame", () => { - show({ width: 100 }); - expect(sliderFor("Horizontal position")).toBeDisabled(); - cleanup(); - show({ width: DEFAULT_CAPTION_SETTINGS.width }); - expect(sliderFor("Horizontal position")).toBeEnabled(); - }); -}); - -describe("caption position presets", () => { - const preset = (label: string) => screen.getByRole("button", { name: label }); - - it("shows the default settings' presets pressed: Bottom and Position center", () => { + it("never offers a negative distance", () => { show({}); - expect(preset("Bottom")).toHaveAttribute("aria-pressed", "true"); - expect(preset("Top")).toHaveAttribute("aria-pressed", "false"); - expect(preset("Position center")).toHaveAttribute("aria-pressed", "true"); - expect(preset("Position left")).toHaveAttribute("aria-pressed", "false"); + expect(Number(sliderFor("Distance from bottom").min)).toBe(0); + fireEvent.click(button("Left")); + expect(Number(sliderFor("Distance from left").min)).toBe(0); }); - it("clicking a vertical preset resets the vertical slider and lights that preset up", () => { - show({ verticalPosition: "bottom", offsetY: -20 }); - expect(preset("Bottom")).toHaveAttribute("aria-pressed", "false"); + it("keeps the distance when the anchor flips, mirroring to the opposite edge", () => { + // The inset means the same thing on both anchors, so there is nothing to reset — + // unlike the old presets, which had to zero an offset that meant something else. + show({ anchorV: "bottom", insetY: 12 }); + fireEvent.click(button("Top")); + expect(sliderFor("Distance from top")).toHaveValue("12"); + }); - fireEvent.click(preset("Top")); + it("hides the horizontal distance when centred instead of disabling it", () => { + // A centred block has no edge to measure from. A dead slider reads as a bug, so + // the control is absent rather than greyed out. + show({ anchorH: "center" }); + expect(screen.queryByText("Distance from left")).not.toBeInTheDocument(); + expect(screen.queryByText("Distance from right")).not.toBeInTheDocument(); - expect(sliderFor("Vertical position")).toHaveValue("0"); - expect(preset("Top")).toHaveAttribute("aria-pressed", "true"); - expect(preset("Bottom")).toHaveAttribute("aria-pressed", "false"); + fireEvent.click(button("Right")); + expect(sliderFor("Distance from right")).toBeEnabled(); }); - it("dragging the vertical slider clears every vertical preset's pressed state", () => { + it("leaves no control disabled once a document is open", () => { show({}); - expect(preset("Bottom")).toHaveAttribute("aria-pressed", "true"); - - fireEvent.change(sliderFor("Vertical position"), { target: { value: "-10" } }); - - expect(preset("Bottom")).toHaveAttribute("aria-pressed", "false"); - expect(preset("Top")).toHaveAttribute("aria-pressed", "false"); - expect(preset("Middle")).toHaveAttribute("aria-pressed", "false"); + for (const name of ["Bottom", "Top", "Left", "Center", "Right"]) { + expect(button(name)).toBeEnabled(); + } + expect(sliderFor("Distance from bottom")).toBeEnabled(); }); - it("clicking Position left/right moves the horizontal slider to the true frame edge", () => { - const settings = show({}); - const range = captionOffsetRange(settings); - - fireEvent.click(preset("Position left")); - expect(Number(sliderFor("Horizontal position").value)).toBeCloseTo(range.x.min, 6); - expect(preset("Position left")).toHaveAttribute("aria-pressed", "true"); + it("writes the anchor and the inset straight through to the document", () => { + show({}); + fireEvent.click(button("Top")); + fireEvent.change(sliderFor("Distance from top"), { target: { value: "18.5" } }); - fireEvent.click(preset("Position right")); - expect(Number(sliderFor("Horizontal position").value)).toBeCloseTo(range.x.max, 6); - expect(preset("Position right")).toHaveAttribute("aria-pressed", "true"); - expect(preset("Position left")).toHaveAttribute("aria-pressed", "false"); + const stored = useProjectStore.getState().document as AxcutDocument; + expect(getCaptionSettings(stored)).toMatchObject({ anchorV: "top", insetY: 18.5 }); }); - it("dragging the horizontal slider clears the horizontal preset row", () => { + it("no longer offers the controls the redesign removed", () => { + // Band width drew nothing until the text happened to wrap; the separate text + // alignment fought the horizontal position for the same outcome. show({}); - fireEvent.change(sliderFor("Horizontal position"), { target: { value: "3" } }); - - expect(preset("Position center")).toHaveAttribute("aria-pressed", "false"); - expect(preset("Position left")).toHaveAttribute("aria-pressed", "false"); - expect(preset("Position right")).toHaveAttribute("aria-pressed", "false"); + expect(screen.queryByText("Width")).not.toBeInTheDocument(); + expect(screen.queryByText("Text align")).not.toBeInTheDocument(); + expect(screen.queryByText("Vertical offset")).not.toBeInTheDocument(); + expect(screen.queryByText("Horizontal offset")).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Middle" })).not.toBeInTheDocument(); }); - it("disables the horizontal preset row exactly when the horizontal slider is disabled", () => { - show({ width: 100 }); - expect(preset("Position left")).toBeDisabled(); - cleanup(); - show({ width: DEFAULT_CAPTION_SETTINGS.width }); - expect(preset("Position left")).toBeEnabled(); + it("explains which way a long caption grows", () => { + show({ anchorV: "bottom" }); + expect(screen.getByText(/grow upward/i)).toBeInTheDocument(); + fireEvent.click(button("Top")); + expect(screen.getByText(/grow downward/i)).toBeInTheDocument(); }); +}); - it("gives the text-align row its own section label, separate from Position", () => { - show({}); - expect(screen.getByText("Text align")).toBeInTheDocument(); - // The words "Left"/"Center"/"Right" belong to text-align; "Position left" etc. - // belong to the new row — both must resolve without ambiguity. - expect(preset("Left")).toBeInTheDocument(); - expect(preset("Position left")).toBeInTheDocument(); +describe("migrating a pre-anchor project into the pane", () => { + it("opens an old document on the anchor that reproduces where it was drawn", () => { + // A default bottom caption from the old model: band at 75%, ink centred in it, + // drawn block ending at 92.67% — so a 7.33% inset from the bottom. + show({ verticalPosition: "bottom", offsetY: 0, width: 80, textAlign: "center" }); + expect(button("Bottom")).toHaveAttribute("aria-pressed", "true"); + // The migrated value is the real distance, not a value snapped to the slider's + // step — the step governs dragging, not what a document may already hold. + expect(Number(sliderFor("Distance from bottom").value)).toBeCloseTo(7.333, 2); }); }); diff --git a/src/components/ai-edition/CaptionsPane.tsx b/src/components/ai-edition/CaptionsPane.tsx index 6fc342a74..6d6a776c9 100644 --- a/src/components/ai-edition/CaptionsPane.tsx +++ b/src/components/ai-edition/CaptionsPane.tsx @@ -9,28 +9,13 @@ // translation is stored beside the transcript, keyed by segment id, and picking // "Original" goes straight back to the SSOT text. -import type { LucideIcon } from "lucide-react"; -import { - AlignHorizontalJustifyCenter, - AlignHorizontalJustifyEnd, - AlignHorizontalJustifyStart, - Captions as CaptionsIcon, - Languages, - Loader2, - Trash2, -} from "lucide-react"; +import { Captions as CaptionsIcon, Languages, Loader2, Trash2 } from "lucide-react"; import { useMemo, useState } from "react"; import { useScopedT } from "@/contexts/I18nContext"; -import type { - CaptionHorizontalPosition, - CaptionTextAlign, - CaptionVerticalPosition, -} from "@/lib/ai-edition/captions"; +import type { CaptionAnchorH, CaptionAnchorV } from "@/lib/ai-edition/captions"; import { - activeHorizontalPositionPreset, - activeVerticalPositionPreset, - captionHorizontalPositionOffset, - captionOffsetRange, + CAPTION_INSET_X_MAX, + CAPTION_INSET_Y_MAX, untranslatedUnits, } from "@/lib/ai-edition/captions"; import { useProjectStore } from "@/lib/ai-edition/store/projectStore"; @@ -44,16 +29,6 @@ import { ColorField } from "./ColorField"; import styles from "./NewEditorShell.module.css"; import { SliderCell, Toggle } from "./RightPanes"; -/** A hundred stops across whatever span the offset currently has. - * - * The bounds are geometry, so they are rarely round numbers. A fixed `step` of 1 - * would leave `max` unreachable whenever the span isn't a whole number of steps — - * the caption would stop just short of the frame edge, which is the very thing - * #396 is about. Deriving the step from the span puts both ends exactly on a stop. */ -function sliderStep(range: { min: number; max: number }): number { - return Math.max((range.max - range.min) / 100, Number.EPSILON); -} - /** The families `src/index.css` already loads for on-canvas text — anything else * would render in the preview but fall back to a default in the export canvas. */ const CAPTION_FONTS = [ @@ -142,11 +117,6 @@ export function CaptionsPane() { const disabled = !hasDocument; const languageOptions = useMemo(() => Object.values(translations), [translations]); - // The reach depends on the anchor, the width and the font size, so it moves as the - // user works. Taking the sliders' bounds from the same function the geometry clamps - // with is what keeps every position on them a position the band can actually take. - const offsetRange = useMemo(() => captionOffsetRange(settings), [settings]); - const handleTranslate = async () => { const doc = useProjectStore.getState().document; if (!doc) return; @@ -478,98 +448,84 @@ export function CaptionsPane() { ) : null} {/* ── Placement ──────────────────────────────────────────── */} + {/* One control per axis, each naming the edge it measures from. The old pane + had four that overlapped: a band width nothing drew, an offset measured + against that invisible band, and a text alignment fighting the offset for + the same visual outcome. */}
{t("captions.position")}
- - value={activeVerticalPositionPreset(settings)} + + value={settings.anchorV} disabled={disabled} options={[ - { value: "top", label: t("captions.positionTop") }, - { value: "middle", label: t("captions.positionMiddle") }, - { value: "bottom", label: t("captions.positionBottom") }, + { value: "bottom", label: t("captions.anchorBottom") }, + { value: "top", label: t("captions.anchorTop") }, ]} - // A preset button is a shortcut to a clean position, not a nudge on top - // of one — resetting the offset is what makes clicking it feel like - // "go here" instead of "go here, plus whatever was left over". - onChange={(verticalPosition) => void set({ verticalPosition, offsetY: 0 })} - /> - - value={activeHorizontalPositionPreset(settings)} - // Mirrors the horizontal slider's own disabled condition just below: a - // full-width band has nowhere left or right to go. - disabled={disabled || offsetRange.x.max <= offsetRange.x.min} - options={[ - { - value: "left", - label: t("captions.positionLeft"), - icon: AlignHorizontalJustifyStart, - }, - { - value: "center", - label: t("captions.positionCenter"), - icon: AlignHorizontalJustifyCenter, - }, - { - value: "right", - label: t("captions.positionRight"), - icon: AlignHorizontalJustifyEnd, - }, - ]} - onChange={(preset) => - void set({ offsetX: captionHorizontalPositionOffset(settings, preset) }) - } + // No offset to reset: the inset means the same thing on both anchors, so + // flipping mirrors the caption to the same distance from the opposite edge. + onChange={(anchorV) => void set({ anchorV })} /> +

+ {settings.anchorV === "bottom" + ? t("captions.anchorHintBottom") + : t("captions.anchorHintTop")} +

setLive({ offsetY: v })} - onCommit={() => void commit()} - /> - setLive({ offsetX: v })} - onCommit={() => void commit()} - /> - setLive({ width: v })} + onChange={(v) => setLive({ insetY: v })} onCommit={() => void commit()} />
- {/* ── Text align (inside the band — a different axis from Position) ── */} -
{t("captions.textAlign")}
- - value={settings.textAlign} + + value={settings.anchorH} disabled={disabled} options={[ { value: "left", label: t("captions.alignLeft") }, { value: "center", label: t("captions.alignCenter") }, { value: "right", label: t("captions.alignRight") }, ]} - onChange={(textAlign) => void set({ textAlign })} + onChange={(anchorH) => void set({ anchorH })} /> + {/* Centre has no edge to measure from, so the control is ABSENT rather than + disabled — a dead slider reads as a bug. */} + {settings.anchorH === "center" ? null : ( +
+ setLive({ insetX: v })} + onCommit={() => void commit()} + /> +
+ )} {/* ── Line length ────────────────────────────────────────── */}
{t("captions.lineLength")}
@@ -636,39 +592,25 @@ function Segmented({ disabled, onChange, }: { - /** `null` means no option is currently active — e.g. a free-dragged slider - * has moved off every preset this row offers. */ - value: T | null; - options: ReadonlyArray<{ - value: T; - label: string; - /** Renders in place of the text label when given (with `label` still used - * as the accessible name and hover title) — for a row that would otherwise - * repeat another row's words for a different axis of meaning. */ - icon?: LucideIcon; - }>; + value: T; + options: ReadonlyArray<{ value: T; label: string }>; disabled?: boolean; onChange: (next: T) => void; }) { return (
- {options.map((option) => { - const Icon = option.icon; - return ( - - ); - })} + {options.map((option) => ( + + ))}
); } diff --git a/src/i18n/locales/ar/settings.json b/src/i18n/locales/ar/settings.json index c19b80bce..2e025e9bd 100644 --- a/src/i18n/locales/ar/settings.json +++ b/src/i18n/locales/ar/settings.json @@ -299,19 +299,17 @@ "backgroundColor": "لون الخلفية", "backgroundOpacity": "العتامة", "position": "الموضع", - "positionTop": "أعلى", - "positionMiddle": "الوسط", - "positionBottom": "أسفل", - "positionLeft": "الموضع الأيسر", - "positionCenter": "الموضع الأوسط", - "positionRight": "الموضع الأيمن", - "textAlign": "محاذاة النص", + "anchorBottom": "أسفل", + "anchorTop": "أعلى", + "anchorHintBottom": "الترجمات الطويلة تمتد إلى أعلى — الحافة السفلية لا تتحرك.", + "anchorHintTop": "الترجمات الطويلة تمتد إلى أسفل — الحافة العلوية لا تتحرك.", + "distanceFromBottom": "المسافة من الأسفل", + "distanceFromTop": "المسافة من الأعلى", + "distanceFromLeft": "المسافة من اليسار", + "distanceFromRight": "المسافة من اليمين", "alignLeft": "يسار", "alignCenter": "توسيط", "alignRight": "يمين", - "verticalOffset": "الموضع الرأسي", - "horizontalOffset": "الموضع الأفقي", - "width": "العرض", "lineLength": "طول السطر", "minWords": "أقل عدد كلمات في السطر", "maxWords": "أكثر عدد كلمات في السطر" diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json index 25430a23e..f6149858d 100644 --- a/src/i18n/locales/en/settings.json +++ b/src/i18n/locales/en/settings.json @@ -305,19 +305,17 @@ "backgroundColor": "Background color", "backgroundOpacity": "Opacity", "position": "Position", - "positionTop": "Top", - "positionMiddle": "Middle", - "positionBottom": "Bottom", - "positionLeft": "Position left", - "positionCenter": "Position center", - "positionRight": "Position right", - "textAlign": "Text align", + "anchorBottom": "Bottom", + "anchorTop": "Top", + "anchorHintBottom": "Long captions grow upward — the bottom edge stays put.", + "anchorHintTop": "Long captions grow downward — the top edge stays put.", + "distanceFromBottom": "Distance from bottom", + "distanceFromTop": "Distance from top", + "distanceFromLeft": "Distance from left", + "distanceFromRight": "Distance from right", "alignLeft": "Left", "alignCenter": "Center", "alignRight": "Right", - "verticalOffset": "Vertical position", - "horizontalOffset": "Horizontal position", - "width": "Width", "lineLength": "Line length", "minWords": "Min words per line", "maxWords": "Max words per line" diff --git a/src/i18n/locales/es/settings.json b/src/i18n/locales/es/settings.json index 93ac340d9..4628154f6 100644 --- a/src/i18n/locales/es/settings.json +++ b/src/i18n/locales/es/settings.json @@ -299,19 +299,17 @@ "backgroundColor": "Color del fondo", "backgroundOpacity": "Opacidad", "position": "Posición", - "positionTop": "Arriba", - "positionMiddle": "Centro", - "positionBottom": "Abajo", - "positionLeft": "Posición izquierda", - "positionCenter": "Posición central", - "positionRight": "Posición derecha", - "textAlign": "Alineación del texto", + "anchorBottom": "Abajo", + "anchorTop": "Arriba", + "anchorHintBottom": "Los subtítulos largos crecen hacia arriba: el borde inferior no se mueve.", + "anchorHintTop": "Los subtítulos largos crecen hacia abajo: el borde superior no se mueve.", + "distanceFromBottom": "Distancia desde abajo", + "distanceFromTop": "Distancia desde arriba", + "distanceFromLeft": "Distancia desde la izquierda", + "distanceFromRight": "Distancia desde la derecha", "alignLeft": "Izquierda", "alignCenter": "Centro", "alignRight": "Derecha", - "verticalOffset": "Posición vertical", - "horizontalOffset": "Posición horizontal", - "width": "Ancho", "lineLength": "Longitud de línea", "minWords": "Mín. palabras por línea", "maxWords": "Máx. palabras por línea" diff --git a/src/i18n/locales/fr/settings.json b/src/i18n/locales/fr/settings.json index 6a8ed73d3..e9521d9d2 100644 --- a/src/i18n/locales/fr/settings.json +++ b/src/i18n/locales/fr/settings.json @@ -299,19 +299,17 @@ "backgroundColor": "Couleur du fond", "backgroundOpacity": "Opacité", "position": "Position", - "positionTop": "Haut", - "positionMiddle": "Milieu", - "positionBottom": "Bas", - "positionLeft": "Position à gauche", - "positionCenter": "Position au centre", - "positionRight": "Position à droite", - "textAlign": "Alignement du texte", + "anchorBottom": "Bas", + "anchorTop": "Haut", + "anchorHintBottom": "Les sous-titres longs s'étendent vers le haut — le bord bas ne bouge pas.", + "anchorHintTop": "Les sous-titres longs s'étendent vers le bas — le bord haut ne bouge pas.", + "distanceFromBottom": "Distance depuis le bas", + "distanceFromTop": "Distance depuis le haut", + "distanceFromLeft": "Distance depuis la gauche", + "distanceFromRight": "Distance depuis la droite", "alignLeft": "Gauche", "alignCenter": "Centre", "alignRight": "Droite", - "verticalOffset": "Position verticale", - "horizontalOffset": "Position horizontale", - "width": "Largeur", "lineLength": "Longueur des lignes", "minWords": "Mots min. par ligne", "maxWords": "Mots max. par ligne" diff --git a/src/i18n/locales/it/settings.json b/src/i18n/locales/it/settings.json index 37a585b2d..ff13f7495 100644 --- a/src/i18n/locales/it/settings.json +++ b/src/i18n/locales/it/settings.json @@ -299,19 +299,17 @@ "backgroundColor": "Colore dello sfondo", "backgroundOpacity": "Opacità", "position": "Posizione", - "positionTop": "Alto", - "positionMiddle": "Centro", - "positionBottom": "Basso", - "positionLeft": "Posizione a sinistra", - "positionCenter": "Posizione centrale", - "positionRight": "Posizione a destra", - "textAlign": "Allineamento testo", + "anchorBottom": "Basso", + "anchorTop": "Alto", + "anchorHintBottom": "I sottotitoli lunghi crescono verso l'alto: il bordo inferiore non si sposta.", + "anchorHintTop": "I sottotitoli lunghi crescono verso il basso: il bordo superiore non si sposta.", + "distanceFromBottom": "Distanza dal basso", + "distanceFromTop": "Distanza dall'alto", + "distanceFromLeft": "Distanza da sinistra", + "distanceFromRight": "Distanza da destra", "alignLeft": "Sinistra", "alignCenter": "Centro", "alignRight": "Destra", - "verticalOffset": "Posizione verticale", - "horizontalOffset": "Posizione orizzontale", - "width": "Larghezza", "lineLength": "Lunghezza riga", "minWords": "Parole min. per riga", "maxWords": "Parole max. per riga" diff --git a/src/i18n/locales/ja-JP/settings.json b/src/i18n/locales/ja-JP/settings.json index 02f27575e..30a9d0875 100644 --- a/src/i18n/locales/ja-JP/settings.json +++ b/src/i18n/locales/ja-JP/settings.json @@ -299,19 +299,17 @@ "backgroundColor": "背景色", "backgroundOpacity": "不透明度", "position": "位置", - "positionTop": "上", - "positionMiddle": "中央", - "positionBottom": "下", - "positionLeft": "左配置", - "positionCenter": "中央配置", - "positionRight": "右配置", - "textAlign": "文字揃え", + "anchorBottom": "下", + "anchorTop": "上", + "anchorHintBottom": "長い字幕は上に伸びます(下端は動きません)。", + "anchorHintTop": "長い字幕は下に伸びます(上端は動きません)。", + "distanceFromBottom": "下端からの距離", + "distanceFromTop": "上端からの距離", + "distanceFromLeft": "左端からの距離", + "distanceFromRight": "右端からの距離", "alignLeft": "左", "alignCenter": "中央", "alignRight": "右", - "verticalOffset": "垂直位置", - "horizontalOffset": "水平位置", - "width": "幅", "lineLength": "行の長さ", "minWords": "1 行の最小単語数", "maxWords": "1 行の最大単語数" diff --git a/src/i18n/locales/ko-KR/settings.json b/src/i18n/locales/ko-KR/settings.json index 69e4b35e1..676fe6959 100644 --- a/src/i18n/locales/ko-KR/settings.json +++ b/src/i18n/locales/ko-KR/settings.json @@ -299,19 +299,17 @@ "backgroundColor": "배경 색", "backgroundOpacity": "불투명도", "position": "위치", - "positionTop": "위", - "positionMiddle": "가운데", - "positionBottom": "아래", - "positionLeft": "왼쪽 배치", - "positionCenter": "가운데 배치", - "positionRight": "오른쪽 배치", - "textAlign": "텍스트 정렬", + "anchorBottom": "아래", + "anchorTop": "위", + "anchorHintBottom": "긴 자막은 위로 늘어납니다 — 아래쪽 가장자리는 그대로입니다.", + "anchorHintTop": "긴 자막은 아래로 늘어납니다 — 위쪽 가장자리는 그대로입니다.", + "distanceFromBottom": "아래에서의 거리", + "distanceFromTop": "위에서의 거리", + "distanceFromLeft": "왼쪽에서의 거리", + "distanceFromRight": "오른쪽에서의 거리", "alignLeft": "왼쪽", "alignCenter": "가운데", "alignRight": "오른쪽", - "verticalOffset": "세로 위치", - "horizontalOffset": "가로 위치", - "width": "너비", "lineLength": "줄 길이", "minWords": "줄당 최소 단어 수", "maxWords": "줄당 최대 단어 수" diff --git a/src/i18n/locales/pt-BR/settings.json b/src/i18n/locales/pt-BR/settings.json index acbd178b5..b6d4a78c4 100644 --- a/src/i18n/locales/pt-BR/settings.json +++ b/src/i18n/locales/pt-BR/settings.json @@ -299,19 +299,17 @@ "backgroundColor": "Cor do fundo", "backgroundOpacity": "Opacidade", "position": "Posição", - "positionTop": "Topo", - "positionMiddle": "Meio", - "positionBottom": "Base", - "positionLeft": "Posição à esquerda", - "positionCenter": "Posição central", - "positionRight": "Posição à direita", - "textAlign": "Alinhamento do texto", + "anchorBottom": "Base", + "anchorTop": "Topo", + "anchorHintBottom": "Legendas longas crescem para cima — a borda inferior não se move.", + "anchorHintTop": "Legendas longas crescem para baixo — a borda superior não se move.", + "distanceFromBottom": "Distância da base", + "distanceFromTop": "Distância do topo", + "distanceFromLeft": "Distância da esquerda", + "distanceFromRight": "Distância da direita", "alignLeft": "Esquerda", "alignCenter": "Centro", "alignRight": "Direita", - "verticalOffset": "Posição vertical", - "horizontalOffset": "Posição horizontal", - "width": "Largura", "lineLength": "Comprimento da linha", "minWords": "Mín. de palavras por linha", "maxWords": "Máx. de palavras por linha" diff --git a/src/i18n/locales/ru/settings.json b/src/i18n/locales/ru/settings.json index b070f0b95..d9c06b71c 100644 --- a/src/i18n/locales/ru/settings.json +++ b/src/i18n/locales/ru/settings.json @@ -299,19 +299,17 @@ "backgroundColor": "Цвет фона", "backgroundOpacity": "Непрозрачность", "position": "Положение", - "positionTop": "Сверху", - "positionMiddle": "По центру", - "positionBottom": "Снизу", - "positionLeft": "Положение слева", - "positionCenter": "Положение по центру", - "positionRight": "Положение справа", - "textAlign": "Выравнивание текста", + "anchorBottom": "Снизу", + "anchorTop": "Сверху", + "anchorHintBottom": "Длинные субтитры растут вверх — нижний край остаётся на месте.", + "anchorHintTop": "Длинные субтитры растут вниз — верхний край остаётся на месте.", + "distanceFromBottom": "Отступ снизу", + "distanceFromTop": "Отступ сверху", + "distanceFromLeft": "Отступ слева", + "distanceFromRight": "Отступ справа", "alignLeft": "Слева", "alignCenter": "По центру", "alignRight": "Справа", - "verticalOffset": "Положение по вертикали", - "horizontalOffset": "Положение по горизонтали", - "width": "Ширина", "lineLength": "Длина строки", "minWords": "Мин. слов в строке", "maxWords": "Макс. слов в строке" diff --git a/src/i18n/locales/tr/settings.json b/src/i18n/locales/tr/settings.json index c5f1791e1..c593e4db7 100644 --- a/src/i18n/locales/tr/settings.json +++ b/src/i18n/locales/tr/settings.json @@ -299,19 +299,17 @@ "backgroundColor": "Arka plan rengi", "backgroundOpacity": "Saydamlık", "position": "Konum", - "positionTop": "Üst", - "positionMiddle": "Orta", - "positionBottom": "Alt", - "positionLeft": "Sol konum", - "positionCenter": "Orta konum", - "positionRight": "Sağ konum", - "textAlign": "Metin hizalama", + "anchorBottom": "Alt", + "anchorTop": "Üst", + "anchorHintBottom": "Uzun altyazılar yukarı doğru büyür — alt kenar yerinde kalır.", + "anchorHintTop": "Uzun altyazılar aşağı doğru büyür — üst kenar yerinde kalır.", + "distanceFromBottom": "Alttan uzaklık", + "distanceFromTop": "Üstten uzaklık", + "distanceFromLeft": "Soldan uzaklık", + "distanceFromRight": "Sağdan uzaklık", "alignLeft": "Sol", "alignCenter": "Orta", "alignRight": "Sağ", - "verticalOffset": "Dikey konum", - "horizontalOffset": "Yatay konum", - "width": "Genişlik", "lineLength": "Satır uzunluğu", "minWords": "Satır başına en az kelime", "maxWords": "Satır başına en çok kelime" diff --git a/src/i18n/locales/vi/settings.json b/src/i18n/locales/vi/settings.json index b87203235..176f13eb6 100644 --- a/src/i18n/locales/vi/settings.json +++ b/src/i18n/locales/vi/settings.json @@ -299,19 +299,17 @@ "backgroundColor": "Màu nền", "backgroundOpacity": "Độ mờ", "position": "Vị trí", - "positionTop": "Trên", - "positionMiddle": "Giữa", - "positionBottom": "Dưới", - "positionLeft": "Vị trí trái", - "positionCenter": "Vị trí giữa", - "positionRight": "Vị trí phải", - "textAlign": "Căn chỉnh văn bản", + "anchorBottom": "Dưới", + "anchorTop": "Trên", + "anchorHintBottom": "Phụ đề dài sẽ cao dần lên trên — cạnh dưới không đổi.", + "anchorHintTop": "Phụ đề dài sẽ dài dần xuống dưới — cạnh trên không đổi.", + "distanceFromBottom": "Khoảng cách từ dưới", + "distanceFromTop": "Khoảng cách từ trên", + "distanceFromLeft": "Khoảng cách từ trái", + "distanceFromRight": "Khoảng cách từ phải", "alignLeft": "Trái", "alignCenter": "Giữa", "alignRight": "Phải", - "verticalOffset": "Vị trí dọc", - "horizontalOffset": "Vị trí ngang", - "width": "Chiều rộng", "lineLength": "Độ dài dòng", "minWords": "Số từ tối thiểu mỗi dòng", "maxWords": "Số từ tối đa mỗi dòng" diff --git a/src/i18n/locales/zh-CN/settings.json b/src/i18n/locales/zh-CN/settings.json index 7ab2ceef9..19f231703 100644 --- a/src/i18n/locales/zh-CN/settings.json +++ b/src/i18n/locales/zh-CN/settings.json @@ -299,19 +299,17 @@ "backgroundColor": "背景颜色", "backgroundOpacity": "不透明度", "position": "位置", - "positionTop": "顶部", - "positionMiddle": "中间", - "positionBottom": "底部", - "positionLeft": "左侧位置", - "positionCenter": "居中位置", - "positionRight": "右侧位置", - "textAlign": "文字对齐", + "anchorBottom": "底部", + "anchorTop": "顶部", + "anchorHintBottom": "较长的字幕向上延伸——底边保持不动。", + "anchorHintTop": "较长的字幕向下延伸——顶边保持不动。", + "distanceFromBottom": "距底部", + "distanceFromTop": "距顶部", + "distanceFromLeft": "距左侧", + "distanceFromRight": "距右侧", "alignLeft": "左对齐", "alignCenter": "居中", "alignRight": "右对齐", - "verticalOffset": "垂直位置", - "horizontalOffset": "水平位置", - "width": "宽度", "lineLength": "行长", "minWords": "每行最少词数", "maxWords": "每行最多词数" diff --git a/src/i18n/locales/zh-TW/settings.json b/src/i18n/locales/zh-TW/settings.json index 147d71233..e2bc412e9 100644 --- a/src/i18n/locales/zh-TW/settings.json +++ b/src/i18n/locales/zh-TW/settings.json @@ -300,19 +300,17 @@ "backgroundColor": "背景顏色", "backgroundOpacity": "不透明度", "position": "位置", - "positionTop": "上", - "positionMiddle": "中", - "positionBottom": "下", - "positionLeft": "靠左位置", - "positionCenter": "置中位置", - "positionRight": "靠右位置", - "textAlign": "文字對齊", + "anchorBottom": "下", + "anchorTop": "上", + "anchorHintBottom": "較長的字幕會向上延伸——下緣維持不動。", + "anchorHintTop": "較長的字幕會向下延伸——上緣維持不動。", + "distanceFromBottom": "距下緣", + "distanceFromTop": "距上緣", + "distanceFromLeft": "距左緣", + "distanceFromRight": "距右緣", "alignLeft": "靠左", "alignCenter": "置中", "alignRight": "靠右", - "verticalOffset": "垂直位置", - "horizontalOffset": "水平位置", - "width": "寬度", "lineLength": "行長", "minWords": "每行最少字數", "maxWords": "每行最多字數" diff --git a/src/lib/ai-edition/captions/captions.test.ts b/src/lib/ai-edition/captions/captions.test.ts index d6529489e..9179a3df4 100644 --- a/src/lib/ai-edition/captions/captions.test.ts +++ b/src/lib/ai-edition/captions/captions.test.ts @@ -1,18 +1,12 @@ import { describe, expect, it } from "vitest"; import type { AxcutDocument, AxcutTranscript } from "../schema"; import { captionCuesToTextRegions, deriveCaptionCues } from "./cues"; -import type { CaptionSettings, CaptionSettingsPatch } from "./settings"; import { - activeHorizontalPositionPreset, - activeVerticalPositionPreset, - CAPTION_BAND_HEIGHT_PCT, - CAPTION_POSITION_PRESET_EPSILON, captionBackgroundCss, - captionBandRect, - captionHorizontalPositionOffset, - captionInkHeightPct, - captionOffsetRange, + captionBoxRect, + captionSafeColumn, DEFAULT_CAPTION_SETTINGS, + defaultCaptionInsetY, getCaptionSettings, patchCaptionSettings, } from "./settings"; @@ -98,6 +92,18 @@ function doc(overrides: Partial = {}): AxcutDocument { } const ON = { ...DEFAULT_CAPTION_SETTINGS, enabled: true }; +const LANDSCAPE = 16 / 9; +const PORTRAIT = 9 / 16; + +/** Where the DRAWN block's edges land, given the box and the edge it is pinned to. + * The renderers put the block flush against the anchored edge of the box, so this + * is the same arithmetic the rasterizers do — expressed once, here, so the tests + * assert the thing the viewer sees rather than the box it lives in. */ +function drawnEdges(settings: typeof ON, aspect: number, blockHeightPct: number) { + const box = captionBoxRect(settings, aspect); + const top = box.verticalAlign === "bottom" ? box.y + box.height - blockHeightPct : box.y; + return { top, bottom: top + blockHeightPct }; +} describe("caption settings", () => { it("defaults to hidden so an existing project doesn't sprout captions on upgrade", () => { @@ -105,8 +111,8 @@ describe("caption settings", () => { }); it("round-trips a patch through the legacyEditor envelope", () => { - const next = patchCaptionSettings(doc(), { enabled: true, fontSize: 44, offsetY: -10 }); - expect(getCaptionSettings(next)).toMatchObject({ enabled: true, fontSize: 44, offsetY: -10 }); + const next = patchCaptionSettings(doc(), { enabled: true, fontSize: 44, insetY: 12 }); + expect(getCaptionSettings(next)).toMatchObject({ enabled: true, fontSize: 44, insetY: 12 }); }); it("keeps an explicit null language instead of falling back to the default", () => { @@ -122,209 +128,161 @@ describe("caption settings", () => { expect(getCaptionSettings(next)).toMatchObject({ minWordsPerLine: 3, maxWordsPerLine: 9 }); }); - it("leaves an untouched project exactly where it was", () => { - // The frame/screen-rect reinterpretation is the only intended visual change. The - // anchor arithmetic must not move on top of it, or every existing project shifts. - expect(captionBandRect({ ...ON, verticalPosition: "top" })).toMatchObject({ x: 10, y: 3 }); - expect(captionBandRect({ ...ON, verticalPosition: "middle" })).toMatchObject({ x: 10, y: 39 }); - expect(captionBandRect({ ...ON, verticalPosition: "bottom" })).toMatchObject({ x: 10, y: 75 }); + it("folds the opacity into the background colour, and reports 'transparent' when off", () => { + expect( + captionBackgroundCss({ ...ON, backgroundColor: "#10b981", backgroundOpacity: 0.5 }), + ).toBe("rgba(16, 185, 129, 0.5)"); + expect(captionBackgroundCss({ ...ON, backgroundEnabled: false })).toBe("transparent"); }); +}); - it("pushes the ink onto the frame edge, and no further", () => { - // The band is a 22% box whose text the renderers centre, so the box has to hang off - // the frame for the glyphs to reach the edge — asserting the BOX stays inside is what - // used to stop the caption a half-band short (#396). What must stay inside is the ink. - for (const verticalPosition of ["top", "middle", "bottom"] as const) { - const settings = { ...ON, verticalPosition }; - const range = captionOffsetRange(settings); - const half = captionInkHeightPct(settings) / 2; - - const low = captionBandRect({ ...settings, offsetY: range.y.min }); - expect(low.y + low.height / 2 - half).toBeCloseTo(0, 6); - expect(low.y).toBeLessThan(0); - - const high = captionBandRect({ ...settings, offsetY: range.y.max }); - expect(high.y + high.height / 2 + half).toBeCloseTo(100, 6); - expect(high.y + high.height).toBeGreaterThan(100); +describe("caption anchoring", () => { + // THE invariant of the redesign, asserted as a property rather than as numbers. + // The old model placed a fixed 22% band and let each renderer centre the ink in it, + // so the drawn block's edges moved with the font size, the background, the wrap — + // which is why widening the band shifted the caption vertically and why the offset + // had to be a signed number clamped against an estimate. + it("pins the anchored edge for every font size, background state and inset", () => { + for (const anchorV of ["bottom", "top"] as const) { + for (const insetY of [0, 0.5, 5, 12.5, 33.3, 50]) { + for (const fontSize of [12, 48, 120, 200]) { + for (const backgroundEnabled of [true, false]) { + const settings = { ...ON, anchorV, insetY, fontSize, backgroundEnabled }; + const box = captionBoxRect(settings, LANDSCAPE); + const pinned = anchorV === "bottom" ? box.y + box.height : box.y; + expect(pinned).toBeCloseTo(anchorV === "bottom" ? 100 - insetY : insetY, 6); + // And the box itself never leaves the frame, so there is no overhang to + // compensate for and no negative `y` for the schema to reject. + expect(box.y).toBeGreaterThanOrEqual(-1e-9); + expect(box.y + box.height).toBeLessThanOrEqual(100 + 1e-9); + } + } + } } }); - it("clamps an offset the anchor cannot reach instead of drawing off-frame", () => { - const pushed = captionBandRect({ ...ON, verticalPosition: "bottom", offsetY: 100 }); - const capped = captionBandRect({ - ...ON, - verticalPosition: "bottom", - offsetY: captionOffsetRange(ON).y.max, - }); - expect(pushed).toEqual(capped); - }); - - it("lets the band reach the left and right frame edges, but never past them", () => { - const range = captionOffsetRange(ON); - expect(captionBandRect({ ...ON, offsetX: range.x.min }).x).toBeCloseTo(0, 6); - expect(captionBandRect({ ...ON, offsetX: range.x.max }).x).toBeCloseTo(100 - ON.width, 6); - expect(captionBandRect({ ...ON, offsetX: 100 }).x).toBeCloseTo(100 - ON.width, 6); + it("holds the anchored edge still while the block grows — the complaint this fixes", () => { + // One line vs three, same settings: the anchored edge must not move. Under the + // old geometry the block was centred, so BOTH edges moved and the subtitle + // visibly drifted whenever its text wrapped. + const bottom = { ...ON, anchorV: "bottom" as const, insetY: 5 }; + expect(drawnEdges(bottom, LANDSCAPE, 6).bottom).toBeCloseTo( + drawnEdges(bottom, LANDSCAPE, 18).bottom, + 6, + ); + const top = { ...ON, anchorV: "top" as const, insetY: 5 }; + expect(drawnEdges(top, LANDSCAPE, 6).top).toBeCloseTo(drawnEdges(top, LANDSCAPE, 18).top, 6); }); - it("gives a full-width band no horizontal travel to offer", () => { - const full = { ...ON, width: 100 }; - const range = captionOffsetRange(full); - expect(range.x.min).toBeCloseTo(0, 6); - expect(range.x.max).toBeCloseTo(0, 6); - expect(captionBandRect({ ...full, offsetX: 40 }).x).toBeCloseTo(0, 6); + it("tells the compositor which edge to pin", () => { + expect(captionBoxRect({ ...ON, anchorV: "bottom" }, LANDSCAPE).verticalAlign).toBe("bottom"); + expect(captionBoxRect({ ...ON, anchorV: "top" }, LANDSCAPE).verticalAlign).toBe("top"); }); - it("shrinks the reach as the font grows, so big captions still fit", () => { - // The guaranteed-visible slice is font-derived: a 200px caption fills the whole band, - // leaving nothing to spill, while a small one can hang most of the band off-frame. - const small = captionOffsetRange({ ...ON, fontSize: 12 }).y.max; - const large = captionOffsetRange({ ...ON, fontSize: 200 }).y.max; - expect(small).toBeGreaterThan(large); - expect(large).toBeCloseTo(100 - CAPTION_BAND_HEIGHT_PCT - 75, 6); + it("keeps the vertical placement independent of everything on the other axis", () => { + // The old `width` slider moved the caption vertically, because a narrower band + // wrapped more, and more lines grew a centred block in both directions. + const base = { ...ON, anchorV: "bottom" as const, insetY: 8 }; + const pinned = (s: typeof base) => { + const b = captionBoxRect(s, LANDSCAPE); + return b.y + b.height; + }; + expect(pinned({ ...base, anchorH: "left", insetX: 0 })).toBeCloseTo(pinned(base), 6); + expect(pinned({ ...base, anchorH: "right", insetX: 25 })).toBeCloseTo(pinned(base), 6); }); +}); - it("re-clamps the offsets against the geometry a patch just created", () => { - // A patch can move the reachable span itself — `width`, `fontSize`, - // `backgroundEnabled` and `verticalPosition` all do. Clamping only on read - // would leave the stored number outside the span until someone read it, and - // the next patch would write that stale number straight back out. - const wide = patchCaptionSettings(doc(), { enabled: true, width: 20 }); - const pushed = patchCaptionSettings(wide, { - offsetX: captionOffsetRange(getCaptionSettings(wide)).x.max, - }); - const narrowed = patchCaptionSettings(pushed, { width: 100 }); +describe("caption horizontal anchoring", () => { + it("pins the named edge, and centres between the column when asked to", () => { + const column = captionSafeColumn(LANDSCAPE); - const stored = (narrowed.legacyEditor as { captions: CaptionSettings }).captions; - expect(stored.offsetX).toBeCloseTo(0, 6); - expect(stored.offsetX).toBeCloseTo(getCaptionSettings(narrowed).offsetX, 6); - }); + const left = captionBoxRect({ ...ON, anchorH: "left", insetX: 4 }, LANDSCAPE); + expect(left.x).toBeCloseTo(4, 6); - it("re-clamps when the anchor moves, not just when the width does", () => { - const low = patchCaptionSettings(doc(), { enabled: true, verticalPosition: "bottom" }); - const pushed = patchCaptionSettings(low, { - offsetY: captionOffsetRange(getCaptionSettings(low)).y.min, - }); - const flipped = patchCaptionSettings(pushed, { verticalPosition: "top" }); - - const stored = (flipped.legacyEditor as { captions: CaptionSettings }).captions; - const range = captionOffsetRange(getCaptionSettings(flipped)); - expect(stored.offsetY).toBeGreaterThanOrEqual(range.y.min - 1e-9); - expect(stored.offsetY).toBeLessThanOrEqual(range.y.max + 1e-9); - expect(stored.offsetY).toBeCloseTo(getCaptionSettings(flipped).offsetY, 6); - }); - - // `fontSize` and `backgroundEnabled` reach the range the long way round, through - // the height of the drawn block: the taller the ink, the less of the band is - // empty, and the empty part is all the band is allowed to hang off the frame. - it.each([ - { field: "fontSize", grow: { fontSize: 200 } as CaptionSettingsPatch }, - { field: "backgroundEnabled", grow: { backgroundEnabled: true } as CaptionSettingsPatch }, - ] as const)("re-clamps when $field narrows the reach", ({ grow }) => { - // Start where the reach is widest, so growing the ink has something to take. - const roomy = patchCaptionSettings(doc(), { - enabled: true, - verticalPosition: "bottom", - fontSize: 12, - backgroundEnabled: false, - }); - const pushed = patchCaptionSettings(roomy, { - offsetY: captionOffsetRange(getCaptionSettings(roomy)).y.max, - }); - const grown = patchCaptionSettings(pushed, grow); + const right = captionBoxRect({ ...ON, anchorH: "right", insetX: 4 }, LANDSCAPE); + expect(right.x + right.width).toBeCloseTo(96, 6); - const range = captionOffsetRange(getCaptionSettings(grown)); - // The reach really did narrow — otherwise this proves nothing. - expect(range.y.max).toBeLessThan(captionOffsetRange(getCaptionSettings(pushed)).y.max); - const stored = (grown.legacyEditor as { captions: CaptionSettings }).captions; - expect(stored.offsetY).toBeLessThanOrEqual(range.y.max + 1e-9); - expect(stored.offsetY).toBeCloseTo(getCaptionSettings(grown).offsetY, 6); + const centre = captionBoxRect({ ...ON, anchorH: "center" }, LANDSCAPE); + expect(centre.x).toBeCloseTo((100 - column.width) / 2, 6); + expect(centre.width).toBeCloseTo(column.width, 6); }); - it("normalises an offset left over from another anchor on read", () => { - // The stored value, the slider position and the drawn band stay the same number. - const parked = patchCaptionSettings(doc(), { enabled: true, offsetY: 45 }); - const read = getCaptionSettings(parked); - expect(read.offsetY).toBeCloseTo(captionOffsetRange(read).y.max, 6); + it("ignores insetX entirely when centred — there is no edge to measure from", () => { + const a = captionBoxRect({ ...ON, anchorH: "center", insetX: 0 }, LANDSCAPE); + const b = captionBoxRect({ ...ON, anchorH: "center", insetX: 25 }, LANDSCAPE); + expect(a).toEqual(b); }); - it("folds the opacity into the background colour, and reports 'transparent' when off", () => { - expect( - captionBackgroundCss({ ...ON, backgroundColor: "#10b981", backgroundOpacity: 0.5 }), - ).toBe("rgba(16, 185, 129, 0.5)"); - expect(captionBackgroundCss({ ...ON, backgroundEnabled: false })).toBe("transparent"); + it("narrows the box rather than pushing it off-frame", () => { + // A 90%-wide portrait column pushed 25% in would otherwise end at 115%. + const box = captionBoxRect({ ...ON, anchorH: "left", insetX: 25 }, PORTRAIT); + expect(box.x + box.width).toBeLessThanOrEqual(100 + 1e-9); + expect(box.width).toBeCloseTo(75, 6); }); }); -describe("caption position presets", () => { - it("reads a vertical preset as active only while there's no nudge off it", () => { - for (const verticalPosition of ["top", "middle", "bottom"] as const) { - const settings = { ...ON, verticalPosition, offsetY: 0 }; - expect(activeVerticalPositionPreset(settings)).toBe(verticalPosition); - // Any nudge at all — even one too small to see — means the band is no - // longer exactly at the preset, so nothing should read as "active". - expect(activeVerticalPositionPreset({ ...settings, offsetY: 5 })).toBeNull(); +describe("caption safe column", () => { + it("follows the BBC line-length table, and stays inside title-safe", () => { + expect(captionSafeColumn(LANDSCAPE)).toEqual({ x: 16, width: 68 }); + expect(captionSafeColumn(PORTRAIT)).toEqual({ x: 5, width: 90 }); + for (const aspect of [LANDSCAPE, 1, PORTRAIT]) { + const c = captionSafeColumn(aspect); + expect(c.x + c.width).toBeCloseTo(100 - c.x, 6); } }); - it("reads left/center/right off offsetX, and null off the preset grid", () => { - expect(activeHorizontalPositionPreset(ON)).toBe("center"); - const range = captionOffsetRange(ON); - expect(activeHorizontalPositionPreset({ ...ON, offsetX: range.x.min })).toBe("left"); - expect(activeHorizontalPositionPreset({ ...ON, offsetX: range.x.max })).toBe("right"); - expect(activeHorizontalPositionPreset({ ...ON, offsetX: range.x.min / 2 })).toBeNull(); + it("defaults a vertical export well clear of the platform chrome", () => { + // 5% on a 9:16 export puts the caption under the Reels/TikTok profile row. + expect(defaultCaptionInsetY(LANDSCAPE)).toBe(5); + expect(defaultCaptionInsetY(PORTRAIT)).toBeGreaterThan(10); }); +}); + +describe("migrating a pre-anchor document", () => { + // The rule is reproduce the PIXELS, not the fields: the old band was a fixed 22% + // box with the ink centred in it, so where the caption was drawn is recoverable, + // and the nearer edge becomes the anchor. A migrated project must not visibly move. + const legacy = (captions: Record) => + getCaptionSettings( + doc({ legacyEditor: { captions: { enabled: true, ...captions } } } as Partial), + LANDSCAPE, + ); - it("collapses to center when the band is full-width, since left/right have nowhere to go", () => { - expect(activeHorizontalPositionPreset({ ...ON, width: 100, offsetX: 0 })).toBe("center"); + it("keeps a default bottom caption at the bottom", () => { + const s = legacy({ verticalPosition: "bottom", offsetY: 0 }); + expect(s.anchorV).toBe("bottom"); + // The old band sat at y=75 and its ink — 48px × (2 lines × 1.4em + 0.2em of + // plate) = 13.33% of frame height — was centred in the 22% box, so the drawn + // block ended at 92.67%. The migrated inset must reproduce exactly that edge. + expect(s.insetY).toBeCloseTo(7.333, 2); }); - it("still picks left/right over center when a near-full-width band squeezes them inside the epsilon", () => { - // At width this close to 100, range.x.min/max themselves fall inside - // CAPTION_POSITION_PRESET_EPSILON of 0 — checking "is this near center?" - // first would wrongly claim an offset that is exactly at the true edge. - const squeezed = { ...ON, width: 100 - 1e-7 }; - const range = captionOffsetRange(squeezed); - expect(Math.abs(range.x.min)).toBeLessThan(CAPTION_POSITION_PRESET_EPSILON); - expect(activeHorizontalPositionPreset({ ...squeezed, offsetX: range.x.min })).toBe("left"); - expect(activeHorizontalPositionPreset({ ...squeezed, offsetX: range.x.max })).toBe("right"); - expect(activeHorizontalPositionPreset({ ...squeezed, offsetX: 0 })).toBe("center"); + it("flips the anchor for a caption that had been dragged to the top", () => { + const s = legacy({ verticalPosition: "bottom", offsetY: -79.333 }); + expect(s.anchorV).toBe("top"); + expect(s.insetY).toBeCloseTo(0, 1); }); - it("sets offsetX to the true frame edge for left/right, matching the reachable range", () => { - const range = captionOffsetRange(ON); - expect(captionHorizontalPositionOffset(ON, "left")).toBeCloseTo(range.x.min, 6); - expect(captionHorizontalPositionOffset(ON, "center")).toBe(0); - expect(captionHorizontalPositionOffset(ON, "right")).toBeCloseTo(range.x.max, 6); + it("keeps a top caption at the top", () => { + const s = legacy({ verticalPosition: "top", offsetY: 0 }); + expect(s.anchorV).toBe("top"); }); - it("reaches the true left and right frame edges through the left/right presets", () => { - const left = captionBandRect({ ...ON, offsetX: captionHorizontalPositionOffset(ON, "left") }); - expect(left.x).toBeCloseTo(0, 6); - const right = captionBandRect({ - ...ON, - offsetX: captionHorizontalPositionOffset(ON, "right"), - }); - expect(right.x + right.width).toBeCloseTo(100, 6); + it("maps the old band+textAlign pair onto the single horizontal anchor", () => { + expect(legacy({ width: 80, offsetX: 0, textAlign: "center" }).anchorH).toBe("center"); + expect(legacy({ width: 40, offsetX: -30, textAlign: "left" }).anchorH).toBe("left"); + expect(legacy({ width: 40, offsetX: 30, textAlign: "right" }).anchorH).toBe("right"); }); - it("de-activates the horizontal preset when width moves the band, without touching offsetX", () => { - // The real asymmetry against the vertical axis: `range.x` moves with - // `width` (`captionAnchor.x` depends on it), so a preset that was flush - // can stop being flush purely because the band got narrower or wider. - // `offsetY === 0` has no such dependency, so a vertical preset never does - // this — it's intended, not a regression. - const atWidth80 = { - ...ON, - width: 80, - offsetX: captionOffsetRange({ ...ON, width: 80 }).x.min, - }; - expect(activeHorizontalPositionPreset(atWidth80)).toBe("left"); - expect(captionBandRect(atWidth80).x).toBeCloseTo(0, 6); + it("lets a stored anchor win, so the migration runs once and then stays out of the way", () => { + const s = legacy({ verticalPosition: "top", offsetY: 0, anchorV: "bottom", insetY: 9 }); + expect(s.anchorV).toBe("bottom"); + expect(s.insetY).toBe(9); + }); - const narrowed = { ...atWidth80, width: 50 }; - expect(activeHorizontalPositionPreset(narrowed)).toBeNull(); - expect(narrowed.offsetX).toBe(atWidth80.offsetX); - expect(captionBandRect(narrowed).x).toBeCloseTo(15, 6); + it("gives a document with no caption settings the aspect-appropriate default", () => { + expect(getCaptionSettings(doc(), LANDSCAPE).insetY).toBe(5); + expect(getCaptionSettings(doc(), PORTRAIT).insetY).toBe(12.5); }); }); @@ -432,7 +390,7 @@ describe("deriveCaptionCues", () => { describe("captionCuesToTextRegions", () => { it("emits plain text regions with no annotationSource marker", () => { - const regions = captionCuesToTextRegions(deriveCaptionCues(doc(), ON, {}), ON); + const regions = captionCuesToTextRegions(deriveCaptionCues(doc(), ON, {}), ON, LANDSCAPE); expect(regions.length).toBeGreaterThan(0); for (const region of regions) { expect(region.type).toBe("text"); @@ -446,17 +404,35 @@ describe("captionCuesToTextRegions", () => { ...ON, color: "#fde047", fontSize: 40, - textAlign: "left" as const, + anchorH: "left" as const, backgroundEnabled: false, }; - const [region] = captionCuesToTextRegions(deriveCaptionCues(doc(), settings, {}), settings); + const [region] = captionCuesToTextRegions( + deriveCaptionCues(doc(), settings, {}), + settings, + LANDSCAPE, + ); expect(region.style).toMatchObject({ color: "#fde047", fontSize: 40, + // One horizontal control: the anchor IS the text alignment the rasterizer gets. textAlign: "left", backgroundColor: "transparent", }); }); + + it("tells the compositor which edge to pin, on every region", () => { + // Without this the rasterizers centre the block in the box, which is the whole + // bug: the caption would drift vertically every time its text wrapped. + const settings = { ...ON, anchorV: "top" as const }; + const regions = captionCuesToTextRegions( + deriveCaptionCues(doc(), settings, {}), + settings, + LANDSCAPE, + ); + expect(regions.length).toBeGreaterThan(0); + for (const region of regions) expect(region.verticalAlign).toBe("top"); + }); }); describe("caption translations", () => { diff --git a/src/lib/ai-edition/captions/cues.ts b/src/lib/ai-edition/captions/cues.ts index 64133e641..c3287ebcb 100644 --- a/src/lib/ai-edition/captions/cues.ts +++ b/src/lib/ai-edition/captions/cues.ts @@ -23,7 +23,12 @@ import { } from "@/lib/captioning/annotationsFromCaptions"; import type { CaptionSegment } from "@/lib/captioning/transcribe"; import type { AxcutClip, AxcutDocument, AxcutTranscript } from "../schema"; -import { type CaptionSettings, captionBackgroundCss, captionBandRect } from "./settings"; +import { + type CaptionAnchorV, + type CaptionSettings, + captionBackgroundCss, + captionBoxRect, +} from "./settings"; import { type CaptionTranslations, captionTranslationUnits } from "./translations"; /** One on-screen caption line, in whichever time base the producer documented. */ @@ -241,7 +246,14 @@ export function captionCueAt(cues: CaptionCue[], timeMs: number): CaptionCue | n * overhangs the frame edge — through `annotationRegionSchema`, which bounds position * to 0..100. Captions are never stored, so they never meet that schema. */ -export type CaptionTextRegion = AnnotationRegion & { space: "frame" }; +export type CaptionTextRegion = AnnotationRegion & { + space: "frame"; + /** Which edge of the drawn block the compositor pins to the region's box. Carried + * here rather than on `AnnotationTextStyle` for the same reason as `space`: an + * annotation must keep rendering centred, and widening the shared style would put + * the key in every stored annotation's payload. */ + verticalAlign: CaptionAnchorV; +}; /** * Cues as text annotation regions, so the export renderer draws captions through @@ -254,10 +266,15 @@ export type CaptionTextRegion = AnnotationRegion & { space: "frame" }; export function captionCuesToTextRegions( cues: CaptionCue[], settings: CaptionSettings, + aspectValue: number, ): CaptionTextRegion[] { - const rect = captionBandRect(settings); + const rect = captionBoxRect(settings, aspectValue); return cues.map((cue, index) => ({ space: "frame" as const, + // The edge the compositor pins the drawn block to inside `size`. Without it the + // rasterizers centre the block — which is what made a caption drift vertically + // every time its text wrapped to another line. + verticalAlign: rect.verticalAlign, id: cue.id, startMs: cue.startMs, endMs: cue.endMs, @@ -273,7 +290,10 @@ export function captionCuesToTextRegions( fontWeight: settings.fontWeight, fontStyle: "normal" as const, textDecoration: "none" as const, - textAlign: settings.textAlign, + // One horizontal control, not two: `anchorH` picks which edge of the block is + // pinned to the column, and the rasterizers' plate maths already snaps the + // plate onto that edge (`text_linux.rs` `plate_x`, and its two mirrors). + textAlign: settings.anchorH, textAnimation: "none" as const, }, zIndex: CAPTION_Z_INDEX_BASE + index, diff --git a/src/lib/ai-edition/captions/index.ts b/src/lib/ai-edition/captions/index.ts index c238654bc..ea5a58413 100644 --- a/src/lib/ai-edition/captions/index.ts +++ b/src/lib/ai-edition/captions/index.ts @@ -8,26 +8,20 @@ export { sourceSpanToTimelineSpans, } from "./cues"; export type { - CaptionBandRect, - CaptionHorizontalPosition, - CaptionOffsetRange, + CaptionAnchorH, + CaptionAnchorV, + CaptionBoxRect, CaptionSettings, CaptionSettingsPatch, - CaptionTextAlign, - CaptionVerticalPosition, } from "./settings"; export { - activeHorizontalPositionPreset, - activeVerticalPositionPreset, - CAPTION_BAND_HEIGHT_PCT, - CAPTION_EDGE_MARGIN_PCT, - CAPTION_POSITION_PRESET_EPSILON, + CAPTION_INSET_X_MAX, + CAPTION_INSET_Y_MAX, captionBackgroundCss, - captionBandRect, - captionHorizontalPositionOffset, - captionInkHeightPct, - captionOffsetRange, + captionBoxRect, + captionSafeColumn, DEFAULT_CAPTION_SETTINGS, + defaultCaptionInsetY, getCaptionSettings, patchCaptionSettings, } from "./settings"; diff --git a/src/lib/ai-edition/captions/settings.ts b/src/lib/ai-edition/captions/settings.ts index 469c67aaf..35682c9d9 100644 --- a/src/lib/ai-edition/captions/settings.ts +++ b/src/lib/ai-edition/captions/settings.ts @@ -12,18 +12,36 @@ import { clamp } from "@/utils/math"; import type { AxcutDocument } from "../schema"; -/** Vertical anchor of the caption band inside the frame. */ -export type CaptionVerticalPosition = "top" | "middle" | "bottom"; - -/** Horizontal alignment of the text inside the (always centred) caption band. */ -export type CaptionTextAlign = "left" | "center" | "right"; +/** + * Which frame edge the caption block is pinned to. The block grows AWAY from it: + * a bottom-anchored caption extends upward as its text wraps, so the anchored + * edge never moves. + * + * This is the whole redesign in one field. The old model placed a fixed-height + * band and let each renderer centre the ink inside it — and a centred block moves + * BOTH its edges when it grows, which is why widening the band used to shift the + * caption vertically and why no setting could hold it against an edge. + * + * `tts:displayAlign` (TTML/IMSC), `\an2` vs `\an8` (ASS), `line:0`/`line:-1` + * (WebVTT) are the same idea; bottom-anchored growth is the default in all of + * them. There is deliberately no "middle": it is the old pathology given a name + * (XSL 1.1 defines `display-align: center` as keeping both edge distances equal), + * and a bottom anchor with a large `insetY` reaches the same place while still + * growing upward. + */ +export type CaptionAnchorV = "bottom" | "top"; -/** Horizontal position preset for the caption band itself — a different axis of - * meaning from `CaptionTextAlign`, which aligns the text *inside* the band. - * Not a stored field: it is derived from `offsetX` (see - * `activeHorizontalPositionPreset`) and set by writing `offsetX` directly (see - * `captionHorizontalPositionOffset`). */ -export type CaptionHorizontalPosition = "left" | "center" | "right"; +/** + * Which edge of the caption block is pinned horizontally — and, for a wrapped + * caption, the ragged edge. + * + * One property, as in ASS, where the `\an` digit is the only horizontal control + * the format has. It replaces BOTH the old `textAlign` (which aligned text inside + * an invisible band) and the old `offsetX` (which moved that band): two controls + * that fought over one visual outcome, neither of which could be understood + * without seeing the band. + */ +export type CaptionAnchorH = "left" | "center" | "right"; export interface CaptionSettings { /** Master show/hide for the whole caption layer (preview AND export). */ @@ -47,19 +65,21 @@ export interface CaptionSettings { backgroundColor: string; /** 0–1. */ backgroundOpacity: number; - verticalPosition: CaptionVerticalPosition; - textAlign: CaptionTextAlign; - /** Fine vertical nudge, in % of OUTPUT FRAME height, applied on top of the anchor. - * Positive moves down. The reachable span depends on the anchor — see - * `captionOffsetRange`, which the inspector uses for its slider bounds so that - * every position on the slider is a position the band can actually take. */ - offsetY: number; - /** Fine horizontal nudge, in % of OUTPUT FRAME width, applied on top of the - * (centred) anchor. Positive moves toward the right edge of the exported frame — - * this is frame geometry, so it is never mirrored by an RTL interface locale. */ - offsetX: number; - /** Caption band width, in % of frame width. */ - width: number; + anchorV: CaptionAnchorV; + /** Distance from the frame edge named by `anchorV` to the near edge of what is + * actually DRAWN — the plate when the background is on, the glyph block when it + * is off — in % of frame height. Always ≥ 0: it is a margin from a named edge, + * which is how every subtitle format states position (ASS `MarginV`, WebVTT + * `line`, TTML `tts:origin`) and why nothing here is ever a signed number. + * 0 puts the caption flush against the frame edge; 50 puts that edge on the + * frame's midline. */ + insetY: number; + anchorH: CaptionAnchorH; + /** The same idea on the horizontal axis (ASS `MarginL` / `MarginR`): distance + * from the frame edge named by `anchorH` to the near edge of the drawn block. + * Ignored when `anchorH` is `"center"` — a centred block has no edge to measure + * from, so the inspector hides the control rather than offering a dead one. */ + insetX: number; /** Lower bound on words shown at once. */ minWordsPerLine: number; /** Upper bound on words shown at once. */ @@ -76,184 +96,153 @@ export const DEFAULT_CAPTION_SETTINGS: CaptionSettings = { backgroundEnabled: true, backgroundColor: "#000000", backgroundOpacity: 0.55, - verticalPosition: "bottom", - textAlign: "center", - offsetY: 0, - offsetX: 0, - width: 80, + anchorV: "bottom", + // Overridden per output aspect on first read (`defaultCaptionInsetY`); this is the + // landscape value, and the one a document keeps once anything has been written. + insetY: 5, + anchorH: "center", + insetX: 16, minWordsPerLine: 2, maxWordsPerLine: 7, }; -/** Band height as a % of frame height. Generous enough for two wrapped lines at - * the default size; the renderers clip to it, so it is deliberately not tight. */ -export const CAPTION_BAND_HEIGHT_PCT = 22; - -/** Margin between the band and the frame edge for the top/bottom anchors, in %. */ -export const CAPTION_EDGE_MARGIN_PCT = 3; - -/** Tolerance for "is this offset at a preset's clean value", in % of frame. - * Deliberately not `Number.EPSILON` (already used below as `sliderStep`'s - * divide-by-zero floor, and far too small to absorb real float noise) — - * matches the `toBeCloseTo(x, 6)` tolerance this file's own tests use. */ -export const CAPTION_POSITION_PRESET_EPSILON = 1e-6; - /** Reference frame height the px-valued settings are authored against, matching * `annotationScale.ts` — `fontSize` is "pixels at a 1080-high frame". */ const CAPTION_REFERENCE_FRAME_HEIGHT = 1080; -/** Line box as a multiple of the font size. Mirrors the rasterizers so the band - * maths and the drawn glyphs agree: `text_linux.rs` is `font_size * 1.4`, and the - * other two backends lay out through the same `text_plate` box model. */ -const CAPTION_LINE_HEIGHT_EM = 1.4; +/** Line box as a multiple of the font size, taken as an UPPER BOUND across the three + * rasterizers (cosmic-text is 1.4, DirectWrite and CoreText both lower). It only + * sizes the box's headroom, never the caption's position — see `captionBoxRect`. */ +const CAPTION_LINE_HEIGHT_EM = 1.5; /** Vertical padding the background plate adds above AND below the text block, * as a multiple of the font size — `text_plate.rs::PAD_Y_EM`. */ const CAPTION_PLATE_PAD_Y_EM = 0.1; -/** Lines the band is sized to hold. The band is a fixed 22% box and all three - * rasterizers centre the text inside it, so this is what decides how much of the - * box is guaranteed to carry ink — and therefore how far the box may hang off the - * frame before a caption would be clipped (see `captionOffsetRange`). */ -const CAPTION_BAND_CAPACITY_LINES = 2; - /** - * Height of the drawn caption block — the background plate when it is on, the text - * block alone when it is off — as a % of frame height, capped at the band it lives - * in. The band is deliberately taller than its content, so this is the slice of the - * band that actually carries pixels. + * Visual lines the box is sized to hold. + * + * HEADROOM, NOT A POSITION. Because the block is anchored to one edge of this box, + * getting the height wrong no longer moves the caption — it only changes how many + * lines can be drawn before the renderer clips. That is the entire point of the + * redesign: the old `CAPTION_BAND_CAPACITY_LINES = 2` *positioned* the ink (the band + * was fixed and the ink centred inside it), so being wrong about it moved the + * subtitle. + * + * Three is ample: `deriveCaptionCues` already groups the transcript into cues of + * `minWordsPerLine`..`maxWordsPerLine` words, so one cue is one logical line and only + * wraps when the column is narrow or the font large. At the default 48px this is 20% + * of frame height — slightly LESS than the 22% band it replaces, so the per-cue + * texture gets marginally smaller rather than larger. */ -export function captionInkHeightPct(settings: CaptionSettings): number { - const lines = CAPTION_BAND_CAPACITY_LINES * CAPTION_LINE_HEIGHT_EM; - const plate = settings.backgroundEnabled ? 2 * CAPTION_PLATE_PAD_Y_EM : 0; - const px = clamp(settings.fontSize, 12, 200) * (lines + plate); - return Math.min(CAPTION_BAND_HEIGHT_PCT, (px / CAPTION_REFERENCE_FRAME_HEIGHT) * 100); -} +const CAPTION_BOX_LINES = 3; + +/** Aspect ratio at or above which a frame counts as landscape for the safe column. */ +const CAPTION_LANDSCAPE_ASPECT = 1.5; /** - * How far the band may hang off the top/bottom of the frame, in % of frame height. + * The column captions are laid out in, as % of frame width. This IS the max-width — + * derived from the output aspect, never stored, never exposed as a control. + * + * It used to be a `width` slider, which was the most confusing control in the pane: + * the background plate hugs the TEXT, so moving it changed nothing visible until the + * text happened to be long enough to wrap. It only ever constrained wrapping, which + * is a question about how much text is on screen — and that is already answered, + * legibly, by `minWordsPerLine` / `maxWordsPerLine`. * - * This is the whole of the "the offset can't reach the edge" half of #396. The band - * is a 22%-tall box whose text every renderer centres, so a band stopped flush at the - * frame edge still leaves its glyphs half a band short of it. Letting the box spill by - * exactly its empty margin puts the ink on the edge while keeping every drawn pixel - * on-frame — and costs nothing in the rasterizers, which already clip to the box. + * The numbers are the BBC line-length table: 68% of a 16:9 frame (≈45 characters at + * 48px on 1080p, inside the Netflix 42 / BBC 37 band the editorial specs legislate), + * 90% for squarer and vertical frames. Centred, a 16:9 column runs 16%→84%, inside + * BBC's 12.5/87.5 title-safe box. */ -function captionBandOverhangPct(settings: CaptionSettings): number { - return Math.max(0, (CAPTION_BAND_HEIGHT_PCT - captionInkHeightPct(settings)) / 2); +export function captionSafeColumn(aspectValue: number): { x: number; width: number } { + return aspectValue >= CAPTION_LANDSCAPE_ASPECT ? { x: 16, width: 68 } : { x: 5, width: 90 }; } -/** The band's anchor position before the user's nudge, in % of the frame. */ -function captionAnchor(settings: CaptionSettings): { x: number; y: number } { - const width = clamp(settings.width, 20, 100); - const height = CAPTION_BAND_HEIGHT_PCT; - return { - x: (100 - width) / 2, - y: - settings.verticalPosition === "top" - ? CAPTION_EDGE_MARGIN_PCT - : settings.verticalPosition === "middle" - ? (100 - height) / 2 - : 100 - height - CAPTION_EDGE_MARGIN_PCT, - }; +/** Default distance from the anchored edge, in % of frame height, for a given output + * aspect. BBC puts the lowest line at a 5% inset; vertical formats need much more, + * because the bottom eighth of a 9:16 export is where TikTok, Reels and Shorts draw + * their own chrome over the video. */ +export function defaultCaptionInsetY(aspectValue: number): number { + return aspectValue >= CAPTION_LANDSCAPE_ASPECT ? 5 : 12.5; } -/** Inclusive min/max for each offset, in % of the frame. */ -export interface CaptionOffsetRange { - x: { min: number; max: number }; - y: { min: number; max: number }; -} +/** Upper bound for the vertical inset, in % of the frame. 50 puts the anchored edge + * on the midline — past that the anchor would point into the far half of the frame, + * which is what the opposite anchor is for. */ +export const CAPTION_INSET_Y_MAX = 50; -/** - * The offsets the current settings can actually honour. - * - * Both the reader's clamp and the inspector's sliders come from here, so the two can - * never disagree: every value the slider can produce moves the band, and no value it - * can produce is silently discarded. The old code hard-coded ±45 in both places and - * then clamped the *result*, which is why nearly half the bottom-anchored slider's - * travel did nothing at all. - */ -export function captionOffsetRange(settings: CaptionSettings): CaptionOffsetRange { - const width = clamp(settings.width, 20, 100); - const anchor = captionAnchor(settings); - const overhang = captionBandOverhangPct(settings); - return { - // Horizontally the band stays wholly on-frame: `textAlign` lets a line hug the - // band's own edge, so an overhang here would push text off the frame. - x: { min: -anchor.x, max: 100 - width - anchor.x }, - y: { - min: -overhang - anchor.y, - max: 100 - CAPTION_BAND_HEIGHT_PCT + overhang - anchor.y, - }, - }; -} +/** Upper bound for the horizontal inset. A finer adjustment on a narrower axis: the + * column is already inset from the frame edge, and pushing much past this eats the + * wrap width without moving the caption anywhere useful. */ +export const CAPTION_INSET_X_MAX = 25; -/** - * Which vertical preset, if any, the current settings match exactly. +/** Where the caption box sits, as percentages of the OUTPUT FRAME. * - * `verticalPosition` is always a real stored value, but a preset button should - * only read as "active" while the user hasn't nudged away from it — otherwise - * clicking a slider would leave a preset highlighted that no longer describes - * where the band actually is. `offsetY` is the nudge *from* the anchor, so - * "at the preset" is exactly "no nudge". - */ -export function activeVerticalPositionPreset( - settings: CaptionSettings, -): CaptionVerticalPosition | null { - return Math.abs(settings.offsetY) < CAPTION_POSITION_PRESET_EPSILON - ? settings.verticalPosition - : null; + * Not of the screen rect: captions are subtitles, so they belong to the frame the + * viewer sees and must hold still when padding resizes the footage underneath them. + * `cues.ts` stamps the regions it builds from this with `space: "frame"`, which is + * what tells the compositor to measure them against the frame. */ +export interface CaptionBoxRect { + x: number; + y: number; + width: number; + height: number; + /** Which edge of the drawn block the compositor pins to this box. Travels to the + * rasterizers as `verticalAlign`; absent there means "centre", which is the + * behaviour every annotation still gets. */ + verticalAlign: CaptionAnchorV; } /** - * Which horizontal position preset, if any, the current settings match exactly. + * The box a caption is laid out in, and the edge its drawn block is pinned to. + * + * The invariant this exists to guarantee, and the one the tests assert: + * + * > Bottom anchor: the drawn block's bottom edge is at `100 − insetY` % of frame + * > height. Top anchor: its top edge is at `insetY` %. For every font size, every + * > background state, every word count, every wrap outcome, every output resolution. * - * There is no stored `horizontalPosition` field — `offsetX` is already an - * absolute-feeling value centred on 0 with a range that reaches both frame - * edges (see `captionOffsetRange`), so "left"/"center"/"right" are just names - * for three points on that existing range. All three coincide at `offsetX===0` - * when the band is full-width (no travel) — and can also *nearly* coincide - * for a band merely close to full-width, where `range.x.min`/`max` shrink - * toward 0 as well. Picking the CLOSEST candidate (not the first one within - * epsilon) is what keeps that near-degenerate case from reporting "center" - * for an offset that is actually sitting exactly on `range.x.min`/`max`. + * Nothing in that sentence mentions line height, line count or the plate — and no + * estimate of the block's height participates in placing it. The height below is only + * how much room the block gets before it would be clipped, which is why being wrong + * about it is now cheap. */ -export function activeHorizontalPositionPreset( - settings: CaptionSettings, -): CaptionHorizontalPosition | null { - const range = captionOffsetRange(settings); - const { offsetX } = settings; - const candidates: ReadonlyArray<[CaptionHorizontalPosition, number]> = [ - ["center", 0], - ["left", range.x.min], - ["right", range.x.max], - ]; - let closest: CaptionHorizontalPosition | null = null; - let closestDistance = CAPTION_POSITION_PRESET_EPSILON; - for (const [preset, target] of candidates) { - const distance = Math.abs(offsetX - target); - if (distance < closestDistance) { - closest = preset; - closestDistance = distance; - } - } - return closest; -} +export function captionBoxRect(settings: CaptionSettings, aspectValue: number): CaptionBoxRect { + const column = captionSafeColumn(aspectValue); + const insetY = clamp(settings.insetY, 0, CAPTION_INSET_Y_MAX); + const insetX = clamp(settings.insetX, 0, CAPTION_INSET_X_MAX); + + // Room for `CAPTION_BOX_LINES` lines plus the plate's own padding, capped so the + // box itself never leaves the frame. + const lines = CAPTION_BOX_LINES * CAPTION_LINE_HEIGHT_EM; + const plate = settings.backgroundEnabled ? 2 * CAPTION_PLATE_PAD_Y_EM : 0; + const capacityPct = + ((clamp(settings.fontSize, 12, 200) * (lines + plate)) / CAPTION_REFERENCE_FRAME_HEIGHT) * 100; + const height = clamp(capacityPct, 10, 100 - insetY); + + // Left/right narrow the box rather than pushing it off-frame: the column is a MAX + // width, so moving the pinned edge inward simply leaves less room to wrap in — and + // that is visible in the guide overlay, unlike a range that silently shrinks. + const width = settings.anchorH === "center" ? column.width : Math.min(column.width, 100 - insetX); + const x = + settings.anchorH === "left" + ? insetX + : settings.anchorH === "right" + ? 100 - insetX - width + : (100 - width) / 2; -/** The `offsetX` that puts the band at a given horizontal preset, for a preset - * button's click handler to write. `left`/`right` reach the true frame edge — - * the same span `activeHorizontalPositionPreset` reads back against. */ -export function captionHorizontalPositionOffset( - settings: CaptionSettings, - preset: CaptionHorizontalPosition, -): number { - if (preset === "center") return 0; - const range = captionOffsetRange(settings); - return preset === "left" ? range.x.min : range.x.max; + return { + x, + y: settings.anchorV === "bottom" ? 100 - insetY - height : insetY, + width, + height, + verticalAlign: settings.anchorV, + }; } -const VERTICAL_POSITIONS: readonly CaptionVerticalPosition[] = ["top", "middle", "bottom"]; -const TEXT_ALIGNS: readonly CaptionTextAlign[] = ["left", "center", "right"]; +const ANCHORS_V: readonly CaptionAnchorV[] = ["bottom", "top"]; +const ANCHORS_H: readonly CaptionAnchorH[] = ["left", "center", "right"]; function isFiniteNumber(value: unknown): value is number { return typeof value === "number" && Number.isFinite(value); @@ -291,65 +280,175 @@ function storedCaptions(doc: AxcutDocument | null | undefined): Record, + fontSize: number, + backgroundEnabled: boolean, + fallbackInsetY: number, +): Pick | null { + const legacyVertical = raw.verticalPosition; + const hasLegacy = + typeof legacyVertical === "string" || + isFiniteNumber(raw.offsetY) || + isFiniteNumber(raw.offsetX) || + isFiniteNumber(raw.width); + if (!hasLegacy) return null; + + // The old band's anchor, before the user's nudge. + const width = clamp(isFiniteNumber(raw.width) ? raw.width : 80, 20, 100); + const anchorY = + legacyVertical === "top" + ? LEGACY_EDGE_MARGIN_PCT + : legacyVertical === "middle" + ? (100 - LEGACY_BAND_HEIGHT_PCT) / 2 + : 100 - LEGACY_BAND_HEIGHT_PCT - LEGACY_EDGE_MARGIN_PCT; + const anchorX = (100 - width) / 2; + + // The ink the old model actually drew: a block of this height, centred in the band. + const inkPx = + clamp(fontSize, 12, 200) * + (LEGACY_CAPACITY_LINES * LEGACY_LINE_HEIGHT_EM + (backgroundEnabled ? 0.2 : 0)); + const inkHeight = Math.min( + LEGACY_BAND_HEIGHT_PCT, + (inkPx / CAPTION_REFERENCE_FRAME_HEIGHT) * 100, + ); + const overhang = Math.max(0, (LEGACY_BAND_HEIGHT_PCT - inkHeight) / 2); + + const offsetY = clamp( + isFiniteNumber(raw.offsetY) ? raw.offsetY : 0, + -overhang - anchorY, + 100 - LEGACY_BAND_HEIGHT_PCT + overhang - anchorY, + ); + const bandY = anchorY + offsetY; + const inkTop = bandY + (LEGACY_BAND_HEIGHT_PCT - inkHeight) / 2; + const inkBottom = inkTop + inkHeight; + + // Pin the edge the caption was nearer to; ties go to the bottom, the default. + const distanceToTop = inkTop; + const distanceToBottom = 100 - inkBottom; + const anchorV: CaptionAnchorV = distanceToTop < distanceToBottom ? "top" : "bottom"; + const insetY = clamp( + anchorV === "top" ? distanceToTop : distanceToBottom, + 0, + CAPTION_INSET_Y_MAX, + ); + + // Horizontally the old model had two controls fighting: `offsetX` moved the band and + // `textAlign` moved the text inside it. What the viewer saw is the combination, so + // take the one anchor nearest to where the ink actually sat. + const offsetX = clamp( + isFiniteNumber(raw.offsetX) ? raw.offsetX : 0, + -anchorX, + 100 - width - anchorX, + ); + const bandX = anchorX + offsetX; + const legacyAlign = raw.textAlign; + const inkCentre = + legacyAlign === "left" ? bandX : legacyAlign === "right" ? bandX + width : bandX + width / 2; + const anchorH: CaptionAnchorH = + inkCentre < 100 / 3 ? "left" : inkCentre > (2 * 100) / 3 ? "right" : "center"; + + return { + anchorV, + insetY: Number.isFinite(insetY) ? insetY : fallbackInsetY, + anchorH, + insetX: 0, + }; +} + +/** + * Read the caption settings, migrating a pre-anchor document on the way through. + * + * `aspectValue` only decides the DEFAULT inset for a document that has never carried + * caption settings — a 5% inset is right for landscape and lands under the platform + * chrome on a 9:16 export. Once anything is written, the stored value wins. + */ +export function getCaptionSettings( + doc: AxcutDocument | null | undefined, + aspectValue = 16 / 9, +): CaptionSettings { const raw = storedCaptions(doc); const d = DEFAULT_CAPTION_SETTINGS; - if (!raw) return { ...d }; + const defaultInsetY = defaultCaptionInsetY(aspectValue); + if (!raw) return { ...d, insetY: defaultInsetY, insetX: captionSafeColumn(aspectValue).x }; const minWords = Math.round(readNumber(raw.minWordsPerLine, d.minWordsPerLine, 1, 12)); const maxWords = Math.round(readNumber(raw.maxWordsPerLine, d.maxWordsPerLine, 1, 12)); + const fontSize = readNumber(raw.fontSize, d.fontSize, 12, 200); + const backgroundEnabled = readBoolean(raw.backgroundEnabled, d.backgroundEnabled); + + // New fields win whenever they are present, so the migration runs once and is inert + // afterwards; it only speaks for a document that still carries the old ones. + const legacy = migrateLegacyPlacement(raw, fontSize, backgroundEnabled, defaultInsetY); + const placement = { + anchorV: readEnum(raw.anchorV, ANCHORS_V, legacy?.anchorV ?? d.anchorV), + insetY: readNumber(raw.insetY, legacy?.insetY ?? defaultInsetY, 0, CAPTION_INSET_Y_MAX), + anchorH: readEnum(raw.anchorH, ANCHORS_H, legacy?.anchorH ?? d.anchorH), + insetX: readNumber( + raw.insetX, + legacy?.insetX ?? captionSafeColumn(aspectValue).x, + 0, + CAPTION_INSET_X_MAX, + ), + }; - const settings: CaptionSettings = { + return { enabled: readBoolean(raw.enabled, d.enabled), // `null` is a meaningful value here ("show the original"), so an explicit // null must survive; only a missing/garbage entry falls back to the default. language: raw.language === null || typeof raw.language === "string" ? raw.language : d.language, - fontSize: readNumber(raw.fontSize, d.fontSize, 12, 200), + fontSize, fontFamily: readString(raw.fontFamily, d.fontFamily), fontWeight: readEnum(raw.fontWeight, ["normal", "bold"] as const, d.fontWeight), color: readString(raw.color, d.color), - backgroundEnabled: readBoolean(raw.backgroundEnabled, d.backgroundEnabled), + backgroundEnabled, backgroundColor: readString(raw.backgroundColor, d.backgroundColor), backgroundOpacity: readNumber(raw.backgroundOpacity, d.backgroundOpacity, 0, 1), - verticalPosition: readEnum(raw.verticalPosition, VERTICAL_POSITIONS, d.verticalPosition), - textAlign: readEnum(raw.textAlign, TEXT_ALIGNS, d.textAlign), - // Read wide here, then clamp to what the geometry allows below: the reachable - // span depends on the anchor, the width and the font size, which are only known - // once the rest of the object is built. - offsetY: readNumber(raw.offsetY, d.offsetY, -100, 100), - offsetX: readNumber(raw.offsetX, d.offsetX, -100, 100), - width: readNumber(raw.width, d.width, 20, 100), + ...placement, minWordsPerLine: Math.min(minWords, maxWords), maxWordsPerLine: Math.max(minWords, maxWords), }; - - // Normalising here rather than at the draw call keeps the stored value, the slider - // position and the drawn band the same number. A value that the current anchor - // cannot reach — a leftover from another anchor, or from the old ±45 domain — is - // pulled to the nearest reachable one instead of being clamped invisibly later. - const range = captionOffsetRange(settings); - settings.offsetY = clamp(settings.offsetY, range.y.min, range.y.max); - settings.offsetX = clamp(settings.offsetX, range.x.min, range.x.max); - return settings; } export type CaptionSettingsPatch = Partial; -/** Apply a patch and return the new document. Pure — no persistence. */ +/** + * Apply a patch and return the new document. Pure — no persistence. + * + * There is no re-clamping pass any more, and that absence is the point: an inset is a + * distance from a named edge, so it means the same thing whatever the font size, the + * background or the other axis does. The old offsets had to be re-clamped on every + * patch because their reachable span moved with four other fields — which is also why + * they could not be shown to a user as a plain number. + */ export function patchCaptionSettings( doc: AxcutDocument, patch: CaptionSettingsPatch, ): AxcutDocument { const next: CaptionSettings = { ...getCaptionSettings(doc), ...patch }; - // Re-clamp against the geometry the patch just produced, not the one it - // replaced: `width`, `fontSize`, `backgroundEnabled` and `verticalPosition` all - // move the reachable span, so a patch to any of them can strand an offset - // outside it. `getCaptionSettings` would pull it back on the next read, but - // until then the document would hold a number the band never draws — and the - // next patch would write that stale number back out. - const range = captionOffsetRange(next); - next.offsetY = clamp(next.offsetY, range.y.min, range.y.max); - next.offsetX = clamp(next.offsetX, range.x.min, range.x.max); + next.insetY = clamp(next.insetY, 0, CAPTION_INSET_Y_MAX); + next.insetX = clamp(next.insetX, 0, CAPTION_INSET_X_MAX); return { ...doc, legacyEditor: { @@ -359,39 +458,6 @@ export function patchCaptionSettings( }; } -/** Where the caption band sits, as percentages of the OUTPUT FRAME. - * - * Not of the screen rect: captions are subtitles, so they belong to the frame the - * viewer sees and must hold still when padding resizes the footage underneath them. - * `cues.ts` stamps the regions it builds from this with `space: "frame"`, which is - * what tells the compositor to measure them against the frame (see `CaptionTextRegion`). */ -export interface CaptionBandRect { - x: number; - y: number; - width: number; - height: number; -} - -/** - * Anchor preset plus the user's nudge, on both axes. - * - * `textAlign` aligns the text *inside* the band, which is how subtitles behave - * everywhere; `offsetX` moves the band itself, which is the only way to reach a - * corner. Offsets are clamped to `captionOffsetRange` — the same span the inspector - * hands its sliders, so nothing the user can dial in is quietly thrown away. - */ -export function captionBandRect(settings: CaptionSettings): CaptionBandRect { - const width = clamp(settings.width, 20, 100); - const anchor = captionAnchor(settings); - const range = captionOffsetRange(settings); - return { - x: anchor.x + clamp(settings.offsetX, range.x.min, range.x.max), - y: anchor.y + clamp(settings.offsetY, range.y.min, range.y.max), - width, - height: CAPTION_BAND_HEIGHT_PCT, - }; -} - /** `backgroundColor` + `backgroundOpacity` as one CSS/canvas colour, or * `"transparent"` when the plate is off. */ export function captionBackgroundCss(settings: CaptionSettings): string { diff --git a/src/lib/ai-edition/store/useCaptions.test.ts b/src/lib/ai-edition/store/useCaptions.test.ts index b0761c225..b4063d68c 100644 --- a/src/lib/ai-edition/store/useCaptions.test.ts +++ b/src/lib/ai-edition/store/useCaptions.test.ts @@ -109,7 +109,7 @@ describe("useCaptions drag snapshots", () => { it("does not record a snapshot of the project the user left", async () => { const { result, rerender } = renderHook(() => useCaptions()); - act(() => result.current.setLive({ width: 60 })); + act(() => result.current.setLive({ insetY: 20 })); act(() => { useProjectStore.setState({ projectId: "proj_b", document: docB }); @@ -135,7 +135,7 @@ describe("useCaptions drag snapshots", () => { it("does not hand a bare commit a base the edits since have already buried", async () => { const { result } = renderHook(() => useCaptions()); - act(() => result.current.setLive({ width: 60 })); + act(() => result.current.setLive({ insetY: 20 })); await act(async () => { await result.current.set({ fontSize: 30 }); diff --git a/src/native/sceneDescription.ts b/src/native/sceneDescription.ts index c9db97b35..38408631c 100644 --- a/src/native/sceneDescription.ts +++ b/src/native/sceneDescription.ts @@ -146,6 +146,11 @@ export interface SceneAnnotation { fontStyle: "normal" | "italic"; textDecoration: "none" | "underline"; textAlign: "left" | "center" | "right"; + /** Which edge of the drawn text block is pinned to the box. Omitted for + * annotations, which keep the historical centring — so their payload does not + * change shape at all. Captions send it because a centred block moves BOTH its + * edges as it grows, which made a subtitle drift every time its text wrapped. */ + verticalAlign?: "top" | "center" | "bottom"; animation: string | null; }; /** Present for `kind: "image"` — the authored `imageContent` (path or data URI). */ @@ -523,10 +528,19 @@ export function buildSceneDescription( // so the compositor measures it against the output frame while annotations stay on the screen // rect. Subtitles have to sit where the viewer's frame ends, not where the footage does, or // they slide inward the moment padding shrinks the screen rect (issue #396). - const captionSettings = getCaptionSettings(document); + // + // The output aspect is what decides the caption column and the default inset (a + // caption 5% off the bottom of a 16:9 export sits under the platform's own chrome + // on a 9:16 one). It comes off `pickOutputDims`, hoisted above the webcam block that + // used to own it so there is exactly ONE caller — preview and export cannot pick a + // different column from each other. + const outputDims = pickOutputDims(document, settings.aspectRatio); + const captionAspect = outputDims.height > 0 ? outputDims.width / outputDims.height : 16 / 9; + const captionSettings = getCaptionSettings(document, captionAspect); const captionRegions = captionCuesToTextRegions( deriveCaptionCues(document, captionSettings, getCaptionTranslations(document)), captionSettings, + captionAspect, ); const projectedAnnotations = projectRegionsToSource( [ @@ -567,7 +581,7 @@ export function buildSceneDescription( // fait sur la résolution de sortie (= taille du canvas rendu) avec les unités sources du // premier asset visible — la même convention que `pickOutputDims` + SCREEN_SOURCE_SIZE / // WEBCAM_SOURCE_SIZE dans PreviewCanvas — ce qui garde preview/export/natif alignés. - const outputDims = pickOutputDims(document, settings.aspectRatio); + // (`outputDims` est résolu plus haut, avec le bloc sous-titres qui en dépend aussi.) // ponytail: when the active camera has been probed (real webcam dims cached by // WebcamOverlay's loadedmetadata handler), use them so the box matches the actual // camera aspect. Without this the box defaults to a hardcoded 4:3 (960x720) and the @@ -805,6 +819,8 @@ export function buildSceneDescription( // Only captions carry a space; annotations must keep emitting the exact same keys // they always have, so the field is omitted rather than sent as null/undefined. const space = (region as { space?: "frame" }).space; + // Same treatment, same reason: only captions pin an edge. + const verticalAlign = (region as { verticalAlign?: "top" | "bottom" }).verticalAlign; const base = { id: region.id, startSec: region.startMs / 1000, @@ -839,6 +855,7 @@ export function buildSceneDescription( fontStyle: style.fontStyle, textDecoration: style.textDecoration, textAlign: style.textAlign, + ...(verticalAlign ? { verticalAlign } : {}), animation: style.textAnimation ?? null, }, }; From 3b3a70a5ac27f4db2cb5f1d6ec65d12e25b7793f Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Sat, 22 Aug 2026 15:47:10 +0200 Subject: [PATCH 3/9] feat(captions): draw the anchor line and the column while the pane is open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The placement controls are all edge-referenced now, but two of those edges — the column's — are derived from the output aspect rather than chosen, so "why does Left stop there?" had no answer on screen. The anchor line is the better reason: it IS the invariant the distance slider sets, and watching it hold still while a caption grows is the fastest way to understand the model. Mounted as a child of `.previewFrame`, never of `.screenStage`. The latter is the screen rect and shrinks with the padding slider, so a guide mounted there would reproduce #396 inside the guide meant to explain it. Inside `.previewFrame`, frame-percent to CSS-percent is the identity, so there is no letterbox arithmetic and no second geometry to keep in sync — the guide and the compositor read the same function. This does not revive the deleted DOM caption painter: that one drew the same TEXT through a second wrapping engine and the two disagreed on line breaks. A rule and two hairlines have no glyphs to disagree about, and a
has no route into buildSceneDescription, so it cannot reach an export. The precedent is AnnotationOverlay — the DOM painter went, the selection chrome stayed. Docs updated: the settings table, and the sections that described the fixed band, the overhang and the preset/epsilon machinery, none of which exists any more. --- .../ai-edition/CaptionGuideOverlay.test.tsx | 127 ++++++++++++++++++ .../ai-edition/CaptionGuideOverlay.tsx | 95 +++++++++++++ src/components/ai-edition/CaptionsPane.tsx | 12 +- src/components/ai-edition/PreviewCanvas.tsx | 6 + .../ai-edition/store/useCaptionGuideBus.ts | 20 +++ .../transcription-and-captions.md | 124 +++++++++++------ 6 files changed, 340 insertions(+), 44 deletions(-) create mode 100644 src/components/ai-edition/CaptionGuideOverlay.test.tsx create mode 100644 src/components/ai-edition/CaptionGuideOverlay.tsx create mode 100644 src/lib/ai-edition/store/useCaptionGuideBus.ts diff --git a/src/components/ai-edition/CaptionGuideOverlay.test.tsx b/src/components/ai-edition/CaptionGuideOverlay.test.tsx new file mode 100644 index 000000000..0030de548 --- /dev/null +++ b/src/components/ai-edition/CaptionGuideOverlay.test.tsx @@ -0,0 +1,127 @@ +// @vitest-environment jsdom +// The caption guide is DOM, and the last DOM caption layer was deleted for good +// reason (it painted the same text through a second wrapping engine and the two +// disagreed). What these tests pin is the boundary that makes this one safe: it +// draws no text, it only appears while the pane that explains it is open, and it +// tracks the same geometry function the compositor is handed. + +import "@testing-library/jest-dom"; +import { cleanup, render } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { captionBoxRect, getCaptionSettings } from "@/lib/ai-edition/captions"; +import type { AxcutAsset, AxcutDocument } from "@/lib/ai-edition/schema"; +import { useProjectStore } from "@/lib/ai-edition/store/projectStore"; +import { useCaptionGuideBus } from "@/lib/ai-edition/store/useCaptionGuideBus"; +import { CaptionGuideOverlay } from "./CaptionGuideOverlay"; + +vi.mock("@/native", () => ({ nativeBridgeClient: { aiEdition: {} } })); +vi.mock("@/native/client", () => ({ nativeBridgeClient: { aiEdition: {} } })); + +const ASSET: AxcutAsset = { + id: "asset_1", + kind: "video", + label: "recording.mp4", + originalPath: "/rec.mp4", + durationSec: 12, + cameraTrack: null, +}; + +function documentWith(captions: Record): AxcutDocument { + return { + schemaVersion: 7, + project: { + id: "proj_1", + title: "Test", + createdAt: "2026-06-25T10:00:00.000Z", + updatedAt: "2026-06-25T10:00:00.000Z", + primaryAssetId: ASSET.id, + }, + assets: [ASSET], + transcript: null, + transcripts: [], + timeline: { + clips: [ + { + id: "clip_1", + assetId: ASSET.id, + sourceStartSec: 0, + sourceEndSec: 12, + timelineStartSec: 0, + timelineEndSec: 12, + wordRefs: [], + origin: "user", + reason: "", + }, + ], + gaps: [], + trimRanges: [], + muteRanges: [], + speedRanges: [], + captionRanges: [], + }, + annotations: [], + zoomRanges: [], + legacyEditor: { captions }, + } as unknown as AxcutDocument; +} + +function show(captions: Record, open: boolean) { + const document = documentWith(captions); + useProjectStore.setState({ + projectId: document.project.id, + document, + status: "ready", + error: null, + dirty: false, + }); + useCaptionGuideBus.setState({ open }); + return { document, ...render() }; +} + +beforeEach(() => { + useProjectStore.getState().clear(); + useCaptionGuideBus.setState({ open: false }); +}); + +afterEach(() => { + cleanup(); +}); + +describe("caption guide overlay", () => { + it("draws nothing while the captions pane is closed", () => { + const { container } = show({ enabled: true }, false); + expect(container).toBeEmptyDOMElement(); + }); + + it("draws nothing when captions are off, even with the pane open", () => { + const { container } = show({ enabled: false }, true); + expect(container).toBeEmptyDOMElement(); + }); + + it("puts the anchor line exactly where the compositor pins the caption", () => { + const { container, document } = show({ enabled: true, anchorV: "bottom", insetY: 12 }, true); + const line = container.querySelector('div[style*="height: 2px"]'); + expect(line).toBeInTheDocument(); + // 100 − insetY: the same number `captionBoxRect` puts the box's bottom edge at, + // so the guide cannot drift from the thing it is drawing. + expect((line as HTMLElement).style.top).toBe("88%"); + + const box = captionBoxRect(getCaptionSettings(document, 16 / 9), 16 / 9); + expect(box.y + box.height).toBeCloseTo(88, 6); + }); + + it("follows the anchor to the top edge", () => { + const { container } = show({ enabled: true, anchorV: "top", insetY: 9 }, true); + const line = container.querySelector('div[style*="height: 2px"]'); + expect((line as HTMLElement).style.top).toBe("9%"); + }); + + it("carries no text — it is a guide, not a second caption painter", () => { + const { container } = show({ enabled: true }, true); + expect(container.textContent).toBe(""); + // And it is inert: it must never eat a click meant for the canvas beneath. + const root = container.firstElementChild as HTMLElement; + expect(root.style.pointerEvents).toBe("none"); + expect(root).toHaveAttribute("aria-hidden", "true"); + }); +}); diff --git a/src/components/ai-edition/CaptionGuideOverlay.tsx b/src/components/ai-edition/CaptionGuideOverlay.tsx new file mode 100644 index 000000000..a298b36dc --- /dev/null +++ b/src/components/ai-edition/CaptionGuideOverlay.tsx @@ -0,0 +1,95 @@ +// The caption guide: the anchor line the user is actually setting, plus the edges of +// the column captions wrap in. Shown only while the Captions pane is open. +// +// Why it exists: the pane's controls are all edge-referenced now, but two of those +// edges (the column's) are derived from the output aspect rather than chosen, so +// "why does Left stop there?" has no answer on screen without this. The anchor line +// is the stronger reason — it IS the invariant the distance slider sets, and watching +// it hold still while a caption grows is the fastest way to understand the model. +// +// This is NOT a second caption painter. `CaptionLayer.tsx` was deleted because it +// drew the same TEXT through a second wrapping engine and the two disagreed on line +// breaks; a rule and two hairlines have no glyphs to disagree about. The precedent is +// `AnnotationOverlay` — the DOM painter went, the selection chrome stayed. A
+// also has no route into `buildSceneDescription`, so it cannot reach an export. + +import { useMemo } from "react"; +import { captionBoxRect, getCaptionSettings } from "@/lib/ai-edition/captions"; +import { resolveAspectRatioValue } from "@/lib/ai-edition/document/outputFormat"; +import { useProjectStore } from "@/lib/ai-edition/store/projectStore"; +import { useCaptionGuideBus } from "@/lib/ai-edition/store/useCaptionGuideBus"; +import { useEditorSettings } from "@/lib/ai-edition/store/useEditorSettings"; + +/** + * Mounted as a direct child of `.previewFrame`, NOT of `.screenStage`. + * + * That is load-bearing: `.screenStage` is the screen rect, which shrinks as the + * padding slider grows, while captions are measured against the output frame + * (`space: "frame"`, issue #396). Drawing the guide on the screen rect would + * reproduce that exact bug inside the guide meant to explain it. Inside + * `.previewFrame`, frame-percent → CSS-percent is the identity, so there is no + * letterbox arithmetic here and no second geometry to keep in sync. + */ +export function CaptionGuideOverlay() { + const open = useCaptionGuideBus((s) => s.open); + const document = useProjectStore((s) => s.document); + const { settings: editorSettings } = useEditorSettings(); + + const guide = useMemo(() => { + if (!document) return null; + const aspect = resolveAspectRatioValue(document, editorSettings.aspectRatio); + const captions = getCaptionSettings(document, aspect); + if (!captions.enabled) return null; + const box = captionBoxRect(captions, aspect); + return { + // The edge the caption is pinned to — the one number the distance slider sets, + // and the one that must not move when the text wraps. + anchorPct: captions.anchorV === "bottom" ? 100 - captions.insetY : captions.insetY, + leftPct: box.x, + rightPct: box.x + box.width, + }; + }, [document, editorSettings.aspectRatio]); + + if (!open || !guide) return null; + + return ( +