From 2defb6ca9f789f8496a40d80bda8c9e541abd244 Mon Sep 17 00:00:00 2001 From: Yuzu Date: Tue, 9 Jun 2026 01:18:30 +0700 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix:=20incorrect=20video=20uploa?= =?UTF-8?q?d=20behavior?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/core/beambox-uploader.ts | 38 ++------- src/lib/processing/frame-extractor.ts | 115 +++----------------------- 2 files changed, 18 insertions(+), 135 deletions(-) diff --git a/src/lib/core/beambox-uploader.ts b/src/lib/core/beambox-uploader.ts index 439a0b3..2a820c4 100644 --- a/src/lib/core/beambox-uploader.ts +++ b/src/lib/core/beambox-uploader.ts @@ -266,12 +266,19 @@ export class BeamBoxUploader { ): Promise { 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 | 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 { return new Promise((resolve) => setTimeout(resolve, ms)); } diff --git a/src/lib/processing/frame-extractor.ts b/src/lib/processing/frame-extractor.ts index 9c34603..51dbdb2 100644 --- a/src/lib/processing/frame-extractor.ts +++ b/src/lib/processing/frame-extractor.ts @@ -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 { - 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 { - 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 { - 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 { - 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? - } }