1
0
Fork 0

fix: 빠른 변환 진행 표시(제목 동적+%) + 영상→이미지 프레임 추출 + GPU 가속

사용자 리포트: mp4 변환 시 %안나오고 멈춤, JPEG 고정 문구, GPU 가속 부재.

- QuickProgressWindow: 제목/타이틀바를 출력 형식으로 동적화('JPEG 고정' 해결), % 텍스트 + indeterminate 표시
- FfmpegProvider: 영상→이미지(jpg/png/webp/bmp) 대표 프레임 추출 직접 엣지 (mp4→jpg가 무거운 mp4→gif→jpg 멀티홉 거치던 문제 해결)
- GPU 가속: ConvertOptions.VideoPreferGpu(기본 on) — NVENC 시도 후 실패 시 CPU 자동 폴백, 설정창 토글로 제어
- 테스트 38개 통과
This commit is contained in:
Yun Chan 2026-06-01 21:28:55 +09:00
parent 573d04521d
commit ef10c139cd
8 changed files with 87 additions and 17 deletions

View file

@ -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)"); 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}"); foreach (var f in files) log.AppendLine($" src: {f}");
var progress = new QuickProgressWindow(files.Count); var progress = new QuickProgressWindow(files.Count, outputExtension);
progress.Show(); progress.Show();
try try
{ {
var options = ConvertOptions.Quick(); var options = ConvertOptions.Quick();
options.VideoPreferGpu = Settings.Get("video.gpu") != "false";
var reporter = new Progress<ConvertProgress>(p => progress.Report(p)); var reporter = new Progress<ConvertProgress>(p => progress.Report(p));
var results = await Engine.ConvertManyAsync(files, outputExtension, options, reporter); var results = await Engine.ConvertManyAsync(files, outputExtension, options, reporter);

View file

@ -304,6 +304,8 @@ public partial class MainWindow : Wpf.Ui.Controls.FluentWindow
var targetLang = AiTargetLangBox?.Text?.Trim(); var targetLang = AiTargetLangBox?.Text?.Trim();
opts.Ai.TargetLanguage = string.IsNullOrEmpty(targetLang) ? null : targetLang; opts.Ai.TargetLanguage = string.IsNullOrEmpty(targetLang) ? null : targetLang;
opts.VideoPreferGpu = ((App)Application.Current).Settings.Get("video.gpu") != "false";
return opts; return opts;
} }

View file

@ -2,7 +2,7 @@
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml" xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml"
Title="JPEG로 빠른 변환" Title="빠른 변환"
Width="560" Height="220" Width="560" Height="220"
ExtendsContentIntoTitleBar="True" ExtendsContentIntoTitleBar="True"
WindowBackdropType="Mica" WindowBackdropType="Mica"
@ -14,7 +14,7 @@
<RowDefinition Height="Auto"/> <RowDefinition Height="Auto"/>
<RowDefinition Height="*"/> <RowDefinition Height="*"/>
</Grid.RowDefinitions> </Grid.RowDefinitions>
<ui:TitleBar Grid.Row="0" Title="JPEG로 빠른 변환"/> <ui:TitleBar Grid.Row="0" x:Name="WindowTitleBar" Title="빠른 변환"/>
<Grid Grid.Row="1" Margin="32,12,32,24"> <Grid Grid.Row="1" Margin="32,12,32,24">
<Grid.RowDefinitions> <Grid.RowDefinitions>
<RowDefinition Height="Auto"/> <RowDefinition Height="Auto"/>
@ -23,7 +23,15 @@
<RowDefinition Height="*"/> <RowDefinition Height="*"/>
<RowDefinition Height="Auto"/> <RowDefinition Height="Auto"/>
</Grid.RowDefinitions> </Grid.RowDefinitions>
<TextBlock Text="변환 중…" Style="{StaticResource TextSubtitle}"/> <Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock Text="변환 중…" Style="{StaticResource TextSubtitle}"/>
<TextBlock x:Name="PercentText" Grid.Column="1" Text="0%"
Style="{StaticResource TextSubtitle}" VerticalAlignment="Center"/>
</Grid>
<TextBlock x:Name="StatusText" Grid.Row="1" Margin="0,4,0,12" <TextBlock x:Name="StatusText" Grid.Row="1" Margin="0,4,0,12"
Style="{StaticResource TextCaption}" TextTrimming="CharacterEllipsis"/> Style="{StaticResource TextCaption}" TextTrimming="CharacterEllipsis"/>
<ProgressBar x:Name="OverallProgress" Grid.Row="2" Height="6" <ProgressBar x:Name="OverallProgress" Grid.Row="2" Height="6"

