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/hdmv.py ADDED
@@ -0,0 +1,149 @@
1
+ """HDMV navigation: the 12-byte navigation commands, MovieObject.bdmv and
2
+ index.bdmv."""
3
+
4
+ import struct
5
+
6
+ from .formats import VERSION
7
+
8
+
9
+ def _u8(v):
10
+ return struct.pack(">B", v)
11
+
12
+
13
+ def _u16(v):
14
+ return struct.pack(">H", v)
15
+
16
+
17
+ def _u32(v):
18
+ return struct.pack(">I", v)
19
+
20
+
21
+ def _pad(data, n):
22
+ return data + b"\0" * (-len(data) % n)
23
+
24
+
25
+ def index_bdmv(title_objects, first_play, top_menu, video_format, frame_rate, interactive=()):
26
+ """title_objects: the movie object of every title (title 1 first);
27
+ interactive: the titles (1-based) that are menus."""
28
+ app = _u32(34) + bytes([0, video_format << 4 | frame_rate]) + bytes(32)
29
+
30
+ def hdmv(obj, playback_type):
31
+ # playback_type 0 = movie, 1 = interactive
32
+ return _u16(playback_type << 14) + _u16(obj) + _u32(0)
33
+
34
+ body = _u32(1 << 30) + hdmv(first_play, 1) + _u32(1 << 30) + hdmv(top_menu, 1)
35
+ body += _u16(len(title_objects))
36
+ for n, obj in enumerate(title_objects, 1):
37
+ body += _u32(1 << 30) + hdmv(obj, int(n in interactive)) # HDMV, access permitted
38
+ index_start = 40 + len(app)
39
+ return b"INDX" + VERSION + _u32(index_start) + _u32(0) + bytes(24) + app + \
40
+ _u32(len(body)) + body
41
+
42
+
43
+ def _cmd(b0, b1, dst=0, src=0, b3=0, b2=0):
44
+ """A 12-byte navigation command: operand count / group / sub-group,
45
+ immediate flags / branch option, compare option, set option; operands."""
46
+ return bytes([b0, b1, b2, b3]) + _u32(dst) + _u32(src)
47
+
48
+
49
+ def play_pl(playlist):
50
+ """Play PlayList <playlist> (an immediate number)."""
51
+ return _cmd(0x22, 0x80, playlist)
52
+
53
+
54
+ def set_subtitles(number):
55
+ """Set Stream: subtitle (PG) track `number` shown, or 0 = none shown."""
56
+ # Set group, SetSystem sub-group, two immediate operands, option 1 (Set Stream);
57
+ # the first operand: PG stream number valid (0x8000), shown (0x4000)
58
+ return _cmd(0x51, 0xC0, 0xC000 | number if number else 0, 0, 0x01)
59
+
60
+
61
+ def jump_title(title):
62
+ """Jump Title <title>: 1..N, 0 = top menu."""
63
+ return _cmd(0x21, 0x81, title)
64
+
65
+
66
+ def jump_title_reg(reg):
67
+ """Jump Title to the number held in general register `reg`."""
68
+ return _cmd(0x21, 0x01, reg)
69
+
70
+
71
+ def jump_object(obj):
72
+ """Jump Object <obj> (a movie object number)."""
73
+ return _cmd(0x21, 0x80, obj)
74
+
75
+
76
+ def play_pl_mark(playlist, mark):
77
+ """Play PlayList <playlist> from mark <mark> (0-based)."""
78
+ return _cmd(0x42, 0xC2, playlist, mark)
79
+
80
+
81
+ def goto(pc):
82
+ """Goto command <pc> of the same object."""
83
+ return _cmd(0x20, 0x81, pc)
84
+
85
+
86
+
87
+ def move(reg, value):
88
+ """Move <value> into general register <reg>."""
89
+ return _cmd(0x50, 0x40, reg, value, 0x01)
90
+
91
+
92
+ def set_button(button=None, reg=None):
93
+ """Set Button Page: select button `button` (an id), or the one held in
94
+ general register `reg`; the page stays."""
95
+ # Set group, SetSystem sub-group, option 3; the operand's top bit: button valid
96
+ if reg is not None:
97
+ return _cmd(0x51, 0x40, 0x80000000 | reg, 0, 0x03)
98
+ return _cmd(0x51, 0xC0, 0x80000000 | button, 0, 0x03)
99
+
100
+
101
+ def add(reg, value):
102
+ """Add <value> to general register <reg>."""
103
+ return _cmd(0x50, 0x40, reg, value, 0x03)
104
+
105
+
106
+ def if_at_least(reg, value):
107
+ """Compare: the next command runs only if general register `reg` >= value."""
108
+ return _cmd(0x48, 0x40, reg, value, b2=0x04)
109
+
110
+
111
+ def if_equal(reg, value):
112
+ """Compare: the next command runs only if general register `reg` == value."""
113
+ return _cmd(0x48, 0x40, reg, value, b2=0x02)
114
+
115
+
116
+ def if_not_equal(reg, value):
117
+ """Compare: the next command runs only if general register `reg` != value."""
118
+ return _cmd(0x48, 0x40, reg, value, b2=0x03)
119
+
120
+
121
+ def popup_off():
122
+ """Pop-up Off: close the pop-up menu."""
123
+ return _cmd(0x11, 0x00, b3=0x07)
124
+
125
+
126
+ def resume():
127
+ """Resume the title the menu was called from."""
128
+ return _cmd(0x01, 0x04)
129
+
130
+
131
+ def set_streams(audio=None, subtitle=None):
132
+ """Set Stream: audio track `audio` (1-based) and/or subtitle track
133
+ `subtitle` shown (0 = subtitles off)."""
134
+ dst = 0
135
+ if audio:
136
+ dst |= 0x80000000 | audio << 16
137
+ if subtitle is not None:
138
+ dst |= (0xC000 | subtitle) if subtitle else 0
139
+ return _cmd(0x51, 0xC0, dst, 0, 0x01)
140
+
141
+
142
+ def movie_objects(objects):
143
+ """objects: lists of 12-byte navigation commands."""
144
+ data = b""
145
+ for cmds in objects:
146
+ # resume_intention_flag set: a menu called during playback can resume it
147
+ data += _u16(1 << 15) + _u16(len(cmds)) + b"".join(cmds)
148
+ body = _u32(0) + _u16(len(objects)) + data
149
+ return b"MOBJ" + VERSION + _u32(0) + bytes(28) + _u32(len(body)) + body
bdmvkit/igs.py ADDED
@@ -0,0 +1,241 @@
1
+ """Interactive Graphics (IG) streams: a menu's buttons.
2
+
3
+ A menu on a Blu-ray is a clip (its picture) carrying an IG stream: one
4
+ interactive composition with a page of buttons, each with a picture for
5
+ its normal, selected and activated states, the buttons it moves to with
6
+ the arrow keys, and the navigation commands it runs when activated.
7
+
8
+ buttons = [Button("play", 200, 600, selected=rgba, activated=rgba, w=400, h=80,
9
+ commands=[hdmv.jump_title(1)])]
10
+ data = ig_stream(buttons, frame_rate=1) # write it as a .sup
11
+
12
+ The state pictures are RGBA (straight alpha) of the button's size; they
13
+ share one palette of up to 255 colours (quantize). Without a normal
14
+ picture a button shows nothing until it is selected (the highlight
15
+ is drawn over the menu's own picture). A pop-up composition (`popup`)
16
+ is shown by the remote's Pop-up key over a title instead of a menu clip.
17
+
18
+ ffmpeg muxes an IG stream given as a .sup like a subtitle stream; the
19
+ clip's PMT is then corrected with patch_ig_stream_type.
20
+ """
21
+
22
+ import dataclasses
23
+ import struct
24
+
25
+ from . import pgs
26
+ from .errors import BdmvError
27
+ from .formats import CODING_IG, HEIGHT, IG_PID, PMT_PID, WIDTH
28
+
29
+ ICS, PDS, ODS, END = 0x18, 0x14, 0x15, 0x80
30
+ NO_OBJECT = 0xFFFF
31
+ DIRECTIONS = ("up", "down", "left", "right")
32
+
33
+
34
+ @dataclasses.dataclass
35
+ class Button:
36
+ id: str
37
+ x: int # top-left on the 1920 x 1080 frame
38
+ y: int
39
+ w: int
40
+ h: int
41
+ selected: bytes # RGBA, w x h
42
+ activated: bytes = None # (default: the selected picture)
43
+ normal: bytes = None # (default: nothing until selected)
44
+ commands: list = dataclasses.field(default_factory=list) # 12-byte commands
45
+ nav: dict = dataclasses.field(default_factory=dict) # direction -> button id
46
+ auto: bool = False # runs as soon as it is selected
47
+
48
+
49
+ def quantize(images):
50
+ """RGBA pictures -> (palette [(r, g, b, a)] for indices 1.., index
51
+ pictures) with at most 255 colours; fully transparent pixels are index
52
+ 0. Fewer bits per channel are kept until the colours fit; past that the
53
+ rarest go to their nearest kept colour."""
54
+ for bits in (8, 7, 6, 5, 4, 3):
55
+ mask = (0xFF << (8 - bits)) & 0xFF
56
+ table = bytes(v & mask for v in range(256))
57
+ q = [im.translate(table) for im in images]
58
+ counts = {}
59
+ for qi in q:
60
+ for i in range(0, len(qi), 4):
61
+ c = qi[i:i + 4]
62
+ if c[3]:
63
+ counts[c] = counts.get(c, 0) + 1
64
+ if len(counts) <= 255:
65
+ break
66
+ kept = sorted(counts, key=counts.get, reverse=True)[:255]
67
+ index = {c: k + 1 for k, c in enumerate(kept)}
68
+ for c in counts:
69
+ if c not in index:
70
+ index[c] = min(index.items(), key=lambda kv: sum((a - b) ** 2 for a, b in
71
+ zip(kv[0], c)))[1]
72
+ half = (~mask & 0xFF) >> 1
73
+ palette = [tuple(v | half if v else v for v in c) for c in kept]
74
+ out = [bytes(index.get(qi[i:i + 4], 0) if qi[i + 3] else 0 for i in range(0, len(qi), 4))
75
+ for qi in q]
76
+ return palette, out
77
+
78
+
79
+ def auto_nav(buttons):
80
+ """The arrow keys between buttons that do not say where they go: the
81
+ nearest button whose centre is that way (closer across counts more
82
+ than along), else the button itself. Returns {id: {direction: id}}."""
83
+ centre = {b.id: (b.x + b.w / 2, b.y + b.h / 2) for b in buttons}
84
+ out = {}
85
+ for b in buttons:
86
+ cx, cy = centre[b.id]
87
+ nav = {}
88
+ for d in DIRECTIONS:
89
+ if d in b.nav:
90
+ nav[d] = b.nav[d]
91
+ continue
92
+ best, score = b.id, None
93
+ for o in buttons:
94
+ if o is b:
95
+ continue
96
+ ox, oy = centre[o.id]
97
+ along = {"up": cy - oy, "down": oy - cy, "left": cx - ox, "right": ox - cx}[d]
98
+ across = abs(ox - cx) if d in ("up", "down") else abs(oy - cy)
99
+ if along <= 0:
100
+ continue
101
+ s = along + 2 * across
102
+ if score is None or s < score:
103
+ best, score = o.id, s
104
+ nav[d] = best
105
+ out[b.id] = nav
106
+ return out
107
+
108
+
109
+ def _segment(kind, data):
110
+ return b"PG" + struct.pack(">IIBH", 0, 0, kind, len(data)) + data
111
+
112
+
113
+ def _objects(oid, w, h, pixels):
114
+ """ODS segment(s) of one object (split when longer than a segment)."""
115
+ coded = pgs.rle(pixels, w, h)
116
+ data = (len(coded) + 4).to_bytes(3, "big") + struct.pack(">HH", w, h) + coded
117
+ size = pgs.MAX_SEGMENT - 4
118
+ chunks = [data[i:i + size] for i in range(0, len(data), size)]
119
+ out = b""
120
+ for k, chunk in enumerate(chunks):
121
+ flags = (0x80 if k == 0 else 0) | (0x40 if k == len(chunks) - 1 else 0)
122
+ out += _segment(ODS, struct.pack(">HBB", oid, 0, flags) + chunk)
123
+ return out
124
+
125
+
126
+ def ig_stream(buttons, frame_rate, default=None, popup=False, button_offset=0,
127
+ follow_selection=False):
128
+ """The buttons as an IG stream (.sup layout: "PG" segments).
129
+
130
+ frame_rate: the menu clip's frame rate code (formats.FORMATS);
131
+ default: the id of the button selected when the menu starts (the
132
+ first); follow_selection: keep the player's selection instead (a menu
133
+ entered with a chosen button, set_button before playing it);
134
+ button_offset: the number keys select button n with n + this."""
135
+ if not buttons:
136
+ raise BdmvError("a menu needs at least one button")
137
+ ids = [b.id for b in buttons]
138
+ if len(set(ids)) != len(ids):
139
+ raise BdmvError("button ids must be unique")
140
+ for b in buttons:
141
+ if b.x < 0 or b.y < 0 or b.x + b.w > WIDTH or b.y + b.h > HEIGHT:
142
+ raise BdmvError(f"button {b.id!r} is not inside the {WIDTH} x {HEIGHT} frame")
143
+ for name in ("selected", "activated", "normal"):
144
+ pic = getattr(b, name)
145
+ if pic is not None and len(pic) != b.w * b.h * 4:
146
+ raise BdmvError(f"button {b.id!r}: its {name} picture is not {b.w} x {b.h} RGBA")
147
+ for d, to in b.nav.items():
148
+ if d not in DIRECTIONS or to not in ids:
149
+ raise BdmvError(f"button {b.id!r}: nav {d} -> {to!r}")
150
+ if default is not None and default not in ids:
151
+ raise BdmvError(f"no button {default!r}")
152
+ if button_offset + len(buttons) > 9999:
153
+ raise BdmvError("button numbers go up to 9999")
154
+ nav = auto_nav(buttons)
155
+ index = {b.id: k for k, b in enumerate(buttons)}
156
+ normal = any(b.normal is not None for b in buttons)
157
+ pictures = []
158
+ for b in buttons:
159
+ empty = bytes(b.w * b.h * 4)
160
+ pictures += [b.normal if b.normal is not None else empty, b.selected,
161
+ b.activated if b.activated is not None else b.selected]
162
+ colours, indexed = quantize(pictures)
163
+ palette = [(0, 16, 128, 128, 0)]
164
+ for k, (r, g, bl, a) in enumerate(colours, 1):
165
+ y, cb, cr = pgs.ycbcr((r, g, bl))
166
+ palette.append((k, y, cr, cb, a))
167
+ objects, body_buttons = b"", b""
168
+ for k, b in enumerate(buttons):
169
+ oids = {}
170
+ for slot in (0, 1, 2): # normal, selected, activated
171
+ if slot == 0 and not normal:
172
+ continue
173
+ oid = 3 * k + slot
174
+ objects += _objects(oid, b.w, b.h, indexed[3 * k + slot])
175
+ oids[slot] = oid
176
+ n_oid = oids.get(0, NO_OBJECT)
177
+ moves = [index[nav[b.id][d]] for d in DIRECTIONS]
178
+ cmds = list(b.commands)
179
+ for c in cmds:
180
+ if len(c) != 12:
181
+ raise BdmvError(f"button {b.id!r}: a navigation command is 12 bytes")
182
+ button = struct.pack(">HHBHH", k, button_offset + k + 1, 0x80 if b.auto else 0, b.x, b.y)
183
+ button += struct.pack(">HHHH", *moves)
184
+ button += struct.pack(">HHB", n_oid, n_oid, 0)
185
+ button += struct.pack(">BHHB", 0xFF, oids[1], oids[1], 0)
186
+ button += struct.pack(">BHH", 0xFF, oids[2], oids[2])
187
+ button += struct.pack(">H", len(cmds)) + b"".join(cmds)
188
+ # one button per overlap group: the buttons do not overlap
189
+ body_buttons += struct.pack(">HB", k, 1) + button
190
+ first = 0xFFFF if follow_selection else index.get(default, 0)
191
+ page = struct.pack(">BB", 0, 0) + bytes(8) # page 0, no user-operation mask
192
+ page += bytes([0, 0]) + bytes([0, 0]) # no in / out effects
193
+ page += struct.pack(">BHHBB", 0, first, NO_OBJECT, 0, len(buttons)) + body_buttons
194
+ # multiplexed; always on, or a pop-up (the Pop-up key shows it); no time-outs
195
+ body = bytes([0x40 if popup else 0x00]) + bytes(10)
196
+ body += (0).to_bytes(3, "big") + bytes([1]) + page # no user time-out; one page
197
+ ics = struct.pack(">HHB", WIDTH, HEIGHT, frame_rate << 4)
198
+ ics += struct.pack(">HB", 0, 0x80) # composition 0, epoch start
199
+ ics += bytes([0xC0]) + len(body).to_bytes(3, "big") + body
200
+ if len(ics) > pgs.MAX_SEGMENT:
201
+ raise BdmvError("too many buttons or button commands for one composition")
202
+ pds = bytes([0, 0]) + b"".join(bytes(e) for e in palette)
203
+ return _segment(ICS, ics) + _segment(PDS, pds) + objects + _segment(END, b"")
204
+
205
+
206
+ def patch_ig_stream_type(path, pmt_pid=PMT_PID, pid=IG_PID):
207
+ """ffmpeg muxes the IG stream as a PGS stream (stream type 0x90): mark it
208
+ 0x91 in every PMT of the clip (and fix the section CRC)."""
209
+ with open(path, "r+b") as f:
210
+ data = bytearray(f.read())
211
+ for off in range(0, len(data), 192):
212
+ p = off + 4
213
+ if (data[p + 1] & 0x1F) << 8 | data[p + 2] != pmt_pid or not data[p + 1] & 0x40:
214
+ continue
215
+ q = p + 4
216
+ if data[p + 3] & 0x20:
217
+ q += 1 + data[q]
218
+ q += 1 + data[q] # pointer field
219
+ length = (data[q + 1] & 0x0F) << 8 | data[q + 2]
220
+ end = q + 3 + length - 4
221
+ info = (data[q + 10] & 0x0F) << 8 | data[q + 11]
222
+ e = q + 12 + info
223
+ while e < end:
224
+ es_pid = (data[e + 1] & 0x1F) << 8 | data[e + 2]
225
+ if es_pid == pid:
226
+ data[e] = CODING_IG
227
+ e += 5 + ((data[e + 3] & 0x0F) << 8 | data[e + 4])
228
+ data[end:end + 4] = crc32(bytes(data[q:end])).to_bytes(4, "big")
229
+ f.seek(0)
230
+ f.write(data)
231
+
232
+
233
+ def crc32(data):
234
+ """MPEG-2 section CRC (polynomial 0x04C11DB7, no reflection)."""
235
+ crc = 0xFFFFFFFF
236
+ for b in data:
237
+ crc ^= b << 24
238
+ for _ in range(8):
239
+ crc = ((crc << 1) ^ 0x04C11DB7) & 0xFFFFFFFF if crc & 0x80000000 else \
240
+ (crc << 1) & 0xFFFFFFFF
241
+ return crc
bdmvkit/m2ts.py ADDED
@@ -0,0 +1,78 @@
1
+ """Reading a BDAV transport stream (.m2ts) back: where its key frames are
2
+ and the span of its pictures, for the clip information and chapter marks."""
3
+
4
+ import dataclasses
5
+
6
+ from .errors import BdmvError
7
+ from .formats import VIDEO_PID
8
+
9
+
10
+ @dataclasses.dataclass
11
+ class ClipScan:
12
+ packets: int # 192-byte source packets
13
+ first_pts: int # 90 kHz, first picture shown
14
+ end_pts: int # 90 kHz, end of the last picture
15
+ pcr_pid: int
16
+ entries: list # [(pts, spn, size in bytes)] of the key frames, in order
17
+
18
+
19
+ def _pts(b, i):
20
+ return (((b[i] >> 1) & 7) << 30 | b[i + 1] << 22 | (b[i + 2] >> 1) << 15 |
21
+ b[i + 3] << 7 | b[i + 4] >> 1)
22
+
23
+
24
+ def scan_m2ts(path, video_pid=VIDEO_PID):
25
+ """Where the key frames of a .m2ts are (for the entry-point map) and the
26
+ span of its pictures. Every picture is one PES packet (ffmpeg); key
27
+ frames carry the random-access flag."""
28
+ entries = []
29
+ pts_all = []
30
+ pcr_pid = None
31
+ spn = 0
32
+ with open(path, "rb") as f:
33
+ while True:
34
+ buf = f.read(192 * 8192)
35
+ if not buf:
36
+ break
37
+ if len(buf) % 192:
38
+ raise BdmvError(f"{path} is not a BDAV stream (length is not 192 × n)")
39
+ for off in range(0, len(buf), 192):
40
+ p = off + 4
41
+ b1 = buf[p + 1]
42
+ if not b1 & 0x40:
43
+ continue
44
+ if buf[p] != 0x47:
45
+ raise BdmvError(f"{path}: lost sync at packet {spn + off // 192}")
46
+ pid = (b1 & 0x1F) << 8 | buf[p + 2]
47
+ if pid != video_pid:
48
+ continue
49
+ afc = buf[p + 3] >> 4 & 3
50
+ q = p + 4
51
+ rai = False
52
+ if afc & 2:
53
+ alen = buf[q]
54
+ if alen:
55
+ rai = bool(buf[q + 1] & 0x40)
56
+ if buf[q + 1] & 0x10:
57
+ pcr_pid = pid
58
+ q += 1 + alen
59
+ # (a PES header must start in this packet with its PTS: 14 bytes)
60
+ if not afc & 1 or q + 14 > p + 188 or buf[q:q + 3] != b"\0\0\1" \
61
+ or not buf[q + 7] & 0x80:
62
+ continue
63
+ pts = _pts(buf, q + 9)
64
+ n = spn + off // 192
65
+ if entries and entries[-1][2] is None:
66
+ entries[-1][2] = (n - entries[-1][1]) * 192
67
+ pts_all.append(pts)
68
+ if rai:
69
+ entries.append([pts, n, None])
70
+ spn += len(buf) // 192
71
+ if not pts_all or not entries:
72
+ raise BdmvError(f"{path}: no pictures with key frames found")
73
+ if entries[-1][2] is None:
74
+ entries[-1][2] = (spn - entries[-1][1]) * 192
75
+ ordered = sorted(pts_all)
76
+ step = ordered[1] - ordered[0] if len(ordered) > 1 else 3003
77
+ return ClipScan(spn, ordered[0], ordered[-1] + step, pcr_pid or video_pid,
78
+ [tuple(e) for e in entries])