Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
170 changes: 170 additions & 0 deletions generate_fal_shots.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
"""
FAL video generation: 5 shots of the same woman character
for Chef-8080 – Missing You music video teasers.

Character: ~35-year-old woman, dark brown wet hair, pale skin,
dark coat, rain-soaked urban street at night, cool blue/neon aesthetic.
All shots generated in 9:16 portrait for 720p social media.
"""

import os, json, time, requests
from pathlib import Path
from dotenv import load_dotenv

load_dotenv(r"C:\Users\ari_v\Claude apps\Openmontage\.env")
os.environ["FAL_KEY"] = os.environ.get("FAL_KEY", "")

import fal_client

OUT_DIR = Path(r"C:\Users\ari_v\Claude apps\Openmontage\output\chef8080_missing_you\fal_shots")
OUT_DIR.mkdir(parents=True, exist_ok=True)

# Character anchor — repeated in every prompt for consistency
CHAR = (
"a 35-year-old woman, dark brown slightly wavy wet hair, pale skin, "
"natural makeup, wearing a dark navy wool coat"
)

SETTING = (
"rain-soaked urban street at night, neon signs reflecting blue and amber "
"on wet pavement, rain falling, bokeh city lights in background, "
"cinematic blue-toned lighting"
)

NEG = (
"blurry, cartoon, anime, painting, illustration, extra limbs, "
"distorted face, ugly, low quality, watermark, text overlay, "
"bright daylight, sunny, indoor, studio"
)

SHOTS = [
{
"id": "shot_01_closeup_singing",
"label": "ECU – Face singing, eyes closed",
"prompt": (
f"Extreme close-up cinematic portrait of {CHAR}, "
"eyes gently closed, lips parted in silent song, "
"rain droplets catching neon light on her cheeks and wet hair, "
f"{SETTING}, 9:16 vertical portrait, shallow depth of field, "
"photorealistic, 4K, music video cinematography, "
"emotional raw performance, slow motion rain"
),
},
{
"id": "shot_02_direct_gaze",
"label": "MCU – Direct emotional gaze to camera",
"prompt": (
f"Medium close-up of {CHAR} standing on a "
"rain-soaked city street at night, looking directly into the camera "
"with a raw, aching emotional expression, mouth slightly open as if "
"about to sing, rain falling lightly around her, neon blue and amber "
f"light on her face, {SETTING}, 9:16 portrait, "
"photorealistic, cinematic music video shot, slow camera push-in"
),
},
{
"id": "shot_03_head_back_rain",
"label": "MS – Head tilted back, rain on face",
"prompt": (
f"Medium shot of {CHAR}, standing in the rain "
"on an empty city street at night, head tilted slightly back, "
"rain falling on her upturned face, singing with eyes closed, "
"arms loosely at her sides, dark coat glistening with rain, "
f"{SETTING}, 9:16 vertical, photorealistic, "
"cinematic slow motion, emotional music video performance"
),
},
{
"id": "shot_04_silhouette_neon",
"label": "Wide – Silhouette against neon glow",
"prompt": (
f"Wide cinematic shot of {CHAR} "
"from a low angle on a wet city street at night, "
"her silhouette dramatic against glowing neon signs and blurred "
"city lights, rain streaks visible in the neon glow, "
"reflections shimmering in puddles at her feet, "
"she stands still, head slightly bowed, "
f"{SETTING}, 9:16 portrait, photorealistic, "
"cinematic composition, music video aesthetic"
),
},
{
"id": "shot_05_walking_turning",
"label": "MS – Walking, turns to look back",
"prompt": (
f"Medium shot following {CHAR} "
"as she walks slowly away on a rain-soaked urban street at night, "
"she pauses and turns to look over her shoulder with a melancholic "
"expression, neon reflections glimmering on the wet pavement, "
"rain falling softly around her, "
f"{SETTING}, 9:16 vertical, photorealistic, "
"slow cinematic movement, music video mood"
),
},
]

MODEL = "fal-ai/kling-video/v1.6/standard/text-to-video"

def generate_shot(shot):
out_path = OUT_DIR / f"{shot['id']}.mp4"
if out_path.exists() and out_path.stat().st_size > 500_000:
print(f" [cached] {shot['id']}")
return str(out_path)

