mirror of
https://github.com/unclshura/splitter.git
synced 2026-06-22 00:22:01 +00:00
UI fixed: rotation support, ff/bb buttons on preview, inspector layout.
This commit is contained in:
parent
e566bb6137
commit
42408bba38
21
Splitter-UI/Converters/RotationAngleToIconConverter.cs
Normal file
21
Splitter-UI/Converters/RotationAngleToIconConverter.cs
Normal 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();
|
||||||
|
}
|
||||||
13
Splitter-UI/Converters/ZeroToBoolConverter.cs
Normal file
13
Splitter-UI/Converters/ZeroToBoolConverter.cs
Normal 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();
|
||||||
|
}
|
||||||
@ -5,5 +5,5 @@ namespace Splitter_UI.Services;
|
|||||||
|
|
||||||
public interface IThumbnailService
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -13,7 +13,13 @@ public sealed class ThumbnailService : IThumbnailService
|
|||||||
private readonly byte [] _bgrBuffer = new byte[_thumbWidth * _thumbHeight * 3];
|
private readonly byte [] _bgrBuffer = new byte[_thumbWidth * _thumbHeight * 3];
|
||||||
private readonly byte [] _bgraBuffer = new byte[_thumbWidth * _thumbHeight * 4];
|
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;
|
width ??= _thumbWidth;
|
||||||
height ??= _thumbHeight;
|
height ??= _thumbHeight;
|
||||||
@ -29,7 +35,7 @@ public sealed class ThumbnailService : IThumbnailService
|
|||||||
var bgraBuffer = canUseStaticBuffers ? _bgraBuffer : new byte[width.Value * height.Value * 4];
|
var bgraBuffer = canUseStaticBuffers ? _bgraBuffer : new byte[width.Value * height.Value * 4];
|
||||||
|
|
||||||
// Decode a single frame using ffmpeg → raw BGR24 into _bgrBuffer
|
// 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)
|
if (!ok)
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
@ -37,17 +43,19 @@ public sealed class ThumbnailService : IThumbnailService
|
|||||||
ConvertBgrToBgra(bgrBuffer, bgraBuffer, width.Value, height.Value);
|
ConvertBgrToBgra(bgrBuffer, bgraBuffer, width.Value, height.Value);
|
||||||
|
|
||||||
// Create Avalonia Bitmap
|
// 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
|
// ffmpeg command: decode one frame, resize, output raw BGR24
|
||||||
var args =
|
var args =
|
||||||
$"-ss {skip.TotalSeconds} -t 0.1 -i \"{file}\" " +
|
$"-ss {skip.TotalSeconds} -t 0.1 -i \"{file}\" " +
|
||||||
"-an -sn " +
|
"-an -sn " +
|
||||||
$"-vf \"scale={width}:{height}:force_original_aspect_ratio=decrease," +
|
$"-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 -";
|
"-f rawvideo -";
|
||||||
|
|
||||||
var psi = new ProcessStartInfo
|
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;
|
int stride = width * 4;
|
||||||
|
|
||||||
fixed (byte* p = bgra)
|
fixed (byte* p = bgra)
|
||||||
|
|||||||
@ -41,6 +41,12 @@ public partial class JobViewModel : ObservableObject
|
|||||||
|
|
||||||
public string FileName => Path.GetFileName(Job.InputFile);
|
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; }
|
public ObservableCollection<ParameterEntry> ParametersList { get; }
|
||||||
= new();
|
= new();
|
||||||
|
|
||||||
@ -105,6 +111,7 @@ public partial class JobViewModel : ObservableObject
|
|||||||
{
|
{
|
||||||
Job.Rotate = value;
|
Job.Rotate = value;
|
||||||
OnPropertyChanged();
|
OnPropertyChanged();
|
||||||
|
Task.Run(CreatePreview);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -145,7 +152,7 @@ public partial class JobViewModel : ObservableObject
|
|||||||
private async Task LoadThumbnailAsync()
|
private async Task LoadThumbnailAsync()
|
||||||
{
|
{
|
||||||
Probe = await _fileProbe.ProbeAsync(Job);
|
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";
|
SuggestedAction = Probe.Rotation == 0 ? "crop" : "rotate";
|
||||||
|
|
||||||
await CreatePreview();
|
await CreatePreview();
|
||||||
@ -157,9 +164,8 @@ public partial class JobViewModel : ObservableObject
|
|||||||
return;
|
return;
|
||||||
try
|
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);
|
Preview = new PreviewData(frame, [], null);
|
||||||
OnPropertyChanged(nameof(Preview));
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@ -197,21 +203,23 @@ public partial class JobViewModel : ObservableObject
|
|||||||
|
|
||||||
private void StepForward()
|
private void StepForward()
|
||||||
{
|
{
|
||||||
var step = 10.0; // seconds
|
|
||||||
if (DurationSeconds <= 0)
|
if (DurationSeconds <= 0)
|
||||||
return;
|
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
|
// trigger seek in your playback pipeline here
|
||||||
}
|
}
|
||||||
|
|
||||||
private void StepBackward()
|
private void StepBackward()
|
||||||
{
|
{
|
||||||
var step = 10.0; // seconds
|
|
||||||
if (DurationSeconds <= 0)
|
if (DurationSeconds <= 0)
|
||||||
return;
|
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
|
// trigger seek in your playback pipeline here
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -3,10 +3,15 @@
|
|||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:vm="clr-namespace:Splitter_UI.ViewModels"
|
xmlns:vm="clr-namespace:Splitter_UI.ViewModels"
|
||||||
xmlns:views="clr-namespace:Splitter_UI.Views"
|
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:Class="Splitter_UI.Views.FileListView"
|
||||||
x:DataType="vm:FileListViewModel">
|
x:DataType="vm:FileListViewModel">
|
||||||
|
|
||||||
|
<UserControl.Resources>
|
||||||
|
<conv:ZeroToBoolConverter x:Key="ZeroToBoolConverter"/>
|
||||||
|
<conv:RotationAngleToIconConverter x:Key="RotationAngleToIconConverter"/>
|
||||||
|
</UserControl.Resources>
|
||||||
|
|
||||||
<UserControl.Styles>
|
<UserControl.Styles>
|
||||||
<Style Selector="views|FileListView Border#DropZone">
|
<Style Selector="views|FileListView Border#DropZone">
|
||||||
<Setter Property="BorderBrush" Value="Transparent"/>
|
<Setter Property="BorderBrush" Value="Transparent"/>
|
||||||
@ -29,7 +34,17 @@
|
|||||||
DragDrop.DragLeave="OnDragLeave">
|
DragDrop.DragLeave="OnDragLeave">
|
||||||
|
|
||||||
|
|
||||||
|
<Grid>
|
||||||
|
|
||||||
|
<!-- Empty message -->
|
||||||
|
<TextBlock Text="Drag files here"
|
||||||
|
HorizontalAlignment="Center"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
FontSize="20"
|
||||||
|
Foreground="#666"
|
||||||
|
IsVisible="{Binding Files.Count, Converter={StaticResource ZeroToBoolConverter}}"/>
|
||||||
|
|
||||||
|
<!-- File list -->
|
||||||
<ScrollViewer>
|
<ScrollViewer>
|
||||||
<ListBox ItemsSource="{Binding Files}"
|
<ListBox ItemsSource="{Binding Files}"
|
||||||
SelectedItems="{Binding SelectedFiles}"
|
SelectedItems="{Binding SelectedFiles}"
|
||||||
@ -71,6 +86,11 @@
|
|||||||
FontSize="12"
|
FontSize="12"
|
||||||
HorizontalAlignment="Right"
|
HorizontalAlignment="Right"
|
||||||
Foreground="LimeGreen"/>
|
Foreground="LimeGreen"/>
|
||||||
|
<TextBlock FontFamily="{StaticResource FontAwesome}"
|
||||||
|
Text="{Binding Rotate, Converter={StaticResource RotationAngleToIconConverter}}"
|
||||||
|
FontSize="12"
|
||||||
|
HorizontalAlignment="Left"
|
||||||
|
Foreground="LimeGreen"/>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
@ -90,5 +110,6 @@
|
|||||||
</ListBox.ItemTemplate>
|
</ListBox.ItemTemplate>
|
||||||
</ListBox>
|
</ListBox>
|
||||||
</ScrollViewer>
|
</ScrollViewer>
|
||||||
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
</UserControl>
|
</UserControl>
|
||||||
|
|||||||
@ -12,8 +12,9 @@ x:DataType="vm:InspectorPaneViewModel">
|
|||||||
<TextBlock Text="Parameters" FontSize="10" Margin="0,0,0,10" FontWeight="Bold"/>
|
<TextBlock Text="Parameters" FontSize="10" Margin="0,0,0,10" FontWeight="Bold"/>
|
||||||
|
|
||||||
<!-- InputFile -->
|
<!-- InputFile -->
|
||||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
<StackPanel Orientation="Vertical" Spacing="8">
|
||||||
<TextBlock Text="{Binding Selected.FileName}" Width="360" Margin="0,0,0,10" FontStyle="Italic"/>
|
<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>
|
</StackPanel>
|
||||||
|
|
||||||
<!-- Rotate -->
|
<!-- Rotate -->
|
||||||
@ -113,13 +114,22 @@ x:DataType="vm:InspectorPaneViewModel">
|
|||||||
<DataGrid ItemsSource="{Binding Selected.ParametersList}"
|
<DataGrid ItemsSource="{Binding Selected.ParametersList}"
|
||||||
AutoGenerateColumns="False"
|
AutoGenerateColumns="False"
|
||||||
HeadersVisibility="Column"
|
HeadersVisibility="Column"
|
||||||
|
Margin="0,0,20,0"
|
||||||
Height="160">
|
Height="160">
|
||||||
|
|
||||||
<DataGrid.Columns>
|
<DataGrid.Columns>
|
||||||
|
|
||||||
<DataGridTextColumn Header="Key"
|
<DataGridTemplateColumn Header="Key" Width="*">
|
||||||
Binding="{Binding Key}"
|
<DataGridTemplateColumn.CellTemplate>
|
||||||
Width="*"/>
|
<DataTemplate>
|
||||||
|
<TextBlock Text="{Binding Key}"
|
||||||
|
FontSize="10"
|
||||||
|
TextTrimming="CharacterEllipsis"
|
||||||
|
ToolTip.Tip="{Binding Key}">
|
||||||
|
</TextBlock>
|
||||||
|
</DataTemplate>
|
||||||
|
</DataGridTemplateColumn.CellTemplate>
|
||||||
|
</DataGridTemplateColumn>
|
||||||
|
|
||||||
<DataGridTemplateColumn Header="Value" Width="2*">
|
<DataGridTemplateColumn Header="Value" Width="2*">
|
||||||
<DataGridTemplateColumn.CellTemplate>
|
<DataGridTemplateColumn.CellTemplate>
|
||||||
|
|||||||
@ -6,7 +6,7 @@
|
|||||||
x:Class="Splitter_UI.Views.MainWindow"
|
x:Class="Splitter_UI.Views.MainWindow"
|
||||||
x:DataType="vm:MainViewModel"
|
x:DataType="vm:MainViewModel"
|
||||||
Width="1400"
|
Width="1400"
|
||||||
Height="900"
|
Height="950"
|
||||||
Title="Splitter UI">
|
Title="Splitter UI">
|
||||||
|
|
||||||
<DockPanel>
|
<DockPanel>
|
||||||
@ -20,7 +20,7 @@
|
|||||||
DataContext="{Binding LogPane}" />
|
DataContext="{Binding LogPane}" />
|
||||||
|
|
||||||
<!-- Main Content -->
|
<!-- Main Content -->
|
||||||
<Grid ColumnDefinitions="2*,3*,2*">
|
<Grid ColumnDefinitions="2*,3*,430">
|
||||||
|
|
||||||
<!-- File List -->
|
<!-- File List -->
|
||||||
<views:FileListView Grid.Column="0"
|
<views:FileListView Grid.Column="0"
|
||||||
|
|||||||
@ -170,16 +170,7 @@ public class TrackingSplitter : LoggingBase, ISegmentProcessor, IDisposable
|
|||||||
var ss = start .ToString("0.###", CultureInfo.InvariantCulture);
|
var ss = start .ToString("0.###", CultureInfo.InvariantCulture);
|
||||||
var t = length.ToString("0.###", CultureInfo.InvariantCulture);
|
var t = length.ToString("0.###", CultureInfo.InvariantCulture);
|
||||||
|
|
||||||
var rotateStr = "";
|
var rotateStr = GetRorationArg(rotate);
|
||||||
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 args =
|
var args =
|
||||||
$"-i \"{inputFile}\" -ss {ss} -t {t} " +
|
$"-i \"{inputFile}\" -ss {ss} -t {t} " +
|
||||||
@ -217,6 +208,22 @@ public class TrackingSplitter : LoggingBase, ISegmentProcessor, IDisposable
|
|||||||
return p;
|
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(
|
private Process StartFfmpegEncode(
|
||||||
string inputFile,
|
string inputFile,
|
||||||
string outputFile,
|
string outputFile,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user