Add video preview, source crop, find-videos, and render toasts

Bring the Video tab closer to Shorts with 4K preview stills and crop
modes (none, auto, center, plus the visual editor). Settings store a
default source folder so Find videos can collect output.avi sequences.
Cap the FFmpeg log with auto-scroll, hide repeat spam, and show a
Windows notification when a video or short finishes or fails.
This commit is contained in:
2026-08-16 09:20:56 +02:00
parent e635b708a2
commit a590904e89
20 changed files with 1259 additions and 103 deletions
+239 -6
View File
@@ -7,6 +7,7 @@ using AmiReel.Models;
using AmiReel.Services;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Media;
using Microsoft.UI.Xaml.Media.Imaging;
using Windows.ApplicationModel.DataTransfer;
using Windows.Storage;
@@ -31,6 +32,7 @@ public sealed partial class MainPage : Page
private int _wizardIndex;
private int _previewGeneration;
private string? _previewDirectory;
private string? _videoPreviewDirectory;
public MainPage()
{
@@ -51,6 +53,12 @@ public sealed partial class MainPage : Page
SelectTheme(_loadedSettings.Theme);
ApplyTheme(_loadedSettings.Theme);
MoveSourcesBox.IsChecked = _loadedSettings.ShouldMoveSourcesToOriginals;
SourceSearchFolderBox.Text = _loadedSettings.SourceSearchFolder;
VideoCropLeftBox.Text = _loadedSettings.VideoCropLeft;
VideoCropRightBox.Text = _loadedSettings.VideoCropRight;
VideoCropTopBox.Text = _loadedSettings.VideoCropTop;
VideoCropBottomBox.Text = _loadedSettings.VideoCropBottom;
SelectComboTag(VideoCropModeBox, _loadedSettings.VideoSourceCropMode);
ShortHookBox.Text = _loadedSettings.ShortsHook;
ShortWebsiteBox.Text = _loadedSettings.ShortsWebsite;
@@ -227,6 +235,48 @@ public sealed partial class MainPage : Page
private void ClearInputs_Click(object sender, RoutedEventArgs e) => _inputs.Clear();
private async void FindVideos_Click(object sender, RoutedEventArgs e)
{
string folder = SourceSearchFolderBox.Text.Trim();
if (string.IsNullOrWhiteSpace(folder))
{
await ShowMessageAsync("Find videos", "Set a default source folder in Settings first.");
return;
}
if (!Directory.Exists(folder))
{
await ShowMessageAsync("Find videos", "The default source folder was not found.");
return;
}
IReadOnlyList<string> found = SourceRecordingFinder.FindSequentialOutputs(folder);
if (found.Count == 0)
{
await ShowMessageAsync("Find videos", "No output.avi sequence was found in the default source folder.");
return;
}
int added = 0;
foreach (string path in found)
{
if (_inputs.Contains(path, StringComparer.OrdinalIgnoreCase))
continue;
_inputs.Add(path);
added++;
}
if (added == 0)
{
await ShowMessageAsync("Find videos", "Those recordings are already in the source list.");
return;
}
if (InputList.SelectedItem is null && _inputs.Count > 0)
InputList.SelectedIndex = 0;
}
private void InputList_DragOver(object sender, DragEventArgs e)
{
e.AcceptedOperation = e.DataView.Contains(StandardDataFormats.StorageItems)
@@ -275,6 +325,14 @@ public sealed partial class MainPage : Page
OutputFolderBox.Text = folder.Path;
}
private async void BrowseSourceSearchFolder_Click(object sender, RoutedEventArgs e)
{
FolderPicker picker = CreateFolderPicker();
var folder = await picker.PickSingleFolderAsync();
if (folder is not null)
SourceSearchFolderBox.Text = folder.Path;
}
private async void BrowseFfmpeg_Click(object sender, RoutedEventArgs e)
{
FileOpenPicker picker = CreateFileOpenPicker();
@@ -327,13 +385,16 @@ public sealed partial class MainPage : Page
{
_keepRenderOutput = true;
UpdateProgress(new(RenderProgressBar.Value, "Cancelled", "The render was cancelled."));
RenderNotifier.ShowCancelled();
}
catch (Exception exception)
{
_keepRenderOutput = true;
AppendLog("ERROR: " + exception);
StageText.Text = "Failed";
await ShowMessageAsync("Render failed", UserFacingErrors.Summarize(exception));
string summary = UserFacingErrors.Summarize(exception);
RenderNotifier.ShowFailed(summary);
await ShowMessageAsync("Render failed", summary);
}
finally
{
@@ -353,6 +414,7 @@ public sealed partial class MainPage : Page
_lastRenderedFile = Path.Combine(settings.OutputDirectory, settings.OutputName + "_final.mp4");
OpenOutputButton.IsEnabled = true;
PreviewRenderedButton.IsEnabled = File.Exists(_lastRenderedFile);
RenderNotifier.ShowCompleted(shorts: false);
await ShowMessageAsync("Render complete", "The AmiReel render completed successfully.");
}
@@ -366,6 +428,7 @@ public sealed partial class MainPage : Page
ShortOutputBox.Text = settings.OutputPath;
OpenOutputButton.IsEnabled = true;
PreviewRenderedButton.IsEnabled = File.Exists(_lastRenderedFile);
RenderNotifier.ShowCompleted(shorts: true);
await ShowMessageAsync("Short complete", "The YouTube Short was rendered successfully.");
}
@@ -432,6 +495,27 @@ public sealed partial class MainPage : Page
EndCardHoldSeconds = Number(HoldBox.Text, "end-card hold"),
ThumbnailInterval = interval,
MoveSourcesToOriginals = MoveSourcesBox.IsChecked == true,
SourceCropMode = SelectedVideoCropMode(),
CropLeft = ParseNonNegativeInt(VideoCropLeftBox.Text, "Crop left"),
CropRight = ParseNonNegativeInt(VideoCropRightBox.Text, "Crop right"),
CropTop = ParseNonNegativeInt(VideoCropTopBox.Text, "Crop top"),
CropBottom = ParseNonNegativeInt(VideoCropBottomBox.Text, "Crop bottom"),
};
}
private RenderSettings ReadVideoPreviewSettings()
{
IReadOnlyList<string> inputs = InputList.SelectedItem is string selected
? [selected]
: _inputs.ToList();
return ReadSettings() with
{
InputFiles = inputs.ToList(),
OutputDirectory = string.IsNullOrWhiteSpace(OutputFolderBox.Text)
? Path.GetTempPath()
: OutputFolderBox.Text.Trim(),
OutputName = string.IsNullOrWhiteSpace(OutputNameBox.Text) ? "preview" : OutputNameBox.Text.Trim(),
};
}
@@ -599,6 +683,49 @@ public sealed partial class MainPage : Page
ShortFontBox.Text = file.Path;
}
private async void OpenVideoCropEditor_Click(object sender, RoutedEventArgs e)
{
string? input = InputList.SelectedItem as string ?? _inputs.FirstOrDefault();
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;
double trimStart = 0;
try { trimStart = ParseNonNegativeNumber(TrimBox.Text, "trim start"); } catch (ArgumentException) { }
await ShortCropEditor.LoadVideoFrameAsync(
input,
RenderPipeline.FormatTimestamp(trimStart),
duration,
FfmpegPathBox.Text.Trim(),
FfprobePathBox.Text.Trim(),
CancellationToken.None);
ShortCropEditor.SetInsets(ReadVideoCropInsets());
ContentDialogResult result = await CropEditorDialog.ShowAsync();
if (result != ContentDialogResult.Primary)
return;
PixelInsets insets = ShortCropEditor.Insets;
VideoCropLeftBox.Text = insets.Left.ToString(CultureInfo.InvariantCulture);
VideoCropRightBox.Text = insets.Right.ToString(CultureInfo.InvariantCulture);
VideoCropTopBox.Text = insets.Top.ToString(CultureInfo.InvariantCulture);
VideoCropBottomBox.Text = insets.Bottom.ToString(CultureInfo.InvariantCulture);
_ = RefreshVideoPreviewAsync(automatic: true);
}
catch (Exception exception)
{
await ShowMessageAsync("Crop source", UserFacingErrors.Summarize(exception));
}
}
private async void OpenCropEditor_Click(object sender, RoutedEventArgs e)
{
string input = ShortInputBox.Text.Trim();
@@ -656,6 +783,22 @@ public sealed partial class MainPage : Page
ReadBox(ShortCropBottomBox.Text));
}
private PixelInsets ReadVideoCropInsets()
{
static int ReadBox(string text)
{
return int.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out int value) && value > 0
? value
: 0;
}
return new PixelInsets(
ReadBox(VideoCropLeftBox.Text),
ReadBox(VideoCropRightBox.Text),
ReadBox(VideoCropTopBox.Text),
ReadBox(VideoCropBottomBox.Text));
}
private async void PreviewShortSource_Click(object sender, RoutedEventArgs e)
{
if (!string.IsNullOrWhiteSpace(ShortInputBox.Text))
@@ -665,6 +808,56 @@ public sealed partial class MainPage : Page
private async void UpdateShortPreview_Click(object sender, RoutedEventArgs e) =>
await RefreshShortPreviewAsync(automatic: false);
private async void UpdateVideoPreview_Click(object sender, RoutedEventArgs e) =>
await RefreshVideoPreviewAsync(automatic: false);
private async Task RefreshVideoPreviewAsync(bool automatic)
{
if (_isRendering)
return;
int generation = ++_previewGeneration;
VideoPreviewProgress.Visibility = Visibility.Visible;
UpdateVideoPreviewButton.IsEnabled = false;
VideoPreviewStatusText.Text = "Rendering 16:9 preview frames...";
try
{
RenderSettings settings = ReadVideoPreviewSettings();
RenderPipeline.ValidatePreview(settings);
_videoPreviewDirectory ??= Path.Combine(Path.GetTempPath(), "AmiReel", "video-preview", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(_videoPreviewDirectory);
VideoPreviewFrames frames = await new RenderPipeline().RenderPreviewAsync(
settings, _videoPreviewDirectory, _ => { }, CancellationToken.None);
if (generation != _previewGeneration)
return;
VideoPreviewOpeningImage.Source = await LoadPreviewBitmapAsync(frames.OpeningPath, 584);
VideoPreviewClosingImage.Source = await LoadPreviewBitmapAsync(frames.ClosingPath, 584);
VideoPreviewStatusText.Text = "4K look · cropped source and end card";
}
catch (Exception exception)
{
if (generation != _previewGeneration)
return;
string message = UserFacingErrors.Summarize(exception);
VideoPreviewStatusText.Text = message;
if (!automatic)
await ShowMessageAsync("Preview failed", message);
}
finally
{
if (generation == _previewGeneration)
{
VideoPreviewProgress.Visibility = Visibility.Collapsed;
UpdateVideoPreviewButton.IsEnabled = true;
}
}
}
private async Task RefreshShortPreviewAsync(bool automatic)
{
if (_isRendering)
@@ -688,8 +881,8 @@ public sealed partial class MainPage : Page
if (generation != _previewGeneration)
return;
ShortPreviewOpeningImage.Source = await LoadPreviewBitmapAsync(frames.OpeningPath);
ShortPreviewClosingImage.Source = await LoadPreviewBitmapAsync(frames.ClosingPath);
ShortPreviewOpeningImage.Source = await LoadPreviewBitmapAsync(frames.OpeningPath, 296);
ShortPreviewClosingImage.Source = await LoadPreviewBitmapAsync(frames.ClosingPath, 296);
bool hasFont = !string.IsNullOrWhiteSpace(settings.FontFile) && File.Exists(settings.FontFile);
ShortPreviewStatusText.Text = hasFont
? $"{settings.Style} · opening hook and closing title card"
@@ -715,10 +908,10 @@ public sealed partial class MainPage : Page
}
}
private static async Task<BitmapImage> LoadPreviewBitmapAsync(string path)
private static async Task<BitmapImage> LoadPreviewBitmapAsync(string path, int decodePixelWidth)
{
await using FileStream stream = File.OpenRead(path);
BitmapImage bitmap = new() { DecodePixelWidth = 296 };
BitmapImage bitmap = new() { DecodePixelWidth = decodePixelWidth };
await bitmap.SetSourceAsync(stream.AsRandomAccessStream());
return bitmap;
}
@@ -814,10 +1007,38 @@ public sealed partial class MainPage : Page
return;
LogBox.Text += chunk;
LogBox.Select(LogBox.Text.Length, 0);
ScrollLogToEnd();
});
}
private void ScrollLogToEnd()
{
LogBox.SelectionStart = LogBox.Text.Length;
LogBox.SelectionLength = 0;
if (FindDescendantScrollViewer(LogBox) is not ScrollViewer viewer)
return;
viewer.UpdateLayout();
double offset = Math.Max(0, viewer.ExtentHeight - viewer.ViewportHeight);
viewer.ChangeView(null, offset, null, disableAnimation: true);
}
private static ScrollViewer? FindDescendantScrollViewer(DependencyObject root)
{
if (root is ScrollViewer viewer)
return viewer;
int count = VisualTreeHelper.GetChildrenCount(root);
for (int index = 0; index < count; index++)
{
ScrollViewer? match = FindDescendantScrollViewer(VisualTreeHelper.GetChild(root, index));
if (match is not null)
return match;
}
return null;
}
private void SaveSettings()
{
AppSettingsStore.Save(new AppSettings
@@ -834,6 +1055,12 @@ public sealed partial class MainPage : Page
EndCardHoldSeconds = HoldBox.Text.Trim(),
ThumbnailInterval = IntervalBox.Text.Trim(),
MoveSourcesToOriginals = MoveSourcesBox.IsChecked == true,
SourceSearchFolder = SourceSearchFolderBox.Text.Trim(),
VideoSourceCropMode = SelectedVideoCropMode().ToString(),
VideoCropLeft = VideoCropLeftBox.Text.Trim(),
VideoCropRight = VideoCropRightBox.Text.Trim(),
VideoCropTop = VideoCropTopBox.Text.Trim(),
VideoCropBottom = VideoCropBottomBox.Text.Trim(),
ShortsStyle = SelectedShortStyle(),
ShortsHook = ShortHookBox.Text.Trim(),
ShortsWebsite = ShortWebsiteBox.Text.Trim(),
@@ -869,6 +1096,12 @@ public sealed partial class MainPage : Page
return Enum.TryParse(tag, ignoreCase: true, out ShortSourceCropMode mode) ? mode : ShortSourceCropMode.Auto;
}
private ShortSourceCropMode SelectedVideoCropMode()
{
string tag = (VideoCropModeBox.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);