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.
936 lines
37 KiB
C#
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();
|
|
}
|
|
}
|