feat: move display transform into frame out

Amp-Thread-ID: https://ampcode.com/threads/T-019f9d91-77c1-7206-a60f-ed6554ce92ab

Co-authored-by: Heaust Azure <heaust.azure@gmail.com>
This commit is contained in:
Amp
2026-07-28 16:14:21 +00:00
co-authored by heaust
parent 972fadc635
commit fd85b3d051
15 changed files with 978 additions and 216 deletions
+12 -16
View File
@@ -10,17 +10,13 @@ import {
spawnRequestedNode,
} from "../static/render-graph/node-spawn.js";
test("add-node model contains all 17 catalog types in application groups", () => {
assert.equal(addNodeItems.length, 17);
test("add-node model contains all 16 catalog types in application groups", () => {
assert.equal(addNodeItems.length, 16);
assert.deepEqual(
[...new Set(addNodeItems.map((item) => item.group))],
["Source", "Compute", "CPU preparation", "Render / post", "Frame"],
);
assert.equal(new Set(addNodeItems.map((item) => item.typeId)).size, 17);
assert.deepEqual(
searchAddNodeItems("tone render").map((item) => item.typeId),
["tone_map"],
);
assert.equal(new Set(addNodeItems.map((item) => item.typeId)).size, 16);
assert.deepEqual(searchAddNodeItems("no such node"), []);
});
@@ -40,7 +36,7 @@ test("allocator avoids existing and session-reserved IDs and is bounded", () =>
);
});
test("all 17 types spawn with exact position, current version and generated ID", async () => {
test("all 16 types spawn with exact position, current version and generated ID", async () => {
let revision = 5,
expectedType;
const request = { compositionRevision: 5, viewPosition: { x: 12.25, y: -4 } };
@@ -91,13 +87,13 @@ test("spawn rechecks composition after getState and propagates add errors", asyn
},
};
assert.equal(
await spawnRequestedNode(root, view, request, "tone_map", allocate),
await spawnRequestedNode(root, view, request, "fullscreen_copy", allocate),
false,
);
revision = 2;
root.getState = async () => ({ version: 3, nodes: [] });
await assert.rejects(
spawnRequestedNode(root, view, request, "tone_map", allocate),
spawnRequestedNode(root, view, request, "fullscreen_copy", allocate),
/must not add/,
);
});
@@ -124,7 +120,7 @@ test("spawn cancels when a pending getState becomes mutated or dead", async () =
root,
view,
request,
"tone_map",
"fullscreen_copy",
() => "node_a",
() => alive,
);
@@ -136,7 +132,7 @@ test("spawn cancels when a pending getState becomes mutated or dead", async () =
root,
view,
request,
"tone_map",
"fullscreen_copy",
() => "node_b",
() => alive,
);
@@ -161,7 +157,7 @@ test("spawn has a final liveness guard after ID allocation", async () => {
root,
view,
request,
"tone_map",
"fullscreen_copy",
() => {
alive = false;
return "node_reserved";
@@ -190,7 +186,7 @@ test("spawn suppresses teardown RPC rejections but propagates genuine live add e
root,
view,
request,
"tone_map",
"fullscreen_copy",
() => "node_a",
() => alive,
),
@@ -208,7 +204,7 @@ test("spawn suppresses teardown RPC rejections but propagates genuine live add e
root,
view,
request,
"tone_map",
"fullscreen_copy",
() => "node_b",
() => alive,
),
@@ -224,7 +220,7 @@ test("spawn suppresses teardown RPC rejections but propagates genuine live add e
root,
view,
request,
"tone_map",
"fullscreen_copy",
() => "node_c",
() => alive,
),
+2 -2
View File
@@ -36,8 +36,8 @@ test("production render graph composition passes fxnode's public validator", asy
result.ok ? undefined : JSON.stringify(result.issues, null, 2),
);
assert.equal(fxNodeComposition.schemaVersion, 2);
assert.equal(fxNodeComposition.version, 5);
assert.equal(Object.keys(fxNodeComposition.nodes).length, 17);
assert.equal(fxNodeComposition.version, 6);
assert.equal(Object.keys(fxNodeComposition.nodes).length, 16);
assert.ok(
Object.values(fxNodeComposition.nodes).every(
(definition) => definition.migrations.length === 0,
+49 -4
View File
@@ -24,7 +24,7 @@ function fixture() {
return {
id: n.id,
typeId: n.executor.key,
typeVersion: 1,
typeVersion: d.version,
known: true,
muted: n.state !== "enabled",
position: { x: 10, y: 20 },
@@ -96,7 +96,7 @@ function fixture() {
test("catalog exhaustively mirrors all current contracts", () => {
for (const [key, semantic] of Object.entries(semanticCatalog)) {
assert.ok(Object.hasOwn(semantic, "version"));
assert.equal(semantic.version, 1);
assert.equal(semantic.version, key === "frame_out" ? 2 : 1);
assert.equal(nodeDefinitions[key].version, semantic.version);
assert.equal(descriptors[key].version, semantic.version);
}
@@ -110,7 +110,6 @@ test("catalog exhaustively mirrors all current contracts", () => {
"pipeline_registry",
"pipeline",
"fullscreen_copy",
"tone_map",
"color_balance",
"exposure_contrast",
"saturation",
@@ -134,7 +133,7 @@ test("catalog exhaustively mirrors all current contracts", () => {
Object.keys(contract.parameters).sort(),
key,
);
assert.equal(CATALOG_VERSION, 5);
assert.equal(CATALOG_VERSION, 6);
assert.deepEqual(nodeDefinitions.pipeline.parameters, {
pipeline: {
type: "string",
@@ -238,6 +237,7 @@ test("adapter validates and exactly lowers canonical pipeline controls and blur
visible: socket.visible,
}),
);
blur.typeVersion = descriptors.bloom_blur.version;
x.nodes.push(blur);
assert.deepEqual(
adaptFxNodeSnapshot(x).nodes.find((node) => node.id === "blur").parameters
@@ -346,6 +346,51 @@ test("adapter rejects hostile shape, IDs, duplicates, catalog, sockets and type
link.fromSocketId = "mesh:mesh";
}, "AUTHORING_LINK_TYPE");
});
test("Frame Out has the exact v2 schema, defaults, UI, and strict authoring validation", () => {
const fields = ["hdrEnabled", "toneMapper", "exposureStops", "outputTransfer", "scaleMode", "filter", "backgroundColor"];
assert.equal(CATALOG_VERSION, 6);
assert.deepEqual(semanticCatalog.frame_out, {
version: 2, execution: "frame", inputs: { color: semanticCatalog.frame_out.inputs.color }, outputs: {},
parameters: { hdrEnabled: true, toneMapper: "aces", exposureStops: 0, outputTransfer: "srgb", scaleMode: "stretch", filter: "linear", backgroundColor: [0, 0, 0, 1] },
});
assert.deepEqual(nodeDefinitions.frame_out.parameters, {
hdrEnabled: { type: "boolean", default: { kind: "boolean", value: true } },
toneMapper: { type: "string", default: { kind: "string", value: "aces" }, enum: ["aces", "reinhard", "none"] },
exposureStops: { type: "number", default: { kind: "number", value: 0 }, minimum: -10, maximum: 10 },
outputTransfer: { type: "string", default: { kind: "string", value: "srgb" }, enum: ["srgb", "linear"] },
scaleMode: { type: "string", default: { kind: "string", value: "stretch" }, enum: ["stretch", "contain", "cover"] },
filter: { type: "string", default: { kind: "string", value: "linear" }, enum: ["linear", "nearest"] },
backgroundColor: { type: "color", default: { kind: "color", value: [0, 0, 0, 1] }, minimum: 0, maximum: 1 },
});
assert.deepEqual(nodeDefinitions.frame_out.ui, [
{ kind: "text", variant: "section", title: "Display Transform" },
{ kind: "parameter", parameter: "hdrEnabled", title: "HDR" },
{ kind: "parameter", parameter: "toneMapper", title: "Tone Mapper", visibleWhen: { parameter: "hdrEnabled", equals: true } },
{ kind: "parameter", parameter: "exposureStops", title: "Exposure", visibleWhen: { parameter: "hdrEnabled", equals: true } },
{ kind: "parameter", parameter: "outputTransfer", title: "Transfer" },
{ kind: "parameter", parameter: "scaleMode", title: "Scale" },
{ kind: "parameter", parameter: "filter" },
{ kind: "parameter", parameter: "backgroundColor", title: "Background", visibleWhen: { parameter: "scaleMode", equals: "contain" } },
{ kind: "socket", socket: "color" },
]);
const reject = (mutate, code, parameter) => {
const x = fixture(), n = x.nodes.find((node) => node.typeId === "frame_out");
mutate(x, n);
assert.throws(() => adaptFxNodeSnapshot(x), (e) => e instanceof AuthoringGraphError && e.code === code && (!parameter || e.details.nodeId === n.id && e.details.parameter === parameter));
};
reject((x) => x.catalogVersion = 5, "AUTHORING_CATALOG");
reject((x, n) => n.typeVersion = 1, "AUTHORING_NODE_INVALID");
for (const field of fields) reject((x, n) => delete n.parameters[field], "AUTHORING_PARAMETER_SET");
reject((x, n) => n.parameters.extra = { kind: "number", value: 0 }, "AUTHORING_PARAMETER_SET");
for (const [field, value] of [
["hdrEnabled", 1], ["toneMapper", "bad"], ["outputTransfer", "bad"], ["scaleMode", "bad"], ["filter", "bad"],
["exposureStops", NaN], ["exposureStops", -10.01], ["exposureStops", 10.01],
["backgroundColor", [0, 0, 0]], ["backgroundColor", [0, 0, Infinity, 1]], ["backgroundColor", [-0.01, 0, 0, 1]], ["backgroundColor", [0, 0, 0, 1.01]],
]) reject((x, n) => n.parameters[field].value = value, "AUTHORING_PARAMETER", field);
for (const [hidden, value] of [["toneMapper", "bad"], ["exposureStops", Infinity]])
reject((x, n) => { n.parameters.hdrEnabled.value = false; n.parameters[hidden].value = value; }, "AUTHORING_PARAMETER", hidden);
reject((x, n) => { n.parameters.scaleMode.value = "stretch"; n.parameters.backgroundColor.value = [2, 0, 0, 1]; }, "AUTHORING_PARAMETER", "backgroundColor");
});
test("adapter counts only active incoming links and reports socket overflow", () => {
const x = fixture();
const active = x.links.find((link) => link.toSocketId === "frame_out:color");
+19 -33
View File
@@ -9,6 +9,9 @@ const order = [
"hdr",
"culling",
"tone",
"contain",
"reinhard",
"linear",
"grading",
"edges",
"bloom",
@@ -61,7 +64,6 @@ const sequences = {
["frame_out", "frame_out"],
],
tone: [
["ldr", "texture"],
["hdr", "texture"],
["depth", "texture"],
["mesh", "mesh"],
@@ -70,17 +72,15 @@ const sequences = {
["ground", "pipeline"],
["pbr", "pipeline"],
["pbr_double", "pipeline"],
["tone", "tone_map"],
["frame_out", "frame_out"],
],
grading: [
["balance_hdr", "texture"], ["exposure_hdr", "texture"], ["saturation_hdr", "texture"], ["mixer_hdr", "texture"], ["ldr", "texture"],
["balance_hdr", "texture"], ["exposure_hdr", "texture"], ["saturation_hdr", "texture"], ["mixer_hdr", "texture"],
["hdr", "texture"], ["depth", "texture"], ["mesh", "mesh"], ["query", "mesh_query"], ["registry", "pipeline_registry"],
["ground", "pipeline"], ["pbr", "pipeline"], ["pbr_double", "pipeline"], ["balance", "color_balance"], ["exposure", "exposure_contrast"],
["saturation", "saturation"], ["mixer", "channel_mixer"], ["tone", "tone_map"], ["frame_out", "frame_out"],
["saturation", "saturation"], ["mixer", "channel_mixer"], ["frame_out", "frame_out"],
],
edges: [
["ldr", "texture"],
["edge_hdr", "texture"],
["hdr", "texture"],
["depth", "texture"],
@@ -91,11 +91,9 @@ const sequences = {
["pbr", "pipeline"],
["pbr_double", "pipeline"],
["edges", "luminance_edge"],
["tone", "tone_map"],
["frame_out", "frame_out"],
],
bloom: [
["ldr", "texture"],
["half_a", "texture"],
["half_b", "texture"],
["half_c", "texture"],
@@ -112,11 +110,9 @@ const sequences = {
["blur_h", "bloom_blur"],
["blur_v", "bloom_blur"],
["composite", "bloom_composite"],
["tone", "tone_map"],
["frame_out", "frame_out"],
],
combined: [
["ldr", "texture"],
["edge_hdr", "texture"],
["half_a", "texture"],
["half_b", "texture"],
@@ -135,10 +131,11 @@ const sequences = {
["blur_v", "bloom_blur"],
["composite", "bloom_composite"],
["edges", "luminance_edge"],
["tone", "tone_map"],
["frame_out", "frame_out"],
],
};
for (const name of ["contain", "reinhard", "linear"])
sequences[name] = sequences.tone;
test("presets have the exact canonical pipeline identities, schemas, and node sequences", () => {
assert.deepEqual(Object.keys(presets.renderGraphPresets), order);
@@ -150,6 +147,9 @@ test("presets have the exact canonical pipeline identities, schemas, and node se
"preset_hdr_fullscreen",
"preset_gpu_culling",
"preset_tone",
"preset_contain",
"preset_reinhard",
"preset_linear",
"preset_grading",
"preset_edges",
"preset_bloom",
@@ -282,16 +282,19 @@ test("presets preserve common mesh, texture, query, pipeline, culling and post w
node: "cull",
socket: "isFrustumCulled",
});
for (const name of ["tone", "grading", "edges", "bloom", "combined"])
for (const name of ["tone", "contain", "reinhard", "linear", "grading", "edges", "bloom", "combined"])
assert.ok(!presets[name].nodes.some((node) => node.id === "copy"));
const finalSource = {
hdr: "pbr_double",
culling: "pbr_double",
tone: "tone",
grading: "tone",
edges: "tone",
bloom: "tone",
combined: "tone",
tone: "pbr_double",
contain: "pbr_double",
reinhard: "pbr_double",
linear: "pbr_double",
grading: "mixer",
edges: "edges",
bloom: "composite",
combined: "edges",
midnight: "pbr_double",
ember: "pbr_double",
};
@@ -300,21 +303,4 @@ test("presets preserve common mesh, texture, query, pipeline, culling and post w
node: finalSource[name],
socket: "color",
});
assert.equal(
presets.tone.nodes.find((node) => node.id === "tone").inputs.source.node,
"pbr_double",
);
assert.equal(
presets.edges.nodes.find((node) => node.id === "tone").inputs.source.node,
"edges",
);
assert.equal(
presets.bloom.nodes.find((node) => node.id === "tone").inputs.source.node,
"composite",
);
assert.equal(
presets.combined.nodes.find((node) => node.id === "tone").inputs.source
.node,
"edges",
);
});