[안드로이드] ffmpeg 종료로 인한 media3 transformer 사용
중단? 띠용?? 갑자기요???!!??
아니 이미 다 만들었는데?????;
https://github.com/arthenica/ffmpeg-kit
GitHub - arthenica/ffmpeg-kit: FFmpeg Kit for applications. Supports Android, Flutter, iOS, Linux, macOS, React Native and tvOS.
FFmpeg Kit for applications. Supports Android, Flutter, iOS, Linux, macOS, React Native and tvOS. Supersedes MobileFFmpeg, flutter_ffmpeg and react-native-ffmpeg. - arthenica/ffmpeg-kit
github.com
ffmpeg의 command 를 사용해서 영상을 비교적 자유롭게 편집하던 방식이 이제는 완전 사라졌습니다. (깃 사이트도 사라짐)
ffmpegKit 는 ffmpeg 의 필수적인 부분만 뽑아낸 라이브러리인데, 이것도 사실은 2025 4월 1일 까지 였으나, 아직 사용가능합니다
(다만 언제 갑자기 종료될지는 모름)
알아야할 점:
어떤 ai 를 붙잡고 늘어져도 잘 안알려주니까 개발하기 무척 힘듬.
검색하면 5년전 글만 있어서 그것도 힘듬.
열심히 돌리면서 삽질해야 개발가능 (media3 는 더 심하고 ffmpeg 는 그나마 나은데 안드로이드 관련된 건 별로 없음)
Media 3 의 장단점
장점:
안드로이드 공식 jetpack에 포함됨 -> 기기 호환 굿
이제 어차피 영상편집 라이브러리에 대체재가 없음 -> 선택권이 없음
기존 ffmpeg 에 비해 command 방식이 아닌 함수 호출형 -> ffmpeg 보다 러닝커브가 낮고 쓰기 편함
단점:
ffmpeg 에서 되는건 media3 에서 안될 수 있음 (ex: 정확한 사이즈로 크롭, 코덱선택, 디테일한 오디오 및 비디오 설정)
-> ffmpeg 보다 자유도가 떨어짐 특히 사이즈 크롭
Transformer 생성에 Main 쓰레드만 사용가능
-> 객체 생성은 Main, 전체적인 동작은 default 에서 사용해야 함으로 코드 복잡함
Cancel 에 대한 콜백이 없음
-> 그냥 빡침 취소가 잘 됐는지 확인이 안됌
특정 기기에서 안될수도?
-> 특히 샤오미 홍미노트에서 안된다는 얘기가 있음 (아는 사람 있으면 답글 부탁드립니다.)
동작 예시 (ffmpeg / media3 동일)
구현 특징:
영상을 여러개 올려야하고 각각의 편집이 중간에 취소될 수도 있다는 점 -> 밑에서 설명할 예정
스크롤이 있기에 리컴포지션에 대응이 가능할 것 (영상에서 스크롤 좌우로 흔들어재끼는 이유) -> IO 에서 처리하면 됨.
기존 ffmpeg (세마포어 같은 공통적인 부분은 맨 아래에서 설명)
package com.ppfriends.presentation.util.camera
import android.content.Context
import com.arthenica.ffmpegkit.FFmpegKit
import com.arthenica.ffmpegkit.LogCallback
import com.arthenica.ffmpegkit.ReturnCode
import com.arthenica.ffmpegkit.Session
import com.arthenica.ffmpegkit.StatisticsCallback
import com.ppfriends.presentation.util.camera.model.FileInfoModel
import com.ppfriends.presentation.util.camera.model.OnResizingFailure
import com.ppfriends.presentation.util.camera.util.FileInformer.getVideoDuration
import com.ppfriends.presentation.util.camera.util.TimeTracker
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Semaphore
import timber.log.Timber
import java.io.File
import java.util.concurrent.ConcurrentHashMap
private const val BIT_RATE = 1_000_000 // 1Mbps
private const val FRAME_RATE = 24
private const val KEY_FRAME_INTERVAL = 1
private const val VIDEO_MAX_DURATION = 15.0
private const val PERMITS_LIMIT = 1
object VideoConvertor {
// 전역 세마포어 - 최대 n개의 동시 변환만 허용 todo 메모리 잔량 및 기기의 스펙에 따라 세마포어를 조절해주면 좋을 것 같음 -성진-
private val conversionSemaphore = Semaphore(PERMITS_LIMIT)
// 활성 세션 추적을 위한 맵 추가
private val activeSessionMap = ConcurrentHashMap<String, Long>()
// 취소를 추적하기 위한 맵 (sesssion이 생성된 후 activeSessions에 세션을 넣는 동안 시간이 걸리기때문에 사용자의 취소작업이 빠르면 뷰는 사라지지만 비동기는 취소가 안되는 경우가 있음)
private val cancelReservationMap = ConcurrentHashMap<String,Boolean>()
private val intentionalCancelMap = ConcurrentHashMap<String,Boolean>()
fun cancelConversion(
key: String,
intention: Boolean = false
) {
cancelReservationMap[key] = true
if (intention) intentionalCancelMap[key] = true
}
fun resizeVideo(
context: Context,
fileName: String,
inputFile: File,
key: String,
mediaType: String,
onSuccess: (FileInfoModel) -> Unit,
onFailure: (OnResizingFailure) -> Unit,
) {
CoroutineScope(Dispatchers.default).launch {
try {
conversionSemaphore.acquire()
val fileSize = inputFile.length() / (1024 * 1024)
if(fileSize >= 200) {
Timber.e("resizeVideo 입력 파일 용량 초과: $fileSize")
onFailure(OnResizingFailure.FileSizeExceeded)
conversionSemaphore.release()
return@launch
}
TimeTracker.start(key)
Timber.e("resizeVideo 작업 시작: (현재 활성 변환: ${PERMITS_LIMIT - conversionSemaphore.availablePermits})")
val outputFile = File(context.cacheDir, "resized_$fileName")
val command = arrayOf(
// 입력 파일 경로
"-i", inputFile.absolutePath,
"-threads", "1", // 단일 스레드 사용 -> 많이 쓰나 적게 쓰나 큰 차이는 안남 (일반적으로 1개의 영상처리에 1~3개까지 권장)
// 비디오 관련 설정
"-vf", "crop=min(iw\\,ih):min(iw\\,ih):(iw-min(iw\\,ih))/2:(ih-min(iw\\,ih))/2,scale=512:512",
"-c:v", "mpeg4", // mpeg4: 하드웨어 코덱(다소 느림, 모든 기기 가능), 소프트웨어 코덱도 있으나 호환 안되는 기기가 많음.
"-b:v", BIT_RATE.toString(),
"-r", FRAME_RATE.toString(),
"-g", (FRAME_RATE * KEY_FRAME_INTERVAL).toString(),
"-crf", "45", // 화질 23~51(최저)
"-pix_fmt", "yuv420p",
"-preset", "ultrafast", // 변환 속도
// 오디오 관련 설정
"-c:a", "aac",
"-b:a", "64k",
"-ac", "1",
// 기타 설정
"-movflags", "+faststart",
"-max_muxing_queue_size", "512",
// 최대 15초 제한
"-t", VIDEO_MAX_DURATION.toString(),
// 메타데이터 제거
"-map_metadata", "-1",
// 출력 파일 경로
"-y", outputFile.absolutePath
).joinToString(" ")
// 비디오 길이 가져오기 (초 단위)
val durationInSeconds = getVideoDuration(inputFile)
// 진행률을 확인하기 위한 콜백
val statisticsCallback = StatisticsCallback { statistics ->
if (durationInSeconds > 0) {
val progressInSeconds = statistics.time / 1000
val goalDuration = if(durationInSeconds < VIDEO_MAX_DURATION) durationInSeconds else VIDEO_MAX_DURATION
val progress =
((progressInSeconds / goalDuration) * 100).toInt().coerceIn(0, 100)
Timber.e("resizeVideo 진행률: $progress% (${progressInSeconds}s/${goalDuration}s) \n file: $fileName \noriginal file dur: $durationInSeconds")
}
}
// 로그 콜백 - 세션 시작 확인용
val logCallback = LogCallback { log ->
if(cancelReservationMap.getOrDefault(key, false) && activeSessionMap[key].toString().isNotEmpty()) {
Timber.e("resizeVideo 작업 취소 log -> cancelMap: ${cancelReservationMap.getOrDefault(key, false)}\nisActiveContains: ${activeSessionMap[key]}")
activeSessionMap[key]?.let { FFmpegKit.cancel(it) }
activeSessionMap.remove(key)
cancelReservationMap.remove(key)
}
}
// FFmpeg를 실행하고 완료 콜백 설정
val completionCallback = { session: Session ->
try {
when {
ReturnCode.isSuccess(session.returnCode) -> {
val outputFileSize = outputFile.length() / (1024 * 1024)
if (outputFileSize > 5) {
outputFile.delete()
onFailure(OnResizingFailure.VideoResizingFailed)
} else {
onSuccess(
FileInfoModel(
fileName = outputFile.name,
file = outputFile,
fileType = mediaType,
duration = durationInSeconds.toInt().coerceIn(1, 15)
)
)
val inputFileSize = (inputFile.length().toDouble() / (1024 * 1024)).toString() + "MB"
Timber.e("resizeVideo 작업 결과 완료: $fileName (세마포어 사용 가능 퍼밋: ${conversionSemaphore.availablePermits})\n file size: $inputFileSize \nkey:$key\ncancel map size: ${cancelReservationMap.size}\nactive map size: ${activeSessionMap.size}")
TimeTracker.end(key, inputFileSize)
cancelReservationMap.remove(key)
activeSessionMap.remove(key)
conversionSemaphore.release()
}
}
ReturnCode.isCancel(session.returnCode) -> {
Timber.e("resizeVideo 작업 결과 중단 :$key\ncancel map size: ${cancelReservationMap.size}\nactive map size: ${activeSessionMap.size}")
outputFile.delete()
if (intentionalCancelMap.getOrDefault(key,false)) intentionalCancelMap.remove(key)
else onFailure(OnResizingFailure.VideoResizingFailed)
activeSessionMap.remove(key) // log callback 을 타지 않고 취소 되었을 경우를 고려해서 복잡하지만 한번 더 remove 해줌.
cancelReservationMap.remove(key)
conversionSemaphore.release()
}
else -> { // 알 수 없는 에러
Timber.e("resizeVideo 작업 결과 알 수 없는 에러로 인한 중단 \nkey :$key\ncancel map size: ${cancelReservationMap.size}\nactive map size: ${activeSessionMap.size}")
outputFile.delete()
activeSessionMap.remove(key)
conversionSemaphore.release()
onFailure(OnResizingFailure.VideoResizingFailed)
}
}
} catch (e: Exception) {
Timber.e("resizeVideo 작업 결과 처리 중 오류: ${e.message} \nkey:$key\ncancel map size: ${cancelReservationMap.size}\nactive map size: ${activeSessionMap.size}")
outputFile.delete()
activeSessionMap.remove(key)
conversionSemaphore.release()
onFailure(OnResizingFailure.VideoResizingFailed)
}
}
//sesssion이 생성된 후 activeSessions에 세션을 넣는 동안 시간이 걸리기때문에 [conCurrentHashMap을 사용] 사용자의 취소작업이 빠르면 뷰는 사라지지만 비동기는 취소가 처리가 안되는 경우가 있음
val session = FFmpegKit.executeAsync(command, completionCallback, logCallback, statisticsCallback)
activeSessionMap[key] = session.sessionId
} catch (e: Exception) {
Timber.e("resizeVideo 작업 결과 비디오 변환 초기화 중 오류 발생: ${e.message} \nkey:$key\ncancel map size: ${cancelReservationMap.size}\nactive map size: ${activeSessionMap.size}")
activeSessionMap.remove(key)
conversionSemaphore.release()
onFailure(OnResizingFailure.VideoResizingFailed)
}
}
}
}
주요 설명:
소프트웨어/하드웨어 코덱이 존재 ->
기기에 맞게 쓰면 좋겠지만 안정적인 호환을 위해 하드웨어 코덱을 사용했습니다.
메모리 사용이 생각보다 중요한 이유는 안드로이드 기기 자체가 ios 보다 메모리를 상당히 많이 쓰고 비효율적이기에
영상을 한번에 여러개 처리할 수 있는 한계 메모리에 빨리 도달하기 때문 : 쉽게 말해서 메모리 겁나 쓰니까 잘 터짐 아껴야함
간단하쥬? | 소프트웨어 (libx265 등) | 하드웨어 (mpeg4 등) |
장점 | 빠름, 메모리 사용 적음 | 모든 기기 호환 |
단점 | 특정 기기 호환x | 느림, 메모리 사용 많음 (소프트보다 20%정도) |
"-vf", "crop=min(iw\\,ih):min(iw\\,ih):(iw-min(iw\\,ih))/2:(ih-min(iw\\,ih))/2,scale=512:512" ->
크롭하는 부분인데 가장 작은 가로/세로에 맞춰서 512x512 사이즈로 잘라달라는 커맨드
복잡해보이지만 커맨드만 적어넣으면 알아서 잘 해주니까 편함 media3 는 조금 더 복잡한 편.
결론:
커맨드가 좀 복잡해 보이지만 그것만 잘하면 완벽한 툴.
Media3
package com.ppfriends.presentation.util.camera
import android.content.Context
import android.media.MediaMetadataRetriever
import android.net.Uri
import androidx.annotation.OptIn
import androidx.media3.common.MediaItem
import androidx.media3.common.util.UnstableApi
import androidx.media3.effect.Crop
import androidx.media3.effect.ScaleAndRotateTransformation
import androidx.media3.transformer.Composition
import androidx.media3.transformer.EditedMediaItem
import androidx.media3.transformer.EditedMediaItemSequence
import androidx.media3.transformer.Effects
import androidx.media3.transformer.ExportException
import androidx.media3.transformer.ExportResult
import androidx.media3.transformer.ProgressHolder
import androidx.media3.transformer.Transformer
import com.ppfriends.presentation.BuildConfig
import com.ppfriends.presentation.util.camera.model.FileInfoModel
import com.ppfriends.presentation.util.camera.model.OnResizingFailure
import com.ppfriends.presentation.util.camera.util.TimeTracker
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Semaphore
import timber.log.Timber
import java.io.File
import java.util.concurrent.ConcurrentHashMap
private const val VIDEO_MAX_DURATION = 15_000
private const val PERMITS_LIMIT = 1
object VideoConvertor {
// 전역 세마포어 - 최대 n개의 동시 변환만 허용
private val conversionSemaphore = Semaphore(PERMITS_LIMIT)
// 취소를 추적하기 위한 맵 (sesssion이 생성된 후 activeSessions에 세션을 넣는 동안 시간이 걸리기때문에 사용자의 취소작업이 빠르면 뷰는 사라지지만 비동기는 취소가 안되는 경우가 있음)
private val cancelReservationMap = ConcurrentHashMap<String, Boolean>()
// 활성 세션 추적을 위한 맵 추가
private val activeTransformerMap = ConcurrentHashMap<String, Transformer>()
@OptIn(UnstableApi::class)
fun cancelConversion(
key: String,
) {
cancelReservationMap[key] = true
}
@UnstableApi
fun trimVideoTo15Seconds(
context: Context,
fileName: String,
inputFile: File,
mediaType: String,
key: String,
onSuccess: (FileInfoModel) -> Unit,
onFailure: (OnResizingFailure) -> Unit,
) {
val keyTag = key.take(5)
CoroutineScope(Dispatchers.default).launch {
try {
conversionSemaphore.acquire()
if (BuildConfig.DEBUG) TimeTracker.start(key) // 변환 소요시간 측정 (디버깅 전용)
val fileSize = inputFile.length() / (1024 * 1024)
if (fileSize >= 200) {
Timber.e("resizeVideo 입력 파일 용량 초과: $fileSize")
onFailure(OnResizingFailure.FileSizeExceeded)
conversionSemaphore.release()
return@launch
}
// 출력 파일 생성
val outputFile = File(context.cacheDir, "resized_$fileName")
val retriever = MediaMetadataRetriever()
retriever.setDataSource(context, Uri.fromFile(inputFile))
val videoWidth = retriever.extractMetadata(
MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH
)?.toFloatOrNull() ?: return@launch
val videoHeight = retriever.extractMetadata(
MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT
)?.toFloatOrNull() ?: return@launch
retriever.release()
val targetAspectRatio = 1f / 1f // 1:1 비율
val sourceAspectRatio = videoWidth / videoHeight
val cropLeft: Float
val cropRight: Float
val cropTop: Float
val cropBottom: Float
if (sourceAspectRatio > targetAspectRatio) { // 좌우 크롭
// 목표 너비 계산 (비디오 높이 * 목표 비율)
val targetWidth = videoHeight * targetAspectRatio
// 크롭 비율 계산 (얼마나 잘라낼지)
val cropRatio = targetWidth / videoWidth
cropLeft = -(cropRatio) // 왼쪽으로 cropRatio만큼 이동
cropRight = cropRatio // 오른쪽으로 cropRatio만큼 이동
cropTop = -1f // 상단은 그대로 -> media3 crop 에서 가장 중요한건 top > bottom, right > left 값을 지켜주지 않으면 런타임 에러가 발생하기 때문에 -1f 1f 로 놓은 것.
cropBottom = 1f // 하단은 그대로
} else { // 상하 크롭
val targetHeight = videoWidth / targetAspectRatio
val cropRatio = targetHeight / videoHeight
cropLeft = -1f
cropRight = 1f
cropTop = -(cropRatio)
cropBottom = cropRatio
}
val cropEffect = Crop(cropLeft, cropRight, cropTop, cropBottom)
val flipEffect = ScaleAndRotateTransformation.Builder()
.setScale(1f, 1f)
.build()
val effects = Effects(
/* 오디오 관련 */ listOf(),
/* 비디오 관련 */ listOf(cropEffect, flipEffect)
)
// 입력 MediaItem 생성
val mediaItem = MediaItem.Builder()
.setUri(Uri.fromFile(inputFile))
.setClippingConfiguration(
MediaItem.ClippingConfiguration.Builder()
.setStartPositionMs(0) // 0초부터
.setEndPositionMs(VIDEO_MAX_DURATION.toLong()) // 15초
.build()
)
.build()
// EditedMediaItem 생성
val editedMediaItem = EditedMediaItem.Builder(mediaItem)
.setRemoveAudio(false) // 오디오 유지
.setEffects(effects)
.build()
// Composition 생성
val composition = Composition.Builder(EditedMediaItemSequence(editedMediaItem)).build()
// 메인 스레드로 전환하여 Transformer 생성 및 시작 -> media3 는 ffmpeg
CoroutineScope(Dispatchers.Main).launch {
val progressHolder = ProgressHolder()
val transformer = Transformer.Builder(context) // 취소에 대한 콜백이 존재하지 않고 취소된 이후에도
.addListener(object : Transformer.Listener {
override fun onCompleted(
composition: Composition,
exportResult: ExportResult
) {
val outputSize = exportResult.fileSizeBytes / (1024 * 1024) // 결과 용량 측정 (MB)
if (activeTransformerMap.containsKey(key)){
Timber.e("resizeVideo success -> dur: ${exportResult.durationMs.toInt()}\nkey: $keyTag\n" +
"size: $outputSize")
activeTransformerMap.remove(key)
conversionSemaphore.release()
if (BuildConfig.DEBUG) TimeTracker.end(key, outputSize.toString()) // 변환 성공 소요시간 측정 (디버깅 전용)
onSuccess(
FileInfoModel(
fileName = outputFile.name,
file = outputFile,
fileType = mediaType,
duration = Math.round((exportResult.durationMs / 1000.0)).toInt().coerceIn(1, 15)
)
)
// }
}
}
override fun onError( // 영상 편집 에러가 발생한 경우
composition: Composition,
exportResult: ExportResult,
exportException: ExportException
) {
Timber.e("resizeVideo 작업 결과 알 수 없는 에러로 인한 중단 \nkey :$keyTag")
outputFile.delete()
activeTransformerMap.remove(key)
conversionSemaphore.release()
onFailure(OnResizingFailure.VideoResizingFailed)
}
}).build()
transformer.start(composition, outputFile.absolutePath)
activeTransformerMap[key] = transformer
while (activeTransformerMap.isNotEmpty()) {
when (transformer.getProgress(progressHolder)) {
Transformer.PROGRESS_STATE_AVAILABLE -> { // 편집이 시작된 상태
Timber.d("resizeVideo -> progress: ${progressHolder.progress}%\nkey: $keyTag")
if(cancelReservationMap.getOrDefault(key, false) && activeTransformerMap.containsKey(key)) { // 취소 상태인지 확인
Timber.e("resizeVideo canceled on purpose by skin head \nkey: $keyTag")
activeTransformerMap[key]?.cancel()
activeTransformerMap.remove(key)
cancelReservationMap.remove(key)
conversionSemaphore.release()
outputFile.delete()
}
}
Transformer.PROGRESS_STATE_NOT_STARTED -> {}
Transformer.PROGRESS_STATE_WAITING_FOR_AVAILABILITY -> {}
Transformer.PROGRESS_STATE_UNAVAILABLE -> { // 사용불가한 에러 발생시 -> 한번도 구현 못해봄
activeTransformerMap[key]?.cancel()
activeTransformerMap.remove(key)
conversionSemaphore.release()
outputFile.delete()
onFailure(OnResizingFailure.VideoResizingFailed)
}
}
delay(500) // 0.5초마다 체크
}
}
} catch (e: Exception) {
Timber.e(e, "resizeVideo failed for key: $keyTag")
activeTransformerMap[key]?.cancel()
activeTransformerMap.remove(key)
conversionSemaphore.release()
onFailure(OnResizingFailure.VideoResizingFailed)
}
}
}
}
얘는 내부코드가 전부 뭔소린지 모르겠음. 생각보다 콜백이나 함수명이나 과정에서 뭘 써야하는지 명확하지가 않음. 얼레벌레 암튼 돌아감;
주요 설명:
MediaItem.Builder() ->
제일 첫 번째로 어떤 파일을 넣을지, 기본적인 옵션으로 어떤 것을 줄지 정할 수 있음 (재생 시간 편집, 캐쉬 유무, 파일 타입, 라이브 등)
EditedMediaItem.Builder(mediaItem) ->
보시다시피 mediaItem 을 넣고 또 편집해주는 녀석, 오디오 및 비디오의 편집을 정할 수 있음
Composition.Builder(EditedMediaItemSequence(editedMediaItem)).build() ->
보시다시피 editedMediaItem 을 넣고 전체적인 청사진을 전달하는 녀석, editedMediaItem 을 여러개 전달할 수도 있다는 점
transformer.start(composition, outputFile.absolutePath) ->
보시다시피 composition 을 넣고 편집을 시작하는 녀석, start 말고 cancel 도 있으나 일괄 취소만 가능하고 전부 비동기임
결론:
이처럼 빌드 구조를 통해서 전달해서 마지막에 시작하는 구조.
처음보는 사람은 이해하기 쉬울 수 있지만 알면 알수록 복잡하고 내부에 함수가 없으면 따로 커스텀도 안되니 엄청 제한적
추가 설명:
크롭하는 부분이 이해하기 정말 어려운데,
OpenGL 좌표계에서 (0, 0)은 윈도우 화면의 중앙에 위치하며, (1, 1)은 화면의 오른쪽 상단, (-1, -1)은 화면의 왼쪽 하단에 대응
위 글을 일단 이해한 상태에서,
if (sourceAspectRatio > targetAspectRatio) { // 좌우 크롭
// 목표 너비 계산 (비디오 높이 * 목표 비율)
val targetWidth = videoHeight * targetAspectRatio
// 크롭 비율 계산 (얼마나 잘라낼지)
val cropRatio = targetWidth / videoWidth
cropLeft = -(cropRatio) // 왼쪽으로 cropRatio만큼 이동
cropRight = cropRatio // 오른쪽으로 cropRatio만큼 이동
cropTop = -1f // 상단은 그대로 -> media3 crop 에서 가장 중요한건 top > bottom, right > left 값을 지켜주지 않으면 런타임 에러가 발생하기 때문에 -1f 1f 로 놓은 것.
cropBottom = 1f // 하단은 그대로
}
이 부분만 보자면, 좌우가 큰 (ex: 16:9) 경우 좌우를 잘라야 1:1 크롭이 가능함으로 좌우를 잘라서 좌표계에 알맞게 이동 시키겠다는 뜻.
예를 들어:
- cropLeft = -(cropRatio): 왼쪽 경계를 오른쪽으로 이동
- cropRight = cropRatio: 오른쪽 경계를 왼쪽으로 이동
실제 효과:
원본 (16:9): 크롭 후 (1:1):
[----------] [ ]
[----------] --> [----]
[----------] [ ]
아마 대략 이렇게 된다는 소리. (사실 저도 잘 모름)
아래 링크에 더 설명이 잘 되어있으니 참고.
공통적인 부분 (세마포어, cancel 관련)
세마포어를 쓴 이유:
여러 영상 작업을 한번에 동시처리하면 메모리 한계치가 낮은 옛날 폰은 터지므로 1개씩 처리하기 위해서 (작업 취소하기도 용이함)
주요 설명:
세마포어는 구명조끼 대여집이라고 생각하면 편함.
밖에 손님(job)이 줄 서 있고 permit(가게에 남은 구명조끼) 갯수만큼 순서대로 가져가는 거임.
permit이 2개고 job이 3개면 앞에 있는 job이 순서대로 2개 먼저 집어가면
남은 job은 다른 job이 끝나고 반납한 permit을 가져가기 전까지 기다리면됨.
그래서 start 할 때는 acquire() 을 통해 퍼밋을 가가져가게 하고,
cancel 될 때는 release() 를 통해 퍼밋을 반납하는 과정을 겪는 것.
그럼 앞에 job이 너무 커서 뒤에 기다리는 job이 한 평생 기다리면 어떡해요?!
-> 프로세스가 쓰레드에 job을 분배할 때 가장 빨리 끝나는 job 을 알아서 먼저 처리하기 때문에 따로 처리해줄 필요 x
요약:
세마포어로 원하는 갯수만큼 동시에 처리하고 그 순서는 프로세스가 알아서 효율적으로 정해줌
cancel 처리방법:
private val cancelReservationMap = ConcurrentHashMap<String, Boolean>()
// 활성 세션 추적을 위한 맵 추가
private val activeTransformerMap = ConcurrentHashMap<String, Transformer>()
ConcurrentHashMap 를 통해서 동기적으로 준비가 된 녀석을 value 로 넣어주는 방식으로
편집이 시작된 transaction 을 담아놓고 편집 과정에서 계속해서 cancel 유무를 체크함.
그냥 취소하면 안뒈요?!
-> 단일 작업이면 그냥 취소하면 되는데 여러 영상을 한 곳에서 동시에 처리하고 특정 영상만 취소해야 함으로
map 으로 작업을 분리했고, 중요한 점은 transaction.start() 나 ffmpeg.excute() 가 비동기이므로 시작이 되기도 전에 취소를
하는 경우 취소 작업을 무시하고 편집이 시작됨.
한마디로 편집이 반드시 시작 되어야 취소가 가능하다는 것.
요약: 시작해야 취소 되니까 예약취소 걸어놓고 중간에 계속 체크함.
전체 결론:
재밌긴 함.
너무 복잡한건 그냥 서버에서 하자.
ios 와 로직도 너무 달라서 맞추기가 힘듬.
변수가 다양한 영상의 사이즈, 용량, 해상도를 전부 완벽하게 맞춰주기가 상담히 힘들고,
기준이 다름 -> [해상도 (720p로 해쥬세요!)개념x / quality (0~100)개념o]
부족한 글 봐주셔서 감사합니다.
정말 엉망이네요. 수정해서 다시 올려보겠습니다~
2025-04-28 수정
- io -> default 로 쓰레드 변경
- 동영상의 회전 정보를 가져와서 회전됨에 따라 가로세로가 바뀌는 케이스를 방지하는 코드 추가