feat: add render graph driven renderer architecture
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:
+372
@@ -0,0 +1,372 @@
|
||||
import { createFxNode, type FxNode, type FxNodeCompositionData, type FxNodeView } from "@lib/index.js";
|
||||
import { prepareFxNodeBrowserHost, type PreparedFxNodeBrowserHost } from "../../examples/shared/browser-host.js";
|
||||
import type { FxNodeResourceOpenRequest } from "@lib/index.js";
|
||||
import type { GraphState } from "@lib/core/types.js";
|
||||
|
||||
const color = "#202124" as const;
|
||||
const theme = {
|
||||
background: color,
|
||||
grid: color,
|
||||
frame: color,
|
||||
frameHeader: color,
|
||||
body: "#35383e",
|
||||
control: color,
|
||||
controlFill: color,
|
||||
controlEditing: color,
|
||||
textSelection: color,
|
||||
outline: "#101114",
|
||||
text: "#eeeeee",
|
||||
muted: "#999999",
|
||||
shadow: "#000000",
|
||||
nodeSelected: "#ed5700",
|
||||
nodeActive: "#ed5700",
|
||||
unknownHeader: "#555555",
|
||||
unknownSocket: "#777777",
|
||||
linkMuted: "#d94b4b",
|
||||
knifeMuted: "#e85b5b",
|
||||
emphasis: "#ffffff",
|
||||
focus: "#f5a623",
|
||||
editOutline: "#666a70",
|
||||
resize: "#8b8e95",
|
||||
muteOverlay: "#14141459",
|
||||
boxSelectionFill: "#f5a6231f",
|
||||
checkerLight: "#aaaaaa",
|
||||
checkerDark: "#777777",
|
||||
widgetBorder: "#111216",
|
||||
rampBorder: "#111111",
|
||||
resourceBackground: "#202228",
|
||||
} as const;
|
||||
const composition = (
|
||||
id: string,
|
||||
version: number,
|
||||
nodeType: string,
|
||||
title: string,
|
||||
header: `#${string}`,
|
||||
maxBytes: number,
|
||||
maxWidth: number,
|
||||
accept: string,
|
||||
) =>
|
||||
({
|
||||
schemaVersion: 2,
|
||||
id,
|
||||
version,
|
||||
compatibility: { wildcardInputTypes: [] },
|
||||
theme,
|
||||
socketTypes: { signal: { title: "Signal", color: header, acceptsFrom: ["signal"] } },
|
||||
nodeStyles: { application: { header } },
|
||||
resources: {
|
||||
image: {
|
||||
kind: "image",
|
||||
title: "Image",
|
||||
openTitle: "Open",
|
||||
accept: [accept],
|
||||
referencePrefix: `${id}-image:`,
|
||||
maxBytes,
|
||||
maxWidth,
|
||||
maxHeight: 4,
|
||||
maxPixels: maxWidth * 4,
|
||||
},
|
||||
strictImage: {
|
||||
kind: "image",
|
||||
title: "Strict Image",
|
||||
openTitle: "Open",
|
||||
accept: [accept],
|
||||
referencePrefix: `${id}-image:`,
|
||||
maxBytes,
|
||||
maxWidth: 1,
|
||||
maxHeight: 1,
|
||||
maxPixels: 1,
|
||||
},
|
||||
},
|
||||
nodes: {
|
||||
[nodeType]: {
|
||||
version: 1,
|
||||
title,
|
||||
behavior: "standard",
|
||||
style: "application",
|
||||
parameters: { image: { type: "string", default: { kind: "string", value: "" } } },
|
||||
sockets: {
|
||||
output: {
|
||||
title: "Output",
|
||||
direction: "output",
|
||||
type: "signal",
|
||||
maxIncomingLinks: 0,
|
||||
visible: true,
|
||||
value: null,
|
||||
showValue: false,
|
||||
},
|
||||
},
|
||||
ui: [
|
||||
{ kind: "resource", resource: "image", parameter: "image" },
|
||||
{ kind: "socket", socket: "output" },
|
||||
],
|
||||
muteBypass: [],
|
||||
migrations: [],
|
||||
},
|
||||
[`${nodeType}-strict`]: {
|
||||
version: 1,
|
||||
title: `${title} Strict`,
|
||||
behavior: "standard",
|
||||
style: "application",
|
||||
parameters: { image: { type: "string", default: { kind: "string", value: "" } } },
|
||||
sockets: {
|
||||
output: {
|
||||
title: "Output",
|
||||
direction: "output",
|
||||
type: "signal",
|
||||
maxIncomingLinks: 0,
|
||||
visible: true,
|
||||
value: null,
|
||||
showValue: false,
|
||||
},
|
||||
},
|
||||
ui: [
|
||||
{ kind: "resource", resource: "strictImage", parameter: "image" },
|
||||
{ kind: "socket", socket: "output" },
|
||||
],
|
||||
muteBypass: [],
|
||||
migrations: [],
|
||||
},
|
||||
},
|
||||
}) as unknown as FxNodeCompositionData;
|
||||
|
||||
export const compositionA = composition(
|
||||
"application-a",
|
||||
11,
|
||||
"alpha-node",
|
||||
"Alpha Node",
|
||||
"#9b59b6",
|
||||
512,
|
||||
1,
|
||||
"image/png",
|
||||
);
|
||||
export const compositionB = composition(
|
||||
"application-b",
|
||||
22,
|
||||
"beta-node",
|
||||
"Beta Node",
|
||||
"#2e86de",
|
||||
2048,
|
||||
4,
|
||||
"image/webp",
|
||||
);
|
||||
export const migrationComposition = {
|
||||
...compositionA,
|
||||
id: "worker-migration-custom",
|
||||
version: 73,
|
||||
nodes: {
|
||||
"odd-migrator": {
|
||||
...compositionA.nodes["alpha-node"]!,
|
||||
version: 2,
|
||||
parameters: { level: { type: "number", default: { kind: "number", value: 5 } } },
|
||||
sockets: {
|
||||
source: {
|
||||
title: "Source",
|
||||
direction: "output",
|
||||
type: "signal",
|
||||
maxIncomingLinks: 0,
|
||||
visible: true,
|
||||
value: null,
|
||||
showValue: false,
|
||||
},
|
||||
sink: {
|
||||
title: "Sink",
|
||||
direction: "input",
|
||||
type: "signal",
|
||||
maxIncomingLinks: 4,
|
||||
visible: true,
|
||||
value: null,
|
||||
showValue: false,
|
||||
},
|
||||
},
|
||||
ui: [
|
||||
{ kind: "parameter", parameter: "level" },
|
||||
{ kind: "socket", socket: "source" },
|
||||
{ kind: "socket", socket: "sink" },
|
||||
],
|
||||
migrations: [
|
||||
{
|
||||
fromVersion: 1,
|
||||
toVersion: 2,
|
||||
steps: [
|
||||
{ kind: "rename-parameter", from: "oldLevel", to: "level" },
|
||||
{ kind: "rename-socket", from: "oldSource", to: "source" },
|
||||
{ kind: "rename-socket", from: "oldSink", to: "sink" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as unknown as FxNodeCompositionData;
|
||||
const layout = (id: string, version: number) => ({
|
||||
schemaVersion: 2 as const,
|
||||
graphId: id,
|
||||
catalogVersion: version,
|
||||
nodes: [],
|
||||
links: [],
|
||||
metadata: {},
|
||||
});
|
||||
const migrationLayout = {
|
||||
schemaVersion: 2 as const,
|
||||
graphId: "worker-historical",
|
||||
catalogVersion: 1,
|
||||
nodes: [
|
||||
{
|
||||
id: "left",
|
||||
typeId: "odd-migrator",
|
||||
typeVersion: 1,
|
||||
position: { x: 0, y: 0 },
|
||||
size: { x: 180, y: 190 },
|
||||
label: "",
|
||||
parameters: { oldLevel: { kind: "number", value: 9 } },
|
||||
sockets: [
|
||||
{
|
||||
id: "left:oldSource",
|
||||
key: "oldSource",
|
||||
label: "Source",
|
||||
direction: "output",
|
||||
dataType: "signal",
|
||||
accepts: [],
|
||||
maxIncomingLinks: 0,
|
||||
visible: true,
|
||||
},
|
||||
{
|
||||
id: "left:oldSink",
|
||||
key: "oldSink",
|
||||
label: "Sink",
|
||||
direction: "input",
|
||||
dataType: "signal",
|
||||
accepts: ["signal"],
|
||||
maxIncomingLinks: 4,
|
||||
visible: true,
|
||||
},
|
||||
],
|
||||
muted: false,
|
||||
collapsed: false,
|
||||
extensions: {},
|
||||
},
|
||||
{
|
||||
id: "right",
|
||||
typeId: "odd-migrator",
|
||||
typeVersion: 1,
|
||||
position: { x: 300, y: 0 },
|
||||
size: { x: 180, y: 190 },
|
||||
label: "",
|
||||
parameters: { oldLevel: { kind: "number", value: 3 } },
|
||||
sockets: [
|
||||
{
|
||||
id: "right:oldSource",
|
||||
key: "oldSource",
|
||||
label: "Source",
|
||||
direction: "output",
|
||||
dataType: "signal",
|
||||
accepts: [],
|
||||
maxIncomingLinks: 0,
|
||||
visible: true,
|
||||
},
|
||||
{
|
||||
id: "right:oldSink",
|
||||
key: "oldSink",
|
||||
label: "Sink",
|
||||
direction: "input",
|
||||
dataType: "signal",
|
||||
accepts: ["signal"],
|
||||
maxIncomingLinks: 4,
|
||||
visible: true,
|
||||
},
|
||||
],
|
||||
muted: false,
|
||||
collapsed: false,
|
||||
extensions: {},
|
||||
},
|
||||
],
|
||||
links: [
|
||||
{
|
||||
id: "historical-link",
|
||||
fromNodeId: "left",
|
||||
fromSocketId: "left:oldSource",
|
||||
toNodeId: "right",
|
||||
toSocketId: "right:oldSink",
|
||||
muted: false,
|
||||
extensions: { kept: true },
|
||||
},
|
||||
],
|
||||
metadata: { fixture: "migration" },
|
||||
};
|
||||
const hosts = new WeakMap<FxNode, PreparedFxNodeBrowserHost>(),
|
||||
views = new WeakMap<FxNode, FxNodeView>();
|
||||
const create = async <C extends FxNodeCompositionData>(
|
||||
canvas: HTMLCanvasElement,
|
||||
value: C,
|
||||
initialLayout: unknown = layout(value.id, value.version),
|
||||
activateResourcePicker?: (request: FxNodeResourceOpenRequest) => void,
|
||||
) => {
|
||||
const host = prepareFxNodeBrowserHost({
|
||||
canvas,
|
||||
addNodeMenuTemplate: document.querySelector<HTMLTemplateElement>("#add-node-menu-template")!,
|
||||
...(activateResourcePicker ? { activateResourcePicker } : {}),
|
||||
});
|
||||
let api: FxNode | undefined;
|
||||
const { id, version, resources } = value;
|
||||
try {
|
||||
api = await createFxNode({
|
||||
applicationId: id,
|
||||
applicationVersion: version,
|
||||
resources,
|
||||
});
|
||||
await api.loadComposition(value as never, { expectedRevision: 0 });
|
||||
if (initialLayout === migrationLayout) await api.load(initialLayout);
|
||||
const view = await api.attachView({ canvas, viewport: host.initialViewport });
|
||||
host.attach(api, view);
|
||||
hosts.set(api, host);
|
||||
views.set(api, view);
|
||||
return api;
|
||||
} catch (error) {
|
||||
host.destroy();
|
||||
api?.destroy();
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
const destroy = (api: FxNode) => {
|
||||
hosts.get(api)?.destroy();
|
||||
hosts.delete(api);
|
||||
views.delete(api);
|
||||
api.destroy();
|
||||
};
|
||||
|
||||
type Harness = {
|
||||
readonly compositionA: typeof compositionA;
|
||||
readonly compositionB: typeof compositionB;
|
||||
createA(activateResourcePicker?: (request: FxNodeResourceOpenRequest) => void): Promise<FxNode>;
|
||||
createB(activateResourcePicker?: (request: FxNodeResourceOpenRequest) => void): Promise<FxNode>;
|
||||
createMigration(): Promise<FxNode>;
|
||||
createRaw(canvas: HTMLCanvasElement, value: FxNodeCompositionData): Promise<FxNode>;
|
||||
view(api: FxNode): FxNodeView;
|
||||
destroy(api: FxNode): void;
|
||||
};
|
||||
(window as unknown as { workerCompositionTest: Harness }).workerCompositionTest = {
|
||||
compositionA,
|
||||
compositionB,
|
||||
createA: (activate) =>
|
||||
create(document.querySelector<HTMLCanvasElement>("#application-a")!, compositionA, undefined, activate),
|
||||
createB: (activate) =>
|
||||
create(document.querySelector<HTMLCanvasElement>("#application-b")!, compositionB, undefined, activate),
|
||||
createMigration: () =>
|
||||
create(document.querySelector<HTMLCanvasElement>("#application-a")!, migrationComposition, migrationLayout),
|
||||
createRaw: async (canvas, value) => {
|
||||
const api = await create(canvas, compositionA);
|
||||
try {
|
||||
await api.loadComposition(value, { expectedRevision: 1 });
|
||||
return api;
|
||||
} catch (error) {
|
||||
destroy(api);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
view: (api) => {
|
||||
const view = views.get(api);
|
||||
if (!view) throw new Error("FxNode test view is unavailable");
|
||||
return view;
|
||||
},
|
||||
destroy,
|
||||
};
|
||||
Reference in New Issue
Block a user