Skip to content

Commit ba5bf3f

Browse files
[API Nodes] HitPaw API nodes (#12117)
* feat(api-nodes): add HitPaw API nodes * remove face_soft_2x model as not working --------- Co-authored-by: Robin Huang <robin.j.huang@gmail.com>
1 parent c05a08a commit ba5bf3f

File tree

3 files changed

+394
-1
lines changed

3 files changed

+394
-1
lines changed

comfy_api_nodes/apis/hitpaw.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
from typing import TypedDict
2+
3+
from pydantic import BaseModel, Field
4+
5+
6+
class InputVideoModel(TypedDict):
7+
model: str
8+
resolution: str
9+
10+
11+
class ImageEnhanceTaskCreateRequest(BaseModel):
12+
model_name: str = Field(...)
13+
img_url: str = Field(...)
14+
extension: str = Field(".png")
15+
exif: bool = Field(False)
16+
DPI: int | None = Field(None)
17+
18+
19+
class VideoEnhanceTaskCreateRequest(BaseModel):
20+
video_url: str = Field(...)
21+
extension: str = Field(".mp4")
22+
model_name: str | None = Field(...)
23+
resolution: list[int] = Field(..., description="Target resolution [width, height]")
24+
original_resolution: list[int] = Field(..., description="Original video resolution [width, height]")
25+
26+
27+
class TaskCreateDataResponse(BaseModel):
28+
job_id: str = Field(...)
29+
consume_coins: int | None = Field(None)
30+
31+
32+
class TaskStatusPollRequest(BaseModel):
33+
job_id: str = Field(...)
34+
35+
36+
class TaskCreateResponse(BaseModel):
37+
code: int = Field(...)
38+
message: str = Field(...)
39+
data: TaskCreateDataResponse | None = Field(None)
40+
41+
42+
class TaskStatusDataResponse(BaseModel):
43+
job_id: str = Field(...)
44+
status: str = Field(...)
45+
res_url: str = Field("")
46+
47+
48+
class TaskStatusResponse(BaseModel):
49+
code: int = Field(...)
50+
message: str = Field(...)
51+
data: TaskStatusDataResponse = Field(...)

comfy_api_nodes/nodes_hitpaw.py

Lines changed: 342 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,342 @@
1+
import math
2+
3+
from typing_extensions import override
4+
5+
from comfy_api.latest import IO, ComfyExtension, Input
6+
from comfy_api_nodes.apis.hitpaw import (
7+
ImageEnhanceTaskCreateRequest,
8+
InputVideoModel,
9+
TaskCreateDataResponse,
10+
TaskCreateResponse,
11+
TaskStatusPollRequest,
12+
TaskStatusResponse,
13+
VideoEnhanceTaskCreateRequest,
14+
)
15+
from comfy_api_nodes.util import (
16+
ApiEndpoint,
17+
download_url_to_image_tensor,
18+
download_url_to_video_output,
19+
downscale_image_tensor,
20+
get_image_dimensions,
21+
poll_op,
22+
sync_op,
23+
upload_image_to_comfyapi,
24+
upload_video_to_comfyapi,
25+
validate_video_duration,
26+
)
27+
28+
VIDEO_MODELS_MODELS_MAP = {
29+
"Portrait Restore Model (1x)": "portrait_restore_1x",
30+
"Portrait Restore Model (2x)": "portrait_restore_2x",
31+
"General Restore Model (1x)": "general_restore_1x",
32+
"General Restore Model (2x)": "general_restore_2x",
33+
"General Restore Model (4x)": "general_restore_4x",
34+
"Ultra HD Model (2x)": "ultrahd_restore_2x",
35+
"Generative Model (1x)": "generative_1x",
36+
}
37+
38+
# Resolution name to target dimension (shorter side) in pixels
39+
RESOLUTION_TARGET_MAP = {
40+
"720p": 720,
41+
"1080p": 1080,
42+
"2K/QHD": 1440,
43+
"4K/UHD": 2160,
44+
"8K": 4320,
45+
}
46+
47+
# Square (1:1) resolutions use standard square dimensions
48+
RESOLUTION_SQUARE_MAP = {
49+
"720p": 720,
50+
"1080p": 1080,
51+
"2K/QHD": 1440,
52+
"4K/UHD": 2048, # DCI 4K square
53+
"8K": 4096, # DCI 8K square
54+
}
55+
56+
# Models with limited resolution support (no 8K)
57+
LIMITED_RESOLUTION_MODELS = {"Generative Model (1x)"}
58+
59+
# Resolution options for different model types
60+
RESOLUTIONS_LIMITED = ["original", "720p", "1080p", "2K/QHD", "4K/UHD"]
61+
RESOLUTIONS_FULL = ["original", "720p", "1080p", "2K/QHD", "4K/UHD", "8K"]
62+
63+
# Maximum output resolution in pixels
64+
MAX_PIXELS_GENERATIVE = 32_000_000
65+
MAX_MP_GENERATIVE = MAX_PIXELS_GENERATIVE // 1_000_000
66+
67+
68+
class HitPawGeneralImageEnhance(IO.ComfyNode):
69+
@classmethod
70+
def define_schema(cls):
71+
return IO.Schema(
72+
node_id="HitPawGeneralImageEnhance",
73+
display_name="HitPaw General Image Enhance",
74+
category="api node/image/HitPaw",
75+
description="Upscale low-resolution images to super-resolution, eliminate artifacts and noise. "
76+
f"Maximum output: {MAX_MP_GENERATIVE} megapixels.",
77+
inputs=[
78+
IO.Combo.Input("model", options=["generative_portrait", "generative"]),
79+
IO.Image.Input("image"),
80+
IO.Combo.Input("upscale_factor", options=[1, 2, 4]),
81+
IO.Boolean.Input(
82+
"auto_downscale",
83+
default=False,
84+
tooltip="Automatically downscale input image if output would exceed the limit.",
85+
),
86+
],
87+
outputs=[
88+
IO.Image.Output(),
89+
],
90+
hidden=[
91+
IO.Hidden.auth_token_comfy_org,
92+
IO.Hidden.api_key_comfy_org,
93+
IO.Hidden.unique_id,
94+
],
95+
is_api_node=True,
96+
price_badge=IO.PriceBadge(
97+
depends_on=IO.PriceBadgeDepends(widgets=["model"]),
98+
expr="""
99+
(
100+
$prices := {
101+
"generative_portrait": {"min": 0.02, "max": 0.06},
102+
"generative": {"min": 0.05, "max": 0.15}
103+
};
104+
$price := $lookup($prices, widgets.model);
105+
{
106+
"type": "range_usd",
107+
"min_usd": $price.min,
108+
"max_usd": $price.max
109+
}
110+
)
111+
""",
112+
),
113+
)
114+
115+
@classmethod
116+
async def execute(
117+
cls,
118+
model: str,
119+
image: Input.Image,
120+
upscale_factor: int,
121+
auto_downscale: bool,
122+
) -> IO.NodeOutput:
123+
height, width = get_image_dimensions(image)
124+
requested_scale = upscale_factor
125+
output_pixels = height * width * requested_scale * requested_scale
126+
if output_pixels > MAX_PIXELS_GENERATIVE:
127+
if auto_downscale:
128+
input_pixels = width * height
129+
scale = 1
130+
max_input_pixels = MAX_PIXELS_GENERATIVE
131+
132+
for candidate in [4, 2, 1]:
133+
if candidate > requested_scale:
134+
continue
135+
scale_output_pixels = input_pixels * candidate * candidate
136+
if scale_output_pixels <= MAX_PIXELS_GENERATIVE:
137+
scale = candidate
138+
max_input_pixels = None
139+
break
140+
# Check if we can downscale input by at most 2x to fit
141+
downscale_ratio = math.sqrt(scale_output_pixels / MAX_PIXELS_GENERATIVE)
142+
if downscale_ratio <= 2.0:
143+
scale = candidate
144+
max_input_pixels = MAX_PIXELS_GENERATIVE // (candidate * candidate)
145+
break
146+
147+
if max_input_pixels is not None:
148+
image = downscale_image_tensor(image, total_pixels=max_input_pixels)
149+
upscale_factor = scale
150+
else:
151+
output_width = width * requested_scale
152+
output_height = height * requested_scale
153+
raise ValueError(
154+
f"Output size ({output_width}x{output_height} = {output_pixels:,} pixels) "
155+
f"exceeds maximum allowed size of {MAX_PIXELS_GENERATIVE:,} pixels ({MAX_MP_GENERATIVE}MP). "
156+
f"Enable auto_downscale or use a smaller input image or a lower upscale factor."
157+
)
158+
159+
initial_res = await sync_op(
160+
cls,
161+
ApiEndpoint(path="/proxy/hitpaw/api/photo-enhancer", method="POST"),
162+
response_model=TaskCreateResponse,
163+
data=ImageEnhanceTaskCreateRequest(
164+
model_name=f"{model}_{upscale_factor}x",
165+
img_url=await upload_image_to_comfyapi(cls, image, total_pixels=None),
166+
),
167+
wait_label="Creating task",
168+
final_label_on_success="Task created",
169+
)
170+
if initial_res.code != 200:
171+
raise ValueError(f"Task creation failed with code {initial_res.code}: {initial_res.message}")
172+
request_price = initial_res.data.consume_coins / 1000
173+
final_response = await poll_op(
174+
cls,
175+
ApiEndpoint(path="/proxy/hitpaw/api/task-status", method="POST"),
176+
data=TaskCreateDataResponse(job_id=initial_res.data.job_id),
177+
response_model=TaskStatusResponse,
178+
status_extractor=lambda x: x.data.status,
179+
price_extractor=lambda x: request_price,
180+
poll_interval=10.0,
181+
max_poll_attempts=480,
182+
)
183+
return IO.NodeOutput(await download_url_to_image_tensor(final_response.data.res_url))
184+
185+
186+
class HitPawVideoEnhance(IO.ComfyNode):
187+
@classmethod
188+
def define_schema(cls):
189+
model_options = []
190+
for model_name in VIDEO_MODELS_MODELS_MAP:
191+
if model_name in LIMITED_RESOLUTION_MODELS:
192+
resolutions = RESOLUTIONS_LIMITED
193+
else:
194+
resolutions = RESOLUTIONS_FULL
195+
model_options.append(
196+
IO.DynamicCombo.Option(
197+
model_name,
198+
[IO.Combo.Input("resolution", options=resolutions)],
199+
)
200+
)
201+
202+
return IO.Schema(
203+
node_id="HitPawVideoEnhance",
204+
display_name="HitPaw Video Enhance",
205+
category="api node/video/HitPaw",
206+
description="Upscale low-resolution videos to high resolution, eliminate artifacts and noise. "
207+
"Prices shown are per second of video.",
208+
inputs=[
209+
IO.DynamicCombo.Input("model", options=model_options),
210+
IO.Video.Input("video"),
211+
],
212+
outputs=[
213+
IO.Video.Output(),
214+
],
215+
hidden=[
216+
IO.Hidden.auth_token_comfy_org,
217+
IO.Hidden.api_key_comfy_org,
218+
IO.Hidden.unique_id,
219+
],
220+
is_api_node=True,
221+
price_badge=IO.PriceBadge(
222+
depends_on=IO.PriceBadgeDepends(widgets=["model", "model.resolution"]),
223+
expr="""
224+
(
225+
$m := $lookup(widgets, "model");
226+
$res := $lookup(widgets, "model.resolution");
227+
$standard_model_prices := {
228+
"original": {"min": 0.01, "max": 0.198},
229+
"720p": {"min": 0.01, "max": 0.06},
230+
"1080p": {"min": 0.015, "max": 0.09},
231+
"2k/qhd": {"min": 0.02, "max": 0.117},
232+
"4k/uhd": {"min": 0.025, "max": 0.152},
233+
"8k": {"min": 0.033, "max": 0.198}
234+
};
235+
$ultra_hd_model_prices := {
236+
"original": {"min": 0.015, "max": 0.264},
237+
"720p": {"min": 0.015, "max": 0.092},
238+
"1080p": {"min": 0.02, "max": 0.12},
239+
"2k/qhd": {"min": 0.026, "max": 0.156},
240+
"4k/uhd": {"min": 0.034, "max": 0.203},
241+
"8k": {"min": 0.044, "max": 0.264}
242+
};
243+
$generative_model_prices := {
244+
"original": {"min": 0.015, "max": 0.338},
245+
"720p": {"min": 0.008, "max": 0.090},
246+
"1080p": {"min": 0.05, "max": 0.15},
247+
"2k/qhd": {"min": 0.038, "max": 0.225},
248+
"4k/uhd": {"min": 0.056, "max": 0.338}
249+
};
250+
$prices := $contains($m, "ultra hd") ? $ultra_hd_model_prices :
251+
$contains($m, "generative") ? $generative_model_prices :
252+
$standard_model_prices;
253+
$price := $lookup($prices, $res);
254+
{
255+
"type": "range_usd",
256+
"min_usd": $price.min,
257+
"max_usd": $price.max,
258+
"format": {"approximate": true, "suffix": "/second"}
259+
}
260+
)
261+
""",
262+
),
263+
)
264+
265+
@classmethod
266+
async def execute(
267+
cls,
268+
model: InputVideoModel,
269+
video: Input.Video,
270+
) -> IO.NodeOutput:
271+
validate_video_duration(video, min_duration=0.5, max_duration=60 * 60)
272+
resolution = model["resolution"]
273+
src_width, src_height = video.get_dimensions()
274+
275+
if resolution == "original":
276+
output_width = src_width
277+
output_height = src_height
278+
else:
279+
if src_width == src_height:
280+
target_size = RESOLUTION_SQUARE_MAP[resolution]
281+
if target_size < src_width:
282+
raise ValueError(
283+
f"Selected resolution {resolution} ({target_size}x{target_size}) is smaller than "
284+
f"the input video ({src_width}x{src_height}). Please select a higher resolution or 'original'."
285+
)
286+
output_width = target_size
287+
output_height = target_size
288+
else:
289+
min_dimension = min(src_width, src_height)
290+
target_size = RESOLUTION_TARGET_MAP[resolution]
291+
if target_size < min_dimension:
292+
raise ValueError(
293+
f"Selected resolution {resolution} ({target_size}p) is smaller than "
294+
f"the input video's shorter dimension ({min_dimension}p). "
295+
f"Please select a higher resolution or 'original'."
296+
)
297+
if src_width > src_height:
298+
output_height = target_size
299+
output_width = int(target_size * (src_width / src_height))
300+
else:
301+
output_width = target_size
302+
output_height = int(target_size * (src_height / src_width))
303+
initial_res = await sync_op(
304+
cls,
305+
ApiEndpoint(path="/proxy/hitpaw/api/video-enhancer", method="POST"),
306+
response_model=TaskCreateResponse,
307+
data=VideoEnhanceTaskCreateRequest(
308+
video_url=await upload_video_to_comfyapi(cls, video),
309+
resolution=[output_width, output_height],
310+
original_resolution=[src_width, src_height],
311+
model_name=VIDEO_MODELS_MODELS_MAP[model["model"]],
312+
),
313+
wait_label="Creating task",
314+
final_label_on_success="Task created",
315+
)
316+
request_price = initial_res.data.consume_coins / 1000
317+
if initial_res.code != 200:
318+
raise ValueError(f"Task creation failed with code {initial_res.code}: {initial_res.message}")
319+
final_response = await poll_op(
320+
cls,
321+
ApiEndpoint(path="/proxy/hitpaw/api/task-status", method="POST"),
322+
data=TaskStatusPollRequest(job_id=initial_res.data.job_id),
323+
response_model=TaskStatusResponse,
324+
status_extractor=lambda x: x.data.status,
325+
price_extractor=lambda x: request_price,
326+
poll_interval=10.0,
327+
max_poll_attempts=320,
328+
)
329+
return IO.NodeOutput(await download_url_to_video_output(final_response.data.res_url))
330+
331+
332+
class HitPawExtension(ComfyExtension):
333+
@override
334+
async def get_node_list(self) -> list[type[IO.ComfyNode]]:
335+
return [
336+
HitPawGeneralImageEnhance,
337+
HitPawVideoEnhance,
338+
]
339+
340+
341+
async def comfy_entrypoint() -> HitPawExtension:
342+
return HitPawExtension()

0 commit comments

Comments
 (0)