Skip to content

Commit 130f7dc

Browse files
committed
feat(image): expose WanGP as a video provider
1 parent b2aead2 commit 130f7dc

4 files changed

Lines changed: 372 additions & 2 deletions

File tree

Dockerfile.combined

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ ARG CORE_IMAGE=ghcr.io/nodetool-ai/nodetool:latest
55
FROM ${CORE_IMAGE}
66

77
ARG NODETOOL_CORE_REPOSITORY=https://github.com/nodetool-ai/nodetool-core.git
8-
ARG NODETOOL_CORE_COMMIT=a85277a888ed48b884d447492cc75ba11ab731e4
8+
ARG NODETOOL_CORE_COMMIT=cb6185b6abc78fc27260e963e7f2994818c128e3
99
ARG WANGP_REPOSITORY=https://github.com/deepbeepmeep/Wan2GP.git
1010
ARG WANGP_COMMIT=057f9ecab9ad57dfbec9768b2daf7a4426ce986c
1111
ARG MMGP_REPOSITORY=https://github.com/deepbeepmeep/mmgp.git
@@ -15,7 +15,7 @@ LABEL org.opencontainers.image.title="NodeTool WanGP non-commercial GPU worker"
1515
org.opencontainers.image.description="Authenticated Python execution worker and private WanGP MCP; free non-monetized evaluation only" \
1616
org.opencontainers.image.licenses="AGPL-3.0-or-later AND LicenseRef-WanGP-Community-2.0 AND LicenseRef-mmGP-NonCommercial" \
1717
org.opencontainers.image.source="https://github.com/nodetool-ai/nodetool-wan2gp" \
18-
io.nodetool.core.commit="a85277a888ed48b884d447492cc75ba11ab731e4" \
18+
io.nodetool.core.commit="cb6185b6abc78fc27260e963e7f2994818c128e3" \
1919
io.nodetool.wangp.commit="057f9ecab9ad57dfbec9768b2daf7a4426ce986c" \
2020
io.nodetool.mmgp.commit="589ba050d320c4df879f48d11b23d6a88e6c68c8" \
2121
io.nodetool.commercial-use="prohibited-without-separate-written-license"
@@ -80,6 +80,7 @@ COPY combined /opt/nodetool-wan2gp-combined
8080
RUN chmod 0755 /usr/local/bin/nodetool-wangp-entrypoint \
8181
/usr/local/bin/nodetool-wangp-healthcheck \
8282
/opt/nodetool-wan2gp-combined/prepare_model.py \
83+
/opt/nodetool-wan2gp-combined/provider_adapter.py \
8384
&& mkdir -p /workspace/wan2gp/config /workspace/wan2gp/models \
8485
/workspace/wan2gp/outputs /workspace/cache \
8586
&& ln -s /workspace/wan2gp/models /opt/Wan2GP/ckpts
@@ -90,6 +91,9 @@ ENV WAN2GP_MCP_URL=http://127.0.0.1:7866/mcp \
9091
WANGP_OUTPUT_DIR=/workspace/wan2gp/outputs \
9192
WANGP_ROOT=/opt/Wan2GP \
9293
NODETOOL_MODEL_PREPARE_COMMAND_WANGP="/opt/wan2gp-venv/bin/python /opt/nodetool-wan2gp-combined/prepare_model.py" \
94+
NODETOOL_PROVIDER_ADAPTER_COMMAND_WANGP="/opt/wan2gp-venv/bin/python /opt/nodetool-wan2gp-combined/provider_adapter.py" \
95+
NODETOOL_PROVIDER_ADAPTER_CAPABILITIES_WANGP="text_to_video,image_to_video" \
96+
NODETOOL_PROVIDER_ADAPTER_DISPLAY_NAME_WANGP="WanGP" \
9397
HF_HOME=/workspace/cache/huggingface \
9498
PYTHONUNBUFFERED=1 \
9599
SDL_AUDIODRIVER=dummy \

