mirror of
https://github.com/YuzuZensai/beamboxctl.git
synced 2026-07-21 20:42:19 +00:00
🐛 fix: incorrect video upload behavior
This commit is contained in:
@@ -266,12 +266,19 @@ export class BeamBoxUploader {
|
||||
): Promise<boolean> {
|
||||
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
|
||||
logger.info(
|
||||
`Extracting frames from animation at ${animationSize[0]}x${animationSize[1]}...`,
|
||||
);
|
||||
const frames = await FrameExtractor.extractFrames(filePath, {
|
||||
targetSize: animationSize,
|
||||
fps: ANIMATION_FPS,
|
||||
maxDurationSecs: MAX_ANIMATION_DURATION_SECS,
|
||||
});
|
||||
|
||||
logger.info(`Extracted ${frames.length} frames`);
|
||||
@@ -293,12 +300,7 @@ export class BeamBoxUploader {
|
||||
logger.info(`Padded to ${frames.length} frames`);
|
||||
}
|
||||
|
||||
// Calculate frame interval
|
||||
// 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;
|
||||
const intervalMs = Math.round(1000 / ANIMATION_FPS);
|
||||
logger.info(`Using frame interval: ${intervalMs}ms`);
|
||||
|
||||
// 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
|
||||
* @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> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
@@ -11,10 +11,12 @@ import { logger } from "../utils/logger.ts";
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
export interface FrameExtractionOptions {
|
||||
/** Target FPS for extraction (default: extract all frames) */
|
||||
/** Target FPS for extraction. */
|
||||
fps?: number;
|
||||
/** Target size for frames [width, height] */
|
||||
targetSize?: [number, number];
|
||||
/** Maximum source duration to extract, in seconds. */
|
||||
maxDurationSecs?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -31,7 +33,10 @@ export class FrameExtractor {
|
||||
filePath: string,
|
||||
options: FrameExtractionOptions = {},
|
||||
): 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
|
||||
const tempDir = await mkdtemp(join(tmpdir(), "beambox-frames-"));
|
||||
@@ -43,13 +48,10 @@ export class FrameExtractor {
|
||||
const outputPattern = join(tempDir, "frame_%05d.jpg");
|
||||
let ffmpegCmd = `ffmpeg -i "${filePath}"`;
|
||||
|
||||
// Build filter chain
|
||||
let filters: string[] = [];
|
||||
ffmpegCmd += ` -t ${maxDurationSecs}`;
|
||||
|
||||
// Add FPS filter if specified
|
||||
if (fps !== null) {
|
||||
filters.push(`fps=${fps}`);
|
||||
}
|
||||
// Build filter chain
|
||||
let filters: string[] = [`fps=${fps}`];
|
||||
|
||||
// Add scaling and cropping to fill frame (official app does this)
|
||||
// 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?
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user