Created
June 19, 2026 05:39
-
-
Save efstathiosntonas/eda491688752c36af260a82389224ada to your computer and use it in GitHub Desktop.
Video utils for FFMPEG
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import { Directory, File, Paths } from 'expo-file-system'; | |
| import { | |
| FFmpegKit, | |
| FFmpegKitConfig, | |
| FFprobeKit, | |
| ReturnCode | |
| } from 'ffmpeg-kit-react-native'; | |
| import { getErrorMessage, isAndroid } from '@utils/utils'; | |
| export const bufferConfig = { | |
| minBufferMs: 10000, // Reduced to 10 seconds for quicker start | |
| maxBufferMs: 30000, // Reduced to 30 seconds to match video length | |
| bufferForPlaybackMs: 2000, // Slightly reduced for quicker start | |
| bufferForPlaybackAfterRebufferMs: 4000, // Slightly reduced for quicker recovery | |
| cacheSizeMB: 100 // Reduced to 100MB to save memory | |
| }; | |
| export const GIF_FRAME_WIDTH = 88; | |
| export const getVideoMetadata = async (uri: string) => { | |
| const session = await FFprobeKit.getMediaInformation(uri); | |
| const information = session.getMediaInformation(); | |
| if (information?.getDuration() !== undefined) { | |
| return information.getDuration(); | |
| } | |
| /* Handle error cases */ | |
| const state = FFmpegKitConfig.sessionStateToString(await session.getState()); | |
| const returnCode = await session.getReturnCode(); | |
| const failStackTrace = await session.getFailStackTrace(); | |
| const sessionLog = await session.getLogsAsString(); | |
| const output = await session.getOutput(); | |
| throw new Error( | |
| `Unable to retrieve video metadata. State: ${state}, Return Code: ${returnCode}, Stack Trace: ${failStackTrace}, Output: ${output}, Logs: ${sessionLog}` | |
| ); | |
| }; | |
| const calculateHlsParameters = (duration: number) => { | |
| const minSegmentDuration = 6; // Minimum segment duration in seconds | |
| const maxSegmentDuration = 10; // Maximum segment duration in seconds | |
| let hlsTime; | |
| if (duration <= maxSegmentDuration) { | |
| hlsTime = duration; // Do not split very short videos | |
| } else { | |
| hlsTime = Math.min( | |
| maxSegmentDuration, | |
| Math.max(minSegmentDuration, Math.floor(duration / 10)) | |
| ); | |
| } | |
| return { hlsTime }; | |
| }; | |
| async function hasAudioStream(uri: string): Promise<boolean> { | |
| const session = await FFprobeKit.getMediaInformation(uri); | |
| const information = session.getMediaInformation(); | |
| const streams = information?.getStreams() || []; | |
| return streams.some((stream) => stream.getType() === 'audio'); | |
| } | |
| const buildFfmpegCommand = ( | |
| uri: string, | |
| outputUri: string, | |
| hlsTime: number, | |
| audioExists: boolean, | |
| targetWidth = 540, // Make resolution configurable | |
| targetFps = 20, // Make FPS configurable | |
| targetBitrateK = 800, // Make bitrate configurable | |
| targetProfile = 'main' // Default to 'main' (77), use 'baseline' (66) if needed | |
| ): string => { | |
| // Calculate related bitrates/buffer based on target average bitrate | |
| const maxRateK = Math.round(targetBitrateK * 1.25); /* e.g., 1.25x average */ | |
| const bufSizeK = maxRateK * 2; /* e.g., 2x maxrate */ | |
| /* Construct core video command parts */ | |
| let command = `-i "${uri}" -hide_banner -loglevel error -avoid_negative_ts make_zero`; | |
| command += ` -vf "scale=${targetWidth}:-2" -r ${targetFps}`; // Use configurable resolution/FPS | |
| command += ` -g ${targetFps * 2}`; // GOP size = 2 seconds based on target FPS | |
| command += ` -c:v libopenh264 -profile:v ${targetProfile} -pix_fmt yuv420p`; // Configurable profile | |
| command += ` -b:v ${targetBitrateK}k -maxrate ${maxRateK}k -bufsize ${bufSizeK}k`; // Use calculated rates/buffer | |
| command += ` -y -force_key_frames expr:gte(t,n_forced*${hlsTime})`; // Keep forced keyframes | |
| command += ` -f hls -hls_time ${hlsTime} -hls_playlist_type vod`; | |
| // Do NOT set hls_base_url - let FFmpeg write just filenames in the manifest. | |
| // The player will resolve segment paths relative to the manifest's directory. | |
| // Setting hls_base_url to a full path causes doubled paths when the manifest | |
| // is served from a CDN (player appends relative path to manifest's base URL). | |
| /* add Audio flags conditionally inside the function */ | |
| if (audioExists) { | |
| command += ' -c:a aac -b:a 128k -ar 44100 -ac 2'; | |
| } else { | |
| command += ' -an'; | |
| } | |
| command += ` "${outputUri}"`; | |
| return command; | |
| }; | |
| export const convertVideo = async ( | |
| uri: string, | |
| outputUri: string, | |
| onProgress?: (percent: number) => void | |
| ): Promise<void> => { | |
| try { | |
| const duration = await getVideoMetadata(uri); | |
| const durationMs = duration * 1000; | |
| const { hlsTime } = calculateHlsParameters(duration); | |
| const audioExists = await hasAudioStream(uri); | |
| let ffmpegCommand = buildFfmpegCommand( | |
| uri, | |
| outputUri, | |
| hlsTime, | |
| audioExists, | |
| 540, | |
| 30, | |
| 1200 | |
| ); | |
| ffmpegCommand = ffmpegCommand.trim(); | |
| if (__DEV__) { | |
| console.log(`Executing FFmpeg command:\n${ffmpegCommand}`); | |
| } | |
| await new Promise<void>((resolve, reject) => { | |
| FFmpegKit.executeAsync( | |
| ffmpegCommand, | |
| async (session) => { | |
| const returnCode = await session.getReturnCode(); | |
| if (ReturnCode.isSuccess(returnCode)) { | |
| console.log('Video conversion succeeded'); | |
| resolve(); | |
| } else { | |
| const sessionLog = await session.getLogsAsString(); | |
| const failStackTrace = await session.getFailStackTrace(); | |
| console.log('Video conversion failed', { | |
| returnCode: returnCode?.toString(), | |
| sessionLog, | |
| failStackTrace | |
| }); | |
| reject( | |
| new Error(`FFmpeg conversion failed: ${sessionLog || failStackTrace}`) | |
| ); | |
| } | |
| }, | |
| undefined, | |
| onProgress && durationMs > 0 | |
| ? (statistics) => { | |
| const time = statistics.getTime(); | |
| const percent = Math.min(100, Math.round((time / durationMs) * 100)); | |
| onProgress(percent); | |
| } | |
| : undefined | |
| ); | |
| }); | |
| } catch (e) { | |
| console.error('Error during video conversion', e); | |
| throw new Error(e instanceof Error ? e.message : 'Video conversion failed'); | |
| } | |
| }; | |
| export const getResultPath = (nanoid: string) => { | |
| const videoDir = `${Paths.cache.uri}videos/${nanoid}`; | |
| // Checks if gif directory exists. If not, creates it | |
| function ensureDirExists() { | |
| const dir = new Directory(videoDir); | |
| if (!dir.exists) { | |
| dir.create({ intermediates: true }); | |
| } | |
| } | |
| ensureDirExists(); | |
| return { | |
| m3u8: `${videoDir}/video.m3u8`, | |
| videoDir | |
| }; | |
| }; | |
| function getRandomTimestamps(duration: number, count: number): number[] { | |
| if (duration <= 0 || isNaN(duration)) { | |
| throw new Error('Video duration must be a positive number.'); | |
| } | |
| if (!Number.isInteger(count) || count <= 0) { | |
| throw new Error('Count must be a positive integer.'); | |
| } | |
| const precision = 2; | |
| const factor = Math.pow(10, precision); | |
| const maxUniqueTimestamps = Math.floor(duration * factor) + 1; | |
| if (count > maxUniqueTimestamps) { | |
| console.log( | |
| `Requested ${count} unique timestamps, but only ${maxUniqueTimestamps} are possible with duration ${duration}s and precision ${precision}. Adjusting count to ${maxUniqueTimestamps}.` | |
| ); | |
| count = maxUniqueTimestamps; | |
| } | |
| const timestampsSet = new Set<number>(); | |
| while (timestampsSet.size < count) { | |
| const randomTimestamp = parseFloat((Math.random() * duration).toFixed(precision)); | |
| timestampsSet.add(randomTimestamp); | |
| } | |
| return Array.from(timestampsSet).sort((a, b) => a - b); | |
| } | |
| // eslint-disable-next-line unused-imports/no-unused-vars | |
| function getFileSystemPath(uri: string): string { | |
| if (uri.startsWith('file://')) { | |
| return uri.slice(7); | |
| } else if (uri.startsWith('content://')) { | |
| // Handle content URIs on Android | |
| if (isAndroid) { | |
| // Copy the content URI to a temporary file in the app's cache directory | |
| const destFileName = `tempfile-${Date.now()}`; | |
| const destPath = `${Paths.cache.uri}${destFileName}`; | |
| try { | |
| const sourceFile = new File(uri); | |
| const destFile = new File(Paths.cache, destFileName); | |
| sourceFile.copy(destFile); | |
| return destPath; | |
| } catch (e) { | |
| throw new Error( | |
| `Failed to copy file from content URI: ${e instanceof Error ? e.message : String(e)}` | |
| ); | |
| } | |
| } else { | |
| throw new Error('Content URIs are not supported on this platform'); | |
| } | |
| } else { | |
| return uri; | |
| } | |
| } | |
| // eslint-disable-next-line unused-imports/no-unused-vars | |
| function cleanupTemporaryFile(filePath: string): void { | |
| try { | |
| const file = new File(filePath); | |
| file.delete(); | |
| } catch (e) { | |
| console.log( | |
| `Failed to delete temporary file: ${e instanceof Error ? e.message : String(e)}` | |
| ); | |
| } | |
| } | |
| export async function createGifFromRandomFrames( | |
| input: string, | |
| output: string, | |
| frameWidth = GIF_FRAME_WIDTH | |
| ) { | |
| const inputPath = input; | |
| const outputPath = output; | |
| const extractedFrames: string[] = []; | |
| try { | |
| const duration = await getVideoMetadata(inputPath); | |
| const timestamps = getRandomTimestamps(duration, 8); | |
| /* Extract frames in parallel with error recovery */ | |
| const extractionPromises = timestamps.map(async (timestamp, i) => { | |
| const frameNumber = i.toString().padStart(3, '0'); | |
| const framePath = `${outputPath}/frame${frameNumber}.bmp`; | |
| const extractCommand = `-hide_banner -loglevel error -ss ${timestamp} -i "${inputPath}" -frames:v 1 -update 1 -vf "scale=${frameWidth}:-1" "${framePath}"`; | |
| try { | |
| const session = await FFmpegKit.execute(`-y ${extractCommand}`); | |
| const returnCode = await session.getReturnCode(); | |
| if (!ReturnCode.isSuccess(returnCode)) { | |
| const sessionLog = await session.getLogsAsString(); | |
| console.warn( | |
| `Failed to extract frame at timestamp ${timestamp}: ${sessionLog}` | |
| ); | |
| return null; | |
| } | |
| /* Verify frame was actually written with retry */ | |
| let verified = false; | |
| for (let retry = 0; retry < 3; retry++) { | |
| const file = new File(framePath); | |
| if (file.exists) { | |
| verified = true; | |
| break; | |
| } | |
| if (retry < 2) { | |
| await new Promise((resolve) => setTimeout(resolve, 50)); | |
| } | |
| } | |
| if (verified) { | |
| extractedFrames.push(framePath); | |
| return framePath; | |
| } else { | |
| console.warn(`Frame file not verified after extraction: ${framePath}`); | |
| return null; | |
| } | |
| } catch (e) { | |
| console.warn( | |
| `Error extracting frame at timestamp ${timestamp}: ${e instanceof Error ? e.message : String(e)}` | |
| ); | |
| return null; | |
| } | |
| }); | |
| /* Wait for all extractions to complete */ | |
| const results = await Promise.all(extractionPromises); | |
| const successfulFrames = results.filter((path) => path !== null) as string[]; | |
| if (successfulFrames.length < 3) { | |
| throw new Error( | |
| `Insufficient frames extracted. Only ${successfulFrames.length} frames succeeded out of ${timestamps.length} attempted. Need at least 3 frames for GIF creation.` | |
| ); | |
| } | |
| if (successfulFrames.length < timestamps.length) { | |
| console.warn( | |
| `Extracted ${successfulFrames.length} frames out of ${timestamps.length} attempted. Proceeding with available frames.` | |
| ); | |
| } | |
| /* Renumber surviving frames so they are contiguous (frame000, frame001, ...). | |
| FFmpeg's frame%03d.bmp input pattern stops at the first missing index, so a | |
| gap left by a failed extraction would silently truncate the GIF to whatever | |
| precedes the gap. successfulFrames is in chronological (index) order, and each | |
| target index is <= its source index, so renaming ascending never collides. */ | |
| const contiguousFrames: string[] = []; | |
| for (let i = 0; i < successfulFrames.length; i++) { | |
| const currentPath = successfulFrames[i]; | |
| const desiredPath = `${outputPath}/frame${i.toString().padStart(3, '0')}.bmp`; | |
| if (currentPath === desiredPath) { | |
| contiguousFrames.push(desiredPath); | |
| continue; | |
| } | |
| try { | |
| const sourceFile = new File(currentPath); | |
| const destFile = new File(desiredPath); | |
| if (destFile.exists) { | |
| destFile.delete(); | |
| } | |
| sourceFile.move(destFile); | |
| contiguousFrames.push(desiredPath); | |
| } catch (e) { | |
| console.warn( | |
| `Failed to renumber frame ${currentPath} -> ${desiredPath}:`, | |
| e instanceof Error ? e.message : String(e) | |
| ); | |
| } | |
| } | |
| if (contiguousFrames.length < 3) { | |
| throw new Error( | |
| `Insufficient frames after renumbering. Only ${contiguousFrames.length} usable frames. Need at least 3 frames for GIF creation.` | |
| ); | |
| } | |
| /* Keep extractedFrames in sync with the renamed paths for cleanup */ | |
| extractedFrames.length = 0; | |
| extractedFrames.push(...contiguousFrames); | |
| /* Generate palette and GIF from successfully extracted frames */ | |
| const paletteFile = `${outputPath}/palette.bmp`; | |
| const paletteCommand = `-hide_banner -loglevel error -framerate 3 -i "${outputPath}/frame%03d.bmp" -vf "palettegen" -f image2 -update 1 "${paletteFile}"`; | |
| try { | |
| const session = await FFmpegKit.execute(`-y ${paletteCommand}`); | |
| const returnCode = await session.getReturnCode(); | |
| if (!ReturnCode.isSuccess(returnCode)) { | |
| const sessionLog = await session.getLogsAsString(); | |
| console.log( | |
| 'Palette generation failed, creating GIF without custom palette. Error:', | |
| sessionLog | |
| ); | |
| await createGifWithoutPalette(outputPath, frameWidth, extractedFrames); | |
| return; | |
| } | |
| } catch (e) { | |
| console.log( | |
| 'Palette generation failed, creating GIF without custom palette. Error:', | |
| e instanceof Error ? e.message : String(e) | |
| ); | |
| await createGifWithoutPalette(outputPath, frameWidth, extractedFrames); | |
| return; | |
| } | |
| /* Verify palette file exists */ | |
| const paletteFileObj = new File(paletteFile); | |
| if (!paletteFileObj.exists) { | |
| console.log('Palette file not found, creating GIF without custom palette'); | |
| await createGifWithoutPalette(outputPath, frameWidth, extractedFrames); | |
| return; | |
| } | |
| /* Create GIF using the palette */ | |
| const gifCommand = `-hide_banner -loglevel error -framerate 3 -i "${outputPath}/frame%03d.bmp" -i "${paletteFile}" -lavfi "fps=25,scale=${frameWidth}:-1:flags=lanczos [x]; [x][1:v] paletteuse" "${outputPath}/video.gif"`; | |
| try { | |
| const session = await FFmpegKit.execute(`-y ${gifCommand}`); | |
| const returnCode = await session.getReturnCode(); | |
| if (!ReturnCode.isSuccess(returnCode)) { | |
| const sessionLog = await session.getLogsAsString(); | |
| console.warn(`Failed to create GIF with palette: ${sessionLog}`); | |
| /* Try without palette as fallback */ | |
| await createGifWithoutPalette(outputPath, frameWidth, extractedFrames); | |
| } else { | |
| console.log('GIF conversion complete'); | |
| } | |
| } catch (e) { | |
| console.warn( | |
| `Failed to create GIF with palette: ${e instanceof Error ? e.message : String(e)}` | |
| ); | |
| /* Try without palette as fallback */ | |
| await createGifWithoutPalette(outputPath, frameWidth, extractedFrames); | |
| } | |
| } catch (error) { | |
| /* Main error handler - ensure cleanup happens */ | |
| console.error('Error during GIF creation:', getErrorMessage(error)); | |
| throw error; | |
| } finally { | |
| /* Always clean up frame files, even on error */ | |
| await cleanupFrameFiles(extractedFrames, outputPath); | |
| } | |
| } | |
| async function cleanupFrameFiles(framePaths: string[], outputPath: string) { | |
| const cleanupPromises: Promise<void>[] = []; | |
| /* Clean up successfully extracted frames */ | |
| for (const framePath of framePaths) { | |
| cleanupPromises.push( | |
| Promise.resolve().then(() => { | |
| try { | |
| const file = new File(framePath); | |
| if (file.exists) { | |
| file.delete(); | |
| } | |
| } catch (e) { | |
| console.warn( | |
| `Failed to delete frame file ${framePath}:`, | |
| e instanceof Error ? e.message : String(e) | |
| ); | |
| } | |
| }) | |
| ); | |
| } | |
| /* Clean up palette file if it exists */ | |
| cleanupPromises.push( | |
| Promise.resolve().then(() => { | |
| try { | |
| const paletteFile = new File(`${outputPath}/palette.bmp`); | |
| if (paletteFile.exists) { | |
| paletteFile.delete(); | |
| } | |
| } catch (e) { | |
| console.warn( | |
| 'Failed to delete palette file:', | |
| e instanceof Error ? e.message : String(e) | |
| ); | |
| } | |
| }) | |
| ); | |
| /* Clean up any other frame files that might exist (in case of partial failures) */ | |
| const framePathSet = new Set(framePaths); | |
| for (let i = 0; i < 8; i++) { | |
| const framePath = `${outputPath}/frame${i.toString().padStart(3, '0')}.bmp`; | |
| if (!framePathSet.has(framePath)) { | |
| cleanupPromises.push( | |
| Promise.resolve().then(() => { | |
| try { | |
| const file = new File(framePath); | |
| if (file.exists) { | |
| file.delete(); | |
| } | |
| } catch (e) { | |
| console.log(e); | |
| } | |
| }) | |
| ); | |
| } | |
| } | |
| await Promise.all(cleanupPromises); | |
| } | |
| async function createGifWithoutPalette( | |
| outputPath: string, | |
| frameWidth: number, | |
| _extractedFrames: string[] | |
| ) { | |
| /* Create GIF directly from frames without custom palette */ | |
| const gifCommand = `-hide_banner -loglevel error -framerate 3 -i "${outputPath}/frame%03d.bmp" -vf "fps=25,scale=${frameWidth}:-1:flags=lanczos" "${outputPath}/video.gif"`; | |
| try { | |
| const session = await FFmpegKit.execute(`-y ${gifCommand}`); | |
| const returnCode = await session.getReturnCode(); | |
| if (!ReturnCode.isSuccess(returnCode)) { | |
| const sessionLog = await session.getLogsAsString(); | |
| throw new Error(`Failed to create GIF without palette: ${sessionLog}`); | |
| } | |
| console.log('GIF conversion complete (without custom palette)'); | |
| } catch (e) { | |
| throw new Error( | |
| `Failed to create GIF without palette: ${e instanceof Error ? e.message : String(e)}` | |
| ); | |
| } | |
| /* Cleanup is now handled by the main function */ | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment