vtskit 0.1.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.
vtskit/menu.py ADDED
@@ -0,0 +1,171 @@
1
+ """Menus: a program chain of a menu domain with its buttons.
2
+
3
+ A menu is a VOB like a title's, with a subpicture overlay (vtskit.spu)
4
+ drawn where the buttons light up, and the highlight information (HLI) of
5
+ every navigation pack's PCI (https://dvdnav.mplayerhq.hu/dvdinfo/pci_pkt.html)
6
+ saying where each button is, which button each arrow key leads to, and the
7
+ command a button runs. The colours come from the program chain's palette:
8
+ entry 0 is the highlight (a button selected), entry 1 the selection (a
9
+ button activated), as dvdauthor and spumux arrange them; the overlay is
10
+ invisible until a button is selected. A menu may instead show its buttons
11
+ in a `normal` colour all the time (spumux's `image`): then the palette is
12
+ normal, highlight, selection, and the overlay draws palette 0.
13
+
14
+ A menu without a stream is a program chain of commands alone (its `pre`
15
+ runs and leaves), as dvdauthor writes a `<pgc>` without a `<vob>`.
16
+
17
+ From dvdauthor's output: the HLI is repeated whole in every VOBU
18
+ (HLI_SS 1), its start time is the menu's first picture, its end times are
19
+ 0xFFFFFFFF (never), the buttons form one group of the normal kind, every
20
+ button uses colour table 1, and in that table only the pattern code (1) is
21
+ coloured: palette 0 and opaque when selected, palette 1 and opaque when
22
+ activated.
23
+ """
24
+
25
+ import struct
26
+ from dataclasses import dataclass, field
27
+
28
+ from . import spu
29
+ from .errors import VtsError
30
+
31
+ YELLOW, RED = (0xD2, 0x92, 0x10), (0x52, 0xF0, 0x5A) # Y, Cr, Cb
32
+
33
+
34
+ @dataclass
35
+ class Button:
36
+ """A button: its rectangle (x0, y0, x1, y1 in the picture, x1 and y1 the
37
+ last column and line + 1), the command it runs (8 bytes), the buttons
38
+ (numbers, from 1) the arrow keys lead to (default: itself), and whether
39
+ it runs as soon as it is selected (`auto`)."""
40
+ rect: tuple
41
+ command: bytes
42
+ up: int = 0
43
+ down: int = 0
44
+ left: int = 0
45
+ right: int = 0
46
+ auto: bool = False
47
+
48
+
49
+ @dataclass
50
+ class Menu:
51
+ """A menu: its program stream (ffmpeg -f dvd, no subpictures), its
52
+ buttons, the overlay of pixel codes lighting up the buttons (code 1
53
+ where a button shows when selected; by default the buttons' rectangles),
54
+ the highlight and selection colours (Y, Cr, Cb, and a contrast 0-15,
55
+ 15 if left out), the colour the buttons show in otherwise (None: they
56
+ show nothing), where its cells start (seconds, as a title's chapters; a
57
+ loop point), its commands, and which remote key it answers (`entry`:
58
+ "title" in the VMG; "root", "subpicture", "audio", "angle" or "ptt" in
59
+ a titleset; None for a menu reached by commands). `stream` None: a
60
+ program chain of commands only, without pictures or buttons."""
61
+ stream: str | None
62
+ buttons: list = field(default_factory=list)
63
+ overlay: object = None
64
+ highlight: tuple = YELLOW
65
+ select: tuple = RED
66
+ normal: tuple | None = None
67
+ cells: tuple = (0.0,)
68
+ pre: tuple = ()
69
+ post: tuple = () # none: Exit, as dvdauthor writes a menu without post commands
70
+ entry: str | None = None
71
+ first_button: int = 0 # the button selected when the menu shows (0: the player's choice)
72
+ button_offset: int = 0 # added to a number key's digit to pick a button
73
+ uops: int = 0 # user operations the menu forbids (bit n: vtskit.vm.UOP_*)
74
+
75
+
76
+ ENTRY_TYPES = {"title": 2, "root": 3, "subpicture": 4, "audio": 5, "angle": 6, "ptt": 7}
77
+ ENTRY_FLAGS = {"title": 0x80, "root": 0x80, "subpicture": 0x40, "audio": 0x20, "angle": 0x10, "ptt": 0x08}
78
+
79
+
80
+ def _colour(c, what):
81
+ """(Y, Cr, Cb), contrast of a colour given as 3 or 4 numbers."""
82
+ if len(c) not in (3, 4) or not all(isinstance(v, int) for v in c):
83
+ raise VtsError(f"the {what} colour is (Y, Cr, Cb) or (Y, Cr, Cb, contrast)")
84
+ contrast = c[3] if len(c) == 4 else 15
85
+ if not 0 <= contrast <= 15:
86
+ raise VtsError(f"the {what} colour's contrast is 0 to 15")
87
+ return tuple(c[:3]), contrast
88
+
89
+
90
+ def check(menu, width, height):
91
+ if menu.stream is None and menu.buttons:
92
+ raise VtsError("a menu without a stream has no buttons")
93
+ if len(menu.buttons) > 36:
94
+ raise VtsError(f"{len(menu.buttons)} buttons (a menu has at most 36)")
95
+ _colour(menu.highlight, "highlight")
96
+ _colour(menu.select, "selection")
97
+ if menu.normal is not None:
98
+ _colour(menu.normal, "normal")
99
+ if menu.entry is not None and menu.entry not in ENTRY_TYPES:
100
+ raise VtsError(f"{menu.entry!r} is not a menu entry kind")
101
+ for n, b in enumerate(menu.buttons, 1):
102
+ x0, y0, x1, y1 = b.rect
103
+ if not (0 <= x0 < x1 <= width and 0 <= y0 < y1 <= height):
104
+ raise VtsError(f"button {n}: {b.rect} is not inside {width}x{height}")
105
+ if len(b.command) != 8:
106
+ raise VtsError(f"button {n}: a command is 8 bytes")
107
+ for k in (b.up, b.down, b.left, b.right):
108
+ if not 0 <= k <= len(menu.buttons):
109
+ raise VtsError(f"button {n} points at button {k}, which the menu has not")
110
+ if not 0 <= menu.first_button <= len(menu.buttons):
111
+ raise VtsError(f"first button {menu.first_button}: the menu has {len(menu.buttons)}")
112
+ if not 0 <= menu.button_offset <= 255:
113
+ raise VtsError("the button offset is 0 to 255")
114
+
115
+
116
+ def overlay(menu, width, height):
117
+ """The menu's overlay: as given, or its buttons' rectangles."""
118
+ if menu.overlay is not None:
119
+ if (menu.overlay.width, menu.overlay.height) != (width, height):
120
+ raise VtsError("the overlay is not the picture's size")
121
+ return menu.overlay
122
+ return spu.Overlay.from_rects(width, height, [b.rect for b in menu.buttons])
123
+
124
+
125
+ def palette(menu):
126
+ """The program chain's colours: the normal colour if any, then the
127
+ highlight and the selection; none for a menu without buttons."""
128
+ if not menu.buttons:
129
+ return ()
130
+ colours = [_colour(menu.highlight, "highlight")[0], _colour(menu.select, "selection")[0]]
131
+ if menu.normal is not None:
132
+ colours.insert(0, _colour(menu.normal, "normal")[0])
133
+ return tuple(colours)
134
+
135
+
136
+ def unit(menu, width, height):
137
+ """The menu's subpicture unit: the overlay, shown in the normal colour
138
+ (palette 0, its contrast) or not at all."""
139
+ o = overlay(menu, width, height)
140
+ if menu.normal is None:
141
+ return spu.encode(o)
142
+ return spu.encode(o, colours=(0, 0, 0, 0), contrasts=(0, _colour(menu.normal, "normal")[1], 0, 0))
143
+
144
+
145
+ def hli(menu, s_ptm):
146
+ """The highlight information of the menu's navigation packs (the PCI
147
+ from 0x60 to 0x316), shown from `s_ptm`, the menu's first picture."""
148
+ buttons = menu.buttons
149
+ out = bytearray(0x316 - 0x60)
150
+ if not buttons:
151
+ return bytes(out)
152
+ struct.pack_into(">HIIIHBBBBBB", out, 0, 1, s_ptm, 0xFFFFFFFF, 0xFFFFFFFF, 0x1000,
153
+ menu.button_offset, len(buttons), len(buttons), 0, menu.first_button, 0)
154
+ # colour table 1: the pattern code, the highlight's palette entry and
155
+ # contrast when selected, the selection's when activated; the other
156
+ # codes stay as they are
157
+ first = 1 if menu.normal is not None else 0
158
+ out[0x16:0x1E] = bytes((0x00, first << 4, 0x00, _colour(menu.highlight, "highlight")[1] << 4,
159
+ 0x00, (first + 1) << 4, 0x00, _colour(menu.select, "selection")[1] << 4))
160
+ for n, b in enumerate(buttons):
161
+ x0, y0, x1, y1 = b.rect
162
+ e = 0x2E + 18 * n
163
+ out[e] = 1 << 6 | x0 >> 4
164
+ out[e + 1] = (x0 & 0xF) << 4 | x1 >> 8
165
+ out[e + 2] = x1 & 0xFF
166
+ out[e + 3] = (0x40 if b.auto else 0) | y0 >> 4 # auto action mode 01
167
+ out[e + 4] = (y0 & 0xF) << 4 | y1 >> 8
168
+ out[e + 5] = y1 & 0xFF
169
+ out[e + 6:e + 10] = bytes(k or n + 1 for k in (b.up, b.down, b.left, b.right))
170
+ out[e + 10:e + 18] = b.command
171
+ return bytes(out)
vtskit/nav.py ADDED
@@ -0,0 +1,256 @@
1
+ """Fill the navigation packs of a VOB.
2
+
3
+ Every VOBU starts with a navigation pack: a PCI packet (presentation:
4
+ times, and a menu's buttons) and a DSI packet (search: where this VOBU
5
+ ends, where its reference pictures end, where the VOBUs 0.5 s to 120 s
6
+ away start, where its sound starts). ffmpeg's `-f dvd` leaves both empty;
7
+ `fill` writes them, and no pack moves.
8
+
9
+ The fields are those described at https://dvdnav.mplayerhq.hu/dvdinfo/
10
+ (pci_pkt.html, dsi_pkt.html). Where the description leaves a choice open,
11
+ the choice is the one dvdauthor makes, found by comparing its output
12
+ bytes (see tests/test_nav.py).
13
+ """
14
+
15
+ import bisect
16
+ import struct
17
+
18
+ from . import ps
19
+ from .errors import VtsError
20
+
21
+ PCI = 0x2D # the PCI data, in the navigation pack
22
+ DSI = 0x407 # the DSI data
23
+
24
+ # the search spans of VOBU_SRI, in half seconds: forward from the farthest,
25
+ # backward from the nearest
26
+ SPANS = (240, 120, 60, 20, 15, 14, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1)
27
+
28
+ COARSE = (20, 60, 120, 240)
29
+ NONE = 0x3FFFFFFF # no VOBU for this span (or no next / previous VOBU)
30
+ NO_VIDEO = 0xBFFFFFFF # no next / previous VOBU with video
31
+ VALID = 0x80000000
32
+ BETWEEN = 0x40000000 # VOBUs lie between this one and the nearer span's
33
+
34
+
35
+ class Vobu:
36
+ """One VOBU of a stream: its sectors (nav pack first, last), times and
37
+ what the DSI points at."""
38
+ __slots__ = ("start", "end", "scr", "s_ptm", "e_ptm", "video", "refs", "cell")
39
+
40
+ def __init__(self, start, end, scr):
41
+ self.start, self.end, self.scr = start, end, scr
42
+ self.s_ptm = self.e_ptm = None
43
+ self.video = False
44
+ self.refs = []
45
+ self.cell = None # (vob id, cell id, index of its first VOBU, of its last)
46
+
47
+
48
+ def vobus(stream):
49
+ """The VOBUs of a scanned stream (`ps.scan`), with their times and
50
+ reference pictures."""
51
+ if not stream.navs or stream.navs[0][0] != 0:
52
+ raise VtsError("the stream does not start with a navigation pack")
53
+ starts = [s for s, _ in stream.navs]
54
+ out = [Vobu(s, e - 1, scr) for (s, scr), e in zip(stream.navs, starts[1:] + [stream.sectors])]
55
+ k = 0
56
+ for p in stream.pictures:
57
+ while k + 1 < len(out) and p.start >= out[k + 1].start:
58
+ k += 1
59
+ v = out[k]
60
+ v.video = True
61
+ if p.pts is not None and (v.s_ptm is None or p.pts < v.s_ptm):
62
+ v.s_ptm = p.pts
63
+ if p.type in "IP" and len(v.refs) < 3:
64
+ v.refs.append(p.end - v.start)
65
+ if any(v.s_ptm is None for v in out):
66
+ raise VtsError("a VOBU without video: a stream of sound alone is not a DVD title (dvdauthor "
67
+ "refuses it too; a still is one picture, with the sound after it)")
68
+ for a, b in zip(out, out[1:]):
69
+ a.e_ptm = b.s_ptm
70
+ out[-1].e_ptm = stream.end
71
+ return out
72
+
73
+
74
+ def _bcd(n):
75
+ return (n // 10) << 4 | n % 10
76
+
77
+
78
+ def elapsed(ticks, frame):
79
+ """A time as the PGC and navigation packs give it: BCD hh:mm:ss:ff, the
80
+ top two bits of ff saying 30 (11) or 25 (01) frames a second."""
81
+ fps, flag = (25, 0x40) if frame == 3600 else (30, 0xC0)
82
+ frames = int(round(ticks / frame))
83
+ s, f = divmod(frames, fps)
84
+ m, s = divmod(s, 60)
85
+ h, m = divmod(m, 60)
86
+ return bytes((_bcd(h), _bcd(m), _bcd(s), _bcd(f) | flag))
87
+
88
+
89
+ def _search(vs, times, i, first, last, pos):
90
+ """VOBU_SRI of VOBU i, its cell being VOBUs first..last (`times`: the
91
+ start of each VOBU, `pos`: the sector of each VOBU on the disc)."""
92
+ v = vs[i]
93
+ end = vs[last].e_ptm
94
+
95
+ def find(t):
96
+ if t < times[first] or t >= end:
97
+ return None
98
+ return bisect.bisect_right(times, t, first, last + 1) - 1
99
+
100
+ def entries(sign):
101
+ # (the coarse spans, 10 s and up, also say whether VOBUs lie between
102
+ # their target and the nearer span's, or past it when they have none)
103
+ out, nearer = [], i
104
+ for span in sorted(SPANS):
105
+ j = find(v.s_ptm + sign * span * 45000)
106
+ coarse = span in COARSE and nearer is not None
107
+ if j is None:
108
+ beyond = first <= nearer + sign <= last if coarse else False
109
+ out.append(NONE | (BETWEEN if beyond else 0))
110
+ else:
111
+ val = VALID | abs(pos[j] - pos[i])
112
+ if coarse and abs(j - nearer) > 1:
113
+ val |= BETWEEN
114
+ out.append(val)
115
+ nearer = j
116
+ return out # nearest first
117
+
118
+ fwd = entries(1)[::-1]
119
+ bwd = entries(-1)
120
+ nxt = VALID | (pos[i + 1] - pos[i]) if i < last else NONE
121
+ prv = VALID | (pos[i] - pos[i - 1]) if i > first else NONE
122
+ nvwv = next((VALID | (pos[j] - pos[i]) for j in range(i + 1, last + 1) if vs[j].video),
123
+ NO_VIDEO)
124
+ pvwv = next((VALID | (pos[i] - pos[j]) for j in range(i - 1, first - 1, -1)
125
+ if vs[j].video), NO_VIDEO)
126
+ return [nvwv] + fwd + [nxt, prv] + bwd + [pvwv]
127
+
128
+
129
+ def _audio_sync(stream, vs, pos, final):
130
+ """a_synca of each VOBU: per sound stream (numbered in the order the
131
+ streams first appear), the offset to the stream's last packet stamped
132
+ at or before the VOBU's start (or its first, if none is)."""
133
+ if len(stream.audio) > 8:
134
+ raise VtsError(f"{len(stream.audio)} sound streams (a DVD title has at most 8)")
135
+ out = [[0] * 8 for _ in vs]
136
+ for n, packets in enumerate(stream.audio.values()):
137
+ stamped = [(s, p) for s, p, _ in packets if p is not None]
138
+ if not stamped:
139
+ continue
140
+ k = 0
141
+ for i, (row, v) in enumerate(zip(out, vs)):
142
+ while k + 1 < len(stamped) and stamped[k + 1][1] <= v.s_ptm:
143
+ k += 1
144
+ off = final(stamped[k][0]) - pos[i]
145
+ row[n] = off if off >= 0 else 0x8000 | -off
146
+ return out
147
+
148
+
149
+ def _subpicture_sync(stream, vs, pos, covered):
150
+ """sp_synca of each VOBU: per subpicture stream (0-31), where the unit
151
+ on screen during the VOBU is: in this VOBU, or nothing on screen
152
+ (7fffffff), or the offset to the VOBU it starts in. The unit on screen
153
+ is the last one shown before the VOBU ends and not gone before it
154
+ starts; a unit from before the VOBU's cell is not pointed at (a
155
+ player entering the cell has not read it), as dvdauthor writes them."""
156
+ starts = [v.start for v in vs]
157
+ cell_start = {}
158
+ for i, cell in enumerate(covered):
159
+ cell_start.setdefault(cell, vs[i].start)
160
+ out = [[0] * 32 for _ in vs]
161
+ for sub, units in stream.subpictures.items():
162
+ n = sub - 0x20
163
+ for i, (row, v) in enumerate(zip(out, vs)):
164
+ val = 0x7FFFFFFF
165
+ for sector, shown, until in reversed(units):
166
+ if sector < cell_start[covered[i]]:
167
+ break
168
+ if shown < v.e_ptm and (until is None or until > v.s_ptm):
169
+ home = pos[bisect.bisect_right(starts, sector) - 1]
170
+ if home > pos[i]:
171
+ val = home - pos[i]
172
+ elif home < pos[i]:
173
+ val = 0x80000000 | (pos[i] - home)
174
+ break
175
+ row[n] = val
176
+ return out
177
+
178
+
179
+ HLI = 0x60 # the highlight information in the PCI data, to 0x316
180
+
181
+
182
+ def fill(data, cells, lbn=0, uops=0, stream=None, hli=None, layout=None, ilvu=None, nsml=None):
183
+ """Fill the navigation packs of a VOB in place (a bytearray or a
184
+ writable mmap).
185
+
186
+ `cells`: [(vob id, cell id, first VOBU index, last VOBU index)], every
187
+ VOBU in one cell. `lbn`: the sector of the stream's first pack in the
188
+ titleset's VOBs, when the VOB lies whole there; `layout`: the sector of
189
+ each VOBU on the disc instead, when the VOB is interleaved with others.
190
+ `stream`: the stream already scanned, if it is. `hli`: {vob id: the
191
+ highlight information (buttons) of its menu, 0x2B6 bytes}, written into
192
+ every navigation pack of that VOB. `ilvu`: {VOBU index: (flags, end,
193
+ next start, next size, [(angle's next unit, size)] * 9)}, the SML_PBI
194
+ and SML_AGLI of a VOBU in or before an interleaved block. `nsml`: {VOBU
195
+ index: [angle's VOBU] * 9}, the PCI's NSML_AGLI. Returns the VOBUs."""
196
+ stream = stream or ps.scan(data)
197
+ vs = vobus(stream)
198
+ pos = list(layout) if layout is not None else [lbn + v.start for v in vs]
199
+ if len(pos) != len(vs):
200
+ raise VtsError("the layout does not give every VOBU a sector")
201
+ starts = [v.start for v in vs]
202
+
203
+ def final(sector):
204
+ k = bisect.bisect_right(starts, sector) - 1
205
+ return pos[k] + sector - vs[k].start
206
+ ilvu = ilvu or {}
207
+ nsml = nsml or {}
208
+ covered = [None] * len(vs)
209
+ for cell in cells:
210
+ vob_id, cell_id, first, last = cell
211
+ for j in range(first, last + 1):
212
+ covered[j] = cell
213
+ if None in covered:
214
+ raise VtsError("a VOBU is in no cell")
215
+ vob_s, vob_e = {}, {}
216
+ for v, cell in zip(vs, covered):
217
+ v.cell = cell
218
+ vob_s.setdefault(cell[0], v.s_ptm)
219
+ vob_e[cell[0]] = v.e_ptm
220
+ times = [v.s_ptm for v in vs]
221
+ sync = _audio_sync(stream, vs, pos, final)
222
+ spu = _subpicture_sync(stream, vs, pos, covered)
223
+ for i, v in enumerate(vs):
224
+ vob_id, cell_id, first, last = v.cell
225
+ base = v.start * ps.SECTOR
226
+ if data[base + 0x26:base + 0x2D] != b"\x00\x00\x01\xbf\x03\xd4\x00" or \
227
+ data[base + 0x400:base + 0x407] != b"\x00\x00\x01\xbf\x03\xfa\x01":
228
+ raise VtsError(f"sector {v.start}: not a navigation pack of PCI and DSI")
229
+ eltm = elapsed(v.s_ptm - vs[first].s_ptm, stream.frame)
230
+ pci = bytearray(0x3D4 - 1)
231
+ struct.pack_into(">IIIIIII", pci, 0, pos[i], 0, uops,
232
+ v.s_ptm, v.e_ptm, 0, int.from_bytes(eltm, "big"))
233
+ if i in nsml:
234
+ struct.pack_into(">9I", pci, 0x3C, *nsml[i])
235
+ if hli and vob_id in hli:
236
+ if len(hli[vob_id]) != 0x316 - HLI:
237
+ raise VtsError("the highlight information is 0x2B6 bytes")
238
+ pci[HLI:0x316] = hli[vob_id]
239
+ dsi = bytearray(0x3FA - 1)
240
+ # (fewer than three reference pictures: the last one's end again,
241
+ # as dvdauthor writes it)
242
+ refs = v.refs + [v.refs[-1] if v.refs else 0] * (3 - len(v.refs))
243
+ struct.pack_into(">IIIIIIHxB4s", dsi, 0, v.scr & 0xFFFFFFFF, pos[i],
244
+ v.end - v.start, *refs, vob_id, cell_id, eltm)
245
+ if i in ilvu:
246
+ flags, ea, sa, sz, agli = ilvu[i]
247
+ struct.pack_into(">HIIH", dsi, 0x20, flags, ea, sa, sz)
248
+ for n, (dsta, size) in enumerate(agli):
249
+ struct.pack_into(">IH", dsi, 0xB4 + 6 * n, dsta, size)
250
+ struct.pack_into(">II", dsi, 0x2C, vob_s[vob_id], vob_e[vob_id])
251
+ struct.pack_into(">42I", dsi, 0xEA, *_search(vs, times, i, first, last, pos))
252
+ struct.pack_into(">8H", dsi, 0x192, *sync[i])
253
+ struct.pack_into(">32I", dsi, 0x1A2, *spu[i])
254
+ data[base + PCI:base + PCI + len(pci)] = pci
255
+ data[base + DSI:base + DSI + len(dsi)] = dsi
256
+ return vs