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> {
|
): 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));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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?
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user