fix: Ajustes de aparencia do PDF

This commit is contained in:
Ricardo Carneiro 2026-02-10 16:13:31 -03:00
parent 480f501993
commit 7417f1f6c6
5 changed files with 342 additions and 614 deletions

View File

@ -97,7 +97,7 @@ app.MapGet("/api/video-info", async (string url, AnalysisService service) =>
{ {
try try
{ {
var info = await service.GetVideoInfoAsync(url); var info = await service.GetVideoInfoAsync(url, CancellationToken.None);
return Results.Ok(info); return Results.Ok(info);
} }
catch (Exception ex) catch (Exception ex)
@ -108,9 +108,9 @@ app.MapGet("/api/video-info", async (string url, AnalysisService service) =>
.WithName("GetVideoInfo"); .WithName("GetVideoInfo");
// Main analysis endpoint // 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"); .WithName("AnalyzeVideo");

View File

@ -1,16 +1,16 @@
using System.Diagnostics; using System.Diagnostics;
using System.Text.RegularExpressions; using System.Runtime.CompilerServices;
using System.Text.Json; using System.Text.Json;
using System.Text.RegularExpressions;
using System.Threading;
using Microsoft.SemanticKernel; using Microsoft.SemanticKernel;
using Microsoft.SemanticKernel.ChatCompletion; using Microsoft.SemanticKernel.ChatCompletion;
using PuppeteerSharp;
using QuestPDF.Fluent; using QuestPDF.Fluent;
using QuestPDF.Helpers; using QuestPDF.Helpers;
using QuestPDF.Infrastructure; using QuestPDF.Infrastructure;
using PuppeteerSharp;
using VideoStudy.Shared;
using SkiaSharp; using SkiaSharp;
using System.Linq; using VideoStudy.Shared;
using System.Security.Cryptography; // Added for MD5 hash
namespace VideoStudy.API.Services; namespace VideoStudy.API.Services;
@ -18,20 +18,18 @@ public class AnalysisService
{ {
private readonly Kernel _kernel; private readonly Kernel _kernel;
private readonly ILogger<AnalysisService> _logger; private readonly ILogger<AnalysisService> _logger;
private readonly List<string> _debugSteps = new();
public AnalysisService(Kernel kernel, ILogger<AnalysisService> logger) public AnalysisService(Kernel kernel, ILogger<AnalysisService> logger)
{ {
_kernel = kernel; _kernel = kernel;
_logger = logger; _logger = logger;
QuestPDF.Settings.License = LicenseType.Community; QuestPDF.Settings.License = LicenseType.Community;
Task.Run(async () => { Task.Run(async () => {
try try
{ {
var browserFetcher = new BrowserFetcher(); var browserFetcher = new BrowserFetcher();
await browserFetcher.DownloadAsync(); await browserFetcher.DownloadAsync();
_logger.LogInformation("Chromium ready.");
} }
catch (Exception ex) 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() private string GetYtDlpPath()
{ {
if (System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform(System.Runtime.InteropServices.OSPlatform.Windows)) if (System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform(System.Runtime.InteropServices.OSPlatform.Windows))
@ -73,559 +65,217 @@ public class AnalysisService
} }
} }
public async Task<VideoInfo> GetVideoInfoAsync(string url) public async IAsyncEnumerable<AnalysisEvent> 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<AnalysisResponse> AnalyzeVideoAsync(AnalysisRequest request)
{
_debugSteps.Clear();
var tempDir = Path.Combine(Path.GetTempPath(), "VideoStudy", Guid.NewGuid().ToString()); var tempDir = Path.Combine(Path.GetTempPath(), "VideoStudy", Guid.NewGuid().ToString());
Directory.CreateDirectory(tempDir); 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 try
{ {
// --- Step 1: Transcription --- yield return new AnalysisEvent { ProgressPercentage = 5, Message = "Iniciando análise técnica..." };
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.");
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 --- yield return new AnalysisEvent { ProgressPercentage = 15, Message = "Obtendo transcrição..." };
AddLog("🧠 Enviando transcrição para o Groq (LLM)..."); var (transcript, _) = await GetTranscriptViaYtDlpAsync(request.VideoUrl, request.Language, tempDir);
var (tutorialSections, rawJson, category, docTitle) = await GenerateTutorialContentAsync(transcript, originalTitle, request.Language, request.OutputLanguage, videoInfo.Duration); if (string.IsNullOrWhiteSpace(transcript)) throw new Exception("Transcrição indisponível.");
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}");
// --- Validate and adjust Image Timestamps --- yield return new AnalysisEvent { ProgressPercentage = 40, Message = "IA estruturando conteúdo e gerando resumo..." };
AddLog("⏳ Validando timestamps de imagem gerados pela IA..."); var (sections, rawJson, category, docTitle, summary) = await GenerateTutorialContentAsync(transcript, videoInfo, request.Language, request.OutputLanguage, cancellationToken);
foreach (var section in tutorialSections)
var sectionsToCapture = sections.Where(s => !string.IsNullOrEmpty(s.ImageTimestamp)).ToList();
if (sectionsToCapture.Any())
{ {
if (TimeSpan.TryParse(section.ImageTimestamp, out var imageTs)) yield return new AnalysisEvent { ProgressPercentage = 70, Message = $"Capturando {sectionsToCapture.Count} imagens críticas..." };
{ await CaptureScreenshotsInParallelAsync(request.VideoUrl, sectionsToCapture, videoInfo.Duration, cancellationToken);
// 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.");
} }
// --- Step 4: PDF Generation --- yield return new AnalysisEvent { ProgressPercentage = 90, Message = "Gerando PDF..." };
AddLog("📄 Gerando PDF final com QuestPDF..."); var pdfBytes = GeneratePdf(docTitle, summary, request.VideoUrl, sections, category);
var pdfBytes = GeneratePdf(docTitle, request.VideoUrl, tutorialSections, category);
AddLog("🎉 Processamento concluído com sucesso!"); var result = new AnalysisResult
return new AnalysisResponse
{ {
Status = "success", VideoTitle = videoInfo.Title,
VideoTitle = originalTitle,
DocumentTitle = docTitle, DocumentTitle = docTitle,
Summary = summary,
Category = category, Category = category,
Transcript = transcript, Transcript = transcript,
TutorialSections = tutorialSections, TutorialSections = sections,
PdfData = pdfBytes, PdfData = pdfBytes,
DebugSteps = new List<string>(_debugSteps), RawLlmResponse = rawJson
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<string>(_debugSteps),
RawLlmResponse = rawLlmResponse
}; };
yield return new AnalysisEvent { ProgressPercentage = 100, Message = "Concluído!", Result = result };
} }
finally finally
{ {
if (Directory.Exists(tempDir)) if (Directory.Exists(tempDir)) try { Directory.Delete(tempDir, true); } catch { }
{
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<TutorialSection> sections, TimeSpan videoDuration, CancellationToken ct)
{ {
var ytDlpPath = GetYtDlpPath(); var rawUrl = await GetRawVideoStreamUrl(videoUrl);
// Use a safe output template to avoid filesystem issues, but we want the title. if (string.IsNullOrEmpty(rawUrl)) return;
// Better: Fetch title separately or read metadata json. using var sem = new SemaphoreSlim(3);
// Let's stick to filename trick but ensure safe chars. await Task.WhenAll(sections.Select(s => ProcessSingleSection(s, rawUrl, videoDuration, sem, ct)));
// 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);
} }
private string ParseVttToText(string vttContent) private async Task ProcessSingleSection(TutorialSection section, string rawUrl, TimeSpan duration, SemaphoreSlim sem, CancellationToken ct)
{ {
var lines = vttContent.Split('\n'); await sem.WaitAsync(ct);
var textLines = new List<string>();
var seen = new HashSet<string>();
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<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>
{
{"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<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'.
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<TutorialSection>();
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<string?> 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<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. As capturas de tela serão ignoradas.");
return;
}
try 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(); using var page = await browser.NewPageAsync();
await page.SetViewportAsync(new ViewPortOptions { Width = 1280, Height = 720 }); await page.SetViewportAsync(new ViewPortOptions { Width = 1280, Height = 720 });
await page.SetContentAsync($"<html><body style='margin:0;background:black;'><video id='v' width='1280' height='720' src='{rawUrl}'></video></body></html>");
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 = $@" foreach (var offset in new[] { 0, -1, 1 })
<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");
// 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]; ct.ThrowIfCancellationRequested();
// ImageTimestamp might be null due to earlier validation int time = Math.Max(0, target + offset);
if (string.IsNullOrEmpty(section.ImageTimestamp)) if (time > duration.TotalSeconds) continue;
{ try {
AddLog($"📸 Pulando captura para seção '{section.Title}' pois o timestamp foi invalidado."); await page.EvaluateFunctionAsync($"(s) => document.getElementById('v').currentTime = s", time);
continue; 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 (TimeSpan.TryParse(section.ImageTimestamp, out var ts)) if (score > 10) candidates.Add((data, score));
{ } catch { }
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<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
}
}
} }
if (candidates.Any()) section.ImageData = candidates.OrderByDescending(c => c.Score).First().Data;
} }
catch (Exception ex) finally { sem.Release(); }
{
AddLog($"❌ Erro irrecuperável no Puppeteer: {ex.Message}. As capturas de tela restantes serão abortadas.");
}
} }
private double CalculateImageClarityScore(byte[] imageBytes) public async Task<VideoInfo> GetVideoInfoAsync(string url, CancellationToken ct)
{ {
if (imageBytes == null || imageBytes.Length == 0) var path = GetYtDlpPath();
return -1.0; 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);
try 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);
// Use SKBitmap.Decode for robust image format handling 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 };
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 async Task<(string, string)> GetTranscriptViaYtDlpAsync(string url, string lang, string dir)
{ {
var categoryColor = category switch 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 };
"TUTORIAL" => Colors.Green.Medium, using var p = Process.Start(start);
"MEETING" => Colors.Orange.Medium, await p!.WaitForExitAsync();
"LECTURE" => Colors.Purple.Medium, var file = Directory.GetFiles(dir, "*.vtt").FirstOrDefault();
_ => Colors.Blue.Medium return file == null ? ("", "") : (ParseVttToText(await File.ReadAllTextAsync(file)), "");
}; }
var document = Document.Create(container => private string ParseVttToText(string vtt)
{ {
container.Page(page => 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<TutorialSection> sections, string rawJson, string category, string docTitle, string summary)> GenerateTutorialContentAsync(string transcript, VideoInfo video, string inLang, string? outLang, CancellationToken ct)
{
var langMap = new Dictionary<string, string> { {"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<IChatCompletionService>().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<string?> 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<float>();
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<TutorialSection> 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.Margin(2, Unit.Centimetre);
page.DefaultTextStyle(x => x.FontSize(11).FontFamily("Segoe UI").Fallback(f => f.FontFamily("Microsoft YaHei"))); page.DefaultTextStyle(x => x.FontSize(11).FontFamily("Segoe UI"));
page.Header().Column(c => {
page.Header().Column(c => c.Item().Row(r => {
{ r.RelativeItem().Text(title).SemiBold().FontSize(22).FontColor(Colors.Blue.Darken3);
c.Item().Row(row => r.ConstantItem(80).AlignRight().Text(category).Bold().FontSize(10).FontColor(color);
{
row.RelativeItem().Text(docTitle).SemiBold().FontSize(20).FontColor(Colors.Black);
row.ConstantItem(100).AlignRight().Text(category).Bold().FontSize(10).FontColor(categoryColor);
}); });
c.Item().PaddingTop(5).LineHorizontal(1).LineColor(Colors.Grey.Lighten2); c.Item().PaddingTop(5).LineHorizontal(1).LineColor(Colors.Grey.Lighten2);
}); });
page.Content().PaddingVertical(1, Unit.Centimetre).Column(col => {
page.Content().PaddingVertical(1, Unit.Centimetre).Column(column => // Resumo Section
{ col.Item().Background(Colors.Grey.Lighten4).Padding(10).Column(rc => {
column.Item().Text($"Fonte: {videoUrl}").Italic().FontSize(9).FontColor(Colors.Grey.Medium); rc.Item().Text("Resumo").Bold().FontSize(12).FontColor(Colors.Blue.Medium);
column.Item().PaddingBottom(20); rc.Item().PaddingTop(2).Text(summary).Italic();
});
foreach (var section in sections) col.Item().PaddingTop(10).Text(t => { t.Span("Fonte: ").SemiBold(); t.Span(url).Italic().FontSize(9); });
{
column.Item().Text(section.Title).Bold().FontSize(14).FontColor(categoryColor); foreach (var s in sections) {
column.Item().Text(text => { text.Span(section.Content); }); col.Item().PaddingTop(20).Text(s.Title).Bold().FontSize(16).FontColor(color);
col.Item().PaddingTop(5).Text(s.Content).LineHeight(1.5f);
if (section.ImageData != null) if (s.ImageData != null) col.Item().PaddingVertical(10).Image(s.ImageData).FitWidth();
{
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.Footer().AlignCenter().Text(x => { x.Span("VideoStudy.app - "); x.CurrentPageNumber(); });
page.Footer().AlignCenter().Text(x => { x.Span("Gerado por VideoStudy.app - "); x.CurrentPageNumber(); });
}); });
}); }).GeneratePdf();
return document.GeneratePdf();
} }
} }

View File

@ -8,6 +8,7 @@
@using VideoStudy.Shared @using VideoStudy.Shared
@using System.Net.Http.Json @using System.Net.Http.Json
@using System.Text.Json @using System.Text.Json
@using System.Threading // Added for CancellationTokenSource
@using VideoStudy.Desktop.Components @using VideoStudy.Desktop.Components
<PageTitle>VideoStudy - Video Analysis</PageTitle> <PageTitle>VideoStudy - Video Analysis</PageTitle>
@ -196,10 +197,11 @@
private void ToggleLogs() => showLogs = !showLogs; 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 }); logs.Insert(0, new LogEntry { Message = message });
if (logs.Count > 100) logs.RemoveAt(logs.Count - 1); if (logs.Count > 100) logs.RemoveAt(logs.Count - 1);
if (eventProgress.HasValue) progress = eventProgress.Value; // Update global progress
StateHasChanged(); StateHasChanged();
} }
@ -239,96 +241,105 @@
logs.Clear(); logs.Clear();
showLogs = true; showLogs = true;
CancellationTokenSource? cancellationTokenSource = null;
try try
{ {
cancellationTokenSource = new CancellationTokenSource();
CancellationToken cancellationToken = cancellationTokenSource.Token;
currentStep = 1; currentStep = 1;
statusMessage = "Starting Unified Pipeline..."; statusMessage = "Iniciando pipeline unificado...";
AddLog("🚀 Starting process (Unified Mode)..."); AddLog("🚀 Iniciando processo (modo streaming)...", 0);
string requestVideoUrl = videoUrl; 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) if (activeTab == "local" && selectedFile != null)
{ {
// Upload logic would go here. For now, let's just warn. AddLog("⚠️ Suporte a arquivo local requer implementação de upload para a API. Focando no YouTube por enquanto.");
AddLog("⚠️ Local file support requires API upload implementation. Focusing on YouTube for this fix."); throw new NotSupportedException("Análise de arquivos locais não está implementada com o pipeline de streaming atual.");
// 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";
} }
// --- Send Request to API (The API now handles EVERYTHING: Download -> Transcribe -> AI -> PDF) --- // --- Enviar Requisição para a API ---
AddLog("📡 Sending request to backend..."); AddLog("📡 Enviando requisição para o backend...", 5);
var request = new AnalysisRequest var requestBody = new AnalysisRequest
{ {
VideoUrl = requestVideoUrl, VideoUrl = requestVideoUrl,
Mode = "unified", // No longer used but kept for model compat Mode = "unified",
Language = selectedLanguage, Language = selectedLanguage,
TranscriptText = null, // API handles transcription now TranscriptText = null,
DurationSeconds = currentVideoInfo?.Duration.TotalSeconds ?? 0 DurationSeconds = currentVideoInfo?.Duration.TotalSeconds ?? 0
}; };
// Long timeout for the heavy process // Prepare the HTTP request for streaming
Http.Timeout = TimeSpan.FromMinutes(10); var httpRequestMessage = new HttpRequestMessage(HttpMethod.Post, "api/analyze")
var response = await Http.PostAsJsonAsync("api/analyze", request);
if (!response.IsSuccessStatusCode)
{ {
var error = await response.Content.ReadAsStringAsync(); Content = JsonContent.Create(requestBody)
throw new Exception($"API Error ({response.StatusCode}): {error}"); };
}
var json = await response.Content.ReadAsStringAsync(); // Use HttpCompletionOption.ResponseHeadersRead to get access to the stream as soon as possible
var result = JsonSerializer.Deserialize<AnalysisResponse>(json, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); 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<AnalysisEvent>(new JsonSerializerOptions { PropertyNameCaseInsensitive = true }, cancellationToken);
// Incorporar logs detalhados da API await foreach (var analysisEvent in stream.WithCancellation(cancellationToken))
if (result.DebugSteps != null && result.DebugSteps.Any())
{ {
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); catch (OperationCanceledException)
{
AddLog("✅ Analysis Complete!"); AddLog("⏸️ Processo cancelado pelo usuário.", progress);
progress = 100; statusMessage = "Cancelado";
// 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 (Exception ex) catch (Exception ex)
{ {
AddLog($"❌ Error: {ex.Message}"); AddLog($"❌ Erro: {ex.Message}", progress);
statusMessage = "Error occurred"; statusMessage = "Erro ocorrido";
} }
finally finally
{ {
isProcessing = false; isProcessing = false;
cancellationTokenSource?.Dispose();
StateHasChanged();
} }
} }
} }

View File

@ -20,6 +20,7 @@ public class AnalysisResponse
{ {
public string VideoTitle { get; set; } = string.Empty; public string VideoTitle { get; set; } = string.Empty;
public string DocumentTitle { get; set; } = string.Empty; // New 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 Category { get; set; } = "OTHER"; // New
public string Transcript { get; set; } = string.Empty; public string Transcript { get; set; } = string.Empty;
public List<KeyMoment> KeyMoments { get; set; } = []; public List<KeyMoment> KeyMoments { get; set; } = [];
@ -65,6 +66,7 @@ public class ProgressUpdate
public class VideoInfo public class VideoInfo
{ {
public string Title { get; set; } = string.Empty; public string Title { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty; // Added
public TimeSpan Duration { get; set; } public TimeSpan Duration { get; set; }
public string Url { get; set; } = string.Empty; public string Url { get; set; } = string.Empty;
public string ThumbnailUrl { get; set; } = string.Empty; public string ThumbnailUrl { get; set; } = string.Empty;
@ -102,4 +104,45 @@ public class DownloadProgress
public interface IPdfSaver public interface IPdfSaver
{ {
Task<string?> SavePdfAsync(byte[] pdfData, string suggestedFileName); Task<string?> SavePdfAsync(byte[] pdfData, string suggestedFileName);
}
/// <summary>
/// A single event in the analysis stream.
/// </summary>
public class AnalysisEvent
{
/// <summary>
/// A user-facing message describing the current step.
/// </summary>
public string Message { get; set; } = string.Empty;
/// <summary>
/// Progress from 0 to 100.
/// </summary>
public int ProgressPercentage { get; set; }
/// <summary>
/// The final result of the analysis. Only present in the last event.
/// </summary>
public AnalysisResult? Result { get; set; }
/// <summary>
/// Set to true if this event represents a critical error that stopped the process.
/// </summary>
public bool IsError { get; set; } = false;
}
/// <summary>
/// The final, consolidated result of a successful analysis.
/// </summary>
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<TutorialSection> TutorialSections { get; set; } = [];
public byte[]? PdfData { get; set; }
public string? RawLlmResponse { get; set; }
} }

View File

@ -4,6 +4,7 @@
@using VideoStudy.Shared @using VideoStudy.Shared
@using System.Net.Http.Json @using System.Net.Http.Json
@using System.Text.Json @using System.Text.Json
@using System.Threading // Added for Cancellation
<PageTitle>VideoStudy - Video Analysis</PageTitle> <PageTitle>VideoStudy - Video Analysis</PageTitle>
@ -203,9 +204,10 @@
private void ToggleLogs() => showLogs = !showLogs; 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 }); logs.Insert(0, new LogEntry { Message = message });
if (eventProgress.HasValue) progress = eventProgress.Value;
StateHasChanged(); StateHasChanged();
} }
@ -255,11 +257,13 @@
logs.Clear(); logs.Clear();
showLogs = true; showLogs = true;
CancellationTokenSource? cts = null;
try try
{ {
currentStep = 1; currentStep = 1;
statusMessage = "Calling API..."; statusMessage = "Iniciando análise...";
AddLog("Sending request to API..."); AddLog("🚀 Enviando requisição para a API...", 5);
var request = new AnalysisRequest var request = new AnalysisRequest
{ {
@ -269,59 +273,79 @@
Mode = "native" Mode = "native"
}; };
// Long timeout via CancellationToken (HttpClient.Timeout can't be changed after first request) cts = new CancellationTokenSource();
using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(10)); 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) if (!response.IsSuccessStatusCode)
{ {
var error = await response.Content.ReadAsStringAsync(); var error = await response.Content.ReadAsStringAsync();
throw new Exception($"API Error ({response.StatusCode}): {error}"); throw new Exception($"API Error ({response.StatusCode}): {error}");
} }
var json = await response.Content.ReadAsStringAsync(); var stream = response.Content.ReadFromJsonAsAsyncEnumerable<AnalysisEvent>(new JsonSerializerOptions { PropertyNameCaseInsensitive = true }, cancellationToken);
var result = JsonSerializer.Deserialize<AnalysisResponse>(json, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
if (result == null) throw new Exception("Failed to deserialize response"); await foreach (var analysisEvent in stream.WithCancellation(cancellationToken))
if (result.DebugSteps != null)
{ {
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!"); AddLog(analysisEvent.Message, analysisEvent.ProgressPercentage);
progress = 100; statusMessage = analysisEvent.Message;
statusMessage = "Done!"; progress = analysisEvent.ProgressPercentage;
StateHasChanged();
if (result.PdfData != null && result.PdfData.Length > 0) if (analysisEvent.Result != null)
{ {
AddLog("Saving PDF..."); var result = analysisEvent.Result;
var fileName = $"VideoStudy_{result.VideoTitle ?? "Tutorial"}_{DateTime.Now:yyyyMMdd_HHmmss}.pdf"; AddLog("✅ Análise concluída!", 100);
// Remove invalid filename chars statusMessage = "Pronto!";
fileName = string.Join("_", fileName.Split(Path.GetInvalidFileNameChars()));
var savedPath = await PdfSaver.SavePdfAsync(result.PdfData, fileName); if (result.PdfData != null && result.PdfData.Length > 0)
if (savedPath != null) {
AddLog($"PDF saved: {savedPath}"); AddLog("Salvando PDF...");
else var fileName = $"VideoStudy_{result.VideoTitle ?? "Tutorial"}_{DateTime.Now:yyyyMMdd_HHmmss}.pdf";
AddLog("PDF save cancelled by user"); // Remove invalid filename chars
} fileName = string.Join("_", fileName.Split(Path.GetInvalidFileNameChars()));
else var savedPath = await PdfSaver.SavePdfAsync(result.PdfData, fileName);
{ if (savedPath != null)
AddLog("No PDF data returned from API"); 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) catch (Exception ex)
{ {
AddLog($"Error: {ex.Message}"); AddLog($"Erro: {ex.Message}");
statusMessage = "Error"; statusMessage = "Erro";
} }
finally finally
{ {
isProcessing = false; isProcessing = false;
videoInfo = null; videoInfo = null;
cts?.Dispose();
} }
} }
} }