Adding custom nodes
This commit is contained in:
+172
@@ -0,0 +1,172 @@
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
share/python-wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
*.py,cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
cover/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Django stuff:
|
||||
*.log
|
||||
local_settings.py
|
||||
db.sqlite3
|
||||
db.sqlite3-journal
|
||||
|
||||
# Flask stuff:
|
||||
instance/
|
||||
.webassets-cache
|
||||
|
||||
# Scrapy stuff:
|
||||
.scrapy
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
|
||||
# PyBuilder
|
||||
.pybuilder/
|
||||
target/
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
# IPython
|
||||
profile_default/
|
||||
ipython_config.py
|
||||
|
||||
# pyenv
|
||||
# For a library or package, you might want to ignore these files since the code is
|
||||
# intended to run in multiple environments; otherwise, check them in:
|
||||
.python-version
|
||||
|
||||
# pipenv
|
||||
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||
# install all needed dependencies.
|
||||
#Pipfile.lock
|
||||
|
||||
# poetry
|
||||
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||
# commonly ignored for libraries.
|
||||
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
||||
#poetry.lock
|
||||
|
||||
# pdm
|
||||
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
||||
#pdm.lock
|
||||
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
||||
# in version control.
|
||||
# https://pdm.fming.dev/#use-with-ide
|
||||
.pdm.toml
|
||||
|
||||
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
||||
__pypackages__/
|
||||
|
||||
# Celery stuff
|
||||
celerybeat-schedule
|
||||
celerybeat.pid
|
||||
|
||||
# SageMath parsed files
|
||||
*.sage.py
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
.spyproject
|
||||
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
|
||||
# mkdocs documentation
|
||||
/site
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
|
||||
# Pyre type checker
|
||||
.pyre/
|
||||
|
||||
# pytype static type analyzer
|
||||
.pytype/
|
||||
|
||||
# Cython debug symbols
|
||||
cython_debug/
|
||||
|
||||
# PyCharm
|
||||
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
||||
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||
#.idea/
|
||||
|
||||
# VS Code
|
||||
.vscode/
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
|
||||
# Windows
|
||||
Thumbs.db
|
||||
|
||||
config.ini
|
||||
node.zip
|
||||
@@ -1 +1,82 @@
|
||||
# ComfyUI-LumaAI-API
|
||||
# ComfyUI-LumaAI-API
|
||||
<p align="center">
|
||||
<img src="./assets/luma_logo.png" alt="LumaAI Logo" width="200">
|
||||
</p>
|
||||
|
||||
|
||||
This is a custom node for ComfyUI that allows you to use the Luma AI API directly in ComfyUI. Luma AI API is based on top of [Dream Machine](https://lumalabs.ai/dream-machine/api), which is a video generation model developed by Luma. For more information, see [Luma AI API Documentation](https://docs.lumalabs.ai/docs/api).
|
||||
|
||||
## Requirements
|
||||
|
||||
Before using this node, you need to have an LumaAI API key. Get one [here](https://lumalabs.ai/dream-machine/api). If you want to use images as input, you will need to host them. As a suggestion, you can use [ImgBB](https://api.imgbb.com/) to host your images.
|
||||
|
||||
## Installation
|
||||
|
||||
### Installing manually
|
||||
|
||||
1. Navigate to the `ComfyUI/custom_nodes` directory.
|
||||
|
||||
2. Clone this repository:
|
||||
```
|
||||
git clone https://github.com/lumalabs/ComfyUI-LumaAI-API.git
|
||||
```
|
||||
The path should be `ComfyUI/custom_nodes/ComfyUI-LumaAI-API/*`, where `*` represents all the files in this repo.
|
||||
|
||||
3. Run `cd ComfyUI-LumaAI-API && pip install -r requirements.txt` to install the dependencies.
|
||||
|
||||
4. If you don't want to expose your Luma API key, you can add it to the `config.ini` file and keep it empty in the node.
|
||||
|
||||
5. Start ComfyUI and enjoy using the LumaAI API node!
|
||||
|
||||
### Installing with ComfyUI-Manager
|
||||
|
||||
1. Open ComfyUI-Manager and install the LumaAI API node (ComfyUI-LumaAI-API).
|
||||
|
||||
### Installing with Comfy Registry
|
||||
|
||||
1. Run `comfy node registry-install comfyui-lumaai-api` to install the node.
|
||||
|
||||
## Nodes
|
||||
|
||||
Most of the nodes allow you to save locally the output video. If you keep the default `filename` (empty string), the video will be saved in the `outputs` folder using the `generation_id` as the name.
|
||||
|
||||
### LumaAIClient
|
||||
|
||||
This node is used to create a LumaAI client.
|
||||
|
||||
### LumaText2Video
|
||||
|
||||
This node is used to generate a video from a text prompt.
|
||||
|
||||
### LumaImage2Video
|
||||
|
||||
This node is used to generate a video from an image. The image can be used as the first or last frame.
|
||||
|
||||
### LumaInterpolateGenerations
|
||||
|
||||
This node is used to interpolate between two generations.
|
||||
|
||||
### LumaExtendGeneration
|
||||
|
||||
This node is used to extend a generation. You can choose to extend before or after the generation.
|
||||
|
||||
### LumaPreviewVideo
|
||||
|
||||
This node is used to preview a video. The video is resized to 768px to look better on ComfyUI.
|
||||
|
||||
### ImgBBUpload
|
||||
|
||||
This node is used to upload an image to ImgBB and return the URL. We need this because Luma API currently only supports image urls as input.
|
||||
To use this node, you need to have an ImgBB API key. Create an account and get one [here](https://api.imgbb.com/).
|
||||
|
||||
## Examples
|
||||
|
||||
For examples, see [workflows folder](./workflows). To use, just download the workflow json and import it into ComfyUI.
|
||||
|
||||
## API Documentation
|
||||
|
||||
For more information about the Luma AI API, see [Luma AI API Documentation](https://docs.lumalabs.ai/docs/api).
|
||||
|
||||
## Pricing
|
||||
|
||||
For pricing, see [Luma AI Pricing](https://lumalabs.ai/dream-machine/api/pricing).
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
from .py.nodes import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS
|
||||
|
||||
WEB_DIRECTORY = "./web"
|
||||
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"]
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 170 KiB |
@@ -0,0 +1,2 @@
|
||||
[API]
|
||||
LUMAAI_API_KEY =
|
||||
@@ -0,0 +1,73 @@
|
||||
# Based on https://github.com/revirevy/Comfyui_saveimage_imgbb/blob/main/ImgBBUploader.py
|
||||
import requests
|
||||
import base64
|
||||
from PIL import Image
|
||||
import io
|
||||
import numpy as np
|
||||
|
||||
|
||||
class ImgBBUpload:
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"image": ("IMAGE",),
|
||||
"api_key": ("STRING", {"default": "", "multiline": False}),
|
||||
"expire": ("BOOLEAN", {"default": False}),
|
||||
"expiration_time": (
|
||||
"INT",
|
||||
{"default": 60, "min": 60, "max": 15552000, "step": 1},
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("STRING",)
|
||||
RETURN_NAMES = ("image_url",)
|
||||
|
||||
FUNCTION = "upload"
|
||||
|
||||
CATEGORY = "image/upload"
|
||||
|
||||
def upload(self, image, api_key, expire, expiration_time):
|
||||
"""
|
||||
Upload an image to ImgBB and return the URL.
|
||||
"""
|
||||
if not api_key:
|
||||
raise ValueError("API Key is required")
|
||||
|
||||
# Convert image tensor to PIL Image
|
||||
img = image[0]
|
||||
img = 255.0 * img.cpu().numpy()
|
||||
img = Image.fromarray(np.clip(img, 0, 255).astype(np.uint8))
|
||||
|
||||
# Save PIL Image to buffer
|
||||
buffer = io.BytesIO()
|
||||
img.save(buffer, format="PNG")
|
||||
buffer.seek(0)
|
||||
|
||||
base64_image = base64.b64encode(buffer.getvalue()).decode("utf-8")
|
||||
|
||||
url = f"https://api.imgbb.com/1/upload?key={api_key}"
|
||||
if expire:
|
||||
url += f"&expiration={expiration_time}"
|
||||
|
||||
payload = {
|
||||
"image": base64_image,
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.post(url, data=payload)
|
||||
result = response.json()
|
||||
|
||||
if result.get("success"):
|
||||
return result["data"]["url"], result["data"]["delete_url"]
|
||||
else:
|
||||
error_message = result.get("error", {}).get("message", "Unknown error")
|
||||
raise ValueError(f"Error: {error_message}")
|
||||
except Exception as e:
|
||||
raise ValueError(f"Error: {str(e)}")
|
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS = {"ImgBBUpload": ImgBBUpload}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {"ImgBBUpload": "Upload to ImgBB"}
|
||||
@@ -0,0 +1,401 @@
|
||||
import configparser
|
||||
import os
|
||||
import requests
|
||||
import time
|
||||
from lumaai import LumaAI
|
||||
|
||||
import folder_paths
|
||||
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
parent_dir = os.path.dirname(current_dir)
|
||||
config_path = os.path.join(parent_dir, "config.ini")
|
||||
|
||||
config = configparser.ConfigParser()
|
||||
config.read(config_path)
|
||||
|
||||
try:
|
||||
luma_api_key = config['API']['LUMAAI_API_KEY']
|
||||
if luma_api_key != "":
|
||||
os.environ["LUMAAI_API_KEY"] = luma_api_key
|
||||
else:
|
||||
print("Warning: LUMAAI_API_KEY is empty in config.ini")
|
||||
except KeyError:
|
||||
print("Error: LUMAAI_API_KEY not found in config.ini")
|
||||
|
||||
|
||||
def download_file(url, file_name):
|
||||
response = requests.get(url, stream=True)
|
||||
with open(file_name, "wb") as file:
|
||||
file.write(response.content)
|
||||
print(f"File downloaded as {file_name}")
|
||||
|
||||
def parse_filename(filename):
|
||||
# Remove file extension if present
|
||||
filename = os.path.splitext(filename)[0]
|
||||
|
||||
# Check if the filename ends with a directory separator
|
||||
if filename.endswith(os.path.sep):
|
||||
# If it does, we'll use the generation_id as the filename later
|
||||
directory = filename
|
||||
filename = ""
|
||||
else:
|
||||
# Otherwise, split the path into directory and filename
|
||||
directory, filename = os.path.split(filename)
|
||||
|
||||
# Create directories if they don't exist
|
||||
output_path = folder_paths.get_output_directory()
|
||||
full_directory = os.path.join(output_path, directory)
|
||||
if directory and not os.path.exists(full_directory):
|
||||
os.makedirs(full_directory)
|
||||
|
||||
return directory, filename
|
||||
|
||||
|
||||
class LumaAIClient:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"api_key": (
|
||||
"STRING",
|
||||
{
|
||||
"default": "",
|
||||
},
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("LUMACLIENT",)
|
||||
RETURN_NAMES = ("client",)
|
||||
FUNCTION = "run"
|
||||
CATEGORY = "LumaAI"
|
||||
|
||||
def run(self, api_key):
|
||||
"""
|
||||
Create a LumaAI client with the provided API key.
|
||||
"""
|
||||
api_key = api_key if api_key != "" else os.environ.get("LUMAAI_API_KEY", "")
|
||||
|
||||
if api_key == "":
|
||||
raise ValueError("API Key is required")
|
||||
|
||||
client = LumaAI(
|
||||
auth_token=api_key,
|
||||
)
|
||||
|
||||
return (client,)
|
||||
|
||||
|
||||
class Text2Video:
|
||||
def __init__(self):
|
||||
self.output_dir = folder_paths.get_output_directory()
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"client": ("LUMACLIENT", {"forceInput": True}),
|
||||
"prompt": ("STRING", {"multiline": True, "default": ""}),
|
||||
"loop": ("BOOLEAN", {"default": False}),
|
||||
"aspect_ratio": (["9:16", "3:4", "1:1", "4:3", "16:9", "21:9"],),
|
||||
"save": ("BOOLEAN", {"default": True}),
|
||||
},
|
||||
"optional": {"filename": ("STRING", {"default": ""})},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("STRING", "STRING")
|
||||
RETURN_NAMES = ("video_url", "generation_id")
|
||||
OUTPUT_NODE = True
|
||||
FUNCTION = "run"
|
||||
CATEGORY = "LumaAI"
|
||||
|
||||
def run(self, client, prompt, loop, aspect_ratio, save, filename):
|
||||
"""
|
||||
Generate a video from a text prompt.
|
||||
"""
|
||||
if prompt == "":
|
||||
raise ValueError("Prompt is required")
|
||||
|
||||
generation = client.generations.create(
|
||||
prompt=prompt, loop=loop, aspect_ratio=aspect_ratio
|
||||
)
|
||||
generation_id = generation.id
|
||||
completed = False
|
||||
while not completed:
|
||||
generation = client.generations.get(id=generation_id)
|
||||
if generation.state == "completed":
|
||||
completed = True
|
||||
elif generation.state == "failed":
|
||||
raise ValueError(f"Generation failed: {generation.failure_reason}")
|
||||
time.sleep(3)
|
||||
|
||||
video_url = generation.assets.video
|
||||
if save:
|
||||
directory, filename = parse_filename(filename)
|
||||
if filename == "":
|
||||
filename = generation_id
|
||||
download_file(video_url, os.path.join(self.output_dir, directory, filename + ".mp4"))
|
||||
|
||||
return {
|
||||
"ui": {"text": [generation_id]},
|
||||
"result": (
|
||||
video_url,
|
||||
generation_id,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
class Image2Video:
|
||||
def __init__(self):
|
||||
self.output_dir = folder_paths.get_output_directory()
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"client": ("LUMACLIENT", {"forceInput": True}),
|
||||
"prompt": ("STRING", {"multiline": True, "default": ""}),
|
||||
"loop": ("BOOLEAN", {"default": False}),
|
||||
"save": ("BOOLEAN", {"default": True}),
|
||||
},
|
||||
"optional": {
|
||||
"init_image_url": ("STRING", {"default": "", "forceInput": True}),
|
||||
"final_image_url": ("STRING", {"default": "", "forceInput": True}),
|
||||
"filename": ("STRING", {"default": ""}),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("STRING", "STRING")
|
||||
RETURN_NAMES = ("video_url", "generation_id")
|
||||
FUNCTION = "run"
|
||||
CATEGORY = "LumaAI"
|
||||
|
||||
def run(
|
||||
self,
|
||||
client,
|
||||
prompt,
|
||||
loop,
|
||||
save,
|
||||
init_image_url="",
|
||||
final_image_url="",
|
||||
filename="",
|
||||
):
|
||||
"""
|
||||
Generate a video from an image prompt.
|
||||
"""
|
||||
if init_image_url == "" and final_image_url == "":
|
||||
raise ValueError("At least one image URL is required")
|
||||
|
||||
keyframes = {}
|
||||
if init_image_url != "":
|
||||
keyframes["frame0"] = {"type": "image", "url": init_image_url}
|
||||
if final_image_url != "":
|
||||
keyframes["frame1"] = {"type": "image", "url": final_image_url}
|
||||
|
||||
generation = client.generations.create(
|
||||
prompt=prompt, loop=loop, keyframes=keyframes
|
||||
)
|
||||
generation_id = generation.id
|
||||
completed = False
|
||||
while not completed:
|
||||
generation = client.generations.get(id=generation_id)
|
||||
if generation.state == "completed":
|
||||
completed = True
|
||||
elif generation.state == "failed":
|
||||
raise ValueError(f"Generation failed: {generation.failure_reason}")
|
||||
time.sleep(3)
|
||||
|
||||
video_url = generation.assets.video
|
||||
if save:
|
||||
directory, filename = parse_filename(filename)
|
||||
if filename == "":
|
||||
filename = generation_id
|
||||
download_file(video_url, os.path.join(self.output_dir, directory, filename + ".mp4"))
|
||||
|
||||
return {
|
||||
"ui": {"text": [generation_id]},
|
||||
"result": (
|
||||
video_url,
|
||||
generation_id,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
class InterpolateGenerations:
|
||||
def __init__(self):
|
||||
self.output_dir = folder_paths.get_output_directory()
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"client": ("LUMACLIENT", {"forceInput": True}),
|
||||
"prompt": ("STRING", {"multiline": True, "default": ""}),
|
||||
"save": ("BOOLEAN", {"default": True}),
|
||||
"generation_id_1": ("STRING", {"default": "", "forceInput": True}),
|
||||
"generation_id_2": ("STRING", {"default": "", "forceInput": True}),
|
||||
},
|
||||
"optional": {"filename": ("STRING", {"default": ""})},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("STRING", "STRING")
|
||||
RETURN_NAMES = ("video_url", "generation_id")
|
||||
FUNCTION = "run"
|
||||
CATEGORY = "LumaAI"
|
||||
|
||||
def run(
|
||||
self,
|
||||
client,
|
||||
prompt,
|
||||
loop,
|
||||
aspect_ratio,
|
||||
save,
|
||||
generation_id_1,
|
||||
generation_id_2,
|
||||
filename="",
|
||||
):
|
||||
"""
|
||||
Generate a video by interpolating between two existing generations.
|
||||
"""
|
||||
if not generation_id_1 or not generation_id_2:
|
||||
raise ValueError("Both generation IDs are required")
|
||||
|
||||
generation = client.generations.create(
|
||||
prompt=prompt,
|
||||
loop=loop,
|
||||
aspect_ratio=aspect_ratio,
|
||||
keyframes={
|
||||
"frame0": {"type": "generation", "id": generation_id_1},
|
||||
"frame1": {"type": "generation", "id": generation_id_2},
|
||||
},
|
||||
)
|
||||
generation_id = generation.id
|
||||
completed = False
|
||||
while not completed:
|
||||
generation = client.generations.get(id=generation_id)
|
||||
if generation.state == "completed":
|
||||
completed = True
|
||||
elif generation.state == "failed":
|
||||
raise ValueError(f"Generation failed: {generation.failure_reason}")
|
||||
time.sleep(3)
|
||||
|
||||
video_url = generation.assets.video
|
||||
if save:
|
||||
directory, filename = parse_filename(filename)
|
||||
if filename == "":
|
||||
filename = generation_id
|
||||
download_file(video_url, os.path.join(self.output_dir, directory, filename + ".mp4"))
|
||||
|
||||
return {
|
||||
"ui": {"text": [generation_id]},
|
||||
"result": (
|
||||
video_url,
|
||||
generation_id,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
class ExtendGeneration:
|
||||
def __init__(self):
|
||||
self.output_dir = folder_paths.get_output_directory()
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"client": ("LUMACLIENT", {"forceInput": True}),
|
||||
"prompt": ("STRING", {"multiline": True, "default": ""}),
|
||||
"save": ("BOOLEAN", {"default": True}),
|
||||
},
|
||||
"optional": {
|
||||
"init_image_url": ("STRING", {"default": "", "forceInput": True}),
|
||||
"final_image_url": ("STRING", {"default": "", "forceInput": True}),
|
||||
"init_generation_id": ("STRING", {"default": "", "forceInput": True}),
|
||||
"final_generation_id": ("STRING", {"default": "", "forceInput": True}),
|
||||
"filename": ("STRING", {"default": ""}),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("STRING", "STRING")
|
||||
RETURN_NAMES = ("video_url", "generation_id")
|
||||
FUNCTION = "run"
|
||||
CATEGORY = "LumaAI"
|
||||
|
||||
def run(
|
||||
self,
|
||||
client,
|
||||
prompt,
|
||||
save,
|
||||
init_image_url="",
|
||||
final_image_url="",
|
||||
init_generation_id="",
|
||||
final_generation_id="",
|
||||
filename="",
|
||||
):
|
||||
"""
|
||||
Generate a video by extending from an image to an existing generation.
|
||||
"""
|
||||
if not init_generation_id and not final_generation_id:
|
||||
raise ValueError("You must provide at least one generation id")
|
||||
if init_image_url and init_generation_id:
|
||||
raise ValueError(
|
||||
"You cannot provide both an init image and a init generation"
|
||||
)
|
||||
if final_image_url and final_generation_id:
|
||||
raise ValueError(
|
||||
"You cannot provide both a final image and a final generation"
|
||||
)
|
||||
|
||||
keyframes = {}
|
||||
if init_image_url != "":
|
||||
keyframes["frame0"] = {"type": "image", "url": init_image_url}
|
||||
if final_image_url != "":
|
||||
keyframes["frame1"] = {"type": "image", "url": final_image_url}
|
||||
if init_generation_id != "":
|
||||
keyframes["frame0"] = {"type": "generation", "id": init_generation_id}
|
||||
if final_generation_id != "":
|
||||
keyframes["frame1"] = {"type": "generation", "id": final_generation_id}
|
||||
|
||||
generation = client.generations.create(prompt=prompt, keyframes=keyframes)
|
||||
new_generation_id = generation.id
|
||||
completed = False
|
||||
while not completed:
|
||||
generation = client.generations.get(id=new_generation_id)
|
||||
if generation.state == "completed":
|
||||
completed = True
|
||||
elif generation.state == "failed":
|
||||
raise ValueError(f"Generation failed: {generation.failure_reason}")
|
||||
time.sleep(3)
|
||||
|
||||
video_url = generation.assets.video
|
||||
if save:
|
||||
directory, filename = parse_filename(filename)
|
||||
if filename == "":
|
||||
filename = new_generation_id
|
||||
download_file(video_url, os.path.join(self.output_dir, directory, filename + ".mp4"))
|
||||
|
||||
return {
|
||||
"ui": {"text": [new_generation_id]},
|
||||
"result": (
|
||||
video_url,
|
||||
new_generation_id,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
class PreviewVideo:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"video_url": ("STRING", {"forceInput": True}),
|
||||
}
|
||||
}
|
||||
|
||||
OUTPUT_NODE = True
|
||||
FUNCTION = "run"
|
||||
CATEGORY = "LumaAI"
|
||||
RETURN_TYPES = ()
|
||||
|
||||
def run(self, video_url):
|
||||
return {"ui": {"video_url": [video_url]}}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
from .lumaai_api_node import (
|
||||
LumaAIClient,
|
||||
Text2Video,
|
||||
Image2Video,
|
||||
InterpolateGenerations,
|
||||
ExtendGeneration,
|
||||
PreviewVideo,
|
||||
)
|
||||
from .imgbb_node import ImgBBUpload
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"LumaAIClient": LumaAIClient,
|
||||
"ImgBBUpload": ImgBBUpload,
|
||||
"LumaText2Video": Text2Video,
|
||||
"LumaImage2Video": Image2Video,
|
||||
"LumaInterpolateGenerations": InterpolateGenerations,
|
||||
"LumaExtendGeneration": ExtendGeneration,
|
||||
"LumaPreviewVideo": PreviewVideo,
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"LumaAIClient": "LumaAI Client",
|
||||
"ImgBBUpload": "ImgBB Upload",
|
||||
"LumaText2Video": "Text to Video",
|
||||
"LumaImage2Video": "Image to Video",
|
||||
"LumaInterpolateGenerations": "Interpolate Generations",
|
||||
"LumaExtendGeneration": "Extend Generation",
|
||||
"LumaPreviewVideo": "LumaAI Preview Video",
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
[project]
|
||||
name = "comfyui-lumaai-api"
|
||||
description = "This custom node allows you to use the LumaAI API."
|
||||
version = "1.0.1"
|
||||
license = {file = "LICENSE"}
|
||||
dependencies = ["lumaai", "requests", "numpy", "Pillow"]
|
||||
|
||||
[project.urls]
|
||||
Repository = "https://github.com/lumalabs/ComfyUI-LumaAI-API"
|
||||
# Used by Comfy Registry https://comfyregistry.org
|
||||
|
||||
[tool.comfy]
|
||||
PublisherId = "lumaai"
|
||||
DisplayName = "ComfyUI-LumaAI-API"
|
||||
Icon = "https://cdn-luma.com/public/lumalabs.ai/images/logo.png"
|
||||
@@ -0,0 +1,4 @@
|
||||
lumaai
|
||||
requests
|
||||
numpy
|
||||
Pillow
|
||||
@@ -0,0 +1,154 @@
|
||||
// Based on https://github.com/ArtVentureX/comfyui-animatediff/blob/main/web/js/vid_preview.js
|
||||
import { app, ANIM_PREVIEW_WIDGET } from '../../../scripts/app.js';
|
||||
import { createImageHost } from "../../../scripts/ui/imagePreview.js"
|
||||
|
||||
const BASE_SIZE = 768;
|
||||
|
||||
function setVideoDimensions(videoElement, width, height) {
|
||||
videoElement.style.width = `${width}px`;
|
||||
videoElement.style.height = `${height}px`;
|
||||
}
|
||||
|
||||
// Resize video maintaining aspect ratio
|
||||
export function resizeVideoAspectRatio(videoElement, maxWidth, maxHeight) {
|
||||
const aspectRatio = videoElement.videoWidth / videoElement.videoHeight;
|
||||
let newWidth, newHeight;
|
||||
|
||||
// Check which dimension is the limiting factor
|
||||
if (videoElement.videoWidth / maxWidth > videoElement.videoHeight / maxHeight) {
|
||||
// Width is the limiting factor
|
||||
newWidth = maxWidth;
|
||||
newHeight = newWidth / aspectRatio;
|
||||
} else {
|
||||
// Height is the limiting factor
|
||||
newHeight = maxHeight;
|
||||
newWidth = newHeight * aspectRatio;
|
||||
}
|
||||
|
||||
setVideoDimensions(videoElement, newWidth, newHeight);
|
||||
}
|
||||
|
||||
export function chainCallback(object, property, callback) {
|
||||
if (object == undefined) {
|
||||
//This should not happen.
|
||||
console.error("Tried to add callback to non-existant object");
|
||||
return;
|
||||
}
|
||||
if (property in object) {
|
||||
const callback_orig = object[property];
|
||||
object[property] = function () {
|
||||
const r = callback_orig.apply(this, arguments);
|
||||
callback.apply(this, arguments);
|
||||
return r;
|
||||
};
|
||||
} else {
|
||||
object[property] = callback;
|
||||
}
|
||||
};
|
||||
|
||||
export function addVideoPreview(nodeType, options = {}) {
|
||||
const createVideoNode = (url) => {
|
||||
return new Promise((cb) => {
|
||||
const videoEl = document.createElement('video');
|
||||
videoEl.addEventListener('loadedmetadata', () => {
|
||||
videoEl.controls = false;
|
||||
videoEl.loop = true;
|
||||
videoEl.muted = true;
|
||||
resizeVideoAspectRatio(videoEl, BASE_SIZE, BASE_SIZE);
|
||||
cb(videoEl);
|
||||
});
|
||||
videoEl.addEventListener('error', () => {
|
||||
cb();
|
||||
});
|
||||
videoEl.src = url;
|
||||
});
|
||||
};
|
||||
|
||||
nodeType.prototype.onDrawBackground = function (ctx) {
|
||||
if (this.flags.collapsed) return;
|
||||
|
||||
let imageURLs = this.images ?? [];
|
||||
let imagesChanged = false;
|
||||
|
||||
if (JSON.stringify(this.displayingImages) !== JSON.stringify(imageURLs)) {
|
||||
this.displayingImages = imageURLs;
|
||||
imagesChanged = true;
|
||||
}
|
||||
|
||||
if (!imagesChanged) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!imageURLs.length) {
|
||||
this.imgs = null;
|
||||
this.animatedImages = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const promises = imageURLs.map((url) => {
|
||||
return createVideoNode(url);
|
||||
});
|
||||
|
||||
Promise.all(promises)
|
||||
.then((imgs) => {
|
||||
this.imgs = imgs.filter(Boolean);
|
||||
})
|
||||
.then(() => {
|
||||
if (!this.imgs.length) return;
|
||||
|
||||
this.animatedImages = true;
|
||||
const widgetIdx = this.widgets?.findIndex((w) => w.name === ANIM_PREVIEW_WIDGET);
|
||||
|
||||
// Set node size to 1024x1024
|
||||
this.size[0] = BASE_SIZE;
|
||||
this.size[1] = BASE_SIZE;
|
||||
|
||||
if (widgetIdx > -1) {
|
||||
// Replace content
|
||||
const widget = this.widgets[widgetIdx];
|
||||
widget.options.host.updateImages(this.imgs);
|
||||
} else {
|
||||
const host = createImageHost(this);
|
||||
const widget = this.addDOMWidget(ANIM_PREVIEW_WIDGET, 'img', host.el, {
|
||||
host,
|
||||
getHeight: host.getHeight,
|
||||
onDraw: host.onDraw,
|
||||
hideOnZoom: false,
|
||||
});
|
||||
widget.serializeValue = () => ({
|
||||
height: BASE_SIZE,
|
||||
});
|
||||
|
||||
widget.options.host.updateImages(this.imgs);
|
||||
}
|
||||
|
||||
this.imgs.forEach((img) => {
|
||||
if (img instanceof HTMLVideoElement) {
|
||||
img.muted = true;
|
||||
img.autoplay = true;
|
||||
img.play();
|
||||
}
|
||||
});
|
||||
|
||||
// Force canvas update
|
||||
this.setDirtyCanvas(true, true);
|
||||
});
|
||||
};
|
||||
|
||||
chainCallback(nodeType.prototype, "onExecuted", function (message) {
|
||||
if (message?.video_url) {
|
||||
this.images = message?.video_url;
|
||||
this.setDirtyCanvas(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
app.registerExtension({
|
||||
name: "VideoPreview",
|
||||
async beforeRegisterNodeDef(nodeType, nodeData) {
|
||||
if (nodeData.name !== "LumaPreviewVideo") {
|
||||
return;
|
||||
}
|
||||
addVideoPreview(nodeType);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
//Based on https://github.com/pythongosssss/ComfyUI-Custom-Scripts/blob/main/web/js/showText.js
|
||||
|
||||
import { app } from "../../../scripts/app.js";
|
||||
import { ComfyWidgets } from "../../../scripts/widgets.js";
|
||||
|
||||
// Displays input text on a node
|
||||
app.registerExtension({
|
||||
name: "lumaai.showgenerationid",
|
||||
async beforeRegisterNodeDef(nodeType, nodeData, app) {
|
||||
if (nodeData.name == "LumaText2Video" || nodeData.name == "LumaImage2Video" || nodeData.name == "LumaInterpolateGenerations" || nodeData.name == "LumaExtendGeneration") {
|
||||
function populate(text) {
|
||||
const v = [...text];
|
||||
if (this.widgets) {
|
||||
if (this.widgets[this.widgets.length - 1].name == "filename") {
|
||||
const w = ComfyWidgets["STRING"](this, "gen_output", ["STRING", { multiline: true }], app).widget;
|
||||
w.inputEl.readOnly = true;
|
||||
w.inputEl.style.opacity = 0.6;
|
||||
w.value = v[0];
|
||||
this.widgets.push(w);
|
||||
} else {
|
||||
this.widgets[this.widgets.length - 1].value = v[0];
|
||||
}
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const sz = this.computeSize();
|
||||
if (sz[0] < this.size[0]) {
|
||||
sz[0] = this.size[0];
|
||||
}
|
||||
if (sz[1] < this.size[1]) {
|
||||
sz[1] = this.size[1];
|
||||
}
|
||||
this.onResize?.(sz);
|
||||
app.graph.setDirtyCanvas(true, false);
|
||||
});
|
||||
}
|
||||
|
||||
// When the node is executed we will be sent the input text, display this in the widget
|
||||
const onExecuted = nodeType.prototype.onExecuted;
|
||||
nodeType.prototype.onExecuted = function (message) {
|
||||
onExecuted?.apply(this, arguments);
|
||||
populate.call(this, message.text);
|
||||
};
|
||||
|
||||
const onExecutionStart = nodeType.prototype.onExecutionStart;
|
||||
nodeType.prototype.onExecutionStart = function () {
|
||||
onExecutionStart?.apply(this, arguments);
|
||||
populate.call(this, ["generating..."]);
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
{
|
||||
"22": {
|
||||
"inputs": {
|
||||
"api_key": "",
|
||||
"expire": false,
|
||||
"expiration_time": 60,
|
||||
"image": [
|
||||
"23",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "ImgBBUpload",
|
||||
"_meta": {
|
||||
"title": "ImgBB Upload"
|
||||
}
|
||||
},
|
||||
"23": {
|
||||
"inputs": {
|
||||
"image": "IMG_9655.JPG",
|
||||
"upload": "image"
|
||||
},
|
||||
"class_type": "LoadImage",
|
||||
"_meta": {
|
||||
"title": "Load Image"
|
||||
}
|
||||
},
|
||||
"25": {
|
||||
"inputs": {
|
||||
"api_key": ""
|
||||
},
|
||||
"class_type": "LumaAIClient",
|
||||
"_meta": {
|
||||
"title": "LumaAI Client"
|
||||
}
|
||||
},
|
||||
"26": {
|
||||
"inputs": {
|
||||
"video_url": [
|
||||
"28",
|
||||
0
|
||||
],
|
||||
"$$comfy_animation_preview": {
|
||||
"height": 768
|
||||
}
|
||||
},
|
||||
"class_type": "LumaPreviewVideo",
|
||||
"_meta": {
|
||||
"title": "LumaAI Preview Video"
|
||||
}
|
||||
},
|
||||
"28": {
|
||||
"inputs": {
|
||||
"prompt": "",
|
||||
"loop": false,
|
||||
"aspect_ratio": "9:16",
|
||||
"save": true,
|
||||
"init_image_url": [
|
||||
"22",
|
||||
0
|
||||
],
|
||||
"filename": "",
|
||||
"gen_output": "generating...",
|
||||
"client": [
|
||||
"25",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "LumaImage2Video",
|
||||
"_meta": {
|
||||
"title": "Image to Video"
|
||||
}
|
||||
},
|
||||
"29": {
|
||||
"inputs": {
|
||||
"prompt": " ",
|
||||
"save": true,
|
||||
"init_generation_id": [
|
||||
"28",
|
||||
1
|
||||
],
|
||||
"filename": "",
|
||||
"gen_output": "generating...",
|
||||
"client": [
|
||||
"25",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "LumaExtendGeneration",
|
||||
"_meta": {
|
||||
"title": "Extend Generation"
|
||||
}
|
||||
},
|
||||
"31": {
|
||||
"inputs": {
|
||||
"video_url": [
|
||||
"29",
|
||||
0
|
||||
],
|
||||
"$$comfy_animation_preview": {
|
||||
"height": 768
|
||||
}
|
||||
},
|
||||
"class_type": "LumaPreviewVideo",
|
||||
"_meta": {
|
||||
"title": "LumaAI Preview Video"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
{
|
||||
"last_node_id": 28,
|
||||
"last_link_id": 17,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 23,
|
||||
"type": "LoadImage",
|
||||
"pos": [
|
||||
18,
|
||||
590
|
||||
],
|
||||
"size": {
|
||||
"0": 315,
|
||||
"1": 314
|
||||
},
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": [
|
||||
10
|
||||
],
|
||||
"shape": 3,
|
||||
"slot_index": 0
|
||||
},
|
||||
{
|
||||
"name": "MASK",
|
||||
"type": "MASK",
|
||||
"links": null,
|
||||
"shape": 3
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "LoadImage"
|
||||
},
|
||||
"widgets_values": [
|
||||
"IMG_9655.JPG",
|
||||
"image"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 25,
|
||||
"type": "LumaAIClient",
|
||||
"pos": [
|
||||
30,
|
||||
344
|
||||
],
|
||||
"size": {
|
||||
"0": 315,
|
||||
"1": 58
|
||||
},
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"outputs": [
|
||||
{
|
||||
"name": "client",
|
||||
"type": "LUMACLIENT",
|
||||
"links": [
|
||||
16
|
||||
],
|
||||
"shape": 3,
|
||||
"slot_index": 0
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "LumaAIClient"
|
||||
},
|
||||
"widgets_values": [
|
||||
""
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 28,
|
||||
"type": "LumaImage2Video",
|
||||
"pos": [
|
||||
727,
|
||||
344
|
||||
],
|
||||
"size": [
|
||||
382.09642410650736,
|
||||
212
|
||||
],
|
||||
"flags": {},
|
||||
"order": 3,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "client",
|
||||
"type": "LUMACLIENT",
|
||||
"link": 16
|
||||
},
|
||||
{
|
||||
"name": "init_image_url",
|
||||
"type": "STRING",
|
||||
"link": 15,
|
||||
"widget": {
|
||||
"name": "init_image_url"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "final_image_url",
|
||||
"type": "STRING",
|
||||
"link": null,
|
||||
"widget": {
|
||||
"name": "final_image_url"
|
||||
}
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "video_url",
|
||||
"type": "STRING",
|
||||
"links": [
|
||||
17
|
||||
],
|
||||
"shape": 3,
|
||||
"slot_index": 0
|
||||
},
|
||||
{
|
||||
"name": "generation_id",
|
||||
"type": "STRING",
|
||||
"links": null,
|
||||
"shape": 3
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "LumaImage2Video"
|
||||
},
|
||||
"widgets_values": [
|
||||
"",
|
||||
false,
|
||||
"9:16",
|
||||
true,
|
||||
"",
|
||||
"",
|
||||
""
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 26,
|
||||
"type": "LumaPreviewVideo",
|
||||
"pos": [
|
||||
1228,
|
||||
342
|
||||
],
|
||||
"size": [
|
||||
312.26042261370117,
|
||||
264.63415629022575
|
||||
],
|
||||
"flags": {},
|
||||
"order": 4,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "video_url",
|
||||
"type": "STRING",
|
||||
"link": 17,
|
||||
"widget": {
|
||||
"name": "video_url"
|
||||
},
|
||||
"slot_index": 0
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "LumaPreviewVideo"
|
||||
},
|
||||
"widgets_values": [
|
||||
""
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 22,
|
||||
"type": "ImgBBUpload",
|
||||
"pos": [
|
||||
363,
|
||||
589
|
||||
],
|
||||
"size": {
|
||||
"0": 315,
|
||||
"1": 106
|
||||
},
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "image",
|
||||
"type": "IMAGE",
|
||||
"link": 10
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "image_url",
|
||||
"type": "STRING",
|
||||
"links": [
|
||||
15
|
||||
],
|
||||
"shape": 3,
|
||||
"slot_index": 0
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "ImgBBUpload"
|
||||
},
|
||||
"widgets_values": [
|
||||
"",
|
||||
false,
|
||||
60
|
||||
]
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[
|
||||
10,
|
||||
23,
|
||||
0,
|
||||
22,
|
||||
0,
|
||||
"IMAGE"
|
||||
],
|
||||
[
|
||||
15,
|
||||
22,
|
||||
0,
|
||||
28,
|
||||
1,
|
||||
"STRING"
|
||||
],
|
||||
[
|
||||
16,
|
||||
25,
|
||||
0,
|
||||
28,
|
||||
0,
|
||||
"LUMACLIENT"
|
||||
],
|
||||
[
|
||||
17,
|
||||
28,
|
||||
0,
|
||||
26,
|
||||
0,
|
||||
"STRING"
|
||||
]
|
||||
],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 0.6115909044841499,
|
||||
"offset": [
|
||||
493.78857374805784,
|
||||
-7.162115379659536
|
||||
]
|
||||
}
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
{
|
||||
"last_node_id": 30,
|
||||
"last_link_id": 16,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 30,
|
||||
"type": "LumaPreviewVideo",
|
||||
"pos": [
|
||||
1033,
|
||||
378
|
||||
],
|
||||
"size": {
|
||||
"0": 768,
|
||||
"1": 768
|
||||
},
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "video_url",
|
||||
"type": "STRING",
|
||||
"link": 16,
|
||||
"widget": {
|
||||
"name": "video_url"
|
||||
}
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "LumaPreviewVideo"
|
||||
},
|
||||
"widgets_values": [
|
||||
"",
|
||||
null
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 28,
|
||||
"type": "LumaText2Video",
|
||||
"pos": [
|
||||
526,
|
||||
378
|
||||
],
|
||||
"size": [
|
||||
400.22849849128625,
|
||||
198.76139540935253
|
||||
],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "client",
|
||||
"type": "LUMACLIENT",
|
||||
"link": 15
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "video_url",
|
||||
"type": "STRING",
|
||||
"links": [
|
||||
16
|
||||
],
|
||||
"shape": 3,
|
||||
"slot_index": 0
|
||||
},
|
||||
{
|
||||
"name": "generation_id",
|
||||
"type": "STRING",
|
||||
"links": null,
|
||||
"shape": 3
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "LumaText2Video"
|
||||
},
|
||||
"widgets_values": [
|
||||
"Extreme closeup footage of a young sailor woman with a concerned expression during a rainstorm.",
|
||||
true,
|
||||
"9:16",
|
||||
false,
|
||||
""
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 29,
|
||||
"type": "LumaAIClient",
|
||||
"pos": [
|
||||
124,
|
||||
375
|
||||
],
|
||||
"size": {
|
||||
"0": 315,
|
||||
"1": 58
|
||||
},
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"outputs": [
|
||||
{
|
||||
"name": "client",
|
||||
"type": "LUMACLIENT",
|
||||
"links": [
|
||||
15
|
||||
],
|
||||
"shape": 3,
|
||||
"slot_index": 0
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "LumaAIClient"
|
||||
},
|
||||
"widgets_values": [
|
||||
""
|
||||
]
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[
|
||||
15,
|
||||
29,
|
||||
0,
|
||||
28,
|
||||
0,
|
||||
"LUMACLIENT"
|
||||
],
|
||||
[
|
||||
16,
|
||||
28,
|
||||
0,
|
||||
30,
|
||||
0,
|
||||
"STRING"
|
||||
]
|
||||
],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 0.8954302432552445,
|
||||
"offset": [
|
||||
-56.377797259191,
|
||||
-151.59915874422396
|
||||
]
|
||||
}
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
Reference in New Issue
Block a user