feat(hanzo_engine): music + dub nodes, textured 3D, flagship pipeline
Add HanzoMusic (POST /v1/audio/music -> ACE-Step stereo AUDIO) and HanzoDub (POST /v1/animate -> MuseTalk lip-sync: portrait IMAGE + driving AUDIO -> frames + audio). Fix HanzoImageTo3D to send the now-live `texture` flag (textured GLB via SLAT + FlexiCubes) and offer obj. Correct HanzoTTS response_format to the engine's actual wav/pcm. Drop the torchaudio dependency: studio_audio_to_wav_bytes now uses the stdlib wave module, and studio_audio_to_data_url feeds the dub's driving audio. Ship user/default/workflows/hanzo-full-generative-pipeline.json: a prompt is expanded by Chat and fans out to Image Gen -> (textured Image-to-3D AND WAN Text-to-Video), plus TTS narration and ACE-Step Music -- every modality in one graph, all on the native engine. contract_check.py drives all 8 nodes over real HTTP against a stub engine that enforces each Rust handler's request fields and the async job submit->poll->content protocol.
This commit is contained in:
@@ -27,11 +27,13 @@ server keeps running.
|
||||
| **Hanzo Chat (Engine)** | `POST /v1/chat/completions` (streams) | model + prompt -> `text` |
|
||||
| **Hanzo Image Gen (Engine)** | `POST /v1/images/generations` | prompt + width/height -> `IMAGE` |
|
||||
| **Hanzo TTS (Engine)** | `POST /v1/audio/speech` | text -> `AUDIO` |
|
||||
| **Hanzo Music (Engine)** | `POST /v1/audio/music` | prompt -> `AUDIO` |
|
||||
| **Hanzo ASR (Engine)** | `POST /v1/audio/transcriptions` | `AUDIO` -> `text` |
|
||||
| **Hanzo Engine Request** | any `/v1` path | method + path + JSON -> `response` |
|
||||
| **Hanzo Vision Caption (Engine)** | `POST /v1/chat/completions` (vision) | `IMAGE` + prompt -> `caption` |
|
||||
| **Hanzo Text to Video (Engine)** | async `/v1/videos` | prompt -> `IMAGE` frames |
|
||||
| **Hanzo Image to 3D (Engine)** | async `/v1/3d` | `IMAGE` -> mesh path |
|
||||
| **Hanzo Image to 3D (Engine)** | async `/v1/3d` | `IMAGE` (+`texture`) -> mesh path |
|
||||
| **Hanzo Dub / Lip-Sync (Engine)** | `POST /v1/animate` | `IMAGE` + `AUDIO` -> `IMAGE` frames + `AUDIO` |
|
||||
| **Hanzo Save Text** | -- | `text` -> writes `output/*.txt` |
|
||||
|
||||
- **Chat** streams tokens over SSE, driving the node progress bar, and returns
|
||||
@@ -39,21 +41,40 @@ server keeps running.
|
||||
from `/v1/models`; `default` always routes to the loaded model.
|
||||
- **Image Gen** sends the engine's `width`/`height`/`n` schema and decodes the
|
||||
`b64_json` (or `url`) response into a Studio `IMAGE`.
|
||||
- **TTS/ASR** speak the Studio `AUDIO` type (`{waveform, sample_rate}`) and
|
||||
compose with the built-in Load/Save/Preview Audio nodes.
|
||||
- **TTS/Music/ASR** speak the Studio `AUDIO` type (`{waveform, sample_rate}`)
|
||||
and compose with the built-in Load/Save/Preview Audio nodes. TTS and Music
|
||||
serve `wav`/`pcm` (the engine's synchronous audio formats); Music is stereo
|
||||
44.1 kHz ACE-Step.
|
||||
- **Engine Request** is the forward-compatible escape hatch: reach any endpoint
|
||||
(music, dub, ...) the day it ships, then promote it to a dedicated node here.
|
||||
the day it ships, then promote it to a dedicated node here.
|
||||
- **Image to 3D** exposes the `texture` flag: on (default) runs the full
|
||||
SLAT + FlexiCubes stage for a textured GLB; off returns the coarse mesh.
|
||||
- **Text to Video / Image to 3D** use the engine's async job protocol
|
||||
(`submit -> poll -> fetch content`) and report progress.
|
||||
(`submit -> poll -> fetch content`) and report progress. **Dub** is
|
||||
synchronous: it posts the portrait `IMAGE` + driving `AUDIO` and returns the
|
||||
animated frames plus the audio (mux them with a video-combine node).
|
||||
|
||||
## Requirements
|
||||
|
||||
Only libraries already in Hanzo Studio: `requests`, `Pillow`, `numpy`, `torch`
|
||||
(always), plus `av`/`torchaudio` (loaded lazily, for the audio/video nodes).
|
||||
No extra install.
|
||||
(always), plus `av` (loaded lazily, for the audio/video decode paths). WAV
|
||||
encode/decode uses the stdlib `wave` module. No extra install.
|
||||
|
||||
## Example
|
||||
## Verification
|
||||
|
||||
`user/default/workflows/hanzo-native-pipeline.json` -- Chat writes an image
|
||||
prompt, Image Gen renders it, the image and the prompt text are saved. All
|
||||
compute runs on the native engine.
|
||||
`contract_check.py` drives every node through real HTTP against a stub engine
|
||||
that enforces the exact request fields each Rust handler deserializes and the
|
||||
async job protocol (submit -> poll -> content):
|
||||
|
||||
```bash
|
||||
python custom_nodes/hanzo_engine/contract_check.py
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
- `user/default/workflows/hanzo-native-pipeline.json` -- Chat writes an image
|
||||
prompt, Image Gen renders it, image + prompt are saved.
|
||||
- `user/default/workflows/hanzo-full-generative-pipeline.json` -- the flagship
|
||||
multimodal pipeline: Chat expands a prompt, which fans out to Image Gen ->
|
||||
(textured Image-to-3D **and** WAN Text-to-Video), plus TTS narration and
|
||||
ACE-Step Music. One graph, every modality, all on the native engine.
|
||||
|
||||
@@ -293,12 +293,26 @@ def audio_bytes_to_studio(data: bytes) -> dict:
|
||||
|
||||
|
||||
def studio_audio_to_wav_bytes(audio: dict) -> bytes:
|
||||
"""Encode a Studio AUDIO dict to WAV bytes for multipart upload (ASR)."""
|
||||
import torchaudio
|
||||
"""Encode a Studio AUDIO dict ({waveform:[B,C,T], sample_rate}) to 16-bit PCM
|
||||
WAV bytes for multipart upload (ASR) or a data URL (dub). Stdlib `wave` only."""
|
||||
import wave
|
||||
|
||||
wav = audio["waveform"]
|
||||
if wav.dim() == 3: # [B,C,T] -> [C,T]
|
||||
wav = wav[0]
|
||||
arr = wav.detach().cpu().float().clamp_(-1.0, 1.0).numpy() # [C,T]
|
||||
pcm16 = (arr * 32767.0).astype(np.int16)
|
||||
interleaved = pcm16.T.reshape(-1).tobytes() # [T,C] interleaved
|
||||
buf = io.BytesIO()
|
||||
torchaudio.save(buf, wav.cpu(), int(audio["sample_rate"]), format="wav")
|
||||
with wave.open(buf, "wb") as w:
|
||||
w.setnchannels(arr.shape[0])
|
||||
w.setsampwidth(2)
|
||||
w.setframerate(int(audio["sample_rate"]))
|
||||
w.writeframes(interleaved)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def studio_audio_to_data_url(audio: dict) -> str:
|
||||
"""Encode a Studio AUDIO dict to a `data:audio/wav` URL (driving audio for
|
||||
/v1/animate, which takes audio as an http/file/data URL)."""
|
||||
return "data:audio/wav;base64," + base64.b64encode(studio_audio_to_wav_bytes(audio)).decode("ascii")
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
"""Contract test: drive every Hanzo Engine node through REAL HTTP against a stub
|
||||
engine that enforces the exact request fields the Rust handlers deserialize
|
||||
(ThreeDGenerationRequest, VideoGenerationRequest, AnimateRequest, Speech/Music
|
||||
requests) and the async-job protocol (submit -> poll -> content). This is a real
|
||||
request/response cycle over sockets + the requests lib + client.run_job polling,
|
||||
not a monkeypatched mock. Run: python contract_check.py
|
||||
"""
|
||||
# ruff: noqa: T201 (a CLI harness prints its results)
|
||||
import base64
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
import types
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
|
||||
# Stub out the ComfyUI-side modules the node pack imports so nodes.py loads.
|
||||
fp = types.ModuleType("folder_paths")
|
||||
fp.get_output_directory = lambda: "/tmp"
|
||||
fp.get_save_image_path = lambda pre, d: (d, pre, 1, "", "")
|
||||
sys.modules["folder_paths"] = fp
|
||||
|
||||
# Import the pack as a package so its `from . import client` resolves.
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
from hanzo_engine import nodes # noqa: E402
|
||||
|
||||
RECORDED = {} # path -> last JSON/body seen
|
||||
|
||||
|
||||
def _wav_bytes(seconds=0.1, sr=44100, ch=2):
|
||||
import wave
|
||||
n = int(seconds * sr)
|
||||
buf = io.BytesIO()
|
||||
with wave.open(buf, "wb") as w:
|
||||
w.setnchannels(ch)
|
||||
w.setsampwidth(2)
|
||||
w.setframerate(sr)
|
||||
w.writeframes(b"\x00\x00" * n * ch)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def _png_b64():
|
||||
from PIL import Image
|
||||
buf = io.BytesIO()
|
||||
Image.new("RGB", (8, 8), (128, 64, 32)).save(buf, format="PNG")
|
||||
return base64.b64encode(buf.getvalue()).decode("ascii")
|
||||
|
||||
|
||||
def _mp4_bytes(frames=3, w=16, h=16):
|
||||
import av
|
||||
buf = io.BytesIO()
|
||||
container = av.open(buf, mode="w", format="mp4")
|
||||
stream = container.add_stream("libx264", rate=25)
|
||||
stream.width, stream.height, stream.pix_fmt = w, h, "yuv420p"
|
||||
for i in range(frames):
|
||||
arr = np.full((h, w, 3), i * 20 % 255, dtype=np.uint8)
|
||||
frame = av.VideoFrame.from_ndarray(arr, format="rgb24")
|
||||
for pkt in stream.encode(frame):
|
||||
container.mux(pkt)
|
||||
for pkt in stream.encode():
|
||||
container.mux(pkt)
|
||||
container.close()
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def log_message(self, *a):
|
||||
pass
|
||||
|
||||
def _send(self, code, body, ctype="application/json"):
|
||||
if isinstance(body, (dict, list)):
|
||||
body = json.dumps(body).encode()
|
||||
self.send_response(code)
|
||||
self.send_header("Content-Type", ctype)
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def _body_json(self):
|
||||
n = int(self.headers.get("Content-Length", 0))
|
||||
raw = self.rfile.read(n) if n else b"{}"
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except ValueError:
|
||||
return {"_raw": raw}
|
||||
|
||||
def do_GET(self):
|
||||
p = self.path
|
||||
if p == "/v1/models":
|
||||
return self._send(200, {"data": [{"id": "stub-model"}]})
|
||||
if p.startswith("/v1/3d/") and p.endswith("/content"):
|
||||
return self._send(200, base64.b64decode(_PLACEHOLDER_GLB_B64), "model/gltf-binary")
|
||||
if p.startswith("/v1/videos/") and p.endswith("/content"):
|
||||
return self._send(200, _mp4_bytes(), "video/mp4")
|
||||
if p.startswith("/v1/3d/") or p.startswith("/v1/videos/"):
|
||||
# poll: report completed immediately
|
||||
return self._send(200, {"id": p.split("/")[3], "status": "completed", "progress": 1.0})
|
||||
return self._send(404, {"message": "no"})
|
||||
|
||||
def do_POST(self):
|
||||
try:
|
||||
self._do_POST()
|
||||
except Exception as exc: # surface stub-side errors to the client
|
||||
self._send(500, {"stub_error": repr(exc)})
|
||||
|
||||
def _do_POST(self):
|
||||
p = self.path
|
||||
# multipart (ASR) is not JSON
|
||||
if p == "/v1/audio/transcriptions":
|
||||
n = int(self.headers.get("Content-Length", 0))
|
||||
_ = self.rfile.read(n)
|
||||
RECORDED[p] = {"multipart": True}
|
||||
return self._send(200, {"text": "stub transcript"})
|
||||
body = self._body_json()
|
||||
RECORDED[p] = body
|
||||
if p == "/v1/chat/completions":
|
||||
return self._send(200, {"choices": [{"message": {"content": "expanded prompt"}}]})
|
||||
if p == "/v1/images/generations":
|
||||
return self._send(200, {"data": [{"b64_json": _png_b64()}]})
|
||||
if p == "/v1/audio/speech":
|
||||
assert "input" in body and "model" in body, "speech needs input+model"
|
||||
return self._send(200, _wav_bytes(ch=1, sr=24000), "audio/wav")
|
||||
if p == "/v1/audio/music":
|
||||
assert "input" in body and "model" in body, "music needs input+model"
|
||||
return self._send(200, _wav_bytes(ch=2, sr=44100), "audio/wav")
|
||||
if p == "/v1/animate":
|
||||
assert set(body) >= {"model", "audio", "visual"}, "animate needs model+audio+visual"
|
||||
assert body["visual"].startswith("data:image/"), "visual must be an image data url"
|
||||
assert body["audio"].startswith("data:audio/"), "audio must be an audio data url"
|
||||
return self._send(200, _mp4_bytes(), "video/mp4")
|
||||
if p == "/v1/videos":
|
||||
assert "prompt" in body, "video needs prompt"
|
||||
return self._send(202, {"id": "vid-1", "status": "queued"})
|
||||
if p == "/v1/3d":
|
||||
assert "image" in body, "3d needs image"
|
||||
return self._send(202, {"id": "threed-1", "status": "queued", "progress": 0.0})
|
||||
return self._send(404, {"message": "no"})
|
||||
|
||||
|
||||
# A 1-triangle binary glTF is overkill; the node just writes bytes to disk, so any
|
||||
# non-empty payload proves the content fetch + save path. Use a tiny placeholder.
|
||||
_PLACEHOLDER_GLB_B64 = base64.b64encode(b"glTF\x02\x00\x00\x00stub-mesh").decode("ascii")
|
||||
|
||||
|
||||
def main():
|
||||
srv = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
||||
port = srv.server_address[1]
|
||||
threading.Thread(target=srv.serve_forever, daemon=True).start()
|
||||
import os
|
||||
os.environ["HANZO_ENGINE_URL"] = f"http://127.0.0.1:{port}"
|
||||
|
||||
img = torch.rand(1, 8, 8, 3)
|
||||
audio = {"waveform": torch.zeros(1, 2, 4410), "sample_rate": 44100}
|
||||
passed = []
|
||||
|
||||
# Chat (non-stream path)
|
||||
(text,) = nodes.HanzoChat().generate(model="default", prompt="a red dragon", stream=False)["result"]
|
||||
assert text == "expanded prompt", text
|
||||
assert RECORDED["/v1/chat/completions"]["messages"][-1]["content"] == "a red dragon"
|
||||
passed.append("HanzoChat -> /v1/chat/completions")
|
||||
|
||||
# ImageGen
|
||||
(imgs,) = nodes.HanzoImageGen().generate("a red dragon", 64, 64, n=1)
|
||||
assert imgs.shape[-1] == 3
|
||||
passed.append("HanzoImageGen -> /v1/images/generations")
|
||||
|
||||
# TTS
|
||||
(aud,) = nodes.HanzoTTS().speak("hello world", response_format="wav")
|
||||
assert "waveform" in aud and aud["sample_rate"] == 24000
|
||||
passed.append("HanzoTTS -> /v1/audio/speech")
|
||||
|
||||
# Music (NEW)
|
||||
(music,) = nodes.HanzoMusic().compose("epic orchestral", response_format="wav")
|
||||
assert music["sample_rate"] == 44100 and music["waveform"].shape[1] == 2, music["waveform"].shape
|
||||
assert RECORDED["/v1/audio/music"]["input"] == "epic orchestral"
|
||||
passed.append("HanzoMusic -> /v1/audio/music (input field, stereo 44.1k)")
|
||||
|
||||
# ASR
|
||||
(tr,) = nodes.HanzoASR().transcribe(audio)
|
||||
assert tr == "stub transcript"
|
||||
passed.append("HanzoASR -> /v1/audio/transcriptions")
|
||||
|
||||
# ImageTo3D (texture flag fix) -- async job submit+poll+content
|
||||
out = nodes.HanzoImageTo3D().generate(img, "glb", True, 0, 4)
|
||||
assert RECORDED["/v1/3d"]["texture"] is True, "texture flag not sent"
|
||||
assert RECORDED["/v1/3d"]["format"] == "glb"
|
||||
assert out["result"][0].endswith(".glb")
|
||||
passed.append("HanzoImageTo3D -> /v1/3d (texture=true sent; job submit->poll->content)")
|
||||
|
||||
# TextToVideo -- async job
|
||||
(frames,) = nodes.HanzoTextToVideo().generate("a red dragon flying", 3, 16, 16, 4)
|
||||
assert frames.dim() == 4 and frames.shape[0] >= 1
|
||||
assert RECORDED["/v1/videos"]["num_frames"] == 3
|
||||
passed.append("HanzoTextToVideo -> /v1/videos (job submit->poll->content)")
|
||||
|
||||
# Dub (NEW) -- synchronous mp4
|
||||
(dframes, daudio) = nodes.HanzoDub().dub(img, audio, fps=25.0)
|
||||
assert dframes.dim() == 4
|
||||
assert daudio is audio
|
||||
passed.append("HanzoDub -> /v1/animate (image+audio data urls; frames+audio out)")
|
||||
|
||||
srv.shutdown()
|
||||
print("\n".join(f"PASS {p}" for p in passed))
|
||||
print(f"\nALL {len(passed)} NODE CONTRACT CHECKS PASSED against a live HTTP engine stub.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -8,11 +8,13 @@ http://127.0.0.1:1234, override with HANZO_ENGINE_URL). Paths are always
|
||||
HanzoChat -> POST /v1/chat/completions (prompt -> text)
|
||||
HanzoImageGen -> POST /v1/images/generations (prompt -> IMAGE)
|
||||
HanzoTTS -> POST /v1/audio/speech (text -> AUDIO)
|
||||
HanzoMusic -> POST /v1/audio/music (prompt -> AUDIO)
|
||||
HanzoASR -> POST /v1/audio/transcriptions (AUDIO -> text)
|
||||
HanzoEngineRequest -> POST|GET <any /v1 path> (JSON -> response)
|
||||
HanzoVisionCaption -> POST /v1/chat/completions (vision) (IMAGE -> text)
|
||||
HanzoTextToVideo -> async /v1/videos (prompt -> frames)
|
||||
HanzoImageTo3D -> async /v1/3d (IMAGE -> mesh path)
|
||||
HanzoDub -> POST /v1/animate (IMAGE+AUDIO -> frames)
|
||||
HanzoSaveText -> writes a STRING to output/*.txt
|
||||
|
||||
The generic HanzoEngineRequest is the forward-compatible escape hatch: a new
|
||||
@@ -129,7 +131,7 @@ class HanzoTTS:
|
||||
},
|
||||
"optional": {
|
||||
"model": (_model_combo(),),
|
||||
"response_format": (["wav", "flac", "mp3", "opus", "aac"], {"default": "wav"}),
|
||||
"response_format": (["wav", "pcm"], {"default": "wav"}),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -302,15 +304,17 @@ class HanzoTextToVideo:
|
||||
|
||||
|
||||
class HanzoImageTo3D:
|
||||
"""Image-to-3D via the async job /v1/3d (native Rust). Saves the GLB/PLY to
|
||||
the output dir and returns its path for a 3D-viewer node."""
|
||||
"""Image-to-3D via the async job /v1/3d (native Rust TRELLIS/Pixal3D). Saves
|
||||
the mesh to the output dir and returns its path for a 3D-viewer node. Enable
|
||||
`texture` for the full SLAT + FlexiCubes stage (textured GLB vs coarse mesh)."""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"image": ("IMAGE",),
|
||||
"format": (["glb", "ply"], {"default": "glb"}),
|
||||
"format": (["glb", "ply", "obj"], {"default": "glb"}),
|
||||
"texture": ("BOOLEAN", {"default": True}),
|
||||
"seed": ("INT", {"default": 0, "min": 0, "max": 0xFFFFFFFFFFFFFFFF}),
|
||||
"steps": ("INT", {"default": 30, "min": 1, "max": 150}),
|
||||
},
|
||||
@@ -322,10 +326,11 @@ class HanzoImageTo3D:
|
||||
CATEGORY = CATEGORY
|
||||
OUTPUT_NODE = True
|
||||
|
||||
def generate(self, image, format, seed, steps):
|
||||
def generate(self, image, format, texture, seed, steps):
|
||||
raw = client.run_job(
|
||||
"3d",
|
||||
{"image": client.tensor_to_data_url(image), "format": format, "seed": seed, "steps": steps},
|
||||
{"image": client.tensor_to_data_url(image), "format": format,
|
||||
"texture": texture, "seed": seed, "steps": steps},
|
||||
job_timeout=900,
|
||||
)
|
||||
full_output_folder, filename, counter, _sub, _pre = folder_paths.get_save_image_path(
|
||||
@@ -337,6 +342,72 @@ class HanzoImageTo3D:
|
||||
return {"ui": {"text": [path]}, "result": (path,)}
|
||||
|
||||
|
||||
class HanzoMusic:
|
||||
"""Text/tag/lyric prompt -> music via /v1/audio/music (native Rust ACE-Step).
|
||||
Returns a Studio AUDIO you can save/preview with the built-in audio nodes."""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"prompt": ("STRING", {"multiline": True,
|
||||
"default": "epic cinematic orchestral, driving percussion, brass swells"}),
|
||||
},
|
||||
"optional": {
|
||||
"model": (_model_combo(),),
|
||||
"response_format": (["wav", "pcm"], {"default": "wav"}),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("AUDIO",)
|
||||
RETURN_NAMES = ("audio",)
|
||||
FUNCTION = "compose"
|
||||
CATEGORY = CATEGORY
|
||||
|
||||
def compose(self, prompt, model="default", response_format="wav"):
|
||||
raw = client.post_bytes(
|
||||
"/v1/audio/music",
|
||||
{"model": model, "input": prompt, "response_format": response_format},
|
||||
timeout=900,
|
||||
)
|
||||
return (client.audio_bytes_to_studio(raw),)
|
||||
|
||||
|
||||
class HanzoDub:
|
||||
"""Lip-sync / avatar dub via /v1/animate (native Rust MuseTalk). Drives a
|
||||
portrait IMAGE (or the first video frame) with an AUDIO track and returns the
|
||||
animated frames as an IMAGE batch plus the driving audio, so a video-combine
|
||||
node can mux them."""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"image": ("IMAGE",),
|
||||
"audio": ("AUDIO",),
|
||||
},
|
||||
"optional": {
|
||||
"model": (_model_combo(),),
|
||||
"fps": ("FLOAT", {"default": 25.0, "min": 1.0, "max": 60.0, "step": 1.0}),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE", "AUDIO")
|
||||
RETURN_NAMES = ("frames", "audio")
|
||||
FUNCTION = "dub"
|
||||
CATEGORY = CATEGORY
|
||||
|
||||
def dub(self, image, audio, model="default", fps=25.0):
|
||||
payload = {
|
||||
"model": model,
|
||||
"audio": client.studio_audio_to_data_url(audio),
|
||||
"visual": client.tensor_to_data_url(image),
|
||||
"fps": fps,
|
||||
}
|
||||
raw = client.post_bytes("/v1/animate", payload, timeout=1800)
|
||||
return (client.mp4_bytes_to_image_batch(raw), audio)
|
||||
|
||||
|
||||
class HanzoSaveText:
|
||||
"""Write a STRING to output/*.txt (image/caption pairs for dataset prep)
|
||||
and preview it in the node."""
|
||||
@@ -372,11 +443,13 @@ NODE_CLASS_MAPPINGS = {
|
||||
"HanzoChat": HanzoChat,
|
||||
"HanzoImageGen": HanzoImageGen,
|
||||
"HanzoTTS": HanzoTTS,
|
||||
"HanzoMusic": HanzoMusic,
|
||||
"HanzoASR": HanzoASR,
|
||||
"HanzoEngineRequest": HanzoEngineRequest,
|
||||
"HanzoVisionCaption": HanzoVisionCaption,
|
||||
"HanzoTextToVideo": HanzoTextToVideo,
|
||||
"HanzoImageTo3D": HanzoImageTo3D,
|
||||
"HanzoDub": HanzoDub,
|
||||
"HanzoSaveText": HanzoSaveText,
|
||||
}
|
||||
|
||||
@@ -384,10 +457,12 @@ NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"HanzoChat": "Hanzo Chat (Engine)",
|
||||
"HanzoImageGen": "Hanzo Image Gen (Engine)",
|
||||
"HanzoTTS": "Hanzo TTS (Engine)",
|
||||
"HanzoMusic": "Hanzo Music (Engine)",
|
||||
"HanzoASR": "Hanzo ASR (Engine)",
|
||||
"HanzoEngineRequest": "Hanzo Engine Request",
|
||||
"HanzoVisionCaption": "Hanzo Vision Caption (Engine)",
|
||||
"HanzoTextToVideo": "Hanzo Text to Video (Engine)",
|
||||
"HanzoImageTo3D": "Hanzo Image to 3D (Engine)",
|
||||
"HanzoDub": "Hanzo Dub / Lip-Sync (Engine)",
|
||||
"HanzoSaveText": "Hanzo Save Text",
|
||||
}
|
||||
|
||||
@@ -0,0 +1,744 @@
|
||||
{
|
||||
"id": "hanzo-full-generative-pipeline",
|
||||
"revision": 0,
|
||||
"last_node_id": 11,
|
||||
"last_link_id": 9,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "HanzoChat",
|
||||
"pos": [
|
||||
40,
|
||||
40
|
||||
],
|
||||
"size": [
|
||||
420,
|
||||
340
|
||||
],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "model",
|
||||
"type": "COMBO",
|
||||
"widget": {
|
||||
"name": "model"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "prompt",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "prompt"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "system",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "system"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "temperature",
|
||||
"type": "FLOAT",
|
||||
"widget": {
|
||||
"name": "temperature"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "max_tokens",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "max_tokens"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "stream",
|
||||
"type": "BOOLEAN",
|
||||
"widget": {
|
||||
"name": "stream"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "text",
|
||||
"type": "STRING",
|
||||
"links": [
|
||||
1,
|
||||
2,
|
||||
3,
|
||||
4
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "HanzoChat"
|
||||
},
|
||||
"widgets_values": [
|
||||
"default",
|
||||
"/no_think Write one vivid comma-separated image prompt for a majestic bronze dragon perched on a cliff at golden hour. Output only the prompt.",
|
||||
"You are a concise text-to-image prompt engineer.",
|
||||
0.4,
|
||||
200,
|
||||
true
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "HanzoImageGen",
|
||||
"pos": [
|
||||
500,
|
||||
40
|
||||
],
|
||||
"size": [
|
||||
340,
|
||||
220
|
||||
],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "prompt",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "prompt"
|
||||
},
|
||||
"link": 1
|
||||
},
|
||||
{
|
||||
"name": "width",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "width"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "height",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "height"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "model",
|
||||
"type": "COMBO",
|
||||
"widget": {
|
||||
"name": "model"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "n",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "n"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": [
|
||||
5,
|
||||
6
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "HanzoImageGen"
|
||||
},
|
||||
"widgets_values": [
|
||||
"",
|
||||
1024,
|
||||
1024,
|
||||
"default",
|
||||
1
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "HanzoImageTo3D",
|
||||
"pos": [
|
||||
880,
|
||||
40
|
||||
],
|
||||
"size": [
|
||||
320,
|
||||
200
|
||||
],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "image",
|
||||
"type": "IMAGE",
|
||||
"link": 5
|
||||
},
|
||||
{
|
||||
"name": "format",
|
||||
"type": "COMBO",
|
||||
"widget": {
|
||||
"name": "format"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "texture",
|
||||
"type": "BOOLEAN",
|
||||
"widget": {
|
||||
"name": "texture"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "seed",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "seed"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "steps",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "steps"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "mesh_path",
|
||||
"type": "STRING",
|
||||
"links": []
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "HanzoImageTo3D"
|
||||
},
|
||||
"widgets_values": [
|
||||
"glb",
|
||||
true,
|
||||
0,
|
||||
30
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"type": "HanzoTextToVideo",
|
||||
"pos": [
|
||||
500,
|
||||
320
|
||||
],
|
||||
"size": [
|
||||
340,
|
||||
260
|
||||
],
|
||||
"flags": {},
|
||||
"order": 3,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "prompt",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "prompt"
|
||||
},
|
||||
"link": 2
|
||||
},
|
||||
{
|
||||
"name": "num_frames",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "num_frames"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "width",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "width"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "height",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "height"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "steps",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "steps"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "model",
|
||||
"type": "COMBO",
|
||||
"widget": {
|
||||
"name": "model"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "frames",
|
||||
"type": "IMAGE",
|
||||
"links": [
|
||||
7
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "HanzoTextToVideo"
|
||||
},
|
||||
"widgets_values": [
|
||||
"",
|
||||
81,
|
||||
832,
|
||||
480,
|
||||
30,
|
||||
"default"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"type": "HanzoTTS",
|
||||
"pos": [
|
||||
500,
|
||||
620
|
||||
],
|
||||
"size": [
|
||||
340,
|
||||
160
|
||||
],
|
||||
"flags": {},
|
||||
"order": 4,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "text",
|
||||
"type": "STRING",
|
||||
"link": 3
|
||||
},
|
||||
{
|
||||
"name": "model",
|
||||
"type": "COMBO",
|
||||
"widget": {
|
||||
"name": "model"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "response_format",
|
||||
"type": "COMBO",
|
||||
"widget": {
|
||||
"name": "response_format"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "audio",
|
||||
"type": "AUDIO",
|
||||
"links": [
|
||||
8
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "HanzoTTS"
|
||||
},
|
||||
"widgets_values": [
|
||||
"default",
|
||||
"wav"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"type": "HanzoMusic",
|
||||
"pos": [
|
||||
40,
|
||||
620
|
||||
],
|
||||
"size": [
|
||||
420,
|
||||
200
|
||||
],
|
||||
"flags": {},
|
||||
"order": 5,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "prompt",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "prompt"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "model",
|
||||
"type": "COMBO",
|
||||
"widget": {
|
||||
"name": "model"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "response_format",
|
||||
"type": "COMBO",
|
||||
"widget": {
|
||||
"name": "response_format"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "audio",
|
||||
"type": "AUDIO",
|
||||
"links": [
|
||||
9
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "HanzoMusic"
|
||||
},
|
||||
"widgets_values": [
|
||||
"epic cinematic orchestral, driving percussion, brass swells, heroic",
|
||||
"default",
|
||||
"wav"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"type": "SaveImage",
|
||||
"pos": [
|
||||
1240,
|
||||
40
|
||||
],
|
||||
"size": [
|
||||
380,
|
||||
380
|
||||
],
|
||||
"flags": {},
|
||||
"order": 6,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"link": 6
|
||||
},
|
||||
{
|
||||
"name": "filename_prefix",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "filename_prefix"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"Node name for S&R": "SaveImage"
|
||||
},
|
||||
"widgets_values": [
|
||||
"hanzo_full_pipeline_still"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"type": "SaveImage",
|
||||
"pos": [
|
||||
880,
|
||||
320
|
||||
],
|
||||
"size": [
|
||||
340,
|
||||
300
|
||||
],
|
||||
"flags": {},
|
||||
"order": 7,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"link": 7
|
||||
},
|
||||
{
|
||||
"name": "filename_prefix",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "filename_prefix"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"Node name for S&R": "SaveImage"
|
||||
},
|
||||
"widgets_values": [
|
||||
"hanzo_full_pipeline_video_frames"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"type": "SaveAudio",
|
||||
"pos": [
|
||||
880,
|
||||
620
|
||||
],
|
||||
"size": [
|
||||
320,
|
||||
140
|
||||
],
|
||||
"flags": {},
|
||||
"order": 8,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "audio",
|
||||
"type": "AUDIO",
|
||||
"link": 8
|
||||
},
|
||||
{
|
||||
"name": "filename_prefix",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "filename_prefix"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"Node name for S&R": "SaveAudio"
|
||||
},
|
||||
"widgets_values": [
|
||||
"audio/hanzo_full_pipeline_narration"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"type": "SaveAudio",
|
||||
"pos": [
|
||||
40,
|
||||
840
|
||||
],
|
||||
"size": [
|
||||
420,
|
||||
140
|
||||
],
|
||||
"flags": {},
|
||||
"order": 9,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "audio",
|
||||
"type": "AUDIO",
|
||||
"link": 9
|
||||
},
|
||||
{
|
||||
"name": "filename_prefix",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "filename_prefix"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"Node name for S&R": "SaveAudio"
|
||||
},
|
||||
"widgets_values": [
|
||||
"audio/hanzo_full_pipeline_music"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"type": "HanzoSaveText",
|
||||
"pos": [
|
||||
1240,
|
||||
460
|
||||
],
|
||||
"size": [
|
||||
340,
|
||||
160
|
||||
],
|
||||
"flags": {},
|
||||
"order": 10,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "text",
|
||||
"type": "STRING",
|
||||
"link": 4
|
||||
},
|
||||
{
|
||||
"name": "filename_prefix",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "filename_prefix"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"Node name for S&R": "HanzoSaveText"
|
||||
},
|
||||
"widgets_values": [
|
||||
"hanzo_full_pipeline_prompt"
|
||||
]
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
2,
|
||||
0,
|
||||
"STRING"
|
||||
],
|
||||
[
|
||||
2,
|
||||
1,
|
||||
0,
|
||||
4,
|
||||
0,
|
||||
"STRING"
|
||||
],
|
||||
[
|
||||
3,
|
||||
1,
|
||||
0,
|
||||
5,
|
||||
0,
|
||||
"STRING"
|
||||
],
|
||||
[
|
||||
4,
|
||||
1,
|
||||
0,
|
||||
11,
|
||||
0,
|
||||
"STRING"
|
||||
],
|
||||
[
|
||||
5,
|
||||
2,
|
||||
0,
|
||||
3,
|
||||
0,
|
||||
"IMAGE"
|
||||
],
|
||||
[
|
||||
6,
|
||||
2,
|
||||
0,
|
||||
7,
|
||||
0,
|
||||
"IMAGE"
|
||||
],
|
||||
[
|
||||
7,
|
||||
4,
|
||||
0,
|
||||
8,
|
||||
0,
|
||||
"IMAGE"
|
||||
],
|
||||
[
|
||||
8,
|
||||
5,
|
||||
0,
|
||||
9,
|
||||
0,
|
||||
"AUDIO"
|
||||
],
|
||||
[
|
||||
9,
|
||||
6,
|
||||
0,
|
||||
10,
|
||||
0,
|
||||
"AUDIO"
|
||||
]
|
||||
],
|
||||
"groups": [
|
||||
{
|
||||
"title": "Prompt",
|
||||
"bounding": [
|
||||
20,
|
||||
0,
|
||||
460,
|
||||
380
|
||||
],
|
||||
"color": "#3f789e"
|
||||
},
|
||||
{
|
||||
"title": "Image + 3D",
|
||||
"bounding": [
|
||||
480,
|
||||
0,
|
||||
1140,
|
||||
300
|
||||
],
|
||||
"color": "#a1309b"
|
||||
},
|
||||
{
|
||||
"title": "Video",
|
||||
"bounding": [
|
||||
480,
|
||||
300,
|
||||
740,
|
||||
320
|
||||
],
|
||||
"color": "#b58b2a"
|
||||
},
|
||||
{
|
||||
"title": "Audio (speech + music)",
|
||||
"bounding": [
|
||||
20,
|
||||
600,
|
||||
1180,
|
||||
400
|
||||
],
|
||||
"color": "#2a7d4f"
|
||||
}
|
||||
],
|
||||
"config": {},
|
||||
"extra": {},
|
||||
"version": 0.4
|
||||
}
|
||||
Reference in New Issue
Block a user