fix: ajustes de tempo dentro do video (estava tentando obter imagens depois do fim do video)

This commit is contained in:
Ricardo Carneiro 2026-02-10 13:27:29 -03:00
parent c5a3b14449
commit 480f501993
3 changed files with 204 additions and 36 deletions

View File

@ -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 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 ```json
{ {
"category": "OTHER", "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": [ "sections": [
{ {
"title": "Introdução à Alimentação no Brasil", "title": "Introdução",
"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]" "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", "title": "A Descoberta da Batata",
"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]" "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", "title": "A Função da Bolinha no Spray de Tinta",
"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]" "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", "title": "Á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]" "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", "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]"
} }
] ]
} }

View File

@ -8,6 +8,9 @@ using QuestPDF.Helpers;
using QuestPDF.Infrastructure; using QuestPDF.Infrastructure;
using PuppeteerSharp; using PuppeteerSharp;
using VideoStudy.Shared; using VideoStudy.Shared;
using SkiaSharp;
using System.Linq;
using System.Security.Cryptography; // Added for MD5 hash
namespace VideoStudy.API.Services; namespace VideoStudy.API.Services;
@ -119,6 +122,11 @@ public class AnalysisService
string rawLlmResponse = ""; 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 try
{ {
// --- Step 1: Transcription --- // --- Step 1: Transcription ---
@ -132,7 +140,7 @@ public class AnalysisService
// --- Step 2: Intelligence --- // --- Step 2: Intelligence ---
AddLog("🧠 Enviando transcrição para o Groq (LLM)..."); 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; rawLlmResponse = rawJson;
// Save debug MD // Save debug MD
@ -141,16 +149,34 @@ public class AnalysisService
await File.WriteAllTextAsync(debugFile, debugContent); await File.WriteAllTextAsync(debugFile, debugContent);
AddLog($"📝 Arquivo de debug gerado: {debugFile}"); 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 --- // --- Step 3: Image Capture ---
var sectionsWithImages = tutorialSections.Where(s => !string.IsNullOrEmpty(s.ImageTimestamp)).ToList(); var sectionsWithImages = tutorialSections.Where(s => !string.IsNullOrEmpty(s.ImageTimestamp)).ToList();
if (sectionsWithImages.Any()) if (sectionsWithImages.Any())
{ {
AddLog($"📸 Capturando {sectionsWithImages.Count} prints usando Puppeteer (Direct Bypass)..."); 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 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 --- // --- Step 4: PDF Generation ---
@ -254,7 +280,7 @@ public class AnalysisService
return string.Join(" ", textLines); return string.Join(" ", textLines);
} }
private async Task<(List<TutorialSection> sections, string rawJson, string category, string docTitle)> GenerateTutorialContentAsync(string transcript, string originalTitle, string inputLanguage, string? outputLanguage) private async Task<(List<TutorialSection> sections, string rawJson, string category, string docTitle)> GenerateTutorialContentAsync(string transcript, string originalTitle, string inputLanguage, string? outputLanguage, TimeSpan videoDuration)
{ {
var langMap = new Dictionary<string, string> var langMap = new Dictionary<string, string>
{ {
@ -266,11 +292,18 @@ public class AnalysisService
var outputLangName = langMap.GetValueOrDefault(outputLang, outputLang); var outputLangName = langMap.GetValueOrDefault(outputLang, outputLang);
var chatService = _kernel.GetRequiredService<IChatCompletionService>(); var chatService = _kernel.GetRequiredService<IChatCompletionService>();
// 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 = $@" var prompt = $@"
Você é um Editor Chefe e Analista de Conteúdo Sênior. Você é um Editor Chefe e Analista de Conteúdo Sênior.
Receberá: Receberá:
A) TÍTULO ORIGINAL: {originalTitle} A) TÍTULO ORIGINAL: {originalTitle}
B) TRANSCRIÇÃO: {transcript[..Math.Min(transcript.Length, 20000)]} B) TRANSCRIÇÃO: {transcript[..Math.Min(transcript.Length, 20000)]}
C) DURAÇÃO TOTAL DO VÍDEO: {formattedVideoDuration} ({totalSecondsVideoDuration} segundos)
SUA MISSÃO: SUA MISSÃO:
1. **Classificar** o vídeo em: 'TUTORIAL', 'MEETING', 'LECTURE' ou 'OTHER'. 1. **Classificar** o vídeo em: 'TUTORIAL', 'MEETING', 'LECTURE' ou 'OTHER'.
@ -281,6 +314,7 @@ SUA MISSÃO:
3. **Estruturar o Conteúdo**: 3. **Estruturar o Conteúdo**:
- Converta o conteúdo em um texto educativo e denso. - 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. - 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}.** **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(); return url?.Trim();
} }
private async Task CaptureScreenshotsWithPuppeteerAsync(string videoUrl, List<TutorialSection> sections, string outputDir) private async Task CaptureScreenshotsWithPuppeteerAsync(string videoUrl, List<TutorialSection> sections, string outputDir, TimeSpan videoDuration)
{ {
var sectionsWithImages = sections.Where(s => !string.IsNullOrEmpty(s.ImageTimestamp)).ToList(); var sectionsWithImages = sections.Where(s => !string.IsNullOrEmpty(s.ImageTimestamp)).ToList();
if (!sectionsWithImages.Any()) return; if (!sectionsWithImages.Any()) return;
AddLog("🔍 Obtendo link direto do vídeo (Bypass YouTube Player)..."); AddLog("🔍 Obtendo link direto do vídeo (Bypass YouTube Player)...");
var rawVideoUrl = await GetRawVideoStreamUrl(videoUrl); 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 try
{ {
@ -368,33 +406,163 @@ SAÍDA JSON OBRIGATÓRIA:
using var page = await browser.NewPageAsync(); using var page = await browser.NewPageAsync();
await page.SetViewportAsync(new ViewPortOptions { Width = 1280, Height = 720 }); await page.SetViewportAsync(new ViewPortOptions { Width = 1280, Height = 720 });
var html = $@"<html><body style='margin:0;background:black;overflow:hidden;'><video id='v' width='1280' height='720' muted><source src='{rawVideoUrl}' type='video/mp4'></video></body></html>"; var htmlContent = $@"
await page.SetContentAsync(html); <html>
await page.WaitForSelectorAsync("video"); <body style='margin:0; background:black; overflow:hidden;'>
<video id='raw-player' width='1280' height='720' muted preload='auto'>
<source src='{rawVideoUrl}' type='video/mp4'>
</video>
</body>
</html>";
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)) if (TimeSpan.TryParse(section.ImageTimestamp, out var ts))
{ {
var sec = (int)ts.TotalSeconds; AddLog($"📸 Processando captura para {section.ImageTimestamp} ('{section.Title}')");
AddLog($"🌐 Renderizando frame: {section.ImageTimestamp}..."); var targetSeconds = (int)ts.TotalSeconds;
await page.EvaluateFunctionAsync(@"(s) => { var candidates = new List<(byte[] ImageData, double Score, int Time, string Hash, int Size)>();
return new Promise(r => {
const v = document.getElementById('v');
v.currentTime = s;
v.addEventListener('seeked', r, {once:true});
});
}", sec);
await Task.Delay(500); // "Best of 3" capture window: T-1, T, T+1 seconds
var path = Path.Combine(outputDir, $"snap_{sec}.jpg"); var timeOffsets = new[] { -1, 0, 1 };
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<double>("() => 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<float>(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<TutorialSection> sections, string category) private byte[] GeneratePdf(string docTitle, string videoUrl, List<TutorialSection> sections, string category)

BIN
VideoStudy.pdf Normal file

Binary file not shown.