Compare commits

..

2 Commits

40 changed files with 1527 additions and 120 deletions

View File

@ -0,0 +1,7 @@
{
"permissions": {
"allow": [
"Bash(dotnet:*)"
]
}
}

View File

@ -1,73 +1,33 @@
# Debug Groq Response # Por Que Arroz e Não Batata: Uma Análise Histórica e Científica (OTHER)
URL: https://youtu.be/-EaO2S4Lyvk?si=tJrVNLZCRT2unmD1 Source: Por que ARROZ e nÒo BATATA? | Cortes do Manual do Mundo
## Raw JSON ## Raw JSON
```json ```json
{ {
"category": "OTHER",
"documentTitle": "Por Que Arroz e Não Batata: Uma Análise Histórica e Científica",
"sections": [ "sections": [
{ {
"title": "Passo 1: Introdução", "title": "Introdução",
"content": "Bem-vindo ao tutorial de The Legend of Zelda: Breath of the Wild. Neste vídeo, vamos explorar 9 dicas para iniciantes para ajudá-los a superar as ameaças iniciais do jogo. [SCREENSHOT: 00:00:20]" "content": "A nossa base alimentar é composta por vários alimentos, mas o arroz é um dos principais. Isso nos leva a questionar por que o arroz é mais comum do que a batata, considerando que fomos colonizados por europeus, que têm a batata como alimento básico. A resposta é complexa e envolve a história da culinária e da colonização. Os indígenas no Brasil já cultivavam alguns tipos de arroz, e na África, o arroz também era cultivado. Isso significa que as populações que se juntaram para formar o Brasil já consumiam arroz. De acordo com a Embrapa, o arroz é um alimento básico da dieta de 2,5 bilhões de pessoas, cerca de 1/3 do mundo inteiro. [SCREENSHOT: 00:00:30]"
}, },
{ {
"title": "Passo 2: Planeje suas batalhas", "title": "A Descoberta da Batata",
"content": "Não é sempre a melhor ideia correr direto para a batalha. Em vez disso, tire um momento para formular um plano de ação antes de se aproximar do inimigo. Por exemplo, se você encontrar um acampamento de inimigos, pense sobre como você pode eliminá-los com o mínimo de problemas. [SCREENSHOT: 00:01:20]" "content": "Quando os europeus chegaram ao Brasil, eles não comiam batata. Isso porque a batata é originária da América, especificamente dos Andes. Foi a população da América que ensinou os europeus a comer batata. Além da batata, outras plantas importantes, como o milho e o cacau, também são originárias da América. Isso mostra que a culinária europeia antes da descoberta do continente americano devia ser bem diferente. [SCREENSHOT: 00:01:45]"
}, },
{ {
"title": "Passo 3: Gerencie seus equipamentos", "title": "A Função da Bolinha no Spray de Tinta",
"content": "Os equipamentos e armas do jogo têm uma mecânica de durabilidade, o que significa que eles se desgastam com o tempo e eventualmente quebram. Certifique-se de usar seus equipamentos mais poderosos apenas contra inimigos mais difíceis e chefs. [SCREENSHOT: 00:02:30]" "content": "Muitas pessoas já se perguntaram sobre a função da bolinha que fica dentro do spray de tinta. Essa bolinha é importante para misturar a tinta com o propelente, que é um gás inflamável, como o propano ou butano. A bolinha ajuda a manter a mistura uniforme, permitindo que a tinta seja aplicada de forma correta. [SCREENSHOT: 00:02:50]"
}, },
{ {
"title": "Passo 4: Escolha suas batalhas", "title": "Água com Gás e Água Oxigenada",
"content": "Não há vergonha em fugir de uma batalha se não há um objetivo em mente. Seus recursos de armas e equipamentos são limitados, então é sempre melhor evitar encontros desnecessários. [SCREENSHOT: 00:03:20]" "content": "Muitas pessoas confundem água com gás e água oxigenada. No entanto, elas são diferentes. A água com gás contém gás carbônico dissolvido, que forma bolhas quando a garrafa é aberta. Já a água oxigenada é uma substância química diferente, com a fórmula H2O2, que contém um átomo de oxigênio a mais do que a água comum. [SCREENSHOT: 00:03:40]"
}, },
{ {
"title": "Passo 5: Complete os santuários", "title": "Conclusão e Novos Projetos",
"content": "Os santuários oferecem uma ótima distração dos períodos prolongados de exploração e completá-los pode recompensar você com espíritos orbitais, que podem ser usados para comprar upgrades úteis. Além disso, completar um santuário permite que você desbloqueie sua localização como um ponto de viagem rápida. [SCREENSHOT: 00:04:30]" "content": "Além de explorar a história e a ciência por trás de nossos alimentos e produtos, também estamos trabalhando em novos projetos, como a revisão do grande livro de biologia, que faz parte da coleção 'O Grande Livro'. Esses livros são uma ótima maneira de aprender de forma divertida e interativa. [SCREENSHOT: 00:04:20]"
},
{
"title": "Passo 6: Evite os guardiões",
"title": "Os guardiões são inimigos mortais que você deve evitar a todo custo, pelo menos no início do jogo. Se você precisar lutar contra um guardião, certifique-se de estar montado a cavalo e equipado com uma arsenal de bombas e flechas elementais. [SCREENSHOT: 00:05:40]"
},
{
"title": "Passo 7: Cozinhe para sobreviver",
"content": "A culinária é uma parte essencial da sua sobrevivência e pode ajudá-lo de muitas maneiras. Para cozinhar, basta escolher alguns ingredientes do seu inventário e jogá-los em uma fogueira com uma panela. Dependendo dos ingredientes que você escolher, você pode criar um prato ou elixir que oferte um efeito de status diferente. [SCREENSHOT: 00:06:50]"
},
{
"title": "Passo 8: Use armas pesadas contra inimigos com escudos",
"content": "Se um inimigo estiver usando um escudo, você pode usar uma arma pesada como um machado ou um martelo para derrubar o escudo e torná-lo vulnerável ao ataque. [SCREENSHOT: 00:07:40]"
},
{
"title": "Passo 9: Ataque inimigos desprevenidos",
"content": "Se você se aproximar de um inimigo por trás, um prompt aparecerá, permitindo que você dê um golpe devastador enquanto o inimigo está desprevenido. Essa manobra é muito útil e pode matar inimigos em um único golpe, independentemente do seu nível de saúde e defesa. [SCREENSHOT: 00:08:30]"
},
{
"title": "Passo 10: Evite riscos desnecessários",
"content": "Não use nada de metal durante uma tempestade, pois isso pode ser perigoso. Além disso, certifique-se de seguir as dicas anteriores para minimizar os riscos e maximizar as recompensas. [SCREENSHOT: 00:09:20]"
} }
] ]
} }
``` ```
## Sections
### Passo 1: Introdução
Bem-vindo ao tutorial de The Legend of Zelda: Breath of the Wild. Neste vídeo, vamos explorar 9 dicas para iniciantes para ajudá-los a superar as ameaças iniciais do jogo.
**Timestamp:** 00:00:20
### Passo 2: Planeje suas batalhas
Não é sempre a melhor ideia correr direto para a batalha. Em vez disso, tire um momento para formular um plano de ação antes de se aproximar do inimigo. Por exemplo, se você encontrar um acampamento de inimigos, pense sobre como você pode eliminá-los com o mínimo de problemas.
**Timestamp:** 00:01:20
### Passo 3: Gerencie seus equipamentos
Os equipamentos e armas do jogo têm uma mecânica de durabilidade, o que significa que eles se desgastam com o tempo e eventualmente quebram. Certifique-se de usar seus equipamentos mais poderosos apenas contra inimigos mais difíceis e chefs.
**Timestamp:** 00:02:30
### Passo 4: Escolha suas batalhas
Não há vergonha em fugir de uma batalha se não há um objetivo em mente. Seus recursos de armas e equipamentos são limitados, então é sempre melhor evitar encontros desnecessários.
**Timestamp:** 00:03:20
### Passo 5: Complete os santuários
Os santuários oferecem uma ótima distração dos períodos prolongados de exploração e completá-los pode recompensar você com espíritos orbitais, que podem ser usados para comprar upgrades úteis. Além disso, completar um santuário permite que você desbloqueie sua localização como um ponto de viagem rápida.
**Timestamp:** 00:04:30

View File

