Skip to content

Commit b9a1d8a

Browse files
committed
Send text from custom workflows to Krita #1285
1 parent 180b004 commit b9a1d8a

6 files changed

Lines changed: 217 additions & 17 deletions

File tree

‎ai_diffusion/client.py‎

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -26,14 +26,30 @@ class ClientEvent(Enum):
2626
queued = 6
2727
upload = 7
2828
published = 8
29+
output = 9
30+
31+
32+
class TextOutput(NamedTuple):
33+
key: str
34+
name: str
35+
text: str
36+
mime: str
37+
38+
39+
class SharedWorkflow(NamedTuple):
40+
publisher: str
41+
workflow: dict
42+
43+
44+
ClientOutput = dict | SharedWorkflow | TextOutput
2945

3046

3147
class ClientMessage(NamedTuple):
3248
event: ClientEvent
3349
job_id: str = ""
3450
progress: float = 0
3551
images: ImageCollection | None = None
36-
result: dict | SharedWorkflow | None = None
52+
result: ClientOutput | None = None
3753
error: str | None = None
3854

3955

@@ -69,11 +85,6 @@ def parse(data: dict):
6985
return DeviceInfo("cpu", "unknown", 0)
7086

7187

72-
class SharedWorkflow(NamedTuple):
73-
publisher: str
74-
workflow: dict
75-
76-
7788
class CheckpointInfo(NamedTuple):
7889
filename: str
7990
arch: Arch

‎ai_diffusion/comfy_client.py‎

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111

