Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
49ef517
docs: spec agent-written shader effects
EtienneLescot Oct 14, 2026
fa56aee
feat(compositor): shader effects contract v1 and the Windows pass
EtienneLescot Oct 14, 2026
b202748
feat(effects): shader effects in the document, and the agent tools
EtienneLescot Oct 14, 2026
5a68fc6
feat(compositor): the Linux pass of shader effects
EtienneLescot Oct 14, 2026
b57881d
feat(effects): the recording's surface in the effect contract
EtienneLescot Oct 14, 2026
9c984b6
fix(effects): address the review of the shader effects integration
EtienneLescot Oct 14, 2026
5d2e36f
ci: run CI on integration branches
EtienneLescot Oct 14, 2026
69eb43c
feat(compositor): the macOS shader effects pass (Metal)
EtienneLescot Oct 14, 2026
6a39c3c
feat(compositor): the macOS effect inputs carry the footage plane
EtienneLescot Oct 14, 2026
b4728e2
feat(compositor): the Metal pass keeps what the scene shows, and maps…
EtienneLescot Oct 14, 2026
4a6a6d5
fix(compositor): a hidden cursor still hands effects its pointer and …
EtienneLescot Oct 14, 2026
8f61301
feat(effects): check an effect's code off the main thread, and price …
EtienneLescot Oct 14, 2026
857e5fd
test(compositor): the Windows test frame carries its size
EtienneLescot Oct 14, 2026
ad27752
feat(effects): getFrames renders from the document when no export is …
EtienneLescot Oct 14, 2026
e022cf4
refactor(effects): one effect at a time, no lanes
EtienneLescot Oct 14, 2026
2e5bea0
feat(effects): the Effects lane and the effect inspector
EtienneLescot Oct 14, 2026
a993ac7
docs(website): the Custom effects page
EtienneLescot Oct 14, 2026
2d2b3e7
fix(effects): getFrames names a frame it could not render, and the re…
EtienneLescot Oct 15, 2026
48615d7
fix(effects): CodeRabbit's second pass on the integration PR
EtienneLescot Oct 15, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,15 @@ name: CI
# #169 — 30k+ lines of deletion and refactor — merged into release/1.8.0 with
# every one of those jobs skipped, because a release branch matched neither
# pattern. A release branch is the LAST place to run a build unguarded.
#
# integration/** for the same reason again: a feature that spans several PRs
# (integration/shader-effects) collects them there before it goes to main, and
# those PRs change native code for platforms their author cannot build locally.
on:
pull_request:
branches: [main, feat/ai-edition, "release/**"]
branches: [main, feat/ai-edition, "release/**", "integration/**"]
push:
branches: [main, feat/ai-edition, "release/**"]
branches: [main, feat/ai-edition, "release/**", "integration/**"]

jobs:
lint:
Expand Down
1 change: 1 addition & 0 deletions crates/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ version = "0.58"
features = [
"Win32_Foundation",
"Win32_Graphics_Direct3D",
"Win32_Graphics_Direct3D_Fxc", # D3DCompile à l'exécution des effets shader (shader_fx.rs)
"Win32_Graphics_Direct3D11",
"Win32_Graphics_Dxgi",
"Win32_Graphics_Dxgi_Common",
Expand Down
218 changes: 169 additions & 49 deletions crates/compositor-view-napi/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -388,6 +388,56 @@ pub fn set_active_clip(
}
}

/// Ce que l'outil de l'agent apprend d'un effet avant de l'accepter (`docs/shader-effects.md`, E.3).
#[napi(object)]
pub struct ShaderEffectCheck {
/// L'erreur telle que l'agent doit la lire (ligne, colonne, extrait), ou `None` s'il est accepté.
pub error: Option<String>,
/// Le palier de coût : "light", "medium", "heavy" ou "very-heavy". Indicatif, jamais un refus.
pub tier: Option<String>,
/// Lectures de `frame()` par pixel, boucles multipliées par leur borne.
pub frame_reads: Option<f64>,
/// Itérations de boucle par pixel.
pub iterations: Option<f64>,
}

