File size: 11,536 Bytes
c219a4c
 
 
 
 
 
 
22144cf
c219a4c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fabab2d
c219a4c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fabab2d
c219a4c
 
 
 
 
 
fabab2d
c219a4c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fabab2d
c219a4c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fabab2d
c219a4c
22144cf
c219a4c
 
 
 
 
 
22144cf
c219a4c
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
#!/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()