mirror of
https://github.com/YuzuZensai/beamboxctl.git
synced 2026-07-21 20:42:19 +00:00
✨ feat: video/gif support
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import { describe, test, expect } from "vitest";
|
||||
import { IMBHeaderBuilder } from "./imb-header.ts";
|
||||
import { expectHex } from "../../../__tests__/utils/test-helpers.ts";
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import { describe, test, expect } from "vitest";
|
||||
import { PayloadBuilder } from "./payload-builder.ts";
|
||||
import { PacketType } from "../packet-types.ts";
|
||||
import { DEFAULT_PROTOCOL_CONFIG } from "../interfaces/defaults.ts";
|
||||
@@ -151,7 +151,7 @@ describe("PayloadBuilder", () => {
|
||||
data: createTestJpeg(100),
|
||||
},
|
||||
];
|
||||
const payload = createBuilder().buildAnimationData(frames, 50, [368, 368]);
|
||||
const payload = createBuilder().buildAnimationData(frames, 50, [360, 360]);
|
||||
const text = payload.toString("utf-8", 0, 15);
|
||||
expect(text).toMatch(/^\{"type":5,"data/);
|
||||
});
|
||||
@@ -163,7 +163,7 @@ describe("PayloadBuilder", () => {
|
||||
data: createTestJpeg(100),
|
||||
},
|
||||
];
|
||||
const payload = createBuilder().buildAnimationData(frames, 50, [368, 368]);
|
||||
const payload = createBuilder().buildAnimationData(frames, 50, [360, 360]);
|
||||
const prefix = payload.toString("utf-8", 0, 17);
|
||||
expect(prefix).toBe('{"type":5,"data":');
|
||||
});
|
||||
@@ -175,7 +175,7 @@ describe("PayloadBuilder", () => {
|
||||
data: createTestJpeg(100),
|
||||
},
|
||||
];
|
||||
const payload = createBuilder().buildAnimationData(frames, 50, [368, 368]);
|
||||
const payload = createBuilder().buildAnimationData(frames, 50, [360, 360]);
|
||||
const lastByte = payload.toString("utf-8", payload.length - 1);
|
||||
expect(lastByte).toBe("}");
|
||||
});
|
||||
@@ -187,7 +187,7 @@ describe("PayloadBuilder", () => {
|
||||
data: createTestJpeg(100),
|
||||
},
|
||||
];
|
||||
const payload = createBuilder().buildAnimationData(frames, 50, [368, 368]);
|
||||
const payload = createBuilder().buildAnimationData(frames, 50, [360, 360]);
|
||||
const prefixLen = '{"type":5,"data":'.length;
|
||||
const xv4Sig = payload.toString("utf-8", prefixLen, prefixLen + 3);
|
||||
expect(xv4Sig).toBe("xV4");
|
||||
@@ -208,7 +208,7 @@ describe("PayloadBuilder", () => {
|
||||
data: createTestJpeg(120),
|
||||
},
|
||||
];
|
||||
const payload = createBuilder().buildAnimationData(frames, 50, [368, 368]);
|
||||
const payload = createBuilder().buildAnimationData(frames, 50, [360, 360]);
|
||||
|
||||
// Check it has proper structure
|
||||
const prefixLen = '{"type":5,"data":'.length;
|
||||
@@ -220,25 +220,35 @@ describe("PayloadBuilder", () => {
|
||||
expect(frameCount).toBe(3);
|
||||
});
|
||||
|
||||
test("custom interval creates correct timing string", () => {
|
||||
test("timing string uses interval value (clamped to 50-99)", () => {
|
||||
const frames: XV4Frame[] = [
|
||||
{
|
||||
name: "frame_00001",
|
||||
data: createTestJpeg(100),
|
||||
},
|
||||
];
|
||||
const payload = createBuilder().buildAnimationData(frames, 100, [368, 368]);
|
||||
// 100ms interval gets clamped to 99ms for the timing string
|
||||
const payload = createBuilder().buildAnimationData(frames, 100, [360, 360]);
|
||||
|
||||
// The timing string is embedded in the xV4 container
|
||||
const prefixLen = '{"type":5,"data":'.length;
|
||||
const xv4Start = prefixLen;
|
||||
|
||||
// Timing string is at offset 16 (xV4 + ver + unk + pad + count + size)
|
||||
// Timing string is at offset 16, 12 bytes total
|
||||
const timingString = payload
|
||||
.subarray(xv4Start + 16, xv4Start + 35)
|
||||
.subarray(xv4Start + 16, xv4Start + 28)
|
||||
.toString("utf8")
|
||||
.replace(/\0.*$/, "");
|
||||
expect(timingString).toBe("output/100ms");
|
||||
// Timing string must fit in 12 bytes, so intervals are clamped to 50-99
|
||||
expect(timingString).toBe("output/99ms");
|
||||
|
||||
// Test with 50ms interval
|
||||
const payload50 = createBuilder().buildAnimationData(frames, 50, [360, 360]);
|
||||
const timingString50 = payload50
|
||||
.subarray(xv4Start + 16, xv4Start + 28)
|
||||
.toString("utf8")
|
||||
.replace(/\0.*$/, "");
|
||||
expect(timingString50).toBe("output/50ms");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ProtocolConfig } from "../interfaces/config.ts";
|
||||
import { PacketType } from "../packet-types.ts";
|
||||
import { IMBHeaderBuilder } from "./imb-header.ts";
|
||||
import { DEFAULT_IMAGE_CONFIG } from "../interfaces/defaults.ts";
|
||||
import { XV4HeaderBuilder, type XV4Frame } from "./xv4-header.ts";
|
||||
|
||||
/**
|
||||
@@ -44,13 +45,13 @@ export class PayloadBuilder {
|
||||
*
|
||||
* @param frames Array of frames with names and JPEG data
|
||||
* @param intervalMs Frame interval in milliseconds (default: 50ms = 20fps)
|
||||
* @param targetSize Image dimensions [width, height] (default: [368, 368])
|
||||
* @param targetSize Image dimensions [width, height] (default: [360, 360])
|
||||
* @returns Animation data payload bytes in format: {"type":5,"data":<xV4_BINARY>}
|
||||
*/
|
||||
public buildAnimationData(
|
||||
frames: XV4Frame[],
|
||||
intervalMs: number = 50,
|
||||
targetSize: [number, number] = [368, 368],
|
||||
targetSize: [number, number] = DEFAULT_IMAGE_CONFIG.animationsSize,
|
||||
): Buffer {
|
||||
const dataPrefix = Buffer.from(
|
||||
`{"type":${PacketType.DYNAMIC_AMBIENCE},"data":`,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect } from "bun:test";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { XV4HeaderBuilder, type XV4Frame } from "./xv4-header.ts";
|
||||
|
||||
describe("XV4HeaderBuilder", () => {
|
||||
@@ -11,7 +11,7 @@ describe("XV4HeaderBuilder", () => {
|
||||
},
|
||||
];
|
||||
|
||||
const container = XV4HeaderBuilder.build(frames, 50, 368, 368);
|
||||
const container = XV4HeaderBuilder.build(frames, 50, 360, 360);
|
||||
|
||||
// Check signature
|
||||
expect(container.subarray(0, 3).toString("ascii")).toBe("xV4");
|
||||
@@ -19,11 +19,14 @@ describe("XV4HeaderBuilder", () => {
|
||||
// Check version
|
||||
expect(container.readUInt8(3)).toBe(0x12);
|
||||
|
||||
// Check unknown byte
|
||||
expect(container.readUInt8(4)).toBe(0x48);
|
||||
// Check header_size field (offset 4) = frame_table_end - 8 = (32 + 1*16) - 8 = 40
|
||||
expect(container.readUInt32LE(4)).toBe(40);
|
||||
|
||||
// Check frame count (offset 8, u32 LE)
|
||||
// Check frame count (offset 8)
|
||||
expect(container.readUInt32LE(8)).toBe(1);
|
||||
|
||||
// Check unknown field (offset 12) = frame_count * 10 + 10 = 20
|
||||
expect(container.readUInt32LE(12)).toBe(20);
|
||||
});
|
||||
|
||||
it("should create a valid xV4 container with multiple frames", () => {
|
||||
@@ -42,11 +45,17 @@ describe("XV4HeaderBuilder", () => {
|
||||
},
|
||||
];
|
||||
|
||||
const container = XV4HeaderBuilder.build(frames, 50, 368, 368);
|
||||
const container = XV4HeaderBuilder.build(frames, 50, 360, 360);
|
||||
|
||||
// Check frame count (offset 8, u32 LE)
|
||||
// Check header_size field = (32 + 3*16) - 8 = 72
|
||||
expect(container.readUInt32LE(4)).toBe(72);
|
||||
|
||||
// Check frame count (offset 8)
|
||||
expect(container.readUInt32LE(8)).toBe(3);
|
||||
|
||||
// Check total data size (offset 28) = 3 * (32 + 6) = 114 (metadata + jpeg per frame)
|
||||
expect(container.readUInt32LE(28)).toBe(114);
|
||||
|
||||
// Validate it's a proper xV4 container
|
||||
expect(XV4HeaderBuilder.validate(container)).toBe(true);
|
||||
});
|
||||
@@ -59,21 +68,188 @@ describe("XV4HeaderBuilder", () => {
|
||||
},
|
||||
];
|
||||
|
||||
const container = XV4HeaderBuilder.build(frames, 100, 368, 368);
|
||||
const container = XV4HeaderBuilder.build(frames, 50, 360, 360);
|
||||
|
||||
// Timing string is after: xV4 (3) + ver (1) + unk (1) + pad (3) + count (4) + size (4) = offset 16
|
||||
// Timing string is at offset 16, 12 bytes
|
||||
const timingString = container
|
||||
.subarray(16, 35)
|
||||
.subarray(16, 28)
|
||||
.toString("utf8")
|
||||
.replace(/\0.*$/, "");
|
||||
expect(timingString).toBe("output/100ms");
|
||||
expect(timingString).toBe("output/50ms");
|
||||
});
|
||||
|
||||
it("should use interval value in timing string (clamped to 50-99)", () => {
|
||||
const frames: XV4Frame[] = [
|
||||
{
|
||||
name: "frame_00001",
|
||||
data: Buffer.from([0xff, 0xd8, 0xff, 0xe0]),
|
||||
},
|
||||
];
|
||||
|
||||
// Test with 99ms interval (within range)
|
||||
const container99 = XV4HeaderBuilder.build(frames, 99, 360, 360);
|
||||
const timingString99 = container99
|
||||
.subarray(16, 28)
|
||||
.toString("utf8")
|
||||
.replace(/\0.*$/, "");
|
||||
expect(timingString99).toBe("output/99ms");
|
||||
|
||||
// Test with 100ms interval (clamped to 99)
|
||||
const container100 = XV4HeaderBuilder.build(frames, 100, 360, 360);
|
||||
const timingString100 = container100
|
||||
.subarray(16, 28)
|
||||
.toString("utf8")
|
||||
.replace(/\0.*$/, "");
|
||||
expect(timingString100).toBe("output/99ms");
|
||||
|
||||
// Test with 30ms interval (clamped to 50)
|
||||
const container30 = XV4HeaderBuilder.build(frames, 30, 360, 360);
|
||||
const timingString30 = container30
|
||||
.subarray(16, 28)
|
||||
.toString("utf8")
|
||||
.replace(/\0.*$/, "");
|
||||
expect(timingString30).toBe("output/50ms");
|
||||
});
|
||||
|
||||
it("should throw error with no frames", () => {
|
||||
expect(() => {
|
||||
XV4HeaderBuilder.build([], 50, 368, 368);
|
||||
XV4HeaderBuilder.build([], 50, 360, 360);
|
||||
}).toThrow("At least one frame is required");
|
||||
});
|
||||
|
||||
it("should include frame names with dot suffix in frame table", () => {
|
||||
const frames: XV4Frame[] = [
|
||||
{
|
||||
name: "frame_00001",
|
||||
data: Buffer.from([0xff, 0xd8, 0xff, 0xe0]),
|
||||
},
|
||||
];
|
||||
|
||||
const container = XV4HeaderBuilder.build(frames, 50, 360, 360);
|
||||
|
||||
// Frame table starts at offset 32
|
||||
// Each entry: 12-byte name + 4-byte offset
|
||||
const frameName = container.subarray(32, 44).toString("utf8");
|
||||
expect(frameName).toBe("frame_00001.");
|
||||
});
|
||||
|
||||
it("should calculate cumulative offsets correctly", () => {
|
||||
const frames: XV4Frame[] = [
|
||||
{
|
||||
name: "frame_00001",
|
||||
data: Buffer.alloc(100), // 100 bytes
|
||||
},
|
||||
{
|
||||
name: "frame_00002",
|
||||
data: Buffer.alloc(200), // 200 bytes
|
||||
},
|
||||
{
|
||||
name: "frame_00003",
|
||||
data: Buffer.alloc(150), // 150 bytes
|
||||
},
|
||||
];
|
||||
|
||||
const container = XV4HeaderBuilder.build(frames, 50, 360, 360);
|
||||
|
||||
// Frame table end = 32 + 3*16 = 80
|
||||
const frameTableEnd = 80;
|
||||
|
||||
// Frame table offsets (each frame's metadata position):
|
||||
// Frame 0: frameTableEnd + 0 = 80
|
||||
// Frame 1: frameTableEnd + (32 + 100) = 80 + 132 = 212
|
||||
// Frame 2: frameTableEnd + (32 + 100) + (32 + 200) = 80 + 132 + 232 = 444
|
||||
|
||||
expect(container.readUInt32LE(32 + 12)).toBe(frameTableEnd); // Frame 0 offset
|
||||
expect(container.readUInt32LE(48 + 12)).toBe(frameTableEnd + 32 + 100); // Frame 1 offset
|
||||
expect(container.readUInt32LE(64 + 12)).toBe(frameTableEnd + 32 + 100 + 32 + 200); // Frame 2 offset
|
||||
});
|
||||
|
||||
it("should place JPEG data after metadata block for each frame", () => {
|
||||
const frames: XV4Frame[] = [
|
||||
{
|
||||
name: "frame_00001",
|
||||
data: Buffer.from([0xff, 0xd8, 0xff, 0xe0]),
|
||||
},
|
||||
{
|
||||
name: "frame_00002",
|
||||
data: Buffer.from([0xff, 0xd8, 0xff, 0xe1]),
|
||||
},
|
||||
];
|
||||
|
||||
const container = XV4HeaderBuilder.build(frames, 50, 360, 360);
|
||||
|
||||
// Frame table end = 32 + 2*16 = 64
|
||||
// Frame 0 metadata at 64, JPEG at 64+32 = 96
|
||||
const jpegOffset = 64 + 32;
|
||||
|
||||
// First frame's JPEG SOI marker
|
||||
expect(container.readUInt16BE(jpegOffset)).toBe(0xffd8);
|
||||
});
|
||||
|
||||
it("should include correct per-frame metadata structure", () => {
|
||||
const frames: XV4Frame[] = [
|
||||
{
|
||||
name: "frame_00001",
|
||||
data: Buffer.from([0xff, 0xd8, 0x00, 0x01, 0x02, 0x03]), // 6 bytes
|
||||
},
|
||||
{
|
||||
name: "frame_00002",
|
||||
data: Buffer.from([0xff, 0xd8, 0x10, 0x11]), // 4 bytes
|
||||
},
|
||||
];
|
||||
|
||||
const container = XV4HeaderBuilder.build(frames, 50, 360, 360);
|
||||
|
||||
// Frame table end = 32 + 2*16 = 64
|
||||
const frameTableEnd = 64;
|
||||
|
||||
// Frame 0 metadata at offset 64
|
||||
const meta0 = frameTableEnd;
|
||||
const jpeg0 = meta0 + 32; // JPEG starts after 32-byte metadata
|
||||
|
||||
// Frame 1 metadata at offset 64 + 32 + 6 = 102
|
||||
const meta1 = meta0 + 32 + 6;
|
||||
const jpeg1 = meta1 + 32;
|
||||
|
||||
// Frame 0 metadata structure:
|
||||
// [0-3] Current frame table offset
|
||||
expect(container.readUInt32LE(meta0)).toBe(meta0);
|
||||
|
||||
// [4-7] Next frame table offset (points to frame 1 metadata)
|
||||
expect(container.readUInt32LE(meta0 + 4)).toBe(meta1);
|
||||
|
||||
// [8-11] Unknown value = frame_count - 3 = -1 clamped to 0
|
||||
expect(container.readUInt32LE(meta0 + 8)).toBe(0);
|
||||
|
||||
// [12-13] Width
|
||||
expect(container.readUInt16LE(meta0 + 12)).toBe(360);
|
||||
|
||||
// [14-15] Height
|
||||
expect(container.readUInt16LE(meta0 + 14)).toBe(360);
|
||||
|
||||
// [16-19] JPEG data start offset
|
||||
expect(container.readUInt32LE(meta0 + 16)).toBe(jpeg0);
|
||||
|
||||
// [20-23] Frame 0 JPEG size
|
||||
expect(container.readUInt32LE(meta0 + 20)).toBe(6);
|
||||
|
||||
// [24-31] Padding zeros
|
||||
expect(container.readUInt32LE(meta0 + 24)).toBe(0);
|
||||
expect(container.readUInt32LE(meta0 + 28)).toBe(0);
|
||||
|
||||
// Frame 1 metadata structure:
|
||||
// [0-3] Current frame table offset
|
||||
expect(container.readUInt32LE(meta1)).toBe(meta1);
|
||||
|
||||
// [4-7] Next frame table offset (loops back to first frame for continuous playback)
|
||||
expect(container.readUInt32LE(meta1 + 4)).toBe(meta0);
|
||||
|
||||
// [16-19] JPEG data start offset
|
||||
expect(container.readUInt32LE(meta1 + 16)).toBe(jpeg1);
|
||||
|
||||
// [20-23] Frame 1 JPEG size
|
||||
expect(container.readUInt32LE(meta1 + 20)).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validate", () => {
|
||||
@@ -85,7 +261,7 @@ describe("XV4HeaderBuilder", () => {
|
||||
},
|
||||
];
|
||||
|
||||
const container = XV4HeaderBuilder.build(frames, 50, 368, 368);
|
||||
const container = XV4HeaderBuilder.build(frames, 50, 360, 360);
|
||||
expect(XV4HeaderBuilder.validate(container)).toBe(true);
|
||||
});
|
||||
|
||||
@@ -95,30 +271,43 @@ describe("XV4HeaderBuilder", () => {
|
||||
});
|
||||
|
||||
it("should reject buffer with wrong signature", () => {
|
||||
const buffer = Buffer.alloc(20);
|
||||
const buffer = Buffer.alloc(40);
|
||||
buffer.write("ABC", 0);
|
||||
buffer.writeUInt8(0x12, 3);
|
||||
buffer.writeUInt8(0x48, 4);
|
||||
|
||||
expect(XV4HeaderBuilder.validate(buffer)).toBe(false);
|
||||
});
|
||||
|
||||
it("should reject buffer with wrong version", () => {
|
||||
const buffer = Buffer.alloc(20);
|
||||
const buffer = Buffer.alloc(40);
|
||||
buffer.write("xV4", 0);
|
||||
buffer.writeUInt8(0x99, 3); // Wrong version
|
||||
buffer.writeUInt8(0x48, 4);
|
||||
|
||||
expect(XV4HeaderBuilder.validate(buffer)).toBe(false);
|
||||
});
|
||||
|
||||
it("should reject buffer with wrong unknown byte", () => {
|
||||
const buffer = Buffer.alloc(20);
|
||||
buffer.write("xV4", 0);
|
||||
buffer.writeUInt8(0x12, 3);
|
||||
buffer.writeUInt8(0x99, 4); // Wrong unknown byte
|
||||
|
||||
expect(XV4HeaderBuilder.validate(buffer)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("dump", () => {
|
||||
it("should dump xV4 container structure", () => {
|
||||
const frames: XV4Frame[] = [
|
||||
{
|
||||
name: "frame_00001",
|
||||
data: Buffer.alloc(100),
|
||||
},
|
||||
{
|
||||
name: "frame_00002",
|
||||
data: Buffer.alloc(200),
|
||||
},
|
||||
];
|
||||
|
||||
const container = XV4HeaderBuilder.build(frames, 50, 360, 360);
|
||||
const dump = XV4HeaderBuilder.dump(container);
|
||||
|
||||
expect(dump).toContain("xV4 Container Dump");
|
||||
expect(dump).toContain("Frame count: 2");
|
||||
expect(dump).toContain("frame_00001.");
|
||||
expect(dump).toContain("frame_00002.");
|
||||
expect(dump).toContain("360x360");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,30 +1,39 @@
|
||||
/**
|
||||
* xV4 Animation header builder
|
||||
*
|
||||
* The xV4 format is a animation container
|
||||
* The xV4 format is an animation container for BeamBox devices.
|
||||
*
|
||||
* ## Format Structure
|
||||
*
|
||||
* ```
|
||||
* [Magic: "xV4"] [3 bytes]
|
||||
* [Version] [1 byte] - Always 0x12
|
||||
* [Unknown] [1 byte] - Always 0x48
|
||||
* [Padding] [3 bytes] - Always 0x00 0x00 0x00
|
||||
* [Frame count] [4 bytes, LE u32]
|
||||
* [Total JPEG size] [4 bytes, LE u32] - Sum of all frame data sizes
|
||||
* [Timing string] [variable, null-terminated] - e.g., "output/50ms\0"
|
||||
* [Frame data offset] [4 bytes, LE u32] - Offset to where JPEG data starts
|
||||
* [Frame table] [variable] - Frame entries (name + size)
|
||||
* [Footer] [16 bytes] - Frame sizes (first 2), unknown, dimensions
|
||||
* [JPEG data] [variable] - Concatenated JPEG frames
|
||||
* HEADER (32 bytes fixed):
|
||||
* [0-3] "xV4" + 0x12 (signature + version)
|
||||
* [4-7] Header size = frame_table_end - 8 (uint32 LE)
|
||||
* [8-11] Frame count (uint32 LE)
|
||||
* [12-15] Unknown value = frame_count * 10 + 10 (uint32 LE)
|
||||
* [16-27] Timing string "output/XXms\0" (12 bytes, null-padded)
|
||||
* [28-31] Total data size including per-frame metadata (uint32 LE)
|
||||
*
|
||||
* FRAME TABLE (frame_count * 16 bytes):
|
||||
* Each entry (16 bytes):
|
||||
* [0-11] Frame name "frame_XXXXX." (12 bytes, dot-terminated)
|
||||
* [12-15] Cumulative offset from frame_table_end (uint32 LE)
|
||||
* This points to the per-frame metadata block, not the JPEG directly
|
||||
*
|
||||
* PER-FRAME DATA (repeated for each frame):
|
||||
* FRAME METADATA (32 bytes):
|
||||
* [0-3] Current 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
|
||||
* [8-11] Unknown value = frame_count - 3 (uint32 LE)
|
||||
* [12-13] Width (uint16 LE)
|
||||
* [14-15] Height (uint16 LE)
|
||||
* [16-19] Actual JPEG start offset in file (uint32 LE)
|
||||
* [20-23] Current frame JPEG size (uint32 LE)
|
||||
* [24-31] Padding zeros (8 bytes)
|
||||
*
|
||||
* JPEG DATA (variable size)
|
||||
* ```
|
||||
*
|
||||
* ## Frame Table Entry
|
||||
*
|
||||
* Each entry consists of:
|
||||
* - Frame name (dot-terminated string): e.g., "frame_00001."
|
||||
* - Frame size (4 bytes, LE u32): Size of this JPEG frame in bytes
|
||||
*
|
||||
*/
|
||||
|
||||
export interface XV4Frame {
|
||||
@@ -37,155 +46,196 @@ export interface XV4Frame {
|
||||
export class XV4HeaderBuilder {
|
||||
private static readonly SIGNATURE = Buffer.from("xV4");
|
||||
private static readonly VERSION = 0x12;
|
||||
private static readonly UNKNOWN_BYTE = 0x48;
|
||||
|
||||
// Fixed sizes
|
||||
private static readonly FIXED_HEADER_SIZE = 32;
|
||||
private static readonly FRAME_ENTRY_SIZE = 16;
|
||||
private static readonly FRAME_NAME_SIZE = 12;
|
||||
private static readonly FRAME_METADATA_SIZE = 32;
|
||||
|
||||
/**
|
||||
* Build complete xV4 animation container with frames
|
||||
*
|
||||
* @param frames Array of frames with names and JPEG data
|
||||
* @param intervalMs Frame interval in milliseconds (e.g., 50 for 20fps)
|
||||
* @param width Image width (default: 368)
|
||||
* @param height Image height (default: 368)
|
||||
* @param width Image width (default: 360)
|
||||
* @param height Image height (default: 360)
|
||||
* @returns Complete xV4 animation buffer ready to send
|
||||
*/
|
||||
static build(
|
||||
frames: XV4Frame[],
|
||||
intervalMs: number = 50,
|
||||
width: number = 368,
|
||||
height: number = 368,
|
||||
width: number = 360,
|
||||
height: number = 360,
|
||||
): Buffer {
|
||||
if (frames.length === 0) {
|
||||
throw new Error("At least one frame is required");
|
||||
}
|
||||
|
||||
// Calculate frame count
|
||||
const frameCount = frames.length;
|
||||
|
||||
// Total JPEG size field is it's frame_count * 1000
|
||||
const totalSizeField = frameCount * 1000;
|
||||
// Calculate sizes
|
||||
const frameTableSize = frameCount * this.FRAME_ENTRY_SIZE;
|
||||
const frameTableEnd = this.FIXED_HEADER_SIZE + frameTableSize;
|
||||
|
||||
const totalJpegSize = frames.reduce(
|
||||
(sum, frame) => sum + frame.data.length,
|
||||
// Total size = header + frame_table + (metadata + jpeg) for each frame
|
||||
const totalDataSize = frames.reduce(
|
||||
(sum, frame) => sum + this.FRAME_METADATA_SIZE + frame.data.length,
|
||||
0,
|
||||
);
|
||||
|
||||
// Build timing string with null terminator
|
||||
const timingStr = `output/${intervalMs}ms`;
|
||||
const timingBuffer = Buffer.from(timingStr + "\0", "utf-8");
|
||||
|
||||
// Calculate frame table size (each entry: name + "." + 4-byte size)
|
||||
let frameTableSize = 0;
|
||||
for (const frame of frames) {
|
||||
frameTableSize += Buffer.from(frame.name + ".", "utf-8").length + 4;
|
||||
}
|
||||
|
||||
// Footer size (from capture: 2 frame sizes + unknown + dimensions)
|
||||
const footerSize = 16;
|
||||
|
||||
// Header structure:
|
||||
const headerSize =
|
||||
3 + // signature "xV4"
|
||||
1 + // version 0x12
|
||||
1 + // unknown 0x48
|
||||
3 + // padding 0x00 0x00 0x00
|
||||
4 + // frame count (u32 LE)
|
||||
4 + // total JPEG size (u32 LE)
|
||||
timingBuffer.length + // timing string with null terminator
|
||||
4; // frame data offset (u32 LE)
|
||||
|
||||
// Frame data offset
|
||||
const frameDataOffsetValue = 0x2c000 + (headerSize - 8);
|
||||
|
||||
// Actual container size: header + frame table + footer + JPEG data
|
||||
// (We don't actually pad to frameDataOffsetValue in our container)
|
||||
const actualDataOffset = headerSize + frameTableSize + footerSize;
|
||||
const totalSize = actualDataOffset + totalJpegSize;
|
||||
const totalSize = frameTableEnd + totalDataSize;
|
||||
|
||||
const container = Buffer.alloc(totalSize);
|
||||
let offset = 0;
|
||||
|
||||
// Write signature "xV4"
|
||||
// ===== HEADER (32 bytes) =====
|
||||
|
||||
// [0-2] Signature "xV4"
|
||||
this.SIGNATURE.copy(container, offset);
|
||||
offset += 3;
|
||||
|
||||
// Write version 0x12
|
||||
// [3] Version 0x12
|
||||
container.writeUInt8(this.VERSION, offset);
|
||||
offset += 1;
|
||||
|
||||
// Write unknown byte 0x48
|
||||
container.writeUInt8(this.UNKNOWN_BYTE, offset);
|
||||
offset += 1;
|
||||
// [4-7] Header size = frame_table_end - 8
|
||||
const headerSizeField = frameTableEnd - 8;
|
||||
container.writeUInt32LE(headerSizeField, offset);
|
||||
offset += 4;
|
||||
|
||||
// Write padding (3 null bytes)
|
||||
container.writeUInt8(0x00, offset);
|
||||
offset += 1;
|
||||
container.writeUInt8(0x00, offset);
|
||||
offset += 1;
|
||||
container.writeUInt8(0x00, offset);
|
||||
offset += 1;
|
||||
|
||||
// Write frame count (u32 LE)
|
||||
// [8-11] Frame count
|
||||
container.writeUInt32LE(frameCount, offset);
|
||||
offset += 4;
|
||||
|
||||
// Write total size field (u32 LE) - this is frame_count * 1000
|
||||
container.writeUInt32LE(totalSizeField, offset);
|
||||
// [12-15] Unknown value = frame_count * 10 + 10
|
||||
const unknownValue = frameCount * 10 + 10;
|
||||
container.writeUInt32LE(unknownValue, offset);
|
||||
offset += 4;
|
||||
|
||||
// Write timing string (null-terminated) - no null byte before it!
|
||||
// [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 timingBuffer = Buffer.alloc(this.FRAME_NAME_SIZE);
|
||||
Buffer.from(timingStr, "utf-8").copy(timingBuffer);
|
||||
// Add null terminator (rest is already zeros from alloc)
|
||||
timingBuffer.copy(container, offset);
|
||||
offset += timingBuffer.length;
|
||||
offset += this.FRAME_NAME_SIZE;
|
||||
|
||||
// Write frame data offset (u32 LE) - this is a reference value, not actual offset in our buffer
|
||||
container.writeUInt32LE(frameDataOffsetValue, offset);
|
||||
// [28-31] Total data size (metadata + jpeg for all frames)
|
||||
container.writeUInt32LE(totalDataSize, offset);
|
||||
offset += 4;
|
||||
|
||||
// Write frame table
|
||||
for (const frame of frames) {
|
||||
// Write frame name with trailing dot (no null terminator)
|
||||
const nameBuffer = Buffer.from(frame.name + ".", "utf-8");
|
||||
nameBuffer.copy(container, offset);
|
||||
offset += nameBuffer.length;
|
||||
|
||||
// Write frame size (u32 LE)
|
||||
container.writeUInt32LE(frame.data.length, offset);
|
||||
offset += 4;
|
||||
}
|
||||
|
||||
// Write footer (16 bytes)
|
||||
// First frame size
|
||||
container.writeUInt32LE(frames[0]?.data.length || 0, offset);
|
||||
offset += 4;
|
||||
|
||||
// Second frame size (or first if only one frame)
|
||||
container.writeUInt32LE(
|
||||
frames[1]?.data.length || frames[0]?.data.length || 0,
|
||||
offset,
|
||||
);
|
||||
offset += 4;
|
||||
|
||||
// Unknown value (from capture: seems to be related to header size)
|
||||
// In capture it was 0x0b (11) for 3 frames
|
||||
container.writeUInt32LE(frameCount + 8, offset);
|
||||
offset += 4;
|
||||
|
||||
// Dimensions (u16 LE + u16 LE)
|
||||
container.writeUInt16LE(width, offset);
|
||||
offset += 2;
|
||||
container.writeUInt16LE(height, offset);
|
||||
offset += 2;
|
||||
|
||||
// Now offset should be at actualDataOffset (where JPEG data actually starts in our buffer)
|
||||
if (offset !== actualDataOffset) {
|
||||
// Verify we're at the right position
|
||||
if (offset !== this.FIXED_HEADER_SIZE) {
|
||||
throw new Error(
|
||||
`Frame data offset calculation error: expected ${actualDataOffset}, got ${offset}`,
|
||||
`Header size mismatch: expected ${this.FIXED_HEADER_SIZE}, got ${offset}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Write JPEG data
|
||||
for (const frame of frames) {
|
||||
// ===== FRAME TABLE (frameCount * 16 bytes) =====
|
||||
|
||||
// Calculate cumulative offsets (metadata + jpeg for each frame)
|
||||
// The offset stored is: frame_table_end + cumulative_offset_to_metadata
|
||||
const frameTableOffsets: number[] = [];
|
||||
let cumulativeOffset = 0;
|
||||
|
||||
for (let i = 0; i < frames.length; i++) {
|
||||
const frame = frames[i]!;
|
||||
|
||||
// [0-11] Frame name (12 bytes, dot-terminated, null-padded)
|
||||
const nameWithDot = frame.name + ".";
|
||||
const nameBuffer = Buffer.alloc(this.FRAME_NAME_SIZE);
|
||||
Buffer.from(nameWithDot, "utf-8").copy(
|
||||
nameBuffer,
|
||||
0,
|
||||
0,
|
||||
this.FRAME_NAME_SIZE,
|
||||
);
|
||||
nameBuffer.copy(container, offset);
|
||||
offset += this.FRAME_NAME_SIZE;
|
||||
|
||||
// [12-15] Offset = frame_table_end + cumulative offset to this frame's metadata
|
||||
const frameOffset = frameTableEnd + cumulativeOffset;
|
||||
frameTableOffsets.push(frameOffset);
|
||||
container.writeUInt32LE(frameOffset, offset);
|
||||
offset += 4;
|
||||
|
||||
// Update cumulative offset for next frame (metadata + jpeg size)
|
||||
cumulativeOffset += this.FRAME_METADATA_SIZE + frame.data.length;
|
||||
}
|
||||
|
||||
// Verify frame table end position
|
||||
if (offset !== frameTableEnd) {
|
||||
throw new Error(
|
||||
`Frame table end mismatch: expected ${frameTableEnd}, got ${offset}`,
|
||||
);
|
||||
}
|
||||
|
||||
// ===== PER-FRAME DATA (metadata + jpeg for each frame) =====
|
||||
|
||||
// TODO: Investigate unknown metadata field meaning
|
||||
const unknownMetaValue = Math.max(0, frameCount - 3); // 11 for 14 frames?
|
||||
|
||||
for (let i = 0; i < frames.length; i++) {
|
||||
const frame = frames[i]!;
|
||||
const frameSize = frame.data.length;
|
||||
|
||||
// Current frame's table offset
|
||||
const currentTableOffset = frameTableOffsets[i]!;
|
||||
// Next frame's table offset (loop back to first frame for continuous playback)
|
||||
const nextTableOffset =
|
||||
i < frames.length - 1
|
||||
? frameTableOffsets[i + 1]!
|
||||
: frameTableOffsets[0]!;
|
||||
// Actual JPEG start position in file
|
||||
const jpegStartOffset = offset + this.FRAME_METADATA_SIZE;
|
||||
|
||||
// ===== FRAME METADATA (32 bytes) =====
|
||||
|
||||
// [0-3] Current frame table offset
|
||||
container.writeUInt32LE(currentTableOffset, offset);
|
||||
offset += 4;
|
||||
|
||||
// [4-7] Next frame table offset (loops back to first frame for continuous playback)
|
||||
container.writeUInt32LE(nextTableOffset, offset);
|
||||
offset += 4;
|
||||
|
||||
// [8-11] Unknown value = frame_count - 3
|
||||
container.writeUInt32LE(unknownMetaValue, offset);
|
||||
offset += 4;
|
||||
|
||||
// [12-13] Width (uint16 LE)
|
||||
container.writeUInt16LE(width, offset);
|
||||
offset += 2;
|
||||
|
||||
// [14-15] Height (uint16 LE)
|
||||
container.writeUInt16LE(height, offset);
|
||||
offset += 2;
|
||||
|
||||
// [16-19] Actual JPEG start offset in file
|
||||
container.writeUInt32LE(jpegStartOffset, offset);
|
||||
offset += 4;
|
||||
|
||||
// [20-23] Current frame JPEG size
|
||||
container.writeUInt32LE(frameSize, offset);
|
||||
offset += 4;
|
||||
|
||||
// [24-31] Padding zeros (already zeroed from alloc)
|
||||
offset += 8;
|
||||
|
||||
// ===== FRAME JPEG DATA =====
|
||||
frame.data.copy(container, offset);
|
||||
offset += frame.data.length;
|
||||
offset += frameSize;
|
||||
}
|
||||
|
||||
// Verify final size
|
||||
if (offset !== totalSize) {
|
||||
throw new Error(
|
||||
`Total size mismatch: expected ${totalSize}, got ${offset}`,
|
||||
);
|
||||
}
|
||||
|
||||
return container;
|
||||
@@ -197,7 +247,7 @@ export class XV4HeaderBuilder {
|
||||
* @returns True if valid xV4 container
|
||||
*/
|
||||
static validate(buffer: Buffer): boolean {
|
||||
if (buffer.length < 20) {
|
||||
if (buffer.length < this.FIXED_HEADER_SIZE) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -211,11 +261,79 @@ export class XV4HeaderBuilder {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check unknown byte
|
||||
if (buffer.readUInt8(4) !== this.UNKNOWN_BYTE) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Debug helper: dump xV4 container structure
|
||||
*/
|
||||
static dump(buffer: Buffer): string {
|
||||
const lines: string[] = [];
|
||||
lines.push("=== xV4 Container Dump ===");
|
||||
|
||||
if (!this.validate(buffer)) {
|
||||
lines.push("Invalid xV4 container");
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
lines.push(`Total size: ${buffer.length} bytes`);
|
||||
lines.push("");
|
||||
|
||||
// Header
|
||||
lines.push("HEADER:");
|
||||
lines.push(` Signature: ${buffer.subarray(0, 3).toString()}`);
|
||||
lines.push(` Version: 0x${buffer.readUInt8(3).toString(16)}`);
|
||||
lines.push(` Header size field: ${buffer.readUInt32LE(4)}`);
|
||||
lines.push(` Frame count: ${buffer.readUInt32LE(8)}`);
|
||||
lines.push(` Unknown field: ${buffer.readUInt32LE(12)}`);
|
||||
|
||||
const timingStr = buffer
|
||||
.subarray(16, 28)
|
||||
.toString("utf-8")
|
||||
.replace(/\0.*$/, "");
|
||||
lines.push(` Timing string: "${timingStr}"`);
|
||||
lines.push(` Total JPEG size: ${buffer.readUInt32LE(28)}`);
|
||||
|
||||
// Frame table
|
||||
const frameCount = buffer.readUInt32LE(8);
|
||||
lines.push("");
|
||||
lines.push(`FRAME TABLE (${frameCount} entries):`);
|
||||
|
||||
let tableOffset = this.FIXED_HEADER_SIZE;
|
||||
for (let i = 0; i < frameCount && tableOffset + 16 <= buffer.length; i++) {
|
||||
const name = buffer
|
||||
.subarray(tableOffset, tableOffset + 12)
|
||||
.toString("utf-8")
|
||||
.replace(/\0.*$/, "");
|
||||
const cumOffset = buffer.readUInt32LE(tableOffset + 12);
|
||||
lines.push(` [${i}] name="${name}", cumOffset=${cumOffset}`);
|
||||
tableOffset += 16;
|
||||
}
|
||||
|
||||
// First frame metadata (32 bytes)
|
||||
const firstMetadataStart =
|
||||
this.FIXED_HEADER_SIZE + frameCount * this.FRAME_ENTRY_SIZE;
|
||||
if (firstMetadataStart + this.FRAME_METADATA_SIZE <= buffer.length) {
|
||||
lines.push("");
|
||||
lines.push("FIRST FRAME METADATA:");
|
||||
lines.push(
|
||||
` Current table offset: ${buffer.readUInt32LE(firstMetadataStart)}`,
|
||||
);
|
||||
lines.push(
|
||||
` Next table offset: ${buffer.readUInt32LE(firstMetadataStart + 4)}`,
|
||||
);
|
||||
lines.push(` Unknown: ${buffer.readUInt32LE(firstMetadataStart + 8)}`);
|
||||
lines.push(
|
||||
` Dimensions: ${buffer.readUInt16LE(firstMetadataStart + 12)}x${buffer.readUInt16LE(firstMetadataStart + 14)}`,
|
||||
);
|
||||
lines.push(
|
||||
` JPEG offset: ${buffer.readUInt32LE(firstMetadataStart + 16)}`,
|
||||
);
|
||||
lines.push(
|
||||
` Frame size: ${buffer.readUInt32LE(firstMetadataStart + 20)}`,
|
||||
);
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user