MalikIbrar commited on
Commit
8e102a9
·
0 Parent(s):

Initial Commit

Browse files
.gitignore ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ venv
2
+ videos
3
+ __pycache__
4
+ *.pyc
5
+ *.pyo
6
+ .vscode
7
+ .idea
8
+ .env
9
+
10
+ Dockerfile
11
+ .vscode
.gradio/certificate.pem ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ -----BEGIN CERTIFICATE-----
2
+ MIIFazCCA1OgAwIBAgIRAIIQz7DSQONZRGPgu2OCiwAwDQYJKoZIhvcNAQELBQAw
3
+ TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh
4
+ cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMTUwNjA0MTEwNDM4
5
+ WhcNMzUwNjA0MTEwNDM4WjBPMQswCQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJu
6
+ ZXQgU2VjdXJpdHkgUmVzZWFyY2ggR3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBY
7
+ MTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK3oJHP0FDfzm54rVygc
8
+ h77ct984kIxuPOZXoHj3dcKi/vVqbvYATyjb3miGbESTtrFj/RQSa78f0uoxmyF+
9
+ 0TM8ukj13Xnfs7j/EvEhmkvBioZxaUpmZmyPfjxwv60pIgbz5MDmgK7iS4+3mX6U
10
+ A5/TR5d8mUgjU+g4rk8Kb4Mu0UlXjIB0ttov0DiNewNwIRt18jA8+o+u3dpjq+sW
11
+ T8KOEUt+zwvo/7V3LvSye0rgTBIlDHCNAymg4VMk7BPZ7hm/ELNKjD+Jo2FR3qyH
12
+ B5T0Y3HsLuJvW5iB4YlcNHlsdu87kGJ55tukmi8mxdAQ4Q7e2RCOFvu396j3x+UC
13
+ B5iPNgiV5+I3lg02dZ77DnKxHZu8A/lJBdiB3QW0KtZB6awBdpUKD9jf1b0SHzUv
14
+ KBds0pjBqAlkd25HN7rOrFleaJ1/ctaJxQZBKT5ZPt0m9STJEadao0xAH0ahmbWn
15
+ OlFuhjuefXKnEgV4We0+UXgVCwOPjdAvBbI+e0ocS3MFEvzG6uBQE3xDk3SzynTn
16
+ jh8BCNAw1FtxNrQHusEwMFxIt4I7mKZ9YIqioymCzLq9gwQbooMDQaHWBfEbwrbw
17
+ qHyGO0aoSCqI3Haadr8faqU9GY/rOPNk3sgrDQoo//fb4hVC1CLQJ13hef4Y53CI
18
+ rU7m2Ys6xt0nUW7/vGT1M0NPAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNV
19
+ HRMBAf8EBTADAQH/MB0GA1UdDgQWBBR5tFnme7bl5AFzgAiIyBpY9umbbjANBgkq
20
+ hkiG9w0BAQsFAAOCAgEAVR9YqbyyqFDQDLHYGmkgJykIrGF1XIpu+ILlaS/V9lZL
21
+ ubhzEFnTIZd+50xx+7LSYK05qAvqFyFWhfFQDlnrzuBZ6brJFe+GnY+EgPbk6ZGQ
22
+ 3BebYhtF8GaV0nxvwuo77x/Py9auJ/GpsMiu/X1+mvoiBOv/2X/qkSsisRcOj/KK
23
+ NFtY2PwByVS5uCbMiogziUwthDyC3+6WVwW6LLv3xLfHTjuCvjHIInNzktHCgKQ5
24
+ ORAzI4JMPJ+GslWYHb4phowim57iaztXOoJwTdwJx4nLCgdNbOhdjsnvzqvHu7Ur
25
+ TkXWStAmzOVyyghqpZXjFaH3pO3JLF+l+/+sKAIuvtd7u+Nxe5AW0wdeRlN8NwdC
26
+ jNPElpzVmbUq4JUagEiuTDkHzsxHpFKVK7q4+63SM1N95R1NbdWhscdCb+ZAJzVc
27
+ oyi3B43njTOQ5yOf+1CceWxG1bQVs5ZufpsMljq4Ui0/1lvh+wjChP4kqKOJ2qxq
28
+ 4RgqsahDYVvTH9w7jXbyLeiNdd8XM2w9U/t7y0Ff/9yi0GE44Za4rF2LN9d11TPA
29
+ mRGunUHBcnWEvgJBQl9nJEiU0Zsnvgc/ubhPgXRR4Xq37Z0j4r7g1SgEEzwxA57d
30
+ emyPxgcYxn/eR44/KJ4EBs+lVDR3veyJm+kXQ99b21/+jh5Xos1AnX5iItreGCc=
31
+ -----END CERTIFICATE-----
README.md ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Video Editing Genie
3
+ emoji: 🎥
4
+ colorFrom: blue
5
+ colorTo: purple
6
+ sdk: gradio
7
+ app_file: app.py
8
+ pinned: false
9
+ tags:
10
+ - mcp-server-track
11
+ ---
12
+
13
+ Video Editing Genie is an AI-powered video editing assistant that helps users create, edit, and enhance videos using advanced AI models like Gemini.
app.py ADDED
@@ -0,0 +1,249 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import gradio as gr
2
+ import os
3
+ from dotenv import load_dotenv
4
+ load_dotenv()
5
+ from tools.video_tools import (
6
+ change_aspect_ratio,
7
+ add_text_overlay,
8
+ change_speed,
9
+ extract_audio,
10
+ add_image_overlay,
11
+ apply_color_filter,
12
+ merge_videos,
13
+ edit_video_segment,
14
+ )
15
+ from tools.audio_tools import (
16
+ add_audio_to_video,
17
+ search_audio_from_envato,
18
+ )
19
+ from typing import List
20
+ from tools.gemini_tool import analyze_video_context
21
+ from tools.subtitle_tools import generate_word_level_subtitles_from_url
22
+ from pathlib import Path
23
+ import ast
24
+ videos_dir = Path.cwd().absolute() / "videos"
25
+ if not videos_dir.exists():
26
+ videos_dir.mkdir(parents=True, exist_ok=True)
27
+
28
+
29
+
30
+
31
+ demo = gr.TabbedInterface(
32
+ [
33
+ gr.Interface(
34
+ fn=change_aspect_ratio,
35
+ inputs=[
36
+ gr.Textbox(label="Video URL"),
37
+ gr.Dropdown(["9:16", "1:1", "4:3", "16:9"], label="Target Ratio", value="9:16"),
38
+ gr.Dropdown(["blur_bg", "pad", "crop", "stretch"], label="Method", value="blur_bg",
39
+ info="blur_bg: Recommended for YouTube style videos - adds blurred background with centered video")
40
+ ],
41
+ outputs=gr.Textbox(label="Result URL"),
42
+ description="Convert video aspect ratio with YouTube-style blurred background",
43
+ api_name="change_aspect_ratio"
44
+ ),
45
+ gr.Interface(
46
+ fn=add_text_overlay,
47
+ inputs=[
48
+ gr.Textbox(label="Video URL"),
49
+ gr.JSON(label="Text Elements", value=[{
50
+ "text": "Sample Text",
51
+ "start_time": 0,
52
+ "end_time": 5,
53
+ "font_size": 24,
54
+ "font_color": "white",
55
+ "x_pos": "center",
56
+ "y_pos": "bottom",
57
+ "box": True,
58
+ "box_color": "[email protected]"
59
+ }])
60
+ ],
61
+ outputs=gr.Textbox(label="Result URL"),
62
+ api_name="add_text_overlay"
63
+ ),
64
+ gr.Interface(
65
+ fn=change_speed,
66
+ inputs=[
67
+ gr.Textbox(label="Video URL"),
68
+ gr.Slider(0.25, 4, value=1.0, label="Speed Factor")
69
+ ],
70
+ outputs=gr.Textbox(label="Result URL"),
71
+ api_name="change_speed"
72
+ ),
73
+ gr.Interface(
74
+ fn=extract_audio,
75
+ inputs=[
76
+ gr.Textbox(label="Video URL"),
77
+ gr.Dropdown(["mp3", "aac", "wav", "ogg", "flac"], label="Audio Format", value="mp3")
78
+ ],
79
+ outputs=gr.Textbox(label="Result URL"),
80
+ api_name="extract_audio"
81
+ ),
82
+
83
+ gr.Interface(
84
+ fn=add_image_overlay,
85
+ inputs=[
86
+ gr.Textbox(label="Video URL"),
87
+ gr.Textbox(label="Image Path"),
88
+ gr.Dropdown(["top_left", "top_right", "bottom_left", "bottom_right", "center"],
89
+ label="Position", value="top_right"),
90
+ gr.Slider(0, 1, value=1.0, label="Opacity"),
91
+ gr.Number(label="Start Time (seconds, optional)", value=None),
92
+ gr.Number(label="End Time (seconds, optional)", value=None),
93
+ gr.Textbox(label="Width (optional, e.g. '100', 'iw*0.1')", value=None),
94
+ gr.Textbox(label="Height (optional, e.g. '50', 'ih*0.1')", value=None)
95
+ ],
96
+ outputs=gr.Textbox(label="Result URL"),
97
+ api_name="add_image_overlay"
98
+ ),
99
+ gr.Interface(
100
+ fn=apply_color_filter,
101
+ inputs=[
102
+ gr.Textbox(label="Video URL"),
103
+ gr.Dropdown(["sepia", "grayscale", "warm", "cool", "vintage"], label="Filter Type", value="sepia"),
104
+ gr.Slider(0, 1, value=1.0, label="Intensity")
105
+ ],
106
+ outputs=gr.Textbox(label="Result URL"),
107
+ api_name="apply_color_filter"
108
+ ),
109
+ gr.Interface(
110
+ fn=merge_videos,
111
+ inputs=[
112
+ gr.Textbox(
113
+ label="Video URLs (Python list format)",
114
+ placeholder='["https://url1.com", "https://url2.com"]',
115
+ lines=4,
116
+ info="Enter a list of video URLs to merge with transitions"
117
+ ),
118
+ # gr.Dropdown(
119
+ # ["fade", "none"],
120
+ # label="Transition Type",
121
+ # value="fade",
122
+ # info="Select the type of transition to apply between videos (only fade is supported)"
123
+ # ),
124
+ # gr.Number(
125
+ # 0.5, 3.0,
126
+ # value=1.0,
127
+ # label="Transition Duration (seconds)",
128
+ # info="Duration of each transition between clips"
129
+ # )
130
+ ],
131
+ outputs=gr.Textbox(label="Result URL"),
132
+ title="Merge Videos",
133
+ description="Combine multiple videos",
134
+ api_name="merge_videos"
135
+ ),
136
+ gr.Interface(
137
+ fn=add_audio_to_video,
138
+ inputs=[
139
+ gr.Textbox(label="Video URL"),
140
+ gr.Textbox(label="Audio URL"),
141
+ gr.Number(label="Start Time (seconds)", value=0),
142
+ gr.Slider(0, 2, value=1.0, label="Volume"),
143
+ gr.Radio(["true", "false"], label="Keep Original Audio", value="true")
144
+ ],
145
+ outputs=gr.Textbox(label="Result URL"),
146
+ api_name="add_audio"
147
+ ),
148
+
149
+ gr.Interface(
150
+ fn=analyze_video_context,
151
+ inputs=[
152
+ gr.Textbox(label="Video URL"),
153
+ gr.Textbox(label="Analysis Task", value="Describe what's happening in this video")
154
+ ],
155
+ outputs=gr.Textbox(label="Analysis Result"),
156
+ api_name="analyze_video"
157
+ ),
158
+ gr.Interface(
159
+ fn=edit_video_segment,
160
+ inputs=[
161
+ gr.Textbox(label="Video URL"),
162
+ gr.Number(label="Start Time (seconds)", value=0),
163
+ gr.Number(label="End Time (seconds)", value=10),
164
+ gr.Radio(["cut", "keep"], label="Action", value="keep")
165
+ ],
166
+ outputs=gr.Textbox(label="Result URL"),
167
+ api_name="edit_video_segment"
168
+ ),
169
+ # Add this to your Gradio app
170
+ gr.Interface(
171
+ fn=generate_word_level_subtitles_from_url,
172
+ inputs=[
173
+ gr.Textbox(
174
+ label="Video URL",
175
+ placeholder="Enter the video URL to add word-level subtitles",
176
+ lines=1,
177
+ info="Required: Direct URL to the video file"
178
+ ),
179
+ gr.Textbox(
180
+ label="Custom API URL (Optional)",
181
+ placeholder="Leave blank to use default transcription API",
182
+ lines=1,
183
+ info="Optional: Custom transcription API endpoint"
184
+ )
185
+ ],
186
+ outputs=gr.Textbox(
187
+ label="Result",
188
+ placeholder="Processing status and download URL will appear here...",
189
+ lines=3,
190
+ info="Check here for the final video URL or any error messages"
191
+ ),
192
+ title="Word-Level Subtitle Generator",
193
+ description="""
194
+ **MCP Server Tool: Add word-by-word subtitles to videos**
195
+
196
+ This tool processes videos to add precise word-level subtitles where each word appears exactly when spoken.
197
+ Perfect for creating engaging content with synchronized text overlay.
198
+
199
+ Simply paste your video URL and click Submit. Processing typically takes 1-3 minutes.
200
+ """,
201
+ examples=[
202
+ ["https://example.com/sample-video.mp4", ""],
203
+ ["https://example.com/another-video.mp4", "https://custom-api.com/transcribe"]
204
+ ],
205
+ api_name="word_level_subtitles",
206
+
207
+ ),
208
+ gr.Interface(
209
+ fn=search_audio_from_envato,
210
+ inputs=[
211
+ gr.Textbox(label="Search Query"),
212
+ gr.Slider(1, 50, value=10, step=1, label="Limit"),
213
+ gr.Dropdown(
214
+ ["music", "effects"],
215
+ label="Category",
216
+ value=None,
217
+ multiselect=False
218
+ ),
219
+ gr.Number(label="Min Length (seconds)", value=None),
220
+ gr.Number(label="Max Length (seconds)", value=None),
221
+ gr.Textbox(label="Tags (comma-separated)", value=None),
222
+ gr.Checkbox(label="Free Only", value=False)
223
+ ],
224
+ outputs=gr.JSON(label="Sound Effects"),
225
+ title="Sound Effect Search",
226
+ description="Search for royalty-free sound effects using Envato API",
227
+ api_name="search_audio_from_envato"
228
+ )
229
+ ],
230
+
231
+ [
232
+ "Change Aspect Ratio",
233
+ "Add Text Overlay",
234
+ "Change Speed",
235
+ "Extract Audio",
236
+ "Add Image Overlay",
237
+ "Apply Color Filter",
238
+ "Merge Videos",
239
+ "Add Audio",
240
+ "Analyze Video",
241
+ "Edit Video Segment",
242
+ "Add Subtitles",
243
+ "Search Audio from Envato"
244
+ ]
245
+ )
246
+
247
+ if __name__ == "__main__":
248
+
249
+ demo.launch(mcp_server=True, share=True, allowed_paths=[videos_dir])
deploy_whisper_on_modal.py ADDED
@@ -0,0 +1,105 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import modal
2
+ from fastapi import Form, HTTPException
3
+
4
+
5
+ cuda_version = "12.4.0" # should be no greater than host CUDA version
6
+ flavor = "devel" # includes full CUDA toolkit
7
+ operating_sys = "ubuntu22.04"
8
+ tag = f"{cuda_version}-{flavor}-{operating_sys}"
9
+
10
+ image = (
11
+ modal.Image.from_registry(f"nvidia/cuda:{tag}", add_python="3.11")
12
+ .apt_install(
13
+ "git",
14
+ "ffmpeg",
15
+ "libcudnn8",
16
+ "libcudnn8-dev",
17
+ )
18
+ .pip_install(
19
+ "fastapi[standard]",
20
+ "httpx",
21
+ "torch==2.0.0",
22
+ "torchaudio==2.0.0",
23
+ "numpy<2.0",
24
+ extra_index_url="https://download.pytorch.org/whl/cu118",
25
+ )
26
+ .pip_install(
27
+ "git+https://github.com/m-bain/[email protected]",
28
+ "ffmpeg-python",
29
+ "ctranslate2==4.4.0",
30
+ )
31
+ )
32
+ app = modal.App("whisperx-api", image=image)
33
+
34
+ GPU_CONFIG = "L4"
35
+
36
+ CACHE_DIR = "/cache"
37
+ cache_vol = modal.Volume.from_name("whisper-cache", create_if_missing=True)
38
+
39
+ @app.cls(
40
+ gpu=GPU_CONFIG,
41
+ volumes={CACHE_DIR: cache_vol},
42
+ scaledown_window=60 * 10,
43
+ timeout=60 * 60,
44
+ )
45
+ @modal.concurrent(max_inputs=15)
46
+ class Model:
47
+ @modal.enter()
48
+ def setup(self):
49
+ import whisperx
50
+
51
+ device = "cuda"
52
+ compute_type = (
53
+ "float16" # change to "int8" if low on GPU mem (may reduce accuracy)
54
+ )
55
+
56
+ # 1. Transcribe with original whisper (batched)
57
+ self.model = whisperx.load_model("large-v2", device, compute_type=compute_type, download_root=CACHE_DIR)
58
+
59
+ @modal.method()
60
+ def transcribe(self, audio_url: str):
61
+ import requests
62
+ import whisperx
63
+
64
+ batch_size = 16
65
+
66
+ response = requests.get(audio_url)
67
+ audio_path = "downloaded_audio.wav"
68
+ with open(audio_path, "wb") as audio_file:
69
+ audio_file.write(response.content)
70
+
71
+ audio = whisperx.load_audio(audio_path)
72
+
73
+ result = self.model.transcribe(audio, batch_size=batch_size)
74
+
75
+ model_a, metadata = whisperx.load_align_model(language_code=result["language"], device="cuda")
76
+ aligned_result = whisperx.align(result["segments"], model_a, metadata, audio_path, device="cuda")
77
+
78
+ results = {
79
+ "language": result["language"],
80
+ "language_probability": result.get("language_probability", 1.0),
81
+ "words": []
82
+ }
83
+
84
+ for word in aligned_result["word_segments"]:
85
+ results["words"].append({
86
+ "start": word["start"],
87
+ "end": word["end"],
88
+ "word": word["word"]
89
+ })
90
+
91
+ return results
92
+
93
+ @app.function()
94
+ @modal.fastapi_endpoint(docs=True, method="POST")
95
+ async def transcribe_endpoint(url: str = Form(...)):
96
+ if not url.startswith(("http://", "https://")):
97
+ raise HTTPException(status_code=400, detail="URL must start with http:// or https://")
98
+ return Model().transcribe.remote(audio_url=url)
99
+
100
+ # ## Run the model
101
+ @app.local_entrypoint()
102
+ def main():
103
+ url = "https://pub-ebe9e51393584bf5b5bea84a67b343c2.r2.dev/examples_english_english.wav"
104
+ print(Model().transcribe.remote(url))
105
+
helpers/video_tool_helper.py ADDED
@@ -0,0 +1,220 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import subprocess
3
+ import tempfile
4
+ import shutil
5
+ from videos_management import save_video_to_static_folder, download_video_from_url
6
+ import uuid
7
+
8
+ def ratio_to_resolution(ratio: str, base_height=720):
9
+ """Convert ratio string W:H to pixel resolution keeping height fixed."""
10
+ try:
11
+ w, h = map(int, ratio.split(':'))
12
+ if w <= 0 or h <= 0:
13
+ raise ValueError()
14
+ except Exception:
15
+ raise ValueError(f"Invalid ratio format: {ratio}. Expected 'W:H' with positive ints.")
16
+ width = int(base_height * w / h)
17
+ height = base_height
18
+ # Ensure even numbers for ffmpeg
19
+ width = width + (width % 2)
20
+ height = height + (height % 2)
21
+ return width, height
22
+
23
+ def probe_video_dimensions(video_path):
24
+ """Use ffprobe to get width and height of video."""
25
+ cmd = [
26
+ "ffprobe", "-v", "error",
27
+ "-select_streams", "v:0",
28
+ "-show_entries", "stream=width,height",
29
+ "-of", "csv=p=0",
30
+ video_path
31
+ ]
32
+ output = subprocess.check_output(cmd).decode().strip()
33
+ width, height = map(int, output.split(','))
34
+ return width, height
35
+
36
+
37
+
38
+ def get_duration(video_path: str) -> float:
39
+ """Get duration of a video using ffprobe."""
40
+ cmd = [
41
+ 'ffprobe', '-v', 'error', '-show_entries', 'format=duration',
42
+ '-of', 'default=noprint_wrappers=1:nokey=1', video_path
43
+ ]
44
+ result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
45
+ return float(result.stdout.strip())
46
+
47
+ def add_transition(
48
+ first_video_url: str,
49
+ second_video_url: str,
50
+ transition_type: str = "fade",
51
+ transition_duration: float = 1.0
52
+ ) -> str:
53
+ """
54
+ Add a simple transition between two videos using fade in/out
55
+
56
+ Args:
57
+ first_video_url: URL to the first video
58
+ second_video_url: URL to the second video
59
+ transition_type: Type of transition (only "fade" is supported)
60
+ transition_duration: Duration of the transition in seconds
61
+
62
+ Returns:
63
+ URL to the merged video with transition
64
+ """
65
+ # Download videos
66
+ first_video_path = download_video_from_url(first_video_url)
67
+ second_video_path = download_video_from_url(second_video_url)
68
+
69
+ if not first_video_path or not second_video_path:
70
+ print("Failed to download one or both videos")
71
+ return None
72
+
73
+ # Create temp directory and files
74
+ temp_dir = tempfile.mkdtemp()
75
+ first_fade_out = os.path.join(temp_dir, "fade_out.mp4")
76
+ second_fade_in = os.path.join(temp_dir, "fade_in.mp4")
77
+ concat_file = os.path.join(temp_dir, "concat.txt")
78
+ output_path = os.path.join(temp_dir, "output.mp4")
79
+
80
+ try:
81
+ # Get durations
82
+ dur1 = get_duration(first_video_path)
83
+ dur2 = get_duration(second_video_path)
84
+
85
+ # Validate transition duration
86
+ if transition_duration <= 0 or transition_duration > min(dur1, dur2):
87
+ print(f"Invalid transition duration (max: {min(dur1, dur2):.2f}s)")
88
+ return None
89
+
90
+ fade_out_start = dur1 - transition_duration
91
+ subprocess.run([
92
+ "ffmpeg", "-y", "-i", first_video_path,
93
+ "-vf", f"fade=t=out:st={fade_out_start}:d={transition_duration}",
94
+ "-c:a", "copy", first_fade_out
95
+ ], check=True, capture_output=True)
96
+
97
+ subprocess.run([
98
+ "ffmpeg", "-y", "-i", second_video_path,
99
+ "-vf", f"fade=t=in:st=0:d={transition_duration}",
100
+ "-c:a", "copy", second_fade_in
101
+ ], check=True, capture_output=True)
102
+
103
+ with open(concat_file, 'w') as f:
104
+ f.write(f"file '{first_fade_out}'\n")
105
+ f.write(f"file '{second_fade_in}'\n")
106
+
107
+ subprocess.run([
108
+ "ffmpeg", "-y", "-f", "concat", "-safe", "0",
109
+ "-i", concat_file, "-c", "copy", output_path
110
+ ], check=True, capture_output=True)
111
+
112
+ result_url = save_video_to_static_folder(output_path)
113
+
114
+ shutil.rmtree(temp_dir)
115
+
116
+ return result_url
117
+
118
+ except Exception as e:
119
+ print(f"Error creating transition: {str(e)}")
120
+ if os.path.exists(temp_dir):
121
+ shutil.rmtree(temp_dir)
122
+ return None
123
+
124
+
125
+
126
+ def crop_video(
127
+ video_url: str,
128
+ x: int,
129
+ y: int,
130
+ width: int,
131
+ height: int
132
+ ) -> str:
133
+ """
134
+ Crop a video to the specified dimensions.
135
+
136
+ Args:
137
+ video_url (str): URL to the input video file
138
+ x (int): X coordinate of the top-left corner
139
+ y (int): Y coordinate of the top-left corner
140
+ width (int): Width of the cropped area
141
+ height (int): Height of the cropped area
142
+
143
+ Returns:
144
+ str: URL to the output video file
145
+ """
146
+ # Download the video from URL
147
+ video_path = download_video_from_url(video_url)
148
+ if not video_path:
149
+ return None
150
+
151
+ output_path = generate_temp_path()
152
+
153
+ try:
154
+ subprocess.run([
155
+ "ffmpeg", "-i", video_path,
156
+ "-vf", f"crop={width}:{height}:{x}:{y}",
157
+ "-c:a", "copy", output_path
158
+ ], check=True, capture_output=True)
159
+
160
+ # Save to static folder and get URL
161
+ result_url = save_video_to_static_folder(output_path)
162
+
163
+ # Clean up temp file (only the output_path, not the original video)
164
+ os.remove(output_path)
165
+
166
+ return result_url
167
+ except Exception as e:
168
+ print(f"Error cropping video: {e}")
169
+ return None
170
+
171
+
172
+
173
+
174
+ def resize_video(
175
+ video_url: str,
176
+ width: int,
177
+ height: int
178
+ ) -> str:
179
+ """
180
+ Resize a video to specified dimensions
181
+
182
+ Args:
183
+ video_url: URL to input video
184
+ width: Width in pixels
185
+ height: Height in pixels
186
+
187
+ Returns:
188
+ URL to output video
189
+ """
190
+ # Download the video from URL
191
+ video_path = download_video_from_url(video_url)
192
+ if not video_path:
193
+ return None
194
+
195
+ output_path = generate_temp_path()
196
+
197
+ try:
198
+ subprocess.run([
199
+ "ffmpeg", "-i", video_path,
200
+ "-vf", f"scale={width}:{height}",
201
+ "-c:a", "copy", output_path
202
+ ], check=True, capture_output=True)
203
+
204
+ # Save to static folder and get URL
205
+ result_url = save_video_to_static_folder(output_path)
206
+
207
+ # Clean up temp file (only the output_path, not the original video)
208
+ os.remove(output_path)
209
+
210
+ return result_url
211
+
212
+ except Exception as e:
213
+ print(f"Error resizing video: {e}")
214
+ return None
215
+
216
+
217
+ def generate_temp_path(extension="mp4"):
218
+ """Generate a temporary file path"""
219
+ temp_dir = tempfile.gettempdir()
220
+ return os.path.join(temp_dir, f"output_{uuid.uuid4()}.{extension}")
requirements.txt ADDED
Binary file (190 Bytes). View file
 