pub struct CheckShaderEffectTask {
code: String,
params_json: String,
}

impl Task for CheckShaderEffectTask {
type Output = ShaderEffectCheck;
type JsValue = ShaderEffectCheck;

fn compute(&mut self) -> Result<Self::Output> {
Ok(match openscreen_compositor::shader_fx::check_for_agent_json(&self.code, &self.params_json) {
Ok(report) => ShaderEffectCheck {
error: None,
tier: Some(report.tier.name().to_string()),
frame_reads: Some(report.frame_reads as f64),
iterations: Some(report.iterations as f64),
},
Err(error) => ShaderEffectCheck { error: Some(error), tier: None, frame_reads: None, iterations: None },
})
}

fn resolve(&mut self, _env: Env, check: Self::Output) -> Result<Self::JsValue> {
Ok(check)
}
}

/// Vérifie le WGSL d'un effet shader : validation, bornes des boucles, compilation réelle pour le
/// backend de la machine (FXC sur Windows, Metal sur macOS, SPIR-V sur Linux), et palier de coût.
/// `params_json` : les paramètres déclarés, `[{ "name": "strength", "type": "number" }, …]`.
///
/// `AsyncTask`, comme `segment_frame` : la compilation Metal d'une source neuve prend 120 ms, et
/// jusqu'à 375 ms pour la première du processus. Ce temps n'a rien à faire sur le thread principal.
#[napi]
pub fn check_shader_effect(code: String, params_json: String) -> AsyncTask<CheckShaderEffectTask> {
AsyncTask::new(CheckShaderEffectTask { code, params_json })
}

