From 6b18372d62e02f306c755ea4780fe22baed3d5c5 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 | 2 + .../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 | 29 ++++++-- .../svt/oss/encore/process/CommandBuilder.kt | 20 ++++-- .../svt/oss/encore/service/FfmpegExecutor.kt | 1 + .../encore/model/profile/AudioEncodeTest.kt | 11 ++- .../model/profile/ThumbnailEncodeTest.kt | 7 ++ .../model/profile/ThumbnailMapEncodeTest.kt | 5 +- .../encore/model/profile/VideoEncodeTest.kt | 34 +++++++++- .../oss/encore/process/CommandBuilderTest.kt | 68 +++++++++++++++++++ 15 files changed, 203 insertions(+), 21 deletions(-) diff --git a/checks.gradle b/checks.gradle index 64012bd3..00854338 100644 --- a/checks.gradle +++ b/checks.gradle @@ -15,6 +15,8 @@ jacocoTestCoverageVerification { '*QueueService.migrateQueues()', '*.ShutdownHandler.*', '*FfmpegExecutor.runFfmpeg$lambda$7(java.lang.Process)', + '*FilterSettings.*', + '*.FfmpegExecutor.getProgress(java.lang.Double, java.lang.String)' ] 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 7bce78f2..b7daa6e0 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 @@ -31,7 +31,11 @@ data class AudioEncode( 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" val audioIn = job.inputs.audioInput(inputLabel) ?: return logOrThrow("Can not generate $outputName! No audio input with label '$inputLabel'.") 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 d90643b0..b9d47e91 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 @@ -22,7 +22,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" job.inputs.analyzedAudio(inputLabel) ?: return logOrThrow("Can not generate $outputName! No audio input with label '$inputLabel'.") 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 fb7f908e..30d2af74 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 @@ -29,7 +29,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 (job.segmentLength != null) { return logOrThrow("Thumbnail is not supported in segmented encode!") } 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 ed4991f5..0094c8e0 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 @@ -32,7 +32,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 (job.segmentLength != null) { return logOrThrow("Thumbnail map is not supported in segmented encode!") } 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 d7a58208..76283ac9 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 @@ -28,9 +28,9 @@ interface VideoEncode : OutputProducer { val codec: String val inputLabel: String - override fun getOutput(job: EncoreJob, encodingProperties: EncodingProperties): Output? { + override fun getOutput(job: EncoreJob, encodingProperties: EncodingProperties, filterSettings: FilterSettings): Output? { 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) ?: throw RuntimeException("No valid video input with label $inputLabel!") return Output( @@ -40,7 +40,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", @@ -66,6 +66,7 @@ interface VideoEncode : OutputProducer { debugOverlay: Boolean, encodingProperties: EncodingProperties, videoInput: VideoIn, + filterSettings: FilterSettings, ): String? { val videoFilters = mutableListOf() var scaleToWidth = width @@ -83,10 +84,28 @@ 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(":") }", + ) } 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..1e19a599 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,7 @@ class CommandBuilder( log.debug { "No video outputs for video input ${input.videoLabel}" } return@mapIndexedNotNull null } - 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 +163,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 +200,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 +215,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 80c27616..63faa0ba 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, ) } 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 6dda4b3e..585c6f32 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") @@ -88,6 +90,7 @@ class AudioEncodeTest { ), ), ), + FilterSettings(), ) assertThat(output) .hasOutput("test_aac_stereo.mp4") @@ -130,6 +133,7 @@ class AudioEncodeTest { ), ), ), + FilterSettings(), ) assertThat(output).isNull() } @@ -150,6 +154,7 @@ class AudioEncodeTest { ), ), ), + FilterSettings(), ) }.isInstanceOf(RuntimeException::class.java) .hasMessageContaining("No audio mix preset for 'de': 5.1 -> stereo") @@ -158,7 +163,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() } @@ -166,7 +171,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 4d2bbb24..f7e0c5eb 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") @@ -48,6 +49,7 @@ class ThumbnailEncodeTest { thumbnailTime = 5.0, ), encodingProperties = EncodingProperties(), + FilterSettings(), ) assertThat(output) .hasOutput("test_thumb%02d.jpg") @@ -71,6 +73,7 @@ class ThumbnailEncodeTest { val output = selectorEncode.getOutput( job = defaultEncoreJob(), encodingProperties = EncodingProperties(), + FilterSettings(), ) assertThat(output) @@ -94,6 +97,7 @@ class ThumbnailEncodeTest { duration = 4.0, ), encodingProperties = EncodingProperties(), + FilterSettings(), ) assertThat(output) .hasOutput("test_thumb%02d.jpg") @@ -124,6 +128,7 @@ class ThumbnailEncodeTest { ), ), encodingProperties = EncodingProperties(), + FilterSettings(), ) assertThat(output) .hasOutput("test_thumb%02d.jpg") @@ -143,6 +148,7 @@ class ThumbnailEncodeTest { val output = encode.copy(inputLabel = "other", optional = true).getOutput( job = defaultEncoreJob(), encodingProperties = EncodingProperties(), + FilterSettings(), ) assertThat(output).isNull() } @@ -153,6 +159,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 dcc1aae5..4e6c3085 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!") 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 e8b2b796..df8e4aa0 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 @@ -29,13 +29,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 @@ -59,6 +60,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") @@ -86,6 +88,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") @@ -101,7 +104,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 @@ -114,6 +117,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( @@ -124,7 +152,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) 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)))