Поиск по этому блогу

воскресенье, 4 августа 2019 г.

Программная нарезка видео с помощью mp4parser

Продолжаем работать с видосиками. У меня недавно был случай что потребовалось нарезать один видео файл на много маленьких файлов вырезая ненужные части. И я такое запедалил с помощью замечательной библиотеки от Google которая называется mp4parser

image

Эта библиотека позволяет помимо нарезки так же:
— соединять файлы у которых одинаковые настройки енкодинга;
— накладывать аудио дорожку на видео;
— изменение или добавление меты;

Библиотека очень мощная, вот прям не могу даже ничего плохого про нее сказать. Очень быстро справляется со всеми задачами, прямо вот в долю секунды. Однажды пробовал нарезать огромный видео файл на несколько частей, так либа справлялась за 2 — 3 секунды, даже боюсь представить как они это сделали, там реально какая-то магия.

app/build.gradle
dependencies {
    implementation 'androidx.appcompat:appcompat:1.0.2'

    implementation "com.googlecode.mp4parser:isoparser:1.1.22"
}
По традиции изначально подключаем библиотеки, у нас их будет две, одни это androidx, а вторая mp4parser.

RangesModel.kt
class RangesModel(var name: String?, var startTime: Double, var endTime: Double)
В этой модели у нас будет в конструкторе передаваться название файла который будем создавать после нарезки, так же начальное время видоса, с какого момента резать и до какого момента резать. То есть если видео допустим длится 10 минут, а мы хотим вырезать видео с 3 секунды и до 8 секунды, будем передавать в startTime = 3, а в endTime = 8 и на выходе получим видео в 5 секунд в котором будет кусок видоса.

TrimmerEndWorkListener.kt
import dajver.com.videotrimmerexample.timmer.model.RangesModel
import java.io.File

interface TrimmerEndWorkListener {
    fun onTrimEnds(file: File, newRange: RangesModel)

    fun onTrimError(error: Exception)

    fun onEmptyFileError(error: Exception)
}
Дальше создадим один лисенер который будет нам возвращать статусы по поводу нарезки видео из самого класса описывающего работу по нарезке. Будет возвращат нам ошибки и по окончанию нарезки будет возвращать нам видос и рендж с которым было нарезанно видео.

Далее опишем сам класс по нарезке видео. Этот код в принципе довольно распостраненный, его можно найти в куче проектов на github'e, я его немного модифицировал для своих нужд, по этому он чуть-чуть выглядеть будет иначе. Но основной функционал стандартный для этой библиотеки.

VideoTrimmer.kt
import android.content.Context
import android.os.Environment
import com.coremedia.iso.IsoFile
import com.googlecode.mp4parser.FileDataSourceImpl
import com.googlecode.mp4parser.authoring.Track
import com.googlecode.mp4parser.authoring.builder.DefaultMp4Builder
import com.googlecode.mp4parser.authoring.container.mp4.MovieCreator
import com.googlecode.mp4parser.authoring.tracks.AppendTrack
import com.googlecode.mp4parser.authoring.tracks.CroppedTrack
import dajver.com.videotrimmerexample.timmer.interfaces.TrimmerEndWorkListener
import dajver.com.videotrimmerexample.timmer.model.RangesModel
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
import java.util.*

class VideoTrimmer(private val context: Context, private val srcPath: File, private val callback: TrimmerEndWorkListener) {

    private var videoTimeScale: Long = 0
    private var totalLength: Double = 0.0

    init {
        try {
            val isoFile = IsoFile(srcPath.absolutePath)
            if (isoFile.movieBox == null) {
                callback.onEmptyFileError(Exception("Bad video error"))
            } else {
                videoTimeScale = isoFile.movieBox.movieHeaderBox.timescale
                totalLength = isoFile.movieBox.movieHeaderBox.duration.toDouble() / videoTimeScale
            }
        } catch (e: IOException) {
            e.printStackTrace()
            callback.onTrimError(e)
        }
    }

