Skip to content

Commit 5ea9a36

Browse files
committed
feat: expose all WanGP highlight models
1 parent a94930d commit 5ea9a36

5 files changed

Lines changed: 319 additions & 51 deletions

File tree

Dockerfile.combined

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,18 +5,18 @@ 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=cb6185b6abc78fc27260e963e7f2994818c128e3
8+
ARG NODETOOL_CORE_COMMIT=a7938288e894af65764287e626ea8d0924bf954a
99
ARG WANGP_REPOSITORY=https://github.com/deepbeepmeep/Wan2GP.git
10-
ARG WANGP_COMMIT=057f9ecab9ad57dfbec9768b2daf7a4426ce986c
10+
ARG WANGP_COMMIT=362c3467a70e1136ceb52eec95907205a8f88543
1111
ARG MMGP_REPOSITORY=https://github.com/deepbeepmeep/mmgp.git
1212
ARG MMGP_COMMIT=589ba050d320c4df879f48d11b23d6a88e6c68c8
1313

1414
LABEL org.opencontainers.image.title="NodeTool WanGP non-commercial GPU worker" \
1515
org.opencontainers.image.description="Authenticated direct-Python WanGP provider worker; 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="cb6185b6abc78fc27260e963e7f2994818c128e3" \
19-
io.nodetool.wangp.commit="057f9ecab9ad57dfbec9768b2daf7a4426ce986c" \
18+
io.nodetool.core.commit="a7938288e894af65764287e626ea8d0924bf954a" \
19+
io.nodetool.wangp.commit="362c3467a70e1136ceb52eec95907205a8f88543" \
2020
io.nodetool.mmgp.commit="589ba050d320c4df879f48d11b23d6a88e6c68c8" \
2121
io.nodetool.commercial-use="prohibited-without-separate-written-license"
2222

@@ -89,7 +89,7 @@ ENV WANGP_CONFIG_DIR=/workspace/wan2gp/config \
8989
WANGP_ROOT=/opt/Wan2GP \
9090
NODETOOL_MODEL_PREPARE_COMMAND_WANGP="/opt/wan2gp-venv/bin/python /opt/nodetool-wan2gp-combined/prepare_model.py" \
9191
NODETOOL_PROVIDER_ADAPTER_COMMAND_WANGP="/opt/wan2gp-venv/bin/python /opt/nodetool-wan2gp-combined/provider_adapter.py" \
92-
NODETOOL_PROVIDER_ADAPTER_CAPABILITIES_WANGP="text_to_video,image_to_video" \
92+
NODETOOL_PROVIDER_ADAPTER_CAPABILITIES_WANGP="text_to_video,image_to_video,text_to_image,image_to_image,text_to_audio,text_to_speech" \
9393
NODETOOL_PROVIDER_ADAPTER_DISPLAY_NAME_WANGP="WanGP" \
9494
HF_HOME=/workspace/cache/huggingface \
9595
PYTHONUNBUFFERED=1 \

combined/provider_adapter.py

Lines changed: 143 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -83,35 +83,58 @@ def _session(root: Path, callbacks: object | None = None) -> Any:
8383
)
8484

8585

86-
def _video_models(session: Any) -> list[dict[str, Any]]:
86+
_MODEL_TASKS = {
87+
"video": ("text_to_video", "image_to_video"),
88+
"image": ("text_to_image", "image_to_image"),
89+
}
90+
91+
92+
def _is_music(metadata: dict[str, Any]) -> bool:
93+
return str(metadata.get("family") or "").casefold() == "music"
94+
95+
96+
def _models(session: Any, model_kind: str) -> list[dict[str, Any]]:
97+
"""Translate WanGP model metadata into NodeTool provider models."""
98+
if model_kind not in {"video", "image", "tts", "music"}:
99+
return []
87100
models: list[dict[str, Any]] = []
88101
for metadata in session.list_model_metadata():
89-
outputs = metadata.get("outputs", [])
90-
if "video" not in outputs:
102+
outputs = metadata.get("main_output", metadata.get("outputs", []))
103+
expected_output = "audio" if model_kind in {"tts", "music"} else model_kind
104+
if expected_output not in outputs:
105+
continue
106+
if model_kind == "music" and not _is_music(metadata):
107+
continue
108+
if model_kind == "tts" and _is_music(metadata):
91109
continue
92110
capabilities = metadata.get("capabilities", {})
93-
supported = [
94-
task
95-
for task in ("text_to_video", "image_to_video")
96-
if capabilities.get(task)
97-
]
111+
if model_kind in _MODEL_TASKS:
112+
supported = [
113+
task for task in _MODEL_TASKS[model_kind] if capabilities.get(task)
114+
]
115+
elif model_kind == "music":
116+
supported = ["text_to_music"] if capabilities.get("text_to_audio") else []
117+
else:
118+
supported = ["text_to_speech"] if capabilities.get("text_to_audio") else []
98119
if not supported:
99120
continue
100121
model_type = str(metadata.get("model_type") or "").strip()
101122
if not model_type:
102123
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-
)
124+
model = {
125+
"id": model_type,
126+
"name": str(metadata.get("name") or model_type),
127+
"provider": "wangp",
128+
}
129+
if model_kind == "tts":
130+
model["capabilities"] = supported
131+
else:
132+
model["supportedTasks"] = supported
133+
models.append(model)
111134
return models
112135

