From dc646e14d12736378412a384ee4df29fa0d77ca5 Mon Sep 17 00:00:00 2001 From: Gustav Grusell Date: Fri, 16 May 2025 15:33:02 +0200 Subject: [PATCH] feat: support for specifiying custom filters for split,scale,crop,pad When doing hardware encoding, it can be desirable to use hardware filters for split, scaling, cropping and padding. This commits add support for specifying replacements for the standard filters in a profile. It is required that the filter specified supports the parameters used by encore. Signed-off-by: Gustav Grusell --- checks.gradle | 1 + .../oss/encore/model/profile/AudioEncode.kt | 6 +- .../encore/model/profile/OutputProducer.kt | 2 +- .../svt/oss/encore/model/profile/Profile.kt | 21 ++++++ .../encore/model/profile/SimpleAudioEncode.kt | 6 +- .../encore/model/profile/ThumbnailEncode.kt | 6 +- .../model/profile/ThumbnailMapEncode.kt | 6 +- .../oss/encore/model/profile/VideoEncode.kt | 33 +++++++-- .../svt/oss/encore/process/CommandBuilder.kt | 21 ++++-- .../svt/oss/encore/service/FfmpegExecutor.kt | 1 + .../encore/model/profile/AudioEncodeTest.kt | 13 ++-- .../model/profile/ThumbnailEncodeTest.kt | 8 +++ .../model/profile/ThumbnailMapEncodeTest.kt | 6 +- .../encore/model/profile/VideoEncodeTest.kt | 40 +++++++++-- .../oss/encore/process/CommandBuilderTest.kt | 68 +++++++++++++++++++ 15 files changed, 212 insertions(+), 26 deletions(-) diff --git a/checks.gradle b/checks.gradle index 7a689d62..b62fecc3 100644 --- a/checks.gradle +++ b/checks.gradle @@ -15,6 +15,7 @@ jacocoTestCoverageVerification { '*QueueService.migrateQueues()', '*.ShutdownHandler.*', '*FfmpegExecutor.runFfmpeg$lambda$?(java.lang.Process)', + '*FilterSettings.*', ] limit { counter = 'LINE' diff --git a/encore-common/src/main/kotlin/se/svt/oss/encore/model/profile/AudioEncode.kt b/encore-common/src/main/kotlin/se/svt/oss/encore/model/profile/AudioEncode.kt index bbb5bf3c..23254d34 100644 --- a/encore-common/src/main/kotlin/se/svt/oss/encore/model/profile/AudioEncode.kt +++ b/encore-common/src/main/kotlin/se/svt/oss/encore/model/profile/AudioEncode.kt @@ -32,7 +32,11 @@ data class AudioEncode( override val enabled: Boolean = true, ) : AudioEncoder() { - override fun getOutput(job: EncoreJob, encodingProperties: EncodingProperties): Output? { + override fun getOutput( + job: EncoreJob, + encodingProperties: EncodingProperties, + filterSettings: FilterSettings, + ): Output? { val outputName = "${job.baseName}$suffix.$format" if (!enabled) { return logOrThrow("$outputName is disabled. Skipping...") diff --git a/encore-common/src/main/kotlin/se/svt/oss/encore/model/profile/OutputProducer.kt b/encore-common/src/main/kotlin/se/svt/oss/encore/model/profile/OutputProducer.kt index 3c91f345..6fe3f166 100644 --- a/encore-common/src/main/kotlin/se/svt/oss/encore/model/profile/OutputProducer.kt +++ b/encore-common/src/main/kotlin/se/svt/oss/encore/model/profile/OutputProducer.kt @@ -21,5 +21,5 @@ import se.svt.oss.encore.model.output.Output JsonSubTypes.Type(value = ThumbnailMapEncode::class, name = "ThumbnailMapEncode"), ) interface OutputProducer { - fun getOutput(job: EncoreJob, encodingProperties: EncodingProperties): Output? + fun getOutput(job: EncoreJob, encodingProperties: EncodingProperties, filterSettings: FilterSettings): Output? } diff --git a/encore-common/src/main/kotlin/se/svt/oss/encore/model/profile/Profile.kt b/encore-common/src/main/kotlin/se/svt/oss/encore/model/profile/Profile.kt index 019fee80..90692fc8 100644 --- a/encore-common/src/main/kotlin/se/svt/oss/encore/model/profile/Profile.kt +++ b/encore-common/src/main/kotlin/se/svt/oss/encore/model/profile/Profile.kt @@ -10,5 +10,26 @@ data class Profile( val encodes: List, val scaling: String? = "bicubic", val deinterlaceFilter: String = "yadif", + val filterSettings: FilterSettings = FilterSettings(), val joinSegmentParams: LinkedHashMap = linkedMapOf(), ) + +data class FilterSettings( + /** + * The splitFilter property will be treated differently depending on if the values contains a '=' or not. + * If no '=' is included, the value is treated as the name of the filter to use and something like + * 'SPLITFILTERVALUE=N[ou1][out2]...' will be added to the filtergraph, where N is the number of + * relevant outputs in the profile. + * If an '=' is included, the value is assumed to already include the size parameters and something like + * 'SPLITFILTERVALUE[ou1][out2]...' will be added to the filtergraph. Care must be taken to ensure that the + * size parameters match the number of relevant outputs in the profile. + * This latter form of specifying the split filter can be useful for + * certain custom split filters that allow extra parameters, ie ni_quadra_split filter for netinit quadra + * cards which allows access to scaled output from the decoder. + */ + val splitFilter: String = "split", + val scaleFilter: String = "scale", + val scaleFilterParams: LinkedHashMap = linkedMapOf(), + val cropFilter: String = "crop", + val padFilter: String = "pad", +) diff --git a/encore-common/src/main/kotlin/se/svt/oss/encore/model/profile/SimpleAudioEncode.kt b/encore-common/src/main/kotlin/se/svt/oss/encore/model/profile/SimpleAudioEncode.kt index de41224e..52fc1a98 100644 --- a/encore-common/src/main/kotlin/se/svt/oss/encore/model/profile/SimpleAudioEncode.kt +++ b/encore-common/src/main/kotlin/se/svt/oss/encore/model/profile/SimpleAudioEncode.kt @@ -23,7 +23,11 @@ data class SimpleAudioEncode( val format: String = "mp4", val inputLabel: String = DEFAULT_AUDIO_LABEL, ) : AudioEncoder() { - override fun getOutput(job: EncoreJob, encodingProperties: EncodingProperties): Output? { + override fun getOutput( + job: EncoreJob, + encodingProperties: EncodingProperties, + filterSettings: FilterSettings, + ): Output? { val outputName = "${job.baseName}$suffix.$format" if (!enabled) { return logOrThrow("$outputName is disabled. Skipping...") diff --git a/encore-common/src/main/kotlin/se/svt/oss/encore/model/profile/ThumbnailEncode.kt b/encore-common/src/main/kotlin/se/svt/oss/encore/model/profile/ThumbnailEncode.kt index ddc64c74..9442c46e 100644 --- a/encore-common/src/main/kotlin/se/svt/oss/encore/model/profile/ThumbnailEncode.kt +++ b/encore-common/src/main/kotlin/se/svt/oss/encore/model/profile/ThumbnailEncode.kt @@ -30,7 +30,11 @@ data class ThumbnailEncode( val decodeOutput: Int? = null, ) : OutputProducer { - override fun getOutput(job: EncoreJob, encodingProperties: EncodingProperties): Output? { + override fun getOutput( + job: EncoreJob, + encodingProperties: EncodingProperties, + filterSettings: FilterSettings, + ): Output? { if (!enabled) { return logOrThrow("Thumbnail with suffix $suffix is disabled. Skipping...") } diff --git a/encore-common/src/main/kotlin/se/svt/oss/encore/model/profile/ThumbnailMapEncode.kt b/encore-common/src/main/kotlin/se/svt/oss/encore/model/profile/ThumbnailMapEncode.kt index 1c840180..91524720 100644 --- a/encore-common/src/main/kotlin/se/svt/oss/encore/model/profile/ThumbnailMapEncode.kt +++ b/encore-common/src/main/kotlin/se/svt/oss/encore/model/profile/ThumbnailMapEncode.kt @@ -33,7 +33,11 @@ data class ThumbnailMapEncode( val decodeOutput: Int? = null, ) : OutputProducer { - override fun getOutput(job: EncoreJob, encodingProperties: EncodingProperties): Output? { + override fun getOutput( + job: EncoreJob, + encodingProperties: EncodingProperties, + filterSettings: FilterSettings, + ): Output? { if (!enabled) { return logOrThrow("Thumbnail map with suffix $suffix is disabled. Skipping...") } diff --git a/encore-common/src/main/kotlin/se/svt/oss/encore/model/profile/VideoEncode.kt b/encore-common/src/main/kotlin/se/svt/oss/encore/model/profile/VideoEncode.kt index f31693a0..08026b00 100644 --- a/encore-common/src/main/kotlin/se/svt/oss/encore/model/profile/VideoEncode.kt +++ b/encore-common/src/main/kotlin/se/svt/oss/encore/model/profile/VideoEncode.kt @@ -38,13 +38,13 @@ interface VideoEncode : OutputProducer { val cropTo: FractionString? val padTo: FractionString? - override fun getOutput(job: EncoreJob, encodingProperties: EncodingProperties): Output? { + override fun getOutput(job: EncoreJob, encodingProperties: EncodingProperties, filterSettings: FilterSettings): Output? { if (!enabled) { log.info { "Encode $suffix is not enabled. Skipping." } return null } val audioEncodesToUse = audioEncodes.ifEmpty { listOfNotNull(audioEncode) } - val audio = audioEncodesToUse.flatMap { it.getOutput(job, encodingProperties)?.audioStreams.orEmpty() } + val audio = audioEncodesToUse.flatMap { it.getOutput(job, encodingProperties, filterSettings)?.audioStreams.orEmpty() } val videoInput = job.inputs.videoInput(inputLabel) ?: return logOrThrow("No valid video input with label $inputLabel!") return Output( @@ -54,7 +54,7 @@ interface VideoEncode : OutputProducer { firstPassParams = firstPassParams().toParams(), inputLabels = listOf(inputLabel), twoPass = twoPass, - filter = videoFilter(job.debugOverlay, encodingProperties, videoInput), + filter = videoFilter(job.debugOverlay, encodingProperties, videoInput, filterSettings), ), audioStreams = audio, output = "${job.baseName}$suffix.$format", @@ -80,10 +80,11 @@ interface VideoEncode : OutputProducer { debugOverlay: Boolean, encodingProperties: EncodingProperties, videoInput: VideoIn, + filterSettings: FilterSettings, ): String? { val videoFilters = mutableListOf() cropTo?.toFraction()?.let { - videoFilters.add("crop=min(iw\\,ih*${it.stringValue()}):min(ih\\,iw/(${it.stringValue()}))") + videoFilters.add("${filterSettings.cropFilter}=min(iw\\,ih*${it.stringValue()}):min(ih\\,iw/(${it.stringValue()}))") } var scaleToWidth = width var scaleToHeight = height @@ -100,13 +101,31 @@ interface VideoEncode : OutputProducer { scaleToHeight = width } if (scaleToWidth != null && scaleToHeight != null) { - videoFilters.add("scale=$scaleToWidth:$scaleToHeight:force_original_aspect_ratio=decrease:force_divisible_by=2") + val scaleParams = listOf( + "$scaleToWidth", + "$scaleToHeight", + ) + ( + linkedMapOf( + "force_original_aspect_ratio" to "decrease", + "force_divisible_by" to "2", + ) + filterSettings.scaleFilterParams + ) + .map { "${it.key}=${it.value}" } + videoFilters.add( + "${filterSettings.scaleFilter}=${scaleParams.joinToString(":") }", + ) videoFilters.add("setsar=1/1") } else if (scaleToWidth != null || scaleToHeight != null) { - videoFilters.add("scale=${scaleToWidth ?: -2}:${scaleToHeight ?: -2}") + val filterParams = listOf( + scaleToWidth?.toString() ?: "-2", + scaleToHeight?.toString() ?: "-2", + ) + filterSettings.scaleFilterParams.map { "${it.key}=${it.value}" } + videoFilters.add( + "${filterSettings.scaleFilter}=${filterParams.joinToString(":") }", + ) } padTo?.toFraction()?.let { - videoFilters.add("pad=aspect=${it.stringValue()}:x=(ow-iw)/2:y=(oh-ih)/2") + videoFilters.add("${filterSettings.padFilter}=aspect=${it.stringValue()}:x=(ow-iw)/2:y=(oh-ih)/2") } filters?.let { videoFilters.addAll(it) } if (debugOverlay) { diff --git a/encore-common/src/main/kotlin/se/svt/oss/encore/process/CommandBuilder.kt b/encore-common/src/main/kotlin/se/svt/oss/encore/process/CommandBuilder.kt index 899023c5..11cbd6f4 100644 --- a/encore-common/src/main/kotlin/se/svt/oss/encore/process/CommandBuilder.kt +++ b/encore-common/src/main/kotlin/se/svt/oss/encore/process/CommandBuilder.kt @@ -145,7 +145,8 @@ class CommandBuilder( log.debug { "No video outputs for video input ${input.videoLabel}" } return@mapIndexedNotNull null } - val split = "split=${splits.size}${splits.joinToString("")}" + // val split = "split=${splits.size}${splits.joinToString("")}" + val split = splitFilter(splits) val analyzed = input.analyzedVideo val globalVideoFilters = globalVideoFilters(input, analyzed) val filters = (globalVideoFilters + split).joinToString(",") @@ -163,6 +164,17 @@ class CommandBuilder( return videoSplits + streamFilters } + private fun splitFilter(splits: List): String { + val splitFilter = profile.filterSettings.splitFilter + + if (splitFilter.find { it == '=' } != null) { + // here we assume the size of the split is already included in the + // custom split filter. + return "${splitFilter}${splits.joinToString("")}" + } + return "$splitFilter=${splits.size}${splits.joinToString("")}" + } + private fun VideoStreamEncode?.usesInput(input: VideoIn) = this?.inputLabels?.contains(input.videoLabel) == true @@ -189,6 +201,7 @@ class CommandBuilder( private fun globalVideoFilters(input: VideoIn, videoFile: VideoFile): List { val filters = mutableListOf() + val filterSettings = profile.filterSettings val videoStream = videoFile.highestBitrateVideoStream if (videoStream.isInterlaced) { log.debug { "Video input ${input.videoLabel} is interlaced. Applying deinterlace filter." } @@ -203,16 +216,16 @@ class CommandBuilder( ?: videoStream.displayAspectRatio?.toFractionOrNull() ?: defaultAspectRatio filters.add("setdar=${dar.stringValue()}") - filters.add("scale=iw*sar:ih") + filters.add("${filterSettings.scaleFilter}=iw*sar:ih") } else if (videoStream.sampleAspectRatio?.toFractionOrNull() == null) { filters.add("setsar=1/1") } input.cropTo?.toFraction()?.let { - filters.add("crop=min(iw\\,ih*${it.stringValue()}):min(ih\\,iw/(${it.stringValue()}))") + filters.add("${filterSettings.cropFilter}=min(iw\\,ih*${it.stringValue()}):min(ih\\,iw/(${it.stringValue()}))") } input.padTo?.toFraction()?.let { - filters.add("pad=aspect=${it.stringValue()}:x=(ow-iw)/2:y=(oh-ih)/2") + filters.add("${filterSettings.padFilter}=aspect=${it.stringValue()}:x=(ow-iw)/2:y=(oh-ih)/2") } return filters + input.videoFilters } diff --git a/encore-common/src/main/kotlin/se/svt/oss/encore/service/FfmpegExecutor.kt b/encore-common/src/main/kotlin/se/svt/oss/encore/service/FfmpegExecutor.kt index 737f40e6..a42eb75d 100644 --- a/encore-common/src/main/kotlin/se/svt/oss/encore/service/FfmpegExecutor.kt +++ b/encore-common/src/main/kotlin/se/svt/oss/encore/service/FfmpegExecutor.kt @@ -49,6 +49,7 @@ class FfmpegExecutor( it.getOutput( encoreJob, encoreProperties.encoding, + profile.filterSettings, ) } check(outputs.isNotEmpty()) { diff --git a/encore-common/src/test/kotlin/se/svt/oss/encore/model/profile/AudioEncodeTest.kt b/encore-common/src/test/kotlin/se/svt/oss/encore/model/profile/AudioEncodeTest.kt index 479b2e3a..de0c0356 100644 --- a/encore-common/src/test/kotlin/se/svt/oss/encore/model/profile/AudioEncodeTest.kt +++ b/encore-common/src/test/kotlin/se/svt/oss/encore/model/profile/AudioEncodeTest.kt @@ -30,7 +30,7 @@ class AudioEncodeTest { @Test fun `no audio streams throws exception`() { assertThatThrownBy { - audioEncode.getOutput(job(), EncodingProperties()) + audioEncode.getOutput(job(), EncodingProperties(), FilterSettings()) }.isInstanceOf(RuntimeException::class.java) .hasMessageContaining("No audio streams in input") } @@ -42,6 +42,7 @@ class AudioEncodeTest { audioEncode.getOutput( job, EncodingProperties(audioMixPresets = mapOf("default" to AudioMixPreset(fallbackToAuto = false))), + FilterSettings(), ) }.isInstanceOf(RuntimeException::class.java) .hasMessage("Audio layout of audio input 'main' is not supported!") @@ -52,6 +53,7 @@ class AudioEncodeTest { val output = audioEncode.getOutput( job(getAudioStream(6)), EncodingProperties(), + FilterSettings(), ) assertThat(output) .hasOutput("test_aac_stereo.mp4") @@ -69,7 +71,7 @@ class AudioEncodeTest { @Test fun `returns null when not enabled`() { val output = audioEncode.copy(enabled = false) - .getOutput(job(getAudioStream(6)), EncodingProperties()) + .getOutput(job(getAudioStream(6)), EncodingProperties(), FilterSettings()) assertThat(output).isNull() } @@ -95,6 +97,7 @@ class AudioEncodeTest { ), ), ), + FilterSettings(), ) assertThat(output) .hasOutput("test_aac_stereo.mp4") @@ -137,6 +140,7 @@ class AudioEncodeTest { ), ), ), + FilterSettings(), ) assertThat(output).isNull() } @@ -157,6 +161,7 @@ class AudioEncodeTest { ), ), ), + FilterSettings(), ) }.isInstanceOf(RuntimeException::class.java) .hasMessageContaining("No audio mix preset for 'de': 5.1 -> stereo") @@ -165,7 +170,7 @@ class AudioEncodeTest { @Test fun `unmapped input optional returns null`() { val audioEncodeLocal = audioEncode.copy(inputLabel = "other", optional = true) - val output = audioEncodeLocal.getOutput(job(getAudioStream(6)), EncodingProperties()) + val output = audioEncodeLocal.getOutput(job(getAudioStream(6)), EncodingProperties(), FilterSettings()) assertThat(output).isNull() } @@ -173,7 +178,7 @@ class AudioEncodeTest { fun `unmapped input not optional throws`() { val audioEncodeLocal = audioEncode.copy(inputLabel = "other") assertThatThrownBy { - audioEncodeLocal.getOutput(job(getAudioStream(6)), EncodingProperties()) + audioEncodeLocal.getOutput(job(getAudioStream(6)), EncodingProperties(), FilterSettings()) }.isInstanceOf(RuntimeException::class.java) .hasMessage("Can not generate test_aac_stereo.mp4! No audio input with label 'other'.") } diff --git a/encore-common/src/test/kotlin/se/svt/oss/encore/model/profile/ThumbnailEncodeTest.kt b/encore-common/src/test/kotlin/se/svt/oss/encore/model/profile/ThumbnailEncodeTest.kt index 97b518ae..496dca8f 100644 --- a/encore-common/src/test/kotlin/se/svt/oss/encore/model/profile/ThumbnailEncodeTest.kt +++ b/encore-common/src/test/kotlin/se/svt/oss/encore/model/profile/ThumbnailEncodeTest.kt @@ -27,6 +27,7 @@ class ThumbnailEncodeTest { val output = encode.getOutput( job = defaultEncoreJob(), encodingProperties = EncodingProperties(), + FilterSettings(), ) assertThat(output) .hasOutput("test_thumb%02d.jpg") @@ -46,6 +47,7 @@ class ThumbnailEncodeTest { val output = encode.copy(enabled = false).getOutput( job = defaultEncoreJob(), encodingProperties = EncodingProperties(), + FilterSettings(), ) assertThat(output).isNull() } @@ -57,6 +59,7 @@ class ThumbnailEncodeTest { thumbnailTime = 5.0, ), encodingProperties = EncodingProperties(), + FilterSettings(), ) assertThat(output) .hasOutput("test_thumb%02d.jpg") @@ -80,6 +83,7 @@ class ThumbnailEncodeTest { val output = selectorEncode.getOutput( job = defaultEncoreJob(), encodingProperties = EncodingProperties(), + FilterSettings(), ) assertThat(output) @@ -103,6 +107,7 @@ class ThumbnailEncodeTest { duration = 4.0, ), encodingProperties = EncodingProperties(), + FilterSettings(), ) assertThat(output) .hasOutput("test_thumb%02d.jpg") @@ -133,6 +138,7 @@ class ThumbnailEncodeTest { ), ), encodingProperties = EncodingProperties(), + FilterSettings(), ) assertThat(output) .hasOutput("test_thumb%02d.jpg") @@ -152,6 +158,7 @@ class ThumbnailEncodeTest { val output = encode.copy(inputLabel = "other", optional = true).getOutput( job = defaultEncoreJob(), encodingProperties = EncodingProperties(), + FilterSettings(), ) assertThat(output).isNull() } @@ -162,6 +169,7 @@ class ThumbnailEncodeTest { encode.copy(inputLabel = "other", optional = false).getOutput( job = defaultEncoreJob(), encodingProperties = EncodingProperties(), + FilterSettings(), ) }.isInstanceOf(RuntimeException::class.java) .hasMessageContaining("No video input with label other!") diff --git a/encore-common/src/test/kotlin/se/svt/oss/encore/model/profile/ThumbnailMapEncodeTest.kt b/encore-common/src/test/kotlin/se/svt/oss/encore/model/profile/ThumbnailMapEncodeTest.kt index 07651e42..b00cf966 100644 --- a/encore-common/src/test/kotlin/se/svt/oss/encore/model/profile/ThumbnailMapEncodeTest.kt +++ b/encore-common/src/test/kotlin/se/svt/oss/encore/model/profile/ThumbnailMapEncodeTest.kt @@ -23,7 +23,7 @@ class ThumbnailMapEncodeTest { @Test fun `correct output`() { - val output = encode.getOutput(defaultEncoreJob(), EncodingProperties()) + val output = encode.getOutput(defaultEncoreJob(), EncodingProperties(), FilterSettings()) assertThat(output) .hasNoAudioStreams() .hasId("_12x20_160x90_thumbnail_map.jpg") @@ -44,6 +44,7 @@ class ThumbnailMapEncodeTest { defaultEncoreJob() .copy(seekTo = 1.0, duration = 5.0), EncodingProperties(), + FilterSettings(), ) assertThat(output) .hasNoAudioStreams() @@ -63,6 +64,7 @@ class ThumbnailMapEncodeTest { val output = encode.copy(inputLabel = "other", optional = true).getOutput( job = defaultEncoreJob(), encodingProperties = EncodingProperties(), + FilterSettings(), ) assertThat(output).isNull() } @@ -73,6 +75,7 @@ class ThumbnailMapEncodeTest { encode.copy(inputLabel = "other", optional = false).getOutput( job = defaultEncoreJob(), encodingProperties = EncodingProperties(), + FilterSettings(), ) }.isInstanceOf(RuntimeException::class.java) .hasMessageContaining("No input with label other!") @@ -83,6 +86,7 @@ class ThumbnailMapEncodeTest { val output = encode.copy(enabled = false).getOutput( job = defaultEncoreJob(), encodingProperties = EncodingProperties(), + filterSettings = FilterSettings(), ) assertThat(output).isNull() } diff --git a/encore-common/src/test/kotlin/se/svt/oss/encore/model/profile/VideoEncodeTest.kt b/encore-common/src/test/kotlin/se/svt/oss/encore/model/profile/VideoEncodeTest.kt index e1a61764..7a4d1a94 100644 --- a/encore-common/src/test/kotlin/se/svt/oss/encore/model/profile/VideoEncodeTest.kt +++ b/encore-common/src/test/kotlin/se/svt/oss/encore/model/profile/VideoEncodeTest.kt @@ -35,13 +35,14 @@ abstract class VideoEncodeTest { ): T private val encodingProperties = EncodingProperties() + private val filterSettings = FilterSettings() private val audioEncode = mockk() private val audioStreamEncode = mockk() private val defaultParams = linkedMapOf("a" to "b") @BeforeEach internal fun setUp() { - every { audioEncode.getOutput(any(), encodingProperties)?.audioStreams } returns listOf(audioStreamEncode) + every { audioEncode.getOutput(any(), encodingProperties, filterSettings)?.audioStreams } returns listOf(audioStreamEncode) } @Test @@ -65,6 +66,7 @@ abstract class VideoEncodeTest { ), ), encodingProperties, + filterSettings, ) assertThat(output?.video).hasFilter("scale=1080:1920:force_original_aspect_ratio=decrease:force_divisible_by=2,setsar=1/1") @@ -92,6 +94,7 @@ abstract class VideoEncodeTest { ), ), encodingProperties, + filterSettings, ) assertThat(output?.video).hasFilter("scale=1080:1920:force_original_aspect_ratio=decrease:force_divisible_by=2,setsar=1/1") @@ -107,7 +110,7 @@ abstract class VideoEncodeTest { filters = listOf("afilter"), audioEncode = audioEncode, ) - val output = encode.getOutput(defaultEncoreJob(), encodingProperties) + val output = encode.getOutput(defaultEncoreJob(), encodingProperties, filterSettings) assertThat(output) .hasOnlyAudioStreams(audioStreamEncode) val videoStreamEncode = output!!.video @@ -120,6 +123,31 @@ abstract class VideoEncodeTest { verifySecondPassParams(encode, videoStreamEncode.params) } + @Test + fun `single pass scale to height with custom scale filter`() { + val filterSettings = FilterSettings(scaleFilter = "myscale") + every { audioEncode.getOutput(any(), encodingProperties, filterSettings)?.audioStreams } returns listOf(audioStreamEncode) + val encode = createEncode( + width = null, + height = 1080, + twoPass = false, + params = defaultParams, + filters = listOf("afilter"), + audioEncode = audioEncode, + ) + val output = encode.getOutput(defaultEncoreJob(), encodingProperties, filterSettings) + assertThat(output) + .hasOnlyAudioStreams(audioStreamEncode) + val videoStreamEncode = output!!.video + assertThat(videoStreamEncode) + .isNotNull + .hasNoFirstPassParams() + .hasTwoPass(false) + .hasFilter("myscale=-2:1080,afilter") + verifyFirstPassParams(encode, videoStreamEncode!!.firstPassParams) + verifySecondPassParams(encode, videoStreamEncode.params) + } + @Test fun `two-pass encode`() { val encode = createEncode( @@ -130,7 +158,7 @@ abstract class VideoEncodeTest { filters = listOf("afilter"), audioEncode = audioEncode, ) - val output = encode.getOutput(defaultEncoreJob(), encodingProperties) + val output = encode.getOutput(defaultEncoreJob(), encodingProperties, filterSettings) assertThat(output).isNotNull val videoStreamEncode = output!!.video assertThat(videoStreamEncode) @@ -162,6 +190,7 @@ abstract class VideoEncodeTest { ), ), encodingProperties, + FilterSettings(), ) assertThat(output).isNull() } @@ -190,6 +219,7 @@ abstract class VideoEncodeTest { ), ), encodingProperties, + FilterSettings(), ) }.hasMessage("No valid video input with label main!") } @@ -205,7 +235,7 @@ abstract class VideoEncodeTest { audioEncode = audioEncode, enabled = false, ) - val output = encode.getOutput(defaultEncoreJob(), encodingProperties) + val output = encode.getOutput(defaultEncoreJob(), encodingProperties, FilterSettings()) assertThat(output).isNull() } @@ -221,7 +251,7 @@ abstract class VideoEncodeTest { cropTo = "9:16", padTo = "16:9", ) - val output = encode.getOutput(defaultEncoreJob(), encodingProperties) + val output = encode.getOutput(defaultEncoreJob(), encodingProperties, FilterSettings()) assertThat(output?.video).hasFilter("crop=min(iw\\,ih*9/16):min(ih\\,iw/(9/16)),scale=1920:1080:force_original_aspect_ratio=decrease:force_divisible_by=2,setsar=1/1,pad=aspect=16/9:x=(ow-iw)/2:y=(oh-ih)/2,afilter") } diff --git a/encore-common/src/test/kotlin/se/svt/oss/encore/process/CommandBuilderTest.kt b/encore-common/src/test/kotlin/se/svt/oss/encore/process/CommandBuilderTest.kt index 0908fde7..86284591 100644 --- a/encore-common/src/test/kotlin/se/svt/oss/encore/process/CommandBuilderTest.kt +++ b/encore-common/src/test/kotlin/se/svt/oss/encore/process/CommandBuilderTest.kt @@ -22,6 +22,7 @@ import se.svt.oss.encore.model.output.AudioStreamEncode import se.svt.oss.encore.model.output.Output import se.svt.oss.encore.model.output.VideoStreamEncode import se.svt.oss.encore.model.profile.ChannelLayout +import se.svt.oss.encore.model.profile.FilterSettings import se.svt.oss.encore.model.profile.Profile import se.svt.oss.mediaanalyzer.file.AudioFile @@ -40,6 +41,7 @@ internal class CommandBuilderTest { commandBuilder = CommandBuilder(encoreJob, profile, encoreJob.outputFolder, encodingProperties) every { profile.scaling } returns "scaling" every { profile.deinterlaceFilter } returns "yadif" + every { profile.filterSettings } returns FilterSettings() } @Test @@ -90,6 +92,72 @@ internal class CommandBuilderTest { assertThat(command).isEqualTo("ffmpeg -xerror -hide_banner -loglevel +level -y -i /input/test.mp4 -filter_complex sws_flags=scaling;[0:a]join=inputs=3:channel_layout=3.0:map=0.0-FL|1.0-FR|2.0-FC,asplit=1[AUDIO-main-test-out-0];[AUDIO-main-test-out-0]aformat=channel_layouts=stereo[AUDIO-test-out-0] -map [AUDIO-test-out-0] -vn -c:a:0 aac -metadata comment=Transcoded using Encore /output/path/out.mp4") } + @Test + fun `custom splitFilter no size param`() { + every { profile.filterSettings } returns FilterSettings(splitFilter = "custom-split-filter") + val buildCommands = commandBuilder.buildCommands(listOf(output(false))) + + assertThat(buildCommands).hasSize(1) + + val command = buildCommands.first().joinToString(" ") + assertThat(command).isEqualTo("ffmpeg -xerror -hide_banner -loglevel +level -y -i /input/test.mp4 -filter_complex sws_flags=scaling;[0:v]custom-split-filter=1[VIDEO-main-test-out];[VIDEO-main-test-out]video-filter[VIDEO-test-out];[0:a]join=inputs=8:channel_layout=7.1:map=0.0-FL|1.0-FR|2.0-FC|3.0-LFE|4.0-BL|5.0-BR|6.0-SL|7.0-SR,asplit=1[AUDIO-main-test-out-0];[AUDIO-main-test-out-0]audio-filter[AUDIO-test-out-0] -map [VIDEO-test-out] -map [AUDIO-test-out-0] video params audio params -metadata comment=Transcoded using Encore /output/path/out.mp4") + } + + @Test + fun `custom splitFilter with size params`() { + every { profile.filterSettings } returns FilterSettings(splitFilter = "custom-split-filter=1:2:3") + val buildCommands = commandBuilder.buildCommands(listOf(output(false))) + + assertThat(buildCommands).hasSize(1) + + val command = buildCommands.first().joinToString(" ") + assertThat(command).isEqualTo("ffmpeg -xerror -hide_banner -loglevel +level -y -i /input/test.mp4 -filter_complex sws_flags=scaling;[0:v]custom-split-filter=1:2:3[VIDEO-main-test-out];[VIDEO-main-test-out]video-filter[VIDEO-test-out];[0:a]join=inputs=8:channel_layout=7.1:map=0.0-FL|1.0-FR|2.0-FC|3.0-LFE|4.0-BL|5.0-BR|6.0-SL|7.0-SR,asplit=1[AUDIO-main-test-out-0];[AUDIO-main-test-out-0]audio-filter[AUDIO-test-out-0] -map [VIDEO-test-out] -map [AUDIO-test-out-0] video params audio params -metadata comment=Transcoded using Encore /output/path/out.mp4") + } + + @Test + fun `custom crop filter`() { + val job = defaultEncoreJob().copy( + inputs = listOf( + AudioVideoInput( + uri = "/input/test.mp4", + analyzed = defaultVideoFile, + cropTo = "1:1", + ), + ), + ) + every { profile.filterSettings } returns FilterSettings(cropFilter = "hw_crop") + commandBuilder = CommandBuilder(job, profile, encoreJob.outputFolder, encodingProperties) + + val buildCommands = commandBuilder.buildCommands(listOf(output(false))) + + assertThat(buildCommands).hasSize(1) + + val command = buildCommands.first().joinToString(" ") + assertThat(command).isEqualTo("ffmpeg -xerror -hide_banner -loglevel +level -y -i /input/test.mp4 -filter_complex sws_flags=scaling;[0:v]hw_crop=min(iw\\,ih*1/1):min(ih\\,iw/(1/1)),split=1[VIDEO-main-test-out];[VIDEO-main-test-out]video-filter[VIDEO-test-out];[0:a]join=inputs=8:channel_layout=7.1:map=0.0-FL|1.0-FR|2.0-FC|3.0-LFE|4.0-BL|5.0-BR|6.0-SL|7.0-SR,asplit=1[AUDIO-main-test-out-0];[AUDIO-main-test-out-0]audio-filter[AUDIO-test-out-0] -map [VIDEO-test-out] -map [AUDIO-test-out-0] video params audio params -metadata comment=Transcoded using Encore /output/path/out.mp4") + } + + @Test + fun `custom pad filter`() { + val job = defaultEncoreJob().copy( + inputs = listOf( + AudioVideoInput( + uri = "/input/test.mp4", + analyzed = defaultVideoFile, + padTo = "1:1", + ), + ), + ) + every { profile.filterSettings } returns FilterSettings(padFilter = "hw_pad") + commandBuilder = CommandBuilder(job, profile, encoreJob.outputFolder, encodingProperties) + + val buildCommands = commandBuilder.buildCommands(listOf(output(false))) + + assertThat(buildCommands).hasSize(1) + + val command = buildCommands.first().joinToString(" ") + assertThat(command).isEqualTo("ffmpeg -xerror -hide_banner -loglevel +level -y -i /input/test.mp4 -filter_complex sws_flags=scaling;[0:v]hw_pad=aspect=1/1:x=(ow-iw)/2:y=(oh-ih)/2,split=1[VIDEO-main-test-out];[VIDEO-main-test-out]video-filter[VIDEO-test-out];[0:a]join=inputs=8:channel_layout=7.1:map=0.0-FL|1.0-FR|2.0-FC|3.0-LFE|4.0-BL|5.0-BR|6.0-SL|7.0-SR,asplit=1[AUDIO-main-test-out-0];[AUDIO-main-test-out-0]audio-filter[AUDIO-test-out-0] -map [VIDEO-test-out] -map [AUDIO-test-out-0] video params audio params -metadata comment=Transcoded using Encore /output/path/out.mp4") + } + @Test fun `one pass encode`() { val buildCommands = commandBuilder.buildCommands(listOf(output(false)))