diff --git a/apps/desktop/qml/components/graph/GraphMinimap.qml b/apps/desktop/qml/components/graph/GraphMinimap.qml new file mode 100644 index 0000000..a903ab6 --- /dev/null +++ b/apps/desktop/qml/components/graph/GraphMinimap.qml @@ -0,0 +1,66 @@ +import QtQuick +import "../.." +import "../../js/graphModel.js" as GM + +Rectangle { + id: root + property var host + property real viewportWidth: 1 + property real viewportHeight: 1 + readonly property var bounds: { + if (!host) return null + host.layoutRev; host.graphRev + return GM.sectionBounds(host.canvasGraph(), host.specs, host.nodeIds, host.nodeSizes) + } + readonly property real mapScale: bounds ? Math.min((width - 20) / Math.max(1, bounds.w), (height - 20) / Math.max(1, bounds.h)) : 1 + readonly property real offsetX: bounds ? (width - bounds.w * mapScale) / 2 - bounds.x * mapScale : 0 + readonly property real offsetY: bounds ? (height - bounds.h * mapScale) / 2 - bounds.y * mapScale : 0 + width: 170; height: 110 + z: 250 + color: Qt.alpha(AppTheme.bgAlt, 0.94) + border.color: AppTheme.border + radius: 6 + clip: true + Canvas { + id: map + anchors.fill: parent + Connections { + target: root.host + function onLayoutRevChanged() { map.requestPaint() } + function onGraphRevChanged() { map.requestPaint() } + function onSelectedIdsChanged() { map.requestPaint() } + function onPanXChanged() { map.requestPaint() } + function onPanYChanged() { map.requestPaint() } + function onZoomChanged() { map.requestPaint() } + } + onPaint: { + var ctx = getContext("2d") + ctx.reset() + if (!root.host || !root.bounds) return + var host = root.host, s = root.mapScale, ox = root.offsetX, oy = root.offsetY + host.canvasGraph().nodes.forEach(function(n) { + var b = GM.nodeRect(n, host.specs, host.nodeSizes) + ctx.fillStyle = host.isSelected(n.id) ? AppTheme.accentHi : GM.categoryColor((host.specs[n.type] || {}).category) + ctx.globalAlpha = 0.7 + ctx.fillRect(ox + b.x * s, oy + b.y * s, Math.max(2, b.w * s), Math.max(2, b.h * s)) + }) + ctx.globalAlpha = 1 + ctx.strokeStyle = AppTheme.text + ctx.lineWidth = 1 + ctx.strokeRect(ox - host.panX / host.zoom * s, oy - host.panY / host.zoom * s, + root.viewportWidth / host.zoom * s, root.viewportHeight / host.zoom * s) + } + } + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + function navigate(x, y) { + if (!root.host || !root.bounds) return + root.host.panX = root.viewportWidth / 2 - (x - root.offsetX) / root.mapScale * root.host.zoom + root.host.panY = root.viewportHeight / 2 - (y - root.offsetY) / root.mapScale * root.host.zoom + } + onPressed: function(m) { navigate(m.x, m.y) } + onPositionChanged: function(m) { if (pressed) navigate(m.x, m.y) } + onReleased: root.host.saveViewport() + } +} diff --git a/apps/desktop/qml/components/graph/GraphNode.qml b/apps/desktop/qml/components/graph/GraphNode.qml index d3bfa3b..9f06639 100644 --- a/apps/desktop/qml/components/graph/GraphNode.qml +++ b/apps/desktop/qml/components/graph/GraphNode.qml @@ -1,5 +1,5 @@ +pragma ComponentBehavior: Bound import QtQuick -import QtQuick.Controls import "../.." import "../" import "../../js/graphModel.js" as GM @@ -14,23 +14,26 @@ Rectangle { property var spec // node-type descriptor from /workflow/node-types property var host // GraphPage property bool selected: false + readonly property bool collapsed: host ? root.host.isCollapsed(root.node.id) : !!root.node.collapsed + property int previewIndex: 0 + property var widgetsByName: ({}) - readonly property var runState: host ? host.runStateFor(node.id) : null + readonly property var runState: host ? root.host.runStateFor(root.node.id) : null readonly property string st: runState ? (runState.state || "") : "" readonly property var workProgress: runState ? runState.progress || null : null - readonly property var shownIssues: host ? host.issuesFor(node.id) : [] - readonly property int rows: Math.max(GM.list(spec.inputs).length, GM.list(spec.outputs).length) - readonly property color catColor: GM.categoryColor(spec.category) + readonly property var shownIssues: host ? root.host.issuesFor(root.node.id) : [] + readonly property int rows: Math.max(GM.list(root.spec.inputs).length, GM.list(root.spec.outputs).length) + readonly property color catColor: GM.categoryColor(root.spec.category) property var visibleParams: { if (!host) return [] - host.visRev - return GM.list(spec.params).filter(function(ps) { return host.paramVisible(node, ps) }) + root.host.visRev + return GM.list(root.spec.params).filter(function(ps) { return root.host.paramVisible(node, ps) }) } property real nowMs: Date.now() - x: node.pos[0] - y: node.pos[1] - width: GM.nodeWidth(node.type) + x: { if (host) root.host.layoutRev; return root.node.pos[0] } + y: { if (host) root.host.layoutRev; return root.node.pos[1] } + width: GM.nodeWidth(root.node.type) height: col.implicitHeight + 6 radius: AppTheme.radius color: AppTheme.surface @@ -43,10 +46,10 @@ Rectangle { opacity: st === "pending" ? 0.82 : 1 z: selected ? 5 : 1 - onXChanged: if (body.drag.active) host.nodeMoved(node.id, x, y) - onYChanged: if (body.drag.active) host.nodeMoved(node.id, x, y) + onHeightChanged: if (host && node) root.host.measureNode(root.node.id, height) + Component.onCompleted: if (host && node) root.host.measureNode(root.node.id, height) - // Soft outline behind a selected node. + // Soft outline behind a selected root.node. Rectangle { anchors.fill: parent anchors.margins: -4 @@ -64,12 +67,15 @@ Rectangle { anchors.fill: parent z: -1 acceptedButtons: Qt.LeftButton | Qt.RightButton - drag.target: root - drag.threshold: 4 + preventStealing: true onPressed: function(m) { - host.select(node.id) - if (m.button === Qt.RightButton) host.openMenu(node.id) + if (m.button === Qt.RightButton) { root.host.openMenu(root.node.id); return } + root.host.beginMove(root.node.id, m.modifiers, mapToItem(root.host.world, m.x, m.y)) } + onPositionChanged: function(m) { if (pressed) root.host.moveNodes(mapToItem(root.host.world, m.x, m.y)) } + onReleased: root.host.endMove(false) + onCanceled: root.host.endMove(true) + onDoubleClicked: root.host.toggleCollapse([root.node.id]) } Column { @@ -103,7 +109,7 @@ Rectangle { anchors.right: tag.left anchors.rightMargin: 6 anchors.verticalCenter: parent.verticalCenter - text: node.title || spec.title + text: root.node.title || root.spec.title color: AppTheme.text font.pixelSize: AppTheme.fontSmall + 1 font.weight: Font.DemiBold @@ -126,6 +132,7 @@ Rectangle { var s = Math.max(0, Math.round((root.nowMs - (root.runState.startedMs || root.nowMs)) / 1000)) return "running " + Math.floor(s / 60) + ":" + (s % 60 < 10 ? "0" : "") + (s % 60) case "done": + if (root.runState.cached) return "cached" return root.runState.ms ? "done " + (root.runState.ms / 1000).toFixed(1) + " s" : "done" case "failed": return "failed" case "canceled": return "canceled" @@ -176,28 +183,30 @@ Rectangle { Repeater { model: root.rows delegate: Item { + id: socketRow + required property int index width: col.width height: GM.ROW - readonly property var inPort: index < GM.list(spec.inputs).length ? spec.inputs[index] : null - readonly property var outPort: index < GM.list(spec.outputs).length ? spec.outputs[index] : null + readonly property var inPort: index < GM.list(root.spec.inputs).length ? root.spec.inputs[index] : null + readonly property var outPort: index < GM.list(root.spec.outputs).length ? root.spec.outputs[index] : null Text { - visible: inPort !== null + visible: socketRow.inPort !== null x: 16 anchors.verticalCenter: parent.verticalCenter text: { - if (!inPort) return "" - var t = GM.types(inPort) - return inPort.name + (t.length > 1 ? " (" + t.join(" or ").toLowerCase() + ")" : "") + if (!socketRow.inPort) return "" + var t = GM.types(socketRow.inPort) + return socketRow.inPort.name + (t.length > 1 ? " (" + t.join(" or ").toLowerCase() + ")" : "") } - color: inPort && inPort.required ? AppTheme.textDim : AppTheme.textFaint + color: socketRow.inPort && socketRow.inPort.required ? AppTheme.textDim : AppTheme.textFaint font.pixelSize: AppTheme.fontSmall } Text { - visible: outPort !== null + visible: socketRow.outPort !== null anchors.right: parent.right anchors.rightMargin: 16 anchors.verticalCenter: parent.verticalCenter - text: outPort ? outPort.name : "" + text: socketRow.outPort ? socketRow.outPort.name : "" color: AppTheme.textDim font.pixelSize: AppTheme.fontSmall } @@ -207,7 +216,8 @@ Rectangle { // ---- parameters ---- Item { width: parent.width - height: paramCol.implicitHeight > 0 ? paramCol.implicitHeight + 14 : 4 + visible: !root.collapsed + height: root.collapsed ? 0 : paramCol.implicitHeight > 0 ? paramCol.implicitHeight + 14 : 4 Column { id: paramCol x: 10 @@ -219,8 +229,10 @@ Rectangle { model: root.visibleParams delegate: Loader { id: loader + required property var modelData width: paramCol.width property var ps: modelData + enabled: !root.host.inspectingRun source: root.widgetFile(ps) onLoaded: root.bindWidget(item, ps) } @@ -235,8 +247,8 @@ Rectangle { // Visibility must not depend on the height of its own children: // a hidden item reports its children as hidden, so they would // never contribute a height and the footer would stay hidden. - visible: root.hasFooter - height: root.hasFooter ? footerCol.implicitHeight + 12 : 0 + visible: root.hasFooter && !root.collapsed + height: visible ? footerCol.implicitHeight + 12 : 0 Rectangle { visible: footerCol.implicitHeight > 0 anchors.top: parent.top @@ -251,6 +263,11 @@ Rectangle { width: parent.width - 20 spacing: 6 + Text { + visible: !!root.runState && root.runState.seed !== undefined && root.runState.seed !== null + text: visible ? "Seed " + root.runState.seed : "" + color: AppTheme.textFaint; font.pixelSize: AppTheme.fontSmall + } Text { width: parent.width visible: text !== "" @@ -274,6 +291,7 @@ Rectangle { } Image { id: preview + objectName: "nodePreview" visible: root.previewUrl !== "" && !root.isVideo && status === Image.Ready width: parent.width height: visible ? Math.min(root.previewMax, width * implicitHeight / Math.max(1, implicitWidth)) : 0 @@ -285,7 +303,7 @@ Rectangle { MouseArea { anchors.fill: parent cursorShape: Qt.PointingHandCursor - onClicked: Qt.openUrlExternally(root.previewUrl) + onClicked: root.host.viewImages(root.previewSources, root.previewIndex) } } AppButton { @@ -295,9 +313,37 @@ Rectangle { implicitHeight: 28 onClicked: Qt.openUrlExternally(root.previewUrl) } + Row { + visible: root.previewSources.length > 1 + spacing: 8 + AppButton { + objectName: "previousPreview" + text: "‹"; flat: true; implicitHeight: 26 + enabled: root.previewIndex > 0 + onClicked: root.previewIndex-- + } + Text { + anchors.verticalCenter: parent.verticalCenter + text: (root.previewIndex + 1) + " / " + root.previewSources.length + color: AppTheme.textDim; font.pixelSize: AppTheme.fontSmall + } + AppButton { + objectName: "nextPreview" + text: "›"; flat: true; implicitHeight: 26 + enabled: root.previewIndex + 1 < root.previewSources.length + onClicked: root.previewIndex++ + } + } + Text { + width: parent.width + visible: root.node.type === "image.load" && root.previewUrl !== "" && preview.status === Image.Error + text: "Input image is unavailable" + color: AppTheme.warning; font.pixelSize: AppTheme.fontSmall + } Repeater { model: root.shownIssues delegate: Text { + required property var modelData width: footerCol.width wrapMode: Text.Wrap text: modelData.message @@ -311,32 +357,41 @@ Rectangle { // ---- sockets ---- Repeater { - model: GM.list(spec.inputs) + model: GM.list(root.spec.inputs) delegate: Socket { + required property var modelData + required index host: root.host; nodeId: root.node.id; cardColor: root.color; nodeWidth: root.width - port: modelData; index: model.index; isOutput: false + port: modelData; isOutput: false } } Repeater { - model: GM.list(spec.outputs) + model: GM.list(root.spec.outputs) delegate: Socket { + required property var modelData + required index host: root.host; nodeId: root.node.id; cardColor: root.color; nodeWidth: root.width - port: modelData; index: model.index; isOutput: true + port: modelData; isOutput: true } } readonly property bool hasFooter: shownIssues.length > 0 - || (runState !== null && ((runState.message || "") !== "" || previewUrl !== "")) + || previewUrl !== "" || (runState !== null && ((runState.message || "") !== "" || runState.seed !== undefined)) // ---- previews ---- - readonly property string previewUrl: { - var urls = runState && runState.file_urls ? runState.file_urls : [] - if (urls.length === 0) return "" - if (node.type === "image.load") return "" - return urls[0] + readonly property var previewSources: { + if (host) root.host.graphRev + if (root.node.type === "image.load" || root.node.type === "mask.load") { + var ps = GM.list(root.spec.params).filter(function(p) { return p.kind === "path" })[0] + var path = ps && host ? root.host.getParam(node, ps) : "" + return path ? [GM.localPathToFileUrl(path)] : [] + } + return runState && runState.file_urls ? runState.file_urls : [] } + onPreviewSourcesChanged: previewIndex = Math.max(0, Math.min(previewIndex, previewSources.length - 1)) + readonly property string previewUrl: previewSources[previewIndex] || "" readonly property bool isVideo: /\.(webm|avi|mp4)$/i.test(previewUrl) - readonly property real previewMax: (node.type === "image.save" || node.type === "video.save") ? 280 : 150 + readonly property real previewMax: (root.node.type === "image.save" || root.node.type === "video.save") ? 280 : 150 function widgetFile(ps) { switch (ps.kind) { @@ -349,27 +404,28 @@ Rectangle { } } function bindWidget(item, ps) { + widgetsByName[ps.name] = item item.spec = ps - item.value = host.getParam(node, ps) + item.value = root.host.getParam(node, ps) if (item.options !== undefined) - item.options = ps.kind === "model" ? host.modelOptions : host.paramOptions(ps) - item.edited.connect(function(v) { host.setParam(node.id, ps.name, v) }) + item.options = ps.kind === "model" ? root.host.modelOptions : root.host.paramOptions(ps) + item.edited.connect(function(v) { root.host.setParam(root.node.id, ps.name, v, ps.kind === "text" || ps.kind === "string") }) if (ps.kind === "path" && item.browseClicked) - item.browseClicked.connect(function() { host.browse(node.id, ps.name) }) + item.browseClicked.connect(function() { root.host.browse(root.node.id, ps.name) }) } function syncParams() { - for (var i = 0; i < paramRepeater.count; i++) { - var l = paramRepeater.itemAt(i) - if (l && l.item && l.item.syncFrom) l.item.syncFrom(host.getParam(node, l.ps)) + for (var i = 0; i < visibleParams.length; i++) { + var ps = visibleParams[i], widget = widgetsByName[ps.name] + if (widget && widget.syncFrom) widget.syncFrom(root.host.getParam(node, ps)) } } Connections { target: root.host function onParamsRevChanged() { root.syncParams() } function onModelOptionsChanged() { - for (var i = 0; i < paramRepeater.count; i++) { - var l = paramRepeater.itemAt(i) - if (l && l.item && l.ps.kind === "model") l.item.options = host.modelOptions + for (var i = 0; i < root.visibleParams.length; i++) { + var ps = root.visibleParams[i], widget = root.widgetsByName[ps.name] + if (widget && ps.kind === "model") widget.options = root.host.modelOptions } } } diff --git a/apps/desktop/qml/components/graph/ImageViewer.qml b/apps/desktop/qml/components/graph/ImageViewer.qml new file mode 100644 index 0000000..b720e02 --- /dev/null +++ b/apps/desktop/qml/components/graph/ImageViewer.qml @@ -0,0 +1,114 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import QtQuick.Window +import "../.." +import ".." + +// A separate native window. open() works independently of popup parenting. +Window { + id: root + property var sources: [] + property int currentIndex: 0 + property string comparisonSource: "" + property string currentSource: sources && currentIndex >= 0 && currentIndex < sources.length + ? String(sources[currentIndex]) : "" + property bool comparing: comparisonSource !== "" + property bool fullscreen: false + property bool _wasMaximized: false + + title: "Image Studio · " + (currentSource ? (currentIndex + 1) + " / " + sources.length : "Results") + width: 1180 + height: 800 + minimumWidth: 640 + minimumHeight: 420 + color: AppTheme.bg + visible: false + + function open() { + currentIndex = sources && sources.length ? Math.max(0, Math.min(sources.length - 1, currentIndex)) : 0 + mainImage.resetView(); referenceImage.resetView() + show() + raise() + requestActivate() + } + function previous() { if (currentIndex > 0) --currentIndex } + function next() { if (sources && currentIndex + 1 < sources.length) ++currentIndex } + function toggleFullscreen() { + if (visibility === Window.FullScreen) { + if (_wasMaximized) showMaximized() + else showNormal() + } else { + _wasMaximized = visibility === Window.Maximized + showFullScreen() + } + } + onVisibilityChanged: function(visibility) { fullscreen = visibility === Window.FullScreen } + onSourcesChanged: { + currentIndex = sources && sources.length ? Math.max(0, Math.min(sources.length - 1, currentIndex)) : 0 + } + Component.onCompleted: AppTheme.applyPalette(root) + + Shortcut { sequence: "Left"; enabled: root.visible; onActivated: root.previous() } + Shortcut { sequence: "Right"; enabled: root.visible; onActivated: root.next() } + Shortcut { sequence: "F11"; enabled: root.visible; onActivated: root.toggleFullscreen() } + Shortcut { + sequence: "Escape"; enabled: root.visible + onActivated: { if (root.fullscreen) root.toggleFullscreen(); else root.close() } + } + Shortcut { sequence: "+"; enabled: root.visible; onActivated: mainImage.zoomAt(mainImage.zoomFactor * 1.25, mainImage.width / 2, mainImage.height / 2) } + Shortcut { sequence: "-"; enabled: root.visible; onActivated: mainImage.zoomAt(mainImage.zoomFactor / 1.25, mainImage.width / 2, mainImage.height / 2) } + Shortcut { sequence: "0"; enabled: root.visible; onActivated: { mainImage.resetView(); referenceImage.resetView() } } + + ColumnLayout { + anchors.fill: parent + anchors.margins: AppTheme.padSmall + spacing: AppTheme.gapTight + RowLayout { + Layout.fillWidth: true + AppButton { objectName: "previousImageButton"; text: "←"; accessibleDescription: "Previous image"; enabled: root.currentIndex > 0; onClicked: root.previous() } + Label { text: root.currentSource ? (root.currentIndex + 1) + " / " + root.sources.length : "No results"; color: AppTheme.text } + AppButton { objectName: "nextImageButton"; text: "→"; accessibleDescription: "Next image"; enabled: root.sources && root.currentIndex + 1 < root.sources.length; onClicked: root.next() } + Item { Layout.fillWidth: true } + AppButton { text: "−"; accessibleDescription: "Zoom out"; enabled: mainImage.zoomFactor > 1; onClicked: mainImage.zoomAt(mainImage.zoomFactor / 1.25, mainImage.width / 2, mainImage.height / 2) } + Label { text: mainImage.zoomFactor.toFixed(1) + "×"; color: AppTheme.textDim } + AppButton { text: "+"; accessibleDescription: "Zoom in"; enabled: mainImage.zoomFactor < 16; onClicked: mainImage.zoomAt(mainImage.zoomFactor * 1.25, mainImage.width / 2, mainImage.height / 2) } + AppButton { objectName: "fitImageButton"; text: "Fit"; onClicked: { mainImage.resetView(); referenceImage.resetView() } } + AppButton { text: root.fullscreen ? "Window" : "Fullscreen"; onClicked: root.toggleFullscreen() } + AppButton { text: "Close"; onClicked: root.close() } + } + RowLayout { + Layout.fillWidth: true + Layout.fillHeight: true + spacing: AppTheme.gap + ColumnLayout { + Layout.fillWidth: true + Layout.fillHeight: true + Label { visible: root.comparing; text: "Current result"; color: AppTheme.textDim } + ImageViewport { id: mainImage; objectName: "mainImageViewport"; Layout.fillWidth: true; Layout.fillHeight: true; fileUrl: root.currentSource } + } + ColumnLayout { + visible: root.comparing + Layout.fillWidth: true + Layout.fillHeight: true + Label { text: "Comparison"; color: AppTheme.textDim } + ImageViewport { id: referenceImage; objectName: "comparisonImageViewport"; Layout.fillWidth: true; Layout.fillHeight: true; fileUrl: root.comparisonSource } + } + } + Label { + Layout.fillWidth: true + text: root.currentSource + textFormat: Text.PlainText + elide: Text.ElideMiddle + color: AppTheme.textDim + font.pixelSize: AppTheme.fontSmall + } + Label { + Layout.fillWidth: true + text: "Wheel to zoom · Drag to pan · ← → for batch · Double-click to fit" + color: AppTheme.textFaint + font.pixelSize: AppTheme.fontSmall + wrapMode: Text.Wrap + } + } +} diff --git a/apps/desktop/qml/components/graph/ImageViewport.qml b/apps/desktop/qml/components/graph/ImageViewport.qml new file mode 100644 index 0000000..72fb624 --- /dev/null +++ b/apps/desktop/qml/components/graph/ImageViewport.qml @@ -0,0 +1,94 @@ +import QtQuick +import QtQuick.Controls +import "../.." + +// A fit-to-view image with pointer-centred wheel zoom and bounded drag pan. +Rectangle { + id: root + property string fileUrl: "" + property real zoomFactor: 1 + property real panX: 0 + property real panY: 0 + property size imageSize: picture.sourceSize + property int imageStatus: picture.status + property real fitWidth: picture.implicitWidth > 0 && picture.implicitHeight > 0 + ? Math.min(width, height * picture.implicitWidth / picture.implicitHeight) : width + property real fitHeight: picture.implicitWidth > 0 && picture.implicitHeight > 0 + ? Math.min(height, width * picture.implicitHeight / picture.implicitWidth) : height + property real maxPanX: Math.max(0, (fitWidth * zoomFactor - width) / 2) + property real maxPanY: Math.max(0, (fitHeight * zoomFactor - height) / 2) + + color: AppTheme.bgAlt + clip: true + border.color: AppTheme.border + Accessible.name: "Image preview" + Accessible.description: fileUrl + + function resetView() { zoomFactor = 1; panX = 0; panY = 0 } + function clampPan() { + panX = Math.max(-maxPanX, Math.min(maxPanX, panX)) + panY = Math.max(-maxPanY, Math.min(maxPanY, panY)) + } + function zoomAt(factor, pointX, pointY) { + var next = Math.max(1, Math.min(16, factor)) + var ratio = next / zoomFactor + panX = (pointX - width / 2) * (1 - ratio) + panX * ratio + panY = (pointY - height / 2) * (1 - ratio) + panY * ratio + zoomFactor = next + clampPan() + } + onFileUrlChanged: resetView() + onWidthChanged: clampPan() + onHeightChanged: clampPan() + + Image { + id: picture + objectName: "viewerImage" + source: root.fileUrl + asynchronous: true + // Retain original pixels for zoom inspection, rather than a thumbnail. + cache: false + fillMode: Image.PreserveAspectFit + width: root.fitWidth * root.zoomFactor + height: root.fitHeight * root.zoomFactor + x: (root.width - width) / 2 + root.panX + y: (root.height - height) / 2 + root.panY + smooth: root.zoomFactor < 4 + onStatusChanged: if (status === Image.Ready) root.clampPan() + } + + BusyIndicator { anchors.centerIn: parent; running: picture.status === Image.Loading } + Label { + anchors.centerIn: parent + width: Math.max(0, parent.width - 32) + horizontalAlignment: Text.AlignHCenter + wrapMode: Text.Wrap + text: root.fileUrl === "" ? "No image selected" : "Image unavailable\nThe result file may have moved or been removed." + visible: root.fileUrl === "" || picture.status === Image.Error + color: AppTheme.textDim + font.pixelSize: AppTheme.fontBody + } + MouseArea { + id: pointer + objectName: "imagePanArea" + anchors.fill: parent + acceptedButtons: Qt.LeftButton + cursorShape: root.zoomFactor > 1 ? (pressed ? Qt.ClosedHandCursor : Qt.OpenHandCursor) : Qt.ArrowCursor + property real lastX: 0 + property real lastY: 0 + onPressed: function(mouse) { lastX = mouse.x; lastY = mouse.y } + onPositionChanged: function(mouse) { + if (!pressed) return + root.panX += mouse.x - lastX + root.panY += mouse.y - lastY + lastX = mouse.x; lastY = mouse.y + root.clampPan() + } + onWheel: function(wheel) { + var delta = wheel.angleDelta.y || wheel.pixelDelta.y + root.zoomAt(root.zoomFactor * Math.pow(1.2, delta / 120), wheel.x, wheel.y) + wheel.accepted = true + } + onDoubleClicked: root.resetView() + } +} diff --git a/apps/desktop/qml/components/graph/MaskEditor.qml b/apps/desktop/qml/components/graph/MaskEditor.qml new file mode 100644 index 0000000..2fdaa00 --- /dev/null +++ b/apps/desktop/qml/components/graph/MaskEditor.qml @@ -0,0 +1,263 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import QtQuick.Window +import "../.." +import ".." + +Window { + id: root + property var api + property string source: "" + property var strokes: [] + // Brush diameter in displayed image pixels. Each stroke stores its radius + // relative to the image's larger side, independent of later window sizing. + property real brushSize: 32 + property bool erase: false + property bool saving: false + property string errorMessage: "" + property bool _painting: false + property int _strokeIndex: -1 + property int _pointCount: 0 + property int _requestSerial: 0 + property int maxStrokes: 1000 + property int maxPoints: 20000 + property bool imageWithinLimits: picture.status === Image.Ready + && picture.sourceSize.width > 0 && picture.sourceSize.height > 0 + && picture.sourceSize.width <= 4096 && picture.sourceSize.height <= 4096 + signal maskCreated(string path) + + title: "Image Studio · Paint mask" + width: 1060 + height: 780 + minimumWidth: 640 + minimumHeight: 440 + color: AppTheme.bg + visible: false + + function open() { + if (saving) { show(); raise(); requestActivate(); return } + clear() + if (!localPath(source)) errorMessage = "Choose a local image before painting a mask." + show() + raise() + requestActivate() + } + function clear() { + if (saving) return + strokes = []; _painting = false; _strokeIndex = -1; _pointCount = 0 + errorMessage = "" + overlay.requestPaint() + } + function localPath(fileUrl) { + var match = String(fileUrl || "").match(/^file:\/\/([^/]*)(\/.*)$/i) + if (!match || (match[1] !== "" && match[1].toLowerCase() !== "localhost")) return "" + var path + try { path = decodeURIComponent(match[2]) } catch (e) { return "" } + if (/^\/[a-z]:\//i.test(path)) path = path.substring(1) + return path.indexOf("\u0000") === -1 ? path : "" + } + function pointAt(stageX, stageY) { + if (picture.status !== Image.Ready || overlay.width <= 0 || overlay.height <= 0) return null + var x = (stageX - overlay.x) / overlay.width + var y = (stageY - overlay.y) / overlay.height + if (x < 0 || x > 1 || y < 0 || y > 1) return null + return [Math.max(0, Math.min(1, x)), Math.max(0, Math.min(1, y))] + } + function beginStroke(stageX, stageY) { + if (saving) return false + if (!imageWithinLimits) { errorMessage = "Mask images must be at most 4096 × 4096 pixels."; return false } + var point = pointAt(stageX, stageY) + if (!point) return false + if (strokes.length >= maxStrokes || _pointCount >= maxPoints) { + errorMessage = "This mask has reached the painting limit. Clear it to start again." + return false + } + var radius = Math.max(0.00001, Math.min(0.5, brushSize / 2 / Math.max(overlay.width, overlay.height))) + strokes = strokes.concat([{points: [point], radius: radius, erase: erase}]) + _strokeIndex = strokes.length - 1; _painting = true; ++_pointCount + errorMessage = "" + overlay.requestPaint() + return true + } + function appendPoint(stageX, stageY) { + if (!_painting || saving) return + var point = pointAt(stageX, stageY) + if (!point) { endStroke(); return } + if (_pointCount >= maxPoints) { + errorMessage = "This mask has reached the painting limit. Clear it to start again." + endStroke(); return + } + var previous = strokes[_strokeIndex].points + var last = previous[previous.length - 1] + // Avoid duplicate stationary samples while retaining short brush taps. + var dx = (point[0] - last[0]) * overlay.width + var dy = (point[1] - last[1]) * overlay.height + if (dx * dx + dy * dy < 1) return + var next = strokes.slice(), current = next[_strokeIndex] + next[_strokeIndex] = {points: current.points.concat([point]), radius: current.radius, erase: current.erase} + strokes = next; ++_pointCount + overlay.requestPaint() + } + function endStroke() { _painting = false; _strokeIndex = -1 } + function undoStroke() { + if (saving || !strokes.length) return + endStroke() + _pointCount -= strokes[strokes.length - 1].points.length + strokes = strokes.slice(0, -1) + overlay.requestPaint() + } + function submit() { + endStroke() + if (!api || saving) return + var path = localPath(source) + if (!path || picture.status !== Image.Ready) { errorMessage = "The source image is unavailable."; return } + if (!imageWithinLimits) { errorMessage = "Mask images must be at most 4096 × 4096 pixels."; return } + if (!strokes.some(function(stroke) { return !stroke.erase })) { errorMessage = "Paint an area to regenerate first."; return } + var serial = ++_requestSerial, submittedSource = source, client = api + saving = true; errorMessage = "" + client.post("/api/v1/workflow/masks", {image_path: path, strokes: strokes}, function(status, data) { + if (serial !== root._requestSerial || submittedSource !== root.source || client !== root.api || !root.saving) return + root.saving = false + if (status >= 200 && status < 300 && data && typeof data.path === "string" && data.path !== "") { + root.maskCreated(data.path) + root.close() + } else { + root.errorMessage = (data && (data.detail || data.error)) || "Could not save this mask. Try again." + } + }) + } + onSourceChanged: { ++_requestSerial; saving = false; clear() } + onApiChanged: { ++_requestSerial; saving = false } + onClosing: function(close) { close.accepted = !root.saving } + Component.onCompleted: AppTheme.applyPalette(root) + + Shortcut { sequence: "Escape"; enabled: root.visible && !root.saving; onActivated: root.close() } + Shortcut { sequence: "Ctrl+Z"; enabled: root.visible && !root.saving; onActivated: root.undoStroke() } + + ColumnLayout { + anchors.fill: parent + anchors.margins: AppTheme.padSmall + spacing: AppTheme.gap + Flow { + Layout.fillWidth: true + spacing: AppTheme.gapTight + Label { text: "Brush diameter"; color: AppTheme.textDim; height: 36; verticalAlignment: Text.AlignVCenter } + AppSlider { objectName: "maskBrushSlider"; width: 180; height: 36; from: 2; to: 160; value: root.brushSize; enabled: !root.saving; onMoved: root.brushSize = value } + Label { text: Math.round(root.brushSize) + " px"; color: AppTheme.textDim; height: 36; verticalAlignment: Text.AlignVCenter } + AppCheckBox { objectName: "maskEraseToggle"; text: "Erase"; checked: root.erase; enabled: !root.saving; onToggled: root.erase = checked } + AppButton { objectName: "maskUndoButton"; text: "Undo stroke"; enabled: !root.saving && root.strokes.length > 0; onClicked: root.undoStroke() } + AppButton { objectName: "maskClearButton"; text: "Clear"; enabled: !root.saving && root.strokes.length > 0; onClicked: root.clear() } + } + Rectangle { + id: stage + objectName: "maskPaintStage" + Layout.fillWidth: true + Layout.fillHeight: true + color: AppTheme.bgAlt + border.color: AppTheme.border + clip: true + Image { + id: picture + objectName: "maskSourceImage" + anchors.fill: parent + source: root.source + fillMode: Image.PreserveAspectFit + asynchronous: true + cache: false + onStatusChanged: if (status === Image.Ready && !root.imageWithinLimits) root.errorMessage = "Mask images must be at most 4096 × 4096 pixels." + } + Canvas { + id: overlay + objectName: "maskOverlay" + width: picture.status === Image.Ready ? picture.paintedWidth : 0 + height: picture.status === Image.Ready ? picture.paintedHeight : 0 + x: (stage.width - width) / 2 + y: (stage.height - height) / 2 + opacity: 0.55 + onWidthChanged: requestPaint() + onHeightChanged: requestPaint() + onPaint: { + var ctx = getContext("2d") + ctx.clearRect(0, 0, width, height) + ctx.lineCap = "round"; ctx.lineJoin = "round" + root.strokes.forEach(function(stroke) { + ctx.globalCompositeOperation = stroke.erase ? "destination-out" : "source-over" + ctx.strokeStyle = "white"; ctx.fillStyle = "white" + var radius = stroke.radius * Math.max(overlay.width, overlay.height) + ctx.lineWidth = radius * 2 + ctx.beginPath() + stroke.points.forEach(function(point, index) { + if (index === 0) ctx.moveTo(point[0] * overlay.width, point[1] * overlay.height) + else ctx.lineTo(point[0] * overlay.width, point[1] * overlay.height) + }) + ctx.stroke() + if (stroke.points.length === 1) { + var point = stroke.points[0] + ctx.beginPath(); ctx.arc(point[0] * overlay.width, point[1] * overlay.height, radius, 0, Math.PI * 2); ctx.fill() + } + }) + ctx.globalCompositeOperation = "source-over" + } + } + BusyIndicator { anchors.centerIn: parent; running: picture.status === Image.Loading } + Label { + anchors.centerIn: parent + text: "Image unavailable" + visible: picture.status === Image.Error || root.source === "" + color: AppTheme.textDim + } + MouseArea { + id: pointer + objectName: "maskPaintPointer" + anchors.fill: parent + enabled: !root.saving && picture.status === Image.Ready + hoverEnabled: true + acceptedButtons: Qt.LeftButton + cursorShape: Qt.CrossCursor + property bool paintGesture: false + onPressed: function(mouse) { paintGesture = root.beginStroke(mouse.x, mouse.y) } + onPositionChanged: function(mouse) { + if (!pressed || !paintGesture) return + if (root._painting) root.appendPoint(mouse.x, mouse.y) + else root.beginStroke(mouse.x, mouse.y) + } + onReleased: { root.endStroke(); paintGesture = false } + onCanceled: { root.endStroke(); paintGesture = false } + Rectangle { + x: pointer.mouseX - width / 2 + y: pointer.mouseY - height / 2 + width: root.brushSize + height: width + radius: width / 2 + visible: pointer.containsMouse && root.pointAt(pointer.mouseX, pointer.mouseY) !== null + color: "transparent" + border.color: root.erase ? AppTheme.danger : "white" + border.width: 1 + } + } + } + Label { + Layout.fillWidth: true + text: "Painted areas (white) will be regenerated. Erased and untouched areas are preserved." + color: AppTheme.textDim + font.pixelSize: AppTheme.fontSmall + wrapMode: Text.Wrap + } + Label { + Layout.fillWidth: true + visible: root.errorMessage !== "" + text: root.errorMessage + textFormat: Text.PlainText + wrapMode: Text.Wrap + color: AppTheme.danger + } + RowLayout { + Layout.fillWidth: true + Label { text: root.strokes.length + " strokes"; color: AppTheme.textFaint; font.pixelSize: AppTheme.fontSmall } + Item { Layout.fillWidth: true } + AppButton { objectName: "maskCloseButton"; text: "Cancel"; enabled: !root.saving; onClicked: root.close() } + AppButton { objectName: "maskSaveButton"; text: root.saving ? "Saving…" : "Use mask"; primary: true; enabled: !root.saving && !!root.api && root.imageWithinLimits && root.strokes.length > 0; onClicked: root.submit() } + } + } +} diff --git a/apps/desktop/qml/components/graph/RunHistory.js b/apps/desktop/qml/components/graph/RunHistory.js new file mode 100644 index 0000000..9c4c472 --- /dev/null +++ b/apps/desktop/qml/components/graph/RunHistory.js @@ -0,0 +1,119 @@ +.pragma library + +// Presentation of durable run snapshots only. No inference, cache decisions, +// progress estimates, or inferred run state belong in the history UI. +function active(run) { + return run && (run.state === "queued" || run.state === "running") +} + +function nodeRows(run) { + var rows = [], seen = {}, states = (run && run.nodes) || {} + var graphNodes = (run && run.graph && run.graph.nodes) || [] + for (var i = 0; i < graphNodes.length; ++i) { + var node = graphNodes[i] + if (!states[node.id]) continue + rows.push({ id: node.id, title: node.title || node.type || node.id, + type: node.type || "", params: node.params || {}, status: states[node.id] }) + seen[node.id] = true + } + Object.keys(states).sort().forEach(function(id) { + if (!seen[id]) rows.push({ id: id, title: id, type: "", params: {}, status: states[id] }) + }) + return rows +} + +function pathKey(value) { + var key = String(value || "") + try { key = decodeURIComponent(key) } catch (e) {} + // localhost is the same local file authority as an empty authority. + return key.replace(/^file:\/\/localhost\//i, "file:///") +} + +function imageUrl(value) { + return /^file:\/\//i.test(String(value || "")) + && /\.(png|jpe?g|webp|gif|bmp|tiff?|avif|svg)$/i.test(pathKey(value)) +} + +function resultImages(run) { + var rows = nodeRows(run), seen = {}, images = [] + // Saved results lead the batch; identical paths emitted by downstream + // preview/save nodes appear once. Keep distinct branch/intermediate files. + rows = rows.filter(function(row) { return row.type === "image.save" }) + .concat(rows.filter(function(row) { return row.type !== "image.save" })) + rows.forEach(function(row) { + var urls = row.status.file_urls || [], outputs = row.status.outputs || [] + urls.forEach(function(url, index) { + if (!imageUrl(url)) return + var key = pathKey(url) + if (seen[key]) return + seen[key] = true + images.push({ fileUrl: String(url), nodeId: row.id, nodeTitle: row.title, + outputPath: outputs[index] || "", saved: row.type === "image.save" }) + }) + }) + return images +} + +function prompts(run) { + var nodes = (run && run.graph && run.graph.nodes) || [], result = [] + nodes.forEach(function(node) { + var params = node.params || {} + if (node.type === "prompt" && params.text) + result.push({ title: node.title || "Prompt", text: String(params.text) }) + // Accommodate snapshots that also retain resolved generation params. + if (params.prompt) result.push({ title: node.title || "Prompt", text: String(params.prompt) }) + if (params.negative_prompt) + result.push({ title: "Negative prompt", text: String(params.negative_prompt) }) + }) + return result +} + +function title(run) { + return (run && run.graph && run.graph.name) || (run && run.id) || "Run" +} + +function matches(run, query) { + var terms = String(query || "").trim().toLowerCase().split(/\s+/) + var text = [title(run), run.id || "", run.state || "", run.error || ""] + prompts(run).forEach(function(prompt) { text.push(prompt.title, prompt.text) }) + nodeRows(run).forEach(function(row) { text.push(row.title, row.status.message || "") }) + var haystack = text.join(" ").toLowerCase() + return terms.every(function(term) { return haystack.indexOf(term) !== -1 }) +} + +function seed(status) { + if (!status) return "" + var value = status.resolved_seed + if (value === undefined || value === null) value = status.seed + if (value !== undefined && value !== null && Number(value) >= 0) return String(value) + var found = String(status.message || "").match(/\bseed\s*[:=]?\s*(\d+)\b/i) + return found ? found[1] : "" +} + +function seedSummary(run) { + var seeds = [] + nodeRows(run).forEach(function(row) { + var value = seed(row.status) + if (value !== "") seeds.push(row.title + ": " + value) + }) + return seeds.join(" · ") +} + +function duration(ms) { + if (!(ms >= 0)) return "" + return ms < 1000 ? Math.round(ms) + " ms" : (ms / 1000).toFixed(1) + " s" +} + +function timestamp(value) { + if (!value) return "" + var date = new Date(value) + return isNaN(date.getTime()) ? String(value) : date.toLocaleString() +} + +function progressText(status) { + var p = status.progress + var text = status.message || (p && p.message) || status.state || "" + if (p && p.total > 0 && !(p.message || status.message)) + text += " · " + (p.current || 0) + "/" + p.total + (p.unit ? " " + p.unit : "") + return text +} diff --git a/apps/desktop/qml/components/graph/RunHistoryCard.qml b/apps/desktop/qml/components/graph/RunHistoryCard.qml new file mode 100644 index 0000000..3426a6c --- /dev/null +++ b/apps/desktop/qml/components/graph/RunHistoryCard.qml @@ -0,0 +1,255 @@ +pragma ComponentBehavior: Bound +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import "../.." +import ".." +import "RunHistory.js" as History + +Rectangle { + id: root + property var runData: ({}) + property bool expanded: false + property bool cancelPending: false + property string cancelError: "" + property var images: History.resultImages(runData) + property var nodes: History.nodeRows(runData) + property var promptRows: History.prompts(runData) + signal inspectRequested() + signal restoreRequested() + signal cancelRequested() + signal toggleDetails() + signal viewRequested(int imageIndex) + signal useRequested(string fileUrl) + signal compareRequested(string fileUrl) + + implicitHeight: body.implicitHeight + 2 * AppTheme.padSmall + color: AppTheme.surface + border.color: AppTheme.border + radius: AppTheme.radius + + ColumnLayout { + id: body + x: AppTheme.padSmall + y: AppTheme.padSmall + width: Math.max(0, root.width - 2 * AppTheme.padSmall) + spacing: AppTheme.gapTight + RowLayout { + Layout.fillWidth: true + Label { + Layout.fillWidth: true + text: History.title(root.runData) + textFormat: Text.PlainText + color: AppTheme.text + font.weight: Font.DemiBold + elide: Text.ElideRight + } + Tag { text: root.runData.state || ""; tone: AppTheme.stateColor(root.runData.state || "") } + AppButton { + objectName: "cancelRunButton" + visible: History.active(root.runData) + text: root.cancelPending ? "Canceling…" : "Cancel" + enabled: !root.cancelPending + flat: true + accessibleDescription: "Cancel run " + root.runData.id + onClicked: root.cancelRequested() + } + } + Label { + Layout.fillWidth: true + text: History.timestamp(root.runData.created_at) + (root.runData.only ? " · Node: " + root.runData.only : "") + color: AppTheme.textFaint + font.pixelSize: AppTheme.fontSmall + textFormat: Text.PlainText + wrapMode: Text.Wrap + } + Label { + Layout.fillWidth: true + visible: root.promptRows.length > 0 && !root.expanded + text: root.promptRows.map(function(prompt) { return prompt.text }).join(" · ") + textFormat: Text.PlainText + maximumLineCount: 2 + wrapMode: Text.Wrap + elide: Text.ElideRight + color: AppTheme.textDim + font.pixelSize: AppTheme.fontSmall + } + Label { + Layout.fillWidth: true + visible: History.seedSummary(root.runData) !== "" + text: "Resolved seed · " + History.seedSummary(root.runData) + textFormat: Text.PlainText + color: AppTheme.textDim + font.pixelSize: AppTheme.fontSmall + wrapMode: Text.Wrap + } + + Repeater { + model: History.active(root.runData) ? root.nodes.filter(function(row) { return row.status.state === "running" }) : [] + delegate: ColumnLayout { + id: progressRow + required property var modelData + Layout.fillWidth: true + spacing: 4 + Label { + Layout.fillWidth: true + text: progressRow.modelData.title + " · " + History.progressText(progressRow.modelData.status) + textFormat: Text.PlainText + color: AppTheme.textDim + wrapMode: Text.Wrap + font.pixelSize: AppTheme.fontSmall + } + AppProgressBar { + Layout.fillWidth: true + visible: progressRow.modelData.status.progress && progressRow.modelData.status.progress.total > 0 + from: 0 + to: progressRow.modelData.status.progress && progressRow.modelData.status.progress.total > 0 ? progressRow.modelData.status.progress.total : 1 + value: progressRow.modelData.status.progress ? (progressRow.modelData.status.progress.current || 0) : 0 + } + } + } + Label { + Layout.fillWidth: true + visible: !!root.runData.error || root.cancelError !== "" + text: root.cancelError || root.runData.error || "" + textFormat: Text.PlainText + wrapMode: Text.Wrap + color: AppTheme.danger + font.pixelSize: AppTheme.fontSmall + } + Flow { + Layout.fillWidth: true + spacing: AppTheme.gapTight + Repeater { + model: root.images + delegate: Column { + id: resultTile + required property var modelData + required property int index + width: Math.max(100, Math.min(156, body.width)) + spacing: 4 + AppButton { + objectName: "resultThumbnail_" + resultTile.index + width: parent.width + height: 112 + padding: 4 + accessibleDescription: "Open result " + (resultTile.index + 1) + " from " + resultTile.modelData.nodeTitle + contentItem: Item { + Image { + id: thumbnail + anchors.fill: parent + source: resultTile.modelData.fileUrl + sourceSize: Qt.size(312, 224) + fillMode: Image.PreserveAspectFit + asynchronous: true + } + Label { + anchors.centerIn: parent + width: parent.width + horizontalAlignment: Text.AlignHCenter + wrapMode: Text.Wrap + visible: thumbnail.status === Image.Error || thumbnail.status === Image.Loading + text: thumbnail.status === Image.Loading ? "Loading…" : "File unavailable" + font.pixelSize: AppTheme.fontSmall + color: AppTheme.textFaint + } + } + onClicked: root.viewRequested(resultTile.index) + } + Label { + width: parent.width + text: resultTile.modelData.nodeTitle + (resultTile.modelData.saved ? " · Saved" : "") + textFormat: Text.PlainText + color: AppTheme.textFaint + font.pixelSize: AppTheme.fontSmall + elide: Text.ElideRight + } + Row { + spacing: 2 + AppButton { objectName: "useResult_" + resultTile.index; text: "Use"; flat: true; accessibleDescription: "Use result " + (resultTile.index + 1); onClicked: root.useRequested(resultTile.modelData.fileUrl) } + AppButton { objectName: "compareResult_" + resultTile.index; text: "Compare"; flat: true; accessibleDescription: "Compare result " + (resultTile.index + 1); onClicked: root.compareRequested(resultTile.modelData.fileUrl) } + } + } + } + } + Flow { + Layout.fillWidth: true + spacing: 4 + AppButton { objectName: "inspectRunButton"; text: "Inspect"; flat: true; onClicked: root.inspectRequested() } + AppButton { objectName: "restoreRunButton"; text: "Restore graph"; flat: true; enabled: !!root.runData.graph; onClicked: root.restoreRequested() } + AppButton { objectName: "runDetailsButton"; text: root.expanded ? "Hide details" : "Details"; flat: true; onClicked: root.toggleDetails() } + } + ColumnLayout { + visible: root.expanded + Layout.fillWidth: true + spacing: AppTheme.gapTight + Label { + Layout.fillWidth: true + text: "Run " + (root.runData.id || "") + + (root.runData.workflow_id ? "\nWorkflow " + root.runData.workflow_id : "") + + (root.runData.started_at ? "\nStarted " + History.timestamp(root.runData.started_at) : "") + + (root.runData.finished_at ? "\nFinished " + History.timestamp(root.runData.finished_at) : "") + textFormat: Text.PlainText + wrapMode: Text.Wrap + color: AppTheme.textDim + font.pixelSize: AppTheme.fontSmall + } + Repeater { + model: root.promptRows + delegate: ColumnLayout { + id: promptRow + required property var modelData + Layout.fillWidth: true + Label { text: promptRow.modelData.title; textFormat: Text.PlainText; color: AppTheme.text; font.pixelSize: AppTheme.fontSmall } + AppTextArea { + Layout.fillWidth: true + text: promptRow.modelData.text + textFormat: TextEdit.PlainText + readOnly: true + font.pixelSize: AppTheme.fontSmall + Accessible.name: promptRow.modelData.title + } + } + } + Repeater { + model: root.nodes + delegate: ColumnLayout { + id: nodeDetail + required property var modelData + Layout.fillWidth: true + spacing: 4 + Label { + Layout.fillWidth: true + text: nodeDetail.modelData.title + " · " + (nodeDetail.modelData.status.state || "") + + (nodeDetail.modelData.status.cached ? " · Cached" : "") + + (nodeDetail.modelData.status.ms !== undefined ? " · " + History.duration(nodeDetail.modelData.status.ms) : "") + textFormat: Text.PlainText + wrapMode: Text.Wrap + font.pixelSize: AppTheme.fontSmall + color: AppTheme.stateColor(nodeDetail.modelData.status.state || "") + } + Label { + Layout.fillWidth: true + text: History.progressText(nodeDetail.modelData.status) + + (History.seed(nodeDetail.modelData.status) !== "" ? "\nResolved seed: " + History.seed(nodeDetail.modelData.status) : "") + + (nodeDetail.modelData.status.job_id ? "\nJob " + nodeDetail.modelData.status.job_id : "") + textFormat: Text.PlainText + wrapMode: Text.Wrap + color: AppTheme.textDim + font.pixelSize: AppTheme.fontSmall + } + AppTextArea { + Layout.fillWidth: true + visible: Object.keys(nodeDetail.modelData.params).length > 0 || (nodeDetail.modelData.status.outputs || []).length > 0 + text: JSON.stringify(nodeDetail.modelData.params, null, 2) + + ((nodeDetail.modelData.status.outputs || []).length ? "\nOutputs:\n" + nodeDetail.modelData.status.outputs.join("\n") : "") + textFormat: TextEdit.PlainText + readOnly: true + font.pixelSize: AppTheme.fontSmall + Accessible.name: nodeDetail.modelData.title + " parameters and output paths" + } + } + } + } + } +} diff --git a/apps/desktop/qml/components/graph/RunHistoryPanel.qml b/apps/desktop/qml/components/graph/RunHistoryPanel.qml new file mode 100644 index 0000000..a86b5de --- /dev/null +++ b/apps/desktop/qml/components/graph/RunHistoryPanel.qml @@ -0,0 +1,225 @@ +pragma ComponentBehavior: Bound +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import "../.." +import ".." +import "RunHistory.js" as History + +Rectangle { + id: root + property var api + property var events + // An empty id shows all history. Queue always shows all workflows so every + // waiting/running job remains individually cancelable. + property string workflowId: "" + property var runs: [] + property bool refreshing: false + property string refreshError: "" + property bool _ready: false + property bool _refreshAgain: false + property int _requestSerial: 0 + property int _cancelSerial: 0 + property var _cancelPending: ({}) + property var _cancelErrors: ({}) + property string _expandedRunId: "" + property string _comparisonSource: "" + property var queueRuns: [] + property var _historyRecords: [] + property var historyRuns: _historyRecords.filter(function(run) { + return (!workflowFilter.checked || root.workflowId === "" || run.workflow_id === root.workflowId) + && History.matches(run, search.text) + }) + signal inspectRun(var run) + signal restoreRun(var run) + signal useImage(string fileUrl) + signal compareImage(string fileUrl) + + implicitWidth: 380 + implicitHeight: 640 + color: AppTheme.bgAlt + border.color: AppTheme.border + + function reload() { + if (!api) return + if (refreshing) { _refreshAgain = true; return } + refreshDelay.stop() + refreshing = true + _refreshAgain = false + var serial = ++_requestSerial + var client = api + client.get("/api/v1/workflow/runs", function(status, data) { + // Ignore callbacks from a replaced API client and duplicate timeout + // callbacks. Never let an older response roll history back. + if (serial !== root._requestSerial || client !== root.api || !root.refreshing) return + root.refreshing = false + if (status === 200 && data && Array.isArray(data.runs)) { + // Avoid recreating image delegates for identical idle snapshots. + if (JSON.stringify(root.runs) !== JSON.stringify(data.runs)) root.runs = data.runs + var queue = data.runs.filter(function(run) { return History.active(run) }) + var history = data.runs.filter(function(run) { return !History.active(run) }) + // Progress updates must not remount the entire finished gallery. + if (JSON.stringify(root.queueRuns) !== JSON.stringify(queue)) root.queueRuns = queue + if (JSON.stringify(root._historyRecords) !== JSON.stringify(history)) root._historyRecords = history + root.refreshError = "" + root._ready = true + } else { + root.refreshError = (data && (data.detail || data.error)) || (status ? "Could not load runs (HTTP " + status + ")" : "Backend unreachable") + } + if (root._refreshAgain) refreshDelay.start() + }) + } + function scheduleRefresh() { + if (refreshing) _refreshAgain = true + else if (!refreshDelay.running) refreshDelay.start() + } + function updateMap(current, key, value) { + var next = {} + Object.keys(current).forEach(function(id) { next[id] = current[id] }) + if (value === undefined) delete next[key] + else next[key] = value + return next + } + function cancelRun(run) { + if (!api || !History.active(run) || _cancelPending[run.id]) return + var id = run.id, client = api, serial = ++_cancelSerial + _cancelPending = updateMap(_cancelPending, id, serial) + _cancelErrors = updateMap(_cancelErrors, id, undefined) + client.post("/api/v1/workflow/runs/" + encodeURIComponent(id) + "/cancel", {}, function(status, data) { + if (client !== root.api || root._cancelPending[id] !== serial) return + root._cancelPending = root.updateMap(root._cancelPending, id, undefined) + if (status < 200 || status >= 300) + root._cancelErrors = root.updateMap(root._cancelErrors, id, (data && (data.detail || data.error)) || "Could not cancel this run") + root.reload() + }) + } + function viewResults(run, index) { + viewer.sources = History.resultImages(run).map(function(image) { return image.fileUrl }) + viewer.currentIndex = index + viewer.open() + } + function selectComparison(fileUrl) { + _comparisonSource = fileUrl + compareImage(fileUrl) + } + function toggleRunDetails(id) { _expandedRunId = _expandedRunId === id ? "" : id } + + Component.onCompleted: reload() + onApiChanged: { + ++_requestSerial + refreshing = false; _refreshAgain = false + _cancelPending = ({}); _cancelErrors = ({}) + runs = []; queueRuns = []; _historyRecords = []; _ready = false; refreshError = "" + if (api) scheduleRefresh() + } + onVisibleChanged: if (visible) scheduleRefresh() + Connections { + target: root.events || null + function onEventReceived(name, payload) { + if (name.indexOf("workflow.") === 0) root.scheduleRefresh() + } + function onReconnected() { root.scheduleRefresh() } + } + Timer { id: refreshDelay; interval: 180; onTriggered: root.reload() } + Timer { + interval: root.queueRuns.length ? 3000 : 12000 + running: root.visible && !!root.api + repeat: true + onTriggered: root.reload() + } + ImageViewer { id: viewer; objectName: "historyImageViewer"; comparisonSource: root._comparisonSource } + + ColumnLayout { + anchors.fill: parent + anchors.margins: AppTheme.padSmall + spacing: AppTheme.gapTight + RowLayout { + Layout.fillWidth: true + Label { text: "Results & history"; color: AppTheme.text; font.pixelSize: AppTheme.fontTitle; font.weight: Font.DemiBold; Layout.fillWidth: true } + AppButton { objectName: "refreshRunsButton"; text: root.refreshing ? "Refreshing…" : "Refresh"; flat: true; enabled: !!root.api && !root.refreshing; onClicked: root.reload() } + } + Label { + visible: root.refreshError !== "" + Layout.fillWidth: true + text: root.refreshError + textFormat: Text.PlainText + color: AppTheme.danger + wrapMode: Text.Wrap + font.pixelSize: AppTheme.fontSmall + } + SearchField { id: search; objectName: "runHistorySearch"; Layout.fillWidth: true; placeholderText: "Search prompts, names, or run IDs"; searchLabel: "Search run history" } + AppCheckBox { id: workflowFilter; objectName: "runWorkflowFilter"; visible: root.workflowId !== ""; checked: true; text: "History for this workflow" } + ScrollView { + id: scroll + objectName: "runHistoryScroll" + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + contentWidth: availableWidth + ScrollBar.horizontal.policy: ScrollBar.AlwaysOff + ColumnLayout { + width: scroll.availableWidth + spacing: AppTheme.gapTight + Label { + Layout.fillWidth: true + text: "Queue · " + root.queueRuns.length + " · All workflows" + color: AppTheme.textDim + font.weight: Font.DemiBold + font.pixelSize: AppTheme.fontSmall + } + Label { + Layout.fillWidth: true + visible: root._ready && !root.queueRuns.length + text: "No queued or running jobs" + color: AppTheme.textFaint + font.pixelSize: AppTheme.fontSmall + } + Repeater { + model: root.queueRuns + delegate: RunHistoryCard { + required property var modelData + objectName: "queueRun_" + modelData.id + Layout.fillWidth: true + runData: modelData + expanded: root._expandedRunId === modelData.id + cancelPending: !!root._cancelPending[modelData.id] + cancelError: root._cancelErrors[modelData.id] || "" + onInspectRequested: root.inspectRun(modelData) + onRestoreRequested: root.restoreRun(modelData) + onCancelRequested: root.cancelRun(modelData) + onToggleDetails: root.toggleRunDetails(modelData.id) + onViewRequested: function(imageIndex) { root.viewResults(modelData, imageIndex) } + onUseRequested: function(fileUrl) { root.useImage(fileUrl) } + onCompareRequested: function(fileUrl) { root.selectComparison(fileUrl) } + } + } + Label { Layout.fillWidth: true; text: "History · " + root.historyRuns.length; color: AppTheme.textDim; font.weight: Font.DemiBold; font.pixelSize: AppTheme.fontSmall; topPadding: 8 } + Repeater { + model: root.historyRuns + delegate: RunHistoryCard { + required property var modelData + objectName: "historyRun_" + modelData.id + Layout.fillWidth: true + runData: modelData + expanded: root._expandedRunId === modelData.id + onInspectRequested: root.inspectRun(modelData) + onRestoreRequested: root.restoreRun(modelData) + onToggleDetails: root.toggleRunDetails(modelData.id) + onViewRequested: function(imageIndex) { root.viewResults(modelData, imageIndex) } + onUseRequested: function(fileUrl) { root.useImage(fileUrl) } + onCompareRequested: function(fileUrl) { root.selectComparison(fileUrl) } + } + } + Label { + Layout.fillWidth: true + visible: !root.historyRuns.length + text: !root._ready ? (root.refreshing ? "Loading run history…" : "Run history unavailable") + : root.runs.length ? "No finished runs match this history filter" : "Run a workflow to see its results here" + color: AppTheme.textFaint + font.pixelSize: AppTheme.fontSmall + wrapMode: Text.Wrap + } + } + } + } +} diff --git a/apps/desktop/qml/js/graphModel.js b/apps/desktop/qml/js/graphModel.js index 23e2ca6..b770182 100644 --- a/apps/desktop/qml/js/graphModel.js +++ b/apps/desktop/qml/js/graphModel.js @@ -141,6 +141,11 @@ function addNode(graph, spec, x, y, params) { function removeNode(graph, id) { graph.nodes = graph.nodes.filter(function(n) { return n.id !== id }) graph.edges = graph.edges.filter(function(e) { return e.from[0] !== id && e.to[0] !== id }) + graph.groups = list(graph.groups).map(function(g) { + var copy = clone(g) + copy.nodes = list(g.nodes).filter(function(n) { return n !== id }) + return copy + }) } function duplicateNode(graph, node, spec) { @@ -257,6 +262,173 @@ function fileUrlToLocalPath(fileUrl) { return s } +function localPathToFileUrl(path) { + var s = String(path || "").replace(/\\/g, "/") + if (!s || s.indexOf("file://") === 0) return s + if (/^[A-Za-z]:\//.test(s)) s = "/" + s + if (s.indexOf("//") === 0) return "file:" + encodeURI(s).replace(/#/g, "%23").replace(/\?/g, "%3F") + return "file://" + s.split("/").map(function(p) { return encodeURIComponent(p) }).join("/") +} + +// Editor history contains document snapshots, never executor state. Adjacent +// typing in the same field shares one undo step until a pause or another edit. +function newHistory() { return { past: [], future: [] } } +function recordEdit(history, before, after, label, key, now) { + if (JSON.stringify(before) === JSON.stringify(after)) return false + var last = history.past[history.past.length - 1] + if (key && last && last.key === key && !history.future.length && now - last.time < 750) { + last.after = clone(after) + last.time = now + } else { + history.past.push({ before: clone(before), after: clone(after), label: label, + key: key || "", time: now }) + if (history.past.length > 100) history.past.shift() + } + history.future = [] + return true +} +function undo(history) { + var edit = history.past.pop() + if (!edit) return null + history.future.push(edit) + return clone(edit.before) +} +function redo(history) { + var edit = history.future.pop() + if (!edit) return null + history.past.push(edit) + return clone(edit.after) +} + +function idSet(ids) { + var out = Object.create(null) + for (var i = 0; i < ids.length; i++) out[ids[i]] = true + return out +} +function connectedSection(graph, ids) { + var seen = idSet(ids), todo = ids.slice() + while (todo.length) { + var id = todo.pop() + for (var i = 0; i < graph.edges.length; i++) { + var e = graph.edges[i], other = e.from[0] === id ? e.to[0] : e.to[0] === id ? e.from[0] : null + if (other && !seen[other]) { seen[other] = true; todo.push(other) } + } + } + return graph.nodes.filter(function(n) { return seen[n.id] }).map(function(n) { return n.id }) +} +function copySection(graph, ids) { + var selected = idSet(ids) + return clone({ version: 1, + nodes: graph.nodes.filter(function(n) { return selected[n.id] }), + edges: graph.edges.filter(function(e) { return selected[e.from[0]] && selected[e.to[0]] }), + groups: list(graph.groups).filter(function(g) { + return list(g.nodes).length && g.nodes.every(function(id) { return selected[id] }) + }) }) +} +// Accept only an editor fragment; external workflow JSON goes through the +// backend import API. Reject malformed/oversize clipboard input before edits. +function parseSection(text, specs) { + if (text.length > 4 * 1024 * 1024) throw new Error("Clipboard section is too large") + var s = JSON.parse(text) + if (!s || s.version !== 1 || !Array.isArray(s.nodes) || !Array.isArray(s.edges) + || !s.nodes.length || s.nodes.length > 1000 || s.edges.length > 4000) + throw new Error("Copy some workflow nodes first") + var seen = Object.create(null) + s.nodes.forEach(function(n) { + if (!n || typeof n.id !== "string" || seen[n.id] || !specs[n.type] + || !Array.isArray(n.pos) || n.pos.length !== 2 + || !n.pos.every(function(v) { return typeof v === "number" && isFinite(v) }) + || (n.params && (typeof n.params !== "object" || Array.isArray(n.params)))) + throw new Error("Clipboard contains an unsupported node") + seen[n.id] = true + }) + s.edges.forEach(function(e) { + if (!e || !Array.isArray(e.from) || !Array.isArray(e.to) || e.from.length !== 2 || e.to.length !== 2 + || !seen[e.from[0]] || !seen[e.to[0]]) throw new Error("Clipboard contains a broken wire") + }) + if (s.groups !== undefined && !Array.isArray(s.groups)) throw new Error("Invalid clipboard frames") + list(s.groups).forEach(function(g) { + if (!g || typeof g.title !== "string" || !Array.isArray(g.nodes) + || !g.nodes.every(function(id) { return seen[id] })) throw new Error("Invalid clipboard frame") + }) + return s +} +function pasteSection(graph, section, x, y) { + var x0 = Infinity, y0 = Infinity, map = Object.create(null), ids = [] + section.nodes.forEach(function(n) { x0 = Math.min(x0, n.pos[0]); y0 = Math.min(y0, n.pos[1]) }) + section.nodes.forEach(function(n) { + var copy = clone(n) + copy.id = nextId(graph) + copy.pos = [Math.round(n.pos[0] - x0 + x), Math.round(n.pos[1] - y0 + y)] + map[n.id] = copy.id + ids.push(copy.id) + graph.nodes.push(copy) + }) + section.edges.forEach(function(e) { graph.edges.push({ from: [map[e.from[0]], e.from[1]], to: [map[e.to[0]], e.to[1]] }) }) + graph.groups = list(graph.groups) + list(section.groups).forEach(function(g) { + var copy = clone(g) + copy.nodes = g.nodes.map(function(id) { return map[id] }) + graph.groups.push(copy) + }) + return ids +} + +function nodeRect(node, specs, sizes) { + var size = sizes && sizes[node.id] + return { x: node.pos[0], y: node.pos[1], w: nodeWidth(node.type), + h: size ? size.h : (node.collapsed && specs[node.type] ? HEADER + 16 + ROW * Math.max(list(specs[node.type].inputs).length, list(specs[node.type].outputs).length) + : specs[node.type] ? estimateHeight(specs[node.type]) : 200) } +} +function selectionInRect(graph, specs, rect, sizes) { + return graph.nodes.filter(function(n) { + var b = nodeRect(n, specs, sizes) + return b.x < rect.x + rect.w && b.x + b.w > rect.x && b.y < rect.y + rect.h && b.y + b.h > rect.y + }).map(function(n) { return n.id }) +} +function sectionBounds(graph, specs, ids, sizes) { + var set = idSet(ids), nodes = graph.nodes.filter(function(n) { return set[n.id] }) + if (!nodes.length) return null + var x = Infinity, y = Infinity, right = -Infinity, bottom = -Infinity + nodes.forEach(function(n) { + var b = nodeRect(n, specs, sizes) + x = Math.min(x, b.x); y = Math.min(y, b.y) + right = Math.max(right, b.x + b.w); bottom = Math.max(bottom, b.y + b.h) + }) + return { x: x, y: y, w: right - x, h: bottom - y } +} +function alignNodes(graph, specs, ids, mode, sizes) { + var set = idSet(ids), b = sectionBounds(graph, specs, ids, sizes) + if (!b) return + graph.nodes.forEach(function(n) { + if (!set[n.id]) return + var r = nodeRect(n, specs, sizes) + if (mode === "left") n.pos[0] = b.x + if (mode === "top") n.pos[1] = b.y + if (mode === "right") n.pos[0] = b.x + b.w - r.w + if (mode === "bottom") n.pos[1] = b.y + b.h - r.h + }) +} +// Position, labels, notes and viewport do not change a run's computation. +// Node IDs alone cannot establish that a run belongs on the editor canvas. +function stableValue(value) { + if (Array.isArray(value)) return value.map(stableValue) + if (value && typeof value === "object") { + var out = {} + Object.keys(value).sort().forEach(function(k) { out[k] = stableValue(value[k]) }) + return out + } + return value +} +function executionKey(graph) { + if (!graph) return "" + var nodes = list(graph.nodes).map(function(n) { return { id: n.id, type: n.type, params: n.params || {} } }) + nodes.sort(function(a, b) { return a.id.localeCompare(b.id) }) + var edges = list(graph.edges).map(function(e) { return { from: e.from, to: e.to } }) + edges.sort(function(a, b) { return JSON.stringify(a).localeCompare(JSON.stringify(b)) }) + return JSON.stringify(stableValue({ version: graph.version, nodes: nodes, edges: edges })) +} + // Rough size of a node for fitting the view before it has been laid out. function estimateHeight(spec) { var rows = Math.max(list(spec.inputs).length, list(spec.outputs).length) diff --git a/apps/desktop/qml/pages/GraphPage.qml b/apps/desktop/qml/pages/GraphPage.qml index e7c5c61..6f35e0a 100644 --- a/apps/desktop/qml/pages/GraphPage.qml +++ b/apps/desktop/qml/pages/GraphPage.qml @@ -1,8 +1,10 @@ +pragma ComponentBehavior: Bound import QtQuick import QtQuick.Controls import QtQuick.Layouts import QtQuick.Dialogs import QtQuick.Shapes +import QtQuick.Window import ".." import "../components" import "../components/graph" @@ -24,6 +26,9 @@ Item { property var nodeTypes: [] property var specs: ({}) property var runtimeInfo: ({}) + property var apiCapabilities: ({}) + property string capabilityModelId: "" + property int capabilityRequest: 0 property var modelOptions: [] property var workflows: [] @@ -32,13 +37,31 @@ Item { property var graph: GM.newGraph() property var nodeById: ({}) property var nodeIds: [] + property var nodeCards: [] property var edgeList: [] property int graphRev: 0 // any content change + property int documentRev: 0 // editor changes; inspection does not dirty saves property int visRev: 0 // changes which params/sockets are shown property int edgeRev: 0 // wires changed property int paramsRev: 0 // params changed from outside a widget property int layoutRev: 0 // a node moved property string selectedId: "" + property var selectedIds: [] + property var history: GM.newHistory() + property int historyRev: 0 + readonly property bool canUndo: { historyRev; return history.past.length > 0 && !inspectingRun } + readonly property bool canRedo: { historyRev; return history.future.length > 0 && !inspectingRun } + property var moveGesture: null + property var wireBefore: null + property var nodeSizes: ({}) + property var marquee: null + property bool historyOpen: false + property bool minimapOpen: true + property string comparisonSource: "" + property var editorView: null + property var saveQueue: [] + property bool saveInFlight: false + property int openRequest: 0 property var wireDrag: null property string saveState: "" @@ -56,26 +79,59 @@ Item { property var runStates: ({}) property string runId: "" property bool running: false + property var selectedRun: null + property bool inspectingRun: false + property var activeRuns: ({}) + property var submittedRuns: ({}) + property var seedAdvanced: ({}) + property var runRequestSerial: ({}) + property int runListSerial: 0 + property int runEventRevision: 0 + property bool forceRun: false + readonly property int activeRunCount: Object.keys(activeRuns).length + readonly property var selectedRuntimeDefaults: { + graphRev + if (inspectingRun || selectedIds.length !== 1 || apiCapabilities.known !== true) return null + var node = nodeById[selectedId] + if (!node || (node.type !== "sample" && node.type !== "sample.video")) return null + var modes = apiCapabilities.defaults_by_mode || {} + var defaults = modes[node.type === "sample.video" ? "vid_gen" : "img_gen"] + return defaults && Object.keys(defaults).length > 0 ? defaults : null + } + readonly property bool runMatchesEditor: { + graphRev + return !!selectedRun && !!selectedRun.graph && selectedRun.workflow_id === workflowId + && GM.executionKey(selectedRun.source_graph || selectedRun.graph) === GM.executionKey(graph) + } property string runError: "" property string hintText: "" - readonly property var samplerNames: ["", "euler", "euler_a", "heun", "dpm2", "dpm++2s_a", "dpm++2m", "dpm++2mv2", "ipndm", "ipndm_v", "lcm", "ddim_trailing", "tcd"] - readonly property var schedulerNames: ["", "discrete", "karras", "exponential", "ays", "gits", "smoothstep", "sgm_uniform", "simple", "lcm"] property bool paletteOpen: true // ------------------------------------------------------------------ // Loading // ------------------------------------------------------------------ + function graphModelId() { + for (var i = 0; i < graph.nodes.length; i++) { + var n = graph.nodes[i] + if (n.type === "checkpoint.load" && n.params && n.params.model && n.params.model.library_id) + return n.params.model.library_id + } + return preferredModelId || "" + } function loadNodeTypes() { - api.get("/api/v1/workflow/node-types", function(st, data) { - if (st !== 200 || !data) return + var model = graphModelId(), request = ++capabilityRequest + capabilityModelId = model + api.get("/api/v1/workflow/node-types" + (model ? "?model_id=" + encodeURIComponent(model) : ""), function(st, data) { + if (st !== 200 || !data || request !== page.capabilityRequest || model !== page.graphModelId()) return var map = {} var list = data.node_types || [] for (var i = 0; i < list.length; i++) map[list[i].type] = list[i] page.specs = map page.nodeTypes = list page.runtimeInfo = data.runtime || {} - if (page.workflowId) page.rebuild() + page.apiCapabilities = data.api_capabilities || {} + if (page.workflowId) { page.rebuild(); page.validateSoon.restart() } }) } function loadLibrary() { @@ -97,11 +153,15 @@ Item { }) } function openWorkflow(id) { - api.get("/api/v1/workflows/" + id, function(st, data) { - if (st !== 200 || !data) return + autosave.stop() + validateSoon.stop() + var request = ++openRequest + var fetch = function() { page.api.get("/api/v1/workflows/" + id, function(st, data) { + if (st !== 200 || !data || request !== page.openRequest) return var g = data.graph || GM.newGraph() g.nodes = g.nodes || [] g.edges = g.edges || [] + g.groups = g.groups || [] for (var i = 0; i < g.nodes.length; i++) { g.nodes[i].params = g.nodes[i].params || {} g.nodes[i].pos = g.nodes[i].pos || [0, 0] @@ -109,42 +169,98 @@ Item { g.name = data.name page.workflowId = data.id page.graph = g - page.selectedId = "" + page.documentRev++ + page.inspectingRun = false + page.editorView = null + page.selectedRun = null + page.runId = "" + page.running = false + page.setSelection([]) + page.history = GM.newHistory(); page.historyRev++ + page.nodeSizes = ({}) + page.saveState = "saved" page.runStates = ({}) page.runError = "" page.showIssues = false + page.issues = []; page.issueMap = ({}); page.planInfo = null page.rebuild() + page.loadNodeTypes() if (g.view && g.view.zoom) { page.panX = g.view.x; page.panY = g.view.y; page.zoom = g.view.zoom } else { Qt.callLater(page.fitView) } page.validateSoon.restart() - }) + }) } + if (workflowId && (saveState === "dirty" || saveState === "error" || saveState === "saving")) { + save(function(ok) { + if (request !== page.openRequest) return + if (ok) fetch() + else { page.hintText = "Save failed; current workflow stays open"; hintTimer.restart() } + }) + } else fetch() } // ------------------------------------------------------------------ // Graph state // ------------------------------------------------------------------ + function canvasGraph() { return inspectingRun && selectedRun && selectedRun.graph ? selectedRun.graph : graph } function rebuild() { var map = {} var ids = [] - for (var i = 0; i < graph.nodes.length; i++) { - var n = graph.nodes[i] + var cards = [] + var shown = canvasGraph() + for (var i = 0; i < shown.nodes.length; i++) { + var n = shown.nodes[i] map[n.id] = n - if (specs[n.type]) ids.push(n.id) + if (specs[n.type]) { ids.push(n.id); cards.push({ node: n, spec: specs[n.type] }) } } nodeById = map nodeIds = ids - edgeList = graph.edges.slice() + nodeCards = cards + edgeList = shown.edges.slice() edgeRev++; visRev++; layoutRev++; graphRev++ } function touch(structural) { graphRev++ + documentRev++ if (structural) rebuild() saveState = "dirty" autosave.restart() validateSoon.restart() + if (graphModelId() !== capabilityModelId) loadNodeTypes() + } + function snapshot() { return { graph: GM.clone(graph), selectedIds: selectedIds.slice() } } + function commitEdit(before, label, key) { + if (GM.recordEdit(history, before, snapshot(), label, key || "", Date.now())) historyRev++ + } + function applySnapshot(state) { + if (!state) return + viewport.forceActiveFocus() + graph = GM.clone(state.graph) + setSelection(state.selectedIds || []) + paramsRev++ + touch(true) + } + function undoEdit() { if (canUndo) { applySnapshot(GM.undo(history)); historyRev++ } } + function redoEdit() { if (canRedo) { applySnapshot(GM.redo(history)); historyRev++ } } + function setSelection(ids) { + selectedIds = ids.slice() + selectedId = ids.length ? ids[ids.length - 1] : "" + } + function isSelected(id) { return selectedIds.indexOf(id) >= 0 } + function measureNode(id, height) { + if (!nodeSizes[id] || Math.abs(nodeSizes[id].h - height) > 0.5) { + nodeSizes[id] = { h: height } + layoutRev++ + } + } + function isCollapsed(id) { visRev; return !!nodeById[id] && !!nodeById[id].collapsed } + function toggleCollapse(ids) { + if (inspectingRun || !ids.length) return + var before = snapshot(), collapse = !ids.every(function(id) { return nodeById[id] && nodeById[id].collapsed }) + ids.forEach(function(id) { if (nodeById[id]) nodeById[id].collapsed = collapse }) + visRev++; layoutRev++; touch(false); commitEdit(before, collapse ? "Collapse selection" : "Expand selection") } function nodeSpec(id) { var n = nodeById[id] @@ -156,14 +272,18 @@ Item { var v = GM.effectiveParam(specs[node.type], node, ps.name) return v === undefined ? "" : v } - function setParam(nodeId, name, value) { + function setParam(nodeId, name, value, coalesce) { + if (inspectingRun) return var node = nodeById[nodeId] if (!node) return + if (JSON.stringify(GM.effectiveParam(specs[node.type], node, name)) === JSON.stringify(value)) return + var before = snapshot() node.params = node.params || {} node.params[name] = value var spec = specs[node.type] if (GM.affectsLayout(spec, name)) visRev++ touch(false) + commitEdit(before, "Edit " + name, coalesce ? nodeId + ":" + name : "") } function setParamExternal(nodeId, name, value) { setParam(nodeId, name, value) @@ -171,41 +291,73 @@ Item { } function paramVisible(node, ps) { visRev - return GM.paramVisible(graph, specs, node, ps) + return GM.paramVisible(canvasGraph(), specs, node, ps) } function paramOptions(ps) { - var list = ps.from === "capabilities.samplers" ? samplerNames - : ps.from === "capabilities.schedulers" ? schedulerNames - : (ps.options || []) + var advertised = ps.from && ps.from.indexOf("capabilities.") === 0 + var field = advertised ? ps.from.substring("capabilities.".length) : "" + var known = apiCapabilities.known === true + var choices = known && Array.isArray(apiCapabilities[field]) ? apiCapabilities[field] : [] + if (field === "upscalers") choices = choices.filter(function(v) { return v && v.model === true && v.image_upscale === true }).map(function(v) { return v.name }) + var list = advertised ? [""].concat(choices.filter(function(v) { return typeof v === "string" && v !== "" })) : (ps.options || []) return list.map(function(o) { return { text: o === "" ? "default" : o, value: o } }) } function isConnected(nodeId, port, isOutput) { edgeRev - for (var i = 0; i < graph.edges.length; i++) { - var e = graph.edges[i] + var shown = canvasGraph() + for (var i = 0; i < shown.edges.length; i++) { + var e = shown.edges[i] if (isOutput ? (e.from[0] === nodeId && e.from[1] === port) : (e.to[0] === nodeId && e.to[1] === port)) return true } return false } function issuesFor(nodeId) { + if (inspectingRun) return [] if (!showIssues && selectedId !== nodeId) return [] return issueMap[nodeId] || [] } - function runStateFor(nodeId) { return runStates[nodeId] || null } - function select(id) { - selectedId = id + function runStateFor(nodeId) { return inspectingRun || runMatchesEditor ? runStates[nodeId] || null : null } + function select(id, modifiers, preserve) { + var next = selectedIds.slice() + if (modifiers & (Qt.ControlModifier | Qt.ShiftModifier)) { + var at = next.indexOf(id) + if (at >= 0) next.splice(at, 1); else next.push(id) + setSelection(next) + } else if (!preserve || !isSelected(id)) setSelection(id ? [id] : []) viewport.forceActiveFocus() } - function nodeMoved(id, x, y) { - var n = nodeById[id] - if (!n) return - n.pos = [Math.round(x), Math.round(y)] + function beginMove(id, modifiers, pt) { + select(id, modifiers, true) + if (inspectingRun || !isSelected(id)) return + startMove(pt) + } + function startMove(pt) { + var starts = {} + selectedIds.forEach(function(id) { if (nodeById[id]) starts[id] = nodeById[id].pos.slice() }) + autosave.stop() + moveGesture = { point: pt, starts: starts, before: snapshot(), moved: false } + } + function moveNodes(pt) { + if (!moveGesture) return + var dx = pt.x - moveGesture.point.x, dy = pt.y - moveGesture.point.y + if (!moveGesture.moved && Math.abs(dx) + Math.abs(dy) < 4 / zoom) return + moveGesture.moved = true + for (var id in moveGesture.starts) { + var p = moveGesture.starts[id] + nodeById[id].pos = [Math.round(p[0] + dx), Math.round(p[1] + dy)] + } layoutRev++ - saveState = "dirty" - autosave.restart() + } + function endMove(cancel) { + var gesture = moveGesture + moveGesture = null + if (!gesture) return + if (cancel && gesture.moved) { applySnapshot(gesture.before); return } + if (gesture.moved) { touch(false); commitEdit(gesture.before, "Move selection") } + else if (saveState === "dirty") autosave.restart() } function openMenu(id) { - selectedId = id + if (!isSelected(id)) setSelection([id]) nodeMenu.popup() } @@ -223,8 +375,8 @@ Item { } } function edgeDim(edge) { - if (selectedId === "") return false - return edge.from[0] !== selectedId && edge.to[0] !== selectedId + if (!selectedIds.length) return false + return !isSelected(edge.from[0]) && !isSelected(edge.to[0]) } function dragPoints() { layoutRev @@ -243,6 +395,8 @@ Item { } } function beginWire(nodeId, port, isOutput, pt) { + if (inspectingRun) return + wireBefore = snapshot() if (!isOutput) { var e = GM.edgeInto(graph, nodeId, port) if (e) { // pick the wire up by its input end @@ -294,20 +448,26 @@ Item { if (why === "") { GM.connect(graph, from, to) touch(true) + commitEdit(wireBefore, "Connect nodes") + wireBefore = null return } hintText = why hintTimer.restart() } - if (d.rerouted) { touch(true); return } // a dropped, picked-up wire is deleted + if (d.rerouted) { touch(true); commitEdit(wireBefore, "Disconnect nodes"); wireBefore = null; return } + wireBefore = null if (!target) openQuickAddForWire(d, pt) } // Adding and removing nodes ------------------------------------------- function addNodeAt(spec, x, y, params) { + if (inspectingRun || !workflowId) return null + var before = snapshot() var n = GM.addNode(graph, spec, x, y, params) touch(true) - selectedId = n.id + setSelection([n.id]) + commitEdit(before, "Add node") return n } function viewCenter() { @@ -319,21 +479,98 @@ Item { addNodeAt(spec, c.x - GM.nodeWidth(spec.type) / 2 + jitter, c.y - 80 + jitter, null) } function deleteSelected() { - if (!selectedId) return - GM.removeNode(graph, selectedId) - selectedId = "" + if (!selectedIds.length || inspectingRun) return + var before = snapshot() + selectedIds.forEach(function(id) { GM.removeNode(graph, id) }) + setSelection([]) touch(true) + commitEdit(before, "Delete selection") } function duplicateSelected() { - var n = nodeById[selectedId] - if (!n) return - var c = GM.duplicateNode(graph, n, specs[n.type]) + if (!selectedIds.length || inspectingRun) return + var before = snapshot(), section = GM.copySection(graph, selectedIds) + var b = GM.sectionBounds(graph, specs, selectedIds, nodeSizes) + setSelection(GM.pasteSection(graph, section, b.x + 28, b.y + 28)) touch(true) - selectedId = c.id + commitEdit(before, "Duplicate section") + } + function selectConnected() { setSelection(GM.connectedSection(canvasGraph(), selectedIds)) } + function copySelected() { + if (!selectedIds.length) return + clipboard.text = JSON.stringify(GM.copySection(canvasGraph(), selectedIds)) + clipboard.selectAll(); clipboard.copy(); clipboard.deselect() + } + function pasteSelected() { + if (inspectingRun || !workflowId) return + clipboard.clear(); clipboard.paste() + try { + var section = GM.parseSection(clipboard.text, specs), before = snapshot(), c = viewCenter() + setSelection(GM.pasteSection(graph, section, c.x - 100, c.y - 60)) + touch(true); commitEdit(before, "Paste section") + } catch (error) { hintText = String(error.message || error); hintTimer.restart() } + } + function alignSelected(mode) { + if (selectedIds.length < 2 || inspectingRun) return + var before = snapshot() + GM.alignNodes(graph, specs, selectedIds, mode, nodeSizes) + layoutRev++; touch(false); commitEdit(before, "Align " + mode) + } + function applyRuntimeDefaults() { + var defaults = selectedRuntimeDefaults, node = nodeById[selectedId] + if (!defaults || !node) return + var before = snapshot() + var sampleFields = ["steps", "sampler", "scheduler", "cfg", "guidance", "strength", "frames", "fps"] + var parameters = GM.list(specs[node.type].params).map(function(p) { return p.name }) + node.params = node.params || {} + sampleFields.forEach(function(key) { + if (parameters.indexOf(key) >= 0 && Object.prototype.hasOwnProperty.call(defaults, key)) node.params[key] = defaults[key] + }) + var start = GM.edgeInto(graph, node.id, "start") + var size = start ? GM.findNode(graph, start.from[0]) : null + if (size && size.type === "latent.empty") { + size.params = size.params || {} + var sizeParams = GM.list(specs[size.type].params).map(function(p) { return p.name }) + var sizeFields = ["width", "height", "batch"] + sizeFields.forEach(function(key) { + if (sizeParams.indexOf(key) >= 0 && Object.prototype.hasOwnProperty.call(defaults, key)) size.params[key] = defaults[key] + }) + } + if (JSON.stringify(before.graph) !== JSON.stringify(graph)) { + paramsRev++; visRev++; touch(false); commitEdit(before, "Apply runtime defaults") + } + } + function frameBounds(group) { + layoutRev + var b = GM.sectionBounds(canvasGraph(), specs, GM.list(group.nodes), nodeSizes) + if (!b) return null + var titleHeight = 32 + (group.note ? 40 : 0) + return { x: b.x - 20, y: b.y - titleHeight - 12, w: b.w + 40, h: b.h + titleHeight + 32, titleHeight: titleHeight } + } + function editFrame(index) { + if (inspectingRun || (!selectedIds.length && index < 0)) return + frameDialog.groupIndex = index + frameText.text = index < 0 ? "Section" : graph.groups[index].title + frameNote.text = index < 0 ? "" : graph.groups[index].note || "" + frameDialog.open() + } + function saveFrame(index, title, note) { + if (inspectingRun || !title.trim()) return + var before = snapshot() + graph.groups = GM.list(graph.groups) + if (index < 0) graph.groups.push({ title: title.trim(), note: note, color: "#6aa8e0", nodes: selectedIds.slice() }) + else { graph.groups[index].title = title.trim(); graph.groups[index].note = note } + touch(true); commitEdit(before, "Edit frame and note") + } + function removeFrame(index) { + if (inspectingRun) return + var before = snapshot() + graph.groups.splice(index, 1) + touch(true); commitEdit(before, "Remove frame") } property var quickCtx: null function openQuickAdd(wx, wy) { + if (inspectingRun) return quickCtx = { x: wx, y: wy, wire: null } quickAdd.heading = "Add a node" quickAdd.accepts = null @@ -357,6 +594,8 @@ Item { quickAdd.openAtItem(world, pt.x, pt.y) } function quickPicked(spec) { + if (inspectingRun) return + var before = snapshot() var c = quickCtx || viewCenter() var d = c.wire var x = c.x, y = c.y @@ -375,7 +614,8 @@ Item { else GM.connect(graph, { node: node.id, port: side[idx].name }, { node: d.fromNode, port: d.fromPort }) } touch(true) - selectedId = node.id + setSelection([node.id]) + commitEdit(before, "Add connected node") return } addNodeAt(spec, x, y, null) @@ -388,15 +628,20 @@ Item { panX = px - (px - panX) * k panY = py - (py - panY) * k zoom = nz - saveState = saveState === "" ? "" : "dirty" + saveViewport() } function fitView() { - var b = GM.bounds(graph, specs) + var b = GM.sectionBounds(canvasGraph(), specs, nodeIds, nodeSizes) if (!b || viewport.width < 50) return var z = Math.min((viewport.width - 80) / b.w, (viewport.height - 80) / b.h, 1) zoom = Math.max(0.3, z) panX = (viewport.width - b.w * zoom) / 2 - b.x * zoom panY = Math.max(24, (viewport.height - b.h * zoom) / 2 - b.y * zoom) + saveViewport() + } + function saveViewport() { + if (inspectingRun || !workflowId) return + documentRev++; saveState = "dirty"; autosave.restart() } // ------------------------------------------------------------------ @@ -407,21 +652,36 @@ Item { interval: 1200 onTriggered: page.save() } - function save() { + function save(then) { if (!workflowId) return saveState = "saving" - var doc = GM.forApi(graph, { x: panX, y: panY, zoom: zoom }) - api.put("/api/v1/workflows/" + workflowId, { name: graph.name || "Untitled workflow", graph: doc }, function(st) { - saveState = st === 200 ? "saved" : "error" + var view = inspectingRun && editorView ? editorView : { x: panX, y: panY, zoom: zoom } + var job = { id: workflowId, rev: documentRev, doc: GM.forApi(graph, view), then: typeof then === "function" ? then : null } + // Serialize writes so a slower previous save cannot overwrite a later edit. + var last = saveQueue[saveQueue.length - 1] + if (last && last.id === job.id && !last.then && !job.then) saveQueue[saveQueue.length - 1] = job + else saveQueue.push(job) + drainSaves() + } + function drainSaves() { + if (saveInFlight || !saveQueue.length) return + var job = saveQueue.shift() + saveInFlight = true + api.put("/api/v1/workflows/" + job.id, { name: job.doc.name || "Untitled workflow", graph: job.doc }, function(st, data) { + page.saveInFlight = false + if (page.workflowId === job.id) page.saveState = st !== 200 ? "error" : page.documentRev === job.rev ? "saved" : "dirty" + if (job.then) job.then(st === 200, data) + page.drainSaves() }) } function rename(name) { + if (inspectingRun) return name = (name || "").trim() if (!name || name === graph.name) return + var before = snapshot() graph.name = name - api.put("/api/v1/workflows/" + workflowId, { name: name }, function(st) { - if (st === 200) page.loadWorkflows("") - }) + touch(false); commitEdit(before, "Rename workflow") + save(function(ok) { if (ok) page.loadWorkflows("") }) } property alias validateSoon: validateTimer Timer { @@ -431,8 +691,9 @@ Item { } function validate() { if (!workflowId) return + var id = workflowId, revision = documentRev api.post("/api/v1/workflows/validate", { graph: GM.forApi(graph) }, function(st, data) { - if (st !== 200 || !data) return + if (st !== 200 || !data || id !== page.workflowId || revision !== page.documentRev) return page.issues = (data.errors || []).concat(data.warnings || []) page.issueMap = GM.issuesByNode(data.errors || []) page.planInfo = data.plan || null @@ -463,8 +724,8 @@ Item { function canRunTo(id) { var n = nodeById[id] if (!n) return false - return n.type === "sample" || n.type === "sample.video" || n.type === "image.resize" - || n.type === "image.save" || n.type === "video.save" + return ["sample", "sample.video", "image.resize", "image.save", "video.save", "image.crop", + "image.pad", "image.rotate", "image.blend", "image.pick", "image.upscale"].indexOf(n.type) >= 0 } function pickModel() { if (preferredModelId) for (var i = 0; i < modelOptions.length; i++) if (modelOptions[i].id === preferredModelId) return modelOptions[i] @@ -493,7 +754,10 @@ Item { if (st !== 200) return page.workflowId = "" page.graph = GM.newGraph() + page.inspectingRun = false; page.selectedRun = null; page.runId = ""; page.running = false + page.history = GM.newHistory(); page.historyRev++; page.setSelection([]) page.nodeIds = []; page.edgeList = []; page.nodeById = ({}) + page.nodeCards = [] page.runStates = ({}) page.loadWorkflows("first") }) @@ -503,34 +767,47 @@ Item { // Running // ------------------------------------------------------------------ function run(only) { - if (!workflowId || running) return + if (!workflowId || inspectingRun) return runError = "" autosave.stop(); save() - api.post("/api/v1/workflow/runs", { graph: GM.forApi(graph), only: only || "" }, function(st, data) { + var doc = GM.forApi(graph), id = workflowId, key = GM.executionKey(doc) + api.post("/api/v1/workflow/runs", { graph: doc, only: only || "", workflow_id: id, force: forceRun }, function(st, data) { if (st === 202 && data && data.run) { - page.runId = data.run.id - var states = {} - for (var id in data.run.nodes) states[id] = { state: data.run.nodes[id].state } - page.runStates = states - page.running = true - page.showIssues = false + // Use the returned snapshot; never invent run nodes or outputs. + page.submittedRuns[data.run.id] = true + page.trackRun(data.run) + if (id === page.workflowId && !page.inspectingRun) { + page.applyRunView(data.run, true) + page.showIssues = false + } } else if (st === 400 && data) { + if (id !== page.workflowId || key !== GM.executionKey(page.graph)) return page.issueMap = GM.issuesByNode(data.errors || []) page.issues = (data.errors || []).concat(data.warnings || []) page.showIssues = true var first = (data.errors || [])[0] page.runError = first ? first.message : (data.error || "Nothing to run") } else { + if (id !== page.workflowId) return page.runError = (data && (data.detail || data.error)) || "Could not start the run" } }) } function cancelRun() { if (!runId) return - api.post("/api/v1/workflow/runs/" + runId + "/cancel", {}, function() {}) + api.post("/api/v1/workflow/runs/" + runId + "/cancel", {}, function(st, data) { + if (st !== 200) page.runError = (data && (data.detail || data.error)) || "Could not cancel the run" + }) } function onRunEvent(name, p) { - if (!p || p.run_id !== runId) return + if (!p || !p.run_id) return + runEventRevision++ + runRequestSerial[p.run_id] = (runRequestSerial[p.run_id] || 0) + 1 + if (name === "workflow.run_queued" || name === "workflow.run_started" || name === "workflow.run_finished") { + refreshRun(p.run_id) + return + } + if (p.run_id !== runId) return if (name === "workflow.node_state") { var next = {} for (var k in runStates) next[k] = runStates[k] @@ -538,81 +815,157 @@ Item { next[p.node_id] = { state: p.state, message: p.message || "", file_urls: p.file_urls || [], outputs: p.outputs || [], ms: p.ms || 0, progress: p.progress || null, job_id: p.job_id || "", + seed: p.seed === undefined ? prev.seed : p.seed, + cached: p.cached === undefined ? prev.cached : p.cached, startedMs: prev.startedMs || (p.state === "running" ? Date.now() : 0) } runStates = next - } else if (name === "workflow.run_finished") { - running = false - if (p.state === "complete") advanceSeeds() - if (p.state === "failed") runError = p.error || "The run failed" - if (p.state === "canceled") runError = "" } } // "increment" and "random" seed modes act after a run, like ComfyUI's // control-after-generate: the next run starts from the new seed. - function advanceSeeds() { + function advanceSeeds(runView) { + if (inspectingRun || !runMatchesEditor || !submittedRuns[runView.id] || seedAdvanced[runView.id]) return + seedAdvanced[runView.id] = true + var before = snapshot() var changed = false for (var i = 0; i < graph.nodes.length; i++) { var n = graph.nodes[i] if (n.type !== "sample" && n.type !== "sample.video") continue - var rs = runStates[n.id] + var rs = runView.nodes[n.id] if (!rs || rs.state !== "done") continue var mode = GM.effectiveParam(specs[n.type], n, "seed_mode") var seed = Number(GM.effectiveParam(specs[n.type], n, "seed")) if (mode === "increment" && seed >= 0) { n.params.seed = seed + 1; changed = true } else if (mode === "random" && seed !== -1) { n.params.seed = -1; changed = true } } - if (changed) { paramsRev++; touch(false) } + if (changed) { paramsRev++; touch(false); commitEdit(before, "Advance run seeds") } } Connections { target: page.events function onEventReceived(name, payload) { if (name.indexOf("workflow.") === 0) page.onRunEvent(name, payload) + else if (name === "media.server_state") page.loadNodeTypes() else if (name === "library.scanned" || name === "library.model_imported" || name === "library.model_updated") page.loadLibrary() } function onReconnected() { - if (page.runId && page.running) { - api.get("/api/v1/workflow/runs/" + page.runId, function(st, data) { - if (st !== 200 || !data) return - page.applyRunView(data) - }) - } + page.refreshRuns() + if (page.runId) page.refreshRun(page.runId) } } - function applyRunView(data) { + function trackRun(data) { + var next = Object.assign({}, activeRuns) + if (data.state === "running" || data.state === "queued") next[data.id] = data + else delete next[data.id] + activeRuns = next + } + function applyRunView(data, selectRun) { + if (!data || !data.id) return + trackRun(data) + if (!selectRun && data.id !== runId) return + page.runId = data.id + page.selectedRun = data var states = {} - for (var id in data.nodes) { + for (var id in (data.nodes || {})) { var n = data.nodes[id] var prev = page.runStates[id] || {} states[id] = { state: n.state, message: n.message || "", file_urls: n.file_urls || [], outputs: n.outputs || [], ms: n.ms || 0, progress: n.progress || null, job_id: n.job_id || "", + seed: n.seed, cached: n.cached === true, startedMs: prev.startedMs || (n.state === "running" ? Date.now() : 0) } } page.runStates = states page.running = data.state === "running" || data.state === "queued" - if (data.state === "failed") page.runError = data.error || "The run failed" + page.runError = data.state === "failed" ? data.error || "The run failed" : "" + if (data.state === "complete") advanceSeeds(data) + } + function refreshRun(id) { + var serial = (runRequestSerial[id] || 0) + 1 + runRequestSerial[id] = serial + api.get("/api/v1/workflow/runs/" + id, function(st, data) { + if (st === 200 && data && page.runRequestSerial[id] === serial) page.applyRunView(data, false) + }) + } + function refreshRuns() { + var serial = ++runListSerial, revision = runEventRevision + api.get("/api/v1/workflow/runs", function(st, data) { + if (st !== 200 || !data || serial !== page.runListSerial || revision !== page.runEventRevision) return + var next = {} + GM.list(data.runs).forEach(function(r) { + if (r.state === "queued" || r.state === "running") next[r.id] = r + if (r.id === page.runId) page.applyRunView(r, false) + }) + page.activeRuns = next + }) + } + function inspectRun(run) { + if (!run || !run.graph) { hintText = "This run has no stored graph snapshot"; hintTimer.restart(); return } + if (!inspectingRun) editorView = { x: panX, y: panY, zoom: zoom } + inspectingRun = true + applyRunView(run, true) + setSelection([]); nodeSizes = ({}); rebuild(); Qt.callLater(fitView) + } + function returnToEditor() { + if (!inspectingRun) return + inspectingRun = false + if (editorView) { panX = editorView.x; panY = editorView.y; zoom = editorView.zoom } + editorView = null + setSelection([]); nodeSizes = ({}); rebuild(); validateSoon.restart() + } + function restoreRun(run) { + if (!workflowId || !run || !run.graph) return + returnToEditor() + var before = snapshot(), name = graph.name + graph = GM.clone(run.graph); graph.name = name + setSelection([]); nodeSizes = ({}); paramsRev++ + touch(true); commitEdit(before, "Restore run snapshot"); Qt.callLater(fitView) + } + function useImage(fileUrl) { + returnToEditor() + if (!workflowId || !specs["image.load"]) return + var n = nodeById[selectedId] + var params = GM.list(specs["image.load"].params) + var pathParam = params.filter(function(p) { return p.kind === "path" })[0] + if (!pathParam) return + if (n && n.type === "image.load") setParamExternal(n.id, pathParam.name, GM.fileUrlToLocalPath(fileUrl)) + else { + var c = viewCenter(), p = {} + p[pathParam.name] = GM.fileUrlToLocalPath(fileUrl) + addNodeAt(specs["image.load"], c.x - 120, c.y - 80, p) + } + } + function viewImages(sources, index) { + imageViewer.sources = sources + imageViewer.currentIndex = index || 0 + imageViewer.open() + } + function compareImage(fileUrl) { + comparisonSource = fileUrl + hintText = "Comparison selected; open another image to compare it." + hintTimer.restart() } // Reconcile authoritative state even when a terminal event was missed. Timer { interval: 3000 - running: page.running && page.runId !== "" + running: page.activeRunCount > 0 repeat: true onTriggered: { - var id = page.runId - page.api.get("/api/v1/workflow/runs/" + id, function(st, data) { - if (st === 200 && data && page.runId === id) page.applyRunView(data) - }) + page.refreshRuns() + if (page.runId) page.refreshRun(page.runId) } } - Component.onCompleted: { loadNodeTypes(); loadLibrary(); loadWorkflows("first") } + Component.onCompleted: { loadNodeTypes(); loadLibrary(); loadWorkflows("first"); refreshRuns() } onVisibleChanged: if (visible && specs) { loadNodeTypes(); loadLibrary() } Timer { id: hintTimer; interval: 3500; onTriggered: page.hintText = "" } + Shortcut { sequences: [StandardKey.Undo]; enabled: page.visible && page.canUndo; onActivated: page.undoEdit() } + Shortcut { sequences: [StandardKey.Redo]; enabled: page.visible && page.canRedo; onActivated: page.redoEdit() } + // ------------------------------------------------------------------ // UI // ------------------------------------------------------------------ @@ -660,6 +1013,7 @@ Item { Repeater { model: GM.templateList() delegate: MenuItem { + required property var modelData text: modelData.title + " — " + modelData.hint onTriggered: page.createFromTemplate(modelData.id) } @@ -673,6 +1027,7 @@ Item { implicitHeight: 32 placeholderText: "Workflow name" text: page.graph.name || "" + enabled: !page.inspectingRun onEditingFinished: page.rename(text) } AppButton { @@ -691,14 +1046,14 @@ Item { Item { Layout.fillWidth: true } Label { - visible: page.workflowId !== "" && page.errorCount() > 0 + visible: page.workflowId !== "" && !page.inspectingRun && page.errorCount() > 0 text: page.errorCount() + (page.errorCount() === 1 ? " problem" : " problems") color: AppTheme.warning font.pixelSize: AppTheme.fontSmall font.weight: Font.DemiBold } Label { - visible: page.workflowId !== "" && page.errorCount() === 0 && page.planSummary() !== "" + visible: page.workflowId !== "" && !page.inspectingRun && page.errorCount() === 0 && page.planSummary() !== "" text: page.planSummary() color: AppTheme.textDim font.pixelSize: AppTheme.fontSmall @@ -713,18 +1068,59 @@ Item { } AppButton { text: "Run to here" - enabled: page.workflowId !== "" && page.canRunTo(page.selectedId) && !page.running + enabled: page.workflowId !== "" && page.canRunTo(page.selectedId) && !page.inspectingRun onClicked: page.run(page.selectedId) } AppButton { - text: page.running ? "Running…" : "▶ Run" + text: page.activeRunCount > 0 ? "Queue run (" + page.activeRunCount + ")" : "▶ Run" primary: true - enabled: page.workflowId !== "" && !page.running + enabled: page.workflowId !== "" && !page.inspectingRun onClicked: page.run("") } } } + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 40 + color: AppTheme.bgAlt + RowLayout { + anchors.fill: parent + anchors.leftMargin: 12; anchors.rightMargin: 12 + spacing: 6 + AppButton { text: "Undo"; flat: true; enabled: page.canUndo; onClicked: page.undoEdit() } + AppButton { text: "Redo"; flat: true; enabled: page.canRedo; onClicked: page.redoEdit() } + AppButton { + text: "Selection ▾"; flat: true; enabled: page.selectedIds.length > 0 + onClicked: selectionMenu.popup() + } + AppButton { text: "Import JSON"; flat: true; onClicked: importDialog.open() } + AppButton { + text: "Export JSON"; flat: true; enabled: page.workflowId !== "" && !page.inspectingRun + onClicked: { exportDialog.workflowId = page.workflowId; exportDialog.open() } + } + CheckBox { text: "Force rerun"; checked: page.forceRun; enabled: !page.inspectingRun; onToggled: page.forceRun = checked } + AppButton { + text: "Runtime defaults"; flat: true + enabled: page.selectedRuntimeDefaults !== null + onClicked: page.applyRuntimeDefaults() + ToolTip.visible: hovered + ToolTip.text: "Apply advertised defaults to the selected sampler and its connected size; one undo step" + } + Label { + Layout.fillWidth: true + elide: Text.ElideRight + text: page.inspectingRun ? "Run " + page.runId.substring(0, 8) + " · " + (page.selectedRun.state || "") + " · snapshot" + : page.selectedIds.length > 1 ? page.selectedIds.length + " selected" + : "Drag to select · Ctrl/Shift adds · Middle drag pans" + color: AppTheme.textFaint; font.pixelSize: AppTheme.fontSmall + } + AppButton { text: "Back to editor"; visible: page.inspectingRun; onClicked: page.returnToEditor() } + AppButton { text: "Map"; flat: true; onClicked: page.minimapOpen = !page.minimapOpen } + AppButton { text: "History"; flat: !page.historyOpen; onClicked: page.historyOpen = !page.historyOpen } + } + } + RowLayout { Layout.fillWidth: true Layout.fillHeight: true @@ -754,6 +1150,7 @@ Item { model: GM.CATEGORY_ORDER delegate: Column { id: catBlock + required property var modelData readonly property string cat: modelData readonly property var members: page.nodeTypes.filter(function(s) { return s.category === catBlock.cat }) width: paletteCol.width @@ -761,7 +1158,7 @@ Item { spacing: 2 Item { width: 1; height: 8 } Text { - text: (GM.CATEGORY_TITLES[cat] || cat).toUpperCase() + text: (GM.CATEGORY_TITLES[catBlock.cat] || catBlock.cat).toUpperCase() color: AppTheme.textFaint font.pixelSize: AppTheme.fontSmall font.weight: Font.DemiBold @@ -771,6 +1168,8 @@ Item { Repeater { model: catBlock.members delegate: Rectangle { + id: paletteNode + required property var modelData width: catBlock.width height: 30 radius: AppTheme.radiusSmall @@ -781,12 +1180,12 @@ Item { x: 8 anchors.verticalCenter: parent.verticalCenter width: 8; height: 8; radius: 2 - color: GM.categoryColor(modelData.category) + color: GM.categoryColor(paletteNode.modelData.category) } Text { x: 26 anchors.verticalCenter: parent.verticalCenter - text: modelData.title + text: paletteNode.modelData.title color: AppTheme.text font.pixelSize: AppTheme.fontBody } @@ -795,9 +1194,9 @@ Item { ToolTip.text: modelData.available ? (modelData.description || "") : modelData.reason MouseArea { anchors.fill: parent - enabled: modelData.available && page.workflowId !== "" + enabled: paletteNode.modelData.available && page.workflowId !== "" && !page.inspectingRun cursorShape: Qt.PointingHandCursor - onClicked: page.addFromPalette(modelData) + onClicked: page.addFromPalette(paletteNode.modelData) } } } @@ -810,6 +1209,7 @@ Item { // ---- canvas ---- Item { id: viewport + objectName: "graphViewport" Layout.fillWidth: true Layout.fillHeight: true clip: true @@ -819,13 +1219,24 @@ Item { Keys.onDeletePressed: page.deleteSelected() Keys.onPressed: function(e) { if (e.key === Qt.Key_Backspace) { page.deleteSelected(); e.accepted = true } - else if (e.key === Qt.Key_Tab && page.workflowId !== "") { + else if (e.key === Qt.Key_Tab && page.workflowId !== "" && !page.inspectingRun) { var c = page.viewCenter() page.openQuickAdd(c.x - 100, c.y - 60) e.accepted = true } else if (e.key === Qt.Key_D && (e.modifiers & Qt.ControlModifier)) { page.duplicateSelected(); e.accepted = true } + else if (e.key === Qt.Key_A && (e.modifiers & Qt.ControlModifier)) { page.setSelection(page.nodeIds); e.accepted = true } + else if (e.key === Qt.Key_C && (e.modifiers & Qt.ControlModifier)) { page.copySelected(); e.accepted = true } + else if (e.key === Qt.Key_V && (e.modifiers & Qt.ControlModifier)) { page.pasteSelected(); e.accepted = true } + else if (e.key === Qt.Key_Z && (e.modifiers & Qt.ControlModifier)) { + if (e.modifiers & Qt.ShiftModifier) page.redoEdit(); else page.undoEdit() + e.accepted = true + } + else if (e.key === Qt.Key_Y && (e.modifiers & Qt.ControlModifier)) { page.redoEdit(); e.accepted = true } else if ((e.key === Qt.Key_Return || e.key === Qt.Key_Enter) && (e.modifiers & Qt.ControlModifier)) { page.run(""); e.accepted = true } - else if (e.key === Qt.Key_Escape) { page.selectedId = ""; e.accepted = true } + else if (e.key === Qt.Key_Escape) { + if (page.wireDrag && page.wireBefore) { page.graph = page.wireBefore.graph; page.wireDrag = null; page.wireBefore = null; page.rebuild() } + page.setSelection([]); e.accepted = true + } } // dot grid @@ -858,7 +1269,7 @@ Item { } } - // pan, deselect, add + // Left drag selects a rectangle; middle drag pans the viewport. MouseArea { id: bg anchors.fill: parent @@ -868,21 +1279,38 @@ Item { property real px: 0 property real py: 0 property bool moved: false + property bool panning: false + property var baseSelection: [] onPressed: function(m) { sx = m.x; sy = m.y; px = page.panX; py = page.panY; moved = false + panning = m.button === Qt.MiddleButton + baseSelection = m.modifiers & (Qt.ControlModifier | Qt.ShiftModifier) ? page.selectedIds.slice() : [] + if (!panning) page.marquee = { x: sx, y: sy, w: 0, h: 0 } viewport.forceActiveFocus() } onPositionChanged: function(m) { if (!pressed) return if (Math.abs(m.x - sx) + Math.abs(m.y - sy) > 3) moved = true - page.panX = px + m.x - sx - page.panY = py + m.y - sy - cursorShape = Qt.ClosedHandCursor + if (panning) { + page.panX = px + m.x - sx; page.panY = py + m.y - sy + cursorShape = Qt.ClosedHandCursor + } else { + page.marquee = { x: Math.min(sx, m.x), y: Math.min(sy, m.y), w: Math.abs(m.x - sx), h: Math.abs(m.y - sy) } + var r = page.marquee + var hits = GM.selectionInRect(page.canvasGraph(), page.specs, + { x: (r.x - page.panX) / page.zoom, y: (r.y - page.panY) / page.zoom, w: r.w / page.zoom, h: r.h / page.zoom }, page.nodeSizes) + page.setSelection(baseSelection.concat(hits.filter(function(id) { return baseSelection.indexOf(id) < 0 }))) + } + } + onReleased: { + cursorShape = Qt.ArrowCursor + if (panning && moved) page.saveViewport() + else if (!panning && !moved) page.setSelection(baseSelection) + page.marquee = null } - onReleased: { cursorShape = Qt.ArrowCursor; if (moved) page.saveState = page.saveState === "" ? "" : "dirty" } - onClicked: if (!moved) page.selectedId = "" + onCanceled: page.marquee = null onDoubleClicked: function(m) { - if (page.workflowId === "") return + if (page.workflowId === "" || page.inspectingRun) return var p = bg.mapToItem(world, m.x, m.y) page.openQuickAdd(p.x, p.y) } @@ -906,10 +1334,57 @@ Item { width: 1 height: 1 + Repeater { + model: { page.graphRev; return GM.list(page.canvasGraph().groups) } + delegate: Rectangle { + id: frame + required property var modelData + required property int index + readonly property int groupIndex: index + readonly property var bounds: page.frameBounds(modelData) + x: bounds ? bounds.x : 0; y: bounds ? bounds.y : 0 + width: bounds ? bounds.w : 0; height: bounds ? bounds.h : 0 + visible: bounds !== null + z: -1 + radius: 8 + color: Qt.alpha(modelData.color || "#6aa8e0", 0.06) + border.color: Qt.alpha(modelData.color || "#6aa8e0", 0.5) + border.width: 1 + Rectangle { + width: parent.width; height: frame.bounds ? frame.bounds.titleHeight : 32 + color: Qt.alpha(frame.modelData.color || "#6aa8e0", 0.14) + Text { + x: 12; y: 7; width: parent.width - 70 + text: frame.modelData.title; color: AppTheme.text; font.pixelSize: AppTheme.fontBody; elide: Text.ElideRight + } + Text { + x: 12; y: 30; width: parent.width - 24; height: 38 + visible: !!frame.modelData.note; text: frame.modelData.note || "" + color: AppTheme.textDim; font.pixelSize: AppTheme.fontSmall; wrapMode: Text.Wrap; elide: Text.ElideRight + } + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.LeftButton | Qt.RightButton + onPressed: function(m) { + if (m.button === Qt.RightButton) { frameMenu.groupIndex = frame.groupIndex; frameMenu.popup(); return } + page.setSelection(frame.modelData.nodes) + viewport.forceActiveFocus() + if (!page.inspectingRun) page.startMove(mapToItem(world, m.x, m.y)) + } + onPositionChanged: function(m) { if (pressed) page.moveNodes(mapToItem(world, m.x, m.y)) } + onReleased: page.endMove(false) + onCanceled: page.endMove(true) + onDoubleClicked: page.editFrame(frame.groupIndex) + } + } + } + } + Repeater { model: page.edgeList delegate: Shape { id: wire + required property var modelData readonly property var pts: page.edgePoints(modelData) visible: pts !== null opacity: page.edgeDim(modelData) ? 0.35 : 0.95 @@ -940,16 +1415,34 @@ Item { } Repeater { - model: page.nodeIds + model: page.nodeCards delegate: GraphNode { - node: page.nodeById[modelData] - spec: page.specs[page.nodeById[modelData].type] + required property var modelData + objectName: "graphNode_" + modelData.node.id + // Repeater roles are QVariant copies. Read live + // nodes from the map; the role is a teardown fallback. + node: page.nodeById[modelData.node.id] || modelData.node + spec: page.specs[modelData.node.type] || modelData.spec host: page - selected: page.selectedId === modelData + selected: page.isSelected(modelData.node.id) } } } + Rectangle { + visible: page.marquee !== null + x: page.marquee ? page.marquee.x : 0; y: page.marquee ? page.marquee.y : 0 + width: page.marquee ? page.marquee.w : 0; height: page.marquee ? page.marquee.h : 0 + color: Qt.alpha(AppTheme.accent, 0.12); border.color: AppTheme.accentHi; z: 200 + } + GraphMinimap { + visible: page.minimapOpen && page.nodeIds.length > 0 + anchors.right: parent.right; anchors.top: parent.top + anchors.margins: 12 + host: page + viewportWidth: viewport.width; viewportHeight: viewport.height + } + // ---- empty state ---- Column { visible: page.workflowId === "" @@ -981,6 +1474,8 @@ Item { Repeater { model: GM.templateList() delegate: AppButton { + required property var modelData + required property int index text: modelData.title primary: index === 0 onClicked: page.createFromTemplate(modelData.id) @@ -1017,7 +1512,7 @@ Item { anchors.bottom: parent.bottom anchors.leftMargin: 14 anchors.bottomMargin: 54 - text: "Scroll to zoom · drag to pan · double-click or Tab to add a node · drag from a socket to connect" + text: "Scroll to zoom · middle drag to pan · double-click or Tab to add · drag a socket to connect" color: AppTheme.textFaint font.pixelSize: AppTheme.fontSmall } @@ -1045,6 +1540,19 @@ Item { MouseArea { anchors.fill: parent; enabled: page.runError !== ""; onClicked: page.runError = "" } } } + + RunHistoryPanel { + visible: page.historyOpen + Layout.preferredWidth: 300 + Layout.fillHeight: true + api: page.api + events: page.events + workflowId: page.workflowId + onInspectRun: function(run) { page.inspectRun(run) } + onRestoreRun: function(run) { page.restoreRun(run) } + onUseImage: function(fileUrl) { page.useImage(fileUrl) } + onCompareImage: function(fileUrl) { page.compareImage(fileUrl) } + } } } @@ -1055,10 +1563,152 @@ Item { Menu { id: nodeMenu - MenuItem { text: "Run to here"; enabled: !page.running && page.canRunTo(page.selectedId); onTriggered: page.run(page.selectedId) } - MenuItem { text: "Duplicate"; onTriggered: page.duplicateSelected() } + MenuItem { text: "Run to here"; enabled: !page.inspectingRun && page.canRunTo(page.selectedId); onTriggered: page.run(page.selectedId) } + MenuItem { text: "Paint mask…"; enabled: !page.inspectingRun && !!page.nodeById[page.selectedId] && page.nodeById[page.selectedId].type === "image.load"; onTriggered: page.paintMask() } + MenuItem { text: "Select connected section"; onTriggered: page.selectConnected() } + MenuItem { text: "Copy"; onTriggered: page.copySelected() } + MenuItem { text: "Paste"; enabled: !page.inspectingRun; onTriggered: page.pasteSelected() } + MenuItem { text: "Duplicate"; enabled: !page.inspectingRun; onTriggered: page.duplicateSelected() } + MenuItem { text: "Collapse / expand"; enabled: !page.inspectingRun; onTriggered: page.toggleCollapse(page.selectedIds) } + MenuItem { text: "Frame selection / add note…"; enabled: !page.inspectingRun; onTriggered: page.editFrame(-1) } + MenuSeparator {} + MenuItem { text: "Delete"; enabled: !page.inspectingRun; onTriggered: page.deleteSelected() } + } + Menu { + id: selectionMenu + MenuItem { text: "Select connected section"; onTriggered: page.selectConnected() } + MenuItem { text: "Copy (Ctrl+C)"; onTriggered: page.copySelected() } + MenuItem { text: "Duplicate (Ctrl+D)"; enabled: !page.inspectingRun; onTriggered: page.duplicateSelected() } + MenuItem { text: "Frame selection / add note…"; enabled: !page.inspectingRun; onTriggered: page.editFrame(-1) } + MenuItem { text: "Collapse / expand"; enabled: !page.inspectingRun; onTriggered: page.toggleCollapse(page.selectedIds) } MenuSeparator {} - MenuItem { text: "Delete"; onTriggered: page.deleteSelected() } + Repeater { + model: ["left", "top", "right", "bottom"] + delegate: MenuItem { + required property var modelData + text: "Align " + modelData + enabled: page.selectedIds.length > 1 && !page.inspectingRun + onTriggered: page.alignSelected(modelData) + } + } + MenuItem { text: "Delete selection"; enabled: !page.inspectingRun; onTriggered: page.deleteSelected() } + } + Menu { + id: frameMenu + property int groupIndex: -1 + MenuItem { text: "Edit frame / note…"; enabled: !page.inspectingRun; onTriggered: page.editFrame(frameMenu.groupIndex) } + MenuItem { text: "Collapse / expand members"; enabled: !page.inspectingRun; onTriggered: page.toggleCollapse(page.graph.groups[frameMenu.groupIndex].nodes) } + MenuItem { text: "Remove frame"; enabled: !page.inspectingRun; onTriggered: page.removeFrame(frameMenu.groupIndex) } + } + Dialog { + id: frameDialog + property int groupIndex: -1 + title: groupIndex < 0 ? "Frame selection" : "Edit frame" + modal: true + anchors.centerIn: parent + width: 380 + standardButtons: Dialog.Ok | Dialog.Cancel + onAccepted: page.saveFrame(groupIndex, frameText.text, frameNote.text) + ColumnLayout { + width: parent.width + AppTextField { id: frameText; Layout.fillWidth: true; placeholderText: "Frame title" } + TextArea { + id: frameNote + Layout.fillWidth: true; Layout.preferredHeight: 110 + placeholderText: "Notes about this section" + wrapMode: TextEdit.Wrap; selectByMouse: true + color: AppTheme.text + } + } + } + + TextEdit { id: clipboard; visible: false } + ImageViewer { + id: imageViewer + objectName: "graphImageViewer" + comparisonSource: page.comparisonSource + } + Loader { + id: maskLoader + objectName: "graphMaskLoader" + active: false + source: "../components/graph/MaskEditor.qml" + property string imageNodeId: "" + property string imageSource: "" + property string targetWorkflowId: "" + onLoaded: { + var editor = item as MaskEditor + editor.api = page.api + editor.source = imageSource + editor.maskCreated.connect(function(path) { + if (maskLoader.targetWorkflowId === page.workflowId) page.addPaintedMask(maskLoader.imageNodeId, path) + }) + editor.open() + } + } + function paintMask() { + var n = nodeById[selectedId], sp = n && specs[n.type] + if (!n || n.type !== "image.load" || inspectingRun) return + var pathParam = GM.list(sp.params).filter(function(p) { return p.kind === "path" })[0] + var path = pathParam ? getParam(n, pathParam) : "" + if (!path) { hintText = "Choose an input image before painting a mask"; hintTimer.restart(); return } + maskLoader.active = false + maskLoader.imageNodeId = n.id; maskLoader.targetWorkflowId = workflowId + maskLoader.imageSource = GM.localPathToFileUrl(path) + maskLoader.active = true + } + function addPaintedMask(imageId, path) { + if (inspectingRun || !specs["mask.load"] || !nodeById[imageId]) return + var before = snapshot(), image = nodeById[imageId], params = {} + var ps = GM.list(specs["mask.load"].params).filter(function(p) { return p.kind === "path" })[0] + if (!ps) return + params[ps.name] = path + var mask = GM.addNode(graph, specs["mask.load"], image.pos[0], image.pos[1] + 260, params) + var targets = graph.edges.filter(function(e) { return e.from[0] === imageId && e.to[1] === "start" }) + targets.forEach(function(e) { + var target = GM.findNode(graph, e.to[0]) + var from = { node: mask.id, port: "mask" }, to = { node: e.to[0], port: "mask" } + if (target && target.type === "sample" && GM.connectError(graph, specs, from, to) === "") GM.connect(graph, from, to) + }) + setSelection([mask.id]); touch(true); commitEdit(before, "Add painted mask") + } + + FileDialog { + id: importDialog + title: "Import workflow JSON" + fileMode: FileDialog.OpenFile + nameFilters: ["Workflow JSON (*.json)"] + onAccepted: page.importWorkflow(GM.fileUrlToLocalPath(selectedFile.toString())) + } + FileDialog { + id: exportDialog + property string workflowId: "" + title: "Export workflow JSON" + fileMode: FileDialog.SaveFile + defaultSuffix: "json" + nameFilters: ["Workflow JSON (*.json)"] + onAccepted: { + if (workflowId !== page.workflowId || page.inspectingRun) return + page.exportWorkflow(GM.fileUrlToLocalPath(selectedFile.toString())) + } + } + function importWorkflow(path) { + api.post("/api/v1/workflows/import", { path: path }, function(st, data) { + if ((st === 200 || st === 201) && data && data.id) { + page.loadWorkflows(""); page.openWorkflow(data.id) + } else { page.hintText = (data && (data.detail || data.error)) || "Could not import workflow JSON"; hintTimer.restart() } + }) + } + function exportWorkflow(path) { + var id = workflowId + autosave.stop() + save(function(ok, data) { + if (!ok) { page.hintText = "Save failed; could not export workflow"; hintTimer.restart(); return } + page.api.post("/api/v1/workflows/" + id + "/export", { path: path }, function(st, result) { + page.hintText = st === 200 ? "Workflow exported" : (result && (result.detail || result.error)) || "Could not export workflow JSON" + hintTimer.restart() + }) + }) } ConfirmDialog { @@ -1083,7 +1733,7 @@ Item { var n = nodeById[nodeId] fileDialog.nodeId = nodeId fileDialog.paramName = paramName - if (n && n.type === "image.load") { + if (n && (n.type === "image.load" || n.type === "mask.load")) { fileDialog.title = "Choose an input image" fileDialog.nameFilters = ["Images (*.png *.jpg *.jpeg *.gif)", "All files (*)"] } else { diff --git a/apps/desktop/resources.qrc b/apps/desktop/resources.qrc index ee22a13..3d25720 100644 --- a/apps/desktop/resources.qrc +++ b/apps/desktop/resources.qrc @@ -44,6 +44,14 @@ qml/pages/LibraryPage.qml qml/pages/GraphPage.qml qml/components/graph/GraphNode.qml + qml/components/graph/GraphMinimap.qml + qml/components/graph/RunHistoryPanel.qml + qml/components/graph/RunHistoryCard.qml + qml/components/graph/RunHistory.js + qml/components/graph/ImageViewer.qml + qml/components/graph/ImageViewport.qml + qml/components/graph/MaskEditor.qml + qml/components/graph/Socket.qml qml/components/graph/QuickAdd.qml qml/components/graph/NodeNumber.qml diff --git a/docs/api.md b/docs/api.md index bec29f4..38da1ac 100644 --- a/docs/api.md +++ b/docs/api.md @@ -328,8 +328,45 @@ paths) — the desktop UI loads `file_urls` only. | POST `/media/jobs/{id}/save` | `{"dest_path": "/…/photo.png"}` copy outputs out of the managed media dir to a user-chosen absolute path. A single-output job lands exactly on `dest_path`; batch extras get `-1`, `-2`, … before the extension. Returns `{ok, saved:[…]}` | | GET `/media/file/{rel}` | serve one stored output (media-root-relative only) | | GET `/models/{id}/media/capabilities` | samplers, schedulers, formats, defaults for the running server | +| GET `/media/servers/{id}/capabilities` | normalized loaded-model capabilities; `id` is the library model id | +| GET `/media/capabilities?model_id=` | the same normalized document; discovery never starts or reloads a model | | POST `/models/{id}/media/server/start\|stop` | manage the model's sd-server | -| POST `/models/{id}/media/generate` | `{kind, prompt, negative_prompt, width, height, steps, cfg_scale, seed, sampler, scheduler, guidance, output_format, batch_count, strength, init_image_path, video_frames, fps}` | +| POST `/models/{id}/media/generate` | `{kind, prompt, negative_prompt, width, height, steps, cfg_scale, seed, sampler, scheduler, guidance, output_format, batch_count, strength, init_image_path, mask_image_path, control_image_path, control_strength, ref_image_paths, lora, video_frames, fps}` | + +The normalized capabilities document reports `known`, `reason`, `model_id`, +`runtime_id`, `current_mode`, `supported_modes`, `features_by_mode`, `samplers`, +`schedulers`, `output_formats_by_mode`, `defaults_by_mode`, `loras`, `upscale`, +`upscalers` and `limits`. Model-dependent features come from the running +server's `/sdcpp/v1/capabilities`, separately from launch flags parsed from +`--help`. Unknown discovery does not enable advanced features. Legacy +single-mode fields apply only to the advertised `current_mode`. + +Managed SD endpoints use a unique in-memory bearer key for each process. +Builds advertising `--api-key` enforce it natively. Stock builds use a Go +authentication adapter on a random loopback port, forwarding only to that +process's separate runtime-internal loopback port. The adapter closes on +stop, crash or failed startup; keys never enter settings, history or logs. + +`defaults_by_mode` uses workflow parameter names (`width`, `height`, `batch`, +`steps`, `sampler`, `scheduler`, `cfg`, `guidance`, `strength`, `frames`, `fps`) +and includes only values actually returned by the runtime. The editor can apply +these defaults to a sampler and its connected size in one undo step. +Native sampler/scheduler `default` sentinels map to the empty automatic choice; +step count `0` also means automatic. Literal seed `0` and explicit zero CFG or +distilled guidance remain zero in native requests. + +Inpainting needs `init_image` and `mask_image` support in `img_gen`; white mask +pixels are regenerated and black pixels preserved. Control images, reference +images, structured LoRAs, and image-to-video are accepted only when the active +mode advertises them. `lora` is an array of +`{path, multiplier, is_high_noise?}` entries with absolute local paths. The +backend maps them into the configured advertised catalog; legacy prompt tags +are converted to structured entries when they resolve there. Graph LoRA +loaders configure the selected files' shared catalog directory, including +subfolders, and reload the server when necessary. Native upscaling uses only +advertised RGB model upscalers. +Unsupported fields, sampler/scheduler choices, formats and limits produce +validation errors rather than silently dropping the requested behavior. Diffusion `POST /models/{id}/preview` returns `can_load` with the component report. A preparation failure returns HTTP 200, `can_load:false`, empty @@ -411,53 +448,81 @@ Events: `media.progress` (`id`, `state`, `message`, `sd_status`, `progress`, `ou A workflow is a JSON node graph the QML canvas edits. The backend owns the node registry, validation, planning and running; QML only draws. Runs go through one -FIFO queue (one GPU, one run at a time), live in memory (the last 30 are kept), -and write their outputs as ordinary media files and `media_jobs` rows. There is -no node cache yet: every run recomputes every stage. +FIFO queue (one GPU, one run at a time; at most 20 waiting). Run snapshots and +terminal node results persist in SQLite, and outputs remain ordinary media +files and `media_jobs` rows. Interrupted queued/running work is recorded as +canceled after restart and is never silently replayed. + +Fixed-seed stages can reuse results when their graph parameters, input files, +dependency outputs and verified runtime/model/load configuration match. +Missing or modified outputs invalidate reuse. Random-seed stages and their +dependent stages are volatile; Save always produces a fresh copy. Unknown +runtime identities skip generation caching. `force:true` bypasses reuse. | Method & path | Purpose | |---|---| -| GET `/workflow/node-types[?runtime_id=]` | `{node_types, port_types, runtime}`. Every node type with its ports, parameters and `available`/`reason` for the selected sd.cpp runtime (a node needing a flag the runtime does not advertise is unavailable). The canvas draws sockets, widgets, palette and inspector from this | +| GET `/workflow/node-types[?runtime_id=&model_id=]` | `{node_types, port_types, runtime, api_capabilities}`. Ports, parameters and availability for the selected runtime and loaded model. The canvas draws sockets, widgets, palette and inspector from this | | GET/POST `/workflows` | list `{workflows:[{id,name,node_count,created_at,updated_at}]}` / create `{name?, graph}` → **201** record | | GET/PUT/DELETE `/workflows/{id}` | load a record `{id,name,graph,created_at,updated_at}` / `{name?, graph?}` replace either part / delete | +| POST `/workflows/import` | `{path}` import an absolute native workflow JSON path → **201** record | +| POST `/workflows/{id}/export` | `{path}` atomically export to an absolute `.json` path | +| POST `/workflow/masks` | `{image_path, strokes:[{points:[[x,y],…],radius,erase?}]}` → **201** `{path,file_url}` | | POST `/workflows/validate` | `{graph, only?, runtime_id?}` → `{ok, errors[], warnings[], plan, runtime}`. `only` plans a node and everything upstream ("Run to here") | -| POST `/workflow/runs` | `{graph, only?, runtime_id?}` → **202** `{run, warnings}`. A graph with problems is **400** `{error, errors[], warnings[]}` and nothing is queued; a full queue is **429** | -| GET `/workflow/runs` | recent runs, newest first | -| GET `/workflow/runs/{id}` | `{id, state, error?, nodes:{:{state, message?, outputs?, file_urls?, job_id?, ms?}}, created_at, started_at?, finished_at?}` | -| POST `/workflow/runs/{id}/cancel` | cancel a queued or running run (the running GPU job is canceled too) | +| POST `/workflow/runs` | `{graph, workflow_id?, only?, force?, runtime_id?}` → **202** `{run, warnings}`. A graph with problems is **400** `{error, errors[], warnings[]}` and nothing is queued; a full queue is **429** | +| GET `/workflow/runs` | up to 200 recent durable runs, newest first, including global queued/running work | +| GET `/workflow/runs/{id}` | `{id, state, error?, workflow_id?, graph?, source_graph?, only?, nodes:{:{state, cached?, seed?, progress?, message?, outputs?, file_urls?, job_id?, ms?}}, created_at, started_at?, finished_at?}` | +| POST `/workflow/runs/{id}/cancel` | cancel any queued or running run; generation forwards native job cancellation. Active GPU work may continue when the runtime cannot abort it; synchronous upscaling discards canceled results | + +Workflow exports are `{format:"openinfer-workflow",version:1,name,graph}`. +Imports also accept raw version-1 OpenInfer graphs. These are native OpenInfer +documents; model references remain library ids and file references remain local +paths, so recipients must have those resources available. + +Mask points are normalized coordinates in `[0,1]`. Radius is a fraction of the +longest source-image dimension, greater than zero and at most `0.5`. Limits: +1000 strokes, 20000 total points, source dimensions at most 4096 each. Raster +work is limited to 300 million candidate pixels; excessive complexity returns +a visible error. Painting observes request cancellation and writes a managed +grayscale PNG. PNG, JPEG and GIF source images are supported. Saving never requires a valid graph (drafts are normal); only the document shape is checked (`version: 1`, at most 500 nodes / 2000 edges). A bad document is **400**, an unknown id **404**. Graph document: `{version:1, name?, nodes:[{id, type, title?, pos:[x,y], -params?}], edges:[{from:[node,port], to:[node,port]}], groups?, view?}`. Models +params?, collapsed?}], edges:[{from:[node,port], to:[node,port]}], groups?, view?}`. Models are referenced by library id (`{"library_id": "…", "name": "…"}`), never by path; local files (LoRA, VAE, text encoder, input image) by absolute path. +Groups retain their title, node membership, color and note text. Issues are `{severity, code, node?, port?, param?, message}` so the canvas can mark the exact socket. Codes: `node.*`, `edge.*` (`type`, `multiple`, `endpoint`, `self`), `input.missing`, `param.*` (`unknown`, `required`, `type`, `range`, `enum`), `graph.cycle`, `capability.missing`, and planner errors `plan.*` (`model_missing`, `file_missing`, `prompt_empty`, `clip_source`, -`vae_source`, `lora_dir`, `lora_name`, `image_size`, `i2v_unsupported`, …). +`vae_source`, `image_size`, `api_capability`, `mask_start`, `mask_size`, …). Events: `workflow.run_queued` (`run_id`, `nodes`), `workflow.run_started`, `workflow.node_state` (`run_id`, `node_id`, `state` pending|running|done|failed| -canceled, `message?`, `outputs?`, `file_urls?`, `job_id?`, `ms?`) and +canceled, `cached?`, `seed?`, `message?`, `outputs?`, `file_urls?`, `job_id?`, `ms?`) and `workflow.run_finished` (`run_id`, `state` complete|failed|canceled, `error?`). Loaders, prompts and sizes have no work of their own: they take the state of the stage that consumes them. A random seed (`-1`) is resolved to a concrete one -before submitting and reported in the node's `message` (`seed N`). Run states -are in memory, so a stage's `outputs` are media-root-relative paths and -`file_urls` are local `file://` URLs for previews. +before submitting and recorded on the node and in the resolved run `graph`. +`source_graph` preserves the original submission, including `seed:-1`, so +editor matching and rerunning random workflows remain correct. Inspection +and restore use the resolved graph. A stage's `outputs` are media-root-relative +paths and `file_urls` are local `file://` URLs for previews. `plan` is `{stages, servers, warnings?}`. Stages are `generate` (one sd-server -request, with the full `GenerateParams` it will send), `resize` (CPU, in the -backend) or `save`. Each carries a content-hash `key` (`volatile` when a seed +request, with the full `GenerateParams` it will send), `resize`, `image` (CPU +crop, padding, rotate/flip, blend or batch-image selection), `upscale` (native +RGB upscale) or `save`. CPU operations use PNG, JPEG and GIF inputs and write +PNG outputs; image transforms are limited to 4096 on each side. Unknown +upscaler dimensions are checked when the output arrives. Each stage carries a content-hash `key` (`volatile` when a seed is random, so it must never be cached). `servers` lists each sd-server the plan uses by load `signature` with `action`: `reuse` (a running server already has -the graph's VAE / text encoder / LoRA folder), `start`, or `restart` (sd-server +the graph's VAE / text encoder), `start`, or `restart` (sd-server only reads those at launch, so changing one reloads the model). ## Chat diff --git a/internal/api/media.go b/internal/api/media.go index 9f7d80e..b15e574 100644 --- a/internal/api/media.go +++ b/internal/api/media.go @@ -344,6 +344,8 @@ func (h *handlers) generateMedia(w http.ResponseWriter, r *http.Request) { } else { p.Seed = -1 } + _, p.CFGScaleExplicit = raw["cfg_scale"] + _, p.GuidanceExplicit = raw["guidance"] if len(p.Prompt) > 8000 { writeErr(w, 400, "prompt too large", nil) return diff --git a/internal/api/media_capabilities.go b/internal/api/media_capabilities.go new file mode 100644 index 0000000..2b61e12 --- /dev/null +++ b/internal/api/media_capabilities.go @@ -0,0 +1,36 @@ +package api + +import ( + "net/http" + "strings" +) + +// mediaGenerationCapabilities serves loaded-model capability discovery. +// The server path's id is a library model ID; model_id selects the query form. +// Discovery is +// read-only: it never loads a model or probes CLI flags as HTTP evidence. +func (h *handlers) mediaGenerationCapabilities(w http.ResponseWriter, r *http.Request) { + if h.d.Media == nil { + writeErr(w, http.StatusServiceUnavailable, "media generation unavailable", nil) + return + } + modelID := strings.TrimSpace(r.PathValue("id")) + queryID := strings.TrimSpace(r.URL.Query().Get("model_id")) + if modelID != "" && queryID != "" && modelID != queryID { + writeErr(w, http.StatusBadRequest, "model_id conflicts with server id", nil) + return + } + if modelID == "" { + modelID = queryID + } + if modelID == "" { + writeErr(w, http.StatusBadRequest, "model_id is required", nil) + return + } + caps, err := h.d.Media.GenerationCapabilities(modelID) + if err != nil { + writeErr(w, http.StatusBadGateway, "reading loaded-model generation capabilities failed", err) + return + } + writeJSON(w, http.StatusOK, caps) +} diff --git a/internal/api/media_capabilities_test.go b/internal/api/media_capabilities_test.go new file mode 100644 index 0000000..10a49ff --- /dev/null +++ b/internal/api/media_capabilities_test.go @@ -0,0 +1,44 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/openinfer/openinfer-studio/internal/mediagen" +) + +func TestMediaGenerationCapabilitiesModelSelectionAndUnknownState(t *testing.T) { + h, _, _, _ := newSaveTest(t) + mux := http.NewServeMux() + mux.HandleFunc("GET /api/v1/media/servers/{id}/capabilities", h.mediaGenerationCapabilities) + mux.HandleFunc("GET /api/v1/media/capabilities", h.mediaGenerationCapabilities) + for _, url := range []string{"/api/v1/media/servers/model-1/capabilities", "/api/v1/media/capabilities?model_id=model-1"} { + w := httptest.NewRecorder() + mux.ServeHTTP(w, httptest.NewRequest(http.MethodGet, url, nil)) + var caps mediagen.APICapabilities + if w.Code != http.StatusOK || json.Unmarshal(w.Body.Bytes(), &caps) != nil || caps.ModelID != "model-1" || caps.Known || caps.Reason == "" { + t.Fatalf("response=%d %s", w.Code, w.Body.String()) + } + if caps.SupportedModes == nil || caps.Samplers == nil || caps.Schedulers == nil || caps.FeaturesByMode == nil { + t.Fatal("unknown capabilities must expose empty collections") + } + } + for _, url := range []string{"/api/v1/media/capabilities", "/api/v1/media/servers/model-1/capabilities?model_id=other"} { + w := httptest.NewRecorder() + mux.ServeHTTP(w, httptest.NewRequest(http.MethodGet, url, nil)) + if w.Code != http.StatusBadRequest { + t.Fatalf("missing/conflicting id accepted: %d", w.Code) + } + } +} + +func TestMediaGenerationCapabilitiesUnavailableManager(t *testing.T) { + h := &handlers{d: &Deps{}} + w := httptest.NewRecorder() + h.mediaGenerationCapabilities(w, httptest.NewRequest(http.MethodGet, "/api/v1/media/capabilities?model_id=m", nil)) + if w.Code != http.StatusServiceUnavailable { + t.Fatalf("status=%d", w.Code) + } +} diff --git a/internal/api/routes.go b/internal/api/routes.go index a5d51af..b17a9f9 100644 --- a/internal/api/routes.go +++ b/internal/api/routes.go @@ -85,6 +85,8 @@ func (s *Server) RegisterRoutes(d *Deps) { s.Handle("GET /api/v1/models/{id}/activity", h.modelActivity) s.Handle("GET /api/v1/media/servers", h.listMediaServers) + s.Handle("GET /api/v1/media/servers/{id}/capabilities", h.mediaGenerationCapabilities) + s.Handle("GET /api/v1/media/capabilities", h.mediaGenerationCapabilities) s.Handle("GET /api/v1/media/jobs", h.listMediaJobs) s.Handle("GET /api/v1/media/jobs/{id}", h.getMediaJob) s.Handle("POST /api/v1/media/jobs/{id}/cancel", h.cancelMediaJob) @@ -96,8 +98,11 @@ func (s *Server) RegisterRoutes(d *Deps) { s.Handle("POST /api/v1/models/{id}/media/generate", h.generateMedia) s.Handle("GET /api/v1/workflow/node-types", h.workflowNodeTypes) + s.Handle("POST /api/v1/workflow/masks", h.paintWorkflowMask) s.Handle("GET /api/v1/workflows", h.listWorkflows) s.Handle("POST /api/v1/workflows", h.createWorkflow) + s.Handle("POST /api/v1/workflows/import", h.importWorkflow) + s.Handle("POST /api/v1/workflows/{id}/export", h.exportWorkflow) s.Handle("POST /api/v1/workflows/validate", h.validateWorkflow) s.Handle("POST /api/v1/workflow/runs", h.startWorkflowRun) s.Handle("GET /api/v1/workflow/runs", h.listWorkflowRuns) diff --git a/internal/api/workflow.go b/internal/api/workflow.go index 74bd98e..c3c8704 100644 --- a/internal/api/workflow.go +++ b/internal/api/workflow.go @@ -38,10 +38,13 @@ func (h *handlers) workflowNodeTypes(w http.ResponseWriter, r *http.Request) { return } info, caps := svc.Capabilities(r.URL.Query().Get("runtime_id")) + apiCaps := svc.ModelCapabilities(r.URL.Query().Get("model_id")) + caps.API = apiCaps writeJSON(w, http.StatusOK, map[string]any{ - "node_types": svc.Registry().View(caps), - "port_types": workflow.AllPortTypes, - "runtime": info, + "node_types": svc.Registry().View(caps), + "port_types": workflow.AllPortTypes, + "runtime": info, + "api_capabilities": apiCaps, }) } @@ -179,9 +182,11 @@ func (h *handlers) startWorkflowRun(w http.ResponseWriter, r *http.Request) { return } var body struct { - Graph json.RawMessage `json:"graph"` - Only string `json:"only"` - RuntimeID string `json:"runtime_id"` + Graph json.RawMessage `json:"graph"` + Only string `json:"only"` + RuntimeID string `json:"runtime_id"` + WorkflowID string `json:"workflow_id"` + Force bool `json:"force"` } if !decodeJSON(w, r, &body) { return @@ -201,7 +206,7 @@ func (h *handlers) startWorkflowRun(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusBadRequest, map[string]any{"error": msg, "errors": errs, "warnings": warns}) return } - view, err := exec.Submit(plan) + view, err := exec.SubmitWithOptions(plan, workflow.RunOptions{Graph: &g, WorkflowID: body.WorkflowID, Only: body.Only, Force: body.Force}) if errors.Is(err, workflow.ErrQueueFull) { writeErr(w, http.StatusTooManyRequests, "too many runs queued", err) return @@ -230,7 +235,12 @@ func (h *handlers) listWorkflowRuns(w http.ResponseWriter, r *http.Request) { if exec == nil { return } - writeJSON(w, http.StatusOK, map[string]any{"runs": exec.List()}) + runs, err := exec.History() + if err != nil { + writeErr(w, http.StatusInternalServerError, "reading run history", err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"runs": runs}) } func (h *handlers) getWorkflowRun(w http.ResponseWriter, r *http.Request) { diff --git a/internal/api/workflow_files.go b/internal/api/workflow_files.go new file mode 100644 index 0000000..6c75055 --- /dev/null +++ b/internal/api/workflow_files.go @@ -0,0 +1,42 @@ +package api + +import ( + "net/http" +) + +func (h *handlers) importWorkflow(w http.ResponseWriter, r *http.Request) { + svc := h.workflowSvc(w) + if svc == nil { + return + } + var body struct { + Path string `json:"path"` + } + if !decodeJSON(w, r, &body) { + return + } + rec, err := svc.Store().Import(body.Path) + if err != nil { + writeWorkflowErr(w, "importing workflow", err) + return + } + writeJSON(w, http.StatusCreated, rec) +} + +func (h *handlers) exportWorkflow(w http.ResponseWriter, r *http.Request) { + svc := h.workflowSvc(w) + if svc == nil { + return + } + var body struct { + Path string `json:"path"` + } + if !decodeJSON(w, r, &body) { + return + } + if err := svc.Store().Export(r.PathValue("id"), body.Path); err != nil { + writeWorkflowErr(w, "exporting workflow", err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "path": body.Path}) +} diff --git a/internal/api/workflow_mask.go b/internal/api/workflow_mask.go new file mode 100644 index 0000000..434ce23 --- /dev/null +++ b/internal/api/workflow_mask.go @@ -0,0 +1,35 @@ +package api + +import ( + "net/http" + "net/url" + "path/filepath" + "runtime" + "strings" + + "github.com/openinfer/openinfer-studio/internal/workflow" +) + +func (h *handlers) paintWorkflowMask(w http.ResponseWriter, r *http.Request) { + if h.d.Media == nil { + writeErr(w, 503, "media store unavailable", nil) + return + } + var body struct { + ImagePath string `json:"image_path"` + Strokes []workflow.MaskStroke `json:"strokes"` + } + if !decodeJSON(w, r, &body) { + return + } + path, err := workflow.PaintMaskContext(r.Context(), h.d.Media.MediaDir(), body.ImagePath, body.Strokes) + if err != nil { + writeWorkflowErr(w, "painting mask", err) + return + } + p := filepath.ToSlash(path) + if runtime.GOOS == "windows" && !strings.HasPrefix(p, "/") { + p = "/" + p + } + writeJSON(w, 201, map[string]any{"path": path, "file_url": "file://" + (&url.URL{Path: p}).EscapedPath()}) +} diff --git a/internal/api/workflow_tools_test.go b/internal/api/workflow_tools_test.go new file mode 100644 index 0000000..1dcfe88 --- /dev/null +++ b/internal/api/workflow_tools_test.go @@ -0,0 +1,95 @@ +package api + +import ( + "encoding/json" + "image" + "image/color" + "image/png" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/openinfer/openinfer-studio/internal/workflow" +) + +func TestWorkflowMaskEndpointCreatesUsableGrayscaleArtifact(t *testing.T) { + h, _, root, dir := newSaveTest(t) + source := filepath.Join(dir, "source #%.png") + f, err := os.Create(source) + if err != nil { + t.Fatal(err) + } + err = png.Encode(f, image.NewGray(image.Rect(0, 0, 32, 16))) + f.Close() + if err != nil { + t.Fatal(err) + } + body, _ := json.Marshal(map[string]any{"image_path": source, "strokes": []workflow.MaskStroke{{Radius: .1, Points: [][2]float64{{.5, .5}}}}}) + w := httptest.NewRecorder() + h.paintWorkflowMask(w, httptest.NewRequest(http.MethodPost, "/api/v1/workflow/masks", strings.NewReader(string(body)))) + var result struct { + Path string `json:"path"` + URL string `json:"file_url"` + } + if w.Code != http.StatusCreated || json.Unmarshal(w.Body.Bytes(), &result) != nil { + t.Fatalf("mask response: %d %s", w.Code, w.Body.String()) + } + u, err := url.Parse(result.URL) + rel, relErr := filepath.Rel(root, result.Path) + urlPath := u.Path + if runtime.GOOS == "windows" { + urlPath = strings.TrimPrefix(urlPath, "/") + } + if err != nil || u.Scheme != "file" || filepath.FromSlash(urlPath) != result.Path || relErr != nil || !filepath.IsLocal(rel) { + t.Fatalf("invalid artifact locations: %+v", result) + } + f, err = os.Open(result.Path) + if err != nil { + t.Fatal(err) + } + img, err := png.Decode(f) + f.Close() + if err != nil || img.Bounds().Dx() != 32 || img.Bounds().Dy() != 16 { + t.Fatalf("unusable mask artifact: %v", err) + } + if color.GrayModel.Convert(img.At(16, 8)).(color.Gray).Y != 255 || color.GrayModel.Convert(img.At(0, 0)).(color.Gray).Y != 0 { + t.Fatal("artifact must paint white and preserve black") + } + bad, _ := json.Marshal(map[string]any{"image_path": source, "strokes": []workflow.MaskStroke{{Radius: .51}}}) + w = httptest.NewRecorder() + h.paintWorkflowMask(w, httptest.NewRequest(http.MethodPost, "/api/v1/workflow/masks", strings.NewReader(string(bad)))) + if w.Code != http.StatusBadRequest { + t.Fatalf("invalid brush accepted: %d", w.Code) + } +} + +func TestWorkflowFileEndpointsRoundTripNativeDocument(t *testing.T) { + svc := newWorkflowService(t, fakeMedia{dir: t.TempDir()}) + h := &handlers{d: &Deps{Workflow: svc}} + record, err := svc.Store().Create("Shared workflow", json.RawMessage(txt2imgJSON)) + if err != nil { + t.Fatal(err) + } + mux := http.NewServeMux() + mux.HandleFunc("POST /api/v1/workflows/{id}/export", h.exportWorkflow) + mux.HandleFunc("POST /api/v1/workflows/import", h.importWorkflow) + path := filepath.Join(t.TempDir(), "workflow.json") + body, _ := json.Marshal(map[string]any{"path": path}) + status, out := call(t, mux, http.MethodPost, "/api/v1/workflows/"+record.ID+"/export", string(body)) + if status != http.StatusOK || out["ok"] != true { + t.Fatalf("export: %d %v", status, out) + } + status, out = call(t, mux, http.MethodPost, "/api/v1/workflows/import", string(body)) + if status != http.StatusCreated || out["name"] != "Shared workflow" || out["id"] == record.ID { + t.Fatalf("import: %d %v", status, out) + } + graph := out["graph"].(map[string]any) + if len(graph["nodes"].([]any)) != 5 { + t.Fatal("import lost workflow nodes") + } +} diff --git a/internal/database/database_test.go b/internal/database/database_test.go index cbcc2fe..19a8fc6 100644 --- a/internal/database/database_test.go +++ b/internal/database/database_test.go @@ -40,8 +40,8 @@ func TestMigrationsIdempotent(t *testing.T) { defer db2.Close() var n int db2.QueryRow(`SELECT COUNT(1) FROM schema_migrations`).Scan(&n) - if n != 5 { - t.Errorf("migrations recorded = %d, want 5", n) + if n != 6 { + t.Errorf("migrations recorded = %d, want 6", n) } } diff --git a/internal/mediagen/api_capabilities.go b/internal/mediagen/api_capabilities.go new file mode 100644 index 0000000..7935f5d --- /dev/null +++ b/internal/mediagen/api_capabilities.go @@ -0,0 +1,328 @@ +package mediagen + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +var ( + ErrServerNotRunning = errors.New("diffusion server is not ready") + ErrCapabilitiesUnavailable = errors.New("runtime does not expose /sdcpp/v1/capabilities") +) + +// Lora is the native server API's structured LoRA entry. Relative paths +// must be advertised by the server or resolved under its configured LoRA dir. +type Lora struct { + Path string `json:"path"` + Multiplier float64 `json:"multiplier"` + IsHighNoise bool `json:"is_high_noise,omitempty"` +} + +type LoraEntry struct { + Name string `json:"name"` + Path string `json:"path"` +} + +type UpscalerEntry struct { + Name string `json:"name"` + Model bool `json:"model"` + ImageUpscale bool `json:"image_upscale"` +} + +type APILimits struct { + MinWidth int `json:"min_width,omitempty"` + MaxWidth int `json:"max_width,omitempty"` + MinHeight int `json:"min_height,omitempty"` + MaxHeight int `json:"max_height,omitempty"` + MaxBatchCount int `json:"max_batch_count,omitempty"` + MaxUpscaleWidth int `json:"max_upscale_width,omitempty"` + MaxUpscaleHeight int `json:"max_upscale_height,omitempty"` +} + +// WorkflowDefaults uses the editor's workflow keys. Presence is retained +// internally so JSON preserves explicit zero/empty defaults while omitting +// fields that the runtime did not return. The exported Go fields are values. +type WorkflowDefaults struct { + Width int `json:"width"` + Height int `json:"height"` + BatchCount int `json:"batch"` + Steps int `json:"steps"` + Sampler string `json:"sampler"` + Scheduler string `json:"scheduler"` + CFG float64 `json:"cfg"` + Guidance float64 `json:"guidance"` + Strength float64 `json:"strength"` + Frames int `json:"frames"` + FPS int `json:"fps"` + present map[string]bool +} + +func (d WorkflowDefaults) MarshalJSON() ([]byte, error) { + fields := map[string]any{"width": d.Width, "height": d.Height, "batch": d.BatchCount, "steps": d.Steps, "sampler": d.Sampler, "scheduler": d.Scheduler, "cfg": d.CFG, "guidance": d.Guidance, "strength": d.Strength, "frames": d.Frames, "fps": d.FPS} + if d.present != nil { + for field := range fields { + if !d.present[field] { + delete(fields, field) + } + } + } + return json.Marshal(fields) +} + +func normalizeWorkflowDefaults(doc map[string]any) WorkflowDefaults { + d := WorkflowDefaults{present: map[string]bool{}} + integer := func(source map[string]any, native, key string, target *int) { + if value, ok := source[native].(float64); ok && value >= -2147483648 && value <= 2147483647 && value == float64(int(value)) { + *target = int(value) + d.present[key] = true + } + } + number := func(source map[string]any, native, key string, target *float64) { + if value, ok := source[native].(float64); ok { + *target = value + d.present[key] = true + } + } + str := func(source map[string]any, native, key string, target *string) { + if value, ok := source[native].(string); ok { + *target = value + d.present[key] = true + } + } + integer(doc, "width", "width", &d.Width) + integer(doc, "height", "height", &d.Height) + integer(doc, "batch_count", "batch", &d.BatchCount) + integer(doc, "video_frames", "frames", &d.Frames) + integer(doc, "fps", "fps", &d.FPS) + number(doc, "strength", "strength", &d.Strength) + sp, _ := doc["sample_params"].(map[string]any) + integer(sp, "sample_steps", "steps", &d.Steps) + str(sp, "sample_method", "sampler", &d.Sampler) + str(sp, "scheduler", "scheduler", &d.Scheduler) + // Native "default" is an automatic sentinel, not an advertised enum + // choice. The workflow's empty choice leaves the server default in effect. + if d.Sampler == "default" { + d.Sampler = "" + } + if d.Scheduler == "default" { + d.Scheduler = "" + } + guidance, _ := sp["guidance"].(map[string]any) + number(guidance, "txt_cfg", "cfg", &d.CFG) + number(guidance, "distilled_guidance", "guidance", &d.Guidance) + return d +} + +// APICapabilities describes only the HTTP API advertised by this loaded +// model's live runtime. CLI flags are intentionally a separate interface. +// Unknown metadata never enables advanced inputs. No runtime-wide cache is +// used: model reloads and component changes can change these features. +type APICapabilities struct { + Known bool `json:"known"` + Reason string `json:"reason,omitempty"` + ModelID string `json:"model_id"` + RuntimeID string `json:"runtime_id"` + CurrentMode string `json:"current_mode,omitempty"` + SupportedModes []string `json:"supported_modes"` + FeaturesByMode map[string]map[string]bool `json:"features_by_mode"` + DefaultsByMode map[string]WorkflowDefaults `json:"defaults_by_mode"` + Samplers []string `json:"samplers"` + Schedulers []string `json:"schedulers"` + OutputFormatsByMode map[string][]string `json:"output_formats_by_mode"` + Loras []LoraEntry `json:"loras"` + Upscale bool `json:"upscale"` + Upscalers []UpscalerEntry `json:"upscalers"` + Limits APILimits `json:"limits"` +} + +func (c *APICapabilities) SupportsMode(mode string) bool { + if c == nil || !c.Known { + return false + } + for _, m := range c.SupportedModes { + if m == mode { + return true + } + } + return false +} + +func (c *APICapabilities) SupportsFeature(mode, feature string) bool { + return c.SupportsMode(mode) && c.FeaturesByMode[mode][feature] +} + +// GenerationCapabilities discovers mode/feature/choice metadata without +// starting or reloading a model. A stopped model returns Known:false and a +// reason, allowing planning to distinguish unavailable from supported. +func (m *Manager) GenerationCapabilities(modelID string) (*APICapabilities, error) { + return m.generationCapabilities(context.Background(), modelID) +} + +func (m *Manager) generationCapabilities(ctx context.Context, modelID string) (*APICapabilities, error) { + runtimeID, _, _ := m.serverMeta(modelID) + doc, err := m.Capabilities(ctx, modelID) + if err != nil { + if errors.Is(err, ErrServerNotRunning) || errors.Is(err, ErrCapabilitiesUnavailable) { + c := normalizeAPICapabilities(nil, modelID, runtimeID) + c.Reason = err.Error() + return c, nil + } + return nil, err + } + return normalizeAPICapabilities(doc, modelID, runtimeID), nil +} + +func normalizeAPICapabilities(doc map[string]any, modelID, runtimeID string) *APICapabilities { + c := &APICapabilities{ + ModelID: modelID, RuntimeID: runtimeID, + SupportedModes: []string{}, FeaturesByMode: map[string]map[string]bool{}, + Samplers: stringList(doc["samplers"]), Schedulers: stringList(doc["schedulers"]), + OutputFormatsByMode: map[string][]string{}, Loras: []LoraEntry{}, Upscalers: []UpscalerEntry{}, + DefaultsByMode: map[string]WorkflowDefaults{}, + } + c.CurrentMode, _ = doc["current_mode"].(string) + // A legacy current_mode is explicit evidence of one mode. Never apply + // its top-level feature mirror to another mode, or override a modern + // explicitly empty supported_modes list. + modes, hasModes := doc["supported_modes"] + if !hasModes && (c.CurrentMode == "img_gen" || c.CurrentMode == "vid_gen") { + modes = []any{c.CurrentMode} + } + featureValue, hasFeatures := doc["features_by_mode"] + features, _ := featureValue.(map[string]any) + formatValue, hasFormats := doc["output_formats_by_mode"] + formats, _ := formatValue.(map[string]any) + defaultValue, hasDefaults := doc["defaults_by_mode"] + defaults, _ := defaultValue.(map[string]any) + for _, mode := range stringList(modes) { + if mode != "img_gen" && mode != "vid_gen" { + continue + } + c.SupportedModes = append(c.SupportedModes, mode) + f, _ := features[mode].(map[string]any) + if !hasFeatures && mode == c.CurrentMode { + f, _ = doc["features"].(map[string]any) + } + flags := map[string]bool{} + for key, value := range f { + if b, ok := value.(bool); ok { + flags[key] = b + } + } + c.FeaturesByMode[mode] = flags + modeDefaults, _ := defaults[mode].(map[string]any) + if !hasDefaults && mode == c.CurrentMode { + modeDefaults, _ = doc["defaults"].(map[string]any) + } + if modeDefaults != nil { + c.DefaultsByMode[mode] = normalizeWorkflowDefaults(modeDefaults) + } + if hasFormats { + c.OutputFormatsByMode[mode] = stringList(formats[mode]) + } else if mode == c.CurrentMode { + c.OutputFormatsByMode[mode] = stringList(doc["output_formats"]) + } + } + c.Upscale, _ = doc["upscale"].(bool) + decodeField := func(key string, dst any) { + if raw, err := json.Marshal(doc[key]); err == nil { + _ = json.Unmarshal(raw, dst) + } + } + decodeField("limits", &c.Limits) + // Decode each catalog entry independently so malformed entries cannot + // partially set booleans and accidentally advertise support. + if list, ok := doc["upscalers"].([]any); ok { + for _, entry := range list { + var up UpscalerEntry + raw, err := json.Marshal(entry) + if err == nil && json.Unmarshal(raw, &up) == nil && up.Name != "" { + c.Upscalers = append(c.Upscalers, up) + } + } + } + if list, ok := doc["loras"].([]any); ok { + for _, entry := range list { + var l LoraEntry + raw, err := json.Marshal(entry) + if err == nil && json.Unmarshal(raw, &l) == nil && l.Name != "" && l.Path != "" { + c.Loras = append(c.Loras, l) + } + } + } + c.Known = len(c.SupportedModes) > 0 || c.Upscale + if !c.Known { + c.Reason = "loaded-model capabilities contain no explicit supported mode; advanced features are unavailable" + } + return c +} + +func stringList(value any) []string { + out := []string{} + seen := map[string]bool{} + if values, ok := value.([]any); ok { + for _, value := range values { + if s, ok := value.(string); ok && strings.TrimSpace(s) != "" && !seen[s] { + out = append(out, s) + seen[s] = true + } + } + } + return out +} + +const maxCapabilityResponseBytes = 1 << 20 + +// readSDJSON bounds response bodies and rejects multiple JSON documents. +func readSDJSON(r io.Reader, limit int64, dst any) error { + raw, err := io.ReadAll(io.LimitReader(r, limit+1)) + if err != nil { + return err + } + if int64(len(raw)) > limit { + return fmt.Errorf("sd-server response exceeds %d byte limit", limit) + } + return json.Unmarshal(raw, dst) +} + +func (m *Manager) fetchCapabilities(ctx context.Context, modelID string) (map[string]any, error) { + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + port, ok := m.ServerPort(modelID) + if !ok { + return nil, fmt.Errorf("%w for model %s", ErrServerNotRunning, modelID) + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, + fmt.Sprintf("http://127.0.0.1:%d/sdcpp/v1/capabilities", port), nil) + if err != nil { + return nil, err + } + resp, err := m.doSD(req, port) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusMethodNotAllowed { + return nil, ErrCapabilitiesUnavailable + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("sd-server capabilities HTTP %d", resp.StatusCode) + } + var out map[string]any + if err := readSDJSON(resp.Body, maxCapabilityResponseBytes, &out); err != nil { + return nil, fmt.Errorf("reading sd-server capabilities: %w", err) + } + if out == nil { + return nil, errors.New("sd-server capabilities must be a JSON object") + } + if !m.serverMatchesPort(modelID, port) { + return nil, ErrServerNotRunning + } + return out, nil +} diff --git a/internal/mediagen/api_capabilities_test.go b/internal/mediagen/api_capabilities_test.go new file mode 100644 index 0000000..dd8a79a --- /dev/null +++ b/internal/mediagen/api_capabilities_test.go @@ -0,0 +1,265 @@ +package mediagen + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "image" + "image/png" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + "time" +) + +func decodeCapabilityFixture(t *testing.T, fixture string) map[string]any { + t.Helper() + var doc map[string]any + if err := json.Unmarshal([]byte(fixture), &doc); err != nil { + t.Fatal(err) + } + return doc +} + +func TestAPICapabilitiesModeAndLegacyBoundaries(t *testing.T) { + for _, tc := range []struct { + name, fixture string + known, mask, video, videoInit bool + }{ + {"modern", `{"supported_modes":["img_gen","vid_gen"],"current_mode":"img_gen","features_by_mode":{"img_gen":{"mask_image":true,"ref_images":"true"},"vid_gen":{"init_image":false}},"features":{"init_image":true}}`, true, true, true, false}, + {"legacy current mode", `{"current_mode":"img_gen","features":{"mask_image":true,"init_image":true}}`, true, true, false, false}, + {"legacy video", `{"current_mode":"vid_gen","features":{"init_image":true}}`, true, false, true, true}, + {"legacy ambiguous", `{"features":{"mask_image":true,"init_image":true},"samplers":["euler"]}`, false, false, false, false}, + {"empty modern defeats mirror", `{"supported_modes":[],"current_mode":"img_gen","features":{"mask_image":true}}`, false, false, false, false}, + {"modern false defeats mirror", `{"supported_modes":["img_gen"],"current_mode":"img_gen","features_by_mode":{"img_gen":{"mask_image":false}},"features":{"mask_image":true}}`, true, false, false, false}, + {"malformed modern defeats mirror", `{"supported_modes":["img_gen"],"current_mode":"img_gen","features_by_mode":null,"features":{"mask_image":true}}`, true, false, false, false}, + {"unadvertised feature mode", `{"supported_modes":["img_gen"],"features_by_mode":{"vid_gen":{"init_image":true}}}`, true, false, false, false}, + } { + t.Run(tc.name, func(t *testing.T) { + c := normalizeAPICapabilities(decodeCapabilityFixture(t, tc.fixture), "m", "r") + if c.Known != tc.known || c.SupportsFeature("img_gen", "mask_image") != tc.mask || c.SupportsMode("vid_gen") != tc.video || c.SupportsFeature("vid_gen", "init_image") != tc.videoInit { + t.Fatalf("incorrect advertisement: %+v", c) + } + if c.SupportsFeature("img_gen", "ref_images") { + t.Fatal("a string must not enable a feature") + } + }) + } +} + +func TestAPICapabilitiesChoicesAndUnsupportedFeatures(t *testing.T) { + c := normalizeAPICapabilities(decodeCapabilityFixture(t, `{"supported_modes":["img_gen"],"samplers":["euler",null,"euler",5],"schedulers":["discrete"],"features_by_mode":{"img_gen":{"init_image":true}},"output_formats_by_mode":{"img_gen":["png"]},"limits":{"max_width":512}}`), "m", "r") + if !reflect.DeepEqual(c.Samplers, []string{"euler"}) { + t.Fatalf("samplers=%v", c.Samplers) + } + for _, p := range []GenerateParams{{Kind: KindVideo}, {MaskImage: "mask"}, {ControlImage: "control"}, {RefImages: []string{"ref"}}, {Lora: []Lora{{Path: "lora"}}}, {Sampler: "invented"}, {Scheduler: "karras"}, {Width: 1024}, {OutputFormat: "webp"}} { + if err := c.ValidateGeneration(p); err == nil { + t.Fatalf("unadvertised request accepted: %+v", p) + } + } + if err := c.ValidateGeneration(GenerateParams{InitImage: "input", Sampler: "Euler", Scheduler: "Discrete", OutputFormat: "png", Width: 512}); err != nil { + t.Fatal(err) + } + unknown := normalizeAPICapabilities(nil, "m", "") + for _, p := range []GenerateParams{{InitImage: "input"}, {Kind: KindVideo}, {MaskImage: "input"}, {Prompt: "p "}} { + if err := unknown.ValidateGeneration(p); err == nil || !strings.Contains(err.Error(), unknown.Reason) { + t.Fatalf("unknown capability did not explain rejection: %v", err) + } + } +} + +func newCapabilitySD(t *testing.T, doc map[string]any, extra func(http.ResponseWriter, *http.Request) bool) *fakeSD { + t.Helper() + f := &fakeSD{} + f.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if extra != nil && extra(w, r) { + return + } + switch { + case r.URL.Path == "/sdcpp/v1/capabilities": + if doc == nil { + http.NotFound(w, r) + return + } + _ = json.NewEncoder(w).Encode(doc) + case r.Method == http.MethodPost && (r.URL.Path == "/sdcpp/v1/img_gen" || r.URL.Path == "/sdcpp/v1/vid_gen"): + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, err.Error(), 400) + return + } + f.requests = append(f.requests, body) + f.poll = okPoll() + if r.URL.Path == "/sdcpp/v1/vid_gen" { + f.poll = map[string]any{"status": "completed", "result": map[string]any{"output_format": "webm", "b64_json": base64.StdEncoding.EncodeToString([]byte("VIDEO"))}} + } + w.WriteHeader(202) + _ = json.NewEncoder(w).Encode(map[string]string{"id": "sd-1"}) + case r.URL.Path == "/sdcpp/v1/jobs/sd-1": + _ = json.NewEncoder(w).Encode(f.poll) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(f.srv.Close) + return f +} + +func pngInput(t *testing.T) ([]byte, string) { + t.Helper() + var b bytes.Buffer + if err := png.Encode(&b, image.NewNRGBA(image.Rect(0, 0, 8, 8))); err != nil { + t.Fatal(err) + } + path := filepath.Join(t.TempDir(), "input.png") + if err := os.WriteFile(path, b.Bytes(), 0600); err != nil { + t.Fatal(err) + } + return b.Bytes(), path +} + +func TestNativeGenerationConditioningAndStructuredLora(t *testing.T) { + doc := decodeCapabilityFixture(t, `{"supported_modes":["img_gen"],"features_by_mode":{"img_gen":{"init_image":true,"mask_image":true,"control_image":true,"ref_images":true,"lora":true}},"samplers":["euler"],"schedulers":["discrete"],"loras":[{"name":"film","path":"film.safetensors"}]}`) + f := newCapabilitySD(t, doc, nil) + m := newJobManager(t, f) + raw, path := pngInput(t) + p := GenerateParams{Prompt: "portrait ", InitImagePath: path, MaskImagePath: path, ControlImagePath: path, ControlStrength: 0.8, RefImagePaths: []string{path}, Sampler: "euler", Scheduler: "discrete", Guidance: 3.5, CFGScale: 7, Strength: 0.7} + j, err := m.RunStage(context.Background(), "model-1", p, nil) + if err != nil { + t.Fatal(err) + } + if len(f.requests) != 1 { + t.Fatalf("requests=%v", f.requests) + } + body := f.requests[0] + if body["prompt"] != "portrait" || body["control_strength"] != 0.8 { + t.Fatalf("body=%v", body) + } + for _, field := range []string{"init_image", "mask_image", "control_image"} { + got, _ := body[field].(string) + if !strings.HasSuffix(got, base64.StdEncoding.EncodeToString(raw)) { + t.Fatalf("%s was not encoded", field) + } + } + refs, _ := body["ref_images"].([]any) + if len(refs) != 1 { + t.Fatalf("refs=%v", refs) + } + loras, _ := body["lora"].([]any) + if len(loras) != 1 || loras[0].(map[string]any)["path"] != "film.safetensors" || loras[0].(map[string]any)["multiplier"] != 0.65 { + t.Fatalf("loras=%v", loras) + } + guidance := body["sample_params"].(map[string]any)["guidance"].(map[string]any) + if guidance["distilled_guidance"] != 3.5 || guidance["txt_cfg"] != float64(7) { + t.Fatalf("guidance=%v", guidance) + } + if bytes.Contains(j.Params, []byte("data:image")) || strings.Contains(string(j.Params), base64.StdEncoding.EncodeToString(raw)) { + t.Fatal("local image payload leaked into persisted params") + } + if p.Prompt != "portrait " || len(p.Lora) != 0 { + t.Fatal("conversion mutated caller params") + } +} + +func TestUnknownLoadedModelRejectsBeforeSubmittingAdvancedRequest(t *testing.T) { + f := newCapabilitySD(t, nil, nil) + m := newJobManager(t, f) + _, path := pngInput(t) + for _, p := range []GenerateParams{{Prompt: "p", InitImagePath: path}, {Prompt: "p "}, {Prompt: "p", Kind: KindVideo}} { + job, err := m.RunStage(context.Background(), "model-1", p, nil) + if err == nil || job == nil || job.State != StateFailed || !strings.Contains(err.Error(), "advertis") { + t.Fatalf("job=%+v err=%v", job, err) + } + } + if len(f.requests) != 0 { + t.Fatal("advanced input reached unsupported runtime") + } +} + +func TestImageToVideoRequiresModeSpecificAdvertisement(t *testing.T) { + _, path := pngInput(t) + for _, enabled := range []bool{false, true} { + t.Run(fmtBool(enabled), func(t *testing.T) { + doc := map[string]any{"supported_modes": []any{"vid_gen"}, "features_by_mode": map[string]any{"vid_gen": map[string]any{"init_image": enabled}}} + f := newCapabilitySD(t, doc, nil) + m := newJobManager(t, f) + _, err := m.RunStage(context.Background(), "model-1", GenerateParams{Kind: KindVideo, Prompt: "p", InitImagePath: path, VideoFrames: 17, FPS: 8}, nil) + if enabled { + if err != nil { + t.Fatal(err) + } + if f.requests[0]["init_image"] == nil { + t.Fatal("video init_image dropped") + } + } else if err == nil || len(f.requests) != 0 { + t.Fatal("unadvertised i2v submitted") + } + }) + } +} + +func fmtBool(b bool) string { + if b { + return "advertised" + } + return "unsupported" +} + +func TestCapabilityDiscoveryBoundsAndAuthentication(t *testing.T) { + for _, tc := range []struct { + name string + code int + body string + wantErr bool + known bool + }{ + {"missing", 404, "", false, false}, {"failed", 500, `{"supported_modes":["img_gen"]}`, true, false}, + {"null", 200, "null", true, false}, {"trailing", 200, `{} {}`, true, false}, + {"oversized", 200, strings.Repeat(" ", maxCapabilityResponseBytes+1), true, false}, + {"valid", 200, `{"supported_modes":["img_gen"]}`, false, true}, + } { + t.Run(tc.name, func(t *testing.T) { + f := newCapabilitySD(t, nil, func(w http.ResponseWriter, r *http.Request) bool { + if r.Header.Get("Authorization") != "Bearer secret" { + http.Error(w, "missing key", 401) + return true + } + w.WriteHeader(tc.code) + _, _ = w.Write([]byte(tc.body)) + return true + }) + m := newJobManager(t, f) + m.servers["model-1"].apiKey = "secret" + c, err := m.GenerationCapabilities("model-1") + if (err != nil) != tc.wantErr || (err == nil && c.Known != tc.known) { + t.Fatalf("caps=%+v err=%v", c, err) + } + }) + } + m := NewManager(nil, nil, nil, nil, nil, nil) + c, err := m.GenerationCapabilities("stopped") + if err != nil || c.Known || c.Reason == "" { + t.Fatalf("stopped=%+v err=%v", c, err) + } +} + +func TestCapabilitiesContextBoundsIncompleteBody(t *testing.T) { + f := newCapabilitySD(t, nil, func(w http.ResponseWriter, r *http.Request) bool { + _, _ = w.Write([]byte(`{"`)) + w.(http.Flusher).Flush() + <-r.Context().Done() + return true + }) + m := newJobManager(t, f) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond) + defer cancel() + start := time.Now() + if _, err := m.Capabilities(ctx, "model-1"); err == nil || time.Since(start) > time.Second { + t.Fatalf("unbounded capability response: %v", err) + } +} diff --git a/internal/mediagen/cache.go b/internal/mediagen/cache.go new file mode 100644 index 0000000..4364672 --- /dev/null +++ b/internal/mediagen/cache.go @@ -0,0 +1,240 @@ +package mediagen + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "path/filepath" + "reflect" + "sort" + "strings" + + "github.com/openinfer/openinfer-studio/internal/runtimes" +) + +type cacheFile struct { + Path string + Resolved string + Size int64 + Modified int64 + Mode uint32 + FileID string +} + +type launchCacheSnapshot struct { + Settings LoadSettings + Args []string + SourceFiles []cacheFile + LoadedFiles []cacheFile +} + +// WorkflowCacheIdentity is available only for a ready, unchanged launch with +// known loaded-model HTTP capabilities. It never loads/reloads a model. The +// parent must treat any error as a cache miss. No credentials enter the hash. +func (m *Manager) WorkflowCacheIdentity(modelID string, ov *LoadOverrides) (string, error) { + if m.rt == nil || m.lib == nil { + return "", fmt.Errorf("workflow cache identity requires managed runtime and model metadata") + } + m.mu.Lock() + sv := m.servers[modelID] + if sv == nil || !sv.ready || sv.cacheSnapshot == nil { + m.mu.Unlock() + return "", fmt.Errorf("workflow cache identity requires a known ready launch") + } + snapshot := *sv.cacheSnapshot + runtimeID, help, exe, port := sv.runtimeID, sv.help, sv.exe, sv.port + m.mu.Unlock() + settings := snapshot.Settings + if ov != nil && !ov.SatisfiedBy(settings) { + return "", fmt.Errorf("workflow launch overrides would reload the model") + } + if strings.TrimSpace(settings.RawArgs) != "" { + return "", fmt.Errorf("workflow cache identity unavailable for raw launch overrides") + } + rt, err := m.rt.Get(runtimeID) + if err != nil { + return "", err + } + if rt.VersionOutput == "" { + return "", fmt.Errorf("runtime version identity is unknown") + } + mdl, err := m.lib.Get(modelID) + if err != nil { + return "", err + } + // Re-run companion pairing against the current model directory. This + // catches newly discovered/replaced default VAEs and text encoders even + // if the model's explicit settings did not change. + launchSettings := settings + m.mu.Lock() + if m.servers[modelID] != sv { + m.mu.Unlock() + return "", ErrServerNotRunning + } + launchSettings = sv.settings + m.mu.Unlock() + preparedExe, args, resolved, preparedHelp, _, _, err := PrepareLaunch(rt, help, mdl.PrimaryPath, launchSettings) + if err != nil { + return "", err + } + if preparedExe != exe || preparedHelp != help || resolved != settings { + return "", fmt.Errorf("effective prepared launch changed; cache identity unavailable until reload") + } + sourceFiles, err := cacheLaunchFiles(exe, args) + if err != nil { + return "", err + } + if !reflect.DeepEqual(sourceFiles, snapshot.SourceFiles) { + return "", fmt.Errorf("checkpoint, runtime, or auto-paired component changed since model load") + } + loadedFiles, err := cacheLaunchFiles(exe, snapshot.Args) + if err != nil { + return "", err + } + if !reflect.DeepEqual(loadedFiles, snapshot.LoadedFiles) { + return "", fmt.Errorf("loaded runtime or component files changed since model load") + } + helpNow, err := m.rt.HelpOutput(runtimeID) + if err != nil { + return "", err + } + if strings.TrimSpace(helpNow) != "" && helpNow != help { + return "", fmt.Errorf("runtime help identity changed") + } + apiDoc, err := m.Capabilities(context.Background(), modelID) + if err != nil { + return "", err + } + caps := normalizeAPICapabilities(apiDoc, modelID, runtimeID) + if !caps.Known { + return "", fmt.Errorf("workflow cache identity requires known loaded-model API capabilities: %s", caps.Reason) + } + identity := struct { + Version int + Runtime *runtimes.Runtime + Settings LoadSettings + Args []string + Help string + API *APICapabilities + APIDocument map[string]any + SourceFiles, LoadedFiles []cacheFile + }{Version: 1, Runtime: rt, Settings: settings, Args: cacheArgs(args), Help: help, API: caps, APIDocument: apiDoc, SourceFiles: sourceFiles, LoadedFiles: loadedFiles} + // Runtime UI fields (Preferred, UsedByModels) do not affect execution. + identity.Runtime = &runtimes.Runtime{ID: rt.ID, Source: rt.Source, ReleaseID: rt.ReleaseID, Build: rt.Build, Commit: rt.Commit, Backend: rt.Backend, ExecutablePath: exe, VersionOutput: rt.VersionOutput, ArchiveSHA256: rt.ArchiveSHA256} + raw, err := json.Marshal(identity) + if err != nil { + return "", err + } + m.mu.Lock() + unchanged := m.servers[modelID] == sv && sv.ready && sv.port == port && sv.cacheSnapshot != nil && reflect.DeepEqual(*sv.cacheSnapshot, snapshot) + m.mu.Unlock() + if !unchanged { + return "", fmt.Errorf("diffusion launch changed while computing workflow cache identity") + } + sum := sha256.Sum256(raw) + return hex.EncodeToString(sum[:]), nil +} + +func cacheArgs(args []string) []string { + out := []string{} + for i := 0; i < len(args); i++ { + if args[i] == "--api-key" || args[i] == "--listen-port" { + i++ + continue + } + out = append(out, args[i]) + } + return out +} + +func cacheLaunchFiles(exe string, args []string) ([]cacheFile, error) { + paths := map[string]bool{exe: false} + fileFlags := map[string]bool{ + "--model": false, "--diffusion-model": false, "--high-noise-diffusion-model": false, + "--vae": false, "--taesd": false, "--tae": false, "--control-net": false, "--ip-adapter": false, + "--llm": false, "--llm_vision": false, "--t5xxl": false, "--clip_l": false, "--clip_g": false, "--clip_vision": false, + "--tokenizer": false, "--motion-module": false, "--upscale-model": false, + "--lora-model-dir": true, "--hires-upscalers-dir": true, "--embd-dir": true, + } + // Only the final occurrence of a component flag is effective. + effective := map[string]string{} + for i := 0; i+1 < len(args); i++ { + if _, ok := fileFlags[args[i]]; ok { + effective[args[i]] = args[i+1] + } + } + for flag, path := range effective { + paths[path] = fileFlags[flag] + } + var files []cacheFile + for path, dir := range paths { + file, err := statCacheFile(path) + if err != nil { + return nil, err + } + files = append(files, file) + if dir { + err = filepath.WalkDir(path, func(child string, d os.DirEntry, err error) error { + if err != nil { + return err + } + if child == path { + return nil + } + if d.Type()&os.ModeSymlink != 0 { + return fmt.Errorf("cache identity cannot verify symlinked component catalog") + } + if len(files) > 4096 { + return fmt.Errorf("component catalog exceeds cache identity entry bound") + } + f, err := statCacheFile(child) + if err != nil { + return err + } + files = append(files, f) + return nil + }) + if err != nil { + return nil, err + } + } + } + sort.Slice(files, func(i, j int) bool { return files[i].Path < files[j].Path }) + return files, nil +} + +func statCacheFile(path string) (cacheFile, error) { + if !filepath.IsAbs(path) { + return cacheFile{}, fmt.Errorf("cache input path must be absolute") + } + real, err := filepath.EvalSymlinks(path) + if err != nil { + return cacheFile{}, err + } + st, err := os.Stat(real) + if err != nil { + return cacheFile{}, err + } + if !st.Mode().IsRegular() && !st.IsDir() { + return cacheFile{}, fmt.Errorf("cache input must be a regular file or directory") + } + // Device/inode (where provided) detect replacement with preserved size + // and mtime. Avoid atime, which changes when the model is read. + fileID := "" + v := reflect.ValueOf(st.Sys()) + if v.IsValid() && v.Kind() == reflect.Pointer && !v.IsNil() { + v = v.Elem() + } + if v.IsValid() && v.Kind() == reflect.Struct { + for _, name := range []string{"Dev", "Ino", "VolumeSerialNumber", "FileIndexHigh", "FileIndexLow"} { + f := v.FieldByName(name) + if f.IsValid() && f.CanInterface() { + fileID += fmt.Sprintf("%s=%v;", name, f.Interface()) + } + } + } + return cacheFile{Path: path, Resolved: real, Size: st.Size(), Modified: st.ModTime().UnixNano(), Mode: uint32(st.Mode()), FileID: fileID}, nil +} diff --git a/internal/mediagen/cache_test.go b/internal/mediagen/cache_test.go new file mode 100644 index 0000000..a11fb15 --- /dev/null +++ b/internal/mediagen/cache_test.go @@ -0,0 +1,136 @@ +package mediagen + +import ( + "os" + "path/filepath" + "reflect" + "testing" + + "github.com/openinfer/openinfer-studio/internal/models" + "github.com/openinfer/openinfer-studio/internal/runtimes" +) + +func TestWorkflowCacheIdentityTracksAutoPairedComponentsAndAPIDefaults(t *testing.T) { + doc := decodeCapabilityFixture(t, `{"supported_modes":["img_gen"],"defaults_by_mode":{"img_gen":{"width":512}}}`) + f := newCapabilitySD(t, doc, nil) + m := newJobManager(t, f) + root := t.TempDir() + primary := filepath.Join(root, "qwen-image-2.1.gguf") + vae := filepath.Join(root, "vae", "qwen_image_vae.safetensors") + encoder := filepath.Join(root, "text_encoders", "qwen3vl_8b.safetensors") + exe := filepath.Join(root, "runtime", "sd-server") + help := sdHelpSample + "\n --llm FNAME encoder\n" + for _, path := range []string{primary, vae, encoder, exe} { + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte("fixture"), 0600); err != nil { + t.Fatal(err) + } + } + if err := os.WriteFile(filepath.Join(filepath.Dir(exe), "help.txt"), []byte(help), 0600); err != nil { + t.Fatal(err) + } + if _, err := m.db.Exec(`INSERT INTO runtimes(id,source,installed_at,install_dir,executable_path,version_output) VALUES(?,?,?,?,?,?)`, "rt", "custom-import", now(), filepath.Dir(exe), exe, "sd.cpp commit fixture"); err != nil { + t.Fatal(err) + } + if _, err := m.db.Exec(`INSERT INTO models(id,alias,primary_path,created_at,updated_at) VALUES(?,?,?,?,?)`, "model-1", "qwen", primary, now(), now()); err != nil { + t.Fatal(err) + } + m.rt = runtimes.NewManager(m.db, filepath.Dir(exe), nil, nil, nil) + m.lib = models.NewLibrary(m.db, root, nil, nil) + rt, err := m.rt.Get("rt") + if err != nil { + t.Fatal(err) + } + _, args, resolved, _, _, _, err := PrepareLaunch(rt, help, primary, LoadSettings{}) + if err != nil { + t.Fatal(err) + } + if resolved.VAE != vae || resolved.LLM != encoder { + t.Fatalf("auto-paired components missing: %+v", resolved) + } + files, err := cacheLaunchFiles(exe, args) + if err != nil { + t.Fatal(err) + } + sv := m.servers["model-1"] + sv.runtimeID = "rt" + sv.exe = exe + sv.modelPath = primary + sv.help = help + sv.resolved = resolved + sv.cacheSnapshot = &launchCacheSnapshot{Settings: resolved, Args: cacheArgs(args), SourceFiles: files, LoadedFiles: files} + first, err := m.WorkflowCacheIdentity("model-1", nil) + if err != nil || first == "" { + t.Fatalf("identity=%q err=%v", first, err) + } + again, err := m.WorkflowCacheIdentity("model-1", nil) + if err != nil || again != first { + t.Fatalf("stable launch missed cache: %s %v", again, err) + } + doc["defaults_by_mode"].(map[string]any)["img_gen"].(map[string]any)["width"] = float64(768) + changed, err := m.WorkflowCacheIdentity("model-1", nil) + if err != nil || changed == first { + t.Fatalf("API defaults did not invalidate: %s %v", changed, err) + } + if _, err := m.WorkflowCacheIdentity("model-1", &LoadOverrides{VAE: "/different"}); err == nil { + t.Fatal("different launch override accepted") + } + for _, path := range []string{vae, encoder, exe, primary} { + before, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + st, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte("changed fixture"), 0600); err != nil { + t.Fatal(err) + } + if _, err := m.WorkflowCacheIdentity("model-1", nil); err == nil { + t.Fatalf("changed loaded file accepted: %s", path) + } + if err := os.WriteFile(path, before, 0600); err != nil { + t.Fatal(err) + } + if err := os.Chtimes(path, st.ModTime(), st.ModTime()); err != nil { + t.Fatal(err) + } + } +} + +func TestCacheFileIdentityIncludesCatalogContentsAndExcludesSecrets(t *testing.T) { + root := t.TempDir() + exe := filepath.Join(root, "sd-server") + loraDir := filepath.Join(root, "loras") + if err := os.WriteFile(exe, []byte("runtime"), 0600); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(loraDir, 0700); err != nil { + t.Fatal(err) + } + args := []string{"--lora-model-dir", loraDir, "--api-key", "secret", "--listen-port", "1234"} + before, err := cacheLaunchFiles(exe, args) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(loraDir, "film.safetensors"), []byte("weights"), 0600); err != nil { + t.Fatal(err) + } + after, err := cacheLaunchFiles(exe, args) + if err != nil { + t.Fatal(err) + } + if reflect.DeepEqual(before, after) { + t.Fatal("catalog contents excluded from identity") + } + if got := cacheArgs(args); !reflect.DeepEqual(got, []string{"--lora-model-dir", loraDir}) { + t.Fatalf("ephemeral key/port in cache args: %v", got) + } + m := NewManager(nil, nil, nil, nil, nil, nil) + if _, err := m.WorkflowCacheIdentity("unknown", nil); err == nil { + t.Fatal("unknown cache identity accepted") + } +} diff --git a/internal/mediagen/defaults_test.go b/internal/mediagen/defaults_test.go new file mode 100644 index 0000000..aa9c25f --- /dev/null +++ b/internal/mediagen/defaults_test.go @@ -0,0 +1,77 @@ +package mediagen + +import ( + "encoding/json" + "reflect" + "testing" +) + +func defaultsJSON(t *testing.T, d WorkflowDefaults) map[string]any { + t.Helper() + raw, err := json.Marshal(d) + if err != nil { + t.Fatal(err) + } + var out map[string]any + if err = json.Unmarshal(raw, &out); err != nil { + t.Fatal(err) + } + return out +} + +func TestWorkflowDefaultsNormalizeNativeSamplingAndExplicitZeroes(t *testing.T) { + doc := decodeCapabilityFixture(t, `{ + "supported_modes":["img_gen","vid_gen"],"current_mode":"img_gen", + "defaults_by_mode":{ + "img_gen":{"width":1024,"height":768,"batch_count":1,"strength":0,"sample_params":{"sample_steps":28,"sample_method":"euler","scheduler":"discrete","guidance":{"txt_cfg":0,"img_cfg":9,"distilled_guidance":3.5}}}, + "vid_gen":{"width":832,"height":480,"video_frames":33,"fps":16,"sample_params":{"sample_steps":0,"sample_method":"","guidance":{"txt_cfg":1,"distilled_guidance":0}}}, + "invented":{"width":100} + },"defaults":{"width":64,"sample_params":{"guidance":{"txt_cfg":99}}} + }`) + c := normalizeAPICapabilities(doc, "m", "r") + image := defaultsJSON(t, c.DefaultsByMode["img_gen"]) + want := map[string]any{"width": float64(1024), "height": float64(768), "batch": float64(1), "strength": float64(0), "steps": float64(28), "sampler": "euler", "scheduler": "discrete", "cfg": float64(0), "guidance": 3.5} + if !reflect.DeepEqual(image, want) { + t.Fatalf("image defaults=%v want=%v", image, want) + } + video := defaultsJSON(t, c.DefaultsByMode["vid_gen"]) + if video["frames"] != float64(33) || video["fps"] != float64(16) || video["guidance"] != float64(0) || video["steps"] != float64(0) || video["sampler"] != "" { + t.Fatalf("video defaults=%v", video) + } + if _, ok := video["batch"]; ok { + t.Fatal("missing batch default invented") + } + if _, ok := video["scheduler"]; ok { + t.Fatal("missing scheduler default invented") + } + if _, ok := c.DefaultsByMode["invented"]; ok { + t.Fatal("defaults for unsupported mode advertised") + } +} + +func TestWorkflowDefaultsLegacyMirrorOnlyAppliesToCurrentMode(t *testing.T) { + for _, tc := range []struct { + name, fixture string + want map[string]map[string]any + }{ + {"legacy", `{"current_mode":"img_gen","defaults":{"width":512,"strength":0}}`, map[string]map[string]any{"img_gen": {"width": float64(512), "strength": float64(0)}}}, + {"modern modes legacy mirror", `{"supported_modes":["img_gen","vid_gen"],"current_mode":"vid_gen","defaults":{"video_frames":17}}`, map[string]map[string]any{"vid_gen": {"frames": float64(17)}}}, + {"no explicit current mode", `{"defaults":{"width":512}}`, map[string]map[string]any{}}, + {"empty modern defeats mirror", `{"supported_modes":[],"current_mode":"img_gen","defaults":{"width":512}}`, map[string]map[string]any{}}, + {"modern defaults defeat mirror", `{"supported_modes":["img_gen"],"current_mode":"img_gen","defaults_by_mode":{},"defaults":{"width":512}}`, map[string]map[string]any{}}, + {"malformed modern defeats mirror", `{"supported_modes":["img_gen"],"current_mode":"img_gen","defaults_by_mode":null,"defaults":{"width":512}}`, map[string]map[string]any{}}, + {"malformed values omitted", `{"current_mode":"img_gen","defaults":{"width":"512","fps":2.5,"strength":false,"sample_params":{"sample_steps":0}}}`, map[string]map[string]any{"img_gen": {"steps": float64(0)}}}, + {"automatic enum sentinels", `{"current_mode":"img_gen","defaults":{"sample_params":{"sample_method":"default","scheduler":"default"}}}`, map[string]map[string]any{"img_gen": {"sampler": "", "scheduler": ""}}}, + } { + t.Run(tc.name, func(t *testing.T) { + c := normalizeAPICapabilities(decodeCapabilityFixture(t, tc.fixture), "m", "r") + got := map[string]map[string]any{} + for mode, d := range c.DefaultsByMode { + got[mode] = defaultsJSON(t, d) + } + if !reflect.DeepEqual(got, tc.want) { + t.Fatalf("defaults=%v want=%v", got, tc.want) + } + }) + } +} diff --git a/internal/mediagen/gateway.go b/internal/mediagen/gateway.go new file mode 100644 index 0000000..0419cd2 --- /dev/null +++ b/internal/mediagen/gateway.go @@ -0,0 +1,125 @@ +package mediagen + +import ( + "context" + "crypto/subtle" + "errors" + "io" + "log" + "net" + "net/http" + "net/http/httputil" + "net/url" + "strconv" + "strings" + "sync" + "time" +) + +// sdGateway is the authenticated managed endpoint for a stock sd-server. +// It has exactly one fixed loopback destination and no general proxy API. +type sdGateway struct { + listener net.Listener + server *http.Server + transport *http.Transport + cancel context.CancelFunc + once sync.Once + done chan struct{} +} + +func startSDGateway(listener net.Listener, nativePort int, key string) (*sdGateway, error) { + if listener == nil { + return nil, errors.New("invalid sd-server gateway endpoint") + } + addr, ok := listener.Addr().(*net.TCPAddr) + if !ok || !addr.IP.Equal(net.IPv4(127, 0, 0, 1)) || addr.Port == 0 || nativePort < 1 || nativePort > 65535 || addr.Port == nativePort || key == "" { + return nil, errors.New("invalid sd-server gateway endpoint") + } + targetAddr := net.JoinHostPort("127.0.0.1", strconv.Itoa(nativePort)) + target := &url.URL{Scheme: "http", Host: targetAddr} + transport := http.DefaultTransport.(*http.Transport).Clone() + transport.Proxy = nil + dialer := &net.Dialer{Timeout: 5 * time.Second, KeepAlive: 30 * time.Second} + transport.DialContext = func(ctx context.Context, network, address string) (net.Conn, error) { + if network != "tcp" || address != targetAddr { + return nil, errors.New("invalid sd-server gateway destination") + } + return dialer.DialContext(ctx, "tcp4", targetAddr) + } + proxy := &httputil.ReverseProxy{ + Transport: transport, + Rewrite: func(r *httputil.ProxyRequest) { + r.SetURL(target) + r.Out.Host = targetAddr + r.Out.URL.User = nil + r.Out.URL.Opaque = "" + r.Out.Header.Del("Authorization") + r.Out.Header.Del("Proxy-Authorization") + r.Out.Header.Del("Cookie") + r.Out.Trailer = nil + }, + ModifyResponse: func(resp *http.Response) error { + if resp.StatusCode >= 300 && resp.StatusCode < 400 || resp.StatusCode == http.StatusSwitchingProtocols { + return errors.New("sd-server gateway refuses redirects and upgrades") + } + return nil + }, + ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) { + http.Error(w, "sd-server unavailable", http.StatusBadGateway) + }, + ErrorLog: log.New(io.Discard, "", 0), + } + ctx, cancel := context.WithCancel(context.Background()) + g := &sdGateway{listener: listener, transport: transport, cancel: cancel, done: make(chan struct{})} + expected := []byte("Bearer " + key) + g.server = &http.Server{ + ReadHeaderTimeout: 5 * time.Second, + ReadTimeout: 2 * time.Minute, + WriteTimeout: 10 * time.Minute, + IdleTimeout: 30 * time.Second, + MaxHeaderBytes: 64 << 10, + ErrorLog: log.New(io.Discard, "", 0), + BaseContext: func(net.Listener) context.Context { return ctx }, + Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodConnect || r.Header.Get("Upgrade") != "" { + http.Error(w, "method not supported", http.StatusMethodNotAllowed) + return + } + keys := r.Header.Values("Authorization") + if len(keys) != 1 || subtle.ConstantTimeCompare([]byte(keys[0]), expected) != 1 { + w.Header().Set("WWW-Authenticate", "Bearer") + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + if !strings.HasPrefix(r.URL.Path, "/sdcpp/v1/") && !strings.HasPrefix(r.URL.Path, "/v1/") && !strings.HasPrefix(r.URL.Path, "/sdapi/v1/") { + http.NotFound(w, r) + return + } + r.Body = http.MaxBytesReader(w, r.Body, 128<<20) + // Native model upscaling can take minutes. Bound the adapter's + // lifetime while preserving the caller's shorter cancellation. + requestCtx, requestCancel := context.WithTimeout(r.Context(), 10*time.Minute) + defer requestCancel() + proxy.ServeHTTP(w, r.WithContext(requestCtx)) + }), + } + go func() { + defer close(g.done) + _ = g.server.Serve(listener) + }() + return g, nil +} + +// Close is idempotent and cancels active upstream requests as well as +// closing the managed listener. It never waits for native GPU work. +func (g *sdGateway) Close() { + if g == nil { + return + } + g.once.Do(func() { + g.cancel() + _ = g.server.Close() + _ = g.listener.Close() + g.transport.CloseIdleConnections() + }) +} diff --git a/internal/mediagen/gateway_test.go b/internal/mediagen/gateway_test.go new file mode 100644 index 0000000..0b0a9a4 --- /dev/null +++ b/internal/mediagen/gateway_test.go @@ -0,0 +1,404 @@ +package mediagen + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "reflect" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/openinfer/openinfer-studio/internal/models" + "github.com/openinfer/openinfer-studio/internal/runtimes" +) + +func gatewayForSD(t *testing.T, native *httptest.Server) (*sdGateway, int) { + t.Helper() + listener, err := net.Listen("tcp4", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + port := listener.Addr().(*net.TCPAddr).Port + gateway, err := startSDGateway(listener, (&fakeSD{srv: native}).port(t), "test-key") + if err != nil { + _ = listener.Close() + t.Fatal(err) + } + t.Cleanup(gateway.Close) + return gateway, port +} + +func gatewayRequest(t *testing.T, client *http.Client, port int, method, path string, headers http.Header, body string) *http.Response { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + t.Cleanup(cancel) + req, err := http.NewRequestWithContext(ctx, method, fmt.Sprintf("http://127.0.0.1:%d%s", port, path), strings.NewReader(body)) + if err != nil { + t.Fatal(err) + } + req.Header = headers.Clone() + resp, err := client.Do(req) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { resp.Body.Close() }) + return resp +} + +func TestSDGatewayAuthAndNativeForwarding(t *testing.T) { + type forwarded struct{ method, path, query, host, auth, proxyAuth, cookie, forwarded, body string } + requests := make(chan forwarded, 8) + native := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + requests <- forwarded{r.Method, r.URL.Path, r.URL.RawQuery, r.Host, r.Header.Get("Authorization"), r.Header.Get("Proxy-Authorization"), r.Header.Get("Cookie"), r.Header.Get("X-Forwarded-Host"), string(body)} + w.Header().Set("Content-Type", "application/json") + if r.Method == http.MethodGet { + _, _ = io.WriteString(w, `{"current_mode":"img_gen","supported_modes":["img_gen"],"features_by_mode":{"img_gen":{"mask_image":true}},"samplers":["euler"],"schedulers":["discrete"]}`) + } else { + w.WriteHeader(http.StatusAccepted) + _, _ = io.WriteString(w, `{"id":"native-job"}`) + } + })) + t.Cleanup(native.Close) + gateway, port := gatewayForSD(t, native) + client := sdHTTPClient() + t.Cleanup(client.CloseIdleConnections) + for _, headers := range []http.Header{ + {}, {"Authorization": {"Bearer wrong"}}, {"Authorization": {"test-key"}}, + {"Authorization": {"Bearer test-key", "Bearer test-key"}}, + } { + resp := gatewayRequest(t, client, port, http.MethodGet, "/sdcpp/v1/capabilities", headers, "") + if resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("unauthenticated status=%d", resp.StatusCode) + } + resp.Body.Close() + } + for _, method := range []string{http.MethodConnect, http.MethodGet} { + headers := http.Header{"Authorization": {"Bearer test-key"}} + if method == http.MethodGet { + headers.Set("Upgrade", "websocket") + } + resp := gatewayRequest(t, client, port, method, "/sdcpp/v1/capabilities", headers, "") + if resp.StatusCode != http.StatusMethodNotAllowed { + t.Fatalf("tunnel/upgrade accepted: %d", resp.StatusCode) + } + resp.Body.Close() + } + select { + case <-requests: + t.Fatal("rejected request reached native server") + default: + } + m := NewManager(nil, nil, nil, nil, nil, nil) + m.servers["model"] = &server{modelID: "model", port: port, nativePort: (&fakeSD{srv: native}).port(t), apiKey: "test-key", gateway: gateway, ready: true, state: ServerReady} + caps, err := m.GenerationCapabilities("model") + if err != nil || !caps.Known || !caps.SupportsFeature("img_gen", "mask_image") { + t.Fatalf("capabilities not forwarded: caps=%+v err=%v", caps, err) + } + capRequest := <-requests + if capRequest.auth != "" || capRequest.path != "/sdcpp/v1/capabilities" { + t.Fatalf("native capability request=%+v", capRequest) + } + req, _ := http.NewRequest(http.MethodPost, fmt.Sprintf("http://127.0.0.1:%d/sdcpp/v1/img_gen?mode=test", port), strings.NewReader(`{"prompt":"fixture"}`)) + req.Host = "untrusted.invalid" + req.Header.Set("Proxy-Authorization", "Bearer test-key") + req.Header.Set("Cookie", "private=test-key") + req.Header.Set("X-Forwarded-Host", "untrusted.invalid") + resp, err := m.doSD(req, port) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + var result map[string]string + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil || result["id"] != "native-job" || resp.StatusCode != http.StatusAccepted { + t.Fatalf("native response not preserved: %v %v", result, err) + } + got := <-requests + if got.method != http.MethodPost || got.query != "mode=test" || got.body != `{"prompt":"fixture"}` || got.auth != "" || got.proxyAuth != "" || got.cookie != "" || got.forwarded != "" || got.host != strings.TrimPrefix(native.URL, "http://") { + t.Fatalf("native request=%+v", got) + } + nativePort := (&fakeSD{srv: native}).port(t) + rawReq, _ := http.NewRequest(http.MethodGet, native.URL+"/sdcpp/v1/capabilities", nil) + if _, err := m.doSD(rawReq, nativePort); !errors.Is(err, ErrServerNotRunning) { + t.Fatalf("manager reached raw native port: %v", err) + } +} + +func TestSDGatewayRefusesRedirectsAndEnvironmentProxies(t *testing.T) { + var otherCalls atomic.Int32 + other := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { otherCalls.Add(1) })) + t.Cleanup(other.Close) + t.Setenv("HTTP_PROXY", other.URL) + t.Setenv("HTTPS_PROXY", other.URL) + t.Setenv("ALL_PROXY", other.URL) + native := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, other.URL, http.StatusTemporaryRedirect) + })) + t.Cleanup(native.Close) + gateway, port := gatewayForSD(t, native) + client := sdHTTPClient() + t.Cleanup(client.CloseIdleConnections) + resp := gatewayRequest(t, client, port, http.MethodPost, "/sdcpp/v1/img_gen", http.Header{"Authorization": {"Bearer test-key"}}, "{}") + if resp.StatusCode != http.StatusBadGateway || resp.Header.Get("Location") != "" || otherCalls.Load() != 0 || gateway.transport.Proxy != nil || client.Transport.(*http.Transport).Proxy != nil { + t.Fatal("gateway redirected or used an environment proxy") + } +} + +func TestSDGatewayCancellationAndClosure(t *testing.T) { + for _, closeGateway := range []bool{false, true} { + t.Run(fmt.Sprintf("close=%t", closeGateway), func(t *testing.T) { + started, canceled := make(chan struct{}), make(chan struct{}) + native := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = io.Copy(io.Discard, r.Body) + close(started) + <-r.Context().Done() + close(canceled) + })) + t.Cleanup(native.Close) + gateway, port := gatewayForSD(t, native) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + req, _ := http.NewRequestWithContext(ctx, http.MethodPost, fmt.Sprintf("http://127.0.0.1:%d/sdcpp/v1/upscale", port), strings.NewReader("{}")) + req.Header.Set("Authorization", "Bearer test-key") + client := sdHTTPClient() + t.Cleanup(client.CloseIdleConnections) + finished := make(chan error, 1) + go func() { + resp, err := client.Do(req) + if resp != nil { + resp.Body.Close() + } + finished <- err + }() + select { + case <-started: + case <-time.After(2 * time.Second): + t.Fatal("native request did not start") + } + if closeGateway { + gateway.Close() + } else { + cancel() + } + select { + case <-canceled: + case <-time.After(2 * time.Second): + t.Fatal("native HTTP request was not canceled") + } + select { + case err := <-finished: + if err == nil { + t.Fatal("canceled request succeeded") + } + case <-time.After(2 * time.Second): + t.Fatal("public request did not finish") + } + gateway.Close() + gateway.Close() + assertGatewayClosed(t, gateway) + }) + } +} + +func assertGatewayClosed(t *testing.T, gateway *sdGateway) { + t.Helper() + select { + case <-gateway.done: + case <-time.After(time.Second): + t.Fatal("gateway serve loop did not close") + } + conn, err := net.DialTimeout("tcp4", gateway.listener.Addr().String(), 100*time.Millisecond) + if err == nil { + conn.Close() + t.Fatal("closed gateway still accepts connections") + } +} + +func TestSDGatewayLifecycleAndClaimShutdown(t *testing.T) { + for _, action := range []string{"stop", "abort", "crash", "replace", "stopped claim", "canceled claim"} { + t.Run(action, func(t *testing.T) { + native := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = io.WriteString(w, "{}") })) + t.Cleanup(native.Close) + gateway, port := gatewayForSD(t, native) + m := NewManager(nil, nil, nil, nil, nil, nil) + ctx, cancel := context.WithCancel(context.Background()) + sv := &server{modelID: "model", port: port, gateway: gateway, apiKey: "test-key", ready: true, state: ServerReady, launchCtx: ctx, cancel: cancel} + m.servers["model"] = sv + switch action { + case "stop": + m.StopServer("model") + case "abort": + m.abortServer("model", port, "fixture abort") + case "crash": + m.handleServerExit("model", sv) + case "replace": + replacementGateway, replacementPort := gatewayForSD(t, native) + m.servers["model"] = &server{modelID: "model", port: replacementPort, gateway: replacementGateway, ready: true, state: ServerReady} + m.closeSDServer(sv) + m.handleServerExit("model", sv) + select { + case <-replacementGateway.done: + t.Fatal("old launch closed replacement gateway") + default: + } + case "stopped claim", "canceled claim": + sv.state = ServerStarting + if action == "stopped claim" { + m.StopServer("model") + } else { + cancel() + } + if err := m.attachSDTransport(sv, &sdEndpoint{port: port, key: "test-key", gateway: gateway}); !errors.Is(err, context.Canceled) { + t.Fatalf("dead claim accepted endpoint: %v", err) + } + gateway.Close() + } + assertGatewayClosed(t, gateway) + }) + } +} + +func TestSDGatewayStopCancelsReadinessRequest(t *testing.T) { + started, canceled := make(chan struct{}), make(chan struct{}) + native := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + close(started) + <-r.Context().Done() + close(canceled) + })) + t.Cleanup(native.Close) + gateway, port := gatewayForSD(t, native) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + m := NewManager(nil, nil, nil, nil, nil, nil) + sv := &server{modelID: "model", port: port, gateway: gateway, apiKey: "test-key", state: ServerStarting, launchCtx: ctx, cancel: cancel} + m.servers["model"] = sv + finished := make(chan error, 1) + go func() { finished <- m.waitReady(sv, make(chan struct{}), time.Minute) }() + select { + case <-started: + case <-time.After(time.Second): + t.Fatal("readiness request did not start") + } + m.StopServer("model") + select { + case err := <-finished: + if !errors.Is(err, context.Canceled) { + t.Fatalf("stopped readiness returned %v", err) + } + case <-time.After(time.Second): + t.Fatal("stop did not interrupt readiness") + } + select { + case <-canceled: + case <-time.After(time.Second): + t.Fatal("stopped readiness still reached native runtime") + } + assertGatewayClosed(t, gateway) +} + +func TestSDTransportStockFlagsAndNativeAuth(t *testing.T) { + model := filepath.Join(t.TempDir(), "model.safetensors") + if err := os.WriteFile(model, []byte("fixture"), 0600); err != nil { + t.Fatal(err) + } + for _, tc := range []struct { + name, extra string + native bool + }{ + {"stock", "", false}, + {"key file only", "\n --api-key-file FILE\n", false}, + {"prose", "\nAuthentication is possible with --api-key TOKEN\n", false}, + {"other option description", "\n --other TEXT refers to --api-key\n", false}, + {"removed", "\n --api-key TOKEN has been removed\n", false}, + {"native", "\n -k, --api-key TOKEN\n", true}, + } { + t.Run(tc.name, func(t *testing.T) { + help := sdHelpSample + tc.extra + base, err := BuildServerArgs(LoadSettings{}, model, false, ParseSDCapabilities(help), help, "127.0.0.1", 0) + if err != nil { + t.Fatal(err) + } + args, endpoint, err := configureSDTransport(help, base) + if err != nil { + t.Fatal(err) + } + t.Cleanup(endpoint.close) + if (endpoint.gateway == nil) != tc.native || len(endpoint.key) != 64 || endpoint.port == 0 || endpoint.nativePort == 0 || (endpoint.port == endpoint.nativePort) != tc.native { + t.Fatal("incorrect native/gateway endpoint selection") + } + if strings.Contains(strings.Join(args, " "), "--api-key") != tc.native || !reflect.DeepEqual(cacheArgs(args), cacheArgs(base)) { + t.Fatal("unsupported flag or ephemeral transport details in cache args") + } + if strings.Contains(strings.Join(redactSDArgs(args), " "), endpoint.key) { + t.Fatal("API key present in logged args") + } + if !tc.native { + return + } + listener, err := net.Listen("tcp4", fmt.Sprintf("127.0.0.1:%d", endpoint.nativePort)) + if err != nil { + t.Fatal(err) + } + native := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer "+endpoint.key { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + _, _ = io.WriteString(w, `{"supported_modes":["img_gen"]}`) + })) + native.Listener.Close() + native.Listener = listener + native.Start() + t.Cleanup(native.Close) + m := NewManager(nil, nil, nil, nil, nil, nil) + m.servers["model"] = &server{modelID: "model", port: endpoint.port, apiKey: endpoint.key, ready: true, state: ServerReady} + caps, err := m.GenerationCapabilities("model") + if err != nil || !caps.Known { + t.Fatalf("authenticated direct capabilities failed: %v", err) + } + }) + } +} + +func TestSDGatewayClosesAfterStartupFailure(t *testing.T) { + f := newFakeSD(t, nil) + m := newJobManager(t, f) + m.StopServer("model-1") + root := t.TempDir() + exe, model := filepath.Join(root, "sd-server"), filepath.Join(root, "model.safetensors") + for _, path := range []string{exe, model} { + // An unexecutable fixture fails after endpoint allocation and never + // loads weights or invokes an installed runtime. + if err := os.WriteFile(path, []byte("fixture"), 0600); err != nil { + t.Fatal(err) + } + } + if err := os.WriteFile(filepath.Join(root, "help.txt"), []byte(sdHelpSample), 0600); err != nil { + t.Fatal(err) + } + if _, err := m.db.Exec(`INSERT INTO runtimes(id,source,installed_at,install_dir,executable_path,version_output) VALUES(?,?,?,?,?,?)`, "rt", "custom-import", now(), root, exe, "fixture"); err != nil { + t.Fatal(err) + } + if _, err := m.db.Exec(`INSERT INTO models(id,alias,primary_path,created_at,updated_at) VALUES(?,?,?,?,?)`, "model-1", "fixture", model, now(), now()); err != nil { + t.Fatal(err) + } + m.rt = runtimes.NewManager(m.db, root, nil, nil, nil) + m.lib = models.NewLibrary(m.db, root, nil, nil) + if _, err := m.EnsureServer("model-1", LoadSettings{RuntimeID: "rt"}); err == nil { + t.Fatal("unexecutable fixture launched") + } + sv := m.servers["model-1"] + if sv.gateway == nil || sv.port != 0 || sv.ready || sv.state != ServerFailed { + t.Fatalf("failed startup retained endpoint: gateway=%t port=%d state=%s", sv.gateway != nil, sv.port, sv.state) + } + assertGatewayClosed(t, sv.gateway) +} diff --git a/internal/mediagen/inputs.go b/internal/mediagen/inputs.go new file mode 100644 index 0000000..47867f7 --- /dev/null +++ b/internal/mediagen/inputs.go @@ -0,0 +1,350 @@ +package mediagen + +import ( + "bytes" + "encoding/base64" + "encoding/binary" + "fmt" + "image" + _ "image/jpeg" + _ "image/png" + "io" + "math" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" +) + +const ( + maxReferenceImages = 8 + maxInputBytes = 64 << 20 + maxInputDimension = 8192 +) + +type imageInput struct{ field, inline, path string } + +func generationInputs(p GenerateParams) []imageInput { + inputs := []imageInput{ + {"init_image", p.InitImage, p.InitImagePath}, {"mask_image", p.MaskImage, p.MaskImagePath}, + {"control_image", p.ControlImage, p.ControlImagePath}, + } + for _, s := range p.RefImages { + inputs = append(inputs, imageInput{field: "ref_images", inline: s}) + } + for _, s := range p.RefImagePaths { + inputs = append(inputs, imageInput{field: "ref_image_paths", path: s}) + } + return inputs +} + +func validateGenerationInputs(p GenerateParams) error { + if len(p.Prompt) > 8000 || len(p.NegativePrompt) > 8000 { + return fmt.Errorf("prompt exceeds 8000 byte limit") + } + for _, f := range []struct { + name string + value float64 + }{{"cfg_scale", p.CFGScale}, {"guidance", p.Guidance}, {"strength", p.Strength}, {"control_strength", p.ControlStrength}} { + if math.IsNaN(f.value) || math.IsInf(f.value, 0) { + return fmt.Errorf("%s must be finite", f.name) + } + } + if p.ControlStrength < 0 || p.ControlStrength > 10 { + return fmt.Errorf("control_strength must be between 0 and 10") + } + if p.ControlStrength != 0 && p.ControlImage == "" && p.ControlImagePath == "" { + return fmt.Errorf("control_strength requires control_image") + } + if (p.MaskImage != "" || p.MaskImagePath != "") && p.InitImage == "" && p.InitImagePath == "" { + return fmt.Errorf("mask_image requires init_image") + } + if len(p.RefImages)+len(p.RefImagePaths) > maxReferenceImages { + return fmt.Errorf("ref_images exceeds %d image limit", maxReferenceImages) + } + if len(p.RefImages) > 0 && len(p.RefImagePaths) > 0 { + return fmt.Errorf("use either ref_images or ref_image_paths") + } + var inlineTotal int + var total int + for _, input := range generationInputs(p) { + if input.inline == "" && input.path == "" { + continue + } + if input.inline != "" && input.path != "" { + return fmt.Errorf("use either %s or %s_path", input.field, input.field) + } + inlineTotal += len(input.inline) + if inlineTotal > maxInitImageInlineBytes { + return fmt.Errorf("inline images exceed %d MiB combined limit; use local image paths", maxInitImageInlineBytes>>20) + } + raw, _, err := readImageInput(input) + if err != nil { + return err + } + total += len(raw) + if total > maxInputBytes { + return fmt.Errorf("image inputs exceed %d MiB combined limit", maxInputBytes>>20) + } + } + for _, s := range append(append([]string(nil), p.RefImages...), p.RefImagePaths...) { + if s == "" { + return fmt.Errorf("reference images must not be empty") + } + } + if len(p.Lora) > 16 { + return fmt.Errorf("lora exceeds 16 entry limit") + } + for _, l := range p.Lora { + if err := validateLora(l); err != nil { + return err + } + } + return nil +} + +func readImageInput(input imageInput) ([]byte, string, error) { + var raw []byte + var err error + if input.path != "" { + if err = validateLocalImagePath(input.path, input.field+"_path"); err != nil { + return nil, "", err + } + f, err := os.Open(input.path) + if err != nil { + return nil, "", fmt.Errorf("%s_path: %w", input.field, err) + } + defer f.Close() + st, err := f.Stat() + if err != nil || !st.Mode().IsRegular() { + return nil, "", fmt.Errorf("%s_path must be a regular file", input.field) + } + raw, err = io.ReadAll(io.LimitReader(f, maxInitImagePathBytes+1)) + if err != nil { + return nil, "", fmt.Errorf("reading %s_path: %w", input.field, err) + } + if len(raw) > maxInitImagePathBytes { + return nil, "", fmt.Errorf("%s_path exceeds %d MiB limit", input.field, maxInitImagePathBytes>>20) + } + } else { + if len(input.inline) > maxInitImageInlineBytes { + return nil, "", fmt.Errorf("%s exceeds %d MiB limit", input.field, maxInitImageInlineBytes>>20) + } + encoded := input.inline + if strings.HasPrefix(encoded, "data:") { + comma := strings.Index(encoded, ",") + if comma < 0 || !strings.HasPrefix(encoded, "data:image/") || !strings.HasSuffix(encoded[:comma], ";base64") { + return nil, "", fmt.Errorf("%s must be a base64 image data URL", input.field) + } + encoded = encoded[comma+1:] + } + raw, err = base64.StdEncoding.DecodeString(encoded) + if err != nil { + return nil, "", fmt.Errorf("%s must contain valid base64", input.field) + } + } + w, h, mime, err := imageInfo(raw) + if err != nil { + return nil, "", fmt.Errorf("%s: %w", input.field, err) + } + if w < 1 || h < 1 || w > maxInputDimension || h > maxInputDimension { + return nil, "", fmt.Errorf("%s dimensions must be 1..%d", input.field, maxInputDimension) + } + if input.path != "" && initImageExts[strings.ToLower(filepath.Ext(input.path))] != mime { + return nil, "", fmt.Errorf("%s_path extension does not match image content", input.field) + } + return raw, mime, nil +} + +// imageInfo reads geometry without decoding the full pixel buffer. PNG and +// JPEG use Go's header decoders; WebP/BMP use their documented file headers. +func imageInfo(raw []byte) (int, int, string, error) { + if cfg, format, err := image.DecodeConfig(bytes.NewReader(raw)); err == nil { + if format == "png" || format == "jpeg" { + return cfg.Width, cfg.Height, "image/" + format, nil + } + } + if len(raw) >= 54 && string(raw[:2]) == "BM" { + dib := binary.LittleEndian.Uint32(raw[14:18]) + if dib >= 40 { + w := int(int32(binary.LittleEndian.Uint32(raw[18:22]))) + h := int(int32(binary.LittleEndian.Uint32(raw[22:26]))) + if h < 0 { + h = -h + } + return w, h, "image/bmp", nil + } + } + if len(raw) >= 30 && string(raw[:4]) == "RIFF" && string(raw[8:12]) == "WEBP" { + u24 := func(b []byte) int { return int(b[0]) | int(b[1])<<8 | int(b[2])<<16 } + switch string(raw[12:16]) { + case "VP8X": + return 1 + u24(raw[24:27]), 1 + u24(raw[27:30]), "image/webp", nil + case "VP8 ": + if bytes.Equal(raw[23:26], []byte{0x9d, 0x01, 0x2a}) { + return int(binary.LittleEndian.Uint16(raw[26:28]) & 0x3fff), int(binary.LittleEndian.Uint16(raw[28:30]) & 0x3fff), "image/webp", nil + } + case "VP8L": + if raw[20] == 0x2f { + b := binary.LittleEndian.Uint32(raw[21:25]) + return int(b&0x3fff) + 1, int((b>>14)&0x3fff) + 1, "image/webp", nil + } + } + } + return 0, 0, "", fmt.Errorf("must contain a readable png/jpeg/webp/bmp image header") +} + +func validateLocalImagePath(path, field string) error { + if !filepath.IsAbs(path) { + return fmt.Errorf("%s must be an absolute path", field) + } + if _, ok := initImageExts[strings.ToLower(filepath.Ext(path))]; !ok { + return fmt.Errorf("%s must be a png/jpg/jpeg/webp/bmp file", field) + } + st, err := os.Stat(path) + if err != nil { + return fmt.Errorf("%s: %w", field, err) + } + if !st.Mode().IsRegular() { + return fmt.Errorf("%s must be a regular file", field) + } + if st.Size() > maxInitImagePathBytes { + return fmt.Errorf("%s exceeds %d MiB limit", field, maxInitImagePathBytes>>20) + } + return nil +} + +func resolveGenerationImages(p GenerateParams) (GenerateParams, error) { + // Revalidate just before reading; disk inputs may have changed during + // model loading. Clone slices before conversion to avoid caller mutation. + if err := validateGenerationInputs(p); err != nil { + return p, err + } + p.RefImages = append([]string(nil), p.RefImages...) + p.Lora = append([]Lora(nil), p.Lora...) + for _, target := range []struct { + path string + dst *string + field string + }{ + {p.InitImagePath, &p.InitImage, "init_image"}, {p.MaskImagePath, &p.MaskImage, "mask_image"}, {p.ControlImagePath, &p.ControlImage, "control_image"}, + } { + if target.path != "" { + raw, mime, err := readImageInput(imageInput{field: target.field, path: target.path}) + if err != nil { + return p, err + } + *target.dst = "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(raw) + } + } + for _, path := range p.RefImagePaths { + raw, mime, err := readImageInput(imageInput{field: "ref_image", path: path}) + if err != nil { + return p, err + } + p.RefImages = append(p.RefImages, "data:"+mime+";base64,"+base64.StdEncoding.EncodeToString(raw)) + } + return p, nil +} + +func validateLora(l Lora) error { + if strings.TrimSpace(l.Path) == "" { + return fmt.Errorf("lora path is required") + } + if math.IsNaN(l.Multiplier) || math.IsInf(l.Multiplier, 0) || l.Multiplier < -10 || l.Multiplier > 10 { + return fmt.Errorf("lora multiplier must be finite and between -10 and 10") + } + return nil +} + +var loraTag = regexp.MustCompile(`(?i)]+)>`) + +func resolveLoraPath(path string, caps *APICapabilities, settings LoadSettings) (string, error) { + if filepath.IsAbs(path) { + ext := strings.ToLower(filepath.Ext(path)) + if ext != ".gguf" && ext != ".safetensors" { + return "", fmt.Errorf("lora path must be gguf or safetensors") + } + st, err := os.Stat(path) + if err != nil { + return "", fmt.Errorf("lora path: %w", err) + } + if !st.Mode().IsRegular() { + return "", fmt.Errorf("lora path must be a regular file") + } + for _, entry := range caps.Loras { + if filepath.IsAbs(entry.Path) && filepath.Clean(entry.Path) == filepath.Clean(path) { + return entry.Path, nil + } + if settings.LoraModelDir != "" && filepath.IsAbs(settings.LoraModelDir) { + candidate := filepath.Join(settings.LoraModelDir, entry.Path) + if filepath.Clean(candidate) == filepath.Clean(path) { + return entry.Path, nil + } + } + } + return "", fmt.Errorf("absolute lora path is not in the loaded runtime's advertised catalog; configure lora_model_dir to include this file") + } + if filepath.Clean(path) == ".." || strings.HasPrefix(filepath.Clean(path), ".."+string(filepath.Separator)) { + return "", fmt.Errorf("lora path must stay within its configured directory") + } + for _, entry := range caps.Loras { + if path == entry.Name || path == entry.Path { + return entry.Path, nil + } + } + if settings.LoraModelDir != "" { + for _, suffix := range []string{"", ".safetensors", ".gguf"} { + full := filepath.Join(settings.LoraModelDir, path+suffix) + if found, err := resolveLoraPath(full, caps, LoadSettings{}); err == nil { + return found, nil + } + } + } + return "", fmt.Errorf("lora %q is not in the advertised catalog or configured local LoRA directory", path) +} + +func convertPromptLora(p GenerateParams, caps *APICapabilities, settings LoadSettings) (GenerateParams, error) { + p.Lora = append([]Lora(nil), p.Lora...) + if strings.Contains(strings.ToLower(p.NegativePrompt), " 16 { + return p, fmt.Errorf("lora exceeds 16 entry limit") + } + for i, l := range p.Lora { + if err := validateLora(l); err != nil { + return p, err + } + path, err := resolveLoraPath(l.Path, caps, settings) + if err != nil { + return p, err + } + p.Lora[i].Path = path + } + p.Prompt = strings.TrimSpace(p.Prompt) + return p, nil +} diff --git a/internal/mediagen/inputs_test.go b/internal/mediagen/inputs_test.go new file mode 100644 index 0000000..7bafaea --- /dev/null +++ b/internal/mediagen/inputs_test.go @@ -0,0 +1,95 @@ +package mediagen + +import ( + "encoding/base64" + "math" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestGenerationInputValidationRejectsInvalidOrAmbiguousInputs(t *testing.T) { + raw, path := pngInput(t) + inline := base64.StdEncoding.EncodeToString(raw) + for _, p := range []GenerateParams{ + {MaskImagePath: path}, {InitImagePath: path, InitImage: inline}, {ControlImage: "invalid-base64"}, + {InitImage: base64.StdEncoding.EncodeToString([]byte("not an image"))}, + {RefImages: []string{inline}, RefImagePaths: []string{path}}, {RefImagePaths: []string{""}}, + {RefImages: make([]string, maxReferenceImages+1)}, {InitImagePath: "relative.png"}, + {InitImage: strings.Repeat("A", maxInitImageInlineBytes+1)}, + {Guidance: math.NaN()}, {ControlStrength: math.Inf(1)}, {ControlStrength: 0.8}, + {ControlImagePath: path, ControlStrength: 11}, {Lora: []Lora{{Path: "model", Multiplier: math.NaN()}}}, + } { + if err := validateGenerationInputs(p); err == nil { + t.Fatalf("invalid inputs accepted: %+v", p) + } + } + bad := filepath.Join(t.TempDir(), "wrong.jpg") + if err := os.WriteFile(bad, raw, 0600); err != nil { + t.Fatal(err) + } + if err := validateGenerationInputs(GenerateParams{InitImagePath: bad}); err == nil { + t.Fatal("extension/content mismatch accepted") + } + large := filepath.Join(t.TempDir(), "large.png") + f, err := os.Create(large) + if err != nil { + t.Fatal(err) + } + if err = f.Truncate(maxInitImagePathBytes + 1); err != nil { + t.Fatal(err) + } + _ = f.Close() + if err = validateGenerationInputs(GenerateParams{ControlImagePath: large}); err == nil { + t.Fatal("oversized local file accepted") + } +} + +func TestSDRequestBodyPreservesConditioningZeroesAndGuidance(t *testing.T) { + p := GenerateParams{InitImage: "init", MaskImage: "mask", ControlImage: "control", RefImages: []string{"ref"}, Guidance: 3.5, Lora: []Lora{{Path: "film", Multiplier: 0}}} + body := SDRequestBody(p) + if v, ok := body["strength"]; !ok || v != float64(0) { + t.Fatal("zero denoising strength dropped") + } + if v, ok := body["control_strength"]; !ok || v != float64(0) { + t.Fatal("zero control strength dropped") + } + if body["sample_params"].(map[string]any)["guidance"].(map[string]any)["distilled_guidance"] != 3.5 { + t.Fatal("guidance dropped") + } + if body["lora"].([]Lora)[0].Multiplier != 0 { + t.Fatal("zero lora multiplier changed") + } + p.Kind = KindVideo + body = SDRequestBody(p) + if body["init_image"] == nil { + t.Fatal("video init_image dropped") + } + for _, field := range []string{"mask_image", "control_image", "ref_images", "batch_count"} { + if _, ok := body[field]; ok { + t.Fatalf("image-only field %s forwarded to vid_gen", field) + } + } +} + +func TestPromptLoraConversionValidatesLocalFilesAndTags(t *testing.T) { + path := filepath.Join(t.TempDir(), "film.safetensors") + if err := os.WriteFile(path, []byte("weights"), 0600); err != nil { + t.Fatal(err) + } + c := normalizeAPICapabilities(nil, "m", "") + c.Loras = []LoraEntry{{Name: "film", Path: "film.safetensors"}} + p, err := convertPromptLora(GenerateParams{Prompt: "portrait "}, c, LoadSettings{LoraModelDir: filepath.Dir(path)}) + if err != nil || len(p.Lora) != 1 || p.Lora[0].Path != "film.safetensors" || p.Lora[0].Multiplier != 0 || p.Prompt != "portrait" { + t.Fatalf("params=%+v err=%v", p, err) + } + for _, prompt := range []string{"p ", "p ", "p ", "p "} { + if _, err := convertPromptLora(GenerateParams{Prompt: prompt}, c, LoadSettings{LoraModelDir: filepath.Dir(path)}); err == nil { + t.Fatalf("invalid tag accepted: %s", prompt) + } + } + if _, err := convertPromptLora(GenerateParams{Prompt: "p", Lora: []Lora{{Path: path + "missing", Multiplier: 1}}}, c, LoadSettings{}); err == nil { + t.Fatal("missing local lora accepted") + } +} diff --git a/internal/mediagen/manager.go b/internal/mediagen/manager.go index c092257..5378572 100644 --- a/internal/mediagen/manager.go +++ b/internal/mediagen/manager.go @@ -59,6 +59,7 @@ type LoadSettings struct { VAE string `json:"vae"` // optional standalone VAE path TAESD string `json:"taesd"` // optional tiny decoder ESRGAN string `json:"esrgan"` // optional upscaler + HiresUpscalersDir string `json:"hires_upscalers_dir"` // native model-upscale catalog directory ControlNet string `json:"control_net"` // optional LLM string `json:"llm"` // LLM text encoder (qwen-image / flux2) LLMVision string `json:"llm_vision"` // --llm_vision (LLM ViT) @@ -93,27 +94,37 @@ func DefaultLoadSettings() LoadSettings { return LoadSettings{FlashAttention: true, VAETiling: true, SaveOnSuccess: true} } -// GenerateParams is one image/video generation request. Pointer-free on -// purpose: the zero value is a valid txt2img default. +// GenerateParams is one image/video generation request. The zero value +// selects txt2img defaults. Local inputs are encoded only at submission. type GenerateParams struct { - Kind string `json:"kind"` // image|video ("" = image) - Prompt string `json:"prompt"` - NegativePrompt string `json:"negative_prompt"` - Width int `json:"width"` // 0 = server default - Height int `json:"height"` // 0 = server default - Steps int `json:"steps"` // 0 = server default - CFGScale float64 `json:"cfg_scale"` - Seed int64 `json:"seed"` // -1 = random (sd.cpp default) - Sampler string `json:"sampler"` - Scheduler string `json:"scheduler"` - BatchCount int `json:"batch_count"` - VideoFrames int `json:"video_frames"` - FPS int `json:"fps"` - Guidance float64 `json:"guidance"` // distilled guidance (FLUX-class); 0 = server default - OutputFormat string `json:"output_format"` // png|jpeg|webp|webm|avi - InitImage string `json:"init_image"` // base64 or data URL (img2img) - InitImagePath string `json:"init_image_path"` // local image file (img2img); read + encoded by the backend - Strength float64 `json:"strength"` // img2img denoising strength + Kind string `json:"kind"` // image|video ("" = image) + Prompt string `json:"prompt"` + NegativePrompt string `json:"negative_prompt"` + Width int `json:"width"` // 0 = server default + Height int `json:"height"` // 0 = server default + Steps int `json:"steps"` // 0 = server default + CFGScale float64 `json:"cfg_scale"` + Seed int64 `json:"seed"` // -1 = random (sd.cpp default) + Sampler string `json:"sampler"` + Scheduler string `json:"scheduler"` + BatchCount int `json:"batch_count"` + VideoFrames int `json:"video_frames"` + FPS int `json:"fps"` + Guidance float64 `json:"guidance"` // distilled guidance (FLUX-class); 0 = server default + OutputFormat string `json:"output_format"` // png|jpeg|webp|webm|avi + InitImage string `json:"init_image"` // base64 or data URL (img2img) + InitImagePath string `json:"init_image_path"` // local image file (img2img); read + encoded by the backend + Strength float64 `json:"strength"` // img2img denoising strength + MaskImage string `json:"mask_image,omitempty"` + MaskImagePath string `json:"mask_image_path,omitempty"` + ControlImage string `json:"control_image,omitempty"` + ControlImagePath string `json:"control_image_path,omitempty"` + ControlStrength float64 `json:"control_strength,omitempty"` + RefImages []string `json:"ref_images,omitempty"` + RefImagePaths []string `json:"ref_image_paths,omitempty"` + Lora []Lora `json:"lora,omitempty"` + CFGScaleExplicit bool `json:"-"` // preserve a caller's explicit zero + GuidanceExplicit bool `json:"-"` } // Job is the API view of one media generation. @@ -163,24 +174,29 @@ const ( // goroutines, so every access (read or write) after the claim is inserted // into Manager.servers must hold Manager.mu. type server struct { - modelID string - runtimeID string - exe string - modelPath string - port int - handle *processes.Handle - logFile string - help string - caps []string - client *http.Client - ready bool - starting bool // launch in flight (claim in m.servers) - started chan struct{} // closed when the launch attempt finishes + modelID string + runtimeID string + exe string + modelPath string + port int + nativePort int // runtime-internal loopback port; never exposed to callers + handle *processes.Handle + logFile string + help string + caps []string + client *http.Client + apiKey string // process-local secret; never persisted or logged + gateway *sdGateway + cacheSnapshot *launchCacheSnapshot + ready bool + starting bool // launch in flight (claim in m.servers) + started chan struct{} // closed when the launch attempt finishes state string // starting|ready|failed errMsg string detail string // what a "starting" server is doing, when it is not just loading weights cancel context.CancelFunc // aborts the launch's own work (restoring model files) before the process exists + launchCtx context.Context startedAt string updatedAt string settings LoadSettings @@ -247,7 +263,7 @@ func NewManager(db *sql.DB, layout *config.Layout, rt *runtimes.Manager, lib *mo } return &Manager{ db: db, layout: layout, rt: rt, lib: lib, events: events, log: log, - http: &http.Client{Timeout: 0}, + http: sdHTTPClient(), servers: map[string]*server{}, jobs: map[string]*jobHandle{}, capCache: map[string][]string{}, @@ -330,20 +346,39 @@ func (m *Manager) EnsureServer(modelID string, s LoadSettings) (int, error) { m.mu.Unlock() <-ch // launch attempt finished (ready or failed); re-check m.mu.Lock() + if m.servers[modelID] != sv { + m.mu.Unlock() + return 0, fmt.Errorf("diffusion server for model %s was stopped during startup", modelID) + } + if !sv.ready { + err := fmt.Errorf("diffusion server startup failed: %s", sv.errMsg) + m.mu.Unlock() + return 0, err + } continue } break } // Claim the launch slot so parallel callers wait instead of racing. + previous := m.servers[modelID] + ctx, cancel := context.WithCancel(context.Background()) claim := &server{ modelID: modelID, starting: true, started: make(chan struct{}), state: ServerStarting, startedAt: now(), updatedAt: now(), + launchCtx: ctx, cancel: cancel, settings: s, } m.servers[modelID] = claim m.mu.Unlock() + if previous != nil { + m.closeSDServer(previous) + } port, err := m.startServer(modelID, s, claim) m.mu.Lock() + stillOurs := m.servers[modelID] == claim + if err == nil && (!stillOurs || ctx.Err() != nil || claim.state == ServerFailed) { + err = fmt.Errorf("diffusion server for model %s was stopped during startup", modelID) + } if err != nil { // Keep the failed entry (with its error + log path) so the Library // can show diagnostics, same as a failed llama-server instance. It @@ -356,27 +391,19 @@ func (m *Manager) EnsureServer(modelID string, s LoadSettings) (int, error) { claim.port = 0 claim.updatedAt = now() m.mu.Unlock() + m.closeSDServer(claim) close(claim.started) - m.publish("media.server_state", map[string]any{"model_id": modelID, "state": ServerFailed, "error": err.Error()}) + if stillOurs { + m.publish("media.server_state", map[string]any{"model_id": modelID, "state": ServerFailed, "error": err.Error()}) + } return 0, err } claim.starting = false claim.ready = true claim.state = ServerReady claim.updatedAt = now() - stillOurs := false - if cur, ok := m.servers[modelID]; ok && cur == claim { - stillOurs = true - } m.mu.Unlock() close(claim.started) - if !stillOurs { - // StopServer ran during startup — do not orphan the process. - if claim.handle != nil { - _ = claim.handle.KillTree() - } - return 0, fmt.Errorf("diffusion server for model %s was stopped during startup", modelID) - } if s.SaveOnSuccess && m.lib != nil { if raw, jerr := json.Marshal(s); jerr == nil { if serr := m.lib.SaveLastGood(modelID, raw); serr != nil { @@ -392,13 +419,23 @@ func (m *Manager) EnsureServer(modelID string, s LoadSettings) (int, error) { // StopServer can cancel mid-launch). Local pipeline companions (VAE, // text encoders, tokenizer) are paired automatically when the settings // leave them empty. -func (m *Manager) startServer(modelID string, s LoadSettings, sv *server) (int, error) { - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() +func (m *Manager) startServer(modelID string, s LoadSettings, sv *server) (port int, retErr error) { m.mu.Lock() - sv.settings = s - sv.cancel = cancel + ctx := sv.launchCtx m.mu.Unlock() + var endpoint *sdEndpoint + var handle *processes.Handle + defer func() { + if retErr != nil { + endpoint.close() + if handle != nil { + _ = handle.KillTree() + } + } + }() + if err := ctx.Err(); err != nil { + return 0, err + } mdl, err := m.lib.Get(modelID) if err != nil { @@ -418,6 +455,7 @@ func (m *Manager) startServer(modelID string, s LoadSettings, sv *server) (int, if err != nil { return 0, err } + sourceFiles, cacheErr := cacheLaunchFiles(exe, args) m.mu.Lock() sv.resolved = resolved m.mu.Unlock() @@ -425,17 +463,6 @@ func (m *Manager) startServer(modelID string, s LoadSettings, sv *server) (int, m.log.Warn("sd-server launch warning", "model_id", modelID, "warning", w) } - port, err := allocatePort() - if err != nil { - return 0, err - } - // Patch the allocated port into the argv (BuildServerArgs used 0). - for i := 0; i+1 < len(args); i++ { - if args[i] == "--listen-port" { - args[i+1] = fmt.Sprint(port) - } - } - logDir := filepath.Join(m.MediaDir(), "logs") _ = os.MkdirAll(logDir, 0o755) logPath := filepath.Join(logDir, sanitizeID(modelID)+".log") @@ -448,6 +475,10 @@ func (m *Manager) startServer(modelID string, s LoadSettings, sv *server) (int, // stable-diffusion.cpp cannot read: swap in restored copies first. setDetail := func(detail string) { m.mu.Lock() + if m.servers[modelID] != sv || ctx.Err() != nil { + m.mu.Unlock() + return + } changed := sv.detail != detail sv.detail = detail sv.updatedAt = now() @@ -470,8 +501,25 @@ func (m *Manager) startServer(modelID string, s LoadSettings, sv *server) (int, return 0, err } setDetail("") + if err := ctx.Err(); err != nil { + return 0, err + } + args, endpoint, err = configureSDTransport(help, args) + if err != nil { + return 0, err + } + if err := m.attachSDTransport(sv, endpoint); err != nil { + return 0, err + } + port = endpoint.port + loadedFiles, loadedCacheErr := cacheLaunchFiles(exe, args) + if cacheErr == nil && loadedCacheErr == nil { + m.mu.Lock() + sv.cacheSnapshot = &launchCacheSnapshot{Settings: resolved, Args: cacheArgs(args), SourceFiles: sourceFiles, LoadedFiles: loadedFiles} + m.mu.Unlock() + } fmt.Fprintf(logFile, "=== sd-server starting %s model=%s ===\n", now(), mdl.PrimaryPath) - fmt.Fprintf(logFile, "=== argv: %s ===\n", strings.Join(args, " ")) + fmt.Fprintf(logFile, "=== argv: %s ===\n", strings.Join(redactSDArgs(args), " ")) env := SDLaunchEnvironment(exe, args, rt.Backend) if env["GGML_CUDA_DISABLE_GRAPHS"] != "" { @@ -483,28 +531,31 @@ func (m *Manager) startServer(modelID string, s LoadSettings, sv *server) (int, if err != nil { return 0, fmt.Errorf("launching sd-server: %w", err) } + handle = h + // Reap this exact launch and close only its own gateway. The public + // listener closes even when the process dies before readiness. + exited := make(chan struct{}) + go func() { + _, _ = h.Wait() + endpoint.close() + close(exited) + }() m.mu.Lock() + if m.servers[modelID] != sv || ctx.Err() != nil || sv.state != ServerStarting { + m.mu.Unlock() + return 0, context.Canceled + } sv.runtimeID = rt.ID sv.exe = exe sv.modelPath = mdl.PrimaryPath - sv.port = port sv.handle = h sv.logFile = logPath sv.help = help sv.caps = caps - sv.client = &http.Client{Timeout: 0} + sv.client = sdHTTPClient() sv.updatedAt = now() m.mu.Unlock() - // Reap + observe exit: Cmd.ProcessState stays nil until Wait, so a - // died-at-startup server must be detected through a waiter or the - // readiness poll would spin until its full deadline. - exited := make(chan struct{}) - go func() { - _, _ = h.Wait() - close(exited) - }() - if err := m.waitReady(sv, exited, 10*time.Minute); err != nil { _ = h.KillTree() return 0, err @@ -519,6 +570,34 @@ func (m *Manager) startServer(modelID string, s LoadSettings, sv *server) (int, return port, nil } +// attachSDTransport refuses to publish a listener for a stopped or replaced +// launch claim. Its caller owns cleanup until startup completes. +func (m *Manager) attachSDTransport(sv *server, endpoint *sdEndpoint) error { + m.mu.Lock() + defer m.mu.Unlock() + if m.servers[sv.modelID] != sv || sv.launchCtx.Err() != nil || sv.state != ServerStarting { + return context.Canceled + } + sv.port, sv.nativePort = endpoint.port, endpoint.nativePort + sv.apiKey, sv.gateway = endpoint.key, endpoint.gateway + return nil +} + +// closeSDServer captures resources under the manager lock, then tears down +// this exact launch outside it. It cannot close a replacement's gateway. +func (m *Manager) closeSDServer(sv *server) { + m.mu.Lock() + gate, cancel, handle := sv.gateway, sv.cancel, sv.handle + m.mu.Unlock() + if cancel != nil { + cancel() + } + gate.Close() + if handle != nil { + _ = handle.KillTree() + } +} + // watchServerExit observes a ready sd-server's process exit. If the entry // is still the live one in Manager.servers (i.e. this was not a deliberate // StopServer, which removes it first), the exit is a crash: the server is @@ -579,6 +658,7 @@ func (m *Manager) handleServerExit(modelID string, sv *server) { sv.port = 0 sv.updatedAt = now() m.mu.Unlock() + m.closeSDServer(sv) m.publish("media.server_error", map[string]any{"model_id": modelID, "error": errMsg}) m.publish("media.server_state", map[string]any{"model_id": modelID, "state": ServerFailed, "error": errMsg}) @@ -591,6 +671,10 @@ func (m *Manager) handleServerExit(modelID string, sv *server) { // "failed" state written here with a plain "canceled". func (m *Manager) failJobsForModel(modelID, reason string) { m.mu.Lock() + var failed *server + if sv := m.servers[modelID]; sv != nil && sv.state == ServerFailed { + failed = sv + } var ids []string for id, jh := range m.jobs { if jh.modelID == modelID && !jh.terminal { @@ -599,6 +683,9 @@ func (m *Manager) failJobsForModel(modelID, reason string) { } } m.mu.Unlock() + if failed != nil { + m.closeSDServer(failed) + } for _, id := range ids { _ = m.setState(id, StateFailed, "", reason) m.publish("media.progress", map[string]any{"id": id, "model_id": modelID, "state": StateFailed, "error": reason, "message": "Diffusion server crashed"}) @@ -640,21 +727,27 @@ func (m *Manager) waitReady(sv *server, exited <-chan struct{}, timeout time.Dur port := sv.port logFile := sv.logFile modelID, modelPath, settings, handle := sv.modelID, sv.modelPath, sv.resolved, sv.handle + launchCtx := sv.launchCtx m.mu.Unlock() deadline := time.Now().Add(timeout) url := fmt.Sprintf("http://127.0.0.1:%d/sdcpp/v1/capabilities", port) - client := &http.Client{Timeout: 5 * time.Second} for { + if err := launchCtx.Err(); err != nil { + return err + } if handle.IsCoreDumping() { return errors.New(coreDumpFailure(logFile)) } - req, _ := http.NewRequest(http.MethodGet, url, nil) - if resp, err := client.Do(req); err == nil { + ctx, cancel := context.WithTimeout(launchCtx, 5*time.Second) + req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if resp, err := m.doSD(req, port); err == nil { resp.Body.Close() if resp.StatusCode == http.StatusOK { + cancel() return nil } } + cancel() select { case <-exited: tail, _ := os.ReadFile(logFile) @@ -669,7 +762,15 @@ func (m *Manager) waitReady(sv *server, exited <-chan struct{}, timeout time.Dur tail, _ := os.ReadFile(logFile) return fmt.Errorf("sd-server did not become ready within %s:\n%s", timeout, lastLines(string(tail), 8)) } - time.Sleep(300 * time.Millisecond) + timer := time.NewTimer(300 * time.Millisecond) + select { + case <-timer.C: + case <-launchCtx.Done(): + timer.Stop() + return launchCtx.Err() + case <-exited: + timer.Stop() + } } } @@ -680,17 +781,9 @@ func (m *Manager) StopServer(modelID string) { m.mu.Lock() sv, ok := m.servers[modelID] delete(m.servers, modelID) - var handle *processes.Handle - var cancel context.CancelFunc - if ok { - handle, cancel = sv.handle, sv.cancel - } m.mu.Unlock() - if cancel != nil { - cancel() // a launch still preparing model files stops there - } - if handle != nil { - _ = handle.KillTree() + if ok { + m.closeSDServer(sv) } m.publish("media.server_stopped", map[string]any{"model_id": modelID}) m.publish("media.server_state", map[string]any{"model_id": modelID, "state": ServerStopped}) @@ -769,25 +862,7 @@ func (m *Manager) ServerPort(modelID string) (int, bool) { // Capabilities proxies the sd-server capability document for a model. func (m *Manager) Capabilities(ctx context.Context, modelID string) (map[string]any, error) { - port, ok := m.ServerPort(modelID) - if !ok { - return nil, fmt.Errorf("diffusion server for model %s is not running", modelID) - } - req, err := http.NewRequestWithContext(ctx, http.MethodGet, - fmt.Sprintf("http://127.0.0.1:%d/sdcpp/v1/capabilities", port), nil) - if err != nil { - return nil, err - } - resp, err := m.http.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - var out map[string]any - if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { - return nil, err - } - return out, nil + return m.fetchCapabilities(ctx, modelID) } // StartGenerate validates a request, records a queued media job, and runs @@ -810,7 +885,7 @@ func (m *Manager) StartGenerateWith(modelID string, p GenerateParams, ov *LoadOv // RunStage runs one generation to completion and returns the finished job. // It is the seam the workflow executor drives: same job row, events and // cancel path as StartGenerate, but blocking. A canceled ctx cancels the job -// (including the GPU work) and returns ctx's error; a failed job returns the +// (and best-effort requests runtime cancellation) and returns ctx's error; a failed job returns the // job alongside an error carrying its message. func (m *Manager) RunStage(ctx context.Context, modelID string, p GenerateParams, ov *LoadOverrides) (*Job, error) { return m.RunStageWithProgress(ctx, modelID, p, ov, nil) @@ -865,17 +940,12 @@ func (m *Manager) startGenerate(modelID string, p GenerateParams, ov *LoadOverri if strings.TrimSpace(p.Prompt) == "" { return nil, nil, fmt.Errorf("prompt is required") } - // Reject an oversize inline init_image before ValidateGenerateParams, - // which otherwise silently drops it (turning img2img into txt2img - // without telling the caller). Large images belong in InitImagePath. - if err := checkInitImageSize(p); err != nil { + p.RefImages = append([]string(nil), p.RefImages...) + p.RefImagePaths = append([]string(nil), p.RefImagePaths...) + p.Lora = append([]Lora(nil), p.Lora...) + if err := validateGenerationInputs(p); err != nil { return nil, nil, err } - if p.InitImagePath != "" { - if err := validateInitImagePath(p.InitImagePath); err != nil { - return nil, nil, err - } - } ValidateGenerateParams(&p) // lib and rt are only ever nil in tests that inject a ready server. @@ -962,7 +1032,21 @@ func (m *Manager) runJob(jctx context.Context, id, modelID, kind string, p Gener settings.SaveOnSuccess = false } var err error - port, err = m.EnsureServer(modelID, settings) + type launchResult struct { + port int + err error + } + launched := make(chan launchResult, 1) + go func() { port, err := m.EnsureServer(modelID, settings); launched <- launchResult{port, err} }() + select { + case result := <-launched: + port, err = result.port, result.err + case <-jctx.Done(): + if !jobCanceledByCrash(jctx) { + _ = m.setState(id, StateCanceled, "", "canceled") + } + return + } if err != nil { msg := err.Error() _ = m.setState(id, StateFailed, "", msg) @@ -984,19 +1068,40 @@ func (m *Manager) runJob(jctx context.Context, id, modelID, kind string, p Gener _, _ = m.db.Exec(`UPDATE media_jobs SET runtime_id=?, log_path=?, pid=?, updated_at=? WHERE id=?`, runtimeID, logPath, pid, now(), id) m.publish("media.progress", map[string]any{"id": id, "model_id": modelID, "state": StateRunning, "message": "Submitting job…"}) - // Resolve init_image_path to a data URL just before use: validated and - // sized at StartGenerate time, but the (potentially large) decoded - // payload is never persisted into params_json. - reqParams := p - if p.InitImagePath != "" && p.InitImage == "" { - dataURL, err := loadInitImageDataURL(p.InitImagePath) - if err != nil { - msg := err.Error() - _ = m.setState(id, StateFailed, "", msg) - m.publish("media.progress", map[string]any{"id": id, "state": StateFailed, "error": msg}) + failRequest := func(err error) { + if jctx.Err() != nil && !jobCanceledByCrash(jctx) { + _ = m.setState(id, StateCanceled, "", "canceled") return } - reqParams.InitImage = dataURL + if jobCanceledByCrash(jctx) { + return + } + _ = m.setState(id, StateFailed, "", err.Error()) + m.publish("media.progress", map[string]any{"id": id, "state": StateFailed, "error": err.Error()}) + } + apiCaps, err := m.generationCapabilities(jctx, modelID) + if err != nil { + failRequest(err) + return + } + if err = apiCaps.ValidateGeneration(p); err != nil { + failRequest(err) + return + } + settings, _ := m.RunningSettings(modelID) + reqParams, err := convertPromptLora(p, apiCaps, settings) + if err != nil { + failRequest(err) + return + } + reqParams, err = resolveGenerationImages(reqParams) + if err != nil { + failRequest(err) + return + } + if !m.serverMatchesPort(modelID, port) { + failRequest(ErrServerNotRunning) + return } endpoint := "/sdcpp/v1/img_gen" @@ -1004,19 +1109,6 @@ func (m *Manager) runJob(jctx context.Context, id, modelID, kind string, p Gener endpoint = "/sdcpp/v1/vid_gen" } sdBody := SDRequestBody(reqParams) - if p.Guidance > 0 { - sp, _ := sdBody["sample_params"].(map[string]any) - if sp == nil { - sp = map[string]any{} - sdBody["sample_params"] = sp - } - guid, _ := sp["guidance"].(map[string]any) - if guid == nil { - guid = map[string]any{} - sp["guidance"] = guid - } - guid["distilled_guidance"] = p.Guidance - } logOffset := logSize(logPath) // what the server logs from here on belongs to this job jobID, status, err := m.submitSDJob(jctx, port, endpoint, sdBody) if err != nil { @@ -1024,14 +1116,17 @@ func (m *Manager) runJob(jctx context.Context, id, modelID, kind string, p Gener // native async API itself is missing (older sd.cpp builds); a 400 // (bad params) or 429 (queue full) must surface as-is, not retry // through a second, different submission. - if shouldFallbackToOpenAICompat(kind, status) { - if _, ferr := m.generateOpenAICompat(jctx, id, modelID, p); ferr != nil { + if shouldFallbackToOpenAICompat(kind, status) && canFallbackGeneration(reqParams) { + if _, ferr := m.generateOpenAICompat(jctx, id, modelID, reqParams); ferr != nil { msg := firstErr(err, ferr) _ = m.setState(id, StateFailed, "", msg) m.publish("media.progress", map[string]any{"id": id, "state": StateFailed, "error": msg}) } return } + if shouldFallbackToOpenAICompat(kind, status) && !canFallbackGeneration(reqParams) { + err = fmt.Errorf("native generation API unavailable; compatibility fallback cannot preserve the requested inputs or sampling controls: %w", err) + } msg := err.Error() _ = m.setState(id, StateFailed, "", msg) m.publish("media.progress", map[string]any{"id": id, "state": StateFailed, "error": msg}) @@ -1044,6 +1139,9 @@ func (m *Manager) runJob(jctx context.Context, id, modelID, kind string, p Gener jh.port = port } m.mu.Unlock() + if jctx.Err() != nil { + m.cancelSDJob(port, jobID) + } tracker := newLogProgress(logPath, logOffset) result, err := m.pollSDJob(jctx, modelID, port, jobID, func(st string) error { @@ -1162,7 +1260,7 @@ func (m *Manager) generateOpenAICompat(ctx context.Context, id, modelID string, return nil, err } req.Header.Set("Content-Type", "application/json") - resp, err := m.http.Do(req) + resp, err := m.doSD(req, port) if err != nil { return nil, err } @@ -1192,6 +1290,9 @@ func (m *Manager) generateOpenAICompat(ctx context.Context, id, modelID string, if ext == "jpeg" { ext = "jpg" } + if !containsChoice([]string{"png", "jpg", "webp"}, ext) { + return nil, fmt.Errorf("sd-server returned unsupported image format %q", ext) + } var paths []string for i, d := range out.Data { raw, err := base64.StdEncoding.DecodeString(d.B64) @@ -1221,14 +1322,17 @@ func (m *Manager) generateOpenAICompat(ctx context.Context, id, modelID string, func (m *Manager) submitSDJob(ctx context.Context, port int, endpoint string, body map[string]any) (string, int, error) { ctx, cancel := context.WithTimeout(ctx, sdSubmitRequestTimeout) defer cancel() - payload, _ := json.Marshal(body) + payload, err := json.Marshal(body) + if err != nil { + return "", 0, fmt.Errorf("encoding sd-server request: %w", err) + } req, err := http.NewRequestWithContext(ctx, http.MethodPost, fmt.Sprintf("http://127.0.0.1:%d%s", port, endpoint), bytes.NewReader(payload)) if err != nil { return "", 0, err } req.Header.Set("Content-Type", "application/json") - resp, err := m.http.Do(req) + resp, err := m.doSD(req, port) if err != nil { return "", 0, err } @@ -1299,7 +1403,7 @@ func (m *Manager) pollSDJob(ctx context.Context, modelID string, port int, jobID cancel() return nil, err } - resp, err := m.http.Do(req) + resp, err := m.doSD(req, port) if err != nil { cancel() if onTick != nil { @@ -1407,6 +1511,13 @@ func (m *Manager) storeSDResult(id, modelID, kind string, p GenerateParams, doc if ext == "jpeg" { ext = "jpg" } + formats := []string{"png", "jpg", "webp"} + if kind == KindVideo { + formats = []string{"webm", "avi", "mp4"} + } + if !containsChoice(formats, ext) { + return nil, nil, fmt.Errorf("sd-server returned unsupported output format %q", ext) + } var paths []string for i, b := range blobs { raw, err := base64.StdEncoding.DecodeString(b) @@ -1442,18 +1553,24 @@ func (m *Manager) storeSDResult(id, modelID, kind string, p GenerateParams, doc // Cancel cancels a queued/running media job. For a job still tracked in // memory it cancels the Go context (stopping polling) and best-effort posts -// the sd.cpp cancel endpoint so the GPU actually stops generating instead -// of finishing behind the caller's back with later jobs queued after it. +// the sd.cpp cancel endpoint. GPU abort depends on the runtime's advertised +// cancel_generating capability; some builds can only cancel queued jobs. // A job id from before a backend restart (no longer in memory) still gets // its DB row marked canceled if it is non-terminal. func (m *Manager) Cancel(id string) error { m.mu.Lock() jh, ok := m.jobs[id] + var cancel context.CancelCauseFunc + var port int + var sdJobID string + if ok && jh != nil { + cancel, port, sdJobID = jh.cancel, jh.port, jh.sdJobID + } m.mu.Unlock() if ok && jh != nil { - jh.cancel(nil) // plain user cancel, not errServerCrashed - if jh.sdJobID != "" && jh.port != 0 { - m.cancelSDJob(jh.port, jh.sdJobID) + cancel(nil) // plain user cancel, not errServerCrashed + if sdJobID != "" && port != 0 { + m.cancelSDJob(port, sdJobID) } return nil } @@ -1482,7 +1599,7 @@ func (m *Manager) cancelSDJob(port int, sdJobID string) { if err != nil { return } - resp, err := m.http.Do(req) + resp, err := m.doSD(req, port) if err != nil { return } diff --git a/internal/mediagen/overrides.go b/internal/mediagen/overrides.go index 40b03dd..65a259c 100644 --- a/internal/mediagen/overrides.go +++ b/internal/mediagen/overrides.go @@ -5,12 +5,15 @@ package mediagen // than the running server has must restart it. Empty fields mean "the graph // does not care": they neither override a launch setting nor force a restart. type LoadOverrides struct { - VAE string `json:"vae,omitempty"` - LLM string `json:"llm,omitempty"` - T5XXL string `json:"t5xxl,omitempty"` - ClipL string `json:"clip_l,omitempty"` - ClipG string `json:"clip_g,omitempty"` - LoraModelDir string `json:"lora_model_dir,omitempty"` + VAE string `json:"vae,omitempty"` + LLM string `json:"llm,omitempty"` + T5XXL string `json:"t5xxl,omitempty"` + ClipL string `json:"clip_l,omitempty"` + ClipG string `json:"clip_g,omitempty"` + LoraModelDir string `json:"lora_model_dir,omitempty"` + ControlNet string `json:"control_net,omitempty"` + ESRGAN string `json:"esrgan,omitempty"` + HiresUpscalersDir string `json:"hires_upscalers_dir,omitempty"` } // IsZero reports whether the overrides leave every launch setting alone. @@ -29,6 +32,12 @@ func (o LoadOverrides) Apply(s *LoadSettings) { set(o.ClipL, &s.ClipL) set(o.ClipG, &s.ClipG) set(o.LoraModelDir, &s.LoraModelDir) + set(o.ControlNet, &s.ControlNet) + set(o.ESRGAN, &s.ESRGAN) + if o.ESRGAN != "" && o.HiresUpscalersDir == "" { + s.HiresUpscalersDir = "" + } // rederive from the selected model + set(o.HiresUpscalersDir, &s.HiresUpscalersDir) } // SatisfiedBy reports whether a server launched with s already has every @@ -38,5 +47,6 @@ func (o LoadOverrides) Apply(s *LoadSettings) { func (o LoadOverrides) SatisfiedBy(s LoadSettings) bool { match := func(want, have string) bool { return want == "" || want == have } return match(o.VAE, s.VAE) && match(o.LLM, s.LLM) && match(o.T5XXL, s.T5XXL) && - match(o.ClipL, s.ClipL) && match(o.ClipG, s.ClipG) && match(o.LoraModelDir, s.LoraModelDir) + match(o.ClipL, s.ClipL) && match(o.ClipG, s.ClipG) && match(o.LoraModelDir, s.LoraModelDir) && + match(o.ControlNet, s.ControlNet) && match(o.ESRGAN, s.ESRGAN) && match(o.HiresUpscalersDir, s.HiresUpscalersDir) } diff --git a/internal/mediagen/progress_test.go b/internal/mediagen/progress_test.go index 27987bf..866b20f 100644 --- a/internal/mediagen/progress_test.go +++ b/internal/mediagen/progress_test.go @@ -188,6 +188,10 @@ func TestAssertionFailsRunningJobAndServerWithoutWaitingForProcessExit(t *testin } srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") + if r.URL.Path == "/sdcpp/v1/capabilities" { + http.NotFound(w, r) + return + } if r.Method == http.MethodPost { w.WriteHeader(http.StatusAccepted) _, _ = w.Write([]byte(`{"id":"asserted"}`)) diff --git a/internal/mediagen/request_capabilities.go b/internal/mediagen/request_capabilities.go new file mode 100644 index 0000000..a03e887 --- /dev/null +++ b/internal/mediagen/request_capabilities.go @@ -0,0 +1,85 @@ +package mediagen + +import ( + "fmt" + "strings" +) + +// ValidateGeneration rejects inputs the loaded-model API has not explicitly +// advertised. Missing discovery permits only the existing basic image path; +// it does not advertise that path as a capability. +func (c *APICapabilities) ValidateGeneration(p GenerateParams) error { + mode := "img_gen" + if p.Kind == KindVideo { + mode = "vid_gen" + } + if c.Known && !c.SupportsMode(mode) { + return fmt.Errorf("loaded-model API does not advertise mode %s", mode) + } + if !c.Known && mode == "vid_gen" { + return fmt.Errorf("video generation requires advertised vid_gen support: %s", c.Reason) + } + requested := []struct { + feature string + active bool + }{ + {"init_image", p.InitImage != "" || p.InitImagePath != ""}, + {"mask_image", p.MaskImage != "" || p.MaskImagePath != ""}, + {"control_image", p.ControlImage != "" || p.ControlImagePath != "" || p.ControlStrength != 0}, + {"ref_images", len(p.RefImages)+len(p.RefImagePaths) > 0}, + {"lora", len(p.Lora) > 0 || strings.Contains(strings.ToLower(p.Prompt), " 0 && ((d.min > 0 && d.value < d.min) || (d.max > 0 && d.value > d.max)) { + return fmt.Errorf("%s exceeds advertised loaded-model limits", d.name) + } + } + if l.MaxBatchCount > 0 && p.BatchCount > l.MaxBatchCount { + return fmt.Errorf("batch_count exceeds advertised loaded-model limit") + } + return nil +} + +func containsChoice(choices []string, value string) bool { + for _, choice := range choices { + if strings.EqualFold(choice, value) { + return true + } + } + return false +} + +func canFallbackGeneration(p GenerateParams) bool { + return p.Kind != KindVideo && p.InitImage == "" && p.InitImagePath == "" && p.MaskImage == "" && p.MaskImagePath == "" && + p.ControlImage == "" && p.ControlImagePath == "" && len(p.RefImages)+len(p.RefImagePaths) == 0 && len(p.Lora) == 0 && + p.Guidance == 0 && !p.GuidanceExplicit && p.Steps == 0 && p.CFGScale == 0 && !p.CFGScaleExplicit && p.Sampler == "" && p.Scheduler == "" && p.NegativePrompt == "" && p.Seed < 0 +} diff --git a/internal/mediagen/sampling_test.go b/internal/mediagen/sampling_test.go new file mode 100644 index 0000000..030c89b --- /dev/null +++ b/internal/mediagen/sampling_test.go @@ -0,0 +1,23 @@ +package mediagen + +import "testing" + +func TestNativeRequestPreservesLiteralSeedAndZeroGuidance(t *testing.T) { + p := GenerateParams{Prompt: "a tree", Seed: 0, CFGScaleExplicit: true, GuidanceExplicit: true} + ValidateGenerateParams(&p) + body := SDRequestBody(p) + if body["seed"] != int64(0) { + t.Fatal("literal zero seed became random") + } + guidance := body["sample_params"].(map[string]any)["guidance"].(map[string]any) + if guidance["txt_cfg"] != float64(0) || guidance["distilled_guidance"] != float64(0) { + t.Fatalf("zero guidance dropped: %v", guidance) + } + if canFallbackGeneration(p) { + t.Fatal("OpenAI fallback cannot represent explicit native sampling fields") + } + unset := SDRequestBody(GenerateParams{Prompt: "a tree", Seed: -1}) + if unset["sample_params"] != nil { + t.Fatal("omitted guidance must preserve runtime defaults") + } +} diff --git a/internal/mediagen/sdserver.go b/internal/mediagen/sdserver.go index bfc740a..8730001 100644 --- a/internal/mediagen/sdserver.go +++ b/internal/mediagen/sdserver.go @@ -225,6 +225,12 @@ func removedInHelp(help, flag string) bool { // classifies them from the tensor layout and takes companions via --vae / // --llm / --clip_*. func BuildServerArgs(s LoadSettings, modelPath string, componentOnly bool, caps []string, help, host string, port int) ([]string, error) { + if host != "127.0.0.1" || port < 0 || port > 65535 { + return nil, fmt.Errorf("sd-server must bind to IPv4 loopback with a valid managed port") + } + if !SupportsSDFlag(caps, help, "--listen-ip") || !SupportsSDFlag(caps, help, "--listen-port") { + return nil, fmt.Errorf("runtime must advertise --listen-ip and --listen-port for managed loopback binding") + } if strings.TrimSpace(modelPath) == "" { return nil, fmt.Errorf("model path is required") } @@ -251,6 +257,7 @@ func BuildServerArgs(s LoadSettings, modelPath string, componentOnly bool, caps {"clip_vision", s.ClipVision, false}, {"tokenizer", s.Tokenizer, false}, {"lora_model_dir", s.LoraModelDir, true}, + {"hires_upscalers_dir", s.HiresUpscalersDir, true}, } { if c.path == "" { continue @@ -306,6 +313,11 @@ func BuildServerArgs(s LoadSettings, modelPath string, componentOnly bool, caps if s.ESRGAN != "" { add("--upscale-model", s.ESRGAN) } + upscalerDir := s.HiresUpscalersDir + if upscalerDir == "" && s.ESRGAN != "" { + upscalerDir = filepath.Dir(s.ESRGAN) + } + add("--hires-upscalers-dir", upscalerDir) if s.ControlNet != "" { add("--control-net", s.ControlNet) } @@ -363,6 +375,7 @@ var sdForbiddenRawFlags = map[string]bool{ "--model": true, "-m": true, "--diffusion-model": true, "--serve-html-path": true, + "--api-key": true, "--api-key-file": true, } // parseSDRawArgs tokenizes LoadSettings.RawArgs without a shell (mirrors the @@ -382,7 +395,13 @@ func parseSDRawArgs(raw string, caps []string, help string) (args []string, warn } } prevFlagAccepted := false + skipManagedValue := false for _, tok := range tokens { + if skipManagedValue && !strings.HasPrefix(tok, "-") { + skipManagedValue = false + continue + } + skipManagedValue = false // Budgets such as --max-vram -2 are values, not short flags. if prevFlagAccepted && strings.HasPrefix(tok, "-") { if _, err := strconv.ParseFloat(tok, 64); err == nil { @@ -392,9 +411,11 @@ func parseSDRawArgs(raw string, caps []string, help string) (args []string, warn } } if strings.HasPrefix(tok, "-") { - if sdForbiddenRawFlags[tok] { - warnings = append(warnings, fmt.Sprintf("raw flag %s may not override the managed network/model flags; skipped", tok)) + managedFlag := strings.SplitN(tok, "=", 2)[0] + if sdForbiddenRawFlags[managedFlag] { + warnings = append(warnings, fmt.Sprintf("raw flag %s may not override the managed network/model flags; skipped", managedFlag)) prevFlagAccepted = false + skipManagedValue = !strings.Contains(tok, "=") continue } if !strings.HasPrefix(tok, "--") { @@ -451,6 +472,9 @@ func PrepareLaunch(rt *runtimes.Runtime, help, modelPath string, s LoadSettings) // Pair local companions (…/vae/…, …/text_encoders/…) for transformer-only // checkpoints. Explicit settings always win. ApplyCompanions(&resolved, ModelRoot(modelPath), modelPath) + if resolved.HiresUpscalersDir == "" && resolved.ESRGAN != "" { + resolved.HiresUpscalersDir = filepath.Dir(resolved.ESRGAN) + } warnings = append(warnings, packedWarnings(modelPath, resolved)...) warnings = append(warnings, reshapedWarnings(modelPath, resolved)...) @@ -508,7 +532,7 @@ func SDRequestBody(p GenerateParams) map[string]any { if p.Steps > 0 { body["sample_params"] = map[string]any{"sample_steps": p.Steps} } - if p.CFGScale != 0 { + if p.CFGScale != 0 || p.Guidance != 0 || p.CFGScaleExplicit || p.GuidanceExplicit { sp, _ := body["sample_params"].(map[string]any) if sp == nil { sp = map[string]any{} @@ -519,11 +543,15 @@ func SDRequestBody(p GenerateParams) map[string]any { guid = map[string]any{} sp["guidance"] = guid } - guid["txt_cfg"] = p.CFGScale - } - if p.Seed != 0 { - body["seed"] = p.Seed + if p.CFGScale != 0 || p.CFGScaleExplicit { + guid["txt_cfg"] = p.CFGScale + } + if p.Guidance != 0 || p.GuidanceExplicit { + guid["distilled_guidance"] = p.Guidance + } } + // Zero is a valid reproducible seed. The control API defaults omission to -1. + body["seed"] = p.Seed if p.Sampler != "" { sp, _ := body["sample_params"].(map[string]any) if sp == nil { @@ -551,12 +579,23 @@ func SDRequestBody(p GenerateParams) map[string]any { if p.BatchCount > 1 { body["batch_count"] = p.BatchCount } - if p.InitImage != "" { - body["init_image"] = p.InitImage - if p.Strength > 0 { - body["strength"] = p.Strength - } + if p.MaskImage != "" { + body["mask_image"] = p.MaskImage + } + if p.ControlImage != "" { + body["control_image"] = p.ControlImage + body["control_strength"] = p.ControlStrength } + if len(p.RefImages) > 0 { + body["ref_images"] = p.RefImages + } + } + if p.InitImage != "" { + body["init_image"] = p.InitImage + body["strength"] = p.Strength + } + if len(p.Lora) > 0 { + body["lora"] = p.Lora } if p.OutputFormat != "" { body["output_format"] = strings.ToLower(p.OutputFormat) @@ -590,6 +629,12 @@ func ValidateGenerateParams(p *GenerateParams) { if p.CFGScale > 30 { p.CFGScale = 30 } + if p.Guidance < 0 { + p.Guidance = 0 + } + if p.Guidance > 30 { + p.Guidance = 30 + } if p.Strength < 0 { p.Strength = 0 } @@ -614,17 +659,5 @@ func ValidateGenerateParams(p *GenerateParams) { if p.FPS > 60 { p.FPS = 60 } - if p.Seed == 0 { - // sd.cpp treats any seed < 0 as random. 0 is never a request for the - // literal seed 0 here — the client sends 0 when the user left the - // field blank (portable "unset" for an int64 request field) — so it - // is mapped to -1 (random) rather than passed through. - p.Seed = -1 - } - // Init images over the API are capped: base64 payloads ride inside the - // 4 MiB control-API body limit (json.go maxRequestBody). - if len(p.InitImage) > 3<<20 { - p.InitImage = "" - } _ = runtime.GOOS } diff --git a/internal/mediagen/sdserver_test.go b/internal/mediagen/sdserver_test.go index f342397..1901e2b 100644 --- a/internal/mediagen/sdserver_test.go +++ b/internal/mediagen/sdserver_test.go @@ -127,7 +127,7 @@ func TestSDRequestBody(t *testing.T) { } func TestValidateGenerateParams(t *testing.T) { - p := GenerateParams{Width: 99999, Steps: 999, CFGScale: 99, BatchCount: 99, VideoFrames: 9999, FPS: 999} + p := GenerateParams{Width: 99999, Steps: 999, CFGScale: 99, BatchCount: 99, VideoFrames: 9999, FPS: 999, Seed: -1} ValidateGenerateParams(&p) if p.Width != 4096 || p.Steps != 300 || p.CFGScale != 30 || p.BatchCount != 8 || p.VideoFrames != 512 || p.FPS != 60 { t.Fatalf("clamps not applied: %+v", p) @@ -136,3 +136,32 @@ func TestValidateGenerateParams(t *testing.T) { t.Fatalf("seed default = %d, want -1", p.Seed) } } + +func TestESRGANOverrideConfiguresNativeUpscalerCatalog(t *testing.T) { + dir := t.TempDir() + model := filepath.Join(dir, "model.safetensors") + upscaler := filepath.Join(dir, "RGB.safetensors") + for _, path := range []string{model, upscaler} { + if err := os.WriteFile(path, []byte("fixture"), 0600); err != nil { + t.Fatal(err) + } + } + help := sdHelpSample + "\n --upscale-model FNAME\n --hires-upscalers-dir DIR\n" + args, err := BuildServerArgs(LoadSettings{ESRGAN: upscaler}, model, false, ParseSDCapabilities(help), help, "127.0.0.1", 1234) + if err != nil { + t.Fatal(err) + } + joined := strings.Join(args, " ") + if !strings.Contains(joined, "--hires-upscalers-dir "+dir) { + t.Fatalf("catalog dir missing: %v", args) + } + if strings.Contains(strings.Join(redactSDArgs([]string{"--api-key", "secret"}), " "), "secret") { + t.Fatal("process key not redacted") + } + if key, err := sdProcessAuth(sdHelpSample); err != nil || len(key) != 64 { + t.Fatal("stock runtime requires a managed gateway key") + } + if key, err := sdProcessAuth(sdHelpSample + "\n --api-key TOKEN\n"); err != nil || len(key) != 64 { + t.Fatalf("auth key length=%d err=%v", len(key), err) + } +} diff --git a/internal/mediagen/transport.go b/internal/mediagen/transport.go new file mode 100644 index 0000000..cd006ab --- /dev/null +++ b/internal/mediagen/transport.go @@ -0,0 +1,158 @@ +package mediagen + +import ( + "crypto/rand" + "encoding/hex" + "fmt" + "net" + "net/http" + "strconv" + "strings" +) + +func sdHTTPClient() *http.Client { + transport := http.DefaultTransport.(*http.Transport).Clone() + transport.Proxy = nil // a loopback process must never use environment proxies + return &http.Client{Transport: transport, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} +} + +// doSD only reaches a supervised loopback process and never redirects its +// bearer credential to another listener. The key is kept solely in memory. +func (m *Manager) doSD(req *http.Request, port int) (*http.Response, error) { + if req.URL.Scheme != "http" || req.URL.Host != "127.0.0.1:"+strconv.Itoa(port) || req.URL.User != nil || req.URL.Opaque != "" || port < 1 || port > 65535 { + return nil, fmt.Errorf("invalid sd-server loopback address") + } + m.mu.Lock() + var found bool + var key string + for _, sv := range m.servers { + if sv.port == port && port != 0 { + found = true + key = sv.apiKey + break + } + } + m.mu.Unlock() + if !found { + return nil, ErrServerNotRunning + } + if key != "" { + req.Header.Set("Authorization", "Bearer "+key) + } + // Copy the client to disallow redirects even in tests/custom transports. + client := *m.http + client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse } + return client.Do(req) +} + +func sdProcessAuth(help string) (string, error) { + // Every managed endpoint has a fresh in-memory key, including stock + // runtimes whose authentication is supplied by the local gateway. + var key [32]byte + if _, err := rand.Read(key[:]); err != nil { + return "", err + } + return hex.EncodeToString(key[:]), nil +} + +func advertisesSDOption(help, option string) bool { + if removedInHelp(help, option) { + return false + } + for _, line := range strings.Split(help, "\n") { + // Only an option declaration counts, never prose mentioning a flag. + for _, field := range strings.Fields(strings.TrimSpace(line)) { + field = strings.Trim(field, ",") + if !strings.HasPrefix(field, "-") { + break + } + name, _, _ := strings.Cut(field, "=") + if name == option { + return true + } + } + } + return false +} + +// sdEndpoint owns the public authenticated endpoint for one launch. For a +// stock runtime the native port is internal loopback transport, never the +// port exposed through ServerPort, events, or the manager's HTTP client. +type sdEndpoint struct { + port, nativePort int + key string + gateway *sdGateway +} + +func (e *sdEndpoint) close() { + if e != nil && e.gateway != nil { + e.gateway.Close() + } +} + +func configureSDTransport(help string, args []string) ([]string, *sdEndpoint, error) { + key, err := sdProcessAuth(help) + if err != nil { + return nil, nil, err + } + e := &sdEndpoint{key: key} + nativeAuth := advertisesSDOption(help, "--api-key") + var listener net.Listener + if !nativeAuth { + // Keep the public socket bound while selecting the separate native + // port, so the two endpoints cannot accidentally be the same. + listener, err = net.Listen("tcp4", "127.0.0.1:0") + if err != nil { + return nil, nil, err + } + e.port = listener.Addr().(*net.TCPAddr).Port + } + e.nativePort, err = allocatePort() + if err != nil { + if listener != nil { + _ = listener.Close() + } + return nil, nil, err + } + out := append([]string(nil), args...) + patched := false + for i := 0; i < len(out); i++ { + if out[i] == "--api-key" || out[i] == "--api-key-file" || strings.HasPrefix(out[i], "--api-key=") || strings.HasPrefix(out[i], "--api-key-file=") { + if listener != nil { + _ = listener.Close() + } + return nil, nil, fmt.Errorf("sd-server authentication arguments must be managed") + } + if out[i] == "--listen-port" && i+1 < len(out) { + out[i+1] = strconv.Itoa(e.nativePort) + patched = true + } + } + if !patched { + if listener != nil { + _ = listener.Close() + } + return nil, nil, fmt.Errorf("sd-server requires an advertised --listen-port option") + } + if nativeAuth { + e.port = e.nativePort + out = append(out, "--api-key", key) + } else { + e.gateway, err = startSDGateway(listener, e.nativePort, key) + if err != nil { + _ = listener.Close() + return nil, nil, err + } + } + return out, e, nil +} + +func redactSDArgs(args []string) []string { + out := append([]string(nil), args...) + for i := 0; i+1 < len(out); i++ { + if out[i] == "--api-key" { + out[i+1] = "[redacted]" + } + } + return out +} diff --git a/internal/mediagen/upscale.go b/internal/mediagen/upscale.go new file mode 100644 index 0000000..2d95b03 --- /dev/null +++ b/internal/mediagen/upscale.go @@ -0,0 +1,225 @@ +package mediagen + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "net/http" + "path/filepath" + "strings" + "time" + + "github.com/google/uuid" +) + +type UpscaleParams struct { + Image string `json:"image,omitempty"` + ImagePath string `json:"image_path,omitempty"` + Upscaler string `json:"upscaler,omitempty"` + Repeats int `json:"repeats,omitempty"` + TileSize int `json:"tile_size,omitempty"` + OutputFormat string `json:"output_format,omitempty"` +} + +// UpscaleStage is the optional workflow seam for native model upscaling. +// Outputs use managed relative paths and ordinary image Job rows. The native +// endpoint is synchronous, has no upstream job ID/cancel endpoint, and may +// continue GPU work after its HTTP request is canceled. Cancellation bounds +// this call and prevents storing results; it does not promise GPU abort. +func (m *Manager) UpscaleStage(ctx context.Context, modelID string, p UpscaleParams) (*Job, error) { + return m.RunUpscale(ctx, modelID, p, nil) +} + +// RunUpscale additionally permits launch overrides such as ESRGAN. A runtime +// must advertise upscale:true; named models also need image_upscale:true. +func (m *Manager) RunUpscale(ctx context.Context, modelID string, p UpscaleParams, ov *LoadOverrides) (*Job, error) { + if ctx.Err() != nil { + return nil, ctx.Err() + } + if (p.Image == "") == (p.ImagePath == "") { + return nil, fmt.Errorf("provide exactly one of image or image_path") + } + if p.Repeats == 0 { + p.Repeats = 1 + } + if ov != nil && ov.ESRGAN != "" && p.Upscaler == "" { + p.Upscaler = strings.TrimSuffix(filepath.Base(ov.ESRGAN), filepath.Ext(ov.ESRGAN)) + } + if strings.TrimSpace(p.Upscaler) == "" { + return nil, fmt.Errorf("select an advertised RGB model upscaler") + } + if p.Repeats < 1 || p.Repeats > 4 { + return nil, fmt.Errorf("upscale repeats must be 1..4") + } + if p.TileSize < 0 || p.TileSize > 2048 { + return nil, fmt.Errorf("upscale tile_size must be 0..2048") + } + if p.OutputFormat != "" && !containsChoice([]string{"png", "jpeg", "webp"}, p.OutputFormat) { + return nil, fmt.Errorf("unsupported upscale output_format") + } + input := imageInput{field: "image", inline: p.Image, path: p.ImagePath} + if _, _, err := readImageInput(input); err != nil { + return nil, err + } + port, err := m.stageServer(ctx, modelID, ov) + if err != nil { + return nil, err + } + caps, err := m.generationCapabilities(ctx, modelID) + if err != nil { + return nil, err + } + if !caps.Known || !caps.Upscale { + return nil, fmt.Errorf("loaded-model API does not advertise native RGB model upscaling: %s", caps.Reason) + } + if p.Upscaler != "" { + allowed := false + for _, up := range caps.Upscalers { + if up.Name == p.Upscaler && up.Model && up.ImageUpscale { + allowed = true + break + } + } + if !allowed { + return nil, fmt.Errorf("upscaler %q is not advertised as a compatible RGB model", p.Upscaler) + } + } + raw, mime, err := readImageInput(input) // revalidate after a potentially lengthy model load + if err != nil { + return nil, err + } + w, h, _, err := imageInfo(raw) + if err != nil { + return nil, err + } + // Upstream ESRGAN scale is model-specific and not advertised in this + // catalog. Never assume 4x: enforce input/local limits here and let the + // endpoint enforce its advertised final output limit before GPU work. + if (caps.Limits.MaxUpscaleWidth > 0 && w > caps.Limits.MaxUpscaleWidth) || (caps.Limits.MaxUpscaleHeight > 0 && h > caps.Limits.MaxUpscaleHeight) { + return nil, fmt.Errorf("image exceeds advertised upscale dimensions") + } + if !m.serverMatchesPort(modelID, port) { + return nil, ErrServerNotRunning + } + id := uuid.NewString() + params, _ := json.Marshal(p) + ts := now() + if _, err = m.db.Exec(`INSERT INTO media_jobs(id,model_id,kind,state,prompt,params_json,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?)`, id, modelID, KindImage, StateRunning, "", string(params), ts, ts); err != nil { + return nil, err + } + jctx, cancel := context.WithCancelCause(ctx) + m.mu.Lock() + m.jobs[id] = &jobHandle{cancel: cancel, modelID: modelID, port: port} + m.mu.Unlock() + defer func() { cancel(nil); m.mu.Lock(); delete(m.jobs, id); m.mu.Unlock() }() + runtimeID, logPath, pid := m.serverMeta(modelID) + _, _ = m.db.Exec(`UPDATE media_jobs SET runtime_id=?,log_path=?,pid=? WHERE id=?`, runtimeID, logPath, pid, id) + m.publish("media.progress", map[string]any{"id": id, "model_id": modelID, "state": StateRunning, "message": "Upscaling image…"}) + finishErr := func(err error) (*Job, error) { + if !jobCanceledByCrash(jctx) { + state := StateFailed + if jctx.Err() != nil { + state = StateCanceled + err = jctx.Err() + } + _ = m.setState(id, state, "", err.Error()) + m.publish("media.progress", map[string]any{"id": id, "model_id": modelID, "state": state, "error": err.Error()}) + } + j, _ := m.Get(id) + return j, err + } + body := map[string]any{"image": "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(raw), "repeats": p.Repeats} + if p.Upscaler != "" { + body["upscaler"] = p.Upscaler + } + if p.TileSize > 0 { + body["tile_size"] = p.TileSize + } + if p.OutputFormat != "" { + body["output_format"] = p.OutputFormat + } + payload, _ := json.Marshal(body) + requestCtx, requestCancel := context.WithTimeout(jctx, 5*time.Minute) + defer requestCancel() + req, _ := http.NewRequestWithContext(requestCtx, http.MethodPost, fmt.Sprintf("http://127.0.0.1:%d/sdcpp/v1/upscale", port), bytes.NewReader(payload)) + req.Header.Set("Content-Type", "application/json") + resp, err := m.doSD(req, port) + if err != nil { + return finishErr(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return finishErr(fmt.Errorf("sd-server upscale HTTP %d", resp.StatusCode)) + } + var result map[string]any + if err = readSDJSON(resp.Body, 128<<20, &result); err != nil { + return finishErr(err) + } + if jctx.Err() != nil { + return finishErr(jctx.Err()) + } + if !m.serverMatchesPort(modelID, port) { + return finishErr(ErrServerNotRunning) + } + outWidth, _ := result["width"].(float64) + outHeight, _ := result["height"].(float64) + maxW, maxH := 8192, 8192 + if caps.Limits.MaxUpscaleWidth > 0 && caps.Limits.MaxUpscaleWidth < maxW { + maxW = caps.Limits.MaxUpscaleWidth + } + if caps.Limits.MaxUpscaleHeight > 0 && caps.Limits.MaxUpscaleHeight < maxH { + maxH = caps.Limits.MaxUpscaleHeight + } + if outWidth < 1 || outHeight < 1 || outWidth > float64(maxW) || outHeight > float64(maxH) { + return finishErr(errors.New("sd-server upscale returned invalid output dimensions")) + } + paths, metadata, err := m.storeSDResult(id, modelID, KindImage, GenerateParams{OutputFormat: p.OutputFormat}, map[string]any{"result": result}) + if err != nil { + return finishErr(err) + } + if _, err = m.db.Exec(`UPDATE media_jobs SET state=?,output_path=?,result_json=?,width=?,height=?,finished_at=?,updated_at=? WHERE id=?`, StateComplete, firstPath(paths), string(metadata), int(outWidth), int(outHeight), now(), now(), id); err != nil { + return finishErr(err) + } + m.publish("media.progress", map[string]any{"id": id, "model_id": modelID, "state": StateComplete, "outputs": paths, "message": "Complete"}) + return m.Get(id) +} + +// stageServer shares the existing managed load/reload lifecycle. Individual +// stage cancellation stops waiting without canceling a load shared by other +// callers. The load itself retains the manager's startup deadline. +func (m *Manager) stageServer(ctx context.Context, modelID string, ov *LoadOverrides) (int, error) { + if ctx.Err() != nil { + return 0, ctx.Err() + } + if port, ok := m.ServerPort(modelID); ok && (ov == nil || m.serverSatisfies(modelID, *ov)) { + return port, nil + } + if _, ok := m.ServerPort(modelID); ok { + m.StopServer(modelID) + } + settings := DefaultLoadSettings() + if m.lib != nil { + if raw, ok := m.lib.LastGood(modelID); ok { + _ = json.Unmarshal(raw, &settings) + } + } + if ov != nil && !ov.IsZero() { + ov.Apply(&settings) + settings.SaveOnSuccess = false + } + type startResult struct { + port int + err error + } + ch := make(chan startResult, 1) + go func() { port, err := m.EnsureServer(modelID, settings); ch <- startResult{port, err} }() + select { + case r := <-ch: + return r.port, r.err + case <-ctx.Done(): + return 0, ctx.Err() + } +} diff --git a/internal/mediagen/upscale_test.go b/internal/mediagen/upscale_test.go new file mode 100644 index 0000000..2a40ab1 --- /dev/null +++ b/internal/mediagen/upscale_test.go @@ -0,0 +1,119 @@ +package mediagen + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "net/http" + "os" + "path/filepath" + "testing" + "time" +) + +func upscaleCapabilities(t *testing.T) map[string]any { + return decodeCapabilityFixture(t, `{"supported_modes":["img_gen"],"upscale":true,"upscalers":[{"name":"RGB","model":true,"image_upscale":true},{"name":"Latent","model":true,"image_upscale":false},{"name":"Nearest","model":false,"image_upscale":false}],"limits":{"max_upscale_width":8192,"max_upscale_height":8192}}`) +} + +func TestNativeUpscaleStoresManagedOutputAndDimensions(t *testing.T) { + raw, path := pngInput(t) + var request map[string]any + f := newCapabilitySD(t, upscaleCapabilities(t), func(w http.ResponseWriter, r *http.Request) bool { + if r.URL.Path != "/sdcpp/v1/upscale" { + return false + } + if r.Header.Get("Authorization") != "Bearer secret" { + http.Error(w, "missing key", 401) + return true + } + _ = json.NewDecoder(r.Body).Decode(&request) + _ = json.NewEncoder(w).Encode(map[string]any{"images": []any{map[string]any{"b64_json": base64.StdEncoding.EncodeToString(raw)}}, "output_format": "png", "width": 8, "height": 8, "scale": 2, "repeats": 1}) + return true + }) + m := newJobManager(t, f) + m.servers["model-1"].apiKey = "secret" + j, err := m.UpscaleStage(context.Background(), "model-1", UpscaleParams{ImagePath: path, Upscaler: "RGB", TileSize: 256}) + if err != nil { + t.Fatal(err) + } + if j.State != StateComplete || j.Width != 8 || j.Height != 8 || len(j.OutputPaths) != 1 { + t.Fatalf("job=%+v", j) + } + if filepath.IsAbs(j.OutputPaths[0]) { + t.Fatal("output path must be managed relative path") + } + got, err := os.ReadFile(filepath.Join(m.MediaDir(), j.OutputPaths[0])) + if err != nil || string(got) != string(raw) { + t.Fatalf("stored=%v err=%v", got, err) + } + if request["upscaler"] != "RGB" || request["repeats"] != float64(1) || request["tile_size"] != float64(256) { + t.Fatalf("request=%v", request) + } + var params UpscaleParams + _ = json.Unmarshal(j.Params, ¶ms) + if params.Image != "" || params.ImagePath != path { + t.Fatal("local input was persisted as image bytes") + } +} + +func TestUpscaleRequiresExplicitRuntimeAndRGBCatalogSupport(t *testing.T) { + _, path := pngInput(t) + for _, tc := range []struct { + name string + doc map[string]any + upscaler string + }{ + {"unknown", nil, ""}, + {"unadvertised endpoint", decodeCapabilityFixture(t, `{"supported_modes":["img_gen"],"upscalers":[{"name":"RGB","model":true,"image_upscale":true}]}`), "RGB"}, + {"legacy catalog", decodeCapabilityFixture(t, `{"upscale":true,"upscalers":[{"name":"RGB"}]}`), "RGB"}, + {"latent model", upscaleCapabilities(t), "Latent"}, + {"builtin resize", upscaleCapabilities(t), "Nearest"}, + {"no model selected", upscaleCapabilities(t), ""}, + } { + t.Run(tc.name, func(t *testing.T) { + called := false + f := newCapabilitySD(t, tc.doc, func(w http.ResponseWriter, r *http.Request) bool { + if r.URL.Path == "/sdcpp/v1/upscale" { + called = true + } + return false + }) + m := newJobManager(t, f) + if _, err := m.UpscaleStage(context.Background(), "model-1", UpscaleParams{ImagePath: path, Upscaler: tc.upscaler}); err == nil || called { + t.Fatalf("unadvertised upscale submitted: err=%v called=%v", err, called) + } + }) + } +} + +func TestUpscaleCancellationBoundsHTTPAndDoesNotStoreOutput(t *testing.T) { + _, path := pngInput(t) + f := newCapabilitySD(t, upscaleCapabilities(t), func(w http.ResponseWriter, r *http.Request) bool { + if r.URL.Path != "/sdcpp/v1/upscale" { + return false + } + var body map[string]any + _ = json.NewDecoder(r.Body).Decode(&body) + <-r.Context().Done() + return true + }) + m := newJobManager(t, f) + ctx, cancel := context.WithTimeout(context.Background(), 40*time.Millisecond) + defer cancel() + start := time.Now() + j, err := m.UpscaleStage(ctx, "model-1", UpscaleParams{ImagePath: path, Upscaler: "RGB"}) + if !errors.Is(err, context.DeadlineExceeded) || j == nil || j.State != StateCanceled || len(j.OutputPaths) != 0 || time.Since(start) > time.Second { + t.Fatalf("job=%+v err=%v elapsed=%v", j, err, time.Since(start)) + } +} + +func TestUpscaleRejectsBadRequestBounds(t *testing.T) { + _, path := pngInput(t) + m := NewManager(nil, nil, nil, nil, nil, nil) + for _, p := range []UpscaleParams{{}, {ImagePath: path, Image: "also"}, {ImagePath: path, Repeats: 5}, {ImagePath: path, Repeats: -1}, {ImagePath: path, TileSize: 3000}, {ImagePath: path, OutputFormat: "../../bad"}} { + if _, err := m.UpscaleStage(context.Background(), "model", p); err == nil { + t.Fatalf("bad request accepted: %+v", p) + } + } +} diff --git a/internal/workflow/advanced.go b/internal/workflow/advanced.go new file mode 100644 index 0000000..f55baf1 --- /dev/null +++ b/internal/workflow/advanced.go @@ -0,0 +1,292 @@ +package workflow + +import ( + "context" + "fmt" + "path/filepath" + + "github.com/openinfer/openinfer-studio/internal/mediagen" +) + +type apiEnv interface { + GenerationCapabilities(string) (*mediagen.APICapabilities, error) +} + +type capabilityResult struct { + caps *mediagen.APICapabilities + err error +} + +// A planning pass queries each loaded model once, including failed discovery. +func (b *builder) modelAPICapabilities(model string) (*mediagen.APICapabilities, error) { + if result, ok := b.apiCaps[model]; ok { + return result.caps, result.err + } + provider, ok := b.env.(apiEnv) + if !ok { + return nil, fmt.Errorf("loaded-model API capability discovery unavailable") + } + caps, err := provider.GenerationCapabilities(model) + if b.apiCaps == nil { + b.apiCaps = map[string]capabilityResult{} + } + b.apiCaps[model] = capabilityResult{caps, err} + return caps, err +} + +func (b *builder) requireFeature(n Node, model, mode, feature string) bool { + _, ok := b.env.(apiEnv) + if !ok { + b.errorf("plan.api_capability", n.ID, "", "%s requires loaded-model API capability discovery", feature) + return false + } + caps, err := b.modelAPICapabilities(model) + if err != nil { + b.errorf("plan.api_capability", n.ID, "", "cannot discover %s: %v", feature, err) + return false + } + if caps == nil || !caps.SupportsFeature(mode, feature) { + reason := "the loaded model does not advertise this feature" + if caps != nil && caps.Reason != "" { + reason = caps.Reason + } + b.errorf("plan.api_capability", n.ID, "", "%s is unavailable for %s: %s. Load a compatible model first.", feature, mode, reason) + return false + } + return true +} + +func (b *builder) sampleInputs(n Node, st *Stage, model string, video bool) { + gs := st.Generate + mode := "img_gen" + if video { + mode = "vid_gen" + } + if video && gs.Init != nil { + b.requireFeature(n, model, mode, "init_image") + } + for _, entry := range []struct { + port, feature string + target **ImageRef + }{{"mask", "mask_image", &gs.Mask}, {"control", "control_image", &gs.Control}, {"reference", "ref_images", &gs.Reference}} { + up, _, ok := b.upstream(n, entry.port) + if !ok { + continue + } + src, ok := b.imgs[up.ID] + if !ok { + continue + } + if !b.requireFeature(n, model, mode, entry.feature) { + continue + } + if entry.port == "mask" { + if gs.Init == nil { + b.errorf("plan.mask_start", n.ID, "mask", "inpainting requires an image connected to start") + continue + } + if src.w > 0 && gs.Params.Width > 0 && (src.w != gs.Params.Width || src.h != gs.Params.Height) { + b.errorf("plan.mask_size", n.ID, "mask", "mask dimensions must match the starting image") + continue + } + } + ref := src.ref + *entry.target = &ref + if ref.Stage != "" { + st.Deps = append(st.Deps, ref.Stage) + } + switch entry.port { + case "mask": + gs.Params.MaskImagePath = ref.Path + case "control": + gs.Params.ControlImagePath = ref.Path + gs.Params.ControlStrength = b.effective(n).num("control_strength") + case "reference": + if ref.Path != "" { + gs.Params.RefImagePaths = []string{ref.Path} + } + } + } + if _, ok := b.env.(apiEnv); ok { + caps, err := b.modelAPICapabilities(model) + if err != nil { + b.errorf("plan.api_capability", n.ID, "", "capability discovery failed: %v", err) + } else if caps != nil && caps.Known { + if err := caps.ValidateGeneration(gs.Params); err != nil { + b.errorf("plan.api_capability", n.ID, "", "%v", err) + } + } else if len(gs.Params.Lora) > 0 { + b.errorf("plan.api_capability", n.ID, "model", "load a model that advertises structured LoRA support first") + } + } +} + +type UpscaleStage struct { + Server string `json:"server"` + Src ImageRef `json:"src"` + Params mediagen.UpscaleParams `json:"params"` +} + +type upscaleRunner interface { + RunUpscale(context.Context, string, mediagen.UpscaleParams, *mediagen.LoadOverrides) (*mediagen.Job, error) +} + +func upscaleSpec() NodeSpec { + return NodeSpec{Type: "image.upscale", Title: "Model Upscale", Category: CatImage, APIFeature: "upscale", Description: "Use a compatible RGB upscaler advertised by the loaded model. Follow with Resize to set explicit dimensions.", Inputs: []Port{{Name: "model", Type: PortTypes{TypeModel}, Required: true}, {Name: "image", Type: PortTypes{TypeImage}, Required: true}}, Outputs: []Port{{Name: "image", Type: PortTypes{TypeImage}}}, Params: []ParamSpec{{Name: "upscaler", Kind: ParamEnum, Required: true, From: "capabilities.upscalers"}, {Name: "repeats", Kind: ParamInt, Default: 1, Min: ptr(1), Max: ptr(4)}, {Name: "tile_size", Kind: ParamInt, Default: 0, Min: ptr(0), Max: ptr(2048)}}} +} + +func (b *builder) upscale(n Node) { + ck, _, ok := b.upstream(n, "model") + if !ok { + return + } + for ck.Type == "lora.load" { + ck, _, _ = b.upstream(ck, "model") + } + if ck.Type != "checkpoint.load" { + b.errorf("plan.model_chain", n.ID, "model", "Model Upscale needs a checkpoint model") + return + } + ref, _ := b.effective(ck)["model"].(map[string]any) + id, _ := ref["library_id"].(string) + info := b.model(ck.ID, id) + if info == nil { + return + } + up, _, ok := b.upstream(n, "image") + if !ok { + return + } + src, ok := b.imgs[up.ID] + if !ok { + return + } + _, ok = b.env.(apiEnv) + if !ok { + b.errorf("plan.api_capability", n.ID, "", "load a model with native RGB upscaling support first") + return + } + caps, err := b.modelAPICapabilities(id) + if err != nil || caps == nil || !caps.Known || !caps.Upscale { + b.errorf("plan.api_capability", n.ID, "", "the loaded model does not advertise native RGB upscaling") + return + } + p := b.effective(n) + name := p.str("upscaler") + if name != "" { + allowed := false + for _, u := range caps.Upscalers { + allowed = allowed || (u.Name == name && u.Model && u.ImageUpscale) + } + if !allowed { + b.errorf("plan.upscaler", n.ID, "", "upscaler %q is not advertised as a compatible RGB model", name) + return + } + } + sig := signature(id, mediagen.LoadOverrides{}) + b.useServer(ServerNeed{Signature: sig, ModelID: id, ModelName: info.Name}) + st := b.newStage(n, StageUpscale) + st.Upscale = &UpscaleStage{Server: sig, Src: src.ref, Params: mediagen.UpscaleParams{Upscaler: name, Repeats: p.integer("repeats"), TileSize: p.integer("tile_size"), OutputFormat: "png"}} + if src.ref.Stage != "" { + st.Deps = []string{src.ref.Stage} + } + // Scale is not advertised by every runtime. Unknown dimensions stay unknown. + b.imgs[n.ID] = imgInfo{ref: ImageRef{Stage: st.ID}} +} + +func (e *Executor) runUpscale(r *run, st Stage, servers map[string]ServerNeed) ([]string, string, string, error) { + runner, ok := e.runner.(upscaleRunner) + if !ok { + return nil, "", "", fmt.Errorf("native upscaling unavailable") + } + need, ok := servers[st.Upscale.Server] + if !ok { + return nil, "", "", fmt.Errorf("unknown upscale server") + } + path, err := e.resolveImage(r, st.Upscale.Src) + if err != nil { + return nil, "", "", err + } + p := st.Upscale.Params + p.ImagePath = path + job, err := runner.RunUpscale(r.ctx, need.ModelID, p, &need.Overrides) + if err != nil { + return nil, "", "", err + } + if job == nil || len(job.OutputPaths) == 0 { + return nil, "", "", fmt.Errorf("upscale returned no image") + } + return job.OutputPaths, job.ID, "upscaled", nil +} + +func (b *builder) structuredLoras(n Node, model string, loras []Node) []mediagen.Lora { + if len(loras) == 0 { + return nil + } + // The native server contract uses structured LoRA fields. The media manager + // validates them again after loading, when the model-specific catalog is known. + result := []mediagen.Lora{} + for i := len(loras) - 1; i >= 0; i-- { + p := b.effective(loras[i]) + result = append(result, mediagen.Lora{Path: p.str("path"), Multiplier: p.num("strength")}) + } + return result +} + +// Native APIs resolve structured entries through a catalog fixed at launch. +// Keep a compatible existing catalog, or use the selected files' common tree. +func (b *builder) configureLoraCatalog(n Node, model string, loras []mediagen.Lora, ov *mediagen.LoadOverrides) { + if len(loras) == 0 { + return + } + inside := func(root, path string) bool { + if !filepath.IsAbs(root) { + return false + } + rel, err := filepath.Rel(root, path) + return err == nil && filepath.IsLocal(rel) + } + settings, running := b.env.RunningServer(model) + allInside := running && filepath.IsAbs(settings.LoraModelDir) + for _, lora := range loras { + allInside = allInside && inside(settings.LoraModelDir, lora.Path) + } + if allInside { + ov.LoraModelDir = settings.LoraModelDir + return + } + // Custom runtimes may advertise absolute catalog paths directly. + if caps, err := b.modelAPICapabilities(model); err == nil && caps != nil && caps.Known { + allListed := true + for _, lora := range loras { + listed := false + for _, entry := range caps.Loras { + listed = listed || (filepath.IsAbs(entry.Path) && filepath.Clean(entry.Path) == filepath.Clean(lora.Path)) + } + allListed = allListed && listed + } + if allListed { + return + } + } + if !b.launchCaps.Has("lora-model-dir") { + b.errorf("plan.lora_catalog", n.ID, "model", "the runtime cannot configure a LoRA catalog; select files already advertised by the loaded model") + return + } + root := filepath.Dir(loras[0].Path) + for _, lora := range loras { + for !inside(root, lora.Path) { + parent := filepath.Dir(root) + if parent == root { + b.errorf("plan.lora_catalog", n.ID, "model", "place the selected LoRAs under one shared model directory") + return + } + root = parent + } + } + if filepath.Dir(root) == root { + b.errorf("plan.lora_catalog", n.ID, "model", "place the selected LoRAs under one shared model directory; a filesystem root cannot be used as a catalog") + return + } + ov.LoraModelDir = root +} diff --git a/internal/workflow/advanced_test.go b/internal/workflow/advanced_test.go new file mode 100644 index 0000000..dead2f1 --- /dev/null +++ b/internal/workflow/advanced_test.go @@ -0,0 +1,93 @@ +package workflow + +import ( + "testing" + + "github.com/openinfer/openinfer-studio/internal/mediagen" +) + +type capableEnv struct { + *fakeEnv + caps *mediagen.APICapabilities +} + +func (e capableEnv) GenerationCapabilities(string) (*mediagen.APICapabilities, error) { + return e.caps, nil +} + +func imageCapabilityEnv(features map[string]bool) capableEnv { + return capableEnv{newFakeEnv(), &mediagen.APICapabilities{Known: true, SupportedModes: []string{"img_gen"}, FeaturesByMode: map[string]map[string]bool{"img_gen": features}, Samplers: []string{"euler"}, Schedulers: []string{"karras"}}} +} + +func maskGraph() Graph { + g := txt2img() + g.Nodes = append(g.Nodes, node("n9", "image.load", map[string]any{"path": "/img/fisherman.png"}), node("n10", "mask.load", map[string]any{"path": "/img/mask.png"})) + g.Edges[5] = wire("n9", "image", "n5", "start") + g.Edges = append(g.Edges, wire("n10", "mask", "n5", "mask")) + return g +} + +func TestInpaintingPlansOnlyWithAdvertisedMaskSupport(t *testing.T) { + env := imageCapabilityEnv(map[string]bool{"init_image": true, "mask_image": true}) + env.files["/img/mask.png"] = FileMeta{Size: 200, Width: 640, Height: 800} + plan, issues := Build(maskGraph(), NewRegistry(), env, allCaps, Options{}) + if plan == nil || HasErrors(issues) { + t.Fatalf("supported inpainting: %+v", issues) + } + gs := plan.Stages[0].Generate + if gs.Mask == nil || gs.Params.MaskImagePath != "/img/mask.png" || gs.Init == nil { + t.Fatalf("mask not forwarded: %+v", gs) + } + env.caps.FeaturesByMode["img_gen"]["mask_image"] = false + if plan, issues := Build(maskGraph(), NewRegistry(), env, allCaps, Options{}); plan != nil || !hasIssue(issues, "plan.api_capability") { + t.Fatalf("unsupported mask accepted: %+v", issues) + } + env.caps.FeaturesByMode["img_gen"]["mask_image"] = true + env.files["/img/mask.png"] = FileMeta{Size: 200, Width: 32, Height: 32} + if plan, issues := Build(maskGraph(), NewRegistry(), env, allCaps, Options{}); plan != nil || !hasIssue(issues, "plan.mask_size") { + t.Fatalf("mismatched mask accepted: %+v", issues) + } +} + +func TestControlAndReferenceInputsUseNativeFields(t *testing.T) { + env := imageCapabilityEnv(map[string]bool{"control_image": true, "ref_images": true}) + g := txt2img() + g.Nodes = append(g.Nodes, node("n9", "image.load", map[string]any{"path": "/img/fisherman.png"})) + g.Edges = append(g.Edges, wire("n9", "image", "n5", "control"), wire("n9", "image", "n5", "reference")) + plan, issues := Build(g, NewRegistry(), env, allCaps, Options{}) + if plan == nil { + t.Fatalf("supported inputs rejected: %+v", issues) + } + gs := plan.Stages[0].Generate + if gs.Control == nil || gs.Reference == nil || gs.Params.ControlStrength != 0.9 || len(gs.Params.RefImagePaths) != 1 { + t.Fatalf("native input missing: %+v", gs) + } +} + +func TestLoadedModelRejectsUnadvertisedSamplerBeforeQueue(t *testing.T) { + env := imageCapabilityEnv(map[string]bool{}) + g := txt2img() + g.Nodes[4].Params["sampler"] = "unsupported" + plan, issues := Build(g, NewRegistry(), env, allCaps, Options{}) + if plan != nil || !hasIssue(issues, "plan.api_capability") { + t.Fatalf("unadvertised sampler accepted: %+v", issues) + } +} + +func TestNativeUpscaleAcceptsOnlyAdvertisedRGBModels(t *testing.T) { + env := imageCapabilityEnv(map[string]bool{}) + env.caps.Upscale = true + env.caps.Upscalers = []mediagen.UpscalerEntry{{Name: "rgb", Model: true, ImageUpscale: true}, {Name: "latent", Model: true}} + g := txt2img() + g.Nodes = append(g.Nodes, node("n9", "image.upscale", map[string]any{"upscaler": "rgb"})) + g.Edges[6] = wire("n9", "image", "n6", "image") + g.Edges = append(g.Edges, wire("n1", "model", "n9", "model"), wire("n5", "image", "n9", "image")) + plan, issues := Build(g, NewRegistry(), env, allCaps, Options{}) + if plan == nil || len(plan.Stages) != 3 || plan.Stages[1].Upscale == nil { + t.Fatalf("native upscale rejected: %+v", issues) + } + g.Nodes[len(g.Nodes)-1].Params["upscaler"] = "latent" + if plan, issues := Build(g, NewRegistry(), env, allCaps, Options{}); plan != nil || !hasIssue(issues, "plan.upscaler") { + t.Fatalf("latent upscaler accepted: %+v", issues) + } +} diff --git a/internal/workflow/cache.go b/internal/workflow/cache.go new file mode 100644 index 0000000..db4f8a2 --- /dev/null +++ b/internal/workflow/cache.go @@ -0,0 +1,168 @@ +package workflow + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "os" + "path/filepath" + + "github.com/openinfer/openinfer-studio/internal/mediagen" +) + +type cacheEntry struct { + Outputs []string `json:"outputs"` + Sizes []int64 `json:"sizes"` + Modified []int64 `json:"modified"` + JobID string `json:"job_id"` + Message string `json:"message"` + Seed *int64 `json:"seed,omitempty"` +} + +// A generation cache identity includes the effective running configuration and +// runtime binary, as supplied by the media manager. Unknown identities do not cache. +type generationCacheIdentity interface { + WorkflowCacheIdentity(modelID string, overrides *mediagen.LoadOverrides) (string, error) +} + +func (e *Executor) cacheKey(r *run, st Stage, servers map[string]ServerNeed) string { + if st.Volatile || st.Kind == StageSave { + return "" + } + identity := "cpu-image-v1" + if st.Kind == StageGenerate || st.Kind == StageUpscale { + provider, ok := e.runner.(generationCacheIdentity) + if !ok { + return "" + } + server := "" + if st.Generate != nil { + server = st.Generate.Server + } else { + server = st.Upscale.Server + } + need := servers[server] + var err error + identity, err = provider.WorkflowCacheIdentity(need.ModelID, &need.Overrides) + if err != nil || identity == "" { + return "" + } + } + inputs := e.cacheInputIdentity(r, st) + if inputs == "" { + return "" + } + sum := sha256.Sum256([]byte(identity + ":" + inputs + ":" + st.Key)) + return hex.EncodeToString(sum[:]) +} + +func (e *Executor) cacheInputIdentity(r *run, st Stage) string { + identity := "inputs-v1" + paths := []string{} + add := func(ref *ImageRef) { + if ref != nil && ref.Path != "" { + paths = append(paths, ref.Path) + } + } + if st.Generate != nil { + add(st.Generate.Init) + add(st.Generate.Mask) + add(st.Generate.Control) + add(st.Generate.Reference) + for _, l := range st.Generate.Params.Lora { + paths = append(paths, l.Path) + } + } + if st.Resize != nil { + add(&st.Resize.Src) + } + if st.Image != nil { + add(&st.Image.Src) + add(st.Image.Other) + } + if st.Upscale != nil { + add(&st.Upscale.Src) + } + for _, path := range paths { + real, err := filepath.EvalSymlinks(path) + if err != nil { + return "" + } + info, err := os.Stat(real) + if err != nil { + return "" + } + raw, _ := json.Marshal(fileIdentity{Path: real, Size: info.Size(), ModTime: info.ModTime().UnixNano()}) + identity += ":" + string(raw) + } + for _, dep := range st.Deps { + for _, rel := range r.outputs[dep] { + info, err := os.Stat(filepath.Join(e.runner.MediaDir(), filepath.FromSlash(rel))) + if err != nil { + return "" + } + raw, _ := json.Marshal(fileIdentity{Path: rel, Size: info.Size(), ModTime: info.ModTime().UnixNano()}) + identity += ":" + string(raw) + } + } + return identity +} + +func (e *Executor) cached(key string) (cacheEntry, bool) { + var entry cacheEntry + if e.db == nil || key == "" { + return entry, false + } + var raw string + if e.db.QueryRow(`SELECT entry_json FROM workflow_cache WHERE key=?`, key).Scan(&raw) != nil || json.Unmarshal([]byte(raw), &entry) != nil { + return entry, false + } + if len(entry.Outputs) == 0 || len(entry.Outputs) != len(entry.Sizes) || len(entry.Outputs) != len(entry.Modified) { + return entry, false + } + for i, rel := range entry.Outputs { + if filepath.IsAbs(rel) || !filepath.IsLocal(filepath.FromSlash(rel)) { + return cacheEntry{}, false + } + path := filepath.Join(e.runner.MediaDir(), filepath.FromSlash(rel)) + real, err := filepath.EvalSymlinks(path) + if err != nil { + return cacheEntry{}, false + } + root, err := filepath.EvalSymlinks(e.runner.MediaDir()) + if err != nil { + return cacheEntry{}, false + } + inside, err := filepath.Rel(root, real) + if err != nil || !filepath.IsLocal(inside) { + return cacheEntry{}, false + } + st, err := os.Stat(real) + if err != nil || !st.Mode().IsRegular() || st.Size() != entry.Sizes[i] || st.ModTime().UnixNano() != entry.Modified[i] { + return cacheEntry{}, false + } + } + return entry, true +} + +func (e *Executor) putCache(key string, entry cacheEntry) { + if e.db == nil || key == "" { + return + } + for _, rel := range entry.Outputs { + st, err := os.Stat(filepath.Join(e.runner.MediaDir(), filepath.FromSlash(rel))) + if err != nil { + return + } + entry.Sizes = append(entry.Sizes, st.Size()) + entry.Modified = append(entry.Modified, st.ModTime().UnixNano()) + } + raw, err := json.Marshal(entry) + if err != nil { + return + } + if _, err := e.db.Exec(`INSERT INTO workflow_cache(key,entry_json,updated_at) VALUES(?,?,?) + ON CONFLICT(key) DO UPDATE SET entry_json=excluded.entry_json,updated_at=excluded.updated_at`, key, string(raw), ts()); err != nil { + e.log.Warn("saving workflow cache", "err", err) + } +} diff --git a/internal/workflow/exec.go b/internal/workflow/exec.go index f4da209..61f4ce2 100644 --- a/internal/workflow/exec.go +++ b/internal/workflow/exec.go @@ -2,12 +2,15 @@ package workflow import ( "context" + "database/sql" + "encoding/json" "errors" "fmt" "image" "image/png" "io" "log/slog" + "math" "math/rand/v2" "net/url" "os" @@ -69,6 +72,8 @@ type progressStageRunner interface { // NodeState is one node's progress within a run. type NodeState struct { + Cached bool `json:"cached,omitempty"` + Seed *int64 `json:"seed,omitempty"` Progress *mediagen.Progress `json:"progress,omitempty"` State string `json:"state"` Message string `json:"message,omitempty"` @@ -80,13 +85,17 @@ type NodeState struct { // RunView is a copy of a run's state, safe to serialise. type RunView struct { - ID string `json:"id"` - State string `json:"state"` - Error string `json:"error,omitempty"` - Nodes map[string]NodeState `json:"nodes"` - CreatedAt string `json:"created_at"` - StartedAt string `json:"started_at,omitempty"` - FinishedAt string `json:"finished_at,omitempty"` + SourceGraph *Graph `json:"source_graph,omitempty"` + Graph *Graph `json:"graph,omitempty"` + WorkflowID string `json:"workflow_id,omitempty"` + Only string `json:"only,omitempty"` + ID string `json:"id"` + State string `json:"state"` + Error string `json:"error,omitempty"` + Nodes map[string]NodeState `json:"nodes"` + CreatedAt string `json:"created_at"` + StartedAt string `json:"started_at,omitempty"` + FinishedAt string `json:"finished_at,omitempty"` } type run struct { @@ -94,6 +103,7 @@ type run struct { plan *Plan ctx context.Context cancel context.CancelFunc + force bool outputs map[string][]string // stage id -> media-root-relative outputs } @@ -106,11 +116,15 @@ type Executor struct { events EventSink log *slog.Logger - mu sync.Mutex - runs map[string]*run - order []string - queue chan *run - quit chan struct{} + mu sync.Mutex + runs map[string]*run + order []string + queue chan *run + quit chan struct{} + done chan struct{} + closeOnce sync.Once + db *sql.DB + initErr error } // NewExecutor starts the single worker. Call Close to stop it. @@ -120,7 +134,7 @@ func NewExecutor(runner StageRunner, events EventSink, log *slog.Logger) *Execut } e := &Executor{ runner: runner, events: events, log: log, - runs: map[string]*run{}, queue: make(chan *run, maxQueuedRuns), quit: make(chan struct{}), + runs: map[string]*run{}, queue: make(chan *run, maxQueuedRuns), quit: make(chan struct{}), done: make(chan struct{}), } go e.loop() return e @@ -128,12 +142,33 @@ func NewExecutor(runner StageRunner, events EventSink, log *slog.Logger) *Execut // Close stops the worker and cancels the active run. func (e *Executor) Close() { - close(e.quit) - e.mu.Lock() - for _, r := range e.runs { - r.cancel() - } - e.mu.Unlock() + e.closeOnce.Do(func() { + close(e.quit) + e.mu.Lock() + for _, r := range e.runs { + r.cancel() + if r.view.State == RunQueued { + r.view.State, r.view.FinishedAt = RunCanceled, ts() + for id, ns := range r.view.Nodes { + ns.State = NodeCanceled + r.view.Nodes[id] = ns + } + if err := e.persistLocked(r); err != nil { + e.log.Error("saving canceled workflow", "err", err) + } + } + } + e.mu.Unlock() + <-e.done + }) +} + +// NewPersistentExecutor adds history and result reuse to the same serialized queue. +func NewPersistentExecutor(runner StageRunner, events EventSink, db *sql.DB) *Executor { + e := NewExecutor(runner, events, nil) + e.db = db + e.initErr = e.recoverRuns() + return e } func (e *Executor) publish(event string, payload map[string]any) { @@ -147,12 +182,49 @@ func ts() string { return time.Now().UTC().Format(time.RFC3339Nano) } // Submit queues a plan. It returns immediately; progress arrives as // workflow.* events and through Get. func (e *Executor) Submit(plan *Plan) (RunView, error) { + return e.SubmitWithOptions(plan, RunOptions{}) +} + +func (e *Executor) SubmitWithOptions(plan *Plan, opts RunOptions) (RunView, error) { + if e.initErr != nil { + return RunView{}, e.initErr + } + select { + case <-e.quit: + return RunView{}, errors.New("workflow executor stopped") + default: + } if plan == nil || len(plan.Stages) == 0 { return RunView{}, errors.New("nothing to run") } + raw, err := json.Marshal(plan) + if err != nil { + return RunView{}, err + } + var snapshot Plan + if err := json.Unmarshal(raw, &snapshot); err != nil { + return RunView{}, err + } + plan = &snapshot + var graph *Graph + if opts.Graph != nil { + raw, err := json.Marshal(opts.Graph) + if err != nil { + return RunView{}, err + } + graph = &Graph{} + if err := json.Unmarshal(raw, graph); err != nil { + return RunView{}, err + } + } ctx, cancel := context.WithCancel(context.Background()) - r := &run{plan: plan, ctx: ctx, cancel: cancel, outputs: map[string][]string{}} - r.view = RunView{ID: uuid.NewString(), State: RunQueued, Nodes: map[string]NodeState{}, CreatedAt: ts()} + r := &run{plan: plan, ctx: ctx, cancel: cancel, force: opts.Force, outputs: map[string][]string{}} + r.view = RunView{ID: uuid.NewString(), State: RunQueued, Nodes: map[string]NodeState{}, CreatedAt: ts(), Graph: graph, WorkflowID: opts.WorkflowID, Only: opts.Only} + if graph != nil { + raw, _ := json.Marshal(graph) + r.view.SourceGraph = &Graph{} + _ = json.Unmarshal(raw, r.view.SourceGraph) + } for _, st := range plan.Stages { r.view.Nodes[st.NodeID] = NodeState{State: NodePending} for _, id := range st.Absorbs { @@ -162,6 +234,23 @@ func (e *Executor) Submit(plan *Plan) (RunView, error) { e.mu.Lock() select { + case <-e.quit: + e.mu.Unlock() + cancel() + return RunView{}, errors.New("workflow executor stopped") + default: + } + if len(e.queue) >= cap(e.queue) { + e.mu.Unlock() + cancel() + return RunView{}, ErrQueueFull + } + if err := e.persistLocked(r); err != nil { + e.mu.Unlock() + cancel() + return RunView{}, err + } + select { case e.queue <- r: default: e.mu.Unlock() @@ -193,8 +282,32 @@ func (e *Executor) evictLocked() { func (r *run) snapshotLocked() RunView { v := r.view + if r.view.Graph != nil { + raw, _ := json.Marshal(r.view.Graph) + v.Graph = &Graph{} + _ = json.Unmarshal(raw, v.Graph) + } + if r.view.SourceGraph != nil { + raw, _ := json.Marshal(r.view.SourceGraph) + v.SourceGraph = &Graph{} + _ = json.Unmarshal(raw, v.SourceGraph) + } v.Nodes = make(map[string]NodeState, len(r.view.Nodes)) for k, n := range r.view.Nodes { + n.Outputs = append([]string(nil), n.Outputs...) + n.FileURLs = append([]string(nil), n.FileURLs...) + if n.Seed != nil { + seed := *n.Seed + n.Seed = &seed + } + if n.Progress != nil { + progress := *n.Progress + if progress.ServerResponding != nil { + responding := *progress.ServerResponding + progress.ServerResponding = &responding + } + n.Progress = &progress + } v.Nodes[k] = n } return v @@ -206,7 +319,8 @@ func (e *Executor) Get(id string) (RunView, bool) { defer e.mu.Unlock() r, ok := e.runs[id] if !ok { - return RunView{}, false + v, err := e.storedRun(id) + return v, err == nil } return r.snapshotLocked(), true } @@ -231,6 +345,9 @@ func (e *Executor) Cancel(id string) error { r, ok := e.runs[id] if !ok { e.mu.Unlock() + if _, err := e.storedRun(id); err == nil { + return nil + } return ErrRunNotFound } queued := r.view.State == RunQueued @@ -242,6 +359,13 @@ func (e *Executor) Cancel(id string) error { r.view.Nodes[k] = n } } + if queued { + if err := e.persistLocked(r); err != nil { + e.mu.Unlock() + r.cancel() + return err + } + } e.mu.Unlock() r.cancel() if queued { @@ -251,6 +375,7 @@ func (e *Executor) Cancel(id string) error { } func (e *Executor) loop() { + defer close(e.done) for { select { case <-e.quit: @@ -288,6 +413,15 @@ func (e *Executor) setNode(r *run, nodeID string, ns NodeState) { if ns.Progress != nil { payload["progress"] = ns.Progress } + if ns.Cached { + payload["cached"] = true + } + if ns.Seed != nil { + payload["seed"] = *ns.Seed + } + if ns.State == NodeDone || ns.State == NodeFailed || ns.State == NodeCanceled { + e.persist(r) + } e.publish("workflow.node_state", payload) } @@ -303,6 +437,9 @@ func (e *Executor) finish(r *run, state, msg string) { r.view.Error = msg r.view.FinishedAt = ts() id := r.view.ID + if err := e.persistLocked(r); err != nil { + e.log.Error("saving finished workflow", "err", err) + } e.mu.Unlock() payload := map[string]any{"run_id": id, "state": state} if msg != "" { @@ -317,6 +454,7 @@ func (e *Executor) execute(r *run) { r.view.StartedAt = ts() id := r.view.ID e.mu.Unlock() + e.persist(r) e.publish("workflow.run_started", map[string]any{"run_id": id}) servers := map[string]ServerNeed{} @@ -339,7 +477,20 @@ func (e *Executor) execute(r *run) { e.setNode(r, st.NodeID, NodeState{State: NodeRunning}) start := time.Now() - outs, jobID, msg, err := e.runStage(r, st, servers, prefix) + inputIdentity := e.cacheInputIdentity(r, st) + key := e.cacheKey(r, st, servers) + entry, hit := e.cached(key) + if r.force { + hit = false + } + var outs []string + var jobID, msg string + var err error + if hit { + outs, jobID, msg = entry.Outputs, entry.JobID, entry.Message + } else { + outs, jobID, msg, err = e.runStage(r, st, servers, prefix) + } elapsed := time.Since(start).Milliseconds() if err != nil { if r.ctx.Err() != nil { @@ -360,7 +511,24 @@ func (e *Executor) execute(r *run) { e.setNode(r, a, NodeState{State: NodeDone}) } } + e.mu.Lock() + seed := r.view.Nodes[st.NodeID].Seed + e.mu.Unlock() + if hit { + seed = entry.Seed + e.recordSeed(r, st.NodeID, seed) + } + if !hit { + // A source or running configuration changed during execution: this + // result cannot be attributed to the new identity. A newly loaded + // server may establish its first identity only after generating. + resolvedKey := e.cacheKey(r, st, servers) + if inputIdentity != "" && inputIdentity == e.cacheInputIdentity(r, st) && (key == "" || key == resolvedKey) { + e.putCache(resolvedKey, cacheEntry{Outputs: outs, JobID: jobID, Message: msg, Seed: seed}) + } + } e.setNode(r, st.NodeID, NodeState{ + Cached: hit, Seed: seed, State: NodeDone, Message: msg, Outputs: outs, FileURLs: e.fileURLs(outs), JobID: jobID, Millis: elapsed, }) } @@ -397,6 +565,11 @@ func (e *Executor) runStage(r *run, st Stage, servers map[string]ServerNeed, pre case StageResize: outs, err = e.runResize(r, st, prefix) return outs, "", "", err + case StageUpscale: + return e.runUpscale(r, st, servers) + case StageImage: + outs, err = e.runImage(r, st, prefix) + return outs, "", "", err case StageSave: outs, err = e.runSave(r, st, prefix) return outs, "", "", err @@ -411,11 +584,14 @@ func (e *Executor) runGenerate(r *run, st Stage, servers map[string]ServerNeed) return nil, "", "", fmt.Errorf("plan references unknown server %q", gs.Server) } p := gs.Params + p.CFGScaleExplicit = true + p.GuidanceExplicit = true if p.Seed < 0 { // Resolve "random" here so the run is reproducible and the seed that // made an image is visible on the node and in the job row. p.Seed = 1 + rand.Int64N(1<<31-1) } + e.recordSeed(r, st.NodeID, &p.Seed) if gs.Init != nil { path, err := e.resolveImage(r, *gs.Init) if err != nil { @@ -423,6 +599,36 @@ func (e *Executor) runGenerate(r *run, st Stage, servers map[string]ServerNeed) } p.InitImagePath = path } + for _, input := range []struct { + ref *ImageRef + dst *string + }{{gs.Mask, &p.MaskImagePath}, {gs.Control, &p.ControlImagePath}} { + if input.ref != nil { + path, err := e.resolveImage(r, *input.ref) + if err != nil { + return nil, "", "", err + } + *input.dst = path + } + } + if gs.Reference != nil { + path, err := e.resolveImage(r, *gs.Reference) + if err != nil { + return nil, "", "", err + } + p.RefImagePaths = []string{path} + } + if p.InitImagePath != "" && (p.Width == 0 || p.Height == 0) { + img, err := readImage(p.InitImagePath) + if err != nil { + return nil, "", "", err + } + p.Width, p.Height = img.Bounds().Dx(), img.Bounds().Dy() + if p.Width > 4096 || p.Height > 4096 { + return nil, "", "", fmt.Errorf("starting image exceeds 4096 × 4096; resize it before sampling") + } + } + ov := need.Overrides var job *mediagen.Job var err error @@ -460,10 +666,10 @@ func (e *Executor) resolveImage(r *run, ref ImageRef) (string, error) { e.mu.Lock() outs := r.outputs[ref.Stage] e.mu.Unlock() - if len(outs) == 0 { - return "", fmt.Errorf("stage %s produced no image", ref.Stage) + if ref.Index < 0 || ref.Index >= len(outs) { + return "", fmt.Errorf("stage %s has no image at index %d", ref.Stage, ref.Index) } - return filepath.Join(e.runner.MediaDir(), filepath.FromSlash(outs[0])), nil + return filepath.Join(e.runner.MediaDir(), filepath.FromSlash(outs[ref.Index])), nil } func (e *Executor) runResize(r *run, st Stage, prefix string) ([]string, error) { @@ -491,7 +697,18 @@ func (e *Executor) runResize(r *run, st Stage, prefix string) ([]string, error) if err != nil { return nil, err } - out := resizeImage(img, rz.Width, rz.Height, rz.Filter) + width, height := rz.Width, rz.Height + if width == 0 && height == 0 && rz.Mode != "size" { + width = int(math.Round(float64(cfg.Width) * rz.Scale)) + height = int(math.Round(float64(cfg.Height) * rz.Scale)) + } + if width < 16 || height < 16 || width > 4096 || height > 4096 { + return nil, fmt.Errorf("Resize would produce %d × %d; the limit is 16 to 4096 on each side", width, height) + } + if err := r.ctx.Err(); err != nil { + return nil, err + } + out := resizeImage(img, width, height, rz.Filter) rel := filepath.ToSlash(filepath.Join("images", fmt.Sprintf("wf-%s-%s.png", prefix, st.ID))) dest := filepath.Join(e.runner.MediaDir(), filepath.FromSlash(rel)) @@ -560,7 +777,7 @@ func (e *Executor) runSave(r *run, st Stage, prefix string) ([]string, error) { name := savePrefix(sv.Prefix, fallback) var outs []string for i, src := range sources { - rel := filepath.ToSlash(filepath.Join(sub, fmt.Sprintf("%s%s-%d%s", name, prefix, i, strings.ToLower(filepath.Ext(src))))) + rel := filepath.ToSlash(filepath.Join(sub, fmt.Sprintf("%s%s-%s-%d%s", name, prefix, st.ID, i, strings.ToLower(filepath.Ext(src))))) if err := copyFileAtomic(src, filepath.Join(e.runner.MediaDir(), filepath.FromSlash(rel))); err != nil { return nil, err } diff --git a/internal/workflow/files.go b/internal/workflow/files.go new file mode 100644 index 0000000..bdb356e --- /dev/null +++ b/internal/workflow/files.go @@ -0,0 +1,83 @@ +package workflow + +import ( + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" +) + +type workflowFile struct { + Format string `json:"format"` + Version int `json:"version"` + Name string `json:"name"` + Graph json.RawMessage `json:"graph"` +} + +func (s *Store) Import(path string) (*Record, error) { + if !filepath.IsAbs(path) { + return nil, invalid("choose an absolute workflow file path") + } + f, err := os.Open(path) + if err != nil { + return nil, invalid("read workflow: %v", err) + } + defer f.Close() + st, err := f.Stat() + if err != nil || !st.Mode().IsRegular() || st.Size() > maxGraphBytes+4096 { + return nil, invalid("workflow must be a regular JSON file of at most 2 MiB") + } + raw, err := io.ReadAll(io.LimitReader(f, maxGraphBytes+4097)) + if err != nil { + return nil, err + } + var doc workflowFile + if err := json.Unmarshal(raw, &doc); err != nil { + return nil, invalid("invalid workflow JSON: %v", err) + } + graph := json.RawMessage(raw) + name := doc.Name + if len(doc.Graph) > 0 { + if doc.Format != "openinfer-workflow" || doc.Version != 1 { + return nil, invalid("unsupported workflow file format") + } + graph = doc.Graph + } + if name == "" { + name = filepath.Base(path) + } + return s.Create(name, graph) +} + +func (s *Store) Export(id, path string) error { + if !filepath.IsAbs(path) || filepath.Ext(path) != ".json" { + return invalid("choose an absolute .json workflow path") + } + rec, err := s.Get(id) + if err != nil { + return err + } + raw, err := json.MarshalIndent(workflowFile{Format: "openinfer-workflow", Version: 1, Name: rec.Name, Graph: rec.Graph}, "", " ") + if err != nil { + return err + } + f, err := os.CreateTemp(filepath.Dir(path), ".openinfer-workflow-*.json") + if err != nil { + return fmt.Errorf("create export: %w", err) + } + temp := f.Name() + defer os.Remove(temp) + if _, err = f.Write(append(raw, '\n')); err != nil { + f.Close() + return err + } + if err = f.Sync(); err != nil { + f.Close() + return err + } + if err = f.Close(); err != nil { + return err + } + return os.Rename(temp, path) +} diff --git a/internal/workflow/history.go b/internal/workflow/history.go new file mode 100644 index 0000000..5f5672a --- /dev/null +++ b/internal/workflow/history.go @@ -0,0 +1,156 @@ +package workflow + +import ( + "database/sql" + "encoding/json" + "fmt" +) + +// RunOptions is captured at submission, independently of subsequent editor changes. +type RunOptions struct { + Graph *Graph + WorkflowID string + Only string + Force bool +} + +func (e *Executor) persistLocked(r *run) error { + if e.db == nil { + return nil + } + raw, err := json.Marshal(r.view) + if err != nil { + return err + } + _, err = e.db.Exec(`INSERT INTO workflow_runs(id,state,created_at,view_json) VALUES(?,?,?,?) + ON CONFLICT(id) DO UPDATE SET state=excluded.state,view_json=excluded.view_json`, + r.view.ID, r.view.State, r.view.CreatedAt, string(raw)) + return err +} + +func (e *Executor) persist(r *run) { + e.mu.Lock() + defer e.mu.Unlock() + if err := e.persistLocked(r); err != nil { + e.log.Error("saving workflow run", "run", r.view.ID, "err", err) + } +} + +func (e *Executor) storedRun(id string) (RunView, error) { + var v RunView + if e.db == nil { + return v, sql.ErrNoRows + } + var raw string + if err := e.db.QueryRow(`SELECT view_json FROM workflow_runs WHERE id=?`, id).Scan(&raw); err != nil { + return v, err + } + err := json.Unmarshal([]byte(raw), &v) + return v, err +} + +// History merges durable results with authoritative in-flight state. +func (e *Executor) History() ([]RunView, error) { + if e.db == nil { + return e.List(), nil + } + rows, err := e.db.Query(`SELECT view_json FROM workflow_runs ORDER BY created_at DESC LIMIT 200`) + if err != nil { + return nil, err + } + out := []RunView{} + for rows.Next() { + var raw string + var v RunView + if err := rows.Scan(&raw); err != nil { + rows.Close() + return nil, err + } + if err := json.Unmarshal([]byte(raw), &v); err != nil { + rows.Close() + return nil, err + } + out = append(out, v) + } + err = rows.Err() + rows.Close() + if err != nil { + return nil, err + } + e.mu.Lock() + defer e.mu.Unlock() + for i := range out { + if r := e.runs[out[i].ID]; r != nil { + out[i] = r.snapshotLocked() + } + } + return out, nil +} + +// Interrupted work is recorded as canceled after restart, never silently replayed. +func (e *Executor) recoverRuns() error { + rows, err := e.db.Query(`SELECT view_json FROM workflow_runs WHERE state IN ('queued','running')`) + if err != nil { + return err + } + views := []RunView{} + for rows.Next() { + var raw string + var v RunView + if err := rows.Scan(&raw); err != nil { + rows.Close() + return err + } + if err := json.Unmarshal([]byte(raw), &v); err != nil { + rows.Close() + return err + } + views = append(views, v) + } + err = rows.Err() + rows.Close() + if err != nil { + return err + } + for _, v := range views { + v.State, v.Error, v.FinishedAt = RunCanceled, "Interrupted when the backend stopped", ts() + for id, ns := range v.Nodes { + if ns.State == NodePending || ns.State == NodeRunning { + ns.State = NodeCanceled + v.Nodes[id] = ns + } + } + if err := e.persistLocked(&run{view: v}); err != nil { + return fmt.Errorf("recover run %s: %w", v.ID, err) + } + } + return nil +} + +func (e *Executor) recordSeed(r *run, nodeID string, seed *int64) { + if seed == nil { + return + } + e.mu.Lock() + defer e.mu.Unlock() + value := *seed + ns := r.view.Nodes[nodeID] + ns.Seed = &value + r.view.Nodes[nodeID] = ns + if r.view.Graph != nil { + for i := range r.view.Graph.Nodes { + n := &r.view.Graph.Nodes[i] + if n.ID == nodeID { + if n.Params == nil { + n.Params = map[string]any{} + } + n.Params["seed"] = value + } + } + } + // Record the resolved seed before inference starts, so even interrupted + // runs can be inspected and restored reproducibly after a restart. + if err := e.persistLocked(r); err != nil { + e.log.Error("saving resolved workflow seed", "run", r.view.ID, "err", err) + } +} diff --git a/internal/workflow/history_test.go b/internal/workflow/history_test.go new file mode 100644 index 0000000..ea9c35a --- /dev/null +++ b/internal/workflow/history_test.go @@ -0,0 +1,211 @@ +package workflow + +import ( + "context" + "image" + "os" + "path/filepath" + "testing" + + "github.com/openinfer/openinfer-studio/internal/mediagen" +) + +type cacheRunner struct { + *fakeRunner + identity string +} + +func (f *cacheRunner) WorkflowCacheIdentity(string, *mediagen.LoadOverrides) (string, error) { + return f.identity, nil +} + +func TestHistorySurvivesRestartAndFreezesGraph(t *testing.T) { + store := newStore(t) + runner := &cacheRunner{fakeRunner: &fakeRunner{dir: t.TempDir()}, identity: "runtime-a"} + e := NewPersistentExecutor(runner, nil, store.db) + g := smallTxt2img() + g.Nodes[4].Params["seed"] = -1 + v, err := e.SubmitWithOptions(planFor(t, g), RunOptions{Graph: &g, WorkflowID: "workflow-a"}) + if err != nil { + t.Fatal(err) + } + g.Nodes[1].Params["text"] = "edited after submission" + v = waitRun(t, e, v.ID, RunComplete) + if v.Graph == nil || v.Graph.Nodes[1].Params["text"] == "edited after submission" || v.Nodes["n5"].Seed == nil || *v.Nodes["n5"].Seed < 1 { + t.Fatalf("snapshot lost graph or seed: %+v", v) + } + if v.SourceGraph == nil || v.SourceGraph.Nodes[4].Params["seed"] != float64(-1) || v.Graph.Nodes[4].Params["seed"] != float64(*v.Nodes["n5"].Seed) { + t.Fatal("source graph must retain the submitted seed while graph records the resolved seed") + } + // Callers may inspect or edit returned snapshots without mutating history. + *v.Nodes["n5"].Seed = -1 + v.Nodes["n5"].Outputs[0] = "changed.png" + frozen, _ := e.Get(v.ID) + if *frozen.Nodes["n5"].Seed < 1 || frozen.Nodes["n5"].Outputs[0] == "changed.png" { + t.Fatal("returned node data aliases the executor") + } + e.Close() + e2 := NewPersistentExecutor(runner, nil, store.db) + defer e2.Close() + got, ok := e2.Get(v.ID) + if !ok || got.State != RunComplete || got.WorkflowID != "workflow-a" || got.Nodes["n5"].Seed == nil { + t.Fatalf("restart history: %+v", got) + } + h, err := e2.History() + if err != nil || len(h) != 1 { + t.Fatalf("history = %v, %v", h, err) + } +} + +func TestCacheDoesNotAttributeOutputToChangedInputsOrRuntime(t *testing.T) { + for _, changeRuntime := range []bool{false, true} { + t.Run(map[bool]string{false: "source", true: "runtime"}[changeRuntime], func(t *testing.T) { + store := newStore(t) + runner := &cacheRunner{fakeRunner: &fakeRunner{dir: t.TempDir()}, identity: "runtime-a"} + source := filepath.Join(runner.dir, "source.png") + if err := writeImage(runner.dir, "source.png", image.NewGray(image.Rect(0, 0, 32, 32))); err != nil { + t.Fatal(err) + } + g := smallTxt2img() + plan := planFor(t, g) + plan.Stages[0].Generate.Init = &ImageRef{Path: source} + plan.Stages[0].Generate.Params.InitImagePath = source + runner.hook = func(context.Context, mediagen.GenerateParams) error { + if changeRuntime { + runner.identity = "runtime-b" + return nil + } + return os.WriteFile(source, []byte("changed after reading"), 0600) + } + e := NewPersistentExecutor(runner, nil, store.db) + defer e.Close() + v, err := e.Submit(plan) + if err != nil { + t.Fatal(err) + } + waitRun(t, e, v.ID, RunComplete) + var entries int + if err := store.db.QueryRow(`SELECT count(*) FROM workflow_cache`).Scan(&entries); err != nil || entries != 0 { + t.Fatalf("result cached under changed identity: entries=%d err=%v", entries, err) + } + }) + } +} + +func TestCacheReusesGenerationInvalidatesRuntimeAndMissingFiles(t *testing.T) { + store := newStore(t) + runner := &cacheRunner{fakeRunner: &fakeRunner{dir: t.TempDir()}, identity: "runtime-a"} + e := NewPersistentExecutor(runner, nil, store.db) + defer e.Close() + submit := func(force bool) RunView { + v, err := e.SubmitWithOptions(planFor(t, smallTxt2img()), RunOptions{Force: force}) + if err != nil { + t.Fatal(err) + } + return waitRun(t, e, v.ID, RunComplete) + } + a := submit(false) + b := submit(false) + if !b.Nodes["n5"].Cached || b.Nodes["n6"].Cached || runner.n != 1 { + t.Fatalf("expected generation reuse and a fresh save: %+v, calls=%d", b.Nodes, runner.n) + } + if a.Nodes["n6"].Outputs[0] == b.Nodes["n6"].Outputs[0] { + t.Fatal("saving must create independent outputs per run") + } + _ = os.Remove(filepath.Join(runner.dir, filepath.FromSlash(b.Nodes["n5"].Outputs[0]))) + if c := submit(false); c.Nodes["n5"].Cached || runner.n != 2 { + t.Fatal("missing cached output must regenerate") + } + runner.identity = "runtime-b" + if c := submit(false); c.Nodes["n5"].Cached || runner.n != 3 { + t.Fatal("runtime change must invalidate generation") + } + if c := submit(true); c.Nodes["n5"].Cached || runner.n != 4 { + t.Fatal("force must bypass cache") + } +} + +func TestRandomStagesNeverReuse(t *testing.T) { + store := newStore(t) + runner := &cacheRunner{fakeRunner: &fakeRunner{dir: t.TempDir()}, identity: "runtime-a"} + e := NewPersistentExecutor(runner, nil, store.db) + defer e.Close() + g := smallTxt2img() + g.Nodes[4].Params["seed"] = -1 + for range 2 { + v, err := e.Submit(planFor(t, g)) + if err != nil { + t.Fatal(err) + } + v = waitRun(t, e, v.ID, RunComplete) + if v.Nodes["n5"].Cached { + t.Fatal("random output reused") + } + } + if runner.n != 2 { + t.Fatal("random stage did not generate twice") + } +} + +func TestResolvedSeedIsDurableBeforeInferenceCompletes(t *testing.T) { + store := newStore(t) + runner := &fakeRunner{dir: t.TempDir()} + started := make(chan struct{}) + runner.hook = func(ctx context.Context, _ mediagen.GenerateParams) error { + close(started) + <-ctx.Done() + return ctx.Err() + } + e := NewPersistentExecutor(runner, nil, store.db) + defer e.Close() + g := smallTxt2img() + g.Nodes[4].Params["seed"] = -1 + v, err := e.SubmitWithOptions(planFor(t, g), RunOptions{Graph: &g}) + if err != nil { + t.Fatal(err) + } + <-started + durable, err := e.storedRun(v.ID) + if err != nil || durable.Nodes["n5"].Seed == nil || *durable.Nodes["n5"].Seed < 1 || durable.Graph == nil || durable.SourceGraph.Nodes[4].Params["seed"] != float64(-1) { + t.Fatalf("running seed was not saved: %+v err=%v", durable, err) + } +} + +func TestRestartRecordsInterruptedRuns(t *testing.T) { + store := newStore(t) + raw := `{"id":"interrupted","state":"running","created_at":"2026-01-01","nodes":{"n1":{"state":"running"},"n2":{"state":"done"}}}` + if _, err := store.db.Exec(`INSERT INTO workflow_runs VALUES ('interrupted','running','2026-01-01',?)`, raw); err != nil { + t.Fatal(err) + } + e := NewPersistentExecutor(&fakeRunner{dir: t.TempDir()}, nil, store.db) + defer e.Close() + v, ok := e.Get("interrupted") + if !ok || v.State != RunCanceled || v.Nodes["n1"].State != NodeCanceled || v.Nodes["n2"].State != NodeDone { + t.Fatalf("recovery = %+v", v) + } +} + +func TestCloseWaitsForActiveRunAndRejectsSubmissions(t *testing.T) { + runner := &fakeRunner{dir: t.TempDir()} + entered := make(chan struct{}) + runner.hook = func(ctx context.Context, _ mediagen.GenerateParams) error { + close(entered) + <-ctx.Done() + return ctx.Err() + } + e := NewExecutor(runner, nil, nil) + v, err := e.Submit(planFor(t, smallTxt2img())) + if err != nil { + t.Fatal(err) + } + <-entered + e.Close() + e.Close() + got, _ := e.Get(v.ID) + if got.State != RunCanceled { + t.Fatalf("shutdown left %s", got.State) + } + if _, err := e.Submit(planFor(t, smallTxt2img())); err == nil { + t.Fatal("closed executor accepted a run") + } +} diff --git a/internal/workflow/imageops.go b/internal/workflow/imageops.go new file mode 100644 index 0000000..f1c0125 --- /dev/null +++ b/internal/workflow/imageops.go @@ -0,0 +1,289 @@ +package workflow + +import ( + "fmt" + "image" + "image/color" + "image/draw" + "image/png" + "os" + "path/filepath" +) + +type ImageStage struct { + Src ImageRef `json:"src"` + Other *ImageRef `json:"other,omitempty"` + Op string `json:"op"` + Params map[string]any `json:"params"` + Width int `json:"width"` + Height int `json:"height"` +} + +func imageSpecs() []NodeSpec { + in := []Port{{Name: "image", Type: PortTypes{TypeImage}, Required: true}} + out := []Port{{Name: "image", Type: PortTypes{TypeImage}}} + integer := func(name string, def, min, max int) ParamSpec { + return ParamSpec{Name: name, Kind: ParamInt, Default: def, Min: ptr(float64(min)), Max: ptr(float64(max))} + } + return []NodeSpec{ + {Type: "mask.load", Title: "Load Mask", Category: CatInput, Description: "White pixels are regenerated; black pixels are preserved. Connect to Sample's mask input.", Outputs: []Port{{Name: "mask", Type: PortTypes{TypeMask}}}, Params: []ParamSpec{{Name: "path", Kind: ParamPath, Required: true}}}, + {Type: "image.crop", Title: "Crop", Category: CatImage, Inputs: in, Outputs: out, Params: []ParamSpec{integer("x", 0, 0, 4096), integer("y", 0, 0, 4096), integer("width", 512, 1, 4096), integer("height", 512, 1, 4096)}}, + {Type: "image.pad", Title: "Pad Canvas", Category: CatImage, Description: "Extend the canvas with transparent pixels. Use a mask for outpainting.", Inputs: in, Outputs: out, Params: []ParamSpec{integer("left", 0, 0, 4096), integer("top", 0, 0, 4096), integer("right", 0, 0, 4096), integer("bottom", 0, 0, 4096)}}, + {Type: "image.rotate", Title: "Rotate / Flip", Category: CatImage, Inputs: in, Outputs: out, Params: []ParamSpec{{Name: "angle", Kind: ParamEnum, Options: []string{"0", "90", "180", "270"}, Default: "90"}, {Name: "flip_x", Kind: ParamBool, Default: false}, {Name: "flip_y", Kind: ParamBool, Default: false}}}, + {Type: "image.blend", Title: "Blend Images", Category: CatImage, Description: "Mix two images with identical dimensions.", Inputs: []Port{in[0], {Name: "other", Type: PortTypes{TypeImage}, Required: true}}, Outputs: out, Params: []ParamSpec{{Name: "opacity", Kind: ParamFloat, Default: 0.5, Min: ptr(0), Max: ptr(1)}}}, + {Type: "image.pick", Title: "Select Batch Image", Category: CatImage, Description: "Choose one image from a batch (index starts at zero).", Inputs: in, Outputs: out, Params: []ParamSpec{integer("index", 0, 0, 7)}}, + {Type: "note", Title: "Note", Category: CatPrompt, Description: "A note for organizing the workflow; it does not run.", Params: []ParamSpec{{Name: "text", Kind: ParamText, Default: ""}}}, + } +} + +func (b *builder) imageOp(n Node) { + up, _, ok := b.upstream(n, "image") + if !ok { + return + } + src, ok := b.imgs[up.ID] + if !ok { + return + } + p := b.effective(n) + w, h := src.w, src.h + var other *ImageRef + switch n.Type { + case "image.crop": + w, h = p.integer("width"), p.integer("height") + if src.w > 0 && src.h > 0 && (p.integer("x")+w > src.w || p.integer("y")+h > src.h) { + b.errorf("plan.crop_size", n.ID, "", "crop exceeds the %d × %d input", src.w, src.h) + return + } + case "image.pad": + if w > 0 && h > 0 { + w += p.integer("left") + p.integer("right") + h += p.integer("top") + p.integer("bottom") + } + case "image.rotate": + if p.str("angle") == "90" || p.str("angle") == "270" { + w, h = h, w + } + case "image.blend": + up, _, ok := b.upstream(n, "other") + if !ok { + return + } + o, ok := b.imgs[up.ID] + if !ok { + return + } + if o.w > 0 && w > 0 && (o.w != w || o.h != h) { + b.errorf("plan.blend_size", n.ID, "other", "Blend requires identical dimensions; resize one input first") + return + } + other = &o.ref + case "image.pick": + prev := b.stageByID(src.ref.Stage) + count := 1 + if prev != nil && prev.Generate != nil { + count = max(1, prev.Generate.Params.BatchCount) + } + if p.integer("index") >= count { + b.errorf("plan.batch_index", n.ID, "", "index is outside the upstream batch") + return + } + } + if (w != 0 || h != 0) && (w < 1 || h < 1 || w > 4096 || h > 4096) { + b.errorf("plan.image_size", n.ID, "", "result must be at most 4096 × 4096") + return + } + st := b.newStage(n, StageImage) + if n.Type != "image.pick" { + b.warnBatch(n.ID, src.ref) + } + st.Image = &ImageStage{Src: src.ref, Other: other, Op: n.Type, Params: p, Width: w, Height: h} + if src.ref.Stage != "" { + st.Deps = append(st.Deps, src.ref.Stage) + } + if other != nil && other.Stage != "" && other.Stage != src.ref.Stage { + st.Deps = append(st.Deps, other.Stage) + } + b.imgs[n.ID] = imgInfo{ref: ImageRef{Stage: st.ID}, w: w, h: h} +} + +func readImage(path string) (image.Image, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + cfg, _, err := image.DecodeConfig(f) + if err != nil { + return nil, fmt.Errorf("image operation needs PNG, JPEG or GIF: %w", err) + } + if cfg.Width <= 0 || cfg.Height <= 0 || int64(cfg.Width)*int64(cfg.Height) > maxResizePix { + return nil, fmt.Errorf("image exceeds 64 megapixels") + } + if _, err := f.Seek(0, 0); err != nil { + return nil, err + } + img, _, err := image.Decode(f) + return img, err +} + +func writeImage(root, rel string, img image.Image) error { + path := filepath.Join(root, filepath.FromSlash(rel)) + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + return err + } + f, err := os.CreateTemp(filepath.Dir(path), ".wf-image-*") + if err != nil { + return err + } + temp := f.Name() + defer os.Remove(temp) + if err := png.Encode(f, img); err != nil { + f.Close() + return err + } + if err := f.Close(); err != nil { + return err + } + return os.Rename(temp, path) +} + +func (e *Executor) runImage(r *run, st Stage, prefix string) ([]string, error) { + op := st.Image + if op.Op == "image.pick" { + ref := op.Src + ref.Index = params(op.Params).integer("index") + path, err := e.resolveImage(r, ref) + if err != nil { + return nil, err + } + rel := filepath.ToSlash(filepath.Join("images", fmt.Sprintf("wf-%s-%s.png", prefix, st.ID))) + img, err := readImage(path) + if err != nil { + return nil, err + } + if err := writeImage(e.runner.MediaDir(), rel, img); err != nil { + return nil, err + } + return []string{rel}, nil + } + path, err := e.resolveImage(r, op.Src) + if err != nil { + return nil, err + } + src, err := readImage(path) + if err != nil { + return nil, err + } + out, err := transformImage(src, op) + if err != nil { + return nil, err + } + if op.Other != nil { + path, err := e.resolveImage(r, *op.Other) + if err != nil { + return nil, err + } + other, err := readImage(path) + if err != nil { + return nil, err + } + if other.Bounds().Dx() != out.Bounds().Dx() || other.Bounds().Dy() != out.Bounds().Dy() { + return nil, fmt.Errorf("blend input dimensions changed") + } + alpha := params(op.Params).num("opacity") + for y := 0; y < out.Bounds().Dy(); y++ { + if r.ctx.Err() != nil { + return nil, r.ctx.Err() + } + for x := 0; x < out.Bounds().Dx(); x++ { + a := color.NRGBAModel.Convert(out.At(x, y)).(color.NRGBA) + b := color.NRGBAModel.Convert(other.At(x+other.Bounds().Min.X, y+other.Bounds().Min.Y)).(color.NRGBA) + mix := func(a, b uint8) uint8 { return uint8(float64(a)*(1-alpha) + float64(b)*alpha + 0.5) } + out.SetNRGBA(x, y, color.NRGBA{R: mix(a.R, b.R), G: mix(a.G, b.G), B: mix(a.B, b.B), A: mix(a.A, b.A)}) + } + } + } + if err := r.ctx.Err(); err != nil { + return nil, err + } + rel := filepath.ToSlash(filepath.Join("images", fmt.Sprintf("wf-%s-%s.png", prefix, st.ID))) + if err := writeImage(e.runner.MediaDir(), rel, out); err != nil { + return nil, err + } + return []string{rel}, nil +} + +func transformImage(src image.Image, op *ImageStage) (*image.NRGBA, error) { + p := params(op.Params) + bounds := src.Bounds() + w, h := bounds.Dx(), bounds.Dy() + resolved := *op + if resolved.Width == 0 && resolved.Height == 0 { + resolved.Width, resolved.Height = w, h + switch op.Op { + case "image.pad": + resolved.Width += p.integer("left") + p.integer("right") + resolved.Height += p.integer("top") + p.integer("bottom") + case "image.rotate": + if p.str("angle") == "90" || p.str("angle") == "270" { + resolved.Width, resolved.Height = h, w + } + } + } + op = &resolved + if op.Width < 1 || op.Height < 1 || op.Width > 4096 || op.Height > 4096 { + return nil, fmt.Errorf("result must be at most 4096 × 4096") + } + out := image.NewNRGBA(image.Rect(0, 0, op.Width, op.Height)) + switch op.Op { + case "image.crop": + x, y := p.integer("x"), p.integer("y") + if x < 0 || y < 0 || x+op.Width > w || y+op.Height > h { + return nil, fmt.Errorf("crop input dimensions changed") + } + draw.Draw(out, out.Bounds(), src, bounds.Min.Add(image.Pt(x, y)), draw.Src) + case "image.pad": + draw.Draw(out, image.Rect(p.integer("left"), p.integer("top"), p.integer("left")+w, p.integer("top")+h), src, bounds.Min, draw.Src) + case "image.rotate": + angle := p.str("angle") + expectedW, expectedH := w, h + if angle == "90" || angle == "270" { + expectedW, expectedH = h, w + } + if expectedW != op.Width || expectedH != op.Height { + return nil, fmt.Errorf("rotate input dimensions changed") + } + flipX, _ := op.Params["flip_x"].(bool) + flipY, _ := op.Params["flip_y"].(bool) + for y := 0; y < h; y++ { + for x := 0; x < w; x++ { + sx, sy := x, y + if flipX { + sx = w - 1 - x + } + if flipY { + sy = h - 1 - y + } + dx, dy := x, y + switch angle { + case "90": + dx, dy = h-1-y, x + case "180": + dx, dy = w-1-x, h-1-y + case "270": + dx, dy = y, w-1-x + } + out.Set(dx, dy, src.At(bounds.Min.X+sx, bounds.Min.Y+sy)) + } + } + case "image.blend": + if w != op.Width || h != op.Height { + return nil, fmt.Errorf("blend input dimensions changed") + } + draw.Draw(out, out.Bounds(), src, bounds.Min, draw.Src) + default: + return nil, fmt.Errorf("unsupported image operation %s", op.Op) + } + return out, nil +} diff --git a/internal/workflow/imageops_test.go b/internal/workflow/imageops_test.go new file mode 100644 index 0000000..c09e460 --- /dev/null +++ b/internal/workflow/imageops_test.go @@ -0,0 +1,104 @@ +package workflow + +import ( + "context" + "errors" + "image" + "image/color" + "os" + "path/filepath" + "testing" +) + +func TestImageOperationsPreserveGeometryAndPixels(t *testing.T) { + src := image.NewNRGBA(image.Rect(0, 0, 3, 2)) + src.SetNRGBA(0, 0, color.NRGBA{R: 255, A: 255}) + src.SetNRGBA(2, 1, color.NRGBA{B: 255, A: 255}) + rotate, err := transformImage(src, &ImageStage{Op: "image.rotate", Width: 2, Height: 3, Params: map[string]any{"angle": "90"}}) + if err != nil || rotate.NRGBAAt(1, 0).R != 255 || rotate.NRGBAAt(0, 2).B != 255 { + t.Fatalf("rotation pixels failed: %v", err) + } + crop, err := transformImage(src, &ImageStage{Op: "image.crop", Width: 1, Height: 1, Params: map[string]any{"x": 2, "y": 1}}) + if err != nil || crop.NRGBAAt(0, 0).B != 255 { + t.Fatal("crop chose wrong region") + } + pad, err := transformImage(src, &ImageStage{Op: "image.pad", Width: 5, Height: 4, Params: map[string]any{"left": 1, "top": 1}}) + if err != nil || pad.NRGBAAt(1, 1).R != 255 || pad.NRGBAAt(0, 0).A != 0 { + t.Fatal("padding must retain image position and transparent border") + } +} + +func TestImageOperationsResolveNativeOutputDimensions(t *testing.T) { + src := image.NewNRGBA(image.Rect(0, 0, 3, 2)) + src.SetNRGBA(2, 1, color.NRGBA{R: 255, A: 255}) + rotated, err := transformImage(src, &ImageStage{Op: "image.rotate", Params: map[string]any{"angle": "90"}}) + if err != nil || rotated.Bounds().Dx() != 2 || rotated.Bounds().Dy() != 3 || rotated.NRGBAAt(0, 2).R != 255 { + t.Fatalf("runtime rotate dimensions: %v", err) + } + padded, err := transformImage(src, &ImageStage{Op: "image.pad", Params: map[string]any{"left": 2, "bottom": 1}}) + if err != nil || padded.Bounds().Dx() != 5 || padded.Bounds().Dy() != 3 || padded.NRGBAAt(4, 1).R != 255 { + t.Fatalf("runtime pad dimensions: %v", err) + } + if _, err := transformImage(src, &ImageStage{Op: "image.pad", Params: map[string]any{"left": 4096}}); err == nil { + t.Fatal("unknown-size source bypassed output limits") + } +} + +func TestMaskBrushPaintAndErase(t *testing.T) { + root := t.TempDir() + source := filepath.Join(root, "source.png") + if err := writeImage(root, "source.png", image.NewGray(image.Rect(0, 0, 32, 16))); err != nil { + t.Fatal(err) + } + path, err := PaintMask(root, source, []MaskStroke{{Points: [][2]float64{{0.2, 0.5}, {0.8, 0.5}}, Radius: 0.1}, {Points: [][2]float64{{0.5, 0.5}}, Radius: 0.05, Erase: true}}) + if err != nil { + t.Fatal(err) + } + img, err := readImage(path) + if err != nil { + t.Fatal(err) + } + if c := color.GrayModel.Convert(img.At(8, 8)).(color.Gray); c.Y != 255 { + t.Fatal("brush stroke should paint white") + } + if c := color.GrayModel.Convert(img.At(15, 8)).(color.Gray); c.Y != 0 { + t.Fatal("erase should restore black") + } + if _, err := PaintMask(root, source, []MaskStroke{{Radius: 0.1, Points: [][2]float64{{2, 0}}}}); err == nil { + t.Fatal("out-of-image coordinates accepted") + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if _, err := PaintMaskContext(ctx, root, source, nil); !errors.Is(err, context.Canceled) { + t.Fatal("canceled request wrote a mask") + } +} + +func TestWorkflowFileRoundTripPreservesEditorFields(t *testing.T) { + store := newStore(t) + g := txt2img() + g.Nodes[0].Collapsed = true + g.Groups = []Group{{Title: "Inputs", Nodes: []string{"n1"}, Note: "shared settings", Color: "#ff0000"}} + rec, err := store.Create("Portable graph", graphJSON(t, g)) + if err != nil { + t.Fatal(err) + } + path := filepath.Join(t.TempDir(), "workflow.json") + if err := store.Export(rec.ID, path); err != nil { + t.Fatal(err) + } + imported, err := store.Import(path) + if err != nil { + t.Fatal(err) + } + got, err := ParseGraph(imported.Graph) + if err != nil || !got.Nodes[0].Collapsed || got.Groups[0].Note != "shared settings" { + t.Fatalf("lost editor fields: %+v, %v", got, err) + } + if err := os.WriteFile(path, []byte(`{"version":99}`), 0600); err != nil { + t.Fatal(err) + } + if _, err := store.Import(path); err == nil { + t.Fatal("unsupported file was imported") + } +} diff --git a/internal/workflow/mask.go b/internal/workflow/mask.go new file mode 100644 index 0000000..d55d2d5 --- /dev/null +++ b/internal/workflow/mask.go @@ -0,0 +1,113 @@ +package workflow + +import ( + "context" + "fmt" + "image" + "image/color" + "math" + "os" + "path/filepath" + + "github.com/google/uuid" +) + +type MaskStroke struct { + Points [][2]float64 `json:"points"` + Radius float64 `json:"radius"` + Erase bool `json:"erase"` +} + +// PaintMask maps normalized image coordinates to a grayscale inpainting mask. +func PaintMask(root, path string, strokes []MaskStroke) (string, error) { + return PaintMaskContext(context.Background(), root, path, strokes) +} + +func PaintMaskContext(ctx context.Context, root, path string, strokes []MaskStroke) (string, error) { + if err := ctx.Err(); err != nil { + return "", err + } + if !filepath.IsAbs(path) { + return "", invalid("input image path must be absolute") + } + f, err := os.Open(path) + if err != nil { + return "", invalid("input image: %v", err) + } + cfg, _, err := image.DecodeConfig(f) + f.Close() + if err != nil { + return "", invalid("mask painting needs PNG, JPEG or GIF") + } + w, h := cfg.Width, cfg.Height + if w < 1 || h < 1 || w > 4096 || h > 4096 { + return "", invalid("mask dimensions must be at most 4096 × 4096") + } + if len(strokes) > 1000 { + return "", invalid("too many mask strokes") + } + points := 0 + for _, s := range strokes { + points += len(s.Points) + if s.Radius <= 0 || s.Radius > 0.5 || math.IsNaN(s.Radius) || math.IsInf(s.Radius, 0) { + return "", invalid("invalid brush radius") + } + for _, p := range s.Points { + for _, v := range p { + if v < 0 || v > 1 || math.IsNaN(v) || math.IsInf(v, 0) { + return "", invalid("brush point must be inside the image") + } + } + } + } + if points > 20000 { + return "", invalid("mask has too many points") + } + out := image.NewGray(image.Rect(0, 0, w, h)) + var work int64 + for _, s := range strokes { + radius := s.Radius * float64(max(w, h)) + value := uint8(255) + if s.Erase { + value = 0 + } + for i, p := range s.Points { + ax, ay := p[0]*float64(w-1), p[1]*float64(h-1) + bx, by := ax, ay + if i > 0 { + bx, by = s.Points[i-1][0]*float64(w-1), s.Points[i-1][1]*float64(h-1) + } + x0, x1 := max(0, int(math.Floor(min(ax, bx)-radius))), min(w-1, int(math.Ceil(max(ax, bx)+radius))) + y0, y1 := max(0, int(math.Floor(min(ay, by)-radius))), min(h-1, int(math.Ceil(max(ay, by)+radius))) + work += int64(x1-x0+1) * int64(y1-y0+1) + if work > 300_000_000 { + return "", invalid("mask is too complex; reduce brush size or simplify strokes") + } + dx, dy := bx-ax, by-ay + length := dx*dx + dy*dy + for y := y0; y <= y1; y++ { + if err := ctx.Err(); err != nil { + return "", err + } + for x := x0; x <= x1; x++ { + t := 0.0 + if length > 0 { + t = math.Max(0, math.Min(1, ((float64(x)-ax)*dx+(float64(y)-ay)*dy)/length)) + } + ex, ey := float64(x)-(ax+t*dx), float64(y)-(ay+t*dy) + if ex*ex+ey*ey <= radius*radius { + out.SetGray(x, y, color.Gray{Y: value}) + } + } + } + } + } + if err := ctx.Err(); err != nil { + return "", err + } + rel := filepath.ToSlash(filepath.Join("images", "mask-"+uuid.NewString()+".png")) + if err := writeImage(root, rel, out); err != nil { + return "", fmt.Errorf("save mask: %w", err) + } + return filepath.Join(root, filepath.FromSlash(rel)), nil +} diff --git a/internal/workflow/plan.go b/internal/workflow/plan.go index 3a5d17f..38b66c7 100644 --- a/internal/workflow/plan.go +++ b/internal/workflow/plan.go @@ -17,6 +17,8 @@ const ( StageGenerate = "generate" // one sd-server img_gen / vid_gen request StageResize = "resize" // CPU image scaling in the backend StageSave = "save" // copy an output into the media store + StageImage = "image" + StageUpscale = "upscale" ) // Server actions, relative to what is running now. @@ -36,6 +38,7 @@ type Plan struct { // ImageRef points at an image a stage consumes: the output of an earlier // stage, or a local file the user supplied. type ImageRef struct { + Index int `json:"index,omitempty"` Stage string `json:"stage,omitempty"` Path string `json:"path,omitempty"` } @@ -43,9 +46,11 @@ type ImageRef struct { // Stage is one unit of work. Generate stages are the only ones that touch // the GPU; each absorbs the loaders, prompts and size wired into its Sample. type Stage struct { - ID string `json:"id"` - NodeID string `json:"node_id"` - Kind string `json:"kind"` + Upscale *UpscaleStage `json:"upscale,omitempty"` + Image *ImageStage `json:"image,omitempty"` + ID string `json:"id"` + NodeID string `json:"node_id"` + Kind string `json:"kind"` // Key is the node's content hash: same key, same output. Volatile stages // (random seed) must never be served from cache. Key string `json:"key"` @@ -63,13 +68,16 @@ type Stage struct { // GenerateStage is one sd-server request plus the server it needs. type GenerateStage struct { - Server string `json:"server"` // ServerNeed.Signature - Params mediagen.GenerateParams `json:"params"` - Init *ImageRef `json:"init,omitempty"` // image to start from (img2img) - SeedMode string `json:"seed_mode"` + Mask *ImageRef `json:"mask,omitempty"` + Control *ImageRef `json:"control,omitempty"` + Reference *ImageRef `json:"reference,omitempty"` + Server string `json:"server"` // ServerNeed.Signature + Params mediagen.GenerateParams `json:"params"` + Init *ImageRef `json:"init,omitempty"` // image to start from (img2img) + SeedMode string `json:"seed_mode"` } -// ResizeStage scales an image. Width and Height are the resolved output size. +// ResizeStage scales an image. Zero dimensions resolve from the actual source. type ResizeStage struct { Src ImageRef `json:"src"` Mode string `json:"mode"` @@ -110,6 +118,7 @@ func Build(g Graph, reg *Registry, env Env, caps Caps, opts Options) (*Plan, []I return nil, issues } b := newBuilder(g, reg, env) + b.launchCaps = caps b.issues = issues plan := b.build(opts) if HasErrors(b.issues) { @@ -130,17 +139,19 @@ type nodeKey struct { } type builder struct { - g Graph - reg *Registry - env Env - nodes map[string]Node - specs map[string]NodeSpec - in map[string]map[string]Endpoint // node -> input port -> upstream - issues []Issue - seen map[string]bool // dedupes issues reported from more than one pass + g Graph + reg *Registry + env Env + launchCaps Caps + nodes map[string]Node + specs map[string]NodeSpec + in map[string]map[string]Endpoint // node -> input port -> upstream + issues []Issue + seen map[string]bool // dedupes issues reported from more than one pass models map[string]*ModelInfo // by library id; nil = failed lookup files map[string]*FileMeta // by path; nil = failed lookup + apiCaps map[string]capabilityResult keys map[string]nodeKey imgs map[string]imgInfo // IMAGE-producing node -> where its image lives servers []ServerNeed @@ -210,8 +221,12 @@ func (b *builder) build(opts Options) *Plan { b.sample(n, false) case "sample.video": b.sample(n, true) - case "image.load": + case "image.upscale": + b.upscale(n) + case "image.load", "mask.load": b.imageLoad(n) + case "image.crop", "image.pad", "image.rotate", "image.blend", "image.pick": + b.imageOp(n) case "image.resize": b.resize(n) case "image.save": @@ -435,7 +450,7 @@ func (b *builder) computeKey(n Node) nodeKey { doc.Files = append(doc.Files, fileIdentity{ID: info.ID, Size: info.Size, ModTime: info.ModTime}) } } - case "textencoder.load", "vae.load", "lora.load", "image.load": + case "textencoder.load", "vae.load", "lora.load", "image.load", "mask.load": if path := p.str("path"); path != "" { if meta := b.file(n.ID, path); meta != nil { doc.Files = append(doc.Files, fileIdentity{Path: path, Size: meta.Size, ModTime: meta.ModTime}) @@ -498,10 +513,15 @@ func (b *builder) resize(n Node) { if p.str("mode") == "size" { w, h = p.integer("width"), p.integer("height") } else { - w = int(math.Round(float64(src.w) * p.num("scale"))) - h = int(math.Round(float64(src.h) * p.num("scale"))) + if src.w == 0 || src.h == 0 { + // Resolve dimensions after a native upscaler returns its image. + w, h = 0, 0 + } else { + w = int(math.Round(float64(src.w) * p.num("scale"))) + h = int(math.Round(float64(src.h) * p.num("scale"))) + } } - if w < 16 || h < 16 || w > 4096 || h > 4096 { + if (w != 0 || h != 0) && (w < 16 || h < 16 || w > 4096 || h > 4096) { b.errorf("plan.resize_size", n.ID, "", "Resize would produce %d × %d; the limit is 16 to 4096 on each side", w, h) return } @@ -603,8 +623,7 @@ func (b *builder) sample(n Node, video bool) { } } - // Prompts and LoRA tags. sd-server applies LoRAs through - // tags in the prompt, read from one directory fixed at launch. + // Prompts stay independent of the native structured LoRA entries. positive := "" if up, _, ok := b.upstream(n, "positive"); ok { positive = b.effective(up).str("text") @@ -616,32 +635,15 @@ func (b *builder) sample(n Node, video bool) { if up, _, ok := b.upstream(n, "negative"); ok { negative = b.effective(up).str("text") } - var tags []string - for i := len(loras) - 1; i >= 0; i-- { // checkpoint-side first - lp := b.effective(loras[i]) - path := lp.str("path") - b.file(loras[i].ID, path) - dir := filepath.Dir(path) - name := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)) - if strings.ContainsAny(name, "<>: \t") { - b.errorf("plan.lora_name", loras[i].ID, "", "LoRA file name %q cannot be used in a prompt tag; rename it without spaces, ':' or angle brackets", name) - continue - } - if ov.LoraModelDir != "" && ov.LoraModelDir != dir { - b.errorf("plan.lora_dir", loras[i].ID, "", "all LoRAs on one Sample must be in the same folder (%s and %s differ)", ov.LoraModelDir, dir) - continue - } - ov.LoraModelDir = dir - tags = append(tags, fmt.Sprintf("", name, lp.num("strength"))) + for _, l := range loras { + b.file(l.ID, b.effective(l).str("path")) } prompt := positive - if len(tags) > 0 { - prompt = strings.TrimRight(positive, " ") + " " + strings.Join(tags, " ") - } gp := mediagen.GenerateParams{ Kind: mediagen.KindImage, Prompt: prompt, + Lora: b.structuredLoras(n, libraryID, loras), NegativePrompt: negative, Steps: p.integer("steps"), CFGScale: p.num("cfg"), @@ -651,6 +653,7 @@ func (b *builder) sample(n Node, video bool) { Guidance: p.num("guidance"), OutputFormat: p.str("output_format"), } + b.configureLoraCatalog(n, libraryID, gp.Lora, &ov) if video { gp.Kind = mediagen.KindVideo gp.VideoFrames = p.integer("frames") @@ -669,10 +672,7 @@ func (b *builder) sample(n Node, video bool) { gp.BatchCount = lp.integer("batch") } default: // an IMAGE producer - if video { - b.errorf("plan.i2v_unsupported", n.ID, "start", "image-to-video is not available yet; start Sample (video) from an Empty Latent") - break - } + src, found := b.imgs[up.ID] if !found { break @@ -700,6 +700,7 @@ func (b *builder) sample(n Node, video bool) { st := b.newStage(n, StageGenerate) st.Deps = deps st.Generate = &GenerateStage{Server: sig, Params: gp, Init: init, SeedMode: p.str("seed_mode")} + b.sampleInputs(n, st, info.ID, video) if !video { b.imgs[n.ID] = imgInfo{ref: ImageRef{Stage: st.ID}, w: gp.Width, h: gp.Height} } diff --git a/internal/workflow/plan_test.go b/internal/workflow/plan_test.go index 2126040..80a6a09 100644 --- a/internal/workflow/plan_test.go +++ b/internal/workflow/plan_test.go @@ -2,6 +2,7 @@ package workflow import ( "encoding/json" + "reflect" "strings" "testing" @@ -65,7 +66,7 @@ func TestDefaultTxt2ImgMatchesTheFormRequest(t *testing.T) { "guidance": 3.5, "output_format": "png", "batch_count": 1 }`) got := plan.Stages[0].Generate.Params - if got != want { + if !reflect.DeepEqual(got, want) { t.Fatalf("graph request differs from form request\n got: %+v\n want: %+v", got, want) } // And the sd-server body built from either is identical. @@ -99,7 +100,7 @@ func TestImg2ImgFromFileMatchesTheFormRequest(t *testing.T) { "sampler": "", "scheduler": "", "guidance": 0, "output_format": "png", "batch_count": 1, "strength": 0.6, "init_image_path": "/img/fisherman.png" }`) - if got := plan.Stages[0].Generate.Params; got != want { + if got := plan.Stages[0].Generate.Params; !reflect.DeepEqual(got, want) { t.Fatalf("img2img request differs\n got: %+v\n want: %+v", got, want) } } @@ -163,7 +164,7 @@ func TestGraphWithoutSaveHasNothingToRun(t *testing.T) { } } -func TestLoraBecomesPromptTagAndLaunchDirectory(t *testing.T) { +func TestLoraBecomesStructuredNativeInput(t *testing.T) { g := txt2img() g.Nodes = append(g.Nodes, node("n9", "lora.load", map[string]any{"path": "/loras/film-grain-v2.safetensors", "strength": 0.65})) g.Edges[0] = wire("n1", "model", "n9", "model") @@ -171,15 +172,18 @@ func TestLoraBecomesPromptTagAndLaunchDirectory(t *testing.T) { plan := mustPlan(t, g, newFakeEnv(), Options{}) gen := plan.Stages[0].Generate - if gen.Params.Prompt != "Studio portrait of an elderly fisherman " { + if gen.Params.Prompt != "Studio portrait of an elderly fisherman" { t.Fatalf("prompt = %q", gen.Params.Prompt) } + if len(gen.Params.Lora) != 1 || gen.Params.Lora[0].Path != "/loras/film-grain-v2.safetensors" || gen.Params.Lora[0].Multiplier != 0.65 { + t.Fatalf("structured LoRA: %+v", gen.Params.Lora) + } if plan.Servers[0].Overrides.LoraModelDir != "/loras" { - t.Fatalf("overrides = %+v, want lora dir /loras", plan.Servers[0].Overrides) + t.Fatal("selected files must be registered in the native LoRA catalog") } } -func TestLorasInDifferentFoldersAreRejected(t *testing.T) { +func TestStructuredLorasMayLiveInSharedCatalogSubfolders(t *testing.T) { g := txt2img() g.Nodes = append(g.Nodes, node("n9", "lora.load", map[string]any{"path": "/loras/film-grain-v2.safetensors"}), @@ -188,8 +192,11 @@ func TestLorasInDifferentFoldersAreRejected(t *testing.T) { g.Edges = append(g.Edges, wire("n9", "model", "n10", "model"), wire("n10", "model", "n5", "model")) plan, issues := Build(g, NewRegistry(), newFakeEnv(), allCaps, Options{}) - if plan != nil || !hasIssue(issues, "plan.lora_dir") { - t.Fatalf("plan=%v issues=%+v, want plan.lora_dir", plan, issues) + if plan == nil || HasErrors(issues) || len(plan.Stages[0].Generate.Params.Lora) != 2 { + t.Fatalf("structured native LoRAs: plan=%v issues=%+v", plan, issues) + } + if plan.Servers[0].Overrides.LoraModelDir != "/loras" { + t.Fatal("nested LoRAs must share their common catalog tree") } } @@ -253,7 +260,7 @@ func TestUnreadableImageSizeIsReported(t *testing.T) { } } -func TestImageToVideoIsNotAvailableYet(t *testing.T) { +func TestImageToVideoRequiresAdvertisedSupport(t *testing.T) { g := Graph{Version: 1, Nodes: []Node{ node("n1", "checkpoint.load", map[string]any{"model": modelRef("m2")}), @@ -269,7 +276,7 @@ func TestImageToVideoIsNotAvailableYet(t *testing.T) { }, } plan, issues := Build(g, NewRegistry(), newFakeEnv(), allCaps, Options{}) - if plan != nil || !hasIssue(issues, "plan.i2v_unsupported") { + if plan != nil || !hasIssue(issues, "plan.api_capability") { t.Fatalf("plan=%v issues=%+v, want plan.i2v_unsupported", plan, issues) } } diff --git a/internal/workflow/registry.go b/internal/workflow/registry.go index 69818eb..4cb9c9e 100644 --- a/internal/workflow/registry.go +++ b/internal/workflow/registry.go @@ -4,12 +4,15 @@ import ( "fmt" "slices" "strings" + + "github.com/openinfer/openinfer-studio/internal/mediagen" ) // Caps is what the selected sd.cpp runtime advertises (sd-server --help). // Same rule as the llama.cpp side: a node that needs a flag the runtime does // not list is shown disabled, and a graph using it is rejected. type Caps struct { + API *mediagen.APICapabilities // Known is false when no stable-diffusion.cpp runtime is installed. Known bool // Flags are capability ids as parsed by mediagen.ParseSDCapabilities. @@ -37,7 +40,7 @@ type Registry struct { // NewRegistry returns the registry of built-in node types. func NewRegistry() *Registry { r := &Registry{index: map[string]int{}} - for _, s := range builtinSpecs() { + for _, s := range append(append(builtinSpecs(), imageSpecs()...), upscaleSpec()) { // Descriptors are a JSON contract: lists are always arrays, never null. if s.Inputs == nil { s.Inputs = []Port{} @@ -71,6 +74,24 @@ func (r *Registry) View(caps Caps) []NodeTypeView { out := make([]NodeTypeView, 0, len(r.specs)) for _, s := range r.specs { v := NodeTypeView{NodeSpec: s, Available: true} + switch s.APIFeature { + case "upscale": + rgb := false + if caps.API != nil { + for _, upscaler := range caps.API.Upscalers { + rgb = rgb || (upscaler.Model && upscaler.ImageUpscale && upscaler.Name != "") + } + } + if caps.API == nil || !caps.API.Known || !caps.API.Upscale || !rgb { + v.Available = false + v.Reason = "Load a compatible model to discover native image upscaling support" + } + case "lora": + if caps.API == nil || !(caps.API.SupportsFeature("img_gen", "lora") || caps.API.SupportsFeature("vid_gen", "lora")) { + v.Available = false + v.Reason = "Load a compatible model to discover structured LoRA support" + } + } if reason := missingCapability(s.Requires, caps); reason != "" { v.Available = false v.Reason = reason @@ -122,7 +143,7 @@ func samplerParams(video bool) []ParamSpec { ps := []ParamSpec{ {Name: "seed", Kind: ParamSeed, Default: -1}, {Name: "seed_mode", Kind: ParamEnum, Options: []string{"fixed", "increment", "random"}, Default: "fixed"}, - {Name: "steps", Kind: ParamInt, Min: ptr(1), Max: ptr(300), Default: 20}, + {Name: "steps", Label: "Steps (0 = auto)", Kind: ParamInt, Min: ptr(0), Max: ptr(300), Default: 20}, {Name: "cfg", Kind: ParamFloat, Min: ptr(0), Max: ptr(30), Default: 7.0}, {Name: "guidance", Kind: ParamFloat, Min: ptr(0), Max: ptr(30), Default: 0.0}, {Name: "sampler", Kind: ParamEnum, From: "capabilities.samplers"}, @@ -136,6 +157,7 @@ func samplerParams(video bool) []ParamSpec { ) } return append(ps, + ParamSpec{Name: "control_strength", Kind: ParamFloat, Min: ptr(0), Max: ptr(10), Default: 0.9, ShowWhen: "control is IMAGE"}, ParamSpec{Name: "strength", Kind: ParamFloat, Min: ptr(0), Max: ptr(1), Default: 0.75, ShowWhen: "start is IMAGE"}, ParamSpec{Name: "output_format", Kind: ParamEnum, Options: []string{"png", "jpeg", "webp"}, Default: "png"}, ) @@ -148,10 +170,23 @@ func samplerInputs() []Port { {Name: "vae", Type: PortTypes{TypeVAE}}, {Name: "positive", Type: PortTypes{TypeCond}, Required: true}, {Name: "negative", Type: PortTypes{TypeCond}}, + {Name: "mask", Type: PortTypes{TypeMask}}, + {Name: "control", Type: PortTypes{TypeImage}}, + {Name: "reference", Type: PortTypes{TypeImage}}, {Name: "start", Type: PortTypes{TypeSize, TypeImage}, Required: true}, } } +func videoInputs() []Port { + out := []Port{} + for _, p := range samplerInputs() { + if p.Name != "mask" && p.Name != "control" && p.Name != "reference" { + out = append(out, p) + } + } + return out +} + func builtinSpecs() []NodeSpec { modelParam := ParamSpec{Name: "model", Kind: ParamModel, Required: true} return []NodeSpec{ @@ -193,14 +228,14 @@ func builtinSpecs() []NodeSpec { }, { Type: "lora.load", Category: CatLoaders, Title: "Load LoRA", - Description: "Applies a LoRA to the model through the prompt. All LoRAs on one Sample must live in one folder.", + Description: "Applies a local LoRA through the runtime’s structured API when the loaded model supports it.", Inputs: []Port{{Name: "model", Type: PortTypes{TypeModel}, Required: true}}, Outputs: []Port{{Name: "model", Type: PortTypes{TypeModel}}}, Params: []ParamSpec{ {Name: "path", Kind: ParamPath, Required: true}, {Name: "strength", Kind: ParamFloat, Min: ptr(-2), Max: ptr(2), Default: 1.0}, }, - Requires: []string{"lora-model-dir"}, + APIFeature: "lora", }, { Type: "prompt", Category: CatPrompt, Title: "Prompt", @@ -231,8 +266,8 @@ func builtinSpecs() []NodeSpec { }, { Type: "sample.video", Category: CatSample, Title: "Sample (video)", - Description: "Text-to-video. Image-to-video is not available yet.", - Inputs: samplerInputs(), + Description: "Text-to-video, or image-to-video when the loaded model advertises it.", + Inputs: videoInputs(), Outputs: []Port{{Name: "video", Type: PortTypes{TypeVideo}}}, Params: samplerParams(true), }, diff --git a/internal/workflow/registry_test.go b/internal/workflow/registry_test.go index 836ec86..520507f 100644 --- a/internal/workflow/registry_test.go +++ b/internal/workflow/registry_test.go @@ -68,7 +68,7 @@ func TestViewMarksUnavailableNodes(t *testing.T) { t.Fatal("vae.load should be available when --vae is advertised") } if v := byType(partial, "lora.load"); v.Available { - t.Fatal("lora.load should be unavailable without --lora-model-dir") + t.Fatal("lora.load should be unavailable without advertised structured API support") } } diff --git a/internal/workflow/service.go b/internal/workflow/service.go index b079c46..d31575d 100644 --- a/internal/workflow/service.go +++ b/internal/workflow/service.go @@ -45,13 +45,13 @@ type Service struct { exec *Executor } -// NewService returns the workflow service. db must have migration 0005. +// NewService returns the workflow service. db must have migration 0006. // events may be nil; media may be nil, which disables planning against a // runtime and running graphs. func NewService(db *sql.DB, lib Models, media Media, events EventSink) *Service { s := &Service{store: NewStore(db), registry: NewRegistry(), models: lib, media: media} if media != nil { - s.exec = NewExecutor(media, events, nil) + s.exec = NewPersistentExecutor(media, events, db) } return s } @@ -72,6 +72,31 @@ func (s *Service) Store() *Store { return s.store } // Registry returns the node registry. func (s *Service) Registry() *Registry { return s.registry } +type modelCapabilities interface { + GenerationCapabilities(string) (*mediagen.APICapabilities, error) +} + +func (s *Service) ModelCapabilities(id string) *mediagen.APICapabilities { + if id == "" { + return nil + } + if m, ok := s.media.(modelCapabilities); ok { + caps, err := m.GenerationCapabilities(id) + if err == nil { + return caps + } + return &mediagen.APICapabilities{ModelID: id, Reason: err.Error()} + } + return nil +} + +func (e serviceEnv) GenerationCapabilities(id string) (*mediagen.APICapabilities, error) { + if m, ok := e.s.media.(modelCapabilities); ok { + return m.GenerationCapabilities(id) + } + return &mediagen.APICapabilities{Reason: "loaded-model capability discovery unavailable"}, nil +} + // Capabilities reads what the selected sd.cpp runtime advertises. When there // is no usable runtime, Known is false and nodes that need flags are marked // unavailable rather than guessed at. diff --git a/internal/workflow/types.go b/internal/workflow/types.go index e7fc0e3..dd145f5 100644 --- a/internal/workflow/types.go +++ b/internal/workflow/types.go @@ -110,6 +110,7 @@ type ParamSpec struct { // NodeSpec is the declarative description of a node type. QML draws sockets, // widgets, palette and inspector from it, so a new node type needs no QML. type NodeSpec struct { + APIFeature string `json:"api_feature,omitempty"` Type string `json:"type"` Category string `json:"category"` Title string `json:"title"` @@ -139,11 +140,12 @@ type Graph struct { // Node is one placed node. Params holds only values the user set; the // registry supplies defaults for the rest. type Node struct { - ID string `json:"id"` - Type string `json:"type"` - Title string `json:"title,omitempty"` - Pos [2]float64 `json:"pos"` - Params map[string]any `json:"params,omitempty"` + Collapsed bool `json:"collapsed,omitempty"` + ID string `json:"id"` + Type string `json:"type"` + Title string `json:"title,omitempty"` + Pos [2]float64 `json:"pos"` + Params map[string]any `json:"params,omitempty"` } // Endpoint addresses one socket: [nodeID, portName]. @@ -163,6 +165,8 @@ type Edge struct { // Group is a labelled frame around nodes. UI only; never part of a cache key. type Group struct { + Color string `json:"color,omitempty"` + Note string `json:"note,omitempty"` Title string `json:"title"` Nodes []string `json:"nodes"` } diff --git a/internal/workflow/validate_test.go b/internal/workflow/validate_test.go index 4ae019b..6743f37 100644 --- a/internal/workflow/validate_test.go +++ b/internal/workflow/validate_test.go @@ -77,16 +77,15 @@ func TestCycleIsReportedOnEveryLoopNode(t *testing.T) { func TestCapabilityGating(t *testing.T) { g := txt2img() - g.Nodes = append(g.Nodes, node("n9", "lora.load", map[string]any{"path": "/loras/x.safetensors"})) - g.Edges[0] = wire("n1", "model", "n9", "model") - g.Edges = append(g.Edges, wire("n9", "model", "n5", "model")) + g.Nodes = append(g.Nodes, node("n9", "vae.load", map[string]any{"path": "/models/vae.safetensors"})) + g.Edges[2] = wire("n9", "vae", "n5", "vae") - noLora := Caps{Known: true, Flags: []string{"vae"}} - issues := Validate(g, NewRegistry(), noLora) + noVAE := Caps{Known: true, Flags: []string{"lora-model-dir"}} + issues := Validate(g, NewRegistry(), noVAE) if !hasIssue(issues, "capability.missing") { t.Fatalf("issues = %+v, want capability.missing", issues) } - if msg := issues[0].Message; !strings.Contains(msg, "--lora-model-dir") { + if msg := issues[0].Message; !strings.Contains(msg, "--vae") { t.Fatalf("message %q should name the missing flag", msg) } diff --git a/migrations/0006_workflow_runs.sql b/migrations/0006_workflow_runs.sql new file mode 100644 index 0000000..a3631a1 --- /dev/null +++ b/migrations/0006_workflow_runs.sql @@ -0,0 +1,13 @@ +-- Durable graph snapshots and stage-result reuse. Outputs remain in the media store. +CREATE TABLE workflow_runs ( + id TEXT PRIMARY KEY, + state TEXT NOT NULL, + created_at TEXT NOT NULL, + view_json TEXT NOT NULL +); +CREATE INDEX idx_workflow_runs_created ON workflow_runs(created_at DESC); +CREATE TABLE workflow_cache ( + key TEXT PRIMARY KEY, + entry_json TEXT NOT NULL, + updated_at TEXT NOT NULL +); diff --git a/tests/qml-editor/fixtures/input.svg b/tests/qml-editor/fixtures/input.svg new file mode 100644 index 0000000..86b159b --- /dev/null +++ b/tests/qml-editor/fixtures/input.svg @@ -0,0 +1,4 @@ + + + + diff --git a/tests/qml-editor/tst_GraphModel.qml b/tests/qml-editor/tst_GraphModel.qml new file mode 100644 index 0000000..58dad41 --- /dev/null +++ b/tests/qml-editor/tst_GraphModel.qml @@ -0,0 +1,84 @@ +import QtQuick +import QtTest +import "../../apps/desktop/qml/js/graphModel.js" as GM + +TestCase { + name: "GraphEditorModel" + function fixture() { + return { version: 1, nodes: [ + { id: "n1", type: "prompt", pos: [10, 20], params: { text: "a" } }, + { id: "n2", type: "prompt", pos: [310, 120], params: { text: "b" }, collapsed: true }, + { id: "n3", type: "prompt", pos: [610, 220], params: {} } + ], edges: [ { from: ["n1", "cond"], to: ["n2", "input"] }, + { from: ["n2", "cond"], to: ["n3", "input"] } ], + groups: [{ title: "Prompts", note: "Keep this note", color: "#6aa8e0", nodes: ["n1", "n2"] }] } + } + readonly property var specs: ({ prompt: { params: [], inputs: [], outputs: [] } }) + + function test_typingCoalescesAndBranches() { + var h = GM.newHistory(), a = { text: "a" }, b = { text: "ab" }, c = { text: "abc" } + verify(GM.recordEdit(h, a, b, "Type", "n1:text", 100)) + verify(GM.recordEdit(h, b, c, "Type", "n1:text", 300)) + compare(h.past.length, 1) + compare(GM.undo(h), a) + compare(GM.redo(h), c) + GM.recordEdit(h, c, { text: "pause" }, "Type", "n1:text", 1200) + compare(h.past.length, 2) + GM.undo(h) + GM.recordEdit(h, c, { text: "branch" }, "Type", "n1:text", 1300) + compare(h.future.length, 0) + compare(h.past.length, 2) + } + function test_copyPasteRemapsInternalWiresAndFrames() { + var g = fixture(), section = GM.copySection(g, ["n1", "n2"]) + compare(section.edges.length, 1) + var ids = GM.pasteSection(g, GM.parseSection(JSON.stringify(section), specs), 1000, 500) + compare(ids, ["n4", "n5"]) + compare(g.nodes[3].pos, [1000, 500]) + compare(g.nodes[4].pos, [1300, 600]) + verify(g.nodes[4].collapsed) + compare(g.edges[2], { from: ["n4", "cond"], to: ["n5", "input"] }) + compare(g.groups[1].nodes, ["n4", "n5"]) + compare(g.groups[1].note, "Keep this note") + g.nodes[3].params.text = "edited copy" + compare(g.nodes[0].params.text, "a") + } + function test_clipboardRejectsMalformedAndExternalNodes() { + var malformed = fixture() + malformed.nodes[1].id = "n1" + var rejected = false + try { GM.parseSection(JSON.stringify(malformed), specs) } catch (e) { rejected = true } + verify(rejected) + malformed = fixture(); malformed.edges[0].to[0] = "missing" + rejected = false + try { GM.parseSection(JSON.stringify(malformed), specs) } catch (e) { rejected = true } + verify(rejected) + } + function test_connectivitySelectionDeletionAndAlignment() { + var g = fixture() + compare(GM.connectedSection(g, ["n2"]), ["n1", "n2", "n3"]) + compare(GM.selectionInRect(g, specs, { x: 0, y: 0, w: 280, h: 90 }), ["n1"]) + GM.alignNodes(g, specs, ["n1", "n2"], "left") + compare(g.nodes[1].pos[0], 10) + compare(g.nodes[2].pos[0], 610) + GM.removeNode(g, "n1") + compare(g.groups[0].nodes, ["n2"]) + compare(g.edges.length, 1) + } + function test_executionIdentityIgnoresOnlyPresentation() { + var a = fixture(), b = GM.clone(a) + b.nodes.reverse(); b.edges.reverse(); b.nodes[0].pos = [-100, 30] + b.groups[0].note = "Different note"; b.name = "Renamed" + b.nodes[0].collapsed = true + compare(GM.executionKey(a), GM.executionKey(b)) + b.nodes[0].params.text = "different input" + verify(GM.executionKey(a) !== GM.executionKey(b)) + } + function test_fileUrlsRoundtripSpecialCharacters() { + var p = "/tmp/a #image? 100%.png" + var url = GM.localPathToFileUrl(p) + verify(url.indexOf("%23") > 0) + verify(url.indexOf("%3F") > 0) + compare(GM.fileUrlToLocalPath(url), p) + } +} diff --git a/tests/qml-editor/tst_GraphPage.qml b/tests/qml-editor/tst_GraphPage.qml new file mode 100644 index 0000000..f6cabef --- /dev/null +++ b/tests/qml-editor/tst_GraphPage.qml @@ -0,0 +1,370 @@ +import QtQuick +import QtTest +import "../../apps/desktop/qml/pages" +import "../../apps/desktop/qml/js/graphModel.js" as GM + +TestCase { + id: test + name: "GraphEditorPage" + width: 1600; height: 900 + visible: true + when: windowShown + property var editor + readonly property string fixtureUrl: Qt.resolvedUrl("fixtures/input.svg").toString() + readonly property var registry: [ + { type: "prompt", title: "Prompt", category: "prompt", available: true, + inputs: [], outputs: [{ name: "cond", type: "COND" }], params: [{ name: "text", kind: "text", default: "" }] }, + { type: "image.load", title: "Image", category: "input", available: true, + inputs: [], outputs: [{ name: "image", type: "IMAGE" }], params: [{ name: "path", kind: "path" }] }, + { type: "mask.load", title: "Mask", category: "input", available: true, + inputs: [], outputs: [{ name: "mask", type: "MASK" }], params: [{ name: "path", kind: "path" }] }, + { type: "sample", title: "Sample", category: "sampling", available: true, + inputs: [{ name: "start", type: ["IMAGE", "SIZE"] }, { name: "mask", type: "MASK" }], + outputs: [{ name: "image", type: "IMAGE" }], params: [ + { name: "steps", kind: "int", default: 20 }, { name: "cfg", kind: "float", default: 7 }, + { name: "guidance", kind: "float", default: 3 }, { name: "strength", kind: "float", default: 1 }, + { name: "seed", kind: "seed", default: -1 }, { name: "control_strength", kind: "float", default: 0.5 }, + { name: "sampler", kind: "enum", from: "capabilities.samplers" }, + { name: "scheduler", kind: "enum", from: "capabilities.schedulers" } ] }, + { type: "sample.video", title: "Video", category: "sampling", available: true, + inputs: [{ name: "start", type: "SIZE" }], outputs: [], params: [ + { name: "steps", kind: "int" }, { name: "frames", kind: "int" }, { name: "fps", kind: "int" } ] }, + { type: "latent.empty", title: "Size", category: "input", available: true, + inputs: [], outputs: [{ name: "size", type: "SIZE" }], params: [ + { name: "width", kind: "int", default: 1024 }, { name: "height", kind: "int", default: 1024 }, + { name: "batch", kind: "int", default: 1 } ] }, + { type: "checkpoint.load", title: "Checkpoint", category: "loaders", available: true, + inputs: [], outputs: [{ name: "model", type: "MODEL" }], params: [{ name: "model", kind: "model" }] } + ] + QtObject { + id: eventBus + signal eventReceived(string name, var payload) + signal reconnected() + } + QtObject { + id: client + property var calls: [] + property var pendingPuts: [] + property var pendingValidations: [] + property bool deferPuts: false + property bool deferValidation: false + property int nextRun: 0 + property var runMap: ({}) + property var capabilities: ({}) + property var typeRequests: [] + property var workflowMap: ({}) + function get(path, callback) { + if (path.indexOf("/api/v1/workflow/node-types") === 0) { + typeRequests.push(path) + callback(200, { node_types: test.registry, api_capabilities: capabilities }) + } + else if (path === "/api/v1/models") callback(200, { models: [] }) + else if (path === "/api/v1/workflows") callback(200, { workflows: [] }) + else if (path === "/api/v1/workflow/runs") callback(200, { runs: Object.keys(runMap).map(function(id) { return runMap[id] }) }) + else if (path.indexOf("/api/v1/workflow/runs/") === 0) callback(200, runMap[path.split("/").pop()]) + else if (path.indexOf("/api/v1/workflows/") === 0 && workflowMap[path.split("/").pop()]) callback(200, GM.clone(workflowMap[path.split("/").pop()])) + else callback(404, {}) + } + function put(path, body, callback) { + calls.push({ method: "PUT", path: path, body: GM.clone(body) }) + if (deferPuts) pendingPuts.push(callback); else callback(200, {}) + } + function post(path, body, callback) { + calls.push({ method: "POST", path: path, body: GM.clone(body) }) + if (path === "/api/v1/workflows/validate") { + if (deferValidation) pendingValidations.push(callback) + else callback(200, { errors: [], warnings: [] }) + } else if (path === "/api/v1/workflow/runs") { + var nodes = {} + body.graph.nodes.forEach(function(n) { nodes[n.id] = { state: "pending" } }) + var run = { id: "run" + (++nextRun), state: "queued", graph: GM.clone(body.graph), + workflow_id: body.workflow_id, only: body.only, nodes: nodes } + runMap[run.id] = run + callback(202, { run: run }) + } else if (path === "/api/v1/workflows/import") callback(201, { id: "imported" }) + else callback(200, {}) + } + function del(path, callback) { callback(200, {}) } + } + Component { id: pageComponent; GraphPage { api: client; events: eventBus } } + function fixture() { + var g = GM.newGraph("Editor") + g.nodes = [ { id: "n1", type: "prompt", pos: [20, 20], params: { text: "one" } }, + { id: "n2", type: "prompt", pos: [400, 40], params: { text: "two" } } ] + return g + } + function init() { + client.calls = []; client.pendingPuts = []; client.pendingValidations = [] + client.deferPuts = false; client.deferValidation = false + client.nextRun = 0; client.runMap = ({}) + client.capabilities = ({}); client.typeRequests = [] + client.workflowMap = ({}) + editor = createTemporaryObject(pageComponent, test, { width: 1600, height: 900 }) + verify(editor !== null) + editor.workflowId = "w1"; editor.graph = fixture(); editor.paletteOpen = false + editor.panX = 40; editor.panY = 40; editor.zoom = 1 + editor.rebuild() + wait(30) + client.calls = [] + } + function cleanup() { + editor.destroy(); editor = null + wait(0) + } + function test_editUndoRedoAndDuplicateInternalWires() { + editor.setParam("n1", "text", "one a", true) + editor.setParam("n1", "text", "one ab", true) + compare(editor.history.past.length, 1) + editor.undoEdit(); compare(editor.nodeById.n1.params.text, "one") + editor.redoEdit(); compare(editor.nodeById.n1.params.text, "one ab") + editor.graph.edges = [{ from: ["n1", "cond"], to: ["n2", "input"] }] + editor.rebuild(); editor.setSelection(["n1", "n2"]); editor.duplicateSelected() + compare(editor.selectedIds, ["n3", "n4"]) + compare(editor.graph.edges[1].from[0], "n3") + compare(editor.graph.edges[1].to[0], "n4") + editor.undoEdit(); compare(editor.graph.nodes.length, 2) + editor.redoEdit(); compare(editor.graph.nodes.length, 4) + } + function test_ctrlShiftMarqueeAndGroupDragWithPointer() { + var view = findChild(editor, "graphViewport"), a = findChild(editor, "graphNode_n1"), b = findChild(editor, "graphNode_n2") + verify(view && a && b) + mouseClick(a, 50, 15) + compare(editor.selectedIds, ["n1"]) + mouseClick(b, 50, 15, Qt.LeftButton, Qt.ControlModifier) + compare(editor.selectedIds, ["n1", "n2"]) + mouseClick(b, 50, 15, Qt.LeftButton, Qt.ShiftModifier) + compare(editor.selectedIds, ["n1"]) + mousePress(view, 35, 35) + mouseMove(view, 750, 320, 50) + mouseRelease(view, 750, 320) + compare(editor.selectedIds, ["n1", "n2"]) + var p1 = editor.nodeById.n1.pos.slice(), p2 = editor.nodeById.n2.pos.slice() + mousePress(a, 50, 15) + mouseMove(view, p1[0] + editor.panX + 100, p1[1] + editor.panY + 65, 50) + mouseRelease(view, p1[0] + editor.panX + 100, p1[1] + editor.panY + 65) + compare(editor.nodeById.n1.pos, [p1[0] + 50, p1[1] + 50]) + compare(editor.nodeById.n2.pos, [p2[0] + 50, p2[1] + 50]) + compare(a.x, editor.nodeById.n1.pos[0]) + compare(b.x, editor.nodeById.n2.pos[0]) + compare(editor.history.past.length, 1) + editor.undoEdit(); compare(editor.nodeById.n1.pos, p1); compare(editor.nodeById.n2.pos, p2) + } + function test_systemClipboardAndKeyboardUndo() { + editor.setSelection(["n1", "n2"]) + var view = findChild(editor, "graphViewport") + view.forceActiveFocus() + keyClick(Qt.Key_C, Qt.ControlModifier) + keyClick(Qt.Key_V, Qt.ControlModifier) + compare(editor.graph.nodes.length, 4) + keyClick(Qt.Key_Z, Qt.ControlModifier) + compare(editor.graph.nodes.length, 2) + keyClick(Qt.Key_Z, Qt.ControlModifier | Qt.ShiftModifier) + compare(editor.graph.nodes.length, 4) + } + function test_frameNoteCollapseAlignmentAndDeleteUndo() { + editor.setSelection(["n1", "n2"]) + editor.saveFrame(-1, "Prompts", "Remember this") + compare(editor.graph.groups[0].note, "Remember this") + editor.toggleCollapse(editor.selectedIds) + verify(editor.nodeById.n1.collapsed && editor.nodeById.n2.collapsed) + editor.alignSelected("left"); compare(editor.nodeById.n2.pos[0], 20) + editor.deleteSelected() + compare(editor.graph.groups[0].nodes.length, 0) + editor.undoEdit(); compare(editor.graph.groups[0].nodes, ["n1", "n2"]) + editor.undoEdit(); compare(editor.nodeById.n2.pos[0], 400) + editor.undoEdit(); verify(!editor.nodeById.n1.collapsed) + } + function test_queueForceAndSnapshotIsolation() { + editor.forceRun = true + editor.run(""); verify(editor.running) + editor.run("n1"); compare(client.nextRun, 2) + compare(editor.activeRunCount, 2) + var posts = client.calls.filter(function(c) { return c.path === "/api/v1/workflow/runs" }) + verify(posts[0].body.force); compare(posts[0].body.workflow_id, "w1") + verify(editor.runStateFor("n1") !== null) + editor.setParam("n1", "text", "new editor input", true) + compare(editor.runStateFor("n1"), null) + var current = GM.clone(editor.graph), old = GM.clone(client.runMap.run1) + editor.inspectRun(old) + verify(editor.inspectingRun) + compare(editor.graph, current) + compare(editor.nodeById.n1.params.text, "one") + editor.setParam("n1", "text", "blocked") + compare(editor.graph, current) + editor.returnToEditor(); compare(editor.nodeById.n1.params.text, "new editor input") + editor.restoreRun(old); compare(editor.nodeById.n1.params.text, "one") + editor.undoEdit(); compare(editor.nodeById.n1.params.text, "new editor input") + old.workflow_id = "other"; editor.applyRunView(old, true) + compare(editor.runStateFor("n1"), null) + } + function test_saveSerializationAndStaleValidation() { + client.deferPuts = true + editor.save() + editor.setParam("n1", "text", "second") + editor.save() + compare(client.pendingPuts.length, 1) + client.pendingPuts.shift()(200, {}) + compare(client.pendingPuts.length, 1) + var puts = client.calls.filter(function(c) { return c.method === "PUT" }) + compare(puts.length, 2) + compare(puts[1].body.graph.nodes[0].params.text, "second") + client.pendingPuts.shift()(200, {}) + compare(editor.saveState, "saved") + client.deferValidation = true + editor.validate() + editor.setParam("n1", "text", "third") + client.pendingValidations.shift()(200, { errors: [{ node: "n1", severity: "error", message: "Old graph" }] }) + compare(editor.issues.length, 0) + } + function test_importExportThroughBackendAndExportAfterSave() { + client.deferPuts = true + editor.exportWorkflow("/tmp/workflow.json") + compare(client.calls.filter(function(c) { return /\/export$/.test(c.path) }).length, 0) + client.pendingPuts.shift()(200, {}) + var exports = client.calls.filter(function(c) { return /\/export$/.test(c.path) }) + compare(exports.length, 1); compare(exports[0].body.path, "/tmp/workflow.json") + editor.importWorkflow("/tmp/import.json") + var imports = client.calls.filter(function(c) { return /\/import$/.test(c.path) }) + compare(imports[0].body, { path: "/tmp/import.json" }) + } + function test_batchInputPreviewAndMaskAttachment() { + editor.graph.nodes = [ { id: "n1", type: "image.load", pos: [20, 20], params: { path: GM.fileUrlToLocalPath(fixtureUrl) } }, + { id: "n2", type: "sample", pos: [400, 40], params: {} } ] + editor.graph.edges = [{ from: ["n1", "image"], to: ["n2", "start"] }] + editor.rebuild(); wait(0) + var input = findChild(editor, "graphNode_n1") + compare(input.previewSources, [fixtureUrl]) + tryCompare(findChild(input, "nodePreview"), "status", Image.Ready) + editor.addPaintedMask("n1", GM.fileUrlToLocalPath(fixtureUrl)) + compare(editor.nodeById.n3.type, "mask.load") + compare(GM.edgeInto(editor.graph, "n2", "mask").from, ["n3", "mask"]) + var r = { id: "batch", state: "complete", workflow_id: "w1", graph: GM.clone(editor.graph), + nodes: { n2: { state: "done", file_urls: [fixtureUrl, fixtureUrl + "?batch=2", fixtureUrl + "?batch=3"] } } } + editor.applyRunView(r, true) + wait(30) + var sample = findChild(editor, "graphNode_n2") + compare(sample.previewSources.length, 3) + var next = findChild(sample, "nextPreview"), previous = findChild(sample, "previousPreview") + mouseClick(next); compare(sample.previewIndex, 1) + mouseClick(next); compare(sample.previewUrl, fixtureUrl + "?batch=3") + verify(!next.enabled) + mouseClick(previous); compare(sample.previewIndex, 1) + editor.setParam("n1", "path", "") + compare(sample.previewSources.length, 0) + } + function test_advertisedChoicesAndCheckpointCapabilities() { + compare(editor.paramOptions({ from: "capabilities.samplers" }), [{ text: "default", value: "" }]) + client.capabilities = { known: true, samplers: ["euler", "custom"], schedulers: ["karras"], + upscalers: [{ name: "esrgan", model: true, image_upscale: true }, { name: "unsupported", model: true, image_upscale: false }, { name: "builtin", model: false, image_upscale: true }] } + editor.graph.nodes.push({ id: "n3", type: "checkpoint.load", pos: [800, 20], params: { model: { library_id: "m 1" } } }) + editor.touch(true) + compare(client.typeRequests[client.typeRequests.length - 1], "/api/v1/workflow/node-types?model_id=m%201") + compare(editor.paramOptions({ from: "capabilities.samplers" }).map(function(p) { return p.value }), ["", "euler", "custom"]) + compare(editor.paramOptions({ from: "capabilities.upscalers" }).map(function(p) { return p.value }), ["", "esrgan"]) + var count = client.typeRequests.length + eventBus.eventReceived("media.server_state", {}) + compare(client.typeRequests.length, count + 1) + } + function test_editorScreenshotAndFrameDrag() { + editor.panY = 140 + editor.setSelection(["n1", "n2"]) + editor.saveFrame(-1, "Prompt section", "Select with Ctrl/Shift; move this frame together.") + wait(0) + var b = editor.frameBounds(editor.graph.groups[0]), view = findChild(editor, "graphViewport") + var x = b.x + editor.panX + 30, y = b.y + editor.panY + 15 + var before = editor.nodeById.n1.pos.slice() + mousePress(view, x, y) + mouseMove(view, x + 30, y + 30, 50) + mouseRelease(view, x + 30, y + 30) + compare(editor.nodeById.n1.pos, [before[0] + 30, before[1] + 30]) + compare(findChild(editor, "graphNode_n1").x, editor.nodeById.n1.pos[0]) + editor.historyOpen = true + wait(50) + grabImage(editor).save("/tmp/openinfer-editor.png") + } + function findTextInput(item) { + if (item.cursorPosition !== undefined && item.selectByMouse !== undefined) return item + var children = item.children || [] + for (var i = 0; i < children.length; i++) { + var found = findTextInput(children[i]) + if (found) return found + } + return null + } + function test_promptTypingUsesCoalescedDocumentUndo() { + var area = findTextInput(findChild(editor, "graphNode_n1")) + verify(area !== null) + area.forceActiveFocus() + keyClick(Qt.Key_End) + keyClick(Qt.Key_A); keyClick(Qt.Key_B) + compare(editor.nodeById.n1.params.text, "oneab") + compare(editor.history.past.length, 1) + keyClick(Qt.Key_Z, Qt.ControlModifier) + compare(editor.nodeById.n1.params.text, "one") + keyClick(Qt.Key_Z, Qt.ControlModifier | Qt.ShiftModifier) + compare(editor.nodeById.n1.params.text, "oneab") + } + function test_resolvedSeedsUseSourceSnapshotAndPreserveBadges() { + editor.graph.nodes = [{ id: "n1", type: "sample", pos: [20, 20], params: { seed: -1 } }] + editor.rebuild() + var source = GM.clone(editor.graph), resolved = GM.clone(source) + resolved.nodes[0].params.seed = 482910337 + var run = { id: "random", state: "complete", workflow_id: "w1", source_graph: source, graph: resolved, + nodes: { n1: { state: "done", seed: 482910337, cached: true } } } + editor.applyRunView(run, true) + verify(editor.runMatchesEditor) + compare(editor.runStateFor("n1").seed, 482910337) + verify(editor.runStateFor("n1").cached) + eventBus.eventReceived("workflow.node_state", { run_id: "random", node_id: "n1", state: "done", seed: 7, cached: true }) + compare(editor.runStateFor("n1").seed, 7) + verify(editor.runStateFor("n1").cached) + var revision = editor.documentRev + editor.inspectRun(run) + compare(editor.nodeById.n1.params.seed, 482910337) + compare(editor.graph.nodes[0].params.seed, -1) + compare(editor.documentRev, revision) + } + function test_runtimeDefaultsAtomicAndOnlyAdvertisedFields() { + editor.graph.nodes.push({ id: "n3", type: "sample", pos: [700, 20], params: { + seed: 123, steps: 30, cfg: 9, guidance: 7, strength: 0.6, control_strength: 0.75 } }) + editor.graph.nodes.push({ id: "n4", type: "latent.empty", pos: [20, 400], params: { width: 1024, height: 1024, batch: 1 } }) + editor.graph.edges.push({ from: ["n4", "size"], to: ["n3", "start"] }) + editor.rebuild(); editor.setSelection(["n3"]) + compare(editor.selectedRuntimeDefaults, null) + editor.apiCapabilities = { known: true, defaults_by_mode: { + img_gen: { width: 640, height: 384, batch: 3, steps: 8, cfg: 1, sampler: "euler" }, + vid_gen: { width: 832, height: 480, frames: 33, fps: 16, steps: 20 } } } + var before = GM.clone(editor.graph) + editor.applyRuntimeDefaults() + compare(editor.history.past.length, 1) + compare(editor.nodeById.n3.params.steps, 8) + compare(editor.nodeById.n3.params.seed, 123) + compare(editor.nodeById.n3.params.guidance, 7) + compare(editor.nodeById.n3.params.strength, 0.6) + compare(editor.nodeById.n3.params.control_strength, 0.75) + compare(editor.nodeById.n1.params.text, "one") + compare(editor.nodeById.n4.params, { width: 640, height: 384, batch: 3 }) + editor.undoEdit(); compare(editor.graph, before) + editor.redoEdit(); compare(editor.nodeById.n3.params.steps, 8) + editor.graph.nodes.push({ id: "n5", type: "sample.video", pos: [1000, 20], params: {} }) + editor.graph.edges.push({ from: ["n4", "size"], to: ["n5", "start"] }) + editor.rebuild(); editor.setSelection(["n5"]) + editor.applyRuntimeDefaults() + compare(editor.nodeById.n5.params, { frames: 33, fps: 16, steps: 20 }) + compare(editor.nodeById.n4.params, { width: 832, height: 480, batch: 3 }) + } + function test_failedSaveKeepsWorkflowOpenBeforeSwitch() { + client.workflowMap = { w2: { id: "w2", name: "Other", graph: GM.newGraph("Other") } } + client.deferPuts = true + editor.setParam("n1", "text", "unsaved") + editor.openWorkflow("w2") + compare(editor.workflowId, "w1") + client.pendingPuts.shift()(500, {}) + compare(editor.workflowId, "w1") + compare(editor.nodeById.n1.params.text, "unsaved") + editor.openWorkflow("w2") + client.pendingPuts.shift()(200, {}) + compare(editor.workflowId, "w2") + compare(editor.history.past.length, 0) + } +} diff --git a/tests/qml/fixtures/result-oversized.svg b/tests/qml/fixtures/result-oversized.svg new file mode 100644 index 0000000..52128d1 --- /dev/null +++ b/tests/qml/fixtures/result-oversized.svg @@ -0,0 +1,3 @@ + + + diff --git a/tests/qml/fixtures/result-tall.svg b/tests/qml/fixtures/result-tall.svg new file mode 100644 index 0000000..810a372 --- /dev/null +++ b/tests/qml/fixtures/result-tall.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/tests/qml/fixtures/result-wide.svg b/tests/qml/fixtures/result-wide.svg new file mode 100644 index 0000000..0a9f422 --- /dev/null +++ b/tests/qml/fixtures/result-wide.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/tests/qml/tst_MaskEditor.qml b/tests/qml/tst_MaskEditor.qml new file mode 100644 index 0000000..7912ce6 --- /dev/null +++ b/tests/qml/tst_MaskEditor.qml @@ -0,0 +1,146 @@ +import QtQuick +import QtTest +import "../../apps/desktop/qml/components/graph" + +TestCase { + id: testCase + name: "MaskEditor" + width: 1100 + height: 900 + visible: true + when: windowShown + property var editor + property var stage + property var overlay + + QtObject { + id: fakeApi + property var posts: [] + property int status: 201 + property bool deferReply: false + property var pending + function post(path, body, callback) { + posts = posts.concat([{path: path, body: JSON.parse(JSON.stringify(body))}]) + if (deferReply) pending = callback + else callback(status, status === 201 ? {path: "/media/masks/painted.png", file_url: "file:///media/masks/painted.png"} : {error: "Mask save failed"}) + } + } + Component { id: maskComponent; MaskEditor {} } + function init() { + fakeApi.posts = []; fakeApi.status = 201; fakeApi.deferReply = false; fakeApi.pending = null + editor = createTemporaryObject(maskComponent, testCase, {api: fakeApi, source: String(Qt.resolvedUrl("fixtures/result-wide.svg"))}) + verify(editor !== null) + editor.open() + tryCompare(editor, "visible", true) + tryCompare(findChild(editor.contentItem, "maskSourceImage"), "status", Image.Ready) + stage = findChild(editor.contentItem, "maskPaintStage") + overlay = findChild(editor.contentItem, "maskOverlay") + tryVerify(function() { return overlay.width > 0 && overlay.height > 0 }) + } + function cleanup() { if (editor) editor.close(); editor = null; stage = null; overlay = null } + function center() { return [overlay.x + overlay.width / 2, overlay.y + overlay.height / 2] } + function draw() { + var point = center() + verify(editor.beginStroke(point[0], point[1])) + editor.appendPoint(point[0] + 30, point[1] + 10) + editor.endStroke() + } + function test_letterboxRejected_normalization_andBoundedPoints() { + verify(overlay.y > 0) + compare(editor.pointAt(stage.width / 2, overlay.y / 2), null) + compare(editor.beginStroke(stage.width / 2, overlay.y / 2), false) + draw() + compare(editor.strokes.length, 1) + compare(editor.strokes[0].points[0], [0.5, 0.5]) + fuzzyCompare(editor.strokes[0].radius, editor.brushSize / 2 / Math.max(overlay.width, overlay.height), 0.000001) + editor.strokes[0].points.forEach(function(point) { verify(point[0] >= 0 && point[0] <= 1 && point[1] >= 0 && point[1] <= 1) }) + verify(!editor.beginStroke(-50, stage.height + 50)) + } + function test_pointerPainting_andEraseFlag_andClear() { + var pointer = findChild(editor.contentItem, "maskPaintPointer"), point = center() + mousePress(pointer, point[0], point[1]) + mouseMove(pointer, point[0] + 50, point[1] + 20) + mouseRelease(pointer, point[0] + 50, point[1] + 20) + compare(editor.strokes.length, 1) + verify(editor.strokes[0].points.length >= 2) + compare(editor.strokes[0].erase, false) + editor.erase = true + draw() + compare(editor.strokes[1].erase, true) + editor.undoStroke() + compare(editor.strokes.length, 1) + mouseClick(findChild(editor.contentItem, "maskClearButton")) + compare(editor.strokes.length, 0) + } + function test_resize_keepsNormalizedStrokes() { + draw() + var before = JSON.stringify(editor.strokes) + editor.width = 750; editor.height = 600 + wait(40) + compare(JSON.stringify(editor.strokes), before) + } + function test_localPath_decoding_noRemoteAuthority() { + compare(editor.localPath("file:///tmp/image%20one%23two.png"), "/tmp/image one#two.png") + compare(editor.localPath("file://localhost/tmp/image.png"), "/tmp/image.png") + compare(editor.localPath("file:///C:/Images/image%20one.png"), "C:/Images/image one.png") + compare(editor.localPath("file://remote/share/image.png"), "") + compare(editor.localPath("https://example.org/image.png"), "") + compare(editor.localPath("file:///tmp/bad%QQ.png"), "") + } + function test_submitPayload_andMaskCreatedSignal() { + draw() + var createdPath = "" + editor.maskCreated.connect(function(path) { createdPath = path }) + mouseClick(findChild(editor.contentItem, "maskSaveButton")) + compare(fakeApi.posts.length, 1) + compare(fakeApi.posts[0].path, "/api/v1/workflow/masks") + compare(fakeApi.posts[0].body.image_path, editor.localPath(editor.source)) + compare(fakeApi.posts[0].body.strokes.length, 1) + compare(createdPath, "/media/masks/painted.png") + compare(editor.visible, false) + } + function test_failedSave_retainsPainting_andShowsError() { + draw() + fakeApi.status = 400 + editor.submit() + compare(editor.errorMessage, "Mask save failed") + compare(editor.strokes.length, 1) + compare(editor.visible, true) + compare(editor.saving, false) + } + function test_staleResponseAfterSourceChange_ignored() { + draw() + fakeApi.deferReply = true + editor.submit() + compare(editor.saving, true) + var createdPath = "" + editor.maskCreated.connect(function(path) { createdPath = path }) + editor.source = String(Qt.resolvedUrl("fixtures/result-tall.svg")) + fakeApi.pending(201, {path: "/media/masks/stale.png"}) + compare(createdPath, "") + compare(editor.saving, false) + compare(editor.strokes.length, 0) + } + function test_strokeAndPointLimits() { + editor.maxStrokes = 1 + draw() + var point = center() + verify(!editor.beginStroke(point[0], point[1])) + verify(editor.errorMessage !== "") + editor.clear(); editor.maxStrokes = 1000; editor.maxPoints = 1 + verify(editor.beginStroke(point[0], point[1])) + editor.appendPoint(point[0] + 30, point[1]) + compare(editor.strokes[0].points.length, 1) + verify(editor.errorMessage !== "") + } + function test_oversizedSource_reportsLimit_andDisablesSave() { + editor.source = String(Qt.resolvedUrl("fixtures/result-oversized.svg")) + tryCompare(findChild(editor.contentItem, "maskSourceImage"), "status", Image.Ready) + compare(editor.imageWithinLimits, false) + verify(editor.errorMessage.indexOf("4096") !== -1) + compare(findChild(editor.contentItem, "maskSaveButton").enabled, false) + var point = center() + verify(!editor.beginStroke(point[0], point[1])) + compare(editor.strokes.length, 0) + } +} diff --git a/tests/qml/tst_RunHistory.qml b/tests/qml/tst_RunHistory.qml new file mode 100644 index 0000000..f67cede --- /dev/null +++ b/tests/qml/tst_RunHistory.qml @@ -0,0 +1,233 @@ +import QtQuick +import QtTest +import "../../apps/desktop/qml/components/graph" +import "../../apps/desktop/qml/components/graph/RunHistory.js" as History + +TestCase { + id: testCase + name: "RunHistory" + width: 640 + height: 900 + visible: true + when: windowShown + property var panel + property string wide: String(Qt.resolvedUrl("fixtures/result-wide.svg")) + property string tall: String(Qt.resolvedUrl("fixtures/result-tall.svg")) + + QtObject { + id: fakeApi + property var responseRuns: [] + property var gets: [] + property var posts: [] + property var pending: [] + property bool deferReply: false + property bool deferCancel: false + property var pendingCancels: [] + property int getStatus: 200 + property int postStatus: 200 + function get(path, callback) { + gets = gets.concat([path]) + if (deferReply) pending = pending.concat([callback]) + else callback(getStatus, getStatus === 200 ? {runs: responseRuns} : {error: "Refresh failed"}) + } + function post(path, body, callback) { + posts = posts.concat([{path: path, body: body}]) + if (deferCancel) { pendingCancels = pendingCancels.concat([callback]); return } + callback(postStatus, postStatus === 200 ? {ok: true} : {error: "Cancel failed"}) + } + } + QtObject { + id: fakeEvents + signal eventReceived(string name, var payload) + signal reconnected() + } + Component { id: panelComponent; RunHistoryPanel { width: 600; height: 860 } } + Component { id: viewerComponent; ImageViewer {} } + + function run(id, runState, workflow) { + return {id: id, state: runState, workflow_id: workflow || "w1", only: "save", + created_at: "2026-10-03T09:00:00Z", started_at: "2026-10-03T09:00:01Z", + graph: {version: 1, name: "Forest study", edges: [], nodes: [ + {id: "prompt", type: "prompt", title: "Positive prompt", params: {text: "misty forest, red fox"}}, + {id: "sample", type: "sample", params: {seed: -1, steps: 20}}, + {id: "save", type: "image.save", params: {prefix: "forest"}} + ]}, nodes: { + sample: {state: "done", seed: 482910337, message: "seed 482910337", file_urls: [wide, tall], outputs: ["wide.svg", "tall.svg"], ms: 1900}, + save: {state: "done", cached: true, file_urls: [wide], outputs: ["wide.svg"], ms: 0} + }} + } + function init() { + fakeApi.responseRuns = []; fakeApi.gets = []; fakeApi.posts = []; fakeApi.pending = [] + fakeApi.deferReply = false; fakeApi.getStatus = 200; fakeApi.postStatus = 200 + fakeApi.deferCancel = false; fakeApi.pendingCancels = [] + } + function cleanup() { panel = null } + function makePanel(items, workflow) { + fakeApi.responseRuns = items + panel = createTemporaryObject(panelComponent, testCase, {api: fakeApi, events: fakeEvents, workflowId: workflow || ""}) + verify(panel !== null) + tryCompare(panel, "refreshing", false) + wait(30) + return panel + } + function test_results_allBranches_savedFirst_deduplicated() { + var data = run("r1", "complete") + var images = History.resultImages(data) + compare(images.length, 2) + compare(images[0].nodeId, "save") + compare(images[0].saved, true) + compare(images[1].fileUrl, tall) + data.nodes.sample.file_urls.push(wide.replace("file:///", "file://localhost/")) + data.nodes.sample.file_urls.push("file:///tmp/movie.mp4", "https://example.org/image.png") + compare(History.resultImages(data).length, 2) + } + function test_search_andResolvedSeedFromBackend() { + var data = run("r1", "complete") + verify(History.matches(data, "FOREST fox")) + verify(!History.matches(data, "forest city")) + compare(History.seed(data.nodes.sample), "482910337") + compare(History.seed({seed: 0, message: "seed 8"}), "0") + compare(History.seed({message: "seed 77"}), "77") + compare(History.seed({message: "sampling", seed: -1}), "") + } + function test_queueGlobal_historyWorkflowFilter_search() { + makePanel([run("other-active", "running", "w2"), run("ours", "complete"), run("other", "failed", "w2")], "w1") + compare(panel.queueRuns.length, 1) + compare(panel.historyRuns.length, 1) + compare(panel.historyRuns[0].id, "ours") + var search = findChild(panel, "runHistorySearch") + search.text = "city" + compare(panel.historyRuns.length, 0) + compare(panel.queueRuns.length, 1) + search.text = "fox" + findChild(panel, "runWorkflowFilter").checked = false + compare(panel.historyRuns.length, 2) + } + function test_events_reconnect_andProgress_reconcileDurableSnapshots() { + var data = run("r1", "running") + data.nodes.sample = {state: "running", progress: {phase: "sampling", current: 3, total: 20, unit: "steps", message: "Sampling: step 3/20"}} + makePanel([data]) + var retainedHistory = run("finished", "complete") + fakeApi.responseRuns = [data, retainedHistory] + panel.reload() + var historyCard = findChild(panel, "historyRun_finished") + var changed = JSON.parse(JSON.stringify(data)) + changed.nodes.sample.progress.current = 8 + fakeApi.responseRuns = [changed, retainedHistory] + fakeEvents.eventReceived("workflow.node_state", {run_id: "r1", node_id: "sample"}) + tryVerify(function() { return panel.runs[0].nodes.sample.progress.current === 8 }) + compare(findChild(panel, "historyRun_finished"), historyCard) + changed = JSON.parse(JSON.stringify(changed)); changed.state = "complete" + fakeApi.responseRuns = [changed] + fakeEvents.reconnected() + tryCompare(panel, "queueRuns", []) + compare(panel.historyRuns.length, 1) + } + function test_inflightRefresh_coalescesEvents_withoutInventingState() { + makePanel([run("r1", "queued")]) + fakeApi.deferReply = true + panel.reload() + compare(fakeApi.pending.length, 1) + fakeEvents.eventReceived("workflow.run_finished", {run_id: "r1", state: "complete"}) + compare(panel.runs[0].state, "queued") + fakeApi.deferReply = false + fakeApi.responseRuns = [run("r1", "complete")] + fakeApi.pending[0](200, {runs: [run("r1", "running")]}) + tryVerify(function() { return panel.runs[0].state === "complete" }) + } + function test_cancelIndividual_failureRetainsRunState() { + makePanel([run("r/1", "queued"), run("r2", "running")]) + fakeApi.postStatus = 500 + var row = findChild(panel, "queueRun_r/1") + var button = findChild(row, "cancelRunButton") + mouseClick(button) + compare(fakeApi.posts.length, 1) + compare(fakeApi.posts[0].path, "/api/v1/workflow/runs/r%2F1/cancel") + compare(panel.runs[0].state, "queued") + compare(panel._cancelErrors["r/1"], "Cancel failed") + } + function test_refreshFailure_preservesResults() { + makePanel([run("r1", "complete")]) + fakeApi.getStatus = 503 + panel.reload() + compare(panel.historyRuns.length, 1) + compare(panel.refreshError, "Refresh failed") + } + function test_cancelSuccess_reconcilesBackend_andIgnoresLateRetryCallback() { + makePanel([run("r1", "queued")]) + fakeApi.deferCancel = true + panel.cancelRun(panel.runs[0]) + var first = fakeApi.pendingCancels[0] + first(0, null) + compare(panel.runs[0].state, "queued") + panel.cancelRun(panel.runs[0]) + var second = fakeApi.pendingCancels[1], serial = panel._cancelPending.r1 + first(500, {error: "Late error"}) + compare(panel._cancelPending.r1, serial) + compare(panel._cancelErrors.r1, undefined) + fakeApi.responseRuns = [run("r1", "canceled")] + second(200, {ok: true}) + compare(panel.runs[0].state, "canceled") + compare(panel.queueRuns.length, 0) + } + function test_apiChange_ignoresOldRefreshCallback() { + makePanel([run("r1", "running")]) + fakeApi.deferReply = true + panel.reload() + var previousCallback = fakeApi.pending[0] + panel.api = null + previousCallback(200, {runs: [run("obsolete", "complete")]}) + compare(panel.runs.length, 0) + compare(panel.refreshing, false) + } + function test_cardActions_emitDurableRunAndLocalUrl() { + var snapshot = run("r1", "complete") + snapshot.source_graph = JSON.parse(JSON.stringify(snapshot.graph)) + snapshot.graph.nodes[1].params.seed = 482910337 + makePanel([snapshot]) + var row = findChild(panel, "historyRun_r1"), inspected, restored, used, compared + panel.inspectRun.connect(function(data) { inspected = data }) + panel.restoreRun.connect(function(data) { restored = data }) + panel.useImage.connect(function(url) { used = url }) + panel.compareImage.connect(function(url) { compared = url }) + mouseClick(findChild(row, "inspectRunButton")) + mouseClick(findChild(row, "restoreRunButton")) + mouseClick(findChild(row, "useResult_0")) + mouseClick(findChild(row, "compareResult_0")) + compare(inspected.id, "r1"); compare(restored.graph.name, "Forest study") + compare(inspected.source_graph.nodes[1].params.seed, -1) + compare(restored.source_graph.nodes[1].params.seed, -1) + compare(inspected.graph.nodes[1].params.seed, 482910337) + compare(restored.graph.nodes[1].params.seed, 482910337) + compare(inspected.nodes.sample.seed, 482910337) + compare(restored.nodes.save.cached, true) + compare(row.nodes[0].params.seed, 482910337) + compare(used, wide); compare(compared, wide) + mouseClick(findChild(row, "runDetailsButton")) + verify(row.expanded) + } + function test_viewer_batch_compare_zoomPan_reset() { + var viewer = createTemporaryObject(viewerComponent, testCase, {sources: [wide, tall], currentIndex: 0, comparisonSource: tall}) + verify(viewer !== null) + viewer.open() + tryCompare(viewer, "visible", true) + var viewport = findChild(viewer.contentItem, "mainImageViewport") + tryCompare(viewport, "imageStatus", Image.Ready) + verify(viewer.comparing) + viewport.zoomAt(4, viewport.width / 2, viewport.height / 2) + compare(viewport.zoomFactor, 4) + verify(viewport.maxPanX > 0) + mousePress(findChild(viewport, "imagePanArea"), viewport.width / 2, viewport.height / 2) + mouseMove(findChild(viewport, "imagePanArea"), viewport.width / 2 + 50, viewport.height / 2) + mouseRelease(findChild(viewport, "imagePanArea"), viewport.width / 2 + 50, viewport.height / 2) + verify(viewport.panX > 0) + viewer.next() + compare(viewer.currentIndex, 1) + compare(viewport.zoomFactor, 1) + viewer.previous() + compare(viewer.currentIndex, 0) + viewer.sources = [wide] + compare(viewer.currentIndex, 0) + viewer.close() + } +}