mirror of
https://github.com/YuzuZensai/beamboxctl.git
synced 2026-07-21 20:42:19 +00:00
🐛 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:
@@ -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)`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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)}`,
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user