django and fastapi apps

This commit is contained in:
2026-02-03 12:20:40 -03:00
parent d31a3ed612
commit 67573713bd
54 changed files with 3272 additions and 11 deletions

13
core/ffmpeg/__init__.py Normal file
View File

@@ -0,0 +1,13 @@
from .capabilities import get_decoders, get_encoders, get_formats
from .probe import ProbeResult, probe_file
from .transcode import TranscodeConfig, transcode
__all__ = [
"probe_file",
"ProbeResult",
"transcode",
"TranscodeConfig",
"get_encoders",
"get_decoders",
"get_formats",
]

145
core/ffmpeg/capabilities.py Normal file
View File

@@ -0,0 +1,145 @@
"""
FFmpeg capabilities - Discover available codecs and formats using ffmpeg-python.
"""
from dataclasses import dataclass
from functools import lru_cache
from typing import Any, Dict, List
import ffmpeg
@dataclass
class Codec:
"""An FFmpeg encoder or decoder."""
name: str
description: str
type: str # 'video' or 'audio'
@dataclass
class Format:
"""An FFmpeg format (muxer/demuxer)."""
name: str
description: str
can_demux: bool
can_mux: bool
@lru_cache(maxsize=1)
def _get_ffmpeg_info() -> Dict[str, Any]:
"""Get FFmpeg capabilities info."""
# ffmpeg-python doesn't have a direct way to get codecs/formats
# but we can use probe on a dummy or parse -codecs output
# For now, return common codecs that are typically available
return {
"video_encoders": [
{"name": "libx264", "description": "H.264 / AVC"},
{"name": "libx265", "description": "H.265 / HEVC"},
{"name": "mpeg4", "description": "MPEG-4 Part 2"},
{"name": "libvpx", "description": "VP8"},
{"name": "libvpx-vp9", "description": "VP9"},
{"name": "h264_nvenc", "description": "NVIDIA NVENC H.264"},
{"name": "hevc_nvenc", "description": "NVIDIA NVENC H.265"},
{"name": "h264_vaapi", "description": "VAAPI H.264"},
{"name": "prores_ks", "description": "Apple ProRes"},
{"name": "dnxhd", "description": "Avid DNxHD/DNxHR"},
{"name": "copy", "description": "Stream copy (no encoding)"},
],
"audio_encoders": [
{"name": "aac", "description": "AAC"},
{"name": "libmp3lame", "description": "MP3"},
{"name": "libopus", "description": "Opus"},
{"name": "libvorbis", "description": "Vorbis"},
{"name": "pcm_s16le", "description": "PCM signed 16-bit little-endian"},
{"name": "flac", "description": "FLAC"},
{"name": "copy", "description": "Stream copy (no encoding)"},
],
"formats": [
{"name": "mp4", "description": "MP4", "can_demux": True, "can_mux": True},
{
"name": "mov",
"description": "QuickTime / MOV",
"can_demux": True,
"can_mux": True,
},
{
"name": "mkv",
"description": "Matroska",
"can_demux": True,
"can_mux": True,
},
{"name": "webm", "description": "WebM", "can_demux": True, "can_mux": True},
{"name": "avi", "description": "AVI", "can_demux": True, "can_mux": True},
{"name": "flv", "description": "FLV", "can_demux": True, "can_mux": True},
{
"name": "ts",
"description": "MPEG-TS",
"can_demux": True,
"can_mux": True,
},
{
"name": "mpegts",
"description": "MPEG-TS",
"can_demux": True,
"can_mux": True,
},
{"name": "hls", "description": "HLS", "can_demux": True, "can_mux": True},
],
}
def get_encoders() -> List[Codec]:
"""Get available encoders (video + audio)."""
info = _get_ffmpeg_info()
codecs = []
for c in info["video_encoders"]:
codecs.append(Codec(name=c["name"], description=c["description"], type="video"))
for c in info["audio_encoders"]:
codecs.append(Codec(name=c["name"], description=c["description"], type="audio"))
return codecs
def get_decoders() -> List[Codec]:
"""Get available decoders."""
# Most encoders can also decode
return get_encoders()
def get_formats() -> List[Format]:
"""Get available formats."""
info = _get_ffmpeg_info()
return [
Format(
name=f["name"],
description=f["description"],
can_demux=f["can_demux"],
can_mux=f["can_mux"],
)
for f in info["formats"]
]
def get_video_encoders() -> List[Codec]:
"""Get available video encoders."""
return [c for c in get_encoders() if c.type == "video"]
def get_audio_encoders() -> List[Codec]:
"""Get available audio encoders."""
return [c for c in get_encoders() if c.type == "audio"]
def get_muxers() -> List[Format]:
"""Get available output formats (muxers)."""
return [f for f in get_formats() if f.can_mux]
def get_demuxers() -> List[Format]:
"""Get available input formats (demuxers)."""
return [f for f in get_formats() if f.can_demux]

