Files
main_backend/cpv3/modules/media/service.py
T
Daniil 259d3da89f rev 4
2026-04-07 13:42:45 +03:00

717 lines
22 KiB
Python

from __future__ import annotations
import asyncio
import glob as glob_mod
import hashlib
import io
import json
from os import path
from tempfile import NamedTemporaryFile, mkdtemp
from typing import Callable
import anyio
from cpv3.infrastructure.storage.base import StorageService
from cpv3.infrastructure.storage.types import FileInfo
from cpv3.modules.media.schemas import FrameSpriteMetadata, MediaProbeSchema
FRAME_WIDTH_PX = 128
FRAME_FPS = 1
FRAME_JPEG_QUALITY = 5
FRAMES_META_FILENAME = "meta.json"
FFMPEG_PROGRESS_DIVISOR = 1_000_000.0
MediaProgressCallback = Callable[[str, float | None], None]
def get_frames_folder(user_folder: str, file_key: str) -> str:
"""Build deterministic S3 folder path for frames based on file_key hash."""
key_hash = hashlib.sha256(file_key.encode()).hexdigest()[:16]
return path.join(user_folder, "frames", key_hash)
async def probe_media(storage: StorageService, *, file_key: str) -> MediaProbeSchema:
tmp = await storage.download_to_temp(file_key)
try:
proc = await asyncio.create_subprocess_exec(
"ffprobe",
"-v",
"error",
"-show_streams",
"-show_format",
"-of",
"json",
tmp.path,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await proc.communicate()
if proc.returncode != 0:
raise RuntimeError(f"ffprobe failed: {stderr.decode(errors='ignore')}")
import json
raw = json.loads(stdout.decode())
return MediaProbeSchema.model_validate(raw)
finally:
tmp.cleanup()
def _parse_ffmpeg_timecode_seconds(value: str) -> float | None:
parts = value.strip().split(":")
if len(parts) != 3:
return None
try:
hours = int(parts[0])
minutes = int(parts[1])
seconds = float(parts[2])
except ValueError:
return None
return hours * 3600 + minutes * 60 + seconds
def _get_ffmpeg_output_time_seconds(progress_snapshot: dict[str, str]) -> float | None:
timecode = progress_snapshot.get("out_time")
if timecode:
parsed = _parse_ffmpeg_timecode_seconds(timecode)
if parsed is not None:
return parsed
for key in ("out_time_us", "out_time_ms"):
raw_value = progress_snapshot.get(key)
if raw_value is None:
continue
try:
return max(float(raw_value), 0.0) / FFMPEG_PROGRESS_DIVISOR
except ValueError:
continue
return None
def _extract_ffmpeg_out_time_ms(progress_snapshot: dict[str, str]) -> float | None:
seconds = _get_ffmpeg_output_time_seconds(progress_snapshot)
if seconds is None:
return None
return seconds * 1000.0
async def _probe_local_duration_seconds(input_path: str) -> float | None:
proc = await asyncio.create_subprocess_exec(
"ffprobe",
"-v",
"error",
"-show_entries",
"format=duration",
"-of",
"json",
input_path,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, _ = await proc.communicate()
if proc.returncode != 0:
return None
try:
raw = json.loads(stdout.decode())
except json.JSONDecodeError:
return None
duration = raw.get("format", {}).get("duration")
if duration is None:
return None
try:
value = float(duration)
except (TypeError, ValueError):
return None
return value if value > 0 else None
async def _forward_ffmpeg_progress(
stdout: asyncio.StreamReader | None,
*,
duration_seconds: float | None,
on_progress: MediaProgressCallback | None,
progress_stage: str,
) -> None:
if stdout is None:
return
snapshot: dict[str, str] = {}
last_pct = -1.0
while True:
line = await stdout.readline()
if not line:
break
decoded = line.decode(errors="ignore").strip()
if "=" not in decoded:
continue
key, value = decoded.split("=", 1)
snapshot[key] = value
if key != "progress":
continue
if (
on_progress is not None
and duration_seconds is not None
and duration_seconds > 0
):
output_time_seconds = _get_ffmpeg_output_time_seconds(snapshot)
if output_time_seconds is not None:
pct = min(max((output_time_seconds / duration_seconds) * 100.0, 0.0), 100.0)
if pct > last_pct:
last_pct = pct
on_progress(progress_stage, pct)
snapshot = {}
def _compute_non_silent_segments(
*,
local_audio_path: str,
min_silence_duration_ms: int,
silence_threshold_db: int,
padding_ms: int,
) -> list[tuple[int, int]]:
from pydub import AudioSegment, silence # type: ignore[import-untyped]
audio: AudioSegment = AudioSegment.from_file(local_audio_path)
duration_ms = len(audio)
raw_segments = silence.detect_nonsilent(
audio_segment=audio,
min_silence_len=min_silence_duration_ms,
silence_thresh=int(audio.dBFS - silence_threshold_db),
)
segments: list[tuple[int, int]] = []
for start_ms, end_ms in raw_segments:
start = max(0, start_ms - padding_ms)
end = min(duration_ms, end_ms + padding_ms)
if end > start:
segments.append((start, end))
return segments
def _build_trim_concat_filter(segments: list[tuple[int, int]]) -> str:
parts: list[str] = []
concat_inputs: list[str] = []
for index, (start_ms, end_ms) in enumerate(segments):
start_s = start_ms / 1000.0
end_s = end_ms / 1000.0
video_label = f"v{index}"
audio_label = f"a{index}"
parts.append(
f"[0:v:0]trim=start={start_s:.3f}:end={end_s:.3f},setpts=PTS-STARTPTS[{video_label}]"
)
parts.append(
f"[0:a:0]atrim=start={start_s:.3f}:end={end_s:.3f},asetpts=PTS-STARTPTS[{audio_label}]"
)
concat_inputs.append(f"[{video_label}][{audio_label}]")
return ";".join(parts + ["".join(concat_inputs) + f"concat=n={len(segments)}:v=1:a=1[v][a]"])
def _build_trim_concat_command(
*,
input_path: str,
out_path: str,
segments: list[tuple[int, int]],
) -> list[str]:
return [
"ffmpeg",
"-y",
"-i",
input_path,
"-filter_complex",
_build_trim_concat_filter(segments),
"-map",
"[v]",
"-map",
"[a]",
"-c:v",
"libx264",
"-c:a",
"aac",
"-preset",
"medium",
out_path,
]
async def detect_silence(
storage: StorageService,
*,
file_key: str,
min_silence_duration_ms: int = 200,
silence_threshold_db: int = 16,
padding_ms: int = 100,
) -> dict:
"""Detect silent segments in a media file and return their intervals."""
input_tmp = await storage.download_to_temp(file_key)
try:
from pydub import AudioSegment # type: ignore[import-untyped]
audio: AudioSegment = await anyio.to_thread.run_sync(
lambda: AudioSegment.from_file(input_tmp.path)
)
duration_ms = len(audio)
non_silent = await anyio.to_thread.run_sync(
lambda: _compute_non_silent_segments(
local_audio_path=input_tmp.path,
min_silence_duration_ms=min_silence_duration_ms,
silence_threshold_db=silence_threshold_db,
padding_ms=padding_ms,
)
)
# Invert non-silent segments to get silent segments
silent_segments: list[dict[str, int]] = []
prev_end = 0
for start_ms, end_ms in non_silent:
if start_ms > prev_end:
silent_segments.append({"start_ms": prev_end, "end_ms": start_ms})
prev_end = end_ms
if prev_end < duration_ms:
silent_segments.append({"start_ms": prev_end, "end_ms": duration_ms})
return {
"silent_segments": silent_segments,
"duration_ms": duration_ms,
"file_key": file_key,
}
finally:
input_tmp.cleanup()
async def apply_silence_cuts(
storage: StorageService,
*,
file_key: str,
out_folder: str,
cuts: list[dict],
output_name: str | None = None,
on_progress: MediaProgressCallback | None = None,
) -> FileInfo:
"""Apply explicit cut regions to a media file, concatenating the non-cut parts."""
input_tmp = await storage.download_to_temp(file_key)
try:
from pydub import AudioSegment # type: ignore[import-untyped]
audio: AudioSegment = await anyio.to_thread.run_sync(
lambda: AudioSegment.from_file(input_tmp.path)
)
duration_ms = len(audio)
# Sort cuts and compute non-cut (keep) segments
sorted_cuts = sorted(cuts, key=lambda c: c["start_ms"])
segments: list[tuple[int, int]] = []
prev_end = 0
for cut in sorted_cuts:
cut_start = max(0, cut["start_ms"])
cut_end = min(duration_ms, cut["end_ms"])
if cut_start > prev_end:
segments.append((prev_end, cut_start))
prev_end = max(prev_end, cut_end)
if prev_end < duration_ms:
segments.append((prev_end, duration_ms))
if not segments:
return await storage.get_file_info(file_key)
output_duration_seconds = sum(end - start for start, end in segments) / 1000.0
with NamedTemporaryFile(
suffix=path.splitext(file_key)[1] or ".mp4", delete=False
) as out:
out_path = out.name
try:
if on_progress is not None:
on_progress("applying_cuts", 0.0)
cmd = _build_trim_concat_command(
input_path=input_tmp.path,
out_path=out_path,
segments=segments,
)
proc = await asyncio.create_subprocess_exec(
*cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
)
progress_task = asyncio.create_task(
_forward_ffmpeg_progress(
proc.stdout,
duration_seconds=output_duration_seconds,
on_progress=on_progress,
progress_stage="applying_cuts",
)
)
stderr_task = asyncio.create_task(
proc.stderr.read() if proc.stderr is not None else asyncio.sleep(0, result=b"")
)
await asyncio.gather(progress_task, stderr_task)
await proc.wait()
stderr = stderr_task.result()
if proc.returncode != 0:
raise RuntimeError(f"ffmpeg failed: {stderr.decode(errors='ignore')}")
base_name = output_name or path.basename(file_key)
output_key = path.join(out_folder or "", "silent", base_name)
if on_progress is not None:
on_progress("uploading", None)
with open(out_path, "rb") as out_file:
_ = await storage.upload_fileobj(
fileobj=out_file,
file_name=path.basename(output_key),
folder=path.dirname(output_key),
gen_name=False,
content_type="video/mp4",
)
return await storage.get_file_info(output_key)
finally:
import os
if os.path.exists(out_path):
os.remove(out_path)
finally:
input_tmp.cleanup()
async def remove_silence(
storage: StorageService,
*,
file_key: str,
out_folder: str,
min_silence_duration_ms: int = 200,
silence_threshold_db: int = 16,
padding_ms: int = 100,
) -> FileInfo:
input_tmp = await storage.download_to_temp(file_key)
try:
segments = await anyio.to_thread.run_sync(
lambda: _compute_non_silent_segments(
local_audio_path=input_tmp.path,
min_silence_duration_ms=min_silence_duration_ms,
silence_threshold_db=silence_threshold_db,
padding_ms=padding_ms,
)
)
if not segments:
return await storage.get_file_info(file_key)
with NamedTemporaryFile(
suffix=path.splitext(file_key)[1] or ".mp4", delete=False
) as out:
out_path = out.name
try:
cmd = _build_trim_concat_command(
input_path=input_tmp.path,
out_path=out_path,
segments=segments,
)
proc = await asyncio.create_subprocess_exec(
*cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
)
_, stderr = await proc.communicate()
if proc.returncode != 0:
raise RuntimeError(f"ffmpeg failed: {stderr.decode(errors='ignore')}")
output_key = path.join(out_folder or "", "silent", path.basename(file_key))
with open(out_path, "rb") as out_file:
_ = await storage.upload_fileobj(
fileobj=out_file,
file_name=path.basename(output_key),
folder=path.dirname(output_key),
gen_name=False,
content_type="video/mp4",
)
return await storage.get_file_info(output_key)
finally:
import os
if os.path.exists(out_path):
os.remove(out_path)
finally:
input_tmp.cleanup()
async def convert_to_mp4(
storage: StorageService,
*,
file_key: str,
out_folder: str,
on_progress: MediaProgressCallback | None = None,
) -> FileInfo:
input_tmp = await storage.download_to_temp(file_key)
try:
filename_without_ext = path.splitext(path.basename(file_key))[0]
mp4_filename = f"Конвертированое видео {filename_without_ext}.mp4"
duration_seconds = await _probe_local_duration_seconds(input_tmp.path)
with NamedTemporaryFile(suffix=".mp4", delete=False) as out:
out_path = out.name
try:
if on_progress is not None:
on_progress("converting", 0.0)
cmd = [
"ffmpeg",
"-y",
"-nostdin",
"-v",
"error",
"-progress",
"pipe:1",
"-i",
input_tmp.path,
"-c:v",
"libx264",
"-c:a",
"aac",
"-preset",
"medium",
"-f",
"mp4",
out_path,
]
proc = await asyncio.create_subprocess_exec(
*cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
)
progress_task = asyncio.create_task(
_forward_ffmpeg_progress(
proc.stdout,
duration_seconds=duration_seconds,
on_progress=on_progress,
progress_stage="converting",
)
)
stderr_task = asyncio.create_task(
proc.stderr.read() if proc.stderr is not None else asyncio.sleep(0, result=b"")
)
await asyncio.gather(progress_task, stderr_task)
await proc.wait()
stderr = stderr_task.result()
if proc.returncode != 0:
raise RuntimeError(f"ffmpeg failed: {stderr.decode(errors='ignore')}")
output_key = path.join(out_folder or "", "converted", mp4_filename)
if on_progress is not None:
on_progress("uploading", None)
with open(out_path, "rb") as out_file:
_ = await storage.upload_fileobj(
fileobj=out_file,
file_name=mp4_filename,
folder=path.dirname(output_key),
gen_name=False,
content_type="video/mp4",
)
return await storage.get_file_info(output_key)
finally:
import os
if os.path.exists(out_path):
os.remove(out_path)
finally:
input_tmp.cleanup()
async def convert_to_ogg_temp(
storage: StorageService, *, file_key: str
) -> tuple[str, Callable[[], None]]:
input_tmp = await storage.download_to_temp(file_key)
filename_without_ext = path.splitext(path.basename(file_key))[0]
with NamedTemporaryFile(suffix=".ogg", delete=False) as out:
out_path = out.name
async def _run() -> None:
cmd = [
"ffmpeg",
"-y",
"-i",
input_tmp.path,
"-c:a",
"libopus",
"-b:a",
"24k",
"-vn",
"-ac",
"1",
"-ar",
"16000",
out_path,
]
proc = await asyncio.create_subprocess_exec(
*cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
)
_, stderr = await proc.communicate()
if proc.returncode != 0:
raise RuntimeError(f"ffmpeg failed: {stderr.decode(errors='ignore')}")
await _run()
def _cleanup() -> None:
import os
input_tmp.cleanup()
if os.path.exists(out_path):
os.remove(out_path)
_ = filename_without_ext
return out_path, _cleanup
async def extract_frames(
storage: StorageService,
*,
file_key: str,
frames_folder: str,
on_progress: Callable[[int, int], None] | None = None,
) -> FrameSpriteMetadata:
"""Extract video frames at 1fps via ffmpeg and upload to S3.
Also writes a ``meta.json`` alongside the frames for fast lookup.
Returns metadata about the extracted frames.
"""
input_tmp = await storage.download_to_temp(file_key)
tmp_dir = mkdtemp(prefix="frames_")
try:
cmd = [
"ffmpeg",
"-y",
"-i",
input_tmp.path,
"-vf",
f"fps={FRAME_FPS},scale={FRAME_WIDTH_PX}:-1",
"-q:v",
str(FRAME_JPEG_QUALITY),
path.join(tmp_dir, "%06d.jpg"),
]
proc = await asyncio.create_subprocess_exec(
*cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
)
_, stderr = await proc.communicate()
if proc.returncode != 0:
raise RuntimeError(f"ffmpeg frame extraction failed: {stderr.decode(errors='ignore')}")
frame_files = sorted(glob_mod.glob(path.join(tmp_dir, "*.jpg")))
frame_count = len(frame_files)
if frame_count == 0:
raise RuntimeError("No frames extracted from video")
# Read first frame dimensions via ffprobe (avoids PIL dependency)
probe_proc = await asyncio.create_subprocess_exec(
"ffprobe",
"-v", "error",
"-select_streams", "v:0",
"-show_entries", "stream=width,height",
"-of", "json",
frame_files[0],
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
probe_stdout, _ = await probe_proc.communicate()
probe_data = json.loads(probe_stdout.decode())
stream = probe_data.get("streams", [{}])[0]
width = stream.get("width", FRAME_WIDTH_PX)
height = stream.get("height", FRAME_WIDTH_PX)
# Upload each frame to S3
for idx, frame_path in enumerate(frame_files):
frame_name = path.basename(frame_path)
with open(frame_path, "rb") as f:
await storage.upload_fileobj(
fileobj=f,
file_name=frame_name,
folder=frames_folder,
gen_name=False,
content_type="image/jpeg",
)
if on_progress is not None:
on_progress(idx + 1, frame_count)
metadata = FrameSpriteMetadata(
frame_count=frame_count,
interval=1.0 / FRAME_FPS,
width=width,
height=height,
folder_key=frames_folder,
source_file_key=file_key,
)
# Write metadata JSON to S3 for fast lookup by the frames endpoint
meta_bytes = json.dumps(metadata.model_dump(mode="json")).encode("utf-8")
await storage.upload_fileobj(
fileobj=io.BytesIO(meta_bytes),
file_name=FRAMES_META_FILENAME,
folder=frames_folder,
gen_name=False,
content_type="application/json",
)
return metadata
finally:
import shutil
input_tmp.cleanup()
shutil.rmtree(tmp_dir, ignore_errors=True)
async def read_frames_metadata(
storage: StorageService, *, frames_folder: str
) -> FrameSpriteMetadata | None:
"""Read frame extraction metadata from S3. Returns None if not found."""
meta_key = path.join(frames_folder, FRAMES_META_FILENAME)
if not await storage.exists(meta_key):
return None
raw = await storage.read(meta_key)
return FrameSpriteMetadata.model_validate(json.loads(raw))
async def delete_frames(
storage: StorageService, *, frames_folder: str, frame_count: int
) -> None:
"""Delete all frame files and metadata from S3 for a given folder."""
for i in range(1, frame_count + 1):
key = path.join(frames_folder, f"{i:06d}.jpg")
try:
await storage.delete(key)
except Exception:
pass
# Delete metadata file
meta_key = path.join(frames_folder, FRAMES_META_FILENAME)
try:
await storage.delete(meta_key)
except Exception:
pass