feat(quick): 빠른 변환 간단 옵션 팝업 + 중간 취소 지원
컨텍스트 메뉴에서 형식(MP4/MP3 등) 선택 시 간단 옵션 팝업을 띄우고, 변환 중 취소를 지원한다. - QuickOptionsWindow(신규): 출력 형식에 맞춘 간단 옵션(영상=품질CRF/해상도/GPU, 오디오=비트레이트, 이미지=품질) 팝업. '변환'으로 진행, '취소'로 종료, '자세히 옵션…'으로 풀 UI(MainWindow) 전환. OptionsViewModel 재사용 → ToConvertOptions()로 변환 옵션 구성. - App.RunQuickAsync: 변환 전 팝업 표시 + 선택 옵션 적용. ConvertManyAsync에 취소 토큰 전달(이전엔 None이라 취소 불가였음). 변환 경로는 ShutdownMode=OnExplicitShutdown으로 전환해 **취소/창 닫기 시 ffmpeg를 종료한 '뒤' 앱을 종료**(고아 프로세스·백그라운드 잔류 방지). 성공 시 결과 창 닫을 때까지 대기. - QuickProgressWindow: 변환 중 '취소' 버튼 + 창 Closing 시 취소(CancellationTokenSource 주입). 완료 시 취소 버튼 숨김. 버그 수정: 빠른 변환 진행 창을 닫아도 변환이 백그라운드에서 계속 돌던 문제 해결. 빌드 0/0, 106 테스트 그린, publish 후 팝업/일반 실행 크래시 없이 로드.
This commit is contained in:
parent
c10da19bfd
commit
7d461d6ae4
5 changed files with 226 additions and 12 deletions
|
|
@ -97,19 +97,34 @@ public partial class App : Application
|
||||||
|
|
||||||
private async Task RunQuickAsync(IReadOnlyList<string> files, string outputExtension)
|
private async Task RunQuickAsync(IReadOnlyList<string> files, string outputExtension)
|
||||||
{
|
{
|
||||||
|
// 빠른 변환 전, 출력 형식에 맞춘 간단 옵션 팝업.
|
||||||
|
// '자세히 옵션…'이면 풀 UI(MainWindow)로 전환, '취소'면 종료, '변환'이면 선택 옵션으로 진행.
|
||||||
|
var optWin = new QuickOptionsWindow(outputExtension, files.Count, Settings);
|
||||||
|
var confirmed = optWin.ShowDialog();
|
||||||
|
if (optWin.OpenFullUi) { ShowConvertDialog(files); return; }
|
||||||
|
if (confirmed != true) { Shutdown(0); return; }
|
||||||
|
|
||||||
|
// 변환 경로 동안은 명시적 종료 모드 — 진행 창을 닫아도 변환을 취소(ffmpeg 종료)한 '뒤' 앱을 종료한다.
|
||||||
|
// (기본 OnLastWindowClose면 창 닫는 즉시 종료가 시작돼 ffmpeg가 고아로 백그라운드에 남는다.)
|
||||||
|
ShutdownMode = System.Windows.ShutdownMode.OnExplicitShutdown;
|
||||||
|
|
||||||
var logPath = Path.Combine(Path.GetTempPath(), "Everything2Everything_quick.log");
|
var logPath = Path.Combine(Path.GetTempPath(), "Everything2Everything_quick.log");
|
||||||
var log = new System.Text.StringBuilder();
|
var log = new System.Text.StringBuilder();
|
||||||
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, outputExtension);
|
using var cts = new CancellationTokenSource();
|
||||||
|
var progress = new QuickProgressWindow(files.Count, cts, outputExtension);
|
||||||
|
var closed = new TaskCompletionSource();
|
||||||
|
progress.Closed += (_, _) => closed.TrySetResult();
|
||||||
progress.Show();
|
progress.Show();
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var options = ConvertOptions.Quick() with { VideoPreferGpu = Settings.Get("video.gpu") != "false" };
|
var options = optWin.Options.ToConvertOptions();
|
||||||
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, BatchMode.Independent, cts.Token);
|
||||||
|
|
||||||
foreach (var r in results)
|
foreach (var r in results)
|
||||||
{
|
{
|
||||||
|
|
@ -120,6 +135,13 @@ public partial class App : Application
|
||||||
}
|
}
|
||||||
|
|
||||||
progress.Finish(results);
|
progress.Finish(results);
|
||||||
|
await closed.Task; // 결과 창을 사용자가 닫을 때까지 대기(성공 경로)
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
// 사용자가 취소(취소 버튼/창 닫기) — ffmpeg는 이미 중단된 뒤 여기에 도달. 진행 창 닫고 종료.
|
||||||
|
log.AppendLine(" CANCELLED by user");
|
||||||
|
try { progress.Close(); } catch { }
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|
@ -132,6 +154,7 @@ public partial class App : Application
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
try { File.WriteAllText(logPath, log.ToString()); } catch { }
|
try { File.WriteAllText(logPath, log.ToString()); } catch { }
|
||||||
|
Shutdown(0); // 모든 경로에서 명시적 종료(백그라운드 잔류·고아 프로세스 방지)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
92
src/Everything2Everything.App/Views/QuickOptionsWindow.xaml
Normal file
92
src/Everything2Everything.App/Views/QuickOptionsWindow.xaml
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
<ui:FluentWindow x:Class="Everything2Everything.App.Views.QuickOptionsWindow"
|
||||||
|
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="빠른 변환"
|
||||||
|
Width="380" SizeToContent="Height"
|
||||||
|
ResizeMode="NoResize"
|
||||||
|
ExtendsContentIntoTitleBar="True"
|
||||||
|
WindowBackdropType="None"
|
||||||
|
WindowCornerPreference="Round"
|
||||||
|
WindowStartupLocation="CenterScreen"
|
||||||
|
UseLayoutRounding="True">
|
||||||
|
<Window.Resources>
|
||||||
|
<ResourceDictionary>
|
||||||
|
<ResourceDictionary.MergedDictionaries>
|
||||||
|
<ResourceDictionary Source="FormatShiftTheme.xaml"/>
|
||||||
|
</ResourceDictionary.MergedDictionaries>
|
||||||
|
</ResourceDictionary>
|
||||||
|
</Window.Resources>
|
||||||
|
|
||||||
|
<Grid Background="{StaticResource FsBgBase}">
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="32"/>
|
||||||
|
<RowDefinition Height="*"/>
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
|
||||||
|
<ui:TitleBar Grid.Row="0" Title="" ShowMaximize="False" ShowMinimize="False"/>
|
||||||
|
|
||||||
|
<StackPanel Grid.Row="1" Margin="24,4,24,20">
|
||||||
|
<TextBlock x:Name="HeaderText" FontSize="16" FontWeight="SemiBold"
|
||||||
|
Foreground="{StaticResource FsTextPrimary}"
|
||||||
|
FontFamily="{StaticResource FsFontSans}" Text="변환"/>
|
||||||
|
<TextBlock Margin="0,4,0,0" Style="{StaticResource FsCaptionStyle}"
|
||||||
|
Text="필요한 옵션만 빠르게 고르고 변환하세요."/>
|
||||||
|
|
||||||
|
<!-- 이미지 품질 (jpg/webp/avif) -->
|
||||||
|
<StackPanel x:Name="ImageQualityPanel" Margin="0,18,0,0" Visibility="Collapsed">
|
||||||
|
<Grid>
|
||||||
|
<Grid.ColumnDefinitions><ColumnDefinition Width="*"/><ColumnDefinition Width="Auto"/></Grid.ColumnDefinitions>
|
||||||
|
<TextBlock Text="품질" Style="{StaticResource FsCaptionStyle}"/>
|
||||||
|
<TextBlock Grid.Column="1" FontFamily="{StaticResource FsFontMono}" FontSize="12"
|
||||||
|
Foreground="{StaticResource FsAccentBlue}"
|
||||||
|
Text="{Binding Quality, StringFormat={}{0}%}"/>
|
||||||
|
</Grid>
|
||||||
|
<Slider Minimum="1" Maximum="100" Margin="0,4,0,0" Value="{Binding Quality, Mode=TwoWay}"/>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<!-- 영상 (간단) -->
|
||||||
|
<StackPanel x:Name="VideoQuickPanel" Margin="0,18,0,0" Visibility="Collapsed">
|
||||||
|
<Grid>
|
||||||
|
<Grid.ColumnDefinitions><ColumnDefinition Width="*"/><ColumnDefinition Width="Auto"/></Grid.ColumnDefinitions>
|
||||||
|
<TextBlock Text="품질 (CRF) — 낮을수록 고화질" Style="{StaticResource FsCaptionStyle}"/>
|
||||||
|
<TextBlock Grid.Column="1" FontFamily="{StaticResource FsFontMono}" FontSize="12"
|
||||||
|
Foreground="{StaticResource FsAccentBlue}" Text="{Binding Crf}"/>
|
||||||
|
</Grid>
|
||||||
|
<Slider Minimum="0" Maximum="51" Margin="0,4,0,0" Value="{Binding Crf, Mode=TwoWay}"/>
|
||||||
|
|
||||||
|
<TextBlock Text="해상도" Style="{StaticResource FsCaptionStyle}" Margin="0,12,0,4"/>
|
||||||
|
<ComboBox SelectedIndex="{Binding ResolutionIndex, Mode=TwoWay}">
|
||||||
|
<ComboBoxItem Content="원본"/><ComboBoxItem Content="2160p (4K)"/><ComboBoxItem Content="1440p"/>
|
||||||
|
<ComboBoxItem Content="1080p"/><ComboBoxItem Content="720p"/><ComboBoxItem Content="480p"/>
|
||||||
|
</ComboBox>
|
||||||
|
|
||||||
|
<CheckBox Margin="0,12,0,0" Content="GPU 하드웨어 가속" IsChecked="{Binding VideoPreferGpu, Mode=TwoWay}"/>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<!-- 오디오 (간단) -->
|
||||||
|
<StackPanel x:Name="AudioQuickPanel" Margin="0,18,0,0" Visibility="Collapsed">
|
||||||
|
<TextBlock x:Name="AudioPanelLabel" Text="오디오 비트레이트" Style="{StaticResource FsCaptionStyle}" Margin="0,0,0,4"/>
|
||||||
|
<ComboBox SelectedIndex="{Binding AudioBitrateIndex, Mode=TwoWay}">
|
||||||
|
<ComboBoxItem Content="96 kbps"/><ComboBoxItem Content="128 kbps"/><ComboBoxItem Content="192 kbps"/>
|
||||||
|
<ComboBoxItem Content="256 kbps"/><ComboBoxItem Content="320 kbps"/>
|
||||||
|
</ComboBox>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<!-- 버튼 -->
|
||||||
|
<Grid Margin="0,22,0,0">
|
||||||
|
<Button HorizontalAlignment="Left" Content="자세히 옵션…"
|
||||||
|
Style="{StaticResource FsSecondaryButtonStyle}"
|
||||||
|
Click="OnMoreClick"/>
|
||||||
|
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right">
|
||||||
|
<Button Content="취소" Margin="0,0,8,0"
|
||||||
|
Style="{StaticResource FsSecondaryButtonStyle}"
|
||||||
|
Click="OnCancelClick"/>
|
||||||
|
<Button Content="변환" Padding="20,6"
|
||||||
|
Style="{StaticResource FsPrimaryButtonStyle}"
|
||||||
|
Click="OnConvertClick"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
</ui:FluentWindow>
|
||||||
|
|
@ -0,0 +1,64 @@
|
||||||
|
using System.Windows;
|
||||||
|
using Everything2Everything.App.ViewModels;
|
||||||
|
using Everything2Everything.Core;
|
||||||
|
using Wpf.Ui.Controls;
|
||||||
|
|
||||||
|
namespace Everything2Everything.App.Views;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 빠른 변환(컨텍스트 메뉴에서 형식 선택) 시 뜨는 간단 옵션 팝업.
|
||||||
|
/// 출력 형식에 맞춘 핵심 옵션만 노출하고, '자세히 옵션…'은 풀 UI(MainWindow)로 넘어간다.
|
||||||
|
/// </summary>
|
||||||
|
public partial class QuickOptionsWindow : FluentWindow
|
||||||
|
{
|
||||||
|
/// <summary>팝업이 바인딩하는 옵션 뷰모델. 변환 확정 시 ToConvertOptions()로 사용.</summary>
|
||||||
|
public OptionsViewModel Options { get; } = new();
|
||||||
|
|
||||||
|
/// <summary>사용자가 '자세히 옵션…'을 눌러 풀 UI로 넘어가길 원하는가.</summary>
|
||||||
|
public bool OpenFullUi { get; private set; }
|
||||||
|
|
||||||
|
public QuickOptionsWindow(string outputExtension, int fileCount, ISettingsStore settings)
|
||||||
|
{
|
||||||
|
Options.VideoPreferGpu = settings.Get("video.gpu") != "false";
|
||||||
|
DataContext = Options;
|
||||||
|
InitializeComponent();
|
||||||
|
ConfigureForFormat(outputExtension, fileCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ConfigureForFormat(string ext, int count)
|
||||||
|
{
|
||||||
|
ext = ext.ToLowerInvariant();
|
||||||
|
var label = ext.TrimStart('.').ToUpperInvariant();
|
||||||
|
HeaderText.Text = count > 1 ? $"{label}(으)로 변환 · {count}개 파일" : $"{label}(으)로 변환";
|
||||||
|
|
||||||
|
var isVideo = ext is ".mp4" or ".mkv" or ".webm" or ".mov" or ".avi";
|
||||||
|
var isLossyAudio = ext is ".mp3" or ".aac" or ".m4a" or ".opus" or ".ogg";
|
||||||
|
var isImageQuality = ext is ".jpg" or ".jpeg" or ".webp" or ".avif";
|
||||||
|
|
||||||
|
VideoQuickPanel.Visibility = isVideo ? Visibility.Visible : Visibility.Collapsed;
|
||||||
|
AudioQuickPanel.Visibility = (isVideo || isLossyAudio) ? Visibility.Visible : Visibility.Collapsed;
|
||||||
|
ImageQualityPanel.Visibility = isImageQuality ? Visibility.Visible : Visibility.Collapsed;
|
||||||
|
|
||||||
|
// 영상의 오디오 트랙임을 구분
|
||||||
|
if (isVideo) AudioPanelLabel.Text = "오디오 비트레이트";
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnConvertClick(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
DialogResult = true;
|
||||||
|
Close();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnCancelClick(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
DialogResult = false;
|
||||||
|
Close();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnMoreClick(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
OpenFullUi = true;
|
||||||
|
DialogResult = false;
|
||||||
|
Close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -8,7 +8,8 @@
|
||||||
WindowBackdropType="Mica"
|
WindowBackdropType="Mica"
|
||||||
WindowCornerPreference="Round"
|
WindowCornerPreference="Round"
|
||||||
WindowStartupLocation="CenterScreen"
|
WindowStartupLocation="CenterScreen"
|
||||||
ResizeMode="NoResize">
|
ResizeMode="NoResize"
|
||||||
|
Closing="OnWindowClosing">
|
||||||
<Grid>
|
<Grid>
|
||||||
<Grid.RowDefinitions>
|
<Grid.RowDefinitions>
|
||||||
<RowDefinition Height="Auto"/>
|
<RowDefinition Height="Auto"/>
|
||||||
|
|
@ -41,13 +42,24 @@
|
||||||
HorizontalAlignment="Center" VerticalAlignment="Center"
|
HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||||
RenderOptions.BitmapScalingMode="HighQuality"
|
RenderOptions.BitmapScalingMode="HighQuality"
|
||||||
Source="pack://application:,,,/Assets/illus-done.png"/>
|
Source="pack://application:,,,/Assets/illus-done.png"/>
|
||||||
<StackPanel Grid.Row="4" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,12,0,0">
|
<Grid Grid.Row="4" Margin="0,12,0,0">
|
||||||
<ui:Button x:Name="OpenFolderButton" Content="결과 폴더 열기"
|
<Grid.ColumnDefinitions>
|
||||||
Icon="{ui:SymbolIcon Folder24}"
|
<ColumnDefinition Width="Auto"/>
|
||||||
Click="OnOpenFolderClick" IsEnabled="False" Margin="0,0,8,0"/>
|
<ColumnDefinition Width="*"/>
|
||||||
<ui:Button x:Name="CloseButton" Content="닫기" Click="OnCloseClick"
|
<ColumnDefinition Width="Auto"/>
|
||||||
Appearance="Primary" IsEnabled="False"/>
|
</Grid.ColumnDefinitions>
|
||||||
</StackPanel>
|
<!-- 변환 중 취소 (완료되면 숨김) -->
|
||||||
|
<ui:Button x:Name="CancelButton" Grid.Column="0" Content="취소"
|
||||||
|
Icon="{ui:SymbolIcon Dismiss24}"
|
||||||
|
Click="OnCancelClick" HorizontalAlignment="Left"/>
|
||||||
|
<StackPanel Grid.Column="2" Orientation="Horizontal" HorizontalAlignment="Right">
|
||||||
|
<ui:Button x:Name="OpenFolderButton" Content="결과 폴더 열기"
|
||||||
|
Icon="{ui:SymbolIcon Folder24}"
|
||||||
|
Click="OnOpenFolderClick" IsEnabled="False" Margin="0,0,8,0"/>
|
||||||
|
<ui:Button x:Name="CloseButton" Content="닫기" Click="OnCloseClick"
|
||||||
|
Appearance="Primary" IsEnabled="False"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
</ui:FluentWindow>
|
</ui:FluentWindow>
|
||||||
|
|
|
||||||
|
|
@ -7,11 +7,14 @@ namespace Everything2Everything.App.Views;
|
||||||
public partial class QuickProgressWindow : FluentWindow
|
public partial class QuickProgressWindow : FluentWindow
|
||||||
{
|
{
|
||||||
private readonly int _total;
|
private readonly int _total;
|
||||||
|
private readonly CancellationTokenSource _cts;
|
||||||
|
private bool _finished;
|
||||||
private string? _firstSuccessOutput;
|
private string? _firstSuccessOutput;
|
||||||
|
|
||||||
public QuickProgressWindow(int total, string? outputExtension = null)
|
public QuickProgressWindow(int total, CancellationTokenSource cts, string? outputExtension = null)
|
||||||
{
|
{
|
||||||
_total = total;
|
_total = total;
|
||||||
|
_cts = cts;
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
|
|
||||||
var label = string.IsNullOrWhiteSpace(outputExtension)
|
var label = string.IsNullOrWhiteSpace(outputExtension)
|
||||||
|
|
@ -39,6 +42,9 @@ public partial class QuickProgressWindow : FluentWindow
|
||||||
{
|
{
|
||||||
if (!CheckAccess()) { Dispatcher.Invoke(() => Finish(results)); return; }
|
if (!CheckAccess()) { Dispatcher.Invoke(() => Finish(results)); return; }
|
||||||
|
|
||||||
|
_finished = true;
|
||||||
|
CancelButton.Visibility = Visibility.Collapsed;
|
||||||
|
|
||||||
var success = results.Count(r => r.Status == ConvertStatus.Success);
|
var success = results.Count(r => r.Status == ConvertStatus.Success);
|
||||||
var skipped = results.Count(r => r.Status == ConvertStatus.Skipped);
|
var skipped = results.Count(r => r.Status == ConvertStatus.Skipped);
|
||||||
var failed = results.Count(r => r.Status == ConvertStatus.Failed);
|
var failed = results.Count(r => r.Status == ConvertStatus.Failed);
|
||||||
|
|
@ -92,6 +98,23 @@ public partial class QuickProgressWindow : FluentWindow
|
||||||
|
|
||||||
private void OnCloseClick(object sender, RoutedEventArgs e) => Close();
|
private void OnCloseClick(object sender, RoutedEventArgs e) => Close();
|
||||||
|
|
||||||
|
private void OnCancelClick(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
try { _cts.Cancel(); } catch { }
|
||||||
|
CancelButton.IsEnabled = false;
|
||||||
|
CancelButton.Content = "취소 중…";
|
||||||
|
StatusText.Text = "취소 중…";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>변환 중에 창을 닫으면 변환을 취소한다(백그라운드 잔류·고아 ffmpeg 방지).</summary>
|
||||||
|
private void OnWindowClosing(object? sender, System.ComponentModel.CancelEventArgs e)
|
||||||
|
{
|
||||||
|
if (!_finished)
|
||||||
|
{
|
||||||
|
try { _cts.Cancel(); } catch { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static void OpenInExplorer(string path)
|
private static void OpenInExplorer(string path)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue