Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
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<out T> {
data class Known<T>(val value: T) : MediaFact<T>
data object Unknown : MediaFact<Nothing>
}

internal val <T> MediaFact<T>.valueOrNull: T?
get() = (this as? MediaFact.Known<T>)?.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<AudioFormat> = 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

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(
val firstPresentationSeconds: Double,
val lastPresentationSeconds: Double,
val sampleCount: Long,
val isMonotonic: Boolean,
)

/**
* 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<VideoCodec> = MediaFact.Unknown,
val videoProfile: MediaFact<VideoProfile> = MediaFact.Unknown,
val encodedDimensions: MediaFact<Dimensions> = MediaFact.Unknown,
val displayDimensions: MediaFact<Dimensions> = MediaFact.Unknown,
val rotationDegrees: MediaFact<Int> = MediaFact.Unknown,
val videoTrackCount: MediaFact<Int> = MediaFact.Unknown,
/** Measured nominal rate for policy; it does not imply constant presentation intervals. */
val frameRate: MediaFact<Double> = MediaFact.Unknown,
val cadence: MediaFact<Cadence> = MediaFact.Unknown,
val timestamps: MediaFact<TimestampFacts> = MediaFact.Unknown,
val durationSeconds: MediaFact<Double> = MediaFact.Unknown,
val audioVideoStartOffsetSeconds: MediaFact<Double> = MediaFact.Unknown,
val averageBitrate: MediaFact<Long> = MediaFact.Unknown,
val maximumGopBitrate: MediaFact<Long> = MediaFact.Unknown,
val maximumGopByteSize: MediaFact<Long> = MediaFact.Unknown,
val maximumKeyframeIntervalSeconds: MediaFact<Double> = MediaFact.Unknown,
val gopStructure: MediaFact<GopStructure> = MediaFact.Unknown,
val pixelFormat: MediaFact<PixelFormat> = MediaFact.Unknown,
val dynamicRange: MediaFact<DynamicRange> = MediaFact.Unknown,
val audioTracks: MediaFact<List<AudioTrack>> = MediaFact.Unknown,
val editList: MediaFact<EditList> = MediaFact.Unknown,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
package com.mux.video.upload.internal.standardization

import com.mux.video.upload.api.HdrHandling
import com.mux.video.upload.internal.InputStandardization

/** 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<VideoCodec> = emptySet(),
/** A FrameRate entry must prove cadence conversion while preserving the effective source timeline. */
val remediableRequirements: Set<PolicyRequirement> = emptySet(),
val toneMappableDynamicRanges: Set<DynamicRange> = emptySet(),
/**
* 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<DynamicRange> = setOf(DynamicRange.Hlg, DynamicRange.Pq),
) {
fun missingCapabilities(conversion: StandardInputConversion): Set<ConversionCapabilityFailure> = 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 && !canPrepareCompliantAacAudio) {
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. */
AacFromFirstTrack,
/** Unknown audio presence must be resolved during preparation, never interpreted as silence. */
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,
val outputCodec: VideoCodec,
val sourceDynamicRange: DynamicRange,
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<PolicyRequirement>,
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,
) {
val outputDynamicRange: DynamicRange get() = DynamicRange.Sdr
}

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 InsufficientEvidence : FallbackReason
data object UnsafeVideoTrackStructure : FallbackReason
data class UnsupportedConversion(
val conversion: StandardInputConversion,
val missingCapabilities: Set<ConversionCapabilityFailure>,
) : FallbackReason
data class UnsupportedHdr(val dynamicRange: DynamicRange) : FallbackReason
data class HdrPreservationUnavailable(val dynamicRange: DynamicRange) : FallbackReason
data class NonStandardHdr(
val dynamicRange: DynamicRange,
val requirements: Set<PolicyRequirement>,
) : 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
}

/** 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(
private val evaluator: StandardInputPolicyEvaluator = StandardInputPolicyEvaluator(),
) {
fun plan(
facts: MediaFacts,
options: InputStandardization = InputStandardization(),
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)

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 == DynamicRange.Hlg || range == DynamicRange.Pq) &&
options.hdrHandling == HdrHandling.ToneMapToSDR
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 (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))
}
if (requirements.isNotEmpty()) return fallback(FallbackReason.NonStandardHdr(range, requirements.toSet()))
return original(OriginalReason.PreserveHdr(range))
}
}
null -> if (requirements.isNotEmpty()) return fallback(FallbackReason.InsufficientEvidence)
DynamicRange.Sdr -> Unit
}
if (requirements.isEmpty() && !toneMap) return original(
if (evaluation.outcome == PolicyStatus.Compliant) OriginalReason.StandardInput
else OriginalReason.NoKnownStandardInputViolation)

val conversion = buildConversion(facts, selection, toneMap)
?: 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(
facts: MediaFacts,
selection: PolicySelection,
toneMap: Boolean,
): StandardInputConversion? {
val codec = facts.videoCodec.valueOrNull ?: return null
val range = facts.dynamicRange.valueOrNull ?: return null
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(MediaFact.Known(outputSize), selection).single()
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)
return StandardInputConversion(
sourceCodec = codec,
outputCodec = outputCodec,
sourceDynamicRange = range,
sourceDisplayDimensions = sourceSize,
toneMapsToSdr = toneMap,
selection = selection,
// 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,
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
audio.isEmpty() -> OutputAudio.None
else -> OutputAudio.AacFromFirstTrack
},
)
}
}
Loading