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:
imSp4rky
2026-11-12 04:47:18 -07:00
parent a28c99b811
commit e29a523907
31 changed files with 513 additions and 67 deletions
+1 -1
View File
@@ -339,7 +339,7 @@ When you give both `season` and `episode`, the server combines them into a `"{se
"bitrate": 8000, "bitrate_bps": 8000000, "width": 3840, "height": 2160,
"resolution": "2160p", "fps": "24", "range": "HDR10",
"range_display": "HDR", "scan_type": null, "closed_captions": [],
"dv_compatible_bitstream": false, "hdr10plus": false, "hdr10_base": false, "language": "en",
"dv_compatible_bitstream": false, "hdr10plus": false, "hdr10_base": false, "stereo": false, "language": "en",
"is_original_lang": true,
"name": null, "track_name": "English", "needs_repack": false, "edition": [], "note": null, "data": null,
"drm": [], "descriptor": "DASH"
+1
View File
@@ -454,6 +454,7 @@ Two DV mechanisms sit on top of the track model. **DV fixup** re-exposes DV sign
HLS and DASH streams that carry a valid DV RPU but advertise plain HEVC, with DV only in the
supplemental codecs (`dv_compatible_bitstream`). `--list` shows such a track as `HDR10+DV` or `HDR10P+DV`.
A service sets `hdr10_base` on a DV track that has an HDR10 base layer (DV profile 8.1), and `--list` shows it as `DV+HDR10`.
The HLS and DASH parsers set `stereo` on a stereoscopic 3D (MV-HEVC) track: an HLS `REQ-VIDEO-LAYOUT` that names `CH-STEREO`, or a DV profile 20 codec. `--list` shows it as `3D`.
**Hybrid** processing (`Video.Range.HYBRID`) selects an HDR10(+) base and a DV donor,
extracts the RPU with `dovi_tool`, and injects it onto the base layer, to make a single
track that carries both HDR10 metadata and the DV RPU. Both are automatic once the
+4 -4
View File
@@ -268,7 +268,7 @@ download. For example, `dovi_tool` only matters if you download Dolby Vision vid
| Tool | Binary looked up | Why it is needed |
| --- | --- | --- |
| Bento4 (`mp4decrypt`) | `mp4decrypt` | Alternative DRM decryptor to shaka-packager |
| MP4Box (GPAC) | `MP4Box` | Muxing DTS:X Profile 2 (DTS-UHD) and MPEG-H audio |
| MP4Box (GPAC) | `MP4Box` | Muxing DTS:X Profile 2 (DTS-UHD) and MPEG-H audio, and 3D video |
| dovi_tool | `dovi_tool` | Dolby Vision metadata handling |
| HDR10Plus_tool | `hdr10plus_tool` | HDR10+ metadata handling |
| SubtitleEdit | `seconv`, `SubtitleEdit` | Subtitle conversion (the `SeConv` CLI from 5.x) |
@@ -280,9 +280,9 @@ download. For example, `dovi_tool` only matters if you download Dolby Vision vid
| Docker | `docker` | Gluetun VPN proxy support |
| Git | `git` | Fetching and updating remote service repositories |
!!! info "MP4Box is only used for DTS:X Profile 2 and MPEG-H"
Titles with DTS:X Profile 2 (DTS-UHD) or MPEG-H audio mux to MP4; everything else muxes to MKV
with `mkvmerge`. Install with `apt install gpac`.
!!! info "MP4Box is only used for DTS:X Profile 2, MPEG-H and 3D video"
Titles with DTS:X Profile 2 (DTS-UHD) or MPEG-H audio, or a stereoscopic 3D video track, mux to
MP4; everything else muxes to MKV with `mkvmerge`. Install with `apt install gpac`.
!!! tip "SubtitleEdit specifics"
unshackle looks for `seconv` first, the batch CLI shipped with
+4
View File
@@ -164,6 +164,10 @@ unshackle dl -q 2160 -r HDR10,DV EXAMPLE '...'
The output file of a track with a Dolby Vision profile 8 RPU plays as Dolby Vision on a DV screen.
`--list` adds `3D` to a stereoscopic 3D (MV-HEVC) track, for example `[H.265, DV, 3D]`. When a title
has a 2D and a 3D track at the same resolution, unshackle selects the 2D track. A 3D title muxes to
MP4, see [3D output](output-and-naming.md#3d-output).
!!! warning "HYBRID requires dovi_tool"
`-r HYBRID` makes a single hybrid track by injecting the Dolby Vision RPU
metadata onto an HDR10/HDR10+ base layer with **dovi_tool**.
+20 -1
View File
@@ -15,7 +15,7 @@ When a download finishes, unshackle builds the final path from three pieces:
- **Output directory**: `directories.downloads` from your config, or whatever you pass to `-o/--output` on the command line for a single run.
- **Folder template**: an optional per-title subfolder. unshackle **always** puts TV episodes and songs in a folder. Movies only get one if you define a `movies` folder template, or a single-string `folder` template that applies to all title kinds (see [Folder templates](#folder-templates)).
- **Filename template**: the `output_template` for the title kind (movie, series, or song).
- **Extension**: chosen by the muxer for a movie or an episode: `.mkv` for video, `.mka` for audio-only, `.mks` for subtitle-only, `.mp4` for [DTS:X Profile 2 and MPEG-H audio](#dtsx-profile-2-and-mpeg-h-output). unshackle does not mux a song. It remuxes a song into the container of its codec, such as `.flac`, `.opus`, or `.m4a`. Read [Music output files](#music-output-files).
- **Extension**: chosen by the muxer for a movie or an episode: `.mkv` for video, `.mka` for audio-only, `.mks` for subtitle-only, `.mp4` for [DTS:X Profile 2 and MPEG-H audio](#dtsx-profile-2-and-mpeg-h-output) and [3D video](#3d-output). unshackle does not mux a song. It remuxes a song into the container of its codec, such as `.flac`, `.opus`, or `.m4a`. Read [Music output files](#music-output-files).
!!! example "A typical episode path"
```text
@@ -110,6 +110,7 @@ Every variable below is valid in both output and folder templates. unshackle tak
| `video` | Video codec | `H.264`, `H.265` |
| `hdr` | Dynamic-range label | `HDR`, `HDR10P`, `DV`, `DV.HDR`, `DV.HDR10P`, `HLG` |
| `hfr` | High frame rate marker (frame rate > 30) | `HFR` |
| `3d` | Stereoscopic 3D marker (MV-HEVC video) | `3D` |
| `edition` | Edition label from the track, if any | `Directors Cut` |
| `tag` | Release-group tag (from `config.tag` or `--tag`) | `TAG` |
| `repack` | `REPACK` when the `--repack` flag is used | `REPACK` |
@@ -429,6 +430,23 @@ An MP4 title loses:
- SubStation Alpha (`.ass`) subtitles
- container tags (`mkvpropedit`)
### 3D output
A stereoscopic 3D (MV-HEVC) video track also muxes to MP4 with MP4Box, because Matroska has no
mapping for its second view and `mkvmerge` drops it. There is no 3D Matroska output. unshackle
finds a 3D track from the manifest, or from a second video layer in the downloaded file. The MP4
title loses the same items as above. For a 3D title, unshackle also:
- decrypts the track with mp4decrypt, whatever [`decryption`](../reference/configuration/drm.md#decryption) selects
- writes QuickTime chapters
- keeps the whole downloaded segments of a `--sample` run, because the MP4Box cut removes the 3D signalling
- does not repack the track or rewrite its colour metadata with FFmpeg
- writes no `--include-sample` sample, because `mkvmerge` cuts only Matroska files
- never uses the track in a `HYBRID` merge
A Dolby Vision profile 20 track has no HDR10 base layer, so a player without Dolby Vision shows
wrong colours.
You configure muxing (the combination of video, audio, subtitle, chapter and attachment tracks into a single Matroska file with `mkvmerge`) under the `muxing` config key. unshackle muxes only movies and episodes. Read [Music output files](#music-output-files) for what a music download does instead.
```yaml title="unshackle.yaml"
@@ -475,6 +493,7 @@ The `--no-mux` flag skips muxing entirely and writes the individual track files,
[`--sample`](cli-reference.md#output-muxing-files) and `--include-sample` write a sample into a
`Sample` folder inside the output folder of the title. The sample keeps the filename of the
title, with a `SAMPLE` tag at the end. The tag uses the separator of the output template.
`--include-sample` cuts the sample with `mkvmerge`, so a title that muxes to MP4 gets no sample.
```text title="A movie with --include-sample"
Some.Movie.2026.1080p.EXAMPLE.WEB-DL.H.264-TAG/
+4
View File
@@ -187,6 +187,10 @@ service tag case-insensitively and uses the `default` entry as the fallback.
unshackle decrypts HLS AES-128 ClearKey in-process and ignores this setting.
unshackle decrypts a stereoscopic 3D (MV-HEVC) track with mp4decrypt whatever this setting says.
shaka-packager refuses its Dolby Vision configuration (version 3), and before v3.5.0 it also drops
the boxes of the second view.
mp4decrypt finds a content key by the KID in the first `sinf` box of the track. When that box
has no CENC `tenc` (for example, a PIFF `sinf` before the CENC one), unshackle also gives
mp4decrypt the content key of that KID by track ID. When mp4decrypt copies a track through
+2 -2
View File
@@ -40,7 +40,7 @@ folder-kind keys.
`absolute`, `date`, `quality`, `resolution`, `source`, `tag`, `track_number`, `artist`,
`album_artist`, `album`, `disc`, `track_total`, `disc_total`, `release_type`, `genre`,
`explicit`, `isrc`, `upc`, `label`, `audio`, `audio_channels`, `audio_full`, `atmos`,
`dual`, `multi`, `dubbed`, `video`, `hdr`, `hfr`, `edition`, `repack`, `lang_tag`,
`dual`, `multi`, `dubbed`, `video`, `hdr`, `hfr`, `3d`, `edition`, `repack`, `lang_tag`,
`title_type`.
!!! warning "`{part?}` is already inside `{season_episode}`"
@@ -129,7 +129,7 @@ Rule semantics:
- A value can be a scalar or a list. A list matches if **any** entry matches.
- Comparison is case-insensitive string equality.
- The keys of `when` are the filename template variables for the release itself:
`title_type`, `quality`, `resolution`, `source`, `video`, `hdr`, `hfr`, `audio`,
`title_type`, `quality`, `resolution`, `source`, `video`, `hdr`, `hfr`, `3d`, `audio`,
`audio_channels`, `audio_full`, `atmos`, `dual`, `multi`, `dubbed`, `edition`, `repack`,
and `lang_tag`. Only these are available. unshackle adds the title's own fields (`title`,
`year`, `season`, `episode`, and so on) after it evaluates the rules, so a condition cannot
+2 -2
View File
@@ -113,7 +113,7 @@ values.
| `{year}` `{season}` `{episode}` `{episode_name}` | Season and episode are plain numbers (`1`, `5`) |
| `{part}` | Part number of a split episode as a plain number (`1` for `Part.1`). Empty when the episode has no part |
| `{season_episode}` `{absolute}` `{date}` | As in the filename template: `{season_episode}` keeps the part suffix (`S01E05.Part.1`) while `{episode}` drops it |
| `{quality}` `{resolution}` `{hdr}` `{hfr}` `{vcodec}` `{acodec}` `{edition}` `{tag}` `{lang_tag}` | As in the filename template, for this file |
| `{quality}` `{resolution}` `{hdr}` `{hfr}` `{3d}` `{vcodec}` `{acodec}` `{edition}` `{tag}` `{lang_tag}` | As in the filename template, for this file |
| `{tmdb}` `{imdb}` `{tvdb}` | The tagging IDs in use, otherwise empty. See [Tagging IDs](#tagging-ids) |
| `{error}` | Failure message. Empty on success |
@@ -132,7 +132,7 @@ Every variable from your `output_template` is available here as well, including
music has none of them.
- **The naming context renders it empty on purpose.** `{hdr}` is empty for an SDR
file, because SDR is the absence of an HDR tag in a filename rather than a tag that
reads `SDR`. `{edition}`, `{atmos}`, `{multi}`, `{dual}` and `{hfr}` behave the same
reads `SDR`. `{edition}`, `{atmos}`, `{multi}`, `{dual}`, `{hfr}` and `{3d}` behave the same
way. If your script needs the word `SDR`, derive it from an empty `{hdr}` yourself.
- The post-script is a `failure` one. A failed download has no output file to read metadata
from, so everything that comes from the output file is empty. See
+13
View File
@@ -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
+1 -1
View File
@@ -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:
+5
View File
@@ -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
+275
View File
@@ -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()
@@ -58,6 +58,11 @@ def test_a_dv_track_keeps_its_hdr10_base_label_over_remote() -> None:
assert deserialize_video(serialize_video_track(v)).range_label == "DV+HDR10"
def test_a_stereo_track_stays_3d_over_remote() -> None:
v = Video(url="https://x/v.mp4", language="en", codec=Video.Codec.HEVC, range_=Video.Range.DV, stereo=True)
assert deserialize_video(serialize_video_track(v)).stereo
def test_a_track_note_shows_over_remote_and_export_and_is_not_an_edition() -> None:
from unshackle.core.tracks.track import Track
+21 -11
View File
@@ -336,18 +336,26 @@ def hybrid_gate_failure(videos: Sequence[Video], generate_dv: bool) -> Optional[
def hybrid_plan(videos: Sequence[Video], generate_dv: bool) -> tuple[list[Video], Optional[Video]]:
"""The hybrid bases and the DV track to pair with them.
"""The hybrid bases and the 2D DV track to pair with them.
With no DV track, a base qualifies only with ``--generate-dv``. A resolution with a
finished hybrid (``native_hybrid``) gets no base: that track is its hybrid.
"""
dv_tracks = [v for v in videos if v.range == Video.Range.DV]
dv_tracks = [v for v in videos if v.range == Video.Range.DV and not v.stereo]
native_heights = {v.height for v in videos if v.native_hybrid}
base_pool = [v for v in videos if v.height not in native_heights and (dv_tracks or generate_dv)]
matching_dv = min(dv_tracks, key=lambda v: v.height or 0) if dv_tracks else None
return select_hybrid_bases(base_pool), matching_dv
def pick_video(candidates: list[Video], worst: bool) -> Optional[Video]:
"""The best candidate, or with ``worst`` the last one, preferring 2D: a 3D track sorts after 2D at one height."""
if worst:
pool = [v for v in candidates if not v.stereo] or candidates
return pool[-1] if pool else None
return next(iter(candidates), None)
def hybrid_source_tracks(base: Video, dv: Optional[Video], base_container: Optional[Path] = None) -> list[Video]:
"""Copies of the base and DV tracks for the hybrid step, with the base on its container file.
@@ -2874,7 +2882,7 @@ class dl:
and (not codec or t.codec == codec)
and (vlang is None or str(t.language) == vlang)
]
match = candidates[-1] if worst and candidates else next(iter(candidates), None)
match = pick_video(candidates, worst)
if match and match not in non_hybrid_selected:
non_hybrid_selected.append(match)
@@ -2903,7 +2911,7 @@ class dl:
and (not codec or t.codec == codec)
and (vlang is None or str(t.language) == vlang)
]
match = candidates[-1] if worst and candidates else next(iter(candidates), None)
match = pick_video(candidates, worst)
if match and match not in selected_videos:
selected_videos.append(match)
title.tracks.videos = selected_videos
@@ -2941,7 +2949,7 @@ class dl:
and (not codec or t.codec == codec)
and (vlang is None or str(t.language) == vlang)
]
match = candidates[-1] if worst and candidates else next(iter(candidates), None)
match = pick_video(candidates, worst)
if match and match not in fallback_selected:
fallback_selected.append(match)
title.tracks.videos = fallback_selected
@@ -3448,13 +3456,9 @@ class dl:
self.suggest_missing_fonts(missing_fonts)
# Handle DRM decryption BEFORE repacking (must decrypt first!)
service_name = service.__class__.__name__.upper()
decryption_method = resolve_decryption(config.decryption_map, config.decryption, service_name)
decrypt_tool = "mp4decrypt" if decryption_method.lower() == "mp4decrypt" else "Shaka Packager"
drm_tracks = [track for track in title.tracks if track.drm]
if drm_tracks:
with console.status(f"Decrypting tracks with {decrypt_tool}..."):
with console.status("Decrypting tracks..."):
has_decrypted = False
for track in drm_tracks:
drm = track.get_drm_for_cdm(self.cdm)
@@ -3470,7 +3474,7 @@ class dl:
f"No matching DRM found for track {track} with CDM type {type(self.cdm).__name__}"
)
if has_decrypted:
self.log.info(f"Decrypted tracks with {decrypt_tool}")
self.log.info("Decrypted tracks")
if (
not no_subs
@@ -4050,7 +4054,11 @@ class dl:
"""Cut a 60 second sample from a finished file into a ``Sample`` folder next to it.
The sample starts 2 minutes in. A file shorter than 3 minutes gives its middle 60 seconds.
mkvmerge cuts only Matroska: an MP4 (3D video, or audio Matroska cannot carry) gets no sample.
"""
if source.suffix.lower() != ".mkv":
self.log.warning(f"No sample for {source.name}: mkvmerge cuts only Matroska files")
return
duration = float(media_info.general_tracks[0].duration or 0) / 1000
start: float
start, end = DEFAULT_SAMPLE
@@ -4518,6 +4526,8 @@ class dl:
}
if isinstance(t, Video):
row.update(width=t.width, height=t.height, range=t.range.name.lower() if t.range else "")
if t.stereo:
row["stereo"] = True
elif isinstance(t, Audio):
row.update(channels=str(t.channels) if t.channels else "", atmos=bool(t.joc), descriptive=t.descriptive)
elif isinstance(t, Subtitle):
+7
View File
@@ -48,6 +48,13 @@ def get_dependencies() -> list[dict]:
"desc": "MKV metadata",
"cat": "Core",
},
{
"name": "MP4Box",
"binary": binaries.MP4Box,
"required": False,
"desc": "MP4 muxing",
"cat": "Core",
},
{
"name": "shaka-packager",
"binary": binaries.ShakaPackager,
+1
View File
@@ -1744,6 +1744,7 @@ def serialize_video_track(track: Video, include_url: bool = False) -> Dict[str,
"dv_compatible_bitstream": bool(getattr(track, "dv_compatible_bitstream", False)),
"hdr10plus": bool(getattr(track, "hdr10plus", False)),
"hdr10_base": bool(getattr(track, "hdr10_base", False)),
"stereo": bool(getattr(track, "stereo", False)),
}
result.update(serialize_track_common(track))
if include_url and hasattr(track, "url") and track.url:
+2
View File
@@ -79,6 +79,7 @@ __binaries = {
"DoviTool": ("dovi_tool",),
"HDR10PlusTool": ("hdr10plus_tool", "HDR10Plus_tool"),
"Mp4decrypt": ("mp4decrypt",),
"MP4Box": ("MP4Box", "mp4box"),
"Docker": ("docker",),
"Git": ("git",),
}
@@ -226,6 +227,7 @@ __all__ = (
"DoviTool",
"HDR10PlusTool",
"Mp4decrypt",
"MP4Box",
"Docker",
"Git",
"find",
+1
View File
@@ -279,6 +279,7 @@ class Config:
"video",
"hdr",
"hfr",
"3d",
"edition",
"repack",
"lang_tag",
+2 -2
View File
@@ -17,7 +17,7 @@ from unshackle.core import binaries
from unshackle.core.config import config
from unshackle.core.console import console
from unshackle.core.drm import key_args as drm_key_args
from unshackle.core.drm.verify import kidless_track_kids, mp4decrypt_or_shaka, read_top_level_box
from unshackle.core.drm.verify import decrypter_for, kidless_track_kids, mp4decrypt_or_shaka, read_top_level_box
from unshackle.core.utilities import log_event
from unshackle.core.utils import run_proxy
@@ -196,7 +196,7 @@ class ClearKeyCENC:
if not path or not path.exists():
raise ValueError("Tried to decrypt a file that does not exist.")
decrypter = str(getattr(config, "decryption", "")).lower()
decrypter = decrypter_for(path)
tool = "mp4decrypt" if decrypter == "mp4decrypt" else "shaka-packager"
log_event(
+2 -2
View File
@@ -25,7 +25,7 @@ from unshackle.core.config import config
from unshackle.core.console import console
from unshackle.core.constants import AnyTrack
from unshackle.core.drm import key_args as drm_key_args
from unshackle.core.drm.verify import kidless_track_kids, mp4decrypt_or_shaka, read_top_level_box
from unshackle.core.drm.verify import decrypter_for, kidless_track_kids, mp4decrypt_or_shaka, read_top_level_box
from unshackle.core.session import AnySession, utf8_text
from unshackle.core.utilities import call_licence, get_boxes, log_event
from unshackle.core.utils import run_proxy
@@ -557,7 +557,7 @@ class PlayReady:
if not path or not path.exists():
raise ValueError("Tried to decrypt a file that does not exist.")
decrypter = str(getattr(config, "decryption", "")).lower()
decrypter = decrypter_for(path)
tool = "mp4decrypt" if decrypter == "mp4decrypt" else "shaka-packager"
log_event(
+33
View File
@@ -5,6 +5,8 @@ from pathlib import Path
from typing import Any, BinaryIO, Callable, Iterator, NamedTuple, Optional
from uuid import UUID
from unshackle.core.config import config
log = logging.getLogger(__name__)
DECRYPT_HOOK: Optional[Callable[..., None]] = None
@@ -221,6 +223,37 @@ def kidless_track_kids(data: bytes) -> dict[int, UUID]:
return kids
def find_child_box(buf: bytes, box_type: bytes) -> Optional[bytes]:
"""Return the first box of the given type nested anywhere in buf, header included."""
i = buf.find(box_type, 4)
while i != -1:
size = int.from_bytes(buf[i - 4 : i], "big")
if 8 <= size <= len(buf) - (i - 4):
return buf[i - 4 : i - 4 + size]
i = buf.find(box_type, i + 1)
return None
def has_second_view(path: Path) -> bool:
"""True if the MP4 holds a second video layer (an ``lhvC`` box, as stereoscopic MV-HEVC has)."""
try:
return find_child_box(read_top_level_box(path, b"moov") or b"", b"lhvC") is not None
except OSError:
return False
def decrypter_for(path: Path) -> str:
"""The configured decrypter, or mp4decrypt for a multi-layer (MV-HEVC) track.
shaka-packager refuses a version 3 dvvC (Dolby Vision profile 20) and, before v3.5.0, drops lhvC and vexu.
"""
configured = str(getattr(config, "decryption", "")).lower()
if configured != "mp4decrypt" and has_second_view(path):
log.debug(f"Decrypting {path.name} with mp4decrypt: it holds a second video layer (lhvC)")
return "mp4decrypt"
return configured
def mp4decrypt_or_shaka(drm: Any, path: Path) -> None:
"""Decrypt ``path`` with mp4decrypt, and again with shaka-packager when mp4decrypt copied it through.
+2 -2
View File
@@ -21,7 +21,7 @@ from unshackle.core.config import config
from unshackle.core.console import console
from unshackle.core.constants import AnyTrack
from unshackle.core.drm import key_args as drm_key_args
from unshackle.core.drm.verify import kidless_track_kids, mp4decrypt_or_shaka, read_top_level_box
from unshackle.core.drm.verify import decrypter_for, kidless_track_kids, mp4decrypt_or_shaka, read_top_level_box
from unshackle.core.session import AnySession, utf8_text
from unshackle.core.utilities import call_licence, get_boxes, log_event
from unshackle.core.utils import run_proxy
@@ -307,7 +307,7 @@ class Widevine:
if not path or not path.exists():
raise ValueError("Tried to decrypt a file that does not exist.")
decrypter = str(getattr(config, "decryption", "")).lower()
decrypter = decrypter_for(path)
tool = "mp4decrypt" if decrypter == "mp4decrypt" else "shaka-packager"
log_event(
+1
View File
@@ -562,6 +562,7 @@ class ImportService:
width=int_or_none(row.get("width")),
height=int_or_none(row.get("height")),
fps=row.get("fps") or None,
stereo=bool(row.get("stereo")),
)
else:
track = Audio(
+1
View File
@@ -333,6 +333,7 @@ class DASH:
video_args["dv_compatible_bitstream"] = Video.is_dv_composite(
track_codec, codecs, get("supplementalCodecs")
)
video_args["stereo"] = Video.is_stereo(codecs, get("supplementalCodecs"))
elif content_type == "audio":
track_type = Audio
track_codec = Audio.Codec.from_codecs(codecs or "")
+8 -1
View File
@@ -222,7 +222,11 @@ class HLS:
supplemental_codecs = self.supplemental_codecs_by_uri()
for playlist in self.manifest.playlists:
video_layouts = [
(raw.get("stream_info") or {}).get("req_video_layout")
for raw in (getattr(self.manifest, "data", None) or {}).get("playlists", [])
]
for playlist, video_layout in zip(self.manifest.playlists, video_layouts):
video_codec: Optional[Video.Codec] = None
primary_track_type: type[Audio] | type[Video]
try:
@@ -272,6 +276,9 @@ class HLS:
(playlist.stream_info.closed_captions or "").strip('"'), []
),
dv_compatible_bitstream=dv_compatible_bitstream,
stereo=Video.is_stereo(
playlist.stream_info.codecs, supplemental_codecs.get(playlist.uri), video_layout
),
)
if primary_track_type is Video
else {}
+3 -2
View File
@@ -119,7 +119,7 @@ class RemoteError(SystemExit):
"""A remote call that failed: logged, then exits a CLI run like ``SystemExit(1)``.
A serve relaying for a REST client catches it instead and answers with ``message``; ``kind`` is the
remote server's error code, or CONNECT / TIMEOUT / AUTH when the session could not be reached or opened.
remote server's error code, or CONNECT / TIMEOUT / AUTH when the remote session could not be reached or opened.
(Not ``code``: SystemExit's ``code`` is the exit status.)
"""
@@ -132,7 +132,7 @@ class RemoteError(SystemExit):
@property
def session_broke(self) -> bool:
"""Whether the remote session itself is gone, not just this one call."""
"""Whether the remote session itself is gone, not only this one call."""
return self.kind in SESSION_LOST
@@ -348,6 +348,7 @@ def deserialize_video(data: Dict[str, Any]) -> Video:
dv_compatible_bitstream=bool(data.get("dv_compatible_bitstream", False)),
hdr10plus=bool(data.get("hdr10plus", False)),
hdr10_base=bool(data.get("hdr10_base", False)),
stereo=bool(data.get("stereo", False)),
)
+2
View File
@@ -106,6 +106,7 @@ class Title:
"video": "",
"hdr": "",
"hfr": "",
"3d": "",
"edition": "",
"lang_tag": "",
"title_type": {"Movie": "movie", "Episode": "series", "Song": "music"}.get(
@@ -163,6 +164,7 @@ class Title:
frame_rate = float(primary_video_track.frame_rate) if primary_video_track.frame_rate else 0.0
context["hfr"] = "HFR" if frame_rate > 30 else ""
context["3d"] = "3D" if any(v.stereo for v in self.tracks.videos[:1]) else ""
if primary_audio_track:
codec = primary_audio_track.format
+1 -11
View File
@@ -30,6 +30,7 @@ from unshackle.core.downloaders.requests import requests
from unshackle.core.drm import DRM_T, BlowfishStripe, ClearKeyCENC, PlayReady, Widevine
from unshackle.core.drm.verify import (
decrypt_track,
find_child_box,
has_encrypted_sample_entry,
read_top_level_box,
senc_protects_samples,
@@ -114,17 +115,6 @@ def iter_top_level_boxes(path: Path) -> Iterable[tuple[bytes, int, int]]:
offset += size
def find_child_box(buf: bytes, box_type: bytes) -> Optional[bytes]:
"""Return the first box of the given type nested anywhere in buf, header included."""
i = buf.find(box_type, 4)
while i != -1:
size = int.from_bytes(buf[i - 4 : i], "big")
if 8 <= size <= len(buf) - (i - 4):
return buf[i - 4 : i - 4 + size]
i = buf.find(box_type, i + 1)
return None
def strip_duplicate_init_boxes(src: Path, dst: Path) -> int:
"""Write src to dst without its repeated ftyp/moov pairs, returning how many it dropped.
+44 -17
View File
@@ -21,6 +21,7 @@ from unshackle.core import binaries
from unshackle.core.config import config
from unshackle.core.console import GradientPulseBarColumn, console
from unshackle.core.constants import AnyTrack, TrackT
from unshackle.core.drm.verify import children, has_second_view, read_top_level_box
from unshackle.core.events import events
from unshackle.core.tracks.attachment import Attachment
from unshackle.core.tracks.audio import Audio
@@ -68,6 +69,12 @@ def file_track_ids(path: Path) -> dict[str, int]:
return ids or {"video": 0, "audio": 1}
def mp4_track_count(path: Path) -> int:
"""How many tracks ``MP4Box -add`` takes from a file: each ``trak`` of an MP4, or 1 for any other file."""
moov = read_top_level_box(path, b"moov") or b""
return sum(1 for kind, _, _ in children(moov, 8) if kind == b"trak") or 1
class Tracks:
"""
Video, Audio, Subtitle, Chapter, and Attachment Track Store.
@@ -287,7 +294,17 @@ class Tracks:
def signature(track: AnyTrack) -> tuple:
"""The attributes that listing and selection read. Two tracks with one signature look and select the same."""
if isinstance(track, Video):
return Video, track.codec, track.range, track.width, track.height, track.fps, track.bitrate, track.name
return (
Video,
track.codec,
track.range,
track.stereo,
track.width,
track.height,
track.fps,
track.bitrate,
track.name,
)
if isinstance(track, Audio):
return (
Audio,
@@ -333,11 +350,13 @@ class Tracks:
def sort_videos(
self, by_language: Optional[Sequence[Union[str, Language]]] = None, exact_match: bool = False
) -> None:
"""Sort video tracks by resolution then bitrate, and optionally language."""
"""Sort video tracks by resolution, 2D before 3D, then bitrate, and optionally language.
A 3D track carries two views, so its higher bitrate would otherwise rank it over the 2D one.
"""
if not self.videos:
return
# resolution first, then bitrate (unknown-bitrate tracks still rank by resolution)
self.videos.sort(key=lambda x: (x.height or 0, float(x.bitrate or 0.0)), reverse=True)
self.videos.sort(key=lambda x: (x.height or 0, not x.stereo, float(x.bitrate or 0.0)), reverse=True)
for language in reversed(by_language or []):
if str(language) in ("all", "best"):
language = self._original_language(self.videos, exact_match)
@@ -486,10 +505,10 @@ class Tracks:
HDR10/HDR10+/DV tracks are hybrid ingredients. They only enter the standalone
pool when the user explicitly requested their range alongside HYBRID, so for
example `-r HYBRID` muxes only the hybrid while `-r HYBRID,HDR10P` also delivers
HDR10+.
HDR10+. A 3D track is never an ingredient: dovi_tool does not handle a second view.
"""
ingredient_ranges = (Video.Range.HDR10, Video.Range.HDR10P, Video.Range.DV)
hybrid_candidates = [v for v in videos if v.range in ingredient_ranges]
hybrid_candidates = [v for v in videos if v.range in ingredient_ranges and not v.stereo]
non_hybrid = [v for v in videos if v.range not in ingredient_ranges or v.range in non_hybrid_ranges]
return hybrid_candidates, non_hybrid
@@ -654,19 +673,21 @@ class Tracks:
"""
Multiplex the Tracks into an MP4 Container with MP4Box.
Only used for a codec Matroska cannot carry, DTS-UHD or MPEG-H, because MP4 carries
far less: it drops the forced, hearing-impaired and original flags, holds no
attachments at all, and leaves out a subtitle it has no format for rather than
mangling it. Losing those beats shipping audio no player can name.
Only used for what Matroska cannot carry: DTS-UHD or MPEG-H audio, and stereoscopic 3D
(MV-HEVC) video, whose second view mkvmerge drops. MP4 carries far less: it drops the
forced, hearing-impaired and original flags, holds no attachments at all, and leaves
out a subtitle it has no format for rather than mangling it. Losing those beats
shipping audio no player can name or a 3D film as 2D.
Returns the same (path, returncode, errors) shape as the Matroska mux.
"""
mp4box = getattr(binaries, "MP4Box", None)
if not mp4box:
raise RuntimeError("MP4Box (GPAC) not found, muxing DTS-UHD or MPEG-H audio needs it")
raise RuntimeError("MP4Box (GPAC) not found, muxing DTS-UHD or MPEG-H audio or 3D video needs it")
progress = progress or partial(lambda **kwargs: None)
stereo = any(v.stereo for v in self.videos)
cl = [str(mp4box)]
sample_base = self.sample_base
names: list[str] = []
@@ -685,7 +706,6 @@ class Tracks:
)
continue
events.emit(events.Types.TRACK_MULTIPLEX, track=track)
track_id += 1
source = track.path
candidate = config.directories.temp / f"mp4mux_{track.id}_{os.getpid()}_{threading.get_ident()}.mp4"
@@ -706,7 +726,8 @@ class Tracks:
cl.extend(["-add", f"{source}:lang={track.mux_language}" + (f":delay={delay}" if delay else "")])
name = "" if isinstance(track, Video) else (track.get_track_name() or "")
if name:
names.extend(["-name", f"{track_id}={name}"])
names.extend(["-name", f"{track_id + 1}={name}"])
track_id += mp4_track_count(source)
if not track_id:
raise ValueError("No tracks provided, at least one track must be provided.")
@@ -719,7 +740,7 @@ class Tracks:
title=sanitize_filename(title), random=f"{self.chapters.id}_{os.getpid()}_{threading.get_ident()}"
)
self.chapters.dump(chapters_path, fallback_name=config.chapter_fallback_name)
cl.extend(["-chap", str(chapters_path)])
cl.extend(["-chapqt" if stereo else "-chap", str(chapters_path)])
if self.attachments:
log_event(
@@ -749,7 +770,9 @@ class Tracks:
"muxer": str(mp4box),
"command": full_command,
"container": "mp4",
"reason": "DTS-UHD or MPEG-H audio has no Matroska CodecID",
"reason": "3D video has no Matroska mapping"
if stereo
else "DTS-UHD or MPEG-H audio has no Matroska CodecID",
},
)
@@ -778,7 +801,7 @@ class Tracks:
returncode = p.wait()
if returncode < 2 and any(MP4BOX_OPEN_FAILED.search(line) for line in errors):
returncode = 2
if config.sample and returncode == 0:
if config.sample and returncode == 0 and not stereo:
start, end = (seconds - sample_base for seconds in config.sample)
cut_path = output_path.with_suffix(".cut.mp4")
try:
@@ -791,6 +814,8 @@ class Tracks:
except RuntimeError as e:
returncode = 2
errors.append(f"MP4Box could not cut the sample window: {e}")
if stereo and returncode == 0 and not has_second_view(output_path):
logging.getLogger("Tracks").warning(f"The 3D mux of {output_path.name} lost its second view")
mux_duration_ms = round((time.monotonic() - mux_start_time) * 1000, 1)
output_size = output_path.stat().st_size if output_path.exists() else 0
@@ -868,7 +893,9 @@ class Tracks:
video.needs_repack = True
video.data["audio_language"] = lang_code
if any(at.path and at.path.exists() and has_mp4_only_audio_entry(at.path) for at in self.audio):
if any(v.stereo for v in self.videos) or any(
at.path and at.path.exists() and has_mp4_only_audio_entry(at.path) for at in self.audio
):
return self._mux_mp4(
title=title,
delete=delete,
+41 -4
View File
@@ -15,6 +15,7 @@ from langcodes import Language
from unshackle.core import binaries
from unshackle.core.config import config
from unshackle.core.drm.verify import has_second_view
from unshackle.core.tracks.subtitle import Subtitle
from unshackle.core.tracks.track import Track
from unshackle.core.utilities import FPS, get_boxes, log_event
@@ -228,6 +229,7 @@ class Video(Track):
dv_compatible_bitstream: bool = False,
hdr10plus: bool = False,
hdr10_base: bool = False,
stereo: bool = False,
**kwargs: Any,
) -> None:
"""
@@ -248,6 +250,7 @@ class Video(Track):
All strings will be cast to either a number or float.
hdr10plus: The DV track also carries HDR10+ metadata. The HDR10+ probe sets it.
hdr10_base: The DV track has an HDR10 base layer, as DV profile 8.1 does.
stereo: The track is stereoscopic 3D (MV-HEVC), with a second view for the other eye.
Note: If codec, bitrate, width, height, or fps is not specified, unshackle can
skip some checks or assume a value. Give as much information as possible.
@@ -298,6 +301,7 @@ class Video(Track):
self.dv_compatible_bitstream = dv_compatible_bitstream
self.hdr10plus = hdr10plus
self.hdr10_base = hdr10_base
self.stereo = stereo
self.hybrid_base_only = False
self.native_hybrid = False
@@ -315,6 +319,7 @@ class Video(Track):
"dv_compatible_bitstream": self.dv_compatible_bitstream,
"hdr10plus": self.hdr10plus,
"hdr10_base": self.hdr10_base,
"stereo": self.stereo,
}
)
return data
@@ -334,6 +339,7 @@ class Video(Track):
dv_compatible_bitstream=data.get("dv_compatible_bitstream", False),
hdr10plus=data.get("hdr10plus", False),
hdr10_base=data.get("hdr10_base", False),
stereo=data.get("stereo", False),
)
@staticmethod
@@ -347,6 +353,16 @@ class Video(Track):
"""Whether a codecs list names a Dolby Vision codec."""
return bool(Video.dv_fourccs(codecs))
@staticmethod
def is_stereo(
codecs: Optional[str], supplemental_codecs: Optional[str] = None, video_layout: Optional[str] = None
) -> bool:
"""Whether a track is stereoscopic 3D: the HLS REQ-VIDEO-LAYOUT names CH-STEREO, or a codec is DV profile 20."""
tokens = re.split(r"[\s,]+", f"{codecs or ''},{supplemental_codecs or ''}".lower())
return "CH-STEREO" in (video_layout or "").upper() or any(
re.match(r"dvh[1e]\.20(\.|$)", token) for token in tokens
)
@staticmethod
def is_dv_composite(
codec: Optional[Video.Codec], codecs: Optional[str], supplemental_codecs: Optional[str]
@@ -377,12 +393,15 @@ class Video(Track):
return f"{label}+DV" if self.dv_compatible_bitstream and self.range != Video.Range.DV else label
def __str__(self) -> str:
labels = filter(
None, [self.codec.value if self.codec else None, self.range_label, "3D" if self.stereo else None]
)
return " | ".join(
filter(
None,
[
"VID",
"[" + (", ".join(filter(None, [self.codec.value if self.codec else None, self.range_label]))) + "]",
f"[{', '.join(labels)}]",
str(self.language),
", ".join(
filter(
@@ -459,16 +478,34 @@ class Video(Track):
self.path = output_path
original_path.unlink()
def download(self, *args: Any, **kwargs: Any) -> None:
"""Download the track and mark it 3D when the file holds a second view; a 3D track is never repacked."""
super().download(*args, **kwargs)
if not self.stereo and self.path and has_second_view(self.path):
self.stereo = True
if self.stereo:
self.needs_repack = False
def repackage(self, bsf_v: Optional[str] = None) -> bool:
"""Remux with FFmpeg, except a 3D track: an FFmpeg remux rewrites its stereo and DV boxes."""
if self.stereo:
logging.getLogger("Video").warning(
f"Skipped the FFmpeg repack of 3D track {self.id}: FFmpeg rewrites its stereo boxes"
)
return False
return super().repackage(bsf_v)
def vui_bsf(self) -> Optional[str]:
"""Return the ``-bsf:v`` value that rewrites SPS VUI colour metadata to match ``self.range``.
Returns None when a rewrite is not necessary: SDR/DV/HYBRID ranges, non-AVC/HEVC codecs, a
missing file, or when the bitstream already ships the correct colour metadata. Otherwise
Returns None when a rewrite is not necessary or not safe: SDR/DV/HYBRID ranges, non-AVC/HEVC
codecs, a 3D track (an FFmpeg remux rewrites its stereo and DV boxes), a missing file, or when the
bitstream already ships the correct colour metadata. Otherwise
returns a ``{h264,hevc}_metadata=...`` string suitable for FFmpeg ``-bsf:v``.
"""
if not self.path or not self.path.exists():
return None
if self.codec not in (Video.Codec.AVC, Video.Codec.HEVC):
if self.codec not in (Video.Codec.AVC, Video.Codec.HEVC) or self.stereo:
return None
if self.range in (Video.Range.SDR, Video.Range.DV, Video.Range.HYBRID):
return None
+4 -4
View File
@@ -22,12 +22,12 @@ tag: "NOGRP"
# ? suffix, e.g. {year?}.
output_template:
# Scene-style naming (dot-separated)
movies: "{title}.{year?}.{repack?}.{edition?}.{lang_tag?}.{quality}.{source}.WEB-DL.{dual?}.{multi?}.{dubbed?}.{audio_full}.{atmos?}.{hdr?}.{hfr?}.{video}-{tag}"
series: "{title}.{year?}.{season_episode}.{episode_name?}.{repack?}.{edition?}.{lang_tag?}.{quality}.{source}.WEB-DL.{dual?}.{multi?}.{dubbed?}.{audio_full}.{atmos?}.{hdr?}.{hfr?}.{video}-{tag}"
movies: "{title}.{year?}.{repack?}.{edition?}.{lang_tag?}.{3d?}.{quality}.{source}.WEB-DL.{dual?}.{multi?}.{dubbed?}.{audio_full}.{atmos?}.{hdr?}.{hfr?}.{video}-{tag}"
series: "{title}.{year?}.{season_episode}.{episode_name?}.{repack?}.{edition?}.{lang_tag?}.{3d?}.{quality}.{source}.WEB-DL.{dual?}.{multi?}.{dubbed?}.{audio_full}.{atmos?}.{hdr?}.{hfr?}.{video}-{tag}"
songs: "{track_number}.{title}.{repack?}.{edition?}.{lang_tag?}.{source?}.WEB-DL.{audio_full}.{atmos?}-{tag}"
folder:
movies: "{title}.{year?}.{repack?}.{edition?}.{lang_tag?}.{quality}.{source}.WEB-DL.{dual?}.{multi?}.{dubbed?}.{audio_full}.{atmos?}.{hdr?}.{hfr?}.{video}-{tag}"
series: "{title}.{year?}.{repack?}.{edition?}.{lang_tag?}.{quality}.{source}.WEB-DL.{dual?}.{multi?}.{dubbed?}.{audio_full}.{atmos?}.{hdr?}.{hfr?}.{video}-{tag}"
movies: "{title}.{year?}.{repack?}.{edition?}.{lang_tag?}.{3d?}.{quality}.{source}.WEB-DL.{dual?}.{multi?}.{dubbed?}.{audio_full}.{atmos?}.{hdr?}.{hfr?}.{video}-{tag}"
series: "{title}.{year?}.{repack?}.{edition?}.{lang_tag?}.{3d?}.{quality}.{source}.WEB-DL.{dual?}.{multi?}.{dubbed?}.{audio_full}.{atmos?}.{hdr?}.{hfr?}.{video}-{tag}"
albums: "{album_artist} - {album} ({year?})"
#