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..34bac45e1 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,28 @@ 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. + // + // `anchor_pad` reserve la marge de la plaque DU COTE ANCRE. Sans elle, coller + // le bloc de texte au bord laisse la plaque poser toute sa marge du cote + // oppose et zero du cote ancre : le fond epouse alors le bas des lettres au + // pixel pres tout en respirant deux fois trop au-dessus. Ce qui doit toucher + // le bord de la boite est la PLAQUE, pas les glyphes — c'est elle que le + // viewer voit. Sans plaque, il n'y a rien a reserver. + let has_plate = spec.background[3] > 0.0; + let anchor_pad = if has_plate { pad_y } else { 0.0 }; + let slack_y = ((h as f32) - text_h).max(0.0); + let y_offset = match spec.valign.as_str() { + "top" | "start" => anchor_pad, + "bottom" | "end" => slack_y - anchor_pad, + _ => slack_y * 0.5, + } + .clamp(0.0, slack_y) + .round() as i32; // LA PLAQUE EPOUSE LE BLOC, PAS LA BOITE. Miroir de // `text_macos::block_layout` (en coordonnees descendantes ici, CoreText @@ -385,6 +416,7 @@ mod tests { italic: false, underline: false, align: align.to_owned(), + valign: "center".to_owned(), box_px: [400, 200], } } @@ -566,6 +598,97 @@ 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"; + + // On mesure la PLAQUE, pas le dernier pixel d'encre. La plaque epouse la boite + // de lignes posee par cosmic-text : c'est exactement ce que ce code epingle et + // ce que le compositeur dessine. Le bas de l'ENCRE, lui, depend des jambages du + // contenu — "Hx" n'en a aucun, "replier" en a — donc il descend plus bas a + // ancrage identique. Ancrer la boite de lignes plutot que l'encre est le + // comportement typographique attendu partout, et c'est la premiere version de + // ce test qui avait tort : elle comparait 184 a 199 et appelait ca une derive. + let plate_of = |valign: &str, content: &str| { + let mut s = spec(content, "center"); + s.valign = valign.to_owned(); + let atlas = raster.build_atlas(&s).expect("atlas"); + let [_, py, _, ph] = atlas.plate; + let rows = ink_rows(&atlas.pixels, w, 0, w); + assert!(!rows.is_empty(), "aucune encre pour {valign:?}"); + (py, py + ph, rows[0], *rows.last().unwrap()) + }; + + let (short_top_edge, short_bottom, _, _) = plate_of("bottom", "Hx"); + let (long_top_edge, long_bottom, _, _) = plate_of("bottom", long); + assert!( + (short_bottom - long_bottom).abs() < 1.0, + "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, _, _, _) = plate_of("top", "Hx"); + let (long_top, _, _, _) = plate_of("top", long); + assert!( + (short_top - long_top).abs() < 1.0, + "ancrage haut : l'arete haute a bouge de {short_top} a {long_top}" + ); + + // Le texte long doit vraiment se replier, sinon les deux assertions ci-dessus + // passeraient sur deux rendus identiques et ne prouveraient rien. + assert!( + (long_bottom - long_top_edge) > (short_bottom - short_top_edge) + 1.0, + "le texte « long » ne s'est pas replie : le test ne prouve rien" + ); + + // Les trois ancrages doivent poser la plaque a trois endroits differents, + // sinon `valign` n'est pas applique du tout. + let (top_y, _, _, _) = plate_of("top", "Hx"); + let (ctr_y, _, _, _) = plate_of("center", "Hx"); + let (bot_y, _, _, _) = plate_of("bottom", "Hx"); + assert!( + top_y < ctr_y && ctr_y < bot_y, + "les trois ancrages ne se distinguent pas : haut={top_y} centre={ctr_y} bas={bot_y}" + ); + + // Enfin, l'encre reste dans la plaque qui la porte, et la plaque dans la boite : + // c'est ce qui relie la boite mesuree ci-dessus a ce que le viewer voit. + // + // Tolerance `pad_y`, la meme que `the_plate_hugs_the_text_instead_of_filling_the_box` + // plus haut : l'encre est bornee par la boite de LIGNES, et un glyphe peut deborder + // legerement la sienne (jambages, accents) sans que rien ne soit casse. C'est + // exactement l'hypothese que la premiere version de ce test avait fausse. + for valign in ["top", "bottom"] { + let (py, pb, ink_top, ink_bottom) = plate_of(valign, long); + let (_, pad_y) = crate::text_plate::padding(40.0); + assert!( + (ink_top as f32) >= py - pad_y && (ink_bottom as f32) <= pb + pad_y, + "{valign} : l'encre ({ink_top}..{ink_bottom}) sort de la plaque ({py}..{pb})" + ); + assert!(pb <= (h as f32) + 0.01, "{valign} : la plaque sort de la boite"); + } + } + + #[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..5979ab14c 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,11 @@ fn block_layout( text_w: CGFloat, text_h: CGFloat, align: u8, + valign: &str, + // Une plaque de fond est-elle dessinée ? Elle décide de la marge à réserver du côté + // ancré — voir `anchor_pad` plus bas. (Commentaire ordinaire et pas `///` : rustc + // refuse un doc-comment sur un paramètre.) + has_plate: bool, font_px: CGFloat, ) -> (CGRect, CGRect) { let (pad_x, pad_y) = plate_padding(font_px); @@ -317,7 +331,24 @@ 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. + // `anchor_pad` réserve la marge de la plaque DU CÔTÉ ANCRÉ. Sans elle, coller le + // bloc de texte au bord laisse la plaque poser toute sa marge du côté opposé et + // zéro du côté ancré : le fond épouse le bas des lettres au pixel près tout en + // respirant deux fois trop au-dessus. Ce qui doit toucher le bord de la boîte est + // la PLAQUE, pas les glyphes. Sans plaque, il n'y a rien à réserver. + let anchor_pad = if has_plate { pad_y } else { 0.0 }; + let slack_y = (box_h - text_h).max(0.0); + let top = match valign { + "top" | "start" => anchor_pad, + "bottom" | "end" => slack_y - anchor_pad, + _ => slack_y * 0.5, + } + .clamp(0.0, slack_y); let frame_x = (box_w - avail_w) * 0.5; let frame = CGRect { origin: CGPoint { @@ -593,7 +624,16 @@ 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, + spec.background[3] > 0.0, + 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 +691,7 @@ mod tests { italic: false, underline: false, align: "center".into(), + valign: "center".into(), box_px: [256, 256], } } @@ -828,7 +869,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", true, 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 +884,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", true, 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, true, 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..e09f4cdc7 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,39 @@ 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, la plaque n'est dessinée QUE si le fond est opaque, et dans ce cas le +/// texte est dessiné à `pad_y` dans une boîte de mise en page rentrée de `2*pad_y` +/// (`anchor_pad` vaut alors `pad_y`). Son haut réel vaut donc `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()`, et la plaque +/// respire autant en dessous qu'au-dessus du texte. +/// +/// 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 +216,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 +233,22 @@ 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 à `anchor_pad`. Sans ça, un ancrage bas colle les glyphes + // au bord de la boîte : la plaque pose alors toute sa marge du côté opposé et zéro + // du côté ancré, et le `.min(h)` plus bas rogne net sa marge basse. Ce qui doit + // toucher le bord de la boîte est la PLAQUE, pas les glyphes. + // + // Conditionné à la présence d'une plaque, comme sur les deux autres backends : + // sans fond il n'y a pas de marge à réserver, et réserver quand même décalerait + // le texte de `pad_y` par rapport à Linux et macOS. Le centrage est rigoureusement + // inchangé dans les deux cas (l'inset et le décalage s'annulent), donc les + // annotations ne bougent pas d'un pixel. + let anchor_pad = if spec.background[3] > 0.0 { pad_y } else { 0.0 }; + let layout_h = ((h as f32) - anchor_pad * 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 +276,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 +294,7 @@ impl TextRasterizer { ); } rt.DrawTextLayout( - D2D_POINT_2F { x: pad_x, y: 0.0 }, + D2D_POINT_2F { x: pad_x, y: anchor_pad }, &layout, &brush, D2D1_DRAW_TEXT_OPTIONS_NONE, @@ -269,10 +324,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 +422,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"); 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..ce3d4a961 100644 --- a/src/lib/ai-edition/captions/captions.test.ts +++ b/src/lib/ai-edition/captions/captions.test.ts @@ -1,18 +1,14 @@ import { describe, expect, it } from "vitest"; import type { AxcutDocument, AxcutTranscript } from "../schema"; import { captionCuesToTextRegions, deriveCaptionCues } from "./cues"; -import type { CaptionSettings, CaptionSettingsPatch } from "./settings"; +import type { CaptionSettings } from "./settings"; import { - activeHorizontalPositionPreset, - activeVerticalPositionPreset, - CAPTION_BAND_HEIGHT_PCT, - CAPTION_POSITION_PRESET_EPSILON, captionBackgroundCss, - captionBandRect, - captionHorizontalPositionOffset, - captionInkHeightPct, - captionOffsetRange, + captionBoxRect, + captionSafeColumn, DEFAULT_CAPTION_SETTINGS, + defaultCaptionInsetX, + defaultCaptionInsetY, getCaptionSettings, patchCaptionSettings, } from "./settings"; @@ -98,6 +94,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 +113,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 +130,195 @@ 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("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("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("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("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("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("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); - }); +describe("caption horizontal anchoring", () => { + it("pins the named edge, and centres between the column when asked to", () => { + const column = captionSafeColumn(LANDSCAPE); - 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 }); + const left = captionBoxRect({ ...ON, anchorH: "left", insetX: 4 }, LANDSCAPE); + expect(left.x).toBeCloseTo(4, 6); + + const right = captionBoxRect({ ...ON, anchorH: "right", insetX: 4 }, LANDSCAPE); + expect(right.x + right.width).toBeCloseTo(96, 6); - const stored = (narrowed.legacyEditor as { captions: CaptionSettings }).captions; - expect(stored.offsetX).toBeCloseTo(0, 6); - expect(stored.offsetX).toBeCloseTo(getCaptionSettings(narrowed).offsetX, 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("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); + 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); + }); - 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); + 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); }); +}); - 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); +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("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("defaults a vertical export well clear of the platform chrome", () => { + // The landscape values are eyeballed against the editor's default padding; the + // portrait one answers a different question — TikTok/Reels/Shorts draw their own + // chrome over the bottom eighth of a 9:16 export, so the same 1.5% would put the + // caption behind a UI. + expect(defaultCaptionInsetY(LANDSCAPE)).toBe(1.5); + expect(defaultCaptionInsetX(LANDSCAPE)).toBe(10); + expect(defaultCaptionInsetY(PORTRAIT)).toBeGreaterThan(10); }); }); -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("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("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("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("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("keeps a top caption at the top", () => { + const s = legacy({ verticalPosition: "top", offsetY: 0 }); + expect(s.anchorV).toBe("top"); }); - 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("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("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("reproduces the horizontal distance too, instead of snapping to the frame edge", () => { + // The old band's own left edge, not 0: `width: 40` centres its anchor at 30, so + // an offset of −25 put the band at 5% — and that is where the caption must stay. + const left = legacy({ width: 40, offsetX: -25, textAlign: "left" }); + expect(left.anchorH).toBe("left"); + expect(left.insetX).toBeCloseTo(5, 6); + + // Mirrored: the band ends at 95%, so the distance from the right edge is 5%. + const right = legacy({ width: 40, offsetX: 25, textAlign: "right" }); + expect(right.anchorH).toBe("right"); + expect(right.insetX).toBeCloseTo(5, 6); }); - 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("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); }); - 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("gives a document with no caption settings the aspect-appropriate default", () => { + expect(getCaptionSettings(doc(), LANDSCAPE).insetY).toBe(1.5); + expect(getCaptionSettings(doc(), LANDSCAPE).insetX).toBe(10); + expect(getCaptionSettings(doc(), PORTRAIT).insetY).toBe(12.5); }); - 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("freezes the PORTRAIT defaults on the first write to a vertical project", () => { + // The first patch is what materialises the defaults into the document, so it + // has to know the aspect too. Patching without it stored the landscape inset + // into a 9:16 project and the stored value then won for good — putting the + // caption under the platform's own chrome, the exact failure the + // aspect-derived default exists to prevent. + const first = patchCaptionSettings(doc(), { enabled: true }, PORTRAIT); + const stored = (first.legacyEditor as { captions: CaptionSettings }).captions; + expect(stored.insetY).toBe(12.5); + expect(stored.insetX).toBe(defaultCaptionInsetX(PORTRAIT)); - const narrowed = { ...atWidth80, width: 50 }; - expect(activeHorizontalPositionPreset(narrowed)).toBeNull(); - expect(narrowed.offsetX).toBe(atWidth80.offsetX); - expect(captionBandRect(narrowed).x).toBeCloseTo(15, 6); + // And it stays: a later patch reads what is stored rather than re-deriving. + const later = patchCaptionSettings(first, { fontSize: 60 }, PORTRAIT); + expect(getCaptionSettings(later, PORTRAIT).insetY).toBe(12.5); }); }); @@ -432,7 +426,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 +440,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..f827d62c0 100644 --- a/src/lib/ai-edition/captions/index.ts +++ b/src/lib/ai-edition/captions/index.ts @@ -8,26 +8,21 @@ 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, + defaultCaptionInsetX, + 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..e50d2e9c1 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,169 @@ 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` / + // `defaultCaptionInsetX`); these are the landscape values, and the ones a document + // keeps once anything has been written. + insetY: 1.5, + anchorH: "center", + insetX: 10, 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. + * + * The landscape value is picked against the editor's DEFAULT PADDING rather than from + * a broadcast spec: the footage sits inset inside the frame, so a caption a hair off + * the frame edge lands where the eye expects it *relative to the picture*. It was + * chosen by looking at a 16:9 export. + * + * Vertical keeps a much larger inset, and for an unrelated reason: the bottom eighth + * of a 9:16 export is where TikTok, Reels and Shorts draw their own chrome over the + * video, so a caption sitting a hair off that edge is a caption behind a UI. Nobody + * has eyeballed this one — it is the conservative value, and the slider is right + * there if it proves wrong. */ +export function defaultCaptionInsetY(aspectValue: number): number { + return aspectValue >= CAPTION_LANDSCAPE_ASPECT ? 1.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 }; +/** Default distance for the horizontal anchor, in % of frame width. Same source as the + * landscape `insetY`: it matches the default padding, not the safe column's own margin, + * which is why it is stated here rather than borrowed from `captionSafeColumn`. */ +export function defaultCaptionInsetX(aspectValue: number): number { + return aspectValue >= CAPTION_LANDSCAPE_ASPECT ? 10 : 5; } -/** - * 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 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; -/** - * Which vertical preset, if any, the current settings match exactly. +/** 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; + +/** 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: * - * 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`. + * > 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. + * + * 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 +296,190 @@ 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"; + // And the distance from that edge to where the band actually sat — the same + // reproduce-the-pixels rule as the vertical half. Returning 0 here would have + // snapped every migrated left/right caption flush to the frame edge, which is a + // place the old band almost never was. + const insetX = clamp( + anchorH === "left" ? bandX : anchorH === "right" ? 100 - (bandX + width) : 0, + 0, + CAPTION_INSET_X_MAX, + ); + + return { + anchorV, + insetY: Number.isFinite(insetY) ? insetY : fallbackInsetY, + anchorH, + insetX, + }; +} + +/** + * 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: defaultCaptionInsetX(aspectValue) }; 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 ?? defaultCaptionInsetX(aspectValue), + 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, + aspectValue = 16 / 9, ): 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); + // The aspect matters on the FIRST write to a document that has never carried + // caption settings: that write is what materialises the defaults, and a portrait + // export wants a much larger inset than a landscape one. Reading without it here + // would freeze the landscape default into a 9:16 project — the exact failure the + // aspect-derived default exists to prevent. + const next: CaptionSettings = { ...getCaptionSettings(doc, aspectValue), ...patch }; + 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 +489,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/lib/ai-edition/store/useCaptions.ts b/src/lib/ai-edition/store/useCaptions.ts index 36b13c95e..5238fa150 100644 --- a/src/lib/ai-edition/store/useCaptions.ts +++ b/src/lib/ai-edition/store/useCaptions.ts @@ -17,8 +17,10 @@ import { putCaptionTranslation, removeCaptionTranslation, } from "../captions"; +import { resolveAspectRatioValue } from "../document/outputFormat"; import type { AxcutDocument } from "../schema"; import { useProjectStore } from "./projectStore"; +import { useEditorSettings } from "./useEditorSettings"; export interface UseCaptionsResult { settings: CaptionSettings; @@ -48,7 +50,23 @@ export function useCaptions(): UseCaptionsResult { const setDocument = useProjectStore((s) => s.setDocument); const saveDocument = useProjectStore((s) => s.saveDocument); - const settings = useMemo(() => getCaptionSettings(document), [document]); + // The output aspect decides the caption column and the DEFAULT insets, so it has + // to reach both the read and every write: the first write to a document that has + // never carried caption settings is what freezes those defaults in, and a 9:16 + // export wants a much larger inset than a 16:9 one. `resolveAspectRatioValue` is + // the same resolver the preview and the scene description use — not + // `getAspectRatioValue`, which answers 16/9 for the legacy "native" selection and + // would disagree with what the compositor is handed. + const { settings: editorSettings } = useEditorSettings(); + const aspectValue = useMemo( + () => resolveAspectRatioValue(document, editorSettings.aspectRatio), + [document, editorSettings.aspectRatio], + ); + + const settings = useMemo( + () => getCaptionSettings(document, aspectValue), + [document, aspectValue], + ); const translations = useMemo(() => getCaptionTranslations(document), [document]); const cues = useMemo( () => deriveCaptionCues(document, settings, translations), @@ -65,14 +83,14 @@ export function useCaptions(): UseCaptionsResult { async (patch: CaptionSettingsPatch) => { const doc = useProjectStore.getState().document; if (!doc) return; - const next = patchCaptionSettings(doc, patch); + const next = patchCaptionSettings(doc, patch, aspectValue); // The optimistic write is not the edit — the save is. Only the one that can // fail records, and it names `doc` as what Ctrl+Z returns to because by then // the store already holds `next`. setDocument(next, { history: false }); await saveDocument(next, { history: true, historyBase: doc }); }, - [setDocument, saveDocument], + [setDocument, saveDocument, aspectValue], ); // See `useEditorSettings.setLive`: one undo step per slider drag, not one per @@ -95,12 +113,12 @@ export function useCaptions(): UseCaptionsResult { (patch: CaptionSettingsPatch) => { const doc = useProjectStore.getState().document; if (!doc) return; - const next = patchCaptionSettings(doc, patch); + const next = patchCaptionSettings(doc, patch, aspectValue); if (liveDocRef.current !== doc) liveBaseRef.current = doc; setDocument(next, { history: false }); liveDocRef.current = next; }, - [setDocument], + [setDocument, aspectValue], ); const commit = useCallback(async () => { @@ -133,13 +151,13 @@ export function useCaptions(): UseCaptionsResult { // language currently on screen is the one being deleted. const cleared = removeCaptionTranslation(doc, language); const next = - getCaptionSettings(cleared).language === language - ? patchCaptionSettings(cleared, { language: null }) + getCaptionSettings(cleared, aspectValue).language === language + ? patchCaptionSettings(cleared, { language: null }, aspectValue) : cleared; setDocument(next, { history: false }); await saveDocument(next, { history: true, historyBase: doc }); }, - [setDocument, saveDocument], + [setDocument, saveDocument, aspectValue], ); return { 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, }, }; diff --git a/technical-documentation/architecture/transcription-and-captions.md b/technical-documentation/architecture/transcription-and-captions.md index 2d34225fe..245519ab5 100644 --- a/technical-documentation/architecture/transcription-and-captions.md +++ b/technical-documentation/architecture/transcription-and-captions.md @@ -519,7 +519,7 @@ unit falls back to the original words (`untranslatedUnits`, Caption appearance lives in `document.legacyEditor.captions`, accessed through `getCaptionSettings` / `patchCaptionSettings` -([`src/lib/ai-edition/captions/settings.ts:279,324`](../../src/lib/ai-edition/captions/settings.ts:279)). +([`src/lib/ai-edition/captions/settings.ts:387,445`](../../src/lib/ai-edition/captions/settings.ts:387)). | Field | Default | Notes | |---|---|---| @@ -528,11 +528,11 @@ through `getCaptionSettings` / `patchCaptionSettings` | `fontSize` | `48` | Pixels at a 1080-high frame; `annotationFontSizeFraction` turns that into a fraction of the box being drawn into (`src/lib/ai-edition/annotationScale.ts`), resolution-free. | | `fontFamily`, `fontWeight`, `color` | `Inter`, `bold`, `#ffffff` | Drawn from the same font families `src/index.css` already loads — anything else would render in the preview but fall back to a default in the export canvas. | | `backgroundEnabled`, `backgroundColor`, `backgroundOpacity` | `true`, `#000000`, `0.55` | When off, the text draws straight over the video with no plate. | -| `verticalPosition` | `bottom` | `top` / `middle` / `bottom`. `captionBandRect` anchors the band; `offsetY` nudges it from there. | -| `offsetY` | `0` | Fine nudge in % of **frame** height, on top of the anchor. Positive moves down. | -| `offsetX` | `0` | Fine nudge in % of **frame** width. Positive moves toward the right edge of the exported frame — frame geometry, so an RTL interface locale never mirrors it. | -| `width` | `80` | Band width in % of frame width. | -| `minWordsPerLine` / `maxWordsPerLine` | `2` / `7` | Line-group bounds; `groupTimedCaptionWordsIntoLines` packs inside the range, `[1, 12]` after clamp. | +| `anchorV` | `bottom` | `bottom` / `top` — which frame edge the drawn block is pinned to. It grows AWAY from that edge, so the edge never moves. | +| `insetY` | `5` (12.5 on a vertical export) | Distance from the edge named by `anchorV` to the near edge of what is DRAWN, in % of frame height. Always ≥ 0. | +| `anchorH` | `center` | `left` / `center` / `right` — which edge of the block is pinned horizontally, and the ragged edge when the text wraps. | +| `insetX` | the column's own margin | Distance from the edge named by `anchorH`. Ignored when `anchorH` is `center`, which has no edge to measure from. | +| `minWordsPerLine` / `maxWordsPerLine` | `2` / `7` | Line-group bounds; `groupTimedCaptionWordsIntoLines` packs inside the range, `[1, 12]` after clamp. Also the only control over how much text is on screen — there is no width slider. | #### Coordinate space @@ -558,45 +558,68 @@ The **font denominator follows the same box.** Flipping the rect without the denominator would hold a caption still while its glyphs kept shrinking with the padding slider, which is why both come off one `anchor` local in each backend. -#### Reach +#### Anchoring -`CAPTION_BAND_HEIGHT_PCT = 22` +**A caption is placed by pinning one edge of the drawn block, never by centring it +in a box.** `captionBoxRect` ([`src/lib/ai-edition/captions/settings.ts`](../../src/lib/ai-edition/captions/settings.ts)) -is generous enough for two wrapped lines at the default size — the -renderers clip to it, so it is deliberately not tight. - -The band is a **box**; the visible caption is a strip centred inside it. All three -rasterizers centre the text vertically, so a box stopped flush against the frame -edge leaves its glyphs half a band short of it. `captionOffsetRange` therefore -lets the box hang off the top or bottom by exactly its empty margin — the ink -reaches the edge, and nothing drawn leaves the frame. The size of that margin is -derived from `fontSize`, because the slice of the band that carries ink is: a -200px caption fills the whole band, gets no overhang, and stays whole. - -`captionOffsetRange` is also what the inspector's sliders take their bounds from, -so the reachable span and the slider span are the same span. Before #396 both -ends were hardcoded to ±45 while the result was clamped separately, which left -the bottom anchor honouring only −45…+3 — nearly half the slider moved the handle -and nothing else. - -#### Position presets - -`verticalPosition` and `offsetX`/`offsetY` are independent fields — a preset is -not a separate mode the offsets are locked out of, it's just a point on the same -range the slider already covers. `activeVerticalPositionPreset` / -`activeHorizontalPositionPreset` (`settings.ts`, right after `captionOffsetRange`) -read "is a preset active" back out of that: a preset counts as active only while -its axis' offset is (within a small epsilon) exactly the value that preset would -set, so dragging a slider away from a preset silently un-highlights it with no -separate "active preset" field to keep in sync. Clicking a preset writes that -clean value back (`offsetY: 0` for a vertical preset; `captionHorizontalPositionOffset` -for a horizontal one) rather than leaving whatever nudge was already there, which -is what makes the click read as "go here" instead of "go here, plus whatever was -left over." `CaptionHorizontalPosition` (left/center/right) is a new axis of -meaning distinct from `CaptionTextAlign` (same three words, but for aligning the -text *inside* the band) — there is no stored `horizontalPosition` field; it is -derived from `offsetX` exactly the way the vertical preset is derived from -`offsetY`. +returns the box plus a `verticalAlign`, and the compositor puts the block flush +against that edge of it. The invariant, which the tests assert as a property: + +> 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. + +No estimate of the block's height participates in placing it. The box's height is +**headroom** — how many lines can be drawn before the renderer clips — so being +wrong about it costs a clipped fourth line, not a moved subtitle. + +That distinction is the whole of the redesign. The model this replaced put the +caption in a fixed 22 % box and let all three rasterizers centre the ink inside it. +A centred block moves BOTH its edges as it grows, so: + +- wrapping to another line shifted the caption vertically, and so did anything that + changed wrapping — which is how the *width* slider ended up moving the caption on + the *vertical* axis; +- the box had to be allowed to hang off the frame by its own empty margin for the + glyphs to reach the edge at all, and that margin was derived from `fontSize`; +- the offset therefore had to be signed and clamped against a reachable range that + moved with four other fields, which is why the inspector could show `-7.3 %` — + a number that corresponds to nothing in any subtitle format. + +All of that is deleted. An inset is a distance from a named edge, so it means the +same thing whatever else changes, and `patchCaptionSettings` has no re-clamping +pass any more. + +Bottom-anchored growth is not an invention here: it is the default in every +subtitle format. `tts:displayAlign="after"` (TTML/IMSC, which the BBC requires on +every region), `\an2` with `MarginV` measured from the bottom (ASS), `line:auto` +resolving to −1 and pushing the box upward (WebVTT), roll-up scrolling (CEA-708). +Deliberately absent: a "middle" anchor. XSL 1.1 defines `display-align: center` as +keeping both edge distances equal — which is precisely the pathology above — and a +bottom anchor with a large `insetY` reaches the same place while still growing +upward. + +#### The column, and why there is no width control + +`captionSafeColumn` derives the wrap width from the output aspect — 68 % of a 16:9 +frame, 90 % of a squarer or vertical one (the BBC line-length table; 68 % at 48 px +on 1080p is ≈45 characters, inside the Netflix 42 / BBC 37 band). It is never +stored and never exposed. + +It used to be a `width` slider, and that control could not be understood: the +background plate hugs the TEXT, not the box, so moving it changed nothing visible +until the text happened to be long enough to wrap. What it actually controlled is +how much text is on screen — a question `minWordsPerLine` / `maxWordsPerLine` +already answers in words rather than in percent. + +The horizontal axis has one control, `anchorH`, which reaches the rasterizers as +the existing `textAlign`. That is not a coincidence: their plate maths already +snaps the plate onto the box's left or right edge (`text_linux.rs`'s `plate_x`, +and its two mirrors), so the alignment *is* the pivot. The model before this had +two controls fighting over that one outcome — `offsetX` moved an invisible band and +`textAlign` moved the text inside it — and neither could be read without seeing the +band. The Captions pane itself ([`src/components/ai-edition/CaptionsPane.tsx`](../../src/components/ai-edition/CaptionsPane.tsx))