rev 4
This commit is contained in:
@@ -157,10 +157,10 @@ async def retrieve_mediafile(
|
||||
repo = MediaFileRepository(db)
|
||||
media_file = await repo.get_by_id(media_file_id)
|
||||
if media_file is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Не найдено")
|
||||
|
||||
if not current_user.is_staff and media_file.owner_id != current_user.id:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Forbidden")
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Доступ запрещён")
|
||||
|
||||
return MediaFileRead.model_validate(media_file)
|
||||
|
||||
@@ -175,10 +175,10 @@ async def patch_mediafile(
|
||||
repo = MediaFileRepository(db)
|
||||
media_file = await repo.get_by_id(media_file_id)
|
||||
if media_file is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Не найдено")
|
||||
|
||||
if not current_user.is_staff and media_file.owner_id != current_user.id:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Forbidden")
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Доступ запрещён")
|
||||
|
||||
media_file = await repo.update(media_file, body)
|
||||
return MediaFileRead.model_validate(media_file)
|
||||
@@ -193,10 +193,10 @@ async def delete_mediafile(
|
||||
repo = MediaFileRepository(db)
|
||||
media_file = await repo.get_by_id(media_file_id)
|
||||
if media_file is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Не найдено")
|
||||
|
||||
if not current_user.is_staff and media_file.owner_id != current_user.id:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Forbidden")
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Доступ запрещён")
|
||||
|
||||
await repo.mark_deleted(media_file)
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
@@ -237,7 +237,7 @@ async def retrieve_artifact_mediafile(
|
||||
repo = ArtifactRepository(db)
|
||||
artifact = await repo.get_by_id(artifact_id)
|
||||
if artifact is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Не найдено")
|
||||
|
||||
return ArtifactMediaFileRead.model_validate(artifact)
|
||||
|
||||
@@ -253,7 +253,7 @@ async def patch_artifact_mediafile(
|
||||
repo = ArtifactRepository(db)
|
||||
artifact = await repo.get_by_id(artifact_id)
|
||||
if artifact is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Не найдено")
|
||||
|
||||
artifact = await repo.update(artifact, body)
|
||||
return ArtifactMediaFileRead.model_validate(artifact)
|
||||
@@ -269,7 +269,7 @@ async def delete_artifact_mediafile(
|
||||
repo = ArtifactRepository(db)
|
||||
artifact = await repo.get_by_id(artifact_id)
|
||||
if artifact is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Не найдено")
|
||||
|
||||
await repo.mark_deleted(artifact)
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
+227
-74
@@ -19,6 +19,9 @@ 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:
|
||||
@@ -55,6 +58,123 @@ async def probe_media(storage: StorageService, *, file_key: str) -> MediaProbeSc
|
||||
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,
|
||||
@@ -83,6 +203,54 @@ def _compute_non_silent_segments(
|
||||
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,
|
||||
*,
|
||||
@@ -137,6 +305,7 @@ async def apply_silence_cuts(
|
||||
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)
|
||||
@@ -165,59 +334,47 @@ async def apply_silence_cuts(
|
||||
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:
|
||||
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,
|
||||
]
|
||||
)
|
||||
if on_progress is not None:
|
||||
on_progress("applying_cuts", 0.0)
|
||||
|
||||
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,
|
||||
]
|
||||
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()
|
||||
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,
|
||||
@@ -267,42 +424,10 @@ async def remove_silence(
|
||||
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,
|
||||
]
|
||||
cmd = _build_trim_concat_command(
|
||||
input_path=input_tmp.path,
|
||||
out_path=out_path,
|
||||
segments=segments,
|
||||
)
|
||||
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
@@ -333,21 +458,34 @@ async def remove_silence(
|
||||
|
||||
|
||||
async def convert_to_mp4(
|
||||
storage: StorageService, *, file_key: str, out_folder: str
|
||||
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",
|
||||
@@ -364,11 +502,26 @@ async def convert_to_mp4(
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
|
||||
)
|
||||
_, stderr = await proc.communicate()
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user