Track selection
This commit is contained in:
parent
5babd17d18
commit
866f7f228c
@ -1,16 +1,55 @@
|
|||||||
package no.iktdev.mediaprocessing.coordinator
|
package no.iktdev.mediaprocessing.coordinator
|
||||||
|
|
||||||
import com.google.gson.Gson
|
import com.google.gson.Gson
|
||||||
|
import com.google.gson.JsonObject
|
||||||
|
import com.google.gson.JsonParser
|
||||||
import no.iktdev.mediaprocessing.ffmpeg.dsl.AudioCodec
|
import no.iktdev.mediaprocessing.ffmpeg.dsl.AudioCodec
|
||||||
import no.iktdev.mediaprocessing.ffmpeg.dsl.VideoCodec
|
import no.iktdev.mediaprocessing.ffmpeg.dsl.VideoCodec
|
||||||
import org.springframework.stereotype.Component
|
import org.springframework.stereotype.Component
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
import java.io.IOException
|
||||||
|
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
// MODELLER
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
|
||||||
data class PeferenceConfig(
|
data class PeferenceConfig(
|
||||||
val processer: ProcesserPreference
|
val processer: ProcesserPreference,
|
||||||
|
val language: LanguagePreference
|
||||||
)
|
)
|
||||||
|
|
||||||
|
data class LanguagePreference(
|
||||||
|
val preferredAudio: List<String>,
|
||||||
|
val preferredSubtitles: List<String>,
|
||||||
|
|
||||||
|
val preferOriginal: Boolean = true,
|
||||||
|
val avoidDub: Boolean = true,
|
||||||
|
|
||||||
|
// NEW: Prioritet for hvilket subtitle-format som skal brukes som master
|
||||||
|
val subtitleFormatPriority: List<String> = listOf("ass", "srt", "vtt", "smi"),
|
||||||
|
|
||||||
|
// NEW: Hvordan subtitles skal velges
|
||||||
|
val subtitleSelectionMode: SubtitleSelectionMode = SubtitleSelectionMode.DialogueOnly
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
fun default() = LanguagePreference(
|
||||||
|
preferredAudio = listOf("eng", "nor", "jpn"),
|
||||||
|
preferredSubtitles = listOf("eng", "nor"),
|
||||||
|
preferOriginal = true,
|
||||||
|
avoidDub = true,
|
||||||
|
subtitleFormatPriority = listOf("ass", "srt", "vtt", "smi"),
|
||||||
|
subtitleSelectionMode = SubtitleSelectionMode.DialogueOnly
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class SubtitleSelectionMode {
|
||||||
|
DialogueOnly, // Kun dialog
|
||||||
|
DialogueAndForced, // Dialog + forced
|
||||||
|
All // Alle typer
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
data class ProcesserPreference(
|
data class ProcesserPreference(
|
||||||
val videoPreference: VideoPreference? = null,
|
val videoPreference: VideoPreference? = null,
|
||||||
val audioPreference: AudioPreference? = null
|
val audioPreference: AudioPreference? = null
|
||||||
@ -19,7 +58,7 @@ data class ProcesserPreference(
|
|||||||
fun default(): ProcesserPreference {
|
fun default(): ProcesserPreference {
|
||||||
return ProcesserPreference(
|
return ProcesserPreference(
|
||||||
videoPreference = VideoPreference(VideoCodec.Hevc(), false),
|
videoPreference = VideoPreference(VideoCodec.Hevc(), false),
|
||||||
audioPreference = AudioPreference("jpn", AudioCodec.Aac())
|
audioPreference = AudioPreference(AudioCodec.Aac())
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -31,57 +70,121 @@ data class VideoPreference(
|
|||||||
)
|
)
|
||||||
|
|
||||||
data class AudioPreference(
|
data class AudioPreference(
|
||||||
val language: String? = null,
|
|
||||||
val codec: AudioCodec
|
val codec: AudioCodec
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
// PREFERENCE COMPONENT
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
|
||||||
@Component
|
@Component
|
||||||
class Preference(private val coordinatorEnv: CoordinatorEnv) {
|
class Preference(private val coordinatorEnv: CoordinatorEnv) {
|
||||||
|
|
||||||
fun getProcesserPreference(): ProcesserPreference {
|
private val gson = Gson()
|
||||||
val default = ProcesserPreference.default()
|
private val lock = Any()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Leser hele configen, men bevarer ukjente felter.
|
||||||
|
*/
|
||||||
|
fun getFullConfig(): PeferenceConfig {
|
||||||
val file = coordinatorEnv.preference
|
val file = coordinatorEnv.preference
|
||||||
|
|
||||||
if (!file.exists()) {
|
if (!file.exists()) {
|
||||||
// Opprett fil med default
|
val default = PeferenceConfig(
|
||||||
file.writeText(Gson().toJson(PeferenceConfig(default)))
|
processer = ProcesserPreference.default(),
|
||||||
|
language = LanguagePreference.default()
|
||||||
|
)
|
||||||
|
writeJsonObject(JsonObject().apply {
|
||||||
|
add("processer", gson.toJsonTree(default.processer))
|
||||||
|
add("language", gson.toJsonTree(default.language))
|
||||||
|
}, file)
|
||||||
return default
|
return default
|
||||||
}
|
}
|
||||||
|
|
||||||
val text = try {
|
val root = try {
|
||||||
file.readText()
|
JsonParser.parseString(file.readText()).asJsonObject
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
return default
|
return PeferenceConfig(
|
||||||
}
|
processer = ProcesserPreference.default(),
|
||||||
|
language = LanguagePreference.default()
|
||||||
val parsed = try {
|
|
||||||
Gson().fromJson(text, PeferenceConfig::class.java)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
return default
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hvis hele configen er null → default
|
|
||||||
val cfg = parsed ?: return default
|
|
||||||
|
|
||||||
// Hvis processer er null → default
|
|
||||||
val p = cfg.processer ?: return default
|
|
||||||
|
|
||||||
// Hvis underfelter er null → fyll inn default
|
|
||||||
val safeVideo = p.videoPreference ?: default.videoPreference
|
|
||||||
val safeAudio = p.audioPreference ?: default.audioPreference
|
|
||||||
|
|
||||||
return ProcesserPreference(
|
|
||||||
videoPreference = safeVideo,
|
|
||||||
audioPreference = safeAudio
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
|
val processer = try {
|
||||||
|
gson.fromJson(root.get("processer"), ProcesserPreference::class.java)
|
||||||
|
?: ProcesserPreference.default()
|
||||||
|
} catch (_: Exception) {
|
||||||
|
ProcesserPreference.default()
|
||||||
|
}
|
||||||
|
|
||||||
private fun File.ifExists(block: File.() -> Unit, orElse: () -> Unit = {}) {
|
val language = try {
|
||||||
if (this.exists()) {
|
gson.fromJson(root.get("language"), LanguagePreference::class.java)
|
||||||
block()
|
?: LanguagePreference.default()
|
||||||
} else {
|
} catch (_: Exception) {
|
||||||
orElse()
|
LanguagePreference.default()
|
||||||
|
}
|
||||||
|
|
||||||
|
return PeferenceConfig(processer, language)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getProcesserPreference(): ProcesserPreference =
|
||||||
|
getFullConfig().processer
|
||||||
|
|
||||||
|
fun getLanguagePreference(): LanguagePreference =
|
||||||
|
getFullConfig().language
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Oppdaterer kun processer-delen og bevarer resten av JSON.
|
||||||
|
*/
|
||||||
|
fun saveProcesserPreference(pref: ProcesserPreference) {
|
||||||
|
val file = coordinatorEnv.preference
|
||||||
|
synchronized(lock) {
|
||||||
|
val root = readOrEmpty(file)
|
||||||
|
root.add("processer", gson.toJsonTree(pref))
|
||||||
|
writeJsonObject(root, file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Oppdaterer kun language-delen og bevarer resten av JSON.
|
||||||
|
*/
|
||||||
|
fun saveLanguagePreference(pref: LanguagePreference) {
|
||||||
|
val file = coordinatorEnv.preference
|
||||||
|
synchronized(lock) {
|
||||||
|
val root = readOrEmpty(file)
|
||||||
|
root.add("language", gson.toJsonTree(pref))
|
||||||
|
writeJsonObject(root, file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Overskriver hele configen (brukes hvis FE sender alt).
|
||||||
|
*/
|
||||||
|
fun saveFullConfig(cfg: PeferenceConfig) {
|
||||||
|
val file = coordinatorEnv.preference
|
||||||
|
synchronized(lock) {
|
||||||
|
val root = JsonObject()
|
||||||
|
root.add("processer", gson.toJsonTree(cfg.processer))
|
||||||
|
root.add("language", gson.toJsonTree(cfg.language))
|
||||||
|
writeJsonObject(root, file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun readOrEmpty(file: File): JsonObject =
|
||||||
|
if (file.exists()) {
|
||||||
|
try {
|
||||||
|
JsonParser.parseString(file.readText()).asJsonObject
|
||||||
|
} catch (_: Exception) {
|
||||||
|
JsonObject()
|
||||||
|
}
|
||||||
|
} else JsonObject()
|
||||||
|
|
||||||
|
private fun writeJsonObject(obj: JsonObject, file: File) {
|
||||||
|
try {
|
||||||
|
file.parentFile?.mkdirs()
|
||||||
|
file.writeText(gson.toJson(obj))
|
||||||
|
} catch (e: IOException) {
|
||||||
|
throw RuntimeException("Failed to write preference file", e)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,34 @@
|
|||||||
|
package no.iktdev.mediaprocessing.coordinator.controller
|
||||||
|
|
||||||
|
import no.iktdev.mediaprocessing.coordinator.PeferenceConfig
|
||||||
|
import no.iktdev.mediaprocessing.coordinator.Preference
|
||||||
|
import no.iktdev.mediaprocessing.coordinator.ProcesserPreference
|
||||||
|
import org.springframework.http.ResponseEntity
|
||||||
|
import org.springframework.web.bind.annotation.*
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/preference")
|
||||||
|
class PreferenceController(
|
||||||
|
private val preference: Preference
|
||||||
|
) {
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
fun getFull(): ResponseEntity<PeferenceConfig> =
|
||||||
|
ResponseEntity.ok(preference.getFullConfig())
|
||||||
|
|
||||||
|
@PutMapping
|
||||||
|
fun putFull(@RequestBody body: PeferenceConfig): ResponseEntity<PeferenceConfig> {
|
||||||
|
preference.saveFullConfig(body)
|
||||||
|
return ResponseEntity.ok(preference.getFullConfig())
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/processer")
|
||||||
|
fun getProcesser(): ResponseEntity<ProcesserPreference> =
|
||||||
|
ResponseEntity.ok(preference.getProcesserPreference())
|
||||||
|
|
||||||
|
@PutMapping("/processer")
|
||||||
|
fun putProcesser(@RequestBody body: ProcesserPreference): ResponseEntity<ProcesserPreference> {
|
||||||
|
preference.saveProcesserPreference(body)
|
||||||
|
return ResponseEntity.ok(preference.getProcesserPreference())
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -13,7 +13,7 @@ import java.io.File
|
|||||||
import java.util.*
|
import java.util.*
|
||||||
|
|
||||||
@Component
|
@Component
|
||||||
class MediaCreateExtractTaskListener: EventListener() {
|
class MediaCreateExtractTaskListener(): EventListener() {
|
||||||
override fun onEvent(
|
override fun onEvent(
|
||||||
event: Event,
|
event: Event,
|
||||||
history: List<Event>
|
history: List<Event>
|
||||||
|
|||||||
@ -2,6 +2,7 @@ package no.iktdev.mediaprocessing.coordinator.listeners.events
|
|||||||
|
|
||||||
import no.iktdev.eventi.events.EventListener
|
import no.iktdev.eventi.events.EventListener
|
||||||
import no.iktdev.eventi.models.Event
|
import no.iktdev.eventi.models.Event
|
||||||
|
import no.iktdev.mediaprocessing.coordinator.Preference
|
||||||
import no.iktdev.mediaprocessing.ffmpeg.data.AudioStream
|
import no.iktdev.mediaprocessing.ffmpeg.data.AudioStream
|
||||||
import no.iktdev.mediaprocessing.ffmpeg.data.VideoStream
|
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.MediaStreamParsedEvent
|
||||||
@ -9,11 +10,9 @@ import no.iktdev.mediaprocessing.shared.common.event_task_contract.events.MediaT
|
|||||||
import org.springframework.stereotype.Component
|
import org.springframework.stereotype.Component
|
||||||
|
|
||||||
@Component
|
@Component
|
||||||
class MediaSelectEncodeTracksListener: EventListener() {
|
class MediaSelectEncodeTracksListener(
|
||||||
|
private val preference: Preference
|
||||||
fun getAudioLanguagePreference(): List<String> {
|
) : EventListener() {
|
||||||
return listOf("jpn")
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onEvent(
|
override fun onEvent(
|
||||||
event: Event,
|
event: Event,
|
||||||
@ -21,10 +20,12 @@ class MediaSelectEncodeTracksListener: EventListener() {
|
|||||||
): Event? {
|
): Event? {
|
||||||
val useEvent = event as? MediaStreamParsedEvent ?: return null
|
val useEvent = event as? MediaStreamParsedEvent ?: return null
|
||||||
|
|
||||||
|
|
||||||
val videoTrackIndex = getVideoTrackToUse(useEvent.data.videoStream)
|
val videoTrackIndex = getVideoTrackToUse(useEvent.data.videoStream)
|
||||||
val audioDefaultTrack = getAudioDefaultTrackToUse(useEvent.data.audioStream)
|
val audioDefaultTrack = getAudioDefaultTrackToUse(useEvent.data.audioStream)
|
||||||
val audioExtendedTrack = getAudioExtendedTrackToUse(useEvent.data.audioStream, selectedDefaultTrack = audioDefaultTrack)
|
val audioExtendedTrack = getAudioExtendedTrackToUse(
|
||||||
|
useEvent.data.audioStream,
|
||||||
|
selectedDefaultTrack = audioDefaultTrack
|
||||||
|
)
|
||||||
|
|
||||||
return MediaTracksEncodeSelectedEvent(
|
return MediaTracksEncodeSelectedEvent(
|
||||||
selectedVideoTrack = videoTrackIndex,
|
selectedVideoTrack = videoTrackIndex,
|
||||||
@ -33,42 +34,125 @@ class MediaSelectEncodeTracksListener: EventListener() {
|
|||||||
).derivedOf(event)
|
).derivedOf(event)
|
||||||
}
|
}
|
||||||
|
|
||||||
protected fun getAudioExtendedTrackToUse(audioStream: List<AudioStream>, selectedDefaultTrack: Int): Int? {
|
// ------------------------------------------------------------
|
||||||
val durationFiltered = audioStream.filterOnPreferredLanguage()
|
// AUDIO SELECTION
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
private fun getAudioDefaultTrackToUse(audioStreams: List<AudioStream>): Int {
|
||||||
|
val pref = preference.getLanguagePreference()
|
||||||
|
|
||||||
|
val selected = selectBestAudioStream(
|
||||||
|
streams = audioStreams,
|
||||||
|
preferredLanguages = pref.preferredAudio,
|
||||||
|
preferOriginal = pref.preferOriginal,
|
||||||
|
avoidDub = pref.avoidDub,
|
||||||
|
mode = AudioSelectMode.DEFAULT
|
||||||
|
) ?: audioStreams.firstOrNull()
|
||||||
|
|
||||||
|
return audioStreams.indexOf(selected)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getAudioExtendedTrackToUse(
|
||||||
|
audioStreams: List<AudioStream>,
|
||||||
|
selectedDefaultTrack: Int
|
||||||
|
): Int? {
|
||||||
|
val pref = preference.getLanguagePreference()
|
||||||
|
|
||||||
|
val candidates = audioStreams
|
||||||
|
.filter { it.index != selectedDefaultTrack }
|
||||||
.filter { (it.duration_ts ?: 0) > 0 }
|
.filter { (it.duration_ts ?: 0) > 0 }
|
||||||
.filter { it.channels > 2 }
|
.filter { it.channels > 2 }
|
||||||
.filter { it.index != selectedDefaultTrack }
|
|
||||||
val selected = durationFiltered.firstOrNull() ?: return null
|
val selected = selectBestAudioStream(
|
||||||
return audioStream.indexOf(selected)
|
streams = candidates,
|
||||||
|
preferredLanguages = pref.preferredAudio,
|
||||||
|
preferOriginal = pref.preferOriginal,
|
||||||
|
avoidDub = pref.avoidDub,
|
||||||
|
mode = AudioSelectMode.EXTENDED
|
||||||
|
) ?: return null
|
||||||
|
|
||||||
|
return audioStreams.indexOf(selected)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Select the default audio track to use for encoding.
|
// ------------------------------------------------------------
|
||||||
* If no default track is found, select the first audio track.
|
// CORE AUDIO SELECTION LOGIC
|
||||||
* If audio track with preferred language (e.g., "nor") is not found, selects "eng" or first available.
|
// ------------------------------------------------------------
|
||||||
*/
|
|
||||||
protected fun getAudioDefaultTrackToUse(audioStream: List<AudioStream>): Int {
|
private enum class AudioSelectMode { DEFAULT, EXTENDED }
|
||||||
val durationFiltered = audioStream.filterOnPreferredLanguage()
|
|
||||||
|
private fun selectBestAudioStream(
|
||||||
|
streams: List<AudioStream>,
|
||||||
|
preferredLanguages: List<String>,
|
||||||
|
preferOriginal: Boolean,
|
||||||
|
avoidDub: Boolean,
|
||||||
|
mode: AudioSelectMode
|
||||||
|
): AudioStream? {
|
||||||
|
if (streams.isEmpty()) return null
|
||||||
|
|
||||||
|
// 1. Originalspråk
|
||||||
|
if (preferOriginal) {
|
||||||
|
val originals = streams.filter { it.disposition.original == 1 }
|
||||||
|
if (originals.isNotEmpty()) {
|
||||||
|
return when (mode) {
|
||||||
|
AudioSelectMode.DEFAULT -> originals.minByOrNull { it.channels }
|
||||||
|
AudioSelectMode.EXTENDED -> originals.maxByOrNull { it.channels }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Filtrer bort dub
|
||||||
|
val filtered = if (avoidDub) {
|
||||||
|
streams.filter { it.disposition.dub != 1 }
|
||||||
|
} else streams
|
||||||
|
|
||||||
|
// 3. Foretrukne språk
|
||||||
|
for (lang in preferredLanguages) {
|
||||||
|
val match = filtered.filter {
|
||||||
|
it.tags.language?.equals(lang, ignoreCase = true) == true
|
||||||
|
}
|
||||||
|
if (match.isNotEmpty()) {
|
||||||
|
return when (mode) {
|
||||||
|
AudioSelectMode.DEFAULT -> match.minByOrNull { it.channels }
|
||||||
|
AudioSelectMode.EXTENDED -> match.maxByOrNull { it.channels }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Default-flagget
|
||||||
|
val default = filtered.firstOrNull { it.disposition.default == 1 }
|
||||||
|
if (default != null) return default
|
||||||
|
|
||||||
|
// 5. Fallback
|
||||||
|
return filtered.firstOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
// QUALITY SCORE (no bitrate)
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
|
||||||
|
private fun qualityScore(s: AudioStream): Int {
|
||||||
|
val channelsScore = s.channels * 10
|
||||||
|
val bitsScore = s.bits_per_sample
|
||||||
|
val sampleRateScore = s.sample_rate.toIntOrNull()?.div(1000) ?: 0
|
||||||
|
|
||||||
|
return channelsScore + bitsScore + sampleRateScore
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
// VIDEO SELECTION
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
|
||||||
|
private fun getVideoTrackToUse(streams: List<VideoStream>): Int {
|
||||||
|
val selectStream = streams
|
||||||
.filter { (it.duration_ts ?: 0) > 0 }
|
.filter { (it.duration_ts ?: 0) > 0 }
|
||||||
|
.maxByOrNull { it.duration_ts ?: 0 }
|
||||||
|
?: streams.minByOrNull { it.index }
|
||||||
|
?: throw Exception("No video streams found")
|
||||||
|
|
||||||
val selected = durationFiltered
|
|
||||||
.filter { it.channels == 2 }.ifEmpty { durationFiltered }
|
|
||||||
.minByOrNull { 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.
|
|
||||||
*/
|
|
||||||
protected fun List<AudioStream>.filterOnPreferredLanguage(): List<AudioStream> {
|
|
||||||
return this.filter { it.tags.language in getAudioLanguagePreference() }.ifEmpty { this }
|
|
||||||
}
|
|
||||||
|
|
||||||
protected 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)
|
return streams.indexOf(selectStream)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -2,22 +2,19 @@ package no.iktdev.mediaprocessing.coordinator.listeners.events
|
|||||||
|
|
||||||
import no.iktdev.eventi.events.EventListener
|
import no.iktdev.eventi.events.EventListener
|
||||||
import no.iktdev.eventi.models.Event
|
import no.iktdev.eventi.models.Event
|
||||||
|
import no.iktdev.mediaprocessing.coordinator.Preference
|
||||||
|
import no.iktdev.mediaprocessing.coordinator.SubtitleSelectionMode
|
||||||
import no.iktdev.mediaprocessing.ffmpeg.data.SubtitleStream
|
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.MediaTracksDetermineSubtitleTypeEvent
|
||||||
import no.iktdev.mediaprocessing.shared.common.event_task_contract.events.MediaTracksExtractSelectedEvent
|
import no.iktdev.mediaprocessing.shared.common.event_task_contract.events.MediaTracksExtractSelectedEvent
|
||||||
|
import no.iktdev.mediaprocessing.shared.common.model.SubtitleItem
|
||||||
import no.iktdev.mediaprocessing.shared.common.model.SubtitleType
|
import no.iktdev.mediaprocessing.shared.common.model.SubtitleType
|
||||||
import org.springframework.stereotype.Component
|
import org.springframework.stereotype.Component
|
||||||
|
|
||||||
@Component
|
@Component
|
||||||
class MediaSelectExtractTracksListener: EventListener() {
|
class MediaSelectExtractTracksListener(
|
||||||
|
private val preference: Preference
|
||||||
open fun limitToLanguages(): Set<String> {
|
) : EventListener() {
|
||||||
return emptySet()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun useTypes(): Set<SubtitleType> {
|
|
||||||
return setOf(SubtitleType.Dialogue)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onEvent(
|
override fun onEvent(
|
||||||
event: Event,
|
event: Event,
|
||||||
@ -25,20 +22,109 @@ class MediaSelectExtractTracksListener: EventListener() {
|
|||||||
): Event? {
|
): Event? {
|
||||||
val useEvent = event as? MediaTracksDetermineSubtitleTypeEvent ?: return null
|
val useEvent = event as? MediaTracksDetermineSubtitleTypeEvent ?: return null
|
||||||
|
|
||||||
val filtered = useEvent.subtitleTrackItems
|
val pref = preference.getLanguagePreference()
|
||||||
.filter { it.type in useTypes() }
|
|
||||||
.map { it.stream }
|
// 1. Filter by subtitle type (dialogue, forced, etc.)
|
||||||
.filterOnPreferredLanguage()
|
val filteredByType = filterBySelectionMode(
|
||||||
|
items = useEvent.subtitleTrackItems,
|
||||||
|
mode = pref.subtitleSelectionMode
|
||||||
|
)
|
||||||
|
|
||||||
|
// 2. Extract streams
|
||||||
|
val streams = filteredByType.map { it.stream }
|
||||||
|
|
||||||
|
// 3. Select subtitles based on language + format priority
|
||||||
|
val selected = selectSubtitleStreams(
|
||||||
|
streams = streams,
|
||||||
|
preferredLanguages = pref.preferredSubtitles,
|
||||||
|
preferOriginal = pref.preferOriginal,
|
||||||
|
formatPriority = pref.subtitleFormatPriority
|
||||||
|
)
|
||||||
|
|
||||||
return MediaTracksExtractSelectedEvent(
|
return MediaTracksExtractSelectedEvent(
|
||||||
selectedSubtitleTracks = filtered.map { it.index }
|
selectedSubtitleTracks = selected.map { it.index }
|
||||||
).derivedOf(event)
|
).derivedOf(event)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
// TYPE FILTERING
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
|
||||||
protected fun List<SubtitleStream>.filterOnPreferredLanguage(): List<SubtitleStream> {
|
private fun filterBySelectionMode(
|
||||||
val languages = limitToLanguages()
|
items: List<SubtitleItem>,
|
||||||
if (languages.isEmpty()) return this
|
mode: SubtitleSelectionMode
|
||||||
return this.filter { it.tags.language != null }.filter { languages.contains(it.tags.language) }
|
): List<SubtitleItem> {
|
||||||
|
return when (mode) {
|
||||||
|
SubtitleSelectionMode.DialogueOnly ->
|
||||||
|
items.filter { it.type == SubtitleType.Dialogue }
|
||||||
|
|
||||||
|
SubtitleSelectionMode.DialogueAndForced ->
|
||||||
|
items.filter {
|
||||||
|
it.type == SubtitleType.Dialogue || it.stream.disposition?.forced == 1
|
||||||
|
}
|
||||||
|
|
||||||
|
SubtitleSelectionMode.All ->
|
||||||
|
items
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
// SUBTITLE SELECTION LOGIC
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
|
||||||
|
private fun selectSubtitleStreams(
|
||||||
|
streams: List<SubtitleStream>,
|
||||||
|
preferredLanguages: List<String>,
|
||||||
|
preferOriginal: Boolean,
|
||||||
|
formatPriority: List<String>
|
||||||
|
): List<SubtitleStream> {
|
||||||
|
if (streams.isEmpty()) return emptyList()
|
||||||
|
|
||||||
|
// 1. Originalspråk
|
||||||
|
if (preferOriginal) {
|
||||||
|
val originals = streams.filter { it.disposition?.original == 1 }
|
||||||
|
if (originals.isNotEmpty()) {
|
||||||
|
return originals.uniquePerLanguageBestFormat(formatPriority)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Preferred languages
|
||||||
|
for (lang in preferredLanguages) {
|
||||||
|
val match = streams.filter {
|
||||||
|
it.tags.language?.equals(lang, ignoreCase = true) == true ||
|
||||||
|
it.subtitle_tags.language?.equals(lang, ignoreCase = true) == true
|
||||||
|
}
|
||||||
|
if (match.isNotEmpty()) {
|
||||||
|
return match.uniquePerLanguageBestFormat(formatPriority)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Default subtitles
|
||||||
|
val defaults = streams.filter { it.disposition?.default == 1 }
|
||||||
|
if (defaults.isNotEmpty()) {
|
||||||
|
return defaults.uniquePerLanguageBestFormat(formatPriority)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Fallback: all subtitles
|
||||||
|
return streams.uniquePerLanguageBestFormat(formatPriority)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
// UNIQUE PER LANGUAGE + FORMAT PRIORITY
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
|
||||||
|
private fun List<SubtitleStream>.uniquePerLanguageBestFormat(
|
||||||
|
formatPriority: List<String>
|
||||||
|
): List<SubtitleStream> {
|
||||||
|
return this
|
||||||
|
.groupBy { it.tags.language ?: it.subtitle_tags.language ?: "unknown" }
|
||||||
|
.mapNotNull { (_, langGroup) ->
|
||||||
|
langGroup
|
||||||
|
.sortedBy { s ->
|
||||||
|
val idx = formatPriority.indexOf(s.codec_name.lowercase())
|
||||||
|
if (idx == -1) Int.MAX_VALUE else idx
|
||||||
|
}
|
||||||
|
.firstOrNull()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -0,0 +1,22 @@
|
|||||||
|
package no.iktdev.mediaprocessing
|
||||||
|
|
||||||
|
import no.iktdev.mediaprocessing.coordinator.CoordinatorEnv
|
||||||
|
import no.iktdev.mediaprocessing.coordinator.config.ExecutablesConfig
|
||||||
|
import no.iktdev.mediaprocessing.shared.common.configs.MediaPaths
|
||||||
|
import no.iktdev.mediaprocessing.shared.common.configs.StreamItConfig
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
// Fake CoordinatorEnv for testing
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
class FakeCoordinatorEnv(prefFile: File) : CoordinatorEnv(
|
||||||
|
streamIt = StreamItConfig(address = "http://localhost"),
|
||||||
|
exec = ExecutablesConfig(ffprobe = "/usr/bin/ffprobe"),
|
||||||
|
media = MediaPaths(
|
||||||
|
cache = "/tmp/cache",
|
||||||
|
outgoing = "/tmp/outgoing",
|
||||||
|
incoming = "/tmp/incoming"
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
override val preference: File = prefFile
|
||||||
|
}
|
||||||
@ -3,8 +3,11 @@ package no.iktdev.mediaprocessing
|
|||||||
import no.iktdev.eventi.models.Event
|
import no.iktdev.eventi.models.Event
|
||||||
import no.iktdev.eventi.models.store.TaskStatus
|
import no.iktdev.eventi.models.store.TaskStatus
|
||||||
import no.iktdev.mediaprocessing.TestBase.DummyTask
|
import no.iktdev.mediaprocessing.TestBase.DummyTask
|
||||||
|
import no.iktdev.mediaprocessing.ffmpeg.data.*
|
||||||
import no.iktdev.mediaprocessing.shared.common.event_task_contract.events.*
|
import no.iktdev.mediaprocessing.shared.common.event_task_contract.events.*
|
||||||
import no.iktdev.mediaprocessing.shared.common.model.MediaType
|
import no.iktdev.mediaprocessing.shared.common.model.MediaType
|
||||||
|
import no.iktdev.mediaprocessing.shared.common.model.SubtitleItem
|
||||||
|
import no.iktdev.mediaprocessing.shared.common.model.SubtitleType
|
||||||
import java.util.*
|
import java.util.*
|
||||||
|
|
||||||
object MockData {
|
object MockData {
|
||||||
@ -119,4 +122,112 @@ object MockData {
|
|||||||
return listOf(start, result)
|
return listOf(start, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun dummyAudioStream(
|
||||||
|
index: Int,
|
||||||
|
language: String,
|
||||||
|
channels: Int,
|
||||||
|
durationTs: Long = 1000
|
||||||
|
): AudioStream {
|
||||||
|
return AudioStream(
|
||||||
|
index = index,
|
||||||
|
codec_name = "aac",
|
||||||
|
codec_long_name = "AAC",
|
||||||
|
codec_type = "audio",
|
||||||
|
codec_tag_string = "",
|
||||||
|
codec_tag = "",
|
||||||
|
r_frame_rate = "0/0",
|
||||||
|
avg_frame_rate = "0/0",
|
||||||
|
time_base = "1/1000",
|
||||||
|
start_pts = 0,
|
||||||
|
start_time = "0",
|
||||||
|
duration = null,
|
||||||
|
duration_ts = durationTs,
|
||||||
|
disposition = Disposition(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0),
|
||||||
|
tags = Tags(
|
||||||
|
title = null, 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 = null, mimetype = null
|
||||||
|
),
|
||||||
|
profile = "LC",
|
||||||
|
sample_fmt = "fltp",
|
||||||
|
sample_rate = "48000",
|
||||||
|
channels = channels,
|
||||||
|
channel_layout = "stereo",
|
||||||
|
bits_per_sample = 16
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun dummyVideoStream(index: Int, durationTs: Long = 1000): VideoStream {
|
||||||
|
return VideoStream(
|
||||||
|
index = index,
|
||||||
|
codec_name = "h264",
|
||||||
|
codec_long_name = "H.264",
|
||||||
|
codec_type = "video",
|
||||||
|
codec_tag_string = "",
|
||||||
|
codec_tag = "",
|
||||||
|
r_frame_rate = "25/1",
|
||||||
|
avg_frame_rate = "25/1",
|
||||||
|
time_base = "1/1000",
|
||||||
|
start_pts = 0,
|
||||||
|
start_time = "0",
|
||||||
|
duration = null,
|
||||||
|
duration_ts = durationTs,
|
||||||
|
disposition = Disposition(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0),
|
||||||
|
tags = Tags(
|
||||||
|
title = null, 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 = "eng", filename = null, mimetype = null
|
||||||
|
),
|
||||||
|
profile = "main",
|
||||||
|
width = 1920,
|
||||||
|
height = 1080,
|
||||||
|
coded_width = 1920,
|
||||||
|
coded_height = 1080,
|
||||||
|
closed_captions = 0,
|
||||||
|
has_b_frames = 0,
|
||||||
|
sample_aspect_ratio = "1:1",
|
||||||
|
display_aspect_ratio = "16:9",
|
||||||
|
pix_fmt = "yuv420p",
|
||||||
|
level = 30,
|
||||||
|
color_range = "tv",
|
||||||
|
color_space = "bt709",
|
||||||
|
color_transfer = "bt709",
|
||||||
|
color_primaries = "bt709",
|
||||||
|
chroma_location = "left",
|
||||||
|
refs = 1
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun dummySubtitleStream(index: Int, language: String?): SubtitleStream {
|
||||||
|
return SubtitleStream(
|
||||||
|
index = index,
|
||||||
|
codec_name = "ass",
|
||||||
|
codec_long_name = "ASS",
|
||||||
|
codec_type = "subtitle",
|
||||||
|
codec_tag_string = "",
|
||||||
|
codec_tag = "",
|
||||||
|
r_frame_rate = "0/0",
|
||||||
|
avg_frame_rate = "0/0",
|
||||||
|
time_base = "1/1000",
|
||||||
|
start_pts = 0,
|
||||||
|
start_time = "0",
|
||||||
|
duration = null,
|
||||||
|
duration_ts = 1000,
|
||||||
|
disposition = null,
|
||||||
|
tags = Tags(
|
||||||
|
title = null, 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 = null, mimetype = null
|
||||||
|
),
|
||||||
|
subtitle_tags = SubtitleTags(language = language, filename = null, mimetype = null)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun dummySubtitleItem(index: Int, language: String?, type: SubtitleType): SubtitleItem {
|
||||||
|
val stream = dummySubtitleStream(index, language)
|
||||||
|
return SubtitleItem(stream = stream, type = type)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
@ -4,7 +4,8 @@ import no.iktdev.eventi.models.Event
|
|||||||
import org.json.JSONArray
|
import org.json.JSONArray
|
||||||
|
|
||||||
enum class Files(val fileName: String) {
|
enum class Files(val fileName: String) {
|
||||||
MultipleLanguageBased("Events.json")
|
MultipleLanguageBased("Events.json"),
|
||||||
|
MediaStreamParsedEvent("MediaStreamParsedEvent.json")
|
||||||
}
|
}
|
||||||
|
|
||||||
fun Files.getContent(): String? {
|
fun Files.getContent(): String? {
|
||||||
|
|||||||
@ -59,4 +59,6 @@ open class TestBase {
|
|||||||
return start
|
return start
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
@ -1,212 +1,151 @@
|
|||||||
package no.iktdev.mediaprocessing.coordinator.listeners.events
|
package no.iktdev.mediaprocessing.coordinator.listeners.events
|
||||||
|
|
||||||
import no.iktdev.eventi.models.Event
|
import no.iktdev.eventi.models.Event
|
||||||
import no.iktdev.mediaprocessing.ffmpeg.data.*
|
import no.iktdev.mediaprocessing.FakeCoordinatorEnv
|
||||||
|
import no.iktdev.mediaprocessing.MockData
|
||||||
|
import no.iktdev.mediaprocessing.coordinator.Preference
|
||||||
|
import no.iktdev.mediaprocessing.ffmpeg.data.ParsedMediaStreams
|
||||||
import no.iktdev.mediaprocessing.shared.common.event_task_contract.events.MediaStreamParsedEvent
|
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.MediaTracksEncodeSelectedEvent
|
||||||
import org.junit.jupiter.api.Assertions.*
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Assertions.assertNull
|
||||||
import org.junit.jupiter.api.DisplayName
|
import org.junit.jupiter.api.DisplayName
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
class MediaTracksEncodeSelectorTest: MediaSelectEncodeTracksListener() {
|
class MediaTracksEncodeSelectorTest {
|
||||||
|
|
||||||
private fun dummyAudioStream(
|
private fun testPreference(): Preference {
|
||||||
index: Int,
|
val tmp = File.createTempFile("pref", ".json")
|
||||||
language: String,
|
tmp.writeText(
|
||||||
channels: Int,
|
"""
|
||||||
durationTs: Long = 1000
|
{
|
||||||
): AudioStream {
|
"processer": {},
|
||||||
return AudioStream(
|
"language": {
|
||||||
index = index,
|
"preferredAudio": ["jpn", "eng"],
|
||||||
codec_name = "aac",
|
"preferredSubtitles": ["eng"],
|
||||||
codec_long_name = "AAC",
|
"preferOriginal": true,
|
||||||
codec_type = "audio",
|
"avoidDub": true,
|
||||||
codec_tag_string = "",
|
"subtitleFormatPriority": ["ass","srt","vtt","smi"],
|
||||||
codec_tag = "",
|
"subtitleSelectionMode": "DialogueOnly"
|
||||||
r_frame_rate = "0/0",
|
}
|
||||||
avg_frame_rate = "0/0",
|
}
|
||||||
time_base = "1/1000",
|
""".trimIndent()
|
||||||
start_pts = 0,
|
|
||||||
start_time = "0",
|
|
||||||
duration = null,
|
|
||||||
duration_ts = durationTs,
|
|
||||||
disposition = Disposition(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0),
|
|
||||||
tags = Tags(
|
|
||||||
title = null, 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 = null, mimetype = null
|
|
||||||
),
|
|
||||||
profile = "LC",
|
|
||||||
sample_fmt = "fltp",
|
|
||||||
sample_rate = "48000",
|
|
||||||
channels = channels,
|
|
||||||
channel_layout = "stereo",
|
|
||||||
bits_per_sample = 0
|
|
||||||
)
|
)
|
||||||
|
return Preference(FakeCoordinatorEnv(tmp))
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun dummyVideoStream(index: Int, durationTs: Long = 1000): VideoStream {
|
private val listener = MediaSelectEncodeTracksListener(testPreference())
|
||||||
return VideoStream(
|
|
||||||
index = index,
|
// ------------------------------------------------------------
|
||||||
codec_name = "h264",
|
// TESTS
|
||||||
codec_long_name = "H.264",
|
// ------------------------------------------------------------
|
||||||
codec_type = "video",
|
|
||||||
codec_tag_string = "",
|
|
||||||
codec_tag = "",
|
|
||||||
r_frame_rate = "25/1",
|
|
||||||
avg_frame_rate = "25/1",
|
|
||||||
time_base = "1/1000",
|
|
||||||
start_pts = 0,
|
|
||||||
start_time = "0",
|
|
||||||
duration = null,
|
|
||||||
duration_ts = durationTs,
|
|
||||||
disposition = Disposition(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0),
|
|
||||||
tags = Tags(
|
|
||||||
title = null, 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 = "eng", filename = null, mimetype = null
|
|
||||||
),
|
|
||||||
profile = "main",
|
|
||||||
width = 1920,
|
|
||||||
height = 1080,
|
|
||||||
coded_width = 1920,
|
|
||||||
coded_height = 1080,
|
|
||||||
closed_captions = 0,
|
|
||||||
has_b_frames = 0,
|
|
||||||
sample_aspect_ratio = "1:1",
|
|
||||||
display_aspect_ratio = "16:9",
|
|
||||||
pix_fmt = "yuv420p",
|
|
||||||
level = 30,
|
|
||||||
color_range = "tv",
|
|
||||||
color_space = "bt709",
|
|
||||||
color_transfer = "bt709",
|
|
||||||
color_primaries = "bt709",
|
|
||||||
chroma_location = "left",
|
|
||||||
refs = 1
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("""
|
@DisplayName("""
|
||||||
Hvis video streams har ulik varighet
|
Når video streams er tilgjengelige
|
||||||
Når getVideoTrackToUse kalles
|
Hvis en stream har lengre varighet enn de andre
|
||||||
Så:
|
Så:
|
||||||
Returneres index til stream med lengst varighet
|
Velges video-sporet med lengst varighet
|
||||||
""")
|
""")
|
||||||
fun testVideoTrackSelection() {
|
fun testVideoTrackSelection() {
|
||||||
val streams = listOf(dummyVideoStream(0, 1000), dummyVideoStream(1, 5000))
|
val streams = listOf(
|
||||||
val index = getVideoTrackToUse(streams)
|
MockData.dummyVideoStream(0, 1000),
|
||||||
assertEquals(1, index)
|
MockData.dummyVideoStream(1, 5000)
|
||||||
|
)
|
||||||
|
|
||||||
|
val event = MediaStreamParsedEvent(
|
||||||
|
ParsedMediaStreams(videoStream = streams, audioStream = emptyList(), subtitleStream = emptyList())
|
||||||
|
).newReferenceId()
|
||||||
|
|
||||||
|
val result = listener.onEvent(event, emptyList()) as MediaTracksEncodeSelectedEvent
|
||||||
|
assertEquals(1, result.selectedVideoTrack)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test@DisplayName("""
|
||||||
@DisplayName("""
|
Når audio streams inneholder flere språk og kanaloppsett
|
||||||
Hvis audio streams inneholder foretrukket språk jpn med 2 kanaler
|
Hvis foretrukket språk finnes i stereo
|
||||||
Når getAudioDefaultTrackToUse kalles
|
|
||||||
Så:
|
Så:
|
||||||
Returneres index til jpn stereo track
|
Velges jpn stereo som default audio
|
||||||
""")
|
""")
|
||||||
fun testAudioDefaultTrackSelectionPreferredLanguageStereo() {
|
fun testAudioDefaultTrackSelectionPreferredLanguageStereo() {
|
||||||
val streams = listOf(
|
val audio = listOf(
|
||||||
dummyAudioStream(0, "eng", 2),
|
MockData.dummyAudioStream(0, "eng", 2),
|
||||||
dummyAudioStream(1, "jpn", 2),
|
MockData.dummyAudioStream(1, "jpn", 2),
|
||||||
dummyAudioStream(2, "jpn", 6)
|
MockData.dummyAudioStream(2, "jpn", 6)
|
||||||
)
|
)
|
||||||
val index = getAudioDefaultTrackToUse(streams)
|
|
||||||
assertEquals(1, index)
|
val event = MediaStreamParsedEvent(
|
||||||
|
ParsedMediaStreams(
|
||||||
|
videoStream = listOf(MockData.dummyVideoStream(0)),
|
||||||
|
audioStream = audio,
|
||||||
|
subtitleStream = emptyList()
|
||||||
|
)
|
||||||
|
).newReferenceId()
|
||||||
|
|
||||||
|
val result = listener.onEvent(event, emptyList()) as MediaTracksEncodeSelectedEvent
|
||||||
|
assertEquals(1, result.selectedAudioTrack)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("""
|
@DisplayName("""
|
||||||
Hvis audio streams inneholder foretrukket språk jpn med 6 kanaler
|
Når audio streams inneholder både stereo og surround
|
||||||
Når getAudioExtendedTrackToUse kalles
|
Hvis foretrukket språk finnes i begge
|
||||||
Så:
|
Så:
|
||||||
Returneres index til jpn 6-kanals track
|
Velges jpn 6-kanals som extended audio
|
||||||
""")
|
""")
|
||||||
fun testAudioExtendedTrackSelectionPreferredLanguageSurround() {
|
fun testAudioExtendedTrackSelectionPreferredLanguageSurround() {
|
||||||
val streams = listOf(
|
val audio = listOf(
|
||||||
dummyAudioStream(0, "jpn", 2),
|
MockData.dummyAudioStream(0, "jpn", 2),
|
||||||
dummyAudioStream(1, "jpn", 6)
|
MockData.dummyAudioStream(1, "jpn", 6)
|
||||||
)
|
)
|
||||||
val defaultIndex = getAudioDefaultTrackToUse(streams)
|
|
||||||
val extendedIndex = getAudioExtendedTrackToUse(streams, defaultIndex)
|
val event = MediaStreamParsedEvent(
|
||||||
assertEquals(0, defaultIndex)
|
ParsedMediaStreams(
|
||||||
assertEquals(1, extendedIndex)
|
videoStream = listOf(MockData.dummyVideoStream(0)),
|
||||||
|
audioStream = audio,
|
||||||
|
subtitleStream = emptyList()
|
||||||
|
)
|
||||||
|
).newReferenceId()
|
||||||
|
|
||||||
|
val result = listener.onEvent(event, emptyList()) as MediaTracksEncodeSelectedEvent
|
||||||
|
assertEquals(0, result.selectedAudioTrack)
|
||||||
|
assertEquals(1, result.selectedAudioExtendedTrack)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class DummyEvent : Event()
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("""
|
@DisplayName("""
|
||||||
Hvis audio streams ikke matcher foretrukket språk
|
Når event ikke er av typen MediaStreamParsedEvent
|
||||||
Når filterOnPreferredLanguage kalles
|
Hvis onEvent kalles
|
||||||
Så:
|
|
||||||
Returneres original liste uten filtrering
|
|
||||||
""")
|
|
||||||
fun testFilterOnPreferredLanguageFallback() {
|
|
||||||
val streams = listOf(
|
|
||||||
dummyAudioStream(0, "eng", 2),
|
|
||||||
dummyAudioStream(1, "fra", 2)
|
|
||||||
)
|
|
||||||
val filtered = streams.filterOnPreferredLanguage()
|
|
||||||
assertEquals(streams.size, filtered.size)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("""
|
|
||||||
Hvis audio streams ikke matcher foretrukket språk
|
|
||||||
Når getAudioDefaultTrackToUse kalles
|
|
||||||
Så:
|
|
||||||
Velges et spor (fallback) selv om ingen matcher
|
|
||||||
""")
|
|
||||||
fun testAudioDefaultTrackFallbackSelection() {
|
|
||||||
val streams = listOf(
|
|
||||||
dummyAudioStream(0, "eng", 2),
|
|
||||||
dummyAudioStream(1, "fra", 2)
|
|
||||||
)
|
|
||||||
|
|
||||||
// filterOnPreferredLanguage skal returnere original listen
|
|
||||||
val filtered = streams.filterOnPreferredLanguage()
|
|
||||||
assertEquals(streams.size, filtered.size)
|
|
||||||
|
|
||||||
// getAudioDefaultTrackToUse skal likevel velge et spor
|
|
||||||
val selectedIndex = getAudioDefaultTrackToUse(streams)
|
|
||||||
|
|
||||||
// Sjekk at det faktisk er en gyldig index
|
|
||||||
assertTrue(selectedIndex in streams.indices)
|
|
||||||
|
|
||||||
// I dette tilfellet velges siste med høyest index (1)
|
|
||||||
assertEquals(0, selectedIndex)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class DummyEvent: Event()
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("""
|
|
||||||
Hvis event ikke er MediaStreamParsedEvent
|
|
||||||
Når onEvent kalles
|
|
||||||
Så:
|
Så:
|
||||||
Returneres null
|
Returneres null
|
||||||
""")
|
""")
|
||||||
fun testOnEventNonParsedEvent() {
|
fun testOnEventNonParsedEvent() {
|
||||||
val result = onEvent(DummyEvent(), emptyList())
|
assertNull(listener.onEvent(DummyEvent(), emptyList()))
|
||||||
assertNull(result)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("""
|
@DisplayName("""
|
||||||
Hvis event er MediaStreamParsedEvent med video og audio
|
Når MediaStreamParsedEvent mottas med video og audio streams
|
||||||
Når onEvent kalles
|
Hvis sporene analyseres etter preferanser
|
||||||
Så:
|
Så:
|
||||||
Returneres MediaTracksEncodeSelectedEvent med riktige spor
|
Velges riktige video-, default audio- og extended audio-spor
|
||||||
""")
|
""")
|
||||||
fun testOnEventParsedEvent() {
|
fun testOnEventParsedEvent() {
|
||||||
val videoStreams = listOf(dummyVideoStream(0, 1000))
|
val video = listOf(MockData.dummyVideoStream(0, 1000))
|
||||||
val audioStreams = listOf(dummyAudioStream(0, "jpn", 2), dummyAudioStream(1, "jpn", 6))
|
val audio = listOf(
|
||||||
val parsedEvent = MediaStreamParsedEvent(
|
MockData.dummyAudioStream(0, "jpn", 2),
|
||||||
ParsedMediaStreams(videoStream = videoStreams, audioStream = audioStreams, subtitleStream = emptyList())
|
MockData.dummyAudioStream(1, "jpn", 6)
|
||||||
|
)
|
||||||
|
|
||||||
|
val parsed = MediaStreamParsedEvent(
|
||||||
|
ParsedMediaStreams(videoStream = video, audioStream = audio, subtitleStream = emptyList())
|
||||||
).newReferenceId()
|
).newReferenceId()
|
||||||
val result = onEvent(parsedEvent, emptyList()) as MediaTracksEncodeSelectedEvent
|
|
||||||
|
val result = listener.onEvent(parsed, emptyList()) as MediaTracksEncodeSelectedEvent
|
||||||
|
|
||||||
assertEquals(0, result.selectedVideoTrack)
|
assertEquals(0, result.selectedVideoTrack)
|
||||||
assertEquals(0, result.selectedAudioTrack)
|
assertEquals(0, result.selectedAudioTrack)
|
||||||
assertEquals(1, result.selectedAudioExtendedTrack)
|
assertEquals(1, result.selectedAudioExtendedTrack)
|
||||||
|
|||||||
@ -1,9 +1,10 @@
|
|||||||
package no.iktdev.mediaprocessing.coordinator.listeners.events
|
package no.iktdev.mediaprocessing.coordinator.listeners.events
|
||||||
|
|
||||||
import no.iktdev.mediaprocessing.TestBase
|
import no.iktdev.eventi.models.Event
|
||||||
import no.iktdev.mediaprocessing.ffmpeg.data.SubtitleStream
|
import no.iktdev.mediaprocessing.FakeCoordinatorEnv
|
||||||
import no.iktdev.mediaprocessing.ffmpeg.data.SubtitleTags
|
import no.iktdev.mediaprocessing.MockData
|
||||||
import no.iktdev.mediaprocessing.ffmpeg.data.Tags
|
import no.iktdev.mediaprocessing.coordinator.Preference
|
||||||
|
import no.iktdev.mediaprocessing.coordinator.SubtitleSelectionMode
|
||||||
import no.iktdev.mediaprocessing.shared.common.event_task_contract.events.MediaTracksDetermineSubtitleTypeEvent
|
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.event_task_contract.events.MediaTracksExtractSelectedEvent
|
||||||
import no.iktdev.mediaprocessing.shared.common.model.SubtitleItem
|
import no.iktdev.mediaprocessing.shared.common.model.SubtitleItem
|
||||||
@ -12,111 +13,145 @@ import org.junit.jupiter.api.Assertions.assertEquals
|
|||||||
import org.junit.jupiter.api.Assertions.assertNull
|
import org.junit.jupiter.api.Assertions.assertNull
|
||||||
import org.junit.jupiter.api.DisplayName
|
import org.junit.jupiter.api.DisplayName
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
class MediaSelectExtractTracksListenerTest: TestBase() {
|
class MediaSelectExtractTracksListenerTest {
|
||||||
|
|
||||||
|
// ------------------------------------------------------------
|
||||||
// Vi lager en subclass som gir oss tilgang til alt og lar oss overstyre språkpreferanser
|
// Helper: create a Preference with custom subtitle settings
|
||||||
class TestableMediaSelectExtractTracksListener(
|
// ------------------------------------------------------------
|
||||||
private val preferredLanguages: Set<String> = emptySet()
|
private fun testPreference(
|
||||||
) : MediaSelectExtractTracksListener() {
|
preferredSubtitles: List<String> = listOf("eng"),
|
||||||
override fun limitToLanguages(): Set<String> = preferredLanguages
|
formatPriority: List<String> = listOf("ass", "srt", "vtt", "smi"),
|
||||||
// gjør private extension tilgjengelig via wrapper
|
mode: SubtitleSelectionMode = SubtitleSelectionMode.DialogueOnly
|
||||||
fun callFilterOnPreferredLanguage(streams: List<SubtitleStream>): List<SubtitleStream> {
|
): Preference {
|
||||||
return streams.filterOnPreferredLanguage()
|
val tmp = File.createTempFile("pref", ".json")
|
||||||
|
tmp.writeText(
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"processer": {},
|
||||||
|
"language": {
|
||||||
|
"preferredAudio": ["eng"],
|
||||||
|
"preferredSubtitles": ${preferredSubtitles},
|
||||||
|
"preferOriginal": true,
|
||||||
|
"avoidDub": true,
|
||||||
|
"subtitleFormatPriority": ${formatPriority},
|
||||||
|
"subtitleSelectionMode": "$mode"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
""".trimIndent()
|
||||||
private fun dummySubtitleStream(index: Int, language: String?, type: SubtitleType): SubtitleItem {
|
|
||||||
val stream = SubtitleStream(
|
|
||||||
index = index,
|
|
||||||
codec_name = "ass",
|
|
||||||
codec_long_name = "ASS",
|
|
||||||
codec_type = "subtitle",
|
|
||||||
codec_tag_string = "",
|
|
||||||
codec_tag = "",
|
|
||||||
r_frame_rate = "0/0",
|
|
||||||
avg_frame_rate = "0/0",
|
|
||||||
time_base = "1/1000",
|
|
||||||
start_pts = 0,
|
|
||||||
start_time = "0",
|
|
||||||
duration = null,
|
|
||||||
duration_ts = 1000,
|
|
||||||
disposition = null,
|
|
||||||
tags = Tags(
|
|
||||||
title = null, 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 = null, mimetype = null
|
|
||||||
),
|
|
||||||
subtitle_tags = SubtitleTags(language = language, filename = null, mimetype = null)
|
|
||||||
)
|
)
|
||||||
return SubtitleItem(stream = stream, type = type)
|
return Preference(FakeCoordinatorEnv(tmp))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun listener(
|
||||||
|
preferredSubtitles: List<String> = listOf("eng"),
|
||||||
|
formatPriority: List<String> = listOf("ass", "srt", "vtt", "smi"),
|
||||||
|
mode: SubtitleSelectionMode = SubtitleSelectionMode.DialogueOnly
|
||||||
|
) = MediaSelectExtractTracksListener(
|
||||||
|
testPreference(preferredSubtitles, formatPriority, mode)
|
||||||
|
)
|
||||||
|
|
||||||
|
class DummyEvent : Event()
|
||||||
|
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
// TESTS
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("""
|
@DisplayName("""
|
||||||
Hvis event ikke er MediaTracksDetermineSubtitleTypeEvent
|
Når event ikke er av typen MediaTracksDetermineSubtitleTypeEvent
|
||||||
Når onEvent kalles
|
Hvis onEvent kalles
|
||||||
Så:
|
Så:
|
||||||
Returneres null
|
Returneres null
|
||||||
""")
|
""")
|
||||||
fun testOnEventNonSubtitleEvent() {
|
fun testOnEventNonSubtitleEvent() {
|
||||||
val listener = TestableMediaSelectExtractTracksListener()
|
val result = listener().onEvent(DummyEvent(), emptyList())
|
||||||
val result = listener.onEvent(DummyEvent(), emptyList())
|
|
||||||
assertNull(result)
|
assertNull(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("""
|
@DisplayName("""
|
||||||
Hvis event inneholder Dialogue subtitles
|
Når subtitles inneholder Dialogue og Commentary
|
||||||
Når onEvent kalles
|
Hvis modus er DialogueOnly
|
||||||
Så:
|
Så:
|
||||||
Returneres MediaTracksExtractSelectedEvent med index til Dialogue tracks
|
Velges kun Dialogue subtitles
|
||||||
""")
|
""")
|
||||||
fun testOnEventDialogueTracksSelected() {
|
fun testDialogueSelection() {
|
||||||
val listener = TestableMediaSelectExtractTracksListener()
|
|
||||||
val items = listOf(
|
val items = listOf(
|
||||||
dummySubtitleStream(0, "eng", SubtitleType.Dialogue),
|
SubtitleItem(MockData.dummySubtitleStream(0, "eng"), SubtitleType.Dialogue),
|
||||||
dummySubtitleStream(1, "eng", SubtitleType.Commentary)
|
SubtitleItem(MockData.dummySubtitleStream(1, "eng"), SubtitleType.Commentary)
|
||||||
)
|
)
|
||||||
val event = MediaTracksDetermineSubtitleTypeEvent(subtitleTrackItems = items).newReferenceId()
|
|
||||||
val result = listener.onEvent(event, emptyList()) as MediaTracksExtractSelectedEvent
|
val event = MediaTracksDetermineSubtitleTypeEvent(items).newReferenceId()
|
||||||
|
val result = listener().onEvent(event, emptyList()) as MediaTracksExtractSelectedEvent
|
||||||
|
|
||||||
assertEquals(listOf(0), result.selectedSubtitleTracks)
|
assertEquals(listOf(0), result.selectedSubtitleTracks)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("""
|
|
||||||
Hvis limitToLanguages returnerer jpn
|
|
||||||
Når filterOnPreferredLanguage kalles
|
|
||||||
Så:
|
|
||||||
Returneres kun spor med språk jpn
|
|
||||||
""")
|
|
||||||
fun testFilterOnPreferredLanguageWithLimit() {
|
|
||||||
val listener = TestableMediaSelectExtractTracksListener(setOf("jpn"))
|
|
||||||
val streams = listOf(
|
|
||||||
dummySubtitleStream(0, "eng", SubtitleType.Dialogue).stream,
|
|
||||||
dummySubtitleStream(1, "jpn", SubtitleType.Dialogue).stream
|
|
||||||
)
|
|
||||||
val filtered = listener.callFilterOnPreferredLanguage(streams)
|
|
||||||
assertEquals(1, filtered.size)
|
|
||||||
assertEquals("jpn", filtered[0].tags.language)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("""
|
@DisplayName("""
|
||||||
Hvis limitToLanguages er tom
|
Når subtitles finnes i flere språk
|
||||||
Når filterOnPreferredLanguage kalles
|
Hvis foretrukket språk er jpn
|
||||||
Så:
|
Så:
|
||||||
Returneres original liste uten filtrering
|
Velges kun subtitles i jpn
|
||||||
""")
|
""")
|
||||||
fun testFilterOnPreferredLanguageNoLimit() {
|
fun testPreferredLanguageSelection() {
|
||||||
val listener = TestableMediaSelectExtractTracksListener()
|
val items = listOf(
|
||||||
val streams = listOf(
|
SubtitleItem(MockData.dummySubtitleStream(0, "eng"), SubtitleType.Dialogue),
|
||||||
dummySubtitleStream(0, "eng", SubtitleType.Dialogue).stream,
|
SubtitleItem(MockData.dummySubtitleStream(1, "jpn"), SubtitleType.Dialogue)
|
||||||
dummySubtitleStream(1, "fra", SubtitleType.Dialogue).stream
|
|
||||||
)
|
)
|
||||||
val filtered = listener.callFilterOnPreferredLanguage(streams)
|
|
||||||
assertEquals(streams.size, filtered.size)
|
val event = MediaTracksDetermineSubtitleTypeEvent(items).newReferenceId()
|
||||||
|
val result = listener(preferredSubtitles = listOf("jpn"))
|
||||||
|
.onEvent(event, emptyList()) as MediaTracksExtractSelectedEvent
|
||||||
|
|
||||||
|
assertEquals(listOf(1), result.selectedSubtitleTracks)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("""
|
||||||
|
Når flere subtitles i samme språk finnes
|
||||||
|
Hvis format-prioritet er ass > srt
|
||||||
|
Så:
|
||||||
|
Velges subtitle med codec ass
|
||||||
|
""")
|
||||||
|
fun testFormatPrioritySelection() {
|
||||||
|
val items = listOf(
|
||||||
|
SubtitleItem(MockData.dummySubtitleStream(0, "eng").copy(codec_name = "srt"), SubtitleType.Dialogue),
|
||||||
|
SubtitleItem(MockData.dummySubtitleStream(1, "eng").copy(codec_name = "ass"), SubtitleType.Dialogue)
|
||||||
|
)
|
||||||
|
|
||||||
|
val event = MediaTracksDetermineSubtitleTypeEvent(items).newReferenceId()
|
||||||
|
val result = listener(
|
||||||
|
preferredSubtitles = listOf("eng"),
|
||||||
|
formatPriority = listOf("ass", "srt")
|
||||||
|
).onEvent(event, emptyList()) as MediaTracksExtractSelectedEvent
|
||||||
|
|
||||||
|
assertEquals(listOf(1), result.selectedSubtitleTracks)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("""
|
||||||
|
Når flere subtitles i samme språk finnes
|
||||||
|
Hvis kun én subtitle per språk skal velges
|
||||||
|
Så:
|
||||||
|
Returneres kun ett spor
|
||||||
|
""")
|
||||||
|
fun testUniquePerLanguage() {
|
||||||
|
val items = listOf(
|
||||||
|
SubtitleItem(MockData.dummySubtitleStream(0, "eng").copy(codec_name = "srt"), SubtitleType.Dialogue),
|
||||||
|
SubtitleItem(MockData.dummySubtitleStream(1, "eng").copy(codec_name = "vtt"), SubtitleType.Dialogue)
|
||||||
|
)
|
||||||
|
|
||||||
|
val event = MediaTracksDetermineSubtitleTypeEvent(items).newReferenceId()
|
||||||
|
val result = listener().onEvent(event, emptyList()) as MediaTracksExtractSelectedEvent
|
||||||
|
|
||||||
|
assertEquals(1, result.selectedSubtitleTracks.size)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
Loading…
Reference in New Issue
Block a user