print(f"\n Generating: {shot['label']}")
print(f" Model: {MODEL}")

result = fal_client.subscribe(
MODEL,
arguments={
"prompt": shot["prompt"],
"negative_prompt": NEG,
"duration": "5",
"aspect_ratio": "9:16",
"cfg_scale": 0.5,
},
with_logs=False,
)

video_url = result["video"]["url"]
print(f" URL: {video_url[:60]}...")

r = requests.get(video_url, stream=True, timeout=120)
r.raise_for_status()
with open(out_path, "wb") as f:
for chunk in r.iter_content(1 << 16):
if chunk:
f.write(chunk)

size_mb = out_path.stat().st_size / 1024 / 1024
print(f" Saved: {out_path.name} ({size_mb:.1f} MB)")
return str(out_path)


if __name__ == "__main__":
print("=" * 60)
print("FAL Video Generation – Chef-8080 Woman Character Shots")
print("=" * 60)
print(f"Model: {MODEL}")
print(f"Shots: {len(SHOTS)}")
print(f"Estimated cost: ~{len(SHOTS) * 0.45:.2f}–{len(SHOTS) * 0.60:.2f} USD")
print()

results = []
for i, shot in enumerate(SHOTS, 1):
print(f"[{i}/{len(SHOTS)}] {shot['id']}")
try:
path = generate_shot(shot)
results.append({"id": shot["id"], "label": shot["label"], "path": path})
except Exception as e:
print(f" ERROR: {e}")
import traceback; traceback.print_exc()

manifest = OUT_DIR / "shots_manifest.json"
manifest.write_text(json.dumps(results, indent=2))

print("\n" + "=" * 60)
print(f"Done: {len(results)}/{len(SHOTS)} shots generated")
for r in results:
print(f" {r['id']}: {r['path']}")
print("=" * 60)
166 changes: 166 additions & 0 deletions missing_you_seedance_mv/mv_assemble.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
"""
Missing You × Seedance 2.0 — Full MV Assembly
Chef-8080 | YouTube 16:9 1280x720 | ~3:04
Concatenates numbered scene clips, mixes master audio, fades in/out.
"""

import os, sys, json, subprocess
from pathlib import Path

FFMPEG_DIR = r"C:\Users\ari_v\AppData\Local\Microsoft\WinGet\Packages\Gyan.FFmpeg_Microsoft.Winget.Source_8wekyb3d8bbwe\ffmpeg-8.1-full_build\bin"
os.environ["PATH"] = FFMPEG_DIR + os.pathsep + os.environ.get("PATH", "")
FFMPEG = os.path.join(FFMPEG_DIR, "ffmpeg.exe")

AUDIO_IN = r"C:\Users\ari_v\Downloads\Missing you P1.4 RADIO.mp3"
MV_DIR = Path(r"C:\Users\ari_v\Claude apps\Openmontage\output\missing_you_seedance")
SCENE_DIR = MV_DIR / "scenes"
TMP_DIR = MV_DIR / "tmp"
TMP_DIR.mkdir(parents=True, exist_ok=True)

SONG_DUR = 183.98 # 3:03.98

# Ordered timeline — anchor clips (001-003) excluded, production clips in sequence
TIMELINE = [
"004_intro_2025_sofia_wide",
"005_cafe_2000_wide",
"006_cafe_2000_marcus_talking",
"007_cafe_2000_sofia_laughing",
"008_cafe_2025_sofia_alone",
"009_cafe_2025_empty_chair",
"010_street_2000_jacket",
"011_street_2025_sofia_alone",
"012_street_2025_marcus_alone",
"013_record_2000",
"014_record_2025_sofia",
"015_park_2000_reading",
"016_park_2025_sofia_alone",
"017_park_2025_marcus_alone",
"018_dance_2000",
"019_dance_2025_sofia_watching",
"020_dance_2025_marcus_bar",
"021_intimate_2000_kiss",
"022_intimate_2000_running_rain",
"023_intimate_2000_walking_away",
"024_intimate_2000_hands",
"025_ecus_2000_sofia_happy",
"026_ecus_2000_marcus_laughing",
"027_ecus_2025_sofia_tears",
"028_ecus_2025_marcus_window",
"029_ecus_2025_sofia_photo",
"030_ecus_2025_marcus_phone",
"031_alone_2025_sofia_jacket",
"032_alone_2025_marcus_bridge",
"036_reunion_bridge_2025",
]


