MediaRouter / app /operations /concat.py
basyx's picture
Upload 142 files
fba6023 verified
Raw
History Blame Contribute Delete
4.47 kB
from __future__ import annotations
from collections.abc import Sequence
from pathlib import Path
from typing import Any
import aiofiles
from app.models.media import InputMedia, OperationResult
from app.operations.common import even, execute, output_path, require_inputs
from app.operations.merge import merge_audio, merge_video
from app.services.ffmpeg_service import FFmpegService
from app.services.validator import as_bool, bounded_number, positive_int
async def concat_video(
ffmpeg: FFmpegService, inputs: Sequence[InputMedia], params: dict[str, Any], output_dir: Path
) -> OperationResult:
require_inputs(inputs, 2)
if not as_bool(params.get("stream_copy"), False):
return await merge_video(
ffmpeg, inputs, params, output_dir, operation="video.concat", stem="concatenated"
)
manifest = output_dir / "concat.txt"
async with aiofiles.open(manifest, "w", encoding="utf-8") as output_file:
for media in inputs:
escaped = str(media.temp_path.resolve()).replace("'", "'\\''")
await output_file.write(f"file '{escaped}'\n")
output = output_path(output_dir, "concatenated", "mp4")
return await execute(
ffmpeg,
[
"-f",
"concat",
"-safe",
"0",
"-i",
manifest,
"-c",
"copy",
"-movflags",
"+faststart",
output,
],
output,
"video.concat",
{"inputs": len(inputs), "stream_copy": True},
)
async def concat_audio(
ffmpeg: FFmpegService, inputs: Sequence[InputMedia], params: dict[str, Any], output_dir: Path
) -> OperationResult:
return await merge_audio(
ffmpeg, inputs, params, output_dir, operation="audio.concat", stem="concatenated"
)
async def image_slideshow(
ffmpeg: FFmpegService, inputs: Sequence[InputMedia], params: dict[str, Any], output_dir: Path
) -> OperationResult:
require_inputs(inputs)
duration = bounded_number(params, "duration_per_image", 3.0, 0.1, 300)
width = even(positive_int(params, "width", 1280))
height = even(positive_int(params, "height", 720))
fps = positive_int(params, "fps", 30)
command: list[str | Path] = []
for media in inputs:
command += ["-loop", "1", "-t", str(duration), "-i", media.temp_path]
filters = [
f"[{index}:v]scale={width}:{height}:force_original_aspect_ratio=decrease,"
f"pad={width}:{height}:(ow-iw)/2:(oh-ih)/2,setsar=1,format=yuv420p[v{index}]"
for index in range(len(inputs))
]
links = "".join(f"[v{index}]" for index in range(len(inputs)))
filters.append(f"{links}concat=n={len(inputs)}:v=1:a=0[v]")
output = output_path(output_dir, "slideshow", "mp4")
command += [
"-filter_complex",
";".join(filters),
"-map",
"[v]",
"-r",
str(fps),
"-c:v",
"libx264",
"-pix_fmt",
"yuv420p",
"-movflags",
"+faststart",
output,
]
return await execute(
ffmpeg,
command,
output,
"image.slideshow",
{"images": len(inputs), "duration_per_image": duration},
)
async def image_sequence(
ffmpeg: FFmpegService, inputs: Sequence[InputMedia], params: dict[str, Any], output_dir: Path
) -> OperationResult:
fps = positive_int(params, "fps", 25)
result = await image_slideshow(
ffmpeg,
inputs,
{
"duration_per_image": params.get("duration_per_image", 1 / fps),
**params,
},
output_dir,
)
result.metadata["operation"] = "image.sequence"
return result
async def image_to_video(
ffmpeg: FFmpegService, inputs: Sequence[InputMedia], params: dict[str, Any], output_dir: Path
) -> OperationResult:
require_inputs(inputs)
duration = bounded_number(params, "duration", 5, 0.1, 3600)
fps = positive_int(params, "fps", 30)
output = output_path(output_dir, "image-video", "mp4")
args = [
"-loop",
"1",
"-i",
inputs[0].temp_path,
"-t",
str(duration),
"-vf",
"scale=trunc(iw/2)*2:trunc(ih/2)*2,format=yuv420p",
"-r",
str(fps),
"-c:v",
"libx264",
"-movflags",
"+faststart",
output,
]
return await execute(ffmpeg, args, output, "image.video", {"duration": duration})