GLAkavya commited on
Commit
090a8cf
Β·
verified Β·
1 Parent(s): dd0d6eb

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +307 -394
app.py CHANGED
@@ -1,495 +1,408 @@
1
- import os
2
- import tempfile
3
- import io
4
- import math
5
- import time
6
- import random
7
  import numpy as np
8
  import cv2
9
  import gradio as gr
10
  from PIL import Image, ImageFilter, ImageEnhance
11
 
12
- # ── HF SETUP ─────────────────────────────────────────────────────────────────
13
- hf_token = (
14
- os.environ.get("HF_TOKEN", "")
15
- or os.environ.get("HF_KEY", "")
16
- ).strip()
17
 
18
  hf_client = None
19
- if hf_token:
20
  try:
21
  from huggingface_hub import login, InferenceClient
22
- login(token=hf_token)
23
- hf_client = InferenceClient(token=hf_token)
24
- print("βœ… HF login OK")
25
  except Exception as e:
26
- print(f"⚠️ HF login skipped: {e}")
 
 
 
 
27
  else:
28
- print("⚠️ No HF token β€” Ken Burns fallback will be used")
29
 
30
  print("βœ… App ready!")
31
 
32
 
33
- # ── HF MODEL FALLBACK CHAIN ───────────────────────────────────────────────────
34
- HF_MODELS = [
35
- {"id": "Lightricks/LTX-2", "name": "LTX-2 (Lightricks) ⚑"},
36
- {"id": "Wan-AI/Wan2.2-I2V-A14B", "name": "Wan 2.2 I2V-A14B"},
37
- {"id": "stabilityai/stable-video-diffusion-img2vid-xt", "name": "Stable Video Diffusion XT"},
38
- {"id": "KlingTeam/LivePortrait", "name": "KlingTeam LivePortrait"},
39
- {"id": "Lightricks/LTX-Video", "name": "LTX-Video"},
40
- {"id": "__ken_burns__", "name": "Ken Burns (local fallback)"},
41
- ]
42
 
 
 
 
 
43
 
44
- def try_hf_model(model_id, pil_image, prompt):
45
- if hf_client is None:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
46
  return None
47
  try:
48
- buf = io.BytesIO()
49
- pil_image.save(buf, format="JPEG", quality=95)
50
- image_bytes = buf.getvalue()
51
- print(f" πŸ€– Trying {model_id} ...")
52
- result = hf_client.image_to_video(
53
- image=image_bytes,
54
- model=model_id,
55
- prompt=prompt,
 
 
 
 
 
 
56
  )
57
- if isinstance(result, bytes):
58
- return result
59
- elif hasattr(result, "read"):
60
- return result.read()
 
 
 
 
 
 
 
61
  return None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
62
  except Exception as e:
63
- print(f" ❌ {model_id} failed: {e}")
 
 
 
 
 
 
64
  return None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
65
 
66
 
