diff --git a/Dotnet/AppApi/AppApi.cs b/Dotnet/AppApi/AppApi.cs index 282b4935..014bfb05 100644 --- a/Dotnet/AppApi/AppApi.cs +++ b/Dotnet/AppApi/AppApi.cs @@ -213,6 +213,45 @@ namespace VRCX return imageSaveMemoryStream.ToArray(); } + public async Task CropAllPrints(string ugcFolderPath) + { + var folder = Path.Combine(GetUGCPhotoLocation(ugcFolderPath), "Prints"); + var files = Directory.GetFiles(folder, "*.png", SearchOption.AllDirectories); + foreach (var file in files) + { + await CropPrintImage(file); + } + } + + public async Task CropPrintImage(string path) + { + var tempPath = path + ".temp"; + var bytes = await File.ReadAllBytesAsync(path); + var ms = new MemoryStream(bytes); + Bitmap print = new Bitmap(ms); + // validation step to ensure image is actually a print + if (print.Width != 2048 || print.Height != 1440) + { + return false; + } + var point = new Point(64, 69); + var size = new Size(1920, 1080); + var rectangle = new Rectangle(point, size); + Bitmap cropped = print.Clone(rectangle, print.PixelFormat); + cropped.Save(tempPath); + if (ScreenshotHelper.HasTXt(path)) + { + var success = ScreenshotHelper.CopyTXt(path, tempPath); + if (!success) + { + File.Delete(tempPath); + return false; + } + } + File.Move(tempPath, path, true); + return true; + } + /// /// Computes the signature of the file represented by the specified base64-encoded string using the librsync library. /// @@ -581,7 +620,7 @@ namespace VRCX { if (enabled) { - var path = Application.ExecutablePath; + var path = System.Windows.Forms.Application.ExecutablePath; key.SetValue("VRCX", $"\"{path}\" --startup"); } else @@ -615,7 +654,7 @@ namespace VRCX { MainForm.Instance.BeginInvoke(new MethodInvoker(() => { - var image = Image.FromFile(path); + var image = System.Drawing.Image.FromFile(path); // Clipboard.SetImage(image); var data = new DataObject(); data.SetData(DataFormats.Bitmap, image); @@ -654,26 +693,30 @@ namespace VRCX return null; } - public async Task SavePrintToFile(string url, string ugcFolderPath, string monthFolder, string fileName) + public async Task SavePrintToFile(string url, string ugcFolderPath, string monthFolder, string fileName) { var folder = Path.Combine(GetUGCPhotoLocation(ugcFolderPath), "Prints", MakeValidFileName(monthFolder)); Directory.CreateDirectory(folder); var filePath = Path.Combine(folder, MakeValidFileName(fileName)); if (File.Exists(filePath)) - return false; + return null; - return await ImageCache.SaveImageToFile(url, filePath); + var success = await ImageCache.SaveImageToFile(url, filePath); + + return success ? filePath : null; } - public async Task SaveStickerToFile(string url, string ugcFolderPath, string monthFolder, string fileName) + public async Task SaveStickerToFile(string url, string ugcFolderPath, string monthFolder, string fileName) { var folder = Path.Combine(GetUGCPhotoLocation(ugcFolderPath), "Stickers", MakeValidFileName(monthFolder)); Directory.CreateDirectory(folder); var filePath = Path.Combine(folder, MakeValidFileName(fileName)); if (File.Exists(filePath)) - return false; + return null; - return await ImageCache.SaveImageToFile(url, filePath); + var success = await ImageCache.SaveImageToFile(url, filePath); + + return success ? filePath : null; } public bool IsRunningUnderWine() diff --git a/Dotnet/ScreenshotMetadata/ScreenshotHelper.cs b/Dotnet/ScreenshotMetadata/ScreenshotHelper.cs index 13668144..858d68a1 100644 --- a/Dotnet/ScreenshotMetadata/ScreenshotHelper.cs +++ b/Dotnet/ScreenshotMetadata/ScreenshotHelper.cs @@ -15,7 +15,7 @@ namespace VRCX { private static readonly ILogger logger = LogManager.GetCurrentClassLogger(); private static readonly byte[] pngSignatureBytes = { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A }; - private static readonly ScreenshotMetadataDatabase cacheDatabase = new ScreenshotMetadataDatabase(Path.Combine(Program.AppDataDirectory, "metadataCache.db")); + private static readonly ScreenshotMetadataDatabase cacheDatabase = new ScreenshotMetadataDatabase(System.IO.Path.Combine(Program.AppDataDirectory, "metadataCache.db")); private static readonly Dictionary metadataCache = new Dictionary(); public enum ScreenshotSearchType @@ -287,6 +287,34 @@ namespace VRCX return true; } + public static bool CopyTXt(string sourceImage, string targetImage) + { + if (!File.Exists(sourceImage) || !IsPNGFile(sourceImage) || + !File.Exists(targetImage) || !IsPNGFile(targetImage)) + return false; + + var sourceMetadata = ReadTXt(sourceImage); + + if (sourceMetadata == null) + return false; + + var targetImageData = File.ReadAllBytes(targetImage); + + var newChunkIndex = FindEndOfChunk(targetImageData, "IHDR"); + if (newChunkIndex == -1) return false; + + // If this file already has a text chunk, chances are it got logged twice for some reason. Stop. + var existingiTXt = FindChunkIndex(targetImageData, "iTXt"); + if (existingiTXt != -1) return false; + + var newFile = targetImageData.ToList(); + newFile.InsertRange(newChunkIndex, sourceMetadata.ConstructChunkByteArray()); + + File.WriteAllBytes(targetImage, newFile.ToArray()); + + return true; + } + /// /// Reads a text description from a PNG file at the specified path. /// Reads any existing iTXt PNG chunk in the target file, using the Description tag. @@ -308,6 +336,30 @@ namespace VRCX } } + public static bool HasTXt(string path) + { + if (!File.Exists(path) || !IsPNGFile(path)) return false; + + using (var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, 512)) + { + var existingiTXt = FindChunk(stream, "iTXt", true); + + return existingiTXt != null; + } + } + + public static PNGChunk ReadTXt(string path) + { + if (!File.Exists(path) || !IsPNGFile(path)) return null; + + using (var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, 512)) + { + var existingiTXt = FindChunk(stream, "iTXt", true); + + return existingiTXt; + } + } + /// /// Reads the PNG resolution. /// diff --git a/html/src/app.js b/html/src/app.js index b8954d17..1e629dec 100644 --- a/html/src/app.js +++ b/html/src/app.js @@ -8602,6 +8602,10 @@ speechSynthesis.getVoices(); this.saveInstancePrints ); + await configRepository.setBool( + 'VRCX_cropInstancePrints', + this.cropInstancePrints + ); await configRepository.setBool( 'VRCX_saveInstanceStickers', this.saveInstanceStickers @@ -18102,19 +18106,39 @@ speechSynthesis.getVoices(); .replace(/T/g, '_') .replace(/Z/g, ''); var fileName = `${displayName}_${fileNameDate}_${fileId}.png`; - var status = await AppApi.SaveStickerToFile( + var filePath = await AppApi.SaveStickerToFile( imageUrl, this.ugcFolderPath, monthFolder, fileName ); - if (status) { + if (filePath) { console.log(`Sticker saved to file: ${monthFolder}\\${fileName}`); } }; // #endregion // #region | Prints + $app.methods.cropPrintsChanged = function () { + if (!this.cropInstancePrints) + return; + this.$confirm( + $t('view.settings.advanced.advanced.save_instance_prints_to_file.crop_convert_old'), + { + confirmButtonText: $t('view.settings.advanced.advanced.save_instance_prints_to_file.crop_convert_old_confirm'), + cancelButtonText: $t('view.settings.advanced.advanced.save_instance_prints_to_file.crop_convert_old_cancel'), + type: 'info', + showInput: false, + callback: (action) => { + if (action === 'confirm') { + AppApi.CropAllPrints(this.ugcFolderPath); + } + } + }); + + this.saveVRCXWindowOption(); + } + API.$on('LOGIN', function () { $app.printTable = []; }); @@ -18284,6 +18308,11 @@ speechSynthesis.getVoices(); false ); + $app.data.cropInstancePrints = await configRepository.getBool( + 'VRCX_cropInstancePrints', + false + ); + $app.data.saveInstanceStickers = await configRepository.getBool( 'VRCX_saveInstanceStickers', false @@ -18325,13 +18354,29 @@ speechSynthesis.getVoices(); }; $app.data.printCache = []; + $app.data.printQueue = []; + $app.data.printQueueWorker = undefined; - $app.methods.trySavePrintToFile = async function (printId) { + $app.methods.queueSavePrintToFile = function (printId) { if ($app.printCache.includes(printId)) return; $app.printCache.push(printId); if ($app.printCache.length > 100) { $app.printCache.shift(); } + + $app.printQueue.push(printId); + + if (!$app.printQueueWorker) { + $app.printQueueWorker = workerTimers.setInterval(() => { + let printId = $app.printQueue.shift(); + if (printId) { + $app.trySavePrintToFile(printId); + } + }, 2_500); + } + } + + $app.methods.trySavePrintToFile = async function (printId) { var args = await API.getPrint({ printId }); var imageUrl = args.json?.files?.image; if (!imageUrl) { @@ -18341,14 +18386,23 @@ speechSynthesis.getVoices(); var createdAt = this.getPrintLocalDate(args.json); var monthFolder = createdAt.toISOString().slice(0, 7); var fileName = this.getPrintFileName(args.json); - var status = await AppApi.SavePrintToFile( + var filePath = await AppApi.SavePrintToFile( imageUrl, this.ugcFolderPath, monthFolder, fileName ); - if (status) { + if (filePath) { console.log(`Print saved to file: ${monthFolder}\\${fileName}`); + + if (this.cropInstancePrints) { + await AppApi.CropPrintImage(filePath); + } + } + + if ($app.printQueue.length == 0) { + workerTimers.clearInterval($app.printQueueWorker); + $app.printQueueWorker = undefined; } }; diff --git a/html/src/classes/gameLog.js b/html/src/classes/gameLog.js index 4f0aa9e5..41fae6d9 100644 --- a/html/src/classes/gameLog.js +++ b/html/src/classes/gameLog.js @@ -286,7 +286,7 @@ export default class extends baseClass { printId = pathArray[4]; } if (printId && printId.length === 41) { - $app.trySavePrintToFile(printId); + $app.queueSavePrintToFile(printId); } } catch (err) { console.error(err); diff --git a/html/src/localization/en/en.json b/html/src/localization/en/en.json index f0e7fc12..ad4b8daf 100644 --- a/html/src/localization/en/en.json +++ b/html/src/localization/en/en.json @@ -507,7 +507,11 @@ "save_instance_prints_to_file": { "header": "Save Instance Prints To File", "header_tooltip": "Requires \"--enable-sdk-log-levels\" VRC launch option", - "description": "Save spawned prints to your VRChat Pictures folder" + "description": "Save spawned prints to your VRChat Pictures folder", + "crop": "Automatically crop saved prints to remove the white border", + "crop_convert_old": "Do you want to crop all prints that have already been saved?", + "crop_convert_old_confirm": "Yes", + "crop_convert_old_cancel": "No" }, "save_instance_stickers_to_file": { "header": "Save Instance Stickers To File", diff --git a/html/src/mixins/tabs/settings.pug b/html/src/mixins/tabs/settings.pug index 23fc918d..8b41135a 100644 --- a/html/src/mixins/tabs/settings.pug +++ b/html/src/mixins/tabs/settings.pug @@ -465,6 +465,11 @@ mixin settingsTab() el-tooltip(placement="top" style="margin-left:5px" :content="$t('view.settings.advanced.advanced.save_instance_prints_to_file.header_tooltip')") i.el-icon-info simple-switch(:label='$t("view.settings.advanced.advanced.save_instance_prints_to_file.description")' :value='saveInstancePrints' @change='saveVRCXWindowOption("VRCX_saveInstancePrints")' :long-label='true') + br + span.name(style="min-width:300px") {{ $t('view.settings.advanced.advanced.save_instance_prints_to_file.crop') }} + el-switch(v-model="cropInstancePrints" @change="cropPrintsChanged") + br + span.sub-header {{ $t('view.settings.advanced.advanced.save_instance_stickers_to_file.header') }} simple-switch(:label='$t("view.settings.advanced.advanced.save_instance_stickers_to_file.description")' :value='saveInstanceStickers' @change='saveVRCXWindowOption("VRCX_saveInstanceStickers")' :long-label='true') //- Advanced | Remote Avatar Database