From 4689201e0284b48e4f9055f382c0582e7d575b0c Mon Sep 17 00:00:00 2001 From: "Finn Hermansson (fihe02)" Date: Fri, 16 Dec 2022 15:00:26 +0100 Subject: [PATCH] Improved audio handling **Features** * Audio handling has been changed to work with Ffmpeg's standard channel layouts (`>ffmpeg -layouts`) instead of number of channels. This allows more control on audio mixing, and allows more advanced audio features in the future. * A new SimpleAudioEncore has been added that preserves the audio layout of the input without filtering. * A option that when enabled (default true) and video input is portrait and output is scaled within a landscape box, rotates the scaling box to portrait. * Spring has been bumped to 2.7.6 **Breaking changes** see changes in `src/test/resources/application-test.yml` and profiles in `src/test/resources/profile`: * channels in AudioEncode has been replaced with channelLayout where accepted values are one of Ffmpeg's standard channel layouts, e.g. 'stereo', '3.0', '5.1'. * Keys in AudioMixPresets are now channelLayouts instead of number of channels * The input parameter useFirstAudioStreams has been replaced with channelLayout acting as a hint on how to handle inputs where channels are separated in mono streams. * Encoding related settings including AudioMixPresets have moved to `encore-settings.encoding` Signed-off-by: Finn Hermansson --- Dockerfile | 5 +- bin/start.sh | 18 --- build.gradle.kts | 56 +++++---- gradle/wrapper/gradle-wrapper.jar | Bin 59821 -> 60756 bytes gradlew | 6 + .../se/svt/oss/encore/RedisConfiguration.kt | 6 +- .../svt/oss/encore/config/AudioMixPreset.kt | 6 +- .../oss/encore/config/EncodingProperties.kt | 9 ++ .../svt/oss/encore/config/EncoreProperties.kt | 4 +- .../se/svt/oss/encore/model/EncoreJob.kt | 59 ++++++--- .../se/svt/oss/encore/model/input/Input.kt | 49 +++++--- .../oss/encore/model/mediafile/Extensions.kt | 34 +++++- .../se/svt/oss/encore/model/output/Output.kt | 1 + .../oss/encore/model/profile/AudioEncode.kt | 64 +++++----- .../oss/encore/model/profile/AudioEncoder.kt | 23 ++++ .../svt/oss/encore/model/profile/ChannelId.kt | 38 ++++++ .../oss/encore/model/profile/ChannelLayout.kt | 105 ++++++++++++++++ .../model/profile/GenericVideoEncode.kt | 4 +- .../encore/model/profile/OutputProducer.kt | 5 +- .../encore/model/profile/SimpleAudioEncode.kt | 48 ++++++++ .../encore/model/profile/ThumbnailEncode.kt | 8 +- .../model/profile/ThumbnailMapEncode.kt | 5 +- .../oss/encore/model/profile/VideoEncode.kt | 50 ++++++-- .../oss/encore/model/profile/X264Encode.kt | 4 +- .../oss/encore/model/profile/X265Encode.kt | 4 +- .../svt/oss/encore/model/queue/QueueItem.kt | 1 - .../svt/oss/encore/process/CommandBuilder.kt | 57 +++++++-- .../repository/ChannelLayoutConverters.kt | 29 +++++ .../svt/oss/encore/service/EncoreService.kt | 2 +- .../svt/oss/encore/service/FfmpegExecutor.kt | 11 +- .../service/localencode/LocalEncodeService.kt | 1 - .../mediaanalyzer/MediaAnalyzerService.kt | 3 +- .../encore/service/profile/ProfileService.kt | 7 +- .../svt/oss/encore/EncoreIntegrationTest.kt | 3 +- .../oss/encore/EncoreIntegrationTestBase.kt | 47 +++----- .../kotlin/se/svt/oss/encore/TestUtils.kt | 5 + .../encore/handlers/EncoreJobHandlerTest.kt | 1 - .../mediafile/MediaFileExtensionsTest.kt | 114 +++++++++++++++--- .../encore/model/profile/AudioEncodeTest.kt | 81 ++++++++++--- .../model/profile/ThumbnailEncodeTest.kt | 17 +-- .../model/profile/ThumbnailMapEncodeTest.kt | 9 +- .../encore/model/profile/VideoEncodeTest.kt | 37 +++++- .../oss/encore/process/CommandBuilderTest.kt | 68 +++++++++-- src/test/resources/application-test-local.yml | 14 +-- src/test/resources/application-test.yml | 32 +++-- .../resources/input/multiple-audio-file.json | 2 + .../resources/input/portrait-video-file.json | 94 +++++++++++++++ src/test/resources/input/video-file.json | 8 ++ src/test/resources/profile/archive.yml | 2 +- src/test/resources/profile/audio-streams.yml | 4 +- .../resources/profile/multiple_inputs.yml | 2 +- src/test/resources/profile/program-x265.yml | 2 +- src/test/resources/profile/program.yml | 2 +- 53 files changed, 974 insertions(+), 292 deletions(-) delete mode 100755 bin/start.sh create mode 100644 src/main/kotlin/se/svt/oss/encore/config/EncodingProperties.kt create mode 100644 src/main/kotlin/se/svt/oss/encore/model/profile/AudioEncoder.kt create mode 100644 src/main/kotlin/se/svt/oss/encore/model/profile/ChannelId.kt create mode 100644 src/main/kotlin/se/svt/oss/encore/model/profile/ChannelLayout.kt create mode 100644 src/main/kotlin/se/svt/oss/encore/model/profile/SimpleAudioEncode.kt create mode 100644 src/main/kotlin/se/svt/oss/encore/repository/ChannelLayoutConverters.kt create mode 100644 src/test/resources/input/portrait-video-file.json diff --git a/Dockerfile b/Dockerfile index 3f7905e5..7524a3cb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,10 +5,7 @@ LABEL org.opencontainers.image.url="https://github.com/svt/encore" LABEL org.opencontainers.image.source="https://github.com/svt/encore" COPY build/libs/encore*.jar /app/encore.jar -COPY bin/start.sh /app/start.sh WORKDIR /app -ENV JAVA_OPTS "-XX:MaxRAMPercentage=10" - -CMD ["/app/start.sh"] +CMD ["java", "-jar", "encore.jar"] diff --git a/bin/start.sh b/bin/start.sh deleted file mode 100755 index e80bc03d..00000000 --- a/bin/start.sh +++ /dev/null @@ -1,18 +0,0 @@ -#!/bin/bash -e - -readonly INSTALL_DIR=$(pwd) - -echo "using install dir [$INSTALL_DIR]" - -JAVA_NETWORK="-Djava.net.preferIPv4Stack=true" - -JAVA_OPTS="$JAVA_OPTS ${JAVA_NETWORK}" - -CMD="java $JAVA_OPTS -jar ${INSTALL_DIR}/encore.jar" - -echo "Starting boot app with command: $CMD" -exec ${CMD} - - - - diff --git a/build.gradle.kts b/build.gradle.kts index 90373ab6..d946db2c 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -3,14 +3,14 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { idea jacoco - id("org.springframework.boot") version "2.6.6" - id("se.ascp.gradle.gradle-versions-filter") version "0.1.10" - kotlin("jvm") version "1.6.20" - kotlin("plugin.spring") version "1.6.20" + id("org.springframework.boot") version "2.7.6" + id("se.ascp.gradle.gradle-versions-filter") version "0.1.16" + kotlin("jvm") version "1.7.21" + kotlin("plugin.spring") version "1.7.21" id("com.github.fhermansson.assertj-generator") version "1.1.4" - id("org.jmailen.kotlinter") version "3.9.0" - id("io.spring.dependency-management") version "1.0.11.RELEASE" - id("pl.allegro.tech.build.axion-release") version "1.13.3" + id("org.jmailen.kotlinter") version "3.12.0" + id("io.spring.dependency-management") version "1.1.0" + id("pl.allegro.tech.build.axion-release") version "1.14.2" //openapi generation id("com.github.johnrengelman.processes") version "0.5.0" @@ -33,7 +33,15 @@ assertjGenerator { } kotlinter { - disabledRules = arrayOf("import-ordering") + disabledRules = arrayOf( + "import-ordering", + "trailing-comma-on-declaration-site", + "trailing-comma-on-call-site" + ) +} + +tasks.lintKotlinTest { + source = (source - fileTree("src/test/generated-java")).asFileTree } tasks.test { @@ -69,14 +77,14 @@ configurations { dependencyManagement { imports { - mavenBom("org.springframework.cloud:spring-cloud-dependencies:2021.0.1") + mavenBom("org.springframework.cloud:spring-cloud-dependencies:2021.0.5") } } -val redissonVersion = "3.18.0" +val redissonVersion = "3.18.1" dependencies { - implementation("se.svt.oss:media-analyzer:1.0.3") + implementation("se.svt.oss:media-analyzer:2.0.1") implementation(kotlin("reflect")) implementation("org.springframework.boot:spring-boot-starter-web") @@ -89,12 +97,12 @@ dependencies { implementation("org.springframework.boot:spring-boot-starter-security") implementation("org.redisson:redisson-spring-boot-starter:$redissonVersion") - implementation("org.redisson:redisson-spring-data-26:$redissonVersion") // match boot version - implementation("io.github.microutils:kotlin-logging:2.1.21") + implementation("org.redisson:redisson-spring-data-27:$redissonVersion") // match boot version + implementation("io.github.microutils:kotlin-logging:3.0.2") implementation("com.fasterxml.jackson.module:jackson-module-kotlin") - implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.0") - implementation("org.jetbrains.kotlinx:kotlinx-coroutines-jdk8:1.6.0") - implementation("org.jetbrains.kotlinx:kotlinx-coroutines-slf4j:1.6.0") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-jdk8:1.6.4") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-slf4j:1.6.4") implementation("org.springframework.cloud:spring-cloud-starter-openfeign") implementation("io.github.openfeign:feign-okhttp") @@ -102,19 +110,19 @@ dependencies { implementation("org.springframework.boot:spring-boot-starter-aop") //openapi generation - implementation("org.springdoc:springdoc-openapi-ui:1.5.10") - implementation("org.springdoc:springdoc-openapi-kotlin:1.5.10") - implementation("org.springdoc:springdoc-openapi-data-rest:1.5.10") - implementation("org.springdoc:springdoc-openapi-hateoas:1.5.10") + implementation("org.springdoc:springdoc-openapi-ui:1.6.12") + implementation("org.springdoc:springdoc-openapi-kotlin:1.6.12") + implementation("org.springdoc:springdoc-openapi-data-rest:1.6.12") + implementation("org.springdoc:springdoc-openapi-hateoas:1.6.12") testImplementation("se.svt.oss:junit5-redis-extension:3.0.0") testImplementation("se.svt.oss:random-port-initializer:1.0.5") - testImplementation("org.awaitility:awaitility:4.1.1") + testImplementation("org.awaitility:awaitility") testImplementation("org.springframework.boot:spring-boot-starter-test") testImplementation("org.springframework.security:spring-security-test") - testImplementation("org.assertj:assertj-core:3.20.2") // Can be bumped with future kotlin 1.7 release, https://github.com/assertj/assertj-core/issues/2357 - testImplementation("io.mockk:mockk:1.12.3") - testImplementation("com.squareup.okhttp3:mockwebserver:3.14.9")//shall match with okhttp version used + testImplementation("org.assertj:assertj-core") + testImplementation("io.mockk:mockk:1.13.2") + testImplementation("com.github.tomakehurst:wiremock-jre8:2.35.0") testImplementation("com.ninja-squad:springmockk:3.1.1") testImplementation("org.junit.jupiter:junit-jupiter-api") testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine") diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index 41d9927a4d4fb3f96a785543079b8df6723c946b..249e5832f090a2944b7473328c07c9755baa3196 100644 GIT binary patch delta 10197 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 diff --git a/gradlew b/gradlew index 1b6c7873..a69d9cb6 100755 --- a/gradlew +++ b/gradlew @@ -205,6 +205,12 @@ set -- \ org.gradle.wrapper.GradleWrapperMain \ "$@" +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + # Use "xargs" to parse quoted args. # # With -n1 it outputs one arg per line, with the quotes and backslashes removed. diff --git a/src/main/kotlin/se/svt/oss/encore/RedisConfiguration.kt b/src/main/kotlin/se/svt/oss/encore/RedisConfiguration.kt index 2ada9657..c51453a8 100644 --- a/src/main/kotlin/se/svt/oss/encore/RedisConfiguration.kt +++ b/src/main/kotlin/se/svt/oss/encore/RedisConfiguration.kt @@ -12,9 +12,11 @@ import org.springframework.context.annotation.Configuration import org.springframework.data.redis.core.RedisKeyValueAdapter import org.springframework.data.redis.core.convert.RedisCustomConversions import org.springframework.data.redis.repository.configuration.EnableRedisRepositories +import se.svt.oss.encore.repository.ByteArrayToChannelLayoutConverter import se.svt.oss.encore.repository.ByteArrayToOffsetDateTimeConverter import se.svt.oss.encore.repository.ByteArrayToURIConverter import se.svt.oss.encore.repository.ByteArrayToUUIDConverter +import se.svt.oss.encore.repository.ChannelLayoutToByteArrayConverter import se.svt.oss.encore.repository.OffsetDateTimeToByteArrayConverter import se.svt.oss.encore.repository.URIToByteArrayConverter import se.svt.oss.encore.repository.UUIDToByteArrayConverter @@ -31,7 +33,9 @@ class RedisConfiguration { UUIDToByteArrayConverter(), ByteArrayToUUIDConverter(), URIToByteArrayConverter(), - ByteArrayToURIConverter() + ByteArrayToURIConverter(), + ChannelLayoutToByteArrayConverter(), + ByteArrayToChannelLayoutConverter() ) ) diff --git a/src/main/kotlin/se/svt/oss/encore/config/AudioMixPreset.kt b/src/main/kotlin/se/svt/oss/encore/config/AudioMixPreset.kt index 5407474b..e8b8a3cd 100644 --- a/src/main/kotlin/se/svt/oss/encore/config/AudioMixPreset.kt +++ b/src/main/kotlin/se/svt/oss/encore/config/AudioMixPreset.kt @@ -3,8 +3,10 @@ // SPDX-License-Identifier: EUPL-1.2 package se.svt.oss.encore.config +import se.svt.oss.encore.model.profile.ChannelLayout + data class AudioMixPreset( val fallbackToAuto: Boolean = true, - val defaultPan: Map = emptyMap(), - val panMapping: Map> = emptyMap() + val defaultPan: Map = emptyMap(), + val panMapping: Map> = emptyMap(), ) diff --git a/src/main/kotlin/se/svt/oss/encore/config/EncodingProperties.kt b/src/main/kotlin/se/svt/oss/encore/config/EncodingProperties.kt new file mode 100644 index 00000000..bb044237 --- /dev/null +++ b/src/main/kotlin/se/svt/oss/encore/config/EncodingProperties.kt @@ -0,0 +1,9 @@ +package se.svt.oss.encore.config + +import se.svt.oss.encore.model.profile.ChannelLayout + +data class EncodingProperties( + val audioMixPresets: Map = mapOf("default" to AudioMixPreset()), + val defaultChannelLayouts: Map = emptyMap(), + val flipWidthHeightIfPortrait: Boolean = true +) diff --git a/src/main/kotlin/se/svt/oss/encore/config/EncoreProperties.kt b/src/main/kotlin/se/svt/oss/encore/config/EncoreProperties.kt index 110dd36f..a6ebb001 100644 --- a/src/main/kotlin/se/svt/oss/encore/config/EncoreProperties.kt +++ b/src/main/kotlin/se/svt/oss/encore/config/EncoreProperties.kt @@ -12,13 +12,13 @@ import java.time.Duration @ConstructorBinding data class EncoreProperties( val localTemporaryEncode: Boolean = false, - val audioMixPresets: Map = mapOf("default" to AudioMixPreset()), val concurrency: Int = 2, val pollInitialDelay: Duration = Duration.ofSeconds(10), val pollDelay: Duration = Duration.ofSeconds(5), val redisKeyPrefix: String = "encore", val security: Security = Security(), - val openApi: OpenApi = OpenApi() + val openApi: OpenApi = OpenApi(), + val encoding: EncodingProperties = EncodingProperties() ) { data class Security( val enabled: Boolean = false, diff --git a/src/main/kotlin/se/svt/oss/encore/model/EncoreJob.kt b/src/main/kotlin/se/svt/oss/encore/model/EncoreJob.kt index 4848becf..37ab924d 100644 --- a/src/main/kotlin/se/svt/oss/encore/model/EncoreJob.kt +++ b/src/main/kotlin/se/svt/oss/encore/model/EncoreJob.kt @@ -28,41 +28,50 @@ import javax.validation.constraints.Positive data class EncoreJob( @Schema( - description = "The Encore Internal EncoreJob Identity", example = "fb2baa17-8972-451b-bb1e-1bc773283476", - accessMode = Schema.AccessMode.READ_ONLY, hidden = false, defaultValue = "A random UUID" + description = "The Encore Internal EncoreJob Identity", + example = "fb2baa17-8972-451b-bb1e-1bc773283476", + accessMode = Schema.AccessMode.READ_ONLY, + hidden = false, + defaultValue = "A random UUID" ) - @Id val id: UUID = UUID.randomUUID(), + @Id + val id: UUID = UUID.randomUUID(), @Schema( - description = "External id - for external backreference", example = "any-string", + description = "External id - for external backreference", + example = "any-string", nullable = true ) val externalId: String? = null, @Schema( description = "The name of the encoding profile to use", - example = "x264-animated", required = true + example = "x264-animated", + required = true ) @NotBlank val profile: String, @Schema( description = "A directory path to where the output should be written", - example = "/an/output/path/dir", required = true + example = "/an/output/path/dir", + required = true ) @NotBlank val outputFolder: String, @Schema( description = "Base filename of output files", - example = "any_file", required = true + example = "any_file", + required = true ) @NotBlank val baseName: String, @Schema( description = "The Creation date for the EncoreJob", - example = "2021-04-22T03:00:48.759168+02:00", accessMode = Schema.AccessMode.READ_ONLY, + example = "2021-04-22T03:00:48.759168+02:00", + accessMode = Schema.AccessMode.READ_ONLY, defaultValue = "now()" ) @Indexed @@ -70,13 +79,16 @@ data class EncoreJob( @Schema( description = "An url to which the progress status callback should be directed", - example = "http://projectx/encorecallback", nullable = true + example = "http://projectx/encorecallback", + nullable = true ) val progressCallbackUri: URI? = null, @Schema( description = "The queue priority of the EncoreJob", - defaultValue = "0", minimum = "0", maximum = "100" + defaultValue = "0", + minimum = "0", + maximum = "100" ) @Min(0) @Max(100) @@ -84,31 +96,41 @@ data class EncoreJob( @Schema( description = "The exception message, if the EncoreJob failed", - example = "input/output error", accessMode = Schema.AccessMode.READ_ONLY, nullable = true + example = "input/output error", + accessMode = Schema.AccessMode.READ_ONLY, + nullable = true ) var message: String? = null, @Schema( description = "The EncoreJob progress", - example = "57", accessMode = Schema.AccessMode.READ_ONLY, defaultValue = "0" + example = "57", + accessMode = Schema.AccessMode.READ_ONLY, + defaultValue = "0" ) var progress: Int = 0, @Schema( description = "The Encoding speed of the job (compared to it's play speed/input duration)", - example = "0.334", accessMode = Schema.AccessMode.READ_ONLY, nullable = true + example = "0.334", + accessMode = Schema.AccessMode.READ_ONLY, + nullable = true ) var speed: Double? = null, @Schema( description = "The time for when the EncoreJob was picked from the queue)", - example = "2021-04-19T07:20:43.819141+02:00", accessMode = Schema.AccessMode.READ_ONLY, nullable = true + example = "2021-04-19T07:20:43.819141+02:00", + accessMode = Schema.AccessMode.READ_ONLY, + nullable = true ) var startedDate: OffsetDateTime? = null, @Schema( description = "The time for when the EncoreJob was completed (fail or success)", - example = "2021-04-19T07:20:43.819141+02:00", accessMode = Schema.AccessMode.READ_ONLY, nullable = true + example = "2021-04-19T07:20:43.819141+02:00", + accessMode = Schema.AccessMode.READ_ONLY, + nullable = true ) var completedDate: OffsetDateTime? = null, @@ -119,7 +141,8 @@ data class EncoreJob( val debugOverlay: Boolean = false, @Schema( - description = "Key/Values to append to the MDC log context", defaultValue = "{}" + description = "Key/Values to append to the MDC log context", + defaultValue = "{}" ) val logContext: Map = emptyMap(), @@ -131,7 +154,8 @@ data class EncoreJob( @Schema( description = "Time in seconds for when the thumbnail should be picked. Overrides profile configuration for thumbnails", - example = "1800.5", nullable = true + example = "1800.5", + nullable = true ) @Positive val thumbnailTime: Double? = null, @@ -150,7 +174,6 @@ data class EncoreJob( description = "The Job Status", accessMode = Schema.AccessMode.READ_ONLY ) - @Indexed var status = Status.NEW set(value) { diff --git a/src/main/kotlin/se/svt/oss/encore/model/input/Input.kt b/src/main/kotlin/se/svt/oss/encore/model/input/Input.kt index 30b7b3cf..95bc460b 100644 --- a/src/main/kotlin/se/svt/oss/encore/model/input/Input.kt +++ b/src/main/kotlin/se/svt/oss/encore/model/input/Input.kt @@ -9,12 +9,12 @@ import com.fasterxml.jackson.annotation.JsonSubTypes import com.fasterxml.jackson.annotation.JsonTypeInfo import io.swagger.v3.oas.annotations.media.Schema import se.svt.oss.encore.model.mediafile.toParams +import se.svt.oss.encore.model.profile.ChannelLayout import se.svt.oss.mediaanalyzer.file.FractionString import se.svt.oss.mediaanalyzer.file.MediaContainer import se.svt.oss.mediaanalyzer.file.MediaFile import se.svt.oss.mediaanalyzer.file.VideoFile import javax.validation.constraints.Pattern -import javax.validation.constraints.Positive import javax.validation.constraints.PositiveOrZero const val TYPE_AUDIO_VIDEO = "AudioVideo" @@ -38,7 +38,11 @@ sealed interface Input { @get:Schema(description = "Input params required to properly decode input", example = """{ "ac": "2" }""") val params: LinkedHashMap - @get:Schema(description = "Type of input", allowableValues = [TYPE_AUDIO_VIDEO, TYPE_VIDEO, TYPE_AUDIO], required = true) + @get:Schema( + description = "Type of input", + allowableValues = [TYPE_AUDIO_VIDEO, TYPE_VIDEO, TYPE_AUDIO], + required = true + ) val type: String @get:Schema( @@ -59,20 +63,22 @@ sealed interface AudioIn : Input { @get:Schema( description = "Label of the input to be matched with a profile output", - example = "dub", defaultValue = DEFAULT_AUDIO_LABEL + example = "dub", + defaultValue = DEFAULT_AUDIO_LABEL ) val audioLabel: String @get:Schema( - description = "Use only the number audio input streams up to the given value", - example = "2", nullable = true + description = "Hint for channel layout when input has mono audio streams. If input has less channels than specified channel layout a default channel will be used.", + example = "5.1", + nullable = true ) - @get:Positive - val useFirstAudioStreams: Int? + val channelLayout: ChannelLayout? @get:Schema( description = "List of FFmpeg filters to apply to all audio outputs", - example = "to-do", defaultValue = "[]" + example = "to-do", + defaultValue = "[]" ) val audioFilters: List @@ -80,7 +86,8 @@ sealed interface AudioIn : Input { @get:Schema( description = "The index of the audio stream to be used as input", - example = "1", nullable = true + example = "1", + nullable = true ) @get:PositiveOrZero val audioStream: Int? @@ -89,28 +96,32 @@ sealed interface AudioIn : Input { sealed interface VideoIn : Input { @get:Schema( description = "Label of the input to be matched with a profile output", - example = "sign", defaultValue = DEFAULT_VIDEO_LABEL + example = "sign", + defaultValue = DEFAULT_VIDEO_LABEL ) val videoLabel: String @get:Schema( description = "The Display Aspect Ratio to use if the input is anamorphic." + " Overrides DAR found from input metadata (for corrupt video metadata)", - example = "16:9", nullable = true + example = "16:9", + nullable = true ) @get:Pattern(regexp = AR_REGEX, message = AR_MESSAGE) val dar: FractionString? @get:Schema( description = "Crop input video to given aspect ratio", - example = "1:1", nullable = true + example = "1:1", + nullable = true ) @get:Pattern(regexp = AR_REGEX, message = AR_MESSAGE) val cropTo: FractionString? @get:Schema( description = "Pad input video to given aspect ratio", - example = "16:9", nullable = true + example = "16:9", + nullable = true ) @get:Pattern(regexp = AR_REGEX, message = AR_MESSAGE) val padTo: FractionString? @@ -126,7 +137,8 @@ sealed interface VideoIn : Input { @get:Schema( description = "The index of the video stream to be used as input", - example = "1", nullable = true + example = "1", + nullable = true ) @get:PositiveOrZero val videoStream: Int? @@ -137,10 +149,10 @@ data class AudioInput( override val uri: String, override val audioLabel: String = DEFAULT_AUDIO_LABEL, override val params: LinkedHashMap = linkedMapOf(), - override val useFirstAudioStreams: Int? = null, override val audioFilters: List = emptyList(), override var analyzed: MediaFile? = null, override val audioStream: Int? = null, + override val channelLayout: ChannelLayout? = null, override val seekTo: Double? = null ) : AudioIn { override val analyzedAudio: MediaContainer @@ -186,7 +198,6 @@ data class AudioVideoInput( override val audioLabel: String = DEFAULT_AUDIO_LABEL, override val params: LinkedHashMap = linkedMapOf(), override val dar: FractionString? = null, - override val useFirstAudioStreams: Int? = null, override val cropTo: FractionString? = null, override val padTo: FractionString? = null, override val videoFilters: List = emptyList(), @@ -195,6 +206,7 @@ data class AudioVideoInput( override val videoStream: Int? = null, override val audioStream: Int? = null, override val probeInterlaced: Boolean = true, + override val channelLayout: ChannelLayout? = null, override val seekTo: Double? = null ) : VideoIn, AudioIn { override val analyzedVideo: VideoFile @@ -229,16 +241,17 @@ fun List.maxDuration(): Double? = maxOfOrNull { } - (it.seekTo ?: 0.0) } -fun List.analyzedAudio(label: String): MediaContainer? { +fun List.audioInput(label: String): AudioIn? { val audioInputs = filterIsInstance() require(audioInputs.distinctBy { it.audioLabel }.size == audioInputs.size) { "Inputs contains duplicate audio labels!" } return audioInputs .find { it.audioLabel == label } - ?.analyzedAudio } +fun List.analyzedAudio(label: String): MediaContainer? = audioInput(label)?.analyzedAudio + fun List.videoInput(label: String): VideoIn? { val videoInputs = filterIsInstance() require(videoInputs.distinctBy { it.videoLabel }.size == videoInputs.size) { diff --git a/src/main/kotlin/se/svt/oss/encore/model/mediafile/Extensions.kt b/src/main/kotlin/se/svt/oss/encore/model/mediafile/Extensions.kt index 0a780e92..40aa36a5 100644 --- a/src/main/kotlin/se/svt/oss/encore/model/mediafile/Extensions.kt +++ b/src/main/kotlin/se/svt/oss/encore/model/mediafile/Extensions.kt @@ -5,6 +5,8 @@ package se.svt.oss.encore.model.mediafile import mu.KotlinLogging +import se.svt.oss.encore.model.input.AudioIn +import se.svt.oss.encore.model.profile.ChannelLayout import se.svt.oss.mediaanalyzer.file.AudioFile import se.svt.oss.mediaanalyzer.file.MediaContainer import se.svt.oss.mediaanalyzer.file.VideoFile @@ -19,23 +21,45 @@ fun MediaContainer.audioLayout() = when { else -> AudioLayout.INVALID } -fun MediaContainer.channelCount() = if (audioLayout() == AudioLayout.MULTI_TRACK) +fun MediaContainer.channelCount() = if (audioLayout() == AudioLayout.MULTI_TRACK) { audioStreams.first().channels -else - audioStreams.sumOf { it.channels } +} else { + audioStreams.size +} + +fun AudioIn.channelLayout(defaultChannelLayouts: Map): ChannelLayout { + return when (analyzedAudio.audioLayout()) { + AudioLayout.NONE, AudioLayout.INVALID -> null + AudioLayout.MONO_STREAMS -> if (analyzedAudio.channelCount() == channelLayout?.channels?.size) { + channelLayout + } else { + defaultChannelLayouts[analyzedAudio.channelCount()] + ?: ChannelLayout.defaultChannelLayout(analyzedAudio.channelCount()) + } + + AudioLayout.MULTI_TRACK -> analyzedAudio.audioStreams.first().channelLayout + ?.let { ChannelLayout.getByNameOrNull(it) } + ?: defaultChannelLayouts[analyzedAudio.channelCount()] + ?: ChannelLayout.defaultChannelLayout(analyzedAudio.channelCount()) + } ?: throw RuntimeException("Could not determine channel layout for audio input '$audioLabel'!") +} fun VideoFile.trimAudio(keep: Int?): VideoFile { return if (keep != null && keep < audioStreams.size) { log.debug { "Using first $keep audio streams of ${audioStreams.size} of ${this.file}" } copy(audioStreams = audioStreams.take(keep)) - } else this + } else { + this + } } fun AudioFile.trimAudio(keep: Int?): AudioFile { return if (keep != null && keep < audioStreams.size) { log.debug { "Using first $keep audio streams of ${audioStreams.size} of ${this.file}" } copy(audioStreams = audioStreams.take(keep)) - } else this + } else { + this + } } fun VideoFile.selectVideoStream(index: Int?): VideoFile { diff --git a/src/main/kotlin/se/svt/oss/encore/model/output/Output.kt b/src/main/kotlin/se/svt/oss/encore/model/output/Output.kt index 4ca21b7c..3c938c52 100644 --- a/src/main/kotlin/se/svt/oss/encore/model/output/Output.kt +++ b/src/main/kotlin/se/svt/oss/encore/model/output/Output.kt @@ -39,6 +39,7 @@ data class AudioStreamEncode( override val params: List, override val filter: String? = null, override val inputLabels: List, + val preserveLayout: Boolean = false ) : StreamEncode { override val twoPass: Boolean get() = false diff --git a/src/main/kotlin/se/svt/oss/encore/model/profile/AudioEncode.kt b/src/main/kotlin/se/svt/oss/encore/model/profile/AudioEncode.kt index c0f0fb2a..ed6abd43 100644 --- a/src/main/kotlin/se/svt/oss/encore/model/profile/AudioEncode.kt +++ b/src/main/kotlin/se/svt/oss/encore/model/profile/AudioEncode.kt @@ -4,14 +4,14 @@ package se.svt.oss.encore.model.profile -import mu.KotlinLogging -import se.svt.oss.encore.config.AudioMixPreset +import se.svt.oss.encore.config.EncodingProperties import se.svt.oss.encore.model.input.DEFAULT_AUDIO_LABEL import se.svt.oss.encore.model.EncoreJob -import se.svt.oss.encore.model.input.analyzedAudio +import se.svt.oss.encore.model.input.audioInput import se.svt.oss.encore.model.mediafile.AudioLayout import se.svt.oss.encore.model.mediafile.audioLayout import se.svt.oss.encore.model.mediafile.channelCount +import se.svt.oss.encore.model.mediafile.channelLayout import se.svt.oss.encore.model.mediafile.toParams import se.svt.oss.encore.model.output.AudioStreamEncode import se.svt.oss.encore.model.output.Output @@ -20,38 +20,50 @@ data class AudioEncode( val codec: String = "libfdk_aac", val bitrate: String? = null, val samplerate: Int = 48000, - val channels: Int = 2, - val suffix: String = "_${codec}_${channels}ch", + val channelLayout: ChannelLayout = ChannelLayout.CH_LAYOUT_STEREO, + val suffix: String = "_${codec}_${channelLayout.layoutName}", val params: LinkedHashMap = linkedMapOf(), val filters: List = emptyList(), val audioMixPreset: String = "default", - val optional: Boolean = false, + override val optional: Boolean = false, val format: String = "mp4", val inputLabel: String = DEFAULT_AUDIO_LABEL, -) : OutputProducer { +) : AudioEncoder() { - private val log = KotlinLogging.logger { } - - override fun getOutput(job: EncoreJob, audioMixPresets: Map): Output? { + override fun getOutput(job: EncoreJob, encodingProperties: EncodingProperties): Output? { val outputName = "${job.baseName}$suffix.$format" - val analyzed = job.inputs.analyzedAudio(inputLabel) + val audioIn = job.inputs.audioInput(inputLabel) ?: return logOrThrow("Can not generate $outputName! No audio input with label '$inputLabel'.") + val analyzed = audioIn.analyzedAudio if (analyzed.audioLayout() == AudioLayout.INVALID) { throw RuntimeException("Audio layout of audio input '$inputLabel' is not supported!") } - val inputChannels = analyzed.channelCount() - val preset = audioMixPresets[audioMixPreset] + if (analyzed.audioLayout() == AudioLayout.NONE) { + return logOrThrow("Can not generate $outputName! No audio streams in input!") + } + val preset = encodingProperties.audioMixPresets[audioMixPreset] ?: throw RuntimeException("Audio mix preset '$audioMixPreset' not found!") - val pan = preset.panMapping[inputChannels]?.get(channels) - ?: if (channels <= inputChannels) preset.defaultPan[channels] else null + val inputChannels = analyzed.channelCount() + val inputChannelLayout = audioIn.channelLayout(encodingProperties.defaultChannelLayouts) + + val mixFilters = mutableListOf() + + val pan = preset.panMapping[inputChannelLayout]?.get(channelLayout) + ?: if (channelLayout.channels.size <= inputChannelLayout.channels.size) { + preset.defaultPan[channelLayout] + } else { + null + } val outParams = linkedMapOf() if (pan == null) { if (preset.fallbackToAuto && isApplicable(inputChannels)) { - outParams["ac:a:{stream_index}"] = channels + mixFilters.add("aformat=channel_layouts=${channelLayout.layoutName}") } else { - return logOrThrow("Can not generate $outputName! No audio mix preset for '$audioMixPreset': $inputChannels -> $channels channels!") + return logOrThrow("Can not generate $outputName! No audio mix preset for '$audioMixPreset': ${inputChannelLayout.layoutName} -> ${channelLayout.layoutName}!") } + } else { + mixFilters.add("pan=${channelLayout.layoutName}|$pan") } outParams["c:a:{stream_index}"] = codec outParams["ar:a:{stream_index}"] = samplerate @@ -65,28 +77,14 @@ data class AudioEncode( AudioStreamEncode( params = outParams.toParams(), inputLabels = listOf(inputLabel), - filter = filtersToString(pan) + filter = (mixFilters + filters).joinToString(",").ifEmpty { null } ) ), output = outputName, ) } - private fun filtersToString(pan: String?): String? { - val allFilters = pan?.let { listOf("pan=$it") + filters } ?: filters - return if (allFilters.isEmpty()) null else allFilters.joinToString(",") - } - private fun isApplicable(channelCount: Int): Boolean { - return channelCount > 0 && (channels == 2 || channels in 1..channelCount) - } - - private fun logOrThrow(message: String): Output? { - if (optional) { - log.info { message } - return null - } else { - throw RuntimeException(message) - } + return channelCount > 0 && (channelLayout == ChannelLayout.CH_LAYOUT_STEREO || channelLayout.channels.size in 1..channelCount) } } diff --git a/src/main/kotlin/se/svt/oss/encore/model/profile/AudioEncoder.kt b/src/main/kotlin/se/svt/oss/encore/model/profile/AudioEncoder.kt new file mode 100644 index 00000000..5bdcd706 --- /dev/null +++ b/src/main/kotlin/se/svt/oss/encore/model/profile/AudioEncoder.kt @@ -0,0 +1,23 @@ +// SPDX-FileCopyrightText: 2020 Sveriges Television AB +// +// SPDX-License-Identifier: EUPL-1.2 + +package se.svt.oss.encore.model.profile + +import mu.KotlinLogging +import se.svt.oss.encore.model.output.Output + +abstract class AudioEncoder : OutputProducer { + private val log = KotlinLogging.logger { } + + abstract val optional: Boolean + + fun logOrThrow(message: String): Output? { + if (optional) { + log.info { message } + return null + } else { + throw RuntimeException(message) + } + } +} diff --git a/src/main/kotlin/se/svt/oss/encore/model/profile/ChannelId.kt b/src/main/kotlin/se/svt/oss/encore/model/profile/ChannelId.kt new file mode 100644 index 00000000..de9d040b --- /dev/null +++ b/src/main/kotlin/se/svt/oss/encore/model/profile/ChannelId.kt @@ -0,0 +1,38 @@ +// SPDX-FileCopyrightText: 2020 Sveriges Television AB +// +// SPDX-License-Identifier: EUPL-1.2 + +package se.svt.oss.encore.model.profile + +enum class ChannelId { + FL, + FR, + FC, + LFE, + BL, + BR, + FLC, + FRC, + BC, + SL, + SR, + TC, + TFL, + TFC, + TFR, + TBL, + TBC, + TBR, + DL, + DR, + WL, + WR, + SDL, + SDR, + LFE2, + TSL, + TSR, + BFC, + BFL, + BFR +} diff --git a/src/main/kotlin/se/svt/oss/encore/model/profile/ChannelLayout.kt b/src/main/kotlin/se/svt/oss/encore/model/profile/ChannelLayout.kt new file mode 100644 index 00000000..c3a5bb1c --- /dev/null +++ b/src/main/kotlin/se/svt/oss/encore/model/profile/ChannelLayout.kt @@ -0,0 +1,105 @@ +// SPDX-FileCopyrightText: 2020 Sveriges Television AB +// +// SPDX-License-Identifier: EUPL-1.2 + +package se.svt.oss.encore.model.profile + +import com.fasterxml.jackson.annotation.JsonValue +import se.svt.oss.encore.model.profile.ChannelId.BC +import se.svt.oss.encore.model.profile.ChannelId.BFC +import se.svt.oss.encore.model.profile.ChannelId.BFL +import se.svt.oss.encore.model.profile.ChannelId.BFR +import se.svt.oss.encore.model.profile.ChannelId.BL +import se.svt.oss.encore.model.profile.ChannelId.BR +import se.svt.oss.encore.model.profile.ChannelId.DL +import se.svt.oss.encore.model.profile.ChannelId.DR +import se.svt.oss.encore.model.profile.ChannelId.FC +import se.svt.oss.encore.model.profile.ChannelId.FL +import se.svt.oss.encore.model.profile.ChannelId.FLC +import se.svt.oss.encore.model.profile.ChannelId.FR +import se.svt.oss.encore.model.profile.ChannelId.FRC +import se.svt.oss.encore.model.profile.ChannelId.LFE +import se.svt.oss.encore.model.profile.ChannelId.LFE2 +import se.svt.oss.encore.model.profile.ChannelId.SL +import se.svt.oss.encore.model.profile.ChannelId.SR +import se.svt.oss.encore.model.profile.ChannelId.TBC +import se.svt.oss.encore.model.profile.ChannelId.TBL +import se.svt.oss.encore.model.profile.ChannelId.TBR +import se.svt.oss.encore.model.profile.ChannelId.TC +import se.svt.oss.encore.model.profile.ChannelId.TFC +import se.svt.oss.encore.model.profile.ChannelId.TFL +import se.svt.oss.encore.model.profile.ChannelId.TFR +import se.svt.oss.encore.model.profile.ChannelId.TSL +import se.svt.oss.encore.model.profile.ChannelId.TSR +import se.svt.oss.encore.model.profile.ChannelId.WL +import se.svt.oss.encore.model.profile.ChannelId.WR + +enum class ChannelLayout(@JsonValue val layoutName: String, val channels: List) { + CH_LAYOUT_MONO("mono", listOf(FC)), + CH_LAYOUT_STEREO("stereo", listOf(FL, FR)), + CH_LAYOUT_2POINT1("2.1", listOf(FL, FR, LFE)), + CH_LAYOUT_3POINT0("3.0", listOf(FL, FR, FC)), + CH_LAYOUT_3POINT0_BACK("3.0(back)", listOf(FL, FR, BC)), + CH_LAYOUT_4POINT0("4.0", listOf(FL, FR, FC, BC)), + CH_LAYOUT_QUAD("quad", listOf(FL, FR, BL, BR)), + CH_LAYOUT_QUAD_SIDE("quad(side)", listOf(FL, FR, SL, SR)), + CH_LAYOUT_3POINT1("3.1", listOf(FL, FR, FC, LFE)), + CH_LAYOUT_5POINT0("5.0", listOf(FL, FR, FC, BL, BR)), + CH_LAYOUT_5POINT0_SIDE("5.0(side)", listOf(FL, FR, FC, SL, SR)), + CH_LAYOUT_4POINT1("4.1", listOf(FL, FR, FC, LFE, BC)), + CH_LAYOUT_5POINT1("5.1", listOf(FL, FR, FC, LFE, BL, BR)), + CH_LAYOUT_5POINT1_SIDE("5.1(side)", listOf(FL, FR, FC, LFE, SL, SR)), + CH_LAYOUT_6POINT0("6.0", listOf(FL, FR, FC, BC, SL, SR)), + CH_LAYOUT_6POINT0_FRONT("6.0(front)", listOf(FL, FR, FLC, FRC, SL, SR)), + CH_LAYOUT_HEXAGONAL("hexagonal", listOf(FL, FR, FC, BL, BR, BC)), + CH_LAYOUT_6POINT1("6.1", listOf(FL, FR, FC, LFE, BC, SL, SR)), + CH_LAYOUT_6POINT1_BACK("6.1(back)", listOf(FL, FR, FC, LFE, BL, BR, BC)), + CH_LAYOUT_6POINT1_FRONT("6.1(front)", listOf(FL, FR, LFE, FLC, FRC, SL, SR)), + CH_LAYOUT_7POINT0("7.0", listOf(FL, FR, FC, BL, BR, SL, SR)), + CH_LAYOUT_7POINT0_FRONT("7.0(front)", listOf(FL, FR, FC, FLC, FRC, SL, SR)), + CH_LAYOUT_7POINT1("7.1", listOf(FL, FR, FC, LFE, BL, BR, SL, SR)), + CH_LAYOUT_7POINT1_WIDE("7.1(wide)", listOf(FL, FR, FC, LFE, BL, BR, FLC, FRC)), + CH_LAYOUT_7POINT1_WIDE_SIDE("7.1(wide-side)", listOf(FL, FR, FC, LFE, FLC, FRC, SL, SR)), + CH_LAYOUT_OCTAGONAL("octagonal)", listOf(FL, FR, FC, BL, BR, BC, SL, SR)), + CH_LAYOUT_HEXADECAGONAL( + "hexadecagonal)", + listOf( + FL, FR, FC, BL, BR, BC, SL, SR, TFL, TFC, TFR, TBL, TBC, TBR, WL, WR + ) + ), + CH_LAYOUT_DOWNMIX("downmix)", listOf(DL, DR)), + CH_LAYOUT_22POINT2( + "22.2", + listOf( + FL, + FR, + FC, + LFE, + BL, + BR, + FLC, + FRC, + BC, + SL, + SR, + TC, + TFL, + TFC, + TFR, + TBL, + TBC, + TBR, + LFE2, + TSL, + TSR, + BFC, + BFL, + BFR + ) + ); + + companion object { + fun defaultChannelLayout(numChannels: Int) = values().firstOrNull { it.channels.size == numChannels } + fun getByNameOrNull(layoutName: String) = values().firstOrNull { it.layoutName == layoutName } + } +} diff --git a/src/main/kotlin/se/svt/oss/encore/model/profile/GenericVideoEncode.kt b/src/main/kotlin/se/svt/oss/encore/model/profile/GenericVideoEncode.kt index 6afa0353..f99f44fb 100644 --- a/src/main/kotlin/se/svt/oss/encore/model/profile/GenericVideoEncode.kt +++ b/src/main/kotlin/se/svt/oss/encore/model/profile/GenericVideoEncode.kt @@ -12,8 +12,8 @@ data class GenericVideoEncode( override val twoPass: Boolean, override val params: LinkedHashMap, override val filters: List = emptyList(), - override val audioEncode: AudioEncode?, - override val audioEncodes: List = emptyList(), + override val audioEncode: AudioEncoder?, + override val audioEncodes: List = emptyList(), override val suffix: String, override val format: String, override val codec: String, diff --git a/src/main/kotlin/se/svt/oss/encore/model/profile/OutputProducer.kt b/src/main/kotlin/se/svt/oss/encore/model/profile/OutputProducer.kt index f207eeac..a7df3f8d 100644 --- a/src/main/kotlin/se/svt/oss/encore/model/profile/OutputProducer.kt +++ b/src/main/kotlin/se/svt/oss/encore/model/profile/OutputProducer.kt @@ -6,13 +6,14 @@ package se.svt.oss.encore.model.profile import com.fasterxml.jackson.annotation.JsonSubTypes import com.fasterxml.jackson.annotation.JsonTypeInfo -import se.svt.oss.encore.config.AudioMixPreset +import se.svt.oss.encore.config.EncodingProperties import se.svt.oss.encore.model.EncoreJob import se.svt.oss.encore.model.output.Output @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type") @JsonSubTypes( JsonSubTypes.Type(value = AudioEncode::class, name = "AudioEncode"), + JsonSubTypes.Type(value = SimpleAudioEncode::class, name = "SimpleAudioEncode"), JsonSubTypes.Type(value = X264Encode::class, name = "X264Encode"), JsonSubTypes.Type(value = X265Encode::class, name = "X265Encode"), JsonSubTypes.Type(value = GenericVideoEncode::class, name = "VideoEncode"), @@ -20,5 +21,5 @@ import se.svt.oss.encore.model.output.Output JsonSubTypes.Type(value = ThumbnailMapEncode::class, name = "ThumbnailMapEncode") ) interface OutputProducer { - fun getOutput(job: EncoreJob, audioMixPresets: Map): Output? + fun getOutput(job: EncoreJob, encodingProperties: EncodingProperties): Output? } diff --git a/src/main/kotlin/se/svt/oss/encore/model/profile/SimpleAudioEncode.kt b/src/main/kotlin/se/svt/oss/encore/model/profile/SimpleAudioEncode.kt new file mode 100644 index 00000000..0c697de3 --- /dev/null +++ b/src/main/kotlin/se/svt/oss/encore/model/profile/SimpleAudioEncode.kt @@ -0,0 +1,48 @@ +// SPDX-FileCopyrightText: 2020 Sveriges Television AB +// +// SPDX-License-Identifier: EUPL-1.2 + +package se.svt.oss.encore.model.profile + +import se.svt.oss.encore.config.EncodingProperties +import se.svt.oss.encore.model.EncoreJob +import se.svt.oss.encore.model.input.DEFAULT_AUDIO_LABEL +import se.svt.oss.encore.model.input.analyzedAudio +import se.svt.oss.encore.model.mediafile.toParams +import se.svt.oss.encore.model.output.AudioStreamEncode +import se.svt.oss.encore.model.output.Output + +data class SimpleAudioEncode( + val codec: String = "libfdk_aac", + val bitrate: String? = null, + val samplerate: Int? = null, + val suffix: String = "_$codec", + val params: LinkedHashMap = linkedMapOf(), + override val optional: Boolean = false, + val format: String = "mp4", + val inputLabel: String = DEFAULT_AUDIO_LABEL, +) : AudioEncoder() { + override fun getOutput(job: EncoreJob, encodingProperties: EncodingProperties): Output? { + val outputName = "${job.baseName}$suffix.$format" + job.inputs.analyzedAudio(inputLabel) + ?: return logOrThrow("Can not generate $outputName! No audio input with label '$inputLabel'.") + val outParams = linkedMapOf() + outParams += params + outParams["c:a"] = codec + samplerate?.let { outParams["ar"] = it } + bitrate?.let { outParams["b:a"] = it } + + return Output( + id = "$suffix.$format", + video = null, + audioStreams = listOf( + AudioStreamEncode( + params = outParams.toParams(), + inputLabels = listOf(inputLabel), + preserveLayout = true + ) + ), + output = outputName, + ) + } +} diff --git a/src/main/kotlin/se/svt/oss/encore/model/profile/ThumbnailEncode.kt b/src/main/kotlin/se/svt/oss/encore/model/profile/ThumbnailEncode.kt index e2d71c0d..ecfa6fbb 100644 --- a/src/main/kotlin/se/svt/oss/encore/model/profile/ThumbnailEncode.kt +++ b/src/main/kotlin/se/svt/oss/encore/model/profile/ThumbnailEncode.kt @@ -5,7 +5,7 @@ package se.svt.oss.encore.model.profile import mu.KotlinLogging -import se.svt.oss.encore.config.AudioMixPreset +import se.svt.oss.encore.config.EncodingProperties import se.svt.oss.encore.model.EncoreJob import se.svt.oss.encore.model.input.DEFAULT_VIDEO_LABEL import se.svt.oss.encore.model.input.videoInput @@ -27,7 +27,7 @@ data class ThumbnailEncode( private val log = KotlinLogging.logger { } - override fun getOutput(job: EncoreJob, audioMixPresets: Map): Output? { + override fun getOutput(job: EncoreJob, encodingProperties: EncodingProperties): Output? { val videoInput = job.inputs.videoInput(inputLabel) val inputSeekTo = videoInput?.seekTo val videoStream = videoInput?.analyzedVideo?.highestBitrateVideoStream @@ -36,7 +36,9 @@ data class ThumbnailEncode( val frameRate = videoStream.frameRate.toFractionOrNull()?.toDouble() ?: if (job.duration != null || job.seekTo != null || job.thumbnailTime != null || inputSeekTo != null) { return logOrThrow("Can not produce thumbnail $suffix! No framerate detected in video input $inputLabel.") - } else 0.0 + } else { + 0.0 + } val numFrames = job.duration?.let { round(it * frameRate).toInt() } ?: ((videoStream.numFrames) - (inputSeekTo?.let { round(it * frameRate).toInt() } ?: 0)) val skipFrames = job.seekTo?.let { round(it * frameRate).toInt() } ?: 0 diff --git a/src/main/kotlin/se/svt/oss/encore/model/profile/ThumbnailMapEncode.kt b/src/main/kotlin/se/svt/oss/encore/model/profile/ThumbnailMapEncode.kt index 6e680bd2..59bcde2d 100644 --- a/src/main/kotlin/se/svt/oss/encore/model/profile/ThumbnailMapEncode.kt +++ b/src/main/kotlin/se/svt/oss/encore/model/profile/ThumbnailMapEncode.kt @@ -6,7 +6,7 @@ package se.svt.oss.encore.model.profile import mu.KotlinLogging import org.apache.commons.math3.fraction.Fraction -import se.svt.oss.encore.config.AudioMixPreset +import se.svt.oss.encore.config.EncodingProperties import se.svt.oss.encore.model.EncoreJob import se.svt.oss.encore.model.input.DEFAULT_VIDEO_LABEL import se.svt.oss.encore.model.input.analyzedVideo @@ -31,10 +31,9 @@ data class ThumbnailMapEncode( private val log = KotlinLogging.logger { } - override fun getOutput(job: EncoreJob, audioMixPresets: Map): Output? { + override fun getOutput(job: EncoreJob, encodingProperties: EncodingProperties): Output? { val videoInput = job.inputs.videoInput(inputLabel) val inputSeekTo = videoInput?.seekTo - val videoStream = job.inputs.analyzedVideo(inputLabel)?.highestBitrateVideoStream ?: return logOrThrow("No input with label $inputLabel!") diff --git a/src/main/kotlin/se/svt/oss/encore/model/profile/VideoEncode.kt b/src/main/kotlin/se/svt/oss/encore/model/profile/VideoEncode.kt index 117221a0..83791d21 100644 --- a/src/main/kotlin/se/svt/oss/encore/model/profile/VideoEncode.kt +++ b/src/main/kotlin/se/svt/oss/encore/model/profile/VideoEncode.kt @@ -4,11 +4,15 @@ package se.svt.oss.encore.model.profile -import se.svt.oss.encore.config.AudioMixPreset +import org.apache.commons.math3.fraction.Fraction +import se.svt.oss.encore.config.EncodingProperties import se.svt.oss.encore.model.EncoreJob +import se.svt.oss.encore.model.input.analyzedVideo import se.svt.oss.encore.model.mediafile.toParams import se.svt.oss.encore.model.output.Output import se.svt.oss.encore.model.output.VideoStreamEncode +import se.svt.oss.mediaanalyzer.file.VideoStream +import se.svt.oss.mediaanalyzer.file.toFractionOrNull interface VideoEncode : OutputProducer { val width: Int? @@ -16,16 +20,18 @@ interface VideoEncode : OutputProducer { val twoPass: Boolean val params: Map val filters: List? - val audioEncode: AudioEncode? - val audioEncodes: List + val audioEncode: AudioEncoder? + val audioEncodes: List val suffix: String val format: String val codec: String val inputLabel: String - override fun getOutput(job: EncoreJob, audioMixPresets: Map): Output? { + override fun getOutput(job: EncoreJob, encodingProperties: EncodingProperties): Output? { val audioEncodesToUse = audioEncodes.ifEmpty { listOfNotNull(audioEncode) } - val audio = audioEncodesToUse.flatMap { it.getOutput(job, audioMixPresets)?.audioStreams.orEmpty() } + val audio = audioEncodesToUse.flatMap { it.getOutput(job, encodingProperties)?.audioStreams.orEmpty() } + val videoInput = job.inputs.analyzedVideo(inputLabel)?.highestBitrateVideoStream + ?: throw RuntimeException("No valid video input with label $inputLabel!") return Output( id = "$suffix.$format", video = VideoStreamEncode( @@ -33,7 +39,7 @@ interface VideoEncode : OutputProducer { firstPassParams = firstPassParams().toParams(), inputLabels = listOf(inputLabel), twoPass = twoPass, - filter = videoFilter(job.debugOverlay), + filter = videoFilter(job.debugOverlay, encodingProperties, videoInput), ), audioStreams = audio, output = "${job.baseName}$suffix.$format" @@ -43,25 +49,43 @@ interface VideoEncode : OutputProducer { fun firstPassParams(): Map { return if (!twoPass) { emptyMap() - } else params + Pair("c:v", codec) + passParams(1) + } else { + params + Pair("c:v", codec) + passParams(1) + } } fun secondPassParams(): Map { return if (!twoPass) { params + Pair("c:v", codec) - } else params + Pair("c:v", codec) + passParams(2) + } else { + params + Pair("c:v", codec) + passParams(2) + } } fun passParams(pass: Int): Map = mapOf("pass" to pass.toString(), "passlogfile" to "log$suffix") - private fun videoFilter(debugOverlay: Boolean): String? { + private fun videoFilter( + debugOverlay: Boolean, + encodingProperties: EncodingProperties, + videoInput: VideoStream + ): String? { val videoFilters = mutableListOf() - if (width != null && height != null) { - videoFilters.add("scale=$width:$height:force_original_aspect_ratio=decrease:force_divisible_by=2") + var scaleToWidth = width + var scaleToHeight = height + val inputDar = videoInput.displayAspectRatio?.toFractionOrNull() + val inputIsPortrait = inputDar != null && inputDar < Fraction.ONE + val isScalingWithinLandscape = + scaleToWidth != null && scaleToHeight != null && Fraction(scaleToWidth, scaleToHeight) > Fraction.ONE + if (encodingProperties.flipWidthHeightIfPortrait && inputIsPortrait && isScalingWithinLandscape) { + scaleToWidth = height + scaleToHeight = width + } + if (scaleToWidth != null && scaleToHeight != null) { + videoFilters.add("scale=$scaleToWidth:$scaleToHeight:force_original_aspect_ratio=decrease:force_divisible_by=2") videoFilters.add("setsar=1/1") - } else if (width != null || height != null) { - videoFilters.add("scale=${width ?: -2}:${height ?: -2}") + } else if (scaleToWidth != null || scaleToHeight != null) { + videoFilters.add("scale=${scaleToWidth ?: -2}:${scaleToHeight ?: -2}") } filters?.let { videoFilters.addAll(it) } if (debugOverlay) { diff --git a/src/main/kotlin/se/svt/oss/encore/model/profile/X264Encode.kt b/src/main/kotlin/se/svt/oss/encore/model/profile/X264Encode.kt index e854bc38..7535cfde 100644 --- a/src/main/kotlin/se/svt/oss/encore/model/profile/X264Encode.kt +++ b/src/main/kotlin/se/svt/oss/encore/model/profile/X264Encode.kt @@ -16,8 +16,8 @@ data class X264Encode( @JsonProperty("x264-params") override val codecParams: LinkedHashMap = linkedMapOf(), override val filters: List = emptyList(), - override val audioEncode: AudioEncode? = null, - override val audioEncodes: List = emptyList(), + override val audioEncode: AudioEncoder? = null, + override val audioEncodes: List = emptyList(), override val suffix: String, override val format: String = "mp4", override val inputLabel: String = DEFAULT_VIDEO_LABEL diff --git a/src/main/kotlin/se/svt/oss/encore/model/profile/X265Encode.kt b/src/main/kotlin/se/svt/oss/encore/model/profile/X265Encode.kt index 30f1d531..ba77dc48 100644 --- a/src/main/kotlin/se/svt/oss/encore/model/profile/X265Encode.kt +++ b/src/main/kotlin/se/svt/oss/encore/model/profile/X265Encode.kt @@ -16,8 +16,8 @@ data class X265Encode( @JsonProperty("x265-params") override val codecParams: LinkedHashMap = linkedMapOf(), override val filters: List = emptyList(), - override val audioEncode: AudioEncode? = null, - override val audioEncodes: List = emptyList(), + override val audioEncode: AudioEncoder? = null, + override val audioEncodes: List = emptyList(), override val suffix: String, override val format: String = "mp4", override val inputLabel: String = DEFAULT_VIDEO_LABEL diff --git a/src/main/kotlin/se/svt/oss/encore/model/queue/QueueItem.kt b/src/main/kotlin/se/svt/oss/encore/model/queue/QueueItem.kt index c717457e..25dc4ba5 100644 --- a/src/main/kotlin/se/svt/oss/encore/model/queue/QueueItem.kt +++ b/src/main/kotlin/se/svt/oss/encore/model/queue/QueueItem.kt @@ -7,7 +7,6 @@ import java.time.LocalDateTime data class QueueItem(val id: String, val priority: Int = 0, val created: LocalDateTime = LocalDateTime.now()) : Comparable { override fun compareTo(other: QueueItem): Int { - if (this == other) { return 0 } diff --git a/src/main/kotlin/se/svt/oss/encore/process/CommandBuilder.kt b/src/main/kotlin/se/svt/oss/encore/process/CommandBuilder.kt index b22a9d9f..df71c5b8 100644 --- a/src/main/kotlin/se/svt/oss/encore/process/CommandBuilder.kt +++ b/src/main/kotlin/se/svt/oss/encore/process/CommandBuilder.kt @@ -6,6 +6,7 @@ package se.svt.oss.encore.process import mu.KotlinLogging import org.apache.commons.math3.fraction.Fraction +import se.svt.oss.encore.config.EncodingProperties import se.svt.oss.encore.model.EncoreJob import se.svt.oss.encore.model.input.AudioIn import se.svt.oss.encore.model.input.Input @@ -13,7 +14,7 @@ import se.svt.oss.encore.model.input.VideoIn import se.svt.oss.encore.model.input.inputParams import se.svt.oss.encore.model.mediafile.AudioLayout import se.svt.oss.encore.model.mediafile.audioLayout -import se.svt.oss.encore.model.mediafile.channelCount +import se.svt.oss.encore.model.mediafile.channelLayout import se.svt.oss.encore.model.output.AudioStreamEncode import se.svt.oss.encore.model.output.Output import se.svt.oss.encore.model.output.VideoStreamEncode @@ -30,7 +31,8 @@ private val defaultAspectRatio = Fraction(16, 9) class CommandBuilder( private val encoreJob: EncoreJob, private val profile: Profile, - private val outputFolder: String + private val outputFolder: String, + private val encodingProperties: EncodingProperties ) { private val log = KotlinLogging.logger { } @@ -70,7 +72,7 @@ class CommandBuilder( if (input !is AudioIn) return@mapIndexedNotNull null val splits = outputs.flatMap { output -> output.audioStreams.withIndex() - .filter { (_, audioStreamEncode) -> audioStreamEncode.usesInput(input) } + .filter { (_, audioStreamEncode) -> audioStreamEncode.usesInput(input) && !audioStreamEncode.preserveLayout } .map { (audioStreamIndex, audioStreamEncode) -> audioStreamEncode.filter?.let { MapName.AUDIO.preFilterLabel(input.audioLabel, "${output.id}-$audioStreamIndex") @@ -84,8 +86,7 @@ class CommandBuilder( val split = "asplit=${splits.size}${splits.joinToString("")}" val analyzed = input.analyzedAudio val globalAudioFilters = globalAudioFilters(input, analyzed) - val selector = input.audioStream?.let { "[$inputIndex:a:$it]" } - ?: if (analyzed.audioLayout() == AudioLayout.MONO_STREAMS) "[$inputIndex:a]" else "[$inputIndex:a:0]" + val selector = audioSelector(input, inputIndex) val filters = (globalAudioFilters + split).joinToString(",") "$selector$filters" } @@ -103,6 +104,17 @@ class CommandBuilder( return audioSplits + streamFilters } + private fun audioSelector(input: AudioIn, index: Int): String { + if (input.audioStream != null) { + return "[$index:a:${input.audioStream}]" + } + val analyzedAudio = input.analyzedAudio + if (analyzedAudio.audioLayout() == AudioLayout.MULTI_TRACK) { + return "[$index:a:0]" + } + return "[$index:a]" + } + private fun AudioStreamEncode.usesInput(input: AudioIn) = this.inputLabels.contains(input.audioLabel) @@ -142,7 +154,11 @@ class CommandBuilder( this?.inputLabels?.contains(input.videoLabel) == true private fun filterParam(filters: List): List { - return listOf("-filter_complex", (listOf("sws_flags=${profile.scaling}") + filters).joinToString(";")) + return if (filters.isEmpty()) { + emptyList() + } else { + listOf("-filter_complex", (listOf("sws_flags=${profile.scaling}") + filters).joinToString(";")) + } } private fun inputParams(inputs: List): List { @@ -190,7 +206,9 @@ class CommandBuilder( private fun globalAudioFilters(input: AudioIn, analyzed: MediaContainer): List { return if (analyzed.audioLayout() == AudioLayout.MONO_STREAMS) { - listOf("amerge=inputs=${analyzed.channelCount()}") + val channelLayout = input.channelLayout(encodingProperties.defaultChannelLayouts) + val map = channelLayout.channels.withIndex().joinToString("|") { "${it.index}.0-${it.value}" } + listOf("join=inputs=${channelLayout.channels.size}:channel_layout=${channelLayout.layoutName}:map=$map") } else { emptyList() } + input.audioFilters @@ -213,8 +231,19 @@ class CommandBuilder( output.video?.let { listOf("-map", MapName.VIDEO.mapLabel(output.id)) + seekParams(output) } ?: emptyList() - val mapA: List = output.audioStreams.flatMapIndexed { index, _ -> - listOf("-map", MapName.AUDIO.mapLabel("${output.id}-$index")) + seekParams(output) + val preserveAudioLayout = output.audioStreams.any { it.preserveLayout } + if (output.audioStreams.size > 1 && preserveAudioLayout) { + throw RuntimeException("Error in profile! Preserve audio layout in combination with multiple audiostreams not supported.") + } + + val mapA: List = output.audioStreams.flatMapIndexed { index, audioStream -> + val mapLabel = if (preserveAudioLayout) { + val inputIndex = encoreJob.inputs.indexOfFirst { it is AudioIn && audioStream.usesInput(it) } + "$inputIndex:a" + } else { + MapName.AUDIO.mapLabel("${output.id}-$index") + } + listOf("-map", mapLabel) + seekParams(output) } val maps = mapV + mapA @@ -238,11 +267,17 @@ class CommandBuilder( File(outputFolder).resolve(output.output).toString() } - private fun seekParams(output: Output): List = if (!output.seekable) emptyList() else + private fun seekParams(output: Output): List = if (!output.seekable) { + emptyList() + } else { encoreJob.seekTo?.let { listOf("-ss", "$it") } ?: emptyList() + } - private fun durationParams(output: Output): List = if (!output.seekable) emptyList() else + private fun durationParams(output: Output): List = if (!output.seekable) { + emptyList() + } else { encoreJob.duration?.let { listOf("-t", "$it") } ?: emptyList() + } private enum class MapName { VIDEO, diff --git a/src/main/kotlin/se/svt/oss/encore/repository/ChannelLayoutConverters.kt b/src/main/kotlin/se/svt/oss/encore/repository/ChannelLayoutConverters.kt new file mode 100644 index 00000000..bdf3334d --- /dev/null +++ b/src/main/kotlin/se/svt/oss/encore/repository/ChannelLayoutConverters.kt @@ -0,0 +1,29 @@ +package se.svt.oss.encore.repository + +import org.springframework.boot.context.properties.ConfigurationPropertiesBinding +import org.springframework.core.convert.converter.Converter +import org.springframework.data.convert.ReadingConverter +import org.springframework.data.convert.WritingConverter +import org.springframework.stereotype.Component +import se.svt.oss.encore.model.profile.ChannelLayout + +@Component +@ConfigurationPropertiesBinding +class StringToChannelLayoutConverter : Converter { + override fun convert(source: String): ChannelLayout = + ChannelLayout.getByNameOrNull(source) + ?: throw IllegalArgumentException("$source is not a valid channel layout. Valid values: ${ChannelLayout.values().map { it.layoutName }}") +} + +@ReadingConverter +class ByteArrayToChannelLayoutConverter : Converter { + override fun convert(source: ByteArray): ChannelLayout = + ChannelLayout.getByNameOrNull(String(source)) + ?: throw IllegalArgumentException("${String(source)} is not a valid channel layout. Valid values: ${ChannelLayout.values().map { it.layoutName }}") +} + +@WritingConverter +class ChannelLayoutToByteArrayConverter : Converter { + override fun convert(source: ChannelLayout): ByteArray = + source.layoutName.toByteArray() +} diff --git a/src/main/kotlin/se/svt/oss/encore/service/EncoreService.kt b/src/main/kotlin/se/svt/oss/encore/service/EncoreService.kt index d0a523a5..050a354b 100644 --- a/src/main/kotlin/se/svt/oss/encore/service/EncoreService.kt +++ b/src/main/kotlin/se/svt/oss/encore/service/EncoreService.kt @@ -83,7 +83,7 @@ class EncoreService( val outputs = profile.encodes.mapNotNull { it.getOutput( encoreJob, - encoreProperties.audioMixPresets + encoreProperties.encoding ) } diff --git a/src/main/kotlin/se/svt/oss/encore/service/FfmpegExecutor.kt b/src/main/kotlin/se/svt/oss/encore/service/FfmpegExecutor.kt index aa1ac8b1..fd988938 100644 --- a/src/main/kotlin/se/svt/oss/encore/service/FfmpegExecutor.kt +++ b/src/main/kotlin/se/svt/oss/encore/service/FfmpegExecutor.kt @@ -9,6 +9,7 @@ import kotlinx.coroutines.channels.SendChannel import kotlinx.coroutines.channels.trySendBlocking import mu.KotlinLogging import org.springframework.stereotype.Service +import se.svt.oss.encore.config.EncoreProperties import se.svt.oss.encore.model.EncoreJob import se.svt.oss.encore.model.input.maxDuration import se.svt.oss.encore.model.output.Output @@ -23,7 +24,10 @@ import kotlin.math.min import kotlin.math.round @Service -class FfmpegExecutor(private val mediaAnalyzer: MediaAnalyzer) { +class FfmpegExecutor( + private val mediaAnalyzer: MediaAnalyzer, + private val encoreProperties: EncoreProperties +) { private val log = KotlinLogging.logger { } @@ -40,7 +44,8 @@ class FfmpegExecutor(private val mediaAnalyzer: MediaAnalyzer) { outputFolder: String, progressChannel: SendChannel ): List { - val commands = CommandBuilder(encoreJob, profile, outputFolder).buildCommands(outputs) + val commands = + CommandBuilder(encoreJob, profile, outputFolder, encoreProperties.encoding).buildCommands(outputs) log.info { "Start encoding ${encoreJob.baseName}..." } val workDir = Files.createTempDirectory("encore_").toFile() val duration = encoreJob.duration ?: encoreJob.inputs.maxDuration() @@ -105,10 +110,12 @@ class FfmpegExecutor(private val mediaAnalyzer: MediaAnalyzer) { ) } } + "error", "fatal" -> { log.warn { line } errorLines.add(line) } + else -> log.debug { line } } } diff --git a/src/main/kotlin/se/svt/oss/encore/service/localencode/LocalEncodeService.kt b/src/main/kotlin/se/svt/oss/encore/service/localencode/LocalEncodeService.kt index a327c0da..13f4ae39 100644 --- a/src/main/kotlin/se/svt/oss/encore/service/localencode/LocalEncodeService.kt +++ b/src/main/kotlin/se/svt/oss/encore/service/localencode/LocalEncodeService.kt @@ -39,7 +39,6 @@ class LocalEncodeService( output: List, encoreJob: EncoreJob ): List { - if (encoreProperties.localTemporaryEncode) { val destination = File(encoreJob.outputFolder) log.debug { "Moving files to correct outputFolder ${encoreJob.outputFolder}, from local temp $outputFolder" } diff --git a/src/main/kotlin/se/svt/oss/encore/service/mediaanalyzer/MediaAnalyzerService.kt b/src/main/kotlin/se/svt/oss/encore/service/mediaanalyzer/MediaAnalyzerService.kt index 0ecebea9..76d8968a 100644 --- a/src/main/kotlin/se/svt/oss/encore/service/mediaanalyzer/MediaAnalyzerService.kt +++ b/src/main/kotlin/se/svt/oss/encore/service/mediaanalyzer/MediaAnalyzerService.kt @@ -24,7 +24,8 @@ class MediaAnalyzerService(private val mediaAnalyzer: MediaAnalyzer) { fun analyzeInput(input: Input) { log.debug { "Analyzing input $input" } val probeInterlaced = input is VideoIn && input.probeInterlaced - val useFirstAudioStreams = (input as? AudioIn)?.useFirstAudioStreams + val useFirstAudioStreams = (input as? AudioIn)?.channelLayout?.channels?.size + input.analyzed = mediaAnalyzer.analyze(input.uri, probeInterlaced).let { val selectedVideoStream = (input as? VideoIn)?.videoStream val selectedAudioStream = (input as? AudioIn)?.audioStream diff --git a/src/main/kotlin/se/svt/oss/encore/service/profile/ProfileService.kt b/src/main/kotlin/se/svt/oss/encore/service/profile/ProfileService.kt index 3418c6f4..74205ccf 100644 --- a/src/main/kotlin/se/svt/oss/encore/service/profile/ProfileService.kt +++ b/src/main/kotlin/se/svt/oss/encore/service/profile/ProfileService.kt @@ -29,8 +29,11 @@ class ProfileService( private val log = KotlinLogging.logger { } private val mapper = - if (profileLocation.filename?.let { File(it).extension.lowercase(Locale.getDefault()) in setOf("yml", "yaml") } == true) - yamlMapper() else objectMapper + if (profileLocation.filename?.let { File(it).extension.lowercase(Locale.getDefault()) in setOf("yml", "yaml") } == true) { + yamlMapper() + } else { + objectMapper + } @Retryable( include = [IOException::class], diff --git a/src/test/kotlin/se/svt/oss/encore/EncoreIntegrationTest.kt b/src/test/kotlin/se/svt/oss/encore/EncoreIntegrationTest.kt index a1cac331..d67bf870 100644 --- a/src/test/kotlin/se/svt/oss/encore/EncoreIntegrationTest.kt +++ b/src/test/kotlin/se/svt/oss/encore/EncoreIntegrationTest.kt @@ -14,6 +14,7 @@ import se.svt.oss.encore.Assertions.assertThat import se.svt.oss.encore.model.Status import se.svt.oss.encore.model.input.AudioInput import se.svt.oss.encore.model.input.VideoInput +import se.svt.oss.encore.model.profile.ChannelLayout import se.svt.oss.encore.model.queue.QueueItem import se.svt.oss.mediaanalyzer.file.ImageFile import se.svt.oss.mediaanalyzer.file.MediaContainer @@ -90,7 +91,7 @@ class EncoreIntegrationTest : EncoreIntegrationTestBase() { ), AudioInput( uri = testFileSurround.file.absolutePath, - useFirstAudioStreams = 6, + channelLayout = ChannelLayout.CH_LAYOUT_5POINT1, audioLabel = "alt", seekTo = 1.0 ), diff --git a/src/test/kotlin/se/svt/oss/encore/EncoreIntegrationTestBase.kt b/src/test/kotlin/se/svt/oss/encore/EncoreIntegrationTestBase.kt index 3ecec1ef..5832209f 100644 --- a/src/test/kotlin/se/svt/oss/encore/EncoreIntegrationTestBase.kt +++ b/src/test/kotlin/se/svt/oss/encore/EncoreIntegrationTestBase.kt @@ -6,10 +6,10 @@ package se.svt.oss.encore import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.module.kotlin.readValue -import okhttp3.mockwebserver.Dispatcher -import okhttp3.mockwebserver.MockResponse -import okhttp3.mockwebserver.MockWebServer -import okhttp3.mockwebserver.RecordedRequest +import com.github.tomakehurst.wiremock.WireMockServer +import com.github.tomakehurst.wiremock.client.WireMock.anyUrl +import com.github.tomakehurst.wiremock.client.WireMock.ok +import com.github.tomakehurst.wiremock.client.WireMock.post import org.awaitility.Awaitility.await import org.awaitility.Durations import org.junit.jupiter.api.AfterEach @@ -30,6 +30,7 @@ import se.svt.oss.encore.model.input.AudioVideoInput import se.svt.oss.encore.model.EncoreJob import se.svt.oss.encore.model.Status import se.svt.oss.encore.model.callback.JobProgress +import se.svt.oss.encore.model.profile.ChannelLayout import java.io.File import java.net.URI import java.time.Duration @@ -65,24 +66,21 @@ class EncoreIntegrationTestBase() { @Value("classpath:input/multiple_audio.mp4") lateinit var testFileMultipleAudio: Resource - lateinit var mockServer: MockWebServer + lateinit var wireMockServer: WireMockServer @BeforeEach fun setUp() { - mockServer = MockWebServer() - mockServer.setDispatcher( - object : Dispatcher() { - override fun dispatch(request: RecordedRequest): MockResponse { - return MockResponse() - } - } + wireMockServer = WireMockServer() + wireMockServer.start() + wireMockServer.stubFor( + post(anyUrl()) + .willReturn(ok()) ) - mockServer.start() } @AfterEach fun tearDown() { - mockServer.shutdown() + wireMockServer.stop() } fun successfulTest( @@ -96,20 +94,9 @@ class EncoreIntegrationTestBase() { assertThat(createdJob).hasStatus(Status.SUCCESSFUL) - val requestCount = mockServer.requestCount - assertThat(requestCount).isGreaterThan(0) - - val jobList = mutableListOf() - repeat(requestCount) { - val request = mockServer.takeRequest() - val json = request.body.readUtf8() - val progress = objectMapper.readValue(json) - jobList.add(progress) - assertThat(progress).hasJobId(createdJob.id).hasExternalId(createdJob.externalId) - assertThat(progress.progress).isBetween(0, 100) - } - assertThat(jobList.subList(0, jobList.size - 1)).allMatch { it.status == Status.IN_PROGRESS } - assertThat(jobList.last()).hasProgress(100).hasStatus(Status.SUCCESSFUL) + val progressCalls = wireMockServer.allServeEvents.map { objectMapper.readValue(it.request.bodyAsString) } + assertThat(progressCalls.first()) + .hasStatus(Status.SUCCESSFUL) val output = createdJob.output.map { it.file } assertThat(output).containsExactlyInAnyOrder(*expectedOutputFiles.toTypedArray()) @@ -160,13 +147,13 @@ class EncoreIntegrationTestBase() { baseName = file.file.nameWithoutExtension, profile = "program", outputFolder = outputDir.absolutePath, - progressCallbackUri = URI.create("http://localhost:${mockServer.port}/callbacks/111"), + progressCallbackUri = URI.create("http://localhost:${wireMockServer.port()}/callbacks/111"), debugOverlay = true, priority = priority, inputs = listOf( AudioVideoInput( uri = file.file.absolutePath, - useFirstAudioStreams = 6 + channelLayout = ChannelLayout.CH_LAYOUT_5POINT1, ) ), logContext = mapOf("FlowId" to UUID.randomUUID().toString()) diff --git a/src/test/kotlin/se/svt/oss/encore/TestUtils.kt b/src/test/kotlin/se/svt/oss/encore/TestUtils.kt index 3bb6c7c8..4b182bfa 100644 --- a/src/test/kotlin/se/svt/oss/encore/TestUtils.kt +++ b/src/test/kotlin/se/svt/oss/encore/TestUtils.kt @@ -31,6 +31,11 @@ val defaultVideoFile by lazy { .readValue(ClassPathResource("/input/video-file.json").file.readText()) } +val portraitVideoFile by lazy { + ObjectMapper().findAndRegisterModules() + .readValue(ClassPathResource("/input/portrait-video-file.json").file.readText()) +} + val longVideoFile by lazy { ObjectMapper().findAndRegisterModules() .readValue(ClassPathResource("/input/video-file-long.json").file.readText()) diff --git a/src/test/kotlin/se/svt/oss/encore/handlers/EncoreJobHandlerTest.kt b/src/test/kotlin/se/svt/oss/encore/handlers/EncoreJobHandlerTest.kt index 2fdfaf0c..53feb449 100644 --- a/src/test/kotlin/se/svt/oss/encore/handlers/EncoreJobHandlerTest.kt +++ b/src/test/kotlin/se/svt/oss/encore/handlers/EncoreJobHandlerTest.kt @@ -60,7 +60,6 @@ class EncoreJobHandlerTest { @Test fun `enqueue fails`() { - every { queueService.enqueue(job) } throws Exception("error") encoreJobHandler.onAfterCreate(job) diff --git a/src/test/kotlin/se/svt/oss/encore/model/mediafile/MediaFileExtensionsTest.kt b/src/test/kotlin/se/svt/oss/encore/model/mediafile/MediaFileExtensionsTest.kt index 2cac5303..23367121 100644 --- a/src/test/kotlin/se/svt/oss/encore/model/mediafile/MediaFileExtensionsTest.kt +++ b/src/test/kotlin/se/svt/oss/encore/model/mediafile/MediaFileExtensionsTest.kt @@ -7,27 +7,35 @@ package se.svt.oss.encore.model.mediafile import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test import se.svt.oss.encore.Assertions.assertThat -import se.svt.oss.encore.multipleAudioFile +import se.svt.oss.encore.Assertions.assertThatThrownBy import se.svt.oss.encore.defaultVideoFile +import se.svt.oss.encore.model.input.AudioInput +import se.svt.oss.encore.model.profile.ChannelLayout +import se.svt.oss.encore.multipleAudioFile import se.svt.oss.encore.multipleVideoFile +import se.svt.oss.mediaanalyzer.file.MediaFile internal class MediaFileExtensionsTest { + private val noAudio = defaultVideoFile.copy(audioStreams = emptyList()) + + private val invalidAudio = defaultVideoFile.copy( + audioStreams = defaultVideoFile.audioStreams.mapIndexed { index, audioStream -> + if (index == 0) audioStream else audioStream.copy(channels = 2) + } + ) + + private val defaultChannelLayouts = mapOf(3 to ChannelLayout.CH_LAYOUT_3POINT0) + @Test @DisplayName("Video file without audio streams has AudioLayout NONE") fun testAudioLayoutNone() { - val videoFile = defaultVideoFile.copy(audioStreams = emptyList()) - assertThat(videoFile.audioLayout()).isEqualTo(AudioLayout.NONE) + assertThat(noAudio.audioLayout()).isEqualTo(AudioLayout.NONE) } @Test @DisplayName("When first audio stream has multiple channels, audiolayout is MULTI_TRACK") fun testAudioLayoutMultiTrack() { - val videoFile = defaultVideoFile.copy( - audioStreams = listOf( - defaultVideoFile.audioStreams.first().copy(channels = 2) - ) - ) - assertThat(videoFile.audioLayout()).isEqualTo(AudioLayout.MULTI_TRACK) + assertThat(multipleAudioFile.audioLayout()).isEqualTo(AudioLayout.MULTI_TRACK) } @Test @@ -47,13 +55,7 @@ internal class MediaFileExtensionsTest { @Test @DisplayName("Audio layout is invalid if first stream has one channel and the rest has two channels or more") fun testAudioLayoutInvalid() { - val videoFile = - defaultVideoFile.copy( - audioStreams = defaultVideoFile.audioStreams.mapIndexed { index, audioStream -> - if (index == 0) audioStream else audioStream.copy(channels = 2) - } - ) - assertThat(videoFile.audioLayout()).isEqualTo(AudioLayout.INVALID) + assertThat(invalidAudio.audioLayout()).isEqualTo(AudioLayout.INVALID) } @Test @@ -73,8 +75,7 @@ internal class MediaFileExtensionsTest { @Test @DisplayName("Channel count for no streams is 0") fun testChannelCountNoSteams() { - val videoFile = defaultVideoFile.copy(audioStreams = emptyList()) - assertThat(videoFile.channelCount()).isEqualTo(0) + assertThat(noAudio.channelCount()).isEqualTo(0) } @Test @@ -140,4 +141,81 @@ internal class MediaFileExtensionsTest { val video = multipleAudioFile.selectAudioStream(null) assertThat(video).isSameAs(multipleAudioFile) } + + @Test + @DisplayName("Channel layout throws when no audio") + fun channelLayoutNoAudio() { + assertThatThrownBy { audioInput(noAudio).channelLayout(defaultChannelLayouts) } + .hasMessage("Could not determine channel layout for audio input 'main'!") + } + + @Test + @DisplayName("Channel layout throws when invalid audio") + fun channelLayoutInvalidAudio() { + assertThatThrownBy { audioInput(invalidAudio).channelLayout(defaultChannelLayouts) } + .hasMessage("Could not determine channel layout for audio input 'main'!") + } + + @Test + @DisplayName("Channel layout is set on input and channel count correct") + fun channelLayoutMonoStreamsSetByParam() { + val input = audioInput(defaultVideoFile.copy(audioStreams = defaultVideoFile.audioStreams.take(3))) + .copy(channelLayout = ChannelLayout.CH_LAYOUT_3POINT0_BACK) + assertThat(input.channelLayout(defaultChannelLayouts)).isEqualTo(ChannelLayout.CH_LAYOUT_3POINT0_BACK) + } + + @Test + @DisplayName("Channel layout is set on input and channel count incorrect use ffmpeg default") + fun channelLayoutMonoStreamsSetByParamIncorrect() { + val input = audioInput(defaultVideoFile.copy(audioStreams = defaultVideoFile.audioStreams.take(2))) + .copy(channelLayout = ChannelLayout.CH_LAYOUT_3POINT0) + assertThat(input.channelLayout(defaultChannelLayouts)).isEqualTo(ChannelLayout.CH_LAYOUT_STEREO) + } + + @Test + @DisplayName("Channel layout is set on input and channel count incorrect use default by config") + fun channelLayoutMonoStreamsSetByParamIncorrectUseConfig() { + val input = audioInput(defaultVideoFile.copy(audioStreams = defaultVideoFile.audioStreams.take(3))) + .copy(channelLayout = ChannelLayout.CH_LAYOUT_5POINT1) + assertThat(input.channelLayout(defaultChannelLayouts)).isEqualTo(ChannelLayout.CH_LAYOUT_3POINT0) + } + + @Test + @DisplayName("Channel layout is present in analyzed") + fun channelLayoutMultiTrack() { + val audioInput = audioInput(multipleAudioFile.copy(audioStreams = multipleAudioFile.audioStreams.drop(1))) + assertThat(audioInput.channelLayout(defaultChannelLayouts)) + .isEqualTo(ChannelLayout.CH_LAYOUT_5POINT1_SIDE) + } + + @Test + @DisplayName("Channel layout is not present in analyzed - uses default from config") + fun channelLayoutMultiTrackNotPresentInAnalyzedUseConfig() { + val audioFile = multipleAudioFile.copy( + audioStreams = multipleAudioFile.audioStreams.take(1).map { + it.copy(channelLayout = null, channels = 3) + } + ) + + assertThat(audioInput(audioFile).channelLayout(defaultChannelLayouts)) + .isEqualTo(ChannelLayout.CH_LAYOUT_3POINT0) + } + + @Test + @DisplayName("Channel layout is not present in analyzed - uses default from ffmpeg") + fun channelLayoutMultiTrackNotPresentInAnalyzedUseDefault() { + val audioFile = multipleAudioFile.copy( + audioStreams = multipleAudioFile.audioStreams.take(1).map { + it.copy(channelLayout = null, channels = 3) + } + ) + + assertThat(audioInput(audioFile).channelLayout(emptyMap())) + .isEqualTo(ChannelLayout.CH_LAYOUT_2POINT1) + } + + private fun audioInput(analyzed: MediaFile) = AudioInput( + uri = "/test.mp", + analyzed = analyzed + ) } diff --git a/src/test/kotlin/se/svt/oss/encore/model/profile/AudioEncodeTest.kt b/src/test/kotlin/se/svt/oss/encore/model/profile/AudioEncodeTest.kt index d42befc7..cd57f287 100644 --- a/src/test/kotlin/se/svt/oss/encore/model/profile/AudioEncodeTest.kt +++ b/src/test/kotlin/se/svt/oss/encore/model/profile/AudioEncodeTest.kt @@ -8,6 +8,7 @@ import org.assertj.core.api.Assertions.assertThatThrownBy import org.junit.jupiter.api.Test import se.svt.oss.encore.Assertions.assertThat import se.svt.oss.encore.config.AudioMixPreset +import se.svt.oss.encore.config.EncodingProperties import se.svt.oss.encore.defaultEncoreJob import se.svt.oss.encore.defaultVideoFile import se.svt.oss.encore.model.input.AudioVideoInput @@ -21,7 +22,7 @@ class AudioEncodeTest { codec = "aac", bitrate = null, samplerate = 48000, - channels = 2 + channelLayout = ChannelLayout.CH_LAYOUT_STEREO ) private val videoFile = defaultVideoFile @@ -29,32 +30,39 @@ class AudioEncodeTest { @Test fun `no audio streams throws exception`() { assertThatThrownBy { - audioEncode.getOutput(job(), mapOf("default" to AudioMixPreset(fallbackToAuto = false))) + audioEncode.getOutput(job(), EncodingProperties()) }.isInstanceOf(RuntimeException::class.java) - .hasMessageContaining("No audio mix preset for 'default': 0 -> 2 channels") + .hasMessageContaining("No audio streams in input") } @Test fun `not supported soundtype throws exception`() { val job = job(getAudioStream(1), getAudioStream(2)) assertThatThrownBy { - audioEncode.getOutput(job, mapOf("default" to AudioMixPreset(fallbackToAuto = false))) + audioEncode.getOutput( + job, + EncodingProperties(audioMixPresets = mapOf("default" to AudioMixPreset(fallbackToAuto = false))) + ) }.isInstanceOf(RuntimeException::class.java) .hasMessage("Audio layout of audio input 'main' is not supported!") } @Test fun `valid output`() { - val output = audioEncode.getOutput(job(getAudioStream(6)), mapOf("default" to AudioMixPreset())) + val output = audioEncode.getOutput( + job(getAudioStream(6)), + EncodingProperties() + ) assertThat(output) - .hasOutput("test_aac_2ch.mp4") + .hasOutput("test_aac_stereo.mp4") .hasSeekable(true) .hasVideo(null) - .hasId("_aac_2ch.mp4") + .hasId("_aac_stereo.mp4") .hasOnlyAudioStreams( AudioStreamEncode( - params = listOf("-ac:a:{stream_index}", "2", "-c:a:{stream_index}", "aac", "-ar:a:{stream_index}", "48000"), - inputLabels = listOf(DEFAULT_AUDIO_LABEL) + params = listOf("-c:a:{stream_index}", "aac", "-ar:a:{stream_index}", "48000"), + inputLabels = listOf(DEFAULT_AUDIO_LABEL), + filter = "aformat=channel_layouts=stereo" ) ) } @@ -65,21 +73,40 @@ class AudioEncodeTest { codec = "aac", bitrate = "72000", samplerate = 48000, - channels = 2, + channelLayout = ChannelLayout.CH_LAYOUT_STEREO, params = linkedMapOf("profile:a" to "LC", "cutoff" to "14000"), filters = listOf("1", "3") ) val output = audioEncodeLocal.getOutput( job = job(getAudioStream(6)), - audioMixPresets = mapOf("default" to AudioMixPreset(panMapping = mapOf(6 to mapOf(2 to "stereo|c0=c0|c1=c1")))) + encodingProperties = EncodingProperties( + audioMixPresets = mapOf( + "default" to AudioMixPreset( + panMapping = mapOf( + ChannelLayout.CH_LAYOUT_5POINT1 to mapOf(ChannelLayout.CH_LAYOUT_STEREO to "c0=c0|c1=c1") + ) + ) + ) + ) ) assertThat(output) - .hasOutput("test_aac_2ch.mp4") + .hasOutput("test_aac_stereo.mp4") .hasVideo(null) .hasOnlyAudioStreams( AudioStreamEncode( - listOf("-c:a:{stream_index}", "aac", "-ar:a:{stream_index}", "48000", "-b:a:{stream_index}", "72000", "-profile:a", "LC", "-cutoff", "14000"), + listOf( + "-c:a:{stream_index}", + "aac", + "-ar:a:{stream_index}", + "48000", + "-b:a:{stream_index}", + "72000", + "-profile:a", + "LC", + "-cutoff", + "14000" + ), "pan=stereo|c0=c0|c1=c1,1,3", listOf( DEFAULT_AUDIO_LABEL @@ -97,7 +124,13 @@ class AudioEncodeTest { val output = audioEncodeLocal.getOutput( job = job(getAudioStream(6)), - audioMixPresets = mapOf("de" to AudioMixPreset(fallbackToAuto = false)) + encodingProperties = EncodingProperties( + audioMixPresets = mapOf( + "de" to AudioMixPreset( + fallbackToAuto = false + ) + ) + ) ) assertThat(output).isNull() } @@ -109,15 +142,24 @@ class AudioEncodeTest { ) assertThatThrownBy { - audioEncodeLocal.getOutput(job(getAudioStream(6)), mapOf("de" to AudioMixPreset(fallbackToAuto = false))) + audioEncodeLocal.getOutput( + job(getAudioStream(6)), + encodingProperties = EncodingProperties( + audioMixPresets = mapOf( + "de" to AudioMixPreset( + fallbackToAuto = false + ) + ) + ) + ) }.isInstanceOf(RuntimeException::class.java) - .hasMessageContaining("No audio mix preset for 'de': 6 -> 2 channels") + .hasMessageContaining("No audio mix preset for 'de': 5.1 -> stereo") } @Test fun `unmapped input optional returns null`() { val audioEncodeLocal = audioEncode.copy(inputLabel = "other", optional = true) - val output = audioEncodeLocal.getOutput(job(getAudioStream(6)), mapOf("default" to AudioMixPreset())) + val output = audioEncodeLocal.getOutput(job(getAudioStream(6)), EncodingProperties()) assertThat(output).isNull() } @@ -125,9 +167,9 @@ class AudioEncodeTest { fun `unmapped input not optional throws`() { val audioEncodeLocal = audioEncode.copy(inputLabel = "other") assertThatThrownBy { - audioEncodeLocal.getOutput(job(getAudioStream(6)), mapOf("de" to AudioMixPreset(fallbackToAuto = false))) + audioEncodeLocal.getOutput(job(getAudioStream(6)), EncodingProperties()) }.isInstanceOf(RuntimeException::class.java) - .hasMessage("Can not generate test_aac_2ch.mp4! No audio input with label 'other'.") + .hasMessage("Can not generate test_aac_stereo.mp4! No audio input with label 'other'.") } private fun job(vararg audioStreams: AudioStream) = @@ -145,6 +187,7 @@ class AudioEncodeTest { codec = "aac", duration = 10.0, channels = channelCount, + channelLayout = ChannelLayout.defaultChannelLayout(channelCount)?.layoutName, samplingRate = 23123, bitrate = 213123 ) diff --git a/src/test/kotlin/se/svt/oss/encore/model/profile/ThumbnailEncodeTest.kt b/src/test/kotlin/se/svt/oss/encore/model/profile/ThumbnailEncodeTest.kt index 7e90d69a..adc7ee85 100644 --- a/src/test/kotlin/se/svt/oss/encore/model/profile/ThumbnailEncodeTest.kt +++ b/src/test/kotlin/se/svt/oss/encore/model/profile/ThumbnailEncodeTest.kt @@ -7,6 +7,7 @@ package se.svt.oss.encore.model.profile import org.junit.jupiter.api.Test import se.svt.oss.encore.Assertions.assertThat import se.svt.oss.encore.Assertions.assertThatThrownBy +import se.svt.oss.encore.config.EncodingProperties import se.svt.oss.encore.defaultEncoreJob import se.svt.oss.encore.defaultVideoFile import se.svt.oss.encore.longVideoFile @@ -26,7 +27,7 @@ class ThumbnailEncodeTest { fun `use percentages for filter`() { val output = encode.getOutput( job = defaultEncoreJob(), - audioMixPresets = emptyMap() + encodingProperties = EncodingProperties() ) assertThat(output) .hasOutput("test_thumb%02d.jpg") @@ -48,7 +49,7 @@ class ThumbnailEncodeTest { job = defaultEncoreJob().copy( thumbnailTime = 5.0, ), - audioMixPresets = emptyMap() + encodingProperties = EncodingProperties() ) assertThat(output) .hasOutput("test_thumb%02d.jpg") @@ -84,7 +85,7 @@ class ThumbnailEncodeTest { ) ), ), - audioMixPresets = emptyMap() + encodingProperties = EncodingProperties() ) }.hasMessageContaining("No framerate detected") } @@ -108,7 +109,7 @@ class ThumbnailEncodeTest { ) ), ), - audioMixPresets = emptyMap() + encodingProperties = EncodingProperties() ) assertThat(output).isNull() } @@ -120,7 +121,7 @@ class ThumbnailEncodeTest { seekTo = 1.0, duration = 4.0 ), - audioMixPresets = emptyMap() + encodingProperties = EncodingProperties() ) assertThat(output) .hasOutput("test_thumb%02d.jpg") @@ -151,7 +152,7 @@ class ThumbnailEncodeTest { ) ) ), - audioMixPresets = emptyMap() + encodingProperties = EncodingProperties() ) assertThat(output) .hasOutput("test_thumb%02d.jpg") @@ -171,7 +172,7 @@ class ThumbnailEncodeTest { fun `unmapped input optional returns null`() { val output = encode.copy(inputLabel = "other", optional = true).getOutput( job = defaultEncoreJob(), - audioMixPresets = emptyMap() + encodingProperties = EncodingProperties() ) assertThat(output).isNull() } @@ -181,7 +182,7 @@ class ThumbnailEncodeTest { assertThatThrownBy { encode.copy(inputLabel = "other", optional = false).getOutput( job = defaultEncoreJob(), - audioMixPresets = emptyMap() + encodingProperties = EncodingProperties() ) }.isInstanceOf(RuntimeException::class.java) .hasMessageContaining("No video input with label other!") diff --git a/src/test/kotlin/se/svt/oss/encore/model/profile/ThumbnailMapEncodeTest.kt b/src/test/kotlin/se/svt/oss/encore/model/profile/ThumbnailMapEncodeTest.kt index ab2142ab..bef28f2c 100644 --- a/src/test/kotlin/se/svt/oss/encore/model/profile/ThumbnailMapEncodeTest.kt +++ b/src/test/kotlin/se/svt/oss/encore/model/profile/ThumbnailMapEncodeTest.kt @@ -7,6 +7,7 @@ package se.svt.oss.encore.model.profile import org.junit.jupiter.api.Test import se.svt.oss.encore.Assertions.assertThat import se.svt.oss.encore.Assertions.assertThatThrownBy +import se.svt.oss.encore.config.EncodingProperties import se.svt.oss.encore.defaultEncoreJob import se.svt.oss.encore.defaultVideoFile import se.svt.oss.encore.model.input.AudioVideoInput @@ -24,7 +25,7 @@ class ThumbnailMapEncodeTest { @Test fun `correct output`() { - val output = encode.getOutput(defaultEncoreJob(), emptyMap()) + val output = encode.getOutput(defaultEncoreJob(), EncodingProperties()) assertThat(output) .hasSeekable(false) .hasNoAudioStreams() @@ -41,7 +42,7 @@ class ThumbnailMapEncodeTest { @Test fun `correct output seekTo and duration`() { - val output = ThumbnailMapEncode(cols = 6, rows = 10).getOutput(defaultEncoreJob().copy(seekTo = 1.0, duration = 5.0), emptyMap()) + val output = ThumbnailMapEncode(cols = 6, rows = 10).getOutput(defaultEncoreJob().copy(seekTo = 1.0, duration = 5.0), EncodingProperties()) assertThat(output) .hasSeekable(false) .hasNoAudioStreams() @@ -75,7 +76,7 @@ class ThumbnailMapEncodeTest { ) ), ), - audioMixPresets = emptyMap() + encodingProperties = EncodingProperties() ) assertThat(output).isNull() } @@ -100,7 +101,7 @@ class ThumbnailMapEncodeTest { ) ), ), - audioMixPresets = emptyMap() + encodingProperties = EncodingProperties() ) }.hasMessageContaining("Video input main did not contain enough frames to generate thumbnail map") } diff --git a/src/test/kotlin/se/svt/oss/encore/model/profile/VideoEncodeTest.kt b/src/test/kotlin/se/svt/oss/encore/model/profile/VideoEncodeTest.kt index d410b29e..b1eed31c 100644 --- a/src/test/kotlin/se/svt/oss/encore/model/profile/VideoEncodeTest.kt +++ b/src/test/kotlin/se/svt/oss/encore/model/profile/VideoEncodeTest.kt @@ -9,9 +9,11 @@ import io.mockk.mockk import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import se.svt.oss.encore.Assertions.assertThat -import se.svt.oss.encore.config.AudioMixPreset +import se.svt.oss.encore.config.EncodingProperties import se.svt.oss.encore.defaultEncoreJob +import se.svt.oss.encore.model.input.AudioVideoInput import se.svt.oss.encore.model.output.AudioStreamEncode +import se.svt.oss.encore.portraitVideoFile abstract class VideoEncodeTest { @@ -24,14 +26,39 @@ abstract class VideoEncodeTest { audioEncode: AudioEncode? ): T - private val audioMixPresets = mapOf("test" to AudioMixPreset()) + private val encodingProperties = EncodingProperties() private val audioEncode = mockk() private val audioStreamEncode = mockk() private val defaultParams = linkedMapOf("a" to "b") @BeforeEach internal fun setUp() { - every { audioEncode.getOutput(any(), audioMixPresets)?.audioStreams } returns listOf(audioStreamEncode) + every { audioEncode.getOutput(any(), encodingProperties)?.audioStreams } returns listOf(audioStreamEncode) + } + + @Test + fun `scale portrait input within portrait box`() { + val encode = createEncode( + width = 1920, + height = 1080, + twoPass = false, + params = defaultParams, + filters = emptyList(), + audioEncode = audioEncode + ) + val output = encode.getOutput( + defaultEncoreJob().copy( + inputs = listOf( + AudioVideoInput( + uri = "/test.mp4", + analyzed = portraitVideoFile + ) + ) + ), + encodingProperties + ) + + assertThat(output?.video).hasFilter("scale=1080:1920:force_original_aspect_ratio=decrease:force_divisible_by=2,setsar=1/1") } @Test @@ -44,7 +71,7 @@ abstract class VideoEncodeTest { filters = listOf("afilter"), audioEncode = audioEncode ) - val output = encode.getOutput(defaultEncoreJob(), audioMixPresets) + val output = encode.getOutput(defaultEncoreJob(), encodingProperties) assertThat(output) .hasOnlyAudioStreams(audioStreamEncode) .hasSeekable(true) @@ -68,7 +95,7 @@ abstract class VideoEncodeTest { filters = listOf("afilter"), audioEncode = audioEncode ) - val output = encode.getOutput(defaultEncoreJob(), audioMixPresets) + val output = encode.getOutput(defaultEncoreJob(), encodingProperties) assertThat(output).isNotNull val videoStreamEncode = output!!.video assertThat(videoStreamEncode) diff --git a/src/test/kotlin/se/svt/oss/encore/process/CommandBuilderTest.kt b/src/test/kotlin/se/svt/oss/encore/process/CommandBuilderTest.kt index 6f4d3c47..b299a1e5 100644 --- a/src/test/kotlin/se/svt/oss/encore/process/CommandBuilderTest.kt +++ b/src/test/kotlin/se/svt/oss/encore/process/CommandBuilderTest.kt @@ -9,6 +9,7 @@ import io.mockk.mockk import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import se.svt.oss.encore.Assertions.assertThat +import se.svt.oss.encore.config.EncodingProperties import se.svt.oss.encore.defaultEncoreJob import se.svt.oss.encore.defaultVideoFile import se.svt.oss.encore.model.input.AudioInput @@ -16,9 +17,11 @@ import se.svt.oss.encore.model.input.AudioVideoInput import se.svt.oss.encore.model.input.DEFAULT_AUDIO_LABEL import se.svt.oss.encore.model.input.DEFAULT_VIDEO_LABEL import se.svt.oss.encore.model.input.VideoInput +import se.svt.oss.encore.model.mediafile.trimAudio 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.Profile import se.svt.oss.mediaanalyzer.file.AudioFile @@ -26,6 +29,7 @@ internal class CommandBuilderTest { val profile: Profile = mockk() var videoFile = defaultVideoFile var encoreJob = defaultEncoreJob() + val encodingProperties = mockk() private lateinit var commandBuilder: CommandBuilder @@ -33,12 +37,60 @@ internal class CommandBuilderTest { @BeforeEach internal fun setUp() { - commandBuilder = CommandBuilder(encoreJob, profile, encoreJob.outputFolder) - + commandBuilder = CommandBuilder(encoreJob, profile, encoreJob.outputFolder, encodingProperties) + every { encodingProperties.defaultChannelLayouts } returns emptyMap() every { profile.scaling } returns "scaling" every { profile.deinterlaceFilter } returns "yadif" } + @Test + fun `audio with preserveLayout no filtering`() { + val output = Output( + video = null, + audioStreams = listOf( + AudioStreamEncode( + params = listOf("-c:a", "copy"), + inputLabels = listOf(DEFAULT_AUDIO_LABEL), + preserveLayout = true + ) + ), + output = "out.mp4", + id = "test-out" + ) + val buildCommands = commandBuilder.buildCommands(listOf(output)) + val command = buildCommands.first().joinToString(" ") + assertThat(command).isEqualTo("ffmpeg -hide_banner -loglevel +level -y -i /input/test.mp4 -map 0:a -vn -c:a copy -metadata comment=Transcoded using Encore /output/path/out.mp4") + } + + @Test + fun `non default channelLayout`() { + val job = encoreJob.copy( + inputs = listOf( + AudioVideoInput( + channelLayout = ChannelLayout.CH_LAYOUT_3POINT0, + analyzed = defaultVideoFile.trimAudio(3), + uri = "/input/test.mp4" + ) + ) + ) + commandBuilder = CommandBuilder(job, profile, encoreJob.outputFolder, encodingProperties) + val output = Output( + video = null, + audioStreams = listOf( + AudioStreamEncode( + params = listOf("-c:a:{stream_index}", "aac"), + inputLabels = listOf(DEFAULT_AUDIO_LABEL), + filter = "aformat=channel_layouts=stereo" + ) + ), + output = "out.mp4", + id = "test-out" + ) + val buildCommands = commandBuilder.buildCommands(listOf(output)) + val command = buildCommands.first().joinToString(" ") + assertThat(command).isEqualTo("ffmpeg -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 `one pass encode`() { val buildCommands = commandBuilder.buildCommands(listOf(output(false))) @@ -46,7 +98,7 @@ internal class CommandBuilderTest { assertThat(buildCommands).hasSize(1) val command = buildCommands.first().joinToString(" ") - assertThat(command).isEqualTo("ffmpeg -hide_banner -loglevel +level -y -i /input/test.mp4 -filter_complex sws_flags=scaling;[0:v]split=1[VIDEO-main-test-out];[VIDEO-main-test-out]video-filter[VIDEO-test-out];[0:a]amerge=inputs=8,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") + assertThat(command).isEqualTo("ffmpeg -hide_banner -loglevel +level -y -i /input/test.mp4 -filter_complex sws_flags=scaling;[0:v]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 @@ -58,7 +110,7 @@ internal class CommandBuilderTest { val secondPass = buildCommands[1].joinToString(" ") assertThat(firstPass).isEqualTo("ffmpeg -hide_banner -loglevel +level -y -i ${defaultVideoFile.file} -filter_complex sws_flags=scaling;[0:v]split=1[VIDEO-main-test-out];[VIDEO-main-test-out]video-filter[VIDEO-test-out] -map [VIDEO-test-out] -an first pass -f mp4 /dev/null") - assertThat(secondPass).isEqualTo("ffmpeg -hide_banner -loglevel +level -y -i ${defaultVideoFile.file} -filter_complex sws_flags=scaling;[0:v]split=1[VIDEO-main-test-out];[VIDEO-main-test-out]video-filter[VIDEO-test-out];[0:a]amerge=inputs=8,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=$metadataComment /output/path/out.mp4") + assertThat(secondPass).isEqualTo("ffmpeg -hide_banner -loglevel +level -y -i ${defaultVideoFile.file} -filter_complex sws_flags=scaling;[0:v]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=$metadataComment /output/path/out.mp4") } @Test @@ -72,7 +124,7 @@ internal class CommandBuilderTest { ) encoreJob = encoreJob.copy(inputs = inputs) - commandBuilder = CommandBuilder(encoreJob, profile, encoreJob.outputFolder) + commandBuilder = CommandBuilder(encoreJob, profile, encoreJob.outputFolder, encodingProperties) val buildCommands = commandBuilder.buildCommands(listOf(output(true))) assertThat(buildCommands).hasSize(2) @@ -81,7 +133,7 @@ internal class CommandBuilderTest { val secondPass = buildCommands[1].joinToString(" ") assertThat(firstPass).isEqualTo("ffmpeg -hide_banner -loglevel +level -y -ss 47.11 -i ${videoFile.file} -filter_complex sws_flags=scaling;[0:v]split=1[VIDEO-main-test-out];[VIDEO-main-test-out]video-filter[VIDEO-test-out] -map [VIDEO-test-out] -an first pass -f mp4 /dev/null") - assertThat(secondPass).isEqualTo("ffmpeg -hide_banner -loglevel +level -y -ss 47.11 -i ${videoFile.file} -filter_complex sws_flags=scaling;[0:v]split=1[VIDEO-main-test-out];[VIDEO-main-test-out]video-filter[VIDEO-test-out];[0:a]amerge=inputs=8,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=$metadataComment /output/path/out.mp4") + assertThat(secondPass).isEqualTo("ffmpeg -hide_banner -loglevel +level -y -ss 47.11 -i ${videoFile.file} -filter_complex sws_flags=scaling;[0:v]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=$metadataComment /output/path/out.mp4") } @Test @@ -144,7 +196,7 @@ internal class CommandBuilderTest { inputs = inputs ) - commandBuilder = CommandBuilder(encoreJob, profile, "/tmp/123") + commandBuilder = CommandBuilder(encoreJob, profile, "/tmp/123", encodingProperties) val buildCommands = commandBuilder.buildCommands(listOf(output(true), audioOutput("other", "extra"))) @@ -154,7 +206,7 @@ internal class CommandBuilderTest { val secondPass = buildCommands[1].joinToString(" ") assertThat(firstPass).isEqualTo("ffmpeg -hide_banner -loglevel +level -y -f mp4 -t 22.5 -i /input/test.mp4 -filter_complex sws_flags=scaling;[0:v:1]yadif,setdar=16/9,scale=iw*sar:ih,crop=min(iw\\,ih*1/1):min(ih\\,iw/(1/1)),pad=aspect=16/9:x=(ow-iw)/2:y=(oh-ih)/2,video,filter,split=1[VIDEO-main-test-out];[VIDEO-main-test-out]video-filter[VIDEO-test-out] -map [VIDEO-test-out] -ss 12.1 -an -t 10.4 first pass -f mp4 /dev/null") - assertThat(secondPass).isEqualTo("ffmpeg -hide_banner -loglevel +level -y -f mp4 -t 22.5 -i /input/test.mp4 -ac 4 -t 22.5 -i /input/main-audio.mp4 -t 22.5 -i /input/other-audio.mp4 -filter_complex sws_flags=scaling;[0:v:1]yadif,setdar=16/9,scale=iw*sar:ih,crop=min(iw\\,ih*1/1):min(ih\\,iw/(1/1)),pad=aspect=16/9:x=(ow-iw)/2:y=(oh-ih)/2,video,filter,split=1[VIDEO-main-test-out];[VIDEO-main-test-out]video-filter[VIDEO-test-out];[1:a]amerge=inputs=4,audio-main,main-filter,asplit=1[AUDIO-main-test-out-0];[2:a:3]asplit=1[AUDIO-other-extra-0];[AUDIO-main-test-out-0]audio-filter[AUDIO-test-out-0];[AUDIO-other-extra-0]audio-filter-extra[AUDIO-extra-0] -map [VIDEO-test-out] -ss 12.1 -map [AUDIO-test-out-0] -ss 12.1 -t 10.4 video params audio params -metadata comment=Transcoded using Encore /tmp/123/out.mp4 -map [AUDIO-extra-0] -ss 12.1 -t 10.4 -vn audio extra -metadata comment=Transcoded using Encore /tmp/123/extra.mp4") + assertThat(secondPass).isEqualTo("ffmpeg -hide_banner -loglevel +level -y -f mp4 -t 22.5 -i /input/test.mp4 -ac 4 -t 22.5 -i /input/main-audio.mp4 -t 22.5 -i /input/other-audio.mp4 -filter_complex sws_flags=scaling;[0:v:1]yadif,setdar=16/9,scale=iw*sar:ih,crop=min(iw\\,ih*1/1):min(ih\\,iw/(1/1)),pad=aspect=16/9:x=(ow-iw)/2:y=(oh-ih)/2,video,filter,split=1[VIDEO-main-test-out];[VIDEO-main-test-out]video-filter[VIDEO-test-out];[1:a]join=inputs=4:channel_layout=4.0:map=0.0-FL|1.0-FR|2.0-FC|3.0-BC,audio-main,main-filter,asplit=1[AUDIO-main-test-out-0];[2:a:3]asplit=1[AUDIO-other-extra-0];[AUDIO-main-test-out-0]audio-filter[AUDIO-test-out-0];[AUDIO-other-extra-0]audio-filter-extra[AUDIO-extra-0] -map [VIDEO-test-out] -ss 12.1 -map [AUDIO-test-out-0] -ss 12.1 -t 10.4 video params audio params -metadata comment=Transcoded using Encore /tmp/123/out.mp4 -map [AUDIO-extra-0] -ss 12.1 -t 10.4 -vn audio extra -metadata comment=Transcoded using Encore /tmp/123/extra.mp4") } private fun output(twoPass: Boolean): Output { diff --git a/src/test/resources/application-test-local.yml b/src/test/resources/application-test-local.yml index a0f84ccb..55811db8 100644 --- a/src/test/resources/application-test-local.yml +++ b/src/test/resources/application-test-local.yml @@ -19,13 +19,11 @@ encore-settings: local-temporary-encode: true poll-initial-delay: 1s poll-delay: 1s - audio-mix-presets: - default: - pan-mapping: - 6: - 2: stereo|c0=1.0*c0+0.707*c2+0.707*c4|c1=1.0*c1+0.707*c2+0.707*c5 - de: - fallback-to-auto: false - + encoding: + audio-mix-presets: + default: + fallback-to-auto: true + de: + fallback-to-auto: false profile: location: classpath:profile/profiles.yml diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml index 8a20cd1c..da270833 100644 --- a/src/test/resources/application-test.yml +++ b/src/test/resources/application-test.yml @@ -25,18 +25,24 @@ encore-settings: local-temporary-encode: false poll-initial-delay: 1s poll-delaly: 1s - - audio-mix-presets: - default: - pan-mapping: - 6: - 2: stereo|c0=1.0*c0+0.707*c2+0.707*c4|c1=1.0*c1+0.707*c2+0.707*c5 - de: - fallback-to-auto: false - pan-mapping: - 6: - 2: stereo|c0<0.25*c0+1.5*c2+0.25*c4|c1<0.25*c1+1.5*c2+0.25*c5 - + encoding: + default-channel-layouts: + 3: "3.0" + audio-mix-presets: + default: + default-pan: + "[5.1]": c0 = c0 | c1 = c1 | c2 = c2 | c3 = c3 | c4 = c4 | c5 = c5 + stereo: c0 = c0 + 0.707*c2 + 0.707*c4 | c1 = c1 + 0.707*c2 + 0.707*c5 + pan-mapping: + "[5.1]": + stereo: c0=1.0*c0+0.707*c2+0.707*c4|c1=1.0*c1+0.707*c2+0.707*c5 + de: + fallback-to-auto: false + pan-mapping: + "[5.1]": + stereo: c0<0.25*c0+1.5*c2+0.25*c4|c1<0.25*c1+1.5*c2+0.25*c5 + "[5.1(side)]": + stereo: c0<0.25*c0+1.5*c2+0.25*c4|c1<0.25*c1+1.5*c2+0.25*c5 profile: location: classpath:profile/profiles.yml @@ -45,4 +51,4 @@ feign: client: config: default: - readTimeout: 20000 + logger-level: basic diff --git a/src/test/resources/input/multiple-audio-file.json b/src/test/resources/input/multiple-audio-file.json index aed6cb99..dc288653 100644 --- a/src/test/resources/input/multiple-audio-file.json +++ b/src/test/resources/input/multiple-audio-file.json @@ -10,6 +10,7 @@ "codec": "aac", "duration": 10.005, "channels": 2, + "channelLayout":"stereo", "samplingRate": 48000, "bitrate": 84538 }, @@ -18,6 +19,7 @@ "codec": "ac3", "duration": 10.01, "channels": 6, + "channelLayout":"5.1(side)", "samplingRate": 48000, "bitrate": 320000 } diff --git a/src/test/resources/input/portrait-video-file.json b/src/test/resources/input/portrait-video-file.json new file mode 100644 index 00000000..55ed48c1 --- /dev/null +++ b/src/test/resources/input/portrait-video-file.json @@ -0,0 +1,94 @@ +{ + "type": "VideoFile", + "file": "/input/test.mp4", + "fileSize": 2660527, + "format": "MPEG-4", + "overallBitrate": 2123749, + "duration": 10.022, + "videoStreams": [ + { + "format": "AVC", + "codec": "h264", + "profile": "High 4:2:2", + "level": "4", + "width": 1080, + "height": 1920, + "sampleAspectRatio": "1:1", + "displayAspectRatio": "9:16", + "pixelFormat": "yuv422p", + "frameRate": "25/1", + "duration": 10.0, + "bitrate": 1947964, + "bitDepth": 8, + "numFrames": 250, + "isInterlaced": false, + "transferCharacteristics": null + } + ], + "audioStreams": [ + { + "format": "AAC", + "codec": "aac", + "duration": 10.0, + "channels": 1, + "samplingRate": 48000, + "bitrate": 69591 + }, + { + "format": "AAC", + "codec": "aac", + "duration": 10.0, + "channels": 1, + "samplingRate": 48000, + "bitrate": 69575 + }, + { + "format": "AAC", + "codec": "aac", + "duration": 10.0, + "channels": 1, + "samplingRate": 48000, + "bitrate": 1527 + }, + { + "format": "AAC", + "codec": "aac", + "duration": 10.0, + "channels": 1, + "samplingRate": 48000, + "bitrate": 1527 + }, + { + "format": "AAC", + "codec": "aac", + "duration": 10.0, + "channels": 1, + "samplingRate": 48000, + "bitrate": 1527 + }, + { + "format": "AAC", + "codec": "aac", + "duration": 10.0, + "channels": 1, + "samplingRate": 48000, + "bitrate": 1527 + }, + { + "format": "AAC", + "codec": "aac", + "duration": 10.0, + "channels": 1, + "samplingRate": 48000, + "bitrate": 1527 + }, + { + "format": "AAC", + "codec": "aac", + "duration": 10.0, + "channels": 1, + "samplingRate": 48000, + "bitrate": 1527 + } + ] +} \ No newline at end of file diff --git a/src/test/resources/input/video-file.json b/src/test/resources/input/video-file.json index c4971c0d..feb7a782 100644 --- a/src/test/resources/input/video-file.json +++ b/src/test/resources/input/video-file.json @@ -31,6 +31,7 @@ "codec": "aac", "duration": 10.0, "channels": 1, + "channelLayout":"mono", "samplingRate": 48000, "bitrate": 69591 }, @@ -39,6 +40,7 @@ "codec": "aac", "duration": 10.0, "channels": 1, + "channelLayout":"mono", "samplingRate": 48000, "bitrate": 69575 }, @@ -47,6 +49,7 @@ "codec": "aac", "duration": 10.0, "channels": 1, + "channelLayout":"mono", "samplingRate": 48000, "bitrate": 1527 }, @@ -55,6 +58,7 @@ "codec": "aac", "duration": 10.0, "channels": 1, + "channelLayout":"mono", "samplingRate": 48000, "bitrate": 1527 }, @@ -63,6 +67,7 @@ "codec": "aac", "duration": 10.0, "channels": 1, + "channelLayout":"mono", "samplingRate": 48000, "bitrate": 1527 }, @@ -71,6 +76,7 @@ "codec": "aac", "duration": 10.0, "channels": 1, + "channelLayout":"mono", "samplingRate": 48000, "bitrate": 1527 }, @@ -79,6 +85,7 @@ "codec": "aac", "duration": 10.0, "channels": 1, + "channelLayout":"mono", "samplingRate": 48000, "bitrate": 1527 }, @@ -87,6 +94,7 @@ "codec": "aac", "duration": 10.0, "channels": 1, + "channelLayout":"mono", "samplingRate": 48000, "bitrate": 1527 } diff --git a/src/test/resources/profile/archive.yml b/src/test/resources/profile/archive.yml index e22a0fee..7a3d38b5 100644 --- a/src/test/resources/profile/archive.yml +++ b/src/test/resources/profile/archive.yml @@ -11,5 +11,5 @@ encodes: format: mxf twoPass: false audioEncode: - type: AudioEncode + type: SimpleAudioEncode codec: pcm_s24le diff --git a/src/test/resources/profile/audio-streams.yml b/src/test/resources/profile/audio-streams.yml index ec5fef65..fe3aa5aa 100644 --- a/src/test/resources/profile/audio-streams.yml +++ b/src/test/resources/profile/audio-streams.yml @@ -13,8 +13,8 @@ encodes: - type: AudioEncode codec: ac3 bitrate: 448k - channels: 6 + channelLayout: '5.1' optional: true - type: AudioEncode bitrate: 128k - channels: 2 \ No newline at end of file + channelLayout: 'stereo' \ No newline at end of file diff --git a/src/test/resources/profile/multiple_inputs.yml b/src/test/resources/profile/multiple_inputs.yml index a0935660..6041b208 100644 --- a/src/test/resources/profile/multiple_inputs.yml +++ b/src/test/resources/profile/multiple_inputs.yml @@ -57,7 +57,7 @@ encodes: bitrate: 448k suffix: _SURROUND optional: true - channels: 6 + channelLayout: '5.1(side)' - type: AudioEncode bitrate: 128k diff --git a/src/test/resources/profile/program-x265.yml b/src/test/resources/profile/program-x265.yml index 5eaac3d8..24b113b7 100644 --- a/src/test/resources/profile/program-x265.yml +++ b/src/test/resources/profile/program-x265.yml @@ -468,7 +468,7 @@ encodes: codec: ac3 bitrate: 448k suffix: _SURROUND - channels: 6 + channelLayout: '5.1' - type: ThumbnailMapEncode diff --git a/src/test/resources/profile/program.yml b/src/test/resources/profile/program.yml index 000d474b..5b3feb9a 100644 --- a/src/test/resources/profile/program.yml +++ b/src/test/resources/profile/program.yml @@ -216,7 +216,7 @@ encodes: bitrate: 448k suffix: _SURROUND optional: true - channels: 6 + channelLayout: '5.1' - type: ThumbnailMapEncode