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); } }