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
|
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");
|
||||||
|
|
||||||
|
|||||||
@ -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,7 +18,6 @@ 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)
|
||||||
{
|
{
|
||||||
@ -31,7 +30,6 @@ public class AnalysisService
|
|||||||
{
|
{
|
||||||
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))
|
var videoInfo = await GetVideoInfoAsync(request.VideoUrl, cancellationToken);
|
||||||
throw new Exception("Não foi possível obter a transcrição do vídeo.");
|
yield return new AnalysisEvent { ProgressPercentage = 10, Message = $"Processando: {videoInfo.Title}" };
|
||||||
|
|
||||||
AddLog($"✅ Transcrição obtida: '{originalTitle}' ({transcript.Length} chars).");
|
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.");
|
||||||
|
|
||||||
// --- Step 2: Intelligence ---
|
yield return new AnalysisEvent { ProgressPercentage = 40, Message = "IA estruturando conteúdo e gerando resumo..." };
|
||||||
AddLog("🧠 Enviando transcrição para o Groq (LLM)...");
|
var (sections, rawJson, category, docTitle, summary) = await GenerateTutorialContentAsync(transcript, videoInfo, request.Language, request.OutputLanguage, cancellationToken);
|
||||||
var (tutorialSections, rawJson, category, docTitle) = await GenerateTutorialContentAsync(transcript, originalTitle, request.Language, request.OutputLanguage, videoInfo.Duration);
|
|
||||||
rawLlmResponse = rawJson;
|
|
||||||
|
|
||||||
// Save debug MD
|
var sectionsToCapture = sections.Where(s => !string.IsNullOrEmpty(s.ImageTimestamp)).ToList();
|
||||||
var debugFile = Path.Combine(Directory.GetCurrentDirectory(), "DEBUG_LAST_RESPONSE.md");
|
if (sectionsToCapture.Any())
|
||||||
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 ---
|
|
||||||
AddLog("⏳ Validando timestamps de imagem gerados pela IA...");
|
|
||||||
foreach (var section in tutorialSections)
|
|
||||||
{
|
{
|
||||||
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 CaptureScreenshotsInParallelAsync(string videoUrl, List<TutorialSection> sections, TimeSpan videoDuration, CancellationToken ct)
|
||||||
|
{
|
||||||
|
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 async Task<(string transcript, string title)> GetTranscriptViaYtDlpAsync(string url, string language, string outputDir)
|
private async Task ProcessSingleSection(TutorialSection section, string rawUrl, TimeSpan duration, SemaphoreSlim sem, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var ytDlpPath = GetYtDlpPath();
|
await sem.WaitAsync(ct);
|
||||||
// Use a safe output template to avoid filesystem issues, but we want the title.
|
try
|
||||||
// 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,
|
using var browser = await Puppeteer.LaunchAsync(new LaunchOptions { Headless = true, Args = new[] { "--no-sandbox" } });
|
||||||
Arguments = $"--print title \"{url}\"",
|
using var page = await browser.NewPageAsync();
|
||||||
RedirectStandardOutput = true,
|
await page.SetViewportAsync(new ViewPortOptions { Width = 1280, Height = 720 });
|
||||||
UseShellExecute = false,
|
await page.SetContentAsync($"<html><body style='margin:0;background:black;'><video id='v' width='1280' height='720' src='{rawUrl}'></video></body></html>");
|
||||||
CreateNoWindow = true
|
await page.WaitForSelectorAsync("#v");
|
||||||
};
|
|
||||||
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
|
if (!TimeSpan.TryParse(section.ImageTimestamp, out var ts)) return;
|
||||||
var arguments = $"--skip-download --write-sub --write-auto-sub --sub-lang {language},en --sub-format vtt --output \"%(id)s\" \"{url}\"";
|
int target = (int)ts.TotalSeconds;
|
||||||
|
var candidates = new List<(byte[] Data, double Score)>();
|
||||||
|
|
||||||
var startInfo = new ProcessStartInfo
|
foreach (var offset in new[] { 0, -1, 1 })
|
||||||
{
|
{
|
||||||
FileName = ytDlpPath,
|
ct.ThrowIfCancellationRequested();
|
||||||
Arguments = arguments,
|
int time = Math.Max(0, target + offset);
|
||||||
WorkingDirectory = outputDir,
|
if (time > duration.TotalSeconds) continue;
|
||||||
RedirectStandardOutput = true,
|
try {
|
||||||
RedirectStandardError = true,
|
await page.EvaluateFunctionAsync($"(s) => document.getElementById('v').currentTime = s", time);
|
||||||
UseShellExecute = false,
|
await page.WaitForFunctionAsync("() => document.getElementById('v').readyState >= 3", new WaitForFunctionOptions { Timeout = 5000 });
|
||||||
CreateNoWindow = true
|
var data = await page.ScreenshotDataAsync(new ScreenshotOptions { Type = ScreenshotType.Jpeg, Quality = 85 });
|
||||||
};
|
var score = CalculateImageClarityScore(data);
|
||||||
|
if (score > 10) candidates.Add((data, score));
|
||||||
using var proc = Process.Start(startInfo);
|
} catch { }
|
||||||
await proc.WaitForExitAsync();
|
}
|
||||||
|
if (candidates.Any()) section.ImageData = candidates.OrderByDescending(c => c.Score).First().Data;
|
||||||
var vttFile = Directory.GetFiles(outputDir, "*.vtt").FirstOrDefault();
|
}
|
||||||
if (vttFile == null) return (string.Empty, title);
|
finally { sem.Release(); }
|
||||||
|
|
||||||
return (ParseVttToText(await File.ReadAllTextAsync(vttFile)), title);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private string ParseVttToText(string vttContent)
|
public async Task<VideoInfo> GetVideoInfoAsync(string url, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var lines = vttContent.Split('\n');
|
var path = GetYtDlpPath();
|
||||||
var textLines = new List<string>();
|
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 });
|
||||||
var seen = new HashSet<string>();
|
await proc!.WaitForExitAsync(ct);
|
||||||
|
var lines = (await proc.StandardOutput.ReadToEndAsync()).Split('\n', StringSplitOptions.RemoveEmptyEntries);
|
||||||
foreach (var line in lines)
|
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 };
|
||||||
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)
|
private async Task<(string, string)> GetTranscriptViaYtDlpAsync(string url, string lang, string dir)
|
||||||
{
|
{
|
||||||
var langMap = new Dictionary<string, string>
|
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)), "");
|
||||||
|
}
|
||||||
|
|
||||||
|
private string ParseVttToText(string vtt)
|
||||||
{
|
{
|
||||||
{"en", "English"}, {"pt", "Portuguese (Brazilian)"}, {"es", "Spanish"},
|
var lines = vtt.Split('\n').Select(l => l.Trim()).Where(l => !string.IsNullOrEmpty(l) && !l.StartsWith("WEBVTT") && !l.StartsWith("NOTE") && !l.Contains("-->"));
|
||||||
{"fr", "French"}, {"de", "German"}, {"it", "Italian"},
|
return string.Join(" ", lines.Select(l => Regex.Replace(l, @"<[^>]*>", ""))).Replace(" ", " ");
|
||||||
{"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);
|
|
||||||
|
|
||||||
|
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 = $@"
|
var prompt = $@"
|
||||||
Você é um Editor Chefe e Analista de Conteúdo Sênior.
|
Você é um ANALISTA TÉCNICO DE CONTEÚDO especializado em converter vídeos em documentação estruturada.
|
||||||
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:
|
### REGRAS DE OURO:
|
||||||
1. **Classificar** o vídeo em: 'TUTORIAL', 'MEETING', 'LECTURE' ou 'OTHER'.
|
1. NÃO RESUMA: Transforme cada explicação do vídeo em um tópico técnico detalhado e denso.
|
||||||
2. **Criar um Título Profissional**:
|
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.
|
||||||
- Use o TÍTULO ORIGINAL como base.
|
3. FOCO NO TÍTULO: Garanta que o tema ""{video.Title}"" seja a seção de maior clareza e detalhamento.
|
||||||
- Remova clickbaits, emojis e CAPS LOCK excessivo.
|
4. SCREENSHOTS: Insira `[SCREENSHOT: HH:MM:SS]` ao final de parágrafos que descrevam algo visualmente importante. (Limite: {dur}).
|
||||||
- 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}.**
|
### DADOS:
|
||||||
|
- Título: {video.Title}
|
||||||
|
- Descrição: {video.Description}
|
||||||
|
- Transcrição: {transcript[..Math.Min(transcript.Length, 25000)]}
|
||||||
|
|
||||||
SAÍDA JSON OBRIGATÓRIA:
|
### FORMATO DE SAÍDA (JSON):
|
||||||
{{
|
{{
|
||||||
""category"": ""TUTORIAL | MEETING | LECTURE | OTHER"",
|
""category"": ""TUTORIAL | MEETING | LECTURE | OTHER"",
|
||||||
""documentTitle"": ""Título Profissional Gerado"",
|
""shortTitle"": ""Título Curto e Limpo"",
|
||||||
|
""summary"": ""Um único parágrafo de até 4 linhas sintetizando o valor principal do vídeo."",
|
||||||
""sections"": [
|
""sections"": [
|
||||||
{{
|
{{ ""title"": ""Título do Tópico"", ""content"": ""Explicação técnica densa... [SCREENSHOT: HH:MM:SS]"" }}
|
||||||
""title"": ""Título da Seção"",
|
|
||||||
""content"": ""Texto explicativo detalhado... [SCREENSHOT: 00:05:30]""
|
|
||||||
}}
|
|
||||||
]
|
]
|
||||||
}}";
|
}}
|
||||||
|
Escreva tudo em {outName}.";
|
||||||
|
|
||||||
var result = await chatService.GetChatMessageContentAsync(prompt);
|
var result = await _kernel.GetRequiredService<IChatCompletionService>().GetChatMessageContentAsync(prompt, cancellationToken: ct);
|
||||||
var json = result.Content?.Trim() ?? "{}";
|
var json = Regex.Match(result.Content ?? "{}", @"\{[\s\S]*\}").Value;
|
||||||
// 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);
|
using var doc = JsonDocument.Parse(json);
|
||||||
var root = doc.RootElement;
|
var root = doc.RootElement;
|
||||||
|
|
||||||
if (root.TryGetProperty("category", out var catEl)) category = catEl.GetString() ?? "OTHER";
|
var sections = root.GetProperty("sections").EnumerateArray().Select(el => new TutorialSection {
|
||||||
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() ?? "",
|
Title = el.GetProperty("title").GetString() ?? "",
|
||||||
Content = content.Replace($"[SCREENSHOT: {ts}]", "").Trim(),
|
Content = Regex.Replace(el.GetProperty("content").GetString() ?? "", @"\[SCREENSHOT: \d{2}:\d{2}:\d{2}\]", "").Trim(),
|
||||||
ImageTimestamp = ts
|
ImageTimestamp = Regex.Match(el.GetProperty("content").GetString() ?? "", @"\[SCREENSHOT:\s*(\d{2}:\d{2}:\d{2})\]").Groups[1].Value
|
||||||
});
|
}).ToList();
|
||||||
}
|
|
||||||
} catch { }
|
return (sections, json, root.GetProperty("category").GetString() ?? "OTHER", root.GetProperty("shortTitle").GetString() ?? video.Title, root.GetProperty("summary").GetString() ?? "");
|
||||||
return (sections, json, category, docTitle);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private string? ExtractTimestamp(string text)
|
private async Task<string?> GetRawVideoStreamUrl(string url)
|
||||||
{
|
{
|
||||||
var match = Regex.Match(text, @"\[SCREENSHOT:\s*(\d{2}:\d{2}:\d{2})\]");
|
var proc = Process.Start(new ProcessStartInfo { FileName = GetYtDlpPath(), Arguments = $"-g -f b \"{url}\"", RedirectStandardOutput = true, UseShellExecute = false, CreateNoWindow = true });
|
||||||
return match.Success ? match.Groups[1].Value : null;
|
return (await proc!.StandardOutput.ReadLineAsync())?.Trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<string?> GetRawVideoStreamUrl(string videoUrl)
|
private double CalculateImageClarityScore(byte[] bytes)
|
||||||
{
|
{
|
||||||
var ytDlpPath = GetYtDlpPath();
|
try {
|
||||||
var startInfo = new ProcessStartInfo
|
using var img = SKBitmap.Decode(bytes);
|
||||||
{
|
var lumas = new List<float>();
|
||||||
FileName = ytDlpPath,
|
for (int y = 0; y < img.Height; y += 20) for (int x = 0; x < img.Width; x += 20) {
|
||||||
Arguments = $"-g -f b \"{videoUrl}\"",
|
var p = img.GetPixel(x,y);
|
||||||
RedirectStandardOutput = true,
|
lumas.Add(p.Red * 0.299f + p.Green * 0.587f + p.Blue * 0.114f);
|
||||||
RedirectStandardError = true,
|
}
|
||||||
UseShellExecute = false,
|
var avg = lumas.Average();
|
||||||
CreateNoWindow = true
|
return Math.Sqrt(lumas.Sum(v => Math.Pow(v - avg, 2)) / lumas.Count);
|
||||||
};
|
} catch { return -1; }
|
||||||
|
|
||||||
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)
|
private byte[] GeneratePdf(string title, string summary, string url, List<TutorialSection> sections, string category)
|
||||||
{
|
|
||||||
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
|
|
||||||
{
|
|
||||||
using var browser = await Puppeteer.LaunchAsync(new LaunchOptions { Headless = true, Args = new[] { "--no-sandbox", "--window-size=1280,720" } });
|
|
||||||
using var page = await browser.NewPageAsync();
|
|
||||||
await page.SetViewportAsync(new ViewPortOptions { Width = 1280, Height = 720 });
|
|
||||||
|
|
||||||
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++)
|
|
||||||
{
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
AddLog($"❌ Erro irrecuperável no Puppeteer: {ex.Message}. As capturas de tela restantes serão abortadas.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private double CalculateImageClarityScore(byte[] imageBytes)
|
|
||||||
{
|
|
||||||
if (imageBytes == null || imageBytes.Length == 0)
|
|
||||||
return -1.0;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
// Use SKBitmap.Decode for robust image format handling
|
|
||||||
using var image = SKBitmap.Decode(imageBytes);
|
|
||||||
if (image == null || image.Width == 0 || image.Height == 0)
|
|
||||||
{
|
|
||||||
_logger.LogWarning("SkiaSharp failed to decode image or image is empty.");
|
|
||||||
return -1.0;
|
|
||||||
}
|
|
||||||
|
|
||||||
var brightnessValues = new List<float>(image.Width * image.Height);
|
|
||||||
|
|
||||||
// Using GetPixel is simpler than handling Pixels array for this use case
|
|
||||||
for (int y = 0; y < image.Height; y++)
|
|
||||||
{
|
|
||||||
for (int x = 0; x < image.Width; x++)
|
|
||||||
{
|
|
||||||
var p = image.GetPixel(x, y);
|
|
||||||
// Luma calculation (standard formula for perceived brightness)
|
|
||||||
var brightness = (p.Red * 0.299f) + (p.Green * 0.587f) + (p.Blue * 0.114f);
|
|
||||||
brightnessValues.Add(brightness);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!brightnessValues.Any()) return 0.0;
|
|
||||||
|
|
||||||
var avg = brightnessValues.Average();
|
|
||||||
var sumOfSquares = brightnessValues.Sum(b => Math.Pow(b - avg, 2));
|
|
||||||
var stdDev = Math.Sqrt(sumOfSquares / brightnessValues.Count);
|
|
||||||
|
|
||||||
// A very low standard deviation indicates a uniform image (likely black, white, or single color)
|
|
||||||
return stdDev;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogError(ex, "Error calculating image clarity score.");
|
|
||||||
// If SkiaSharp throws an exception, it's a bad image.
|
|
||||||
return -1.0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private byte[] GeneratePdf(string docTitle, string videoUrl, List<TutorialSection> sections, string category)
|
|
||||||
{
|
|
||||||
var categoryColor = category switch
|
|
||||||
{
|
|
||||||
"TUTORIAL" => Colors.Green.Medium,
|
|
||||||
"MEETING" => Colors.Orange.Medium,
|
|
||||||
"LECTURE" => Colors.Purple.Medium,
|
|
||||||
_ => Colors.Blue.Medium
|
|
||||||
};
|
|
||||||
|
|
||||||
var document = Document.Create(container =>
|
|
||||||
{
|
|
||||||
container.Page(page =>
|
|
||||||
{
|
{
|
||||||
|
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 => {
|
||||||
|
// 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); });
|
||||||
|
|
||||||
page.Content().PaddingVertical(1, Unit.Centimetre).Column(column =>
|
foreach (var s in sections) {
|
||||||
{
|
col.Item().PaddingTop(20).Text(s.Title).Bold().FontSize(16).FontColor(color);
|
||||||
column.Item().Text($"Fonte: {videoUrl}").Italic().FontSize(9).FontColor(Colors.Grey.Medium);
|
col.Item().PaddingTop(5).Text(s.Content).LineHeight(1.5f);
|
||||||
column.Item().PaddingBottom(20);
|
if (s.ImageData != null) col.Item().PaddingVertical(10).Image(s.ImageData).FitWidth();
|
||||||
|
|
||||||
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.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();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -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}");
|
};
|
||||||
|
|
||||||
|
// 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();
|
||||||
|
|
||||||
|
// Read the streaming response
|
||||||
|
var stream = response.Content.ReadFromJsonAsAsyncEnumerable<AnalysisEvent>(new JsonSerializerOptions { PropertyNameCaseInsensitive = true }, cancellationToken);
|
||||||
|
|
||||||
|
await foreach (var analysisEvent in stream.WithCancellation(cancellationToken))
|
||||||
|
{
|
||||||
|
if (analysisEvent == null) continue;
|
||||||
|
|
||||||
|
if (analysisEvent.IsError)
|
||||||
|
{
|
||||||
|
throw new Exception($"Erro da API: {analysisEvent.Message}");
|
||||||
}
|
}
|
||||||
|
|
||||||
var json = await response.Content.ReadAsStringAsync();
|
AddLog(analysisEvent.Message, analysisEvent.ProgressPercentage);
|
||||||
var result = JsonSerializer.Deserialize<AnalysisResponse>(json, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
statusMessage = analysisEvent.Message;
|
||||||
|
StateHasChanged();
|
||||||
|
|
||||||
if (result == null) throw new Exception("Failed to deserialize response");
|
if (analysisEvent.Result != null)
|
||||||
|
|
||||||
// Incorporar logs detalhados da API
|
|
||||||
if (result.DebugSteps != null && result.DebugSteps.Any())
|
|
||||||
{
|
{
|
||||||
foreach (var step in result.DebugSteps)
|
// Final result received
|
||||||
{
|
AddLog("✅ Análise completa!", 100);
|
||||||
AddLog($"[API] {step}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result.Status == "error") throw new Exception(result.ErrorMessage);
|
|
||||||
|
|
||||||
AddLog("✅ Analysis Complete!");
|
|
||||||
progress = 100;
|
|
||||||
|
|
||||||
// Save PDF
|
// Save PDF
|
||||||
if (result.PdfData != null && result.PdfData.Length > 0)
|
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 downloadsPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Downloads");
|
||||||
string fileName = $"VideoStudy_{DateTime.Now:yyyyMMdd_HHmmss}.pdf";
|
string fileName = $"VideoStudy_{DateTime.Now:yyyyMMdd_HHmmss}.pdf";
|
||||||
string fullPath = Path.Combine(downloadsPath, fileName);
|
string fullPath = Path.Combine(downloadsPath, fileName);
|
||||||
|
|
||||||
await File.WriteAllBytesAsync(fullPath, result.PdfData);
|
await File.WriteAllBytesAsync(fullPath, analysisEvent.Result.PdfData);
|
||||||
generatedPdfPath = fullPath;
|
generatedPdfPath = fullPath;
|
||||||
AddLog($"📄 PDF Saved: {fullPath}");
|
AddLog($"📄 PDF Salvo em: {fullPath}", 100);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
AddLog("⚠️ No PDF data returned from API.");
|
AddLog("⚠️ Nenhum dado de PDF retornado da API.", 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
statusMessage = "Completed!";
|
statusMessage = "Completo!";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
AddLog("⏸️ Processo cancelado pelo usuário.", progress);
|
||||||
|
statusMessage = "Cancelado";
|
||||||
}
|
}
|
||||||
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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -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;
|
||||||
@ -103,3 +105,44 @@ 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; }
|
||||||
|
}
|
||||||
@ -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,10 +273,15 @@
|
|||||||
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)
|
||||||
{
|
{
|
||||||
@ -280,48 +289,63 @@
|
|||||||
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 (analysisEvent.IsError)
|
||||||
|
{
|
||||||
|
throw new Exception($"Erro da API: {analysisEvent.Message}");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result.Status == "error") throw new Exception(result.ErrorMessage);
|
AddLog(analysisEvent.Message, analysisEvent.ProgressPercentage);
|
||||||
|
statusMessage = analysisEvent.Message;
|
||||||
|
progress = analysisEvent.ProgressPercentage;
|
||||||
|
StateHasChanged();
|
||||||
|
|
||||||
AddLog("Analysis Complete!");
|
if (analysisEvent.Result != null)
|
||||||
progress = 100;
|
{
|
||||||
statusMessage = "Done!";
|
var result = analysisEvent.Result;
|
||||||
|
AddLog("✅ Análise concluída!", 100);
|
||||||
|
statusMessage = "Pronto!";
|
||||||
|
|
||||||
if (result.PdfData != null && result.PdfData.Length > 0)
|
if (result.PdfData != null && result.PdfData.Length > 0)
|
||||||
{
|
{
|
||||||
AddLog("Saving PDF...");
|
AddLog("Salvando PDF...");
|
||||||
var fileName = $"VideoStudy_{result.VideoTitle ?? "Tutorial"}_{DateTime.Now:yyyyMMdd_HHmmss}.pdf";
|
var fileName = $"VideoStudy_{result.VideoTitle ?? "Tutorial"}_{DateTime.Now:yyyyMMdd_HHmmss}.pdf";
|
||||||
// Remove invalid filename chars
|
// Remove invalid filename chars
|
||||||
fileName = string.Join("_", fileName.Split(Path.GetInvalidFileNameChars()));
|
fileName = string.Join("_", fileName.Split(Path.GetInvalidFileNameChars()));
|
||||||
var savedPath = await PdfSaver.SavePdfAsync(result.PdfData, fileName);
|
var savedPath = await PdfSaver.SavePdfAsync(result.PdfData, fileName);
|
||||||
if (savedPath != null)
|
if (savedPath != null)
|
||||||
AddLog($"PDF saved: {savedPath}");
|
AddLog($"✓ PDF salvo: {savedPath}");
|
||||||
else
|
else
|
||||||
AddLog("PDF save cancelled by user");
|
AddLog("⚠️ Salvamento do PDF cancelado pelo usuário");
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
AddLog("No PDF data returned from API");
|
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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user