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 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
@@ -0,0 +1,9 @@
1
+ """The one exception bdmvkit raises."""
2
+
3
+
4
+ class BdmvError(Exception):
5
+ """A disc cannot be written as asked."""
6
+
7
+ def __init__(self, message):
8
+ super().__init__(message)
9
+ self.message = message
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}