Skip to content

Commit 78aebec

Browse files
authored
Merge pull request #13 from nodetool-ai/codex/highlight-models
Expose all WanGP highlight models through the combined worker
2 parents a94930d + cb23b3f commit 78aebec

5 files changed

Lines changed: 470 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=2f5c2519d9e46b38975c349c8467e41e053ed0e8
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="2f5c2519d9e46b38975c349c8467e41e053ed0e8" \
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_encoded" \
9393
NODETOOL_PROVIDER_ADAPTER_DISPLAY_NAME_WANGP="WanGP" \
9494
HF_HOME=/workspace/cache/huggingface \
9595
PYTHONUNBUFFERED=1 \

combined/provider_adapter.py

Lines changed: 210 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -83,35 +83,104 @@ 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 _choice_values(definition: Any) -> list[str]:
97+
if not isinstance(definition, dict):
98+
return []
99+
values: list[str] = []
100+
for choice in definition.get("choices", []):
101+
value = choice.get("value") if isinstance(choice, dict) else choice
102+
if value is not None and str(value):
103+
values.append(str(value))
104+
return values
105+
106+
107+
def _models(session: Any, model_kind: str) -> list[dict[str, Any]]:
108+
"""Translate WanGP model metadata into NodeTool provider models."""
109+
if model_kind not in {"video", "image", "tts", "music"}:
110+
return []
87111
models: list[dict[str, Any]] = []
88112
for metadata in session.list_model_metadata():
89-
outputs = metadata.get("outputs", [])
90-
if "video" not in outputs:
113+
outputs = metadata.get("main_output", metadata.get("outputs", []))
114+
expected_output = "audio" if model_kind in {"tts", "music"} else model_kind
115+
if expected_output not in outputs:
116+
continue
117+
if model_kind == "music" and not _is_music(metadata):
118+
continue
119+
if model_kind == "tts" and _is_music(metadata):
91120
continue
92121
capabilities = metadata.get("capabilities", {})
93-
supported = [
94-
task
95-
for task in ("text_to_video", "image_to_video")
96-
if capabilities.get(task)
97-
]
122+
if model_kind in _MODEL_TASKS:
123+
supported = [
124+
task for task in _MODEL_TASKS[model_kind] if capabilities.get(task)
125+
]
126+
elif model_kind == "music":
127+
supported = ["text_to_music"] if capabilities.get("text_to_audio") else []
128+
else:
129+
supported = ["text_to_speech"] if capabilities.get("text_to_audio") else []
98130
if not supported:
99131
continue
100132
model_type = str(metadata.get("model_type") or "").strip()
101133
if not model_type:
102134
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-
)
135+
model = {
136+
"id": model_type,
137+
"name": str(metadata.get("name") or model_type),
138+
"provider": "wangp",
139+
}
140+
if model_kind == "tts":
141+
base_model_type = str(metadata.get("base_model_type") or model_type)
142+
tts_capabilities = list(supported)
143+
media_inputs = metadata.get("media_inputs")
144+
audio_inputs = (
145+
media_inputs.get("audio", {}) if isinstance(media_inputs, dict) else {}
146+
)
147+
if audio_inputs.get("prompt"):
148+
tts_capabilities.append("voice_cloning")
149+
if base_model_type in {"qwen3_tts_base", "omnivoice"}:
150+
tts_capabilities.append("reference_transcript")
151+
if base_model_type in {
152+
"qwen3_tts_customvoice",
153+
"index_tts2",
154+
"index_tts25",
155+
}:
156+
tts_capabilities.append("instruction_control")
157+
if base_model_type in {"qwen3_tts_voicedesign", "omnivoice"}:
158+
tts_capabilities.append("voice_design")
159+
160+
setting_values = metadata.get("setting_values")
161+
model_mode = (
162+
setting_values.get("model_mode")
163+
if isinstance(setting_values, dict)
164+
else None
165+
)
166+
mode_label = str(
167+
model_mode.get("label", "") if isinstance(model_mode, dict) else ""
168+
).casefold()
169+
mode_values = _choice_values(model_mode)
170+
if mode_label == "speaker":
171+
tts_capabilities.append("preset_voice")
172+
model["voices"] = mode_values
173+
elif mode_label == "language":
174+
tts_capabilities.append("language_selection")
175+
model["languages"] = mode_values
176+
model["capabilities"] = list(dict.fromkeys(tts_capabilities))
177+
else:
178+
model["supportedTasks"] = supported
179+
models.append(model)
111180
return models
112181

