Skip to content

Instantly share code, notes, and snippets.

@efstathiosntonas
Created June 19, 2026 05:39
Show Gist options
  • Select an option

  • Save efstathiosntonas/eda491688752c36af260a82389224ada to your computer and use it in GitHub Desktop.

Select an option

Save efstathiosntonas/eda491688752c36af260a82389224ada to your computer and use it in GitHub Desktop.
Video utils for FFMPEG
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