mirror of
https://github.com/MrUnknownDE/VRCX.git
synced 2026-05-04 22:06:06 +02:00
Add queue for print downloads and automatic cropping for saved prints (#1068)
* Add queue for print downloads To prevent issues with rate limits in instances with a lot of prints switch downloading prints to use a queue. * Auto cropping for instance print saving Add option to automatically crop prints saved using the "Save Instance Prints" feature and a popup to apply crop to all previously saved prints while preserving metadata. --------- Co-authored-by: Natsumi <11171153+Natsumi-sama@users.noreply.github.com>
This commit is contained in:
+51
-8
@@ -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<bool> 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes the signature of the file represented by the specified base64-encoded string using the librsync library.
|
||||
/// </summary>
|
||||
@@ -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<bool> SavePrintToFile(string url, string ugcFolderPath, string monthFolder, string fileName)
|
||||
public async Task<string> 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<bool> SaveStickerToFile(string url, string ugcFolderPath, string monthFolder, string fileName)
|
||||
public async Task<string> 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()
|
||||
|
||||
@@ -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<string, ScreenshotMetadata> metadataCache = new Dictionary<string, ScreenshotMetadata>();
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the PNG resolution.
|
||||
/// </summary>
|
||||
|
||||
+59
-5
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user