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

Video Compression and Storage Service for DetectifAI



This module handles video compression and MinIO storage for compressed videos.

"""

import os
import cv2
import subprocess
import logging
from io import BytesIO
from typing import Dict, Optional
from datetime import timedelta
from minio.error import S3Error

logger = logging.getLogger(__name__)

class VideoCompressionService:
    """Service for compressing videos and storing in S3-compatible storage"""

    def __init__(self, db_manager, config=None):
        self._db_manager = db_manager
        self.bucket = db_manager.config.minio_video_bucket  # Store compressed videos in the videos bucket
        self.config = config

        # Default compression settings
        self.output_resolution = "720p"  # 720p for web delivery
        self.compression_crf = 23  # 0-51, lower = better quality (23 is default)
        self.compression_preset = "medium"  # ultrafast to veryslow

        # Check if FFmpeg is available
        self.ffmpeg_available = self._check_ffmpeg_available()

    @property
    def minio(self):
        """Lazy access to S3 storage β€” tolerates unavailable storage"""
        return self._db_manager.minio_client

    def _check_ffmpeg_available(self) -> bool:
        """Check if FFmpeg is available on the system"""
        try:
            result = subprocess.run(
                ['ffmpeg', '-version'],
                capture_output=True,
                text=True,
                timeout=5
            )
            return result.returncode == 0
        except (subprocess.TimeoutExpired, FileNotFoundError, subprocess.SubprocessError):
            return False

    def compress_and_store(self, input_path: str, video_id: str) -> Optional[Dict]:
        """Compress video and store in MinIO and locally"""
        try:
            # Create local storage directory
            local_dir = os.path.join("video_processing_outputs", "compressed", video_id)
            os.makedirs(local_dir, exist_ok=True)
            local_path = os.path.join(local_dir, "video.mp4")

            # Use BytesIO for in-memory compression
            from io import BytesIO
            compressed_buffer = BytesIO()

            # Try FFmpeg first if available, otherwise use OpenCV
            if self.ffmpeg_available:
                success = self._compress_with_ffmpeg_to_buffer(input_path, compressed_buffer)
                if not success:
                    logger.warning("FFmpeg compression failed, falling back to OpenCV")
                    compressed_buffer.seek(0)  # Reset buffer position
                    success = self._compress_with_opencv_to_buffer(input_path, compressed_buffer)
            else:
                logger.info("FFmpeg not available, using OpenCV compression")
                success = self._compress_with_opencv_to_buffer(input_path, compressed_buffer)

            if not success:
                logger.error("Both compression methods failed")
                return None

            # Get buffer contents
            compressed_buffer.seek(0)
            compressed_data = compressed_buffer.getvalue()
            compressed_size = len(compressed_data)

            # Save locally
            with open(local_path, 'wb') as f:
                f.write(compressed_data)
            logger.info(f"βœ… Video saved locally: {local_path}")

            # Calculate compression stats
            original_size = os.path.getsize(input_path)
            compression_ratio = ((original_size - compressed_size) / original_size) * 100

            # Upload directly to S3 using consistent path structure (skip if unavailable)
            minio_path = None
            if self.minio is not None:
                try:
                    minio_path = f"compressed/{video_id}/video.mp4"
                    compressed_buffer.seek(0)  # Reset buffer for S3 upload
                    self.minio.put_object(
                        self.bucket,
                        minio_path,
                        compressed_buffer,
                        length=compressed_size,
                        content_type='video/mp4'
                    )
                except Exception as s3_err:
                    logger.warning(f"⚠️ S3 upload skipped for compressed video: {s3_err}")
                    minio_path = None
            else:
                logger.info("S3 storage unavailable β€” compressed video stored locally only")

            result = {
                'success': True,
                'minio_path': minio_path,
                'local_path': local_path,
                'original_size': original_size,
                'compressed_size': compressed_size,
                'compression_ratio': round(compression_ratio, 2),
                'output_resolution': self.output_resolution
            }

            logger.info(f"βœ… Video compressed and stored: {compression_ratio:.1f}% reduction")
            return result

        except Exception as e:
            logger.error(f"❌ Compression and storage failed: {e}")
            return None

    def get_compressed_video_presigned_url(self, video_id: str, expires: timedelta = timedelta(hours=1)) -> str:
        """Generate presigned URL for compressed video access"""
        if self.minio is None:
            return None
        try:
            minio_path = f"compressed/{video_id}/video.mp4"
            return self.minio.presigned_get_object(self.bucket, minio_path, expires=expires)
        except S3Error as e:
            logger.error(f"❌ Failed to generate presigned URL for compressed video: {e}")
            return None
    
    def _compress_with_ffmpeg(self, input_path: str, output_path: str) -> bool:
        """Compress video using FFmpeg"""
        try:
            # Build FFmpeg command
            cmd = [
                'ffmpeg',
                '-i', input_path,
                '-c:v', 'libx264',  # H.264 codec
                '-crf', str(self.compression_crf),
                '-preset', self.compression_preset,
                '-movflags', '+faststart',  # Enable web playback
                '-y'  # Overwrite output file
            ]
            
            # Add resolution scaling if needed
            if self.output_resolution == "720p":
                cmd.extend(['-vf', "scale='min(1280,iw)':'min(720,ih)':force_original_aspect_ratio=decrease,pad=ceil(iw/2)*2:ceil(ih/2)*2"])
            elif self.output_resolution == "480p":
                cmd.extend(['-vf', "scale='min(854,iw)':'min(480,ih)':force_original_aspect_ratio=decrease,pad=ceil(iw/2)*2:ceil(ih/2)*2"])
            
            cmd.append(output_path)
            
            # Run FFmpeg
            result = subprocess.run(
                cmd,
                capture_output=True,
                text=True
            )
            
            if result.returncode == 0 and os.path.exists(output_path):
                logger.info("βœ… FFmpeg compression successful")
                return True
            else:
                logger.error(f"FFmpeg error: {result.stderr}")
                return False
                
        except Exception as e:
            logger.error(f"FFmpeg compression failed: {e}")
            return False
    
    def _compress_with_ffmpeg_to_buffer(self, input_path: str, output_buffer: BytesIO) -> bool:
        """Compress video using FFmpeg with temporary file (more reliable than pipe)"""
        import tempfile
        try:
            # Create temporary file for FFmpeg output
            with tempfile.NamedTemporaryFile(suffix='.mp4', delete=False) as temp_file:
                temp_path = temp_file.name
            
            # Build FFmpeg command to output to temporary file
            cmd = [
                'ffmpeg',
                '-i', input_path,
                '-c:v', 'libx264',  # H.264 codec
                '-crf', str(self.compression_crf),
                '-preset', self.compression_preset,
                '-movflags', '+faststart',  # Enable web playback (safe for file output)
                '-y'  # Overwrite output
            ]
            
            # Add resolution scaling if needed
            if self.output_resolution == "720p":
                cmd.extend(['-vf', "scale='min(1280,iw)':'min(720,ih)':force_original_aspect_ratio=decrease,pad=ceil(iw/2)*2:ceil(ih/2)*2"])
            elif self.output_resolution == "480p":
                cmd.extend(['-vf', "scale='min(854,iw)':'min(480,ih)':force_original_aspect_ratio=decrease,pad=ceil(iw/2)*2:ceil(ih/2)*2"])
            
            # Add output file
            cmd.append(temp_path)
            
            # Run FFmpeg
            result = subprocess.run(
                cmd,
                capture_output=True,
                text=True,
                timeout=300  # 5 minute timeout
            )
            
            if result.returncode == 0 and os.path.exists(temp_path):
                # Read temporary file into buffer
                with open(temp_path, 'rb') as f:
                    output_buffer.write(f.read())
                
                # Clean up temporary file
                os.unlink(temp_path)
                
                logger.info("βœ… FFmpeg compression to buffer successful")
                return True
            else:
                # Clean up temporary file on error
                if os.path.exists(temp_path):
                    os.unlink(temp_path)
                logger.error(f"FFmpeg error: {result.stderr}")
                return False
                
        except Exception as e:
            logger.error(f"FFmpeg compression to buffer failed: {e}")
            return False
    
    def _compress_with_opencv_to_buffer(self, input_path: str, output_buffer: BytesIO) -> bool:
        """Fallback compression using OpenCV directly to a buffer"""
        try:
            # Open input video
            cap = cv2.VideoCapture(input_path)
            if not cap.isOpened():
                logger.error(f"Cannot open input video: {input_path}")
                return False
            
            # Get video properties
            width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
            height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
            fps = cap.get(cv2.CAP_PROP_FPS)
            
            # Calculate new dimensions
            if self.output_resolution == "720p":
                new_height = 720
                new_width = int((width / height) * new_height)
            elif self.output_resolution == "480p":
                new_height = 480
                new_width = int((width / height) * new_height)
            else:
                new_width, new_height = width, height
            
            # Create temporary file for OpenCV (required for VideoWriter)
            import tempfile
            with tempfile.NamedTemporaryFile(suffix='.mp4', delete=False) as temp_file:
                temp_path = temp_file.name
            
            # Create video writer with best available codec
            # Prioritize H.264 (avc1) for browser compatibility
            codecs_to_try = [
                ('avc1', 'H.264'), 
                ('h264', 'H.264'), 
                ('X264', 'H.264'), 
                ('mp4v', 'MPEG-4')
            ]
            
            out = None
            used_codec = None
            
            for fourcc_code, name in codecs_to_try:
                try:
                    fourcc = cv2.VideoWriter_fourcc(*fourcc_code)
                    out = cv2.VideoWriter(temp_path, fourcc, fps, (new_width, new_height))
                    if out.isOpened():
                        used_codec = name
                        logger.info(f"βœ… Using codec: {name} ({fourcc_code})")
                        break
                    out.release()
                except Exception as e:
                    logger.debug(f"Codec {fourcc_code} failed: {e}")
                    
            if not out or not out.isOpened():
                logger.error("❌ No suitable video codec found")
                return False
            
            while True:
                ret, frame = cap.read()
                if not ret:
                    break
                
                # Resize frame if needed
                if (new_width, new_height) != (width, height):
                    frame = cv2.resize(frame, (new_width, new_height))
                
                out.write(frame)
            
            cap.release()
            out.release()
            
            # Read compressed file into buffer
            if os.path.exists(temp_path):
                with open(temp_path, 'rb') as f:
                    output_buffer.write(f.read())
                os.unlink(temp_path)  # Delete temporary file
                logger.info("βœ… OpenCV compression to buffer successful")
                return True
            else:
                logger.error("OpenCV compression failed - output file not created")
                return False
                
        except Exception as e:
            logger.error(f"OpenCV compression to buffer failed: {e}")
            return False
    
    def _compress_with_opencv(self, input_path: str, output_path: str) -> bool:
        """Fallback compression using OpenCV"""
        try:
            # Open input video
            cap = cv2.VideoCapture(input_path)
            if not cap.isOpened():
                logger.error(f"Cannot open input video: {input_path}")
                return False
            
            # Get video properties
            width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
            height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
            fps = cap.get(cv2.CAP_PROP_FPS)
            
            # Calculate new dimensions
            if self.output_resolution == "720p":
                new_height = 720
                new_width = int((width / height) * new_height)
            elif self.output_resolution == "480p":
                new_height = 480
                new_width = int((width / height) * new_height)
            else:
                new_width, new_height = width, height
            
            # Create video writer
            fourcc = cv2.VideoWriter_fourcc(*'mp4v')
            out = cv2.VideoWriter(
                output_path,
                fourcc,
                fps,
                (new_width, new_height)
            )
            
            while True:
                ret, frame = cap.read()
                if not ret:
                    break
                
                # Resize frame
                if (new_width, new_height) != (width, height):
                    frame = cv2.resize(frame, (new_width, new_height))
                
                out.write(frame)
            
            cap.release()
            out.release()
            
            if os.path.exists(output_path):
                logger.info("βœ… OpenCV compression successful")
                return True
            else:
                logger.error("OpenCV compression failed - output file not created")
                return False
                
        except Exception as e:
            logger.error(f"OpenCV compression failed: {e}")
            return False