Files
AmiReel/MainPage.xaml.cs
T
klevze e635b708a2 Improve crop editor and copy publish output to dist
Add resize handles, zoom, view brightness, and a multi-frame still
strip. Keep the crop box on mouse-up, and copy AmiReel.exe to dist.
2026-08-15 22:59:20 +02:00

936 lines
37 KiB
C#

using System.Collections.ObjectModel;
using System.Diagnostics;
using System.Globalization;
using System.Runtime.InteropServices.WindowsRuntime;
using System.Text;
using AmiReel.Models;
using AmiReel.Services;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Media.Imaging;
using Windows.ApplicationModel.DataTransfer;
using Windows.Storage;
using Windows.Storage.Pickers;
using WinRT.Interop;
namespace AmiReel;
public sealed partial class MainPage : Page
{
private readonly ObservableCollection<string> _inputs = [];
private readonly AppSettings _loadedSettings;
private readonly StringBuilder _pendingLog = new();
private readonly object _logLock = new();
private CancellationTokenSource? _renderCancellation;
private string? _lastRenderedFile;
private bool _logFlushScheduled;
private bool _isShortsTab;
private bool _isRendering;
private bool _keepRenderOutput;
private List<ShortWizardStep> _wizardSteps = [.. ShortWizard.StepsFor(ShortStyle.Brand)];
private int _wizardIndex;
private int _previewGeneration;
private string? _previewDirectory;
public MainPage()
{
InitializeComponent();
InputList.ItemsSource = _inputs;
_loadedSettings = AppSettingsStore.Load();
OutputFolderBox.Text = _loadedSettings.OutputFolder;
EndCardBox.Text = _loadedSettings.EndCardPath;
FfmpegPathBox.Text = _loadedSettings.FfmpegPath;
FfprobePathBox.Text = _loadedSettings.FfprobePath;
PreviewPlayerPathBox.Text = _loadedSettings.PreviewPlayerPath;
OutputNameBox.Text = "amigadb_intro";
TrimBox.Text = _loadedSettings.TrimStart;
FadeBox.Text = _loadedSettings.FadeSeconds;
HoldBox.Text = _loadedSettings.EndCardHoldSeconds;
IntervalBox.Text = _loadedSettings.ThumbnailInterval;
SelectEncoder(_loadedSettings.Encoder);
SelectTheme(_loadedSettings.Theme);
ApplyTheme(_loadedSettings.Theme);
MoveSourcesBox.IsChecked = _loadedSettings.ShouldMoveSourcesToOriginals;
ShortHookBox.Text = _loadedSettings.ShortsHook;
ShortWebsiteBox.Text = _loadedSettings.ShortsWebsite;
ShortCrfBox.Text = _loadedSettings.ShortsCrf;
ShortBackgroundBox.Text = _loadedSettings.ShortsBackgroundImage;
ShortBackgroundVideoBox.Text = _loadedSettings.ShortsBackgroundVideo;
ShortVideoBlurBox.Text = _loadedSettings.ShortsVideoBlur;
ShortVideoOpacityBox.Text = _loadedSettings.ShortsVideoOpacity;
ShortSourceMarginBox.Text = _loadedSettings.ShortsSourceMarginX;
ShortFadeInBox.Text = _loadedSettings.ShortsSourceFadeIn;
ShortCropLeftBox.Text = _loadedSettings.ShortsCropLeft;
ShortCropRightBox.Text = _loadedSettings.ShortsCropRight;
ShortCropTopBox.Text = _loadedSettings.ShortsCropTop;
ShortCropBottomBox.Text = _loadedSettings.ShortsCropBottom;
ShortFontBox.Text = string.IsNullOrWhiteSpace(_loadedSettings.ShortsFontFile)
? ShortsPipeline.FindDefaultFont() ?? ""
: _loadedSettings.ShortsFontFile;
SelectComboTag(ShortStyleBox, _loadedSettings.ShortsStyle);
SelectComboTag(ShortPresetBox, _loadedSettings.ShortsPreset);
SelectComboTag(ShortCropModeBox, _loadedSettings.ShortsSourceCropMode);
RefreshWizardSteps();
ShowWizardStep();
}
private void VideoTab_Click(object sender, RoutedEventArgs e) => SetActiveTab(shorts: false);
private void ShortsTab_Click(object sender, RoutedEventArgs e) => SetActiveTab(shorts: true);
private void SetActiveTab(bool shorts)
{
if (!_isRendering)
_keepRenderOutput = false;
_isShortsTab = shorts;
Style primaryButtonStyle = (Style)Application.Current.Resources["PrimaryButtonStyle"];
Style tabButtonStyle = (Style)Application.Current.Resources["TabButtonStyle"];
VideoTabButton.Style = shorts ? tabButtonStyle : primaryButtonStyle;
ShortsTabButton.Style = shorts ? primaryButtonStyle : tabButtonStyle;
RenderButton.Content = shorts ? "Render short" : "Start render";
UpdateWorkspaceVisibility();
}
private void ShortStyleBox_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
if (!IsLoaded) return;
RefreshWizardSteps();
ShowWizardStep();
}
private void ShortBack_Click(object sender, RoutedEventArgs e)
{
if (_wizardIndex <= 0) return;
_keepRenderOutput = false;
_wizardIndex--;
ShowWizardStep();
}
private async void ShortNext_Click(object sender, RoutedEventArgs e)
{
try
{
ValidateCurrentWizardStep();
if (_wizardIndex >= _wizardSteps.Count - 1) return;
_keepRenderOutput = false;
_wizardIndex++;
ShowWizardStep();
}
catch (Exception exception)
{
await ShowMessageAsync("Check this step", exception.Message);
}
}
private void RefreshWizardSteps()
{
ShortWizardStep current = _wizardSteps[Math.Clamp(_wizardIndex, 0, _wizardSteps.Count - 1)];
_wizardSteps = [.. ShortWizard.StepsFor(SelectedShortStyleEnum())];
int index = _wizardSteps.IndexOf(current);
_wizardIndex = index >= 0 ? index : Math.Min(_wizardIndex, _wizardSteps.Count - 1);
}
private void ShowWizardStep()
{
ShortWizardStep step = _wizardSteps[Math.Clamp(_wizardIndex, 0, _wizardSteps.Count - 1)];
ShortWizardTitleText.Text = ShortWizard.Title(step);
ShortWizardStepText.Text = $"Step {_wizardIndex + 1} of {_wizardSteps.Count}";
ShortWizardProgress.Maximum = _wizardSteps.Count;
ShortWizardProgress.Value = _wizardIndex + 1;
ShortStepClipPanel.Visibility = VisibleIf(step == ShortWizardStep.Clip);
ShortStepEffectPanel.Visibility = VisibleIf(step == ShortWizardStep.Effect);
ShortStepStyleOptionsPanel.Visibility = VisibleIf(step == ShortWizardStep.StyleOptions);
ShortStepTitlesPanel.Visibility = VisibleIf(step == ShortWizardStep.Titles);
ShortStepEncodePanel.Visibility = VisibleIf(step == ShortWizardStep.Encode);
ShortStyle style = SelectedShortStyleEnum();
ShortStyleDescriptionText.Text = ShortWizard.Description(style);
ShortBackgroundRow.Visibility = VisibleIf(style == ShortStyle.Brand);
ShortVideoOptionsPanel.Visibility = VisibleIf(style == ShortStyle.Video);
if (step == ShortWizardStep.Encode)
ShortSummaryText.Text = BuildShortSummary();
UpdateWizardNavigation();
if (IsLoaded && step != ShortWizardStep.Clip)
_ = RefreshShortPreviewAsync(automatic: true);
}
private void UpdateWizardNavigation()
{
bool shorts = _isShortsTab && !_isRendering;
bool lastStep = _wizardIndex >= _wizardSteps.Count - 1;
ShortBackButton.Visibility = VisibleIf(shorts);
ShortNextButton.Visibility = VisibleIf(shorts && !lastStep);
ShortBackButton.IsEnabled = shorts && _wizardIndex > 0;
ShortNextButton.IsEnabled = shorts;
RenderButton.Visibility = VisibleIf(!_isRendering && (!shorts || lastStep));
}
private void ValidateCurrentWizardStep()
{
ShortWizardStep step = _wizardSteps[_wizardIndex];
switch (step)
{
case ShortWizardStep.Clip:
if (string.IsNullOrWhiteSpace(ShortInputBox.Text) || !File.Exists(ShortInputBox.Text.Trim()))
throw new FileNotFoundException("Select a valid source video for the Short.");
ParsePositiveDuration();
break;
case ShortWizardStep.Effect:
ParseNonNegativeNumber(ShortFadeInBox.Text, "source fade-in");
break;
case ShortWizardStep.StyleOptions:
if (SelectedShortStyleEnum() == ShortStyle.Brand
&& (string.IsNullOrWhiteSpace(ShortBackgroundBox.Text) || !File.Exists(ShortBackgroundBox.Text.Trim())))
throw new FileNotFoundException("The brand style requires a background image.");
if (SelectedShortStyleEnum() == ShortStyle.Video)
ReadVideoStyleOptions();
break;
case ShortWizardStep.Titles:
if (string.IsNullOrWhiteSpace(ShortTitleBox.Text))
throw new ArgumentException("Enter a title.");
break;
case ShortWizardStep.Encode:
if (string.IsNullOrWhiteSpace(ShortFontBox.Text) || !File.Exists(ShortFontBox.Text.Trim()))
throw new FileNotFoundException("Select a bold TrueType/OpenType font file.");
ParseCrf();
break;
}
}
private string BuildShortSummary()
{
string input = string.IsNullOrWhiteSpace(ShortInputBox.Text)
? "(no source yet)"
: Path.GetFileName(ShortInputBox.Text.Trim());
string title = string.IsNullOrWhiteSpace(ShortTitleBox.Text) ? "(no title yet)" : ShortTitleBox.Text.Trim();
return $"{SelectedShortStyleEnum()} · {input} · {ShortStartBox.Text.Trim()} + {ShortDurationBox.Text.Trim()}s · {title}";
}
private static Visibility VisibleIf(bool visible) => visible ? Visibility.Visible : Visibility.Collapsed;
private async void AddInputs_Click(object sender, RoutedEventArgs e)
{
FileOpenPicker picker = CreateFileOpenPicker();
foreach (string extension in SupportedVideoFormats.Extensions)
picker.FileTypeFilter.Add(extension);
picker.SuggestedStartLocation = PickerLocationId.VideosLibrary;
var files = await picker.PickMultipleFilesAsync();
if (files is null) return;
foreach (string path in files.Select(file => file.Path).OrderBy(NaturalKey))
if (!_inputs.Contains(path, StringComparer.OrdinalIgnoreCase)) _inputs.Add(path);
}
private void ClearInputs_Click(object sender, RoutedEventArgs e) => _inputs.Clear();
private void InputList_DragOver(object sender, DragEventArgs e)
{
e.AcceptedOperation = e.DataView.Contains(StandardDataFormats.StorageItems)
? DataPackageOperation.Copy
: DataPackageOperation.None;
}
private async void InputList_Drop(object sender, DragEventArgs e)
{
if (!e.DataView.Contains(StandardDataFormats.StorageItems)) return;
DragOperationDeferral deferral = e.GetDeferral();
try
{
IReadOnlyList<IStorageItem> items = await e.DataView.GetStorageItemsAsync();
foreach (string path in items.OfType<StorageFile>()
.Select(file => file.Path)
.Where(SupportedVideoFormats.IsSupported)
.OrderBy(NaturalKey))
if (!_inputs.Contains(path, StringComparer.OrdinalIgnoreCase)) _inputs.Add(path);
}
finally
{
deferral.Complete();
}
}
private async void BrowseEndCard_Click(object sender, RoutedEventArgs e)
{
FileOpenPicker picker = CreateFileOpenPicker();
picker.FileTypeFilter.Add(".png");
picker.FileTypeFilter.Add(".jpg");
picker.FileTypeFilter.Add(".jpeg");
picker.FileTypeFilter.Add(".webp");
picker.FileTypeFilter.Add(".bmp");
var file = await picker.PickSingleFileAsync();
if (file is not null)
EndCardBox.Text = file.Path;
}
private async void BrowseOutput_Click(object sender, RoutedEventArgs e)
{
FolderPicker picker = CreateFolderPicker();
var folder = await picker.PickSingleFolderAsync();
if (folder is not null)
OutputFolderBox.Text = folder.Path;
}
private async void BrowseFfmpeg_Click(object sender, RoutedEventArgs e)
{
FileOpenPicker picker = CreateFileOpenPicker();
picker.FileTypeFilter.Add(".exe");
var file = await picker.PickSingleFileAsync();
if (file is not null)
FfmpegPathBox.Text = file.Path;
}
private async void BrowseFfprobe_Click(object sender, RoutedEventArgs e)
{
FileOpenPicker picker = CreateFileOpenPicker();
picker.FileTypeFilter.Add(".exe");
var file = await picker.PickSingleFileAsync();
if (file is not null)
FfprobePathBox.Text = file.Path;
}
private async void BrowsePreviewPlayer_Click(object sender, RoutedEventArgs e)
{
FileOpenPicker picker = CreateFileOpenPicker();
picker.FileTypeFilter.Add(".exe");
var file = await picker.PickSingleFileAsync();
if (file is not null)
PreviewPlayerPathBox.Text = file.Path;
}
private async void OpenSettings_Click(object sender, RoutedEventArgs e)
{
SettingsDialog.XamlRoot = XamlRoot;
await SettingsDialog.ShowAsync();
SaveSettings();
}
private async void Render_Click(object sender, RoutedEventArgs e)
{
try
{
_keepRenderOutput = false;
SetRendering(true);
LogBox.Text = string.Empty;
_renderCancellation = new CancellationTokenSource();
if (_isShortsTab)
await RenderShortAsync(_renderCancellation.Token);
else
await RenderVideoAsync(_renderCancellation.Token);
}
catch (OperationCanceledException)
{
_keepRenderOutput = true;
UpdateProgress(new(RenderProgressBar.Value, "Cancelled", "The render was cancelled."));
}
catch (Exception exception)
{
_keepRenderOutput = true;
AppendLog("ERROR: " + exception);
StageText.Text = "Failed";
await ShowMessageAsync("Render failed", UserFacingErrors.Summarize(exception));
}
finally
{
_renderCancellation?.Dispose();
_renderCancellation = null;
SetRendering(false);
}
}
private async Task RenderVideoAsync(CancellationToken token)
{
RenderSettings settings = ReadSettings();
SaveSettings();
Progress<RenderProgress> progress = new(UpdateProgress);
IReadOnlyList<string> resolvedInputs = await new RenderPipeline().RenderAsync(settings, progress, AppendLog, token);
ReplaceInputs(resolvedInputs);
_lastRenderedFile = Path.Combine(settings.OutputDirectory, settings.OutputName + "_final.mp4");
OpenOutputButton.IsEnabled = true;
PreviewRenderedButton.IsEnabled = File.Exists(_lastRenderedFile);
await ShowMessageAsync("Render complete", "The AmiReel render completed successfully.");
}
private async Task RenderShortAsync(CancellationToken token)
{
ShortSettings settings = ReadShortSettings();
SaveSettings();
Progress<RenderProgress> progress = new(UpdateProgress);
await new ShortsPipeline().RenderAsync(settings, progress, AppendLog, token);
_lastRenderedFile = settings.OutputPath;
ShortOutputBox.Text = settings.OutputPath;
OpenOutputButton.IsEnabled = true;
PreviewRenderedButton.IsEnabled = File.Exists(_lastRenderedFile);
await ShowMessageAsync("Short complete", "The YouTube Short was rendered successfully.");
}
private void Cancel_Click(object sender, RoutedEventArgs e)
{
CancelButton.IsEnabled = false;
StatusText.Text = "Stopping FFmpeg...";
_renderCancellation?.Cancel();
}
private void OpenOutput_Click(object sender, RoutedEventArgs e)
{
string? directory = !string.IsNullOrWhiteSpace(_lastRenderedFile)
? Path.GetDirectoryName(_lastRenderedFile)
: OutputFolderBox.Text;
if (!string.IsNullOrWhiteSpace(directory) && Directory.Exists(directory))
Process.Start(new ProcessStartInfo("explorer.exe", directory) { UseShellExecute = true });
}
private async void PreviewSource_Click(object sender, RoutedEventArgs e)
{
if (InputList.SelectedItem is string path)
await OpenPreviewAsync(path);
}
private async void PreviewRendered_Click(object sender, RoutedEventArgs e)
{
if (!string.IsNullOrWhiteSpace(_lastRenderedFile) && File.Exists(_lastRenderedFile))
await OpenPreviewAsync(_lastRenderedFile);
}
private void ThemeBox_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
if (!IsLoaded) return;
string theme = SelectedTheme();
ApplyTheme(theme);
SaveSettings();
}
private RenderSettings ReadSettings()
{
static double Number(string text, string name)
{
if (!double.TryParse(text.Replace(',', '.'), NumberStyles.Float, CultureInfo.InvariantCulture, out double value) || value < 0)
throw new ArgumentException($"Enter a valid non-negative value for {name}.");
return value;
}
if (!int.TryParse(IntervalBox.Text, out int interval) || interval < 1)
throw new ArgumentException("Thumbnail interval must be at least one second.");
EncoderMode encoder = Enum.Parse<EncoderMode>(SelectedEncoder());
return new RenderSettings
{
InputFiles = _inputs.ToList(),
EndCardPath = EndCardBox.Text.Trim(),
OutputDirectory = OutputFolderBox.Text.Trim(),
OutputName = OutputNameBox.Text.Trim(),
FfmpegPath = FfmpegPathBox.Text.Trim(),
FfprobePath = FfprobePathBox.Text.Trim(),
Encoder = encoder,
TrimStart = Number(TrimBox.Text, "trim start"),
FadeSeconds = Number(FadeBox.Text, "fade"),
EndCardHoldSeconds = Number(HoldBox.Text, "end-card hold"),
ThumbnailInterval = interval,
MoveSourcesToOriginals = MoveSourcesBox.IsChecked == true,
};
}
private void ReplaceInputs(IReadOnlyList<string> paths)
{
_inputs.Clear();
foreach (string path in paths)
_inputs.Add(path);
}
private ShortSettings ReadShortSettings()
{
double duration = ParsePositiveDuration();
int crf = ParseCrf();
(double blur, double opacity, int marginX, ShortSourceCropMode cropMode, int left, int right, int top, int bottom)
= ReadVideoStyleOptions();
double fadeIn = ParseNonNegativeNumber(ShortFadeInBox.Text, "source fade-in");
string input = ShortInputBox.Text.Trim();
string output = ShortOutputBox.Text.Trim();
ShortStyle style = SelectedShortStyleEnum();
if (string.IsNullOrWhiteSpace(output) && !string.IsNullOrWhiteSpace(input))
{
string baseName = Path.GetFileNameWithoutExtension(input);
string? directory = Path.GetDirectoryName(input);
output = Path.Combine(directory ?? "", $"{baseName}-{style.ToString().ToLowerInvariant()}-short.mp4");
}
return new ShortSettings
{
InputFile = input,
OutputPath = output,
Title = ShortTitleBox.Text.Trim(),
Group = ShortGroupBox.Text.Trim(),
Year = ShortYearBox.Text.Trim(),
Type = ShortTypeBox.Text.Trim(),
Hook = ShortHookBox.Text.Trim(),
Website = ShortWebsiteBox.Text.Trim(),
Start = string.IsNullOrWhiteSpace(ShortStartBox.Text) ? "00:00:00" : ShortStartBox.Text.Trim(),
DurationSeconds = duration,
Style = style,
BackgroundImage = ShortBackgroundBox.Text.Trim(),
BackgroundVideo = ShortBackgroundVideoBox.Text.Trim(),
VideoBlur = blur,
VideoOpacity = opacity,
SourceCropMode = cropMode,
CropLeft = left,
CropRight = right,
CropTop = top,
CropBottom = bottom,
SourceMarginX = marginX,
SourceFadeInSeconds = fadeIn,
FontFile = ShortFontBox.Text.Trim(),
FfmpegPath = FfmpegPathBox.Text.Trim(),
FfprobePath = FfprobePathBox.Text.Trim(),
Crf = crf,
Preset = SelectedShortPreset(),
};
}
private double ParsePositiveDuration()
{
if (!double.TryParse(ShortDurationBox.Text.Replace(',', '.'), NumberStyles.Float, CultureInfo.InvariantCulture, out double duration) || duration <= 0)
throw new ArgumentException("Enter a valid duration in seconds.");
return duration;
}
private int ParseCrf()
{
if (!int.TryParse(ShortCrfBox.Text, NumberStyles.Integer, CultureInfo.InvariantCulture, out int crf))
throw new ArgumentException("CRF must be an integer.");
return crf;
}
private static double ParseNonNegativeNumber(string text, string name)
{
if (!double.TryParse(text.Replace(',', '.'), NumberStyles.Float, CultureInfo.InvariantCulture, out double value) || value < 0)
throw new ArgumentException($"Enter a valid non-negative value for {name}.");
return value;
}
private static int ParseNonNegativeInt(string text, string name)
{
if (!int.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out int value) || value < 0)
throw new ArgumentException($"{name} must be a non-negative integer.");
return value;
}
private (double Blur, double Opacity, int MarginX, ShortSourceCropMode CropMode, int Left, int Right, int Top, int Bottom)
ReadVideoStyleOptions()
{
double blur = ParseNonNegativeNumber(ShortVideoBlurBox.Text, "background blur");
double opacity = ParseNonNegativeNumber(ShortVideoOpacityBox.Text, "background opacity");
if (opacity > 1)
throw new ArgumentException("Background opacity must be between 0.0 and 1.0.");
int marginX = ParseNonNegativeInt(ShortSourceMarginBox.Text, "Side margin");
if (marginX >= 540)
throw new ArgumentException("Source side margin must be less than 540 pixels.");
if (SelectedShortStyleEnum() == ShortStyle.Video
&& (string.IsNullOrWhiteSpace(ShortBackgroundVideoBox.Text) || !File.Exists(ShortBackgroundVideoBox.Text.Trim())))
throw new FileNotFoundException("The video style requires a background video.");
return (
blur,
opacity,
marginX,
SelectedShortCropMode(),
ParseNonNegativeInt(ShortCropLeftBox.Text, "Crop left"),
ParseNonNegativeInt(ShortCropRightBox.Text, "Crop right"),
ParseNonNegativeInt(ShortCropTopBox.Text, "Crop top"),
ParseNonNegativeInt(ShortCropBottomBox.Text, "Crop bottom"));
}
private async void BrowseShortInput_Click(object sender, RoutedEventArgs e)
{
FileOpenPicker picker = CreateFileOpenPicker();
foreach (string extension in SupportedVideoFormats.Extensions)
picker.FileTypeFilter.Add(extension);
picker.SuggestedStartLocation = PickerLocationId.VideosLibrary;
var file = await picker.PickSingleFileAsync();
if (file is not null)
ShortInputBox.Text = file.Path;
}
private async void BrowseShortOutput_Click(object sender, RoutedEventArgs e)
{
FileSavePicker picker = new();
picker.FileTypeChoices.Add("MP4 video", [".mp4"]);
picker.SuggestedFileName = "short.mp4";
InitializeWithWindow.Initialize(picker, App.MainWindowHandle);
var file = await picker.PickSaveFileAsync();
if (file is not null)
ShortOutputBox.Text = file.Path;
}
private async void BrowseShortBackground_Click(object sender, RoutedEventArgs e)
{
FileOpenPicker picker = CreateFileOpenPicker();
picker.FileTypeFilter.Add(".png");
picker.FileTypeFilter.Add(".jpg");
picker.FileTypeFilter.Add(".jpeg");
var file = await picker.PickSingleFileAsync();
if (file is not null)
ShortBackgroundBox.Text = file.Path;
}
private async void BrowseShortBackgroundVideo_Click(object sender, RoutedEventArgs e)
{
FileOpenPicker picker = CreateFileOpenPicker();
foreach (string extension in SupportedVideoFormats.Extensions)
picker.FileTypeFilter.Add(extension);
picker.SuggestedStartLocation = PickerLocationId.VideosLibrary;
var file = await picker.PickSingleFileAsync();
if (file is not null)
ShortBackgroundVideoBox.Text = file.Path;
}
private async void BrowseShortFont_Click(object sender, RoutedEventArgs e)
{
FileOpenPicker picker = CreateFileOpenPicker();
picker.FileTypeFilter.Add(".ttf");
picker.FileTypeFilter.Add(".otf");
var file = await picker.PickSingleFileAsync();
if (file is not null)
ShortFontBox.Text = file.Path;
}
private async void OpenCropEditor_Click(object sender, RoutedEventArgs e)
{
string input = ShortInputBox.Text.Trim();
if (string.IsNullOrWhiteSpace(input) || !File.Exists(input))
{
await ShowMessageAsync("Crop source", "Select a valid source video first.");
return;
}
try
{
CropEditorDialog.XamlRoot = XamlRoot;
CropEditorDialog.RequestedTheme = ActualTheme;
double duration = 30;
try { duration = ParsePositiveDuration(); } catch (ArgumentException) { }
await ShortCropEditor.LoadVideoFrameAsync(
input,
string.IsNullOrWhiteSpace(ShortStartBox.Text) ? "00:00:00" : ShortStartBox.Text.Trim(),
duration,
FfmpegPathBox.Text.Trim(),
FfprobePathBox.Text.Trim(),
CancellationToken.None);
ShortCropEditor.SetInsets(ReadCropInsets());
ContentDialogResult result = await CropEditorDialog.ShowAsync();
if (result != ContentDialogResult.Primary)
return;
PixelInsets insets = ShortCropEditor.Insets;
ShortCropLeftBox.Text = insets.Left.ToString(CultureInfo.InvariantCulture);
ShortCropRightBox.Text = insets.Right.ToString(CultureInfo.InvariantCulture);
ShortCropTopBox.Text = insets.Top.ToString(CultureInfo.InvariantCulture);
ShortCropBottomBox.Text = insets.Bottom.ToString(CultureInfo.InvariantCulture);
}
catch (Exception exception)
{
await ShowMessageAsync("Crop source", UserFacingErrors.Summarize(exception));
}
}
private PixelInsets ReadCropInsets()
{
static int ReadBox(string text)
{
return int.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out int value) && value > 0
? value
: 0;
}
return new PixelInsets(
ReadBox(ShortCropLeftBox.Text),
ReadBox(ShortCropRightBox.Text),
ReadBox(ShortCropTopBox.Text),
ReadBox(ShortCropBottomBox.Text));
}
private async void PreviewShortSource_Click(object sender, RoutedEventArgs e)
{
if (!string.IsNullOrWhiteSpace(ShortInputBox.Text))
await OpenPreviewAsync(ShortInputBox.Text.Trim());
}
private async void UpdateShortPreview_Click(object sender, RoutedEventArgs e) =>
await RefreshShortPreviewAsync(automatic: false);
private async Task RefreshShortPreviewAsync(bool automatic)
{
if (_isRendering)
return;
int generation = ++_previewGeneration;
ShortPreviewProgress.Visibility = Visibility.Visible;
UpdateShortPreviewButton.IsEnabled = false;
ShortPreviewStatusText.Text = "Rendering 9:16 preview frames...";
try
{
ShortSettings settings = ReadShortSettings();
ShortsPipeline.ValidatePreview(settings);
_previewDirectory ??= Path.Combine(Path.GetTempPath(), "AmiReel", "short-preview", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(_previewDirectory);
ShortPreviewFrames frames = await new ShortsPipeline().RenderPreviewAsync(
settings, _previewDirectory, _ => { }, CancellationToken.None);
if (generation != _previewGeneration)
return;
ShortPreviewOpeningImage.Source = await LoadPreviewBitmapAsync(frames.OpeningPath);
ShortPreviewClosingImage.Source = await LoadPreviewBitmapAsync(frames.ClosingPath);
bool hasFont = !string.IsNullOrWhiteSpace(settings.FontFile) && File.Exists(settings.FontFile);
ShortPreviewStatusText.Text = hasFont
? $"{settings.Style} · opening hook and closing title card"
: "Look only — pick a font on the last step to include the title-card text.";
}
catch (Exception exception)
{
if (generation != _previewGeneration)
return;
string message = UserFacingErrors.Summarize(exception);
ShortPreviewStatusText.Text = message;
if (!automatic)
await ShowMessageAsync("Preview failed", message);
}
finally
{
if (generation == _previewGeneration)
{
ShortPreviewProgress.Visibility = Visibility.Collapsed;
UpdateShortPreviewButton.IsEnabled = true;
}
}
}
private static async Task<BitmapImage> LoadPreviewBitmapAsync(string path)
{
await using FileStream stream = File.OpenRead(path);
BitmapImage bitmap = new() { DecodePixelWidth = 296 };
await bitmap.SetSourceAsync(stream.AsRandomAccessStream());
return bitmap;
}
private void SetRendering(bool rendering)
{
_isRendering = rendering;
RenderButton.IsEnabled = !rendering;
CancelButton.IsEnabled = rendering;
OpenSettingsButton.IsEnabled = !rendering;
VideoTabButton.IsEnabled = !rendering;
ShortsTabButton.IsEnabled = !rendering;
UpdateWorkspaceVisibility();
}
private void UpdateWorkspaceVisibility()
{
bool showSetup = !_isRendering;
bool showOutput = _isRendering || _keepRenderOutput;
VideoContentPanel.Visibility = VisibleIf(showSetup && !_isShortsTab);
ShortsContentPanel.Visibility = VisibleIf(showSetup && _isShortsTab);
RenderProgressPanel.Visibility = VisibleIf(showOutput);
FfmpegLogPanel.Visibility = VisibleIf(showOutput);
UpdateWizardNavigation();
}
private void UpdateProgress(RenderProgress value)
{
RenderProgressBar.Value = value.Percent;
PercentText.Text = $"{value.Percent:0}%";
StageText.Text = value.Stage;
StatusText.Text = value.Message;
}
private async Task OpenPreviewAsync(string mediaPath)
{
if (!File.Exists(mediaPath))
{
await ShowMessageAsync("Preview unavailable", "The selected media file was not found.");
return;
}
string playerPath = PreviewPlayerPathBox.Text.Trim();
try
{
if (!string.IsNullOrWhiteSpace(playerPath))
{
if (!File.Exists(playerPath))
throw new FileNotFoundException("Configured preview player was not found.", playerPath);
ProcessStartInfo customPlayer = new()
{
FileName = playerPath,
UseShellExecute = false
};
customPlayer.ArgumentList.Add(mediaPath);
Process.Start(customPlayer);
return;
}
Process.Start(new ProcessStartInfo(mediaPath) { UseShellExecute = true });
}
catch (Exception exception)
{
await ShowMessageAsync("Preview failed", exception.Message);
}
}
private void AppendLog(string line)
{
lock (_logLock)
{
_pendingLog.AppendLine(line);
if (_logFlushScheduled)
return;
_logFlushScheduled = true;
}
_ = DispatcherQueue.TryEnqueue(() =>
{
string chunk;
lock (_logLock)
{
chunk = _pendingLog.ToString();
_pendingLog.Clear();
_logFlushScheduled = false;
}
if (chunk.Length == 0)
return;
LogBox.Text += chunk;
LogBox.Select(LogBox.Text.Length, 0);
});
}
private void SaveSettings()
{
AppSettingsStore.Save(new AppSettings
{
OutputFolder = OutputFolderBox.Text.Trim(),
EndCardPath = EndCardBox.Text.Trim(),
FfmpegPath = FfmpegPathBox.Text.Trim(),
FfprobePath = FfprobePathBox.Text.Trim(),
PreviewPlayerPath = PreviewPlayerPathBox.Text.Trim(),
Theme = SelectedTheme(),
Encoder = SelectedEncoder(),
TrimStart = TrimBox.Text.Trim(),
FadeSeconds = FadeBox.Text.Trim(),
EndCardHoldSeconds = HoldBox.Text.Trim(),
ThumbnailInterval = IntervalBox.Text.Trim(),
MoveSourcesToOriginals = MoveSourcesBox.IsChecked == true,
ShortsStyle = SelectedShortStyle(),
ShortsHook = ShortHookBox.Text.Trim(),
ShortsWebsite = ShortWebsiteBox.Text.Trim(),
ShortsFontFile = ShortFontBox.Text.Trim(),
ShortsBackgroundImage = ShortBackgroundBox.Text.Trim(),
ShortsBackgroundVideo = ShortBackgroundVideoBox.Text.Trim(),
ShortsVideoBlur = ShortVideoBlurBox.Text.Trim(),
ShortsVideoOpacity = ShortVideoOpacityBox.Text.Trim(),
ShortsSourceCropMode = SelectedShortCropMode().ToString(),
ShortsCropLeft = ShortCropLeftBox.Text.Trim(),
ShortsCropRight = ShortCropRightBox.Text.Trim(),
ShortsCropTop = ShortCropTopBox.Text.Trim(),
ShortsCropBottom = ShortCropBottomBox.Text.Trim(),
ShortsSourceMarginX = ShortSourceMarginBox.Text.Trim(),
ShortsSourceFadeIn = ShortFadeInBox.Text.Trim(),
ShortsCrf = ShortCrfBox.Text.Trim(),
ShortsPreset = SelectedShortPreset(),
});
}
private string SelectedTheme() => (ThemeBox.SelectedItem as ComboBoxItem)?.Tag?.ToString() ?? "Dark";
private string SelectedEncoder() => (EncoderBox.SelectedItem as ComboBoxItem)?.Tag?.ToString() ?? "Auto";
private string SelectedShortStyle() => (ShortStyleBox.SelectedItem as ComboBoxItem)?.Tag?.ToString() ?? "Brand";
private ShortStyle SelectedShortStyleEnum() =>
Enum.TryParse(SelectedShortStyle(), ignoreCase: true, out ShortStyle style) ? style : ShortStyle.Brand;
private ShortSourceCropMode SelectedShortCropMode()
{
string tag = (ShortCropModeBox.SelectedItem as ComboBoxItem)?.Tag?.ToString() ?? "Auto";
return Enum.TryParse(tag, ignoreCase: true, out ShortSourceCropMode mode) ? mode : ShortSourceCropMode.Auto;
}
private string SelectedShortPreset() => (ShortPresetBox.SelectedItem as ComboBoxItem)?.Tag?.ToString() ?? "slow";
private void SelectTheme(string theme) => SelectComboTag(ThemeBox, theme);
private void SelectEncoder(string encoder) => SelectComboTag(EncoderBox, encoder);
private static void SelectComboTag(ComboBox comboBox, string tag)
{
foreach (ComboBoxItem item in comboBox.Items.Cast<ComboBoxItem>())
{
if (string.Equals(item.Tag?.ToString(), tag, StringComparison.OrdinalIgnoreCase))
{
comboBox.SelectedItem = item;
return;
}
}
comboBox.SelectedIndex = 0;
}
private void InputList_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
PreviewSourceButton.IsEnabled = InputList.SelectedItem is string;
}
private void ApplyTheme(string theme)
{
bool isDark = !string.Equals(theme, "Light", StringComparison.OrdinalIgnoreCase);
App.MainWindowInstance?.ApplyTheme(isDark);
}
private FileOpenPicker CreateFileOpenPicker()
{
FileOpenPicker picker = new();
InitializeWithWindow.Initialize(picker, App.MainWindowHandle);
return picker;
}
private FolderPicker CreateFolderPicker()
{
FolderPicker picker = new();
picker.FileTypeFilter.Add("*");
InitializeWithWindow.Initialize(picker, App.MainWindowHandle);
return picker;
}
private static string NaturalKey(string path)
{
string name = Path.GetFileNameWithoutExtension(path);
int underscore = name.LastIndexOf('_');
return underscore >= 0 && int.TryParse(name[(underscore + 1)..], out int number)
? name[..underscore] + number.ToString("D10")
: name;
}
private async Task ShowMessageAsync(string title, string message)
{
ContentDialog dialog = DialogHelper.CreateStyled(XamlRoot, ActualTheme);
dialog.Title = title;
dialog.Content = message;
dialog.CloseButtonText = "OK";
await dialog.ShowAsync();
}
}