mirror of
https://github.com/MrUnknownDE/VRCX.git
synced 2026-04-30 12:13:48 +02:00
feat: Add options to save the current world name/id to screenshot metadata/filenames
This commit is contained in:
22
AppApi.cs
22
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 { }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -168,7 +168,7 @@ namespace VRCX
|
|||||||
|
|
||||||
m_FirstRun = false;
|
m_FirstRun = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ParseLog(FileInfo fileInfo, LogContext logContext)
|
private void ParseLog(FileInfo fileInfo, LogContext logContext)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -227,7 +227,8 @@ namespace VRCX
|
|||||||
ParseLogUsharpVideoPlay(fileInfo, logContext, line, offset) == true ||
|
ParseLogUsharpVideoPlay(fileInfo, logContext, line, offset) == true ||
|
||||||
ParseLogUsharpVideoSync(fileInfo, logContext, line, offset) == true ||
|
ParseLogUsharpVideoSync(fileInfo, logContext, line, offset) == true ||
|
||||||
ParseLogWorldVRCX(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;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -344,6 +345,22 @@ namespace VRCX
|
|||||||
return false;
|
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)
|
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)
|
// 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)
|
||||||
|
|||||||
152
ScreenshotHelper.cs
Normal file
152
ScreenshotHelper.cs
Normal file
@@ -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<byte> 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<byte>();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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<byte> chunk = new List<byte>();
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -89,6 +89,7 @@
|
|||||||
<Compile Include="ImageCache.cs" />
|
<Compile Include="ImageCache.cs" />
|
||||||
<Compile Include="IPCClient.cs" />
|
<Compile Include="IPCClient.cs" />
|
||||||
<Compile Include="IPCServer.cs" />
|
<Compile Include="IPCServer.cs" />
|
||||||
|
<Compile Include="ScreenshotHelper.cs" />
|
||||||
<Compile Include="StartupArgs.cs" />
|
<Compile Include="StartupArgs.cs" />
|
||||||
<Compile Include="Update.cs" />
|
<Compile Include="Update.cs" />
|
||||||
<Compile Include="CefService.cs" />
|
<Compile Include="CefService.cs" />
|
||||||
|
|||||||
@@ -9272,6 +9272,22 @@ speechSynthesis.getVoices();
|
|||||||
this.nowPlaying.offset = parseInt(timestamp, 10);
|
this.nowPlaying.offset = parseInt(timestamp, 10);
|
||||||
}
|
}
|
||||||
return;
|
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':
|
case 'api-request':
|
||||||
var bias = Date.parse(gameLog.dt) + 60 * 1000;
|
var bias = Date.parse(gameLog.dt) + 60 * 1000;
|
||||||
if (
|
if (
|
||||||
@@ -13477,6 +13493,9 @@ speechSynthesis.getVoices();
|
|||||||
'VRCX_progressPieFilter'
|
'VRCX_progressPieFilter'
|
||||||
);
|
);
|
||||||
|
|
||||||
|
$app.data.screenshotHelper = configRepository.getBool('VRCX_screenshotHelper');
|
||||||
|
$app.data.screenshotHelperModifyFilename = configRepository.getBool('VRCX_screenshotHelperModifyFilename');
|
||||||
|
|
||||||
$app.methods.updateVRConfigVars = function () {
|
$app.methods.updateVRConfigVars = function () {
|
||||||
var notificationTheme = 'relax';
|
var notificationTheme = 'relax';
|
||||||
if (this.isDarkMode) {
|
if (this.isDarkMode) {
|
||||||
@@ -20060,6 +20079,18 @@ speechSynthesis.getVoices();
|
|||||||
this.VRChatConfigFile.screenshot_res_width = res.width;
|
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
|
// YouTube API
|
||||||
|
|
||||||
$app.data.youTubeApiKey = '';
|
$app.data.youTubeApiKey = '';
|
||||||
|
|||||||
@@ -1444,6 +1444,20 @@ html
|
|||||||
div.options-container-item
|
div.options-container-item
|
||||||
span.name {{ $t('view.settings.advanced.advanced.video_progress_pie.dance_world_only') }}
|
span.name {{ $t('view.settings.advanced.advanced.video_progress_pie.dance_world_only') }}
|
||||||
el-switch(v-model="progressPieFilter" @change="changeYouTubeApi" :disabled="!openVR")
|
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")
|
div.options-container(v-if="photonLoggingEnabled")
|
||||||
span.header {{ $t('view.settings.advanced.photon.header') }}
|
span.header {{ $t('view.settings.advanced.photon.header') }}
|
||||||
div.options-container-item
|
div.options-container-item
|
||||||
|
|||||||
@@ -381,6 +381,14 @@
|
|||||||
"enable_tooltip": "Requires SteamVR overlay to be enabled",
|
"enable_tooltip": "Requires SteamVR overlay to be enabled",
|
||||||
"dance_world_only": "Dance worlds only"
|
"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": {
|
"cache_debug": {
|
||||||
"header": "VRCX Instance Cache/Debug",
|
"header": "VRCX Instance Cache/Debug",
|
||||||
"disable_gamelog": "Disable GameLog",
|
"disable_gamelog": "Disable GameLog",
|
||||||
|
|||||||
@@ -380,6 +380,14 @@
|
|||||||
"enable_tooltip": "스팀VR 오버레이 사용 필요",
|
"enable_tooltip": "스팀VR 오버레이 사용 필요",
|
||||||
"dance_world_only": "댄스 월드에서만 사용"
|
"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": {
|
"cache_debug": {
|
||||||
"header": "VRCX 인스턴스 캐시/디버그",
|
"header": "VRCX 인스턴스 캐시/디버그",
|
||||||
"disable_gamelog": "게임 기록 하지 않기",
|
"disable_gamelog": "게임 기록 하지 않기",
|
||||||
|
|||||||
@@ -374,11 +374,13 @@
|
|||||||
"enable": "啟用",
|
"enable": "啟用",
|
||||||
"youtube_api_key": "Youtube API 金鑰"
|
"youtube_api_key": "Youtube API 金鑰"
|
||||||
},
|
},
|
||||||
"video_progress_pie": {
|
"screenshot_helper": {
|
||||||
"header": "影片進度圓餅疊層",
|
"header": "屏幕截图助手",
|
||||||
|
"description": "将在你在游戏中拍摄的任何照片的文件元数据中存储世界ID和世界名称。",
|
||||||
|
"description_tooltip": "不幸的是,windows不支持本机查看PNG文本块(很多东西都不支持),但你可以使用exiftool等命令行工具、png块检查器或十六进制编辑器来查看。",
|
||||||
"enable": "啟用",
|
"enable": "啟用",
|
||||||
"enable_tooltip": "需要啟用 SteamVR 疊層選項",
|
"modify_filename": "修改文件名",
|
||||||
"dance_world_only": "僅限跳舞世界"
|
"modify_filename_tooltip": "除了文件元数据外,将把世界ID添加到截图文件名中。"
|
||||||
},
|
},
|
||||||
"cache_debug": {
|
"cache_debug": {
|
||||||
"header": "VRCX 世界快取/除錯",
|
"header": "VRCX 世界快取/除錯",
|
||||||
|
|||||||
@@ -58,6 +58,10 @@ class GameLogService {
|
|||||||
gameLog.photonId = args[1];
|
gameLog.photonId = args[1];
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'screenshot':
|
||||||
|
gameLog.screenshotPath = args[0]
|
||||||
|
break;
|
||||||
|
|
||||||
case 'vrc-quit':
|
case 'vrc-quit':
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user