Nipun Claude commited on
Commit
631a800
·
1 Parent(s): b6649d4

Add interactive Gradio web interface for video trimming

Browse files

- Create video_trimmer_demo.py with visual trim controls
- Add drag-to-scrub sliders for precise start/end selection
- Include audio player for extracted AAC files
- Auto-reload functionality for development
- Update README with web interface documentation
- Add screenshot of the UI interface

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

Files changed (4) hide show
  1. README.md +32 -7
  2. requirements.txt +4 -0
  3. run_demo.sh +50 -0
  4. video_trimmer_demo.py +377 -0
README.md CHANGED
@@ -1,12 +1,13 @@
1
- A fast and efficient command-line tool for trimming MP4 videos with minimal processing and automatic audio extraction. This tool prioritizes speed by using stream copying when possible, falling back to re-encoding only when necessary for precision.
2
 
3
  ## Features
4
 
5
- - **Trim videos** between specified start and end times
6
- - **Minimal processing** with smart stream copying when possible
7
- - **Extract audio** in AAC format automatically
8
- - **Cross-platform** compatibility (macOS and Linux)
9
- - **Flexible options** with sensible defaults
 
10
 
11
  ## Prerequisites
12
 
@@ -72,6 +73,30 @@ trim-convert --help
72
 
73
  Now you can use `trim-convert` from any directory.
74
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
75
  ## File Permissions
76
 
77
  ### Understanding Script Permissions
@@ -99,7 +124,7 @@ Look for `x` in the permissions (e.g., `-rwxr-xr-x`). The `x` indicates execute
99
  - **"Operation not permitted"**: Need `sudo` for system directories
100
  - **"Command not found"**: Script not in current directory or PATH
101
 
102
- ## Usage
103
 
104
  ```bash
105
  ./trim-convert.sh [options] input.mp4
 
1
+ A fast and efficient video trimming toolkit with both a **web interface** and **command-line tool** for MP4 video processing. Features visual trimming with drag-to-scrub sliders and automatic audio extraction.
2
 
3
  ## Features
4
 
5
+ - **Web Interface**: Interactive Gradio demo with drag-to-trim sliders
6
+ - **Command Line**: Fast bash script for automated processing
7
+ - **Smart Trimming**: Visual video scrubbing to find exact cut points
8
+ - **Audio Extraction**: Automatic AAC extraction with built-in player
9
+ - **Minimal Processing**: Stream copying when possible for speed
10
+ - **Cross-Platform**: Works on macOS, Linux, and Windows WSL
11
 
12
  ## Prerequisites
13
 
 
73
 
74
  Now you can use `trim-convert` from any directory.
75
 
76
+ ## Gradio Web Interface
77
+
78
+ For an interactive video trimming experience, use the web interface:
79
+
80
+ ![Video Trimmer Interface](demo/screenshot-ui.jpg)
81
+
82
+ ### Quick Start
83
+ ```bash
84
+ # Install dependencies
85
+ pip install -r requirements.txt
86
+
87
+ # Launch the web interface
88
+ ./run_demo.sh
89
+ ```
90
+
91
+ ### Features
92
+ - **Video Upload**: Drag & drop MP4/MOV/AVI files
93
+ - **Visual Trimming**: Scrub sliders to find exact start/end points
94
+ - **Live Preview**: Video seeks to slider position for precise editing
95
+ - **Audio Playback**: Built-in player for extracted audio
96
+ - **Download**: Get both trimmed video and AAC audio files
97
+
98
+ The web interface automatically converts times and calls the command-line script for processing.
99
+
100
  ## File Permissions
101
 
102
  ### Understanding Script Permissions
 
124
  - **"Operation not permitted"**: Need `sudo` for system directories
125
  - **"Command not found"**: Script not in current directory or PATH
126
 
127
+ ## Command Line Usage
128
 
129
  ```bash
130
  ./trim-convert.sh [options] input.mp4
