Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
162 changes: 161 additions & 1 deletion crates/compositor/src/compositor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,32 @@ fn decode_data_uri(uri: &str) -> Option<Vec<u8>> {
}

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);
Expand All @@ -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<f32> {
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.
Expand Down Expand Up @@ -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
Expand Down
98 changes: 0 additions & 98 deletions src/components/ai-edition/CaptionLayer.tsx

This file was deleted.

10 changes: 0 additions & 10 deletions src/components/ai-edition/PreviewCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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";
Expand Down Expand Up @@ -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<HTMLDivElement | null>(null);
Expand Down Expand Up @@ -442,13 +439,6 @@ export function PreviewCanvas(props: PreviewCanvasProps) {
onCommit={props.onAnnotationCommit}
/>
) : null}
<CaptionLayer
cues={captionCues}
settings={captionSettings}
currentTimeSec={props.currentTimeSec}
containerWidth={layout.screenRect.width}
containerHeight={layout.screenRect.height}
/>
</div>
) : null}
{layout?.webcamRect && showWebcamSlot ? (
Expand Down
14 changes: 14 additions & 0 deletions src/native/sceneDescription.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@ flowchart LR
C -- "IPC return" --> B
B --> F["AxcutTranscript<br/>on document.transcripts[]"]
F --> G["deriveCaptionCues<br/>(src/lib/ai-edition/captions/cues.ts)"]
G -- "CaptionCue[]<br/>(virtual ms)" --> H["Preview overlay<br/>(CaptionLayer.tsx)"]
G -- "synthetic text regions<br/>(annotation path)" --> I["Export<br/>(native compositor)"]
G -- "CaptionCue[]<br/>(virtual ms)" --> H["captionCuesToTextRegions<br/>(synthetic text regions)"]
H -- "annotation path<br/>(scene description)" --> I["Native compositor<br/>(preview AND export)"]
```

The renderer pieces — `transcribeMono16kToSegments`
Expand Down Expand Up @@ -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
Expand Down
Loading