diff --git a/.vscode/launch.json b/.vscode/launch.json
index 32bdc5020..a1316c3be 100644
--- a/.vscode/launch.json
+++ b/.vscode/launch.json
@@ -10,7 +10,7 @@
"request": "launch",
"preLaunchTask": "build",
// If you have changed target frameworks, make sure to update the program path.
- "program": "${workspaceFolder}/src/Beutl/bin/Debug/net7.0/Beutl.dll",
+ "program": "${workspaceFolder}/src/Beutl/bin/Debug/net8.0/Beutl.dll",
"args": [],
"cwd": "${workspaceFolder}/src/Beutl",
// For more information about the 'console' field, see https://aka.ms/VSCode-CS-LaunchJson-Console
diff --git a/src/Beutl.Language/Strings.Designer.cs b/src/Beutl.Language/Strings.Designer.cs
index 8ec0c08b1..5c78f6cda 100644
--- a/src/Beutl.Language/Strings.Designer.cs
+++ b/src/Beutl.Language/Strings.Designer.cs
@@ -591,6 +591,15 @@ public static string Copy {
}
}
+ ///
+ /// Copy as image に類似しているローカライズされた文字列を検索します。
+ ///
+ public static string CopyAsImage {
+ get {
+ return ResourceManager.GetString("CopyAsImage", resourceCulture);
+ }
+ }
+
///
/// Corner Radius に類似しているローカライズされた文字列を検索します。
///
@@ -2158,6 +2167,15 @@ public static string SaveAs {
}
}
+ ///
+ /// Save as image に類似しているローカライズされた文字列を検索します。
+ ///
+ public static string SaveAsImage {
+ get {
+ return ResourceManager.GetString("SaveAsImage", resourceCulture);
+ }
+ }
+
///
/// Save a frame as an image に類似しているローカライズされた文字列を検索します。
///
diff --git a/src/Beutl.Language/Strings.ja.resx b/src/Beutl.Language/Strings.ja.resx
index d509c4d39..17fc01a37 100644
--- a/src/Beutl.Language/Strings.ja.resx
+++ b/src/Beutl.Language/Strings.ja.resx
@@ -1006,4 +1006,10 @@ b-editorがダウンロードURLを管理します。
カラーキー
+
+ 画像として保存
+
+
+ 画像としてコピー
+
\ No newline at end of file
diff --git a/src/Beutl.Language/Strings.resx b/src/Beutl.Language/Strings.resx
index 6df328563..7005cac37 100644
--- a/src/Beutl.Language/Strings.resx
+++ b/src/Beutl.Language/Strings.resx
@@ -1006,4 +1006,10 @@ and b-editor maintains the download URL.
Color key
+
+ Save as image
+
+
+ Copy as image
+
\ No newline at end of file
diff --git a/src/Beutl/Helpers/WindowsClipboard.cs b/src/Beutl/Helpers/WindowsClipboard.cs
new file mode 100644
index 000000000..e3fe754b2
--- /dev/null
+++ b/src/Beutl/Helpers/WindowsClipboard.cs
@@ -0,0 +1,152 @@
+using System.Runtime.InteropServices;
+
+using Beutl.Graphics;
+using Beutl.Media;
+using Beutl.Media.Pixel;
+
+namespace Beutl.Helpers;
+
+public static class WindowsClipboard
+{
+ private const string CopyImagePowerShellCode = """
+ Add-Type -AssemblyName System.Drawing
+ Add-Type -AssemblyName System.Windows.Forms
+
+ $data = New-Object Windows.Forms.DataObject
+ $pngstream = New-Object System.IO.MemoryStream
+ $dibstream = [System.IO.File]::OpenRead("{0}")
+ $image = New-Object System.Drawing.Bitmap("{1}")
+ $image.Save($pngstream, [System.Drawing.Imaging.ImageFormat]::Png)
+
+ $data.SetImage($image)
+ $data.SetData("PNG", $False, $pngstream)
+ $data.SetData([Windows.Forms.DataFormats]::Dib, $dibstream)
+
+ [Windows.Forms.Clipboard]::SetDataObject($data, $True)
+
+ $image.Dispose()
+ $pngstream.Dispose()
+ $dibstream.Dispose()
+ """;
+
+ public static async Task CopyImage(Bitmap image)
+ {
+ // pngファイルを作成
+ string pngFile = Path.GetTempFileName();
+ pngFile = Path.ChangeExtension(pngFile, "png");
+ string dibFile = Path.GetTempFileName();
+ string ps1File = Path.ChangeExtension(Path.GetTempFileName(), "ps1");
+
+ try
+ {
+ image.Save(pngFile, EncodedImageFormat.Png);
+
+ // dibファイルを作成
+ await File.WriteAllBytesAsync(dibFile, ConvertToDib(image));
+
+ // ps1ファイルを作成
+ File.WriteAllText(ps1File, string.Format(CopyImagePowerShellCode, dibFile, pngFile));
+
+
+ var startInfo = new ProcessStartInfo()
+ {
+ FileName = "powershell.exe",
+ Arguments = $"-NoProfile -ExecutionPolicy ByPass -File \"{ps1File}\"",
+ UseShellExecute = false,
+ CreateNoWindow = true
+ };
+ Process proc = Process.Start(startInfo) ?? throw new Exception("Failed to launch 'powershell.exe'.");
+ await proc.WaitForExitAsync();
+ }
+ finally
+ {
+ TryDeleteFile(pngFile);
+ TryDeleteFile(dibFile);
+ TryDeleteFile(ps1File);
+ }
+ }
+
+ private static void TryDeleteFile(string file)
+ {
+ try
+ {
+ File.Delete(file);
+ }
+ catch
+ {
+ }
+ }
+
+ public static byte[] ConvertToDib(Bitmap image)
+ {
+ byte[] bm32bData;
+ int width = image.Width;
+ int height = image.Height;
+ // Ensure image is 32bppARGB by painting it on a new 32bppARGB image.
+ using Bitmap bm32b = image.Clone();
+ bm32b.Flip(FlipMode.XY);
+ bm32bData = MemoryMarshal.AsBytes(bm32b.DataSpan).ToArray();
+
+ // BITMAPINFOHEADER struct for DIB.
+ const int hdrSize = 0x28;
+ byte[] fullImageArr = new byte[hdrSize + 12 + bm32bData.Length];
+ Span fullImage = fullImageArr;
+ //Int32 biSize;
+ BitConverter.TryWriteBytes(fullImage, (uint)hdrSize);
+ fullImage = fullImage.Slice(4);
+
+ //Int32 biWidth;
+ BitConverter.TryWriteBytes(fullImage, (uint)width);
+ fullImage = fullImage.Slice(4);
+
+ //Int32 biHeight;
+ BitConverter.TryWriteBytes(fullImage, (uint)height);
+ fullImage = fullImage.Slice(4);
+
+ //Int16 biPlanes;
+ BitConverter.TryWriteBytes(fullImage, (ushort)1);
+ fullImage = fullImage.Slice(2);
+
+ //Int16 biBitCount;
+ BitConverter.TryWriteBytes(fullImage, (ushort)32);
+ fullImage = fullImage.Slice(2);
+
+ //BITMAPCOMPRESSION biCompression = BITMAPCOMPRESSION.BITFIELDS;
+ BitConverter.TryWriteBytes(fullImage, (uint)3);
+ fullImage = fullImage.Slice(4);
+
+ //Int32 biSizeImage;
+ BitConverter.TryWriteBytes(fullImage, (uint)bm32bData.Length);
+ fullImage = fullImage.Slice(4);
+
+ // These are all 0. Since .net clears new arrays, don't bother writing them.
+ //Int32 biXPelsPerMeter = 0;
+ //Int32 biYPelsPerMeter = 0;
+ //Int32 biClrUsed = 0;
+ //Int32 biClrImportant = 0;
+ fullImage = fullImageArr;
+
+ // The aforementioned "BITFIELDS": colour masks applied to the Int32 pixel value to get the R, G and B values.
+ fullImage = fullImage.Slice(hdrSize);
+ BitConverter.TryWriteBytes(fullImage, 0x00FF0000);
+ fullImage = fullImage.Slice(4);
+ BitConverter.TryWriteBytes(fullImage, 0x0000FF00);
+ fullImage = fullImage.Slice(4);
+ BitConverter.TryWriteBytes(fullImage, 0x000000FF);
+
+ Array.Copy(bm32bData, 0, fullImageArr, hdrSize + 12, bm32bData.Length);
+ return fullImageArr;
+ }
+
+ public static void WriteIntToByteArray(byte[] data, int startIndex, int bytes, bool littleEndian, uint value)
+ {
+ int lastByte = bytes - 1;
+ if (data.Length < startIndex + bytes)
+ throw new ArgumentOutOfRangeException("startIndex", "Data array is too small to write a " + bytes + "-byte value at offset " + startIndex + ".");
+ for (int index = 0; index < bytes; index++)
+ {
+ int offs = startIndex + (littleEndian ? index : lastByte - index);
+ data[offs] = (byte)(value >> (8 * index) & 0xFF);
+ }
+ }
+}
diff --git a/src/Beutl/ViewModels/PlayerViewModel.cs b/src/Beutl/ViewModels/PlayerViewModel.cs
index 84c00461b..c18606591 100644
--- a/src/Beutl/ViewModels/PlayerViewModel.cs
+++ b/src/Beutl/ViewModels/PlayerViewModel.cs
@@ -142,6 +142,8 @@ public PlayerViewModel(EditViewModel editViewModel)
public ReactivePropertySlim IsHandMode { get; } = new(false);
+ public ReactivePropertySlim IsCropMode { get; } = new(false);
+
public ReactivePropertySlim FrameMatrix { get; } = new(Matrix.Identity);
public event EventHandler? PreviewInvalidated;
@@ -149,6 +151,8 @@ public PlayerViewModel(EditViewModel editViewModel)
// View側から設定
public Size MaxFrameSize { get; set; }
+ public Rect LastSelectedRect { get; set; }
+
public async void Play()
{
if (!_isEnabled.Value || Scene == null)
@@ -541,6 +545,17 @@ public void Dispose()
Scene = null!;
}
+ public async Task StartSelectRect()
+ {
+ TcsForCrop = new TaskCompletionSource();
+ IsCropMode.Value = true;
+ Rect r = await TcsForCrop.Task;
+ TcsForCrop = null;
+ return r;
+ }
+
+ public TaskCompletionSource? TcsForCrop { get; private set; }
+
public Task> DrawSelectedDrawable(Drawable drawable)
{
Pause();
diff --git a/src/Beutl/Views/Cursors.cs b/src/Beutl/Views/Cursors.cs
index 0ae75fe40..0be7c83f1 100644
--- a/src/Beutl/Views/Cursors.cs
+++ b/src/Beutl/Views/Cursors.cs
@@ -13,6 +13,7 @@ public static class Cursors
public static readonly Cursor DragMove = new(StandardCursorType.DragMove);
public static readonly Cursor DragCopy = new(StandardCursorType.DragCopy);
public static readonly Cursor DragLink = new(StandardCursorType.DragLink);
+ public static readonly Cursor Cross = new(StandardCursorType.Cross);
public static readonly Cursor Hand;
public static readonly Cursor HandGrab;
diff --git a/src/Beutl/Views/EditView.axaml b/src/Beutl/Views/EditView.axaml
index 1a3c5b577..aa53b53b2 100644
--- a/src/Beutl/Views/EditView.axaml
+++ b/src/Beutl/Views/EditView.axaml
@@ -122,13 +122,16 @@
-
+
+
+
+
diff --git a/src/Beutl/Views/EditView.axaml.MouseControl.cs b/src/Beutl/Views/EditView.axaml.MouseControl.cs
index 254ffdbb4..818bd3be3 100644
--- a/src/Beutl/Views/EditView.axaml.MouseControl.cs
+++ b/src/Beutl/Views/EditView.axaml.MouseControl.cs
@@ -1,20 +1,28 @@
using Avalonia.Controls;
using Avalonia.Input;
+using Avalonia.Platform.Storage;
using Beutl.Animation;
using Beutl.Commands;
using Beutl.Controls;
using Beutl.Graphics;
using Beutl.Graphics.Transformation;
+using Beutl.Helpers;
+using Beutl.Media;
+using Beutl.Media.Pixel;
using Beutl.ProjectSystem;
+using Beutl.Services;
using Beutl.ViewModels;
+using FluentAvalonia.UI.Controls;
+
using AvaImage = Avalonia.Controls.Image;
using AvaPoint = Avalonia.Point;
+using AvaRect = Avalonia.Rect;
namespace Beutl.Views;
-static file class CommandHelper
+file static class CommandHelper
{
public static IRecordableCommand? Compose(IRecordableCommand? first, IRecordableCommand? second)
{
@@ -373,6 +381,217 @@ public void OnPressed(PointerPressedEventArgs e)
}
}
+ private sealed class MouseControlCrop : IMouseControlHandler
+ {
+ private bool _pressed;
+ private AvaPoint _start;
+ private AvaPoint _position;
+ private AvaPoint _startInPanel;
+ private AvaPoint _positionInPanel;
+ private Border? _border;
+
+ public required Player Player { get; init; }
+
+ public required AvaImage Image { get; init; }
+
+ public required EditViewModel ViewModel { get; init; }
+
+ public void OnMoved(PointerEventArgs e)
+ {
+ if (_pressed)
+ {
+ _position = e.GetPosition(Image);
+ _positionInPanel = e.GetPosition(Player.GetFramePanel());
+ if (_border != null)
+ {
+ AvaRect rect = new AvaRect(_startInPanel, _positionInPanel).Normalize();
+ _border.Margin = new(rect.X, rect.Y, 0, 0);
+ _border.Width = rect.Width;
+ _border.Height = rect.Height;
+ }
+
+ e.Handled = true;
+ }
+ }
+
+ private static Bitmap CropFrame(Bitmap frame, Rect rect)
+ {
+ var pxRect = PixelRect.FromRect(rect);
+ var bounds = new PixelRect(0, 0, frame.Width, frame.Height);
+ if (bounds.Contains(pxRect))
+ {
+ return frame[pxRect];
+ }
+ else
+ {
+ PixelRect intersect = bounds.Intersect(pxRect);
+ using Bitmap intersectBitmap = frame[intersect];
+ var result = new Bitmap(pxRect.Width, pxRect.Height);
+
+ PixelPoint leftTop = intersect.Position - pxRect.Position;
+ result[new PixelRect(leftTop.X, leftTop.Y, intersect.Width, intersect.Height)] = intersectBitmap;
+
+ return result;
+ }
+ }
+
+ private async void OnCopyAsImageClicked(Rect rect)
+ {
+ try
+ {
+ EditViewModel viewModel = ViewModel;
+ Scene scene = ViewModel.Scene;
+ Task> renderTask = viewModel.Player.DrawFrame();
+
+ FilePickerSaveOptions options = SharedFilePickerOptions.SaveImage();
+
+ using Bitmap frame = await renderTask;
+ using Bitmap croped = CropFrame(frame, rect);
+
+ await WindowsClipboard.CopyImage(croped);
+ }
+ catch (Exception ex)
+ {
+ Telemetry.Exception(ex);
+ s_logger.Error(ex, "Failed to save image.");
+ NotificationService.ShowError(Message.Failed_to_save_image, ex.Message);
+ }
+ }
+
+ public void OnReleased(PointerReleasedEventArgs e)
+ {
+ if (_pressed)
+ {
+ float scale = ViewModel.Scene.Width / (float)Image.Bounds.Width;
+ Rect rect = new Rect(_start.ToBtlPoint() * scale, _position.ToBtlPoint() * scale).Normalize();
+
+ if (ViewModel.Player.TcsForCrop == null)
+ {
+ var copyAsString = new MenuFlyoutItem()
+ {
+ Text = Strings.Copy,
+ IconSource = new SymbolIconSource()
+ {
+ Symbol = Symbol.Copy
+ }
+ };
+ var saveAsImage = new MenuFlyoutItem()
+ {
+ Text = Strings.SaveAsImage,
+ IconSource = new SymbolIconSource()
+ {
+ Symbol = Symbol.SaveAs
+ }
+ };
+ copyAsString.Click += (s, e) =>
+ {
+ if (TopLevel.GetTopLevel(Player) is { Clipboard: { } clipboard })
+ {
+ clipboard.SetTextAsync(rect.ToString());
+ }
+ };
+ saveAsImage.Click += async (s, e) =>
+ {
+ if (TopLevel.GetTopLevel(Player)?.StorageProvider is { } storage)
+ {
+ try
+ {
+ EditViewModel viewModel = ViewModel;
+ Scene scene = ViewModel.Scene;
+ Task> renderTask = viewModel.Player.DrawFrame();
+
+ FilePickerSaveOptions options = SharedFilePickerOptions.SaveImage();
+ string addtional = Path.GetFileNameWithoutExtension(scene.FileName);
+ IStorageFile? file = await SaveImageFilePicker(addtional, storage);
+
+ if (file != null)
+ {
+ using Bitmap frame = await renderTask;
+ using Bitmap croped = CropFrame(frame, rect);
+
+ await SaveImage(file, croped);
+ }
+ }
+ catch (Exception ex)
+ {
+ Telemetry.Exception(ex);
+ s_logger.Error(ex, "Failed to save image.");
+ NotificationService.ShowError(Message.Failed_to_save_image, ex.Message);
+ }
+ }
+ };
+
+ var list = new List();
+ if (OperatingSystem.IsWindows())
+ {
+ var copyAsImage = new MenuFlyoutItem()
+ {
+ Text = Strings.CopyAsImage,
+ IconSource = new SymbolIconSource()
+ {
+ Symbol = Symbol.ImageCopy
+ }
+ };
+ copyAsImage.Click += (s, e) => OnCopyAsImageClicked(rect);
+
+ list.Add(copyAsImage);
+ }
+ list.AddRange([copyAsString, saveAsImage]);
+
+ var f = new FAMenuFlyout
+ {
+ ItemsSource = list
+ };
+
+ f.ShowAt(Player, true);
+ }
+ else
+ {
+ ViewModel.Player.TcsForCrop?.SetResult(rect);
+ }
+
+ ViewModel.Player.LastSelectedRect = rect;
+
+ if (_border != null)
+ {
+ Player.GetFramePanel().Children.Remove(_border);
+ _border = null;
+ }
+
+ _pressed = false;
+ }
+ }
+
+
+ public void OnPressed(PointerPressedEventArgs e)
+ {
+ PointerPoint pointerPoint = e.GetCurrentPoint(Image);
+ _pressed = pointerPoint.Properties.IsLeftButtonPressed;
+ _start = pointerPoint.Position;
+ Panel panel = Player.GetFramePanel();
+ _startInPanel = e.GetCurrentPoint(panel).Position;
+ if (_pressed)
+ {
+ _border = panel.Children.OfType().FirstOrDefault(x => x.Tag is nameof(MouseControlCrop));
+ if (_border == null)
+ {
+ _border = new()
+ {
+ Tag = nameof(MouseControlCrop),
+ BorderBrush = TimelineSharedObject.SelectionPen.Brush,
+ BorderThickness = new(0.5),
+ Background = TimelineSharedObject.SelectionFillBrush,
+ HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Left,
+ VerticalAlignment = Avalonia.Layout.VerticalAlignment.Top
+ };
+ panel.Children.Add(_border);
+ }
+
+ e.Handled = true;
+ }
+ }
+ }
+
private readonly WeakReference _lastSelected = new(null);
private IMouseControlHandler? _mouseState;
@@ -405,7 +624,7 @@ private IMouseControlHandler CreateMouseHandler(EditViewModel viewModel)
viewModel = viewModel
};
}
- else
+ else if (viewModel.Player.IsHandMode.Value)
{
return new MouseControlHand
{
@@ -414,6 +633,15 @@ private IMouseControlHandler CreateMouseHandler(EditViewModel viewModel)
viewModel = viewModel
};
}
+ else
+ {
+ return new MouseControlCrop
+ {
+ Player = Player,
+ Image = Image,
+ ViewModel = viewModel
+ };
+ }
}
private void OnFramePointerPressed(object? sender, PointerPressedEventArgs e)
diff --git a/src/Beutl/Views/EditView.axaml.cs b/src/Beutl/Views/EditView.axaml.cs
index e13bb8afa..a626d9c9b 100644
--- a/src/Beutl/Views/EditView.axaml.cs
+++ b/src/Beutl/Views/EditView.axaml.cs
@@ -313,10 +313,18 @@ protected override void OnDataContextChanged(EventArgs e)
})
.DisposeWith(_disposables);
- vm.Player.IsHandMode
+ vm.Player.IsHandMode.CombineLatest(vm.Player.IsCropMode)
.ObserveOnUIDispatcher()
.Where(_ => Player.GetFramePanel() != null)
- .Subscribe(v => Player.GetFramePanel().Cursor = v ? Cursors.Hand : null)
+ .Subscribe(t =>
+ {
+ if (t.First)
+ Player.GetFramePanel().Cursor = Cursors.Hand;
+ else if (t.Second)
+ Player.GetFramePanel().Cursor = Cursors.Cross;
+ else
+ Player.GetFramePanel().Cursor = null;
+ })
.DisposeWith(_disposables);
}
}