VideoStudy/VideoStudy.API/Services/AnalysisService.cs

631 lines
28 KiB
C#
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System.Diagnostics;
using System.Text.RegularExpressions;
using System.Text.Json;
using Microsoft.SemanticKernel;
using Microsoft.SemanticKernel.ChatCompletion;
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
namespace VideoStudy.API.Services;
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
{
var browserFetcher = new BrowserFetcher();
await browserFetcher.DownloadAsync();
_logger.LogInformation("Chromium ready.");
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to download Chromium.");
}
});
}
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))
{
var exeName = "yt-dlp.exe";
var currentPath = Path.Combine(Directory.GetCurrentDirectory(), exeName);
if (File.Exists(currentPath)) return currentPath;
var basePath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, exeName);
if (File.Exists(basePath)) return basePath;
var binPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Binaries", exeName);
if (File.Exists(binPath)) return binPath;
return "yt-dlp";
}
else
{
var baseDir = AppDomain.CurrentDomain.BaseDirectory;
var binariesDir = Path.Combine(baseDir, "Binaries");
string executableName = "yt-dlp_linux";
if (System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform(System.Runtime.InteropServices.OSPlatform.OSX))
executableName = "yt-dlp_macos";
var fullPath = Path.Combine(binariesDir, executableName);
if (!File.Exists(fullPath)) return "yt-dlp";
try { Process.Start("chmod", $"+x \"{fullPath}\"").WaitForExit(); } catch { }
return fullPath;
}
}
public async Task<VideoInfo> GetVideoInfoAsync(string url)
{
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.");
AddLog($"✅ Transcrição obtida: '{originalTitle}' ({transcript.Length} chars).");
// --- 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}");
// --- Validate and adjust Image Timestamps ---
AddLog("⏳ Validando timestamps de imagem gerados pela IA...");
foreach (var section in tutorialSections)
{
if (TimeSpan.TryParse(section.ImageTimestamp, out var imageTs))
{
// Allow for a small buffer (e.g., 5 seconds) just in case, but prefer strict bounds.
// If timestamp is more than 5 seconds past video end, consider it invalid.
if (imageTs.TotalSeconds > videoInfo.Duration.TotalSeconds + 5)
{
AddLog($" ⚠️ Timestamp de imagem ({section.ImageTimestamp}) para '{section.Title}' excede a duração do vídeo ({videoInfo.Duration:hh\\:mm\\:ss}). Ajustando para null para evitar erros de captura.");
section.ImageTimestamp = null; // Set to null to skip screenshot for this section
}
}
}
AddLog("✅ Validação de timestamps concluída.");
// --- Step 3: Image Capture ---
var sectionsWithImages = tutorialSections.Where(s => !string.IsNullOrEmpty(s.ImageTimestamp)).ToList();
if (sectionsWithImages.Any())
{
AddLog($"📸 Capturando {sectionsWithImages.Count} prints usando Puppeteer (Direct Bypass)...");
// 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 ---
AddLog("📄 Gerando PDF final com QuestPDF...");
var pdfBytes = GeneratePdf(docTitle, request.VideoUrl, tutorialSections, category);
AddLog("🎉 Processamento concluído com sucesso!");
return new AnalysisResponse
{
Status = "success",
VideoTitle = originalTitle,
DocumentTitle = docTitle,
Category = category,
Transcript = transcript,
TutorialSections = tutorialSections,
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
};
}
finally
{
if (Directory.Exists(tempDir))
{
try { Directory.Delete(tempDir, true); } catch { }
}
}
}
private async Task<(string transcript, string title)> GetTranscriptViaYtDlpAsync(string url, string language, string outputDir)
{
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);
}
private string ParseVttToText(string vttContent)
{
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;
}
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 =>
{
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);
});
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.Footer().AlignCenter().Text(x => { x.Span("Gerado por VideoStudy.app - "); x.CurrentPageNumber(); });
});
});
return document.GeneratePdf();
}
}