Commit: f2875d4
Parent: 0ca2c4c

Nicer clip generation

Mårten Åsberg committed on 2026-05-07 at 15:09
src/Cli/Program.cs +10 -12
diff --git a/src/Cli/Program.cs b/src/Cli/Program.cs
index 2c66369..0e0bca6 100644
@@ -1,7 +1,7 @@
using System.Threading;
using System;
using System.Threading;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Options;
using Slopper.Domain;
using Slopper.Infrastructure.Ai;
using Slopper.Infrastructure.Database;
@@ -15,15 +15,13 @@ builder.Services.AddJellyfinDatabase().AddFfmpegServices().AddAi();
using var app = builder.Build();
var options = app.Services.GetRequiredService<IOptions<ClipSelectorOptions>>();
var media = new MediaItem("test", args[0], new Subtitles.Embedded(0));
var subtitleReader = app.Services.GetRequiredService<ISubtitleReader>();
var subtitles = await subtitleReader.ReadSubtitles(media, CancellationToken.None);
var clipSelector = app.Services.GetRequiredService<ClipSelector>();
var (start, duration) = await clipSelector.PickClip(subtitles, CancellationToken.None);
var clipExtractor = app.Services.GetRequiredService<ClipExtractor>();
await clipExtractor.Clip(media, start, duration, args[1]);
var clipExtractor = app.Services.GetRequiredService<IClipExtractor>();
await clipExtractor.ExtractClip(
media,
TimeSpan.FromSeconds(11),
TimeSpan.FromSeconds(2),
args[1],
CancellationToken.None
);
src/Domain/IClipExtractor.cs +16 -0
diff --git a/src/Domain/IClipExtractor.cs b/src/Domain/IClipExtractor.cs
new file mode 100644
index 0000000..3866385
@@ -0,0 +1,16 @@
using System;
using System.Threading;
using System.Threading.Tasks;
namespace Slopper.Domain;
public interface IClipExtractor
{
Task ExtractClip(
MediaItem media,
TimeSpan start,
TimeSpan duration,
string outputPath,
CancellationToken cancellationToken
);
}
src/Infrastructure/Ffmpeg/ClipExtractor.cs +120 -5
diff --git a/src/Infrastructure/Ffmpeg/ClipExtractor.cs b/src/Infrastructure/Ffmpeg/ClipExtractor.cs
index 1349dcf..29baba5 100644
@@ -1,4 +1,9 @@
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;
@@ -8,9 +13,77 @@ using Slopper.Domain;
namespace Slopper.Infrastructure.Ffmpeg;
public sealed class ClipExtractor(ILogger<ClipExtractor> logger)
internal sealed partial class ClipExtractor(ILogger<ClipExtractor> logger) : IClipExtractor
{
public async Task Clip(MediaItem media, TimeSpan start, TimeSpan duration, string outputPath)
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)
@@ -20,7 +93,24 @@ public sealed class ClipExtractor(ILogger<ClipExtractor> logger)
options
.Seek(start)
.EndSeek(start + duration)
.WithVideoFilters(filter => filter.HardBurnSubtitle(CreateSubtitleOptions(media)))
.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)
@@ -28,7 +118,8 @@ public sealed class ClipExtractor(ILogger<ClipExtractor> logger)
.WithVideoBitrate(4000)
.WithAudioBitrate(128)
.ForceFormat("mp4")
);
)
.CancellableThrough(cancellationToken);
logger.LogInformation("Running ffmpeg {FfmpegArguments}", args.Arguments);
await args.ProcessAsynchronously();
}
@@ -43,6 +134,30 @@ public sealed class ClipExtractor(ILogger<ClipExtractor> logger)
}
).WithParameter(
"force_style",
"FontName=Segoe UI,FontSize=20,PrimaryColour=&HFFFFFF&,BackColour=&H000000&,BorderStyle=1,Alignment=2"
"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;
src/Infrastructure/Ffmpeg/ServiceCollectionExtensions.cs +1 -1
diff --git a/src/Infrastructure/Ffmpeg/ServiceCollectionExtensions.cs b/src/Infrastructure/Ffmpeg/ServiceCollectionExtensions.cs
index 85e69fa..66f4427 100644
@@ -6,5 +6,5 @@ namespace Slopper.Infrastructure.Ffmpeg;
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddFfmpegServices(this IServiceCollection services) =>
services.AddTransient<ClipExtractor>().AddTransient<ISubtitleReader, SubtitleReader>();
services.AddTransient<IClipExtractor, ClipExtractor>().AddTransient<ISubtitleReader, SubtitleReader>();
}