FFMpegProcessor.kt (9453B) - raw
1 package me.rhunk.snapenhance.download 2 3 import android.media.AudioFormat 4 import android.media.MediaMetadataRetriever 5 import com.arthenica.ffmpegkit.FFmpegKit 6 import com.arthenica.ffmpegkit.FFmpegSession 7 import com.arthenica.ffmpegkit.Level 8 import com.arthenica.ffmpegkit.Statistics 9 import kotlinx.coroutines.suspendCancellableCoroutine 10 import me.rhunk.snapenhance.LogManager 11 import me.rhunk.snapenhance.RemoteSideContext 12 import me.rhunk.snapenhance.common.config.impl.DownloaderConfig 13 import me.rhunk.snapenhance.common.data.download.AudioStreamFormat 14 import me.rhunk.snapenhance.common.logger.LogLevel 15 import me.rhunk.snapenhance.task.PendingTask 16 import java.io.File 17 import java.util.concurrent.Executors 18 19 20 class ArgumentList { 21 private val arguments = mutableListOf<Pair<String, String>>() 22 23 operator fun plusAssign(stringPair: Pair<String, String>) { 24 arguments += stringPair 25 } 26 27 operator fun plusAssign(key: String) { 28 arguments += key to "" 29 } 30 31 operator fun minusAssign(key: String) { 32 arguments.removeIf { it.first == key } 33 } 34 35 operator fun get(key: String) = arguments.find { it.first == key }?.second 36 37 fun forEach(action: (Pair<String, String>) -> Unit) { 38 arguments.forEach(action) 39 } 40 41 fun clear() { 42 arguments.clear() 43 } 44 } 45 46 47 class FFMpegProcessor( 48 private val logManager: LogManager, 49 private val ffmpegOptions: DownloaderConfig.FFMpegOptions, 50 private val onStatistics: (Statistics) -> Unit = {} 51 ) { 52 companion object { 53 private const val TAG = "ffmpeg-processor" 54 55 fun newFFMpegProcessor(context: RemoteSideContext, pendingTask: PendingTask) = FFMpegProcessor( 56 logManager = context.log, 57 ffmpegOptions = context.config.root.downloader.ffmpegOptions, 58 onStatistics = { 59 pendingTask.updateProgress("Processing (frames=${it.videoFrameNumber}, fps=${it.videoFps}, time=${it.time}, bitrate=${it.bitrate}, speed=${it.speed})") 60 } 61 ) 62 } 63 enum class Action { 64 DOWNLOAD_DASH, 65 MERGE_OVERLAY, 66 CONVERSION, 67 MERGE_MEDIA, 68 DOWNLOAD_AUDIO_STREAM, 69 } 70 71 data class Request( 72 val action: Action, 73 val inputs: List<String>, 74 val output: File, 75 val overlay: File? = null, //only for MERGE_OVERLAY 76 val startTime: Long? = null, //only for DOWNLOAD_DASH 77 val duration: Long? = null, //only for DOWNLOAD_DASH 78 val audioStreamFormat: AudioStreamFormat? = null, //only for DOWNLOAD_AUDIO_STREAM 79 80 var videoCodec: String? = null, 81 var audioCodec: String? = null, 82 ) 83 84 85 private suspend fun newFFMpegTask(globalArguments: ArgumentList, inputArguments: ArgumentList, outputArguments: ArgumentList) = suspendCancellableCoroutine<FFmpegSession> { 86 val stringBuilder = StringBuilder() 87 arrayOf(globalArguments, inputArguments, outputArguments).forEach { argumentList -> 88 argumentList.forEach { (key, value) -> 89 stringBuilder.append("$key ${value.takeIf { it.isNotEmpty() }?.plus(" ") ?: ""}") 90 } 91 } 92 93 logManager.debug("arguments: $stringBuilder", "FFMpegProcessor") 94 95 FFmpegKit.executeAsync(stringBuilder.toString(), 96 { session -> 97 it.resumeWith( 98 if (session.returnCode.isValueSuccess) { 99 Result.success(session) 100 } else { 101 Result.failure(Exception(session.output)) 102 } 103 ) 104 }, logFunction@{ log -> 105 logManager.internalLog(TAG, when (log.level) { 106 Level.AV_LOG_ERROR, Level.AV_LOG_FATAL -> LogLevel.ERROR 107 Level.AV_LOG_WARNING -> LogLevel.WARN 108 Level.AV_LOG_VERBOSE -> LogLevel.VERBOSE 109 else -> return@logFunction 110 }, log.message) 111 }, { onStatistics(it) }, Executors.newSingleThreadExecutor()) 112 } 113 114 suspend fun execute(args: Request) { 115 // load ffmpeg native sync to avoid native crash 116 synchronized(this) { FFmpegKit.listSessions() } 117 val globalArguments = ArgumentList().apply { 118 this += "-y" 119 this += "-threads" to ffmpegOptions.threads.get().toString() 120 } 121 122 val inputArguments = ArgumentList().apply { 123 args.inputs.forEach { path -> 124 this += "-i" to path 125 } 126 } 127 128 val outputArguments = ArgumentList().apply { 129 this += "-preset" to (ffmpegOptions.preset.getNullable() ?: "ultrafast") 130 this += "-c:v" to (ffmpegOptions.customVideoCodec.get().takeIf { it.isNotEmpty() } ?: "h264_mediacodec") 131 this += "-c:a" to (ffmpegOptions.customAudioCodec.get().takeIf { it.isNotEmpty() } ?: "copy") 132 this += "-crf" to ffmpegOptions.constantRateFactor.get().let { "\"$it\"" } 133 this += "-b:v" to ffmpegOptions.videoBitrate.get().toString() + "K" 134 this += "-b:a" to ffmpegOptions.audioBitrate.get().toString() + "K" 135 } 136 137 when (args.action) { 138 Action.DOWNLOAD_DASH -> { 139 outputArguments += "-ss" to "'${args.startTime}ms'" 140 if (args.duration != null) { 141 outputArguments += "-t" to "'${args.duration}ms'" 142 } 143 } 144 Action.MERGE_OVERLAY -> { 145 inputArguments += "-i" to args.overlay!!.absolutePath 146 outputArguments += "-filter_complex" to "\"[0]scale2ref[img][vid];[img]setsar=1[img];[vid]nullsink;[img][1]overlay=(W-w)/2:(H-h)/2,scale=2*trunc(iw*sar/2):2*trunc(ih/2)\"" 147 } 148 Action.CONVERSION -> { 149 if (ffmpegOptions.customAudioCodec.isEmpty()) { 150 outputArguments -= "-c:a" 151 } 152 outputArguments -= "-c:v" 153 args.videoCodec?.let { 154 outputArguments += "-c:v" to it 155 } ?: run { 156 outputArguments += "-vn" 157 } 158 args.audioCodec?.let { 159 outputArguments -= "-c:a" 160 outputArguments += "-c:a" to it 161 } 162 } 163 Action.MERGE_MEDIA -> { 164 inputArguments.clear() 165 val filesInfo = args.inputs.mapNotNull { file -> 166 runCatching { 167 MediaMetadataRetriever().apply { setDataSource(file) } 168 }.getOrNull()?.let { file to it } 169 } 170 171 val (maxWidth, maxHeight) = filesInfo.maxByOrNull { (_, r) -> 172 r.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toIntOrNull() ?: 0 173 }?.let { (_, r) -> 174 r.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toIntOrNull() to 175 r.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)?.toIntOrNull() 176 } ?: throw Exception("Failed to get video size") 177 178 val filterFirstPart = StringBuilder() 179 val filterSecondPart = StringBuilder() 180 var containsNoSound = false 181 182 filesInfo.forEachIndexed { index, (file, retriever) -> 183 filterFirstPart.append("[$index:v]scale=$maxWidth:$maxHeight,setsar=1[v$index];") 184 if (retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_HAS_AUDIO) == "yes") { 185 filterSecondPart.append("[v$index][$index:a]") 186 } else { 187 containsNoSound = true 188 filterSecondPart.append("[v$index][${filesInfo.size}]") 189 } 190 inputArguments += "-i" to file 191 } 192 193 if (containsNoSound) { 194 inputArguments += "-f" to "lavfi" 195 inputArguments += "-t" to "0.1" 196 inputArguments += "-i" to "anullsrc=channel_layout=stereo:sample_rate=44100" 197 } 198 199 if (outputArguments["-c:a"] == "copy") { 200 outputArguments -= "-c:a" 201 } 202 203 outputArguments += "-fps_mode" to "vfr" 204 205 outputArguments += "-filter_complex" to "\"$filterFirstPart ${filterSecondPart}concat=n=${args.inputs.size}:v=1:a=1[vout][aout]\"" 206 outputArguments += "-map" to "\"[aout]\"" 207 outputArguments += "-map" to "\"[vout]\"" 208 209 filesInfo.forEach { it.second.close() } 210 } 211 Action.DOWNLOAD_AUDIO_STREAM -> { 212 outputArguments.clear() 213 globalArguments += "-f" to when (args.audioStreamFormat!!.encoding) { 214 AudioFormat.ENCODING_PCM_8BIT -> "u8" 215 AudioFormat.ENCODING_PCM_16BIT -> "s16le" 216 AudioFormat.ENCODING_PCM_FLOAT -> "f32le" 217 AudioFormat.ENCODING_PCM_32BIT -> "s32le" 218 else -> throw IllegalArgumentException("Unsupported audio encoding") 219 } 220 globalArguments += "-ar" to args.audioStreamFormat.sampleRate.toString() 221 globalArguments += "-ac" to args.audioStreamFormat.channels.toString() 222 } 223 } 224 outputArguments += args.output.absolutePath 225 newFFMpegTask(globalArguments, inputArguments, outputArguments) 226 } 227 }