92
core/ffmpeg/probe.py Normal file
View File

@@ -0,0 +1,92 @@
"""
FFmpeg probe module - Extract metadata from media files using ffprobe.
"""
from dataclasses import dataclass
from typing import Any, Dict, Optional
import ffmpeg
@dataclass
class ProbeResult:
"""Structured ffprobe result."""
duration: Optional[float]
file_size: int
# Video
video_codec: Optional[str]
width: Optional[int]
height: Optional[int]
framerate: Optional[float]
video_bitrate: Optional[int]
# Audio
audio_codec: Optional[str]
audio_channels: Optional[int]
audio_samplerate: Optional[int]
audio_bitrate: Optional[int]
# Raw data
raw: Dict[str, Any]
def probe_file(file_path: str) -> ProbeResult:
"""
Run ffprobe and return structured result.
Args:
file_path: Path to the media file
Returns:
ProbeResult with extracted metadata
Raises:
ffmpeg.Error: If ffprobe fails
"""
data = ffmpeg.probe(file_path)
# Extract video stream info
video_stream = next(
(s for s in data.get("streams", []) if s.get("codec_type") == "video"), {}
)
# Extract audio stream info
audio_stream = next(
(s for s in data.get("streams", []) if s.get("codec_type") == "audio"), {}
)
format_info = data.get("format", {})
# Parse framerate (e.g., "30000/1001" -> 29.97)
framerate = None
if "r_frame_rate" in video_stream:
try:
num, den = video_stream["r_frame_rate"].split("/")
framerate = float(num) / float(den)
except (ValueError, ZeroDivisionError):
pass
# Parse duration
duration = None
if "duration" in format_info:
try:
duration = float(format_info["duration"])
except ValueError:
pass
return ProbeResult(
duration=duration,
file_size=int(format_info.get("size", 0)),
video_codec=video_stream.get("codec_name"),
width=video_stream.get("width"),
height=video_stream.get("height"),
framerate=framerate,
video_bitrate=int(video_stream.get("bit_rate", 0)) or None,
audio_codec=audio_stream.get("codec_name"),
audio_channels=audio_stream.get("channels"),
audio_samplerate=int(audio_stream.get("sample_rate", 0)) or None,
audio_bitrate=int(audio_stream.get("bit_rate", 0)) or None,
raw=data,
)

225
core/ffmpeg/transcode.py Normal file
View File