113182

114-
def _settings(request: dict[str, Any]) -> dict[str, Any]:
183+
def _model_id(request: dict[str, Any]) -> str:
115184
params = request.get("params")
116185
if not isinstance(params, dict):
117186
raise ValueError("Provider generation requires params")
@@ -121,33 +190,119 @@ def _settings(request: dict[str, Any]) -> dict[str, Any]:
121190
model_type = str(model or "").strip()
122191
if not model_type:
123192
raise ValueError("Provider generation requires a model id")
193+
return model_type
194+
195+
196+
def _input_path(request: dict[str, Any], name: str) -> Path:
197+
path = Path(str(request.get(name) or "")).resolve()
198+
if not path.is_file():
199+
raise ValueError(f"{request.get('operation')} requires a readable {name}")
200+
return path
201+
202+
203+
def _setting_choice_with_flag(
204+
metadata: dict[str, Any] | None, setting: str, flag: str
205+
) -> str:
206+
definitions = (metadata or {}).get("setting_values", {}).get(
207+
"video_prompt_type", {}
208+
)
209+
choice_def = definitions.get(setting)
210+
if not isinstance(choice_def, dict):
211+
return ""
212+
for choice in choice_def.get("choices", []):
213+
value = choice.get("value", "") if isinstance(choice, dict) else ""
214+
if flag in str(value):
215+
return str(value)
216+
return ""
217+
218+
219+
def _settings(
220+
request: dict[str, Any], metadata: dict[str, Any] | None = None
221+
) -> dict[str, Any]:
222+
params = request["params"]
223+
operation = str(request.get("operation") or "")
224+
model_type = _model_id(request)
124225

125226
settings: dict[str, Any] = {
126227
"model_type": model_type,
127-
"prompt": str(params.get("prompt") or ""),
228+
"prompt": str(params.get("text") or params.get("prompt") or ""),
128229
}
129230
mappings = {
130231
"negativePrompt": "negative_prompt",
131232
"resolution": "resolution",
132233
"guidanceScale": "guidance_scale",
133234
"numInferenceSteps": "num_inference_steps",
134235
"seed": "seed",
236+
"fps": "force_fps",
237+
"scheduler": "sample_solver",
238+
"strength": "denoising_strength",
135239
}
136240
for source, target in mappings.items():
137241
value = params.get(source)
138242
if value is not None:
139243
settings[target] = value
244+
if "resolution" not in settings:
245+
width = params.get("width", params.get("targetWidth"))
246+
height = params.get("height", params.get("targetHeight"))
247+
if width is not None and height is not None:
248+
settings["resolution"] = f"{int(width)}x{int(height)}"
140249
if params.get("numFrames") is not None:
141250
settings["video_length"] = int(params["numFrames"])
142-
elif params.get("durationSeconds") is not None:
251+
elif params.get("durationSeconds") is not None and operation.endswith("video"):
143252
settings["video_length"] = f"{float(params['durationSeconds']):g}s"
144253

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"
254+
if operation in {"text_to_image", "image_to_image"}:
255+
settings["image_mode"] = 1
256+
257+
if operation in {"image_to_image", "image_to_video"}:
258+
image_path = str(_input_path(request, "image_path"))
259+
image_inputs = (metadata or {}).get("media_inputs", {}).get("image", {})
260+
if image_inputs.get("start") or operation == "image_to_video":
261+
settings["image_start"] = image_path
262+
settings["image_prompt_type"] = "S"
263+
elif image_inputs.get("reference"):
264+
settings["image_refs"] = [image_path]
265+
elif image_inputs.get("control"):
266+
settings["image_guide"] = image_path
267+
control_mode = _setting_choice_with_flag(
268+
metadata, "guide_preprocessing", "V"
269+
) or _setting_choice_with_flag(metadata, "guide_custom_choices", "V")
270+
if control_mode:
271+
settings["video_prompt_type"] = control_mode
272+
else:
273+
raise ValueError(f"Model {model_type} does not accept an input image")
274+
275+
if operation == "text_to_audio":
276+
style_prompt = str(params.get("prompt") or "")
277+
lyrics = str(params.get("lyrics") or "").strip()
278+
base_model_type = str((metadata or {}).get("base_model_type") or model_type)
279+
if base_model_type.startswith("stable_audio3"):
280+
settings["prompt"] = style_prompt
281+
else:
282+
settings["prompt"] = lyrics or "[Instrumental]"
283+
settings["alt_prompt"] = style_prompt
284+
if params.get("durationSeconds") is not None:
285+
settings["duration_seconds"] = float(params["durationSeconds"])
286+
287+
if operation == "tts_encoded":
288+
if params.get("referenceText") is not None:
289+
settings["alt_prompt"] = str(params["referenceText"])
290+
elif params.get("instructions") is not None:
291+
settings["alt_prompt"] = str(params["instructions"])
292+
base_model_type = str((metadata or {}).get("base_model_type") or model_type)
293+
model_mode = (
294+
params.get("voice")
295+
if base_model_type == "qwen3_tts_customvoice"
296+
else params.get("language")
297+
)
298+
if model_mode:
299+
settings["model_mode"] = str(model_mode)
300+
if params.get("speed") is not None and base_model_type == "index_tts25":
301+
settings["custom_settings"] = {"speech_speed": float(params["speed"])}
302+
if request.get("reference_audio_path"):
303+
settings["audio_guide"] = str(
304+
_input_path(request, "reference_audio_path")
305+
)
151306
return settings
152307

