Vo Hoang Minh commited on
Commit
32e1e14
·
1 Parent(s): aaaeefa
Files changed (4) hide show
  1. app.py +292 -103
  2. motion.json +282 -127
  3. motion_processor.py +115 -0
  4. video_processor.py +90 -0
app.py CHANGED
@@ -1,115 +1,304 @@
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
- launch()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import gradio as gr
2
+ import atexit
3
+ from motion_processor import MotionProcessor
4
+ from file_handler import FileHandler
5
+ from video_processor import VideoProcessor
6
 
7
+ # Initialize
8
+ motion = MotionProcessor()
9
+ files = FileHandler()
10
+ processor = VideoProcessor(motion, files)
 
11
 
12
+ atexit.register(files.cleanup)
 
 
 
 
13
 
14
+ def preview_files(uploaded_files):
15
+ if not uploaded_files:
16
+ return None, "No files selected"
17
+
18
+ file_list = files.get_files(uploaded_files)
19
+ preview = files.create_preview(file_list)
20
+ info = f"Found {len(file_list)} media files"
21
+ return preview, info
22
 
23
+ def filter_templates_by_tag(tag_filter):
24
+ if tag_filter == "all":
25
+ filtered = motion.templates
26
+ else:
27
+ filtered = motion.get_templates_by_tag(tag_filter)
28
+
29
+ choices = [f"{t['name']} ({', '.join(t.get('tags', []))})" for t in filtered]
30
+ return gr.Dropdown(choices=choices, value=choices[0] if choices else None)
31
 
32
+ def search_templates(search_query):
33
+ if not search_query.strip():
34
+ choices = [f"{t['name']} ({', '.join(t.get('tags', []))})" for t in motion.templates]
35
+ else:
36
+ filtered = motion.search_templates(search_query)
37
+ choices = [f"{t['name']} ({', '.join(t.get('tags', []))})" for t in filtered]
38
+
39
+ return gr.Dropdown(choices=choices, value=choices[0] if choices else None)
40
 
41
+ def test_single_effect(test_file, template_choice, aspect):
42
+ if not test_file or not template_choice:
43
+ return None, "Please upload a test file and select a template"
44
+
45
+ template_name = template_choice.split(" (")[0] # Extract name from "name (tags)"
46
+
47
+ result, status = processor.test_effect(test_file.name, template_name, aspect)
48
+ return result, status
49
 
50
+ def create_motion_video(input_files, aspect, output_format, use_random, selected_template,
51
+ workers, quality, fps, progress=gr.Progress()):
52
+ def progress_fn(val, desc):
53
+ progress(val, desc=desc)
54
+
55
+ # Prepare template
56
+ template = None
57
+ if not use_random and selected_template:
58
+ template_name = selected_template.split(" (")[0]
59
+ template = next((t for t in motion.templates if t['name'] == template_name), None)
60
+
61
+ # Advanced settings
62
+ advanced_settings = {
63
+ 'workers': workers,
64
+ 'quality': quality,
65
+ 'fps': fps
66
+ }
67
+
68
+ result, status = processor.create_video(
69
+ input_files, aspect, output_format, progress_fn, template, advanced_settings
70
+ )
71
+
72
+ if result:
73
+ return result, status, gr.DownloadButton(label="📥 Download", value=result, visible=True)
74
+ return None, status, gr.DownloadButton(visible=False)
75
 
76
+ def load_template(template_choice):
77
+ if not template_choice:
78
+ return "", 4, 1.0, 1.3, 0, 0, 0, 0, "", "", 0, 0
79
+
80
+ template_name = template_choice.split(" (")[0]
81
+ template = next((t for t in motion.templates if t['name'] == template_name), motion.templates[0])
82
+
83
+ return (
84
+ template['name'],
85
+ template['duration'],
86
+ template['scale'][0], template['scale'][1], # scale start, end
87
+ template['pan'][0], template['pan'][1], # pan x1, y1
88
+ template['pan'][2], template['pan'][3], # pan x2, y2
89
+ ', '.join(template.get('tags', [])),
90
+ template.get('desc', ''),
91
+ template['rotate'][0], template['rotate'][1] # rotate start, end
92
+ )
93
 
