diff --git a/crates/compositor/Cargo.toml b/crates/compositor/Cargo.toml index 246f914e2..e33fa2932 100644 --- a/crates/compositor/Cargo.toml +++ b/crates/compositor/Cargo.toml @@ -82,6 +82,9 @@ metal = "0.29" objc = "0.2" block = "0.1" core-foundation = "0.9" +# Effets shader (`shader_fx::to_msl`) : naga vers MSL. wgpu l'active déjà par l'unification des +# features, mais rien ne le garantit : déclaré ici, à la même version (24) que plus haut. +naga = { version = "24", features = ["msl-out"] } # Linux : wgpu (Vulkan) pour le rendu, pollster pour block_on les ops wgpu, # cosmic-text pour la rastérisation du texte (remplace DirectWrite/CoreText). diff --git a/crates/compositor/src/compositor_macos.rs b/crates/compositor/src/compositor_macos.rs index 07ea2dd7c..8eb51ed78 100644 --- a/crates/compositor/src/compositor_macos.rs +++ b/crates/compositor/src/compositor_macos.rs @@ -37,7 +37,7 @@ pub use crate::frame_geometry::{ use crate::frame_geometry::{parse_hex, FrameGeometryInput, ShadowCaster, SCREEN_SHADOW_SPREAD_FRAC, WEBCAM_SHADOW_OFFSET_FRAC, WEBCAM_SHADOW_OPACITY, WEBCAM_SHADOW_SPREAD_FRAC}; -use crate::scene::{Scene, SceneBackground, WallpaperMotion}; +use crate::scene::{Scene, SceneBackground, SceneShaderEffect, ShaderEffectTarget, WallpaperMotion}; use anyhow::{anyhow, Result}; use metal::foreign_types::ForeignType; use std::cell::RefCell; @@ -361,6 +361,18 @@ pub struct Compositor { text_cache: RefCell>, text_raster: Option, + // --- Effets shader de l'agent (`shader_fx`, `docs/shader-effects.md`) --- + /// Compilés une fois par source, les moins récents évincés au-delà de `SHADER_FX_CACHE`. + shader_fx: RefCell>, + shader_fx_tick: std::cell::Cell, + /// Le calque transparent des textes, pour les effets qui ne visent qu'eux. Alloué à la + /// première frame qui porte un effet `text`, à la taille de rendu. + text_layer: RefCell>, + /// Vrai par défaut : une frame dont l'effet compile encore attend sa pipeline, parce qu'un + /// export ne doit jamais rendre de frame sans l'effet. La preview le passe à faux et saute + /// l'effet le temps de la compilation, puis recompose (`shader_fx_landed`). + shader_fx_blocking: std::cell::Cell, + // --- Segmentation du sujet webcam (cf. `pump_segmentation`) --- /// Masque du sujet, R8 à la résolution du modèle. Écrit par `set_webcam_mask`, lu au /// moment de dessiner la webcam. `None` tant qu'aucune frame n'a été segmentée — l'effet @@ -540,7 +552,7 @@ fn make_targets(device: &metal::Device, rw: u32, rh: u32) -> Targets { /// Comment un draw se mélange à ce qui est déjà dans la cible. #[derive(Clone, Copy, PartialEq)] -enum Blend { +pub(crate) enum Blend { /// Opaque : la conversion NV12 et le composite fullscreen écrasent. Replace, /// « over » alpha prémultiplié — la passe de composition normale. @@ -562,8 +574,28 @@ struct BgCache { tex: metal::Texture, } -/// Un pipeline state à une seule pièce jointe couleur. -fn make_pipeline( +/// Un effet shader de la scène (`shader_fx`), par hash de sa source. Cf. +/// `compositor_windows::ShaderFx`. +enum ShaderFx { + /// naga, le compilateur de Metal et le pipeline state tournent sur un thread de fond. + Pending(std::sync::mpsc::Receiver>), + Ready(metal::RenderPipelineState), + /// Erreur déjà écrite ; l'effet ne se dessine pas. + Failed, +} + +struct ShaderFxEntry { + state: ShaderFx, + /// Dernier usage, pour l'éviction (`SHADER_FX_CACHE`). + used: u64, +} + +/// Combien d'effets compilés le compositeur garde. Un agent qui itère en crée un par version. +const SHADER_FX_CACHE: usize = 64; + +/// Un pipeline state à une seule pièce jointe couleur. Aussi celui des effets shader +/// (`shader_fx::compile_metal`), construit sur leur thread de fond. +pub(crate) fn make_pipeline( device: &metal::Device, library: &metal::Library, vs: &str, @@ -764,6 +796,10 @@ impl Compositor { ann_img_cache: RefCell::new(std::collections::HashMap::new()), text_cache: RefCell::new(std::collections::HashMap::new()), text_raster: crate::text::TextRasterizer::new().ok(), + shader_fx: RefCell::new(std::collections::HashMap::new()), + shader_fx_tick: std::cell::Cell::new(0), + text_layer: RefCell::new(None), + shader_fx_blocking: std::cell::Cell::new(true), webcam_mask: RefCell::new(None), seg_capture: RefCell::new(None), seg_worker: RefCell::new(None), @@ -811,6 +847,7 @@ impl Compositor { blur_eighth, ann_copy, bg_cache: RefCell::new(None), + text_layer: RefCell::new(None), ..self }) } @@ -834,8 +871,155 @@ impl Compositor { self.live_params.borrow_mut().has_webcam = v; } + /// Installe (ou retire) la scène de l'app. Les effets shader nouveaux partent compiler tout de + /// suite, hors du thread de rendu : à la première frame qui les montre, ils sont en général + /// déjà prêts. Après la pose de la scène : l'éviction épargne ce qu'elle montre. pub fn set_scene(&self, s: Option) { *self.scene.borrow_mut() = s; + if let Some(scene) = self.scene.borrow().as_ref() { + for effect in &scene.shader_effects { + self.shader_fx_entry(effect); + } + } + } + + /// Faux pour la preview : voir `shader_fx_blocking`. + pub fn set_shader_fx_blocking(&self, on: bool) { + self.shader_fx_blocking.set(on); + } + + /// Fait aboutir les compilations terminées. Vrai si un effet vient d'être prêt : la preview + /// en pause doit alors recomposer, sinon elle resterait sur la frame sans l'effet. + pub fn shader_fx_landed(&self) -> bool { + let mut landed = false; + for entry in self.shader_fx.borrow_mut().values_mut() { + let got = match &entry.state { + ShaderFx::Pending(rx) => rx.try_recv(), + _ => continue, + }; + match got { + Ok(result) => { + entry.state = Self::finish_shader_fx(result); + landed = true; + } + Err(std::sync::mpsc::TryRecvError::Empty) => {} + Err(std::sync::mpsc::TryRecvError::Disconnected) => entry.state = ShaderFx::Failed, + } + } + landed + } + + /// La clé de cache d'`effect`, sa compilation lancée s'il est neuf, et son dernier usage noté. + /// `None` pour un contrat que ce compositeur ne connaît pas : la région ne se dessine pas. + fn shader_fx_entry(&self, effect: &SceneShaderEffect) -> Option { + if effect.contract != crate::shader_fx::CONTRACT { + return None; + } + let key = crate::shader_fx::source_hash(effect); + let tick = self.shader_fx_tick.get() + 1; + self.shader_fx_tick.set(tick); + let mut fx = self.shader_fx.borrow_mut(); + fx.entry(key) + .or_insert_with(|| ShaderFxEntry { + state: ShaderFx::Pending(crate::shader_fx::spawn_compile(effect, &self.gpu.device)), + used: tick, + }) + .used = tick; + // Un agent qui itère crée une entrée par version : on garde les plus récentes, et toutes + // celles que la scène montre. + if fx.len() > SHADER_FX_CACHE { + let scene = self.scene.borrow(); + let shown = scene.as_ref().map_or(&[][..], |s| &s.shader_effects[..]); + if let Some(oldest) = crate::shader_fx::eviction_victim(fx.iter().map(|(k, e)| (*k, e.used)), shown) { + fx.remove(&oldest); + } + } + Some(key) + } + + /// Sur Metal, le thread de fond rend la pipeline elle-même : il ne reste qu'à noter l'erreur. + fn finish_shader_fx(result: Result) -> ShaderFx { + match result { + Ok(pipeline) => ShaderFx::Ready(pipeline), + Err(e) => { + eprintln!("[shader-fx] {e}"); + ShaderFx::Failed + } + } + } + + /// La pipeline d'`effect`. Le compositeur d'export attend sa compilation ; la preview + /// (`shader_fx_blocking` faux) rend `None` tant qu'elle compile. `None` aussi si elle a échoué. + fn shader_fx_ready(&self, effect: &SceneShaderEffect) -> Option { + let key = self.shader_fx_entry(effect)?; + let mut fx = self.shader_fx.borrow_mut(); + let entry = fx.get_mut(&key)?; + if let ShaderFx::Pending(rx) = &entry.state { + let got = if self.shader_fx_blocking.get() { + rx.recv().map_err(|e| e.to_string()) + } else { + match rx.try_recv() { + Ok(result) => Ok(result), + Err(std::sync::mpsc::TryRecvError::Empty) => return None, + Err(e) => Err(e.to_string()), + } + }; + entry.state = Self::finish_shader_fx(got.and_then(|r| r)); + } + match &entry.state { + ShaderFx::Ready(pipeline) => Some(pipeline.clone()), + _ => None, + } + } + + /// Une passe d'effet sur `dst` (le RT, ou le calque des textes), dans le command buffer de la + /// frame : `dst` est copié dans `ann_copy`, libre à ce stade, puis l'effet le repeint en entier + /// dans une passe qui CHARGE la cible. + unsafe fn apply_shader_effect( + &self, + cmd: &metal::CommandBufferRef, + effect: &SceneShaderEffect, + dst: &metal::Texture, + ctx: &crate::shader_fx::FxContext, + ) -> Result<()> { + let Some(pipeline) = self.shader_fx_ready(effect) else { return Ok(()) }; + // On ne lit pas la cible sur laquelle on dessine : copie dans `ann_copy` (les flous + // d'annotation et le cristal de Prism Glow l'ont déjà lue). + self.snapshot(cmd, dst, false); + let params = crate::shader_fx::pack_params(effect); + let e = self.begin_pass(cmd, dst, None, &pipeline)?; + e.set_fragment_texture(0, Some(&self.ann_copy)); + e.set_fragment_bytes( + crate::shader_fx::MSL_CTX_BUFFER, + std::mem::size_of::() as u64, + ctx as *const crate::shader_fx::FxContext as *const std::ffi::c_void, + ); + e.set_fragment_bytes( + crate::shader_fx::MSL_PARAMS_BUFFER, + (params.len() * 4) as u64, + params.as_ptr() as *const std::ffi::c_void, + ); + e.draw_primitives(metal::MTLPrimitiveType::Triangle, 0, 3); + e.end_encoding(); + Ok(()) + } + + /// Le calque transparent des textes, à la taille de rendu, alloué à la première frame qui en a + /// besoin (un effet `ShaderEffectTarget::Text`). + fn text_layer(&self) -> metal::Texture { + self.text_layer + .borrow_mut() + .get_or_insert_with(|| { + make_texture( + &self.gpu.device, + metal::MTLPixelFormat::RGBA8Unorm, + self.render_w, + self.render_h, + metal::MTLStorageMode::Private, + metal::MTLTextureUsage::RenderTarget | metal::MTLTextureUsage::ShaderRead, + ) + }) + .clone() } pub fn set_cursor(&self, track: crate::cursor::CursorTrack) { @@ -1497,9 +1681,14 @@ impl Compositor { /// Fige l'image composée dans `ann_copy` (mip 0), qu'y lisent les flous (mips dérivés, /// `mips`) et le cristal de Prism Glow. unsafe fn snapshot_rt(&self, cmd: &metal::CommandBufferRef, mips: bool) { + self.snapshot(cmd, &self.rt, mips); + } + + /// Copie `src` (le RT, ou le calque des textes des effets shader) dans `ann_copy` (mip 0). + unsafe fn snapshot(&self, cmd: &metal::CommandBufferRef, src: &metal::Texture, mips: bool) { let blit = cmd.new_blit_command_encoder(); blit.copy_from_texture( - &self.rt, 0, 0, + src, 0, 0, metal::MTLOrigin { x: 0, y: 0, z: 0 }, metal::MTLSize { width: self.render_w as u64, height: self.render_h as u64, depth: 1 }, &self.ann_copy, 0, 0, @@ -1522,9 +1711,14 @@ impl Compositor { /// /// `privacy` : les flous de confidentialité seuls, dessinés sur le métrage avant le curseur ; /// sinon toutes les autres annotations, le calque le plus haut. + /// + /// `target` : le RT, ou le calque transparent des textes quand un effet shader `text` les vise + /// (`compose_frame`). Les flous de confidentialité visent toujours le RT, qu'ils relisent. + #[allow(clippy::too_many_arguments)] unsafe fn draw_annotations( &self, cmd: &metal::CommandBufferRef, + target: &metal::Texture, scene: Option<&Scene>, t: f32, s_ann: [f32; 4], @@ -1546,7 +1740,7 @@ impl Compositor { self.snapshot_rt(cmd, true); } - let enc = self.begin_pass(cmd, &self.rt, None, &self.pipeline_main)?; + let enc = self.begin_pass(cmd, target, None, &self.pipeline_main)?; // La liste arrive déjà triée par zIndex côté app : l'ordre d'itération EST l'ordre // de peinture. for a in &scene.annotations { @@ -2548,7 +2742,11 @@ impl Compositor { // --- flous de confidentialité : sur le métrage, AVANT le curseur (parité Windows). Le // curseur reste net par-dessus, et le cristal de Prism Glow, qui réfracte l'image // composée, n'y voit que des pixels déjà floutés. - self.draw_annotations(cmd_buf, scene_ref.as_ref(), g.source_t, g.s_ann, &g, true)?; + self.draw_annotations(cmd_buf, &self.rt, scene_ref.as_ref(), g.source_t, g.s_ann, &g, true)?; + + // La pointe et les clics récents, tels que les effets shader les lisent. + let mut fx_cursor = None; + let mut fx_clicks = Vec::new(); // --- curseur --- (parité `compositor_windows.rs`, section « curseur custom ») if let Some(track) = cursor_ref.as_ref() { @@ -2567,6 +2765,8 @@ impl Compositor { ) .map(|p| p.for_backend(self.gpu.backend == crate::d3d::Backend::Cpu)); if let Some(plan) = plan { + fx_cursor = Some(plan.pointer); + fx_clicks = plan.clicks.clone(); let sprites = scene_ref .as_ref() .map(|s| s.cursor.cursor_sprites.clone()) @@ -2764,12 +2964,64 @@ impl Compositor { enc.end_encoding(); + // --- effets shader de l'agent, autour des textes selon leur cible (`docs/shader-effects.md`, + // D.1) : `image` avant les annotations et les sous-titres, `text` sur un calque des textes + // à part, `both` sur l'image finale. Tout dans le command buffer de la frame. + let fx_inputs = crate::shader_fx::FxFrameInputs { + cursor: fx_cursor, + clicks: fx_clicks, + footage: g.footage_quad([rw, rh]), + footage_aspect: g.footage_aspect([rw, rh]), + camera: lp.has_webcam.then_some(g.w_dst), + }; + let effects = scene_ref + .as_ref() + .map(|s| crate::shader_fx::active_effects(&s.shader_effects, g.source_t)) + .unwrap_or_default(); + let fx_context = |e: &SceneShaderEffect| { + crate::shader_fx::context(e, g.source_t, g.programme_t, [rw, rh], &fx_inputs) + }; + let with_target = |target: ShaderEffectTarget| -> Vec<&SceneShaderEffect> { + effects.iter().copied().filter(|e| e.target == target).collect() + }; + for e in with_target(ShaderEffectTarget::Image) { + self.apply_shader_effect(cmd_buf, e, &self.rt, &fx_context(e))?; + } + // --- annotations : calque le plus haut, ancré sur le rect ÉCRAN SANS ZOOM --- // `s_ann`, pas `s_dst` : le zoom vit dans la boîte depuis l'issue #179, donc `s_dst` // grandit avec lui et emmenait annotations et sous-titres dans le mouvement. Le flou de // confidentialité est l'exception : il suit le contenu, d'où `&g` ; il est passé avant le // curseur (plus haut). - self.draw_annotations(cmd_buf, scene_ref.as_ref(), g.source_t, g.s_ann, &g, false)?; + let text_effects = with_target(ShaderEffectTarget::Text); + if text_effects.is_empty() { + self.draw_annotations(cmd_buf, &self.rt, scene_ref.as_ref(), g.source_t, g.s_ann, &g, false)?; + } else { + // Les textes sur un calque transparent, l'effet sur ce calque seul, puis le calque + // posé sur l'image (« over » prémultiplié, `pipeline_fs_tex`) : l'image dessous reste + // intacte. Le calque est effacé dans sa propre passe : `draw_annotations` n'en ouvre + // aucune quand rien n'est visible. + let layer = self.text_layer(); + self.begin_pass( + cmd_buf, + &layer, + Some(metal::MTLClearColor::new(0.0, 0.0, 0.0, 0.0)), + &self.pipeline_main, + )? + .end_encoding(); + self.draw_annotations(cmd_buf, &layer, scene_ref.as_ref(), g.source_t, g.s_ann, &g, false)?; + for e in text_effects { + self.apply_shader_effect(cmd_buf, e, &layer, &fx_context(e))?; + } + let c = self.begin_pass(cmd_buf, &self.rt, None, &self.pipeline_fs_tex)?; + c.set_fragment_texture(0, Some(&layer)); + c.draw_primitives(metal::MTLPrimitiveType::Triangle, 0, 3); + c.end_encoding(); + } + + for e in with_target(ShaderEffectTarget::Both) { + self.apply_shader_effect(cmd_buf, e, &self.rt, &fx_context(e))?; + } // Ni miroir RGBA ni attente ici : le miroir ne sert qu'à `readback_direct` (la // preview), et l'export ne lit jamais le RGBA — le blit pleine résolution était payé @@ -4519,4 +4771,352 @@ mod tests { let (rw, rh, rgba) = unsafe { comp.readback_direct() }.expect("readback"); assert_eq!((rw, rh, rgba.len()), (182, 322, 182 * 322 * 4)); } + + // ----------------------------------------------------------------------- + // Effets shader de l'agent (`shader_fx`) : le pendant des tests GPU de + // `compositor_windows.rs`, sur le device Metal du système. + // ----------------------------------------------------------------------- + + /// Une scène à fond rouge plein, l'écran rétréci par le padding : les coins sont du fond. + /// `annotations` : celles de la scène, pour les tests de cible. + fn shader_fx_scene_with(effects: serde_json::Value, annotations: serde_json::Value) -> Scene { + Scene::from_json(&shader_fx_json(effects, annotations).to_string()).expect("scène") + } + + fn shader_fx_json(effects: serde_json::Value, annotations: serde_json::Value) -> serde_json::Value { + serde_json::json!({ + "clips": [], + "layout": {"preset":"no-webcam","webcamSize":1,"webcamShape":"rectangle","webcamMirror":false,"webcamPosition":null,"webcamReactiveZoom":false}, + "effects": {"padding":0.5,"blur":false,"shadow":0,"roundnessFrac":0,"motionBlur":0}, + "background": {"kind":"color","color":"#ff0000"}, + "zoomRegions": [], + "annotations": annotations, + "cursor": {"show":false,"size":1,"smoothing":0,"motionBlur":0,"clickBounce":0,"clipToBounds":false,"theme":"default"}, + "cropByClip": [], + "output": {"width":320,"height":180,"fps":null}, + "shaderEffects": effects, + }) + } + + fn shader_fx_scene(effects: serde_json::Value) -> Scene { + shader_fx_scene_with(effects, serde_json::json!([])) + } + + /// Un fragment d'effet de 1 s à 3 s, tel que l'app l'envoie. + fn fx(code: &str, target: &str, lane: u32) -> serde_json::Value { + serde_json::json!({ + "clipIndex": 0, "startSec": 1.0, "endSec": 3.0, "target": target, "lane": lane, + "contract": 1, "code": code, + "params": [{"name": "level", "type": "number"}, {"name": "tint", "type": "color"}], + "values": {"level": 1.0, "tint": [0.0, 1.0, 0.0, 1.0]}, + }) + } + + const INVERT: &str = "fn effect(uv: vec2) -> vec4 {\n let c = frame(uv);\n return vec4(vec3(params.level) - c.rgb, 1.0);\n}\n"; + const BLUE: &str = "fn effect(uv: vec2) -> vec4 { return vec4(0.0, 0.0, 1.0, 1.0); }"; + const TINT: &str = "fn effect(uv: vec2) -> vec4 { return params.tint; }"; + const SAME: &str = "fn effect(uv: vec2) -> vec4 { return frame(uv); }"; + + fn fx_cfg() -> crate::config::Cfg { + let mut cfg = crate::config::Cfg::c8(); + cfg.bg_blur = 0.0; + cfg.mblur_n = 1; + cfg + } + + /// L'image composée au temps source `t`, en RGBA. + fn compose_fx_at(comp: &super::Compositor, t: f32) -> Vec { + let screen = FakeFrame::new(64, 64, |_, _| 126); + comp.set_timeline_time(Some(t)); + comp.set_programme_time(Some(t)); + unsafe { comp.compose_frame(screen.as_ptr(), screen.as_ptr(), 0.0, &fx_cfg()) }.expect("compose"); + let (_, _, rgba) = unsafe { comp.readback_direct() }.expect("readback"); + rgba + } + + /// Le pixel du coin haut-gauche (du fond) au temps source `t`. + fn corner_at(comp: &super::Compositor, t: f32) -> [u8; 3] { + let rgba = compose_fx_at(comp, t); + [rgba[0], rgba[1], rgba[2]] + } + + /// Le coin à 2 s, dans la région, sous les effets donnés. + fn corner_under(effects: serde_json::Value) -> Option<[u8; 3]> { + let gpu = crate::d3d::Gpu::create(false).ok()?; + let comp = super::Compositor::new_sized(&gpu, 320, 180).expect("compositeur"); + comp.set_scene(Some(shader_fx_scene(effects))); + Some(corner_at(&comp, 2.0)) + } + + /// La passe de l'agent repeint l'image composée, dans sa région et nulle part ailleurs. Le + /// compositeur par défaut est celui de l'export : il attend la pipeline, jamais de frame sans. + #[test] + fn a_shader_effect_repaints_the_composed_frame_inside_its_region() { + let Ok(gpu) = crate::d3d::Gpu::create(false) else { + eprintln!("pas de device Metal — test sauté"); + return; + }; + let comp = super::Compositor::new_sized(&gpu, 320, 180).expect("compositeur"); + comp.set_scene(Some(shader_fx_scene(serde_json::json!([fx(INVERT, "image", 0)])))); + assert_eq!(corner_at(&comp, 0.5), [255, 0, 0], "avant la région : le fond rouge"); + assert_eq!(corner_at(&comp, 2.0), [0, 255, 255], "dans la région : rouge inversé"); + assert_eq!(corner_at(&comp, 3.5), [255, 0, 0], "après la région : le fond rouge"); + } + + /// La preview ne bloque pas : elle saute l'effet tant qu'il compile, et `shader_fx_landed` + /// lui dit quand recomposer. Ensuite la frame est celle de l'export, au pixel. + #[test] + fn the_preview_shows_the_effect_once_it_landed() { + let Ok(gpu) = crate::d3d::Gpu::create(false) else { + eprintln!("pas de device Metal — test sauté"); + return; + }; + let comp = super::Compositor::new_sized(&gpu, 320, 180).expect("compositeur"); + comp.set_shader_fx_blocking(false); + comp.set_scene(Some(shader_fx_scene(serde_json::json!([fx(INVERT, "image", 0)])))); + let start = std::time::Instant::now(); + while !comp.shader_fx_landed() { + assert!(start.elapsed().as_secs() < 10, "la compilation n'a jamais abouti"); + std::thread::sleep(std::time::Duration::from_millis(1)); + } + eprintln!("effet prêt en {:.1} ms", start.elapsed().as_secs_f64() * 1000.0); + assert_eq!(corner_at(&comp, 2.0), [0, 255, 255]); + } + + /// Un WGSL invalide, ou un contrat inconnu, ne casse pas le rendu : l'effet ne se dessine + /// pas, la frame reste celle de la composition. + #[test] + fn a_broken_or_unknown_effect_leaves_the_frame_untouched() { + let broken = fx("fn effect(uv: vec2) -> vec4 { nope }", "image", 0); + let mut future = fx(BLUE, "image", 0); + future["contract"] = serde_json::json!(2); + for effect in [broken, future] { + let Some(corner) = corner_under(serde_json::json!([effect])) else { return }; + assert_eq!(corner, [255, 0, 0]); + } + } + + /// Les paramètres arrivent dans `params` au layout de la struct générée : la couleur `tint` + /// suit le nombre `level` dans le buffer, et se lit telle que l'app l'a écrite. + #[test] + fn a_color_parameter_reaches_the_shader() { + let Some(corner) = corner_under(serde_json::json!([fx(TINT, "image", 0)])) else { return }; + assert_eq!(corner, [0, 255, 0]); + } + + /// `fx_to_footage` retrouve le point du métrage que le compositeur a dessiné sous un pixel, + /// à plat, sous un angle fixe (warp bilinéaire) et sous la caméra réelle (homographie), et + /// `fx_from_footage` le ramène sur ce pixel. En 1080p, l'arrondi 8 bits et le demi-pixel + /// tiennent sous 0,005 ; la pose orbit testée écarte l'homographie du bilinéaire de ~0,01. + /// Le pendant du test Windows : les deux fonctions passent ici par la traduction MSL. + #[test] + fn the_footage_plane_maps_back_to_what_the_compositor_drew() { + let Ok(gpu) = crate::d3d::Gpu::create(false) else { + eprintln!("pas de device Metal — test sauté"); + return; + }; + const PLANE: &str = "fn effect(uv: vec2) -> vec4 {\n let p = fx_to_footage(uv);\n return vec4(p, distance(fx_from_footage(p), uv) * 100.0, 1.0);\n}\n"; + for (rotation, projective) in [(None, false), (Some("left"), false), (Some("orbit"), true)] { + let mut json = shader_fx_json(serde_json::json!([fx(PLANE, "image", 0)]), serde_json::json!([])); + json["clips"] = serde_json::json!([{"screenPath": "/s.mp4", "webcamPath": "", "sourceStartSec": 0, + "sourceEndSec": 10, "webcamOffsetSec": 0, "hasAudio": false}]); + json["layout"]["screenRect"] = serde_json::json!({"x": 0.1, "y": 0.1, "width": 0.8, "height": 0.8}); + json["cropByClip"] = serde_json::json!([null]); + if let Some(rotation) = rotation { + json["zoomRegions"] = serde_json::json!([{"clipIndex": 0, "startSec": 0, "endSec": 10, + "scale": 1.5, "focusX": 0.5, "focusY": 0.5, "rotation": rotation}]); + } + let comp = super::Compositor::new_sized(&gpu, 1920, 1080).expect("compositeur"); + let scene = Scene::from_json(&json.to_string()).expect("scène"); + comp.set_live_params(live_params_from_scene(&scene)); + comp.set_scene(Some(scene)); + let screen = FakeFrame::new(64, 64, |_, _| 126); + comp.set_timeline_time(Some(2.0)); + comp.set_programme_time(Some(2.0)); + unsafe { comp.compose_frame(screen.as_ptr(), screen.as_ptr(), 0.0, &fx_cfg()) }.expect("compose"); + let quad = comp.footage_quad().expect("le métrage de la frame"); + assert_eq!(quad.projective, projective, "{rotation:?}"); + let (w, h, rgba) = unsafe { comp.readback_direct() }.expect("readback"); + let [tl, tr, br, bl] = quad.corners.map(|[x, y]| (x, y)); + for (s, t) in [(0.25, 0.3), (0.7, 0.4), (0.4, 0.75)] { + let (x, y) = if projective { + crate::regions::square_to_quad(&[tl, tr, br, bl], s, t) + } else { + let top = (tl.0 + (tr.0 - tl.0) * s, tl.1 + (tr.1 - tl.1) * s); + let bottom = (bl.0 + (br.0 - bl.0) * s, bl.1 + (br.1 - bl.1) * s); + (top.0 + (bottom.0 - top.0) * t, top.1 + (bottom.1 - top.1) * t) + }; + let i = ((y * h as f32) as usize * w as usize + (x * w as f32) as usize) * 4; + let got = (rgba[i] as f32 / 255.0, rgba[i + 1] as f32 / 255.0); + assert!( + (got.0 - s).abs() < 0.005 && (got.1 - t).abs() < 0.005, + "{rotation:?} ({s}, {t}) : lu {got:?}, coins {:?}", + quad.corners + ); + assert!(rgba[i + 2] < 3, "{rotation:?} ({s}, {t}) : aller-retour à {}", rgba[i + 2]); + } + } + } + + /// Les lanes s'appliquent dans l'ordre : bleu puis inversion donne jaune, l'inverse donne bleu. + #[test] + fn lanes_apply_in_order() { + let Some(blue_then_invert) = + corner_under(serde_json::json!([fx(INVERT, "image", 1), fx(BLUE, "image", 0)])) + else { + return; + }; + assert_eq!(blue_then_invert, [255, 255, 0]); + let invert_then_blue = + corner_under(serde_json::json!([fx(INVERT, "image", 0), fx(BLUE, "image", 1)])).unwrap(); + assert_eq!(invert_then_blue, [0, 0, 255]); + } + + /// `text` ne touche que le calque des textes, posé ensuite sur l'image ; `both` voit tout. + /// Sans annotation, le calque est transparent : un effet `text` qui le rend tel quel laisse + /// l'image intacte, un effet `text` opaque la recouvre. + #[test] + fn the_three_targets_keep_to_their_layer() { + let Some(image_only) = corner_under(serde_json::json!([fx(BLUE, "image", 0)])) else { return }; + assert_eq!(image_only, [0, 0, 255]); + let text_untouched = + corner_under(serde_json::json!([fx(BLUE, "image", 0), fx(SAME, "text", 0)])).unwrap(); + assert_eq!(text_untouched, [0, 0, 255], "un calque transparent ne change pas l'image"); + let text_opaque = + corner_under(serde_json::json!([fx(BLUE, "image", 0), fx(TINT, "text", 0)])).unwrap(); + assert_eq!(text_opaque, [0, 255, 0], "le calque des textes, opaque, posé sur l'image"); + let both = corner_under(serde_json::json!([fx(BLUE, "image", 0), fx(INVERT, "both", 0)])).unwrap(); + assert_eq!(both, [255, 255, 0], "`both` voit l'image finale"); + } + + /// Avec une vraie annotation : un effet `image` passe dessous sans la toucher, un effet `text` + /// la repeint sans toucher l'image. La flèche blanche est dessinée dans le calque des textes, + /// pas dans le RT : c'est ce que `draw_annotations(…, target, …)` doit garantir. + #[test] + fn an_annotation_stays_out_of_image_effects_and_inside_text_effects() { + let Ok(gpu) = crate::d3d::Gpu::create(false) else { + eprintln!("pas de device Metal — test sauté"); + return; + }; + let arrow = serde_json::json!([{ + "id": "a", "startSec": 0.0, "endSec": 10.0, "kind": "figure", "space": "frame", + "x": 0.1, "y": 0.1, "w": 0.8, "h": 0.8, "zIndex": 0, + "figure": {"direction": "right", "color": "#ffffff", "strokeWidth": 30}, + }]); + // Le calque des textes est prémultiplié : l'effet garde l'alpha et peint en vert ce qui + // est couvert. + const GREEN_TEXT: &str = "fn effect(uv: vec2) -> vec4 { let a = frame(uv).a; return vec4(0.0, a, 0.0, a); }"; + let count = |rgba: &[u8], rgb: [u8; 3]| rgba.chunks_exact(4).filter(|p| p[..3] == rgb).count(); + let rgba_under = |effects: serde_json::Value| { + let comp = super::Compositor::new_sized(&gpu, 320, 180).expect("compositeur"); + comp.set_scene(Some(shader_fx_scene_with(effects, arrow.clone()))); + compose_fx_at(&comp, 2.0) + }; + + let bare = rgba_under(serde_json::json!([])); + let arrow_px = count(&bare, [255, 255, 255]); + assert!(arrow_px > 500, "la flèche est dessinée : {arrow_px} px blancs"); + + let image = rgba_under(serde_json::json!([fx(BLUE, "image", 0)])); + assert_eq!(count(&image, [255, 255, 255]), arrow_px, "l'effet `image` passe sous la flèche"); + assert_eq!([image[0], image[1], image[2]], [0, 0, 255]); + + let text = rgba_under(serde_json::json!([fx(GREEN_TEXT, "text", 0)])); + assert_eq!(count(&text, [255, 255, 255]), 0, "la flèche est passée par l'effet `text`"); + assert_eq!(count(&text, [0, 255, 0]), arrow_px, "et elle seule est verte"); + assert_eq!([text[0], text[1], text[2]], [255, 0, 0], "l'image dessous est intacte"); + } + + /// Calibre les paliers de coût : le surcoût par frame d'un effet qui lit `frame()` N fois, + /// à 1080p et en 4K, sur le GPU de la machine ; puis le temps de compilation d'un effet. + /// Opt-in, comme les autres mesures : + /// + /// OPENSCREEN_FX_BENCH=1 cargo test -p openscreen-compositor --release --lib \ + /// shader_fx_tier_calibration -- --nocapture + #[test] + fn shader_fx_tier_calibration() { + if std::env::var_os("OPENSCREEN_FX_BENCH").is_none() { + eprintln!("OPENSCREEN_FX_BENCH absent — saute"); + return; + } + let gpu = crate::d3d::Gpu::create(false).expect("device Metal"); + let screen = FakeFrame::new(1920, 1080, |x, y| ((x ^ y) & 0xff) as u8); + let cfg = fx_cfg(); + let reads_effect = |n: u32| { + format!( + "fn effect(uv: vec2) -> vec4 {{\n var acc = vec4(0.0);\n for (var i = 0; i < {n}; i++) {{\n let a = f32(i) * 2.399;\n acc += frame(uv + vec2(cos(a), sin(a)) * fx_px(f32(i)));\n }}\n return acc / {n}.0;\n}}\n" + ) + }; + let per_frame = |w: u32, h: u32, effects: serde_json::Value| -> f64 { + let comp = super::Compositor::new_sized(&gpu, w, h).expect("compositeur"); + comp.set_scene(Some(shader_fx_scene(effects))); + comp.set_timeline_time(Some(2.0)); + comp.set_programme_time(Some(2.0)); + unsafe { comp.compose_frame(screen.as_ptr(), screen.as_ptr(), 0.0, &cfg) }.unwrap(); + unsafe { comp.readback_direct() }.unwrap(); + let n = 200; + let t0 = std::time::Instant::now(); + for f in 0..n { + unsafe { comp.compose_frame(screen.as_ptr(), screen.as_ptr(), f as f32, &cfg) }.unwrap(); + } + unsafe { comp.readback_direct() }.unwrap(); + t0.elapsed().as_secs_f64() * 1000.0 / n as f64 + }; + for (w, h) in [(1920u32, 1080u32), (3840, 2160)] { + let base = (0..5).map(|_| per_frame(w, h, serde_json::json!([]))).fold(f64::MAX, f64::min); + eprintln!("{w}x{h} : sans effet {base:.3} ms/frame"); + for reads in [1u32, 8, 32, 128, 512] { + let code = reads_effect(reads); + let report = crate::shader_fx::analyze(&code, &[]).expect("analyse").report; + let with = (0..5) + .map(|_| per_frame(w, h, serde_json::json!([fx(&code, "image", 0)]))) + .fold(f64::MAX, f64::min); + eprintln!( + "{w}x{h} : {reads:>3} lectures ({:>10}) : +{:.3} ms/frame", + report.tier.name(), + with - base + ); + } + } + + // La compilation : naga, le compilateur de Metal et le pipeline state, sur le ripple de la + // spec. Une fonction de plus, au nom unique, à chaque tour : sans elle le MSL serait le + // même (naga ne garde pas les commentaires) et Metal le servirait depuis son cache. + let ripple = include_str!("../../../docs/shader-effects.md") + .split("```wgsl\n") + .nth(2) + .and_then(|s| s.split("```").next()) + .expect("l'exemple C.6"); + let params: Vec = ["strength", "radius", "duration"] + .into_iter() + .map(|name| crate::scene::ShaderParamDecl { + name: name.into(), + ty: crate::scene::ShaderParamType::Number, + }) + .collect(); + let nonce = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |d| d.as_nanos()); + let mut times: Vec = (0..20) + .map(|i| { + let code = format!("{ripple}\nfn fx_nonce_{nonce}_{i}() -> f32 {{ return {i}.0; }}\n"); + let t0 = std::time::Instant::now(); + crate::shader_fx::compile_metal(&gpu.device, &code, ¶ms).expect("compile"); + t0.elapsed().as_secs_f64() * 1000.0 + }) + .collect(); + let first = times[0]; + times.sort_by(f64::total_cmp); + eprintln!( + "compilation du ripple ({} lignes) : première {first:.1} ms, médiane {:.1} ms, max {:.1} ms", + ripple.lines().count(), + times[times.len() / 2], + times[times.len() - 1] + ); + // Et la même source une seconde fois : ce que coûte un effet que Metal a déjà vu. + let code = format!("{ripple}\nfn fx_nonce_{nonce}_0() -> f32 {{ return 0.0; }}\n"); + let t0 = std::time::Instant::now(); + crate::shader_fx::compile_metal(&gpu.device, &code, ¶ms).expect("compile"); + eprintln!("recompilation d'une source déjà vue : {:.1} ms", t0.elapsed().as_secs_f64() * 1000.0); + } } diff --git a/crates/compositor/src/live.rs b/crates/compositor/src/live.rs index 74cb71959..ca1645605 100644 --- a/crates/compositor/src/live.rs +++ b/crates/compositor/src/live.rs @@ -1493,7 +1493,6 @@ unsafe fn render_thread( let mut comp = Compositor::new(&gpu)?; // La preview ne bloque jamais sur la compilation d'un effet shader : elle le saute le temps // qu'il compile (voir `shader_fx_landed` plus bas). L'export, lui, attend. - #[cfg(any(windows, target_os = "linux"))] comp.set_shader_fx_blocking(false); // Vue live = le VRAI enregistrement, pas la fenêtre fixture (100s@6s, taillée pour l'ancien // fixture POC). On charge toute la piste depuis t=0 ; 24h couvre large toute recording réelle. @@ -1786,10 +1785,7 @@ unsafe fn render_thread( } // Un effet shader vient de finir de compiler : la frame en pause doit le montrer. - #[cfg(any(windows, target_os = "linux"))] let fx_landed = comp.shader_fx_landed(); - #[cfg(not(any(windows, target_os = "linux")))] - let fx_landed = false; // avance : seek app-piloté (presentTime) prioritaire, sinon lecture libre (60 fps) let requested = shared.requested_frame.lock().unwrap().take(); diff --git a/crates/compositor/src/shader_fx.rs b/crates/compositor/src/shader_fx.rs index b43f9e6ad..71e77acb1 100644 --- a/crates/compositor/src/shader_fx.rs +++ b/crates/compositor/src/shader_fx.rs @@ -613,6 +613,125 @@ pub fn to_hlsl(parsed: &Parsed) -> Result { Ok(out) } +/// Les index de buffer Metal de `ctx` et de `params`, ceux que `to_msl` donne au module et que +/// `compositor_macos::apply_shader_effect` passe à `set_fragment_bytes`. Les mêmes numéros que les +/// registres HLSL (b2, b3). +#[cfg(target_os = "macos")] +pub const MSL_CTX_BUFFER: u64 = 2; +#[cfg(target_os = "macos")] +pub const MSL_PARAMS_BUFFER: u64 = 3; + +/// Le module traduit en MSL, et les noms que naga a donnés à ses deux étages. +#[cfg(target_os = "macos")] +pub struct Msl { + pub source: String, + pub vs: String, + pub fs: String, +} + +/// MSL pour Metal. Les bindings du prélude sont liés explicitement : la texture en 0, le sampler +/// en `constexpr` (linéaire, bords répétés, comme `sampNV` de `shaders.metal` : le dépôt n'a aucun +/// `MTLSamplerState`), `ctx` et `params` en buffers. Sans cette carte, naga invente les bindings +/// (`fake_missing_bindings`) et émet `[[user(fake0)]]`, qui n'est pas du MSL. +#[cfg(target_os = "macos")] +pub fn to_msl(parsed: &Parsed) -> Result { + use naga::back::msl; + use naga::proc::BoundsCheckPolicy::ReadZeroSkipWrite; + let at = |binding| naga::ResourceBinding { group: 0, binding }; + let resources = msl::BindingMap::from([ + (at(0), msl::BindTarget { texture: Some(0), ..Default::default() }), + (at(1), msl::BindTarget { sampler: Some(msl::BindSamplerTarget::Inline(0)), ..Default::default() }), + (at(2), msl::BindTarget { buffer: Some(MSL_CTX_BUFFER as u8), ..Default::default() }), + (at(3), msl::BindTarget { buffer: Some(MSL_PARAMS_BUFFER as u8), ..Default::default() }), + ]); + let options = msl::Options { + lang_version: (2, 0), + per_entry_point_map: ["fx_vs", "fx_fs"] + .into_iter() + .map(|name| { + (name.to_string(), msl::EntryPointResources { resources: resources.clone(), ..Default::default() }) + }) + .collect(), + inline_samplers: vec![msl::sampler::InlineSampler { + address: [msl::sampler::Address::ClampToEdge; 3], + mag_filter: msl::sampler::Filter::Linear, + min_filter: msl::sampler::Filter::Linear, + ..Default::default() + }], + fake_missing_bindings: false, + // naga borne sinon chaque boucle par une lecture `volatile` à chaque tour, contre un + // compilateur qui supposerait qu'elle finit. `analyze` l'a déjà prouvé (bornes + // littérales, 4 096 itérations au plus) : la garde coûtait 7 à 10 % de la passe sur M1. + force_loop_bounding: false, + // Un index hors bornes lit 0, comme sous D3D11, au lieu de lire au-delà du buffer. + bounds_check_policies: naga::proc::BoundsCheckPolicies { + index: ReadZeroSkipWrite, + buffer: ReadZeroSkipWrite, + image_load: ReadZeroSkipWrite, + binding_array: ReadZeroSkipWrite, + }, + ..Default::default() + }; + let (source, info) = + msl::write_string(&parsed.module, &parsed.info, &options, &msl::PipelineOptions::default()) + .map_err(|e| format!("MSL: {e}"))?; + let name = |entry: &str| -> Result { + let i = parsed + .module + .entry_points + .iter() + .position(|e| e.name == entry) + .ok_or_else(|| format!("the module has no {entry} entry point"))?; + info.entry_point_names[i].clone().map_err(|e| format!("MSL {entry}: {e}")) + }; + Ok(Msl { vs: name("fx_vs")?, fs: name("fx_fs")?, source }) +} + +/// La pipeline de l'effet, prête pour la passe : MSL compilé par Metal, puis le pipeline state +/// RGBA8 sans blending (l'effet rend le pixel final). +#[cfg(target_os = "macos")] +pub type CompiledFx = metal::RenderPipelineState; + +/// Compile pour Metal. Hors du thread de rendu, donc sous un pool d'autorelease à soi : un thread +/// lancé par Rust n'en a pas, et les objets autoreleased de Metal (l'erreur de compilation, les +/// fonctions de la bibliothèque) fuiraient. +#[cfg(target_os = "macos")] +pub fn compile_metal(device: &metal::Device, code: &str, params: &[ShaderParamDecl]) -> Result { + let msl = to_msl(&analyze(code, params)?)?; + objc::rc::autoreleasepool(|| { + let library = device + .new_library_with_source(&msl.source, &metal::CompileOptions::new()) + .map_err(|e| format!("Metal: {e}"))?; + crate::compositor_macos::make_pipeline( + device, + &library, + &msl.vs, + &msl.fs, + metal::MTLPixelFormat::RGBA8Unorm, + crate::compositor_macos::Blend::Replace, + ) + .map_err(|e| format!("Metal: {e:#}")) + }) +} + +/// Compile sur un thread à part ; le résultat arrive par le canal. Le pendant de la version +/// Windows : `Device`, `Library` et `RenderPipelineState` de metal-rs sont `Send` et `Sync`. +#[cfg(target_os = "macos")] +pub fn spawn_compile( + effect: &SceneShaderEffect, + device: &metal::Device, +) -> std::sync::mpsc::Receiver> { + let (tx, rx) = std::sync::mpsc::channel(); + let (code, params, device) = (effect.code.clone(), effect.params.clone(), device.clone()); + std::thread::Builder::new() + .name("shader-fx".into()) + .spawn(move || { + let _ = tx.send(compile_metal(&device, &code, ¶ms)); + }) + .expect("thread shader-fx"); + rx +} + /// Clé du cache des shaders compilés : ce qui change le binaire, et rien d'autre. pub fn source_hash(effect: &SceneShaderEffect) -> u64 { let mut h = std::collections::hash_map::DefaultHasher::new(); @@ -793,6 +912,8 @@ pub fn check_for_agent(code: &str, params: &[ShaderParamDecl]) -> Result (&'static str, Vec) { let code = include_str!("../../../docs/shader-effects.md") .split("```wgsl\n") .nth(2) @@ -995,6 +1116,12 @@ mod tests { decl("radius", ShaderParamType::Number), decl("duration", ShaderParamType::Number), ]; + (code, params) + } + + #[test] + fn the_ripple_of_the_spec_is_light() { + let (code, params) = spec_ripple(); let r = analyze(code, ¶ms).expect("l'exemple de la spec compile").report; assert_eq!((r.frame_reads, r.tier), (1, Tier::Light)); } @@ -1003,6 +1130,14 @@ mod tests { /// against this prelude, at the tier the resource promises. #[test] fn the_examples_of_the_mcp_contract_compile() { + let [(ripple, ripple_params), (vignette, vignette_params)] = mcp_examples(); + assert_eq!(analyze(&ripple, &ripple_params).expect("ripple").report.tier, Tier::Light); + assert_eq!(analyze(&vignette, &vignette_params).expect("vignette").report.tier, Tier::Light); + } + + /// Les deux exemples de la ressource MCP (`electron/mcp/effects-contract.ts`), le ripple et la + /// vignette, avec leurs paramètres. + fn mcp_examples() -> [(String, Vec); 2] { let src = include_str!("../../../electron/mcp/effects-contract.ts"); let examples: Vec = src .split("## Example") @@ -1015,15 +1150,18 @@ mod tests { .join("\n") }) .collect(); - assert_eq!(examples.len(), 2, "le ripple et la vignette"); - let ripple = [ - decl("strength", ShaderParamType::Number), - decl("radius", ShaderParamType::Number), - decl("duration", ShaderParamType::Number), - ]; - let vignette = [decl("depth", ShaderParamType::Number), decl("tint", ShaderParamType::Color)]; - assert_eq!(analyze(&examples[0], &ripple).expect("ripple").report.tier, Tier::Light); - assert_eq!(analyze(&examples[1], &vignette).expect("vignette").report.tier, Tier::Light); + let [ripple, vignette]: [String; 2] = examples.try_into().expect("le ripple et la vignette"); + [ + ( + ripple, + vec![ + decl("strength", ShaderParamType::Number), + decl("radius", ShaderParamType::Number), + decl("duration", ShaderParamType::Number), + ], + ), + (vignette, vec![decl("depth", ShaderParamType::Number), decl("tint", ShaderParamType::Color)]), + ] } #[test] @@ -1079,4 +1217,44 @@ mod tests { let c = compile_dxbc(VIGNETTE, &[decl("strength", ShaderParamType::Number)]).expect("compile"); assert!(!c.vs.is_empty() && !c.ps.is_empty()); } + + /// Les bindings que `compositor_macos` lie sont ceux du MSL : la texture en 0, `ctx` et + /// `params` aux buffers 2 et 3, le sampler en `constexpr`, et aucun binding inventé. + #[cfg(target_os = "macos")] + #[test] + fn a_valid_effect_becomes_msl_with_the_bindings_metal_binds() { + let parsed = analyze(VIGNETTE, &[decl("strength", ShaderParamType::Number)]).expect("analyze"); + let msl = to_msl(&parsed).expect("translate"); + assert!(msl.source.contains(&msl.vs) && msl.source.contains(&msl.fs)); + for binding in ["[[texture(0)]]", "[[buffer(2)]]", "[[buffer(3)]]", "constexpr metal::sampler"] { + assert!(msl.source.contains(binding), "{binding} missing:\n{}", msl.source); + } + assert!(!msl.source.contains("fake"), "binding inventé :\n{}", msl.source); + } + + /// « Accepté » veut dire « se dessinera » : le compilateur de Metal et le pipeline state + /// acceptent l'exemple de la spec, les deux de la ressource MCP et un effet qui lit chaque + /// type de paramètre. + #[cfg(target_os = "macos")] + #[test] + fn metal_compiles_the_examples() { + let Some(device) = metal::Device::system_default() else { + eprintln!("pas de device Metal — test sauté"); + return; + }; + let (ripple, ripple_params) = spec_ripple(); + compile_metal(&device, ripple, &ripple_params).expect("le ripple de la spec"); + for (code, params) in mcp_examples() { + compile_metal(&device, &code, ¶ms).expect("un exemple de la ressource MCP"); + } + let every_type = [ + decl("a", ShaderParamType::Number), + decl("tint", ShaderParamType::Color), + decl("on", ShaderParamType::Toggle), + decl("at", ShaderParamType::Point), + ]; + let code = "fn effect(uv: vec2) -> vec4 {\n let n = fx_hash(vec2(uv * ctx.resolution));\n let d = distance(uv, params.at) * ctx.aspect + fx_px(4.0) + n * 0.0;\n return mix(frame(uv), params.tint, params.on * params.a * ctx.clicks[0].w * d);\n}\n"; + compile_metal(&device, code, &every_type).expect("chaque type de paramètre"); + check_for_agent(VIGNETTE, &[decl("strength", ShaderParamType::Number)]).expect("check_for_agent"); + } } diff --git a/docs/shader-effects.md b/docs/shader-effects.md index 039445a18..3126ade0f 100644 --- a/docs/shader-effects.md +++ b/docs/shader-effects.md @@ -352,9 +352,12 @@ refusé parce que la machine qui l'écrit est modeste. 1080p, 350 fois la RTX. Un effet léger y ajoute jusqu'à 11 ms à une frame qui en coûte 7,5 ; un lourd, 234 ms. L'ordre des paliers tient, mais sans GPU même un effet léger double le temps d'export. -- **Le GPU intégré de référence** (le Radeon 610M des mesures Linux) reste à mesurer : on y attend - plusieurs fois ces coûts. La description de l'outil donnera les paliers en ms sur les deux - machines, pour que l'agent raisonne en temps et non en lectures. +- **Mesuré sur un Apple M1** (GPU intégré, Metal, même banc) : environ 0,12 ms par lecture en + 1080p et 0,5 ms en 4K, au-dessus d'un fixe de 0,3 ms. Un effet léger coûte au plus 1,3 ms par + frame en 1080p, un moyen 4,3 ms, un lourd 16 ms, 512 lectures 49 ms : trente fois la RTX. Les + lectures y dominent aussi : sans les `sin`/`cos` du banc, 128 lectures coûtent encore 10,5 ms. +- **Le Radeon 610M** des mesures Linux reste à mesurer. La description de l'outil donnera les + paliers en ms sur la RTX et sur le M1, pour que l'agent raisonne en temps et non en lectures. - **Plusieurs effets au même instant s'additionnent.** Le retour d'un outil donne le palier de l'effet, et le palier cumulé du moment le plus chargé de sa région. - **Rien n'est refusé au coût.** Le palier et sa recommandation reviennent à l'agent (E.3), qui @@ -417,8 +420,9 @@ Il y a deux compilations, à deux moments : - **L'erreur revient à l'agent** avec la ligne et la colonne de SON code. 2. **À la pose de la scène** (`Compositor::set_scene`). Chaque code nouveau part compiler sur un thread de fond. - - **La preview** saute l'effet le temps de la compilation, environ 10 ms, puis recompose la - frame en pause (`shader_fx_landed`). + - **La preview** saute l'effet le temps de la compilation, puis recompose la frame en pause + (`shader_fx_landed`). Environ 10 ms avec FXC ; avec Metal, 120 ms pour une source neuve et + jusqu'à 375 ms pour la première du processus (H.3). - **L'export** attend le binaire, et ne rend jamais une frame sans son effet. C'est le mode par défaut du compositeur : seule la vue live le coupe. @@ -426,7 +430,8 @@ Le cache est en mémoire, par compositeur. - **La clé** est le hash de (contrat, layout des paramètres, code). - **L'éviction** est LRU au-delà de 64 entrées : un agent qui itère en crée une par version. - **Pas de cache disque en v1.** 10 ms par effet ne le justifient pas, et Metal comme Mesa gardent - déjà leur propre cache de shaders. L'app ne passe d'ailleurs aujourd'hui aucun dossier + déjà leur propre cache de shaders : sur le M1, une source déjà vue se recompile en 0,4 ms dans le + même processus, en 3 ms dans un autre. L'app ne passe d'ailleurs aujourd'hui aucun dossier inscriptible au natif : il faudrait une variable d'environnement sur le modèle de `OPENSCREEN_FONTS_DIR`. @@ -438,10 +443,10 @@ Une seule source WGSL, traduite par naga au moment de compiler. |---|---|---|---| | Traduction | naga vers HLSL SM 5.0 | naga vers MSL | aucune : WGSL tel quel | | Compilation | `D3DCompile` (FXC) vers DXBC, puis `CreateVertexShader`/`CreatePixelShader` | `new_library_with_source`, puis `make_pipeline(…, Blend::Replace)` | `create_shader_module`, puis `create_render_pipeline` | -| Thread de fond | naga et FXC (fait) | bibliothèque et pipeline, sous `autoreleasepool` | module et pipeline (fait) | -| Copie du RT | `CopySubresourceRegion` vers `ann_copy` (fait) | `snapshot_rt(cmd_buf, false)` | `copy_texture_to_texture` vers le niveau 0 d'`ann_copy` (fait) | -| Insertion | fin de `compose_frame` (fait) | entre `draw_annotations(…, false)` et `submit` | `image` entre la passe caméra et l'ann-pass, `text` et `both` après (fait) | -| Uniforms | `Map(WRITE_DISCARD)` (fait) | `set_fragment_bytes` | `create_buffer_init(UNIFORM)` par passe (fait) | +| Thread de fond | naga et FXC (fait) | naga, bibliothèque et pipeline, sous `autoreleasepool` (fait) | module et pipeline (fait) | +| Copie du RT | `CopySubresourceRegion` vers `ann_copy` (fait) | blit vers `ann_copy` dans le command buffer de la frame (fait) | `copy_texture_to_texture` vers le niveau 0 d'`ann_copy` (fait) | +| Insertion | fin de `compose_frame` (fait) | fin de `compose_frame`, avant `submit` (fait) | `image` entre la passe caméra et l'ann-pass, `text` et `both` après (fait) | +| Uniforms | `Map(WRITE_DISCARD)` (fait) | `set_fragment_bytes` (fait) | `create_buffer_init(UNIFORM)` par passe (fait) | **macOS en détail** - **Activer `msl-out`.** Le dépôt l'obtient aujourd'hui par unification des features de wgpu ; le @@ -454,6 +459,15 @@ Une seule source WGSL, traduite par naga au moment de compiler. - **Lire les noms de fonctions** dans `TranslationInfo.entry_point_names`. - **Le thread de fond.** `Device`, `Library` et `RenderPipelineState` de metal-rs sont `Send` et `Sync`, et `live.rs` déplace déjà un `Gpu` vers un thread de préchargement. +- **Pas de garde de boucle.** naga borne sinon chaque boucle par une lecture `volatile` à chaque + tour (`force_loop_bounding`). La validation a déjà prouvé que toute boucle finit (C.4) : la garde + coûtait 7 à 10 % de la passe sur le M1. +- **Le calque des textes.** `draw_annotations` ouvre sa propre passe : elle prend désormais sa + cible, le RT ou le calque. Le calque est effacé dans une passe à part, puisque + `draw_annotations` n'en ouvre aucune quand rien n'est visible, puis posé sur le RT par + `pipeline_fs_tex` (« over » prémultiplié). +- **La passe charge sa cible** au lieu de l'ignorer : un effet peut faire `discard`, et le pixel + doit alors rester celui de la composition. **Linux en détail** - **Un bind group layout explicite,** jamais `layout: None`. Sinon un effet qui n'appelle pas @@ -717,10 +731,27 @@ Mesuré à la passe Linux (lavapipe sous WSL, 16 threads) : effet prêt en 18 ms module et pipeline compris ; les paliers en C.7. À mesurer à l'implémentation : -- un GPU intégré (le Radeon 610M des mesures Linux) ; -- le compilateur Metal. +- le Radeon 610M des mesures Linux. -Les résultats iront dans +### H.3 Mesuré sur macOS (Apple M1, Metal) + +| Mesure | Résultat | +|---|---| +| Compilation (naga + Metal + pipeline) d'une source neuve, le ripple de C.6 | 120 ms en médiane, 160 à 375 ms pour la première du processus, sur le thread de fond | +| Recompilation d'une source que Metal a déjà vue | 0,4 ms dans le même processus, 3 ms dans un autre | +| `addEffect` accepté, compilation réelle comprise (CLI `mcp`) | 152 ms ; refusé à la ligne de l'agent en 6 ms | +| Passe d'un effet de 1 lecture, 1080p et 4K | +0,41 ms et +1,2 ms par frame | +| Passe d'un effet de 128 lectures, 1080p et 4K | +16 ms et +63 ms par frame | +| Export de 180 frames en 1752×1080 : sans effet, ripple, effet lourd du spike | 2 469, 2 428 et 2 375 ms : dans le bruit, l'export est limité par l'encodeur | +| Preview en lecture, source à 30 i/s | 29,7 i/s avec et sans effet | +| Vue live contre MP4 (BT.709), image entière | 3,1 / 255 avec effet, 3,0 sans | +| Vue live contre MP4, sur les pixels où l'effet agit | 13 à 16 / 255 avec effet, 14 à 16 sans effet sur les mêmes pixels : le bruit du codec sur des bords nets | + +La compilation, dix fois plus lente qu'avec FXC, reste hors du rendu. `checkShaderEffect`, lui, +compile sur le thread qui l'appelle : le processus principal y passe ces 120 ms à chaque code +neuf. + +Le détail est dans [rendering-performance.md](../technical-documentation/engineering/rendering-performance.md). --- @@ -730,7 +761,7 @@ Les résultats iront dans | Niveau | Ce qu'il couvre | |---|---| | Rust, `shader_fx.rs` | Prélude et struct générée ; packer de paramètres contre les offsets de naga ; borne littérale et plafond d'itérations ; estimation du coût et paliers (boucles imbriquées, fonctions dépliées) ; erreurs aux lignes de l'agent ; sélection des régions (lane, ordre, `underTrim`) ; `ctx.region` sur une pilule coupée. | -| Rust, par backend | Une passe repeint la frame dans sa région et nulle part ailleurs ; les trois cibles (`text` laisse l'image intacte, `image` laisse les textes intacts) ; preview non bloquante puis `shader_fx_landed` ; effet cassé = frame intacte. Windows (fait dans le spike), macOS sur le runner CI, Linux sur lavapipe. | +| Rust, par backend | Une passe repeint la frame dans sa région et nulle part ailleurs ; les trois cibles (`text` laisse l'image intacte, `image` laisse les textes intacts) ; preview non bloquante puis `shader_fx_landed` ; effet cassé = frame intacte. Windows (fait), macOS (fait, sur le runner CI, avec en plus une vraie annotation entre une cible `image` et une cible `text`), Linux sur lavapipe. | | Rust, `frame_geometry.rs` | `ctx.clicks`, `ctx.footage`, `ctx.camera` en uv de sortie, sous zoom et sous tilt. | | TS, schéma | Parse, bornes des `values`, définition orpheline retirée, document ancien inchangé. | | TS, timeline | La région suit `moveClip`, `removeClip`, `replaceTimeline` et les trims (`timelineMap.test.ts`, `timeline.test.ts`). | diff --git a/technical-documentation/architecture/native-compositor.md b/technical-documentation/architecture/native-compositor.md index c80cd8e83..43bf0ab65 100644 --- a/technical-documentation/architecture/native-compositor.md +++ b/technical-documentation/architecture/native-compositor.md @@ -29,7 +29,7 @@ and without that flag the runtime corruption is silent. | [`crates/compositor/src/ffi.rs`](../../crates/compositor/src/ffi.rs) | bindgen-generated `libav*` bindings (ffmpeg 8.x headers) | | [`crates/compositor/src/compositor.rs`](../../crates/compositor/src/compositor_windows.rs) | HLSL compositor — every per-frame pass (`compose_frame`, background, screen, webcam, cursor, annotations, shadows, blur) | | [`crates/compositor/src/scene.rs`](../../crates/compositor/src/scene.rs) | the `Scene` struct parsed from the app's `SceneDescription` JSON | -| [`crates/compositor/src/shader_fx.rs`](../../crates/compositor/src/shader_fx.rs) | agent-written shader effects: the WGSL contract, validation and loop rule, cost tiers, parameter packing, background compilation (FXC on Windows; on Linux, `compositor_linux.rs` builds wgpu pipelines) | +| [`crates/compositor/src/shader_fx.rs`](../../crates/compositor/src/shader_fx.rs) | agent-written shader effects: the WGSL contract, validation and loop rule, cost tiers, parameter packing, background compilation (FXC on Windows, MSL and the Metal compiler on macOS; on Linux, `compositor_linux.rs` builds wgpu pipelines) | | [`crates/compositor/src/regions.rs`](../../crates/compositor/src/regions.rs) | zoom / speed / Full Camera regions — envelope shapes and per-frame state sampling | | [`crates/compositor/src/pipeline.rs`](../../crates/compositor/src/pipeline_windows.rs) | demux + `D3D11VA` decode + composite + AMF encode + mux (`run_c0` for decode/encode only, `run_composited` for the full path) | | [`crates/compositor/src/audio.rs`](../../crates/compositor/src/audio.rs) | per-clip audio decode, swresample → f32 planar 48 kHz stereo, libavfilter `atempo` speed stretch (WSOLA fallback), multi-track mix, AAC encoder | @@ -192,7 +192,14 @@ backend-specific. - **Compilation never happens on the render thread.** `set_scene` starts one background compile per new source. The cache is keyed by contract, code and parameter layout, and keeps the 64 most recently used. - - **Windows:** naga to HLSL SM 5.0, then FXC. + - **Windows:** naga to HLSL SM 5.0, then FXC; the device creates the shaders when the bytecode + lands. + - **macOS:** naga to MSL, then `new_library_with_source` and the pipeline state (RGBA8, no + blending), all on the background thread under its own autorelease pool. The bindings are + mapped explicitly (`shader_fx::to_msl`): texture 0, the sampler inlined as a `constexpr` + (linear, clamp to edge, like `sampNV`), `ctx` and `params` at buffers 2 and 3, uploaded with + `set_fragment_bytes`. naga's `force_loop_bounding` is off: `analyze` has already proved every + loop bounded, and the guard cost 7 to 10 % of the pass on an M1. - **Linux:** the WGSL goes to wgpu as it is. The thread builds the shader module and the pipeline, against an explicit layout of the prelude's four bindings. wgpu panics on a validation error, since no `on_uncaptured_error` is set: `analyze` runs first so that none @@ -203,7 +210,11 @@ backend-specific. - **Targets.** `image` effects run before the annotations and captions. `text` effects run on a transparent text layer, which is then drawn over the image. `both` effects run last. Within a target, effects apply by lane, then in scene order. Each pass copies its destination into - `ann_copy` and repaints it with blending off. + `ann_copy` and repaints it with blending off. On Metal every pass is encoded in the frame's + command buffer: a blit into `ann_copy`, then a render pass that loads its target (an effect may + `discard`). `draw_annotations` takes the target it opens its pass on, which is how the text layer + receives the annotations; the layer is cleared in a pass of its own, since `draw_annotations` + opens none when nothing is visible. - **`ctx` comes from the frame geometry** and is in output uv: the pointer and the last four clicks (`plan_cursor`), the footage rect (`FrameGeometry::footage_quad`, bounding box under a tilt), the camera rect, the region's progress (over the whole pill in programme time when the app sends @@ -442,12 +453,6 @@ licence. D3D11VA + AMF survive the LGPL-shared build (verified: rasteriser: one that needs no D3D device at all. Giving the helper a WARP device would add a slow path nobody needs alongside a working one. -### Known gap: shader effects do not draw on macOS - -The Metal compositor parses `shader_effects` but does not draw them: an effect is visible on -Windows and Linux only. [docs/shader-effects.md](../../docs/shader-effects.md) (D.5) specifies the -Metal pass. - ### Known gap: the capture fallback is unreachable on a host that fails D3D That fallback is only reachable through the pre-flight probe, and the probe diff --git a/technical-documentation/engineering/rendering-performance.md b/technical-documentation/engineering/rendering-performance.md index f8bb47187..b88bedc35 100644 --- a/technical-documentation/engineering/rendering-performance.md +++ b/technical-documentation/engineering/rendering-performance.md @@ -460,7 +460,23 @@ An agent-written effect (`crates/compositor/src/shader_fx.rs`, [docs/shader-effe | 512 | very heavy | +1 035 ms | +5 068 ms | - **The tiers keep their order on lavapipe,** but without a GPU a light effect already doubles the frame. The pipeline is ready 18 ms after `set_scene` there, module and driver compile included. -- **Not measured yet:** an integrated GPU, where the same reads cost several times more, and the Metal pass. + +**Apple M1 (integrated GPU, Metal), the same bench** (`compositor_macos::tests::shader_fx_tier_calibration`, macOS 26.5, on mains power). Composing a frame without an effect takes 2.42 ms at 1080p and 8.98 ms at 4K. + +| `frame()` reads | Tier | 1080p | 4K | +|---:|---|---:|---:| +| 1 | light | +0.413 ms | +1.225 ms | +| 8 | light | +1.260 ms | +4.575 ms | +| 32 | medium | +4.256 ms | +16.310 ms | +| 128 | heavy | +16.240 ms | +62.903 ms | +| 512 | very heavy | +49.143 ms | +201.583 ms | + +- **About 0.12 ms per read at 1080p and 0.5 ms at 4K,** over a fixed 0.3 ms: thirty times the RTX. The fixed part is the copy into `ann_copy` and the full-frame pass, which are bandwidth on a 68 GB/s unified memory. +- **The reads dominate here too.** The bench effect computes a `sin` and a `cos` per read; with integer offsets instead, 128 reads cost 10.5 ms rather than 16.2. +- **naga's `force_loop_bounding` is off** (`shader_fx::to_msl`): its guard, a `volatile` read per iteration, cost 7 to 10 % of every row above. The validation has already proved each loop bounded. +- **Compiling is ten times slower than FXC.** naga, `new_library_with_source` and the pipeline state take 120 ms in the median for a source Metal has not seen (the C.6 ripple), and 160 to 375 ms for the first one in a process. A source Metal has seen comes back in 0.4 ms in the same process, 3 ms in another. All of it runs on the background thread; the preview shows the effect once it lands. `checkShaderEffect` compiles on the calling thread, so an agent's `addEffect` took 152 ms through the `mcp` CLI. +- **The export does not notice an effect:** 180 frames at 1752×1080 (`exportMulti`, three alternated runs after a warm-up) took 2469 ms without, 2428 ms with the ripple over the whole video, and 2375 ms with the spike's heavier effect. The M1 export is encoder-bound. The live view held 29.7 fps against a 30 fps source with and without the effect. +- **Not measured yet:** the Radeon 610M. ## How we got here — the WebCodecs trail diff --git a/technical-documentation/testing/manual-e2e-checklist.md b/technical-documentation/testing/manual-e2e-checklist.md index 9b212b5e0..5358185fa 100644 --- a/technical-documentation/testing/manual-e2e-checklist.md +++ b/technical-documentation/testing/manual-e2e-checklist.md @@ -960,3 +960,4 @@ These checks are the human slice: an agent cannot click Apple's picker (see abov | 2026-10-13 | `release/v2.1.0` @ `912b44800` (fast-forwarded to `main`: #1144, #1145, #1150, #1152, #1153, #1154, #1156, #1161, #1163, #1165), CI artifact `openscreen-windows-x64` from build run 38370716184 (NSIS sha512 matched `update-info-win-x64.json`; the run's macOS x64 job failed once on a notarization-staple network error, `NSURLErrorDomain -1005`, and passed on re-run), silent per-user install, About still `2.0.0` (intermediate build) | Windows 11 Home 26200 (x64), Ryzen 7 5800X, RTX 4070 Ti, 1920×1080 @ 100 %; Logitech StreamCam | **Pass — scoped re-run of the fixes from the 2026-10-12 row, 1 new minor** | Real OS input through computer-use, measurements on the files. **#945 / #1165:** 10:57 full-screen take, StreamCam 1080p30 + system audio, while 44 CLI exports ran back to back: screen 657.535 s, audio 657.536 s, webcam 657.537 s (2026-10-12: screen −187 ms and −221 ms); HUD-timer flips filmed at 21, 301, 451, 601 and 651 s all sit at +0.033 to +0.050 s, so the screen holds the wall clock to one frame over the whole take (2026-10-12: −217 ms after 5:00); TTS markers drift 0 ms over 567 s. **#1163:** CLI export of the two-clip project at 60 fps gave 4886 frames against 4886.8 predicted from the clips' real video length (48.765 s each); the progress total said 4890, the residual the PR documents. **#1154:** with the project open in the editor, stdio `listProjects` reports it `open: true`, `createCheckpoint` + `addTrim` is refused ("open in the OpenScreen editor …") and the file is untouched; `.open-in-editor.json` carries the GUI's pid. **#1145:** an installed `Openscreen.exe mcp` locks its own `cli-sessions\-\Local Storage`, never the GUI profile, with no cache error, and dead runs' folders are swept on the next run. The GUI-side symptom (language prompt on every launch) could not be re-tested: ten older dev `electron … mcp` servers from another worktree, built before the fix, still hold `%APPDATA%\openscreen\Local Storage\leveldb\LOCK`. **#1150/#1153:** `listSources`, `startRecording`'s not-found error and `stopRecording` now say what is not listed and how clicks share zooms; the not-found error still carries a JavaScript stack trace. **#1152:** a project's first chat message shows `11:16:44`, like the reply. **#1156:** Enter in the Open project search opens the highlighted single match. **#1161:** HUD dropped at y≈200, the language menu and Device settings each push the bar down while open and it returns to y≈200 once they close. **#1144:** two identical 1.5× regions, the second dragged into the first, stop flush at 25.173 s with the length kept (4.983 s). **New, minor:** adding a region with its key while the playhead is inside an existing region of the same kind creates an overlapping one, whatever the values (2× 20.190–25.173 then 1.5× 23.253–28.236), the creation counterpart of #1133. Not re-run: microphone (no working input on this machine), everything outside the fixed points. | | 2026-10-14 | `main` @ `b668b2c8d` (MCP unification #1174: #1175, #1176, #1179; editor parity #1180: #1181 to #1186), CI artifact `openscreen-windows-x64` from build run 38538195250 (NSIS sha512 matched `update-info-win-x64.json`), silent per-user install, About still `2.0.0` (intermediate build) | Windows 11 Home 26200 (x64), Ryzen 7 5800X, RTX 4070 Ti, 1920×1080 @ 100 %; Logitech StreamCam | **Partial — MCP server section only, 2 defects** | Real OS input through computer-use; MCP calls from an SDK client (`@modelcontextprotocol/sdk`) to the installed `Openscreen.exe mcp`, with the app running unless stated. **Tools:** `listTools` lists all 51 tools (record, export, frames, settings, captions, transcription, clips, projects, media). **Record:** `startRecording` on a Notepad window with `camera: "StreamCam"` and system audio, then real clicks and `stopRecording`, gives an 890×596 60 fps take with a webcam track, a cursor sidecar and 1 zoom for 3 close clicks. `cursor: "system"` gives no zoom and the session says `system`. A take dies with its stdio server: a client that disconnects after `startRecording` leaves an 83-byte MP4 and an empty webcam file. **Closed project, app running:** `restoreCheckpoint` with the checkpoint from another client's `stopRecording` works, because it lives in the app. **Media, transcription, captions:** `importMedia` of a spoken 17.2 s MP4, then `insertClip`. `transcribe` finds 40 English words in about 15 s, and `getCaptionTranslationUnits fr` returns 6 units. `putCaptionTranslation` writes all 6, and `setCaptionSettings {enabled, style: bold, language: fr}` shows them. **Look:** `setEditorSettings` applies a laptop frame, a wallpaper, 16:9 and a camera blur cut-out, and turns format fill on as the editor does. The `inactive` reasons are read back. Also tested: `addAnnotation` for a 72 px text and a mosaic blur. **Export:** `exportVideo` at `fps: 30` gives 1920×1080 at 30 fps, 1472 frames for 49.07 s. The frames at 5 and 35 s show the blur, the webcam cut-out top-right, the title and the French captions. `gifLoop` on an `.mp4` is refused. **Open in the editor:** `listProjects` reports `open: true`. `addZoom` and a wallpaper change land live, and each comes off with one Ctrl+Z. `restoreCheckpoint` resets the take live, and one Ctrl+Z brings every edit back. **App quit and relaunch:** one client session spans an app quit and a relaunch. With the app gone, an edit asks for a checkpoint and lands in the file after `createCheckpoint`. Once the app is relaunched and the project reopened, the same session's edit shows live, with no client restart. **Other tools:** `setProjectInfo`, `duplicateClip`, a clip `cropRegion`, `addAutoZooms` (0 new, telemetry reported per asset) and `importMedia` of an MP3 work. `removeMedia` refuses an asset still on the timeline and names its clips, and `createProject` from a video returns a checkpoint. **CLI:** `Openscreen.exe captions --max-words 5` writes a 40-word transcript, turns captions on, and leaves no `auto-caption` annotation. **Defect 1 (P1): every GIF export over MCP fails.** It fails with the app running and without it: `exportVideo` to `.gif` → "--quality is only supported for MP4 exports", because `electron/mcp/media-tools.ts` always sends `quality: "good"` and the export runner refuses a quality on a GIF. **Defect 2 (P2): the recording bar loses always-on-top** after the source picker or the language prompt closes. Its window style goes from `0x280028` to `0x200000` and it sinks behind other windows; Ctrl+Shift+O brings it forward, still not topmost. **Minor:** `camera: true` picks the first listed camera, a virtual "VCam Camera" here, which wrote a 0-byte webcam file that the take dropped without a word. The language prompt shows on every launch, the #1145 symptom: ten older dev `mcp` servers from another worktree still hold the profile. The first two MCP takes had silent system audio (−91 dB) although the narration played; a take from the bar and three later MCP takes, with and without StreamCam, all had it (−3 dB), so this is not attributed. Not run: the in-app HTTP server, because copying its access token is refused to the agent; the microphone, with no working input on this machine; everything outside the MCP section. | | 2026-10-14 | `main` @ `5bc559198` (adds #1192 GIF export over MCP, #1194 recording bar kept on top, #1197 GIF plays once), CI artifact `openscreen-windows-x64` from build run 38564622152 (NSIS sha512 matched `update-info-win-x64.json`), silent per-user install, About still `2.0.0` (intermediate build) | Windows 11 Home 26200 (x64), Ryzen 7 5800X, RTX 4070 Ti, 1920×1080 @ 100 %; Logitech StreamCam | **Pass — MCP server section re-run, both earlier defects fixed** | Same scope and method as the earlier row of this day (run 38538195250). **#1194:** the recording bar keeps `WS_EX_TOPMOST` (`0x280028`) after the language prompt and after the source picker, and stays above a Notepad window brought to the front. **#1192 / #1197:** `exportVideo` to a `.gif` with `gifSize: "small"` gives 852×480 at 15 fps; with `gifLoop: false` the file carries no NETSCAPE block, so it plays once, and with looping on its count is 0. **Record:** `startRecording` on a Notepad window with `camera: "StreamCam"` and system audio gives a 40.6 s 1166×888 60 fps take with a webcam file, system audio at −3.6 dB and 1 zoom. **Transcript and captions:** `transcribe` finds 18 English words, and `putCaptionTranslation` writes 3 French units. **Look and annotations:** `setCaptionSettings` shows the French captions in the `bold` style. `setEditorSettings` applies a laptop frame, 16:9 and a camera blur cut-out, and `addAnnotation` adds a 72 px title and a mosaic blur. **MP4 export:** 1920×1080 at 30 fps, 1219 frames for 40.63 s; the 4 s frame shows the title, the French caption, the blur and the cut-out webcam. **Open project:** `listProjects` reports `open: true`. A zoom and a wallpaper change land live, and each comes off with one Ctrl+Z. **One client session across an app quit and relaunch:** with the app gone, an edit asks for a checkpoint and lands in the file after `createCheckpoint`. Back in the reopened editor, the next edit shows live. **Media, clips, checkpoints:** `importMedia` of an MP4 and an MP3, `insertClip`, `addAudio` with a fade, `duplicateClip`, a clip `cropRegion` and `setProjectInfo` work. `removeMedia` refuses an asset still on the timeline. `restoreCheckpoint` on the open project resets it, and one Ctrl+Z brings every edit back. **Without the app:** `createProject` from a video returns a checkpoint, and `Openscreen.exe captions` writes a 40-word transcript with captions on and no `auto-caption` annotation. Not run: the in-app HTTP server (copying its access token is refused to the agent), the microphone (no working input on this machine), everything outside the MCP section. | +| 2026-10-14 | dev, local branch `test/shader-fx-macos-e2e`: `feat/shader-effects-macos` (the Metal pass) with #1202 merged in locally, never pushed, plus a local-only line that writes each export's scene to a file. Compositor addon rebuilt from that tree (string probe `no Metal device` HIT, control `wallpaper image` HIT), `npm run build-vite` | macOS 26.5 (25F71), Mac mini M1, on mains power | Partial: agent-written shader effects only, pass | Headless, so no computer-use: `electron . --user-data-dir= mcp` over stdio, and the addon loaded in Node for the live view (`createView`, `setScene`, `setParam` as `pushAllNativeParams` does, `presentTime`, `readFrame`). A 6 s 1920×1080 test pattern with a hand-written `.cursor.json` of three clicks. **Agent loop:** `addEffect` with a typo refused in 6 ms at the agent's line 10; the C.6 ripple accepted in 152 ms, real Metal compile included, `validated: true`, tier light; `exportVideo` then `getFrames` show the ripple after each click and nothing at 0.5 s. **Preview against export** (BT.709): 3.1/255 over the whole frame with the effect, 3.0 without; on the pixels the effect changes, 13 to 16/255 against the export with the effect and 14 to 16 against the one without on the same pixels with no effect, which is the codec on sharp edges (two exports of the same project decode to identical pixels). The live view held 29.7 fps against the 30 fps source with and without. **Export** of 180 frames at 1752×1080: 2469 ms without, 2428 with the ripple, 2375 with a heavier effect. **Driver note:** without the `setParam` calls the editor makes, the headless view draws the cursor at its default size, which reads as a preview/export mismatch. Not run: real capture, the editor UI (effects have no track until the UI PR), the in-app agent, a `text`-target effect over a real caption (covered by `an_annotation_stays_out_of_image_effects_and_inside_text_effects` on the Metal device). |