Track selection

This commit is contained in:
Brage Skjønborg 2026-02-02 21:43:11 +01:00
parent 5babd17d18
commit 866f7f228c
11 changed files with 783 additions and 366 deletions

View File

@ -1,16 +1,55 @@
package no.iktdev.mediaprocessing.coordinator
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.VideoCodec
import org.springframework.stereotype.Component
import java.io.File
import java.io.IOException
// ------------------------------------------------------------
// MODELLER
// ------------------------------------------------------------
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(
val videoPreference: VideoPreference? = null,
val audioPreference: AudioPreference? = null
@ -19,7 +58,7 @@ data class ProcesserPreference(
fun default(): ProcesserPreference {
return ProcesserPreference(
videoPreference = VideoPreference(VideoCodec.Hevc(), false),
audioPreference = AudioPreference("jpn", AudioCodec.Aac())
audioPreference = AudioPreference(AudioCodec.Aac())
)
}
}
@ -31,57 +70,121 @@ data class VideoPreference(
)
data class AudioPreference(
val language: String? = null,
val codec: AudioCodec
)
// ------------------------------------------------------------
// PREFERENCE COMPONENT
// ------------------------------------------------------------
@Component
class Preference(private val coordinatorEnv: CoordinatorEnv) {
fun getProcesserPreference(): ProcesserPreference {
val default = ProcesserPreference.default()
private val gson = Gson()
private val lock = Any()
/**
* Leser hele configen, men bevarer ukjente felter.
*/
fun getFullConfig(): PeferenceConfig {
val file = coordinatorEnv.preference
if (!file.exists()) {
// Opprett fil med default
file.writeText(Gson().toJson(PeferenceConfig(default)))
val default = PeferenceConfig(
processer = ProcesserPreference.default(),
language = LanguagePreference.default()
)
writeJsonObject(JsonObject().apply {
add("processer", gson.toJsonTree(default.processer))
add("language", gson.toJsonTree(default.language))
}, file)
return default
}
val text = try {
file.readText()
val root = try {
JsonParser.parseString(file.readText()).asJsonObject
} 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
val processer = try {
gson.fromJson(root.get("processer"), ProcesserPreference::class.java)
?: ProcesserPreference.default()
} catch (_: Exception) {
ProcesserPreference.default()
}
// Hvis hele configen er null → default
val cfg = parsed ?: return default
val language = try {
gson.fromJson(root.get("language"), LanguagePreference::class.java)
?: LanguagePreference.default()
} catch (_: Exception) {
LanguagePreference.default()
}
// Hvis processer er null → default
val p = cfg.processer ?: return default
return PeferenceConfig(processer, language)
}
// Hvis underfelter er null → fyll inn default
val safeVideo = p.videoPreference ?: default.videoPreference
val safeAudio = p.audioPreference ?: default.audioPreference
fun getProcesserPreference(): ProcesserPreference =
getFullConfig().processer
return ProcesserPreference(
videoPreference = safeVideo,
audioPreference = safeAudio
)
}
}
private fun File.ifExists(block: File.() -> Unit, orElse: () -> Unit = {}) {
if (this.exists()) {
block()
} else {
orElse()
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)
}
}
}

View File

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

View File