@ -92,6 +92,21 @@ app.UseCors("AllowAll");
app.MapGet("/health", () => Results.Ok(new { status = "healthy", timestamp = DateTime.UtcNow })); app.MapGet("/health", () => Results.Ok(new { status = "healthy", timestamp = DateTime.UtcNow }));
// Video info endpoint (lightweight preview)
app.MapGet("/api/video-info", async (string url, AnalysisService service) =>
{
try
{
var info = await service.GetVideoInfoAsync(url);
return Results.Ok(info);
}
catch (Exception ex)
{
return Results.BadRequest(new { error = ex.Message });
}
})
.WithName("GetVideoInfo");
// Main analysis endpoint // Main analysis endpoint
app.MapPost("/api/analyze", async (AnalysisRequest request, AnalysisService service) => app.MapPost("/api/analyze", async (AnalysisRequest request, AnalysisService service) =>
{ {

View File

@ -8,6 +8,9 @@ using QuestPDF.Helpers;
using QuestPDF.Infrastructure; using QuestPDF.Infrastructure;
using PuppeteerSharp; using PuppeteerSharp;
using VideoStudy.Shared; using VideoStudy.Shared;
using SkiaSharp;
using System.Linq;
using System.Security.Cryptography; // Added for MD5 hash
namespace VideoStudy.API.Services; namespace VideoStudy.API.Services;
@ -70,6 +73,46 @@ public class AnalysisService
} }
} }
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) public async Task<AnalysisResponse> AnalyzeVideoAsync(AnalysisRequest request)
{ {
_debugSteps.Clear(); _debugSteps.Clear();
@ -79,51 +122,75 @@ public class AnalysisService
string rawLlmResponse = ""; 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 --- // --- Step 1: Transcription ---
AddLog("🌐 Obtendo transcrição via yt-dlp..."); AddLog("🌐 Obtendo transcrição via yt-dlp...");
var transcript = await GetTranscriptViaYtDlpAsync(request.VideoUrl, request.Language, tempDir); var (transcript, originalTitle) = await GetTranscriptViaYtDlpAsync(request.VideoUrl, request.Language, tempDir);
if (string.IsNullOrWhiteSpace(transcript)) if (string.IsNullOrWhiteSpace(transcript))
throw new Exception("Não foi possível obter a transcrição do vídeo."); throw new Exception("Não foi possível obter a transcrição do vídeo.");
AddLog($"✅ Transcrição obtida ({transcript.Length} caracteres)."); AddLog($"✅ Transcrição obtida: '{originalTitle}' ({transcript.Length} chars).");
// --- Step 2: Intelligence --- // --- Step 2: Intelligence ---
AddLog("🧠 Enviando transcrição para o Groq (LLM)..."); AddLog("🧠 Enviando transcrição para o Groq (LLM)...");
var (tutorialSections, rawJson) = await GenerateTutorialContentAsync(transcript, request.Language); var (tutorialSections, rawJson, category, docTitle) = await GenerateTutorialContentAsync(transcript, originalTitle, request.Language, request.OutputLanguage, videoInfo.Duration);
rawLlmResponse = rawJson; rawLlmResponse = rawJson;
// Save debug MD in project root // Save debug MD
var debugFile = Path.Combine(Directory.GetCurrentDirectory(), "DEBUG_LAST_RESPONSE.md"); var debugFile = Path.Combine(Directory.GetCurrentDirectory(), "DEBUG_LAST_RESPONSE.md");
var debugContent = $"# Debug Groq Response\n\nURL: {request.VideoUrl}\n\n## Raw JSON\n```json\n{rawJson}\n```\n\n## Sections\n" + var debugContent = $"# {docTitle} ({category})\n\nSource: {originalTitle}\n\n## Raw JSON\n```json\n{rawJson}\n```\n";
string.Join("\n\n", tutorialSections.Select(s => $"### {s.Title}\n{s.Content}\n**Timestamp:** {s.ImageTimestamp}"));
await File.WriteAllTextAsync(debugFile, debugContent); await File.WriteAllTextAsync(debugFile, debugContent);
AddLog($"📝 Arquivo de debug gerado: {debugFile}"); 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 --- // --- Step 3: Image Capture ---
var sectionsWithImages = tutorialSections.Where(s => !string.IsNullOrEmpty(s.ImageTimestamp)).ToList(); var sectionsWithImages = tutorialSections.Where(s => !string.IsNullOrEmpty(s.ImageTimestamp)).ToList();
if (sectionsWithImages.Any()) if (sectionsWithImages.Any())
{ {
AddLog($"📸 Capturando {sectionsWithImages.Count} prints usando Puppeteer (Direct Bypass)..."); AddLog($"📸 Capturando {sectionsWithImages.Count} prints usando Puppeteer (Direct Bypass)...");
await CaptureScreenshotsWithPuppeteerAsync(request.VideoUrl, tutorialSections, tempDir); // Pass videoInfo.Duration to the screenshot method
await CaptureScreenshotsWithPuppeteerAsync(request.VideoUrl, tutorialSections, tempDir, videoInfo.Duration);
} }
else else
{ {
AddLog("⚠️ Nenhuma tag [SCREENSHOT] foi gerada pela IA."); AddLog("⚠️ Nenhuma tag [SCREENSHOT] foi gerada pela IA, ou todas foram invalidadas.");
} }
// --- Step 4: PDF Generation --- // --- Step 4: PDF Generation ---
AddLog("📄 Gerando PDF final com QuestPDF..."); AddLog("📄 Gerando PDF final com QuestPDF...");
var pdfBytes = GeneratePdf(request.VideoUrl, tutorialSections); var pdfBytes = GeneratePdf(docTitle, request.VideoUrl, tutorialSections, category);
AddLog("🎉 Processamento concluído com sucesso!"); AddLog("🎉 Processamento concluído com sucesso!");
return new AnalysisResponse return new AnalysisResponse
{ {
Status = "success", Status = "success",
VideoTitle = request.VideoUrl, VideoTitle = originalTitle,
DocumentTitle = docTitle,
Category = category,
Transcript = transcript, Transcript = transcript,
TutorialSections = tutorialSections, TutorialSections = tutorialSections,
PdfData = pdfBytes, PdfData = pdfBytes,
@ -152,10 +219,30 @@ public class AnalysisService
} }
} }
private async Task<string> GetTranscriptViaYtDlpAsync(string url, string language, string outputDir) private async Task<(string transcript, string title)> GetTranscriptViaYtDlpAsync(string url, string language, string outputDir)
{ {
var ytDlpPath = GetYtDlpPath(); var ytDlpPath = GetYtDlpPath();
var arguments = $"--skip-download --write-sub --write-auto-sub --sub-lang {language},en --sub-format vtt --output \"%(title)s\" \"{url}\""; // 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 var startInfo = new ProcessStartInfo
{ {
@ -172,9 +259,9 @@ public class AnalysisService
await proc.WaitForExitAsync(); await proc.WaitForExitAsync();
var vttFile = Directory.GetFiles(outputDir, "*.vtt").FirstOrDefault(); var vttFile = Directory.GetFiles(outputDir, "*.vtt").FirstOrDefault();
if (vttFile == null) return string.Empty; if (vttFile == null) return (string.Empty, title);
return ParseVttToText(await File.ReadAllTextAsync(vttFile)); return (ParseVttToText(await File.ReadAllTextAsync(vttFile)), title);
} }
private string ParseVttToText(string vttContent) private string ParseVttToText(string vttContent)
@ -193,35 +280,75 @@ public class AnalysisService
return string.Join(" ", textLines); return string.Join(" ", textLines);
} }
private async Task<(List<TutorialSection> sections, string rawJson)> GenerateTutorialContentAsync(string transcript, string language) 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>(); 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 = $@" var prompt = $@"
Converta a transcrição abaixo em um Tutorial Passo a Passo em {language}. Você é um Editor Chefe e Analista de Conteúdo Sênior.
REGRAS: Receberá:
1. Divida em passos lógicos. A) TÍTULO ORIGINAL: {originalTitle}
2. Identifique onde um print da tela é necessário e insira [SCREENSHOT: HH:MM:SS]. B) TRANSCRIÇÃO: {transcript[..Math.Min(transcript.Length, 20000)]}
3. Retorne APENAS JSON. C) DURAÇÃO TOTAL DO VÍDEO: {formattedVideoDuration} ({totalSecondsVideoDuration} segundos)
JSON:
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"": [ ""sections"": [
{{ ""title"": ""Passo 1"", ""content"": ""Texto... [SCREENSHOT: 00:01:20]"" }} {{
""title"": ""Título da Seção"",
""content"": ""Texto explicativo detalhado... [SCREENSHOT: 00:05:30]""
}}
] ]
}} }}";
Transcrição: {transcript[..Math.Min(transcript.Length, 15000)]}";
var result = await chatService.GetChatMessageContentAsync(prompt); var result = await chatService.GetChatMessageContentAsync(prompt);
var json = result.Content?.Trim() ?? "{}"; var json = result.Content?.Trim() ?? "{}";
if (json.StartsWith("```")) { // Extract JSON from LLM response — handles text before/after the JSON block
var idx = json.IndexOf('\n'); var jsonMatch = Regex.Match(json, @"\{[\s\S]*\}", RegexOptions.Singleline);
if (idx > 0) json = json[(idx+1)..]; if (jsonMatch.Success)
if (json.EndsWith("```")) json = json[..^3]; json = jsonMatch.Value;
}
var sections = new List<TutorialSection>(); var sections = new List<TutorialSection>();
string category = "OTHER";
string docTitle = originalTitle;
try { try {
using var doc = JsonDocument.Parse(json); using var doc = JsonDocument.Parse(json);
foreach (var el in doc.RootElement.GetProperty("sections").EnumerateArray()) { 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 content = el.GetProperty("content").GetString() ?? "";
var ts = ExtractTimestamp(content); var ts = ExtractTimestamp(content);
sections.Add(new TutorialSection { sections.Add(new TutorialSection {
@ -231,7 +358,7 @@ Transcrição: {transcript[..Math.Min(transcript.Length, 15000)]}";
}); });
} }
} catch { } } catch { }
return (sections, json); return (sections, json, category, docTitle);
} }
private string? ExtractTimestamp(string text) private string? ExtractTimestamp(string text)
@ -260,14 +387,18 @@ Transcrição: {transcript[..Math.Min(transcript.Length, 15000)]}";
return url?.Trim(); return url?.Trim();
} }
private async Task CaptureScreenshotsWithPuppeteerAsync(string videoUrl, List<TutorialSection> sections, string outputDir) private async Task CaptureScreenshotsWithPuppeteerAsync(string videoUrl, List<TutorialSection> sections, string outputDir, TimeSpan videoDuration)
{ {
var sectionsWithImages = sections.Where(s => !string.IsNullOrEmpty(s.ImageTimestamp)).ToList(); var sectionsWithImages = sections.Where(s => !string.IsNullOrEmpty(s.ImageTimestamp)).ToList();
if (!sectionsWithImages.Any()) return; if (!sectionsWithImages.Any()) return;
AddLog("🔍 Obtendo link direto do vídeo (Bypass YouTube Player)..."); AddLog("🔍 Obtendo link direto do vídeo (Bypass YouTube Player)...");
var rawVideoUrl = await GetRawVideoStreamUrl(videoUrl); var rawVideoUrl = await GetRawVideoStreamUrl(videoUrl);
if (string.IsNullOrEmpty(rawVideoUrl)) { AddLog("❌ Falha ao obter link direto."); return; } if (string.IsNullOrEmpty(rawVideoUrl))
{
AddLog("❌ Falha ao obter link direto. As capturas de tela serão ignoradas.");
return;
}
try try
{ {
@ -275,57 +406,223 @@ Transcrição: {transcript[..Math.Min(transcript.Length, 15000)]}";
using var page = await browser.NewPageAsync(); using var page = await browser.NewPageAsync();
await page.SetViewportAsync(new ViewPortOptions { Width = 1280, Height = 720 }); await page.SetViewportAsync(new ViewPortOptions { Width = 1280, Height = 720 });
var html = $@"<html><body style='margin:0;background:black;overflow:hidden;'><video id='v' width='1280' height='720' muted><source src='{rawVideoUrl}' type='video/mp4'></video></body></html>"; var htmlContent = $@"
await page.SetContentAsync(html); <html>
await page.WaitForSelectorAsync("video"); <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");
foreach (var section in sectionsWithImages) // 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)) if (TimeSpan.TryParse(section.ImageTimestamp, out var ts))
{ {
var sec = (int)ts.TotalSeconds; AddLog($"📸 Processando captura para {section.ImageTimestamp} ('{section.Title}')");
AddLog($"🌐 Renderizando frame: {section.ImageTimestamp}..."); var targetSeconds = (int)ts.TotalSeconds;
await page.EvaluateFunctionAsync(@"(s) => { var candidates = new List<(byte[] ImageData, double Score, int Time, string Hash, int Size)>();
return new Promise(r => {
const v = document.getElementById('v');
v.currentTime = s;
v.addEventListener('seeked', r, {once:true});
});
}", sec);
await Task.Delay(500); // "Best of 3" capture window: T-1, T, T+1 seconds
var path = Path.Combine(outputDir, $"snap_{sec}.jpg"); var timeOffsets = new[] { -1, 0, 1 };
await page.ScreenshotAsync(path, new ScreenshotOptions { Type = ScreenshotType.Jpeg, Quality = 90 });
if (File.Exists(path)) section.ImageData = await File.ReadAllBytesAsync(path);
}
}
}
catch (Exception ex) { AddLog($"❌ Erro Puppeteer: {ex.Message}"); }
}
private byte[] GeneratePdf(string videoUrl, List<TutorialSection> sections) 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 => var document = Document.Create(container =>
{ {
container.Page(page => container.Page(page =>
{ {
page.Margin(2, Unit.Centimetre); page.Margin(2, Unit.Centimetre);
page.DefaultTextStyle(x => x.FontSize(11).FontFamily("Arial")); page.DefaultTextStyle(x => x.FontSize(11).FontFamily("Segoe UI").Fallback(f => f.FontFamily("Microsoft YaHei")));
page.Header().Text("Video Tutorial").SemiBold().FontSize(24).FontColor(Colors.Blue.Medium);
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 => page.Content().PaddingVertical(1, Unit.Centimetre).Column(column =>
{ {
column.Item().Text($"Fonte: {videoUrl}").Italic().FontSize(10).FontColor(Colors.Grey.Medium); column.Item().Text($"Fonte: {videoUrl}").Italic().FontSize(9).FontColor(Colors.Grey.Medium);
column.Item().PaddingBottom(20); column.Item().PaddingBottom(20);
foreach (var section in sections) foreach (var section in sections)
{ {
column.Item().Text(section.Title).Bold().FontSize(16); column.Item().Text(section.Title).Bold().FontSize(14).FontColor(categoryColor);
column.Item().Text(text => { text.Span(section.Content); }); column.Item().Text(text => { text.Span(section.Content); });
if (section.ImageData != null) if (section.ImageData != null)
column.Item().PaddingVertical(15).Image(section.ImageData).FitWidth(); {
column.Item().PaddingBottom(20); 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(); }); page.Footer().AlignCenter().Text(x => { x.Span("Gerado por VideoStudy.app - "); x.CurrentPageNumber(); });
}); });
}); });

View File

@ -4,6 +4,7 @@
<TargetFramework>net8.0</TargetFramework> <TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Platforms>AnyCPU;x64</Platforms>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>

View File

@ -6,6 +6,7 @@
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<NoDefaultLaunchSettingsFile>true</NoDefaultLaunchSettingsFile> <NoDefaultLaunchSettingsFile>true</NoDefaultLaunchSettingsFile>
<StaticWebAssetProjectMode>Default</StaticWebAssetProjectMode> <StaticWebAssetProjectMode>Default</StaticWebAssetProjectMode>
<Platforms>AnyCPU;x64</Platforms>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>

View File

@ -4,6 +4,7 @@
<TargetFramework>net8.0</TargetFramework> <TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Platforms>AnyCPU;x64</Platforms>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>

View File

@ -0,0 +1,7 @@
<?xml version = "1.0" encoding = "UTF-8" ?>
<Application xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
x:Class="VideoStudy.Native.App">
<Application.Resources>
</Application.Resources>
</Application>

View File

@ -0,0 +1,14 @@
namespace VideoStudy.Native;
public partial class App : Application
{
public App()
{
InitializeComponent();
}
protected override Window CreateWindow(IActivationState? activationState)
{
return new Window(new MainPage());
}
}

View File

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:blazor="clr-namespace:Microsoft.AspNetCore.Components.WebView.Maui;assembly=Microsoft.AspNetCore.Components.WebView.Maui"
xmlns:ui="clr-namespace:VideoStudy.UI;assembly=VideoStudy.UI"
x:Class="VideoStudy.Native.MainPage"
BackgroundColor="White">
<blazor:BlazorWebView x:Name="blazorWebView" HostPage="wwwroot/index.html">
<blazor:BlazorWebView.RootComponents>
<blazor:RootComponent Selector="#app" ComponentType="{x:Type ui:Routes}" />
</blazor:BlazorWebView.RootComponents>
</blazor:BlazorWebView>
</ContentPage>

View File

@ -0,0 +1,9 @@
namespace VideoStudy.Native;
public partial class MainPage : ContentPage
{
public MainPage()
{
InitializeComponent();
}
}

View File

@ -0,0 +1,37 @@
using Microsoft.AspNetCore.Components.WebView.Maui;
using Microsoft.Extensions.Logging;
using VideoStudy.Native.Services;
using VideoStudy.Shared;
using VideoStudy.UI;
namespace VideoStudy.Native;
public static class MauiProgram
{
public static MauiApp CreateMauiApp()
{
var builder = MauiApp.CreateBuilder();
builder
.UseMauiApp<App>();
builder.Services.AddMauiBlazorWebView();
#if DEBUG
builder.Services.AddBlazorWebViewDeveloperTools();
builder.Logging.AddDebug();
builder.Logging.SetMinimumLevel(LogLevel.Debug);
#endif
// Configurar HttpClient para API Local (timeout longo para análise de vídeo)
builder.Services.AddScoped(sp => new HttpClient
{
BaseAddress = new Uri("http://localhost:5000"),
Timeout = TimeSpan.FromMinutes(10)
});
// PDF Saver (FileSavePicker do Windows)
builder.Services.AddSingleton<IPdfSaver, WindowsPdfSaver>();
return builder.Build();
}
}

View File

@ -0,0 +1,7 @@
<maui:MauiWinUIApplication
x:Class="VideoStudy.Native.WinUI.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:maui="using:Microsoft.Maui"
xmlns:local="using:VideoStudy.Native.WinUI">
</maui:MauiWinUIApplication>

View File

@ -0,0 +1,13 @@
using Microsoft.Maui;
using Microsoft.Maui.Hosting;
namespace VideoStudy.Native.WinUI;
public partial class App : MauiWinUIApplication
{
public App()
{
}
protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
}

View File

@ -0,0 +1,49 @@
<?xml version="1.0" encoding="utf-8"?>
<Package
xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10"
xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10"
xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities"
IgnorableNamespaces="uap rescap">
<Identity
Name="com.companyname.videostudy"
Publisher="CN=Ricardo"
Version="1.0.0.0" />
<Properties>
<DisplayName>VideoStudy</DisplayName>
<PublisherDisplayName>Ricardo</PublisherDisplayName>
<Logo>Resources\AppIcon\appicon.svg</Logo>
</Properties>
<Dependencies>
<TargetDeviceFamily Name="Windows.Universal" MinVersion="10.0.17763.0" MaxVersionTested="10.0.19041.0" />
<TargetDeviceFamily Name="Windows.Desktop" MinVersion="10.0.17763.0" MaxVersionTested="10.0.19041.0" />
</Dependencies>
<Resources>
<Resource Language="x-generate"/>
</Resources>
<Applications>
<Application Id="App"
Executable="$targetnametoken$.exe"
EntryPoint="$targetentrypoint$">
<uap:VisualElements
DisplayName="VideoStudy"
Description="VideoStudy"
BackgroundColor="transparent"
Square150x150Logo="Resources\AppIcon\appicon.svg"
Square44x44Logo="Resources\AppIcon\appicon.svg">
<uap:DefaultTile Wide310x150Logo="Resources\AppIcon\appicon.svg" />
<uap:SplashScreen Image="Resources\Splash\splash.svg" />
</uap:VisualElements>
</Application>
</Applications>
<Capabilities>
<rescap:Capability Name="runFullTrust" />
<Capability Name="internetClient" />
</Capabilities>
</Package>

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg width="128" height="128" viewBox="0 0 128 128" xmlns="http://www.w3.org/2000/svg">
<circle cx="64" cy="64" r="60" fill="#512BD4"/>
</svg>

After

Width:  |  Height:  |  Size: 200 B

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg width="128" height="128" viewBox="0 0 128 128" xmlns="http://www.w3.org/2000/svg">
<text x="50%" y="50%" dominant-baseline="middle" text-anchor="middle" font-family="Arial" font-size="40" fill="white">VS</text>
</svg>

After

Width:  |  Height:  |  Size: 280 B

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg width="168" height="208" viewBox="0 0 168 208" xmlns="http://www.w3.org/2000/svg">
<rect width="168" height="208" fill="#512BD4"/>
</svg>

After

Width:  |  Height:  |  Size: 200 B

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg width="128" height="128" viewBox="0 0 128 128" xmlns="http://www.w3.org/2000/svg">
<rect width="128" height="128" fill="#512BD4"/>
<text x="50%" y="50%" dominant-baseline="middle" text-anchor="middle" font-family="Arial" font-size="20" fill="white">VideoStudy</text>
</svg>

After

Width:  |  Height:  |  Size: 338 B

View File

@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8" ?>
<?xaml-comp compile="true" ?>
<ResourceDictionary
xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml">
<Color x:Key="Primary">#512BD4</Color>
<Color x:Key="Secondary">#DFD8F7</Color>
<Color x:Key="Tertiary">#2B0B98</Color>
<Color x:Key="White">#FFFFFF</Color>
<Color x:Key="Black">#000000</Color>
<Color x:Key="Gray100">#E1E1E1</Color>
<Color x:Key="Gray200">#C8C8C8</Color>
<Color x:Key="Gray300">#ACACAC</Color>
<Color x:Key="Gray400">#919191</Color>
<Color x:Key="Gray500">#6E6E6E</Color>
<Color x:Key="Gray600">#404040</Color>
<Color x:Key="Gray900">#212121</Color>
<Color x:Key="Gray950">#141414</Color>
<SolidColorBrush x:Key="PrimaryBrush" Color="{StaticResource Primary}"/>
<SolidColorBrush x:Key="SecondaryBrush" Color="{StaticResource Secondary}"/>
<SolidColorBrush x:Key="TertiaryBrush" Color="{StaticResource Tertiary}"/>
<SolidColorBrush x:Key="WhiteBrush" Color="{StaticResource White}"/>
<SolidColorBrush x:Key="BlackBrush" Color="{StaticResource Black}"/>
</ResourceDictionary>

View File

@ -0,0 +1,27 @@
<?xml version="1.0" encoding="UTF-8" ?>
<?xaml-comp compile="true" ?>
<ResourceDictionary
xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml">
<Style TargetType="ActivityIndicator">
<Setter Property="Color" Value="{StaticResource Primary}" />
</Style>
<Style TargetType="Button">
<Setter Property="TextColor" Value="{StaticResource White}" />
<Setter Property="BackgroundColor" Value="{StaticResource Primary}" />
<Setter Property="FontSize" Value="14" />
<Setter Property="CornerRadius" Value="8" />
<Setter Property="Padding" Value="14,10" />
</Style>
<Style TargetType="Label">
<Setter Property="TextColor" Value="{StaticResource Black}" />
<Setter Property="FontSize" Value="14" />
</Style>
<Style TargetType="Page">
<Setter Property="BackgroundColor" Value="{StaticResource White}" />
</Style>
</ResourceDictionary>

View File

@ -0,0 +1,38 @@
using VideoStudy.Shared;
using Windows.Storage;
using Windows.Storage.Pickers;
using WinRT.Interop;
namespace VideoStudy.Native.Services;
public class WindowsPdfSaver : IPdfSaver
{
public async Task<string?> SavePdfAsync(byte[] pdfData, string suggestedFileName)
{
var savePicker = new FileSavePicker
{
SuggestedStartLocation = PickerLocationId.Downloads,
SuggestedFileName = suggestedFileName
};
savePicker.FileTypeChoices.Add("PDF", [".pdf"]);
// Get the window handle for the picker
var window = Application.Current?.Windows.FirstOrDefault();
var mauiWindow = window?.Handler?.PlatformView;
if (mauiWindow is Microsoft.UI.Xaml.Window winuiWindow)
{
var hwnd = WindowNative.GetWindowHandle(winuiWindow);
InitializeWithWindow.Initialize(savePicker, hwnd);
}
var file = await savePicker.PickSaveFileAsync();
if (file == null) return null;
await FileIO.WriteBytesAsync(file, pdfData);
// Open the PDF after saving
await Windows.System.Launcher.LaunchFileAsync(file);
return file.Path;
}
}

View File

@ -0,0 +1,39 @@
<Project Sdk="Microsoft.NET.Sdk.Razor">
<PropertyGroup>
<TargetFramework>net10.0-windows10.0.19041.0</TargetFramework>
<TargetPlatformMinVersion>10.0.17763.0</TargetPlatformMinVersion>
<OutputType>WinExe</OutputType>
<UseMaui>true</UseMaui>
<SingleProject>true</SingleProject>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<ApplicationTitle>VideoStudy</ApplicationTitle>
<ApplicationId>com.companyname.videostudy</ApplicationId>
<ApplicationDisplayVersion>1.0</ApplicationDisplayVersion>
<ApplicationVersion>1</ApplicationVersion>
<WindowsPackageType>None</WindowsPackageType>
</PropertyGroup>
<ItemGroup>
<MauiIcon Include="Resources\AppIcon\appicon.svg" ForegroundFile="Resources\AppIcon\appiconfg.svg" Color="#512BD4" />
<MauiSplashScreen Include="Resources\Splash\splash.svg" Color="#512BD4" BaseSize="128,128" />
<MauiImage Include="Resources\Images\*" />
<MauiFont Include="Resources\Fonts\*" />
<MauiAsset Include="Resources\Raw\**" LogicalName="%(RecursiveDir)%(Filename)%(Extension)" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Maui.Controls" Version="10.0.31" />
<PackageReference Include="Microsoft.Maui.Controls.Compatibility" Version="10.0.31" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebView.Maui" Version="10.0.31" />
<PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="10.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\VideoStudy.UI\VideoStudy.UI.csproj" />
<ProjectReference Include="..\VideoStudy.Shared\VideoStudy.Shared.csproj" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,31 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
<title>VideoStudy.Native</title>
<base href="/" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" />
<link rel="stylesheet" href="_content/VideoStudy.UI/app.css" />
</head>
<body>
<div id="app">
<div style="position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);">
<div class="spinner-border text-primary" role="status"></div>
<div style="margin-top:10px;">Carregando VideoStudy...</div>
</div>
</div>
<div id="blazor-error-ui">
An unhandled error has occurred.
<a href="" class="reload">Reload</a>
<a class="dismiss">🗙</a>
</div>
<script src="_framework/blazor.webview.js"></script>
</body>
</html>

View File

@ -6,7 +6,8 @@ namespace VideoStudy.Shared;
public class AnalysisRequest public class AnalysisRequest
{ {
public string VideoUrl { get; set; } = string.Empty; public string VideoUrl { get; set; } = string.Empty;
public string Language { get; set; } = "en"; public string Language { get; set; } = "en"; // Input language (subtitles)
public string? OutputLanguage { get; set; } // Output language (tutorial). null = same as Language
public string Mode { get; set; } = "fast"; // fast or advanced public string Mode { get; set; } = "fast"; // fast or advanced
public string? TranscriptText { get; set; } // Optional: if client already transcribed it public string? TranscriptText { get; set; } // Optional: if client already transcribed it
public double DurationSeconds { get; set; } public double DurationSeconds { get; set; }
@ -18,6 +19,8 @@ public class AnalysisRequest
public class AnalysisResponse 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 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; } = [];
public string Analysis { get; set; } = string.Empty; public string Analysis { get; set; } = string.Empty;
@ -95,3 +98,8 @@ public class DownloadProgress
public double PercentComplete { get; set; } public double PercentComplete { get; set; }
public string Status { get; set; } = string.Empty; public string Status { get; set; } = string.Empty;
} }
public interface IPdfSaver
{
Task<string?> SavePdfAsync(byte[] pdfData, string suggestedFileName);
}

View File

@ -1,9 +1,10 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net8.0</TargetFramework> <TargetFrameworks>net8.0;net10.0</TargetFrameworks>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<Platforms>AnyCPU;x64</Platforms>
</PropertyGroup> </PropertyGroup>
</Project> </Project>

26
VideoStudy.UI/App.razor Normal file
View File

@ -0,0 +1,26 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<base href="/" />
<!-- Bootstrap 5.3.2 CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="app.css" />
<link rel="stylesheet" href="VideoStudy.Desktop.styles.css" />
<HeadOutlet />
</head>
<body>
<Routes />
<!-- Bootstrap 5.3.2 JS -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
<script src="_framework/blazor.web.js"></script>
</body>
</html>

View File

@ -0,0 +1,36 @@
using Microsoft.JSInterop;
namespace VideoStudy.UI;
// This class provides an example of how JavaScript functionality can be wrapped
// in a .NET class for easy consumption. The associated JavaScript module is
// loaded on demand when first needed.
//
// This class can be registered as scoped DI service and then injected into Blazor
// components for use.
public class ExampleJsInterop : IAsyncDisposable
{
private readonly Lazy<Task<IJSObjectReference>> moduleTask;
public ExampleJsInterop(IJSRuntime jsRuntime)
{
moduleTask = new (() => jsRuntime.InvokeAsync<IJSObjectReference>(
"import", "./_content/VideoStudy.UI/exampleJsInterop.js").AsTask());
}
public async ValueTask<string> Prompt(string message)
{
var module = await moduleTask.Value;
return await module.InvokeAsync<string>("showPrompt", message);
}
public async ValueTask DisposeAsync()
{
if (moduleTask.IsValueCreated)
{
var module = await moduleTask.Value;
await module.DisposeAsync();
}
}
}

View File

@ -0,0 +1,58 @@
@inherits LayoutComponentBase
<div class="page">
<nav class="navbar navbar-dark bg-dark sticky-top mb-4">
<div class="container-fluid">
<span class="navbar-brand mb-0 h1">📺 VideoStudy</span>
</div>
</nav>
<main role="main" class="px-4">
@Body
</main>
</div>
<div id="blazor-error-ui">
<div class="alert alert-danger m-3" role="alert">
<h4 class="alert-heading">⚠️ Unhandled error</h4>
<p>An unhandled error has occurred. Please reload the page.</p>
<hr>
<button class="btn btn-primary reload">Reload</button>
<button class="btn btn-secondary dismiss">Dismiss</button>
</div>
</div>
<style>
.page {
position: relative;
display: flex;
flex-direction: column;
min-height: 100vh;
}
main {
flex: 1;
}
#blazor-error-ui {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
z-index: 9999;
align-items: center;
justify-content: center;
}
#blazor-error-ui[style*="display"] {
align-items: center;
justify-content: center;
}
.reload, .dismiss {
cursor: pointer;
}
</style>

View File

@ -0,0 +1,18 @@
#blazor-error-ui {
background: lightyellow;
bottom: 0;
box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2);
display: none;
left: 0;
padding: 0.6rem 1.25rem 0.7rem 1.25rem;
position: fixed;
width: 100%;
z-index: 1000;
}
#blazor-error-ui .dismiss {
cursor: pointer;
position: absolute;
right: 0.75rem;
top: 0.5rem;
}