combined/provider_adapter.py

Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
#!/usr/bin/env python3
2+
"""Expose the isolated WanGP runtime through NodeTool's provider adapter API."""
3+
4+
from __future__ import annotations
5+
6+
import contextlib
7+
import json
8+
import os
9+
import sys
10+
import threading
11+
from pathlib import Path
12+
from typing import Any
13+
14+
15+
class _Emitter:
16+
def __init__(self) -> None:
17+
try:
18+
# Preserve a dedicated copy of the protocol pipe before fd 1 is
19+
# redirected. Native extensions and upstream code can bypass
20+
# contextlib.redirect_stdout by writing to the descriptor itself.
21+
self._stream = os.fdopen(
22+
os.dup(sys.stdout.fileno()),
23+
"w",
24+
encoding="utf-8",
25+
buffering=1,
26+
)
27+
except (AttributeError, OSError): # StringIO and embedded test runners.
28+
self._stream = sys.stdout
29+
self._lock = threading.Lock()
30+
31+
def send(self, event_type: str, data: dict[str, Any]) -> None:
32+
with self._lock:
33+
self._stream.write(
34+
json.dumps(
35+
{"type": event_type, "data": data}, separators=(",", ":")
36+
)
37+
+ "\n"
38+
)
39+
self._stream.flush()
40+
41+
42+
def _read_request() -> dict[str, Any]:
43+
line = sys.stdin.buffer.readline(1024 * 1024 + 1)
44+
if not line or len(line) > 1024 * 1024:
45+
raise ValueError("Expected one provider request under 1 MiB")
46+
value = json.loads(line)
47+
if not isinstance(value, dict):
48+
raise ValueError("Provider request must be a JSON object")
49+
return value
50+
51+
52+
def _reserve_stdout_for_protocol() -> None:
53+
"""Route all ordinary and low-level stdout writes to stderr."""
54+
try:
55+
sys.stdout.flush()
56+
os.dup2(sys.stderr.fileno(), sys.stdout.fileno())
57+
except (AttributeError, OSError):
58+
# contextlib.redirect_stdout below remains the fallback for streams
59+
# without real file descriptors.
60+
pass
61+
62+
63+
def _configure_wangp_root() -> Path:
64+
root = Path(os.environ.get("WANGP_ROOT", "/opt/Wan2GP")).resolve()
65+
if not (root / "shared").is_dir():
66+
raise FileNotFoundError(f"WanGP checkout is missing its shared package: {root}")
67+
root_text = str(root)
68+
if root_text not in sys.path:
69+
sys.path.insert(0, root_text)
70+
return root
71+
72+
73+
def _session(root: Path, callbacks: object | None = None) -> Any:
74+
from shared.api import init
75+
76+
return init(
77+
root=str(root),
78+
config_path=os.environ.get("WANGP_CONFIG_PATH"),
79+
output_dir=os.environ.get("WANGP_OUTPUT_DIR"),
80+
callbacks=callbacks,
81+
console_output=True,
82+
console_isatty=False,
83+
)
84+
85+
86+
def _video_models(session: Any) -> list[dict[str, Any]]:
87+
models: list[dict[str, Any]] = []
88+
for metadata in session.list_model_metadata():
89+
outputs = metadata.get("outputs", [])
90+
if "video" not in outputs:
91+
continue
92+
capabilities = metadata.get("capabilities", {})
93+
supported = [
94+
task
95+
for task in ("text_to_video", "image_to_video")
96+
if capabilities.get(task)
97+
]
98+
if not supported:
99+
continue
100+
model_type = str(metadata.get("model_type") or "").strip()
101+
if not model_type:
102+
continue
103+
models.append(
104+
{
105+
"id": model_type,
106+
"name": str(metadata.get("name") or model_type),
107+
"provider": "wangp",
108+
"supportedTasks": supported,
109+
}
110+
)
111+
return models
112+
113+
114+
def _settings(request: dict[str, Any]) -> dict[str, Any]:
115+
params = request.get("params")
116+
if not isinstance(params, dict):
117+
raise ValueError("Provider generation requires params")
118+
model = params.get("model")
119+
if isinstance(model, dict):
120+
model = model.get("id")
121+
model_type = str(model or "").strip()
122+
if not model_type:
123+
raise ValueError("Provider generation requires a model id")
124+
125+
settings: dict[str, Any] = {
126+
"model_type": model_type,
127+
"prompt": str(params.get("prompt") or ""),
128+
}
129+
mappings = {
130+
"negativePrompt": "negative_prompt",
131+
"resolution": "resolution",
132+
"guidanceScale": "guidance_scale",
133+
"numInferenceSteps": "num_inference_steps",
134+
"seed": "seed",
135+
}
136+
for source, target in mappings.items():
137+
value = params.get(source)
138+
if value is not None:
139+
settings[target] = value
140+
if params.get("numFrames") is not None:
141+
settings["video_length"] = int(params["numFrames"])
142+
elif params.get("durationSeconds") is not None:
143+
settings["video_length"] = f"{float(params['durationSeconds']):g}s"
144+
145+
if request["operation"] == "image_to_video":
146+
image_path = Path(str(request.get("image_path") or "")).resolve()
147+
if not image_path.is_file():
148+
raise ValueError("image_to_video requires a readable image_path")
149+
settings["image_start"] = str(image_path)
150+
settings["image_prompt_type"] = "S"
151+
return settings
152+
153+
154+
class _Callbacks:
155+
def __init__(self, emitter: _Emitter) -> None:
156+
self._emitter = emitter
157+
158+
def on_progress(self, update: Any) -> None:
159+
self._emitter.send(
160+
"progress",
161+
{
162+
"phase": str(getattr(update, "phase", "") or ""),
163+
"status": str(getattr(update, "status", "") or ""),
164+
"progress": int(getattr(update, "progress", 0) or 0),
165+
"current_step": getattr(update, "current_step", None),
166+
"total_steps": getattr(update, "total_steps", None),
167+
},
168+
)
169+
170+
171+
def _generated_path(result: Any) -> str:
172+
if not getattr(result, "success", False):
173+
errors = getattr(result, "errors", ())
174+
detail = "; ".join(str(error) for error in errors) or "generation failed"
175+
raise RuntimeError(detail)
176+
for path in getattr(result, "generated_files", ()):
177+
candidate = Path(str(path))
178+
if candidate.is_file():
179+
return str(candidate.resolve())
180+
for artifact in getattr(result, "artifacts", ()):
181+
path = getattr(artifact, "path", None)
182+
if path and Path(path).is_file():
183+
return str(Path(path).resolve())
184+
raise RuntimeError("WanGP completed without a generated media file")
185+
186+
187+
def main() -> int:
188+
emitter = _Emitter()
189+
request = _read_request()
190+
_reserve_stdout_for_protocol()
191+
operation = str(request.get("operation") or "")
192+
root = _configure_wangp_root()
193+
194+
# WanGP writes human-readable diagnostics to stdout. Preserve stdout as the
195+
# machine-readable adapter channel without modifying upstream code.
196+
with contextlib.redirect_stdout(sys.stderr):
197+
if operation == "models":
198+
model_type = str(request.get("model_type") or "")
199+
models = _video_models(_session(root)) if model_type == "video" else []
200+
emitter.send("result", {"models": models})
201+
return 0
202+
if operation not in {"text_to_video", "image_to_video"}:
203+
raise ValueError(f"Unsupported provider operation: {operation}")
204+
callbacks = _Callbacks(emitter)
205+
result = _session(root, callbacks).run_task(_settings(request))
206+
emitter.send("result", {"path": _generated_path(result)})
207+
return 0
208+
209+
210+
if __name__ == "__main__":
211+
try:
212+
raise SystemExit(main())
213+
except Exception as exc:
214+
print(f"WanGP provider adapter failed: {exc}", file=sys.stderr)
215+
raise SystemExit(1) from exc

docs/combined-worker.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,16 @@ continues with `stalled: true`; the worker does not abort automatically.
2222
The combined Dockerfile pins the NodeTool worker revision that implements this
2323
protocol so it does not depend on when the moving base image is rebuilt.
2424

25+
The same worker advertises WanGP as a provider with video model discovery,
26+
`text_to_video`, and `image_to_video` capabilities. These requests launch a
27+
short-lived adapter in WanGP's isolated interpreter and call its public
28+
in-process API directly. Model identifiers and capabilities come from the
29+
pinned WanGP runtime. Encoded inputs cross the interpreter boundary through a
30+
worker-owned temporary file; the adapter returns an output path and the worker
31+
streams the resulting video bytes over its existing authenticated protocol.
32+
WanGP progress callbacks are relayed as provider progress frames. No WanGP code
33+
is imported into the NodeTool environment and no upstream source is modified.
34+
2535
## Runtime configuration
2636

2737
Starting the container requires both:

0 commit comments

Comments
 (0)