mirror of
https://github.com/YuzuZensai/beamboxctl.git
synced 2026-07-21 20:42:19 +00:00
✨ feat: video/gif partial support
This commit is contained in:
@@ -3,6 +3,7 @@ import { PayloadBuilder } from "./payload-builder.ts";
|
||||
import { PacketType } from "../packet-types.ts";
|
||||
import { DEFAULT_PROTOCOL_CONFIG } from "../interfaces/defaults.ts";
|
||||
import type { ProtocolConfig } from "../interfaces/config.ts";
|
||||
import type { XV4Frame } from "./xv4-header.ts";
|
||||
import {
|
||||
expectHex,
|
||||
createTestJpeg,
|
||||
@@ -142,19 +143,102 @@ describe("PayloadBuilder", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildInitPayload()", () => {
|
||||
test("throws error (not implemented)", () => {
|
||||
expect(() => createBuilder().buildInitPayload()).toThrow();
|
||||
describe("buildAnimationData()", () => {
|
||||
test("format starts with {\"type\":5,\"data\"", () => {
|
||||
const frames: XV4Frame[] = [
|
||||
{
|
||||
name: "frame_00001",
|
||||
data: createTestJpeg(100),
|
||||
},
|
||||
];
|
||||
const payload = createBuilder().buildAnimationData(frames, 50, [368, 368]);
|
||||
const text = payload.toString("utf-8", 0, 15);
|
||||
expect(text).toMatch(/^\{"type":5,"data/);
|
||||
});
|
||||
|
||||
test("error message mentions Type 5/DYNAMIC_AMBIENCE", () => {
|
||||
expect(() => createBuilder().buildInitPayload()).toThrow(
|
||||
/DYNAMIC_AMBIENCE|Type 5/i
|
||||
);
|
||||
test("has correct prefix", () => {
|
||||
const frames: XV4Frame[] = [
|
||||
{
|
||||
name: "frame_00001",
|
||||
data: createTestJpeg(100),
|
||||
},
|
||||
];
|
||||
const payload = createBuilder().buildAnimationData(frames, 50, [368, 368]);
|
||||
const prefix = payload.toString("utf-8", 0, 17);
|
||||
expect(prefix).toBe('{"type":5,"data":');
|
||||
});
|
||||
|
||||
test("error message mentions not implemented", () => {
|
||||
expect(() => createBuilder().buildInitPayload()).toThrow(/not.*implemented/i);
|
||||
test("suffix is '}'", () => {
|
||||
const frames: XV4Frame[] = [
|
||||
{
|
||||
name: "frame_00001",
|
||||
data: createTestJpeg(100),
|
||||
},
|
||||
];
|
||||
const payload = createBuilder().buildAnimationData(frames, 50, [368, 368]);
|
||||
const lastByte = payload.toString("utf-8", payload.length - 1);
|
||||
expect(lastByte).toBe("}");
|
||||
});
|
||||
|
||||
test("xV4 header present after prefix", () => {
|
||||
const frames: XV4Frame[] = [
|
||||
{
|
||||
name: "frame_00001",
|
||||
data: createTestJpeg(100),
|
||||
},
|
||||
];
|
||||
const payload = createBuilder().buildAnimationData(frames, 50, [368, 368]);
|
||||
const prefixLen = '{"type":5,"data":'.length;
|
||||
const xv4Sig = payload.toString("utf-8", prefixLen, prefixLen + 3);
|
||||
expect(xv4Sig).toBe("xV4");
|
||||
});
|
||||
|
||||
test("works with multiple frames", () => {
|
||||
const frames: XV4Frame[] = [
|
||||
{
|
||||
name: "frame_00001",
|
||||
data: createTestJpeg(100),
|
||||
},
|
||||
{
|
||||
name: "frame_00002",
|
||||
data: createTestJpeg(150),
|
||||
},
|
||||
{
|
||||
name: "frame_00003",
|
||||
data: createTestJpeg(120),
|
||||
},
|
||||
];
|
||||
const payload = createBuilder().buildAnimationData(frames, 50, [368, 368]);
|
||||
|
||||
// Check it has proper structure
|
||||
const prefixLen = '{"type":5,"data":'.length;
|
||||
const xv4Sig = payload.toString("utf-8", prefixLen, prefixLen + 3);
|
||||
expect(xv4Sig).toBe("xV4");
|
||||
|
||||
// Check frame count in xV4 header (offset 8, u32 LE)
|
||||
const frameCount = payload.readUInt32LE(prefixLen + 8);
|
||||
expect(frameCount).toBe(3);
|
||||
});
|
||||
|
||||
test("custom interval creates correct timing string", () => {
|
||||
const frames: XV4Frame[] = [
|
||||
{
|
||||
name: "frame_00001",
|
||||
data: createTestJpeg(100),
|
||||
},
|
||||
];
|
||||
const payload = createBuilder().buildAnimationData(frames, 100, [368, 368]);
|
||||
|
||||
// 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)
|
||||
const timingString = payload
|
||||
.subarray(xv4Start + 16, xv4Start + 35)
|
||||
.toString("utf8")
|
||||
.replace(/\0.*$/, "");
|
||||
expect(timingString).toBe("output/100ms");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ProtocolConfig } from "../interfaces/config.ts";
|
||||
import { PacketType } from "../packet-types.ts";
|
||||
import { IMBHeaderBuilder } from "./imb-header.ts";
|
||||
import { XV4HeaderBuilder, type XV4Frame } from "./xv4-header.ts";
|
||||
|
||||
/**
|
||||
* Builder for creating protocol payloads and packets for image uploads
|
||||
@@ -39,33 +40,33 @@ export class PayloadBuilder {
|
||||
}
|
||||
|
||||
/**
|
||||
* Build initialization payload for dynamic ambience mode (animations, gallery)
|
||||
* Build animation data payload for dynamic ambience mode
|
||||
*
|
||||
* Type 5 (DYNAMIC_AMBIENCE) is used for:
|
||||
* - Video uploads (frames extracted via FFmpeg)
|
||||
* - GIF uploads (frames separated)
|
||||
* - Gallery/slideshow mode (multiple images converted to animation)
|
||||
*
|
||||
* Process:
|
||||
* 1. Extract/convert frames using FFmpeg
|
||||
* 2. Send info packet: {"type":6,"number":1} (uses buildImageInfo)
|
||||
* 3. Send data packet: {"type":5,"data":<xV4_ANIMATION>}
|
||||
*
|
||||
* Animation data format (needs implementation):
|
||||
* - Signature: "xV4" (0x78 0x56 0x34)
|
||||
* - Frame timing: "output/50ms" or similar
|
||||
* - Frame references: "frame_00001", "frame_00002", etc.
|
||||
* - Multiple JPEG frames embedded
|
||||
*
|
||||
* @throws {Error} Dynamic ambience feature is not yet implemented
|
||||
* @deprecated This feature is not implemented yet
|
||||
* @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])
|
||||
* @returns Animation data payload bytes in format: {"type":5,"data":<xV4_BINARY>}
|
||||
*/
|
||||
public buildInitPayload(): Buffer {
|
||||
throw new Error(
|
||||
"Dynamic ambience (PacketType.DYNAMIC_AMBIENCE) is not yet implemented. " +
|
||||
"This feature is required for video/GIF upload and gallery/slideshow mode. " +
|
||||
"Only static single image upload (PacketType.IMAGE) is currently supported.",
|
||||
public buildAnimationData(
|
||||
frames: XV4Frame[],
|
||||
intervalMs: number = 50,
|
||||
targetSize: [number, number] = [368, 368],
|
||||
): Buffer {
|
||||
const dataPrefix = Buffer.from(
|
||||
`{"type":${PacketType.DYNAMIC_AMBIENCE},"data":`,
|
||||
"utf-8",
|
||||
);
|
||||
const dataSuffix = Buffer.from("}", "utf-8");
|
||||
|
||||
// Build xV4 animation container
|
||||
const xv4Container = XV4HeaderBuilder.build(
|
||||
frames,
|
||||
intervalMs,
|
||||
targetSize[0],
|
||||
targetSize[1],
|
||||
);
|
||||
|
||||
return Buffer.concat([dataPrefix, xv4Container, dataSuffix]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import { describe, it, expect } from "bun:test";
|
||||
import { XV4HeaderBuilder, type XV4Frame } from "./xv4-header.ts";
|
||||
|
||||
describe("XV4HeaderBuilder", () => {
|
||||
describe("build", () => {
|
||||
it("should create a valid xV4 container with single frame", () => {
|
||||
const frames: XV4Frame[] = [
|
||||
{
|
||||
name: "frame_00001",
|
||||
data: Buffer.from([0xff, 0xd8, 0xff, 0xe0]), // Fake JPEG header
|
||||
},
|
||||
];
|
||||
|
||||
const container = XV4HeaderBuilder.build(frames, 50, 368, 368);
|
||||
|
||||
// Check signature
|
||||
expect(container.subarray(0, 3).toString("ascii")).toBe("xV4");
|
||||
|
||||
// Check version
|
||||
expect(container.readUInt8(3)).toBe(0x12);
|
||||
|
||||
// Check unknown byte
|
||||
expect(container.readUInt8(4)).toBe(0x48);
|
||||
|
||||
// Check frame count (offset 8, u32 LE)
|
||||
expect(container.readUInt32LE(8)).toBe(1);
|
||||
});
|
||||
|
||||
it("should create a valid xV4 container with multiple frames", () => {
|
||||
const frames: XV4Frame[] = [
|
||||
{
|
||||
name: "frame_00001",
|
||||
data: Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10]),
|
||||
},
|
||||
{
|
||||
name: "frame_00002",
|
||||
data: Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x11]),
|
||||
},
|
||||
{
|
||||
name: "frame_00003",
|
||||
data: Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x12]),
|
||||
},
|
||||
];
|
||||
|
||||
const container = XV4HeaderBuilder.build(frames, 50, 368, 368);
|
||||
|
||||
// Check frame count (offset 8, u32 LE)
|
||||
expect(container.readUInt32LE(8)).toBe(3);
|
||||
|
||||
// Validate it's a proper xV4 container
|
||||
expect(XV4HeaderBuilder.validate(container)).toBe(true);
|
||||
});
|
||||
|
||||
it("should include correct timing string", () => {
|
||||
const frames: XV4Frame[] = [
|
||||
{
|
||||
name: "frame_00001",
|
||||
data: Buffer.from([0xff, 0xd8, 0xff, 0xe0]),
|
||||
},
|
||||
];
|
||||
|
||||
const container = XV4HeaderBuilder.build(frames, 100, 368, 368);
|
||||
|
||||
// Timing string is after: xV4 (3) + ver (1) + unk (1) + pad (3) + count (4) + size (4) = offset 16
|
||||
const timingString = container
|
||||
.subarray(16, 35)
|
||||
.toString("utf8")
|
||||
.replace(/\0.*$/, "");
|
||||
expect(timingString).toBe("output/100ms");
|
||||
});
|
||||
|
||||
it("should throw error with no frames", () => {
|
||||
expect(() => {
|
||||
XV4HeaderBuilder.build([], 50, 368, 368);
|
||||
}).toThrow("At least one frame is required");
|
||||
});
|
||||
});
|
||||
|
||||
describe("validate", () => {
|
||||
it("should validate a proper xV4 container", () => {
|
||||
const frames: XV4Frame[] = [
|
||||
{
|
||||
name: "frame_00001",
|
||||
data: Buffer.from([0xff, 0xd8, 0xff, 0xe0]),
|
||||
},
|
||||
];
|
||||
|
||||
const container = XV4HeaderBuilder.build(frames, 50, 368, 368);
|
||||
expect(XV4HeaderBuilder.validate(container)).toBe(true);
|
||||
});
|
||||
|
||||
it("should reject buffer that is too small", () => {
|
||||
const buffer = Buffer.alloc(10);
|
||||
expect(XV4HeaderBuilder.validate(buffer)).toBe(false);
|
||||
});
|
||||
|
||||
it("should reject buffer with wrong signature", () => {
|
||||
const buffer = Buffer.alloc(20);
|
||||
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);
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* xV4 Animation header builder
|
||||
*
|
||||
* The xV4 format is a animation container
|
||||
*
|
||||
* ## 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
|
||||
* ```
|
||||
*
|
||||
* ## 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 {
|
||||
/** Frame name without extension, e.g., "frame_00001" */
|
||||
name: string;
|
||||
/** JPEG image data for this frame */
|
||||
data: Buffer;
|
||||
}
|
||||
|
||||
export class XV4HeaderBuilder {
|
||||
private static readonly SIGNATURE = Buffer.from("xV4");
|
||||
private static readonly VERSION = 0x12;
|
||||
private static readonly UNKNOWN_BYTE = 0x48;
|
||||
|
||||
/**
|
||||
* 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)
|
||||
* @returns Complete xV4 animation buffer ready to send
|
||||
*/
|
||||
static build(
|
||||
frames: XV4Frame[],
|
||||
intervalMs: number = 50,
|
||||
width: number = 368,
|
||||
height: number = 368,
|
||||
): 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;
|
||||
|
||||
const totalJpegSize = frames.reduce(
|
||||
(sum, frame) => sum + 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 container = Buffer.alloc(totalSize);
|
||||
let offset = 0;
|
||||
|
||||
// Write signature "xV4"
|
||||
this.SIGNATURE.copy(container, offset);
|
||||
offset += 3;
|
||||
|
||||
// Write version 0x12
|
||||
container.writeUInt8(this.VERSION, offset);
|
||||
offset += 1;
|
||||
|
||||
// Write unknown byte 0x48
|
||||
container.writeUInt8(this.UNKNOWN_BYTE, offset);
|
||||
offset += 1;
|
||||
|
||||
// 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)
|
||||
container.writeUInt32LE(frameCount, offset);
|
||||
offset += 4;
|
||||
|
||||
// Write total size field (u32 LE) - this is frame_count * 1000
|
||||
container.writeUInt32LE(totalSizeField, offset);
|
||||
offset += 4;
|
||||
|
||||
// Write timing string (null-terminated) - no null byte before it!
|
||||
timingBuffer.copy(container, offset);
|
||||
offset += timingBuffer.length;
|
||||
|
||||
// Write frame data offset (u32 LE) - this is a reference value, not actual offset in our buffer
|
||||
container.writeUInt32LE(frameDataOffsetValue, 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) {
|
||||
throw new Error(
|
||||
`Frame data offset calculation error: expected ${actualDataOffset}, got ${offset}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Write JPEG data
|
||||
for (const frame of frames) {
|
||||
frame.data.copy(container, offset);
|
||||
offset += frame.data.length;
|
||||
}
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that a buffer is a proper xV4 container
|
||||
* @param buffer Buffer to validate
|
||||
* @returns True if valid xV4 container
|
||||
*/
|
||||
static validate(buffer: Buffer): boolean {
|
||||
if (buffer.length < 20) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check signature
|
||||
if (!buffer.subarray(0, 3).equals(this.SIGNATURE)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check version
|
||||
if (buffer.readUInt8(3) !== this.VERSION) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check unknown byte
|
||||
if (buffer.readUInt8(4) !== this.UNKNOWN_BYTE) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user