    fun startTrim(range: RangesModel) {
        try {
            genVideoUsingMp4Parser(range)
        } catch (ex: Exception) {
            callback.onTrimError(ex)
        }
    }

    private fun genVideoUsingMp4Parser(range: RangesModel) {
        try {
            val createdFile: File?
            var actualRange: RangesModel? = null

            val movie = MovieCreator.build(FileDataSourceImpl(srcPath.absolutePath))

            val tracks = movie.tracks
            movie.tracks = LinkedList()

            var startTime1 = range.startTime
            var endTime1 = range.endTime

            var timeCorrected = false
            for (track in tracks) {
                if (track.syncSamples != null && track.syncSamples.isNotEmpty()) {
                    if (timeCorrected) {
                        callback.onTrimError(RuntimeException("The startTime has already been corrected by another track with SyncSample. Not Supported."))
                    }

                    val correctedStartTime = correctTimeToSyncSample(track, startTime1, false)
                    if (correctedStartTime < startTime1 || (startTime1 == 0.0 && range.startTime != 0.0 && correctedStartTime != 0.0)) {
                        startTime1 = correctedStartTime
                    }

                    val correctedEndTime = correctTimeToSyncSample(track, endTime1, true)
                    if (correctedEndTime > endTime1 || (endTime1 == 0.0 && range.endTime != 0.0 && correctedEndTime != 0.0)) {
                        endTime1 = correctedEndTime
                    }

                    timeCorrected = true
                }
            }

            var finalCutPointStartTime = 0.0
            var finalCutPointEndTime = 0.0
            var realCutPointsCalculated = false

            for (track in tracks) {
                var currentSample: Long = 0
                var currentTime = 0.0
                var lastTime = -1.0
                var startSample1: Long = -1
                var endSample1: Long = -1

                track.sampleDurations.forEach {
                    if (currentTime > lastTime && currentTime <= startTime1) {
                        startSample1 = currentSample
                        finalCutPointStartTime = currentTime
                    }
                    if (currentTime > lastTime && currentTime <= endTime1) {
                        endSample1 = currentSample
                        finalCutPointEndTime = currentTime + (it.toDouble() / track.trackMetaData.timescale.toDouble())
                    }
                    lastTime = currentTime
                    currentTime += it.toDouble() / track.trackMetaData.timescale.toDouble()
                    currentSample++
                }

                if (!realCutPointsCalculated) {
                    if (finalCutPointStartTime > 0) {
                        finalCutPointStartTime *= 1000
                    }

                    val newRange = RangesModel(range.name, finalCutPointStartTime, finalCutPointEndTime * 1000)
                    actualRange = newRange
                    realCutPointsCalculated = true
                }

                movie.addTrack(AppendTrack(CroppedTrack(track, startSample1, endSample1)))
            }

            val created = File(context.getExternalFilesDir(Environment.DIRECTORY_DCIM), range.name + ".mp4")
            if (!created.exists())
                created.createNewFile()

             createdFile = created

            val out = DefaultMp4Builder().build(movie)

            val fos = FileOutputStream(created)
            val fc = fos.channel
            out.writeContainer(fc)

            fc.close()
            fos.close()

            callback.onTrimEnds(createdFile, actualRange!!)
        } catch (e: OutOfMemoryError) {
            callback.onTrimError(java.lang.Exception(e.localizedMessage))
        } catch (e1: Throwable) {
            callback.onTrimError(java.lang.Exception(e1.localizedMessage))
        }
    }

