From af0edddad4e8b10a2bfe44a4f47b3af0c3f2ecd6 Mon Sep 17 00:00:00 2001 From: m1 Date: Thu, 30 Jul 2026 17:49:18 +0200 Subject: [PATCH 1/2] =?UTF-8?q?fix(compositor):=20les=20blocs=20de=20texte?= =?UTF-8?q?=20se=20centrent=20dans=20leur=20bo=C3=AEte,=20et=20leur=20plaq?= =?UTF-8?q?ue=20=C3=A9pouse=20le=20texte?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rapport utilisateur : « il y a une énorme marge en bas » sur les blocs de sous-titres, en rendu Metal. Mesuré sur une vraie bande (1536x238 en 1080p), avant correctif : 11 px d'encre au-dessus du texte, **180 px en dessous**, et la plaque de fond couvrant la boîte ENTIÈRE. Deux causes, toutes deux dans `text_macos.rs` : * `CTFrameDraw` remplit son cadre du haut vers le bas, et le cadre couvrait toute la boîte — les lignes se collaient donc en haut. Direct2D n'avait pas le problème, il pose `DWRITE_PARAGRAPH_ALIGNMENT_CENTER`. macOS était le seul à ne pas centrer. * le fond était un `CGContextFillRect` sur la boîte entière, là où Direct2D le dessine sur `DWRITE_TEXT_METRICS`, donc ajusté au texte. Les deux se cumulent sur les sous-titres parce qu'une bande fait 22 % de la hauteur d'image (`CAPTION_BAND_HEIGHT_PCT`, volontairement généreuse pour absorber deux lignes) : la boîte est toujours bien plus haute que son texte. Les annotations texte passent par le même rastériseur, d'où le même symptôme. macOS mesure désormais le bloc (`CTFramesetterSuggestFrameSizeWithConstraints`) puis `block_layout` en déduit le cadre — centré verticalement — et la plaque qui l'habille. Un texte qui déborde de sa boîte part toujours du haut et se coupe en bas, plutôt que d'être rogné des deux côtés. Windows reçoit la même plaque : marge et coins arrondis, au lieu des métriques nues. Les trois nombres qui décident de l'allure du bloc (0.2em / 0.1em / 4px à 48px de police, le modèle de boîte du `` de l'overlay DOM et de `lib/exporter/annotationRenderer.ts`) vivent dans un `text_plate.rs` partagé : recopiés, ils dériveraient en silence, puisque rien dans un rendu Windows ne signale qu'une marge macOS a bougé. Après, sur la même bande : 84 px / 84 px verticalement, 502 px / 502 px horizontalement. Vérification. macOS : `cargo test -p openscreen-compositor --lib --tests` vert (106 unitaires + 5 d'intégration, goldens de géométrie compris), dont cinq tests qui rastérisent pour de vrai sur le device Metal — non-miroir, centrage, plaque ajustée, marge autour des glyphes, débordement. Le test qui affirmait « le texte se dessine EN HAUT de sa boîte » est réécrit : il comparait les deux moitiés de la TEXTURE, ce qui ne veut plus rien dire une fois le bloc centré ; il compare maintenant les deux moitiés de la boîte englobante de l'encre, ce qui détecte le miroir sans dépendre du centrage. Windows : pas de machine sous la main, mais `text_windows.rs` type-checke pour `x86_64-pc-windows-msvc` sans warning, et les signatures `FillRoundedRectangle` / `D2D1_ROUNDED_RECT` ont été relues dans les bindings `windows-0.58`. Le rendu Windows lui-même n'est pas vérifié à l'œil. --- crates/compositor/src/lib.rs | 1 + crates/compositor/src/text_macos.rs | 454 +++++++++++++++++++++----- crates/compositor/src/text_plate.rs | 94 ++++++ crates/compositor/src/text_windows.rs | 45 ++- 4 files changed, 510 insertions(+), 84 deletions(-) create mode 100644 crates/compositor/src/text_plate.rs diff --git a/crates/compositor/src/lib.rs b/crates/compositor/src/lib.rs index 3a7930e05f..79ea40c73f 100644 --- a/crates/compositor/src/lib.rs +++ b/crates/compositor/src/lib.rs @@ -36,6 +36,7 @@ pub mod gif_export; pub mod regions; pub mod scene; pub mod text_anim; +pub mod text_plate; pub(crate) mod timeline_walk; // GPU backend : Windows → d3d_windows, macOS → d3d_macos. Ré-exporté sous le nom `d3d` diff --git a/crates/compositor/src/text_macos.rs b/crates/compositor/src/text_macos.rs index 0b136adcd4..2dea29fbf2 100644 --- a/crates/compositor/src/text_macos.rs +++ b/crates/compositor/src/text_macos.rs @@ -19,18 +19,15 @@ //! 1. `CGBitmapContextCreate` sur un buffer CPU, BGRA prémultiplié //! (`kCGImageAlphaPremultipliedFirst | kCGBitmapByteOrder32Little`) — l'ordre d'octets //! que `MTLPixelFormat::BGRA8Unorm` attend. -//! 2. fond optionnel (`spec.background`, alpha 0 = transparent). -//! 3. `CFAttributedString` avec police (`kCTFontAttributeName`), couleur +//! 2. `CFAttributedString` avec police (`kCTFontAttributeName`), couleur //! (`kCTForegroundColorAttributeName`), soulignement (`kCTUnderlineStyleAttributeName`) //! et alignement (`kCTParagraphStyleAttributeName`). -//! 4. `CTFramesetterCreateFrame` sur un `CGPath` rectangulaire couvrant la boîte, puis -//! `CTFrameDraw`. +//! 3. `CTFramesetterSuggestFrameSizeWithConstraints` mesure le bloc mis en page, puis +//! `block_layout` en déduit le cadre (centré verticalement) et la plaque de fond +//! (`spec.background`, alpha 0 = transparent) qui l'habille. +//! 4. `CTFramesetterCreateFrame` sur ce cadre, puis `CTFrameDraw`. //! 5. `MTLTexture` BGRA8Unorm + `replace_region` depuis le buffer CPU. //! -//! CoreGraphics a son origine en BAS à gauche : le contexte est retourné -//! (`CGContextTranslateCTM` + `CGContextScaleCTM`) pour que la boîte `box_px` se lise -//! comme côté Windows, origine en haut à gauche. -//! //! `TextSpec::cache_key()` est byte-identique à la version Windows — la policy de cache //! est partagée. @@ -187,11 +184,16 @@ extern "C" { bitmap_info: u32, ) -> CFTypeRef; fn CGContextRelease(ctx: CFTypeRef); - fn CGContextTranslateCTM(ctx: CFTypeRef, tx: CGFloat, ty: CGFloat); - fn CGContextScaleCTM(ctx: CFTypeRef, sx: CGFloat, sy: CGFloat); fn CGContextSetRGBFillColor(ctx: CFTypeRef, r: CGFloat, g: CGFloat, b: CGFloat, a: CGFloat); - fn CGContextFillRect(ctx: CFTypeRef, rect: CGRect); + fn CGContextAddPath(ctx: CFTypeRef, path: CFTypeRef); + fn CGContextFillPath(ctx: CFTypeRef); fn CGPathCreateWithRect(rect: CGRect, transform: *const c_void) -> CFTypeRef; + fn CGPathCreateWithRoundedRect( + rect: CGRect, + corner_width: CGFloat, + corner_height: CGFloat, + transform: *const c_void, + ) -> CFTypeRef; } #[link(name = "CoreText", kind = "framework")] @@ -206,6 +208,13 @@ extern "C" { ) -> CFTypeRef; fn CTParagraphStyleCreate(settings: *const CTParagraphStyleSetting, count: usize) -> CFTypeRef; fn CTFramesetterCreateWithAttributedString(attr: CFTypeRef) -> CFTypeRef; + fn CTFramesetterSuggestFrameSizeWithConstraints( + framesetter: CFTypeRef, + string_range: CFRange, + frame_attributes: CFTypeRef, + constraints: CGSize, + fit_range: *mut CFRange, + ) -> CGSize; fn CTFramesetterCreateFrame( framesetter: CFTypeRef, string_range: CFRange, @@ -244,6 +253,113 @@ impl Drop for CFOwned { } } +// --------------------------------------------------------------------------- +// Modèle de boîte du bloc de texte +// --------------------------------------------------------------------------- + +/// Marge et rayon de la plaque : `crate::text_plate`, partagé avec le rendu Direct2D. +/// Les deux plateformes DOIVENT lire les mêmes nombres — cf. l'en-tête de ce module. +fn plate_padding(font_px: CGFloat) -> (CGFloat, CGFloat) { + let (x, y) = crate::text_plate::padding(font_px as f32); + (x as CGFloat, y as CGFloat) +} + +/// `CTTextAlignment` : 0 = left, 1 = right, 2 = center (3 = justified, 4 = natural). +fn ct_alignment(align: &str) -> u8 { + match align { + "left" => 0, + "right" => 1, + _ => 2, + } +} + +/// Où poser le cadre de mise en page et la plaque de fond dans une boîte `box_w`×`box_h`, +/// une fois le bloc mesuré à `text_w`×`text_h`. +/// +/// Repère **CoreGraphics** : origine en BAS à gauche, `y` croissant vers le haut. Le +/// bitmap, lui, range sa ligne 0 en HAUT — d'où la conversion `box_h - haut - hauteur`, +/// faite ici une fois pour toutes plutôt que dispersée dans les appels de dessin. +/// +/// Deux choses que la version précédente ne faisait pas : +/// +/// * **centrage vertical.** `CTFrameDraw` remplit son cadre du haut vers le bas ; avec un +/// cadre couvrant toute la boîte, les lignes se collaient en haut et laissaient le reste +/// vide. Une bande de sous-titres fait 22 % de la hauteur de l'image +/// (`CAPTION_BAND_HEIGHT_PCT`, volontairement généreuse pour absorber deux lignes), donc +/// « le reste » représentait ~180 px sur 238 en 1080p. C'est l'énorme marge basse. +/// Windows n'avait pas le problème : `DWRITE_PARAGRAPH_ALIGNMENT_CENTER`. +/// * **plaque ajustée au texte.** Le fond couvrait la boîte entière, là où le `` du +/// DOM, le renderer canvas et Direct2D (qui remplit `DWRITE_TEXT_METRICS`) l'ajustent +/// tous au bloc mis en page. +/// +/// Le cadre garde toute la largeur utile (`box_w` moins la marge de plaque) : c'est sur +/// elle que CoreText applique l'alignement de paragraphe, exactement comme DirectWrite. +/// L'inset horizontal joue le rôle du `p-2` que l'overlay DOM posait sur le conteneur — il +/// réserve la place de la marge de plaque, pour qu'un texte aligné à gauche ou à droite ne +/// la voie pas rognée par le bord de la boîte. +fn block_layout( + box_w: CGFloat, + box_h: CGFloat, + text_w: CGFloat, + text_h: CGFloat, + align: u8, + font_px: CGFloat, +) -> (CGRect, CGRect) { + let (pad_x, pad_y) = plate_padding(font_px); + let avail_w = layout_width(box_w, font_px); + + // Un cadre haut d'exactement `text_h` perd parfois sa dernière ligne sur un arrondi de + // 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); + let frame_x = (box_w - avail_w) * 0.5; + let frame = CGRect { + origin: CGPoint { + x: frame_x, + y: box_h - top - text_h - GUARD, + }, + size: CGSize { + width: avail_w, + height: text_h + GUARD, + }, + }; + + // La plaque épouse le bloc, marge comprise, sans jamais déborder de la boîte : au-delà + // elle serait coupée net par le bord de la texture et perdrait ses coins arrondis. + let plate_w = (text_w + pad_x * 2.0).min(box_w); + let plate_h = (text_h + pad_y * 2.0).min(box_h); + let slack_x = (box_w - plate_w).max(0.0); + let plate_x = match align { + // À gauche, les lignes commencent au bord gauche du cadre ; à droite, elles + // finissent au bord droit. La plaque déborde de `pad_x` du côté concerné. + 0 => frame_x - pad_x, + 1 => frame_x + avail_w + pad_x - plate_w, + _ => slack_x * 0.5, + } + .clamp(0.0, slack_x); + let plate_y = (box_h - top - text_h - pad_y).clamp(0.0, (box_h - plate_h).max(0.0)); + + ( + frame, + CGRect { + origin: CGPoint { + x: plate_x, + y: plate_y, + }, + size: CGSize { + width: plate_w, + height: plate_h, + }, + }, + ) +} + +/// Largeur offerte aux lignes — `crate::text_plate::layout_width`, en `CGFloat`. +fn layout_width(box_w: CGFloat, font_px: CGFloat) -> CGFloat { + crate::text_plate::layout_width(box_w as f32, font_px as f32) as CGFloat +} + unsafe fn cf_string(s: &str) -> Option { CFOwned::new(CFStringCreateWithBytes( std::ptr::null(), @@ -296,34 +412,14 @@ impl TextRasterizer { bail!("CGBitmapContextCreate {w}x{h} a renvoyé NULL"); } - let box_rect = CGRect { - origin: CGPoint { x: 0.0, y: 0.0 }, - size: CGSize { - width: w as CGFloat, - height: h as CGFloat, - }, - }; - - // Fond avant le flip : un rect plein est invariant par retournement. - if spec.background[3] > 0.0 { - CGContextSetRGBFillColor( - ctx, - spec.background[0] as CGFloat, - spec.background[1] as CGFloat, - spec.background[2] as CGFloat, - spec.background[3] as CGFloat, - ); - CGContextFillRect(ctx, box_rect); - } - - // PAS de flip du CTM ici, et c'est contre-intuitif. `CGBitmapContext` a bien son - // origine en bas à gauche, MAIS il stocke la ligne 0 du buffer EN HAUT de l'image — - // et `CTFrameDraw` remplit son cadre du haut vers le bas. La première ligne de texte - // atterrit donc déjà dans les premières lignes du buffer, c'est-à-dire en haut de la + // PAS de flip du CTM, et c'est contre-intuitif. `CGBitmapContext` a bien son origine + // en bas à gauche, MAIS il stocke la ligne 0 du buffer EN HAUT de l'image — et + // `CTFrameDraw` remplit son cadre du haut vers le bas. Le sommet du cadre atterrit + // donc déjà dans les premières lignes du buffer, c'est-à-dire en haut de la // `MTLTexture`. Le `ScaleCTM(1, -1)` que ce code faisait retournait une image déjà // correcte : le texte s'affichait en miroir vertical. - let drawn = self.draw_text(ctx, space, spec, box_rect); + let drawn = self.draw_text(ctx, space, spec, w as CGFloat, h as CGFloat); CGContextRelease(ctx); CGColorSpaceRelease(space); @@ -362,7 +458,8 @@ impl TextRasterizer { ctx: CFTypeRef, space: CFTypeRef, spec: &TextSpec, - box_rect: CGRect, + box_w: CGFloat, + box_h: CGFloat, ) -> Result<()> { let content = cf_string(&spec.content).ok_or_else(|| anyhow!("CFStringCreateWithBytes NULL"))?; @@ -409,12 +506,7 @@ impl TextRasterizer { .ok_or_else(|| anyhow!("CGColorCreate a renvoyé NULL"))?; // --- alignement --- - // `CTTextAlignment` : 0 = left, 1 = right, 2 = center, 3 = justified, 4 = natural. - let alignment: u8 = match spec.align.as_str() { - "left" => 0, - "right" => 1, - _ => 2, - }; + let alignment: u8 = ct_alignment(&spec.align); let settings = [CTParagraphStyleSetting { spec: K_CT_PARAGRAPH_STYLE_SPECIFIER_ALIGNMENT, value_size: std::mem::size_of::(), @@ -464,17 +556,71 @@ impl TextRasterizer { let framesetter = CFOwned::new(CTFramesetterCreateWithAttributedString(attributed.get())) .ok_or_else(|| anyhow!("CTFramesetterCreateWithAttributedString NULL"))?; - let path = CFOwned::new(CGPathCreateWithRect(box_rect, std::ptr::null())) - .ok_or_else(|| anyhow!("CGPathCreateWithRect NULL"))?; + // `length: 0` = « jusqu'à la fin de la chaîne », la convention CoreText — pas // besoin de compter les caractères (et surtout pas en `chars()`, qui compte des // scalaires Unicode là où CFAttributedString compte des unités UTF-16). - let frame = CFOwned::new(CTFramesetterCreateFrame( + let whole = CFRange { + location: 0, + length: 0, + }; + + // --- mesure du bloc mis en page --- + // Hauteur non contrainte (`CGFLOAT_MAX`) : on veut la place que le texte PREND, pas + // celle qu'on lui offre. Un texte plus haut que la boîte est ensuite recadré sur + // elle, ce qui le rend coupé en bas plutôt que centré et coupé des deux côtés. + let font_px = spec.font_size_px.max(1.0) as CGFloat; + let avail_w = layout_width(box_w, font_px); + let mut fit = whole; + let measured = CTFramesetterSuggestFrameSizeWithConstraints( framesetter.get(), - CFRange { - location: 0, - length: 0, + whole, + std::ptr::null(), + CGSize { + width: avail_w, + height: CGFloat::MAX, }, + &mut fit as *mut CFRange, + ); + // Arrondi au pixel supérieur : la mesure revient parfois une fraction sous la + // réalité, et il en faut peu pour rogner la dernière ligne. + let text_w = measured.width.ceil().clamp(0.0, avail_w); + 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); + + // --- plaque de fond, sous le texte --- + if spec.background[3] > 0.0 && plate_rect.size.width > 0.0 && plate_rect.size.height > 0.0 + { + let radius = crate::text_plate::radius( + font_px as f32, + plate_rect.size.width as f32, + plate_rect.size.height as f32, + ) as CGFloat; + let plate = CFOwned::new(CGPathCreateWithRoundedRect( + plate_rect, + radius, + radius, + std::ptr::null(), + )) + .ok_or_else(|| anyhow!("CGPathCreateWithRoundedRect NULL"))?; + CGContextSetRGBFillColor( + ctx, + spec.background[0] as CGFloat, + spec.background[1] as CGFloat, + spec.background[2] as CGFloat, + spec.background[3] as CGFloat, + ); + CGContextAddPath(ctx, plate.get()); + CGContextFillPath(ctx); + } + + let path = CFOwned::new(CGPathCreateWithRect(frame_rect, std::ptr::null())) + .ok_or_else(|| anyhow!("CGPathCreateWithRect NULL"))?; + let frame = CFOwned::new(CTFramesetterCreateFrame( + framesetter.get(), + whole, path.get(), std::ptr::null(), )) @@ -489,17 +635,9 @@ impl TextRasterizer { mod tests { use super::*; - /// Une ligne de texte se dessine EN HAUT de sa boîte. Le test regarde où est l'encre - /// plutôt que de faire confiance au sens du CTM : c'est la seule façon de distinguer - /// « bien orienté » de « retourné », et le retournement était précisément le bug. - #[test] - fn text_lands_in_the_upper_half_not_mirrored() { - let Ok(gpu) = crate::d3d::Gpu::create(false) else { - eprintln!("pas de device Metal — test sauté"); - return; - }; - let spec = TextSpec { - content: "Ag".into(), + fn spec(content: &str) -> TextSpec { + TextSpec { + content: content.into(), color: [1.0, 1.0, 1.0, 1.0], background: [0.0, 0.0, 0.0, 0.0], font_size_px: 48.0, @@ -507,34 +645,204 @@ mod tests { bold: false, italic: false, underline: false, - align: "left".into(), + align: "center".into(), box_px: [256, 256], + } + } + + /// Rastérise et rend les octets BGRA, ou `None` si la machine n'a pas de device Metal. + fn raster_bgra(spec: &TextSpec) -> Option<(Vec, usize, usize)> { + let Ok(gpu) = crate::d3d::Gpu::create(false) else { + eprintln!("pas de device Metal — test sauté"); + return None; }; let raster = TextRasterizer::new().expect("TextRasterizer::new"); - let tex = unsafe { raster.rasterize(&gpu, &spec) }.expect("rasterize"); - - let (w, h) = (256usize, 256usize); + let tex = unsafe { raster.rasterize(&gpu, spec) }.expect("rasterize"); + let (w, h) = (spec.box_px[0] as usize, spec.box_px[1] as usize); let mut px = vec![0u8; w * h * 4]; tex.get_bytes( px.as_mut_ptr() as *mut c_void, (w * 4) as u64, metal::MTLRegion { origin: metal::MTLOrigin { x: 0, y: 0, z: 0 }, - size: metal::MTLSize { width: w as u64, height: h as u64, depth: 1 }, + size: metal::MTLSize { + width: w as u64, + height: h as u64, + depth: 1, + }, }, 0, ); + Some((px, w, h)) + } + + /// Boîte englobante de l'encre (alpha > 8) : `(x0, y0, x1, y1)`, bornes incluses. + fn ink_bounds(px: &[u8], w: usize, h: usize) -> (usize, usize, usize, usize) { + let (mut x0, mut y0, mut x1, mut y1) = (w, h, 0usize, 0usize); + for y in 0..h { + for x in 0..w { + if px[(y * w + x) * 4 + 3] > 8 { + x0 = x0.min(x); + y0 = y0.min(y); + x1 = x1.max(x); + y1 = y1.max(y); + } + } + } + assert!(x0 <= x1 && y0 <= y1, "aucune encre : rien n'a été rastérisé"); + (x0, y0, x1, y1) + } + + /// Le texte n'est pas retourné. Le test regarde où est l'encre plutôt que de faire + /// confiance au sens du CTM : c'est la seule façon de distinguer « bien orienté » de + /// « retourné », et le retournement était un vrai bug de ce fichier. + /// + /// Il compare les deux moitiés de la boîte ENGLOBANTE, pas de la boîte de sortie : + /// depuis le centrage vertical, un texte bien orienté n'est plus majoritairement dans + /// la moitié haute de la texture. `H` sur la première ligne et `.` sur la seconde rend + /// le bloc très dissymétrique, donc le miroir se voit immédiatement. + #[test] + fn text_is_not_mirrored_vertically() { + let Some((px, w, h)) = raster_bgra(&spec("HHHH\n.")) else { + return; + }; + let (_, y0, _, y1) = ink_bounds(&px, w, h); let ink = |rows: std::ops::Range| -> u64 { - rows.map(|y| { - (0..w).map(|x| px[(y * w + x) * 4 + 3] as u64).sum::() - }) - .sum() + rows.map(|y| (0..w).map(|x| px[(y * w + x) * 4 + 3] as u64).sum::()) + .sum() + }; + let mid = (y0 + y1) / 2; + let (upper, lower) = (ink(y0..mid), ink(mid..y1 + 1)); + assert!( + upper > lower * 3, + "texte retourné : encre haut={upper}, bas={lower} (les `HHHH` sont sur la 1re ligne)" + ); + } + + /// Le bug rapporté : une ligne de sous-titre se collait en haut de sa bande et laissait + /// ~180 px de vide en dessous. La bande fait 22 % de la hauteur de l'image, donc la + /// boîte est toujours bien plus haute que le texte — le bloc doit y être centré. + /// + /// La mesure porte sur la PLAQUE, pas sur les glyphes : l'encre ne remplit jamais sa + /// hauteur de ligne (au-dessus des capitales et sous les jambages il reste du vide, + /// en quantités inégales), donc ses marges ne sont pas symétriques même parfaitement + /// centrées. La plaque, elle, est le bloc mis en page. + #[test] + fn a_single_line_is_centred_in_a_tall_box() { + let mut s = spec("Bonjour tout le monde"); + s.background = [0.0, 0.0, 0.0, 1.0]; + // Une vraie bande de sous-titres en 1080p : 80 % de large, 22 % de haut. + s.box_px = [1536, 238]; + let Some((px, w, h)) = raster_bgra(&s) else { + return; + }; + let (x0, y0, x1, y1) = ink_bounds(&px, w, h); + let (top, bottom) = (y0 as i64, (h - 1 - y1) as i64); + let (left, right) = (x0 as i64, (w - 1 - x1) as i64); + assert!( + (top - bottom).abs() <= 1, + "bloc non centré verticalement : {top} px au-dessus, {bottom} px en dessous" + ); + assert!( + (left - right).abs() <= 1, + "bloc non centré horizontalement : {left} px à gauche, {right} px à droite" + ); + // Et le vide restant est réparti, pas empilé en bas comme avant le correctif. + assert!(top > 20, "la boîte fait {h} px de haut : le bloc devrait flotter dedans"); + } + + /// La plaque de fond épouse le bloc de texte au lieu de remplir la boîte. Sans ça, une + /// bande de sous-titres est un pavé opaque de 22 % de la hauteur de l'image. + #[test] + fn the_background_plate_hugs_the_text_not_the_box() { + let mut s = spec("Bonjour"); + s.background = [0.0, 0.0, 0.0, 1.0]; + s.box_px = [1536, 238]; + let Some((px, w, h)) = raster_bgra(&s) else { + return; + }; + let (x0, y0, x1, y1) = ink_bounds(&px, w, h); + let (plate_w, plate_h) = (x1 - x0 + 1, y1 - y0 + 1); + assert!( + plate_h < h / 2, + "la plaque couvre {plate_h} px sur {h} : elle remplit encore la boîte" + ); + assert!( + plate_w < w / 2, + "la plaque couvre {plate_w} px sur {w} : elle remplit encore la boîte" + ); + // Le fond est opaque : les coins de la boîte doivent rester vides. + for (cx, cy) in [(0, 0), (w - 1, 0), (0, h - 1), (w - 1, h - 1)] { + assert_eq!( + px[(cy * w + cx) * 4 + 3], + 0, + "coin ({cx}, {cy}) peint : la plaque déborde du bloc" + ); + } + } + + /// La plaque laisse respirer le texte : `0.1em` en haut/bas, `0.2em` à gauche/droite, + /// le modèle de boîte partagé avec l'overlay DOM et le renderer canvas. + #[test] + fn the_plate_keeps_a_margin_around_the_glyphs() { + let mut s = spec("Bonjour"); + s.box_px = [1536, 238]; + let Some((glyphs, w, h)) = raster_bgra(&s) else { + return; + }; + let (gx0, _, gx1, _) = ink_bounds(&glyphs, w, h); + + s.background = [0.0, 0.0, 0.0, 1.0]; + let Some((plate, _, _)) = raster_bgra(&s) else { + return; + }; + let (px0, _, px1, _) = ink_bounds(&plate, w, h); + + assert!( + px0 < gx0 && px1 > gx1, + "la plaque ({px0}..{px1}) ne dépasse pas les glyphes ({gx0}..{gx1})" + ); + } + + /// Un texte plus haut que sa boîte se coupe en BAS. Le centrer puis le rogner des deux + /// côtés mangerait la première ligne, qui est celle qu'on veut lire. + #[test] + fn an_overflowing_text_starts_at_the_top() { + let mut s = spec("Un texte tres long qui deborde largement de la boite prevue pour lui"); + s.box_px = [240, 90]; + let Some((px, w, h)) = raster_bgra(&s) else { + return; }; - let (top, bottom) = (ink(0..h / 2), ink(h / 2..h)); - assert!(top > 0, "aucune encre : le texte n'a pas été rastérisé du tout"); + let (_, y0, _, _) = ink_bounds(&px, w, h); assert!( - top > bottom * 4, - "texte retourné : encre haut={top}, bas={bottom} (attendu très majoritairement en haut)" + y0 < h / 4, + "le débordement ne part pas du haut : première ligne d'encre à y={y0} sur {h}" ); } + + /// 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); + // 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; + assert!((above - below).abs() <= 1.5, "cadre décentré : {above} / {below}"); + // Plaque = bloc + 0.2em/0.1em, centrée elle aussi. + assert!((plate.size.width - (500.0 + 2.0 * 9.6)).abs() < 0.01); + assert!((plate.size.height - (56.0 + 2.0 * 4.8)).abs() < 0.01); + assert!((plate.origin.x - (1536.0 - plate.size.width) * 0.5).abs() < 0.01); + } + + #[test] + 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); + 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}"); + } + } } diff --git a/crates/compositor/src/text_plate.rs b/crates/compositor/src/text_plate.rs new file mode 100644 index 0000000000..0e88ca9a2c --- /dev/null +++ b/crates/compositor/src/text_plate.rs @@ -0,0 +1,94 @@ +//! Le modèle de boîte de la plaque de fond d'un bloc de texte, partagé par les deux +//! rastériseurs. +//! +//! `text_windows.rs` (Direct2D) et `text_macos.rs` (CoreText) dessinent la même chose avec +//! deux API qui n'ont rien en commun ; ce qu'elles PEUVENT partager, ce sont les trois +//! nombres qui décident de l'allure du bloc. Ils vivent ici parce que c'est exactement le +//! genre de constante qui dérive en silence quand elle est recopiée : rien dans un rendu +//! Windows ne signale qu'une marge macOS a bougé, et personne ne compare les deux à l'œil. +//! +//! Les valeurs viennent du modèle de boîte de référence de l'app — le `` que +//! l'overlay DOM posait derrière le texte et son jumeau canvas +//! (`src/lib/exporter/annotationRenderer.ts`) : `padding: 0.1em 0.2em`, `border-radius: 4px` +//! à la taille de police par défaut des sous-titres. +//! +//! Tout est exprimé en **em**, jamais en pixels : `font_size_px` est déjà mis à l'échelle de +//! la sortie par l'appelant (`font_size_rel * hauteur_du_rect_écran`), donc une marge en em +//! reste juste en 720p comme en 4K, là où une constante en pixels ne vaudrait qu'à une seule +//! résolution. + +/// Marge interne horizontale de la plaque, en em. +const PAD_X_EM: f32 = 0.2; +/// Marge interne verticale, en em. Plus serrée que l'horizontale : la hauteur de ligne +/// apporte déjà du blanc au-dessus des capitales et sous les jambages, la largeur non. +const PAD_Y_EM: f32 = 0.1; +/// Rayon des coins, en em. La référence dit « 4 px » à la taille de police par défaut des +/// sous-titres (48 px sur une frame haute de 1080). +const RADIUS_EM: f32 = 4.0 / 48.0; + +/// Marge interne `(horizontale, verticale)` de la plaque, en pixels de sortie. +pub fn padding(font_px: f32) -> (f32, f32) { + let f = font_px.max(1.0); + (f * PAD_X_EM, f * PAD_Y_EM) +} + +/// Rayon des coins de la plaque, en pixels de sortie. `plate_w`/`plate_h` le bornent à la +/// moitié du plus petit côté : au-delà, Direct2D comme CoreGraphics rendent une forme +/// dégénérée plutôt qu'un rectangle arrondi. +pub fn radius(font_px: f32, plate_w: f32, plate_h: f32) -> f32 { + (font_px.max(1.0) * RADIUS_EM) + .min(plate_w * 0.5) + .min(plate_h * 0.5) + .max(0.0) +} + +/// Largeur offerte aux lignes dans une boîte large de `box_w`. +/// +/// La boîte est rentrée de la marge de plaque, comme le `p-2` que l'overlay DOM posait sur +/// le conteneur : sans ça, un texte aligné à gauche ou à droite colle au bord et sa plaque +/// se fait rogner du côté où elle devrait respirer. La mesure du bloc et le cadre de mise en +/// page doivent TOUS DEUX passer par ici — mesurer sur une largeur et composer sur une autre +/// coupe les lignes ailleurs que là où la plaque a été dimensionnée. +pub fn layout_width(box_w: f32, font_px: f32) -> f32 { + (box_w - padding(font_px).0 * 2.0).max(1.0) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Les valeurs de référence, à la taille de police par défaut des sous-titres. Ce test + /// existe pour qu'un changement de marge soit un choix explicite et non un effet de bord. + #[test] + fn the_reference_box_model_at_the_default_caption_size() { + let (pad_x, pad_y) = padding(48.0); + assert!((pad_x - 9.6).abs() < 1e-4, "0.2em de 48 px"); + assert!((pad_y - 4.8).abs() < 1e-4, "0.1em de 48 px"); + assert!((radius(48.0, 400.0, 60.0) - 4.0).abs() < 1e-4, "4 px à 48 px de police"); + } + + /// Les marges suivent la police, donc la résolution de sortie : le même bloc rendu deux + /// fois plus grand doit avoir des marges deux fois plus grandes, pas les mêmes. + #[test] + fn the_padding_scales_with_the_font() { + let (x1, y1) = padding(48.0); + let (x2, y2) = padding(96.0); + assert!((x2 - x1 * 2.0).abs() < 1e-4); + assert!((y2 - y1 * 2.0).abs() < 1e-4); + } + + #[test] + fn the_radius_never_degenerates_the_plate() { + // Plaque plus mince que le rayon nominal : il se rabat sur la moitié du petit côté. + assert!((radius(200.0, 300.0, 6.0) - 3.0).abs() < 1e-4); + assert!(radius(48.0, 0.0, 0.0) >= 0.0); + } + + #[test] + fn the_layout_width_never_collapses() { + assert!((layout_width(1000.0, 48.0) - (1000.0 - 19.2)).abs() < 1e-4); + // Boîte plus étroite que ses propres marges : une largeur nulle ou négative ferait + // boucler la mise en page au lieu de simplement déborder. + assert!(layout_width(4.0, 200.0) >= 1.0); + } +} diff --git a/crates/compositor/src/text_windows.rs b/crates/compositor/src/text_windows.rs index 551990c4ca..93a5f6df8c 100644 --- a/crates/compositor/src/text_windows.rs +++ b/crates/compositor/src/text_windows.rs @@ -23,7 +23,7 @@ use windows::Win32::Graphics::Direct2D::Common::{ use windows::Win32::Graphics::Direct2D::{ D2D1CreateFactory, ID2D1Factory, D2D1_DRAW_TEXT_OPTIONS_NONE, D2D1_FACTORY_TYPE_SINGLE_THREADED, D2D1_FEATURE_LEVEL_DEFAULT, D2D1_RENDER_TARGET_PROPERTIES, D2D1_RENDER_TARGET_TYPE_DEFAULT, - D2D1_RENDER_TARGET_USAGE_NONE, + D2D1_RENDER_TARGET_USAGE_NONE, D2D1_ROUNDED_RECT, }; use windows::Win32::Graphics::Direct3D11::{ ID3D11Device, ID3D11ShaderResourceView, ID3D11Texture2D, D3D11_BIND_RENDER_TARGET, @@ -109,7 +109,8 @@ impl TextRasterizer { /// Rastérise `spec` dans une texture neuve et rend sa SRV. Le fond éventuel est dessiné /// derrière le texte, ajusté aux métriques de la mise en page — comme le CSS, où - /// `backgroundColor` est porté par le `` et épouse donc le texte, pas la boîte. + /// `backgroundColor` est porté par le `` et épouse donc le texte, pas la boîte — + /// avec la marge et les coins arrondis de `crate::text_plate`, partagés avec CoreText. pub unsafe fn rasterize( &self, dev: &ID3D11Device, @@ -176,8 +177,16 @@ impl TextRasterizer { format.SetParagraphAlignment(DWRITE_PARAGRAPH_ALIGNMENT_CENTER)?; let text: Vec = spec.content.encode_utf16().collect(); - let layout = - self.dwrite.CreateTextLayout(&text, &format, w as f32, h as f32)?; + // La boîte de mise en page est rentrée de la marge de plaque (cf. `text_plate`), et + // le texte se dessine à `pad_x` : sans cet inset, un texte aligné à gauche ou à + // droite colle au bord de la boîte et sa plaque se fait rogner du côté où elle + // devrait respirer. + 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); + let layout = self + .dwrite + .CreateTextLayout(&text, &format, layout_w, h as f32)?; if spec.underline { layout.SetUnderline( true, @@ -205,18 +214,32 @@ impl TextRasterizer { a: spec.background[3], }; let bg_brush = rt.CreateSolidColorBrush(&bg, None)?; - rt.FillRectangle( - &D2D_RECT_F { - left: m.left, - top: m.top, - right: m.left + m.width, - bottom: m.top + m.height, + // 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 radius = crate::text_plate::radius( + font_px, + (rect.right - rect.left).max(0.0), + (rect.bottom - rect.top).max(0.0), + ); + rt.FillRoundedRectangle( + &D2D1_ROUNDED_RECT { + rect, + radiusX: radius, + radiusY: radius, }, &bg_brush, ); } rt.DrawTextLayout( - D2D_POINT_2F { x: 0.0, y: 0.0 }, + D2D_POINT_2F { x: pad_x, y: 0.0 }, &layout, &brush, D2D1_DRAW_TEXT_OPTIONS_NONE, From a336449c9fb158fba009d34531a3ab338b081008 Mon Sep 17 00:00:00 2001 From: m1 Date: Thu, 30 Jul 2026 17:49:36 +0200 Subject: [PATCH 2/2] fix(compositor): annotations et sous-titres ne suivent plus le zoom MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rapport utilisateur : « les sous-titres étaient zoomés avec le screen recording ». Ce n'est pas un bug de sous-titres — les captions sont projetées en annotations texte par `captionCuesToTextRegions` — mais une régression qui touche TOUTES les annotations, sur les deux backends. Le contrat de `SceneAnnotation` est explicite : « deliberately NOT affected by the zoom crop — the overlay is a sibling of the element carrying the zoom transform, so annotations hold still while the content zooms underneath them ». Tant que le zoom se jouait dans la coupe source, `s_dst` tenait ce rôle gratuitement : la boîte écran restait le rect paddé. L'issue #179 a rendu le zoom à la BOÎTE (pour qu'il atteigne les bords du cadre au lieu de buter sur le padding), donc `s_dst` grandit et se déplace avec lui — et les deux compositeurs continuaient d'y ancrer les annotations. `plan_frame` expose donc `s_ann` : la boîte écran AVANT le `remap_box` du zoom, c'est-à-dire le rect que l'app a résolu (`layout.screenRect`) et que l'overlay web reçoit comme conteneur. `draw_annotations` s'y ancre des deux côtés. Effet de bord utile : la poignée de sélection DOM était déjà posée sur ce `screenRect` non zoomé, donc les pixels natifs et le cadre de sélection se recollent — ils divergeaient dès qu'un zoom était actif. `the_annotation_anchor_ignores_the_zoom` épingle l'invariant sur la scène golden, avec et sans région de zoom. Il vérifie d'abord que le zoom agit bel et bien sur `s_dst` — sans cette assertion, le test passerait aussi si `remap_box` cessait de faire son travail, et ne prouverait rien. Il tourne dans le job macOS ET dans le job Windows, comme le golden `plan_frame`, qui reste inchangé au bit près : ajouter un champ ne déplace aucune des 21 valeurs épinglées. `cargo test -p openscreen-compositor --lib --tests` vert (106 + 5). --- crates/compositor/src/compositor_macos.rs | 6 +- crates/compositor/src/compositor_windows.rs | 15 +++-- crates/compositor/src/frame_geometry.rs | 62 +++++++++++++++++++++ 3 files changed, 75 insertions(+), 8 deletions(-) diff --git a/crates/compositor/src/compositor_macos.rs b/crates/compositor/src/compositor_macos.rs index d92174d267..cd553fb94d 100644 --- a/crates/compositor/src/compositor_macos.rs +++ b/crates/compositor/src/compositor_macos.rs @@ -1637,8 +1637,10 @@ impl Compositor { enc.end_encoding(); - // --- annotations : calque le plus haut, ancré sur le rect ÉCRAN --- - self.draw_annotations(cmd_buf, scene_ref.as_ref(), g.source_t, g.s_dst)?; + // --- annotations : calque le plus haut, ancré sur le rect ÉCRAN SANS ZOOM --- + // `s_ann`, pas `s_dst` : le zoom vit dans la boîte depuis l'issue #179, donc `s_dst` + // grandit avec lui et emmenait annotations et sous-titres dans le mouvement. + self.draw_annotations(cmd_buf, scene_ref.as_ref(), g.source_t, g.s_ann)?; // Ni miroir RGBA ni attente ici : le miroir ne sert qu'à `readback_direct` (la // preview), et l'export ne lit jamais le RGBA — le blit pleine résolution était payé diff --git a/crates/compositor/src/compositor_windows.rs b/crates/compositor/src/compositor_windows.rs index 9a9c16a887..09381e4543 100644 --- a/crates/compositor/src/compositor_windows.rs +++ b/crates/compositor/src/compositor_windows.rs @@ -1100,7 +1100,8 @@ impl Compositor { // inchangé, à l'octet près. let crate::frame_geometry::FrameGeometry { scene_preset, mb_taps, source_t, zoom_rotation, padding_scale, cut, s_dst, - s_dst_prev, s_radius, frame_min_px, w_dst, w_dst_prev, w_px, w_radius, shape_fade, + s_dst_prev, s_ann, s_radius, frame_min_px, w_dst, w_dst_prev, w_px, w_radius, + shape_fade, } = crate::frame_geometry::plan_frame(&crate::frame_geometry::FrameGeometryInput { render_px: [self.rw(), self.rh()], screen_tex_px: [stw as f32, sth as f32], @@ -1556,14 +1557,16 @@ impl Compositor { } // --- annotations : calque le plus haut, comme dans le DOM de la preview (le calque y est - // monté après la vidéo). Ancrées sur `s_dst`, le rect ÉCRAN — c'est le conteneur que reçoit - // l'overlay web (`layout.screenRect`) — et volontairement pas sur le rect de sortie, ni - // sujettes au crop de zoom : dans la preview l'overlay est frère de l'élément qui porte la - // transform, donc les annotations restent en place pendant que le contenu zoome dessous. + // monté après la vidéo). Ancrées sur `s_ann`, le rect ÉCRAN SANS ZOOM — c'est le conteneur + // que reçoit l'overlay web (`layout.screenRect`) — et volontairement pas sur le rect de + // sortie, ni sujettes au zoom : dans la preview l'overlay est frère de l'élément qui porte + // la transform, donc les annotations restent en place pendant que le contenu zoome dessous. + // Ce fut `s_dst` tant que le zoom vivait dans la coupe source ; depuis l'issue #179 il vit + // dans la BOÎTE, et `s_dst` emmenait annotations et sous-titres avec lui. // `source_t`, la même base de temps que les zoom/speed regions : le temps SOURCE du clip, // pas le compteur de frames. C'est ce qui garde une annotation alignée sur l'image quand // une speed region répète ou saute des frames. - self.draw_annotations(scene_ref.as_ref(), source_t, s_dst); + self.draw_annotations(scene_ref.as_ref(), source_t, s_ann); Ok(()) } diff --git a/crates/compositor/src/frame_geometry.rs b/crates/compositor/src/frame_geometry.rs index b77ea0b077..0addb485dc 100644 --- a/crates/compositor/src/frame_geometry.rs +++ b/crates/compositor/src/frame_geometry.rs @@ -697,6 +697,18 @@ pub struct FrameGeometry { pub cut: [f32; 4], pub s_dst: [f32; 4], pub s_dst_prev: [f32; 4], + /// Boîte écran **sans le zoom** : le conteneur auquel les annotations et les + /// sous-titres sont ancrés. + /// + /// C'est `s_dst` avant le `remap_box` du zoom, donc le rect que l'app a résolu + /// (`layout.screenRect`) et que l'overlay web reçoit comme conteneur. Le contrat de + /// `SceneAnnotation` est explicite : « deliberately NOT affected by the zoom crop — the + /// overlay is a sibling of the element carrying the zoom transform, so annotations hold + /// still while the content zooms underneath them ». Tant que le zoom vivait dans la + /// coupe source, `s_dst` tenait ce rôle ; depuis l'issue #179 il vit dans la BOÎTE, donc + /// `s_dst` grandit et se déplace avec lui — et les annotations le suivaient, sous-titres + /// compris, qui se mettaient à zoomer avec l'écran. + pub s_ann: [f32; 4], pub s_radius: f32, pub frame_min_px: f32, pub w_dst: [f32; 4], @@ -1079,6 +1091,8 @@ pub fn plan_frame(input: &FrameGeometryInput) -> FrameGeometry { cut, s_dst, s_dst_prev, + // La boîte écran telle qu'elle serait sans zoom : `remap_box` n'est PAS appliqué. + s_ann: s_base, s_radius, frame_min_px, w_dst, @@ -1252,6 +1266,54 @@ mod tests { } } + /// La même scène, avec une région de zoom active à `t = 1.5 s`. + fn zoomed_golden_scene() -> Scene { + Scene::from_json( + r##"{ + "clips":[{"screenPath":"/s.mp4","webcamPath":"/w.mp4","sourceStartSec":0,"sourceEndSec":10,"webcamOffsetSec":0,"hasAudio":true}], + "layout":{"preset":"picture-in-picture","webcamSize":0.44,"webcamShape":"circle","webcamMirror":false, + "webcamPosition":{"cx":0.8577,"cy":0.8159},"webcamReactiveZoom":false}, + "effects":{"padding":0.51,"blur":false,"shadow":0.35,"roundnessFrac":0.0255,"motionBlur":0.35}, + "background":{"kind":"color","color":"#1e1e2e"}, + "zoomRegions":[{"clipIndex":0,"startSec":0.0,"endSec":5.0,"scale":2.0,"focusX":0.5,"focusY":0.3,"rotation":"none"}], + "cursor":{"show":true,"size":7.76,"smoothing":0,"motionBlur":0.35,"clickBounce":1,"clipToBounds":false,"theme":"default"}, + "cropByClip":[{"x":0,"y":0,"width":0.61,"height":0.61}], + "output":{"width":1170,"height":658,"fps":60} + }"##, + ) + .expect("zoomed golden scene") + } + + /// L'ancre des annotations ne bouge PAS avec le zoom, alors que la boîte écran, si. + /// + /// C'est tout le contrat de `SceneAnnotation` : l'overlay web est frère de l'élément qui + /// porte la transform de zoom, donc annotations et sous-titres tiennent en place pendant + /// que le contenu grossit dessous. Tant que le zoom vivait dans la coupe source, `s_dst` + /// jouait ce rôle sans effort ; depuis l'issue #179 il vit dans la BOÎTE, et le natif + /// zoomait les sous-titres avec l'écran. Ce test échoue si `s_ann` se remet à suivre. + #[test] + fn the_annotation_anchor_ignores_the_zoom() { + let cfg = crate::config::all().pop().expect("au moins une config"); + let plain = golden_scene(); + let zoomed = zoomed_golden_scene(); + let a = plan_frame(&golden_input(&plain, &cfg)); + let b = plan_frame(&golden_input(&zoomed, &cfg)); + + assert_ne!( + a.s_dst, b.s_dst, + "le zoom doit bel et bien agir sur la boîte écran (issue #179) — \ + sinon ce test ne prouve rien" + ); + assert_eq!( + a.s_ann, b.s_ann, + "l'ancre des annotations a suivi le zoom : sans zoom {:?}, avec zoom {:?}", + a.s_ann, b.s_ann + ); + // Et sans zoom, l'ancre EST la boîte écran : `s_ann` ne doit pas devenir un rect + // parallèle qui dériverait de `s_dst` pour d'autres raisons (padding, cover, crop). + assert_eq!(a.s_ann, a.s_dst, "sans zoom, ancre et boîte écran coïncident"); + } + /// **Le golden iso-render.** /// /// Les deux backends ne peuvent pas tourner sur la même machine, donc « iso avec