fix: ajustes de tempo dentro do video (estava tentando obter imagens depois do fim do video)
This commit is contained in:
parent
c5a3b14449
commit
480f501993
@ -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]"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@ -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<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>
|
||||
{
|
||||
@ -266,11 +292,18 @@ public class AnalysisService
|
||||
var outputLangName = langMap.GetValueOrDefault(outputLang, outputLang);
|
||||
|
||||
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 = $@"
|
||||
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<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();
|
||||
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 = $@"<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>";
|
||||
await page.SetContentAsync(html);
|
||||
await page.WaitForSelectorAsync("video");
|
||||
var htmlContent = $@"
|
||||
<html>
|
||||
<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))
|
||||
{
|
||||
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)>();
|
||||
|
||||
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);
|
||||
// "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<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)
|
||||
|
||||
BIN
VideoStudy.pdf
Normal file
BIN
VideoStudy.pdf
Normal file
Binary file not shown.
Loading…
Reference in New Issue
Block a user