diff --git a/src/modules/AdvancedPaste/AdvancedPaste.UnitTests/Mocks/NoOpKernelQueryCacheService.cs b/src/modules/AdvancedPaste/AdvancedPaste.UnitTests/Mocks/NoOpKernelQueryCacheService.cs index e7ba121c13c6..0d0446dc926f 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste.UnitTests/Mocks/NoOpKernelQueryCacheService.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste.UnitTests/Mocks/NoOpKernelQueryCacheService.cs @@ -2,6 +2,7 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using System; using System.Threading.Tasks; using AdvancedPaste.Models.KernelQueryCache; diff --git a/src/modules/AdvancedPaste/AdvancedPaste.UnitTests/Mocks/NoOpProgress.cs b/src/modules/AdvancedPaste/AdvancedPaste.UnitTests/Mocks/NoOpProgress.cs new file mode 100644 index 000000000000..b5e55469a549 --- /dev/null +++ b/src/modules/AdvancedPaste/AdvancedPaste.UnitTests/Mocks/NoOpProgress.cs @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; + +namespace AdvancedPaste.UnitTests.Mocks; + +internal sealed class NoOpProgress : IProgress +{ + public void Report(double value) + { + } +} diff --git a/src/modules/AdvancedPaste/AdvancedPaste.UnitTests/ServicesTests/AIServiceBatchIntegrationTests.cs b/src/modules/AdvancedPaste/AdvancedPaste.UnitTests/ServicesTests/AIServiceBatchIntegrationTests.cs index 224ec9f99fa1..20aaf77709b7 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste.UnitTests/ServicesTests/AIServiceBatchIntegrationTests.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste.UnitTests/ServicesTests/AIServiceBatchIntegrationTests.cs @@ -8,6 +8,7 @@ using System.Linq; using System.Text.Json; using System.Text.Json.Serialization; +using System.Threading; using System.Threading.Tasks; using AdvancedPaste.Helpers; @@ -131,17 +132,18 @@ private static async Task GetOutputDataPackageAsync(BatchTestInput { VaultCredentialsProvider credentialsProvider = new(); PromptModerationService promptModerationService = new(credentialsProvider); + NoOpProgress progress = new(); CustomTextTransformService customTextTransformService = new(credentialsProvider, promptModerationService); switch (format) { case PasteFormats.CustomTextTransformation: - return DataPackageHelpers.CreateFromText(await customTextTransformService.TransformTextAsync(batchTestInput.Prompt, batchTestInput.Clipboard)); + return DataPackageHelpers.CreateFromText(await customTextTransformService.TransformTextAsync(batchTestInput.Prompt, batchTestInput.Clipboard, CancellationToken.None, progress)); case PasteFormats.KernelQuery: var clipboardData = DataPackageHelpers.CreateFromText(batchTestInput.Clipboard).GetView(); KernelService kernelService = new(new NoOpKernelQueryCacheService(), credentialsProvider, promptModerationService, customTextTransformService); - return await kernelService.TransformClipboardAsync(batchTestInput.Prompt, clipboardData, isSavedQuery: false); + return await kernelService.TransformClipboardAsync(batchTestInput.Prompt, clipboardData, isSavedQuery: false, CancellationToken.None, progress); default: throw new InvalidOperationException($"Unexpected format {format}"); diff --git a/src/modules/AdvancedPaste/AdvancedPaste.UnitTests/ServicesTests/KernelServiceIntegrationTests.cs b/src/modules/AdvancedPaste/AdvancedPaste.UnitTests/ServicesTests/KernelServiceIntegrationTests.cs index 14eb5100a8b7..998534cf5e89 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste.UnitTests/ServicesTests/KernelServiceIntegrationTests.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste.UnitTests/ServicesTests/KernelServiceIntegrationTests.cs @@ -6,6 +6,7 @@ using System.IO; using System.Linq; using System.Text.RegularExpressions; +using System.Threading; using System.Threading.Tasks; using AdvancedPaste.Helpers; @@ -130,7 +131,7 @@ private static async Task CreatePackageAsync(ClipboardFormat format private async Task GetKernelOutputAsync(string prompt, DataPackage input) { - var output = await _kernelService.TransformClipboardAsync(prompt, input.GetView(), isSavedQuery: false); + var output = await _kernelService.TransformClipboardAsync(prompt, input.GetView(), isSavedQuery: false, CancellationToken.None, new NoOpProgress()); Assert.AreEqual(1, _eventListener.SemanticKernelEvents.Count); Assert.IsTrue(_eventListener.SemanticKernelTokens > 0); diff --git a/src/modules/AdvancedPaste/AdvancedPaste.UnitTests/ServicesTests/TranscodeHelperIntegrationTests.cs b/src/modules/AdvancedPaste/AdvancedPaste.UnitTests/ServicesTests/TranscodeHelperIntegrationTests.cs new file mode 100644 index 000000000000..514cefeb830a --- /dev/null +++ b/src/modules/AdvancedPaste/AdvancedPaste.UnitTests/ServicesTests/TranscodeHelperIntegrationTests.cs @@ -0,0 +1,109 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +using AdvancedPaste.Helpers; +using AdvancedPaste.Models; +using AdvancedPaste.UnitTests.Mocks; +using ManagedCommon; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Windows.Storage; +using Windows.Storage.FileProperties; + +namespace AdvancedPaste.UnitTests.ServicesTests; + +[TestClass] +public sealed class TranscodeHelperIntegrationTests +{ + private sealed record class MediaProperties(BasicProperties Basic, MusicProperties Music, VideoProperties Video); + + private const string InputRootFolder = @"%USERPROFILE%\AdvancedPasteTranscodeMediaTestData"; + + /// Tests transforming a folder of media files. + /// - Verifies that the output file has the same basic properties (e.g. duration) as the input file. + /// - Copies the output file to a subfolder of the input folder for manual inspection. + /// + [TestMethod] + [DataRow(@"audio", PasteFormats.TranscodeToMp3)] + [DataRow(@"video", PasteFormats.TranscodeToMp4)] + public async Task TestTransformFolder(string inputSubfolder, PasteFormats format) + { + var inputFolder = Environment.ExpandEnvironmentVariables(Path.Combine(InputRootFolder, inputSubfolder)); + + if (!Directory.Exists(inputFolder)) + { + Assert.Inconclusive($"Skipping tests for {inputFolder} as it does not exist"); + } + + var outputPath = Path.Combine(inputFolder, $"test_output_{format}"); + + foreach (var inputPath in Directory.EnumerateFiles(inputFolder)) + { + await RunTestTransformFileAsync(inputPath, outputPath, format); + } + } + + private async Task RunTestTransformFileAsync(string inputPath, string finalOutputPath, PasteFormats format) + { + Logger.LogDebug($"Running {nameof(RunTestTransformFileAsync)} for {inputPath}/{format}"); + + Directory.CreateDirectory(finalOutputPath); + + var inputPackage = await DataPackageHelpers.CreateFromFileAsync(inputPath); + var inputProperties = await GetPropertiesAsync(await StorageFile.GetFileFromPathAsync(inputPath)); + + var outputPackage = await TransformHelpers.TransformAsync(format, inputPackage.GetView(), CancellationToken.None, new NoOpProgress()); + + var outputItems = await outputPackage.GetView().GetStorageItemsAsync(); + Assert.AreEqual(1, outputItems.Count); + var outputFile = outputItems.Single() as StorageFile; + Assert.IsNotNull(outputFile); + var outputProperties = await GetPropertiesAsync(outputFile); + AssertPropertiesMatch(format, inputProperties, outputProperties); + + await outputFile.CopyAsync(await StorageFolder.GetFolderFromPathAsync(finalOutputPath), outputFile.Name, NameCollisionOption.ReplaceExisting); + await outputPackage.GetView().TryCleanupAfterDelayAsync(TimeSpan.Zero); + } + + private static void AssertPropertiesMatch(PasteFormats format, MediaProperties inputProperties, MediaProperties outputProperties) + { + Assert.IsTrue(outputProperties.Basic.Size > 0); + + Assert.AreEqual(inputProperties.Music.Title, outputProperties.Music.Title); + Assert.AreEqual(inputProperties.Music.Album, outputProperties.Music.Album); + Assert.AreEqual(inputProperties.Music.Artist, outputProperties.Music.Artist); + AssertDurationsApproxEqual(inputProperties.Music.Duration, outputProperties.Music.Duration); + + if (format == PasteFormats.TranscodeToMp4) + { + Assert.AreEqual(inputProperties.Video.Title, outputProperties.Video.Title); + AssertDurationsApproxEqual(inputProperties.Video.Duration, outputProperties.Video.Duration); + + var inputVideoDimensions = GetNormalizedDimensions(inputProperties.Video); + if (inputVideoDimensions != null) + { + Assert.AreEqual(inputVideoDimensions, GetNormalizedDimensions(outputProperties.Video)); + } + } + } + + private static async Task GetPropertiesAsync(StorageFile file) => + new(await file.GetBasicPropertiesAsync(), await file.Properties.GetMusicPropertiesAsync(), await file.Properties.GetVideoPropertiesAsync()); + + private static void AssertDurationsApproxEqual(TimeSpan expected, TimeSpan actual) => + Assert.AreEqual(expected.Ticks, actual.Ticks, delta: TimeSpan.FromSeconds(1).Ticks); + + /// + /// Gets the dimensions of a video, if available. Accounts for the fact that the dimensions may sometimes be swapped. + /// + private static (uint Width, uint Height)? GetNormalizedDimensions(VideoProperties properties) => + properties.Width == 0 || properties.Height == 0 + ? null + : (Math.Max(properties.Width, properties.Height), Math.Min(properties.Width, properties.Height)); +} diff --git a/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Controls/AnimatedContentControl/AnimatedContentControl.xaml b/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Controls/AnimatedContentControl/AnimatedContentControl.xaml index 00d672c6f0a1..fd42be2b3ba8 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Controls/AnimatedContentControl/AnimatedContentControl.xaml +++ b/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Controls/AnimatedContentControl/AnimatedContentControl.xaml @@ -28,6 +28,7 @@ Background="Transparent" BorderThickness="4" CornerRadius="{TemplateBinding CornerRadius}" + IsHitTestVisible="False" Visibility="Collapsed"> diff --git a/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Controls/PromptBox.xaml b/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Controls/PromptBox.xaml index 69f675d4cb06..dd09c717b064 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Controls/PromptBox.xaml +++ b/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Controls/PromptBox.xaml @@ -178,17 +178,36 @@ Padding="0" HorizontalAlignment="Stretch" VerticalAlignment="Stretch"> - - + + + + + + + + + + + diff --git a/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Controls/PromptBox.xaml.cs b/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Controls/PromptBox.xaml.cs index 3383af5292aa..19c0fd8ce67e 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Controls/PromptBox.xaml.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Controls/PromptBox.xaml.cs @@ -55,9 +55,9 @@ public PromptBox() private void ViewModel_PropertyChanged(object sender, PropertyChangedEventArgs e) { - if (e.PropertyName == nameof(ViewModel.Busy) || e.PropertyName == nameof(ViewModel.PasteActionError)) + if (e.PropertyName is nameof(ViewModel.IsBusy) or nameof(ViewModel.PasteActionError)) { - var state = ViewModel.Busy ? "LoadingState" : ViewModel.PasteActionError.HasText ? "ErrorState" : "DefaultState"; + var state = ViewModel.IsBusy ? "LoadingState" : ViewModel.PasteActionError.HasText ? "ErrorState" : "DefaultState"; VisualStateManager.GoToState(this, state, true); } } @@ -78,6 +78,9 @@ private void Grid_Loaded(object sender, RoutedEventArgs e) [RelayCommand] private async Task GenerateCustomAIAsync() => await ViewModel.ExecuteCustomAIFormatFromCurrentQueryAsync(PasteActionSource.PromptBox); + [RelayCommand] + private async Task CancelPasteActionAsync() => await ViewModel.CancelPasteActionAsync(); + private async void InputTxtBox_KeyDown(object sender, Microsoft.UI.Xaml.Input.KeyRoutedEventArgs e) { if (e.Key == Windows.System.VirtualKey.Enter && InputTxtBox.Text.Length > 0 && ViewModel.IsCustomAIAvailable) diff --git a/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/MainWindow.xaml.cs b/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/MainWindow.xaml.cs index 7743e6764bb2..7d90cf62dce0 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/MainWindow.xaml.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/MainWindow.xaml.cs @@ -24,6 +24,7 @@ public sealed partial class MainWindow : WindowEx, IDisposable { private readonly WindowMessageMonitor _msgMonitor; private readonly IUserSettings _userSettings; + private readonly OptionsViewModel _optionsViewModel; private bool _disposedValue; @@ -32,8 +33,7 @@ public MainWindow() InitializeComponent(); _userSettings = App.GetService(); - - var optionsViewModel = App.GetService(); + _optionsViewModel = App.GetService(); var baseHeight = MinHeight; var coreActionCount = PasteFormat.MetadataDict.Values.Count(metadata => metadata.IsCoreAction); @@ -43,7 +43,7 @@ void UpdateHeight() double GetHeight(int maxCustomActionCount) => baseHeight + new PasteFormatsToHeightConverter().GetHeight(coreActionCount + _userSettings.AdditionalActions.Count) + - new PasteFormatsToHeightConverter() { MaxItems = maxCustomActionCount }.GetHeight(optionsViewModel.IsCustomAIServiceEnabled ? _userSettings.CustomActions.Count : 0); + new PasteFormatsToHeightConverter() { MaxItems = maxCustomActionCount }.GetHeight(_optionsViewModel.IsCustomAIServiceEnabled ? _userSettings.CustomActions.Count : 0); MinHeight = GetHeight(1); Height = GetHeight(5); @@ -52,9 +52,9 @@ double GetHeight(int maxCustomActionCount) => UpdateHeight(); _userSettings.Changed += (_, _) => UpdateHeight(); - optionsViewModel.PropertyChanged += (_, e) => + _optionsViewModel.PropertyChanged += (_, e) => { - if (e.PropertyName == nameof(optionsViewModel.IsCustomAIServiceEnabled)) + if (e.PropertyName == nameof(_optionsViewModel.IsCustomAIServiceEnabled)) { UpdateHeight(); } @@ -111,8 +111,9 @@ public void Dispose() GC.SuppressFinalize(this); } - private void WindowEx_Closed(object sender, Microsoft.UI.Xaml.WindowEventArgs args) + private async void WindowEx_Closed(object sender, Microsoft.UI.Xaml.WindowEventArgs args) { + await _optionsViewModel.CancelPasteActionAsync(); Hide(); args.Handled = true; } diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Helpers/DataPackageHelpers.cs b/src/modules/AdvancedPaste/AdvancedPaste/Helpers/DataPackageHelpers.cs index 4aacd0c1153c..529773f9a6d5 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/Helpers/DataPackageHelpers.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste/Helpers/DataPackageHelpers.cs @@ -4,10 +4,14 @@ using System; using System.Collections.Generic; +using System.IO; using System.Linq; +using System.Text; using System.Threading.Tasks; using AdvancedPaste.Models; +using ManagedCommon; +using Microsoft.Win32; using Windows.ApplicationModel.DataTransfer; using Windows.Data.Html; using Windows.Graphics.Imaging; @@ -18,8 +22,6 @@ namespace AdvancedPaste.Helpers; internal static class DataPackageHelpers { - private static readonly Lazy> ImageFileTypes = new(GetImageFileTypes()); - private static readonly (string DataFormat, ClipboardFormat ClipboardFormat)[] DataFormats = [ (StandardDataFormats.Text, ClipboardFormat.Text), @@ -27,6 +29,14 @@ private static readonly (string DataFormat, ClipboardFormat ClipboardFormat)[] D (StandardDataFormats.Bitmap, ClipboardFormat.Image), ]; + private static readonly Lazy<(ClipboardFormat Format, HashSet FileTypes)[]> SupportedFileTypes = + new(() => + [ + (ClipboardFormat.Image, GetImageFileTypes()), + (ClipboardFormat.Audio, GetMediaFileTypes("audio")), + (ClipboardFormat.Video, GetMediaFileTypes("video")), + ]); + internal static DataPackage CreateFromText(string text) { DataPackage dataPackage = new(); @@ -57,9 +67,12 @@ internal static async Task GetAvailableFormatsAsync(this DataPa { availableFormats |= ClipboardFormat.File; - if (ImageFileTypes.Value.Contains(file.FileType)) + foreach (var (format, fileTypes) in SupportedFileTypes.Value) { - availableFormats |= ClipboardFormat.Image; + if (fileTypes.Contains(file.FileType)) + { + availableFormats |= format; + } } } } @@ -93,6 +106,60 @@ internal static async Task HasUsableDataAsync(this DataPackageView dataPac return availableFormats == ClipboardFormat.Text ? !string.IsNullOrEmpty(await dataPackageView.GetTextAsync()) : availableFormats != ClipboardFormat.None; } + internal static async Task TryCleanupAfterDelayAsync(this DataPackageView dataPackageView, TimeSpan delay) + { + try + { + var tempFile = await GetSingleTempFileOrNullAsync(dataPackageView); + + if (tempFile != null) + { + await Task.Delay(delay); + + Logger.LogDebug($"Cleaning up temporary file with extension [{tempFile.Extension}] from data package after delay"); + + tempFile.Delete(); + if (NormalizeDirectoryPath(tempFile.Directory?.Parent?.FullName) == NormalizeDirectoryPath(Path.GetTempPath())) + { + tempFile.Directory?.Delete(); + } + } + } + catch (Exception ex) + { + Logger.LogError("Failed to clean up temporary files", ex); + } + } + + private static async Task GetSingleTempFileOrNullAsync(this DataPackageView dataPackageView) + { + if (!dataPackageView.Contains(StandardDataFormats.StorageItems)) + { + return null; + } + + var storageItems = await dataPackageView.GetStorageItemsAsync(); + + if (storageItems.Count != 1 || storageItems.Single() is not StorageFile file) + { + return null; + } + + FileInfo fileInfo = new(file.Path); + var tempPathDirectory = NormalizeDirectoryPath(Path.GetTempPath()); + + var directoryPaths = new[] { fileInfo.Directory, fileInfo.Directory?.Parent } + .Where(directory => directory != null) + .Select(directory => NormalizeDirectoryPath(directory.FullName)); + + return directoryPaths.Contains(NormalizeDirectoryPath(Path.GetTempPath())) ? fileInfo : null; + } + + private static string NormalizeDirectoryPath(string path) => + Path.GetFullPath(new Uri(path).LocalPath) + .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + .ToUpperInvariant(); + internal static async Task GetTextOrEmptyAsync(this DataPackageView dataPackageView) => dataPackageView.Contains(StandardDataFormats.Text) ? await dataPackageView.GetTextAsync() : string.Empty; @@ -153,4 +220,27 @@ private static HashSet GetImageFileTypes() => BitmapDecoder.GetDecoderInformationEnumerator() .SelectMany(di => di.FileExtensions) .ToHashSet(StringComparer.InvariantCultureIgnoreCase); + + private static HashSet GetMediaFileTypes(string mediaKind) + { + static string AssocQueryString(NativeMethods.AssocStr assocStr, string extension) + { + uint pcchOut = 0; + + NativeMethods.AssocQueryString(NativeMethods.AssocF.None, assocStr, extension, null, null, ref pcchOut); + + StringBuilder pszOut = new((int)pcchOut); + var hResult = NativeMethods.AssocQueryString(NativeMethods.AssocF.None, assocStr, extension, null, pszOut, ref pcchOut); + return hResult == NativeMethods.HResult.Ok ? pszOut.ToString() : string.Empty; + } + + var comparison = StringComparison.OrdinalIgnoreCase; + var extensions = from extension in Registry.ClassesRoot.GetSubKeyNames() + where extension.StartsWith('.') + where AssocQueryString(NativeMethods.AssocStr.PerceivedType, extension).Equals(mediaKind, comparison) || + AssocQueryString(NativeMethods.AssocStr.ContentType, extension).StartsWith($"{mediaKind}/", comparison) + select extension; + + return extensions.ToHashSet(StringComparer.InvariantCultureIgnoreCase); + } } diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Helpers/KernelExtensions.cs b/src/modules/AdvancedPaste/AdvancedPaste/Helpers/KernelExtensions.cs index 2708e2ca6946..886d24d721d2 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/Helpers/KernelExtensions.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste/Helpers/KernelExtensions.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; +using System.Threading; using System.Threading.Tasks; using AdvancedPaste.Models; @@ -17,6 +18,8 @@ internal static class KernelExtensions private const string DataPackageKey = "DataPackage"; private const string LastErrorKey = "LastError"; private const string ActionChainKey = "ActionChain"; + private const string CancellationTokenKey = "CancellationToken"; + private const string ProgressKey = "Progress"; internal static DataPackageView GetDataPackageView(this Kernel kernel) { @@ -40,6 +43,14 @@ internal static async Task GetDataFormatsAsync(this Kernel kernel) internal static void SetDataPackageView(this Kernel kernel, DataPackageView dataPackageView) => kernel.Data[DataPackageKey] = dataPackageView; + internal static CancellationToken GetCancellationToken(this Kernel kernel) => kernel.Data.TryGetValue(CancellationTokenKey, out object value) ? (CancellationToken)value : CancellationToken.None; + + internal static void SetCancellationToken(this Kernel kernel, CancellationToken cancellationToken) => kernel.Data[CancellationTokenKey] = cancellationToken; + + internal static IProgress GetProgress(this Kernel kernel) => kernel.Data.TryGetValue(ProgressKey, out object obj) ? obj as IProgress : null; + + internal static void SetProgress(this Kernel kernel, IProgress progress) => kernel.Data[ProgressKey] = progress; + internal static Exception GetLastError(this Kernel kernel) => kernel.Data.TryGetValue(LastErrorKey, out object obj) ? obj as Exception : null; internal static void SetLastError(this Kernel kernel, Exception error) => kernel.Data[LastErrorKey] = error; diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Helpers/NativeMethods.cs b/src/modules/AdvancedPaste/AdvancedPaste/Helpers/NativeMethods.cs index a28626ca1f21..6e53e9b61890 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/Helpers/NativeMethods.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste/Helpers/NativeMethods.cs @@ -4,6 +4,7 @@ using System; using System.Runtime.InteropServices; +using System.Text; namespace AdvancedPaste.Helpers { @@ -83,6 +84,68 @@ internal enum KeyEventF Scancode = 0x0008, } + public enum HResult + { + Ok = 0x0000, + False = 0x0001, + InvalidArguments = unchecked((int)0x80070057), + OutOfMemory = unchecked((int)0x8007000E), + NoInterface = unchecked((int)0x80004002), + Fail = unchecked((int)0x80004005), + ExtractionFailed = unchecked((int)0x8004B200), + ElementNotFound = unchecked((int)0x80070490), + TypeElementNotFound = unchecked((int)0x8002802B), + NoObject = unchecked((int)0x800401E5), + Win32ErrorCanceled = 1223, + Canceled = unchecked((int)0x800704C7), + ResourceInUse = unchecked((int)0x800700AA), + AccessDenied = unchecked((int)0x80030005), + } + + [Flags] + public enum AssocF + { + None = 0, + Init_NoRemapCLSID = 0x1, + Init_ByExeName = 0x2, + Open_ByExeName = 0x3, + Init_DefaultToStar = 0x4, + Init_DefaultToFolder = 0x8, + NoUserSettings = 0x10, + NoTruncate = 0x20, + Verify = 0x40, + RemapRunDll = 0x80, + NoFixUps = 0x100, + IgnoreBaseClass = 0x200, + } + + public enum AssocStr + { + Command = 1, + Executable, + FriendlyDocName, + FriendlyAppName, + NoOpen, + ShellNewValue, + DDECommand, + DDEIfExec, + DDEApplication, + DDETopic, + InfoTip, + QuickTip, + TileInfo, + ContentType, + DefaultIcon, + ShellExtension, + PerceivedType, + DelegateExecute, + SupportedUriProtocols, + ProgId, + AppId, + AppPublisher, + AppIconReference, + } + [DllImport("user32.dll")] internal static extern uint SendInput(uint nInputs, INPUT[] pInputs, int cbSize); @@ -100,5 +163,8 @@ internal struct PointInter [DllImport("user32.dll")] internal static extern bool GetCursorPos(out PointInter lpPoint); + + [DllImport("Shlwapi.dll", SetLastError = true, CharSet = CharSet.Unicode)] + internal static extern HResult AssocQueryString(AssocF flags, AssocStr str, string pszAssoc, string pszExtra, [Out] StringBuilder pszOut, [In][Out] ref uint pcchOut); } } diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Helpers/OcrHelpers.cs b/src/modules/AdvancedPaste/AdvancedPaste/Helpers/OcrHelpers.cs index 1ed0665f9d9b..b56868ece8cb 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/Helpers/OcrHelpers.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste/Helpers/OcrHelpers.cs @@ -4,6 +4,7 @@ using System; using System.Linq; +using System.Threading; using System.Threading.Tasks; using Windows.Globalization; @@ -15,11 +16,14 @@ namespace AdvancedPaste.Helpers; public static class OcrHelpers { - public static async Task ExtractTextAsync(SoftwareBitmap bitmap) + public static async Task ExtractTextAsync(SoftwareBitmap bitmap, CancellationToken cancellationToken) { var ocrLanguage = GetOCRLanguage() ?? throw new InvalidOperationException("Unable to determine OCR language"); + cancellationToken.ThrowIfCancellationRequested(); var ocrEngine = OcrEngine.TryCreateFromLanguage(ocrLanguage) ?? throw new InvalidOperationException("Unable to create OCR engine"); + cancellationToken.ThrowIfCancellationRequested(); + var ocrResult = await ocrEngine.RecognizeAsync(bitmap); return string.IsNullOrWhiteSpace(ocrResult.Text) diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Helpers/TranscodeHelpers.cs b/src/modules/AdvancedPaste/AdvancedPaste/Helpers/TranscodeHelpers.cs new file mode 100644 index 000000000000..f526bb7f64ac --- /dev/null +++ b/src/modules/AdvancedPaste/AdvancedPaste/Helpers/TranscodeHelpers.cs @@ -0,0 +1,149 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +using AdvancedPaste.Models; +using ManagedCommon; +using Windows.ApplicationModel.DataTransfer; +using Windows.Media.MediaProperties; +using Windows.Media.Transcoding; +using Windows.Storage; + +namespace AdvancedPaste.Helpers; + +internal static class TranscodeHelpers +{ + public static async Task TranscodeToMp3Async(DataPackageView clipboardData, CancellationToken cancellationToken, IProgress progress) => + await TranscodeMediaAsync(clipboardData, MediaEncodingProfile.CreateMp3(AudioEncodingQuality.High), ".mp3", cancellationToken, progress); + + public static async Task TranscodeToMp4Async(DataPackageView clipboardData, CancellationToken cancellationToken, IProgress progress) => + await TranscodeMediaAsync(clipboardData, MediaEncodingProfile.CreateMp4(VideoEncodingQuality.HD1080p), ".mp4", cancellationToken, progress); + + private static async Task TranscodeMediaAsync(DataPackageView clipboardData, MediaEncodingProfile baseOutputProfile, string extension, CancellationToken cancellationToken, IProgress progress) + { + Logger.LogTrace(); + + var inputFiles = await clipboardData.GetStorageItemsAsync(); + + if (inputFiles.Count != 1) + { + throw new InvalidOperationException($"{nameof(TranscodeMediaAsync)} does not support multiple files"); + } + + var inputFile = inputFiles.Single() as StorageFile ?? throw new InvalidOperationException($"{nameof(TranscodeMediaAsync)} only supports files"); + var inputFileNameWithoutExtension = Path.GetFileNameWithoutExtension(inputFile.Path); + + var inputProfile = await MediaEncodingProfile.CreateFromFileAsync(inputFile); + var outputProfile = CreateOutputProfile(inputProfile, baseOutputProfile); + +#if DEBUG + static string ProfileToString(MediaEncodingProfile profile) => System.Text.Json.JsonSerializer.Serialize(profile, options: new() { WriteIndented = true }); + Logger.LogDebug($"{nameof(inputProfile)}: {ProfileToString(inputProfile)}"); + Logger.LogDebug($"{nameof(outputProfile)}: {ProfileToString(outputProfile)}"); +#endif + + var outputFolder = await Task.Run(() => Directory.CreateTempSubdirectory("PowerToys_AdvancedPaste_"), cancellationToken); + var outputFileName = StringComparer.OrdinalIgnoreCase.Equals(Path.GetExtension(inputFile.Path), extension) ? inputFileNameWithoutExtension + "_1" : inputFileNameWithoutExtension; + var outputFilePath = Path.Combine(outputFolder.FullName, Path.ChangeExtension(outputFileName, extension)); + await File.WriteAllBytesAsync(outputFilePath, [], cancellationToken); // TranscodeAsync seems to require the output file to exist + + await TranscodeMediaAsync(inputFile, await StorageFile.GetFileFromPathAsync(outputFilePath), outputProfile, cancellationToken, progress); + + return await DataPackageHelpers.CreateFromFileAsync(outputFilePath); + } + + private static MediaEncodingProfile CreateOutputProfile(MediaEncodingProfile inputProfile, MediaEncodingProfile baseOutputProfile) + { + MediaEncodingProfile outputProfile = new() + { + Video = null, + Audio = null, + }; + + outputProfile.Container = baseOutputProfile.Container.Copy(); + + if (inputProfile.Video != null && baseOutputProfile.Video != null) + { + outputProfile.Video = baseOutputProfile.Video.Copy(); + + if (inputProfile.Video.Bitrate != 0) + { + outputProfile.Video.Bitrate = inputProfile.Video.Bitrate; + } + + if (inputProfile.Video.FrameRate.Numerator != 0) + { + outputProfile.Video.FrameRate.Numerator = inputProfile.Video.FrameRate.Numerator; + } + + if (inputProfile.Video.FrameRate.Denominator != 0) + { + outputProfile.Video.FrameRate.Denominator = inputProfile.Video.FrameRate.Denominator; + } + + if (inputProfile.Video.PixelAspectRatio.Numerator != 0) + { + outputProfile.Video.PixelAspectRatio.Numerator = inputProfile.Video.PixelAspectRatio.Numerator; + } + + if (inputProfile.Video.PixelAspectRatio.Denominator != 0) + { + outputProfile.Video.PixelAspectRatio.Denominator = inputProfile.Video.PixelAspectRatio.Denominator; + } + + outputProfile.Video.Width = inputProfile.Video.Width; + outputProfile.Video.Height = inputProfile.Video.Height; + } + + if (inputProfile.Audio != null && baseOutputProfile.Audio != null) + { + outputProfile.Audio = baseOutputProfile.Audio.Copy(); + + if (inputProfile.Audio.Bitrate != 0) + { + outputProfile.Audio.Bitrate = inputProfile.Audio.Bitrate; + } + + if (inputProfile.Audio.BitsPerSample != 0) + { + outputProfile.Audio.BitsPerSample = inputProfile.Audio.BitsPerSample; + } + + if (inputProfile.Audio.ChannelCount != 0) + { + outputProfile.Audio.ChannelCount = inputProfile.Audio.ChannelCount; + } + + if (inputProfile.Audio.SampleRate != 0) + { + outputProfile.Audio.SampleRate = inputProfile.Audio.SampleRate; + } + } + + return outputProfile; + } + + private static async Task TranscodeMediaAsync(StorageFile inputFile, StorageFile outputFile, MediaEncodingProfile outputProfile, CancellationToken cancellationToken, IProgress progress) + { + if (outputProfile.Video == null && outputProfile.Audio == null) + { + throw new InvalidOperationException("Target profile does not contain media"); + } + + var prepareOp = await new MediaTranscoder().PrepareFileTranscodeAsync(inputFile, outputFile, outputProfile); + + if (!prepareOp.CanTranscode) + { + var message = ResourceLoaderInstance.ResourceLoader.GetString(prepareOp.FailureReason == TranscodeFailureReason.CodecNotFound ? "TranscodeErrorUnsupportedCodec" : "TranscodeErrorGeneral"); + throw new PasteActionException(message, new InvalidOperationException($"Error transcoding; {nameof(prepareOp.FailureReason)}={prepareOp.FailureReason}")); + } + + await prepareOp.TranscodeAsync().AsTask(cancellationToken, progress); + } +} diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Helpers/TransformHelpers.cs b/src/modules/AdvancedPaste/AdvancedPaste/Helpers/TransformHelpers.cs index 2c8f442cd7d0..fe66ba6cbbfb 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/Helpers/TransformHelpers.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste/Helpers/TransformHelpers.cs @@ -5,6 +5,7 @@ using System; using System.Globalization; using System.IO; +using System.Threading; using System.Threading.Tasks; using AdvancedPaste.Models; @@ -17,17 +18,19 @@ namespace AdvancedPaste.Helpers; public static class TransformHelpers { - public static async Task TransformAsync(PasteFormats format, DataPackageView clipboardData) + public static async Task TransformAsync(PasteFormats format, DataPackageView clipboardData, CancellationToken cancellationToken, IProgress progress) { return format switch { PasteFormats.PlainText => await ToPlainTextAsync(clipboardData), PasteFormats.Markdown => await ToMarkdownAsync(clipboardData), PasteFormats.Json => await ToJsonAsync(clipboardData), - PasteFormats.ImageToText => await ImageToTextAsync(clipboardData), - PasteFormats.PasteAsTxtFile => await ToTxtFileAsync(clipboardData), - PasteFormats.PasteAsPngFile => await ToPngFileAsync(clipboardData), - PasteFormats.PasteAsHtmlFile => await ToHtmlFileAsync(clipboardData), + PasteFormats.ImageToText => await ImageToTextAsync(clipboardData, cancellationToken), + PasteFormats.PasteAsTxtFile => await ToTxtFileAsync(clipboardData, cancellationToken), + PasteFormats.PasteAsPngFile => await ToPngFileAsync(clipboardData, cancellationToken), + PasteFormats.PasteAsHtmlFile => await ToHtmlFileAsync(clipboardData, cancellationToken), + PasteFormats.TranscodeToMp3 => await TranscodeHelpers.TranscodeToMp3Async(clipboardData, cancellationToken, progress), + PasteFormats.TranscodeToMp4 => await TranscodeHelpers.TranscodeToMp4Async(clipboardData, cancellationToken, progress), PasteFormats.KernelQuery => throw new ArgumentException($"Unsupported format {format}", nameof(format)), PasteFormats.CustomTextTransformation => throw new ArgumentException($"Unsupported format {format}", nameof(format)), _ => throw new ArgumentException($"Unknown value {format}", nameof(format)), @@ -52,16 +55,16 @@ private static async Task ToJsonAsync(DataPackageView clipboardData return CreateDataPackageFromText(await JsonHelper.ToJsonFromXmlOrCsvAsync(clipboardData)); } - private static async Task ImageToTextAsync(DataPackageView clipboardData) + private static async Task ImageToTextAsync(DataPackageView clipboardData, CancellationToken cancellationToken) { Logger.LogTrace(); var bitmap = await clipboardData.GetImageContentAsync() ?? throw new ArgumentException("No image content found in clipboard", nameof(clipboardData)); - var text = await OcrHelpers.ExtractTextAsync(bitmap); + var text = await OcrHelpers.ExtractTextAsync(bitmap, cancellationToken); return CreateDataPackageFromText(text); } - private static async Task ToPngFileAsync(DataPackageView clipboardData) + private static async Task ToPngFileAsync(DataPackageView clipboardData, CancellationToken cancellationToken) { Logger.LogTrace(); @@ -72,25 +75,25 @@ private static async Task ToPngFileAsync(DataPackageView clipboardD encoder.SetSoftwareBitmap(clipboardBitmap); await encoder.FlushAsync(); - return await CreateDataPackageFromFileContentAsync(pngStream.AsStreamForRead(), "png"); + return await CreateDataPackageFromFileContentAsync(pngStream.AsStreamForRead(), "png", cancellationToken); } - private static async Task ToTxtFileAsync(DataPackageView clipboardData) + private static async Task ToTxtFileAsync(DataPackageView clipboardData, CancellationToken cancellationToken) { Logger.LogTrace(); var text = await clipboardData.GetTextOrHtmlTextAsync(); - return await CreateDataPackageFromFileContentAsync(text, "txt"); + return await CreateDataPackageFromFileContentAsync(text, "txt", cancellationToken); } - private static async Task ToHtmlFileAsync(DataPackageView clipboardData) + private static async Task ToHtmlFileAsync(DataPackageView clipboardData, CancellationToken cancellationToken) { Logger.LogTrace(); var cfHtml = await clipboardData.GetHtmlContentAsync(); var html = RemoveHtmlMetadata(cfHtml); - return await CreateDataPackageFromFileContentAsync(html, "html"); + return await CreateDataPackageFromFileContentAsync(html, "html", cancellationToken); } /// @@ -114,7 +117,7 @@ private static string RemoveHtmlMetadata(string cfHtml) return (startFragmentIndex == null || endFragmentIndex == null) ? cfHtml : cfHtml[startFragmentIndex.Value..endFragmentIndex.Value]; } - private static async Task CreateDataPackageFromFileContentAsync(string data, string fileExtension) + private static async Task CreateDataPackageFromFileContentAsync(string data, string fileExtension, CancellationToken cancellationToken) { if (string.IsNullOrEmpty(data)) { @@ -123,16 +126,16 @@ private static async Task CreateDataPackageFromFileContentAsync(str var path = GetPasteAsFileTempFilePath(fileExtension); - await File.WriteAllTextAsync(path, data); + await File.WriteAllTextAsync(path, data, cancellationToken); return await DataPackageHelpers.CreateFromFileAsync(path); } - private static async Task CreateDataPackageFromFileContentAsync(Stream stream, string fileExtension) + private static async Task CreateDataPackageFromFileContentAsync(Stream stream, string fileExtension, CancellationToken cancellationToken) { var path = GetPasteAsFileTempFilePath(fileExtension); using var fileStream = File.Create(path); - await stream.CopyToAsync(fileStream); + await stream.CopyToAsync(fileStream, cancellationToken); return await DataPackageHelpers.CreateFromFileAsync(path); } diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Helpers/UserSettings.cs b/src/modules/AdvancedPaste/AdvancedPaste/Helpers/UserSettings.cs index 70a4cf0f9e4d..8a25b70f07ea 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/Helpers/UserSettings.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste/Helpers/UserSettings.cs @@ -108,7 +108,9 @@ void UpdateSettings() (PasteFormats.ImageToText, [sourceAdditionalActions.ImageToText]), (PasteFormats.PasteAsTxtFile, [sourceAdditionalActions.PasteAsFile, sourceAdditionalActions.PasteAsFile.PasteAsTxtFile]), (PasteFormats.PasteAsPngFile, [sourceAdditionalActions.PasteAsFile, sourceAdditionalActions.PasteAsFile.PasteAsPngFile]), - (PasteFormats.PasteAsHtmlFile, [sourceAdditionalActions.PasteAsFile, sourceAdditionalActions.PasteAsFile.PasteAsHtmlFile]) + (PasteFormats.PasteAsHtmlFile, [sourceAdditionalActions.PasteAsFile, sourceAdditionalActions.PasteAsFile.PasteAsHtmlFile]), + (PasteFormats.TranscodeToMp3, [sourceAdditionalActions.Transcode, sourceAdditionalActions.Transcode.TranscodeToMp3]), + (PasteFormats.TranscodeToMp4, [sourceAdditionalActions.Transcode, sourceAdditionalActions.Transcode.TranscodeToMp4]), ]; _additionalActions.Clear(); diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Models/ClipboardFormat.cs b/src/modules/AdvancedPaste/AdvancedPaste/Models/ClipboardFormat.cs index 63c935b63ed6..af445e85b045 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/Models/ClipboardFormat.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste/Models/ClipboardFormat.cs @@ -13,6 +13,7 @@ public enum ClipboardFormat Text = 1 << 0, Html = 1 << 1, Audio = 1 << 2, - Image = 1 << 3, - File = 1 << 4, // output only for now + Video = 1 << 3, + Image = 1 << 4, + File = 1 << 5, // output only for now } diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Models/PasteActionError.cs b/src/modules/AdvancedPaste/AdvancedPaste/Models/PasteActionError.cs index 6ec9a49028f0..701ffe54bb18 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/Models/PasteActionError.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste/Models/PasteActionError.cs @@ -30,7 +30,7 @@ public static PasteActionError FromResourceId(string resourceId) => public static PasteActionError FromException(Exception ex) => new() { - Text = ex is PasteActionException ? ex.Message : ResourceLoaderInstance.ResourceLoader.GetString("PasteError"), + Text = ex is PasteActionException ? ex.Message : ResourceLoaderInstance.ResourceLoader.GetString(ex is OperationCanceledException ? "PasteActionCanceled" : "PasteError"), Details = (ex as PasteActionException)?.AIServiceMessage ?? string.Empty, }; } diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Models/PasteFormats.cs b/src/modules/AdvancedPaste/AdvancedPaste/Models/PasteFormats.cs index 588f10250629..99243ebb5efa 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/Models/PasteFormats.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste/Models/PasteFormats.cs @@ -82,12 +82,34 @@ public enum PasteFormats KernelFunctionDescription = "Takes HTML data in the clipboard and transforms it to an HTML file.")] PasteAsHtmlFile, + [PasteFormatMetadata( + IsCoreAction = false, + ResourceId = "TranscodeToMp3", + IconGlyph = "\uE8D6", + RequiresAIService = false, + CanPreview = false, + SupportedClipboardFormats = ClipboardFormat.Audio | ClipboardFormat.Video, + IPCKey = AdvancedPasteTranscodeAction.PropertyNames.TranscodeToMp3, + KernelFunctionDescription = "Takes an audio or video file in the clipboard and transcodes it to MP3.")] + TranscodeToMp3, + + [PasteFormatMetadata( + IsCoreAction = false, + ResourceId = "TranscodeToMp4", + IconGlyph = "\uE714", + RequiresAIService = false, + CanPreview = false, + SupportedClipboardFormats = ClipboardFormat.Video, + IPCKey = AdvancedPasteTranscodeAction.PropertyNames.TranscodeToMp4, + KernelFunctionDescription = "Takes a video file in the clipboard and transcodes it to MP4 (H.264/AAC).")] + TranscodeToMp4, + [PasteFormatMetadata( IsCoreAction = false, IconGlyph = "\uE945", RequiresAIService = true, CanPreview = true, - SupportedClipboardFormats = ClipboardFormat.Text | ClipboardFormat.Html | ClipboardFormat.Audio | ClipboardFormat.Image, + SupportedClipboardFormats = ClipboardFormat.Text | ClipboardFormat.Html | ClipboardFormat.Audio | ClipboardFormat.Video | ClipboardFormat.Image, RequiresPrompt = true)] KernelQuery, diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Services/ICustomTextTransformService.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/ICustomTextTransformService.cs index 800f7b04161a..75f1df259e8c 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/Services/ICustomTextTransformService.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste/Services/ICustomTextTransformService.cs @@ -2,11 +2,13 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using System; +using System.Threading; using System.Threading.Tasks; namespace AdvancedPaste.Services; public interface ICustomTextTransformService { - Task TransformTextAsync(string prompt, string inputText); + Task TransformTextAsync(string prompt, string inputText, CancellationToken cancellationToken, IProgress progress); } diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Services/IKernelService.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/IKernelService.cs index ae99fccf443a..beb62fb293d2 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/Services/IKernelService.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste/Services/IKernelService.cs @@ -2,6 +2,8 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using System; +using System.Threading; using System.Threading.Tasks; using Windows.ApplicationModel.DataTransfer; @@ -10,5 +12,5 @@ namespace AdvancedPaste.Services; public interface IKernelService { - Task TransformClipboardAsync(string prompt, DataPackageView clipboardData, bool isSavedQuery); + Task TransformClipboardAsync(string prompt, DataPackageView clipboardData, bool isSavedQuery, CancellationToken cancellationToken, IProgress progress); } diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Services/IPasteFormatExecutor.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/IPasteFormatExecutor.cs index 9df354e3d185..3b3237faffee 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/Services/IPasteFormatExecutor.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste/Services/IPasteFormatExecutor.cs @@ -2,6 +2,8 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using System; +using System.Threading; using System.Threading.Tasks; using AdvancedPaste.Models; @@ -11,5 +13,5 @@ namespace AdvancedPaste.Services; public interface IPasteFormatExecutor { - Task ExecutePasteFormatAsync(PasteFormat pasteFormat, PasteActionSource source); + Task ExecutePasteFormatAsync(PasteFormat pasteFormat, PasteActionSource source, CancellationToken cancellationToken, IProgress progress); } diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Services/IPromptModerationService.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/IPromptModerationService.cs index bd7963ac7829..f80b8d30ab7d 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/Services/IPromptModerationService.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste/Services/IPromptModerationService.cs @@ -2,11 +2,12 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using System.Threading; using System.Threading.Tasks; namespace AdvancedPaste.Services; public interface IPromptModerationService { - Task ValidateAsync(string fullPrompt); + Task ValidateAsync(string fullPrompt, CancellationToken cancellationToken); } diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Services/KernelServiceBase.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/KernelServiceBase.cs index c988d2f8ced8..e921b21e54a0 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/Services/KernelServiceBase.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste/Services/KernelServiceBase.cs @@ -6,6 +6,7 @@ using System.Collections.Generic; using System.Linq; using System.Text.Json; +using System.Threading; using System.Threading.Tasks; using AdvancedPaste.Helpers; @@ -36,12 +37,14 @@ public abstract class KernelServiceBase(IKernelQueryCacheService queryCacheServi protected abstract AIServiceUsage GetAIServiceUsage(ChatMessageContent chatMessage); - public async Task TransformClipboardAsync(string prompt, DataPackageView clipboardData, bool isSavedQuery) + public async Task TransformClipboardAsync(string prompt, DataPackageView clipboardData, bool isSavedQuery, CancellationToken cancellationToken, IProgress progress) { Logger.LogTrace(); var kernel = CreateKernel(); kernel.SetDataPackageView(clipboardData); + kernel.SetCancellationToken(cancellationToken); + kernel.SetProgress(progress); CacheKey cacheKey = new() { Prompt = prompt, AvailableFormats = await clipboardData.GetAvailableFormatsAsync() }; var maybeCacheValue = _queryCacheService.ReadOrNull(cacheKey); @@ -51,7 +54,7 @@ public async Task TransformClipboardAsync(string prompt, DataPackag try { - (chatHistory, var usage) = cacheUsed ? await ExecuteCachedActionChain(kernel, maybeCacheValue.ActionChain) : await ExecuteAICompletion(kernel, prompt); + (chatHistory, var usage) = cacheUsed ? await ExecuteCachedActionChain(kernel, maybeCacheValue.ActionChain) : await ExecuteAICompletion(kernel, prompt, cancellationToken); LogResult(cacheUsed, isSavedQuery, kernel.GetOrAddActionChain(), usage); @@ -84,7 +87,7 @@ public async Task TransformClipboardAsync(string prompt, DataPackag AdvancedPasteSemanticKernelErrorEvent errorEvent = new(ex is PasteActionModeratedException ? PasteActionModeratedException.ErrorDescription : ex.Message); PowerToysTelemetry.Log.WriteEvent(errorEvent); - if (ex is PasteActionException) + if (ex is PasteActionException or OperationCanceledException) { throw; } @@ -127,7 +130,7 @@ private static string GetFullPrompt(ChatHistory initialHistory) return $"{combinedSystemMessage}{newLine}{newLine}User instructions:{newLine}{userPromptMessage.Content}"; } - private async Task<(ChatHistory ChatHistory, AIServiceUsage Usage)> ExecuteAICompletion(Kernel kernel, string prompt) + private async Task<(ChatHistory ChatHistory, AIServiceUsage Usage)> ExecuteAICompletion(Kernel kernel, string prompt, CancellationToken cancellationToken) { ChatHistory chatHistory = []; @@ -141,10 +144,10 @@ The user will put in a request to format their clipboard data and you will fulfi chatHistory.AddSystemMessage($"Available clipboard formats: {await kernel.GetDataFormatsAsync()}"); chatHistory.AddUserMessage(prompt); - await _promptModerationService.ValidateAsync(GetFullPrompt(chatHistory)); + await _promptModerationService.ValidateAsync(GetFullPrompt(chatHistory), cancellationToken); var chatResult = await kernel.GetRequiredService() - .GetChatMessageContentAsync(chatHistory, PromptExecutionSettings, kernel); + .GetChatMessageContentAsync(chatHistory, PromptExecutionSettings, kernel, cancellationToken); chatHistory.Add(chatResult); var totalUsage = chatHistory.Select(GetAIServiceUsage) @@ -157,6 +160,8 @@ The user will put in a request to format their clipboard data and you will fulfi { foreach (var item in actionChain) { + kernel.GetCancellationToken().ThrowIfCancellationRequested(); + if (item.Arguments.Count > 0) { await ExecutePromptTransformAsync(kernel, item.Format, item.Arguments[PromptParameterName]); @@ -208,14 +213,14 @@ private Task ExecutePromptTransformAsync(Kernel kernel, PasteFormats for async dataPackageView => { var input = await dataPackageView.GetTextAsync(); - string output = await GetPromptBasedOutput(format, prompt, input); + string output = await GetPromptBasedOutput(format, prompt, input, kernel.GetCancellationToken(), kernel.GetProgress()); return DataPackageHelpers.CreateFromText(output); }); - private async Task GetPromptBasedOutput(PasteFormats format, string prompt, string input) => + private async Task GetPromptBasedOutput(PasteFormats format, string prompt, string input, CancellationToken cancellationToken, IProgress progress) => format switch { - PasteFormats.CustomTextTransformation => await _customTextTransformService.TransformTextAsync(prompt, input), + PasteFormats.CustomTextTransformation => await _customTextTransformService.TransformTextAsync(prompt, input, cancellationToken, progress), _ => throw new ArgumentException($"Unsupported format {format} for prompt transform", nameof(format)), }; @@ -223,7 +228,7 @@ private Task ExecuteStandardTransformAsync(Kernel kernel, PasteFormats f ExecuteTransformAsync( kernel, new ActionChainItem(format, Arguments: []), - async dataPackageView => await TransformHelpers.TransformAsync(format, dataPackageView)); + async dataPackageView => await TransformHelpers.TransformAsync(format, dataPackageView, kernel.GetCancellationToken(), kernel.GetProgress())); private static async Task ExecuteTransformAsync(Kernel kernel, ActionChainItem actionChainItem, Func> transformFunc) { diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Services/OpenAI/CustomTextTransformService.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/OpenAI/CustomTextTransformService.cs index 95823d8d246f..b6aa156b9df1 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/Services/OpenAI/CustomTextTransformService.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste/Services/OpenAI/CustomTextTransformService.cs @@ -4,6 +4,7 @@ using System; using System.Text.Json; +using System.Threading; using System.Threading.Tasks; using AdvancedPaste.Helpers; @@ -23,11 +24,11 @@ public sealed class CustomTextTransformService(IAICredentialsProvider aiCredenti private readonly IAICredentialsProvider _aiCredentialsProvider = aiCredentialsProvider; private readonly IPromptModerationService _promptModerationService = promptModerationService; - private async Task GetAICompletionAsync(string systemInstructions, string userMessage) + private async Task GetAICompletionAsync(string systemInstructions, string userMessage, CancellationToken cancellationToken) { var fullPrompt = systemInstructions + "\n\n" + userMessage; - await _promptModerationService.ValidateAsync(fullPrompt); + await _promptModerationService.ValidateAsync(fullPrompt, cancellationToken); OpenAIClient azureAIClient = new(_aiCredentialsProvider.Key); @@ -41,7 +42,8 @@ private async Task GetAICompletionAsync(string systemInstructions, }, Temperature = 0.01F, MaxTokens = 2000, - }); + }, + cancellationToken); if (response.Value.Choices[0].FinishReason == "length") { @@ -51,7 +53,7 @@ private async Task GetAICompletionAsync(string systemInstructions, return response; } - public async Task TransformTextAsync(string prompt, string inputText) + public async Task TransformTextAsync(string prompt, string inputText, CancellationToken cancellationToken, IProgress progress) { if (string.IsNullOrWhiteSpace(prompt)) { @@ -80,7 +82,7 @@ public async Task TransformTextAsync(string prompt, string inputText) try { - var response = await GetAICompletionAsync(systemInstructions, userMessage); + var response = await GetAICompletionAsync(systemInstructions, userMessage, cancellationToken); var usage = response.Usage; AdvancedPasteGenerateCustomFormatEvent telemetryEvent = new(usage.PromptTokens, usage.CompletionTokens, ModelName); @@ -98,7 +100,7 @@ public async Task TransformTextAsync(string prompt, string inputText) AdvancedPasteGenerateCustomErrorEvent errorEvent = new(ex is PasteActionModeratedException ? PasteActionModeratedException.ErrorDescription : ex.Message); PowerToysTelemetry.Log.WriteEvent(errorEvent); - if (ex is PasteActionException) + if (ex is PasteActionException or OperationCanceledException) { throw; } diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Services/OpenAI/PromptModerationService.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/OpenAI/PromptModerationService.cs index e78a44b533a1..0ca15e4161de 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/Services/OpenAI/PromptModerationService.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste/Services/OpenAI/PromptModerationService.cs @@ -3,6 +3,7 @@ // See the LICENSE file in the project root for more information. using System.ClientModel; +using System.Threading; using System.Threading.Tasks; using AdvancedPaste.Helpers; @@ -18,12 +19,12 @@ public sealed class PromptModerationService(IAICredentialsProvider aiCredentials private readonly IAICredentialsProvider _aiCredentialsProvider = aiCredentialsProvider; - public async Task ValidateAsync(string fullPrompt) + public async Task ValidateAsync(string fullPrompt, CancellationToken cancellationToken) { try { ModerationClient moderationClient = new(ModelName, _aiCredentialsProvider.Key); - var moderationClientResult = await moderationClient.ClassifyTextAsync(fullPrompt); + var moderationClientResult = await moderationClient.ClassifyTextAsync(fullPrompt, cancellationToken); var moderationResult = moderationClientResult.Value; Logger.LogDebug($"{nameof(PromptModerationService)}.{nameof(ValidateAsync)} complete; {nameof(moderationResult.Flagged)}={moderationResult.Flagged}"); diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Services/PasteFormatExecutor.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/PasteFormatExecutor.cs index e7e7f9b4cf89..5d6740977bb5 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/Services/PasteFormatExecutor.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste/Services/PasteFormatExecutor.cs @@ -3,6 +3,7 @@ // See the LICENSE file in the project root for more information. using System; +using System.Threading; using System.Threading.Tasks; using AdvancedPaste.Helpers; @@ -17,7 +18,7 @@ public sealed class PasteFormatExecutor(IKernelService kernelService, ICustomTex private readonly IKernelService _kernelService = kernelService; private readonly ICustomTextTransformService _customTextTransformService = customTextTransformService; - public async Task ExecutePasteFormatAsync(PasteFormat pasteFormat, PasteActionSource source) + public async Task ExecutePasteFormatAsync(PasteFormat pasteFormat, PasteActionSource source, CancellationToken cancellationToken, IProgress progress) { if (!pasteFormat.IsEnabled) { @@ -34,9 +35,9 @@ public async Task ExecutePasteFormatAsync(PasteFormat pasteFormat, return await Task.Run(async () => pasteFormat.Format switch { - PasteFormats.KernelQuery => await _kernelService.TransformClipboardAsync(pasteFormat.Prompt, clipboardData, pasteFormat.IsSavedQuery), - PasteFormats.CustomTextTransformation => DataPackageHelpers.CreateFromText(await _customTextTransformService.TransformTextAsync(pasteFormat.Prompt, await clipboardData.GetTextAsync())), - _ => await TransformHelpers.TransformAsync(format, clipboardData), + PasteFormats.KernelQuery => await _kernelService.TransformClipboardAsync(pasteFormat.Prompt, clipboardData, pasteFormat.IsSavedQuery, cancellationToken, progress), + PasteFormats.CustomTextTransformation => DataPackageHelpers.CreateFromText(await _customTextTransformService.TransformTextAsync(pasteFormat.Prompt, await clipboardData.GetTextAsync(), cancellationToken, progress)), + _ => await TransformHelpers.TransformAsync(format, clipboardData, cancellationToken, progress), }); } diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Strings/en-us/Resources.resw b/src/modules/AdvancedPaste/AdvancedPaste/Strings/en-us/Resources.resw index 1c2839d06424..30b46190e329 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/Strings/en-us/Resources.resw +++ b/src/modules/AdvancedPaste/AdvancedPaste/Strings/en-us/Resources.resw @@ -135,6 +135,9 @@ OpenAI request failed with status code: + + The paste operation was canceled + An error occurred during the paste operation @@ -188,7 +191,19 @@ Paste as .html file + + + Transcode to .mp3 + + Transcode to .mp4 (H.264/AAC) + + + An error occurred while transcoding media file + + + The media file contains an unsupported codec + Paste @@ -207,6 +222,9 @@ Generate and paste data + + Cancel paste operation + Regenerate @@ -216,6 +234,9 @@ Generate and paste data + + Cancel paste operation + Open settings diff --git a/src/modules/AdvancedPaste/AdvancedPaste/ViewModels/OptionsViewModel.cs b/src/modules/AdvancedPaste/AdvancedPaste/ViewModels/OptionsViewModel.cs index 30e4a6f359eb..688c3047e236 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/ViewModels/OptionsViewModel.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste/ViewModels/OptionsViewModel.cs @@ -8,6 +8,8 @@ using System.Diagnostics; using System.IO.Abstractions; using System.Linq; +using System.Runtime.InteropServices; +using System.Threading; using System.Threading.Tasks; using AdvancedPaste.Helpers; @@ -29,7 +31,7 @@ namespace AdvancedPaste.ViewModels { - public sealed partial class OptionsViewModel : ObservableObject, IDisposable + public sealed partial class OptionsViewModel : ObservableObject, IProgress, IDisposable { private readonly DispatcherQueue _dispatcherQueue = DispatcherQueue.GetForCurrentThread(); private readonly DispatcherTimer _clipboardTimer; @@ -37,6 +39,8 @@ public sealed partial class OptionsViewModel : ObservableObject, IDisposable private readonly IPasteFormatExecutor _pasteFormatExecutor; private readonly IAICredentialsProvider _aiCredentialsProvider; + private CancellationTokenSource _pasteActionCancellationTokenSource; + public DataPackageView ClipboardData { get; set; } [ObservableProperty] @@ -65,7 +69,11 @@ public sealed partial class OptionsViewModel : ObservableObject, IDisposable private bool _pasteFormatsDirty; [ObservableProperty] - private bool _busy; + private bool _isBusy; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HasIndeterminateTransformProgress))] + private double _transformProgress = double.NaN; public ObservableCollection StandardPasteFormats { get; } = []; @@ -81,9 +89,24 @@ public sealed partial class OptionsViewModel : ObservableObject, IDisposable public bool ClipboardHasDataForCustomAI => PasteFormat.SupportsClipboardFormats(CustomAIFormat, AvailableClipboardFormats); + public bool HasIndeterminateTransformProgress => double.IsNaN(TransformProgress); + private PasteFormats CustomAIFormat => _userSettings.IsAdvancedAIEnabled ? PasteFormats.KernelQuery : PasteFormats.CustomTextTransformation; - private bool Visible => GetMainWindow()?.Visible is true; + private bool Visible + { + get + { + try + { + return GetMainWindow()?.Visible is true; + } + catch (COMException) + { + return false; // window is closed + } + } + } public event EventHandler PreviewRequested; @@ -189,7 +212,12 @@ orderby pasteFormat.IsEnabled descending void UpdateFormats(ObservableCollection collection, IEnumerable pasteFormats) { - collection.Clear(); + // Hack: Clear collection via repeated RemoveAt to avoid this crash, which seems to occasionally occur when using Clear: + // https://github.com/microsoft/microsoft-ui-xaml/issues/8684 + while (collection.Count > 0) + { + collection.RemoveAt(collection.Count - 1); + } foreach (var format in FilterAndSort(pasteFormats)) { @@ -214,12 +242,13 @@ void UpdateFormats(ObservableCollection collection, IEnumerable package.GetView().TryCleanupAfterDelayAsync(TimeSpan.FromSeconds(30))); } // Command to select the previous custom format @@ -362,7 +395,7 @@ internal async Task ExecutePasteFormatAsync(PasteFormats format, PasteActionSour internal async Task ExecutePasteFormatAsync(PasteFormat pasteFormat, PasteActionSource source) { - if (Busy) + if (IsBusy) { Logger.LogWarning($"Execution of {pasteFormat.Format} from {source} suppressed as busy"); return; @@ -377,16 +410,18 @@ internal async Task ExecutePasteFormatAsync(PasteFormat pasteFormat, PasteAction var elapsedWatch = Stopwatch.StartNew(); Logger.LogDebug($"Started executing {pasteFormat.Format} from source {source}"); - Busy = true; + IsBusy = true; + _pasteActionCancellationTokenSource = new(); + TransformProgress = double.NaN; PasteActionError = PasteActionError.None; Query = pasteFormat.Query; try { // Minimum time to show busy spinner for AI actions when triggered by global keyboard shortcut. - var aiActionMinTaskTime = TimeSpan.FromSeconds(2); + var aiActionMinTaskTime = TimeSpan.FromSeconds(1.5); var delayTask = (Visible && source == PasteActionSource.GlobalKeyboardShortcut) ? Task.Delay(aiActionMinTaskTime) : Task.CompletedTask; - var dataPackage = await _pasteFormatExecutor.ExecutePasteFormatAsync(pasteFormat, source); + var dataPackage = await _pasteFormatExecutor.ExecutePasteFormatAsync(pasteFormat, source, _pasteActionCancellationTokenSource.Token, this); await delayTask; @@ -410,7 +445,9 @@ internal async Task ExecutePasteFormatAsync(PasteFormat pasteFormat, PasteAction PasteActionError = PasteActionError.FromException(ex); } - Busy = false; + IsBusy = false; + _pasteActionCancellationTokenSource?.Dispose(); + _pasteActionCancellationTokenSource = null; elapsedWatch.Stop(); Logger.LogDebug($"Finished executing {pasteFormat.Format} from source {source}; timeTakenMs={elapsedWatch.ElapsedMilliseconds}"); } @@ -484,5 +521,26 @@ private bool UpdateOpenAIKey() return IsAllowedByGPO && _aiCredentialsProvider.Refresh(); } + + public async Task CancelPasteActionAsync() + { + if (_pasteActionCancellationTokenSource != null) + { + await _pasteActionCancellationTokenSource.CancelAsync(); + } + } + + void IProgress.Report(double value) + { + ReportProgress(value); + } + + private void ReportProgress(double value) + { + _dispatcherQueue.TryEnqueue(() => + { + TransformProgress = value; + }); + } } } diff --git a/src/modules/AdvancedPaste/AdvancedPasteModuleInterface/trace.cpp b/src/modules/AdvancedPaste/AdvancedPasteModuleInterface/trace.cpp index c5af4f231e89..c6b1bfa0a9cd 100644 --- a/src/modules/AdvancedPaste/AdvancedPasteModuleInterface/trace.cpp +++ b/src/modules/AdvancedPaste/AdvancedPasteModuleInterface/trace.cpp @@ -88,6 +88,8 @@ void Trace::AdvancedPaste_SettingsTelemetry(const PowertoyModuleIface::Hotkey& p TraceLoggingWideString(getAdditionalActionHotkeyCStr(L"ImageToText"), "ImageToTextHotkey"), TraceLoggingWideString(getAdditionalActionHotkeyCStr(L"PasteAsTxtFile"), "PasteAsTxtFileHotkey"), TraceLoggingWideString(getAdditionalActionHotkeyCStr(L"PasteAsPngFile"), "PasteAsPngFileHotkey"), - TraceLoggingWideString(getAdditionalActionHotkeyCStr(L"PasteAsHtmlFile"), "PasteAsHtmlFileHotkey") + TraceLoggingWideString(getAdditionalActionHotkeyCStr(L"PasteAsHtmlFile"), "PasteAsHtmlFileHotkey"), + TraceLoggingWideString(getAdditionalActionHotkeyCStr(L"TranscodeToMp3"), "TranscodeToMp3Hotkey"), + TraceLoggingWideString(getAdditionalActionHotkeyCStr(L"TranscodeToMp4"), "TranscodeToMp4Hotkey") ); } diff --git a/src/settings-ui/Settings.UI.Library/AdvancedPasteAdditionalAction.cs b/src/settings-ui/Settings.UI.Library/AdvancedPasteAdditionalAction.cs index 7a6fd3081aa1..28bed9201292 100644 --- a/src/settings-ui/Settings.UI.Library/AdvancedPasteAdditionalAction.cs +++ b/src/settings-ui/Settings.UI.Library/AdvancedPasteAdditionalAction.cs @@ -2,6 +2,7 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using System.Collections.Generic; using System.Text.Json.Serialization; using Microsoft.PowerToys.Settings.UI.Library.Helpers; @@ -36,4 +37,7 @@ public bool IsShown get => _isShown; set => Set(ref _isShown, value); } + + [JsonIgnore] + public IEnumerable SubActions => []; } diff --git a/src/settings-ui/Settings.UI.Library/AdvancedPasteAdditionalActions.cs b/src/settings-ui/Settings.UI.Library/AdvancedPasteAdditionalActions.cs index ce26962b020e..3b1a859364a3 100644 --- a/src/settings-ui/Settings.UI.Library/AdvancedPasteAdditionalActions.cs +++ b/src/settings-ui/Settings.UI.Library/AdvancedPasteAdditionalActions.cs @@ -14,6 +14,7 @@ public static class PropertyNames { public const string ImageToText = "image-to-text"; public const string PasteAsFile = "paste-as-file"; + public const string Transcode = "transcode"; } [JsonPropertyName(PropertyNames.ImageToText)] @@ -22,6 +23,22 @@ public static class PropertyNames [JsonPropertyName(PropertyNames.PasteAsFile)] public AdvancedPastePasteAsFileAction PasteAsFile { get; init; } = new(); - [JsonIgnore] - public IEnumerable AllActions => new IAdvancedPasteAction[] { ImageToText, PasteAsFile }.Concat(PasteAsFile.SubActions); + [JsonPropertyName(PropertyNames.Transcode)] + public AdvancedPasteTranscodeAction Transcode { get; init; } = new(); + + public IEnumerable GetAllActions() + { + Queue queue = new([ImageToText, PasteAsFile, Transcode]); + + while (queue.Count != 0) + { + var action = queue.Dequeue(); + yield return action; + + foreach (var subAction in action.SubActions) + { + queue.Enqueue(subAction); + } + } + } } diff --git a/src/settings-ui/Settings.UI.Library/AdvancedPasteCustomAction.cs b/src/settings-ui/Settings.UI.Library/AdvancedPasteCustomAction.cs index f3bb4431ca29..971d24c93b69 100644 --- a/src/settings-ui/Settings.UI.Library/AdvancedPasteCustomAction.cs +++ b/src/settings-ui/Settings.UI.Library/AdvancedPasteCustomAction.cs @@ -3,6 +3,7 @@ // See the LICENSE file in the project root for more information. using System; +using System.Collections.Generic; using System.Text.Json.Serialization; using Microsoft.PowerToys.Settings.UI.Library.Helpers; @@ -98,6 +99,9 @@ public bool IsValid private set => Set(ref _isValid, value); } + [JsonIgnore] + public IEnumerable SubActions => []; + public object Clone() { AdvancedPasteCustomAction clone = new(); diff --git a/src/settings-ui/Settings.UI.Library/AdvancedPastePasteAsFileAction.cs b/src/settings-ui/Settings.UI.Library/AdvancedPastePasteAsFileAction.cs index 979e967d4a0a..c4489eaaf762 100644 --- a/src/settings-ui/Settings.UI.Library/AdvancedPastePasteAsFileAction.cs +++ b/src/settings-ui/Settings.UI.Library/AdvancedPastePasteAsFileAction.cs @@ -52,5 +52,5 @@ public AdvancedPasteAdditionalAction PasteAsHtmlFile } [JsonIgnore] - public IEnumerable SubActions => [PasteAsTxtFile, PasteAsPngFile, PasteAsHtmlFile]; + public IEnumerable SubActions => [PasteAsTxtFile, PasteAsPngFile, PasteAsHtmlFile]; } diff --git a/src/settings-ui/Settings.UI.Library/AdvancedPasteTranscodeAction.cs b/src/settings-ui/Settings.UI.Library/AdvancedPasteTranscodeAction.cs new file mode 100644 index 000000000000..82ea4d09f566 --- /dev/null +++ b/src/settings-ui/Settings.UI.Library/AdvancedPasteTranscodeAction.cs @@ -0,0 +1,47 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Collections.Generic; +using System.Text.Json.Serialization; + +using Microsoft.PowerToys.Settings.UI.Library.Helpers; + +namespace Microsoft.PowerToys.Settings.UI.Library; + +public sealed class AdvancedPasteTranscodeAction : Observable, IAdvancedPasteAction +{ + public static class PropertyNames + { + public const string TranscodeToMp3 = "transcode-to-mp3"; + public const string TranscodeToMp4 = "transcode-to-mp4"; + } + + private AdvancedPasteAdditionalAction _transcodeToMp3 = new(); + private AdvancedPasteAdditionalAction _transcodeToMp4 = new(); + private bool _isShown = true; + + [JsonPropertyName("isShown")] + public bool IsShown + { + get => _isShown; + set => Set(ref _isShown, value); + } + + [JsonPropertyName(PropertyNames.TranscodeToMp3)] + public AdvancedPasteAdditionalAction TranscodeToMp3 + { + get => _transcodeToMp3; + init => Set(ref _transcodeToMp3, value); + } + + [JsonPropertyName(PropertyNames.TranscodeToMp4)] + public AdvancedPasteAdditionalAction TranscodeToMp4 + { + get => _transcodeToMp4; + init => Set(ref _transcodeToMp4, value); + } + + [JsonIgnore] + public IEnumerable SubActions => [TranscodeToMp3, TranscodeToMp4]; +} diff --git a/src/settings-ui/Settings.UI.Library/IAdvancedPasteAction.cs b/src/settings-ui/Settings.UI.Library/IAdvancedPasteAction.cs index 4c31557010c9..6571853be54a 100644 --- a/src/settings-ui/Settings.UI.Library/IAdvancedPasteAction.cs +++ b/src/settings-ui/Settings.UI.Library/IAdvancedPasteAction.cs @@ -2,6 +2,7 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using System.Collections.Generic; using System.ComponentModel; namespace Microsoft.PowerToys.Settings.UI.Library; @@ -9,4 +10,6 @@ namespace Microsoft.PowerToys.Settings.UI.Library; public interface IAdvancedPasteAction : INotifyPropertyChanged { public bool IsShown { get; } + + public IEnumerable SubActions { get; } } diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/AdvancedPaste.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Views/AdvancedPaste.xaml index f6308feba6bc..7aa817662141 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/AdvancedPaste.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/AdvancedPaste.xaml @@ -276,6 +276,37 @@ + + + + + + + + + + + + + + + + + + Paste as .html file + + Transcode audio / video + + + Transcode to .mp3 + + + Transcode to .mp4 (H.264/AAC) + OpenAI API key: diff --git a/src/settings-ui/Settings.UI/ViewModels/AdvancedPasteViewModel.cs b/src/settings-ui/Settings.UI/ViewModels/AdvancedPasteViewModel.cs index a84006a30544..ae75fe5bf6dc 100644 --- a/src/settings-ui/Settings.UI/ViewModels/AdvancedPasteViewModel.cs +++ b/src/settings-ui/Settings.UI/ViewModels/AdvancedPasteViewModel.cs @@ -84,7 +84,7 @@ public AdvancedPasteViewModel( _delayedTimer.Elapsed += DelayedTimer_Tick; _delayedTimer.AutoReset = false; - foreach (var action in _additionalActions.AllActions) + foreach (var action in _additionalActions.GetAllActions()) { action.PropertyChanged += OnAdditionalActionPropertyChanged; } @@ -366,7 +366,7 @@ public bool CloseAfterLosingFocus .Any(hotkey => WarnHotkeys.Contains(hotkey.ToString())); public bool IsAdditionalActionConflictingCopyShortcut => - _additionalActions.AllActions + _additionalActions.GetAllActions() .OfType() .Select(additionalAction => additionalAction.Shortcut) .Any(hotkey => WarnHotkeys.Contains(hotkey.ToString()));