UI fixed: rotation support, ff/bb buttons on preview, inspector layout.

This commit is contained in:
Alexander Shabarshov 2026-05-23 10:05:58 +01:00
parent e566bb6137
commit 42408bba38
9 changed files with 185 additions and 92 deletions

View File

@ -0,0 +1,21 @@
using System.Globalization;
using Avalonia.Data.Converters;
namespace Splitter_UI.Converters;
public sealed class RotationAngleToIconConverter : IValueConverter
{
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{
return value switch
{
90 => "\uf2f9", // FA7 (fa-rotate-left / fa-arrow-rotate-left / fa-undo)
180 => "\uf2f1", // FA7 (fa-sync-alt)
270 => "\uf2ea", // FA7 (fa-rotate-right / fa-arrow-rotate-right / fa-redo)
_ => null
};
}
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
=> throw new NotSupportedException();
}

View File

@ -0,0 +1,13 @@
using System.Globalization;
using Avalonia.Data.Converters;
namespace Splitter_UI.Converters;
public sealed class ZeroToBoolConverter : IValueConverter
{
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
=> (value is int i && i == 0);
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
=> throw new NotSupportedException();
}

View File

@ -5,5 +5,5 @@ namespace Splitter_UI.Services;
public interface IThumbnailService
{
Task<Bitmap?> CreateThumbnailAsync(string file, VideoInfo probe, TimeSpan? skip = null, int? width = null, int? height = null);
Task<Bitmap?> CreateThumbnailAsync(string file, VideoInfo probe, TimeSpan? skip = null, int? width = null, int? height = null, int? rotateDegree = null);
}

View File

