Skip to content

Commit 8248cb4

Browse files
authored
Merge pull request #28 from ClipABit/feature/av1
Av1 codec support
2 parents 66306ee + 0ece4f2 commit 8248cb4

File tree

7 files changed

+331
-31
lines changed

7 files changed

+331
-31
lines changed

.github/workflows/ci.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,9 @@ jobs:
3131
enable-cache: true
3232
cache-suffix: "backend"
3333

34+
- name: install ffmpeg
35+
run: sudo apt-get update && sudo apt-get install -y ffmpeg
36+
3437
- name: install dependencies
3538
run: uv sync --frozen
3639

backend/main.py

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,6 @@
33
from fastapi import UploadFile, HTTPException, Form
44
import modal
55

6-
# Pinecone index names per environment
7-
PINECONE_INDEX_MAP = {
8-
"dev": "chunks-index",
9-
"prod": "prod-chunks"
10-
}
11-
126
# Configure logging
137
logging.basicConfig(
148
level=logging.INFO,
@@ -20,6 +14,7 @@
2014
# dependencies found in pyproject.toml
2115
image = (
2216
modal.Image.debian_slim(python_version="3.12")
17+
.apt_install("ffmpeg", "libsm6", "libxext6") # for video processing
2318
.uv_sync(extra_options="--no-dev") # exclude dev dependencies to avoid package conflicts
2419
.add_local_python_source( # add all local modules here
2520
"preprocessing",
@@ -91,7 +86,7 @@ def startup(self):
9186
logger.info(f"Running in environment: {ENVIRONMENT}")
9287

9388
# Select Pinecone index based on environment
94-
pinecone_index = PINECONE_INDEX_MAP[ENVIRONMENT]
89+
pinecone_index = f"{ENVIRONMENT}-chunks"
9590
logger.info(f"Using Pinecone index: {pinecone_index}")
9691

9792
# Instantiate classes

backend/preprocessing/preprocessor.py

Lines changed: 130 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import logging
22
import tempfile
3+
import subprocess
4+
import os
35
from typing import List, Dict, Any, Optional
46
from concurrent.futures import ThreadPoolExecutor, as_completed
57
import cv2
@@ -97,6 +99,81 @@ def _get_video_metadata(self, video_path: str) -> Dict[str, Any]:
9799
logger.debug("Cached metadata: fps=%.2f, frames=%d, duration=%.2fs", fps, frame_count, duration)
98100
return metadata
99101

102+
def _get_video_codec(self, video_path: str) -> str:
103+
"""Get the video codec using ffprobe."""
104+
try:
105+
cmd = [
106+
"ffprobe",
107+
"-v", "error",
108+
"-select_streams", "v:0",
109+
"-show_entries", "stream=codec_name",
110+
"-of", "default=noprint_wrappers=1:nokey=1",
111+
video_path
112+
]
113+
codec = subprocess.check_output(cmd).decode().strip()
114+
return codec
115+
except Exception as e:
116+
logger.warning(f"Failed to detect codec for {video_path}: {e}")
117+
return "unknown"
118+
119+
def _transcode_to_h264(self, input_path: str, codec: str) -> str:
120+
"""
121+
Transcode video to H.264 codec using ffmpeg.
122+
123+
Arguments:
124+
input_path: Path to the input video file.
125+
codec: Detected codec of the input video.
126+
127+
Returns:
128+
Path to the transcoded video file.
129+
"""
130+
if codec == "h264":
131+
return input_path # No transcoding needed
132+
133+
transcoded_temp = tempfile.NamedTemporaryFile(suffix='.mp4', delete=False)
134+
transcoded_path = transcoded_temp.name
135+
transcoded_temp.close() # Close so ffmpeg can write to it
136+
137+
logger.info(f"Transcoding video ({codec} -> h264) to ensure compatibility: {input_path} -> {transcoded_path}")
138+
139+
# FFmpeg command: -y (overwrite), -i (input), -c:v libx264 (video codec), -c:a aac (audio), -preset fast
140+
cmd = [
141+
"ffmpeg", "-y", "-v", "error",
142+
"-i", input_path,
143+
"-c:v", "libx264",
144+
"-preset", "fast",
145+
"-c:a", "aac",
146+
transcoded_path
147+
]
148+
149+
try:
150+
completed_process = subprocess.run(
151+
cmd,
152+
check=True,
153+
capture_output=True,
154+
text=True,
155+
)
156+
# Log any non-fatal stderr output for debugging at a lower level
157+
if completed_process.stderr:
158+
logger.debug(
159+
"ffmpeg stderr output during successful transcoding for %s: %s",
160+
input_path,
161+
completed_process.stderr,
162+
)
163+
return transcoded_path
164+
except subprocess.CalledProcessError as e:
165+
logger.error(
166+
"ffmpeg transcoding failed for %s with return code %s. stderr: %s",
167+
input_path,
168+
e.returncode,
169+
e.stderr,
170+
)
171+
# Cleanup partial file if transcoding fails
172+
if os.path.exists(transcoded_path):
173+
os.unlink(transcoded_path)
174+
raise
175+
176+
100177
def process_video_from_bytes(
101178
self,
102179
video_bytes: bytes,
@@ -107,20 +184,59 @@ def process_video_from_bytes(
107184
"""Process video from uploaded bytes with automatic temp file cleanup."""
108185
logger.info("Starting preprocessing: video_id=%s, filename=%s", video_id, filename)
109186

110-
with tempfile.NamedTemporaryFile(suffix='.mp4', delete=True) as temp_file:
111-
temp_file.write(video_bytes)
112-
temp_file.flush()
187+
input_path = None
188+
processing_path = None
113189

114-
try:
115-
return self.process_video(
116-
video_path=temp_file.name,
117-
video_id=video_id,
118-
filename=filename,
119-
hashed_identifier=hashed_identifier
120-
)
121-
except Exception as e:
122-
logger.error("Processing failed for video_id=%s: %s", video_id, e)
123-
raise
190+
try:
191+
# Create a temp file for the original upload
192+
with tempfile.NamedTemporaryFile(suffix='.mp4', delete=False) as input_temp:
193+
input_path = input_temp.name
194+
input_temp.write(video_bytes)
195+
input_temp.flush()
196+
197+
processing_path = input_path
198+
199+
# Detect codec
200+
codec = self._get_video_codec(input_path)
201+
logger.info(f"Detected video codec: {codec}")
202+
203+
if codec != "h264":
204+
processing_path = self._transcode_to_h264(input_path, codec)
205+
else:
206+
logger.info("Video is already H.264, skipping transcoding")
207+
208+
return self.process_video(
209+
video_path=processing_path,
210+
video_id=video_id,
211+
filename=filename,
212+
hashed_identifier=hashed_identifier
213+
)
214+
215+
except subprocess.CalledProcessError as e:
216+
stderr_output = ""
217+
if getattr(e, "stderr", None):
218+
if isinstance(e.stderr, bytes):
219+
stderr_output = e.stderr.decode("utf-8", errors="replace")
220+
else:
221+
stderr_output = str(e.stderr)
222+
if stderr_output:
223+
logger.error("FFmpeg transcoding failed. Stderr: %s", stderr_output)
224+
error_message = f"Failed to process video codec. FFmpeg stderr: {stderr_output}"
225+
else:
226+
logger.error("FFmpeg transcoding failed: %s", e)
227+
error_message = "Failed to process video codec"
228+
raise RuntimeError(error_message) from e
229+
except Exception as e:
230+
logger.error("Processing failed for video_id=%s: %s", video_id, e)
231+
raise
232+
finally:
233+
# Cleanup all temp files
234+
if input_path and os.path.exists(input_path):
235+
os.unlink(input_path)
236+
237+
# If processing_path is different (transcoded), clean it up too
238+
if processing_path and processing_path != input_path and os.path.exists(processing_path):
239+
os.unlink(processing_path)
124240

125241
def process_video(
126242
self,
@@ -198,7 +314,7 @@ def _process_single_chunk(
198314
Thread-safe for parallel execution.
199315
Returns None if processing fails.
200316
"""
201-
#TODO: Specifcy explicit return type and not just a dict in docstring
317+
#TODO: Specify explicit return type and not just a dict in docstring
202318
try:
203319
# Extract frames with complexity analysis
204320
frames, sampling_fps, complexity_score = self.extractor.extract_frames(video_path, chunk)

backend/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ dev = [
3737
]
3838

3939
[tool.pytest.ini_options]
40-
testpaths = ["tests"]
40+
testpaths = ["tests/unit", "tests/integration"]
4141
pythonpath = ["."]
4242
python_files = ["test_*.py"]
4343
markers = [

backend/tests/conftest.py

Lines changed: 79 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from pathlib import Path
1818
import tempfile
1919
import shutil
20+
import subprocess
2021

2122
from preprocessing.chunker import Chunker
2223
from preprocessing.frame_extractor import FrameExtractor
@@ -49,7 +50,8 @@ def sample_video_5s(tmp_path_factory) -> Path:
4950

5051
video_dir = tmp_path_factory.mktemp("test_videos")
5152
video_path = video_dir / "sample_5s.mp4"
52-
53+
54+
# Create a simple video using OpenCV
5355
fps, duration = 30, 5
5456
width, height = 640, 480
5557

@@ -71,6 +73,82 @@ def sample_video_5s(tmp_path_factory) -> Path:
7173
return video_path
7274

7375

76+
@pytest.fixture(scope="session")
77+
def sample_video_h264(tmp_path_factory) -> Path:
78+
"""1-second H.264 test video generated with ffmpeg."""
79+
video_dir = tmp_path_factory.mktemp("test_videos_h264")
80+
video_path = video_dir / "sample_h264.mp4"
81+
82+
cmd = [
83+
"ffmpeg", "-y",
84+
"-f", "lavfi", "-i", "testsrc=duration=1:size=320x240:rate=30",
85+
"-c:v", "libx264",
86+
"-preset", "fast",
87+
str(video_path)
88+
]
89+
90+
try:
91+
subprocess.run(cmd, check=True, capture_output=True)
92+
except subprocess.CalledProcessError as e:
93+
pytest.skip(f"Failed to generate H.264 video: {e.stderr.decode()}")
94+
except FileNotFoundError:
95+
pytest.skip("ffmpeg not found, skipping H.264 test")
96+
97+
return video_path
98+
99+
100+
@pytest.fixture(scope="session")
101+
def sample_video_vp9(tmp_path_factory) -> Path:
102+
"""1-second VP9 test video generated with ffmpeg."""
103+
video_dir = tmp_path_factory.mktemp("test_videos_vp9")
104+
video_path = video_dir / "sample_vp9.mp4"
105+
106+
cmd = [
107+
"ffmpeg", "-y",
108+
"-f", "lavfi", "-i", "testsrc=duration=1:size=320x240:rate=30",
109+
"-c:v", "libvpx-vp9",
110+
"-b:v", "0", "-crf", "30",
111+
str(video_path)
112+
]
113+
114+
try:
115+
subprocess.run(cmd, check=True, capture_output=True)
116+
except subprocess.CalledProcessError as e:
117+
pytest.skip(f"Failed to generate VP9 video: {e.stderr.decode()}")
118+
except FileNotFoundError:
119+
pytest.skip("ffmpeg not found, skipping VP9 test")
120+
121+
return video_path
122+
123+
124+
@pytest.fixture(scope="session")
125+
def sample_video_av1(tmp_path_factory) -> Path:
126+
"""1-second AV1 test video generated with ffmpeg."""
127+
video_dir = tmp_path_factory.mktemp("test_videos_av1")
128+
video_path = video_dir / "sample_av1.mp4"
129+
130+
# Generate AV1 video using ffmpeg
131+
# -f lavfi -i testsrc=duration=1:size=320x240:rate=30
132+
# -c:v libsvtav1 -preset 8 -crf 50
133+
cmd = [
134+
"ffmpeg", "-y",
135+
"-f", "lavfi", "-i", "testsrc=duration=1:size=320x240:rate=30",
136+
"-c:v", "libsvtav1",
137+
"-preset", "8",
138+
"-crf", "50",
139+
str(video_path)
140+
]
141+
142+
try:
143+
subprocess.run(cmd, check=True, capture_output=True)
144+
except subprocess.CalledProcessError as e:
145+
pytest.skip(f"Failed to generate AV1 video (ffmpeg might not support libsvtav1): {e.stderr.decode()}")
146+
except FileNotFoundError:
147+
pytest.skip("ffmpeg not found, skipping AV1 test")
148+
149+
return video_path
150+
151+
74152
@pytest.fixture(scope="session")
75153
def sample_video_static(tmp_path_factory) -> Path:
76154
"""10-second static video with minimal motion."""

0 commit comments

Comments
 (0)