feat: MAUI nativo. Aplicativo rodando local no windows.

This commit is contained in:
Ricardo Carneiro 2026-02-10 12:26:43 -03:00
parent 8b75f1f62d
commit c5a3b14449
39 changed files with 1336 additions and 97 deletions

View File

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

View File

@ -1,73 +1,33 @@
# Debug Groq Response # A Origem da Alimentação no Brasil e a Importância da Química na Vida Cotidiana (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": "A Origem da Alimentação no Brasil e a Importância da Química na Vida Cotidiana",
"sections": [ "sections": [
{ {
"title": "Passo 1: Introdução", "title": "Introdução à Alimentação no Brasil",
"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 base alimentar brasileira é composta pelo arroz, mas por que não pela batata, se fomos colonizados por europeus que comem batata? Essa é uma pergunta interessante que leva a uma história complexa. Os indígenas no Brasil já cultivavam alguns tipos de arroz, assim como as populações africanas. O arroz é um alimento básico da dieta de 2,5 bilhões de pessoas, aproximadamente 1/3 do mundo inteiro. [SCREENSHOT: 00:01:30]"
}, },
{ {
"title": "Passo 2: Planeje suas batalhas", "title": "A Descoberta da Batata pelos Europeus",
"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": "Os europeus descobriram a batata quando chegaram à América, mais especificamente nos Andes. A batata é originária da América, e foi a população local que ensinou os europeus a comê-la. Além da batata, outros alimentos importantes, como o milho e o cacau, também são originários da América. [SCREENSHOT: 00:03:45]"
}, },
{ {
"title": "Passo 3: Gerencie seus equipamentos", "title": "A Importância da Química na Vida Cotidiana",
"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": "A química está presente em todos os aspectos da vida, desde a culinária até a indústria. Um exemplo interessante é o funcionamento de um spray de tinta. A bolinha dentro do spray serve para misturar a tinta com o propelente, um gás inflamável, garantindo que a tinta saia uniformemente. [SCREENSHOT: 00:06:15]"
}, },
{ {
"title": "Passo 4: Escolha suas batalhas", "title": "A Diferença entre Á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": "A água com gás contém bolhinhas de gás carbônico, enquanto a água oxigenada é uma substância química diferente, com uma molécula de H2O2. A água oxigenada não é simplesmente água com oxigênio dissolvido, mas sim uma substância com propriedades únicas. [SCREENSHOT: 00:09:30]"
}, },
{ {
"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": "A química é uma ciência fascinante que pode ser aprendida de maneira divertida. A equipe do Manual do Mundo está trabalhando em novos projetos, incluindo a revisão do Grande Livro de Biologia. Esses livros são uma ótima forma de aprender sobre ciência de maneira interativa e divertida. [SCREENSHOT: 00:12:00]"
},
{
"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

@ -70,6 +70,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();
@ -83,22 +123,21 @@ public class AnalysisService
{ {
// --- 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);
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}");
@ -116,14 +155,16 @@ public class AnalysisService
// --- 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 +193,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 +233,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 +254,67 @@ 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)
{ {
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>();
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.
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: 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 +324,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)
@ -304,28 +397,64 @@ Transcrição: {transcript[..Math.Min(transcript.Length, 15000)]}";
catch (Exception ex) { AddLog($"❌ Erro Puppeteer: {ex.Message}"); } catch (Exception ex) { AddLog($"❌ Erro Puppeteer: {ex.Message}"); }
} }
private byte[] GeneratePdf(string videoUrl, List<TutorialSection> sections) 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;
}

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