diff --git a/crates/compositor/src/compositor.rs b/crates/compositor/src/compositor.rs index 19a2e3885..11fa7b500 100644 --- a/crates/compositor/src/compositor.rs +++ b/crates/compositor/src/compositor.rs @@ -62,7 +62,32 @@ 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]); + } + // 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); @@ -78,6 +103,58 @@ 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> { + // `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 inner = after_name.strip_prefix('(')?.strip_suffix(')')?.trim(); + if inner.is_empty() { + return None; + } + Some(inner) +} + +/// `"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 (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(rgb[0], 255.0)?, + parse_color_channel(rgb[1], 255.0)?, + parse_color_channel(rgb[2], 255.0)?, + alpha, + ]) +} + +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 +3199,89 @@ 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(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] 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/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 ? ( 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"); + }); }); 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