    private fun correctTimeToSyncSample(track: Track, cutHere: Double, next: Boolean): Double {
        val timeOfSyncSamples = DoubleArray(track.syncSamples.size)
        var currentSample: Long = 0
        var currentTime = 0.0
        track.sampleDurations.forEach {
            if (Arrays.binarySearch(track.syncSamples, currentSample + 1) >= 0) {
                timeOfSyncSamples[Arrays.binarySearch(track.syncSamples, currentSample + 1)] = currentTime
            }
            currentTime += it.toDouble() / track.trackMetaData.timescale.toDouble()
            currentSample++
        }

        var previous = 0.0
        for (timeOfSyncSample in timeOfSyncSamples) {
            if (timeOfSyncSample > cutHere) {
                return if (next) {
                    timeOfSyncSample
                } else {
                    previous
                }
            }
            previous = timeOfSyncSample
        }
        return timeOfSyncSamples[timeOfSyncSamples.size - 1]
    }
}
В кратце если сказать что тут происходит — то тут в конструктор мы передаем контекст для работы с путями в файловой системе, передаем файл который мы будем нарезать, и коллбек для получения результатов по окончанию работы библиотеки. Дальше мы в конструкторе получаем длинну видео, создаем трек с помощью которого дальше нарезаем в цикле видео по ренжам которые мы задали. Так же мы проверяем что у нас endTime не больше чем startTime, корректируем ренжи если это требуется и потом уже нарезаем видео.

Если все хорошо, и видео нормально записалось без каких либо проблем, мы получаем коллбек в активити что все ок, если нет то получаем эксепшены в которых понятно в чем проблема. Если более детально описать то

Если более детально разложить по методам, то это выглядит немного сложнее. В конструкторе init — мы проверяем длинну видео, что бы она была не 0, если все нормально то записываем длинну видео и дальше переходим в startTrim — который запускает если видео файл корректный — метод по нарезке genVideoUsingMp4Parser, иначе если файл битый или у него какие-то проблемы с метой, try выкинет эксепшн в catch и нам прилетит колбек с ексепшином о том что случилось во время старта нарезки видео.
— genVideoUsingMp4Parser — самый главный метод в данном классе. В нем мы создаем инстанс MovieCreator в который передаем путь к файлу который мы хотим нарезать, создаем треки исходя из этого файла. Далее корректируем тайминг у видео, если это требуется, бывает такое что тайминг уже откорректирован у видео, на пример если оно было уже пропущено через эту библиотеку. Далее исходя из треков которые мы создали ранее на основе видео в цикле нарезаем видео. По окончанию всех действий над видео, мы записываем в наш инстанс MovieCreator начало видео с какого момента мы будем писать видео в файл, и конец видео на котором у нас будет видео заканчиваться. Дальше мы записываем ренжи и видео в отдельный файл и если все прошло успешно то мы отправляем коллбек в активити, если же на каком-то из этих этапов у нас произошла ошибка, то кидаем туда ексепшн.
— correctTimeToSyncSample — здесь мы пытаемся найти трек с синхронизированными сэмплами. Тут мы должны убедиться, что начало нового фрагмента точно тот кадр который был указан в ренжах, что бы не обрезать ничего лишнего. Это стандартный метод который описан в документации к библиотеке mp4parser.

activity_main.xml
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
        xmlns:android="http://schemas.android.com/apk/res/android"
        android:layout_width="match_parent"
        android:layout_height="match_parent" 
        android:orientation="vertical" 
        android:gravity="center">
    <LinearLayout
            android:orientation="horizontal"
            android:layout_width="match_parent"
            android:layout_height="wrap_content">
        <LinearLayout
                android:orientation="vertical"
                android:layout_width="match_parent"
                android:layout_height="match_parent" 
                android:layout_weight="0.1" 
                android:layout_margin="20dp"
                android:gravity="center">
            <TextView
                    android:text="Start trim time"
                    android:layout_width="wrap_content"
                    android:layout_height="wrap_content" 
                    android:layout_weight="0.1"/>
            <EditText
                    android:layout_width="wrap_content"
                    android:layout_height="wrap_content"
                    android:inputType="textPersonName"
                    android:ems="10"
                    android:id="@+id/startTrim"
                    android:layout_weight="0.1" 
                    android:gravity="center|center_horizontal"/>
        </LinearLayout>
        <LinearLayout
                android:orientation="vertical"
                android:layout_width="match_parent"
                android:layout_height="match_parent" 
                android:layout_weight="0.1" 
                android:layout_margin="20dp"
                android:gravity="center">
            <TextView
                    android:text="End trim time"
                    android:layout_width="wrap_content"
                    android:layout_height="wrap_content" 
                    android:layout_weight="0.1"/>
            <EditText
                    android:layout_width="wrap_content"
                    android:layout_height="wrap_content"
                    android:inputType="textPersonName"
                    android:ems="10"
                    android:id="@+id/endTrim"
                    android:layout_weight="0.1"
                    android:gravity="center|center_horizontal"/>
        </LinearLayout>
    </LinearLayout>
    <Button
            android:text="Start Trim"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content" 
            android:id="@+id/trimButton"/>