67
- def generate_video_with_fallback(pil_image, prompt, style, progress_callback=None):
68
- for model_info in HF_MODELS:
69
- model_id = model_info["id"]
70
- model_name = model_info["name"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
71
 
72
- if progress_callback:
73
- progress_callback(f"⏳ Trying: {model_name}")
 
74
 
75
- if model_id == "__ken_burns__":
76
- print(" 🎬 Using Ken Burns (cinematic local)")
77
  path = generate_video_ken_burns(pil_image, style=style.lower())
78
- return path, f"🎨 {model_name}"
79
 
80
- video_bytes = try_hf_model(model_id, pil_image, prompt)
81
- if video_bytes:
82
- tmp = tempfile.NamedTemporaryFile(suffix=".mp4", delete=False)
83
- tmp.write(video_bytes)
84
- tmp.flush()
85
- print(f" βœ… SUCCESS with {model_name}")
86
- return tmp.name, f"πŸ€– {model_name}"
87
-
88
- time.sleep(1)
89
 
90
  path = generate_video_ken_burns(pil_image, style=style.lower())
91
  return path, "🎨 Ken Burns (local)"
92
 
93
 
94
- # ═══════════════════════════════════════════════════════════════════
95
- # CINEMATIC KEN BURNS β€” UPGRADED
96
- # ═══════════════════════════════════════════════════════════════════
97
-
98
- # ── Easing ───────────────────────────────────────────────────────────────────
99
  def ease_in_out(t):
100
  t = max(0.0, min(1.0, t))
101
- return t * t * (3 - 2 * t)
102
-
103
- def ease_out_expo(t):
104
- return 1 - math.pow(2, -10 * t) if t < 1 else 1.0
105
 
106
  def ease_in_out_cubic(t):
107
  t = max(0.0, min(1.0, t))
108
- if t < 0.5:
109
- return 4 * t * t * t
110
- return 1 - math.pow(-2 * t + 2, 3) / 2
111
-
112
- def cubic_bezier(t, p0, p1, p2, p3):
113
- """Generic cubic bezier interpolation."""
114
- u = 1 - t
115
- return u**3*p0 + 3*u**2*t*p1 + 3*u*t**2*p2 + t**3*p3
116
 
 
 
117
 
118
- # ── Image Pre-processing ──────────────────────────────────────────────────────
119
- def preprocess_image(pil_image, target_w, target_h):
120
- """Resize + smart sharpen + slight contrast boost."""
121
  img = pil_image.convert("RGB")
122
-
123
- # Smart crop to fill target aspect ratio without distortion
124
- src_w, src_h = img.size
125
- src_ratio = src_w / src_h
126
- tgt_ratio = target_w / target_h
127
- if src_ratio > tgt_ratio:
128
- new_h = src_h
129
- new_w = int(src_h * tgt_ratio)
130
- left = (src_w - new_w) // 2
131
- img = img.crop((left, 0, left + new_w, new_h))
132
  else:
133
- new_w = src_w
134
- new_h = int(src_w / tgt_ratio)
135
- top = (src_h - new_h) // 2
136
- img = img.crop((0, top, new_w, top + new_h))
137
-
138
- # Resize with high quality
139
- img = img.resize((target_w, target_h), Image.LANCZOS)
140
-
141
- # Unsharp mask β€” brings out crisp details
142
- img = img.filter(ImageFilter.UnsharpMask(radius=1.2, percent=130, threshold=2))
143
-
144
- # Subtle contrast + saturation lift
145
  img = ImageEnhance.Contrast(img).enhance(1.08)
146
  img = ImageEnhance.Color(img).enhance(1.12)
147
- img = ImageEnhance.Sharpness(img).enhance(1.15)
148
-
149
  return np.array(img)
150
 
151
-
152
- # ── Color Grading ─────────────────────────────────────────────────────────────
153
- def apply_color_grade(frame, style="premium"):
154
- """
155
- Premium LUT-style grading:
156
- - S-curve for contrast
157
- - Per-channel color shifts
158
- - Highlight/shadow split toning
159
- """
160
- f = frame.astype(np.float32) / 255.0
161
-
162
- # S-curve (raises mids, deepens blacks, lifts highlights)
163
- def scurve(x, strength=0.18):
164
- return x + strength * x * (1 - x) * (2 * x - 1) * (-1)
165
-
166
- f = scurve(f, strength=0.20)
167
-
168
  if style == "premium":
169
- # Teal-orange β€” Hollywood standard
170
- # Shadows β†’ teal, highlights β†’ warm orange
171
- lum = 0.299*f[:,:,0] + 0.587*f[:,:,1] + 0.114*f[:,:,2]
172
- shadow_mask = np.clip(1.0 - lum * 2.5, 0, 1)[:,:,np.newaxis]
173
- highlight_mask = np.clip((lum - 0.6) * 2.5, 0, 1)[:,:,np.newaxis]
174
- # Shadows: +teal (G+B, -R)
175
- f[:,:,0] -= 0.04 * shadow_mask[:,:,0]
176
- f[:,:,1] += 0.03 * shadow_mask[:,:,0]
177
- f[:,:,2] += 0.05 * shadow_mask[:,:,0]
178
- # Highlights: +warm (R+G, -B)
179
- f[:,:,0] += 0.05 * highlight_mask[:,:,0]
180
- f[:,:,1] += 0.02 * highlight_mask[:,:,0]
181
- f[:,:,2] -= 0.04 * highlight_mask[:,:,0]
182
- # Global slight brightness
183
  f *= 1.04
184
-
185
  elif style == "energetic":
186
- # High saturation, punchy contrast, slight crush blacks
187
- gray = 0.299*f[:,:,0:1] + 0.587*f[:,:,1:2] + 0.114*f[:,:,2:3]
188
- f = np.clip(gray + 1.5 * (f - gray), 0, 1) # +50% saturation
189
- f = np.clip(f * 1.12 - 0.02, 0, 1) # crush blacks slightly
190
- f[:,:,0] = np.clip(f[:,:,0] * 1.06, 0, 1) # red channel boost
191
-
192
  elif style == "fun":
193
- # Warm, bright, pastel-ish
194
- f[:,:,0] = np.clip(f[:,:,0] * 1.10, 0, 1) # warm reds
195
- f[:,:,1] = np.clip(f[:,:,1] * 1.06, 0, 1) # green lift
196
- f[:,:,2] = np.clip(f[:,:,2] * 0.95, 0, 1) # desaturate blue
197
- f = np.clip(f * 1.05 + 0.02, 0, 1) # lift blacks (matte look)
198
-
199
- return np.clip(f * 255, 0, 255).astype(np.uint8)
200
-
201
-
202
- # ── Vignette ──────────────────────────────────────────────────────────────────
203
- def apply_vignette(frame, strength=0.65, softness=2.0):
204
- """Oval cinematic vignette β€” darker, softer edges."""
205
- h, w = frame.shape[:2]
206
- Y, X = np.ogrid[:h, :w]
207
- cx, cy = w / 2, h / 2
208
- # Oval (wider horizontally for portrait/reel format)
209
- dist = np.sqrt(((X - cx) / (cx * 0.85))**2 + ((Y - cy) / cy)**2)
210
- mask = np.clip(1.0 - strength * (dist ** softness), 0, 1)
211
- return (frame * mask[:, :, np.newaxis]).astype(np.uint8)
212
-
213
-
214
- # ── Film Grain ────────────────────────────────────────────────────────────────
215
- def apply_film_grain(frame, intensity=6.0):
216
- """Subtle luminance noise β€” makes it feel organic, not AI-flat."""
217
- grain = np.random.normal(0, intensity, frame.shape).astype(np.float32)
218
- result = frame.astype(np.float32) + grain
219
- return np.clip(result, 0, 255).astype(np.uint8)
220
-
221
-
222
- # ── Light Leak ────────────────────────────────────────────────────────────────
223
- def apply_light_leak(frame, progress, style="premium"):
224
- """
225
- Sweeping diagonal light leak β€” appears at 30-60% of video.
226
- Gives that 'lens flare' feel without actual 3D rendering.
227
- """
228
- if not (0.28 < progress < 0.65):
229
- return frame
230
-
231
- h, w = frame.shape[:2]
232
- t = (progress - 0.28) / 0.37 # 0β†’1 within leak window
233
- peak = math.sin(t * math.pi) # rises and falls
234
-
235
- # Diagonal gradient from top-right
236
- Y, X = np.ogrid[:h, :w]
237
- diag = (X / w + (h - Y) / h) / 2.0
238
- leak_pos = 0.3 + t * 0.6 # sweep across
239
- leak_width = 0.25
240
- leak_mask = np.exp(-((diag - leak_pos)**2) / (2 * leak_width**2))
241
-
242
- if style == "premium":
243
- color = np.array([255, 220, 160], dtype=np.float32) # warm gold
244
- elif style == "energetic":
245
- color = np.array([160, 200, 255], dtype=np.float32) # electric blue
246
- else:
247
- color = np.array([255, 180, 200], dtype=np.float32) # pink fun
248
-
249
- strength = peak * 0.22
250
- leak_layer = (leak_mask[:,:,np.newaxis] * color * strength).astype(np.float32)
251
- result = np.clip(frame.astype(np.float32) + leak_layer, 0, 255)
252
- return result.astype(np.uint8)
253
-
254
-
255
- # ── Cinematic Bars ─────────────────────────────────────────────────────────────
256
- def apply_letterbox(frame, bar_h=40):
257
- """Black cinematic bars at top and bottom."""
258
- frame[:bar_h, :] = 0
259
- frame[-bar_h:, :] = 0
260
- return frame
261
-
262
-
263
- # ── Main Video Generator ──────────────────────────────────────────────────────
264
- def generate_video_ken_burns(
265
- pil_image,
266
- duration_sec = 6,
267
- fps = 30, # 30fps β€” smoother than 24
268
- style = "premium",
269
- add_grain = True,
270
- add_leak = True,
271
- add_bars = True,
272
- ):
273
- """
274
- Cinematic Ken Burns with:
275
- βœ… 1080Γ—1920 (full HD portrait / Reels format)
276
- βœ… 30 fps
277
- οΏ½οΏ½οΏ½ Smart aspect-ratio crop + LANCZOS resize
278
- βœ… Unsharp mask sharpening
279
- βœ… S-curve + split-toning color grade
280
- βœ… Oval soft vignette
281
- βœ… Subtle film grain
282
- βœ… Diagonal light leak sweep
283
- βœ… Cinematic letterbox bars
284
- βœ… Smooth bezier motion paths
285
- βœ… Fade in / fade out
286
- """
287
- TARGET_W, TARGET_H = 1080, 1920
288
- RENDER_W, RENDER_H = 1080, 1920 # full res render
289
-
290
- total_frames = duration_sec * fps # 180 frames @ 30fps
291
-
292
- # ── Prepare canvas ──────────────────────────────────────────────────────
293
- pad = 220 # generous padding for all movements
294
- big_w = RENDER_W + pad * 2
295
- big_h = RENDER_H + pad * 2
296
-
297
- base = preprocess_image(pil_image, big_w, big_h) # large canvas
298
-
299
- # ── Output file ─────────────────────────────────────────────────────────
300
- tmp = tempfile.NamedTemporaryFile(suffix=".mp4", delete=False)
301
- fourcc = cv2.VideoWriter_fourcc(*"mp4v")
302
- writer = cv2.VideoWriter(tmp.name, fourcc, fps, (TARGET_W, TARGET_H))
303
-
304
- # ── Motion keyframes (zoom, pan_x, pan_y) ───────────────────────────────
305
- # Each segment: (start_zoom, end_zoom, start_px, end_px, start_py, end_py)
306
- SEG = [
307
- # t=0.00-0.25 : burst zoom in
308
- (0.00, 0.25, 1.40, 1.15, 0, int(-pad*0.10), 0, int(-pad*0.12)),
309
- # t=0.25-0.55 : slow upward drift
310
- (0.25, 0.55, 1.15, 1.08, int(-pad*0.05), int(pad*0.08), int(-pad*0.12), int(-pad*0.30)),
311
- # t=0.55-0.78 : subtle right pan + tiny zoom out
312
- (0.55, 0.78, 1.08, 1.05, int(pad*0.08), int(pad*0.18), int(-pad*0.30), int(-pad*0.18)),
313
- # t=0.78-1.00 : pull back + settle
314
- (0.78, 1.00, 1.05, 1.00, int(pad*0.18), 0, int(-pad*0.18), 0),
315
  ]
316
 
317
- for i in range(total_frames):
318
- t_global = i / (total_frames - 1) # 0.0 β†’ 1.0
319
-
320
- # Find active segment
321
- zoom = pan_x = pan_y = None
322
- for (t0, t1, z0, z1, px0, px1, py0, py1) in SEG:
323
- if t0 <= t_global <= t1 or (zoom is None and t_global < t0):
324
- if t0 <= t_global <= t1:
325
- seg_t = (t_global - t0) / (t1 - t0)
326
- te = ease_in_out_cubic(seg_t)
327
- zoom = z0 + (z1 - z0) * te
328
- pan_x = int(px0 + (px1 - px0) * te)
329
- pan_y = int(py0 + (py1 - py0) * te)
330
- break
331
-
332
- if zoom is None:
333
- zoom, pan_x, pan_y = 1.00, 0, 0
334
-
335
- # ── Micro camera shake (only in first 30% of video) ─────────────────
336
- if t_global < 0.30:
337
- shake_strength = (0.30 - t_global) / 0.30 * 2.5
338
- pan_x += int(shake_strength * math.sin(i * 1.3))
339
- pan_y += int(shake_strength * math.cos(i * 0.9))
340
-
341
- # ── Crop from canvas ─────────────────────────────────────────────────
342
- crop_w = int(RENDER_W / zoom)
343
- crop_h = int(RENDER_H / zoom)
344
- cx = big_w // 2 + pan_x
345
- cy = big_h // 2 + pan_y
346
-
347
- x1 = max(0, cx - crop_w // 2)
348
- y1 = max(0, cy - crop_h // 2)
349
- x2 = min(big_w, x1 + crop_w)
350
- y2 = min(big_h, y1 + crop_h)
351
-
352
- # Guard
353
- if (x2 - x1) < 10 or (y2 - y1) < 10:
354
- x1, y1, x2, y2 = 0, 0, RENDER_W, RENDER_H
355
-
356
- cropped = base[y1:y2, x1:x2]
357
- # High quality resize β€” LANCZOS4 is the best OpenCV offers
358
- frame = cv2.resize(cropped, (RENDER_W, RENDER_H), interpolation=cv2.INTER_LANCZOS4)
359
-
360
- # ── Post-processing pipeline ─────────────────────────────────────────
361
- frame = apply_color_grade(frame, style)
362
-
363
- if add_leak:
364
- frame = apply_light_leak(frame, t_global, style)
365
-
366
- frame = apply_vignette(frame, strength=0.60, softness=2.2)
367
-
368
  if add_grain:
369
- grain_strength = 5.5 if style != "premium" else 4.0
370
- frame = apply_film_grain(frame, intensity=grain_strength)
371
-
372
- if add_bars:
373
- frame = apply_letterbox(frame, bar_h=48)
374
-
375
- # ── Fade in / out ────────────────────────────────────────────────────
376
- FADE_IN = 0.06 # first 6%
377
- FADE_OUT = 0.90 # last 10%
378
- if t_global < FADE_IN:
379
- alpha = ease_out_expo(t_global / FADE_IN)
380
- elif t_global > FADE_OUT:
381
- alpha = ease_in_out(1.0 - (t_global - FADE_OUT) / (1.0 - FADE_OUT))
382
- else:
383
- alpha = 1.0
384
 
385
- if alpha < 1.0:
386
- frame = np.clip(frame.astype(np.float32) * alpha, 0, 255).astype(np.uint8)
387
-
388
- # ── Write ────────────────────────────────────────────────────────────
389
- frame_bgr = cv2.cvtColor(frame, cv2.COLOR_RGB2BGR)
390
- writer.write(frame_bgr)
391
 
 
392
  writer.release()
393
  return tmp.name
394
 
395
 
396
- # ── MAIN ──────────────────────────────────────────────────────────────────────
 
 
397
  def generate_ad(image, prompt_text, style, add_grain, add_leak, add_bars, progress=gr.Progress()):
398
  if image is None:
399
  return None, "⚠️ Please upload an image first!"
400
 
401
- pil_image = image if isinstance(image, Image.Image) else Image.fromarray(image)
402
- prompt = prompt_text.strip() if prompt_text.strip() else "cinematic product advertisement, smooth motion"
403
-
404
- status_lines = []
405
 
406
  def log(msg):
407
- status_lines.append(msg)
408
- progress(0.2 + len(status_lines) * 0.10, desc=msg)
409
 
410
- progress(0.1, desc="🎬 Starting video generation...")
411
-
412
- video_path, model_used = generate_video_with_fallback(
413
- pil_image,
414
- prompt=prompt,
415
- style=style,
416
- progress_callback=log,
417
- )
418
 
419
- # If ken burns was used, regenerate with user options
420
  if "Ken Burns" in model_used:
421
- progress(0.7, desc="🎨 Rendering cinematic video...")
422
  video_path = generate_video_ken_burns(
423
- pil_image,
424
- style = style.lower(),
425
- add_grain= add_grain,
426
- add_leak = add_leak,
427
- add_bars = add_bars,
428
  )
429
 
430
  progress(1.0, desc="βœ… Done!")
431
- log_text = "\n".join(status_lines) + f"\n\nβœ… Used: {model_used}"
432
- return video_path, log_text
433
 
434
 
435
- # ── UI ────────────────────────────────────────────────────────────────────────
 
 
436
  css = """
437
- #title { text-align:center; font-size:2.4rem; font-weight:900; margin-bottom:.2rem; }
438
- #sub { text-align:center; color:#888; margin-bottom:1.5rem; font-size:1.05rem; }
 
439
  """
440
 
441
  with gr.Blocks(css=css, theme=gr.themes.Soft(primary_hue="violet")) as demo:
442
-
443
  gr.Markdown("# 🎬 AI Reel Generator", elem_id="title")
444
- gr.Markdown("Image upload karo β†’ **cinematic 1080p video** ready in seconds!", elem_id="sub")
445
 
446
  with gr.Row():
447
- # ── LEFT ─────────────────────────────────────────────────────────────
448
  with gr.Column(scale=1):
449
- image_input = gr.Image(label="πŸ“Έ Upload Image", type="pil", height=320)
450
- prompt_input = gr.Textbox(
451
- label="✏️ Prompt (optional β€” for AI models)",
452
- placeholder="e.g. cinematic slow zoom, product floating in air ...",
453
- lines=2,
454
- )
455
- style_dd = gr.Dropdown(
456
- choices=["Premium", "Energetic", "Fun"],
457
- value="Premium", label="🎨 Color Grade Style",
458
  )
459
-
460
  with gr.Row():
461
- grain_cb = gr.Checkbox(label="🎞 Film Grain", value=True)
462
- leak_cb = gr.Checkbox(label="✨ Light Leak", value=True)
463
  bars_cb = gr.Checkbox(label="🎬 Cinematic Bars", value=True)
464
-
465
- gen_btn = gr.Button("πŸš€ Generate Video", variant="primary", size="lg")
466
 
467
  gr.Markdown(
468
- "**πŸ”— Fallback Chain:**\n"
469
- "1. Lightricks/LTX-2 ⚑\n"
470
- "2. Wan 2.2 I2V-A14B\n"
471
- "3. Stable Video Diffusion XT\n"
472
- "4. KlingTeam/LivePortrait\n"
473
- "5. Lightricks/LTX-Video\n"
474
- "6. 🎨 Ken Burns **1080p** (always works βœ…)"
 
475
  )
476
 
477
- # ── RIGHT ────────────────────────────────────────────────────────────
478
  with gr.Column(scale=1):
479
- video_out = gr.Video(label="πŸŽ₯ Generated Video (1080Γ—1920)", height=500)
480
- status_out = gr.Textbox(label="πŸ“Š Model Log", lines=8, interactive=False)
481
 
482
  gen_btn.click(
483
  fn=generate_ad,
484
- inputs=[image_input, prompt_input, style_dd, grain_cb, leak_cb, bars_cb],
485
- outputs=[video_out, status_out],
486
  )
487
 
488
  gr.Markdown(
489
  "---\n"
490
- "**Ken Burns pipeline:** Smart crop β†’ LANCZOS resize β†’ Unsharp Mask β†’ "
491
- "S-curve + Split-toning Grade β†’ Light Leak β†’ Oval Vignette β†’ "
492
- "Film Grain β†’ Cinematic Bars β†’ Bezier motion β†’ 30fps @ 1080Γ—1920"
493
  )
494
 
495
  if __name__ == "__main__":
 
1
+ import os, tempfile, io, math, time, threading, base64, requests
 
 
 
 
 
2
  import numpy as np
3
  import cv2
4
  import gradio as gr
5
  from PIL import Image, ImageFilter, ImageEnhance
6
 
7
+ # ══════════════════════════════════════════════════════
8
+ # TOKENS
9
+ # ══════════════════════════════════════════════════════
10
+ FAL_KEY = (os.environ.get("FAL_KEY", "") or os.environ.get("FAL_API_KEY", "")).strip()
11
+ HF_TOKEN = (os.environ.get("HF_TOKEN", "") or os.environ.get("HF_KEY", "")).strip()
12
 
13
  hf_client = None
14
+ if HF_TOKEN:
15
  try:
16
  from huggingface_hub import login, InferenceClient
17
+ login(token=HF_TOKEN)
18
+ hf_client = InferenceClient(token=HF_TOKEN)
19
+ print("βœ… HF ready")
20
  except Exception as e:
21
+ print(f"⚠️ HF: {e}")
22
+
23
+ if FAL_KEY:
24
+ os.environ["FAL_KEY"] = FAL_KEY
25
+ print("βœ… fal.ai ready")
26
  else:
27
+ print("⚠️ No FAL_KEY β€” will skip fal.ai models")
28
 
29
  print("βœ… App ready!")
30
 
31
 
32
+ # ══════════════════════════════════════════════════════
33
+ # HELPERS
34
+ # ══════════════════════════════════════════════════════
35
+ def pil_to_b64(img: Image.Image, quality=92) -> str:
36
+ buf = io.BytesIO()
37
+ img.save(buf, format="JPEG", quality=quality)
38
+ return "data:image/jpeg;base64," + base64.b64encode(buf.getvalue()).decode()
 
 
39
 
40
+ def pil_to_bytes(img: Image.Image, quality=92) -> bytes:
41
+ buf = io.BytesIO()
42
+ img.save(buf, format="JPEG", quality=quality)
43
+ return buf.getvalue()
44
 
45
+ def download_video(url: str) -> bytes | None:
46
+ try:
47
+ r = requests.get(url, timeout=60)
48
+ if r.status_code == 200:
49
+ return r.content
50
+ except Exception as e:
51
+ print(f" ⚠️ download failed: {e}")
52
+ return None
53
+
54
+ def save_video_bytes(data: bytes) -> str:
55
+ tmp = tempfile.NamedTemporaryFile(suffix=".mp4", delete=False)
56
+ tmp.write(data); tmp.flush()
57
+ return tmp.name
58
+
59
+ def run_with_timeout(fn, timeout, *args, **kwargs):
60
+ """Run fn in thread, return result or None on timeout."""
61
+ box = [None]; err = [None]
62
+ def _run():
63
+ try: box[0] = fn(*args, **kwargs)
64
+ except Exception as e: err[0] = str(e)
65
+ t = threading.Thread(target=_run, daemon=True)
66
+ t.start(); t.join(timeout=timeout)
67
+ if t.is_alive():
68
+ print(f" ⏱ Timeout after {timeout}s")
69
+ return None
70
+ if err[0]: print(f" ❌ Error: {err[0][:120]}")
71
+ return box[0]
72
+
73
+
74
+ # ══════════════════════════════════════════════════════
75
+ # FAL.AI MODELS (real AI generation β€” best quality)
76
+ # ══════════════════════════════════════════════════════
77
+
78
+ def try_fal_ltx(pil_image: Image.Image, prompt: str) -> bytes | None:
79
+ """fal-ai/ltx-video/image-to-video β€” fastest, ~15-25s"""
80
+ if not FAL_KEY:
81
  return None
82
  try:
83
+ import fal_client
84
+
85
+ img_url = fal_client.upload_image(pil_image) # upload to fal CDN
86
+
87
+ result = fal_client.run(
88
+ "fal-ai/ltx-video/image-to-video",
89
+ arguments={
90
+ "image_url": img_url,
91
+ "prompt": prompt,
92
+ "num_frames": 121, # ~5s @ 24fps
93
+ "fps": 24,
94
+ "guidance_scale": 3.5,
95
+ "num_inference_steps": 30,
96
+ },
97
  )
98
+ video_url = result.get("video", {}).get("url") or result.get("video_url")
99
+ if video_url:
100
+ return download_video(video_url)
101
+ except Exception as e:
102
+ print(f" ❌ fal LTX: {e}")
103
+ return None
104
+
105
+
106
+ def try_fal_wan(pil_image: Image.Image, prompt: str) -> bytes | None:
107
+ """fal-ai/wan-i2v β€” Wan2.1 image-to-video"""
108
+ if not FAL_KEY:
109
  return None
110
+ try:
111
+ import fal_client
112
+
113
+ img_url = fal_client.upload_image(pil_image)
114
+
115
+ result = fal_client.run(
116
+ "fal-ai/wan-i2v",
117
+ arguments={
118
+ "image_url": img_url,
119
+ "prompt": prompt,
120
+ "num_frames": 81,
121
+ "fps": 16,
122
+ },
123
+ )
124
+ video_url = (
125
+ result.get("video", {}).get("url")
126
+ or result.get("video_url")
127
+ )
128
+ if video_url:
129
+ return download_video(video_url)
130
  except Exception as e:
131
+ print(f" ❌ fal Wan: {e}")
132
+ return None
133
+
134
+
135
+ def try_fal_kling(pil_image: Image.Image, prompt: str) -> bytes | None:
136
+ """fal-ai/kling-video/v1.6/standard/image-to-video"""
137
+ if not FAL_KEY:
138
  return None
139
+ try:
140
+ import fal_client
141
+
142
+ img_b64 = pil_to_b64(pil_image)
143
+
144
+ result = fal_client.run(
145
+ "fal-ai/kling-video/v1.6/standard/image-to-video",
146
+ arguments={
147
+ "image_url": img_b64,
148
+ "prompt": prompt,
149
+ "duration": "5",
150
+ "aspect_ratio": "9:16",
151
+ },
152
+ )
153
+ video_url = (
154
+ result.get("video", {}).get("url")
155
+ or result.get("video_url")
156
+ )
157
+ if video_url:
158
+ return download_video(video_url)
159
+ except Exception as e:
160
+ print(f" ❌ fal Kling: {e}")
161
+ return None
162
 
163
 
164
+ # ══════════════════════════════════════════════════════
165
+ # HF FALLBACK
166
+ # ══════════════════════════════════════════════════════
167
+ def try_hf_ltx(pil_image: Image.Image, prompt: str) -> bytes | None:
168
+ if hf_client is None:
169
+ return None
170
+ try:
171
+ r = hf_client.image_to_video(
172
+ image=pil_to_bytes(pil_image),
173
+ model="Lightricks/LTX-2",
174
+ prompt=prompt,
175
+ )
176
+ return r.read() if hasattr(r, "read") else r
177
+ except Exception as e:
178
+ print(f" ❌ HF LTX-2: {e}")
179
+ return None
180
+
181
+
182
+ # ══════════════════════════════════════════════════════
183
+ # FULL FALLBACK CHAIN
184
+ # ══════════════════════════════════════════════════════
185
+ CHAIN = [
186
+ # (name, fn, timeout_sec)
187
+ ("πŸ€– fal.ai β€” LTX-Video", try_fal_ltx, 90),
188
+ ("πŸ€– fal.ai β€” Wan2.1 I2V", try_fal_wan, 120),
189
+ ("πŸ€– fal.ai β€” Kling v1.6", try_fal_kling, 120),
190
+ ("πŸ€– HF β€” LTX-2", try_hf_ltx, 60),
191
+ ("🎨 Ken Burns (local)", None, 0), # always works
192
+ ]
193
 
194
+ def generate_video_with_fallback(pil_image, prompt, style, cb=None):
195
+ for name, fn, timeout in CHAIN:
196
+ if cb: cb(f"⏳ Trying: {name}")
197
 
198
+ if fn is None: # Ken Burns fallback
 
199
  path = generate_video_ken_burns(pil_image, style=style.lower())
200
+ return path, f"🎨 Ken Burns (local)"
201
 
202
+ result = run_with_timeout(fn, timeout, pil_image, prompt)
203
+ if result:
204
+ return save_video_bytes(result), name
 
 
 
 
 
 
205
 
206
  path = generate_video_ken_burns(pil_image, style=style.lower())
207
  return path, "🎨 Ken Burns (local)"
208
 
209
 
210
+ # ══════════════════════════════════════════════════════
211
+ # CINEMATIC KEN BURNS (local fallback β€” always works)
212
+ # ══════════════════════════════════════════════════════
 
 
213
  def ease_in_out(t):
214
  t = max(0.0, min(1.0, t))
215
+ return t*t*(3-2*t)
 
 
 
216
 
217
  def ease_in_out_cubic(t):
218
  t = max(0.0, min(1.0, t))
219
+ return 4*t*t*t if t<0.5 else 1-math.pow(-2*t+2,3)/2
 
 
 
 
 
 
 
220
 
221
+ def ease_out_expo(t):
222
+ return 1-math.pow(2,-10*t) if t<1 else 1.0
223
 
224
+ def preprocess_image(pil_image, W, H):
 
 
225
  img = pil_image.convert("RGB")
226
+ sw, sh = img.size
227
+ if sw/sh > W/H:
228
+ nw = int(sh*W/H); img = img.crop(((sw-nw)//2,0,(sw-nw)//2+nw,sh))
 
 
 
 
 
 
 
229
  else:
230
+ nh = int(sw*H/W); img = img.crop((0,(sh-nh)//2,sw,(sh-nh)//2+nh))
231
+ img = img.resize((W,H), Image.LANCZOS)
232
+ img = img.filter(ImageFilter.UnsharpMask(radius=1.0, percent=120, threshold=2))
 
 
 
 
 
 
 
 
 
233
  img = ImageEnhance.Contrast(img).enhance(1.08)
234
  img = ImageEnhance.Color(img).enhance(1.12)
 
 
235
  return np.array(img)
236
 
237
+ def apply_color_grade(f32, style):
238
+ f = f32 / 255.0
239
+ f = f + 0.20*f*(1-f)*(2*f-1)*(-1) # S-curve
 
 
 
 
 
 
 
 
 
 
 
 
 
 
240
  if style == "premium":
241
+ lum = 0.299*f[:,:,0]+0.587*f[:,:,1]+0.114*f[:,:,2]
242
+ sh = np.clip(1.0-lum*2.5,0,1)[:,:,np.newaxis]
243
+ hi = np.clip((lum-0.6)*2.5,0,1)[:,:,np.newaxis]
244
+ f[:,:,0] += -0.04*sh[:,:,0] + 0.05*hi[:,:,0]
245
+ f[:,:,1] += 0.03*sh[:,:,0] + 0.02*hi[:,:,0]
246
+ f[:,:,2] += 0.05*sh[:,:,0] - 0.04*hi[:,:,0]
 
 
 
 
 
 
 
 
247
  f *= 1.04
 
248
  elif style == "energetic":
249
+ gray = 0.299*f[:,:,0:1]+0.587*f[:,:,1:2]+0.114*f[:,:,2:3]
250
+ f = np.clip(gray+1.5*(f-gray),0,1); f = np.clip(f*1.12-0.02,0,1)
251
+ f[:,:,0] = np.clip(f[:,:,0]*1.06,0,1)
 
 
 
252
  elif style == "fun":
253
+ f[:,:,0]=np.clip(f[:,:,0]*1.10,0,1)
254
+ f[:,:,1]=np.clip(f[:,:,1]*1.06,0,1)
255
+ f[:,:,2]=np.clip(f[:,:,2]*0.95,0,1)
256
+ f=np.clip(f*1.05+0.02,0,1)
257
+ return np.clip(f*255,0,255).astype(np.uint8)
258
+
259
+ def apply_light_leak(frame, tg, style):
260
+ if not (0.28<tg<0.65): return frame
261
+ t=((tg-0.28)/0.37); peak=math.sin(t*math.pi)
262
+ h,w=frame.shape[:2]; Y,X=np.ogrid[:h,:w]
263
+ diag=(X/w+(h-Y)/h)/2.0; lpos=0.3+t*0.6
264
+ mask=np.exp(-((diag-lpos)**2)/(2*0.25**2))
265
+ c={"premium":[255,220,160],"energetic":[160,200,255],"fun":[255,180,200]}
266
+ col=np.array(c.get(style,[255,220,160]),dtype=np.float32)
267
+ leak=(mask[:,:,np.newaxis]*col*peak*0.20).astype(np.float32)
268
+ return np.clip(frame.astype(np.float32)+leak,0,255).astype(np.uint8)
269
+
270
+ def generate_video_ken_burns(pil_image, duration_sec=6, fps=30, style="premium",
271
+ add_grain=True, add_leak=True, add_bars=True):
272
+ TW,TH=720,1280; pad=180; BW,BH=TW+pad*2,TH+pad*2
273
+ base=preprocess_image(pil_image,BW,BH)
274
+ total=duration_sec*fps
275
+ tmp=tempfile.NamedTemporaryFile(suffix=".mp4",delete=False)
276
+ writer=cv2.VideoWriter(tmp.name,cv2.VideoWriter_fourcc(*"mp4v"),fps,(TW,TH))
277
+
278
+ SEG=[
279
+ (0.00,0.25, 1.38,1.14, 0, int(-pad*.08), 0, int(-pad*.10)),
280
+ (0.25,0.55, 1.14,1.07, int(-pad*.05),int(pad*.07),int(-pad*.10),int(-pad*.28)),
281
+ (0.55,0.78, 1.07,1.04, int(pad*.07),int(pad*.16),int(-pad*.28),int(-pad*.16)),
282
+ (0.78,1.00, 1.04,1.00, int(pad*.16),0, int(-pad*.16),0),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
283
  ]
284
 
285
+ Y,X=np.ogrid[:TH,:TW]; cx,cy=TW/2,TH/2
286
+ vmask=np.clip(1.0-0.60*(np.sqrt(((X-cx)/(cx*0.85))**2+((Y-cy)/cy)**2)**2.0),0,1).astype(np.float32)
287
+
288
+ for i in range(total):
289
+ tg=i/(total-1)
290
+ zoom=pan_x=pan_y=None
291
+ for t0,t1,z0,z1,px0,px1,py0,py1 in SEG:
292
+ if t0<=tg<=t1:
293
+ te=ease_in_out_cubic((tg-t0)/(t1-t0))
294
+ zoom=z0+(z1-z0)*te; pan_x=int(px0+(px1-px0)*te); pan_y=int(py0+(py1-py0)*te)
295
+ break
296
+ if zoom is None: zoom,pan_x,pan_y=1.0,0,0
297
+ if tg<0.30:
298
+ s=(0.30-tg)/0.30*2.2
299
+ pan_x+=int(s*math.sin(i*1.3)); pan_y+=int(s*math.cos(i*0.9))
300
+
301
+ cw=int(TW/zoom); ch=int(TH/zoom)
302
+ ox=BW//2+pan_x; oy=BH//2+pan_y
303
+ x1=max(0,ox-cw//2); y1=max(0,oy-ch//2)
304
+ x2=min(BW,x1+cw); y2=min(BH,y1+ch)
305
+ if (x2-x1)<10 or (y2-y1)<10: x1,y1,x2,y2=0,0,TW,TH
306
+
307
+ frame=cv2.resize(base[y1:y2,x1:x2],(TW,TH),interpolation=cv2.INTER_LINEAR)
308
+ frame=apply_color_grade(frame.astype(np.float32),style)
309
+ if add_leak: frame=apply_light_leak(frame,tg,style)
310
+ frame=np.clip(frame.astype(np.float32)*vmask[:,:,np.newaxis],0,255).astype(np.uint8)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
311
  if add_grain:
312
+ frame=np.clip(frame.astype(np.float32)+np.random.normal(0,4.5,frame.shape),0,255).astype(np.uint8)
313
+ if add_bars: frame[:42,:]=0; frame[-42:,:]=0
 
 
 
 
 
 
 
 
 
 
 
 
 
314
 
315
+ if tg<0.06: alpha=ease_out_expo(tg/0.06)
316
+ elif tg>0.90: alpha=ease_in_out(1.0-(tg-0.90)/0.10)
317
+ else: alpha=1.0
318
+ if alpha<1.0: frame=np.clip(frame.astype(np.float32)*alpha,0,255).astype(np.uint8)
 
 
319
 
320
+ writer.write(cv2.cvtColor(frame,cv2.COLOR_RGB2BGR))
321
  writer.release()
322
  return tmp.name
323
 
324
 
325
+ # ══════════════════════════════════════════════════════
326
+ # MAIN PIPELINE
327
+ # ══════════════════════════════════════════════════════
328
  def generate_ad(image, prompt_text, style, add_grain, add_leak, add_bars, progress=gr.Progress()):
329
  if image is None:
330
  return None, "⚠️ Please upload an image first!"
331
 
332
+ pil = image if isinstance(image, Image.Image) else Image.fromarray(image)
333
+ prompt = prompt_text.strip() or "cinematic product advertisement, smooth dynamic motion, dramatic lighting"
334
+ lines = []
 
335
 
336
  def log(msg):
337
+ lines.append(msg)
338
+ progress(min(0.1+len(lines)*0.12,0.85), desc=msg)
339
 
340
+ progress(0.05, desc="πŸš€ Starting AI video generation...")
341
+ video_path, model_used = generate_video_with_fallback(pil, prompt, style, cb=log)
 
 
 
 
 
 
342
 
 
343
  if "Ken Burns" in model_used:
344
+ progress(0.80, desc="🎨 Rendering cinematic fallback...")
345
  video_path = generate_video_ken_burns(
346
+ pil, style=style.lower(),
347
+ add_grain=add_grain, add_leak=add_leak, add_bars=add_bars,
 
 
 
348
  )
349
 
350
  progress(1.0, desc="βœ… Done!")
351
+ return video_path, "\n".join(lines) + f"\n\nβœ… Model used: {model_used}"
 
352
 
353
 
354
+ # ══════════════════════════════════════════════════════
355
+ # UI
356
+ # ══════════════════════════════════════════════════════
357
  css = """
358
+ #title{text-align:center;font-size:2.3rem;font-weight:900;margin-bottom:.2rem}
359
+ #sub {text-align:center;color:#888;margin-bottom:1.5rem}
360
+ .chain{font-size:.85rem;line-height:1.9}
361
  """
362
 
363
  with gr.Blocks(css=css, theme=gr.themes.Soft(primary_hue="violet")) as demo:
 
364
  gr.Markdown("# 🎬 AI Reel Generator", elem_id="title")
365
+ gr.Markdown("Image + prompt β†’ **real AI video** (fal.ai β†’ HF β†’ Ken Burns fallback)", elem_id="sub")
366
 
367
  with gr.Row():
 
368
  with gr.Column(scale=1):
369
+ img_in = gr.Image(label="πŸ“Έ Upload Image", type="pil", height=300)
370
+ prm_in = gr.Textbox(
371
+ label="✏️ Prompt",
372
+ value="cinematic product shot, smooth motion, dramatic lighting, professional ad",
373
+ lines=3,
 
 
 
 
374
  )
375
+ sty_dd = gr.Dropdown(["Premium","Energetic","Fun"], value="Premium", label="🎨 Style")
376
  with gr.Row():
377
+ grain_cb = gr.Checkbox(label="🎞 Film Grain", value=True)
378
+ leak_cb = gr.Checkbox(label="✨ Light Leak", value=True)
379
  bars_cb = gr.Checkbox(label="🎬 Cinematic Bars", value=True)
380
+ gen_btn = gr.Button("πŸš€ Generate AI Video", variant="primary", size="lg")
 
381
 
382
  gr.Markdown(
383
+ "**πŸ”— AI Chain (best β†’ fallback):**\n\n"
384
+ "1. πŸ€– **fal.ai LTX-Video** β€” fastest real AI (~20s)\n"
385
+ "2. πŸ€– **fal.ai Wan 2.1** β€” high quality (~40s)\n"
386
+ "3. πŸ€– **fal.ai Kling v1.6** β€” cinematic (~60s)\n"
387
+ "4. πŸ€– **HF LTX-2** β€” free fallback\n"
388
+ "5. 🎨 **Ken Burns** β€” always works βœ…\n\n"
389
+ "πŸ’‘ Add `FAL_KEY` secret for real AI generation!",
390
+ elem_classes="chain",
391
  )
392
 
 
393
  with gr.Column(scale=1):
394
+ vid_out = gr.Video(label="πŸŽ₯ AI Generated Video", height=500)
395
+ log_out = gr.Textbox(label="πŸ“Š Generation Log", lines=7, interactive=False)
396
 
397
  gen_btn.click(
398
  fn=generate_ad,
399
+ inputs=[img_in, prm_in, sty_dd, grain_cb, leak_cb, bars_cb],
400
+ outputs=[vid_out, log_out],
401
  )
402
 
403
  gr.Markdown(
404
  "---\n"
405
+ "**Get fal.ai key free:** [fal.ai/dashboard](https://fal.ai/dashboard) β†’ API Keys β†’ add as `FAL_KEY` secret in HF Space"
 
 
406
  )
407
 
408
  if __name__ == "__main__":