📄 src/Domain/ClipDescriber.cs
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;
        }
    }
}