bdmvkit 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- bdmvkit/__init__.py +16 -0
- bdmvkit/__main__.py +86 -0
- bdmvkit/clip.py +70 -0
- bdmvkit/disc.py +257 -0
- bdmvkit/encode.py +152 -0
- bdmvkit/errors.py +9 -0
- bdmvkit/formats.py +45 -0
- bdmvkit/hdmv.py +149 -0
- bdmvkit/igs.py +241 -0
- bdmvkit/m2ts.py +78 -0
- bdmvkit/pgs.py +260 -0
- bdmvkit/playlist.py +62 -0
- bdmvkit-0.2.0.dist-info/METADATA +165 -0
- bdmvkit-0.2.0.dist-info/RECORD +19 -0
- bdmvkit-0.2.0.dist-info/WHEEL +5 -0
- bdmvkit-0.2.0.dist-info/entry_points.txt +2 -0
- bdmvkit-0.2.0.dist-info/licenses/COPYING +674 -0
- bdmvkit-0.2.0.dist-info/licenses/COPYING.LESSER +165 -0
- bdmvkit-0.2.0.dist-info/top_level.txt +1 -0
bdmvkit/__init__.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""bdmvkit: Blu-ray (BDMV) authoring in pure Python.
|
|
2
|
+
|
|
3
|
+
* `bdmvkit.Disc`: a BDMV folder from encoded clips (titles, chapters, sound
|
|
4
|
+
and subtitle tracks, played in order) and menus (`MenuButton`).
|
|
5
|
+
* `bdmvkit.igs`: a menu's buttons as an Interactive Graphics stream.
|
|
6
|
+
* `bdmvkit.encode.encode_clip`: a clip encoded for Blu-ray with ffmpeg + x264.
|
|
7
|
+
* `bdmvkit.pgs`: PGS subtitle streams (.sup) from pictures.
|
|
8
|
+
* The files themselves: `playlist.mpls`, `clip.clpi`, `hdmv.index_bdmv`,
|
|
9
|
+
`hdmv.movie_objects` and the HDMV navigation commands, `m2ts.scan_m2ts`.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from .disc import Disc, MenuButton
|
|
13
|
+
from .errors import BdmvError
|
|
14
|
+
|
|
15
|
+
__version__ = "0.2.0"
|
|
16
|
+
__all__ = ["Disc", "MenuButton", "BdmvError"]
|
bdmvkit/__main__.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Command line:
|
|
2
|
+
|
|
3
|
+
bdmvkit encode SOURCE CLIP.m2ts [--fps 24000/1001] [--chapters 0,60,120] [--fast]
|
|
4
|
+
bdmvkit build DISC.json OUT_DIR
|
|
5
|
+
|
|
6
|
+
DISC.json: {"titles": [{"clip": "a.m2ts", "fps": "24000/1001",
|
|
7
|
+
"chapters": [0, 60], "audio": [["ac3", 2, "en"]], "subtitles": ["ja"],
|
|
8
|
+
"end": "menu:main"}], "menus": [{"id": "main", "background": "menu.png",
|
|
9
|
+
"buttons": [{"id": "play", "x": 160, "y": 700, "selected": "play.png",
|
|
10
|
+
"action": "title:1"}]}], "play_all": true}; paths are relative to the JSON
|
|
11
|
+
file. A button may also have "activated" and "normal" pictures, "nav"
|
|
12
|
+
({"up": id, ...}) and "auto"; a menu "default" (a button id) and "fps".
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import sys
|
|
19
|
+
|
|
20
|
+
from . import __version__
|
|
21
|
+
from .disc import Disc, MenuButton
|
|
22
|
+
from .encode import encode_clip
|
|
23
|
+
from .errors import BdmvError
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _fps(text):
|
|
27
|
+
num, _, den = str(text).partition("/")
|
|
28
|
+
return int(num), int(den or 1)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _times(text):
|
|
32
|
+
return [float(t) for t in text.split(",") if t.strip()] if text else [0.0]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def main(argv=None):
|
|
36
|
+
p = argparse.ArgumentParser(prog="bdmvkit", description="Blu-ray (BDMV) authoring.")
|
|
37
|
+
p.add_argument("--version", action="version", version=f"bdmvkit {__version__}")
|
|
38
|
+
sub = p.add_subparsers(dest="command", required=True)
|
|
39
|
+
s = sub.add_parser("encode", help="encode a video into a Blu-ray clip (ffmpeg + x264)")
|
|
40
|
+
s.add_argument("source")
|
|
41
|
+
s.add_argument("clip", help="the .m2ts to write")
|
|
42
|
+
s.add_argument("--fps", default="24000/1001", help="24000/1001, 25 or 30000/1001")
|
|
43
|
+
s.add_argument("--chapters", help="chapter starts in seconds, e.g. 0,60,120")
|
|
44
|
+
s.add_argument("--fast", action="store_true", help="quick, lower-quality encoding")
|
|
45
|
+
s = sub.add_parser("build", help="write a BDMV folder from encoded clips and menus")
|
|
46
|
+
s.add_argument("disc", help="the disc description (JSON)")
|
|
47
|
+
s.add_argument("out", help="the folder to write BDMV into")
|
|
48
|
+
s.add_argument("--fast", action="store_true", help="quick encoding of the menus")
|
|
49
|
+
args = p.parse_args(argv)
|
|
50
|
+
try:
|
|
51
|
+
if args.command == "encode":
|
|
52
|
+
encode_clip(args.source, args.clip, fps=_fps(args.fps),
|
|
53
|
+
chapters=_times(args.chapters), fast=args.fast)
|
|
54
|
+
print(args.clip)
|
|
55
|
+
return 0
|
|
56
|
+
with open(args.disc, encoding="utf-8") as f:
|
|
57
|
+
spec = json.load(f)
|
|
58
|
+
base = os.path.dirname(os.path.abspath(args.disc))
|
|
59
|
+
disc = Disc()
|
|
60
|
+
for t in spec.get("titles", []):
|
|
61
|
+
disc.add_title(os.path.join(base, t["clip"]), fps=_fps(t.get("fps", "24000/1001")),
|
|
62
|
+
chapters=t.get("chapters", [0]),
|
|
63
|
+
audio=[tuple(a) for a in t.get("audio", [["ac3", 2, "und"]])],
|
|
64
|
+
subtitles=t.get("subtitles", []), end=t.get("end"))
|
|
65
|
+
|
|
66
|
+
def file(name):
|
|
67
|
+
return os.path.join(base, name) if name else None
|
|
68
|
+
|
|
69
|
+
for m in spec.get("menus", []):
|
|
70
|
+
disc.add_menu(m["id"], file(m["background"]), [
|
|
71
|
+
MenuButton(b["id"], int(b["x"]), int(b["y"]), file(b["selected"]),
|
|
72
|
+
file(b.get("activated")), file(b.get("normal")), b.get("action"),
|
|
73
|
+
dict(b.get("nav", {})), bool(b.get("auto")))
|
|
74
|
+
for b in m["buttons"]], fps=_fps(m.get("fps", "24000/1001")),
|
|
75
|
+
default=m.get("default"))
|
|
76
|
+
res = disc.write(args.out, play_all=spec.get("play_all", True), fast=args.fast)
|
|
77
|
+
except (BdmvError, OSError, KeyError, ValueError) as e:
|
|
78
|
+
print(f"bdmvkit: {getattr(e, 'message', e)}", file=sys.stderr)
|
|
79
|
+
return 1
|
|
80
|
+
print(f"{args.out}: {res['titles']} title(s), {res['menus']} menu(s), "
|
|
81
|
+
f"chapters {res['chapters']}")
|
|
82
|
+
return 0
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
if __name__ == "__main__":
|
|
86
|
+
sys.exit(main())
|
bdmvkit/clip.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Clip information (.clpi): a clip's streams, time span and entry-point map."""
|
|
2
|
+
|
|
3
|
+
from .formats import (AUDIO_PID, CODING_H264, CODING_IG, IG_PID, PG_PID, PMT_PID, TS_RATE,
|
|
4
|
+
VERSION, VIDEO_PID)
|
|
5
|
+
from .hdmv import _pad, _u8, _u16, _u32
|
|
6
|
+
from .playlist import _audio_attr, _pg_attr
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def ep_map(entries):
|
|
10
|
+
"""EP_map() of the video stream: a coarse entry whenever the upper PTS or
|
|
11
|
+
packet-number bits change, a fine entry per key frame."""
|
|
12
|
+
coarse, fine = [], []
|
|
13
|
+
last = None
|
|
14
|
+
for i, (pts, spn, size) in enumerate(entries):
|
|
15
|
+
key = (pts >> 19, spn & 0xFFFE0000)
|
|
16
|
+
if key != last:
|
|
17
|
+
coarse.append((i, pts >> 19 & 0x3FFF, spn))
|
|
18
|
+
last = key
|
|
19
|
+
# I_end_position_offset: how many bytes the key picture takes, in steps
|
|
20
|
+
end = next((k for k, lim in enumerate((131072, 262144, 393216, 589824, 917504,
|
|
21
|
+
1310720), 1) if size < lim), 7)
|
|
22
|
+
fine.append(end << 28 | (pts >> 9 & 0x7FF) << 17 | spn & 0x1FFFF)
|
|
23
|
+
table = b"".join(_u32(ref << 14 | cpts) + _u32(cspn) for ref, cpts, cspn in coarse)
|
|
24
|
+
one = _u32(4 + len(table)) + table + b"".join(_u32(v) for v in fine)
|
|
25
|
+
# stream_PID, 10 reserved bits + EP_stream_type 1 (video), the coarse and
|
|
26
|
+
# fine counts (16 + 18 bits), then where the stream's map starts
|
|
27
|
+
counts = 1 << 34 | len(coarse) << 18 | len(fine)
|
|
28
|
+
head_len = 2 + 2 + 6 + 4 # (the address counts from the start of EP_map)
|
|
29
|
+
head = _u8(0) + _u8(1) + _u16(VIDEO_PID) + counts.to_bytes(6, "big") + _u32(head_len)
|
|
30
|
+
return head + one
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def clpi(scan, video_format, frame_rate, audio, pg=(), ig=False):
|
|
34
|
+
"""Clip information of one .m2ts: its streams, time span and EP map.
|
|
35
|
+
audio: [(coding, channels, lang3)]; pg: [lang3]; ig: menu buttons."""
|
|
36
|
+
ts_type = bytes([0x80]) + b"HDMV" + bytes(9) + bytes(16)
|
|
37
|
+
info = _u16(0) + _u8(1) + _u8(1) + _u32(0) + _u32(TS_RATE) + _u32(scan.packets) + \
|
|
38
|
+
bytes(128) + _u16(len(ts_type)) + ts_type
|
|
39
|
+
info = _u32(len(info)) + info
|
|
40
|
+
start, end = scan.first_pts // 2, scan.end_pts // 2
|
|
41
|
+
seq = _u8(0) + _u8(1) + _u32(0) + _u8(1) + _u8(0) + _u16(scan.pcr_pid) + _u32(0) + \
|
|
42
|
+
_u32(start) + _u32(end)
|
|
43
|
+
seq = _u32(len(seq)) + seq
|
|
44
|
+
isrc = b"0" * 12
|
|
45
|
+
streams = _u16(VIDEO_PID)
|
|
46
|
+
coding = bytes([CODING_H264, video_format << 4 | frame_rate, 3 << 4, 0, 0]) + isrc + bytes(4)
|
|
47
|
+
streams += _u8(len(coding)) + coding
|
|
48
|
+
for k, tr in enumerate(audio):
|
|
49
|
+
coding = _audio_attr(tr) + isrc + bytes(4)
|
|
50
|
+
streams += _u16(AUDIO_PID + k) + _u8(len(coding)) + coding
|
|
51
|
+
for k, lang in enumerate(pg):
|
|
52
|
+
coding = _pg_attr(lang) + isrc + bytes(4)
|
|
53
|
+
streams += _u16(PG_PID + k) + _u8(len(coding)) + coding
|
|
54
|
+
if ig:
|
|
55
|
+
coding = _pg_attr("und", CODING_IG) + isrc + bytes(4)
|
|
56
|
+
streams += _u16(IG_PID) + _u8(len(coding)) + coding
|
|
57
|
+
prog = _u8(0) + _u8(1) + _u32(0) + _u16(PMT_PID) + \
|
|
58
|
+
_u8(1 + len(audio) + len(pg) + int(ig)) + _u8(0) + streams
|
|
59
|
+
prog = _u32(len(prog)) + prog
|
|
60
|
+
cpi = _u16(1) + ep_map(scan.entries) # 12 reserved bits, CPI_type 1 (EP map)
|
|
61
|
+
cpi = _u32(len(cpi)) + cpi
|
|
62
|
+
parts = [info, seq, prog, cpi, _u32(0)] # (the last one: no clip marks)
|
|
63
|
+
pos, offsets, body = 40, [], b""
|
|
64
|
+
for part in parts:
|
|
65
|
+
offsets.append(pos)
|
|
66
|
+
part = _pad(part, 4)
|
|
67
|
+
body += part
|
|
68
|
+
pos += len(part)
|
|
69
|
+
return b"HDMV" + VERSION + b"".join(_u32(o) for o in offsets[1:]) + _u32(0) + \
|
|
70
|
+
bytes(12) + body
|
bdmvkit/disc.py
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
"""A BDMV folder from encoded clips: titles with chapters, played from the
|
|
2
|
+
first one on (or each on its own), with their sound and subtitle tracks.
|
|
3
|
+
|
|
4
|
+
disc = Disc()
|
|
5
|
+
disc.add_title("feature.m2ts", chapters=[0, 300, 600], audio=[("ac3", 6, "en")],
|
|
6
|
+
subtitles=["en", "ja"])
|
|
7
|
+
disc.add_title("extras.m2ts")
|
|
8
|
+
disc.write("out") # out/BDMV/...
|
|
9
|
+
|
|
10
|
+
Each title is a playlist with a chapter mark at every chapter (on the key
|
|
11
|
+
frame nearest to it: encode the clip with key frames there, as
|
|
12
|
+
encode.encode_clip does) and a movie object that plays it. First play and
|
|
13
|
+
the Top Menu key start title 1; with `play_all`, each title goes on to the
|
|
14
|
+
next when it ends. Clips are BDAV streams with the PIDs formats.py names.
|
|
15
|
+
|
|
16
|
+
Menus: a picture with buttons, each linked to a title, a chapter or
|
|
17
|
+
another menu:
|
|
18
|
+
|
|
19
|
+
disc.add_menu("main", "menu.png", [
|
|
20
|
+
MenuButton("play", 160, 700, "play.png", action="title:1"),
|
|
21
|
+
MenuButton("scenes", 160, 800, "scenes.png", "scenes-lit.png", action="menu:scenes"),
|
|
22
|
+
])
|
|
23
|
+
disc.add_title("feature.m2ts", end="menu:main") # back to the menu at the end
|
|
24
|
+
|
|
25
|
+
A button's pictures are its selected (and activated) look, drawn over the
|
|
26
|
+
menu where it is; with a `normal` picture it is drawn there before it is
|
|
27
|
+
selected too. The arrow keys go to the nearest button that way unless
|
|
28
|
+
`nav` says otherwise. Each menu is an interactive title after the titles:
|
|
29
|
+
its picture held as a still, its buttons an IG stream (igs.py) in its clip.
|
|
30
|
+
With menus, first play and the Top Menu key open the first menu.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
import dataclasses
|
|
34
|
+
import os
|
|
35
|
+
import shutil
|
|
36
|
+
|
|
37
|
+
import tempfile
|
|
38
|
+
|
|
39
|
+
from . import clip as clip_info
|
|
40
|
+
from . import encode, hdmv, igs, playlist
|
|
41
|
+
from .errors import BdmvError
|
|
42
|
+
from .formats import CODECS, FORMATS, HEIGHT, WIDTH, lang3
|
|
43
|
+
from .m2ts import scan_m2ts
|
|
44
|
+
|
|
45
|
+
DIRS = ["BDMV/PLAYLIST", "BDMV/CLIPINF", "BDMV/STREAM", "BDMV/AUXDATA", "BDMV/BDJO",
|
|
46
|
+
"BDMV/JAR", "BDMV/META", "BDMV/BACKUP/PLAYLIST", "BDMV/BACKUP/CLIPINF",
|
|
47
|
+
"BDMV/BACKUP/BDJO", "CERTIFICATE/BACKUP"]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclasses.dataclass
|
|
51
|
+
class Title:
|
|
52
|
+
clip: str # an encoded .m2ts
|
|
53
|
+
fps: tuple = (24000, 1001) # its frame rate: 24000/1001, 25/1 or 30000/1001
|
|
54
|
+
chapters: list = dataclasses.field(default_factory=lambda: [0.0]) # starts, seconds
|
|
55
|
+
audio: list = dataclasses.field(default_factory=lambda: [("ac3", 2, "und")])
|
|
56
|
+
subtitles: list = dataclasses.field(default_factory=list) # PG tracks: languages
|
|
57
|
+
end: str = None # after it: "title:<n>", "menu:<id>" (None: the next with play_all)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclasses.dataclass
|
|
61
|
+
class MenuButton:
|
|
62
|
+
id: str
|
|
63
|
+
x: int # top-left on the 1920 x 1080 menu
|
|
64
|
+
y: int
|
|
65
|
+
selected: str # picture files (PNG ...): selected,
|
|
66
|
+
activated: str = None # activated (default: the selected one),
|
|
67
|
+
normal: str = None # and before it is selected (default: nothing)
|
|
68
|
+
action: str = None # "title:<n>", "chapter:<n>:<c>", "menu:<id>"
|
|
69
|
+
nav: dict = dataclasses.field(default_factory=dict) # direction -> button id
|
|
70
|
+
auto: bool = False # runs as soon as it is selected
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclasses.dataclass
|
|
74
|
+
class Menu:
|
|
75
|
+
id: str
|
|
76
|
+
background: str # picture file, fitted into the HD frame
|
|
77
|
+
buttons: list
|
|
78
|
+
fps: tuple = (24000, 1001)
|
|
79
|
+
default: str = None # the button selected first (default: the first)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def chapter_marks(chapters, scan):
|
|
83
|
+
"""Chapter marks (45 kHz): each chapter on the key frame nearest to it,
|
|
84
|
+
the first at the start of the clip."""
|
|
85
|
+
marks = [scan.first_pts // 2]
|
|
86
|
+
keys = [pts for pts, _, _ in scan.entries]
|
|
87
|
+
for t in sorted(c for c in chapters if c > 0):
|
|
88
|
+
want = scan.first_pts + round(t * 90000)
|
|
89
|
+
near = min(keys, key=lambda k: abs(k - want))
|
|
90
|
+
if near // 2 > marks[-1]:
|
|
91
|
+
marks.append(near // 2)
|
|
92
|
+
return marks
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _write(out_dir, rel, data):
|
|
96
|
+
for base in ("BDMV", os.path.join("BDMV", "BACKUP")):
|
|
97
|
+
with open(os.path.join(out_dir, base, rel), "wb") as f:
|
|
98
|
+
f.write(data)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class Disc:
|
|
102
|
+
def __init__(self):
|
|
103
|
+
self.titles = []
|
|
104
|
+
self.menus = []
|
|
105
|
+
|
|
106
|
+
def add_title(self, clip, fps=(24000, 1001), chapters=(0.0,), audio=(("ac3", 2, "und"),),
|
|
107
|
+
subtitles=(), end=None):
|
|
108
|
+
"""Add a title; returns its number (1-based). audio: (codec, channels,
|
|
109
|
+
language) per track, codec "ac3", "lpcm" or "dts"; languages are ISO
|
|
110
|
+
639-1 ("en") or 639-2 ("eng") codes. end: where it goes when it ends,
|
|
111
|
+
"title:<n>" or "menu:<id>" (default: the next title with play_all)."""
|
|
112
|
+
if tuple(fps) not in FORMATS:
|
|
113
|
+
raise BdmvError(f"{fps[0]}/{fps[1]} fps has no Blu-ray format")
|
|
114
|
+
for codec, _, _ in audio:
|
|
115
|
+
if codec not in CODECS:
|
|
116
|
+
raise BdmvError(f"unknown audio codec {codec!r} (ac3, lpcm or dts)")
|
|
117
|
+
if not audio:
|
|
118
|
+
raise BdmvError("a title needs at least one audio track")
|
|
119
|
+
self.titles.append(Title(clip, tuple(fps), list(chapters) or [0.0], list(audio),
|
|
120
|
+
list(subtitles), end))
|
|
121
|
+
return len(self.titles)
|
|
122
|
+
|
|
123
|
+
def add_menu(self, menu_id, background, buttons, fps=(24000, 1001), default=None):
|
|
124
|
+
"""Add a menu: `background` a picture, `buttons` MenuButtons. The
|
|
125
|
+
menu's clip is made when the disc is written (ffmpeg)."""
|
|
126
|
+
if tuple(fps) not in FORMATS:
|
|
127
|
+
raise BdmvError(f"{fps[0]}/{fps[1]} fps has no Blu-ray format")
|
|
128
|
+
if any(m.id == menu_id for m in self.menus):
|
|
129
|
+
raise BdmvError(f"there is a menu {menu_id!r} already")
|
|
130
|
+
if not buttons:
|
|
131
|
+
raise BdmvError(f"menu {menu_id!r} needs at least one button")
|
|
132
|
+
self.menus.append(Menu(menu_id, background, list(buttons), tuple(fps), default))
|
|
133
|
+
|
|
134
|
+
def _links(self):
|
|
135
|
+
"""{action: commands} for every link the titles and buttons use, and
|
|
136
|
+
the chapter objects [(title, chapter)] they need."""
|
|
137
|
+
n_titles = len(self.titles)
|
|
138
|
+
menu_title = {m.id: n_titles + k for k, m in enumerate(self.menus, 1)}
|
|
139
|
+
actions = [t.end for t in self.titles if t.end] + \
|
|
140
|
+
[b.action for m in self.menus for b in m.buttons if b.action]
|
|
141
|
+
links, chapter_objects = {}, []
|
|
142
|
+
for a in actions:
|
|
143
|
+
kind, _, rest = a.partition(":")
|
|
144
|
+
if kind == "title" and rest.isdigit() and 1 <= int(rest) <= n_titles:
|
|
145
|
+
links[a] = [hdmv.jump_title(int(rest))]
|
|
146
|
+
elif kind == "menu" and rest in menu_title:
|
|
147
|
+
links[a] = [hdmv.jump_title(menu_title[rest])]
|
|
148
|
+
elif kind == "chapter":
|
|
149
|
+
n, _, c = rest.partition(":")
|
|
150
|
+
if not (n.isdigit() and c.isdigit() and 1 <= int(n) <= n_titles):
|
|
151
|
+
raise BdmvError(f"{a!r}: no such title")
|
|
152
|
+
key = (int(n), int(c))
|
|
153
|
+
if key not in chapter_objects:
|
|
154
|
+
chapter_objects.append(key)
|
|
155
|
+
links[a] = key # (its object's number: once known)
|
|
156
|
+
else:
|
|
157
|
+
raise BdmvError(f"{a!r}: not title:<n>, chapter:<n>:<c> or menu:<id> "
|
|
158
|
+
"of this disc")
|
|
159
|
+
return links, chapter_objects, menu_title
|
|
160
|
+
|
|
161
|
+
def write(self, out_dir, move=False, play_all=True, fast=False):
|
|
162
|
+
"""Write the BDMV folder into `out_dir` (copying the clips into
|
|
163
|
+
BDMV/STREAM, or moving them with `move`; menu clips are encoded,
|
|
164
|
+
quickly with `fast`). Returns {"titles", "menus", "playlists",
|
|
165
|
+
"chapters"}."""
|
|
166
|
+
if not self.titles:
|
|
167
|
+
raise BdmvError("a disc needs at least one title")
|
|
168
|
+
links, chapter_objects, menu_title = self._links()
|
|
169
|
+
n_titles = len(self.titles)
|
|
170
|
+
# movie objects: one per title, one per menu, first play, top menu,
|
|
171
|
+
# then one per chapter a link starts
|
|
172
|
+
first_chapter_object = n_titles + len(self.menus) + 2
|
|
173
|
+
for a, v in links.items():
|
|
174
|
+
if isinstance(v, tuple):
|
|
175
|
+
links[a] = [hdmv.jump_object(first_chapter_object + chapter_objects.index(v))]
|
|
176
|
+
for d in DIRS:
|
|
177
|
+
os.makedirs(os.path.join(out_dir, d), exist_ok=True)
|
|
178
|
+
objects, chapters = [], []
|
|
179
|
+
for n, t in enumerate(self.titles, 1):
|
|
180
|
+
name = f"{n:05d}"
|
|
181
|
+
scan = scan_m2ts(t.clip)
|
|
182
|
+
video_format, frame_rate = FORMATS[t.fps]
|
|
183
|
+
audio = [(CODECS[c], ch, _lang(lang)) for c, ch, lang in t.audio]
|
|
184
|
+
pg = [_lang(lang) for lang in t.subtitles]
|
|
185
|
+
marks = chapter_marks(t.chapters, scan)
|
|
186
|
+
chapters.append(len(marks))
|
|
187
|
+
_write(out_dir, os.path.join("CLIPINF", f"{name}.clpi"),
|
|
188
|
+
clip_info.clpi(scan, video_format, frame_rate, audio, pg))
|
|
189
|
+
item = (name, marks[0], scan.end_pts // 2, video_format, frame_rate, audio, pg,
|
|
190
|
+
False, 0)
|
|
191
|
+
_write(out_dir, os.path.join("PLAYLIST", f"{name}.mpls"),
|
|
192
|
+
playlist.mpls([item], [(0, m) for m in marks]))
|
|
193
|
+
dest = os.path.join(out_dir, "BDMV", "STREAM", f"{name}.m2ts")
|
|
194
|
+
if os.path.abspath(t.clip) != os.path.abspath(dest):
|
|
195
|
+
(shutil.move if move else shutil.copyfile)(t.clip, dest)
|
|
196
|
+
objects.append([hdmv.play_pl(n)] + self._end(t, n, play_all, links))
|
|
197
|
+
for k, m in enumerate(self.menus, n_titles + 1):
|
|
198
|
+
self._write_menu(out_dir, k, m, links, fast)
|
|
199
|
+
objects.append([hdmv.play_pl(k)]) # (its last picture held: a still)
|
|
200
|
+
# first play and the Top Menu key: the first menu, or title 1
|
|
201
|
+
objects.append([hdmv.jump_title(n_titles + 1 if self.menus else 1)])
|
|
202
|
+
start = len(objects) - 1
|
|
203
|
+
objects.append([hdmv.jump_title(n_titles + 1 if self.menus else 1)])
|
|
204
|
+
for n, c in chapter_objects:
|
|
205
|
+
t = self.titles[n - 1]
|
|
206
|
+
if c > chapters[n - 1]:
|
|
207
|
+
raise BdmvError(f"chapter:{n}:{c}: title {n} has {chapters[n - 1]} chapters")
|
|
208
|
+
objects.append([hdmv.play_pl_mark(n, c - 1)] + self._end(t, n, play_all, links))
|
|
209
|
+
first = FORMATS[self.titles[0].fps]
|
|
210
|
+
total = n_titles + len(self.menus)
|
|
211
|
+
_write(out_dir, "index.bdmv", hdmv.index_bdmv(
|
|
212
|
+
list(range(total)), start, start + 1, *first,
|
|
213
|
+
interactive=range(n_titles + 1, total + 1)))
|
|
214
|
+
_write(out_dir, "MovieObject.bdmv", hdmv.movie_objects(objects))
|
|
215
|
+
return {"titles": n_titles, "menus": len(self.menus),
|
|
216
|
+
"playlists": [f"{n:05d}.mpls" for n in range(1, total + 1)],
|
|
217
|
+
"chapters": chapters}
|
|
218
|
+
|
|
219
|
+
def _end(self, t, n, play_all, links):
|
|
220
|
+
if t.end:
|
|
221
|
+
return list(links[t.end])
|
|
222
|
+
return [hdmv.jump_title(n + 1)] if play_all and n < len(self.titles) else []
|
|
223
|
+
|
|
224
|
+
def _write_menu(self, out_dir, k, m, links, fast):
|
|
225
|
+
"""Menu `m` as title / playlist / clip k: its buttons' pictures read,
|
|
226
|
+
the IG stream made and encoded with its picture into the clip."""
|
|
227
|
+
name = f"{k:05d}"
|
|
228
|
+
buttons = []
|
|
229
|
+
for b in m.buttons:
|
|
230
|
+
w, h, sel = encode.picture(b.selected)
|
|
231
|
+
if b.x < 0 or b.y < 0 or b.x + w > WIDTH or b.y + h > HEIGHT:
|
|
232
|
+
raise BdmvError(f"menu {m.id!r}: button {b.id!r} ({w} x {h} at {b.x}, {b.y}) "
|
|
233
|
+
f"is not inside the {WIDTH} x {HEIGHT} frame")
|
|
234
|
+
act = encode.picture(b.activated, (w, h))[2] if b.activated else None
|
|
235
|
+
nor = encode.picture(b.normal, (w, h))[2] if b.normal else None
|
|
236
|
+
buttons.append(igs.Button(b.id, b.x, b.y, w, h, sel, act, nor,
|
|
237
|
+
list(links[b.action]) if b.action else [],
|
|
238
|
+
dict(b.nav), b.auto))
|
|
239
|
+
video_format, frame_rate = FORMATS[m.fps]
|
|
240
|
+
dest = os.path.join(out_dir, "BDMV", "STREAM", f"{name}.m2ts")
|
|
241
|
+
with tempfile.TemporaryDirectory(prefix="bdmvkit-") as work:
|
|
242
|
+
sup = os.path.join(work, "menu.sup")
|
|
243
|
+
with open(sup, "wb") as f:
|
|
244
|
+
f.write(igs.ig_stream(buttons, frame_rate, default=m.default))
|
|
245
|
+
encode.encode_menu(m.background, sup, dest, m.fps, fast=fast)
|
|
246
|
+
scan = scan_m2ts(dest)
|
|
247
|
+
_write(out_dir, os.path.join("CLIPINF", f"{name}.clpi"),
|
|
248
|
+
clip_info.clpi(scan, video_format, frame_rate, [], [], ig=True))
|
|
249
|
+
item = (name, scan.first_pts // 2, scan.end_pts // 2, video_format, frame_rate, [], [],
|
|
250
|
+
True, 2)
|
|
251
|
+
_write(out_dir, os.path.join("PLAYLIST", f"{name}.mpls"),
|
|
252
|
+
playlist.mpls([item], [(0, scan.first_pts // 2)]))
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _lang(code):
|
|
256
|
+
code = (code or "").lower()
|
|
257
|
+
return code if len(code) == 3 and code.isalpha() else lang3(code)
|
bdmvkit/encode.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Encoding a video into a Blu-ray clip (.m2ts) with ffmpeg and x264.
|
|
2
|
+
|
|
3
|
+
The picture is fitted into the 1920 x 1080 frame (letter- or pillarboxed),
|
|
4
|
+
encoded as H.264 High at level 4.1 with x264's Blu-ray settings (at most a
|
|
5
|
+
second between key frames, key frames at the chapters), and muxed with its
|
|
6
|
+
sound and any PGS subtitle streams into a BDAV transport stream with the
|
|
7
|
+
PIDs the playlists name. 25 and 29.97 fps become progressive frames in an
|
|
8
|
+
interlaced (1080i) stream, as the format has no 1080p at those rates.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import shutil
|
|
12
|
+
import subprocess
|
|
13
|
+
|
|
14
|
+
from .errors import BdmvError
|
|
15
|
+
from .formats import (AUDIO_PID, FORMATS, HEIGHT, IG_PID, MAX_VIDEO_BPS, PG_PID, PMT_PID,
|
|
16
|
+
VBV_BUFFER, VIDEO_PID, WIDTH)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def video_args(fps, max_bps=MAX_VIDEO_BPS, fast=False, keys=()):
|
|
20
|
+
"""x264 options for a Blu-ray video stream at `fps` ((num, den)); `keys`:
|
|
21
|
+
times (s) to make key frames, e.g. the chapters."""
|
|
22
|
+
if fps not in FORMATS:
|
|
23
|
+
raise BdmvError(f"{fps[0]}/{fps[1]} fps has no Blu-ray format "
|
|
24
|
+
f"(use one of {', '.join(f'{a}/{b}' for a, b in FORMATS)})")
|
|
25
|
+
gop = round(fps[0] / fps[1]) # at most one second between key frames
|
|
26
|
+
x264 = (f"bluray-compat=1:slices=4:aud=1:nal-hrd=vbr:keyint={gop}:min-keyint=1:"
|
|
27
|
+
"force-cfr=1")
|
|
28
|
+
if FORMATS[fps][0] == 4:
|
|
29
|
+
x264 += ":fake-interlaced=1:pic-struct=1"
|
|
30
|
+
args = ["-c:v", "libx264", "-preset", "ultrafast" if fast else "medium",
|
|
31
|
+
"-crf", "24" if fast else "19", "-profile:v", "high", "-level", "4.1",
|
|
32
|
+
"-maxrate", str(max_bps), "-bufsize", str(VBV_BUFFER), "-x264-params", x264,
|
|
33
|
+
"-r", f"{fps[0]}/{fps[1]}", "-pix_fmt", "yuv420p", "-aspect", "16:9",
|
|
34
|
+
"-color_primaries", "bt709", "-color_trc", "bt709", "-colorspace", "bt709",
|
|
35
|
+
"-forced-idr", "1"]
|
|
36
|
+
if keys:
|
|
37
|
+
args += ["-force_key_frames", ",".join(f"{t:.6f}" for t in sorted(keys))]
|
|
38
|
+
return args
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def audio_args(codec="ac3", channels=2, k=0, bitrate=None):
|
|
42
|
+
"""Options for audio output stream k: "ac3", "lpcm" (16-bit stereo) or
|
|
43
|
+
"copy" (as it is, e.g. AC-3 or DTS)."""
|
|
44
|
+
if codec == "copy":
|
|
45
|
+
return [f"-c:a:{k}", "copy"]
|
|
46
|
+
if codec == "lpcm":
|
|
47
|
+
return [f"-c:a:{k}", "pcm_bluray", f"-sample_fmt:a:{k}", "s16", f"-ar:a:{k}", "48000",
|
|
48
|
+
f"-ac:a:{k}", "2"]
|
|
49
|
+
if codec != "ac3":
|
|
50
|
+
raise BdmvError(f"unknown audio codec {codec!r} (ac3, lpcm or copy)")
|
|
51
|
+
bitrate = bitrate or (448000 if channels > 2 else 192000)
|
|
52
|
+
return [f"-c:a:{k}", "ac3", f"-b:a:{k}", str(bitrate), f"-ar:a:{k}", "48000",
|
|
53
|
+
f"-ac:a:{k}", str(channels)]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def fit_filter(fps):
|
|
57
|
+
"""The picture fitted into the HD frame, square pixels, at `fps`."""
|
|
58
|
+
return (f"scale={WIDTH}:{HEIGHT}:force_original_aspect_ratio=decrease,"
|
|
59
|
+
f"pad={WIDTH}:{HEIGHT}:(ow-iw)/2:(oh-ih)/2,setsar=1,fps={fps[0]}/{fps[1]}")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def encode_clip(source, out, fps=(24000, 1001), chapters=(), audio="ac3", channels=2,
|
|
63
|
+
sups=(), max_bps=MAX_VIDEO_BPS, fast=False, ffmpeg=None):
|
|
64
|
+
"""Encode `source` (any video ffmpeg reads) into the Blu-ray clip `out`:
|
|
65
|
+
its first audio stream, or silence when it has none. `chapters`: times
|
|
66
|
+
(s) that become key frames, so chapter marks land on them. `sups`: PGS
|
|
67
|
+
streams (.sup, e.g. from pgs.write_sup) muxed in as subtitle tracks."""
|
|
68
|
+
ffmpeg = ffmpeg or shutil.which("ffmpeg")
|
|
69
|
+
if not ffmpeg:
|
|
70
|
+
raise BdmvError("encoding needs ffmpeg (with libx264)")
|
|
71
|
+
cmd = [ffmpeg, "-hide_banner", "-nostdin", "-v", "error", "-y", "-i", source]
|
|
72
|
+
if _has_audio(source, ffmpeg):
|
|
73
|
+
sound = ["-map", "0:a:0"]
|
|
74
|
+
else:
|
|
75
|
+
cmd += ["-f", "lavfi", "-i", "anullsrc=channel_layout=stereo:sample_rate=48000"]
|
|
76
|
+
sound = ["-map", "1:a:0", "-shortest"]
|
|
77
|
+
first_sup = cmd.count("-i")
|
|
78
|
+
for sup in sups:
|
|
79
|
+
cmd += ["-i", sup]
|
|
80
|
+
cmd += ["-filter_complex", f"[0:v:0]{fit_filter(fps)}[v]", "-map", "[v]"] + sound
|
|
81
|
+
for k in range(len(sups)):
|
|
82
|
+
cmd += ["-map", f"{first_sup + k}:s:0"]
|
|
83
|
+
cmd += video_args(fps, max_bps, fast, [t for t in chapters if t > 0])
|
|
84
|
+
cmd += audio_args(audio, channels)
|
|
85
|
+
if sups:
|
|
86
|
+
cmd += ["-c:s", "copy"]
|
|
87
|
+
cmd += ["-streamid", f"0:{VIDEO_PID}", "-streamid", f"1:{AUDIO_PID}"]
|
|
88
|
+
for k in range(len(sups)):
|
|
89
|
+
cmd += ["-streamid", f"{2 + k}:{PG_PID + k}"]
|
|
90
|
+
cmd += ["-f", "mpegts", "-mpegts_m2ts_mode", "1", "-mpegts_pmt_start_pid", str(PMT_PID), out]
|
|
91
|
+
p = subprocess.run(cmd, capture_output=True, text=True)
|
|
92
|
+
if p.returncode != 0:
|
|
93
|
+
raise BdmvError(f"ffmpeg could not encode {source}: {p.stderr.strip()[-800:]}")
|
|
94
|
+
return out
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _has_audio(source, ffmpeg):
|
|
98
|
+
probe = shutil.which("ffprobe") or ffmpeg.replace("ffmpeg", "ffprobe")
|
|
99
|
+
p = subprocess.run([probe, "-v", "error", "-select_streams", "a", "-show_entries",
|
|
100
|
+
"stream=index", "-of", "csv=p=0", source], capture_output=True, text=True)
|
|
101
|
+
return bool(p.stdout.strip())
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def picture(path, size=None, ffmpeg=None):
|
|
105
|
+
"""A picture file (PNG, JPEG ...) as (width, height, RGBA bytes), scaled
|
|
106
|
+
to `size` (w, h) when given."""
|
|
107
|
+
ffmpeg = ffmpeg or shutil.which("ffmpeg")
|
|
108
|
+
if not ffmpeg:
|
|
109
|
+
raise BdmvError("reading pictures needs ffmpeg")
|
|
110
|
+
if size is None:
|
|
111
|
+
probe = shutil.which("ffprobe") or ffmpeg.replace("ffmpeg", "ffprobe")
|
|
112
|
+
p = subprocess.run([probe, "-v", "error", "-select_streams", "v:0", "-show_entries",
|
|
113
|
+
"stream=width,height", "-of", "csv=p=0", path],
|
|
114
|
+
capture_output=True, text=True)
|
|
115
|
+
try:
|
|
116
|
+
w, h = (int(v) for v in p.stdout.strip().split(",")[:2])
|
|
117
|
+
except ValueError:
|
|
118
|
+
raise BdmvError(f"cannot read the picture {path}")
|
|
119
|
+
else:
|
|
120
|
+
w, h = size
|
|
121
|
+
p = subprocess.run([ffmpeg, "-hide_banner", "-nostdin", "-v", "error", "-i", path,
|
|
122
|
+
"-frames:v", "1", "-vf", f"scale={w}:{h}", "-f", "rawvideo",
|
|
123
|
+
"-pix_fmt", "rgba", "-"], capture_output=True)
|
|
124
|
+
if p.returncode != 0 or len(p.stdout) != w * h * 4:
|
|
125
|
+
why = p.stderr.decode(errors="replace")[-300:]
|
|
126
|
+
raise BdmvError(f"cannot read the picture {path}: {why}")
|
|
127
|
+
return w, h, p.stdout
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def encode_menu(background, sup, out, fps=(24000, 1001), seconds=1.0, fast=False, ffmpeg=None):
|
|
131
|
+
"""A menu clip: the picture `background` (fitted into the HD frame) for
|
|
132
|
+
`seconds`, with the IG stream `sup` (igs.ig_stream) as its buttons; the
|
|
133
|
+
playlist holds its last picture."""
|
|
134
|
+
ffmpeg = ffmpeg or shutil.which("ffmpeg")
|
|
135
|
+
if not ffmpeg:
|
|
136
|
+
raise BdmvError("encoding needs ffmpeg (with libx264)")
|
|
137
|
+
from .igs import patch_ig_stream_type
|
|
138
|
+
num, den = fps
|
|
139
|
+
cmd = [ffmpeg, "-hide_banner", "-nostdin", "-v", "error", "-y",
|
|
140
|
+
"-loop", "1", "-framerate", f"{num}/{den}", "-t", f"{seconds:.3f}", "-i", background,
|
|
141
|
+
"-f", "sup", "-i", sup,
|
|
142
|
+
"-filter_complex", f"[0:v]{fit_filter(fps)},format=yuv420p[v]",
|
|
143
|
+
"-map", "[v]", "-map", "1:0"] + video_args(fps, MAX_VIDEO_BPS, fast) + \
|
|
144
|
+
["-c:s", "copy", "-streamid", f"0:{VIDEO_PID}", "-streamid", f"1:{IG_PID}",
|
|
145
|
+
"-t", f"{seconds:.3f}", "-f", "mpegts", "-mpegts_m2ts_mode", "1",
|
|
146
|
+
"-mpegts_pmt_start_pid", str(PMT_PID), out]
|
|
147
|
+
p = subprocess.run(cmd, capture_output=True, text=True)
|
|
148
|
+
if p.returncode != 0:
|
|
149
|
+
raise BdmvError(f"ffmpeg could not encode the menu {background}: "
|
|
150
|
+
f"{p.stderr.strip()[-800:]}")
|
|
151
|
+
patch_ig_stream_type(out)
|
|
152
|
+
return out
|
bdmvkit/errors.py
ADDED
bdmvkit/formats.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Numbers of the BD-ROM format bdmvkit writes: stream PIDs, coding types,
|
|
2
|
+
video format and frame rate codes, languages."""
|
|
3
|
+
|
|
4
|
+
VERSION = b"0200" # of the navigation files
|
|
5
|
+
WIDTH, HEIGHT = 1920, 1080
|
|
6
|
+
|
|
7
|
+
MAX_VIDEO_BPS = 40_000_000 # primary video limit
|
|
8
|
+
VBV_BUFFER = 30_000_000 # coded picture buffer at level 4.1
|
|
9
|
+
TS_RATE = 48_000_000 // 8 # ts_recording_rate: the 48 Mbit/s maximum, in bytes/s
|
|
10
|
+
|
|
11
|
+
VIDEO_PID = 0x1011
|
|
12
|
+
AUDIO_PID = 0x1100 # + track number
|
|
13
|
+
PG_PID = 0x1200 # + subtitle track number
|
|
14
|
+
IG_PID = 0x1400 # a menu's buttons (Interactive Graphics)
|
|
15
|
+
PMT_PID = 0x0100
|
|
16
|
+
|
|
17
|
+
# frame rate -> (video_format, frame_rate) codes; 25 and 29.97 fps are coded
|
|
18
|
+
# as progressive frames in an interlaced (1080i) stream, as the format has
|
|
19
|
+
# no 1080p at those rates
|
|
20
|
+
FORMATS = {
|
|
21
|
+
(24000, 1001): (6, 1), # 1080p 23.976
|
|
22
|
+
(25, 1): (4, 3), # 1080i 25
|
|
23
|
+
(30000, 1001): (4, 4), # 1080i 29.97
|
|
24
|
+
}
|
|
25
|
+
CODING_H264, CODING_LPCM, CODING_AC3, CODING_DTS = 0x1B, 0x80, 0x81, 0x82
|
|
26
|
+
CODING_PG, CODING_IG = 0x90, 0x91
|
|
27
|
+
|
|
28
|
+
# ISO 639-1 -> ISO 639-2/B (the disc's codes)
|
|
29
|
+
LANGS = {
|
|
30
|
+
"ar": "ara", "bg": "bul", "ca": "cat", "cs": "cze", "da": "dan", "de": "ger",
|
|
31
|
+
"el": "gre", "en": "eng", "es": "spa", "et": "est", "fa": "per", "fi": "fin",
|
|
32
|
+
"fr": "fre", "he": "heb", "hi": "hin", "hr": "hrv", "hu": "hun", "id": "ind",
|
|
33
|
+
"is": "ice", "it": "ita", "ja": "jpn", "ko": "kor", "lt": "lit", "lv": "lav",
|
|
34
|
+
"ms": "may", "nl": "dut", "no": "nor", "pl": "pol", "pt": "por", "ro": "rum",
|
|
35
|
+
"ru": "rus", "sk": "slo", "sl": "slv", "sr": "srp", "sv": "swe", "th": "tha",
|
|
36
|
+
"tr": "tur", "uk": "ukr", "vi": "vie", "zh": "chi",
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def lang3(code):
|
|
41
|
+
return LANGS.get((code or "").lower(), "und")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
CODECS = {"ac3": CODING_AC3, "lpcm": CODING_LPCM, "dts": CODING_DTS}
|