pipelabs-bridge 0.1.0__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.
- pipelabs_bridge-0.1.0/PKG-INFO +39 -0
- pipelabs_bridge-0.1.0/README.md +30 -0
- pipelabs_bridge-0.1.0/pipelabs_bridge/__init__.py +3 -0
- pipelabs_bridge-0.1.0/pipelabs_bridge/cli.py +31 -0
- pipelabs_bridge-0.1.0/pipelabs_bridge/client.py +109 -0
- pipelabs_bridge-0.1.0/pipelabs_bridge/comfy.py +67 -0
- pipelabs_bridge-0.1.0/pipelabs_bridge/health.py +77 -0
- pipelabs_bridge-0.1.0/pipelabs_bridge/pacer.py +37 -0
- pipelabs_bridge-0.1.0/pipelabs_bridge/runner.py +398 -0
- pipelabs_bridge-0.1.0/pipelabs_bridge.egg-info/PKG-INFO +39 -0
- pipelabs_bridge-0.1.0/pipelabs_bridge.egg-info/SOURCES.txt +16 -0
- pipelabs_bridge-0.1.0/pipelabs_bridge.egg-info/dependency_links.txt +1 -0
- pipelabs_bridge-0.1.0/pipelabs_bridge.egg-info/entry_points.txt +2 -0
- pipelabs_bridge-0.1.0/pipelabs_bridge.egg-info/requires.txt +2 -0
- pipelabs_bridge-0.1.0/pipelabs_bridge.egg-info/top_level.txt +1 -0
- pipelabs_bridge-0.1.0/pyproject.toml +17 -0
- pipelabs_bridge-0.1.0/setup.cfg +4 -0
- pipelabs_bridge-0.1.0/tests/test_pacer.py +56 -0
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pipelabs-bridge
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Pipelabs bridge — connects your local ComfyUI server to Pipelabs.
|
|
5
|
+
Requires-Python: >=3.9
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: requests>=2.31
|
|
8
|
+
Requires-Dist: pillow>=10.0
|
|
9
|
+
|
|
10
|
+
# pipelabs-bridge
|
|
11
|
+
|
|
12
|
+
The Pipelabs bridge runs on your ComfyUI server. It connects **outbound** to
|
|
13
|
+
Pipelabs (no inbound ports needed, so it works behind Vast.ai's NAT), polls for
|
|
14
|
+
jobs, talks to your local ComfyUI API, and reports status back.
|
|
15
|
+
|
|
16
|
+
## Install & run
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
pip install pipelabs-bridge
|
|
20
|
+
pipelabs-bridge --token <YOUR_TOKEN> --app-url https://your-pipelabs-app
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Options (all can also be set via environment variables):
|
|
24
|
+
|
|
25
|
+
| Flag | Env var | Default |
|
|
26
|
+
| ------------- | ------------------- | ----------------------- |
|
|
27
|
+
| `--token` | `PIPELABS_TOKEN` | (required) |
|
|
28
|
+
| `--app-url` | `PIPELABS_APP_URL` | `http://localhost:3000` |
|
|
29
|
+
| `--comfy-url` | `COMFY_URL` | `http://127.0.0.1:8188` |
|
|
30
|
+
| `--interval` | — | `3.0` (seconds) |
|
|
31
|
+
| `--max-interval` | — | `20.0` (seconds, capped at 25) |
|
|
32
|
+
|
|
33
|
+
Keep it running in `tmux` or `screen` so your server stays "online" in the app.
|
|
34
|
+
|
|
35
|
+
The bridge polls every `--interval` seconds, then slows to `--max-interval` after
|
|
36
|
+
2 minutes with no jobs, so a new job can take up to that long to start when idle.
|
|
37
|
+
|
|
38
|
+
Handles every Pipelabs job: health checks, images, reference boards, clips and
|
|
39
|
+
stitching.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# pipelabs-bridge
|
|
2
|
+
|
|
3
|
+
The Pipelabs bridge runs on your ComfyUI server. It connects **outbound** to
|
|
4
|
+
Pipelabs (no inbound ports needed, so it works behind Vast.ai's NAT), polls for
|
|
5
|
+
jobs, talks to your local ComfyUI API, and reports status back.
|
|
6
|
+
|
|
7
|
+
## Install & run
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install pipelabs-bridge
|
|
11
|
+
pipelabs-bridge --token <YOUR_TOKEN> --app-url https://your-pipelabs-app
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Options (all can also be set via environment variables):
|
|
15
|
+
|
|
16
|
+
| Flag | Env var | Default |
|
|
17
|
+
| ------------- | ------------------- | ----------------------- |
|
|
18
|
+
| `--token` | `PIPELABS_TOKEN` | (required) |
|
|
19
|
+
| `--app-url` | `PIPELABS_APP_URL` | `http://localhost:3000` |
|
|
20
|
+
| `--comfy-url` | `COMFY_URL` | `http://127.0.0.1:8188` |
|
|
21
|
+
| `--interval` | — | `3.0` (seconds) |
|
|
22
|
+
| `--max-interval` | — | `20.0` (seconds, capped at 25) |
|
|
23
|
+
|
|
24
|
+
Keep it running in `tmux` or `screen` so your server stays "online" in the app.
|
|
25
|
+
|
|
26
|
+
The bridge polls every `--interval` seconds, then slows to `--max-interval` after
|
|
27
|
+
2 minutes with no jobs, so a new job can take up to that long to start when idle.
|
|
28
|
+
|
|
29
|
+
Handles every Pipelabs job: health checks, images, reference boards, clips and
|
|
30
|
+
stitching.
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import os
|
|
3
|
+
|
|
4
|
+
from .client import BridgeClient
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def main() -> None:
|
|
8
|
+
p = argparse.ArgumentParser(
|
|
9
|
+
prog="pipelabs-bridge",
|
|
10
|
+
description="Connect your local ComfyUI server to Pipelabs.",
|
|
11
|
+
)
|
|
12
|
+
p.add_argument("--token", default=os.environ.get("PIPELABS_TOKEN"),
|
|
13
|
+
help="Your connection token from the Pipelabs Connect page.")
|
|
14
|
+
p.add_argument("--app-url", default=os.environ.get("PIPELABS_APP_URL", "http://localhost:3000"),
|
|
15
|
+
help="Base URL of the Pipelabs app.")
|
|
16
|
+
p.add_argument("--comfy-url", default=os.environ.get("COMFY_URL", "http://127.0.0.1:8188"),
|
|
17
|
+
help="Base URL of your local ComfyUI API.")
|
|
18
|
+
p.add_argument("--interval", type=float, default=3.0,
|
|
19
|
+
help="Seconds between polls while active (default: 3).")
|
|
20
|
+
p.add_argument("--max-interval", type=float, default=20.0,
|
|
21
|
+
help="Seconds between polls once idle for 2 minutes (default: 20, capped at 25).")
|
|
22
|
+
args = p.parse_args()
|
|
23
|
+
|
|
24
|
+
if not args.token:
|
|
25
|
+
p.error("--token is required (or set PIPELABS_TOKEN).")
|
|
26
|
+
|
|
27
|
+
BridgeClient(args.token, args.app_url, args.comfy_url, args.interval, args.max_interval).run()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
if __name__ == "__main__":
|
|
31
|
+
main()
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import time
|
|
2
|
+
import traceback
|
|
3
|
+
|
|
4
|
+
import requests
|
|
5
|
+
|
|
6
|
+
from . import health, runner
|
|
7
|
+
from .comfy import ComfyClient
|
|
8
|
+
from .pacer import PollPacer
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class BridgeClient:
|
|
12
|
+
def __init__(self, token: str, app_url: str, comfy_url: str, interval: float = 3.0,
|
|
13
|
+
max_interval: float = 20.0):
|
|
14
|
+
self.app_url = app_url.rstrip("/")
|
|
15
|
+
self.comfy = ComfyClient(comfy_url)
|
|
16
|
+
self.pacer = PollPacer(base=interval, max_delay=max_interval)
|
|
17
|
+
self.s = requests.Session()
|
|
18
|
+
self.s.headers["Authorization"] = f"Bearer {token}"
|
|
19
|
+
|
|
20
|
+
def _url(self, path: str) -> str:
|
|
21
|
+
return f"{self.app_url}{path}"
|
|
22
|
+
|
|
23
|
+
def hello(self) -> None:
|
|
24
|
+
try:
|
|
25
|
+
self.s.post(self._url("/api/bridge/hello"),
|
|
26
|
+
json={"comfy": self.comfy.ping()}, timeout=15)
|
|
27
|
+
print(f"[pipelabs] connected to {self.app_url}")
|
|
28
|
+
except Exception as e: # noqa: BLE001
|
|
29
|
+
print(f"[pipelabs] hello failed: {e}")
|
|
30
|
+
|
|
31
|
+
def run(self) -> None:
|
|
32
|
+
print(f"[pipelabs] starting bridge; ComfyUI at {self.comfy.base}")
|
|
33
|
+
self.hello()
|
|
34
|
+
while True:
|
|
35
|
+
try:
|
|
36
|
+
r = self.s.get(self._url("/api/bridge/jobs"), timeout=30)
|
|
37
|
+
job = r.json().get("job") if r.ok else None
|
|
38
|
+
if job:
|
|
39
|
+
self.handle(job)
|
|
40
|
+
self.pacer.job_seen()
|
|
41
|
+
# (A successful GET /jobs already counts as a heartbeat server-side.)
|
|
42
|
+
except Exception as e: # noqa: BLE001
|
|
43
|
+
print(f"[pipelabs] poll error: {e}")
|
|
44
|
+
time.sleep(self.pacer.next_delay())
|
|
45
|
+
|
|
46
|
+
def handle(self, job: dict) -> None:
|
|
47
|
+
jid, jtype = job["id"], job["type"]
|
|
48
|
+
payload = job.get("payload") or {}
|
|
49
|
+
print(f"[pipelabs] job {jid} ({jtype})")
|
|
50
|
+
|
|
51
|
+
def progress(pct, logs=""):
|
|
52
|
+
self.progress(jid, pct, logs)
|
|
53
|
+
|
|
54
|
+
try:
|
|
55
|
+
if jtype == "HEALTH_CHECK":
|
|
56
|
+
self.result(jid, True, health.collect(self.comfy, payload))
|
|
57
|
+
return
|
|
58
|
+
|
|
59
|
+
# Generation jobs run whatever patched workflow the app sends.
|
|
60
|
+
workflow = payload.get("workflow")
|
|
61
|
+
if jtype == "GENERATE_IMAGE":
|
|
62
|
+
if not workflow:
|
|
63
|
+
self.result(jid, False, {
|
|
64
|
+
"error": "No workflow attached to this job."})
|
|
65
|
+
elif payload.get("extractStills"):
|
|
66
|
+
# Base image via text-to-video: run, then pull still frames.
|
|
67
|
+
self.result(jid, True, runner.run_image_via_video(
|
|
68
|
+
self.comfy, workflow, int(payload["extractStills"]), progress))
|
|
69
|
+
else:
|
|
70
|
+
self.result(jid, True, runner.run_image_workflow(self.comfy, workflow, progress))
|
|
71
|
+
elif jtype == "GENERATE_CLIP":
|
|
72
|
+
if not workflow:
|
|
73
|
+
self.result(jid, False, {"error": "No workflow attached to this job."})
|
|
74
|
+
else:
|
|
75
|
+
pics = payload.get("pictures") or []
|
|
76
|
+
if pics:
|
|
77
|
+
node = payload.get("mediaNodeId", "416")
|
|
78
|
+
workflow[node]["inputs"]["media_state"] = runner.build_media_state(self.comfy, pics)
|
|
79
|
+
res = runner.run_clip(self.comfy, workflow, progress)
|
|
80
|
+
self.result(jid, res.get("ok", False), res)
|
|
81
|
+
elif jtype == "REFERENCE_BOARD":
|
|
82
|
+
if not workflow:
|
|
83
|
+
self.result(jid, False, {"error": "No workflow attached to this job."})
|
|
84
|
+
else:
|
|
85
|
+
res = runner.run_reference_board(self.comfy, workflow, payload, progress)
|
|
86
|
+
self.result(jid, res.get("ok", False), res)
|
|
87
|
+
elif jtype == "STITCH":
|
|
88
|
+
res = runner.run_stitch(self.comfy, payload, progress)
|
|
89
|
+
self.result(jid, res.get("ok", False), res)
|
|
90
|
+
else:
|
|
91
|
+
self.result(jid, False,
|
|
92
|
+
{"error": f"{jtype} is not supported by this bridge version yet."})
|
|
93
|
+
except Exception as e: # noqa: BLE001
|
|
94
|
+
traceback.print_exc()
|
|
95
|
+
self.result(jid, False, {"error": str(e)})
|
|
96
|
+
|
|
97
|
+
def progress(self, jid: str, pct: int, logs: str = "") -> None:
|
|
98
|
+
try:
|
|
99
|
+
self.s.post(self._url(f"/api/bridge/jobs/{jid}/progress"),
|
|
100
|
+
json={"progress": pct, "logs": logs}, timeout=15)
|
|
101
|
+
except Exception as e: # noqa: BLE001
|
|
102
|
+
print(f"[pipelabs] progress error: {e}")
|
|
103
|
+
|
|
104
|
+
def result(self, jid: str, ok: bool, data: dict) -> None:
|
|
105
|
+
try:
|
|
106
|
+
self.s.post(self._url(f"/api/bridge/jobs/{jid}/result"),
|
|
107
|
+
json={"ok": ok, "result": data}, timeout=30)
|
|
108
|
+
except Exception as e: # noqa: BLE001
|
|
109
|
+
print(f"[pipelabs] result error: {e}")
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import uuid
|
|
3
|
+
|
|
4
|
+
import requests
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class ComfyClient:
|
|
8
|
+
"""Thin wrapper around the local ComfyUI HTTP API."""
|
|
9
|
+
|
|
10
|
+
def __init__(self, base: str):
|
|
11
|
+
self.base = base.rstrip("/")
|
|
12
|
+
self.client_id = str(uuid.uuid4())
|
|
13
|
+
|
|
14
|
+
# --- Status / introspection ------------------------------------------
|
|
15
|
+
def ping(self) -> dict:
|
|
16
|
+
"""Check that ComfyUI is reachable; include system stats when it is."""
|
|
17
|
+
try:
|
|
18
|
+
r = requests.get(f"{self.base}/system_stats", timeout=10)
|
|
19
|
+
if r.ok:
|
|
20
|
+
return {"reachable": True, "system": r.json()}
|
|
21
|
+
return {"reachable": False, "error": f"HTTP {r.status_code}"}
|
|
22
|
+
except Exception as e: # noqa: BLE001
|
|
23
|
+
return {"reachable": False, "error": str(e)}
|
|
24
|
+
|
|
25
|
+
def object_info(self) -> dict:
|
|
26
|
+
"""Map of available node class_type -> definition."""
|
|
27
|
+
r = requests.get(f"{self.base}/object_info", timeout=30)
|
|
28
|
+
r.raise_for_status()
|
|
29
|
+
return r.json()
|
|
30
|
+
|
|
31
|
+
# --- Execution -------------------------------------------------------
|
|
32
|
+
def upload_image(self, filepath: str, subfolder: str = "minimax_h3", overwrite: bool = True) -> dict:
|
|
33
|
+
"""Upload a local file into ComfyUI's input dir. Returns {name, subfolder, type}."""
|
|
34
|
+
with open(filepath, "rb") as f:
|
|
35
|
+
files = {"image": (os.path.basename(filepath), f, "application/octet-stream")}
|
|
36
|
+
data = {"subfolder": subfolder, "type": "input", "overwrite": "true" if overwrite else "false"}
|
|
37
|
+
r = requests.post(f"{self.base}/upload/image", files=files, data=data, timeout=120)
|
|
38
|
+
r.raise_for_status()
|
|
39
|
+
return r.json()
|
|
40
|
+
|
|
41
|
+
def queue_prompt(self, workflow: dict) -> str:
|
|
42
|
+
"""Queue an API-format workflow. Returns the prompt_id."""
|
|
43
|
+
payload = {"prompt": workflow, "client_id": self.client_id}
|
|
44
|
+
r = requests.post(f"{self.base}/prompt", json=payload, timeout=60)
|
|
45
|
+
if not r.ok:
|
|
46
|
+
# ComfyUI returns a helpful validation error body on 400.
|
|
47
|
+
raise RuntimeError(f"ComfyUI rejected the workflow ({r.status_code}): {r.text[:500]}")
|
|
48
|
+
return r.json()["prompt_id"]
|
|
49
|
+
|
|
50
|
+
def history(self, prompt_id: str) -> dict:
|
|
51
|
+
r = requests.get(f"{self.base}/history/{prompt_id}", timeout=30)
|
|
52
|
+
r.raise_for_status()
|
|
53
|
+
return r.json()
|
|
54
|
+
|
|
55
|
+
def queue_state(self) -> dict:
|
|
56
|
+
try:
|
|
57
|
+
r = requests.get(f"{self.base}/queue", timeout=15)
|
|
58
|
+
return r.json() if r.ok else {}
|
|
59
|
+
except Exception: # noqa: BLE001
|
|
60
|
+
return {}
|
|
61
|
+
|
|
62
|
+
def view(self, filename: str, subfolder: str = "", folder_type: str = "output") -> bytes:
|
|
63
|
+
"""Fetch an output/temp/input file's bytes."""
|
|
64
|
+
params = {"filename": filename, "subfolder": subfolder, "type": folder_type}
|
|
65
|
+
r = requests.get(f"{self.base}/view", params=params, timeout=120)
|
|
66
|
+
r.raise_for_status()
|
|
67
|
+
return r.content
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import shutil
|
|
3
|
+
import subprocess
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def _gpu() -> dict:
|
|
7
|
+
try:
|
|
8
|
+
out = subprocess.check_output(
|
|
9
|
+
["nvidia-smi", "--query-gpu=name,memory.total,memory.free",
|
|
10
|
+
"--format=csv,noheader,nounits"],
|
|
11
|
+
timeout=10,
|
|
12
|
+
).decode().strip()
|
|
13
|
+
name, total, free = [x.strip() for x in out.splitlines()[0].split(",")]
|
|
14
|
+
return {"name": name, "totalMB": int(total), "freeMB": int(free)}
|
|
15
|
+
except Exception as e: # noqa: BLE001
|
|
16
|
+
return {"error": str(e)}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _disk(path: str = "/") -> dict:
|
|
20
|
+
try:
|
|
21
|
+
u = shutil.disk_usage(path)
|
|
22
|
+
return {"path": path, "freeGB": round(u.free / 1e9, 1), "totalGB": round(u.total / 1e9, 1)}
|
|
23
|
+
except Exception as e: # noqa: BLE001
|
|
24
|
+
return {"error": str(e)}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _basename(p: str) -> str:
|
|
28
|
+
return os.path.basename(str(p).replace("\\", "/"))
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _available_files(info: dict) -> set:
|
|
32
|
+
"""All filenames ComfyUI offers in loader dropdowns (from /object_info).
|
|
33
|
+
|
|
34
|
+
Each input spec looks like [<combo_list>, {...}] where <combo_list> is a
|
|
35
|
+
list of selectable strings (model filenames for loaders).
|
|
36
|
+
"""
|
|
37
|
+
names = set()
|
|
38
|
+
for node_def in info.values():
|
|
39
|
+
if not isinstance(node_def, dict):
|
|
40
|
+
continue
|
|
41
|
+
inp = node_def.get("input") or {}
|
|
42
|
+
for group in ("required", "optional"):
|
|
43
|
+
for spec in (inp.get(group) or {}).values():
|
|
44
|
+
if isinstance(spec, list) and spec and isinstance(spec[0], list):
|
|
45
|
+
for opt in spec[0]:
|
|
46
|
+
if isinstance(opt, str):
|
|
47
|
+
names.add(opt)
|
|
48
|
+
return names
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def collect(comfy, payload: dict) -> dict:
|
|
52
|
+
"""Run the health check and return a result the app knows how to display."""
|
|
53
|
+
ping = comfy.ping()
|
|
54
|
+
result = {"comfy": ping, "gpu": _gpu(), "disk": _disk()}
|
|
55
|
+
|
|
56
|
+
required_nodes = payload.get("requiredNodes") or []
|
|
57
|
+
required_models = payload.get("requiredModels") or []
|
|
58
|
+
|
|
59
|
+
nodes, models = [], []
|
|
60
|
+
if (required_nodes or required_models) and ping.get("reachable"):
|
|
61
|
+
try:
|
|
62
|
+
info = comfy.object_info()
|
|
63
|
+
# Custom node presence
|
|
64
|
+
nodes = [{"name": n, "present": n in info} for n in required_nodes]
|
|
65
|
+
# Model presence, matched by basename against loader dropdowns
|
|
66
|
+
avail = {_basename(x) for x in _available_files(info)}
|
|
67
|
+
for m in required_models:
|
|
68
|
+
base = _basename(m.get("path") or m.get("name") or "")
|
|
69
|
+
models.append({"name": m.get("name"), "present": base in avail})
|
|
70
|
+
except Exception as e: # noqa: BLE001
|
|
71
|
+
err = str(e)
|
|
72
|
+
nodes = [{"name": n, "present": False, "error": err} for n in required_nodes]
|
|
73
|
+
models = [{"name": m.get("name"), "present": False, "error": err} for m in required_models]
|
|
74
|
+
|
|
75
|
+
result["nodes"] = nodes
|
|
76
|
+
result["models"] = models
|
|
77
|
+
return result
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Decides how long the bridge waits between polls for jobs.
|
|
2
|
+
|
|
3
|
+
Polling costs the app's operator a request and database writes every time, so the
|
|
4
|
+
bridge slows down when nothing is happening. It stays fast for a while after a job
|
|
5
|
+
because the user is probably still working and about to queue the next one.
|
|
6
|
+
"""
|
|
7
|
+
import time
|
|
8
|
+
|
|
9
|
+
# The app shows a server as offline after 35 seconds without contact. Stay well
|
|
10
|
+
# under that so one slow or failed poll doesn't make it flicker.
|
|
11
|
+
MAX_SAFE_DELAY = 25.0
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class PollPacer:
|
|
15
|
+
def __init__(self, base: float = 3.0, max_delay: float = 20.0,
|
|
16
|
+
hold_seconds: float = 120.0, growth: float = 1.5, now=time.monotonic):
|
|
17
|
+
self.base = base
|
|
18
|
+
# Never below the requested base, and capped to keep the server "online"
|
|
19
|
+
# (unless the user explicitly asked for a base slower than the cap).
|
|
20
|
+
self.max_delay = max(base, min(max_delay, MAX_SAFE_DELAY))
|
|
21
|
+
self.hold_seconds = hold_seconds
|
|
22
|
+
self.growth = growth
|
|
23
|
+
self._now = now
|
|
24
|
+
self._delay = base
|
|
25
|
+
self._active_at = now()
|
|
26
|
+
|
|
27
|
+
def job_seen(self) -> None:
|
|
28
|
+
"""Call when a job finishes: go back to fast polling."""
|
|
29
|
+
self._active_at = self._now()
|
|
30
|
+
self._delay = self.base
|
|
31
|
+
|
|
32
|
+
def next_delay(self) -> float:
|
|
33
|
+
"""Seconds to wait before the next poll."""
|
|
34
|
+
if self._now() - self._active_at < self.hold_seconds:
|
|
35
|
+
return self.base
|
|
36
|
+
self._delay = min(self.max_delay, self._delay * self.growth)
|
|
37
|
+
return self._delay
|
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
"""Generic ComfyUI workflow execution: queue an API-format workflow, wait for
|
|
2
|
+
it, collect image/video outputs, and (for characters) build reference boards.
|
|
3
|
+
|
|
4
|
+
Workflow-agnostic — the app patches a workflow and sends it in the job payload;
|
|
5
|
+
this just runs whatever it's given. Frame extraction and the board montage need
|
|
6
|
+
ffmpeg + Pillow on the server.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import base64
|
|
10
|
+
import copy
|
|
11
|
+
import datetime
|
|
12
|
+
import glob
|
|
13
|
+
import io
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import subprocess
|
|
17
|
+
import tempfile
|
|
18
|
+
import time
|
|
19
|
+
|
|
20
|
+
try:
|
|
21
|
+
from PIL import Image, ImageDraw
|
|
22
|
+
_HAS_PIL = True
|
|
23
|
+
except Exception: # noqa: BLE001
|
|
24
|
+
_HAS_PIL = False
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _thumbnail(data: bytes, size: int = 256) -> str | None:
|
|
28
|
+
if not _HAS_PIL:
|
|
29
|
+
return None
|
|
30
|
+
try:
|
|
31
|
+
im = Image.open(io.BytesIO(data)).convert("RGB")
|
|
32
|
+
im.thumbnail((size, size))
|
|
33
|
+
buf = io.BytesIO()
|
|
34
|
+
im.save(buf, format="JPEG", quality=70)
|
|
35
|
+
return "data:image/jpeg;base64," + base64.b64encode(buf.getvalue()).decode()
|
|
36
|
+
except Exception: # noqa: BLE001
|
|
37
|
+
return None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
PROGRESS_REPORT_SECONDS = 15
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _wait(comfy, prompt_id: str, on_progress=None, timeout: int = 1800) -> dict:
|
|
44
|
+
start = time.time()
|
|
45
|
+
last_report = 0.0
|
|
46
|
+
while True:
|
|
47
|
+
hist = comfy.history(prompt_id)
|
|
48
|
+
if prompt_id in hist:
|
|
49
|
+
return hist[prompt_id]
|
|
50
|
+
# ComfyUI is local, so checking it often is free. Reports to the app are not,
|
|
51
|
+
# but they double as the heartbeat, so keep them under its 35 s online window.
|
|
52
|
+
if on_progress and time.time() - last_report >= PROGRESS_REPORT_SECONDS:
|
|
53
|
+
last_report = time.time()
|
|
54
|
+
q = comfy.queue_state()
|
|
55
|
+
running = len(q.get("queue_running", []))
|
|
56
|
+
pending = len(q.get("queue_pending", []))
|
|
57
|
+
on_progress(5, f"queued (running={running}, pending={pending})")
|
|
58
|
+
if time.time() - start > timeout:
|
|
59
|
+
raise TimeoutError("Workflow timed out")
|
|
60
|
+
time.sleep(2)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _collect(comfy, outputs: dict):
|
|
64
|
+
images, videos, thumbs = [], [], {}
|
|
65
|
+
for out in outputs.values():
|
|
66
|
+
for img in out.get("images", []) or []:
|
|
67
|
+
if img.get("type") == "temp":
|
|
68
|
+
continue
|
|
69
|
+
ref = f"{img.get('subfolder', '')}/{img['filename']}".lstrip("/")
|
|
70
|
+
images.append(ref)
|
|
71
|
+
try:
|
|
72
|
+
thumbs[ref] = _thumbnail(comfy.view(img["filename"], img.get("subfolder", ""), img.get("type", "output")))
|
|
73
|
+
except Exception: # noqa: BLE001
|
|
74
|
+
pass
|
|
75
|
+
for vid in out.get("gifs", []) or []: # VHS_VideoCombine mp4/webm
|
|
76
|
+
ref = f"{vid.get('subfolder', '')}/{vid['filename']}".lstrip("/")
|
|
77
|
+
videos.append(ref)
|
|
78
|
+
return images, videos, thumbs
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _extract_frames(comfy, video_ref: str, outdir: str) -> list:
|
|
82
|
+
"""Download an output video and ffmpeg-extract its frames into outdir."""
|
|
83
|
+
subfolder, _, filename = video_ref.rpartition("/")
|
|
84
|
+
data = comfy.view(filename, subfolder, "output")
|
|
85
|
+
mp4 = os.path.join(outdir, "clip.mp4")
|
|
86
|
+
with open(mp4, "wb") as f:
|
|
87
|
+
f.write(data)
|
|
88
|
+
subprocess.run(
|
|
89
|
+
["ffmpeg", "-y", "-i", mp4, os.path.join(outdir, "f_%05d.png")],
|
|
90
|
+
check=True, capture_output=True, timeout=180,
|
|
91
|
+
)
|
|
92
|
+
return sorted(glob.glob(os.path.join(outdir, "f_*.png")))
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def run_image_workflow(comfy, workflow: dict, on_progress=None) -> dict:
|
|
96
|
+
prompt_id = comfy.queue_prompt(workflow)
|
|
97
|
+
if on_progress:
|
|
98
|
+
on_progress(10, "queued")
|
|
99
|
+
data = _wait(comfy, prompt_id, on_progress)
|
|
100
|
+
images, _videos, thumbs = _collect(comfy, data.get("outputs", {}))
|
|
101
|
+
return {"candidates": images, "thumbnails": thumbs, "prompt_id": prompt_id}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def run_video_workflow(comfy, workflow: dict, on_progress=None) -> dict:
|
|
105
|
+
prompt_id = comfy.queue_prompt(workflow)
|
|
106
|
+
if on_progress:
|
|
107
|
+
on_progress(10, "queued")
|
|
108
|
+
data = _wait(comfy, prompt_id, on_progress)
|
|
109
|
+
images, videos, thumbs = _collect(comfy, data.get("outputs", {}))
|
|
110
|
+
return {"videos": videos, "stills": images, "thumbnails": thumbs, "prompt_id": prompt_id}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def run_image_via_video(comfy, workflow: dict, extract: int = 3, on_progress=None) -> dict:
|
|
114
|
+
"""Run a text-to-video workflow, then pull `extract` still frames as base-image
|
|
115
|
+
candidates (uploaded back into ComfyUI's input dir for reuse)."""
|
|
116
|
+
res = run_video_workflow(comfy, workflow, on_progress)
|
|
117
|
+
videos = res.get("videos") or []
|
|
118
|
+
if not videos:
|
|
119
|
+
return {"candidates": [], "thumbnails": {}, "error": "the workflow produced no video output"}
|
|
120
|
+
if on_progress:
|
|
121
|
+
on_progress(90, "extracting frames")
|
|
122
|
+
|
|
123
|
+
candidates, thumbs = [], {}
|
|
124
|
+
with tempfile.TemporaryDirectory() as td:
|
|
125
|
+
try:
|
|
126
|
+
frames = _extract_frames(comfy, videos[0], td)
|
|
127
|
+
except Exception as e: # noqa: BLE001
|
|
128
|
+
return {"candidates": [], "thumbnails": {}, "error": f"frame extraction failed (is ffmpeg installed?): {e}"}
|
|
129
|
+
if not frames:
|
|
130
|
+
return {"candidates": [], "thumbnails": {}, "error": "no frames extracted"}
|
|
131
|
+
|
|
132
|
+
if extract <= 1:
|
|
133
|
+
picks = [frames[len(frames) // 2]]
|
|
134
|
+
elif len(frames) <= extract:
|
|
135
|
+
picks = frames
|
|
136
|
+
else:
|
|
137
|
+
picks = [frames[round(k * (len(frames) - 1) / (extract - 1))] for k in range(extract)]
|
|
138
|
+
|
|
139
|
+
for p in picks:
|
|
140
|
+
up = comfy.upload_image(p, subfolder="minimax_h3")
|
|
141
|
+
cref = f"{up.get('subfolder', '')}/{up.get('name')}".lstrip("/")
|
|
142
|
+
candidates.append(cref)
|
|
143
|
+
try:
|
|
144
|
+
with open(p, "rb") as fh:
|
|
145
|
+
thumbs[cref] = _thumbnail(fh.read())
|
|
146
|
+
except Exception: # noqa: BLE001
|
|
147
|
+
pass
|
|
148
|
+
return {"candidates": candidates, "thumbnails": thumbs}
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def build_media_state(comfy, refs: list) -> str:
|
|
152
|
+
"""Build a MiniMaxH3MediaLoader media_state from uploaded input-dir refs,
|
|
153
|
+
reading each image's dimensions."""
|
|
154
|
+
entries = []
|
|
155
|
+
for i, ref in enumerate(refs):
|
|
156
|
+
subfolder, _, filename = str(ref).rpartition("/")
|
|
157
|
+
try:
|
|
158
|
+
w, h = Image.open(io.BytesIO(comfy.view(filename, subfolder, "input"))).size
|
|
159
|
+
except Exception: # noqa: BLE001
|
|
160
|
+
w, h = 0, 0
|
|
161
|
+
entries.append({
|
|
162
|
+
"kind": "picture", "file": f"{ref} [input]", "name": filename,
|
|
163
|
+
"duration": None, "width": w, "height": h, "has_audio": False,
|
|
164
|
+
"audio_mode": "off", "uid": f"ref{i}",
|
|
165
|
+
})
|
|
166
|
+
return json.dumps(entries)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def run_clip(comfy, workflow: dict, on_progress=None) -> dict:
|
|
170
|
+
"""Render one scenario clip; return the output video ref + a thumbnail."""
|
|
171
|
+
res = run_video_workflow(comfy, workflow, on_progress)
|
|
172
|
+
vids = res.get("videos") or []
|
|
173
|
+
if not vids:
|
|
174
|
+
return {"ok": False, "error": "the workflow produced no video output"}
|
|
175
|
+
out = {"ok": True, "outputRef": vids[0]}
|
|
176
|
+
try:
|
|
177
|
+
with tempfile.TemporaryDirectory() as td:
|
|
178
|
+
frames = _extract_frames(comfy, vids[0], td)
|
|
179
|
+
if frames:
|
|
180
|
+
with open(frames[len(frames) // 2], "rb") as fh:
|
|
181
|
+
out["thumbnail"] = _thumbnail(fh.read())
|
|
182
|
+
except Exception: # noqa: BLE001
|
|
183
|
+
pass
|
|
184
|
+
return out
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _make_board(labeled: list, cols: int = 2, cell: int = 512, pad: int = 12, label_h: int = 28) -> bytes:
|
|
188
|
+
"""Tile (name, image_bytes) pairs into one labeled board PNG."""
|
|
189
|
+
n = len(labeled)
|
|
190
|
+
rows = (n + cols - 1) // cols
|
|
191
|
+
W = cols * cell + (cols + 1) * pad
|
|
192
|
+
H = rows * (cell + label_h) + (rows + 1) * pad
|
|
193
|
+
board = Image.new("RGB", (W, H), (20, 24, 33))
|
|
194
|
+
draw = ImageDraw.Draw(board)
|
|
195
|
+
for idx, (name, data) in enumerate(labeled):
|
|
196
|
+
im = Image.open(io.BytesIO(data)).convert("RGB")
|
|
197
|
+
im.thumbnail((cell, cell))
|
|
198
|
+
r, c = divmod(idx, cols)
|
|
199
|
+
x = pad + c * (cell + pad)
|
|
200
|
+
y = pad + r * (cell + label_h + pad)
|
|
201
|
+
board.paste(im, (x + (cell - im.width) // 2, y))
|
|
202
|
+
draw.text((x, y + cell + 6), name, fill=(230, 233, 239))
|
|
203
|
+
buf = io.BytesIO()
|
|
204
|
+
board.save(buf, format="PNG")
|
|
205
|
+
return buf.getvalue()
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def run_reference_board(comfy, base_workflow: dict, payload: dict, on_progress=None) -> dict:
|
|
209
|
+
"""Render one short clip per angle (each referencing the base image as
|
|
210
|
+
<Picture 1>), take the middle frame of each, and montage them into a board
|
|
211
|
+
image stored on the user's server."""
|
|
212
|
+
prompt_node = payload.get("promptNodeId", "365")
|
|
213
|
+
media_node = payload.get("mediaNodeId", "416")
|
|
214
|
+
seed_node = payload.get("seedNodeId", "16")
|
|
215
|
+
base_ref = payload.get("baseImageRef")
|
|
216
|
+
angles = payload.get("angles") or []
|
|
217
|
+
seed_base = int(payload.get("seedBase") or 0)
|
|
218
|
+
|
|
219
|
+
if not base_ref or not angles:
|
|
220
|
+
return {"ok": False, "error": "missing base image or angles"}
|
|
221
|
+
|
|
222
|
+
# Build media_state for the base image (needs its dimensions).
|
|
223
|
+
subfolder, _, filename = base_ref.rpartition("/")
|
|
224
|
+
base_bytes = comfy.view(filename, subfolder, "input")
|
|
225
|
+
w, h = Image.open(io.BytesIO(base_bytes)).size
|
|
226
|
+
media_state = json.dumps([{
|
|
227
|
+
"kind": "picture", "file": f"{base_ref} [input]", "name": filename,
|
|
228
|
+
"duration": None, "width": w, "height": h, "has_audio": False,
|
|
229
|
+
"audio_mode": "off", "uid": "base0",
|
|
230
|
+
}])
|
|
231
|
+
|
|
232
|
+
labeled = []
|
|
233
|
+
total = len(angles)
|
|
234
|
+
for i, angle in enumerate(angles):
|
|
235
|
+
wf = copy.deepcopy(base_workflow)
|
|
236
|
+
wf[media_node]["inputs"]["media_state"] = media_state
|
|
237
|
+
wf[prompt_node]["inputs"]["prompt"] = angle["prompt"]
|
|
238
|
+
if seed_node in wf:
|
|
239
|
+
wf[seed_node]["inputs"]["seed"] = seed_base + i
|
|
240
|
+
if on_progress:
|
|
241
|
+
on_progress(int(10 + 70 * i / max(1, total)), f"rendering {angle['name']}")
|
|
242
|
+
res = run_video_workflow(comfy, wf)
|
|
243
|
+
vids = res.get("videos") or []
|
|
244
|
+
if not vids:
|
|
245
|
+
return {"ok": False, "error": f"no video output for {angle['name']}"}
|
|
246
|
+
with tempfile.TemporaryDirectory() as td:
|
|
247
|
+
frames = _extract_frames(comfy, vids[0], td)
|
|
248
|
+
if not frames:
|
|
249
|
+
return {"ok": False, "error": f"no frames for {angle['name']}"}
|
|
250
|
+
with open(frames[len(frames) // 2], "rb") as fh:
|
|
251
|
+
labeled.append((angle["name"], fh.read()))
|
|
252
|
+
|
|
253
|
+
if on_progress:
|
|
254
|
+
on_progress(90, "combining board")
|
|
255
|
+
board_png = _make_board(labeled)
|
|
256
|
+
with tempfile.TemporaryDirectory() as td:
|
|
257
|
+
bp = os.path.join(td, "board.png")
|
|
258
|
+
with open(bp, "wb") as f:
|
|
259
|
+
f.write(board_png)
|
|
260
|
+
up = comfy.upload_image(bp, subfolder="minimax_h3")
|
|
261
|
+
board_ref = f"{up.get('subfolder', '')}/{up.get('name')}".lstrip("/")
|
|
262
|
+
|
|
263
|
+
return {"ok": True, "boardRef": board_ref, "thumbnail": _thumbnail(board_png)}
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
# --- Stitch / export (feature 7) --------------------------------------------
|
|
267
|
+
|
|
268
|
+
def _duration(path: str) -> float:
|
|
269
|
+
out = subprocess.check_output(
|
|
270
|
+
["ffprobe", "-v", "error", "-show_entries", "format=duration",
|
|
271
|
+
"-of", "default=nw=1:nk=1", path],
|
|
272
|
+
timeout=30,
|
|
273
|
+
).decode().strip()
|
|
274
|
+
return float(out)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _simple_concat(clips: list, out: str, td: str) -> None:
|
|
278
|
+
listf = os.path.join(td, "list.txt")
|
|
279
|
+
with open(listf, "w") as f:
|
|
280
|
+
for p in clips:
|
|
281
|
+
f.write(f"file '{p}'\n")
|
|
282
|
+
subprocess.run(
|
|
283
|
+
["ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", listf,
|
|
284
|
+
"-c:v", "libx264", "-c:a", "aac", "-pix_fmt", "yuv420p", out],
|
|
285
|
+
check=True, capture_output=True, timeout=900,
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _xfade_concat(clips: list, out: str, xf: float) -> None:
|
|
290
|
+
durs = [_duration(p) for p in clips]
|
|
291
|
+
inputs = []
|
|
292
|
+
for p in clips:
|
|
293
|
+
inputs += ["-i", p]
|
|
294
|
+
filters = []
|
|
295
|
+
cur_v, cur_a, t = "[0:v]", "[0:a]", durs[0]
|
|
296
|
+
for i in range(1, len(clips)):
|
|
297
|
+
off = max(0.0, t - xf)
|
|
298
|
+
nv, na = f"[v{i}]", f"[a{i}]"
|
|
299
|
+
filters.append(f"{cur_v}[{i}:v]xfade=transition=fade:duration={xf}:offset={off}{nv}")
|
|
300
|
+
filters.append(f"{cur_a}[{i}:a]acrossfade=d={xf}{na}")
|
|
301
|
+
cur_v, cur_a, t = nv, na, t + durs[i] - xf
|
|
302
|
+
subprocess.run(
|
|
303
|
+
["ffmpeg", "-y", *inputs, "-filter_complex", ";".join(filters),
|
|
304
|
+
"-map", cur_v, "-map", cur_a, "-c:v", "libx264", "-c:a", "aac", "-pix_fmt", "yuv420p", out],
|
|
305
|
+
check=True, capture_output=True, timeout=1200,
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _add_music(video: str, music: str, out: str) -> None:
|
|
310
|
+
subprocess.run(
|
|
311
|
+
["ffmpeg", "-y", "-i", video, "-i", music,
|
|
312
|
+
"-filter_complex", "[0:a][1:a]amix=inputs=2:duration=first:dropout_transition=2[a]",
|
|
313
|
+
"-map", "0:v", "-map", "[a]", "-c:v", "copy", "-c:a", "aac", out],
|
|
314
|
+
check=True, capture_output=True, timeout=600,
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _export_aspect(src: str, out: str, aspect: str) -> None:
|
|
319
|
+
if aspect == "9:16":
|
|
320
|
+
vf = "scale=1080:1920:force_original_aspect_ratio=increase,crop=1080:1920"
|
|
321
|
+
subprocess.run(
|
|
322
|
+
["ffmpeg", "-y", "-i", src, "-vf", vf, "-c:v", "libx264", "-c:a", "copy", "-pix_fmt", "yuv420p", out],
|
|
323
|
+
check=True, capture_output=True, timeout=600,
|
|
324
|
+
)
|
|
325
|
+
else: # 16:9 — already widescreen, just finalize the container
|
|
326
|
+
subprocess.run(["ffmpeg", "-y", "-i", src, "-c", "copy", out], check=True, capture_output=True, timeout=300)
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def run_stitch(comfy, payload: dict, on_progress=None) -> dict:
|
|
330
|
+
"""Concatenate ordered clips (optional crossfade + music), then export the
|
|
331
|
+
requested aspect ratios. Files are written to ./pipelabs_exports and their
|
|
332
|
+
absolute paths are reported back. Requires ffmpeg + ffprobe."""
|
|
333
|
+
clip_refs = payload.get("clips") or []
|
|
334
|
+
if not clip_refs:
|
|
335
|
+
return {"ok": False, "error": "no rendered clips to stitch"}
|
|
336
|
+
cross = payload.get("crossfade") or {}
|
|
337
|
+
xf = float(cross.get("duration") or 0) if cross.get("enabled") else 0.0
|
|
338
|
+
music_ref = payload.get("music")
|
|
339
|
+
aspects = payload.get("aspects") or ["16:9", "9:16"]
|
|
340
|
+
prefix = payload.get("filenamePrefix") or "pipelabs_export"
|
|
341
|
+
|
|
342
|
+
try:
|
|
343
|
+
with tempfile.TemporaryDirectory() as td:
|
|
344
|
+
if on_progress:
|
|
345
|
+
on_progress(10, "downloading clips")
|
|
346
|
+
local = []
|
|
347
|
+
for i, ref in enumerate(clip_refs):
|
|
348
|
+
sub, _, fn = str(ref).rpartition("/")
|
|
349
|
+
p = os.path.join(td, f"c{i:03d}.mp4")
|
|
350
|
+
with open(p, "wb") as f:
|
|
351
|
+
f.write(comfy.view(fn, sub, "output"))
|
|
352
|
+
local.append(p)
|
|
353
|
+
|
|
354
|
+
music_path = None
|
|
355
|
+
if music_ref:
|
|
356
|
+
sub, _, fn = str(music_ref).rpartition("/")
|
|
357
|
+
for ftype in ("input", "output"):
|
|
358
|
+
try:
|
|
359
|
+
data = comfy.view(fn, sub, ftype)
|
|
360
|
+
music_path = os.path.join(td, "music" + (os.path.splitext(fn)[1] or ".mp3"))
|
|
361
|
+
with open(music_path, "wb") as f:
|
|
362
|
+
f.write(data)
|
|
363
|
+
break
|
|
364
|
+
except Exception: # noqa: BLE001
|
|
365
|
+
continue
|
|
366
|
+
|
|
367
|
+
if on_progress:
|
|
368
|
+
on_progress(40, "concatenating")
|
|
369
|
+
base = os.path.join(td, "base.mp4")
|
|
370
|
+
if xf > 0 and len(local) > 1:
|
|
371
|
+
_xfade_concat(local, base, xf)
|
|
372
|
+
else:
|
|
373
|
+
_simple_concat(local, base, td)
|
|
374
|
+
|
|
375
|
+
if music_path:
|
|
376
|
+
if on_progress:
|
|
377
|
+
on_progress(60, "adding music")
|
|
378
|
+
mixed = os.path.join(td, "base_music.mp4")
|
|
379
|
+
_add_music(base, music_path, mixed)
|
|
380
|
+
base = mixed
|
|
381
|
+
|
|
382
|
+
out_dir = os.path.abspath("pipelabs_exports")
|
|
383
|
+
os.makedirs(out_dir, exist_ok=True)
|
|
384
|
+
stamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
385
|
+
outputs = {}
|
|
386
|
+
for a in aspects:
|
|
387
|
+
if on_progress:
|
|
388
|
+
on_progress(80, f"exporting {a}")
|
|
389
|
+
outp = os.path.join(out_dir, f"{prefix}_{a.replace(':', 'x')}_{stamp}.mp4")
|
|
390
|
+
_export_aspect(base, outp, a)
|
|
391
|
+
outputs[a] = outp
|
|
392
|
+
|
|
393
|
+
return {"ok": True, "outputs": outputs}
|
|
394
|
+
except subprocess.CalledProcessError as e:
|
|
395
|
+
err = (e.stderr or b"").decode()[-500:]
|
|
396
|
+
return {"ok": False, "error": f"ffmpeg failed: {err}"}
|
|
397
|
+
except Exception as e: # noqa: BLE001
|
|
398
|
+
return {"ok": False, "error": str(e)}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pipelabs-bridge
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Pipelabs bridge — connects your local ComfyUI server to Pipelabs.
|
|
5
|
+
Requires-Python: >=3.9
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: requests>=2.31
|
|
8
|
+
Requires-Dist: pillow>=10.0
|
|
9
|
+
|
|
10
|
+
# pipelabs-bridge
|
|
11
|
+
|
|
12
|
+
The Pipelabs bridge runs on your ComfyUI server. It connects **outbound** to
|
|
13
|
+
Pipelabs (no inbound ports needed, so it works behind Vast.ai's NAT), polls for
|
|
14
|
+
jobs, talks to your local ComfyUI API, and reports status back.
|
|
15
|
+
|
|
16
|
+
## Install & run
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
pip install pipelabs-bridge
|
|
20
|
+
pipelabs-bridge --token <YOUR_TOKEN> --app-url https://your-pipelabs-app
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Options (all can also be set via environment variables):
|
|
24
|
+
|
|
25
|
+
| Flag | Env var | Default |
|
|
26
|
+
| ------------- | ------------------- | ----------------------- |
|
|
27
|
+
| `--token` | `PIPELABS_TOKEN` | (required) |
|
|
28
|
+
| `--app-url` | `PIPELABS_APP_URL` | `http://localhost:3000` |
|
|
29
|
+
| `--comfy-url` | `COMFY_URL` | `http://127.0.0.1:8188` |
|
|
30
|
+
| `--interval` | — | `3.0` (seconds) |
|
|
31
|
+
| `--max-interval` | — | `20.0` (seconds, capped at 25) |
|
|
32
|
+
|
|
33
|
+
Keep it running in `tmux` or `screen` so your server stays "online" in the app.
|
|
34
|
+
|
|
35
|
+
The bridge polls every `--interval` seconds, then slows to `--max-interval` after
|
|
36
|
+
2 minutes with no jobs, so a new job can take up to that long to start when idle.
|
|
37
|
+
|
|
38
|
+
Handles every Pipelabs job: health checks, images, reference boards, clips and
|
|
39
|
+
stitching.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
pipelabs_bridge/__init__.py
|
|
4
|
+
pipelabs_bridge/cli.py
|
|
5
|
+
pipelabs_bridge/client.py
|
|
6
|
+
pipelabs_bridge/comfy.py
|
|
7
|
+
pipelabs_bridge/health.py
|
|
8
|
+
pipelabs_bridge/pacer.py
|
|
9
|
+
pipelabs_bridge/runner.py
|
|
10
|
+
pipelabs_bridge.egg-info/PKG-INFO
|
|
11
|
+
pipelabs_bridge.egg-info/SOURCES.txt
|
|
12
|
+
pipelabs_bridge.egg-info/dependency_links.txt
|
|
13
|
+
pipelabs_bridge.egg-info/entry_points.txt
|
|
14
|
+
pipelabs_bridge.egg-info/requires.txt
|
|
15
|
+
pipelabs_bridge.egg-info/top_level.txt
|
|
16
|
+
tests/test_pacer.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
pipelabs_bridge
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "pipelabs-bridge"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Pipelabs bridge — connects your local ComfyUI server to Pipelabs."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.9"
|
|
7
|
+
dependencies = ["requests>=2.31", "pillow>=10.0"]
|
|
8
|
+
|
|
9
|
+
[project.scripts]
|
|
10
|
+
pipelabs-bridge = "pipelabs_bridge.cli:main"
|
|
11
|
+
|
|
12
|
+
[build-system]
|
|
13
|
+
requires = ["setuptools>=68"]
|
|
14
|
+
build-backend = "setuptools.build_meta"
|
|
15
|
+
|
|
16
|
+
[tool.setuptools]
|
|
17
|
+
packages = ["pipelabs_bridge"]
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Run from bridge/: python -m unittest discover tests"""
|
|
2
|
+
import unittest
|
|
3
|
+
|
|
4
|
+
from pipelabs_bridge.pacer import MAX_SAFE_DELAY, PollPacer
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class FakeClock:
|
|
8
|
+
def __init__(self):
|
|
9
|
+
self.t = 0.0
|
|
10
|
+
|
|
11
|
+
def __call__(self):
|
|
12
|
+
return self.t
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class PollPacerTest(unittest.TestCase):
|
|
16
|
+
def setUp(self):
|
|
17
|
+
self.clock = FakeClock()
|
|
18
|
+
self.pacer = PollPacer(base=3.0, max_delay=20.0, hold_seconds=120.0, now=self.clock)
|
|
19
|
+
|
|
20
|
+
def test_stays_fast_during_hold(self):
|
|
21
|
+
self.clock.t = 119.0
|
|
22
|
+
self.assertEqual(self.pacer.next_delay(), 3.0)
|
|
23
|
+
|
|
24
|
+
def test_slows_down_after_hold(self):
|
|
25
|
+
self.clock.t = 120.0
|
|
26
|
+
self.assertEqual(self.pacer.next_delay(), 4.5)
|
|
27
|
+
self.assertEqual(self.pacer.next_delay(), 6.75)
|
|
28
|
+
|
|
29
|
+
def test_never_exceeds_max(self):
|
|
30
|
+
self.clock.t = 500.0
|
|
31
|
+
delays = [self.pacer.next_delay() for _ in range(20)]
|
|
32
|
+
self.assertEqual(delays[-1], 20.0)
|
|
33
|
+
self.assertLessEqual(max(delays), 20.0)
|
|
34
|
+
|
|
35
|
+
def test_job_returns_to_fast_polling(self):
|
|
36
|
+
self.clock.t = 500.0
|
|
37
|
+
for _ in range(20):
|
|
38
|
+
self.pacer.next_delay()
|
|
39
|
+
self.pacer.job_seen()
|
|
40
|
+
self.assertEqual(self.pacer.next_delay(), 3.0)
|
|
41
|
+
self.clock.t = 619.0
|
|
42
|
+
self.assertEqual(self.pacer.next_delay(), 3.0)
|
|
43
|
+
self.clock.t = 620.0
|
|
44
|
+
self.assertEqual(self.pacer.next_delay(), 4.5)
|
|
45
|
+
|
|
46
|
+
def test_max_is_capped_to_stay_online(self):
|
|
47
|
+
pacer = PollPacer(base=3.0, max_delay=600.0, now=self.clock)
|
|
48
|
+
self.assertEqual(pacer.max_delay, MAX_SAFE_DELAY)
|
|
49
|
+
|
|
50
|
+
def test_slow_base_is_respected(self):
|
|
51
|
+
pacer = PollPacer(base=60.0, max_delay=20.0, hold_seconds=0.0, now=self.clock)
|
|
52
|
+
self.assertEqual(pacer.next_delay(), 60.0)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
if __name__ == "__main__":
|
|
56
|
+
unittest.main()
|