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.
@@ -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,3 @@
1
+ """Pipelabs bridge package."""
2
+
3
+ __version__ = "0.1.0"
@@ -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,2 @@
1
+ [console_scripts]
2
+ pipelabs-bridge = pipelabs_bridge.cli:main
@@ -0,0 +1,2 @@
1
+ requests>=2.31
2
+ pillow>=10.0
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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()