Files
main_backend/cpv3/modules/media/service.py
T
2026-02-27 23:33:56 +03:00

564 lines
17 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"
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 _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
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,
) -> 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)
with NamedTemporaryFile(
suffix=path.splitext(file_key)[1] or ".mp4", delete=False
) as out:
out_path = out.name
try:
cmd: list[str] = ["ffmpeg"]
for start_ms, end_ms in segments:
start_s = start_ms / 1000.0
duration_s = (end_ms - start_ms) / 1000.0
cmd.extend(
[
"-ss",
f"{start_s:.3f}",
"-t",
f"{duration_s:.3f}",
"-y",
"-i",
input_tmp.path,
]
)
seg_count = len(segments)
parts = [f"[{i}:v:0][{i}:a:0]" for i in range(seg_count)]
filter_complex = "".join(parts) + f"concat=n={seg_count}:v=1:a=1[v][a]"
cmd.extend(
[
"-filter_complex",
filter_complex,
"-map",
"[v]",
"-map",
"[a]",
"-c:v",
"libx264",
"-c:a",
"aac",
"-preset",
"medium",
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')}")
base_name = output_name or path.basename(file_key)
output_key = path.join(out_folder or "", "silent", base_name)
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: list[str] = ["ffmpeg"]
for start_ms, end_ms in segments:
start_s = start_ms / 1000.0
duration_s = (end_ms - start_ms) / 1000.0
cmd.extend(
[
"-ss",
f"{start_s:.3f}",
"-t",
f"{duration_s:.3f}",
"-y",
"-i",
input_tmp.path,
]
)
seg_count = len(segments)
parts = [f"[{i}:v:0][{i}:a:0]" for i in range(seg_count)]
filter_complex = "".join(parts) + f"concat=n={seg_count}:v=1:a=1[v][a]"
cmd.extend(
[
"-filter_complex",
filter_complex,
"-map",
"[v]",
"-map",
"[a]",
"-c:v",
"libx264",
"-c:a",
"aac",
"-preset",
"medium",
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')}")
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
) -> FileInfo:
input_tmp = await storage.download_to_temp(file_key)
try:
filename_without_ext = path.splitext(path.basename(file_key))[0]
mp4_filename = filename_without_ext + ".mp4"
with NamedTemporaryFile(suffix=".mp4", delete=False) as out:
out_path = out.name
try:
cmd = [
"ffmpeg",
"-y",
"-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
)
_, stderr = await proc.communicate()
if proc.returncode != 0:
raise RuntimeError(f"ffmpeg failed: {stderr.decode(errors='ignore')}")
output_key = path.join(out_folder or "", "converted", mp4_filename)
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