Files
AmiReel/MainPage.xaml.cs
T
klevze 58e11ba4e3 Add YouTube Shorts tab (C# port of amigadb-short.sh)
New "Video | Shorts" tab strip on the main page. The existing Video tab and
its render pipeline are unchanged; Shorts is a new, independent workflow
sharing the same render-progress/log/output controls.

- Services/ShortsPipeline.cs: C# port of amigadb-short.sh rather than
  shelling out to bash — end users on plain Windows don't have bash/WSL, and
  shipping this as a native part of the app keeps distribution self-contained.
  All 19 visual style filtergraphs (brand, pixel, mirror, crop, workbench,
  blur, crt, copper, stars, grid, tiles, scan, starfield, plasma,
  rasterbars, vhs, monitor, split, spectrum) are copied verbatim from the
  bash heredocs. Per explicit decision, kept parity with the script's actual
  behavior where the brand-style HOOK_BOX_Y/INFO_BOX_Y/font-size variables
  are computed but never actually used in the final filter (dead code in
  the original) rather than "fixing" it and changing rendered output.
  Reuses ToolExtractor/MediaProbe/ProcessRunner exactly as RenderPipeline
  does, so FFmpeg resolution and NVENC fallback behave identically.

- Models/ShortSettings.cs: job parameters + ShortStyle enum.

- AppSettings gains sticky Shorts defaults (style, hook, website, font,
  background image, CRF, preset) alongside the existing Video defaults.

- MainPage: Video/Shorts tab buttons toggle two content panels; the
  background-image field auto-hides for non-brand styles; font field
  defaults to the first bold system font found (Segoe UI Bold, Segoe UI
  Semibold, Arial Bold, Calibri Bold); blank output path is auto-derived
  from the input filename and style, matching the script's default.

- 13 new unit tests (escaping order, meta-line joining, validation,
  every style producing a non-empty filter, the drawtext trailer's timing
  gates). Full run is 72/72 passing.

- Fixed a bug caught during manual testing: MainPage.Resources["PrimaryButtonStyle"]
  looked in the Page's own (empty) resource dictionary instead of
  Application.Current.Resources, throwing on every tab switch.

Verified end-to-end: rendered a real Short from an existing video through
the actual running app (not just unit tests) and inspected extracted
frames — hook/title/meta/website text overlays appear and disappear at the
correct timestamps with correct styling.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 17:30:25 +02:00

574 lines
21 KiB
C#

using System.Collections.ObjectModel;
using System.Diagnostics;
using System.Globalization;
using System.Text;
using AmiReel.Models;
using AmiReel.Services;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
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;
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;
ShortFontBox.Text = string.IsNullOrWhiteSpace(_loadedSettings.ShortsFontFile)
? ShortsPipeline.FindDefaultFont() ?? ""
: _loadedSettings.ShortsFontFile;
SelectComboTag(ShortStyleBox, _loadedSettings.ShortsStyle);
SelectComboTag(ShortPresetBox, _loadedSettings.ShortsPreset);
UpdateShortBackgroundVisibility();
}
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)
{
_isShortsTab = shorts;
VideoContentPanel.Visibility = shorts ? Visibility.Collapsed : Visibility.Visible;
ShortsContentPanel.Visibility = shorts ? Visibility.Visible : Visibility.Collapsed;
Style primaryButtonStyle = (Style)Application.Current.Resources["PrimaryButtonStyle"];
VideoTabButton.Style = shorts ? null : primaryButtonStyle;
ShortsTabButton.Style = shorts ? primaryButtonStyle : null;
RenderButton.Content = shorts ? "Render short" : "Start render";
}
private void ShortStyleBox_SelectionChanged(object sender, SelectionChangedEventArgs e) => UpdateShortBackgroundVisibility();
private void UpdateShortBackgroundVisibility()
{
bool isBrand = string.Equals(SelectedShortStyle(), nameof(ShortStyle.Brand), StringComparison.OrdinalIgnoreCase);
ShortBackgroundRow.Visibility = isBrand ? 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
{
SetRendering(true);
LogBox.Text = string.Empty;
_renderCancellation = new CancellationTokenSource();
if (_isShortsTab)
await RenderShortAsync(_renderCancellation.Token);
else
await RenderVideoAsync(_renderCancellation.Token);
}
catch (OperationCanceledException)
{
UpdateProgress(new(RenderProgressBar.Value, "Cancelled", "The render was cancelled."));
}
catch (Exception exception)
{
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()
{
if (!double.TryParse(ShortDurationBox.Text.Replace(',', '.'), NumberStyles.Float, CultureInfo.InvariantCulture, out double duration) || duration <= 0)
throw new ArgumentException("Enter a valid duration in seconds.");
if (!int.TryParse(ShortCrfBox.Text, out int crf))
throw new ArgumentException("CRF must be an integer.");
string input = ShortInputBox.Text.Trim();
string output = ShortOutputBox.Text.Trim();
ShortStyle style = Enum.Parse<ShortStyle>(SelectedShortStyle());
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(),
FontFile = ShortFontBox.Text.Trim(),
FfmpegPath = FfmpegPathBox.Text.Trim(),
FfprobePath = FfprobePathBox.Text.Trim(),
Crf = crf,
Preset = SelectedShortPreset(),
};
}
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 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 PreviewShortSource_Click(object sender, RoutedEventArgs e)
{
if (!string.IsNullOrWhiteSpace(ShortInputBox.Text))
await OpenPreviewAsync(ShortInputBox.Text.Trim());
}
private void SetRendering(bool rendering)
{
RenderButton.IsEnabled = !rendering;
CancelButton.IsEnabled = rendering;
OpenSettingsButton.IsEnabled = !rendering;
VideoTabButton.IsEnabled = !rendering;
ShortsTabButton.IsEnabled = !rendering;
}
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(),
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 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();
}
}