commit 89a155227794b987bb91abd003c33db9d9a50161
parent 94d064e0a548432049e144af10953172afe87b25
Author: rhunk <101876869+rhunk@users.noreply.github.com>
Date:   Fri,  1 Sep 2023 15:10:14 +0200

feat: force voice note audio format
- add custom ffmpeg options

Diffstat:
Mapp/build.gradle.kts | 1+
Mapp/src/main/kotlin/me/rhunk/snapenhance/download/DownloadProcessor.kt | 40++++++++++++++++++++++++++++------------
Aapp/src/main/kotlin/me/rhunk/snapenhance/download/FFMpegProcessor.kt | 122+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcore/build.gradle.kts | 1-
Mcore/src/main/assets/lang/en_US.json | 34++++++++++++++++++++++++++++++++++
Mcore/src/main/kotlin/me/rhunk/snapenhance/core/config/ConfigObjects.kt | 1+
Mcore/src/main/kotlin/me/rhunk/snapenhance/core/config/impl/DownloaderConfig.kt | 15+++++++++++++++
Mcore/src/main/kotlin/me/rhunk/snapenhance/core/download/DownloadManagerClient.kt | 17++++++++++++-----
Mcore/src/main/kotlin/me/rhunk/snapenhance/core/download/data/DownloadRequest.kt | 3++-
Mcore/src/main/kotlin/me/rhunk/snapenhance/data/FileType.kt | 12++++++++++--
Mcore/src/main/kotlin/me/rhunk/snapenhance/features/impl/downloader/MediaDownloader.kt | 7++++---
Mcore/src/main/kotlin/me/rhunk/snapenhance/util/snap/MediaDownloaderHelper.kt | 44--------------------------------------------
12 files changed, 229 insertions(+), 68 deletions(-)