View File

@ -0,0 +1,33 @@
@page "/Error"
@using System.Diagnostics
<PageTitle>Error</PageTitle>
<h1 class="text-danger">Error.</h1>
<h2 class="text-danger">An error occurred while processing your request.</h2>
@if (ShowRequestId)
{
<p>
<strong>Request ID:</strong> <code>@RequestId</code>
</p>
}
<h3>Development Mode</h3>
<p>
Swapping to <strong>Development</strong> environment will display more detailed information about the error that occurred.
</p>
<p>
<strong>The Development environment shouldn't be enabled for deployed applications.</strong>
It can result in displaying sensitive information from exceptions to end users.
For local debugging, enable the <strong>Development</strong> environment by setting the <strong>ASPNETCORE_ENVIRONMENT</strong> environment variable to <strong>Development</strong>
and restarting the app.
</p>
@code{
private string? RequestId { get; set; }
private bool ShowRequestId => !string.IsNullOrEmpty(RequestId);
protected override void OnInitialized() =>
RequestId = Activity.Current?.Id;
}

View File

@ -0,0 +1,327 @@
@page "/"
@inject HttpClient Http
@inject IPdfSaver PdfSaver
@using VideoStudy.Shared
@using System.Net.Http.Json
@using System.Text.Json
<PageTitle>VideoStudy - Video Analysis</PageTitle>
<div class="container-fluid py-4">
<!-- Header -->
<header class="text-center mb-5">
<h1 class="display-4 fw-bold" style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); -webkit-background-clip: text; -webkit-text-fill-color: transparent;">
VideoStudy (Native)
</h1>
<p class="lead text-muted">Transforme videos em apostilas inteligentes com IA</p>
</header>
<div class="row">
<div class="col-lg-8 mx-auto">
<!-- Tabs Navigation -->
<ul class="nav nav-pills nav-fill mb-4 shadow-sm p-1 bg-light rounded-pill">
<li class="nav-item">
<button class="nav-link rounded-pill py-3 fw-bold @(activeTab == "youtube" ? "active bg-primary text-white" : "text-muted")"
@onclick="@(() => activeTab = "youtube")" disabled="@(isProcessing || isFetchingInfo)">
<span style="font-size:1.1rem;">YouTube Video</span>
</button>
</li>
<li class="nav-item">
<button class="nav-link rounded-pill py-3 fw-bold text-muted" disabled>
Local Video (Coming Soon)
</button>
</li>
</ul>
<!-- Tab Content -->
<div class="card shadow-soft border-0 rounded-4 overflow-hidden mb-4 p-4 bg-white">
@if (activeTab == "youtube")
{
<div class="mb-4">
<label class="form-label fw-bold text-muted small">YouTube URL</label>
<div class="input-group mb-3">
<input type="text" class="form-control form-control-lg" placeholder="https://www.youtube.com/watch?v=..."
@bind="videoUrl" disabled="@(isProcessing || isFetchingInfo)" />
</div>
</div>
}
<!-- Video Info Preview -->
@if (videoInfo != null)
{
<div class="border rounded-3 p-3 mb-4 d-flex align-items-start gap-3" style="background: #f0f4ff;">
@if (!string.IsNullOrEmpty(videoInfo.ThumbnailUrl))
{
<img src="@videoInfo.ThumbnailUrl" alt="Thumbnail"
style="width: 180px; border-radius: 8px; flex-shrink: 0;" />
}
<div class="flex-grow-1">
<h6 class="fw-bold mb-1">@videoInfo.Title</h6>
<div class="text-muted small">
<span class="me-3">@videoInfo.Author</span>
<span>@videoInfo.Duration.ToString(@"hh\:mm\:ss")</span>
</div>
</div>
<button class="btn btn-sm btn-outline-secondary" @onclick="ClearVideoInfo"
disabled="@(isProcessing)" title="Limpar">X</button>
</div>
}
<!-- Language Selectors -->
<div class="row g-3">
<div class="col-md-6">
<label class="form-label fw-bold text-muted small">Idioma do Video (Legendas)</label>
<select class="form-select" @bind="selectedLanguage" disabled="@(isProcessing || isFetchingInfo)">
<option value="en">English</option>
<option value="pt">Portuguese (BR)</option>
<option value="es">Spanish</option>
<option value="fr">French</option>
<option value="de">German</option>
<option value="ja">Japanese</option>
<option value="ko">Korean</option>
</select>
</div>
<div class="col-md-6">
<label class="form-label fw-bold text-muted small">Idioma de Saida (Tutorial)</label>
<select class="form-select" @bind="selectedOutputLanguage" disabled="@(isProcessing || isFetchingInfo)">
<option value="pt">Portuguese (BR)</option>
<option value="en">English</option>
<option value="es">Spanish</option>
<option value="fr">French</option>
<option value="de">German</option>
</select>
</div>
</div>
<!-- Action Buttons -->
<div class="mt-4">
@if (videoInfo == null)
{
<!-- Step 1: Fetch video info -->
<button class="btn btn-lg btn-primary w-100 py-3 fw-bold shadow-sm"
@onclick="FetchVideoInfo"
disabled="@(isProcessing || isFetchingInfo || string.IsNullOrWhiteSpace(videoUrl))">
@if (isFetchingInfo)
{
<span class="spinner-border spinner-border-sm me-2"></span>
<span>Verificando video...</span>
}
else
{
<span>Generate Tutorial PDF</span>
}
</button>
}
else
{
<!-- Step 2: Confirm and generate -->
<div class="d-flex gap-2">
<button class="btn btn-lg btn-success flex-grow-1 py-3 fw-bold shadow-sm"
@onclick="StartAnalysis"
disabled="@isProcessing">
@if (isProcessing)
{
<span class="spinner-border spinner-border-sm me-2"></span>
<span>Gerando PDF...</span>
}
else
{
<span>Confirmar e Gerar PDF</span>
}
</button>
</div>
}
</div>
</div>
<!-- Progress Indicator -->
@if (isProcessing || currentStep > 0)
{
<div class="progress mb-3" style="height: 25px;">
<div class="progress-bar progress-bar-striped progress-bar-animated bg-success" role="progressbar" style="width: @(progress)%">
@statusMessage
</div>
</div>
}
<!-- Logs (Collapsible) -->
@if (logs.Count > 0)
{
<div class="card shadow-sm mb-4 border-0 rounded-4 overflow-hidden">
<div class="card-header bg-dark text-white d-flex justify-content-between align-items-center" style="cursor: pointer;" @onclick="ToggleLogs">
<small class="fw-bold">Execution Logs</small>
<small>@logs.Count items @(showLogs ? "v" : ">")</small>
</div>
@if (showLogs)
{
<div class="card-body bg-light" style="max-height: 200px; overflow-y: auto; font-family: monospace; font-size: 0.8rem;">
@foreach (var log in logs)
{
<div class="text-dark border-bottom py-1">
<span class="text-muted">[@log.Timestamp:HH:mm:ss]</span> @log.Message
</div>
}
</div>
}
</div>
}
</div>
</div>
</div>
<style>
.nav-pills .nav-link.active {
box-shadow: 0 4px 15px rgba(0, 123, 255, 0.3);
}
.shadow-soft {
box-shadow: 0 10px 30px rgba(0,0,0,0.05);
}
</style>
@code {
private string activeTab = "youtube";
private bool isProcessing = false;
private bool isFetchingInfo = false;
private int progress = 0;
private int currentStep = 0;
private string statusMessage = "Ready";
private bool showLogs = false;
private List<LogEntry> logs = new();
private string videoUrl = string.Empty;
private string selectedLanguage = "en";
private string selectedOutputLanguage = "pt";
private VideoInfo? videoInfo;
private class LogEntry
{
public DateTime Timestamp { get; set; } = DateTime.Now;
public string Message { get; set; } = string.Empty;
}
private void ToggleLogs() => showLogs = !showLogs;
private void AddLog(string message)
{
logs.Insert(0, new LogEntry { Message = message });
StateHasChanged();
}
private void ClearVideoInfo()
{
videoInfo = null;
}
private async Task FetchVideoInfo()
{
if (string.IsNullOrWhiteSpace(videoUrl)) return;
isFetchingInfo = true;
showLogs = true;
try
{
AddLog("Buscando informacoes do video...");
var response = await Http.GetAsync($"api/video-info?url={Uri.EscapeDataString(videoUrl)}");
if (!response.IsSuccessStatusCode)
{
var error = await response.Content.ReadAsStringAsync();
throw new Exception($"API Error: {error}");
}
videoInfo = await response.Content.ReadFromJsonAsync<VideoInfo>(
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
if (videoInfo != null)
AddLog($"Video encontrado: {videoInfo.Title} ({videoInfo.Duration:hh\\:mm\\:ss})");
}
catch (Exception ex)
{
AddLog($"Erro: {ex.Message}");
}
finally
{
isFetchingInfo = false;
}
}
private async Task StartAnalysis()
{
isProcessing = true;
progress = 0;
currentStep = 0;
logs.Clear();
showLogs = true;
try
{
currentStep = 1;
statusMessage = "Calling API...";
AddLog("Sending request to API...");
var request = new AnalysisRequest
{
VideoUrl = videoUrl,
Language = selectedLanguage,
OutputLanguage = selectedOutputLanguage,
Mode = "native"
};
// Long timeout via CancellationToken (HttpClient.Timeout can't be changed after first request)
using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(10));
var response = await Http.PostAsJsonAsync("api/analyze", request, cts.Token);
if (!response.IsSuccessStatusCode)
{
var error = await response.Content.ReadAsStringAsync();
throw new Exception($"API Error ({response.StatusCode}): {error}");
}
var json = await response.Content.ReadAsStringAsync();
var result = JsonSerializer.Deserialize<AnalysisResponse>(json, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
if (result == null) throw new Exception("Failed to deserialize response");
if (result.DebugSteps != null)
{
foreach (var step in result.DebugSteps) AddLog($"[API] {step}");
}
if (result.Status == "error") throw new Exception(result.ErrorMessage);
AddLog("Analysis Complete!");
progress = 100;
statusMessage = "Done!";
if (result.PdfData != null && result.PdfData.Length > 0)
{
AddLog("Saving PDF...");
var fileName = $"VideoStudy_{result.VideoTitle ?? "Tutorial"}_{DateTime.Now:yyyyMMdd_HHmmss}.pdf";
// Remove invalid filename chars
fileName = string.Join("_", fileName.Split(Path.GetInvalidFileNameChars()));
var savedPath = await PdfSaver.SavePdfAsync(result.PdfData, fileName);
if (savedPath != null)
AddLog($"PDF saved: {savedPath}");
else
AddLog("PDF save cancelled by user");
}
else
{
AddLog("No PDF data returned from API");
}
}
catch (Exception ex)
{
AddLog($"Error: {ex.Message}");
statusMessage = "Error";
}
finally
{
isProcessing = false;
videoInfo = null;
}
}
}

View File

@ -0,0 +1,43 @@
<div class="modal fade show d-block" tabindex="-1" style="background: rgba(0,0,0,0.5);">
<div class="modal-dialog modal-lg">
<div class="modal-content border-0 shadow-lg">
<div class="modal-header bg-gradient text-white">
<h5 class="modal-title">📄 PDF Preview</h5>
<button type="button" class="btn-close btn-close-white" @onclick="OnCancel"></button>
</div>
<div class="modal-body bg-light p-4 text-center">
<div class="pdf-icon mb-3">
<span style="font-size: 4rem;">📄</span>
</div>
<h5>Your Study Notes are ready!</h5>
<p class="text-muted">@System.IO.Path.GetFileName(PdfPath)</p>
<div class="alert alert-info text-start mt-3">
<strong>Saved to:</strong> <br/>
<small>@PdfPath</small>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @onclick="OnCancel">Close</button>
<button type="button" class="btn btn-primary" @onclick="OpenPdf">📂 Open PDF</button>
</div>
</div>
</div>
</div>
@code {
[Parameter] public string PdfPath { get; set; } = string.Empty;
[Parameter] public EventCallback OnCancel { get; set; }
private void OpenPdf()
{
// Platform specific open
try
{
var p = new System.Diagnostics.Process();
p.StartInfo = new System.Diagnostics.ProcessStartInfo(PdfPath) { UseShellExecute = true };
p.Start();
}
catch {}
}
}

View File

@ -0,0 +1,57 @@
<div class="progress-container mb-4">
<div class="d-flex justify-content-between mb-2">
<span class="fw-bold text-primary">@Status</span>
<span class="text-muted">@Percent%</span>
</div>
<div class="progress" style="height: 10px;">
<div class="progress-bar progress-bar-striped progress-bar-animated bg-gradient"
role="progressbar"
style="width: @Percent%"></div>
</div>
<div class="steps d-flex justify-content-between mt-3 px-2">
@foreach (var step in Steps)
{
<div class="step text-center @(step.IsActive ? "active" : "") @(step.IsCompleted ? "completed" : "")">
<div class="step-circle mb-1">
@if (step.IsCompleted) { <span>✓</span> } else { <span>@step.Number</span> }
</div>
<small class="step-label d-none d-sm-block">@step.Label</small>
</div>
}
</div>
</div>
@code {
[Parameter] public string Status { get; set; } = "Ready";
[Parameter] public int Percent { get; set; } = 0;
// Simple step model
public class Step
{
public int Number { get; set; }
public string Label { get; set; } = "";
public bool IsActive { get; set; }
public bool IsCompleted { get; set; }
}
// We can pass current step index from parent
[Parameter] public int CurrentStepIndex { get; set; } = 0;
private List<Step> Steps = new()
{
new Step { Number = 1, Label = "Download" },
new Step { Number = 2, Label = "Transcrição" },
new Step { Number = 3, Label = "Análise IA" },
new Step { Number = 4, Label = "PDF" }
};
protected override void OnParametersSet()
{
for (int i = 0; i < Steps.Count; i++)
{
Steps[i].IsCompleted = i < CurrentStepIndex;
Steps[i].IsActive = i == CurrentStepIndex;
}
}
}

View File

@ -0,0 +1,6 @@
<Router AppAssembly="typeof(Routes).Assembly">
<Found Context="routeData">
<RouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)" />
<FocusOnNavigate RouteData="routeData" Selector="h1" />
</Found>
</Router>

