diff --git a/crates/compositor/src/compositor_linux.rs b/crates/compositor/src/compositor_linux.rs index 59b2a3b7d..464b1debe 100644 --- a/crates/compositor/src/compositor_linux.rs +++ b/crates/compositor/src/compositor_linux.rs @@ -1238,14 +1238,15 @@ impl Compositor { } pub fn set_scene(&self, s: Option) { + *self.scene.borrow_mut() = s; // Les effets 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. - if let Some(scene) = &s { + // 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. + if let Some(scene) = self.scene.borrow().as_ref() { for effect in &scene.shader_effects { self.shader_fx_entry(effect); } } - *self.scene.borrow_mut() = s; } /// Faux pour la preview : voir `shader_fx_blocking`. @@ -1290,9 +1291,12 @@ impl Compositor { used: tick, }) .used = tick; - // Un agent qui itère crée une entrée par version : on garde les plus récentes. + // 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 { - if let Some(oldest) = fx.iter().min_by_key(|(_, e)| e.used).map(|(k, _)| *k) { + 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); } } diff --git a/crates/compositor/src/compositor_windows.rs b/crates/compositor/src/compositor_windows.rs index 8de571408..5627c35b4 100644 --- a/crates/compositor/src/compositor_windows.rs +++ b/crates/compositor/src/compositor_windows.rs @@ -900,14 +900,15 @@ impl Compositor { /// Installe (ou retire) la scène de l'app. Présente → `compose_frame` prend ses placements /// depuis le layout preset au lieu du planning fixture. pub fn set_scene(&self, s: Option) { + *self.scene.borrow_mut() = s; // Les effets 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. - if let Some(scene) = &s { + // 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. + if let Some(scene) = self.scene.borrow().as_ref() { for effect in &scene.shader_effects { self.shader_fx_entry(effect); } } - *self.scene.borrow_mut() = s; } /// Faux pour la preview : voir `shader_fx_blocking`. @@ -952,9 +953,12 @@ impl Compositor { used: tick, }) .used = tick; - // Un agent qui itère crée une entrée par version : on garde les plus récentes. + // 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 { - if let Some(oldest) = fx.iter().min_by_key(|(_, e)| e.used).map(|(k, _)| *k) { + 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); } } diff --git a/crates/compositor/src/shader_fx.rs b/crates/compositor/src/shader_fx.rs index b43f9e6ad..babc4f420 100644 --- a/crates/compositor/src/shader_fx.rs +++ b/crates/compositor/src/shader_fx.rs @@ -389,7 +389,7 @@ struct CostWalk<'a> { impl CostWalk<'_> { fn block(&mut self, func: &naga::Function, block: &naga::Block) -> Result { let mut total = Cost::default(); - for (stmt, span) in block.span_iter() { + for (at, (stmt, span)) in block.span_iter().enumerate() { let c = match stmt { naga::Statement::Emit(range) => { let reads = range @@ -415,7 +415,7 @@ impl CostWalk<'_> { worst } naga::Statement::Loop { body, continuing, break_if } => { - let trips = self.trip_count(func, block, body, continuing, *break_if, *span)?; + let trips = self.trip_count(func, (block, at), body, continuing, *break_if, *span)?; let inner = self.block(func, body)?.add(self.block(func, continuing)?); Cost { reads: inner.reads, iterations: inner.iterations.saturating_add(1) }.times(trips) } @@ -447,11 +447,12 @@ impl CostWalk<'_> { } /// Le nombre de tours d'une boucle `for` à bornes littérales. Toute autre forme est refusée : - /// une boucle qui ne finit pas bloque le GPU jusqu'au TDR. + /// une boucle qui ne finit pas bloque le GPU jusqu'au TDR. `(parent, at)` : le bloc de la + /// boucle et son rang dans ce bloc. fn trip_count( &self, func: &naga::Function, - parent: &naga::Block, + (parent, at): (&naga::Block, usize), body: &naga::Block, continuing: &naga::Block, break_if: Option>, @@ -486,17 +487,35 @@ impl CostWalk<'_> { _ => return Err(self.refuse(span, "is not bounded by a literal")), }, }; - let init = func.local_variables[var] - .init - .and_then(|h| self.literal(func, h)) - .or_else(|| self.store_before(func, parent, var)) - .ok_or_else(|| self.refuse(span, "starts its counter from a value that is not a literal"))?; let step = self .step(func, continuing, var) .ok_or_else(|| self.refuse(span, "does not step its counter by a literal in its header"))?; - if Self::writes(func, body, var) { + if Self::writes(func, body, var) > 0 { return Err(self.refuse(span, "changes its counter inside its body")); } + // La valeur à l'entrée : le dernier `Store` qui précède la boucle dans son bloc, sinon + // l'initialisation. Elle n'est sûre que si rien d'autre n'écrit le compteur : une écriture + // dans un autre bloc, après la boucle quand son bloc se répète, ou par un pointeur, la + // changerait sans que le compte le voie, et le plafond de sécurité serait faux. + let is_var = |h: naga::Handle| { + matches!(func.expressions[h], E::LocalVariable(v) if v == var) + }; + let before: Vec<_> = parent + .iter() + .take(at) + .filter_map(|s| match s { + S::Store { pointer, value } if is_var(*pointer) => Some(*value), + _ => None, + }) + .collect(); + if Self::writes(func, &func.body, var) != before.len() + 1 { + return Err(self.refuse(span, "writes its counter outside its header")); + } + let init = match before.last() { + Some(&value) => self.literal(func, value), + None => func.local_variables[var].init.and_then(|h| self.literal(func, h)), + } + .ok_or_else(|| self.refuse(span, "starts its counter from a value that is not a literal"))?; let trips = match op { B::Less if step > 0.0 => ((bound - init) / step).ceil(), B::LessEqual if step > 0.0 => ((bound - init) / step).floor() + 1.0, @@ -540,23 +559,6 @@ impl CostWalk<'_> { } } - /// Le dernier `Store` d'un littéral dans `var` qui précède une boucle dans son bloc. - fn store_before(&self, func: &naga::Function, parent: &naga::Block, var: naga::Handle) -> Option { - let mut found = None; - for s in parent.iter() { - match s { - naga::Statement::Store { pointer, value } - if matches!(func.expressions[*pointer], naga::Expression::LocalVariable(v) if v == var) => - { - found = self.literal(func, *value); - } - naga::Statement::Loop { .. } => {} - _ => {} - } - } - found - } - /// Le pas du compteur dans `continuing` : `i = i + k` ou `i = i - k`, `k` littéral. fn step(&self, func: &naga::Function, continuing: &naga::Block, var: naga::Handle) -> Option { continuing.iter().find_map(|s| match s { @@ -579,22 +581,28 @@ impl CostWalk<'_> { }) } - /// Le corps écrit-il le compteur (directement, ou en passant son pointeur à une fonction) ? - fn writes(func: &naga::Function, block: &naga::Block, var: naga::Handle) -> bool { + /// Combien d'instructions du bloc écrivent le compteur, directement ou en passant son pointeur + /// à une fonction, blocs imbriqués compris. + fn writes(func: &naga::Function, block: &naga::Block, var: naga::Handle) -> usize { let is_var = |h: naga::Handle| { matches!(func.expressions[h], naga::Expression::LocalVariable(v) if v == var) }; - block.iter().any(|s| match s { - naga::Statement::Store { pointer, .. } => is_var(*pointer), - naga::Statement::Call { arguments, .. } => arguments.iter().any(|&a| is_var(a)), - naga::Statement::Block(b) => Self::writes(func, b, var), - naga::Statement::If { accept, reject, .. } => Self::writes(func, accept, var) || Self::writes(func, reject, var), - naga::Statement::Switch { cases, .. } => cases.iter().any(|c| Self::writes(func, &c.body, var)), - naga::Statement::Loop { body, continuing, .. } => { - Self::writes(func, body, var) || Self::writes(func, continuing, var) - } - _ => false, - }) + block + .iter() + .map(|s| match s { + naga::Statement::Store { pointer, .. } => usize::from(is_var(*pointer)), + naga::Statement::Call { arguments, .. } => usize::from(arguments.iter().any(|&a| is_var(a))), + naga::Statement::Block(b) => Self::writes(func, b, var), + naga::Statement::If { accept, reject, .. } => { + Self::writes(func, accept, var) + Self::writes(func, reject, var) + } + naga::Statement::Switch { cases, .. } => cases.iter().map(|c| Self::writes(func, &c.body, var)).sum(), + naga::Statement::Loop { body, continuing, .. } => { + Self::writes(func, body, var) + Self::writes(func, continuing, var) + } + _ => 0, + }) + .sum() } } @@ -625,6 +633,14 @@ pub fn source_hash(effect: &SceneShaderEffect) -> u64 { h.finish() } +/// L'entrée à évincer d'un cache plein, parmi `(clé, dernier usage)` : la moins récemment utilisée +/// de celles que la scène n'emploie pas. Aucune si elle les emploie toutes : le cache dépasse alors +/// sa taille plutôt que de recompiler à chaque frame un effet qu'elle montre. +pub fn eviction_victim(entries: impl Iterator, scene: &[SceneShaderEffect]) -> Option { + let shown: std::collections::HashSet = scene.iter().map(source_hash).collect(); + entries.filter(|(key, _)| !shown.contains(key)).min_by_key(|&(_, used)| used).map(|(key, _)| key) +} + /// Les régions actives au temps source `t`, dans l'ordre où elles s'appliquent : par lane, puis /// dans l'ordre de la scène. pub fn active_effects(effects: &[SceneShaderEffect], t: f32) -> Vec<&SceneShaderEffect> { @@ -970,6 +986,28 @@ mod tests { } } + /// Le compteur part de la valeur qu'il a à l'entrée de SA boucle : le dernier `Store` qui la + /// précède dans son bloc, pas une écriture plus loin. Une écriture ailleurs le rendrait + /// inconnu : refusée, pour que le plafond de sécurité ne compte jamais trop peu de tours. + #[test] + fn the_counter_starts_from_its_value_at_the_loop() { + let wrap = |body: &str| format!("fn effect(uv: vec2) -> vec4 {{\n var i = 0;\n{body}\n return frame(uv);\n}}\n"); + let reused = wrap(" i = 2;\n for (; i < 6; i++) {}"); + assert_eq!(analyze(&reused, &[]).expect("analyze").report.iterations, 4); + let refused = [ + // Écrit après la boucle : si son bloc se répète, la valeur d'entrée n'est plus 0. + wrap(" for (; i < 4; i++) {}\n i = -100000;"), + // Écrit dans un autre bloc avant la boucle : 100 004 tours, pas 4. + wrap(" if (uv.x > 0.5) { i = -100000; }\n for (; i < 4; i++) {}"), + // Deux boucles sur le même compteur : chacune voit le pas de l'autre. + wrap(" for (; i < 4; i++) { }\n for (; i < 8; i++) { }"), + ]; + for code in refused { + let err = analyze(&code, &[]).err().unwrap_or_else(|| panic!("accepted: {code}")); + assert!(err.contains("writes its counter outside its header"), "{err}"); + } + } + #[test] fn a_module_constant_is_a_literal_bound() { let code = "const TAPS = 12;\nfn effect(uv: vec2) -> vec4 {\n var acc = vec4(0.0);\n for (var i = 0; i < TAPS; i += 3) { acc += frame(uv); }\n return acc;\n}\n"; @@ -1063,6 +1101,16 @@ mod tests { assert_eq!((ctx.footage_aspect, ctx.footage_warp), (16.0 / 9.0, 1.0)); } + #[test] + fn eviction_spares_the_effects_the_scene_shows() { + let scene = [test_effect(VIGNETTE, vec![], &[]), test_effect("fn effect(uv: vec2) -> vec4 { return frame(uv); }", vec![], &[])]; + let (a, b) = (source_hash(&scene[0]), source_hash(&scene[1])); + let entries = || [(a, 1), (b, 2), (7, 5), (9, 3)].into_iter(); + assert_eq!(eviction_victim(entries(), &scene), Some(9), "la plus ancienne hors de la scène"); + assert_eq!(eviction_victim(entries(), &[]), Some(a), "sans scène, la plus ancienne"); + assert_eq!(eviction_victim([(a, 1), (b, 2)].into_iter(), &scene), None, "tout est montré"); + } + #[test] fn the_cache_key_follows_code_and_layout_not_values() { let p = vec![decl("strength", ShaderParamType::Number)]; diff --git a/docs/shader-effects.md b/docs/shader-effects.md index 039445a18..da93f5cfa 100644 --- a/docs/shader-effects.md +++ b/docs/shader-effects.md @@ -410,7 +410,7 @@ rien y retrouver : il n'existe aucun chemin vers l'image d'avant le flou. Il y a deux compilations, à deux moments : 1. **À l'écriture.** Quand un outil pose ou change un code (`addEffect`, - `setEffectDefinition`), le processus principal appelle `validateShaderEffect` de l'addon. + `setEffectDefinition`), le processus principal appelle `checkShaderEffect(code, params)` de l'addon. - **La compilation est réelle,** pour le backend de la machine, en plus de la validation naga. « Accepté » veut donc dire « se dessinera ». - **Le coût est estimé** depuis le code (C.7), sans rendu ni mesure sur la machine. @@ -585,13 +585,9 @@ coûte un export complet. Sur un projet de dix minutes, la boucle de l'agent dev ### E.6 L'agent intégré -Il a les mêmes outils, mais pas le validateur : `ChatRunEnv` ne le transmet pas. Il manque -quatre ajouts d'une ligne, sur le modèle de `cameraSegmentation` : -- le champ dans `ChatRunEnv` ; -- son passage dans `chat-service.ts` ; -- le champ dans `InvokeArgs`, et son passage à `buildTools` ; -- `validateShaderEffect: (code) => loudnessService.validateShaderEffect(code)` dans - `handlers.ts`, à côté de `cameraSegmentation`. +Il a les mêmes outils et le même validateur que le serveur MCP : `handlers.ts` passe +`checkShaderEffect: (code, params) => loudnessService.checkShaderEffect(code, params)` à +`runChat`, qui le transmet par `ChatRunEnv` et `InvokeArgs` jusqu'à `buildTools`. ### E.7 Le banc (workbench) @@ -782,7 +778,7 @@ ailleurs (`legacyEditor`, sans ancre). `ctx.footage`, `ctx.camera`, `fx_px`, `fx_hash` ; borne littérale des boucles et plafond d'itérations ; estimation du coût et paliers, calibrés en ms sur le GPU intégré de référence. - - `validate_shader_effect` compile pour le backend et rend le palier. + - `check_shader_effect` compile pour le backend et rend le palier. - `scene.rs` lit la nouvelle forme ; la passe Windows gère les trois cibles (calque de textes à part pour `text`), `lane` et l'éviction LRU. 2. **Modèle de données et DSL (TS).** diff --git a/electron/ai-edition/agent-tools.test.ts b/electron/ai-edition/agent-tools.test.ts index 2d16fc3f2..ca4261cf2 100644 --- a/electron/ai-edition/agent-tools.test.ts +++ b/electron/ai-edition/agent-tools.test.ts @@ -3859,6 +3859,18 @@ describe("shader effects", () => { }); }); + it("moves a region against its own lane only: another lane's effect does not shorten it", () => { + const below = add(fixtureDocument()).document as AxcutDocument; + const doc = add(below, { startSec: 8, endSec: 9, lane: 1 }).document as AxcutDocument; + const upper = doc.shaderEffectRanges?.find((r) => r.lane === 1); + const moved = run(doc, "setEffect", { effectRegionId: upper?.id, startSec: 4 }); + expect(moved.ok).toBe(true); + expect(moved.document?.shaderEffectRanges?.find((r) => r.lane === 1)).toMatchObject({ + startMs: 4000, + endMs: 9000, + }); + }); + it("changes the definition for every region, recompiles, and drops a removed parameter's values", () => { const doc = add(fixtureDocument(), { values: { tint: "#ff0000" } }).document as AxcutDocument; const effectId = doc.shaderEffects?.[0].id; diff --git a/electron/ai-edition/agent-tools.ts b/electron/ai-edition/agent-tools.ts index 4de94e670..91c18d756 100644 --- a/electron/ai-edition/agent-tools.ts +++ b/electron/ai-edition/agent-tools.ts @@ -3431,10 +3431,20 @@ export function executeAgentTool( const blocked = effectLaneBlocked(patched, lane, startMs, endMs, pillIds); if (blocked) return failure(`The effect was NOT changed: ${blocked}`); const moved = parsed.data.startSec !== undefined || parsed.data.endSec !== undefined; + // Clamped against its own lane only, the one `effectLaneBlocked` checked: effects of other + // lanes stack with it and must not shorten it. const rebuilt = moved - ? replacePillSpan(patched, existing.id, startMs, endMs, document.timeline.clips, () => - createId("fxr"), - ) + ? [ + ...patched.filter((r) => r.lane !== lane), + ...replacePillSpan( + patched.filter((r) => r.lane === lane), + existing.id, + startMs, + endMs, + document.timeline.clips, + () => createId("fxr"), + ), + ] : patched; const landing = landingAfterPillEdit(prev, rebuilt, pillIds, document); if (!landing.anchored) return coversNoClip("effect", startMs / 1000, endMs / 1000, document); diff --git a/electron/native-bridge/services/compositorViewService.ts b/electron/native-bridge/services/compositorViewService.ts index 9a88969e1..fd0a25b52 100644 --- a/electron/native-bridge/services/compositorViewService.ts +++ b/electron/native-bridge/services/compositorViewService.ts @@ -598,10 +598,16 @@ export class CompositorViewService { ): ShaderEffectCheck | null { const addon = this.ensureAddon(); if (!addon || typeof addon.checkShaderEffect !== "function") return null; - return addon.checkShaderEffect( - code, - JSON.stringify(params.map(({ name, type }) => ({ name, type }))), - ); + try { + return addon.checkShaderEffect( + code, + JSON.stringify(params.map(({ name, type }) => ({ name, type }))), + ); + } catch (err) { + // A native throw is the addon failing, not the effect: "not checked", as documented. + console.warn("[compositor-view] checkShaderEffect failed:", err); + return null; + } } /** Subject mask for one camera frame, no view needed — see `segmentFrame` in the addon. diff --git a/technical-documentation/architecture/ai-agent.md b/technical-documentation/architecture/ai-agent.md index 7b5f44333..5a90259ef 100644 --- a/technical-documentation/architecture/ai-agent.md +++ b/technical-documentation/architecture/ai-agent.md @@ -69,10 +69,10 @@ The model never free-writes the project document. It can only call the fixed set | `setEffectDefinition` | Changes an effect's name, description, code or parameters, and so every region using it; new code is checked first. A removed parameter's values drop. | The `shaderEffects` entry and its regions' `values`. | | `getEffect` | Reads one effect in full, code included; `getCurrentDocument` lists effects without their code. | Read only. | | `removeTrim` | Deletes a trim so its source span plays and exports again. | `timeline.trimRanges`. | -| `removeModifier` | Resolves and deletes a zoom, speed, annotation, or camera-fullscreen pill by ID; `removedIds` reports every touching storage row deleted with that pill. | The matching modifier collection. | +| `removeModifier` | Resolves and deletes a zoom, speed, annotation, camera-fullscreen, audio, or effect pill by ID; `removedIds` reports every touching storage row deleted with that pill. | The matching modifier collection. | | `removeClip` | Deletes a placed clip, closes the gap, and drops effects anchored only to it. The result names the modifiers and trims it took with it. | Timeline clips and affected anchored modifiers. | -Clips and trims use source time. Zoom, speed, annotation, and camera-fullscreen tools use virtual edited-timeline time; the executor converts these spans to the clip-anchored millisecond representation used by the document. +Clips and trims use source time. Zoom, speed, annotation, camera-fullscreen, and effect tools use virtual edited-timeline time; the executor converts these spans to the clip-anchored millisecond representation used by the document. ## Checkpoints and undo diff --git a/workbench/lib/editorial.ts b/workbench/lib/editorial.ts index 1523e5baa..5c7c989ab 100644 --- a/workbench/lib/editorial.ts +++ b/workbench/lib/editorial.ts @@ -538,7 +538,14 @@ export function zoomIssues(document: AxcutDocument, options: ZoomHygieneOptions // ─── 6. did it do THAT, and nothing else ──────────────────────────────────── -export type EditFamily = "clip" | "trim" | "zoom" | "annotation" | "speed" | "cameraFullscreen"; +export type EditFamily = + | "clip" + | "trim" + | "zoom" + | "annotation" + | "speed" + | "cameraFullscreen" + | "shaderEffect"; export interface FamilyDelta { family: EditFamily; diff --git a/workbench/scenarios/effect-ripple-on-click.scn.ts b/workbench/scenarios/effect-ripple-on-click.scn.ts index e1a87ce1c..651c3c422 100644 --- a/workbench/scenarios/effect-ripple-on-click.scn.ts +++ b/workbench/scenarios/effect-ripple-on-click.scn.ts @@ -73,7 +73,15 @@ export default defineScenario({ id: "dsl.effect.covers-the-clicks", weight: 3, check: (c) => { - const ranges = c.after.shaderEffectRanges ?? []; + // Only a range of an effect that reads the clicks makes a ripple there. + const readsClicks = new Set( + (c.after.shaderEffects ?? []) + .filter((e) => e.code.includes("ctx.clicks")) + .map((e) => e.id), + ); + const ranges = (c.after.shaderEffectRanges ?? []).filter((r) => + readsClicks.has(r.effectId), + ); const missed = DWELLS.filter( (d) => !ranges.some( @@ -83,7 +91,7 @@ export default defineScenario({ return missed.length === 0 ? pass() : fail( - `clics hors de toute région d'effet : ${missed.map((d) => `${d.atSec} s`).join(", ")}`, + `clics hors de toute région d'un effet qui lit ctx.clicks : ${missed.map((d) => `${d.atSec} s`).join(", ")}`, ); }, },