diff --git a/src/StreamShorts.Console/Program.cs b/src/StreamShorts.Console/Program.cs index 9b74e8d..991ddfb 100644 --- a/src/StreamShorts.Console/Program.cs +++ b/src/StreamShorts.Console/Program.cs @@ -22,6 +22,7 @@ try services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); }) .BuildApp() .RunAsync(args); @@ -37,105 +38,3 @@ finally { await Log.CloseAndFlushAsync(); } - -// if (string.IsNullOrWhiteSpace(apiKey)) -// { -// throw new InvalidOperationException("GeminiApiKey is not configured in appsettings.json."); -// } - -// using var client = new HttpClient() -// { -// Timeout = TimeSpan.FromMinutes(30) -// }; - -// var requestUrl = $"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-lite:generateContent?key={apiKey}"; -// using var request = new HttpRequestMessage(HttpMethod.Post, requestUrl) -// { -// Content = new StringContent( -// JsonSerializer.Serialize(new -// { -// contents = new[] -// { -// new -// { -// role = "user", -// parts = new[] -// { -// new -// { -// text = prompt -// } -// }, -// } -// }, -// generationConfig = new -// { -// responseMimeType = "application/json", -// } -// }), -// Encoding.UTF8, -// "application/json" -// ) -// }; -// var response = await client.SendAsync(request); -// var responseContent = await response.Content.ReadAsStringAsync(); -// var responseJson = JsonSerializer.Deserialize(responseContent); -// var candidatesText = responseJson? -// .Candidates? -// .FirstOrDefault()? -// .Content -// .Parts?.FirstOrDefault()? -// .Text; -// var analysis = JsonSerializer.Deserialize>(candidatesText ?? string.Empty); - -// if (analysis is null) -// { -// Console.WriteLine(resourceManager.GetString("LLMAnalysisFailed", CultureInfo.CurrentCulture)); -// return; -// } - -// foreach (var result in analysis) -// { -// var fileName = string.Concat(result.Title.Split(Path.GetInvalidFileNameChars())); -// await FFMpeg.SubVideoAsync( -// args[0], -// $"{fileName}.mp4", -// result.StartTime, -// result.EndTime -// ); -// } - -// // Step 6: Use analysis to generate a short video - -// record LLMAnalysis( -// [property: JsonPropertyName("title")] -// string Title, -// [property: JsonPropertyName("description")] -// string Description, -// [property: JsonPropertyName("explanation")] -// string Explanation, -// [property: JsonPropertyName("start_time")] -// TimeSpan StartTime, -// [property: JsonPropertyName("end_time")] -// TimeSpan EndTime -// ); - -// record LLMResponse( -// [property: JsonPropertyName("candidates")] -// Candidate[] Candidates -// ); - -// record Candidate( -// [property: JsonPropertyName("content")] -// Content Content -// ); - -// record Content( -// [property: JsonPropertyName("parts")] -// Part[] Parts -// ); - -// record Part( -// [property: JsonPropertyName("text")] -// string Text -// ); \ No newline at end of file diff --git a/src/StreamShorts.Console/Usings.cs b/src/StreamShorts.Console/Usings.cs index f3aee19..fd2723d 100644 --- a/src/StreamShorts.Console/Usings.cs +++ b/src/StreamShorts.Console/Usings.cs @@ -15,6 +15,7 @@ global using Spectre.Console.Cli; global using StreamShorts.Console.Commands; global using StreamShorts.Console.Hosting; +global using StreamShorts.Library.Analysis; +global using StreamShorts.Library.Analysis.Gemini; global using StreamShorts.Library.Media.Audio; global using StreamShorts.Library.Transcription; -global using StreamShorts.Library.Analysis; \ No newline at end of file diff --git a/src/StreamShorts.Library/Analysis/Gemini/Content.cs b/src/StreamShorts.Library/Analysis/Gemini/Content.cs new file mode 100644 index 0000000..66615a9 --- /dev/null +++ b/src/StreamShorts.Library/Analysis/Gemini/Content.cs @@ -0,0 +1,13 @@ +using System.Text.Json.Serialization; + +internal record Content( + [property: JsonPropertyName("role")] + string Role, + [property: JsonPropertyName("parts")] + Part[] Parts +); + +internal record Part( + [property: JsonPropertyName("text")] + string Text +); \ No newline at end of file diff --git a/src/StreamShorts.Library/Analysis/GeminiAnalyzer.cs b/src/StreamShorts.Library/Analysis/Gemini/GeminiAnalyzer.cs similarity index 53% rename from src/StreamShorts.Library/Analysis/GeminiAnalyzer.cs rename to src/StreamShorts.Library/Analysis/Gemini/GeminiAnalyzer.cs index 6282f83..d5f23ce 100644 --- a/src/StreamShorts.Library/Analysis/GeminiAnalyzer.cs +++ b/src/StreamShorts.Library/Analysis/Gemini/GeminiAnalyzer.cs @@ -1,88 +1,72 @@ using System.Text; using System.Text.Json; -using System.Text.Json.Serialization; - -using NAudio.CoreAudioApi; +using StreamShorts.Library.Analysis.Prompts; using StreamShorts.Library.Transcription; -namespace StreamShorts.Library.Analysis; +namespace StreamShorts.Library.Analysis.Gemini; +/// +/// Represents an analyzer that uses Gemini to analyze transcript segments and generate short clips. +/// +/// public sealed class GeminiAnalyzer( IHttpClientFactory httpClientFactory, string apiKey -) : ITranscriptAnalyzer + ) : ITranscriptAnalyzer { private readonly IHttpClientFactory _httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory)); private readonly string _apiKey = apiKey ?? throw new ArgumentNullException(nameof(apiKey)); + private readonly IAnalysisPrompt _prompt = new DefaultAnalysisPrompt(); + + public GeminiAnalyzer( + IHttpClientFactory httpClientFactory, + string apiKey, + IAnalysisPrompt prompt + ) : this(httpClientFactory, apiKey) + { + _prompt = prompt ?? throw new ArgumentNullException(nameof(prompt)); + } public async Task AnalyzeAsync(IEnumerable segments) { using var client = _httpClientFactory.CreateClient(); client.Timeout = TimeSpan.FromMinutes(5); - // TODO: Load prompt from resource file var requestUrl = $"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-lite:generateContent?key={_apiKey}"; + var generateContentRequest = new GenerateContentRequest( + [ + new Content( + Role: "user", + Parts:[ new Part(Text: _prompt.GetPrompt(segments)) ] + ) + ], + new GenerationConfig(ResponseMimeType: "application/json") + ); + using var requestContent = new StringContent( + JsonSerializer.Serialize(generateContentRequest), + Encoding.UTF8, + "application/json" + ); using var request = new HttpRequestMessage(HttpMethod.Post, requestUrl) { - Content = new StringContent( - JsonSerializer.Serialize(new - { - contents = new[] - { - new - { - role = "user", - parts = new[] - { - new - { - text = prompt - } - }, - } - }, - generationConfig = new - { - responseMimeType = "application/json", - } - }), - Encoding.UTF8, - "application/json" - ) + Content = requestContent }; + var response = await client.SendAsync(request).ConfigureAwait(false); var responseContent = await response.Content.ReadAsStringAsync().ConfigureAwait(false); - var responseJson = JsonSerializer.Deserialize(responseContent); + var responseJson = JsonSerializer.Deserialize(responseContent); var candidatesText = responseJson? .Candidates? .FirstOrDefault()? .Content .Parts?.FirstOrDefault()? .Text; - + var clips = JsonSerializer.Deserialize>(candidatesText ?? string.Empty); return new TranscriptAnalysis(clips ?? []); } } -// TODO: Sort this shit out -record LLMResponse( - [property: JsonPropertyName("candidates")] - Candidate[] Candidates -); -record Candidate( - [property: JsonPropertyName("content")] - Content Content -); -record Content( - [property: JsonPropertyName("parts")] - Part[] Parts -); - -record Part( - [property: JsonPropertyName("text")] - string Text -); diff --git a/src/StreamShorts.Library/Analysis/Gemini/GenerateContentRequest.cs b/src/StreamShorts.Library/Analysis/Gemini/GenerateContentRequest.cs new file mode 100644 index 0000000..f4bd08e --- /dev/null +++ b/src/StreamShorts.Library/Analysis/Gemini/GenerateContentRequest.cs @@ -0,0 +1,15 @@ +using System.Text.Json.Serialization; + +namespace StreamShorts.Library.Analysis.Gemini; + +internal record GenerateContentRequest( + [property: JsonPropertyName("contents")] + Content[] Contents, + [property: JsonPropertyName("generationConfig")] + GenerationConfig GenerationConfig +); + +internal record GenerationConfig( + [property: JsonPropertyName("responseMimeType")] + string ResponseMimeType +); \ No newline at end of file diff --git a/src/StreamShorts.Library/Analysis/Gemini/GenerateContentResponse.cs b/src/StreamShorts.Library/Analysis/Gemini/GenerateContentResponse.cs new file mode 100644 index 0000000..4a45668 --- /dev/null +++ b/src/StreamShorts.Library/Analysis/Gemini/GenerateContentResponse.cs @@ -0,0 +1,14 @@ +using System.Text.Json.Serialization; + +namespace StreamShorts.Library.Analysis.Gemini; + +internal record GenerateContentResponse( + [property: JsonPropertyName("candidates")] + Candidate[] Candidates +); + +internal record Candidate( + [property: JsonPropertyName("content")] + Content Content +); + diff --git a/src/StreamShorts.Library/Analysis/ITranscriptAnalyzer.cs b/src/StreamShorts.Library/Analysis/ITranscriptAnalyzer.cs index f9ce38e..3e51ac5 100644 --- a/src/StreamShorts.Library/Analysis/ITranscriptAnalyzer.cs +++ b/src/StreamShorts.Library/Analysis/ITranscriptAnalyzer.cs @@ -2,7 +2,15 @@ using StreamShorts.Library.Transcription; namespace StreamShorts.Library.Analysis; +/// +/// Defines the contract for transcript analyzers that process segments of a transcript and produce an analysis result containing short clips. +/// public interface ITranscriptAnalyzer { + /// + /// Analyzes the provided transcript segments and generates a transcript analysis result. + /// + /// The transcript segments to analyze. + /// A task that represents the asynchronous operation. The task result contains the containing the short clips derived from the transcript. Task AnalyzeAsync(IEnumerable segments); } \ No newline at end of file diff --git a/src/StreamShorts.Library/Analysis/Prompts/DefaultAnalysisPrompt.cs b/src/StreamShorts.Library/Analysis/Prompts/DefaultAnalysisPrompt.cs new file mode 100644 index 0000000..1d174a3 --- /dev/null +++ b/src/StreamShorts.Library/Analysis/Prompts/DefaultAnalysisPrompt.cs @@ -0,0 +1,49 @@ +using System.Globalization; +using System.Text; + +using StreamShorts.Library.Transcription; + +namespace StreamShorts.Library.Analysis.Prompts; + +/// +/// Default implementation of the analysis prompt for generating YouTube Shorts. +/// +/// +internal sealed class DefaultAnalysisPrompt : IAnalysisPrompt +{ + private static readonly CompositeFormat Prompt = CompositeFormat.Parse(@" + I need your help to transform my YouTube live stream transcript into engaging YouTube Shorts. Act as my content editor and pinpoint **all potential candidate segments** that are perfect for short-form video. I'm looking for clips that are: + + - **Funny:** Moments that will make viewers laugh. + - **Informative:** Sections packed with valuable information or tips. + - **Insightful:** Portions offering unique perspectives or 'aha!' moments. + + For each suggested short, please provide: + + - The **start time** of the initial segment and the **end time** of the final segment. The duration of each short should be no longer than 3 minutes, but **aim for durations between 15 seconds and 60 seconds**. However, the short **must be as long as necessary to capture the complete thought or idea**, even if it means exceeding the target range or extending slightly to capture all necessary dialogue. + - A concise **title** that grabs attention. + - A brief **description** highlighting the short's content and its appeal. + - An **explanation** of why this particular segment is suitable for a YouTube Short, focusing on its potential for discoverability and engagement. + + Please format your response as a JSON array of objects with the following structure: + + ```json + {{ + ""title"": ""string"", + ""start_time"": ""string"", + ""end_time"": ""string"", + ""description"": ""string"", + ""explanation"": ""string"" + }} + ``` + + Here is the transcript of my YouTube live stream: + + {0} + "); + + public string GetPrompt(IEnumerable transcript) + { + return string.Format(CultureInfo.InvariantCulture, Prompt, transcript); + } +} \ No newline at end of file diff --git a/src/StreamShorts.Library/Analysis/Prompts/IAnalysisPrompt.cs b/src/StreamShorts.Library/Analysis/Prompts/IAnalysisPrompt.cs new file mode 100644 index 0000000..46fdcf1 --- /dev/null +++ b/src/StreamShorts.Library/Analysis/Prompts/IAnalysisPrompt.cs @@ -0,0 +1,16 @@ +using StreamShorts.Library.Transcription; + +namespace StreamShorts.Library.Analysis.Prompts; + +/// +/// Defines the contract for analysis prompts used in transcript analysis. +/// +public interface IAnalysisPrompt +{ + /// + /// Generates a prompt based on the provided transcript segments. + /// + /// The transcript segments to analyze. + /// A formatted prompt string for analysis. + string GetPrompt(IEnumerable transcript); +} \ No newline at end of file diff --git a/src/StreamShorts.Library/Analysis/ShortClip.cs b/src/StreamShorts.Library/Analysis/ShortClip.cs index 8582caa..3f8901b 100644 --- a/src/StreamShorts.Library/Analysis/ShortClip.cs +++ b/src/StreamShorts.Library/Analysis/ShortClip.cs @@ -2,6 +2,9 @@ using System.Text.Json.Serialization; namespace StreamShorts.Library.Analysis; +/// +/// Represents a short clip derived from a transcript +/// public record ShortClip( [property: JsonPropertyName("title")] string Title, diff --git a/src/StreamShorts.Library/Analysis/TranscriptAnalysis.cs b/src/StreamShorts.Library/Analysis/TranscriptAnalysis.cs index 56ead05..87172c2 100644 --- a/src/StreamShorts.Library/Analysis/TranscriptAnalysis.cs +++ b/src/StreamShorts.Library/Analysis/TranscriptAnalysis.cs @@ -1,6 +1,12 @@ namespace StreamShorts.Library.Analysis; -public class TranscriptAnalysis(IEnumerable shortClips) +/// +/// Represents the analysis of a transcript, containing short clips derived from the transcript. +/// +public sealed class TranscriptAnalysis(IEnumerable shortClips) { + /// + /// Gets the short clips derived from the transcript. + /// public IEnumerable ShortClips { get; init; } = shortClips; } \ No newline at end of file diff --git a/src/StreamShorts.Library/Media/FFMpegService.cs b/src/StreamShorts.Library/Media/FFMpegService.cs index 3c7411a..b2e9a7d 100644 --- a/src/StreamShorts.Library/Media/FFMpegService.cs +++ b/src/StreamShorts.Library/Media/FFMpegService.cs @@ -1,5 +1,3 @@ -using System.Diagnostics.CodeAnalysis; - using FFMpegCore; using FFMpegCore.Enums; using FFMpegCore.Pipes; diff --git a/src/StreamShorts.Library/Media/IAudioService.cs b/src/StreamShorts.Library/Media/IAudioService.cs index 8711fe7..5410c57 100644 --- a/src/StreamShorts.Library/Media/IAudioService.cs +++ b/src/StreamShorts.Library/Media/IAudioService.cs @@ -1,4 +1,3 @@ - namespace StreamShorts.Library.Media; /// diff --git a/src/StreamShorts.Library/Resources/DefaultAnalysisPrompt.md b/src/StreamShorts.Library/Resources/DefaultAnalysisPrompt.md deleted file mode 100644 index 5c47082..0000000 --- a/src/StreamShorts.Library/Resources/DefaultAnalysisPrompt.md +++ /dev/null @@ -1,28 +0,0 @@ -I need your help to transform my YouTube live stream transcript into engaging YouTube Shorts. Act as my content editor and pinpoint **all potential candidate segments** that are perfect for short-form video. I'm looking for clips that are: - -  - **Funny:** Moments that will make viewers laugh. -  - **Informative:** Sections packed with valuable information or tips. -  - **Insightful:** Portions offering unique perspectives or 'aha\!' moments. - -For each suggested short, please provide: - -  - The **start time** of the initial segment and the **end time** of the final segment. The duration of each short should be no longer than 3 minutes, but **aim for durations between 15 seconds and 60 seconds**. However, the short **must be as long as necessary to capture the complete thought or idea**, even if it means exceeding the target range or extending slightly to capture all necessary dialogue. -  - A concise **title** that grabs attention. -  - A brief **description** highlighting the short's content and its appeal. -  - An **explanation** of why this particular segment is suitable for a YouTube Short, focusing on its potential for discoverability and engagement. - -Please format your response as a JSON array of objects with the following structure: - -```json -{ -  "title": "string", -  "start_time": "string", -  "end_time": "string", -  "description": "string", -  "explanation": "string" -} -``` - -Here is the transcript of my YouTube live stream: - -{0} diff --git a/src/StreamShorts.Library/Transcription/ITranscriber.cs b/src/StreamShorts.Library/Transcription/ITranscriber.cs index 39572bd..207c854 100644 --- a/src/StreamShorts.Library/Transcription/ITranscriber.cs +++ b/src/StreamShorts.Library/Transcription/ITranscriber.cs @@ -1,5 +1,3 @@ -using System.Runtime.CompilerServices; - namespace StreamShorts.Library.Transcription; ///