1212
from .api import WorkflowInput
1313
from .client import Client, CheckpointInfo, ClientMessage, ClientEvent, DeviceInfo, ClientModels
14-
from .client import SharedWorkflow, TranslationPackage, ClientFeatures
14+
from .client import SharedWorkflow, TranslationPackage, ClientFeatures, TextOutput
1515
from .client import filter_supported_styles, loras_to_upload
1616
from .files import FileFormat
1717
from .image import Image, ImageCollection
@@ -308,10 +308,13 @@ async def _listen_websocket(self, websocket: websockets_client.WebSocketClientPr
308308
log.error(f"Received message {msg} but there is no active job")
309309

310310
if msg["type"] == "executed":
311-
job = self._get_active_job(msg["data"]["prompt_id"])
312-
pose_json = _extract_pose_json(msg)
313-
if job and pose_json:
314-
result = pose_json
311+
if job := self._get_active_job(msg["data"]["prompt_id"]):
312+
text_output = _extract_text_output(job.local_id, msg)
313+
if text_output is not None:
314+
await self._messages.put(text_output)
315+
pose_json = _extract_pose_json(msg)
316+
if pose_json is not None:
317+
result = pose_json
315318

316319
if msg["type"] == "execution_error":
317320
job = self._get_active_job(msg["data"]["prompt_id"])
@@ -733,3 +736,27 @@ def _extract_pose_json(msg: dict):
733736
except Exception as e:
734737
log.warning(f"Error processing message, error={str(e)}, msg={msg}")
735738
return None
739+
740+
741+
def _extract_text_output(job_id: str, msg: dict):
742+
try:
743+
output = msg["data"]["output"]
744+
if output is not None and "text" in output:
745+
key = msg["data"].get("node")
746+
payload = output["text"]
747+
name, text, mime = (None, None, "text/plain")
748+
if isinstance(payload, list) and len(payload) >= 1:
749+
payload = payload[0]
750+
if isinstance(payload, dict):
751+
text = payload.get("text")
752+
name = payload.get("name")
753+
mime = payload.get("content-type", mime)
754+
elif isinstance(payload, str):
755+
text = payload
756+
name = f"Node {key}"
757+
if text is not None and name is not None:
758+
result = TextOutput(key, name, text, mime)
759+
return ClientMessage(ClientEvent.output, job_id, result=result)
760+
except Exception as e:
761+
log.warning(f"Error processing message, error={str(e)}, msg={msg}")
762+
return None

‎ai_diffusion/custom_workflow.py‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from PyQt5.QtCore import pyqtSignal
1111

1212
from .api import WorkflowInput
13+
from .client import TextOutput, ClientOutput
1314
from .comfy_workflow import ComfyWorkflow, ComfyNode
1415
from .connection import Connection, ConnectionState
1516
from .image import Bounds, Image
@@ -323,6 +324,7 @@ class CustomWorkspace(QObject, ObservableProperties):
323324
mode = Property(CustomGenerationMode.regular, setter="_set_mode")
324325
is_live = Property(False, setter="toggle_live")
325326
has_result = Property(False)
327+
outputs = Property({})
326328

327329
workflow_id_changed = pyqtSignal(str)
328330
graph_changed = pyqtSignal()
@@ -331,6 +333,7 @@ class CustomWorkspace(QObject, ObservableProperties):
331333
is_live_changed = pyqtSignal(bool)
332334
result_available = pyqtSignal(Image)
333335
has_result_changed = pyqtSignal(bool)
336+
outputs_changed = pyqtSignal(dict)
334337
modified = pyqtSignal(QObject, str)
335338

336339
_live_poll_rate = 0.1
@@ -345,6 +348,7 @@ def __init__(self, workflows: WorkflowCollection, generator: ImageGenerator, job
345348
self._last_input: WorkflowInput | None = None
346349
self._last_result: Image | None = None
347350
self._last_job: JobParams | None = None
351+
self._new_outputs: list[str] = []
348352

349353
jobs.job_finished.connect(self._handle_job_finished)
350354
workflows.dataChanged.connect(self._update_workflow)
@@ -463,7 +467,20 @@ def collect_parameters(self, layers: "LayerManager", bounds: Bounds):
463467

464468
return params
465469

470+
def show_output(self, output: ClientOutput | None):
471+
if isinstance(output, TextOutput):
472+
self._new_outputs.append(output.key)
473+
self.outputs[output.key] = output
474+
self.outputs_changed.emit(self.outputs)
475+
466476
def _handle_job_finished(self, job: Job):
477+
to_remove = [k for k in self.outputs.keys() if k not in self._new_outputs]
478+
for key in to_remove:
479+
del self.outputs[key]
480+
if len(to_remove) > 0:
481+
self.outputs_changed.emit(self.outputs)
482+
self._new_outputs.clear()
483+
467484
if job.kind is JobKind.live_preview:
468485
if len(job.results) > 0:
469486
self._last_result = job.results[0]

‎ai_diffusion/model.py‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
from .settings import ApplyBehavior, settings
1818
from .network import NetworkError
1919
from .image import Extent, Image, Mask, Bounds, DummyImage
20-
from .client import ClientMessage, ClientEvent, SharedWorkflow
20+
from .client import ClientMessage, ClientEvent, ClientOutput
2121
from .client import filter_supported_styles, resolve_arch
2222
from .custom_workflow import CustomWorkspace, WorkflowCollection, CustomGenerationMode
2323
from .document import Document, KritaDocument
@@ -461,6 +461,8 @@ def handle_message(self, message: ClientMessage):
461461
self.jobs.notify_started(job)
462462
self.progress_kind = ProgressKind.upload
463463
self.progress = message.progress
464+
elif message.event is ClientEvent.output:
465+
self.custom.show_output(message.result)
464466
elif message.event is ClientEvent.finished:
465467
if message.images:
466468
self.jobs.set_results(job, message.images)
@@ -604,7 +606,7 @@ def apply_generated_result(self, job_id: str, index: int):
604606
self.jobs.selection = None
605607
self.jobs.notify_used(job_id, index)
606608

607-
def add_control_layer(self, job: Job, result: dict | SharedWorkflow | None):
609+
def add_control_layer(self, job: Job, result: ClientOutput | None):
608610
assert job.kind is JobKind.control_layer and job.control
609611
if job.control.mode is ControlMode.pose and isinstance(result, (dict, list)):
610612
pose = Pose.from_open_pose_json(result)

‎ai_diffusion/ui/custom_workflow.py‎

Lines changed: 101 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,11 @@
77
from PyQt5.QtWidgets import QComboBox, QFileDialog, QFrame, QGridLayout, QHBoxLayout, QMenu
88
from PyQt5.QtWidgets import QLabel, QLineEdit, QListWidgetItem, QMessageBox, QSpinBox, QAction
99
from PyQt5.QtWidgets import QToolButton, QVBoxLayout, QWidget, QSlider, QDoubleSpinBox
10+
from PyQt5.QtWidgets import QScrollArea, QTextEdit, QSizePolicy
1011

1112
from ..custom_workflow import CustomParam, ParamKind, SortedWorkflows, WorkflowSource
1213
from ..custom_workflow import CustomGenerationMode
14+
from ..client import TextOutput
1315
from ..jobs import JobKind
1416
from ..model import Model, ApplyBehavior
1517
from ..properties import Binding, Bind, bind, bind_combo
@@ -22,6 +24,7 @@
2224
from .live import LivePreviewArea
2325
from .switch import SwitchWidget
2426
from .widget import TextPromptWidget, WorkspaceSelectWidget, StyleSelectWidget
27+
from .settings_widgets import ExpanderButton
2528
from . import theme
2629

2730

@@ -384,6 +387,93 @@ def value(self, values: dict[str, Any]):
384387
widget.value = value
385388

386389

390+
class WorkflowOutputsWidget(QWidget):
391+
def __init__(self, parent: QWidget):
392+
super().__init__(parent)
393+
self._value: dict[str, TextOutput] = {}
394+
395+
self._scroll_area = QScrollArea(self)
396+
self._scroll_area.setFrameShape(QFrame.Shape.NoFrame)
397+
self._scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
398+
399+
self.expander = ExpanderButton(_("Text Output"), self)
400+
self.expander.setStyleSheet("QToolButton { border: none; }")
401+
self.expander.setChecked(True)
402+
self.expander.toggled.connect(self._scroll_area.setVisible)
403+
404+
layout = QVBoxLayout(self)
405+
layout.setContentsMargins(0, 0, 0, 0)
406+
layout.addWidget(self.expander)
407+
layout.addWidget(self._scroll_area)
408+
409+
@property
410+
def value(self):
411+
return self._value
412+
413+
@value.setter
414+
def value(self, value: dict[str, TextOutput]):
415+
self._value = value
416+
self._update()
417+
418+
def _update(self):
419+
if len(self._value) == 0:
420+
self.expander.hide()
421+
self._scroll_area.hide()
422+
return
423+
elif not self.expander.isVisible():
424+
self.expander.show()
425+
self._scroll_area.show()
426+
427+
widget = QWidget(self._scroll_area)
428+
layout = QGridLayout()
429+
layout.setContentsMargins(0, 0, 0, 0)
430+
layout.setColumnMinimumWidth(1, 8)
431+
layout.setColumnStretch(2, 1)
432+
widget.setLayout(layout)
433+
434+
line = 0
435+
text_areas: list[QTextEdit] = []
436+
for output in self._value.values():
437+
label = QLabel(output.name, widget)
438+
if (not output.mime or output.mime == "text/plain") and len(output.text) < 40:
439+
value = QLabel(output.text, widget)
440+
value.setWordWrap(True)
441+
value.setMinimumWidth(40)
442+
layout.addWidget(label, line, 0)
443+
layout.addWidget(value, line, 2)
444+
line += 1
445+
else:
446+
value = QTextEdit(widget)
447+
value.setFrameShape(QFrame.Shape.StyledPanel)
448+
value.setStyleSheet(
449+
"QTextEdit { background: transparent; border-left: 1px solid %s; padding-left: 2px; }"
450+
% theme.line
451+
)
452+
value.setReadOnly(True)
453+
match output.mime:
454+
case "" | "text/plain":
455+
value.setPlainText(output.text)
456+
case "text/html":
457+
value.setHtml(output.text)
458+
case "text/markdown":
459+
value.setMarkdown(output.text)
460+
layout.addWidget(label, line, 0, 1, 3)
461+
layout.addWidget(value, line + 1, 0, 1, 3)
462+
text_areas.append(value)
463+
line += 2
464+
465+
layout.setRowStretch(line, 1)
466+
widget.setFixedWidth(self._scroll_area.width() - 8)
467+
self._scroll_area.setWidget(widget)
468+
if self.expander.isChecked():
469+
widget.show()
470+
471+
for w in text_areas:
472+
size = ensure(w.document()).size().toSize()
473+
w.setFixedHeight(max(size.height() + 2, self.fontMetrics().height() + 6))
474+
widget.adjustSize()
475+
476+
387477
def popup_on_error(func):
388478
@wraps(func)
389479
def wrapper(self, *args, **kwargs):
@@ -487,6 +577,9 @@ def __init__(self):
487577
self._progress_bar = ProgressBar(self)
488578
self._error_text = create_error_label(self)
489579

580+
self._outputs = WorkflowOutputsWidget(self)
581+
self._outputs.expander.toggled.connect(self._update_layout)
582+
490583
self._history = HistoryWidget(self)
491584
self._history.item_activated.connect(self.apply_result)
492585

@@ -525,12 +618,17 @@ def __init__(self):
525618
self._layout.addLayout(actions_layout)
526619
self._layout.addWidget(self._progress_bar)
527620
self._layout.addWidget(self._error_text)
528-
self._layout.addWidget(self._history)
529-
self._layout.addWidget(self._live_preview)
621+
self._layout.addWidget(self._outputs, stretch=1)
622+
self._layout.addWidget(self._history, stretch=3)
623+
self._layout.addWidget(self._live_preview, stretch=5)
530624
self.setLayout(self._layout)
531625

532626
self._update_ui()
533627

628+
def _update_layout(self):
629+
stretch = 1 if self._outputs.expander.isChecked() else 0
630+
self._layout.setStretchFactor(self._outputs, stretch)
631+
534632
@property
535633
def model(self):
536634
return self._model
@@ -543,6 +641,7 @@ def model(self, model: Model):
543641
self._model_bindings = [
544642
bind(model, "workspace", self._workspace_select, "value", Bind.one_way),
545643
bind_combo(model.custom, "workflow_id", self._workflow_select, Bind.one_way),
644+
bind(model.custom, "outputs", self._outputs, "value", Bind.one_way),
546645
model.workspace_changed.connect(self._cancel_name),
547646
model.custom.graph_changed.connect(self._update_current_workflow),
548647
model.error_changed.connect(self._error_text.setText),

0 commit comments

Comments
 (0)