diff --git a/AppApi.cs b/AppApi.cs index 7820a04f..86e648e0 100644 --- a/AppApi.cs +++ b/AppApi.cs @@ -634,5 +634,27 @@ namespace VRCX { } } + + public void AddScreenshotMetadata(string path, string worldName, string worldId, bool changeFilename = false) + { + try + { + string fileName = Path.GetFileNameWithoutExtension(path); + if (!File.Exists(path) || !path.EndsWith(".png") || !fileName.StartsWith("VRChat_")) return; + + if (changeFilename) + { + var newFileName = $"{fileName}_{worldId}"; + var newPath = Path.Combine(Path.GetDirectoryName(path), newFileName + Path.GetExtension(path)); + File.Move(path, newPath); + path = newPath; + } + + string metadataString = $"{Program.Version}||{worldId}||{worldName}"; + + ScreenshotHelper.WritePNGDescription(path, metadataString); + } + catch { } + } } } \ No newline at end of file diff --git a/LogWatcher.cs b/LogWatcher.cs index daca175a..f54acc07 100644 --- a/LogWatcher.cs +++ b/LogWatcher.cs @@ -168,7 +168,7 @@ namespace VRCX m_FirstRun = false; } - + private void ParseLog(FileInfo fileInfo, LogContext logContext) { try @@ -227,7 +227,8 @@ namespace VRCX ParseLogUsharpVideoPlay(fileInfo, logContext, line, offset) == true || ParseLogUsharpVideoSync(fileInfo, logContext, line, offset) == true || ParseLogWorldVRCX(fileInfo, logContext, line, offset) == true || - ParseLogOnAudioConfigurationChanged(fileInfo, logContext, line, offset) == true) + ParseLogOnAudioConfigurationChanged(fileInfo, logContext, line, offset) == true || + ParseLogScreenshot(fileInfo, logContext, line, offset) == true) { continue; } @@ -344,6 +345,22 @@ namespace VRCX return false; } + private bool ParseLogScreenshot(FileInfo fileInfo, LogContext logContext, string line, int offset) + { + // This won't work with VRChat's new "Multi Layer" camera mode, since it doesn't output any logs like normal pictures. + // 2023.02.08 12:31:35 Log - [VRC Camera] Took screenshot to: C:\Users\Tea\Pictures\VRChat\2023-02\VRChat_2023-02-08_12-31-35.104_1920x1080.png + if (!line.Contains("[VRC Camera] Took screenshot to: ")) + return false; + + var lineOffset = line.LastIndexOf("] Took screenshot to: "); + if (lineOffset < 0) + return true; + + var screenshotPath = line.Substring(lineOffset + 22); + AppendLog(new[] { fileInfo.Name, ConvertLogTimeToISO8601(line), "screenshot", screenshotPath }); + return true; + } + private bool ParseLogLocationDestination(FileInfo fileInfo, LogContext logContext, string line, int offset) { // 2021.09.02 00:02:12 Log - [Behaviour] Destination set: wrld_4432ea9b-729c-46e3-8eaf-846aa0a37fdd:15609~private(usr_032383a7-748c-4fb2-94e4-bcb928e5de6b)~nonce(72CC87D420C1D49AEFFBEE8824C84B2DF0E38678E840661E) diff --git a/ScreenshotHelper.cs b/ScreenshotHelper.cs new file mode 100644 index 00000000..23f15943 --- /dev/null +++ b/ScreenshotHelper.cs @@ -0,0 +1,152 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace VRCX +{ + internal static class ScreenshotHelper + { + private static byte[] pngSignatureBytes = new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A }; + + public static bool WritePNGDescription(string path, string text) + { + if (!File.Exists(path) || !IsPNGFile(path)) return false; + + var png = File.ReadAllBytes(path); + + int newChunkIndex = FindChunk(png, "IHDR"); + if (newChunkIndex == -1) return false; + + // If this file already has a text chunk, chances are it got logged twice for some reason. Stop. + int existingiTXt = FindChunk(png, "iTXt"); + if (existingiTXt != -1) return false; + + var newChunk = new PNGChunk("iTXt"); + newChunk.InitializeTextChunk("Description", text); + + var newFile = png.ToList(); + newFile.InsertRange(newChunkIndex, newChunk.ConstructChunkByteArray()); + + File.WriteAllBytes(path, newFile.ToArray()); + + return true; + } + + public static bool IsPNGFile(string path) + { + var png = File.ReadAllBytes(path); + var pngSignature = png.Take(8).ToArray(); + return pngSignatureBytes.SequenceEqual(pngSignature); + } + + static int FindChunk(byte[] png, string type) + { + int index = 8; + + while (index < png.Length) + { + byte[] chunkLength = new byte[4]; + Array.Copy(png, index, chunkLength, 0, 4); + Array.Reverse(chunkLength); + int length = BitConverter.ToInt32(chunkLength, 0); + + byte[] chunkName = new byte[4]; + Array.Copy(png, index + 4, chunkName, 0, 4); + string name = Encoding.ASCII.GetString(chunkName); + + if (name == type) + { + return index + length + 12; + } + index += length + 12; + } + + return -1; + } + } + + // See http://www.libpng.org/pub/png/spec/1.2/PNG-Chunks.html 4.2.3 + // Basic PNG Chunk Structure: Length(int, 4 bytes) | Type (string, 4 bytes) | chunk data (Depends on type) | 32-bit CRC code (4 bytes) + // basic tEXt data structure: Keyword (1-79 bytes string) | Null separator (1 byte) | Text (x bytes) + // basic iTXt data structure: Keyword (1-79 bytes string) | Null separator (1 byte) | Compression flag (1 byte) | Compression method (1 byte) | Language tag (0-x bytes) | Null separator | Translated keyword (0-x bytes) | Null separator | Text (x bytes) + + // Proper practice here for arbitrary image processing would be to check the PNG file being passed for any existing iTXt chunks with the same keyword that we're trying to use; If we find one, we replace that chunk's data instead of creating a new chunk. + // Luckily, VRChat should never do this! Bugs notwithstanding, we should never re-process a png file either. So we're just going to skip that logic. + internal class PNGChunk + { + public string ChunkType; + public List ChunkDataBytes; + public int ChunkDataLength; + + // crc lookup table + private static uint[] crcTable; + // init lookup table and store crc for iTXt + private static uint tEXtCRC = Crc32(new byte[] { (byte)'i', (byte)'T', (byte)'X', (byte)'t' }, 0, 4, 0); + + public PNGChunk(string chunkType) + { + this.ChunkType = chunkType; + this.ChunkDataBytes = new List(); + } + + // Construct iTXt chunk data + public void InitializeTextChunk(string keyword, string text) + { + // Create our chunk data byte array + ChunkDataBytes.AddRange(Encoding.UTF8.GetBytes(keyword)); // keyword + ChunkDataBytes.Add(0x0); // Null separator + ChunkDataBytes.Add(0x0); // Compression flag + ChunkDataBytes.Add(0x0); // Compression method + ChunkDataBytes.Add(0x0); // Null separator (skipping over language tag byte) + ChunkDataBytes.Add(0x0); // Null separator (skipping over translated keyword byte) + ChunkDataBytes.AddRange(Encoding.UTF8.GetBytes(text)); // our text + + ChunkDataLength = ChunkDataBytes.Count; + } + + // Construct & return PNG chunk + public byte[] ConstructChunkByteArray() + { + List chunk = new List(); + + chunk.AddRange(BitConverter.GetBytes(ChunkDataLength).Reverse()); // add data length + chunk.AddRange(Encoding.ASCII.GetBytes(ChunkType)); // add chunk type + chunk.AddRange(ChunkDataBytes); // Add chunk data + chunk.AddRange(BitConverter.GetBytes(Crc32(ChunkDataBytes.ToArray(), 0, ChunkDataLength, tEXtCRC)).Reverse()); // Add chunk CRC32 hash + + return chunk.ToArray(); + } + + // https://web.archive.org/web/20150825201508/http://upokecenter.dreamhosters.com/articles/png-image-encoder-in-c/ + private static uint Crc32(byte[] stream, int offset, int length, uint crc) + { + uint c; + if (crcTable == null) + { + crcTable = new uint[256]; + for (uint n = 0; n <= 255; n++) + { + c = n; + for (var k = 0; k <= 7; k++) + { + if ((c & 1) == 1) + c = 0xEDB88320 ^ ((c >> 1) & 0x7FFFFFFF); + else + c = ((c >> 1) & 0x7FFFFFFF); + } + crcTable[n] = c; + } + } + c = crc ^ 0xffffffff; + var endOffset = offset + length; + for (var i = offset; i < endOffset; i++) + { + c = crcTable[(c ^ stream[i]) & 255] ^ ((c >> 8) & 0xFFFFFF); + } + return c ^ 0xffffffff; + } + } +} diff --git a/VRCX.csproj b/VRCX.csproj index 71d4f54e..eb825f71 100644 --- a/VRCX.csproj +++ b/VRCX.csproj @@ -89,6 +89,7 @@ + diff --git a/html/src/app.js b/html/src/app.js index d46903ec..66f33fda 100644 --- a/html/src/app.js +++ b/html/src/app.js @@ -9272,6 +9272,22 @@ speechSynthesis.getVoices(); this.nowPlaying.offset = parseInt(timestamp, 10); } return; + case 'screenshot': + if (!this.isGameRunning || !this.screenshotHelper) return; + + var entry = { + created_at: gameLog.dt, + type: 'Event', + //location: location, + data: "Screenshot Processed: " + gameLog.screenshotPath.replace(/^.*[\\\/]/, ''), + }; + + let world = API.parseLocation(this.lastLocation.location); + let worldID = world.worldId; + + database.addGamelogEventToDatabase(entry); + AppApi.AddScreenshotMetadata(gameLog.screenshotPath, this.lastLocation.name, worldID, this.screenshotHelperModifyFilename); + break; case 'api-request': var bias = Date.parse(gameLog.dt) + 60 * 1000; if ( @@ -13477,6 +13493,9 @@ speechSynthesis.getVoices(); 'VRCX_progressPieFilter' ); + $app.data.screenshotHelper = configRepository.getBool('VRCX_screenshotHelper'); + $app.data.screenshotHelperModifyFilename = configRepository.getBool('VRCX_screenshotHelperModifyFilename'); + $app.methods.updateVRConfigVars = function () { var notificationTheme = 'relax'; if (this.isDarkMode) { @@ -20060,6 +20079,18 @@ speechSynthesis.getVoices(); this.VRChatConfigFile.screenshot_res_width = res.width; }; + // Screenshot Helper + + $app.methods.saveScreenshotHelper = function () { + console.log("save helper toggle press") + configRepository.setBool('VRCX_screenshotHelper', this.screenshotHelper); + }; + + $app.methods.saveScreenshotHelperModifyFilename = function () { + console.log("save helper filename toggle press") + configRepository.setBool('VRCX_screenshotHelperSaveFilename', this.screenshotHelperModifyFilename); + }; + // YouTube API $app.data.youTubeApiKey = ''; diff --git a/html/src/index.pug b/html/src/index.pug index 35ae4c86..1e9dc39c 100644 --- a/html/src/index.pug +++ b/html/src/index.pug @@ -1444,6 +1444,20 @@ html div.options-container-item span.name {{ $t('view.settings.advanced.advanced.video_progress_pie.dance_world_only') }} el-switch(v-model="progressPieFilter" @change="changeYouTubeApi" :disabled="!openVR") + div.options-container + span.header {{ $t('view.settings.advanced.advanced.screenshot_helper.header') }} + div.options-container-item + span.name {{ $t('view.settings.advanced.advanced.screenshot_helper.description') }} + el-tooltip(placement="top" style="margin-left:5px" :content="$t('view.settings.advanced.advanced.screenshot_helper.description_tooltip')") + i.el-icon-info + div.options-container-item + span.name {{ $t('view.settings.advanced.advanced.screenshot_helper.enable') }} + el-switch(v-model="screenshotHelper" @change="saveScreenshotHelper") + div.options-container-item + span.name {{ $t('view.settings.advanced.advanced.screenshot_helper.modify_filename') }} + el-tooltip(placement="top" style="margin-left:5px" :content="$t('view.settings.advanced.advanced.screenshot_helper.modify_filename_tooltip')") + i.el-icon-info + el-switch(v-model="screenshotHelperModifyFilename" @change="saveScreenshotHelperModifyFilename") div.options-container(v-if="photonLoggingEnabled") span.header {{ $t('view.settings.advanced.photon.header') }} div.options-container-item diff --git a/html/src/localization/strings/en.json b/html/src/localization/strings/en.json index b1dd3467..a71460b4 100644 --- a/html/src/localization/strings/en.json +++ b/html/src/localization/strings/en.json @@ -381,6 +381,14 @@ "enable_tooltip": "Requires SteamVR overlay to be enabled", "dance_world_only": "Dance worlds only" }, + "screenshot_helper": { + "header": "Screenshot Helper", + "description": "Will store the world ID and world name in the file metadata of any pictures you take in-game.", + "description_tooltip": "Unfortunately, windows doesn't support viewing PNG text chunks(few things do) natively, but you can view it using a command-line tool like exiftool, a png chunk inspector, or a hex editor.", + "enable": "Enable", + "modify_filename": "Modify Filename", + "modify_filename_tooltip": "Will add the World ID to screenshot filename, in addition to file metadata." + }, "cache_debug": { "header": "VRCX Instance Cache/Debug", "disable_gamelog": "Disable GameLog", diff --git a/html/src/localization/strings/ko.json b/html/src/localization/strings/ko.json index 0b0106e4..49484c6e 100644 --- a/html/src/localization/strings/ko.json +++ b/html/src/localization/strings/ko.json @@ -380,6 +380,14 @@ "enable_tooltip": "스팀VR 오버레이 사용 필요", "dance_world_only": "댄스 월드에서만 사용" }, + "screenshot_helper": { + "header": "스크린샷 도우미", + "description": "게임 내에서 찍은 사진의 파일 메타데이터에 월드 ID와 월드 이름을 저장합니다.", + "description_tooltip": "안타깝게도 Windows는 기본적으로 PNG 텍스트 청크 보기를 지원하지 않지만(몇 가지 기능은 지원), exiftool, PNG chunk inspector 또는 헥스 에디터와 같은 명령줄 도구를 사용하여 볼 수 있습니다.", + "enable": "사용", + "modify_filename": "파일 이름 수정", + "modify_filename_tooltip": "파일 메타데이터 외에 스크린샷 파일 이름에 월드 ID를 추가합니다." + }, "cache_debug": { "header": "VRCX 인스턴스 캐시/디버그", "disable_gamelog": "게임 기록 하지 않기", diff --git a/html/src/localization/strings/zh_TW.json b/html/src/localization/strings/zh_TW.json index 72e8e4b7..04450653 100644 --- a/html/src/localization/strings/zh_TW.json +++ b/html/src/localization/strings/zh_TW.json @@ -374,11 +374,13 @@ "enable": "啟用", "youtube_api_key": "Youtube API 金鑰" }, - "video_progress_pie": { - "header": "影片進度圓餅疊層", + "screenshot_helper": { + "header": "屏幕截图助手", + "description": "将在你在游戏中拍摄的任何照片的文件元数据中存储世界ID和世界名称。", + "description_tooltip": "不幸的是,windows不支持本机查看PNG文本块(很多东西都不支持),但你可以使用exiftool等命令行工具、png块检查器或十六进制编辑器来查看。", "enable": "啟用", - "enable_tooltip": "需要啟用 SteamVR 疊層選項", - "dance_world_only": "僅限跳舞世界" + "modify_filename": "修改文件名", + "modify_filename_tooltip": "除了文件元数据外,将把世界ID添加到截图文件名中。" }, "cache_debug": { "header": "VRCX 世界快取/除錯", diff --git a/html/src/service/gamelog.js b/html/src/service/gamelog.js index db0cabd8..ee4b3d31 100644 --- a/html/src/service/gamelog.js +++ b/html/src/service/gamelog.js @@ -58,6 +58,10 @@ class GameLogService { gameLog.photonId = args[1]; break; + case 'screenshot': + gameLog.screenshotPath = args[0] + break; + case 'vrc-quit': break;