* fix(playback): claim Safari DV/HDR10 decode evidence and preserve dvvC Follow-up to #613 for #609: Safari 26 reports dynamic-range: standard even on an XDR display, and answers canPlayType "probably" only for dvh1/hvc1 sample entries, never dvhe/hev1. The web probe gated every structured HDR claim on that media query and probed only dvhe, so the planner still saw empty hdr_details and terminated with hdr_transcode_unsupported. - Run the Dolby Vision and HDR10 shape probes unconditionally; the dynamic-range query survives only as the best-effort hdr output boolean. - Probe dvh1+dvhe per DV profile and hvc1+hev1 for the exact HDR10 Media Capabilities shape; either definitive answer earns the claim. - Tag preserved-DV remuxes dvh1 with -strict unofficial: FFmpeg omits the dvvC configuration record under either tag without it, so the previous dvhe output carried no DV signaling at all. - Scale the screen-derived max_resolution by devicePixelRatio so 2x panels stop advertising 1080p. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(playback): align DV/HDR10 claims with the sample entries the remux emits Review follow-ups on #617 (Codex P1s): - Probe only dvh1: a browser answering "probably" solely for dvhe has given no evidence for the dvh1-tagged file the preserve remux delivers, so that answer no longer earns a Dolby Vision claim; such browsers keep the validated HDR10 fallback. - Label the explicit v3 HDR10 strip output hvc1 so the file matches the hvc1 evidence the web probe accepts; legacy/auto strips keep hev1. - Cover preserve tagging for profiles 5 and 8, and exercise the mode-to-argument mapping through StartRemuxWithDVMode instead of passing the tag flag directly. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(playback): name the subtitle when burn-in alone forces a refused transcode Selecting a PGS/VOBSUB track on a client without a bitmap renderer forces a burn-in transcode. When that transcode cannot run, the terminal blamed the HDR pipeline (hdr_transcode_unsupported) or the 4K policy (no_alternate_version) — problems that were not blocking playback, since deselecting the subtitle restores the previous route. - When the burn requirement is the sole trigger of the adaptation, the HDR and 4K-policy terminals emit subtitle_conversion_unsupported with a message naming the burn requirement and the actual blocker. A range the client genuinely cannot take keeps the HDR terminal. - describePlanTerminal passes the server's subtitle message through instead of flattening every subtitle_* reason to one generic sentence. - The web player toasts the refusal when it rolls the subtitle selection back; previously the only surface was the quality menu, which a user who just picked a subtitle has no reason to open. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(playback): require decode evidence for the exact hvc1 entry and gate the disabled-transcode subtitle terminal Review follow-ups on #617: - The HDR10 probe accepts only hvc1.2.4.L153.B0: the explicit v3 strip remux labels its output hvc1, so an hev1-only decodingInfo answer is evidence for bytes Silo never sends and earns no claim (Codex P1). - The disabled-transcode branch blames the subtitle only when the burn requirement was the sole adaptation trigger; other causes keep transcoding_disabled. In practice a bitmap selection without transcode terminals in the subtitle policy before this branch, but the guard keeps the sole-cause invariant if that ordering ever changes (CodeRabbit / Codex P2). - Protocol doc scopes the hvc1 labeling to the explicit v3 strip path; legacy/auto strips keep hev1 (CodeRabbit). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
289 lines
12 KiB
Go
289 lines
12 KiB
Go
package playback
|
|
|
|
import (
|
|
"context"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func argsContainPair(args []string, a, b string) bool {
|
|
for i := 0; i < len(args)-1; i++ {
|
|
if args[i] == a && args[i+1] == b {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// Profile 7 remuxes drop the enhancement-layer track (-map 0:v:0 keeps only
|
|
// the base layer), which leaves dangling dual-layer RPU metadata on the BL.
|
|
// Stripping the RPUs yields a clean HDR10 stream — both a correctness fix and
|
|
// the Apple-parity fallback presentation for devices without a P7 decoder.
|
|
func TestBuildRemuxArgsStripsDolbyVisionRPUForProfile7(t *testing.T) {
|
|
args := buildRemuxArgs("/x.mkv", "mp4", 0, false, -1, 7, false, false)
|
|
if !argsContainPair(args, "-bsf:v", "dovi_rpu=strip=1") {
|
|
t.Fatalf("profile 7 remux must strip DV RPUs from the base layer, args=%v", strings.Join(args, " "))
|
|
}
|
|
}
|
|
|
|
func TestBuildRemuxArgsExcludesAttachedPictures(t *testing.T) {
|
|
args := buildRemuxArgs("/book.m4b", "mp4", 0, true, -1, 0, false, true)
|
|
joined := strings.Join(args, " ")
|
|
if !strings.Contains(joined, "-map 0:V:0?") {
|
|
t.Fatalf("remux args must exclude attached-picture video streams: %s", joined)
|
|
}
|
|
}
|
|
|
|
func TestBuildRemuxArgsHonorsPlannedAACOutput(t *testing.T) {
|
|
args := buildRemuxArgsWithAudioV3("/book.m4b", "mp4", 0, true, -1, 0, false, true, 1, 96)
|
|
if !argsContainPair(args, "-ac", "1") || !argsContainPair(args, "-b:a", "96k") {
|
|
t.Fatalf("planned mono bitrate missing from remux args: %s", strings.Join(args, " "))
|
|
}
|
|
}
|
|
|
|
func TestBuildRemuxArgsRequiresVideoForVideoPlans(t *testing.T) {
|
|
args := buildRemuxArgs("/movie.mkv", "mp4", 0, false, -1, 0, false, false)
|
|
if !argsContainPair(args, "-map", "0:V:0") || argsContainPair(args, "-map", "0:V:0?") {
|
|
t.Fatalf("video remux must use a mandatory video map, args=%s", strings.Join(args, " "))
|
|
}
|
|
}
|
|
|
|
// An ffmpeg without the dovi_rpu bitstream filter (pre-7.1) would abort on
|
|
// the unknown filter before producing any output. The profile must be
|
|
// neutralized so the remux still starts, keeping the pre-strip behavior.
|
|
func TestRemuxDVProfileFallsBackWithoutFilterSupport(t *testing.T) {
|
|
if got := remuxDVProfile(7, false); got != 0 {
|
|
t.Errorf("remuxDVProfile(7, false) = %d, want 0 (no strip without filter support)", got)
|
|
}
|
|
if got := remuxDVProfile(7, true); got != 7 {
|
|
t.Errorf("remuxDVProfile(7, true) = %d, want 7", got)
|
|
}
|
|
for _, profile := range []int{0, 5, 8} {
|
|
for _, canStrip := range []bool{false, true} {
|
|
if got := remuxDVProfile(profile, canStrip); got != profile {
|
|
t.Errorf("remuxDVProfile(%d, %t) = %d, want %d (pass through)", profile, canStrip, got, profile)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Profile 8 base layers are self-contained: the RPU stays valid without an
|
|
// enhancement layer and DV-capable clients can render it. Never strip.
|
|
func TestBuildRemuxArgsKeepsRPUForProfile8AndPlainFiles(t *testing.T) {
|
|
for _, profile := range []int{0, 5, 8} {
|
|
args := buildRemuxArgs("/x.mkv", "mp4", 0, false, -1, profile, false, false)
|
|
if argsContainPair(args, "-bsf:v", "dovi_rpu=strip=1") {
|
|
t.Fatalf("profile %d remux must not strip DV RPUs, args=%v", profile, strings.Join(args, " "))
|
|
}
|
|
}
|
|
}
|
|
|
|
// The DV sample entry is an explicit opt-in for the v3 preserve recipe:
|
|
// Media3 keys decoder selection from it, but legacy web/jellycompat consumers
|
|
// rely on the pre-v3 hev1 labeling their demuxers accept.
|
|
func TestBuildRemuxArgsTagsPreservedDolbyVisionOnlyWhenRequested(t *testing.T) {
|
|
for _, profile := range []int{5, 8} {
|
|
args := buildRemuxArgs("/x.mkv", "mp4", 0, false, -1, profile, true, false)
|
|
if !argsContainPair(args, "-tag:v", "dvh1") {
|
|
t.Fatalf("preserved profile %d must retain a DV sample entry, args=%v", profile, strings.Join(args, " "))
|
|
}
|
|
// Without -strict unofficial FFmpeg drops the dvvC configuration record
|
|
// and the output is an untagged HEVC stream no DV decoder will select.
|
|
if !argsContainPair(args, "-strict", "unofficial") {
|
|
t.Fatalf("preserved profile %d must allow the dvvC box, args=%v", profile, strings.Join(args, " "))
|
|
}
|
|
legacy := buildRemuxArgs("/x.mkv", "mp4", 0, false, -1, profile, false, false)
|
|
if argsContainPair(legacy, "-tag:v", "dvh1") || argsContainPair(legacy, "-strict", "unofficial") {
|
|
t.Fatalf("legacy profile %d remux must keep hev1 labeling, args=%v", profile, strings.Join(legacy, " "))
|
|
}
|
|
}
|
|
}
|
|
|
|
// The explicit v3 strip recipe labels its HDR10 output hvc1 — the sample entry
|
|
// the web probe collected evidence for — while the legacy/auto strip keeps
|
|
// ffmpeg's default hev1 for pre-v3 consumers. Neither carries a DOVI record,
|
|
// so neither needs the -strict relaxation.
|
|
func TestBuildRemuxArgsTagsStrippedHDR10OnlyWhenRequested(t *testing.T) {
|
|
tagged := buildRemuxArgs("/x.mkv", "mp4", 0, false, -1, 7, true, false)
|
|
if !argsContainPair(tagged, "-bsf:v", "dovi_rpu=strip=1") || !argsContainPair(tagged, "-tag:v", "hvc1") {
|
|
t.Fatalf("explicit strip must emit an hvc1-labeled HDR10 stream, args=%v", strings.Join(tagged, " "))
|
|
}
|
|
if argsContainPair(tagged, "-tag:v", "dvh1") || argsContainPair(tagged, "-strict", "unofficial") {
|
|
t.Fatalf("stripped output must not carry DV signaling, args=%v", strings.Join(tagged, " "))
|
|
}
|
|
legacy := buildRemuxArgs("/x.mkv", "mp4", 0, false, -1, 7, false, false)
|
|
if argsContainPair(legacy, "-tag:v", "hvc1") || argsContainPair(legacy, "-tag:v", "dvh1") {
|
|
t.Fatalf("legacy strip consumers must keep hev1 labeling, args=%v", strings.Join(legacy, " "))
|
|
}
|
|
}
|
|
|
|
// Dual-layer Profile 7 cannot be preserved by a base-layer-only remux; the
|
|
// explicit preserve recipe must fail fast instead of emitting a stream with
|
|
// dangling RPUs and no enhancement layer.
|
|
func TestStartRemuxRejectsPreservedProfile7(t *testing.T) {
|
|
if _, err := StartRemuxWithDVMode(t.Context(), "/nonexistent.mkv", "mp4", 0, false, -1, 7, RemuxDVPreserveV3, ""); err == nil {
|
|
t.Fatal("preserve mode accepted a profile 7 source")
|
|
}
|
|
}
|
|
|
|
// An unknown mode must fail for every profile, not only Profile 7 sources.
|
|
func TestStartRemuxRejectsUnknownModeForAllProfiles(t *testing.T) {
|
|
for _, profile := range []int{0, 5, 7, 8} {
|
|
if _, err := StartRemuxWithDVMode(t.Context(), "/nonexistent.mkv", "mp4", 0, false, -1, profile, RemuxDVMode("bogus"), ""); err == nil {
|
|
t.Fatalf("unknown remux DV mode accepted for profile %d", profile)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBuildRemuxArgsDelaysMoovForCopiedAtmosConfiguration(t *testing.T) {
|
|
args := buildRemuxArgs("/x.mkv", "mp4", 0, false, -1, 8, false, false)
|
|
if !argsContainPair(args, "-movflags", "frag_keyframe+delay_moov+default_base_moof") {
|
|
t.Fatalf("remux must delay moov until copied audio is parsed, args=%v", strings.Join(args, " "))
|
|
}
|
|
}
|
|
|
|
// writeProbeAwareFFmpeg stands in for an ffmpeg that carries the dovi_rpu
|
|
// filter but cannot parse this source's RPU: it advertises the filter, fails
|
|
// the probe the way the real one does (rejecting packets while exiting 0), and
|
|
// records the arguments of every non-probe invocation.
|
|
func writeProbeAwareFFmpeg(t *testing.T) (bin, argLog string) {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
bin = filepath.Join(dir, "ffmpeg")
|
|
argLog = filepath.Join(dir, "args")
|
|
script := "#!/bin/sh\n" +
|
|
"case \"$*\" in\n" +
|
|
" *-bsfs*) echo dovi_rpu; exit 0;;\n" +
|
|
" *'-f null'*) echo '[dovi_rpu @ 0x55] Failed to read unit 1 (type 39).' >&2; exit 0;;\n" +
|
|
"esac\n" +
|
|
"echo \"$*\" >> " + argLog + "\n" +
|
|
"exit 0\n"
|
|
if err := os.WriteFile(bin, []byte(script), 0o755); err != nil {
|
|
t.Fatalf("write fake ffmpeg: %v", err)
|
|
}
|
|
return bin, argLog
|
|
}
|
|
|
|
// writeRecordingFFmpeg stands in for an ffmpeg whose dovi_rpu probe succeeds,
|
|
// recording the arguments of every non-probe invocation so tests can assert
|
|
// what the remux mode actually mapped onto the command line.
|
|
func writeRecordingFFmpeg(t *testing.T) (bin, argLog string) {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
bin = filepath.Join(dir, "ffmpeg")
|
|
argLog = filepath.Join(dir, "args")
|
|
script := "#!/bin/sh\n" +
|
|
"case \"$*\" in\n" +
|
|
" *-bsfs*) echo dovi_rpu; exit 0;;\n" +
|
|
" *'-f null'*) exit 0;;\n" +
|
|
"esac\n" +
|
|
"echo \"$*\" >> " + argLog + "\n" +
|
|
"exit 0\n"
|
|
if err := os.WriteFile(bin, []byte(script), 0o755); err != nil {
|
|
t.Fatalf("write fake ffmpeg: %v", err)
|
|
}
|
|
return bin, argLog
|
|
}
|
|
|
|
func recordedRemuxArgs(t *testing.T, session *RemuxSession, argLog string) string {
|
|
t.Helper()
|
|
_, _ = io.ReadAll(session)
|
|
_ = session.Close()
|
|
recorded, err := os.ReadFile(argLog)
|
|
if err != nil {
|
|
t.Fatalf("the remux never ran: %v", err)
|
|
}
|
|
return string(recorded)
|
|
}
|
|
|
|
// The preserve mode, not the caller, decides the sample-entry tagging:
|
|
// buildRemuxArgs cannot be handed the flag directly by production code, so the
|
|
// mapping from RemuxDVPreserveV3 must be exercised through the mode switch.
|
|
func TestPreserveModeTagsTheSampleEntry(t *testing.T) {
|
|
for _, profile := range []int{5, 8} {
|
|
bin, argLog := writeRecordingFFmpeg(t)
|
|
session, err := StartRemuxWithDVMode(t.Context(), remuxSourceFile(t), "mp4", 0, false, -1, profile, RemuxDVPreserveV3, bin)
|
|
if err != nil {
|
|
t.Fatalf("preserve remux refused profile %d: %v", profile, err)
|
|
}
|
|
recorded := recordedRemuxArgs(t, session, argLog)
|
|
if !strings.Contains(recorded, "-tag:v dvh1") || !strings.Contains(recorded, "-strict unofficial") {
|
|
t.Fatalf("preserve mode must map to dvh1 + -strict unofficial for profile %d: %s", profile, recorded)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestExplicitStripModeTagsHVC1(t *testing.T) {
|
|
for _, profile := range []int{7, 8} {
|
|
bin, argLog := writeRecordingFFmpeg(t)
|
|
session, err := StartRemuxWithDVMode(t.Context(), remuxSourceFile(t), "mp4", 0, false, -1, profile, RemuxDVStripToHDR10V3, bin)
|
|
if err != nil {
|
|
t.Fatalf("strip remux refused profile %d: %v", profile, err)
|
|
}
|
|
recorded := recordedRemuxArgs(t, session, argLog)
|
|
if !strings.Contains(recorded, "dovi_rpu=strip=1") || !strings.Contains(recorded, "-tag:v hvc1") {
|
|
t.Fatalf("explicit strip must map to dovi_rpu + hvc1 for profile %d: %s", profile, recorded)
|
|
}
|
|
if strings.Contains(recorded, "dvh1") || strings.Contains(recorded, "-strict unofficial") {
|
|
t.Fatalf("stripped output must not carry DV signaling for profile %d: %s", profile, recorded)
|
|
}
|
|
}
|
|
}
|
|
|
|
func remuxSourceFile(t *testing.T) string {
|
|
t.Helper()
|
|
path := filepath.Join(t.TempDir(), "movie.mkv")
|
|
if err := os.WriteFile(path, []byte("not really a movie"), 0o600); err != nil {
|
|
t.Fatalf("write remux fixture: %v", err)
|
|
}
|
|
return path
|
|
}
|
|
|
|
// The legacy/auto mode derives the strip from the profile alone, with no plan
|
|
// to consult — the web player, jellycompat and pre-v3 stream tokens all arrive
|
|
// here. An unparseable RPU has to neutralize the profile the same way a missing
|
|
// dovi_rpu filter does, or the filter rejects every packet and the response
|
|
// never produces a byte.
|
|
func TestLegacyRemuxDropsTheStripForAnUnstrippableSource(t *testing.T) {
|
|
bin, argLog := writeProbeAwareFFmpeg(t)
|
|
path := remuxSourceFile(t)
|
|
|
|
session, err := StartRemuxWithDVMode(context.Background(), path, "mp4", 0, false, -1, 7, RemuxDVLegacyAutoV3, bin)
|
|
if err != nil {
|
|
t.Fatalf("legacy remux refused to start: %v", err)
|
|
}
|
|
// Drain to EOF so the stand-in has finished recording before it is killed.
|
|
_, _ = io.ReadAll(session)
|
|
session.Close()
|
|
|
|
recorded, err := os.ReadFile(argLog)
|
|
if err != nil {
|
|
t.Fatalf("the remux never ran: %v", err)
|
|
}
|
|
if strings.Contains(string(recorded), DV7ToHDR10BitstreamFilter) {
|
|
t.Fatalf("the hanging filter survived into the remux: %s", recorded)
|
|
}
|
|
}
|
|
|
|
// The explicit v3 recipe has already promised the client HDR10. Reaching it
|
|
// with an unstrippable source means a session or stream token minted before
|
|
// the verdict was known, and neither honouring nor silently dropping the strip
|
|
// is right: fail so the request gets a definite error instead of a stalled
|
|
// stream, and so the next start re-plans onto a route that works.
|
|
func TestExplicitStripRecipeRefusesAnUnstrippableSource(t *testing.T) {
|
|
bin, _ := writeProbeAwareFFmpeg(t)
|
|
path := remuxSourceFile(t)
|
|
|
|
session, err := StartRemuxWithDVMode(context.Background(), path, "mp4", 0, false, -1, 7, RemuxDVStripToHDR10V3, bin)
|
|
if err == nil {
|
|
session.Close()
|
|
t.Fatal("the explicit HDR10 strip accepted a source it cannot strip")
|
|
}
|
|
if !strings.Contains(err.Error(), "cannot be stripped") {
|
|
t.Fatalf("error = %v, want it to name the unstrippable source", err)
|
|
}
|
|
}
|