113136

114-
def _settings(request: dict[str, Any]) -> dict[str, Any]:
137+
def _model_id(request: dict[str, Any]) -> str:
115138
params = request.get("params")
116139
if not isinstance(params, dict):
117140
raise ValueError("Provider generation requires params")
@@ -121,33 +144,107 @@ def _settings(request: dict[str, Any]) -> dict[str, Any]:
121144
model_type = str(model or "").strip()
122145
if not model_type:
123146
raise ValueError("Provider generation requires a model id")
147+
return model_type
148+
149+
150+
def _input_path(request: dict[str, Any], name: str) -> Path:
151+
path = Path(str(request.get(name) or "")).resolve()
152+
if not path.is_file():
153+
raise ValueError(f"{request.get('operation')} requires a readable {name}")
154+
return path
155+
156+
157+
def _setting_choice_with_flag(
158+
metadata: dict[str, Any] | None, setting: str, flag: str
159+
) -> str:
160+
definitions = (metadata or {}).get("setting_values", {}).get(
161+
"video_prompt_type", {}
162+
)
163+
choice_def = definitions.get(setting)
164+
if not isinstance(choice_def, dict):
165+
return ""
166+
for choice in choice_def.get("choices", []):
167+
value = choice.get("value", "") if isinstance(choice, dict) else ""
168+
if flag in str(value):
169+
return str(value)
170+
return ""
171+
172+
173+
def _settings(
174+
request: dict[str, Any], metadata: dict[str, Any] | None = None
175+
) -> dict[str, Any]:
176+
params = request["params"]
177+
operation = str(request.get("operation") or "")
178+
model_type = _model_id(request)
124179

125180
settings: dict[str, Any] = {
126181
"model_type": model_type,
127-
"prompt": str(params.get("prompt") or ""),
182+
"prompt": str(params.get("text") or params.get("prompt") or ""),
128183
}
129184
mappings = {
130185
"negativePrompt": "negative_prompt",
131186
"resolution": "resolution",
132187
"guidanceScale": "guidance_scale",
133188
"numInferenceSteps": "num_inference_steps",
134189
"seed": "seed",
190+
"fps": "force_fps",
191+
"scheduler": "sample_solver",
192+
"strength": "denoising_strength",
135193
}
136194
for source, target in mappings.items():
137195
value = params.get(source)
138196
if value is not None:
139197
settings[target] = value
198+
if "resolution" not in settings:
199+
width = params.get("width", params.get("targetWidth"))
200+
height = params.get("height", params.get("targetHeight"))
201+
if width is not None and height is not None:
202+
settings["resolution"] = f"{int(width)}x{int(height)}"
140203
if params.get("numFrames") is not None:
141204
settings["video_length"] = int(params["numFrames"])
142-
elif params.get("durationSeconds") is not None:
205+
elif params.get("durationSeconds") is not None and operation.endswith("video"):
143206
settings["video_length"] = f"{float(params['durationSeconds']):g}s"
144207

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"
208+
if operation in {"image_to_image", "image_to_video"}:
209+
image_path = str(_input_path(request, "image_path"))
210+
image_inputs = (metadata or {}).get("media_inputs", {}).get("image", {})
211+
if image_inputs.get("start") or operation == "image_to_video":
212+
settings["image_start"] = image_path
213+
settings["image_prompt_type"] = "S"
214+
elif image_inputs.get("reference"):
215+
settings["image_refs"] = [image_path]
216+
elif image_inputs.get("control"):
217+
settings["image_guide"] = image_path
218+
control_mode = _setting_choice_with_flag(
219+
metadata, "guide_preprocessing", "V"
220+
) or _setting_choice_with_flag(metadata, "guide_custom_choices", "V")
221+
if control_mode:
222+
settings["video_prompt_type"] = control_mode
223+
else:
224+
raise ValueError(f"Model {model_type} does not accept an input image")
225+
226+
if operation == "text_to_audio":
227+
style_prompt = str(params.get("prompt") or "")
228+
lyrics = str(params.get("lyrics") or "").strip()
229+
settings["prompt"] = lyrics or "[Instrumental]"
230+
settings["alt_prompt"] = style_prompt
231+
if params.get("durationSeconds") is not None:
232+
settings["duration_seconds"] = float(params["durationSeconds"])
233+
234+
if operation == "tts_encoded":
235+
if params.get("referenceText") is not None:
236+
settings["alt_prompt"] = str(params["referenceText"])
237+
elif params.get("instructions") is not None:
238+
settings["alt_prompt"] = str(params["instructions"])
239+
model_mode = params.get("voice") or params.get("language")
240+
if model_mode:
241+
settings["model_mode"] = str(model_mode)
242+
if params.get("speed") is not None:
243+
settings["speech_speed"] = float(params["speed"])
244+
if request.get("reference_audio_path"):
245+
settings["audio_guide"] = str(
246+
_input_path(request, "reference_audio_path")
247+
)
151248
return settings
152249

