VideoStudy/VideoStudy.API/Services/AnalysisService.cs

281 lines
15 KiB
C#

using System.Diagnostics;
using System.Runtime.CompilerServices;
using System.Text.Json;
using System.Text.RegularExpressions;
using System.Threading;
using Microsoft.SemanticKernel;
using Microsoft.SemanticKernel.ChatCompletion;
using PuppeteerSharp;
using QuestPDF.Fluent;
using QuestPDF.Helpers;
using QuestPDF.Infrastructure;
using SkiaSharp;
using VideoStudy.Shared;
namespace VideoStudy.API.Services;
public class AnalysisService
{
private readonly Kernel _kernel;
private readonly ILogger<AnalysisService> _logger;
public AnalysisService(Kernel kernel, ILogger<AnalysisService> logger)
{
_kernel = kernel;
_logger = logger;
QuestPDF.Settings.License = LicenseType.Community;
Task.Run(async () => {
try
{
var browserFetcher = new BrowserFetcher();
await browserFetcher.DownloadAsync();
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to download Chromium.");
}
});
}
private string GetYtDlpPath()
{
if (System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform(System.Runtime.InteropServices.OSPlatform.Windows))
{
var exeName = "yt-dlp.exe";
var currentPath = Path.Combine(Directory.GetCurrentDirectory(), exeName);
if (File.Exists(currentPath)) return currentPath;
var basePath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, exeName);
if (File.Exists(basePath)) return basePath;
var binPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Binaries", exeName);
if (File.Exists(binPath)) return binPath;
return "yt-dlp";
}
else
{
var baseDir = AppDomain.CurrentDomain.BaseDirectory;
var binariesDir = Path.Combine(baseDir, "Binaries");
string executableName = "yt-dlp_linux";
if (System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform(System.Runtime.InteropServices.OSPlatform.OSX))
executableName = "yt-dlp_macos";
var fullPath = Path.Combine(binariesDir, executableName);
if (!File.Exists(fullPath)) return "yt-dlp";
try { Process.Start("chmod", $"+x \"{fullPath}\"").WaitForExit(); } catch { }
return fullPath;
}
}
public async IAsyncEnumerable<AnalysisEvent> AnalyzeVideoAsync(AnalysisRequest request, [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
var tempDir = Path.Combine(Path.GetTempPath(), "VideoStudy", Guid.NewGuid().ToString());
Directory.CreateDirectory(tempDir);
try
{
yield return new AnalysisEvent { ProgressPercentage = 5, Message = "Iniciando análise técnica..." };
var videoInfo = await GetVideoInfoAsync(request.VideoUrl, cancellationToken);
yield return new AnalysisEvent { ProgressPercentage = 10, Message = $"Processando: {videoInfo.Title}" };
yield return new AnalysisEvent { ProgressPercentage = 15, Message = "Obtendo transcrição..." };
var (transcript, _) = await GetTranscriptViaYtDlpAsync(request.VideoUrl, request.Language, tempDir);
if (string.IsNullOrWhiteSpace(transcript)) throw new Exception("Transcrição indisponível.");
yield return new AnalysisEvent { ProgressPercentage = 40, Message = "IA estruturando conteúdo e gerando resumo..." };
var (sections, rawJson, category, docTitle, summary) = await GenerateTutorialContentAsync(transcript, videoInfo, request.Language, request.OutputLanguage, cancellationToken);
var sectionsToCapture = sections.Where(s => !string.IsNullOrEmpty(s.ImageTimestamp)).ToList();
if (sectionsToCapture.Any())
{
yield return new AnalysisEvent { ProgressPercentage = 70, Message = $"Capturando {sectionsToCapture.Count} imagens críticas..." };
await CaptureScreenshotsInParallelAsync(request.VideoUrl, sectionsToCapture, videoInfo.Duration, cancellationToken);
}
yield return new AnalysisEvent { ProgressPercentage = 90, Message = "Gerando PDF..." };
var pdfBytes = GeneratePdf(docTitle, summary, request.VideoUrl, sections, category);
var result = new AnalysisResult
{
VideoTitle = videoInfo.Title,
DocumentTitle = docTitle,
Summary = summary,
Category = category,
Transcript = transcript,
TutorialSections = sections,
PdfData = pdfBytes,
RawLlmResponse = rawJson
};
yield return new AnalysisEvent { ProgressPercentage = 100, Message = "Concluído!", Result = result };
}
finally
{
if (Directory.Exists(tempDir)) try { Directory.Delete(tempDir, true); } catch { }
}
}
private async Task CaptureScreenshotsInParallelAsync(string videoUrl, List<TutorialSection> sections, TimeSpan videoDuration, CancellationToken ct)
{
var rawUrl = await GetRawVideoStreamUrl(videoUrl);
if (string.IsNullOrEmpty(rawUrl)) return;
using var sem = new SemaphoreSlim(3);
await Task.WhenAll(sections.Select(s => ProcessSingleSection(s, rawUrl, videoDuration, sem, ct)));
}
private async Task ProcessSingleSection(TutorialSection section, string rawUrl, TimeSpan duration, SemaphoreSlim sem, CancellationToken ct)
{
await sem.WaitAsync(ct);
try
{
using var browser = await Puppeteer.LaunchAsync(new LaunchOptions { Headless = true, Args = new[] { "--no-sandbox" } });
using var page = await browser.NewPageAsync();
await page.SetViewportAsync(new ViewPortOptions { Width = 1280, Height = 720 });
await page.SetContentAsync($"<html><body style='margin:0;background:black;'><video id='v' width='1280' height='720' src='{rawUrl}'></video></body></html>");
await page.WaitForSelectorAsync("#v");
if (!TimeSpan.TryParse(section.ImageTimestamp, out var ts)) return;
int target = (int)ts.TotalSeconds;
var candidates = new List<(byte[] Data, double Score)>();
foreach (var offset in new[] { 0, -1, 1 })
{
ct.ThrowIfCancellationRequested();
int time = Math.Max(0, target + offset);
if (time > duration.TotalSeconds) continue;
try {
await page.EvaluateFunctionAsync($"(s) => document.getElementById('v').currentTime = s", time);
await page.WaitForFunctionAsync("() => document.getElementById('v').readyState >= 3", new WaitForFunctionOptions { Timeout = 5000 });
var data = await page.ScreenshotDataAsync(new ScreenshotOptions { Type = ScreenshotType.Jpeg, Quality = 85 });
var score = CalculateImageClarityScore(data);
if (score > 10) candidates.Add((data, score));
} catch { }
}
if (candidates.Any()) section.ImageData = candidates.OrderByDescending(c => c.Score).First().Data;
}
finally { sem.Release(); }
}
public async Task<VideoInfo> GetVideoInfoAsync(string url, CancellationToken ct)
{
var path = GetYtDlpPath();
var proc = Process.Start(new ProcessStartInfo { FileName = path, Arguments = $"--print title --print channel --print duration --print thumbnail --print description \"{url}\"", RedirectStandardOutput = true, UseShellExecute = false, CreateNoWindow = true });
await proc!.WaitForExitAsync(ct);
var lines = (await proc.StandardOutput.ReadToEndAsync()).Split('\n', StringSplitOptions.RemoveEmptyEntries);
double.TryParse(lines.Length > 2 ? lines[2] : "0", System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out var sec);
return new VideoInfo { Title = lines[0].Trim(), Author = lines[1].Trim(), Duration = TimeSpan.FromSeconds(sec), ThumbnailUrl = lines[3].Trim(), Description = lines.Length > 4 ? string.Join("\n", lines.Skip(4)).Trim() : "", Url = url };
}
private async Task<(string, string)> GetTranscriptViaYtDlpAsync(string url, string lang, string dir)
{
var path = GetYtDlpPath();
var start = new ProcessStartInfo { FileName = path, Arguments = $"--skip-download --write-sub --write-auto-sub --sub-lang {lang},en --sub-format vtt --output \"%(id)s\" \"{url}\"", WorkingDirectory = dir, RedirectStandardOutput = true, UseShellExecute = false, CreateNoWindow = true };
using var p = Process.Start(start);
await p!.WaitForExitAsync();
var file = Directory.GetFiles(dir, "*.vtt").FirstOrDefault();
return file == null ? ("", "") : (ParseVttToText(await File.ReadAllTextAsync(file)), "");
}
private string ParseVttToText(string vtt)
{
var lines = vtt.Split('\n').Select(l => l.Trim()).Where(l => !string.IsNullOrEmpty(l) && !l.StartsWith("WEBVTT") && !l.StartsWith("NOTE") && !l.Contains("-->"));
return string.Join(" ", lines.Select(l => Regex.Replace(l, @"<[^>]*>", ""))).Replace(" ", " ");
}
private async Task<(List<TutorialSection> sections, string rawJson, string category, string docTitle, string summary)> GenerateTutorialContentAsync(string transcript, VideoInfo video, string inLang, string? outLang, CancellationToken ct)
{
var langMap = new Dictionary<string, string> { {"en", "English"}, {"pt", "Portuguese (Brazilian)"}, {"es", "Spanish"} };
var outName = langMap.GetValueOrDefault(outLang ?? inLang, "Portuguese (Brazilian)");
var dur = video.Duration.ToString(@"hh\:mm\:ss");
var prompt = $@"
Você é um ANALISTA TÉCNICO DE CONTEÚDO especializado em converter vídeos em documentação estruturada.
### REGRAS DE OURO:
1. NÃO RESUMA: Transforme cada explicação do vídeo em um tópico técnico detalhado e denso.
2. ISONOMIA: Dedique o mesmo nível de profundidade a todos os tópicos. Se o vídeo explica 5 curiosidades, gere 5 seções extensas.
3. FOCO NO TÍTULO: Garanta que o tema ""{video.Title}"" seja a seção de maior clareza e detalhamento.
4. SCREENSHOTS: Insira `[SCREENSHOT: HH:MM:SS]` ao final de parágrafos que descrevam algo visualmente importante. (Limite: {dur}).
### DADOS:
- Título: {video.Title}
- Descrição: {video.Description}
- Transcrição: {transcript[..Math.Min(transcript.Length, 25000)]}
### FORMATO DE SAÍDA (JSON):
{{
""category"": ""TUTORIAL | MEETING | LECTURE | OTHER"",
""shortTitle"": ""Título Curto e Limpo"",
""summary"": ""Um único parágrafo de até 4 linhas sintetizando o valor principal do vídeo."",
""sections"": [
{{ ""title"": ""Título do Tópico"", ""content"": ""Explicação técnica densa... [SCREENSHOT: HH:MM:SS]"" }}
]
}}
Escreva tudo em {outName}.";
var result = await _kernel.GetRequiredService<IChatCompletionService>().GetChatMessageContentAsync(prompt, cancellationToken: ct);
var json = Regex.Match(result.Content ?? "{}", @"\{[\s\S]*\}").Value;
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
var sections = root.GetProperty("sections").EnumerateArray().Select(el => new TutorialSection {
Title = el.GetProperty("title").GetString() ?? "",
Content = Regex.Replace(el.GetProperty("content").GetString() ?? "", @"\[SCREENSHOT: \d{2}:\d{2}:\d{2}\]", "").Trim(),
ImageTimestamp = Regex.Match(el.GetProperty("content").GetString() ?? "", @"\[SCREENSHOT:\s*(\d{2}:\d{2}:\d{2})\]").Groups[1].Value
}).ToList();
return (sections, json, root.GetProperty("category").GetString() ?? "OTHER", root.GetProperty("shortTitle").GetString() ?? video.Title, root.GetProperty("summary").GetString() ?? "");
}
private async Task<string?> GetRawVideoStreamUrl(string url)
{
var proc = Process.Start(new ProcessStartInfo { FileName = GetYtDlpPath(), Arguments = $"-g -f b \"{url}\"", RedirectStandardOutput = true, UseShellExecute = false, CreateNoWindow = true });
return (await proc!.StandardOutput.ReadLineAsync())?.Trim();
}
private double CalculateImageClarityScore(byte[] bytes)
{
try {
using var img = SKBitmap.Decode(bytes);
var lumas = new List<float>();
for (int y = 0; y < img.Height; y += 20) for (int x = 0; x < img.Width; x += 20) {
var p = img.GetPixel(x,y);
lumas.Add(p.Red * 0.299f + p.Green * 0.587f + p.Blue * 0.114f);
}
var avg = lumas.Average();
return Math.Sqrt(lumas.Sum(v => Math.Pow(v - avg, 2)) / lumas.Count);
} catch { return -1; }
}
private byte[] GeneratePdf(string title, string summary, string url, List<TutorialSection> sections, string category)
{
var color = category switch { "TUTORIAL" => Colors.Green.Medium, "MEETING" => Colors.Orange.Medium, _ => Colors.Blue.Medium };
return Document.Create(container => {
container.Page(page => {
page.Margin(2, Unit.Centimetre);
page.DefaultTextStyle(x => x.FontSize(11).FontFamily("Segoe UI"));
page.Header().Column(c => {
c.Item().Row(r => {
r.RelativeItem().Text(title).SemiBold().FontSize(22).FontColor(Colors.Blue.Darken3);
r.ConstantItem(80).AlignRight().Text(category).Bold().FontSize(10).FontColor(color);
});
c.Item().PaddingTop(5).LineHorizontal(1).LineColor(Colors.Grey.Lighten2);
});
page.Content().PaddingVertical(1, Unit.Centimetre).Column(col => {
// Resumo Section
col.Item().Background(Colors.Grey.Lighten4).Padding(10).Column(rc => {
rc.Item().Text("Resumo").Bold().FontSize(12).FontColor(Colors.Blue.Medium);
rc.Item().PaddingTop(2).Text(summary).Italic();
});
col.Item().PaddingTop(10).Text(t => { t.Span("Fonte: ").SemiBold(); t.Span(url).Italic().FontSize(9); });
foreach (var s in sections) {
col.Item().PaddingTop(20).Text(s.Title).Bold().FontSize(16).FontColor(color);
col.Item().PaddingTop(5).Text(s.Content).LineHeight(1.5f);
if (s.ImageData != null) col.Item().PaddingVertical(10).Image(s.ImageData).FitWidth();
}
});
page.Footer().AlignCenter().Text(x => { x.Span("VideoStudy.app - "); x.CurrentPageNumber(); });
});
}).GeneratePdf();
}
}