🐛 fix: media processing and XV4 protocol

Attempts to correct the "unknown" XV4 header and frame metadata fields and adjusted frame extraction quality and encoding to match official app. Hopefully should fix #2
This commit is contained in:
2026-06-09 03:03:53 +07:00
parent a1bcadb354
commit c368fc63e1
4 changed files with 46 additions and 37 deletions
+3 -3
View File
@@ -598,7 +598,7 @@ export class BleUploader {
onProgress( onProgress(
combinedProgress, combinedProgress,
`Uploading (${i + 1} sent, ${this.notificationHandler.packetSuccessCount} confirmed)`, `Sending: (${i + 1}/${totalChunks} packets)`,
); );
} }
@@ -631,7 +631,7 @@ export class BleUploader {
onProgress( onProgress(
combinedProgress, combinedProgress,
`Uploading (${i + 1} sent, ${this.notificationHandler.packetSuccessCount} confirmed)`, `Sending: (${this.notificationHandler.packetSuccessCount}/${totalChunks} packets)`,
); );
} }
@@ -689,7 +689,7 @@ export class BleUploader {
onProgress( onProgress(
combinedProgress, combinedProgress,
`Uploading (${this.notificationHandler.expectedAckCount} sent, ${this.notificationHandler.packetSuccessCount} confirmed)`, `Confirming: (${this.notificationHandler.packetSuccessCount}/${this.notificationHandler.expectedAckCount} packets)`,
); );
} }
+5 -6
View File
@@ -66,8 +66,8 @@ export class FrameExtractor {
} }
// Add quality settings // Add quality settings
// Use -q:v 10 for initial extraction (will be re-encoded with Sharp) // -q:v 3 matches the official app
ffmpegCmd += ` -q:v 10`; ffmpegCmd += ` -q:v 3`;
ffmpegCmd += ` "${outputPattern}"`; ffmpegCmd += ` "${outputPattern}"`;
@@ -126,13 +126,12 @@ export class FrameExtractor {
const framePromises = frameFiles.map(async (file) => { const framePromises = frameFiles.map(async (file) => {
const framePath = join(tempDir, file); const framePath = join(tempDir, file);
// Decode and re-encode to ensure consistent JPEG format with quality 75 // Decode and re-encode to ensure consistent JPEG format with quality 75 (matches official app)
// This should match official app settings: // the official app's encoder doesn't seem to optimise so optimiseCoding will be off too
// with Chroma subsampling: 4:4:4 (no subsampling, highest quality)
const reencoded = await sharp(framePath) const reencoded = await sharp(framePath)
.jpeg({ .jpeg({
quality: 75, quality: 75,
optimiseCoding: true, optimiseCoding: false,
mozjpeg: false, mozjpeg: false,
chromaSubsampling: "4:4:4", chromaSubsampling: "4:4:4",
}) })
+7 -6
View File
@@ -25,8 +25,8 @@ describe("XV4HeaderBuilder", () => {
// Check frame count (offset 8) // Check frame count (offset 8)
expect(container.readUInt32LE(8)).toBe(1); expect(container.readUInt32LE(8)).toBe(1);
// Check unknown field (offset 12) = frame_count * 10 + 10 = 20 // Check interval field (offset 12) = clamped intervalMs = 50
expect(container.readUInt32LE(12)).toBe(20); expect(container.readUInt32LE(12)).toBe(50);
}); });
it("should create a valid xV4 container with multiple frames", () => { it("should create a valid xV4 container with multiple frames", () => {
@@ -53,8 +53,9 @@ describe("XV4HeaderBuilder", () => {
// Check frame count (offset 8) // Check frame count (offset 8)
expect(container.readUInt32LE(8)).toBe(3); expect(container.readUInt32LE(8)).toBe(3);
// Check total data size (offset 28) = 3 * (32 + 6) = 114 (metadata + jpeg per frame) // Check total container size (offset 28) = frame_table_end + per-frame data
expect(container.readUInt32LE(28)).toBe(114); // = (32 + 3*16) + 3*(32 + 6) = 80 + 114 = 194
expect(container.readUInt32LE(28)).toBe(194);
// Validate it's a proper xV4 container // Validate it's a proper xV4 container
expect(XV4HeaderBuilder.validate(container)).toBe(true); expect(XV4HeaderBuilder.validate(container)).toBe(true);
@@ -218,8 +219,8 @@ describe("XV4HeaderBuilder", () => {
// [4-7] Next frame table offset (points to frame 1 metadata) // [4-7] Next frame table offset (points to frame 1 metadata)
expect(container.readUInt32LE(meta0 + 4)).toBe(meta1); expect(container.readUInt32LE(meta0 + 4)).toBe(meta1);
// [8-11] Unknown value = frame_count - 3 = -1 clamped to 0 // [8-11] Constant value 11
expect(container.readUInt32LE(meta0 + 8)).toBe(0); expect(container.readUInt32LE(meta0 + 8)).toBe(11);
// [12-13] Width // [12-13] Width
expect(container.readUInt16LE(meta0 + 12)).toBe(360); expect(container.readUInt16LE(meta0 + 12)).toBe(360);
+31 -22
View File
@@ -10,9 +10,9 @@
* [0-3] "xV4" + 0x12 (signature + version) * [0-3] "xV4" + 0x12 (signature + version)
* [4-7] Header size = frame_table_end - 8 (uint32 LE) * [4-7] Header size = frame_table_end - 8 (uint32 LE)
* [8-11] Frame count (uint32 LE) * [8-11] Frame count (uint32 LE)
* [12-15] Unknown value = frame_count * 10 + 10 (uint32 LE) * [12-15] Frame interval in milliseconds, clamped to 50-99 (uint32 LE)
* [16-27] Timing string "output/XXms\0" (12 bytes, null-padded) * [16-27] Timing string "output/XXms\0" (12 bytes, null-padded)
* [28-31] Total data size including per-frame metadata (uint32 LE) * [28-31] Total container size = frame_table_end + per-frame data (uint32 LE)
* *
* FRAME TABLE (frame_count * 16 bytes): * FRAME TABLE (frame_count * 16 bytes):
* Each entry (16 bytes): * Each entry (16 bytes):
@@ -25,7 +25,7 @@
* [0-3] Current frame table offset (uint32 LE) * [0-3] Current frame table offset (uint32 LE)
* [4-7] Next frame table offset (uint32 LE) * [4-7] Next frame table offset (uint32 LE)
* Points to next frame's metadata, or back to first frame for looping * Points to next frame's metadata, or back to first frame for looping
* [8-11] Unknown value = frame_count - 3 (uint32 LE) * [8-11] Constant value 11 (uint32 LE)
* [12-13] Width (uint16 LE) * [12-13] Width (uint16 LE)
* [14-15] Height (uint16 LE) * [14-15] Height (uint16 LE)
* [16-19] Actual JPEG start offset in file (uint32 LE) * [16-19] Actual JPEG start offset in file (uint32 LE)
@@ -78,12 +78,16 @@ export class XV4HeaderBuilder {
const frameTableSize = frameCount * this.FRAME_ENTRY_SIZE; const frameTableSize = frameCount * this.FRAME_ENTRY_SIZE;
const frameTableEnd = this.FIXED_HEADER_SIZE + frameTableSize; const frameTableEnd = this.FIXED_HEADER_SIZE + frameTableSize;
// Total size = header + frame_table + (metadata + jpeg) for each frame // Per-frame data = (metadata + jpeg) for each frame
const totalDataSize = frames.reduce( const perFrameDataSize = frames.reduce(
(sum, frame) => sum + this.FRAME_METADATA_SIZE + frame.data.length, (sum, frame) => sum + this.FRAME_METADATA_SIZE + frame.data.length,
0, 0,
); );
const totalSize = frameTableEnd + totalDataSize;
// Total size = header + frame_table + per-frame data.
// The official app writes this same value into the [28-31] field below
// (i.e. the field is the full container size, not just the per-frame data).
const totalSize = frameTableEnd + perFrameDataSize;
const container = Buffer.alloc(totalSize); const container = Buffer.alloc(totalSize);
let offset = 0; let offset = 0;
@@ -107,16 +111,17 @@ export class XV4HeaderBuilder {
container.writeUInt32LE(frameCount, offset); container.writeUInt32LE(frameCount, offset);
offset += 4; offset += 4;
// [12-15] Unknown value = frame_count * 10 + 10 // The timing string must fit in 12 bytes including null terminator, which
const unknownValue = frameCount * 10 + 10; // limits intervals to 2 digits (10-99ms) for the "output/XXms\0" format.
container.writeUInt32LE(unknownValue, offset); // Additionally, I floor-ed at 50ms to match the official app's observed range from many packet captures (50-99ms).
// (in practice intervalMs *should* always be 50, derived from the fixed 20fps extraction rate).
const clampedInterval = Math.max(50, Math.min(99, intervalMs));
// [12-15] Frame interval in milliseconds (same value as in the timing string)
container.writeUInt32LE(clampedInterval, offset);
offset += 4; offset += 4;
// [16-27] Timing string "output/XXms\0" (12 bytes, null-padded) // [16-27] Timing string "output/XXms\0" (12 bytes, null-padded)
// The timing string must fit in 12 bytes including null terminator.
// This means intervals must be 10-99ms (2 digits) to fit "output/XXms\0" format.
// Clamp intervals to 50-99 range to ensure proper format.
const clampedInterval = Math.max(50, Math.min(99, intervalMs));
const timingStr = `output/${clampedInterval}ms`; const timingStr = `output/${clampedInterval}ms`;
const timingBuffer = Buffer.alloc(this.FRAME_NAME_SIZE); const timingBuffer = Buffer.alloc(this.FRAME_NAME_SIZE);
Buffer.from(timingStr, "utf-8").copy(timingBuffer); Buffer.from(timingStr, "utf-8").copy(timingBuffer);
@@ -124,8 +129,10 @@ export class XV4HeaderBuilder {
timingBuffer.copy(container, offset); timingBuffer.copy(container, offset);
offset += this.FRAME_NAME_SIZE; offset += this.FRAME_NAME_SIZE;
// [28-31] Total data size (metadata + jpeg for all frames) // [28-31] Total container size = frame_table_end + per-frame data
container.writeUInt32LE(totalDataSize, offset); // (I checked against captures from official app, this field seems to always equals the
// full xV4 container length, not just the per-frame metadata+jpeg sum)
container.writeUInt32LE(totalSize, offset);
offset += 4; offset += 4;
// Verify we're at the right position // Verify we're at the right position
@@ -176,8 +183,8 @@ export class XV4HeaderBuilder {
// ===== PER-FRAME DATA (metadata + jpeg for each frame) ===== // ===== PER-FRAME DATA (metadata + jpeg for each frame) =====
// TODO: Investigate unknown metadata field meaning // Constant observed across all frames regardless of frame count or interval
const unknownMetaValue = Math.max(0, frameCount - 3); // 11 for 14 frames? const FRAME_META_CONSTANT = 11;
for (let i = 0; i < frames.length; i++) { for (let i = 0; i < frames.length; i++) {
const frame = frames[i]!; const frame = frames[i]!;
@@ -203,8 +210,8 @@ export class XV4HeaderBuilder {
container.writeUInt32LE(nextTableOffset, offset); container.writeUInt32LE(nextTableOffset, offset);
offset += 4; offset += 4;
// [8-11] Unknown value = frame_count - 3 // [8-11] Constant value 11
container.writeUInt32LE(unknownMetaValue, offset); container.writeUInt32LE(FRAME_META_CONSTANT, offset);
offset += 4; offset += 4;
// [12-13] Width (uint16 LE) // [12-13] Width (uint16 LE)
@@ -285,14 +292,14 @@ export class XV4HeaderBuilder {
lines.push(` Version: 0x${buffer.readUInt8(3).toString(16)}`); lines.push(` Version: 0x${buffer.readUInt8(3).toString(16)}`);
lines.push(` Header size field: ${buffer.readUInt32LE(4)}`); lines.push(` Header size field: ${buffer.readUInt32LE(4)}`);
lines.push(` Frame count: ${buffer.readUInt32LE(8)}`); lines.push(` Frame count: ${buffer.readUInt32LE(8)}`);
lines.push(` Unknown field: ${buffer.readUInt32LE(12)}`); lines.push(` Interval (ms): ${buffer.readUInt32LE(12)}`);
const timingStr = buffer const timingStr = buffer
.subarray(16, 28) .subarray(16, 28)
.toString("utf-8") .toString("utf-8")
.replace(/\0.*$/, ""); .replace(/\0.*$/, "");
lines.push(` Timing string: "${timingStr}"`); lines.push(` Timing string: "${timingStr}"`);
lines.push(` Total JPEG size: ${buffer.readUInt32LE(28)}`); lines.push(` Total container size: ${buffer.readUInt32LE(28)}`);
// Frame table // Frame table
const frameCount = buffer.readUInt32LE(8); const frameCount = buffer.readUInt32LE(8);
@@ -322,7 +329,9 @@ export class XV4HeaderBuilder {
lines.push( lines.push(
` Next table offset: ${buffer.readUInt32LE(firstMetadataStart + 4)}`, ` Next table offset: ${buffer.readUInt32LE(firstMetadataStart + 4)}`,
); );
lines.push(` Unknown: ${buffer.readUInt32LE(firstMetadataStart + 8)}`); lines.push(
` Constant: ${buffer.readUInt32LE(firstMetadataStart + 8)}`,
);
lines.push( lines.push(
` Dimensions: ${buffer.readUInt16LE(firstMetadataStart + 12)}x${buffer.readUInt16LE(firstMetadataStart + 14)}`, ` Dimensions: ${buffer.readUInt16LE(firstMetadataStart + 12)}x${buffer.readUInt16LE(firstMetadataStart + 14)}`,
); );