153250

@@ -168,19 +265,21 @@ def on_progress(self, update: Any) -> None:
168265
)
169266

170267

171-
def _generated_path(result: Any) -> str:
268+
def _generated_path(result: Any, media_type: str | None = None) -> str:
172269
if not getattr(result, "success", False):
173270
errors = getattr(result, "errors", ())
174271
detail = "; ".join(str(error) for error in errors) or "generation failed"
175272
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())
180273
for artifact in getattr(result, "artifacts", ()):
274+
if media_type and getattr(artifact, "media_type", None) != media_type:
275+
continue
181276
path = getattr(artifact, "path", None)
182277
if path and Path(path).is_file():
183278
return str(Path(path).resolve())
279+
for path in getattr(result, "generated_files", ()):
280+
candidate = Path(str(path))
281+
if candidate.is_file():
282+
return str(candidate.resolve())
184283
raise RuntimeError("WanGP completed without a generated media file")
185284

186285

@@ -196,14 +295,24 @@ def main() -> int:
196295
with contextlib.redirect_stdout(sys.stderr):
197296
if operation == "models":
198297
model_type = str(request.get("model_type") or "")
199-
models = _video_models(_session(root)) if model_type == "video" else []
298+
models = _models(_session(root), model_type)
200299
emitter.send("result", {"models": models})
201300
return 0
202-
if operation not in {"text_to_video", "image_to_video"}:
301+
media_type = {
302+
"text_to_image": "image",
303+
"image_to_image": "image",
304+
"text_to_video": "video",
305+
"image_to_video": "video",
306+
"text_to_audio": "audio",
307+
"tts_encoded": "audio",
308+
}.get(operation)
309+
if media_type is None:
203310
raise ValueError(f"Unsupported provider operation: {operation}")
204311
callbacks = _Callbacks(emitter)
205-
result = _session(root, callbacks).run_task(_settings(request))
206-
emitter.send("result", {"path": _generated_path(result)})
312+
session = _session(root, callbacks)
313+
metadata = session.get_model_metadata(_model_id(request))
314+
result = session.run_task(_settings(request, metadata))
315+
emitter.send("result", {"path": _generated_path(result, media_type)})
207316
return 0
208317

209318

docs/combined-image-licenses.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ separately licensed programs. The image is not endorsed by the WanGP authors.
1313
`WANGP_COMMIT`. Its complete source, license, notices, and bundled
1414
`docs/third_party_licenses` directory remain at `/opt/Wan2GP` in the image.
1515
No WanGP source modifications are made by this Dockerfile.
16+
- **NodeTool worker adapter support**: pinned by `NODETOOL_CORE_COMMIT`. The
17+
source revision is fetched from the public nodetool-core repository during
18+
the build and is not modified in the image.
1619
- **mmgp**: non-commercial use with attribution, pinned by `MMGP_COMMIT` and
1720
installed at the version required by WanGP. Its license and attribution are
1821
copied to `/opt/licenses/mmgp`.

docs/combined-worker.md

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -19,15 +19,21 @@ continues with `stalled: true`; the worker does not abort automatically.
1919
The combined Dockerfile pins the NodeTool worker revision that implements this
2020
protocol so it does not depend on when the moving base image is rebuilt.
2121

22-
The same worker advertises WanGP as a provider with video model discovery,
23-
`text_to_video`, and `image_to_video` capabilities. These requests launch a
24-
short-lived adapter in WanGP's isolated interpreter and call its public
25-
in-process API directly. Model identifiers and capabilities come from the
26-
pinned WanGP runtime. Encoded inputs cross the interpreter boundary through a
27-
worker-owned temporary file; the adapter returns an output path and the worker
28-
streams the resulting video bytes over its existing authenticated protocol.
29-
WanGP progress callbacks are relayed as provider progress frames. No WanGP code
30-
is imported into the NodeTool environment and no upstream source is modified.
22+
The same worker advertises WanGP as a provider with dynamic video, image, TTS,
23+
and music model discovery. It implements `text_to_video`, `image_to_video`,
24+
`text_to_image`, `image_to_image`, `text_to_audio`, and encoded
25+
`text_to_speech` generation. These requests launch a short-lived adapter in
26+
WanGP's isolated interpreter and call its public in-process API directly. Model
27+
identifiers, output modality, and task capabilities come from the pinned WanGP
28+
runtime, including its `music` family marker used to keep music generators out
29+
of the speech list.
30+
31+
Encoded input images and voice references cross the interpreter boundary
32+
through worker-owned temporary files. The adapter returns an output path and
33+
the worker streams the resulting image, video, or audio bytes over its existing
34+
authenticated chunked protocol. WanGP progress callbacks are relayed as
35+
provider progress frames. No WanGP code is imported into the NodeTool
36+
environment and no upstream source is modified.
3137

3238
## Runtime configuration
3339

0 commit comments

Comments
 (0)