🐛 fix: incorrect video upload behavior

This commit is contained in:
2026-06-09 01:18:30 +07:00
parent c4c66c0e8d
commit 2defb6ca9f
2 changed files with 18 additions and 135 deletions
+8 -30
View File
@@ -266,12 +266,19 @@ export class BeamBoxUploader {
): Promise<boolean> { ): Promise<boolean> {
const animationSize: [number, number] = targetSize; const animationSize: [number, number] = targetSize;
// Official app does this. so let's do it too
// caps every animation source at 3 seconds, 20fps
const MAX_ANIMATION_DURATION_SECS = 3;
const ANIMATION_FPS = 20;
// Extract frames from the file // Extract frames from the file
logger.info( logger.info(
`Extracting frames from animation at ${animationSize[0]}x${animationSize[1]}...`, `Extracting frames from animation at ${animationSize[0]}x${animationSize[1]}...`,
); );
const frames = await FrameExtractor.extractFrames(filePath, { const frames = await FrameExtractor.extractFrames(filePath, {
targetSize: animationSize, targetSize: animationSize,
fps: ANIMATION_FPS,
maxDurationSecs: MAX_ANIMATION_DURATION_SECS,
}); });
logger.info(`Extracted ${frames.length} frames`); logger.info(`Extracted ${frames.length} frames`);
@@ -293,12 +300,7 @@ export class BeamBoxUploader {
logger.info(`Padded to ${frames.length} frames`); logger.info(`Padded to ${frames.length} frames`);
} }
// Calculate frame interval const intervalMs = Math.round(1000 / ANIMATION_FPS);
// 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`); logger.info(`Using frame interval: ${intervalMs}ms`);
// Wait for device to be ready // Wait for device to be ready
@@ -414,14 +416,6 @@ export class BeamBoxUploader {
}); });
} }
/**
* Check if device is ready
* @returns True if device is ready
*/
public isDeviceReady(): boolean {
return this.ble.isDeviceReady();
}
/** /**
* Get device status notifications * Get device status notifications
* @param timeoutMs Maximum time to wait for status in milliseconds * @param timeoutMs Maximum time to wait for status in milliseconds
@@ -501,22 +495,6 @@ export class BeamBoxUploader {
} }
} }
/**
* Get device status
* @returns Device status object
*/
public getDeviceStatus(): Record<string, unknown> | null {
return this.ble.getDeviceStatus();
}
/**
* Check if an error occurred
* @returns True if error occurred
*/
public hasError(): boolean {
return this.ble.hasError();
}
private sleep(ms: number): Promise<void> { private sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms)); return new Promise((resolve) => setTimeout(resolve, ms));
} }
+10 -105
View File
@@ -11,10 +11,12 @@ import { logger } from "../utils/logger.ts";
const execAsync = promisify(exec); const execAsync = promisify(exec);
export interface FrameExtractionOptions { export interface FrameExtractionOptions {
/** Target FPS for extraction (default: extract all frames) */ /** Target FPS for extraction. */
fps?: number; fps?: number;
/** Target size for frames [width, height] */ /** Target size for frames [width, height] */
targetSize?: [number, number]; targetSize?: [number, number];
/** Maximum source duration to extract, in seconds. */
maxDurationSecs?: number;
} }
/** /**
@@ -31,7 +33,10 @@ export class FrameExtractor {
filePath: string, filePath: string,
options: FrameExtractionOptions = {}, options: FrameExtractionOptions = {},
): Promise<XV4Frame[]> { ): Promise<XV4Frame[]> {
const { fps = null, targetSize = [360, 360] } = options; // The official app extracts at 20fps, and caps at 3 seconds (60 frames max).
// Let's do the same to ensure consistent results and avoid overwhelming the device with too many frames
// Because the device firmware blindly accepts all frames and can cause the device to brick :(
const { fps = 20, targetSize = [360, 360], maxDurationSecs = 3 } = 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-"));
@@ -43,13 +48,10 @@ export class FrameExtractor {
const outputPattern = join(tempDir, "frame_%05d.jpg"); const outputPattern = join(tempDir, "frame_%05d.jpg");
let ffmpegCmd = `ffmpeg -i "${filePath}"`; let ffmpegCmd = `ffmpeg -i "${filePath}"`;
// Build filter chain ffmpegCmd += ` -t ${maxDurationSecs}`;
let filters: string[] = [];
// Add FPS filter if specified // Build filter chain
if (fps !== null) { let filters: string[] = [`fps=${fps}`];
filters.push(`fps=${fps}`);
}
// Add scaling and cropping to fill frame (official app does this) // Add scaling and cropping to fill frame (official app does this)
// Use 'increase' to scale up to fill, then crop to exact dimensions // Use 'increase' to scale up to fill, then crop to exact dimensions
@@ -178,101 +180,4 @@ export class FrameExtractor {
} }
} }
} }
/**
* 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
* @returns Frame rate in fps
*/
static async getFrameRate(filePath: string): Promise<number> {
try {
const cmd = `ffprobe -v quiet -select_streams v:0 -show_entries stream=r_frame_rate -of csv=p=0 "${filePath}"`;
const { stdout } = await execAsync(cmd);
// Parse fraction (e.g., "30/1" or "30000/1001")
const parts = stdout.trim().split("/");
if (parts.length === 2) {
const num = parseInt(parts[0]!);
const den = parseInt(parts[1]!);
return num / den;
}
return 30; // Default fallback
} catch (error) {
logger.warning(`Failed to get frame rate: ${error}`);
return 30; // Default fallback
}
}
/**
* Get duration of a video file in seconds
* @param filePath Path to the video file
* @returns Duration in seconds
*/
static async getDuration(filePath: string): Promise<number> {
try {
const cmd = `ffprobe -v quiet -show_entries format=duration -of csv=p=0 "${filePath}"`;
const { stdout } = await execAsync(cmd);
return parseFloat(stdout.trim());
} catch (error) {
logger.warning(`Failed to get duration: ${error}`);
return 0;
}
}
/**
* 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 Frame interval in milliseconds
*/
static async calculateFrameInterval(
filePath: string,
extractedFrameCount: number,
): Promise<number> {
const duration = await this.getDuration(filePath);
if (duration > 0 && extractedFrameCount > 1) {
// Calculate interval to maintain original playback speed
const intervalMs = (duration * 1000) / extractedFrameCount;
// 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 150; // Default 150ms (~6.7fps), device minimum for animation?
}
} }