requirements.txt ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ gradio>=4.0.0
2
+ numpy>=1.24.0
3
+ Pillow>=9.0.0
4
+ watchdog>=3.0.0
run_demo.sh ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/bash
2
+
3
+ echo "🚀 Setting up Video Trimmer Demo..."
4
+
5
+ # Install Python dependencies
6
+ echo "📦 Installing dependencies..."
7
+ pip install -r requirements.txt
8
+
9
+ # Run the demo with auto-reload
10
+ echo "🎬 Starting Gradio demo with auto-reload..."
11
+ echo "💡 The demo will auto-reload when you save changes to video_trimmer_demo.py"
12
+ python -c "
13
+ import subprocess
14
+ import time
15
+ from pathlib import Path
16
+ from watchdog.observers import Observer
17
+ from watchdog.events import FileSystemEventHandler
18
+
19
+ class ReloadHandler(FileSystemEventHandler):
20
+ def __init__(self):
21
+ self.process = None
22
+ self.start_server()
23
+
24
+ def start_server(self):
25
+ if self.process:
26
+ self.process.terminate()
27
+ self.process.wait()
28
+ print('🔄 Starting/Restarting Gradio server...')
29
+ self.process = subprocess.Popen(['python', 'video_trimmer_demo.py'])
30
+
31
+ def on_modified(self, event):
32
+ if event.src_path.endswith('video_trimmer_demo.py'):
33
+ print('📝 File changed, reloading...')
34
+ time.sleep(0.5) # Brief delay to ensure file is fully written
35
+ self.start_server()
36
+
37
+ handler = ReloadHandler()
38
+ observer = Observer()
39
+ observer.schedule(handler, '.', recursive=False)
40
+ observer.start()
41
+
42
+ try:
43
+ while True:
44
+ time.sleep(1)
45
+ except KeyboardInterrupt:
46
+ observer.stop()
47
+ if handler.process:
48
+ handler.process.terminate()
49
+ observer.join()
50
+ "
video_trimmer_demo.py ADDED
@@ -0,0 +1,377 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import gradio as gr
2
+ import subprocess
3
+ import os
4
+ import tempfile
5
+ import shutil
6
+ import logging
7
+ import time
8
+ from pathlib import Path
9
+
10
+ # Set up logging
11
+ logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
12
+ logger = logging.getLogger(__name__)
13
+
14
+ def process_video_trim(video_file, start_time, end_time):
15
+ """Process video trimming using the trim-convert.sh script"""
16
+ logger.info(f"🎬 Starting trim process: file={video_file}, start={start_time}, end={end_time}")
17
+
18
+ if not video_file or start_time is None or end_time is None:
19
+ error_msg = "Please provide video file and both start/end times"
20
+ logger.error(f"❌ {error_msg}")
21
+ return None, None, None, error_msg
22
+
23
+ try:
24
+ # start_time and end_time are now numbers (seconds) from sliders
25
+ start_seconds = float(start_time)
26
+ end_seconds = float(end_time)
27
+
28
+ logger.info(f"📊 Parsed times: start={start_seconds}s, end={end_seconds}s")
29
+
30
+ if start_seconds >= end_seconds:
31
+ error_msg = "Start time must be less than end time"
32
+ logger.error(f"❌ {error_msg}")
33
+ return None, None, None, error_msg
34
+
35
+ # Check if input file exists
36
+ if not os.path.exists(video_file):
37
+ error_msg = f"Input video file not found: {video_file}"
38
+ logger.error(f"❌ {error_msg}")
39
+ return None, None, None, error_msg
40
+
41
+ # Create temporary directory for output
42
+ temp_dir = tempfile.mkdtemp()
43
+ logger.info(f"📁 Created temp directory: {temp_dir}")
44
+
45
+ input_path = video_file
46
+
47
+ # Get the base filename without extension
48
+ base_name = Path(input_path).stem
49
+ output_prefix = os.path.join(temp_dir, f"{base_name}_trimmed")
50
+
51
+ # The script will create these files based on the prefix
52
+ output_video = f"{output_prefix}.mp4"
53
+ output_audio = f"{output_prefix}.aac"
54
+
55
+ logger.info(f"📤 Output files will be: video={output_video}, audio={output_audio}")
56
+
57
+ # Check if trim-convert.sh script exists
58
+ script_path = "./trim-convert.sh"
59
+ if not os.path.exists(script_path):
60
+ error_msg = f"trim-convert.sh script not found at: {script_path}"
61
+ logger.error(f"❌ {error_msg}")
62
+ return None, None, None, error_msg
63
+
64
+ # Convert seconds to HH:MM:SS format for the script
65
+ def seconds_to_time(seconds):
66
+ hours = int(seconds // 3600)
67
+ minutes = int((seconds % 3600) // 60)
68
+ secs = seconds % 60
69
+ return f"{hours:02d}:{minutes:02d}:{secs:06.3f}"
70
+
71
+ start_time_str = seconds_to_time(start_seconds)
72
+ end_time_str = seconds_to_time(end_seconds)
73
+
74
+ logger.info(f"🕒 Converted times: start={start_time_str}, end={end_time_str}")
75
+
76
+ # Call the trim-convert.sh script with proper format
77
+ cmd = [
78
+ "bash", script_path,
79
+ "-s", start_time_str,
80
+ "-e", end_time_str,
81
+ "-o", output_prefix,
82
+ input_path
83
+ ]
84
+
85
+ logger.info(f"🚀 Running command: {' '.join(cmd)}")
86
+
87
+ result = subprocess.run(cmd, capture_output=True, text=True, cwd='.')
88
+
89
+ logger.info(f"📋 Command finished with return code: {result.returncode}")
90
+ logger.info(f"📤 STDOUT: {result.stdout}")
91
+ if result.stderr:
92
+ logger.warning(f"⚠️ STDERR: {result.stderr}")
93
+
94
+ if result.returncode == 0:
95
+ # Check if files were created
96
+ video_exists = os.path.exists(output_video)
97
+ audio_exists = os.path.exists(output_audio)
98
+
99
+ logger.info(f"📁 File check: video_exists={video_exists}, audio_exists={audio_exists}")
100
+
101
+ if video_exists and audio_exists:
102
+ video_size = os.path.getsize(output_video)
103
+ audio_size = os.path.getsize(output_audio)
104
+ logger.info(f"📊 File sizes: video={video_size} bytes, audio={audio_size} bytes")
105
+
106
+ # Create MP3 version for audio player (better browser compatibility)
107
+ timestamp = str(int(time.time() * 1000))
108
+ temp_audio_dir = os.path.dirname(output_audio)
109
+ audio_player_file = os.path.join(temp_audio_dir, f"player_audio_{timestamp}.mp3")
110
+
111
+ # Convert AAC to MP3 for better browser support
112
+ convert_cmd = [
113
+ "ffmpeg", "-y", "-i", output_audio,
114
+ "-codec:a", "libmp3lame", "-b:a", "128k",
115
+ audio_player_file
116
+ ]
117
+
118
+ logger.info(f"🔄 Converting audio for player: {' '.join(convert_cmd)}")
119
+ convert_result = subprocess.run(convert_cmd, capture_output=True, text=True)
120
+
121
+ if convert_result.returncode == 0 and os.path.exists(audio_player_file):
122
+ logger.info(f"🎵 Created MP3 audio player file: {audio_player_file}")
123
+ logger.info(f"📊 Audio player file size: {os.path.getsize(audio_player_file)} bytes")
124
+ else:
125
+ logger.warning(f"⚠️ MP3 conversion failed, using original AAC file")
126
+ audio_player_file = output_audio
127
+
128
+ success_msg = f"✅ Successfully trimmed video from {start_seconds:.1f}s to {end_seconds:.1f}s"
129
+ logger.info(success_msg)
130
+ return output_video, audio_player_file, output_audio, success_msg
131
+ else:
132
+ error_msg = f"❌ Output files not created.\n\nScript STDOUT:\n{result.stdout}\n\nScript STDERR:\n{result.stderr}\n\nExpected files:\nVideo: {output_video}\nAudio: {output_audio}"
133
+ logger.error(error_msg)
134
+ return None, None, None, error_msg
135
+ else:
136
+ error_msg = f"❌ trim-convert.sh failed with return code {result.returncode}\n\nCommand run:\n{' '.join(cmd)}\n\nSTDOUT:\n{result.stdout}\n\nSTDERR:\n{result.stderr}"
137
+ logger.error(error_msg)
138
+ return None, None, None, error_msg
139
+
140
+ except Exception as e:
141
+ error_msg = f"❌ Unexpected error: {str(e)}"
142
+ logger.exception(error_msg)
143
+ return None, None, None, error_msg
144
+
145
+ def get_video_duration(video_file):
146
+ """Get video duration in seconds"""
147
+ if not video_file:
148
+ return 0
149
+
150
+ try:
151
+ logger.info(f"📺 Getting duration for: {video_file}")
152
+
153
+ # Use ffprobe to get video duration
154
+ cmd = [
155
+ "ffprobe", "-v", "quiet", "-print_format", "json",
156
+ "-show_format", "-show_streams", video_file
157
+ ]
158
+ result = subprocess.run(cmd, capture_output=True, text=True)
159
+
160
+ if result.returncode == 0:
161
+ import json
162
+ data = json.loads(result.stdout)
163
+ duration = float(data['format']['duration'])
164
+ logger.info(f"⏱️ Video duration: {duration} seconds")
165
+ return duration
166
+ else:
167
+ logger.warning(f"⚠️ Could not get duration: {result.stderr}")
168
+ return 0
169
+ except Exception as e:
170
+ logger.exception(f"❌ Error getting video duration: {e}")
171
+ return 0
172
+
173
+ def format_time(seconds):
174
+ """Format seconds to mm:ss"""
175
+ if seconds is None:
176
+ return "0:00"
177
+ minutes = int(seconds // 60)
178
+ secs = int(seconds % 60)
179
+ return f"{minutes}:{secs:02d}"
180
+
181
+ def get_video_info(video_file):
182
+ """Get video duration and basic info"""
183
+ if not video_file:
184
+ return "No video uploaded", 0, 0, 0
185
+
186
+ logger.info(f"📹 Processing video upload: {video_file}")
187
+
188
+ duration = get_video_duration(video_file)
189
+ if duration > 0:
190
+ minutes = int(duration // 60)
191
+ seconds = int(duration % 60)
192
+ info = f"📹 Video loaded! Duration: {minutes}:{seconds:02d} ({duration:.1f}s)"
193
+ logger.info(f"✅ {info}")
194
+ return info, duration, 0, duration
195
+ else:
196
+ info = "📹 Video loaded! (Could not determine duration)"
197
+ logger.warning(f"⚠️ {info}")
198
+ return info, 100, 0, 100
199
+
200
+ # Create the Gradio interface with custom CSS and JS
201
+ custom_css = """
202
+ .video-container video {
203
+ width: 100%;
204
+ max-height: 400px;
205
+ }
206
+ .slider-container {
207
+ margin: 10px 0;
208
+ }
209
+ """
210
+
211
+ custom_js = """
212
+ function seekVideo(slider_value, video_id) {
213
+ const video = document.querySelector('#' + video_id + ' video');
214
+ if (video && !isNaN(slider_value)) {
215
+ video.currentTime = slider_value;
216
+ }
217
+ return slider_value;
218
+ }
219
+ """
220
+
221
+ with gr.Blocks(title="Video Trimmer Tool", theme=gr.themes.Soft(), css=custom_css, js=custom_js) as demo:
222
+ gr.Markdown("""
223
+ # 🎬 Video Trimmer Demo
224
+ Upload an MP4 video, set trim points, and generate trimmed video + audio files.
225
+ """)
226
+
227
+ with gr.Row():
228
+ with gr.Column(scale=2):
229
+ # Video upload and display
230
+ video_input = gr.File(
231
+ label="📁 Upload MP4 Video",
232
+ file_types=[".mp4", ".mov", ".avi", ".mkv"],
233
+ type="filepath"
234
+ )
235
+
236
+ video_player = gr.Video(
237
+ label="🎥 Video Player",
238
+ show_label=True,
239
+ elem_id="main_video_player",
240
+ elem_classes=["video-container"]
241
+ )
242
+
243
+ video_info = gr.Textbox(
244
+ label="📊 Video Info",
245
+ interactive=False,
246
+ value="Upload a video to see information"
247
+ )
248
+
249
+ with gr.Column(scale=1):
250
+ # Trim controls
251
+ gr.Markdown("### ✂️ Trim Settings")
252
+ gr.Markdown("**🎯 Drag sliders to set trim points:**")
253
+
254
+ with gr.Group():
255
+ gr.Markdown("**🎯 Scrub to find start point:**")
256
+ start_slider = gr.Slider(
257
+ minimum=0,
258
+ maximum=100,
259
+ value=0,
260
+ step=0.1,
261
+ label="⏯️ Start Time (scrub video)",
262
+ info="Drag to seek video and set start position",
263
+ elem_classes=["slider-container"]
264
+ )
265
+
266
+ start_time_display = gr.Textbox(
267
+ label="⏯️ Start Time",
268
+ value="0:00",
269
+ interactive=False,
270
+ info="Current start time"
271
+ )
272
+
273
+ with gr.Group():
274
+ gr.Markdown("**🎯 Scrub to find end point:**")
275
+ end_slider = gr.Slider(
276
+ minimum=0,
277
+ maximum=100,
278
+ value=100,
279
+ step=0.1,
280
+ label="⏹️ End Time (scrub video)",
281
+ info="Drag to seek video and set end position",
282
+ elem_classes=["slider-container"]
283
+ )
284
+
285
+ end_time_display = gr.Textbox(
286
+ label="⏹️ End Time",
287
+ value="1:40",
288
+ interactive=False,
289
+ info="Current end time"
290
+ )
291
+
292
+ trim_btn = gr.Button(
293
+ "✂️ Trim Video",
294
+ variant="primary",
295
+ size="lg"
296
+ )
297
+
298
+ status_msg = gr.Textbox(
299
+ label="📝 Status",
300
+ interactive=False,
301
+ value="Ready to trim..."
302
+ )
303
+
304
+ # Output section
305
+ gr.Markdown("### 📤 Output Files")
306
+
307
+ with gr.Row():
308
+ with gr.Column():
309
+ output_video = gr.Video(
310
+ label="🎬 Trimmed Video",
311
+ show_label=True
312
+ )
313
+
314
+ with gr.Column():
315
+ output_audio_player = gr.Audio(
316
+ label="🎵 Play Extracted Audio",
317
+ show_label=True,
318
+ type="filepath"
319
+ )
320
+
321
+ output_audio_download = gr.File(
322
+ label="💾 Download Audio (AAC)",
323
+ show_label=True
324
+ )
325
+
326
+ # Event handlers
327
+ def update_video_and_sliders(video_file):
328
+ info, duration, start_val, end_val = get_video_info(video_file)
329
+ return (
330
+ video_file, # video_player
331
+ info, # video_info
332
+ gr.Slider(minimum=0, maximum=duration, value=0, step=0.1), # start_slider
333
+ gr.Slider(minimum=0, maximum=duration, value=duration, step=0.1), # end_slider
334
+ "0:00", # start_time_display
335
+ format_time(duration) # end_time_display
336
+ )
337
+
338
+ def update_start_display(start_val):
339
+ return format_time(start_val)
340
+
341
+ def update_end_display(end_val):
342
+ return format_time(end_val)
343
+
344
+ video_input.change(
345
+ fn=update_video_and_sliders,
346
+ inputs=[video_input],
347
+ outputs=[video_player, video_info, start_slider, end_slider, start_time_display, end_time_display]
348
+ )
349
+
350
+ start_slider.change(
351
+ fn=update_start_display,
352
+ inputs=[start_slider],
353
+ outputs=[start_time_display],
354
+ js="(value) => { const video = document.querySelector('#main_video_player video'); if (video && !isNaN(value)) { video.currentTime = value; } return value; }"
355
+ )
356
+
357
+ end_slider.change(
358
+ fn=update_end_display,
359
+ inputs=[end_slider],
360
+ outputs=[end_time_display],
361
+ js="(value) => { const video = document.querySelector('#main_video_player video'); if (video && !isNaN(value)) { video.currentTime = value; } return value; }"
362
+ )
363
+
364
+ trim_btn.click(
365
+ fn=process_video_trim,
366
+ inputs=[video_input, start_slider, end_slider],
367
+ outputs=[output_video, output_audio_player, output_audio_download, status_msg]
368
+ )
369
+
370
+ if __name__ == "__main__":
371
+ demo.launch(
372
+ server_name="0.0.0.0",
373
+ server_port=None, # Auto-find available port
374
+ share=False,
375
+ show_error=True,
376
+ debug=True
377
+ )