oss-licenses-plugin 사용해보기

최근 개인 프로젝트를 진행하면서, 사용한 오픈소스 라이센스 라이브러리들에 대해 표시할 메뉴를 만들 일이 있었다.

기존에는 하나하나 조사했던 반면, 최근에 Google이 ‘oss-service-plugin’ 라는 플러그인을 만든 것을 알아내서 사용해보기로 했다.

사용

최상단의 build.gradle에 아래 의존성을 추가한다.

dependencies {
   ...
   classpath 'com.google.android.gms:oss-licenses-plugin:0.10.2'
   ...
}

(버전 참조: https://maven.google.com/web/index.html?q=oss-licenses-plugin#com.google.android.gms:oss-licenses-plugin:0.10.2)

그 다음, 앱 모듈의 build.gradle에 아래 의존성을 추가한다.

...
apply plugin: 'com.google.android.gms.oss-licenses-plugin'

...

dependencies {
   implementation 'com.google.android.gms:play-services-oss-licenses:17.0.0'
}

(버전 참조: https://maven.google.com/web/index.html?q=play-services-oss-licenses#com.google.android.gms:play-services-oss-licenses:17.0.0)

마지막으로, Setting 등의 공간에 메뉴를 추가하고 클릭했을 때 아래의 코드를 실행한다.

startActivity(Intent(requireContext(), OssLicensesMenuActivity::class.java))

적용 사진

OssLicensesMenuActivity 를 실행하면 위와 같이 사용한 라이브러리의 목록이 나오고, 각 목록을 클릭하면 해당 라이브러리의 라이센스 문서가 나온다.

위 처럼 의존성을 적어주고 startActivity를 해주는 것 만으로도 쉽게 구현할 수 있었다.

내부 살펴보기

기재한 플러그인인 ‘com.google.android.gms.oss-licenses-plugin’ 은 아래와 같은 작업을 가지고 있다.

getDependencies

코드: https://github.com/google/play-services-plugins/blob/master/oss-licenses-plugin/src/main/groovy/com/google/android/gms/oss/licenses/plugin/DependencyTask.groovy

configuration 에서 의존성의 목록(ResolvedDependency, https://gradle.github.io/gradle-script-kotlin-docs/api/org.gradle.api.artifacts/-resolved-dependency/) 과 그의 child dependency 를 재귀로 통해 가져와, build/generated/dependencies.json 를 구성한다.

...
{
       "group": "com.google.dagger",
       "version": "2.28",
       "fileLocation": "C:\\Users\\winds\\.gradle\\caches\\modules-2\\files-2.1\\com.google.dagger\\dagger\\2.28\\3c1b86e40d4957297d6fde6bdce74b3f48aac49d\\dagger-2.28.jar",
       "name": "dagger"
},
...

generateLicenses

코드: https://github.com/google/play-services-plugins/blob/master/oss-licenses-plugin/src/main/groovy/com/google/android/gms/oss/licenses/plugin/LicensesTask.groovy

getDependencies에서 생성한 dependencies.json를 가지고 한 개씩 순회하면서 아래 작업을 진행한다.

  • 만일, 라이브러리의 그룹이 구글 라이브러리면, (com.google.android.gms 또는 com.google.firebase) addLicensesFromPom() 를 실행하고 그의 transitive licenses(전이된 라이센스) 를 추가한다.
    • 구글 라이브러리 이면, 각자의 라이브러리 파일에 “third_party_licenses.json”, “third_party_licenses.txt” 가 있으므로 Zip 파일에서 해당 파일을 얻어 appendLicense() 를 실행한다.
  • 아니라면, addLicensesFromPom() 를 실행한다.

addLicensesFromPom() 는 아래와 같은 작업을 진행한다.

  1. 아키텍트 파일에서 .pom 파일을 가져온다.
  2. .pom 파일에서 라이센스 정보를 가져와 third_party_licenses 파일에 작성한다.
  3. “${start}:${content.length} ${group}:${name}” 처럼 각 라이브러리에 대해 정보를 third_party_license_metadata 에 추가한다.

즉, third_party_licenses 파일은 표시할 모든 라이브러리 라이센스에 대한 내용을 가지고 있고, third_party_license_metadata 는 특정 라이브러리의 라이센스를 표시할 메타데이터를 가지고 있다.

third_party_license_metadata 에는 아래의 데이터들을 볼 수 있다.

0:46 androidx.databinding:databinding-runtime
48:46 androidx.lifecycle:lifecycle-common
96:46 androidx.annotation:annotation
144:46 androidx.arch.core:core-common
192:46 androidx.databinding:databinding-common
240:46 androidx.collection:collection
288:46 androidx.lifecycle:lifecycle-runtime
336:46 androidx.databinding:viewbinding
384:46 androidx.databinding:databinding-adapters
432:46 com.google.firebase:firebase-installations
480:46 androidx.localbroadcastmanager:localbroadcastmanager
528:47 com.google.android.gms:play-services-measurement-impl

그리고, third_pary_licenses에는 아래의 데이터들을 볼 수 있다.

http://www.apache.org/licenses/LICENSE-2.0.txt
http://www.apache.org/licenses/LICENSE-2.0.txt
http://www.apache.org/licenses/LICENSE-2.0.txt
http://www.apache.org/licenses/LICENSE-2.0.txt
http://www.apache.org/licenses/LICENSE-2.0.txt
http://www.apache.org/licenses/LICENSE-2.0.txt
http://www.apache.org/licenses/LICENSE-2.0.txt
http://www.apache.org/licenses/LICENSE-2.0.txt
http://www.apache.org/licenses/LICENSE-2.0.txt
http://www.apache.org/licenses/LICENSE-2.0.txt
http://www.apache.org/licenses/LICENSE-2.0.txt
https://developer.android.com/studio/terms.html

  Copyright (c) 2005-2011, The Android Open Source Project

  Licensed under the Apache License, Version 2.0 (the "License");
  you may not use this file except in compliance with the License.

  Unless required by applicable law or agreed to in writing, software

이 데이터들을 가지고, OssLicensesMenuActivity 를 실행시키면 내부 코드에서 third_pary_licenses와 third_party_license_metadata 를 가지고 목록을 표시하거나, 내용을 표시하는 것을 알 수 있다.

for(int var6 = 0; var6 < var5; ++var6) {
  String var7;
  int var8 = (var7 = var4[var6]).indexOf(32);
  String[] var9;
  boolean var14 = (var9 = var7.substring(0, var8).split(":")).length == 2 && var8 > 0;
  String var10002 = String.valueOf(var7);
  String var10001;
  if (var10002.length() != 0) {
      var10001 = "Invalid license meta-data line:\n".concat(var10002);
  } else {
      String var10003 = new String;
      var10001 = var10003;
      var10003.<init>("Invalid license meta-data line:\n");
  }

  String var13 = var10001;
  if (!var14) {
      throw new IllegalStateException(String.valueOf(var13));
  }

  long var10 = Long.parseLong(var9[0]);
  int var12 = Integer.parseInt(var9[1]);
  var3.add(zzc.zza(var7.substring(var8 + 1), var10, var12, var1));
}

Android MediaCodec, MediaMuxer 살펴보기

꽤나 최근부터 Android의 API중 MediaCodec나 MediaMuxer, MediaExtractor 를 이용하여 동영상 파일에 대한 후처리 작업을 진행하고 있었다.

처음에는 FFmpeg(https://ffmpeg.org/)의 도입을 강력하게 생각하고 있었는데, 이는 iOS나 타 플랫폼에도 동일하게 적용할 수 있고, 실제로 원하는 기능에 대해 구현에 성공했기 때문이었다.

하지만 FFmpeg 의 LGPL 라이센스 문제나, 일부 플랫폼의 static linking 불가 문제, 일부 플랫폼의 애플리케이션 용량 문제로 도입을 하지 않고 각자의 영상 관련 API를 사용하기로 결정을 내렸고, 이 것이 MediaCodec 나 MediaMuxer 에 대해 알아본 계기였다.

분명히 직전 회사에 재직하고 있을 2017년 11월에도 같은 고민을 한 적이 있던 것 같지만(https://blog.uzuki.live/?p=174), 이번에는 직접 API들을 다뤄보면서 Inter-Frame coding 된 영상을 받아서 모든 프레임을 KeyFrames 로 바꾸는 클래스를 제작해보기로 한다.

키 프레임? Inter Frame?

우리의 작업을 시행하기 전에, Inter-Frame coding (https://en.wikipedia.org/wiki/Data_compression#Inter-frame_coding)에 대해 좀 더 알아볼 필요가 있다.

하나의 영상은 여러 개의 프레임을 가지고 있다. 우리가 흔히 30fps다, 60fps다 부르는 것은, 1초당 프레임이 30/60개가 존재한다는 의미로도 사용된다. 그리고 이 프레임 하나를 ‘인트라 프레임’ 또는 ‘키 프레임’ 이라고 부른다.

특히, 영상의 재생 시간이 길 수록 이 프레임 갯수도 늘어나게 되는데, 문제는 리소스가 많이 든다는 점이다.

이런 점을 해결하기 위해 ‘인터 프레임’ 이라는 기법이 도입되었는데, 인트라 프레임이 각 프레임이 모든 정보를 갖고 있는 반면, 인터 프레임은 인트라 프레임 하나에 여러 인터 프레임으로, 인터 프레임은 인트라 프레임 대비 변동된 부분에 대한 데이터만 갖고 있는다.

Android%20MediaCodec%20MediaMuxer%20c57ffb3225c44f43a8c2f3cf85d75375/Untitled.png

출처: https://www.bhphotovideo.com/explora/video/tips-and-solutions/compression-a-basic-understanding

위 사진이 그런 점을 잘 설명하고 있는데, 인트라 프레임은 사람의 모든 모습을 가지고 있는 반면, 인터프레임은 사람의 팔이 움직이는 부분의 데이터만 갖고 있는다.

그렇기 때문에 비교적 적은 리소스로도 영상을 구성하는 것이 가능했고, 이런 기술들을 통해 영상이 많이 진화될 수 있었다.

하지만, 이러한 ‘인터 프레임’ 은 앞으로 우리가 할 것에 방해를 주는데, 인터프레임은 인트라 프레임 대비 변동된 데이터만 갖고 있는 특성을 가진 탓에, 프레임을 직접 조작하기에는 다소 많은 어려움이 존재한다.

그렇기 때문에, 이러한 인터 프레임을 인트라 프레임으로 변경하는 작업이 필요하고 그것이 우리가 이제부터 할 작업이다.

일단 사용해야 할 것을 정리해보자.

여러 API가 있지만, MediaExtractor, MediaCodec, MediaFormat, MediaMuxer 이 4개가 주인공으로 각 역할을 맡게 된다.

  • MediaFormat: MediaCodec 등이 사용할 포맷등을 정의하는 역할
  • MediaCodec: MediaExtractor 로 가져온 ByteBuffer를 읽거나 작성하는 역할
  • MediaExtractor: 비디오 파일로부터 인코드된 비디오 프레임을 얻는 역할
  • MediaMuxer: 인코드 된 버퍼를 작성하는 역할

이 중 MediaCodec가 가장 큰 역할을 맡게 되는데, 이는 다음부터 설명하기로 한다.

MediaCodec의 동작 방식

안드로이드 공식 문서에서도 나와있는 이 그림은 MediaCodec의 동작 방식을 단적으로 보여준다.

Android%20MediaCodec%20MediaMuxer%20c57ffb3225c44f43a8c2f3cf85d75375/02.png

Step 1. InputBuffer에 대한 해독

// 해독할 InputBuffer의 Index를 반환한다. 주어진 timeoutUs 만큼 버퍼의 가용성을 기다린다.
val inBufferId = decoder.dequeueInputBuffer(mediaCodedTimeoutUs)
if (inBufferId >= 0) {
  // InputBuffer 의 Index를 가지고 실제 버퍼를 가져온다.
  val buffer = decoder.getInputBuffer(inBufferId)
  ...
}

Step 2. 해독한 InputBuffer에 대한 큐잉

// 현재 인코딩된 sample를 가져오고, 주어진 오프셋부터 byte buffer에 저장
val sampleSize = extractor.readSampleData(buffer, 0)
if (sampleSize >= 0) {
  // queue 할 수 있는 InputBuffer가 존재함. 따라서 sampleSize, 현재 버퍼의 presentation timestamp, 샘플의 플래그를 이용하여 InputBuffer를 Decoder에 큐잉하게 됨.
  decoder.queueInputBuffer(inBufferId, 0, sampleSize, extractor.sampleTime, extractor.sampleFlags)
  extractor.advance()
} else {
  // 모든 InputBuffer를 읽었으므로, BUFFER_FLAG_END_OF_STREAM 플래그를 통해 모든 버퍼를 읽었다는 것을 알린다.
  decoder.queueInputBuffer(inBufferId, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM)
  allInputExtracted = true
}

Step 3. OutputBuffer에 대한 해독과 Muxer에 작성

// 해독할 OutputBuffer의 Index를 반환한다.
val encodeOutBufferId = encoder.dequeueOutputBuffer(bufferInfo, mediaCodedTimeoutUs)
if (encodeOutBufferId >= 0) {
  // OutputBuffer의 Index를 가지고 실제 버퍼를 가져온다.
  val encodedBuffer = encoder.getOutputBuffer(encodeOutBufferId)
  // 특정 트랙에 가져온 OutputBuffer를 작성한다.
  muxer.writeSampleData(trackIndex, encodedBuffer, bufferInfo)
}

Step 4. OutputBuffer의 릴리즈

// 작성한 OutputBuffer는 바로 릴리즈하여 다음에도 해독할 수 있도록 준비
encoder.releaseOutputBuffer(encodeOutBufferId, false)

일반적으로 코덱은 입력 데이터를 처리해서 출력 데이터를 생성하는 역할을 하는데, 여기에서는 빈 입력 버퍼를 요청하여 버퍼를 채우고, 데이터를 모두 사용하여 빈 출력 버퍼를 릴리즈하는 과정을 가지고 있다.

위 사항을 기반으로 하나씩 준비해보자.

필요한 요소 준비하기

MediaFormat는 영상 파일에 대한 정보 (가로, 세로, 비트레이트 등) 을 담고 있는 클래스로, 여기에서는 Input 파일의 정보를 가져와 Output 파일에 대한 정보를 기록한다.

extractor = MediaExtractor()
extractor.setDataSource(input)
val inFormat = selectVideoTrack(extractor, "video/")

fun selectVideoTrack(extractor: MediaExtractor, prefix: String = "video/"): MediaFormat {
   for (i in 0 until extractor.trackCount) {
       val format = extractor.getTrackFormat(i)
       if (format.getString(MediaFormat.KEY_MIME).startsWith(prefix)) {
           extractor.selectTrack(i)
           return format
      }
  }
   throw InvalidParameterException("File contains no video track")
}

기본적으로 MP4 파일은 여러 개의 트랙을 가지고 있을 수 있는데, 일반적으로 지원하는 형태는 1 영상 트랙, 1 음악 트랙인 경우가 많다.

여기서 우리는 ‘비디오 트랙’ 에 대한 정보가 필요하므로, MediaExtractor의 트랙 저옵를 꺼내서 video/ 로 시작하는 첫 번째 트랙을 선택한다.

이렇게 가져온 MediaFormat를 기반으로 Output 파일에 대한 MediaFormat를 생성하는데, 이는 다음과 같다.

private fun getOutputFormat(inputFormat: MediaFormat, frameInterval: Int = 0): MediaFormat {
  // inputFomrat 에 대한 width, height를 가지고 Size를 생성한다.
  val inputSize = Size(inputFormat.getInteger(MediaFormat.KEY_WIDTH), inputFormat.getInteger(MediaFormat.KEY_HEIGHT))
  // inputSize를 가지고 기기에서 해당 사이즈를 지원하는지 체크한다. mime 로 video/avc를 넘기는데, 이는 H.264 컨테이너에 대한 mime라고 볼 수 있다.
  val outputSize = getSupportedVideoSize(encoder, "video/avc", inputSize)
 
  return MediaFormat.createVideoFormat("video/avc", outputSize.width, outputSize.height).apply {
           // Surface 객체로 통해 인코딩이 가능하도록 한다.
           setInteger(MediaFormat.KEY_COLOR_FORMAT, MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface)
           setInteger(MediaFormat.KEY_BIT_RATE, 20000000)
           setInteger(MediaFormat.KEY_FRAME_RATE, inputFormat.getInteger(MediaFormat.KEY_FRAME_RATE))
           // 전체 프레임을 키 프레임으로 변환하기 위하여, I_FRAME_INTERVAL를 0으로 준다.
           // (일부 기기에서는 -1를 사용하는 경우도 있어, try-catch 로 CodecException이 발생했을 때 frameInterval를 -1로 주고 다시 configure를 시도한다.
           setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, frameInterval)
           setString(MediaFormat.KEY_MIME, "video/avc")
  }
}

@RequiresApi(21)
fun getSupportedVideoSize(mediaCodec: MediaCodec, mime: String, preferredResolution: Size): Size {
   // 주어진 Size가 기기에서 지원하는지 먼저 체크한다.
   if (isSizeSupported(mediaCodec, mime, preferredResolution)) {
       return preferredResolution
  }
   
   // 기기에서 지원하지 않는 사이즈면, 비율에 맞는 최대한 가까운 크기를 선택한다.
   val resolutions = mutableListOf(
       Size(176, 144),
       Size(320, 240),
       Size(320, 180),
       Size(640, 360),
       Size(720, 480),
       Size(1280, 720),
       Size(1920, 1080)
  )

   val pix = preferredResolution.width * preferredResolution.height
   val preferredAspect = preferredResolution.width.toFloat() / preferredResolution.height.toFloat()

   val nearestToFurthest = resolutions.sortedWith(
       compareBy(
          { pix - it.width * it.height },
          {
               val aspect = if (it.width < it.height) it.width.toFloat() / it.height.toFloat() else it.height.toFloat() / it.width.toFloat()
              (preferredAspect - aspect).absoluteValue
          })
  )

   val result = nearestToFurthest.firstOrNull { isSizeSupported(mediaCodec, mime, it) }

   if (result != null) {
       return result
  }

   throw RuntimeException("Couldn't find supported resolution")
}

@RequiresApi(21)
private fun isSizeSupported(mediaCodec: MediaCodec, mime: String, size: Size): Boolean {
   return mediaCodec.codecInfo.getCapabilitiesForType(mime).videoCapabilities.isSizeSupported(size.width, size.height)
}

Output 파일에 대한 MediaFormat를 생성했다면, 이를 MediaCodec 객체에 configure 라는 메서드를 통하여 설정한다.

var outFormat: MediaFormat
try {
   // inFormat 값을 기반으로 Output 파일에 대한 Format를 설정
   outFormat = getOutputFormat(inFormat).also {
       width = it.getInteger(MediaFormat.KEY_WIDTH)
       height = it.getInteger(MediaFormat.KEY_HEIGHT)
  }

   // encoder 객체에 outFormat를 설정.
   // surface와 crypto의 역할은 다음과 같다.
   // surface: 해당 Decoder에 대해 결과물을 출력할 android.view.Surface 객체. 만일 해당 MediaCodec가 raw video output를 출력하지 않거나 (Decoder가 아니거나) ByteBuffer로 아웃풋을 내고 싶을 경우에는 null 로 선언한다.
   // crypto: DRM 등 MediaCrypto가 걸린 영상을 Decode/Encode하려고 할 때 선언하는 adnroid.media.MediaCrypto 객체.
   // 여기서는 Encoder 객체이므로 surface, crypto 둘 다 null를 주고, flags로는 CONFIGURE_FLAG_ENCODE 로 해당 MediaCodec 객체가 Encoder로서 활용할 것임을 선언한다.
   encoder.configure(outFormat, surface = null, crypto = null, MediaCodec.CONFIGURE_FLAG_ENCODE)
} catch (e: MediaCodec.CodecException) {
   // MediaCodec.configure 가 실패할 수 있는 경우
   // IllegalArgumentException -> surface 가 이미 릴리즈 되었거나, Format가 기기에서 지원하지 않는 경우. 또는 플래그가 잘못 설정된 경우
   // IllegalStateException -> 초기화되지 않은 상태가 아닐 경우
   // CryptoException -> DRM 관련 에러
   // CodecException -> Codec 관련 에러

   // 일부 기기의 경우, i-frame-interval 가 0 이 아닌 -1 이 '키 프레임' 을 나타내는 경우가 있다.
   // 따라서, -1로 OutputFormat 를 생성하고 다시 configure를 시도한다.
   outFormat = getOutputFormat(inFormat, -1).also {
       width = it.getInteger(MediaFormat.KEY_WIDTH)
       height = it.getInteger(MediaFormat.KEY_HEIGHT)
  }

   encoder.configure(outFormat, surface = null, crypto = null, MediaCodec.CONFIGURE_FLAG_ENCODE)
}

surface = encoder.createInputSurface()

// Decoder 객체의 경우, 결과물을 출력할 Surface를 삽입한다.
decoder = MediaCodec.createDecoderByType("video/avc").apply {
    configure(inFormat, surface, null, 0)
}

마지막으로 Muxer 객체를 선언하고, encoderdecoder 객체를 생성하고 준비 작업을 마친다.

muxer = MediaMuxer(outPath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4)
encoder.start()
decoder.start()

디코딩, 인코딩, Muxing

위 과정을 통해 MediaCodec에 대한 초기화 작업을 마쳤다면, 이제 영상의 Buffer를 꺼내면서 디코딩 – 인코딩 – Muxing를 거칠 차례이고, 아래 순서로 진행하여 이를 영상의 끝 까지 반복시킨다.

  1. decoder.dequeInputBuffer ~ decoder.getInputBuffer ~ readSampleData ~ queueInputBuffer 로 Decoder에 input data 주입
  2. decoder.dequeueOutputBuffer ~ decoder.releaseOutputBuffer로 decoder에서 Output data를 꺼내 encoder에 주입
  3. encoder.dequeueOutputBuffer ~ encoder.getOutputBuffer로 Encoder에서 output data를 꺼냄
  4. muxer.writeSampleData 로 muxer에 버퍼 기록

이를 반영한 것이, 아래 코드가 된다.

private fun convert() {
    allInputExtracted = false
    allInputDecoded = false
    allOutputEncoded = false

    while (!allOutputEncoded) {
        // Decoder에 input data를 삽입
        if (!allInputExtracted) {
            val inBufferId = decoder.dequeueInputBuffer(mediaCodedTimeoutUs)
            if (inBufferId >= 0) {
                // input buffer의 인덱스에서 `cleared` 하고 쓰기 가능한 ByteBuffer 객체를 반환함.
                val buffer = decoder.getInputBuffer(inBufferId)
                // 반환된 ByteBuffer를 가지고 extractor에서 읽어온다. buffer에는 읽어온 ByteBuffer가 덮어지고, 반환 값에는 샘플 사이즈가 기록된다. 
                val sampleSize = extractor.readSampleData(buffer, 0)
       
                if (sampleSize >= 0) {
                    decoder.queueInputBuffer(inBufferId, 0, sampleSize, extractor.sampleTime, extractor.sampleFlags)
                    extractor.advance()
                } else {
                    decoder.queueInputBuffer(inBufferId, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM)
                    allInputExtracted = true
                }
            }
        }

        var encoderOutputAvailable = true
        var decoderOutputAvailable = !allInputDecoded

        while (encoderOutputAvailable || decoderOutputAvailable) {
            // Encoder에서 데이터를 꺼내고, 이를 muxer에 주입함.
            val encodeOutBufferId = encoder.dequeueOutputBuffer(bufferInfo, mediaCodedTimeoutUs)
            if (encodeOutBufferId >= 0) {
                // output buffer의 인덱스에서 `cleared` 하고 쓰기 가능한 ByteBuffer 객체를 반환함.
                val encodedBuffer = encoder.getOutputBuffer(encodeOutBufferId)
                // 인코딩된 샘플을 muxer에 작성한다.
                muxer.writeSampleData(trackIndex, encodedBuffer, bufferInfo)
                encoder.releaseOutputBuffer(encodeOutBufferId, false)

                // MPEG4 포맷의 경우, BUFFER_FLAG_END_OF_STREAM 플래그와 함께 빈 버퍼(bufferInfo.size = 0) 을 전달하여 트랙 내 마지막 샘플을 설정할 수 있다.
                // 즉, 이 과정이 모두 끝나면 작업이 모두 완료되었다고 가정함.
                if ((bufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0) {
                    allOutputEncoded = true
                    break
                }
            } else if (encodeOutBufferId == MediaCodec.INFO_TRY_AGAIN_LATER) {
                encoderOutputAvailable = false
            } else if (encodeOutBufferId == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) {
                trackIndex = muxer.addTrack(encoder.outputFormat)
                muxer.start()
            }

            if (encodeOutBufferId != MediaCodec.INFO_TRY_AGAIN_LATER) {
                continue
            }

            // Decoder에서 output 를 읽고, 이를 encoder에 주입한다. 
            // (이 상태를 거치지 않으면, 위의 encodeOutBufferId 는 -1를 반환한다.)
            if (!allInputDecoded) {
                val decoderOutBufferId = decoder.dequeueOutputBuffer(bufferInfo, mediaCodedTimeoutUs)
                if (decoderOutBufferId >= 0) {
                    val render = bufferInfo.size > 0
                    decoder.releaseOutputBuffer(decoderOutBufferId, render)
                    // 이 단계에서 Decoder에서 꺼내온 Decoded frame가 SurfaceTexture에 주입되고, onFrameAvailable() 가 불린다.
                    // 그렇기에, render 가 true이고, onFrameAvailable 가 호출되었다면 Surface에 작업을 가할 수 있다. (OpenGL 등)

                    // Decoder에서 모든 정보를 다 꺼냈다면, encoder에 모든 정보가 다 입력됨을 알려준다.
                    if ((bufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0) {
                        allInputDecoded = true
                        encoder.signalEndOfInputStream()
                    }
                } else if (decoderOutBufferId == MediaCodec.INFO_TRY_AGAIN_LATER) {
                    decoderOutputAvailable = false
                }
            }
        }
    }
}

마지막으로 모든 작업을 마친 후에는 리소스 해제 작업을 진행한다.

private fun release() {
    extractor.release()

    decoder.stop()
    decoder.release()

    encoder.stop()
    encoder.release()

    muxer.stop()
    muxer.release()

    surface.release()

    width = -1
    height = -1
    trackIndex = -1
}

레퍼런스

JNI에서 RegisterNatives 사용하기

아득히 먼 예전(약 5년 이상)에는 JNI를 사용하기 위해 javah 를 사용하여 헤더 파일을 만들고, 헤더 파일에 선언된 메서드를 사용하는 일이 있었다.

JNIEXPORT void JNICALL Java_com_github_windsekirun_**_bridge_JniBridge_nativeOnStart(JNIEnv *env, jclass type) {
      LAppDelegate::GetInstance()->OnStart();
}

가령 JniBridge 라는 클래스에 nativeOnStart 라는 메서드가 있다면, 위에 선언된 메서드를 실행하는 방식이다.

하나 두개 쯤은 문제가 없지만, 메서드가 많이 있을때는 아무래도 깔끔하지 못하는 단점이 있었다.

이번에 Live2D를 사용한 토이 프로젝트를 진행하면서 다시 JNI를 사용하게 되었는데, 몇주 전 동적으로 메서드를 등록할 수 있다는 방법을 알게 된 터라 한번 사용해보았다.

RegisterNatives

JNI_OnLoad() (네이티브 라이브러리가 로드될 때 초기화 작업하는 메서드) 에서 사용하며, 아래의 파라미터를 받는다.

jclass clazz

위의 JNIBridge처럼 native 메서드가 선언된 클래스이다.

com/github/windsekirun/**/bridge/JniBridge 처럼 선언된다.

JNINativeMethod*

jni.h에 선언된 구조체로, 아래와 같은 형식이다.

typedef struct {
  const char* name;
  const char* signature;
  void*       fnPtr;
} JNINativeMethod;

name는 native 메서드가 선언된 클래스에서의 메서드 이름, signature는 해당 메서드의 JNI 시그니쳐, fnPtr는 jni에서의 메서드로 보면 된다.

가령 아래와 같은 메서드가 있다고 가정한다.

// java
public static native void nativeOnStart();

// c++
static void nativeOnStart(JNIEnv *env, jclass type) {
  LAppDelegate::GetInstance()->OnStart();
}

이 때, JNINativeMethod는 {"nativeOnStart", "()V", (void *) nativeOnStart} 가 된다.

따라서, 두 번째 파라미터에는 이러한 JNINativeMethod의 배열을 삽입하면 된다.

numMethods

두번째의 배열에 대하여 전체 갯수를 적는다.

실제 사용하기

JNIBridge 라는 클래스에 아래 메서드들이 있다.

public static native void nativeOnStart();
public static native void nativeOnPause();
public static native void nativeOnStop();
public static native void nativeOnDestroy();
public static native void nativeOnSurfaceCreated();
public static native void nativeOnSurfaceChanged(int width, int height);
public static native void nativeOnDrawFrame();
public static native void nativeOnTouchesBegan(float pointX, float pointY);
public static native void nativeOnTouchesEnded(float pointX, float pointY);
public static native void nativeOnTouchesMoved(float pointX, float pointY);
public static native void nativeLoadModel(String modelName);

그리고, 이를 등록할 JNINativeMethod* 를 선언한다.

static const char *classPathName = "com/github/windsekirun/**/bridge/JniBridge";

static JNINativeMethod methods[] = {
      {"nativeOnStart",         "()V",                 (void *) nativeOnStart},
      {"nativeOnPause",         "()V",                 (void *) nativeOnPause},
      {"nativeOnStop",           "()V",                 (void *) nativeOnStop},
      {"nativeOnDestroy",       "()V",                 (void *) nativeOnDestroy},
      {"nativeOnSurfaceCreated", "()V",                 (void *) nativeOnSurfaceCreated},
      {"nativeOnSurfaceChanged", "(II)V",               (void *) nativeOnSurfaceChanged},
      {"nativeOnDrawFrame",     "()V",                 (void *) nativeOnDrawFrame},
      {"nativeOnTouchesBegan",   "(FF)V",               (void *) nativeOnTouchesBegan},
      {"nativeOnTouchesEnded",   "(FF)V",               (void *) nativeOnTouchesEnded},
      {"nativeOnTouchesMoved",   "(FF)V",               (void *) nativeOnTouchesMoved},
      {"nativeLoadModel",       "(Ljava/lang/String)V", (void *) nativeLoadModel},
};

마지막으로 RegisterNative 메서드를 실행하는 메서드를 작성한다.

static int registerNativeMethods(JNIEnv *env, const char *className, JNINativeMethod *gMethods,
                                int numMethods) {
  jclass clazz;
  clazz = env->FindClass(className);
  if (clazz == nullptr) {
      return JNI_FALSE;
  }

  if (env->RegisterNatives(clazz, gMethods, numMethods) < 0) {
      return JNI_FALSE;
  }

  return JNI_TRUE;
}

마지막으로 JNI_OnLoad에서 메서드를 실행한다.

registerNativeMethods(env, classPathName, methods, sizeof(methods) / sizeof(methods[0]));