diff --git a/src/cli/index.tsx b/src/cli/index.tsx index 1175f87..fe60568 100644 --- a/src/cli/index.tsx +++ b/src/cli/index.tsx @@ -3,6 +3,9 @@ import { logger, LogLevel } from "../lib/utils/logger.ts"; import { statSync } from "node:fs"; import { scanDirectoryForImages } from "../utils/app-utils.ts"; import type { UploadOptions, StatusOptions } from "./types.ts"; +import { render } from "ink"; +import { App } from "../components/App.tsx"; +import { StatusApp } from "../components/StatusApp.tsx"; export function setupCLI() { const program = new Command(); @@ -24,6 +27,7 @@ export function setupCLI() { ) .option("--address
", "BLE device address (optional)") .option("--size ", "Target size WxH", "368x368") + .option("--animation-size ", "Animation size WxH", "360x360") .option("--test", "Upload 8x8 checkerboard test pattern", false) .option("--packet-delay ", "Delay between packets in milliseconds", "20") .action(async (imageArg: string | undefined, options: UploadOptions) => { @@ -90,8 +94,13 @@ export function setupCLI() { process.exit(1); } - const { App } = await import("../components/App.tsx"); - const { render } = await import("ink"); + if (!sizeRegex.test(options.animationSize)) { + console.error( + "Error: Invalid animation size format. Use WIDTHxHEIGHT (e.g., 360x360)", + ); + process.exit(1); + } + render(); }); @@ -107,8 +116,6 @@ export function setupCLI() { logger.setLevel(LogLevel.DEBUG); } - const { StatusApp } = await import("../components/StatusApp.tsx"); - const { render } = await import("ink"); render(); }); diff --git a/src/cli/types.ts b/src/cli/types.ts index efae702..30ae43c 100644 --- a/src/cli/types.ts +++ b/src/cli/types.ts @@ -2,6 +2,7 @@ export interface UploadOptions { image?: string; address?: string; size: string; + animationSize: string; test: boolean; packetDelay: number; images?: string[]; diff --git a/src/hooks/useUpload.ts b/src/hooks/useUpload.ts index 84c2fa8..979dfb5 100644 --- a/src/hooks/useUpload.ts +++ b/src/hooks/useUpload.ts @@ -110,6 +110,13 @@ export function useUpload(options: UploadOptions, verbose: boolean) { try { const [width, height] = options.size.split("x").map(Number); const targetSize: [number, number] = [width!, height!]; + const [animationWidth, animationHeight] = options.animationSize + .split("x") + .map(Number); + const animationSize: [number, number] = [ + animationWidth!, + animationHeight!, + ]; const packetDelaySeconds = options.packetDelay / 1000.0; const isBulk = @@ -169,16 +176,18 @@ export function useUpload(options: UploadOptions, verbose: boolean) { ]); setProgress(0); - const success = await uploader.uploadImageFromFile( - imagePath, - targetSize, - (prog, status) => { - setProgress(prog); - if (status) { - setMessage(`${i + 1}/${imagesToUpload.length}: ${status}`); - } - }, - ); + const success = await uploader.uploadImageFromFile( + imagePath, + targetSize, + animationSize, + (prog: number, status?: string) => { + setProgress(prog); + if (status) { + setMessage(`${i + 1}/${imagesToUpload.length}: ${status}`); + } + }, + ); + if (!success) { throw new Error(`Failed to upload ${fileName}`); @@ -203,17 +212,22 @@ export function useUpload(options: UploadOptions, verbose: boolean) { let success: boolean; if (options.test) { - success = await uploader.uploadCheckerboard(targetSize, 8, (prog, status) => { - setProgress(prog); - if (status) { - setMessage(status); - } - }); + success = await uploader.uploadCheckerboard( + targetSize, + 8, + (prog: number, status?: string) => { + setProgress(prog); + if (status) { + setMessage(status); + } + }, + ); } else if (options.image) { success = await uploader.uploadImageFromFile( options.image, targetSize, - (prog, status) => { + animationSize, + (prog: number, status?: string) => { setProgress(prog); if (status) { setMessage(status); diff --git a/src/lib/core/beambox-uploader.ts b/src/lib/core/beambox-uploader.ts index 9bf7f2d..f5cd86d 100644 --- a/src/lib/core/beambox-uploader.ts +++ b/src/lib/core/beambox-uploader.ts @@ -21,6 +21,7 @@ export interface UploadOptions { imagePath?: string; imageData?: Buffer; targetSize?: [number, number]; + animationSize?: [number, number]; onProgress?: (progress: number, status?: string) => void; } @@ -84,13 +85,16 @@ export class BeamBoxUploader { * @returns True if upload successful */ public async upload(options: UploadOptions): Promise { - const { imagePath, imageData, targetSize, onProgress } = options; + const { imagePath, imageData, targetSize, animationSize, onProgress } = + options; if (!imageData && !imagePath) { throw new UploadError("No image provided"); } const effectiveSize = targetSize ?? this.imageConfig.defaultSize; + const effectiveAnimationSize = + animationSize ?? this.imageConfig.animationsSize; // Detect file type if path provided let isAnimated = false; @@ -104,7 +108,11 @@ export class BeamBoxUploader { if (isAnimated) { logger.info("File is animated, using Type 5 (DYNAMIC_AMBIENCE)"); - return await this.uploadAnimation(imagePath, effectiveSize, onProgress); + return await this.uploadAnimation( + imagePath, + effectiveAnimationSize, + onProgress, + ); } else { logger.info("File is static image, using Type 6 (IMAGE)"); } @@ -176,20 +184,39 @@ export class BeamBoxUploader { targetSize: [number, number], onProgress?: (progress: number) => void, ): Promise { + const animationSize: [number, number] = targetSize; + // Extract frames from the file - logger.info("Extracting frames from animation..."); + logger.info( + `Extracting frames from animation at ${animationSize[0]}x${animationSize[1]}...`, + ); const frames = await FrameExtractor.extractFrames(filePath, { - maxFrames: 100, - targetSize, + targetSize: animationSize, }); logger.info(`Extracted ${frames.length} frames`); + // Ensure minimum 2 frames for device compatibility + // The xV4 format requires at least 2 frames + const MIN_FRAMES = 2; + if (frames.length < MIN_FRAMES) { + logger.info(`Padding from ${frames.length} to ${MIN_FRAMES} frames by duplicating last frame`); + while (frames.length < MIN_FRAMES) { + const lastFrame = frames[frames.length - 1]!; + frames.push({ + name: `frame_${String(frames.length + 1).padStart(5, '0')}`, + data: lastFrame.data, // Reuse the same buffer + }); + } + logger.info(`Padded to ${frames.length} frames`); + } + // Calculate frame interval - const intervalMs = await FrameExtractor.calculateFrameInterval( - filePath, - frames.length, - ); + // Based on analysis: working animations use 50ms interval + // TODO: Experiment with different intervals later + // The timing string must fit in 12 bytes ("output/XXms\0"), so intervals + // must be 2 digits (10-99). Using 50ms as it's proven to work. + const intervalMs = 50; logger.info(`Using frame interval: ${intervalMs}ms`); // Wait for device to be ready @@ -208,7 +235,7 @@ export class BeamBoxUploader { const fullData = this.payloadBuilder.buildAnimationData( frames, intervalMs, - targetSize, + animationSize, ); logger.info( @@ -247,9 +274,10 @@ export class BeamBoxUploader { public async uploadImageFromFile( imagePath: string, targetSize?: [number, number], + animationSize?: [number, number], onProgress?: (progress: number, status?: string) => void, ): Promise { - return await this.upload({ imagePath, targetSize, onProgress }); + return await this.upload({ imagePath, targetSize, animationSize, onProgress }); } /** diff --git a/src/lib/processing/frame-extractor.ts b/src/lib/processing/frame-extractor.ts index fb00274..9c34603 100644 --- a/src/lib/processing/frame-extractor.ts +++ b/src/lib/processing/frame-extractor.ts @@ -3,6 +3,7 @@ import { promisify } from "util"; import { mkdtemp, readdir, readFile, rm } from "fs/promises"; import { tmpdir } from "os"; import { join } from "path"; +import sharp from "sharp"; import type { XV4Frame } from "../protocol/index.ts"; import { ImageProcessingError } from "../utils/errors.ts"; import { logger } from "../utils/logger.ts"; @@ -10,8 +11,6 @@ import { logger } from "../utils/logger.ts"; const execAsync = promisify(exec); export interface FrameExtractionOptions { - /** Maximum number of frames to extract (default: 100) */ - maxFrames?: number; /** Target FPS for extraction (default: extract all frames) */ fps?: number; /** Target size for frames [width, height] */ @@ -32,11 +31,7 @@ export class FrameExtractor { filePath: string, options: FrameExtractionOptions = {}, ): Promise { - const { - maxFrames = 100, - fps = null, - targetSize = [368, 368], - } = options; + const { fps = null, targetSize = [360, 360] } = options; // Create temporary directory for frames const tempDir = await mkdtemp(join(tmpdir(), "beambox-frames-")); @@ -56,20 +51,21 @@ export class FrameExtractor { filters.push(`fps=${fps}`); } - // Add scaling and padding - filters.push(`scale=${targetSize[0]}:${targetSize[1]}:force_original_aspect_ratio=decrease`); - filters.push(`pad=${targetSize[0]}:${targetSize[1]}:(ow-iw)/2:(oh-ih)/2`); + // Add scaling and cropping to fill frame (official app does this) + // Use 'increase' to scale up to fill, then crop to exact dimensions + filters.push( + `scale=${targetSize[0]}:${targetSize[1]}:force_original_aspect_ratio=increase`, + ); + filters.push(`crop=${targetSize[0]}:${targetSize[1]}`); // Combine all filters if (filters.length > 0) { - ffmpegCmd += ` -vf "${filters.join(',')}"`; + ffmpegCmd += ` -vf "${filters.join(",")}"`; } - // Add frame limit - ffmpegCmd += ` -vframes ${maxFrames}`; - // Add quality settings - ffmpegCmd += ` -q:v 2`; // High quality JPEG + // Use -q:v 10 for initial extraction (will be re-encoded with Sharp) + ffmpegCmd += ` -q:v 10`; ffmpegCmd += ` "${outputPattern}"`; @@ -79,7 +75,7 @@ export class FrameExtractor { const { stdout, stderr } = await execAsync(ffmpegCmd); if (stderr && !stderr.includes("frame=")) { - logger.warn(`ffmpeg stderr: ${stderr}`); + logger.warning(`ffmpeg stderr: ${stderr}`); } // Read extracted frames @@ -97,19 +93,72 @@ export class FrameExtractor { logger.info(`Extracted ${frameFiles.length} frames`); // Load frames into XV4Frame format - const frames: XV4Frame[] = []; - for (const file of frameFiles) { - const framePath = join(tempDir, file); - const data = await readFile(framePath); + // Re-encode frames to ensure consistent JPEG format + logger.info("Re-encoding frames to quality 75..."); - // Extract frame number from filename (e.g., "frame_00001.jpg" -> "frame_00001") + // Standard JFIF APP0 marker segment (18 bytes) + // This marker is required for some devices to properly decode the JPEG + // Structure: FF E0 + length (16) + 'JFIF\0' + version 1.1 + aspect ratio + density + const jfifMarker = Buffer.from([ + 0xff, + 0xe0, // APP0 marker + 0x00, + 0x10, // Length: 16 bytes (including these 2) + 0x4a, + 0x46, + 0x49, + 0x46, + 0x00, // 'JFIF\0' + 0x01, + 0x01, // Version 1.1 + 0x00, // Aspect ratio units: 0 = no units + 0x00, + 0x01, // X density: 1 + 0x00, + 0x01, // Y density: 1 + 0x00, + 0x00, // No thumbnail + ]); + + // Process frames in parallel for speed + const framePromises = frameFiles.map(async (file) => { + const framePath = join(tempDir, file); + + // Decode and re-encode to ensure consistent JPEG format with quality 75 + // This should match official app settings: + // with Chroma subsampling: 4:4:4 (no subsampling, highest quality) + const reencoded = await sharp(framePath) + .jpeg({ + quality: 75, + optimiseCoding: true, + mozjpeg: false, + chromaSubsampling: "4:4:4", + }) + .toBuffer(); + + // Inject JFIF marker after SOI (FF D8) + // Sharp doesn't include JFIF by default, but the device may require it? Just to be safe. + // SOI is always at the start: FF D8 + const withJfif = Buffer.concat([ + reencoded.subarray(0, 2), // SOI (FF D8) + jfifMarker, // JFIF APP0 marker + reencoded.subarray(2), // Rest of JPEG data + ]); + + // Extract frame number from filename like ("frame_00001.jpg" -> "frame_00001") const name = file.replace(".jpg", ""); - frames.push({ + return { name, - data, - }); - } + data: withJfif, + }; + }); + + const frames = await Promise.all(framePromises); + const totalSize = frames.reduce((sum, f) => sum + f.data.length, 0); + logger.info( + `Re-encoding complete. Total JPEG data: ${(totalSize / 1024).toFixed(2)} KB`, + ); return frames; } catch (error) { @@ -125,11 +174,39 @@ export class FrameExtractor { try { await rm(tempDir, { recursive: true, force: true }); } catch (error) { - logger.warn(`Failed to clean up temp directory: ${error}`); + logger.warning(`Failed to clean up temp directory: ${error}`); } } } + /** + * Calculate GIF frame interval using app logic + * + * The app uses frame-count-based intervals for GIFs: + * - <=12 frames: 200ms (5 fps) + * - <=24 frames: 150ms (6.7 fps) + * - >24 frames: 100ms (10 fps) + * + * This is then clamped to [50, 300]ms range. + * + * @param frameCount Number of extracted frames + * @returns Frame interval in milliseconds + */ + static calculateGifInterval(frameCount: number): number { + let interval: number; + + if (frameCount <= 12) { + interval = 200; + } else if (frameCount <= 24) { + interval = 150; + } else { + interval = 100; + } + + // Clamp to [50, 300]ms range + return Math.max(50, Math.min(300, interval)); + } + /** * Get frame rate of a video file * @param filePath Path to the video file @@ -150,7 +227,7 @@ export class FrameExtractor { return 30; // Default fallback } catch (error) { - logger.warn(`Failed to get frame rate: ${error}`); + logger.warning(`Failed to get frame rate: ${error}`); return 30; // Default fallback } } @@ -166,16 +243,20 @@ export class FrameExtractor { const { stdout } = await execAsync(cmd); return parseFloat(stdout.trim()); } catch (error) { - logger.warn(`Failed to get duration: ${error}`); + logger.warning(`Failed to get duration: ${error}`); return 0; } } /** - * Calculate recommended frame interval in milliseconds based on extracted frames and original duration + * Calculate recommended frame interval in milliseconds + * + * Calculates the interval to preserve the original animation duration + * based on source duration and actual extracted frame count. + * * @param filePath Path to the source file * @param extractedFrameCount Number of frames that were extracted - * @returns Recommended interval in milliseconds + * @returns Frame interval in milliseconds */ static async calculateFrameInterval( filePath: string, @@ -186,9 +267,12 @@ export class FrameExtractor { if (duration > 0 && extractedFrameCount > 1) { // Calculate interval to maintain original playback speed const intervalMs = (duration * 1000) / extractedFrameCount; - return Math.max(20, Math.round(intervalMs)); // Minimum 20ms (50fps) + // Device requires minimum 150ms interval to play animations? Maybe, from trials and errors. + // Below 150ms, the device shows only the first frame, somtimes? + // Need more testing to confirm. + return Math.max(150, Math.round(intervalMs)); } - return 50; // Default 50ms (20fps) + return 150; // Default 150ms (~6.7fps), device minimum for animation? } } diff --git a/src/lib/processing/image-processor.test.ts b/src/lib/processing/image-processor.test.ts index fbf06bf..a45c838 100644 --- a/src/lib/processing/image-processor.test.ts +++ b/src/lib/processing/image-processor.test.ts @@ -1,4 +1,4 @@ -import { describe, test, expect } from "bun:test"; +import { describe, test, expect } from "vitest"; import { ImageProcessor } from "./image-processor.ts"; import { ImageProcessingError } from "../utils/errors.ts"; import { DEFAULT_IMAGE_CONFIG } from "../protocol/interfaces/defaults.ts"; diff --git a/src/lib/processing/image-processor.ts b/src/lib/processing/image-processor.ts index 32e1b57..ac5ddfc 100644 --- a/src/lib/processing/image-processor.ts +++ b/src/lib/processing/image-processor.ts @@ -62,11 +62,35 @@ export class ImageProcessor { } } + // Standard JFIF APP0 marker segment (18 bytes) + // This marker is required for some devices to properly decode the JPEG + // Structure: FF E0 + length (16) + 'JFIF\0' + version 1.1 + aspect ratio + density + private static readonly JFIF_MARKER = Buffer.from([ + 0xff, + 0xe0, // APP0 marker + 0x00, + 0x10, // Length: 16 bytes (including these 2) + 0x4a, + 0x46, + 0x49, + 0x46, + 0x00, // 'JFIF\0' + 0x01, + 0x01, // Version 1.1 + 0x00, // Aspect ratio units: 0 = no units + 0x00, + 0x01, // X density: 1 + 0x00, + 0x01, // Y density: 1 + 0x00, + 0x00, // No thumbnail + ]); + /** * Prepare an image as JPEG bytes * @param imageInput Sharp instance or buffer * @param targetSize Target size [width, height] - * @returns JPEG image as Buffer + * @returns JPEG image as Buffer with JFIF marker */ public async prepareImage( imageInput: sharp.Sharp | Buffer, @@ -77,9 +101,10 @@ export class ImageProcessor { ? sharp(imageInput) : imageInput; - return await pipeline + const jpegData = await pipeline .resize(targetSize[0], targetSize[1], { - fit: "fill", + fit: "cover", // Use 'cover' to fill frame (official app does scale increase + crop) + position: "center", kernel: "lanczos3", }) .toColorspace("srgb") @@ -90,6 +115,14 @@ export class ImageProcessor { chromaSubsampling: "4:2:0", }) .toBuffer(); + + // Inject JFIF marker after SOI (FF D8) + // Sharp doesn't include JFIF by default, but the device may require it? Just to be safe. + return Buffer.concat([ + jpegData.subarray(0, 2), // SOI (FF D8) + ImageProcessor.JFIF_MARKER, // JFIF APP0 marker + jpegData.subarray(2), // Rest of JPEG data + ]); } catch (error) { throw new ImageProcessingError(`Failed to prepare image: ${error}`); } diff --git a/src/lib/processing/media-detector.test.ts b/src/lib/processing/media-detector.test.ts index 282977e..8e5733a 100644 --- a/src/lib/processing/media-detector.test.ts +++ b/src/lib/processing/media-detector.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from "bun:test"; +import { describe, it, expect } from "vitest"; import { MediaDetector } from "./media-detector.ts"; describe("MediaDetector", () => { diff --git a/src/lib/protocol/builders/imb-header.test.ts b/src/lib/protocol/builders/imb-header.test.ts index 5df98ee..2bc1a8b 100644 --- a/src/lib/protocol/builders/imb-header.test.ts +++ b/src/lib/protocol/builders/imb-header.test.ts @@ -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"; diff --git a/src/lib/protocol/builders/payload-builder.test.ts b/src/lib/protocol/builders/payload-builder.test.ts index 3e49a22..4043b96 100644 --- a/src/lib/protocol/builders/payload-builder.test.ts +++ b/src/lib/protocol/builders/payload-builder.test.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"); }); }); diff --git a/src/lib/protocol/builders/payload-builder.ts b/src/lib/protocol/builders/payload-builder.ts index fb87ae7..6bf5767 100644 --- a/src/lib/protocol/builders/payload-builder.ts +++ b/src/lib/protocol/builders/payload-builder.ts @@ -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":} */ 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":`, diff --git a/src/lib/protocol/builders/xv4-header.test.ts b/src/lib/protocol/builders/xv4-header.test.ts index 5e56e4f..f19de09 100644 --- a/src/lib/protocol/builders/xv4-header.test.ts +++ b/src/lib/protocol/builders/xv4-header.test.ts @@ -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"); + }); + }); }); diff --git a/src/lib/protocol/builders/xv4-header.ts b/src/lib/protocol/builders/xv4-header.ts index 692e13d..58c3dd1 100644 --- a/src/lib/protocol/builders/xv4-header.ts +++ b/src/lib/protocol/builders/xv4-header.ts @@ -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"); + } } diff --git a/src/lib/protocol/interfaces/config.ts b/src/lib/protocol/interfaces/config.ts index 263082d..7db2b82 100644 --- a/src/lib/protocol/interfaces/config.ts +++ b/src/lib/protocol/interfaces/config.ts @@ -86,6 +86,13 @@ export interface ImageConfig { */ defaultSize: [number, number]; + /** + * Default frame dimensions [width, height] for animations + * + * Frames are resized to fit these dimensions + */ + animationsSize: [number, number]; + /** * JPEG compression quality (0-100) * diff --git a/src/lib/protocol/interfaces/defaults.ts b/src/lib/protocol/interfaces/defaults.ts index f71b800..96197f0 100644 --- a/src/lib/protocol/interfaces/defaults.ts +++ b/src/lib/protocol/interfaces/defaults.ts @@ -40,16 +40,18 @@ export const DEFAULT_PROTOCOL_CONFIG: ProtocolConfig = { /** * Default image processing configuration * - * Standard settings for processing images for the 368x368 BeamBox display. + * Standard settings for processing images for the BeamBox display. * - * - defaultSize: [368, 368] - device native resolution - * - jpegQuality: 70 - good balance between quality and size + * - defaultSize: [368, 368] - device native resolution for static images + * - animationsSize: [360, 360] - frame resolution for animations (Type 5) + * - jpegQuality: 80 - quality for static images (animations use 75) * - jpegOptimize: true - enable optimization for smaller files * - checkerboardSquares: 8 - for test pattern generation */ export const DEFAULT_IMAGE_CONFIG: ImageConfig = { defaultSize: [368, 368], - jpegQuality: 70, + animationsSize: [360, 360], + jpegQuality: 80, jpegOptimize: true, checkerboardSquares: 8, }; diff --git a/src/lib/protocol/interfaces/device-status.ts b/src/lib/protocol/interfaces/device-status.ts index e23c7e8..ed173f3 100644 --- a/src/lib/protocol/interfaces/device-status.ts +++ b/src/lib/protocol/interfaces/device-status.ts @@ -27,7 +27,7 @@ export interface DeviceStatus { /** * Display resolution as "width,height" string * - * Example: "368,368" = 368x368 pixels + * Example: "368,368" = 368x368 pixels (static); animations use 360x360 */ size: string; diff --git a/src/lib/protocol/packet-types.ts b/src/lib/protocol/packet-types.ts index 65cb295..96c60d1 100644 --- a/src/lib/protocol/packet-types.ts +++ b/src/lib/protocol/packet-types.ts @@ -16,7 +16,7 @@ export enum PacketType { /** * 0x05: DYNAMIC_AMBIENCE (Client to Device) * - * Used for ALL animated content: videos, GIFs, and image gallery mode + * Used for ALL animated content: videos, GIFs, and image gallery mode. * * ## Upload Process (Two-Step) * @@ -30,35 +30,27 @@ export enum PacketType { * {"type":5,"data":} * ``` * - * ## Animation Data Format + * ## xV4 Animation Format * - * The data payload contains: - * TODO: Validate this format more thoroughly - * - Signature: "xV4" (0x78 0x56 0x34), custom animation format? - * - Frame timing: "output/50ms" interval between frames - * - Frame references: "frame_00001", "frame_00002", etc. - * - Multiple JPEG frames embedded in single payload + * The data payload is an xV4 container containing: + * - 32-byte header with signature "xV4" + version 0x12 + * - Frame table with offsets to each frame's metadata + * - Per-frame data: 32-byte metadata + JPEG data + * - Looping: last frame's next_offset points back to first frame * - * ## How Content is Converted to Animation + * Frame requirements: + * - Size: 360x360 pixels + * - Format: JPEG with JFIF APP0 marker + * - Quality: 75 + * - Chroma: 4:4:4 (no subsampling) * - * TODO: Implement using ffmpeg to extract frames and build xV4 format? - * - * ```bash - * # Get video info - * ffprobe -v quiet -show_entries format=duration -of csv=p=0 input.mp4 - * ffprobe -v quiet -select_streams v:0 -show_entries stream=r_frame_rate -of csv=p=0 input.mp4 - * - * # Extract frames - * ffmpeg -i input.mp4 output/frame_%05d.jpg - * ``` + * For more information, go see xv4-header.ts * * ## Use Cases * * - Video upload: Extract frames from video file * - GIF upload: Separate animated GIF into frames - * - Gallery/slideshow Mode: Convert multiple images to animation with interval (the official app does this) - * - * TODO: Full implementation pending + * - Gallery/slideshow: Convert multiple images to animation with interval */ DYNAMIC_AMBIENCE = 0x05, @@ -73,37 +65,37 @@ export enum PacketType { * ```json * {"type":6,"number":1} * ``` - * TODO: Validate if "number" means image count - * - Announces that 1 image is coming? (Always observed as 1, even for multiple images, so might be something else) + * - Announces that 1 image is coming * - Device responds with DEVICE_STATUS (type 13) * * Step 2 - Send image data: * ``` * {"type":6,"data":} * ``` - * - IMB header: 36 bytes (contains size, dimensions) + * - IMB header: 14 bytes (signature + size + dimensions + padding) * - JPEG data: Raw binary JPEG file * - Sent in chunks (0x1F0 (496) bytes per chunk) * - Device responds "GetPacketSuccess" for each chunk * + * ## Image Requirements from official app + * + * - Size: 368x368 pixels + * - Format: JPEG with JFIF APP0 marker + * - Quality: 80 + * - Chroma: 4:2:0 subsampling + * * ## Important Notes * - For multiple images: repeat the 2-step process sequentially - * - For IMB header format, see IMBHeaderBuilder in src/lib/protocol/imb-header.ts - * - Gallery/slideshow mode on the official app does NOT use this, uses DYNAMIC_AMBIENCE (Type 5) and preprocessing instead - * - * The official app's "Image Gallery" feature works by: - * 1. Converting multiple images into animation using FFmpeg - * 2. Creating frame sequence: frame_00001.jpg, frame_00002.jpg, etc. - * 3. Sending as Type 5 (DYNAMIC_AMBIENCE) with embedded timing - * + * - For IMB header format, see IMBHeaderBuilder in imb-header.ts + * - Gallery/slideshow mode uses DYNAMIC_AMBIENCE (Type 5), not this */ IMAGE = 0x06, /** * 0x0C: PHOTO_ALBUM_COUNT (Client to Device) * - * Would announce number of images in gallery/album mode?? - * TODO: Does not seem to be used or might be used on another model, needs further investigation + * Would announce number of images in gallery/album mode. + * Not used in current implementation, may be for other models. */ PHOTO_ALBUM_COUNT = 0x0c, @@ -128,9 +120,8 @@ export enum PacketType { * ## When Sent * * Device sends this in response to: - * - Image info packet (Type) + * - Image info packet (Type 6) * - Used to check available storage before upload - * */ DEVICE_STATUS = 0x0d, } diff --git a/src/lib/protocol/parsers/response-parser.test.ts b/src/lib/protocol/parsers/response-parser.test.ts index d69f776..96b7c59 100644 --- a/src/lib/protocol/parsers/response-parser.test.ts +++ b/src/lib/protocol/parsers/response-parser.test.ts @@ -1,4 +1,4 @@ -import { describe, test, expect } from "bun:test"; +import { describe, test, expect } from "vitest"; import { ResponseParser } from "./response-parser.ts"; import { PacketType } from "../packet-types.ts"; import { ResponseStatus } from "../response-types.ts"; diff --git a/src/lib/utils/errors.test.ts b/src/lib/utils/errors.test.ts index 30b6117..086488c 100644 --- a/src/lib/utils/errors.test.ts +++ b/src/lib/utils/errors.test.ts @@ -1,4 +1,4 @@ -import { describe, test, expect } from "bun:test"; +import { describe, test, expect } from "vitest"; import { BeamBoxError, DeviceNotFoundError,