From 223658022ffaf793119b3bc462b9fc85472ee43e Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Sat, 17 Oct 2026 16:16:22 +0200 Subject: [PATCH 1/4] fix(compositor): tilt the whole card under a filled format Under Format > Follow cursor, a tilted zoom (orbit camera, iso, left, right) cut the card's corners flat. Format fill reused the block-layout slot mask, whose rule is "the container stays flat, the footage tilts inside": the plane of the whole recording tilted and the invisible, axis-aligned slot rect clipped it. The orbit camera also framed that whole-recording plane rather than the card. Under a filled format with a tilt, the box is now the slot, the drawn cut is the zoomed window a flat fill zoom shows, and nothing masks it. The camera films the card at zoom 1, contained in its slot over the whole orbit, and its aim frames the zoom inside the card. Flat fill zooms and side-by-side / stacked layouts are unchanged. Introduced with format fill (51fc97404, v2.0.0). --- crates/compositor/src/frame_geometry.rs | 139 ++++++++++++++--- .../tests/format_fill_tilt_render.rs | 142 ++++++++++++++++++ 2 files changed, 261 insertions(+), 20 deletions(-) create mode 100644 crates/compositor/tests/format_fill_tilt_render.rs diff --git a/crates/compositor/src/frame_geometry.rs b/crates/compositor/src/frame_geometry.rs index 2988aabc2..45909be89 100644 --- a/crates/compositor/src/frame_geometry.rs +++ b/crates/compositor/src/frame_geometry.rs @@ -3315,17 +3315,33 @@ pub fn plan_frame(input: &FrameGeometryInput) -> FrameGeometry { // ce correctif ; la coupe DESSINÉE ne porte plus que le crop. `remap_box` reporte la // seconde à travers le mapping de la première, ce qui conserve le cadrage exact. // Le focus courant reste volontairement utilisé pour la frame précédente, comme avant. + // + // Sous un plan incliné (angle fixe ou caméra réelle), le remplissage penche la CARTE : la + // boîte est le slot, la coupe la fenêtre zoomée qu'il montre, et rien ne la rogne. Le slot + // n'y est pas un conteneur qu'on voit, comme dans un bloc : rogner à son rect droit le plan + // de l'enregistrement entier y coupait des coins à plat. Sous la caméra réelle, c'est sa + // visée qui cadre le zoom (`regions::resolve_focus` laisse la boîte zoomer sur son centre). + let fill_tilt = + follow && (zoom_camera > 0.0 || !crate::regions::is_identity_rotation(zoom_rotation)); + let focus = if fill_tilt && zoom_camera > 0.0 { zoom_aim } else { p.focus }; let cut_ref = - cover(screen_source_rect(u_max, v_max, active_crop, p.zoom, p.focus), follow_at(source_t)); + cover(screen_source_rect(u_max, v_max, active_crop, p.zoom, focus), follow_at(source_t)); let cut_ref_prev = cover( - screen_source_rect(u_max, v_max, active_crop, pp.zoom, p.focus), + screen_source_rect(u_max, v_max, active_crop, pp.zoom, focus), follow_at(source_t_prev), ); // Sous le remplissage, la coupe dessinée est le crop ENTIER : la fenêtre bouge, et une // coupe dessinée qui ne la contiendrait plus laisserait un trou dans le slot. Le masque du // slot (`screen_mask`) rogne le reste. let crop_cut = screen_source_rect(u_max, v_max, active_crop, 1.0, p.focus); - let cut = if follow { crop_cut } else { cover(crop_cut, None) }; + // Le remplissage incliné dessine la fenêtre zoomée elle-même : sa boîte est le slot. + let cut = if fill_tilt { + cut_ref + } else if follow { + crop_cut + } else { + cover(crop_cut, None) + }; // Ce que l'écran montre au repos : la fenêtre couverte, suivie ou non. Le curseur, les coins // et l'ombre se mesurent sur elle, pas sur la coupe entière que le remplissage dessine. let window = cover(crop_cut, follow_at(source_t)); @@ -3356,24 +3372,38 @@ pub fn plan_frame(input: &FrameGeometryInput) -> FrameGeometry { // même report dans la coupe (un cover la rogne). La mise au point suit le pointeur lissé // (l'orbite), pas le point visé : celui-ci reste au centre au zoom 1 et bute sur sa portée // au zoom, là où le spectateur regarde le pointeur. - let camera = (zoom_camera > 0.0).then(|| crate::camera::CameraPose { - weight: zoom_camera, - aim: focus_in_cut(u_max, v_max, active_crop, zoom_aim, cut), - orbit: focus_in_cut(u_max, v_max, active_crop, zoom_orbit, cut), - zoom: p.zoom, - press, - }); + // Sous le remplissage incliné, la coupe est déjà la fenêtre zoomée : la caméra filme la + // carte au zoom 1, entière et contenue dans son slot depuis toute l'orbite + // (`camera::View::new`), et l'œil se place sur le pointeur dans l'enregistrement entier, + // où vit le poids de l'orbite (rapportée à la fenêtre, elle sauterait à l'entrée du zoom). + let pose = |weight: f32, aim: [f32; 2], orbit: [f32; 2], zoom: f32| { + if fill_tilt { + crate::camera::CameraPose { + weight, + aim: [0.5; 2], + orbit: focus_in_cut(u_max, v_max, active_crop, orbit, crop_cut), + zoom: 1.0, + press, + } + } else { + crate::camera::CameraPose { + weight, + aim: focus_in_cut(u_max, v_max, active_crop, aim, cut), + orbit: focus_in_cut(u_max, v_max, active_crop, orbit, cut), + zoom, + press, + } + } + }; + let camera = (zoom_camera > 0.0).then(|| pose(zoom_camera, zoom_aim, zoom_orbit, p.zoom)); // La caméra réelle de la frame d'avant, dès qu'elle y était active — y compris quand elle // ne l'est plus à celle-ci (fin de sa moitié d'une transition vers un angle fixe) ; `None` // quand elle ne l'était pas, et le mode 8 reprend alors la rotation d'avant. - let camera_prev = (zoom_camera_prev > 0.0).then(|| crate::camera::CameraPose { - weight: zoom_camera_prev, - aim: focus_in_cut(u_max, v_max, active_crop, zoom_aim_prev, cut), - orbit: focus_in_cut(u_max, v_max, active_crop, zoom_orbit_prev, cut), - zoom: pp.zoom, - press, - }); + let camera_prev = (zoom_camera_prev > 0.0) + .then(|| pose(zoom_camera_prev, zoom_aim_prev, zoom_orbit_prev, pp.zoom)); let focus_plane = match camera { + // Le pointeur sur la carte, là où l'orbite du remplissage n'est pas dans la coupe. + Some(_) if fill_tilt => focus_in_cut(u_max, v_max, active_crop, zoom_orbit, cut), Some(pose) => pose.orbit, None => focus_in_cut(u_max, v_max, active_crop, p.focus, cut), }; @@ -3490,7 +3520,8 @@ pub fn plan_frame(input: &FrameGeometryInput) -> FrameGeometry { // l'app le lève pour ces deux layouts, sur un clip qui a une caméra, et pour le // remplissage du format, qui exclut les cadres (`formatFillAvailability`). Un cadre sous // un slot est donc toujours dans un bloc : il y devient le conteneur, au repos autour. - let screen_mask = scene.is_some_and(|s| s.layout.screen_cover).then(|| { + // Le remplissage incliné n'a pas de masque : sa boîte est le slot, et la carte penche entière. + let screen_mask = (scene.is_some_and(|s| s.layout.screen_cover) && !fill_tilt).then(|| { let slot_px = [s_base[2] * rw, s_base[3] * rh]; ScreenMask { rect: s_base, @@ -6348,6 +6379,11 @@ mod tests { /// Un clip 16:9 dans une sortie 9:16 remplie (`screenCover` + `screenFollow`) : la boîte écran /// est la zone paddée entière, `follow` décide si la fenêtre suit le pointeur. fn fill_plan(follow: bool, pointer: Option<(f32, f32)>) -> FrameGeometry { + fill_plan_with(follow, pointer, "[]") + } + + /// `fill_plan`, avec ces `zoomRegions`. + fn fill_plan_with(follow: bool, pointer: Option<(f32, f32)>, zoom_regions: &str) -> FrameGeometry { let scene = Scene::from_json(&format!( r##"{{ "clips":[{{"screenPath":"/s.mp4","webcamPath":"","sourceStartSec":0,"sourceEndSec":10,"webcamOffsetSec":0,"hasAudio":false}}], @@ -6356,8 +6392,8 @@ mod tests { "screenCover":true,"screenFollow":{follow}}}, "effects":{{"padding":0.5,"blur":false,"shadow":0.5,"roundnessFrac":0.02,"motionBlur":0}}, "background":{{"kind":"color","color":"#1e1e2e"}}, - "zoomRegions":[], - "cursor":{{"show":false,"size":1,"smoothing":0,"motionBlur":0,"clickBounce":1,"clipToBounds":false,"theme":"default"}}, + "zoomRegions":{zoom_regions}, + "cursor":{{"show":true,"size":1,"smoothing":0,"motionBlur":0,"clickBounce":1,"clipToBounds":false,"theme":"default"}}, "cropByClip":[null], "output":{{"width":1080,"height":1920,"fps":60}} }}"## @@ -6408,6 +6444,69 @@ mod tests { } } + /// Le remplissage sous un plan incliné, angle fixe ou caméra en orbite : c'est la CARTE qui + /// penche, entière, ses quatre coins arrondis. Son slot n'est pas un conteneur qu'on voit, + /// comme celui d'un bloc : y rogner le plan de l'enregistrement entier coupait des coins à plat + /// (v2.0.0 à v2.1.0). + /// + /// * aucun masque ne rogne le plan, et sa boîte est le slot ; + /// * la carte montre ce qu'un zoom à plat y montre : la même fenêtre zoomée ; + /// * le plan tient dans la sortie, centré sur le slot ; + /// * à plat, rien ne change : le slot masque toujours la boîte zoomée. + #[test] + fn a_tilted_zoom_tilts_the_whole_filled_card() { + let render = [1080.0f32, 1920.0]; + let zoom = |rotation: &str| { + format!(r#"[{{"clipIndex":0,"startSec":0.0,"endSec":5.0,"scale":2.0,"focusX":0.3,"focusY":0.6,"rotation":{rotation}}}]"#) + }; + let pointer = Some((0.85, 0.8)); + // La fenêtre que la carte montre, en UV : le slot reporté par s_dst → cut. + let seen = |g: &FrameGeometry| -> [f32; 4] { + let (s, d, c) = (g.s_ann, g.s_dst, g.cut); + let x = |f: f32| c[0] + (f - d[0]) / d[2] * (c[2] - c[0]); + let y = |f: f32| c[1] + (f - d[1]) / d[3] * (c[3] - c[1]); + [x(s[0]), y(s[1]), x(s[0] + s[2]), y(s[1] + s[3])] + }; + for (rotation, flat_rotation) in [ + (r#""iso""#, "null"), + (r#""right""#, "null"), + (ORBIT, r#"null,"focusMode":"auto""#), + ] { + let flat = fill_plan_with(true, pointer, &zoom(flat_rotation)); + assert!(!flat.tilted(), "{rotation} : garde, le zoom à plat est droit"); + assert!(flat.screen_mask.is_some(), "{rotation} : à plat, le slot masque la boîte zoomée"); + assert!(!contains(flat.s_ann, flat.s_dst, 1e-3), "{rotation} : garde, la boîte à plat déborde"); + + let g = fill_plan_with(true, pointer, &zoom(rotation)); + assert!(g.tilted(), "{rotation} : garde, l'écran doit pencher"); + assert_eq!(g.screen_mask, None, "{rotation} : un rect droit rogne la carte inclinée"); + for k in 0..4 { + assert!((g.s_dst[k] - g.s_ann[k]).abs() < 1e-5, "{rotation} : boîte {:?}, slot {:?}", g.s_dst, g.s_ann); + } + let (w, wf) = (seen(&g), seen(&flat)); + for k in 0..4 { + assert!((w[k] - wf[k]).abs() < 1e-4, "{rotation} : fenêtre {w:?}, à plat {wf:?}"); + } + + let s_px = [g.s_dst[2] * render[0], g.s_dst[3] * render[1]]; + let quad = g.screen_tilt(s_px).expect("incliné"); + let c = g.screen_center_px(render); + let slot_c = [(g.s_ann[0] + g.s_ann[2] * 0.5) * render[0], (g.s_ann[1] + g.s_ann[3] * 0.5) * render[1]]; + let mut mean = [0.0f32; 2]; + for &(x, y) in &quad.corners { + let (x, y) = (c[0] + x, c[1] + y); + assert!((0.0..=render[0]).contains(&x) && (0.0..=render[1]).contains(&y), "{rotation} : coin ({x}, {y}) hors de la sortie"); + mean = [mean[0] + x * 0.25, mean[1] + y * 0.25]; + } + let off = (mean[0] - slot_c[0]).hypot(mean[1] - slot_c[1]); + assert!(off < 0.1 * s_px[0].min(s_px[1]), "{rotation} : carte à {off} px du slot"); + + let cb = tilted_screen_cb(&quad, s_px, c, g.cut, g.focus_plane, g.s_radius, 0.0, false, render, g.screen_mask, None); + assert_eq!(cb.color[3], 0.0, "{rotation} : aucun slot ne rogne le plan"); + assert!(cb.radius_px > 1.0, "{rotation} : coins arrondis attendus, rayon {}", cb.radius_px); + } + } + /// `slot_scene` à 1,5 s (zoom tenu), pointeur garé en `pointer` (la caméra en orbite le suit). fn slot_plan(scene: &Scene, pointer: Option<(f32, f32)>) -> FrameGeometry { let cfg = crate::config::all().pop().expect("au moins une config"); diff --git a/crates/compositor/tests/format_fill_tilt_render.rs b/crates/compositor/tests/format_fill_tilt_render.rs new file mode 100644 index 000000000..2adb8b15d --- /dev/null +++ b/crates/compositor/tests/format_fill_tilt_render.rs @@ -0,0 +1,142 @@ +//! Le remplissage du format sous la caméra en orbite, rendu par le vrai compositeur D3D11. +//! +//! Un enregistrement 4:3 remplit une sortie 16:9 (`screenCover` + `screenFollow`), zoom ×2 en +//! orbite, pointeur garé en bas à droite : la caméra tourne à fond. La carte doit pencher ENTIÈRE, +//! ses quatre coins arrondis. De v2.0.0 à v2.1.0, le plan de l'enregistrement entier penchait +//! dans le rect droit du slot, qui le rognait : des coins coupés à plat, par une arête alignée sur +//! les axes, alors que la carte tenait dans la sortie. +//! +//! Frame NV12 synthétique et unie, fond noir, sans ombre : le métrage est clair, tout le reste +//! noir. Sans adaptateur matériel, le test se saute. + +// Windows seulement : le readback et le décodage D3D11VA de ce harnais n'existent que là. +#![cfg(windows)] + +use openscreen_compositor::compositor::Compositor; +use openscreen_compositor::config::Cfg; +use openscreen_compositor::cursor::CursorTrack; +use openscreen_compositor::frame_geometry::{live_params_from_scene, plan_frame, FrameGeometryInput}; +use openscreen_compositor::scene::Scene; + +mod common; +use common::{gpu, Nv12Frame}; + +const SRC: (u32, u32) = (640, 480); +const OUT: (u32, u32) = (1280, 720); +/// En plein palier de la région (0..10 s), le cadreur posé. +const T: f32 = 3.0; + +fn scene() -> Scene { + Scene::from_json( + r##"{"clips":[{"screenPath":"/s.mp4","webcamPath":"","sourceStartSec":0,"sourceEndSec":10,"webcamOffsetSec":0,"hasAudio":false}], + "layout":{"preset":"no-webcam","webcamSize":1,"webcamShape":"rounded","webcamMirror":false,"webcamPosition":null,"webcamReactiveZoom":false, + "screenRect":{"x":0.1,"y":0.1,"width":0.8,"height":0.8},"screenCover":true,"screenFollow":true}, + "effects":{"padding":0.2,"blur":false,"shadow":0,"roundnessFrac":0.04,"motionBlur":0,"depthOfField":false}, + "background":{"kind":"color","color":"#000000"}, + "zoomRegions":[{"clipIndex":0,"startSec":0,"endSec":10,"scale":2.0,"focusX":0.5,"focusY":0.5,"focusMode":"auto","rotation":"orbit"}], + "annotations":[], + "cursor":{"show":true,"size":0.05,"smoothing":0,"motionBlur":0,"clickBounce":0,"clipToBounds":false,"theme":"none"}, + "cropByClip":[null], + "output":{"width":1280,"height":720,"fps":30}}"##, + ) + .expect("scène valide") +} + +/// Pointeur garé en (x, y), échantillonné à 60 Hz sur 10 s, écrit dans un sidecar temporaire. +fn parked(x: f32, y: f32) -> CursorTrack { + let body: Vec = + (0..=600).map(|i| format!(r#"{{"timeMs":{},"cx":{x},"cy":{y}}}"#, (i as f32 * 1000.0 / 60.0).round())).collect(); + let path = std::env::temp_dir().join(format!("os_fill_tilt_{}.json", std::process::id())); + std::fs::write(&path, format!(r#"{{"samples":[{}]}}"#, body.join(","))).expect("sidecar"); + let track = CursorTrack::load(path.to_str().unwrap(), 0.0, 10.0).expect("piste curseur"); + let _ = std::fs::remove_file(&path); + track +} + +fn cfg() -> Cfg { + let mut cfg = Cfg::c8(); + cfg.bg_blur = 0.0; + cfg.zoom = false; + cfg.layout_anim = false; + cfg.cursor = true; + cfg.mblur_n = 1; + cfg.shadow = false; + cfg +} + +/// Hors de la sortie, rien n'est dessiné : noir, comme le fond. +fn luma(rgba: &[u8], x: f32, y: f32) -> f32 { + let (x, y) = (x.round() as i32, y.round() as i32); + if !(0..OUT.0 as i32).contains(&x) || !(0..OUT.1 as i32).contains(&y) { + return 0.0; + } + let i = ((y * OUT.0 as i32 + x) * 4) as usize; + 0.2126 * rgba[i] as f32 + 0.7152 * rgba[i + 1] as f32 + 0.0722 * rgba[i + 2] as f32 +} + +/// Chaque coin de la carte inclinée est arrondi : sur la bissectrice, à 0,15 rayon du sommet +/// projeté, le fond (l'arrondi l'a retiré) ; à 1,3 rayon, le métrage. Un coin coupé par le rect +/// du slot laissait du noir là où la carte devait être, ou la poussait hors de la sortie. +#[test] +fn every_corner_of_a_filled_card_stays_rounded_under_the_orbit() { + let Some(gpu) = gpu() else { return }; + let comp = Compositor::new_sized(&gpu, OUT.0, OUT.1).expect("compositor"); + let screen = Nv12Frame::new(&gpu, SRC, |_, _| 220, |_, _| [128, 128]); + let scene = scene(); + let track = parked(0.9, 0.9); + + let mut live = live_params_from_scene(&scene); + live.has_webcam = false; + comp.set_live_params(live); + comp.set_has_webcam(false); + comp.set_scene(Some(scene.clone())); + comp.set_cursor(track.clone()); + comp.set_cursor_time(Some(T)); + comp.set_timeline_time(Some(T)); + let cfg = cfg(); + let (w, h, rgba) = unsafe { + comp.compose_frame(screen.as_ptr(), screen.as_ptr(), 0.0, &cfg).expect("compose_frame"); + comp.readback_direct().expect("readback") + }; + assert_eq!((w, h), OUT); + if let Ok(dir) = std::env::var("OPENSCREEN_FILL_TILT_OUT") { + image::RgbaImage::from_raw(w, h, rgba.clone()).unwrap().save(format!("{dir}/fill-orbit.png")).unwrap(); + } + + let render_px = [w as f32, h as f32]; + let src = [SRC.0 as f32, SRC.1 as f32]; + let g = plan_frame(&FrameGeometryInput { + render_px, + screen_tex_px: src, + screen_visible_px: src, + webcam_visible_px: src, + u_max: 1.0, + v_max: 1.0, + frame: 0.0, + cfg: &cfg, + live, + scene: Some(&scene), + cursor: Some(&track), + timeline_t_override: Some(T), + programme_time: None, + }); + let camera = g.camera.expect("la caméra réelle doit être posée"); + assert!(camera.orbit[0] > 0.8, "garde : la caméra doit tourner à fond, orbite {:?}", camera.orbit); + let s_px = [g.s_dst[2] * render_px[0], g.s_dst[3] * render_px[1]]; + let quad = g.screen_tilt(s_px).expect("quad de la caméra"); + let center = [(g.s_dst[0] + g.s_dst[2] * 0.5) * render_px[0], (g.s_dst[1] + g.s_dst[3] * 0.5) * render_px[1]]; + let r = g.s_radius * quad.scale; + assert!(r > 20.0, "garde : rayon {r} px, trop petit pour se mesurer"); + + let corners = quad.corners.map(|(x, y)| [center[0] + x, center[1] + y]); + let mid = [corners.iter().map(|c| c[0]).sum::() / 4.0, corners.iter().map(|c| c[1]).sum::() / 4.0]; + for (name, c) in ["haut-gauche", "haut-droit", "bas-droit", "bas-gauche"].iter().zip(corners) { + let (dx, dy) = (mid[0] - c[0], mid[1] - c[1]); + let n = dx.hypot(dy); + let at = |k: f32| luma(&rgba, c[0] + dx / n * k * r, c[1] + dy / n * k * r); + let (rounded, inside) = (at(0.15), at(1.3)); + println!("coin {name} ({:.0}, {:.0}) : arrondi {rounded:.0}, dedans {inside:.0}", c[0], c[1]); + assert!(rounded < 40.0, "coin {name} : pas arrondi (luma {rounded:.0} au sommet)"); + assert!(inside > 150.0, "coin {name} : la carte est coupée (luma {inside:.0} à 1,3 rayon)"); + } +} From 64600d9ee54475a5ed5e3339acaa8b3fe93a6275 Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Sat, 17 Oct 2026 18:08:28 +0200 Subject: [PATCH 2/4] docs(testing): log the Windows and Linux pass of the filled-card tilt fix --- technical-documentation/testing/manual-e2e-checklist.md | 1 + 1 file changed, 1 insertion(+) diff --git a/technical-documentation/testing/manual-e2e-checklist.md b/technical-documentation/testing/manual-e2e-checklist.md index d59ed495d..7620218b5 100644 --- a/technical-documentation/testing/manual-e2e-checklist.md +++ b/technical-documentation/testing/manual-e2e-checklist.md @@ -989,3 +989,4 @@ These checks are the human slice: an agent cannot click Apple's picker (see abov | 2026-10-17 | `v2.1.0-rc.6`, tag @ `36b3814`, installer `Openscreen.Setup.2.1.0-rc.6-x64.exe` from the GitHub release (build run 39010637863). sha512 matched `latest.yml`; installed silently over rc.5. The uninstall entry and the MCP server report `2.1.0-rc.6`. | Windows 11 Home 26200 (x64), Ryzen 7 5800X, RTX 4070 Ti, 1920×1080 at 279 Hz; Logitech StreamCam | **Partial — minimal pass: smoke test and the fixes since rc.4. All pass, no critical defect** | Real OS input through computer-use. Export checks also ran from an SDK MCP client over stdio. **Smoke:** - The HUD appeared; a left click on the tray icon brought the minimized editor back. The silent install landed in `C:\Program Files\Openscreen`, where an rc.6 per-machine install already existed, rather than the per-user folder; the per-user rc.5 was removed. - A 44 s take with webcam, microphone and system audio, with clicks in an Explorer window (`wgc-capture.exe` granted mid-take). Stop opened the editor with the take, the webcam and one auto zoom. The take's audio peaks at −8 dB. - Playback moved; on a 60 fps capture of the preview, no step back. - Export from the dialog, MP4 1080p60: 1752×1080, 44.35 s, H.264 and AAC, decoded without error, audio peaking at −1.7 dB. - After quitting, reopening from the HUD showed the project intact: take, webcam, auto zoom, changed wallpaper, rewound zoom absent. **Fixes:** - Full Camera trail (#1294, #1304): the rc.5 project re-exported with the same cuts at 1080p60. The frame that trailed on rc.5 (first frame after the trim ending at source 46.34986635) is sharp, and no one-frame outlier is left in the export. Frames before it are identical to the rc.5 export. - Pause on a held frame (#1296): paused at 0:32.1 on a still passage, then changed the wallpaper. The playhead, the cursor and the webcam did not move. - Chat rewind (#1292): "Add a zoom from 2 s to 4 s" added a 1.8× zoom. Rewinding that message showed "Rewound to the start of that message": the zoom was removed and the text returned to the composer. No "Message not found" in the UI or the logs. **Not run:** - smoothness at 60 Hz (rc.5 row, #1301); - the rest of the checklist. | | 2026-10-17 | `v2.1.0-rc.6`, `release/v2.1.0` @ `36b3814`, DMG `Openscreen-macOS-Apple-Silicon-2.1.0-rc.6.dmg` from the GitHub pre-release (build run 39010637863): 140 603 025 bytes, sha256 matches the release asset's digest; `spctl` accepted, Notarized Developer ID (M4LK7C6S84); `stapler validate` OK; `CFBundleShortVersionString` 2.1.0-rc.6. Installed over rc.5 | macOS 27.0.1 (26A434), Apple M1 (arm64), 8 GB, one 1920×1080 display at 120 Hz (1×); OBS Virtual Camera (no physical camera), BlackHole 2ch | **Partial — minimal pass toward stable: no critical bug; all 8 checked items pass** | Real OS input through computer-use, launched with `OPENSCREEN_MAC_SOURCE_PICKER=legacy`, `OPENSCREEN_DIAGNOSTIC=1` and `--remote-debugging-port` (read only: the `scripts/perf-ab/probe.js` paint probe, the playhead text and the editor's media elements). Measured with the app's bundled `ffmpeg`. Nobody else used the machine during the measures. **Smoke:** 1. The HUD appears. The tray icon (the green one in the menu bar; the aperture icon beside it is OBS's) opens Open / Check for Updates / Update Settings / Send Anonymous Usage Count / About / Save Diagnostics / Permissions… / Quit. *Open* brings back the HUD after *Hide recording bar*. 2. HUD take, 34 s, OBS webcam and microphone, six clicks on a 60 fps test pattern: the editor opens on it with the webcam PiP and two automatic zooms (1.25×, 1.80×). The take is 1920×1080 H.264 at about 56 fps, plus AAC 48 kHz stereo (mean −21.4 dB, real signal). 3. Playback: the preview moves. Probe 28.3–29.0 paints/s, p50 33.4 ms, p95 36.6. The preview's `