Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
7 changes: 7 additions & 0 deletions crates/remux-sdks/src/remux/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1716,6 +1716,9 @@ pub struct VideoStreamQuery {
pub device_id: Option<String>,
pub audio_codec: Option<String>,
pub video_codec: Option<String>,
/// Sample-entry fourcc for an HEVC stream copy (`hvc1`/`hev1`), resolved
/// from the client's DeviceProfile at PlaybackInfo time.
pub video_codec_tag: Option<String>,
pub video_bit_rate: Option<i64>,
pub audio_bit_rate: Option<i64>,
pub audio_channels: Option<i64>,
Expand Down Expand Up @@ -4373,6 +4376,10 @@ pub struct HlsVideoQuery {
#[serde(alias = "mediaSourceId")]
pub media_source_id: Option<Uuid>,
pub video_codec: Option<String>,
/// Sample-entry fourcc for an HEVC stream copy (`hvc1`/`hev1`), resolved
/// from the client's DeviceProfile at PlaybackInfo time and carried here
/// because the profile isn't available on this request.
pub video_codec_tag: Option<String>,
pub audio_codec: Option<String>,
pub segment_length: Option<i32>,
pub start_time_ticks: Option<i64>,
Expand Down
12 changes: 12 additions & 0 deletions crates/remux-server/src/api/hls.rs
Original file line number Diff line number Diff line change
Expand Up @@ -425,6 +425,8 @@ async fn create_hls_session(
is_live,
source_video_codec,
source_audio_codec,
q.video_codec_tag
.clone(),
source_video_profile,
source_video_level,
source_video_range_type,
Expand Down Expand Up @@ -500,6 +502,11 @@ async fn create_hls_session(
.await
.source_audio_codec
.clone(),
hevc_copy_tag: session
.read()
.await
.hevc_copy_tag
.clone(),
accelerator: hw_accel::from_encoding_opts(&encoding_opts),
source_video_range_type,
enable_tonemapping: encoding_opts
Expand Down Expand Up @@ -1180,6 +1187,11 @@ async fn hls_segment_inner(
.await
.source_audio_codec
.clone(),
hevc_copy_tag: session
.read()
.await
.hevc_copy_tag
.clone(),
accelerator: hw_accel::from_encoding_opts(&encoding_opts),
source_video_range_type: session
.read()
Expand Down
3 changes: 3 additions & 0 deletions crates/remux-server/src/api/playback.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1042,6 +1042,9 @@ async fn videos_stream_inner(
encoding_preset: encoding_opts.encoding_preset,
source_video_codec,
source_audio_codec,
hevc_copy_tag: q
.video_codec_tag
.clone(),
accelerator: hw_accel::from_encoding_opts(&encoding_opts),
source_video_range_type,
enable_tonemapping: encoding_opts
Expand Down
122 changes: 122 additions & 0 deletions crates/remux-server/src/device_profile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,14 @@ pub trait DeviceProfileExt {
fn subtitle_delivery_method(&self, codec: &str) -> Option<SubtitleDeliveryMethod>;
fn supports_direct_play(&self, media_source: &MediaSourceInfo) -> bool;
fn check_direct_play(&self, media_source: &MediaSourceInfo) -> TranscodeReasons;
fn hevc_copy_tag(&self) -> &'static str;
}
Comment thread
lostb1t marked this conversation as resolved.

/// Sample-entry fourcc for HEVC in an MP4-family container. `hvc1` asserts the
/// `hvcC` carries VPS/SPS/PPS out-of-band; `hev1` also permits them in-band.
pub const HEVC_TAG_HVC1: &str = "hvc1";
pub const HEVC_TAG_HEV1: &str = "hev1";
Comment thread
lostb1t marked this conversation as resolved.

pub(crate) fn subtitle_codec_matches_profile(
codec: &str,
profile_format: &str,
Expand Down Expand Up @@ -135,6 +141,42 @@ impl DeviceProfileExt for DeviceProfile {
r
})
}

/// Which HEVC sample-entry tag this client needs when we stream-copy HEVC
/// into fMP4.
///
/// Apple clients advertise a `VideoCodecTag` condition on their hevc codec
/// profile (Safari sends `EqualsAny hvc1|dvh1`), because Apple's HLS
/// authoring spec mandates `hvc1`. Clients that don't care simply omit the
/// condition, and for those `hev1` is the safer choice: `hvc1` promises the
/// `hvcC` carries VPS/SPS/PPS out-of-band, which is a lie for sources that
/// keep parameter sets in-band (some WEB-DL repackages). ffmpeg copies the
/// header-only record through verbatim and the resulting empty `hvcC`
/// leaves ExoPlayer unable to initialise a decoder.
///
/// Decided by asking the client's own conditions whether `hev1` is
/// acceptable, so `Equals`/`NotEquals`/`EqualsAny` are all honoured without
/// re-implementing them here.
fn hevc_copy_tag(&self) -> &'static str {
let hev1_rejected = self
.codec_profiles
.iter()
.filter(|cp| matches!(cp.type_, Some(DlnaProfileType::Video)))
.filter(|cp| cp.applies_to_codec("hevc"))
.flat_map(|cp| &cp.conditions)
.filter(|cond| {
cond.property
.as_deref()
== Some("VideoCodecTag")
})
.any(|cond| !cond.is_satisfied_opt(Some(HEVC_TAG_HEV1)));

if hev1_rejected {
HEVC_TAG_HVC1
} else {
HEVC_TAG_HEV1
}
}
}

fn check_codec_profiles(
Expand Down Expand Up @@ -759,4 +801,84 @@ mod tests {
"an audio-only constraint must not produce VideoCodecNotSupported: {reasons:?}"
);
}

fn hevc_tag_condition(condition: &str, value: &str) -> DeviceProfile {
DeviceProfile {
codec_profiles: vec![CodecProfile {
type_: Some(DlnaProfileType::Video),
codec: Some(vec!["hevc".to_string()]),
conditions: vec![ProfileCondition {
condition: Some(condition.to_string()),
property: Some("VideoCodecTag".to_string()),
value: Some(value.to_string()),
is_required: Some(true),
}],
}],
..Default::default()
}
}

#[test]
fn hevc_copy_tag_is_hvc1_for_safaris_declared_condition() {
// Verbatim from Jellyfin's own Safari test profile
// (tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-SafariNext.json).
let profile = hevc_tag_condition("EqualsAny", "hvc1|dvh1");
assert_eq!(profile.hevc_copy_tag(), "hvc1");
}

#[test]
fn hevc_copy_tag_is_hev1_when_client_declares_no_tag_constraint() {
// No VideoCodecTag condition at all — the client doesn't care, so use
// hev1, which stays valid when parameter sets are in-band.
let profile = DeviceProfile {
codec_profiles: vec![CodecProfile {
type_: Some(DlnaProfileType::Video),
codec: Some(vec!["hevc".to_string()]),
conditions: vec![ProfileCondition {
condition: Some("EqualsAny".to_string()),
property: Some("VideoProfile".to_string()),
value: Some("main|main 10".to_string()),
is_required: Some(false),
}],
}],
..Default::default()
};
assert_eq!(profile.hevc_copy_tag(), "hev1");
}

#[test]
fn hevc_copy_tag_is_hev1_for_an_empty_profile() {
assert_eq!(DeviceProfile::default().hevc_copy_tag(), "hev1");
}

#[test]
fn hevc_copy_tag_honours_a_condition_that_explicitly_accepts_hev1() {
let profile = hevc_tag_condition("EqualsAny", "hvc1|hev1");
assert_eq!(profile.hevc_copy_tag(), "hev1");
}

#[test]
fn hevc_copy_tag_honours_not_equals_conditions() {
// NotEquals hev1 means the client refuses hev1 -> must send hvc1.
let profile = hevc_tag_condition("NotEquals", "hev1");
assert_eq!(profile.hevc_copy_tag(), "hvc1");
}

#[test]
fn hevc_copy_tag_ignores_tag_conditions_scoped_to_other_codecs() {
let profile = DeviceProfile {
codec_profiles: vec![CodecProfile {
type_: Some(DlnaProfileType::Video),
codec: Some(vec!["h264".to_string()]),
conditions: vec![ProfileCondition {
condition: Some("EqualsAny".to_string()),
property: Some("VideoCodecTag".to_string()),
value: Some("avc1".to_string()),
is_required: Some(true),
}],
}],
..Default::default()
};
assert_eq!(profile.hevc_copy_tag(), "hev1");
}
}
14 changes: 12 additions & 2 deletions crates/remux-server/src/playback/decision.rs
Original file line number Diff line number Diff line change
Expand Up @@ -294,10 +294,18 @@ fn build_video_transcode(
.start_time_ticks
.map(|t| format!("&StartTimeTicks={t}"))
.unwrap_or_default();
// The DeviceProfile only exists on this request, but the tag is needed
// later when the HLS session builds its ffmpeg args — carry the resolved
// value on the URL, like every other per-playback decision above.
let video_codec_tag = q
.device_profile
.as_ref()
.map(|p| format!("&VideoCodecTag={}", p.hevc_copy_tag()))
.unwrap_or_default();

let url = if protocol.eq_ignore_ascii_case("hls") {
format!(
"/videos/{}/master.m3u8?PlaySessionId={}&MediaSourceId={}&VideoCodec={}&AudioCodec={}{}{}{}{}{}{}&ApiKey={}",
"/videos/{}/master.m3u8?PlaySessionId={}&MediaSourceId={}&VideoCodec={}&AudioCodec={}{}{}{}{}{}{}{}&ApiKey={}",
cfg.item_id,
cfg.play_session_id,
source.id,
Expand All @@ -309,14 +317,15 @@ fn build_video_transcode(
sub_idx,
sub_method,
start_time,
video_codec_tag,
session
.device
.access_token
.expose(),
)
} else {
format!(
"/videos/{}/stream.{}?PlaySessionId={}&MediaSourceId={}&VideoCodec={}&AudioCodec={}{}{}{}{}{}{}&ApiKey={}",
"/videos/{}/stream.{}?PlaySessionId={}&MediaSourceId={}&VideoCodec={}&AudioCodec={}{}{}{}{}{}{}{}&ApiKey={}",
cfg.item_id,
container,
cfg.play_session_id,
Expand All @@ -329,6 +338,7 @@ fn build_video_transcode(
sub_idx,
sub_method,
start_time,
video_codec_tag,
session
.device
.access_token
Expand Down
Loading
Loading