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()
+ }
+}