From d17acb10ea91e7874242d839a922bfd480f16665 Mon Sep 17 00:00:00 2001 From: Eugene Morozov Date: Thu, 1 Oct 2026 13:55:03 -0700 Subject: [PATCH 1/5] Add a pure Standard Input policy and planner --- .../internal/standardization/MediaFacts.kt | 68 ++++ .../standardization/StandardInputPlanner.kt | 167 ++++++++++ .../standardization/StandardInputPolicy.kt | 151 +++++++++ .../StandardInputPlannerTests.kt | 303 ++++++++++++++++++ .../StandardInputPolicyTests.kt | 180 +++++++++++ .../standardization/StandardInputTestFacts.kt | 40 +++ 6 files changed, 909 insertions(+) create mode 100644 library/src/main/java/com/mux/video/upload/internal/standardization/MediaFacts.kt create mode 100644 library/src/main/java/com/mux/video/upload/internal/standardization/StandardInputPlanner.kt create mode 100644 library/src/main/java/com/mux/video/upload/internal/standardization/StandardInputPolicy.kt create mode 100644 library/src/test/java/com/mux/video/upload/internal/standardization/StandardInputPlannerTests.kt create mode 100644 library/src/test/java/com/mux/video/upload/internal/standardization/StandardInputPolicyTests.kt create mode 100644 library/src/test/java/com/mux/video/upload/internal/standardization/StandardInputTestFacts.kt diff --git a/library/src/main/java/com/mux/video/upload/internal/standardization/MediaFacts.kt b/library/src/main/java/com/mux/video/upload/internal/standardization/MediaFacts.kt new file mode 100644 index 00000000..31a56077 --- /dev/null +++ b/library/src/main/java/com/mux/video/upload/internal/standardization/MediaFacts.kt @@ -0,0 +1,68 @@ +package com.mux.video.upload.internal.standardization + +/** Unproven facts remain unknown; absence of metadata is not a compliant value. */ +internal sealed interface MediaFact { + data class Known(val value: T) : MediaFact + data object Unknown : MediaFact +} + +internal val MediaFact.valueOrNull: T? + get() = (this as? MediaFact.Known)?.value + +internal enum class VideoCodec { H264, Hevc, Other } +internal enum class VideoProfile { H264Baseline, H264Main, H264High, HevcMain, HevcMain10, Other } +internal enum class ChromaSubsampling { Yuv420, Other } +internal data class PixelFormat(val bitDepth: Int, val chromaSubsampling: ChromaSubsampling) +internal enum class DynamicRange { Sdr, Hlg, Pq, DolbyVision, OtherHdr } +internal enum class GopStructure { ClosedWithIdr, ClosedWithoutIdr, Open } +internal enum class EditList { None, Simple, Complex } +internal enum class Cadence { Constant, Variable } +internal enum class AudioChannelLayout { Mono, Stereo, FivePointOne, Other } + +internal sealed interface AudioFormat { + data class Aac(val layout: AudioChannelLayout) : AudioFormat + data object OtherCodec : AudioFormat +} + +/** Track order is container order, so conversion can select the first audio track. */ +internal data class AudioTrack(val format: MediaFact = MediaFact.Unknown) + +internal data class Dimensions(val width: Int, val height: Int) { + val longSide: Int get() = maxOf(width, height) + val shortSide: Int get() = minOf(width, height) + val isValid: Boolean get() = width > 0 && height > 0 + + fun fitsWithin(bounds: Dimensions): Boolean = + longSide <= bounds.longSide && shortSide <= bounds.shortSide +} + +internal data class TimestampFacts( + val firstPresentationSeconds: Double, + val lastPresentationSeconds: Double, + val sampleCount: Long, + val isMonotonic: Boolean, +) + +/** Normalized evidence only. Inspection, hardware probing, files, and Android APIs live elsewhere. */ +internal data class MediaFacts( + val videoCodec: MediaFact = MediaFact.Unknown, + val videoProfile: MediaFact = MediaFact.Unknown, + val encodedDimensions: MediaFact = MediaFact.Unknown, + val displayDimensions: MediaFact = MediaFact.Unknown, + val rotationDegrees: MediaFact = MediaFact.Unknown, + val videoTrackCount: MediaFact = MediaFact.Unknown, + val frameRate: MediaFact = MediaFact.Unknown, + val cadence: MediaFact = MediaFact.Unknown, + val timestamps: MediaFact = MediaFact.Unknown, + val durationSeconds: MediaFact = MediaFact.Unknown, + val audioVideoStartOffsetSeconds: MediaFact = MediaFact.Unknown, + val averageBitrate: MediaFact = MediaFact.Unknown, + val maximumGopBitrate: MediaFact = MediaFact.Unknown, + val maximumGopByteSize: MediaFact = MediaFact.Unknown, + val maximumKeyframeIntervalSeconds: MediaFact = MediaFact.Unknown, + val gopStructure: MediaFact = MediaFact.Unknown, + val pixelFormat: MediaFact = MediaFact.Unknown, + val dynamicRange: MediaFact = MediaFact.Unknown, + val audioTracks: MediaFact> = MediaFact.Unknown, + val editList: MediaFact = MediaFact.Unknown, +) diff --git a/library/src/main/java/com/mux/video/upload/internal/standardization/StandardInputPlanner.kt b/library/src/main/java/com/mux/video/upload/internal/standardization/StandardInputPlanner.kt new file mode 100644 index 00000000..fc47977b --- /dev/null +++ b/library/src/main/java/com/mux/video/upload/internal/standardization/StandardInputPlanner.kt @@ -0,0 +1,167 @@ +package com.mux.video.upload.internal.standardization + +import com.mux.video.upload.api.HdrHandling +import com.mux.video.upload.internal.InputStandardization +import kotlin.math.floor + +/** Proof for this source and conversion path, supplied by the caller, never inferred from a codec list. */ +internal data class PlanningCapabilities( + val sourceIsDecodable: Boolean = false, + val encodableVideoCodecs: Set = emptySet(), + val remediableRequirements: Set = emptySet(), + val toneMappableDynamicRanges: Set = emptySet(), + // Unchanged HDR upload does not need a local encoder or a server-configuration probe. + val preservableHdrDynamicRanges: Set = setOf(DynamicRange.Hlg, DynamicRange.Pq), +) { + fun supports(conversion: StandardInputConversion): Boolean = + sourceIsDecodable && conversion.outputCodec in encodableVideoCodecs && + remediableRequirements.containsAll(conversion.requirementsToRemediate) && + (!conversion.toneMapsToSdr || conversion.sourceDynamicRange in toneMappableDynamicRanges) +} + +internal enum class OutputAudio { + None, + /** Produce AAC from audio track ordinal zero. Compliant AAC may be copied; this adds no surround encoder. */ + AacFromFirstTrack, + /** Unknown audio presence must be resolved during preparation, never interpreted as silence. */ + AacFromFirstTrackIfPresent, +} + +/** Always a whole-file SDR conversion. Generated bytes still require reinspection before upload. */ +internal data class StandardInputConversion( + val sourceCodec: VideoCodec, + val outputCodec: VideoCodec, + val sourceDynamicRange: DynamicRange, + val sourceDisplayDimensions: MediaFact, + val toneMapsToSdr: Boolean, + val selection: PolicySelection, + val requirementsToRemediate: Set, + val outputDimensions: MediaFact, + val outputFrameRate: MediaFact, + val outputPixelFormat: PixelFormat, + val outputAudio: OutputAudio, +) { + val outputDynamicRange: DynamicRange get() = DynamicRange.Sdr + val requiresBt709ToneMapping: Boolean get() = toneMapsToSdr +} + +internal sealed interface OriginalReason { + data object StandardizationNotRequested : OriginalReason + data object StandardInput : OriginalReason + data object NoKnownStandardInputViolation : OriginalReason + data class PreserveHdr(val dynamicRange: DynamicRange) : OriginalReason +} + +internal sealed interface FallbackReason { + data object InsufficientEvidenceForConversion : FallbackReason + data object UnsafeVideoTrackStructure : FallbackReason + data class UnsupportedConversion(val conversion: StandardInputConversion) : FallbackReason + data class UnsupportedHdr(val dynamicRange: DynamicRange) : FallbackReason + data class HdrPreservationUnavailable(val dynamicRange: DynamicRange) : FallbackReason + data class NonStandardHdr( + val dynamicRange: DynamicRange, + val requirements: Set, + ) : FallbackReason +} + +internal sealed interface StandardInputAction { + data class UploadOriginal(val reason: OriginalReason) : StandardInputAction + data class Convert(val conversion: StandardInputConversion) : StandardInputAction + /** A categorized local failure still selects original bytes; cancellation belongs to orchestration. */ + data class Fallback(val reason: FallbackReason) : StandardInputAction +} + +internal data class StandardInputPlan(val action: StandardInputAction, val evaluation: PolicyEvaluation) + +internal class StandardInputPlanner( + private val evaluator: StandardInputPolicyEvaluator = StandardInputPolicyEvaluator(), +) { + fun plan( + facts: MediaFacts, + options: InputStandardization = InputStandardization(), + capabilities: PlanningCapabilities = PlanningCapabilities(), + ): StandardInputPlan { + val selection = PolicySelection(options.maximumResolution) + val evaluation = evaluator.evaluate(facts, selection) + fun original(reason: OriginalReason) = StandardInputPlan(StandardInputAction.UploadOriginal(reason), evaluation) + fun fallback(reason: FallbackReason) = StandardInputPlan(StandardInputAction.Fallback(reason), evaluation) + + if (!options.standardizationRequested) return original(OriginalReason.StandardizationNotRequested) + val trackCount = facts.videoTrackCount.valueOrNull + if (trackCount != null && trackCount != 1) return fallback(FallbackReason.UnsafeVideoTrackStructure) + + val requirements = evaluation.nonCompliantRequirements.toMutableSet() + facts.displayDimensions.valueOrNull?.takeIf { it.isValid }?.let { + if (!it.fitsWithin(selection.generatedOutputDimensions)) requirements.add(PolicyRequirement.VideoResolution) + } + val range = facts.dynamicRange.valueOrNull + val toneMap = range in setOf(DynamicRange.Hlg, DynamicRange.Pq) && options.hdrHandling == HdrHandling.ToneMapToSDR + when (range) { + DynamicRange.DolbyVision, DynamicRange.OtherHdr -> return fallback(FallbackReason.UnsupportedHdr(range)) + DynamicRange.Hlg, DynamicRange.Pq -> { + if (!toneMap) { + if (evaluation.checks[PolicyRequirement.DynamicRange] != PolicyStatus.Compliant) { + return fallback(FallbackReason.UnsupportedHdr(range)) + } + if (range !in capabilities.preservableHdrDynamicRanges) { + return fallback(FallbackReason.HdrPreservationUnavailable(range)) + } + if (requirements.isNotEmpty()) return fallback(FallbackReason.NonStandardHdr(range, requirements.toSet())) + return original(OriginalReason.PreserveHdr(range)) + } + if (facts.videoCodec.valueOrNull !in setOf(VideoCodec.H264, VideoCodec.Hevc)) { + return fallback(FallbackReason.UnsupportedHdr(range)) + } + } + null -> if (requirements.isNotEmpty()) return fallback(FallbackReason.InsufficientEvidenceForConversion) + DynamicRange.Sdr -> Unit + } + if (requirements.isEmpty() && !toneMap) return original( + if (evaluation.outcome == PolicyStatus.Compliant) OriginalReason.StandardInput + else OriginalReason.NoKnownStandardInputViolation) + + val codec = facts.videoCodec.valueOrNull ?: return fallback(FallbackReason.InsufficientEvidenceForConversion) + if (range == null) return fallback(FallbackReason.InsufficientEvidenceForConversion) + val dimensions = outputDimensions(facts.displayDimensions, selection.generatedOutputDimensions) + // A known one-pixel axis cannot produce 4:2:0 output without upscaling. + if (facts.displayDimensions.valueOrNull?.isValid == true && dimensions == MediaFact.Unknown) { + return fallback(FallbackReason.InsufficientEvidenceForConversion) + } + val outputCodec = if (codec == VideoCodec.Other) VideoCodec.H264 else codec + val outputLimits = evaluator.profile.limits(evaluator.effectiveTier(dimensions, selection)) + val frameRate = facts.frameRate.valueOrNull?.takeIf { it.isFinite() && it > 0 }?.let { + MediaFact.Known(if (it in outputLimits.minimumFrameRate..outputLimits.maximumFrameRate) it else 30.0) + } ?: MediaFact.Unknown + val audio = facts.audioTracks.valueOrNull + val conversion = StandardInputConversion( + sourceCodec = codec, + outputCodec = outputCodec, + sourceDynamicRange = range, + sourceDisplayDimensions = facts.displayDimensions, + toneMapsToSdr = toneMap, + selection = selection, + requirementsToRemediate = requirements.toSet(), + outputDimensions = dimensions, + outputFrameRate = frameRate, + outputPixelFormat = if (!toneMap && outputCodec == VideoCodec.Hevc && + facts.pixelFormat.valueOrNull == PixelFormat(10, ChromaSubsampling.Yuv420)) { + PixelFormat(10, ChromaSubsampling.Yuv420) + } else PixelFormat(8, ChromaSubsampling.Yuv420), + outputAudio = when { + audio == null -> OutputAudio.AacFromFirstTrackIfPresent + audio.isEmpty() -> OutputAudio.None + else -> OutputAudio.AacFromFirstTrack + }, + ) + return if (capabilities.supports(conversion)) StandardInputPlan(StandardInputAction.Convert(conversion), evaluation) + else fallback(FallbackReason.UnsupportedConversion(conversion)) + } + + private fun outputDimensions(source: MediaFact, bounds: Dimensions): MediaFact { + val size = source.valueOrNull?.takeIf { it.isValid && it.shortSide >= 2 } ?: return MediaFact.Unknown + val scale = minOf(1.0, bounds.longSide.toDouble() / size.longSide, bounds.shortSide.toDouble() / size.shortSide) + // Match Swift's nearest even alignment, capped at the source axis to prevent odd-size upscaling. + fun aligned(axis: Int): Int = minOf(axis / 2 * 2, maxOf(2, (floor(axis * scale / 2 + 0.5) * 2).toInt())) + return MediaFact.Known(Dimensions(aligned(size.width), aligned(size.height))) + } +} diff --git a/library/src/main/java/com/mux/video/upload/internal/standardization/StandardInputPolicy.kt b/library/src/main/java/com/mux/video/upload/internal/standardization/StandardInputPolicy.kt new file mode 100644 index 00000000..3d6655e3 --- /dev/null +++ b/library/src/main/java/com/mux/video/upload/internal/standardization/StandardInputPolicy.kt @@ -0,0 +1,151 @@ +package com.mux.video.upload.internal.standardization + +import com.mux.video.upload.internal.MaximumResolution + +internal enum class AcceptanceTier { UpTo1080p, HighResolution } +internal enum class MediaRole { SourceInput, GeneratedOutput } +internal enum class PolicyStatus { Compliant, NonCompliant, Unknown } +internal enum class PolicyRequirement { + VideoCodec, VideoResolution, FrameRate, AverageBitrate, MaximumGopBitrate, + KeyframeInterval, GopStructure, PixelFormat, DynamicRange, Audio, EditList, +} + +internal data class PolicySelection( + val acceptanceTier: AcceptanceTier, + val generatedOutputDimensions: Dimensions, +) { + constructor(maximumResolution: MaximumResolution) : this( + acceptanceTier = when (maximumResolution) { + MaximumResolution.Preset2560x1440, MaximumResolution.Preset3840x2160 -> AcceptanceTier.HighResolution + else -> AcceptanceTier.UpTo1080p + }, + generatedOutputDimensions = Dimensions(maximumResolution.width, maximumResolution.height), + ) +} + +internal data class TierLimits( + val maximumSourceDimension: Int, + val minimumFrameRate: Double, + val maximumFrameRate: Double, + val maximumAverageBitrate: Long, + val maximumGopBitrate: Long?, + val maximumKeyframeIntervals: Map, +) + +/** Published acceptance ceilings, separate from encoder targets. Boundaries match shipped Swift. */ +internal data class StandardInputPolicyProfile( + val upTo1080p: TierLimits, + val highResolution: TierLimits, +) { + fun limits(tier: AcceptanceTier): TierLimits = when (tier) { + AcceptanceTier.UpTo1080p -> upTo1080p + AcceptanceTier.HighResolution -> highResolution + } + + companion object { + val PublishedMux = StandardInputPolicyProfile( + upTo1080p = TierLimits(2048, 5.0, 120.0, 8_000_000, 16_000_000, + mapOf(VideoCodec.H264 to 20.0, VideoCodec.Hevc to 10.0)), + highResolution = TierLimits(4096, 5.0, 60.0, 20_000_000, null, + mapOf(VideoCodec.H264 to 10.0, VideoCodec.Hevc to 6.0)), + ) + } +} + +internal data class PolicyEvaluation(val checks: Map) { + val outcome: PolicyStatus get() = when { + PolicyStatus.NonCompliant in checks.values -> PolicyStatus.NonCompliant + PolicyStatus.Unknown in checks.values -> PolicyStatus.Unknown + else -> PolicyStatus.Compliant + } + val nonCompliantRequirements: Set + get() = checks.filterValues { it == PolicyStatus.NonCompliant }.keys + val unknownRequirements: Set + get() = checks.filterValues { it == PolicyStatus.Unknown }.keys +} + +internal class StandardInputPolicyEvaluator( + val profile: StandardInputPolicyProfile = StandardInputPolicyProfile.PublishedMux, +) { + fun effectiveTier(dimensions: MediaFact, selection: PolicySelection): AcceptanceTier { + val size = dimensions.valueOrNull + return if (selection.acceptanceTier == AcceptanceTier.HighResolution && size?.isValid == true && + size.longSide <= profile.upTo1080p.maximumSourceDimension) { + AcceptanceTier.UpTo1080p + } else selection.acceptanceTier + } + + fun evaluate( + facts: MediaFacts, + selection: PolicySelection, + role: MediaRole = MediaRole.SourceInput, + ): PolicyEvaluation { + val limits = profile.limits(effectiveTier(facts.displayDimensions, selection)) + val codec = facts.videoCodec.valueOrNull + val pixel = facts.pixelFormat.valueOrNull + val intervalLimit = limits.maximumKeyframeIntervals[codec] + return PolicyEvaluation(linkedMapOf( + PolicyRequirement.VideoCodec to check(facts.videoCodec) { it != VideoCodec.Other }, + PolicyRequirement.VideoResolution to dimensions(facts.displayDimensions, selection, limits, role), + PolicyRequirement.FrameRate to positiveFinite(facts.frameRate) { + it in limits.minimumFrameRate..limits.maximumFrameRate + }, + PolicyRequirement.AverageBitrate to positive(facts.averageBitrate) { it <= limits.maximumAverageBitrate }, + PolicyRequirement.MaximumGopBitrate to (limits.maximumGopBitrate?.let { maximum -> + positive(facts.maximumGopBitrate) { it <= maximum } + } ?: PolicyStatus.Compliant), + PolicyRequirement.KeyframeInterval to (intervalLimit?.let { maximum -> + positiveFinite(facts.maximumKeyframeIntervalSeconds) { it <= maximum } + } ?: PolicyStatus.Unknown), + PolicyRequirement.GopStructure to check(facts.gopStructure) { it == GopStructure.ClosedWithIdr }, + PolicyRequirement.PixelFormat to (if (codec == null || pixel == null) PolicyStatus.Unknown else + status(pixel.chromaSubsampling == ChromaSubsampling.Yuv420 && + (codec == VideoCodec.H264 && pixel.bitDepth == 8 || + codec == VideoCodec.Hevc && pixel.bitDepth in setOf(8, 10)))), + PolicyRequirement.DynamicRange to dynamicRange(facts), + PolicyRequirement.Audio to audio(facts.audioTracks), + PolicyRequirement.EditList to check(facts.editList) { it != EditList.Complex }, + )) + } + + private fun dimensions( + fact: MediaFact, selection: PolicySelection, limits: TierLimits, role: MediaRole, + ): PolicyStatus { + val size = fact.valueOrNull?.takeIf { it.isValid } ?: return PolicyStatus.Unknown + return status(when (role) { + MediaRole.SourceInput -> size.longSide <= limits.maximumSourceDimension + MediaRole.GeneratedOutput -> size.fitsWithin(selection.generatedOutputDimensions) + }) + } + + private fun dynamicRange(facts: MediaFacts): PolicyStatus = when (facts.dynamicRange.valueOrNull) { + null -> PolicyStatus.Unknown + DynamicRange.Sdr -> PolicyStatus.Compliant + DynamicRange.DolbyVision, DynamicRange.OtherHdr -> PolicyStatus.NonCompliant + DynamicRange.Hlg, DynamicRange.Pq -> { + val codec = facts.videoCodec.valueOrNull + val pixel = facts.pixelFormat.valueOrNull + if (codec == null || pixel == null) PolicyStatus.Unknown else status( + codec == VideoCodec.Hevc && pixel == PixelFormat(10, ChromaSubsampling.Yuv420)) + } + } + + private fun audio(fact: MediaFact>): PolicyStatus { + val tracks = fact.valueOrNull ?: return PolicyStatus.Unknown + if (tracks.isEmpty()) return PolicyStatus.Compliant + if (tracks.size > 1) return PolicyStatus.Unknown + return check(tracks.single().format) { it is AudioFormat.Aac && it.layout != AudioChannelLayout.Other } + } + + private fun check(fact: MediaFact, predicate: (T) -> Boolean): PolicyStatus = + fact.valueOrNull?.let { status(predicate(it)) } ?: PolicyStatus.Unknown + + private fun positive(fact: MediaFact, predicate: (Long) -> Boolean): PolicyStatus = + fact.valueOrNull?.takeIf { it > 0 }?.let { status(predicate(it)) } ?: PolicyStatus.Unknown + + private fun positiveFinite(fact: MediaFact, predicate: (Double) -> Boolean): PolicyStatus = + fact.valueOrNull?.takeIf { it.isFinite() && it > 0 }?.let { status(predicate(it)) } ?: PolicyStatus.Unknown + + private fun status(compliant: Boolean): PolicyStatus = + if (compliant) PolicyStatus.Compliant else PolicyStatus.NonCompliant +} diff --git a/library/src/test/java/com/mux/video/upload/internal/standardization/StandardInputPlannerTests.kt b/library/src/test/java/com/mux/video/upload/internal/standardization/StandardInputPlannerTests.kt new file mode 100644 index 00000000..f0f638a1 --- /dev/null +++ b/library/src/test/java/com/mux/video/upload/internal/standardization/StandardInputPlannerTests.kt @@ -0,0 +1,303 @@ +package com.mux.video.upload.internal.standardization + +import com.mux.video.upload.api.HdrHandling +import com.mux.video.upload.internal.InputStandardization +import com.mux.video.upload.internal.MaximumResolution +import org.junit.Assert.* +import org.junit.Test + +class StandardInputPlannerTests { + private val planner = StandardInputPlanner() + + @Test fun compliantSharedCasesUploadOriginalWithoutEncodeCapabilities() { + for (codec in listOf(VideoCodec.H264, VideoCodec.Hevc)) { + for (resolution in MaximumResolution.entries) { + for (depth in if (codec == VideoCodec.Hevc) listOf(8, 10) else listOf(8)) { + val facts = compliantFacts(codec, Dimensions(resolution.width, resolution.height), + pixelFormat = PixelFormat(depth, ChromaSubsampling.Yuv420)) + assertEquals("$codec $resolution $depth", StandardInputAction.UploadOriginal(OriginalReason.StandardInput), + planner.plan(facts, options(resolution)).action) + } + } + } + } + + @Test fun disabledStandardizationAlwaysUploadsOriginal() { + for (range in DynamicRange.entries) { + val facts = compliantFacts(VideoCodec.Other, dynamicRange = range).copy(videoTrackCount = known(2), + frameRate = known(121.0), editList = known(EditList.Complex)) + assertEquals(StandardInputAction.UploadOriginal(OriginalReason.StandardizationNotRequested), + planner.plan(facts, InputStandardization(false, MaximumResolution.Default, HdrHandling.ToneMapToSDR)).action) + } + } + + @Test fun unknownOnlyFactsUploadOriginalWithoutConversion() { + for (facts in listOf(MediaFacts(), compliantFacts().copy(maximumGopBitrate = MediaFact.Unknown), + compliantFacts().copy(dynamicRange = MediaFact.Unknown), compliantFacts().copy(audioTracks = MediaFact.Unknown))) { + assertEquals(StandardInputAction.UploadOriginal(OriginalReason.NoKnownStandardInputViolation), planner.plan(facts).action) + assertEquals(PolicyStatus.Unknown, planner.plan(facts).evaluation.outcome) + } + } + + @Test fun knownViolationPreservesH264AndHevcFamilies() { + for (codec in listOf(VideoCodec.H264, VideoCodec.Hevc)) { + val conversion = conversion(compliantFacts(codec).copy(frameRate = known(121.0))) + assertEquals(codec, conversion.sourceCodec) + assertEquals(codec, conversion.outputCodec) + assertEquals(setOf(PolicyRequirement.FrameRate), conversion.requirementsToRemediate) + assertEquals(known(30.0), conversion.outputFrameRate) + assertFalse(conversion.toneMapsToSdr) + assertEquals(DynamicRange.Sdr, conversion.outputDynamicRange) + } + } + + @Test fun selectedOutputSizeTriggersConversionBelowPublishedSourceLimit() { + val facts = compliantFacts(dimensions = Dimensions(2048, 1152)) + val plan = planner.plan(facts, capabilities = fullCapabilities()) + assertEquals(PolicyStatus.Compliant, plan.evaluation.outcome) + val conversion = (plan.action as StandardInputAction.Convert).conversion + assertEquals(setOf(PolicyRequirement.VideoResolution), conversion.requirementsToRemediate) + assertEquals(known(Dimensions(1920, 1080)), conversion.outputDimensions) + } + + @Test fun wholeFileAudioViolationConvertsVideoInSameFamily() { + for (codec in listOf(VideoCodec.H264, VideoCodec.Hevc)) { + val conversion = conversion(compliantFacts(codec).copy(audioTracks = known(listOf(AudioTrack(known(AudioFormat.OtherCodec)))))) + assertEquals(setOf(PolicyRequirement.Audio), conversion.requirementsToRemediate) + assertEquals(codec, conversion.outputCodec) + assertEquals(OutputAudio.AacFromFirstTrack, conversion.outputAudio) + } + } + + @Test fun multipleAudioTracksAloneDoNotForceConversion() { + val tracks = listOf(AudioTrack(known(AudioFormat.OtherCodec)), AudioTrack(known(AudioFormat.Aac(AudioChannelLayout.Stereo)))) + val facts = compliantFacts().copy(audioTracks = known(tracks)) + assertEquals(StandardInputAction.UploadOriginal(OriginalReason.NoKnownStandardInputViolation), planner.plan(facts).action) + assertEquals(setOf(PolicyRequirement.Audio), planner.plan(facts).evaluation.unknownRequirements) + } + + @Test fun anotherViolationWithMultipleAudioTracksSelectsFirstTrack() { + val facts = compliantFacts().copy(frameRate = known(121.0), + audioTracks = known(listOf(AudioTrack(known(AudioFormat.OtherCodec)), AudioTrack()))) + val conversion = conversion(facts) + assertEquals(OutputAudio.AacFromFirstTrack, conversion.outputAudio) + assertEquals(setOf(PolicyRequirement.FrameRate), conversion.requirementsToRemediate) + assertEquals(StandardInputAction.UploadOriginal(OriginalReason.NoKnownStandardInputViolation), + planner.plan(facts.copy(frameRate = known(30.0))).action) + } + + @Test fun conversionDoesNotGuessUnknownAudioPresenceMeansSilence() { + val facts = compliantFacts().copy(frameRate = known(121.0)) + assertEquals(OutputAudio.AacFromFirstTrackIfPresent, conversion(facts.copy(audioTracks = MediaFact.Unknown)).outputAudio) + assertEquals(OutputAudio.None, conversion(facts.copy(audioTracks = known(emptyList()))).outputAudio) + assertEquals(OutputAudio.AacFromFirstTrack, conversion(facts.copy(audioTracks = known(listOf(AudioTrack())))).outputAudio) + } + + @Test fun otherDecodableSdrCodecConvertsToH264() { + val conversion = conversion(compliantFacts(VideoCodec.Other).copy(pixelFormat = known(PixelFormat(10, ChromaSubsampling.Other)))) + assertEquals(VideoCodec.Other, conversion.sourceCodec) + assertEquals(VideoCodec.H264, conversion.outputCodec) + assertTrue(PolicyRequirement.VideoCodec in conversion.requirementsToRemediate) + assertEquals(PixelFormat(8, ChromaSubsampling.Yuv420), conversion.outputPixelFormat) + } + + @Test fun noHevcEncoderFallsBackWithoutSubstitutingH264() { + val facts = compliantFacts(VideoCodec.Hevc).copy(maximumKeyframeIntervalSeconds = known(11.0)) + val reason = fallback(facts, fullCapabilities().copy(encodableVideoCodecs = setOf(VideoCodec.H264))) + as FallbackReason.UnsupportedConversion + assertEquals(VideoCodec.Hevc, reason.conversion.outputCodec) + assertEquals(setOf(PolicyRequirement.KeyframeInterval), reason.conversion.requirementsToRemediate) + } + + @Test fun undecodableSourceFallsBack() { + val reason = fallback(compliantFacts(VideoCodec.Other), fullCapabilities().copy(sourceIsDecodable = false)) + assertTrue(reason is FallbackReason.UnsupportedConversion) + } + + @Test fun unremediableViolationsFallBack() { + val cases = mapOf( + PolicyRequirement.FrameRate to compliantFacts().copy(frameRate = known(121.0)), + PolicyRequirement.AverageBitrate to compliantFacts().copy(averageBitrate = known(8_000_001L)), + PolicyRequirement.MaximumGopBitrate to compliantFacts().copy(maximumGopBitrate = known(16_000_001L)), + PolicyRequirement.KeyframeInterval to compliantFacts().copy(maximumKeyframeIntervalSeconds = known(21.0)), + PolicyRequirement.GopStructure to compliantFacts().copy(gopStructure = known(GopStructure.Open)), + PolicyRequirement.PixelFormat to compliantFacts().copy(pixelFormat = known(PixelFormat(10, ChromaSubsampling.Yuv420))), + PolicyRequirement.Audio to compliantFacts().copy(audioTracks = known(listOf(AudioTrack(known(AudioFormat.OtherCodec))))), + PolicyRequirement.EditList to compliantFacts().copy(editList = known(EditList.Complex)), + PolicyRequirement.VideoResolution to compliantFacts(dimensions = Dimensions(2048, 1152)), + PolicyRequirement.VideoCodec to compliantFacts(VideoCodec.Other), + ) + for ((requirement, facts) in cases) { + val reason = fallback(facts, fullCapabilities().copy(remediableRequirements = PolicyRequirement.entries.toSet() - requirement)) + assertTrue("$requirement", reason is FallbackReason.UnsupportedConversion) + } + } + + @Test fun missingCodecOrDynamicRangeWithKnownViolationFallsBack() { + for (facts in listOf( + compliantFacts().copy(videoCodec = MediaFact.Unknown, frameRate = known(121.0)), + compliantFacts().copy(dynamicRange = MediaFact.Unknown, frameRate = known(121.0)), + compliantFacts(dimensions = Dimensions(2048, 1152)).copy(dynamicRange = MediaFact.Unknown))) { + assertEquals(FallbackReason.InsufficientEvidenceForConversion, fallback(facts)) + } + } + + @Test fun unknownMeasurementsDoNotEraseKnownViolationsOrFabricateTargets() { + val facts = compliantFacts().copy(averageBitrate = known(8_000_001L), maximumGopBitrate = MediaFact.Unknown, + frameRate = MediaFact.Unknown, displayDimensions = MediaFact.Unknown) + val conversion = conversion(facts) + assertEquals(setOf(PolicyRequirement.AverageBitrate), conversion.requirementsToRemediate) + assertEquals(MediaFact.Unknown, conversion.outputDimensions) + assertEquals(MediaFact.Unknown, conversion.outputFrameRate) + } + + @Test fun eligibleHlgAndPqAreIntentionalOriginalPlansWithoutLocalEncoder() { + for (range in listOf(DynamicRange.Hlg, DynamicRange.Pq)) { + assertEquals(StandardInputAction.UploadOriginal(OriginalReason.PreserveHdr(range)), planner.plan(hdrFacts(range)).action) + } + } + + @Test fun eligibleHdrWithUnrelatedUnknownFactsStillUploadsOriginal() { + val facts = hdrFacts().copy(maximumGopBitrate = MediaFact.Unknown, + audioTracks = known(listOf(AudioTrack(), AudioTrack()))) + val plan = planner.plan(facts) + assertEquals(StandardInputAction.UploadOriginal(OriginalReason.PreserveHdr(DynamicRange.Hlg)), plan.action) + assertEquals(setOf(PolicyRequirement.MaximumGopBitrate, PolicyRequirement.Audio), plan.evaluation.unknownRequirements) + } + + @Test fun preservedHdrWithKnownViolationFallsBackWithoutHdrConversion() { + for (range in listOf(DynamicRange.Hlg, DynamicRange.Pq)) { + assertEquals(FallbackReason.NonStandardHdr(range, setOf(PolicyRequirement.AverageBitrate)), + fallback(hdrFacts(range).copy(averageBitrate = known(8_000_001L)))) + assertEquals(FallbackReason.NonStandardHdr(range, setOf(PolicyRequirement.VideoResolution)), + fallback(hdrFacts(range).copy(displayDimensions = known(Dimensions(2048, 1152))))) + } + } + + @Test fun unknownHdrEligibilityFallsBack() { + for (facts in listOf(hdrFacts().copy(pixelFormat = MediaFact.Unknown), hdrFacts().copy(videoCodec = MediaFact.Unknown), + hdrFacts().copy(pixelFormat = known(PixelFormat(8, ChromaSubsampling.Yuv420))), hdrFacts().copy(videoCodec = known(VideoCodec.H264)))) { + assertEquals(FallbackReason.UnsupportedHdr(DynamicRange.Hlg), fallback(facts)) + } + } + + @Test fun hdrPreservationUnavailableFallsBack() { + assertEquals(FallbackReason.HdrPreservationUnavailable(DynamicRange.Pq), + fallback(hdrFacts(DynamicRange.Pq), fullCapabilities().copy(preservableHdrDynamicRanges = setOf(DynamicRange.Hlg)))) + } + + @Test fun toneMappingAlwaysConvertsAndKeepsCodecFamily() { + for (codec in listOf(VideoCodec.H264, VideoCodec.Hevc)) for (range in listOf(DynamicRange.Hlg, DynamicRange.Pq)) { + val conversion = conversion(hdrFacts(range).copy(videoCodec = known(codec)), toneMapOptions()) + assertEquals(codec, conversion.outputCodec) + assertTrue(conversion.toneMapsToSdr) + assertTrue(conversion.requiresBt709ToneMapping) + assertEquals(DynamicRange.Sdr, conversion.outputDynamicRange) + assertEquals(PixelFormat(8, ChromaSubsampling.Yuv420), conversion.outputPixelFormat) + } + } + + @Test fun unsupportedToneMappingFallsBackPerRange() { + for (range in listOf(DynamicRange.Hlg, DynamicRange.Pq)) { + val reason = fallback(hdrFacts(range), fullCapabilities().copy(toneMappableDynamicRanges = emptySet()), toneMapOptions()) + as FallbackReason.UnsupportedConversion + assertEquals(range, reason.conversion.sourceDynamicRange) + assertTrue(reason.conversion.toneMapsToSdr) + } + } + + @Test fun unsupportedHdrAndHdrInOtherCodecFallBackForEitherOption() { + for (handling in HdrHandling.entries) { + val options = InputStandardization(true, MaximumResolution.Default, handling) + for (range in listOf(DynamicRange.OtherHdr, DynamicRange.DolbyVision)) { + assertEquals(FallbackReason.UnsupportedHdr(range), fallback(hdrFacts(range), options = options)) + } + assertEquals(FallbackReason.UnsupportedHdr(DynamicRange.Hlg), + fallback(hdrFacts().copy(videoCodec = known(VideoCodec.Other)), options = options)) + } + } + + @Test fun sdrDoesNotToneMapBecauseOptionIsSet() { + val facts = compliantFacts().copy(frameRate = known(121.0)) + assertFalse(conversion(facts, toneMapOptions()).toneMapsToSdr) + assertEquals(StandardInputAction.UploadOriginal(OriginalReason.StandardInput), planner.plan(compliantFacts(), toneMapOptions()).action) + } + + @Test fun tenBitHevcSdrConversionKeepsTenBit420() { + val facts = compliantFacts(VideoCodec.Hevc, pixelFormat = PixelFormat(10, ChromaSubsampling.Yuv420)) + .copy(frameRate = known(121.0)) + assertEquals(PixelFormat(10, ChromaSubsampling.Yuv420), conversion(facts).outputPixelFormat) + } + + @Test fun unsafeVideoTrackStructuresFallBack() { + for (count in listOf(-1, 0, 2, 3)) { + assertEquals(FallbackReason.UnsafeVideoTrackStructure, fallback(compliantFacts().copy(videoTrackCount = known(count)))) + } + } + + @Test fun portraitAndSquareConversionsFitBothAxes() { + val cases = listOf(Dimensions(2160, 3840) to Dimensions(1080, 1920), + Dimensions(1920, 1920) to Dimensions(1080, 1080), Dimensions(4096, 2160) to Dimensions(1920, 1012)) + for ((source, expected) in cases) { + assertEquals(known(expected), conversion(compliantFacts(dimensions = source)).outputDimensions) + } + } + + @Test fun everyTierBoundsOutputWithoutUpscaling() { + val sizes = listOf(Dimensions(640, 360), Dimensions(1919, 1079), Dimensions(3840, 2160), + Dimensions(2160, 3840), Dimensions(4096, 4096), Dimensions(2, 2), Dimensions(100, 3999)) + for (resolution in MaximumResolution.entries) for (source in sizes) { + val conversion = conversion(compliantFacts(dimensions = source).copy(gopStructure = known(GopStructure.Open)), options(resolution)) + val output = conversion.outputDimensions.valueOrNull!! + assertTrue("$source $resolution -> $output", output.fitsWithin(Dimensions(resolution.width, resolution.height))) + assertTrue(output.width <= source.width && output.height <= source.height) + assertEquals(0, output.width % 2) + assertEquals(0, output.height % 2) + } + } + + @Test fun nearestEvenAlignmentMatchesSwiftCinematicCase() { + assertEquals(known(Dimensions(1280, 546)), conversion(compliantFacts(dimensions = Dimensions(1920, 818)), + options(MaximumResolution.Preset1280x720)).outputDimensions) + } + + @Test fun onePixelAxisCannotBeConvertedWithoutUpscaling() { + assertEquals(FallbackReason.InsufficientEvidenceForConversion, + fallback(compliantFacts(dimensions = Dimensions(1, 100)).copy(gopStructure = known(GopStructure.Open)))) + } + + @Test fun downscaledOutputUsesOutputTierForFrameRate() { + val source = compliantFacts(dimensions = Dimensions(3840, 2160)).copy(frameRate = known(120.0)) + assertEquals(known(30.0), conversion(source, options(MaximumResolution.Preset3840x2160)).outputFrameRate) + assertEquals(known(120.0), conversion(source, options(MaximumResolution.Preset1920x1080)).outputFrameRate) + } + + @Test fun plansAreDeterministicAndDoNotMutateInput() { + val facts = compliantFacts().copy(frameRate = known(121.0)) + val original = facts.copy() + val capabilities = fullCapabilities() + assertEquals(planner.plan(facts, capabilities = capabilities), planner.plan(facts, capabilities = capabilities)) + assertEquals(original, facts) + assertEquals(PolicyRequirement.entries.toSet(), capabilities.remediableRequirements) + assertEquals(planner.plan(facts, options(), capabilities), + planner.plan(facts, options(MaximumResolution.Preset1920x1080), capabilities)) + } + + private fun conversion(facts: MediaFacts, options: InputStandardization = InputStandardization()): StandardInputConversion { + val action = planner.plan(facts, options, fullCapabilities()).action + assertTrue("Expected conversion, got $action", action is StandardInputAction.Convert) + return (action as StandardInputAction.Convert).conversion + } + + private fun fallback( + facts: MediaFacts, + capabilities: PlanningCapabilities = fullCapabilities(), + options: InputStandardization = InputStandardization(), + ): FallbackReason { + val action = planner.plan(facts, options, capabilities).action + assertTrue("Expected fallback, got $action", action is StandardInputAction.Fallback) + return (action as StandardInputAction.Fallback).reason + } + + private fun toneMapOptions() = InputStandardization(true, MaximumResolution.Default, HdrHandling.ToneMapToSDR) +} diff --git a/library/src/test/java/com/mux/video/upload/internal/standardization/StandardInputPolicyTests.kt b/library/src/test/java/com/mux/video/upload/internal/standardization/StandardInputPolicyTests.kt new file mode 100644 index 00000000..51b2fb3a --- /dev/null +++ b/library/src/test/java/com/mux/video/upload/internal/standardization/StandardInputPolicyTests.kt @@ -0,0 +1,180 @@ +package com.mux.video.upload.internal.standardization + +import com.mux.video.upload.internal.MaximumResolution +import org.junit.Assert.* +import org.junit.Test + +class StandardInputPolicyTests { + private val evaluator = StandardInputPolicyEvaluator() + + @Test fun resolutionSelectionsKeepAcceptanceSeparateFromOutput() { + for (resolution in MaximumResolution.entries) { + val selection = selection(resolution) + assertEquals(Dimensions(resolution.width, resolution.height), selection.generatedOutputDimensions) + assertEquals(if (resolution.width > 2048) AcceptanceTier.HighResolution else AcceptanceTier.UpTo1080p, + selection.acceptanceTier) + } + assertEquals(selection(), selection(MaximumResolution.Preset1920x1080)) + } + + @Test fun lowerTierInclusiveBoundariesMatchSwift() { + val facts = compliantFacts().copy(frameRate = known(120.0), averageBitrate = known(8_000_000L), + maximumGopBitrate = known(16_000_000L), maximumKeyframeIntervalSeconds = known(20.0)) + assertEquals(PolicyStatus.Compliant, evaluate(facts).outcome) + assertEquals(PolicyStatus.Compliant, evaluate(facts.copy(frameRate = known(5.0))).outcome) + } + + @Test fun frameRateBoundariesForBothTiers() { + for (resolution in MaximumResolution.entries) { + val high = resolution.width > 2048 + val facts = compliantFacts(dimensions = Dimensions(resolution.width, resolution.height)) + val maximum = if (high) 60.0 else 120.0 + for ((rate, expected) in listOf(4.999 to PolicyStatus.NonCompliant, 5.0 to PolicyStatus.Compliant, + maximum to PolicyStatus.Compliant, maximum + 0.001 to PolicyStatus.NonCompliant)) { + assertEquals("$resolution $rate", expected, evaluate(facts.copy(frameRate = known(rate)), resolution) + .checks[PolicyRequirement.FrameRate]) + } + } + } + + @Test fun codecKeyframeLimitsForBothTiers() { + for (codec in listOf(VideoCodec.H264, VideoCodec.Hevc)) { + for ((resolution, maximum) in listOf( + MaximumResolution.Default to if (codec == VideoCodec.H264) 20.0 else 10.0, + MaximumResolution.Preset2560x1440 to if (codec == VideoCodec.H264) 10.0 else 6.0, + MaximumResolution.Preset3840x2160 to if (codec == VideoCodec.H264) 10.0 else 6.0)) { + val facts = compliantFacts(codec, Dimensions(resolution.width, resolution.height)) + assertEquals(PolicyStatus.Compliant, evaluate(facts.copy(maximumKeyframeIntervalSeconds = known(maximum)), resolution) + .checks[PolicyRequirement.KeyframeInterval]) + assertEquals(PolicyStatus.NonCompliant, evaluate(facts.copy(maximumKeyframeIntervalSeconds = known(maximum + 0.001)), resolution) + .checks[PolicyRequirement.KeyframeInterval]) + } + } + } + + @Test fun averageAndGopBitratesAreIndependent() { + val averageViolation = evaluate(compliantFacts().copy(averageBitrate = known(8_000_001L))) + assertEquals(setOf(PolicyRequirement.AverageBitrate), averageViolation.nonCompliantRequirements) + val gopViolation = evaluate(compliantFacts().copy(maximumGopBitrate = known(16_000_001L))) + assertEquals(setOf(PolicyRequirement.MaximumGopBitrate), gopViolation.nonCompliantRequirements) + assertEquals(PolicyStatus.Compliant, evaluate(compliantFacts().copy(maximumGopByteSize = known(Long.MAX_VALUE))).outcome) + } + + @Test fun highTierHasNoInventedPerGopCeiling() { + for (bitrate in listOf(MediaFact.Unknown, known(Long.MAX_VALUE))) { + val facts = compliantFacts(dimensions = Dimensions(2560, 1440)).copy( + averageBitrate = known(20_000_000L), maximumGopBitrate = bitrate) + assertEquals(PolicyStatus.Compliant, evaluate(facts, MaximumResolution.Preset2560x1440).outcome) + assertEquals(setOf(PolicyRequirement.AverageBitrate), evaluate(facts.copy(averageBitrate = known(20_000_001L)), + MaximumResolution.Preset2560x1440).nonCompliantRequirements) + } + } + + @Test fun highSelectionUsesLowerTierThrough2048Inclusive() { + for (resolution in listOf(MaximumResolution.Preset2560x1440, MaximumResolution.Preset3840x2160)) { + for (longSide in listOf(1920, 2048, 2049)) { + val facts = compliantFacts(dimensions = Dimensions(1152, longSide)).copy(frameRate = known(120.0), + averageBitrate = known(15_000_000L), maximumGopBitrate = known(16_000_001L), maximumKeyframeIntervalSeconds = known(15.0)) + val expected = if (longSide <= 2048) setOf(PolicyRequirement.AverageBitrate, PolicyRequirement.MaximumGopBitrate) + else setOf(PolicyRequirement.FrameRate, PolicyRequirement.KeyframeInterval) + assertEquals("$resolution $longSide", expected, evaluate(facts, resolution).nonCompliantRequirements) + } + } + } + + @Test fun sourceLimitsAreOrientationNeutral() { + for ((resolution, maximum) in listOf(MaximumResolution.Default to 2048, MaximumResolution.Preset3840x2160 to 4096)) { + for (portrait in listOf(false, true)) { + fun facts(longSide: Int) = compliantFacts(dimensions = if (portrait) Dimensions(1080, longSide) else Dimensions(longSide, 1080)) + assertEquals(PolicyStatus.Compliant, evaluate(facts(maximum), resolution).checks[PolicyRequirement.VideoResolution]) + assertEquals(PolicyStatus.NonCompliant, evaluate(facts(maximum + 1), resolution).checks[PolicyRequirement.VideoResolution]) + } + } + } + + @Test fun generatedOutputMustFitBothAxesOfSelectedBounds() { + for ((resolution, source) in listOf(MaximumResolution.Default to Dimensions(2048, 1152), + MaximumResolution.Preset3840x2160 to Dimensions(2160, 4096), MaximumResolution.Default to Dimensions(1920, 1920))) { + val facts = compliantFacts(dimensions = source) + assertEquals(PolicyStatus.Compliant, evaluate(facts, resolution).checks[PolicyRequirement.VideoResolution]) + assertEquals(PolicyStatus.NonCompliant, evaluator.evaluate(facts, selection(resolution), MediaRole.GeneratedOutput) + .checks[PolicyRequirement.VideoResolution]) + } + assertEquals(PolicyStatus.Compliant, evaluator.evaluate(compliantFacts(dimensions = Dimensions(1080, 1920)), + selection(), MediaRole.GeneratedOutput).checks[PolicyRequirement.VideoResolution]) + } + + @Test fun pixelFormatsAreCodecAware() { + for (codec in VideoCodec.entries) for (depth in listOf(8, 10, 12)) for (chroma in ChromaSubsampling.entries) { + val expected = if (chroma == ChromaSubsampling.Yuv420 && + (codec == VideoCodec.H264 && depth == 8 || codec == VideoCodec.Hevc && depth in listOf(8, 10))) { + PolicyStatus.Compliant + } else PolicyStatus.NonCompliant + assertEquals("$codec $depth $chroma", expected, evaluate(compliantFacts(codec, pixelFormat = PixelFormat(depth, chroma))) + .checks[PolicyRequirement.PixelFormat]) + } + } + + @Test fun hdrRequiresHevcTenBit420() { + for (range in listOf(DynamicRange.Hlg, DynamicRange.Pq)) { + assertEquals(PolicyStatus.Compliant, evaluate(hdrFacts(range)).checks[PolicyRequirement.DynamicRange]) + assertEquals(PolicyStatus.NonCompliant, evaluate(hdrFacts(range).copy(videoCodec = known(VideoCodec.H264))) + .checks[PolicyRequirement.DynamicRange]) + assertEquals(PolicyStatus.NonCompliant, evaluate(hdrFacts(range).copy(pixelFormat = known(PixelFormat(8, ChromaSubsampling.Yuv420)))) + .checks[PolicyRequirement.DynamicRange]) + assertEquals(PolicyStatus.Unknown, evaluate(hdrFacts(range).copy(pixelFormat = MediaFact.Unknown)).checks[PolicyRequirement.DynamicRange]) + assertEquals(PolicyStatus.Unknown, evaluate(hdrFacts(range).copy(videoCodec = MediaFact.Unknown)).checks[PolicyRequirement.DynamicRange]) + } + for (range in listOf(DynamicRange.DolbyVision, DynamicRange.OtherHdr)) { + assertEquals(PolicyStatus.NonCompliant, evaluate(hdrFacts(range)).checks[PolicyRequirement.DynamicRange]) + } + } + + @Test fun audioAcceptsNoAudioAndAacMonoStereoSurround() { + assertEquals(PolicyStatus.Compliant, evaluate(compliantFacts().copy(audioTracks = known(emptyList()))).outcome) + for (layout in AudioChannelLayout.entries) { + val expected = if (layout == AudioChannelLayout.Other) PolicyStatus.NonCompliant else PolicyStatus.Compliant + assertEquals(expected, evaluate(compliantFacts().copy(audioTracks = known(listOf(AudioTrack(known(AudioFormat.Aac(layout))))))) + .checks[PolicyRequirement.Audio]) + } + assertEquals(PolicyStatus.NonCompliant, evaluate(compliantFacts().copy(audioTracks = known(listOf(AudioTrack(known(AudioFormat.OtherCodec)))))) + .checks[PolicyRequirement.Audio]) + } + + @Test fun multipleAudioTracksRemainUnknownEvenWithKnownNonAac() { + val facts = compliantFacts().copy(audioTracks = known(listOf(AudioTrack(known(AudioFormat.OtherCodec)), AudioTrack()))) + assertEquals(setOf(PolicyRequirement.Audio), evaluate(facts).unknownRequirements) + assertTrue(evaluate(facts).nonCompliantRequirements.isEmpty()) + } + + @Test fun gopAndEditListRejectKnownUnsupportedValues() { + for (gop in listOf(GopStructure.Open, GopStructure.ClosedWithoutIdr)) { + assertEquals(setOf(PolicyRequirement.GopStructure, PolicyRequirement.EditList), evaluate(compliantFacts() + .copy(gopStructure = known(gop), editList = known(EditList.Complex))).nonCompliantRequirements) + } + assertEquals(PolicyStatus.Compliant, evaluate(compliantFacts().copy(editList = known(EditList.None))).outcome) + assertEquals(PolicyStatus.NonCompliant, evaluate(compliantFacts(VideoCodec.Other)).checks[PolicyRequirement.VideoCodec]) + } + + @Test fun unknownFactsAreNotZeroAndKnownViolationWins() { + assertEquals(PolicyRequirement.entries.toSet(), evaluate(MediaFacts()).unknownRequirements) + assertEquals(PolicyStatus.Unknown, evaluate(MediaFacts()).outcome) + val facts = compliantFacts().copy(averageBitrate = known(8_000_001L), maximumGopBitrate = MediaFact.Unknown) + assertEquals(PolicyStatus.NonCompliant, evaluate(facts).outcome) + assertEquals(setOf(PolicyRequirement.MaximumGopBitrate), evaluate(facts).unknownRequirements) + assertEquals(setOf(PolicyRequirement.AverageBitrate), evaluate(facts).nonCompliantRequirements) + } + + @Test fun invalidMeasurementSentinelsRemainUnknown() { + for (number in listOf(0.0, -1.0, Double.NaN, Double.POSITIVE_INFINITY, Double.NEGATIVE_INFINITY)) { + val facts = compliantFacts().copy(displayDimensions = known(Dimensions(0, 1080)), frameRate = known(number), + averageBitrate = known(0L), maximumGopBitrate = known(-1L), maximumKeyframeIntervalSeconds = known(number)) + assertEquals(setOf(PolicyRequirement.VideoResolution, PolicyRequirement.FrameRate, PolicyRequirement.AverageBitrate, + PolicyRequirement.MaximumGopBitrate, PolicyRequirement.KeyframeInterval), evaluate(facts).unknownRequirements) + assertTrue(evaluate(facts).nonCompliantRequirements.isEmpty()) + } + } + + private fun evaluate(facts: MediaFacts, resolution: MaximumResolution = MaximumResolution.Default) = + evaluator.evaluate(facts, selection(resolution)) +} diff --git a/library/src/test/java/com/mux/video/upload/internal/standardization/StandardInputTestFacts.kt b/library/src/test/java/com/mux/video/upload/internal/standardization/StandardInputTestFacts.kt new file mode 100644 index 00000000..71b26b01 --- /dev/null +++ b/library/src/test/java/com/mux/video/upload/internal/standardization/StandardInputTestFacts.kt @@ -0,0 +1,40 @@ +package com.mux.video.upload.internal.standardization + +import com.mux.video.upload.internal.InputStandardization +import com.mux.video.upload.internal.MaximumResolution + +// Normalized facts shared with the shipped Swift policy/planner tests; no fixture binaries or I/O. +internal fun compliantFacts( + codec: VideoCodec = VideoCodec.H264, + dimensions: Dimensions = Dimensions(1920, 1080), + dynamicRange: DynamicRange = DynamicRange.Sdr, + pixelFormat: PixelFormat = PixelFormat(8, ChromaSubsampling.Yuv420), +): MediaFacts = MediaFacts( + videoCodec = known(codec), + displayDimensions = known(dimensions), + videoTrackCount = known(1), + frameRate = known(30.0), + averageBitrate = known(4_000_000L), + maximumGopBitrate = known(5_000_000L), + maximumKeyframeIntervalSeconds = known(2.0), + gopStructure = known(GopStructure.ClosedWithIdr), + pixelFormat = known(pixelFormat), + dynamicRange = known(dynamicRange), + audioTracks = known(listOf(AudioTrack(known(AudioFormat.Aac(AudioChannelLayout.Stereo))))), + editList = known(EditList.Simple), +) + +internal fun hdrFacts(range: DynamicRange = DynamicRange.Hlg): MediaFacts = compliantFacts( + codec = VideoCodec.Hevc, dynamicRange = range, pixelFormat = PixelFormat(10, ChromaSubsampling.Yuv420), +) + +internal fun fullCapabilities() = PlanningCapabilities( + sourceIsDecodable = true, + encodableVideoCodecs = setOf(VideoCodec.H264, VideoCodec.Hevc), + remediableRequirements = PolicyRequirement.entries.toSet(), + toneMappableDynamicRanges = setOf(DynamicRange.Hlg, DynamicRange.Pq), +) + +internal fun known(value: T): MediaFact = MediaFact.Known(value) +internal fun selection(resolution: MaximumResolution = MaximumResolution.Default) = PolicySelection(resolution) +internal fun options(resolution: MaximumResolution = MaximumResolution.Default) = InputStandardization(true, resolution) From 3f766f81f8349fe71ebb26ad6600f847eee850a0 Mon Sep 17 00:00:00 2001 From: Eugene Morozov Date: Thu, 1 Oct 2026 14:02:49 -0700 Subject: [PATCH 2/5] Simplify Standard Input planning and policy checks --- .../internal/standardization/MediaFacts.kt | 11 ++++ .../standardization/StandardInputPlanner.kt | 54 +++++++++---------- .../standardization/StandardInputPolicy.kt | 26 +++++---- .../StandardInputPlannerTests.kt | 1 - 4 files changed, 54 insertions(+), 38 deletions(-) diff --git a/library/src/main/java/com/mux/video/upload/internal/standardization/MediaFacts.kt b/library/src/main/java/com/mux/video/upload/internal/standardization/MediaFacts.kt index 31a56077..6ad2979b 100644 --- a/library/src/main/java/com/mux/video/upload/internal/standardization/MediaFacts.kt +++ b/library/src/main/java/com/mux/video/upload/internal/standardization/MediaFacts.kt @@ -1,5 +1,7 @@ package com.mux.video.upload.internal.standardization +import kotlin.math.floor + /** Unproven facts remain unknown; absence of metadata is not a compliant value. */ internal sealed interface MediaFact { data class Known(val value: T) : MediaFact @@ -34,6 +36,15 @@ internal data class Dimensions(val width: Int, val height: Int) { fun fitsWithin(bounds: Dimensions): Boolean = longSide <= bounds.longSide && shortSide <= bounds.shortSide + + fun scaledToFit(bounds: Dimensions): Dimensions? { + // A one-pixel axis cannot produce 4:2:0 output without upscaling. + if (!isValid || shortSide < 2) return null + val scale = minOf(1.0, bounds.longSide.toDouble() / longSide, bounds.shortSide.toDouble() / shortSide) + // Match Swift's nearest even alignment, capped at the source axis to prevent odd-size upscaling. + fun aligned(axis: Int): Int = minOf(axis / 2 * 2, maxOf(2, (floor(axis * scale / 2 + 0.5) * 2).toInt())) + return Dimensions(aligned(width), aligned(height)) + } } internal data class TimestampFacts( diff --git a/library/src/main/java/com/mux/video/upload/internal/standardization/StandardInputPlanner.kt b/library/src/main/java/com/mux/video/upload/internal/standardization/StandardInputPlanner.kt index fc47977b..72de61e8 100644 --- a/library/src/main/java/com/mux/video/upload/internal/standardization/StandardInputPlanner.kt +++ b/library/src/main/java/com/mux/video/upload/internal/standardization/StandardInputPlanner.kt @@ -2,7 +2,6 @@ package com.mux.video.upload.internal.standardization import com.mux.video.upload.api.HdrHandling import com.mux.video.upload.internal.InputStandardization -import kotlin.math.floor /** Proof for this source and conversion path, supplied by the caller, never inferred from a codec list. */ internal data class PlanningCapabilities( @@ -33,6 +32,7 @@ internal data class StandardInputConversion( val outputCodec: VideoCodec, val sourceDynamicRange: DynamicRange, val sourceDisplayDimensions: MediaFact, + /** Explicit HDR-to-BT.709 SDR pixel conversion; retagging metadata is insufficient. */ val toneMapsToSdr: Boolean, val selection: PolicySelection, val requirementsToRemediate: Set, @@ -42,7 +42,6 @@ internal data class StandardInputConversion( val outputAudio: OutputAudio, ) { val outputDynamicRange: DynamicRange get() = DynamicRange.Sdr - val requiresBt709ToneMapping: Boolean get() = toneMapsToSdr } internal sealed interface OriginalReason { @@ -95,7 +94,8 @@ internal class StandardInputPlanner( if (!it.fitsWithin(selection.generatedOutputDimensions)) requirements.add(PolicyRequirement.VideoResolution) } val range = facts.dynamicRange.valueOrNull - val toneMap = range in setOf(DynamicRange.Hlg, DynamicRange.Pq) && options.hdrHandling == HdrHandling.ToneMapToSDR + val toneMap = (range == DynamicRange.Hlg || range == DynamicRange.Pq) && + options.hdrHandling == HdrHandling.ToneMapToSDR when (range) { DynamicRange.DolbyVision, DynamicRange.OtherHdr -> return fallback(FallbackReason.UnsupportedHdr(range)) DynamicRange.Hlg, DynamicRange.Pq -> { @@ -120,48 +120,48 @@ internal class StandardInputPlanner( if (evaluation.outcome == PolicyStatus.Compliant) OriginalReason.StandardInput else OriginalReason.NoKnownStandardInputViolation) - val codec = facts.videoCodec.valueOrNull ?: return fallback(FallbackReason.InsufficientEvidenceForConversion) - if (range == null) return fallback(FallbackReason.InsufficientEvidenceForConversion) - val dimensions = outputDimensions(facts.displayDimensions, selection.generatedOutputDimensions) - // A known one-pixel axis cannot produce 4:2:0 output without upscaling. - if (facts.displayDimensions.valueOrNull?.isValid == true && dimensions == MediaFact.Unknown) { - return fallback(FallbackReason.InsufficientEvidenceForConversion) - } + val conversion = buildConversion(facts, selection, requirements.toSet(), toneMap) + ?: return fallback(FallbackReason.InsufficientEvidenceForConversion) + return if (capabilities.supports(conversion)) StandardInputPlan(StandardInputAction.Convert(conversion), evaluation) + else fallback(FallbackReason.UnsupportedConversion(conversion)) + } + + private fun buildConversion( + facts: MediaFacts, + selection: PolicySelection, + requirements: Set, + toneMap: Boolean, + ): StandardInputConversion? { + val codec = facts.videoCodec.valueOrNull ?: return null + val range = facts.dynamicRange.valueOrNull ?: return null + val sourceSize = facts.displayDimensions.valueOrNull + val outputSize = sourceSize?.scaledToFit(selection.generatedOutputDimensions) + if (sourceSize?.isValid == true && outputSize == null) return null + val dimensions = outputSize?.let { MediaFact.Known(it) } ?: MediaFact.Unknown val outputCodec = if (codec == VideoCodec.Other) VideoCodec.H264 else codec val outputLimits = evaluator.profile.limits(evaluator.effectiveTier(dimensions, selection)) val frameRate = facts.frameRate.valueOrNull?.takeIf { it.isFinite() && it > 0 }?.let { - MediaFact.Known(if (it in outputLimits.minimumFrameRate..outputLimits.maximumFrameRate) it else 30.0) + MediaFact.Known(if (it in outputLimits.frameRateRange) it else 30.0) } ?: MediaFact.Unknown val audio = facts.audioTracks.valueOrNull - val conversion = StandardInputConversion( + val keepTenBit = !toneMap && outputCodec == VideoCodec.Hevc && + facts.pixelFormat.valueOrNull == PixelFormat(10, ChromaSubsampling.Yuv420) + return StandardInputConversion( sourceCodec = codec, outputCodec = outputCodec, sourceDynamicRange = range, sourceDisplayDimensions = facts.displayDimensions, toneMapsToSdr = toneMap, selection = selection, - requirementsToRemediate = requirements.toSet(), + requirementsToRemediate = requirements, outputDimensions = dimensions, outputFrameRate = frameRate, - outputPixelFormat = if (!toneMap && outputCodec == VideoCodec.Hevc && - facts.pixelFormat.valueOrNull == PixelFormat(10, ChromaSubsampling.Yuv420)) { - PixelFormat(10, ChromaSubsampling.Yuv420) - } else PixelFormat(8, ChromaSubsampling.Yuv420), + outputPixelFormat = PixelFormat(if (keepTenBit) 10 else 8, ChromaSubsampling.Yuv420), outputAudio = when { audio == null -> OutputAudio.AacFromFirstTrackIfPresent audio.isEmpty() -> OutputAudio.None else -> OutputAudio.AacFromFirstTrack }, ) - return if (capabilities.supports(conversion)) StandardInputPlan(StandardInputAction.Convert(conversion), evaluation) - else fallback(FallbackReason.UnsupportedConversion(conversion)) - } - - private fun outputDimensions(source: MediaFact, bounds: Dimensions): MediaFact { - val size = source.valueOrNull?.takeIf { it.isValid && it.shortSide >= 2 } ?: return MediaFact.Unknown - val scale = minOf(1.0, bounds.longSide.toDouble() / size.longSide, bounds.shortSide.toDouble() / size.shortSide) - // Match Swift's nearest even alignment, capped at the source axis to prevent odd-size upscaling. - fun aligned(axis: Int): Int = minOf(axis / 2 * 2, maxOf(2, (floor(axis * scale / 2 + 0.5) * 2).toInt())) - return MediaFact.Known(Dimensions(aligned(size.width), aligned(size.height))) } } diff --git a/library/src/main/java/com/mux/video/upload/internal/standardization/StandardInputPolicy.kt b/library/src/main/java/com/mux/video/upload/internal/standardization/StandardInputPolicy.kt index 3d6655e3..250a4801 100644 --- a/library/src/main/java/com/mux/video/upload/internal/standardization/StandardInputPolicy.kt +++ b/library/src/main/java/com/mux/video/upload/internal/standardization/StandardInputPolicy.kt @@ -25,8 +25,7 @@ internal data class PolicySelection( internal data class TierLimits( val maximumSourceDimension: Int, - val minimumFrameRate: Double, - val maximumFrameRate: Double, + val frameRateRange: ClosedFloatingPointRange, val maximumAverageBitrate: Long, val maximumGopBitrate: Long?, val maximumKeyframeIntervals: Map, @@ -44,9 +43,9 @@ internal data class StandardInputPolicyProfile( companion object { val PublishedMux = StandardInputPolicyProfile( - upTo1080p = TierLimits(2048, 5.0, 120.0, 8_000_000, 16_000_000, + upTo1080p = TierLimits(2048, 5.0..120.0, 8_000_000, 16_000_000, mapOf(VideoCodec.H264 to 20.0, VideoCodec.Hevc to 10.0)), - highResolution = TierLimits(4096, 5.0, 60.0, 20_000_000, null, + highResolution = TierLimits(4096, 5.0..60.0, 20_000_000, null, mapOf(VideoCodec.H264 to 10.0, VideoCodec.Hevc to 6.0)), ) } @@ -82,13 +81,12 @@ internal class StandardInputPolicyEvaluator( ): PolicyEvaluation { val limits = profile.limits(effectiveTier(facts.displayDimensions, selection)) val codec = facts.videoCodec.valueOrNull - val pixel = facts.pixelFormat.valueOrNull val intervalLimit = limits.maximumKeyframeIntervals[codec] return PolicyEvaluation(linkedMapOf( PolicyRequirement.VideoCodec to check(facts.videoCodec) { it != VideoCodec.Other }, PolicyRequirement.VideoResolution to dimensions(facts.displayDimensions, selection, limits, role), PolicyRequirement.FrameRate to positiveFinite(facts.frameRate) { - it in limits.minimumFrameRate..limits.maximumFrameRate + it in limits.frameRateRange }, PolicyRequirement.AverageBitrate to positive(facts.averageBitrate) { it <= limits.maximumAverageBitrate }, PolicyRequirement.MaximumGopBitrate to (limits.maximumGopBitrate?.let { maximum -> @@ -98,10 +96,7 @@ internal class StandardInputPolicyEvaluator( positiveFinite(facts.maximumKeyframeIntervalSeconds) { it <= maximum } } ?: PolicyStatus.Unknown), PolicyRequirement.GopStructure to check(facts.gopStructure) { it == GopStructure.ClosedWithIdr }, - PolicyRequirement.PixelFormat to (if (codec == null || pixel == null) PolicyStatus.Unknown else - status(pixel.chromaSubsampling == ChromaSubsampling.Yuv420 && - (codec == VideoCodec.H264 && pixel.bitDepth == 8 || - codec == VideoCodec.Hevc && pixel.bitDepth in setOf(8, 10)))), + PolicyRequirement.PixelFormat to pixelFormat(facts), PolicyRequirement.DynamicRange to dynamicRange(facts), PolicyRequirement.Audio to audio(facts.audioTracks), PolicyRequirement.EditList to check(facts.editList) { it != EditList.Complex }, @@ -130,6 +125,17 @@ internal class StandardInputPolicyEvaluator( } } + private fun pixelFormat(facts: MediaFacts): PolicyStatus { + val codec = facts.videoCodec.valueOrNull ?: return PolicyStatus.Unknown + val format = facts.pixelFormat.valueOrNull ?: return PolicyStatus.Unknown + val supportedDepth = when (codec) { + VideoCodec.H264 -> format.bitDepth == 8 + VideoCodec.Hevc -> format.bitDepth == 8 || format.bitDepth == 10 + VideoCodec.Other -> false + } + return status(supportedDepth && format.chromaSubsampling == ChromaSubsampling.Yuv420) + } + private fun audio(fact: MediaFact>): PolicyStatus { val tracks = fact.valueOrNull ?: return PolicyStatus.Unknown if (tracks.isEmpty()) return PolicyStatus.Compliant diff --git a/library/src/test/java/com/mux/video/upload/internal/standardization/StandardInputPlannerTests.kt b/library/src/test/java/com/mux/video/upload/internal/standardization/StandardInputPlannerTests.kt index f0f638a1..1a10b62f 100644 --- a/library/src/test/java/com/mux/video/upload/internal/standardization/StandardInputPlannerTests.kt +++ b/library/src/test/java/com/mux/video/upload/internal/standardization/StandardInputPlannerTests.kt @@ -191,7 +191,6 @@ class StandardInputPlannerTests { val conversion = conversion(hdrFacts(range).copy(videoCodec = known(codec)), toneMapOptions()) assertEquals(codec, conversion.outputCodec) assertTrue(conversion.toneMapsToSdr) - assertTrue(conversion.requiresBt709ToneMapping) assertEquals(DynamicRange.Sdr, conversion.outputDynamicRange) assertEquals(PixelFormat(8, ChromaSubsampling.Yuv420), conversion.outputPixelFormat) } From a24b38ac47c14472bd2a0e1443b45a618755c4bd Mon Sep 17 00:00:00 2001 From: Eugene Morozov Date: Thu, 1 Oct 2026 14:20:27 -0700 Subject: [PATCH 3/5] Require audio capability proof and preserve unknown tier evidence --- .../internal/standardization/MediaFacts.kt | 6 +- .../standardization/StandardInputPlanner.kt | 30 +++++-- .../standardization/StandardInputPolicy.kt | 33 ++++--- .../StandardInputPlannerTests.kt | 89 +++++++++++++++++++ .../StandardInputPolicyTests.kt | 31 +++++++ .../standardization/StandardInputTestFacts.kt | 1 + 6 files changed, 169 insertions(+), 21 deletions(-) diff --git a/library/src/main/java/com/mux/video/upload/internal/standardization/MediaFacts.kt b/library/src/main/java/com/mux/video/upload/internal/standardization/MediaFacts.kt index 6ad2979b..6cbe32b6 100644 --- a/library/src/main/java/com/mux/video/upload/internal/standardization/MediaFacts.kt +++ b/library/src/main/java/com/mux/video/upload/internal/standardization/MediaFacts.kt @@ -54,7 +54,11 @@ internal data class TimestampFacts( val isMonotonic: Boolean, ) -/** Normalized evidence only. Inspection, hardware probing, files, and Android APIs live elsewhere. */ +/** + * Normalized evidence shared with inspection, conversion, and output validation. The policy reads only + * its published predicates; profile, encoded geometry/rotation, and timeline/cadence also serve those + * later stages. Display dimensions already include orientation. No file or platform APIs belong here. + */ internal data class MediaFacts( val videoCodec: MediaFact = MediaFact.Unknown, val videoProfile: MediaFact = MediaFact.Unknown, diff --git a/library/src/main/java/com/mux/video/upload/internal/standardization/StandardInputPlanner.kt b/library/src/main/java/com/mux/video/upload/internal/standardization/StandardInputPlanner.kt index 72de61e8..e1c7b6af 100644 --- a/library/src/main/java/com/mux/video/upload/internal/standardization/StandardInputPlanner.kt +++ b/library/src/main/java/com/mux/video/upload/internal/standardization/StandardInputPlanner.kt @@ -7,15 +7,24 @@ import com.mux.video.upload.internal.InputStandardization internal data class PlanningCapabilities( val sourceIsDecodable: Boolean = false, val encodableVideoCodecs: Set = emptySet(), + /** A FrameRate entry must prove cadence conversion while preserving the effective source timeline. */ val remediableRequirements: Set = emptySet(), val toneMappableDynamicRanges: Set = emptySet(), - // Unchanged HDR upload does not need a local encoder or a server-configuration probe. + /** Proven first-track AAC preparation, including decode/encode or compliant AAC copy and muxing. */ + val canProduceAacAudio: Boolean = false, + // These ranges allow unchanged bytes after media eligibility checks. They are not conversion capabilities. + // Unlike conversion, pass-through does not need a local encoder or a server-configuration probe. val preservableHdrDynamicRanges: Set = setOf(DynamicRange.Hlg, DynamicRange.Pq), ) { - fun supports(conversion: StandardInputConversion): Boolean = - sourceIsDecodable && conversion.outputCodec in encodableVideoCodecs && - remediableRequirements.containsAll(conversion.requirementsToRemediate) && + fun supports(conversion: StandardInputConversion): Boolean { + // Proven tone mapping already remediates HDR transfer; unrelated violations need their own proof. + val requirements = if (conversion.toneMapsToSdr) conversion.requirementsToRemediate - PolicyRequirement.DynamicRange + else conversion.requirementsToRemediate + return sourceIsDecodable && conversion.outputCodec in encodableVideoCodecs && + remediableRequirements.containsAll(requirements) && + (conversion.outputAudio == OutputAudio.None || canProduceAacAudio) && (!conversion.toneMapsToSdr || conversion.sourceDynamicRange in toneMappableDynamicRanges) + } } internal enum class OutputAudio { @@ -70,6 +79,7 @@ internal sealed interface StandardInputAction { data class Fallback(val reason: FallbackReason) : StandardInputAction } +/** Evaluation describes published source compliance; the action also enforces the selected output size. */ internal data class StandardInputPlan(val action: StandardInputAction, val evaluation: PolicyEvaluation) internal class StandardInputPlanner( @@ -81,6 +91,7 @@ internal class StandardInputPlanner( capabilities: PlanningCapabilities = PlanningCapabilities(), ): StandardInputPlan { val selection = PolicySelection(options.maximumResolution) + // Retain source diagnostics even when preparation is disabled; this does no inspection or device probing. val evaluation = evaluator.evaluate(facts, selection) fun original(reason: OriginalReason) = StandardInputPlan(StandardInputAction.UploadOriginal(reason), evaluation) fun fallback(reason: FallbackReason) = StandardInputPlan(StandardInputAction.Fallback(reason), evaluation) @@ -109,8 +120,10 @@ internal class StandardInputPlanner( if (requirements.isNotEmpty()) return fallback(FallbackReason.NonStandardHdr(range, requirements.toSet())) return original(OriginalReason.PreserveHdr(range)) } - if (facts.videoCodec.valueOrNull !in setOf(VideoCodec.H264, VideoCodec.Hevc)) { - return fallback(FallbackReason.UnsupportedHdr(range)) + when (facts.videoCodec.valueOrNull) { + null -> return fallback(FallbackReason.InsufficientEvidenceForConversion) + VideoCodec.Other -> return fallback(FallbackReason.UnsupportedHdr(range)) + VideoCodec.H264, VideoCodec.Hevc -> Unit } } null -> if (requirements.isNotEmpty()) return fallback(FallbackReason.InsufficientEvidenceForConversion) @@ -139,9 +152,10 @@ internal class StandardInputPlanner( if (sourceSize?.isValid == true && outputSize == null) return null val dimensions = outputSize?.let { MediaFact.Known(it) } ?: MediaFact.Unknown val outputCodec = if (codec == VideoCodec.Other) VideoCodec.H264 else codec - val outputLimits = evaluator.profile.limits(evaluator.effectiveTier(dimensions, selection)) + val outputLimits = evaluator.applicableLimits(dimensions, selection) val frameRate = facts.frameRate.valueOrNull?.takeIf { it.isFinite() && it > 0 }?.let { - MediaFact.Known(if (it in outputLimits.frameRateRange) it else 30.0) + val targets = outputLimits.map { limits -> if (it in limits.frameRateRange) it else 30.0 }.distinct() + targets.singleOrNull()?.let { target -> MediaFact.Known(target) } } ?: MediaFact.Unknown val audio = facts.audioTracks.valueOrNull val keepTenBit = !toneMap && outputCodec == VideoCodec.Hevc && diff --git a/library/src/main/java/com/mux/video/upload/internal/standardization/StandardInputPolicy.kt b/library/src/main/java/com/mux/video/upload/internal/standardization/StandardInputPolicy.kt index 250a4801..6d82bf1c 100644 --- a/library/src/main/java/com/mux/video/upload/internal/standardization/StandardInputPolicy.kt +++ b/library/src/main/java/com/mux/video/upload/internal/standardization/StandardInputPolicy.kt @@ -36,11 +36,6 @@ internal data class StandardInputPolicyProfile( val upTo1080p: TierLimits, val highResolution: TierLimits, ) { - fun limits(tier: AcceptanceTier): TierLimits = when (tier) { - AcceptanceTier.UpTo1080p -> upTo1080p - AcceptanceTier.HighResolution -> highResolution - } - companion object { val PublishedMux = StandardInputPolicyProfile( upTo1080p = TierLimits(2048, 5.0..120.0, 8_000_000, 16_000_000, @@ -66,12 +61,12 @@ internal data class PolicyEvaluation(val checks: Map, selection: PolicySelection): AcceptanceTier { - val size = dimensions.valueOrNull - return if (selection.acceptanceTier == AcceptanceTier.HighResolution && size?.isValid == true && - size.longSide <= profile.upTo1080p.maximumSourceDimension) { - AcceptanceTier.UpTo1080p - } else selection.acceptanceTier + fun applicableLimits(dimensions: MediaFact, selection: PolicySelection): List { + if (selection.acceptanceTier == AcceptanceTier.UpTo1080p) return listOf(profile.upTo1080p) + val size = dimensions.valueOrNull?.takeIf { it.isValid } + ?: return listOf(profile.upTo1080p, profile.highResolution) + return listOf(if (size.longSide <= profile.upTo1080p.maximumSourceDimension) profile.upTo1080p + else profile.highResolution) } fun evaluate( @@ -79,7 +74,21 @@ internal class StandardInputPolicyEvaluator( selection: PolicySelection, role: MediaRole = MediaRole.SourceInput, ): PolicyEvaluation { - val limits = profile.limits(effectiveTier(facts.displayDimensions, selection)) + val evaluations = applicableLimits(facts.displayDimensions, selection).map { evaluate(facts, selection, role, it) } + if (evaluations.size == 1) return evaluations.single() + // An unknown size cannot choose a tier. A check is proven only when both possible tiers agree. + return PolicyEvaluation(PolicyRequirement.entries.associateWith { requirement -> + val statuses = evaluations.map { it.checks.getValue(requirement) }.distinct() + statuses.singleOrNull() ?: PolicyStatus.Unknown + }) + } + + private fun evaluate( + facts: MediaFacts, + selection: PolicySelection, + role: MediaRole, + limits: TierLimits, + ): PolicyEvaluation { val codec = facts.videoCodec.valueOrNull val intervalLimit = limits.maximumKeyframeIntervals[codec] return PolicyEvaluation(linkedMapOf( diff --git a/library/src/test/java/com/mux/video/upload/internal/standardization/StandardInputPlannerTests.kt b/library/src/test/java/com/mux/video/upload/internal/standardization/StandardInputPlannerTests.kt index 1a10b62f..73b40c81 100644 --- a/library/src/test/java/com/mux/video/upload/internal/standardization/StandardInputPlannerTests.kt +++ b/library/src/test/java/com/mux/video/upload/internal/standardization/StandardInputPlannerTests.kt @@ -93,6 +93,95 @@ class StandardInputPlannerTests { assertEquals(OutputAudio.AacFromFirstTrack, conversion(facts.copy(audioTracks = known(listOf(AudioTrack())))).outputAudio) } + @Test fun unknownAudioRequiresIndependentAacPreparationProof() { + val videoCapabilities = PlanningCapabilities( + sourceIsDecodable = true, + encodableVideoCodecs = setOf(VideoCodec.H264), + remediableRequirements = setOf(PolicyRequirement.FrameRate), + ) + val facts = compliantFacts().copy(frameRate = known(121.0)) + for (audio in listOf(MediaFact.Unknown, known(listOf(AudioTrack())), + known(listOf(AudioTrack(known(AudioFormat.OtherCodec)), AudioTrack())))) { + val action = planner.plan(facts.copy(audioTracks = audio), capabilities = videoCapabilities).action + assertTrue("Audio must have its own proven AAC path: $action", action is StandardInputAction.Fallback) + assertTrue((action as StandardInputAction.Fallback).reason is FallbackReason.UnsupportedConversion) + } + assertTrue(planner.plan(facts.copy(audioTracks = known(emptyList())), capabilities = videoCapabilities) + .action is StandardInputAction.Convert) + } + + @Test fun aacPreparationProofCoversCopyAndEncodingForTheSelectedTrack() { + val videoOnly = fullCapabilities().copy(canProduceAacAudio = false) + val facts = compliantFacts().copy(frameRate = known(121.0)) + for (audio in listOf(facts.audioTracks, MediaFact.Unknown, known(listOf(AudioTrack())), + known(listOf(AudioTrack(known(AudioFormat.OtherCodec)))), + known(listOf(AudioTrack(known(AudioFormat.OtherCodec)), AudioTrack())), + known(listOf(AudioTrack(known(AudioFormat.Aac(AudioChannelLayout.FivePointOne))))))) { + val source = facts.copy(audioTracks = audio) + assertTrue(fallback(source, videoOnly) is FallbackReason.UnsupportedConversion) + assertTrue(planner.plan(source, capabilities = videoOnly.copy(canProduceAacAudio = true)).action + is StandardInputAction.Convert) + } + assertTrue(planner.plan(facts.copy(audioTracks = known(emptyList())), capabilities = videoOnly).action + is StandardInputAction.Convert) + // Lack of a local audio pipeline must never block compliant original bytes. + assertEquals(StandardInputAction.UploadOriginal(OriginalReason.StandardInput), planner.plan(compliantFacts()).action) + } + + @Test fun toneMappingWithUnknownCodecReportsInsufficientEvidence() { + assertEquals(FallbackReason.InsufficientEvidenceForConversion, + fallback(hdrFacts().copy(videoCodec = MediaFact.Unknown), options = toneMapOptions())) + } + + @Test fun unknownSizeDoesNotChooseTheLooserOrStricterTier() { + val facts = compliantFacts().copy(displayDimensions = MediaFact.Unknown, frameRate = known(90.0), + averageBitrate = known(15_000_000L), maximumGopBitrate = MediaFact.Unknown) + for (resolution in listOf(MaximumResolution.Preset2560x1440, MaximumResolution.Preset3840x2160)) { + val plan = planner.plan(facts, options(resolution), fullCapabilities()) + assertEquals(StandardInputAction.UploadOriginal(OriginalReason.NoKnownStandardInputViolation), plan.action) + assertEquals(setOf(PolicyRequirement.VideoResolution, PolicyRequirement.FrameRate, + PolicyRequirement.AverageBitrate, PolicyRequirement.MaximumGopBitrate), plan.evaluation.unknownRequirements) + } + } + + @Test fun unrelatedKnownViolationWithUnknownOutputSizeDoesNotInventFrameRate() { + val facts = compliantFacts().copy(displayDimensions = MediaFact.Unknown, frameRate = known(90.0), + gopStructure = known(GopStructure.Open)) + val conversion = conversion(facts, options(MaximumResolution.Preset3840x2160)) + assertEquals(setOf(PolicyRequirement.GopStructure), conversion.requirementsToRemediate) + assertEquals(MediaFact.Unknown, conversion.outputDimensions) + assertEquals(MediaFact.Unknown, conversion.outputFrameRate) + } + + @Test fun provenToneMappingRemediatesDynamicRangeForEitherCodec() { + val capabilities = fullCapabilities().copy(remediableRequirements = emptySet()) + for (codec in listOf(VideoCodec.H264, VideoCodec.Hevc)) for (range in listOf(DynamicRange.Hlg, DynamicRange.Pq)) { + val facts = hdrFacts(range).copy(videoCodec = known(codec), + pixelFormat = known(PixelFormat(if (codec == VideoCodec.H264) 8 else 10, ChromaSubsampling.Yuv420))) + val action = planner.plan(facts, toneMapOptions(), capabilities).action + assertTrue("Proven tone mapping must be enough for $codec $range: $action", action is StandardInputAction.Convert) + val conversion = (action as StandardInputAction.Convert).conversion + assertEquals(codec, conversion.outputCodec) + assertTrue(conversion.toneMapsToSdr) + } + // Tone mapping does not establish unrelated pixel-format remediation. + assertTrue(fallback(hdrFacts().copy(videoCodec = known(VideoCodec.H264)), capabilities, toneMapOptions()) + is FallbackReason.UnsupportedConversion) + assertTrue(fallback(hdrFacts().copy(videoCodec = known(VideoCodec.H264), + pixelFormat = known(PixelFormat(8, ChromaSubsampling.Yuv420))), + capabilities.copy(toneMappableDynamicRanges = emptySet()), toneMapOptions()) is FallbackReason.UnsupportedConversion) + } + + @Test fun thirtyFpsTargetsRequireProvenCadenceRemediation() { + for ((facts, resolution) in listOf( + compliantFacts().copy(frameRate = known(2.0)) to MaximumResolution.Default, + compliantFacts(dimensions = Dimensions(3840, 2160)).copy(frameRate = known(120.0)) to MaximumResolution.Preset3840x2160)) { + assertTrue(fallback(facts, fullCapabilities().copy(remediableRequirements = PolicyRequirement.entries.toSet() - + PolicyRequirement.FrameRate), options(resolution)) is FallbackReason.UnsupportedConversion) + assertEquals(known(30.0), conversion(facts, options(resolution)).outputFrameRate) + } + } + @Test fun otherDecodableSdrCodecConvertsToH264() { val conversion = conversion(compliantFacts(VideoCodec.Other).copy(pixelFormat = known(PixelFormat(10, ChromaSubsampling.Other)))) assertEquals(VideoCodec.Other, conversion.sourceCodec) diff --git a/library/src/test/java/com/mux/video/upload/internal/standardization/StandardInputPolicyTests.kt b/library/src/test/java/com/mux/video/upload/internal/standardization/StandardInputPolicyTests.kt index 51b2fb3a..d67d80ee 100644 --- a/library/src/test/java/com/mux/video/upload/internal/standardization/StandardInputPolicyTests.kt +++ b/library/src/test/java/com/mux/video/upload/internal/standardization/StandardInputPolicyTests.kt @@ -175,6 +175,37 @@ class StandardInputPolicyTests { } } + @Test fun unknownSizeRequiresAgreementBetweenPossibleTiers() { + for (resolution in listOf(MaximumResolution.Preset2560x1440, MaximumResolution.Preset3840x2160)) { + for (dimensions in listOf(MediaFact.Unknown, known(Dimensions(0, 1080)))) { + val facts = compliantFacts().copy(displayDimensions = dimensions) + for ((rate, expected) in listOf(4.999 to PolicyStatus.NonCompliant, 5.0 to PolicyStatus.Compliant, + 60.0 to PolicyStatus.Compliant, 90.0 to PolicyStatus.Unknown, 120.0 to PolicyStatus.Unknown, + 120.001 to PolicyStatus.NonCompliant)) { + assertEquals(expected, evaluate(facts.copy(frameRate = known(rate)), resolution).checks[PolicyRequirement.FrameRate]) + } + for ((bitrate, expected) in listOf(8_000_000L to PolicyStatus.Compliant, 15_000_000L to PolicyStatus.Unknown, + 20_000_000L to PolicyStatus.Unknown, 20_000_001L to PolicyStatus.NonCompliant)) { + assertEquals(expected, evaluate(facts.copy(averageBitrate = known(bitrate)), resolution).checks[PolicyRequirement.AverageBitrate]) + } + assertEquals(PolicyStatus.Unknown, evaluate(facts.copy(maximumGopBitrate = MediaFact.Unknown), resolution) + .checks[PolicyRequirement.MaximumGopBitrate]) + assertEquals(PolicyStatus.Unknown, evaluate(facts.copy(maximumGopBitrate = known(16_000_001L)), resolution) + .checks[PolicyRequirement.MaximumGopBitrate]) + assertEquals(PolicyStatus.Compliant, evaluate(facts.copy(maximumGopBitrate = known(16_000_000L)), resolution) + .checks[PolicyRequirement.MaximumGopBitrate]) + for ((codec, lowLimit, highLimit) in listOf(Triple(VideoCodec.H264, 20.0, 10.0), Triple(VideoCodec.Hevc, 10.0, 6.0))) { + assertEquals(PolicyStatus.Compliant, evaluate(facts.copy(videoCodec = known(codec), + maximumKeyframeIntervalSeconds = known(highLimit)), resolution).checks[PolicyRequirement.KeyframeInterval]) + assertEquals(PolicyStatus.Unknown, evaluate(facts.copy(videoCodec = known(codec), + maximumKeyframeIntervalSeconds = known(lowLimit)), resolution).checks[PolicyRequirement.KeyframeInterval]) + assertEquals(PolicyStatus.NonCompliant, evaluate(facts.copy(videoCodec = known(codec), + maximumKeyframeIntervalSeconds = known(lowLimit + 0.001)), resolution).checks[PolicyRequirement.KeyframeInterval]) + } + } + } + } + private fun evaluate(facts: MediaFacts, resolution: MaximumResolution = MaximumResolution.Default) = evaluator.evaluate(facts, selection(resolution)) } diff --git a/library/src/test/java/com/mux/video/upload/internal/standardization/StandardInputTestFacts.kt b/library/src/test/java/com/mux/video/upload/internal/standardization/StandardInputTestFacts.kt index 71b26b01..7ebc5739 100644 --- a/library/src/test/java/com/mux/video/upload/internal/standardization/StandardInputTestFacts.kt +++ b/library/src/test/java/com/mux/video/upload/internal/standardization/StandardInputTestFacts.kt @@ -33,6 +33,7 @@ internal fun fullCapabilities() = PlanningCapabilities( encodableVideoCodecs = setOf(VideoCodec.H264, VideoCodec.Hevc), remediableRequirements = PolicyRequirement.entries.toSet(), toneMappableDynamicRanges = setOf(DynamicRange.Hlg, DynamicRange.Pq), + canProduceAacAudio = true, ) internal fun known(value: T): MediaFact = MediaFact.Known(value) From 6c1f6b6918aa9925584997863192157508338006 Mon Sep 17 00:00:00 2001 From: Eugene Morozov Date: Thu, 1 Oct 2026 15:03:33 -0700 Subject: [PATCH 4/5] Require concrete conversion targets and consistent capability proof --- .../standardization/StandardInputPlanner.kt | 90 +++++++----- .../standardization/StandardInputPolicy.kt | 4 +- .../StandardInputPlannerTests.kt | 129 ++++++++++++++---- 3 files changed, 159 insertions(+), 64 deletions(-) diff --git a/library/src/main/java/com/mux/video/upload/internal/standardization/StandardInputPlanner.kt b/library/src/main/java/com/mux/video/upload/internal/standardization/StandardInputPlanner.kt index e1c7b6af..8822d2ba 100644 --- a/library/src/main/java/com/mux/video/upload/internal/standardization/StandardInputPlanner.kt +++ b/library/src/main/java/com/mux/video/upload/internal/standardization/StandardInputPlanner.kt @@ -16,17 +16,30 @@ internal data class PlanningCapabilities( // Unlike conversion, pass-through does not need a local encoder or a server-configuration probe. val preservableHdrDynamicRanges: Set = setOf(DynamicRange.Hlg, DynamicRange.Pq), ) { - fun supports(conversion: StandardInputConversion): Boolean { - // Proven tone mapping already remediates HDR transfer; unrelated violations need their own proof. - val requirements = if (conversion.toneMapsToSdr) conversion.requirementsToRemediate - PolicyRequirement.DynamicRange - else conversion.requirementsToRemediate - return sourceIsDecodable && conversion.outputCodec in encodableVideoCodecs && - remediableRequirements.containsAll(requirements) && - (conversion.outputAudio == OutputAudio.None || canProduceAacAudio) && - (!conversion.toneMapsToSdr || conversion.sourceDynamicRange in toneMappableDynamicRanges) + fun missingCapabilities(conversion: StandardInputConversion): Set = buildSet { + if (!sourceIsDecodable) add(ConversionCapabilityFailure.SourceDecode) + if (conversion.outputCodec !in encodableVideoCodecs) add(ConversionCapabilityFailure.VideoEncode) + for (requirement in conversion.requirementsToRemediate - remediableRequirements) { + add(ConversionCapabilityFailure.Remediation(requirement)) + } + if (conversion.outputAudio != OutputAudio.None && !canProduceAacAudio) { + add(ConversionCapabilityFailure.AacAudioPreparation) + } + if (conversion.toneMapsToSdr && conversion.sourceDynamicRange !in toneMappableDynamicRanges) { + add(ConversionCapabilityFailure.ToneMapping) + } } } +/** Missing proof for the codec, range, and requirements carried by the associated conversion. */ +internal sealed interface ConversionCapabilityFailure { + data object SourceDecode : ConversionCapabilityFailure + data object VideoEncode : ConversionCapabilityFailure + data class Remediation(val requirement: PolicyRequirement) : ConversionCapabilityFailure + data object AacAudioPreparation : ConversionCapabilityFailure + data object ToneMapping : ConversionCapabilityFailure +} + internal enum class OutputAudio { None, /** Produce AAC from audio track ordinal zero. Compliant AAC may be copied; this adds no surround encoder. */ @@ -35,18 +48,19 @@ internal enum class OutputAudio { AacFromFirstTrackIfPresent, } -/** Always a whole-file SDR conversion. Generated bytes still require reinspection before upload. */ +/** Whole-file SDR conversion with concrete output targets. Generated bytes require reinspection before upload. */ internal data class StandardInputConversion( val sourceCodec: VideoCodec, val outputCodec: VideoCodec, val sourceDynamicRange: DynamicRange, - val sourceDisplayDimensions: MediaFact, + val sourceDisplayDimensions: Dimensions, /** Explicit HDR-to-BT.709 SDR pixel conversion; retagging metadata is insufficient. */ val toneMapsToSdr: Boolean, val selection: PolicySelection, + /** Other policy violations requiring proof; explicit AAC preparation and tone mapping supply their own. */ val requirementsToRemediate: Set, - val outputDimensions: MediaFact, - val outputFrameRate: MediaFact, + val outputDimensions: Dimensions, + val outputFrameRate: Double, val outputPixelFormat: PixelFormat, val outputAudio: OutputAudio, ) { @@ -61,9 +75,12 @@ internal sealed interface OriginalReason { } internal sealed interface FallbackReason { - data object InsufficientEvidenceForConversion : FallbackReason + data object InsufficientEvidence : FallbackReason data object UnsafeVideoTrackStructure : FallbackReason - data class UnsupportedConversion(val conversion: StandardInputConversion) : FallbackReason + data class UnsupportedConversion( + val conversion: StandardInputConversion, + val missingCapabilities: Set, + ) : FallbackReason data class UnsupportedHdr(val dynamicRange: DynamicRange) : FallbackReason data class HdrPreservationUnavailable(val dynamicRange: DynamicRange) : FallbackReason data class NonStandardHdr( @@ -110,9 +127,14 @@ internal class StandardInputPlanner( when (range) { DynamicRange.DolbyVision, DynamicRange.OtherHdr -> return fallback(FallbackReason.UnsupportedHdr(range)) DynamicRange.Hlg, DynamicRange.Pq -> { + val codec = facts.videoCodec.valueOrNull ?: return fallback(FallbackReason.InsufficientEvidence) + if (codec == VideoCodec.Other) return fallback(FallbackReason.UnsupportedHdr(range)) if (!toneMap) { - if (evaluation.checks[PolicyRequirement.DynamicRange] != PolicyStatus.Compliant) { - return fallback(FallbackReason.UnsupportedHdr(range)) + if (codec != VideoCodec.Hevc) return fallback(FallbackReason.UnsupportedHdr(range)) + when (evaluation.checks.getValue(PolicyRequirement.DynamicRange)) { + PolicyStatus.Unknown -> return fallback(FallbackReason.InsufficientEvidence) + PolicyStatus.NonCompliant -> return fallback(FallbackReason.UnsupportedHdr(range)) + PolicyStatus.Compliant -> Unit } if (range !in capabilities.preservableHdrDynamicRanges) { return fallback(FallbackReason.HdrPreservationUnavailable(range)) @@ -120,13 +142,8 @@ internal class StandardInputPlanner( if (requirements.isNotEmpty()) return fallback(FallbackReason.NonStandardHdr(range, requirements.toSet())) return original(OriginalReason.PreserveHdr(range)) } - when (facts.videoCodec.valueOrNull) { - null -> return fallback(FallbackReason.InsufficientEvidenceForConversion) - VideoCodec.Other -> return fallback(FallbackReason.UnsupportedHdr(range)) - VideoCodec.H264, VideoCodec.Hevc -> Unit - } } - null -> if (requirements.isNotEmpty()) return fallback(FallbackReason.InsufficientEvidenceForConversion) + null -> if (requirements.isNotEmpty()) return fallback(FallbackReason.InsufficientEvidence) DynamicRange.Sdr -> Unit } if (requirements.isEmpty() && !toneMap) return original( @@ -134,9 +151,10 @@ internal class StandardInputPlanner( else OriginalReason.NoKnownStandardInputViolation) val conversion = buildConversion(facts, selection, requirements.toSet(), toneMap) - ?: return fallback(FallbackReason.InsufficientEvidenceForConversion) - return if (capabilities.supports(conversion)) StandardInputPlan(StandardInputAction.Convert(conversion), evaluation) - else fallback(FallbackReason.UnsupportedConversion(conversion)) + ?: return fallback(FallbackReason.InsufficientEvidence) + val missingCapabilities = capabilities.missingCapabilities(conversion) + return if (missingCapabilities.isEmpty()) StandardInputPlan(StandardInputAction.Convert(conversion), evaluation) + else fallback(FallbackReason.UnsupportedConversion(conversion, missingCapabilities)) } private fun buildConversion( @@ -147,16 +165,12 @@ internal class StandardInputPlanner( ): StandardInputConversion? { val codec = facts.videoCodec.valueOrNull ?: return null val range = facts.dynamicRange.valueOrNull ?: return null - val sourceSize = facts.displayDimensions.valueOrNull - val outputSize = sourceSize?.scaledToFit(selection.generatedOutputDimensions) - if (sourceSize?.isValid == true && outputSize == null) return null - val dimensions = outputSize?.let { MediaFact.Known(it) } ?: MediaFact.Unknown + val sourceSize = facts.displayDimensions.valueOrNull ?: return null + val outputSize = sourceSize.scaledToFit(selection.generatedOutputDimensions) ?: return null + val sourceFrameRate = facts.frameRate.valueOrNull?.takeIf { it.isFinite() && it > 0 } ?: return null val outputCodec = if (codec == VideoCodec.Other) VideoCodec.H264 else codec - val outputLimits = evaluator.applicableLimits(dimensions, selection) - val frameRate = facts.frameRate.valueOrNull?.takeIf { it.isFinite() && it > 0 }?.let { - val targets = outputLimits.map { limits -> if (it in limits.frameRateRange) it else 30.0 }.distinct() - targets.singleOrNull()?.let { target -> MediaFact.Known(target) } - } ?: MediaFact.Unknown + val outputLimits = evaluator.applicableLimits(MediaFact.Known(outputSize), selection).single() + val frameRate = if (sourceFrameRate in outputLimits.frameRateRange) sourceFrameRate else 30.0 val audio = facts.audioTracks.valueOrNull val keepTenBit = !toneMap && outputCodec == VideoCodec.Hevc && facts.pixelFormat.valueOrNull == PixelFormat(10, ChromaSubsampling.Yuv420) @@ -164,11 +178,13 @@ internal class StandardInputPlanner( sourceCodec = codec, outputCodec = outputCodec, sourceDynamicRange = range, - sourceDisplayDimensions = facts.displayDimensions, + sourceDisplayDimensions = sourceSize, toneMapsToSdr = toneMap, selection = selection, - requirementsToRemediate = requirements, - outputDimensions = dimensions, + // These operations have dedicated proof, regardless of how much source inspection established. + requirementsToRemediate = requirements - PolicyRequirement.Audio - + (if (toneMap) setOf(PolicyRequirement.DynamicRange) else emptySet()), + outputDimensions = outputSize, outputFrameRate = frameRate, outputPixelFormat = PixelFormat(if (keepTenBit) 10 else 8, ChromaSubsampling.Yuv420), outputAudio = when { diff --git a/library/src/main/java/com/mux/video/upload/internal/standardization/StandardInputPolicy.kt b/library/src/main/java/com/mux/video/upload/internal/standardization/StandardInputPolicy.kt index 6d82bf1c..2ee90986 100644 --- a/library/src/main/java/com/mux/video/upload/internal/standardization/StandardInputPolicy.kt +++ b/library/src/main/java/com/mux/video/upload/internal/standardization/StandardInputPolicy.kt @@ -74,7 +74,7 @@ internal class StandardInputPolicyEvaluator( selection: PolicySelection, role: MediaRole = MediaRole.SourceInput, ): PolicyEvaluation { - val evaluations = applicableLimits(facts.displayDimensions, selection).map { evaluate(facts, selection, role, it) } + val evaluations = applicableLimits(facts.displayDimensions, selection).map { evaluateForTier(facts, selection, role, it) } if (evaluations.size == 1) return evaluations.single() // An unknown size cannot choose a tier. A check is proven only when both possible tiers agree. return PolicyEvaluation(PolicyRequirement.entries.associateWith { requirement -> @@ -83,7 +83,7 @@ internal class StandardInputPolicyEvaluator( }) } - private fun evaluate( + private fun evaluateForTier( facts: MediaFacts, selection: PolicySelection, role: MediaRole, diff --git a/library/src/test/java/com/mux/video/upload/internal/standardization/StandardInputPlannerTests.kt b/library/src/test/java/com/mux/video/upload/internal/standardization/StandardInputPlannerTests.kt index 73b40c81..f35ecd0d 100644 --- a/library/src/test/java/com/mux/video/upload/internal/standardization/StandardInputPlannerTests.kt +++ b/library/src/test/java/com/mux/video/upload/internal/standardization/StandardInputPlannerTests.kt @@ -45,7 +45,7 @@ class StandardInputPlannerTests { assertEquals(codec, conversion.sourceCodec) assertEquals(codec, conversion.outputCodec) assertEquals(setOf(PolicyRequirement.FrameRate), conversion.requirementsToRemediate) - assertEquals(known(30.0), conversion.outputFrameRate) + assertEquals(30.0, conversion.outputFrameRate, 0.0) assertFalse(conversion.toneMapsToSdr) assertEquals(DynamicRange.Sdr, conversion.outputDynamicRange) } @@ -57,13 +57,15 @@ class StandardInputPlannerTests { assertEquals(PolicyStatus.Compliant, plan.evaluation.outcome) val conversion = (plan.action as StandardInputAction.Convert).conversion assertEquals(setOf(PolicyRequirement.VideoResolution), conversion.requirementsToRemediate) - assertEquals(known(Dimensions(1920, 1080)), conversion.outputDimensions) + assertEquals(Dimensions(1920, 1080), conversion.outputDimensions) } @Test fun wholeFileAudioViolationConvertsVideoInSameFamily() { for (codec in listOf(VideoCodec.H264, VideoCodec.Hevc)) { - val conversion = conversion(compliantFacts(codec).copy(audioTracks = known(listOf(AudioTrack(known(AudioFormat.OtherCodec)))))) - assertEquals(setOf(PolicyRequirement.Audio), conversion.requirementsToRemediate) + val facts = compliantFacts(codec).copy(audioTracks = known(listOf(AudioTrack(known(AudioFormat.OtherCodec))))) + val conversion = conversion(facts) + assertTrue(conversion.requirementsToRemediate.isEmpty()) + assertEquals(setOf(PolicyRequirement.Audio), planner.plan(facts).evaluation.nonCompliantRequirements) assertEquals(codec, conversion.outputCodec) assertEquals(OutputAudio.AacFromFirstTrack, conversion.outputAudio) } @@ -129,10 +131,51 @@ class StandardInputPlannerTests { } @Test fun toneMappingWithUnknownCodecReportsInsufficientEvidence() { - assertEquals(FallbackReason.InsufficientEvidenceForConversion, + assertEquals(FallbackReason.InsufficientEvidence, fallback(hdrFacts().copy(videoCodec = MediaFact.Unknown), options = toneMapOptions())) } + @Test fun betterAudioInspectionDoesNotAddAnotherCapabilityGate() { + val capabilities = fullCapabilities().copy(remediableRequirements = setOf(PolicyRequirement.FrameRate)) + for (format in listOf(MediaFact.Unknown, known(AudioFormat.OtherCodec))) { + val facts = compliantFacts().copy(frameRate = known(121.0), audioTracks = known(listOf(AudioTrack(format)))) + val plan = planner.plan(facts, capabilities = capabilities) + assertTrue("AAC proof must cover known and unknown formats: ${plan.action}", plan.action is StandardInputAction.Convert) + assertEquals(setOf(PolicyRequirement.FrameRate), + (plan.action as StandardInputAction.Convert).conversion.requirementsToRemediate) + assertEquals(if (format == MediaFact.Unknown) PolicyStatus.Unknown else PolicyStatus.NonCompliant, + plan.evaluation.checks[PolicyRequirement.Audio]) + } + } + + @Test fun conversionRequiresKnownUsableDimensionsAndFrameRate() { + val facts = compliantFacts().copy(gopStructure = known(GopStructure.Open)) + val incompleteSources = listOf( + facts.copy(displayDimensions = MediaFact.Unknown), + facts.copy(displayDimensions = known(Dimensions(0, 1080))), + facts.copy(frameRate = MediaFact.Unknown), + facts.copy(frameRate = known(Double.NaN)), + facts.copy(frameRate = known(0.0)), + ) + for (resolution in MaximumResolution.entries) for (source in incompleteSources) { + val plan = planner.plan(source, options(resolution), fullCapabilities()) + assertEquals(StandardInputAction.Fallback(FallbackReason.InsufficientEvidence), plan.action) + assertTrue(PolicyRequirement.GopStructure in plan.evaluation.nonCompliantRequirements) + } + assertEquals(FallbackReason.InsufficientEvidence, + fallback(hdrFacts().copy(displayDimensions = MediaFact.Unknown), options = toneMapOptions())) + } + + @Test fun missingHdrEvidenceHasTheSameReasonForBothHandlingOptions() { + for (handling in HdrHandling.entries) { + assertEquals(FallbackReason.InsufficientEvidence, + fallback(hdrFacts().copy(videoCodec = MediaFact.Unknown), + options = InputStandardization(true, MaximumResolution.Default, handling))) + } + assertEquals(FallbackReason.InsufficientEvidence, + fallback(hdrFacts().copy(pixelFormat = MediaFact.Unknown))) + } + @Test fun unknownSizeDoesNotChooseTheLooserOrStricterTier() { val facts = compliantFacts().copy(displayDimensions = MediaFact.Unknown, frameRate = known(90.0), averageBitrate = known(15_000_000L), maximumGopBitrate = MediaFact.Unknown) @@ -144,13 +187,12 @@ class StandardInputPlannerTests { } } - @Test fun unrelatedKnownViolationWithUnknownOutputSizeDoesNotInventFrameRate() { + @Test fun unrelatedKnownViolationWithUnknownOutputSizeFallsBack() { val facts = compliantFacts().copy(displayDimensions = MediaFact.Unknown, frameRate = known(90.0), gopStructure = known(GopStructure.Open)) - val conversion = conversion(facts, options(MaximumResolution.Preset3840x2160)) - assertEquals(setOf(PolicyRequirement.GopStructure), conversion.requirementsToRemediate) - assertEquals(MediaFact.Unknown, conversion.outputDimensions) - assertEquals(MediaFact.Unknown, conversion.outputFrameRate) + val plan = planner.plan(facts, options(MaximumResolution.Preset3840x2160), fullCapabilities()) + assertEquals(setOf(PolicyRequirement.GopStructure), plan.evaluation.nonCompliantRequirements) + assertEquals(StandardInputAction.Fallback(FallbackReason.InsufficientEvidence), plan.action) } @Test fun provenToneMappingRemediatesDynamicRangeForEitherCodec() { @@ -163,6 +205,7 @@ class StandardInputPlannerTests { val conversion = (action as StandardInputAction.Convert).conversion assertEquals(codec, conversion.outputCodec) assertTrue(conversion.toneMapsToSdr) + assertFalse(PolicyRequirement.DynamicRange in conversion.requirementsToRemediate) } // Tone mapping does not establish unrelated pixel-format remediation. assertTrue(fallback(hdrFacts().copy(videoCodec = known(VideoCodec.H264)), capabilities, toneMapOptions()) @@ -178,7 +221,7 @@ class StandardInputPlannerTests { compliantFacts(dimensions = Dimensions(3840, 2160)).copy(frameRate = known(120.0)) to MaximumResolution.Preset3840x2160)) { assertTrue(fallback(facts, fullCapabilities().copy(remediableRequirements = PolicyRequirement.entries.toSet() - PolicyRequirement.FrameRate), options(resolution)) is FallbackReason.UnsupportedConversion) - assertEquals(known(30.0), conversion(facts, options(resolution)).outputFrameRate) + assertEquals(30.0, conversion(facts, options(resolution)).outputFrameRate, 0.0) } } @@ -203,6 +246,36 @@ class StandardInputPlannerTests { assertTrue(reason is FallbackReason.UnsupportedConversion) } + @Test fun unsupportedConversionNamesEachMissingCapability() { + val facts = hdrFacts().copy(gopStructure = known(GopStructure.Open)) + val full = fullCapabilities() + val cases = listOf( + full.copy(sourceIsDecodable = false) to ConversionCapabilityFailure.SourceDecode, + full.copy(encodableVideoCodecs = emptySet()) to ConversionCapabilityFailure.VideoEncode, + full.copy(remediableRequirements = emptySet()) to + ConversionCapabilityFailure.Remediation(PolicyRequirement.GopStructure), + full.copy(canProduceAacAudio = false) to ConversionCapabilityFailure.AacAudioPreparation, + full.copy(toneMappableDynamicRanges = emptySet()) to ConversionCapabilityFailure.ToneMapping, + ) + for ((capabilities, missing) in cases) { + val reason = fallback(facts, capabilities, toneMapOptions()) as FallbackReason.UnsupportedConversion + assertEquals(setOf(missing), reason.missingCapabilities) + assertEquals(VideoCodec.Hevc, reason.conversion.outputCodec) + assertEquals(DynamicRange.Hlg, reason.conversion.sourceDynamicRange) + } + val allMissing = fallback(facts, PlanningCapabilities(), toneMapOptions()) as FallbackReason.UnsupportedConversion + assertEquals(cases.map { it.second }.toSet(), allMissing.missingCapabilities) + } + + @Test fun audioOnlyConversionUsesTheDedicatedPreparationProof() { + val facts = compliantFacts().copy(audioTracks = known(listOf(AudioTrack(known(AudioFormat.OtherCodec))))) + val capabilities = fullCapabilities().copy(remediableRequirements = emptySet()) + assertTrue(planner.plan(facts, capabilities = capabilities).action is StandardInputAction.Convert) + val reason = fallback(facts, capabilities.copy(canProduceAacAudio = false)) as FallbackReason.UnsupportedConversion + assertEquals(setOf(ConversionCapabilityFailure.AacAudioPreparation), reason.missingCapabilities) + assertTrue(reason.conversion.requirementsToRemediate.isEmpty()) + } + @Test fun unremediableViolationsFallBack() { val cases = mapOf( PolicyRequirement.FrameRate to compliantFacts().copy(frameRate = known(121.0)), @@ -211,7 +284,6 @@ class StandardInputPlannerTests { PolicyRequirement.KeyframeInterval to compliantFacts().copy(maximumKeyframeIntervalSeconds = known(21.0)), PolicyRequirement.GopStructure to compliantFacts().copy(gopStructure = known(GopStructure.Open)), PolicyRequirement.PixelFormat to compliantFacts().copy(pixelFormat = known(PixelFormat(10, ChromaSubsampling.Yuv420))), - PolicyRequirement.Audio to compliantFacts().copy(audioTracks = known(listOf(AudioTrack(known(AudioFormat.OtherCodec))))), PolicyRequirement.EditList to compliantFacts().copy(editList = known(EditList.Complex)), PolicyRequirement.VideoResolution to compliantFacts(dimensions = Dimensions(2048, 1152)), PolicyRequirement.VideoCodec to compliantFacts(VideoCodec.Other), @@ -219,6 +291,8 @@ class StandardInputPlannerTests { for ((requirement, facts) in cases) { val reason = fallback(facts, fullCapabilities().copy(remediableRequirements = PolicyRequirement.entries.toSet() - requirement)) assertTrue("$requirement", reason is FallbackReason.UnsupportedConversion) + assertEquals(setOf(ConversionCapabilityFailure.Remediation(requirement)), + (reason as FallbackReason.UnsupportedConversion).missingCapabilities) } } @@ -227,17 +301,16 @@ class StandardInputPlannerTests { compliantFacts().copy(videoCodec = MediaFact.Unknown, frameRate = known(121.0)), compliantFacts().copy(dynamicRange = MediaFact.Unknown, frameRate = known(121.0)), compliantFacts(dimensions = Dimensions(2048, 1152)).copy(dynamicRange = MediaFact.Unknown))) { - assertEquals(FallbackReason.InsufficientEvidenceForConversion, fallback(facts)) + assertEquals(FallbackReason.InsufficientEvidence, fallback(facts)) } } @Test fun unknownMeasurementsDoNotEraseKnownViolationsOrFabricateTargets() { val facts = compliantFacts().copy(averageBitrate = known(8_000_001L), maximumGopBitrate = MediaFact.Unknown, frameRate = MediaFact.Unknown, displayDimensions = MediaFact.Unknown) - val conversion = conversion(facts) - assertEquals(setOf(PolicyRequirement.AverageBitrate), conversion.requirementsToRemediate) - assertEquals(MediaFact.Unknown, conversion.outputDimensions) - assertEquals(MediaFact.Unknown, conversion.outputFrameRate) + val plan = planner.plan(facts, capabilities = fullCapabilities()) + assertEquals(setOf(PolicyRequirement.AverageBitrate), plan.evaluation.nonCompliantRequirements) + assertEquals(StandardInputAction.Fallback(FallbackReason.InsufficientEvidence), plan.action) } @Test fun eligibleHlgAndPqAreIntentionalOriginalPlansWithoutLocalEncoder() { @@ -264,8 +337,12 @@ class StandardInputPlannerTests { } @Test fun unknownHdrEligibilityFallsBack() { - for (facts in listOf(hdrFacts().copy(pixelFormat = MediaFact.Unknown), hdrFacts().copy(videoCodec = MediaFact.Unknown), - hdrFacts().copy(pixelFormat = known(PixelFormat(8, ChromaSubsampling.Yuv420))), hdrFacts().copy(videoCodec = known(VideoCodec.H264)))) { + for (facts in listOf(hdrFacts().copy(pixelFormat = MediaFact.Unknown), hdrFacts().copy(videoCodec = MediaFact.Unknown))) { + assertEquals(FallbackReason.InsufficientEvidence, fallback(facts)) + } + for (facts in listOf(hdrFacts().copy(pixelFormat = known(PixelFormat(8, ChromaSubsampling.Yuv420))), + hdrFacts().copy(videoCodec = known(VideoCodec.H264)), + hdrFacts().copy(videoCodec = known(VideoCodec.H264), pixelFormat = MediaFact.Unknown))) { assertEquals(FallbackReason.UnsupportedHdr(DynamicRange.Hlg), fallback(facts)) } } @@ -327,7 +404,7 @@ class StandardInputPlannerTests { val cases = listOf(Dimensions(2160, 3840) to Dimensions(1080, 1920), Dimensions(1920, 1920) to Dimensions(1080, 1080), Dimensions(4096, 2160) to Dimensions(1920, 1012)) for ((source, expected) in cases) { - assertEquals(known(expected), conversion(compliantFacts(dimensions = source)).outputDimensions) + assertEquals(expected, conversion(compliantFacts(dimensions = source)).outputDimensions) } } @@ -336,7 +413,9 @@ class StandardInputPlannerTests { Dimensions(2160, 3840), Dimensions(4096, 4096), Dimensions(2, 2), Dimensions(100, 3999)) for (resolution in MaximumResolution.entries) for (source in sizes) { val conversion = conversion(compliantFacts(dimensions = source).copy(gopStructure = known(GopStructure.Open)), options(resolution)) - val output = conversion.outputDimensions.valueOrNull!! + val output = conversion.outputDimensions + assertEquals(source, conversion.sourceDisplayDimensions) + assertEquals(30.0, conversion.outputFrameRate, 0.0) assertTrue("$source $resolution -> $output", output.fitsWithin(Dimensions(resolution.width, resolution.height))) assertTrue(output.width <= source.width && output.height <= source.height) assertEquals(0, output.width % 2) @@ -345,19 +424,19 @@ class StandardInputPlannerTests { } @Test fun nearestEvenAlignmentMatchesSwiftCinematicCase() { - assertEquals(known(Dimensions(1280, 546)), conversion(compliantFacts(dimensions = Dimensions(1920, 818)), + assertEquals(Dimensions(1280, 546), conversion(compliantFacts(dimensions = Dimensions(1920, 818)), options(MaximumResolution.Preset1280x720)).outputDimensions) } @Test fun onePixelAxisCannotBeConvertedWithoutUpscaling() { - assertEquals(FallbackReason.InsufficientEvidenceForConversion, + assertEquals(FallbackReason.InsufficientEvidence, fallback(compliantFacts(dimensions = Dimensions(1, 100)).copy(gopStructure = known(GopStructure.Open)))) } @Test fun downscaledOutputUsesOutputTierForFrameRate() { val source = compliantFacts(dimensions = Dimensions(3840, 2160)).copy(frameRate = known(120.0)) - assertEquals(known(30.0), conversion(source, options(MaximumResolution.Preset3840x2160)).outputFrameRate) - assertEquals(known(120.0), conversion(source, options(MaximumResolution.Preset1920x1080)).outputFrameRate) + assertEquals(30.0, conversion(source, options(MaximumResolution.Preset3840x2160)).outputFrameRate, 0.0) + assertEquals(120.0, conversion(source, options(MaximumResolution.Preset1920x1080)).outputFrameRate, 0.0) } @Test fun plansAreDeterministicAndDoNotMutateInput() { From e1131cc66996fce9586dd80a7cceb7a9deede5fe Mon Sep 17 00:00:00 2001 From: Eugene Morozov Date: Thu, 1 Oct 2026 16:20:31 -0700 Subject: [PATCH 5/5] Align conversion proof with output limits and preserve cadence --- .../internal/standardization/MediaFacts.kt | 1 + .../standardization/StandardInputPlanner.kt | 32 +++++-- .../StandardInputPlannerTests.kt | 89 +++++++++++++++++-- .../standardization/StandardInputTestFacts.kt | 2 +- 4 files changed, 112 insertions(+), 12 deletions(-) diff --git a/library/src/main/java/com/mux/video/upload/internal/standardization/MediaFacts.kt b/library/src/main/java/com/mux/video/upload/internal/standardization/MediaFacts.kt index 6cbe32b6..00b187ef 100644 --- a/library/src/main/java/com/mux/video/upload/internal/standardization/MediaFacts.kt +++ b/library/src/main/java/com/mux/video/upload/internal/standardization/MediaFacts.kt @@ -66,6 +66,7 @@ internal data class MediaFacts( val displayDimensions: MediaFact = MediaFact.Unknown, val rotationDegrees: MediaFact = MediaFact.Unknown, val videoTrackCount: MediaFact = MediaFact.Unknown, + /** Measured nominal rate for policy; it does not imply constant presentation intervals. */ val frameRate: MediaFact = MediaFact.Unknown, val cadence: MediaFact = MediaFact.Unknown, val timestamps: MediaFact = MediaFact.Unknown, diff --git a/library/src/main/java/com/mux/video/upload/internal/standardization/StandardInputPlanner.kt b/library/src/main/java/com/mux/video/upload/internal/standardization/StandardInputPlanner.kt index 8822d2ba..87f69ee3 100644 --- a/library/src/main/java/com/mux/video/upload/internal/standardization/StandardInputPlanner.kt +++ b/library/src/main/java/com/mux/video/upload/internal/standardization/StandardInputPlanner.kt @@ -10,8 +10,11 @@ internal data class PlanningCapabilities( /** A FrameRate entry must prove cadence conversion while preserving the effective source timeline. */ val remediableRequirements: Set = emptySet(), val toneMappableDynamicRanges: Set = emptySet(), - /** Proven first-track AAC preparation, including decode/encode or compliant AAC copy and muxing. */ - val canProduceAacAudio: Boolean = false, + /** + * Source-specific preparation of AAC mono/stereo/5.1, including compliant copy and muxing. + * An AAC encoder alone is insufficient; unsupported channel-layout remediation leaves this false. + */ + val canPrepareCompliantAacAudio: Boolean = false, // These ranges allow unchanged bytes after media eligibility checks. They are not conversion capabilities. // Unlike conversion, pass-through does not need a local encoder or a server-configuration probe. val preservableHdrDynamicRanges: Set = setOf(DynamicRange.Hlg, DynamicRange.Pq), @@ -22,7 +25,7 @@ internal data class PlanningCapabilities( for (requirement in conversion.requirementsToRemediate - remediableRequirements) { add(ConversionCapabilityFailure.Remediation(requirement)) } - if (conversion.outputAudio != OutputAudio.None && !canProduceAacAudio) { + if (conversion.outputAudio != OutputAudio.None && !canPrepareCompliantAacAudio) { add(ConversionCapabilityFailure.AacAudioPreparation) } if (conversion.toneMapsToSdr && conversion.sourceDynamicRange !in toneMappableDynamicRanges) { @@ -48,6 +51,13 @@ internal enum class OutputAudio { AacFromFirstTrackIfPresent, } +internal enum class OutputCadence { + /** Preserve presentation timestamps and the source timeline, including variable frame intervals. */ + PreserveSourceTimestamps, + /** Resample to outputFrameRate without changing duration; requires FrameRate remediation proof. */ + ConstantFrameRate, +} + /** Whole-file SDR conversion with concrete output targets. Generated bytes require reinspection before upload. */ internal data class StandardInputConversion( val sourceCodec: VideoCodec, @@ -60,7 +70,11 @@ internal data class StandardInputConversion( /** Other policy violations requiring proof; explicit AAC preparation and tone mapping supply their own. */ val requirementsToRemediate: Set, val outputDimensions: Dimensions, + /** Nominal measured rate when preserving timestamps; an exact target when resampling. */ val outputFrameRate: Double, + val outputCadence: OutputCadence, + /** Published ceilings for the generated dimensions; encoder settings must produce compliant output. */ + val outputPolicyLimits: TierLimits, val outputPixelFormat: PixelFormat, val outputAudio: OutputAudio, ) { @@ -150,7 +164,7 @@ internal class StandardInputPlanner( if (evaluation.outcome == PolicyStatus.Compliant) OriginalReason.StandardInput else OriginalReason.NoKnownStandardInputViolation) - val conversion = buildConversion(facts, selection, requirements.toSet(), toneMap) + val conversion = buildConversion(facts, selection, toneMap) ?: return fallback(FallbackReason.InsufficientEvidence) val missingCapabilities = capabilities.missingCapabilities(conversion) return if (missingCapabilities.isEmpty()) StandardInputPlan(StandardInputAction.Convert(conversion), evaluation) @@ -160,7 +174,6 @@ internal class StandardInputPlanner( private fun buildConversion( facts: MediaFacts, selection: PolicySelection, - requirements: Set, toneMap: Boolean, ): StandardInputConversion? { val codec = facts.videoCodec.valueOrNull ?: return null @@ -170,7 +183,12 @@ internal class StandardInputPlanner( val sourceFrameRate = facts.frameRate.valueOrNull?.takeIf { it.isFinite() && it > 0 } ?: return null val outputCodec = if (codec == VideoCodec.Other) VideoCodec.H264 else codec val outputLimits = evaluator.applicableLimits(MediaFact.Known(outputSize), selection).single() - val frameRate = if (sourceFrameRate in outputLimits.frameRateRange) sourceFrameRate else 30.0 + val preserveTimestamps = sourceFrameRate in outputLimits.frameRateRange + val frameRate = if (preserveTimestamps) sourceFrameRate else 30.0 + // Source evaluation selects the action; conversion proof covers the generated output's tier. + val requirements = evaluator.evaluate(facts.copy(displayDimensions = MediaFact.Known(outputSize)), + selection, MediaRole.GeneratedOutput).nonCompliantRequirements.toMutableSet() + if (outputSize != sourceSize) requirements.add(PolicyRequirement.VideoResolution) val audio = facts.audioTracks.valueOrNull val keepTenBit = !toneMap && outputCodec == VideoCodec.Hevc && facts.pixelFormat.valueOrNull == PixelFormat(10, ChromaSubsampling.Yuv420) @@ -186,6 +204,8 @@ internal class StandardInputPlanner( (if (toneMap) setOf(PolicyRequirement.DynamicRange) else emptySet()), outputDimensions = outputSize, outputFrameRate = frameRate, + outputCadence = if (preserveTimestamps) OutputCadence.PreserveSourceTimestamps else OutputCadence.ConstantFrameRate, + outputPolicyLimits = outputLimits, outputPixelFormat = PixelFormat(if (keepTenBit) 10 else 8, ChromaSubsampling.Yuv420), outputAudio = when { audio == null -> OutputAudio.AacFromFirstTrackIfPresent diff --git a/library/src/test/java/com/mux/video/upload/internal/standardization/StandardInputPlannerTests.kt b/library/src/test/java/com/mux/video/upload/internal/standardization/StandardInputPlannerTests.kt index f35ecd0d..506d0c43 100644 --- a/library/src/test/java/com/mux/video/upload/internal/standardization/StandardInputPlannerTests.kt +++ b/library/src/test/java/com/mux/video/upload/internal/standardization/StandardInputPlannerTests.kt @@ -113,7 +113,7 @@ class StandardInputPlannerTests { } @Test fun aacPreparationProofCoversCopyAndEncodingForTheSelectedTrack() { - val videoOnly = fullCapabilities().copy(canProduceAacAudio = false) + val videoOnly = fullCapabilities().copy(canPrepareCompliantAacAudio = false) val facts = compliantFacts().copy(frameRate = known(121.0)) for (audio in listOf(facts.audioTracks, MediaFact.Unknown, known(listOf(AudioTrack())), known(listOf(AudioTrack(known(AudioFormat.OtherCodec)))), @@ -121,7 +121,7 @@ class StandardInputPlannerTests { known(listOf(AudioTrack(known(AudioFormat.Aac(AudioChannelLayout.FivePointOne))))))) { val source = facts.copy(audioTracks = audio) assertTrue(fallback(source, videoOnly) is FallbackReason.UnsupportedConversion) - assertTrue(planner.plan(source, capabilities = videoOnly.copy(canProduceAacAudio = true)).action + assertTrue(planner.plan(source, capabilities = videoOnly.copy(canPrepareCompliantAacAudio = true)).action is StandardInputAction.Convert) } assertTrue(planner.plan(facts.copy(audioTracks = known(emptyList())), capabilities = videoOnly).action @@ -221,10 +221,54 @@ class StandardInputPlannerTests { compliantFacts(dimensions = Dimensions(3840, 2160)).copy(frameRate = known(120.0)) to MaximumResolution.Preset3840x2160)) { assertTrue(fallback(facts, fullCapabilities().copy(remediableRequirements = PolicyRequirement.entries.toSet() - PolicyRequirement.FrameRate), options(resolution)) is FallbackReason.UnsupportedConversion) - assertEquals(30.0, conversion(facts, options(resolution)).outputFrameRate, 0.0) + val conversion = conversion(facts, options(resolution)) + assertEquals(30.0, conversion.outputFrameRate, 0.0) + assertEquals(OutputCadence.ConstantFrameRate, conversion.outputCadence) + } + } + + @Test fun unrelatedConversionPreservesKnownOrUnknownSourceCadence() { + for (cadence in listOf(known(Cadence.Constant), known(Cadence.Variable), MediaFact.Unknown)) { + val facts = compliantFacts().copy(cadence = cadence, gopStructure = known(GopStructure.Open)) + val plan = planner.plan(facts, capabilities = fullCapabilities().copy( + remediableRequirements = setOf(PolicyRequirement.GopStructure))) + assertTrue("GOP conversion must preserve the source timestamps: ${plan.action}", plan.action is StandardInputAction.Convert) + val conversion = (plan.action as StandardInputAction.Convert).conversion + assertEquals(OutputCadence.PreserveSourceTimestamps, conversion.outputCadence) + assertEquals(30.0, conversion.outputFrameRate, 0.0) + assertEquals(setOf(PolicyRequirement.GopStructure), conversion.requirementsToRemediate) } } + @Test fun resamplingVariableOrUnknownCadenceRequiresFrameRateProof() { + for (cadence in listOf(known(Cadence.Variable), MediaFact.Unknown)) { + val facts = compliantFacts().copy(cadence = cadence, frameRate = known(121.0), + gopStructure = known(GopStructure.Open)) + val gopOnly = fullCapabilities().copy(remediableRequirements = setOf(PolicyRequirement.GopStructure)) + val reason = fallback(facts, gopOnly) as FallbackReason.UnsupportedConversion + assertEquals(setOf(ConversionCapabilityFailure.Remediation(PolicyRequirement.FrameRate)), reason.missingCapabilities) + assertEquals(OutputCadence.ConstantFrameRate, reason.conversion.outputCadence) + assertEquals(30.0, reason.conversion.outputFrameRate, 0.0) + assertTrue(planner.plan(facts, capabilities = gopOnly.copy( + remediableRequirements = setOf(PolicyRequirement.GopStructure, PolicyRequirement.FrameRate))) + .action is StandardInputAction.Convert) + } + } + + @Test fun unsupportedAacChannelLayoutNeedsCompliantPreparationProof() { + // Other includes layouts such as 7.1. Generic AAC encoding or Audio remediation cannot prove a downmix. + val facts = compliantFacts().copy(audioTracks = known(listOf(AudioTrack(known(AudioFormat.Aac(AudioChannelLayout.Other)))))) + val plan = planner.plan(facts, capabilities = fullCapabilities().copy(canPrepareCompliantAacAudio = false)) + assertEquals(setOf(PolicyRequirement.Audio), plan.evaluation.nonCompliantRequirements) + val reason = (plan.action as StandardInputAction.Fallback).reason as FallbackReason.UnsupportedConversion + assertEquals(setOf(ConversionCapabilityFailure.AacAudioPreparation), reason.missingCapabilities) + assertTrue(reason.conversion.requirementsToRemediate.isEmpty()) + assertEquals(OutputAudio.AacFromFirstTrack, reason.conversion.outputAudio) + // Accepted 5.1 remains eligible for unchanged upload without any preparation capability. + assertEquals(StandardInputAction.UploadOriginal(OriginalReason.StandardInput), planner.plan(facts.copy( + audioTracks = known(listOf(AudioTrack(known(AudioFormat.Aac(AudioChannelLayout.FivePointOne))))))).action) + } + @Test fun otherDecodableSdrCodecConvertsToH264() { val conversion = conversion(compliantFacts(VideoCodec.Other).copy(pixelFormat = known(PixelFormat(10, ChromaSubsampling.Other)))) assertEquals(VideoCodec.Other, conversion.sourceCodec) @@ -254,7 +298,7 @@ class StandardInputPlannerTests { full.copy(encodableVideoCodecs = emptySet()) to ConversionCapabilityFailure.VideoEncode, full.copy(remediableRequirements = emptySet()) to ConversionCapabilityFailure.Remediation(PolicyRequirement.GopStructure), - full.copy(canProduceAacAudio = false) to ConversionCapabilityFailure.AacAudioPreparation, + full.copy(canPrepareCompliantAacAudio = false) to ConversionCapabilityFailure.AacAudioPreparation, full.copy(toneMappableDynamicRanges = emptySet()) to ConversionCapabilityFailure.ToneMapping, ) for ((capabilities, missing) in cases) { @@ -271,7 +315,7 @@ class StandardInputPlannerTests { val facts = compliantFacts().copy(audioTracks = known(listOf(AudioTrack(known(AudioFormat.OtherCodec))))) val capabilities = fullCapabilities().copy(remediableRequirements = emptySet()) assertTrue(planner.plan(facts, capabilities = capabilities).action is StandardInputAction.Convert) - val reason = fallback(facts, capabilities.copy(canProduceAacAudio = false)) as FallbackReason.UnsupportedConversion + val reason = fallback(facts, capabilities.copy(canPrepareCompliantAacAudio = false)) as FallbackReason.UnsupportedConversion assertEquals(setOf(ConversionCapabilityFailure.AacAudioPreparation), reason.missingCapabilities) assertTrue(reason.conversion.requirementsToRemediate.isEmpty()) } @@ -439,6 +483,41 @@ class StandardInputPlannerTests { assertEquals(120.0, conversion(source, options(MaximumResolution.Preset1920x1080)).outputFrameRate, 0.0) } + @Test fun downscaledOutputDoesNotRequireUnneededFrameRateOrKeyframeRemediation() { + for (codec in listOf(VideoCodec.H264, VideoCodec.Hevc)) { + val source = compliantFacts(codec, Dimensions(4096, 4096)).copy(frameRate = known(90.0), + maximumKeyframeIntervalSeconds = known(if (codec == VideoCodec.H264) 15.0 else 8.0)) + val plan = planner.plan(source, options(MaximumResolution.Preset2560x1440), + fullCapabilities().copy(remediableRequirements = setOf(PolicyRequirement.VideoResolution))) + assertEquals(setOf(PolicyRequirement.FrameRate, PolicyRequirement.KeyframeInterval), + plan.evaluation.nonCompliantRequirements) + assertTrue("Only resizing is needed for the output tier: ${plan.action}", plan.action is StandardInputAction.Convert) + val conversion = (plan.action as StandardInputAction.Convert).conversion + assertEquals(Dimensions(1440, 1440), conversion.outputDimensions) + assertEquals(90.0, conversion.outputFrameRate, 0.0) + assertEquals(OutputCadence.PreserveSourceTimestamps, conversion.outputCadence) + assertEquals(setOf(PolicyRequirement.VideoResolution), conversion.requirementsToRemediate) + assertEquals(8_000_000L, conversion.outputPolicyLimits.maximumAverageBitrate) + assertEquals(16_000_000L, conversion.outputPolicyLimits.maximumGopBitrate) + assertEquals(if (codec == VideoCodec.H264) 20.0 else 10.0, + conversion.outputPolicyLimits.maximumKeyframeIntervals.getValue(codec), 0.0) + } + } + + @Test fun downscaledOutputRequiresStricterBitrateRemediation() { + val source = compliantFacts(dimensions = Dimensions(4096, 4096)).copy( + averageBitrate = known(15_000_000L), maximumGopBitrate = known(17_000_000L)) + val plan = planner.plan(source, options(MaximumResolution.Preset2560x1440), + fullCapabilities().copy(remediableRequirements = setOf(PolicyRequirement.VideoResolution))) + assertEquals(PolicyStatus.Compliant, plan.evaluation.outcome) + assertTrue("Generated output needs the lower-tier bitrate proof: ${plan.action}", plan.action is StandardInputAction.Fallback) + val reason = (plan.action as StandardInputAction.Fallback).reason as FallbackReason.UnsupportedConversion + assertEquals(setOf(ConversionCapabilityFailure.Remediation(PolicyRequirement.AverageBitrate), + ConversionCapabilityFailure.Remediation(PolicyRequirement.MaximumGopBitrate)), reason.missingCapabilities) + assertEquals(setOf(PolicyRequirement.VideoResolution, PolicyRequirement.AverageBitrate, + PolicyRequirement.MaximumGopBitrate), reason.conversion.requirementsToRemediate) + } + @Test fun plansAreDeterministicAndDoNotMutateInput() { val facts = compliantFacts().copy(frameRate = known(121.0)) val original = facts.copy() diff --git a/library/src/test/java/com/mux/video/upload/internal/standardization/StandardInputTestFacts.kt b/library/src/test/java/com/mux/video/upload/internal/standardization/StandardInputTestFacts.kt index 7ebc5739..f917f832 100644 --- a/library/src/test/java/com/mux/video/upload/internal/standardization/StandardInputTestFacts.kt +++ b/library/src/test/java/com/mux/video/upload/internal/standardization/StandardInputTestFacts.kt @@ -33,7 +33,7 @@ internal fun fullCapabilities() = PlanningCapabilities( encodableVideoCodecs = setOf(VideoCodec.H264, VideoCodec.Hevc), remediableRequirements = PolicyRequirement.entries.toSet(), toneMappableDynamicRanges = setOf(DynamicRange.Hlg, DynamicRange.Pq), - canProduceAacAudio = true, + canPrepareCompliantAacAudio = true, ) internal fun known(value: T): MediaFact = MediaFact.Known(value)