import {app} from "../../scripts/app.js"; import {api} from "../../scripts/api.js"; import { fabric } from "./fabric.js"; /** check if this is a Compositor3 node */ function isCompositor3(node) { return node.constructor.comfyClass == "Compositor3"; } function isCompositorConfig3(node) { return node.constructor.comfyClass == "CompositorConfig3"; } function getCompositorWidget(node, widgetName) { return node.widgets.find((w) => w.name === widgetName); } function handleTogglePreciseSelection(e, currentNode) { const optionValue = e.data.value; currentNode.compositorInstance.preciseSelection = optionValue; const c = currentNode.compositorInstance.fcanvas; c.getObjects().map(function (i) { return i.set('perPixelTargetFind', optionValue); }); } function handleResetOldTransform(e, currentNode) { const optionValue = e.data.value; const instance = currentNode.compositorInstance; const c = instance.fcanvas; c.getObjects().forEach(function (image, index) { instance.resetOldTransform(index); }); } function centerSelected(e, currentNode) { const optionValue = e.data.value; const instance = currentNode.compositorInstance; const c = instance.fcanvas; // get the selected and set the instance.needsUpload = true; c.getActiveObjects().forEach((o)=>o.center()); c.renderAll(); instance.uploadIfNeeded(instance); // c.getObjects().forEach(function (image, index) { // instance.resetOldTransform(index); // // }); } /** * registering an extension gives the possibility to tap into lifecycle methods * here is the sequence from the docs: * api events * 0: "status" * 1: "graphChanged" * 2: "promptQueued" * 3: "graphCleared" * 4: "executed" * 5: "execution_start" * 6: "execution_cached" * 7: "executing" * 8: "reconnecting" * 9: "reconnected" * 10: "manager-terminal-feedback" * 11: "cm-api-try-install-customnode" * 12: "progress" * 13: "execution_error" * 14: "b_preview" * 15: "crystools.monitor" * 16: "configure" * 17: "compositor.images" * -- Web page load -- * invokeExtensionsAsync init * invokeExtensionsAsync addCustomNodeDefs * invokeExtensionsAsync getCustomWidgets * invokeExtensionsAsync beforeRegisterNodeDef [repeated multiple times] * invokeExtensionsAsync registerCustomNodes * invokeExtensionsAsync beforeConfigureGraph * invokeExtensionsAsync nodeCreated * invokeExtensions loadedGraphNode * invokeExtensionsAsync afterConfigureGraph * invokeExtensionsAsync setup * * -- Loading workflow -- * invokeExtensionsAsync beforeConfigureGraph * invokeExtensionsAsync beforeRegisterNodeDef [zero, one, or multiple times] * invokeExtensionsAsync nodeCreated [repeated multiple times] * invokeExtensions loadedGraphNode [repeated multiple times] * invokeExtensionsAsync afterConfigureGraph * * -- Adding new node -- * invokeExtensionsAsync nodeCreated * * * more info about what the hell a node is etc * https://docs.comfy.org/essentials/javascript_objects_and_hijacking */ app.registerExtension({ name: "Comfy.Compositor3", async getCustomWidgets(app) { // no custom widgets }, /** * Called at the end of the startup process. * A good place to add event listeners (either for Comfy events, or DOM events), or adding to the global menus, * at this point we get a nodeId from a message (if we pass it) but have no node context, so we need to find it. * * Capture UI events * This works just like you’d expect - find the UI element in the DOM and add an eventListener. * setup() is a good place to do this, since the page has fully loaded. * For instance, to detect a click on the ‘Queue’ button: * ``` * function queue_button_pressed() { console.log("Queue button was pressed!") } * document.getElementById("queue-button").addEventListener("click", queue_button_pressed); * ``` */ async setup(app) { Editor.addCompositorSettings(); /** * @deprecated * once the node processes in python, we finally know from the event * what are the connected images * they are passed as base64 encoded or null if not connected * and the unique name id of the node. * @param event * */ function imageMessageHandler(event) { } function hook(nodeId) { return app.graph.getNodeById(nodeId); } /** example of arbitrary messages PromptServer.instance.send_sync("my.custom.message", {"node": node_id, "other_things": etc}) in api.ts search for "case 'executing'": for all events emitted or "new CustomEvent('executing'" example of built-in, this should be when a node is about to start processing (in the back?) */ function executingMessageHandler(event) { //console.log("executingMessageHandler", event, arguments); const current = app.graph.getNodeById(event.detail); // probably too later here as it's already running in the back // if (current && current.type == "Compositor3") { // const instance = current.compositorInstance; // if (instance.captureOnQueue.value) { // //instance.capture(); // instance.grabUploadAndSetOutput.bind(instance); // } // } // if pause // if (getConfigWidgetValue(current, 4)) { // interrupt(); // } // } } /** * handle progress message sent during .py execution */ function progressHandler() { // need to filter by node id // console.log(arguments); } /** when a node "returns" an ui element, usually at the end of processing */ function executedMessageHandler(event, a, b) { //console.log("executedMessageHandler", event, a, b); // Litegraph docs // https://github.com/jagenjo/litegraph.js/blob/master/guides/README.md // get stuff connected to this config also...careful with the gui now... const e = event.detail.output; const nodeId = event.detail.node; const node = Editor.hook(nodeId); if (!node || node.type != "Compositor3") { // console.log(node.type); return; } const instance = node.compositorInstance; // console.log("hasResult,awaitedResult", e.hasResult[0], e.awaited[0]); node.compositorInstance.w.value = e.width[0]; node.compositorInstance.h.value = e.height[0]; node.compositorInstance.p.value = e.padding[0]; node.compositorInstance.onWidthChange(e.width[0]); node.compositorInstance.onHeightChange(e.height[0]); node.compositorInstance.onPaddingChange(e.padding[0]); // node.compositorInstance.onCaptureOnQueueChange(e.captureOnQueue[0]); const images = [...e.names]; const restore = Editor.deserializeStuff(node.fabricDataWidget.value); const shouldRestore = restore ?? false; // Editor.getConfigWidgetValue(node, 3); const normalizeHeight = Editor.getConfigWidgetValue(node, 3); const onConfigChanged = Editor.getConfigWidgetValue(node, 4); instance.normalizeHeigh = normalizeHeight; instance.onConfigChanged = onConfigChanged; instance.configChanged = e.configChanged[0]; images.map((b64, index) => { function fromUrlCallback(oImg) { node.compositorInstance.addOrReplaceImage(oImg, index, nodeId, restore, shouldRestore); } /** * fabric.Image.fromURL * http://fabricjs.com/docs/fabric.Image.html */ fabric.Image.fromURL(b64, fromUrlCallback); }); if(instance.configChanged) { instance.needsUpload = true; // true: grab and continue if(onConfigChanged){ console.log("upload and continue") const reEnqueue = () => { console.log("upload and continue") interrupt(); instance.continue(); } instance.uploadIfNeeded(instance, reEnqueue); }else{ // False, stop instance.uploadIfNeeded(instance) console.log("stopped, just upload") } } // console.log("configChanged",instance.configChanged); // // if(instance.onConfigChanged == "grabAndContinue"){ // // console.log("grabAndContinue") // // instance.needsUpload = true; // // console.log("set instance needs upload to ", instance.needsUpload) // // const reEneuque = () => { // // app.queuePrompt(0, 1); // // } // // instance.uploadIfNeeded(instance,reEneuque); // // }else{ // // console.log("stop:config") // instance.needsUpload = true; // console.log("set instance needs upload to ", instance.needsUpload) // // }else{ // instance.needsUpload = false; // console.log("config did not hcange, needs upload set to ", instance.needsUpload) // } } /** important messaging considerations https://docs.comfy.org/essentials/comms_messages */ function configureHandler() { //console.log("configurehanlder", arguments); } function executionStartHandler() { //console.log("executionStartHandler", arguments); } function executionCachedHandler() { //console.log("executionCachedHandler", arguments); } function graphChangedHandler() { console.log("graphChangedHandler", arguments); } function changeWorkflowHandler() { //console.log("changeWorkflowHandler", arguments); } // change_workflow // global on_change for node and widgets api.addEventListener("compositor_init", executedMessageHandler); api.addEventListener("graphChanged", graphChangedHandler); api.addEventListener("change_workflow", changeWorkflowHandler); api.addEventListener("execution_start", executionStartHandler); api.addEventListener("execution_cached", executionCachedHandler); api.addEventListener("executing", executingMessageHandler); /** when a node returns an ui element */ api.addEventListener("executed", executedMessageHandler); /** * test "progress" received during .py execution */ api.addEventListener("progress", progressHandler); /** ? */ api.addEventListener("configure", configureHandler); }, /** * Called when the Comfy webpage is loaded (or reloaded). * The call is made after the graph object has been created, * but before any nodes are registered or created. * It can be used to modify core Comfy behavior by hijacking methods of the app, or of the graph * (a LiteGraph object). * This is discussed further in Comfy Objects. */ async init(args) { // console.log("init", args) }, /** * Called once for each node type (the list of nodes available in the AddNode menu), * and is used to modify the behaviour of the node. * * async beforeRegisterNodeDef(nodeType, nodeData, app) * The object passed in the nodeType parameter serves as a template for all nodes that will be created of this type. * The modifications made to "nodeType.prototype" will apply to all nodes of this type. * nodeData is an encapsulation of aspects of the node defined in the Python code, * such as its category, inputs, and outputs. * app is a reference to the main Comfy app object (which you have already imported anyway!) ``` async beforeRegisterNodeDef(nodeType, nodeData, app) { if (nodeType.comfyClass == 'Compositor') { // console.log("beforeRegisterNodeDef", nodeType, nodeData, app); const orig_nodeCreated = nodeType.prototype.onNodeCreated; nodeType.prototype.onNodeCreated = async function () { // console.log("onNodeCreated", this); orig_nodeCreated?.apply(this, arguments) this.setSize([this.stuff.v.getWidth() + 100, this.stuff.v.getHeight() + 556]) } const onExecuted = nodeType.prototype.onExecuted; nodeType.prototype.onExecuted = function (message) { // console.log("onExecuted", this, message); const r = onExecuted?.apply?.(this, arguments) return r; } } }, ``` */ async beforeRegisterNodeDef(nodeType, nodeData, app) { // chainCallback(nodeType.prototype, "onNodeCreate, createdCallback(){ // this is the node here, we can add widgets, hide them, add button, add property to widgets // in this case he's addn the whole point editor or resetting this in case it does or does not have // certain properties, like points // same place for onConfigure and onExecuted //} // console.log("beforeRegisterNodeDef",nodeType,nodeData); // KJ initailizes the node here // with a callback for onNodeCreated // nodeType.prototype.capture = ()=>{ // console.log(this,arguments); // } }, /** loadedGraphNode, after nodeCreated * ``` if(node.type == "Compositor" && console.log("loadedGraphNode", node, app, node.stuff)){ const ns = node.stuff; ns.safeArea.setHeight(ns.h.value); ns.safeArea.setWidth(ns.w.value); ns.safeArea.setLeft(ns.p.value); ns.safeArea.setTop(ns.p.value); ns.compositionBorder.setHeight(ns.h.value + ns.stuff.COMPOSITION_BORDER_SIZE*2); ns.compositionBorder.setWidth(ns.w.value + ns.stuff.COMPOSITION_BORDER_SIZE*2); ns.compositionBorder.setLeft(ns.p.value - ns.stuff.COMPOSITION_BORDER_SIZE); ns.compositionBorder.setTop(ns.p.value - ns.stuff.COMPOSITION_BORDER_SIZE*2); ns.compositionBorder.set("strokeWidth", ns.stuff.COMPOSITION_BORDER_SIZE); ns.compositionBorder.set("stroke", ns.stuff.COMPOSITION_BORDER_COLOR); ns.compositionBorder.bringToFront() canvas.bringToFront(ns.compositionBorder); //console.log(v.getWidth(), v.getHeight(), value); ns.canvas.setHeight(ns.safeArea.getHeight() + (ns.p.value * 2)); ns.canvas.setWidth(ns.safeArea.getWidth() + (ns.p.value * 2)); ns.canvas.renderAll(); ns.node.setSize(calculateNodeSize(v)); ns.capture(); } ``` */ async loadedGraphNode(node, app) { // console.log("loadedGraphNode"); }, async afterConfigureGraph(args) { // To do something when a workflow has loaded, use afterConfigureGraph, not setup // console.log("afterConfigureGraph", args); // reset the config timestamp, to ensure re-triggering const configs = app.graph.findNodesByType("CompositorConfig3"); configs.forEach((c) => { const initialized = getCompositorWidget(c, "initialized"); initialized.value = Date.now(); }) // enable nodes to talk to each other without running the frontend through a dedicated // broadcast channel // // setup broadcast channel, also needs to be done on node created or connection change... const nodes = app.graph.findNodesByType("Compositor3"); // probably too late here as it's already running in the back nodes.forEach((currentNode) => { const tools = currentNode.getInputNode(1); // Add this null check if (!tools) { console.log("No tools node connected to input 1 for Compositor3 node:", currentNode.id); return; // Skip this node } const CHANNELNAME = `Tools${tools.id}`; //console.log(CHANNELNAME) const channel = new BroadcastChannel(CHANNELNAME); channel.addEventListener("message", (e) => { switch (e.data.action) { case "togglePreciseSelection": handleTogglePreciseSelection(e, currentNode); break; case "resetTransforms": handleResetOldTransform(e, currentNode); break; case "centerSelected": centerSelected(e, currentNode); break; default: console.log("unknown broadcast event", e); } }); currentNode.channel = channel; // console.log("looping afterconfiguregraph compostior node", current); // console.log("looping afterconfiguregraph compostior node configs", config); }) app.graph.setDirtyCanvas(true, true); }, /** * Called when a **specific instance** of a node gets created * (right at the end of the ComfyNode() function on nodeType which serves as a constructor). * In this hook you can make modifications to **individual instances** of your node. * nde: whereas **before register node def is better for prototype changes** (??) * node ref * https://docs.comfy.org/essentials/javascript_objects_and_hijacking * * not the same nodeCreated event available in beforeRegisterNodeDef prototype (that one is aproptotype node class instance) */ async nodeCreated(node) { if (!isCompositor3(node)) return; /** our output composite image */ //node.compositionChangedWidget = getCompositorWidget(node, "compositionChanged"); node.imageNameWidget = getCompositorWidget(node, "imageName"); const originalCallback = node.imageNameWidget.callback; node.imageNameWidget.callback = () => { //debugger; //console.log("callback of imageNameWidget with ", arguments); originalCallback(arguments); } node.imageNameWidget.computeSize = () => [0, 0]; // imageName.computeSize = () => [0, 0]; hideWidgetForGood(node, node.imageNameWidget); node.configWidget = getCompositorWidget(node, "config"); node.fabricDataWidget = getCompositorWidget(node, "fabricData"); node.fabricDataWidget.computeSize = () => [0, 0]; hideWidgetForGood(node, node.fabricDataWidget); // make sure when we reload the widget will be re-executed const firstRun = Editor.deserializeStuff(node.fabricDataWidget.value); firstRun["firstRun"] = Date.now(); node.fabricDataWidget.value = JSON.stringify(firstRun); const containerDiv = Editor.createCompositorContainerDiv(node) const c = document.createElement("canvas"); c.id= "c_" + node.id; containerDiv.appendChild(c); node.editorWidget = node.addDOMWidget("test", "test", containerDiv, { //serialize: false, hideOnZoom: false, }); const fc = new fabric.Canvas(c,{ backgroundColor: 'transparent', selectionColor: 'transparent', selectionLineWidth: 1, preserveObjectStacking: true, altSelectionKey: "ctrlKey", altActionKey: "ctrlKey", centeredKey: "altKey", }); /** initialize the compositor gui widget */ const compositorInstance = new Editor(node, containerDiv); compositorInstance.initFabric(fc); /** * grabUploadAndSetOutput callback can't be async so pass the widget in upload image * addWidget(type, name, value, callback, options) */ //node.capture = node.addWidget("button", "capture", "capture", compositorInstance.grabUploadAndSetOutput.bind(compositorInstance)); node.continue = node.addWidget("button", "continue", "continue", compositorInstance.continue.bind(compositorInstance)); node.onMouseOut = function (e, pos, canvas) { // console.log("mouseout") const original_onMouseDown = node.onMouseOut; return original_onMouseDown?.apply(this, arguments); } //node.compositorInstance = compositorInstance; }, }); //from melmass function hideWidgetForGood(node, widget, suffix = '') { widget.origType = widget.type widget.origComputeSize = widget.computeSize widget.origSerializeValue = widget.serializeValue widget.computeSize = () => [0, -4] // -4 is due to the gap litegraph adds between widgets automatically widget.type = "converted-widget" + suffix // widget.serializeValue = () => { // // Prevent serializing the widget if we have no input linked // const w = node.inputs?.find((i) => i.widget?.name === widget.name); // if (w?.link == null) { // return undefined; // } // return widget.origSerializeValue ? widget.origSerializeValue() : widget.value; // }; // Hide any linked widgets, e.g. seed+seedControl if (widget.linkedWidgets) { for (const w of widget.linkedWidgets) { hideWidgetForGood(node, w, ':' + widget.name) } } } /** will be added on node created to the node via addDOMWidget */ class Editor { id; canvasEl; /** the fabric canvas */ fcanvas; /** the dom element that's passed to addDomWidget */ containerDiv; /** contains the last image, i probably just need the hash anyways */ cblob; /** the hash of the current image in blob */ c1; /** the hash of the new blob to be checked */ c2; /** if c1 === c2*/ sameHash; /** the selected objec in fcanvas, for manipulation events*/ selected; /** settings */ CANVAS_BORDER_COLOR = "#00b300b0"; COMPOSITION_BORDER_COLOR = "#00b300b0"; COMPOSITION_BORDER_SIZE = 2; COMPOSITION_BACKGROUND_COLOR = "rgba(0,0,0,0.2)"; compositionArea; compositionBorder; preciseSelection = false; /** (widget) references / config params*/ p; w; h; /** a reference to capture on queue widget value*/ //captureOnQueue; inputImages = [null, null, null, null, null, null, null, null]; fabricDataWidget; needsUpload = false; configurationNode; static hook(nodeId) { return app.graph.getNodeById(nodeId); } static deserializeStuff(value) { try { return JSON.parse(value) } catch (e) { console.log("deserializeStuff", e, value); return undefined; } } /** * serializes some info from the node, currently the transforms supplied to the images * this is currently called on capture (regardless of the flag) */ static serializeStuff(node) { console.log("serializeStuff"); const instance = node.compositorInstance; const result = { // or the widget ? boh width: instance.w.value, height: instance.h.value, padding: instance.p.value, transforms: undefined, }; const res = [0, 1, 2, 3, 4, 5, 6, 7].map((i) => { try { let t = instance.getOldTransform(i); return t; } catch (e) { return undefined; } }); result.transforms = res; const bboxes = [0, 1, 2, 3, 4, 5, 6, 7].map((i) => { try { let t = instance.getBoundingBox(i); return t; } catch (e) { return undefined; } }); result.bboxes = bboxes; return JSON.stringify(result); } /** * in CompositorConfig * - 4 is pause ->3 removed * - 5 is storeTransforms ->3 removed * - 6 initialized ->3 */ static getConfigWidgetValue(node, slot) { // console.log(node, slot); const connected = node.getInputNode(0); return connected.widgets[slot].value; } static getToolWidget(instance) { // console.log(node, slot); return instance.node.getInputNode(1); } /** * in CompositorConfig * - 4 is pause >removed * - 5 is storeTransforms >removed * - 6 initialized -> 2 */ static setConfigWidgetValue(node, slot, value) { // console.log(node, slot); const connected = node.getInputNode(0); return connected.widgets[slot].value = value; } static addCanvasBorderColorSetting() { app.extensionManager.setting.set({ id: "Compositor3.Canvas.BORDER_COLOR", name: "Border Color", tooltip: "give an hex code with alpha, e.g.: #00b300b0, it's the area controlled by 'padding' size outside the output that will not be exported but used for manipulation", type: "text", defaultValue: "#00b300b0", onChange: (newVal, oldVal) => { // console.log(newVal, this); }, }); } static addCompositionBorderColorSetting() { app.extensionManager.setting.set({ id: "Compositor3.Composition.BORDER_COLOR", name: "Border Color (not rendered)", type: "text", tooltip: "give hex code with alpha eg.: #00b300b0, this will help identifying what is withing the output", defaultValue: "#00b300b0", onChange: (newVal, oldVal) => { // console.log(newVal, this); }, }); } static addCompositionBorderSizeSetting() { app.extensionManager.setting.set({ id: "Compositor3.Composition.BORDER_SIZE", name: "Border Size", type: "slider", attrs: { min: 0, max: 2, step: 1 }, defaultValue: 2, tooltip: "Border size, 0 for invisible, overlayed and unselectable, not part of the node ouptut", onChange: (newVal, oldVal) => { // console.log(newVal, this); }, }); } static addCompositionBackgroundColorSetting() { app.extensionManager.setting.set({ id: "Compositor3.Composition.BACKGROUND_COLOR", name: "Background Color - Output", type: "text", tooltip: "give hex code with alpha eg.: #00b300b0, this will help identifying what is withing the output", defaultValue: "rgba(0,0,0,0.2)", onChange: (newVal, oldVal) => { // console.log(newVal, this); }, }); } static addCompositorSettings() { Editor.addCanvasBorderColorSetting(); Editor.addCompositionBorderColorSetting(); Editor.addCompositionBorderSizeSetting(); Editor.addCompositionBackgroundColorSetting(); } getCompositorSettings() { // this.CANVAS_BORDER_COLOR = app.extensionManager.setting.get("Compositor3.Canvas.BORDER_COLOR"); // this.COMPOSITION_BORDER_COLOR = app.extensionManager.setting.get("Compositor3.Composition.BORDER_COLOR"); // this.COMPOSITION_BORDER_SIZE = app.extensionManager.setting.get("Compositor3.Composition.BORDER_SIZE"); // this.COMPOSITION_BACKGROUND_COLOR = app.extensionManager.setting.get("Compositor3.Composition.BACKGROUND_COLOR"); } static getRandomCompositorUniqueId() { const randomUniqueIds = new Uint32Array(10); const compositorId = 'c_' + self.crypto.getRandomValues(randomUniqueIds)[0] + '_' + self.crypto.getRandomValues(randomUniqueIds)[1]; return compositorId; } static createCompositorContainerDiv() { const container = document.createElement("div"); container.style.backgroundColor = "rgba(15,0,25,0.25)"; container.style.textAlign = "center"; return container; } // static CreateIframe() { // const container = document.createElement("iframe"); // container.style.backgroundColor = "rgba(15,0,25,0.25)"; // container.style.zIndex = 3000; // container.style.textAlign = "center"; // container.src = "https://comfy.org/"; // return container; // } static createCanvasElement() { const canvas = document.createElement("canvas"); canvas.id = Editor.getRandomCompositorUniqueId(); return canvas; } onHeightChange(value) { // console.log("h callback"); this.fcanvas.setHeight(value + (this.p.value * 2)); this.compositionArea.setHeight(value); this.compositionBorder.setHeight(value + this.COMPOSITION_BORDER_SIZE * 2); this.node.setSize(this.calculateNodeSize()) this.fcanvas.renderAll(); } onWidthChange(value) { // console.log("h callback"); this.fcanvas.setWidth(value + (this.p.value * 2)); this.compositionArea.setWidth(value); this.compositionBorder.setWidth(this.COMPOSITION_BORDER_SIZE * 2); this.node.setSize(this.calculateNodeSize()); this.fcanvas.renderAll(); } onPaddingChange(padding) { // console.log("p callback") // value is the padding value this.compositionArea.setHeight(this.h.value); this.compositionArea.setWidth(this.w.value); this.compositionArea.setLeft(padding); this.compositionArea.setTop(padding); this.compositionBorder.setHeight(this.h.value + this.COMPOSITION_BORDER_SIZE * 2); this.compositionBorder.setWidth(this.w.value + this.COMPOSITION_BORDER_SIZE * 2); this.compositionBorder.setLeft(padding - this.COMPOSITION_BORDER_SIZE); this.compositionBorder.setTop(padding - this.COMPOSITION_BORDER_SIZE); this.fcanvas.setHeight(this.compositionArea.getHeight() + (padding * 2)); this.fcanvas.setWidth(this.compositionArea.getWidth() + (padding * 2)); this.fcanvas.renderAll(); this.node.setSize(this.calculateNodeSize()) } getOldTransform(index) { const ref = this.inputImages[this.imageNameAt(index)]; return { left: ref.left, top: ref.top, scaleX: ref.scaleX, scaleY: ref.scaleY, angle: ref.angle, flipX: ref.flipX, flipY: ref.flipY, originX: ref.originX, originY: ref.originY, xwidth: ref.width, xheight: ref.height, skewY: ref.skewY, skewX: ref.skewX, }; } getBoundingBox(index) { const ref = this.inputImages[this.imageNameAt(index)].getBoundingRect(); return { left: ref.left, top: ref.top, scaleX: ref.scaleX, scaleY: ref.scaleY, angle: ref.angle, flipX: ref.flipX, flipY: ref.flipY, originX: ref.originX, originY: ref.originY, xwidth: ref.height, xheight: ref.width, skewY: ref.skewY, skewX: ref.skewX, }; } resetOldTransform(index) { this.inputImages[this.imageNameAt(index)].left = 0; this.inputImages[this.imageNameAt(index)].top = 0; this.inputImages[this.imageNameAt(index)].scaleX = 1; this.inputImages[this.imageNameAt(index)].scaleY = 1; this.inputImages[this.imageNameAt(index)].angle = 0; this.inputImages[this.imageNameAt(index)].flipX = false; this.inputImages[this.imageNameAt(index)].flipY = false; this.inputImages[this.imageNameAt(index)].originX = "top"; this.inputImages[this.imageNameAt(index)].originY = "left"; // this.inputImages[this.imageNameAt(index)].height; // this.inputImages[this.imageNameAt(index)].width; this.inputImages[this.imageNameAt(index)].skewY = 0; this.inputImages[this.imageNameAt(index)].skewX = 0; this.inputImages[this.imageNameAt(index)].perPixelTargetFind = this.preciseSelection; this.fcanvas.renderAll(); } /** * checks if the reference at index for an image is not null * references are stored in "inputImages" * @param node * @param index * @return {boolean} */ hasImageAtIndex(index) { return this.inputImages[this.imageNameAt(index)] != null; } imageNameAt(index) { return 'image' + (index + 1); } addImage(index, theImage) { this.inputImages[this.imageNameAt(index)] = theImage; this.fcanvas.add(theImage); // this will be empty so, should we restore ? // only happens here once at first run afer reloading } replaceImage(index, theImage) { const oldTransform = this.getOldTransform(index); // Remove the old image from the canvas this.fcanvas.remove(this.inputImages[this.imageNameAt(index)]); // this breaks if we have width and height so renamed to xwidth and xheight theImage.set(oldTransform); this.fcanvas.add(theImage); this.inputImages[this.imageNameAt(index)] = theImage; } addOrReplaceImage(theImage, index, nodeId, r, shouldRestore) { const node = app.graph.getNodeById(nodeId); const instance = node.compositorInstance; if (instance.hasImageAtIndex(index)) { instance.replaceImage(index, theImage); } else { instance.addImage(index, theImage); } //instance.fcanvas.renderAll(); if (shouldRestore) { try { if (theImage) { // restore the transforms const restoreParams = r.transforms[index]; theImage.scaleX = restoreParams.scaleX; theImage.scaleY = restoreParams.scaleY; theImage.angle = restoreParams.angle; theImage.left = restoreParams.left; theImage.top = restoreParams.top; theImage.flipX = restoreParams.flipX; theImage.flipY = restoreParams.flipY; theImage.originX = restoreParams.originX; theImage.originY = restoreParams.originY; theImage.skewY = restoreParams.skewY; theImage.skewX = restoreParams.skewX; } } catch (e) { // console.log(e); } } // whatever it happens, ensure the composition border is on top instance.fcanvas.bringToFront(instance.compositionBorder); instance.fcanvas.renderAll(); } /** * Initialize a fabricJs instance. * Fabric is the engine that makes it possible to manipulate the images * and extract the final composite image * init params: http://fabricjs.com/docs/fabric.Canvas.html * @param cavasEl the dom element with canvas tag * @return {fabric.Canvas} */ static createFabricCanvas(id) { const canvasElement = document.getElementById(id); const fcanvas = new fabric.Canvas(canvasElement, { backgroundColor: 'transparent', selectionColor: 'transparent', selectionLineWidth: 1, preserveObjectStacking: true, altSelectionKey: "ctrlKey", altActionKey: "ctrlKey", centeredKey: "altKey", }); return fcanvas; } /** converts a data URL to a blob*/ static dataURLToBlob = (dataURL) => { const parts = dataURL.split(','); const mime = parts[0].match(/:(.*?);/)[1]; const binary = atob(parts[1]); const array = []; for (let i = 0; i < binary.length; i++) { array.push(binary.charCodeAt(i)); } return new Blob([new Uint8Array(array)], {type: mime}); } // #addDrawNodeHandler in code static uploadImage = (blob, imageNameWidget, node_id, setDone, callback) => { const node = app.graph.getNodeById(node_id); node.compositorInstance.compositionBorder.set("stroke", "orange"); node.compositorInstance.fcanvas.renderAll(); const UPLOAD_ENDPOINT = "/upload/image"; //const name = `composition.png`; const name = `${+new Date()}.png`; const file = new File([blob], name); const body = new FormData(); body.append("image", file); body.append("subfolder", "compositor"); body.append("type", "temp"); api.fetchApi(UPLOAD_ENDPOINT, { method: "POST", body, }).then((value) => { // debugger; const outputValue = `compositor/${name} [temp]`; //imageNameWidget.value = Math.random() > 0.5 ? outputValue : "mask.png" imageNameWidget.value = outputValue; const body = new FormData(); body.append('filename', outputValue); body.append('node_id', node_id); body.append('overwrite', "true"); node.compositorInstance.compositionBorder.set("stroke", node.compositorInstance.COMPOSITION_BORDER_COLOR); node.compositorInstance.fcanvas.renderAll(); node.setDirtyCanvas(true, true); if (callback) callback() // deprecated, not really needed anymore if (setDone) api.fetchApi("/compositor/done", {method: "POST", body}); }, () => { console.log("some error") }); } /** if we have no blob stored in memory, this should be the first run */ hasNeverRun() { return this.cblob == undefined } /** this can't be async so resort to promise resolving and callbacks * @params setDone **deprecated** when setDone is true, it will raise a /compositor/done event for the backend * @params callback will be passed to uploadImage and called when the upload has finished * */ grabUploadAndSetOutput(instance, setDone, callback) { // console.log("capture"); // console.log("grap upload and set output") // prepare the image const img = new Image(); // load something existing into it via view api, for testing // api/view?filename=R.jpg&type=input&subfolder=&rand=0.6726800041773884 // img.src = "/api/view?filename=R.jpg&type=input&subfolder=&rand=0.6726800041773884"; this.fcanvas.discardActiveObject().renderAll(); const data = this.fcanvas.toDataURL({ format: 'jpeg', quality: 0.8, left: this.p.value, top: this.p.value, width: this.w.value, height: this.h.value }); img.src = data; // once finished, export image , upload id with temp name simulating compositing // and update the output name value img.onload = (e) => { // testing resize of fcanvas with widget // cmp.setSize([1600/3,(1200/3)+123]) // cmp.setDirtyCanvas(true, true) const blob = Editor.dataURLToBlob(data); if (this.hasNeverRun()) { Editor.uploadImage(blob, this.node.imageNameWidget, this.node.id, false, callback); } else { /** * grabUploadAndSetOutput callback can't be async, so this one cant wait for the result and name * pass the widget in upload image as well, so we can just process it. * not sure if it creates problems if we run a "capture on queue" */ Editor.uploadImage(blob, this.node.imageNameWidget, this.node.id, setDone, callback); } this.cblob = blob; // serialization of transforms const serialized = Editor.serializeStuff(this.node); if (!serialized.includes("[null,null,null,null,null,null,null,null]")) { this.node.fabricDataWidget.value = serialized; // console.log(node.stuff.fabricDataWidget.value) } } } continue(setDone) { app.queuePrompt(0, 1); } /** * moves the active object in the fabric canvas * @param direction array with [x,y] coords in range -1 +1 with 0 for no movement * @param withShift */ moveSelected(direction = [], withShift = false) { const STEP = withShift ? 10 : 1; const activeObject = this.fcanvas.getActiveObject(); if (activeObject) { activeObject.set({ left: activeObject.left + direction[0] * STEP, top: activeObject.top + direction[1] * STEP, }); this.fcanvas.renderAll(); instance.fcanvas.bringToFront(instance.compositionBorder); // console.log("selected objects are moved"); } } /** handling of * - selections * - wheel * - keyboard * inside the fabric canvas */ setupfCanvasEvents(compositorInstance) { function isSubmit(key, ctl) { return key === 13 && ctl; } function isLeft(key) { return key === 37; } function isTop(key) { return key === 38; } function isRight(key) { return key === 39; } function isDown(key) { return key === 40; } function downDirection() { return [-1, 0]; } function topDirection() { return [0, -1]; } function rightDirection() { return [1, 0]; } this.fcanvas.on('selection:created', function (opt) { this.selected = opt.selected; // debugger; opt.e.preventDefault(); opt.e.stopPropagation(); }); this.fcanvas.on('selection:updated', function (opt) { this.selected = opt.selected; // debugger; opt.e.preventDefault(); opt.e.stopPropagation(); }); this.fcanvas.on('selection:cleared', function (opt) { this.selected = undefined; //opt.e.preventDefault(); //opt.e.stopPropagation(); }); this.fcanvas.on('mouse:out', function (opt) { // console.log("mouseout") // moving outside editor, this might fail to be intercepted depending on how full the // canvas is if (opt.target === null || opt.target === undefined || opt.target && opt.nextTarget === undefined) { compositorInstance.uploadIfNeeded(compositorInstance); } }); this.fcanvas.on('object:modified', function (opt) { // console.log(this, compositorInstance); // mark as needing upload so when we mouse out we doit then reset // mouse out is flimsy, sometimes it's not triggering compositorInstance.needsUpload = true; compositorInstance.fcanvas.bringToFront(compositorInstance.compositionBorder); compositorInstance.fcanvas.renderAll(); }); this.fcanvas.on('mouse:wheel', function (opt) { //console.log(opt); try { if (opt.target.cacheKey !== this.selected[0].cacheKey) return; if (!this.selected) return const sign = Math.sign(opt.e.deltaY); opt.target.scaleX = opt.target.scaleX + (sign * 0.01); opt.target.scaleY = opt.target.scaleY + (sign * 0.01); opt.target.dirty = true; opt.e.preventDefault(); opt.e.stopPropagation(); //this.fcanvas.renderAll() this.renderAll() } catch (e) { return; } }) fabric.util.addListener(document.body, 'keydown', function keydownHandler(options) { var key = options.which || options.keyCode; // key detection if (isLeft(key)) { this.moveSelected(downDirection(), options.shiftKey); } else if (isTop(key)) { this.moveSelected(topDirection(), options.shiftKey); } else if (isRight(key)) { this.moveSelected(rightDirection(), options.shiftKey); } else if (isDown(key)) { this.moveSelected([0, 1], options.shiftKey); } else if (isSubmit(key, options.ctrlKey)) { compositorInstance.uploadIfNeeded(compositorInstance); } }.bind(this)); } uploadIfNeeded(compositorInstance,callback = ()=>{console.log("upload if needed")}) { if (compositorInstance.needsUpload) { compositorInstance.needsUpload = false; const serialized = Editor.serializeStuff(compositorInstance.node); compositorInstance.node.fabricDataWidget.value = serialized; // const callback = () => { // // } compositorInstance.grabUploadAndSetOutput(compositorInstance, false, callback) } else { console.log("no upload needed to be done"); } } /** * The actual area of WxH dimensions that will be exported as output */ createCompositionArea() { //p, w, h, node return new fabric.Rect({ left: this.p.value, top: this.p.value, fill: this.COMPOSITION_BACKGROUND_COLOR, width: this.w.value, height: this.h.value, selectable: false, }); } /** * A non-interactive rectangle with transparent content and * colored border around that frames the composition from the outside * and is overlaid on top of all passed images * the size and position are calculated given the width height and * COMPOSITION_BORDER_SIZE * COMPOSITION_BORDER_COLOR */ createCompositionBorder() { // p, w, h, node debugger; const compositionBorder = new fabric.Rect({ left: this.p.value - this.COMPOSITION_BORDER_SIZE, top: this.p.value - this.COMPOSITION_BORDER_SIZE, fill: 'transparent', width: this.w.value + this.COMPOSITION_BORDER_SIZE * 2, height: this.h.value + this.COMPOSITION_BORDER_SIZE * 2, selectable: false, evented: false, }); console.log("compositionBorder", compositionBorder, this.COMPOSITION_BORDER_COLOR, this.COMPOSITION_BORDER_SIZE); compositionBorder.set("strokeWidth", this.COMPOSITION_BORDER_SIZE); compositionBorder.set("stroke", this.COMPOSITION_BORDER_COLOR); compositionBorder.set("selectable", false); compositionBorder.set("evented", false); return compositionBorder; } calculateNodeSize() { const ch = this.fcanvas.getHeight(); const cw = this.fcanvas.getWidth(); return [cw + 21, ch + 91]; } initFabric(c) { this.getCompositorSettings() // wannabe widgets this.w = { value: 512, callback: (value, graphCanvas, node) => { } }; this.h = { value: 512, callback: (value, graphCanvas, node) => { } }; this.p = { value: 100, callback: (value, graphCanvas, node) => { } }; this.containerDiv.width = this.w.value + 2 * this.p.value; this.containerDiv.height = this.h.value + 2 * this.p.value; if(!c) { this.canvasEl = Editor.createCanvasElement(); //this.canvasEl.id = 'test'; // ditor.getRandomCompositorUniqueId(); // this.canvasEl.id = Editor.getRandomCompositorUniqueId(); this.containerDiv.appendChild(this.canvasEl); this.containerDiv.style.overflow = "hidden"; this.canvasEl.width = this.w.value + 2 * this.p.value; this.canvasEl.height = this.h.value + 2 * this.p.value; this.fcanvas = Editor.createFabricCanvas(this.canvasEl); }else{ this.containerDiv.style.overflow = "hidden"; this.fcanvas = c; this.fcanvas.setWidth(this.w.value + 2 * this.p.value); this.fcanvas.setHeight(this.h.value + 2 * this.p.value); } console.log("this.fcanvas",this.fcanvas); this.compositionArea = this.createCompositionArea(); this.compositionBorder = this.createCompositionBorder(); this.fcanvas.add(this.compositionArea) this.fcanvas.add(this.compositionBorder) //this.fcanvas.bringToFront(this.compositionBorder); this.setupfCanvasEvents(this); this.fcanvas.renderAll(); this.node["compositorInstance"] = this; this.node.setSize(this.calculateNodeSize()) this.node.setDirtyCanvas(true, true); // make the node unresizable // this.node.resizable = false; } constructor(context, container) { this.node = context; this.containerDiv = container; this.node["compositorInstance"] = this; // also get settings here // WIDGET CALLBACKS this.reference = context.widgets.find(w => w.name === "widgetName"); // this.reference.callback = () => { // this.someVariable = this.reference.value // context.setSize... // this.updateSomething() // } // init library and setup events // define callbacks and other functions } } async function interrupt() { const response = await fetch('/interrupt', { method: 'POST', cache: 'no-cache', headers: { 'Content-Type': 'text/html' }, }); return await response.json(); }