django and fastapi apps
This commit is contained in:
13
core/ffmpeg/__init__.py
Normal file
13
core/ffmpeg/__init__.py
Normal 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
145
core/ffmpeg/capabilities.py
Normal 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
92
core/ffmpeg/probe.py
Normal 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
225
core/ffmpeg/transcode.py
Normal 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
|
||||
Reference in New Issue
Block a user