Spaces:
Running
Running
Commit
·
d948455
1
Parent(s):
68d53f7
working with progress bar
Browse files- .gitignore +2 -0
- __pycache__/video_config.cpython-311.pyc +0 -0
- __pycache__/workflow_handler.cpython-311.pyc +0 -0
- app.py +331 -41
- config.json +23 -0
- video_config.py +24 -0
- wani2v.json +258 -0
- workflow_handler.py +104 -0
.gitignore
ADDED
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
1 |
+
.env
|
2 |
+
venv/
|
__pycache__/video_config.cpython-311.pyc
ADDED
Binary file (811 Bytes). View file
|
|
__pycache__/workflow_handler.cpython-311.pyc
ADDED
Binary file (7.17 kB). View file
|
|
app.py
CHANGED
@@ -1,77 +1,367 @@
|
|
1 |
import os
|
2 |
import requests
|
3 |
import gradio as gr
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
4 |
|
|
|
|
|
|
|
|
|
|
|
5 |
|
6 |
loras = [
|
7 |
{
|
8 |
#I suggest it to be a gif instead of an mp4!
|
9 |
"image": "https://huggingface.co/Remade-AI/Squish/resolve/main/example_videos/tank_squish.mp4",
|
10 |
#This is an id you can send to your backend, obviously you can change it
|
11 |
-
"id": "
|
12 |
#This is the title that is shown on the front end
|
13 |
-
"title": "Squish"
|
|
|
|
|
14 |
},
|
15 |
{
|
16 |
"image": "https://huggingface.co/Remade-AI/Rotate/resolve/main/example_videos/man_rotate.mp4",
|
17 |
-
"id": "
|
18 |
-
"title": "Rotate"
|
|
|
19 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
20 |
|
21 |
]
|
22 |
|
23 |
-
|
24 |
-
|
25 |
-
|
26 |
-
|
27 |
-
|
28 |
-
|
29 |
-
|
30 |
-
|
31 |
-
|
32 |
-
|
33 |
-
#
|
34 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
35 |
|
36 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
37 |
|
38 |
def update_selection(evt: gr.SelectData):
|
39 |
selected_lora = loras[evt.index]
|
40 |
sentence = f"Selected LoRA: {selected_lora['title']}"
|
41 |
return selected_lora['id'], sentence
|
42 |
|
43 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
44 |
selected_index = gr.State(None)
|
|
|
|
|
45 |
gr.Markdown("# Remade AI - Wan 2.1 I2V effects LoRAs ")
|
46 |
selected_info = gr.HTML("")
|
|
|
47 |
with gr.Row():
|
48 |
-
|
49 |
-
|
50 |
-
|
51 |
-
|
52 |
-
|
53 |
-
|
54 |
-
|
55 |
-
|
56 |
-
|
57 |
-
|
58 |
-
|
59 |
-
|
60 |
-
|
61 |
-
|
62 |
-
|
63 |
-
|
64 |
-
|
65 |
-
|
|
|
66 |
gallery.select(
|
67 |
update_selection,
|
68 |
outputs=[selected_index, selected_info]
|
69 |
-
)
|
70 |
-
|
71 |
-
|
72 |
-
|
73 |
-
|
|
|
|
|
|
|
74 |
)
|
75 |
|
76 |
if __name__ == "__main__":
|
|
|
77 |
demo.launch()
|
|
|
1 |
import os
|
2 |
import requests
|
3 |
import gradio as gr
|
4 |
+
import uuid
|
5 |
+
import datetime
|
6 |
+
from supabase import create_client, Client
|
7 |
+
from supabase.lib.client_options import ClientOptions
|
8 |
+
import dotenv
|
9 |
+
from google.cloud import storage
|
10 |
+
import json
|
11 |
+
from pathlib import Path
|
12 |
+
import mimetypes
|
13 |
+
from workflow_handler import WanVideoWorkflow
|
14 |
+
from video_config import MODEL_FRAME_RATES, calculate_frames
|
15 |
+
import asyncio
|
16 |
|
17 |
+
dotenv.load_dotenv()
|
18 |
+
|
19 |
+
SCRIPT_DIR = Path(__file__).parent
|
20 |
+
CONFIG_PATH = SCRIPT_DIR / "config.json"
|
21 |
+
WORKFLOW_PATH = SCRIPT_DIR / "wani2v.json"
|
22 |
|
23 |
loras = [
|
24 |
{
|
25 |
#I suggest it to be a gif instead of an mp4!
|
26 |
"image": "https://huggingface.co/Remade-AI/Squish/resolve/main/example_videos/tank_squish.mp4",
|
27 |
#This is an id you can send to your backend, obviously you can change it
|
28 |
+
"id": "06ce6840-f976-4963-9644-b6cf7f323f90",
|
29 |
#This is the title that is shown on the front end
|
30 |
+
"title": "Squish",
|
31 |
+
|
32 |
+
"example_prompt": "In the video, a miniature rodent is presented. The rodent is held in a person's hands. The person then presses on the rodent, causing a sq41sh squish effect. The person keeps pressing down on the rodent, further showing the sq41sh squish effect.",
|
33 |
},
|
34 |
{
|
35 |
"image": "https://huggingface.co/Remade-AI/Rotate/resolve/main/example_videos/man_rotate.mp4",
|
36 |
+
"id": "4ac08cfa-841e-4aa9-9022-c3fc80fb6ef4",
|
37 |
+
"title": "Rotate",
|
38 |
+
"example_prompt": "The video shows an elderly Asian man's head and shoulders with blurred background, performing a r0t4tion 360 degrees rotation.",
|
39 |
},
|
40 |
+
{
|
41 |
+
"image": "https://huggingface.co/Remade-AI/Cakeify/resolve/main/example_videos/timberland_cakeify.mp4",
|
42 |
+
"id": "b05c1dc7-a71c-4d24-b512-4877a12dea7e",
|
43 |
+
"title": "Cakeify",
|
44 |
+
"example_prompt": "The video opens on a woman. A knife, held by a hand, is coming into frame and hovering over the woman. The knife then begins cutting into the woman to c4k3 cakeify it. As the knife slices the woman open, the inside of the woman is revealed to be cake with chocolate layers. The knife cuts through and the contents of the woman are revealed."
|
45 |
+
},
|
46 |
+
|
47 |
+
|
48 |
+
|
49 |
+
|
50 |
+
|
51 |
|
52 |
]
|
53 |
|
54 |
+
# Initialize Supabase client with async support
|
55 |
+
supabase: Client = create_client(
|
56 |
+
os.getenv('SUPABASE_URL'),
|
57 |
+
os.getenv('SUPABASE_KEY'),
|
58 |
+
|
59 |
+
)
|
60 |
+
|
61 |
+
def initialize_gcs():
|
62 |
+
"""Initialize Google Cloud Storage client with credentials from environment"""
|
63 |
+
try:
|
64 |
+
# Parse service account JSON from environment variable
|
65 |
+
service_account_json = os.getenv('SERVICE_ACCOUNT_JSON')
|
66 |
+
if not service_account_json:
|
67 |
+
raise ValueError("SERVICE_ACCOUNT_JSON environment variable not found")
|
68 |
+
|
69 |
+
credentials_info = json.loads(service_account_json)
|
70 |
+
|
71 |
+
# Initialize storage client
|
72 |
+
storage_client = storage.Client.from_service_account_info(credentials_info)
|
73 |
+
print("Successfully initialized Google Cloud Storage client")
|
74 |
+
return storage_client
|
75 |
+
except Exception as e:
|
76 |
+
print(f"Error initializing Google Cloud Storage: {e}")
|
77 |
+
raise
|
78 |
+
|
79 |
+
def upload_to_gcs(file_path, content_type=None, folder='user_uploads'):
|
80 |
+
"""
|
81 |
+
Uploads a file to Google Cloud Storage
|
82 |
+
Args:
|
83 |
+
file_path: Path to the file to upload
|
84 |
+
content_type: MIME type of the file (optional)
|
85 |
+
folder: Folder path in bucket (default: 'user_uploads')
|
86 |
+
Returns:
|
87 |
+
str: Public URL of the uploaded file
|
88 |
+
"""
|
89 |
+
try:
|
90 |
+
bucket_name = 'remade-v2'
|
91 |
+
storage_client = initialize_gcs()
|
92 |
+
bucket = storage_client.bucket(bucket_name)
|
93 |
+
|
94 |
+
# Get file extension and generate unique filename
|
95 |
+
file_extension = Path(file_path).suffix
|
96 |
+
if not content_type:
|
97 |
+
content_type = mimetypes.guess_type(file_path)[0] or 'application/octet-stream'
|
98 |
+
|
99 |
+
# Validate file type
|
100 |
+
valid_types = ['image/jpeg', 'image/png', 'image/gif']
|
101 |
+
if content_type not in valid_types:
|
102 |
+
raise ValueError("Invalid file type. Please upload a JPG, PNG or GIF image.")
|
103 |
+
|
104 |
+
# Generate unique filename with proper path structure
|
105 |
+
filename = f"{str(uuid.uuid4())}{file_extension}"
|
106 |
+
file_path_in_gcs = f"{folder}/{filename}"
|
107 |
+
|
108 |
+
# Create blob and set metadata
|
109 |
+
blob = bucket.blob(file_path_in_gcs)
|
110 |
+
blob.content_type = content_type
|
111 |
+
blob.cache_control = 'public, max-age=31536000'
|
112 |
+
|
113 |
+
print(f'Uploading file to GCS: {file_path_in_gcs}')
|
114 |
+
|
115 |
+
# Upload the file
|
116 |
+
blob.upload_from_filename(
|
117 |
+
file_path,
|
118 |
+
timeout=120 # 2 minute timeout
|
119 |
+
)
|
120 |
+
|
121 |
+
# Generate public URL with correct path format
|
122 |
+
image_url = f"https://storage.googleapis.com/{bucket_name}/{file_path_in_gcs}"
|
123 |
+
print(f"Successfully uploaded to GCS: {image_url}")
|
124 |
+
return image_url
|
125 |
+
|
126 |
+
except Exception as e:
|
127 |
+
print(f"Error uploading to GCS: {e}")
|
128 |
+
raise ValueError(f"Failed to upload image to storage: {str(e)}")
|
129 |
+
|
130 |
+
def build_lora_prompt(subject, lora_id):
|
131 |
+
"""
|
132 |
+
Builds a standardized prompt based on the selected LoRA and subject
|
133 |
+
"""
|
134 |
+
# Get LoRA config
|
135 |
+
lora_config = next((lora for lora in loras if lora["id"] == lora_id), None)
|
136 |
+
if not lora_config:
|
137 |
+
raise ValueError(f"Invalid LoRA ID: {lora_id}")
|
138 |
+
|
139 |
+
if lora_id == "06ce6840-f976-4963-9644-b6cf7f323f90": # Squish
|
140 |
+
return (
|
141 |
+
f"In the video, a miniature {subject} is presented. "
|
142 |
+
f"The {subject} is held in a person's hands. "
|
143 |
+
f"The person then presses on the {subject}, causing a sq41sh squish effect. "
|
144 |
+
f"The person keeps pressing down on the {subject}, further showing the sq41sh squish effect."
|
145 |
+
)
|
146 |
+
|
147 |
+
elif lora_id == "4ac08cfa-841e-4aa9-9022-c3fc80fb6ef4": # Rotate
|
148 |
+
return (
|
149 |
+
f"The video shows a {subject} performing a r0t4tion 360 degrees rotation."
|
150 |
+
)
|
151 |
+
|
152 |
+
elif lora_id == "b05c1dc7-a71c-4d24-b512-4877a12dea7e": # Cakeify
|
153 |
+
return (
|
154 |
+
f"The video opens on a {subject}. A knife, held by a hand, is coming into frame "
|
155 |
+
f"and hovering over the {subject}. The knife then begins cutting into the {subject} "
|
156 |
+
f"to c4k3 cakeify it. As the knife slices the {subject} open, the inside of the "
|
157 |
+
f"{subject} is revealed to be cake with chocolate layers. The knife cuts through "
|
158 |
+
f"and the contents of the {subject} are revealed."
|
159 |
+
)
|
160 |
|
161 |
+
else:
|
162 |
+
raise ValueError(f"Unknown LoRA ID: {lora_id}")
|
163 |
+
|
164 |
+
def poll_generation_status(generation_id):
|
165 |
+
"""Poll generation status from database"""
|
166 |
+
try:
|
167 |
+
# Query the database for the current status
|
168 |
+
response = supabase.table('generations') \
|
169 |
+
.select('*') \
|
170 |
+
.eq('generation_id', generation_id) \
|
171 |
+
.execute()
|
172 |
+
|
173 |
+
if not response.data:
|
174 |
+
return None
|
175 |
+
|
176 |
+
return response.data[0]
|
177 |
+
except Exception as e:
|
178 |
+
print(f"Error polling generation status: {e}")
|
179 |
+
raise e
|
180 |
+
|
181 |
+
async def generate_video(input_image, subject, duration, selected_index, progress=gr.Progress()):
|
182 |
+
try:
|
183 |
+
# Initialize workflow handler with explicit paths
|
184 |
+
workflow_handler = WanVideoWorkflow(
|
185 |
+
supabase,
|
186 |
+
config_path=str(CONFIG_PATH),
|
187 |
+
workflow_path=str(WORKFLOW_PATH)
|
188 |
+
)
|
189 |
+
|
190 |
+
# Upload image to GCS and get public URL
|
191 |
+
image_url = upload_to_gcs(input_image)
|
192 |
+
|
193 |
+
# Map duration selection to actual seconds
|
194 |
+
duration_mapping = {
|
195 |
+
"Short (3s)": 3,
|
196 |
+
"Long (5s)": 5
|
197 |
+
}
|
198 |
+
video_duration = duration_mapping[duration]
|
199 |
+
|
200 |
+
# Get LoRA config
|
201 |
+
lora_config = next((lora for lora in loras if lora["id"] == selected_index), None)
|
202 |
+
if not lora_config:
|
203 |
+
raise ValueError(f"Invalid LoRA ID: {selected_index}")
|
204 |
+
|
205 |
+
# Generate unique ID
|
206 |
+
generation_id = str(uuid.uuid4())
|
207 |
+
|
208 |
+
# Update workflow
|
209 |
+
prompt = build_lora_prompt(subject, selected_index)
|
210 |
+
workflow_handler.update_prompt(prompt)
|
211 |
+
workflow_handler.update_input_image(image_url)
|
212 |
+
await workflow_handler.update_lora(lora_config)
|
213 |
+
workflow_handler.update_length(video_duration)
|
214 |
+
workflow_handler.update_output_name(generation_id)
|
215 |
+
|
216 |
+
# Get final workflow
|
217 |
+
workflow = workflow_handler.get_workflow()
|
218 |
+
|
219 |
+
# Store generation data in Supabase
|
220 |
+
generation_data = {
|
221 |
+
"generation_id": generation_id,
|
222 |
+
"user_id": "anonymous",
|
223 |
+
"status": "queued",
|
224 |
+
"progress": 0,
|
225 |
+
"worker_id": None,
|
226 |
+
"created_at": datetime.datetime.utcnow().isoformat(),
|
227 |
+
"message": {
|
228 |
+
"generationId": generation_id,
|
229 |
+
"workflow": {
|
230 |
+
"prompt": workflow
|
231 |
+
}
|
232 |
+
},
|
233 |
+
"metadata": {
|
234 |
+
"prompt": {
|
235 |
+
"original": subject,
|
236 |
+
"enhanced": subject
|
237 |
+
},
|
238 |
+
"lora": {
|
239 |
+
"id": selected_index,
|
240 |
+
"strength": 1.0,
|
241 |
+
"name": lora_config["title"]
|
242 |
+
},
|
243 |
+
"workflow": "img2vid",
|
244 |
+
"dimensions": None,
|
245 |
+
"input_image_url": image_url,
|
246 |
+
"video_length": {"duration": video_duration},
|
247 |
+
"platform": "huggingface"
|
248 |
+
},
|
249 |
+
"error": None,
|
250 |
+
"output_url": None,
|
251 |
+
"batch_id": None
|
252 |
+
}
|
253 |
+
|
254 |
+
# Remove await - the execute() method returns the response directly
|
255 |
+
response = supabase.table('generations').insert(generation_data).execute()
|
256 |
+
print(f"Stored generation data with ID: {generation_id}")
|
257 |
+
|
258 |
+
# Return generation ID for tracking
|
259 |
+
return generation_id
|
260 |
+
|
261 |
+
except Exception as e:
|
262 |
+
print(f"Error in generate_video: {e}")
|
263 |
+
raise e
|
264 |
|
265 |
def update_selection(evt: gr.SelectData):
|
266 |
selected_lora = loras[evt.index]
|
267 |
sentence = f"Selected LoRA: {selected_lora['title']}"
|
268 |
return selected_lora['id'], sentence
|
269 |
|
270 |
+
async def handle_generation(input_image, subject, duration, selected_index, progress=gr.Progress(track_tqdm=True)):
|
271 |
+
try:
|
272 |
+
if selected_index is None:
|
273 |
+
raise gr.Error("You must select a LoRA before proceeding.")
|
274 |
+
|
275 |
+
# Generate the video and get generation ID
|
276 |
+
generation_id = await generate_video(input_image, subject, duration, selected_index)
|
277 |
+
|
278 |
+
# Poll for status updates
|
279 |
+
while True:
|
280 |
+
generation = poll_generation_status(generation_id)
|
281 |
+
|
282 |
+
if not generation:
|
283 |
+
raise ValueError(f"Generation {generation_id} not found")
|
284 |
+
|
285 |
+
# Update progress
|
286 |
+
if 'progress' in generation:
|
287 |
+
progress_value = generation['progress']
|
288 |
+
progress_bar = f'<div class="progress-container"><div class="progress-bar" style="--current: {progress_value}; --total: 100;"></div></div>'
|
289 |
+
|
290 |
+
# Check status
|
291 |
+
if generation['status'] == 'completed':
|
292 |
+
# Final yield with completed video
|
293 |
+
yield generation['output_url'], generation_id, gr.update(visible=False)
|
294 |
+
break # Exit the loop
|
295 |
+
elif generation['status'] == 'error':
|
296 |
+
raise ValueError(f"Generation failed: {generation.get('error')}")
|
297 |
+
else:
|
298 |
+
# Yield progress update
|
299 |
+
yield None, generation_id, gr.update(value=progress_bar, visible=True)
|
300 |
+
|
301 |
+
# Wait before next poll
|
302 |
+
await asyncio.sleep(2)
|
303 |
+
|
304 |
+
except Exception as e:
|
305 |
+
print(f"Error in handle_generation: {e}")
|
306 |
+
raise e
|
307 |
+
|
308 |
+
css = '''
|
309 |
+
#gen_btn{height: 100%}
|
310 |
+
#gen_column{align-self: stretch}
|
311 |
+
#title{text-align: center}
|
312 |
+
#title h1{font-size: 3em; display:inline-flex; align-items:center}
|
313 |
+
#title img{width: 100px; margin-right: 0.5em}
|
314 |
+
#gallery .grid-wrap{height: 10vh}
|
315 |
+
#lora_list{background: var(--block-background-fill);padding: 0 1em .3em; font-size: 90%}
|
316 |
+
.card_internal{display: flex;height: 100px;margin-top: .5em}
|
317 |
+
.card_internal img{margin-right: 1em}
|
318 |
+
.styler{--form-gap-width: 0px !important}
|
319 |
+
#progress{height:30px}
|
320 |
+
#progress .generating{display:none}
|
321 |
+
.progress-container {width: 100%;height: 30px;background-color: #f0f0f0;border-radius: 15px;overflow: hidden;margin-bottom: 20px}
|
322 |
+
.progress-bar {height: 100%;background-color: #4f46e5;width: calc(var(--current) / var(--total) * 100%);transition: width 0.5s ease-in-out}
|
323 |
+
'''
|
324 |
+
|
325 |
+
with gr.Blocks(css=css) as demo:
|
326 |
selected_index = gr.State(None)
|
327 |
+
current_generation_id = gr.State(None)
|
328 |
+
|
329 |
gr.Markdown("# Remade AI - Wan 2.1 I2V effects LoRAs ")
|
330 |
selected_info = gr.HTML("")
|
331 |
+
|
332 |
with gr.Row():
|
333 |
+
with gr.Column():
|
334 |
+
gallery = gr.Gallery(
|
335 |
+
[(item["image"], item["title"]) for item in loras],
|
336 |
+
label="Select LoRA",
|
337 |
+
allow_preview=False,
|
338 |
+
columns=4,
|
339 |
+
elem_id="gallery",
|
340 |
+
show_share_button=False,
|
341 |
+
height=350
|
342 |
+
)
|
343 |
+
input_image = gr.Image(type="filepath")
|
344 |
+
subject = gr.Textbox(label="Describe your subject", placeholder="Cat toy")
|
345 |
+
duration = gr.Radio(["Short (3s)", "Long (5s)"], label="Duration", value="Short (3s)")
|
346 |
+
button = gr.Button("Generate", variant="primary", elem_id="gen_btn")
|
347 |
+
|
348 |
+
with gr.Column():
|
349 |
+
progress_bar = gr.Markdown(elem_id="progress", visible=False)
|
350 |
+
output = gr.Video(interactive=False, label="Output video")
|
351 |
+
|
352 |
gallery.select(
|
353 |
update_selection,
|
354 |
outputs=[selected_index, selected_info]
|
355 |
+
)
|
356 |
+
|
357 |
+
# Use gr.on for the button click to match the example
|
358 |
+
gr.on(
|
359 |
+
triggers=[button.click],
|
360 |
+
fn=handle_generation,
|
361 |
+
inputs=[input_image, subject, duration, selected_index],
|
362 |
+
outputs=[output, current_generation_id, progress_bar]
|
363 |
)
|
364 |
|
365 |
if __name__ == "__main__":
|
366 |
+
demo.queue()
|
367 |
demo.launch()
|
config.json
ADDED
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
{
|
2 |
+
"models": [
|
3 |
+
|
4 |
+
{
|
5 |
+
"id": "wanvideo_itv",
|
6 |
+
"name": "WanVideo I2V",
|
7 |
+
"description": "WanVideo image-to-video generation model",
|
8 |
+
"workflow": "wanvideo_I2V_3_with_lora.json",
|
9 |
+
"type": "img2vid",
|
10 |
+
"nodes": {
|
11 |
+
"prompt": "16",
|
12 |
+
"seed": "27",
|
13 |
+
"videoCombine": "30",
|
14 |
+
"dimensions": "17",
|
15 |
+
"image": "18",
|
16 |
+
"lora": "39",
|
17 |
+
"modelLoader": "22"
|
18 |
+
}
|
19 |
+
|
20 |
+
}
|
21 |
+
]
|
22 |
+
}
|
23 |
+
|
video_config.py
ADDED
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# Frame rates for different model types
|
2 |
+
MODEL_FRAME_RATES = {
|
3 |
+
"wanvideo": 16, # WanVideo models use 16 fps
|
4 |
+
"hunyuan": 24 # Hunyuan models use 24 fps
|
5 |
+
}
|
6 |
+
|
7 |
+
def calculate_frames(duration, frame_rate):
|
8 |
+
"""
|
9 |
+
Calculate frames ensuring they follow the 4K+1 pattern
|
10 |
+
Args:
|
11 |
+
duration: Video duration in seconds
|
12 |
+
frame_rate: Frames per second
|
13 |
+
Returns:
|
14 |
+
int: Number of frames following 4K+1 pattern
|
15 |
+
"""
|
16 |
+
# Calculate raw frames
|
17 |
+
raw_frames = round(duration * frame_rate)
|
18 |
+
|
19 |
+
# Adjust to nearest 4K+1 value
|
20 |
+
# First, find the nearest multiple of 4
|
21 |
+
nearest_multiple_of_4 = round(raw_frames / 4) * 4
|
22 |
+
|
23 |
+
# Then add 1 to get 4K+1
|
24 |
+
return nearest_multiple_of_4 + 1
|
wani2v.json
ADDED
@@ -0,0 +1,258 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
{
|
2 |
+
"11": {
|
3 |
+
"inputs": {
|
4 |
+
"model_name": "umt5-xxl-enc-bf16.safetensors",
|
5 |
+
"precision": "bf16",
|
6 |
+
"load_device": "offload_device",
|
7 |
+
"quantization": "disabled"
|
8 |
+
},
|
9 |
+
"class_type": "LoadWanVideoT5TextEncoder",
|
10 |
+
"_meta": {
|
11 |
+
"title": "Load WanVideo T5 TextEncoder"
|
12 |
+
}
|
13 |
+
},
|
14 |
+
"13": {
|
15 |
+
"inputs": {
|
16 |
+
"model_name": "open-clip-xlm-roberta-large-vit-huge-14_visual_fp32.safetensors",
|
17 |
+
"precision": "fp32",
|
18 |
+
"load_device": "offload_device"
|
19 |
+
},
|
20 |
+
"class_type": "LoadWanVideoClipTextEncoder",
|
21 |
+
"_meta": {
|
22 |
+
"title": "Load WanVideo Clip TextEncoder"
|
23 |
+
}
|
24 |
+
},
|
25 |
+
"16": {
|
26 |
+
"inputs": {
|
27 |
+
"positive_prompt": "a cute anime girl with massive fennec ears and a big fluffy tail wearing a maid outfit turning around",
|
28 |
+
"negative_prompt": "bad quality video",
|
29 |
+
"force_offload": true,
|
30 |
+
"t5": [
|
31 |
+
"11",
|
32 |
+
0
|
33 |
+
]
|
34 |
+
},
|
35 |
+
"class_type": "WanVideoTextEncode",
|
36 |
+
"_meta": {
|
37 |
+
"title": "WanVideo TextEncode"
|
38 |
+
}
|
39 |
+
},
|
40 |
+
"17": {
|
41 |
+
"inputs": {
|
42 |
+
"generation_width": 512,
|
43 |
+
"generation_height": 512,
|
44 |
+
"num_frames": 49,
|
45 |
+
"force_offload": true,
|
46 |
+
"noise_aug_strength": 0,
|
47 |
+
"latent_strength": 1,
|
48 |
+
"clip_embed_strength": 1,
|
49 |
+
"adjust_resolution": true,
|
50 |
+
"clip_vision": [
|
51 |
+
"13",
|
52 |
+
0
|
53 |
+
],
|
54 |
+
"image": [
|
55 |
+
"18",
|
56 |
+
0
|
57 |
+
],
|
58 |
+
"vae": [
|
59 |
+
"21",
|
60 |
+
0
|
61 |
+
]
|
62 |
+
},
|
63 |
+
"class_type": "WanVideoImageClipEncode",
|
64 |
+
"_meta": {
|
65 |
+
"title": "WanVideo ImageClip Encode"
|
66 |
+
}
|
67 |
+
},
|
68 |
+
"18": {
|
69 |
+
"inputs": {
|
70 |
+
"image": "flux_dev_example.png",
|
71 |
+
"upload": "image"
|
72 |
+
},
|
73 |
+
"class_type": "LoadImage",
|
74 |
+
"_meta": {
|
75 |
+
"title": "Load Image"
|
76 |
+
}
|
77 |
+
},
|
78 |
+
"21": {
|
79 |
+
"inputs": {
|
80 |
+
"model_name": "Wan2_1_VAE_bf16.safetensors",
|
81 |
+
"precision": "bf16"
|
82 |
+
},
|
83 |
+
"class_type": "WanVideoVAELoader",
|
84 |
+
"_meta": {
|
85 |
+
"title": "WanVideo VAE Loader"
|
86 |
+
}
|
87 |
+
},
|
88 |
+
"22": {
|
89 |
+
"inputs": {
|
90 |
+
"model": "Wan2_1-I2V-14B-480P_fp8_e4m3fn.safetensors",
|
91 |
+
"base_precision": "bf16",
|
92 |
+
"quantization": "fp8_e4m3fn",
|
93 |
+
"load_device": "offload_device",
|
94 |
+
"attention_mode": "sdpa",
|
95 |
+
"lora": [
|
96 |
+
"39",
|
97 |
+
0
|
98 |
+
]
|
99 |
+
},
|
100 |
+
"class_type": "WanVideoModelLoader",
|
101 |
+
"_meta": {
|
102 |
+
"title": "WanVideo Model Loader"
|
103 |
+
}
|
104 |
+
},
|
105 |
+
"27": {
|
106 |
+
"inputs": {
|
107 |
+
"steps": 30,
|
108 |
+
"cfg": 6,
|
109 |
+
"shift": 5,
|
110 |
+
"seed": 733113722085555,
|
111 |
+
"force_offload": true,
|
112 |
+
"scheduler": "dpm++",
|
113 |
+
"riflex_freq_index": 0,
|
114 |
+
"denoise_strength": 1,
|
115 |
+
"model": [
|
116 |
+
"22",
|
117 |
+
0
|
118 |
+
],
|
119 |
+
"text_embeds": [
|
120 |
+
"16",
|
121 |
+
0
|
122 |
+
],
|
123 |
+
"image_embeds": [
|
124 |
+
"17",
|
125 |
+
0
|
126 |
+
],
|
127 |
+
"feta_args": [
|
128 |
+
"37",
|
129 |
+
0
|
130 |
+
],
|
131 |
+
"teacache_args": [
|
132 |
+
"36",
|
133 |
+
0
|
134 |
+
]
|
135 |
+
},
|
136 |
+
"class_type": "WanVideoSampler",
|
137 |
+
"_meta": {
|
138 |
+
"title": "WanVideo Sampler"
|
139 |
+
}
|
140 |
+
},
|
141 |
+
"28": {
|
142 |
+
"inputs": {
|
143 |
+
"enable_vae_tiling": true,
|
144 |
+
"tile_x": 272,
|
145 |
+
"tile_y": 272,
|
146 |
+
"tile_stride_x": 160,
|
147 |
+
"tile_stride_y": 128,
|
148 |
+
"vae": [
|
149 |
+
"21",
|
150 |
+
0
|
151 |
+
],
|
152 |
+
"samples": [
|
153 |
+
"27",
|
154 |
+
0
|
155 |
+
]
|
156 |
+
},
|
157 |
+
"class_type": "WanVideoDecode",
|
158 |
+
"_meta": {
|
159 |
+
"title": "WanVideo Decode"
|
160 |
+
}
|
161 |
+
},
|
162 |
+
"30": {
|
163 |
+
"inputs": {
|
164 |
+
"frame_rate": 32,
|
165 |
+
"loop_count": 0,
|
166 |
+
"filename_prefix": "WanVideo2_1",
|
167 |
+
"format": "video/h264-mp4",
|
168 |
+
"pix_fmt": "yuv420p",
|
169 |
+
"crf": 19,
|
170 |
+
"save_metadata": true,
|
171 |
+
"trim_to_audio": false,
|
172 |
+
"pingpong": false,
|
173 |
+
"save_output": true,
|
174 |
+
"images": [
|
175 |
+
"38",
|
176 |
+
0
|
177 |
+
]
|
178 |
+
},
|
179 |
+
"class_type": "VHS_VideoCombine",
|
180 |
+
"_meta": {
|
181 |
+
"title": "Video Combine 🎥🅥🅗🅢"
|
182 |
+
}
|
183 |
+
},
|
184 |
+
"32": {
|
185 |
+
"inputs": {
|
186 |
+
"blocks_to_swap": 10,
|
187 |
+
"offload_img_emb": false,
|
188 |
+
"offload_txt_emb": false
|
189 |
+
},
|
190 |
+
"class_type": "WanVideoBlockSwap",
|
191 |
+
"_meta": {
|
192 |
+
"title": "WanVideo BlockSwap"
|
193 |
+
}
|
194 |
+
},
|
195 |
+
"35": {
|
196 |
+
"inputs": {
|
197 |
+
"backend": "inductor",
|
198 |
+
"fullgraph": false,
|
199 |
+
"mode": "default",
|
200 |
+
"dynamic": false,
|
201 |
+
"dynamo_cache_size_limit": 64,
|
202 |
+
"compile_transformer_blocks": true
|
203 |
+
},
|
204 |
+
"class_type": "WanVideoTorchCompileSettings",
|
205 |
+
"_meta": {
|
206 |
+
"title": "WanVideo Torch Compile Settings"
|
207 |
+
}
|
208 |
+
},
|
209 |
+
"36": {
|
210 |
+
"inputs": {
|
211 |
+
"rel_l1_thresh": 0.25,
|
212 |
+
"start_step": 1,
|
213 |
+
"end_step": -1,
|
214 |
+
"cache_device": "offload_device",
|
215 |
+
"use_coefficients": "true"
|
216 |
+
},
|
217 |
+
"class_type": "WanVideoTeaCache",
|
218 |
+
"_meta": {
|
219 |
+
"title": "WanVideo TeaCache"
|
220 |
+
}
|
221 |
+
},
|
222 |
+
"37": {
|
223 |
+
"inputs": {
|
224 |
+
"weight": 2,
|
225 |
+
"start_percent": 0,
|
226 |
+
"end_percent": 1
|
227 |
+
},
|
228 |
+
"class_type": "WanVideoEnhanceAVideo",
|
229 |
+
"_meta": {
|
230 |
+
"title": "WanVideo Enhance-A-Video"
|
231 |
+
}
|
232 |
+
},
|
233 |
+
"38": {
|
234 |
+
"inputs": {
|
235 |
+
"ckpt_name": "film_net_fp32.pt",
|
236 |
+
"clear_cache_after_n_frames": 100,
|
237 |
+
"multiplier": 2,
|
238 |
+
"frames": [
|
239 |
+
"28",
|
240 |
+
0
|
241 |
+
]
|
242 |
+
},
|
243 |
+
"class_type": "FILM VFI",
|
244 |
+
"_meta": {
|
245 |
+
"title": "FILM VFI"
|
246 |
+
}
|
247 |
+
},
|
248 |
+
"39": {
|
249 |
+
"inputs": {
|
250 |
+
"lora": "Cakeify\\cakeify_30.safetensors",
|
251 |
+
"strength": 1
|
252 |
+
},
|
253 |
+
"class_type": "WanVideoLoraSelect",
|
254 |
+
"_meta": {
|
255 |
+
"title": "WanVideo Lora Select"
|
256 |
+
}
|
257 |
+
}
|
258 |
+
}
|
workflow_handler.py
ADDED
@@ -0,0 +1,104 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import json
|
2 |
+
from pathlib import Path
|
3 |
+
from video_config import MODEL_FRAME_RATES, calculate_frames
|
4 |
+
|
5 |
+
class WanVideoWorkflow:
|
6 |
+
def __init__(self, supabase_client, config_path="config.json", workflow_path="wani2v.json"):
|
7 |
+
# Add debug prints and error handling
|
8 |
+
try:
|
9 |
+
workflow_path = Path(workflow_path)
|
10 |
+
config_path = Path(config_path)
|
11 |
+
|
12 |
+
print(f"Loading workflow from: {workflow_path.absolute()}")
|
13 |
+
print(f"File exists: {workflow_path.exists()}")
|
14 |
+
print(f"File size: {workflow_path.stat().st_size}")
|
15 |
+
|
16 |
+
# Load config and workflow
|
17 |
+
with open(config_path, 'r', encoding='utf-8') as f:
|
18 |
+
self.config = json.load(f)
|
19 |
+
|
20 |
+
with open(workflow_path, 'r', encoding='utf-8') as f:
|
21 |
+
content = f.read()
|
22 |
+
print(f"Raw content length: {len(content)}")
|
23 |
+
if not content:
|
24 |
+
raise ValueError(f"Workflow file is empty: {workflow_path}")
|
25 |
+
self.workflow = json.loads(content)
|
26 |
+
|
27 |
+
self.supabase = supabase_client
|
28 |
+
|
29 |
+
# Get node mappings
|
30 |
+
self.model_config = next(
|
31 |
+
(model for model in self.config["models"] if model["id"] == "wanvideo_itv"),
|
32 |
+
None
|
33 |
+
)
|
34 |
+
if not self.model_config:
|
35 |
+
raise ValueError("WanVideo I2V model config not found")
|
36 |
+
|
37 |
+
self.nodes = self.model_config["nodes"]
|
38 |
+
|
39 |
+
except Exception as e:
|
40 |
+
print(f"Error initializing WanVideoWorkflow: {str(e)}")
|
41 |
+
raise
|
42 |
+
|
43 |
+
async def get_lora_path(self, lora_id):
|
44 |
+
"""Get LoRA path from Supabase training_jobs table"""
|
45 |
+
response = self.supabase.table('training_jobs').select(
|
46 |
+
'name, visible, config'
|
47 |
+
).eq('id', lora_id).execute()
|
48 |
+
|
49 |
+
if not response.data:
|
50 |
+
raise ValueError(f"LoRA with ID {lora_id} not found")
|
51 |
+
|
52 |
+
lora_data = response.data[0]
|
53 |
+
visible_epoch = lora_data['visible']
|
54 |
+
|
55 |
+
if visible_epoch is None:
|
56 |
+
raise ValueError(f"LoRA {lora_id} has no visible epoch")
|
57 |
+
|
58 |
+
return f"{lora_id}/run/epoch{visible_epoch}/adapter_model.safetensors"
|
59 |
+
|
60 |
+
def update_prompt(self, prompt):
|
61 |
+
"""Update the prompt node"""
|
62 |
+
prompt_node = self.workflow[self.nodes["prompt"]]
|
63 |
+
if not prompt_node.get("inputs"):
|
64 |
+
raise ValueError("Invalid prompt node structure")
|
65 |
+
prompt_node["inputs"]["positive_prompt"] = prompt
|
66 |
+
|
67 |
+
def update_input_image(self, image_path):
|
68 |
+
"""Update the input image node"""
|
69 |
+
image_node = self.workflow[self.nodes["image"]]
|
70 |
+
if not image_node.get("inputs"):
|
71 |
+
raise ValueError("Invalid image node structure")
|
72 |
+
image_node["inputs"]["image"] = Path(image_path).name
|
73 |
+
|
74 |
+
async def update_lora(self, lora_config):
|
75 |
+
"""Update the LoRA node"""
|
76 |
+
lora_node = self.workflow[self.nodes["lora"]]
|
77 |
+
if not lora_node.get("inputs"):
|
78 |
+
raise ValueError("Invalid LoRA node structure")
|
79 |
+
|
80 |
+
# Get LoRA path from Supabase
|
81 |
+
lora_path = await self.get_lora_path(lora_config["id"])
|
82 |
+
lora_node["inputs"]["lora"] = lora_path
|
83 |
+
lora_node["inputs"]["strength"] = 1.0
|
84 |
+
|
85 |
+
def update_length(self, duration):
|
86 |
+
"""Update video length (number of frames)"""
|
87 |
+
dimensions_node = self.workflow[self.nodes["dimensions"]]
|
88 |
+
if not dimensions_node.get("inputs"):
|
89 |
+
raise ValueError("Invalid dimensions node structure")
|
90 |
+
|
91 |
+
frame_rate = MODEL_FRAME_RATES["wanvideo"]
|
92 |
+
num_frames = calculate_frames(duration, frame_rate)
|
93 |
+
dimensions_node["inputs"]["num_frames"] = num_frames
|
94 |
+
|
95 |
+
def update_output_name(self, generation_id):
|
96 |
+
"""Update the output filename"""
|
97 |
+
video_combine_node = self.workflow[self.nodes["videoCombine"]]
|
98 |
+
if not video_combine_node.get("inputs"):
|
99 |
+
raise ValueError("Invalid video combine node structure")
|
100 |
+
video_combine_node["inputs"]["filename_prefix"] = generation_id
|
101 |
+
|
102 |
+
def get_workflow(self):
|
103 |
+
"""Return the complete workflow"""
|
104 |
+
return self.workflow
|