diff --git a/crates/remux-sdks/src/remux/mod.rs b/crates/remux-sdks/src/remux/mod.rs index 3eb82a7b..c8044177 100644 --- a/crates/remux-sdks/src/remux/mod.rs +++ b/crates/remux-sdks/src/remux/mod.rs @@ -1716,6 +1716,9 @@ pub struct VideoStreamQuery { pub device_id: Option, pub audio_codec: Option, pub video_codec: Option, + /// Sample-entry fourcc for an HEVC stream copy (`hvc1`/`hev1`), resolved + /// from the client's DeviceProfile at PlaybackInfo time. + pub video_codec_tag: Option, pub video_bit_rate: Option, pub audio_bit_rate: Option, pub audio_channels: Option, @@ -4373,6 +4376,10 @@ pub struct HlsVideoQuery { #[serde(alias = "mediaSourceId")] pub media_source_id: Option, pub video_codec: Option, + /// 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, pub audio_codec: Option, pub segment_length: Option, pub start_time_ticks: Option, diff --git a/crates/remux-server/src/api/hls.rs b/crates/remux-server/src/api/hls.rs index 73234b67..02457a70 100644 --- a/crates/remux-server/src/api/hls.rs +++ b/crates/remux-server/src/api/hls.rs @@ -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, @@ -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 @@ -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() diff --git a/crates/remux-server/src/api/playback.rs b/crates/remux-server/src/api/playback.rs index 261493e4..3aaee503 100644 --- a/crates/remux-server/src/api/playback.rs +++ b/crates/remux-server/src/api/playback.rs @@ -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 diff --git a/crates/remux-server/src/device_profile.rs b/crates/remux-server/src/device_profile.rs index ddbd4943..9a992f1e 100644 --- a/crates/remux-server/src/device_profile.rs +++ b/crates/remux-server/src/device_profile.rs @@ -12,8 +12,14 @@ pub trait DeviceProfileExt { fn subtitle_delivery_method(&self, codec: &str) -> Option; fn supports_direct_play(&self, media_source: &MediaSourceInfo) -> bool; fn check_direct_play(&self, media_source: &MediaSourceInfo) -> TranscodeReasons; + fn hevc_copy_tag(&self, media_source: &MediaSourceInfo) -> &'static str; } +/// 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"; + pub(crate) fn subtitle_codec_matches_profile( codec: &str, profile_format: &str, @@ -135,6 +141,70 @@ impl DeviceProfileExt for DeviceProfile { r }) } + + /// Which HEVC sample-entry tag to write when we stream-copy HEVC into fMP4. + /// + /// Two independent questions decide this, and `hvc1` — today's behaviour, + /// and what Apple's HLS authoring spec mandates — wins unless both come + /// back clean: + /// + /// 1. *What will the client accept?* Apple clients advertise a + /// `VideoCodecTag` condition on their hevc codec profile (Safari sends + /// `EqualsAny hvc1|dvh1`); most clients omit it entirely. Asked through + /// the client's own conditions, so `Equals`/`NotEquals`/`EqualsAny` are + /// all honoured without re-implementing them here. + /// 2. *Is `hvc1` true for this file?* `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. A muxer that put the + /// parameter sets in-band said so in its own sample entry, so the + /// source's fourcc is the signal. + /// + /// A silent client on an ordinary `hvc1` source therefore stays on `hvc1`; + /// only a source that is itself `hev1` moves, and only when the client + /// hasn't ruled `hev1` out. + fn hevc_copy_tag(&self, media_source: &MediaSourceInfo) -> &'static str { + let client_rejects = |tag: &str| { + 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(tag))) + }; + + // A declared constraint is the client telling us outright. Checked + // hvc1-first so a contradictory profile that rejects both still lands + // on today's behaviour. + if client_rejects(HEVC_TAG_HEV1) { + return HEVC_TAG_HVC1; + } + if client_rejects(HEVC_TAG_HVC1) { + return HEVC_TAG_HEV1; + } + + // The client takes either, so keep hvc1 unless the source itself says + // its parameter sets are in-band. + let source_is_hev1 = media_source + .video_stream() + .and_then(|s| { + s.codec_tag + .as_deref() + }) + .is_some_and(|tag| tag.eq_ignore_ascii_case(HEVC_TAG_HEV1)); + + if source_is_hev1 { + HEVC_TAG_HEV1 + } else { + HEVC_TAG_HVC1 + } + } } fn check_codec_profiles( @@ -759,4 +829,123 @@ 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() + } + } + + /// An HEVC source whose sample entry is `tag` (`None` = the container + /// reports no fourcc, as MKV does). + fn hevc_source(tag: Option<&str>) -> MediaSourceInfo { + MediaSourceInfo { + container: Some(VideoContainer::Mp4), + media_streams: vec![MediaStream { + codec: Some("hevc".to_string()), + codec_tag: tag.map(str::to_string), + type_: Some(MediaStreamType::Video), + index: 0, + ..Default::default() + }], + ..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). + // Declared constraints outrank the source: even an hev1 source has to + // be retagged for a client that only accepts hvc1. + let profile = hevc_tag_condition("EqualsAny", "hvc1|dvh1"); + assert_eq!(profile.hevc_copy_tag(&hevc_source(Some("hev1"))), "hvc1"); + } + + #[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(&hevc_source(Some("hev1"))), "hvc1"); + } + + #[test] + fn hevc_copy_tag_is_hev1_when_the_client_rules_hvc1_out() { + let profile = hevc_tag_condition("Equals", "hev1"); + assert_eq!(profile.hevc_copy_tag(&hevc_source(Some("hvc1"))), "hev1"); + } + + #[test] + fn hevc_copy_tag_follows_the_source_when_the_client_is_silent() { + // No VideoCodecTag condition anywhere: an hev1 source keeps hev1, + // because its parameter sets are in-band and hvc1 would be a lie. + let silent = 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!(silent.hevc_copy_tag(&hevc_source(Some("hev1"))), "hev1"); + } + + #[test] + fn hevc_copy_tag_stays_hvc1_for_ordinary_sources() { + // The regression guard: a silent client on anything that isn't + // positively hev1 keeps today's behaviour. An absent fourcc is not + // evidence — MKV reports none yet keeps parameter sets out-of-band in + // CodecPrivate. + for tag in [None, Some("hvc1")] { + assert_eq!( + DeviceProfile::default().hevc_copy_tag(&hevc_source(tag)), + "hvc1", + "tag={tag:?}" + ); + } + } + + #[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() + }; + // The h264 constraint must not decide anything, leaving the source to. + assert_eq!(profile.hevc_copy_tag(&hevc_source(Some("hev1"))), "hev1"); + assert_eq!(profile.hevc_copy_tag(&hevc_source(Some("hvc1"))), "hvc1"); + } + + #[test] + fn hevc_copy_tag_is_hvc1_when_the_source_has_no_video_stream() { + let empty = MediaSourceInfo { + container: Some(VideoContainer::Mp4), + ..Default::default() + }; + assert_eq!(DeviceProfile::default().hevc_copy_tag(&empty), "hvc1"); + } } diff --git a/crates/remux-server/src/playback/decision.rs b/crates/remux-server/src/playback/decision.rs index 272f0c7e..04a3b473 100644 --- a/crates/remux-server/src/playback/decision.rs +++ b/crates/remux-server/src/playback/decision.rs @@ -1,6 +1,8 @@ use crate::{ api, db, - device_profile::{DeviceProfileExt, SubtitleCodec, subtitle_codec_matches_profile}, + device_profile::{ + DeviceProfileExt, SubtitleCodec, VideoCodec, subtitle_codec_matches_profile, + }, }; use remux_sdks::remux::{EmbeddedSubtitleHandling, EncodingOptions}; use uuid::Uuid; @@ -259,7 +261,10 @@ fn build_video_transcode( // If policy constraints reduced both codecs to copy, this would be a no-op // remux. If the source container already matches the transcoding target // there is nothing to do — upgrade to direct play. - if video_codec == "copy" && audio_codec == "copy" { + let needs_hevc_retag = reasons.contains( + &api::TranscodeReason::VideoCodecTagNotSupported(String::new()), + ); + if video_codec == "copy" && audio_codec == "copy" && !needs_hevc_retag { let src = source .container .as_ref() @@ -294,10 +299,34 @@ 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. Only a + // stream copy of HEVC has a sample entry to write, so nothing else carries + // the parameter. + let is_hevc_copy = video_codec == "copy" + && source + .video_stream() + .and_then(|s| { + s.codec + .as_deref() + }) + .and_then(|c| { + c.parse::() + .ok() + }) + .map(|c| c.is_hevc()) + .unwrap_or(false); + let video_codec_tag = q + .device_profile + .as_ref() + .filter(|_| is_hevc_copy) + .map(|p| format!("&VideoCodecTag={}", p.hevc_copy_tag(source))) + .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, @@ -309,6 +338,7 @@ fn build_video_transcode( sub_idx, sub_method, start_time, + video_codec_tag, session .device .access_token @@ -316,7 +346,7 @@ fn build_video_transcode( ) } else { format!( - "/videos/{}/stream.{}?PlaySessionId={}&MediaSourceId={}&VideoCodec={}&AudioCodec={}{}{}{}{}{}{}&ApiKey={}", + "/videos/{}/stream.{}?PlaySessionId={}&MediaSourceId={}&VideoCodec={}&AudioCodec={}{}{}{}{}{}{}{}&ApiKey={}", cfg.item_id, container, cfg.play_session_id, @@ -329,6 +359,7 @@ fn build_video_transcode( sub_idx, sub_method, start_time, + video_codec_tag, session .device .access_token @@ -779,6 +810,84 @@ mod tests { ); } + #[test] + fn hevc_tag_mismatch_same_container_still_remuxes() { + // Rewriting hvc1/hev1 is the sole purpose of this remux. Returning + // direct play merely because the container already matches would leave + // the incompatible sample entry untouched. + let session = + make_session_with_policy(remux_sdks::remux::UserPolicy::default()); + let mut source = make_video_source(VideoContainer::Ts); + source.media_streams[0].codec = Some("hevc".to_string()); + let mut reasons = api::TranscodeReasons::default(); + reasons.insert(api::TranscodeReason::VideoCodecTagNotSupported( + "hev1".to_string(), + )); + + let TranscodeDecision::Transcode(outcome) = build_transcode_decision( + &source, + &reasons, + None, + &force_transcode_query(), + &session, + &base_cfg(EncodingOptions::default()), + ) else { + panic!("an HEVC sample-entry mismatch must remux, not direct play"); + }; + assert!( + outcome + .url + .contains("VideoCodec=copy") + ); + } + + /// An HEVC stream copy out of MKV, so the target container differs and the + /// decision reaches the URL builder. + fn hevc_copy_outcome( + codec: &str, + codec_tag: Option<&str>, + ) -> super::TranscodeOutcome { + let session = + make_session_with_policy(remux_sdks::remux::UserPolicy::default()); + let mut source = make_video_source(VideoContainer::Mkv); + source.media_streams[0].codec = Some(codec.to_string()); + source.media_streams[0].codec_tag = codec_tag.map(str::to_string); + let mut q = force_transcode_query(); + q.device_profile = Some(api::DeviceProfile::default()); + + let TranscodeDecision::Transcode(outcome) = build_transcode_decision( + &source, + &api::TranscodeReasons::default(), + None, + &q, + &session, + &base_cfg(EncodingOptions::default()), + ) else { + panic!("expected a remux"); + }; + outcome + } + + #[test] + fn hevc_copy_url_carries_the_resolved_sample_entry_tag() { + let url = hevc_copy_outcome("hevc", Some("hev1")).url; + assert!(url.contains("&VideoCodecTag=hev1"), "{url}"); + } + + #[test] + fn hevc_copy_url_keeps_hvc1_for_an_ordinary_source() { + let url = hevc_copy_outcome("hevc", Some("hvc1")).url; + assert!(url.contains("&VideoCodecTag=hvc1"), "{url}"); + } + + #[test] + fn non_hevc_copy_url_omits_the_sample_entry_tag() { + // Only HEVC has a sample entry to rewrite; on anything else the + // parameter is noise the session would carry around for nothing. + let url = hevc_copy_outcome("h264", Some("avc1")).url; + assert!(!url.contains("VideoCodecTag"), "{url}"); + } + #[test] fn both_copy_different_container_returns_transcode() { // video transcode disabled + audio copy + but container needs to change → remux URL diff --git a/crates/remux-server/src/playback/engine.rs b/crates/remux-server/src/playback/engine.rs index d90a0c01..1b660d3b 100644 --- a/crates/remux-server/src/playback/engine.rs +++ b/crates/remux-server/src/playback/engine.rs @@ -289,6 +289,9 @@ pub struct TranscodeParams { /// Codec of the source audio stream (e.g. "aac", "ac3"), used to apply /// codec-specific bitstream filters such as `aac_adtstoasc` when copying. pub source_audio_codec: Option, + /// HEVC sample-entry tag (`hvc1`/`hev1`) resolved from the client's + /// DeviceProfile; see `DeviceProfileExt::hevc_copy_tag`. + pub hevc_copy_tag: Option, pub accelerator: Box, /// HDR type of the source video, used to decide whether tone-mapping or /// SDR colour-space override is needed. @@ -340,6 +343,7 @@ impl Default for TranscodeParams { encoding_preset: None, source_video_codec: None, source_audio_codec: None, + hevc_copy_tag: None, accelerator: Box::new(NoAccel), source_video_range_type: None, enable_tonemapping: false, @@ -357,6 +361,20 @@ impl Default for TranscodeParams { } } +/// Sample-entry tag for a stream-copied HEVC track. The real decision belongs +/// to `DeviceProfileExt::hevc_copy_tag`, which weighs the client's declared +/// constraints against the source's own fourcc at PlaybackInfo time and hands +/// the answer down on the transcode URL. This only narrows that answer back to +/// the two legal values, falling back to `hvc1` for requests that carry no tag +/// at all — a direct hit on the stream endpoint, or a session predating the +/// parameter. +fn hevc_copy_tag(tag: Option<&str>) -> &str { + match tag { + Some(t) if t.eq_ignore_ascii_case("hev1") => "hev1", + _ => "hvc1", + } +} + /// Return the expected output video dimensions based on transcode params. fn output_dimensions(params: &TranscodeParams) -> (Option, Option) { (params.max_width, params.max_height) @@ -827,7 +845,15 @@ pub(crate) fn build_hls_args(params: &TranscodeParams) -> Vec { if ffmpeg_video_codec == "copy" { if is_hevc_copy { - args.extend(["-tag:v".into(), "hvc1".into()]); + args.extend([ + "-tag:v".into(), + hevc_copy_tag( + params + .hevc_copy_tag + .as_deref(), + ) + .into(), + ]); // Strip embedded Dolby Vision RPU NALs only when the source is actually DoVi; // dovi_rpu only supports hevc/av1 and will crash ffmpeg on any other codec. let is_dovi = matches!( @@ -1296,6 +1322,8 @@ pub struct ProgressiveTranscodeParams { pub encoding_preset: Option, pub source_video_codec: Option, pub source_audio_codec: Option, + /// See `TranscodeParams::hevc_copy_tag`. + pub hevc_copy_tag: Option, pub accelerator: Box, pub source_video_range_type: Option, pub enable_tonemapping: bool, @@ -1596,7 +1624,7 @@ pub(crate) fn build_progressive_args( // Video args.extend(["-c:v".into(), ffmpeg_video_codec.clone()]); if ffmpeg_video_codec == "copy" { - // Apply hvc1 codec tag for HEVC Apple compatibility + // Apply the HEVC sample-entry tag the client asked for. if params .source_video_codec .as_deref() @@ -1608,7 +1636,15 @@ pub(crate) fn build_progressive_args( .map(VideoCodec::is_hevc) .unwrap_or(false) { - args.extend(["-tag:v".into(), "hvc1".into()]); + args.extend([ + "-tag:v".into(), + hevc_copy_tag( + params + .hevc_copy_tag + .as_deref(), + ) + .into(), + ]); } } else if is_hw { if let Some(bitrate) = params.video_bitrate { @@ -1897,7 +1933,11 @@ pub fn generate_variant_playlist( /// Generate the HEVC codec string for HLS CODECS attribute. /// Matches Jellyfin's `HlsCodecStringHelpers.GetH265String()`. -fn hevc_hls_codec_string(profile: Option<&str>, level: Option) -> String { +fn hevc_hls_codec_string( + tag: &str, + profile: Option<&str>, + level: Option, +) -> String { let profile_part = match profile { Some(p) if p.eq_ignore_ascii_case("main 10") @@ -1908,7 +1948,7 @@ fn hevc_hls_codec_string(profile: Option<&str>, level: Option) -> String { _ => "1.4", }; let level_val = level.unwrap_or(150.0) as i32; - format!("hvc1.{}.L{}.B0", profile_part, level_val) + format!("{}.{}.L{}.B0", tag, profile_part, level_val) } /// Generate a master HLS playlist that references the variant playlist. @@ -1933,7 +1973,15 @@ pub fn generate_master_playlist(session: &TranscodeSession) -> String { .map(VideoCodec::is_hevc) .unwrap_or(false) { + // Must name the fourcc build_hls_args actually wrote, or + // clients that validate CODECS against the segments (Safari + // via MSE) reject the stream. hevc_hls_codec_string( + hevc_copy_tag( + session + .hevc_copy_tag + .as_deref(), + ), session .source_video_profile .as_deref(), @@ -1944,7 +1992,10 @@ pub fn generate_master_playlist(session: &TranscodeSession) -> String { } } "h264" | "libx264" => "avc1.640028".to_string(), + // Encoding produces MPEG-TS segments, which carry no sample entry at + // all; hvc1 is the conventional RFC 6381 identifier there. "hevc" | "libx265" => hevc_hls_codec_string( + "hvc1", session .source_video_profile .as_deref(), @@ -2216,6 +2267,7 @@ mod tests { encoding_preset: None, source_video_codec: None, source_audio_codec: None, + hevc_copy_tag: None, accelerator: Box::new(NoAccel), source_video_range_type: None, enable_tonemapping: false, @@ -2446,6 +2498,116 @@ mod tests { assert!(!args_contains(&args, "-ss")); } + fn hevc_session(video_codec: &str, tag: Option<&str>) -> TranscodeSession { + TranscodeSession { + id: "play-session".into(), + item_id: Uuid::nil(), + media_source_id: Uuid::nil(), + output_dir: PathBuf::from("/tmp/test_master"), + input_url: "http://example.invalid/video".into(), + state: TranscodeState::Running, + state_tx: Arc::new(tokio::sync::watch::channel(TranscodeState::Running).0), + created_at: std::time::Instant::now(), + video_codec: video_codec.into(), + audio_codec: "aac".into(), + audio_stream_index: None, + subtitle_stream_index: None, + burn_subtitle: false, + segment_length: 6, + transcode_reasons: TranscodeReasons::default(), + kill_tx: None, + wait_done: Arc::new(tokio::sync::Notify::new()), + last_segment_index: Arc::new(AtomicU32::new(0)), + start_time_secs: 0, + playback_offset_secs: Arc::new(AtomicU32::new(0)), + runtime_ticks: 120i64 + .to_ticks(TickUnit::Seconds) + .unwrap(), + is_live: false, + source_video_codec: Some("hevc".into()), + source_audio_codec: Some("aac".into()), + hevc_copy_tag: tag.map(str::to_string), + source_video_profile: Some("Main 10".into()), + source_video_level: Some(120.0), + source_video_range_type: None, + source_video_width: None, + source_video_height: None, + source_frame_rate: None, + video_bitrate: None, + hardware_acceleration_type: None, + } + } + + #[test] + fn hls_hevc_copy_uses_the_tag_the_client_asked_for() { + let args = build_hls_args(&TranscodeParams { + video_codec: "copy".into(), + source_video_codec: Some("hevc".into()), + hevc_copy_tag: Some("hev1".into()), + ..default_hls(PathBuf::from("/tmp/test_hev1")) + }); + assert_eq!(arg_after(&args, "-tag:v"), Some("hev1")); + } + + #[test] + fn hls_hevc_copy_defaults_to_hvc1_when_client_said_nothing() { + // Unknown -> today's behaviour, so nothing that plays now regresses. + for tag in [None, Some("hvc1")] { + let args = build_hls_args(&TranscodeParams { + video_codec: "copy".into(), + source_video_codec: Some("hevc".into()), + hevc_copy_tag: tag.map(str::to_string), + ..default_hls(PathBuf::from("/tmp/test_hvc1_default")) + }); + assert_eq!(arg_after(&args, "-tag:v"), Some("hvc1"), "tag={tag:?}"); + } + } + + #[test] + fn progressive_hevc_copy_uses_the_tag_the_client_asked_for() { + let args = build_progressive_args(&ProgressiveTranscodeParams { + source_video_codec: Some("hevc".into()), + hevc_copy_tag: Some("hev1".into()), + ..default_progressive() + }); + assert_eq!(arg_after(&args, "-tag:v"), Some("hev1")); + } + + #[test] + fn master_playlist_codecs_matches_the_sample_entry_written_on_copy() { + // CODECS has to name the fourcc build_hls_args wrote, or Safari + // validates the manifest against the segments and refuses. + let playlist = generate_master_playlist(&hevc_session("copy", Some("hev1"))); + assert!( + playlist.contains("hev1.2.4.L120.B0"), + "expected hev1 CODECS: {playlist}" + ); + assert!(!playlist.contains("hvc1."), "playlist: {playlist}"); + } + + #[test] + fn master_playlist_codecs_stays_hvc1_when_client_said_nothing() { + for tag in [None, Some("hvc1")] { + let playlist = generate_master_playlist(&hevc_session("copy", tag)); + assert!( + playlist.contains("hvc1.2.4.L120.B0"), + "expected hvc1 CODECS for {tag:?}: {playlist}" + ); + } + } + + #[test] + fn master_playlist_codecs_stays_hvc1_when_encoding_hevc() { + // The encode path emits MPEG-TS, which has no sample entry at all, so + // the source-side tag must not leak into CODECS. + let playlist = generate_master_playlist(&hevc_session("hevc", Some("hev1"))); + assert!( + playlist.contains("hvc1.2.4.L120.B0"), + "expected hvc1 CODECS: {playlist}" + ); + assert!(!playlist.contains("hev1."), "playlist: {playlist}"); + } + #[test] fn resumed_vod_playlist_advertises_start_offset_and_full_seek_map() { let session = TranscodeSession { @@ -2475,6 +2637,7 @@ mod tests { is_live: false, source_video_codec: Some("h264".into()), source_audio_codec: Some("aac".into()), + hevc_copy_tag: None, source_video_profile: None, source_video_level: None, source_video_range_type: None, diff --git a/crates/remux-server/src/playback/session.rs b/crates/remux-server/src/playback/session.rs index 351ca5c2..07073f84 100644 --- a/crates/remux-server/src/playback/session.rs +++ b/crates/remux-server/src/playback/session.rs @@ -50,6 +50,9 @@ pub struct TranscodeSession { pub source_video_codec: Option, /// Codec name of the source audio stream (e.g. "eac3", "aac"). pub source_audio_codec: Option, + /// HEVC sample-entry tag (`hvc1`/`hev1`) resolved from the client's + /// DeviceProfile, used when stream-copying HEVC into fMP4. + pub hevc_copy_tag: Option, /// Profile of the source video stream (e.g. "Main 10"), used to generate /// the correct HLS CODECS attribute string for HEVC. pub source_video_profile: Option, @@ -87,6 +90,7 @@ impl TranscodeSession { is_live: bool, source_video_codec: Option, source_audio_codec: Option, + hevc_copy_tag: Option, source_video_profile: Option, source_video_level: Option, source_video_range_type: Option, @@ -123,6 +127,7 @@ impl TranscodeSession { is_live, source_video_codec, source_audio_codec, + hevc_copy_tag, source_video_profile, source_video_level, source_video_range_type,