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:
+239
-6
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user