94
+ def save_template(name, duration, scale_start, scale_end, x1, y1, x2, y2, tags, desc, rot_start, rot_end):
95
+ if not name:
96
+ return "Enter template name", gr.Dropdown()
97
+
98
+ tag_list = [tag.strip() for tag in tags.split(',') if tag.strip()] if tags else []
99
+
100
+ new_template = {
101
+ 'name': name,
102
+ 'duration': duration,
103
+ 'scale': [scale_start, scale_end],
104
+ 'pan': [x1, y1, x2, y2],
105
+ 'rotate': [rot_start, rot_end],
106
+ 'tags': tag_list,
107
+ 'desc': desc or f"Custom template: {name}"
108
+ }
109
+
110
+ # Update or add
111
+ existing = next((i for i, t in enumerate(motion.templates) if t['name'] == name), None)
112
+ if existing is not None:
113
+ motion.templates[existing] = new_template
114
+ else:
115
+ motion.templates.append(new_template)
116
+
117
+ motion.save_templates(motion.templates)
118
+ choices = [f"{t['name']} ({', '.join(t.get('tags', []))})" for t in motion.templates]
119
+ return f"Saved '{name}'!", gr.Dropdown(choices=choices, value=f"{name} ({', '.join(tag_list)})")
120
 
121
+ # Get all unique tags
122
+ all_tags = set()
123
+ for template in motion.templates:
124
+ all_tags.update(template.get('tags', []))
125
+ tag_choices = ["all"] + sorted(list(all_tags))
126
 
