VideoStudy/VideoStudy.API/Services/AnalysisService.cs
Ricardo Carneiro 222a30d658 fix: corrige deadlock no FFmpeg por stderr redirecionado sem leitura
Redirecionar stderr sem consumir o buffer causa deadlock quando FFmpeg
escreve muito (codec info, progress). Removida a redireção — stderr
vai para o processo pai (API console).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 14:28:03 -03:00

364 lines
16 KiB
C#

using System.Diagnostics;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using System.Text.Json;
using System.Text.RegularExpressions;
using Microsoft.SemanticKernel;
using Microsoft.SemanticKernel.ChatCompletion;
using QuestPDF.Fluent;
using QuestPDF.Helpers;
using QuestPDF.Infrastructure;
using VideoStudy.Shared;
namespace VideoStudy.API.Services;
public class AnalysisService
{
private readonly Kernel _kernel;
private readonly ILogger<AnalysisService> _logger;
private readonly IConfiguration _configuration;
public AnalysisService(Kernel kernel, ILogger<AnalysisService> logger, IConfiguration configuration)
{
_kernel = kernel;
_logger = logger;
_configuration = configuration;
QuestPDF.Settings.License = LicenseType.Community;
}
private string GetYtDlpPath()
{
string exeName = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "yt-dlp.exe" :
RuntimeInformation.IsOSPlatform(OSPlatform.OSX) ? "yt-dlp_macos" : "yt-dlp_linux";
// Walk up from base directory looking for yt-dlp
var dir = new DirectoryInfo(AppDomain.CurrentDomain.BaseDirectory);
for (int i = 0; i < 7; i++)
{
var path = Path.Combine(dir.FullName, exeName);
if (File.Exists(path)) return path;
var binPath = Path.Combine(dir.FullName, "Binaries", exeName);
if (File.Exists(binPath)) return binPath;
if (dir.Parent == null) break;
dir = dir.Parent;
}
return "yt-dlp"; // fallback to PATH
}
private string GetCookiesArg()
{
var browser = _configuration["YtDlp:CookiesBrowser"];
return string.IsNullOrWhiteSpace(browser) ? "" : $"--cookies-from-browser {browser}";
}
public async IAsyncEnumerable<AnalysisEvent> AnalyzeVideoAsync(AnalysisRequest request, [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
var tempDir = Path.Combine(Path.GetTempPath(), "VideoStudy", Guid.NewGuid().ToString());
Directory.CreateDirectory(tempDir);
string? errorMessage = null;
AnalysisResult? finalResult = null;
yield return new AnalysisEvent { ProgressPercentage = 5, Message = "Obtendo informações do vídeo..." };
VideoInfo? videoInfo = null;
try {
videoInfo = await GetVideoInfoAsync(request.VideoUrl, cancellationToken);
} catch (Exception ex) {
errorMessage = $"Erro ao acessar o YouTube: {ex.Message}";
}
if (errorMessage == null && videoInfo != null)
{
yield return new AnalysisEvent { ProgressPercentage = 10, Message = $"Analisando: {videoInfo.Title}" };
yield return new AnalysisEvent { ProgressPercentage = 15, Message = "Obtendo transcrição..." };
string? transcript = null;
try {
transcript = await GetTranscriptViaYtDlpAsync(request.VideoUrl, request.Language, tempDir);
if (string.IsNullOrWhiteSpace(transcript)) errorMessage = "O vídeo não possui transcrição disponível.";
} catch (Exception ex) {
errorMessage = $"Erro na transcrição: {ex.Message}";
}
if (errorMessage == null && transcript != null)
{
yield return new AnalysisEvent { ProgressPercentage = 40, Message = "IA estruturando conteúdo..." };
List<TutorialSection>? sections = null;
string? rawJson = null, category = null, docTitle = null, summary = null;
try {
var aiResult = await GenerateTutorialContentAsync(transcript, videoInfo, request.Language, request.OutputLanguage, request.UserContext, cancellationToken);
sections = aiResult.sections;
rawJson = aiResult.rawJson;
category = aiResult.category;
docTitle = aiResult.docTitle;
summary = aiResult.summary;
} catch (Exception ex) {
errorMessage = $"IA Indisponível: {ex.Message}. Verifique a chave do Groq.";
}
if (errorMessage == null && sections != null)
{
var sectionsWithImages = sections.Where(s => !string.IsNullOrEmpty(s.ImageTimestamp)).ToList();
if (sectionsWithImages.Any())
{
yield return new AnalysisEvent { ProgressPercentage = 70, Message = $"Capturando {sectionsWithImages.Count} imagens com FFmpeg..." };
try {
await CaptureScreenshotsAsync(request.VideoUrl, sectionsWithImages, videoInfo.Duration, cancellationToken);
} catch (Exception ex) {
_logger.LogWarning(ex, "Falha na captura de screenshots — continuando sem imagens.");
}
}
yield return new AnalysisEvent { ProgressPercentage = 90, Message = "Gerando documento PDF..." };
try {
var pdfBytes = GeneratePdf(docTitle!, summary!, request.VideoUrl, sections, category!);
finalResult = new AnalysisResult {
VideoTitle = videoInfo.Title,
DocumentTitle = docTitle!,
Summary = summary!,
Category = category!,
Transcript = transcript,
TutorialSections = sections,
PdfData = pdfBytes,
RawLlmResponse = rawJson
};
} catch (Exception ex) {
errorMessage = $"Erro ao gerar PDF: {ex.Message}";
}
}
}
}
try { if (Directory.Exists(tempDir)) Directory.Delete(tempDir, true); } catch { }
if (errorMessage != null) {
yield return new AnalysisEvent { IsError = true, Message = errorMessage, ProgressPercentage = 100 };
} else if (finalResult != null) {
yield return new AnalysisEvent { ProgressPercentage = 100, Message = "Concluído!", Result = finalResult };
}
}
private async Task CaptureScreenshotsAsync(string videoUrl, List<TutorialSection> sections, TimeSpan videoDuration, CancellationToken ct)
{
var streamUrl = await GetRawVideoStreamUrl(videoUrl);
if (string.IsNullOrEmpty(streamUrl)) return;
using var sem = new SemaphoreSlim(2);
await Task.WhenAll(sections.Select(s => CaptureFrameAsync(s, streamUrl, videoDuration, sem, ct)));
}
private async Task CaptureFrameAsync(TutorialSection section, string streamUrl, TimeSpan duration, SemaphoreSlim sem, CancellationToken ct)
{
await sem.WaitAsync(ct);
var outputFile = Path.Combine(Path.GetTempPath(), $"vs_frame_{Guid.NewGuid()}.jpg");
try
{
if (!TimeSpan.TryParse(section.ImageTimestamp, out var ts) || ts > duration) return;
var args = $"-ss {ts:hh\\:mm\\:ss} -i \"{streamUrl}\" -frames:v 1 -q:v 2 -y \"{outputFile}\"";
var psi = new ProcessStartInfo
{
FileName = "ffmpeg",
Arguments = args,
RedirectStandardError = false,
UseShellExecute = false,
CreateNoWindow = true
};
using var p = Process.Start(psi)!;
await p.WaitForExitAsync(ct);
if (p.ExitCode == 0 && File.Exists(outputFile))
section.ImageData = await File.ReadAllBytesAsync(outputFile, ct);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Screenshot falhou em {Timestamp}", section.ImageTimestamp);
}
finally
{
if (File.Exists(outputFile)) File.Delete(outputFile);
sem.Release();
}
}
public async Task<VideoInfo> GetVideoInfoAsync(string url, CancellationToken ct)
{
var ytdlp = GetYtDlpPath();
var cookies = GetCookiesArg();
var psi = new ProcessStartInfo
{
FileName = ytdlp,
Arguments = $"{cookies} --print title --print channel --print duration --print thumbnail \"{url}\"",
RedirectStandardOutput = true,
UseShellExecute = false,
CreateNoWindow = true
};
using var proc = Process.Start(psi)!;
await proc.WaitForExitAsync(ct);
var output = await proc.StandardOutput.ReadToEndAsync(ct);
var lines = output.Split('\n', StringSplitOptions.RemoveEmptyEntries);
if (lines.Length < 1) throw new Exception("Falha ao ler dados do vídeo via yt-dlp.");
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.Length > 1 ? lines[1].Trim() : "",
Duration = TimeSpan.FromSeconds(sec),
ThumbnailUrl = lines.Length > 3 ? lines[3].Trim() : "",
Url = url
};
}
private async Task<string> GetTranscriptViaYtDlpAsync(string url, string lang, string dir)
{
var ytdlp = GetYtDlpPath();
var cookies = GetCookiesArg();
var psi = new ProcessStartInfo
{
FileName = ytdlp,
Arguments = $"{cookies} --skip-download --write-auto-sub --sub-lang {lang},en --sub-format vtt --output \"%(id)s\" \"{url}\"",
WorkingDirectory = dir,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
};
using var p = Process.Start(psi)!;
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, string? userContext, CancellationToken ct)
{
var langMap = new Dictionary<string, string> { { "en", "English" }, { "pt", "Portuguese (Brazilian)" }, { "es", "Spanish" }, { "fr", "French" } };
var outName = langMap.GetValueOrDefault(outLang ?? inLang, "Portuguese (Brazilian)");
var dur = video.Duration.ToString(@"hh\:mm\:ss");
var contextSection = string.IsNullOrWhiteSpace(userContext)
? ""
: $"\n### CONTEXTO DO USUÁRIO:\n{userContext}\nUse este contexto para ajustar o nível de detalhe, linguagem e foco da análise.\n";
var prompt = $@"Você é um ANALISTA TÉCNICO DE CONTEÚDO especializado em converter vídeos em documentação estruturada.
{contextSection}
### REGRAS:
1. Transforme cada explicação em um tópico técnico detalhado — NÃO resuma demais.
2. Dedique o mesmo nível de profundidade a todos os tópicos.
3. Garanta que o tema ""{video.Title}"" seja a seção de maior clareza.
4. Insira `[SCREENSHOT: HH:MM:SS]` ao final de parágrafos com algo visualmente importante. (Limite: {dur})
### DADOS:
- Título: {video.Title}
- Transcrição: {transcript[..Math.Min(transcript.Length, 25000)]}
### FORMATO DE SAÍDA (JSON):
{{
""category"": ""TUTORIAL | LECTURE | OTHER"",
""shortTitle"": ""Título Curto e Limpo"",
""summary"": ""Um parágrafo de até 4 linhas resumindo o valor principal do vídeo."",
""sections"": [
{{ ""title"": ""Título do Tópico"", ""content"": ""Explicação densa... [SCREENSHOT: HH:MM:SS]"" }}
]
}}
Escreva tudo em {outName}.";
var chatService = _kernel.GetRequiredService<IChatCompletionService>();
var result = await chatService.GetChatMessageContentAsync(prompt, cancellationToken: ct);
string rawContent = result.Content ?? "{}";
_logger.LogInformation("Resposta bruta da IA: {RawContent}", rawContent);
var jsonMatch = Regex.Match(rawContent, @"\{[\s\S]*\}");
if (!jsonMatch.Success) throw new Exception("A IA não retornou um JSON válido.");
string json = jsonMatch.Value;
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
var sections = root.GetProperty("sections").EnumerateArray().Select(el =>
{
var content = el.GetProperty("content").GetString() ?? "";
var tsMatch = Regex.Match(content, @"\[SCREENSHOT:\s*(\d{2}:\d{2}:\d{2})\]");
return new TutorialSection
{
Title = el.GetProperty("title").GetString() ?? "",
Content = Regex.Replace(content, @"\[SCREENSHOT: \d{2}:\d{2}:\d{2}\]", "").Trim(),
ImageTimestamp = tsMatch.Success ? tsMatch.Groups[1].Value : null
};
}).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 ytdlp = GetYtDlpPath();
var cookies = GetCookiesArg();
var psi = new ProcessStartInfo
{
FileName = ytdlp,
Arguments = $"{cookies} -g -f \"bv*[height<=720][ext=mp4]/bv*[height<=720]/b\" \"{url}\"",
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
};
using var proc = Process.Start(psi)!;
var line = await proc.StandardOutput.ReadLineAsync();
await proc.WaitForExitAsync();
return line?.Trim();
}
private byte[] GeneratePdf(string title, string summary, string url, List<TutorialSection> sections, string category)
{
var color = category switch { "TUTORIAL" => Colors.Green.Medium, "LECTURE" => 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 =>
{
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();
}
}