</LinearLayout>
Наше приложение будет иметь два текстовых поля и два поля для ввода времени начала набрезки и конца нарезки видео. Так же у нас будет кнопка по нажатию на которую у нас будет вызываться метод startTrim и будет происходится нарезка видео.

MainActivity.kt
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.os.Environment
import androidx.appcompat.app.AppCompatActivity
import com.coremedia.iso.IsoFile
import dajver.com.videotrimmerexample.timmer.VideoTrimmer
import dajver.com.videotrimmerexample.timmer.interfaces.TrimmerEndWorkListener
import dajver.com.videotrimmerexample.timmer.model.RangesModel
import kotlinx.android.synthetic.main.activity_main.*
import java.io.File

class MainActivity : AppCompatActivity(), TrimmerEndWorkListener {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        val fileToTrim = File(getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), "sample.mp4")
        val fileDuration = getFileDuration(fileToTrim)

        startTrim.setText(0.toString())
        endTrim.setText(fileDuration.toString())
        trimButton.setOnClickListener {
            val startTime = startTrim.text.toString().toDouble()
            val endTime = endTrim.text.toString().toDouble()
            val rangeModel = RangesModel("trimmed_file", startTime, endTime)
            val trimmer = VideoTrimmer(MainActivity@this, fileToTrim, MainActivity@this)
            trimmer.startTrim(rangeModel)
        }
    }

    private fun getFileDuration(file: File) : Double {
        val videoTimeScale: Long?
        var totalLength: Double? = null

        val isoFile = IsoFile(file.absolutePath)
        if (isoFile.movieBox != null) {
            videoTimeScale = isoFile.movieBox.movieHeaderBox.timescale
            totalLength = isoFile.movieBox.movieHeaderBox.duration.toDouble() / videoTimeScale
        }
        return totalLength!!
    }

    override fun onTrimEnds(file: File, newRange: RangesModel) {
        val intent = Intent(Intent.ACTION_VIEW, Uri.parse(file!!.path))
        intent.setDataAndType(Uri.parse(file.path), "video/mp4")
        startActivity(intent)
    }

    override fun onTrimError(error: Exception) {
        error.printStackTrace()
    }

    override fun onEmptyFileError(error: Exception) {
        error.printStackTrace()
    }
}
В onCreate мы задаем файл который мы будем обрезать, я решил что бы не добавлять лишнего ничего просто загружу файл на девайс и с памяти девайса буду брать его. Файл для примера можно скачать отсюда. Это видео должно быть загружено в папку /storage/emulated/0/Android/data/dajver.com.videotrimmerexample/files/Download/. Если есть большое желание то можно переделать в выбор из галереи например. Так вот, получаем видео, дальше получаем длинну видео с помощью метода getFileDuration() и отображаем ее в поле для ввода endTime. По клику на trimButton мы запускаем нашего триммера, подписываемся на коллбек, передаем файл который нарезаем и создаем методы лисенеров которые попросит создать студия.

И после того как у нас вернется успешный результат о том что видео нарезано и все хорошо — мы открываем видео в видео плеере с помощью интента.

Вот такая штуковина эта нарезка видео с помощью mp4parser. Надеюсь кому-то это пригодится.

Исходники:
GitHub

Комментариев нет:

Отправить комментарий