Wip
This commit is contained in:
parent
b32ff8ce4f
commit
2c61650a0e
@ -14,10 +14,7 @@ import org.springframework.boot.autoconfigure.SpringBootApplication
|
||||
import org.springframework.boot.runApplication
|
||||
import org.springframework.context.annotation.Configuration
|
||||
|
||||
@SpringBootApplication(scanBasePackages = [
|
||||
"no.iktdev.converter",
|
||||
"no.iktdev.mediaprocessing.shared.common"
|
||||
])
|
||||
|
||||
open class ConverterApplication: DatabaseApplication() {
|
||||
}
|
||||
|
||||
|
||||
@ -0,0 +1,66 @@
|
||||
package no.iktdev.mediaprocessing.converter
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import no.iktdev.eventi.models.Event
|
||||
import no.iktdev.eventi.tasks.AbstractTaskPoller
|
||||
import no.iktdev.eventi.tasks.TaskReporter
|
||||
import no.iktdev.mediaprocessing.shared.common.stores.EventStore
|
||||
import no.iktdev.mediaprocessing.shared.common.stores.TaskStore
|
||||
import org.springframework.boot.ApplicationArguments
|
||||
import org.springframework.boot.ApplicationRunner
|
||||
import org.springframework.context.annotation.Bean
|
||||
import org.springframework.stereotype.Component
|
||||
import org.springframework.stereotype.Service
|
||||
import java.util.UUID
|
||||
|
||||
@Component
|
||||
class PollerAdministrator(
|
||||
private val taskPoller: TaskPoller,
|
||||
): ApplicationRunner {
|
||||
override fun run(args: ApplicationArguments?) {
|
||||
CoroutineScope(Dispatchers.Default).launch {
|
||||
taskPoller.start()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Service
|
||||
class TaskPoller(
|
||||
private val reporter: TaskReporter,
|
||||
) : AbstractTaskPoller(
|
||||
taskStore = TaskStore,
|
||||
reporterFactory = { reporter } // én reporter brukes for alle tasks
|
||||
) {
|
||||
|
||||
}
|
||||
|
||||
|
||||
@Component
|
||||
class DefaultTaskReporter() : TaskReporter {
|
||||
override fun markClaimed(taskId: UUID, workerId: String) {
|
||||
TaskStore.claim(taskId, workerId)
|
||||
}
|
||||
|
||||
override fun updateLastSeen(taskId: UUID) {
|
||||
TaskStore.heartbeat(taskId)
|
||||
}
|
||||
|
||||
override fun markConsumed(taskId: UUID) {
|
||||
TaskStore.markConsumed(taskId)
|
||||
}
|
||||
|
||||
override fun updateProgress(taskId: UUID, progress: Int) {
|
||||
// Not to be implemented for this application
|
||||
}
|
||||
|
||||
override fun log(taskId: UUID, message: String) {
|
||||
// Not to be implemented for this application
|
||||
}
|
||||
|
||||
override fun publishEvent(event: Event) {
|
||||
EventStore.persist(event)
|
||||
}
|
||||
}
|
||||
@ -7,7 +7,7 @@ import no.iktdev.eventi.tasks.TaskListener
|
||||
import no.iktdev.eventi.tasks.TaskType
|
||||
import no.iktdev.mediaprocessing.converter.convert.ConvertListener
|
||||
import no.iktdev.mediaprocessing.converter.convert.Converter2
|
||||
import no.iktdev.mediaprocessing.shared.common.event_task_contract.events.ConvertTaskPerformedEvent
|
||||
import no.iktdev.mediaprocessing.shared.common.event_task_contract.events.ConvertTaskResultEvent
|
||||
import no.iktdev.mediaprocessing.shared.common.event_task_contract.events.ConvertedData
|
||||
import no.iktdev.mediaprocessing.shared.common.event_task_contract.tasks.ConvertTask
|
||||
import org.springframework.stereotype.Component
|
||||
@ -43,7 +43,7 @@ class ConvertTaskListener: TaskListener(TaskType.CPU_INTENSIVE) {
|
||||
|
||||
return try {
|
||||
val result = converter.getResult()
|
||||
val newEvent = ConvertTaskPerformedEvent(
|
||||
val newEvent = ConvertTaskResultEvent(
|
||||
data = ConvertedData(
|
||||
language = task.data.language,
|
||||
outputFiles = result,
|
||||
@ -54,7 +54,7 @@ class ConvertTaskListener: TaskListener(TaskType.CPU_INTENSIVE) {
|
||||
newEvent
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
val newEvent = ConvertTaskPerformedEvent(
|
||||
val newEvent = ConvertTaskResultEvent(
|
||||
data = null,
|
||||
status = TaskStatus.Failed
|
||||
).producedFrom(task)
|
||||
|
||||
@ -39,6 +39,7 @@ dependencies {
|
||||
|
||||
implementation("no.iktdev:exfl:0.0.16-SNAPSHOT")
|
||||
implementation("no.iktdev.streamit.library:streamit-library-db:1.0.0-alpha14")
|
||||
implementation("no.iktdev:eventi:1.0-rc13")
|
||||
|
||||
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.1")
|
||||
@ -73,6 +74,7 @@ dependencies {
|
||||
|
||||
testImplementation(platform("org.junit:junit-bom:5.9.1"))
|
||||
testImplementation("org.junit.jupiter:junit-jupiter")
|
||||
testImplementation("org.junit.jupiter:junit-jupiter-params")
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
testImplementation("org.mockito:mockito-core:3.+")
|
||||
testImplementation("org.assertj:assertj-core:3.4.1")
|
||||
|
||||
@ -0,0 +1,52 @@
|
||||
package no.iktdev.mediaprocessing.coordinator
|
||||
|
||||
import mu.KotlinLogging
|
||||
import no.iktdev.eventi.events.EventTypeRegistry
|
||||
import no.iktdev.eventi.tasks.TaskTypeRegistry
|
||||
import no.iktdev.exfl.coroutines.CoroutinesDefault
|
||||
import no.iktdev.exfl.coroutines.CoroutinesIO
|
||||
import no.iktdev.exfl.observable.Observables
|
||||
import no.iktdev.mediaprocessing.shared.common.DatabaseApplication
|
||||
import no.iktdev.mediaprocessing.shared.common.event_task_contract.EventRegistry
|
||||
import no.iktdev.mediaprocessing.shared.common.event_task_contract.TaskRegistry
|
||||
import no.iktdev.mediaprocessing.shared.common.getAppVersion
|
||||
import org.springframework.boot.runApplication
|
||||
import org.springframework.context.annotation.Configuration
|
||||
|
||||
class CoordinatorApplication: DatabaseApplication() {
|
||||
}
|
||||
|
||||
val ioCoroutine = CoroutinesIO()
|
||||
val defaultCoroutine = CoroutinesDefault()
|
||||
|
||||
private val log = KotlinLogging.logger {}
|
||||
|
||||
fun main(args: Array<String>) {
|
||||
ioCoroutine.addListener(listener = object: Observables.ObservableValue.ValueListener<Throwable> {
|
||||
override fun onUpdated(value: Throwable) {
|
||||
value.printStackTrace()
|
||||
}
|
||||
})
|
||||
defaultCoroutine.addListener(listener = object: Observables.ObservableValue.ValueListener<Throwable> {
|
||||
override fun onUpdated(value: Throwable) {
|
||||
value.printStackTrace()
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
runApplication<CoordinatorApplication>(*args)
|
||||
log.info { "App Version: ${getAppVersion()}" }
|
||||
}
|
||||
//private val logger = KotlinLogging.logger {}
|
||||
|
||||
@Configuration
|
||||
open class ApplicationConfiguration() {
|
||||
init {
|
||||
EventRegistry.getEvents().let {
|
||||
EventTypeRegistry.register(it)
|
||||
}
|
||||
TaskRegistry.getTasks().let {
|
||||
TaskTypeRegistry.register(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,11 @@
|
||||
package no.iktdev.mediaprocessing.coordinator
|
||||
|
||||
import java.io.File
|
||||
|
||||
class CoordinatorEnv {
|
||||
companion object {
|
||||
val ffprobe: String = System.getenv("SUPPORTING_EXECUTABLE_FFPROBE") ?: "ffprobe"
|
||||
|
||||
val preference: File = File("/data/config/preference.json")
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,30 @@
|
||||
package no.iktdev.mediaprocessing.coordinator
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import no.iktdev.eventi.events.AbstractEventPoller
|
||||
import no.iktdev.eventi.events.EventDispatcher
|
||||
import no.iktdev.eventi.events.SequenceDispatchQueue
|
||||
import no.iktdev.mediaprocessing.shared.common.stores.EventStore
|
||||
import org.springframework.boot.ApplicationArguments
|
||||
import org.springframework.boot.ApplicationRunner
|
||||
import org.springframework.stereotype.Component
|
||||
|
||||
|
||||
@Component
|
||||
class PollerAdministrator(
|
||||
private val eventPoller: EventPoller,
|
||||
): ApplicationRunner {
|
||||
override fun run(args: ApplicationArguments?) {
|
||||
CoroutineScope(Dispatchers.Default).launch {
|
||||
eventPoller.start()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val sequenceDispatcher = SequenceDispatchQueue(8)
|
||||
val dispatcher = EventDispatcher(eventStore = EventStore)
|
||||
|
||||
class EventPoller: AbstractEventPoller(eventStore = EventStore, dispatchQueue = sequenceDispatcher, dispatcher = dispatcher) {
|
||||
}
|
||||
@ -0,0 +1,46 @@
|
||||
package no.iktdev.mediaprocessing.coordinator
|
||||
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.GsonBuilder
|
||||
import no.iktdev.mediaprocessing.ffmpeg.dsl.AudioCodec
|
||||
import no.iktdev.mediaprocessing.ffmpeg.dsl.VideoCodec
|
||||
import no.iktdev.mediaprocessing.shared.common.silentTry
|
||||
import java.io.File
|
||||
|
||||
class ProcesserPreference {
|
||||
val videoPreference: VideoPreference? = null
|
||||
val audioPreference: AudioPreference? = null
|
||||
}
|
||||
|
||||
data class VideoPreference(
|
||||
val codec: VideoCodec,
|
||||
val enforceMkv: Boolean = false
|
||||
)
|
||||
|
||||
data class AudioPreference(
|
||||
val language: String,
|
||||
val codec: AudioCodec
|
||||
)
|
||||
|
||||
|
||||
object Preference {
|
||||
fun getProcesserPreference(): ProcesserPreference {
|
||||
var preference: ProcesserPreference = ProcesserPreference()
|
||||
CoordinatorEnv.preference.ifExists {
|
||||
val text = readText()
|
||||
try {
|
||||
val result = Gson().fromJson(text, ProcesserPreference::class.java)
|
||||
preference = result
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
return preference
|
||||
}
|
||||
}
|
||||
|
||||
private fun File.ifExists(block: File.() -> Unit) {
|
||||
if (this.exists()) {
|
||||
block()
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,15 @@
|
||||
package no.iktdev.mediaprocessing.coordinator.listeners.events
|
||||
|
||||
import no.iktdev.eventi.events.EventListener
|
||||
import no.iktdev.eventi.models.Event
|
||||
import org.springframework.stereotype.Component
|
||||
|
||||
@Component
|
||||
class MediaCreateConvertTaskListener: EventListener() {
|
||||
override fun onEvent(
|
||||
event: Event,
|
||||
history: List<Event>
|
||||
): Event? {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,72 @@
|
||||
package no.iktdev.mediaprocessing.coordinator.listeners.events
|
||||
|
||||
import no.iktdev.eventi.events.EventListener
|
||||
import no.iktdev.eventi.models.Event
|
||||
import no.iktdev.mediaprocessing.coordinator.Preference
|
||||
import no.iktdev.mediaprocessing.ffmpeg.dsl.AudioCodec
|
||||
import no.iktdev.mediaprocessing.ffmpeg.dsl.AudioTarget
|
||||
import no.iktdev.mediaprocessing.ffmpeg.dsl.MediaPlan
|
||||
import no.iktdev.mediaprocessing.ffmpeg.dsl.VideoCodec
|
||||
import no.iktdev.mediaprocessing.ffmpeg.dsl.VideoTarget
|
||||
import no.iktdev.mediaprocessing.shared.common.event_task_contract.events.MediaStreamParsedEvent
|
||||
import no.iktdev.mediaprocessing.shared.common.event_task_contract.events.MediaTracksEncodeSelectedEvent
|
||||
import no.iktdev.mediaprocessing.shared.common.event_task_contract.events.StartProcessingEvent
|
||||
import no.iktdev.mediaprocessing.shared.common.event_task_contract.tasks.EncodeData
|
||||
import no.iktdev.mediaprocessing.shared.common.event_task_contract.tasks.EncodeTask
|
||||
import no.iktdev.mediaprocessing.shared.common.stores.TaskStore
|
||||
import org.springframework.stereotype.Component
|
||||
import java.io.File
|
||||
|
||||
@Component
|
||||
class MediaCreateEncodeTaskListener : EventListener() {
|
||||
|
||||
|
||||
override fun onEvent(
|
||||
event: Event,
|
||||
history: List<Event>
|
||||
): Event? {
|
||||
val preference = Preference.getProcesserPreference()
|
||||
|
||||
val startedEvent = history.filterIsInstance<StartProcessingEvent>().firstOrNull() ?: return null
|
||||
val selectedEvent = event as? MediaTracksEncodeSelectedEvent ?: return null
|
||||
val streams = history.filterIsInstance<MediaStreamParsedEvent>().firstOrNull()?.data ?: return null
|
||||
|
||||
val videoPreference = preference.videoPreference?.codec ?: VideoCodec.Hevc()
|
||||
val audioPreference = preference.audioPreference?.codec ?: AudioCodec.Aac(channels = 2)
|
||||
|
||||
val audioTargets = mutableListOf<AudioTarget>(
|
||||
AudioTarget(
|
||||
index = selectedEvent.selectedAudioTrack,
|
||||
codec = audioPreference
|
||||
)
|
||||
)
|
||||
selectedEvent.selectedAudioExtendedTrack?.let {
|
||||
audioTargets.add(AudioTarget(
|
||||
index = it,
|
||||
codec = audioPreference
|
||||
))
|
||||
}
|
||||
|
||||
|
||||
val plan = MediaPlan(
|
||||
videoTrack = VideoTarget(index = selectedEvent.selectedVideoTrack, codec = videoPreference),
|
||||
audioTracks = audioTargets
|
||||
)
|
||||
val args = plan.toFfmpegArgs(streams.videoStream, streams.audioStream)
|
||||
|
||||
val task = EncodeTask(
|
||||
data = EncodeData(
|
||||
arguments = args,
|
||||
outputFileName = startedEvent.data.fileUri.let { File(it).nameWithoutExtension },
|
||||
inputFile = startedEvent.data.fileUri
|
||||
)
|
||||
).derivedOf(event)
|
||||
|
||||
|
||||
TaskStore.persist(task)
|
||||
return null // Create task instead of event
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
@ -0,0 +1,19 @@
|
||||
package no.iktdev.mediaprocessing.coordinator.listeners.events
|
||||
|
||||
import no.iktdev.eventi.events.EventListener
|
||||
import no.iktdev.eventi.models.Event
|
||||
import no.iktdev.mediaprocessing.shared.common.event_task_contract.events.MediaTracksExtractSelectedEvent
|
||||
import org.springframework.stereotype.Component
|
||||
|
||||
@Component
|
||||
class MediaCreateExtractTaskListener: EventListener() {
|
||||
override fun onEvent(
|
||||
event: Event,
|
||||
history: List<Event>
|
||||
): Event? {
|
||||
val useEvent = event as? MediaTracksExtractSelectedEvent ?: return null
|
||||
|
||||
|
||||
return null
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,76 @@
|
||||
package no.iktdev.mediaprocessing.coordinator.listeners.events
|
||||
|
||||
import no.iktdev.eventi.events.EventListener
|
||||
import no.iktdev.eventi.models.Event
|
||||
import no.iktdev.mediaprocessing.ffmpeg.data.SubtitleStream
|
||||
import no.iktdev.mediaprocessing.shared.common.event_task_contract.events.MediaStreamParsedEvent
|
||||
import no.iktdev.mediaprocessing.shared.common.event_task_contract.events.MediaTracksDetermineSubtitleTypeEvent
|
||||
import no.iktdev.mediaprocessing.shared.common.model.SubtitleItem
|
||||
import no.iktdev.mediaprocessing.shared.common.model.SubtitleType
|
||||
import org.springframework.stereotype.Component
|
||||
|
||||
@Component
|
||||
class MediaDetermineSubtitleTrackTypeListener: EventListener() {
|
||||
fun ignoreSHD(): Boolean = true
|
||||
fun ignoreCC(): Boolean = true
|
||||
fun ignoreSongs(): Boolean = true
|
||||
fun ignoreCommentary(): Boolean = true
|
||||
|
||||
val supportedCodecs = setOf(
|
||||
"ass", "subrip", "webvtt", "vtt", "smi"
|
||||
)
|
||||
|
||||
override fun onEvent(
|
||||
event: Event,
|
||||
history: List<Event>
|
||||
): Event? {
|
||||
val useEvent = event as? MediaStreamParsedEvent ?: return null
|
||||
|
||||
val collected = useEvent.data.subtitleStream
|
||||
.mapToType()
|
||||
.excludeTypes()
|
||||
.onlySupportedCodecs()
|
||||
|
||||
|
||||
return MediaTracksDetermineSubtitleTypeEvent(
|
||||
subtitleTrackItems = collected
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
fun getCommentaryFilters(): Set<String> = setOf("commentary", "kommentar", "kommentaar")
|
||||
fun getSongFilters(): Set<String> = setOf("song", "sign")
|
||||
fun getClosedCaptionFilters(): Set<String> = setOf("closed caption", "cc", "close caption", "closed-caption", "cc.")
|
||||
fun getSHDFilters(): Set<String> = setOf("shd", "hh", "hard of hearing", "hard-of-hearing")
|
||||
|
||||
private fun List<SubtitleItem>.excludeTypes(): List<SubtitleItem> {
|
||||
return this.filter {
|
||||
when (it.type) {
|
||||
SubtitleType.Song -> !ignoreSongs()
|
||||
SubtitleType.Commentary -> !ignoreCommentary()
|
||||
SubtitleType.ClosedCaption -> !ignoreCC()
|
||||
SubtitleType.SHD -> !ignoreSHD()
|
||||
SubtitleType.Dialogue -> true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun List<SubtitleStream>.mapToType(): List<SubtitleItem> {
|
||||
return this.map {
|
||||
val title = it.tags.title?.lowercase() ?: ""
|
||||
val type = when {
|
||||
getCommentaryFilters().any { keyword -> title.contains(keyword) } -> SubtitleType.Commentary
|
||||
getSongFilters().any { keyword -> title.contains(keyword) } -> SubtitleType.Song
|
||||
getClosedCaptionFilters().any { keyword -> title.contains(keyword) } -> SubtitleType.ClosedCaption
|
||||
getSHDFilters().any { keyword -> title.contains(keyword) } -> SubtitleType.SHD
|
||||
else -> SubtitleType.Dialogue
|
||||
}
|
||||
SubtitleItem(it, type)
|
||||
}
|
||||
}
|
||||
|
||||
private fun List<SubtitleItem>.onlySupportedCodecs(): List<SubtitleItem> {
|
||||
return this.filter { it.stream.codec_type in supportedCodecs }
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,75 @@
|
||||
package no.iktdev.mediaprocessing.coordinator.listeners.events
|
||||
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.JsonObject
|
||||
import mu.KotlinLogging
|
||||
import no.iktdev.eventi.events.EventListener
|
||||
import no.iktdev.eventi.models.Event
|
||||
import no.iktdev.mediaprocessing.ffmpeg.data.AudioStream
|
||||
import no.iktdev.mediaprocessing.ffmpeg.data.ParsedMediaStreams
|
||||
import no.iktdev.mediaprocessing.ffmpeg.data.SubtitleStream
|
||||
import no.iktdev.mediaprocessing.ffmpeg.data.VideoStream
|
||||
import no.iktdev.mediaprocessing.shared.common.event_task_contract.events.MediaStreamParsedEvent
|
||||
import no.iktdev.mediaprocessing.shared.common.event_task_contract.events.MediaStreamReadEvent
|
||||
import org.springframework.core.annotation.Order
|
||||
import org.springframework.stereotype.Component
|
||||
|
||||
@Order(4)
|
||||
@Component
|
||||
class MediaParseStreamsListener: EventListener() {
|
||||
val log = KotlinLogging.logger {}
|
||||
|
||||
override fun onEvent(
|
||||
event: Event,
|
||||
history: List<Event>
|
||||
): Event? {
|
||||
if (event !is MediaStreamReadEvent) return null
|
||||
|
||||
val streams = parseStreams(event.data)
|
||||
return MediaStreamParsedEvent(
|
||||
data = streams
|
||||
).derivedOf(event)
|
||||
}
|
||||
|
||||
|
||||
fun parseStreams(data: JsonObject?): ParsedMediaStreams {
|
||||
val ignoreCodecs = listOf("png", "mjpeg")
|
||||
val gson = Gson()
|
||||
return try {
|
||||
val jStreams = data!!.getAsJsonArray("streams")
|
||||
|
||||
val videoStreams = mutableListOf<VideoStream>()
|
||||
val audioStreams = mutableListOf<AudioStream>()
|
||||
val subtitleStreams = mutableListOf<SubtitleStream>()
|
||||
|
||||
for (streamJson in jStreams) {
|
||||
val streamObject = streamJson.asJsonObject
|
||||
if (!streamObject.has("codec_name")) continue
|
||||
val codecName = streamObject.get("codec_name").asString
|
||||
val codecType = streamObject.get("codec_type").asString
|
||||
|
||||
if (codecName in ignoreCodecs) continue
|
||||
|
||||
when (codecType) {
|
||||
"video" -> videoStreams.add(gson.fromJson(streamObject, VideoStream::class.java))
|
||||
"audio" -> audioStreams.add(gson.fromJson(streamObject, AudioStream::class.java))
|
||||
"subtitle" -> subtitleStreams.add(gson.fromJson(streamObject, SubtitleStream::class.java))
|
||||
}
|
||||
}
|
||||
|
||||
val parsedStreams = ParsedMediaStreams(
|
||||
videoStream = videoStreams,
|
||||
audioStream = audioStreams,
|
||||
subtitleStream = subtitleStreams
|
||||
)
|
||||
parsedStreams
|
||||
|
||||
} catch (e: Exception) {
|
||||
"Failed to parse data, its either not a valid json structure or expected and required fields are not present.".also {
|
||||
log.error { it }
|
||||
}
|
||||
throw e
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@ -2,21 +2,36 @@ package no.iktdev.mediaprocessing.coordinator.listeners.events
|
||||
|
||||
import no.iktdev.eventi.events.EventListener
|
||||
import no.iktdev.eventi.models.Event
|
||||
import no.iktdev.mediaprocessing.shared.common.event_task_contract.events.MediaParsedInfoEvent
|
||||
import no.iktdev.mediaprocessing.shared.common.event_task_contract.events.StartProcessingEvent
|
||||
import no.iktdev.mediaprocessing.shared.common.model.MediaType
|
||||
import org.springframework.core.annotation.Order
|
||||
import org.springframework.stereotype.Component
|
||||
import java.io.File
|
||||
|
||||
@Order(2)
|
||||
@Component
|
||||
class MediaEventParsedInfoListener : EventListener() {
|
||||
class MediaParsedInfoListener : EventListener() {
|
||||
override fun onEvent(
|
||||
event: Event,
|
||||
history: List<Event>
|
||||
): Event? {
|
||||
val started = event as? StartProcessingEvent ?: return null
|
||||
val fileName = File(started.data.fileUri).nameWithoutExtension
|
||||
val cleanedTitle = fileName.getCleanedTitle()
|
||||
val file = File(started.data.fileUri)
|
||||
|
||||
return null
|
||||
val filename = file.guessDesiredFileName()
|
||||
val collection = file.getDesiredCollection()
|
||||
val searchTitles = file.guessSearchableTitle()
|
||||
val mediaType = file.guessMovieOrSeries()
|
||||
|
||||
return MediaParsedInfoEvent(
|
||||
MediaParsedInfoEvent.ParsedData(
|
||||
parsedFileName = filename,
|
||||
parsedCollection = collection,
|
||||
parsedSearchTitles = searchTitles,
|
||||
mediaType = mediaType
|
||||
)
|
||||
).derivedOf(event)
|
||||
}
|
||||
|
||||
fun String.getCleanedTitle(): String {
|
||||
@ -47,10 +62,6 @@ class MediaEventParsedInfoListener : EventListener() {
|
||||
fun String.noExtraSpaces() = Regex("\\s{2,}").replace(this, " ")
|
||||
fun String.fullTrim() = this.trim('.', ',', ' ', '_', '-')
|
||||
|
||||
enum class MediaType {
|
||||
Movie,
|
||||
Serie
|
||||
}
|
||||
|
||||
fun File.guessMovieOrSeries(): MediaType {
|
||||
val name = this.nameWithoutExtension.lowercase()
|
||||
@ -0,0 +1,32 @@
|
||||
package no.iktdev.mediaprocessing.coordinator.listeners.events
|
||||
|
||||
import no.iktdev.eventi.events.EventListener
|
||||
import no.iktdev.eventi.models.Event
|
||||
import no.iktdev.mediaprocessing.shared.common.event_task_contract.events.MediaParsedInfoEvent
|
||||
import no.iktdev.mediaprocessing.shared.common.event_task_contract.events.MediaStreamReadTaskCreatedEvent
|
||||
import no.iktdev.mediaprocessing.shared.common.event_task_contract.events.StartProcessingEvent
|
||||
import no.iktdev.mediaprocessing.shared.common.event_task_contract.tasks.MediaReadTask
|
||||
import no.iktdev.mediaprocessing.shared.common.stores.TaskStore
|
||||
import org.springframework.core.annotation.Order
|
||||
import org.springframework.stereotype.Component
|
||||
|
||||
@Order(3)
|
||||
@Component
|
||||
class MediaReadStreamsTaskCreatedListener: EventListener() {
|
||||
override fun onEvent(
|
||||
event: Event,
|
||||
history: List<Event>
|
||||
): Event? {
|
||||
if (event !is MediaParsedInfoEvent) return null
|
||||
val startEvent = history.lastOrNull { it is StartProcessingEvent } as? StartProcessingEvent
|
||||
?: return null
|
||||
|
||||
|
||||
val readTask = MediaReadTask(
|
||||
fileUri = startEvent.data.fileUri
|
||||
)
|
||||
|
||||
TaskStore.persist(readTask)
|
||||
return null // Create task instead of event
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,74 @@
|
||||
package no.iktdev.mediaprocessing.coordinator.listeners.events
|
||||
|
||||
import no.iktdev.eventi.events.EventListener
|
||||
import no.iktdev.eventi.models.Event
|
||||
import no.iktdev.mediaprocessing.ffmpeg.data.AudioStream
|
||||
import no.iktdev.mediaprocessing.ffmpeg.data.VideoStream
|
||||
import no.iktdev.mediaprocessing.shared.common.event_task_contract.events.MediaStreamParsedEvent
|
||||
import no.iktdev.mediaprocessing.shared.common.event_task_contract.events.MediaTracksEncodeSelectedEvent
|
||||
import org.springframework.stereotype.Component
|
||||
|
||||
@Component
|
||||
class MediaSelectEncodeTracksListener: EventListener() {
|
||||
|
||||
fun getAudioLanguagePreference(): List<String> {
|
||||
return listOf("jpn")
|
||||
}
|
||||
|
||||
override fun onEvent(
|
||||
event: Event,
|
||||
history: List<Event>
|
||||
): Event? {
|
||||
val useEvent = event as? MediaStreamParsedEvent ?: return null
|
||||
|
||||
|
||||
val videoTrackIndex = getVideoTrackToUse(useEvent.data.videoStream)
|
||||
val audioDefaultTrack = getAudioDefaultTrackToUse(useEvent.data.audioStream)
|
||||
val audioExtendedTrack = getAudioExtendedTrackToUse(useEvent.data.audioStream, selectedDefaultTrack = audioDefaultTrack)
|
||||
|
||||
return MediaTracksEncodeSelectedEvent(
|
||||
selectedVideoTrack = videoTrackIndex,
|
||||
selectedAudioTrack = audioDefaultTrack,
|
||||
selectedAudioExtendedTrack = audioExtendedTrack
|
||||
).derivedOf(event)
|
||||
}
|
||||
|
||||
private fun getAudioExtendedTrackToUse(audioStream: List<AudioStream>, selectedDefaultTrack: Int): Int? {
|
||||
val durationFiltered = audioStream.filterOnPreferredLanguage()
|
||||
.filter { (it.duration_ts ?: 0) > 0 }
|
||||
.filter { it.channels > 2 }
|
||||
.filter { it.index != selectedDefaultTrack }
|
||||
val selected = durationFiltered.firstOrNull() ?: return null
|
||||
return audioStream.indexOf(selected)
|
||||
}
|
||||
|
||||
/**
|
||||
* Select the default audio track to use for encoding.
|
||||
* If no default track is found, select the first audio track.
|
||||
* If audio track with preferred language (e.g., "nor") is not found, selects "eng" or first available.
|
||||
*/
|
||||
private fun getAudioDefaultTrackToUse(audioStream: List<AudioStream>): Int {
|
||||
val durationFiltered = audioStream.filterOnPreferredLanguage()
|
||||
.filter { (it.duration_ts ?: 0) > 0 }
|
||||
|
||||
val selected = durationFiltered
|
||||
.filter { it.channels == 2 }.ifEmpty { durationFiltered }
|
||||
.maxByOrNull { it.index } ?: audioStream.minByOrNull { it.index } ?: durationFiltered.firstOrNull()
|
||||
|
||||
return audioStream.indexOf(selected)
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters audio streams based on preferred languages.
|
||||
* If no streams match the preferred languages, returns the original list.
|
||||
*/
|
||||
private fun List<AudioStream>.filterOnPreferredLanguage(): List<AudioStream> {
|
||||
return this.filter { it.tags.language in getAudioLanguagePreference() }.ifEmpty { this }
|
||||
}
|
||||
|
||||
private fun getVideoTrackToUse(streams: List<VideoStream>): Int {
|
||||
val selectStream = streams.filter { (it.duration_ts ?: 0) > 0 }
|
||||
.maxByOrNull { it.duration_ts ?: 0 } ?: streams.minByOrNull { it.index } ?: throw Exception("No video streams found")
|
||||
return streams.indexOf(selectStream)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,44 @@
|
||||
package no.iktdev.mediaprocessing.coordinator.listeners.events
|
||||
|
||||
import no.iktdev.eventi.events.EventListener
|
||||
import no.iktdev.eventi.models.Event
|
||||
import no.iktdev.mediaprocessing.ffmpeg.data.SubtitleStream
|
||||
import no.iktdev.mediaprocessing.shared.common.event_task_contract.events.MediaTracksDetermineSubtitleTypeEvent
|
||||
import no.iktdev.mediaprocessing.shared.common.event_task_contract.events.MediaTracksExtractSelectedEvent
|
||||
import no.iktdev.mediaprocessing.shared.common.model.SubtitleType
|
||||
import org.springframework.stereotype.Component
|
||||
|
||||
@Component
|
||||
class MediaSelectExtractTracksListener: EventListener() {
|
||||
|
||||
fun limitToLanguages(): Set<String> {
|
||||
return emptySet()
|
||||
}
|
||||
|
||||
fun useTypes(): Set<SubtitleType> {
|
||||
return setOf(SubtitleType.Dialogue)
|
||||
}
|
||||
|
||||
override fun onEvent(
|
||||
event: Event,
|
||||
history: List<Event>
|
||||
): Event? {
|
||||
val useEvent = event as? MediaTracksDetermineSubtitleTypeEvent ?: return null
|
||||
|
||||
val filtered = useEvent.subtitleTrackItems
|
||||
.filter { it.type in useTypes() }
|
||||
.map { it.stream }
|
||||
.filterOnPreferredLanguage()
|
||||
|
||||
return MediaTracksExtractSelectedEvent(
|
||||
selectedSubtitleTracks = filtered.map { it.index }
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
private fun List<SubtitleStream>.filterOnPreferredLanguage(): List<SubtitleStream> {
|
||||
val languages = limitToLanguages()
|
||||
if (languages.isEmpty()) return this
|
||||
return this.filter { it.tags.language != null }.filter { languages.contains(it.tags.language) }
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,34 @@
|
||||
package no.iktdev.mediaprocessing.coordinator.listeners.events
|
||||
|
||||
import no.iktdev.eventi.events.EventListener
|
||||
import no.iktdev.eventi.models.Event
|
||||
import no.iktdev.mediaprocessing.shared.common.event_task_contract.events.FileReadyEvent
|
||||
import no.iktdev.mediaprocessing.shared.common.event_task_contract.events.OperationType
|
||||
import no.iktdev.mediaprocessing.shared.common.event_task_contract.events.ProcessFlow
|
||||
import no.iktdev.mediaprocessing.shared.common.event_task_contract.events.StartData
|
||||
import no.iktdev.mediaprocessing.shared.common.event_task_contract.events.StartProcessingEvent
|
||||
import org.springframework.core.annotation.Order
|
||||
import org.springframework.stereotype.Component
|
||||
|
||||
@Order(1)
|
||||
@Component
|
||||
class StartedListener : EventListener() {
|
||||
override fun onEvent(
|
||||
event: Event,
|
||||
history: List<Event>
|
||||
): Event? {
|
||||
val useEvent = event as? FileReadyEvent ?: return null
|
||||
|
||||
return StartProcessingEvent(
|
||||
data = StartData(
|
||||
flow = ProcessFlow.Auto,
|
||||
fileUri = useEvent.data.fileUri,
|
||||
operation = setOf(
|
||||
OperationType.Extract,
|
||||
OperationType.Convert,
|
||||
OperationType.Encode
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,9 @@
|
||||
package no.iktdev.mediaprocessing.coordinator.listeners.tasks
|
||||
|
||||
import no.iktdev.eventi.tasks.TaskListener
|
||||
import no.iktdev.eventi.tasks.TaskType
|
||||
import no.iktdev.mediaprocessing.ffmpeg.FFprobe
|
||||
|
||||
abstract class FfprobeTaskListener(taskType: TaskType): TaskListener(taskType) {
|
||||
abstract fun getFfprobe(): FFprobe
|
||||
}
|
||||
@ -0,0 +1,49 @@
|
||||
package no.iktdev.mediaprocessing.coordinator.listeners.tasks
|
||||
|
||||
import mu.KotlinLogging
|
||||
import no.iktdev.eventi.models.Event
|
||||
import no.iktdev.eventi.models.Task
|
||||
import no.iktdev.eventi.tasks.TaskType
|
||||
import no.iktdev.mediaprocessing.coordinator.CoordinatorEnv
|
||||
import no.iktdev.mediaprocessing.ffmpeg.FFprobe
|
||||
import no.iktdev.mediaprocessing.shared.common.event_task_contract.events.MediaStreamReadEvent
|
||||
import no.iktdev.mediaprocessing.shared.common.event_task_contract.tasks.MediaReadTask
|
||||
import org.springframework.stereotype.Component
|
||||
import java.util.UUID
|
||||
|
||||
@Component
|
||||
class MediaStreamReadTaskListener: FfprobeTaskListener(TaskType.CPU_INTENSIVE) {
|
||||
val log = KotlinLogging.logger {}
|
||||
|
||||
override fun getWorkerId(): String {
|
||||
return "${this::class.java.simpleName}-${TaskType.CPU_INTENSIVE}-${UUID.randomUUID()}"
|
||||
}
|
||||
|
||||
override fun supports(task: Task): Boolean {
|
||||
return task is MediaReadTask
|
||||
}
|
||||
|
||||
override suspend fun onTask(task: Task): Event? {
|
||||
val pickedTask = task as? MediaReadTask ?: return null
|
||||
try {
|
||||
val probeResult = getFfprobe()
|
||||
.readJsonStreams(pickedTask.fileUri)
|
||||
|
||||
val result = probeResult.data
|
||||
assert(result != null) { "No data returned from ffprobe for ${pickedTask.fileUri}" }
|
||||
|
||||
return MediaStreamReadEvent(data = result!!).producedFrom(task)
|
||||
|
||||
} catch (e: Exception) {
|
||||
log.error(e) { "Error reading media streams for ${pickedTask.fileUri}" }
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
override fun getFfprobe(): FFprobe {
|
||||
return JsonFfinfo(CoordinatorEnv.ffprobe)
|
||||
}
|
||||
|
||||
class JsonFfinfo(override val executable: String): FFprobe() {
|
||||
}
|
||||
}
|
||||
@ -1,8 +1,6 @@
|
||||
package no.iktdev.mediaprocessing
|
||||
|
||||
import no.iktdev.mediaprocessing.shared.common.contract.Events
|
||||
import no.iktdev.mediaprocessing.shared.common.contract.data.Event
|
||||
import no.iktdev.mediaprocessing.shared.common.contract.jsonToEvent
|
||||
import no.iktdev.eventi.models.Event
|
||||
import org.json.JSONArray
|
||||
|
||||
enum class Files(val fileName: String) {
|
||||
@ -28,9 +26,9 @@ fun Files.databaseJsonToEvents(): List<Event> {
|
||||
val obj = dataArray.getJSONObject(x)
|
||||
val eventType = obj.getString("event")
|
||||
val dataString = obj.getString("data")
|
||||
dataString.jsonToEvent(eventType).also {
|
||||
events.add(it)
|
||||
}
|
||||
// dataString.jsonToEvent(eventType).also {
|
||||
// events.add(it)
|
||||
// }
|
||||
}
|
||||
return events
|
||||
}
|
||||
|
||||
@ -1,59 +0,0 @@
|
||||
package no.iktdev.mediaprocessing.coordinator
|
||||
|
||||
import no.iktdev.eventi.data.EventMetadata
|
||||
import no.iktdev.eventi.data.EventStatus
|
||||
import no.iktdev.mediaprocessing.coordinator.tasksV2.listeners.MetadataWaitOrDefaultTaskListener
|
||||
import no.iktdev.mediaprocessing.shared.common.contract.data.*
|
||||
import no.iktdev.mediaprocessing.shared.common.contract.dto.OperationEvents
|
||||
import java.util.UUID
|
||||
|
||||
val defaultReferenceId = UUID.randomUUID().toString()
|
||||
|
||||
fun defaultStartEvent(): MediaProcessStartEvent {
|
||||
return MediaProcessStartEvent(
|
||||
metadata = defaultMetadata(),
|
||||
data = StartEventData(
|
||||
operations = listOf(OperationEvents.ENCODE, OperationEvents.EXTRACT, OperationEvents.CONVERT),
|
||||
file = "DummyTestFile.mkv"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun defaultBaseInfoEvent(): BaseInfoEvent {
|
||||
return BaseInfoEvent(
|
||||
metadata = defaultMetadata(),
|
||||
data = BaseInfo(
|
||||
title = "Potetmos",
|
||||
sanitizedName = "Potetmos mannen",
|
||||
searchTitles = listOf("Potetmos mannen")
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun metadataSearchTimedOutEvent(): MediaMetadataReceivedEvent {
|
||||
return MediaMetadataReceivedEvent(
|
||||
metadata = defaultMetadata()
|
||||
.copy(status = EventStatus.Skipped)
|
||||
.copy(source = MetadataWaitOrDefaultTaskListener::class.java.simpleName),
|
||||
data = null
|
||||
)
|
||||
}
|
||||
|
||||
fun defaultMetadataSearchEvent(): MediaMetadataReceivedEvent {
|
||||
return MediaMetadataReceivedEvent(
|
||||
metadata = defaultMetadata(),
|
||||
data = pyMetadata(
|
||||
title = "Potetmos",
|
||||
type = "movie",
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
fun defaultMetadata(): EventMetadata {
|
||||
return EventMetadata(
|
||||
referenceId = defaultReferenceId,
|
||||
status = EventStatus.Success,
|
||||
source = "TestData"
|
||||
)
|
||||
}
|
||||
@ -1,14 +1,14 @@
|
||||
package no.iktdev.mediaprocessing.coordinator.events
|
||||
|
||||
import no.iktdev.mediaprocessing.coordinator.listeners.events.MediaEventParsedInfoListener
|
||||
import no.iktdev.mediaprocessing.shared.common.parsing.FileNameParser
|
||||
import no.iktdev.mediaprocessing.coordinator.listeners.events.MediaParsedInfoListener
|
||||
import no.iktdev.mediaprocessing.shared.common.model.MediaType
|
||||
import org.assertj.core.api.Assertions.assertThat
|
||||
import org.junit.jupiter.api.Named
|
||||
import org.junit.jupiter.params.ParameterizedTest
|
||||
import org.junit.jupiter.params.provider.MethodSource
|
||||
import java.io.File
|
||||
|
||||
class MediaEventParsedInfoListenerTest : MediaEventParsedInfoListener() {
|
||||
class MediaParsedInfoListenerTest : MediaParsedInfoListener() {
|
||||
|
||||
|
||||
@MethodSource("parsedInfoTest")
|
||||
@ -1,168 +0,0 @@
|
||||
package no.iktdev.mediaprocessing.coordinator.reader
|
||||
/*
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.JsonObject
|
||||
import no.iktdev.mediaprocessing.shared.kafka.core.CoordinatorProducer
|
||||
import no.iktdev.mediaprocessing.shared.kafka.dto.events_result.ReaderPerformed
|
||||
import no.iktdev.mediaprocessing.shared.kafka.dto.Status
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
import org.junit.jupiter.api.Assertions.*
|
||||
import org.junit.jupiter.api.Named
|
||||
import org.junit.jupiter.params.ParameterizedTest
|
||||
import org.junit.jupiter.params.provider.MethodSource
|
||||
import org.mockito.Mock
|
||||
import org.skyscreamer.jsonassert.JSONAssert
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
|
||||
class ParseVideoFileStreamsTest {
|
||||
|
||||
@Autowired
|
||||
private lateinit var testBase: KafkaTestBase
|
||||
|
||||
@Mock
|
||||
lateinit var coordinatorProducer: CoordinatorProducer
|
||||
|
||||
val parseVideoStreams = ParseVideoFileStreams(coordinatorProducer)
|
||||
|
||||
@ParameterizedTest
|
||||
@MethodSource("streams")
|
||||
fun parseStreams(data: TestInfo) {
|
||||
val gson = Gson()
|
||||
val converted = gson.fromJson(data.input, JsonObject::class.java)
|
||||
val result = parseVideoStreams.parseStreams(ReaderPerformed(
|
||||
Status.COMPLETED,
|
||||
file = "ignore",
|
||||
output = converted
|
||||
))
|
||||
JSONAssert.assertEquals(
|
||||
data.expected,
|
||||
gson.toJson(result),
|
||||
false
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
data class TestInfo(
|
||||
val input: String,
|
||||
val expected: String
|
||||
)
|
||||
|
||||
companion object {
|
||||
@JvmStatic
|
||||
fun streams(): List<Named<TestInfo>> {
|
||||
return listOf(
|
||||
Named.of(
|
||||
"Top Clown streams", TestInfo(
|
||||
"""
|
||||
{
|
||||
"streams": [
|
||||
{
|
||||
"index": 0,
|
||||
"codec_name": "h264",
|
||||
"codec_long_name": "H.264 / AVC / MPEG-4 AVC / MPEG-4 part 10",
|
||||
"profile": "Main",
|
||||
"codec_type": "video",
|
||||
"codec_tag_string": "[0][0][0][0]",
|
||||
"codec_tag": "0x0000",
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"coded_width": 1920,
|
||||
"coded_height": 1080,
|
||||
"closed_captions": 0,
|
||||
"film_grain": 0,
|
||||
"has_b_frames": 0,
|
||||
"sample_aspect_ratio": "1:1",
|
||||
"display_aspect_ratio": "16:9",
|
||||
"pix_fmt": "yuv420p",
|
||||
"level": 40,
|
||||
"chroma_location": "left",
|
||||
"field_order": "progressive",
|
||||
"refs": 1,
|
||||
"is_avc": "true",
|
||||
"nal_length_size": "4",
|
||||
"r_frame_rate": "24000/1001",
|
||||
"avg_frame_rate": "24000/1001",
|
||||
"time_base": "1/1000",
|
||||
"start_pts": 0,
|
||||
"start_time": "0.000000",
|
||||
"bits_per_raw_sample": "8",
|
||||
"extradata_size": 55,
|
||||
"disposition": {
|
||||
"default": 1,
|
||||
"dub": 0,
|
||||
"original": 0,
|
||||
"comment": 0,
|
||||
"lyrics": 0,
|
||||
"karaoke": 0,
|
||||
"forced": 0,
|
||||
"hearing_impaired": 0,
|
||||
"visual_impaired": 0,
|
||||
"clean_effects": 0,
|
||||
"attached_pic": 0,
|
||||
"timed_thumbnails": 0,
|
||||
"non_diegetic": 0,
|
||||
"captions": 0,
|
||||
"descriptions": 0,
|
||||
"metadata": 0,
|
||||
"dependent": 0,
|
||||
"still_image": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"index": 1,
|
||||
"codec_name": "aac",
|
||||
"codec_long_name": "AAC (Advanced Audio Coding)",
|
||||
"profile": "HE-AAC",
|
||||
"codec_type": "audio",
|
||||
"codec_tag_string": "[0][0][0][0]",
|
||||
"codec_tag": "0x0000",
|
||||
"sample_fmt": "fltp",
|
||||
"sample_rate": "48000",
|
||||
"channels": 6,
|
||||
"channel_layout": "5.1",
|
||||
"bits_per_sample": 0,
|
||||
"initial_padding": 0,
|
||||
"r_frame_rate": "0/0",
|
||||
"avg_frame_rate": "0/0",
|
||||
"time_base": "1/1000",
|
||||
"start_pts": 0,
|
||||
"start_time": "0.000000",
|
||||
"extradata_size": 2,
|
||||
"disposition": {
|
||||
"default": 1,
|
||||
"dub": 0,
|
||||
"original": 0,
|
||||
"comment": 0,
|
||||
"lyrics": 0,
|
||||
"karaoke": 0,
|
||||
"forced": 0,
|
||||
"hearing_impaired": 0,
|
||||
"visual_impaired": 0,
|
||||
"clean_effects": 0,
|
||||
"attached_pic": 0,
|
||||
"timed_thumbnails": 0,
|
||||
"non_diegetic": 0,
|
||||
"captions": 0,
|
||||
"descriptions": 0,
|
||||
"metadata": 0,
|
||||
"dependent": 0,
|
||||
"still_image": 0
|
||||
},
|
||||
"tags": {
|
||||
"language": "eng"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
""".trimIndent(),
|
||||
"""
|
||||
|
||||
""".trimIndent()
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}*/
|
||||
@ -1,40 +0,0 @@
|
||||
package no.iktdev.mediaprocessing.coordinator.tasks.event
|
||||
|
||||
import no.iktdev.mediaprocessing.coordinator.tasksV2.listeners.MediaOutInformationTaskListener
|
||||
import org.assertj.core.api.Assertions.assertThat
|
||||
import org.junit.jupiter.api.Test
|
||||
/*
|
||||
class MetadataAndBaseInfoToFileOutTest {
|
||||
|
||||
fun testData(): String {
|
||||
return """
|
||||
[
|
||||
{"type":"header","version":"5.2.1","comment":"Export to JSON plugin for PHPMyAdmin"},
|
||||
{"type":"database","name":"eventsV2"},
|
||||
{"type":"table","name":"events","database":"eventsV2","data":
|
||||
[
|
||||
{"id":"9","referenceId":"f015ad8a-8210-4040-993b-bdaa5bd25d80","eventId":"3cea9a98-2e65-4e70-96bc-4b6933c06af7","event":"event:media-read-base-info:performed","data":"{\"status\":\"COMPLETED\",\"title\":\"Psycho-Pass Movie\",\"sanitizedName\":\"Psycho-Pass Movie - Providence\",\"derivedFromEventId\":\"62408248-d457-4f4d-a2c7-9b17e5701336\"}","created":"2024-04-15 22:24:07.406088"},
|
||||
{"id":"19","referenceId":"f015ad8a-8210-4040-993b-bdaa5bd25d80","eventId":"0edaa265-fc85-41bc-952a-acb21771feb9","event":"event:media-metadata-search:performed","data":"{\"status\":\"COMPLETED\",\"data\":{\"title\":\"Psycho-Pass Movie: Providence\",\"altTitle\":[],\"cover\":\"https:\/\/cdn.myanimelist.net\/images\/anime\/1244\/134653.jpg\",\"type\":\"movie\",\"summary\":[{\"summary\":\"In 2113, the Ministry of Foreign Affairs (MFA) dissolved their secret paramilitary unit known as the Peacebreakers. However, the squad disappeared, and their activities remained a mystery. Five years later, the Peacebreakers resurface when they murder Milcia Stronskaya, a scientist in possession of highly classified documents essential to the future of the Sybil System—Japan\\u0027s surveillance structure that detects potential criminals in society. To investigate the incident and prepare for a clash against the Peacebreakers in coordination with the MFA, the chief of the Public Safety Bureau decides to recruit the former Enforcer Shinya Kougami back into the force. Having defected years ago, Kougami currently works for the MFA under Frederica Hanashiro\\u0027s command. Kougami\\u0027s return creates tensions between him and his former colleagues Akane Tsunemori and Nobuchika Ginoza, but they must set aside their past grudges to focus on ensuring the security of the Sybil System. [Written by MAL Rewrite]\",\"language\":\"eng\"}],\"genres\":[\"Action\",\"Mystery\",\"Sci-Fi\",\"Suspense\"]}}","created":"2024-04-15 22:24:18.339106"}
|
||||
]
|
||||
}
|
||||
]
|
||||
""".trimIndent()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testVideoData() {
|
||||
val pmdj = PersistentMessageFromJsonDump(testData())
|
||||
val events = pmdj.getPersistentMessages()
|
||||
|
||||
val baseInfo = events.lastOrSuccessOf(KafkaEvents.EventMediaReadBaseInfoPerformed) { it.data is BaseInfoPerformed }?.data as BaseInfoPerformed
|
||||
val meta = events.lastOrSuccessOf(KafkaEvents.EventMediaMetadataSearchPerformed) { it.data is MetadataPerformed }?.data as MetadataPerformed?
|
||||
|
||||
val pm = MediaOutInformationTaskListener.ProcessMediaInfoAndMetadata(baseInfo, meta)
|
||||
|
||||
|
||||
val vi = pm.getVideoPayload()
|
||||
|
||||
assertThat(vi).isNotNull()
|
||||
}
|
||||
|
||||
}*/
|
||||
@ -1,80 +0,0 @@
|
||||
package no.iktdev.mediaprocessing.coordinator.tasks.event.ffmpeg
|
||||
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import no.iktdev.mediaprocessing.coordinator.tasksV2.mapping.streams.AudioArguments
|
||||
import no.iktdev.mediaprocessing.shared.common.contract.ffmpeg.AudioPreference
|
||||
import no.iktdev.mediaprocessing.shared.common.contract.ffmpeg.AudioStream
|
||||
import no.iktdev.mediaprocessing.shared.common.contract.ffmpeg.ParsedMediaStreams
|
||||
import org.assertj.core.api.Assertions.assertThat
|
||||
import org.junit.jupiter.api.Assertions.*
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class EncodeArgumentCreatorTaskTest {
|
||||
|
||||
@Test
|
||||
fun verifyThatEacStreamGetsCorrectArguments() {
|
||||
val audio = AudioArguments(
|
||||
audioStream = audioStreamsEAC().first(),
|
||||
allStreams = ParsedMediaStreams(listOf(), audioStreamsEAC(), listOf()),
|
||||
preference = AudioPreference(preserveChannels = true, forceStereo = false, convertToEac3OnUnsupportedSurround = true)
|
||||
)
|
||||
val arguments = audio.getAudioArguments()
|
||||
assertThat(arguments.codecParameters).isEqualTo(listOf("-acodec", "copy"))
|
||||
|
||||
}
|
||||
|
||||
private fun audioStreamsEAC(): List<AudioStream> {
|
||||
//language=json
|
||||
val streams = """
|
||||
|
||||
[
|
||||
{
|
||||
"index": 1,
|
||||
"codec_name": "eac3",
|
||||
"codec_long_name": "ATSC A/52B (AC-3, E-AC-3)",
|
||||
"codec_type": "audio",
|
||||
"codec_tag_string": "[0][0][0][0]",
|
||||
"codec_tag": "0x0000",
|
||||
"r_frame_rate": "0/0",
|
||||
"avg_frame_rate": "0/0",
|
||||
"time_base": "1/1000",
|
||||
"start_pts": 0,
|
||||
"start_time": "0.000000",
|
||||
"disposition": {
|
||||
"default": 1,
|
||||
"dub": 0,
|
||||
"original": 0,
|
||||
"comment": 0,
|
||||
"lyrics": 0,
|
||||
"karaoke": 0,
|
||||
"forced": 0,
|
||||
"hearing_impaired": 0,
|
||||
"visual_impaired": 0,
|
||||
"clean_effects": 0,
|
||||
"attached_pic": 0,
|
||||
"timed_thumbnails": 0
|
||||
},
|
||||
"tags": {
|
||||
"BPS": "256000",
|
||||
"DURATION": "01:09:55.296000000",
|
||||
"NUMBER_OF_FRAMES": "131103",
|
||||
"NUMBER_OF_BYTES": "134249472",
|
||||
"_STATISTICS_WRITING_APP": "64-bit",
|
||||
"_STATISTICS_TAGS": "BPS DURATION NUMBER_OF_FRAMES NUMBER_OF_BYTES",
|
||||
"language": "eng"
|
||||
},
|
||||
"sample_fmt": "fltp",
|
||||
"sample_rate": "48000",
|
||||
"channels": 6,
|
||||
"bits_per_sample": 0
|
||||
}
|
||||
]
|
||||
|
||||
""".trimIndent()
|
||||
val type = object : TypeToken<List<AudioStream>>() {}.type
|
||||
return Gson().fromJson<List<AudioStream>>(streams, type)
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@ -1,77 +0,0 @@
|
||||
package no.iktdev.mediaprocessing.coordinator.tasksV2.listeners
|
||||
|
||||
import no.iktdev.eventi.data.referenceId
|
||||
import no.iktdev.mediaprocessing.Files
|
||||
import no.iktdev.mediaprocessing.coordinator.defaultMetadata
|
||||
import no.iktdev.mediaprocessing.coordinator.defaultStartEvent
|
||||
import no.iktdev.mediaprocessing.databaseJsonToEvents
|
||||
import no.iktdev.mediaprocessing.shared.common.contract.Events
|
||||
import no.iktdev.mediaprocessing.shared.common.contract.data.Event
|
||||
import no.iktdev.mediaprocessing.shared.common.contract.data.StartEventData
|
||||
import no.iktdev.mediaprocessing.shared.common.contract.dto.OperationEvents
|
||||
import org.assertj.core.api.Assertions.assertThat
|
||||
import org.junit.jupiter.api.DisplayName
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
|
||||
class ConvertWorkTaskListenerTest {
|
||||
|
||||
@Test
|
||||
@DisplayName("""
|
||||
When incoming event is of Start Event, and there is multiple operations,
|
||||
Validation check should fail
|
||||
""")
|
||||
fun validate_shouldIProcessAndHandleEvent1() {
|
||||
val listener = ConvertWorkTaskListener()
|
||||
val events = listOf<Event>(defaultStartEvent())
|
||||
val result = listener.shouldIProcessAndHandleEvent(defaultStartEvent(), events)
|
||||
assertThat(result).isFalse()
|
||||
}
|
||||
@Test
|
||||
@DisplayName("""
|
||||
When incoming event is of Start Event but start is missing form list, and there is multiple operations,
|
||||
Validation check should fail
|
||||
""")
|
||||
fun validate_shouldIProcessAndHandleEvent2() {
|
||||
val listener = ConvertWorkTaskListener()
|
||||
val result = listener.shouldIProcessAndHandleEvent(defaultStartEvent(), emptyList())
|
||||
assertThat(result).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("""
|
||||
When incoming event is of Start Event and single operation is Convert,
|
||||
Validation check should succeed
|
||||
""")
|
||||
fun validate_shouldIProcessAndHandleEvent3() {
|
||||
val listener = ConvertWorkTaskListener()
|
||||
val startedEvent = defaultStartEvent().copy(
|
||||
data = StartEventData(
|
||||
operations = listOf(OperationEvents.CONVERT),
|
||||
file = "DummyTestFile.ass"
|
||||
)
|
||||
)
|
||||
val events = listOf<Event>(startedEvent)
|
||||
val result = listener.shouldIProcessAndHandleEvent(startedEvent, events)
|
||||
assertThat(result).isTrue()
|
||||
}
|
||||
|
||||
|
||||
|
||||
@Test
|
||||
fun validateParsingOfEvents() {
|
||||
val content = Files.MultipleLanguageBased.databaseJsonToEvents()
|
||||
assertThat(content).isNotEmpty()
|
||||
val referenceId = content.firstOrNull()?.referenceId()
|
||||
assertThat(referenceId).isNotNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun validateCreationOfConvertTasks() {
|
||||
val listener: ConvertWorkTaskListener = ConvertWorkTaskListener()
|
||||
val content = Files.MultipleLanguageBased.databaseJsonToEvents().filter { it.eventType in listOf( Events.ExtractTaskCompleted, Events.ProcessStarted, Events.ConvertTaskCreated, Events.ConvertTaskCompleted) }
|
||||
assertThat(listener).isNotNull()
|
||||
val success = content.map { listener.shouldIProcessAndHandleEvent(it, content) to it }
|
||||
assertThat(success.filter { it.first }.size).isGreaterThan(3)
|
||||
}
|
||||
}
|
||||
@ -1,52 +0,0 @@
|
||||
package no.iktdev.mediaprocessing.coordinator.tasksV2.listeners
|
||||
|
||||
import no.iktdev.mediaprocessing.coordinator.defaultBaseInfoEvent
|
||||
import no.iktdev.mediaprocessing.coordinator.defaultMetadataSearchEvent
|
||||
import no.iktdev.mediaprocessing.coordinator.defaultStartEvent
|
||||
import no.iktdev.mediaprocessing.coordinator.metadataSearchTimedOutEvent
|
||||
import no.iktdev.mediaprocessing.shared.common.contract.data.Event
|
||||
import org.junit.jupiter.api.Assertions.*
|
||||
import org.junit.jupiter.api.DisplayName
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class MetadataWaitOrDefaultTaskListenerTest {
|
||||
|
||||
@Test
|
||||
@DisplayName("""
|
||||
When incoming event is of base name, and there is no search performed,
|
||||
Validation check should proceed
|
||||
""")
|
||||
fun validate_shouldIProcessAndHandleEvent1() {
|
||||
val listener = MetadataWaitOrDefaultTaskListener()
|
||||
val events = listOf<Event>(defaultStartEvent(), defaultBaseInfoEvent())
|
||||
val result = listener.shouldIProcessAndHandleEvent(incomingEvent = events.last(), events)
|
||||
assertTrue(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("""
|
||||
When incoming event is of MetadataReceivedEvent,
|
||||
And timeout listener is the origin,
|
||||
Then validation should abort
|
||||
""")
|
||||
fun validate_shouldIProcessAndHandleEvent2() {
|
||||
val listener = MetadataWaitOrDefaultTaskListener()
|
||||
val events = listOf<Event>(defaultStartEvent(), defaultBaseInfoEvent(), metadataSearchTimedOutEvent())
|
||||
val result = listener.shouldIProcessAndHandleEvent(incomingEvent = events.last(), events)
|
||||
assertFalse(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("""
|
||||
When incoming event is of MetadataReceivedEvent,
|
||||
And metadata service has produced the event,
|
||||
Then validation should allow, due to cleanup
|
||||
""")
|
||||
fun validate_shouldIProcessAndHandleEvent3() {
|
||||
val listener = MetadataWaitOrDefaultTaskListener()
|
||||
val events = listOf<Event>(defaultStartEvent(), defaultBaseInfoEvent(), defaultMetadataSearchEvent())
|
||||
val result = listener.shouldIProcessAndHandleEvent(incomingEvent = events.last(), events)
|
||||
assertTrue(result)
|
||||
}
|
||||
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@ -1,31 +0,0 @@
|
||||
package no.iktdev.mediaprocessing.coordinator.tasksV2.mapping
|
||||
|
||||
import no.iktdev.mediaprocessing.shared.common.contract.Events
|
||||
import no.iktdev.mediaprocessing.shared.common.contract.data.MediaFileStreamsParsedEvent
|
||||
import no.iktdev.mediaprocessing.shared.common.contract.data.az
|
||||
import no.iktdev.mediaprocessing.shared.common.contract.ffmpeg.AudioPreference
|
||||
import no.iktdev.mediaprocessing.shared.common.contract.ffmpeg.EncodingPreference
|
||||
import no.iktdev.mediaprocessing.shared.common.contract.ffmpeg.VideoPreference
|
||||
import no.iktdev.mediaprocessing.shared.common.contract.jsonToEvent
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class EncodeWorkArgumentsMappingTest {
|
||||
|
||||
@Test
|
||||
fun parse() {
|
||||
val event = data.jsonToEvent(Events.StreamParsed.name)
|
||||
val parser = EncodeWorkArgumentsMapping(
|
||||
"potato.mkv",
|
||||
"potato.mp4",
|
||||
event.az<MediaFileStreamsParsedEvent>()!!.data!!,
|
||||
EncodingPreference(VideoPreference(), AudioPreference())
|
||||
)
|
||||
val result = parser.getArguments()
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
val data = """
|
||||
{"metadata":{"derivedFromEventId":"308267f3-6eee-4250-9dc4-0f54edb1a120","eventId":"51970dfb-790d-48e4-a37e-ae0915bb1b70","referenceId":"d8be4d8f-64c2-4df1-aed1-e66cdc7163b4","status":"Success","created":"2024-07-19T18:45:31.956816095"},"data":{"videoStream":[{"index":0,"codec_name":"hevc","codec_long_name":"H.265 / HEVC (High Efficiency Video Coding)","codec_type":"video","codec_tag_string":"[0][0][0][0]","codec_tag":"0x0000","r_frame_rate":"24000/1001","avg_frame_rate":"24000/1001","time_base":"1/1000","start_pts":0,"start_time":"0.000000","disposition":{"default":1,"dub":0,"original":0,"comment":0,"lyrics":0,"karaoke":0,"forced":0,"hearing_impaired":0,"visual_impaired":0,"clean_effects":0,"attached_pic":0,"timed_thumbnails":0},"tags":{"title":"Presented By EMBER","BPS":"1288407","DURATION":"00:23:40.002000000","NUMBER_OF_FRAMES":"34046","NUMBER_OF_BYTES":"228692681","_STATISTICS_WRITING_APP":"mkvmerge v69.0.0 (\u0027Day And Age\u0027) 64-bit","_STATISTICS_WRITING_DATE_UTC":"2024-01-06 04:19:11","_STATISTICS_TAGS":"BPS DURATION NUMBER_OF_FRAMES NUMBER_OF_BYTES"},"profile":"Main 10","width":1920,"height":1080,"coded_width":1920,"coded_height":1080,"closed_captions":0,"has_b_frames":2,"sample_aspect_ratio":"1:1","display_aspect_ratio":"16:9","pix_fmt":"yuv420p10le","level":120,"color_range":"tv","color_space":"bt709","color_transfer":"bt709","color_primaries":"bt709","refs":1},{"index":18,"codec_name":"png","codec_long_name":"PNG (Portable Network Graphics) image","codec_type":"video","codec_tag_string":"[0][0][0][0]","codec_tag":"0x0000","r_frame_rate":"90000/1","avg_frame_rate":"0/0","time_base":"1/90000","start_pts":0,"start_time":"0.000000","disposition":{"default":0,"dub":0,"original":0,"comment":0,"lyrics":0,"karaoke":0,"forced":0,"hearing_impaired":0,"visual_impaired":0,"clean_effects":0,"attached_pic":1,"timed_thumbnails":0},"tags":{"filename":"cover.png","mimetype":"image/png"},"duration":"1421.100000","duration_ts":127899000,"width":350,"height":197,"coded_width":350,"coded_height":197,"closed_captions":0,"has_b_frames":0,"sample_aspect_ratio":"1:1","display_aspect_ratio":"350:197","pix_fmt":"rgb24","level":-99,"color_range":"pc","refs":1}],"audioStream":[{"index":1,"codec_name":"eac3","codec_long_name":"ATSC A/52B (AC-3, E-AC-3)","codec_type":"audio","codec_tag_string":"[0][0][0][0]","codec_tag":"0x0000","r_frame_rate":"0/0","avg_frame_rate":"0/0","time_base":"1/1000","start_pts":12,"start_time":"0.012000","disposition":{"default":1,"dub":0,"original":0,"comment":0,"lyrics":0,"karaoke":0,"forced":0,"hearing_impaired":0,"visual_impaired":0,"clean_effects":0,"attached_pic":0,"timed_thumbnails":0},"tags":{"BPS":"128000","DURATION":"00:23:41.088000000","NUMBER_OF_FRAMES":"44409","NUMBER_OF_BYTES":"22737408","_STATISTICS_WRITING_APP":"mkvmerge v69.0.0 (\u0027Day And Age\u0027) 64-bit","_STATISTICS_WRITING_DATE_UTC":"2024-01-06 04:19:11","_STATISTICS_TAGS":"BPS DURATION NUMBER_OF_FRAMES NUMBER_OF_BYTES","language":"jpn"},"sample_fmt":"fltp","sample_rate":"48000","channels":2,"bits_per_sample":0}],"subtitleStream":[{"index":2,"codec_name":"ass","codec_long_name":"ASS (Advanced SSA) subtitle","codec_type":"subtitle","codec_tag_string":"[0][0][0][0]","codec_tag":"0x0000","r_frame_rate":"0/0","avg_frame_rate":"0/0","time_base":"1/1000","start_pts":0,"start_time":"0.000000","duration":"1421.100000","duration_ts":1421100,"disposition":{"default":1,"dub":0,"original":0,"comment":0,"lyrics":0,"karaoke":0,"forced":0,"hearing_impaired":0,"visual_impaired":0,"clean_effects":0,"attached_pic":0,"timed_thumbnails":0},"tags":{"BPS":"110","DURATION":"00:21:01.710000000","NUMBER_OF_FRAMES":"255","NUMBER_OF_BYTES":"17392","_STATISTICS_WRITING_APP":"mkvmerge v69.0.0 (\u0027Day And Age\u0027) 64-bit","_STATISTICS_WRITING_DATE_UTC":"2024-01-06 04:19:11","_STATISTICS_TAGS":"BPS DURATION NUMBER_OF_FRAMES NUMBER_OF_BYTES","language":"eng"}}]},"eventType":"EventMediaParseStreamPerformed"}
|
||||
""".trimIndent()
|
||||
@ -1,256 +0,0 @@
|
||||
package no.iktdev.mediaprocessing.coordinator.tasksV2.mapping.streams
|
||||
|
||||
import com.google.gson.Gson
|
||||
import no.iktdev.mediaprocessing.shared.common.Preference
|
||||
import no.iktdev.mediaprocessing.shared.common.contract.ffmpeg.AudioPreference
|
||||
import no.iktdev.mediaprocessing.shared.common.contract.ffmpeg.AudioStream
|
||||
import no.iktdev.mediaprocessing.shared.common.contract.ffmpeg.ParsedMediaStreams
|
||||
import org.assertj.core.api.Assertions.assertThat
|
||||
import org.junit.jupiter.api.Assertions.*
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class AudioArgumentsTest {
|
||||
|
||||
@Test
|
||||
fun validateChecks1() {
|
||||
val data = Gson().fromJson(ac3AudioStreamJson, AudioStream::class.java)
|
||||
val audioArguments = AudioArguments(
|
||||
allStreams = ParsedMediaStreams(
|
||||
audioStream = listOf(data), videoStream = emptyList(), subtitleStream = emptyList()),
|
||||
preference = Preference.getPreference().encodePreference.audio,
|
||||
audioStream = data
|
||||
)
|
||||
assertThat(audioArguments.isAudioCodecEqual()).isFalse()
|
||||
assertThat(audioArguments.isSurround()).isTrue()
|
||||
|
||||
assertThat(audioArguments.getAudioArguments().codecParameters).isEqualTo(listOf("-acodec", "copy"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun validateChecks2() {
|
||||
val data = Gson().fromJson(eac3AudioStreamJson, AudioStream::class.java)
|
||||
val audioArguments = AudioArguments(
|
||||
allStreams = ParsedMediaStreams(
|
||||
audioStream = listOf(data), videoStream = emptyList(), subtitleStream = emptyList()),
|
||||
preference = Preference.getPreference().encodePreference.audio,
|
||||
audioStream = data
|
||||
)
|
||||
assertThat(audioArguments.isAudioCodecEqual()).isFalse()
|
||||
assertThat(audioArguments.isSurround()).isTrue()
|
||||
|
||||
assertThat(audioArguments.getAudioArguments().codecParameters).isEqualTo(listOf("-acodec", "copy"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun validateChecks3() {
|
||||
val data = Gson().fromJson(aacSurroundAudioStreamJson, AudioStream::class.java)
|
||||
val audioArguments = AudioArguments(
|
||||
allStreams = ParsedMediaStreams(
|
||||
audioStream = listOf(data), videoStream = emptyList(), subtitleStream = emptyList()),
|
||||
preference = Preference.getPreference().encodePreference.audio,
|
||||
audioStream = data
|
||||
)
|
||||
assertThat(audioArguments.isAudioCodecEqual()).isTrue()
|
||||
assertThat(audioArguments.isSurround()).isTrue()
|
||||
|
||||
assertThat(audioArguments.getAudioArguments().codecParameters).isEqualTo(listOf("-c:a", "eac3"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun validateChecks4() {
|
||||
val data = Gson().fromJson(aacStereoAudioStreamJson, AudioStream::class.java)
|
||||
val audioArguments = AudioArguments(
|
||||
allStreams = ParsedMediaStreams(
|
||||
audioStream = listOf(data), videoStream = emptyList(), subtitleStream = emptyList()),
|
||||
preference = Preference.getPreference().encodePreference.audio,
|
||||
audioStream = data
|
||||
)
|
||||
assertThat(audioArguments.isAudioCodecEqual()).isTrue()
|
||||
assertThat(audioArguments.isSurround()).isFalse()
|
||||
|
||||
assertThat(audioArguments.getAudioArguments().codecParameters).isEqualTo(listOf("-acodec", "copy"))
|
||||
}
|
||||
|
||||
|
||||
val ac3AudioStreamJson = """
|
||||
{
|
||||
"index": 1,
|
||||
"codec_name": "ac3",
|
||||
"codec_long_name": "ATSC A/52A (AC-3)",
|
||||
"codec_type": "audio",
|
||||
"codec_tag_string": "[0][0][0][0]",
|
||||
"codec_tag": "0x0000",
|
||||
"sample_fmt": "fltp",
|
||||
"sample_rate": "48000",
|
||||
"channels": 6,
|
||||
"channel_layout": "5.1(side)",
|
||||
"bits_per_sample": 0,
|
||||
"dmix_mode": "-1",
|
||||
"ltrt_cmixlev": "-1.000000",
|
||||
"ltrt_surmixlev": "-1.000000",
|
||||
"loro_cmixlev": "-1.000000",
|
||||
"loro_surmixlev": "-1.000000",
|
||||
"r_frame_rate": "0/0",
|
||||
"avg_frame_rate": "0/0",
|
||||
"time_base": "1/1000",
|
||||
"start_pts": -5,
|
||||
"start_time": "-0.005000",
|
||||
"bit_rate": "448000",
|
||||
"disposition": {
|
||||
"default": 1,
|
||||
"dub": 0,
|
||||
"original": 0,
|
||||
"comment": 0,
|
||||
"lyrics": 0,
|
||||
"karaoke": 0,
|
||||
"forced": 0,
|
||||
"hearing_impaired": 0,
|
||||
"visual_impaired": 0,
|
||||
"clean_effects": 0,
|
||||
"attached_pic": 0,
|
||||
"timed_thumbnails": 0
|
||||
},
|
||||
"tags": {
|
||||
"language": "eng",
|
||||
"ENCODER": "Lavc60.31.102 ac3",
|
||||
"DURATION": "01:05:13.024000000"
|
||||
}
|
||||
}
|
||||
""".trimIndent()
|
||||
val eac3AudioStreamJson = """
|
||||
{
|
||||
"index": 1,
|
||||
"codec_name": "eac3",
|
||||
"codec_long_name": "E-AC3",
|
||||
"codec_type": "audio",
|
||||
"codec_tag_string": "[0][0][0][0]",
|
||||
"codec_tag": "0x0000",
|
||||
"sample_fmt": "fltp",
|
||||
"sample_rate": "48000",
|
||||
"channels": 6,
|
||||
"channel_layout": "5.1(side)",
|
||||
"bits_per_sample": 0,
|
||||
"dmix_mode": "-1",
|
||||
"ltrt_cmixlev": "-1.000000",
|
||||
"ltrt_surmixlev": "-1.000000",
|
||||
"loro_cmixlev": "-1.000000",
|
||||
"loro_surmixlev": "-1.000000",
|
||||
"r_frame_rate": "0/0",
|
||||
"avg_frame_rate": "0/0",
|
||||
"time_base": "1/1000",
|
||||
"start_pts": -5,
|
||||
"start_time": "-0.005000",
|
||||
"bit_rate": "448000",
|
||||
"disposition": {
|
||||
"default": 1,
|
||||
"dub": 0,
|
||||
"original": 0,
|
||||
"comment": 0,
|
||||
"lyrics": 0,
|
||||
"karaoke": 0,
|
||||
"forced": 0,
|
||||
"hearing_impaired": 0,
|
||||
"visual_impaired": 0,
|
||||
"clean_effects": 0,
|
||||
"attached_pic": 0,
|
||||
"timed_thumbnails": 0
|
||||
},
|
||||
"tags": {
|
||||
"language": "eng",
|
||||
"ENCODER": "Lavc60.31.102 ac3",
|
||||
"DURATION": "01:05:13.024000000"
|
||||
}
|
||||
}
|
||||
""".trimIndent()
|
||||
val aacStereoAudioStreamJson = """
|
||||
{
|
||||
"index": 1,
|
||||
"codec_name": "aac",
|
||||
"codec_long_name": "AAC (Advanced Audio Coding)",
|
||||
"profile": "LC",
|
||||
"codec_type": "audio",
|
||||
"codec_tag_string": "mp4a",
|
||||
"codec_tag": "0x6134706d",
|
||||
"sample_fmt": "fltp",
|
||||
"sample_rate": "48000",
|
||||
"channels": 2,
|
||||
"channel_layout": "2",
|
||||
"bits_per_sample": 0,
|
||||
"r_frame_rate": "0/0",
|
||||
"avg_frame_rate": "0/0",
|
||||
"time_base": "1/48000",
|
||||
"start_pts": 0,
|
||||
"start_time": "0.000000",
|
||||
"duration_ts": 376210896,
|
||||
"duration": "7837.727000",
|
||||
"bit_rate": "224000",
|
||||
"nb_frames": "367396",
|
||||
"disposition": {
|
||||
"default": 1,
|
||||
"dub": 0,
|
||||
"original": 0,
|
||||
"comment": 0,
|
||||
"lyrics": 0,
|
||||
"karaoke": 0,
|
||||
"forced": 0,
|
||||
"hearing_impaired": 0,
|
||||
"visual_impaired": 0,
|
||||
"clean_effects": 0,
|
||||
"attached_pic": 0,
|
||||
"timed_thumbnails": 0
|
||||
},
|
||||
"tags": {
|
||||
"creation_time": "2022-02-08T20:37:35.000000Z",
|
||||
"language": "eng",
|
||||
"handler_name": "SoundHandler",
|
||||
"vendor_id": "[0][0][0][0]"
|
||||
}
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val aacSurroundAudioStreamJson = """
|
||||
{
|
||||
"index": 1,
|
||||
"codec_name": "aac",
|
||||
"codec_long_name": "AAC (Advanced Audio Coding)",
|
||||
"profile": "LC",
|
||||
"codec_type": "audio",
|
||||
"codec_tag_string": "mp4a",
|
||||
"codec_tag": "0x6134706d",
|
||||
"sample_fmt": "fltp",
|
||||
"sample_rate": "48000",
|
||||
"channels": 6,
|
||||
"channel_layout": "5.1",
|
||||
"bits_per_sample": 0,
|
||||
"r_frame_rate": "0/0",
|
||||
"avg_frame_rate": "0/0",
|
||||
"time_base": "1/48000",
|
||||
"start_pts": 0,
|
||||
"start_time": "0.000000",
|
||||
"duration_ts": 376210896,
|
||||
"duration": "7837.727000",
|
||||
"bit_rate": "224000",
|
||||
"nb_frames": "367396",
|
||||
"disposition": {
|
||||
"default": 1,
|
||||
"dub": 0,
|
||||
"original": 0,
|
||||
"comment": 0,
|
||||
"lyrics": 0,
|
||||
"karaoke": 0,
|
||||
"forced": 0,
|
||||
"hearing_impaired": 0,
|
||||
"visual_impaired": 0,
|
||||
"clean_effects": 0,
|
||||
"attached_pic": 0,
|
||||
"timed_thumbnails": 0
|
||||
},
|
||||
"tags": {
|
||||
"creation_time": "2022-02-08T20:37:35.000000Z",
|
||||
"language": "eng",
|
||||
"handler_name": "SoundHandler",
|
||||
"vendor_id": "[0][0][0][0]"
|
||||
}
|
||||
}
|
||||
""".trimIndent()
|
||||
}
|
||||
@ -1,566 +0,0 @@
|
||||
package no.iktdev.mediaprocessing.coordinator.tasksV2.mapping.streams
|
||||
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import no.iktdev.mediaprocessing.shared.common.contract.ffmpeg.SubtitleStream
|
||||
import org.assertj.core.api.Assertions.assertThat
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class SubtitleArgumentsTest {
|
||||
val type = object : TypeToken<List<SubtitleStream>>() {}.type
|
||||
|
||||
@Test
|
||||
fun validate1() {
|
||||
val data = Gson().fromJson<List<SubtitleStream>>(multipleSubtitleStreamsWithSameLanguage, type)
|
||||
assertThat(data.all { it is SubtitleStream }).isTrue()
|
||||
assertThat(data).isNotNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun validate2() {
|
||||
val data = Gson().fromJson<List<SubtitleStream>>(multipleSubtitleStreamsWithSameLanguage, type)
|
||||
val args = SubtitleArguments(data)
|
||||
val selectable = args.excludeLowFrameCount(data)
|
||||
assertThat(selectable).hasSize(3)
|
||||
assertThat(selectable.find { it.index == 4 })
|
||||
assertThat(selectable.find { it.index == 5 })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun validate3() {
|
||||
val data = Gson().fromJson<List<SubtitleStream>>(multipleSubtitleStreamsWithSameLanguage, type)
|
||||
val args = SubtitleArguments(data).getSubtitleArguments()
|
||||
assertThat(args).hasSize(1)
|
||||
assertThat(args.firstOrNull()?.mediaIndex).isEqualTo(4)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun validate3_2() {
|
||||
val data = Gson().fromJson<List<SubtitleStream>>(multipleSubtitleStreamsWithSameLanguageWithDisposition, type)
|
||||
val args = SubtitleArguments(data).getSubtitleArguments()
|
||||
assertThat(args).hasSize(1)
|
||||
assertThat(args.firstOrNull()?.mediaIndex).isEqualTo(4)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun assertThatCorrectTrackIsSelected() {
|
||||
val data = Gson().fromJson<List<SubtitleStream>>(selectCorrectTrack, type)
|
||||
val args = SubtitleArguments(data).getSubtitleArguments()
|
||||
assertThat(args).hasSize(1)
|
||||
assertThat(args.firstOrNull()?.index).isEqualTo(0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun assertThatCommentaryIsNotSelected() {
|
||||
val data = Gson().fromJson<List<SubtitleStream>>(streamsWithCommentary, type)
|
||||
val args = SubtitleArguments(data).getSubtitleArguments()
|
||||
assertThat(args).hasSize(1)
|
||||
assertThat(args.firstOrNull()?.mediaIndex).isEqualTo(4)
|
||||
}
|
||||
|
||||
val multipleSubtitleStreamsWithSameLanguage = """
|
||||
[{
|
||||
"index": 3,
|
||||
"codec_name": "ass",
|
||||
"codec_long_name": "ASS (Advanced SSA) subtitle",
|
||||
"codec_type": "subtitle",
|
||||
"codec_tag_string": "[0][0][0][0]",
|
||||
"codec_tag": "0x0000",
|
||||
"r_frame_rate": "0/0",
|
||||
"avg_frame_rate": "0/0",
|
||||
"time_base": "1/1000",
|
||||
"start_pts": 0,
|
||||
"start_time": "0.000000",
|
||||
"duration_ts": 1437083,
|
||||
"duration": "1437.083000",
|
||||
"extradata_size": 1967,
|
||||
"tags": {
|
||||
"language": "eng",
|
||||
"title": "Forced",
|
||||
"BPS": "5",
|
||||
"DURATION": "00:21:42.640000000",
|
||||
"NUMBER_OF_FRAMES": "14",
|
||||
"NUMBER_OF_BYTES": "835",
|
||||
"_STATISTICS_WRITING_APP": "mkvmerge v69.0.0 ('Day And Age') 64-bit",
|
||||
"_STATISTICS_WRITING_DATE_UTC": "2024-10-04 08:12:59",
|
||||
"_STATISTICS_TAGS": "BPS DURATION NUMBER_OF_FRAMES NUMBER_OF_BYTES"
|
||||
}
|
||||
},
|
||||
{
|
||||
"index": 4,
|
||||
"codec_name": "ass",
|
||||
"codec_long_name": "ASS (Advanced SSA) subtitle",
|
||||
"codec_type": "subtitle",
|
||||
"codec_tag_string": "[0][0][0][0]",
|
||||
"codec_tag": "0x0000",
|
||||
"r_frame_rate": "0/0",
|
||||
"avg_frame_rate": "0/0",
|
||||
"time_base": "1/1000",
|
||||
"start_pts": 0,
|
||||
"start_time": "0.000000",
|
||||
"duration_ts": 1437083,
|
||||
"duration": "1437.083000",
|
||||
"extradata_size": 1791,
|
||||
"tags": {
|
||||
"language": "eng",
|
||||
"BPS": "129",
|
||||
"DURATION": "00:22:26.550000000",
|
||||
"NUMBER_OF_FRAMES": "356",
|
||||
"NUMBER_OF_BYTES": "21787",
|
||||
"_STATISTICS_WRITING_APP": "mkvmerge v69.0.0 ('Day And Age') 64-bit",
|
||||
"_STATISTICS_WRITING_DATE_UTC": "2024-10-04 08:12:59",
|
||||
"_STATISTICS_TAGS": "BPS DURATION NUMBER_OF_FRAMES NUMBER_OF_BYTES"
|
||||
}
|
||||
},
|
||||
{
|
||||
"index": 5,
|
||||
"codec_name": "subrip",
|
||||
"codec_long_name": "SubRip subtitle",
|
||||
"codec_type": "subtitle",
|
||||
"codec_tag_string": "[0][0][0][0]",
|
||||
"codec_tag": "0x0000",
|
||||
"r_frame_rate": "0/0",
|
||||
"avg_frame_rate": "0/0",
|
||||
"time_base": "1/1000",
|
||||
"start_pts": 790,
|
||||
"start_time": "0.790000",
|
||||
"tags": {
|
||||
"language": "eng",
|
||||
"title": "CC",
|
||||
"BPS": "83",
|
||||
"DURATION": "00:23:56.060000000",
|
||||
"NUMBER_OF_FRAMES": "495",
|
||||
"NUMBER_OF_BYTES": "14954",
|
||||
"_STATISTICS_WRITING_APP": "mkvmerge v69.0.0 ('Day And Age') 64-bit",
|
||||
"_STATISTICS_WRITING_DATE_UTC": "2024-10-04 08:12:59",
|
||||
"_STATISTICS_TAGS": "BPS DURATION NUMBER_OF_FRAMES NUMBER_OF_BYTES"
|
||||
}
|
||||
}]
|
||||
""".trimIndent()
|
||||
|
||||
//language=json
|
||||
val multipleSubtitleStreamsWithSameLanguageWithDisposition = """
|
||||
[{
|
||||
"index": 3,
|
||||
"codec_name": "ass",
|
||||
"codec_long_name": "ASS (Advanced SSA) subtitle",
|
||||
"codec_type": "subtitle",
|
||||
"codec_tag_string": "[0][0][0][0]",
|
||||
"codec_tag": "0x0000",
|
||||
"r_frame_rate": "0/0",
|
||||
"avg_frame_rate": "0/0",
|
||||
"time_base": "1/1000",
|
||||
"start_pts": 0,
|
||||
"start_time": "0.000000",
|
||||
"duration_ts": 1437083,
|
||||
"duration": "1437.083000",
|
||||
"extradata_size": 1967,
|
||||
"disposition": {
|
||||
"default": 1,
|
||||
"dub": 0,
|
||||
"original": 0,
|
||||
"comment": 0,
|
||||
"lyrics": 0,
|
||||
"karaoke": 0,
|
||||
"forced": 1,
|
||||
"hearing_impaired": 0,
|
||||
"visual_impaired": 0,
|
||||
"clean_effects": 0,
|
||||
"attached_pic": 0,
|
||||
"timed_thumbnails": 0,
|
||||
"non_diegetic": 0,
|
||||
"captions": 0,
|
||||
"descriptions": 0,
|
||||
"metadata": 0,
|
||||
"dependent": 0,
|
||||
"still_image": 0
|
||||
},
|
||||
"tags": {
|
||||
"language": "eng",
|
||||
"title": "Forced",
|
||||
"BPS": "5",
|
||||
"DURATION": "00:21:42.640000000",
|
||||
"NUMBER_OF_FRAMES": "14",
|
||||
"NUMBER_OF_BYTES": "835",
|
||||
"_STATISTICS_WRITING_APP": "mkvmerge v69.0.0 ('Day And Age') 64-bit",
|
||||
"_STATISTICS_WRITING_DATE_UTC": "2024-10-04 08:12:59",
|
||||
"_STATISTICS_TAGS": "BPS DURATION NUMBER_OF_FRAMES NUMBER_OF_BYTES"
|
||||
}
|
||||
},
|
||||
{
|
||||
"index": 4,
|
||||
"codec_name": "ass",
|
||||
"codec_long_name": "ASS (Advanced SSA) subtitle",
|
||||
"codec_type": "subtitle",
|
||||
"codec_tag_string": "[0][0][0][0]",
|
||||
"codec_tag": "0x0000",
|
||||
"r_frame_rate": "0/0",
|
||||
"avg_frame_rate": "0/0",
|
||||
"time_base": "1/1000",
|
||||
"start_pts": 0,
|
||||
"start_time": "0.000000",
|
||||
"duration_ts": 1437083,
|
||||
"duration": "1437.083000",
|
||||
"extradata_size": 1791,
|
||||
"disposition": {
|
||||
"default": 0,
|
||||
"dub": 0,
|
||||
"original": 0,
|
||||
"comment": 0,
|
||||
"lyrics": 0,
|
||||
"karaoke": 0,
|
||||
"forced": 0,
|
||||
"hearing_impaired": 0,
|
||||
"visual_impaired": 0,
|
||||
"clean_effects": 0,
|
||||
"attached_pic": 0,
|
||||
"timed_thumbnails": 0,
|
||||
"non_diegetic": 0,
|
||||
"captions": 0,
|
||||
"descriptions": 0,
|
||||
"metadata": 0,
|
||||
"dependent": 0,
|
||||
"still_image": 0
|
||||
},
|
||||
"tags": {
|
||||
"language": "eng",
|
||||
"BPS": "129",
|
||||
"DURATION": "00:22:26.550000000",
|
||||
"NUMBER_OF_FRAMES": "356",
|
||||
"NUMBER_OF_BYTES": "21787",
|
||||
"_STATISTICS_WRITING_APP": "mkvmerge v69.0.0 ('Day And Age') 64-bit",
|
||||
"_STATISTICS_WRITING_DATE_UTC": "2024-10-04 08:12:59",
|
||||
"_STATISTICS_TAGS": "BPS DURATION NUMBER_OF_FRAMES NUMBER_OF_BYTES"
|
||||
}
|
||||
},
|
||||
{
|
||||
"index": 5,
|
||||
"codec_name": "subrip",
|
||||
"codec_long_name": "SubRip subtitle",
|
||||
"codec_type": "subtitle",
|
||||
"codec_tag_string": "[0][0][0][0]",
|
||||
"codec_tag": "0x0000",
|
||||
"r_frame_rate": "0/0",
|
||||
"avg_frame_rate": "0/0",
|
||||
"time_base": "1/1000",
|
||||
"start_pts": 790,
|
||||
"start_time": "0.790000",
|
||||
"disposition": {
|
||||
"default": 0,
|
||||
"dub": 0,
|
||||
"original": 0,
|
||||
"comment": 0,
|
||||
"lyrics": 0,
|
||||
"karaoke": 0,
|
||||
"forced": 0,
|
||||
"hearing_impaired": 0,
|
||||
"visual_impaired": 0,
|
||||
"clean_effects": 0,
|
||||
"attached_pic": 0,
|
||||
"timed_thumbnails": 0,
|
||||
"non_diegetic": 0,
|
||||
"captions": 0,
|
||||
"descriptions": 0,
|
||||
"metadata": 0,
|
||||
"dependent": 0,
|
||||
"still_image": 0
|
||||
},
|
||||
"tags": {
|
||||
"language": "eng",
|
||||
"title": "CC",
|
||||
"BPS": "83",
|
||||
"DURATION": "00:23:56.060000000",
|
||||
"NUMBER_OF_FRAMES": "495",
|
||||
"NUMBER_OF_BYTES": "14954",
|
||||
"_STATISTICS_WRITING_APP": "mkvmerge v69.0.0 ('Day And Age') 64-bit",
|
||||
"_STATISTICS_WRITING_DATE_UTC": "2024-10-04 08:12:59",
|
||||
"_STATISTICS_TAGS": "BPS DURATION NUMBER_OF_FRAMES NUMBER_OF_BYTES"
|
||||
}
|
||||
}]
|
||||
""".trimIndent()
|
||||
|
||||
val selectCorrectTrack = """
|
||||
[
|
||||
{
|
||||
"index": 2,
|
||||
"codec_name": "ass",
|
||||
"codec_long_name": "ASS (Advanced SSA) subtitle",
|
||||
"codec_type": "subtitle",
|
||||
"codec_tag_string": "[0][0][0][0]",
|
||||
"codec_tag": "0x0000",
|
||||
"r_frame_rate": "0/0",
|
||||
"avg_frame_rate": "0/0",
|
||||
"time_base": "1/1000",
|
||||
"start_pts": 0,
|
||||
"start_time": "0.000000",
|
||||
"duration_ts": 1430048,
|
||||
"duration": "1430.048000",
|
||||
"extradata_size": 2185,
|
||||
"disposition": {
|
||||
"default": 1,
|
||||
"dub": 0,
|
||||
"original": 0,
|
||||
"comment": 0,
|
||||
"lyrics": 0,
|
||||
"karaoke": 0,
|
||||
"forced": 0,
|
||||
"hearing_impaired": 0,
|
||||
"visual_impaired": 0,
|
||||
"clean_effects": 0,
|
||||
"attached_pic": 0,
|
||||
"timed_thumbnails": 0,
|
||||
"non_diegetic": 0,
|
||||
"captions": 0,
|
||||
"descriptions": 0,
|
||||
"metadata": 0,
|
||||
"dependent": 0,
|
||||
"still_image": 0
|
||||
},
|
||||
"tags": {
|
||||
"language": "eng",
|
||||
"BPS": "173",
|
||||
"DURATION": "00:23:43.500000000",
|
||||
"NUMBER_OF_FRAMES": "436",
|
||||
"NUMBER_OF_BYTES": "30896",
|
||||
"_STATISTICS_WRITING_APP": "mkvmerge v69.0.0 ('Day And Age') 64-bit",
|
||||
"_STATISTICS_WRITING_DATE_UTC": "2025-01-03 02:19:23",
|
||||
"_STATISTICS_TAGS": "BPS DURATION NUMBER_OF_FRAMES NUMBER_OF_BYTES"
|
||||
}
|
||||
},
|
||||
{
|
||||
"index": 3,
|
||||
"codec_name": "subrip",
|
||||
"codec_long_name": "SubRip subtitle",
|
||||
"codec_type": "subtitle",
|
||||
"codec_tag_string": "[0][0][0][0]",
|
||||
"codec_tag": "0x0000",
|
||||
"r_frame_rate": "0/0",
|
||||
"avg_frame_rate": "0/0",
|
||||
"time_base": "1/1000",
|
||||
"start_pts": 0,
|
||||
"start_time": "0.000000",
|
||||
"duration_ts": 1430048,
|
||||
"duration": "1430.048000",
|
||||
"disposition": {
|
||||
"default": 0,
|
||||
"dub": 0,
|
||||
"original": 0,
|
||||
"comment": 0,
|
||||
"lyrics": 0,
|
||||
"karaoke": 0,
|
||||
"forced": 0,
|
||||
"hearing_impaired": 0,
|
||||
"visual_impaired": 0,
|
||||
"clean_effects": 0,
|
||||
"attached_pic": 0,
|
||||
"timed_thumbnails": 0,
|
||||
"non_diegetic": 0,
|
||||
"captions": 0,
|
||||
"descriptions": 0,
|
||||
"metadata": 0,
|
||||
"dependent": 0,
|
||||
"still_image": 0
|
||||
},
|
||||
"tags": {
|
||||
"language": "eng",
|
||||
"BPS": "83",
|
||||
"DURATION": "00:23:41.860000000",
|
||||
"NUMBER_OF_FRAMES": "432",
|
||||
"NUMBER_OF_BYTES": "14853",
|
||||
"_STATISTICS_WRITING_APP": "mkvmerge v69.0.0 ('Day And Age') 64-bit",
|
||||
"_STATISTICS_WRITING_DATE_UTC": "2025-01-03 02:19:23",
|
||||
"_STATISTICS_TAGS": "BPS DURATION NUMBER_OF_FRAMES NUMBER_OF_BYTES"
|
||||
}
|
||||
}
|
||||
]
|
||||
""".trimIndent()
|
||||
|
||||
val streamsWithCommentary = """
|
||||
[
|
||||
{
|
||||
"index": 4,
|
||||
"codec_name": "subrip",
|
||||
"codec_long_name": "SubRip subtitle",
|
||||
"codec_type": "subtitle",
|
||||
"codec_tag_string": "[0][0][0][0]",
|
||||
"codec_tag": "0x0000",
|
||||
"r_frame_rate": "0/0",
|
||||
"avg_frame_rate": "0/0",
|
||||
"time_base": "1/1000",
|
||||
"start_pts": 0,
|
||||
"start_time": "0.000000",
|
||||
"duration_ts": 5501856,
|
||||
"duration": "5501.856000",
|
||||
"disposition": {
|
||||
"default": 0,
|
||||
"dub": 0,
|
||||
"original": 0,
|
||||
"comment": 0,
|
||||
"lyrics": 0,
|
||||
"karaoke": 0,
|
||||
"forced": 1,
|
||||
"hearing_impaired": 0,
|
||||
"visual_impaired": 0,
|
||||
"clean_effects": 0,
|
||||
"attached_pic": 0,
|
||||
"timed_thumbnails": 0,
|
||||
"non_diegetic": 0,
|
||||
"captions": 0,
|
||||
"descriptions": 0,
|
||||
"metadata": 0,
|
||||
"dependent": 0,
|
||||
"still_image": 0
|
||||
},
|
||||
"tags": {
|
||||
"language": "eng",
|
||||
"title": "English (Forced)",
|
||||
"BPS": "4",
|
||||
"DURATION": "00:50:23.770000000",
|
||||
"NUMBER_OF_FRAMES": "67",
|
||||
"NUMBER_OF_BYTES": "1591",
|
||||
"_STATISTICS_WRITING_APP": "mkvmerge v90.0 ('Hanging On') 64-bit",
|
||||
"_STATISTICS_WRITING_DATE_UTC": "2025-03-12 18:54:52",
|
||||
"_STATISTICS_TAGS": "BPS DURATION NUMBER_OF_FRAMES NUMBER_OF_BYTES"
|
||||
}
|
||||
},
|
||||
{
|
||||
"index": 5,
|
||||
"codec_name": "subrip",
|
||||
"codec_long_name": "SubRip subtitle",
|
||||
"codec_type": "subtitle",
|
||||
"codec_tag_string": "[0][0][0][0]",
|
||||
"codec_tag": "0x0000",
|
||||
"r_frame_rate": "0/0",
|
||||
"avg_frame_rate": "0/0",
|
||||
"time_base": "1/1000",
|
||||
"start_pts": 0,
|
||||
"start_time": "0.000000",
|
||||
"duration_ts": 5501856,
|
||||
"duration": "5501.856000",
|
||||
"disposition": {
|
||||
"default": 0,
|
||||
"dub": 0,
|
||||
"original": 0,
|
||||
"comment": 0,
|
||||
"lyrics": 0,
|
||||
"karaoke": 0,
|
||||
"forced": 0,
|
||||
"hearing_impaired": 1,
|
||||
"visual_impaired": 0,
|
||||
"clean_effects": 0,
|
||||
"attached_pic": 0,
|
||||
"timed_thumbnails": 0,
|
||||
"non_diegetic": 0,
|
||||
"captions": 0,
|
||||
"descriptions": 0,
|
||||
"metadata": 0,
|
||||
"dependent": 0,
|
||||
"still_image": 0
|
||||
},
|
||||
"tags": {
|
||||
"language": "eng",
|
||||
"title": "English (SDH)",
|
||||
"BPS": "54",
|
||||
"DURATION": "01:28:15.462000000",
|
||||
"NUMBER_OF_FRAMES": "1302",
|
||||
"NUMBER_OF_BYTES": "35817",
|
||||
"_STATISTICS_WRITING_APP": "mkvmerge v90.0 ('Hanging On') 64-bit",
|
||||
"_STATISTICS_WRITING_DATE_UTC": "2025-03-12 18:54:52",
|
||||
"_STATISTICS_TAGS": "BPS DURATION NUMBER_OF_FRAMES NUMBER_OF_BYTES"
|
||||
}
|
||||
},
|
||||
{
|
||||
"index": 8,
|
||||
"codec_name": "subrip",
|
||||
"codec_long_name": "SubRip subtitle",
|
||||
"codec_type": "subtitle",
|
||||
"codec_tag_string": "[0][0][0][0]",
|
||||
"codec_tag": "0x0000",
|
||||
"r_frame_rate": "0/0",
|
||||
"avg_frame_rate": "0/0",
|
||||
"time_base": "1/1000",
|
||||
"start_pts": 0,
|
||||
"start_time": "0.000000",
|
||||
"duration_ts": 5501856,
|
||||
"duration": "5501.856000",
|
||||
"disposition": {
|
||||
"default": 0,
|
||||
"dub": 0,
|
||||
"original": 0,
|
||||
"comment": 0,
|
||||
"lyrics": 0,
|
||||
"karaoke": 0,
|
||||
"forced": 0,
|
||||
"hearing_impaired": 0,
|
||||
"visual_impaired": 0,
|
||||
"clean_effects": 0,
|
||||
"attached_pic": 0,
|
||||
"timed_thumbnails": 0,
|
||||
"non_diegetic": 0,
|
||||
"captions": 0,
|
||||
"descriptions": 0,
|
||||
"metadata": 0,
|
||||
"dependent": 0,
|
||||
"still_image": 0
|
||||
},
|
||||
"tags": {
|
||||
"language": "eng",
|
||||
"title": "English (Commentary #1)",
|
||||
"BPS": "124",
|
||||
"DURATION": "01:30:35.847000000",
|
||||
"NUMBER_OF_FRAMES": "1596",
|
||||
"NUMBER_OF_BYTES": "84444",
|
||||
"_STATISTICS_WRITING_APP": "mkvmerge v90.0 ('Hanging On') 64-bit",
|
||||
"_STATISTICS_WRITING_DATE_UTC": "2025-03-12 18:54:52",
|
||||
"_STATISTICS_TAGS": "BPS DURATION NUMBER_OF_FRAMES NUMBER_OF_BYTES"
|
||||
}
|
||||
},
|
||||
{
|
||||
"index": 9,
|
||||
"codec_name": "subrip",
|
||||
"codec_long_name": "SubRip subtitle",
|
||||
"codec_type": "subtitle",
|
||||
"codec_tag_string": "[0][0][0][0]",
|
||||
"codec_tag": "0x0000",
|
||||
"r_frame_rate": "0/0",
|
||||
"avg_frame_rate": "0/0",
|
||||
"time_base": "1/1000",
|
||||
"start_pts": 0,
|
||||
"start_time": "0.000000",
|
||||
"duration_ts": 5501856,
|
||||
"duration": "5501.856000",
|
||||
"disposition": {
|
||||
"default": 0,
|
||||
"dub": 0,
|
||||
"original": 0,
|
||||
"comment": 0,
|
||||
"lyrics": 0,
|
||||
"karaoke": 0,
|
||||
"forced": 0,
|
||||
"hearing_impaired": 0,
|
||||
"visual_impaired": 0,
|
||||
"clean_effects": 0,
|
||||
"attached_pic": 0,
|
||||
"timed_thumbnails": 0,
|
||||
"non_diegetic": 0,
|
||||
"captions": 0,
|
||||
"descriptions": 0,
|
||||
"metadata": 0,
|
||||
"dependent": 0,
|
||||
"still_image": 0
|
||||
},
|
||||
"tags": {
|
||||
"language": "eng",
|
||||
"title": "English (Commentary #2)",
|
||||
"BPS": "134",
|
||||
"DURATION": "01:31:22.561000000",
|
||||
"NUMBER_OF_FRAMES": "1646",
|
||||
"NUMBER_OF_BYTES": "92269",
|
||||
"_STATISTICS_WRITING_APP": "mkvmerge v90.0 ('Hanging On') 64-bit",
|
||||
"_STATISTICS_WRITING_DATE_UTC": "2025-03-12 18:54:52",
|
||||
"_STATISTICS_TAGS": "BPS DURATION NUMBER_OF_FRAMES NUMBER_OF_BYTES"
|
||||
}
|
||||
}
|
||||
]
|
||||
""".trimIndent()
|
||||
}
|
||||
@ -1,361 +0,0 @@
|
||||
package no.iktdev.mediaprocessing.coordinator.tasksV2.mapping.streams
|
||||
|
||||
import com.google.gson.Gson
|
||||
import no.iktdev.mediaprocessing.shared.common.Preference
|
||||
import no.iktdev.mediaprocessing.shared.common.contract.ffmpeg.ParsedMediaStreams
|
||||
import no.iktdev.mediaprocessing.shared.common.contract.ffmpeg.VideoPreference
|
||||
import no.iktdev.mediaprocessing.shared.common.contract.ffmpeg.VideoStream
|
||||
import org.assertj.core.api.Assertions.assertThat
|
||||
import org.junit.jupiter.api.DisplayName
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class VideoArgumentsTest {
|
||||
|
||||
private fun getVideoArguments(videoStream: VideoStream, preference: VideoPreference): VideoArguments {
|
||||
return VideoArguments(
|
||||
allStreams = ParsedMediaStreams(
|
||||
videoStream = listOf(videoStream),
|
||||
audioStream = emptyList(),
|
||||
subtitleStream = emptyList()
|
||||
),
|
||||
preference = preference,
|
||||
videoStream = videoStream
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun hevcStream1() {
|
||||
val data = Gson().fromJson(hevcStream1, VideoStream::class.java)
|
||||
val videoArguments = getVideoArguments(data,
|
||||
Preference.getPreference().encodePreference.video
|
||||
.copy(codec = "h265")
|
||||
)
|
||||
assertThat(videoArguments.isVideoCodecEqual()).isTrue()
|
||||
assertThat(videoArguments.getCodec()).isEqualTo("libx265")
|
||||
assertThat(videoArguments.getVideoArguments().codecParameters.take(2)).isEqualTo(listOf("-c:v", "copy"))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("""
|
||||
When a hevc encoded media file gets received,
|
||||
But it has unset metadata, and re-encode for chromecast is set,
|
||||
Then the parameters should not specify copy
|
||||
""")
|
||||
fun hevcStream2() {
|
||||
val data = Gson().fromJson(hevcStream2, VideoStream::class.java)
|
||||
val videoArguments = getVideoArguments(data,
|
||||
Preference.getPreference().encodePreference.video
|
||||
.copy(codec = "h265", reencodeOnIncorrectMetadataForChromecast = true)
|
||||
)
|
||||
assertThat(videoArguments.isVideoCodecEqual()).isTrue()
|
||||
assertThat(videoArguments.getCodec()).isEqualTo("libx265")
|
||||
assertThat(videoArguments.getVideoArguments().codecParameters.take(2)).isEqualTo(listOf("-c:v", "libx265"))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("""
|
||||
When a vc1 encoded media file gets received
|
||||
And preference is set to hevc,
|
||||
Then the parameters should be to encode in hevc
|
||||
""")
|
||||
fun vc1Stream1() {
|
||||
val data = Gson().fromJson(vc1Stream, VideoStream::class.java)
|
||||
val videoArguments = getVideoArguments(data,
|
||||
Preference.getPreference().encodePreference.video
|
||||
.copy(codec = "h265", reencodeOnIncorrectMetadataForChromecast = true)
|
||||
)
|
||||
assertThat(videoArguments.isVideoCodecEqual()).isFalse()
|
||||
assertThat(videoArguments.getCodec()).isEqualTo("vc1")
|
||||
assertThat(videoArguments.getVideoArguments().codecParameters.take(2)).isEqualTo(listOf("-c:v", "libx265"))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("""
|
||||
When a vc1 encoded media file gets received
|
||||
And preference is set to h264,
|
||||
Then the parameters should be to encode in h264
|
||||
""")
|
||||
fun vc1Stream2() {
|
||||
val data = Gson().fromJson(vc1Stream, VideoStream::class.java)
|
||||
val videoArguments = getVideoArguments(data,
|
||||
Preference.getPreference().encodePreference.video
|
||||
.copy(codec = "h264", reencodeOnIncorrectMetadataForChromecast = true)
|
||||
)
|
||||
assertThat(videoArguments.isVideoCodecEqual()).isFalse()
|
||||
assertThat(videoArguments.getCodec()).isEqualTo("vc1")
|
||||
assertThat(videoArguments.getVideoArguments().codecParameters.take(2)).isEqualTo(listOf("-c:v", "libx264"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun h264Stream1() {
|
||||
val data = Gson().fromJson(h264stream1, VideoStream::class.java)
|
||||
val videoArguments = getVideoArguments(data,
|
||||
Preference.getPreference().encodePreference.video
|
||||
.copy(codec = "h265", reencodeOnIncorrectMetadataForChromecast = true)
|
||||
)
|
||||
assertThat(videoArguments.isVideoCodecEqual()).isFalse()
|
||||
assertThat(videoArguments.getCodec()).isEqualTo("libx264")
|
||||
assertThat(videoArguments.getVideoArguments().codecParameters.take(2)).isEqualTo(listOf("-c:v", "libx265"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun h264Stream2() {
|
||||
val data = Gson().fromJson(h264stream1, VideoStream::class.java)
|
||||
val videoArguments = getVideoArguments(data,
|
||||
Preference.getPreference().encodePreference.video
|
||||
.copy(reencodeOnIncorrectMetadataForChromecast = true)
|
||||
)
|
||||
assertThat(videoArguments.isVideoCodecEqual()).isTrue()
|
||||
assertThat(videoArguments.getCodec()).isEqualTo("libx264")
|
||||
assertThat(videoArguments.getVideoArguments().codecParameters.take(2)).isEqualTo(listOf("-c:v", "copy"))
|
||||
}
|
||||
|
||||
val hevcStream1 = """
|
||||
{
|
||||
"index": 0,
|
||||
"codec_name": "hevc",
|
||||
"codec_long_name": "H.265 / HEVC (High Efficiency Video Coding)",
|
||||
"profile": "Main 10",
|
||||
"codec_type": "video",
|
||||
"codec_tag_string": "hev1",
|
||||
"codec_tag": "0x31766568",
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"coded_width": 1920,
|
||||
"coded_height": 1080,
|
||||
"closed_captions": 0,
|
||||
"film_grain": 0,
|
||||
"has_b_frames": 2,
|
||||
"sample_aspect_ratio": "1:1",
|
||||
"display_aspect_ratio": "16:9",
|
||||
"pix_fmt": "yuv420p10le",
|
||||
"level": 150,
|
||||
"color_range": "tv",
|
||||
"chroma_location": "left",
|
||||
"refs": 1,
|
||||
"id": "0x1",
|
||||
"r_frame_rate": "24000/1001",
|
||||
"avg_frame_rate": "34045000/1419959",
|
||||
"time_base": "1/16000",
|
||||
"start_pts": 0,
|
||||
"start_time": "0.000000",
|
||||
"duration_ts": 22719344,
|
||||
"duration": "1419.959000",
|
||||
"bit_rate": "2020313",
|
||||
"nb_frames": "34045",
|
||||
"extradata_size": 2535,
|
||||
"disposition": {
|
||||
"default": 1,
|
||||
"dub": 0,
|
||||
"original": 0,
|
||||
"comment": 0,
|
||||
"lyrics": 0,
|
||||
"karaoke": 0,
|
||||
"forced": 0,
|
||||
"hearing_impaired": 0,
|
||||
"visual_impaired": 0,
|
||||
"clean_effects": 0,
|
||||
"attached_pic": 0,
|
||||
"timed_thumbnails": 0,
|
||||
"non_diegetic": 0,
|
||||
"captions": 0,
|
||||
"descriptions": 0,
|
||||
"metadata": 0,
|
||||
"dependent": 0,
|
||||
"still_image": 0
|
||||
},
|
||||
"tags": {
|
||||
"language": "jpn",
|
||||
"handler_name": "VideoHandler",
|
||||
"vendor_id": "[0][0][0][0]"
|
||||
}
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val hevcStream2 = """
|
||||
{
|
||||
"index": 0,
|
||||
"codec_name": "hevc",
|
||||
"codec_long_name": "H.265 / HEVC (High Efficiency Video Coding)",
|
||||
"profile": "Main 10",
|
||||
"codec_type": "video",
|
||||
"codec_tag_string": "[0][0][0][0]",
|
||||
"codec_tag": "0x0000",
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"coded_width": 1920,
|
||||
"coded_height": 1080,
|
||||
"closed_captions": 0,
|
||||
"film_grain": 0,
|
||||
"has_b_frames": 2,
|
||||
"sample_aspect_ratio": "1:1",
|
||||
"display_aspect_ratio": "16:9",
|
||||
"pix_fmt": "yuv420p10le",
|
||||
"level": 150,
|
||||
"color_range": "tv",
|
||||
"chroma_location": "left",
|
||||
"refs": 1,
|
||||
"r_frame_rate": "24000/1001",
|
||||
"avg_frame_rate": "24000/1001",
|
||||
"time_base": "1/1000",
|
||||
"start_pts": 0,
|
||||
"start_time": "0.000000",
|
||||
"extradata_size": 2535,
|
||||
"disposition": {
|
||||
"default": 1,
|
||||
"dub": 0,
|
||||
"original": 0,
|
||||
"comment": 0,
|
||||
"lyrics": 0,
|
||||
"karaoke": 0,
|
||||
"forced": 0,
|
||||
"hearing_impaired": 0,
|
||||
"visual_impaired": 0,
|
||||
"clean_effects": 0,
|
||||
"attached_pic": 0,
|
||||
"timed_thumbnails": 0,
|
||||
"non_diegetic": 0,
|
||||
"captions": 0,
|
||||
"descriptions": 0,
|
||||
"metadata": 0,
|
||||
"dependent": 0,
|
||||
"still_image": 0
|
||||
},
|
||||
"tags": {
|
||||
"language": "jpn",
|
||||
"title": "nan",
|
||||
"BPS": "2105884",
|
||||
"DURATION": "00:53:27.204000000",
|
||||
"NUMBER_OF_FRAMES": "76896",
|
||||
"NUMBER_OF_BYTES": "844250247",
|
||||
"_STATISTICS_WRITING_APP": "mkvmerge v91.0 ('Signs') 64-bit",
|
||||
"_STATISTICS_WRITING_DATE_UTC": "2025-03-31 17:33:38",
|
||||
"_STATISTICS_TAGS": "BPS DURATION NUMBER_OF_FRAMES NUMBER_OF_BYTES"
|
||||
}
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
|
||||
val vc1Stream = """
|
||||
{
|
||||
"index": 0,
|
||||
"codec_name": "vc1",
|
||||
"codec_long_name": "SMPTE VC-1",
|
||||
"profile": "Advanced",
|
||||
"codec_type": "video",
|
||||
"codec_tag_string": "WVC1",
|
||||
"codec_tag": "0x31435657",
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"coded_width": 1920,
|
||||
"coded_height": 1080,
|
||||
"closed_captions": 0,
|
||||
"film_grain": 0,
|
||||
"has_b_frames": 1,
|
||||
"sample_aspect_ratio": "1:1",
|
||||
"display_aspect_ratio": "16:9",
|
||||
"pix_fmt": "yuv420p",
|
||||
"level": 3,
|
||||
"chroma_location": "left",
|
||||
"field_order": "progressive",
|
||||
"refs": 1,
|
||||
"r_frame_rate": "24000/1001",
|
||||
"avg_frame_rate": "24000/1001",
|
||||
"time_base": "1/1000",
|
||||
"start_pts": 0,
|
||||
"start_time": "0.000000",
|
||||
"duration_ts": 5189856,
|
||||
"duration": "5189.856000",
|
||||
"extradata_size": 34,
|
||||
"disposition": {
|
||||
"default": 1,
|
||||
"dub": 0,
|
||||
"original": 0,
|
||||
"comment": 0,
|
||||
"lyrics": 0,
|
||||
"karaoke": 0,
|
||||
"forced": 0,
|
||||
"hearing_impaired": 0,
|
||||
"visual_impaired": 0,
|
||||
"clean_effects": 0,
|
||||
"attached_pic": 0,
|
||||
"timed_thumbnails": 0,
|
||||
"non_diegetic": 0,
|
||||
"captions": 0,
|
||||
"descriptions": 0,
|
||||
"metadata": 0,
|
||||
"dependent": 0,
|
||||
"still_image": 0
|
||||
},
|
||||
"tags": {
|
||||
"title": ""
|
||||
}
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val h264stream1 = """
|
||||
{
|
||||
"index": 0,
|
||||
"codec_name": "h264",
|
||||
"codec_long_name": "H.264 / AVC / MPEG-4 AVC / MPEG-4 part 10",
|
||||
"profile": "High",
|
||||
"codec_type": "video",
|
||||
"codec_tag_string": "avc1",
|
||||
"codec_tag": "0x31637661",
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"coded_width": 1920,
|
||||
"coded_height": 1080,
|
||||
"closed_captions": 0,
|
||||
"film_grain": 0,
|
||||
"has_b_frames": 2,
|
||||
"sample_aspect_ratio": "1:1",
|
||||
"display_aspect_ratio": "16:9",
|
||||
"pix_fmt": "yuv420p",
|
||||
"level": 40,
|
||||
"chroma_location": "left",
|
||||
"field_order": "progressive",
|
||||
"refs": 1,
|
||||
"is_avc": "true",
|
||||
"nal_length_size": "4",
|
||||
"id": "0x1",
|
||||
"r_frame_rate": "30000/1001",
|
||||
"avg_frame_rate": "30000/1001",
|
||||
"time_base": "1/30000",
|
||||
"start_pts": 0,
|
||||
"start_time": "0.000000",
|
||||
"duration_ts": 185519300,
|
||||
"duration": "6183.976667",
|
||||
"bit_rate": "6460534",
|
||||
"bits_per_raw_sample": "8",
|
||||
"nb_frames": "185334",
|
||||
"extradata_size": 45,
|
||||
"disposition": {
|
||||
"default": 1,
|
||||
"dub": 0,
|
||||
"original": 0,
|
||||
"comment": 0,
|
||||
"lyrics": 0,
|
||||
"karaoke": 0,
|
||||
"forced": 0,
|
||||
"hearing_impaired": 0,
|
||||
"visual_impaired": 0,
|
||||
"clean_effects": 0,
|
||||
"attached_pic": 0,
|
||||
"timed_thumbnails": 0,
|
||||
"non_diegetic": 0,
|
||||
"captions": 0,
|
||||
"descriptions": 0,
|
||||
"metadata": 0,
|
||||
"dependent": 0,
|
||||
"still_image": 0
|
||||
},
|
||||
"tags": {
|
||||
"creation_time": "2018-07-09T06:20:13.000000Z",
|
||||
"language": "und",
|
||||
"handler_name": "nah",
|
||||
"vendor_id": "[0][0][0][0]"
|
||||
}
|
||||
}
|
||||
""".trimIndent()
|
||||
}
|
||||
@ -21,7 +21,6 @@ repositories {
|
||||
}
|
||||
}
|
||||
|
||||
val exposedVersion = "0.44.0"
|
||||
dependencies {
|
||||
|
||||
/*Spring boot*/
|
||||
@ -29,20 +28,15 @@ dependencies {
|
||||
implementation("org.springframework.boot:spring-boot-starter:2.7.0")
|
||||
// implementation("org.springframework.kafka:spring-kafka:3.0.1")
|
||||
implementation("org.springframework.boot:spring-boot-starter-websocket:2.6.3")
|
||||
implementation("org.springframework.kafka:spring-kafka:2.8.5")
|
||||
|
||||
|
||||
implementation("org.jetbrains.exposed:exposed-core:$exposedVersion")
|
||||
implementation("org.jetbrains.exposed:exposed-dao:$exposedVersion")
|
||||
implementation("org.jetbrains.exposed:exposed-jdbc:$exposedVersion")
|
||||
implementation("org.jetbrains.exposed:exposed-java-time:$exposedVersion")
|
||||
implementation ("mysql:mysql-connector-java:8.0.29")
|
||||
|
||||
implementation("io.github.microutils:kotlin-logging-jvm:2.0.11")
|
||||
implementation("com.google.code.gson:gson:2.8.9")
|
||||
implementation("org.json:json:20210307")
|
||||
|
||||
implementation("no.iktdev:exfl:0.0.16-SNAPSHOT")
|
||||
implementation("no.iktdev:eventi:1.0-rc13")
|
||||
|
||||
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.1")
|
||||
implementation("com.github.vishna:watchservice-ktx:master-SNAPSHOT")
|
||||
@ -65,6 +59,7 @@ dependencies {
|
||||
testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:5.7.2")
|
||||
testImplementation("io.kotlintest:kotlintest-assertions:3.3.2")
|
||||
testImplementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.3.0")
|
||||
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2")
|
||||
implementation(kotlin("stdlib-jdk8"))
|
||||
}
|
||||
|
||||
|
||||
@ -0,0 +1,52 @@
|
||||
package no.iktdev.mediaprocessing.processer
|
||||
|
||||
import mu.KotlinLogging
|
||||
import no.iktdev.eventi.events.EventTypeRegistry
|
||||
import no.iktdev.eventi.tasks.TaskTypeRegistry
|
||||
import no.iktdev.exfl.coroutines.CoroutinesDefault
|
||||
import no.iktdev.exfl.coroutines.CoroutinesIO
|
||||
import no.iktdev.exfl.observable.Observables
|
||||
import no.iktdev.mediaprocessing.shared.common.DatabaseApplication
|
||||
import no.iktdev.mediaprocessing.shared.common.event_task_contract.EventRegistry
|
||||
import no.iktdev.mediaprocessing.shared.common.event_task_contract.TaskRegistry
|
||||
import no.iktdev.mediaprocessing.shared.common.getAppVersion
|
||||
import org.springframework.boot.runApplication
|
||||
import org.springframework.context.annotation.Configuration
|
||||
|
||||
class ProcesserApplication: DatabaseApplication() {
|
||||
}
|
||||
|
||||
val ioCoroutine = CoroutinesIO()
|
||||
val defaultCoroutine = CoroutinesDefault()
|
||||
|
||||
private val log = KotlinLogging.logger {}
|
||||
|
||||
fun main(args: Array<String>) {
|
||||
ioCoroutine.addListener(listener = object: Observables.ObservableValue.ValueListener<Throwable> {
|
||||
override fun onUpdated(value: Throwable) {
|
||||
value.printStackTrace()
|
||||
}
|
||||
})
|
||||
defaultCoroutine.addListener(listener = object: Observables.ObservableValue.ValueListener<Throwable> {
|
||||
override fun onUpdated(value: Throwable) {
|
||||
value.printStackTrace()
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
runApplication<ProcesserApplication>(*args)
|
||||
log.info { "App Version: ${getAppVersion()}" }
|
||||
}
|
||||
//private val logger = KotlinLogging.logger {}
|
||||
|
||||
@Configuration
|
||||
open class ApplicationConfiguration() {
|
||||
init {
|
||||
EventRegistry.getEvents().let {
|
||||
EventTypeRegistry.register(it)
|
||||
}
|
||||
TaskRegistry.getTasks().let {
|
||||
TaskTypeRegistry.register(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,26 @@
|
||||
package no.iktdev.mediaprocessing.processer
|
||||
|
||||
import no.iktdev.exfl.using
|
||||
import java.io.File
|
||||
|
||||
class ProcesserEnv {
|
||||
companion object {
|
||||
val wsAllowedOrigins: String = System.getenv("AllowedOriginsWebsocket")?.takeIf { it.isNotBlank() } ?: ""
|
||||
|
||||
val ffmpeg: String = System.getenv("SUPPORTING_EXECUTABLE_FFMPEG") ?: "ffmpeg"
|
||||
val allowOverwrite = System.getenv("ALLOW_OVERWRITE").toBoolean() ?: false
|
||||
|
||||
var cachedContent: File = if (!System.getenv("DIRECTORY_CONTENT_CACHE").isNullOrBlank()) File(System.getenv("DIRECTORY_CONTENT_CACHE")) else File("/src/cache")
|
||||
val outgoingContent: File = if (!System.getenv("DIRECTORY_CONTENT_OUTGOING").isNullOrBlank()) File(System.getenv("DIRECTORY_CONTENT_OUTGOING")) else File("/src/output")
|
||||
|
||||
|
||||
val logDirectory = if (!System.getenv("LOG_DIR").isNullOrBlank()) File(System.getenv("LOG_DIR")) else
|
||||
File("data").using("logs")
|
||||
|
||||
val encodeLogDirectory = logDirectory.using("encode")
|
||||
val extractLogDirectory = logDirectory.using("extract")
|
||||
val subtitleExtractLogDirectory = logDirectory.using("subtitles")
|
||||
|
||||
val fullLogging = System.getenv("FullLogging").toBoolean()
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,65 @@
|
||||
package no.iktdev.mediaprocessing.processer
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import no.iktdev.eventi.models.Event
|
||||
import no.iktdev.eventi.tasks.AbstractTaskPoller
|
||||
import no.iktdev.eventi.tasks.TaskReporter
|
||||
import no.iktdev.mediaprocessing.shared.common.stores.EventStore
|
||||
import no.iktdev.mediaprocessing.shared.common.stores.TaskStore
|
||||
import org.springframework.boot.ApplicationArguments
|
||||
import org.springframework.boot.ApplicationRunner
|
||||
import org.springframework.stereotype.Component
|
||||
import org.springframework.stereotype.Service
|
||||
import java.util.UUID
|
||||
|
||||
@Component
|
||||
class PollerAdministrator(
|
||||
private val taskPoller: TaskPoller,
|
||||
): ApplicationRunner {
|
||||
override fun run(args: ApplicationArguments?) {
|
||||
CoroutineScope(Dispatchers.Default).launch {
|
||||
taskPoller.start()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Service
|
||||
class TaskPoller(
|
||||
private val reporter: TaskReporter,
|
||||
) : AbstractTaskPoller(
|
||||
taskStore = TaskStore,
|
||||
reporterFactory = { reporter } // én reporter brukes for alle tasks
|
||||
) {
|
||||
|
||||
}
|
||||
|
||||
|
||||
@Component
|
||||
class DefaultTaskReporter() : TaskReporter {
|
||||
override fun markClaimed(taskId: UUID, workerId: String) {
|
||||
TaskStore.claim(taskId, workerId)
|
||||
}
|
||||
|
||||
override fun updateLastSeen(taskId: UUID) {
|
||||
TaskStore.heartbeat(taskId)
|
||||
}
|
||||
|
||||
override fun markConsumed(taskId: UUID) {
|
||||
TaskStore.markConsumed(taskId)
|
||||
}
|
||||
|
||||
override fun updateProgress(taskId: UUID, progress: Int) {
|
||||
// Not to be implemented for this application
|
||||
}
|
||||
|
||||
override fun log(taskId: UUID, message: String) {
|
||||
// Not to be implemented for this application
|
||||
}
|
||||
|
||||
override fun publishEvent(event: Event) {
|
||||
EventStore.persist(event)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,10 @@
|
||||
package no.iktdev.mediaprocessing.processer
|
||||
|
||||
import no.iktdev.exfl.using
|
||||
import java.io.File
|
||||
|
||||
object Util {
|
||||
fun getTemporaryStoreFile(fileName: String): File {
|
||||
return ProcesserEnv.cachedContent.using(fileName)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,9 @@
|
||||
package no.iktdev.mediaprocessing.processer.listeners
|
||||
|
||||
import no.iktdev.eventi.tasks.TaskListener
|
||||
import no.iktdev.eventi.tasks.TaskType
|
||||
import no.iktdev.mediaprocessing.ffmpeg.FFmpeg
|
||||
|
||||
abstract class FfmpegTaskListener(taskType: TaskType): TaskListener(taskType) {
|
||||
abstract fun getFfmpeg(): FFmpeg
|
||||
}
|
||||
@ -0,0 +1,4 @@
|
||||
package no.iktdev.mediaprocessing.processer.listeners
|
||||
|
||||
class MuxAudioVideoTaskListener {
|
||||
}
|
||||
@ -0,0 +1,74 @@
|
||||
package no.iktdev.mediaprocessing.processer.listeners
|
||||
|
||||
import no.iktdev.eventi.models.Event
|
||||
import no.iktdev.eventi.models.Task
|
||||
import no.iktdev.eventi.models.store.TaskStatus
|
||||
import no.iktdev.eventi.tasks.TaskListener
|
||||
import no.iktdev.eventi.tasks.TaskType
|
||||
import no.iktdev.mediaprocessing.ffmpeg.FFmpeg
|
||||
import no.iktdev.mediaprocessing.ffmpeg.arguments.MpegArgument
|
||||
import no.iktdev.mediaprocessing.processer.ProcesserEnv
|
||||
import no.iktdev.mediaprocessing.processer.Util
|
||||
import no.iktdev.mediaprocessing.shared.common.event_task_contract.events.ExtractResult
|
||||
import no.iktdev.mediaprocessing.shared.common.event_task_contract.events.ProcesserExtractEvent
|
||||
import no.iktdev.mediaprocessing.shared.common.event_task_contract.tasks.ExtractSubtitleTask
|
||||
import org.springframework.stereotype.Service
|
||||
import java.util.UUID
|
||||
|
||||
@Service
|
||||
class SubtitleTaskListener: TaskListener(TaskType.CPU_INTENSIVE) {
|
||||
override fun getWorkerId() = "${this::class.java.simpleName}-${taskType}-${UUID.randomUUID()}"
|
||||
|
||||
override fun supports(task: Task) = task is ExtractSubtitleTask
|
||||
|
||||
override suspend fun onTask(task: Task): Event? {
|
||||
val taskData = task as ExtractSubtitleTask
|
||||
|
||||
val cachedOutFile = Util.getTemporaryStoreFile(taskData.data.outputFileName).also {
|
||||
if (!it.parentFile.exists()) {
|
||||
it.parentFile.mkdirs()
|
||||
}
|
||||
}
|
||||
|
||||
if (cachedOutFile.exists() && taskData.data.arguments.firstOrNull() != "-y") {
|
||||
reporter?.publishEvent(ProcesserExtractEvent(
|
||||
data = ExtractResult(
|
||||
status = TaskStatus.Failed
|
||||
)
|
||||
).producedFrom(task))
|
||||
throw IllegalStateException("${cachedOutFile.absolutePath} does already exist, and arguments does not permit overwrite")
|
||||
}
|
||||
|
||||
val arguments = MpegArgument()
|
||||
.inputFile(taskData.data.inputFile)
|
||||
.outputFile(cachedOutFile.absolutePath)
|
||||
.args(taskData.data.arguments)
|
||||
|
||||
val result = SubtitleFFmpeg()
|
||||
withHeartbeatRunner {
|
||||
reporter?.updateLastSeen(task.taskId)
|
||||
}
|
||||
result.run(arguments)
|
||||
if (result.result.resultCode != 0 ) {
|
||||
return ProcesserExtractEvent(data = ExtractResult(status = TaskStatus.Failed)).producedFrom(task)
|
||||
}
|
||||
|
||||
return ProcesserExtractEvent(
|
||||
data = ExtractResult(
|
||||
status = TaskStatus.Completed,
|
||||
cachedOutputFile = cachedOutFile.absolutePath
|
||||
)
|
||||
).producedFrom(task)
|
||||
}
|
||||
|
||||
|
||||
|
||||
class SubtitleFFmpeg(override val listener: Listener? = null): FFmpeg(executable = ProcesserEnv.ffmpeg, logDir = ProcesserEnv.subtitleExtractLogDirectory ) {
|
||||
|
||||
override fun onCreate() {
|
||||
if (!ProcesserEnv.subtitleExtractLogDirectory.exists()) {
|
||||
ProcesserEnv.subtitleExtractLogDirectory.mkdirs()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,89 @@
|
||||
package no.iktdev.mediaprocessing.processer.listeners
|
||||
|
||||
import no.iktdev.eventi.models.Event
|
||||
import no.iktdev.eventi.models.Task
|
||||
import no.iktdev.eventi.models.store.TaskStatus
|
||||
import no.iktdev.eventi.tasks.TaskType
|
||||
import no.iktdev.mediaprocessing.ffmpeg.FFmpeg
|
||||
import no.iktdev.mediaprocessing.ffmpeg.arguments.MpegArgument
|
||||
import no.iktdev.mediaprocessing.ffmpeg.decoder.FfmpegDecodedProgress
|
||||
import no.iktdev.mediaprocessing.processer.ProcesserEnv
|
||||
import no.iktdev.mediaprocessing.processer.Util
|
||||
import no.iktdev.mediaprocessing.shared.common.event_task_contract.events.EncodeResult
|
||||
import no.iktdev.mediaprocessing.shared.common.event_task_contract.events.ProcesserEncodeEvent
|
||||
import no.iktdev.mediaprocessing.shared.common.event_task_contract.tasks.EncodeTask
|
||||
import org.springframework.stereotype.Service
|
||||
import java.util.*
|
||||
|
||||
@Service
|
||||
class VideoTaskListener: FfmpegTaskListener(TaskType.CPU_INTENSIVE) {
|
||||
override fun getWorkerId() = "${this::class.java.simpleName}-${taskType}-${UUID.randomUUID()}"
|
||||
|
||||
override fun supports(task: Task) = task is EncodeTask
|
||||
|
||||
override suspend fun onTask(task: Task): Event? {
|
||||
val taskData = task as EncodeTask
|
||||
val cachedOutFile = Util.getTemporaryStoreFile(taskData.data.outputFileName).also {
|
||||
if (!it.parentFile.exists()) {
|
||||
it.parentFile.mkdirs()
|
||||
}
|
||||
}
|
||||
if (cachedOutFile.exists() && taskData.data.arguments.firstOrNull() != "-y") {
|
||||
reporter?.publishEvent(ProcesserEncodeEvent(
|
||||
data = EncodeResult(
|
||||
status = TaskStatus.Failed
|
||||
)
|
||||
).producedFrom(task))
|
||||
throw IllegalStateException("${cachedOutFile.absolutePath} does already exist, and arguments does not permit overwrite")
|
||||
}
|
||||
|
||||
val arguments = MpegArgument()
|
||||
.inputFile(taskData.data.inputFile)
|
||||
.outputFile(cachedOutFile.absolutePath)
|
||||
.args(taskData.data.arguments)
|
||||
.withProgress(true)
|
||||
|
||||
val result = getFfmpeg()
|
||||
withHeartbeatRunner {
|
||||
reporter?.updateLastSeen(task.taskId)
|
||||
}
|
||||
result.run(arguments)
|
||||
if (result.result.resultCode != 0 ) {
|
||||
return ProcesserEncodeEvent(data = EncodeResult(status = TaskStatus.Failed)).producedFrom(task)
|
||||
}
|
||||
|
||||
return ProcesserEncodeEvent(
|
||||
data = EncodeResult(
|
||||
status = TaskStatus.Completed,
|
||||
cachedOutputFile = cachedOutFile.absolutePath
|
||||
)
|
||||
).producedFrom(task)
|
||||
}
|
||||
|
||||
override fun getFfmpeg(): FFmpeg {
|
||||
return VideoFFmpeg(object : FFmpeg.Listener {
|
||||
override fun onStarted(inputFile: String) {
|
||||
}
|
||||
|
||||
override fun onCompleted(inputFile: String, outputFile: String) {
|
||||
}
|
||||
|
||||
override fun onProgressChanged(
|
||||
inputFile: String,
|
||||
progress: FfmpegDecodedProgress
|
||||
) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
class VideoFFmpeg(override val listener: Listener? = null): FFmpeg(executable = ProcesserEnv.ffmpeg, logDir = ProcesserEnv.encodeLogDirectory) {
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
if (!ProcesserEnv.encodeLogDirectory.exists()) {
|
||||
ProcesserEnv.encodeLogDirectory.mkdirs()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,15 +0,0 @@
|
||||
package no.iktdev.mediaprocessing.processer.ffmpeg
|
||||
|
||||
import no.iktdev.mediaprocessing.processer.Files
|
||||
import no.iktdev.mediaprocessing.processer.getAsList
|
||||
import org.junit.jupiter.api.Assertions.*
|
||||
import org.junit.jupiter.api.DisplayName
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class FfmpegRunnerTest {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
@ -0,0 +1,38 @@
|
||||
package no.iktdev.mediaprocessing.processer.listeners
|
||||
|
||||
import com.github.pgreze.process.ProcessResult
|
||||
import kotlinx.coroutines.delay
|
||||
import no.iktdev.mediaprocessing.ffmpeg.FFmpeg
|
||||
import no.iktdev.mediaprocessing.ffmpeg.arguments.MpegArgument
|
||||
import no.iktdev.mediaprocessing.ffmpeg.decoder.FfmpegDecodedProgress
|
||||
import java.io.File
|
||||
|
||||
class MockFFmpeg(override val listener: Listener, val delayMillis: Long = 500, private val simulateSuccess: Boolean = true) : FFmpeg(executable = "", logDir = File("/null")) {
|
||||
|
||||
companion object {
|
||||
fun emptyListener() = object : Listener {
|
||||
override fun onStarted(inputFile: String) {}
|
||||
override fun onCompleted(inputFile: String, outputFile: String) {}
|
||||
override fun onProgressChanged(inputFile: String, progress: FfmpegDecodedProgress) {}
|
||||
override fun onError(inputFile: String, message: String) {}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun run(argument: MpegArgument) {
|
||||
inputFile = argument.inputFile!!
|
||||
listener.onStarted(argument.inputFile!!)
|
||||
delay(delayMillis)
|
||||
|
||||
result = ProcessResult(
|
||||
resultCode = if (simulateSuccess) 0 else 1,
|
||||
output = listOf("Simulated ffmpeg output")
|
||||
)
|
||||
|
||||
if (simulateSuccess) {
|
||||
listener.onCompleted(inputFile, argument.outputFile!!)
|
||||
} else {
|
||||
listener.onError(inputFile, "Simulated error")
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,80 @@
|
||||
package no.iktdev.mediaprocessing.processer.listeners
|
||||
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import no.iktdev.eventi.models.Event
|
||||
import no.iktdev.eventi.models.Task
|
||||
import no.iktdev.eventi.models.store.TaskStatus
|
||||
import no.iktdev.eventi.tasks.TaskReporter
|
||||
import no.iktdev.eventi.tasks.TaskTypeRegistry
|
||||
import no.iktdev.mediaprocessing.ffmpeg.FFmpeg
|
||||
import no.iktdev.mediaprocessing.shared.common.event_task_contract.events.ProcesserEncodeEvent
|
||||
import no.iktdev.mediaprocessing.shared.common.event_task_contract.tasks.EncodeData
|
||||
import no.iktdev.mediaprocessing.shared.common.event_task_contract.tasks.EncodeTask
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.util.UUID
|
||||
import kotlin.system.measureTimeMillis
|
||||
|
||||
class VideoTaskListenerTest {
|
||||
|
||||
class TestListener(val delay: Long): VideoTaskListener() {
|
||||
fun getJob() = currentJob
|
||||
|
||||
private var _result: Event? = null
|
||||
fun getResult(): Event? {
|
||||
return _result
|
||||
}
|
||||
override fun onComplete(task: Task, result: Event?) {
|
||||
this._result = result
|
||||
}
|
||||
|
||||
override fun getFfmpeg(): FFmpeg {
|
||||
return MockFFmpeg(delayMillis = delay, listener = MockFFmpeg.emptyListener())
|
||||
}
|
||||
}
|
||||
|
||||
val overrideReporter = object : TaskReporter {
|
||||
override fun markClaimed(taskId: UUID, workerId: String) {}
|
||||
override fun updateLastSeen(taskId: UUID) {}
|
||||
override fun markConsumed(taskId: UUID) {}
|
||||
override fun updateProgress(taskId: UUID, progress: Int) {}
|
||||
override fun log(taskId: UUID, message: String) {}
|
||||
override fun publishEvent(event: Event) {
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
TaskTypeRegistry.register(EncodeTask::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `onTask waits for runner to complete`() = runTest {
|
||||
val delay = 1000L
|
||||
val testTask = EncodeTask(
|
||||
EncodeData(
|
||||
inputFile = "input.mp4",
|
||||
outputFileName = "output.mp4",
|
||||
arguments = listOf("-y")
|
||||
)
|
||||
).newReferenceId()
|
||||
|
||||
val listener = TestListener(delay)
|
||||
|
||||
val time = measureTimeMillis {
|
||||
listener.accept(testTask, overrideReporter)
|
||||
listener.getJob()?.join()
|
||||
val event = listener.getResult()
|
||||
assertTrue(event is ProcesserEncodeEvent)
|
||||
assertEquals(TaskStatus.Completed, (event as ProcesserEncodeEvent).data.status)
|
||||
}
|
||||
|
||||
assertTrue(time >= delay, "Expected onTask to wait at least $delay ms, waited for $time ms")
|
||||
assertTrue(time <= (delay*2), "Expected onTask to wait less than ${(delay*2)} ms, waited for $time ms")
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@ -1,4 +0,0 @@
|
||||
package no.iktdev.mediaprocessing.processer.services
|
||||
|
||||
class EncodeServiceTest {
|
||||
}
|
||||
@ -59,7 +59,7 @@ dependencies {
|
||||
|
||||
implementation("com.zaxxer:HikariCP:7.0.2")
|
||||
|
||||
|
||||
implementation(project(":shared:ffmpeg"))
|
||||
implementation("no.iktdev:eventi:1.0-rc13")
|
||||
|
||||
testImplementation(platform("org.junit:junit-bom:5.10.0"))
|
||||
|
||||
@ -1,26 +1,26 @@
|
||||
package no.iktdev.mediaprocessing.shared.common.event_task_contract
|
||||
|
||||
import no.iktdev.mediaprocessing.shared.common.event_task_contract.events.ConvertTaskCreatedEvents
|
||||
import no.iktdev.mediaprocessing.shared.common.event_task_contract.events.ConvertTaskPerformedEvent
|
||||
import no.iktdev.mediaprocessing.shared.common.event_task_contract.events.ConvertTaskResultEvent
|
||||
import no.iktdev.mediaprocessing.shared.common.event_task_contract.events.FileAddedEvent
|
||||
import no.iktdev.mediaprocessing.shared.common.event_task_contract.events.FileReadyEvent
|
||||
import no.iktdev.mediaprocessing.shared.common.event_task_contract.events.FileRemovedEvent
|
||||
import no.iktdev.mediaprocessing.shared.common.event_task_contract.events.MediaParsedInfoEvent
|
||||
import no.iktdev.mediaprocessing.shared.common.event_task_contract.events.MetadataSearchTaskCreated
|
||||
import no.iktdev.mediaprocessing.shared.common.event_task_contract.events.MetadataSearchTaskPerformed
|
||||
import no.iktdev.mediaprocessing.shared.common.event_task_contract.events.ProcesserEncodePerformedEvent
|
||||
import no.iktdev.mediaprocessing.shared.common.event_task_contract.events.ProcesserEncodeEvent
|
||||
import no.iktdev.mediaprocessing.shared.common.event_task_contract.events.ProcesserEncodeTaskCreatedEvent
|
||||
import no.iktdev.mediaprocessing.shared.common.event_task_contract.events.ProcesserExtractTaskCreatedEvent
|
||||
import no.iktdev.mediaprocessing.shared.common.event_task_contract.events.ProcesserExtractedPerformedEvent
|
||||
import no.iktdev.mediaprocessing.shared.common.event_task_contract.events.ProcesserExtractEvent
|
||||
import no.iktdev.mediaprocessing.shared.common.event_task_contract.events.ProcesserReadTaskCreatedEvent
|
||||
import no.iktdev.mediaprocessing.shared.common.event_task_contract.events.StartProcessingEvent
|
||||
import no.iktdev.eventi.models.Event
|
||||
import no.iktdev.mediaprocessing.shared.common.event_task_contract.events.ConvertTaskCreatedEvent
|
||||
|
||||
object EventRegistry {
|
||||
fun getEvents(): List<Class<out Event>> {
|
||||
return listOf(
|
||||
ConvertTaskCreatedEvents::class.java,
|
||||
ConvertTaskPerformedEvent::class.java,
|
||||
ConvertTaskCreatedEvent::class.java,
|
||||
ConvertTaskResultEvent::class.java,
|
||||
|
||||
FileAddedEvent::class.java,
|
||||
FileReadyEvent::class.java,
|
||||
@ -32,10 +32,10 @@ object EventRegistry {
|
||||
MetadataSearchTaskPerformed::class.java,
|
||||
|
||||
ProcesserExtractTaskCreatedEvent::class.java,
|
||||
ProcesserExtractedPerformedEvent::class.java,
|
||||
ProcesserExtractEvent::class.java,
|
||||
|
||||
ProcesserEncodeTaskCreatedEvent::class.java,
|
||||
ProcesserEncodePerformedEvent::class.java,
|
||||
ProcesserEncodeEvent::class.java,
|
||||
|
||||
ProcesserReadTaskCreatedEvent::class.java, // Do i need this?
|
||||
|
||||
|
||||
@ -2,7 +2,7 @@ package no.iktdev.mediaprocessing.shared.common.event_task_contract
|
||||
|
||||
import no.iktdev.mediaprocessing.shared.common.event_task_contract.tasks.ConvertTask
|
||||
import no.iktdev.mediaprocessing.shared.common.event_task_contract.tasks.EncodeTask
|
||||
import no.iktdev.mediaprocessing.shared.common.event_task_contract.tasks.ExtractTask
|
||||
import no.iktdev.mediaprocessing.shared.common.event_task_contract.tasks.ExtractSubtitleTask
|
||||
import no.iktdev.mediaprocessing.shared.common.event_task_contract.tasks.MediaReadTask
|
||||
import no.iktdev.mediaprocessing.shared.common.event_task_contract.tasks.MetadataSearchTask
|
||||
import no.iktdev.eventi.models.Task
|
||||
@ -12,7 +12,7 @@ object TaskRegistry {
|
||||
return listOf(
|
||||
ConvertTask::class.java,
|
||||
EncodeTask::class.java,
|
||||
ExtractTask::class.java,
|
||||
ExtractSubtitleTask::class.java,
|
||||
MediaReadTask::class.java,
|
||||
MetadataSearchTask::class.java
|
||||
)
|
||||
|
||||
@ -0,0 +1,6 @@
|
||||
package no.iktdev.mediaprocessing.shared.common.event_task_contract.events
|
||||
|
||||
import no.iktdev.eventi.models.Event
|
||||
|
||||
class ConvertTaskCreatedEvent: Event() {
|
||||
}
|
||||
@ -3,12 +3,7 @@ package no.iktdev.mediaprocessing.shared.common.event_task_contract.events
|
||||
import no.iktdev.eventi.models.Event
|
||||
import no.iktdev.eventi.models.store.TaskStatus
|
||||
|
||||
|
||||
// Placeholder event, so that the listener does not continue to create tasks
|
||||
class ConvertTaskCreatedEvents: Event() {
|
||||
}
|
||||
|
||||
data class ConvertTaskPerformedEvent(
|
||||
data class ConvertTaskResultEvent(
|
||||
val data: ConvertedData?,
|
||||
val status: TaskStatus,
|
||||
): Event() {
|
||||
@ -0,0 +1,9 @@
|
||||
package no.iktdev.mediaprocessing.shared.common.event_task_contract.events
|
||||
|
||||
import no.iktdev.eventi.models.Event
|
||||
import no.iktdev.mediaprocessing.shared.common.model.FileInfo
|
||||
|
||||
data class FileAddedEvent(
|
||||
val data: FileInfo
|
||||
): Event() {
|
||||
}
|
||||
@ -1,23 +0,0 @@
|
||||
package no.iktdev.mediaprocessing.shared.common.event_task_contract.events
|
||||
|
||||
import no.iktdev.eventi.models.DeleteEvent
|
||||
import no.iktdev.eventi.models.Event
|
||||
|
||||
data class FileAddedEvent(
|
||||
val data: FileInfo
|
||||
): Event() {
|
||||
}
|
||||
|
||||
data class FileReadyEvent(
|
||||
val data: FileInfo
|
||||
): Event() {}
|
||||
|
||||
class FileRemovedEvent(
|
||||
val data: FileInfo
|
||||
): DeleteEvent() {
|
||||
}
|
||||
|
||||
data class FileInfo(
|
||||
val fileName: String,
|
||||
val fileUri: String,
|
||||
)
|
||||
@ -0,0 +1,8 @@
|
||||
package no.iktdev.mediaprocessing.shared.common.event_task_contract.events
|
||||
|
||||
import no.iktdev.eventi.models.Event
|
||||
import no.iktdev.mediaprocessing.shared.common.model.FileInfo
|
||||
|
||||
data class FileReadyEvent(
|
||||
val data: FileInfo
|
||||
): Event() {}
|
||||
@ -0,0 +1,9 @@
|
||||
package no.iktdev.mediaprocessing.shared.common.event_task_contract.events
|
||||
|
||||
import no.iktdev.eventi.models.DeleteEvent
|
||||
import no.iktdev.mediaprocessing.shared.common.model.FileInfo
|
||||
|
||||
class FileRemovedEvent(
|
||||
val data: FileInfo
|
||||
): DeleteEvent() {
|
||||
}
|
||||
@ -1,16 +1,19 @@
|
||||
package no.iktdev.mediaprocessing.shared.common.event_task_contract.events
|
||||
|
||||
import no.iktdev.eventi.models.Event
|
||||
import no.iktdev.mediaprocessing.shared.common.model.MediaType
|
||||
|
||||
|
||||
class MediaParsedInfoEvent(
|
||||
val data: ParsedData
|
||||
): Event() {
|
||||
}
|
||||
|
||||
data class ParsedData(
|
||||
val parsedTitle: String,
|
||||
val parsedCollection: String,
|
||||
val parsedFileName: String,
|
||||
val parsedSearchTitles: List<String>
|
||||
val parsedSearchTitles: List<String>,
|
||||
val mediaType: MediaType
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
package no.iktdev.mediaprocessing.shared.common.event_task_contract.events
|
||||
|
||||
import no.iktdev.eventi.models.Event
|
||||
import no.iktdev.mediaprocessing.shared.common.model.ParsedMediaStreams
|
||||
import no.iktdev.mediaprocessing.ffmpeg.data.ParsedMediaStreams
|
||||
|
||||
data class MediaStreamParsedEvent(
|
||||
val data: ParsedMediaStreams
|
||||
|
||||
@ -3,5 +3,7 @@ package no.iktdev.mediaprocessing.shared.common.event_task_contract.events
|
||||
import no.iktdev.eventi.models.Event
|
||||
|
||||
|
||||
class MediaStreamReadTaskCreatedEvent(): Event() {
|
||||
data class MediaStreamReadTaskCreatedEvent(
|
||||
val fileUri: String
|
||||
): Event() {
|
||||
}
|
||||
@ -0,0 +1,10 @@
|
||||
package no.iktdev.mediaprocessing.shared.common.event_task_contract.events
|
||||
|
||||
import no.iktdev.eventi.models.Event
|
||||
import no.iktdev.mediaprocessing.shared.common.model.SubtitleItem
|
||||
|
||||
data class MediaTracksDetermineSubtitleTypeEvent(
|
||||
val subtitleTrackItems: List<SubtitleItem>
|
||||
): Event() {
|
||||
|
||||
}
|
||||
@ -0,0 +1,9 @@
|
||||
package no.iktdev.mediaprocessing.shared.common.event_task_contract.events
|
||||
|
||||
import no.iktdev.eventi.models.Event
|
||||
|
||||
data class MediaTracksEncodeSelectedEvent(
|
||||
val selectedVideoTrack: Int,
|
||||
val selectedAudioTrack: Int,
|
||||
val selectedAudioExtendedTrack: Int? = null // Optional extended audio track, e.g Dolby Atmos or Enhanced AAC
|
||||
): Event()
|
||||
@ -0,0 +1,9 @@
|
||||
package no.iktdev.mediaprocessing.shared.common.event_task_contract.events
|
||||
|
||||
import no.iktdev.eventi.models.Event
|
||||
|
||||
data class MediaTracksExtractSelectedEvent(
|
||||
val selectedSubtitleTracks: List<Int>
|
||||
): Event() {
|
||||
|
||||
}
|
||||
@ -0,0 +1,6 @@
|
||||
package no.iktdev.mediaprocessing.shared.common.event_task_contract.events
|
||||
|
||||
import no.iktdev.eventi.models.Event
|
||||
|
||||
class ProcesserEncodePerformedEvent: Event() {
|
||||
}
|
||||
@ -0,0 +1,6 @@
|
||||
package no.iktdev.mediaprocessing.shared.common.event_task_contract.events
|
||||
|
||||
import no.iktdev.eventi.models.Event
|
||||
|
||||
class ProcesserEncodeTaskCreatedEvent: Event() {
|
||||
}
|
||||
@ -7,16 +7,13 @@ import no.iktdev.eventi.models.store.TaskStatus
|
||||
class ProcesserExtractTaskCreatedEvent: Event() {
|
||||
}
|
||||
|
||||
// Placeholder event, so that the listener does not continue to create tasks
|
||||
class ProcesserEncodeTaskCreatedEvent: Event() {
|
||||
}
|
||||
|
||||
// Placeholder event, so that the listener does not continue to create tasks
|
||||
class ProcesserReadTaskCreatedEvent: Event() {
|
||||
}
|
||||
|
||||
|
||||
data class ProcesserEncodePerformedEvent(
|
||||
data class ProcesserEncodeEvent(
|
||||
val data: EncodeResult
|
||||
): Event() {
|
||||
|
||||
@ -28,7 +25,7 @@ data class EncodeResult(
|
||||
)
|
||||
|
||||
|
||||
data class ProcesserExtractedPerformedEvent(
|
||||
data class ProcesserExtractEvent(
|
||||
val data: ExtractResult
|
||||
): Event()
|
||||
|
||||
|
||||
@ -9,6 +9,6 @@ data class EncodeTask(
|
||||
|
||||
data class EncodeData(
|
||||
val arguments: List<String>,
|
||||
val outputFile: String,
|
||||
val outputFileName: String,
|
||||
val inputFile: String
|
||||
)
|
||||
@ -2,12 +2,12 @@ package no.iktdev.mediaprocessing.shared.common.event_task_contract.tasks
|
||||
|
||||
import no.iktdev.eventi.models.Task
|
||||
|
||||
data class ExtractTask(
|
||||
val data: ExtractData
|
||||
data class ExtractSubtitleTask(
|
||||
val data: ExtractSubtitleData
|
||||
): Task() {
|
||||
}
|
||||
|
||||
data class ExtractData(
|
||||
data class ExtractSubtitleData(
|
||||
val arguments: List<String>,
|
||||
val outputFileName: String,
|
||||
val language: String,
|
||||
@ -0,0 +1,6 @@
|
||||
package no.iktdev.mediaprocessing.shared.common.model
|
||||
|
||||
data class FileInfo(
|
||||
val fileName: String,
|
||||
val fileUri: String,
|
||||
)
|
||||
@ -0,0 +1,6 @@
|
||||
package no.iktdev.mediaprocessing.shared.common.model
|
||||
|
||||
enum class MediaType {
|
||||
Movie,
|
||||
Serie
|
||||
}
|
||||
@ -0,0 +1,5 @@
|
||||
package no.iktdev.mediaprocessing.shared.common.model
|
||||
|
||||
import no.iktdev.mediaprocessing.ffmpeg.data.SubtitleStream
|
||||
|
||||
data class SubtitleItem(val stream: SubtitleStream, val type: SubtitleType)
|
||||
@ -0,0 +1,9 @@
|
||||
package no.iktdev.mediaprocessing.shared.common.model
|
||||
|
||||
enum class SubtitleType {
|
||||
Song,
|
||||
Commentary,
|
||||
ClosedCaption,
|
||||
SHD,
|
||||
Dialogue,
|
||||
}
|
||||
@ -0,0 +1,47 @@
|
||||
package no.iktdev.mediaprocessing.shared.common.projection
|
||||
|
||||
import no.iktdev.eventi.models.Event
|
||||
import no.iktdev.mediaprocessing.shared.common.event_task_contract.events.OperationType
|
||||
import no.iktdev.mediaprocessing.shared.common.event_task_contract.events.ProcessFlow
|
||||
import java.io.File
|
||||
|
||||
class Project(val events: List<Event>) {
|
||||
lateinit var startedWith: StartProjection
|
||||
private set
|
||||
var metadataTaskStatus: TaskStatus = TaskStatus.NotInitiated
|
||||
private set
|
||||
var encodeTaskStatus: TaskStatus = TaskStatus.NotInitiated
|
||||
private set
|
||||
var extreactTaskStatus: TaskStatus = TaskStatus.NotInitiated
|
||||
private set
|
||||
var convertTaskStatus: TaskStatus = TaskStatus.NotInitiated
|
||||
private set
|
||||
var coverDownloadTaskStatus: TaskStatus = TaskStatus.NotInitiated
|
||||
private set
|
||||
|
||||
|
||||
init {
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
data class StartProjection(
|
||||
val inputFile: String,
|
||||
val mode: ProcessFlow,
|
||||
val tasks: Set<OperationType>
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
enum class TaskStatus {
|
||||
NotInitiated,
|
||||
NotAvailable,
|
||||
Pending,
|
||||
Skipped,
|
||||
Completed,
|
||||
Failed
|
||||
}
|
||||
|
||||
}
|
||||
@ -25,6 +25,10 @@ dependencies {
|
||||
implementation("no.iktdev:exfl:1.0-rc1")
|
||||
|
||||
|
||||
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.1")
|
||||
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2")
|
||||
|
||||
testImplementation("org.assertj:assertj-core:3.4.1")
|
||||
testImplementation(kotlin("test"))
|
||||
}
|
||||
|
||||
|
||||
@ -12,9 +12,7 @@ import java.io.FileOutputStream
|
||||
import java.time.LocalDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
abstract class FFmpeg {
|
||||
abstract val executable: String
|
||||
abstract val logDir: File
|
||||
open class FFmpeg(val executable: String, val logDir: File) {
|
||||
open val listener: Listener? = null
|
||||
|
||||
private var progress: FfmpegDecodedProgress? = null
|
||||
@ -31,7 +29,14 @@ abstract class FFmpeg {
|
||||
lateinit var result: ProcessResult
|
||||
protected set
|
||||
|
||||
private lateinit var inputFile: String
|
||||
|
||||
open fun onCreate() {}
|
||||
|
||||
init {
|
||||
onCreate()
|
||||
}
|
||||
|
||||
protected lateinit var inputFile: String
|
||||
open suspend fun run(argument: MpegArgument) {
|
||||
inputFile = if (argument.inputFile == null) throw RuntimeException("Input file is required") else argument.inputFile!!
|
||||
logFile = logDir.using("$formattedDateTime-${File(inputFile).nameWithoutExtension}.log")
|
||||
|
||||
@ -7,7 +7,7 @@ import com.google.gson.Gson
|
||||
import com.google.gson.JsonObject
|
||||
import no.iktdev.mediaprocessing.ffmpeg.data.FFinfoOutput
|
||||
|
||||
abstract class FFinfo {
|
||||
abstract class FFprobe {
|
||||
open val defaultArguments: List<String> = listOf("-v", "quiet")
|
||||
abstract val executable: String
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
package no.iktdev.mediaprocessing.shared.common.model
|
||||
package no.iktdev.mediaprocessing.ffmpeg.data
|
||||
|
||||
data class ParsedMediaStreams(
|
||||
val videoStream: List<VideoStream> = listOf(),
|
||||
@ -0,0 +1,227 @@
|
||||
package no.iktdev.mediaprocessing.ffmpeg.dsl
|
||||
|
||||
import no.iktdev.mediaprocessing.ffmpeg.data.AudioStream
|
||||
|
||||
sealed class AudioCodec(val codec: String, open var bitrate: Int? = null, open var sampleRate: Int? = null, open var channels: Int? = null) {
|
||||
|
||||
// AAC (Advanced Audio Coding)
|
||||
class Aac(
|
||||
// Bitrate i kbps (typisk 128–256 for stereo)
|
||||
override var bitrate: Int? = null,
|
||||
// Profile: LC (Low Complexity), HE (High Efficiency), HEv2
|
||||
var profile: AacProfile = AacProfile.LC,
|
||||
// Antall kanaler (1 = mono, 2 = stereo)
|
||||
override var channels: Int? = null, // = 2,
|
||||
// Sample rate i Hz (typisk 44100 eller 48000)
|
||||
override var sampleRate: Int? = null
|
||||
) : AudioCodec("aac") {
|
||||
override fun determineTranscodeDecision(stream: AudioStream): TranscodeDecision {
|
||||
val superDecision = super.determineTranscodeDecision(stream)
|
||||
if (superDecision == TranscodeDecision.Reencode) return superDecision
|
||||
if (forceCopy) return TranscodeDecision.Copy
|
||||
|
||||
val profileOk = stream.profile.lowercase() == "lc"
|
||||
val channelsOk = stream.channels <= 6
|
||||
val sampleRateOk = stream.sample_rate.toIntOrNull() in listOf(44100, 48000)
|
||||
|
||||
return when {
|
||||
!profileOk -> TranscodeDecision.Reencode // HE/HEv2 → reencode til LC
|
||||
!channelsOk || !sampleRateOk -> TranscodeDecision.Reencode
|
||||
else -> TranscodeDecision.Copy
|
||||
}
|
||||
}
|
||||
|
||||
override fun buildFfmpegArgs(stream: AudioStream, trackIndex: Int?): List<String> {
|
||||
val args = super.buildFfmpegArgs(stream, trackIndex).toMutableList()
|
||||
|
||||
// AAC-spesifikt: profile
|
||||
if (profile != AacProfile.LC) {
|
||||
args += listOf("-profile:a", profile.ffmpegName)
|
||||
}
|
||||
|
||||
return args
|
||||
}
|
||||
}
|
||||
|
||||
// MP3 (MPEG Layer III)
|
||||
class Mp3(
|
||||
override var bitrate: Int? = null, // = 192,
|
||||
override var channels: Int? = null, // = 2,
|
||||
override var sampleRate: Int? = null // = 44100
|
||||
) : AudioCodec("libmp3lame")
|
||||
|
||||
// Opus (moderne, lav latency, bra for streaming)
|
||||
class Opus(
|
||||
override var bitrate: Int? = null, // = 128,
|
||||
override var channels: Int? = null, // = 2,
|
||||
override var sampleRate: Int? = null, // = 48000,
|
||||
// Application mode: audio, voip, lowdelay
|
||||
var application: OpusApplication = OpusApplication.Audio
|
||||
) : AudioCodec("opus") {
|
||||
override fun determineTranscodeDecision(stream: AudioStream): TranscodeDecision {
|
||||
val base = super.determineTranscodeDecision(stream)
|
||||
if (base == TranscodeDecision.Reencode) return base
|
||||
if (forceCopy) return TranscodeDecision.Copy
|
||||
|
||||
// Opus må alltid være 48kHz internt, så hvis input != 48000 → reencode
|
||||
val sampleRateOk = stream.sample_rate.toIntOrNull() == 48000
|
||||
val channelsOk = (channels ?: stream.channels) <= 2 // typisk stereo
|
||||
|
||||
return if (sampleRateOk && channelsOk) {
|
||||
TranscodeDecision.Copy
|
||||
} else {
|
||||
TranscodeDecision.Reencode
|
||||
}
|
||||
}
|
||||
|
||||
override fun buildFfmpegArgs(stream: AudioStream, trackIndex: Int?): List<String> {
|
||||
val args = super.buildFfmpegArgs(stream, trackIndex).toMutableList()
|
||||
// Opus-spesifikt: application mode
|
||||
args += listOf("-application", application.ffmpegName)
|
||||
return args
|
||||
}
|
||||
}
|
||||
|
||||
// Vorbis (åpen kildekode, brukt i Ogg)
|
||||
class Vorbis(
|
||||
override var bitrate: Int? = null, // = 128,
|
||||
override var channels: Int? = null, // = 2,
|
||||
override var sampleRate: Int? = null, // = 44100
|
||||
) : AudioCodec("libvorbis")
|
||||
|
||||
// FLAC (lossless)
|
||||
class Flac(
|
||||
var compressionLevel: Int? = null, // = 5,
|
||||
override var channels: Int? = null, // = 2,
|
||||
override var sampleRate: Int? = null, // = 48000
|
||||
) : AudioCodec("flac") {
|
||||
override fun buildFfmpegArgs(stream: AudioStream, trackIndex: Int?): List<String> {
|
||||
val args = mutableListOf<String>()
|
||||
args += if (trackIndex != null) listOf("-c:a:$trackIndex", "flac")
|
||||
else listOf("-c:a", "flac")
|
||||
|
||||
compressionLevel?.let { args += listOf("-compression_level", compressionLevel.toString()) }
|
||||
return args
|
||||
}
|
||||
}
|
||||
|
||||
// AC3 (Dolby Digital)
|
||||
class Ac3(
|
||||
override var bitrate: Int? = null, // = 384,
|
||||
override var channels: Int? = null, // = 6,
|
||||
override var sampleRate: Int? = null, // = 48000
|
||||
) : AudioCodec("ac3")
|
||||
|
||||
|
||||
class Dts(
|
||||
override var bitrate: Int? = null,
|
||||
override var channels: Int? = null, // = 6,
|
||||
override var sampleRate: Int? = null, // = 48000
|
||||
) : AudioCodec("dts")
|
||||
|
||||
class Pcm : AudioCodec("pcm_s16le") {
|
||||
override fun buildFfmpegArgs(stream: AudioStream, trackIndex: Int?): List<String> {
|
||||
val idx = trackIndex?.let { ":$it" } ?: ""
|
||||
return listOf("-c:a$idx", "pcm_s16le")
|
||||
}
|
||||
|
||||
override fun determineTranscodeDecision(stream: AudioStream) = TranscodeDecision.Reencode
|
||||
}
|
||||
|
||||
|
||||
// Kopier eksisterende audio uten reenkoding
|
||||
object Copy : AudioCodec("copy")
|
||||
|
||||
var forceCopy: Boolean = false
|
||||
|
||||
open fun determineTranscodeDecision(stream: AudioStream): TranscodeDecision {
|
||||
// 1) Hvis vi eksplisitt vil kopiere
|
||||
if (forceCopy || this == Copy) return TranscodeDecision.Copy
|
||||
|
||||
// 2) Hvis codec er identisk og ingen parametre er satt → Copy
|
||||
val sameCodec = this.isSame(stream.codec_name)
|
||||
val wantsBitrateChange = bitrate != null
|
||||
val wantsSampleRateChange = sampleRate?.let { sr ->
|
||||
val inSr = stream.sample_rate.toIntOrNull()
|
||||
inSr != null && sr != inSr
|
||||
} ?: false
|
||||
val wantsChannelChange = channels?.let { ch ->
|
||||
ch != stream.channels
|
||||
} ?: false
|
||||
|
||||
return when {
|
||||
// samme codec og ingen endringer → Copy
|
||||
sameCodec && !wantsBitrateChange && !wantsSampleRateChange && !wantsChannelChange ->
|
||||
TranscodeDecision.Copy
|
||||
|
||||
// ellers → Reencode
|
||||
else -> TranscodeDecision.Reencode
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Felles bygging av ffmpeg-argumenter.
|
||||
* - Tar hensyn til felter som er satt (bitrate, channels, sampleRate).
|
||||
* - Hopper over felter som er null.
|
||||
* - Validerer mot input-stream (ikke høyere enn input).
|
||||
*/
|
||||
open fun buildFfmpegArgs(stream: AudioStream, trackIndex: Int? = null): List<String> {
|
||||
val args = mutableListOf<String>()
|
||||
|
||||
// codec
|
||||
args += if (trackIndex != null) {
|
||||
listOf("-c:a:$trackIndex", codec)
|
||||
} else {
|
||||
listOf("-c:a", codec)
|
||||
}
|
||||
|
||||
// bitrate
|
||||
bitrate?.let {
|
||||
args += listOf("-b:a", "${it}k")
|
||||
}
|
||||
|
||||
// sample rate
|
||||
sampleRate?.let {
|
||||
val effective = it.coerceAtMost(stream.sample_rate.toInt())
|
||||
args += listOf("-ar", effective.toString())
|
||||
}
|
||||
|
||||
// channels
|
||||
channels?.let {
|
||||
val effective = it.coerceAtMost(stream.channels)
|
||||
args += listOf("-ac", effective.toString())
|
||||
}
|
||||
|
||||
return args
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
fun AudioCodec.isSame(name: String): Boolean {
|
||||
val codecObject = when (name.lowercase()) {
|
||||
"aac", "mp4a", "libfdk_aac" -> AudioCodec.Aac()
|
||||
"mp3", "mpeg3", "libmp3lame" -> AudioCodec.Mp3()
|
||||
"opus", "libopus" -> AudioCodec.Opus()
|
||||
"vorbis", "oggvorbis", "libvorbis" -> AudioCodec.Vorbis()
|
||||
"flac" -> AudioCodec.Flac()
|
||||
"ac3", "dolby", "dolbydigital" -> AudioCodec.Ac3()
|
||||
"dts", "dca" -> AudioCodec.Dts() // ← lagt til her
|
||||
"pcm_s16le", "pcm" -> AudioCodec.Pcm()
|
||||
"copy" -> AudioCodec.Copy
|
||||
else -> throw IllegalArgumentException("Unsupported audio codec: $name")
|
||||
}
|
||||
return (this.codec == codecObject.codec)
|
||||
}
|
||||
|
||||
|
||||
enum class AacProfile(val ffmpegName: String) {
|
||||
LC("aac_low"), // Low Complexity – mest brukt
|
||||
HE("aac_he"), // High Efficiency – bedre komprimering
|
||||
HEv2("aac_he_v2") // High Efficiency v2 – enda mer komprimering
|
||||
}
|
||||
|
||||
enum class OpusApplication(val ffmpegName: String) {
|
||||
Audio("audio"), // Vanlig musikk/lyd
|
||||
Voip("voip"), // Optimalisert for tale
|
||||
LowDelay("lowdelay") // Lav latency, f.eks. live streaming
|
||||
}
|
||||
@ -0,0 +1,73 @@
|
||||
package no.iktdev.mediaprocessing.ffmpeg.dsl
|
||||
|
||||
import no.iktdev.mediaprocessing.ffmpeg.data.AudioStream
|
||||
import no.iktdev.mediaprocessing.ffmpeg.data.VideoStream
|
||||
|
||||
// DSL entrypoint
|
||||
data class MediaPlan(
|
||||
val videoTrack: VideoTarget,
|
||||
val audioTracks: MutableList<AudioTarget> = mutableListOf()
|
||||
) {
|
||||
fun toFfmpegArgs(
|
||||
videoStreams: List<VideoStream>,
|
||||
audioStreams: List<AudioStream>
|
||||
): List<String> {
|
||||
val args = mutableListOf<String>()
|
||||
|
||||
|
||||
// Video
|
||||
val vStream = videoStreams[videoTrack.index]
|
||||
val vDecision = videoTrack.codec.determineTranscodeDecision(vStream)
|
||||
args += listOf("-map", "0:v:${videoTrack.index}")
|
||||
args += when (vDecision) {
|
||||
TranscodeDecision.Copy -> listOf("-c:v", "copy")
|
||||
TranscodeDecision.Remux -> listOf("-c:v", videoTrack.codec.codec)
|
||||
TranscodeDecision.Reencode -> videoTrack.codec.buildFfmpegArgs(vStream)
|
||||
}
|
||||
|
||||
// Audio
|
||||
audioTracks.forEachIndexed { outIdx, target ->
|
||||
val aStream = audioStreams[target.index]
|
||||
val aDecision = target.codec.determineTranscodeDecision(aStream)
|
||||
|
||||
if (outIdx > 0) {
|
||||
val otherIsCopy = audioTracks.filter { it -> it.index == target.index && it !== target }.any { it.codec == AudioCodec.Copy }
|
||||
if (otherIsCopy && aDecision == TranscodeDecision.Copy) {
|
||||
// Hvis en annen audio-track med samme index er satt til copy, kan vi ikke copy denne også
|
||||
return@forEachIndexed
|
||||
}
|
||||
}
|
||||
|
||||
args += listOf("-map", "0:a:${target.index}")
|
||||
when (aDecision) {
|
||||
TranscodeDecision.Copy -> args += listOf("-c:a:$outIdx", "copy")
|
||||
TranscodeDecision.Remux -> args += listOf("-c:a:$outIdx", target.codec.codec)
|
||||
TranscodeDecision.Reencode -> {
|
||||
val built = target.codec.buildFfmpegArgs(aStream).toMutableList()
|
||||
// injiser output-indeks i alle audio-flagg
|
||||
for (i in built.indices) {
|
||||
if (built[i] == "-c:a") built[i] = "-c:a:$outIdx"
|
||||
if (built[i] == "-b:a") built[i] = "-b:a:$outIdx"
|
||||
if (built[i] == "-ar") built[i] = "-ar:$outIdx"
|
||||
if (built[i] == "-ac") built[i] = "-ac:$outIdx"
|
||||
}
|
||||
args += built
|
||||
}
|
||||
}
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Video target: index + codec
|
||||
data class VideoTarget(
|
||||
val index: Int,
|
||||
val codec: VideoCodec
|
||||
)
|
||||
|
||||
// Audio target: index + codec
|
||||
data class AudioTarget(
|
||||
val index: Int,
|
||||
val codec: AudioCodec
|
||||
)
|
||||
@ -0,0 +1,7 @@
|
||||
package no.iktdev.mediaprocessing.ffmpeg.dsl
|
||||
|
||||
enum class TranscodeDecision {
|
||||
Copy,
|
||||
Remux,
|
||||
Reencode
|
||||
}
|
||||
@ -0,0 +1,253 @@
|
||||
package no.iktdev.mediaprocessing.ffmpeg.dsl
|
||||
|
||||
import no.iktdev.mediaprocessing.ffmpeg.data.VideoStream
|
||||
|
||||
sealed class VideoCodec(val codec: String, open val crf: Int? = null, open val bitrate: Int? = null) {
|
||||
|
||||
// HEVC / H.265 encoder (libx265)
|
||||
class Hevc(
|
||||
// Preset styrer hastighet vs komprimeringseffektivitet.
|
||||
// "ultrafast" = rask, men stor fil; "veryslow" = treg, men liten fil.
|
||||
var preset: Presets = Presets.Slow,
|
||||
|
||||
// CRF (Constant Rate Factor) styrer kvalitet vs bitrate.
|
||||
// Lavere tall = bedre kvalitet, høyere tall = lavere bitrate.
|
||||
override var crf: Int = 18,
|
||||
override val bitrate: Int? = null,
|
||||
|
||||
// Tune kan brukes for spesifikke scenarier (film, animation, grain).
|
||||
var tune: String? = null,
|
||||
) : VideoCodec("libx265") {
|
||||
|
||||
override fun determineTranscodeDecision(stream: VideoStream): TranscodeDecision {
|
||||
val superDecision = super.determineTranscodeDecision(stream)
|
||||
if (superDecision == TranscodeDecision.Reencode) {
|
||||
return superDecision
|
||||
}
|
||||
|
||||
val unsetTag = stream.codec_tag_string == "[0][0][0][0]" || stream.codec_tag == "0x0000"
|
||||
val validTag = stream.codec_tag_string.equals("hev1", ignoreCase = true) ||
|
||||
stream.codec_tag_string.equals("hvc1", ignoreCase = true)
|
||||
|
||||
val profileOk = stream.profile.lowercase() in listOf("main", "main10")
|
||||
val levelOk = stream.level <= 153 // 5.1 ≈ 153
|
||||
|
||||
return when {
|
||||
// Profil eller level utenfor Chromecast‑krav → reencode
|
||||
!profileOk || !levelOk -> TranscodeDecision.Reencode
|
||||
|
||||
// Tag unset eller gyldig → vi kan fortsatt copy/remux
|
||||
unsetTag || validTag -> TranscodeDecision.Copy
|
||||
|
||||
// Alle andre tilfeller → safe fallback til reencode
|
||||
else -> TranscodeDecision.Reencode
|
||||
}
|
||||
}
|
||||
override fun buildFfmpegArgs(stream: VideoStream): List<String> {
|
||||
val args = super.buildFfmpegArgs(stream).toMutableList()
|
||||
args += listOf("-preset", preset.presetName)
|
||||
tune?.let { args += listOf("-tune", it) }
|
||||
return args
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// H.264 encoder (libx264)
|
||||
class H264(
|
||||
// Preset: samme som for HEVC, styrer encoding speed vs compression.
|
||||
var preset: Presets = Presets.Slow,
|
||||
|
||||
// Profile: Baseline/Main/High etc. styrer kompatibilitet og features.
|
||||
// High gir best kvalitet, Baseline brukes ofte for mobile enheter.
|
||||
var profile: H264Profiles = H264Profiles.High,
|
||||
|
||||
// Level: definerer maks bitrate, oppløsning og framerate.
|
||||
// Eks: 4.1 passer for 1080p @ 30fps, 5.1 for 4K.
|
||||
var level: Double = 4.2,
|
||||
|
||||
// CRF: styrer kvalitet vs bitrate (samme som for HEVC).
|
||||
override var crf: Int = 23,
|
||||
override val bitrate: Int? = null
|
||||
) : VideoCodec("libx264") {
|
||||
override fun determineTranscodeDecision(stream: VideoStream): TranscodeDecision {
|
||||
val superDecision = super.determineTranscodeDecision(stream)
|
||||
if (superDecision == TranscodeDecision.Reencode) return superDecision
|
||||
|
||||
val profileOk = stream.profile.lowercase() in listOf("baseline", "main", "high", "high10")
|
||||
val levelOk = stream.level <= 51 // 5.1 typisk maks for bred støtte
|
||||
|
||||
return when {
|
||||
!profileOk || !levelOk -> TranscodeDecision.Reencode
|
||||
else -> TranscodeDecision.Copy
|
||||
}
|
||||
}
|
||||
override fun buildFfmpegArgs(stream: VideoStream): List<String> {
|
||||
val args = super.buildFfmpegArgs(stream).toMutableList()
|
||||
args += listOf("-preset", preset.presetName)
|
||||
args += listOf("-profile:v", profile.profileName)
|
||||
args += listOf("-level", level.toString())
|
||||
return args
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// VP9 encoder (libvpx-vp9)
|
||||
class Vp9(
|
||||
// CRF: styrer kvalitet vs bitrate for VP9.
|
||||
override var crf: Int = 32,
|
||||
|
||||
// Bitrate: kan settes eksplisitt i kbps hvis du vil ha CBR/VBR.
|
||||
override var bitrate: Int? = null,
|
||||
|
||||
var cpuUsed: Int = 4 // Tradeoff mellom hastighet og komprimering.
|
||||
) : VideoCodec("libvpx-vp9") {
|
||||
override fun determineTranscodeDecision(stream: VideoStream): TranscodeDecision {
|
||||
val superDecision = super.determineTranscodeDecision(stream)
|
||||
if (superDecision == TranscodeDecision.Reencode) return superDecision
|
||||
|
||||
// MP4 har dårlig VP9-støtte, WebM er tryggere
|
||||
val containerOk = stream.codec_tag_string.equals("vp09", ignoreCase = true)
|
||||
|
||||
return if (containerOk) TranscodeDecision.Copy else TranscodeDecision.Remux
|
||||
}
|
||||
override fun buildFfmpegArgs(stream: VideoStream): List<String> {
|
||||
val args = super.buildFfmpegArgs(stream).toMutableList()
|
||||
args += listOf("-cpu-used", cpuUsed.toString())
|
||||
return args
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// VP8 encoder (libvpx)
|
||||
class Vp8(
|
||||
override var crf: Int = 10,
|
||||
override var bitrate: Int? = null
|
||||
) : VideoCodec("libvpx")
|
||||
|
||||
|
||||
// AV1 encoder (libaom-av1)
|
||||
class Av1(
|
||||
// CRF: styrer kvalitet vs bitrate for AV1.
|
||||
override var crf: Int = 30,
|
||||
|
||||
// cpuUsed: tradeoff mellom hastighet og komprimering.
|
||||
// Lav verdi = treg, men effektiv; høy verdi = rask, men mindre effektiv.
|
||||
var cpuUsed: Int = 4
|
||||
) : VideoCodec("libaom-av1") {
|
||||
override fun determineTranscodeDecision(stream: VideoStream): TranscodeDecision {
|
||||
val superDecision = super.determineTranscodeDecision(stream)
|
||||
if (superDecision == TranscodeDecision.Reencode) return superDecision
|
||||
|
||||
val validTag = stream.codec_tag_string.equals("av01", ignoreCase = true)
|
||||
val levelOk = stream.level <= 51 // AV1 nivåer, typisk ≤ 5.1
|
||||
|
||||
return when {
|
||||
!validTag || !levelOk -> TranscodeDecision.Reencode
|
||||
else -> TranscodeDecision.Copy
|
||||
}
|
||||
}
|
||||
override fun buildFfmpegArgs(stream: VideoStream): List<String> {
|
||||
val args = super.buildFfmpegArgs(stream).toMutableList()
|
||||
args += listOf("-cpu-used", cpuUsed.toString())
|
||||
return args
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// VVC (Versatile Video Coding, H.266)
|
||||
class Vvc(
|
||||
// Preset: encoding speed vs compression tradeoff.
|
||||
var preset: Presets = Presets.Medium,
|
||||
override var crf: Int = 27,
|
||||
override val bitrate: Int? = null
|
||||
) : VideoCodec("libvvc") {
|
||||
override fun buildFfmpegArgs(stream: VideoStream): List<String> {
|
||||
val args = super.buildFfmpegArgs(stream).toMutableList()
|
||||
args += listOf("-preset", preset.presetName)
|
||||
return args
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Xvid (MPEG-4 Part 2)
|
||||
class Vid(
|
||||
// Bitrate: typisk parameter for Xvid, ofte brukt i kbps.
|
||||
override var bitrate: Int? = null,
|
||||
var qscale: Int? = null // Xvid bruker qscale i stedet for CRF
|
||||
) : VideoCodec("libxvid") {
|
||||
override fun buildFfmpegArgs(stream: VideoStream): List<String> {
|
||||
val args = mutableListOf("-c:v", codec)
|
||||
bitrate?.let { args += listOf("-b:v", "${it}k") }
|
||||
qscale?.let { args += listOf("-qscale:v", it.toString()) }
|
||||
return args
|
||||
}
|
||||
}
|
||||
|
||||
// Raw video (ingen komprimering)
|
||||
object Raw : VideoCodec("rawvideo") {
|
||||
override fun determineTranscodeDecision(stream: VideoStream): TranscodeDecision {
|
||||
return TranscodeDecision.Reencode
|
||||
}
|
||||
}
|
||||
|
||||
// Copy: ingen reenkoding, bare remuxing av eksisterende stream.
|
||||
object Copy : VideoCodec("copy")
|
||||
|
||||
open fun determineTranscodeDecision(stream: VideoStream): TranscodeDecision {
|
||||
return if (this.isSame(stream.codec_name)) {
|
||||
TranscodeDecision.Copy
|
||||
} else {
|
||||
when (this) {
|
||||
is Copy -> TranscodeDecision.Copy
|
||||
else -> TranscodeDecision.Reencode
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
open fun buildFfmpegArgs(stream: VideoStream): List<String> {
|
||||
val args = mutableListOf("-c:v", codec)
|
||||
|
||||
crf?.let { args += listOf("-crf", it.toString()) }
|
||||
bitrate?.let { args += listOf("-b:v", "${it}k") }
|
||||
|
||||
return args
|
||||
}
|
||||
}
|
||||
|
||||
fun VideoCodec.isSame(name: String): Boolean {
|
||||
val codecObject = when (name.lowercase()) {
|
||||
"hevc", "hevec", "h265", "h.265", "libx265" -> VideoCodec.Hevc()
|
||||
"h.264", "h264", "libx264" -> VideoCodec.H264()
|
||||
"vp9", "vp-9", "libvpx-vp9" -> VideoCodec.Vp9()
|
||||
"av1", "libaom-av1" -> VideoCodec.Av1()
|
||||
"mpeg4", "mp4", "libxvid" -> VideoCodec.Vid()
|
||||
"vvc", "h.266", "libvvc" -> VideoCodec.Vvc()
|
||||
"vp8", "libvpx" -> VideoCodec.Vp8()
|
||||
"rawvideo" -> VideoCodec.Raw
|
||||
"copy" -> VideoCodec.Copy
|
||||
else -> throw IllegalArgumentException("Unsupported codec: $name")
|
||||
}
|
||||
return (this.codec == codecObject.codec)
|
||||
}
|
||||
|
||||
enum class H264Profiles(val profileName: String) {
|
||||
Baseline("baseline"),
|
||||
Main("main"),
|
||||
High("high"),
|
||||
High10("high10"),
|
||||
High422("high422"),
|
||||
High444("high444")
|
||||
}
|
||||
|
||||
enum class Presets(val presetName: String) {
|
||||
Ultrafast("ultrafast"),
|
||||
Superfast("superfast"),
|
||||
Veryfast("veryfast"),
|
||||
Faster("faster"),
|
||||
Fast("fast"),
|
||||
Medium("medium"),
|
||||
Slow("slow"),
|
||||
Slower("slower"),
|
||||
Veryslow("veryslow"),
|
||||
Placebo("placebo")
|
||||
}
|
||||
@ -0,0 +1,30 @@
|
||||
package no.iktdev.mediaprocessing.ffmpeg.util
|
||||
|
||||
enum class FfmpegCodecs(val ffmpegName: String) {
|
||||
hevc("libx265"),
|
||||
h264("libx264"),
|
||||
vp9("libvpx-vp9"),
|
||||
av1("libaom-av1"),
|
||||
vid("libxvid"),
|
||||
vvc("libvvc"),
|
||||
vp8("libvpx");
|
||||
|
||||
fun getCodecs(): List<FfmpegCodecs> {
|
||||
return entries
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun CodecNameToFfmpegCodec(name: String): FfmpegCodecs {
|
||||
return when (name.lowercase()) {
|
||||
"hevc", "hevec", "h265", "h.265", "libx265" -> FfmpegCodecs.hevc
|
||||
"h.264", "h264", "libx264" -> FfmpegCodecs.h264
|
||||
"vp9", "vp-9", "libvpx-vp9" -> FfmpegCodecs.vp9
|
||||
"av1", "libaom-av1" -> FfmpegCodecs.av1
|
||||
"mpeg4", "mp4", "libxvid" -> FfmpegCodecs.vid
|
||||
"vvc", "h.266", "libvvc" -> FfmpegCodecs.vvc
|
||||
"vp8", "libvpx" -> FfmpegCodecs.vp8
|
||||
else -> throw IllegalArgumentException("Unsupported codec: $name")
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,73 @@
|
||||
package no.iktdev.mediaprocessing.ffmpeg
|
||||
|
||||
import com.github.pgreze.process.ProcessResult
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import no.iktdev.mediaprocessing.ffmpeg.arguments.MpegArgument
|
||||
import no.iktdev.mediaprocessing.ffmpeg.decoder.FfmpegDecodedProgress
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.DisplayName
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.io.File
|
||||
import kotlin.system.measureTimeMillis
|
||||
import kotlin.test.assertFalse
|
||||
|
||||
class FFmpegTest {
|
||||
|
||||
class MockFFmpeg(override val listener: Listener, val delayMillis: Long = 500, private val simulateSuccess: Boolean = true) : FFmpeg(executable = "", logDir = File("/null")) {
|
||||
override suspend fun run(argument: MpegArgument) {
|
||||
inputFile = argument.inputFile!!
|
||||
listener.onStarted(argument.inputFile!!)
|
||||
delay(delayMillis)
|
||||
|
||||
result = ProcessResult(
|
||||
resultCode = if (simulateSuccess) 0 else 1,
|
||||
output = listOf("Simulated ffmpeg output")
|
||||
)
|
||||
|
||||
if (simulateSuccess) {
|
||||
listener.onCompleted(inputFile, argument.outputFile!!)
|
||||
} else {
|
||||
listener.onError(inputFile, "Simulated error")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
@DisplayName("Test FFmpeg Mock Success")
|
||||
fun scenarioSuccess() = runBlocking {
|
||||
val arguments = MpegArgument()
|
||||
.inputFile("input.mp4")
|
||||
.outputFile("output.mp4")
|
||||
.args(listOf("-y"))
|
||||
.withProgress(true)
|
||||
|
||||
val listener = object : FFmpeg.Listener {
|
||||
var completed: Boolean = false
|
||||
private set
|
||||
override fun onStarted(inputFile: String) {
|
||||
println("Started processing $inputFile")
|
||||
}
|
||||
|
||||
override fun onCompleted(inputFile: String, outputFile: String) {
|
||||
println("Completed processing $inputFile to $outputFile")
|
||||
completed = true
|
||||
}
|
||||
|
||||
override fun onProgressChanged(inputFile: String, progress: FfmpegDecodedProgress) {
|
||||
println("Progress for $inputFile: $progress")
|
||||
}
|
||||
}
|
||||
|
||||
val runner = MockFFmpeg(listener, delayMillis = 1000, simulateSuccess = true)
|
||||
assertFalse(listener.completed, "Expected onCompleted to be false before run")
|
||||
|
||||
val elapsed = measureTimeMillis {
|
||||
runner.run(arguments)
|
||||
assertTrue(listener.completed, "Expected onCompleted to be called")
|
||||
}
|
||||
|
||||
assertTrue(elapsed >= 1000, "Expected to wait at least 1000 ms, but waited for $elapsed ms")
|
||||
}
|
||||
}
|
||||
@ -1,9 +1,5 @@
|
||||
package no.iktdev.mediaprocessing.processer
|
||||
package no.iktdev.mediaprocessing.ffmpeg
|
||||
|
||||
import no.iktdev.mediaprocessing.shared.common.contract.Events
|
||||
import no.iktdev.mediaprocessing.shared.common.contract.data.Event
|
||||
import no.iktdev.mediaprocessing.shared.common.contract.jsonToEvent
|
||||
import org.json.JSONArray
|
||||
|
||||
enum class Files(val fileName: String) {
|
||||
Output1("encodeProgress1.txt")
|
||||
@ -1,10 +1,8 @@
|
||||
package no.iktdev.mediaprocessing.processer.ffmpeg.progress
|
||||
package no.iktdev.mediaprocessing.ffmpeg.decoder
|
||||
|
||||
import no.iktdev.mediaprocessing.processer.Files
|
||||
import no.iktdev.mediaprocessing.processer.ffmpeg.FfmpegRunner
|
||||
import no.iktdev.mediaprocessing.processer.getAsList
|
||||
import no.iktdev.mediaprocessing.ffmpeg.Files
|
||||
import no.iktdev.mediaprocessing.ffmpeg.getAsList
|
||||
import org.assertj.core.api.Assertions.assertThat
|
||||
import org.junit.jupiter.api.Assertions.*
|
||||
import org.junit.jupiter.api.DisplayName
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
@ -0,0 +1,377 @@
|
||||
package no.iktdev.mediaprocessing.ffmpeg.dsl
|
||||
|
||||
import no.iktdev.mediaprocessing.ffmpeg.data.AudioStream
|
||||
import no.iktdev.mediaprocessing.ffmpeg.data.Disposition
|
||||
import no.iktdev.mediaprocessing.ffmpeg.data.Tags
|
||||
import no.iktdev.mediaprocessing.ffmpeg.data.VideoStream
|
||||
import org.junit.jupiter.api.Assertions
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.DisplayName
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class MediaPlanTest {
|
||||
|
||||
@Test
|
||||
fun `video copy with one audio copy`() {
|
||||
val plan = MediaPlan(
|
||||
videoTrack = VideoTarget(0, VideoCodec.Copy),
|
||||
audioTracks = mutableListOf(AudioTarget(0, AudioCodec.Copy))
|
||||
)
|
||||
|
||||
val args = plan.toFfmpegArgs(
|
||||
videoStreams = listOf(mockVideoStream(codec = "h264", disposition = mockDisposition(), tags = mockTags())),
|
||||
audioStreams = listOf(mockAudioStream(codec = "aac", disposition = mockDisposition(), tags = mockTags()))
|
||||
)
|
||||
|
||||
Assertions.assertEquals(
|
||||
listOf(
|
||||
"-map", "0:v:0", "-c:v", "copy",
|
||||
"-map", "0:a:0", "-c:a:0", "copy"
|
||||
),
|
||||
args
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `video reencode to hevc with crf`() {
|
||||
val plan = MediaPlan(
|
||||
videoTrack = VideoTarget(0, VideoCodec.Hevc(crf = 18)),
|
||||
audioTracks = mutableListOf(AudioTarget(0, AudioCodec.Aac(bitrate = 192)))
|
||||
)
|
||||
|
||||
val args = plan.toFfmpegArgs(
|
||||
videoStreams = listOf(mockVideoStream(codec = "h264", disposition = mockDisposition(), tags = mockTags())),
|
||||
audioStreams = listOf(mockAudioStream(codec = "mp3", disposition = mockDisposition(), tags = mockTags()))
|
||||
)
|
||||
|
||||
Assertions.assertEquals(
|
||||
listOf(
|
||||
"-map", "0:v:0", "-c:v", "libx265", "-crf", "18", "-preset", "slow",
|
||||
"-map", "0:a:0", "-c:a:0", "aac", "-b:a:0", "192k"
|
||||
),
|
||||
args
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `two audio tracks with different codecs`() {
|
||||
val plan = MediaPlan(
|
||||
videoTrack = VideoTarget(0, VideoCodec.Copy),
|
||||
audioTracks = mutableListOf(
|
||||
AudioTarget(0, AudioCodec.Aac(bitrate = 128)),
|
||||
AudioTarget(1, AudioCodec.Opus(bitrate = 96))
|
||||
)
|
||||
)
|
||||
|
||||
val args = plan.toFfmpegArgs(
|
||||
videoStreams = listOf(mockVideoStream(codec = "h264", disposition = mockDisposition(), tags = mockTags())),
|
||||
audioStreams = listOf(
|
||||
mockAudioStream(codec = "aac", channels = 6, disposition = mockDisposition(), tags = mockTags()),
|
||||
mockAudioStream(index = 1, codec = "ac3", disposition = mockDisposition(), tags = mockTags())
|
||||
)
|
||||
)
|
||||
|
||||
Assertions.assertEquals(
|
||||
listOf(
|
||||
"-map", "0:v:0", "-c:v", "copy",
|
||||
"-map", "0:a:0", "-c:a:0", "aac", "-b:a:0", "128k",
|
||||
"-map", "0:a:1", "-c:a:1", "opus", "-b:a:1", "96k", "-application", "audio"),
|
||||
args
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Video copy, Audio AAC reencode`() {
|
||||
|
||||
val plan = MediaPlan(
|
||||
videoTrack = VideoTarget(0, VideoCodec.H264()),
|
||||
audioTracks = mutableListOf(
|
||||
AudioTarget(0, AudioCodec.Aac(bitrate = 128, profile = AacProfile.LC)),
|
||||
)
|
||||
)
|
||||
|
||||
val args = plan.toFfmpegArgs(
|
||||
videoStreams = listOf(mockVideoStream(codec = "h264", disposition = mockDisposition(), tags = mockTags())),
|
||||
audioStreams = listOf(
|
||||
mockAudioStream(codec = "aac", channels = 6, profile = AacProfile.HE.ffmpegName, disposition = mockDisposition(), tags = mockTags()),
|
||||
)
|
||||
)
|
||||
|
||||
Assertions.assertEquals(
|
||||
listOf(
|
||||
"-map", "0:v:0", "-c:v", "copy",
|
||||
"-map", "0:a:0", "-c:a:0", "aac", "-b:a:0", "128k",
|
||||
),
|
||||
args
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Video copy + Audio AAC reencode (HE→LC, bitrate 128k)")
|
||||
fun videoCopyAudioAacReencode() {
|
||||
val plan = MediaPlan(
|
||||
videoTrack = VideoTarget(0, VideoCodec.H264()),
|
||||
audioTracks = mutableListOf(
|
||||
AudioTarget(0, AudioCodec.Aac(bitrate = 128, profile = AacProfile.LC)),
|
||||
)
|
||||
)
|
||||
|
||||
val args = plan.toFfmpegArgs(
|
||||
videoStreams = listOf(mockVideoStream(codec = "h264", disposition = mockDisposition(), tags = mockTags())),
|
||||
audioStreams = listOf(mockAudioStream(codec = "aac", channels = 6, profile = AacProfile.HE.ffmpegName, disposition = mockDisposition(), tags = mockTags()))
|
||||
)
|
||||
|
||||
Assertions.assertEquals(
|
||||
listOf(
|
||||
"-map", "0:v:0", "-c:v", "copy",
|
||||
"-map", "0:a:0", "-c:a:0", "aac", "-b:a:0", "128k",
|
||||
),
|
||||
args
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Video reencode to HEVC with CRF=18 and preset=slow, Audio copy")
|
||||
fun videoReencodeHevcCrfPresetAudioCopy() {
|
||||
val plan = MediaPlan(
|
||||
videoTrack = VideoTarget(0, VideoCodec.Hevc(crf = 18, preset = Presets.Slow)),
|
||||
audioTracks = mutableListOf(AudioTarget(0, AudioCodec.Copy))
|
||||
)
|
||||
|
||||
val args = plan.toFfmpegArgs(
|
||||
videoStreams = listOf(mockVideoStream(codec = "h264", disposition = mockDisposition(), tags = mockTags())),
|
||||
audioStreams = listOf(mockAudioStream(codec = "aac", channels = 2, profile = AacProfile.LC.ffmpegName, disposition = mockDisposition(), tags = mockTags()))
|
||||
)
|
||||
|
||||
Assertions.assertEquals(
|
||||
listOf(
|
||||
"-map", "0:v:0", "-c:v", "libx265", "-crf", "18", "-preset", "slow",
|
||||
"-map", "0:a:0", "-c:a:0", "copy",
|
||||
),
|
||||
args
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Two audio tracks: AAC reencode 128k + Opus reencode 96k")
|
||||
fun twoAudioTracksDifferentCodecs() {
|
||||
val plan = MediaPlan(
|
||||
videoTrack = VideoTarget(0, VideoCodec.Copy),
|
||||
audioTracks = mutableListOf(
|
||||
AudioTarget(0, AudioCodec.Aac(bitrate = 128)),
|
||||
AudioTarget(1, AudioCodec.Opus(bitrate = 96))
|
||||
)
|
||||
)
|
||||
|
||||
val args = plan.toFfmpegArgs(
|
||||
videoStreams = listOf(mockVideoStream(codec = "h264", disposition = mockDisposition(), tags = mockTags())),
|
||||
audioStreams = listOf(
|
||||
mockAudioStream(codec = "aac", channels = 2, profile = AacProfile.LC.ffmpegName, disposition = mockDisposition(), tags = mockTags()),
|
||||
mockAudioStream(codec = "vorbis", channels = 2, profile = "", disposition = mockDisposition(), tags = mockTags())
|
||||
)
|
||||
)
|
||||
|
||||
Assertions.assertEquals(
|
||||
listOf(
|
||||
"-map", "0:v:0", "-c:v", "copy",
|
||||
"-map", "0:a:0", "-c:a:0", "aac", "-b:a:0", "128k",
|
||||
"-map", "0:a:1", "-c:a:1", "opus", "-b:a:1", "96k", "-application", "audio",
|
||||
),
|
||||
args
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("PCM input downmix to AAC stereo 192k")
|
||||
fun pcmInputDownmixToAacStereo() {
|
||||
val plan = MediaPlan(
|
||||
videoTrack = VideoTarget(0, VideoCodec.Copy),
|
||||
audioTracks = mutableListOf(AudioTarget(0, AudioCodec.Aac(bitrate = 192, channels = 2)))
|
||||
)
|
||||
|
||||
val args = plan.toFfmpegArgs(
|
||||
videoStreams = listOf(mockVideoStream(codec = "rawvideo", disposition = mockDisposition(), tags = mockTags())),
|
||||
audioStreams = listOf(mockAudioStream(codec = "pcm_s16le", channels = 6, profile = "", disposition = mockDisposition(), tags = mockTags()))
|
||||
)
|
||||
|
||||
Assertions.assertEquals(
|
||||
listOf(
|
||||
"-map", "0:v:0", "-c:v", "copy",
|
||||
"-map", "0:a:0", "-c:a:0", "aac", "-b:a:0", "192k", "-ac:0", "2",
|
||||
),
|
||||
args
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("FLAC input remux to FLAC (no reencode)")
|
||||
fun flacInputRemux() {
|
||||
val plan = MediaPlan(
|
||||
videoTrack = VideoTarget(0, VideoCodec.Copy),
|
||||
audioTracks = mutableListOf(AudioTarget(0, AudioCodec.Flac()))
|
||||
)
|
||||
|
||||
val args = plan.toFfmpegArgs(
|
||||
videoStreams = listOf(mockVideoStream(codec = "rawvideo", disposition = mockDisposition(), tags = mockTags())),
|
||||
audioStreams = listOf(mockAudioStream(codec = "flac", channels = 2, profile = "", disposition = mockDisposition(), tags = mockTags()))
|
||||
)
|
||||
|
||||
Assertions.assertEquals(
|
||||
listOf(
|
||||
"-map", "0:v:0", "-c:v", "copy",
|
||||
"-map", "0:a:0", "-c:a:0", "copy",
|
||||
),
|
||||
args
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
@DisplayName("Extended track skipped when same as default and default is copy")
|
||||
fun skipExtendedIfSameAsDefaultAndDefaultIsCopy() {
|
||||
// Arrange: lag en plan med default og extended som peker på samme input index
|
||||
val defaultTarget = AudioTarget(
|
||||
index = 0,
|
||||
codec = AudioCodec.Copy // default er copy
|
||||
)
|
||||
val extendedTarget = AudioTarget(
|
||||
index = 0, // peker på samme input index som default
|
||||
codec = AudioCodec.Copy
|
||||
)
|
||||
|
||||
val plan = MediaPlan(
|
||||
videoTrack = VideoTarget(0, VideoCodec.Copy),
|
||||
audioTracks = mutableListOf(defaultTarget, extendedTarget)
|
||||
)
|
||||
|
||||
val audioStreams = listOf(
|
||||
mockAudioStream(codec = "aac", channels = 2, disposition = mockDisposition(), tags = mockTags())
|
||||
)
|
||||
val videoStreams = listOf(
|
||||
mockVideoStream(codec = "h264", disposition = mockDisposition(), tags = mockTags())
|
||||
)
|
||||
|
||||
// Act: bygg ffmpeg args
|
||||
val args = plan.toFfmpegArgs(videoStreams, audioStreams)
|
||||
|
||||
// Assert: extended track skal være forkastet, kun ett audio map/codec skal finnes
|
||||
val expected = listOf(
|
||||
"-map", "0:v:0", "-c:v", "copy",
|
||||
"-map", "0:a:0", "-c:a:0", "copy",
|
||||
)
|
||||
assertEquals(expected, args)
|
||||
}
|
||||
|
||||
|
||||
fun mockVideoStream(
|
||||
index: Int = 0,
|
||||
codec: String = "h264",
|
||||
width: Int = 1920,
|
||||
height: Int = 1080,
|
||||
disposition: Disposition,
|
||||
tags: Tags
|
||||
) = VideoStream(
|
||||
index = index,
|
||||
codec_name = codec,
|
||||
codec_long_name = "H.264 / AVC / MPEG-4 AVC / MPEG-4 part 10",
|
||||
codec_type = "video",
|
||||
codec_tag_string = "avc1",
|
||||
codec_tag = "0x31637661",
|
||||
r_frame_rate = "25/1",
|
||||
avg_frame_rate = "25/1",
|
||||
time_base = "1/90000",
|
||||
start_pts = 0,
|
||||
start_time = "0.000000",
|
||||
disposition = disposition,
|
||||
tags = tags,
|
||||
duration = "60.0",
|
||||
duration_ts = 54000,
|
||||
profile = "High",
|
||||
width = width,
|
||||
height = height,
|
||||
coded_width = width,
|
||||
coded_height = height,
|
||||
closed_captions = 0,
|
||||
has_b_frames = 2,
|
||||
sample_aspect_ratio = "1:1",
|
||||
display_aspect_ratio = "16:9",
|
||||
pix_fmt = "yuv420p",
|
||||
level = 40,
|
||||
color_range = "tv",
|
||||
color_space = "bt709",
|
||||
color_transfer = "bt709",
|
||||
color_primaries = "bt709",
|
||||
chroma_location = "left",
|
||||
refs = 1
|
||||
)
|
||||
|
||||
fun mockAudioStream(
|
||||
index: Int = 0,
|
||||
codec: String = "aac",
|
||||
channels: Int = 2,
|
||||
profile: String = "LC",
|
||||
disposition: Disposition,
|
||||
tags: Tags
|
||||
) = AudioStream(
|
||||
index = index,
|
||||
codec_name = codec,
|
||||
codec_long_name = "AAC (Advanced Audio Coding)",
|
||||
codec_type = "audio",
|
||||
codec_tag_string = "mp4a",
|
||||
codec_tag = "0x6134706d",
|
||||
r_frame_rate = "0/0",
|
||||
avg_frame_rate = "0/0",
|
||||
time_base = "1/48000",
|
||||
start_pts = 0,
|
||||
start_time = "0.000000",
|
||||
duration = "60.0",
|
||||
duration_ts = 2880000,
|
||||
disposition = disposition,
|
||||
tags = tags,
|
||||
profile = profile,
|
||||
sample_fmt = "fltp",
|
||||
sample_rate = "48000",
|
||||
channels = channels,
|
||||
channel_layout = "stereo",
|
||||
bits_per_sample = 0
|
||||
)
|
||||
|
||||
fun mockDisposition(
|
||||
default: Int = 1,
|
||||
forced: Int = 0
|
||||
) = Disposition(
|
||||
default = default,
|
||||
dub = 0,
|
||||
original = 0,
|
||||
comment = 0,
|
||||
lyrics = 0,
|
||||
karaoke = 0,
|
||||
forced = forced,
|
||||
hearing_impaired = 0,
|
||||
captions = 0,
|
||||
visual_impaired = 0,
|
||||
clean_effects = 0,
|
||||
attached_pic = 0,
|
||||
timed_thumbnails = 0
|
||||
)
|
||||
|
||||
fun mockTags(
|
||||
language: String? = "eng",
|
||||
title: String? = null,
|
||||
filename: String? = null
|
||||
) = Tags(
|
||||
title = title,
|
||||
BPS = null,
|
||||
DURATION = null,
|
||||
NUMBER_OF_FRAMES = 0,
|
||||
NUMBER_OF_BYTES = null,
|
||||
_STATISTICS_WRITING_APP = null,
|
||||
_STATISTICS_WRITING_DATE_UTC = null,
|
||||
_STATISTICS_TAGS = null,
|
||||
language = language,
|
||||
filename = filename,
|
||||
mimetype = null
|
||||
)
|
||||
|
||||
|
||||
}
|
||||
12
apps/processer/src/test/resources/encodeProgress1.txt → shared/ffmpeg/src/test/resources/encodeProgress1.txt
Normal file → Executable file
12
apps/processer/src/test/resources/encodeProgress1.txt → shared/ffmpeg/src/test/resources/encodeProgress1.txt
Normal file → Executable file
@ -10,7 +10,7 @@ Input #0, matroska,webm, from '/src/input/completed/standalone/Potato.mkv':
|
||||
DURATION-eng : 00:29:09.748000000
|
||||
NUMBER_OF_FRAMES-eng: 41952
|
||||
NUMBER_OF_BYTES-eng: 2020851044
|
||||
_STATISTICS_WRITING_APP-eng: mkvmerge v43.0.0 ('The Quartermaster') 64-bit
|
||||
_STATISTICS_WRITING_APP-eng: mkvmerge v43.0.0 ('The Potato') 64-bit
|
||||
_STATISTICS_WRITING_DATE_UTC-eng: 2020-02-17 16:42:19
|
||||
_STATISTICS_TAGS-eng: BPS DURATION NUMBER_OF_FRAMES NUMBER_OF_BYTES
|
||||
Stream #0:1(eng): Audio: eac3, 48000 Hz, 5.1, fltp (default)
|
||||
@ -19,7 +19,7 @@ Input #0, matroska,webm, from '/src/input/completed/standalone/Potato.mkv':
|
||||
DURATION-eng : 00:29:09.728000000
|
||||
NUMBER_OF_FRAMES-eng: 54679
|
||||
NUMBER_OF_BYTES-eng: 139978240
|
||||
_STATISTICS_WRITING_APP-eng: mkvmerge v43.0.0 ('The Quartermaster') 64-bit
|
||||
_STATISTICS_WRITING_APP-eng: mkvmerge v43.0.0 ('The Potato') 64-bit
|
||||
_STATISTICS_WRITING_DATE_UTC-eng: 2020-02-17 16:42:19
|
||||
_STATISTICS_TAGS-eng: BPS DURATION NUMBER_OF_FRAMES NUMBER_OF_BYTES
|
||||
Stream #0:2(eng): Subtitle: subrip
|
||||
@ -28,7 +28,7 @@ Input #0, matroska,webm, from '/src/input/completed/standalone/Potato.mkv':
|
||||
DURATION-eng : 00:28:08.917000000
|
||||
NUMBER_OF_FRAMES-eng: 718
|
||||
NUMBER_OF_BYTES-eng: 25445
|
||||
_STATISTICS_WRITING_APP-eng: mkvmerge v43.0.0 ('The Quartermaster') 64-bit
|
||||
_STATISTICS_WRITING_APP-eng: mkvmerge v43.0.0 ('The Potato') 64-bit
|
||||
_STATISTICS_WRITING_DATE_UTC-eng: 2020-02-17 16:42:19
|
||||
_STATISTICS_TAGS-eng: BPS DURATION NUMBER_OF_FRAMES NUMBER_OF_BYTES
|
||||
Stream #0:3(eng): Subtitle: subrip
|
||||
@ -38,7 +38,7 @@ Input #0, matroska,webm, from '/src/input/completed/standalone/Potato.mkv':
|
||||
DURATION-eng : 00:28:08.917000000
|
||||
NUMBER_OF_FRAMES-eng: 718
|
||||
NUMBER_OF_BYTES-eng: 25445
|
||||
_STATISTICS_WRITING_APP-eng: mkvmerge v43.0.0 ('The Quartermaster') 64-bit
|
||||
_STATISTICS_WRITING_APP-eng: mkvmerge v43.0.0 ('The Potato') 64-bit
|
||||
_STATISTICS_WRITING_DATE_UTC-eng: 2020-02-17 16:42:19
|
||||
_STATISTICS_TAGS-eng: BPS DURATION NUMBER_OF_FRAMES NUMBER_OF_BYTES
|
||||
Stream mapping:
|
||||
@ -112,7 +112,7 @@ Output #0, mp4, to '/src/cache/Potato.work.mp4':
|
||||
DURATION-eng : 00:29:09.748000000
|
||||
NUMBER_OF_FRAMES-eng: 41952
|
||||
NUMBER_OF_BYTES-eng: 2020851044
|
||||
_STATISTICS_WRITING_APP-eng: mkvmerge v43.0.0 ('The Quartermaster') 64-bit
|
||||
_STATISTICS_WRITING_APP-eng: mkvmerge v43.0.0 ('The Potato') 64-bit
|
||||
_STATISTICS_WRITING_DATE_UTC-eng: 2020-02-17 16:42:19
|
||||
_STATISTICS_TAGS-eng: BPS DURATION NUMBER_OF_FRAMES NUMBER_OF_BYTES
|
||||
encoder : Lavc58.91.100 libx265
|
||||
@ -124,7 +124,7 @@ Output #0, mp4, to '/src/cache/Potato.work.mp4':
|
||||
DURATION-eng : 00:29:09.728000000
|
||||
NUMBER_OF_FRAMES-eng: 54679
|
||||
NUMBER_OF_BYTES-eng: 139978240
|
||||
_STATISTICS_WRITING_APP-eng: mkvmerge v43.0.0 ('The Quartermaster') 64-bit
|
||||
_STATISTICS_WRITING_APP-eng: mkvmerge v43.0.0 ('The Potato') 64-bit
|
||||
_STATISTICS_WRITING_DATE_UTC-eng: 2020-02-17 16:42:19
|
||||
_STATISTICS_TAGS-eng: BPS DURATION NUMBER_OF_FRAMES NUMBER_OF_BYTES
|
||||
frame=34
|
||||
Loading…
Reference in New Issue
Block a user