fix: one app.
This commit is contained in:
parent
ad5312ba10
commit
667c1c91a1
@ -12,7 +12,8 @@
|
||||
"Bash(git add:*)",
|
||||
"Bash(git commit:*)",
|
||||
"Bash(xargs ls:*)",
|
||||
"Bash(python3 -c \"import sys,json; d=json.load\\(sys.stdin\\); [print\\(e['Route']\\) for e in d.get\\('Endpoints',[]\\)[:20]]\")"
|
||||
"Bash(python3 -c \"import sys,json; d=json.load\\(sys.stdin\\); [print\\(e['Route']\\) for e in d.get\\('Endpoints',[]\\)[:20]]\")",
|
||||
"Bash(python3:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@ -73,8 +73,9 @@ public class AnalysisService
|
||||
|
||||
yield return new AnalysisEvent { ProgressPercentage = 15, Message = "Obtendo transcrição..." };
|
||||
string? transcript = null;
|
||||
string? transcriptReadable = null;
|
||||
try {
|
||||
transcript = await GetTranscriptViaYtDlpAsync(request.VideoUrl, request.Language, tempDir);
|
||||
(transcript, transcriptReadable) = await GetTranscriptViaYtDlpAsync(request.VideoUrl, request.Language, tempDir);
|
||||
if (string.IsNullOrWhiteSpace(transcript)) errorMessage = "O vídeo não possui transcrição disponível.";
|
||||
} catch (Exception ex) {
|
||||
errorMessage = $"Erro na transcrição: {ex.Message}";
|
||||
@ -112,7 +113,7 @@ public class AnalysisService
|
||||
|
||||
yield return new AnalysisEvent { ProgressPercentage = 90, Message = "Gerando documento PDF..." };
|
||||
try {
|
||||
var pdfBytes = GeneratePdf(docTitle!, summary!, request.VideoUrl, sections, category!);
|
||||
var pdfBytes = GeneratePdf(docTitle!, summary!, request.VideoUrl, sections, category!, transcriptReadable);
|
||||
finalResult = new AnalysisResult {
|
||||
VideoTitle = videoInfo.Title,
|
||||
DocumentTitle = docTitle!,
|
||||
@ -211,7 +212,7 @@ public class AnalysisService
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<string> GetTranscriptViaYtDlpAsync(string url, string lang, string dir)
|
||||
private async Task<(string flat, string readable)> GetTranscriptViaYtDlpAsync(string url, string lang, string dir)
|
||||
{
|
||||
var ytdlp = GetYtDlpPath();
|
||||
var cookies = GetCookiesArg();
|
||||
@ -228,10 +229,12 @@ public class AnalysisService
|
||||
using var p = Process.Start(psi)!;
|
||||
await p.WaitForExitAsync();
|
||||
var file = Directory.GetFiles(dir, "*.vtt").FirstOrDefault();
|
||||
return file == null ? "" : ParseVttToText(await File.ReadAllTextAsync(file));
|
||||
if (file == null) return ("", "");
|
||||
var vtt = await File.ReadAllTextAsync(file);
|
||||
return (ParseVttToFlat(vtt), ParseVttToReadable(vtt));
|
||||
}
|
||||
|
||||
private string ParseVttToText(string vtt)
|
||||
private string ParseVttToFlat(string vtt)
|
||||
{
|
||||
var lines = vtt.Split('\n')
|
||||
.Select(l => l.Trim())
|
||||
@ -239,6 +242,72 @@ public class AnalysisService
|
||||
return string.Join(" ", lines.Select(l => Regex.Replace(l, @"<[^>]*>", ""))).Replace(" ", " ");
|
||||
}
|
||||
|
||||
private string ParseVttToReadable(string vtt)
|
||||
{
|
||||
// Parse cues: timestamp line + text lines
|
||||
var cues = new List<(TimeSpan time, string text)>();
|
||||
var lines = vtt.Split('\n').Select(l => l.Trim()).ToArray();
|
||||
|
||||
for (int i = 0; i < lines.Length; i++)
|
||||
{
|
||||
var line = lines[i];
|
||||
if (!line.Contains("-->")) continue;
|
||||
|
||||
// Parse start time from "HH:MM:SS.mmm --> HH:MM:SS.mmm"
|
||||
var timePart = line.Split("-->")[0].Trim().Replace(',', '.');
|
||||
if (!TimeSpan.TryParse(timePart, out var ts)) continue;
|
||||
|
||||
// Collect text lines until blank
|
||||
var textLines = new List<string>();
|
||||
i++;
|
||||
while (i < lines.Length && !string.IsNullOrEmpty(lines[i]) && !lines[i].Contains("-->"))
|
||||
{
|
||||
var t = Regex.Replace(lines[i], @"<[^>]*>", "").Trim();
|
||||
if (!string.IsNullOrEmpty(t)) textLines.Add(t);
|
||||
i++;
|
||||
}
|
||||
i--; // step back, outer loop will increment
|
||||
|
||||
if (textLines.Count > 0)
|
||||
cues.Add((ts, string.Join(" ", textLines)));
|
||||
}
|
||||
|
||||
if (cues.Count == 0) return ParseVttToFlat(vtt);
|
||||
|
||||
// Merge consecutive cues with same/similar text (VTT often duplicates lines)
|
||||
var merged = new List<(TimeSpan time, string text)>();
|
||||
foreach (var cue in cues)
|
||||
{
|
||||
if (merged.Count > 0 && merged[^1].text == cue.text) continue;
|
||||
merged.Add(cue);
|
||||
}
|
||||
|
||||
// Group into paragraphs every ~60 seconds
|
||||
var sb = new System.Text.StringBuilder();
|
||||
TimeSpan? paraStart = null;
|
||||
var paraWords = new List<string>();
|
||||
|
||||
void FlushParagraph()
|
||||
{
|
||||
if (paraWords.Count == 0) return;
|
||||
sb.AppendLine($"[{paraStart!.Value:hh\\:mm\\:ss}] {string.Join(" ", paraWords)}");
|
||||
sb.AppendLine();
|
||||
paraWords.Clear();
|
||||
paraStart = null;
|
||||
}
|
||||
|
||||
foreach (var (time, text) in merged)
|
||||
{
|
||||
if (paraStart == null) paraStart = time;
|
||||
paraWords.Add(text);
|
||||
if ((time - paraStart.Value).TotalSeconds >= 60)
|
||||
FlushParagraph();
|
||||
}
|
||||
FlushParagraph();
|
||||
|
||||
return sb.ToString().TrimEnd();
|
||||
}
|
||||
|
||||
private async Task<(List<TutorialSection> sections, string rawJson, string category, string docTitle, string summary)>
|
||||
GenerateTutorialContentAsync(string transcript, VideoInfo video, string inLang, string? outLang, string? userContext, CancellationToken ct)
|
||||
{
|
||||
@ -322,11 +391,12 @@ Escreva tudo em {outName}.";
|
||||
return line?.Trim();
|
||||
}
|
||||
|
||||
private byte[] GeneratePdf(string title, string summary, string url, List<TutorialSection> sections, string category)
|
||||
private byte[] GeneratePdf(string title, string summary, string url, List<TutorialSection> sections, string category, string? transcriptReadable = null)
|
||||
{
|
||||
var color = category switch { "TUTORIAL" => Colors.Green.Medium, "LECTURE" => Colors.Orange.Medium, _ => Colors.Blue.Medium };
|
||||
return Document.Create(container =>
|
||||
{
|
||||
// Main content page
|
||||
container.Page(page =>
|
||||
{
|
||||
page.Margin(2, Unit.Centimetre);
|
||||
@ -358,6 +428,53 @@ Escreva tudo em {outName}.";
|
||||
});
|
||||
page.Footer().AlignCenter().Text(x => { x.Span("VideoStudy.app — "); x.CurrentPageNumber(); });
|
||||
});
|
||||
|
||||
// Transcript appendix page
|
||||
if (!string.IsNullOrWhiteSpace(transcriptReadable))
|
||||
{
|
||||
container.Page(page =>
|
||||
{
|
||||
page.Margin(2, Unit.Centimetre);
|
||||
page.DefaultTextStyle(x => x.FontSize(10).FontFamily("Segoe UI"));
|
||||
page.Header().Column(c =>
|
||||
{
|
||||
c.Item().Row(r =>
|
||||
{
|
||||
r.RelativeItem().Text("Apêndice — Transcrição").SemiBold().FontSize(18).FontColor(Colors.Grey.Darken2);
|
||||
r.ConstantItem(80).AlignRight().Text(title).FontSize(8).FontColor(Colors.Grey.Medium).Italic();
|
||||
});
|
||||
c.Item().PaddingTop(5).LineHorizontal(1).LineColor(Colors.Grey.Lighten2);
|
||||
c.Item().PaddingTop(4).Text("Cada parágrafo representa aproximadamente 60 segundos. O timestamp indica o início do trecho.")
|
||||
.FontSize(8).Italic().FontColor(Colors.Grey.Medium);
|
||||
});
|
||||
page.Content().PaddingVertical(1, Unit.Centimetre).Column(col =>
|
||||
{
|
||||
foreach (var paragraph in transcriptReadable.Split("\n\n", StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
var trimmed = paragraph.Trim();
|
||||
if (string.IsNullOrEmpty(trimmed)) continue;
|
||||
|
||||
// Split timestamp from text: "[HH:MM:SS] rest of text"
|
||||
var bracketEnd = trimmed.IndexOf(']');
|
||||
if (bracketEnd > 0 && trimmed.StartsWith('['))
|
||||
{
|
||||
var timestamp = trimmed[..(bracketEnd + 1)];
|
||||
var text = trimmed[(bracketEnd + 1)..].Trim();
|
||||
col.Item().PaddingBottom(8).Column(p =>
|
||||
{
|
||||
p.Item().Text(timestamp).Bold().FontSize(9).FontColor(Colors.Blue.Medium);
|
||||
p.Item().PaddingTop(2).Text(text).LineHeight(1.5f);
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
col.Item().PaddingBottom(8).Text(trimmed).LineHeight(1.5f);
|
||||
}
|
||||
}
|
||||
});
|
||||
page.Footer().AlignCenter().Text(x => { x.Span("VideoStudy.app — "); x.CurrentPageNumber(); });
|
||||
});
|
||||
}
|
||||
}).GeneratePdf();
|
||||
}
|
||||
}
|
||||
|
||||
21
VideoStudy.App/AppShell.razor
Normal file
21
VideoStudy.App/AppShell.razor
Normal file
@ -0,0 +1,21 @@
|
||||
@using Microsoft.AspNetCore.Components.Web
|
||||
@using static Microsoft.AspNetCore.Components.Web.RenderMode
|
||||
@using VideoStudy.UI
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="pt-BR">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>VideoStudy</title>
|
||||
<base href="/" />
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet" />
|
||||
<link rel="stylesheet" href="_content/VideoStudy.UI/app.css" />
|
||||
<HeadOutlet />
|
||||
</head>
|
||||
<body>
|
||||
<Routes @rendermode="new InteractiveServerRenderMode(prerender: false)" />
|
||||
<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>
|
||||
@ -1,5 +1,7 @@
|
||||
using System.Threading;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Photino.Blazor;
|
||||
using Photino.NET;
|
||||
using VideoStudy.UI;
|
||||
|
||||
namespace VideoStudy.App;
|
||||
@ -9,26 +11,48 @@ class Program
|
||||
[STAThread]
|
||||
static void Main(string[] args)
|
||||
{
|
||||
var builder = PhotinoBlazorAppBuilder.CreateDefault(args);
|
||||
const string appUrl = "http://localhost:5002";
|
||||
|
||||
builder.Services.AddVideoStudyUI();
|
||||
var serverReady = new ManualResetEventSlim(false);
|
||||
|
||||
builder.Services.AddScoped(sp => new HttpClient
|
||||
// Start Blazor Server in background thread
|
||||
var serverThread = new Thread(() =>
|
||||
{
|
||||
BaseAddress = new Uri("http://localhost:5000"),
|
||||
Timeout = TimeSpan.FromMinutes(10)
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.Services.AddRazorComponents()
|
||||
.AddInteractiveServerComponents();
|
||||
builder.Services.AddVideoStudyUI();
|
||||
builder.Services.AddScoped(_ => new HttpClient
|
||||
{
|
||||
BaseAddress = new Uri("http://localhost:5000"),
|
||||
Timeout = TimeSpan.FromMinutes(10)
|
||||
});
|
||||
|
||||
var app = builder.Build();
|
||||
app.UseStaticFiles();
|
||||
app.UseAntiforgery();
|
||||
app.MapRazorComponents<AppShell>()
|
||||
.AddInteractiveServerRenderMode()
|
||||
.AddAdditionalAssemblies(typeof(Routes).Assembly);
|
||||
|
||||
app.Lifetime.ApplicationStarted.Register(() => serverReady.Set());
|
||||
app.Run(appUrl);
|
||||
});
|
||||
serverThread.IsBackground = true;
|
||||
serverThread.Start();
|
||||
|
||||
builder.RootComponents.Add<VideoStudy.UI.App>("#app");
|
||||
// Wait for Kestrel to be ready (max 15s)
|
||||
serverReady.Wait(TimeSpan.FromSeconds(15));
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
app.MainWindow
|
||||
// Open native window pointing to local server
|
||||
var window = new PhotinoWindow()
|
||||
.SetTitle("VideoStudy")
|
||||
.SetSize(1280, 800)
|
||||
.SetDevToolsEnabled(true)
|
||||
.Center();
|
||||
.Center()
|
||||
.Load(new Uri(appUrl));
|
||||
|
||||
app.Run();
|
||||
window.WaitForClose();
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Razor">
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<RootNamespace>VideoStudy.App</RootNamespace>
|
||||
@ -10,13 +10,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Photino.Blazor" Version="3.2.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Update="wwwroot\**\*">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
<PackageReference Include="Photino.NET" Version="4.0.16" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@ -1,61 +1,37 @@
|
||||
@using VideoStudy.Shared
|
||||
|
||||
<div class="processor-card p-2">
|
||||
<div class="input-group mb-3">
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-bold text-muted small">URL do YouTube</label>
|
||||
<input type="text" class="form-control form-control-lg"
|
||||
placeholder="https://www.youtube.com/watch?v=..."
|
||||
@bind="VideoUrl" disabled="@IsProcessing" />
|
||||
<button type="button" class="btn btn-primary"
|
||||
@onclick="FetchInfo"
|
||||
disabled="@(IsProcessing || string.IsNullOrWhiteSpace(VideoUrl))">
|
||||
Verificar
|
||||
</button>
|
||||
@bind="VideoUrl" @bind:event="oninput"
|
||||
@onchange="OnUrlChanged"
|
||||
disabled="@IsProcessing" />
|
||||
</div>
|
||||
|
||||
@if (VideoInfo != null)
|
||||
{
|
||||
<div class="mb-4 p-3 bg-light rounded-3">
|
||||
<div class="p-3 bg-light rounded-3">
|
||||
<div class="fw-bold">@VideoInfo.Title</div>
|
||||
<div class="d-flex gap-3 text-muted small mt-1">
|
||||
<span>@VideoInfo.Author</span>
|
||||
<span>@VideoInfo.Duration.ToString(@"hh\:mm\:ss")</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-grid">
|
||||
<button class="btn btn-lg btn-primary" @onclick="StartProcessing" disabled="@IsProcessing">
|
||||
@if (IsProcessing)
|
||||
{
|
||||
<span class="spinner-border spinner-border-sm me-2"></span>
|
||||
<span>Analisando...</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>Analisar com IA</span>
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter] public EventCallback<string> OnVideoUrlChanged { get; set; }
|
||||
[Parameter] public EventCallback OnStart { get; set; }
|
||||
[Parameter] public bool IsProcessing { get; set; }
|
||||
[Parameter] public VideoInfo? VideoInfo { get; set; }
|
||||
|
||||
[Inject] YouTubeService YouTubeService { get; set; } = default!;
|
||||
|
||||
private string VideoUrl { get; set; } = "";
|
||||
|
||||
private async Task FetchInfo()
|
||||
private async Task OnUrlChanged(ChangeEventArgs e)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(VideoUrl)) return;
|
||||
VideoUrl = e.Value?.ToString() ?? "";
|
||||
await OnVideoUrlChanged.InvokeAsync(VideoUrl);
|
||||
}
|
||||
|
||||
private async Task StartProcessing()
|
||||
{
|
||||
await OnStart.InvokeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
@ -24,11 +24,9 @@
|
||||
<div class="card shadow-sm border-0 rounded-4 overflow-hidden mb-4 p-4 bg-white">
|
||||
<YouTubeProcessor
|
||||
OnVideoUrlChanged="HandleVideoUrlChanged"
|
||||
OnStart="StartAnalysis"
|
||||
IsProcessing="@isProcessing"
|
||||
VideoInfo="@currentVideoInfo" />
|
||||
|
||||
<!-- Contexto do usuário -->
|
||||
<div class="mt-3">
|
||||
<label class="form-label fw-bold text-muted small">
|
||||
Contexto <span class="fw-normal">(opcional)</span>
|
||||
@ -38,7 +36,7 @@
|
||||
@bind="userContext" disabled="@isProcessing"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="row g-3 mt-1">
|
||||
<div class="row g-3 mt-2">
|
||||
<div class="col-md-12">
|
||||
<label class="form-label fw-bold text-muted small">Idioma de saída</label>
|
||||
<select class="form-select" @bind="selectedLanguage" disabled="@isProcessing">
|
||||
@ -49,6 +47,21 @@
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-grid mt-4">
|
||||
<button class="btn btn-lg btn-primary" @onclick="StartAnalysis"
|
||||
disabled="@(isProcessing || string.IsNullOrWhiteSpace(videoUrl))">
|
||||
@if (isProcessing)
|
||||
{
|
||||
<span class="spinner-border spinner-border-sm me-2"></span>
|
||||
<span>Analisando...</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>Analisar com IA</span>
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (isProcessing || currentStep > 0)
|
||||
@ -117,19 +130,10 @@
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private async Task HandleVideoUrlChanged(string url)
|
||||
private void HandleVideoUrlChanged(string url)
|
||||
{
|
||||
videoUrl = url;
|
||||
try
|
||||
{
|
||||
AddLog($"Buscando informações: {url}");
|
||||
currentVideoInfo = await YouTubeService.GetVideoInfoAsync(url);
|
||||
if (currentVideoInfo != null) AddLog($"Encontrado: {currentVideoInfo.Title}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AddLog($"Erro ao buscar vídeo: {ex.Message}");
|
||||
}
|
||||
currentVideoInfo = null;
|
||||
}
|
||||
|
||||
private async Task StartAnalysis()
|
||||
@ -148,9 +152,22 @@
|
||||
cts = new CancellationTokenSource();
|
||||
var token = cts.Token;
|
||||
|
||||
// Buscar info do vídeo antes de iniciar
|
||||
currentStep = 1;
|
||||
statusMessage = "Buscando informações do vídeo...";
|
||||
AddLog($"Verificando URL: {videoUrl}", 2);
|
||||
try
|
||||
{
|
||||
currentVideoInfo = await YouTubeService.GetVideoInfoAsync(videoUrl);
|
||||
AddLog($"Vídeo encontrado: {currentVideoInfo.Title}", 5);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new Exception($"Não foi possível obter informações do vídeo: {ex.Message}");
|
||||
}
|
||||
|
||||
statusMessage = "Iniciando análise...";
|
||||
AddLog("Enviando para o servidor...", 5);
|
||||
AddLog("Enviando para o servidor...", 8);
|
||||
|
||||
var request = new AnalysisRequest
|
||||
{
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Razor">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net8.0;net10.0</TargetFrameworks>
|
||||
<TargetFrameworks>net8.0;net9.0;net10.0</TargetFrameworks>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Platforms>AnyCPU;x64</Platforms>
|
||||
@ -15,6 +15,10 @@
|
||||
<PackageReference Include="Microsoft.AspNetCore.Components.Web" Version="8.0.22" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup Condition="'$(TargetFramework)' == 'net9.0'">
|
||||
<PackageReference Include="Microsoft.AspNetCore.Components.Web" Version="9.0.5" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup Condition="'$(TargetFramework)' == 'net10.0'">
|
||||
<PackageReference Include="Microsoft.AspNetCore.Components.Web" Version="10.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user