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 64012bd..0085433 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 7bce78f..b7daa6e 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 3c91f34..6fe3f16 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 019fee8..90692fc 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 d90643b..b9d47e9 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 fb7f908..30d2af7 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 ed4991f..0094c8e 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 d7a5820..76283ac 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 899023c..1e19a59 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 80c2761..63faa0b 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 6dda4b3..585c6f3 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 4d2bbb2..f7e0c5e 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 dcc1aae..4e6c308 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 e8b2b79..df8e4aa 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 0908fde..8628459 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)))