Vo Hoang Minh commited on
Commit
03dbefe
·
1 Parent(s): 1bae598
Files changed (6) hide show
  1. .gitattributes +0 -35
  2. app.py +110 -57
  3. assets/3d_perspective.json +0 -146
  4. assets/light_effect.json +0 -205
  5. kenburns.py +252 -0
  6. motion.json +171 -0
.gitattributes DELETED
@@ -1,35 +0,0 @@
1
- *.7z filter=lfs diff=lfs merge=lfs -text
2
- *.arrow filter=lfs diff=lfs merge=lfs -text
3
- *.bin filter=lfs diff=lfs merge=lfs -text
4
- *.bz2 filter=lfs diff=lfs merge=lfs -text
5
- *.ckpt filter=lfs diff=lfs merge=lfs -text
6
- *.ftz filter=lfs diff=lfs merge=lfs -text
7
- *.gz filter=lfs diff=lfs merge=lfs -text
8
- *.h5 filter=lfs diff=lfs merge=lfs -text
9
- *.joblib filter=lfs diff=lfs merge=lfs -text
10
- *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
- *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
- *.model filter=lfs diff=lfs merge=lfs -text
13
- *.msgpack filter=lfs diff=lfs merge=lfs -text
14
- *.npy filter=lfs diff=lfs merge=lfs -text
15
- *.npz filter=lfs diff=lfs merge=lfs -text
16
- *.onnx filter=lfs diff=lfs merge=lfs -text
17
- *.ot filter=lfs diff=lfs merge=lfs -text
18
- *.parquet filter=lfs diff=lfs merge=lfs -text
19
- *.pb filter=lfs diff=lfs merge=lfs -text
20
- *.pickle filter=lfs diff=lfs merge=lfs -text
21
- *.pkl filter=lfs diff=lfs merge=lfs -text
22
- *.pt filter=lfs diff=lfs merge=lfs -text
23
- *.pth filter=lfs diff=lfs merge=lfs -text
24
- *.rar filter=lfs diff=lfs merge=lfs -text
25
- *.safetensors filter=lfs diff=lfs merge=lfs -text
26
- saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
- *.tar.* filter=lfs diff=lfs merge=lfs -text
28
- *.tar filter=lfs diff=lfs merge=lfs -text
29
- *.tflite filter=lfs diff=lfs merge=lfs -text
30
- *.tgz filter=lfs diff=lfs merge=lfs -text
31
- *.wasm filter=lfs diff=lfs merge=lfs -text
32
- *.xz filter=lfs diff=lfs merge=lfs -text
33
- *.zip filter=lfs diff=lfs merge=lfs -text
34
- *.zst filter=lfs diff=lfs merge=lfs -text
35
- *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app.py CHANGED
@@ -1,63 +1,116 @@
 
1
  import gradio as gr
2
- import json, os, subprocess, tempfile, shlex, mimetypes
 
3
 
4
- def load_effects(path="assets"):
5
- effects = {}
6
- for file in os.listdir(path):
7
- if file.endswith(".json"):
8
- with open(os.path.join(path, file), encoding="utf-8") as f:
9
- for e in json.load(f).get("effects", []):
10
- effects[e["name"]] = e["command"]
11
- return effects
12
 
13
- EFFECTS = load_effects()
 
 
 
 
14
 
15
- def get_command(name): return EFFECTS.get(name, "")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16
 
17
- def run_ffmpeg(input_path, command_str, output_ext):
18
- out = tempfile.NamedTemporaryFile(suffix=output_ext, delete=False).name
19
- args = ["ffmpeg", "-y", "-i", input_path] + shlex.split(command_str) + ["-preset", "ultrafast", out]
20
  try:
21
- subprocess.run(args, check=True)
22
- return "✅ Hiệu ứng đã tạo", out, " ".join(args)
23
- except subprocess.CalledProcessError as e:
24
- return f"❌ Lỗi FFmpeg: {e}", None, " ".join(args)
25
-
26
- def apply_effect(file, command, out_type):
27
- if not file:
28
- return "❌ Chưa chọn file", None, ""
29
- ext = ".gif" if out_type == "GIF" else ".mp4"
30
- return run_ffmpeg(file.name, command, ext)
31
-
32
- def preview_media(file):
33
- if not file:
34
- return None, None
35
- mime, _ = mimetypes.guess_type(file.name)
36
- if mime and mime.startswith("image"):
37
- return file.name, None
38
- elif mime and mime.startswith("video"):
39
- return None, file.name
40
- return None, None
41
-
42
- with gr.Blocks() as app:
43
- gr.Markdown("🎞️ **Hiệu ứng video bằng FFmpeg từ file JSON**")
44
-
45
- f = gr.File(label="Tệp ảnh/video", file_types=["image", "video"])
46
- t = gr.Radio(["MP4", "GIF"], value="MP4", label="Loại đầu ra")
47
- name_select = gr.Dropdown(choices=list(EFFECTS), label="Hoặc chọn hiệu ứng")
48
- cmd_box = gr.Textbox(label="Lệnh FFmpeg", lines=2)
49
- log_box = gr.Textbox(label="Log", lines=2, interactive=False)
50
- msg = gr.Textbox(label="Trạng thái")
51
-
52
- with gr.Row():
53
- preview_img = gr.Image(label="Ảnh đã chọn", visible=False)
54
- preview_vid = gr.Video(label="Video đã chọn", visible=False)
55
-
56
- out = gr.Video(label="Kết quả")
57
- btn = gr.Button("Tạo hiệu ứng")
58
-
59
- name_select.change(get_command, name_select, cmd_box)
60
- f.change(preview_media, f, [preview_img, preview_vid])
61
- btn.click(apply_effect, [f, cmd_box, t], [msg, out, log_box])
62
-
63
- app.launch()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pathlib import Path
2
  import gradio as gr