@ -13,7 +13,7 @@ import java.io.File
import java.util.*
@Component
class MediaCreateExtractTaskListener: EventListener() {
class MediaCreateExtractTaskListener(): EventListener() {
override fun onEvent(
event: Event,
history: List<Event>

View File

@ -2,6 +2,7 @@ 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.data.AudioStream
import no.iktdev.mediaprocessing.ffmpeg.data.VideoStream
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
@Component
class MediaSelectEncodeTracksListener: EventListener() {
fun getAudioLanguagePreference(): List<String> {
return listOf("jpn")
}
class MediaSelectEncodeTracksListener(
private val preference: Preference
) : EventListener() {
override fun onEvent(
event: Event,
@ -21,10 +20,12 @@ class MediaSelectEncodeTracksListener: EventListener() {
): 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)
val audioExtendedTrack = getAudioExtendedTrackToUse(
useEvent.data.audioStream,
selectedDefaultTrack = audioDefaultTrack
)
return MediaTracksEncodeSelectedEvent(
selectedVideoTrack = videoTrackIndex,
@ -33,42 +34,125 @@ class MediaSelectEncodeTracksListener: EventListener() {
).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.channels > 2 }
.filter { it.index != selectedDefaultTrack }
val selected = durationFiltered.firstOrNull() ?: return null
return audioStream.indexOf(selected)
val selected = selectBestAudioStream(
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.
* If audio track with preferred language (e.g., "nor") is not found, selects "eng" or first available.
*/
protected fun getAudioDefaultTrackToUse(audioStream: List<AudioStream>): Int {
val durationFiltered = audioStream.filterOnPreferredLanguage()
// ------------------------------------------------------------
// CORE AUDIO SELECTION LOGIC
// ------------------------------------------------------------
private enum class AudioSelectMode { DEFAULT, EXTENDED }
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 }
.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)
}
}
}

View File

@ -2,22 +2,19 @@ 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.coordinator.SubtitleSelectionMode
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.SubtitleItem
import no.iktdev.mediaprocessing.shared.common.model.SubtitleType
import org.springframework.stereotype.Component
@Component
class MediaSelectExtractTracksListener: EventListener() {
open fun limitToLanguages(): Set<String> {
return emptySet()
}
fun useTypes(): Set<SubtitleType> {
return setOf(SubtitleType.Dialogue)
}
class MediaSelectExtractTracksListener(
private val preference: Preference
) : EventListener() {
override fun onEvent(
event: Event,
@ -25,20 +22,109 @@ class MediaSelectExtractTracksListener: EventListener() {
): Event? {
val useEvent = event as? MediaTracksDetermineSubtitleTypeEvent ?: return null
val filtered = useEvent.subtitleTrackItems
.filter { it.type in useTypes() }
.map { it.stream }
.filterOnPreferredLanguage()
val pref = preference.getLanguagePreference()
// 1. Filter by subtitle type (dialogue, forced, etc.)
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(
selectedSubtitleTracks = filtered.map { it.index }
selectedSubtitleTracks = selected.map { it.index }
).derivedOf(event)
}
// ------------------------------------------------------------
// TYPE FILTERING
// ------------------------------------------------------------
protected 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) }
private fun filterBySelectionMode(
items: List<SubtitleItem>,
mode: SubtitleSelectionMode
): 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()
}
}
}

View File

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

View File

@ -3,8 +3,11 @@ package no.iktdev.mediaprocessing
import no.iktdev.eventi.models.Event
import no.iktdev.eventi.models.store.TaskStatus
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.model.MediaType
import no.iktdev.mediaprocessing.shared.common.model.SubtitleItem
import no.iktdev.mediaprocessing.shared.common.model.SubtitleType
import java.util.*
object MockData {
@ -119,4 +122,112 @@ object MockData {
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)
}
}

View File

@ -4,7 +4,8 @@ import no.iktdev.eventi.models.Event
import org.json.JSONArray
enum class Files(val fileName: String) {
MultipleLanguageBased("Events.json")
MultipleLanguageBased("Events.json"),
MediaStreamParsedEvent("MediaStreamParsedEvent.json")
}
fun Files.getContent(): String? {

View File

@ -59,4 +59,6 @@ open class TestBase {
return start
}
}

View File

