ai did his job.
This commit is contained in:
@@ -0,0 +1,346 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
from dataclasses import dataclass
|
||||
from fractions import Fraction
|
||||
from pathlib import Path
|
||||
|
||||
MAX_VIDEO_SECONDS = 3.0
|
||||
MAX_STICKER_BYTES = 256 * 1024
|
||||
MAX_INPUT_PIXELS = 8192 * 4096
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MediaConversionError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class IncomingMedia:
|
||||
path: Path
|
||||
source_unique_id: str
|
||||
kind: str
|
||||
emoji: str = "❤️"
|
||||
content_hash: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ConvertedMedia:
|
||||
path: Path
|
||||
content_hash: str
|
||||
|
||||
|
||||
class MediaConverter:
|
||||
def __init__(
|
||||
self,
|
||||
ffmpeg: str = "ffmpeg",
|
||||
ffprobe: str = "ffprobe",
|
||||
*,
|
||||
process_timeout: float = 60,
|
||||
max_parallel: int = 2,
|
||||
) -> None:
|
||||
self.ffmpeg = ffmpeg
|
||||
self.ffprobe = ffprobe
|
||||
self.process_timeout = process_timeout
|
||||
self._semaphore = asyncio.Semaphore(max_parallel)
|
||||
|
||||
async def convert(self, media: IncomingMedia) -> ConvertedMedia:
|
||||
converted = await self.convert_path(media.path, media.kind)
|
||||
content_hash = media.content_hash or await asyncio.to_thread(
|
||||
self._sha256, media.path
|
||||
)
|
||||
return ConvertedMedia(converted.path, content_hash)
|
||||
|
||||
async def convert_path(self, source: Path, kind: str) -> ConvertedMedia:
|
||||
async with self._semaphore:
|
||||
try:
|
||||
async with asyncio.timeout(self.process_timeout):
|
||||
return await self._convert_path_unlocked(source, kind)
|
||||
except TimeoutError as error:
|
||||
raise MediaConversionError(
|
||||
"Обработка медиа превысила допустимое время."
|
||||
) from error
|
||||
|
||||
async def _convert_path_unlocked(self, source: Path, kind: str) -> ConvertedMedia:
|
||||
probe = await self._probe(source)
|
||||
if not any(
|
||||
stream.get("codec_type") == "video" for stream in probe.get("streams", [])
|
||||
):
|
||||
raise MediaConversionError("В файле не найдено изображение или видео.")
|
||||
self._validate_input(probe)
|
||||
|
||||
if kind == "auto" and self._needs_frame_count(probe):
|
||||
probe = await self._probe(source, count_frames=True)
|
||||
|
||||
resolved_kind = self._resolve_kind(kind, probe)
|
||||
if resolved_kind != "static":
|
||||
duration = self._duration(probe)
|
||||
if duration is None:
|
||||
raise MediaConversionError("Не удалось определить длительность видео.")
|
||||
if duration > MAX_VIDEO_SECONDS + 0.01:
|
||||
raise MediaConversionError("Видео должно быть не длиннее 3 секунд.")
|
||||
|
||||
fd, output_name = tempfile.mkstemp(suffix=".webm", dir=source.parent)
|
||||
os.close(fd)
|
||||
Path(output_name).unlink(missing_ok=True)
|
||||
try:
|
||||
output = Path(output_name)
|
||||
last_error = ""
|
||||
for crf in (32, 38, 44, 50, 56, 63):
|
||||
with source.open("rb") as input_file:
|
||||
input_fd = input_file.fileno()
|
||||
command = self._command(
|
||||
source, output, resolved_kind, crf, input_fd=input_fd
|
||||
)
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*command,
|
||||
stdout=asyncio.subprocess.DEVNULL,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
pass_fds=(input_fd,),
|
||||
)
|
||||
_, stderr = await self._communicate(process)
|
||||
last_error = stderr.decode(errors="replace").strip()
|
||||
if (
|
||||
process.returncode == 0
|
||||
and output.exists()
|
||||
and output.stat().st_size <= MAX_STICKER_BYTES
|
||||
):
|
||||
self._validate_output(await self._probe(output))
|
||||
return ConvertedMedia(output, self._sha256(source))
|
||||
output.unlink(missing_ok=True)
|
||||
detail = (
|
||||
last_error.splitlines()[-1]
|
||||
if last_error
|
||||
else "неизвестная ошибка ffmpeg"
|
||||
)
|
||||
logger.warning("ffmpeg rejected media: %s", detail)
|
||||
raise MediaConversionError("Не удалось преобразовать медиа в видео-стикер.")
|
||||
except BaseException:
|
||||
Path(output_name).unlink(missing_ok=True)
|
||||
raise
|
||||
|
||||
def _command(
|
||||
self,
|
||||
source: Path,
|
||||
output: Path,
|
||||
kind: str,
|
||||
crf: int,
|
||||
*,
|
||||
input_fd: int,
|
||||
) -> list[str]:
|
||||
command = [
|
||||
self.ffmpeg,
|
||||
"-y",
|
||||
"-v",
|
||||
"error",
|
||||
"-protocol_whitelist",
|
||||
"fd",
|
||||
"-fd",
|
||||
str(input_fd),
|
||||
]
|
||||
if kind == "static":
|
||||
command.extend(["-loop", "1"])
|
||||
command.extend(["-i", "fd:", "-map", "0:v:0", "-an"])
|
||||
if kind == "static":
|
||||
command.extend(["-t", "1", "-r", "15"])
|
||||
else:
|
||||
command.extend(["-t", "3", "-r", "30"])
|
||||
command.extend(
|
||||
[
|
||||
"-vf",
|
||||
"scale='if(gte(iw,ih),512,-2)':'if(gte(iw,ih),-2,512)'",
|
||||
"-c:v",
|
||||
"libvpx-vp9",
|
||||
"-pix_fmt",
|
||||
"yuva420p",
|
||||
"-b:v",
|
||||
"0",
|
||||
"-crf",
|
||||
str(crf),
|
||||
"-deadline",
|
||||
"good",
|
||||
"-cpu-used",
|
||||
"4",
|
||||
"-row-mt",
|
||||
"1",
|
||||
"-threads",
|
||||
"2",
|
||||
"-auto-alt-ref",
|
||||
"0",
|
||||
str(output),
|
||||
]
|
||||
)
|
||||
return command
|
||||
|
||||
async def _probe(self, source: Path, *, count_frames: bool = False) -> dict:
|
||||
try:
|
||||
with source.open("rb") as input_file:
|
||||
input_fd = input_file.fileno()
|
||||
command = self._probe_command(
|
||||
count_frames=count_frames, input_fd=input_fd
|
||||
)
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*command,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
pass_fds=(input_fd,),
|
||||
)
|
||||
stdout, stderr = await self._communicate(process)
|
||||
except FileNotFoundError as error:
|
||||
raise MediaConversionError(
|
||||
"ffmpeg/ffprobe не установлен на сервере."
|
||||
) from error
|
||||
if process.returncode != 0:
|
||||
detail = stderr.decode(errors="replace").strip().splitlines()
|
||||
logger.warning(
|
||||
"ffprobe rejected media: %s",
|
||||
detail[-1] if detail else "unknown format",
|
||||
)
|
||||
raise MediaConversionError("Формат медиа не поддерживается.")
|
||||
return json.loads(stdout)
|
||||
|
||||
def _probe_command(self, *, count_frames: bool, input_fd: int) -> list[str]:
|
||||
command = [
|
||||
self.ffprobe,
|
||||
"-v",
|
||||
"error",
|
||||
"-protocol_whitelist",
|
||||
"fd",
|
||||
"-fd",
|
||||
str(input_fd),
|
||||
]
|
||||
if count_frames:
|
||||
command.extend(["-read_intervals", "%+3.02", "-count_frames"])
|
||||
command.extend(
|
||||
[
|
||||
"-show_streams",
|
||||
"-show_format",
|
||||
"-of",
|
||||
"json",
|
||||
"fd:",
|
||||
]
|
||||
)
|
||||
return command
|
||||
|
||||
@staticmethod
|
||||
async def _communicate(
|
||||
process: asyncio.subprocess.Process,
|
||||
) -> tuple[bytes, bytes]:
|
||||
try:
|
||||
stdout, stderr = await process.communicate()
|
||||
return stdout or b"", stderr or b""
|
||||
except BaseException:
|
||||
if process.returncode is None:
|
||||
process.kill()
|
||||
await process.wait()
|
||||
raise
|
||||
|
||||
@staticmethod
|
||||
def _duration(probe: dict) -> float | None:
|
||||
candidates = [probe.get("format", {}).get("duration")]
|
||||
candidates.extend(stream.get("duration") for stream in probe.get("streams", []))
|
||||
for value in candidates:
|
||||
if value not in (None, "N/A"):
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
for stream in probe.get("streams", []):
|
||||
frame_count = stream.get("nb_read_frames") or stream.get("nb_frames")
|
||||
frame_rate = stream.get("avg_frame_rate") or stream.get("r_frame_rate")
|
||||
if frame_count in (None, "N/A") or frame_rate in (None, "N/A", "0/0"):
|
||||
continue
|
||||
try:
|
||||
fps = float(Fraction(str(frame_rate)))
|
||||
if fps > 0:
|
||||
return int(frame_count) / fps
|
||||
except (TypeError, ValueError, ZeroDivisionError):
|
||||
pass
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def _needs_frame_count(cls, probe: dict) -> bool:
|
||||
if cls._duration(probe) is not None:
|
||||
return False
|
||||
animated_codecs = {"apng", "gif", "webp"}
|
||||
return any(
|
||||
stream.get("codec_type") == "video"
|
||||
and stream.get("codec_name") in animated_codecs
|
||||
for stream in probe.get("streams", [])
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _resolve_kind(cls, kind: str, probe: dict) -> str:
|
||||
if kind != "auto":
|
||||
return kind
|
||||
duration = cls._duration(probe)
|
||||
for stream in probe.get("streams", []):
|
||||
value = stream.get("nb_read_frames") or stream.get("nb_frames")
|
||||
if value not in (None, "N/A"):
|
||||
try:
|
||||
if int(value) > 1:
|
||||
return "video"
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
return "video" if duration is not None and duration > 0.1 else "static"
|
||||
|
||||
@classmethod
|
||||
def _validate_output(cls, probe: dict) -> None:
|
||||
videos = [
|
||||
stream
|
||||
for stream in probe.get("streams", [])
|
||||
if stream.get("codec_type") == "video"
|
||||
]
|
||||
if len(videos) != 1 or videos[0].get("codec_name") != "vp9":
|
||||
raise MediaConversionError(
|
||||
"Результат конвертации должен быть в формате VP9."
|
||||
)
|
||||
if any(
|
||||
stream.get("codec_type") == "audio" for stream in probe.get("streams", [])
|
||||
):
|
||||
raise MediaConversionError("Результат конвертации содержит аудио.")
|
||||
width = int(videos[0].get("width", 0))
|
||||
height = int(videos[0].get("height", 0))
|
||||
if min(width, height) <= 0 or max(width, height) != 512:
|
||||
raise MediaConversionError("Некорректный размер видео-стикера.")
|
||||
duration = cls._duration(probe)
|
||||
if duration is None or duration > MAX_VIDEO_SECONDS + 0.01:
|
||||
raise MediaConversionError("Некорректная длительность видео-стикера.")
|
||||
frame_rate = videos[0].get("avg_frame_rate", "0/1")
|
||||
try:
|
||||
fps = float(Fraction(str(frame_rate)))
|
||||
except (ValueError, ZeroDivisionError):
|
||||
fps = 0
|
||||
if fps <= 0 or fps > 30.01:
|
||||
raise MediaConversionError("Частота кадров видео-стикера превышает 30 FPS.")
|
||||
|
||||
@staticmethod
|
||||
def _validate_input(probe: dict) -> None:
|
||||
video = next(
|
||||
(
|
||||
stream
|
||||
for stream in probe.get("streams", [])
|
||||
if stream.get("codec_type") == "video"
|
||||
),
|
||||
None,
|
||||
)
|
||||
if video is None:
|
||||
return
|
||||
width = int(video.get("width", 0))
|
||||
height = int(video.get("height", 0))
|
||||
if min(width, height) <= 0 or width * height > MAX_INPUT_PIXELS:
|
||||
raise MediaConversionError("Слишком большое разрешение входного медиа.")
|
||||
|
||||
@staticmethod
|
||||
def _sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as file:
|
||||
for chunk in iter(lambda: file.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
Reference in New Issue
Block a user