3
+ import kenburns as kb
4
+ import tempfile, shutil
5
 
6
+ # ---------------------------------------------------------------------------
7
+ # 1. Helpers
8
+ # ---------------------------------------------------------------------------
9
+ TMP_DIR = Path(tempfile.gettempdir()) / "kb_ui"
10
+ TMP_DIR.mkdir(exist_ok=True, parents=True)
 
 
 
11
 
12
+ EXAMPLE_DIR = Path(__file__).parent.parent / "examples"
13
+ EXAMPLE_SETS = [
14
+ [str(p) for p in sorted((EXAMPLE_DIR / "set1").glob("*.jpg"))],
15
+ [str(p) for p in sorted((EXAMPLE_DIR / "set2").glob("*.jpg"))],
16
+ ]
17
 
18
+ STYLE_OPTIONS = [
19
+ ("Random all", "*"),
20
+ ("Random zoom preset (one for ALL slides)", "zoom_*"),
21
+ ("Random zoom preset (DIFFERENT per slide)", "zoom_*!"),
22
+ ("Random pan preset (all)", "pan_*"),
23
+ ("Random pan preset (diff per slide)", "pan_*!"),
24
+ ]
25
+
26
+
27
+ def build_preview(files):
28
+ """Return list of file paths for Gallery"""
29
+ return [f.name for f in files] if files else None
30
+
31
+
32
+ # Gradio runner -------------------------------------------------------------
33
+
34
+ def run_slideshow(files, dur, style_pattern, fps, cf, seed, progress=gr.Progress()):
35
+ if not files:
36
+ return None, None, "⚠️ Please upload or select example images."
37
+
38
+ # ensure reproducible paths & order
39
+ image_paths = [f.name for f in files]
40
+ durations = [dur] * len(image_paths)
41
+
42
+ out_path = TMP_DIR / "slideshow.mp4"
43
 
 
 
 
44
  try:
