hypercli-sdk 0.4.6__tar.gz → 0.4.7__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {hypercli_sdk-0.4.6 → hypercli_sdk-0.4.7}/PKG-INFO +1 -1
- {hypercli_sdk-0.4.6 → hypercli_sdk-0.4.7}/hypercli/__init__.py +4 -6
- {hypercli_sdk-0.4.6 → hypercli_sdk-0.4.7}/hypercli/client.py +6 -6
- {hypercli_sdk-0.4.6 → hypercli_sdk-0.4.7}/hypercli/config.py +1 -1
- {hypercli_sdk-0.4.6 → hypercli_sdk-0.4.7}/hypercli/files.py +11 -11
- {hypercli_sdk-0.4.6 → hypercli_sdk-0.4.7}/hypercli/job/__init__.py +2 -0
- {hypercli_sdk-0.4.6 → hypercli_sdk-0.4.7}/hypercli/job/base.py +20 -20
- {hypercli_sdk-0.4.6 → hypercli_sdk-0.4.7}/hypercli/job/comfyui.py +249 -17
- {hypercli_sdk-0.4.6 → hypercli_sdk-0.4.7}/hypercli/logs.py +30 -20
- {hypercli_sdk-0.4.6 → hypercli_sdk-0.4.7}/hypercli/renders.py +8 -8
- {hypercli_sdk-0.4.6 → hypercli_sdk-0.4.7}/pyproject.toml +1 -1
- {hypercli_sdk-0.4.6 → hypercli_sdk-0.4.7}/.gitignore +0 -0
- {hypercli_sdk-0.4.6 → hypercli_sdk-0.4.7}/README.md +0 -0
- {hypercli_sdk-0.4.6 → hypercli_sdk-0.4.7}/hypercli/billing.py +0 -0
- {hypercli_sdk-0.4.6 → hypercli_sdk-0.4.7}/hypercli/http.py +0 -0
- {hypercli_sdk-0.4.6 → hypercli_sdk-0.4.7}/hypercli/instances.py +0 -0
- {hypercli_sdk-0.4.6 → hypercli_sdk-0.4.7}/hypercli/jobs.py +0 -0
- {hypercli_sdk-0.4.6 → hypercli_sdk-0.4.7}/hypercli/user.py +0 -0
- {hypercli_sdk-0.4.6 → hypercli_sdk-0.4.7}/tests/test_apply_params.py +0 -0
- {hypercli_sdk-0.4.6 → hypercli_sdk-0.4.7}/tests/test_graph_to_api.py +0 -0
|
@@ -1,20 +1,17 @@
|
|
|
1
1
|
"""HyperCLI SDK - Python client for HyperCLI API"""
|
|
2
2
|
from .client import HyperCLI
|
|
3
|
-
|
|
4
|
-
# Backwards compatibility alias
|
|
5
|
-
C3 = HyperCLI
|
|
6
3
|
from .config import configure, GHCR_IMAGES, COMFYUI_IMAGE
|
|
7
4
|
from .http import APIError, AsyncHTTPClient
|
|
8
5
|
from .instances import GPUType, GPUConfig, Region, GPUPricing, PricingTier
|
|
9
6
|
from .jobs import Job, JobMetrics, GPUMetrics, find_job, find_by_id, find_by_hostname, find_by_ip
|
|
10
7
|
from .renders import Render, RenderStatus
|
|
11
8
|
from .files import File, AsyncFiles
|
|
12
|
-
from .job import BaseJob, ComfyUIJob, apply_params, apply_graph_modes, find_node, find_nodes, load_template, graph_to_api, DEFAULT_OBJECT_INFO
|
|
9
|
+
from .job import BaseJob, ComfyUIJob, apply_params, apply_graph_modes, find_node, find_nodes, load_template, graph_to_api, expand_subgraphs, DEFAULT_OBJECT_INFO
|
|
13
10
|
from .logs import LogStream, stream_logs, fetch_logs
|
|
14
11
|
|
|
15
|
-
__version__ = "0.
|
|
12
|
+
__version__ = "0.4.7"
|
|
16
13
|
__all__ = [
|
|
17
|
-
"HyperCLI",
|
|
14
|
+
"HyperCLI",
|
|
18
15
|
"configure",
|
|
19
16
|
"APIError",
|
|
20
17
|
# Images
|
|
@@ -52,6 +49,7 @@ __all__ = [
|
|
|
52
49
|
"find_nodes",
|
|
53
50
|
"load_template",
|
|
54
51
|
"graph_to_api",
|
|
52
|
+
"expand_subgraphs",
|
|
55
53
|
"DEFAULT_OBJECT_INFO",
|
|
56
54
|
# Log streaming
|
|
57
55
|
"LogStream",
|
|
@@ -14,18 +14,18 @@ class HyperCLI:
|
|
|
14
14
|
HyperCLI API Client
|
|
15
15
|
|
|
16
16
|
Usage:
|
|
17
|
-
from hypercli import
|
|
17
|
+
from hypercli import HyperCLI
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
client = HyperCLI() # Uses HYPERCLI_API_KEY from env or ~/.hypercli/config
|
|
20
20
|
# or
|
|
21
|
-
|
|
21
|
+
client = HyperCLI(api_key="your_key")
|
|
22
22
|
|
|
23
23
|
# Billing
|
|
24
|
-
balance =
|
|
24
|
+
balance = client.billing.balance()
|
|
25
25
|
print(f"Balance: ${balance.total}")
|
|
26
26
|
|
|
27
27
|
# Jobs
|
|
28
|
-
job =
|
|
28
|
+
job = client.jobs.create(
|
|
29
29
|
image="nvidia/cuda:12.0",
|
|
30
30
|
gpu_type="l40s",
|
|
31
31
|
command="python train.py"
|
|
@@ -33,7 +33,7 @@ class HyperCLI:
|
|
|
33
33
|
print(f"Job: {job.job_id}")
|
|
34
34
|
|
|
35
35
|
# User
|
|
36
|
-
user =
|
|
36
|
+
user = client.user.get()
|
|
37
37
|
"""
|
|
38
38
|
|
|
39
39
|
def __init__(self, api_key: str = None, api_url: str = None):
|
|
@@ -11,7 +11,7 @@ DEFAULT_WS_URL = "wss://api.hypercli.com"
|
|
|
11
11
|
WS_LOGS_PATH = "/orchestra/ws/logs" # WebSocket path for job logs: {WS_URL}{WS_LOGS_PATH}/{job_key}
|
|
12
12
|
|
|
13
13
|
# GHCR images
|
|
14
|
-
GHCR_IMAGES = "ghcr.io/
|
|
14
|
+
GHCR_IMAGES = "ghcr.io/compute3ai/images"
|
|
15
15
|
COMFYUI_IMAGE = f"{GHCR_IMAGES}/comfyui"
|
|
16
16
|
|
|
17
17
|
|
|
@@ -23,7 +23,7 @@ class File:
|
|
|
23
23
|
filename: str
|
|
24
24
|
content_type: str
|
|
25
25
|
file_size: int
|
|
26
|
-
url: str # Internal S3 reference - only valid for use in
|
|
26
|
+
url: str # Internal S3 reference - only valid for use in HyperCLI renders
|
|
27
27
|
state: str | None = None # processing, done, failed (for async uploads)
|
|
28
28
|
error: str | None = None # Error message if state=failed
|
|
29
29
|
created_at: str | None = None
|
|
@@ -72,11 +72,11 @@ class Files:
|
|
|
72
72
|
|
|
73
73
|
Returns:
|
|
74
74
|
File object with id and internal url for use in render calls.
|
|
75
|
-
The url is an S3 reference that only works within
|
|
75
|
+
The url is an S3 reference that only works within HyperCLI.
|
|
76
76
|
|
|
77
77
|
Example:
|
|
78
|
-
file =
|
|
79
|
-
render =
|
|
78
|
+
file = client.files.upload("./my_image.png")
|
|
79
|
+
render = client.renders.image_to_video("dancing", file.url)
|
|
80
80
|
"""
|
|
81
81
|
# Read file
|
|
82
82
|
with open(file_path, "rb") as f:
|
|
@@ -106,7 +106,7 @@ class Files:
|
|
|
106
106
|
File object with id and url for use in render calls
|
|
107
107
|
|
|
108
108
|
Example:
|
|
109
|
-
file =
|
|
109
|
+
file = client.files.upload_bytes(image_bytes, "image.png", "image/png")
|
|
110
110
|
"""
|
|
111
111
|
files = {"file": (filename, content, content_type)}
|
|
112
112
|
data = self._http.post_multipart("/api/files/multi", files=files)
|
|
@@ -126,8 +126,8 @@ class Files:
|
|
|
126
126
|
File object with id and state=processing.
|
|
127
127
|
|
|
128
128
|
Example:
|
|
129
|
-
file =
|
|
130
|
-
file =
|
|
129
|
+
file = client.files.upload_url("https://example.com/image.png")
|
|
130
|
+
file = client.files.wait_ready(file.id) # Wait for completion
|
|
131
131
|
"""
|
|
132
132
|
payload = {"url": url}
|
|
133
133
|
if path:
|
|
@@ -159,8 +159,8 @@ class Files:
|
|
|
159
159
|
Example:
|
|
160
160
|
import base64
|
|
161
161
|
b64_data = base64.b64encode(image_bytes).decode()
|
|
162
|
-
file =
|
|
163
|
-
file =
|
|
162
|
+
file = client.files.upload_b64(b64_data, "image.png", "image/png")
|
|
163
|
+
file = client.files.wait_ready(file.id)
|
|
164
164
|
"""
|
|
165
165
|
payload = {"data": data, "filename": filename}
|
|
166
166
|
if content_type:
|
|
@@ -216,8 +216,8 @@ class Files:
|
|
|
216
216
|
ValueError: If file upload failed
|
|
217
217
|
|
|
218
218
|
Example:
|
|
219
|
-
file =
|
|
220
|
-
file =
|
|
219
|
+
file = client.files.upload_url("https://example.com/image.png")
|
|
220
|
+
file = client.files.wait_ready(file.id, timeout=30)
|
|
221
221
|
print(f"Ready: {file.url}")
|
|
222
222
|
"""
|
|
223
223
|
start = time.time()
|
|
@@ -8,6 +8,7 @@ from .comfyui import (
|
|
|
8
8
|
find_nodes,
|
|
9
9
|
load_template,
|
|
10
10
|
graph_to_api,
|
|
11
|
+
expand_subgraphs,
|
|
11
12
|
DEFAULT_OBJECT_INFO,
|
|
12
13
|
)
|
|
13
14
|
|
|
@@ -20,5 +21,6 @@ __all__ = [
|
|
|
20
21
|
"find_nodes",
|
|
21
22
|
"load_template",
|
|
22
23
|
"graph_to_api",
|
|
24
|
+
"expand_subgraphs",
|
|
23
25
|
"DEFAULT_OBJECT_INFO",
|
|
24
26
|
]
|
|
@@ -4,7 +4,7 @@ import httpx
|
|
|
4
4
|
from typing import TYPE_CHECKING
|
|
5
5
|
|
|
6
6
|
if TYPE_CHECKING:
|
|
7
|
-
from ..client import
|
|
7
|
+
from ..client import HyperCLI
|
|
8
8
|
from ..jobs import Job
|
|
9
9
|
|
|
10
10
|
|
|
@@ -16,8 +16,8 @@ class BaseJob:
|
|
|
16
16
|
HEALTH_ENDPOINT: str = "/"
|
|
17
17
|
HEALTH_TIMEOUT: float = 5.0
|
|
18
18
|
|
|
19
|
-
def __init__(self,
|
|
20
|
-
self.
|
|
19
|
+
def __init__(self, client: "HyperCLI", job: "Job"):
|
|
20
|
+
self.client = client
|
|
21
21
|
self.job = job
|
|
22
22
|
self._base_url: str | None = None
|
|
23
23
|
|
|
@@ -39,24 +39,24 @@ class BaseJob:
|
|
|
39
39
|
@property
|
|
40
40
|
def auth_headers(self) -> dict:
|
|
41
41
|
"""Headers for authenticated requests. Override in subclasses for custom auth."""
|
|
42
|
-
return {"Authorization": f"Bearer {self.
|
|
42
|
+
return {"Authorization": f"Bearer {self.client._api_key}"}
|
|
43
43
|
|
|
44
44
|
@classmethod
|
|
45
|
-
def get_running(cls,
|
|
45
|
+
def get_running(cls, client: "HyperCLI", image_filter: str = None) -> "BaseJob | None":
|
|
46
46
|
"""Find an existing running job, optionally filtering by image"""
|
|
47
|
-
jobs =
|
|
47
|
+
jobs = client.jobs.list(state="running")
|
|
48
48
|
for job in jobs:
|
|
49
49
|
if image_filter and image_filter not in job.docker_image:
|
|
50
50
|
continue
|
|
51
|
-
return cls(
|
|
51
|
+
return cls(client, job)
|
|
52
52
|
return None
|
|
53
53
|
|
|
54
54
|
@classmethod
|
|
55
|
-
def get_by_instance(cls,
|
|
55
|
+
def get_by_instance(cls, client: "HyperCLI", instance: str, state: str = "running") -> "BaseJob":
|
|
56
56
|
"""Get a job by ID, hostname, or IP address.
|
|
57
57
|
|
|
58
58
|
Args:
|
|
59
|
-
|
|
59
|
+
client: HyperCLI client instance
|
|
60
60
|
instance: Job ID (UUID), hostname (partial match), or IP address
|
|
61
61
|
state: State filter for hostname/IP search (default: running)
|
|
62
62
|
|
|
@@ -68,15 +68,15 @@ class BaseJob:
|
|
|
68
68
|
"""
|
|
69
69
|
from ..jobs import find_job
|
|
70
70
|
|
|
71
|
-
job = find_job(
|
|
71
|
+
job = find_job(client.jobs, instance, state=state)
|
|
72
72
|
if not job:
|
|
73
73
|
raise ValueError(f"No job found matching: {instance}")
|
|
74
|
-
return cls(
|
|
74
|
+
return cls(client, job)
|
|
75
75
|
|
|
76
76
|
@classmethod
|
|
77
77
|
def create(
|
|
78
78
|
cls,
|
|
79
|
-
|
|
79
|
+
client: "HyperCLI",
|
|
80
80
|
image: str = None,
|
|
81
81
|
gpu_type: str = None,
|
|
82
82
|
gpu_count: int = 1,
|
|
@@ -84,19 +84,19 @@ class BaseJob:
|
|
|
84
84
|
**kwargs,
|
|
85
85
|
) -> "BaseJob":
|
|
86
86
|
"""Create a new job"""
|
|
87
|
-
job =
|
|
87
|
+
job = client.jobs.create(
|
|
88
88
|
image=image or cls.DEFAULT_IMAGE,
|
|
89
89
|
gpu_type=gpu_type or cls.DEFAULT_GPU_TYPE,
|
|
90
90
|
gpu_count=gpu_count,
|
|
91
91
|
runtime=runtime,
|
|
92
92
|
**kwargs,
|
|
93
93
|
)
|
|
94
|
-
return cls(
|
|
94
|
+
return cls(client, job)
|
|
95
95
|
|
|
96
96
|
@classmethod
|
|
97
97
|
def get_or_create(
|
|
98
98
|
cls,
|
|
99
|
-
|
|
99
|
+
client: "HyperCLI",
|
|
100
100
|
image: str = None,
|
|
101
101
|
gpu_type: str = None,
|
|
102
102
|
gpu_count: int = 1,
|
|
@@ -106,12 +106,12 @@ class BaseJob:
|
|
|
106
106
|
) -> "BaseJob":
|
|
107
107
|
"""Get existing running job or create new one"""
|
|
108
108
|
if reuse:
|
|
109
|
-
existing = cls.get_running(
|
|
109
|
+
existing = cls.get_running(client, image_filter=image or cls.DEFAULT_IMAGE)
|
|
110
110
|
if existing:
|
|
111
111
|
return existing
|
|
112
112
|
|
|
113
113
|
return cls.create(
|
|
114
|
-
|
|
114
|
+
client,
|
|
115
115
|
image=image,
|
|
116
116
|
gpu_type=gpu_type,
|
|
117
117
|
gpu_count=gpu_count,
|
|
@@ -121,7 +121,7 @@ class BaseJob:
|
|
|
121
121
|
|
|
122
122
|
def refresh(self) -> "BaseJob":
|
|
123
123
|
"""Refresh job state from API"""
|
|
124
|
-
self.job = self.
|
|
124
|
+
self.job = self.client.jobs.get(self.job_id)
|
|
125
125
|
self._base_url = None
|
|
126
126
|
return self
|
|
127
127
|
|
|
@@ -241,9 +241,9 @@ class BaseJob:
|
|
|
241
241
|
|
|
242
242
|
def shutdown(self) -> dict:
|
|
243
243
|
"""Cancel the job"""
|
|
244
|
-
return self.
|
|
244
|
+
return self.client.jobs.cancel(self.job_id)
|
|
245
245
|
|
|
246
246
|
def extend(self, runtime: int) -> "BaseJob":
|
|
247
247
|
"""Extend job runtime"""
|
|
248
|
-
self.job = self.
|
|
248
|
+
self.job = self.client.jobs.extend(self.job_id, runtime=runtime)
|
|
249
249
|
return self
|
|
@@ -12,7 +12,7 @@ from ..config import COMFYUI_IMAGE
|
|
|
12
12
|
|
|
13
13
|
|
|
14
14
|
if TYPE_CHECKING:
|
|
15
|
-
from ..client import
|
|
15
|
+
from ..client import HyperCLI
|
|
16
16
|
|
|
17
17
|
|
|
18
18
|
# Default object_info for offline workflow conversion (no running instance needed)
|
|
@@ -495,6 +495,235 @@ def apply_params(workflow: dict, **params) -> dict:
|
|
|
495
495
|
return workflow
|
|
496
496
|
|
|
497
497
|
|
|
498
|
+
def expand_subgraphs(graph: dict, debug: bool = False) -> dict:
|
|
499
|
+
"""
|
|
500
|
+
Expand subgraph/group nodes into their constituent nodes.
|
|
501
|
+
|
|
502
|
+
ComfyUI supports "workflow components" (group nodes) where a subgraph is
|
|
503
|
+
collapsed into a single node with a UUID type. These must be expanded
|
|
504
|
+
before the workflow can be executed.
|
|
505
|
+
|
|
506
|
+
Args:
|
|
507
|
+
graph: Workflow in graph format (may contain subgraph definitions)
|
|
508
|
+
debug: If True, print debug info
|
|
509
|
+
|
|
510
|
+
Returns:
|
|
511
|
+
New graph with subgraphs expanded (original graph not modified)
|
|
512
|
+
"""
|
|
513
|
+
import copy
|
|
514
|
+
|
|
515
|
+
# Check if there are subgraph definitions
|
|
516
|
+
subgraphs = {sg["id"]: sg for sg in graph.get("definitions", {}).get("subgraphs", [])}
|
|
517
|
+
if not subgraphs:
|
|
518
|
+
return graph # No subgraphs, return as-is
|
|
519
|
+
|
|
520
|
+
# Deep copy to avoid mutating original
|
|
521
|
+
graph = copy.deepcopy(graph)
|
|
522
|
+
|
|
523
|
+
# Find group nodes that reference subgraphs
|
|
524
|
+
nodes = graph.get("nodes", [])
|
|
525
|
+
links = graph.get("links", [])
|
|
526
|
+
|
|
527
|
+
# Track new nodes and links to add
|
|
528
|
+
new_nodes = []
|
|
529
|
+
new_links = []
|
|
530
|
+
nodes_to_remove = set()
|
|
531
|
+
links_to_remove = set()
|
|
532
|
+
|
|
533
|
+
# Track ID remapping for each expanded subgraph
|
|
534
|
+
# We prefix subgraph node IDs to avoid conflicts
|
|
535
|
+
next_node_id = graph.get("last_node_id", 0) + 1
|
|
536
|
+
next_link_id = graph.get("last_link_id", 0) + 1
|
|
537
|
+
|
|
538
|
+
for node in nodes:
|
|
539
|
+
node_type = str(node.get("type", ""))
|
|
540
|
+
node_id = node.get("id")
|
|
541
|
+
mode = node.get("mode", 0)
|
|
542
|
+
|
|
543
|
+
if node_type not in subgraphs:
|
|
544
|
+
continue
|
|
545
|
+
|
|
546
|
+
# Skip bypassed/muted group nodes
|
|
547
|
+
if mode in (2, 4):
|
|
548
|
+
if debug:
|
|
549
|
+
print(f"Skipping bypassed group node {node_id}")
|
|
550
|
+
nodes_to_remove.add(node_id)
|
|
551
|
+
# Remove links to/from this node
|
|
552
|
+
for link in links:
|
|
553
|
+
if link[1] == node_id or link[3] == node_id:
|
|
554
|
+
links_to_remove.add(link[0])
|
|
555
|
+
continue
|
|
556
|
+
|
|
557
|
+
if debug:
|
|
558
|
+
print(f"Expanding group node {node_id} -> subgraph {node_type[:20]}...")
|
|
559
|
+
|
|
560
|
+
sg = subgraphs[node_type]
|
|
561
|
+
sg_nodes = sg.get("nodes", [])
|
|
562
|
+
sg_links = sg.get("links", [])
|
|
563
|
+
|
|
564
|
+
# Build ID mapping: old_sg_node_id -> new_node_id
|
|
565
|
+
id_map = {}
|
|
566
|
+
for sg_node in sg_nodes:
|
|
567
|
+
old_id = sg_node.get("id")
|
|
568
|
+
id_map[old_id] = next_node_id
|
|
569
|
+
next_node_id += 1
|
|
570
|
+
|
|
571
|
+
# Apply widget values from proxyWidgets
|
|
572
|
+
proxy_widgets = node.get("properties", {}).get("proxyWidgets", [])
|
|
573
|
+
widget_values = node.get("widgets_values", [])
|
|
574
|
+
|
|
575
|
+
# proxyWidgets format: [[target_node_id, widget_name], ...]
|
|
576
|
+
# widget_values are in same order as proxyWidgets
|
|
577
|
+
widget_overrides = {} # {node_id: {widget_name: value}}
|
|
578
|
+
for i, (target_id, widget_name) in enumerate(proxy_widgets):
|
|
579
|
+
if i < len(widget_values):
|
|
580
|
+
target_id = str(target_id)
|
|
581
|
+
if target_id not in widget_overrides:
|
|
582
|
+
widget_overrides[target_id] = {}
|
|
583
|
+
widget_overrides[target_id][widget_name] = widget_values[i]
|
|
584
|
+
|
|
585
|
+
# Copy subgraph nodes with new IDs and apply widget overrides
|
|
586
|
+
for sg_node in sg_nodes:
|
|
587
|
+
old_id = sg_node.get("id")
|
|
588
|
+
new_node = copy.deepcopy(sg_node)
|
|
589
|
+
new_node["id"] = id_map[old_id]
|
|
590
|
+
|
|
591
|
+
# Apply widget overrides
|
|
592
|
+
if str(old_id) in widget_overrides:
|
|
593
|
+
overrides = widget_overrides[str(old_id)]
|
|
594
|
+
sg_widgets = new_node.get("widgets_values", [])
|
|
595
|
+
|
|
596
|
+
# Need to find widget index by name - use input order
|
|
597
|
+
# For simplicity, just update widgets_values if it's a dict
|
|
598
|
+
if isinstance(sg_widgets, dict):
|
|
599
|
+
sg_widgets.update(overrides)
|
|
600
|
+
else:
|
|
601
|
+
# For list format, we need the node's input spec to map names to indices
|
|
602
|
+
# For now, handle common cases
|
|
603
|
+
node_type_inner = new_node.get("type", "")
|
|
604
|
+
if "text" in overrides and node_type_inner in ("CLIPTextEncode", "CLIPTextEncodeFlux"):
|
|
605
|
+
# text is usually first widget
|
|
606
|
+
if sg_widgets:
|
|
607
|
+
sg_widgets[0] = overrides["text"]
|
|
608
|
+
else:
|
|
609
|
+
new_node["widgets_values"] = [overrides["text"]]
|
|
610
|
+
|
|
611
|
+
new_nodes.append(new_node)
|
|
612
|
+
|
|
613
|
+
# Build link ID mapping: old_link_id -> new_link_id
|
|
614
|
+
link_id_map = {}
|
|
615
|
+
|
|
616
|
+
# Copy subgraph links with remapped IDs
|
|
617
|
+
# Subgraph links can be dicts or arrays
|
|
618
|
+
for sg_link in sg_links:
|
|
619
|
+
if isinstance(sg_link, dict):
|
|
620
|
+
old_link_id = sg_link.get("id")
|
|
621
|
+
from_node = sg_link.get("origin_id")
|
|
622
|
+
from_slot = sg_link.get("origin_slot", 0)
|
|
623
|
+
to_node = sg_link.get("target_id")
|
|
624
|
+
to_slot = sg_link.get("target_slot", 0)
|
|
625
|
+
link_type = sg_link.get("type", "")
|
|
626
|
+
else:
|
|
627
|
+
# Array format: [link_id, from_node, from_slot, to_node, to_slot, type]
|
|
628
|
+
old_link_id, from_node, from_slot, to_node, to_slot, link_type = sg_link[:6]
|
|
629
|
+
|
|
630
|
+
# Remap node IDs
|
|
631
|
+
new_from = id_map.get(from_node, from_node)
|
|
632
|
+
new_to = id_map.get(to_node, to_node)
|
|
633
|
+
|
|
634
|
+
new_link = [next_link_id, new_from, from_slot, new_to, to_slot, link_type]
|
|
635
|
+
link_id_map[old_link_id] = next_link_id
|
|
636
|
+
next_link_id += 1
|
|
637
|
+
new_links.append(new_link)
|
|
638
|
+
|
|
639
|
+
# Update node input link references using the link ID mapping
|
|
640
|
+
for new_node in new_nodes:
|
|
641
|
+
for inp in new_node.get("inputs", []):
|
|
642
|
+
old_link = inp.get("link")
|
|
643
|
+
if old_link is not None and old_link in link_id_map:
|
|
644
|
+
inp["link"] = link_id_map[old_link]
|
|
645
|
+
|
|
646
|
+
# Helper to get link fields from dict or array format
|
|
647
|
+
def get_link_fields(lnk):
|
|
648
|
+
if isinstance(lnk, dict):
|
|
649
|
+
return (lnk.get("id"), lnk.get("origin_id"), lnk.get("origin_slot", 0),
|
|
650
|
+
lnk.get("target_id"), lnk.get("target_slot", 0), lnk.get("type", ""))
|
|
651
|
+
return tuple(lnk[:6])
|
|
652
|
+
|
|
653
|
+
# Rewire external connections
|
|
654
|
+
# Group node inputs -> subgraph input nodes
|
|
655
|
+
sg_inputs = sg.get("inputs", [])
|
|
656
|
+
for inp in node.get("inputs", []):
|
|
657
|
+
link_id = inp.get("link")
|
|
658
|
+
if link_id is None:
|
|
659
|
+
continue
|
|
660
|
+
|
|
661
|
+
# Find which subgraph input this maps to
|
|
662
|
+
inp_name = inp.get("name")
|
|
663
|
+
for sg_inp in sg_inputs:
|
|
664
|
+
if sg_inp.get("name") == inp_name or sg_inp.get("label") == inp.get("label"):
|
|
665
|
+
# Find the internal link that connects to this input
|
|
666
|
+
for sg_link_id in sg_inp.get("linkIds", []):
|
|
667
|
+
# Find the link in sg_links
|
|
668
|
+
for sg_link in sg_links:
|
|
669
|
+
lf = get_link_fields(sg_link)
|
|
670
|
+
if lf[0] == sg_link_id:
|
|
671
|
+
# Redirect external link to the internal target
|
|
672
|
+
target_node = id_map.get(lf[3], lf[3])
|
|
673
|
+
target_slot = lf[4]
|
|
674
|
+
|
|
675
|
+
# Update the external link's target
|
|
676
|
+
for link in links:
|
|
677
|
+
if link[0] == link_id:
|
|
678
|
+
link[3] = target_node
|
|
679
|
+
link[4] = target_slot
|
|
680
|
+
break
|
|
681
|
+
break
|
|
682
|
+
break
|
|
683
|
+
|
|
684
|
+
# Group node outputs -> subgraph output nodes
|
|
685
|
+
sg_outputs = sg.get("outputs", [])
|
|
686
|
+
for out_idx, out in enumerate(node.get("outputs", [])):
|
|
687
|
+
out_links = out.get("links", [])
|
|
688
|
+
if not out_links:
|
|
689
|
+
continue
|
|
690
|
+
|
|
691
|
+
# Find which subgraph output this maps to
|
|
692
|
+
for sg_out in sg_outputs:
|
|
693
|
+
# Match by index or name
|
|
694
|
+
for sg_link_id in sg_out.get("linkIds", []):
|
|
695
|
+
# Find the internal link
|
|
696
|
+
for sg_link in sg_links:
|
|
697
|
+
lf = get_link_fields(sg_link)
|
|
698
|
+
if lf[0] == sg_link_id:
|
|
699
|
+
# This internal link's source is the real output
|
|
700
|
+
source_node = id_map.get(lf[1], lf[1])
|
|
701
|
+
source_slot = lf[2]
|
|
702
|
+
|
|
703
|
+
# Redirect all external links from this output
|
|
704
|
+
for ext_link_id in out_links:
|
|
705
|
+
for link in links:
|
|
706
|
+
if link[0] == ext_link_id:
|
|
707
|
+
link[1] = source_node
|
|
708
|
+
link[2] = source_slot
|
|
709
|
+
break
|
|
710
|
+
break
|
|
711
|
+
break
|
|
712
|
+
|
|
713
|
+
nodes_to_remove.add(node_id)
|
|
714
|
+
|
|
715
|
+
# Apply changes
|
|
716
|
+
graph["nodes"] = [n for n in nodes if n.get("id") not in nodes_to_remove] + new_nodes
|
|
717
|
+
graph["links"] = [l for l in links if l[0] not in links_to_remove] + new_links
|
|
718
|
+
graph["last_node_id"] = next_node_id
|
|
719
|
+
graph["last_link_id"] = next_link_id
|
|
720
|
+
|
|
721
|
+
if debug:
|
|
722
|
+
print(f"Expanded {len(nodes_to_remove)} group nodes, added {len(new_nodes)} nodes")
|
|
723
|
+
|
|
724
|
+
return graph
|
|
725
|
+
|
|
726
|
+
|
|
498
727
|
def graph_to_api(graph: dict, object_info: dict = None, debug: bool = False) -> dict:
|
|
499
728
|
"""
|
|
500
729
|
Convert ComfyUI graph format (from UI) to API format (for /prompt endpoint).
|
|
@@ -507,6 +736,9 @@ def graph_to_api(graph: dict, object_info: dict = None, debug: bool = False) ->
|
|
|
507
736
|
Returns:
|
|
508
737
|
Workflow in API format (node IDs as keys)
|
|
509
738
|
"""
|
|
739
|
+
# First, expand any subgraphs/group nodes
|
|
740
|
+
graph = expand_subgraphs(graph, debug=debug)
|
|
741
|
+
|
|
510
742
|
if object_info is None:
|
|
511
743
|
object_info = DEFAULT_OBJECT_INFO
|
|
512
744
|
api = {}
|
|
@@ -670,8 +902,8 @@ class ComfyUIJob(BaseJob):
|
|
|
670
902
|
|
|
671
903
|
COMFYUI_PORT = 8188
|
|
672
904
|
|
|
673
|
-
def __init__(self,
|
|
674
|
-
super().__init__(
|
|
905
|
+
def __init__(self, client: "HyperCLI", job, template: str = None, use_lb: bool = False, use_auth: bool = False):
|
|
906
|
+
super().__init__(client, job)
|
|
675
907
|
self._object_info: dict | None = None
|
|
676
908
|
self._auth_headers: dict | None = None
|
|
677
909
|
self._job_token: str | None = None
|
|
@@ -707,16 +939,16 @@ class ComfyUIJob(BaseJob):
|
|
|
707
939
|
if self.use_auth:
|
|
708
940
|
# Use job-specific token for LB auth
|
|
709
941
|
if not self._job_token:
|
|
710
|
-
self._job_token = self.
|
|
942
|
+
self._job_token = self.client.jobs.token(self.job_id)
|
|
711
943
|
return {"Authorization": f"Bearer {self._job_token}"}
|
|
712
944
|
else:
|
|
713
945
|
# Use API key for direct connections
|
|
714
|
-
return {"Authorization": f"Bearer {self.
|
|
946
|
+
return {"Authorization": f"Bearer {self.client._api_key}"}
|
|
715
947
|
|
|
716
948
|
@classmethod
|
|
717
949
|
def create_for_template(
|
|
718
950
|
cls,
|
|
719
|
-
|
|
951
|
+
client: "HyperCLI",
|
|
720
952
|
template: str,
|
|
721
953
|
gpu_type: str = None,
|
|
722
954
|
gpu_count: int = 1,
|
|
@@ -728,7 +960,7 @@ class ComfyUIJob(BaseJob):
|
|
|
728
960
|
"""Create a new ComfyUI job configured for a specific template.
|
|
729
961
|
|
|
730
962
|
Args:
|
|
731
|
-
|
|
963
|
+
client: HyperCLI client instance
|
|
732
964
|
template: Template name (passed as COMFYUI_TEMPLATES env var)
|
|
733
965
|
gpu_type: GPU type
|
|
734
966
|
gpu_count: Number of GPUs
|
|
@@ -747,7 +979,7 @@ class ComfyUIJob(BaseJob):
|
|
|
747
979
|
# Direct port exposure (HTTP)
|
|
748
980
|
ports[str(cls.COMFYUI_PORT)] = cls.COMFYUI_PORT
|
|
749
981
|
|
|
750
|
-
job =
|
|
982
|
+
job = client.jobs.create(
|
|
751
983
|
image=cls.DEFAULT_IMAGE,
|
|
752
984
|
gpu_type=gpu_type or cls.DEFAULT_GPU_TYPE,
|
|
753
985
|
gpu_count=gpu_count,
|
|
@@ -757,12 +989,12 @@ class ComfyUIJob(BaseJob):
|
|
|
757
989
|
auth=auth,
|
|
758
990
|
**kwargs,
|
|
759
991
|
)
|
|
760
|
-
return cls(
|
|
992
|
+
return cls(client, job, template=template, use_lb=bool(lb), use_auth=auth)
|
|
761
993
|
|
|
762
994
|
@classmethod
|
|
763
995
|
def get_instance(
|
|
764
996
|
cls,
|
|
765
|
-
|
|
997
|
+
client: "HyperCLI",
|
|
766
998
|
instance: str,
|
|
767
999
|
use_lb: bool = False,
|
|
768
1000
|
use_auth: bool = False,
|
|
@@ -770,17 +1002,17 @@ class ComfyUIJob(BaseJob):
|
|
|
770
1002
|
"""Connect to a specific ComfyUI instance by job ID or hostname.
|
|
771
1003
|
|
|
772
1004
|
Args:
|
|
773
|
-
|
|
1005
|
+
client: HyperCLI client instance
|
|
774
1006
|
instance: Job ID (UUID) or hostname
|
|
775
1007
|
use_lb: Whether instance uses HTTPS load balancer
|
|
776
1008
|
use_auth: Whether instance uses token auth
|
|
777
1009
|
"""
|
|
778
1010
|
# Check if it looks like a UUID (job ID)
|
|
779
1011
|
if "-" in instance and len(instance) > 30:
|
|
780
|
-
job =
|
|
1012
|
+
job = client.jobs.get(instance)
|
|
781
1013
|
else:
|
|
782
1014
|
# Assume hostname - search running jobs
|
|
783
|
-
jobs =
|
|
1015
|
+
jobs = client.jobs.list(state="running")
|
|
784
1016
|
job = None
|
|
785
1017
|
for j in jobs:
|
|
786
1018
|
if j.hostname and (j.hostname == instance or j.hostname.startswith(instance)):
|
|
@@ -789,12 +1021,12 @@ class ComfyUIJob(BaseJob):
|
|
|
789
1021
|
if not job:
|
|
790
1022
|
raise ValueError(f"No running job found with hostname: {instance}")
|
|
791
1023
|
|
|
792
|
-
return cls(
|
|
1024
|
+
return cls(client, job, use_lb=use_lb, use_auth=use_auth)
|
|
793
1025
|
|
|
794
1026
|
@classmethod
|
|
795
1027
|
def get_or_create_for_template(
|
|
796
1028
|
cls,
|
|
797
|
-
|
|
1029
|
+
client: "HyperCLI",
|
|
798
1030
|
template: str,
|
|
799
1031
|
gpu_type: str = None,
|
|
800
1032
|
gpu_count: int = 1,
|
|
@@ -810,7 +1042,7 @@ class ComfyUIJob(BaseJob):
|
|
|
810
1042
|
(note: the existing job may have different models loaded).
|
|
811
1043
|
"""
|
|
812
1044
|
if reuse:
|
|
813
|
-
existing = cls.get_running(
|
|
1045
|
+
existing = cls.get_running(client, image_filter=cls.DEFAULT_IMAGE)
|
|
814
1046
|
if existing:
|
|
815
1047
|
existing.template = template
|
|
816
1048
|
# TODO: detect lb/auth from existing job's config
|
|
@@ -819,7 +1051,7 @@ class ComfyUIJob(BaseJob):
|
|
|
819
1051
|
return existing
|
|
820
1052
|
|
|
821
1053
|
return cls.create_for_template(
|
|
822
|
-
|
|
1054
|
+
client,
|
|
823
1055
|
template=template,
|
|
824
1056
|
gpu_type=gpu_type,
|
|
825
1057
|
gpu_count=gpu_count,
|
|
@@ -9,7 +9,7 @@ import websockets
|
|
|
9
9
|
from .config import get_ws_url, WS_LOGS_PATH
|
|
10
10
|
|
|
11
11
|
if TYPE_CHECKING:
|
|
12
|
-
from .client import
|
|
12
|
+
from .client import HyperCLI
|
|
13
13
|
|
|
14
14
|
|
|
15
15
|
# Default limits to prevent memory blowup
|
|
@@ -17,11 +17,11 @@ DEFAULT_MAX_INITIAL_LINES = 1000 # Max lines to fetch on initial REST call
|
|
|
17
17
|
DEFAULT_MAX_BUFFER = 5000 # Max lines to keep in memory buffer
|
|
18
18
|
|
|
19
19
|
|
|
20
|
-
def fetch_logs(
|
|
20
|
+
def fetch_logs(client: "HyperCLI", job_id: str, tail: int = None) -> list[str]:
|
|
21
21
|
"""Fetch logs via REST API (one-time call).
|
|
22
22
|
|
|
23
23
|
Args:
|
|
24
|
-
|
|
24
|
+
client: HyperCLI client instance
|
|
25
25
|
job_id: Job ID
|
|
26
26
|
tail: Only return last N lines (default: all)
|
|
27
27
|
|
|
@@ -29,7 +29,7 @@ def fetch_logs(c3: "C3", job_id: str, tail: int = None) -> list[str]:
|
|
|
29
29
|
List of log lines
|
|
30
30
|
"""
|
|
31
31
|
try:
|
|
32
|
-
logs =
|
|
32
|
+
logs = client.jobs.logs(job_id)
|
|
33
33
|
if not logs:
|
|
34
34
|
return []
|
|
35
35
|
lines = logs.strip().split("\n")
|
|
@@ -44,7 +44,7 @@ class LogStream:
|
|
|
44
44
|
"""Async log streamer - websocket streaming with optional initial fetch.
|
|
45
45
|
|
|
46
46
|
Usage:
|
|
47
|
-
stream = LogStream(
|
|
47
|
+
stream = LogStream(client, job)
|
|
48
48
|
await stream.connect()
|
|
49
49
|
async for line in stream:
|
|
50
50
|
print(line)
|
|
@@ -59,7 +59,7 @@ class LogStream:
|
|
|
59
59
|
|
|
60
60
|
def __init__(
|
|
61
61
|
self,
|
|
62
|
-
|
|
62
|
+
client: "HyperCLI",
|
|
63
63
|
job_id: str,
|
|
64
64
|
job_key: str = None,
|
|
65
65
|
fetch_initial: bool = True,
|
|
@@ -68,14 +68,14 @@ class LogStream:
|
|
|
68
68
|
):
|
|
69
69
|
"""
|
|
70
70
|
Args:
|
|
71
|
-
|
|
71
|
+
client: HyperCLI client instance
|
|
72
72
|
job_id: Job ID for REST log fetch
|
|
73
73
|
job_key: Job key for websocket (if None, fetched from job)
|
|
74
74
|
fetch_initial: Whether to fetch existing logs on connect
|
|
75
75
|
max_initial_lines: Max lines to fetch initially (prevents huge fetch)
|
|
76
76
|
max_buffer: Max lines to keep in buffer (oldest dropped)
|
|
77
77
|
"""
|
|
78
|
-
self.
|
|
78
|
+
self.client = client
|
|
79
79
|
self.job_id = job_id
|
|
80
80
|
self.job_key = job_key
|
|
81
81
|
self.fetch_initial = fetch_initial
|
|
@@ -112,21 +112,31 @@ class LogStream:
|
|
|
112
112
|
|
|
113
113
|
# Fetch initial logs ONCE (bounded)
|
|
114
114
|
if self.fetch_initial and not self._initial_fetched:
|
|
115
|
-
initial_lines = fetch_logs(self.
|
|
115
|
+
initial_lines = fetch_logs(self.client, self.job_id, tail=self.max_initial_lines)
|
|
116
116
|
for line in initial_lines:
|
|
117
117
|
self._buffer.append(line)
|
|
118
118
|
self._initial_fetched = True
|
|
119
119
|
|
|
120
120
|
# Get job_key if not provided
|
|
121
121
|
if not self.job_key:
|
|
122
|
-
job = self.
|
|
122
|
+
job = self.client.jobs.get(self.job_id)
|
|
123
123
|
self.job_key = job.job_key
|
|
124
124
|
|
|
125
|
-
# Connect websocket
|
|
125
|
+
# Connect websocket with timeout
|
|
126
126
|
if self.job_key and not self._ws:
|
|
127
127
|
ws_url = get_ws_url()
|
|
128
128
|
full_url = f"{ws_url}{WS_LOGS_PATH}/{self.job_key}"
|
|
129
|
-
self._ws = await
|
|
129
|
+
self._ws = await asyncio.wait_for(
|
|
130
|
+
websockets.connect(
|
|
131
|
+
full_url,
|
|
132
|
+
ping_interval=30,
|
|
133
|
+
ping_timeout=20,
|
|
134
|
+
close_timeout=5,
|
|
135
|
+
max_size=2**20, # 1MB max message size
|
|
136
|
+
compression=None, # Disable compression for lower latency
|
|
137
|
+
),
|
|
138
|
+
timeout=30,
|
|
139
|
+
)
|
|
130
140
|
self._connected = True
|
|
131
141
|
|
|
132
142
|
return initial_lines
|
|
@@ -181,7 +191,7 @@ class LogStream:
|
|
|
181
191
|
|
|
182
192
|
|
|
183
193
|
async def stream_logs(
|
|
184
|
-
|
|
194
|
+
client: "HyperCLI",
|
|
185
195
|
job_id: str,
|
|
186
196
|
on_line: Callable[[str], None],
|
|
187
197
|
until_state: set[str] = None,
|
|
@@ -193,7 +203,7 @@ async def stream_logs(
|
|
|
193
203
|
"""Stream logs until job reaches a terminal state.
|
|
194
204
|
|
|
195
205
|
Args:
|
|
196
|
-
|
|
206
|
+
client: HyperCLI client instance
|
|
197
207
|
job_id: Job ID to stream logs from
|
|
198
208
|
on_line: Callback for each log line (called immediately, no buffering)
|
|
199
209
|
until_state: States to stop on (default: terminal states)
|
|
@@ -211,7 +221,7 @@ async def stream_logs(
|
|
|
211
221
|
if until_state is None:
|
|
212
222
|
until_state = {"succeeded", "failed", "canceled", "terminated"}
|
|
213
223
|
|
|
214
|
-
job =
|
|
224
|
+
job = client.jobs.get(job_id)
|
|
215
225
|
initial_fetched = False
|
|
216
226
|
ws = None
|
|
217
227
|
|
|
@@ -219,18 +229,18 @@ async def stream_logs(
|
|
|
219
229
|
# Wait for job to be assigned/running
|
|
220
230
|
while job.state in ("pending", "queued"):
|
|
221
231
|
await asyncio.sleep(poll_state_interval)
|
|
222
|
-
job =
|
|
232
|
+
job = client.jobs.get(job_id)
|
|
223
233
|
|
|
224
234
|
# Check for immediate terminal state
|
|
225
235
|
if job.state in until_state:
|
|
226
236
|
if fetch_final:
|
|
227
|
-
for line in fetch_logs(
|
|
237
|
+
for line in fetch_logs(client, job_id, tail=max_initial_lines):
|
|
228
238
|
on_line(line)
|
|
229
239
|
return
|
|
230
240
|
|
|
231
241
|
# Fetch initial logs ONCE when running (bounded)
|
|
232
242
|
if fetch_initial and job.state == "running" and not initial_fetched:
|
|
233
|
-
for line in fetch_logs(
|
|
243
|
+
for line in fetch_logs(client, job_id, tail=max_initial_lines):
|
|
234
244
|
on_line(line)
|
|
235
245
|
initial_fetched = True
|
|
236
246
|
|
|
@@ -255,7 +265,7 @@ async def stream_logs(
|
|
|
255
265
|
continue
|
|
256
266
|
except asyncio.TimeoutError:
|
|
257
267
|
# Check job state (NOT polling logs!)
|
|
258
|
-
job =
|
|
268
|
+
job = client.jobs.get(job_id)
|
|
259
269
|
if job.state in until_state:
|
|
260
270
|
break
|
|
261
271
|
except websockets.ConnectionClosed:
|
|
@@ -265,7 +275,7 @@ async def stream_logs(
|
|
|
265
275
|
if fetch_final:
|
|
266
276
|
# Small delay to let final logs flush
|
|
267
277
|
await asyncio.sleep(0.5)
|
|
268
|
-
for line in fetch_logs(
|
|
278
|
+
for line in fetch_logs(client, job_id, tail=max_initial_lines):
|
|
269
279
|
on_line(line)
|
|
270
280
|
|
|
271
281
|
finally:
|
|
@@ -145,7 +145,7 @@ class Renders:
|
|
|
145
145
|
notify_url: Optional webhook URL for completion notification
|
|
146
146
|
|
|
147
147
|
Example:
|
|
148
|
-
render =
|
|
148
|
+
render = client.renders.text_to_image("a cat wearing sunglasses")
|
|
149
149
|
"""
|
|
150
150
|
return self._flow("/api/flow/text-to-image", prompt=prompt, negative=negative, width=width, height=height, notify_url=notify_url)
|
|
151
151
|
|
|
@@ -167,7 +167,7 @@ class Renders:
|
|
|
167
167
|
notify_url: Optional webhook URL for completion notification
|
|
168
168
|
|
|
169
169
|
Example:
|
|
170
|
-
render =
|
|
170
|
+
render = client.renders.text_to_image_hidream("a mystical forest")
|
|
171
171
|
"""
|
|
172
172
|
return self._flow("/api/flow/text-to-image-hidream", prompt=prompt, negative=negative, width=width, height=height, notify_url=notify_url)
|
|
173
173
|
|
|
@@ -189,7 +189,7 @@ class Renders:
|
|
|
189
189
|
notify_url: Optional webhook URL for completion notification
|
|
190
190
|
|
|
191
191
|
Example:
|
|
192
|
-
render =
|
|
192
|
+
render = client.renders.text_to_video("a cat walking through a garden")
|
|
193
193
|
"""
|
|
194
194
|
return self._flow("/api/flow/text-to-video", prompt=prompt, negative=negative, width=width, height=height, notify_url=notify_url)
|
|
195
195
|
|
|
@@ -213,7 +213,7 @@ class Renders:
|
|
|
213
213
|
notify_url: Optional webhook URL for completion notification
|
|
214
214
|
|
|
215
215
|
Example:
|
|
216
|
-
render =
|
|
216
|
+
render = client.renders.image_to_video("dancing", "https://example.com/img.png", width=832, height=480)
|
|
217
217
|
"""
|
|
218
218
|
return self._flow("/api/flow/image-to-video", prompt=prompt, image_url=image_url, negative=negative, width=width, height=height, notify_url=notify_url)
|
|
219
219
|
|
|
@@ -239,7 +239,7 @@ class Renders:
|
|
|
239
239
|
notify_url: Optional webhook URL for completion notification
|
|
240
240
|
|
|
241
241
|
Example:
|
|
242
|
-
render =
|
|
242
|
+
render = client.renders.speaking_video(
|
|
243
243
|
"A person talking to camera",
|
|
244
244
|
"https://example.com/face.png",
|
|
245
245
|
"https://example.com/speech.mp3"
|
|
@@ -269,7 +269,7 @@ class Renders:
|
|
|
269
269
|
notify_url: Optional webhook URL for completion notification
|
|
270
270
|
|
|
271
271
|
Example:
|
|
272
|
-
render =
|
|
272
|
+
render = client.renders.speaking_video_wan(
|
|
273
273
|
"The person is singing",
|
|
274
274
|
"https://example.com/face.png",
|
|
275
275
|
"https://example.com/song.mp3"
|
|
@@ -297,7 +297,7 @@ class Renders:
|
|
|
297
297
|
notify_url: Optional webhook URL for completion notification
|
|
298
298
|
|
|
299
299
|
Example:
|
|
300
|
-
render =
|
|
300
|
+
render = client.renders.image_to_image(
|
|
301
301
|
"Apply the artistic style from the references",
|
|
302
302
|
[
|
|
303
303
|
"https://example.com/subject.jpg",
|
|
@@ -330,7 +330,7 @@ class Renders:
|
|
|
330
330
|
notify_url: Optional webhook URL for completion notification
|
|
331
331
|
|
|
332
332
|
Example:
|
|
333
|
-
render =
|
|
333
|
+
render = client.renders.first_last_frame_video(
|
|
334
334
|
"smooth transition from day to night",
|
|
335
335
|
"https://example.com/day.png",
|
|
336
336
|
"https://example.com/night.png"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|