127
+ # Interface
128
+ with gr.Blocks(title="Ken Burns Story Creator", css="""
129
+ .gradio-container {max-width: 1400px !important}
130
+ .compact-group {margin: 5px 0 !important}
131
+ """) as app:
132
+ gr.Markdown("# 🎬 Ken Burns Story Creator\nCreate motion videos for YouTube, TikTok, Reels with 30+ effects")
133
+
134
+ with gr.Tabs():
135
+ with gr.Tab("📹 Create Video"):
136
+ with gr.Row():
137
+ with gr.Column(scale=2):
138
+ # Input
139
+ files_input = gr.File(label="Upload Images/Videos", file_count="multiple",
140
+ file_types=["image", "video"])
141
+
142
+ # Preview
143
+ with gr.Group():
144
+ gr.Markdown("### Preview")
145
+ preview_img = gr.Image(label="Files Preview", height=200)
146
+ preview_info = gr.Textbox(label="Info", interactive=False)
147
+
148
+ # Effect Selection
149
+ with gr.Group():
150
+ gr.Markdown("### 🎭 Effect Selection")
151
+
152
+ with gr.Row():
153
+ use_random = gr.Checkbox(label="Random Effects", value=True)
154
+
155
+ with gr.Column(visible=False) as effect_selector:
156
+ with gr.Row():
157
+ tag_filter = gr.Dropdown(
158
+ choices=tag_choices, value="all",
159
+ label="Filter by Tag", scale=1
160
+ )
161
+ search_box = gr.Textbox(
162
+ label="Search Effects", placeholder="zoom, pan, dramatic...", scale=2
163
+ )
164
+
165
+ template_dropdown = gr.Dropdown(
166
+ choices=[f"{t['name']} ({', '.join(t.get('tags', []))})" for t in motion.templates],
167
+ label="Select Effect"
168
+ )
169
+
170
+ # Settings
171
+ with gr.Row():
172
+ aspect_ratio = gr.Dropdown(
173
+ choices=['reels', 'tiktok', 'youtube', 'square', 'widescreen'],
174
+ value='reels',
175
+ label="📱 Platform"
176
+ )
177
+ output_format = gr.Radio(['mp4', 'gif'], value='mp4', label="Format")
178
+
179
+ with gr.Column(scale=1):
180
+ # Test Effect
181
+ with gr.Group():
182
+ gr.Markdown("### 🧪 Test Effect")
183
+ test_file = gr.File(label="Test Image", file_types=["image"])
184
+ test_btn = gr.Button("🎬 Test Effect", size="sm")
185
+ test_result = gr.Video(label="Test Result", height=200)
186
+ test_status = gr.Textbox(label="Test Status", interactive=False)
187
+
188
+ # Create Video
189
+ create_btn = gr.Button("🚀 Create Story", variant="primary", size="lg")
190
+ status = gr.Textbox(label="Status", interactive=False)
191
+ output_video = gr.Video(label="Result", height=300)
192
+ download_btn = gr.DownloadButton(label="📥 Download", visible=False)
193
+
194
+ with gr.Tab("⚙️ Advanced Settings"):
195
+ gr.Markdown("### Advanced Processing Settings")
196
+
197
+ with gr.Row():
198
+ workers = gr.Slider(1, 8, value=4, step=1, label="🔄 Parallel Workers")
199
+ fps = gr.Slider(15, 60, value=25, step=5, label="🎞️ FPS")
200
+
201
+ with gr.Row():
202
+ quality = gr.Dropdown(
203
+ choices=['ultrafast', 'superfast', 'veryfast', 'faster', 'fast', 'medium', 'slow'],
204
+ value='fast',
205
+ label="🏆 Quality Preset"
206
+ )
207
+
208
+ with gr.Tab("🎭 Templates"):
209
+ gr.Markdown("### Motion Templates Manager")
210
+
211
+ with gr.Row():
212
+ with gr.Column(scale=2):
213
+ template_dropdown_edit = gr.Dropdown(
214
+ choices=[f"{t['name']} ({', '.join(t.get('tags', []))})" for t in motion.templates],
215
+ value=f"{motion.templates[0]['name']} ({', '.join(motion.templates[0].get('tags', []))})",
216
+ label="Template"
217
+ )
218
+
219
+ with gr.Row():
220
+ name_input = gr.Textbox(label="Name", placeholder="template_name")
221
+ duration_input = gr.Number(label="Duration (s)", value=4, minimum=2, maximum=10)
222
+
223
+ with gr.Row():
224
+ scale_start = gr.Number(label="Scale Start", value=1.0, step=0.1)
225
+ scale_end = gr.Number(label="Scale End", value=1.3, step=0.1)
226
+
227
+ with gr.Row():
228
+ x1 = gr.Number(label="Pan X1", value=0)
229
+ y1 = gr.Number(label="Pan Y1", value=0)
230
+ x2 = gr.Number(label="Pan X2", value=0)
231
+ y2 = gr.Number(label="Pan Y2", value=0)
232
+
233
+ with gr.Row():
234
+ rot_start = gr.Number(label="Rotate Start (°)", value=0)
235
+ rot_end = gr.Number(label="Rotate End (°)", value=0)
236
+
237
+ tags_input = gr.Textbox(
238
+ label="Tags (comma separated)",
239
+ placeholder="zoom, left, dramatic",
240
+ value=""
241
+ )
242
+
243
+ desc_input = gr.Textbox(
244
+ label="Description",
245
+ placeholder="Describe the motion effect...",
246
+ lines=2
247
+ )
248
+
249
+ save_btn = gr.Button("💾 Save Template", variant="primary")
250
+ template_status = gr.Textbox(label="Status", interactive=False)
251
+
252
+ with gr.Column(scale=1):
253
+ gr.Markdown("### 📋 Available Tags")
254
+ gr.Markdown(f"**{len(tag_choices)-1} unique tags:**")
255
+ gr.Markdown(", ".join(sorted(tag_choices[1:]))) # Exclude "all"
256
+
257
+ gr.Markdown("### 📖 Template Guide")
258
+ gr.Markdown("""
259
+ **Scale**: Zoom level (1.0 = normal)
260
+ **Pan X1,Y1**: Start position
261
+ **Pan X2,Y2**: End position
262
+ **Duration**: Effect length in seconds
263
+ **Tags**: For easy searching
264
+ """)
265
+
266
+ with gr.Tab("ℹ️ Help"):
267
+ gr.Markdown(f"""
268
+ ### 📖 How to Use
269
+ 1. **Upload** your images/videos
270
+ 2. **Choose effects**: Random or specific template
271
+ 3. **Test effects** on single image first
272
+ 4. **Select platform** aspect ratio
273
+ 5. **Create** your story video
274
+
275
+ ### 🎭 Available Effects ({len(motion.templates)} total)
276
+
277
+ **Zoom Effects:**
278
+ - Center zoom in/out
279
+ - Directional zooms (left, right, top, bottom)
280
+ - Dramatic push/pull effects
281
+
282
+ **Pan Effects:**
283
+ - Horizontal/vertical pans
284
+ - Diagonal movements
285
+ - Arc and spiral motions
286
+
287
+ **Cinematic Effects:**
288
+ - Slow drifts and breathing
289
+ - Focus shifts and reveals
290
+ - Documentary style movements
291
+
292
+ ### 📱 Platform Settings
293
+ - **Reels/TikTok**: 9:16 (1080x1920)
294
+ - **YouTube**: 16:9 (1920x1080)
295
+ - **Square**: 1:1 (1080x1080)
296
+
297
+ ### ⚙️ Advanced Settings
298
+ - **Workers**: Parallel processing (more = faster)
299
+ - **Quality**: FFmpeg preset (fast vs quality)
300
+ - **FPS**: Frame rate (higher = smoother)
301
+ """)
302
+
303
+ # Events
304
+ files_input.change(preview_files, [files_input], [preview_img, preview_info])
motion.json CHANGED
@@ -1,171 +1,326 @@
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
- }
 
