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 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()
} }
val language = try {
gson.fromJson(root.get("language"), LanguagePreference::class.java)
?: LanguagePreference.default()
} catch (_: Exception) {
LanguagePreference.default()
}
private fun File.ifExists(block: File.() -> Unit, orElse: () -> Unit = {}) { return PeferenceConfig(processer, language)
if (this.exists()) { }
block()
} else { fun getProcesserPreference(): ProcesserPreference =
orElse() 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)
}
} }
} }

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.* 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>

View File

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

View File

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

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

View File

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

View File

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

View File

@ -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
: :
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
: :
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
: :
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)
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)
) )
// filterOnPreferredLanguage skal returnere original listen val event = MediaStreamParsedEvent(
val filtered = streams.filterOnPreferredLanguage() ParsedMediaStreams(
assertEquals(streams.size, filtered.size) videoStream = listOf(MockData.dummyVideoStream(0)),
audioStream = audio,
subtitleStream = emptyList()
)
).newReferenceId()
// getAudioDefaultTrackToUse skal likevel velge et spor val result = listener.onEvent(event, emptyList()) as MediaTracksEncodeSelectedEvent
val selectedIndex = getAudioDefaultTrackToUse(streams) assertEquals(0, result.selectedAudioTrack)
assertEquals(1, result.selectedAudioExtendedTrack)
// 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() class DummyEvent : Event()
@Test @Test
@DisplayName(""" @DisplayName("""
Hvis event ikke er MediaStreamParsedEvent Når event ikke er av typen MediaStreamParsedEvent
Når onEvent kalles Hvis onEvent kalles
: :
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
: :
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)

View File

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