mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-11-14 13:05:09 +00:00
feat(tracks): download and mux stereoscopic 3D (MV-HEVC) video to MP4
Mark 3D from REQ-VIDEO-LAYOUT, DV profile 20 codecs or an lhvC box; mux with MP4Box, decrypt with mp4decrypt, skip FFmpeg repack, sample cut and hybrid; add {3d} and env MP4Box.
This commit is contained in:
@@ -476,3 +476,16 @@ def test_import_keeps_every_period_when_no_exported_period_is_left(
|
||||
|
||||
assert video.data["dash"]["filtered_period_ids"] == []
|
||||
assert "keeping every period" in caplog.text
|
||||
|
||||
|
||||
def test_a_3d_video_stays_3d_through_export_and_import(tmp_path: Path) -> None:
|
||||
export = tmp_path / "export.json"
|
||||
title = make_title()
|
||||
title.tracks.videos[0].stereo = True
|
||||
runner = make_dl()
|
||||
for track in [*title.tracks.videos, *title.tracks.audio]:
|
||||
runner.write_export(export, title, track)
|
||||
assert [row.get("stereo") for row in entry(export)["tracks"]][0] is True
|
||||
svc = ImportService(import_ctx(), "EXAMPLE", "movie-1", str(export))
|
||||
movie = next(iter(svc.get_titles()))
|
||||
assert svc.get_tracks(movie).videos[0].stereo
|
||||
|
||||
@@ -856,7 +856,7 @@ def video(
|
||||
codec: Optional[Video.Codec] = Video.Codec.HEVC,
|
||||
native_hybrid: bool = False,
|
||||
) -> Any:
|
||||
return SimpleNamespace(id=id_, height=height, range=range_, codec=codec, native_hybrid=native_hybrid)
|
||||
return SimpleNamespace(id=id_, height=height, range=range_, codec=codec, native_hybrid=native_hybrid, stereo=False)
|
||||
|
||||
|
||||
def test_hybrid_fails_with_neither_a_base_nor_dv() -> None:
|
||||
|
||||
@@ -297,3 +297,8 @@ def test_a_file_row_can_name_its_codec_and_range_by_name(tmp_path: Path) -> None
|
||||
service = file_export(tmp_path, [row])
|
||||
video = service.get_tracks(next(iter(service.get_titles()))).videos[0]
|
||||
assert (video.codec, video.range) == (Video.Codec.HEVC, Video.Range.HDR10P)
|
||||
|
||||
|
||||
def test_a_file_row_marked_stereo_imports_as_a_3d_video(tmp_path: Path) -> None:
|
||||
service = file_export(tmp_path, [{**FILE_ROWS[0], "stereo": True}])
|
||||
assert service.get_tracks(next(iter(service.get_titles()))).videos[0].stereo
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import itertools
|
||||
import logging
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from langcodes import Language
|
||||
|
||||
from unshackle.commands.dl import dl, hybrid_plan, pick_video
|
||||
from unshackle.core import binaries
|
||||
from unshackle.core.config import config
|
||||
from unshackle.core.drm.verify import decrypter_for
|
||||
from unshackle.core.manifests import DASH, HLS
|
||||
from unshackle.core.titles.movie import Movie
|
||||
from unshackle.core.tracks import Audio, Chapter, Chapters, Track, Tracks, Video
|
||||
from unshackle.core.tracks import tracks as tracks_module
|
||||
|
||||
VIDEO_IDS = itertools.count()
|
||||
|
||||
MASTER = """#EXTM3U
|
||||
#EXT-X-VERSION:12
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=6786883,CODECS="dvh1.20.02",RESOLUTION=960x540,VIDEO-RANGE=PQ,REQ-VIDEO-LAYOUT="CH-MONO,CH-STEREO"
|
||||
stereo.m3u8
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=5000000,CODECS="hvc1.2.4.L150.90",RESOLUTION=1920x1080,REQ-VIDEO-LAYOUT="CH-STEREO"
|
||||
mvhevc.m3u8
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=4000000,CODECS="dvh1.08.06",RESOLUTION=1920x1080,VIDEO-RANGE=PQ
|
||||
flat.m3u8
|
||||
"""
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("codecs", "layout", "expected"),
|
||||
[
|
||||
("dvh1.20.06,ec-3", None, True),
|
||||
("dvhe.20.09", None, True),
|
||||
("hvc1.2.4.L150.90", '"CH-MONO,CH-STEREO"', True),
|
||||
("dvh1.08.06", '"CH-MONO"', False),
|
||||
("dvh1.05.06", None, False),
|
||||
("dvh1.200.06", None, False),
|
||||
(None, None, False),
|
||||
],
|
||||
)
|
||||
def test_is_stereo(codecs: str | None, layout: str | None, expected: bool) -> None:
|
||||
assert Video.is_stereo(codecs, video_layout=layout) is expected
|
||||
|
||||
|
||||
def test_hls_marks_stereo_tracks_from_the_layout_or_the_codec() -> None:
|
||||
tracks = HLS.from_text(MASTER, url="https://cdn.example/master.m3u8").to_tracks(Language.get("en"))
|
||||
stereo = {str(v.url).rsplit("/", 1)[-1]: v.stereo for v in tracks.videos}
|
||||
assert stereo == {"stereo.m3u8": True, "mvhevc.m3u8": True, "flat.m3u8": False}
|
||||
|
||||
|
||||
def test_a_stereo_track_lists_as_3d_and_keeps_the_flag_through_to_dict() -> None:
|
||||
video = Video(url="https://x/v.mp4", language="en", codec=Video.Codec.HEVC, range_=Video.Range.DV, stereo=True)
|
||||
assert str(video).startswith("VID | [H.265, DV, 3D] |")
|
||||
assert Video.from_dict(video.to_dict()).stereo
|
||||
|
||||
|
||||
def stereo_video(tmp_path: Path, stereo: bool = True, **kwargs: Any) -> Video:
|
||||
n = next(VIDEO_IDS)
|
||||
path = tmp_path / f"v{n}.mp4"
|
||||
path.write_bytes(b"\x00" * 16)
|
||||
video = Video(url=f"https://x/v{n}.mp4", language="en", codec=Video.Codec.HEVC, stereo=stereo, **kwargs)
|
||||
video.path = path
|
||||
return video
|
||||
|
||||
|
||||
def test_dash_marks_a_dv_profile_20_representation_stereo() -> None:
|
||||
mpd = """<?xml version="1.0"?>
|
||||
<MPD xmlns="urn:mpeg:dash:schema:mpd:2011" type="static" mediaPresentationDuration="PT10S">
|
||||
<Period>
|
||||
<AdaptationSet contentType="video" mimeType="video/mp4">
|
||||
<SegmentTemplate media="$RepresentationID$/$Number$.m4s" initialization="$RepresentationID$/init.mp4" duration="2" />
|
||||
<Representation id="v3d" codecs="dvh1.20.06" bandwidth="9000000" width="1920" height="1080" />
|
||||
<Representation id="v2d" codecs="dvh1.08.06" bandwidth="5000000" width="1920" height="1080" />
|
||||
</AdaptationSet>
|
||||
</Period>
|
||||
</MPD>"""
|
||||
videos = DASH.from_text(mpd, url="https://cdn.example/manifest.mpd").to_tracks(language="en").videos
|
||||
assert {v.data["dash"]["representation"].get("id"): v.stereo for v in videos} == {"v3d": True, "v2d": False}
|
||||
|
||||
|
||||
def box(kind: bytes, payload: bytes = b"") -> bytes:
|
||||
return (8 + len(payload)).to_bytes(4, "big") + kind + payload
|
||||
|
||||
|
||||
def test_a_track_with_a_second_video_layer_decrypts_with_mp4decrypt(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
(layered := tmp_path / "3d.mp4").write_bytes(box(b"moov", box(b"lhvC")))
|
||||
(flat := tmp_path / "2d.mp4").write_bytes(box(b"moov", box(b"hvcC")))
|
||||
monkeypatch.setattr(config, "decryption", "Shaka")
|
||||
assert decrypter_for(layered) == "mp4decrypt"
|
||||
assert decrypter_for(flat) == "shaka"
|
||||
monkeypatch.setattr(config, "decryption", "MP4DECRYPT")
|
||||
assert decrypter_for(flat) == "mp4decrypt"
|
||||
|
||||
|
||||
def test_a_3d_title_muxes_with_mp4box(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
calls: list[str] = []
|
||||
monkeypatch.setattr(Tracks, "_mux_mp4", lambda self, **kwargs: calls.append(kwargs["title"]) or (Path("x"), 0, []))
|
||||
assert Tracks(stereo_video(tmp_path)).mux("Film")[1] == 0
|
||||
assert calls == ["Film"]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mp4box_commands(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> list[list[str]]:
|
||||
commands: list[list[str]] = []
|
||||
|
||||
class FakePopen:
|
||||
def __init__(self, command: list[str], **kwargs: Any) -> None:
|
||||
commands.append([str(c) for c in command])
|
||||
Path(command[-1]).write_bytes(b"mp4")
|
||||
self.stdout = io.StringIO("")
|
||||
|
||||
def wait(self) -> int:
|
||||
return 0
|
||||
|
||||
monkeypatch.setattr(binaries, "MP4Box", Path("/bin/MP4Box"), raising=False)
|
||||
monkeypatch.setattr(tracks_module.subprocess, "Popen", FakePopen)
|
||||
monkeypatch.setattr(tracks_module, "run_step", lambda command, **kwargs: commands.append([str(c) for c in command]))
|
||||
monkeypatch.setattr(config, "sample", None)
|
||||
monkeypatch.setattr(config.directories, "temp", tmp_path)
|
||||
return commands
|
||||
|
||||
|
||||
def test_a_3d_mp4_mux_keeps_the_sample_whole(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, mp4box_commands: list[list[str]]
|
||||
) -> None:
|
||||
monkeypatch.setattr(config, "sample", (120.0, 180.0))
|
||||
Tracks(stereo_video(tmp_path))._mux_mp4(title="Film", output_path=tmp_path / "out.mkv")
|
||||
assert len(mp4box_commands) == 1
|
||||
assert "-splitx" not in mp4box_commands[0]
|
||||
|
||||
|
||||
def test_a_3d_mp4_mux_writes_quicktime_chapters(tmp_path: Path, mp4box_commands: list[list[str]]) -> None:
|
||||
for stereo, flag in ((True, "-chapqt"), (False, "-chap")):
|
||||
tracks = Tracks(stereo_video(tmp_path, stereo=stereo))
|
||||
tracks.chapters = Chapters([Chapter(0.0, "Story")])
|
||||
tracks._mux_mp4(title="Film", output_path=tmp_path / f"out{stereo}.mkv")
|
||||
assert flag in mp4box_commands[-1] and ({"-chap", "-chapqt"} - {flag}).isdisjoint(mp4box_commands[-1])
|
||||
|
||||
|
||||
def test_a_3d_track_is_never_a_hybrid_ingredient(tmp_path: Path) -> None:
|
||||
flat, layered = (stereo_video(tmp_path, stereo=s, range_=Video.Range.DV) for s in (False, True))
|
||||
candidates, standalone = Tracks.partition_hybrid_videos([flat, layered], [])
|
||||
assert candidates == [flat] and standalone == []
|
||||
|
||||
|
||||
def test_2d_ranks_over_3d_at_one_resolution_and_the_signatures_differ(tmp_path: Path) -> None:
|
||||
flat = stereo_video(tmp_path, stereo=False, height=1080, bitrate=5_000_000)
|
||||
layered = stereo_video(tmp_path, stereo=True, height=1080, bitrate=9_000_000)
|
||||
uhd = stereo_video(tmp_path, stereo=True, height=2160, bitrate=40_000_000, width=3840)
|
||||
tracks = Tracks(layered, flat, uhd)
|
||||
tracks.sort_videos()
|
||||
assert tracks.videos == [uhd, flat, layered]
|
||||
assert Tracks.signature(flat) != Tracks.signature(
|
||||
stereo_video(tmp_path, stereo=True, height=1080, bitrate=5_000_000)
|
||||
)
|
||||
|
||||
|
||||
def test_a_3d_track_gets_no_ffmpeg_vui_rewrite_or_repack(tmp_path: Path) -> None:
|
||||
track = stereo_video(tmp_path, range_=Video.Range.HDR10)
|
||||
assert track.vui_bsf() is None
|
||||
assert track.repackage() is False
|
||||
assert track.path.exists()
|
||||
|
||||
|
||||
def test_the_filename_context_marks_a_3d_title(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(config, "tag_rules", [])
|
||||
monkeypatch.setattr(config, "language_tags", {})
|
||||
|
||||
class Svc:
|
||||
pass
|
||||
|
||||
def context(stereo: bool) -> str:
|
||||
movie = Movie(id_="movie-0001", service=Svc, name="The Film", year=2024, language="en")
|
||||
movie.tracks = Tracks(Video(url="https://x/v.mp4", language="en", codec=Video.Codec.HEVC, stereo=stereo))
|
||||
video = SimpleNamespace(
|
||||
width=3840,
|
||||
height=2160,
|
||||
format="HEVC",
|
||||
scan_type="Progressive",
|
||||
hdr_format_commercial="",
|
||||
hdr_format="",
|
||||
hdr_format_profile="",
|
||||
transfer_characteristics="PQ",
|
||||
transfer_characteristics_original=None,
|
||||
frame_rate="47.952",
|
||||
)
|
||||
return str(movie.build_base_template_context(SimpleNamespace(video_tracks=[video], audio_tracks=[]))["3d"])
|
||||
|
||||
assert (context(True), context(False)) == ("3D", "")
|
||||
|
||||
|
||||
def test_worst_prefers_the_2d_track_at_one_height(tmp_path: Path) -> None:
|
||||
videos = [
|
||||
stereo_video(tmp_path, stereo=s, height=2160, bitrate=b)
|
||||
for s, b in ((False, 9_000_000), (False, 5_000_000), (True, 40_000_000), (True, 30_000_000))
|
||||
]
|
||||
ranked = Tracks(videos)
|
||||
ranked.sort_videos()
|
||||
assert pick_video(ranked.videos, worst=True) is videos[1]
|
||||
assert pick_video(ranked.videos, worst=False) is videos[0]
|
||||
assert pick_video([videos[3], videos[2]], worst=True) is videos[2]
|
||||
assert pick_video([], worst=True) is None
|
||||
|
||||
|
||||
def test_hybrid_never_pairs_a_3d_dv_track(tmp_path: Path) -> None:
|
||||
base = stereo_video(tmp_path, stereo=False, range_=Video.Range.HDR10, height=2160)
|
||||
layered = stereo_video(tmp_path, stereo=True, range_=Video.Range.DV, height=2160)
|
||||
assert hybrid_plan([base, layered], generate_dv=False) == ([], None)
|
||||
|
||||
|
||||
def test_mp4_names_skip_the_extra_tracks_of_a_3d_video_file(tmp_path: Path, mp4box_commands: list[list[str]]) -> None:
|
||||
video = stereo_video(tmp_path)
|
||||
video.path.write_bytes(box(b"moov", box(b"trak") + box(b"trak")))
|
||||
audio = Audio(url="https://x/a.mp4", language="en", codec=Audio.Codec.EC3, name="English")
|
||||
audio.path = tmp_path / "a.mp4"
|
||||
audio.path.write_bytes(b"\x00" * 16)
|
||||
Tracks([video, audio])._mux_mp4(title="Film", output_path=tmp_path / "out.mkv")
|
||||
command = mp4box_commands[0]
|
||||
assert command[command.index("-name") + 1] == "3=English"
|
||||
|
||||
|
||||
def test_a_3d_mux_that_loses_its_second_view_warns(
|
||||
tmp_path: Path, mp4box_commands: list[list[str]], caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
Tracks(stereo_video(tmp_path))._mux_mp4(title="Film", output_path=tmp_path / "out.mkv")
|
||||
assert "lost its second view" in caplog.text
|
||||
|
||||
|
||||
def test_a_downloaded_file_with_a_second_view_marks_the_video_3d(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
video = stereo_video(tmp_path, stereo=False)
|
||||
monkeypatch.setattr(Track, "download", lambda self, *a, **k: self.path.write_bytes(box(b"moov", box(b"lhvC"))))
|
||||
video.download(None, None)
|
||||
assert video.stereo
|
||||
|
||||
|
||||
def test_dash_reads_dv_profile_20_from_supplemental_codecs() -> None:
|
||||
mpd = """<?xml version="1.0"?>
|
||||
<MPD xmlns="urn:mpeg:dash:schema:mpd:2011" type="static" mediaPresentationDuration="PT10M">
|
||||
<Period id="1"><AdaptationSet contentType="video" mimeType="video/mp4" lang="en">
|
||||
<Representation id="v" codecs="hvc1.2.4.L153" supplementalCodecs="dvh1.20.09" bandwidth="1" width="3840" height="2160">
|
||||
<BaseURL>v.mp4</BaseURL><SegmentBase indexRange="0-1"/>
|
||||
</Representation>
|
||||
</AdaptationSet></Period>
|
||||
</MPD>"""
|
||||
assert DASH.from_text(mpd, url="https://cdn.example/m.mpd").to_tracks(language="en").videos[0].stereo
|
||||
|
||||
|
||||
def test_hls_reads_each_variant_layout_even_when_two_share_a_playlist() -> None:
|
||||
master = """#EXTM3U
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=2,CODECS="hvc1.2.4.L150.90",RESOLUTION=1920x1080,REQ-VIDEO-LAYOUT="CH-STEREO"
|
||||
shared.m3u8
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=1,CODECS="hvc1.2.4.L150.90",RESOLUTION=1920x1080,AUDIO="a"
|
||||
shared.m3u8
|
||||
"""
|
||||
tracks = HLS.from_text(master, url="https://cdn.example/master.m3u8").to_tracks(Language.get("en"))
|
||||
assert any(v.stereo for v in tracks.videos)
|
||||
|
||||
|
||||
def test_a_sample_is_cut_only_from_a_matroska_file(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
runner = dl.__new__(dl)
|
||||
runner.log = logging.getLogger("download")
|
||||
monkeypatch.setattr(subprocess, "run", lambda *a, **k: pytest.fail("mkvmerge ran on an MP4"))
|
||||
runner.write_sample(tmp_path / "Film.mp4", SimpleNamespace(), SimpleNamespace())
|
||||
assert not (tmp_path / "Sample").exists()
|
||||
Reference in New Issue
Block a user