diff --git a/src/Everything2Everything.App/App.xaml.cs b/src/Everything2Everything.App/App.xaml.cs index 3c7b93a..95291d4 100644 --- a/src/Everything2Everything.App/App.xaml.cs +++ b/src/Everything2Everything.App/App.xaml.cs @@ -94,12 +94,13 @@ public partial class App : Application log.AppendLine($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}] Quick start → {outputExtension}, {files.Count} file(s)"); foreach (var f in files) log.AppendLine($" src: {f}"); - var progress = new QuickProgressWindow(files.Count); + var progress = new QuickProgressWindow(files.Count, outputExtension); progress.Show(); try { var options = ConvertOptions.Quick(); + options.VideoPreferGpu = Settings.Get("video.gpu") != "false"; var reporter = new Progress(p => progress.Report(p)); var results = await Engine.ConvertManyAsync(files, outputExtension, options, reporter); diff --git a/src/Everything2Everything.App/Views/MainWindow.xaml.cs b/src/Everything2Everything.App/Views/MainWindow.xaml.cs index 7e499a6..fa8f69c 100644 --- a/src/Everything2Everything.App/Views/MainWindow.xaml.cs +++ b/src/Everything2Everything.App/Views/MainWindow.xaml.cs @@ -304,6 +304,8 @@ public partial class MainWindow : Wpf.Ui.Controls.FluentWindow var targetLang = AiTargetLangBox?.Text?.Trim(); opts.Ai.TargetLanguage = string.IsNullOrEmpty(targetLang) ? null : targetLang; + opts.VideoPreferGpu = ((App)Application.Current).Settings.Get("video.gpu") != "false"; + return opts; } diff --git a/src/Everything2Everything.App/Views/QuickProgressWindow.xaml b/src/Everything2Everything.App/Views/QuickProgressWindow.xaml index fbfbedf..0d668ca 100644 --- a/src/Everything2Everything.App/Views/QuickProgressWindow.xaml +++ b/src/Everything2Everything.App/Views/QuickProgressWindow.xaml @@ -2,7 +2,7 @@ xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml" - Title="JPEG로 빠른 변환" + Title="빠른 변환" Width="560" Height="220" ExtendsContentIntoTitleBar="True" WindowBackdropType="Mica" @@ -14,7 +14,7 @@ - + @@ -23,7 +23,15 @@ - + + + + + + + + Report(p)); return; } var overall = _total == 0 ? 0 : (p.Index + p.FileProgress) / _total; - OverallProgress.Value = Math.Clamp(overall, 0, 1); + var clamped = Math.Clamp(overall, 0, 1); + OverallProgress.Value = clamped; + // 진행률이 멈춘 듯 보이지 않도록 % 표시 (영상 트랜스코딩처럼 오래 걸려도 단계 진행이 보이게) + OverallProgress.IsIndeterminate = clamped <= 0; + PercentText.Text = clamped <= 0 ? "처리 중…" : $"{(int)Math.Round(clamped * 100)}%"; StatusText.Text = $"{Math.Min(p.Index + 1, _total)} / {_total} — {Path.GetFileName(p.CurrentPath)}"; } @@ -33,7 +44,9 @@ public partial class QuickProgressWindow : FluentWindow var failed = results.Count(r => r.Status == ConvertStatus.Failed); var outputs = results.Sum(r => r.OutputPaths.Count); + OverallProgress.IsIndeterminate = false; OverallProgress.Value = 1; + PercentText.Text = failed > 0 ? "완료(일부 실패)" : "100%"; StatusText.Text = $"성공 {success}개 (출력 {outputs}), 건너뜀 {skipped}, 실패 {failed}"; CloseButton.IsEnabled = true; diff --git a/src/Everything2Everything.App/Views/SettingsWindow.xaml b/src/Everything2Everything.App/Views/SettingsWindow.xaml index ba9d8d7..597381e 100644 --- a/src/Everything2Everything.App/Views/SettingsWindow.xaml +++ b/src/Everything2Everything.App/Views/SettingsWindow.xaml @@ -102,7 +102,9 @@ + Style="{StaticResource FsCaptionStyle}" TextWrapping="Wrap" Margin="0,4,0,12"/> + diff --git a/src/Everything2Everything.App/Views/SettingsWindow.xaml.cs b/src/Everything2Everything.App/Views/SettingsWindow.xaml.cs index 7a5b4fb..398b6a9 100644 --- a/src/Everything2Everything.App/Views/SettingsWindow.xaml.cs +++ b/src/Everything2Everything.App/Views/SettingsWindow.xaml.cs @@ -35,6 +35,8 @@ public partial class SettingsWindow : Wpf.Ui.Controls.FluentWindow SetKeyStatus(OpenAiDot, OpenAiStatus, _settings.Contains("openai.apikey"), HasEnv("OPENAI_API_KEY")); SetKeyStatus(AnthropicDot, AnthropicStatus, _settings.Contains("anthropic.apikey"), HasEnv("ANTHROPIC_API_KEY")); + + GpuToggle.IsChecked = _settings.Get("video.gpu") != "false"; // 기본 켜짐 } private static bool HasEnv(string name) => !string.IsNullOrEmpty(Environment.GetEnvironmentVariable(name)); @@ -126,6 +128,7 @@ public partial class SettingsWindow : Wpf.Ui.Controls.FluentWindow if (string.IsNullOrEmpty(model)) _settings.Remove("ai.model"); else _settings.Set("ai.model", model); + _settings.Set("video.gpu", GpuToggle.IsChecked == true ? "true" : "false"); if (OpenAiKeyBox.Password.Length > 0) _settings.Set("openai.apikey", OpenAiKeyBox.Password); if (AnthropicKeyBox.Password.Length > 0) _settings.Set("anthropic.apikey", AnthropicKeyBox.Password); diff --git a/src/Everything2Everything.Core/ConvertOptions.cs b/src/Everything2Everything.Core/ConvertOptions.cs index bc8ed6b..1b4dfa4 100644 --- a/src/Everything2Everything.Core/ConvertOptions.cs +++ b/src/Everything2Everything.Core/ConvertOptions.cs @@ -64,6 +64,9 @@ public sealed class ConvertOptions /// 래스터화 같은 큰 손실 엣지를 회피한다. public bool AvoidLossy { get; set; } = false; + /// 영상 인코딩 시 GPU 하드웨어 가속(NVENC)을 우선 시도하고, 실패하면 CPU로 자동 폴백한다. + public bool VideoPreferGpu { get; set; } = true; + public PdfCompressOptions PdfCompress { get; set; } = new(); public AiOptions Ai { get; set; } = new(); diff --git a/src/Everything2Everything.Core/Converters/FfmpegProvider.cs b/src/Everything2Everything.Core/Converters/FfmpegProvider.cs index ffb2c28..e31fb9a 100644 --- a/src/Everything2Everything.Core/Converters/FfmpegProvider.cs +++ b/src/Everything2Everything.Core/Converters/FfmpegProvider.cs @@ -11,6 +11,7 @@ public sealed class FfmpegProvider : IConverterProvider { private static readonly string[] Video = { ".mp4", ".mkv", ".webm", ".mov", ".avi", ".gif" }; private static readonly string[] Audio = { ".mp3", ".aac", ".m4a", ".opus", ".ogg", ".flac", ".wav" }; + private static readonly string[] ImageFromVideo = { ".png", ".jpg", ".jpeg", ".webp", ".bmp" }; public ProviderCapability Capability { get; } = new( Id: "ffmpeg", @@ -34,6 +35,7 @@ public sealed class FfmpegProvider : IConverterProvider pairs.AddRange(ProviderCapability.PairsFromMatrix(Video, Video, LossClass.Recode)); pairs.AddRange(ProviderCapability.PairsFromMatrix(Audio, Audio, LossClass.Recode)); pairs.AddRange(ProviderCapability.PairsFromMatrix(Video, Audio, LossClass.Recode)); // 영상 → 오디오 추출 + pairs.AddRange(ProviderCapability.PairsFromMatrix(Video, ImageFromVideo, LossClass.Rasterize)); // 영상 → 대표 프레임 이미지 return pairs; } @@ -60,27 +62,63 @@ public sealed class FfmpegProvider : IConverterProvider return ConvertResult.Skip(sourcePath, "기존 파일이 있어 건너뜁니다."); var ffOptions = new FFOptions { BinaryFolder = dir }; + var inExt = ConversionPair.Normalize(Path.GetExtension(sourcePath)); + var isImageOut = outExt is ".png" or ".jpg" or ".jpeg" or ".webp" or ".bmp"; try { - progress?.Report(0.05); + progress?.Report(0.1); + + // 영상 → 이미지: 대표 프레임 1장 추출 (gif 경유 멀티홉을 피해 즉시 처리) + if (isImageOut && Array.IndexOf(Video, inExt) >= 0) + { + var okImg = await FFMpegArguments + .FromFileInput(sourcePath) + .OutputToFile(outPath, overwrite: true, o => o.WithCustomArgument("-frames:v 1 -update 1")) + .CancellableThrough(cancellationToken) + .ProcessAsynchronously(throwOnError: true, ffOptions) + .ConfigureAwait(false); + progress?.Report(1.0); + return okImg && File.Exists(outPath) + ? ConvertResult.Ok(sourcePath, new[] { outPath }) + : ConvertResult.Fail(sourcePath, "영상에서 프레임 추출에 실패했습니다."); + } // 진행률 best-effort: ffprobe로 길이를 알면 시간 기반 보고 TimeSpan total = TimeSpan.Zero; try { total = (await FFProbe.AnalyseAsync(sourcePath, ffOptions, cancellationToken).ConfigureAwait(false)).Duration; } catch { /* 길이 미상이면 진행률 생략 */ } - var processor = FFMpegArguments - .FromFileInput(sourcePath) - .OutputToFile(outPath, overwrite: true) - .CancellableThrough(cancellationToken); + // GPU(NVENC) 가속은 H.264 컨테이너(mp4/mkv/mov)에만 적용 시도하고, 실패하면 CPU로 폴백 + var tryGpu = options.VideoPreferGpu && (outExt is ".mp4" or ".mkv" or ".mov"); - if (total > TimeSpan.Zero) - processor = processor.NotifyOnProgress( - percent => progress?.Report(Math.Clamp(percent / 100.0, 0, 1)), total); + async Task RunAsync(bool gpu) + { + var processor = FFMpegArguments + .FromFileInput(sourcePath) + .OutputToFile(outPath, overwrite: true, o => + { + if (gpu) o.WithVideoCodec("h264_nvenc"); + }) + .CancellableThrough(cancellationToken); + if (total > TimeSpan.Zero) + processor = processor.NotifyOnProgress( + percent => progress?.Report(Math.Clamp(percent / 100.0, 0, 1)), total); + return await processor.ProcessAsynchronously(throwOnError: true, ffOptions).ConfigureAwait(false); + } + + bool ok; + try + { + ok = await RunAsync(tryGpu).ConfigureAwait(false); + } + catch when (tryGpu && !cancellationToken.IsCancellationRequested) + { + // NVENC 미지원(GPU 없음 등) → CPU 인코더로 폴백 + progress?.Report(0.05); + ok = await RunAsync(false).ConfigureAwait(false); + } - var ok = await processor.ProcessAsynchronously(throwOnError: true, ffOptions).ConfigureAwait(false); progress?.Report(1.0); - return ok && File.Exists(outPath) ? ConvertResult.Ok(sourcePath, new[] { outPath }) : ConvertResult.Fail(sourcePath, "FFmpeg 변환에 실패했습니다.");