Skip to content

Commit 24863e2

Browse files
authored
Merge pull request #350 from shadowcz007/video-all-in-one-fal
0.46.0
2 parents 4a9413c + fe8b526 commit 24863e2

File tree

6 files changed

+1130
-2
lines changed

6 files changed

+1130
-2
lines changed

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ For business cooperation, please contact email [email protected]
1010

1111
##### `最新`
1212

13+
- 新增[fal.ai](https://fal.ai/dashboard)的视频生成:Kling、RunwayGen3、LumaDreamMachine,[工作流下载](./workflow/video-all-in-one-test-workflow.json)
14+
1315
- 新增 SimulateDevDesignDiscussions,需要安装[swarm](https://github.com/openai/swarm)[Comfyui-ChatTTS](https://github.com/shadowcz007/Comfyui-ChatTTS)[工作流下载](./workflow/swarm制作的播客节点workflow.json)
1416

1517
- 新增 SenseVoice

__init__.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1455,4 +1455,23 @@ def mix_status(request):
14551455
except Exception as e:
14561456
logging.info('Whisper.available False' )
14571457

1458+
1459+
try:
1460+
from .nodes.FalVideo import VideoGenKlingNode,VideoGenLumaDreamMachineNode,VideoGenRunwayGen3Node,LoadVideoFromURL
1461+
logging.info('FalVideo.available')
1462+
# Update Node class mappings
1463+
NODE_CLASS_MAPPINGS['VideoGenKlingNode']=VideoGenKlingNode
1464+
NODE_CLASS_MAPPINGS['VideoGenRunwayGen3Node']=VideoGenRunwayGen3Node
1465+
NODE_CLASS_MAPPINGS['VideoGenLumaDreamMachineNode']=VideoGenLumaDreamMachineNode
1466+
NODE_CLASS_MAPPINGS['LoadVideoFromURL']=LoadVideoFromURL
1467+
1468+
NODE_DISPLAY_NAME_MAPPINGS["VideoGenKlingNode"]= "Kling Video Generation @fal"
1469+
NODE_DISPLAY_NAME_MAPPINGS["VideoGenRunwayGen3Node"]= "Runway Gen3 Image-to-Video @fal"
1470+
NODE_DISPLAY_NAME_MAPPINGS["VideoGenLumaDreamMachineNode"]= "Luma Dream Machine @fal"
1471+
NODE_DISPLAY_NAME_MAPPINGS["LoadVideoFromURL"]= "Load Video from URL"
1472+
1473+
1474+
except Exception as e:
1475+
logging.info('FalVideo.available False' )
1476+
14581477
logging.info('\033[93m -------------- \033[0m')

nodes/FalVideo.py

Lines changed: 332 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,332 @@
1+
# 修改自 https://github.com/gokayfem/ComfyUI-fal-API/blob/main/nodes/video_node.py
2+
# image-to-video all in one
3+
4+
import os,sys
5+
import torch
6+
from PIL import Image
7+
import tempfile
8+
import numpy as np
9+
import requests
10+
import cv2
11+
import subprocess
12+
import importlib.util
13+
python = sys.executable
14+
15+
def is_installed(package, package_overwrite=None,auto_install=True):
16+
is_has=False
17+
try:
18+
spec = importlib.util.find_spec(package)
19+
is_has=spec is not None
20+
except ModuleNotFoundError:
21+
pass
22+
23+
package = package_overwrite or package
24+
25+
if spec is None:
26+
if auto_install==True:
27+
print(f"Installing {package}...")
28+
# 清华源 -i https://pypi.tuna.tsinghua.edu.cn/simple
29+
command = f'"{python}" -m pip install {package}'
30+
31+
result = subprocess.run(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=True, env=os.environ)
32+
33+
is_has=True
34+
35+
if result.returncode != 0:
36+
print(f"Couldn't install\nCommand: {command}\nError code: {result.returncode}")
37+
is_has=False
38+
else:
39+
print(package+'## OK')
40+
41+
return is_has
42+
43+
44+
try:
45+
if is_installed('fal_client','fal-client')==True:
46+
from fal_client import submit, upload_file
47+
except:
48+
print("#install fal-client error")
49+
50+
51+
def upload_image(image):
52+
try:
53+
# Convert the image tensor to a numpy array
54+
if isinstance(image, torch.Tensor):
55+
image_np = image.cpu().numpy()
56+
else:
57+
image_np = np.array(image)
58+
59+
# Ensure the image is in the correct format (H, W, C)
60+
if image_np.ndim == 4:
61+
image_np = image_np.squeeze(0) # Remove batch dimension if present
62+
if image_np.ndim == 2:
63+
image_np = np.stack([image_np] * 3, axis=-1) # Convert grayscale to RGB
64+
elif image_np.shape[0] == 3:
65+
image_np = np.transpose(image_np, (1, 2, 0)) # Change from (C, H, W) to (H, W, C)
66+
67+
# Normalize the image data to 0-255 range
68+
if image_np.dtype == np.float32 or image_np.dtype == np.float64:
69+
image_np = (image_np * 255).astype(np.uint8)
70+
71+
# Convert to PIL Image
72+
pil_image = Image.fromarray(image_np)
73+
74+
# Save the image to a temporary file
75+
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as temp_file:
76+
pil_image.save(temp_file, format="PNG")
77+
temp_file_path = temp_file.name
78+
79+
# Upload the temporary file
80+
image_url = upload_file(temp_file_path)
81+
return image_url
82+
except Exception as e:
83+
print(f"Error uploading image: {str(e)}")
84+
return None
85+
finally:
86+
# Clean up the temporary file
87+
if 'temp_file_path' in locals():
88+
os.unlink(temp_file_path)
89+
90+
91+
class VideoGenKlingNode:
92+
@classmethod
93+
def INPUT_TYPES(cls):
94+
return {
95+
"required": {
96+
"prompt": ("STRING", {"default": "", "multiline": True}),
97+
"duration": (["5", "10"], {"default": "5"}),
98+
"aspect_ratio": (["16:9", "9:16", "1:1"], {"default": "16:9"}),
99+
"mode": (["standard", "pro"], {"default": "standard"}),
100+
"fal_key":("STRING", {"forceInput": True,}),
101+
},
102+
"optional": {
103+
"image": ("IMAGE",),
104+
},
105+
}
106+
107+
RETURN_TYPES = ("STRING",)
108+
FUNCTION = "generate_video"
109+
CATEGORY = "♾️Mixlab/Video"
110+
111+
def generate_video(self, prompt, duration, aspect_ratio,mode,fal_key, image=None):
112+
arguments = {
113+
"prompt": prompt,
114+
"duration": duration,
115+
"aspect_ratio": aspect_ratio,
116+
}
117+
118+
os.environ["FAL_KEY"] = fal_key
119+
120+
api_url="fal-ai/kling-video/v1/"+mode
121+
122+
try:
123+
if image is not None:
124+
image_url = upload_image(image)
125+
if image_url:
126+
arguments["image_url"] = image_url
127+
handler = submit(api_url+"/image-to-video", arguments=arguments)
128+
else:
129+
return ("Error: Unable to upload image.",)
130+
else:
131+
handler = submit(api_url+"/text-to-video", arguments=arguments)
132+
133+
result = handler.get()
134+
video_url = result["video"]["url"]
135+
return (video_url,)
136+
except Exception as e:
137+
print(f"Error generating video: {str(e)}")
138+
return ("Error: Unable to generate video.",)
139+
140+
141+
class VideoGenRunwayGen3Node:
142+
@classmethod
143+
def INPUT_TYPES(cls):
144+
return {
145+
"required": {
146+
"prompt": ("STRING", {"default": "", "multiline": True}),
147+
"image": ("IMAGE",),
148+
"duration": (["5", "10"], {"default": "5"}),
149+
"aspect_ratio": (["16:9", "9:16"], {"default": "16:9"}),
150+
"fal_key":("STRING", {"forceInput": True,}),
151+
},
152+
}
153+
154+
RETURN_TYPES = ("STRING",)
155+
FUNCTION = "generate_video"
156+
CATEGORY = "♾️Mixlab/Video"
157+
158+
def generate_video(self, prompt, image, duration,aspect_ratio,fal_key):
159+
os.environ["FAL_KEY"] = fal_key
160+
try:
161+
image_url = upload_image(image)
162+
if not image_url:
163+
return ("Error: Unable to upload image.",)
164+
165+
arguments = {
166+
"prompt": prompt,
167+
"image_url": image_url,
168+
"duration": duration,
169+
"ratio":aspect_ratio
170+
}
171+
172+
handler = submit("fal-ai/runway-gen3/turbo/image-to-video", arguments=arguments)
173+
result = handler.get()
174+
video_url = result["video"]["url"]
175+
return (video_url,)
176+
except Exception as e:
177+
print(f"Error generating video: {str(e)}")
178+
return ("Error: Unable to generate video.",)
179+
180+
class VideoGenLumaDreamMachineNode:
181+
@classmethod
182+
def INPUT_TYPES(cls):
183+
return {
184+
"required": {
185+
"prompt": ("STRING", {"default": "", "multiline": True}),
186+
"aspect_ratio": (["16:9", "9:16", "4:3", "3:4", "21:9", "9:21"], {"default": "16:9"}),
187+
"fal_key":("STRING", {"forceInput": True,}),
188+
},
189+
"optional": {
190+
"image": ("IMAGE",),
191+
"loop": ("BOOLEAN", {"default": True}),
192+
},
193+
}
194+
195+
RETURN_TYPES = ("STRING",)
196+
FUNCTION = "generate_video"
197+
CATEGORY = "♾️Mixlab/Video"
198+
199+
def generate_video(self, prompt, aspect_ratio,fal_key, image=None, loop=True):
200+
201+
os.environ["FAL_KEY"] = fal_key
202+
203+
arguments = {
204+
"prompt": prompt,
205+
"aspect_ratio": aspect_ratio,
206+
"loop": loop,
207+
}
208+
209+
try:
210+
if image is not None:
211+
image_url = upload_image(image)
212+
if not image_url:
213+
return ("Error: Unable to upload image.",)
214+
arguments["image_url"] = image_url
215+
endpoint = "fal-ai/luma-dream-machine/image-to-video"
216+
else:
217+
endpoint = "fal-ai/luma-dream-machine"
218+
219+
handler = submit(endpoint, arguments=arguments)
220+
result = handler.get()
221+
video_url = result["video"]["url"]
222+
return (video_url,)
223+
except Exception as e:
224+
print(f"Error generating video: {str(e)}")
225+
return ("Error: Unable to generate video.",)
226+
227+
class LoadVideoFromURL:
228+
@classmethod
229+
def INPUT_TYPES(cls):
230+
return {
231+
"required": {
232+
"url": ("STRING", {"default": "https://example.com/video.mp4"}),
233+
"force_rate": ("INT", {"default": 0, "min": 0, "max": 60, "step": 1}),
234+
"force_size": (["Disabled", "Custom Height", "Custom Width", "Custom", "256x?", "?x256", "256x256", "512x?", "?x512", "512x512"],),
235+
"custom_width": ("INT", {"default": 512, "min": 0, "max": 8192, "step": 8}),
236+
"custom_height": ("INT", {"default": 512, "min": 0, "max": 8192, "step": 8}),
237+
"frame_load_cap": ("INT", {"default": 0, "min": 0, "max": 1000000, "step": 1}),
238+
"skip_first_frames": ("INT", {"default": 0, "min": 0, "max": 1000000, "step": 1}),
239+
"select_every_nth": ("INT", {"default": 1, "min": 1, "max": 1000000, "step": 1}),
240+
},
241+
}
242+
243+
RETURN_TYPES = ("IMAGE", "INT", "VHS_VIDEOINFO")
244+
RETURN_NAMES = ("frames", "frame_count", "video_info")
245+
FUNCTION = "load_video_from_url"
246+
CATEGORY = "♾️Mixlab/Video"
247+
248+
def load_video_from_url(self, url, force_rate, force_size, custom_width, custom_height, frame_load_cap, skip_first_frames, select_every_nth):
249+
# Download the video to a temporary file
250+
with tempfile.NamedTemporaryFile(delete=False, suffix=".mp4") as temp_file:
251+
response = requests.get(url, stream=True)
252+
for chunk in response.iter_content(chunk_size=8192):
253+
temp_file.write(chunk)
254+
temp_file_path = temp_file.name
255+
256+
# Load the video using OpenCV
257+
cap = cv2.VideoCapture(temp_file_path)
258+
259+
# Get video properties
260+
fps = cap.get(cv2.CAP_PROP_FPS)
261+
total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
262+
width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
263+
height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
264+
duration = total_frames / fps
265+
266+
# Calculate target size
267+
if force_size != "Disabled":
268+
if force_size == "Custom Width":
269+
new_height = int(height * (custom_width / width))
270+
new_width = custom_width
271+
elif force_size == "Custom Height":
272+
new_width = int(width * (custom_height / height))
273+
new_height = custom_height
274+
elif force_size == "Custom":
275+
new_width, new_height = custom_width, custom_height
276+
else:
277+
target_width, target_height = map(int, force_size.replace("?", "0").split("x"))
278+
if target_width == 0:
279+
new_width = int(width * (target_height / height))
280+
new_height = target_height
281+
else:
282+
new_height = int(height * (target_width / width))
283+
new_width = target_width
284+
else:
285+
new_width, new_height = width, height
286+
287+
frames = []
288+
frame_count = 0
289+
290+
for i in range(total_frames):
291+
ret, frame = cap.read()
292+
if not ret:
293+
break
294+
295+
if i < skip_first_frames:
296+
continue
297+
298+
if (i - skip_first_frames) % select_every_nth != 0:
299+
continue
300+
301+
if force_size != "Disabled":
302+
frame = cv2.resize(frame, (new_width, new_height))
303+
304+
frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
305+
frame = torch.from_numpy(frame).float() / 255.0
306+
frames.append(frame)
307+
308+
frame_count += 1
309+
310+
if frame_load_cap > 0 and frame_count >= frame_load_cap:
311+
break
312+
313+
cap.release()
314+
os.unlink(temp_file_path)
315+
316+
frames = torch.stack(frames)
317+
318+
video_info = {
319+
"source_fps": fps,
320+
"source_frame_count": total_frames,
321+
"source_duration": duration,
322+
"source_width": width,
323+
"source_height": height,
324+
"loaded_fps": fps if force_rate == 0 else force_rate,
325+
"loaded_frame_count": frame_count,
326+
"loaded_duration": frame_count / (fps if force_rate == 0 else force_rate),
327+
"loaded_width": new_width,
328+
"loaded_height": new_height,
329+
}
330+
331+
return (frames, frame_count, video_info)
332+

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
[project]
22
name = "comfyui-mixlab-nodes"
33
description = "3D, ScreenShareNode & FloatingVideoNode, SpeechRecognition & SpeechSynthesis, GPT, LoadImagesFromLocal, Layers, Other Nodes, ..."
4-
version = "0.45.0"
4+
version = "0.46.0"
55
license = "MIT"
66
dependencies = ["numpy", "pyOpenSSL", "watchdog", "opencv-python-headless", "matplotlib", "openai", "simple-lama-inpainting", "clip-interrogator==0.6.0", "transformers>=4.36.0", "lark-parser", "imageio-ffmpeg", "rembg[gpu]", "omegaconf==2.3.0", "Pillow>=9.5.0", "einops==0.7.0", "trimesh>=4.0.5", "huggingface-hub", "scikit-image"]
77

web/javascript/checkVersion_mixlab.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { app } from '../../../scripts/app.js'
33
const repoOwner = 'shadowcz007' // 替换为仓库的所有者
44
const repoName = 'comfyui-mixlab-nodes' // 替换为仓库的名称
55

6-
const version = 'v0.45.0'
6+
const version = 'v0.46.0'
77

88
fetch(`https://api.github.com/repos/${repoOwner}/${repoName}/releases/latest`)
99
.then(response => response.json())

0 commit comments

Comments
 (0)