fix: Ajustes de aparencia do PDF
This commit is contained in:
parent
480f501993
commit
7417f1f6c6
@ -97,7 +97,7 @@ app.MapGet("/api/video-info", async (string url, AnalysisService service) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var info = await service.GetVideoInfoAsync(url);
|
||||
var info = await service.GetVideoInfoAsync(url, CancellationToken.None);
|
||||
return Results.Ok(info);
|
||||
}
|
||||
catch (Exception ex)
|
||||
@ -108,9 +108,9 @@ app.MapGet("/api/video-info", async (string url, AnalysisService service) =>
|
||||
.WithName("GetVideoInfo");
|
||||
|
||||
// Main analysis endpoint
|
||||
app.MapPost("/api/analyze", async (AnalysisRequest request, AnalysisService service) =>
|
||||
app.MapPost("/api/analyze", (AnalysisRequest request, AnalysisService service, CancellationToken cancellationToken) =>
|
||||
{
|
||||
return await service.AnalyzeVideoAsync(request);
|
||||
return service.AnalyzeVideoAsync(request, cancellationToken);
|
||||
})
|
||||
.WithName("AnalyzeVideo");
|
||||
|
||||
|
||||
@ -1,16 +1,16 @@
|
||||
using System.Diagnostics;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading;
|
||||
using Microsoft.SemanticKernel;
|
||||
using Microsoft.SemanticKernel.ChatCompletion;
|
||||
using PuppeteerSharp;
|
||||
using QuestPDF.Fluent;
|
||||
using QuestPDF.Helpers;
|
||||
using QuestPDF.Infrastructure;
|
||||
using PuppeteerSharp;
|
||||
using VideoStudy.Shared;
|
||||
using SkiaSharp;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography; // Added for MD5 hash
|
||||
using VideoStudy.Shared;
|
||||
|
||||
namespace VideoStudy.API.Services;
|
||||
|
||||
@ -18,20 +18,18 @@ public class AnalysisService
|
||||
{
|
||||
private readonly Kernel _kernel;
|
||||
private readonly ILogger<AnalysisService> _logger;
|
||||
private readonly List<string> _debugSteps = new();
|
||||
|
||||
public AnalysisService(Kernel kernel, ILogger<AnalysisService> logger)
|
||||
{
|
||||
_kernel = kernel;
|
||||
_logger = logger;
|
||||
QuestPDF.Settings.License = LicenseType.Community;
|
||||
|
||||
|
||||
Task.Run(async () => {
|
||||
try
|
||||
try
|
||||
{
|
||||
var browserFetcher = new BrowserFetcher();
|
||||
await browserFetcher.DownloadAsync();
|
||||
_logger.LogInformation("Chromium ready.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@ -40,12 +38,6 @@ public class AnalysisService
|
||||
});
|
||||
}
|
||||
|
||||
private void AddLog(string message)
|
||||
{
|
||||
_logger.LogInformation(message);
|
||||
_debugSteps.Add($"[{DateTime.Now:HH:mm:ss}] {message}");
|
||||
}
|
||||
|
||||
private string GetYtDlpPath()
|
||||
{
|
||||
if (System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform(System.Runtime.InteropServices.OSPlatform.Windows))
|
||||
@ -73,559 +65,217 @@ public class AnalysisService
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<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());
|
||||
Directory.CreateDirectory(tempDir);
|
||||
AddLog($"📁 Inciando processamento em: {tempDir}");
|
||||
|
||||
string rawLlmResponse = "";
|
||||
|
||||
// Get video info early to use duration for timestamp validation
|
||||
AddLog("ℹ️ Obtendo informações do vídeo para validação de timestamps...");
|
||||
var videoInfo = await GetVideoInfoAsync(request.VideoUrl);
|
||||
AddLog($"✅ Duração do vídeo (via yt-dlp): {videoInfo.Duration:hh\\:mm\\:ss}");
|
||||
|
||||
try
|
||||
{
|
||||
// --- Step 1: Transcription ---
|
||||
AddLog("🌐 Obtendo transcrição via yt-dlp...");
|
||||
var (transcript, originalTitle) = await GetTranscriptViaYtDlpAsync(request.VideoUrl, request.Language, tempDir);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(transcript))
|
||||
throw new Exception("Não foi possível obter a transcrição do vídeo.");
|
||||
yield return new AnalysisEvent { ProgressPercentage = 5, Message = "Iniciando análise técnica..." };
|
||||
|
||||
AddLog($"✅ Transcrição obtida: '{originalTitle}' ({transcript.Length} chars).");
|
||||
var videoInfo = await GetVideoInfoAsync(request.VideoUrl, cancellationToken);
|
||||
yield return new AnalysisEvent { ProgressPercentage = 10, Message = $"Processando: {videoInfo.Title}" };
|
||||
|
||||
// --- Step 2: Intelligence ---
|
||||
AddLog("🧠 Enviando transcrição para o Groq (LLM)...");
|
||||
var (tutorialSections, rawJson, category, docTitle) = await GenerateTutorialContentAsync(transcript, originalTitle, request.Language, request.OutputLanguage, videoInfo.Duration);
|
||||
rawLlmResponse = rawJson;
|
||||
|
||||
// Save debug MD
|
||||
var debugFile = Path.Combine(Directory.GetCurrentDirectory(), "DEBUG_LAST_RESPONSE.md");
|
||||
var debugContent = $"# {docTitle} ({category})\n\nSource: {originalTitle}\n\n## Raw JSON\n```json\n{rawJson}\n```\n";
|
||||
await File.WriteAllTextAsync(debugFile, debugContent);
|
||||
AddLog($"📝 Arquivo de debug gerado: {debugFile}");
|
||||
yield return new AnalysisEvent { ProgressPercentage = 15, Message = "Obtendo transcrição..." };
|
||||
var (transcript, _) = await GetTranscriptViaYtDlpAsync(request.VideoUrl, request.Language, tempDir);
|
||||
if (string.IsNullOrWhiteSpace(transcript)) throw new Exception("Transcrição indisponível.");
|
||||
|
||||
// --- Validate and adjust Image Timestamps ---
|
||||
AddLog("⏳ Validando timestamps de imagem gerados pela IA...");
|
||||
foreach (var section in tutorialSections)
|
||||
yield return new AnalysisEvent { ProgressPercentage = 40, Message = "IA estruturando conteúdo e gerando resumo..." };
|
||||
var (sections, rawJson, category, docTitle, summary) = await GenerateTutorialContentAsync(transcript, videoInfo, request.Language, request.OutputLanguage, cancellationToken);
|
||||
|
||||
var sectionsToCapture = sections.Where(s => !string.IsNullOrEmpty(s.ImageTimestamp)).ToList();
|
||||
if (sectionsToCapture.Any())
|
||||
{
|
||||
if (TimeSpan.TryParse(section.ImageTimestamp, out var imageTs))
|
||||
{
|
||||
// Allow for a small buffer (e.g., 5 seconds) just in case, but prefer strict bounds.
|
||||
// If timestamp is more than 5 seconds past video end, consider it invalid.
|
||||
if (imageTs.TotalSeconds > videoInfo.Duration.TotalSeconds + 5)
|
||||
{
|
||||
AddLog($" ⚠️ Timestamp de imagem ({section.ImageTimestamp}) para '{section.Title}' excede a duração do vídeo ({videoInfo.Duration:hh\\:mm\\:ss}). Ajustando para null para evitar erros de captura.");
|
||||
section.ImageTimestamp = null; // Set to null to skip screenshot for this section
|
||||
}
|
||||
}
|
||||
}
|
||||
AddLog("✅ Validação de timestamps concluída.");
|
||||
|
||||
// --- Step 3: Image Capture ---
|
||||
var sectionsWithImages = tutorialSections.Where(s => !string.IsNullOrEmpty(s.ImageTimestamp)).ToList();
|
||||
if (sectionsWithImages.Any())
|
||||
{
|
||||
AddLog($"📸 Capturando {sectionsWithImages.Count} prints usando Puppeteer (Direct Bypass)...");
|
||||
// Pass videoInfo.Duration to the screenshot method
|
||||
await CaptureScreenshotsWithPuppeteerAsync(request.VideoUrl, tutorialSections, tempDir, videoInfo.Duration);
|
||||
}
|
||||
else
|
||||
{
|
||||
AddLog("⚠️ Nenhuma tag [SCREENSHOT] foi gerada pela IA, ou todas foram invalidadas.");
|
||||
yield return new AnalysisEvent { ProgressPercentage = 70, Message = $"Capturando {sectionsToCapture.Count} imagens críticas..." };
|
||||
await CaptureScreenshotsInParallelAsync(request.VideoUrl, sectionsToCapture, videoInfo.Duration, cancellationToken);
|
||||
}
|
||||
|
||||
// --- Step 4: PDF Generation ---
|
||||
AddLog("📄 Gerando PDF final com QuestPDF...");
|
||||
var pdfBytes = GeneratePdf(docTitle, request.VideoUrl, tutorialSections, category);
|
||||
yield return new AnalysisEvent { ProgressPercentage = 90, Message = "Gerando PDF..." };
|
||||
var pdfBytes = GeneratePdf(docTitle, summary, request.VideoUrl, sections, category);
|
||||
|
||||
AddLog("🎉 Processamento concluído com sucesso!");
|
||||
|
||||
return new AnalysisResponse
|
||||
var result = new AnalysisResult
|
||||
{
|
||||
Status = "success",
|
||||
VideoTitle = originalTitle,
|
||||
VideoTitle = videoInfo.Title,
|
||||
DocumentTitle = docTitle,
|
||||
Summary = summary,
|
||||
Category = category,
|
||||
Transcript = transcript,
|
||||
TutorialSections = tutorialSections,
|
||||
TutorialSections = sections,
|
||||
PdfData = pdfBytes,
|
||||
DebugSteps = new List<string>(_debugSteps),
|
||||
RawLlmResponse = rawLlmResponse,
|
||||
Analysis = "Tutorial gerado com sucesso!"
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AddLog($"❌ ERRO: {ex.Message}");
|
||||
return new AnalysisResponse
|
||||
{
|
||||
Status = "error",
|
||||
ErrorMessage = ex.Message,
|
||||
DebugSteps = new List<string>(_debugSteps),
|
||||
RawLlmResponse = rawLlmResponse
|
||||
RawLlmResponse = rawJson
|
||||
};
|
||||
|
||||
yield return new AnalysisEvent { ProgressPercentage = 100, Message = "Concluído!", Result = result };
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (Directory.Exists(tempDir))
|
||||
{
|
||||
try { Directory.Delete(tempDir, true); } catch { }
|
||||
}
|
||||
if (Directory.Exists(tempDir)) try { Directory.Delete(tempDir, true); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<(string transcript, string title)> GetTranscriptViaYtDlpAsync(string url, string language, string outputDir)
|
||||
private async Task CaptureScreenshotsInParallelAsync(string videoUrl, List<TutorialSection> sections, TimeSpan videoDuration, CancellationToken ct)
|
||||
{
|
||||
var ytDlpPath = GetYtDlpPath();
|
||||
// Use a safe output template to avoid filesystem issues, but we want the title.
|
||||
// Better: Fetch title separately or read metadata json.
|
||||
// Let's stick to filename trick but ensure safe chars.
|
||||
// Actually, just fetch title with --print title
|
||||
|
||||
// 1. Fetch Title
|
||||
var titleStartInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = ytDlpPath,
|
||||
Arguments = $"--print title \"{url}\"",
|
||||
RedirectStandardOutput = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true
|
||||
};
|
||||
var pTitle = Process.Start(titleStartInfo);
|
||||
var title = (await pTitle!.StandardOutput.ReadToEndAsync()).Trim();
|
||||
await pTitle.WaitForExitAsync();
|
||||
if (string.IsNullOrEmpty(title)) title = "Video Analysis";
|
||||
|
||||
// 2. Fetch Subs
|
||||
var arguments = $"--skip-download --write-sub --write-auto-sub --sub-lang {language},en --sub-format vtt --output \"%(id)s\" \"{url}\"";
|
||||
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = ytDlpPath,
|
||||
Arguments = arguments,
|
||||
WorkingDirectory = outputDir,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true
|
||||
};
|
||||
|
||||
using var proc = Process.Start(startInfo);
|
||||
await proc.WaitForExitAsync();
|
||||
|
||||
var vttFile = Directory.GetFiles(outputDir, "*.vtt").FirstOrDefault();
|
||||
if (vttFile == null) return (string.Empty, title);
|
||||
|
||||
return (ParseVttToText(await File.ReadAllTextAsync(vttFile)), title);
|
||||
var rawUrl = await GetRawVideoStreamUrl(videoUrl);
|
||||
if (string.IsNullOrEmpty(rawUrl)) return;
|
||||
using var sem = new SemaphoreSlim(3);
|
||||
await Task.WhenAll(sections.Select(s => ProcessSingleSection(s, rawUrl, videoDuration, sem, ct)));
|
||||
}
|
||||
|
||||
private string ParseVttToText(string vttContent)
|
||||
private async Task ProcessSingleSection(TutorialSection section, string rawUrl, TimeSpan duration, SemaphoreSlim sem, CancellationToken ct)
|
||||
{
|
||||
var lines = vttContent.Split('\n');
|
||||
var textLines = new List<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;
|
||||
}
|
||||
|
||||
await sem.WaitAsync(ct);
|
||||
try
|
||||
{
|
||||
using var browser = await Puppeteer.LaunchAsync(new LaunchOptions { Headless = true, Args = new[] { "--no-sandbox", "--window-size=1280,720" } });
|
||||
using var browser = await Puppeteer.LaunchAsync(new LaunchOptions { Headless = true, Args = new[] { "--no-sandbox" } });
|
||||
using var page = await browser.NewPageAsync();
|
||||
await page.SetViewportAsync(new ViewPortOptions { Width = 1280, Height = 720 });
|
||||
await page.SetContentAsync($"<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 = $@"
|
||||
<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++)
|
||||
foreach (var offset in new[] { 0, -1, 1 })
|
||||
{
|
||||
var section = sectionsWithImages[i];
|
||||
// ImageTimestamp might be null due to earlier validation
|
||||
if (string.IsNullOrEmpty(section.ImageTimestamp))
|
||||
{
|
||||
AddLog($"📸 Pulando captura para seção '{section.Title}' pois o timestamp foi invalidado.");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (TimeSpan.TryParse(section.ImageTimestamp, out var ts))
|
||||
{
|
||||
AddLog($"📸 Processando captura para {section.ImageTimestamp} ('{section.Title}')");
|
||||
var targetSeconds = (int)ts.TotalSeconds;
|
||||
|
||||
var candidates = new List<(byte[] ImageData, double Score, int Time, string Hash, int Size)>();
|
||||
|
||||
// "Best of 3" capture window: T-1, T, T+1 seconds
|
||||
var timeOffsets = new[] { -1, 0, 1 };
|
||||
|
||||
foreach (var offset in timeOffsets)
|
||||
{
|
||||
var captureTime = Math.Max(0, targetSeconds + offset);
|
||||
|
||||
// Ensure captureTime does not exceed video duration
|
||||
if (captureTime > videoDuration.TotalSeconds + 5) // Add a small buffer for safety
|
||||
{
|
||||
AddLog($" - ⚠️ Tempo de captura {captureTime}s excede a duração do vídeo ({videoDuration.TotalSeconds:F2}s). Ignorando este candidato.");
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
AddLog($" - Solicitando seek para {captureTime}s (candidato para {section.ImageTimestamp}).");
|
||||
// 1. Seek to the target time
|
||||
await page.EvaluateFunctionAsync(@"(s) => {{
|
||||
const video = document.getElementById('raw-player');
|
||||
video.currentTime = s;
|
||||
}}", captureTime);
|
||||
|
||||
// 2. Intelligent Wait: Wait for the video to have enough data to play
|
||||
await page.WaitForFunctionAsync("() => document.getElementById('raw-player').readyState >= 3", new WaitForFunctionOptions { Timeout = 10000 });
|
||||
|
||||
var actualCurrentTime = await page.EvaluateFunctionAsync<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
|
||||
}
|
||||
}
|
||||
ct.ThrowIfCancellationRequested();
|
||||
int time = Math.Max(0, target + offset);
|
||||
if (time > duration.TotalSeconds) continue;
|
||||
try {
|
||||
await page.EvaluateFunctionAsync($"(s) => document.getElementById('v').currentTime = s", time);
|
||||
await page.WaitForFunctionAsync("() => document.getElementById('v').readyState >= 3", new WaitForFunctionOptions { Timeout = 5000 });
|
||||
var data = await page.ScreenshotDataAsync(new ScreenshotOptions { Type = ScreenshotType.Jpeg, Quality = 85 });
|
||||
var score = CalculateImageClarityScore(data);
|
||||
if (score > 10) candidates.Add((data, score));
|
||||
} catch { }
|
||||
}
|
||||
if (candidates.Any()) section.ImageData = candidates.OrderByDescending(c => c.Score).First().Data;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AddLog($"❌ Erro irrecuperável no Puppeteer: {ex.Message}. As capturas de tela restantes serão abortadas.");
|
||||
}
|
||||
finally { sem.Release(); }
|
||||
}
|
||||
|
||||
private double CalculateImageClarityScore(byte[] imageBytes)
|
||||
public async Task<VideoInfo> GetVideoInfoAsync(string url, CancellationToken ct)
|
||||
{
|
||||
if (imageBytes == null || imageBytes.Length == 0)
|
||||
return -1.0;
|
||||
|
||||
try
|
||||
{
|
||||
// Use SKBitmap.Decode for robust image format handling
|
||||
using var image = SKBitmap.Decode(imageBytes);
|
||||
if (image == null || image.Width == 0 || image.Height == 0)
|
||||
{
|
||||
_logger.LogWarning("SkiaSharp failed to decode image or image is empty.");
|
||||
return -1.0;
|
||||
}
|
||||
|
||||
var brightnessValues = new List<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;
|
||||
}
|
||||
var path = GetYtDlpPath();
|
||||
var proc = Process.Start(new ProcessStartInfo { FileName = path, Arguments = $"--print title --print channel --print duration --print thumbnail --print description \"{url}\"", RedirectStandardOutput = true, UseShellExecute = false, CreateNoWindow = true });
|
||||
await proc!.WaitForExitAsync(ct);
|
||||
var lines = (await proc.StandardOutput.ReadToEndAsync()).Split('\n', StringSplitOptions.RemoveEmptyEntries);
|
||||
double.TryParse(lines.Length > 2 ? lines[2] : "0", System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out var sec);
|
||||
return new VideoInfo { Title = lines[0].Trim(), Author = lines[1].Trim(), Duration = TimeSpan.FromSeconds(sec), ThumbnailUrl = lines[3].Trim(), Description = lines.Length > 4 ? string.Join("\n", lines.Skip(4)).Trim() : "", Url = url };
|
||||
}
|
||||
|
||||
private byte[] GeneratePdf(string docTitle, string videoUrl, List<TutorialSection> sections, string category)
|
||||
private async Task<(string, string)> GetTranscriptViaYtDlpAsync(string url, string lang, string dir)
|
||||
{
|
||||
var categoryColor = category switch
|
||||
{
|
||||
"TUTORIAL" => Colors.Green.Medium,
|
||||
"MEETING" => Colors.Orange.Medium,
|
||||
"LECTURE" => Colors.Purple.Medium,
|
||||
_ => Colors.Blue.Medium
|
||||
};
|
||||
var path = GetYtDlpPath();
|
||||
var start = new ProcessStartInfo { FileName = path, Arguments = $"--skip-download --write-sub --write-auto-sub --sub-lang {lang},en --sub-format vtt --output \"%(id)s\" \"{url}\"", WorkingDirectory = dir, RedirectStandardOutput = true, UseShellExecute = false, CreateNoWindow = true };
|
||||
using var p = Process.Start(start);
|
||||
await p!.WaitForExitAsync();
|
||||
var file = Directory.GetFiles(dir, "*.vtt").FirstOrDefault();
|
||||
return file == null ? ("", "") : (ParseVttToText(await File.ReadAllTextAsync(file)), "");
|
||||
}
|
||||
|
||||
var document = Document.Create(container =>
|
||||
{
|
||||
container.Page(page =>
|
||||
{
|
||||
private string ParseVttToText(string vtt)
|
||||
{
|
||||
var lines = vtt.Split('\n').Select(l => l.Trim()).Where(l => !string.IsNullOrEmpty(l) && !l.StartsWith("WEBVTT") && !l.StartsWith("NOTE") && !l.Contains("-->"));
|
||||
return string.Join(" ", lines.Select(l => Regex.Replace(l, @"<[^>]*>", ""))).Replace(" ", " ");
|
||||
}
|
||||
|
||||
private async Task<(List<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.DefaultTextStyle(x => x.FontSize(11).FontFamily("Segoe UI").Fallback(f => f.FontFamily("Microsoft YaHei")));
|
||||
|
||||
page.Header().Column(c =>
|
||||
{
|
||||
c.Item().Row(row =>
|
||||
{
|
||||
row.RelativeItem().Text(docTitle).SemiBold().FontSize(20).FontColor(Colors.Black);
|
||||
row.ConstantItem(100).AlignRight().Text(category).Bold().FontSize(10).FontColor(categoryColor);
|
||||
page.DefaultTextStyle(x => x.FontSize(11).FontFamily("Segoe UI"));
|
||||
page.Header().Column(c => {
|
||||
c.Item().Row(r => {
|
||||
r.RelativeItem().Text(title).SemiBold().FontSize(22).FontColor(Colors.Blue.Darken3);
|
||||
r.ConstantItem(80).AlignRight().Text(category).Bold().FontSize(10).FontColor(color);
|
||||
});
|
||||
c.Item().PaddingTop(5).LineHorizontal(1).LineColor(Colors.Grey.Lighten2);
|
||||
});
|
||||
|
||||
page.Content().PaddingVertical(1, Unit.Centimetre).Column(column =>
|
||||
{
|
||||
column.Item().Text($"Fonte: {videoUrl}").Italic().FontSize(9).FontColor(Colors.Grey.Medium);
|
||||
column.Item().PaddingBottom(20);
|
||||
|
||||
foreach (var section in sections)
|
||||
{
|
||||
column.Item().Text(section.Title).Bold().FontSize(14).FontColor(categoryColor);
|
||||
column.Item().Text(text => { text.Span(section.Content); });
|
||||
|
||||
if (section.ImageData != null)
|
||||
{
|
||||
column.Item().PaddingVertical(10).Image(section.ImageData).FitWidth();
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(section.ImageTimestamp))
|
||||
{
|
||||
// Placeholder for missing image (Graceful Degradation)
|
||||
column.Item().PaddingVertical(10)
|
||||
.Background(Colors.Grey.Lighten3)
|
||||
.Height(100)
|
||||
.AlignCenter()
|
||||
.AlignMiddle()
|
||||
.Text($"[Imagem Indisponível: {section.ImageTimestamp}]")
|
||||
.FontSize(10)
|
||||
.FontColor(Colors.Grey.Darken2);
|
||||
}
|
||||
|
||||
column.Item().PaddingBottom(15);
|
||||
page.Content().PaddingVertical(1, Unit.Centimetre).Column(col => {
|
||||
// Resumo Section
|
||||
col.Item().Background(Colors.Grey.Lighten4).Padding(10).Column(rc => {
|
||||
rc.Item().Text("Resumo").Bold().FontSize(12).FontColor(Colors.Blue.Medium);
|
||||
rc.Item().PaddingTop(2).Text(summary).Italic();
|
||||
});
|
||||
col.Item().PaddingTop(10).Text(t => { t.Span("Fonte: ").SemiBold(); t.Span(url).Italic().FontSize(9); });
|
||||
|
||||
foreach (var s in sections) {
|
||||
col.Item().PaddingTop(20).Text(s.Title).Bold().FontSize(16).FontColor(color);
|
||||
col.Item().PaddingTop(5).Text(s.Content).LineHeight(1.5f);
|
||||
if (s.ImageData != null) col.Item().PaddingVertical(10).Image(s.ImageData).FitWidth();
|
||||
}
|
||||
});
|
||||
|
||||
page.Footer().AlignCenter().Text(x => { x.Span("Gerado por VideoStudy.app - "); x.CurrentPageNumber(); });
|
||||
page.Footer().AlignCenter().Text(x => { x.Span("VideoStudy.app - "); x.CurrentPageNumber(); });
|
||||
});
|
||||
});
|
||||
return document.GeneratePdf();
|
||||
}).GeneratePdf();
|
||||
}
|
||||
}
|
||||
@ -8,6 +8,7 @@
|
||||
@using VideoStudy.Shared
|
||||
@using System.Net.Http.Json
|
||||
@using System.Text.Json
|
||||
@using System.Threading // Added for CancellationTokenSource
|
||||
@using VideoStudy.Desktop.Components
|
||||
|
||||
<PageTitle>VideoStudy - Video Analysis</PageTitle>
|
||||
@ -196,10 +197,11 @@
|
||||
|
||||
private void ToggleLogs() => showLogs = !showLogs;
|
||||
|
||||
private void AddLog(string message)
|
||||
private void AddLog(string message, int? eventProgress = null)
|
||||
{
|
||||
logs.Insert(0, new LogEntry { Message = message });
|
||||
if (logs.Count > 100) logs.RemoveAt(logs.Count - 1);
|
||||
if (eventProgress.HasValue) progress = eventProgress.Value; // Update global progress
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
@ -239,96 +241,105 @@
|
||||
logs.Clear();
|
||||
showLogs = true;
|
||||
|
||||
CancellationTokenSource? cancellationTokenSource = null;
|
||||
|
||||
try
|
||||
{
|
||||
cancellationTokenSource = new CancellationTokenSource();
|
||||
CancellationToken cancellationToken = cancellationTokenSource.Token;
|
||||
|
||||
currentStep = 1;
|
||||
statusMessage = "Starting Unified Pipeline...";
|
||||
AddLog("🚀 Starting process (Unified Mode)...");
|
||||
statusMessage = "Iniciando pipeline unificado...";
|
||||
AddLog("🚀 Iniciando processo (modo streaming)...", 0);
|
||||
|
||||
string requestVideoUrl = videoUrl;
|
||||
|
||||
// Handle Local File (Simplification: In a real app we'd upload. Here we just error if local for now or assume shared path)
|
||||
// Since we are running on the same machine, we can cheat and pass the path if we save it to a temp location accessible by API?
|
||||
// No, API is separate process. For now, let's prioritize YouTube as requested for the fix.
|
||||
if (activeTab == "local" && selectedFile != null)
|
||||
{
|
||||
// Upload logic would go here. For now, let's just warn.
|
||||
AddLog("⚠️ Local file support requires API upload implementation. Focusing on YouTube for this fix.");
|
||||
// In a real scenario:
|
||||
// var content = new MultipartFormDataContent();
|
||||
// content.Add(new StreamContent(selectedFile.OpenReadStream(...)), "file", selectedFile.Name);
|
||||
// await Http.PostAsync("api/upload", content);
|
||||
// requestVideoUrl = "uploaded_file_path";
|
||||
AddLog("⚠️ Suporte a arquivo local requer implementação de upload para a API. Focando no YouTube por enquanto.");
|
||||
throw new NotSupportedException("Análise de arquivos locais não está implementada com o pipeline de streaming atual.");
|
||||
}
|
||||
|
||||
// --- Send Request to API (The API now handles EVERYTHING: Download -> Transcribe -> AI -> PDF) ---
|
||||
AddLog("📡 Sending request to backend...");
|
||||
// --- Enviar Requisição para a API ---
|
||||
AddLog("📡 Enviando requisição para o backend...", 5);
|
||||
|
||||
var request = new AnalysisRequest
|
||||
var requestBody = new AnalysisRequest
|
||||
{
|
||||
VideoUrl = requestVideoUrl,
|
||||
Mode = "unified", // No longer used but kept for model compat
|
||||
Mode = "unified",
|
||||
Language = selectedLanguage,
|
||||
TranscriptText = null, // API handles transcription now
|
||||
TranscriptText = null,
|
||||
DurationSeconds = currentVideoInfo?.Duration.TotalSeconds ?? 0
|
||||
};
|
||||
|
||||
// Long timeout for the heavy process
|
||||
Http.Timeout = TimeSpan.FromMinutes(10);
|
||||
|
||||
var response = await Http.PostAsJsonAsync("api/analyze", request);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
// Prepare the HTTP request for streaming
|
||||
var httpRequestMessage = new HttpRequestMessage(HttpMethod.Post, "api/analyze")
|
||||
{
|
||||
var error = await response.Content.ReadAsStringAsync();
|
||||
throw new Exception($"API Error ({response.StatusCode}): {error}");
|
||||
}
|
||||
Content = JsonContent.Create(requestBody)
|
||||
};
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync();
|
||||
var result = JsonSerializer.Deserialize<AnalysisResponse>(json, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||
// Use HttpCompletionOption.ResponseHeadersRead to get access to the stream as soon as possible
|
||||
using var response = await Http.SendAsync(httpRequestMessage, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
if (result == null) throw new Exception("Failed to deserialize response");
|
||||
// Read the streaming response
|
||||
var stream = response.Content.ReadFromJsonAsAsyncEnumerable<AnalysisEvent>(new JsonSerializerOptions { PropertyNameCaseInsensitive = true }, cancellationToken);
|
||||
|
||||
// Incorporar logs detalhados da API
|
||||
if (result.DebugSteps != null && result.DebugSteps.Any())
|
||||
await foreach (var analysisEvent in stream.WithCancellation(cancellationToken))
|
||||
{
|
||||
foreach (var step in result.DebugSteps)
|
||||
if (analysisEvent == null) continue;
|
||||
|
||||
if (analysisEvent.IsError)
|
||||
{
|
||||
AddLog($"[API] {step}");
|
||||
throw new Exception($"Erro da API: {analysisEvent.Message}");
|
||||
}
|
||||
|
||||
AddLog(analysisEvent.Message, analysisEvent.ProgressPercentage);
|
||||
statusMessage = analysisEvent.Message;
|
||||
StateHasChanged();
|
||||
|
||||
if (analysisEvent.Result != null)
|
||||
{
|
||||
// Final result received
|
||||
AddLog("✅ Análise completa!", 100);
|
||||
|
||||
// Save PDF
|
||||
if (analysisEvent.Result.PdfData != null && analysisEvent.Result.PdfData.Length > 0)
|
||||
{
|
||||
// Blazor Hybrid approach: saving directly to Downloads folder
|
||||
string downloadsPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Downloads");
|
||||
string fileName = $"VideoStudy_{DateTime.Now:yyyyMMdd_HHmmss}.pdf";
|
||||
string fullPath = Path.Combine(downloadsPath, fileName);
|
||||
|
||||
await File.WriteAllBytesAsync(fullPath, analysisEvent.Result.PdfData);
|
||||
generatedPdfPath = fullPath;
|
||||
AddLog($"📄 PDF Salvo em: {fullPath}", 100);
|
||||
}
|
||||
else
|
||||
{
|
||||
AddLog("⚠️ Nenhum dado de PDF retornado da API.", 100);
|
||||
}
|
||||
|
||||
statusMessage = "Completo!";
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (result.Status == "error") throw new Exception(result.ErrorMessage);
|
||||
|
||||
AddLog("✅ Analysis Complete!");
|
||||
progress = 100;
|
||||
|
||||
// Save PDF
|
||||
if (result.PdfData != null && result.PdfData.Length > 0)
|
||||
{
|
||||
string downloadsPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Downloads");
|
||||
string fileName = $"VideoStudy_{DateTime.Now:yyyyMMdd_HHmmss}.pdf";
|
||||
string fullPath = Path.Combine(downloadsPath, fileName);
|
||||
|
||||
await File.WriteAllBytesAsync(fullPath, result.PdfData);
|
||||
generatedPdfPath = fullPath;
|
||||
AddLog($"📄 PDF Saved: {fullPath}");
|
||||
}
|
||||
else
|
||||
{
|
||||
AddLog("⚠️ No PDF data returned from API.");
|
||||
}
|
||||
|
||||
statusMessage = "Completed!";
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
AddLog("⏸️ Processo cancelado pelo usuário.", progress);
|
||||
statusMessage = "Cancelado";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AddLog($"❌ Error: {ex.Message}");
|
||||
statusMessage = "Error occurred";
|
||||
AddLog($"❌ Erro: {ex.Message}", progress);
|
||||
statusMessage = "Erro ocorrido";
|
||||
}
|
||||
finally
|
||||
{
|
||||
isProcessing = false;
|
||||
cancellationTokenSource?.Dispose();
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -20,6 +20,7 @@ public class AnalysisResponse
|
||||
{
|
||||
public string VideoTitle { get; set; } = string.Empty;
|
||||
public string DocumentTitle { get; set; } = string.Empty; // New
|
||||
public string Summary { get; set; } = string.Empty; // Renamed from ExecutiveSummary
|
||||
public string Category { get; set; } = "OTHER"; // New
|
||||
public string Transcript { get; set; } = string.Empty;
|
||||
public List<KeyMoment> KeyMoments { get; set; } = [];
|
||||
@ -65,6 +66,7 @@ public class ProgressUpdate
|
||||
public class VideoInfo
|
||||
{
|
||||
public string Title { get; set; } = string.Empty;
|
||||
public string Description { get; set; } = string.Empty; // Added
|
||||
public TimeSpan Duration { get; set; }
|
||||
public string Url { get; set; } = string.Empty;
|
||||
public string ThumbnailUrl { get; set; } = string.Empty;
|
||||
@ -102,4 +104,45 @@ public class DownloadProgress
|
||||
public interface IPdfSaver
|
||||
{
|
||||
Task<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; }
|
||||
}
|
||||
@ -4,6 +4,7 @@
|
||||
@using VideoStudy.Shared
|
||||
@using System.Net.Http.Json
|
||||
@using System.Text.Json
|
||||
@using System.Threading // Added for Cancellation
|
||||
|
||||
<PageTitle>VideoStudy - Video Analysis</PageTitle>
|
||||
|
||||
@ -203,9 +204,10 @@
|
||||
|
||||
private void ToggleLogs() => showLogs = !showLogs;
|
||||
|
||||
private void AddLog(string message)
|
||||
private void AddLog(string message, int? eventProgress = null)
|
||||
{
|
||||
logs.Insert(0, new LogEntry { Message = message });
|
||||
if (eventProgress.HasValue) progress = eventProgress.Value;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
@ -255,11 +257,13 @@
|
||||
logs.Clear();
|
||||
showLogs = true;
|
||||
|
||||
CancellationTokenSource? cts = null;
|
||||
|
||||
try
|
||||
{
|
||||
currentStep = 1;
|
||||
statusMessage = "Calling API...";
|
||||
AddLog("Sending request to API...");
|
||||
statusMessage = "Iniciando análise...";
|
||||
AddLog("🚀 Enviando requisição para a API...", 5);
|
||||
|
||||
var request = new AnalysisRequest
|
||||
{
|
||||
@ -269,59 +273,79 @@
|
||||
Mode = "native"
|
||||
};
|
||||
|
||||
// Long timeout via CancellationToken (HttpClient.Timeout can't be changed after first request)
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(10));
|
||||
cts = new CancellationTokenSource();
|
||||
var cancellationToken = cts.Token;
|
||||
|
||||
var response = await Http.PostAsJsonAsync("api/analyze", request, cts.Token);
|
||||
var httpRequestMessage = new HttpRequestMessage(HttpMethod.Post, "api/analyze")
|
||||
{
|
||||
Content = JsonContent.Create(request)
|
||||
};
|
||||
|
||||
using var response = await Http.SendAsync(httpRequestMessage, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var error = await response.Content.ReadAsStringAsync();
|
||||
throw new Exception($"API Error ({response.StatusCode}): {error}");
|
||||
}
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync();
|
||||
var result = JsonSerializer.Deserialize<AnalysisResponse>(json, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||
var stream = response.Content.ReadFromJsonAsAsyncEnumerable<AnalysisEvent>(new JsonSerializerOptions { PropertyNameCaseInsensitive = true }, cancellationToken);
|
||||
|
||||
if (result == null) throw new Exception("Failed to deserialize response");
|
||||
|
||||
if (result.DebugSteps != null)
|
||||
await foreach (var analysisEvent in stream.WithCancellation(cancellationToken))
|
||||
{
|
||||
foreach (var step in result.DebugSteps) AddLog($"[API] {step}");
|
||||
}
|
||||
if (analysisEvent == null) continue;
|
||||
|
||||
if (result.Status == "error") throw new Exception(result.ErrorMessage);
|
||||
if (analysisEvent.IsError)
|
||||
{
|
||||
throw new Exception($"Erro da API: {analysisEvent.Message}");
|
||||
}
|
||||
|
||||
AddLog("Analysis Complete!");
|
||||
progress = 100;
|
||||
statusMessage = "Done!";
|
||||
AddLog(analysisEvent.Message, analysisEvent.ProgressPercentage);
|
||||
statusMessage = analysisEvent.Message;
|
||||
progress = analysisEvent.ProgressPercentage;
|
||||
StateHasChanged();
|
||||
|
||||
if (result.PdfData != null && result.PdfData.Length > 0)
|
||||
{
|
||||
AddLog("Saving PDF...");
|
||||
var fileName = $"VideoStudy_{result.VideoTitle ?? "Tutorial"}_{DateTime.Now:yyyyMMdd_HHmmss}.pdf";
|
||||
// Remove invalid filename chars
|
||||
fileName = string.Join("_", fileName.Split(Path.GetInvalidFileNameChars()));
|
||||
var savedPath = await PdfSaver.SavePdfAsync(result.PdfData, fileName);
|
||||
if (savedPath != null)
|
||||
AddLog($"PDF saved: {savedPath}");
|
||||
else
|
||||
AddLog("PDF save cancelled by user");
|
||||
}
|
||||
else
|
||||
{
|
||||
AddLog("No PDF data returned from API");
|
||||
if (analysisEvent.Result != null)
|
||||
{
|
||||
var result = analysisEvent.Result;
|
||||
AddLog("✅ Análise concluída!", 100);
|
||||
statusMessage = "Pronto!";
|
||||
|
||||
if (result.PdfData != null && result.PdfData.Length > 0)
|
||||
{
|
||||
AddLog("Salvando PDF...");
|
||||
var fileName = $"VideoStudy_{result.VideoTitle ?? "Tutorial"}_{DateTime.Now:yyyyMMdd_HHmmss}.pdf";
|
||||
// Remove invalid filename chars
|
||||
fileName = string.Join("_", fileName.Split(Path.GetInvalidFileNameChars()));
|
||||
var savedPath = await PdfSaver.SavePdfAsync(result.PdfData, fileName);
|
||||
if (savedPath != null)
|
||||
AddLog($"✓ PDF salvo: {savedPath}");
|
||||
else
|
||||
AddLog("⚠️ Salvamento do PDF cancelado pelo usuário");
|
||||
}
|
||||
else
|
||||
{
|
||||
AddLog("⚠️ Nenhum dado de PDF retornado da API");
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
AddLog("⏸️ Operação cancelada.");
|
||||
statusMessage = "Cancelado";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AddLog($"Error: {ex.Message}");
|
||||
statusMessage = "Error";
|
||||
AddLog($"❌ Erro: {ex.Message}");
|
||||
statusMessage = "Erro";
|
||||
}
|
||||
finally
|
||||
{
|
||||
isProcessing = false;
|
||||
videoInfo = null;
|
||||
cts?.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user