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:
+11
-4
@@ -3,6 +3,9 @@ import { logger, LogLevel } from "../lib/utils/logger.ts";
|
|||||||
import { statSync } from "node:fs";
|
import { statSync } from "node:fs";
|
||||||
import { scanDirectoryForImages } from "../utils/app-utils.ts";
|
import { scanDirectoryForImages } from "../utils/app-utils.ts";
|
||||||
import type { UploadOptions, StatusOptions } from "./types.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() {
|
export function setupCLI() {
|
||||||
const program = new Command();
|
const program = new Command();
|
||||||
@@ -24,6 +27,7 @@ export function setupCLI() {
|
|||||||
)
|
)
|
||||||
.option("--address <address>", "BLE device address (optional)")
|
.option("--address <address>", "BLE device address (optional)")
|
||||||
.option("--size <size>", "Target size WxH", "368x368")
|
.option("--size <size>", "Target size WxH", "368x368")
|
||||||
|
.option("--animation-size <size>", "Animation size WxH", "360x360")
|
||||||
.option("--test", "Upload 8x8 checkerboard test pattern", false)
|
.option("--test", "Upload 8x8 checkerboard test pattern", false)
|
||||||
.option("--packet-delay <ms>", "Delay between packets in milliseconds", "20")
|
.option("--packet-delay <ms>", "Delay between packets in milliseconds", "20")
|
||||||
.action(async (imageArg: string | undefined, options: UploadOptions) => {
|
.action(async (imageArg: string | undefined, options: UploadOptions) => {
|
||||||
@@ -90,8 +94,13 @@ export function setupCLI() {
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { App } = await import("../components/App.tsx");
|
if (!sizeRegex.test(options.animationSize)) {
|
||||||
const { render } = await import("ink");
|
console.error(
|
||||||
|
"Error: Invalid animation size format. Use WIDTHxHEIGHT (e.g., 360x360)",
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
render(<App options={options} verbose={verbose} />);
|
render(<App options={options} verbose={verbose} />);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -107,8 +116,6 @@ export function setupCLI() {
|
|||||||
logger.setLevel(LogLevel.DEBUG);
|
logger.setLevel(LogLevel.DEBUG);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { StatusApp } = await import("../components/StatusApp.tsx");
|
|
||||||
const { render } = await import("ink");
|
|
||||||
render(<StatusApp options={options} verbose={verbose} />);
|
render(<StatusApp options={options} verbose={verbose} />);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ export interface UploadOptions {
|
|||||||
image?: string;
|
image?: string;
|
||||||
address?: string;
|
address?: string;
|
||||||
size: string;
|
size: string;
|
||||||
|
animationSize: string;
|
||||||
test: boolean;
|
test: boolean;
|
||||||
packetDelay: number;
|
packetDelay: number;
|
||||||
images?: string[];
|
images?: string[];
|
||||||
|
|||||||
+31
-17
@@ -110,6 +110,13 @@ export function useUpload(options: UploadOptions, verbose: boolean) {
|
|||||||
try {
|
try {
|
||||||
const [width, height] = options.size.split("x").map(Number);
|
const [width, height] = options.size.split("x").map(Number);
|
||||||
const targetSize: [number, number] = [width!, height!];
|
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 packetDelaySeconds = options.packetDelay / 1000.0;
|
||||||
|
|
||||||
const isBulk =
|
const isBulk =
|
||||||
@@ -169,16 +176,18 @@ export function useUpload(options: UploadOptions, verbose: boolean) {
|
|||||||
]);
|
]);
|
||||||
setProgress(0);
|
setProgress(0);
|
||||||
|
|
||||||
const success = await uploader.uploadImageFromFile(
|
const success = await uploader.uploadImageFromFile(
|
||||||
imagePath,
|
imagePath,
|
||||||
targetSize,
|
targetSize,
|
||||||
(prog, status) => {
|
animationSize,
|
||||||
setProgress(prog);
|
(prog: number, status?: string) => {
|
||||||
if (status) {
|
setProgress(prog);
|
||||||
setMessage(`${i + 1}/${imagesToUpload.length}: ${status}`);
|
if (status) {
|
||||||
}
|
setMessage(`${i + 1}/${imagesToUpload.length}: ${status}`);
|
||||||
},
|
}
|
||||||
);
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
if (!success) {
|
if (!success) {
|
||||||
throw new Error(`Failed to upload ${fileName}`);
|
throw new Error(`Failed to upload ${fileName}`);
|
||||||
@@ -203,17 +212,22 @@ export function useUpload(options: UploadOptions, verbose: boolean) {
|
|||||||
|
|
||||||
let success: boolean;
|
let success: boolean;
|
||||||
if (options.test) {
|
if (options.test) {
|
||||||
success = await uploader.uploadCheckerboard(targetSize, 8, (prog, status) => {
|
success = await uploader.uploadCheckerboard(
|
||||||
setProgress(prog);
|
targetSize,
|
||||||
if (status) {
|
8,
|
||||||
setMessage(status);
|
(prog: number, status?: string) => {
|
||||||
}
|
setProgress(prog);
|
||||||
});
|
if (status) {
|
||||||
|
setMessage(status);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
} else if (options.image) {
|
} else if (options.image) {
|
||||||
success = await uploader.uploadImageFromFile(
|
success = await uploader.uploadImageFromFile(
|
||||||
options.image,
|
options.image,
|
||||||
targetSize,
|
targetSize,
|
||||||
(prog, status) => {
|
animationSize,
|
||||||
|
(prog: number, status?: string) => {
|
||||||
setProgress(prog);
|
setProgress(prog);
|
||||||
if (status) {
|
if (status) {
|
||||||
setMessage(status);
|
setMessage(status);
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export interface UploadOptions {
|
|||||||
imagePath?: string;
|
imagePath?: string;
|
||||||
imageData?: Buffer;
|
imageData?: Buffer;
|
||||||
targetSize?: [number, number];
|
targetSize?: [number, number];
|
||||||
|
animationSize?: [number, number];
|
||||||
onProgress?: (progress: number, status?: string) => void;
|
onProgress?: (progress: number, status?: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,13 +85,16 @@ export class BeamBoxUploader {
|
|||||||
* @returns True if upload successful
|
* @returns True if upload successful
|
||||||
*/
|
*/
|
||||||
public async upload(options: UploadOptions): Promise<boolean> {
|
public async upload(options: UploadOptions): Promise<boolean> {
|
||||||
const { imagePath, imageData, targetSize, onProgress } = options;
|
const { imagePath, imageData, targetSize, animationSize, onProgress } =
|
||||||
|
options;
|
||||||
|
|
||||||
if (!imageData && !imagePath) {
|
if (!imageData && !imagePath) {
|
||||||
throw new UploadError("No image provided");
|
throw new UploadError("No image provided");
|
||||||
}
|
}
|
||||||
|
|
||||||
const effectiveSize = targetSize ?? this.imageConfig.defaultSize;
|
const effectiveSize = targetSize ?? this.imageConfig.defaultSize;
|
||||||
|
const effectiveAnimationSize =
|
||||||
|
animationSize ?? this.imageConfig.animationsSize;
|
||||||
|
|
||||||
// Detect file type if path provided
|
// Detect file type if path provided
|
||||||
let isAnimated = false;
|
let isAnimated = false;
|
||||||
@@ -104,7 +108,11 @@ export class BeamBoxUploader {
|
|||||||
|
|
||||||
if (isAnimated) {
|
if (isAnimated) {
|
||||||
logger.info("File is animated, using Type 5 (DYNAMIC_AMBIENCE)");
|
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 {
|
} else {
|
||||||
logger.info("File is static image, using Type 6 (IMAGE)");
|
logger.info("File is static image, using Type 6 (IMAGE)");
|
||||||
}
|
}
|
||||||
@@ -176,20 +184,39 @@ export class BeamBoxUploader {
|
|||||||
targetSize: [number, number],
|
targetSize: [number, number],
|
||||||
onProgress?: (progress: number) => void,
|
onProgress?: (progress: number) => void,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
|
const animationSize: [number, number] = targetSize;
|
||||||
|
|
||||||
// Extract frames from the file
|
// 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, {
|
const frames = await FrameExtractor.extractFrames(filePath, {
|
||||||
maxFrames: 100,
|
targetSize: animationSize,
|
||||||
targetSize,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
logger.info(`Extracted ${frames.length} frames`);
|
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
|
// Calculate frame interval
|
||||||
const intervalMs = await FrameExtractor.calculateFrameInterval(
|
// Based on analysis: working animations use 50ms interval
|
||||||
filePath,
|
// TODO: Experiment with different intervals later
|
||||||
frames.length,
|
// 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`);
|
logger.info(`Using frame interval: ${intervalMs}ms`);
|
||||||
|
|
||||||
// Wait for device to be ready
|
// Wait for device to be ready
|
||||||
@@ -208,7 +235,7 @@ export class BeamBoxUploader {
|
|||||||
const fullData = this.payloadBuilder.buildAnimationData(
|
const fullData = this.payloadBuilder.buildAnimationData(
|
||||||
frames,
|
frames,
|
||||||
intervalMs,
|
intervalMs,
|
||||||
targetSize,
|
animationSize,
|
||||||
);
|
);
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -247,9 +274,10 @@ export class BeamBoxUploader {
|
|||||||
public async uploadImageFromFile(
|
public async uploadImageFromFile(
|
||||||
imagePath: string,
|
imagePath: string,
|
||||||
targetSize?: [number, number],
|
targetSize?: [number, number],
|
||||||
|
animationSize?: [number, number],
|
||||||
onProgress?: (progress: number, status?: string) => void,
|
onProgress?: (progress: number, status?: string) => void,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
return await this.upload({ imagePath, targetSize, onProgress });
|
return await this.upload({ imagePath, targetSize, animationSize, onProgress });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { promisify } from "util";
|
|||||||
import { mkdtemp, readdir, readFile, rm } from "fs/promises";
|
import { mkdtemp, readdir, readFile, rm } from "fs/promises";
|
||||||
import { tmpdir } from "os";
|
import { tmpdir } from "os";
|
||||||
import { join } from "path";
|
import { join } from "path";
|
||||||
|
import sharp from "sharp";
|
||||||
import type { XV4Frame } from "../protocol/index.ts";
|
import type { XV4Frame } from "../protocol/index.ts";
|
||||||
import { ImageProcessingError } from "../utils/errors.ts";
|
import { ImageProcessingError } from "../utils/errors.ts";
|
||||||
import { logger } from "../utils/logger.ts";
|
import { logger } from "../utils/logger.ts";
|
||||||
@@ -10,8 +11,6 @@ import { logger } from "../utils/logger.ts";
|
|||||||
const execAsync = promisify(exec);
|
const execAsync = promisify(exec);
|
||||||
|
|
||||||
export interface FrameExtractionOptions {
|
export interface FrameExtractionOptions {
|
||||||
/** Maximum number of frames to extract (default: 100) */
|
|
||||||
maxFrames?: number;
|
|
||||||
/** Target FPS for extraction (default: extract all frames) */
|
/** Target FPS for extraction (default: extract all frames) */
|
||||||
fps?: number;
|
fps?: number;
|
||||||
/** Target size for frames [width, height] */
|
/** Target size for frames [width, height] */
|
||||||
@@ -32,11 +31,7 @@ export class FrameExtractor {
|
|||||||
filePath: string,
|
filePath: string,
|
||||||
options: FrameExtractionOptions = {},
|
options: FrameExtractionOptions = {},
|
||||||
): Promise<XV4Frame[]> {
|
): Promise<XV4Frame[]> {
|
||||||
const {
|
const { fps = null, targetSize = [360, 360] } = options;
|
||||||
maxFrames = 100,
|
|
||||||
fps = null,
|
|
||||||
targetSize = [368, 368],
|
|
||||||
} = options;
|
|
||||||
|
|
||||||
// Create temporary directory for frames
|
// Create temporary directory for frames
|
||||||
const tempDir = await mkdtemp(join(tmpdir(), "beambox-frames-"));
|
const tempDir = await mkdtemp(join(tmpdir(), "beambox-frames-"));
|
||||||
@@ -56,20 +51,21 @@ export class FrameExtractor {
|
|||||||
filters.push(`fps=${fps}`);
|
filters.push(`fps=${fps}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add scaling and padding
|
// Add scaling and cropping to fill frame (official app does this)
|
||||||
filters.push(`scale=${targetSize[0]}:${targetSize[1]}:force_original_aspect_ratio=decrease`);
|
// Use 'increase' to scale up to fill, then crop to exact dimensions
|
||||||
filters.push(`pad=${targetSize[0]}:${targetSize[1]}:(ow-iw)/2:(oh-ih)/2`);
|
filters.push(
|
||||||
|
`scale=${targetSize[0]}:${targetSize[1]}:force_original_aspect_ratio=increase`,
|
||||||
|
);
|
||||||
|
filters.push(`crop=${targetSize[0]}:${targetSize[1]}`);
|
||||||
|
|
||||||
// Combine all filters
|
// Combine all filters
|
||||||
if (filters.length > 0) {
|
if (filters.length > 0) {
|
||||||
ffmpegCmd += ` -vf "${filters.join(',')}"`;
|
ffmpegCmd += ` -vf "${filters.join(",")}"`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add frame limit
|
|
||||||
ffmpegCmd += ` -vframes ${maxFrames}`;
|
|
||||||
|
|
||||||
// Add quality settings
|
// 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}"`;
|
ffmpegCmd += ` "${outputPattern}"`;
|
||||||
|
|
||||||
@@ -79,7 +75,7 @@ export class FrameExtractor {
|
|||||||
const { stdout, stderr } = await execAsync(ffmpegCmd);
|
const { stdout, stderr } = await execAsync(ffmpegCmd);
|
||||||
|
|
||||||
if (stderr && !stderr.includes("frame=")) {
|
if (stderr && !stderr.includes("frame=")) {
|
||||||
logger.warn(`ffmpeg stderr: ${stderr}`);
|
logger.warning(`ffmpeg stderr: ${stderr}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read extracted frames
|
// Read extracted frames
|
||||||
@@ -97,19 +93,72 @@ export class FrameExtractor {
|
|||||||
logger.info(`Extracted ${frameFiles.length} frames`);
|
logger.info(`Extracted ${frameFiles.length} frames`);
|
||||||
|
|
||||||
// Load frames into XV4Frame format
|
// Load frames into XV4Frame format
|
||||||
const frames: XV4Frame[] = [];
|
// Re-encode frames to ensure consistent JPEG format
|
||||||
for (const file of frameFiles) {
|
logger.info("Re-encoding frames to quality 75...");
|
||||||
const framePath = join(tempDir, file);
|
|
||||||
const data = await readFile(framePath);
|
|
||||||
|
|
||||||
// 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", "");
|
const name = file.replace(".jpg", "");
|
||||||
|
|
||||||
frames.push({
|
return {
|
||||||
name,
|
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;
|
return frames;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -125,11 +174,39 @@ export class FrameExtractor {
|
|||||||
try {
|
try {
|
||||||
await rm(tempDir, { recursive: true, force: true });
|
await rm(tempDir, { recursive: true, force: true });
|
||||||
} catch (error) {
|
} 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
|
* Get frame rate of a video file
|
||||||
* @param filePath Path to the video file
|
* @param filePath Path to the video file
|
||||||
@@ -150,7 +227,7 @@ export class FrameExtractor {
|
|||||||
|
|
||||||
return 30; // Default fallback
|
return 30; // Default fallback
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.warn(`Failed to get frame rate: ${error}`);
|
logger.warning(`Failed to get frame rate: ${error}`);
|
||||||
return 30; // Default fallback
|
return 30; // Default fallback
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -166,16 +243,20 @@ export class FrameExtractor {
|
|||||||
const { stdout } = await execAsync(cmd);
|
const { stdout } = await execAsync(cmd);
|
||||||
return parseFloat(stdout.trim());
|
return parseFloat(stdout.trim());
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.warn(`Failed to get duration: ${error}`);
|
logger.warning(`Failed to get duration: ${error}`);
|
||||||
return 0;
|
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 filePath Path to the source file
|
||||||
* @param extractedFrameCount Number of frames that were extracted
|
* @param extractedFrameCount Number of frames that were extracted
|
||||||
* @returns Recommended interval in milliseconds
|
* @returns Frame interval in milliseconds
|
||||||
*/
|
*/
|
||||||
static async calculateFrameInterval(
|
static async calculateFrameInterval(
|
||||||
filePath: string,
|
filePath: string,
|
||||||
@@ -186,9 +267,12 @@ export class FrameExtractor {
|
|||||||
if (duration > 0 && extractedFrameCount > 1) {
|
if (duration > 0 && extractedFrameCount > 1) {
|
||||||
// Calculate interval to maintain original playback speed
|
// Calculate interval to maintain original playback speed
|
||||||
const intervalMs = (duration * 1000) / extractedFrameCount;
|
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?
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, test, expect } from "bun:test";
|
import { describe, test, expect } from "vitest";
|
||||||
import { ImageProcessor } from "./image-processor.ts";
|
import { ImageProcessor } from "./image-processor.ts";
|
||||||
import { ImageProcessingError } from "../utils/errors.ts";
|
import { ImageProcessingError } from "../utils/errors.ts";
|
||||||
import { DEFAULT_IMAGE_CONFIG } from "../protocol/interfaces/defaults.ts";
|
import { DEFAULT_IMAGE_CONFIG } from "../protocol/interfaces/defaults.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
|
* Prepare an image as JPEG bytes
|
||||||
* @param imageInput Sharp instance or buffer
|
* @param imageInput Sharp instance or buffer
|
||||||
* @param targetSize Target size [width, height]
|
* @param targetSize Target size [width, height]
|
||||||
* @returns JPEG image as Buffer
|
* @returns JPEG image as Buffer with JFIF marker
|
||||||
*/
|
*/
|
||||||
public async prepareImage(
|
public async prepareImage(
|
||||||
imageInput: sharp.Sharp | Buffer,
|
imageInput: sharp.Sharp | Buffer,
|
||||||
@@ -77,9 +101,10 @@ export class ImageProcessor {
|
|||||||
? sharp(imageInput)
|
? sharp(imageInput)
|
||||||
: imageInput;
|
: imageInput;
|
||||||
|
|
||||||
return await pipeline
|
const jpegData = await pipeline
|
||||||
.resize(targetSize[0], targetSize[1], {
|
.resize(targetSize[0], targetSize[1], {
|
||||||
fit: "fill",
|
fit: "cover", // Use 'cover' to fill frame (official app does scale increase + crop)
|
||||||
|
position: "center",
|
||||||
kernel: "lanczos3",
|
kernel: "lanczos3",
|
||||||
})
|
})
|
||||||
.toColorspace("srgb")
|
.toColorspace("srgb")
|
||||||
@@ -90,6 +115,14 @@ export class ImageProcessor {
|
|||||||
chromaSubsampling: "4:2:0",
|
chromaSubsampling: "4:2:0",
|
||||||
})
|
})
|
||||||
.toBuffer();
|
.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) {
|
} catch (error) {
|
||||||
throw new ImageProcessingError(`Failed to prepare image: ${error}`);
|
throw new ImageProcessingError(`Failed to prepare image: ${error}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, it, expect } from "bun:test";
|
import { describe, it, expect } from "vitest";
|
||||||
import { MediaDetector } from "./media-detector.ts";
|
import { MediaDetector } from "./media-detector.ts";
|
||||||
|
|
||||||
describe("MediaDetector", () => {
|
describe("MediaDetector", () => {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, test, expect } from "bun:test";
|
import { describe, test, expect } from "vitest";
|
||||||
import { IMBHeaderBuilder } from "./imb-header.ts";
|
import { IMBHeaderBuilder } from "./imb-header.ts";
|
||||||
import { expectHex } from "../../../__tests__/utils/test-helpers.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 { PayloadBuilder } from "./payload-builder.ts";
|
||||||
import { PacketType } from "../packet-types.ts";
|
import { PacketType } from "../packet-types.ts";
|
||||||
import { DEFAULT_PROTOCOL_CONFIG } from "../interfaces/defaults.ts";
|
import { DEFAULT_PROTOCOL_CONFIG } from "../interfaces/defaults.ts";
|
||||||
@@ -151,7 +151,7 @@ describe("PayloadBuilder", () => {
|
|||||||
data: createTestJpeg(100),
|
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);
|
const text = payload.toString("utf-8", 0, 15);
|
||||||
expect(text).toMatch(/^\{"type":5,"data/);
|
expect(text).toMatch(/^\{"type":5,"data/);
|
||||||
});
|
});
|
||||||
@@ -163,7 +163,7 @@ describe("PayloadBuilder", () => {
|
|||||||
data: createTestJpeg(100),
|
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);
|
const prefix = payload.toString("utf-8", 0, 17);
|
||||||
expect(prefix).toBe('{"type":5,"data":');
|
expect(prefix).toBe('{"type":5,"data":');
|
||||||
});
|
});
|
||||||
@@ -175,7 +175,7 @@ describe("PayloadBuilder", () => {
|
|||||||
data: createTestJpeg(100),
|
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);
|
const lastByte = payload.toString("utf-8", payload.length - 1);
|
||||||
expect(lastByte).toBe("}");
|
expect(lastByte).toBe("}");
|
||||||
});
|
});
|
||||||
@@ -187,7 +187,7 @@ describe("PayloadBuilder", () => {
|
|||||||
data: createTestJpeg(100),
|
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 prefixLen = '{"type":5,"data":'.length;
|
||||||
const xv4Sig = payload.toString("utf-8", prefixLen, prefixLen + 3);
|
const xv4Sig = payload.toString("utf-8", prefixLen, prefixLen + 3);
|
||||||
expect(xv4Sig).toBe("xV4");
|
expect(xv4Sig).toBe("xV4");
|
||||||
@@ -208,7 +208,7 @@ describe("PayloadBuilder", () => {
|
|||||||
data: createTestJpeg(120),
|
data: createTestJpeg(120),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
const payload = createBuilder().buildAnimationData(frames, 50, [368, 368]);
|
const payload = createBuilder().buildAnimationData(frames, 50, [360, 360]);
|
||||||
|
|
||||||
// Check it has proper structure
|
// Check it has proper structure
|
||||||
const prefixLen = '{"type":5,"data":'.length;
|
const prefixLen = '{"type":5,"data":'.length;
|
||||||
@@ -220,25 +220,35 @@ describe("PayloadBuilder", () => {
|
|||||||
expect(frameCount).toBe(3);
|
expect(frameCount).toBe(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("custom interval creates correct timing string", () => {
|
test("timing string uses interval value (clamped to 50-99)", () => {
|
||||||
const frames: XV4Frame[] = [
|
const frames: XV4Frame[] = [
|
||||||
{
|
{
|
||||||
name: "frame_00001",
|
name: "frame_00001",
|
||||||
data: createTestJpeg(100),
|
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
|
// The timing string is embedded in the xV4 container
|
||||||
const prefixLen = '{"type":5,"data":'.length;
|
const prefixLen = '{"type":5,"data":'.length;
|
||||||
const xv4Start = prefixLen;
|
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
|
const timingString = payload
|
||||||
.subarray(xv4Start + 16, xv4Start + 35)
|
.subarray(xv4Start + 16, xv4Start + 28)
|
||||||
.toString("utf8")
|
.toString("utf8")
|
||||||
.replace(/\0.*$/, "");
|
.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 type { ProtocolConfig } from "../interfaces/config.ts";
|
||||||
import { PacketType } from "../packet-types.ts";
|
import { PacketType } from "../packet-types.ts";
|
||||||
import { IMBHeaderBuilder } from "./imb-header.ts";
|
import { IMBHeaderBuilder } from "./imb-header.ts";
|
||||||
|
import { DEFAULT_IMAGE_CONFIG } from "../interfaces/defaults.ts";
|
||||||
import { XV4HeaderBuilder, type XV4Frame } from "./xv4-header.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 frames Array of frames with names and JPEG data
|
||||||
* @param intervalMs Frame interval in milliseconds (default: 50ms = 20fps)
|
* @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>}
|
* @returns Animation data payload bytes in format: {"type":5,"data":<xV4_BINARY>}
|
||||||
*/
|
*/
|
||||||
public buildAnimationData(
|
public buildAnimationData(
|
||||||
frames: XV4Frame[],
|
frames: XV4Frame[],
|
||||||
intervalMs: number = 50,
|
intervalMs: number = 50,
|
||||||
targetSize: [number, number] = [368, 368],
|
targetSize: [number, number] = DEFAULT_IMAGE_CONFIG.animationsSize,
|
||||||
): Buffer {
|
): Buffer {
|
||||||
const dataPrefix = Buffer.from(
|
const dataPrefix = Buffer.from(
|
||||||
`{"type":${PacketType.DYNAMIC_AMBIENCE},"data":`,
|
`{"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";
|
import { XV4HeaderBuilder, type XV4Frame } from "./xv4-header.ts";
|
||||||
|
|
||||||
describe("XV4HeaderBuilder", () => {
|
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
|
// Check signature
|
||||||
expect(container.subarray(0, 3).toString("ascii")).toBe("xV4");
|
expect(container.subarray(0, 3).toString("ascii")).toBe("xV4");
|
||||||
@@ -19,11 +19,14 @@ describe("XV4HeaderBuilder", () => {
|
|||||||
// Check version
|
// Check version
|
||||||
expect(container.readUInt8(3)).toBe(0x12);
|
expect(container.readUInt8(3)).toBe(0x12);
|
||||||
|
|
||||||
// Check unknown byte
|
// Check header_size field (offset 4) = frame_table_end - 8 = (32 + 1*16) - 8 = 40
|
||||||
expect(container.readUInt8(4)).toBe(0x48);
|
expect(container.readUInt32LE(4)).toBe(40);
|
||||||
|
|
||||||
// Check frame count (offset 8, u32 LE)
|
// Check frame count (offset 8)
|
||||||
expect(container.readUInt32LE(8)).toBe(1);
|
expect(container.readUInt32LE(8)).toBe(1);
|
||||||
|
|
||||||
|
// Check unknown field (offset 12) = frame_count * 10 + 10 = 20
|
||||||
|
expect(container.readUInt32LE(12)).toBe(20);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should create a valid xV4 container with multiple frames", () => {
|
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);
|
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
|
// Validate it's a proper xV4 container
|
||||||
expect(XV4HeaderBuilder.validate(container)).toBe(true);
|
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
|
const timingString = container
|
||||||
.subarray(16, 35)
|
.subarray(16, 28)
|
||||||
.toString("utf8")
|
.toString("utf8")
|
||||||
.replace(/\0.*$/, "");
|
.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", () => {
|
it("should throw error with no frames", () => {
|
||||||
expect(() => {
|
expect(() => {
|
||||||
XV4HeaderBuilder.build([], 50, 368, 368);
|
XV4HeaderBuilder.build([], 50, 360, 360);
|
||||||
}).toThrow("At least one frame is required");
|
}).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", () => {
|
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);
|
expect(XV4HeaderBuilder.validate(container)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -95,30 +271,43 @@ describe("XV4HeaderBuilder", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should reject buffer with wrong signature", () => {
|
it("should reject buffer with wrong signature", () => {
|
||||||
const buffer = Buffer.alloc(20);
|
const buffer = Buffer.alloc(40);
|
||||||
buffer.write("ABC", 0);
|
buffer.write("ABC", 0);
|
||||||
buffer.writeUInt8(0x12, 3);
|
buffer.writeUInt8(0x12, 3);
|
||||||
buffer.writeUInt8(0x48, 4);
|
|
||||||
|
|
||||||
expect(XV4HeaderBuilder.validate(buffer)).toBe(false);
|
expect(XV4HeaderBuilder.validate(buffer)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should reject buffer with wrong version", () => {
|
it("should reject buffer with wrong version", () => {
|
||||||
const buffer = Buffer.alloc(20);
|
const buffer = Buffer.alloc(40);
|
||||||
buffer.write("xV4", 0);
|
buffer.write("xV4", 0);
|
||||||
buffer.writeUInt8(0x99, 3); // Wrong version
|
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);
|
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
|
* xV4 Animation header builder
|
||||||
*
|
*
|
||||||
* The xV4 format is a animation container
|
* The xV4 format is an animation container for BeamBox devices.
|
||||||
*
|
*
|
||||||
* ## Format Structure
|
* ## Format Structure
|
||||||
*
|
*
|
||||||
* ```
|
* ```
|
||||||
* [Magic: "xV4"] [3 bytes]
|
* HEADER (32 bytes fixed):
|
||||||
* [Version] [1 byte] - Always 0x12
|
* [0-3] "xV4" + 0x12 (signature + version)
|
||||||
* [Unknown] [1 byte] - Always 0x48
|
* [4-7] Header size = frame_table_end - 8 (uint32 LE)
|
||||||
* [Padding] [3 bytes] - Always 0x00 0x00 0x00
|
* [8-11] Frame count (uint32 LE)
|
||||||
* [Frame count] [4 bytes, LE u32]
|
* [12-15] Unknown value = frame_count * 10 + 10 (uint32 LE)
|
||||||
* [Total JPEG size] [4 bytes, LE u32] - Sum of all frame data sizes
|
* [16-27] Timing string "output/XXms\0" (12 bytes, null-padded)
|
||||||
* [Timing string] [variable, null-terminated] - e.g., "output/50ms\0"
|
* [28-31] Total data size including per-frame metadata (uint32 LE)
|
||||||
* [Frame data offset] [4 bytes, LE u32] - Offset to where JPEG data starts
|
*
|
||||||
* [Frame table] [variable] - Frame entries (name + size)
|
* FRAME TABLE (frame_count * 16 bytes):
|
||||||
* [Footer] [16 bytes] - Frame sizes (first 2), unknown, dimensions
|
* Each entry (16 bytes):
|
||||||
* [JPEG data] [variable] - Concatenated JPEG frames
|
* [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 {
|
export interface XV4Frame {
|
||||||
@@ -37,155 +46,196 @@ export interface XV4Frame {
|
|||||||
export class XV4HeaderBuilder {
|
export class XV4HeaderBuilder {
|
||||||
private static readonly SIGNATURE = Buffer.from("xV4");
|
private static readonly SIGNATURE = Buffer.from("xV4");
|
||||||
private static readonly VERSION = 0x12;
|
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
|
* Build complete xV4 animation container with frames
|
||||||
*
|
*
|
||||||
* @param frames Array of frames with names and JPEG data
|
* @param frames Array of frames with names and JPEG data
|
||||||
* @param intervalMs Frame interval in milliseconds (e.g., 50 for 20fps)
|
* @param intervalMs Frame interval in milliseconds (e.g., 50 for 20fps)
|
||||||
* @param width Image width (default: 368)
|
* @param width Image width (default: 360)
|
||||||
* @param height Image height (default: 368)
|
* @param height Image height (default: 360)
|
||||||
* @returns Complete xV4 animation buffer ready to send
|
* @returns Complete xV4 animation buffer ready to send
|
||||||
*/
|
*/
|
||||||
static build(
|
static build(
|
||||||
frames: XV4Frame[],
|
frames: XV4Frame[],
|
||||||
intervalMs: number = 50,
|
intervalMs: number = 50,
|
||||||
width: number = 368,
|
width: number = 360,
|
||||||
height: number = 368,
|
height: number = 360,
|
||||||
): Buffer {
|
): Buffer {
|
||||||
if (frames.length === 0) {
|
if (frames.length === 0) {
|
||||||
throw new Error("At least one frame is required");
|
throw new Error("At least one frame is required");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate frame count
|
|
||||||
const frameCount = frames.length;
|
const frameCount = frames.length;
|
||||||
|
|
||||||
// Total JPEG size field is it's frame_count * 1000
|
// Calculate sizes
|
||||||
const totalSizeField = frameCount * 1000;
|
const frameTableSize = frameCount * this.FRAME_ENTRY_SIZE;
|
||||||
|
const frameTableEnd = this.FIXED_HEADER_SIZE + frameTableSize;
|
||||||
|
|
||||||
const totalJpegSize = frames.reduce(
|
// Total size = header + frame_table + (metadata + jpeg) for each frame
|
||||||
(sum, frame) => sum + frame.data.length,
|
const totalDataSize = frames.reduce(
|
||||||
|
(sum, frame) => sum + this.FRAME_METADATA_SIZE + frame.data.length,
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
|
const totalSize = frameTableEnd + totalDataSize;
|
||||||
// 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);
|
const container = Buffer.alloc(totalSize);
|
||||||
let offset = 0;
|
let offset = 0;
|
||||||
|
|
||||||
// Write signature "xV4"
|
// ===== HEADER (32 bytes) =====
|
||||||
|
|
||||||
|
// [0-2] Signature "xV4"
|
||||||
this.SIGNATURE.copy(container, offset);
|
this.SIGNATURE.copy(container, offset);
|
||||||
offset += 3;
|
offset += 3;
|
||||||
|
|
||||||
// Write version 0x12
|
// [3] Version 0x12
|
||||||
container.writeUInt8(this.VERSION, offset);
|
container.writeUInt8(this.VERSION, offset);
|
||||||
offset += 1;
|
offset += 1;
|
||||||
|
|
||||||
// Write unknown byte 0x48
|
// [4-7] Header size = frame_table_end - 8
|
||||||
container.writeUInt8(this.UNKNOWN_BYTE, offset);
|
const headerSizeField = frameTableEnd - 8;
|
||||||
offset += 1;
|
container.writeUInt32LE(headerSizeField, offset);
|
||||||
|
offset += 4;
|
||||||
|
|
||||||
// Write padding (3 null bytes)
|
// [8-11] Frame count
|
||||||
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);
|
container.writeUInt32LE(frameCount, offset);
|
||||||
offset += 4;
|
offset += 4;
|
||||||
|
|
||||||
// Write total size field (u32 LE) - this is frame_count * 1000
|
// [12-15] Unknown value = frame_count * 10 + 10
|
||||||
container.writeUInt32LE(totalSizeField, offset);
|
const unknownValue = frameCount * 10 + 10;
|
||||||
|
container.writeUInt32LE(unknownValue, offset);
|
||||||
offset += 4;
|
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);
|
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
|
// [28-31] Total data size (metadata + jpeg for all frames)
|
||||||
container.writeUInt32LE(frameDataOffsetValue, offset);
|
container.writeUInt32LE(totalDataSize, offset);
|
||||||
offset += 4;
|
offset += 4;
|
||||||
|
|
||||||
// Write frame table
|
// Verify we're at the right position
|
||||||
for (const frame of frames) {
|
if (offset !== this.FIXED_HEADER_SIZE) {
|
||||||
// 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(
|
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
|
// ===== FRAME TABLE (frameCount * 16 bytes) =====
|
||||||
for (const frame of frames) {
|
|
||||||
|
// 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);
|
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;
|
return container;
|
||||||
@@ -197,7 +247,7 @@ export class XV4HeaderBuilder {
|
|||||||
* @returns True if valid xV4 container
|
* @returns True if valid xV4 container
|
||||||
*/
|
*/
|
||||||
static validate(buffer: Buffer): boolean {
|
static validate(buffer: Buffer): boolean {
|
||||||
if (buffer.length < 20) {
|
if (buffer.length < this.FIXED_HEADER_SIZE) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -211,11 +261,79 @@ export class XV4HeaderBuilder {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check unknown byte
|
|
||||||
if (buffer.readUInt8(4) !== this.UNKNOWN_BYTE) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,6 +86,13 @@ export interface ImageConfig {
|
|||||||
*/
|
*/
|
||||||
defaultSize: [number, number];
|
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)
|
* JPEG compression quality (0-100)
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -40,16 +40,18 @@ export const DEFAULT_PROTOCOL_CONFIG: ProtocolConfig = {
|
|||||||
/**
|
/**
|
||||||
* Default image processing configuration
|
* 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
|
* - defaultSize: [368, 368] - device native resolution for static images
|
||||||
* - jpegQuality: 70 - good balance between quality and size
|
* - 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
|
* - jpegOptimize: true - enable optimization for smaller files
|
||||||
* - checkerboardSquares: 8 - for test pattern generation
|
* - checkerboardSquares: 8 - for test pattern generation
|
||||||
*/
|
*/
|
||||||
export const DEFAULT_IMAGE_CONFIG: ImageConfig = {
|
export const DEFAULT_IMAGE_CONFIG: ImageConfig = {
|
||||||
defaultSize: [368, 368],
|
defaultSize: [368, 368],
|
||||||
jpegQuality: 70,
|
animationsSize: [360, 360],
|
||||||
|
jpegQuality: 80,
|
||||||
jpegOptimize: true,
|
jpegOptimize: true,
|
||||||
checkerboardSquares: 8,
|
checkerboardSquares: 8,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export interface DeviceStatus {
|
|||||||
/**
|
/**
|
||||||
* Display resolution as "width,height" string
|
* Display resolution as "width,height" string
|
||||||
*
|
*
|
||||||
* Example: "368,368" = 368x368 pixels
|
* Example: "368,368" = 368x368 pixels (static); animations use 360x360
|
||||||
*/
|
*/
|
||||||
size: string;
|
size: string;
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export enum PacketType {
|
|||||||
/**
|
/**
|
||||||
* 0x05: DYNAMIC_AMBIENCE (Client to Device)
|
* 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)
|
* ## Upload Process (Two-Step)
|
||||||
*
|
*
|
||||||
@@ -30,35 +30,27 @@ export enum PacketType {
|
|||||||
* {"type":5,"data":<xV4_ANIMATION_DATA>}
|
* {"type":5,"data":<xV4_ANIMATION_DATA>}
|
||||||
* ```
|
* ```
|
||||||
*
|
*
|
||||||
* ## Animation Data Format
|
* ## xV4 Animation Format
|
||||||
*
|
*
|
||||||
* The data payload contains:
|
* The data payload is an xV4 container containing:
|
||||||
* TODO: Validate this format more thoroughly
|
* - 32-byte header with signature "xV4" + version 0x12
|
||||||
* - Signature: "xV4" (0x78 0x56 0x34), custom animation format?
|
* - Frame table with offsets to each frame's metadata
|
||||||
* - Frame timing: "output/50ms" interval between frames
|
* - Per-frame data: 32-byte metadata + JPEG data
|
||||||
* - Frame references: "frame_00001", "frame_00002", etc.
|
* - Looping: last frame's next_offset points back to first frame
|
||||||
* - Multiple JPEG frames embedded in single payload
|
|
||||||
*
|
*
|
||||||
* ## 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?
|
* For more information, go see xv4-header.ts
|
||||||
*
|
|
||||||
* ```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
|
|
||||||
* ```
|
|
||||||
*
|
*
|
||||||
* ## Use Cases
|
* ## Use Cases
|
||||||
*
|
*
|
||||||
* - Video upload: Extract frames from video file
|
* - Video upload: Extract frames from video file
|
||||||
* - GIF upload: Separate animated GIF into frames
|
* - GIF upload: Separate animated GIF into frames
|
||||||
* - Gallery/slideshow Mode: Convert multiple images to animation with interval (the official app does this)
|
* - Gallery/slideshow: Convert multiple images to animation with interval
|
||||||
*
|
|
||||||
* TODO: Full implementation pending
|
|
||||||
*/
|
*/
|
||||||
DYNAMIC_AMBIENCE = 0x05,
|
DYNAMIC_AMBIENCE = 0x05,
|
||||||
|
|
||||||
@@ -73,37 +65,37 @@ export enum PacketType {
|
|||||||
* ```json
|
* ```json
|
||||||
* {"type":6,"number":1}
|
* {"type":6,"number":1}
|
||||||
* ```
|
* ```
|
||||||
* TODO: Validate if "number" means image count
|
* - Announces that 1 image is coming
|
||||||
* - Announces that 1 image is coming? (Always observed as 1, even for multiple images, so might be something else)
|
|
||||||
* - Device responds with DEVICE_STATUS (type 13)
|
* - Device responds with DEVICE_STATUS (type 13)
|
||||||
*
|
*
|
||||||
* Step 2 - Send image data:
|
* Step 2 - Send image data:
|
||||||
* ```
|
* ```
|
||||||
* {"type":6,"data":<IMB_HEADER><JPEG_BINARY>}
|
* {"type":6,"data":<IMB_HEADER><JPEG_BINARY>}
|
||||||
* ```
|
* ```
|
||||||
* - IMB header: 36 bytes (contains size, dimensions)
|
* - IMB header: 14 bytes (signature + size + dimensions + padding)
|
||||||
* - JPEG data: Raw binary JPEG file
|
* - JPEG data: Raw binary JPEG file
|
||||||
* - Sent in chunks (0x1F0 (496) bytes per chunk)
|
* - Sent in chunks (0x1F0 (496) bytes per chunk)
|
||||||
* - Device responds "GetPacketSuccess" for each 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
|
* ## Important Notes
|
||||||
* - For multiple images: repeat the 2-step process sequentially
|
* - For multiple images: repeat the 2-step process sequentially
|
||||||
* - For IMB header format, see IMBHeaderBuilder in src/lib/protocol/imb-header.ts
|
* - For IMB header format, see IMBHeaderBuilder in imb-header.ts
|
||||||
* - Gallery/slideshow mode on the official app does NOT use this, uses DYNAMIC_AMBIENCE (Type 5) and preprocessing instead
|
* - Gallery/slideshow mode uses DYNAMIC_AMBIENCE (Type 5), not this
|
||||||
*
|
|
||||||
* 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
|
|
||||||
*
|
|
||||||
*/
|
*/
|
||||||
IMAGE = 0x06,
|
IMAGE = 0x06,
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 0x0C: PHOTO_ALBUM_COUNT (Client to Device)
|
* 0x0C: PHOTO_ALBUM_COUNT (Client to Device)
|
||||||
*
|
*
|
||||||
* Would announce number of images in gallery/album mode??
|
* 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
|
* Not used in current implementation, may be for other models.
|
||||||
*/
|
*/
|
||||||
PHOTO_ALBUM_COUNT = 0x0c,
|
PHOTO_ALBUM_COUNT = 0x0c,
|
||||||
|
|
||||||
@@ -128,9 +120,8 @@ export enum PacketType {
|
|||||||
* ## When Sent
|
* ## When Sent
|
||||||
*
|
*
|
||||||
* Device sends this in response to:
|
* Device sends this in response to:
|
||||||
* - Image info packet (Type)
|
* - Image info packet (Type 6)
|
||||||
* - Used to check available storage before upload
|
* - Used to check available storage before upload
|
||||||
*
|
|
||||||
*/
|
*/
|
||||||
DEVICE_STATUS = 0x0d,
|
DEVICE_STATUS = 0x0d,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, test, expect } from "bun:test";
|
import { describe, test, expect } from "vitest";
|
||||||
import { ResponseParser } from "./response-parser.ts";
|
import { ResponseParser } from "./response-parser.ts";
|
||||||
import { PacketType } from "../packet-types.ts";
|
import { PacketType } from "../packet-types.ts";
|
||||||
import { ResponseStatus } from "../response-types.ts";
|
import { ResponseStatus } from "../response-types.ts";
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, test, expect } from "bun:test";
|
import { describe, test, expect } from "vitest";
|
||||||
import {
|
import {
|
||||||
BeamBoxError,
|
BeamBoxError,
|
||||||
DeviceNotFoundError,
|
DeviceNotFoundError,
|
||||||
|
|||||||
Reference in New Issue
Block a user