@ -1,212 +1,151 @@
package no.iktdev.mediaprocessing.coordinator.listeners.events
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.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.Test
import java.io.File
class MediaTracksEncodeSelectorTest: MediaSelectEncodeTracksListener() {
class MediaTracksEncodeSelectorTest {
private 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 = 0
private fun testPreference(): Preference {
val tmp = File.createTempFile("pref", ".json")
tmp.writeText(
"""
{
"processer": {},
"language": {
"preferredAudio": ["jpn", "eng"],
"preferredSubtitles": ["eng"],
"preferOriginal": true,
"avoidDub": true,
"subtitleFormatPriority": ["ass","srt","vtt","smi"],
"subtitleSelectionMode": "DialogueOnly"
}
}
""".trimIndent()
)
return Preference(FakeCoordinatorEnv(tmp))
}
private 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
)
}
private val listener = MediaSelectEncodeTracksListener(testPreference())
// ------------------------------------------------------------
// TESTS
// ------------------------------------------------------------
@Test
@DisplayName("""
Hvis video streams har ulik varighet
Når getVideoTrackToUse kalles
Når video streams er tilgjengelige
Hvis en stream har lengre varighet enn de andre
:
Returneres index til stream med lengst varighet
""")
Velges video-sporet med lengst varighet
""")
fun testVideoTrackSelection() {
val streams = listOf(dummyVideoStream(0, 1000), dummyVideoStream(1, 5000))
val index = getVideoTrackToUse(streams)
assertEquals(1, index)
}
@Test
@DisplayName("""
Hvis audio streams inneholder foretrukket språk jpn med 2 kanaler
Når getAudioDefaultTrackToUse kalles
:
Returneres index til jpn stereo track
""")
fun testAudioDefaultTrackSelectionPreferredLanguageStereo() {
val streams = listOf(
dummyAudioStream(0, "eng", 2),
dummyAudioStream(1, "jpn", 2),
dummyAudioStream(2, "jpn", 6)
)
val index = getAudioDefaultTrackToUse(streams)
assertEquals(1, index)
}
@Test
@DisplayName("""
Hvis audio streams inneholder foretrukket språk jpn med 6 kanaler
Når getAudioExtendedTrackToUse kalles
:
Returneres index til jpn 6-kanals track
""")
fun testAudioExtendedTrackSelectionPreferredLanguageSurround() {
val streams = listOf(
dummyAudioStream(0, "jpn", 2),
dummyAudioStream(1, "jpn", 6)
)
val defaultIndex = getAudioDefaultTrackToUse(streams)
val extendedIndex = getAudioExtendedTrackToUse(streams, defaultIndex)
assertEquals(0, defaultIndex)
assertEquals(1, extendedIndex)
}
@Test
@DisplayName("""
Hvis audio streams ikke matcher foretrukket språk
Når filterOnPreferredLanguage kalles
:
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
:
Velges et spor (fallback) selv om ingen matcher
""")
fun testAudioDefaultTrackFallbackSelection() {
val streams = listOf(
dummyAudioStream(0, "eng", 2),
dummyAudioStream(1, "fra", 2)
MockData.dummyVideoStream(0, 1000),
MockData.dummyVideoStream(1, 5000)
)
// 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
:
Returneres null
""")
fun testOnEventNonParsedEvent() {
val result = onEvent(DummyEvent(), emptyList())
assertNull(result)
}
@Test
@DisplayName("""
Hvis event er MediaStreamParsedEvent med video og audio
Når onEvent kalles
:
Returneres MediaTracksEncodeSelectedEvent med riktige spor
""")
fun testOnEventParsedEvent() {
val videoStreams = listOf(dummyVideoStream(0, 1000))
val audioStreams = listOf(dummyAudioStream(0, "jpn", 2), dummyAudioStream(1, "jpn", 6))
val parsedEvent = MediaStreamParsedEvent(
ParsedMediaStreams(videoStream = videoStreams, audioStream = audioStreams, subtitleStream = emptyList())
val event = MediaStreamParsedEvent(
ParsedMediaStreams(videoStream = streams, audioStream = emptyList(), subtitleStream = emptyList())
).newReferenceId()
val result = onEvent(parsedEvent, emptyList()) as MediaTracksEncodeSelectedEvent
val result = listener.onEvent(event, emptyList()) as MediaTracksEncodeSelectedEvent
assertEquals(1, result.selectedVideoTrack)
}
@Test@DisplayName("""
Når audio streams inneholder flere språk og kanaloppsett
Hvis foretrukket språk finnes i stereo
:
Velges jpn stereo som default audio
""")
fun testAudioDefaultTrackSelectionPreferredLanguageStereo() {
val audio = listOf(
MockData.dummyAudioStream(0, "eng", 2),
MockData.dummyAudioStream(1, "jpn", 2),
MockData.dummyAudioStream(2, "jpn", 6)
)
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
@DisplayName("""
Når audio streams inneholder både stereo og surround
Hvis foretrukket språk finnes i begge
:
Velges jpn 6-kanals som extended audio
""")
fun testAudioExtendedTrackSelectionPreferredLanguageSurround() {
val audio = listOf(
MockData.dummyAudioStream(0, "jpn", 2),
MockData.dummyAudioStream(1, "jpn", 6)
)
val event = MediaStreamParsedEvent(
ParsedMediaStreams(
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
@DisplayName("""
Når event ikke er av typen MediaStreamParsedEvent
Hvis onEvent kalles
:
Returneres null
""")
fun testOnEventNonParsedEvent() {
assertNull(listener.onEvent(DummyEvent(), emptyList()))
}
@Test
@DisplayName("""
Når MediaStreamParsedEvent mottas med video og audio streams
Hvis sporene analyseres etter preferanser
:
Velges riktige video-, default audio- og extended audio-spor
""")
fun testOnEventParsedEvent() {
val video = listOf(MockData.dummyVideoStream(0, 1000))
val audio = listOf(
MockData.dummyAudioStream(0, "jpn", 2),
MockData.dummyAudioStream(1, "jpn", 6)
)
val parsed = MediaStreamParsedEvent(
ParsedMediaStreams(videoStream = video, audioStream = audio, subtitleStream = emptyList())
).newReferenceId()
val result = listener.onEvent(parsed, emptyList()) as MediaTracksEncodeSelectedEvent
assertEquals(0, result.selectedVideoTrack)
assertEquals(0, result.selectedAudioTrack)
assertEquals(1, result.selectedAudioExtendedTrack)

View File

@ -1,9 +1,10 @@
package no.iktdev.mediaprocessing.coordinator.listeners.events
import no.iktdev.mediaprocessing.TestBase
import no.iktdev.mediaprocessing.ffmpeg.data.SubtitleStream
import no.iktdev.mediaprocessing.ffmpeg.data.SubtitleTags
import no.iktdev.mediaprocessing.ffmpeg.data.Tags
import no.iktdev.eventi.models.Event
import no.iktdev.mediaprocessing.FakeCoordinatorEnv
import no.iktdev.mediaprocessing.MockData
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.MediaTracksExtractSelectedEvent
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.DisplayName
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
class TestableMediaSelectExtractTracksListener(
private val preferredLanguages: Set<String> = emptySet()
) : MediaSelectExtractTracksListener() {
override fun limitToLanguages(): Set<String> = preferredLanguages
// gjør private extension tilgjengelig via wrapper
fun callFilterOnPreferredLanguage(streams: List<SubtitleStream>): List<SubtitleStream> {
return streams.filterOnPreferredLanguage()
}
}
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)
// ------------------------------------------------------------
// Helper: create a Preference with custom subtitle settings
// ------------------------------------------------------------
private fun testPreference(
preferredSubtitles: List<String> = listOf("eng"),
formatPriority: List<String> = listOf("ass", "srt", "vtt", "smi"),
mode: SubtitleSelectionMode = SubtitleSelectionMode.DialogueOnly
): Preference {
val tmp = File.createTempFile("pref", ".json")
tmp.writeText(
"""
{
"processer": {},
"language": {
"preferredAudio": ["eng"],
"preferredSubtitles": ${preferredSubtitles},
"preferOriginal": true,
"avoidDub": true,
"subtitleFormatPriority": ${formatPriority},
"subtitleSelectionMode": "$mode"
}
}
""".trimIndent()
)
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
@DisplayName("""
Hvis event ikke er MediaTracksDetermineSubtitleTypeEvent
Når onEvent kalles
Når event ikke er av typen MediaTracksDetermineSubtitleTypeEvent
Hvis onEvent kalles
:
Returneres null
""")
Returneres null
""")
fun testOnEventNonSubtitleEvent() {
val listener = TestableMediaSelectExtractTracksListener()
val result = listener.onEvent(DummyEvent(), emptyList())
val result = listener().onEvent(DummyEvent(), emptyList())
assertNull(result)
}
@Test
@DisplayName("""
Hvis event inneholder Dialogue subtitles
Når onEvent kalles
Når subtitles inneholder Dialogue og Commentary
Hvis modus er DialogueOnly
:
Returneres MediaTracksExtractSelectedEvent med index til Dialogue tracks
""")
fun testOnEventDialogueTracksSelected() {
val listener = TestableMediaSelectExtractTracksListener()
Velges kun Dialogue subtitles
""")
fun testDialogueSelection() {
val items = listOf(
dummySubtitleStream(0, "eng", SubtitleType.Dialogue),
dummySubtitleStream(1, "eng", SubtitleType.Commentary)
SubtitleItem(MockData.dummySubtitleStream(0, "eng"), SubtitleType.Dialogue),
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)
}
@Test
@DisplayName("""
Hvis limitToLanguages returnerer jpn
Når filterOnPreferredLanguage kalles
:
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
@DisplayName("""
Hvis limitToLanguages er tom
Når filterOnPreferredLanguage kalles
Når subtitles finnes i flere språk
Hvis foretrukket språk er jpn
:
Returneres original liste uten filtrering
""")
fun testFilterOnPreferredLanguageNoLimit() {
val listener = TestableMediaSelectExtractTracksListener()
val streams = listOf(
dummySubtitleStream(0, "eng", SubtitleType.Dialogue).stream,
dummySubtitleStream(1, "fra", SubtitleType.Dialogue).stream
Velges kun subtitles i jpn
""")
fun testPreferredLanguageSelection() {
val items = listOf(
SubtitleItem(MockData.dummySubtitleStream(0, "eng"), SubtitleType.Dialogue),
SubtitleItem(MockData.dummySubtitleStream(1, "jpn"), SubtitleType.Dialogue)
)
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
:
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
:
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)
}
}