前言
公司業務中有一些場景需要用到服務端音視訊剪輯技術,最開始為了快速上線使用的是某公有云的商用解決方案,但由於費用太高所以我們團隊經過一個星期的衝刺,給出了一個FFmpeg+Serverless的解決方案,更好地滿足了業務方的視訊剪輯需求。經過統計,自研方案成功地將剪輯失敗率降到了萬分之一左右,並且將費用成本降到了原本的15%左右,是一個非常大的突破。現在我們計劃把該平臺做得更加通用化,讓更多的業務可以無縫接入,通過任務編排來實現更多定製化需求。
題外話,為什麼我們會選擇Serverless來實現該業務呢,因為我們的業務高峰期特別明顯,時效性要求也高,在使用某公有云解決方案期間經常觸發系統QPS限制,經過多次溝通也只能臨時調整,而且對方技術半夜還打電話來問我是否可以限制QPS,作為使用方肯定是不願意的,所以除了成本外效能也是我們下決心自研系統的原因之一。Serverless在使用過程中遇到的問題我也會在後續時間裡面記錄下來。
本編部落格主要是記錄在整個過程中涉及到的FFmpeg常見命令,以及一些坑,分享給大家使用,若有謬誤請批評指正。
基本命令
音視訊剪下
String.format("%s -i %s -vcodec libx264 -ss %s -to %s %s -y", ffmpegCommandPath, sourceFilePath, startTime, endTime, targetFilePath)
ffmpegCommandPath 表示FFmpeg執行命令的路徑
sourceFilePath 表示原始檔路徑
startTime 表示剪下的起始點,格式為 "00:00:00", 例如 "00:00:15" 表示剪下從第15秒開始
endTime 表示剪下的終止點,格式為 "00:00:00", 例如 "00:00:20" 表示剪下截止到第20秒
targetFilePath 表示剪下後的輸出檔案
-y 表示輸出檔案若存在則覆蓋
音訊/視訊簡單拼接
String.format("%s -f concat -safe 0 -i %s -c copy %s", ffmpegCommandPath, concatListFilePath, destinationFilePath)
ffmpegCommandPath 表示FFmpeg執行命令的路徑
concatListFilePath 表示拼接的配置檔案,內容格式為
file 'filePath1.audio'
file 'filePath2.audio'
destinationFilePath 表示拼接後的輸出檔案
使用限制:該方式不涉及到檔案的解碼、編碼,所以速度極快,但如果待處理檔案的編碼格式不同則請勿使用,否則輸出的檔案可能無法正常播放(或者只能播放一部分)。如果編碼格式不同,請參考下文中的音訊拼接/視訊拼接方式,會更加可靠,當會更加消耗資源。
音訊拼接
由於涉及到到引數拼接,所以直接上程式碼(Java方式)。
/** * 音訊檔案拼接 * @param files 音訊檔案資源路徑陣列 * @param destinationFilePath 處理後輸出檔案路徑 */ public static void audioConcat(String[] files, String destinationFilePath) { // command list List<String> commandList = new ArrayList<>(); commandList.add("ffmpeg"); // input_options for (String file : files) { commandList.add("-i"); commandList.add(file); } // filter_complex StringBuilder filterComplexOptions = new StringBuilder(); for (int i = 0; i < files.length; i++) { filterComplexOptions.append(String.format("[%s:0]", i)); } filterComplexOptions.append(String.format("concat=n=%s:v=0:a=1[out]", files.length)); commandList.add("-filter_complex"); commandList.add(filterComplexOptions.toString()); commandList.add("-map"); commandList.add("[out]"); commandList.add(destinationFilePath); Runtime.getRuntime().exec(commandList.toArray(new String[0])); // next process }
視訊拼接
由於涉及到到引數拼接,所以直接上程式碼(Java方式)。
/** * 視訊拼接 * @param files 音訊檔案資源路徑陣列 * @param destinationFilePath 處理後輸出檔案路徑 * @param outputWidth 輸出視訊的寬度 * @param outputHeight 輸出視訊的高度 */ public static void videoConcat(String[] files, String destinationFilePath, Integer outputWidth, Integer outputHeight) { // command list List<String> commandList = buildFfmpegCommand(); commandList.add("ffmpeg"); // input_options for (String file : files) { commandList.add("-i"); commandList.add(file); } // filter_complex StringBuilder filterComplexOptions = new StringBuilder(); StringBuilder streamsOptions = new StringBuilder(); for (int i = 0; i < files.length; i++) { filterComplexOptions.append(String.format("[%s:v]scale=w=%s:h=%s,setsar=1/1[v%s];", i, outputWidth, outputHeight, i)); streamsOptions.append(String.format("[v%s][%s:a]", i, i)); } streamsOptions.append(String.format("concat=n=%s:v=1:a=1 [vv] [aa]", files.length)); commandList.add("-filter_complex"); commandList.add(String.format("%s%s", filterComplexOptions.toString(), streamsOptions.toString())); Collections.addAll(commandList, "-map", "[vv]", "-map", "[aa]", "-c:v", "libx264", "-x264-params", "profile=main:level=3.1", "-crf", "18", "-y", "-vsync", "vfr"); commandList.add(destinationFilePath); Runtime.getRuntime().exec(commandList.toArray(new String[0])); // next process }
踩坑經驗: 我們在拼接過程中遇到了視訊拼接出錯的情況,但數量比較少,通過FFprobe命令分析,發現這種情況出現在其中某個視訊無音軌的情況,找了很多解決方案,最後採用的方式是為這個視訊配一個音軌,相當於先把素材標準化處理,為視訊注入音軌的方式見下文 "無音軌視訊配音"。
音視訊混合
String.format("%s -i %s -i %s -filter_complex amix -map 0:v -map 0:a -map 1:a -shortest -y %s", ffmpegCommandPath, videoFilePath, audioFilePath, targetFilePath)
ffmpegCommandPath 表示FFmpeg執行命令的路徑
videoFilePath 表示視訊檔案路徑
audioFilePath 表示音訊檔案路徑
targetFilePath 表示輸出檔案路徑
無音軌視訊配音
String.format("%s -i %s -f lavfi -i aevalsrc=0 -shortest -y %s", ffmpegCommandPath, videoFilePath, targetFilePath)
ffmpegCommandPath 表示FFmpeg執行命令的路徑
videoFilePath 表示視訊檔案路徑
targetFilePath 表示輸出檔案路徑
踩坑經驗
Runtime.getRuntime().exec() 的問題
上述涉及到Java的部分都是採用的 Runtime.getRuntime().exec(String[] cmdarray) 而不是 Runtime.getRuntime().exec(String command),因為後者一旦遇到雙引號就會帶來問題,當時就想使用字串的方式來執行(這樣的話一旦程式中遇到問題就可以很方便地複製到Shell中復現),但命令中一旦存在雙引號就無法解決,困擾了很久,讀到了 "getruntime() exec() with double quotes in command" 這篇文章後決定就用陣列形式吧。
執行結果 Process.waitFor() 的問題
Runtime.getRuntime().exec() 的執行結果需要通過 waitFor() 方式來獲取子程式退出碼,以此來判斷是否執行成功,但倘若 FFmpeg 沒有關閉除錯資訊,則可能會導致該函式一直卡在這。當時程式中有少部分任務會卡死,我還以為是Serverless平臺的問題,但後面通過 "Java process.waitFor() 卡死問題" 這篇文章找到了解決方案,因此可以通過關閉除錯資訊的方式來規避,即"ffmpeg -loglevel quiet"。