From 9c1599a4e4ba17d3b07924c22c14f50077a3b849 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Tue, 28 Jul 2026 13:59:37 +0200 Subject: [PATCH 1/4] fix(export): keep the caption background plate through the native bridge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #178 — captions appear in the preview (DOM overlay + native compositor) but the exported video is missing the background plate, so depending on the recording's brightness the user reads it as 'no captions in the export'. Root cause: the caption inspector stores colour and opacity as two separate fields, and \captionBackgroundCss\ recombines them into a CSS string the editor overlay can render directly (e.g. \ gba(0, 0, 0, 0.55)\). The native side's \parse_hex\ only understood 3- or 6-char hex strings, so anything else — including the caption background — fell through to the \[0, 0, 0, 0]\ fallback (alpha 0, no plate). The text was drawn, the plate was not, and the contrast that made captions legible in the preview disappeared in the file. Fix: teach \parse_hex\ to accept the CSS surface area the JS bridge already produces — \ gba(...)\, \ gb(...)\, and \ ransparent\ — alongside the existing hex format. The fallback to None (and from there the caller's \[0,0,0,0]\) is preserved for everything that isn't a recognised colour, so a regression in any of the existing annotation colours still surfaces as a missing element rather than a wrong one. This also unblocks the gradient stop path, which has been sending the same \ gba(...)\ strings through \parse_hex\ for the same reason — they silently fell back to the colour preset. Not the issue's headline, but the same fix. The JS contract is now pinned by a test in \sceneDescription.test.ts\: the caption background leaves the bridge as \ gba(0, 0, 0, 0.55)\ exactly, and the text colour remains the ColorField hex (\#ffffff\). A future rewrite of either side that drops the alpha will fail this test instead of silently regressing the user-visible output. --- crates/compositor/src/compositor.rs | 138 +++++++++++++++++++++++++++- src/native/sceneDescription.test.ts | 14 +++ 2 files changed, 151 insertions(+), 1 deletion(-) diff --git a/crates/compositor/src/compositor.rs b/crates/compositor/src/compositor.rs index 19a2e3885..8b49f68bd 100644 --- a/crates/compositor/src/compositor.rs +++ b/crates/compositor/src/compositor.rs @@ -62,7 +62,26 @@ fn decode_data_uri(uri: &str) -> Option> { } fn parse_hex(s: &str) -> Option<[f32; 4]> { - let h = s.trim().trim_start_matches('#'); + // Le contrat accepte du CSS, pas seulement de l'hex : la bridge des captions produit du + // `rgba(r, g, b, a)` (l'inspector stocke couleur + opacité séparément, et `captionBackgroundCss` + // les recombine en rgba pour la preview) et les stops de gradient arrivent aussi sous cette + // forme. `transparent` est un cas particulier documenté : alpha 0, pas de plaque. Tout le + // reste tombe sur None → l'appelant applique son fallback (alpha 0 pour un fond, alpha 1 + // pour un texte, etc.) — la même sémantique qu'avant l'ajout du parseur rgba. + let trimmed = s.trim(); + if trimmed.eq_ignore_ascii_case("transparent") { + return Some([0.0, 0.0, 0.0, 0.0]); + } + if let Some(inner) = strip_color_fn(trimmed, "rgba") { + // `rgba(...)` exige 4 composantes : un `rgba(0, 0, 0)` à 3 args ne correspond à rien + // en CSS (c'est une erreur d'auteur) et on préfère le signaler que de l'avaler comme + // noir opaque. Le `parse_rgba_components` ci-dessous ne valide que ce format strict. + return parse_rgba_components(inner); + } + if let Some(inner) = strip_color_fn(trimmed, "rgb") { + return parse_rgb_components(inner); + } + let h = trimmed.trim_start_matches('#'); let (r, g, b) = match h.len() { 3 => { let d = |i: usize| u8::from_str_radix(&h[i..=i], 16).ok().map(|v| v * 17); @@ -78,6 +97,67 @@ fn parse_hex(s: &str) -> Option<[f32; 4]> { Some([r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0, 1.0]) } +/// `rgba(0, 0, 0, 0.55)` → `"0, 0, 0, 0.55"` (le contenu entre les parenthèses), None si +/// l'enveloppe n'est pas de la forme `fn(...)`. Tolère les espaces et les tabs, refuse les +/// virgules finales et les arguments vides — le gradient parser a déjà démontré que la couche +/// application produit des chaînes propres, donc rester strict ici évite d'avaler des CSS +/// tordus qu'on ne maîtrise pas. La casse du préfixe est libre (`RGBA(...)` est valide) parce +/// que CSS le permet. +fn strip_color_fn<'a>(s: &'a str, name: &str) -> Option<&'a str> { + if s.len() < name.len() + 2 { + return None; + } + if !s[..name.len()].eq_ignore_ascii_case(name) { + return None; + } + let after_name = &s[name.len()..]; + let open = after_name.strip_prefix('(')?; + let inner = open.strip_suffix(')')?.trim(); + if inner.is_empty() { + return None; + } + Some(inner) +} + +/// `"r, g, b, a"` (4 floats 0..255 pour r/g/b, 0..1 pour a) → `[r, g, b, a]` en 0..1. +/// Strict : 4 composantes exactement, sinon None. Tolère les espaces autour des virgules, +/// pas les pourcentages : le gradient parser n'envoie pas de `rgb(50%, …)` et les couches UI +/// qui le font n'arrivent pas ici (les couleurs wallpaper passent par une autre route, cf. +/// `parseWallpaper`). +fn parse_rgba_components(s: &str) -> Option<[f32; 4]> { + let parts: Vec<&str> = s.split(',').map(str::trim).collect(); + let [r, g, b, a] = parts.as_slice() else { return None; }; + Some([ + parse_color_channel(r, 255.0)?, + parse_color_channel(g, 255.0)?, + parse_color_channel(b, 255.0)?, + // L'alpha est déjà sur [0..1] par convention (`rgba(...,0.55)`, pas `rgba(...,55)`). + parse_color_channel(a, 1.0)?, + ]) +} + +/// `"r, g, b"` (3 floats 0..255) → `[r, g, b, 1]`. Mêmes règles que `parse_rgba_components` +/// pour les espaces et le refus des pourcentages, mais l'alpha est forcée à 1 (opaque) — +/// `rgb(…)` n'a pas de canal alpha en CSS. +fn parse_rgb_components(s: &str) -> Option<[f32; 4]> { + let parts: Vec<&str> = s.split(',').map(str::trim).collect(); + let [r, g, b] = parts.as_slice() else { return None; }; + Some([ + parse_color_channel(r, 255.0)?, + parse_color_channel(g, 255.0)?, + parse_color_channel(b, 255.0)?, + 1.0, + ]) +} + +fn parse_color_channel(raw: &str, max: f32) -> Option { + let n: f32 = raw.parse().ok()?; + if !n.is_finite() || n < 0.0 || n > max { + return None; + } + Some(n / max) +} + /// Rect source après crop puis zoom, dans les UV de la texture D3D. `u_max`/`v_max` /// excluent le padding NV12 ; le crop reste donc exprimé dans le frame visible (0..1), /// comme `VirtualPreview.cropVideoStyle`, puis le focus du zoom est remappé dans ce crop. @@ -3122,6 +3202,62 @@ mod tests { assert_eq!(decode_data_uri("data:image/png;base64,SGkh").unwrap(), b"Hi!".to_vec()); } + /// L'inspector stocke les couleurs de caption comme `couleur_hex` + `opacité` puis la + /// bridge JS recombine en `rgba(r, g, b, a)` pour la preview. Le natif doit rendre la même + /// plaque (couleur et opacité) — sinon le calque disparaît silencieusement et la caption + /// n'apparaît qu'en texte brut dans l'export. C'était exactement le bug de l'issue #178. + #[test] + fn parse_hex_understands_rgba_caption_backgrounds() { + let parsed = parse_hex("rgba(0, 0, 0, 0.55)").expect("rgba doit parser"); + assert!((parsed[3] - 0.55).abs() < 1e-6, "alpha 0.55 transmise, pas tombée à 0"); + assert_eq!([parsed[0], parsed[1], parsed[2]], [0.0, 0.0, 0.0]); + } + + /// `rgb(...)` sans alpha est sémantiquement `rgba(..., 1)` — il faut le supporter pour + /// qu'un inspector qui n'expose pas d'opacité n'écrive pas un fond invisible. + #[test] + fn parse_hex_treats_rgb_as_opaque() { + let parsed = parse_hex("rgb(255, 128, 0)").expect("rgb doit parser"); + assert_eq!(parsed, [1.0, 128.0 / 255.0, 0.0, 1.0]); + } + + /// Le cas "transparent" est documenté dans le code d'appel : on garde la sémantique + /// historique (alpha 0) — la plaque est sautée côté rastérisation, ce qui est exactement ce + /// que veut le CSS. Le nouveau parseur ne doit pas le casser. + #[test] + fn parse_hex_keeps_transparent_at_alpha_zero() { + assert_eq!(parse_hex("transparent"), Some([0.0, 0.0, 0.0, 0.0])); + // La casse ne doit pas non plus casser : CSS autorise `TRANSPARENT` en théorie, et + // refuse une chaîne qui ressemble à un rgba mal formé. + assert_eq!(parse_hex("Transparent"), Some([0.0, 0.0, 0.0, 0.0])); + assert_eq!(parse_hex("rgba(0, 0, 0, 0)"), Some([0.0, 0.0, 0.0, 0.0])); + } + + /// Le contrat historique `#rrggbb` / `rrggbb` ne doit pas régresser : les annotations + /// normales (saisies via `ColorField`) ne passent que par ce chemin, et leurs snapshots + /// ne pardonneraient pas un changement d'alpha implicite. + #[test] + fn parse_hex_still_understands_hex_colours() { + assert_eq!(parse_hex("#fff"), Some([1.0, 1.0, 1.0, 1.0])); + assert_eq!(parse_hex("#000000"), Some([0.0, 0.0, 0.0, 1.0])); + assert_eq!( + parse_hex("ff8800"), + Some([1.0, 136.0 / 255.0, 0.0, 1.0]) + ); + } + + /// Hors-format (channel > 255, chaîne vide, named color) → None → l'appelant retombe sur + /// son fallback. C'est la même politique qu'avant l'ajout du parseur rgba, on la garde + /// explicite pour qu'elle ne dérive pas. + #[test] + fn parse_hex_rejects_malformed_colours() { + assert_eq!(parse_hex(""), None); + assert_eq!(parse_hex("not-a-color"), None); + assert_eq!(parse_hex("rgba(0, 0, 0)"), None); // alpha manquante + assert_eq!(parse_hex("rgba(256, 0, 0, 1)"), None); // canal >255 + assert_eq!(parse_hex("rgba(0, 0, 0, 1.5)"), None); // alpha >1 + } + #[test] fn ignores_padding_and_line_breaks_inside_the_payload() { // Un URI replié ou paddé doit décoder à l'identique : les caractères hors alphabet sont diff --git a/src/native/sceneDescription.test.ts b/src/native/sceneDescription.test.ts index e6218ff3d..0881e133d 100644 --- a/src/native/sceneDescription.test.ts +++ b/src/native/sceneDescription.test.ts @@ -1520,4 +1520,18 @@ describe("buildSceneDescription.captions", () => { // 48px authored against a 1080-high frame. expect(scene.annotations[0].text?.fontSizeRel).toBeCloseTo(48 / 1080, 6); }); + + // Issue #178 — captions rendered as text without the background plate, because the JS + // bridge ships `rgba(...)` (CSS string from `captionBackgroundCss`) and the native side's + // colour parser only understood hex. The plate disappeared, the user saw floating white + // text, and reported "no captions in the export". This test pins the JS contract so a + // future rewrite of either side can't silently drop the alpha again. + it("ships the caption background as a parseable CSS colour, not a hex", () => { + const scene = buildSceneDescription(docWithCaptions(true)); + const text = scene.annotations[0].text; + expect(text?.backgroundColor).toBe("rgba(0, 0, 0, 0.55)"); + // The text colour is independently chosen via ColorField (hex); the background is the + // only piece that goes through the opacity-combining path. + expect(text?.color).toBe("#ffffff"); + }); }); From fdc0cb2789e1ed534077f163104a1693b0b02564 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Tue, 28 Jul 2026 18:48:14 +0200 Subject: [PATCH 2/4] fix(compositor): stop the colour parser panicking on non-ASCII, and follow CSS arity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-up on the caption-plate fix. `strip_color_fn` sliced `s[..name.len()]` without checking the UTF-8 char boundary, so any colour string whose byte 3 or 4 lands mid-character aborted the process instead of returning None. `parseWallpaper` hands every `#`-prefixed string straight through to `SceneBackground::Color`, so `#ab€cd` was enough to take out the render loop — and a panic crossing the N-API bridge is exactly what this parser's None-then-caller-fallback contract exists to avoid. Taking the tail with `get` first proves the boundary, which makes the head slice safe by construction and makes the old length guard redundant. The hex path had the same latent bug independently (`h[i..=i]` / `h[0..2]` on a body of exactly 3 or 6 bytes, e.g. `éa` or `€€`); an `is_ascii` guard closes it before any slicing happens. Both predate the rgba work, which only widened the first one's reach. Also collapses the two component parsers into one. CSS Color 4 makes `rgb()` and `rgba()` synonyms, both taking 3 or 4 components, so rejecting `rgba(0, 0, 0)` was non-standard — and it failed the same silent way #178 did, by falling through to the caller's alpha-0 fallback and painting no plate at all. Tests: 83/83 in the compositor lib (81 before, +2 here). --- crates/compositor/src/compositor.rs | 102 +++++++++++++++++----------- 1 file changed, 63 insertions(+), 39 deletions(-) diff --git a/crates/compositor/src/compositor.rs b/crates/compositor/src/compositor.rs index 8b49f68bd..11fa7b500 100644 --- a/crates/compositor/src/compositor.rs +++ b/crates/compositor/src/compositor.rs @@ -72,16 +72,22 @@ fn parse_hex(s: &str) -> Option<[f32; 4]> { if trimmed.eq_ignore_ascii_case("transparent") { return Some([0.0, 0.0, 0.0, 0.0]); } - if let Some(inner) = strip_color_fn(trimmed, "rgba") { - // `rgba(...)` exige 4 composantes : un `rgba(0, 0, 0)` à 3 args ne correspond à rien - // en CSS (c'est une erreur d'auteur) et on préfère le signaler que de l'avaler comme - // noir opaque. Le `parse_rgba_components` ci-dessous ne valide que ce format strict. - return parse_rgba_components(inner); - } - if let Some(inner) = strip_color_fn(trimmed, "rgb") { + // CSS Color 4 fait de `rgb()` et `rgba()` des synonymes : les deux acceptent 3 ou 4 + // composantes. On les traite donc par le même chemin plutôt que d'imposer une arité par + // nom — refuser `rgba(0, 0, 0)` ne « signalerait » rien d'utile, ça retomberait sur le + // fallback de l'appelant, c'est-à-dire une plaque invisible : exactement le bug #178. + if let Some(inner) = + strip_color_fn(trimmed, "rgba").or_else(|| strip_color_fn(trimmed, "rgb")) + { return parse_rgb_components(inner); } let h = trimmed.trim_start_matches('#'); + // Un corps hex est ASCII par définition, et les découpes par octet ci-dessous (`h[i..=i]`, + // `h[0..2]`…) paniqueraient au milieu d'un caractère multi-octets qui ferait pile 3 ou 6 + // octets (`éa`, `€€`). On refuse avant de découper. + if !h.is_ascii() { + return None; + } let (r, g, b) = match h.len() { 3 => { let d = |i: usize| u8::from_str_radix(&h[i..=i], 16).ok().map(|v| v * 17); @@ -104,49 +110,40 @@ fn parse_hex(s: &str) -> Option<[f32; 4]> { /// tordus qu'on ne maîtrise pas. La casse du préfixe est libre (`RGBA(...)` est valide) parce /// que CSS le permet. fn strip_color_fn<'a>(s: &'a str, name: &str) -> Option<&'a str> { - if s.len() < name.len() + 2 { - return None; - } + // `get` rend None si `name.len()` n'est pas une frontière de caractère : c'est ce qui rend + // le slice `s[..name.len()]` juste en dessous sûr par construction. Un `&s[..n]` direct + // paniquerait au milieu d'un caractère multi-octets (`#ab€cd` coupe dans le `€`), et une + // panique traverserait le pont N-API au lieu de retomber sur le fallback de l'appelant — + // le contraire de ce que ce parseur promet. + let after_name = s.get(name.len()..)?; if !s[..name.len()].eq_ignore_ascii_case(name) { return None; } - let after_name = &s[name.len()..]; - let open = after_name.strip_prefix('(')?; - let inner = open.strip_suffix(')')?.trim(); + let inner = after_name.strip_prefix('(')?.strip_suffix(')')?.trim(); if inner.is_empty() { return None; } Some(inner) } -/// `"r, g, b, a"` (4 floats 0..255 pour r/g/b, 0..1 pour a) → `[r, g, b, a]` en 0..1. -/// Strict : 4 composantes exactement, sinon None. Tolère les espaces autour des virgules, -/// pas les pourcentages : le gradient parser n'envoie pas de `rgb(50%, …)` et les couches UI -/// qui le font n'arrivent pas ici (les couleurs wallpaper passent par une autre route, cf. -/// `parseWallpaper`). -fn parse_rgba_components(s: &str) -> Option<[f32; 4]> { - let parts: Vec<&str> = s.split(',').map(str::trim).collect(); - let [r, g, b, a] = parts.as_slice() else { return None; }; - Some([ - parse_color_channel(r, 255.0)?, - parse_color_channel(g, 255.0)?, - parse_color_channel(b, 255.0)?, - // L'alpha est déjà sur [0..1] par convention (`rgba(...,0.55)`, pas `rgba(...,55)`). - parse_color_channel(a, 1.0)?, - ]) -} - -/// `"r, g, b"` (3 floats 0..255) → `[r, g, b, 1]`. Mêmes règles que `parse_rgba_components` -/// pour les espaces et le refus des pourcentages, mais l'alpha est forcée à 1 (opaque) — -/// `rgb(…)` n'a pas de canal alpha en CSS. +/// `"r, g, b"` ou `"r, g, b, a"` (floats 0..255 pour r/g/b, 0..1 pour a) → `[r, g, b, a]` en +/// 0..1, l'alpha valant 1 (opaque) quand elle est absente. Toute autre arité → None. Tolère +/// les espaces autour des virgules, pas les pourcentages : le gradient parser n'envoie pas de +/// `rgb(50%, …)` et les couches UI qui le font n'arrivent pas ici (les couleurs wallpaper +/// passent par une autre route, cf. `parseWallpaper`). fn parse_rgb_components(s: &str) -> Option<[f32; 4]> { let parts: Vec<&str> = s.split(',').map(str::trim).collect(); - let [r, g, b] = parts.as_slice() else { return None; }; + let (rgb, alpha) = match parts.as_slice() { + [r, g, b] => ([r, g, b], 1.0), + // L'alpha est déjà sur [0..1] par convention (`rgba(...,0.55)`, pas `rgba(...,55)`). + [r, g, b, a] => ([r, g, b], parse_color_channel(a, 1.0)?), + _ => return None, + }; Some([ - parse_color_channel(r, 255.0)?, - parse_color_channel(g, 255.0)?, - parse_color_channel(b, 255.0)?, - 1.0, + parse_color_channel(rgb[0], 255.0)?, + parse_color_channel(rgb[1], 255.0)?, + parse_color_channel(rgb[2], 255.0)?, + alpha, ]) } @@ -3253,9 +3250,36 @@ mod tests { fn parse_hex_rejects_malformed_colours() { assert_eq!(parse_hex(""), None); assert_eq!(parse_hex("not-a-color"), None); - assert_eq!(parse_hex("rgba(0, 0, 0)"), None); // alpha manquante assert_eq!(parse_hex("rgba(256, 0, 0, 1)"), None); // canal >255 assert_eq!(parse_hex("rgba(0, 0, 0, 1.5)"), None); // alpha >1 + assert_eq!(parse_hex("rgba(0, 0, 0, 0.5, 1)"), None); // 5 composantes + assert_eq!(parse_hex("rgb(0, 0)"), None); // 2 composantes + } + + /// CSS Color 4 : `rgb()` et `rgba()` sont synonymes, les deux prennent 3 ou 4 composantes. + /// Une couleur bien formée ne doit pas finir sur le fallback de l'appelant — pour un fond + /// c'est alpha 0, donc une plaque invisible, soit très exactement le symptôme de #178. + #[test] + fn parse_hex_accepts_both_arities_on_both_names() { + assert_eq!(parse_hex("rgba(0, 0, 0)"), Some([0.0, 0.0, 0.0, 1.0])); + assert_eq!(parse_hex("rgb(0, 0, 0, 0.5)"), Some([0.0, 0.0, 0.0, 0.5])); + } + + /// Une couleur non-ASCII doit être refusée, pas paniquer : `strip_color_fn` découpait + /// `s[..3]` / `s[..4]` sans vérifier la frontière de caractère, donc `#ab€cd` (le `€` occupe + /// les octets 3..6) tuait le process au lieu de retomber sur le fallback. `parseWallpaper` + /// laisse passer n'importe quelle chaîne préfixée `#` jusqu'ici, une panique côté natif + /// traverserait le pont N-API et emporterait l'export. + #[test] + fn parse_hex_refuses_non_ascii_without_panicking() { + assert_eq!(parse_hex("#ab€cd"), None); + assert_eq!(parse_hex("rg€(0, 0, 0)"), None); + assert_eq!(parse_hex("é"), None); + assert_eq!(parse_hex("🎨🎨"), None); + // Le chemin hex découpe par octet sur les longueurs 3 et 6 : `éa` fait 3 octets et + // `€€` en fait 6, donc les deux tombaient pile sur une découpe intra-caractère. + assert_eq!(parse_hex("éa"), None); + assert_eq!(parse_hex("€€"), None); } #[test] From 8f69a346eb79590c86f3a03a9646d09ee55bce11 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Tue, 28 Jul 2026 19:34:52 +0200 Subject: [PATCH 3/4] fix(preview): stop drawing captions twice in the preview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The preview showed every caption twice, most visibly at widths where the two copies wrapped differently — one broke to a second line, the other did not. `PreviewCanvas` hosts the native D3D canvas as "the sole pixel source" (its own header comment) with the DOM overlays kept interactive-only. `CaptionLayer` was not interactive: `aria-hidden`, `pointerEvents: none`, and a visible `` painting the cue text and its background plate. Meanwhile the native compositor already draws the same cue, because captions are emitted into the scene as ordinary text annotations. Two painters, two line-breakers — CSS `word-break`/`pre-wrap` against DirectWrite laying out into `box_px` — so the same string wrapped at two different points. The component's own comment explains how it got here: it "mirrors annotationRenderer.renderText's box model ... so what the preview shows is what the export writes". That mirror was right when the preview was DOM-drawn and the exporter was a separate renderer; it became a duplicate once the native compositor started drawing the preview too. Deleted rather than gated: there is no non-native preview path left to fall back to (`VITE_NATIVE_COMPOSITOR` is read nowhere in the tree, and `NativeCompositorOverlay` mounts unconditionally). Dropping the DOM copy also makes preview and export agree by construction instead of by two implementations of the same box model. `useCaptions` stays — `CaptionsPane` still uses it. Pre-existing, not introduced by the plate fix: the native side always drew the caption text, it just drew it without a plate, so the duplicate read as a faint ghost. Restoring the plate made both copies solid and the doubling obvious. Tests: 96 files / 1061 pass, tsc clean, biome clean. --- src/components/ai-edition/CaptionLayer.tsx | 98 --------------------- src/components/ai-edition/PreviewCanvas.tsx | 10 --- 2 files changed, 108 deletions(-) delete mode 100644 src/components/ai-edition/CaptionLayer.tsx diff --git a/src/components/ai-edition/CaptionLayer.tsx b/src/components/ai-edition/CaptionLayer.tsx deleted file mode 100644 index db2914cf6..000000000 --- a/src/components/ai-edition/CaptionLayer.tsx +++ /dev/null @@ -1,98 +0,0 @@ -// The caption overlay in the preview. -// -// Deliberately NOT an `AnnotationOverlay`: a caption has no per-item identity to -// select, nothing to drag and nothing to resize — its placement comes from the -// caption settings, which the inspector owns. So this is a plain, pointer-events -// -none band, which also means it can never steal a click from an annotation -// sitting underneath it. -// -// It mirrors `annotationRenderer.renderText`'s box model (centred band, text -// vertically centred, per-line background plate, 1.4 line-height) so what the -// preview shows is what the export writes. - -import { useMemo } from "react"; -import { annotationFontSizePx } from "@/lib/ai-edition/annotationScale"; -import { - type CaptionCue, - type CaptionSettings, - captionBackgroundCss, - captionBandRect, - captionCueAt, -} from "@/lib/ai-edition/captions"; - -interface CaptionLayerProps { - cues: CaptionCue[]; - settings: CaptionSettings; - /** Playhead in virtual (timeline) seconds — the same clock the cues use. */ - currentTimeSec: number; - containerWidth: number; - containerHeight: number; -} - -export function CaptionLayer({ - cues, - settings, - currentTimeSec, - containerWidth, - containerHeight, -}: CaptionLayerProps) { - const currentTimeMs = Math.round(currentTimeSec * 1000); - const cue = useMemo(() => captionCueAt(cues, currentTimeMs), [cues, currentTimeMs]); - - if (!settings.enabled || !cue || containerWidth <= 0 || containerHeight <= 0) return null; - - const rect = captionBandRect(settings); - const background = captionBackgroundCss(settings); - - return ( -
- - {cue.text} - -
- ); -} diff --git a/src/components/ai-edition/PreviewCanvas.tsx b/src/components/ai-edition/PreviewCanvas.tsx index 80800f309..99d73ba8c 100644 --- a/src/components/ai-edition/PreviewCanvas.tsx +++ b/src/components/ai-edition/PreviewCanvas.tsx @@ -40,7 +40,6 @@ import type { AxcutZoomRegion, } from "@/lib/ai-edition/schema"; import { useProjectStore } from "@/lib/ai-edition/store/projectStore"; -import { useCaptions } from "@/lib/ai-edition/store/useCaptions"; import { useEditorSettings } from "@/lib/ai-edition/store/useEditorSettings"; import { resolveActiveCameraTrack } from "@/lib/ai-edition/timeline/camera"; import { createPlaybackClockRef } from "@/lib/ai-edition/timeline/playback-clock"; @@ -55,7 +54,6 @@ import { classifyWallpaper, resolveImageWallpaperUrl } from "@/lib/wallpaper"; import { getCssClipPath } from "@/lib/webcamMaskShapes"; import { clamp, clamp01 } from "@/utils/math"; import { AnnotationLayer } from "./AnnotationLayer"; -import { CaptionLayer } from "./CaptionLayer"; import { NativeCompositorOverlay } from "./NativeCompositorOverlay"; import styles from "./NewEditorShell.module.css"; import { type VideoSource, VirtualPreview } from "./VirtualPreview"; @@ -110,7 +108,6 @@ export function PreviewCanvas(props: PreviewCanvasProps) { const { settings, setLive, commit } = useEditorSettings(); // Captions are derived from the transcript, not passed down as regions — the // preview reads them from the same façade the inspector writes to. - const { cues: captionCues, settings: captionSettings } = useCaptions(); const document = useProjectStore((s) => s.document); const assets = document?.assets ?? []; const frameRef = useRef(null); @@ -442,13 +439,6 @@ export function PreviewCanvas(props: PreviewCanvasProps) { onCommit={props.onAnnotationCommit} /> ) : null} - ) : null} {layout?.webcamRect && showWebcamSlot ? ( From 4b1d0fab8cc9fb0bf07ca2aac85e629bb6bc908a Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Tue, 28 Jul 2026 19:40:47 +0200 Subject: [PATCH 4/4] docs(captions): fold the two render paths into the one that survived MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The caption doc still described a DOM preview painter alongside the native exporter, and linked to `CaptionLayer.tsx`, which no longer exists — the docs check failed on the dead link. Rewritten around what is actually there: one path, cues -> synthetic text regions -> annotation plumbing -> native compositor, which draws preview and export alike. That is a stronger version of the property the old text was reaching for: preview and export cannot drift because they are the same renderer, not two box models kept in sync by hand. Kept a short note on why the DOM layer existed and why it went, so the next reader does not re-add a "preview overlay" to fix a perceived gap. Also recorded that `captionBackgroundCss` emits `rgba(...)`, which is what forces the native colour parser to accept CSS colours rather than hex only. `docs:check` OK (22 files). --- .../transcription-and-captions.md | 43 +++++++++++-------- 1 file changed, 25 insertions(+), 18 deletions(-) diff --git a/technical-documentation/architecture/transcription-and-captions.md b/technical-documentation/architecture/transcription-and-captions.md index 77962e341..1ae45bc0e 100644 --- a/technical-documentation/architecture/transcription-and-captions.md +++ b/technical-documentation/architecture/transcription-and-captions.md @@ -21,8 +21,8 @@ flowchart LR C -- "IPC return" --> B B --> F["AxcutTranscript
on document.transcripts[]"] F --> G["deriveCaptionCues
(src/lib/ai-edition/captions/cues.ts)"] - G -- "CaptionCue[]
(virtual ms)" --> H["Preview overlay
(CaptionLayer.tsx)"] - G -- "synthetic text regions
(annotation path)" --> I["Export
(native compositor)"] + G -- "CaptionCue[]
(virtual ms)" --> H["captionCuesToTextRegions
(synthetic text regions)"] + H -- "annotation path
(scene description)" --> I["Native compositor
(preview AND export)"] ``` The renderer pieces — `transcribeMono16kToSegments` @@ -340,23 +340,30 @@ document — see the next subsection. ### Render paths -The cue list reaches two surfaces, designed to share the same code so -preview and export cannot drift: - -- **Preview** — [`src/components/ai-edition/CaptionLayer.tsx`](../../src/components/ai-edition/CaptionLayer.tsx) - paints the cue active at the current playhead inside a - pointer-events-none band. The per-line background plate uses - `boxDecorationBreak: clone` so each wrapped line gets its own plate — - the same trick the native export uses, so what the preview shows is - what the export draws. `zIndex: 60` keeps the caption above the - annotation overlay in the preview. -- **Export** — `captionCuesToTextRegions` +There is **one** render path. The cue list becomes synthetic text regions +that ride the annotation plumbing into the native compositor, which draws +both the preview and the export — so preview and export cannot drift, +because they are the same renderer rather than two implementations kept in +sync. + +> Until 2026-07-28 the preview had a second, DOM-based painter +> (`CaptionLayer.tsx`) that mirrored the exporter's box model. Once the +> native compositor took over the preview it became a duplicate: both +> painted the same cue, and because CSS `word-break` and DirectWrite break +> lines differently, the two copies wrapped at different points and the +> caption visibly doubled. The DOM layer was deleted; the native canvas is +> the sole pixel source (see [preview.md](preview.md)). + +- **Preview and export** — `captionCuesToTextRegions` ([`src/lib/ai-edition/captions/cues.ts:242`](../../src/lib/ai-edition/captions/cues.ts:242)) - converts the virtual-ms cue list into synthetic `AnnotationRegion`s, - using the same `captionBandRect` + `captionBackgroundCss` helpers as - the preview. Those regions ride the existing annotation path through - the scene description and onto the native compositor; the export has - no separate caption path of its own. `CAPTION_Z_INDEX_BASE = 100_000` + converts the virtual-ms cue list into synthetic `AnnotationRegion`s via + the `captionBandRect` + `captionBackgroundCss` helpers. Those regions + ride the existing annotation path through the scene description and onto + the native compositor; neither surface has a caption path of its own. + Note that `captionBackgroundCss` emits `rgba(...)` (it recombines the + inspector's separate colour and opacity fields), so the native colour + parser has to accept CSS colours and not just hex — that contract is + pinned by a test on each side. `CAPTION_Z_INDEX_BASE = 100_000` ([`src/lib/ai-edition/captions/cues.ts:39`](../../src/lib/ai-edition/captions/cues.ts:39)) gives the export even more clearance above real annotations, and the synthetic regions carry no `annotationSource` marker because they are