1+ # Copyright (c) 2025 Stephen G. Pope
2+ #
3+ # This program is free software; you can redistribute it and/or modify
4+ # it under the terms of the GNU General Public License as published by
5+ # the Free Software Foundation; either version 2 of the License, or
6+ # (at your option) any later version.
7+ #
8+ # This program is distributed in the hope that it will be useful,
9+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
10+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11+ # GNU General Public License for more details.
12+ #
13+ # You should have received a copy of the GNU General Public License along
14+ # with this program; if not, write to the Free Software Foundation, Inc.,
15+ # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
16+
17+
18+
19+ import os
20+ import json
21+ import subprocess
22+ import logging
23+ import uuid
24+ from services .file_management import download_file
25+ from services .cloud_storage import upload_file
26+ from config import LOCAL_STORAGE_PATH
27+
28+ # Set up logging
29+ logger = logging .getLogger (__name__ )
30+ logging .basicConfig (level = logging .INFO )
31+
32+ def time_to_seconds (time_str ):
33+ """
34+ Convert a time string in format HH:MM:SS[.mmm] to seconds.
35+
36+ Args:
37+ time_str (str): Time string
38+
39+ Returns:
40+ float: Time in seconds
41+ """
42+ try :
43+ parts = time_str .split (':' )
44+ if len (parts ) == 3 :
45+ hours , minutes , seconds = parts
46+ return int (hours ) * 3600 + int (minutes ) * 60 + float (seconds )
47+ elif len (parts ) == 2 :
48+ minutes , seconds = parts
49+ return int (minutes ) * 60 + float (seconds )
50+ else :
51+ return float (time_str )
52+ except ValueError :
53+ raise ValueError (f"Invalid time format: { time_str } . Expected HH:MM:SS[.mmm]" )
54+
55+ def split_video (video_url , splits , job_id = None , video_codec = 'libx264' , video_preset = 'medium' ,
56+ video_crf = 23 , audio_codec = 'aac' , audio_bitrate = '128k' ):
57+ """
58+ Splits a video file into multiple segments with customizable encoding settings.
59+
60+ Args:
61+ video_url (str): URL of the video file to split
62+ splits (list): List of dictionaries with 'start' and 'end' timestamps
63+ job_id (str, optional): Unique job identifier
64+ video_codec (str, optional): Video codec to use for encoding (default: 'libx264')
65+ video_preset (str, optional): Encoding preset for speed/quality tradeoff (default: 'medium')
66+ video_crf (int, optional): Constant Rate Factor for quality (0-51, default: 23)
67+ audio_codec (str, optional): Audio codec to use for encoding (default: 'aac')
68+ audio_bitrate (str, optional): Audio bitrate (default: '128k')
69+
70+ Returns:
71+ tuple: (list of output file paths, input file path)
72+ """
73+ logger .info (f"Starting video split operation for { video_url } " )
74+ if not job_id :
75+ job_id = str (uuid .uuid4 ())
76+
77+ input_filename = download_file (video_url , os .path .join (LOCAL_STORAGE_PATH , f"{ job_id } _input" ))
78+ logger .info (f"Downloaded video to local file: { input_filename } " )
79+
80+ output_files = []
81+
82+ try :
83+ # Get the file extension
84+ _ , ext = os .path .splitext (input_filename )
85+
86+ # Get the duration of the input file
87+ probe_cmd = [
88+ 'ffprobe' ,
89+ '-v' , 'error' ,
90+ '-show_entries' , 'format=duration' ,
91+ '-of' , 'default=noprint_wrappers=1:nokey=1' ,
92+ input_filename
93+ ]
94+ duration_result = subprocess .run (probe_cmd , capture_output = True , text = True )
95+
96+ try :
97+ file_duration = float (duration_result .stdout .strip ())
98+ logger .info (f"File duration: { file_duration } seconds" )
99+ except (ValueError , IndexError ):
100+ logger .warning ("Could not determine file duration, using a large value" )
101+ file_duration = 86400 # 24 hours as a fallback
102+
103+ # Validate and process splits
104+ valid_splits = []
105+ for i , split in enumerate (splits ):
106+ try :
107+ start_seconds = time_to_seconds (split ['start' ])
108+ end_seconds = time_to_seconds (split ['end' ])
109+
110+ # Validate split times
111+ if start_seconds >= end_seconds :
112+ logger .warning (f"Invalid split { i + 1 } : start time ({ split ['start' ]} ) must be before end time ({ split ['end' ]} ). Skipping." )
113+ continue
114+
115+ if start_seconds < 0 :
116+ logger .warning (f"Split { i + 1 } start time { split ['start' ]} is negative, using 0 instead" )
117+ start_seconds = 0
118+
119+ if end_seconds > file_duration :
120+ logger .warning (f"Split { i + 1 } end time { split ['end' ]} exceeds file duration, using file duration instead" )
121+ end_seconds = file_duration
122+
123+ # Only add valid splits
124+ if start_seconds < end_seconds :
125+ valid_splits .append ((i , start_seconds , end_seconds , split ))
126+ except ValueError as e :
127+ logger .warning (f"Error processing split { i + 1 } : { str (e )} . Skipping." )
128+
129+ if not valid_splits :
130+ raise ValueError ("No valid split segments specified" )
131+
132+ logger .info (f"Processing { len (valid_splits )} valid splits" )
133+
134+ # Process each split
135+ for index , (split_index , start_seconds , end_seconds , split_data ) in enumerate (valid_splits ):
136+ # Create output filename for this split
137+ output_filename = os .path .join (LOCAL_STORAGE_PATH , f"{ job_id } _split_{ index + 1 } { ext } " )
138+
139+ # Create FFmpeg command to extract the segment
140+ cmd = [
141+ 'ffmpeg' ,
142+ '-i' , input_filename ,
143+ '-ss' , str (start_seconds ),
144+ '-to' , str (end_seconds ),
145+ '-c:v' , video_codec ,
146+ '-preset' , video_preset ,
147+ '-crf' , str (video_crf ),
148+ '-c:a' , audio_codec ,
149+ '-b:a' , audio_bitrate ,
150+ '-avoid_negative_ts' , 'make_zero' ,
151+ output_filename
152+ ]
153+
154+ logger .info (f"Running FFmpeg command for split { index + 1 } : { ' ' .join (cmd )} " )
155+
156+ # Run the FFmpeg command
157+ process = subprocess .run (cmd , capture_output = True , text = True )
158+
159+ if process .returncode != 0 :
160+ logger .error (f"Error processing split { index + 1 } : { process .stderr } " )
161+ raise Exception (f"FFmpeg error for split { index + 1 } : { process .stderr } " )
162+
163+ # Add the output file to the list
164+ output_files .append (output_filename )
165+ logger .info (f"Successfully created split { index + 1 } : { output_filename } " )
166+
167+ # Return the list of output files and the input filename
168+ return output_files , input_filename
169+
170+ except Exception as e :
171+ logger .error (f"Video split operation failed: { str (e )} " )
172+
173+ # Clean up all temporary files if they exist
174+ if 'input_filename' in locals () and os .path .exists (input_filename ):
175+ os .remove (input_filename )
176+
177+ for output_file in output_files :
178+ if os .path .exists (output_file ):
179+ os .remove (output_file )
180+
181+ raise
0 commit comments