test.py ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ def test():
2
+ """
3
+ This function is a placeholder for testing purposes.
4
+ It currently does nothing but can be expanded with test cases.
5
+ """
6
+ pass
tools/audio_tools.py ADDED
@@ -0,0 +1,190 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import tempfile
3
+ import subprocess
4
+ import uuid
5
+ import requests
6
+ from typing import List
7
+ from videos_management import save_video_to_static_folder, download_video_from_url
8
+
9
+
10
+
11
+ def generate_temp_path(extension="mp4"):
12
+ """Generate a temporary file path"""
13
+ temp_dir = tempfile.gettempdir()
14
+ return os.path.join(temp_dir, f"output_{uuid.uuid4()}.{extension}")
15
+
16
+
17
+
18
+ def search_audio_from_envato(
19
+ query: str,
20
+ limit: int = 10,
21
+ category: str = None,
22
+ length_min: int = None,
23
+ length_max: int = None,
24
+ tags: str = None,
25
+ free_only: bool = False
26
+ ) -> List[dict]:
27
+ """
28
+ This function is used to search for royalty-free sound effects using Envato API.
29
+ It returns a list of sound effects with metadata and download URLs or preview URLs.
30
+
31
+ Args:
32
+ query (str): Search query
33
+ limit (int): Maximum number of results to return
34
+ category (str, optional): Category to filter by (e.g., "music", "effects")
35
+ length_min (int, optional): Minimum audio length in seconds
36
+ length_max (int, optional): Maximum audio length in seconds
37
+ tags (str, optional): Comma-separated list of tags to match
38
+ free_only (bool, optional): If True, only return free items
39
+
40
+ Returns:
41
+ List[dict]: List of sound effects with metadata and download URLs
42
+ """
43
+ api_key = os.getenv("ENVATO_API_KEY")
44
+ if not api_key:
45
+ print("ENVATO_API_KEY not found in environment variables")
46
+ return []
47
+
48
+ try:
49
+ # Envato API endpoint for AudioJungle
50
+ url = "https://api.envato.com/v1/discovery/search/search/item"
51
+
52
+ # Prepare parameters
53
+ params = {
54
+ "site": "audiojungle.net",
55
+ "term": query,
56
+ "page_size": limit
57
+ }
58
+
59
+ if category:
60
+ params["category"] = category
61
+
62
+ if length_min is not None:
63
+ params["length_min"] = length_min
64
+
65
+ if length_max is not None:
66
+ params["length_max"] = length_max
67
+
68
+ if tags:
69
+ params["tags"] = tags
70
+
71
+ headers = {"Authorization": f"Bearer {api_key}"}
72
+ response = requests.get(url, headers=headers, params=params)
73
+
74
+ if response.status_code != 200:
75
+ print(f"API request failed with status code {response.status_code}: {response.text}")
76
+ return []
77
+
78
+ # Parse response
79
+ data = response.json()
80
+ results = []
81
+
82
+ for item in data.get("matches", []):
83
+ if free_only and item.get("price_cents", 0) > 0:
84
+ continue
85
+
86
+ preview_url = None
87
+ if "previews" in item and "icon_with_audio_preview" in item["previews"]:
88
+ preview_url = item["previews"]["icon_with_audio_preview"].get("mp3_url")
89
+
90
+ if not preview_url:
91
+ continue
92
+
93
+ length_info = item.get("previews", {}).get("icon_with_audio_preview", {}).get("length", {})
94
+ length_seconds = (length_info.get("hours", 0) * 3600 +
95
+ length_info.get("minutes", 0) * 60 +
96
+ length_info.get("seconds", 0))
97
+
98
+
99
+ results.append({
100
+ "id": item.get("id"),
101
+ "name": item.get("name"),
102
+ "description": item.get("description"),
103
+ "preview_url": preview_url,
104
+ "thumbnail_url": item.get("previews", {}).get("icon_with_audio_preview", {}).get("icon_url"),
105
+ "length_seconds": length_seconds,
106
+
107
+ })
108
+ print(results)
109
+ return results
110
+ except Exception as e:
111
+ print(f"Error searching sound effects: {str(e)}")
112
+ return []
113
+
114
+
115
+ def add_audio_to_video(
116
+ video_url: str,
117
+ audio_path: str,
118
+ start_time: float = 0,
119
+ volume: float = 1.0,
120
+ keep_original_audio: bool = True
121
+ ) -> str:
122
+ """
123
+ Add audio to a video
124
+
125
+ Args:
126
+ video_url: URL to input video
127
+ audio_path: Path or URL to audio file
128
+ start_time: Time to start adding audio (seconds)
129
+ volume: Volume adjustment (1.0 is normal)
130
+ keep_original_audio: Whether to keep original audio
131
+
132
+ Returns:
133
+ URL to output video
134
+ """
135
+ # Download the video from URL
136
+ video_path = download_video_from_url(video_url)
137
+ if not video_path:
138
+ return None
139
+
140
+ # If audio_path is a URL, download it first
141
+ temp_audio_file = None
142
+ try:
143
+ if audio_path.startswith('http://') or audio_path.startswith('https://'):
144
+ # Download the audio from URL
145
+ response = requests.get(audio_path, stream=True)
146
+ if response.status_code != 200:
147
+ print(f"Failed to download audio: HTTP {response.status_code}")
148
+ return None
149
+
150
+ temp_audio_file = generate_temp_path(extension="mp3")
151
+ with open(temp_audio_file, 'wb') as f:
152
+ for chunk in response.iter_content(chunk_size=8192):
153
+ f.write(chunk)
154
+
155
+ audio_path = temp_audio_file
156
+
157
+ output_path = generate_temp_path()
158
+
159
+ if keep_original_audio:
160
+ # Mix original audio with new audio
161
+ subprocess.run([
162
+ "ffmpeg", "-y", "-i", video_path, "-i", audio_path,
163
+ "-filter_complex", f"[1:a]adelay={int(start_time*1000)}|{int(start_time*1000)},volume={volume}[added];[0:a][added]amix=duration=first[a]",
164
+ "-map", "0:v", "-map", "[a]", "-c:v", "copy", output_path
165
+ ], check=True, capture_output=True)
166
+ else:
167
+ # Replace original audio with new audio
168
+ subprocess.run([
169
+ "ffmpeg", "-y", "-i", video_path, "-i", audio_path,
170
+ "-filter_complex", f"[1:a]adelay={int(start_time*1000)}|{int(start_time*1000)},volume={volume}[a]",
171
+ "-map", "0:v", "-map", "[a]", "-c:v", "copy", output_path
172
+ ], check=True, capture_output=True)
173
+
174
+ # Save to static folder and get URL
175
+ result_url = save_video_to_static_folder(output_path)
176
+
177
+ # Clean up temp files
178
+ if os.path.exists(output_path):
179
+ os.remove(output_path)
180
+ if temp_audio_file and os.path.exists(temp_audio_file):
181
+ os.remove(temp_audio_file)
182
+
183
+ return result_url
184
+
185
+ except Exception as e:
186
+ print(f"Error adding audio to video: {e}")
187
+ # Clean up temp files
188
+ if temp_audio_file and os.path.exists(temp_audio_file):
189
+ os.remove(temp_audio_file)
190
+ return None
tools/gemini_tool.py ADDED
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # To run this code you need to install the following dependencies:
2
+ # pip install google-genai
3
+
4
+ import base64
5
+ import os
6
+ import tempfile
7
+ import mimetypes
8
+ from google import genai
9
+ from google.genai import types
10
+ from videos_management import download_video_from_url
11
+ import time
12
+
13
+ def analyze_video_context(video_url: str, task: str):
14
+ """
15
+ This is an AI modal that can analyze the entire video along with video. You can use this to analyze the context of a video.
16
+ Args:
17
+ video_url (str): The URL to the video file.
18
+ task (str): The task to analyze the video for.
19
+ Returns:
20
+ str: The response from the GEMINI AI MODEL.
21
+ """
22
+
23
+ video_path = download_video_from_url(video_url)
24
+ if not video_path:
25
+ return "Error: Could not download video from URL"
26
+
27
+ try:
28
+
29
+ client = genai.Client(
30
+ api_key=os.environ.get("GEMINI_API_KEY"),
31
+ )
32
+
33
+ instructions = """You are a video editing assistant that processes long videos and helps identify key moments for creating shorts. Analyze the video and provide clear, concise answers to timestamp-related questions. For example, if asked 'At what time does the hero die?' or 'What happens at [timestamp]?', give the exact time and a brief description of the scene. Use your analysis to pinpoint important segments to assist with video editing."""
34
+
35
+ mime_type, _ = mimetypes.guess_type(video_path)
36
+ if not mime_type or not mime_type.startswith('video/'):
37
+ mime_type = "video/mp4" # Default to mp4 if we can't determine or it's not a video
38
+
39
+ with open(video_path, "rb") as f:
40
+ video_bytes = f.read()
41
+
42
+ file_size = os.path.getsize(video_path)
43
+ if file_size > 20 * 1024 * 1024: # if larger than 20MB
44
+ with open(video_path, "rb") as f:
45
+ # Explicitly specify MIME type when uploading
46
+ video_file = client.files.upload(
47
+ file=f,
48
+ config=types.UploadFileConfigDict(mime_type=mime_type)
49
+ )
50
+
51
+ # Wait for processing to complete
52
+ max_retries = 12 # 1 minute max wait (5 sec * 12)
53
+ retry_count = 0
54
+ while video_file.state == "PROCESSING" and retry_count < max_retries:
55
+ print(f'Waiting for video to be processed. Attempt {retry_count+1}/{max_retries}')
56
+ time.sleep(5)
57
+ video_file = client.files.get(name=video_file.name)
58
+ retry_count += 1
59
+
60
+ if video_file.state != "SUCCEEDED":
61
+ return f"Error: Video processing failed or timed out. Current state: {video_file.state}"
62
+
63
+ response = client.models.generate_content(
64
+ model="gemini-2.5-pro-preview-05-06",
65
+ contents=[
66
+ video_file,
67
+ types.Part.from_text(text=instructions),
68
+ types.VideoMetadata(
69
+ fps=5,
70
+
71
+ )
72
+ ],
73
+ config=types.GenerateContentConfig(
74
+ system_instruction=instructions,
75
+ response_mime_type="text/plain",
76
+
77
+ )
78
+ )
79
+ else:
80
+ response = client.models.generate_content(
81
+ model="gemini-2.0-flash",
82
+ contents=types.Content(
83
+ parts=[
84
+ types.Part(
85
+ inline_data=types.Blob(
86
+ data=video_bytes,
87
+ mime_type=mime_type
88
+ ),
89
+ video_metadata=types.VideoMetadata(fps=5)
90
+ ),
91
+ types.Part(text=instructions)
92
+ ]
93
+ ),
94
+ config=types.GenerateContentConfig(
95
+ system_instruction=task,
96
+ response_mime_type="text/plain",
97
+ )
98
+ )
99
+
100
+ return response.text
101
+ except Exception as e:
102
+ return f"Error analyzing video: {str(e)}"
103
+
104
+
tools/subtitle_tools.py ADDED
@@ -0,0 +1,304 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import httpx
2
+ import os
3
+ import tempfile
4
+ import subprocess
5
+ import json
6
+ from typing import List, Dict, Any, Optional
7
+ from videos_management import download_video_from_url, save_video_to_static_folder
8
+ import uuid
9
+ import shutil
10
+ import requests
11
+ from functools import lru_cache
12
+ from dotenv import load_dotenv
13
+ import gradio as gr
14
+ import ffmpeg
15
+ import shlex
16
+
17
+ # Load environment variables
18
+ load_dotenv()
19
+
20
+ @lru_cache(maxsize=100)
21
+ def fetch_word_level_transcription(video_url: str, api_url: str) -> Dict[str, Any]:
22
+ """
23
+ Fetch word-level transcription from the API.
24
+
25
+ Args:
26
+ video_url (str): URL of the video to transcribe
27
+ api_url (str, optional): URL of the transcription API. If None, uses environment variable
28
+
29
+ Returns:
30
+ Dict[str, Any]: Dictionary containing transcription data with 'words' key, or None if failed
31
+
32
+ Raises:
33
+ Exception: If API request fails or returns invalid response
34
+ """
35
+ try:
36
+ if not api_url:
37
+ api_url = os.getenv("TRANSCRIPTION_API_URL", "https://malikibrarbhutta--whisperx-api-transcribe-endpoint.modal.run/")
38
+
39
+ print(f"Sending request to: {api_url}")
40
+
41
+ # Use httpx for better timeout handling
42
+ with httpx.Client(timeout=120.0) as client: # 2 minute timeout
43
+ response = client.post(
44
+ api_url,
45
+ data={"url": video_url},
46
+ headers={
47
+ "accept": "application/json",
48
+ "Content-Type": "application/x-www-form-urlencoded"
49
+ }
50
+ )
51
+
52
+ print(response.text)
53
+ if response.status_code != 200:
54
+ print(f"API error: {response.status_code} - {response.text}")
55
+ return None
56
+
57
+ return response.json()
58
+ except Exception as e:
59
+ print(f"Error fetching transcription: {e}")
60
+ return None
61
+
62
+ def create_simple_srt_file(transcription: dict) -> str:
63
+ """
64
+ Create a simple SRT subtitle file for word-level subtitles.
65
+
66
+ Args:
67
+ transcription (dict): Dictionary containing transcription data with 'words' key
68
+
69
+ Returns:
70
+ str: Path to the created SRT file, or None if creation failed
71
+
72
+ Raises:
73
+ Exception: If file creation fails or transcription data is invalid
74
+ """
75
+ words = transcription.get("words", [])
76
+ if not words:
77
+ return None
78
+
79
+ # Create temporary SRT file
80
+ temp_dir = tempfile.gettempdir()
81
+ srt_file = os.path.join(temp_dir, f"subtitles_{uuid.uuid4()}.srt")
82
+
83
+ def format_time(seconds):
84
+ """Convert seconds to SRT time format (HH:MM:SS,mmm)"""
85
+ hours = int(seconds // 3600)
86
+ minutes = int((seconds % 3600) // 60)
87
+ secs = int(seconds % 60)
88
+ millis = int((seconds % 1) * 1000)
89
+ return f"{hours:02d}:{minutes:02d}:{secs:02d},{millis:03d}"
90
+
91
+ with open(srt_file, "w", encoding="utf-8") as f:
92
+ for i, word in enumerate(words, 1):
93
+ word_text = word.get("word", "").strip()
94
+ if not word_text:
95
+ continue
96
+
97
+ start_time = format_time(word["start"])
98
+ end_time = format_time(word["end"])
99
+
100
+ f.write(f"{i}\n")
101
+ f.write(f"{start_time} --> {end_time}\n")
102
+ f.write(f"{word_text}\n\n")
103
+
104
+ return srt_file
105
+
106
+ def get_position_filter(position: str) -> str:
107
+ """
108
+ Convert position string to FFmpeg drawtext filter parameters with better spacing.
109
+
110
+ Args:
111
+ position (str): Position identifier ('bottom-center', 'top-center', 'middle', 'bottom-left', 'bottom-right')
112
+
113
+ Returns:
114
+ str: FFmpeg position parameters string
115
+ """
116
+ # Adjusted positions with better spacing from edges
117
+ positions = {
118
+ "bottom-center": "x=(w-text_w)/2:y=h-text_h-80", # More space from bottom
119
+ "top-center": "x=(w-text_w)/2:y=80", # More space from top
120
+ "middle": "x=(w-text_w)/2:y=(h-text_h)/2",
121
+ "bottom-left": "x=80:y=h-text_h-80", # More space from edges
122
+ "bottom-right": "x=w-text_w-80:y=h-text_h-80" # More space from edges
123
+ }
124
+ return positions.get(position, positions["bottom-center"])
125
+
126
+
127
+ def add_simple_subtitles_to_video(
128
+ video_path: str,
129
+ srt_file: str,
130
+ output_path: str,
131
+ font_size: int = 36,
132
+ font_color: str = "white",
133
+ position: str = "bottom-center"
134
+ ) -> bool:
135
+ try:
136
+ # Map friendly names to ASS format values
137
+ alignment_map = {
138
+ "bottom-center": 2,
139
+ "top-center": 8,
140
+ "center": 5
141
+ }
142
+ alignment = alignment_map.get(position, 2)
143
+
144
+ color_map = {
145
+ "white": "&HFFFFFF&",
146
+ "yellow": "&H00FFFF&",
147
+ "red": "&H0000FF&",
148
+ "green": "&H00FF00&",
149
+ "blue": "&HFF0000&",
150
+ "black": "&H000000&"
151
+ }
152
+ color = color_map.get(font_color.lower(), "&HFFFFFF&")
153
+
154
+ # Safely quote file paths
155
+ quoted_srt = shlex.quote(srt_file)
156
+
157
+ # Subtitle filter with style
158
+ subtitle_filter = (
159
+ f"subtitles={quoted_srt}:"
160
+ f"force_style='Fontsize={font_size},PrimaryColour={color},"
161
+ f"OutlineColour=&H000000&,Alignment={alignment},MarginV=20'"
162
+ )
163
+
164
+ # Run ffmpeg command
165
+ (
166
+ ffmpeg
167
+ .input(video_path)
168
+ .output(output_path, vf=subtitle_filter, vcodec='libx264', acodec='copy')
169
+ .overwrite_output()
170
+ .run()
171
+ )
172
+
173
+ print("✅ Subtitles added successfully.")
174
+ return True
175
+
176
+ except ffmpeg.Error as e:
177
+ print("❌ FFmpeg error:")
178
+ print(e.stderr.decode() if e.stderr else str(e))
179
+ return False
180
+
181
+
182
+ def get_color_hex(color: str) -> str:
183
+ """
184
+ Convert color name or hex to FFmpeg hex format.
185
+
186
+ Args:
187
+ color (str): Color name (e.g., 'white', 'red') or hex color (e.g., '#FFFFFF')
188
+
189
+ Returns:
190
+ str: Hex color code in uppercase format without '#' prefix
191
+ """
192
+ color_map = {
193
+ "white": "FFFFFF",
194
+ "black": "000000",
195
+ "red": "FF0000",
196
+ "green": "00FF00",
197
+ "blue": "0000FF",
198
+ "yellow": "FFFF00",
199
+ "cyan": "00FFFF",
200
+ "magenta": "FF00FF"
201
+ }
202
+
203
+ if color.startswith("#"):
204
+ return color[1:].upper()
205
+
206
+ return color_map.get(color.lower(), "FFFFFF")
207
+
208
+ def generate_word_level_subtitles_from_url(
209
+ video_url: str,
210
+ transcription_api_url: str = ""
211
+ ) -> str:
212
+ """
213
+ Generate word-level subtitles for a video from URL (MCP Server Function).
214
+
215
+ This function downloads a video, fetches word-level transcription, creates subtitles
216
+ where each word appears individually at the correct timing, and returns the final video URL.
217
+
218
+ Args:
219
+ video_url (str): URL of the video to process. Must be a valid, accessible video URL
220
+ transcription_api_url (str, optional): URL of the transcription API. If empty, uses default from environment
221
+
222
+ Returns:
223
+ str: URL to the output video with word-level subtitles, or error message if processing failed
224
+
225
+ Raises:
226
+ Exception: Various exceptions may occur during video download, transcription, or processing
227
+ """
228
+ try:
229
+
230
+ if not video_url or not video_url.strip():
231
+ return "Error: Video URL is required"
232
+
233
+ video_path = download_video_from_url(video_url)
234
+ if not video_path:
235
+ return "Error: Failed to download video. Please check the URL and try again."
236
+
237
+
238
+ # Fetch transcription
239
+ print("Fetching transcription...")
240
+ api_url = transcription_api_url.strip() if transcription_api_url.strip() else None
241
+ transcription = fetch_word_level_transcription(video_url, api_url)
242
+
243
+ if not transcription:
244
+ return "Error: Failed to get transcription from API. Please check your API URL or try again later."
245
+
246
+ words_count = len(transcription.get('words', []))
247
+ print(f"Transcription received with {words_count} words")
248
+
249
+ if words_count == 0:
250
+ return "Error: No words found in transcription. The video might be too short or contain no speech."
251
+
252
+ # Create SRT file
253
+ print("Creating subtitle file...")
254
+ srt_file = create_simple_srt_file(transcription)
255
+
256
+ if not srt_file:
257
+ return "Error: Failed to create subtitle file from transcription data."
258
+
259
+ print(f"Subtitle file created: {srt_file}")
260
+
261
+ # Create output path
262
+ temp_dir = tempfile.mkdtemp()
263
+ output_path = os.path.join(temp_dir, "subtitled_video.mp4")
264
+
265
+ # Add subtitles to video with improved positioning
266
+ print("Adding subtitles to video...")
267
+ success = add_simple_subtitles_to_video(
268
+ video_path=video_path,
269
+ srt_file=srt_file,
270
+ output_path=output_path,
271
+ font_size=38, # Slightly larger for better readability
272
+ font_color="white",
273
+ position="bottom-center"
274
+ )
275
+
276
+ if not success:
277
+ return "Error: Failed to add subtitles to video. FFmpeg processing failed."
278
+
279
+ print("Subtitles added successfully")
280
+
281
+ # Save to static folder
282
+ print("Saving final video...")
283
+ result_url = save_video_to_static_folder(output_path)
284
+
285
+ if result_url:
286
+ print(f"Video saved successfully: {result_url}")
287
+ return f"Success! Video with word-level subtitles: {result_url}"
288
+ else:
289
+ return "Error: Failed to save final video to static folder."
290
+
291
+ except Exception as e:
292
+ error_msg = f"Error: {str(e)}"
293
+ print(error_msg)
294
+ return error_msg
295
+
296
+ finally:
297
+ # Clean up temporary files
298
+ try:
299
+ if 'srt_file' in locals() and os.path.exists(srt_file):
300
+ os.remove(srt_file)
301
+ if 'temp_dir' in locals():
302
+ shutil.rmtree(temp_dir, ignore_errors=True)
303
+ except:
304
+ pass
tools/video_tools.py ADDED
@@ -0,0 +1,783 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import tempfile
3
+ import subprocess
4
+ import uuid
5
+ import requests
6
+ import json
7
+ import dotenv
8
+ import shutil
9
+ from typing import Optional, Tuple, Union, List
10
+ from pathlib import Path
11
+ from videos_management import save_video_to_static_folder, download_video_from_url
12
+ from helpers.video_tool_helper import generate_temp_path, get_duration
13
+ dotenv.load_dotenv()
14
+
15
+
16
+ def edit_video_segment(
17
+ video_url: str,
18
+ start_time: float,
19
+ end_time: float,
20
+ action: str = "cut"
21
+ ) -> str:
22
+ """
23
+ Edit a video segment - can cut out a segment or keep only that segment
24
+
25
+ Args:
26
+ video_url: URL to input video
27
+ start_time: Start time in seconds
28
+ end_time: End time in seconds
29
+ action: Either "cut" (remove segment) or "keep" (keep only segment)
30
+
31
+ Returns:
32
+ URL to output video
33
+ """
34
+ # Download the video from URL
35
+ video_path = download_video_from_url(video_url)
36
+ if not video_path:
37
+ return None
38
+
39
+ output_path = generate_temp_path()
40
+
41
+ try:
42
+ if action == "keep":
43
+ # Keep only the specified segment
44
+ subprocess.run([
45
+ "ffmpeg", "-i", video_path,
46
+ "-ss", str(start_time), "-to", str(end_time),
47
+ "-c", "copy", output_path
48
+ ], check=True, capture_output=True)
49
+
50
+ # Save to static folder and get URL
51
+ result_url = save_video_to_static_folder(output_path)
52
+
53
+ # Clean up temp files (only the output_path, not the original video)
54
+ os.remove(output_path)
55
+
56
+ return result_url
57
+
58
+ elif action == "cut":
59
+ # Get video duration
60
+ duration_cmd = [
61
+ "ffprobe", "-v", "error", "-show_entries", "format=duration",
62
+ "-of", "default=noprint_wrappers=1:nokey=1", video_path
63
+ ]
64
+ duration = float(subprocess.check_output(duration_cmd).decode().strip())
65
+
66
+ # Create temp directory for segments
67
+ temp_dir = tempfile.mkdtemp()
68
+ segments = []
69
+
70
+ # First segment (before cut)
71
+ if start_time > 0:
72
+ segment1 = os.path.join(temp_dir, "segment1.mp4")
73
+ subprocess.run([
74
+ "ffmpeg", "-i", video_path,
75
+ "-ss", "0", "-to", str(start_time),
76
+ "-c", "copy", segment1
77
+ ], check=True, capture_output=True)
78
+ segments.append(segment1)
79
+
80
+ # Second segment (after cut)
81
+ if end_time < duration:
82
+ segment2 = os.path.join(temp_dir, "segment2.mp4")
83
+ subprocess.run([
84
+ "ffmpeg", "-i", video_path,
85
+ "-ss", str(end_time), "-to", str(duration),
86
+ "-c", "copy", segment2
87
+ ], check=True, capture_output=True)
88
+ segments.append(segment2)
89
+
90
+ # If no segments to keep
91
+ if not segments:
92
+ # Clean up
93
+ shutil.rmtree(temp_dir)
94
+ return None
95
+
96
+ # If only one segment
97
+ if len(segments) == 1:
98
+ shutil.copy(segments[0], output_path)
99
+
100
+ # Save to static folder and get URL
101
+ result_url = save_video_to_static_folder(output_path)
102
+
103
+ # Clean up
104
+ shutil.rmtree(temp_dir)
105
+ os.remove(output_path)
106
+
107
+ return result_url
108
+
109
+ # Concatenate segments
110
+ concat_file = os.path.join(temp_dir, "concat.txt")
111
+ with open(concat_file, 'w') as f:
112
+ for segment in segments:
113
+ f.write(f"file '{segment}'\n")
114
+
115
+ subprocess.run([
116
+ "ffmpeg", "-f", "concat", "-safe", "0",
117
+ "-i", concat_file, "-c", "copy", output_path
118
+ ], check=True, capture_output=True)
119
+
120
+ # Save to static folder and get URL
121
+ result_url = save_video_to_static_folder(output_path)
122
+
123
+ # Clean up
124
+ shutil.rmtree(temp_dir)
125
+ os.remove(output_path)
126
+
127
+ return result_url
128
+ else:
129
+ print(f"Unknown action: {action}")
130
+ return None
131
+
132
+ except Exception as e:
133
+ print(f"Error editing video segment: {e}")
134
+ return None
135
+
136
+ def add_text_overlay(
137
+ video_url: str,
138
+ text_elements: list[dict]
139
+ ) -> str:
140
+ """
141
+ Add one or more text overlays to a video at specified times and positions.
142
+
143
+ Args:
144
+ video_url: URL to the input video file.
145
+ text_elements: A list of dictionaries, where each dictionary defines a text overlay.
146
+ Required keys for each text_element dict:
147
+ - 'text': str - The text to display.
148
+ - 'start_time': str or float - Start time (seconds).
149
+ - 'end_time': str or float - End time (seconds).
150
+ Optional keys for each text_element dict:
151
+ - 'font_size': int (default: 24)
152
+ - 'font_color': str (default: 'white')
153
+ - 'x_pos': str or int (default: 'center')
154
+ - 'y_pos': str or int (default: 'h-th-10')
155
+ - 'box': bool (default: False)
156
+ - 'box_color': str (default: '[email protected]')
157
+ - 'box_border_width': int (default: 0)
158
+
159
+ Returns:
160
+ URL to output video
161
+ """
162
+ # Download the video from URL
163
+ video_path = download_video_from_url(video_url)
164
+ if not video_path:
165
+ return None
166
+
167
+ if not text_elements:
168
+ return video_url # Return original URL if no text elements
169
+
170
+ output_path = generate_temp_path()
171
+
172
+ try:
173
+ # Build filter string for each text element
174
+ filter_parts = []
175
+
176
+ for element in text_elements:
177
+ text = element.get('text')
178
+ start_time = element.get('start_time')
179
+ end_time = element.get('end_time')
180
+
181
+ if text is None or start_time is None or end_time is None:
182
+ print("Text element is missing required keys (text, start_time, end_time)")
183
+ continue
184
+
185
+ # Escape special characters in text
186
+ safe_text = text.replace('\\', '\\\\').replace("'", "\\'").replace(':', '\\:').replace(',', '\\,')
187
+
188
+ # Build filter parameters
189
+ params = [
190
+ f"text='{safe_text}'",
191
+ f"fontsize={element.get('font_size', 24)}",
192
+ f"fontcolor={element.get('font_color', 'white')}"
193
+ ]
194
+
195
+ # Handle position
196
+ x_pos = element.get('x_pos', 'center')
197
+ if x_pos == 'center':
198
+ x_pos = '(w-text_w)/2'
199
+ params.append(f"x={x_pos}")
200
+
201
+ y_pos = element.get('y_pos', 'h-th-10')
202
+ if y_pos == 'bottom':
203
+ y_pos = 'h-text_h-10'
204
+ elif y_pos == 'top':
205
+ y_pos = '10'
206
+ params.append(f"y={y_pos}")
207
+
208
+ # Enable text during specified time range
209
+ params.append(f"enable=between(t\\,{start_time}\\,{end_time})")
210
+
211
+ # Add box if specified
212
+ if element.get('box', False):
213
+ params.append("box=1")
214
+ params.append(f"boxcolor={element.get('box_color', '[email protected]')}")
215
+ if 'box_border_width' in element:
216
+ params.append(f"boxborderw={element['box_border_width']}")
217
+
218
+ # Add font file if specified
219
+ if 'font_file' in element:
220
+ font_path = element['font_file'].replace('\\', '\\\\').replace("'", "\\'").replace(':', '\\:')
221
+ params.append(f"fontfile='{font_path}'")
222
+
223
+ # Join parameters
224
+ filter_parts.append(f"drawtext={':'.join(params)}")
225
+
226
+ # Join all filter parts
227
+ filter_string = ','.join(filter_parts)
228
+
229
+ # Apply filters
230
+ subprocess.run([
231
+ "ffmpeg", "-i", video_path,
232
+ "-vf", filter_string,
233
+ "-c:a", "copy",
234
+ output_path
235
+ ], check=True, capture_output=True)
236
+
237
+ # Save to static folder and get URL
238
+ result_url = save_video_to_static_folder(output_path)
239
+
240
+ # Clean up temp file
241
+ os.remove(output_path)
242
+
243
+ return result_url
244
+
245
+ except Exception as e:
246
+ print(f"Error adding text overlay: {e}")
247
+ return None
248
+
249
+ def change_speed(
250
+ video_url: str,
251
+ speed_factor: float
252
+ ) -> str:
253
+ """
254
+ Change video playback speed
255
+
256
+ Args:
257
+ video_url: URL to input video
258
+ speed_factor: Speed factor (1.0 is normal, <1 is slower, >1 is faster)
259
+
260
+ Returns:
261
+ URL to output video
262
+ """
263
+ video_path = download_video_from_url(video_url)
264
+ if not video_path:
265
+ return None
266
+
267
+ output_path = generate_temp_path()
268
+
269
+ try:
270
+ # For video: setpts=1/speed_factor*PTS
271
+ # For audio: atempo=speed_factor (limited to 0.5-2.0 range)
272
+
273
+ # Handle audio speed limits
274
+ audio_filter = ""
275
+ remaining_factor = speed_factor
276
+
277
+ while remaining_factor > 2.0:
278
+ audio_filter += "atempo=2.0,"
279
+ remaining_factor /= 2.0
280
+
281
+ while remaining_factor < 0.5:
282
+ audio_filter += "atempo=0.5,"
283
+ remaining_factor *= 2.0
284
+
285
+ audio_filter += f"atempo={remaining_factor}"
286
+
287
+ subprocess.run([
288
+ "ffmpeg", "-i", video_path,
289
+ "-filter_complex", f"[0:v]setpts={1/speed_factor}*PTS[v];[0:a]{audio_filter}[a]",
290
+ "-map", "[v]", "-map", "[a]", output_path
291
+ ], check=True, capture_output=True)
292
+
293
+ # Save to static folder and get URL
294
+ result_url = save_video_to_static_folder(output_path)
295
+
296
+ # Clean up temp file (only the output_path, not the original video)
297
+ os.remove(output_path)
298
+
299
+ return result_url
300
+
301
+ except Exception as e:
302
+ print(f"Error changing video speed: {e}")
303
+ return None
304
+
305
+ def extract_audio(
306
+ video_url: str,
307
+ audio_format: str = "mp3"
308
+ ) -> str:
309
+ """
310
+ Extract audio from a video
311
+
312
+ Args:
313
+ video_url: URL to input video
314
+ audio_format: Output audio format (mp3, aac, wav, etc.)
315
+
316
+ Returns:
317
+ URL to output audio
318
+ """
319
+ print("extension", audio_format)
320
+ video_path = download_video_from_url(video_url)
321
+ if not video_path:
322
+ return None
323
+
324
+ output_path = generate_temp_path(extension=audio_format)
325
+
326
+ try:
327
+ # Map audio codecs to formats
328
+ codec_map = {
329
+ "mp3": "libmp3lame",
330
+ "aac": "aac",
331
+ "wav": "pcm_s16le",
332
+ "ogg": "libvorbis",
333
+ "flac": "flac"
334
+ }
335
+
336
+ codec = codec_map.get(audio_format, audio_format)
337
+
338
+ subprocess.run([
339
+ "ffmpeg", "-i", video_path,
340
+ "-vn", "-acodec", codec, output_path
341
+ ], check=True, capture_output=True)
342
+
343
+ result_url = save_video_to_static_folder(video_path=output_path, extension=audio_format)
344
+
345
+ os.remove(output_path)
346
+
347
+ return result_url
348
+
349
+ except Exception as e:
350
+ print(f"Error extracting audio: {e}")
351
+ return None
352
+
353
+
354
+ def add_image_overlay(
355
+ video_url: str,
356
+ image_path: str,
357
+ position: str = 'top_right',
358
+ opacity: float = 1.0,
359
+ start_time: float = None,
360
+ end_time: float = None,
361
+ width: str = None,
362
+ height: str = None
363
+ ) -> str:
364
+ """
365
+ Add an image overlay (watermark/logo) to a video.
366
+
367
+ Args:
368
+ video_url: URL to the input video file.
369
+ image_path: Path to the image file for the overlay.
370
+ position: Position of the overlay.
371
+ Options: 'top_left', 'top_right', 'bottom_left', 'bottom_right', 'center'.
372
+ Or specify custom coordinates like 'x=10:y=10'.
373
+ opacity: Opacity of the overlay (0.0 to 1.0).
374
+ start_time: Start time for the overlay in seconds. If None, starts from beginning.
375
+ end_time: End time for the overlay in seconds. If None, lasts till end.
376
+ width: Width for the overlay image (e.g., '100', 'iw*0.1'). Original if None.
377
+ height: Height for the overlay image (e.g., '50', 'ih*0.1'). Original if None.
378
+
379
+ Returns:
380
+ URL to output video
381
+ """
382
+ # Download the video from URL
383
+ video_path = download_video_from_url(video_url)
384
+ if not video_path:
385
+ return None
386
+
387
+ if not os.path.exists(image_path):
388
+ print(f"Image file not found: {image_path}")
389
+ return None
390
+
391
+ output_path = generate_temp_path()
392
+
393
+ try:
394
+ # Process the image (scale, set opacity)
395
+ image_filters = []
396
+
397
+ # Apply scaling if requested
398
+ if width or height:
399
+ scale_params = []
400
+ if width:
401
+ scale_params.append(f"width={width}")
402
+ else:
403
+ scale_params.append("width=-1") # Auto-width maintaining aspect
404
+
405
+ if height:
406
+ scale_params.append(f"height={height}")
407
+ else:
408
+ scale_params.append("height=-1") # Auto-height maintaining aspect
409
+
410
+ image_filters.append(f"scale={':'.join(scale_params)}")
411
+
412
+ # Apply opacity if requested
413
+ if opacity is not None and opacity < 1.0:
414
+ image_filters.append("format=rgba") # Ensure alpha channel exists
415
+ image_filters.append(f"colorchannelmixer=aa={opacity}")
416
+
417
+ # Determine overlay position coordinates
418
+ overlay_x = '0'
419
+ overlay_y = '0'
420
+
421
+ if position == 'top_left':
422
+ overlay_x, overlay_y = '10', '10'
423
+ elif position == 'top_right':
424
+ overlay_x, overlay_y = 'main_w-overlay_w-10', '10'
425
+ elif position == 'bottom_left':
426
+ overlay_x, overlay_y = '10', 'main_h-overlay_h-10'
427
+ elif position == 'bottom_right':
428
+ overlay_x, overlay_y = 'main_w-overlay_w-10', 'main_h-overlay_h-10'
429
+ elif position == 'center':
430
+ overlay_x, overlay_y = '(main_w-overlay_w)/2', '(main_h-overlay_h)/2'
431
+ elif ':' in position:
432
+ pos_parts = position.split(':')
433
+ for part in pos_parts:
434
+ if part.startswith('x='):
435
+ overlay_x = part.split('=')[1]
436
+ if part.startswith('y='):
437
+ overlay_y = part.split('=')[1]
438
+
439
+ # Create a temporary file for the processed image
440
+ temp_image = generate_temp_path(extension="png")
441
+
442
+ # Process the image if needed
443
+ if image_filters:
444
+ image_filter_string = ','.join(image_filters)
445
+ subprocess.run([
446
+ "ffmpeg", "-i", image_path,
447
+ "-vf", image_filter_string,
448
+ temp_image
449
+ ], check=True, capture_output=True)
450
+ else:
451
+ # Just copy the image
452
+ shutil.copy(image_path, temp_image)
453
+
454
+ # Prepare overlay filter parameters
455
+ overlay_params = [f"x={overlay_x}", f"y={overlay_y}"]
456
+
457
+ # Add time-based enabling condition if specified
458
+ if start_time is not None or end_time is not None:
459
+ enable_expr = []
460
+ if start_time is not None:
461
+ enable_expr.append(f"gte(t,{start_time})")
462
+ if end_time is not None:
463
+ enable_expr.append(f"lte(t,{end_time})")
464
+
465
+ if enable_expr:
466
+ overlay_params.append(f"enable={'*'.join(enable_expr)}")
467
+
468
+ # Build the filter complex
469
+ filter_complex = f"[0:v][1:v]overlay={':'.join(overlay_params)}[out]"
470
+
471
+ # Apply the overlay
472
+ subprocess.run([
473
+ "ffmpeg", "-i", video_path, "-i", temp_image,
474
+ "-filter_complex", filter_complex,
475
+ "-map", "[out]", "-map", "0:a?",
476
+ "-c:a", "copy",
477
+ output_path
478
+ ], check=True, capture_output=True)
479
+
480
+ # Clean up temp image
481
+ os.remove(temp_image)
482
+
483
+ # Save to static folder and get URL
484
+ result_url = save_video_to_static_folder(output_path)
485
+
486
+ # Clean up temp file
487
+ os.remove(output_path)
488
+
489
+ return result_url
490
+
491
+ except Exception as e:
492
+ print(f"Error adding image overlay: {e}")
493
+ return None
494
+
495
+ def apply_color_filter(
496
+ video_url: str,
497
+ filter_type: str,
498
+ intensity: float = 1.0
499
+ ) -> str:
500
+ """
501
+ Apply a color filter/effect to a video.
502
+
503
+ Args:
504
+ video_url (str): URL to the input video file
505
+ filter_type (str): Type of filter ('sepia', 'grayscale', 'warm', 'cool', 'vintage')
506
+ intensity (float): Intensity of the effect (0-1)
507
+
508
+ Returns:
509
+ str: URL to the output video file
510
+ """
511
+ # Download the video from URL
512
+ video_path = download_video_from_url(video_url)
513
+ if not video_path:
514
+ return None
515
+
516
+ output_path = generate_temp_path()
517
+
518
+ try:
519
+ # Define filter expressions based on filter type
520
+ if filter_type == "sepia":
521
+ filter_expr = (
522
+ f"colorchannelmixer="
523
+ f"rr={1-0.393*intensity}:rg={0.769*intensity}:rb={0.189*intensity}:"
524
+ f"gr={0.349*intensity}:gg={1-0.686*intensity}:gb={0.168*intensity}:"
525
+ f"br={0.272*intensity}:bg={0.534*intensity}:bb={1-0.131*intensity}"
526
+ )
527
+ elif filter_type == "grayscale":
528
+ filter_expr = f"colorchannelmixer=.3:.4:.3:0:.3:.4:.3:0:.3:.4:.3"
529
+ elif filter_type == "warm":
530
+ filter_expr = f"colortemperature=temperature={6500+1500*intensity}"
531
+ elif filter_type == "cool":
532
+ filter_expr = f"colortemperature=temperature={6500-1500*intensity}"
533
+ elif filter_type == "vintage":
534
+ # Vintage look: slight sepia tone + vignette + grain
535
+ filter_expr = (
536
+ f"colorchannelmixer="
537
+ f"rr=.93:rg=.01:rb=.07:"
538
+ f"gr=.22:gg=.85:gb=.05:"
539
+ f"br=.05:bg=.20:bb=.90,"
540
+ f"vignette=angle=PI/4:x0=(W/2):y0=(H/2)"
541
+ )
542
+ else:
543
+ print(f"Unsupported filter type: {filter_type}")
544
+ return None
545
+
546
+ subprocess.run([
547
+ "ffmpeg", "-i", video_path,
548
+ "-vf", filter_expr,
549
+ "-c:a", "copy", output_path
550
+ ], check=True, capture_output=True)
551
+
552
+ # Save to static folder and get URL
553
+ result_url = save_video_to_static_folder(output_path)
554
+
555
+ # Clean up temp file (only the output_path, not the original video)
556
+ os.remove(output_path)
557
+
558
+ return result_url
559
+ except Exception as e:
560
+ print(f"Error applying color filter: {e}")
561
+ return None
562
+
563
+ def merge_videos(
564
+ video_urls: List[str]
565
+ ) -> str:
566
+ """
567
+ Merge multiple videos without transitions
568
+
569
+ Args:
570
+ video_urls: List of video URLs to merge
571
+
572
+ Returns:
573
+ URL to the merged video
574
+ """
575
+ # Validate input
576
+ if not video_urls:
577
+ print("No video URLs provided")
578
+ return None
579
+
580
+ if not isinstance(video_urls, list):
581
+ print(f"Expected list of video URLs, got {type(video_urls)}")
582
+ # Try to handle single video case
583
+ if isinstance(video_urls, str):
584
+ video_urls = [video_urls]
585
+ else:
586
+ return None
587
+
588
+ # Download all videos
589
+ video_paths = []
590
+ for url in video_urls:
591
+ if url is None:
592
+ continue
593
+
594
+ print(f"Processing video: {url}")
595
+ path = download_video_from_url(url)
596
+ if path:
597
+ video_paths.append(path)
598
+ else:
599
+ print(f"Failed to download video: {url}")
600
+ # Continue with other videos instead of failing completely
601
+
602
+ # Check if we have any valid videos
603
+ if not video_paths:
604
+ print("No valid videos to merge")
605
+ return None
606
+
607
+ if len(video_paths) == 1:
608
+ # Just return a copy of the single video
609
+ result_url = save_video_to_static_folder(video_paths[0])
610
+ return result_url
611
+
612
+ # Create temp files
613
+ temp_dir = tempfile.mkdtemp()
614
+ output_path = os.path.join(temp_dir, "output.mp4")
615
+ concat_file = os.path.join(temp_dir, "concat.txt")
616
+
617
+ try:
618
+ # Simple concatenation without transitions
619
+ with open(concat_file, 'w') as f:
620
+ for path in video_paths:
621
+ f.write(f"file '{path}'\n")
622
+
623
+ subprocess.run([
624
+ "ffmpeg", "-y", "-f", "concat", "-safe", "0",
625
+ "-i", concat_file, "-c", "copy", output_path
626
+ ], check=True, capture_output=True)
627
+
628
+ # Save to static folder and get URL
629
+ result_url = save_video_to_static_folder(output_path)
630
+
631
+ # Clean up temp files
632
+ shutil.rmtree(temp_dir)
633
+
634
+ return result_url
635
+ except Exception as e:
636
+ print(f"Error merging videos: {str(e)}")
637
+
638
+ # Clean up temp files
639
+ if os.path.exists(temp_dir):
640
+ shutil.rmtree(temp_dir)
641
+
642
+ return None
643
+
644
+
645
+ def change_aspect_ratio(video_url: str, target_ratio: str = "9:16", method: str = "smart") -> str:
646
+ """ Change the aspect ratio of a video to a target ratio using specified method.
647
+ Args:
648
+ video_url (str): URL of the video to process.
649
+ target_ratio (str): Target aspect ratio in format "W:H" (default: "9:16").
650
+ method (str): Method to use for changing aspect ratio ("pad", "crop", "stretch", "smart").
651
+ Returns:
652
+ str: URL to the processed video with changed aspect ratio.
653
+ """
654
+ if not video_url:
655
+ print("No video URL provided")
656
+ return None
657
+
658
+ valid_methods = ["pad", "crop", "stretch", "smart"]
659
+ if method not in valid_methods:
660
+ print(f"Invalid method: {method}. Must be one of: {', '.join(valid_methods)}")
661
+ return None
662
+
663
+ try:
664
+ target_w, target_h = map(int, target_ratio.split(':'))
665
+ if target_w <= 0 or target_h <= 0:
666
+ raise ValueError("Ratio values must be positive integers")
667
+ except Exception:
668
+ print(f"Invalid target ratio format: {target_ratio}. Must be in format 'W:H' with positive integers")
669
+ return None
670
+
671
+ video_path = download_video_from_url(video_url)
672
+ if not video_path:
673
+ print("Failed to download video from URL")
674
+ return None
675
+
676
+ output_path = generate_temp_path()
677
+ temp_dir = tempfile.mkdtemp()
678
+ blurred_bg = None
679
+
680
+ try:
681
+ # Get original video dimensions
682
+ probe_cmd = [
683
+ "ffprobe", "-v", "error", "-select_streams", "v:0",
684
+ "-show_entries", "stream=width,height",
685
+ "-of", "csv=p=0", video_path
686
+ ]
687
+ width, height = map(int, subprocess.check_output(probe_cmd).decode().strip().split(','))
688
+ target_aspect = target_w / target_h
689
+ current_aspect = width / height
690
+
691
+ if method == "pad":
692
+ filter_expr = f"scale=iw*min({target_aspect}*ih/iw,1):ih*min({1/target_aspect}*iw/ih,1),pad={target_w}:{target_h}:(ow-iw)/2:(oh-ih)/2:black"
693
+
694
+ elif method == "crop":
695
+ if current_aspect > target_aspect:
696
+ new_width = int(height * target_aspect)
697
+ x_offset = int((width - new_width) / 2)
698
+ filter_expr = f"crop={new_width}:{height}:{x_offset}:0,scale={target_w}:{target_h}"
699
+ else:
700
+ new_height = int(width / target_aspect)
701
+ y_offset = int((height - new_height) / 2)
702
+ filter_expr = f"crop={width}:{new_height}:0:{y_offset},scale={target_w}:{target_h}"
703
+
704
+ elif method == "stretch":
705
+ filter_expr = f"scale={target_w}:{target_h},setdar={target_ratio}"
706
+
707
+ # Smart → internally uses blur_bg when converting landscape to portrait
708
+ if method == "blur_bg" or (method == "smart" and target_aspect < current_aspect and target_ratio == "9:16"):
709
+ # Use "blur_bg" logic
710
+ target_w, target_h = 1080, 1920
711
+ scale_factor_w = target_w / width
712
+ scale_factor_h = target_h / height
713
+ main_scale = min(scale_factor_w, scale_factor_h)
714
+ main_width = int(width * main_scale)
715
+ main_height = int(height * main_scale)
716
+ main_y = int((target_h - main_height) / 2)
717
+ bg_scale = max(target_w / width, target_h / height)
718
+
719
+ if width * bg_scale > 3840:
720
+ bg_scale = 3840 / width
721
+
722
+ bg_width = (int(width * bg_scale) + 1) & ~1
723
+ bg_height = (int(height * bg_scale) + 1) & ~1
724
+ main_width = (main_width + 1) & ~1
725
+ main_height = (main_height + 1) & ~1
726
+
727
+ blurred_bg = os.path.join(temp_dir, "blurred_bg.mp4")
728
+
729
+ # Try to create blurred background
730
+ try:
731
+ filter_string = f"scale={bg_width}:{bg_height},boxblur=8:1"
732
+ subprocess.run([
733
+ "ffmpeg", "-i", video_path,
734
+ "-vf", filter_string,
735
+ "-c:v", "libx264", "-preset", "fast", "-crf", "23",
736
+ "-c:a", "copy", blurred_bg
737
+ ], check=True, capture_output=True)
738
+
739
+ subprocess.run([
740
+ "ffmpeg",
741
+ "-i", blurred_bg,
742
+ "-i", video_path,
743
+ "-filter_complex",
744
+ f"[0:v]scale={target_w}:{target_h},setsar=1[bg];" +
745
+ f"[1:v]scale={main_width}:{main_height}[main];" +
746
+ f"[bg][main]overlay=(W-w)/2:{main_y}:format=auto",
747
+ "-map", "[v]", "-map", "1:a?",
748
+ "-c:v", "libx264", "-preset", "fast", "-crf", "23",
749
+ "-c:a", "copy", output_path
750
+ ], check=True, capture_output=True)
751
+
752
+ return save_video_to_static_folder(output_path)
753
+
754
+ except subprocess.CalledProcessError as e:
755
+ print("FFmpeg error:", e.stderr.decode())
756
+ return None
757
+
758
+ elif method == "smart":
759
+ # Fallback if not a 9:16 conversion, use crop logic
760
+ return change_aspect_ratio(video_url, target_ratio, method="crop")
761
+
762
+ # For simple methods (pad/crop/stretch)
763
+ subprocess.run([
764
+ "ffmpeg", "-i", video_path,
765
+ "-vf", filter_expr,
766
+ "-c:v", "libx264", "-preset", "fast", "-crf", "23",
767
+ "-c:a", "copy", output_path
768
+ ], check=True, capture_output=True)
769
+
770
+ return save_video_to_static_folder(output_path)
771
+
772
+ except Exception as e:
773
+ print("Unexpected error:", str(e))
774
+ return None
775
+
776
+ finally:
777
+ for path in [output_path, blurred_bg]:
778
+ if path and os.path.exists(path):
779
+ try: os.remove(path)
780
+ except: pass
781
+ if os.path.exists(temp_dir):
782
+ try: shutil.rmtree(temp_dir)
783
+ except: pass
videos_management.py ADDED
@@ -0,0 +1,106 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pathlib import Path
2
+ import uuid
3
+ import os
4
+ import shutil
5
+
6
+ APP_BASE_URL = os.getenv("APP_BASE_URL", "http://127.0.0.1:7860/gradio_api")
7
+
8
+ def generate_unique_id():
9
+ """Generate a unique ID for a video file"""
10
+ return str(uuid.uuid4())
11
+
12
+ def save_video_to_static_folder(video_path, unique_id=None, extension="mp4"):
13
+ """
14
+ Save a video to the static videos folder
15
+
16
+ Args:
17
+ video_path (str): Path to the video file
18
+ unique_id (str, optional): Unique ID for the video. If None, one will be generated
19
+
20
+ Returns:
21
+ str: URL to access the video
22
+ """
23
+ if unique_id is None:
24
+ unique_id = generate_unique_id()
25
+
26
+ output_dir = Path.cwd().absolute() / "videos"
27
+ if not output_dir.exists():
28
+ output_dir.mkdir(parents=True, exist_ok=True)
29
+
30
+ output_path = output_dir / f"{unique_id}.{extension}"
31
+
32
+ # Copy the file to the output path
33
+ shutil.copy(video_path, output_path)
34
+
35
+ # Generate URL
36
+ video_url = f"{APP_BASE_URL}/file=videos/{unique_id}.{extension}"
37
+
38
+ return video_url
39
+
40
+ def download_video_from_url(video_url):
41
+ """
42
+ Download a video from a URL to a temporary file or handle local file paths
43
+
44
+ Args:
45
+ video_url (str): URL of the video or path to local file
46
+
47
+ Returns:
48
+ str: Path to the downloaded temporary file or local file
49
+ """
50
+ import tempfile
51
+ import requests
52
+ from pathlib import Path
53
+ import os
54
+
55
+ print("Video url", video_url)
56
+
57
+ if video_url is None:
58
+ print("Video URL is None")
59
+ return None
60
+
61
+ video_url = str(video_url)
62
+
63
+ # Check if it's a local file path
64
+ if os.path.exists(video_url):
65
+ print(f"Using existing local file: {video_url}")
66
+ return video_url
67
+
68
+ # Check if it's a Gradio API URL
69
+ if video_url.startswith(f"{APP_BASE_URL}/file=videos/"):
70
+ # Extract the unique ID from the URL
71
+ unique_id = video_url.split('/')[-1].split('.')[0]
72
+ video_path = Path.cwd().absolute() / "videos" / f"{unique_id}.mp4"
73
+ if video_path.exists():
74
+ print(f"Found local file for URL: {str(video_path)}")
75
+ return str(video_path)
76
+
77
+ # Handle file paths that may have been incorrectly passed as URLs
78
+ # For Windows paths that may be misinterpreted as URLs
79
+ if ":\\" in video_url or (os.path.sep == "\\" and "\\" in video_url):
80
+ potential_path = video_url
81
+ if os.path.exists(potential_path):
82
+ print(f"Using existing Windows path: {potential_path}")
83
+ return potential_path
84
+
85
+ # If not a local file or path, download it from URL
86
+ try:
87
+ print(f"Downloading from URL: {video_url}")
88
+ response = requests.get(video_url, stream=True)
89
+ if response.status_code != 200:
90
+ print(f"Failed to download video: HTTP {response.status_code}")
91
+ return None
92
+
93
+ # Create a temporary file
94
+ temp_file = tempfile.NamedTemporaryFile(delete=False, suffix=".mp4")
95
+
96
+ # Write the content to the file
97
+ for chunk in response.iter_content(chunk_size=8192):
98
+ temp_file.write(chunk)
99
+
100
+ temp_file.close()
101
+ print(f"Downloaded to: {temp_file.name}")
102
+ return temp_file.name
103
+
104
+ except Exception as e:
105
+ print(f"Error downloading video: {str(e)}")
106
+ return None