View File

@ -0,0 +1,31 @@
<Project Sdk="Microsoft.NET.Sdk.Razor">
<PropertyGroup>
<TargetFrameworks>net8.0;net10.0</TargetFrameworks>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<Platforms>AnyCPU;x64</Platforms>
</PropertyGroup>
<ItemGroup>
<SupportedPlatform Include="browser" />
</ItemGroup>
<ItemGroup Condition="'$(TargetFramework)' == 'net8.0'">
<PackageReference Include="Microsoft.AspNetCore.Components.Web" Version="8.0.22" />
</ItemGroup>
<ItemGroup Condition="'$(TargetFramework)' == 'net10.0'">
<PackageReference Include="Microsoft.AspNetCore.Components.Web" Version="10.0.0" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.2" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\VideoStudy.Shared\VideoStudy.Shared.csproj" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,12 @@
@using System.Net.Http
@using System.Net.Http.Json
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web
@using static Microsoft.AspNetCore.Components.Web.RenderMode
@using Microsoft.AspNetCore.Components.Web.Virtualization
@using Microsoft.JSInterop
@using VideoStudy.UI
@using VideoStudy.UI.Layout
@using VideoStudy.UI.Pages
@using VideoStudy.Shared

View File

@ -0,0 +1,99 @@
:root {
/* Colors from vcart.me.novo analysis */
--primary: #007bff;
--secondary: #0056b3;
--accent-purple: #764ba2;
--accent-blue: #667eea;
--success: #28a745;
--text-color: #212529;
--bg-light: #f8f9fa;
--bg-white: #ffffff;
}
body {
background-color: var(--bg-light);
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
color: var(--text-color);
}
/* Gradients */
.bg-gradient {
background: linear-gradient(135deg, var(--accent-blue) 0%, var(--accent-purple) 100%) !important;
}
.btn-gradient {
background: linear-gradient(135deg, var(--accent-blue) 0%, var(--accent-purple) 100%);
border: none;
transition: all 0.3s ease;
}
.btn-gradient:hover {
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(118, 75, 162, 0.3);
opacity: 0.9;
}
/* Cards */
.card {
border-radius: 15px;
border: none;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.05);
overflow: hidden;
}
.processor-card {
background: white;
border-radius: 15px;
}
/* Accordion Custom */
.accordion-button:not(.collapsed) {
background-color: rgba(102, 126, 234, 0.1);
color: var(--accent-purple);
}
.accordion-button:focus {
box-shadow: none;
border-color: rgba(102, 126, 234, 0.5);
}
/* Steps */
.step-circle {
width: 30px;
height: 30px;
border-radius: 50%;
background: #e9ecef;
color: #6c757d;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto;
font-weight: bold;
}
.step.active .step-circle {
background: var(--primary);
color: white;
box-shadow: 0 0 0 4px rgba(0, 123, 255, 0.2);
}
.step.completed .step-circle {
background: var(--success);
color: white;
}
/* Mode Selection */
.btn-outline-purple {
border: 2px solid var(--accent-purple);
color: var(--accent-purple);
}
.btn-outline-purple:hover, .btn-outline-purple.active {
background: var(--accent-purple);
color: white;
}
/* Utilities */
.shadow-soft {
box-shadow: 0 10px 40px rgba(0,0,0,0.08) !important;
}