View file

@ -9,10 +9,17 @@ public partial class QuickProgressWindow : FluentWindow
private readonly int _total; private readonly int _total;
private string? _firstSuccessOutput; private string? _firstSuccessOutput;
public QuickProgressWindow(int total) public QuickProgressWindow(int total, string? outputExtension = null)
{ {
_total = total; _total = total;
InitializeComponent(); InitializeComponent();
var label = string.IsNullOrWhiteSpace(outputExtension)
? "빠른 변환"
: $"{outputExtension.TrimStart('.').ToUpperInvariant()}(으)로 변환";
Title = label;
WindowTitleBar.Title = label;
StatusText.Text = $"0 / {_total}"; StatusText.Text = $"0 / {_total}";
} }
@ -20,7 +27,11 @@ public partial class QuickProgressWindow : FluentWindow
{ {
if (!CheckAccess()) { Dispatcher.Invoke(() => Report(p)); return; } if (!CheckAccess()) { Dispatcher.Invoke(() => Report(p)); return; }
var overall = _total == 0 ? 0 : (p.Index + p.FileProgress) / _total; 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)}"; 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 failed = results.Count(r => r.Status == ConvertStatus.Failed);
var outputs = results.Sum(r => r.OutputPaths.Count); var outputs = results.Sum(r => r.OutputPaths.Count);
OverallProgress.IsIndeterminate = false;
OverallProgress.Value = 1; OverallProgress.Value = 1;
PercentText.Text = failed > 0 ? "완료(일부 실패)" : "100%";
StatusText.Text = $"성공 {success}개 (출력 {outputs}), 건너뜀 {skipped}, 실패 {failed}"; StatusText.Text = $"성공 {success}개 (출력 {outputs}), 건너뜀 {skipped}, 실패 {failed}";
CloseButton.IsEnabled = true; CloseButton.IsEnabled = true;

View file

@ -102,7 +102,9 @@
<StackPanel> <StackPanel>
<TextBlock Text="외부 도구" Style="{StaticResource FsBodyStyle}" FontWeight="SemiBold" FontSize="15"/> <TextBlock Text="외부 도구" Style="{StaticResource FsBodyStyle}" FontWeight="SemiBold" FontSize="15"/>
<TextBlock Text="설치하면 영상/오디오·한글/Word 변환이 자동 활성화됩니다." <TextBlock Text="설치하면 영상/오디오·한글/Word 변환이 자동 활성화됩니다."
Style="{StaticResource FsCaptionStyle}" TextWrapping="Wrap" Margin="0,4,0,16"/> Style="{StaticResource FsCaptionStyle}" TextWrapping="Wrap" Margin="0,4,0,12"/>
<CheckBox x:Name="GpuToggle" Content="영상 변환 시 GPU 가속(NVENC) 시도 — 없으면 CPU 자동 전환"
Foreground="{StaticResource FsTextSecondary}" IsChecked="True" Margin="0,0,0,16"/>
<!-- FFmpeg --> <!-- FFmpeg -->
<Grid Margin="0,0,0,14"> <Grid Margin="0,0,0,14">

View file

