Skip to content

Commit fe47dd8

Browse files
committed
feat(api-nodes): add Sonilo nodes
Signed-off-by: bigcat88 <bigcat88@icloud.com>
1 parent acd7185 commit fe47dd8

1 file changed

Lines changed: 282 additions & 0 deletions

File tree

comfy_api_nodes/nodes_sonilo.py

Lines changed: 282 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,282 @@
1+
import base64
2+
import json
3+
import logging
4+
import time
5+
from urllib.parse import urljoin
6+
7+
import aiohttp
8+
from typing_extensions import override
9+
10+
from comfy_api.latest import IO, ComfyExtension, Input
11+
from comfy_api_nodes.util import (
12+
ApiEndpoint,
13+
audio_bytes_to_audio_input,
14+
upload_video_to_comfyapi,
15+
validate_string,
16+
)
17+
from comfy_api_nodes.util._helpers import (
18+
default_base_url,
19+
get_auth_header,
20+
get_node_id,
21+
is_processing_interrupted,
22+
)
23+
from comfy_api_nodes.util.common_exceptions import ProcessingInterrupted
24+
from server import PromptServer
25+
26+
logger = logging.getLogger(__name__)
27+
28+
29+
class SoniloVideoToMusic(IO.ComfyNode):
30+
"""Generate music from video using Sonilo's AI model."""
31+
32+
@classmethod
33+
def define_schema(cls) -> IO.Schema:
34+
return IO.Schema(
35+
node_id="SoniloVideoToMusic",
36+
display_name="Sonilo Video to Music",
37+
category="api node/audio/Sonilo",
38+
description="Generate music from video content using Sonilo's AI model. "
39+
"Analyzes the video and creates matching music.",
40+
inputs=[
41+
IO.Video.Input(
42+
"video",
43+
tooltip="Input video to generate music from. Maximum duration: 6 minutes.",
44+
),
45+
IO.String.Input(
46+
"prompt",
47+
default="",
48+
multiline=True,
49+
tooltip="Optional text prompt to guide music generation. "
50+
"Leave empty for best quality - the model will fully analyze the video content.",
51+
),
52+
IO.Int.Input(
53+
"seed",
54+
default=0,
55+
min=0,
56+
max=0xFFFFFFFFFFFFFFFF,
57+
control_after_generate=True,
58+
tooltip="Seed for reproducibility. Currently ignored by the Sonilo "
59+
"service but kept for graph consistency.",
60+
),
61+
],
62+
outputs=[IO.Audio.Output()],
63+
hidden=[
64+
IO.Hidden.auth_token_comfy_org,
65+
IO.Hidden.api_key_comfy_org,
66+
IO.Hidden.unique_id,
67+
],
68+
is_api_node=True,
69+
price_badge=IO.PriceBadge(
70+
expr='{"type":"usd","usd":0.009,"format":{"suffix":"/second"}}',
71+
),
72+
)
73+
74+
@classmethod
75+
async def execute(
76+
cls,
77+
video: Input.Video,
78+
prompt: str = "",
79+
seed: int = 0,
80+
) -> IO.NodeOutput:
81+
video_url = await upload_video_to_comfyapi(cls, video, max_duration=360)
82+
form = aiohttp.FormData()
83+
form.add_field("video_url", video_url)
84+
if prompt.strip():
85+
form.add_field("prompt", prompt.strip())
86+
audio_bytes = await _stream_sonilo_music(
87+
cls,
88+
ApiEndpoint(path="/proxy/sonilo/v2m/generate", method="POST"),
89+
form,
90+
)
91+
return IO.NodeOutput(audio_bytes_to_audio_input(audio_bytes))
92+
93+
94+
class SoniloTextToMusic(IO.ComfyNode):
95+
"""Generate music from a text prompt using Sonilo's AI model."""
96+
97+
@classmethod
98+
def define_schema(cls) -> IO.Schema:
99+
return IO.Schema(
100+
node_id="SoniloTextToMusic",
101+
display_name="Sonilo Text to Music",
102+
category="api node/audio/Sonilo",
103+
description="Generate music from a text prompt using Sonilo's AI model. "
104+
"Leave duration at 0 to let the model infer it from the prompt.",
105+
inputs=[
106+
IO.String.Input(
107+
"prompt",
108+
default="",
109+
multiline=True,
110+
tooltip="Text prompt describing the music to generate.",
111+
),
112+
IO.Int.Input(
113+
"duration",
114+
default=0,
115+
min=0,
116+
max=360,
117+
tooltip="Target duration in seconds. Set to 0 to let the model "
118+
"infer the duration from the prompt. Maximum: 6 minutes.",
119+
),
120+
IO.Int.Input(
121+
"seed",
122+
default=0,
123+
min=0,
124+
max=0xFFFFFFFFFFFFFFFF,
125+
control_after_generate=True,
126+
tooltip="Seed for reproducibility. Currently ignored by the Sonilo "
127+
"service but kept for graph consistency.",
128+
),
129+
],
130+
outputs=[IO.Audio.Output()],
131+
hidden=[
132+
IO.Hidden.auth_token_comfy_org,
133+
IO.Hidden.api_key_comfy_org,
134+
IO.Hidden.unique_id,
135+
],
136+
is_api_node=True,
137+
price_badge=IO.PriceBadge(
138+
depends_on=IO.PriceBadgeDepends(widgets=["duration"]),
139+
expr="""
140+
(
141+
widgets.duration > 0
142+
? {"type":"usd","usd": 0.009 * widgets.duration}
143+
: {"type":"usd","usd": 0.009, "format":{"suffix":"/second"}}
144+
)
145+
""",
146+
),
147+
)
148+
149+
@classmethod
150+
async def execute(
151+
cls,
152+
prompt: str,
153+
duration: int = 0,
154+
seed: int = 0,
155+
) -> IO.NodeOutput:
156+
validate_string(prompt, strip_whitespace=True, min_length=1)
157+
form = aiohttp.FormData()
158+
form.add_field("prompt", prompt)
159+
if duration > 0:
160+
form.add_field("duration", str(duration))
161+
audio_bytes = await _stream_sonilo_music(
162+
cls,
163+
ApiEndpoint(path="/proxy/sonilo/t2m/generate", method="POST"),
164+
form,
165+
)
166+
return IO.NodeOutput(audio_bytes_to_audio_input(audio_bytes))
167+
168+
169+
async def _stream_sonilo_music(
170+
cls: type[IO.ComfyNode],
171+
endpoint: ApiEndpoint,
172+
form: aiohttp.FormData,
173+
) -> bytes:
174+
"""POST ``form`` to Sonilo, read the NDJSON stream, and return the first stream's audio bytes."""
175+
url = urljoin(default_base_url().rstrip("/") + "/", endpoint.path.lstrip("/"))
176+
177+
headers: dict[str, str] = {}
178+
headers.update(get_auth_header(cls))
179+
headers.update(endpoint.headers)
180+
181+
node_id = get_node_id(cls)
182+
start_ts = time.monotonic()
183+
audio_streams: dict[int, list[bytes]] = {}
184+
title: str | None = None
185+
186+
timeout = aiohttp.ClientTimeout(total=1200.0, sock_read=300.0)
187+
async with aiohttp.ClientSession(timeout=timeout) as session:
188+
PromptServer.instance.send_progress_text("Status: Queued", node_id)
189+
async with session.post(url, data=form, headers=headers) as resp:
190+
if resp.status >= 400:
191+
msg = await _extract_error_message(resp)
192+
raise Exception(f"Sonilo API error ({resp.status}): {msg}")
193+
194+
while True:
195+
if is_processing_interrupted():
196+
raise ProcessingInterrupted("Task cancelled")
197+
198+
raw_line = await resp.content.readline()
199+
if not raw_line:
200+
break
201+
202+
line = raw_line.decode("utf-8").strip()
203+
if not line:
204+
continue
205+
206+
try:
207+
evt = json.loads(line)
208+
except json.JSONDecodeError:
209+
logger.warning("Sonilo: skipping malformed NDJSON line")
210+
continue
211+
212+
evt_type = evt.get("type")
213+
if evt_type == "error":
214+
code = evt.get("code", "UNKNOWN")
215+
message = evt.get("message", "Unknown error")
216+
raise Exception(f"Sonilo generation error ({code}): {message}")
217+
if evt_type == "duration":
218+
duration_sec = evt.get("duration_sec")
219+
if duration_sec is not None:
220+
PromptServer.instance.send_progress_text(
221+
f"Status: Generating\nVideo duration: {duration_sec:.1f}s",
222+
node_id,
223+
)
224+
elif evt_type in ("titles", "title"):
225+
# v2m sends a "titles" list, t2m sends a scalar "title"
226+
if evt_type == "titles":
227+
titles = evt.get("titles", [])
228+
if titles:
229+
title = titles[0]
230+
else:
231+
title = evt.get("title") or title
232+
if title:
233+
PromptServer.instance.send_progress_text(
234+
f"Status: Generating\nTitle: {title}",
235+
node_id,
236+
)
237+
elif evt_type == "audio_chunk":
238+
stream_idx = evt.get("stream_index", 0)
239+
chunk_data = base64.b64decode(evt["data"])
240+
241+
if stream_idx not in audio_streams:
242+
audio_streams[stream_idx] = []
243+
audio_streams[stream_idx].append(chunk_data)
244+
245+
total_chunks = sum(len(chunks) for chunks in audio_streams.values())
246+
elapsed = int(time.monotonic() - start_ts)
247+
status_lines = ["Status: Receiving audio"]
248+
if title:
249+
status_lines.append(f"Title: {title}")
250+
status_lines.append(f"Chunks received: {total_chunks}")
251+
status_lines.append(f"Time elapsed: {elapsed}s")
252+
PromptServer.instance.send_progress_text("\n".join(status_lines), node_id)
253+
elif evt_type == "complete":
254+
break
255+
256+
if not audio_streams:
257+
raise Exception("Sonilo API returned no audio data.")
258+
259+
PromptServer.instance.send_progress_text("Status: Completed", node_id)
260+
return b"".join(next(iter(audio_streams.values())))
261+
262+
263+
async def _extract_error_message(resp: aiohttp.ClientResponse) -> str:
264+
"""Extract a human-readable error message from an HTTP error response."""
265+
try:
266+
error_body = await resp.json()
267+
detail = error_body.get("detail", {})
268+
if isinstance(detail, dict):
269+
return detail.get("message", str(detail))
270+
return str(detail)
271+
except Exception:
272+
return await resp.text()
273+
274+
275+
class SoniloExtension(ComfyExtension):
276+
@override
277+
async def get_node_list(self) -> list[type[IO.ComfyNode]]:
278+
return [SoniloVideoToMusic, SoniloTextToMusic]
279+
280+
281+
async def comfy_entrypoint() -> SoniloExtension:
282+
return SoniloExtension()

0 commit comments

Comments
 (0)