/// Installe la scène de l'app (JSON `SceneDescription`) sur la vue : layout preset piloté par
/// l'app au lieu de la fixture. JSON invalide → ignoré côté natif.
#[napi]
Expand Down Expand Up @@ -614,21 +664,7 @@ impl Task for ExportMultiTask {
// libopenh264 au lieu d'AMF, plutôt que d'échouer.
let gpu = Gpu::create_auto(false).map_err(|e| Error::from_reason(format!("{e:#}")))?;
self.control.check().map_err(mp4_task_error)?;
let mut cfg = config::all().pop().expect("au moins une config"); // C8
cfg.zoom = false;
cfg.layout_anim = false;
cfg.mblur_n = 1; // layout statique → pas de motion blur de layout (pas de surcoût)

// scène de l'app = même chemin que la preview live : fond, layout, webcam, curseur.
// JSON absent/invalide → pas de scène (fixture), pareil que si la preview n'en avait
// jamais reçu — jamais un fallback masquant, juste rien de configuré.
let scene = self.scene_json.as_deref().and_then(|j| Scene::from_json(j).ok());
if let Some(scene) = &scene {
cfg.bg_blur = scene.effects.blur;
cfg.cursor = scene.cursor.show;
} else {
cfg.cursor = false;
}
let (cfg, scene) = export_cfg_and_scene(self.scene_json.as_deref());

let mut export_params = pipeline::ExportParams::default();
let (width, height) = resolve_output_size(
Expand Down Expand Up @@ -713,6 +749,121 @@ fn mp4_task_error(error: anyhow::Error) -> Error {
}
}

/// La config et la scène d'un rendu d'export (MP4, GIF, frames) : la C8 en layout statique, et ce
/// que la scène de l'app règle. Scène = même chemin que la preview live : fond, layout, webcam,
/// curseur. JSON absent ou invalide → pas de scène (fixture), pareil que si la preview n'en avait
/// jamais reçu : jamais un fallback masquant, juste rien de configuré.
fn export_cfg_and_scene(scene_json: Option<&str>) -> (config::Cfg, Option<Scene>) {
let mut cfg = config::all().pop().expect("au moins une config"); // C8
cfg.zoom = false;
cfg.layout_anim = false;
cfg.mblur_n = 1; // layout statique → pas de motion blur de layout (pas de surcoût)
let scene = scene_json.and_then(|j| Scene::from_json(j).ok());
match &scene {
Some(scene) => {
cfg.bg_blur = scene.effects.blur;
cfg.cursor = scene.cursor.show;
}
None => cfg.cursor = false,
}
(cfg, scene)
}

fn clip_sources(clips: Vec<ClipInput>) -> Vec<pipeline::ClipSource> {
clips
.into_iter()
.map(|c| pipeline::ClipSource {
screen: c.screen_path,
webcam: c.webcam_path,
source_start_sec: c.source_start_sec,
source_end_sec: c.source_end_sec,
webcam_offset_sec: c.webcam_offset_sec,
has_audio: c.has_audio,
})
.collect()
}

/// Une frame de `render_frames`.
#[napi(object)]
pub struct RenderedFrameOutput {
/// Son indice sur la grille de sortie.
pub index: u32,
pub width: u32,
pub height: u32,
/// JPEG, qualité 85.
pub jpeg: Buffer,
}

pub struct RenderFramesTask {
clips: Vec<pipeline::ClipSource>,
scene_json: Option<String>,
fps: u32,
frames: Vec<u32>,
max_width: u32,
}

impl Task for RenderFramesTask {
type Output = Vec<(u32, u32, u32, Vec<u8>)>;
type JsValue = Vec<RenderedFrameOutput>;

fn compute(&mut self) -> Result<Self::Output> {
let reason = |e: anyhow::Error| Error::from_reason(format!("{e:#}"));
let gpu = Gpu::create_auto(false).map_err(reason)?;
let (cfg, scene) = export_cfg_and_scene(self.scene_json.as_deref());
let default = pipeline::ExportParams::default();
let (width, height) = resolve_output_size(scene.as_ref(), None, None, (default.width, default.height))?;
// Un compositeur neuf attend la compilation des effets shader, comme celui de l'export.
let comp = Compositor::new_sized(&gpu, width, height).map_err(reason)?;
if let Some(scene) = &scene {
comp.set_live_params(live_params_from_scene(scene));
}
comp.set_scene(scene);
let wanted: Vec<u64> = self.frames.iter().map(|&f| u64::from(f)).collect();
let frames = unsafe {
openscreen_compositor::timeline_walk::render_frames(
&self.clips,
&gpu,
&comp,
&cfg,
self.fps.max(1) as i32,
&wanted,
)
}
.map_err(reason)?;
frames
.into_iter()
.map(|frame| {
let index = frame.index as u32;
let (w, h, jpeg) = frame.to_jpeg(self.max_width).map_err(reason)?;
Ok((index, w, h, jpeg))
})
.collect()
}

fn resolve(&mut self, _env: Env, out: Self::Output) -> Result<Self::JsValue> {
Ok(out
.into_iter()
.map(|(index, width, height, jpeg)| RenderedFrameOutput { index, width, height, jpeg: jpeg.into() })
.collect())
}
}

/// Rend des frames de la timeline sans l'exporter : `getFrames` quand l'export manque ou date
/// (`docs/shader-effects.md`, E.4). Mêmes clips et même scène qu'`export_multi`, mêmes pixels :
/// la marche est celle de l'export (`timeline_walk::render_frames`), sans encodage. `frames` sont
/// des indices sur la grille `fps` ; ceux au-delà de la timeline ne rendent rien. Les images sont
/// en JPEG, réduites à `max_width` de large.
#[napi]
pub fn render_frames(
clips: Vec<ClipInput>,
scene_json: Option<String>,
fps: u32,
frames: Vec<u32>,
max_width: u32,
) -> AsyncTask<RenderFramesTask> {
AsyncTask::new(RenderFramesTask { clips: clip_sources(clips), scene_json, fps, frames, max_width })
}

/// Lance un export multiclip natif (vraie timeline → MP4) et résout `Promise<ExportStats>`.
/// `scene_json` : même `SceneDescription` que la preview (fond/layout/webcam/effets/curseur).
/// `params` : taille/cadence/codec de sortie voulus (absent → 1920x1080/fps du 1er clip/h264).
Expand All @@ -729,17 +880,7 @@ pub fn export_multi(
on_progress: Option<JsFunction>,
control: Option<External<ExportControl>>,
) -> Result<AsyncTask<ExportMultiTask>> {
let clips = clips
.into_iter()
.map(|c| pipeline::ClipSource {
screen: c.screen_path,
webcam: c.webcam_path,
source_start_sec: c.source_start_sec,
source_end_sec: c.source_end_sec,
webcam_offset_sec: c.webcam_offset_sec,
has_audio: c.has_audio,
})
.collect();
let clips = clip_sources(clips);
Ok(AsyncTask::new(ExportMultiTask {
out_path,
clips,
Expand Down Expand Up @@ -833,18 +974,7 @@ impl Task for ExportGifTask {
// where the CPU backend exists specifically so the export still completes.
let gpu = Gpu::create_auto(false).map_err(|e| Error::from_reason(format!("{e:#}")))?;
self.control.check().map_err(gif_task_error)?;
let mut cfg = config::all().pop().expect("au moins une config"); // C8
cfg.zoom = false;
cfg.layout_anim = false;
cfg.mblur_n = 1;

let scene = self.scene_json.as_deref().and_then(|j| Scene::from_json(j).ok());
if let Some(scene) = &scene {
cfg.bg_blur = scene.effects.blur;
cfg.cursor = scene.cursor.show;
} else {
cfg.cursor = false;
}
let (cfg, scene) = export_cfg_and_scene(self.scene_json.as_deref());

let (width, height) = resolve_output_size(
scene.as_ref(),
Expand Down Expand Up @@ -911,17 +1041,7 @@ pub fn export_gif(
// Deliberately the same argument shape as `export_multi`: the caller builds
// one clip list and one scene, and picks the container. Cursor comes from
// the scene like every other effect — there is no GIF-specific input left.
let clips = clips
.into_iter()
.map(|c| pipeline::ClipSource {
screen: c.screen_path,
webcam: c.webcam_path,
source_start_sec: c.source_start_sec,
source_end_sec: c.source_end_sec,
webcam_offset_sec: c.webcam_offset_sec,
has_audio: c.has_audio,
})
.collect();
let clips = clip_sources(clips);
let gif_params = params
.map(|p| GifExportParams {
width: p.width,
Expand Down
8 changes: 8 additions & 0 deletions crates/compositor/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ ndarray = { workspace = true, optional = true }
serde.workspace = true
serde_json.workspace = true
image.workspace = true
# Effets shader écrits à l'exécution (`shader_fx`) : la version que wgpu 24 tire déjà. Pas au-delà
# de 24 : dès la 25, la sortie HLSL ne passe plus FXC en SM 5.0 (voir `shader_fx::to_hlsl`).
naga = { version = "24", features = ["wgsl-in", "hlsl-out"] }

# Acces Vulkan brut, UNIQUEMENT pour ouvrir le device avec les extensions de
# memoire externe (cf. `d3d_linux::open_device_with_dmabuf_export`). Les versions
Expand All @@ -50,6 +53,8 @@ image.workspace = true
[target.'cfg(target_os = "linux")'.dependencies]
ash = "0.38"
wgpu-hal = { version = "24", features = ["vulkan"] }
# `shader_fx::check_for_agent` traduit l'effet en SPIR-V, comme wgpu le fera pour Vulkan.
naga = { version = "24", features = ["spv-out"] }

# Windows : D3D11 + D3D11VA + Direct2D/DirectWrite + HLSL à l'exécution.
[target.'cfg(windows)'.dependencies]
Expand Down Expand Up @@ -77,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).
Loading
Loading