diff --git a/app/Models/MediaProbe.php b/app/Models/MediaProbe.php index c02d486..ed41598 100644 --- a/app/Models/MediaProbe.php +++ b/app/Models/MediaProbe.php @@ -205,6 +205,21 @@ public function playsDirectly(): ?bool return false; } + // 10-bit H.264 is **High 10**, which Apple's hardware decoder refuses + // outright -- the audio plays and the picture stays black. + // + // The codec name alone does not catch this: the stream is genuinely + // `h264` in a genuinely `.mp4`, so every other check passes and the + // file direct-plays into a black rectangle. The depth was already + // measured and simply never consulted. + // + // This is the same failure #305 fixed on the *transcode* side, by the + // other route: there, libx264 inherited 10-bit from the source and + // emitted High 10; here, a 10-bit file is handed over untouched. + if (($this->bit_depth ?? 8) > 8) { + return false; + } + $audio = collect($this->audio_streams ?? []) ->pluck('codec') ->filter() diff --git a/changelog/303-reject-ten-bit.md b/changelog/303-reject-ten-bit.md new file mode 100644 index 0000000..31602bc --- /dev/null +++ b/changelog/303-reject-ten-bit.md @@ -0,0 +1,32 @@ +# 10-bit H.264 is not direct-playable either + +#305 fixed the transcode side of the 10-bit problem: `libx264` inherited the +source's pixel format and emitted **High 10**, which Apple's hardware decoder +refuses. a5's review then noted the Midnight Gospel episodes are 10-bit HEVC +Main 10 — which prompted checking the other route. + +A **10-bit H.264 `.mp4`** reaches the same failure without any transcode at +all. The stream is genuinely `h264`, in a genuinely `.mp4`, so every check +passed and the file was handed over untouched — as High 10, playing black. + +The probe had recorded `bit_depth` all along. Nothing consulted it. + +## Verified against a real encode + +``` +codec_name=h264 profile=High 10 pix_fmt=yuv420p10le bits_per_raw_sample=10 +``` + +Before: `bit_depth=10`, `isPlayableVideo=true`. After: correctly refused, so it +routes to the transcode that now produces 8-bit High. + +## Unknown depth means 8-bit + +A probe that recorded no depth is treated as 8-bit, because that is +overwhelmingly what it is. Assuming otherwise would transcode every file an +older probe measured, for a case that is rare. + +## Testing + +3 new tests, **385 pass**. Confirmed to fail without the check, and the real +10-bit file is correctly refused. diff --git a/tests/Feature/PlaysDirectlyTest.php b/tests/Feature/PlaysDirectlyTest.php index bf72186..5215ee8 100644 --- a/tests/Feature/PlaysDirectlyTest.php +++ b/tests/Feature/PlaysDirectlyTest.php @@ -41,7 +41,7 @@ protected function setUp(): void /** * @param array> $audio */ - private function film(string $extension, ?string $video, array $audio = []): MediaItem + private function film(string $extension, ?string $video, array $audio = [], int $bitDepth = 8): MediaItem { $item = MediaItem::unresolved()->create([ 'user_id' => $this->user->id, @@ -57,6 +57,7 @@ private function film(string $extension, ?string $video, array $audio = []): Med 'probed_at' => now(), 'video_codec' => $video, 'audio_streams' => $audio, + 'bit_depth' => $bitDepth, 'width' => 1920, 'height' => 1080, ]); @@ -132,6 +133,52 @@ public function test_vp9_in_mp4_does_not_play_directly(): void $this->assertFalse($film->isPlayableVideo()); } + /** + * 10-bit H.264 is **High 10**, which Apple's hardware decoder refuses. + * + * The codec name does not catch this: the stream really is `h264` in a + * real `.mp4`, so every other check passes and the file direct-plays into + * a black rectangle. Verified against a real encode — ffprobe reports + * `codec_name=h264, profile=High 10, bits_per_raw_sample=10`. + * + * This is the same failure #305 fixed on the transcode side, reached by + * the other route: there libx264 *produced* High 10 from a 10-bit source; + * here a 10-bit file is handed over untouched. + */ + public function test_ten_bit_h264_does_not_play_directly(): void + { + $film = $this->film('mp4', 'h264', [['codec' => 'aac', 'channels' => 2]], bitDepth: 10); + + $this->assertFalse( + $film->isPlayableVideo(), + '10-bit H.264 is High 10 profile, which Apple hardware will not decode.', + ); + } + + /** + * And 8-bit is unaffected, so this does not transcode the whole library. + */ + public function test_eight_bit_h264_still_plays_directly(): void + { + $film = $this->film('mp4', 'h264', [['codec' => 'aac', 'channels' => 2]], bitDepth: 8); + + $this->assertTrue($film->isPlayableVideo()); + } + + /** + * A probe that recorded no depth is treated as 8-bit, because that is + * overwhelmingly what it is — and assuming otherwise would transcode + * every file an older probe measured. + */ + public function test_an_unknown_bit_depth_is_treated_as_eight(): void + { + $film = $this->film('mp4', 'h264', [['codec' => 'aac', 'channels' => 2]]); + + $film->probe->forceFill(['bit_depth' => null])->saveQuietly(); + + $this->assertTrue($film->fresh()->load('probe')->isPlayableVideo()); + } + /* ---------------------------------------------------------- audio --- */ /**