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
45 changes: 33 additions & 12 deletions crates/compositor-view-napi/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -401,20 +401,41 @@ pub struct ShaderEffectCheck {
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 quand elle existe (FXC sur Windows), et palier de coût. `params_json` :
/// les paramètres déclarés, `[{ "name": "strength", "type": "number" }, …]`.
/// 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) -> ShaderEffectCheck {
match openscreen_compositor::shader_fx::check_for_agent_json(&code, &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 },
}
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
Expand Down
8 changes: 5 additions & 3 deletions docs/shader-effects.md
Original file line number Diff line number Diff line change
Expand Up @@ -416,6 +416,9 @@ Il y a deux compilations, à deux moments :
`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 ».
- **Elle tourne sur le pool de libuv** (une `AsyncTask`) : la compilation Metal d'un code neuf
prend 120 ms, et jusqu'à 375 ms pour la première du processus. `runDocumentTool` attend la
réponse, puis la passe à l'exécuteur des outils, qui reste synchrone.
- **Le coût est estimé** depuis le code (C.7), sans rendu ni mesure sur la machine.
- **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
Expand Down Expand Up @@ -743,9 +746,8 @@ module et pipeline compris ; les paliers en C.7.
| 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.
La compilation, dix fois plus lente qu'avec FXC, reste hors du rendu. Elle tourne aussi à
l'écriture (`checkShaderEffect`), d'où le pool de libuv plutôt que le thread principal (D.4).

Le détail est dans
[rendering-performance.md](../technical-documentation/engineering/rendering-performance.md).
Expand Down
36 changes: 36 additions & 0 deletions electron/ai-edition/agent-tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import {
executeAgentTool,
isMutatingTool,
MUTATING_TOOL_NAMES,
shaderEffectCheckLookup,
shaderEffectToCheck,
} from "./agent-tools";
import { measureTextInShippedFonts } from "./text-measure";

Expand Down Expand Up @@ -3924,3 +3926,37 @@ describe("shader effects", () => {
expect(JSON.parse(added.resultJson)).toMatchObject({ validated: false, cost: null });
});
});

describe("the executor's view of a check asked beforehand", () => {
const source = {
code: "fn effect(uv: vec2<f32>) -> vec4<f32> { return frame(uv); }",
params: [{ name: "level", type: "number" as const }],
};
const check = { tier: "light" as const, frameReads: 1, iterations: 0 };

it("is the answer for the same code and layout, and nothing for any other", () => {
const lookup = shaderEffectCheckLookup(source, check);
expect(lookup(source.code, [{ name: "level", type: "number" }])).toBe(check);
expect(lookup(`${source.code}\n`, source.params)).toBeNull();
expect(lookup(source.code, [{ name: "level", type: "color" }])).toBeNull();
expect(lookup(source.code, [])).toBeNull();
});

it("is asked only by the two tools that compile", () => {
const document = fixtureDocument();
expect(
shaderEffectToCheck(document, "addEffect", { startSec: 0, endSec: 1, effectId: "fx_1" }),
).toBeNull();
expect(shaderEffectToCheck(document, "addTrim", { startSec: 0, endSec: 1 })).toBeNull();
expect(
shaderEffectToCheck(document, "addEffect", {
startSec: 0,
endSec: 1,
effect: { name: "A", description: "", code: source.code },
}),
).toEqual({ code: source.code, params: [] });
expect(
shaderEffectToCheck(document, "setEffectDefinition", { effectId: "fx_missing", code: "x" }),
).toBeNull();
});
});
58 changes: 53 additions & 5 deletions electron/ai-edition/agent-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ import {
findShaderEffect,
SHADER_EFFECT_CONTRACT,
type ShaderEffectCheck,
type ShaderEffectChecker,
type ShaderEffectCheckLookup,
shaderEffectCostAdvice,
shaderEffectTierOf,
storedShaderEffectValues,
Expand Down Expand Up @@ -1526,7 +1526,7 @@ export function documentSnapshotForModel(
* wired, its cost cached. An error reads as the agent must act on it. */
function checkedEffectDefinition(
candidate: unknown,
checker: ShaderEffectChecker | undefined,
checker: ShaderEffectCheckLookup | undefined,
): { definition: AxcutShaderEffect; check: ShaderEffectCheck | null } | { error: string } {
const parsed = shaderEffectSchema.safeParse(candidate);
if (!parsed.success) {
Expand All @@ -1546,6 +1546,53 @@ function checkedEffectDefinition(
return { definition: { ...definition, cost }, check };
}

type ShaderEffectSource = {
code: string;
params: Array<{ name: string; type: AxcutShaderEffect["params"][number]["type"] }>;
};

/** The code and parameter layout `addEffect` or `setEffectDefinition` will have the compositor
* check, read from the same arguments the executor reads; `null` for a call that compiles nothing.
* The check is asynchronous (a Metal compile takes 120 ms and more) and the executor is not: the
* async wrapper asks first, then hands the answer over (`shaderEffectCheckLookup`). */
export function shaderEffectToCheck(
document: AxcutDocument,
name: string,
args: unknown,
): ShaderEffectSource | null {
const layout = (
params: ReadonlyArray<Pick<AxcutShaderEffect["params"][number], "name" | "type">>,
) => params.map(({ name, type }) => ({ name, type }));
if (name === "addEffect") {
const parsed = addEffectArgs.safeParse(args);
if (!parsed.success || !parsed.data.effect) return null;
return { code: parsed.data.effect.code, params: layout(parsed.data.effect.params ?? []) };
}
if (name === "setEffectDefinition") {
const parsed = setEffectDefinitionArgs.safeParse(args);
if (!parsed.success) return null;
const { effectId, code, params } = parsed.data;
const existing = findShaderEffect(document, effectId);
if (!existing || (code === undefined && params === undefined)) return null;
return { code: code ?? existing.code, params: layout(params ?? existing.params) };
}
return null;
}

/** The executor's view of an answer asked for `source`: that answer for the same code and
* layout, `null` (not checked) for anything else. */
export function shaderEffectCheckLookup(
source: ShaderEffectSource,
check: ShaderEffectCheck | null,
): ShaderEffectCheckLookup {
return (code, params) =>
code === source.code &&
params.length === source.params.length &&
params.every((p, i) => p.name === source.params[i].name && p.type === source.params[i].type)
? check
: null;
}

/** Two effects of one lane cannot overlap: the refusal names what is in the way, and the way out. */
function effectLaneBlocked(
ranges: AxcutShaderEffectRange[],
Expand Down Expand Up @@ -1733,9 +1780,10 @@ export interface AgentToolOptions {
/** Whether this machine can cut out the camera background, asked only by the two tools that
* report it. Absent where nothing can ask the compositor: reported as not checked. */
cameraSegmentation?: () => SegmentationSupport;
/** Asks the compositor to check an effect's WGSL (`checkShaderEffect`). Absent where nothing
* can: the effect is stored unchecked, and the result says `validated: false`. */
checkShaderEffect?: ShaderEffectChecker;
/** The compositor's check of the WGSL this call writes (`checkShaderEffect`), asked beforehand
* by the async wrapper (`shaderEffectToCheck`). Absent where nothing can: the effect is stored
* unchecked, and the result says `validated: false`. */
checkShaderEffect?: ShaderEffectCheckLookup;
/** `addAutoZooms`' plan, made by the async wrapper from the recorded clicks. Absent means
* no cursor reader is wired here, which the tool reports as "could not look". */
autoZooms?: AutoZoomPlan;
Expand Down
101 changes: 101 additions & 0 deletions electron/ai-edition/deep-agent/service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -780,3 +780,104 @@ describe("addAutoZooms reads the clicks through the runtime's cursor reader", ()
expect(result.resultJson).toContain("could not be read");
});
});

describe("an effect's code is checked off the main thread, before the executor reads it", () => {
const LEVEL = { type: "number", name: "level", label: "Level", default: 1, min: 0, max: 2 };
const effect = (code: string) => ({
name: "Dim",
description: "Dims the picture.",
code,
params: [LEVEL],
});
// The compositor's check takes a while (a Metal compile is 120 ms and more): a promise that
// settles later, refusing `nope` and pricing the rest at 20 reads.
const slowChecker = (asked: Array<{ code: string; params: unknown }>) => ({
checkShaderEffect: async (
code: string,
params: ReadonlyArray<{ name: string; type: string }>,
) => {
asked.push({ code, params });
await new Promise((resolve) => setTimeout(resolve, 5));
return code.includes("nope")
? { error: "error: no definition in scope for identifier: `nope`" }
: { tier: "medium" as const, frameReads: 20, iterations: 20 };
},
});

it("waits for the compositor's answer, accepting or refusing on it", async () => {
const asked: Array<{ code: string; params: unknown }> = [];
const runtime = slowChecker(asked);
const accepted = await runDocumentTool(
fixtureDocument(),
"addEffect",
{
startSec: 1,
endSec: 2,
effect: effect("fn effect(uv: vec2<f32>) -> vec4<f32> { return frame(uv); }"),
},
true,
runtime,
);
expect(accepted.ok).toBe(true);
const result = JSON.parse(accepted.resultJson);
expect(result.validated).toBe(true);
expect(result.cost).toMatchObject({ tier: "medium", frameReads: 20 });
expect(asked[0].params).toEqual([{ name: "level", type: "number" }]);

const refused = await runDocumentTool(
fixtureDocument(),
"addEffect",
{ startSec: 1, endSec: 2, effect: effect("fn effect(uv: vec2<f32>) -> vec4<f32> { nope }") },
true,
runtime,
);
expect(refused.ok).toBe(false);
expect(refused.resultJson).toContain("does not compile");
});

it("checks a new code against the definition's parameters when only the code changes", async () => {
const asked: Array<{ code: string; params: unknown }> = [];
const runtime = slowChecker(asked);
const added = await runDocumentTool(
fixtureDocument(),
"addEffect",
{
startSec: 1,
endSec: 2,
effect: effect("fn effect(uv: vec2<f32>) -> vec4<f32> { return frame(uv); }"),
},
true,
runtime,
);
const { effectId } = JSON.parse(added.resultJson);
const document = added.document as AxcutDocument;
const code = "fn effect(uv: vec2<f32>) -> vec4<f32> { return frame(uv) * params.level; }";
const changed = await runDocumentTool(
document,
"setEffectDefinition",
{ effectId, code },
true,
runtime,
);
expect(changed.ok).toBe(true);
expect(asked.at(-1)).toEqual({ code, params: [{ name: "level", type: "number" }] });
});

it("asks nothing when edits are off, or for a tool that compiles nothing", async () => {
const asked: Array<{ code: string; params: unknown }> = [];
const runtime = slowChecker(asked);
await runDocumentTool(
fixtureDocument(),
"addEffect",
{
startSec: 1,
endSec: 2,
effect: effect("fn effect(uv: vec2<f32>) -> vec4<f32> { return frame(uv); }"),
},
false,
runtime,
);
await runDocumentTool(fixtureDocument(), "addTrim", { startSec: 1, endSec: 2 }, true, runtime);
expect(asked).toEqual([]);
});
});
Loading
Loading