@@ -0,0 +1,225 @@
"""
FFmpeg transcode module - Transcode media files using ffmpeg-python.
"""
import sys
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional
import ffmpeg
@dataclass
class TranscodeConfig:
"""Configuration for a transcode operation."""
input_path: str
output_path: str
# Video
video_codec: str = "libx264"
video_bitrate: Optional[str] = None
video_crf: Optional[int] = None
video_preset: Optional[str] = None
resolution: Optional[str] = None
framerate: Optional[float] = None
# Audio
audio_codec: str = "aac"
audio_bitrate: Optional[str] = None
audio_channels: Optional[int] = None
audio_samplerate: Optional[int] = None
# Trimming
trim_start: Optional[float] = None
trim_end: Optional[float] = None
# Container
container: str = "mp4"
# Extra args (key-value pairs)
extra_args: List[str] = field(default_factory=list)
@property
def is_copy(self) -> bool:
"""Check if this is a stream copy (no transcoding)."""
return self.video_codec == "copy" and self.audio_codec == "copy"
def build_stream(config: TranscodeConfig):
"""
Build an ffmpeg-python stream from config.
Returns the stream object ready to run.
"""
# Input options
input_kwargs = {}
if config.trim_start is not None:
input_kwargs["ss"] = config.trim_start
stream = ffmpeg.input(config.input_path, **input_kwargs)
# Output options
output_kwargs = {
"vcodec": config.video_codec,
"acodec": config.audio_codec,
}
# Trimming duration
if config.trim_end is not None:
if config.trim_start is not None:
output_kwargs["t"] = config.trim_end - config.trim_start
else:
output_kwargs["t"] = config.trim_end
# Video options (skip if copy)
if config.video_codec != "copy":
if config.video_crf is not None:
output_kwargs["crf"] = config.video_crf
elif config.video_bitrate:
output_kwargs["video_bitrate"] = config.video_bitrate
if config.video_preset:
output_kwargs["preset"] = config.video_preset
if config.resolution:
output_kwargs["s"] = config.resolution
if config.framerate:
output_kwargs["r"] = config.framerate
# Audio options (skip if copy)
if config.audio_codec != "copy":
if config.audio_bitrate:
output_kwargs["audio_bitrate"] = config.audio_bitrate
if config.audio_channels:
output_kwargs["ac"] = config.audio_channels
if config.audio_samplerate:
output_kwargs["ar"] = config.audio_samplerate
# Parse extra args into kwargs
extra_kwargs = parse_extra_args(config.extra_args)
output_kwargs.update(extra_kwargs)
stream = ffmpeg.output(stream, config.output_path, **output_kwargs)
stream = ffmpeg.overwrite_output(stream)
return stream
def parse_extra_args(extra_args: List[str]) -> Dict[str, Any]:
"""
Parse extra args list into kwargs dict.
["-vtag", "xvid", "-pix_fmt", "yuv420p"] -> {"vtag": "xvid", "pix_fmt": "yuv420p"}
"""
kwargs = {}
i = 0
while i < len(extra_args):
key = extra_args[i].lstrip("-")
if i + 1 < len(extra_args) and not extra_args[i + 1].startswith("-"):
kwargs[key] = extra_args[i + 1]
i += 2
else:
# Flag without value
kwargs[key] = None
i += 1
return kwargs
def transcode(
config: TranscodeConfig,
duration: Optional[float] = None,
progress_callback: Optional[Callable[[float, Dict[str, Any]], None]] = None,
) -> bool:
"""
Transcode a media file.
Args:
config: Transcode configuration
duration: Total duration in seconds (for progress calculation, optional)
progress_callback: Called with (percent, details_dict) - requires duration
Returns:
True if successful
Raises:
ffmpeg.Error: If transcoding fails
"""
# Ensure output directory exists
Path(config.output_path).parent.mkdir(parents=True, exist_ok=True)
stream = build_stream(config)
if progress_callback and duration:
# Run with progress tracking using run_async
return _run_with_progress(stream, config, duration, progress_callback)
else:
# Run synchronously
ffmpeg.run(stream, capture_stdout=True, capture_stderr=True)
return True
def _run_with_progress(
stream,
config: TranscodeConfig,
duration: float,
progress_callback: Callable[[float, Dict[str, Any]], None],
) -> bool:
"""Run FFmpeg with progress tracking using run_async and stderr parsing."""
import re
# Calculate effective duration
effective_duration = duration
if config.trim_start and config.trim_end:
effective_duration = config.trim_end - config.trim_start
elif config.trim_end:
effective_duration = config.trim_end
elif config.trim_start:
effective_duration = duration - config.trim_start
# Run async to get process handle
process = ffmpeg.run_async(stream, pipe_stdout=True, pipe_stderr=True)
# Parse stderr for progress (time=HH:MM:SS.ms pattern)
time_pattern = re.compile(r"time=(\d+):(\d+):(\d+)\.(\d+)")
while True:
line = process.stderr.readline()
if not line:
break
line = line.decode("utf-8", errors="ignore")
match = time_pattern.search(line)
if match:
hours = int(match.group(1))
minutes = int(match.group(2))
seconds = int(match.group(3))
ms = int(match.group(4))
current_time = hours * 3600 + minutes * 60 + seconds + ms / 100
percent = min(100.0, (current_time / effective_duration) * 100)
progress_callback(
percent,
{
"time": current_time,
"percent": percent,
},
)
# Wait for completion
process.wait()
if process.returncode != 0:
raise ffmpeg.Error(
"ffmpeg", stdout=process.stdout.read(), stderr=process.stderr.read()
)
# Final callback
progress_callback(
100.0, {"time": effective_duration, "percent": 100.0, "done": True}
)
return True