Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
00504a4
chore(release): bump to 1.10.0-rc.1 [skip ci]
github-actions[bot] Aug 22, 2026
b67d702
fix(editor): make the gradient picker and a couple of dialogs follow …
EtienneLescot Aug 22, 2026
d933d5d
fix(editor): a subagent review of the previous commit's own diff, add…
EtienneLescot Aug 22, 2026
cfcb63b
fix(editor): close the clip picker on an outside click
EtienneLescot Aug 22, 2026
f866879
fix(stt): offer every whisper.cpp language in the regenerate picker
EtienneLescot Aug 22, 2026
6818829
fix(stt): wire the language list into the picker that's actually live
EtienneLescot Aug 22, 2026
eb10b19
fix(stt): validate regenLang against the known code list
EtienneLescot Aug 22, 2026
64db229
fix(editor): zoom/pan the whole timeline pane on scroll, not just the…
EtienneLescot Aug 22, 2026
e31c1ed
test(editor): cover Shift+wheel pan from the ruler too
EtienneLescot Aug 22, 2026
9e38a50
fix(editor): remove the redundant top-bar settings button
EtienneLescot Aug 22, 2026
faccec2
fix(captions): position presets you can click, sliders that reach the…
EtienneLescot Aug 22, 2026
19ca0db
fix(captions): pick the closest position preset, not the first one ch…
EtienneLescot Aug 22, 2026
ae2d166
chore(release): bump to 1.10.0-rc.2 [skip ci]
github-actions[bot] Aug 22, 2026
a2a411b
fix(editor): give the timeline clip's delete icon the same dark-chip …
EtienneLescot Aug 22, 2026
ce212e5
feat(compositor): let a text region pin an edge instead of centring i…
EtienneLescot Aug 22, 2026
c610318
fix(captions): place captions by anchor and margin, not by an invisib…
EtienneLescot Aug 22, 2026
0979b9a
test(compositor): anchor on the line box, not on the last pixel of ink
EtienneLescot Aug 22, 2026
1e1c900
fix(captions): reserve the plate's margin on the anchored side, and m…
EtienneLescot Aug 22, 2026
22e9109
fix(compositor): a doc comment cannot sit on a function parameter
EtienneLescot Aug 22, 2026
491939f
fix(captions): let the first write know the output aspect too
EtienneLescot Aug 22, 2026
34f98eb
docs(captions): describe the anchor model on the release branch too
EtienneLescot Aug 22, 2026
b71311e
fix(captions): default the insets to what actually sits well with the…
EtienneLescot Aug 22, 2026
9c6f624
chore(release): bump to 1.10.0-rc.3 [skip ci]
github-actions[bot] Aug 22, 2026
c9cd061
fix: initialise CoreGraphics before building a window capture filter
naadydev Aug 23, 2026
f5b33f9
fix(recording): use hardware H.264 on Windows by default, not just wh…
EtienneLescot Aug 23, 2026
0c333af
chore(release): bump to 1.10.0-rc.4 [skip ci]
github-actions[bot] Aug 23, 2026
c283da8
fix(app): reach Save Diagnostics from the menu and tray, not nowhere
EtienneLescot Aug 23, 2026
e067355
fix(app): address CodeRabbit review on the Save Diagnostics PR
EtienneLescot Aug 23, 2026
bc792b9
fix(recording): stop burning the step budget on a join that cannot su…
EtienneLescot Aug 24, 2026
dbdadb7
chore(release): bump to 1.10.0 [skip ci]
github-actions[bot] Aug 24, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions crates/compositor/src/compositor_linux.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
3 changes: 3 additions & 0 deletions crates/compositor/src/compositor_macos.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
3 changes: 3 additions & 0 deletions crates/compositor/src/compositor_windows.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
12 changes: 12 additions & 0 deletions crates/compositor/src/scene.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>` 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<String>,
#[serde(default)]
pub animation: Option<String>,
}
Expand Down
125 changes: 124 additions & 1 deletion crates/compositor/src/text_linux.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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],
}
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -385,6 +416,7 @@ mod tests {
italic: false,
underline: false,
align: align.to_owned(),
valign: "center".to_owned(),
box_px: [400, 200],
}
}
Expand Down Expand Up @@ -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
Expand Down
87 changes: 83 additions & 4 deletions crates/compositor/src/text_macos.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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],
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand All @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -651,6 +691,7 @@ mod tests {
italic: false,
underline: false,
align: "center".into(),
valign: "center".into(),
box_px: [256, 256],
}
}
Expand Down Expand Up @@ -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;
Expand All @@ -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"
);
}
}
Loading
Loading