diff --git a/app/build.gradle.kts b/app/build.gradle.kts @@ -101,6 +101,7 @@ dependencies { implementation(libs.gson) implementation(libs.coil.compose) implementation(libs.coil.video) + implementation(libs.ffmpeg.kit) implementation(libs.osmdroid.android) debugImplementation("androidx.compose.ui:ui-tooling:1.4.3") diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/download/DownloadProcessor.kt b/app/src/main/kotlin/me/rhunk/snapenhance/download/DownloadProcessor.kt @@ -27,7 +27,6 @@ import me.rhunk.snapenhance.core.download.data.DownloadStage import me.rhunk.snapenhance.core.download.data.InputMedia import me.rhunk.snapenhance.core.download.data.MediaEncryptionKeyPair import me.rhunk.snapenhance.util.download.RemoteMediaResolver -import me.rhunk.snapenhance.util.snap.MediaDownloaderHelper import java.io.File import java.io.InputStream import java.net.HttpURLConnection @@ -63,9 +62,8 @@ class DownloadProcessor ( remoteSideContext.translation.getCategory("download_processor") } - private val gson by lazy { - GsonBuilder().setPrettyPrinting().create() - } + private val ffmpegProcessor by lazy { FFMpegProcessor(remoteSideContext.config.root.downloader.ffmpegOptions) } + private val gson by lazy { GsonBuilder().setPrettyPrinting().create() } private fun fallbackToast(message: Any) { android.os.Handler(remoteSideContext.androidContext.mainLooper).post { @@ -251,6 +249,21 @@ class DownloadProcessor ( val media = downloadedMedias[inputMedia]!! if (!downloadRequest.isDashPlaylist) { + if (inputMedia.messageContentType == "NOTE") { + remoteSideContext.config.root.downloader.forceVoiceNoteFormat.getNullable()?.let { format -> + val outputFile = File.createTempFile("voice_note", ".$format") + ffmpegProcessor.execute(FFMpegProcessor.Request( + action = FFMpegProcessor.Action.AUDIO_CONVERSION, + input = media.file, + output = outputFile + )) + media.file.delete() + saveMediaToGallery(outputFile, downloadObjectObject) + outputFile.delete() + return + } + } + saveMediaToGallery(media.file, downloadObjectObject) media.file.delete() return @@ -275,11 +288,13 @@ class DownloadProcessor ( callbackOnProgress(translation.format("download_toast", "path" to dashPlaylistFile.nameWithoutExtension)) val outputFile = File.createTempFile("dash", ".mp4") runCatching { - MediaDownloaderHelper.downloadDashChapterFile( - dashPlaylist = dashPlaylistFile, + ffmpegProcessor.execute(FFMpegProcessor.Request( + action = FFMpegProcessor.Action.DOWNLOAD_DASH, + input = dashPlaylistFile, output = outputFile, startTime = dashOptions.offsetTime, - duration = dashOptions.duration) + duration = dashOptions.duration + )) saveMediaToGallery(outputFile, downloadObjectObject) }.onFailure { exception -> if (coroutineContext.job.isCancelled) return@onFailure @@ -370,11 +385,12 @@ class DownloadProcessor ( callbackOnProgress(translation.format("download_toast", "path" to media.file.nameWithoutExtension)) downloadObjectObject.downloadStage = DownloadStage.MERGING - MediaDownloaderHelper.mergeOverlayFile( - media = renamedMedia, - overlay = renamedOverlayMedia, - output = mergedOverlay - ) + ffmpegProcessor.execute(FFMpegProcessor.Request( + action = FFMpegProcessor.Action.MERGE_OVERLAY, + input = renamedMedia, + output = mergedOverlay, + overlay = renamedOverlayMedia + )) saveMediaToGallery(mergedOverlay, downloadObjectObject) }.onFailure { exception -> diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/download/FFMpegProcessor.kt b/app/src/main/kotlin/me/rhunk/snapenhance/download/FFMpegProcessor.kt @@ -0,0 +1,121 @@ +package me.rhunk.snapenhance.download + +import com.arthenica.ffmpegkit.FFmpegKit +import com.arthenica.ffmpegkit.FFmpegSession +import kotlinx.coroutines.suspendCancellableCoroutine +import me.rhunk.snapenhance.Logger +import me.rhunk.snapenhance.core.config.impl.DownloaderConfig +import java.io.File +import java.util.concurrent.Executors + + +class ArgumentList : LinkedHashMap<String, MutableList<String>>() { + operator fun plusAssign(stringPair: Pair<String, String>) { + val (key, value) = stringPair + if (this.containsKey(key)) { + this[key]!!.add(value) + } else { + this[key] = mutableListOf(value) + } + } + + operator fun plusAssign(key: String) { + this[key] = mutableListOf<String>().apply { + this += "" + } + } + + operator fun minusAssign(key: String) { + this.remove(key) + } +} + + +class FFMpegProcessor( + private val ffmpegOptions: DownloaderConfig.FFMpegOptions +) { + enum class Action { + DOWNLOAD_DASH, + MERGE_OVERLAY, + AUDIO_CONVERSION, + } + + data class Request( + val action: Action, + val input: File, + val output: File, + val overlay: File? = null, //only for MERGE_OVERLAY + val startTime: Long? = null, //only for DOWNLOAD_DASH + val duration: Long? = null //only for DOWNLOAD_DASH + ) + + + private suspend fun newFFMpegTask(globalArguments: ArgumentList, inputArguments: ArgumentList, outputArguments: ArgumentList) = suspendCancellableCoroutine<FFmpegSession> { + val stringBuilder = StringBuilder() + arrayOf(globalArguments, inputArguments, outputArguments).forEach { argumentList -> + argumentList.forEach { (key, values) -> + values.forEach valueForEach@{ value -> + if (value.isEmpty()) { + stringBuilder.append("$key ") + return@valueForEach + } + stringBuilder.append("$key $value ") + } + } + } + + Logger.directDebug("arguments: $stringBuilder", "FFMpegProcessor") + + FFmpegKit.executeAsync(stringBuilder.toString(), { session -> + it.resumeWith( + if (session.returnCode.isValueSuccess) { + Result.success(session) + } else { + Result.failure(Exception(session.output)) + } + ) + }, Executors.newSingleThreadExecutor()) + } + + suspend fun execute(args: Request) { + val globalArguments = ArgumentList().apply { + this += "-y" + this += "-threads" to ffmpegOptions.threads.get().toString() + } + + val inputArguments = ArgumentList().apply { + this += "-i" to args.input.absolutePath + } + + val outputArguments = ArgumentList().apply { + this += "-preset" to (ffmpegOptions.preset.getNullable() ?: "ultrafast") + this += "-c:v" to (ffmpegOptions.customVideoCodec.get().takeIf { it.isNotEmpty() } ?: "libx264") + this += "-c:a" to (ffmpegOptions.customAudioCodec.get().takeIf { it.isNotEmpty() } ?: "copy") + this += "-crf" to ffmpegOptions.constantRateFactor.get().let { "\"$it\"" } + this += "-b:v" to ffmpegOptions.videoBitrate.get().toString() + "K" + } + + when (args.action) { + Action.DOWNLOAD_DASH -> { + outputArguments += "-ss" to "'${args.startTime}ms'" + if (args.duration != null) { + outputArguments += "-t" to "'${args.duration}ms'" + } + } + Action.MERGE_OVERLAY -> { + inputArguments += "-i" to args.overlay!!.absolutePath + 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)\"" + } + Action.AUDIO_CONVERSION -> { + if (ffmpegOptions.customAudioCodec.isEmpty()) { + outputArguments -= "-c:a" + } + if (ffmpegOptions.customVideoCodec.isEmpty()) { + outputArguments -= "-c:v" + } + } + } + outputArguments += args.output.absolutePath + newFFMpegTask(globalArguments, inputArguments, outputArguments) + } +}+ \ No newline at end of file diff --git a/core/build.gradle.kts b/core/build.gradle.kts @@ -37,7 +37,6 @@ dependencies { implementation(libs.kotlin.reflect) implementation(libs.recyclerview) implementation(libs.gson) - implementation(libs.ffmpeg.kit) implementation(libs.okhttp) implementation(libs.androidx.documentfile) diff --git a/core/src/main/assets/lang/en_US.json b/core/src/main/assets/lang/en_US.json @@ -115,10 +115,44 @@ "name": "Force Image Format", "description": "Forces images to be saved as a specific format" }, + "force_voice_note_format": { + "name": "Force Voice Note Format", + "description": "Forces voice notes to be saved as a specific format" + }, "chat_download_context_menu": { "name": "Chat Download Context Menu", "description": "Allows to download messages from a conversation by long pressing them" }, + "ffmpeg_options": { + "name": "FFmpeg Options", + "description": "Specify additional FFmpeg options", + "properties": { + "threads": { + "name": "Threads", + "description": "The amount of threads to use" + }, + "preset": { + "name": "Preset", + "description": "Set the speed of the conversion" + }, + "constant_rate_factor": { + "name": "Constant Rate Factor", + "description": "Set the constant rate factor for the video encoder\nFrom 0 to 51 for libx264 (lower to higher quality)" + }, + "video_bitrate": { + "name": "Video Bitrate", + "description": "Set the video bitrate (in kbps)" + }, + "custom_video_codec": { + "name": "Custom Video Codec", + "description": "Set a custom video codec (e.g. libx264)" + }, + "custom_audio_codec": { + "name": "Custom Audio Codec", + "description": "Set a custom audio codec (e.g. aac)" + } + } + }, "logging": { "name": "Logging", "description": "Shows toasts when media is downloading" diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/config/ConfigObjects.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/config/ConfigObjects.kt @@ -65,6 +65,7 @@ class PropertyValue<T>( fun isSet() = value != null fun getNullable() = value?.takeIf { it != "null" } + fun isEmpty() = value == null || value == "null" || value.toString().isEmpty() fun get() = getNullable() ?: throw IllegalStateException("Property is not set") fun set(value: T?) { this.value = value } @Suppress("UNCHECKED_CAST") diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/config/impl/DownloaderConfig.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/config/impl/DownloaderConfig.kt @@ -5,6 +5,17 @@ import me.rhunk.snapenhance.core.config.ConfigFlag import me.rhunk.snapenhance.core.config.FeatureNotice class DownloaderConfig : ConfigContainer() { + inner class FFMpegOptions : ConfigContainer() { + val threads = integer("threads", 1) + val preset = unique("preset", "ultrafast", "superfast", "veryfast", "faster", "fast", "medium", "slow", "slower", "veryslow") { + addFlags(ConfigFlag.NO_TRANSLATE) + } + val constantRateFactor = integer("constant_rate_factor", 30) + val videoBitrate = integer("video_bitrate", 5000) + val customVideoCodec = string("custom_video_codec") { addFlags(ConfigFlag.NO_TRANSLATE) } + val customAudioCodec = string("custom_audio_codec") { addFlags(ConfigFlag.NO_TRANSLATE) } + } + val saveFolder = string("save_folder") { addFlags(ConfigFlag.FOLDER) } val autoDownloadSources = multiple("auto_download_sources", "friend_snaps", @@ -26,7 +37,11 @@ class DownloaderConfig : ConfigContainer() { val forceImageFormat = unique("force_image_format", "jpg", "png", "webp") { addFlags(ConfigFlag.NO_TRANSLATE) } + val forceVoiceNoteFormat = unique("force_voice_note_format", "aac", "mp3", "opus") { + addFlags(ConfigFlag.NO_TRANSLATE) + } val chatDownloadContextMenu = boolean("chat_download_context_menu") + val ffmpegOptions = container("ffmpeg_options", FFMpegOptions()) { addNotices(FeatureNotice.UNSTABLE) } val logging = multiple("logging", "started", "success", "progress", "failure").apply { set(mutableListOf("started", "success")) } diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/download/DownloadManagerClient.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/download/DownloadManagerClient.kt @@ -10,6 +10,7 @@ import me.rhunk.snapenhance.core.download.data.DownloadMetadata import me.rhunk.snapenhance.core.download.data.DownloadRequest import me.rhunk.snapenhance.core.download.data.InputMedia import me.rhunk.snapenhance.core.download.data.MediaEncryptionKeyPair +import me.rhunk.snapenhance.data.ContentType class DownloadManagerClient ( private val context: ModContext, @@ -45,15 +46,21 @@ class DownloadManagerClient ( ) } - fun downloadSingleMedia(mediaData: String, mediaType: DownloadMediaType, encryption: MediaEncryptionKeyPair? = null) { + fun downloadSingleMedia( + mediaData: String, + mediaType: DownloadMediaType, + encryption: MediaEncryptionKeyPair? = null, + messageContentType: ContentType? = null + ) { enqueueDownloadRequest( DownloadRequest( inputMedias = arrayOf( InputMedia( - content = mediaData, - type = mediaType, - encryption = encryption - ) + content = mediaData, + type = mediaType, + encryption = encryption, + messageContentType = messageContentType?.name + ) ) ) ) diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/download/data/DownloadRequest.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/download/data/DownloadRequest.kt @@ -5,7 +5,8 @@ data class DashOptions(val offsetTime: Long, val duration: Long?) data class InputMedia( val content: String, val type: DownloadMediaType, - val encryption: MediaEncryptionKeyPair? = null + val encryption: MediaEncryptionKeyPair? = null, + val messageContentType: String? = null, ) class DownloadRequest( diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/data/FileType.kt b/core/src/main/kotlin/me/rhunk/snapenhance/data/FileType.kt @@ -1,5 +1,6 @@ package me.rhunk.snapenhance.data +import me.rhunk.snapenhance.Logger import java.io.File import java.io.InputStream @@ -14,6 +15,8 @@ enum class FileType( PNG("png", "image/png", false, true, false), MP4("mp4", "video/mp4", true, false, false), MP3("mp3", "audio/mp3",false, false, true), + OPUS("opus", "audio/opus", false, false, true), + AAC("aac", "audio/aac", false, false, true), JPG("jpg", "image/jpg",false, true, false), ZIP("zip", "application/zip", false, false, false), WEBP("webp", "image/webp", false, true, false), @@ -25,9 +28,12 @@ enum class FileType( "52494646" to WEBP, "504b0304" to ZIP, "89504e47" to PNG, - "00000020" to MP4, + "00000020" to MP4, "00000018" to MP4, "0000001c" to MP4, + "494433" to MP3, + "4f676753" to OPUS, + "fff15" to AAC, "ffd8ff" to JPG, ) @@ -55,7 +61,9 @@ enum class FileType( val headerBytes = ByteArray(16) System.arraycopy(array, 0, headerBytes, 0, 16) val hex = bytesToHex(headerBytes) - return fileSignatures.entries.firstOrNull { hex.startsWith(it.key) }?.value ?: UNKNOWN + return fileSignatures.entries.firstOrNull { hex.startsWith(it.key) }?.value ?: UNKNOWN.also { + Logger.directDebug("unknown file type, header: $hex", "FileType") + } } fun fromInputStream(inputStream: InputStream): FileType { diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/downloader/MediaDownloader.kt b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/downloader/MediaDownloader.kt @@ -473,9 +473,10 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp mediaAuthor = authorName, friendInfo = friendInfo ).downloadSingleMedia( - Base64.UrlSafe.encode(urlProto), - DownloadMediaType.PROTO_MEDIA, - encryption = encryptionKeys?.toKeyPair() + mediaData = Base64.UrlSafe.encode(urlProto), + mediaType = DownloadMediaType.PROTO_MEDIA, + encryption = encryptionKeys?.toKeyPair(), + messageContentType = contentType ) return } diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/util/snap/MediaDownloaderHelper.kt b/core/src/main/kotlin/me/rhunk/snapenhance/util/snap/MediaDownloaderHelper.kt @@ -1,8 +1,5 @@ package me.rhunk.snapenhance.util.snap -import com.arthenica.ffmpegkit.FFmpegKit -import com.arthenica.ffmpegkit.FFmpegSession -import kotlinx.coroutines.suspendCancellableCoroutine import me.rhunk.snapenhance.Constants import me.rhunk.snapenhance.core.download.data.SplitMediaAssetType import me.rhunk.snapenhance.data.ContentType @@ -10,10 +7,8 @@ import me.rhunk.snapenhance.data.FileType import me.rhunk.snapenhance.util.download.RemoteMediaResolver import me.rhunk.snapenhance.util.protobuf.ProtoReader import java.io.ByteArrayInputStream -import java.io.File import java.io.FileNotFoundException import java.io.InputStream -import java.util.concurrent.Executors import java.util.zip.ZipInputStream @@ -69,43 +64,4 @@ object MediaDownloaderHelper { return mapOf(SplitMediaAssetType.ORIGINAL to content) } - - - private suspend fun runFFmpegAsync(vararg args: String) = suspendCancellableCoroutine<FFmpegSession> { - FFmpegKit.executeAsync(args.joinToString(" "), { session -> - it.resumeWith( - if (session.returnCode.isValueSuccess) { - Result.success(session) - } else { - Result.failure(Exception(session.output)) - } - ) - }, - Executors.newSingleThreadExecutor()) - } - - //TODO: implement setting parameters - - suspend fun downloadDashChapterFile( - dashPlaylist: File, - output: File, - startTime: Long, - duration: Long?) { - runFFmpegAsync( - "-y", "-i", dashPlaylist.absolutePath, "-ss", "'${startTime}ms'", *(if (duration != null) arrayOf("-t", "'${duration}ms'") else arrayOf()), - "-c:v", "libx264", "-preset", "ultrafast", "-threads", "6", "-q:v", "13", output.absolutePath - ) - } - - suspend fun mergeOverlayFile( - media: File, - overlay: File, - output: File - ) { - runFFmpegAsync( - "-y", "-i", media.absolutePath, "-i", overlay.absolutePath, - "-filter_complex", "\"[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)\"", - "-c:v", "libx264", "-b:v", "5M", "-c:a", "copy", "-preset", "ultrafast", "-threads", "6", output.absolutePath - ) - } } \ No newline at end of file