281 lines
15 KiB
C#
281 lines
15 KiB
C#
using System.Diagnostics;
|
|
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 SkiaSharp;
|
|
using VideoStudy.Shared;
|
|
|
|
namespace VideoStudy.API.Services;
|
|
|
|
public class AnalysisService
|
|
{
|
|
private readonly Kernel _kernel;
|
|
private readonly ILogger<AnalysisService> _logger;
|
|
|
|
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();
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Failed to download Chromium.");
|
|
}
|
|
});
|
|
}
|
|
|
|
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 IAsyncEnumerable<AnalysisEvent> AnalyzeVideoAsync(AnalysisRequest request, [EnumeratorCancellation] CancellationToken cancellationToken = default)
|
|
{
|
|
var tempDir = Path.Combine(Path.GetTempPath(), "VideoStudy", Guid.NewGuid().ToString());
|
|
Directory.CreateDirectory(tempDir);
|
|
|
|
try
|
|
{
|
|
yield return new AnalysisEvent { ProgressPercentage = 5, Message = "Iniciando análise técnica..." };
|
|
|
|
var videoInfo = await GetVideoInfoAsync(request.VideoUrl, cancellationToken);
|
|
yield return new AnalysisEvent { ProgressPercentage = 10, Message = $"Processando: {videoInfo.Title}" };
|
|
|
|
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.");
|
|
|
|
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())
|
|
{
|
|
yield return new AnalysisEvent { ProgressPercentage = 70, Message = $"Capturando {sectionsToCapture.Count} imagens críticas..." };
|
|
await CaptureScreenshotsInParallelAsync(request.VideoUrl, sectionsToCapture, videoInfo.Duration, cancellationToken);
|
|
}
|
|
|
|
yield return new AnalysisEvent { ProgressPercentage = 90, Message = "Gerando PDF..." };
|
|
var pdfBytes = GeneratePdf(docTitle, summary, request.VideoUrl, sections, category);
|
|
|
|
var result = new AnalysisResult
|
|
{
|
|
VideoTitle = videoInfo.Title,
|
|
DocumentTitle = docTitle,
|
|
Summary = summary,
|
|
Category = category,
|
|
Transcript = transcript,
|
|
TutorialSections = sections,
|
|
PdfData = pdfBytes,
|
|
RawLlmResponse = rawJson
|
|
};
|
|
|
|
yield return new AnalysisEvent { ProgressPercentage = 100, Message = "Concluído!", Result = result };
|
|
}
|
|
finally
|
|
{
|
|
if (Directory.Exists(tempDir)) 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 ProcessSingleSection(TutorialSection section, string rawUrl, TimeSpan duration, SemaphoreSlim sem, CancellationToken ct)
|
|
{
|
|
await sem.WaitAsync(ct);
|
|
try
|
|
{
|
|
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)>();
|
|
|
|
foreach (var offset in new[] { 0, -1, 1 })
|
|
{
|
|
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;
|
|
}
|
|
finally { sem.Release(); }
|
|
}
|
|
|
|
public async Task<VideoInfo> GetVideoInfoAsync(string url, CancellationToken ct)
|
|
{
|
|
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 async Task<(string, string)> GetTranscriptViaYtDlpAsync(string url, string lang, string dir)
|
|
{
|
|
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)
|
|
{
|
|
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"));
|
|
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(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("VideoStudy.app - "); x.CurrentPageNumber(); });
|
|
});
|
|
}).GeneratePdf();
|
|
}
|
|
} |