45
+ # show spinner/progress while FFmpeg runs
46
+ with progress.tqdm(desc="Rendering", total=1) as pbar:
47
+ vid_path, cmd = kb.generate_video(
48
+ image_paths,
49
+ durations,
50
+ style=style_pattern,
51
+ fps=fps,
52
+ cf=cf,
53
+ seed=(seed if seed else None),
54
+ out=str(out_path),
55
+ )
56
+ pbar.update()
57
+ return str(vid_path), cmd, "✅ Done"
58
+ except Exception as exc:
59
+ return None, None, f"❌ Error: {exc}"
60
+
61
+
62
+ # Gradio interface ----------------------------------------------------------
63
+
64
+ def launch():
65
+ with gr.Blocks(title="Ken Burns Slideshow (v2)") as demo:
66
+ gr.Markdown("## 🖼️ Ken Burns Slideshow Generator")
67
+
68
+ # --- Upload and preview ------------------------------------------
69
+ with gr.Row():
70
+ file_uploader = gr.File(
71
+ label="Upload images (drag to reorder in your OS before upload)",
72
+ file_count="multiple",
73
+ interactive=True,
74
+ )
75
+ gallery = gr.Gallery(label="Preview", show_label=True).style(grid=4)
76
+ file_uploader.upload(build_preview, file_uploader, gallery)
77
+
78
+ # --- Examples ----------------------------------------------------
79
+ gr.Examples(
80
+ examples=EXAMPLE_SETS,
81
+ inputs=file_uploader,
82
+ label="🖼️ Use example image sets (click to load)",
83
+ )
84
+
85
+ # --- Parameters --------------------------------------------------
86
+ with gr.Column():
87
+ dur_slider = gr.Slider(3, 15, value=6, step=0.5, label="Seconds per image")
88
+ style_dropdown = gr.Dropdown(
89
+ choices=[o[1] for o in STYLE_OPTIONS],
90
+ value="*",
91
+ label="Style pattern",
92
+ info="* = fully random | zoom_* = random zoom preset | zoom_*! = random zoom per slide",
93
+ )
94
+
95
+ with gr.Accordion("Advanced settings", open=False):
96
+ fps_slider = gr.Slider(15, 60, value=30, step=5, label="FPS")
97
+ cf_slider = gr.Slider(0.05, 0.3, value=0.15, step=0.01, label="Cross‑fade factor (0‑1)")
98
+ seed_num = gr.Number(value=0, label="Random seed (0 = random each run)")
99
+
100
+ # --- Output ------------------------------------------------------
101
+ out_video = gr.Video(label="Output video")
102
+ ff_cmd_box = gr.Textbox(label="FFmpeg command", lines=3)
103
+ status_box = gr.Textbox(label="Status / Errors", interactive=False)
104
+
105
+ run_btn = gr.Button("Generate 🎬")
106
+ run_btn.click(
107
+ run_slideshow,
108
+ inputs=[file_uploader, dur_slider, style_dropdown, fps_slider, cf_slider, seed_num],
109
+ outputs=[out_video, ff_cmd_box, status_box],
110
+ )
111
+
112
+ demo.launch()
113
+
114
+
115
+ if __name__ == "__main__":
116
+ launch()
assets/3d_perspective.json DELETED
@@ -1,146 +0,0 @@
1
- {
2
- "category": "3d_perspective",
3
- "tags": ["3d", "perspective", "fisheye", "barrel", "distortion", "warp", "transform", "projection"],
4
- "effects": [
5
- {
6
- "name": "Fisheye Lens Effect",
7
- "command": "-vf \"v360=input=flat:output=fisheye:ih_fov=60:iv_fov=60\"",
8
- "preview_url": "",
9
- "description": "Create fisheye lens distortion effect",
10
- "input": "video or image",
11
- "rating": {
12
- "popularity": 3.8,
13
- "visual_impact": 4.5,
14
- "ease_of_use": 3.5,
15
- "creativity": 4.2
16
- },
17
- "tags": ["fisheye", "distortion", "wide-angle", "curved"]
18
- },
19
- {
20
- "name": "Barrel Distortion",
21
- "command": "-vf \"lenscorrection=k1=0.15:k2=0.05\"",
22
- "preview_url": "",
23
- "description": "Barrel distortion for vintage lens effect",
24
- "input": "video or image",
25
- "rating": {
26
- "popularity": 3.2,
27
- "visual_impact": 3.8,
28
- "ease_of_use": 3.0,
29
- "creativity": 4.0
30
- },
31
- "tags": ["barrel", "vintage", "lens", "curved"]
32
- },
33
- {
34
- "name": "Pincushion Distortion",
35
- "command": "-vf \"lenscorrection=k1=-0.15:k2=-0.05\"",
36
- "preview_url": "",
37
- "description": "Pincushion distortion effect",
38
- "input": "video or image",
39
- "rating": {
40
- "popularity": 2.8,
41
- "visual_impact": 3.5,
42
- "ease_of_use": 3.0,
43
- "creativity": 3.8
44
- },
45
- "tags": ["pincushion", "inward", "distortion", "telephoto"]
46
- },
47
- {
48
- "name": "Fisheye to Flat",
49
- "command": "-vf \"v360=input=fisheye:output=flat:ih_fov=180:iv_fov=180\"",
50
- "preview_url": "",
51
- "description": "Convert fisheye footage to flat perspective",
52
- "input": "fisheye video or image",
53
- "rating": {
54
- "popularity": 4.0,
55
- "visual_impact": 3.2,
56
- "ease_of_use": 3.8,
57
- "creativity": 2.5
58
- },
59
- "tags": ["correction", "flat", "unfisheye", "normalize"]
60
- },
61
- {
62
- "name": "360 to Equirectangular",
63
- "command": "-vf \"v360=input=dfisheye:output=e:yaw=-90\"",
64
- "preview_url": "",
65
- "description": "Convert dual fisheye 360 to equirectangular format",
66
- "input": "360 dual fisheye video",
67
- "rating": {
68
- "popularity": 3.5,
69
- "visual_impact": 3.0,
70
- "ease_of_use": 3.2,
71
- "creativity": 2.8
72
- },
73
- "tags": ["360", "equirectangular", "vr", "panorama"]
74
- },
75
- {
76
- "name": "Perspective Rotation",
77
- "command": "-vf \"v360=input=flat:output=flat:pitch=20:yaw=15:roll=5\"",
78
- "preview_url": "",
79
- "description": "Rotate perspective in 3D space",
80
- "input": "video or image",
81
- "rating": {
82
- "popularity": 2.5,
83
- "visual_impact": 3.8,
84
- "ease_of_use": 3.0,
85
- "creativity": 4.5
86
- },
87
- "tags": ["3d", "rotation", "pitch", "yaw", "roll"]
88
- },
89
- {
90
- "name": "Lens Correction",
91
- "command": "-vf \"lenscorrection=k1=-0.227:k2=-0.022\"",
92
- "preview_url": "",
93
- "description": "Correct lens distortion from real cameras",
94
- "input": "distorted video or image",
95
- "rating": {
96
- "popularity": 3.8,
97
- "visual_impact": 2.5,
98
- "ease_of_use": 2.8,
99
- "creativity": 2.0
100
- },
101
- "tags": ["correction", "undistort", "calibration", "fix"]
102
- },
103
- {
104
- "name": "Spherical Perspective",
105
- "command": "-vf \"v360=input=flat:output=sg:h_fov=90:v_fov=90\"",
106
- "preview_url": "",
107
- "description": "Map flat image to spherical perspective",
108
- "input": "video or image",
109
- "rating": {
110
- "popularity": 2.2,
111
- "visual_impact": 4.0,
112
- "ease_of_use": 2.8,
113
- "creativity": 4.7
114
- },
115
- "tags": ["spherical", "planet", "globe", "wrap"]
116
- },
117
- {
118
- "name": "VR Projection",
119
- "command": "-vf \"v360=input=equirect:output=cubemap_3_2\"",
120
- "preview_url": "",
121
- "description": "Convert equirectangular to VR cubemap format",
122
- "input": "360 equirectangular video",
123
- "rating": {
124
- "popularity": 3.0,
125
- "visual_impact": 3.2,
126
- "ease_of_use": 3.5,
127
- "creativity": 3.5
128
- },
129
- "tags": ["vr", "cubemap", "projection", "format"]
130
- },
131
- {
132
- "name": "Tiny Planet Effect",
133
- "command": "-vf \"v360=input=equirect:output=sg:pitch=90\"",
134
- "preview_url": "",
135
- "description": "Create tiny planet effect from 360 footage",
136
- "input": "360 equirectangular video",
137
- "rating": {
138
- "popularity": 3.8,
139
- "visual_impact": 4.8,
140
- "ease_of_use": 4.0,
141
- "creativity": 4.9
142
- },
143
- "tags": ["tiny-planet", "stereographic", "miniature", "creative"]
144
- }
145
- ]
146
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
assets/light_effect.json DELETED
@@ -1,205 +0,0 @@
1
- {
2
- "category": "light_effects",
3
- "tags": [
4
- "light",
5
- "glow",
6
- "blur",
7
- "flare",
8
- "radial",
9
- "lens",
10
- "bright",
11
- "illuminate"
12
- ],
13
- "effects": [
14
- {
15
- "name": "Gaussian Blur Glow",
16
- "command": "-vf \"gblur=sigma=10\"",
17
- "preview_url": "",
18
- "description": "Soft gaussian blur for dreamy glow effect",
19
- "input": "video or image",
20
- "rating": {
21
- "popularity": 4.2,
22
- "visual_impact": 3.8,
23
- "ease_of_use": 5.0,
24
- "creativity": 3.0
25
- },
26
- "tags": [
27
- "gaussian",
28
- "blur",
29
- "soft",
30
- "dreamy"
31
- ]
32
- },
33
- {
34
- "name": "Box Blur Glow",
35
- "command": "-vf \"boxblur=10\"",
36
- "preview_url": "",
37
- "description": "Box blur for bokeh-like camera effect",
38
- "input": "video or image",
39
- "rating": {
40
- "popularity": 3.8,
41
- "visual_impact": 3.5,
42
- "ease_of_use": 5.0,
43
- "creativity": 2.8
44
- },
45
- "tags": [
46
- "box",
47
- "blur",
48
- "bokeh",
49
- "camera"
50
- ]
51
- },
52
- {
53
- "name": "Intense Glow",
54
- "command": "-vf \"gblur=sigma=20:steps=3\"",
55
- "preview_url": "",
56
- "description": "Strong glow effect with multiple blur steps",
57
- "input": "video or image",
58
- "rating": {
59
- "popularity": 3.5,
60
- "visual_impact": 4.2,
61
- "ease_of_use": 4.5,
62
- "creativity": 3.5
63
- },
64
- "tags": [
65
- "intense",
66
- "glow",
67
- "strong",
68
- "multiple"
69
- ]
70
- },
71
- {
72
- "name": "Radial Blur Zoom",
73
- "command": "-vf \"crop=iw-40:ih-40:20:20,scale=iw:ih,gblur=sigma=5\"",
74
- "preview_url": "",
75
- "description": "Radial blur effect simulating motion towards center",
76
- "input": "video or image",
77
- "rating": {
78
- "popularity": 3.2,
79
- "visual_impact": 4.0,
80
- "ease_of_use": 3.8,
81
- "creativity": 4.2
82
- },
83
- "tags": [
84
- "radial",
85
- "motion",
86
- "zoom",
87
- "center"
88
- ]
89
- },
90
- {
91
- "name": "Brightness Explosion",
92
- "command": "-vf \"eq=brightness=0.3:contrast=1.5,gblur=sigma=8\"",
93
- "preview_url": "",
94
- "description": "Bright explosion effect with glow",
95
- "input": "video or image",
96
- "rating": {
97
- "popularity": 3.0,
98
- "visual_impact": 4.3,
99
- "ease_of_use": 4.0,
100
- "creativity": 4.0
101
- },
102
- "tags": [
103
- "explosion",
104
- "bright",
105
- "dramatic",
106
- "energy"
107
- ]
108
- },
109
- {
110
- "name": "Selective Blur Glow",
111
- "command": "-vf \"smartblur=1.0:0.8:0\"",
112
- "preview_url": "",
113
- "description": "Smart blur that preserves edges while creating glow",
114
- "input": "video or image",
115
- "rating": {
116
- "popularity": 2.8,
117
- "visual_impact": 3.8,
118
- "ease_of_use": 3.5,
119
- "creativity": 3.8
120
- },
121
- "tags": [
122
- "smart",
123
- "selective",
124
- "edges",
125
- "preserve"
126
- ]
127
- },
128
- {
129
- "name": "Soft Light Halo",
130
- "command": "-vf \"gblur=sigma=15,eq=brightness=0.1:contrast=0.9\"",
131
- "preview_url": "",
132
- "description": "Soft halo effect around subjects",
133
- "input": "video or image",
134
- "rating": {
135
- "popularity": 3.5,
136
- "visual_impact": 4.0,
137
- "ease_of_use": 4.2,
138
- "creativity": 3.7
139
- },
140
- "tags": [
141
- "halo",
142
- "soft",
143
- "angelic",
144
- "portrait"
145
- ]
146
- },
147
- {
148
- "name": "Lens Flare Simulation",
149
- "command": "-vf \"eq=brightness=0.2:contrast=1.3,gblur=sigma=12\"",
150
- "preview_url": "",
151
- "description": "Basic lens flare simulation with brightness and blur",
152
- "input": "video or image",
153
- "rating": {
154
- "popularity": 4.0,
155
- "visual_impact": 4.2,
156
- "ease_of_use": 4.5,
157
- "creativity": 3.8
158
- },
159
- "tags": [
160
- "lens-flare",
161
- "simulation",
162
- "cinematic",
163
- "bright"
164
- ]
165
- },
166
- {
167
- "name": "Moonlight Glow",
168
- "command": "-vf \"eq=brightness=-0.1:contrast=0.8,gblur=sigma=6,hue=h=240\"",
169
- "preview_url": "",
170
- "description": "Soft moonlight effect with blue tint and glow",
171
- "input": "video or image",
172
- "rating": {
173
- "popularity": 3.2,
174
- "visual_impact": 4.1,
175
- "ease_of_use": 4.0,
176
- "creativity": 4.3
177
- },
178
- "tags": [
179
- "moonlight",
180
- "blue",
181
- "night",
182
- "romantic"
183
- ]
184
- },
185
- {
186
- "name": "Golden Hour Glow",
187
- "command": "-vf \"eq=brightness=0.15:saturation=1.2,gblur=sigma=4,colortemperature=temperature=3000\"",
188
- "preview_url": "",
189
- "description": "Warm golden hour lighting with soft glow",
190
- "input": "video or image",
191
- "rating": {
192
- "popularity": 4.5,
193
- "visual_impact": 4.6,
194
- "ease_of_use": 4.2,
195
- "creativity": 4.0
196
- },
197
- "tags": [
198
- "golden-hour",
199
- "warm",
200
- "sunset",
201
- "cinematic"
202
- ]
203
- }
204
- ]
205
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
kenburns.py ADDED
@@ -0,0 +1,252 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import fnmatch
5
+ import random
6
+ import shlex
7
+ import subprocess
8
+ from dataclasses import dataclass
9
+ from functools import lru_cache
10
+ from math import cos, pi
11
+ from pathlib import Path
12
+ from typing import Dict, List, Sequence, Tuple, Union
13
+
14
+ ################################################################################
15
+ # Domain models
16
+ ################################################################################
17
+
18
+ @dataclass(frozen=True, slots=True)
19
+ class Preset:
20
+ kind: str
21
+ start: float | None = None
22
+ end: float | None = None
23
+ anchor: str | None = None
24
+ dx: float | None = None
25
+ dy: float | None = None
26
+ rot_start: float | None = None
27
+ rot_end: float | None = None
28
+ ease: str = "linear"
29
+
30
+ @staticmethod
31
+ def from_dict(d: Dict) -> "Preset":
32
+ return Preset(**{k: v for k, v in d.items() if k in Preset.__dataclass_fields__})
33
+
34
+ @dataclass(slots=True)
35
+ class Slide:
36
+ path: str
37
+ duration: float
38
+ preset_name: str
39
+
40
+ ################################################################################
41
+ # Template loader & style picker
42
+ ################################################################################
43
+
44
+ ROOT = Path(__file__).resolve().parent
45
+ _TEMPLATE_FILE = ROOT / "motion.json"
46
+
47
+ @lru_cache(maxsize=1)
48
+ def load_templates(path: Union[str, Path] = _TEMPLATE_FILE) -> Dict[str, Preset]:
49
+ try:
50
+ data = json.loads(Path(path).read_text("utf-8"))
51
+ return {k: Preset.from_dict(v) for k, v in data.items()}
52
+ except (FileNotFoundError, json.JSONDecodeError, TypeError) as e:
53
+ raise ValueError(f"Failed to load templates: {e}")
54
+
55
+ def pick_styles(n: int, pattern: str = "*", *, seed: int | None = None,
56
+ presets: Dict[str, Preset] | None = None) -> List[str]:
57
+ if n <= 0:
58
+ raise ValueError(f"n must be positive, got {n}")
59
+
60
+ db = presets or load_templates()
61
+ many = pattern.endswith("!")
62
+ pat = pattern[:-1] if many else pattern
63
+ pool = [k for k in db if pat == "*" or fnmatch.fnmatch(k, pat)]
64
+
65
+ if not pool:
66
+ available = list(db.keys())[:5] # Show first 5
67
+ raise ValueError(f"No preset matches '{pat}'. Available: {available}...")
68
+
69
+ rng = random.Random(seed)
70
+ return [rng.choice(pool) for _ in range(n)] if (many or pat == "*") else [rng.choice(pool)] * n
71
+
72
+ ################################################################################
73
+ # Low‑level helpers
74
+ ################################################################################
75
+
76
+ def _ease(expr: str, mode: str) -> str:
77
+ easing_funcs = {
78
+ "linear": lambda e: e,
79
+ "ease-in": lambda e: f"({e})*({e})",
80
+ "ease-out": lambda e: f"1-((1-({e}))*(1-({e})))",
81
+ "ease-in-out": lambda e: f"(0.5-0.5*cos(pi*({e})))"
82
+ }
83
+ return easing_funcs.get(mode, easing_funcs["linear"])(expr)
84
+
85
+ _ANCHOR_MAP = {
86
+ "center": ("iw/2-(iw/zoom/2)", "ih/2-(ih/zoom/2)"),
87
+ "left_top": ("0", "0"),
88
+ "right_bottom": ("iw-iw/zoom", "ih-ih/zoom"),
89
+ "left_center": ("0", "ih/2-(ih/zoom/2)"),
90
+ "right_center": ("iw-iw/zoom", "ih/2-(ih/zoom/2)"),
91
+ "center_top": ("iw/2-(iw/zoom/2)", "0"),
92
+ "center_bottom": ("iw/2-(iw/zoom/2)", "ih-ih/zoom"),
93
+ }
94
+
95
+ def _anchor(a: str) -> Tuple[str, str]:
96
+ return _ANCHOR_MAP.get(a, _ANCHOR_MAP["center"])
97
+
98
+ ################################################################################
99
+ # Preset → zoompan string
100
+ ################################################################################
101
+
102
+ def to_zoompan(p: Preset, frames: int) -> str:
103
+ if frames <= 0:
104
+ raise ValueError(f"frames must be positive, got {frames}")
105
+
106
+ t = _ease(f"in/{frames}", p.ease)
107
+
108
+ # Validation helpers
109
+ def require(*fields):
110
+ missing = [f for f in fields if getattr(p, f) is None]
111
+ if missing:
112
+ raise ValueError(f"{p.kind} preset missing: {missing}")
113
+
114
+ # Zoom operations
115
+ if p.kind == "zoom":
116
+ require("start", "end")
117
+ z = f"'{p.start}+({p.end-p.start})*({t})'"
118
+ x, y = _anchor(p.anchor or "center")
119
+ return f"zoompan=z={z}:x='{x}':y='{y}':d={frames},setsar=1"
120
+
121
+ # Pan operations
122
+ elif p.kind == "pan":
123
+ require("dx", "dy")
124
+ x, y = f"'{p.dx}*iw*({t})'", f"'{p.dy}*ih*({t})'"
125
+ return f"zoompan=z='1':x={x}:y={y}:d={frames},setsar=1"
126
+
127
+ # Combined operations
128
+ elif p.kind == "zoom_pan":
129
+ require("start", "end", "dx", "dy")
130
+ z = f"'{p.start}+({p.end-p.start})*({t})'"
131
+ x = f"'iw/2-(iw/zoom/2)+{p.dx}*iw*({t})'"
132
+ y = f"'ih/2-(ih/zoom/2)+{p.dy}*ih*({t})'"
133
+ return f"zoompan=z={z}:x={x}:y={y}:d={frames},setsar=1"
134
+
135
+ elif p.kind == "zoom_rotate":
136
+ require("start", "end", "rot_start", "rot_end")
137
+ z = f"'{p.start}+({p.end-p.start})*({t})'"
138
+ x, y = _anchor(p.anchor or "center")
139
+ rot = f"rotate={p.rot_start}+({p.rot_end-p.rot_start})*({t})"
140
+ return f"zoompan=z={z}:x='{x}':y='{y}':d={frames},setsar=1,{rot}"
141
+
142
+ elif p.kind == "zoom_pan_rotate":
143
+ require("start", "end", "dx", "dy", "rot_start", "rot_end")
144
+ z = f"'{p.start}+({p.end-p.start})*({t})'"
145
+ x = f"'iw/2-(iw/zoom/2)+{p.dx}*iw*({t})'"
146
+ y = f"'ih/2-(ih/zoom/2)+{p.dy}*ih*({t})'"
147
+ rot = f"rotate={p.rot_start}+({p.rot_end-p.rot_start})*({t})"
148
+ return f"zoompan=z={z}:x={x}:y={y}:d={frames},setsar=1,{rot}"
149
+
150
+ else:
151
+ valid_kinds = {"zoom", "pan", "zoom_pan", "zoom_rotate", "zoom_pan_rotate"}
152
+ raise ValueError(f"Unsupported kind '{p.kind}'. Valid: {valid_kinds}")
153
+
154
+ ################################################################################
155
+ # FFmpeg command builder / runner
156
+ ################################################################################
157
+
158
+ def _xfade_info(durs: Sequence[float], fx: float, lo: float = 0.1, hi: float = 1.0) -> Tuple[List[float], List[float]]:
159
+ if len(durs) < 2:
160
+ return [], []
161
+
162
+ # Clamp crossfade duration to reasonable bounds
163
+ cf = [max(lo, min(hi, fx * min(durs[i], durs[i+1]))) for i in range(len(durs)-1)]
164
+ off = [durs[0]]
165
+ for i in range(1, len(durs)-1):
166
+ off.append(off[-1] + durs[i] - cf[i-1])
167
+ return cf, off
168
+
169
+ def build_cmd(images: Sequence[str], durations: Sequence[float], *,
170
+ style: str = "*", fps: int = 30, cf_factor: float = 0.15,
171
+ seed: int | None = None, out: Union[str, Path] = "slideshow.mp4",
172
+ presets: Dict[str, Preset] | None = None) -> str:
173
+
174
+ # Input validation
175
+ if len(images) != len(durations):
176
+ raise ValueError(f"Mismatch: {len(images)} images vs {len(durations)} durations")
177
+ if not images:
178
+ raise ValueError("No images provided")
179
+ if any(d <= 0 for d in durations):
180
+ bad_durs = [d for d in durations if d <= 0]
181
+ raise ValueError(f"All durations must be positive, got: {bad_durs}")
182
+ if fps <= 0:
183
+ raise ValueError(f"fps must be positive, got {fps}")
184
+ if not (0 <= cf_factor <= 1):
185
+ raise ValueError(f"cf_factor must be 0-1, got {cf_factor}")
186
+
187
+ # Load presets and pick styles
188
+ db = presets or load_templates()
189
+ styles = pick_styles(len(images), style, seed=seed, presets=db)
190
+
191
+ # Verify all styles exist
192
+ missing = [s for s in styles if s not in db]
193
+ if missing:
194
+ raise ValueError(f"Styles not found: {missing}")
195
+
196
+ # Build filters
197
+ filters = []
198
+ for i, (img, dur, sname) in enumerate(zip(images, durations, styles)):
199
+ frames = max(1, int(dur * fps)) # Ensure at least 1 frame
200
+ filter_str = to_zoompan(db[sname], frames)
201
+ filters.append(f"[{i}:v]{filter_str}[v{i}]")
202
+
203
+ # Handle single image case
204
+ if len(images) == 1:
205
+ fc = filters[0].replace("[v0]", "[vout]")
206
+ else:
207
+ # Build crossfade chain
208
+ cfs, ofs = _xfade_info(durations, cf_factor)
209
+ chain = []
210
+ for i in range(len(images) - 1):
211
+ left = "[v0]" if i == 0 else f"[x{i}]"
212
+ right = f"[v{i+1}]"
213
+ output = f"[x{i+1}]" if i < len(images) - 2 else "[vout]"
214
+ duration = cfs[i] if i < len(cfs) else 0.5
215
+ offset = ofs[i] if i < len(ofs) else 0.0
216
+ chain.append(f"{left}{right}xfade=transition=fade:duration={duration:.3f}:offset={offset:.3f}{output}")
217
+ fc = ";".join(filters + chain)
218
+
219
+ # Build command
220
+ inputs = []
221
+ for img, d in zip(images, durations):
222
+ inputs.extend(['-loop', '1', '-t', str(d), '-i', str(img)])
223
+
224
+ cmd_parts = [
225
+ "ffmpeg", *inputs,
226
+ "-filter_complex", fc,
227
+ "-map", "[vout]",
228
+ "-r", str(fps),
229
+ "-c:v", "libx264", "-preset", "veryfast", "-pix_fmt", "yuv420p",
230
+ "-y", str(out)
231
+ ]
232
+
233
+ return shlex.join(cmd_parts)
234
+
235
+ def generate_video(images: Sequence[str], durations: Sequence[float], **kw) -> Tuple[Path, str]:
236
+ # Check images exist
237
+ missing = [img for img in images if not Path(img).exists()]
238
+ if missing:
239
+ raise FileNotFoundError(f"Images not found: {missing}")
240
+
241
+ cmd = build_cmd(images, durations, **kw)
242
+ output_path = Path(kw.get("out", "slideshow.mp4"))
243
+
244
+ try:
245
+ subprocess.run(cmd, shell=True, check=True,
246
+ stderr=subprocess.DEVNULL, stdout=subprocess.DEVNULL)
247
+ except subprocess.CalledProcessError as e:
248
+ raise RuntimeError(f"FFmpeg failed (code {e.returncode}). Check: images exist, ffmpeg installed")
249
+ except FileNotFoundError:
250
+ raise FileNotFoundError("FFmpeg not found. Install ffmpeg and add to PATH")
251
+
252
+ return output_path, cmd
motion.json ADDED
@@ -0,0 +1,171 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "zoom_in_center": {
3
+ "kind": "zoom", "start": 1.00, "end": 1.30, "anchor": "center",
4
+ "ease": "linear",
5
+ "tags": ["focus", "calm", "intro"],
6
+ "desc": "Zoom-in 30 % giữ tâm ảnh, làm nổi bật nhân vật/đối tượng chính."
7
+ },
8
+ "zoom_out_center": {
9
+ "kind": "zoom", "start": 1.35, "end": 1.00, "anchor": "center",
10
+ "ease": "linear",
11
+ "tags": ["context", "reveal", "calm"],
12
+ "desc": "Zoom-out mở rộng khung cảnh, dùng sau cận cảnh để hé lộ bối cảnh."
13
+ },
14
+ "zoom_in_leftTop": {
15
+ "kind": "zoom", "start": 1.00, "end": 1.25, "anchor": "left_top",
16
+ "ease": "linear",
17
+ "tags": ["rule_of_thirds", "tension", "focus"],
18
+ "desc": "Zoom-in vào góc 1/3 trái-trên, tạo cảm giác bí ẩn hoặc căng thẳng nhẹ."
19
+ },
20
+ "zoom_in_rightBottom": {
21
+ "kind": "zoom", "start": 1.00, "end": 1.25, "anchor": "right_bottom",
22
+ "ease": "linear",
23
+ "tags": ["rule_of_thirds", "tension", "focus"],
24
+ "desc": "Zoom-in góc 1/3 phải-dưới, thích hợp nhấn mạnh chi tiết nhỏ."
25
+ },
26
+ "zoom_out_leftBottom": {
27
+ "kind": "zoom", "start": 1.30, "end": 1.00, "anchor": "left_bottom",
28
+ "ease": "linear",
29
+ "tags": ["reveal", "dramatic"],
30
+ "desc": "Zoom-out từ góc trái-dưới để lộ bố cục rộng."
31
+ },
32
+ "zoom_out_rightTop": {
33
+ "kind": "zoom", "start": 1.30, "end": 1.00, "anchor": "right_top",
34
+ "ease": "linear",
35
+ "tags": ["reveal", "dramatic"],
36
+ "desc": "Zoom-out từ góc phải-trên, tạo cảm giác thoát lên cao."
37
+ },
38
+
39
+ "pan_left": {
40
+ "kind": "pan", "dx": -0.25, "dy": 0.00,
41
+ "ease": "linear",
42
+ "tags": ["flow", "dynamic", "reading_dir"],
43
+ "desc": "Trượt trái 25 % khung, dẫn mắt thuận chiều đọc (trái → phải)."
44
+ },
45
+ "pan_right": {
46
+ "kind": "pan", "dx": 0.25, "dy": 0.00,
47
+ "ease": "linear",
48
+ "tags": ["flow", "dynamic", "reverse"],
49
+ "desc": "Trượt phải, hay dùng cho hồi tưởng hoặc nhân vật rời khung."
50
+ },
51
+ "pan_up": {
52
+ "kind": "pan", "dx": 0.00, "dy": -0.25,
53
+ "ease": "linear",
54
+ "tags": ["vertical", "reveal"],
55
+ "desc": "Pan-up 25 %: từ đất lên trời, mở khung cao."
56
+ },
57
+ "pan_down": {
58
+ "kind": "pan", "dx": 0.00, "dy": 0.25,
59
+ "ease": "linear",
60
+ "tags": ["vertical", "reveal"],
61
+ "desc": "Pan-down 25 %: từ trời xuống nhân vật/chi tiết."
62
+ },
63
+ "pan_diagonal_ul": {
64
+ "kind": "pan", "dx": -0.20, "dy": -0.20,
65
+ "ease": "linear",
66
+ "tags": ["diagonal", "energy", "comic"],
67
+ "desc": "Pan chéo lên-trái 20 %, tăng nhịp hành động."
68
+ },
69
+ "pan_diagonal_dr": {
70
+ "kind": "pan", "dx": 0.20, "dy": 0.20,
71
+ "ease": "linear",
72
+ "tags": ["diagonal", "energy", "comic"],
73
+ "desc": "Pan chéo xuống-phải, tạo cảm giác rơi/tuột nhanh."
74
+ },
75
+
76
+ "zoom_pan_left": {
77
+ "kind": "zoom_pan",
78
+ "start": 1.00, "end": 1.25, "dx": -0.20, "dy": 0.00,
79
+ "ease": "linear",
80
+ "tags": ["combo", "immersive"],
81
+ "desc": "Vừa pan trái vừa zoom-in 25 %, hiệu ứng dolly-tracking nhẹ."
82
+ },
83
+ "zoom_pan_right": {
84
+ "kind": "zoom_pan",
85
+ "start": 1.00, "end": 1.25, "dx": 0.20, "dy": 0.00,
86
+ "ease": "linear",
87
+ "tags": ["combo", "immersive"],
88
+ "desc": "Pan phải + zoom-in, hướng ánh nhìn theo chuyển động nhân vật."
89
+ },
90
+ "zoom_pan_up": {
91
+ "kind": "zoom_pan",
92
+ "start": 1.00, "end": 1.25, "dx": 0.00, "dy": -0.20,
93
+ "ease": "linear",
94
+ "tags": ["combo", "vertical"],
95
+ "desc": "Kéo lên trên và phóng to nhẹ, gợi cảm giác vươn cao."
96
+ },
97
+ "zoom_pan_down": {
98
+ "kind": "zoom_pan",
99
+ "start": 1.00, "end": 1.25, "dx": 0.00, "dy": 0.20,
100
+ "ease": "linear",
101
+ "tags": ["combo", "vertical"],
102
+ "desc": "Kéo xuống và phóng to nhẹ – phù hợp chuyển cảnh sky-to-subject."
103
+ },
104
+ "zoom_pan_diag_ul": {
105
+ "kind": "zoom_pan",
106
+ "start": 1.00, "end": 1.30, "dx": -0.15, "dy": -0.15,
107
+ "ease": "linear",
108
+ "tags": ["combo", "diagonal", "adventure"],
109
+ "desc": "Zoom + pan chéo lên-trái; dùng cho đoạn cao trào, phiêu lưu."
110
+ },
111
+ "zoom_pan_diag_dr": {
112
+ "kind": "zoom_pan",
113
+ "start": 1.00, "end": 1.30, "dx": 0.15, "dy": 0.15,
114
+ "ease": "linear",
115
+ "tags": ["combo", "diagonal", "adventure"],
116
+ "desc": "Zoom + pan chéo xuống-phải; nhấn tốc độ và hướng rơi."
117
+ },
118
+
119
+ "rotate_zoom_cw": {
120
+ "kind": "zoom_rotate",
121
+ "start": 1.00, "end": 1.25,
122
+ "rot_start": 0, "rot_end": 3,
123
+ "anchor": "center",
124
+ "ease": "linear",
125
+ "tags": ["drama", "unsettle"],
126
+ "desc": "Zoom-in kèm xoay 3° chiều kim đồng hồ, gợi cảm giác bất ổn."
127
+ },
128
+ "rotate_zoom_ccw": {
129
+ "kind": "zoom_rotate",
130
+ "start": 1.00, "end": 1.25,
131
+ "rot_start": 0, "rot_end": -3,
132
+ "anchor": "center",
133
+ "ease": "linear",
134
+ "tags": ["drama", "unsettle"],
135
+ "desc": "Zoom-in kèm xoay 3° ngược kim đồng hồ, tăng căng thẳng."
136
+ },
137
+
138
+ "parallax_left": {
139
+ "kind": "zoom_pan_rotate",
140
+ "start": 1.00, "end": 1.20,
141
+ "dx": -0.12, "dy": 0.00,
142
+ "rot_start": 0, "rot_end": 2,
143
+ "anchor": "center",
144
+ "ease": "linear",
145
+ "tags": ["parallax", "depth", "immersive"],
146
+ "desc": "Hiệu ứng parallax nhẹ: zoom-in + pan trái + xoay 2°."
147
+ },
148
+ "parallax_right": {
149
+ "kind": "zoom_pan_rotate",
150
+ "start": 1.00, "end": 1.20,
151
+ "dx": 0.12, "dy": 0.00,
152
+ "rot_start": 0, "rot_end": -2,
153
+ "anchor": "center",
154
+ "ease": "linear",
155
+ "tags": ["parallax", "depth", "immersive"],
156
+ "desc": "Parallax zoom-in + pan phải + xoay −2°."
157
+ },
158
+
159
+ "dolly_in_slow": {
160
+ "kind": "zoom", "start": 1.00, "end": 1.15, "anchor": "center",
161
+ "ease": "ease-in-out",
162
+ "tags": ["slow", "emotional", "close_up"],
163
+ "desc": "Zoom-in 15 % rất chậm (ease-in-out) cho khoảnh khắc cảm xúc."
164
+ },
165
+ "dolly_out_fast": {
166
+ "kind": "zoom", "start": 1.40, "end": 1.00, "anchor": "center",
167
+ "ease": "ease-out",
168
+ "tags": ["fast", "shock", "reveal"],
169
+ "desc": "Zoom-out 40 % nhanh (ease-out) tạo cú giật lùi, lộ plot-twist."
170
+ }
171
+ }