@ -35,6 +35,8 @@ public partial class SettingsWindow : Wpf.Ui.Controls.FluentWindow
SetKeyStatus(OpenAiDot, OpenAiStatus, _settings.Contains("openai.apikey"), HasEnv("OPENAI_API_KEY")); SetKeyStatus(OpenAiDot, OpenAiStatus, _settings.Contains("openai.apikey"), HasEnv("OPENAI_API_KEY"));
SetKeyStatus(AnthropicDot, AnthropicStatus, _settings.Contains("anthropic.apikey"), HasEnv("ANTHROPIC_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)); 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"); if (string.IsNullOrEmpty(model)) _settings.Remove("ai.model");
else _settings.Set("ai.model", 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 (OpenAiKeyBox.Password.Length > 0) _settings.Set("openai.apikey", OpenAiKeyBox.Password);
if (AnthropicKeyBox.Password.Length > 0) _settings.Set("anthropic.apikey", AnthropicKeyBox.Password); if (AnthropicKeyBox.Password.Length > 0) _settings.Set("anthropic.apikey", AnthropicKeyBox.Password);

View file

@ -64,6 +64,9 @@ public sealed class ConvertOptions
/// <summary>래스터화 같은 큰 손실 엣지를 회피한다.</summary> /// <summary>래스터화 같은 큰 손실 엣지를 회피한다.</summary>
public bool AvoidLossy { get; set; } = false; public bool AvoidLossy { get; set; } = false;
/// <summary>영상 인코딩 시 GPU 하드웨어 가속(NVENC)을 우선 시도하고, 실패하면 CPU로 자동 폴백한다.</summary>
public bool VideoPreferGpu { get; set; } = true;
public PdfCompressOptions PdfCompress { get; set; } = new(); public PdfCompressOptions PdfCompress { get; set; } = new();
public AiOptions Ai { get; set; } = new(); public AiOptions Ai { get; set; } = new();

View file

@ -11,6 +11,7 @@ public sealed class FfmpegProvider : IConverterProvider
{ {
private static readonly string[] Video = { ".mp4", ".mkv", ".webm", ".mov", ".avi", ".gif" }; 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[] Audio = { ".mp3", ".aac", ".m4a", ".opus", ".ogg", ".flac", ".wav" };
private static readonly string[] ImageFromVideo = { ".png", ".jpg", ".jpeg", ".webp", ".bmp" };
public ProviderCapability Capability { get; } = new( public ProviderCapability Capability { get; } = new(
Id: "ffmpeg", Id: "ffmpeg",
@ -34,6 +35,7 @@ public sealed class FfmpegProvider : IConverterProvider
pairs.AddRange(ProviderCapability.PairsFromMatrix(Video, Video, LossClass.Recode)); pairs.AddRange(ProviderCapability.PairsFromMatrix(Video, Video, LossClass.Recode));
pairs.AddRange(ProviderCapability.PairsFromMatrix(Audio, Audio, LossClass.Recode)); pairs.AddRange(ProviderCapability.PairsFromMatrix(Audio, Audio, LossClass.Recode));
pairs.AddRange(ProviderCapability.PairsFromMatrix(Video, Audio, LossClass.Recode)); // 영상 → 오디오 추출 pairs.AddRange(ProviderCapability.PairsFromMatrix(Video, Audio, LossClass.Recode)); // 영상 → 오디오 추출
pairs.AddRange(ProviderCapability.PairsFromMatrix(Video, ImageFromVideo, LossClass.Rasterize)); // 영상 → 대표 프레임 이미지
return pairs; return pairs;
} }
@ -60,27 +62,63 @@ public sealed class FfmpegProvider : IConverterProvider
return ConvertResult.Skip(sourcePath, "기존 파일이 있어 건너뜁니다."); return ConvertResult.Skip(sourcePath, "기존 파일이 있어 건너뜁니다.");
var ffOptions = new FFOptions { BinaryFolder = dir }; 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 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로 길이를 알면 시간 기반 보고 // 진행률 best-effort: ffprobe로 길이를 알면 시간 기반 보고
TimeSpan total = TimeSpan.Zero; TimeSpan total = TimeSpan.Zero;
try { total = (await FFProbe.AnalyseAsync(sourcePath, ffOptions, cancellationToken).ConfigureAwait(false)).Duration; } try { total = (await FFProbe.AnalyseAsync(sourcePath, ffOptions, cancellationToken).ConfigureAwait(false)).Duration; }
catch { /* 길이 미상이면 진행률 생략 */ } catch { /* 길이 미상이면 진행률 생략 */ }
var processor = FFMpegArguments // GPU(NVENC) 가속은 H.264 컨테이너(mp4/mkv/mov)에만 적용 시도하고, 실패하면 CPU로 폴백
.FromFileInput(sourcePath) var tryGpu = options.VideoPreferGpu && (outExt is ".mp4" or ".mkv" or ".mov");
.OutputToFile(outPath, overwrite: true)
.CancellableThrough(cancellationToken);
if (total > TimeSpan.Zero) async Task<bool> RunAsync(bool gpu)
processor = processor.NotifyOnProgress( {
percent => progress?.Report(Math.Clamp(percent / 100.0, 0, 1)), total); 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); progress?.Report(1.0);
return ok && File.Exists(outPath) return ok && File.Exists(outPath)
? ConvertResult.Ok(sourcePath, new[] { outPath }) ? ConvertResult.Ok(sourcePath, new[] { outPath })
: ConvertResult.Fail(sourcePath, "FFmpeg 변환에 실패했습니다."); : ConvertResult.Fail(sourcePath, "FFmpeg 변환에 실패했습니다.");