Files

723 lines
25 KiB
Python

"""Expose a sync-friendly Mutiny runtime service for ComfyUI nodes."""
from __future__ import annotations
import logging
import math
import time
from dataclasses import replace
from decimal import ROUND_HALF_UP, Decimal, InvalidOperation
from mutiny import ImageOutput, JobSnapshot, JobStatus, TextOutput, VideoOutput
from ..prompting.validators import normalize_prompt_text
from ..services import ResultVideoLoader
from ..services.image_results import ResultImageLoader
from ..settings.service import get_settings_service
from .errors import (
JobFailedError,
RuntimeAdapterError,
format_runtime_context,
translate_runtime_exception,
)
from .manager import MutinyRuntimeManager
from .models import (
JobTrackingSnapshot,
MidjourneyAnimateMotion,
MidjourneyPanDirection,
MidjourneyUpscaleMode,
MidjourneyVariationMode,
RuntimeActionContext,
RuntimeDescribeResult,
RuntimeImagineImageInputs,
RuntimeJobResult,
RuntimeSubmission,
RuntimeVideoResult,
)
logger = logging.getLogger(__name__)
_DEFAULT_RUNTIME_SERVICE: "MutinyRuntimeService | None" = None
class MutinyRuntimeService:
"""Submit jobs through Mutiny and wait for results without exposing async details."""
def __init__(
self,
manager: MutinyRuntimeManager,
*,
image_loader: ResultImageLoader | None = None,
video_loader: ResultVideoLoader | None = None,
) -> None:
"""Store the runtime manager and result loaders used for node execution."""
self._manager = manager
self._image_loader = image_loader or ResultImageLoader()
self._video_loader = video_loader or ResultVideoLoader()
def imagine_and_wait(
self,
prompt_text: str,
*,
node_name: str,
model_name: str | None = None,
image_inputs: RuntimeImagineImageInputs | None = None,
progress_reporter=None,
) -> RuntimeJobResult:
"""Submit an imagine job and wait for its terminal image result."""
context = RuntimeActionContext(
node_name=node_name,
action_name="imagine",
model_name=model_name,
)
try:
submission = self._manager.submit_imagine(
prompt_text,
image_inputs=image_inputs,
)
context = replace(context, job_id=submission.job_id)
return self._wait_for_image_result(
submission,
context=context,
expect_split_result=True,
progress_reporter=progress_reporter,
)
except Exception as exc:
self._raise_runtime_error(exc, context)
def get_job(self, job_id: str) -> JobSnapshot:
"""Return one public job snapshot from the shared runtime."""
context = RuntimeActionContext(
node_name="runtime",
action_name="get_job",
job_id=job_id,
)
try:
return self._manager.get_job(job_id)
except Exception as exc:
self._raise_runtime_error(exc, context)
def upscale_image_and_wait(
self,
image_bytes: bytes,
*,
mode: MidjourneyUpscaleMode,
node_name: str,
progress_reporter=None,
) -> RuntimeJobResult:
"""Resolve one image context and submit the selected upscale-family action."""
context = RuntimeActionContext(
node_name=node_name,
action_name="upscale",
)
try:
resolved_context = self._resolve_recognized_source_context(
image_bytes=image_bytes
)
submission = self._manager.submit_upscale(
resolved_context.job_id,
index=self._resolve_upscale_index(
resolved_index=resolved_context.index,
mode=mode,
),
mode=mode,
)
context = replace(context, job_id=submission.job_id)
return self._wait_for_image_result(
submission,
context=context,
expect_split_result=False,
progress_reporter=progress_reporter,
)
except Exception as exc:
self._raise_runtime_error(exc, context)
def vary_region_image_and_wait(
self,
source_image_data_url: str,
mask_data_url: str,
*,
prompt_text: str,
node_name: str,
progress_reporter=None,
) -> RuntimeJobResult:
"""Submit one Vary Region request and wait for the final image result."""
context = RuntimeActionContext(
node_name=node_name,
action_name="vary_region",
)
try:
submission = self._manager.submit_vary_region(
source_image_data_url=source_image_data_url,
mask_data_url=mask_data_url,
prompt_text=prompt_text,
)
context = replace(context, job_id=submission.job_id)
return self._wait_for_image_result(
submission,
context=context,
expect_split_result=True,
progress_reporter=progress_reporter,
)
except Exception as exc:
self._raise_runtime_error(exc, context)
def variation_image_and_wait(
self,
image_bytes: bytes,
*,
mode: MidjourneyVariationMode,
node_name: str,
progress_reporter=None,
) -> RuntimeJobResult:
"""Resolve one grid tile and submit the selected variation-family action."""
context = RuntimeActionContext(
node_name=node_name,
action_name="vary",
)
try:
resolved_context = self._resolve_recognized_source_context(
image_bytes=image_bytes
)
submission = self._manager.submit_variation(
resolved_context.job_id,
index=self._resolve_variation_index(
resolved_index=resolved_context.index,
mode=mode,
),
mode=mode,
)
context = replace(context, job_id=submission.job_id)
return self._wait_for_image_result(
submission,
context=context,
expect_split_result=True,
progress_reporter=progress_reporter,
)
except Exception as exc:
self._raise_runtime_error(exc, context)
def describe_image_and_wait(
self,
image_data_url: str,
*,
node_name: str,
progress_reporter=None,
) -> RuntimeDescribeResult:
"""Submit one describe request and return the final prompt text."""
context = RuntimeActionContext(
node_name=node_name,
action_name="describe",
)
try:
submission = self._manager.submit_describe(image_data_url=image_data_url)
context = replace(context, job_id=submission.job_id)
snapshot = self._wait_for_terminal_snapshot(
submission,
context=context,
progress_reporter=progress_reporter,
)
return RuntimeDescribeResult(
job=snapshot,
prompt_text=self._extract_describe_text(snapshot),
)
except Exception as exc:
self._raise_runtime_error(exc, context)
def animate_image_and_wait(
self,
start_frame_data_url: str,
*,
motion: MidjourneyAnimateMotion,
end_frame_data_url: str | None = None,
prompt_text: str = "",
batch_size: int | None = None,
node_name: str,
progress_reporter=None,
) -> RuntimeVideoResult:
"""Submit one animate request and return the final native video result."""
context = RuntimeActionContext(
node_name=node_name,
action_name="animate",
)
try:
submission = self._manager.submit_animate(
start_frame_data_url=start_frame_data_url,
end_frame_data_url=end_frame_data_url,
prompt_text=prompt_text,
motion=motion,
batch_size=batch_size,
)
context = replace(context, job_id=submission.job_id)
snapshot = self._wait_for_terminal_snapshot(
submission,
context=context,
progress_reporter=progress_reporter,
progress_text_transformer=self._normalize_video_progress_text,
)
return self._build_video_result(snapshot, submission=submission)
except Exception as exc:
self._raise_runtime_error(exc, context)
def extend_video_and_wait(
self,
video_bytes: bytes,
*,
motion: MidjourneyAnimateMotion,
node_name: str,
progress_reporter=None,
) -> RuntimeVideoResult:
"""Submit one animate-extend request and return the final native video result."""
context = RuntimeActionContext(
node_name=node_name,
action_name="extend",
)
try:
submission = self._manager.submit_extend(
video_bytes=video_bytes,
motion=motion,
)
context = replace(context, job_id=submission.job_id)
snapshot = self._wait_for_terminal_snapshot(
submission,
context=context,
progress_reporter=progress_reporter,
progress_text_transformer=self._normalize_video_progress_text,
)
return self._build_video_result(snapshot, submission=submission)
except Exception as exc:
self._raise_runtime_error(exc, context)
def pan_image_and_wait(
self,
image_bytes: bytes,
*,
direction: MidjourneyPanDirection,
node_name: str,
progress_reporter=None,
) -> RuntimeJobResult:
"""Resolve one recognized Midjourney source image and submit the pan action."""
context = RuntimeActionContext(
node_name=node_name,
action_name="pan",
)
try:
resolved_context = self._resolve_recognized_source_context(
image_bytes=image_bytes
)
submission = self._manager.submit_pan(
resolved_context.job_id,
direction=direction,
index=self._normalize_follow_up_submission_index(
resolved_context.index
),
)
context = replace(context, job_id=submission.job_id)
return self._wait_for_image_result(
submission,
context=context,
expect_split_result=True,
progress_reporter=progress_reporter,
)
except Exception as exc:
self._raise_runtime_error(exc, context)
def zoom_image_and_wait(
self,
image_bytes: bytes,
*,
zoom_factor: float,
prompt_text: str,
node_name: str,
progress_reporter=None,
) -> RuntimeJobResult:
"""Resolve one recognized Midjourney source image and submit the zoom action."""
context = RuntimeActionContext(
node_name=node_name,
action_name="zoom",
)
try:
resolved_context = self._resolve_recognized_source_context(
image_bytes=image_bytes
)
normalized_factor = self._normalize_zoom_factor(zoom_factor)
normalized_prompt = normalize_prompt_text(prompt_text) or None
submission = self._manager.submit_zoom(
resolved_context.job_id,
factor=normalized_factor,
prompt_text=normalized_prompt,
index=self._normalize_follow_up_submission_index(
resolved_context.index
),
)
context = replace(context, job_id=submission.job_id)
return self._wait_for_image_result(
submission,
context=context,
expect_split_result=False,
progress_reporter=progress_reporter,
)
except Exception as exc:
self._raise_runtime_error(exc, context)
def close(self) -> None:
"""Close the underlying shared runtime manager."""
self._manager.close()
def _wait_for_image_result(
self,
submission: RuntimeSubmission,
*,
context: RuntimeActionContext,
expect_split_result: bool,
progress_reporter,
) -> RuntimeJobResult:
"""Wait for one image-producing job and decode its final media payload."""
snapshot = self._wait_for_terminal_snapshot(
submission,
context=context,
progress_reporter=progress_reporter,
)
image_source = self._extract_image_source(snapshot)
image_bytes = self._image_loader.fetch_bytes(
image_source,
config=submission.config,
)
tiles = ()
if expect_split_result:
tiles = self._manager.split_image_result(submission.job_id, image_bytes)
return RuntimeJobResult(job=snapshot, image_bytes=image_bytes, tiles=tiles)
def _wait_for_terminal_snapshot(
self,
submission: RuntimeSubmission,
*,
context: RuntimeActionContext,
progress_reporter,
progress_text_transformer=None,
) -> JobSnapshot:
"""Wait for tracked job updates until the job succeeds or fails."""
timeout_seconds = max(
1,
submission.config.engine.execution.task_timeout_minutes * 60,
)
deadline = time.monotonic() + timeout_seconds
last_version = -1
last_progress_text: str | None = None
last_preview_image_url: str | None = None
while True:
remaining = deadline - time.monotonic()
if remaining <= 0:
raise TimeoutError("Task timeout: Maximum wait time exceeded")
snapshot = self._manager.wait_for_snapshot(
submission.job_id,
after_version=last_version,
timeout_s=remaining,
)
if snapshot.version != last_version:
if snapshot.status in {JobStatus.SUCCEEDED, JobStatus.FAILED}:
last_version = snapshot.version
elif self._should_defer_preview_only_snapshot(
snapshot,
last_preview_image_url=last_preview_image_url,
):
last_version = snapshot.version
else:
progress_text = snapshot.progress_text
if progress_text_transformer is not None:
progress_text = progress_text_transformer(progress_text)
(
last_progress_text,
last_preview_image_url,
) = self._report_progress(
progress_text,
snapshot.preview_image_url,
last_progress_text=last_progress_text,
last_preview_image_url=last_preview_image_url,
progress_reporter=progress_reporter,
config=submission.config,
context=context,
)
last_version = snapshot.version
if snapshot.status == JobStatus.SUCCEEDED:
return self._manager.get_job(submission.job_id)
if snapshot.status == JobStatus.FAILED:
fail_reason = snapshot.fail_reason or "Unknown error"
raise JobFailedError(f"Task failed: {fail_reason}")
def _build_video_result(
self,
snapshot: JobSnapshot,
*,
submission: RuntimeSubmission,
) -> RuntimeVideoResult:
"""Build one native Comfy video result from a completed job snapshot."""
output = snapshot.output
if not isinstance(output, VideoOutput):
raise RuntimeAdapterError(
"Animate completed without returning a video artifact."
)
video_source = output.local_file_path or output.video_url or output.website_url
if not video_source:
raise RuntimeAdapterError(
"Animate completed without returning a video artifact."
)
return RuntimeVideoResult(
job=snapshot,
video_url=output.video_url or output.website_url or video_source,
video_value=self._video_loader.fetch_video(
video_source,
config=submission.config,
),
)
def _extract_describe_text(self, snapshot: JobSnapshot) -> str:
"""Return the final describe text from one completed public job snapshot."""
if isinstance(snapshot.output, TextOutput) and snapshot.output.text:
return snapshot.output.text
if snapshot.prompt_text:
return snapshot.prompt_text
raise RuntimeAdapterError("Describe completed without returning prompt text.")
def _extract_image_source(self, snapshot: JobSnapshot) -> str:
"""Return the local path or remote URL for one completed image result."""
output = snapshot.output
if not isinstance(output, ImageOutput):
raise RuntimeAdapterError("Generated image output was missing.")
image_source = output.local_file_path or output.image_url
if not image_source:
raise RuntimeAdapterError("Generated image output was missing.")
return image_source
def _resolve_upscale_index(
self,
*,
resolved_index: int,
mode: MidjourneyUpscaleMode,
) -> int:
"""Return the public submission index for one upscale mode."""
if mode is MidjourneyUpscaleMode.STANDARD:
if resolved_index not in {1, 2, 3, 4}:
raise RuntimeAdapterError(
"Image was recognized, but Standard Upscale requires a Midjourney grid tile."
)
return resolved_index
if resolved_index not in {0, 1, 2, 3, 4}:
raise RuntimeAdapterError(
"Image was recognized, but this Upscale mode requires one recognized Midjourney image."
)
return self._normalize_follow_up_submission_index(resolved_index)
def _normalize_follow_up_submission_index(self, resolved_index: int) -> int:
"""Normalize one recognized image index into a concrete follow-up submission index."""
if resolved_index == 0:
return 1
return resolved_index
def _resolve_recognized_source_context(
self,
*,
image_bytes: bytes,
):
"""Resolve one recognized Midjourney image into the stored Mutiny job context."""
resolved_context = self._manager.resolve_image_context(image_bytes)
if resolved_context is None:
raise RuntimeAdapterError(
"Image was not recognized as a cached Midjourney image by Mutiny."
)
return resolved_context
def _resolve_variation_index(
self,
*,
resolved_index: int,
mode: MidjourneyVariationMode,
) -> int:
"""Return the required tile index for one variation mode."""
del mode
if resolved_index not in {1, 2, 3, 4}:
raise RuntimeAdapterError(
"Image was recognized, but Midjourney Variation requires a Midjourney grid tile."
)
return resolved_index
def _normalize_zoom_factor(self, zoom_factor: float) -> float:
"""Normalize one user-entered zoom factor into the public submission range."""
if not math.isfinite(zoom_factor):
raise RuntimeAdapterError(
"Midjourney Zoom factor must be between 1.00 and 2.00."
)
try:
normalized = Decimal(str(zoom_factor)).quantize(
Decimal("0.01"),
rounding=ROUND_HALF_UP,
)
except (InvalidOperation, ValueError) as exc:
raise RuntimeAdapterError(
"Midjourney Zoom factor must be between 1.00 and 2.00."
) from exc
if normalized < Decimal("1.00") or normalized > Decimal("2.00"):
raise RuntimeAdapterError(
"Midjourney Zoom factor must be between 1.00 and 2.00."
)
return float(normalized)
def _normalize_video_progress_text(self, progress_text: str | None) -> str | None:
"""Normalize animate progress text into one Comfy-friendly percentage when possible."""
if not progress_text:
return progress_text
percent_index = progress_text.find("%")
if percent_index == -1:
return progress_text
digits = []
for character in reversed(progress_text[:percent_index]):
if character.isdigit():
digits.append(character)
continue
if digits:
break
if not digits:
return progress_text
return f"{''.join(reversed(digits))}%"
def _should_defer_preview_only_snapshot(
self,
snapshot: JobTrackingSnapshot,
*,
last_preview_image_url: str | None,
) -> bool:
"""Return whether one non-terminal preview-only snapshot should wait for progress text."""
return (
snapshot.status == JobStatus.IN_PROGRESS
and snapshot.progress_text is None
and bool(snapshot.preview_image_url)
and snapshot.preview_image_url != last_preview_image_url
)
def _report_progress(
self,
progress_text: str | None,
preview_image_url: str | None,
*,
last_progress_text: str | None,
last_preview_image_url: str | None,
progress_reporter,
config,
context: RuntimeActionContext,
) -> tuple[str | None, str | None]:
"""Forward new progress updates to ComfyUI without breaking the main wait."""
if progress_reporter is None:
return progress_text, preview_image_url
if (
progress_text == last_progress_text
and preview_image_url == last_preview_image_url
):
return last_progress_text, last_preview_image_url
preview_payload = None
if preview_image_url and preview_image_url != last_preview_image_url:
try:
preview_payload = self._image_loader.fetch_bytes(
preview_image_url,
config=config,
)
except Exception:
logger.warning(
"Failed to fetch progress preview %s preview_image_url=%s",
format_runtime_context(context),
preview_image_url,
exc_info=True,
)
try:
progress_reporter(progress_text, preview_payload)
except Exception:
logger.warning(
"Progress reporter failed %s progress=%s has_preview=%s",
format_runtime_context(context),
progress_text or "-",
preview_payload is not None,
exc_info=True,
)
return progress_text, preview_image_url
def _raise_runtime_error(
self,
exc: Exception,
context: RuntimeActionContext,
) -> None:
"""Log one runtime failure with context and raise the normalized error."""
translated = translate_runtime_exception(exc)
if isinstance(exc, RuntimeAdapterError):
logger.error(
"Mutiny runtime action failed %s error=%s",
format_runtime_context(context),
translated,
)
else:
logger.exception(
"Unexpected Mutiny runtime action failure %s",
format_runtime_context(context),
)
raise translated from exc
def build_default_runtime_service() -> MutinyRuntimeService:
"""Construct the process-wide default runtime service."""
return MutinyRuntimeService(MutinyRuntimeManager(get_settings_service()))
def get_runtime_service() -> MutinyRuntimeService:
"""Return the process-wide runtime service used by exported nodes."""
global _DEFAULT_RUNTIME_SERVICE
if _DEFAULT_RUNTIME_SERVICE is None:
_DEFAULT_RUNTIME_SERVICE = build_default_runtime_service()
return _DEFAULT_RUNTIME_SERVICE
def close_runtime_service() -> None:
"""Close the process-wide runtime service when it exists."""
if _DEFAULT_RUNTIME_SERVICE is not None:
_DEFAULT_RUNTIME_SERVICE.close()
def reset_runtime_service() -> None:
"""Discard the process-wide runtime service after closing it."""
global _DEFAULT_RUNTIME_SERVICE
if _DEFAULT_RUNTIME_SERVICE is not None:
_DEFAULT_RUNTIME_SERVICE.close()
_DEFAULT_RUNTIME_SERVICE = None
__all__ = [
"MutinyRuntimeService",
"build_default_runtime_service",
"close_runtime_service",
"get_runtime_service",
"reset_runtime_service",
]