diff --git a/AppApi.cs b/AppApi.cs index aa55eecc..83d7d5d7 100644 --- a/AppApi.cs +++ b/AppApi.cs @@ -25,6 +25,8 @@ using librsync.net; using Microsoft.Win32; using Newtonsoft.Json; using Newtonsoft.Json.Linq; +using NLog; +using Newtonsoft.Json.Serialization; namespace VRCX { @@ -32,6 +34,7 @@ namespace VRCX { public static readonly AppApi Instance; + private static readonly Logger logger = LogManager.GetCurrentClassLogger(); private static readonly MD5 _hasher = MD5.Create(); private static bool dialogOpen; @@ -959,7 +962,8 @@ namespace VRCX if (string.IsNullOrEmpty(path)) return; - GetScreenshotMetadata(path); + ExecuteAppFunction("screenshotMetadataResetSearch", null); + ExecuteAppFunction("getAndDisplayScreenshot", path); } }); @@ -967,110 +971,128 @@ namespace VRCX thread.Start(); } + public string GetExtraScreenshotData(string path, bool carouselCache) + { + var fileName = Path.GetFileNameWithoutExtension(path); + var metadata = new JObject(); + + if (!File.Exists(path) || !path.EndsWith(".png")) + return null; + + var files = Directory.GetFiles(Path.GetDirectoryName(path), "*.png"); + + // Add previous/next file paths to metadata so the screenshot viewer carousel can request metadata for next/previous images in directory + if (carouselCache) + { + var index = Array.IndexOf(files, path); + if (index > 0) + { + metadata.Add("previousFilePath", files[index - 1]); + } + if (index < files.Length - 1) + { + metadata.Add("nextFilePath", files[index + 1]); + } + } + + metadata.Add("fileResolution", ScreenshotHelper.ReadPNGResolution(path)); + + var creationDate = File.GetCreationTime(path); + metadata.Add("creationDate", creationDate.ToString("yyyy-MM-dd HH:mm:ss")); + + var fileSizeBytes = new FileInfo(path).Length; + metadata.Add("fileSizeBytes", fileSizeBytes.ToString()); + metadata.Add("fileName", fileName); + metadata.Add("filePath", path); + metadata.Add("fileSize", $"{(fileSizeBytes / 1024f / 1024f).ToString("0.00")} MB"); + + return metadata.ToString(Formatting.Indented); + } + /// /// Retrieves metadata from a PNG screenshot file and send the result to displayScreenshotMetadata in app.js /// /// The path to the PNG screenshot file. - public void GetScreenshotMetadata(string path) + public string GetScreenshotMetadata(string path) { if (string.IsNullOrEmpty(path)) - return; + return null; - var fileName = Path.GetFileNameWithoutExtension(path); - var metadata = new JObject(); - if (File.Exists(path) && path.EndsWith(".png")) + + var metadata = ScreenshotHelper.GetScreenshotMetadata(path); + + if (metadata == null) { - string metadataString = null; - var readPNGFailed = false; + var obj = new JObject + { + { "sourceFile", path }, + { "error", "Screenshot contains no metadata." } + }; - try - { - metadataString = ScreenshotHelper.ReadPNGDescription(path); - } - catch (Exception ex) - { - metadata.Add("error", $"VRCX encountered an error while trying to parse this file. The file might be an invalid/corrupted PNG file.\n({ex.Message})"); - readPNGFailed = true; - } + return obj.ToString(Formatting.Indented); + }; - if (!string.IsNullOrEmpty(metadataString)) - { - if (metadataString.StartsWith("lfs") || metadataString.StartsWith("screenshotmanager")) - { - try - { - metadata = ScreenshotHelper.ParseLfsPicture(metadataString); - } - catch (Exception ex) - { - metadata.Add("error", $"This file contains invalid LFS/SSM metadata unable to be parsed by VRCX. \n({ex.Message})\nText: {metadataString}"); - } - } - else - { - try - { - metadata = JObject.Parse(metadataString); - } - catch (JsonReaderException ex) - { - metadata.Add("error", $"This file contains invalid metadata unable to be parsed by VRCX. \n({ex.Message})\nText: {metadataString}"); - } - } - } - else - { - if (!readPNGFailed) - metadata.Add("error", "No metadata found in this file."); - } - } - else + if (metadata.Error != null) { - metadata.Add("error", "Invalid file selected. Please select a valid VRChat screenshot."); + var obj = new JObject + { + { "sourceFile", path }, + { "error", metadata.Error } + }; + + return obj.ToString(Formatting.Indented); } - var files = Directory.GetFiles(Path.GetDirectoryName(path), "*.png"); - var index = Array.IndexOf(files, path); - if (index > 0) + return JsonConvert.SerializeObject(metadata, Formatting.Indented, new JsonSerializerSettings { - metadata.Add("previousFilePath", files[index - 1]); + ContractResolver = new DefaultContractResolver + { + NamingStrategy = new CamelCaseNamingStrategy() // This'll serialize our .net property names to their camelCase equivalents. Ex; "FileName" -> "fileName" + } + }); + } + + public string FindScreenshotsBySearch(string searchQuery, int searchType = 0) + { + var stopwatch = new Stopwatch(); + stopwatch.Start(); + + var searchPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.MyPictures), "VRChat"); + var screenshots = ScreenshotHelper.FindScreenshots(searchQuery, searchPath, (ScreenshotHelper.ScreenshotSearchType)searchType); + + JArray json = new JArray(); + + foreach (var screenshot in screenshots) + { + json.Add(screenshot.SourceFile); } - if (index < files.Length - 1) - { - metadata.Add("nextFilePath", files[index + 1]); - } + stopwatch.Stop(); - metadata.Add("fileResolution", ScreenshotHelper.ReadPNGResolution(path)); - var creationDate = File.GetCreationTime(path); - metadata.Add("creationDate", creationDate.ToString("yyyy-MM-dd HH:mm:ss")); - metadata.Add("fileName", fileName); - metadata.Add("filePath", path); - var fileSizeBytes = new FileInfo(path).Length; - metadata.Add("fileSizeBytes", fileSizeBytes.ToString()); - metadata.Add("fileSize", $"{(fileSizeBytes / 1024f / 1024f).ToString("0.00")} MB"); - ExecuteAppFunction("displayScreenshotMetadata", metadata.ToString(Formatting.Indented)); + logger.Info($"FindScreenshotsBySearch took {stopwatch.ElapsedMilliseconds}ms to complete."); + + return json.ToString(); } /// - /// Gets the last screenshot taken by VRChat and retrieves its metadata. + /// Gets and returns the path of the last screenshot taken by VRChat. /// - public void GetLastScreenshot() + public string GetLastScreenshot() { // Get the last screenshot taken by VRChat var path = GetVRChatPhotosLocation(); if (!Directory.Exists(path)) - return; + return null; var lastDirectory = Directory.GetDirectories(path).OrderByDescending(Directory.GetCreationTime).FirstOrDefault(); if (lastDirectory == null) - return; + return null; var lastScreenshot = Directory.GetFiles(lastDirectory, "*.png").OrderByDescending(File.GetCreationTime).FirstOrDefault(); if (lastScreenshot == null) - return; + return null; - GetScreenshotMetadata(lastScreenshot); + return lastScreenshot; } /// diff --git a/ScreenshotHelper.cs b/ScreenshotHelper.cs index bf6e74d0..c2e4117f 100644 --- a/ScreenshotHelper.cs +++ b/ScreenshotHelper.cs @@ -2,14 +2,197 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using System.Numerics; using System.Text; +using Newtonsoft.Json; using Newtonsoft.Json.Linq; +using NLog; namespace VRCX { internal static class ScreenshotHelper { + 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 Dictionary metadataCache = new Dictionary(); + + public enum ScreenshotSearchType + { + Username, + UserID, + WorldName, + WorldID, + } + + public static bool TryGetCachedMetadata(string filePath, out ScreenshotMetadata metadata) + { + if (metadataCache.TryGetValue(filePath, out metadata)) + return true; + + int id = cacheDatabase.IsFileCached(filePath); + + if (id != -1) + { + string metadataStr = cacheDatabase.GetMetadataById(id); + var metadataObj = metadataStr == null ? null : JsonConvert.DeserializeObject(metadataStr); + + metadataCache.Add(filePath, metadataObj); + + metadata = metadataObj; + return true; + } + + return false; + } + + public static List FindScreenshots(string query, string directory, ScreenshotSearchType searchType) + { + var result = new List(); + + var files = Directory.GetFiles(directory, "*.png", SearchOption.AllDirectories); + + var addToCache = new List(); + + int amtFromCache = 0; + foreach (var file in files) + { + ScreenshotMetadata metadata = null; + + if (TryGetCachedMetadata(file, out metadata)) + { + amtFromCache++; + } + else + { + metadata = GetScreenshotMetadata(file, false); + var dbEntry = new MetadataCache() + { + FilePath = file, + Metadata = null, + CachedAt = DateTimeOffset.Now + }; + + if (metadata == null || metadata.Error != null) + { + addToCache.Add(dbEntry); + metadataCache.Add(file, null); + continue; + } + + dbEntry.Metadata = JsonConvert.SerializeObject(metadata); + addToCache.Add(dbEntry); + metadataCache.Add(file, metadata); + } + + if (metadata == null) continue; + + switch (searchType) + { + case ScreenshotSearchType.Username: + if (metadata.ContainsPlayerName(query, true, true)) + result.Add(metadata); + + break; + case ScreenshotSearchType.UserID: + if (metadata.ContainsPlayerID(query)) + result.Add(metadata); + + break; + case ScreenshotSearchType.WorldName: + if (metadata.World.Name.IndexOf(query, StringComparison.OrdinalIgnoreCase) != -1) + result.Add(metadata); + + break; + case ScreenshotSearchType.WorldID: + if (metadata.World.Id == query) + result.Add(metadata); + + break; + + } + } + + if (addToCache.Count > 0) + cacheDatabase.BulkAddMetadataCache(addToCache); + + logger.ConditionalDebug("Found {0}/{1} screenshots matching query '{2}' of type '{3}'. {4}/{5} pulled from cache.", result.Count, files.Length, query, searchType, amtFromCache, files.Length); + + return result; + } + + /// + /// Retrieves metadata from a PNG screenshot file and attempts to parse it. + /// + /// The path to the PNG screenshot file. + /// A JObject containing the metadata or null if no metadata was found. + public static ScreenshotMetadata GetScreenshotMetadata(string path, bool includeJSON = false) + { + // Early return if file doesn't exist, or isn't a PNG(Check both extension and file header) + if (!File.Exists(path) || !path.EndsWith(".png") || !IsPNGFile(path)) + return null; + + ///if (metadataCache.TryGetValue(path, out var cachedMetadata)) + // return cachedMetadata; + + string metadataString; + + // Get the metadata string from the PNG file + try + { + metadataString = ReadPNGDescription(path); + } + catch (Exception ex) + { + logger.Error(ex, "Failed to read PNG description for file '{0}'", path); + return ScreenshotMetadata.JustError(path, "Failed to read PNG description. Check logs."); + } + + // If the metadata string is empty for some reason, there's nothing to parse. + if (string.IsNullOrEmpty(metadataString)) + return null; + + // Check for specific metadata string start sequences + if (metadataString.StartsWith("lfs") || metadataString.StartsWith("screenshotmanager")) + { + try + { + var result = ScreenshotHelper.ParseLfsPicture(metadataString); + result.SourceFile = path; + + return result; + } + catch (Exception ex) + { + logger.Error(ex, "Failed to parse LFS/ScreenshotManager metadata for file '{0}'", path); + return ScreenshotMetadata.JustError(path, "Failed to parse LFS/ScreenshotManager metadata."); + } + } + + // If not JSON metadata, return early so we're not throwing/catching pointless exceptions + if (!metadataString.StartsWith("{")) + { + logger.ConditionalDebug("Screenshot file '{0}' has unknown non-JSON metadata:\n{1}\n", path, metadataString); + return ScreenshotMetadata.JustError(path, "File has unknown non-JSON metadata."); + } + + // Parse the metadata as VRCX JSON metadata + try + { + var result = JsonConvert.DeserializeObject(metadataString); + result.SourceFile = path; + + if (includeJSON) + result.JSON = metadataString; + + return result; + } + catch (JsonException ex) + { + logger.Error(ex, "Failed to parse screenshot metadata JSON for file '{0}'", path); + return ScreenshotMetadata.JustError(path, "Failed to parse screenshot metadata JSON. Check logs."); + } + } /// /// Writes a text description into a PNG file at the specified path. @@ -57,26 +240,31 @@ namespace VRCX { if (!File.Exists(path) || !IsPNGFile(path)) return null; - var png = File.ReadAllBytes(path); - var existingiTXt = FindChunk(png, "iTXt"); - if (existingiTXt == null) return null; + using (var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, 512)) + { + var existingiTXt = FindChunk(stream, "iTXt", true); + if (existingiTXt == null) return null; - var text = existingiTXt.GetText("Description"); - - return text; + return existingiTXt.GetText("Description"); + } } + /// + /// Reads the PNG resolution. + /// + /// The path. + /// public static string ReadPNGResolution(string path) { if (!File.Exists(path) || !IsPNGFile(path)) return null; - var png = File.ReadAllBytes(path); - var existingpHYs = FindChunk(png, "IHDR"); - if (existingpHYs == null) return null; + using (var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, 512)) + { + var existingiHDR = FindChunk(stream, "IHDR", false); + if (existingiHDR == null) return null; - var text = existingpHYs.GetResolution(); - - return text; + return existingiHDR.GetResolution(); + } } /// @@ -89,7 +277,7 @@ namespace VRCX // Read only the first 8 bytes of the file to check if it's a PNG file instead of reading the entire thing into memory just to see check a couple bytes. using (var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite)) { - if (fs.Length < 33) return false; + if (fs.Length < 33) return false; // I don't remember how I came up with this number, but a PNG file below this size is not going to be valid for our purposes. var signature = new byte[8]; fs.Read(signature, 0, 8); @@ -98,13 +286,18 @@ namespace VRCX } /// - /// Finds the index of the first byte of the specified chunk type in the specified PNG file. + /// Finds the index of the first of a specified chunk type in the specified PNG file. /// /// Array of bytes representing a PNG file. /// Type of PMG chunk to find /// private static int FindChunkIndex(byte[] png, string type) { + int chunksProcessed = 0; + int chunkSeekLimit = 5; + + bool isLittleEndian = BitConverter.IsLittleEndian; + // The first 8 bytes of the file are the png signature, so we can skip them. var index = 8; @@ -114,7 +307,7 @@ namespace VRCX Array.Copy(png, index, chunkLength, 0, 4); // BitConverter wants little endian(unless your system is big endian for some reason), PNG multi-byte integers are big endian. So we reverse the array. - if (BitConverter.IsLittleEndian) Array.Reverse(chunkLength); + if (isLittleEndian) Array.Reverse(chunkLength); var length = BitConverter.ToInt32(chunkLength, 0); @@ -134,7 +327,80 @@ namespace VRCX } // The chunk length is 4 bytes, the chunk name is 4 bytes, the chunk data is length bytes, and the chunk CRC is 4 bytes. + // We add 12 to the index to get to the start of the next chunk in the file on the next loop. index += length + 12; + chunksProcessed++; + + if (chunksProcessed > chunkSeekLimit) break; + } + + return -1; + } + + private static int FindChunkIndex(FileStream fs, string type, bool seekEnd) + { + int chunksProcessed = 0; + int chunkSeekLimit = 5; + + bool isLittleEndian = BitConverter.IsLittleEndian; + + fs.Seek(8, SeekOrigin.Begin); + + byte[] buffer = new byte[8]; + + while (fs.Position < fs.Length) + { + int chunkIndex = (int)fs.Position; + + fs.Read(buffer, 0, 8); // Read both chunkLength and chunkName at once into this buffer + + // BitConverter wants little endian(unless your system is big endian for some reason), PNG multi-byte integers are big endian. So we reverse the array. + if (isLittleEndian) Array.Reverse(buffer, 0, 4); // Only reverse the chunkLength part + + int chunkLength = BitConverter.ToInt32(buffer, 0); + string chunkType = Encoding.UTF8.GetString(buffer, 4, 4); // We don't need to reverse strings since UTF-8 strings aren't affected by endianess, given that they're a sequence of bytes. + + if (chunkType == type) return chunkIndex; + if (chunkType == "IEND") return -1; // Nothing should exist past IEND in a normal png file, so we should stop parsing here to avoid trying to parse junk data. + + // The chunk length is 4 bytes, the chunk name is 4 bytes, the chunk data is chunkLength bytes, and the chunk CRC after chunk data is 4 bytes. + // We've already read the length/type which is the first 8 bytes, so we'll seek the chunk length + 4(CRC) to get to the start of the next chunk in the file. + fs.Seek(chunkLength + 4, SeekOrigin.Current); + chunksProcessed++; + + if (chunksProcessed > chunkSeekLimit) break; + } + + // If we've processed more than 5 chunks and still haven't found the chunk we're looking for, we'll start searching from the end of the file. + + // We start at an offset of 12 since the IEND chunk (should) always be the last chunk in the file, be 12 bytes, and we don't need to check it. + fs.Seek(-12, SeekOrigin.End); + + // We're going to read the last 4096 bytes of the file, which (should) be enough to find any trailing iTXt chunks we're looking for. + // If an LFS screenshots has the metadata of like 80 players attached to it, this likely won't be enough to find the iTXt chunk. + // I don't have any screenshots with that much metadata to test with and will not create them manually, so I'm not going to worry about it for now. + var chunkNameBytes = Encoding.UTF8.GetBytes(type); + fs.Seek(-4096, SeekOrigin.Current); + + byte[] trailingBytes = new byte[4096]; + fs.Read(trailingBytes, 0, 4096); + + // At this scale we can just brute force/naive search for the chunk name in the trailing bytes and performance will be fine. + for (int i = 0; i <= trailingBytes.Length - chunkNameBytes.Length; i++) + { + bool isMatch = true; + for (int j = 0; j < chunkNameBytes.Length; j++) + { + if (trailingBytes[i + j] != chunkNameBytes[j]) + { + isMatch = false; + break; + } + } + if (isMatch) + { + return (int)fs.Position - 4096 + i - 4; + } } return -1; @@ -181,143 +447,142 @@ namespace VRCX return new PNGChunk(type, chunkData); } + /// + /// Finds the specified chunk type in the specified PNG file and returns it as a PNGChunk. + /// + /// FileStream of a PNG file. + /// Type of PMG chunk to find + /// PNGChunk + private static PNGChunk FindChunk(FileStream fs, string type, bool seekFromEnd) + { + var index = FindChunkIndex(fs, type, seekFromEnd); + if (index == -1) return null; + + // Seek back to start of found chunk + fs.Seek(index, SeekOrigin.Begin); + + var chunkLength = new byte[4]; + fs.Read(chunkLength, 0, 4); + Array.Reverse(chunkLength); + var length = BitConverter.ToInt32(chunkLength, 0); + + // Skip the chunk type bytes + fs.Seek(4, SeekOrigin.Current); + + var chunkData = new byte[length]; + fs.Read(chunkData, 0, length); + + return new PNGChunk(type, chunkData); + } + /// /// Parses the metadata string of a vrchat screenshot with taken with LFS and returns a JObject containing the parsed data. /// /// The metadata string to parse. /// A JObject containing the parsed data. - public static JObject ParseLfsPicture(string metadataString) + public static ScreenshotMetadata ParseLfsPicture(string metadataString) { - var metadata = new JObject(); + var metadata = new ScreenshotMetadata(); + // LFS v2 format: https://github.com/knah/VRCMods/blob/c7e84936b52b6f476db452a37ab889eabe576845/LagFreeScreenshots/API/MetadataV2.cs#L35 + // Normal entry // lfs|2|author:usr_032383a7-748c-4fb2-94e4-bcb928e5de6b,Natsumi-sama|world:wrld_b016712b-5ce6-4bcb-9144-c8ed089b520f,35372,pet park test|pos:-60.49379,-0.002925932,5.805772|players:usr_9d73bff9-4543-4b6f-a004-9e257869ff50,-0.85,-0.17,-0.58,Olivia.;usr_3097f91e-a816-4c7a-a625-38fbfdee9f96,12.30,13.72,0.08,Zettai Ryouiki;usr_032383a7-748c-4fb2-94e4-bcb928e5de6b,0.68,0.32,-0.28,Natsumi-sama;usr_7525f45f-517e-442b-9abc-fbcfedb29f84,0.51,0.64,0.70,Weyoun + // Entry with image rotation enabled (rq:) // lfs|2|author:usr_8c0a2f22-26d4-4dc9-8396-2ab40e3d07fc,knah|world:wrld_fb4edc80-6c48-43f2-9bd1-2fa9f1345621,35341,Luminescent Ledge|pos:8.231676,0.257298,-0.1983307|rq:2|players:usr_65b9eeeb-7c91-4ad2-8ce4-addb1c161cd6,0.74,0.59,1.57,Jakkuba;usr_6a50647f-d971-4281-90c3-3fe8caf2ba80,8.07,9.76,0.16,SopwithPup;usr_8c0a2f22-26d4-4dc9-8396-2ab40e3d07fc,0.26,1.03,-0.28,knah;usr_7f593ad1-3e9e-4449-a623-5c1c0a8d8a78,0.15,0.60,1.46,NekOwneD - // lfs|cvr|1|author:047b30bd-089d-887c-8734-b0032df5d176,Hordini|world:2e73b387-c6d4-45e9-b998-0fd6aa122c1d,i+efec20004ef1cd8b-404003-93833f-1aee112a,Bono's Basement (Anime) (#816724)|pos:2.196716,0.01250899,-3.817466|players:5301af21-eb8d-7b36-3ef4-b623fa51c2c6,3.778407,0.01250887,-3.815876,DDAkebono;f9e5c36c-41b0-7031-1185-35b4034010c0,4.828233,0.01250893,-3.920135,Natsumi - var lfs = metadataString.Split('|'); - if (lfs[1] == "cvr") - lfs = lfs.Skip(1).ToArray(); - var version = int.Parse(lfs[1]); - var application = lfs[0]; - metadata.Add("application", application); - metadata.Add("version", version); + // LFS v1 format: https://github.com/knah/VRCMods/blob/23c3311fdfc4af4b568eedfb2e366710f2a9f925/LagFreeScreenshots/LagFreeScreenshotsMod.cs + // Why support this tho + // lfs|1|world:wrld_6caf5200-70e1-46c2-b043-e3c4abe69e0f:47213,The Great Pug|players:usr_290c03d6-66cc-4f0e-b782-c07f5cfa8deb,VirtualTeacup;usr_290c03d6-66cc-4f0e-b782-c07f5cfa8deb,VirtualTeacup + + // LFS CVR Edition v1 format: https://github.com/dakyneko/DakyModsCVR/blob/48eecd1bccd1a5b2ea844d899d59cf1186ec9912/LagFreeScreenshots/API/MetadataV2.cs#L41 + // lfs|cvr|1|author:047b30bd-089d-887c-8734-b0032df5d176,Hordini|world:2e73b387-c6d4-45e9-b998-0fd6aa122c1d,i+efec20004ef1cd8b-404003-93833f-1aee112a,Bono's Basement (Anime) (#816724)|pos:2.196716,0.01250899,-3.817466|players:5301af21-eb8d-7b36-3ef4-b623fa51c2c6,3.778407,0.01250887,-3.815876,DDAkebono;f9e5c36c-41b0-7031-1185-35b4034010c0,4.828233,0.01250893,-3.920135,Natsumi + + var lfsParts = metadataString.Split('|'); + if (lfsParts[1] == "cvr") + lfsParts = lfsParts.Skip(1).ToArray(); + + var version = int.Parse(lfsParts[1]); + var application = lfsParts[0]; + metadata.Application = application; + metadata.Version = version; + + bool isCVR = application == "cvr"; if (application == "screenshotmanager") { + // ScreenshotManager format: https://github.com/DragonPlayerX/ScreenshotManager/blob/33950b98003e795d29c68ce5fe1d86e7e65c92ad/ScreenshotManager/Core/FileDataHandler.cs#L94 // screenshotmanager|0|author:usr_290c03d6-66cc-4f0e-b782-c07f5cfa8deb,VirtualTeacup|wrld_6caf5200-70e1-46c2-b043-e3c4abe69e0f,47213,The Great Pug - var author = lfs[2].Split(','); - metadata.Add("author", new JObject - { - { "id", author[0] }, - { "displayName", author[1] } - }); - var world = lfs[3].Split(','); - metadata.Add("world", new JObject - { - { "id", world[0] }, - { "name", world[2] }, - { "instanceId", world[1] } - }); + var author = lfsParts[2].Split(','); + + metadata.Author.Id = author[0]; + metadata.Author.DisplayName = author[1]; + + var world = lfsParts[3].Split(','); + + metadata.World.Id = world[0]; + metadata.World.Name = world[2]; + metadata.World.InstanceId = string.Join(":", world[0], world[1]); // worldId:instanceId format, same as vrcx format, just minimal return metadata; } - for (var i = 2; i < lfs.Length; i++) + for (var i = 2; i < lfsParts.Length; i++) { - var split = lfs[i].Split(':'); - switch (split[0]) + var split = lfsParts[i].Split(':'); + var key = split[0]; + var value = split[1]; + + if (String.IsNullOrEmpty(value)) // One of my LFS files had an empty value for 'players:'. not pog + continue; + + var parts = value.Split(','); + + switch (key) { case "author": - var author = split[1].Split(','); - if (application == "cvr") - { - metadata.Add("author", new JObject - { - { "id", string.Empty }, - { "displayName", $"{author[1]} ({author[0]})" } - }); - break; - } - - metadata.Add("author", new JObject - { - { "id", author[0] }, - { "displayName", author[1] } - }); + metadata.Author.Id = isCVR ? string.Empty : parts[0]; + metadata.Author.DisplayName = isCVR ? $"{parts[1]} ({parts[0]})" : parts[1]; break; + case "world": - if (application == "cvr") - { - var world = split[1].Split(','); - metadata.Add("world", new JObject - { - { "id", string.Empty }, - { "name", $"{world[2]} ({world[0]})" }, - { "instanceId", string.Empty } - }); - } - else if (version == 1) - { - metadata.Add("world", new JObject - { - { "id", string.Empty }, - { "name", split[1] }, - { "instanceId", string.Empty } - }); - } - else - { - var world = split[1].Split(','); - metadata.Add("world", new JObject - { - { "id", world[0] }, - { "name", world[2] }, - { "instanceId", world[0] } - }); - } + metadata.World.Id = isCVR || version == 1 ? string.Empty : parts[0]; + metadata.World.InstanceId = isCVR || version == 1 ? string.Empty : string.Join(":", parts[0], parts[1]); // worldId:instanceId format, same as vrcx format, just minimal + metadata.World.Name = isCVR ? $"{parts[2]} ({parts[0]})" : (version == 1 ? value : parts[2]); + break; - break; case "pos": - var pos = split[1].Split(','); - metadata.Add("pos", new JObject - { - { "x", pos[0] }, - { "y", pos[1] }, - { "z", pos[2] } - }); - break; - case "rq": - metadata.Add("rq", split[1]); + float.TryParse(parts[0], out float x); + float.TryParse(parts[1], out float y); + float.TryParse(parts[2], out float z); + + metadata.Pos = new Vector3(x, y, z); break; + + // We don't use this, so don't parse it. + /*case "rq": + // Image rotation + metadata.Add("rq", value); + break;*/ + case "players": - var players = split[1].Split(';'); - var playersArray = new JArray(); + var playersArray = metadata.Players; + var players = value.Split(';'); + foreach (var player in players) { - var playerSplit = player.Split(','); - if (application == "cvr") - { - playersArray.Add(new JObject - { - { "id", string.Empty }, - { "x", playerSplit[1] }, - { "y", playerSplit[2] }, - { "z", playerSplit[3] }, - { "displayName", $"{playerSplit[4]} ({playerSplit[0]})" } - }); - } - else - { - playersArray.Add(new JObject - { - { "id", playerSplit[0] }, - { "x", playerSplit[1] }, - { "y", playerSplit[2] }, - { "z", playerSplit[3] }, - { "displayName", playerSplit[4] } - }); - } - } + var playerParts = player.Split(','); - metadata.Add("players", playersArray); + float.TryParse(playerParts[1], out float x2); + float.TryParse(playerParts[2], out float y2); + float.TryParse(playerParts[3], out float z2); + + var playerDetail = new ScreenshotMetadata.PlayerDetail + { + Id = isCVR ? string.Empty : playerParts[0], + DisplayName = isCVR ? $"{playerParts[4]} ({playerParts[0]})" : playerParts[4], + Pos = new Vector3(x2, y2, z2) + }; + + playersArray.Add(playerDetail); + } break; } } diff --git a/ScreenshotMetadata.cs b/ScreenshotMetadata.cs new file mode 100644 index 00000000..a3f4f024 --- /dev/null +++ b/ScreenshotMetadata.cs @@ -0,0 +1,142 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Numerics; +using Newtonsoft.Json; + +namespace VRCX +{ + public class ScreenshotMetadata + { + /// + /// Name of the application writing to the screenshot. Should be VRCX. + /// + public string Application { get; set; } + + /// + /// The version of this schema. If the format changes, this number should change. + /// + public int Version { get; set; } + + /// + /// The details of the user that took the picture. + /// + public AuthorDetail Author { get; set; } + + /// + /// Information about the world the picture was taken in. + /// + public WorldDetail World { get; set; } + + /// + /// A list of players in the world at the time the picture was taken. + /// + public List Players { get; set; } + + /// + /// If this class was serialized from a file, this should be the path to the file. + /// + [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] + internal string SourceFile; + + /// + /// The position of the player that took the picture when the shot was taken. Not written by VRCX, this is legacy support for reading LFS files. + /// + [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] + public Vector3? Pos { get; set; } + + /// + /// Any error that occurred while parsing the file. This being true implies nothing else is set. + /// + [JsonIgnore] + internal string Error; + + [JsonIgnore] + internal string JSON; + + public ScreenshotMetadata() + { + Application = "VRCX"; + Version = 1; + Author = new AuthorDetail(); + World = new WorldDetail(); + Players = new List(); + } + + public static ScreenshotMetadata JustError(string sourceFile, string error) + { + return new ScreenshotMetadata + { + Error = error, + SourceFile = sourceFile + }; + } + + public bool ContainsPlayerID(string id) + { + return Players.Any(p => p.Id == id); + } + + public bool ContainsPlayerName(string playerName, bool partial, bool ignoreCase) + { + var comparisonType = ignoreCase ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; + + if (partial) + { + return Players.Any(p => p.DisplayName.IndexOf(playerName, comparisonType) != -1); + } + + return Players.Any(p => p.DisplayName.Equals(playerName, comparisonType)); + } + + public class AuthorDetail + { + /// + /// The ID of the user. + /// + public string Id { get; set; } + + /// + /// The display name of the user. + /// + public string DisplayName { get; set; } + } + + public class WorldDetail + { + /// + /// The ID of the world. + /// + public string Id { get; set; } + + /// + /// The name of the world. + /// + public string Name { get; set; } + + /// + /// The full ID of the game instance. + /// + public string InstanceId { get; set; } + } + + public class PlayerDetail + { + /// + /// The ID of the player in the world. + /// + public string Id { get; set; } + + /// + /// The display name of the player in the world. + /// + public string DisplayName { get; set; } + + /// + /// The position of the player in the world. Not written by VRCX, this is legacy support for reading LFS files. + /// + [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] + public Vector3? Pos { get; set; } = null; + } + } +} diff --git a/ScreenshotMetadataDatabase.cs b/ScreenshotMetadataDatabase.cs new file mode 100644 index 00000000..65470a58 --- /dev/null +++ b/ScreenshotMetadataDatabase.cs @@ -0,0 +1,85 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using SQLite; + +namespace VRCX +{ + [Table("cache")] + public class MetadataCache + { + [PrimaryKey, AutoIncrement] + [Column("id")] + public int Id { get; set; } + [Column("file_path"), NotNull, Indexed] + public string FilePath { get; set; } + [Column("metadata")] + public string Metadata { get; set; } + [Column("cached_at"), NotNull] + public DateTimeOffset CachedAt { get; set; } + } + + // Imagine using SQLite to store json strings in one table lmao + // Couldn't be me... oh wait + internal class ScreenshotMetadataDatabase + { + private SQLiteConnection sqlite; + + public ScreenshotMetadataDatabase(string databaseLocation) + { + var options = new SQLiteConnectionString(databaseLocation, true); + sqlite = new SQLiteConnection(options); + + sqlite.CreateTable(); + } + + public void AddMetadataCache(string filePath, string metadata) + { + var cache = new MetadataCache() + { + FilePath = filePath, + Metadata = metadata, + CachedAt = DateTimeOffset.Now + }; + sqlite.Insert(cache); + } + + public void BulkAddMetadataCache(IEnumerable cache) + { + sqlite.InsertAll(cache, runInTransaction: true); + } + + public int IsFileCached(string filePath) + { + var query = sqlite.Table().Where(c => c.FilePath == filePath).Select(c => c.Id); + + if (query.Any()) + { + return query.First(); + } + else + { + return -1; + } + } + + public string GetMetadata(string filePath) + { + var query = sqlite.Table().Where(c => c.FilePath == filePath).Select(c => c.Metadata); + return query.FirstOrDefault(); + } + + public string GetMetadataById(int id) + { + var query = sqlite.Table().Where(c => c.Id == id).Select(c => c.Metadata); + return query.FirstOrDefault(); + } + + public void Close() + { + sqlite.Close(); + } + } +} diff --git a/VRCX.csproj b/VRCX.csproj index bf57f777..f3681c2c 100644 --- a/VRCX.csproj +++ b/VRCX.csproj @@ -1,4 +1,4 @@ - + @@ -87,6 +87,8 @@ + + diff --git a/html/src/app.js b/html/src/app.js index e256c562..d9be5806 100644 --- a/html/src/app.js +++ b/html/src/app.js @@ -22033,20 +22033,53 @@ speechSynthesis.getVoices(); ); }; + $app.methods.getAndDisplayScreenshot = function ( + path, + needsCarouselFiles = true + ) { + AppApi.GetScreenshotMetadata(path).then((metadata) => + this.displayScreenshotMetadata(metadata, needsCarouselFiles) + ); + }; + + $app.methods.getAndDisplayLastScreenshot = function () { + this.screenshotMetadataResetSearch(); + AppApi.GetLastScreenshot().then((path) => + this.getAndDisplayScreenshot(path) + ); + }; + /** - * This function should only ever be called by .NET * Function receives an unmodified json string grabbed from the screenshot file * Error checking and and verification of data is done in .NET already; In the case that the data/file is invalid, a JSON object with the token "error" will be returned containing a description of the problem. * Example: {"error":"Invalid file selected. Please select a valid VRChat screenshot."} * See docs/screenshotMetadata.json for schema * @param {string} metadata - JSON string grabbed from PNG file + * @param {string} needsCarouselFiles - Whether or not to get the last/next files for the carousel + * @returns {void} */ - $app.methods.displayScreenshotMetadata = function (metadata) { + $app.methods.displayScreenshotMetadata = async function ( + json, + needsCarouselFiles = true + ) { var D = this.screenshotMetadataDialog; - var json = JSON.parse(metadata); - D.metadata = json; + var metadata = JSON.parse(json); - var regex = json.fileName.match( + // Get extra data for display dialog like resolution, file size, etc + D.loading = true; + var extraData = await AppApi.GetExtraScreenshotData( + metadata.sourceFile, + needsCarouselFiles + ); + D.loading = false; + var extraDataObj = JSON.parse(extraData); + Object.assign(metadata, extraDataObj); + + // console.log("Displaying screenshot metadata", json, "extra data", extraDataObj, "path", json.filePath) + + D.metadata = metadata; + + var regex = metadata.fileName.match( /VRChat_((\d{3,})x(\d{3,})_(\d{4})-(\d{2})-(\d{2})_(\d{2})-(\d{2})-(\d{2})\.(\d{1,})|(\d{4})-(\d{2})-(\d{2})_(\d{2})-(\d{2})-(\d{2})\.(\d{3})_(\d{3,})x(\d{3,}))/ ); if (regex) { @@ -22073,11 +22106,19 @@ speechSynthesis.getVoices(); D.metadata.dateTime = Date.parse(json.creationDate); } - this.openScreenshotMetadataDialog(); + if (this.fullscreenImageDialog?.visible) { + this.showFullscreenImageDialog(D.metadata.filePath); + } else { + this.openScreenshotMetadataDialog(); + } }; $app.data.screenshotMetadataDialog = { visible: false, + loading: false, + search: '', + searchType: 'Player Name', + searchTypes: ['Player Name', 'Player ID', 'World Name', 'World ID'], metadata: {}, isUploading: false }; @@ -22093,30 +22134,135 @@ speechSynthesis.getVoices(); $app.methods.showScreenshotMetadataDialog = function () { var D = this.screenshotMetadataDialog; if (!D.metadata.filePath) { - AppApi.GetLastScreenshot(); + this.getAndDisplayLastScreenshot(); } this.openScreenshotMetadataDialog(); }; + $app.methods.screenshotMetadataResetSearch = function () { + var D = this.screenshotMetadataDialog; + + D.search = ''; + D.searchIndex = null; + D.searchResults = null; + }; + + $app.data.screenshotMetadataSearchInputs = 0; + $app.methods.screenshotMetadataSearch = function () { + var D = this.screenshotMetadataDialog; + + // Don't search if user is still typing + this.screenshotMetadataSearchInputs++; + let current = this.screenshotMetadataSearchInputs; + setTimeout(() => { + if (current !== this.screenshotMetadataSearchInputs) { + return; + } + this.screenshotMetadataSearchInputs = 0; + + if (D.search === '') { + this.screenshotMetadataResetSearch(); + if (D.metadata.filePath !== null) { + // Re-retrieve the current screenshot metadata and get previous/next files for regular carousel directory navigation + this.getAndDisplayScreenshot(D.metadata.filePath, true); + } + return; + } + + var searchType = D.searchTypes.indexOf(D.searchType); // Matches the search type enum in .NET + D.loading = true; + AppApi.FindScreenshotsBySearch(D.search, searchType) + .then((json) => { + var results = JSON.parse(json); + + if (results.length === 0) { + D.metadata = {}; + D.metadata.error = 'No results found'; + + D.searchIndex = null; + D.searchResults = null; + return; + } + + D.searchIndex = 0; + D.searchResults = results; + + // console.log("Search results", results) + this.getAndDisplayScreenshot(results[0], false); + }) + .finally(() => { + D.loading = false; + }); + }, 500); + }; + + $app.methods.screenshotMetadataCarouselChangeSearch = function (index) { + var D = this.screenshotMetadataDialog; + var searchIndex = D.searchIndex; + var filesArr = D.searchResults; + + if (searchIndex === null) { + return; + } + + if (index === 0) { + if (searchIndex > 0) { + this.getAndDisplayScreenshot(filesArr[searchIndex - 1], false); + searchIndex--; + } else { + this.getAndDisplayScreenshot( + filesArr[filesArr.length - 1], + false + ); + searchIndex = filesArr.length - 1; + } + } else if (index === 2) { + if (searchIndex < filesArr.length - 1) { + this.getAndDisplayScreenshot(filesArr[searchIndex + 1], false); + searchIndex++; + } else { + this.getAndDisplayScreenshot(filesArr[0], false); + searchIndex = 0; + } + } + + if (typeof this.$refs.screenshotMetadataCarousel !== 'undefined') { + this.$refs.screenshotMetadataCarousel.setActiveItem(1); + } + + D.searchIndex = searchIndex; + }; + $app.methods.screenshotMetadataCarouselChange = function (index) { var D = this.screenshotMetadataDialog; + var searchIndex = D.searchIndex; + + if (searchIndex !== null) { + this.screenshotMetadataCarouselChangeSearch(index); + return; + } + if (index === 0) { if (D.metadata.previousFilePath) { - AppApi.GetScreenshotMetadata(D.metadata.previousFilePath); + this.getAndDisplayScreenshot(D.metadata.previousFilePath); } else { - AppApi.GetScreenshotMetadata(D.metadata.filePath); + this.getAndDisplayScreenshot(D.metadata.filePath); } } if (index === 2) { if (D.metadata.nextFilePath) { - AppApi.GetScreenshotMetadata(D.metadata.nextFilePath); + this.getAndDisplayScreenshot(D.metadata.nextFilePath); } else { - AppApi.GetScreenshotMetadata(D.metadata.filePath); + this.getAndDisplayScreenshot(D.metadata.filePath); } } if (typeof this.$refs.screenshotMetadataCarousel !== 'undefined') { this.$refs.screenshotMetadataCarousel.setActiveItem(1); } + + if (this.fullscreenImageDialog.visible) { + // TODO + } }; $app.methods.uploadScreenshotToGallery = function () { @@ -22165,8 +22311,10 @@ speechSynthesis.getVoices(); if (this.currentlyDroppingFile === null) { return; } - console.log('Dropped file into window: ', this.currentlyDroppingFile); - AppApi.GetScreenshotMetadata(this.currentlyDroppingFile); + console.log('Dropped file into viewer: ', this.currentlyDroppingFile); + + this.screenshotMetadataResetSearch(); + this.getAndDisplayScreenshot(this.currentlyDroppingFile); event.preventDefault(); }; diff --git a/html/src/index.pug b/html/src/index.pug index 7e2ad861..cef6dc6f 100644 --- a/html/src/index.pug +++ b/html/src/index.pug @@ -2535,21 +2535,30 @@ html //- dialog: screenshot metadata el-dialog.x-dialog(:before-close="beforeDialogClose" @mousedown.native="dialogMouseDown" @mouseup.native="dialogMouseUp" ref="screenshotMetadataDialog" :visible.sync="screenshotMetadataDialog.visible" :title="$t('dialog.screenshot_metadata.header')" width="1050px") - div(v-if="screenshotMetadataDialog.visible" @dragover.prevent @dragenter.prevent @drop="handleDrop" style="-webkit-app-region: drag") + div(v-if="screenshotMetadataDialog.visible" v-loading="screenshotMetadataDialog.loading" @dragover.prevent @dragenter.prevent @drop="handleDrop" style="-webkit-app-region: drag") span(style="margin-left:5px;color:#909399;font-family:monospace") {{ $t('dialog.screenshot_metadata.drag') }} br el-button(size="small" icon="el-icon-folder-opened" @click="AppApi.OpenScreenshotFileDialog()") {{ $t('dialog.screenshot_metadata.browse') }} - el-button(size="small" icon="el-icon-picture-outline" @click="AppApi.GetLastScreenshot()") {{ $t('dialog.screenshot_metadata.last_screenshot') }} + el-button(size="small" icon="el-icon-picture-outline" @click="getAndDisplayLastScreenshot()") {{ $t('dialog.screenshot_metadata.last_screenshot') }} el-button(size="small" icon="el-icon-copy-document" @click="copyImageToClipboard(screenshotMetadataDialog.metadata.filePath)") {{ $t('dialog.screenshot_metadata.copy_image') }} el-button(size="small" icon="el-icon-folder" @click="openImageFolder(screenshotMetadataDialog.metadata.filePath)") {{ $t('dialog.screenshot_metadata.open_folder') }} el-button(v-if="API.currentUser.$isVRCPlus && screenshotMetadataDialog.metadata.filePath" size="small" icon="el-icon-upload2" @click="uploadScreenshotToGallery") {{ $t('dialog.screenshot_metadata.upload') }} br + //- Search bar input + el-input(v-model="screenshotMetadataDialog.search" size="small" placeholder="Search" clearable style="width:200px" @input="screenshotMetadataSearch") + //- Search index/total label + template(v-if="screenshotMetadataDialog.searchIndex != null") + span(style="white-space:pre-wrap;font-size:12px;margin-left:10px") {{ (screenshotMetadataDialog.searchIndex + 1) + "/" + screenshotMetadataDialog.searchResults.length }} + //- Search type dropdown + el-select(v-model="screenshotMetadataDialog.searchType" size="small" placeholder="Search Type" style="width:150px;margin-left:10px" @change="screenshotMetadataSearch") + el-option(v-for="type in screenshotMetadataDialog.searchTypes" :key="type" :label="type" :value="type") + br br span(v-text="screenshotMetadataDialog.metadata.fileName") br - span(v-if="screenshotMetadataDialog.metadata.dateTime") {{ screenshotMetadataDialog.metadata.dateTime | formatDate('long') }} - span(v-if="screenshotMetadataDialog.metadata.fileResolution" v-text="screenshotMetadataDialog.metadata.fileResolution" style="margin-left:5px") - el-tag(v-if="screenshotMetadataDialog.metadata.fileSize" type="info" effect="plain" size="mini" style="margin-left:5px" v-text="screenshotMetadataDialog.metadata.fileSize") + span(v-if="screenshotMetadataDialog.metadata.dateTime" style="margin-right:5px") {{ screenshotMetadataDialog.metadata.dateTime | formatDate('long') }} + span(v-if="screenshotMetadataDialog.metadata.fileResolution" v-text="screenshotMetadataDialog.metadata.fileResolution" style="margin-right:5px") + el-tag(v-if="screenshotMetadataDialog.metadata.fileSize" type="info" effect="plain" size="mini" v-text="screenshotMetadataDialog.metadata.fileSize") br location(v-if="screenshotMetadataDialog.metadata.world" :location="screenshotMetadataDialog.metadata.world.instanceId" :hint="screenshotMetadataDialog.metadata.world.name") br @@ -2557,24 +2566,21 @@ html br el-carousel(ref="screenshotMetadataCarousel" :interval="0" initial-index="1" indicator-position="none" arrow="always" height="600px" style="margin-top:10px" @change="screenshotMetadataCarouselChange") el-carousel-item - el-popover(placement="top" width="700px" trigger="click") + span(placement="top" width="700px" trigger="click") img.x-link(slot="reference" v-lazy="screenshotMetadataDialog.metadata.previousFilePath" style="width:100%;height:100%;object-fit:contain") - img(v-lazy="screenshotMetadataDialog.metadata.previousFilePath" style="height:700px") el-carousel-item - el-popover(placement="top" width="700px" trigger="click") + span(placement="top" width="700px" trigger="click" @click="showFullscreenImageDialog(screenshotMetadataDialog.metadata.filePath)") img.x-link(slot="reference" v-lazy="screenshotMetadataDialog.metadata.filePath" style="width:100%;height:100%;object-fit:contain") - img(v-lazy="screenshotMetadataDialog.metadata.filePath" style="height:700px") el-carousel-item - el-popover(placement="top" width="700px" trigger="click") + span(placement="top" width="700px" trigger="click") img.x-link(slot="reference" v-lazy="screenshotMetadataDialog.metadata.nextFilePath" style="width:100%;height:100%;object-fit:contain") - img(v-lazy="screenshotMetadataDialog.metadata.nextFilePath" style="height:700px") br template(v-if="screenshotMetadataDialog.metadata.error") pre(v-text="screenshotMetadataDialog.metadata.error" style="white-space:pre-wrap;font-size:12px") br span(v-for="user in screenshotMetadataDialog.metadata.players" style="margin-top:5px") span.x-link(v-text="user.displayName" @click="lookupUser(user)") - span(v-if="user.x" v-text="'('+user.x+', '+user.y+', '+user.z+')'" style="margin-left:5px;color:#909399;font-family:monospace") + span(v-if="user.pos" v-text="'('+user.pos.x+', '+user.pos.y+', '+user.pos.z+')'" style="margin-left:5px;color:#909399;font-family:monospace") br //- dialog: change log @@ -2605,10 +2611,10 @@ html img.avatar(v-lazy="image.versions[image.versions.length - 1].file.url") //- dialog: full screen image - el-dialog.x-dialog(ref="fullscreenImageDialog" :before-close="beforeDialogClose" @mousedown.native="dialogMouseDown" @mouseup.native="dialogMouseUp" :visible.sync="fullscreenImageDialog.visible" top="5vh" width="90vw") + el-dialog.x-dialog(ref="fullscreenImageDialog" :before-close="beforeDialogClose" @mousedown.native="dialogMouseDown" @mouseup.native="dialogMouseUp" :visible.sync="fullscreenImageDialog.visible" top="3vh" width="97vw") el-button(@click="copyImageUrl(fullscreenImageDialog.imageUrl)" size="mini" icon="el-icon-s-order" circle) el-button(type="default" size="mini" icon="el-icon-download" circle @click="downloadAndSaveImage(fullscreenImageDialog.imageUrl)" style="margin-left:5px") - img(v-lazy="fullscreenImageDialog.imageUrl" style="width:100%;height:80vh;object-fit:contain") + img(v-lazy="fullscreenImageDialog.imageUrl" style="width:100%;height:100vh;object-fit:contain") //- dialog: open source software notice el-dialog.x-dialog(:before-close="beforeDialogClose" @mousedown.native="dialogMouseDown" @mouseup.native="dialogMouseUp" :visible.sync="ossDialog" :title="$t('dialog.open_source.header')" width="650px")