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 _logger; public AnalysisService(Kernel kernel, ILogger 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 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 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($""); 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 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 sections, string rawJson, string category, string docTitle, string summary)> GenerateTutorialContentAsync(string transcript, VideoInfo video, string inLang, string? outLang, CancellationToken ct) { var langMap = new Dictionary { {"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().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 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(); 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 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(); } }