From 7417f1f6c65b2eda7f6f5a7a18c9f02c3812f120 Mon Sep 17 00:00:00 2001 From: Ricardo Carneiro Date: Tue, 10 Feb 2026 16:13:31 -0300 Subject: [PATCH] fix: Ajustes de aparencia do PDF --- VideoStudy.API/Program.cs | 6 +- VideoStudy.API/Services/AnalysisService.cs | 686 +++++------------- .../Components/Pages/Home.razor | 129 ++-- VideoStudy.Shared/Models.cs | 43 ++ VideoStudy.UI/Pages/Home.razor | 92 ++- 5 files changed, 342 insertions(+), 614 deletions(-) diff --git a/VideoStudy.API/Program.cs b/VideoStudy.API/Program.cs index 2f15354..84baf47 100644 --- a/VideoStudy.API/Program.cs +++ b/VideoStudy.API/Program.cs @@ -97,7 +97,7 @@ app.MapGet("/api/video-info", async (string url, AnalysisService service) => { try { - var info = await service.GetVideoInfoAsync(url); + var info = await service.GetVideoInfoAsync(url, CancellationToken.None); return Results.Ok(info); } catch (Exception ex) @@ -108,9 +108,9 @@ app.MapGet("/api/video-info", async (string url, AnalysisService service) => .WithName("GetVideoInfo"); // Main analysis endpoint -app.MapPost("/api/analyze", async (AnalysisRequest request, AnalysisService service) => +app.MapPost("/api/analyze", (AnalysisRequest request, AnalysisService service, CancellationToken cancellationToken) => { - return await service.AnalyzeVideoAsync(request); + return service.AnalyzeVideoAsync(request, cancellationToken); }) .WithName("AnalyzeVideo"); diff --git a/VideoStudy.API/Services/AnalysisService.cs b/VideoStudy.API/Services/AnalysisService.cs index 91566fc..1e5123c 100644 --- a/VideoStudy.API/Services/AnalysisService.cs +++ b/VideoStudy.API/Services/AnalysisService.cs @@ -1,16 +1,16 @@ using System.Diagnostics; -using System.Text.RegularExpressions; +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 PuppeteerSharp; -using VideoStudy.Shared; using SkiaSharp; -using System.Linq; -using System.Security.Cryptography; // Added for MD5 hash +using VideoStudy.Shared; namespace VideoStudy.API.Services; @@ -18,20 +18,18 @@ public class AnalysisService { private readonly Kernel _kernel; private readonly ILogger _logger; - private readonly List _debugSteps = new(); public AnalysisService(Kernel kernel, ILogger logger) { _kernel = kernel; _logger = logger; QuestPDF.Settings.License = LicenseType.Community; - + Task.Run(async () => { - try + try { var browserFetcher = new BrowserFetcher(); await browserFetcher.DownloadAsync(); - _logger.LogInformation("Chromium ready."); } catch (Exception ex) { @@ -40,12 +38,6 @@ public class AnalysisService }); } - private void AddLog(string message) - { - _logger.LogInformation(message); - _debugSteps.Add($"[{DateTime.Now:HH:mm:ss}] {message}"); - } - private string GetYtDlpPath() { if (System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform(System.Runtime.InteropServices.OSPlatform.Windows)) @@ -73,559 +65,217 @@ public class AnalysisService } } - public async Task GetVideoInfoAsync(string url) + public async IAsyncEnumerable AnalyzeVideoAsync(AnalysisRequest request, [EnumeratorCancellation] CancellationToken cancellationToken = default) { - var ytDlpPath = GetYtDlpPath(); - var startInfo = new ProcessStartInfo - { - FileName = ytDlpPath, - Arguments = $"--print title --print channel --print duration --print thumbnail \"{url}\"", - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true - }; - - using var proc = Process.Start(startInfo) ?? throw new Exception("Failed to start yt-dlp"); - var output = await proc.StandardOutput.ReadToEndAsync(); - var error = await proc.StandardError.ReadToEndAsync(); - await proc.WaitForExitAsync(); - - if (proc.ExitCode != 0) - throw new Exception($"yt-dlp error: {error}"); - - var lines = output.Trim().Split('\n', StringSplitOptions.RemoveEmptyEntries); - var title = lines.Length > 0 ? lines[0].Trim() : "Unknown"; - var channel = lines.Length > 1 ? lines[1].Trim() : "Unknown"; - var durationStr = lines.Length > 2 ? lines[2].Trim() : "0"; - var thumbnail = lines.Length > 3 ? lines[3].Trim() : ""; - - double.TryParse(durationStr, System.Globalization.NumberStyles.Any, - System.Globalization.CultureInfo.InvariantCulture, out var durationSeconds); - - return new VideoInfo - { - Title = title, - Author = channel, - Duration = TimeSpan.FromSeconds(durationSeconds), - Url = url, - ThumbnailUrl = thumbnail - }; - } - - public async Task AnalyzeVideoAsync(AnalysisRequest request) - { - _debugSteps.Clear(); var tempDir = Path.Combine(Path.GetTempPath(), "VideoStudy", Guid.NewGuid().ToString()); Directory.CreateDirectory(tempDir); - AddLog($"📁 Inciando processamento em: {tempDir}"); - - string rawLlmResponse = ""; - - // Get video info early to use duration for timestamp validation - AddLog("ℹ️ Obtendo informações do vídeo para validação de timestamps..."); - var videoInfo = await GetVideoInfoAsync(request.VideoUrl); - AddLog($"✅ Duração do vídeo (via yt-dlp): {videoInfo.Duration:hh\\:mm\\:ss}"); try { - // --- Step 1: Transcription --- - AddLog("🌐 Obtendo transcrição via yt-dlp..."); - var (transcript, originalTitle) = await GetTranscriptViaYtDlpAsync(request.VideoUrl, request.Language, tempDir); - - if (string.IsNullOrWhiteSpace(transcript)) - throw new Exception("Não foi possível obter a transcrição do vídeo."); + yield return new AnalysisEvent { ProgressPercentage = 5, Message = "Iniciando análise técnica..." }; - AddLog($"✅ Transcrição obtida: '{originalTitle}' ({transcript.Length} chars)."); + var videoInfo = await GetVideoInfoAsync(request.VideoUrl, cancellationToken); + yield return new AnalysisEvent { ProgressPercentage = 10, Message = $"Processando: {videoInfo.Title}" }; - // --- Step 2: Intelligence --- - AddLog("🧠 Enviando transcrição para o Groq (LLM)..."); - var (tutorialSections, rawJson, category, docTitle) = await GenerateTutorialContentAsync(transcript, originalTitle, request.Language, request.OutputLanguage, videoInfo.Duration); - rawLlmResponse = rawJson; - - // Save debug MD - var debugFile = Path.Combine(Directory.GetCurrentDirectory(), "DEBUG_LAST_RESPONSE.md"); - var debugContent = $"# {docTitle} ({category})\n\nSource: {originalTitle}\n\n## Raw JSON\n```json\n{rawJson}\n```\n"; - await File.WriteAllTextAsync(debugFile, debugContent); - AddLog($"📝 Arquivo de debug gerado: {debugFile}"); + 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."); - // --- Validate and adjust Image Timestamps --- - AddLog("⏳ Validando timestamps de imagem gerados pela IA..."); - foreach (var section in tutorialSections) + 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()) { - if (TimeSpan.TryParse(section.ImageTimestamp, out var imageTs)) - { - // Allow for a small buffer (e.g., 5 seconds) just in case, but prefer strict bounds. - // If timestamp is more than 5 seconds past video end, consider it invalid. - if (imageTs.TotalSeconds > videoInfo.Duration.TotalSeconds + 5) - { - AddLog($" ⚠️ Timestamp de imagem ({section.ImageTimestamp}) para '{section.Title}' excede a duração do vídeo ({videoInfo.Duration:hh\\:mm\\:ss}). Ajustando para null para evitar erros de captura."); - section.ImageTimestamp = null; // Set to null to skip screenshot for this section - } - } - } - AddLog("✅ Validação de timestamps concluída."); - - // --- Step 3: Image Capture --- - var sectionsWithImages = tutorialSections.Where(s => !string.IsNullOrEmpty(s.ImageTimestamp)).ToList(); - if (sectionsWithImages.Any()) - { - AddLog($"📸 Capturando {sectionsWithImages.Count} prints usando Puppeteer (Direct Bypass)..."); - // Pass videoInfo.Duration to the screenshot method - await CaptureScreenshotsWithPuppeteerAsync(request.VideoUrl, tutorialSections, tempDir, videoInfo.Duration); - } - else - { - AddLog("⚠️ Nenhuma tag [SCREENSHOT] foi gerada pela IA, ou todas foram invalidadas."); + yield return new AnalysisEvent { ProgressPercentage = 70, Message = $"Capturando {sectionsToCapture.Count} imagens críticas..." }; + await CaptureScreenshotsInParallelAsync(request.VideoUrl, sectionsToCapture, videoInfo.Duration, cancellationToken); } - // --- Step 4: PDF Generation --- - AddLog("📄 Gerando PDF final com QuestPDF..."); - var pdfBytes = GeneratePdf(docTitle, request.VideoUrl, tutorialSections, category); + yield return new AnalysisEvent { ProgressPercentage = 90, Message = "Gerando PDF..." }; + var pdfBytes = GeneratePdf(docTitle, summary, request.VideoUrl, sections, category); - AddLog("🎉 Processamento concluído com sucesso!"); - - return new AnalysisResponse + var result = new AnalysisResult { - Status = "success", - VideoTitle = originalTitle, + VideoTitle = videoInfo.Title, DocumentTitle = docTitle, + Summary = summary, Category = category, Transcript = transcript, - TutorialSections = tutorialSections, + TutorialSections = sections, PdfData = pdfBytes, - DebugSteps = new List(_debugSteps), - RawLlmResponse = rawLlmResponse, - Analysis = "Tutorial gerado com sucesso!" - }; - } - catch (Exception ex) - { - AddLog($"❌ ERRO: {ex.Message}"); - return new AnalysisResponse - { - Status = "error", - ErrorMessage = ex.Message, - DebugSteps = new List(_debugSteps), - RawLlmResponse = rawLlmResponse + RawLlmResponse = rawJson }; + + yield return new AnalysisEvent { ProgressPercentage = 100, Message = "Concluído!", Result = result }; } finally { - if (Directory.Exists(tempDir)) - { - try { Directory.Delete(tempDir, true); } catch { } - } + if (Directory.Exists(tempDir)) try { Directory.Delete(tempDir, true); } catch { } } } - private async Task<(string transcript, string title)> GetTranscriptViaYtDlpAsync(string url, string language, string outputDir) + private async Task CaptureScreenshotsInParallelAsync(string videoUrl, List sections, TimeSpan videoDuration, CancellationToken ct) { - var ytDlpPath = GetYtDlpPath(); - // Use a safe output template to avoid filesystem issues, but we want the title. - // Better: Fetch title separately or read metadata json. - // Let's stick to filename trick but ensure safe chars. - // Actually, just fetch title with --print title - - // 1. Fetch Title - var titleStartInfo = new ProcessStartInfo - { - FileName = ytDlpPath, - Arguments = $"--print title \"{url}\"", - RedirectStandardOutput = true, - UseShellExecute = false, - CreateNoWindow = true - }; - var pTitle = Process.Start(titleStartInfo); - var title = (await pTitle!.StandardOutput.ReadToEndAsync()).Trim(); - await pTitle.WaitForExitAsync(); - if (string.IsNullOrEmpty(title)) title = "Video Analysis"; - - // 2. Fetch Subs - var arguments = $"--skip-download --write-sub --write-auto-sub --sub-lang {language},en --sub-format vtt --output \"%(id)s\" \"{url}\""; - - var startInfo = new ProcessStartInfo - { - FileName = ytDlpPath, - Arguments = arguments, - WorkingDirectory = outputDir, - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true - }; - - using var proc = Process.Start(startInfo); - await proc.WaitForExitAsync(); - - var vttFile = Directory.GetFiles(outputDir, "*.vtt").FirstOrDefault(); - if (vttFile == null) return (string.Empty, title); - - return (ParseVttToText(await File.ReadAllTextAsync(vttFile)), title); + 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 string ParseVttToText(string vttContent) + private async Task ProcessSingleSection(TutorialSection section, string rawUrl, TimeSpan duration, SemaphoreSlim sem, CancellationToken ct) { - var lines = vttContent.Split('\n'); - var textLines = new List(); - var seen = new HashSet(); - - foreach (var line in lines) - { - var l = line.Trim(); - if (string.IsNullOrWhiteSpace(l) || l.StartsWith("WEBVTT") || l.StartsWith("NOTE") || l.Contains("-->")) continue; - l = Regex.Replace(l, @"<[^>]*>", ""); - if (!seen.Contains(l)) { textLines.Add(l); seen.Add(l); } - } - return string.Join(" ", textLines); - } - - private async Task<(List sections, string rawJson, string category, string docTitle)> GenerateTutorialContentAsync(string transcript, string originalTitle, string inputLanguage, string? outputLanguage, TimeSpan videoDuration) - { - var langMap = new Dictionary - { - {"en", "English"}, {"pt", "Portuguese (Brazilian)"}, {"es", "Spanish"}, - {"fr", "French"}, {"de", "German"}, {"it", "Italian"}, - {"ja", "Japanese"}, {"ko", "Korean"}, {"zh", "Chinese"} - }; - var outputLang = string.IsNullOrWhiteSpace(outputLanguage) ? inputLanguage : outputLanguage; - var outputLangName = langMap.GetValueOrDefault(outputLang, outputLang); - - var chatService = _kernel.GetRequiredService(); - - // Pre-format video duration strings to avoid potential issues in interpolated string - string formattedVideoDuration = videoDuration.ToString("hh\\:mm\\:ss", System.Globalization.CultureInfo.InvariantCulture); - string totalSecondsVideoDuration = videoDuration.TotalSeconds.ToString(System.Globalization.CultureInfo.InvariantCulture); - - - var prompt = $@" -Você é um Editor Chefe e Analista de Conteúdo Sênior. -Receberá: -A) TÍTULO ORIGINAL: {originalTitle} -B) TRANSCRIÇÃO: {transcript[..Math.Min(transcript.Length, 20000)]} -C) DURAÇÃO TOTAL DO VÍDEO: {formattedVideoDuration} ({totalSecondsVideoDuration} segundos) - -SUA MISSÃO: -1. **Classificar** o vídeo em: 'TUTORIAL', 'MEETING', 'LECTURE' ou 'OTHER'. -2. **Criar um Título Profissional**: - - Use o TÍTULO ORIGINAL como base. - - Remova clickbaits, emojis e CAPS LOCK excessivo. - - O título deve parecer o de um documento técnico ou ata oficial. -3. **Estruturar o Conteúdo**: - - Converta o conteúdo em um texto educativo e denso. - - Identifique momentos visuais críticos e insira `[SCREENSHOT: HH:MM:SS]` no final do parágrafo correspondente. - - **IMPORTANTE:** Os timestamps `HH:MM:SS` para os `[SCREENSHOT]` **NÃO DEVEM, EM HIPÓTESE ALGUMA, EXCEDER A DURAÇÃO TOTAL DO VÍDEO** ({formattedVideoDuration}). Se um momento visual crítico ocorrer perto do final do vídeo, use um timestamp que esteja dentro da duração total. - -**IMPORTANTE: Todo o texto de saída (documentTitle, títulos das seções e conteúdo) DEVE ser escrito em {outputLangName}.** - -SAÍDA JSON OBRIGATÓRIA: -{{ - ""category"": ""TUTORIAL | MEETING | LECTURE | OTHER"", - ""documentTitle"": ""Título Profissional Gerado"", - ""sections"": [ - {{ - ""title"": ""Título da Seção"", - ""content"": ""Texto explicativo detalhado... [SCREENSHOT: 00:05:30]"" - }} - ] -}}"; - - var result = await chatService.GetChatMessageContentAsync(prompt); - var json = result.Content?.Trim() ?? "{}"; - // Extract JSON from LLM response — handles text before/after the JSON block - var jsonMatch = Regex.Match(json, @"\{[\s\S]*\}", RegexOptions.Singleline); - if (jsonMatch.Success) - json = jsonMatch.Value; - - var sections = new List(); - string category = "OTHER"; - string docTitle = originalTitle; - - try { - using var doc = JsonDocument.Parse(json); - var root = doc.RootElement; - - if (root.TryGetProperty("category", out var catEl)) category = catEl.GetString() ?? "OTHER"; - if (root.TryGetProperty("documentTitle", out var titleEl)) docTitle = titleEl.GetString() ?? originalTitle; - - foreach (var el in root.GetProperty("sections").EnumerateArray()) { - var content = el.GetProperty("content").GetString() ?? ""; - var ts = ExtractTimestamp(content); - sections.Add(new TutorialSection { - Title = el.GetProperty("title").GetString() ?? "", - Content = content.Replace($"[SCREENSHOT: {ts}]", "").Trim(), - ImageTimestamp = ts - }); - } - } catch { } - return (sections, json, category, docTitle); - } - - private string? ExtractTimestamp(string text) - { - var match = Regex.Match(text, @"\[SCREENSHOT:\s*(\d{2}:\d{2}:\d{2})\]"); - return match.Success ? match.Groups[1].Value : null; - } - - private async Task GetRawVideoStreamUrl(string videoUrl) - { - var ytDlpPath = GetYtDlpPath(); - var startInfo = new ProcessStartInfo - { - FileName = ytDlpPath, - Arguments = $"-g -f b \"{videoUrl}\"", - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true - }; - - using var proc = Process.Start(startInfo); - if (proc == null) return null; - var url = await proc.StandardOutput.ReadLineAsync(); - await proc.WaitForExitAsync(); - return url?.Trim(); - } - - private async Task CaptureScreenshotsWithPuppeteerAsync(string videoUrl, List sections, string outputDir, TimeSpan videoDuration) - { - var sectionsWithImages = sections.Where(s => !string.IsNullOrEmpty(s.ImageTimestamp)).ToList(); - if (!sectionsWithImages.Any()) return; - - AddLog("🔍 Obtendo link direto do vídeo (Bypass YouTube Player)..."); - var rawVideoUrl = await GetRawVideoStreamUrl(videoUrl); - if (string.IsNullOrEmpty(rawVideoUrl)) - { - AddLog("❌ Falha ao obter link direto. As capturas de tela serão ignoradas."); - return; - } - + await sem.WaitAsync(ct); try { - using var browser = await Puppeteer.LaunchAsync(new LaunchOptions { Headless = true, Args = new[] { "--no-sandbox", "--window-size=1280,720" } }); + 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)>(); - var htmlContent = $@" - - - - - "; - await page.SetContentAsync(htmlContent); - await page.WaitForSelectorAsync("#raw-player"); - - // Use the passed videoDuration instead of evaluating it from the page - AddLog($"🎥 Duração total do vídeo (passada): {videoDuration:hh\\:mm\\:ss}"); - - // Loop with index for unique identifiers - for (int i = 0; i < sectionsWithImages.Count; i++) + foreach (var offset in new[] { 0, -1, 1 }) { - var section = sectionsWithImages[i]; - // ImageTimestamp might be null due to earlier validation - if (string.IsNullOrEmpty(section.ImageTimestamp)) - { - AddLog($"📸 Pulando captura para seção '{section.Title}' pois o timestamp foi invalidado."); - continue; - } - - if (TimeSpan.TryParse(section.ImageTimestamp, out var ts)) - { - AddLog($"📸 Processando captura para {section.ImageTimestamp} ('{section.Title}')"); - var targetSeconds = (int)ts.TotalSeconds; - - var candidates = new List<(byte[] ImageData, double Score, int Time, string Hash, int Size)>(); - - // "Best of 3" capture window: T-1, T, T+1 seconds - var timeOffsets = new[] { -1, 0, 1 }; - - foreach (var offset in timeOffsets) - { - var captureTime = Math.Max(0, targetSeconds + offset); - - // Ensure captureTime does not exceed video duration - if (captureTime > videoDuration.TotalSeconds + 5) // Add a small buffer for safety - { - AddLog($" - ⚠️ Tempo de captura {captureTime}s excede a duração do vídeo ({videoDuration.TotalSeconds:F2}s). Ignorando este candidato."); - continue; - } - - try - { - AddLog($" - Solicitando seek para {captureTime}s (candidato para {section.ImageTimestamp})."); - // 1. Seek to the target time - await page.EvaluateFunctionAsync(@"(s) => {{ - const video = document.getElementById('raw-player'); - video.currentTime = s; - }}", captureTime); - - // 2. Intelligent Wait: Wait for the video to have enough data to play - await page.WaitForFunctionAsync("() => document.getElementById('raw-player').readyState >= 3", new WaitForFunctionOptions { Timeout = 10000 }); - - var actualCurrentTime = await page.EvaluateFunctionAsync("() => document.getElementById('raw-player').currentTime"); - AddLog($" -> Buscado para {captureTime}s. CurrentTime real pós-seek: {actualCurrentTime:F2}s."); - - // 3. Capture screenshot into memory - var screenshotData = await page.ScreenshotDataAsync(new ScreenshotOptions { Type = ScreenshotType.Jpeg, Quality = 90 }); - - // Calculate MD5 hash - string hash; - using (var md5 = MD5.Create()) - { - var hashBytes = md5.ComputeHash(screenshotData); - hash = BitConverter.ToString(hashBytes).Replace("-", "").ToLowerInvariant(); - } - AddLog($" -> Imagem Candidata ({captureTime}s): {screenshotData.Length} bytes, Hash MD5: {hash}"); - - // 4. Score the image - var score = CalculateImageClarityScore(screenshotData); - AddLog($" - Candidato {captureTime}s: Score de claridade = {score:F2}"); - - if (score > 0) - { - candidates.Add((screenshotData, score, captureTime, hash, screenshotData.Length)); - } - } - catch (WaitTaskTimeoutException) - { - AddLog($" - ⚠️ Timeout esperando pelo frame em {captureTime}s. Ignorando."); - } - catch (Exception ex) - { - AddLog($" - ❌ Erro ao capturar frame em {captureTime}s: {ex.Message}"); - } - } - - if (candidates.Any()) - { - // Select the best image based on the highest score - var bestCandidate = candidates.OrderByDescending(c => c.Score).First(); - section.ImageData = bestCandidate.ImageData; - AddLog($" => ✅ Selecionado frame de {bestCandidate.Time}s (Score: {bestCandidate.Score:F2}, Hash: {bestCandidate.Hash}) para o timestamp {section.ImageTimestamp}."); - } - else - { - AddLog($" => ❌ Falha ao capturar um frame válido para {section.ImageTimestamp}. O PDF usará um placeholder."); - section.ImageData = null; // Ensure it's null if all attempts fail - } - } + 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; } - catch (Exception ex) - { - AddLog($"❌ Erro irrecuperável no Puppeteer: {ex.Message}. As capturas de tela restantes serão abortadas."); - } + finally { sem.Release(); } } - private double CalculateImageClarityScore(byte[] imageBytes) + public async Task GetVideoInfoAsync(string url, CancellationToken ct) { - if (imageBytes == null || imageBytes.Length == 0) - return -1.0; - - try - { - // Use SKBitmap.Decode for robust image format handling - using var image = SKBitmap.Decode(imageBytes); - if (image == null || image.Width == 0 || image.Height == 0) - { - _logger.LogWarning("SkiaSharp failed to decode image or image is empty."); - return -1.0; - } - - var brightnessValues = new List(image.Width * image.Height); - - // Using GetPixel is simpler than handling Pixels array for this use case - for (int y = 0; y < image.Height; y++) - { - for (int x = 0; x < image.Width; x++) - { - var p = image.GetPixel(x, y); - // Luma calculation (standard formula for perceived brightness) - var brightness = (p.Red * 0.299f) + (p.Green * 0.587f) + (p.Blue * 0.114f); - brightnessValues.Add(brightness); - } - } - - if (!brightnessValues.Any()) return 0.0; - - var avg = brightnessValues.Average(); - var sumOfSquares = brightnessValues.Sum(b => Math.Pow(b - avg, 2)); - var stdDev = Math.Sqrt(sumOfSquares / brightnessValues.Count); - - // A very low standard deviation indicates a uniform image (likely black, white, or single color) - return stdDev; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error calculating image clarity score."); - // If SkiaSharp throws an exception, it's a bad image. - return -1.0; - } + 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 byte[] GeneratePdf(string docTitle, string videoUrl, List sections, string category) + private async Task<(string, string)> GetTranscriptViaYtDlpAsync(string url, string lang, string dir) { - var categoryColor = category switch - { - "TUTORIAL" => Colors.Green.Medium, - "MEETING" => Colors.Orange.Medium, - "LECTURE" => Colors.Purple.Medium, - _ => Colors.Blue.Medium - }; + 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)), ""); + } - var document = Document.Create(container => - { - container.Page(page => - { + 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").Fallback(f => f.FontFamily("Microsoft YaHei"))); - - page.Header().Column(c => - { - c.Item().Row(row => - { - row.RelativeItem().Text(docTitle).SemiBold().FontSize(20).FontColor(Colors.Black); - row.ConstantItem(100).AlignRight().Text(category).Bold().FontSize(10).FontColor(categoryColor); + 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(column => - { - column.Item().Text($"Fonte: {videoUrl}").Italic().FontSize(9).FontColor(Colors.Grey.Medium); - column.Item().PaddingBottom(20); - - foreach (var section in sections) - { - column.Item().Text(section.Title).Bold().FontSize(14).FontColor(categoryColor); - column.Item().Text(text => { text.Span(section.Content); }); - - if (section.ImageData != null) - { - column.Item().PaddingVertical(10).Image(section.ImageData).FitWidth(); - } - else if (!string.IsNullOrEmpty(section.ImageTimestamp)) - { - // Placeholder for missing image (Graceful Degradation) - column.Item().PaddingVertical(10) - .Background(Colors.Grey.Lighten3) - .Height(100) - .AlignCenter() - .AlignMiddle() - .Text($"[Imagem Indisponível: {section.ImageTimestamp}]") - .FontSize(10) - .FontColor(Colors.Grey.Darken2); - } - - column.Item().PaddingBottom(15); + 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("Gerado por VideoStudy.app - "); x.CurrentPageNumber(); }); + page.Footer().AlignCenter().Text(x => { x.Span("VideoStudy.app - "); x.CurrentPageNumber(); }); }); - }); - return document.GeneratePdf(); + }).GeneratePdf(); } } \ No newline at end of file diff --git a/VideoStudy.Desktop/VideoStudy.Desktop/Components/Pages/Home.razor b/VideoStudy.Desktop/VideoStudy.Desktop/Components/Pages/Home.razor index e7b6741..aa5aec5 100644 --- a/VideoStudy.Desktop/VideoStudy.Desktop/Components/Pages/Home.razor +++ b/VideoStudy.Desktop/VideoStudy.Desktop/Components/Pages/Home.razor @@ -8,6 +8,7 @@ @using VideoStudy.Shared @using System.Net.Http.Json @using System.Text.Json +@using System.Threading // Added for CancellationTokenSource @using VideoStudy.Desktop.Components VideoStudy - Video Analysis @@ -196,10 +197,11 @@ private void ToggleLogs() => showLogs = !showLogs; - private void AddLog(string message) + private void AddLog(string message, int? eventProgress = null) { logs.Insert(0, new LogEntry { Message = message }); if (logs.Count > 100) logs.RemoveAt(logs.Count - 1); + if (eventProgress.HasValue) progress = eventProgress.Value; // Update global progress StateHasChanged(); } @@ -239,96 +241,105 @@ logs.Clear(); showLogs = true; + CancellationTokenSource? cancellationTokenSource = null; + try { + cancellationTokenSource = new CancellationTokenSource(); + CancellationToken cancellationToken = cancellationTokenSource.Token; + currentStep = 1; - statusMessage = "Starting Unified Pipeline..."; - AddLog("🚀 Starting process (Unified Mode)..."); + statusMessage = "Iniciando pipeline unificado..."; + AddLog("🚀 Iniciando processo (modo streaming)...", 0); string requestVideoUrl = videoUrl; - // Handle Local File (Simplification: In a real app we'd upload. Here we just error if local for now or assume shared path) - // Since we are running on the same machine, we can cheat and pass the path if we save it to a temp location accessible by API? - // No, API is separate process. For now, let's prioritize YouTube as requested for the fix. if (activeTab == "local" && selectedFile != null) { - // Upload logic would go here. For now, let's just warn. - AddLog("⚠️ Local file support requires API upload implementation. Focusing on YouTube for this fix."); - // In a real scenario: - // var content = new MultipartFormDataContent(); - // content.Add(new StreamContent(selectedFile.OpenReadStream(...)), "file", selectedFile.Name); - // await Http.PostAsync("api/upload", content); - // requestVideoUrl = "uploaded_file_path"; + AddLog("⚠️ Suporte a arquivo local requer implementação de upload para a API. Focando no YouTube por enquanto."); + throw new NotSupportedException("Análise de arquivos locais não está implementada com o pipeline de streaming atual."); } - // --- Send Request to API (The API now handles EVERYTHING: Download -> Transcribe -> AI -> PDF) --- - AddLog("📡 Sending request to backend..."); + // --- Enviar Requisição para a API --- + AddLog("📡 Enviando requisição para o backend...", 5); - var request = new AnalysisRequest + var requestBody = new AnalysisRequest { VideoUrl = requestVideoUrl, - Mode = "unified", // No longer used but kept for model compat + Mode = "unified", Language = selectedLanguage, - TranscriptText = null, // API handles transcription now + TranscriptText = null, DurationSeconds = currentVideoInfo?.Duration.TotalSeconds ?? 0 }; - // Long timeout for the heavy process - Http.Timeout = TimeSpan.FromMinutes(10); - - var response = await Http.PostAsJsonAsync("api/analyze", request); - - if (!response.IsSuccessStatusCode) + // Prepare the HTTP request for streaming + var httpRequestMessage = new HttpRequestMessage(HttpMethod.Post, "api/analyze") { - var error = await response.Content.ReadAsStringAsync(); - throw new Exception($"API Error ({response.StatusCode}): {error}"); - } + Content = JsonContent.Create(requestBody) + }; - var json = await response.Content.ReadAsStringAsync(); - var result = JsonSerializer.Deserialize(json, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + // Use HttpCompletionOption.ResponseHeadersRead to get access to the stream as soon as possible + using var response = await Http.SendAsync(httpRequestMessage, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + response.EnsureSuccessStatusCode(); - if (result == null) throw new Exception("Failed to deserialize response"); + // Read the streaming response + var stream = response.Content.ReadFromJsonAsAsyncEnumerable(new JsonSerializerOptions { PropertyNameCaseInsensitive = true }, cancellationToken); - // Incorporar logs detalhados da API - if (result.DebugSteps != null && result.DebugSteps.Any()) + await foreach (var analysisEvent in stream.WithCancellation(cancellationToken)) { - foreach (var step in result.DebugSteps) + if (analysisEvent == null) continue; + + if (analysisEvent.IsError) { - AddLog($"[API] {step}"); + throw new Exception($"Erro da API: {analysisEvent.Message}"); + } + + AddLog(analysisEvent.Message, analysisEvent.ProgressPercentage); + statusMessage = analysisEvent.Message; + StateHasChanged(); + + if (analysisEvent.Result != null) + { + // Final result received + AddLog("✅ Análise completa!", 100); + + // Save PDF + if (analysisEvent.Result.PdfData != null && analysisEvent.Result.PdfData.Length > 0) + { + // Blazor Hybrid approach: saving directly to Downloads folder + string downloadsPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Downloads"); + string fileName = $"VideoStudy_{DateTime.Now:yyyyMMdd_HHmmss}.pdf"; + string fullPath = Path.Combine(downloadsPath, fileName); + + await File.WriteAllBytesAsync(fullPath, analysisEvent.Result.PdfData); + generatedPdfPath = fullPath; + AddLog($"📄 PDF Salvo em: {fullPath}", 100); + } + else + { + AddLog("⚠️ Nenhum dado de PDF retornado da API.", 100); + } + + statusMessage = "Completo!"; + break; } } - - if (result.Status == "error") throw new Exception(result.ErrorMessage); - - AddLog("✅ Analysis Complete!"); - progress = 100; - - // Save PDF - if (result.PdfData != null && result.PdfData.Length > 0) - { - string downloadsPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Downloads"); - string fileName = $"VideoStudy_{DateTime.Now:yyyyMMdd_HHmmss}.pdf"; - string fullPath = Path.Combine(downloadsPath, fileName); - - await File.WriteAllBytesAsync(fullPath, result.PdfData); - generatedPdfPath = fullPath; - AddLog($"📄 PDF Saved: {fullPath}"); - } - else - { - AddLog("⚠️ No PDF data returned from API."); - } - - statusMessage = "Completed!"; + } + catch (OperationCanceledException) + { + AddLog("⏸️ Processo cancelado pelo usuário.", progress); + statusMessage = "Cancelado"; } catch (Exception ex) { - AddLog($"❌ Error: {ex.Message}"); - statusMessage = "Error occurred"; + AddLog($"❌ Erro: {ex.Message}", progress); + statusMessage = "Erro ocorrido"; } finally { isProcessing = false; + cancellationTokenSource?.Dispose(); + StateHasChanged(); } } } \ No newline at end of file diff --git a/VideoStudy.Shared/Models.cs b/VideoStudy.Shared/Models.cs index a2ec47f..4cc5bc8 100644 --- a/VideoStudy.Shared/Models.cs +++ b/VideoStudy.Shared/Models.cs @@ -20,6 +20,7 @@ public class AnalysisResponse { public string VideoTitle { get; set; } = string.Empty; public string DocumentTitle { get; set; } = string.Empty; // New + public string Summary { get; set; } = string.Empty; // Renamed from ExecutiveSummary public string Category { get; set; } = "OTHER"; // New public string Transcript { get; set; } = string.Empty; public List KeyMoments { get; set; } = []; @@ -65,6 +66,7 @@ public class ProgressUpdate public class VideoInfo { public string Title { get; set; } = string.Empty; + public string Description { get; set; } = string.Empty; // Added public TimeSpan Duration { get; set; } public string Url { get; set; } = string.Empty; public string ThumbnailUrl { get; set; } = string.Empty; @@ -102,4 +104,45 @@ public class DownloadProgress public interface IPdfSaver { Task SavePdfAsync(byte[] pdfData, string suggestedFileName); +} + +/// +/// A single event in the analysis stream. +/// +public class AnalysisEvent +{ + /// + /// A user-facing message describing the current step. + /// + public string Message { get; set; } = string.Empty; + + /// + /// Progress from 0 to 100. + /// + public int ProgressPercentage { get; set; } + + /// + /// The final result of the analysis. Only present in the last event. + /// + public AnalysisResult? Result { get; set; } + + /// + /// Set to true if this event represents a critical error that stopped the process. + /// + public bool IsError { get; set; } = false; +} + +/// +/// The final, consolidated result of a successful analysis. +/// +public class AnalysisResult +{ + public string VideoTitle { get; set; } = string.Empty; + public string DocumentTitle { get; set; } = string.Empty; + public string Summary { get; set; } = string.Empty; // Renamed from ExecutiveSummary + public string Category { get; set; } = "OTHER"; + public string Transcript { get; set; } = string.Empty; + public List TutorialSections { get; set; } = []; + public byte[]? PdfData { get; set; } + public string? RawLlmResponse { get; set; } } \ No newline at end of file diff --git a/VideoStudy.UI/Pages/Home.razor b/VideoStudy.UI/Pages/Home.razor index 3f71a99..3f77791 100644 --- a/VideoStudy.UI/Pages/Home.razor +++ b/VideoStudy.UI/Pages/Home.razor @@ -4,6 +4,7 @@ @using VideoStudy.Shared @using System.Net.Http.Json @using System.Text.Json +@using System.Threading // Added for Cancellation VideoStudy - Video Analysis @@ -203,9 +204,10 @@ private void ToggleLogs() => showLogs = !showLogs; - private void AddLog(string message) + private void AddLog(string message, int? eventProgress = null) { logs.Insert(0, new LogEntry { Message = message }); + if (eventProgress.HasValue) progress = eventProgress.Value; StateHasChanged(); } @@ -255,11 +257,13 @@ logs.Clear(); showLogs = true; + CancellationTokenSource? cts = null; + try { currentStep = 1; - statusMessage = "Calling API..."; - AddLog("Sending request to API..."); + statusMessage = "Iniciando análise..."; + AddLog("🚀 Enviando requisição para a API...", 5); var request = new AnalysisRequest { @@ -269,59 +273,79 @@ Mode = "native" }; - // Long timeout via CancellationToken (HttpClient.Timeout can't be changed after first request) - using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(10)); + cts = new CancellationTokenSource(); + var cancellationToken = cts.Token; - var response = await Http.PostAsJsonAsync("api/analyze", request, cts.Token); + var httpRequestMessage = new HttpRequestMessage(HttpMethod.Post, "api/analyze") + { + Content = JsonContent.Create(request) + }; + using var response = await Http.SendAsync(httpRequestMessage, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + if (!response.IsSuccessStatusCode) { var error = await response.Content.ReadAsStringAsync(); throw new Exception($"API Error ({response.StatusCode}): {error}"); } - var json = await response.Content.ReadAsStringAsync(); - var result = JsonSerializer.Deserialize(json, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + var stream = response.Content.ReadFromJsonAsAsyncEnumerable(new JsonSerializerOptions { PropertyNameCaseInsensitive = true }, cancellationToken); - if (result == null) throw new Exception("Failed to deserialize response"); - - if (result.DebugSteps != null) + await foreach (var analysisEvent in stream.WithCancellation(cancellationToken)) { - foreach (var step in result.DebugSteps) AddLog($"[API] {step}"); - } + if (analysisEvent == null) continue; - if (result.Status == "error") throw new Exception(result.ErrorMessage); + if (analysisEvent.IsError) + { + throw new Exception($"Erro da API: {analysisEvent.Message}"); + } - AddLog("Analysis Complete!"); - progress = 100; - statusMessage = "Done!"; + AddLog(analysisEvent.Message, analysisEvent.ProgressPercentage); + statusMessage = analysisEvent.Message; + progress = analysisEvent.ProgressPercentage; + StateHasChanged(); - if (result.PdfData != null && result.PdfData.Length > 0) - { - AddLog("Saving PDF..."); - var fileName = $"VideoStudy_{result.VideoTitle ?? "Tutorial"}_{DateTime.Now:yyyyMMdd_HHmmss}.pdf"; - // Remove invalid filename chars - fileName = string.Join("_", fileName.Split(Path.GetInvalidFileNameChars())); - var savedPath = await PdfSaver.SavePdfAsync(result.PdfData, fileName); - if (savedPath != null) - AddLog($"PDF saved: {savedPath}"); - else - AddLog("PDF save cancelled by user"); - } - else - { - AddLog("No PDF data returned from API"); + if (analysisEvent.Result != null) + { + var result = analysisEvent.Result; + AddLog("✅ Análise concluída!", 100); + statusMessage = "Pronto!"; + + if (result.PdfData != null && result.PdfData.Length > 0) + { + AddLog("Salvando PDF..."); + var fileName = $"VideoStudy_{result.VideoTitle ?? "Tutorial"}_{DateTime.Now:yyyyMMdd_HHmmss}.pdf"; + // Remove invalid filename chars + fileName = string.Join("_", fileName.Split(Path.GetInvalidFileNameChars())); + var savedPath = await PdfSaver.SavePdfAsync(result.PdfData, fileName); + if (savedPath != null) + AddLog($"✓ PDF salvo: {savedPath}"); + else + AddLog("⚠️ Salvamento do PDF cancelado pelo usuário"); + } + else + { + AddLog("⚠️ Nenhum dado de PDF retornado da API"); + } + break; + } } } + catch (OperationCanceledException) + { + AddLog("⏸️ Operação cancelada."); + statusMessage = "Cancelado"; + } catch (Exception ex) { - AddLog($"Error: {ex.Message}"); - statusMessage = "Error"; + AddLog($"❌ Erro: {ex.Message}"); + statusMessage = "Erro"; } finally { isProcessing = false; videoInfo = null; + cts?.Dispose(); } } }