Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 9 additions & 5 deletions crates/compositor/src/compositor_linux.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1238,14 +1238,15 @@ impl Compositor {
}

pub fn set_scene(&self, s: Option<Scene>) {
*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`.
Expand Down Expand Up @@ -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);
}
}
Expand Down
14 changes: 9 additions & 5 deletions crates/compositor/src/compositor_windows.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Scene>) {
*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`.
Expand Down Expand Up @@ -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);
}
}
Expand Down
128 changes: 88 additions & 40 deletions crates/compositor/src/shader_fx.rs
Original file line number Diff line number Diff line change
Expand Up @@ -389,7 +389,7 @@ struct CostWalk<'a> {
impl CostWalk<'_> {
fn block(&mut self, func: &naga::Function, block: &naga::Block) -> Result<Cost, String> {
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
Expand All @@ -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)
}
Expand Down Expand Up @@ -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<naga::Handle<naga::Expression>>,
Expand Down Expand Up @@ -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<naga::Expression>| {
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,
Expand Down Expand Up @@ -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<naga::LocalVariable>) -> Option<f64> {
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<naga::LocalVariable>) -> Option<f64> {
continuing.iter().find_map(|s| match s {
Expand All @@ -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<naga::LocalVariable>) -> 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<naga::LocalVariable>) -> usize {
let is_var = |h: naga::Handle<naga::Expression>| {
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()
}
}

Expand Down Expand Up @@ -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<Item = (u64, u64)>, scene: &[SceneShaderEffect]) -> Option<u64> {
let shown: std::collections::HashSet<u64> = 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> {
Expand Down Expand Up @@ -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<f32>) -> vec4<f32> {{\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<f32>) -> vec4<f32> {\n var acc = vec4<f32>(0.0);\n for (var i = 0; i < TAPS; i += 3) { acc += frame(uv); }\n return acc;\n}\n";
Expand Down Expand Up @@ -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<f32>) -> vec4<f32> { 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)];
Expand Down
14 changes: 5 additions & 9 deletions docs/shader-effects.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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).**
Expand Down
12 changes: 12 additions & 0 deletions electron/ai-edition/agent-tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
16 changes: 13 additions & 3 deletions electron/ai-edition/agent-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
14 changes: 10 additions & 4 deletions electron/native-bridge/services/compositorViewService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading
Loading