diff --git a/VideoStudy.API/DEBUG_LAST_RESPONSE.md b/VideoStudy.API/DEBUG_LAST_RESPONSE.md index b2b8246..41dd5c8 100644 --- a/VideoStudy.API/DEBUG_LAST_RESPONSE.md +++ b/VideoStudy.API/DEBUG_LAST_RESPONSE.md @@ -1,4 +1,4 @@ -# A Origem da Alimentação no Brasil e a Importância da Química na Vida Cotidiana (OTHER) +# Por Que Arroz e Não Batata: Uma Análise Histórica e Científica (OTHER) Source: Por que ARROZ e nÒo BATATA? | Cortes do Manual do Mundo @@ -6,27 +6,27 @@ Source: Por que ARROZ e nÒo BATATA? | Cortes do Manual do Mundo ```json { "category": "OTHER", - "documentTitle": "A Origem da Alimentação no Brasil e a Importância da Química na Vida Cotidiana", + "documentTitle": "Por Que Arroz e Não Batata: Uma Análise Histórica e Científica", "sections": [ { - "title": "Introdução à Alimentação no Brasil", - "content": "A base alimentar brasileira é composta pelo arroz, mas por que não pela batata, se fomos colonizados por europeus que comem batata? Essa é uma pergunta interessante que leva a uma história complexa. Os indígenas no Brasil já cultivavam alguns tipos de arroz, assim como as populações africanas. O arroz é um alimento básico da dieta de 2,5 bilhões de pessoas, aproximadamente 1/3 do mundo inteiro. [SCREENSHOT: 00:01:30]" + "title": "Introdução", + "content": "A nossa base alimentar é composta por vários alimentos, mas o arroz é um dos principais. Isso nos leva a questionar por que o arroz é mais comum do que a batata, considerando que fomos colonizados por europeus, que têm a batata como alimento básico. A resposta é complexa e envolve a história da culinária e da colonização. Os indígenas no Brasil já cultivavam alguns tipos de arroz, e na África, o arroz também era cultivado. Isso significa que as populações que se juntaram para formar o Brasil já consumiam arroz. De acordo com a Embrapa, o arroz é um alimento básico da dieta de 2,5 bilhões de pessoas, cerca de 1/3 do mundo inteiro. [SCREENSHOT: 00:00:30]" }, { - "title": "A Descoberta da Batata pelos Europeus", - "content": "Os europeus descobriram a batata quando chegaram à América, mais especificamente nos Andes. A batata é originária da América, e foi a população local que ensinou os europeus a comê-la. Além da batata, outros alimentos importantes, como o milho e o cacau, também são originários da América. [SCREENSHOT: 00:03:45]" + "title": "A Descoberta da Batata", + "content": "Quando os europeus chegaram ao Brasil, eles não comiam batata. Isso porque a batata é originária da América, especificamente dos Andes. Foi a população da América que ensinou os europeus a comer batata. Além da batata, outras plantas importantes, como o milho e o cacau, também são originárias da América. Isso mostra que a culinária europeia antes da descoberta do continente americano devia ser bem diferente. [SCREENSHOT: 00:01:45]" }, { - "title": "A Importância da Química na Vida Cotidiana", - "content": "A química está presente em todos os aspectos da vida, desde a culinária até a indústria. Um exemplo interessante é o funcionamento de um spray de tinta. A bolinha dentro do spray serve para misturar a tinta com o propelente, um gás inflamável, garantindo que a tinta saia uniformemente. [SCREENSHOT: 00:06:15]" + "title": "A Função da Bolinha no Spray de Tinta", + "content": "Muitas pessoas já se perguntaram sobre a função da bolinha que fica dentro do spray de tinta. Essa bolinha é importante para misturar a tinta com o propelente, que é um gás inflamável, como o propano ou butano. A bolinha ajuda a manter a mistura uniforme, permitindo que a tinta seja aplicada de forma correta. [SCREENSHOT: 00:02:50]" }, { - "title": "A Diferença entre Água com Gás e Água Oxigenada", - "content": "A água com gás contém bolhinhas de gás carbônico, enquanto a água oxigenada é uma substância química diferente, com uma molécula de H2O2. A água oxigenada não é simplesmente água com oxigênio dissolvido, mas sim uma substância com propriedades únicas. [SCREENSHOT: 00:09:30]" + "title": "Água com Gás e Água Oxigenada", + "content": "Muitas pessoas confundem água com gás e água oxigenada. No entanto, elas são diferentes. A água com gás contém gás carbônico dissolvido, que forma bolhas quando a garrafa é aberta. Já a água oxigenada é uma substância química diferente, com a fórmula H2O2, que contém um átomo de oxigênio a mais do que a água comum. [SCREENSHOT: 00:03:40]" }, { "title": "Conclusão e Novos Projetos", - "content": "A química é uma ciência fascinante que pode ser aprendida de maneira divertida. A equipe do Manual do Mundo está trabalhando em novos projetos, incluindo a revisão do Grande Livro de Biologia. Esses livros são uma ótima forma de aprender sobre ciência de maneira interativa e divertida. [SCREENSHOT: 00:12:00]" + "content": "Além de explorar a história e a ciência por trás de nossos alimentos e produtos, também estamos trabalhando em novos projetos, como a revisão do grande livro de biologia, que faz parte da coleção 'O Grande Livro'. Esses livros são uma ótima maneira de aprender de forma divertida e interativa. [SCREENSHOT: 00:04:20]" } ] } diff --git a/VideoStudy.API/Services/AnalysisService.cs b/VideoStudy.API/Services/AnalysisService.cs index d60cea3..91566fc 100644 --- a/VideoStudy.API/Services/AnalysisService.cs +++ b/VideoStudy.API/Services/AnalysisService.cs @@ -8,6 +8,9 @@ using QuestPDF.Helpers; using QuestPDF.Infrastructure; using PuppeteerSharp; using VideoStudy.Shared; +using SkiaSharp; +using System.Linq; +using System.Security.Cryptography; // Added for MD5 hash namespace VideoStudy.API.Services; @@ -119,6 +122,11 @@ public class AnalysisService 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 --- @@ -132,7 +140,7 @@ public class AnalysisService // --- Step 2: Intelligence --- AddLog("🧠 Enviando transcrição para o Groq (LLM)..."); - var (tutorialSections, rawJson, category, docTitle) = await GenerateTutorialContentAsync(transcript, originalTitle, request.Language, request.OutputLanguage); + var (tutorialSections, rawJson, category, docTitle) = await GenerateTutorialContentAsync(transcript, originalTitle, request.Language, request.OutputLanguage, videoInfo.Duration); rawLlmResponse = rawJson; // Save debug MD @@ -141,16 +149,34 @@ public class AnalysisService await File.WriteAllTextAsync(debugFile, debugContent); AddLog($"📝 Arquivo de debug gerado: {debugFile}"); + // --- Validate and adjust Image Timestamps --- + AddLog("⏳ Validando timestamps de imagem gerados pela IA..."); + foreach (var section in tutorialSections) + { + 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)..."); - await CaptureScreenshotsWithPuppeteerAsync(request.VideoUrl, tutorialSections, tempDir); + // Pass videoInfo.Duration to the screenshot method + await CaptureScreenshotsWithPuppeteerAsync(request.VideoUrl, tutorialSections, tempDir, videoInfo.Duration); } else { - AddLog("⚠️ Nenhuma tag [SCREENSHOT] foi gerada pela IA."); + AddLog("⚠️ Nenhuma tag [SCREENSHOT] foi gerada pela IA, ou todas foram invalidadas."); } // --- Step 4: PDF Generation --- @@ -254,7 +280,7 @@ public class AnalysisService return string.Join(" ", textLines); } - private async Task<(List sections, string rawJson, string category, string docTitle)> GenerateTutorialContentAsync(string transcript, string originalTitle, string inputLanguage, string? outputLanguage) + 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 { @@ -266,11 +292,18 @@ public class AnalysisService 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'. @@ -281,6 +314,7 @@ SUA MISSÃO: 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}.** @@ -353,14 +387,18 @@ SAÍDA JSON OBRIGATÓRIA: return url?.Trim(); } - private async Task CaptureScreenshotsWithPuppeteerAsync(string videoUrl, List sections, string outputDir) + 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."); return; } + if (string.IsNullOrEmpty(rawVideoUrl)) + { + AddLog("❌ Falha ao obter link direto. As capturas de tela serão ignoradas."); + return; + } try { @@ -368,33 +406,163 @@ SAÍDA JSON OBRIGATÓRIA: using var page = await browser.NewPageAsync(); await page.SetViewportAsync(new ViewPortOptions { Width = 1280, Height = 720 }); - var html = $@""; - await page.SetContentAsync(html); - await page.WaitForSelectorAsync("video"); + var htmlContent = $@" + + + + + "; + await page.SetContentAsync(htmlContent); + await page.WaitForSelectorAsync("#raw-player"); - foreach (var section in sectionsWithImages) + // 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++) { + 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)) { - var sec = (int)ts.TotalSeconds; - AddLog($"🌐 Renderizando frame: {section.ImageTimestamp}..."); + AddLog($"📸 Processando captura para {section.ImageTimestamp} ('{section.Title}')"); + var targetSeconds = (int)ts.TotalSeconds; - await page.EvaluateFunctionAsync(@"(s) => { - return new Promise(r => { - const v = document.getElementById('v'); - v.currentTime = s; - v.addEventListener('seeked', r, {once:true}); - }); - }", sec); + 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 }; - await Task.Delay(500); - var path = Path.Combine(outputDir, $"snap_{sec}.jpg"); - await page.ScreenshotAsync(path, new ScreenshotOptions { Type = ScreenshotType.Jpeg, Quality = 90 }); - if (File.Exists(path)) section.ImageData = await File.ReadAllBytesAsync(path); + 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 + } } } } - catch (Exception ex) { AddLog($"❌ Erro Puppeteer: {ex.Message}"); } + catch (Exception ex) + { + AddLog($"❌ Erro irrecuperável no Puppeteer: {ex.Message}. As capturas de tela restantes serão abortadas."); + } + } + + private double CalculateImageClarityScore(byte[] imageBytes) + { + 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; + } } private byte[] GeneratePdf(string docTitle, string videoUrl, List sections, string category) @@ -460,4 +628,4 @@ SAÍDA JSON OBRIGATÓRIA: }); return document.GeneratePdf(); } -} +} \ No newline at end of file diff --git a/VideoStudy.pdf b/VideoStudy.pdf new file mode 100644 index 0000000..90741a1 Binary files /dev/null and b/VideoStudy.pdf differ