1
+ [
2
+ {
3
+ "name": "zoom_in_center",
4
+ "scale": [1.0, 1.3],
5
+ "pan": [0, 0, 0, 0],
6
+ "rotate": [0, 0],
7
+ "duration": 4,
8
  "tags": ["focus", "calm", "intro"],
9
+ "desc": "Zoom-in 30% giữ tâm ảnh, làm nổi bật nhân vật/đối tượng chính"
10
  },
11
+ {
12
+ "name": "zoom_out_center",
13
+ "scale": [1.35, 1.0],
14
+ "pan": [0, 0, 0, 0],
15
+ "rotate": [0, 0],
16
+ "duration": 4,
17
  "tags": ["context", "reveal", "calm"],
18
+ "desc": "Zoom-out mở rộng khung cảnh, dùng sau cận cảnh để hé lộ bối cảnh"
19
  },
20
+ {
21
+ "name": "zoom_in_left_top",
22
+ "scale": [1.0, 1.25],
23
+ "pan": [50, 50, -30, -30],
24
+ "rotate": [0, 0],
25
+ "duration": 4,
26
  "tags": ["rule_of_thirds", "tension", "focus"],
27
+ "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ẹ"
28
  },
29
+ {
30
+ "name": "zoom_in_right_bottom",
31
+ "scale": [1.0, 1.25],
32
+ "pan": [-50, -50, 30, 30],
33
+ "rotate": [0, 0],
34
+ "duration": 4,
35
  "tags": ["rule_of_thirds", "tension", "focus"],
36
+ "desc": "Zoom-in góc 1/3 phải-dưới, thích hợp nhấn mạnh chi tiết nhỏ"
37
  },
38
+ {
39
+ "name": "zoom_out_left_bottom",
40
+ "scale": [1.3, 1.0],
41
+ "pan": [30, -30, 0, 0],
42
+ "rotate": [0, 0],
43
+ "duration": 4,
44
  "tags": ["reveal", "dramatic"],
45
+ "desc": "Zoom-out từ góc trái-dưới để lộ bố cục rộng"
46
  },
47
+ {
48
+ "name": "zoom_out_right_top",
49
+ "scale": [1.3, 1.0],
50
+ "pan": [-30, 30, 0, 0],
51
+ "rotate": [0, 0],
52
+ "duration": 4,
53
  "tags": ["reveal", "dramatic"],
54
+ "desc": "Zoom-out từ góc phải-trên, tạo cảm giác thoát lên cao"
55
  },
56
+ {
57
+ "name": "pan_left",
58
+ "scale": [1.2, 1.2],
59
+ "pan": [100, 0, -100, 0],
60
+ "rotate": [0, 0],
61
+ "duration": 5,
62
  "tags": ["flow", "dynamic", "reading_dir"],
63
+ "desc": "Trượt trái 25% khung, dẫn mắt thuận chiều đọc (trái → phải)"
64
  },
65
+ {
66
+ "name": "pan_right",
67
+ "scale": [1.2, 1.2],
68
+ "pan": [-100, 0, 100, 0],
69
+ "rotate": [0, 0],
70
+ "duration": 5,
71
  "tags": ["flow", "dynamic", "reverse"],
72
+ "desc": "Trượt phải, hay dùng cho hồi tưởng hoặc nhân vật rời khung"
73
  },
74
+ {
75
+ "name": "pan_up",
76
+ "scale": [1.2, 1.2],
77
+ "pan": [0, 80, 0, -80],
78
+ "rotate": [0, 0],
79
+ "duration": 5,
80
  "tags": ["vertical", "reveal"],
81
+ "desc": "Pan-up 25%: từ đất lên trời, mở khung cao"
82
  },
83
+ {
84
+ "name": "pan_down",
85
+ "scale": [1.2, 1.2],
86
+ "pan": [0, -80, 0, 80],
87
+ "rotate": [0, 0],
88
+ "duration": 5,
89
  "tags": ["vertical", "reveal"],
90
+ "desc": "Pan-down 25%: từ trời xuống nhân vật/chi tiết"
91
  },
92
+ {
93
+ "name": "pan_diagonal_ul",
94
+ "scale": [1.1, 1.1],
95
+ "pan": [60, 60, -60, -60],
96
+ "rotate": [0, 0],
97
+ "duration": 4,
98
  "tags": ["diagonal", "energy", "comic"],
99
+ "desc": "Pan chéo lên-trái 20%, tăng nhịp hành động"
100
  },
101
+ {
102
+ "name": "pan_diagonal_dr",
103
+ "scale": [1.1, 1.1],
104
+ "pan": [-60, -60, 60, 60],
105
+ "rotate": [0, 0],
106
+ "duration": 4,
107
  "tags": ["diagonal", "energy", "comic"],
108
+ "desc": "Pan chéo xuống-phải, tạo cảm giác rơi/tuột nhanh"
109
  },
110
+ {
111
+ "name": "zoom_pan_left",
112
+ "scale": [1.0, 1.25],
113
+ "pan": [60, 0, -60, 0],
114
+ "rotate": [0, 0],
115
+ "duration": 4,
116
  "tags": ["combo", "immersive"],
117
+ "desc": "Vừa pan trái vừa zoom-in 25%, hiệu ứng dolly-tracking nhẹ"
118
  },
119
+ {
120
+ "name": "zoom_pan_right",
121
+ "scale": [1.0, 1.25],
122
+ "pan": [-60, 0, 60, 0],
123
+ "rotate": [0, 0],
124
+ "duration": 4,
125
  "tags": ["combo", "immersive"],
126
+ "desc": "Pan phải + zoom-in, hướng ánh nhìn theo chuyển động nhân vật"
127
  },
128
+ {
129
+ "name": "zoom_pan_up",
130
+ "scale": [1.0, 1.25],
131
+ "pan": [0, 60, 0, -60],
132
+ "rotate": [0, 0],
133
+ "duration": 4,
134
  "tags": ["combo", "vertical"],
135
+ "desc": "Kéo lên trên và phóng to nhẹ, gợi cảm giác vươn cao"
136
  },
137
+ {
138
+ "name": "zoom_pan_down",
139
+ "scale": [1.0, 1.25],
140
+ "pan": [0, -60, 0, 60],
141
+ "rotate": [0, 0],
142
+ "duration": 4,
143
  "tags": ["combo", "vertical"],
144
+ "desc": "Kéo xuống và phóng to nhẹ – phù hợp chuyển cảnh sky-to-subject"
145
  },
146
+ {
147
+ "name": "zoom_pan_diag_ul",
148
+ "scale": [1.0, 1.3],
149
+ "pan": [45, 45, -45, -45],
150
+ "rotate": [0, 0],
151
+ "duration": 4,
152
  "tags": ["combo", "diagonal", "adventure"],
153
+ "desc": "Zoom + pan chéo lên-trái; dùng cho đoạn cao trào, phiêu lưu"
154
  },
155
+ {
156
+ "name": "zoom_pan_diag_dr",
157
+ "scale": [1.0, 1.3],
158
+ "pan": [-45, -45, 45, 45],
159
+ "rotate": [0, 0],
160
+ "duration": 4,
161
  "tags": ["combo", "diagonal", "adventure"],
162
+ "desc": "Zoom + pan chéo xuống-phải; nhấn tốc độ và hướng rơi"
163
+ },
164
+ {
165
+ "name": "rotate_zoom_cw",
166
+ "scale": [1.0, 1.25],
167
+ "pan": [0, 0, 0, 0],
168
+ "rotate": [0, 3],
169
+ "duration": 4,
 
170
  "tags": ["drama", "unsettle"],
171
+ "desc": "Zoom-in kèm xoay 3° chiều kim đồng hồ, gợi cảm giác bất ổn"
172
+ },
173
+ {
174
+ "name": "rotate_zoom_ccw",
175
+ "scale": [1.0, 1.25],
176
+ "pan": [0, 0, 0, 0],
177
+ "rotate": [0, -3],
178
+ "duration": 4,
179
  "tags": ["drama", "unsettle"],
180
+ "desc": "Zoom-in kèm xoay 3° ngược kim đồng hồ, tăng căng thẳng"
181
+ },
182
+ {
183
+ "name": "parallax_left",
184
+ "scale": [1.0, 1.2],
185
+ "pan": [40, 0, -40, 0],
186
+ "rotate": [0, 2],
187
+ "duration": 5,
 
 
188
  "tags": ["parallax", "depth", "immersive"],
189
+ "desc": "Hiệu ứng parallax nhẹ: zoom-in + pan trái + xoay 2°"
190
+ },
191
+ {
192
+ "name": "parallax_right",
193
+ "scale": [1.0, 1.2],
194
+ "pan": [-40, 0, 40, 0],
195
+ "rotate": [0, -2],
196
+ "duration": 5,
 
197
  "tags": ["parallax", "depth", "immersive"],
198
+ "desc": "Parallax zoom-in + pan phải + xoay −2°"
199
  },
200
+ {
201
+ "name": "dolly_in_slow",
202
+ "scale": [1.0, 1.15],
203
+ "pan": [0, 0, 0, 0],
204
+ "rotate": [0, 0],
205
+ "duration": 6,
206
  "tags": ["slow", "emotional", "close_up"],
207
+ "desc": "Zoom-in 15% rất chậm cho khoảnh khắc cảm xúc"
208
  },
209
+ {
210
+ "name": "dolly_out_fast",
211
+ "scale": [1.4, 1.0],
212
+ "pan": [0, 0, 0, 0],
213
+ "rotate": [0, 0],
214
+ "duration": 3,
215
  "tags": ["fast", "shock", "reveal"],
216
+ "desc": "Zoom-out 40% nhanh tạo cú giật lùi, lộ plot-twist"
217
+ },
218
+ {
219
+ "name": "breathe_gentle",
220
+ "scale": [1.0, 1.08],
221
+ "pan": [0, 0, 0, 0],
222
+ "rotate": [0, 0],
223
+ "duration": 7,
224
+ "tags": ["subtle", "organic", "peaceful"],
225
+ "desc": "Zoom nhẹ như nhịp thở, tạo cảm giác sống động tự nhiên"
226
+ },
227
+ {
228
+ "name": "spiral_in",
229
+ "scale": [1.0, 1.35],
230
+ "pan": [30, 30, -30, -30],
231
+ "rotate": [0, 8],
232
+ "duration": 5,
233
+ "tags": ["spiral", "hypnotic", "intense"],
234
+ "desc": "Hiệu ứng xoắn ốc zoom-in kèm xoay 8°, tạo cảm giác mê hoặc"
235
+ },
236
+ {
237
+ "name": "spiral_out",
238
+ "scale": [1.35, 1.0],
239
+ "pan": [-30, -30, 30, 30],
240
+ "rotate": [8, 0],
241
+ "duration": 5,
242
+ "tags": ["spiral", "release", "dramatic"],
243
+ "desc": "Xoắn ốc ngược zoom-out từ 8° về 0°, giải phóng căng thẳng"
244
+ },
245
+ {
246
+ "name": "pendulum_left",
247
+ "scale": [1.05, 1.05],
248
+ "pan": [50, 0, -50, 0],
249
+ "rotate": [0, 0],
250
+ "duration": 6,
251
+ "tags": ["pendulum", "rhythm", "hypnotic"],
252
+ "desc": "Pan trái nhẹ nhàng như lắc lư, tạo nhịp điệu ru ngủ"
253
+ },
254
+ {
255
+ "name": "pendulum_right",
256
+ "scale": [1.05, 1.05],
257
+ "pan": [-50, 0, 50, 0],
258
+ "rotate": [0, 0],
259
+ "duration": 6,
260
+ "tags": ["pendulum", "rhythm", "hypnotic"],
261
+ "desc": "Pan phải nhẹ nhàng như lắc lư, đối xứng với pendulum_left"
262
+ },
263
+ {
264
+ "name": "tilt_dramatic",
265
+ "scale": [1.0, 1.0],
266
+ "pan": [0, 0, 0, 0],
267
+ "rotate": [0, 15],
268
+ "duration": 3,
269
+ "tags": ["tilt", "dramatic", "action"],
270
+ "desc": "Nghiêng camera 15° nhanh, tăng tính kịch tính hành động"
271
+ },
272
+ {
273
+ "name": "straighten",
274
+ "scale": [1.0, 1.0],
275
+ "pan": [0, 0, 0, 0],
276
+ "rotate": [15, 0],
277
+ "duration": 4,
278
+ "tags": ["stabilize", "calm", "resolution"],
279
+ "desc": "Từ nghiêng 15° về thẳng, tạo cảm giác ổn định trở lại"
280
+ },
281
+ {
282
+ "name": "vertigo_zoom",
283
+ "scale": [1.0, 1.5],
284
+ "pan": [0, 0, 0, 0],
285
+ "rotate": [0, 0],
286
+ "duration": 2,
287
+ "tags": ["vertigo", "shock", "intense"],
288
+ "desc": "Zoom-in 50% rất nhanh, tạo hiệu ứng vertigo gây choáng váng"
289
+ },
290
+ {
291
+ "name": "wide_sweep_left",
292
+ "scale": [1.1, 1.1],
293
+ "pan": [120, 0, -120, 0],
294
+ "rotate": [0, 0],
295
+ "duration": 6,
296
+ "tags": ["wide", "sweep", "panoramic"],
297
+ "desc": "Quét ngang rộng từ phải sang trái, hiệu ứng panorama"
298
+ },
299
+ {
300
+ "name": "wide_sweep_right",
301
+ "scale": [1.1, 1.1],
302
+ "pan": [-120, 0, 120, 0],
303
+ "rotate": [0, 0],
304
+ "duration": 6,
305
+ "tags": ["wide", "sweep", "panoramic"],
306
+ "desc": "Quét ngang rộng từ trái sang phải, mở rộng không gian"
307
+ },
308
+ {
309
+ "name": "intimate_close",
310
+ "scale": [1.0, 1.6],
311
+ "pan": [0, 0, -10, -10],
312
+ "rotate": [0, 0],
313
+ "duration": 3,
314
+ "tags": ["intimate", "close", "portrait"],
315
+ "desc": "Zoom-in sâu 60% tạo cảm giác thân mật, gần gũi"
316
+ },
317
+ {
318
+ "name": "documentary_drift",
319
+ "scale": [1.05, 1.2],
320
+ "pan": [-30, 10, 20, -15],
321
+ "rotate": [0, 1],
322
+ "duration": 5,
323
+ "tags": ["documentary", "natural", "organic"],
324
+ "desc": "Chuyển động tự nhiên như quay phim tài liệu"
325
  }
326
+ ]
motion_processor.py ADDED
@@ -0,0 +1,115 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import random
3
+ import subprocess
4
+ import math
5
+ from pathlib import Path
6
+
7
+ class MotionProcessor:
8
+ def __init__(self, motion_file="motion.json"):
9
+ self.motion_file = motion_file
10
+ self.templates = self._load_templates()
11
+ self.aspect_ratios = {
12
+ 'youtube': '1920:1080',
13
+ 'reels': '1080:1920',
14
+ 'tiktok': '1080:1920',
15
+ 'square': '1080:1080',
16
+ 'widescreen': '1920:1080'
17
+ }
18
+
19
+ def _load_templates(self):
20
+ try:
21
+ return json.load(open(self.motion_file))
22
+ except:
23
+ return []
24
+
25
+ def save_templates(self, templates):
26
+ json.dump(templates, open(self.motion_file, 'w'), indent=2)
27
+ self.templates = templates
28
+
29
+ def get_random_template(self):
30
+ return random.choice(self.templates)
31
+
32
+ def get_templates_by_tag(self, tag):
33
+ return [t for t in self.templates if tag.lower() in [tag.lower() for tag in t.get('tags', [])]]
34
+
35
+ def search_templates(self, query):
36
+ query = query.lower()
37
+ return [t for t in self.templates
38
+ if query in t['name'].lower() or
39
+ any(query in tag.lower() for tag in t.get('tags', []))]
40
+
41
+ def apply_motion(self, input_path, output_path, template=None, aspect='youtube', quality='fast', fps=25):
42
+ if not template:
43
+ template = self.get_random_template()
44
+
45
+ resolution = self.aspect_ratios[aspect]
46
+ w, h = map(int, resolution.split(':'))
47
+ duration = template['duration']
48
+
49
+ # Extract values - đơn giản
50
+ scale_start, scale_end = template['scale']
51
+ x1, y1, x2, y2 = template['pan']
52
+ rot_start, rot_end = template['rotate']
53
+
54
+ # Build filter - logic đơn giản
55
+ total_frames = duration * fps
56
+
57
+ filter_parts = [
58
+ f"[0:v]scale={w}:{h}:force_original_aspect_ratio=increase",
59
+ f"crop={w}:{h}"
60
+ ]
61
+
62
+ # Zoom + Pan
63
+ if scale_start != scale_end or any([x1, y1, x2, y2]):
64
+ zoom_filter = (
65
+ f"zoompan=z='min({scale_start}+({scale_end}-{scale_start})*on/{total_frames},{scale_end})':"
66
+ f"d={total_frames}:"
67
+ f"x='iw/2-(iw/zoom/2)+({x1}+({x2}-{x1})*on/{total_frames})':"
68
+ f"y='ih/2-(ih/zoom/2)+({y1}+({y2}-{y1})*on/{total_frames})':"
69
+ f"s={w}x{h}:fps={fps}"
70
+ )
71
+ filter_parts.append(zoom_filter)
72
+
73
+ # Rotation
74
+ if rot_start != rot_end:
75
+ rot_rad_start = math.radians(rot_start)
76
+ rot_rad_end = math.radians(rot_end)
77
+ rotate_filter = (
78
+ f"rotate='({rot_rad_start}+({rot_rad_end}-{rot_rad_start})*t/{duration})':"
79
+ f"c=black:ow={w}:oh={h}"
80
+ )
81
+ filter_parts.append(rotate_filter)
82
+
83
+ filter_parts.append("[v]")
84
+ filter_complex = ",".join(filter_parts)
85
+
86
+ cmd = [
87
+ 'ffmpeg', '-y', '-i', input_path,
88
+ '-filter_complex', filter_complex,
89
+ '-map', '[v]', '-t', str(duration),
90
+ '-c:v', 'libx264', '-preset', quality, '-crf', '23',
91
+ output_path
92
+ ]
93
+
94
+ subprocess.run(cmd, check=True, capture_output=True)
95
+ return output_path
96
+
97
+ def concat_videos(self, video_files, output_path, output_format='mp4'):
98
+ list_file = "temp_list.txt"
99
+ with open(list_file, 'w') as f:
100
+ for v in video_files:
101
+ f.write(f"file '{v}'\n")
102
+
103
+ if output_format == 'gif':
104
+ subprocess.run([
105
+ 'ffmpeg', '-y', '-f', 'concat', '-safe', '0', '-i', list_file,
106
+ '-vf', 'fps=12,scale=640:-1:flags=lanczos', output_path
107
+ ], check=True, capture_output=True)
108
+ else:
109
+ subprocess.run([
110
+ 'ffmpeg', '-y', '-f', 'concat', '-safe', '0', '-i', list_file,
111
+ '-c', 'copy', output_path
112
+ ], check=True, capture_output=True)
113
+
114
+ Path(list_file).unlink(missing_ok=True)
115
+ return output_path
video_processor.py ADDED
@@ -0,0 +1,90 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from concurrent.futures import ThreadPoolExecutor
3
+ from pathlib import Path
4
+
5
+ class VideoProcessor:
6
+ def __init__(self, motion_processor, file_handler):
7
+ self.motion = motion_processor
8
+ self.files = file_handler
9
+
10
+ def process_single(self, file_path, output_path, aspect):
11
+ try:
12
+ return self.motion.apply_motion(file_path, output_path, aspect=aspect)
13
+ except Exception as e:
14
+ print(f"Error processing {file_path}: {e}")
15
+ return None
16
+
17
+ def create_video(self, input_files, aspect='youtube', output_format='mp4', progress_fn=None,
18
+ selected_template=None, advanced_settings=None):
19
+ files = self.files.get_files(input_files)
20
+ if not files:
21
+ return None, "No valid files found"
22
+
23
+ if progress_fn:
24
+ progress_fn(0.1, f"Processing {len(files)} files...")
25
+
26
+ # Process files parallel
27
+ segments_dir = self.files.temp_path("segments")
28
+ os.makedirs(segments_dir, exist_ok=True)
29
+
30
+ # Advanced settings
31
+ workers = advanced_settings.get('workers', 4) if advanced_settings else 4
32
+ quality = advanced_settings.get('quality', 'fast') if advanced_settings else 'fast'
33
+ fps = advanced_settings.get('fps', 25) if advanced_settings else 25
34
+
35
+ processed = []
36
+ with ThreadPoolExecutor(max_workers=workers) as executor:
37
+ futures = []
38
+ for i, file_path in enumerate(files):
39
+ output_path = os.path.join(segments_dir, f"seg_{i:03d}.mp4")
40
+ # Use selected template or random
41
+ template = selected_template if selected_template else None
42
+ future = executor.submit(self.process_single, file_path, output_path, aspect, template, quality, fps)
43
+ futures.append(future)
44
+
45
+ for future in futures:
46
+ result = future.result()
47
+ if result:
48
+ processed.append(result)
49
+
50
+ if not processed:
51
+ return None, "Failed to process any files"
52
+
53
+ if progress_fn:
54
+ progress_fn(0.8, "Combining segments...")
55
+
56
+ # Combine videos
57
+ ext = 'gif' if output_format == 'gif' else 'mp4'
58
+ final_output = self.files.temp_path(f"final.{ext}")
59
+
60
+ self.motion.concat_videos(processed, final_output, output_format)
61
+
62
+ # Cleanup
63
+ for seg in processed:
64
+ try:
65
+ os.unlink(seg)
66
+ except:
67
+ pass
68
+
69
+ if progress_fn:
70
+ progress_fn(1.0, "Complete!")
71
+
72
+ return final_output, f"Created video from {len(files)} files"
73
+
74
+
75
+ def test_effect(self, test_file, template_name, aspect='youtube'):
76
+ """Test single effect on one file"""
77
+ if not test_file:
78
+ return None, "No test file provided"
79
+
80
+ template = next((t for t in self.motion.templates if t['name'] == template_name), None)
81
+ if not template:
82
+ return None, f"Template '{template_name}' not found"
83
+
84
+ test_output = self.files.temp_path(f"test_{template_name}.mp4")
85
+
86
+ try:
87
+ result = self.motion.apply_motion(test_file, test_output, template, aspect)
88
+ return result, f"Test completed with '{template_name}' effect"
89
+ except Exception as e:
90
+ return None, f"Test failed: {str(e)}"