diff --git a/lib/media/video-utils.js b/lib/media/video-utils.js --- a/lib/media/video-utils.js +++ b/lib/media/video-utils.js @@ -1,7 +1,4 @@ // @flow - -import invariant from 'invariant'; - import { replaceExtension, sanitizeFilename } from './file-utils.js'; import { maxDimensions } from './media-utils.js'; import type { Dimensions, MediaMissionFailure } from '../types/media-types.js'; @@ -20,18 +17,20 @@ +inputDuration: number, +inputDimensions: Dimensions, +outputDirectory: string, - +outputCodec: string, +clientConnectionInfo?: { +hasWiFi: boolean, +speed: number, // in kilobytes per second }, +clientTranscodeSpeed?: number, // in input video seconds per second }; + export type ProcessPlan = { +action: 'process', + +inputPath: string, +outputPath: string, +thumbnailPath: string, - +ffmpegCommand: string, + +width: number, + +height: number, }; type Plan = | { +action: 'none', +thumbnailPath: string } @@ -47,7 +46,6 @@ inputDuration, inputDimensions, outputDirectory, - outputCodec, clientConnectionInfo, clientTranscodeSpeed, } = input; @@ -97,44 +95,31 @@ ); const outputPath = `${outputDirectory}${outputFilename}`; - let hardwareAcceleration, quality, speed, scale, pixelFormat; - if (outputCodec === 'h264_mediacodec') { - hardwareAcceleration = 'mediacodec'; - quality = ''; - speed = ''; - scale = `-vf scale=${maxWidth}:${maxHeight}:force_original_aspect_ratio=decrease`; - pixelFormat = ''; - } else if (outputCodec === 'h264_videotoolbox') { - hardwareAcceleration = 'videotoolbox'; - quality = '-profile:v baseline'; - speed = '-realtime 1'; - const { width, height } = inputDimensions; - scale = ''; - const exceedsDimensions = width > maxWidth || height > maxHeight; - if (exceedsDimensions && width / height > maxWidth / maxHeight) { - scale = `-vf scale=${maxWidth}:-1`; - } else if (exceedsDimensions) { - scale = `-vf scale=-1:${maxHeight}`; + const { width, height } = inputDimensions; + let targetWidth = -1, + targetHeight = -1; + const exceedsDimensions = width > maxWidth || height > maxHeight; + if (exceedsDimensions) { + if (width / height > maxWidth / maxHeight) { + targetWidth = maxWidth; + targetHeight = (maxWidth * height) / width; + } else { + targetHeight = maxHeight; + targetWidth = (maxHeight * width) / height; } - pixelFormat = '-pix_fmt yuv420p'; } else { - invariant(false, `unrecognized outputCodec ${outputCodec}`); + targetWidth = width; + targetHeight = height; } - const ffmpegCommand = - `-hwaccel ${hardwareAcceleration} ` + - `-i ${inputPath} ` + - `-c:a copy -c:v ${outputCodec} ` + - `${quality} ` + - '-fps_mode cfr -r 30 ' + - `${scale} ` + - `${speed} ` + - '-movflags +faststart ' + - `${pixelFormat} ` + - '-v quiet ' + - outputPath; - - return { action: 'process', thumbnailPath, outputPath, ffmpegCommand }; + return { + action: 'process', + thumbnailPath, + inputPath, + outputPath, + width: targetWidth, + height: targetHeight, + }; } const videoDurationLimit = 3; // in minutes diff --git a/lib/types/media-types.js b/lib/types/media-types.js --- a/lib/types/media-types.js +++ b/lib/types/media-types.js @@ -104,10 +104,6 @@ +time: number, // total result file size in bytes so far +size: number, - +videoQuality: number, - +videoFrameNumber: number, - +videoFps: number, - +bitrate: number, }; export type TranscodeVideoMediaMissionStep = { @@ -115,7 +111,6 @@ +success: boolean, +exceptionMessage: ?string, +time: number, // ms - +returnCode: ?number, +newPath: ?string, +stats: ?FFmpegStatistics, }; diff --git a/native/media/ffmpeg.js b/native/media/ffmpeg.js --- a/native/media/ffmpeg.js +++ b/native/media/ffmpeg.js @@ -1,13 +1,12 @@ // @flow - -import { FFmpegKit, FFmpegKitConfig } from 'ffmpeg-kit-react-native'; - import type { FFmpegStatistics, VideoInfo } from 'lib/types/media-types.js'; +import type { TranscodeOptions } from '../utils/media-module.js'; import { getVideoInfo, hasMultipleFrames, generateThumbnail, + transcodeVideo, } from '../utils/media-module.js'; const maxSimultaneousCalls = { @@ -80,32 +79,23 @@ } transcodeVideo( - ffmpegCommand: string, - inputVideoDuration: number, + inputPath: string, + outputPath: string, + transcodeOptions: TranscodeOptions, onTranscodingProgress?: (percent: number) => void, - ): Promise<{ rc: number, lastStats: ?FFmpegStatistics }> { - const duration = inputVideoDuration > 0 ? inputVideoDuration : 0.001; + ): Promise<FFmpegStatistics> { const wrappedCommand = async () => { - let lastStats; - if (onTranscodingProgress) { - FFmpegKitConfig.enableStatisticsCallback(statisticsObject => { - const time = statisticsObject.getTime(); - onTranscodingProgress(time / 1000 / duration); - lastStats = { - speed: statisticsObject.getSpeed(), - time, - size: statisticsObject.getSize(), - videoQuality: statisticsObject.getVideoQuality(), - videoFrameNumber: statisticsObject.getVideoFrameNumber(), - videoFps: statisticsObject.getVideoFps(), - bitrate: statisticsObject.getBitrate(), - }; - }); - } - const session = await FFmpegKit.execute(ffmpegCommand); - const returnCode = await session.getReturnCode(); - const rc = returnCode.getValue(); - return { rc, lastStats }; + const stats = await transcodeVideo( + inputPath, + outputPath, + transcodeOptions, + onTranscodingProgress, + ); + return { + speed: stats.speed, + time: stats.duration, + size: stats.size, + }; }; return this.queueCommand('process', wrappedCommand); } diff --git a/native/media/video-utils.js b/native/media/video-utils.js --- a/native/media/video-utils.js +++ b/native/media/video-utils.js @@ -1,7 +1,6 @@ // @flow import invariant from 'invariant'; -import { Platform } from 'react-native'; import filesystem from 'react-native-fs'; import { mediaConfig, pathFromURI } from 'lib/media/file-utils.js'; @@ -32,6 +31,8 @@ const validCodecs = ['avc', 'avc1', 'h264']; const validFormats = ['mp4']; +const transcodeBitrate = 4000; // kb/s + type ProcessVideoInfo = { +uri: string, +mime: string, @@ -82,11 +83,6 @@ inputDuration: duration, inputDimensions: input.dimensions, outputDirectory: temporaryDirectoryPath, - outputCodec: Platform.select({ - ios: 'h264_videotoolbox', - android: 'h264_mediacodec', - default: 'h264', - }), clientConnectionInfo: { hasWiFi: input.hasWiFi, speed: input.hasWiFi ? uploadSpeeds.wifi : uploadSpeeds.cellular, @@ -216,33 +212,28 @@ onProgressCallback?: number => void, ): Promise<TranscodeVideoMediaMissionStep> { const transcodeStart = Date.now(); - let returnCode, - newPath, - stats, - success = false, - exceptionMessage; + let newPath, stats, exceptionMessage; try { - const { rc, lastStats } = await ffmpeg.transcodeVideo( - plan.ffmpegCommand, - duration, + stats = await ffmpeg.transcodeVideo( + plan.inputPath, + plan.outputPath, + { + width: plan.width, + height: plan.height, + bitrate: transcodeBitrate, + }, onProgressCallback, ); - success = rc === 0; - if (success) { - returnCode = rc; - newPath = plan.outputPath; - stats = lastStats; - } + newPath = plan.outputPath; } catch (e) { exceptionMessage = getMessageForException(e); } return { step: 'video_ffmpeg_transcode', - success, + success: !exceptionMessage, exceptionMessage, time: Date.now() - transcodeStart, - returnCode, newPath, stats, }; diff --git a/native/utils/media-module.js b/native/utils/media-module.js --- a/native/utils/media-module.js +++ b/native/utils/media-module.js @@ -1,6 +1,10 @@ // @flow -import { requireNativeModule } from 'expo-modules-core'; +import { + requireNativeModule, + NativeModulesProxy, + EventEmitter, +} from 'expo-modules-core'; type VideoInfo = { +duration: number, // seconds @@ -10,12 +14,40 @@ +format: string, }; +export type H264Profile = 'baseline' | 'main' | 'high'; + +export type TranscodeOptions = { + +width: number, + +height: number, + +bitrate?: number, + +profile?: H264Profile, +}; + +type ProgressCallback = (progress: number) => void; + +type TranscodeProgressEvent = { + +progress: number, +}; + +type TranscodeStats = { + +size: number, + +duration: number, + +speed: number, +}; + const MediaModule: { +getVideoInfo: (path: string) => Promise<VideoInfo>, +hasMultipleFrames: (path: string) => Promise<boolean>, +generateThumbnail: (inputPath: string, outputPath: string) => Promise<void>, + +transcodeVideo: ( + inputPath: string, + outputPath: string, + options: TranscodeOptions, + ) => Promise<TranscodeStats>, } = requireNativeModule('MediaModule'); +const emitter = new EventEmitter(MediaModule ?? NativeModulesProxy.MediaModule); + export function getVideoInfo(path: string): Promise<VideoInfo> { return MediaModule.getVideoInfo(path); } @@ -30,3 +62,20 @@ ): Promise<void> { return MediaModule.generateThumbnail(inputPath, outputPath); } + +export async function transcodeVideo( + inputPath: string, + outputPath: string, + options: TranscodeOptions, + progressCallback?: ProgressCallback, +): Promise<TranscodeStats> { + const listener = (event: TranscodeProgressEvent) => { + progressCallback?.(event.progress); + }; + const subscription = emitter.addListener('onTranscodeProgress', listener); + try { + return await MediaModule.transcodeVideo(inputPath, outputPath, options); + } finally { + subscription.remove(); + } +}