153308

@@ -168,19 +323,30 @@ def on_progress(self, update: Any) -> None:
168323
)
169324

170325

171-
def _generated_path(result: Any) -> str:
326+
def _generated_path(result: Any, media_type: str | None = None) -> str:
172327
if not getattr(result, "success", False):
173328
errors = getattr(result, "errors", ())
174329
detail = "; ".join(str(error) for error in errors) or "generation failed"
175330
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())
180331
for artifact in getattr(result, "artifacts", ()):
332+
if media_type and getattr(artifact, "media_type", None) != media_type:
333+
continue
181334
path = getattr(artifact, "path", None)
182335
if path and Path(path).is_file():
183336
return str(Path(path).resolve())
337+
media_suffixes = {
338+
"image": {".bmp", ".gif", ".jpeg", ".jpg", ".png", ".tif", ".tiff", ".webp"},
339+
"video": {".avi", ".mkv", ".mov", ".mp4", ".webm"},
340+
"audio": {".aac", ".flac", ".m4a", ".mp3", ".ogg", ".opus", ".wav"},
341+
}
342+
expected_suffixes = media_suffixes.get(media_type or "")
343+
for path in getattr(result, "generated_files", ()):
344+
candidate = Path(str(path))
345+
if candidate.is_file() and (
346+
expected_suffixes is None
347+
or candidate.suffix.casefold() in expected_suffixes
348+
):
349+
return str(candidate.resolve())
184350
raise RuntimeError("WanGP completed without a generated media file")
185351

186352

@@ -196,14 +362,24 @@ def main() -> int:
196362
with contextlib.redirect_stdout(sys.stderr):
197363
if operation == "models":
198364
model_type = str(request.get("model_type") or "")
199-
models = _video_models(_session(root)) if model_type == "video" else []
365+
models = _models(_session(root), model_type)
200366
emitter.send("result", {"models": models})
201367
return 0
202-
if operation not in {"text_to_video", "image_to_video"}:
368+
media_type = {
369+
"text_to_image": "image",
370+
"image_to_image": "image",
371+
"text_to_video": "video",
372+
"image_to_video": "video",
373+
"text_to_audio": "audio",
374+
"tts_encoded": "audio",
375+
}.get(operation)
376+
if media_type is None:
203377
raise ValueError(f"Unsupported provider operation: {operation}")
204378
callbacks = _Callbacks(emitter)
205-
result = _session(root, callbacks).run_task(_settings(request))
206-
emitter.send("result", {"path": _generated_path(result)})
379+
session = _session(root, callbacks)
380+
metadata = session.get_model_metadata(_model_id(request))
381+
result = session.run_task(_settings(request, metadata))
382+
emitter.send("result", {"path": _generated_path(result, media_type)})
207383
return 0
208384

209385

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)