Directory.Packages.props
+0
-1
diff --git a/Directory.Packages.props b/Directory.Packages.props
index d284a24..f33d812 100644
@@ -24,7 +24,6 @@
Version="11.0.0-preview.3.26207.106"
/>
<PackageVersion Include="Microsoft.Extensions.Http" Version="11.0.0-preview.3.26207.106" />
<PackageVersion Include="Microsoft.Extensions.Http.Resilience" Version="10.5.0" />
<PackageVersion
Include="Microsoft.Extensions.Logging.Abstractions"
Version="11.0.0-preview.3.26207.106"
src/Api/Api.csproj
+0
-1
diff --git a/src/Api/Api.csproj b/src/Api/Api.csproj
index f4b267a..7d78a9c 100644
@@ -12,7 +12,6 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" />
<PackageReference Include="Microsoft.Extensions.Http.Resilience" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" />
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" />
src/Api/Clip.cs
+2
-2
diff --git a/src/Api/Clip.cs b/src/Api/Clip.cs
index b53c96f..3b7008c 100644
@@ -2,7 +2,7 @@ using System;
namespace Slopper.Api;
public sealed record Clip(Guid Id, TimeSpan Duration, DateTimeOffset CreatedAt)
public sealed record Clip(Guid Id, TimeSpan Duration, DateTimeOffset CreatedAt, string? Description)
{
public static Clip FromDomain(Domain.Clip clip) => new(clip.Id, clip.Duration, clip.CreatedAt);
public static Clip FromDomain(Domain.Clip clip) => new(clip.Id, clip.Duration, clip.CreatedAt, clip.Description);
}
src/Api/Program.cs
+0
-2
diff --git a/src/Api/Program.cs b/src/Api/Program.cs
index 09bb24d..52ea166 100644
@@ -12,8 +12,6 @@ var builder = WebApplication.CreateBuilder(args);
builder.ConfigureOpenTelemetry();
builder.Services.ConfigureHttpClientDefaults(http => http.AddStandardResilienceHandler());
builder.Services.AddOpenApi();
builder.Services.AddClipSelector().AddClipGenerator();
src/Api/appsettings.Development.json
+3
-0
diff --git a/src/Api/appsettings.Development.json b/src/Api/appsettings.Development.json
index 876ce4d..4d5f6e2 100644
@@ -9,6 +9,9 @@
"ClipGenerationJob": {
"Interval": "00:00:10.000"
},
"ClipDescriber": {
"Prompt": "Give one short sentence caption of these frames from a video clip, optimized for engagement on TikTok and Instagram Reels. Only ever give on sentence, no alternatives, not formatting, just a plain text caption, optimized for engagement."
},
"ClipSelector": {
"ClippableQuotes": [
"I'll be back",
src/Api/packages.lock.json
+0
-83
diff --git a/src/Api/packages.lock.json b/src/Api/packages.lock.json
index ef91d8a..66c4ab6 100644
@@ -18,16 +18,6 @@
"Microsoft.OpenApi": "3.3.1"
}
},
"Microsoft.Extensions.Http.Resilience": {
"type": "Direct",
"requested": "[10.5.0, )",
"resolved": "10.5.0",
"contentHash": "81rw+wjFFP5jREOERb1PHIPvBNFtE6NXO8bsLTSCET2UZWxj7cwrpzcI3l07tOpHEprYmruZAF3kZEar7uG4Iw==",
"dependencies": {
"Microsoft.Extensions.Http.Diagnostics": "10.5.0",
"Microsoft.Extensions.Resilience": "10.5.0"
}
},
"OpenTelemetry.Exporter.OpenTelemetryProtocol": {
"type": "Direct",
"requested": "[1.15.3, )",
@@ -157,16 +147,6 @@
"SQLitePCLRaw.core": "2.1.11"
}
},
"Microsoft.Extensions.AmbientMetadata.Application": {
"type": "Transitive",
"resolved": "10.5.0",
"contentHash": "lCJjEDknSYeTXB133DwLNwXYA6q9nzJiJFjQb1KO1n3sS6wHfROm6zqG6y3UthQP5oPnNbE1a7M15LpjSf5yBg=="
},
"Microsoft.Extensions.Compliance.Abstractions": {
"type": "Transitive",
"resolved": "10.5.0",
"contentHash": "xbWZji13Vb2jDJNtwVrKpI09jd8x3n3fL+GzhiLK+8O5Wc2A+GyqCZalST2fV46Pf0QfCwkXf83y+3/rDkCd7A=="
},
"Microsoft.Extensions.Configuration": {
"type": "Transitive",
"resolved": "11.0.0-preview.3.26207.106",
@@ -258,11 +238,6 @@
"resolved": "11.0.0-preview.3.26207.106",
"contentHash": "+gJnv1/kfXLXPv21R3iluhKqfXdf2zPWUaHBiSvlJurThv2D5HRUfU5z5SpmBII4I0JSpuprX9DlHrKz/1wCXA=="
},
"Microsoft.Extensions.DependencyInjection.AutoActivation": {
"type": "Transitive",
"resolved": "10.5.0",
"contentHash": "vby/PzPScy9pX3r3f5UuHutxSr4Q8SXqyIiH6+JEK7SVpTCL6f8R9mp04OUVsZLlsME2rBjA9PHXf9L9aG7wbg=="
},
"Microsoft.Extensions.DependencyModel": {
"type": "Transitive",
"resolved": "10.0.7",
@@ -287,11 +262,6 @@
"Microsoft.Extensions.Options": "11.0.0-preview.3.26207.106"
}
},
"Microsoft.Extensions.Diagnostics.ExceptionSummarization": {
"type": "Transitive",
"resolved": "10.5.0",
"contentHash": "+jdC9YUfMkX9/Yb3Pi8Kovt1nFVGGB2UqSHZgLapo63d+WAhYf9KiuNA3jiaaRINhVyCgWuKFoMtjWKET5oXEQ=="
},
"Microsoft.Extensions.FileProviders.Abstractions": {
"type": "Transitive",
"resolved": "11.0.0-preview.3.26207.106",
@@ -315,14 +285,6 @@
"resolved": "11.0.0-preview.3.26207.106",
"contentHash": "gI8O5FzTgw9yKbYKvGxDdymIackACfG+VF5cAisZExZcZ3/BaZ1YBN7jsURoiHUmaN8KTNwCqjxWhITHFq18Cw=="
},
"Microsoft.Extensions.Http.Diagnostics": {
"type": "Transitive",
"resolved": "10.5.0",
"contentHash": "HoWdJKvBt7vkLlclRbjDTXcCp3s9hwFf1CY4ovlmMKFAbKSI7zKl0fUQ4LMvUI3sHIhpEtMjp7Mxjaf/yEmVvQ==",
"dependencies": {
"Microsoft.Extensions.Telemetry": "10.5.0"
}
},
"Microsoft.Extensions.Logging": {
"type": "Transitive",
"resolved": "11.0.0-preview.3.26207.106",
@@ -400,35 +362,6 @@
"resolved": "11.0.0-preview.3.26207.106",
"contentHash": "IBOlwyX13ax6/fXA7AoZFswKFytta9TExBv3/8qemMJGBoDXYlQEcw4WerHQCvmerJ5uP2o8bjIAvxcNdTZVLQ=="
},
"Microsoft.Extensions.Resilience": {
"type": "Transitive",
"resolved": "10.5.0",
"contentHash": "yjbGQkSqLkP8/lKZLfaUcdkNUpWUqMafCsm56kw9uzznhJb/uJiIRy5/zG9D0SFsBzJkz2AcvWU2J/MJydPxoA==",
"dependencies": {
"Microsoft.Extensions.Diagnostics.ExceptionSummarization": "10.5.0",
"Microsoft.Extensions.Telemetry.Abstractions": "10.5.0",
"Polly.Extensions": "8.4.2",
"Polly.RateLimiting": "8.4.2"
}
},
"Microsoft.Extensions.Telemetry": {
"type": "Transitive",
"resolved": "10.5.0",
"contentHash": "jI7b9rkfoz06ZEQols6WG3D0iQMIbtRDHkx1F7QvQOSDmzyXLwUIBbJEO8ftr7aD/2tvsHplqycp+WXFvMfujg==",
"dependencies": {
"Microsoft.Extensions.AmbientMetadata.Application": "10.5.0",
"Microsoft.Extensions.DependencyInjection.AutoActivation": "10.5.0",
"Microsoft.Extensions.Telemetry.Abstractions": "10.5.0"
}
},
"Microsoft.Extensions.Telemetry.Abstractions": {
"type": "Transitive",
"resolved": "10.5.0",
"contentHash": "VmU7e6xHqoubWKl7y9MtWyQAjlDpvbds3gY8ZKMS/1GxY2+U1/aMNnMj09aOXAa3p5qhHSSkBzDJvyokCjVkPg==",
"dependencies": {
"Microsoft.Extensions.Compliance.Abstractions": "10.5.0"
}
},
"Microsoft.OpenApi": {
"type": "Transitive",
"resolved": "3.3.1",
@@ -460,22 +393,6 @@
"resolved": "8.6.5",
"contentHash": "t+sUVrIwvo7UmsgHGgOG9F0GDZSRIm47u2ylH17Gvcv1q5hNEwgD5GoBlFyc0kh/pebmPyrAgvGsR/65ZBaXlg=="
},
"Polly.Extensions": {
"type": "Transitive",
"resolved": "8.4.2",
"contentHash": "GZ9vRVmR0jV2JtZavt+pGUsQ1O1cuRKG7R7VOZI6ZDy9y6RNPvRvXK1tuS4ffUrv8L0FTea59oEuQzgS0R7zSA==",
"dependencies": {
"Polly.Core": "8.4.2"
}
},
"Polly.RateLimiting": {
"type": "Transitive",
"resolved": "8.4.2",
"contentHash": "ehTImQ/eUyO07VYW2WvwSmU9rRH200SKJ/3jku9rOkyWE0A2JxNFmAVms8dSn49QLSjmjFRRSgfNyOgr/2PSmA==",
"dependencies": {
"Polly.Core": "8.4.2"
}
},
"Quartz.Extensions.DependencyInjection": {
"type": "Transitive",
"resolved": "3.18.1",
src/Cli/Program.cs
+4
-4
diff --git a/src/Cli/Program.cs b/src/Cli/Program.cs
index aaa93d6..2567fd0 100644
@@ -22,11 +22,11 @@ using var app = builder.Build();
var logger = app.Services.GetRequiredService<ILogger<Program>>();
using var scope = app.Services.CreateScope();
var clipExtractor = scope.ServiceProvider.GetRequiredService<IClipExtractor>();
await clipExtractor.ExtractClip(
var clipDescriber = scope.ServiceProvider.GetRequiredService<ClipDescriber>();
var description = await clipDescriber.DescribeClip(
new(Guid.NewGuid(), args[0], new Subtitles.Embedded(3)),
TimeSpan.FromSeconds(11),
TimeSpan.FromSeconds(5),
args[1],
TimeSpan.FromSeconds(16),
CancellationToken.None
);
logger.LogInformation("{Description}", description);
src/Cli/appsettings.Development.json
+3
-0
diff --git a/src/Cli/appsettings.Development.json b/src/Cli/appsettings.Development.json
index af7d375..75d9278 100644
@@ -5,6 +5,9 @@
"Slopper": "Debug"
}
},
"ClipDescriber": {
"Prompt": "Give one short sentence caption of these frames from a video clip, optimized for engagement on TikTok and Instagram Reels. Only ever give on sentence, no alternatives, not formatting, just a plain text caption, optimized for engagement."
},
"ClipSelector": {
"ClippableQuotes": [
"I'll be back",
src/Domain/Clip.cs
+2
-1
diff --git a/src/Domain/Clip.cs b/src/Domain/Clip.cs
index 8fca7bf..ed02efb 100644
@@ -8,5 +8,6 @@ public sealed record Clip(
string Path,
TimeSpan Start,
TimeSpan Duration,
DateTimeOffset CreatedAt
DateTimeOffset CreatedAt,
string? Description
);
src/Domain/ClipDescriber.cs
+63
-0
diff --git a/src/Domain/ClipDescriber.cs b/src/Domain/ClipDescriber.cs
new file mode 100644
index 0000000..81fe83d
@@ -0,0 +1,63 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
namespace Slopper.Domain;
public sealed class ClipDescriber(
IFrameExtractor frameExtractor,
IChatClient chatClient,
IOptionsMonitor<ClipDescriberOptions> options
)
{
public async Task<string> DescribeClip(
MediaItem media,
TimeSpan start,
TimeSpan end,
CancellationToken cancellationToken
)
{
var frames = await frameExtractor.ExtractFrames(media, [start, end], cancellationToken);
List<AIContent> contents = [];
foreach (var frame in frames)
contents.Add(new DataContent(frame, "image/png"));
contents.Add(new TextContent(options.CurrentValue.Prompt));
var response = await chatClient.GetResponseAsync(
[new ChatMessage(ChatRole.User, contents)],
cancellationToken: cancellationToken
);
return response.Text;
}
}
public sealed class ClipDescriberOptions
{
[Required]
public required string Prompt { get; set; }
}
[OptionsValidator]
internal sealed partial class ClipDescriberOptionsValidator : IValidateOptions<ClipDescriberOptions>;
public static class ClipDescriberServiceCollectionExtensions
{
extension(IServiceCollection services)
{
public IServiceCollection AddClipDescriber()
{
services.AddOptions<ClipDescriberOptions>().BindConfiguration("ClipDescriber").ValidateOnStart();
services.AddTransient<IValidateOptions<ClipDescriberOptions>, ClipDescriberOptionsValidator>();
services.AddTransient<ClipDescriber>();
return services;
}
}
}
src/Domain/ClipGenerator.cs
+6
-2
diff --git a/src/Domain/ClipGenerator.cs b/src/Domain/ClipGenerator.cs
index 8d438b9..dff7747 100644
@@ -14,7 +14,8 @@ public sealed class ClipGenerator(
IMediaRepository mediaRepository,
ClipSelector clipSelector,
IClipExtractor clipExtractor,
IClipRepository clipRepository
IClipRepository clipRepository,
ClipDescriber clipDescriber
)
{
private readonly string clipDirectory = options.Value.ClipDirectory;
@@ -28,9 +29,11 @@ public sealed class ClipGenerator(
var clipId = Guid.CreateVersion7();
var clipPath = Path.Join(clipDirectory, $"{clipId}.mp4");
var description = await clipDescriber.DescribeClip(media, start, start + duration, cancellationToken);
await clipExtractor.ExtractClip(media, start, duration, clipPath, cancellationToken);
var clip = new Clip(clipId, media.Id, clipPath, start, duration, utcNow);
var clip = new Clip(clipId, media.Id, clipPath, start, duration, utcNow, description);
await clipRepository.Save(clip, cancellationToken);
return clip;
@@ -48,6 +51,7 @@ public static class ClipGeneratorServiceCollectionExtensions
services.TryAddSingleton(TimeProvider.System);
services.AddTransient<ClipGenerator>();
services.AddClipDescriber();
return services;
}
src/Domain/IFrameExtractor.cs
+15
-0
diff --git a/src/Domain/IFrameExtractor.cs b/src/Domain/IFrameExtractor.cs
new file mode 100644
index 0000000..453d03c
@@ -0,0 +1,15 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace Slopper.Domain;
public interface IFrameExtractor
{
Task<IReadOnlyList<byte[]>> ExtractFrames(
MediaItem media,
IReadOnlyList<TimeSpan> timestamps,
CancellationToken cancellationToken
);
}
src/Frontend/src/Clip.ts
+1
-0
diff --git a/src/Frontend/src/Clip.ts b/src/Frontend/src/Clip.ts
index 7fbf1a4..3763d97 100644
@@ -2,4 +2,5 @@ export interface Clip {
id: string;
duration: string;
createdAt: Date;
description: string | null;
}
src/Frontend/src/components/ClipItem.vue
+15
-0
diff --git a/src/Frontend/src/components/ClipItem.vue b/src/Frontend/src/components/ClipItem.vue
index bcf3922..a0c94ce 100644
@@ -36,11 +36,15 @@ onUnmounted(() => observer?.disconnect());
<template>
<div class="clip">
<video ref="videoEl" :src="`/api/clips/${clip.id}/stream`" loop playsinline muted></video>
<div v-if="clip.description" class="description">
<p>{{ clip.description }}</p>
</div>
</div>
</template>
<style scoped>
.clip {
position: relative;
height: 100dvh;
scroll-snap-align: start;
scroll-snap-stop: always;
@@ -51,4 +55,15 @@ video {
height: 100%;
object-fit: cover;
}
.description {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 1rem;
background: linear-gradient(to bottom, rgba(0, 0, 0, 0) 0%, rgba(0, 0, 0, 0.75) 1.2rem);
color: #ffffff;
line-height: 1.4;
}
</style>
src/Infrastructure/Ai/AiOptions.cs
+6
-3
diff --git a/src/Infrastructure/Ai/EmbeddingOptions.cs b/src/Infrastructure/Ai/AiOptions.cs
similarity index 70%
rename from src/Infrastructure/Ai/EmbeddingOptions.cs
rename to src/Infrastructure/Ai/AiOptions.cs
index 9f4aee1..a1b6f83 100644
@@ -4,10 +4,13 @@ using Microsoft.Extensions.Options;
namespace Slopper.Infrastructure.Ai;
internal sealed class EmbeddingOptions
internal sealed class AiOptions
{
[Required]
public required string Model { get; set; }
public required string EmbeddingModel { get; set; }
[Required]
public required string DescriptionModel { get; set; }
public string? BasicAuth { get; set; }
@@ -16,4 +19,4 @@ internal sealed class EmbeddingOptions
}
[OptionsValidator]
internal sealed partial class EmbeddingOptionsValidator : IValidateOptions<EmbeddingOptions>;
internal sealed partial class AiOptionsValidator : IValidateOptions<AiOptions>;
src/Infrastructure/Ai/ServiceCollectionExtensions.cs
+33
-13
diff --git a/src/Infrastructure/Ai/ServiceCollectionExtensions.cs b/src/Infrastructure/Ai/ServiceCollectionExtensions.cs
index c16a607..cb121b7 100644
@@ -1,3 +1,5 @@
using System;
using System.Net.Http;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
@@ -11,23 +13,41 @@ public static class ServiceCollectionExtensions
{
public IServiceCollection AddAi()
{
services.AddOptions<EmbeddingOptions>().BindConfiguration("Embedding").ValidateOnStart();
services.AddTransient<IValidateOptions<EmbeddingOptions>, EmbeddingOptionsValidator>();
services.AddOptions<AiOptions>().BindConfiguration("Ai").ValidateOnStart();
services.AddTransient<IValidateOptions<AiOptions>, AiOptionsValidator>();
services.AddHttpClient<OllamaApiClient, OllamaApiClient>(
(client, sp) =>
{
var options = sp.GetRequiredService<IOptions<EmbeddingOptions>>().Value;
client.BaseAddress = options.Endpoint;
if (options.BasicAuth is not null)
services
.AddHttpClient("Ollama")
.ConfigureHttpClient(
(sp, client) =>
{
client.DefaultRequestHeaders.Authorization = new("Basic", options.BasicAuth);
var options = sp.GetRequiredService<IOptions<AiOptions>>().Value;
client.BaseAddress = options.Endpoint;
client.Timeout = TimeSpan.FromMinutes(10);
if (options.BasicAuth is not null)
{
client.DefaultRequestHeaders.Authorization = new("Basic", options.BasicAuth);
}
}
return new OllamaApiClient(client, options.Model);
}
);
);
services
.AddEmbeddingGenerator(sp =>
{
var factory = sp.GetRequiredService<IHttpClientFactory>();
var options = sp.GetRequiredService<IOptions<AiOptions>>().Value;
return new OllamaApiClient(factory.CreateClient("Ollama"), options.EmbeddingModel);
})
.UseOpenTelemetry();
services.AddEmbeddingGenerator(sp => sp.GetRequiredService<OllamaApiClient>()).UseOpenTelemetry();
services
.AddChatClient(sp =>
{
var factory = sp.GetRequiredService<IHttpClientFactory>();
var options = sp.GetRequiredService<IOptions<AiOptions>>().Value;
return new OllamaApiClient(factory.CreateClient("Ollama"), options.DescriptionModel);
})
.UseOpenTelemetry();
return services;
}
src/Infrastructure/Database/Slopper/Migrations/20260509175844_AddClipDescription.Designer.cs
+55
-0
diff --git a/src/Infrastructure/Database/Slopper/Migrations/20260509175844_AddClipDescription.Designer.cs b/src/Infrastructure/Database/Slopper/Migrations/20260509175844_AddClipDescription.Designer.cs
new file mode 100644
index 0000000..ee29fa0
@@ -0,0 +1,55 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Slopper.Infrastructure.Database.Slopper;
#nullable disable
namespace Slopper.Infrastructure.Database.Slopper.Migrations
{
[DbContext(typeof(SlopperDbContext))]
[Migration("20260509175844_AddClipDescription")]
partial class AddClipDescription
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.7");
modelBuilder.Entity("Slopper.Domain.Clip", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Description")
.HasColumnType("TEXT");
b.Property<TimeSpan>("Duration")
.HasColumnType("TEXT");
b.Property<Guid>("MediaItemId")
.HasColumnType("TEXT");
b.Property<string>("Path")
.IsRequired()
.HasColumnType("TEXT");
b.Property<TimeSpan>("Start")
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("Clips");
});
#pragma warning restore 612, 618
}
}
}
src/Infrastructure/Database/Slopper/Migrations/20260509175844_AddClipDescription.cs
+22
-0
diff --git a/src/Infrastructure/Database/Slopper/Migrations/20260509175844_AddClipDescription.cs b/src/Infrastructure/Database/Slopper/Migrations/20260509175844_AddClipDescription.cs
new file mode 100644
index 0000000..f0eed04
@@ -0,0 +1,22 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Slopper.Infrastructure.Database.Slopper.Migrations
{
/// <inheritdoc />
public partial class AddClipDescription : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(name: "Description", table: "Clips", type: "TEXT", nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(name: "Description", table: "Clips");
}
}
}
src/Infrastructure/Database/Slopper/Migrations/SlopperDbContextModelSnapshot.cs
+3
-0
diff --git a/src/Infrastructure/Database/Slopper/Migrations/SlopperDbContextModelSnapshot.cs b/src/Infrastructure/Database/Slopper/Migrations/SlopperDbContextModelSnapshot.cs
index 6ad0f58..c7822b7 100644
@@ -26,6 +26,9 @@ namespace Slopper.Infrastructure.Database.Slopper.Migrations
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Description")
.HasColumnType("TEXT");
b.Property<TimeSpan>("Duration")
.HasColumnType("TEXT");
src/Infrastructure/Database/Slopper/SlopperDbContext.cs
+1
-0
diff --git a/src/Infrastructure/Database/Slopper/SlopperDbContext.cs b/src/Infrastructure/Database/Slopper/SlopperDbContext.cs
index 6edf3b2..a3af510 100644
@@ -14,5 +14,6 @@ internal sealed class SlopperDbContext(DbContextOptions options) : DbContext(opt
clipBuilder.Property(c => c.MediaItemId);
clipBuilder.Property(c => c.Path);
clipBuilder.Property(c => c.CreatedAt);
clipBuilder.Property(c => c.Description);
}
}
src/Infrastructure/Ffmpeg/ClipExtractor.cs
+0
-23
diff --git a/src/Infrastructure/Ffmpeg/ClipExtractor.cs b/src/Infrastructure/Ffmpeg/ClipExtractor.cs
index ad24f86..c9ad301 100644
@@ -158,26 +158,3 @@ internal sealed partial class ClipExtractor(ILogger<ClipExtractor> logger) : ICl
return SubtitleHardBurnOptions.Create(path).SetSubtitleIndex(subIndex);
}
}
file static class Extensions
{
extension(FFMpegArgumentOptions options)
{
public FFMpegArgumentOptions WithArgument(string argument) =>
options.WithArgument(new CustomArgument(argument));
}
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/FfmpegExtensions.cs
+27
-0
diff --git a/src/Infrastructure/Ffmpeg/FfmpegExtensions.cs b/src/Infrastructure/Ffmpeg/FfmpegExtensions.cs
new file mode 100644
index 0000000..4166503
@@ -0,0 +1,27 @@
using FFMpegCore;
using FFMpegCore.Arguments;
namespace Slopper.Infrastructure.Ffmpeg;
internal static class FfmpegExtensions
{
extension(FFMpegArgumentOptions options)
{
public FFMpegArgumentOptions WithArgument(string argument) =>
options.WithArgument(new CustomArgument(argument));
}
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;
}
}
}
internal sealed record CustomVideoFilterArgument(string Key, string Value) : IVideoFilterArgument;
src/Infrastructure/Ffmpeg/FrameExtractor.cs
+44
-0
diff --git a/src/Infrastructure/Ffmpeg/FrameExtractor.cs b/src/Infrastructure/Ffmpeg/FrameExtractor.cs
new file mode 100644
index 0000000..01bebfe
@@ -0,0 +1,44 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using FFMpegCore;
using FFMpegCore.Pipes;
using Microsoft.Extensions.Logging;
using Slopper.Domain;
namespace Slopper.Infrastructure.Ffmpeg;
internal sealed class FrameExtractor(ILogger<FrameExtractor> logger) : IFrameExtractor
{
public async Task<IReadOnlyList<byte[]>> ExtractFrames(
MediaItem media,
IReadOnlyList<TimeSpan> timestamps,
CancellationToken cancellationToken
)
{
var frames = new List<byte[]>(timestamps.Count);
foreach (var timestamp in timestamps)
{
using var stream = new MemoryStream();
var args = FFMpegArguments
.FromFileInput(media.Path, addArguments: options => options.Seek(timestamp))
.OutputToPipe(
new StreamPipeSink(stream),
addArguments: options =>
options
.WithArgument("-frames:v 1")
.WithVideoFilters(filter => filter.Scale(-1, 720))
.ForceFormat("image2pipe")
.WithArgument("-c:v png")
)
.CancellableThrough(cancellationToken);
using var activity = Tracing.StartExtractFrame(media.Path, timestamp);
logger.LogInformation("Running ffmpeg {FfmpegArguments}", args.Arguments);
await args.ProcessAsynchronously();
frames.Add(stream.ToArray());
}
return frames;
}
}
src/Infrastructure/Ffmpeg/ServiceCollectionExtensions.cs
+4
-1
diff --git a/src/Infrastructure/Ffmpeg/ServiceCollectionExtensions.cs b/src/Infrastructure/Ffmpeg/ServiceCollectionExtensions.cs
index 66f4427..fe08e30 100644
@@ -6,5 +6,8 @@ namespace Slopper.Infrastructure.Ffmpeg;
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddFfmpegServices(this IServiceCollection services) =>
services.AddTransient<IClipExtractor, ClipExtractor>().AddTransient<ISubtitleReader, SubtitleReader>();
services
.AddTransient<IClipExtractor, ClipExtractor>()
.AddTransient<ISubtitleReader, SubtitleReader>()
.AddTransient<IFrameExtractor, FrameExtractor>();
}
src/Infrastructure/Ffmpeg/Tracing.cs
+6
-0
diff --git a/src/Infrastructure/Ffmpeg/Tracing.cs b/src/Infrastructure/Ffmpeg/Tracing.cs
index 4d0e275..227178b 100644
@@ -26,4 +26,10 @@ internal static class Tracing
.StartActivity("ReadSubtitles", ActivityKind.Internal)
?.SetTag("Path", path)
?.SetTag("Index", index);
public static Activity? StartExtractFrame(string path, TimeSpan timestamp) =>
FfmpegActivity
.StartActivity("ExtractFrame", ActivityKind.Internal)
?.SetTag("Path", path)
?.SetTag("Timestamp", timestamp);
}