BIN
VideoStudy.pdf Normal file

Binary file not shown.

View File

@ -1,7 +1,7 @@
 
Microsoft Visual Studio Solution File, Format Version 12.00 Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17 # Visual Studio Version 18
VisualStudioVersion = 17.0.31903.59 VisualStudioVersion = 18.2.11415.280
MinimumVisualStudioVersion = 10.0.40219.1 MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "VideoStudy.Shared", "VideoStudy.Shared\VideoStudy.Shared.csproj", "{C2F5E2F7-0872-422B-B246-A9EC585AF3CC}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "VideoStudy.Shared", "VideoStudy.Shared\VideoStudy.Shared.csproj", "{C2F5E2F7-0872-422B-B246-A9EC585AF3CC}"
EndProject EndProject
@ -13,34 +13,75 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "VideoStudy.Desktop", "Video
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "VideoStudy.Desktop.Client", "VideoStudy.Desktop\VideoStudy.Desktop.Client\VideoStudy.Desktop.Client.csproj", "{59672094-7BE6-4CB2-8401-59D59D8AF07A}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "VideoStudy.Desktop.Client", "VideoStudy.Desktop\VideoStudy.Desktop.Client\VideoStudy.Desktop.Client.csproj", "{59672094-7BE6-4CB2-8401-59D59D8AF07A}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "VideoStudy.UI", "VideoStudy.UI\VideoStudy.UI.csproj", "{CE82389C-E484-4EAC-8F78-0FB5C6EB63A4}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "VideoStudy.Native", "VideoStudy.Native\VideoStudy.Native.csproj", "{0387DAAC-724C-43D1-9C68-D8383F05E909}"
EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU Debug|Any CPU = Debug|Any CPU
Debug|x64 = Debug|x64
Release|Any CPU = Release|Any CPU Release|Any CPU = Release|Any CPU
EndGlobalSection Release|x64 = Release|x64
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution GlobalSection(ProjectConfigurationPlatforms) = postSolution
{C2F5E2F7-0872-422B-B246-A9EC585AF3CC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {C2F5E2F7-0872-422B-B246-A9EC585AF3CC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{C2F5E2F7-0872-422B-B246-A9EC585AF3CC}.Debug|Any CPU.Build.0 = Debug|Any CPU {C2F5E2F7-0872-422B-B246-A9EC585AF3CC}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C2F5E2F7-0872-422B-B246-A9EC585AF3CC}.Debug|x64.ActiveCfg = Debug|x64
{C2F5E2F7-0872-422B-B246-A9EC585AF3CC}.Debug|x64.Build.0 = Debug|x64
{C2F5E2F7-0872-422B-B246-A9EC585AF3CC}.Release|Any CPU.ActiveCfg = Release|Any CPU {C2F5E2F7-0872-422B-B246-A9EC585AF3CC}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C2F5E2F7-0872-422B-B246-A9EC585AF3CC}.Release|Any CPU.Build.0 = Release|Any CPU {C2F5E2F7-0872-422B-B246-A9EC585AF3CC}.Release|Any CPU.Build.0 = Release|Any CPU
{C2F5E2F7-0872-422B-B246-A9EC585AF3CC}.Release|x64.ActiveCfg = Release|x64
{C2F5E2F7-0872-422B-B246-A9EC585AF3CC}.Release|x64.Build.0 = Release|x64
{022CD193-2FB4-4507-BAA2-56DB7A40841E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {022CD193-2FB4-4507-BAA2-56DB7A40841E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{022CD193-2FB4-4507-BAA2-56DB7A40841E}.Debug|Any CPU.Build.0 = Debug|Any CPU {022CD193-2FB4-4507-BAA2-56DB7A40841E}.Debug|Any CPU.Build.0 = Debug|Any CPU
{022CD193-2FB4-4507-BAA2-56DB7A40841E}.Debug|x64.ActiveCfg = Debug|x64
{022CD193-2FB4-4507-BAA2-56DB7A40841E}.Debug|x64.Build.0 = Debug|x64
{022CD193-2FB4-4507-BAA2-56DB7A40841E}.Release|Any CPU.ActiveCfg = Release|Any CPU {022CD193-2FB4-4507-BAA2-56DB7A40841E}.Release|Any CPU.ActiveCfg = Release|Any CPU
{022CD193-2FB4-4507-BAA2-56DB7A40841E}.Release|Any CPU.Build.0 = Release|Any CPU {022CD193-2FB4-4507-BAA2-56DB7A40841E}.Release|Any CPU.Build.0 = Release|Any CPU
{022CD193-2FB4-4507-BAA2-56DB7A40841E}.Release|x64.ActiveCfg = Release|x64
{022CD193-2FB4-4507-BAA2-56DB7A40841E}.Release|x64.Build.0 = Release|x64
{0E1E304A-DEC7-4704-BCE8-65A4DACE00BC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {0E1E304A-DEC7-4704-BCE8-65A4DACE00BC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{0E1E304A-DEC7-4704-BCE8-65A4DACE00BC}.Debug|Any CPU.Build.0 = Debug|Any CPU {0E1E304A-DEC7-4704-BCE8-65A4DACE00BC}.Debug|Any CPU.Build.0 = Debug|Any CPU
{0E1E304A-DEC7-4704-BCE8-65A4DACE00BC}.Debug|x64.ActiveCfg = Debug|Any CPU
{0E1E304A-DEC7-4704-BCE8-65A4DACE00BC}.Debug|x64.Build.0 = Debug|Any CPU
{0E1E304A-DEC7-4704-BCE8-65A4DACE00BC}.Release|Any CPU.ActiveCfg = Release|Any CPU {0E1E304A-DEC7-4704-BCE8-65A4DACE00BC}.Release|Any CPU.ActiveCfg = Release|Any CPU
{0E1E304A-DEC7-4704-BCE8-65A4DACE00BC}.Release|Any CPU.Build.0 = Release|Any CPU {0E1E304A-DEC7-4704-BCE8-65A4DACE00BC}.Release|Any CPU.Build.0 = Release|Any CPU
{0E1E304A-DEC7-4704-BCE8-65A4DACE00BC}.Release|x64.ActiveCfg = Release|x64
{0E1E304A-DEC7-4704-BCE8-65A4DACE00BC}.Release|x64.Build.0 = Release|x64
{59672094-7BE6-4CB2-8401-59D59D8AF07A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {59672094-7BE6-4CB2-8401-59D59D8AF07A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{59672094-7BE6-4CB2-8401-59D59D8AF07A}.Debug|Any CPU.Build.0 = Debug|Any CPU {59672094-7BE6-4CB2-8401-59D59D8AF07A}.Debug|Any CPU.Build.0 = Debug|Any CPU
{59672094-7BE6-4CB2-8401-59D59D8AF07A}.Debug|x64.ActiveCfg = Debug|Any CPU
{59672094-7BE6-4CB2-8401-59D59D8AF07A}.Debug|x64.Build.0 = Debug|Any CPU
{59672094-7BE6-4CB2-8401-59D59D8AF07A}.Release|Any CPU.ActiveCfg = Release|Any CPU {59672094-7BE6-4CB2-8401-59D59D8AF07A}.Release|Any CPU.ActiveCfg = Release|Any CPU
{59672094-7BE6-4CB2-8401-59D59D8AF07A}.Release|Any CPU.Build.0 = Release|Any CPU {59672094-7BE6-4CB2-8401-59D59D8AF07A}.Release|Any CPU.Build.0 = Release|Any CPU
{59672094-7BE6-4CB2-8401-59D59D8AF07A}.Release|x64.ActiveCfg = Release|x64
{59672094-7BE6-4CB2-8401-59D59D8AF07A}.Release|x64.Build.0 = Release|x64
{CE82389C-E484-4EAC-8F78-0FB5C6EB63A4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{CE82389C-E484-4EAC-8F78-0FB5C6EB63A4}.Debug|Any CPU.Build.0 = Debug|Any CPU
{CE82389C-E484-4EAC-8F78-0FB5C6EB63A4}.Debug|x64.ActiveCfg = Debug|x64
{CE82389C-E484-4EAC-8F78-0FB5C6EB63A4}.Debug|x64.Build.0 = Debug|x64
{CE82389C-E484-4EAC-8F78-0FB5C6EB63A4}.Release|Any CPU.ActiveCfg = Release|Any CPU
{CE82389C-E484-4EAC-8F78-0FB5C6EB63A4}.Release|Any CPU.Build.0 = Release|Any CPU
{CE82389C-E484-4EAC-8F78-0FB5C6EB63A4}.Release|x64.ActiveCfg = Release|x64
{CE82389C-E484-4EAC-8F78-0FB5C6EB63A4}.Release|x64.Build.0 = Release|x64
{0387DAAC-724C-43D1-9C68-D8383F05E909}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{0387DAAC-724C-43D1-9C68-D8383F05E909}.Debug|Any CPU.Build.0 = Debug|Any CPU
{0387DAAC-724C-43D1-9C68-D8383F05E909}.Debug|x64.ActiveCfg = Debug|x64
{0387DAAC-724C-43D1-9C68-D8383F05E909}.Debug|x64.Build.0 = Debug|x64
{0387DAAC-724C-43D1-9C68-D8383F05E909}.Release|Any CPU.ActiveCfg = Release|Any CPU
{0387DAAC-724C-43D1-9C68-D8383F05E909}.Release|Any CPU.Build.0 = Release|Any CPU
{0387DAAC-724C-43D1-9C68-D8383F05E909}.Release|x64.ActiveCfg = Release|x64
{0387DAAC-724C-43D1-9C68-D8383F05E909}.Release|x64.Build.0 = Release|x64
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection EndGlobalSection
GlobalSection(NestedProjects) = preSolution GlobalSection(NestedProjects) = preSolution
{0E1E304A-DEC7-4704-BCE8-65A4DACE00BC} = {A245341F-91C7-4FC3-9EB8-FC6455180427} {0E1E304A-DEC7-4704-BCE8-65A4DACE00BC} = {A245341F-91C7-4FC3-9EB8-FC6455180427}
{59672094-7BE6-4CB2-8401-59D59D8AF07A} = {A245341F-91C7-4FC3-9EB8-FC6455180427} {59672094-7BE6-4CB2-8401-59D59D8AF07A} = {A245341F-91C7-4FC3-9EB8-FC6455180427}
EndGlobalSection EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {68B75065-924C-42D1-8B6C-A4B5678C2A85}
EndGlobalSection
EndGlobal EndGlobal