Spaces:
Running
Running
| #!/usr/bin/env python3 | |
| import argparse | |
| import shlex | |
| import subprocess | |
| import sys | |
| from pathlib import Path | |
| from PIL import Image | |
| import shutil | |
| # pip install glitch-this pillow | |
| try: | |
| from glitch_this import ImageGlitcher | |
| except Exception as e: | |
| print("Missing dependency: pip install glitch-this pillow", file=sys.stderr) | |
| raise | |
| def log(msg: str): | |
| print(msg, flush=True) | |
| def run_cmd(cmd): | |
| pretty = shlex.join(cmd) if isinstance(cmd, list) else cmd | |
| log(f"[CMD] {pretty}") | |
| proc = subprocess.Popen( | |
| cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, universal_newlines=True | |
| ) | |
| for line in proc.stdout: | |
| print(line.rstrip()) | |
| ret = proc.wait() | |
| if ret != 0: | |
| raise subprocess.CalledProcessError(ret, cmd) | |
| return ret | |
| def ensure_parent(p: Path): | |
| p.parent.mkdir(parents=True, exist_ok=True) | |
| def safe_delete(*paths: Path): | |
| for p in paths: | |
| try: | |
| if p and Path(p).exists(): | |
| Path(p).unlink() | |
| log(f"[CLEAN] removed {p}") | |
| except Exception as e: | |
| log(f"[CLEAN][warn] could not remove {p}: {e}") | |
| # ---------------- builders return True if they CREATED the output ---------------- | |
| def make_glitch_gif(img_path: Path, out_gif: Path, fps: int, n_frames: int, | |
| mode: str = "constant", amt_start: float = 0.7, amt_end: float = 0.7) -> bool: | |
| """Make GIF. Return True if created (file didn't exist).""" | |
| if out_gif.exists(): | |
| log(f"[SKIP] {out_gif} already exists (not overwriting)") | |
| return False | |
| ensure_parent(out_gif) | |
| log(f"[GLITCH] source={img_path} -> {out_gif} | fps={fps} frames={n_frames} mode={mode} amt={amt_start}->{amt_end}") | |
| img = Image.open(img_path).convert("RGBA") | |
| glitcher = ImageGlitcher() | |
| frames = [] | |
| if n_frames < 2: | |
| n_frames = 2 | |
| for i in range(n_frames): | |
| if mode == "ramp" and n_frames > 1: | |
| amt = amt_start + (amt_end - amt_start) * (i / (n_frames - 1)) | |
| else: | |
| amt = amt_start | |
| try: | |
| frame = glitcher.glitch_image(img, amt, color_offset=True, scan_lines=False, seed=i) | |
| except TypeError: | |
| frame = glitcher.glitch_image(img, amt, color_offset=True, scan_lines=False) | |
| frames.append(frame.convert("P", palette=Image.ADAPTIVE)) | |
| if n_frames <= 120 or i % max(1, n_frames // 60) == 0: | |
| log(f"[GLITCH] frame {i+1}/{n_frames} amt={amt:.3f}") | |
| delay_ms = max(1, round(1000 / fps)) | |
| frames[0].save( | |
| out_gif, | |
| save_all=True, | |
| append_images=frames[1:], | |
| duration=delay_ms, | |
| loop=0, | |
| disposal=2, | |
| optimize=False, | |
| transparency=0, | |
| ) | |
| log(f"[GLITCH] wrote {out_gif}") | |
| return True | |
| def build_concat_raw(gif1: Path, gif2: Path, out_mp4: Path, fps: int, dur_total: float, dur_g2: float) -> bool: | |
| """Concat with looped GIF1; return True if created.""" | |
| if out_mp4.exists(): | |
| log(f"[SKIP] {out_mp4} already exists (not overwriting)") | |
| return False | |
| ensure_parent(out_mp4) | |
| F = int(fps) | |
| D1 = max(0.0, float(dur_total) - float(dur_g2)) | |
| D2 = float(dur_g2) | |
| filter_complex = ( | |
| f"[0:v]fps={F},setpts=N/({F}*TB),trim=duration={D1}[a];" | |
| f"[1:v]fps={F},setpts=N/({F}*TB),trim=duration={D2}[b];" | |
| f"[a][b]concat=n=2:v=1:a=0[v]" | |
| ) | |
| cmd = [ | |
| "ffmpeg", "-n", | |
| "-stream_loop", "-1", "-i", str(gif1), | |
| "-ignore_loop", "1", "-i", str(gif2), | |
| "-filter_complex", filter_complex, | |
| "-map", "[v]", | |
| "-r", str(F), | |
| "-t", str(dur_total), | |
| "-c:v", "libx264", "-pix_fmt", "yuv420p", | |
| str(out_mp4) | |
| ] | |
| run_cmd(cmd) | |
| return True | |
| def apply_vfx(in_mp4: Path, out_mp4: Path, fps: int, dur_total: float) -> bool: | |
| """ | |
| Softer VFX: gentler zoom, smaller XY wobble, smaller rotation sway. | |
| Keeps more of the picture visible. | |
| """ | |
| if out_mp4.exists(): | |
| log(f"[SKIP] {out_mp4} already exists (not overwriting)") | |
| return False | |
| ensure_parent(out_mp4) | |
| F = int(fps) | |
| L = float(dur_total) | |
| # toned-down params | |
| base_zoom = 1.01 | |
| zoom_amp = 0.01 | |
| x_wobble_1 = 10 | |
| x_wobble_2 = 4 | |
| y_wobble_1 = 8 | |
| y_wobble_2 = 3 | |
| rot_main = 0.006 | |
| rot_jitter = 0.002 | |
| pre_scale_h = 2400 | |
| overscan_w = 1152 | |
| overscan_h = 2048 | |
| filter_complex = ( | |
| f"[0:v]fps={F},setpts=N/({F}*TB)," | |
| f"scale=-1:{pre_scale_h}," | |
| f"zoompan=" | |
| f"z='{base_zoom}+{zoom_amp}*sin(2*PI*(on/{F})/{L})':" | |
| f"x='(iw-iw/zoom)/2 + {x_wobble_1}*sin(2*PI*(on/{F})/{L}*3) + {x_wobble_2}*sin(2*PI*(on/{F})/{L}*7)':" | |
| f"y='(ih-ih/zoom)/2 + {y_wobble_1}*sin(2*PI*(on/{F})/{L}*2) + {y_wobble_2}*sin(2*PI*(on/{F})/{L}*5)':" | |
| f"d=1:s={overscan_w}x{overscan_h}:fps={F}," | |
| f"rotate='{rot_main}*sin(2*PI*t/{L}) + {rot_jitter}*sin(2*PI*t/{L}*7)':" | |
| f"ow=rotw(iw):oh=roth(ih)," | |
| f"crop=1080:1920[v]" | |
| ) | |
| cmd = [ | |
| "ffmpeg", "-n", | |
| "-i", str(in_mp4), | |
| "-filter_complex", filter_complex, | |
| "-map", "[v]", | |
| "-r", str(F), | |
| "-c:v", "libx264", "-pix_fmt", "yuv420p", | |
| str(out_mp4) | |
| ] | |
| log("[CMD] " + shlex.join(cmd)) | |
| run_cmd(cmd) | |
| return True | |
| def apply_vfxHigh(in_mp4: Path, out_mp4: Path, fps: int, dur_total: float) -> bool: | |
| """Original stronger VFX; return True if created.""" | |
| if out_mp4.exists(): | |
| log(f"[SKIP] {out_mp4} already exists (not overwriting)") | |
| return False | |
| ensure_parent(out_mp4) | |
| F = int(fps) | |
| L = float(dur_total) | |
| filter_complex = ( | |
| f"[0:v]fps={F},setpts=N/({F}*TB)," | |
| f"scale=-1:2880," | |
| f"zoompan=" | |
| f"z='1.10+0.08*sin(2*PI*(on/{F})/{L})':" | |
| f"x='(iw-iw/zoom)/2 + 24*sin(2*PI*(on/{F})/{L}*3) + 10*sin(2*PI*(on/{F})/{L}*7)':" | |
| f"y='(ih-ih/zoom)/2 + 18*sin(2*PI*(on/{F})/{L}*2) + 9*sin(2*PI*(on/{F})/{L}*5)':" | |
| f"d=1:s=1296x2304:fps={F}," | |
| f"rotate='0.012*sin(2*PI*t/{L}) + 0.004*sin(2*PI*t/{L}*7)':ow=rotw(iw):oh=roth(ih)," | |
| f"crop=1080:1920[v]" | |
| ) | |
| cmd = [ | |
| "ffmpeg", "-n", | |
| "-i", str(in_mp4), | |
| "-filter_complex", filter_complex, | |
| "-map", "[v]", | |
| "-r", str(F), | |
| "-c:v", "libx264", "-pix_fmt", "yuv420p", | |
| str(out_mp4) | |
| ] | |
| run_cmd(cmd) | |
| return True | |
| def add_transitions(in_mp4: Path, out_mp4: Path, fps: int, dur_total: float, | |
| wobble_main: float = 0.028, wobble_jitter: float = 0.012, | |
| wobble_f1: float = 5.0, wobble_f2: float = 11.0, | |
| blur_sigma: int = 42) -> bool: | |
| """ | |
| Wobble/sway IN (0–0.5s) and OUT (last 0.5s). Heavy blur only during transitions. | |
| """ | |
| if out_mp4.exists(): | |
| log(f"[SKIP] {out_mp4} already exists (not overwriting)") | |
| return False | |
| ensure_parent(out_mp4) | |
| F = int(fps) | |
| L = float(dur_total) | |
| end_start = max(0.0, L - 0.5) | |
| angle_expr = ( | |
| f"( if(lte(t,0.5),1,0) + if(gte(t,{end_start}),1,0) ) * " | |
| f"({wobble_main}*sin(2*PI*t*{wobble_f1}) + {wobble_jitter}*sin(2*PI*t*{wobble_f2}))" | |
| ) | |
| blur_enable = f"between(t,0,0.5)+between(t,{end_start},{end_start}+0.5)" | |
| filt = ( | |
| f"[0:v]fps={F},scale=1296:2304," | |
| f"rotate='{angle_expr}':ow=rotw(iw):oh=roth(ih)," | |
| f"gblur=sigma={blur_sigma}:steps=3:enable='{blur_enable}'," | |
| f"crop=1080:1920[v]" | |
| ) | |
| cmd = [ | |
| "ffmpeg", "-n", | |
| "-i", str(in_mp4), | |
| "-t", f"{L:.3f}", | |
| "-filter_complex", filt, | |
| "-map", "[v]", "-map", "0:a?", | |
| "-c:v", "libx264", "-r", str(F), "-pix_fmt", "yuv420p", | |
| "-c:a", "copy", | |
| str(out_mp4) | |
| ] | |
| run_cmd(cmd) | |
| return True | |
| # --------------------------------- main --------------------------------- | |
| def main(): | |
| ap = argparse.ArgumentParser(description="Glitch → loop+concat → VFX → wobble-blur transitions (no overwrite, verbose)") | |
| ap.add_argument("image", type=Path, help="Input image path") | |
| ap.add_argument("duration", type=float, help="Total output duration in seconds (e.g., 8.0)") | |
| ap.add_argument("--fps", type=int, default=60, help="Frames per second (default: 60)") | |
| ap.add_argument("--base", type=Path, default=None, help="Output basename (default: image stem)") | |
| ap.add_argument("--out", type=Path, default=None, help="Output filename") | |
| ap.add_argument("--glitch2_secs", type=float, default=2.0, help="Duration of heavy glitch segment (default: 2.0s)") | |
| # Transition tuning | |
| ap.add_argument("--wobble_main", type=float, default=0.008, help="Main wobble radians amplitude during transitions") | |
| ap.add_argument("--wobble_jitter", type=float, default=0.002, help="Jitter wobble radians amplitude during transitions") | |
| ap.add_argument("--wobble_f1", type=float, default=1.0, help="Wobble frequency 1 (Hz)") | |
| ap.add_argument("--wobble_f2", type=float, default=1.0, help="Wobble frequency 2 (Hz)") | |
| ap.add_argument("--blur", type=int, default=6, help="Gaussian blur sigma during transitions") | |
| args = ap.parse_args() | |
| img_path = args.image | |
| duration = float(args.duration) | |
| fps = int(args.fps) | |
| base = args.base or img_path.with_suffix("") | |
| base = Path(str(base)) | |
| glitch2_secs = float(args.glitch2_secs) | |
| # Durations | |
| seg1_secs = max(0.0, duration - glitch2_secs) | |
| seg2_secs = glitch2_secs | |
| # Frames to generate initially (GIF1 loops later) | |
| gif1_frames = max(2, int(round(min(seg1_secs if seg1_secs > 0 else 2.0, 2.0) * fps))) | |
| gif2_frames = max(2, int(round(seg2_secs * fps))) | |
| gif1 = Path(f"{base}_glitch1.gif") | |
| gif2 = Path(f"{base}_glitch2.gif") | |
| concat_raw = Path(f"{base}_raw.mp4") | |
| vfx_mp4 = Path(f"{base}_vfx.mp4") | |
| final_mp4 = args.base or Path(f"{base}_final.mp4") | |
| log(f"[SETUP] image={img_path} duration={duration}s fps={fps} glitch2_secs={glitch2_secs}s") | |
| log(f"[PLAN] seg1(loop)={seg1_secs:.3f}s seg2(heavy)={seg2_secs:.3f}s") | |
| log(f"[FRAMES] gif1={gif1_frames} gif2={gif2_frames}") | |
| log(f"[OUTPUTS] {gif1}, {gif2}, {concat_raw}, {vfx_mp4}, {final_mp4}") | |
| # 1) GIFs | |
| created_g1 = make_glitch_gif(img_path, gif1, fps=fps, n_frames=gif1_frames, mode="constant", amt_start=0.7, amt_end=0.7) | |
| created_g2 = make_glitch_gif(img_path, gif2, fps=fps, n_frames=gif2_frames, mode="ramp", amt_start=3.0, amt_end=5.0) | |
| # If either GIF was (re)generated, downstream is stale | |
| if created_g1 or created_g2: | |
| safe_delete(concat_raw, vfx_mp4, final_mp4) | |
| # 2) Concat | |
| created_concat = build_concat_raw(gif1, gif2, concat_raw, fps=fps, dur_total=duration, dur_g2=seg2_secs) | |
| if created_concat: | |
| safe_delete(vfx_mp4, final_mp4) | |
| # 3) VFX | |
| created_vfx = apply_vfx(concat_raw, vfx_mp4, fps=fps, dur_total=duration) | |
| if created_vfx: | |
| safe_delete(final_mp4) | |
| # 4) Transitions | |
| add_transitions(vfx_mp4, final_mp4, fps=fps, dur_total=duration, | |
| wobble_main=args.wobble_main, wobble_jitter=args.wobble_jitter, | |
| wobble_f1=args.wobble_f1, wobble_f2=args.wobble_f2, | |
| blur_sigma=args.blur) | |
| shutil.copy(final_mp4, args.out) | |
| log("[DONE]") | |
| log(f" - GIF 1: {gif1}") | |
| log(f" - GIF 2: {gif2}") | |
| log(f" - MP4 raw (looped+concat): {concat_raw}") | |
| log(f" - MP4 with VFX: {vfx_mp4}") | |
| log(f" - MP4 final with transitions: {final_mp4}") | |
| log(f" - Out File: {args.out}") | |
| if __name__ == "__main__": | |
| main() | |