This commit is contained in:
Brage Skjønborg 2025-12-11 02:54:48 +01:00
parent b32ff8ce4f
commit 2c61650a0e
85 changed files with 2427 additions and 1845 deletions

View File

@ -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() {
}

View File

@ -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)
}
}

View File

@ -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)

View File

@ -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")

View File

@ -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)
}
}
}

View File

@ -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")
}
}

View File

@ -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) {
}

View File

@ -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()
}
}

View File

@ -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;
}
}

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -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 }
}
}

View File

@ -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
}
}
}

View File

@ -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()

View File

@ -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
}
}

View File

@ -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)
}
}

View File

@ -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) }
}
}

View File

@ -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
)
)
)
}
}

View File

@ -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
}

View File

@ -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() {
}
}

View File

@ -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
}

View File

@ -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"
)
}

View File

@ -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")

View File

@ -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()
)
)
)
}
}
}*/

View File

@ -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()
}
}*/

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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()

View File

@ -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()
}

View File

@ -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()
}

View File

@ -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()
}

View File

@ -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"))
}

View File

@ -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)
}
}
}

View File

@ -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()
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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
}

View File

@ -0,0 +1,4 @@
package no.iktdev.mediaprocessing.processer.listeners
class MuxAudioVideoTaskListener {
}

View File

@ -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()
}
}
}
}

View File

@ -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()
}
}
}
}

View File

@ -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 {
}

View File

@ -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")
}
}
}

View File

@ -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")
}
}

View File

@ -1,4 +0,0 @@
package no.iktdev.mediaprocessing.processer.services
class EncodeServiceTest {
}

View File

@ -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"))

View File

@ -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?

View File

@ -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
)

View File

@ -0,0 +1,6 @@
package no.iktdev.mediaprocessing.shared.common.event_task_contract.events
import no.iktdev.eventi.models.Event
class ConvertTaskCreatedEvent: Event() {
}

View File

@ -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() {

View File

@ -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() {
}

View File

@ -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,
)

View File

@ -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() {}

View File

@ -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() {
}

View File

@ -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 parsedCollection: String,
val parsedFileName: String,
val parsedSearchTitles: List<String>,
val mediaType: MediaType
) {
}
}
data class ParsedData(
val parsedTitle: String,
val parsedFileName: String,
val parsedSearchTitles: List<String>
) {
}

View File

@ -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

View File

@ -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() {
}

View File

@ -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() {
}

View File

@ -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()

View File

@ -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() {
}

View File

@ -0,0 +1,6 @@
package no.iktdev.mediaprocessing.shared.common.event_task_contract.events
import no.iktdev.eventi.models.Event
class ProcesserEncodePerformedEvent: Event() {
}

View File

@ -0,0 +1,6 @@
package no.iktdev.mediaprocessing.shared.common.event_task_contract.events
import no.iktdev.eventi.models.Event
class ProcesserEncodeTaskCreatedEvent: Event() {
}

View File

@ -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()

View File

@ -9,6 +9,6 @@ data class EncodeTask(
data class EncodeData(
val arguments: List<String>,
val outputFile: String,
val outputFileName: String,
val inputFile: String
)

View File

@ -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,

View File

@ -0,0 +1,6 @@
package no.iktdev.mediaprocessing.shared.common.model
data class FileInfo(
val fileName: String,
val fileUri: String,
)

View File

@ -0,0 +1,6 @@
package no.iktdev.mediaprocessing.shared.common.model
enum class MediaType {
Movie,
Serie
}

View File

@ -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)

View File

@ -0,0 +1,9 @@
package no.iktdev.mediaprocessing.shared.common.model
enum class SubtitleType {
Song,
Commentary,
ClosedCaption,
SHD,
Dialogue,
}

View File

@ -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
}
}

View File

@ -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"))
}

View File

@ -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")

View File

@ -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

View File

@ -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(),

View File

@ -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 128256 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
}

View File

@ -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
)

View File

@ -0,0 +1,7 @@
package no.iktdev.mediaprocessing.ffmpeg.dsl
enum class TranscodeDecision {
Copy,
Remux,
Reencode
}

View File

@ -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 Chromecastkrav → 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")
}

View File

@ -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")
}
}

View File

@ -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")
}
}

View File

@ -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")

View File

@ -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

View File

@ -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
)
}

View 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