@ -13,7 +13,13 @@ public sealed class ThumbnailService : IThumbnailService
private readonly byte [] _bgrBuffer = new byte[_thumbWidth * _thumbHeight * 3];
private readonly byte [] _bgraBuffer = new byte[_thumbWidth * _thumbHeight * 4];
public async Task<Bitmap?> CreateThumbnailAsync(string file, VideoInfo probe, TimeSpan? skip = null, int? width = null, int? height = null)
public async Task<Bitmap?> CreateThumbnailAsync(
string file,
VideoInfo probe,
TimeSpan? skip = null,
int? width = null,
int? height = null,
int? rotateDegree = null)
{
width ??= _thumbWidth;
height ??= _thumbHeight;
@ -29,7 +35,7 @@ public sealed class ThumbnailService : IThumbnailService
var bgraBuffer = canUseStaticBuffers ? _bgraBuffer : new byte[width.Value * height.Value * 4];
// Decode a single frame using ffmpeg → raw BGR24 into _bgrBuffer
bool ok = await DecodeFrameAsync(bgrBuffer, file, skip.Value, width.Value, height.Value);
bool ok = await DecodeFrameAsync(bgrBuffer, file, skip.Value, width.Value, height.Value, rotateDegree);
if (!ok)
return null;
@ -37,17 +43,19 @@ public sealed class ThumbnailService : IThumbnailService
ConvertBgrToBgra(bgrBuffer, bgraBuffer, width.Value, height.Value);
// Create Avalonia Bitmap
return CreateBitmap(bgraBuffer, width.Value, height.Value);
return CreateBitmap(bgraBuffer, width.Value, height.Value, rotateDegree == 90 || rotateDegree == 270);
}
private static async Task<bool> DecodeFrameAsync(byte [] bgrBuffer, string file, TimeSpan skip, int width, int height)
private static async Task<bool> DecodeFrameAsync(byte [] bgrBuffer, string file, TimeSpan skip, int width, int height, int? rotateDegree)
{
var rotationStr = TrackingSplitter.GetRorationArg(rotateDegree);
// ffmpeg command: decode one frame, resize, output raw BGR24
var args =
$"-ss {skip.TotalSeconds} -t 0.1 -i \"{file}\" " +
"-an -sn " +
$"-vf \"scale={width}:{height}:force_original_aspect_ratio=decrease," +
$"pad={width}:{height}:(ow-iw)/2:(oh-ih)/2,format=bgr24\" " +
$"pad={width}:{height}:(ow-iw)/2:(oh-ih)/2,format=bgr24{rotationStr}\" " +
"-f rawvideo -";
var psi = new ProcessStartInfo
@ -107,8 +115,13 @@ public sealed class ThumbnailService : IThumbnailService
}
}
private static unsafe Bitmap CreateBitmap(byte[] bgra, int width, int height)
private static unsafe Bitmap CreateBitmap(byte[] bgra, int width, int height, bool isRotated)
{
if (isRotated)
{
(height, width) = (width, height);
}
int stride = width * 4;
fixed (byte* p = bgra)

View File

@ -41,6 +41,12 @@ public partial class JobViewModel : ObservableObject
public string FileName => Path.GetFileName(Job.InputFile);
public string TextDesc => Probe != null
? $"{Probe.Width}x{Probe.Height}, {TimeSpan.FromSeconds(Probe.Duration).ToString(@"hh\:mm\:ss")}), FPS: {Probe.Fps:F2}, Bitrate: {Probe.Bitrate/1024/1024:F2} MB/s"
: "";
public override string ToString() => $"{FileName} - {TextDesc}";
public ObservableCollection<ParameterEntry> ParametersList { get; }
= new();
@ -105,6 +111,7 @@ public partial class JobViewModel : ObservableObject
{
Job.Rotate = value;
OnPropertyChanged();
Task.Run(CreatePreview);
}
}
@ -145,7 +152,7 @@ public partial class JobViewModel : ObservableObject
private async Task LoadThumbnailAsync()
{
Probe = await _fileProbe.ProbeAsync(Job);
Thumbnail = await _thumbnails.CreateThumbnailAsync(Job.InputFile, Probe);
Thumbnail = await _thumbnails.CreateThumbnailAsync(Job.InputFile, Probe, rotateDegree: Job.Rotate);
SuggestedAction = Probe.Rotation == 0 ? "crop" : "rotate";
await CreatePreview();
@ -157,9 +164,8 @@ public partial class JobViewModel : ObservableObject
return;
try
{
var frame = await _thumbnails.CreateThumbnailAsync(Job.InputFile, Probe, TimeSpan.FromSeconds(PositionSeconds), Probe.Width, Probe.Height);
var frame = await _thumbnails.CreateThumbnailAsync(Job.InputFile, Probe, TimeSpan.FromSeconds(PositionSeconds), Probe.Width, Probe.Height, Job.Rotate);
Preview = new PreviewData(frame, [], null);
OnPropertyChanged(nameof(Preview));
}
catch (Exception ex)
{
@ -197,21 +203,23 @@ public partial class JobViewModel : ObservableObject
private void StepForward()
{
var step = 10.0; // seconds
if (DurationSeconds <= 0)
return;
PositionSeconds = Math.Min(DurationSeconds, PositionSeconds + step);
var step = DurationSeconds * 0.1; // 10% of total duration
SliderLiveValue = Math.Min(DurationSeconds, SliderLiveValue + step);
// trigger seek in your playback pipeline here
}
private void StepBackward()
{
var step = 10.0; // seconds
if (DurationSeconds <= 0)
return;
PositionSeconds = Math.Max(0, PositionSeconds - step);
var step = DurationSeconds * 0.1; // 10% of total duration
SliderLiveValue = Math.Max(0, SliderLiveValue - step);
// trigger seek in your playback pipeline here
}

View File

@ -3,10 +3,15 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:Splitter_UI.ViewModels"
xmlns:views="clr-namespace:Splitter_UI.Views"
xmlns:svg="clr-namespace:Avalonia.Svg.Skia;assembly=Avalonia.Svg.Skia"
xmlns:conv="clr-namespace:Splitter_UI.Converters"
x:Class="Splitter_UI.Views.FileListView"
x:DataType="vm:FileListViewModel">
<UserControl.Resources>
<conv:ZeroToBoolConverter x:Key="ZeroToBoolConverter"/>
<conv:RotationAngleToIconConverter x:Key="RotationAngleToIconConverter"/>
</UserControl.Resources>
<UserControl.Styles>
<Style Selector="views|FileListView Border#DropZone">
<Setter Property="BorderBrush" Value="Transparent"/>
@ -20,75 +25,91 @@
</UserControl.Styles>
<Border x:Name="DropZone"
Background="#1E1E1E"
Padding="10"
DragDrop.AllowDrop="True"
DragDrop.Drop="OnDrop"
DragDrop.DragOver="OnDragOver"
DragDrop.DragEnter="OnDragEnter"
DragDrop.DragLeave="OnDragLeave">
Background="#1E1E1E"
Padding="10"
DragDrop.AllowDrop="True"
DragDrop.Drop="OnDrop"
DragDrop.DragOver="OnDragOver"
DragDrop.DragEnter="OnDragEnter"
DragDrop.DragLeave="OnDragLeave">
<Grid>
<ScrollViewer>
<ListBox ItemsSource="{Binding Files}"
SelectedItems="{Binding SelectedFiles}"
SelectedItem="{Binding Selected}"
SelectionMode="Multiple"
BorderThickness="0"
Background="Transparent">
<!-- Empty message -->
<TextBlock Text="Drag files here"
HorizontalAlignment="Center"
VerticalAlignment="Center"
FontSize="20"
Foreground="#666"
IsVisible="{Binding Files.Count, Converter={StaticResource ZeroToBoolConverter}}"/>
<ListBox.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel Orientation="Horizontal"/>
</ItemsPanelTemplate>
</ListBox.ItemsPanel>
<!-- File list -->
<ScrollViewer>
<ListBox ItemsSource="{Binding Files}"
SelectedItems="{Binding SelectedFiles}"
SelectedItem="{Binding Selected}"
SelectionMode="Multiple"
BorderThickness="0"
Background="Transparent">
<ListBox.Styles>
<Style Selector="ListBoxItem:selected /template/ ContentPresenter">
<Setter Property="Background" Value="#9A9A9A"/>
<ListBox.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel Orientation="Horizontal"/>
</ItemsPanelTemplate>
</ListBox.ItemsPanel>
<ListBox.Styles>
<Style Selector="ListBoxItem:selected /template/ ContentPresenter">
<Setter Property="Background" Value="#9A9A9A"/>
</Style>
</ListBox.Styles>
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:JobViewModel">
<Border x:Name="ItemRoot"
Margin="0"
Padding="0"
CornerRadius="4"
Background="#2A2A2A">
<StackPanel MinWidth="160" MaxWidth="160">
</ListBox.Styles>
<Border Width="160" Height="90" ClipToBounds="True">
<Grid>
<Image Source="{Binding Thumbnail}"
Stretch="UniformToFill"
HorizontalAlignment="Center"
VerticalAlignment="Center"/>
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:JobViewModel">
<Border x:Name="ItemRoot"
Margin="0"
Padding="0"
CornerRadius="4"
Background="#2A2A2A">
<StackPanel MinWidth="160" MaxWidth="160">
<TextBlock FontFamily="{StaticResource FontAwesome}"
Text="{Binding SuggestedAction, Converter={StaticResource ActionToIconConverter}}"
FontSize="12"
HorizontalAlignment="Right"
Foreground="LimeGreen"/>
</Grid>
</Border>
<Border Width="160" Height="90" ClipToBounds="True">
<Grid>
<Image Source="{Binding Thumbnail}"
Stretch="UniformToFill"
HorizontalAlignment="Center"
VerticalAlignment="Center"/>
<TextBlock Text="{Binding FileName}"
TextWrapping="Wrap"
Margin="0,6,0,0"
FontSize="10"/>
<TextBlock FontFamily="{StaticResource FontAwesome}"
Text="{Binding SuggestedAction, Converter={StaticResource ActionToIconConverter}}"
FontSize="12"
HorizontalAlignment="Right"
Foreground="LimeGreen"/>
<TextBlock FontFamily="{StaticResource FontAwesome}"
Text="{Binding Rotate, Converter={StaticResource RotationAngleToIconConverter}}"
FontSize="12"
HorizontalAlignment="Left"
Foreground="LimeGreen"/>
</Grid>
</Border>
<ProgressBar MinWidth="160"
MaxWidth="160"
Height="10"
Margin="0,4,0,0"
Value="{Binding Progress.Percent}" />
</StackPanel>
</Border>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</ScrollViewer>
<TextBlock Text="{Binding FileName}"
TextWrapping="Wrap"
Margin="0,6,0,0"
FontSize="10"/>
<ProgressBar MinWidth="160"
MaxWidth="160"
Height="10"
Margin="0,4,0,0"
Value="{Binding Progress.Percent}" />
</StackPanel>
</Border>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</ScrollViewer>
</Grid>
</Border>
</UserControl>

View File

@ -12,8 +12,9 @@ x:DataType="vm:InspectorPaneViewModel">
<TextBlock Text="Parameters" FontSize="10" Margin="0,0,0,10" FontWeight="Bold"/>
<!-- InputFile -->
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Text="{Binding Selected.FileName}" Width="360" Margin="0,0,0,10" FontStyle="Italic"/>
<StackPanel Orientation="Vertical" Spacing="8">
<TextBlock Text="{Binding Selected.FileName}" Width="360" Margin="0,0,0,5" FontStyle="Italic"/>
<TextBlock Text="{Binding Selected.TextDesc}" Width="360" FontSize="10" Margin="0,0,0,10" FontWeight="Bold" Foreground="#676767"/>
</StackPanel>
<!-- Rotate -->
@ -113,13 +114,22 @@ x:DataType="vm:InspectorPaneViewModel">
<DataGrid ItemsSource="{Binding Selected.ParametersList}"
AutoGenerateColumns="False"
HeadersVisibility="Column"
Margin="0,0,20,0"
Height="160">
<DataGrid.Columns>
<DataGridTextColumn Header="Key"
Binding="{Binding Key}"
Width="*"/>
<DataGridTemplateColumn Header="Key" Width="*">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<TextBlock Text="{Binding Key}"
FontSize="10"
TextTrimming="CharacterEllipsis"
ToolTip.Tip="{Binding Key}">
</TextBlock>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
<DataGridTemplateColumn Header="Value" Width="2*">
<DataGridTemplateColumn.CellTemplate>

View File

@ -6,7 +6,7 @@
x:Class="Splitter_UI.Views.MainWindow"
x:DataType="vm:MainViewModel"
Width="1400"
Height="900"
Height="950"
Title="Splitter UI">
<DockPanel>
@ -20,7 +20,7 @@
DataContext="{Binding LogPane}" />
<!-- Main Content -->
<Grid ColumnDefinitions="2*,3*,2*">
<Grid ColumnDefinitions="2*,3*,430">
<!-- File List -->
<views:FileListView Grid.Column="0"

View File

@ -170,16 +170,7 @@ public class TrackingSplitter : LoggingBase, ISegmentProcessor, IDisposable
var ss = start .ToString("0.###", CultureInfo.InvariantCulture);
var t = length.ToString("0.###", CultureInfo.InvariantCulture);
var rotateStr = "";
if (rotate != null)
{
switch (rotate.Value)
{
case 90: rotateStr = ",transpose=1"; break;
case 180: rotateStr = ",transpose=PI"; break;
case 270: rotateStr = ",transpose=2"; break;
}
}
var rotateStr = GetRorationArg(rotate);
var args =
$"-i \"{inputFile}\" -ss {ss} -t {t} " +
@ -217,6 +208,22 @@ public class TrackingSplitter : LoggingBase, ISegmentProcessor, IDisposable
return p;
}
public static string GetRorationArg(int? rotate)
{
var rotateStr = "";
if (rotate != null)
{
switch (rotate.Value)
{
case 90: rotateStr = ",transpose=1"; break;
case 180: rotateStr = ",transpose=PI"; break;
case 270: rotateStr = ",transpose=2"; break;
}
}
return rotateStr;
}
private Process StartFfmpegEncode(
string inputFile,
string outputFile,