mirror of
https://github.com/vrcx-team/VRCX.git
synced 2026-04-06 00:32:02 +02:00
feat: Rewrite png metadata handling, new VRC metadata (#1311)
* refactor: Move ScreenshotHelper png parsing to PNGHelper, simplify interface * refactor: Fix references to screenshotmanager * fix: Read resolution, not description * refactor: Rewrite/move all png reading logic into new class * refactor: Integrate new metadata helper functions * refactor: Add docs, re-add legacy mods support, change error handling There are no longer specific errors for each metadata type as it was just super unnecessary; A verbose log including the exception/string is now logged to file instead and a generic error is given in the UI. * fix: Show old vrc beta format images They were being treated as a non-image
This commit is contained in:
@@ -33,7 +33,8 @@ namespace VRCX
|
||||
path = newPath;
|
||||
}
|
||||
|
||||
ScreenshotHelper.WritePNGDescription(path, metadataString);
|
||||
ScreenshotHelper.WriteVRCXMetadata(metadataString, path);
|
||||
|
||||
return path;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -198,8 +198,18 @@ namespace VRCX
|
||||
return false;
|
||||
|
||||
await print.SaveAsPngAsync(tempPath);
|
||||
if (ScreenshotHelper.HasTXt(path))
|
||||
ScreenshotHelper.CopyTXt(path, tempPath);
|
||||
|
||||
using var oldPngFile = new PNGFile(path);
|
||||
using var newPngFile = new PNGFile(tempPath);
|
||||
|
||||
// Copy all iTXt chunks to new file
|
||||
var textChunks = oldPngFile.GetChunksOfType(PNGChunkTypeFilter.iTXt);
|
||||
|
||||
for (var i = 0; i < textChunks.Count; i++)
|
||||
{
|
||||
newPngFile.WriteChunk(textChunks[i]);
|
||||
}
|
||||
|
||||
File.Move(tempPath, path, true);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -35,7 +35,8 @@ public partial class AppApi
|
||||
}
|
||||
}
|
||||
|
||||
metadata.Add("fileResolution", ScreenshotHelper.ReadPNGResolution(path));
|
||||
using var png = new PNGFile(path);
|
||||
metadata.Add("fileResolution", PNGHelper.ReadResolution(png));
|
||||
|
||||
var creationDate = File.GetCreationTime(path);
|
||||
metadata.Add("creationDate", creationDate.ToString("yyyy-MM-dd HH:mm:ss"));
|
||||
|
||||
@@ -37,7 +37,7 @@ namespace VRCX
|
||||
path = newPath;
|
||||
}
|
||||
|
||||
ScreenshotHelper.WritePNGDescription(path, metadataString);
|
||||
ScreenshotHelper.WriteVRCXMetadata(path, metadataString);
|
||||
return path;
|
||||
}
|
||||
}
|
||||
|
||||
189
Dotnet/ScreenshotMetadata/PNGChunk.cs
Normal file
189
Dotnet/ScreenshotMetadata/PNGChunk.cs
Normal file
@@ -0,0 +1,189 @@
|
||||
using System;
|
||||
using System.Buffers.Binary;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
|
||||
namespace VRCX;
|
||||
|
||||
public struct PNGChunk
|
||||
{
|
||||
public int Index;
|
||||
public int Length;
|
||||
public string ChunkType;
|
||||
public PNGChunkTypeFilter ChunkTypeEnum;
|
||||
public byte[] Data;
|
||||
|
||||
private static uint[] crcTable;
|
||||
|
||||
/// <summary>
|
||||
/// Checks if the chunk data is empty
|
||||
/// </summary>
|
||||
/// <returns>True if the chunk data is empty, false otherwise</returns>
|
||||
public bool IsZero()
|
||||
{
|
||||
return Data == null || Data.Length == 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads an iTXt chunk and returns the keyword and text
|
||||
/// </summary>
|
||||
/// <returns>A tuple of the keyword and text in the iTXt chunk (keyword, text)</returns>
|
||||
/// <exception cref="Exception">Thrown if the chunk is invalid or not an iTXt chunk</exception>
|
||||
public Tuple<string, string> ReadITXtChunk()
|
||||
{
|
||||
if (this.IsZero())
|
||||
throw new Exception("Tried to read from invalid PNG chunk");
|
||||
|
||||
if (ChunkTypeEnum != PNGChunkTypeFilter.iTXt)
|
||||
throw new Exception("Cannot read text from chunk type " + ChunkType);
|
||||
|
||||
int chunkLength = this.Length;
|
||||
byte[] chunkData = this.Data;
|
||||
|
||||
/*
|
||||
* iTXt Chunk Structure:
|
||||
* Keyword: 1-79 bytes (character string)
|
||||
Null separator: 1 byte
|
||||
Compression flag: 1 byte
|
||||
Compression method: 1 byte
|
||||
Language tag: 0 or more bytes (character string)
|
||||
Null separator: 1 byte
|
||||
Translated keyword: 0 or more bytes
|
||||
Null separator: 1 byte
|
||||
Text: 0 or more bytes
|
||||
|
||||
We're treating the language tag/translated keyword as if they dont exist
|
||||
*/
|
||||
|
||||
// Parse keyword as null-terminated string
|
||||
var keywordEncoding = Encoding.GetEncoding("ISO-8859-1");
|
||||
int keywordLength = 0;
|
||||
for (int i = 0; i < chunkLength; i++)
|
||||
{
|
||||
if (chunkData[i] == 0x0)
|
||||
{
|
||||
keywordLength = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (keywordLength == 0 || keywordLength > 79 || chunkLength < keywordLength) return null;
|
||||
string keyword = keywordEncoding.GetString(chunkData, 0, keywordLength);
|
||||
|
||||
// lazy skip over the rest of the chunk
|
||||
int textOffset = keywordLength + 5;
|
||||
int textLength = chunkLength - textOffset;
|
||||
|
||||
return new Tuple<string, string>(keyword, Encoding.UTF8.GetString(chunkData, textOffset, textLength));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the IHDR chunk to extract the resolution of the PNG image.
|
||||
/// </summary>
|
||||
/// <returns>A tuple containing the width and height of the image. (width, height)</returns>
|
||||
/// <exception cref="Exception">Thrown if the chunk is invalid or not an IHDR chunk.</exception>
|
||||
public Tuple<int, int> ReadIHDRChunkResolution()
|
||||
{
|
||||
if (this.IsZero())
|
||||
throw new Exception("Tried to read from invalid PNG chunk");
|
||||
|
||||
if (ChunkTypeEnum != PNGChunkTypeFilter.IHDR)
|
||||
throw new Exception("Cannot read text from chunk type " + ChunkType);
|
||||
|
||||
/*
|
||||
* IHDR Chunk Structure:
|
||||
* Width: 4 bytes
|
||||
Height: 4 bytes
|
||||
Bit depth: 1 byte
|
||||
Color type: 1 byte
|
||||
Compression method: 1 byte
|
||||
Filter method: 1 byte
|
||||
Interlace method: 1 byte
|
||||
*/
|
||||
|
||||
int chunkLength = this.Length;
|
||||
byte[] chunkData = this.Data;
|
||||
|
||||
if (BitConverter.IsLittleEndian)
|
||||
{
|
||||
Array.Reverse(chunkData, 0, 4);
|
||||
Array.Reverse(chunkData, 4, 4);
|
||||
}
|
||||
|
||||
int width = BitConverter.ToInt32(chunkData, 0);
|
||||
int height = BitConverter.ToInt32(chunkData, 4);
|
||||
|
||||
return new Tuple<int, int>(width, height);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Constructs and returns a byte array representation of the PNG chunk. Generates a CRC.
|
||||
/// This data can be added to a PNG file as-is.
|
||||
/// </summary>
|
||||
/// <returns>A byte array containing the length, chunk type, data, and CRC of the chunk.</returns>
|
||||
/// <remarks>
|
||||
/// The byte array is structured as follows:
|
||||
/// - 4 bytes: Length of the data in big-endian format.
|
||||
/// - 4 bytes: ASCII encoded chunk type.
|
||||
/// - N bytes: Chunk data.
|
||||
/// - 4 bytes: CRC of the chunk type and data, in big-endian format.
|
||||
/// </remarks>
|
||||
public byte[] GetBytes()
|
||||
{
|
||||
byte[] chunkTypeBytes = Encoding.ASCII.GetBytes(ChunkType);
|
||||
int totalLength = Data.Length + 12; // data length + length + chunk type + crc
|
||||
byte[] result = new byte[totalLength];
|
||||
|
||||
// Copy length
|
||||
var chunkLength = BinaryPrimitives.ReverseEndianness(Data.Length);
|
||||
Buffer.BlockCopy(BitConverter.GetBytes(chunkLength), 0, result, 0, 4);
|
||||
|
||||
// Copy chunk type
|
||||
Buffer.BlockCopy(chunkTypeBytes, 0, result, 4, chunkTypeBytes.Length);
|
||||
|
||||
// Copy data
|
||||
Buffer.BlockCopy(Data, 0, result, 8, Data.Length);
|
||||
|
||||
// Calculate and copy CRC
|
||||
uint crc = Crc32(Data, 0, Data.Length, Crc32(chunkTypeBytes, 0, chunkTypeBytes.Length, 0));
|
||||
uint reversedCrc = BinaryPrimitives.ReverseEndianness(crc);
|
||||
|
||||
Buffer.BlockCopy(BitConverter.GetBytes(reversedCrc), 0, result, totalLength - 4, 4);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Crc32 implementation from
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
11
Dotnet/ScreenshotMetadata/PNGChunkTypeFilter.cs
Normal file
11
Dotnet/ScreenshotMetadata/PNGChunkTypeFilter.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
namespace VRCX;
|
||||
|
||||
public enum PNGChunkTypeFilter
|
||||
{
|
||||
UNKNOWN,
|
||||
IHDR,
|
||||
sRGB,
|
||||
iTXt,
|
||||
IDAT,
|
||||
IEND
|
||||
}
|
||||
365
Dotnet/ScreenshotMetadata/PNGFile.cs
Normal file
365
Dotnet/ScreenshotMetadata/PNGFile.cs
Normal file
@@ -0,0 +1,365 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
|
||||
namespace VRCX;
|
||||
|
||||
// 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 (x bytes) | 32-bit CRC code (4 bytes)
|
||||
public class PNGFile : IDisposable
|
||||
{
|
||||
private FileStream fileStream;
|
||||
private List<PNGChunk> metadataChunkCache = new List<PNGChunk>();
|
||||
|
||||
private static readonly byte[] pngSignatureBytes = new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A };
|
||||
private const int MAX_CHUNKS_TO_READ = 16;
|
||||
private const int CHUNK_FIELD_SIZE = 4;
|
||||
private const int CHUNK_NONDATA_SIZE = 12;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of <see cref="PNGFile"/> class with the specified file path.
|
||||
/// Opens the PNG file for reading and writing.
|
||||
/// </summary>
|
||||
/// <param name="filePath">The path to the PNG file to open for reading and writing.</param>
|
||||
public PNGFile(string filePath)
|
||||
{
|
||||
fileStream = new FileStream(filePath, FileMode.Open, FileAccess.ReadWrite, FileShare.ReadWrite, 4096);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves the first PNG chunk of the specified type from the file, or null if none were found.
|
||||
/// </summary>
|
||||
/// <param name="chunkTypeFilter">The type of chunk to search for.</param>
|
||||
/// <returns>The first chunk of the specified type, or null if none were found.</returns>
|
||||
public PNGChunk? GetChunk(PNGChunkTypeFilter chunkTypeFilter)
|
||||
{
|
||||
ReadAndCacheMetadata();
|
||||
|
||||
var chunk = metadataChunkCache.FirstOrDefault((chunk) => chunkTypeFilter.HasFlag(chunk.ChunkTypeEnum));
|
||||
if (chunk.IsZero())
|
||||
return null;
|
||||
|
||||
return chunk;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves a PNG chunk of the specified type by searching from the last 8KB of the file to the end of the file.
|
||||
/// </summary>
|
||||
/// <param name="chunkTypeFilter">The type of chunk to search for.</param>
|
||||
/// <returns>The first chunk of the specified type found, or <c>null</c> if no such chunk exists or the chunk is empty.</returns>
|
||||
/// <remarks>
|
||||
/// This method is only intended to be used to find chunks added by legacy vrc mods that append data at the end of the file.
|
||||
/// </remarks>
|
||||
public PNGChunk? GetChunkReverse(PNGChunkTypeFilter chunkTypeFilter)
|
||||
{
|
||||
var chunk = ReadChunkReverse(chunkTypeFilter);
|
||||
if (chunk.HasValue &&chunk.Value.IsZero())
|
||||
return null;
|
||||
|
||||
return chunk;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves a list of all PNG metadata chunks read from the file, in order.
|
||||
/// </summary>
|
||||
/// <returns>A list of <see cref="PNGChunk"/> objects, which represent PNG metadata chunks.</returns>
|
||||
/// <remarks>
|
||||
/// If the metadata cache is empty, it will first populate the cache by reading from the file.
|
||||
/// </remarks>
|
||||
public List<PNGChunk> GetChunks()
|
||||
{
|
||||
ReadAndCacheMetadata();
|
||||
|
||||
return metadataChunkCache.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes a new PNG chunk at the position immediately following the last cached PNG metadata chunk(before the first IDAT).
|
||||
/// </summary>
|
||||
/// <param name="chunk">The PNGChunk to write to the file stream.</param>
|
||||
/// <returns>True if the chunk was successfully written, otherwise false if the metadata cache is empty or the last chunk is invalid.</returns>
|
||||
public bool WriteChunk(PNGChunk chunk)
|
||||
{
|
||||
ReadAndCacheMetadata();
|
||||
|
||||
if (metadataChunkCache.Count == 0)
|
||||
return false;
|
||||
|
||||
var lastChunk = metadataChunkCache.LastOrDefault();
|
||||
if (lastChunk.IsZero())
|
||||
return false;
|
||||
|
||||
int newChunkPosition = lastChunk.Index + CHUNK_NONDATA_SIZE + lastChunk.Length;
|
||||
fileStream.Seek((long)newChunkPosition, SeekOrigin.Begin);
|
||||
|
||||
byte[] fileBytes = new byte[fileStream.Length - newChunkPosition];
|
||||
fileStream.ReadExactly(fileBytes, 0, (int)(fileStream.Length - newChunkPosition));
|
||||
fileStream.Seek(newChunkPosition, SeekOrigin.Begin);
|
||||
|
||||
// Write new chunk, append rest of file
|
||||
var chunkBytes = chunk.GetBytes();
|
||||
fileStream.Write(chunkBytes, 0, chunkBytes.Length);
|
||||
fileStream.Write(fileBytes, 0, fileBytes.Length);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves all PNG metadata chunks of a specified type
|
||||
/// </summary>
|
||||
/// <param name="chunkTypeFilter">The type of chunk to search for.</param>
|
||||
/// <returns>A list of <see cref="PNGChunk"/> objects that match the specified type.</returns>
|
||||
/// <remarks>
|
||||
/// If the metadata cache is empty, it will first populate the cache by reading from the file.
|
||||
/// </remarks>
|
||||
public List<PNGChunk> GetChunksOfType(PNGChunkTypeFilter chunkTypeFilter)
|
||||
{
|
||||
ReadAndCacheMetadata();
|
||||
|
||||
return metadataChunkCache.FindAll((chunk) => chunk.ChunkTypeEnum.HasFlag(chunkTypeFilter));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads PNG metadata chunks
|
||||
/// </summary>
|
||||
/// <returns>An enumerable collection of <see cref="PNGChunk"/> objects found in the file.</returns>
|
||||
/// <remarks>
|
||||
/// Each chunk is represented by a <see cref="PNGChunk"/> object containing its length, type, and data.
|
||||
/// This method reads chunks sequentially from the start of the file, up to a maximum of
|
||||
/// <see cref="MAX_CHUNKS_TO_READ"/> chunks. It stops on encountering the "IDAT" or "IEND".
|
||||
/// </remarks>
|
||||
private IEnumerable<PNGChunk> ReadChunks()
|
||||
{
|
||||
int currentIndex = pngSignatureBytes.Length;
|
||||
int chunksRead = 0;
|
||||
byte[] buffer = new byte[4];
|
||||
|
||||
while (currentIndex < fileStream.Length)
|
||||
{
|
||||
if (chunksRead >= MAX_CHUNKS_TO_READ)
|
||||
yield break;
|
||||
|
||||
chunksRead++;
|
||||
fileStream.Seek(currentIndex, SeekOrigin.Begin);
|
||||
|
||||
// Read chunk length
|
||||
fileStream.ReadExactly(buffer, 0, CHUNK_FIELD_SIZE);
|
||||
|
||||
// Convert from big endian to system endian
|
||||
if (BitConverter.IsLittleEndian)
|
||||
Array.Reverse(buffer, 0, 4);
|
||||
|
||||
int chunkLength = BitConverter.ToInt32(buffer, 0);
|
||||
if (chunkLength < 0 || chunkLength > fileStream.Length - currentIndex - CHUNK_NONDATA_SIZE)
|
||||
yield break;
|
||||
|
||||
// Read chunk type
|
||||
fileStream.ReadExactly(buffer, 0, CHUNK_FIELD_SIZE);
|
||||
|
||||
string chunkType = Encoding.ASCII.GetString(buffer, 0, CHUNK_FIELD_SIZE);
|
||||
|
||||
// Stop on start of image data
|
||||
if (chunkType == "IDAT")
|
||||
yield break;
|
||||
|
||||
// Stop if we've reached IEND somehow
|
||||
if (chunkType == "IEND")
|
||||
yield break;
|
||||
|
||||
// Read chunk data (we could make a class for PNGChunk and lazy load this instead... but the performance/memory impact of the allocations is negligible compared to IO sooo not worth. also im lazy)
|
||||
byte[] chunkData = new byte[chunkLength];
|
||||
fileStream.ReadExactly(chunkData, 0, chunkLength);
|
||||
|
||||
// Skip CRC (4 bytes)
|
||||
// fileStream.Seek(CHUNK_FIELD_SIZE, SeekOrigin.Current);
|
||||
|
||||
PNGChunk chunk = new PNGChunk
|
||||
{
|
||||
Length = chunkLength,
|
||||
ChunkType = chunkType,
|
||||
ChunkTypeEnum = ChunkNameToEnum(chunkType),
|
||||
Data = chunkData,
|
||||
Index = currentIndex
|
||||
};
|
||||
|
||||
yield return chunk;
|
||||
|
||||
// Move to next chunk
|
||||
currentIndex += CHUNK_NONDATA_SIZE + chunkLength;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Reads a PNG chunk from the end of the file, backtracking and bruteforce matching to find the first chunk of the given type.
|
||||
/// </summary>
|
||||
/// <param name="chunkTypeFilter">The type of chunk to search for.</param>
|
||||
/// <returns>The first chunk of the given type, if any were found, or <c>null</c> if none were found.</returns>
|
||||
/// <remarks>
|
||||
/// This method is used to find chunks added by mods that do not follow the PNG spec and append chunks to the end of the file.
|
||||
/// </remarks>
|
||||
private PNGChunk? ReadChunkReverse(PNGChunkTypeFilter chunkTypeFilter)
|
||||
{
|
||||
if (fileStream.Length < 8300)
|
||||
return null;
|
||||
|
||||
byte[] searchChunkBytes = Encoding.ASCII.GetBytes(ChunkTypeEnumToChunkName(chunkTypeFilter));
|
||||
|
||||
// Read last 8KB of file minus IEND length, which should be enough to find any trailing chunks added by mods not following spec.
|
||||
fileStream.Seek(fileStream.Length - 8192 - CHUNK_NONDATA_SIZE, SeekOrigin.Begin);
|
||||
|
||||
byte[] trailingBytesBuffer = new byte[8192];
|
||||
fileStream.ReadExactly(trailingBytesBuffer, 0, 8192);
|
||||
|
||||
for (int i = 0; i < trailingBytesBuffer.Length - searchChunkBytes.Length; i++)
|
||||
{
|
||||
if (trailingBytesBuffer[i] == searchChunkBytes[0] &&
|
||||
trailingBytesBuffer[i + 1] == searchChunkBytes[1] &&
|
||||
trailingBytesBuffer[i + 2] == searchChunkBytes[2] &&
|
||||
trailingBytesBuffer[i + 3] == searchChunkBytes[3])
|
||||
{
|
||||
fileStream.Seek(fileStream.Length - trailingBytesBuffer.Length - CHUNK_NONDATA_SIZE + i - CHUNK_FIELD_SIZE, SeekOrigin.Begin);
|
||||
|
||||
byte[] buffer = new byte[4];
|
||||
|
||||
// Read chunk length
|
||||
fileStream.ReadExactly(buffer, 0, CHUNK_FIELD_SIZE);
|
||||
|
||||
// Convert from big endian to system endian
|
||||
if (BitConverter.IsLittleEndian)
|
||||
Array.Reverse(buffer, 0, 4);
|
||||
|
||||
int chunkLength = BitConverter.ToInt32(buffer, 0);
|
||||
if (chunkLength < 0 || chunkLength > fileStream.Length - i - CHUNK_NONDATA_SIZE)
|
||||
return null;
|
||||
|
||||
// Read chunk type
|
||||
fileStream.ReadExactly(buffer, 0, CHUNK_FIELD_SIZE);
|
||||
|
||||
string chunkType = Encoding.ASCII.GetString(buffer, 0, CHUNK_FIELD_SIZE);
|
||||
|
||||
// Read chunk data (we could make a class for PNGChunk and lazy load this instead... but the performance/memory impact of the allocations is negligible compared to IO sooo not worth. also im lazy)
|
||||
byte[] chunkData = new byte[chunkLength];
|
||||
fileStream.ReadExactly(chunkData, 0, chunkLength);
|
||||
|
||||
// Skip CRC (4 bytes)
|
||||
fileStream.Seek(CHUNK_FIELD_SIZE, SeekOrigin.Current);
|
||||
|
||||
PNGChunk chunk = new PNGChunk
|
||||
{
|
||||
Length = chunkLength,
|
||||
ChunkType = chunkType,
|
||||
ChunkTypeEnum = ChunkNameToEnum(chunkType),
|
||||
Data = chunkData,
|
||||
};
|
||||
|
||||
return chunk;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads PNG metadata chunks from the file and caches them for later use.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This method populates <see cref="metadataChunkCache"/> with PNG chunks from <see cref="ReadChunks"/>.
|
||||
/// The method is intended to ensure that the metadata is available for multiple operations and avoid unnecessary IO.
|
||||
/// </remarks>
|
||||
private void ReadAndCacheMetadata()
|
||||
{
|
||||
if (metadataChunkCache.Count > 0)
|
||||
return;
|
||||
|
||||
if (!IsValid())
|
||||
return;
|
||||
|
||||
// literally only here because I abandoned the original usage of the enumerable impl rip
|
||||
metadataChunkCache.AddRange(ReadChunks());
|
||||
}
|
||||
|
||||
private bool FilterHasChunkType(string chunkTypeStr, PNGChunkTypeFilter chunkType)
|
||||
{
|
||||
switch (chunkTypeStr)
|
||||
{
|
||||
case "IHDR":
|
||||
return chunkType.HasFlag(PNGChunkTypeFilter.IHDR);
|
||||
case "sRGB":
|
||||
return chunkType.HasFlag(PNGChunkTypeFilter.sRGB);
|
||||
case "iTXt":
|
||||
return chunkType.HasFlag(PNGChunkTypeFilter.iTXt);
|
||||
case "IDAT":
|
||||
return chunkType.HasFlag(PNGChunkTypeFilter.IDAT);
|
||||
case "IEND":
|
||||
return chunkType.HasFlag(PNGChunkTypeFilter.IEND);
|
||||
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private PNGChunkTypeFilter ChunkNameToEnum(string chunkType)
|
||||
{
|
||||
switch (chunkType)
|
||||
{
|
||||
case "IHDR":
|
||||
return PNGChunkTypeFilter.IHDR;
|
||||
case "sRGB":
|
||||
return PNGChunkTypeFilter.sRGB;
|
||||
case "iTXt":
|
||||
return PNGChunkTypeFilter.iTXt;
|
||||
case "IDAT":
|
||||
return PNGChunkTypeFilter.IDAT;
|
||||
case "IEND":
|
||||
return PNGChunkTypeFilter.IEND;
|
||||
|
||||
}
|
||||
|
||||
return PNGChunkTypeFilter.UNKNOWN;
|
||||
}
|
||||
|
||||
private string ChunkTypeEnumToChunkName(PNGChunkTypeFilter chunkType)
|
||||
{
|
||||
switch (chunkType)
|
||||
{
|
||||
case PNGChunkTypeFilter.IHDR:
|
||||
return "IHDR";
|
||||
case PNGChunkTypeFilter.sRGB:
|
||||
return "sRGB";
|
||||
case PNGChunkTypeFilter.iTXt:
|
||||
return "iTXt";
|
||||
case PNGChunkTypeFilter.IDAT:
|
||||
return "IDAT";
|
||||
case PNGChunkTypeFilter.IEND:
|
||||
return "IEND";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether this is a valid PNG file. We do this by checking if the first 8 bytes in the file path match the PNG signature and the file is a minimum size of 57 bytes.
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
public bool IsValid()
|
||||
{
|
||||
if (fileStream.Length < 57)
|
||||
return false; // Ignore files smaller than the absolute minimum size any PNG file could theoretically be (Signature + IHDR + IDAT + IEND)
|
||||
|
||||
var signature = new byte[8];
|
||||
if (fileStream.Read(signature, 0, 8) < 8) return false;
|
||||
|
||||
return signature.SequenceEqual(pngSignatureBytes);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Disposes of the file stream.
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
fileStream.Dispose();
|
||||
}
|
||||
}
|
||||
98
Dotnet/ScreenshotMetadata/PNGHelper.cs
Normal file
98
Dotnet/ScreenshotMetadata/PNGHelper.cs
Normal file
@@ -0,0 +1,98 @@
|
||||
using SixLabors.ImageSharp.ColorSpaces;
|
||||
using System;
|
||||
using System.Buffers.Binary;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.IO.Pipes;
|
||||
using System.Linq;
|
||||
using System.Net.NetworkInformation;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace VRCX
|
||||
{
|
||||
public static class PNGHelper
|
||||
{
|
||||
public static string ReadResolution(PNGFile pngFile)
|
||||
{
|
||||
var ihdrChunk = pngFile.GetChunk(PNGChunkTypeFilter.IHDR);
|
||||
if (ihdrChunk.HasValue)
|
||||
{
|
||||
var resolution = ihdrChunk.Value.ReadIHDRChunkResolution();
|
||||
return resolution.Item1 + "x" + resolution.Item2;
|
||||
}
|
||||
|
||||
return "0x0";
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Reads the metadata associated with a specified keyword from text chunks within a PNG file.
|
||||
/// </summary>
|
||||
/// <param name="keyword">The unique keyword for a speicifc text chunk to search for.</param>
|
||||
/// <param name="pngFile">The PNG file containing the chunks to be searched.</param>
|
||||
/// <param name="legacySearch">
|
||||
/// Specifies whether to search for legacy text chunks created by older VRChat mods.
|
||||
/// If true, the function searches from the end of the file using a reverse search bruteforce method.
|
||||
/// </param>
|
||||
/// <returns>The text associated with the specified keyword, or null if not found.</returns>
|
||||
public static string ReadTextChunk(string keyword, PNGFile pngFile, bool legacySearch = false)
|
||||
{
|
||||
// Search for legacy text chunks created by old vrchat mods
|
||||
if (legacySearch)
|
||||
{
|
||||
var legacyTextChunk = pngFile.GetChunkReverse(PNGChunkTypeFilter.iTXt);
|
||||
if (legacyTextChunk.HasValue)
|
||||
{
|
||||
var data = legacyTextChunk.Value.ReadITXtChunk();
|
||||
if (data.Item1 == keyword)
|
||||
return data.Item2;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
var iTXtChunk = pngFile.GetChunksOfType(PNGChunkTypeFilter.iTXt);
|
||||
if (iTXtChunk.Count == 0)
|
||||
return null;
|
||||
|
||||
for (int i = 0; i < iTXtChunk.Count; i++)
|
||||
{
|
||||
var data = iTXtChunk[i].ReadITXtChunk();
|
||||
if (data.Item1 == keyword)
|
||||
return data.Item2;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates a PNG text chunk ready for writing.
|
||||
/// </summary>
|
||||
/// <param name="keyword">The keyword to write to the text chunk.</param>
|
||||
/// <param name="text">The text to write to the text chunk.</param>
|
||||
/// <returns>The binary data for the text chunk.</returns>
|
||||
public static PNGChunk GenerateTextChunk(string keyword, string text)
|
||||
{
|
||||
byte[] textBytes = Encoding.UTF8.GetBytes(text);
|
||||
byte[] keywordBytes = Encoding.GetEncoding("ISO-8859-1").GetBytes(keyword);
|
||||
|
||||
List<byte> constructedTextChunk = new List<byte>();
|
||||
constructedTextChunk.AddRange(keywordBytes);
|
||||
constructedTextChunk.Add(0x0); // Null separator
|
||||
constructedTextChunk.Add(0x0); // Compression flag
|
||||
constructedTextChunk.Add(0x0); // Compression method
|
||||
constructedTextChunk.Add(0x0); // Null separator (skipping over language tag byte)
|
||||
constructedTextChunk.Add(0x0); // Null separator (skipping over translated keyword byte)
|
||||
constructedTextChunk.AddRange(textBytes);
|
||||
|
||||
return new PNGChunk
|
||||
{
|
||||
ChunkType = "iTXt",
|
||||
ChunkTypeEnum = PNGChunkTypeFilter.iTXt,
|
||||
Data = constructedTextChunk.ToArray(),
|
||||
Length = constructedTextChunk.Count
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -119,89 +119,112 @@ namespace VRCX
|
||||
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))
|
||||
if (!File.Exists(path) || !path.EndsWith(".png"))
|
||||
return null;
|
||||
|
||||
// if (metadataCache.TryGetValue(path, out var cachedMetadata))
|
||||
// return cachedMetadata;
|
||||
List<string> metadata = ReadTextMetadata(path);
|
||||
ScreenshotMetadata result = new ScreenshotMetadata();
|
||||
|
||||
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"))
|
||||
for (var i = 0; i < metadata.Count; i++)
|
||||
{
|
||||
bool gotMetadata = false;
|
||||
bool gotVrchatMetadata = false;
|
||||
try
|
||||
{
|
||||
var result = ScreenshotHelper.ParseLfsPicture(metadataString);
|
||||
result.SourceFile = path;
|
||||
var metadataString = metadata[i];
|
||||
|
||||
return result;
|
||||
if (metadataString.StartsWith("<x:xmpmeta"))
|
||||
{
|
||||
result = ParseVRCImage(metadataString);
|
||||
result.SourceFile = path;
|
||||
|
||||
gotVrchatMetadata = true;
|
||||
}
|
||||
|
||||
if (metadataString.StartsWith("{") && metadataString.EndsWith("}")) // # Professional Json Validatior© 2.0
|
||||
{
|
||||
var vrcxMetadataResult = JsonConvert.DeserializeObject<ScreenshotMetadata>(metadataString);
|
||||
if (vrcxMetadataResult != null)
|
||||
{
|
||||
vrcxMetadataResult.SourceFile = path;
|
||||
if (gotVrchatMetadata)
|
||||
{
|
||||
result.Players = vrcxMetadataResult.Players;
|
||||
result.World.InstanceId = vrcxMetadataResult.World.InstanceId;
|
||||
}
|
||||
else
|
||||
{
|
||||
result = vrcxMetadataResult;
|
||||
}
|
||||
|
||||
if (includeJSON)
|
||||
result.JSON = metadataString;
|
||||
|
||||
gotMetadata = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (metadataString.StartsWith("lfs") || metadataString.StartsWith("screenshotmanager"))
|
||||
{
|
||||
result = ScreenshotHelper.ParseLfsPicture(metadataString);
|
||||
result.SourceFile = path;
|
||||
}
|
||||
}
|
||||
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.");
|
||||
Logger.Error(ex, "Failed to parse metadata for file '{0}\n---'{1}\n---", path, String.Join("\n", metadata));
|
||||
return ScreenshotMetadata.JustError(path, "Failed to parse metadata. Check log file for details.");
|
||||
}
|
||||
}
|
||||
|
||||
// If not JSON metadata, return early so we're not throwing/catching pointless exceptions
|
||||
if (!metadataString.StartsWith('{'))
|
||||
{
|
||||
// parse VRC prints
|
||||
var xmlIndex = metadataString.IndexOf("<x:xmpmeta", StringComparison.Ordinal);
|
||||
if (xmlIndex != -1)
|
||||
{
|
||||
try
|
||||
{
|
||||
var xmlString = metadataString.Substring(xmlIndex);
|
||||
// everything after index
|
||||
var result = ParseVRCImage(xmlString);
|
||||
result.SourceFile = path;
|
||||
if (result.Application == null || metadata.Count == 0)
|
||||
return ScreenshotMetadata.JustError(path, "Image has no valid metadata.");
|
||||
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error(ex, "Failed to parse VRC image XML metadata for file '{0}'", path);
|
||||
return ScreenshotMetadata.JustError(path, "Failed to parse VRC image metadata.");
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads textual metadata from a PNG image file.
|
||||
/// </summary>
|
||||
/// <param name="path">The path to the PNG image file.</param>
|
||||
/// <returns>A list of metadata strings found in the image file.</returns>
|
||||
/// <remarks>
|
||||
/// This function reads all the text chunks from the PNG image file and returns them as a list.
|
||||
/// For VRChat screenshots, the list will contain the "XML:com.adobe.xmp"(VRChat, usually) and "Description"(VRCX) chunks, with the VRChat metadata always coming first if available.
|
||||
/// The strings are not guaranteed to be valid metadata.
|
||||
/// If no metadata is found, an empty list is returned.
|
||||
/// </remarks>
|
||||
public static List<string> ReadTextMetadata(string path)
|
||||
{
|
||||
using var pngFile = new PNGFile(path);
|
||||
var result = new List<string>();
|
||||
var metadata = PNGHelper.ReadTextChunk("Description", pngFile);
|
||||
var vrchatMetadata = PNGHelper.ReadTextChunk("XML:com.adobe.xmp", pngFile);
|
||||
|
||||
if (!string.IsNullOrEmpty(vrchatMetadata))
|
||||
result.Add(vrchatMetadata);
|
||||
|
||||
if (!string.IsNullOrEmpty(metadata))
|
||||
result.Add(metadata);
|
||||
|
||||
// Check for chunk only present in files created by older modded versions of vrchat. (LFS, screenshotmanager), which put their metadata at the end of the file (which is not in spec bro).
|
||||
// Searching from the end of the file is a slower bruteforce operation so only do it if we have to.
|
||||
if (result.Count == 0 && pngFile.GetChunk(PNGChunkTypeFilter.sRGB).HasValue)
|
||||
{
|
||||
var lfsMetadata = PNGHelper.ReadTextChunk("Description", pngFile, true);
|
||||
|
||||
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.");
|
||||
if (!string.IsNullOrEmpty(lfsMetadata))
|
||||
result.Add(lfsMetadata);
|
||||
}
|
||||
|
||||
// Parse the metadata as VRCX JSON metadata
|
||||
try
|
||||
{
|
||||
var result = JsonConvert.DeserializeObject<ScreenshotMetadata>(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.");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public static bool WriteVRCXMetadata(string text, string path)
|
||||
{
|
||||
using var pngFile = new PNGFile(path);
|
||||
var chunk = PNGHelper.GenerateTextChunk("Description", text);
|
||||
return pngFile.WriteChunk(chunk);
|
||||
}
|
||||
|
||||
public static ScreenshotMetadata ParseVRCImage(string xmlString)
|
||||
@@ -254,126 +277,6 @@ namespace VRCX
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes a text description into a PNG file at the specified path.
|
||||
/// Creates an iTXt PNG chunk in the target file, using the Description tag, with the specified text.
|
||||
/// </summary>
|
||||
/// <param name="path">The file path of the PNG file in which the description is to be written.</param>
|
||||
/// <param name="text">The text description that is to be written into the PNG file.</param>
|
||||
/// <returns>
|
||||
/// <c>true</c> if the text description is successfully written to the PNG file;
|
||||
/// otherwise, <c>false</c>.
|
||||
/// </returns>
|
||||
public static bool WritePNGDescription(string path, string text)
|
||||
{
|
||||
if (!File.Exists(path) || !IsPNGFile(path))
|
||||
return false;
|
||||
|
||||
var png = File.ReadAllBytes(path);
|
||||
var newChunkIndex = FindEndOfChunk(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.
|
||||
var screenShotMetadata = GetScreenshotMetadata(path);
|
||||
if (screenShotMetadata != null && screenShotMetadata.Application == "VRCX")
|
||||
{
|
||||
Logger.Error("Screenshot file '{0}' already has VRCX metadata", path);
|
||||
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 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.
|
||||
/// </summary>
|
||||
/// <param name="path">The file path of the PNG file in which the description is to be read from.</param>
|
||||
/// <returns>
|
||||
/// The text description that is read from the PNG file.
|
||||
/// </returns>
|
||||
public static string? ReadPNGDescription(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?.GetText("Description");
|
||||
}
|
||||
|
||||
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>
|
||||
/// <param name="path">The path.</param>
|
||||
/// <returns></returns>
|
||||
public static string? ReadPNGResolution(string path)
|
||||
{
|
||||
if (!File.Exists(path) || !IsPNGFile(path)) return null;
|
||||
|
||||
using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, 512);
|
||||
var existingiHDR = FindChunk(stream, "IHDR", false);
|
||||
|
||||
return existingiHDR?.GetResolution();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether the specified file is a PNG file. We do this by checking if the first 8 bytes in the file path match the PNG signature.
|
||||
/// </summary>
|
||||
@@ -392,193 +295,6 @@ namespace VRCX
|
||||
return signature.SequenceEqual(pngSignatureBytes);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finds the index of the first of a specified chunk type in the specified PNG file.
|
||||
/// </summary>
|
||||
/// <param name="png">Array of bytes representing a PNG file.</param>
|
||||
/// <param name="type">Type of PMG chunk to find</param>
|
||||
/// <returns></returns>
|
||||
private static int FindChunkIndex(byte[] png, string type)
|
||||
{
|
||||
var chunksProcessed = 0;
|
||||
var chunkSeekLimit = 5;
|
||||
var isLittleEndian = BitConverter.IsLittleEndian;
|
||||
|
||||
// The first 8 bytes of the file are the png signature, so we can skip them.
|
||||
var index = 8;
|
||||
|
||||
while (index < png.Length)
|
||||
{
|
||||
var chunkLength = new byte[4];
|
||||
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 (isLittleEndian) Array.Reverse(chunkLength);
|
||||
|
||||
var length = BitConverter.ToInt32(chunkLength, 0);
|
||||
|
||||
// We don't need to reverse strings since UTF-8 strings aren't affected by endianess, given that they're a sequence of bytes.
|
||||
var chunkName = new byte[4];
|
||||
Array.Copy(png, index + 4, chunkName, 0, 4);
|
||||
var name = Encoding.UTF8.GetString(chunkName);
|
||||
|
||||
if (name == type)
|
||||
{
|
||||
return index;
|
||||
}
|
||||
|
||||
if (name == "IEND") // Nothing should exist past IEND in a normal png file, so we should stop parsing here to avoid trying to parse junk data.
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
|
||||
// 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)
|
||||
{
|
||||
var chunksProcessed = 0;
|
||||
var chunkSeekLimit = 5;
|
||||
var isLittleEndian = BitConverter.IsLittleEndian;
|
||||
|
||||
fs.Seek(8, SeekOrigin.Begin);
|
||||
var buffer = new byte[8];
|
||||
while (fs.Position < fs.Length)
|
||||
{
|
||||
var chunkIndex = (int)fs.Position;
|
||||
fs.ReadExactly(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
|
||||
|
||||
var chunkLength = BitConverter.ToInt32(buffer, 0);
|
||||
var 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);
|
||||
|
||||
var trailingBytes = new byte[4096];
|
||||
fs.ReadExactly(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 (var i = 0; i <= trailingBytes.Length - chunkNameBytes.Length; i++)
|
||||
{
|
||||
var isMatch = true;
|
||||
for (var 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finds the index of the end of the specified chunk type in the specified PNG file.
|
||||
/// </summary>
|
||||
/// <param name="png">Array of bytes representing a PNG file.</param>
|
||||
/// <param name="type">Type of PMG chunk to find</param>
|
||||
/// <returns></returns>
|
||||
private static int FindEndOfChunk(byte[] png, string type)
|
||||
{
|
||||
var index = FindChunkIndex(png, type);
|
||||
if (index == -1)
|
||||
return index;
|
||||
|
||||
var chunkLength = new byte[4];
|
||||
Array.Copy(png, index, chunkLength, 0, 4);
|
||||
Array.Reverse(chunkLength);
|
||||
var length = BitConverter.ToInt32(chunkLength, 0);
|
||||
|
||||
return index + length + 12;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finds the specified chunk type in the specified PNG file and returns it as a PNGChunk.
|
||||
/// </summary>
|
||||
/// <param name="png">Array of bytes representing a PNG file</param>
|
||||
/// <param name="type">Type of PMG chunk to find</param>
|
||||
/// <returns>PNGChunk</returns>
|
||||
private static PNGChunk? FindChunk(byte[] png, string type)
|
||||
{
|
||||
var index = FindChunkIndex(png, type);
|
||||
if (index == -1) return null;
|
||||
|
||||
var chunkLength = new byte[4];
|
||||
Array.Copy(png, index, chunkLength, 0, 4);
|
||||
Array.Reverse(chunkLength);
|
||||
var length = BitConverter.ToInt32(chunkLength, 0);
|
||||
|
||||
var chunkData = new byte[length];
|
||||
Array.Copy(png, index + 8, chunkData, 0, length);
|
||||
|
||||
return new PNGChunk(type, chunkData);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finds the specified chunk type in the specified PNG file and returns it as a PNGChunk.
|
||||
/// </summary>
|
||||
/// <param name="fs">FileStream of a PNG file.</param>
|
||||
/// <param name="type">Type of PMG chunk to find</param>
|
||||
/// <returns>PNGChunk</returns>
|
||||
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.ReadExactly(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.ReadExactly(chunkData, 0, length);
|
||||
|
||||
return new PNGChunk(type, chunkData);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses the metadata string of a vrchat screenshot with taken with LFS and returns a JObject containing the parsed data.
|
||||
/// </summary>
|
||||
@@ -693,134 +409,4 @@ namespace VRCX
|
||||
return metadata;
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
// This code would be HORRIBLE for general parsing of PNG files/metadata. It's not really meant to do that, it's just meant to do exactly what we need it to do.
|
||||
internal class PNGChunk
|
||||
{
|
||||
// crc lookup table
|
||||
private static uint[] crcTable;
|
||||
|
||||
// init lookup table and store crc for iTXt
|
||||
private static readonly uint iTXtCrc = Crc32(new[] { (byte)'i', (byte)'T', (byte)'X', (byte)'t' }, 0, 4, 0);
|
||||
private readonly Encoding keywordEncoding = Encoding.GetEncoding("ISO-8859-1"); // ISO-8859-1/Latin1 is the encoding used for the keyword in text chunks.
|
||||
private List<byte> ChunkDataBytes;
|
||||
private int ChunkDataLength;
|
||||
private string ChunkType;
|
||||
|
||||
public PNGChunk(string chunkType)
|
||||
{
|
||||
ChunkType = chunkType;
|
||||
ChunkDataBytes = new List<byte>();
|
||||
}
|
||||
|
||||
public PNGChunk(string chunkType, byte[] bytes)
|
||||
{
|
||||
ChunkType = chunkType;
|
||||
ChunkDataBytes = bytes.ToList();
|
||||
ChunkDataLength = bytes.Length;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes this PNGChunk's data in the format of an iTXt chunk with the specified keyword and text.
|
||||
/// </summary>
|
||||
/// <param name="keyword">Keyword for text chunk</param>
|
||||
/// <param name="text">Text data for text chunk</param>
|
||||
public void InitializeTextChunk(string keyword, string text)
|
||||
{
|
||||
// Create our chunk data byte array
|
||||
ChunkDataBytes.AddRange(keywordEncoding.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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Constructs and returns a full, coherent PNG chunk from this PNGChunk's data.
|
||||
/// </summary>
|
||||
/// <returns>PNG chunk byte array</returns>
|
||||
public byte[] ConstructChunkByteArray()
|
||||
{
|
||||
var chunk = new List<byte>();
|
||||
|
||||
var chunkLengthBytes = BitConverter.GetBytes(ChunkDataLength);
|
||||
var chunkCRCBytes = BitConverter.GetBytes(Crc32(ChunkDataBytes.ToArray(), 0, ChunkDataLength, iTXtCrc));
|
||||
|
||||
// Reverse the chunk length bytes/CRC bytes if system is little endian since PNG integers are big endian
|
||||
if (BitConverter.IsLittleEndian)
|
||||
{
|
||||
Array.Reverse(chunkLengthBytes);
|
||||
Array.Reverse(chunkCRCBytes);
|
||||
}
|
||||
|
||||
chunk.AddRange(chunkLengthBytes); // add data length
|
||||
chunk.AddRange(Encoding.UTF8.GetBytes(ChunkType)); // add chunk type
|
||||
chunk.AddRange(ChunkDataBytes); // Add chunk data
|
||||
chunk.AddRange(chunkCRCBytes); // Add chunk CRC32 hash.
|
||||
|
||||
return chunk.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the text from an iTXt chunk
|
||||
/// </summary>
|
||||
/// <param name="keyword">Keyword of the text chunk</param>
|
||||
/// <returns>Text from chunk.</returns>
|
||||
public string GetText(string keyword)
|
||||
{
|
||||
var offset = keywordEncoding.GetByteCount(keyword) + 5;
|
||||
return Encoding.UTF8.GetString(ChunkDataBytes.ToArray(), offset, ChunkDataBytes.Count - offset);
|
||||
}
|
||||
|
||||
public string GetResolution()
|
||||
{
|
||||
var x = BitConverter.ToInt32(ChunkDataBytes.Take(4).Reverse().ToArray(), 0);
|
||||
var y = BitConverter.ToInt32(ChunkDataBytes.Skip(4).Take(4).Reverse().ToArray(), 0);
|
||||
return $"{x}x{y}";
|
||||
}
|
||||
|
||||
// Crc32 implementation from
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user