Skip to content

Commit bcadff6

Browse files
Stephen G. PopeStephen G. Pope
authored andcommitted
added split
1 parent 5022f43 commit bcadff6

3 files changed

Lines changed: 298 additions & 0 deletions

File tree

app.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,7 @@ def wrapper(*args, **kwargs):
169169
from routes.v1.audio.concatenate import v1_audio_concatenate_bp
170170
from routes.v1.media.silence import v1_media_silence_bp
171171
from routes.v1.video.cut import v1_video_cut_bp
172+
from routes.v1.video.split import v1_video_split_bp
172173

173174
app.register_blueprint(v1_ffmpeg_compose_bp)
174175
app.register_blueprint(v1_media_transcribe_bp)
@@ -192,6 +193,7 @@ def wrapper(*args, **kwargs):
192193
app.register_blueprint(v1_audio_concatenate_bp)
193194
app.register_blueprint(v1_media_silence_bp)
194195
app.register_blueprint(v1_video_cut_bp)
196+
app.register_blueprint(v1_video_split_bp)
195197

196198
return app
197199

routes/v1/video/split.py

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
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+
from flask import Blueprint
20+
from app_utils import *
21+
import logging
22+
from services.v1.video.split import split_video
23+
from services.authentication import authenticate
24+
25+
v1_video_split_bp = Blueprint('v1_video_split', __name__)
26+
logger = logging.getLogger(__name__)
27+
28+
@v1_video_split_bp.route('/v1/video/split', methods=['POST'])
29+
@authenticate
30+
@validate_payload({
31+
"type": "object",
32+
"properties": {
33+
"video_url": {"type": "string", "format": "uri"},
34+
"splits": {
35+
"type": "array",
36+
"items": {
37+
"type": "object",
38+
"properties": {
39+
"start": {"type": "string"},
40+
"end": {"type": "string"}
41+
},
42+
"required": ["start", "end"],
43+
"additionalProperties": False
44+
},
45+
"minItems": 1
46+
},
47+
"video_codec": {"type": "string"},
48+
"video_preset": {"type": "string"},
49+
"video_crf": {"type": "number", "minimum": 0, "maximum": 51},
50+
"audio_codec": {"type": "string"},
51+
"audio_bitrate": {"type": "string"},
52+
"webhook_url": {"type": "string", "format": "uri"},
53+
"id": {"type": "string"}
54+
},
55+
"required": ["video_url", "splits"],
56+
"additionalProperties": False
57+
})
58+
@queue_task_wrapper(bypass_queue=False)
59+
def video_split(job_id, data):
60+
"""Split a video file into multiple segments with optional encoding settings."""
61+
video_url = data['video_url']
62+
splits = data['splits']
63+
64+
# Extract encoding settings with defaults
65+
video_codec = data.get('video_codec', 'libx264')
66+
video_preset = data.get('video_preset', 'medium')
67+
video_crf = data.get('video_crf', 23)
68+
audio_codec = data.get('audio_codec', 'aac')
69+
audio_bitrate = data.get('audio_bitrate', '128k')
70+
71+
logger.info(f"Job {job_id}: Received video split request for {video_url}")
72+
73+
try:
74+
# Process the video file and get list of output files
75+
output_files, input_filename = split_video(
76+
video_url=video_url,
77+
splits=splits,
78+
job_id=job_id,
79+
video_codec=video_codec,
80+
video_preset=video_preset,
81+
video_crf=video_crf,
82+
audio_codec=audio_codec,
83+
audio_bitrate=audio_bitrate
84+
)
85+
86+
# Upload all output files to cloud storage
87+
from services.cloud_storage import upload_file
88+
result_files = []
89+
90+
for i, output_file in enumerate(output_files):
91+
cloud_url = upload_file(output_file)
92+
result_files.append({
93+
"file_url": cloud_url,
94+
"start": splits[i]["start"],
95+
"end": splits[i]["end"]
96+
})
97+
# Remove the local file after upload
98+
import os
99+
os.remove(output_file)
100+
logger.info(f"Job {job_id}: Uploaded and removed split file {i+1}")
101+
102+
# Clean up input file
103+
import os
104+
os.remove(input_filename)
105+
logger.info(f"Job {job_id}: Removed input file")
106+
107+
# Prepare the response with only file URLs
108+
response = [{"file_url": item["file_url"]} for item in result_files]
109+
110+
logger.info(f"Job {job_id}: Video split operation completed successfully")
111+
return response, "/v1/video/split", 200
112+
113+
except Exception as e:
114+
logger.error(f"Job {job_id}: Error during video split process - {str(e)}")
115+
return str(e), "/v1/video/split", 500

services/v1/video/split.py

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
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

Comments
 (0)