📄 src/Infrastructure/Ffmpeg/ClipExtractor.cs
using System;
using System.Collections.Generic;
using System.Drawing;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using FFMpegCore;
using FFMpegCore.Arguments;
using FFMpegCore.Enums;
using Microsoft.Extensions.Logging;
using Slopper.Domain;

namespace Slopper.Infrastructure.Ffmpeg;

internal sealed partial class ClipExtractor(ILogger<ClipExtractor> logger) : IClipExtractor
{
    public async Task ExtractClip(
        MediaItem media,
        TimeSpan start,
        TimeSpan duration,
        string outputPath,
        CancellationToken cancellationToken
    )
    {
        var crop = await AnalyzeCropArea(media, start, duration, cancellationToken);

        await CreateClip(media, start, duration, crop, outputPath, cancellationToken);
    }

    private async Task<Rectangle?> AnalyzeCropArea(
        MediaItem media,
        TimeSpan start,
        TimeSpan duration,
        CancellationToken cancellationToken
    )
    {
        var crops = new Dictionary<Rectangle, int>();
        var args = FFMpegArguments
            .FromFileInput(media.Path)
            .OutputToFile(
                "-",
                addArguments: options =>
                    options
                        .Seek(start)
                        .EndSeek(start + duration)
                        .WithVideoFilters(filter =>
                            filter
                                .Add("zscale", "t=linear:npl=100")
                                .Add("format", "yuv420p")
                                .Add("cropdetect", "24:2:1")
                        )
                        .ForceFormat("null")
            )
            .NotifyOnError(s =>
            {
                var match = CropPattern.Match(s);
                if (
                    !match.Success
                    || !int.TryParse(match.Groups[1].ValueSpan, out var width)
                    || !int.TryParse(match.Groups[2].ValueSpan, out var height)
                    || !int.TryParse(match.Groups[3].ValueSpan, out var x)
                    || !int.TryParse(match.Groups[4].ValueSpan, out var y)
                )
                {
                    return;
                }
                crops.Add(new(x, y, width, height));
            })
            .CancellableThrough(cancellationToken);
        logger.LogInformation("Running ffmpeg {FfmpegArguments}", args.Arguments);
        await args.ProcessAsynchronously();
        return crops.MaxRectangle;
    }

    [GeneratedRegex(@"crop=(\d+):(\d+):(\d+):(\d+)")]
    private static partial Regex CropPattern { get; }

    private async Task CreateClip(
        MediaItem media,
        TimeSpan start,
        TimeSpan duration,
        Rectangle? crop,
        string outputPath,
        CancellationToken cancellationToken
    )
    {
        var args = FFMpegArguments
            .FromFileInput(media.Path)
            .OutputToFile(
                outputPath,
                addArguments: options =>
                    options
                        .Seek(start)
                        .EndSeek(start + duration)
                        .WithVideoFilters(filter =>
                        {
                            if (crop is { Width: var width, Height: var height, X: var x, Y: var y })
                            {
                                filter.Add("crop", $"{width}:{height}:{x}:{y}");
                            }
                            filter.Add("split", "2[s0][s1]");

                            filter.Add("[s0]scale", "1080:-1[sharp]");

                            filter
                                .Add("[s1]scale", "-1:1920[scaled1]")
                                .Add("[scaled1]gblur", "sigma=30[blurred]")
                                .Add("[blurred]crop", "1080:1920:(iw - 1080) / 2:0[cropped]")
                                .Add("[cropped][sharp]overlay", "0:(H - h) / 2");

                            filter.HardBurnSubtitle(CreateSubtitleOptions(media));
                        })
                        .WithFastStart()
                        .WithVideoCodec(VideoCodec.LibX264)
                        .WithAudioCodec(AudioCodec.Aac)
                        .WithFramerate(30.0d)
                        .WithVideoBitrate(4000)
                        .WithAudioBitrate(128)
                        .ForceFormat("mp4")
            )
            .CancellableThrough(cancellationToken);
        logger.LogInformation("Running ffmpeg {FfmpegArguments}", args.Arguments);
        await args.ProcessAsynchronously();
    }

    private static SubtitleHardBurnOptions CreateSubtitleOptions(MediaItem media) =>
        (
            media.Subtitles switch
            {
                Subtitles.External(var path) => SubtitleHardBurnOptions.Create(path),
                Subtitles.Embedded(var index) => SubtitleHardBurnOptions.Create(media.Path).SetSubtitleIndex(index),
                _ => throw new ArgumentException("Unknown subtitle type"),
            }
        ).WithParameter(
            "force_style",
            "FontSize=10,BorderStyle=1,Alignment=2,MarginV=80,PrimaryColour=&HFFFFFF&,BackColour=&H000000&"
        );
}

file static class Extensions
{
    extension(Dictionary<Rectangle, int> dictionary)
    {
        public void Add(Rectangle rectangle) => dictionary[rectangle] = dictionary.GetValueOrDefault(rectangle) + 1;

        public Rectangle? MaxRectangle => dictionary.Count > 0 ? dictionary.MaxBy(kvp => kvp.Value).Key : null;
    }

    extension(VideoFilterOptions filter)
    {
        public VideoFilterOptions Add(string key, string value) =>
            filter.Add(new CustomVideoFilterArgument(key, value));

        public VideoFilterOptions Add(IVideoFilterArgument argument)
        {
            filter.Arguments.Add(argument);
            return filter;
        }
    }
}

file sealed record CustomVideoFilterArgument(string Key, string Value) : IVideoFilterArgument;