def prep_segment(clip_name, seg_idx):
"""Scale to 1280x720 @ 24fps, strip audio."""
src = SCENE_DIR / f"{clip_name}.mp4"
if not src.exists():
print(f" [MISSING] {clip_name}.mp4")
return None

out = TMP_DIR / f"seg_{seg_idx:03d}.mp4"
vf = "scale=1280:720:force_original_aspect_ratio=decrease,pad=1280:720:(ow-iw)/2:(oh-ih)/2,fps=24"
try:
subprocess.run([
FFMPEG, "-y",
"-i", str(src),
"-vf", vf,
"-c:v", "libx264", "-crf", "20", "-preset", "medium",
"-an",
str(out)
], check=True, capture_output=True, timeout=120)
return str(out)
except subprocess.CalledProcessError as e:
print(f" [prep fail] {clip_name}: {e.stderr.decode(errors='replace')[-300:]}")
return None


def assemble():
print(f"\nPreparing {len(TIMELINE)} timeline segments...")
prepared = []
total_dur = 0.0

for idx, clip_name in enumerate(TIMELINE):
path = prep_segment(clip_name, idx)
if path:
prepared.append(path)
# Read actual duration from source
res = subprocess.run([
FFMPEG.replace("ffmpeg.exe", "ffprobe.exe"),
"-v", "error", "-show_entries", "format=duration",
"-of", "default=noprint_wrappers=1:nokey=1",
str(SCENE_DIR / f"{clip_name}.mp4")
], capture_output=True, text=True)
dur = float(res.stdout.strip()) if res.stdout.strip() else 0
total_dur += dur
print(f" [{idx:02d}] {clip_name:45} cum={total_dur:.1f}s")
else:
print(f" [{idx:02d}] SKIPPED {clip_name}")

print(f"\n{len(prepared)}/{len(TIMELINE)} segments prepared, total={total_dur:.1f}s")
print(f"Song duration: {SONG_DUR}s")

if not prepared:
print("ERROR: No segments prepared. Aborting.")
sys.exit(1)

# ── Concatenate ────────────────────────────────────────────────────────────
print("\nConcatenating...")
raw = TMP_DIR / "timeline_raw.mp4"
clist = TMP_DIR / "concat.txt"
clist.write_text("\n".join(f"file '{p}'" for p in prepared), encoding="utf-8")
subprocess.run([
FFMPEG, "-y", "-f", "concat", "-safe", "0",
"-i", str(clist), "-c", "copy", str(raw)
], check=True, capture_output=True)

# ── Trim to song length ────────────────────────────────────────────────────
trimmed = TMP_DIR / "timeline_trimmed.mp4"
subprocess.run([
FFMPEG, "-y",
"-t", str(SONG_DUR),
"-i", str(raw),
"-c", "copy",
str(trimmed)
], check=True, capture_output=True)

# ── Final render ───────────────────────────────────────────────────────────
out_path = MV_DIR / "chef8080_missing_you_FULLMV_720p.mp4"
print(f"\nFinal render -> {out_path.name}")

vf_final = (
f"fade=t=in:st=0:d=1.5,"
f"fade=t=out:st={SONG_DUR - 2.5:.2f}:d=2.5"
)

res = subprocess.run([
FFMPEG, "-y",
"-i", str(trimmed),
"-i", AUDIO_IN,
"-vf", vf_final,
"-c:v", "libx264", "-crf", "18", "-preset", "medium",
"-c:a", "aac", "-b:a", "192k",
"-map", "0:v:0", "-map", "1:a:0",
"-t", str(SONG_DUR),
"-s", "1280x720",
"-movflags", "+faststart",
str(out_path)
], capture_output=True, timeout=900)

if res.returncode != 0:
print(res.stderr.decode(errors="replace"))
raise RuntimeError("Final render failed")

mb = out_path.stat().st_size / 1024 / 1024
print(f"\nDONE: {out_path} ({mb:.1f} MB)")
return out_path


if __name__ == "__main__":
print("=" * 65)
print("Chef-8080 – Missing You | Full MV Assembly (Seedance 2.0)")
print("=" * 65)
assemble()
Loading