vtskit 0.1.0__tar.gz → 0.2.0__tar.gz
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-0.1.0/vtskit.egg-info → vtskit-0.2.0}/PKG-INFO +38 -15
- {vtskit-0.1.0 → vtskit-0.2.0}/README.md +37 -14
- {vtskit-0.1.0 → vtskit-0.2.0}/pyproject.toml +1 -1
- {vtskit-0.1.0 → vtskit-0.2.0}/tests/test_angles.py +150 -16
- vtskit-0.2.0/tests/test_title_buttons.py +210 -0
- {vtskit-0.1.0 → vtskit-0.2.0}/vtskit/__init__.py +1 -1
- {vtskit-0.1.0 → vtskit-0.2.0}/vtskit/menu.py +103 -13
- {vtskit-0.1.0 → vtskit-0.2.0}/vtskit/nav.py +11 -5
- {vtskit-0.1.0 → vtskit-0.2.0}/vtskit/ps.py +5 -1
- {vtskit-0.1.0 → vtskit-0.2.0}/vtskit/titleset.py +122 -16
- {vtskit-0.1.0 → vtskit-0.2.0/vtskit.egg-info}/PKG-INFO +38 -15
- {vtskit-0.1.0 → vtskit-0.2.0}/vtskit.egg-info/SOURCES.txt +1 -0
- {vtskit-0.1.0 → vtskit-0.2.0}/COPYING +0 -0
- {vtskit-0.1.0 → vtskit-0.2.0}/COPYING.LESSER +0 -0
- {vtskit-0.1.0 → vtskit-0.2.0}/setup.cfg +0 -0
- {vtskit-0.1.0 → vtskit-0.2.0}/tests/test_ifo.py +0 -0
- {vtskit-0.1.0 → vtskit-0.2.0}/tests/test_lang.py +0 -0
- {vtskit-0.1.0 → vtskit-0.2.0}/tests/test_menu.py +0 -0
- {vtskit-0.1.0 → vtskit-0.2.0}/tests/test_nav.py +0 -0
- {vtskit-0.1.0 → vtskit-0.2.0}/tests/test_subtitles.py +0 -0
- {vtskit-0.1.0 → vtskit-0.2.0}/tests/test_titleset.py +0 -0
- {vtskit-0.1.0 → vtskit-0.2.0}/tests/test_vm.py +0 -0
- {vtskit-0.1.0 → vtskit-0.2.0}/tests/test_vmg.py +0 -0
- {vtskit-0.1.0 → vtskit-0.2.0}/vtskit/errors.py +0 -0
- {vtskit-0.1.0 → vtskit-0.2.0}/vtskit/ifo.py +0 -0
- {vtskit-0.1.0 → vtskit-0.2.0}/vtskit/lang.py +0 -0
- {vtskit-0.1.0 → vtskit-0.2.0}/vtskit/spu.py +0 -0
- {vtskit-0.1.0 → vtskit-0.2.0}/vtskit/subtitles.py +0 -0
- {vtskit-0.1.0 → vtskit-0.2.0}/vtskit/vm.py +0 -0
- {vtskit-0.1.0 → vtskit-0.2.0}/vtskit/vmg.py +0 -0
- {vtskit-0.1.0 → vtskit-0.2.0}/vtskit.egg-info/dependency_links.txt +0 -0
- {vtskit-0.1.0 → vtskit-0.2.0}/vtskit.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: vtskit
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: DVD-Video titlesets in pure Python: VOBs, navigation packs and IFO files
|
|
5
5
|
Author: kajisho5
|
|
6
6
|
License-Expression: LGPL-3.0-or-later
|
|
@@ -85,18 +85,22 @@ play the discs vtskit writes.
|
|
|
85
85
|
## Installing
|
|
86
86
|
|
|
87
87
|
vtskit is one package of plain Python (3.11 or later), with nothing else
|
|
88
|
-
to install
|
|
88
|
+
to install:
|
|
89
89
|
|
|
90
90
|
```
|
|
91
|
-
pip install
|
|
91
|
+
pip install vtskit
|
|
92
92
|
```
|
|
93
93
|
|
|
94
|
-
A
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
94
|
+
A release is made by the version: when the version in `pyproject.toml`
|
|
95
|
+
(and `vtskit/__init__.py`) reaches `main` without a tag of its own,
|
|
96
|
+
`.github/workflows/release.yml` runs the tests, builds the package, makes
|
|
97
|
+
the GitHub release with its tag and publishes to PyPI. Merging the version
|
|
98
|
+
bump is the whole act; a tag `v*` pushed by hand releases the same way.
|
|
99
|
+
PyPI knows this repository and workflow as the project's trusted
|
|
100
|
+
publisher. oncemore does not install vtskit: it
|
|
101
|
+
carries a copy of each release as `oncemore/_vendor/vtskit`, taken from
|
|
102
|
+
PyPI with its `tools/vendor.py vtskit --pypi <version>` (a pull request
|
|
103
|
+
does it there each day a newer release is out).
|
|
100
104
|
|
|
101
105
|
## What is here now
|
|
102
106
|
|
|
@@ -129,9 +133,23 @@ oncemore writing its discs with vtskit.
|
|
|
129
133
|
libdvdnav a title whose post commands run out plays on into its next
|
|
130
134
|
program chain; dvdauthor writes none of these. A title may be
|
|
131
135
|
several parts in a row, streams and `Angles` blocks of 2 to 9 streams: an
|
|
132
|
-
angle block is interleaved on the disc
|
|
133
|
-
|
|
134
|
-
|
|
136
|
+
angle block is interleaved on the disc and its cells are marked as an
|
|
137
|
+
angle block. A seamless block (the default) is switched at the end of an
|
|
138
|
+
interleaved unit: its navigation packs point from each unit to every
|
|
139
|
+
angle's next (SML_AGLI) and its cells say "seamless angle"; a block
|
|
140
|
+
switched with a break (`seamless=False`) points instead at every angle's
|
|
141
|
+
VOBU of the same moment (NSML_AGLI). Only one of the two is filled, as
|
|
142
|
+
players take NSML_AGLI whenever it is there. A stream whose interleaved
|
|
143
|
+
units start with open groups of pictures (B-pictures leaning on the group
|
|
144
|
+
before, another angle's after a switch) is refused. A title may carry
|
|
145
|
+
buttons over stretches of it (`vtskit.menu.TitleButtons`: from a start
|
|
146
|
+
to an end, the buttons, the palette entries that draw them): their
|
|
147
|
+
overlay is a forced subpicture added with `vtskit.subtitles`, and the
|
|
148
|
+
highlight information goes only into the VOBUs that play during the
|
|
149
|
+
stretch, with the stretch's start and end as its start time, end time
|
|
150
|
+
and the end of the selection (dvdinfo's hli_s_ptm, hli_e_ptm,
|
|
151
|
+
btn_sl_e_ptm). dvdauthor gives a title's buttons the whole title (every
|
|
152
|
+
VOBU, never ending), which is what buttons without an end write.
|
|
135
153
|
* `vtskit.subtitles` adds subtitles to a title's program stream, as spumux
|
|
136
154
|
does: each cue a picture of up to four colours placed on the screen and
|
|
137
155
|
shown for a while, as subpicture packs a little before their time.
|
|
@@ -172,13 +190,18 @@ dvdauthor's (its buttons made with spumux), and a menu's overlay in a
|
|
|
172
190
|
normal colour is spumux's unit byte for byte; subtitles muxed by vtskit are
|
|
173
191
|
spumux's units, placed where spumux places them, and the title written
|
|
174
192
|
from that stream (4:3, and 16:9 with and without pan-scan or letterbox) is
|
|
175
|
-
dvdauthor's
|
|
193
|
+
dvdauthor's; a title with buttons over the whole of it is dvdauthor's
|
|
194
|
+
VOB byte for byte. A VIDEO_TS written by vtskit alone plays
|
|
176
195
|
its titles and chapters in order under libdvdnav, its commands jumping
|
|
177
196
|
between titles, its menus' buttons lead where they say, and its jumps into
|
|
178
197
|
another titleset's chapters land; a title with an angle block plays and
|
|
179
198
|
switches angles under libdvdnav, every pointer of its navigation packs
|
|
180
|
-
landing on the unit it names
|
|
181
|
-
|
|
199
|
+
landing on the unit it names, and the pictures libdvdnav hands a player
|
|
200
|
+
through a switch, decoded with ffmpeg, are every picture once, in order,
|
|
201
|
+
the new angle from the end of a unit (dvdauthor writes no angles, so a
|
|
202
|
+
set-top player is the check still owed); a title's buttons over a stretch
|
|
203
|
+
are offered by libdvdnav in that stretch's VOBUs alone, and run their
|
|
204
|
+
commands
|
|
182
205
|
(`python -m unittest discover -s tests -t .`; a test is skipped when a tool
|
|
183
206
|
it needs is missing). oncemore's switch to vtskit (step 4) is merged:
|
|
184
207
|
[oncemore#90](https://github.com/kajisho5/oncemore/pull/90) and
|
|
@@ -68,18 +68,22 @@ play the discs vtskit writes.
|
|
|
68
68
|
## Installing
|
|
69
69
|
|
|
70
70
|
vtskit is one package of plain Python (3.11 or later), with nothing else
|
|
71
|
-
to install
|
|
71
|
+
to install:
|
|
72
72
|
|
|
73
73
|
```
|
|
74
|
-
pip install
|
|
74
|
+
pip install vtskit
|
|
75
75
|
```
|
|
76
76
|
|
|
77
|
-
A
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
77
|
+
A release is made by the version: when the version in `pyproject.toml`
|
|
78
|
+
(and `vtskit/__init__.py`) reaches `main` without a tag of its own,
|
|
79
|
+
`.github/workflows/release.yml` runs the tests, builds the package, makes
|
|
80
|
+
the GitHub release with its tag and publishes to PyPI. Merging the version
|
|
81
|
+
bump is the whole act; a tag `v*` pushed by hand releases the same way.
|
|
82
|
+
PyPI knows this repository and workflow as the project's trusted
|
|
83
|
+
publisher. oncemore does not install vtskit: it
|
|
84
|
+
carries a copy of each release as `oncemore/_vendor/vtskit`, taken from
|
|
85
|
+
PyPI with its `tools/vendor.py vtskit --pypi <version>` (a pull request
|
|
86
|
+
does it there each day a newer release is out).
|
|
83
87
|
|
|
84
88
|
## What is here now
|
|
85
89
|
|
|
@@ -112,9 +116,23 @@ oncemore writing its discs with vtskit.
|
|
|
112
116
|
libdvdnav a title whose post commands run out plays on into its next
|
|
113
117
|
program chain; dvdauthor writes none of these. A title may be
|
|
114
118
|
several parts in a row, streams and `Angles` blocks of 2 to 9 streams: an
|
|
115
|
-
angle block is interleaved on the disc
|
|
116
|
-
|
|
117
|
-
|
|
119
|
+
angle block is interleaved on the disc and its cells are marked as an
|
|
120
|
+
angle block. A seamless block (the default) is switched at the end of an
|
|
121
|
+
interleaved unit: its navigation packs point from each unit to every
|
|
122
|
+
angle's next (SML_AGLI) and its cells say "seamless angle"; a block
|
|
123
|
+
switched with a break (`seamless=False`) points instead at every angle's
|
|
124
|
+
VOBU of the same moment (NSML_AGLI). Only one of the two is filled, as
|
|
125
|
+
players take NSML_AGLI whenever it is there. A stream whose interleaved
|
|
126
|
+
units start with open groups of pictures (B-pictures leaning on the group
|
|
127
|
+
before, another angle's after a switch) is refused. A title may carry
|
|
128
|
+
buttons over stretches of it (`vtskit.menu.TitleButtons`: from a start
|
|
129
|
+
to an end, the buttons, the palette entries that draw them): their
|
|
130
|
+
overlay is a forced subpicture added with `vtskit.subtitles`, and the
|
|
131
|
+
highlight information goes only into the VOBUs that play during the
|
|
132
|
+
stretch, with the stretch's start and end as its start time, end time
|
|
133
|
+
and the end of the selection (dvdinfo's hli_s_ptm, hli_e_ptm,
|
|
134
|
+
btn_sl_e_ptm). dvdauthor gives a title's buttons the whole title (every
|
|
135
|
+
VOBU, never ending), which is what buttons without an end write.
|
|
118
136
|
* `vtskit.subtitles` adds subtitles to a title's program stream, as spumux
|
|
119
137
|
does: each cue a picture of up to four colours placed on the screen and
|
|
120
138
|
shown for a while, as subpicture packs a little before their time.
|
|
@@ -155,13 +173,18 @@ dvdauthor's (its buttons made with spumux), and a menu's overlay in a
|
|
|
155
173
|
normal colour is spumux's unit byte for byte; subtitles muxed by vtskit are
|
|
156
174
|
spumux's units, placed where spumux places them, and the title written
|
|
157
175
|
from that stream (4:3, and 16:9 with and without pan-scan or letterbox) is
|
|
158
|
-
dvdauthor's
|
|
176
|
+
dvdauthor's; a title with buttons over the whole of it is dvdauthor's
|
|
177
|
+
VOB byte for byte. A VIDEO_TS written by vtskit alone plays
|
|
159
178
|
its titles and chapters in order under libdvdnav, its commands jumping
|
|
160
179
|
between titles, its menus' buttons lead where they say, and its jumps into
|
|
161
180
|
another titleset's chapters land; a title with an angle block plays and
|
|
162
181
|
switches angles under libdvdnav, every pointer of its navigation packs
|
|
163
|
-
landing on the unit it names
|
|
164
|
-
|
|
182
|
+
landing on the unit it names, and the pictures libdvdnav hands a player
|
|
183
|
+
through a switch, decoded with ffmpeg, are every picture once, in order,
|
|
184
|
+
the new angle from the end of a unit (dvdauthor writes no angles, so a
|
|
185
|
+
set-top player is the check still owed); a title's buttons over a stretch
|
|
186
|
+
are offered by libdvdnav in that stretch's VOBUs alone, and run their
|
|
187
|
+
commands
|
|
165
188
|
(`python -m unittest discover -s tests -t .`; a test is skipped when a tool
|
|
166
189
|
it needs is missing). oncemore's switch to vtskit (step 4) is merged:
|
|
167
190
|
[oncemore#90](https://github.com/kajisho5/oncemore/pull/90) and
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""Angle blocks (vtskit.titleset.Angles): the interleaved layout, every
|
|
2
2
|
pointer of the navigation packs landing where the descriptions say, the
|
|
3
|
-
cells and tables of the IFO,
|
|
4
|
-
|
|
3
|
+
cells and tables of the IFO, libdvdnav playing and switching the angles,
|
|
4
|
+
and the pictures it hands a player decoded, through a switch. dvdauthor writes no angles, so there is nothing of its to compare
|
|
5
5
|
with; the structure is checked against itself and the descriptions."""
|
|
6
6
|
|
|
7
7
|
import os
|
|
@@ -23,17 +23,61 @@ except Exception: # pragma: no cover
|
|
|
23
23
|
TOOLS_OK = bool(shutil.which("ffmpeg"))
|
|
24
24
|
|
|
25
25
|
|
|
26
|
-
def encode(path, seconds, colour, gop=15):
|
|
26
|
+
def encode(path, seconds, colour, gop=15, extra=()):
|
|
27
27
|
"""A stream like oncemore's, a coloured frame around the test picture;
|
|
28
28
|
the same arguments give angles the same GOPs and VOBUs."""
|
|
29
29
|
subprocess.run(["ffmpeg", "-hide_banner", "-v", "error", "-y",
|
|
30
30
|
"-f", "lavfi", "-i", f"testsrc2=s=720x480:r=30000/1001:d={seconds}",
|
|
31
31
|
"-f", "lavfi", "-i", f"sine=f=440:d={seconds}",
|
|
32
32
|
"-vf", f"drawbox=c={colour}:t=40", "-target", "ntsc-dvd", "-aspect", "4:3",
|
|
33
|
-
"-b:v", "3000k", "-g", str(gop), "-sc_threshold", "1000000000",
|
|
33
|
+
"-b:v", "3000k", "-g", str(gop), "-sc_threshold", "1000000000", *extra,
|
|
34
34
|
"-c:a", "ac3", "-ac", "2", path], check=True)
|
|
35
35
|
|
|
36
36
|
|
|
37
|
+
def border(path, frames=None):
|
|
38
|
+
"""The colour of each picture's frame (the drawbox), as (Y, Cb, Cr)."""
|
|
39
|
+
raw = subprocess.run(["ffmpeg", "-v", "error", "-i", path] +
|
|
40
|
+
(["-frames:v", str(frames)] if frames else []) +
|
|
41
|
+
["-vf", "crop=20:20:5:5,scale=1:1:flags=area,format=yuv444p",
|
|
42
|
+
"-f", "rawvideo", "-"], capture_output=True, check=True).stdout
|
|
43
|
+
return [tuple(raw[i:i + 3]) for i in range(0, len(raw), 3)]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def play(disc, switch=None):
|
|
47
|
+
"""What libdvdnav hands a player, title domain only, as one program
|
|
48
|
+
stream (the VOBUs as delivered, navigation packs included), and the
|
|
49
|
+
angle switches made: `switch` = (after that many navigation packs, to
|
|
50
|
+
angle n)."""
|
|
51
|
+
blocks, done = [], []
|
|
52
|
+
with dvdnav_sim.DvdSim(disc, LIBDVDNAV) as d:
|
|
53
|
+
d.wait_title()
|
|
54
|
+
navs = 0
|
|
55
|
+
for _ in range(2_000_000):
|
|
56
|
+
e = d.step()
|
|
57
|
+
if e in (dvdnav_sim.EV_BLOCK_OK, dvdnav_sim.EV_NAV_PACKET) and d.domain() == "title":
|
|
58
|
+
blocks.append(bytes(d.buf.raw[:2048]))
|
|
59
|
+
if e == dvdnav_sim.EV_NAV_PACKET and d.domain() == "title":
|
|
60
|
+
navs += 1
|
|
61
|
+
if switch and not done and navs == switch[0]:
|
|
62
|
+
done.append(d.change_angle(switch[1]))
|
|
63
|
+
if d.stopped:
|
|
64
|
+
break
|
|
65
|
+
return b"".join(blocks), done
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def runs(colours, names):
|
|
69
|
+
"""The colours as [(name, pictures in a row)], each picture named after
|
|
70
|
+
the nearest of `names` ({name: (Y, Cb, Cr)})."""
|
|
71
|
+
out = []
|
|
72
|
+
for c in colours:
|
|
73
|
+
n = min(names, key=lambda k: sum((a - b) ** 2 for a, b in zip(names[k], c)))
|
|
74
|
+
if out and out[-1][0] == n:
|
|
75
|
+
out[-1][1] += 1
|
|
76
|
+
else:
|
|
77
|
+
out.append([n, 1])
|
|
78
|
+
return [tuple(x) for x in out]
|
|
79
|
+
|
|
80
|
+
|
|
37
81
|
def u32(b, o):
|
|
38
82
|
return struct.unpack_from(">I", b, o)[0]
|
|
39
83
|
|
|
@@ -109,7 +153,10 @@ class TestAngleBlock(unittest.TestCase):
|
|
|
109
153
|
# block, interleaved, the first stretch with STC discontinuity, the
|
|
110
154
|
# second seamless and "seamless angle linked in DSI"
|
|
111
155
|
self.assertEqual(cats[0], 0x02)
|
|
112
|
-
|
|
156
|
+
# (every cell of a seamless block is "seamless angle linked in DSI",
|
|
157
|
+
# the first stretch's too: libdvdnav switches it at a unit's end)
|
|
158
|
+
self.assertEqual(cats[1:4], [0x40 | 0x10 | 0x04 | 0x02 | 0x01, 0x80 | 0x10 | 0x04 | 0x02 | 0x01,
|
|
159
|
+
0xC0 | 0x10 | 0x04 | 0x02 | 0x01])
|
|
113
160
|
self.assertEqual(cats[4:7], [0x40 | 0x10 | 0x08 | 0x04 | 0x01, 0x80 | 0x10 | 0x08 | 0x04 | 0x01,
|
|
114
161
|
0xC0 | 0x10 | 0x08 | 0x04 | 0x01])
|
|
115
162
|
self.assertEqual(cats[7], 0x02)
|
|
@@ -200,17 +247,8 @@ class TestAngleBlock(unittest.TestCase):
|
|
|
200
247
|
self.assertTrue(u16(tdsi, 0x20) & 0x2000)
|
|
201
248
|
self.assertEqual(size, u32(tdsi, 0x22) + 1)
|
|
202
249
|
self.assertGreater(target, unit_end)
|
|
203
|
-
# NSML_AGLI
|
|
204
|
-
|
|
205
|
-
dsta = u32(pci, 0x3C + 4 * b)
|
|
206
|
-
if b >= 3:
|
|
207
|
-
self.assertEqual(dsta, 0)
|
|
208
|
-
continue
|
|
209
|
-
target = s + (dsta & 0x7FFFFFFF) * (-1 if dsta & 0x80000000 else 1)
|
|
210
|
-
tpci, tdsi = self.navs[target]
|
|
211
|
-
self.assertEqual(u16(tdsi, 0x18), 2 + b)
|
|
212
|
-
self.assertEqual(tpci[0x18:0x1C], pci[0x18:0x1C], "the same cell elapsed time")
|
|
213
|
-
self.assertEqual(tdsi[0x1B], dsi[0x1B], "the same cell")
|
|
250
|
+
# NSML_AGLI is left empty in a seamless block (TestBreak fills it)
|
|
251
|
+
self.assertEqual(pci[0x3C:0x60], bytes(36))
|
|
214
252
|
# the VOB start and end times of every angle VOBU are its own VOB's
|
|
215
253
|
for s in block:
|
|
216
254
|
dsi = self.navs[s][1]
|
|
@@ -236,6 +274,28 @@ class TestAngleBlock(unittest.TestCase):
|
|
|
236
274
|
self.assertIn(target, self.navs, f"VOBU at {s}, pointer {k}")
|
|
237
275
|
self.assertEqual(u16(self.navs[target][1], 0x18), vob, f"VOBU at {s}, pointer {k}")
|
|
238
276
|
|
|
277
|
+
@unittest.skipUnless(LIBDVDNAV, "needs libdvdnav")
|
|
278
|
+
def test_what_the_player_shows(self):
|
|
279
|
+
# the pictures libdvdnav hands a player through a switch from angle 1
|
|
280
|
+
# to 2, decoded: every picture once, in order (as many as the sources
|
|
281
|
+
# have), the switch at the end of an interleaved unit
|
|
282
|
+
stream, done = play(self.disc, switch=(9, 2))
|
|
283
|
+
self.assertEqual(done, [True])
|
|
284
|
+
path = os.path.join(self.tmp, "played.mpg")
|
|
285
|
+
with open(path, "wb") as f:
|
|
286
|
+
f.write(stream)
|
|
287
|
+
sources = [os.path.join(self.tmp, n) for n in ("intro.mpg", "outro.mpg", "plain.mpg")] + \
|
|
288
|
+
self.angles
|
|
289
|
+
names = dict(zip(("white", "black", "gray", "red", "green", "blue"),
|
|
290
|
+
(border(p, 1)[0] for p in sources)))
|
|
291
|
+
seen = runs(border(path), names)
|
|
292
|
+
self.assertEqual([n for n, _ in seen], ["white", "red", "green", "black", "gray"], seen)
|
|
293
|
+
counts = dict(seen)
|
|
294
|
+
self.assertEqual((counts["white"], counts["black"], counts["gray"]), (90, 60, 60))
|
|
295
|
+
self.assertEqual(counts["red"] + counts["green"], 180) # nothing twice, nothing lost
|
|
296
|
+
self.assertEqual(counts["red"] % 30, 0) # at a unit's end (a second)
|
|
297
|
+
self.assertGreater(counts["red"], 15 * 3) # (after the 9th VOBU played)
|
|
298
|
+
|
|
239
299
|
@unittest.skipUnless(LIBDVDNAV, "needs libdvdnav")
|
|
240
300
|
def test_libdvdnav_switches_angles(self):
|
|
241
301
|
played = []
|
|
@@ -268,6 +328,18 @@ class TestAngleBlock(unittest.TestCase):
|
|
|
268
328
|
self.assertEqual([c for i, c in enumerate(chapters) if i == 0 or chapters[i - 1] != c],
|
|
269
329
|
[(1, 1), (1, 2), (1, 3), (1, 4), (2, 1)])
|
|
270
330
|
|
|
331
|
+
def test_open_groups_refused(self):
|
|
332
|
+
# B-pictures shown before a unit's I-picture lean on the group before
|
|
333
|
+
# (another angle's after a switch) unless the group is closed
|
|
334
|
+
a, b = (os.path.join(self.tmp, f"open{k}.mpg") for k in (1, 2))
|
|
335
|
+
for path, colour in ((a, "red"), (b, "green")):
|
|
336
|
+
encode(path, 4, colour, extra=("-bf", "2"))
|
|
337
|
+
with self.assertRaisesRegex(VtsError, "closed groups"):
|
|
338
|
+
titleset.write(os.path.join(self.tmp, "open"), 1, [Title(parts=(Angles((a, b)),))])
|
|
339
|
+
for path, colour in ((a, "red"), (b, "green")):
|
|
340
|
+
encode(path, 4, colour, extra=("-bf", "2", "-flags", "+cgop"))
|
|
341
|
+
titleset.write(os.path.join(self.tmp, "closed"), 1, [Title(parts=(Angles((a, b)),))])
|
|
342
|
+
|
|
271
343
|
def test_refused(self):
|
|
272
344
|
short = os.path.join(self.tmp, "short.mpg")
|
|
273
345
|
encode(short, 4, "red")
|
|
@@ -279,5 +351,67 @@ class TestAngleBlock(unittest.TestCase):
|
|
|
279
351
|
titleset.write(os.path.join(self.tmp, "x"), 1, [Title(self.angles[0], parts=(self.angles[1],))])
|
|
280
352
|
|
|
281
353
|
|
|
354
|
+
@unittest.skipUnless(TOOLS_OK, "needs ffmpeg")
|
|
355
|
+
class TestBreak(unittest.TestCase):
|
|
356
|
+
"""A title that is one angle block, switched with a break (not
|
|
357
|
+
seamless), as a title of oncemore's angles is laid out: NSML_AGLI
|
|
358
|
+
filled, SML_AGLI empty, cells not marked seamless."""
|
|
359
|
+
|
|
360
|
+
@classmethod
|
|
361
|
+
def setUpClass(cls):
|
|
362
|
+
cls.tmp = tempfile.mkdtemp(prefix="vtskit-angles-break-")
|
|
363
|
+
cls.angles = [os.path.join(cls.tmp, f"angle{k}.mpg") for k in range(2)]
|
|
364
|
+
for path, colour in zip(cls.angles, ("red", "green")):
|
|
365
|
+
encode(path, 4, colour)
|
|
366
|
+
cls.disc = os.path.join(cls.tmp, "disc")
|
|
367
|
+
cls.ts = titleset.write(cls.disc, 1, [Title(parts=(Angles(tuple(cls.angles), seamless=False),),
|
|
368
|
+
chapters=(0, 2))])
|
|
369
|
+
vmg.write(cls.disc, [cls.ts])
|
|
370
|
+
cls.ifo = open(os.path.join(cls.disc, "VTS_01_0.IFO"), "rb").read()
|
|
371
|
+
cls.vob = b"".join(open(os.path.join(cls.disc, n), "rb").read()
|
|
372
|
+
for n in sorted(cls.ts.files) if n.endswith(".VOB"))
|
|
373
|
+
cls.navs = nav_packs(cls.vob)
|
|
374
|
+
|
|
375
|
+
@classmethod
|
|
376
|
+
def tearDownClass(cls):
|
|
377
|
+
shutil.rmtree(cls.tmp, ignore_errors=True)
|
|
378
|
+
|
|
379
|
+
def test_cells(self):
|
|
380
|
+
cells, program_map = pgc_cells(self.ifo)
|
|
381
|
+
self.assertEqual(program_map, [1, 3])
|
|
382
|
+
self.assertEqual([c[0][0] for c in cells],
|
|
383
|
+
[0x40 | 0x10 | 0x04 | 0x02, 0xC0 | 0x10 | 0x04 | 0x02,
|
|
384
|
+
0x40 | 0x10 | 0x04, 0xC0 | 0x10 | 0x04])
|
|
385
|
+
|
|
386
|
+
def test_pointers(self):
|
|
387
|
+
for s, (pci, dsi) in self.navs.items():
|
|
388
|
+
self.assertTrue(u16(dsi, 0x20) & 0x4000, "ILVU flag")
|
|
389
|
+
self.assertEqual(dsi[0xB4:0xB4 + 54], bytes(54), "SML_AGLI empty")
|
|
390
|
+
for b in range(9):
|
|
391
|
+
dsta = u32(pci, 0x3C + 4 * b)
|
|
392
|
+
if b >= 2:
|
|
393
|
+
self.assertEqual(dsta, 0)
|
|
394
|
+
continue
|
|
395
|
+
target = s + (dsta & 0x7FFFFFFF) * (-1 if dsta & 0x80000000 else 1)
|
|
396
|
+
tpci, tdsi = self.navs[target]
|
|
397
|
+
self.assertEqual(u16(tdsi, 0x18), 1 + b)
|
|
398
|
+
self.assertEqual(tpci[0x18:0x1C], pci[0x18:0x1C], "the same cell elapsed time")
|
|
399
|
+
self.assertEqual(tdsi[0x1B], dsi[0x1B], "the same cell")
|
|
400
|
+
|
|
401
|
+
@unittest.skipUnless(LIBDVDNAV, "needs libdvdnav")
|
|
402
|
+
def test_switches(self):
|
|
403
|
+
stream, done = play(self.disc, switch=(3, 2))
|
|
404
|
+
self.assertEqual(done, [True])
|
|
405
|
+
path = os.path.join(self.tmp, "played.mpg")
|
|
406
|
+
with open(path, "wb") as f:
|
|
407
|
+
f.write(stream)
|
|
408
|
+
names = {"red": border(self.angles[0], 1)[0], "green": border(self.angles[1], 1)[0]}
|
|
409
|
+
seen = runs(border(path), names)
|
|
410
|
+
# angle 1, then angle 2 to the end (from the moment it was at: a
|
|
411
|
+
# VOBU may come again, as a break)
|
|
412
|
+
self.assertEqual([n for n, _ in seen], ["red", "green"], seen)
|
|
413
|
+
self.assertGreaterEqual(sum(n for _, n in seen), 120)
|
|
414
|
+
|
|
415
|
+
|
|
282
416
|
if __name__ == "__main__":
|
|
283
417
|
unittest.main()
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""Buttons over a stretch of a title (vtskit.menu.TitleButtons): the VOB
|
|
2
|
+
checked byte for byte against dvdauthor's for buttons over the whole
|
|
3
|
+
title, the highlight information only in the VOBUs of a stretch, and
|
|
4
|
+
libdvdnav offering the buttons during the stretch alone and running their
|
|
5
|
+
commands."""
|
|
6
|
+
|
|
7
|
+
import ctypes
|
|
8
|
+
import os
|
|
9
|
+
import shutil
|
|
10
|
+
import struct
|
|
11
|
+
import subprocess
|
|
12
|
+
import sys
|
|
13
|
+
import tempfile
|
|
14
|
+
import unittest
|
|
15
|
+
|
|
16
|
+
from vtskit import VtsError, menu, ps, spu, subtitles, titleset, vm, vmg
|
|
17
|
+
from vtskit.menu import Button, TitleButtons
|
|
18
|
+
from vtskit.titleset import Audio, Title
|
|
19
|
+
|
|
20
|
+
from .test_menu import RECTS, png
|
|
21
|
+
from .test_titleset import encode
|
|
22
|
+
|
|
23
|
+
try:
|
|
24
|
+
from . import dvdnav_sim
|
|
25
|
+
LIBDVDNAV = dvdnav_sim.load_library()
|
|
26
|
+
except Exception: # pragma: no cover
|
|
27
|
+
LIBDVDNAV = None
|
|
28
|
+
|
|
29
|
+
TOOLS_OK = bool(shutil.which("ffmpeg") and shutil.which("dvdauthor") and shutil.which("spumux"))
|
|
30
|
+
ENV = {**os.environ, "VIDEO_FORMAT": "NTSC"}
|
|
31
|
+
YELLOW, RED = (0xD2, 0x92, 0x10), (0x52, 0xF0, 0x5A)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def read(path):
|
|
35
|
+
with open(path, "rb") as f:
|
|
36
|
+
return f.read()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def buttons(start, end, first=(vm.jump_vts_ptt(1, 1), vm.exit())):
|
|
40
|
+
return TitleButtons(start, end, [Button(RECTS[0], first[0].encode(), up=2, down=2),
|
|
41
|
+
Button(RECTS[1], first[1].encode(), up=1, down=1)],
|
|
42
|
+
highlight=(0, 15), select=(1, 15))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def navs(vob):
|
|
46
|
+
"""(sector, VOBU s_ptm, the PCI's highlight information) of every navigation pack."""
|
|
47
|
+
out = []
|
|
48
|
+
for pk in ps.iter_packs(vob):
|
|
49
|
+
if pk.nav:
|
|
50
|
+
o = pk.sector * ps.SECTOR + 0x2D
|
|
51
|
+
out.append((pk.sector, struct.unpack_from(">I", vob, o + 0x0C)[0], vob[o + 0x60:o + 0x316]))
|
|
52
|
+
return out
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class TestFields(unittest.TestCase):
|
|
56
|
+
def test_hli(self):
|
|
57
|
+
h = buttons(1.0, 2.0).hli(1000, 2000)
|
|
58
|
+
self.assertEqual(struct.unpack_from(">HIIIHBBBBBB", h, 0),
|
|
59
|
+
(1, 1000, 2000, 2000, 0x1000, 0, 2, 2, 0, 0, 0))
|
|
60
|
+
self.assertEqual(h[0x16:0x1E].hex(), "000000f0001000f0") # dvdauthor's for a title
|
|
61
|
+
self.assertEqual(h[0x2E:0x34].hex(), "46412c06408c") # colour table 1
|
|
62
|
+
|
|
63
|
+
def test_cue(self):
|
|
64
|
+
c = buttons(1.0, 3.5).cue(720, 480)
|
|
65
|
+
self.assertEqual((c.start, c.end, c.x, c.y, c.forced), (1.0, 3.5, 100, 100, True))
|
|
66
|
+
self.assertEqual((c.picture.width, c.picture.height), (200, 140))
|
|
67
|
+
self.assertEqual((c.colours, c.contrasts), ((0, 0, 0, 0), (0, 0, 0, 0)))
|
|
68
|
+
area, pixels, cmds = spu.decode(subtitles.unit(c))
|
|
69
|
+
self.assertEqual(area, (100, 299, 100, 239))
|
|
70
|
+
self.assertEqual(cmds[0], 0x00) # forced start
|
|
71
|
+
self.assertEqual(sum(1 for v in pixels.values() if v == 1), 2 * 200 * 40)
|
|
72
|
+
shown = TitleButtons(0, None, buttons(0, 1).buttons, (0, 15), (1, 15), normal=(2, 9))
|
|
73
|
+
c = shown.cue(720, 480, length=8)
|
|
74
|
+
self.assertEqual((c.end, c.colours, c.contrasts), (8, (0, 2, 0, 0), (0, 9, 0, 0)))
|
|
75
|
+
with self.assertRaisesRegex(VtsError, "length"):
|
|
76
|
+
shown.cue(720, 480)
|
|
77
|
+
|
|
78
|
+
def test_refused(self):
|
|
79
|
+
bad = [TitleButtons(2.0, 1.0, buttons(0, 1).buttons, (0, 15), (1, 15)),
|
|
80
|
+
TitleButtons(0, 1, buttons(0, 1).buttons, (16, 15), (1, 15)),
|
|
81
|
+
TitleButtons(0, 1, buttons(0, 1).buttons, (0, 15), (1,)),
|
|
82
|
+
TitleButtons(0, 1, [], (0, 15), (1, 15)),
|
|
83
|
+
TitleButtons(0, 1, [Button((0, 0, 800, 10), bytes(8))], (0, 15), (1, 15)),
|
|
84
|
+
TitleButtons(0, 1, buttons(0, 1).buttons, (0, 15), (1, 15), first_button=3)]
|
|
85
|
+
for tb in bad:
|
|
86
|
+
with self.assertRaises(VtsError, msg=tb):
|
|
87
|
+
menu.check_title_buttons(tb, 720, 480, "t")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@unittest.skipUnless(TOOLS_OK, "needs ffmpeg, spumux and dvdauthor")
|
|
91
|
+
class TestTitleButtons(unittest.TestCase):
|
|
92
|
+
@classmethod
|
|
93
|
+
def setUpClass(cls):
|
|
94
|
+
cls.tmp = t = tempfile.mkdtemp(prefix="vtskit-tbtn-")
|
|
95
|
+
cls.plain = os.path.join(t, "plain.mpg")
|
|
96
|
+
encode(cls.plain, seconds=10)
|
|
97
|
+
cls.short = os.path.join(t, "short.mpg")
|
|
98
|
+
encode(cls.short, seconds=2)
|
|
99
|
+
|
|
100
|
+
@classmethod
|
|
101
|
+
def tearDownClass(cls):
|
|
102
|
+
shutil.rmtree(cls.tmp, ignore_errors=True)
|
|
103
|
+
|
|
104
|
+
def test_whole_title_as_dvdauthor(self):
|
|
105
|
+
# spumux's buttons over the whole title, then dvdauthor; vtskit
|
|
106
|
+
# given the same stream and buttons without an end writes the same
|
|
107
|
+
# VOB (dvdauthor writes the HLI in every VOBU, never ending)
|
|
108
|
+
t = self.tmp
|
|
109
|
+
png(f"{t}/hl.png", 720, 480, RECTS, (255, 255, 0))
|
|
110
|
+
png(f"{t}/sel.png", 720, 480, RECTS, (255, 0, 0))
|
|
111
|
+
with open(f"{t}/spu.xml", "w") as f:
|
|
112
|
+
f.write('<subpictures format="NTSC"><stream><spu start="00:00:00.00" end="00:00:10.00" '
|
|
113
|
+
f'highlight="{t}/hl.png" select="{t}/sel.png">'
|
|
114
|
+
'<button name="b1" x0="100" y0="100" x1="300" y1="140" up="b2" down="b2"/>'
|
|
115
|
+
'<button name="b2" x0="100" y0="200" x1="300" y1="240" up="b1" down="b1"/>'
|
|
116
|
+
'</spu></stream></subpictures>')
|
|
117
|
+
sp = f"{t}/buttons.mpg"
|
|
118
|
+
with open(self.plain, "rb") as src, open(sp, "wb") as dst:
|
|
119
|
+
subprocess.run(["spumux", "-s", "0", f"{t}/spu.xml"], stdin=src, stdout=dst, check=True,
|
|
120
|
+
stderr=subprocess.DEVNULL, env=ENV)
|
|
121
|
+
with open(f"{t}/dvd.xml", "w") as f:
|
|
122
|
+
f.write(f'<dvdauthor dest="{t}/dvd"><vmgm/><titleset><titles>'
|
|
123
|
+
'<video format="ntsc" aspect="4:3"/><audio format="ac3" channels="2" lang="en"/>'
|
|
124
|
+
f'<pgc><vob file="{sp}"/><button name="b1">jump title 1 chapter 1;</button>'
|
|
125
|
+
'<button name="b2">exit;</button></pgc></titles></titleset></dvdauthor>')
|
|
126
|
+
subprocess.run(["dvdauthor", "-x", f"{t}/dvd.xml"], check=True, capture_output=True, env=ENV)
|
|
127
|
+
mine = f"{t}/mine-whole"
|
|
128
|
+
titleset.write(mine, 1, [Title(sp, audio=(Audio("en", 2),), buttons=(buttons(0, None),))])
|
|
129
|
+
a, b = read(f"{mine}/VTS_01_1.VOB"), read(f"{t}/dvd/VIDEO_TS/VTS_01_1.VOB")
|
|
130
|
+
self.assertEqual([n[2] for n in navs(a)], [n[2] for n in navs(b)])
|
|
131
|
+
self.assertEqual(a, b)
|
|
132
|
+
|
|
133
|
+
def stretch_disc(self, start, end):
|
|
134
|
+
"""Title 1 (10 s) with buttons from `start` to `end` (button 1:
|
|
135
|
+
title 2, button 2: exit), title 2 short."""
|
|
136
|
+
t = self.tmp
|
|
137
|
+
tb = buttons(start, end, (vm.jump_vts_tt(2), vm.exit()))
|
|
138
|
+
mpg = f"{t}/stretch-{start}-{end}.mpg"
|
|
139
|
+
with open(mpg, "wb") as f:
|
|
140
|
+
f.write(subtitles.mux(read(self.plain), {0: [tb.cue(720, 480)]}))
|
|
141
|
+
out = f"{t}/disc-{start}-{end}"
|
|
142
|
+
ts = titleset.write(out, 1, [Title(mpg, audio=(Audio("en", 2),), palette=(YELLOW, RED),
|
|
143
|
+
buttons=(tb,)),
|
|
144
|
+
Title(self.short, audio=(Audio("en", 2),))])
|
|
145
|
+
vmg.write(out, [ts])
|
|
146
|
+
return out
|
|
147
|
+
|
|
148
|
+
def test_hli_in_the_stretch_only(self):
|
|
149
|
+
out = self.stretch_disc(3.0, 6.0)
|
|
150
|
+
packs = navs(read(f"{out}/VTS_01_1.VOB"))
|
|
151
|
+
packs = packs[:next(k for k in range(1, len(packs)) if packs[k][1] < packs[k - 1][1])] # title 1's
|
|
152
|
+
s0 = packs[0][1]
|
|
153
|
+
ends = [p[1] for p in packs[1:]] + [None]
|
|
154
|
+
lit = 0
|
|
155
|
+
for (sector, s_ptm, h), e_ptm in zip(packs, ends):
|
|
156
|
+
during = (e_ptm is None or e_ptm > s0 + 270000) and s_ptm < s0 + 540000
|
|
157
|
+
if during:
|
|
158
|
+
lit += 1
|
|
159
|
+
self.assertEqual(struct.unpack_from(">HIII", h, 0),
|
|
160
|
+
(1, s0 + 270000, s0 + 540000, s0 + 540000), sector)
|
|
161
|
+
self.assertEqual(h[17], 2) # btn_ns
|
|
162
|
+
else:
|
|
163
|
+
self.assertEqual(h, bytes(len(h)), sector) # hli_ss 0, nothing
|
|
164
|
+
self.assertGreater(lit, 4)
|
|
165
|
+
self.assertLess(lit, len(packs) - 4)
|
|
166
|
+
|
|
167
|
+
def test_refused_in_a_title(self):
|
|
168
|
+
def write(*tbs, stream=None):
|
|
169
|
+
titleset.write(os.path.join(self.tmp, "refused"), 1,
|
|
170
|
+
[Title(stream or self.plain, audio=(Audio("en", 2),), buttons=tbs)])
|
|
171
|
+
with self.assertRaisesRegex(VtsError, "overlap"):
|
|
172
|
+
write(buttons(1, 4), buttons(3, 5))
|
|
173
|
+
with self.assertRaisesRegex(VtsError, "overlap"):
|
|
174
|
+
write(buttons(1, None), buttons(3, 5))
|
|
175
|
+
with self.assertRaisesRegex(VtsError, "one set of buttons"):
|
|
176
|
+
write(buttons(1, 2.8), buttons(2.9, 5)) # in the VOBU from 2.4 s to 3.0 s
|
|
177
|
+
with self.assertRaisesRegex(VtsError, "after the title ends"):
|
|
178
|
+
write(buttons(30, 40))
|
|
179
|
+
|
|
180
|
+
@unittest.skipUnless(LIBDVDNAV, "needs libdvdnav")
|
|
181
|
+
def test_under_libdvdnav(self):
|
|
182
|
+
out = self.stretch_disc(3.0, 6.0)
|
|
183
|
+
with dvdnav_sim.DvdSim(out, LIBDVDNAV) as d:
|
|
184
|
+
self.assertEqual(d.wait_title(), (1, 1))
|
|
185
|
+
seen = [] # (VOBU start, buttons) through title 1
|
|
186
|
+
e = dvdnav_sim.EV_NAV_PACKET
|
|
187
|
+
while not d.stopped: # (title 1 ends with Exit)
|
|
188
|
+
if e == dvdnav_sim.EV_NAV_PACKET and d.domain() == "title":
|
|
189
|
+
self.assertEqual(d.title_part()[0], 1)
|
|
190
|
+
raw = ctypes.string_at(LIBDVDNAV.dvdnav_get_current_nav_pci(d.h), 16)
|
|
191
|
+
seen.append((int.from_bytes(raw[12:16], sys.byteorder), d.button_count()))
|
|
192
|
+
e = d.step()
|
|
193
|
+
s0 = seen[0][0]
|
|
194
|
+
starts = [s for s, _ in seen]
|
|
195
|
+
ends = starts[1:] + [None]
|
|
196
|
+
expect = [2 if (e is None or e > s0 + 270000) and s < s0 + 540000 else 0
|
|
197
|
+
for s, e in zip(starts, ends)]
|
|
198
|
+
self.assertEqual([n for _, n in seen], expect)
|
|
199
|
+
self.assertIn(0, expect[:2])
|
|
200
|
+
# the first button, pressed while the buttons are there: title 2
|
|
201
|
+
with dvdnav_sim.DvdSim(out, LIBDVDNAV) as d:
|
|
202
|
+
d.wait_title()
|
|
203
|
+
d.run_until(lambda e: e == dvdnav_sim.EV_NAV_PACKET and d.button_count() > 0)
|
|
204
|
+
self.assertEqual(d.title_part(), (1, 1))
|
|
205
|
+
d.activate(1)
|
|
206
|
+
self.assertEqual(d.wait_title(), (2, 1))
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
if __name__ == "__main__":
|
|
210
|
+
unittest.main()
|
|
@@ -14,6 +14,17 @@ normal, highlight, selection, and the overlay draws palette 0.
|
|
|
14
14
|
A menu without a stream is a program chain of commands alone (its `pre`
|
|
15
15
|
runs and leaves), as dvdauthor writes a `<pgc>` without a `<vob>`.
|
|
16
16
|
|
|
17
|
+
A title may carry buttons too, over a stretch of it (TitleButtons): their
|
|
18
|
+
overlay is a forced subpicture shown for the stretch (`TitleButtons.cue`,
|
|
19
|
+
added to the title's stream with vtskit.subtitles.mux), and the HLI is in
|
|
20
|
+
the navigation packs of the VOBUs that play during the stretch, with the
|
|
21
|
+
stretch's start and end as its start time (hli_s_ptm), end time (hli_e_ptm)
|
|
22
|
+
and the end of the user's selection (btn_sl_e_ptm: "ignore user after
|
|
23
|
+
this", dvdinfo); the other VOBUs have none (hli_ss 0: "no highlight
|
|
24
|
+
information for this VOBU"). dvdauthor gives a title's buttons the whole
|
|
25
|
+
title instead: the HLI in every VOBU, from the first picture, never ending,
|
|
26
|
+
which is what a stretch without an end writes.
|
|
27
|
+
|
|
17
28
|
From dvdauthor's output: the HLI is repeated whole in every VOBU
|
|
18
29
|
(HLI_SS 1), its start time is the menu's first picture, its end times are
|
|
19
30
|
0xFFFFFFFF (never), the buttons form one group of the normal kind, every
|
|
@@ -25,7 +36,7 @@ activated.
|
|
|
25
36
|
import struct
|
|
26
37
|
from dataclasses import dataclass, field
|
|
27
38
|
|
|
28
|
-
from . import spu
|
|
39
|
+
from . import spu, subtitles
|
|
29
40
|
from .errors import VtsError
|
|
30
41
|
|
|
31
42
|
YELLOW, RED = (0xD2, 0x92, 0x10), (0x52, 0xF0, 0x5A) # Y, Cr, Cb
|
|
@@ -73,6 +84,69 @@ class Menu:
|
|
|
73
84
|
uops: int = 0 # user operations the menu forbids (bit n: vtskit.vm.UOP_*)
|
|
74
85
|
|
|
75
86
|
|
|
87
|
+
@dataclass
|
|
88
|
+
class TitleButtons:
|
|
89
|
+
"""Buttons over a stretch of a title: from `start` to `end` (seconds
|
|
90
|
+
from the title's first picture; `end` None: to the end, the buttons
|
|
91
|
+
never taken off), the buttons, the entries of the title's palette
|
|
92
|
+
(0-15) and contrasts (0-15) drawing a button selected (`highlight`) and
|
|
93
|
+
activated (`select`) as (entry, contrast), the entry and contrast the
|
|
94
|
+
buttons show in otherwise (`normal`; None: nothing), the overlay (code
|
|
95
|
+
1 where a button lights up; by default the buttons' rectangles), the
|
|
96
|
+
button selected when they show (0: the player's choice) and the number
|
|
97
|
+
added to a number key's digit."""
|
|
98
|
+
start: float
|
|
99
|
+
end: float | None
|
|
100
|
+
buttons: list
|
|
101
|
+
highlight: tuple
|
|
102
|
+
select: tuple
|
|
103
|
+
normal: tuple | None = None
|
|
104
|
+
overlay: object = None
|
|
105
|
+
first_button: int = 0
|
|
106
|
+
button_offset: int = 0
|
|
107
|
+
|
|
108
|
+
def cue(self, width, height, length=None):
|
|
109
|
+
"""The overlay as a subtitle cue for vtskit.subtitles.mux: forced
|
|
110
|
+
(shown with subtitles off), from `start` to `end` (or `length`
|
|
111
|
+
seconds, the title's, for buttons without an end), transparent or
|
|
112
|
+
in the normal colour."""
|
|
113
|
+
end = self.end if self.end is not None else length
|
|
114
|
+
if end is None:
|
|
115
|
+
raise VtsError("buttons without an end: give the title's length")
|
|
116
|
+
o = self.overlay if self.overlay is not None else \
|
|
117
|
+
spu.Overlay.from_rects(width, height, [b.rect for b in self.buttons])
|
|
118
|
+
if (o.width, o.height) != (width, height):
|
|
119
|
+
raise VtsError("the overlay is not the picture's size")
|
|
120
|
+
box = o.bounds()
|
|
121
|
+
if box is None:
|
|
122
|
+
raise VtsError("the overlay is blank")
|
|
123
|
+
x0, y0, x1, y1 = box
|
|
124
|
+
crop = b"".join(o.pixels[y * width + x0:y * width + x1 + 1] for y in range(y0, y1 + 1))
|
|
125
|
+
entry, contrast = self.normal if self.normal is not None else (0, 0)
|
|
126
|
+
return subtitles.Cue(self.start, end, spu.Overlay(x1 - x0 + 1, y1 - y0 + 1, crop), x0, y0,
|
|
127
|
+
colours=(0, entry, 0, 0), contrasts=(0, contrast, 0, 0), forced=True)
|
|
128
|
+
|
|
129
|
+
def hli(self, s_ptm, e_ptm):
|
|
130
|
+
"""The highlight information shown from `s_ptm` to `e_ptm` (the
|
|
131
|
+
stretch's start and end in the VOB's time)."""
|
|
132
|
+
return _hli(self.buttons, s_ptm, e_ptm, self.button_offset, self.first_button,
|
|
133
|
+
self.highlight + self.select)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def check_title_buttons(tb, width, height, what):
|
|
137
|
+
"""Refuse buttons a title cannot carry."""
|
|
138
|
+
for name, pair in (("highlight", tb.highlight), ("selection", tb.select), ("normal", tb.normal)):
|
|
139
|
+
if pair is None and name == "normal":
|
|
140
|
+
continue
|
|
141
|
+
if len(pair) != 2 or not all(isinstance(v, int) and 0 <= v <= 15 for v in pair):
|
|
142
|
+
raise VtsError(f"{what}: the {name} is (palette entry 0-15, contrast 0-15)")
|
|
143
|
+
if tb.start < 0 or tb.end is not None and tb.end <= tb.start:
|
|
144
|
+
raise VtsError(f"{what}: buttons from {tb.start} to {tb.end} s")
|
|
145
|
+
if not tb.buttons:
|
|
146
|
+
raise VtsError(f"{what}: no buttons")
|
|
147
|
+
_check_buttons(tb.buttons, tb.first_button, tb.button_offset, width, height)
|
|
148
|
+
|
|
149
|
+
|
|
76
150
|
ENTRY_TYPES = {"title": 2, "root": 3, "subpicture": 4, "audio": 5, "angle": 6, "ptt": 7}
|
|
77
151
|
ENTRY_FLAGS = {"title": 0x80, "root": 0x80, "subpicture": 0x40, "audio": 0x20, "angle": 0x10, "ptt": 0x08}
|
|
78
152
|
|
|
@@ -98,18 +172,24 @@ def check(menu, width, height):
|
|
|
98
172
|
_colour(menu.normal, "normal")
|
|
99
173
|
if menu.entry is not None and menu.entry not in ENTRY_TYPES:
|
|
100
174
|
raise VtsError(f"{menu.entry!r} is not a menu entry kind")
|
|
101
|
-
|
|
175
|
+
_check_buttons(menu.buttons, menu.first_button, menu.button_offset, width, height)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _check_buttons(buttons, first_button, button_offset, width, height):
|
|
179
|
+
if len(buttons) > 36:
|
|
180
|
+
raise VtsError(f"{len(buttons)} buttons (at most 36)")
|
|
181
|
+
for n, b in enumerate(buttons, 1):
|
|
102
182
|
x0, y0, x1, y1 = b.rect
|
|
103
183
|
if not (0 <= x0 < x1 <= width and 0 <= y0 < y1 <= height):
|
|
104
184
|
raise VtsError(f"button {n}: {b.rect} is not inside {width}x{height}")
|
|
105
185
|
if len(b.command) != 8:
|
|
106
186
|
raise VtsError(f"button {n}: a command is 8 bytes")
|
|
107
187
|
for k in (b.up, b.down, b.left, b.right):
|
|
108
|
-
if not 0 <= k <= len(
|
|
109
|
-
raise VtsError(f"button {n} points at button {k}, which
|
|
110
|
-
if not 0 <=
|
|
111
|
-
raise VtsError(f"first button {
|
|
112
|
-
if not 0 <=
|
|
188
|
+
if not 0 <= k <= len(buttons):
|
|
189
|
+
raise VtsError(f"button {n} points at button {k}, which is not there")
|
|
190
|
+
if not 0 <= first_button <= len(buttons):
|
|
191
|
+
raise VtsError(f"first button {first_button}: there are {len(buttons)}")
|
|
192
|
+
if not 0 <= button_offset <= 255:
|
|
113
193
|
raise VtsError("the button offset is 0 to 255")
|
|
114
194
|
|
|
115
195
|
|
|
@@ -145,18 +225,28 @@ def unit(menu, width, height):
|
|
|
145
225
|
def hli(menu, s_ptm):
|
|
146
226
|
"""The highlight information of the menu's navigation packs (the PCI
|
|
147
227
|
from 0x60 to 0x316), shown from `s_ptm`, the menu's first picture."""
|
|
148
|
-
|
|
228
|
+
first = 1 if menu.normal is not None else 0
|
|
229
|
+
return _hli(menu.buttons, s_ptm, 0xFFFFFFFF, menu.button_offset, menu.first_button,
|
|
230
|
+
(first, _colour(menu.highlight, "highlight")[1],
|
|
231
|
+
first + 1, _colour(menu.select, "selection")[1]))
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _hli(buttons, s_ptm, e_ptm, button_offset, first_button, colours):
|
|
235
|
+
"""The highlight information (PCI 0x60 to 0x316): the buttons, shown
|
|
236
|
+
from `s_ptm` to `e_ptm` (hli_s_ptm, hli_e_ptm; the user's selection
|
|
237
|
+
ends with them, btn_sl_e_ptm), drawn selected and activated with the
|
|
238
|
+
palette entries and contrasts `colours` (entry, contrast, entry,
|
|
239
|
+
contrast)."""
|
|
149
240
|
out = bytearray(0x316 - 0x60)
|
|
150
241
|
if not buttons:
|
|
151
242
|
return bytes(out)
|
|
152
|
-
struct.pack_into(">HIIIHBBBBBB", out, 0, 1, s_ptm,
|
|
153
|
-
|
|
243
|
+
struct.pack_into(">HIIIHBBBBBB", out, 0, 1, s_ptm, e_ptm, e_ptm, 0x1000,
|
|
244
|
+
button_offset, len(buttons), len(buttons), 0, first_button, 0)
|
|
154
245
|
# colour table 1: the pattern code, the highlight's palette entry and
|
|
155
246
|
# contrast when selected, the selection's when activated; the other
|
|
156
247
|
# codes stay as they are
|
|
157
|
-
|
|
158
|
-
out[0x16:0x1E] = bytes((0x00,
|
|
159
|
-
0x00, (first + 1) << 4, 0x00, _colour(menu.select, "selection")[1] << 4))
|
|
248
|
+
hl, hl_c, sel, sel_c = colours
|
|
249
|
+
out[0x16:0x1E] = bytes((0x00, hl << 4, 0x00, hl_c << 4, 0x00, sel << 4, 0x00, sel_c << 4))
|
|
160
250
|
for n, b in enumerate(buttons):
|
|
161
251
|
x0, y0, x1, y1 = b.rect
|
|
162
252
|
e = 0x2E + 18 * n
|
|
@@ -179,7 +179,8 @@ def _subpicture_sync(stream, vs, pos, covered):
|
|
|
179
179
|
HLI = 0x60 # the highlight information in the PCI data, to 0x316
|
|
180
180
|
|
|
181
181
|
|
|
182
|
-
def fill(data, cells, lbn=0, uops=0, stream=None, hli=None, layout=None, ilvu=None, nsml=None
|
|
182
|
+
def fill(data, cells, lbn=0, uops=0, stream=None, hli=None, layout=None, ilvu=None, nsml=None,
|
|
183
|
+
highlights=None):
|
|
183
184
|
"""Fill the navigation packs of a VOB in place (a bytearray or a
|
|
184
185
|
writable mmap).
|
|
185
186
|
|
|
@@ -192,7 +193,9 @@ def fill(data, cells, lbn=0, uops=0, stream=None, hli=None, layout=None, ilvu=No
|
|
|
192
193
|
every navigation pack of that VOB. `ilvu`: {VOBU index: (flags, end,
|
|
193
194
|
next start, next size, [(angle's next unit, size)] * 9)}, the SML_PBI
|
|
194
195
|
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.
|
|
196
|
+
index: [angle's VOBU] * 9}, the PCI's NSML_AGLI. `highlights`: {VOBU
|
|
197
|
+
index: highlight information}, buttons in some VOBUs only (a title's).
|
|
198
|
+
Returns the VOBUs."""
|
|
196
199
|
stream = stream or ps.scan(data)
|
|
197
200
|
vs = vobus(stream)
|
|
198
201
|
pos = list(layout) if layout is not None else [lbn + v.start for v in vs]
|
|
@@ -232,10 +235,13 @@ def fill(data, cells, lbn=0, uops=0, stream=None, hli=None, layout=None, ilvu=No
|
|
|
232
235
|
v.s_ptm, v.e_ptm, 0, int.from_bytes(eltm, "big"))
|
|
233
236
|
if i in nsml:
|
|
234
237
|
struct.pack_into(">9I", pci, 0x3C, *nsml[i])
|
|
235
|
-
|
|
236
|
-
|
|
238
|
+
h = highlights.get(i) if highlights else None
|
|
239
|
+
if h is None and hli and vob_id in hli:
|
|
240
|
+
h = hli[vob_id]
|
|
241
|
+
if h is not None:
|
|
242
|
+
if len(h) != 0x316 - HLI:
|
|
237
243
|
raise VtsError("the highlight information is 0x2B6 bytes")
|
|
238
|
-
pci[HLI:0x316] =
|
|
244
|
+
pci[HLI:0x316] = h
|
|
239
245
|
dsi = bytearray(0x3FA - 1)
|
|
240
246
|
# (fewer than three reference pictures: the last one's end again,
|
|
241
247
|
# as dvdauthor writes it)
|
|
@@ -198,13 +198,14 @@ class Mapped:
|
|
|
198
198
|
|
|
199
199
|
|
|
200
200
|
class Picture:
|
|
201
|
-
__slots__ = ("type", "start", "end", "pts", "fields", "order")
|
|
201
|
+
__slots__ = ("type", "start", "end", "pts", "fields", "order", "closed")
|
|
202
202
|
|
|
203
203
|
def __init__(self, type_, start, pts, order):
|
|
204
204
|
self.type, self.start, self.pts = type_, start, pts
|
|
205
205
|
self.end = None # sector of the picture's last byte
|
|
206
206
|
self.fields = 2
|
|
207
207
|
self.order = order # temporal_reference: its place in its group's display order
|
|
208
|
+
self.closed = None # the first picture of a group: its header's closed_gop (13818-2)
|
|
208
209
|
|
|
209
210
|
|
|
210
211
|
class _Video:
|
|
@@ -221,6 +222,7 @@ class _Video:
|
|
|
221
222
|
self.before = None # the sector of the byte before the tail
|
|
222
223
|
self.pending_pts = None # the PTS of the packet the tail's first start code is in
|
|
223
224
|
self.last_sector = None
|
|
225
|
+
self.gop_closed = None # the closed_gop of the group header just read
|
|
224
226
|
|
|
225
227
|
def feed(self, chunk, sector, pts):
|
|
226
228
|
buf = self.tail + chunk
|
|
@@ -253,6 +255,7 @@ class _Video:
|
|
|
253
255
|
buf[k + 7] >> 4, buf[k + 7] & 0x0F)
|
|
254
256
|
elif code == _GOP:
|
|
255
257
|
self.groups.append([])
|
|
258
|
+
self.gop_closed = bool(buf[k + 7] & 0x40) # (after the 25-bit time code)
|
|
256
259
|
elif code == _PICTURE:
|
|
257
260
|
t = "?IPB"[(buf[k + 5] >> 3) & 7]
|
|
258
261
|
# a packet's PTS belongs to the first picture that starts in it
|
|
@@ -263,6 +266,7 @@ class _Video:
|
|
|
263
266
|
else:
|
|
264
267
|
own = None
|
|
265
268
|
picture = Picture(t, sector_of(k), own, buf[k + 4] << 2 | buf[k + 5] >> 6)
|
|
269
|
+
picture.closed, self.gop_closed = self.gop_closed, None
|
|
266
270
|
if not self.groups or t == "I" and self.groups[-1]:
|
|
267
271
|
self.groups.append([]) # (a group without its header)
|
|
268
272
|
self.groups[-1].append(picture)
|
|
@@ -41,12 +41,15 @@ or an Angles block of 2 to 9 streams the viewer switches between. An angle
|
|
|
41
41
|
block is interleaved (dvdinfo dsi_pkt.html, pgc.html): each angle's VOBUs
|
|
42
42
|
are cut into interleaved units (ILVUs) of the same number of VOBUs, and the
|
|
43
43
|
units take turns on the disc, angle 1's first, then angle 2's, ... then
|
|
44
|
-
angle 1's second; every VOBU's DSI says where its unit ends
|
|
45
|
-
angle's next unit is
|
|
46
|
-
every
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
44
|
+
angle 1's second; every VOBU's DSI says where its unit ends and where its
|
|
45
|
+
angle's next unit is. A seamless block (the default) is switched at the
|
|
46
|
+
end of a unit: every DSI says where every angle's next unit is (SML_AGLI)
|
|
47
|
+
and every cell is marked "seamless angle linked in DSI"; a block switched
|
|
48
|
+
with a break says instead, in every PCI, where every angle's VOBU of the
|
|
49
|
+
same moment is (NSML_AGLI). Only one of the two is filled: libdvdnav takes
|
|
50
|
+
NSML_AGLI whenever it is there. The three VOBUs before a block carry the
|
|
51
|
+
PREU flags; the block's cells are one an angle, marked first, middle and
|
|
52
|
+
last of an angle block, and interleaved. dvdauthor writes no angles, so none of this is
|
|
50
53
|
checked against it: the structure is checked against the descriptions and
|
|
51
54
|
libdvdnav plays and switches the angles (tests/test_angles.py); a set-top
|
|
52
55
|
player is the check still owed.
|
|
@@ -114,7 +117,11 @@ class Title:
|
|
|
114
117
|
writes none (titles are chained with commands instead). Under
|
|
115
118
|
libdvdnav, a title whose post commands run out plays on into its next
|
|
116
119
|
program chain, and one without a next stops (tests/test_titleset.py);
|
|
117
|
-
what players make of them is otherwise not described.
|
|
120
|
+
what players make of them is otherwise not described.
|
|
121
|
+
|
|
122
|
+
`buttons`: vtskit.menu.TitleButtons, buttons over stretches of the
|
|
123
|
+
title that do not overlap; their overlays must be in the stream
|
|
124
|
+
already (TitleButtons.cue, vtskit.subtitles.mux)."""
|
|
118
125
|
stream: str | None = None
|
|
119
126
|
chapters: tuple = (0.0,)
|
|
120
127
|
audio: tuple = ()
|
|
@@ -129,6 +136,7 @@ class Title:
|
|
|
129
136
|
next_pgc: int = 0
|
|
130
137
|
prev_pgc: int = 0
|
|
131
138
|
up_pgc: int = 0
|
|
139
|
+
buttons: tuple = () # vtskit.menu.TitleButtons over stretches of the title
|
|
132
140
|
|
|
133
141
|
|
|
134
142
|
@dataclass
|
|
@@ -150,6 +158,7 @@ class Cell:
|
|
|
150
158
|
stc_discontinuity: bool = True # the first cell of its VOB
|
|
151
159
|
seamless: bool = False # continues the previous cell without a break
|
|
152
160
|
interleaved: bool = False
|
|
161
|
+
seamless_angle: bool = False # an angle block switched at its interleaved units (SML_AGLI)
|
|
153
162
|
last_of_program: bool = True
|
|
154
163
|
segment: tuple = (0, 1) # (part, stretch of it) the cell plays
|
|
155
164
|
|
|
@@ -161,7 +170,7 @@ class Cell:
|
|
|
161
170
|
kind = 0x40 if self.angle == 1 else 0xC0 if self.angle == self.angles else 0x80
|
|
162
171
|
kind |= 0x10
|
|
163
172
|
return kind | (0x08 if self.seamless else 0) | (0x04 if self.interleaved else 0) \
|
|
164
|
-
| (0x02 if self.stc_discontinuity else 0) | (0x01 if self.
|
|
173
|
+
| (0x02 if self.stc_discontinuity else 0) | (0x01 if self.seamless_angle and self.angle else 0)
|
|
165
174
|
|
|
166
175
|
|
|
167
176
|
@dataclass
|
|
@@ -509,21 +518,63 @@ def _aligned(loaded, where):
|
|
|
509
518
|
f"{len(fs.pictures)} and {len(os_.pictures)} pictures)")
|
|
510
519
|
|
|
511
520
|
|
|
521
|
+
def _clean_starts(where, stream, vs, starts):
|
|
522
|
+
"""An angle is switched to at the start of an interleaved unit: the
|
|
523
|
+
pictures there must not lean on the group before, which is another
|
|
524
|
+
angle's after the switch. B-pictures coded after the unit's first
|
|
525
|
+
I-picture but shown before it are predicted from the previous group
|
|
526
|
+
unless its header says closed_gop (ISO 13818-2); such a stream is
|
|
527
|
+
refused (ffmpeg's -flags +cgop, or no B-pictures, makes groups that
|
|
528
|
+
stand alone; oncemore's have no B-pictures)."""
|
|
529
|
+
pictures = stream.pictures
|
|
530
|
+
firsts = [p.start for p in pictures]
|
|
531
|
+
for i in starts:
|
|
532
|
+
k = bisect.bisect_left(firsts, vs[i].start)
|
|
533
|
+
if k >= len(pictures) or pictures[k].type != "I":
|
|
534
|
+
continue
|
|
535
|
+
head = pictures[k]
|
|
536
|
+
leading = []
|
|
537
|
+
for p in pictures[k + 1:]:
|
|
538
|
+
if p.type != "B":
|
|
539
|
+
break
|
|
540
|
+
if p.order < head.order:
|
|
541
|
+
leading.append(p)
|
|
542
|
+
if leading and not head.closed:
|
|
543
|
+
raise VtsError(f"{where}: the group of pictures that starts an interleaved unit "
|
|
544
|
+
f"(VOBU {i + 1}) is open: {len(leading)} B-pictures shown before its "
|
|
545
|
+
"I-picture lean on the group before, which is another angle's after a "
|
|
546
|
+
"switch. Encode the angles with closed groups (ffmpeg -flags +cgop) "
|
|
547
|
+
"or without B-pictures")
|
|
548
|
+
|
|
549
|
+
|
|
512
550
|
def _title(number, title, offset, vob_id):
|
|
513
551
|
"""Read a title's parts, place its chapters, lay its VOBUs on the disc
|
|
514
552
|
(interleaving the angle blocks) and fill their navigation packs:
|
|
515
|
-
(WrittenTitle, the next free sector, the next VOB id).
|
|
553
|
+
(WrittenTitle, the next free sector, the next VOB id). The streams
|
|
554
|
+
read are closed again if the title is refused."""
|
|
555
|
+
loaded = [] # a part: [(stream, vobus, bytes)] one an angle
|
|
556
|
+
try:
|
|
557
|
+
return _lay_title(number, title, offset, vob_id, loaded)
|
|
558
|
+
except BaseException:
|
|
559
|
+
for part in loaded:
|
|
560
|
+
for _, _, buffer in part:
|
|
561
|
+
_close(buffer)
|
|
562
|
+
raise
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
def _lay_title(number, title, offset, vob_id, loaded):
|
|
516
566
|
parts = title.parts or (title.stream,)
|
|
517
567
|
if not parts or title.stream and title.parts:
|
|
518
568
|
raise VtsError(f"title {number}: give a stream or parts, not both")
|
|
519
|
-
loaded = [] # a part: [(stream, vobus, bytes)] one an angle
|
|
520
569
|
for k, part in enumerate(parts, 1):
|
|
521
570
|
if isinstance(part, Angles):
|
|
522
571
|
if not 2 <= len(part.streams) <= 9:
|
|
523
572
|
raise VtsError(f"title {number}, part {k}: 2 to 9 angles")
|
|
524
|
-
angles = [
|
|
525
|
-
_aligned(angles, f"title {number}, part {k}")
|
|
573
|
+
angles = []
|
|
526
574
|
loaded.append(angles)
|
|
575
|
+
for p in part.streams:
|
|
576
|
+
angles.append(_load(p))
|
|
577
|
+
_aligned(angles, f"title {number}, part {k}")
|
|
527
578
|
elif isinstance(part, str):
|
|
528
579
|
loaded.append([_load(part)])
|
|
529
580
|
else:
|
|
@@ -597,6 +648,9 @@ def _title(number, title, offset, vob_id):
|
|
|
597
648
|
if unit < 1:
|
|
598
649
|
raise VtsError("an interleaved unit holds at least one VOBU")
|
|
599
650
|
groups = [range(g, min(g + unit, i1)) for g in range(i0, i1, unit)]
|
|
651
|
+
for a in range(n_angles):
|
|
652
|
+
_clean_starts(f"title {number}, part {k + 1}, angle {a + 1}", part[a][0],
|
|
653
|
+
part[a][1], [rng[0] for rng in groups[1:]])
|
|
600
654
|
# the three VOBUs before the block: PREU (the first of them
|
|
601
655
|
# Unit_Start, the last Unit_End), when they are not interleaved
|
|
602
656
|
before = [p for p in preceding[-3:] if not isinstance(parts[p[0]], Angles)]
|
|
@@ -631,9 +685,16 @@ def _title(number, title, offset, vob_id):
|
|
|
631
685
|
nxt_sa, nxt_sz = 0xFFFFFFFF, 0xFFFF
|
|
632
686
|
else:
|
|
633
687
|
nxt_sa, nxt_sz = unit_start[(a, g + 1)] - lay[i], unit_size[(a, g + 1)]
|
|
688
|
+
# a seamless block says where every angle's next unit
|
|
689
|
+
# is (SML_AGLI) and leaves NSML_AGLI empty; a block
|
|
690
|
+
# switched with a break, the other way round. Players
|
|
691
|
+
# take the pointers that are there: libdvdnav follows
|
|
692
|
+
# NSML_AGLI whenever it is filled, replaying the moment
|
|
693
|
+
# it was at, and SML_AGLI otherwise, going on at the
|
|
694
|
+
# next unit (tests/test_angles.py)
|
|
634
695
|
agli = []
|
|
635
696
|
for b in range(9):
|
|
636
|
-
if b >= n_angles:
|
|
697
|
+
if b >= n_angles or not block.seamless:
|
|
637
698
|
agli.append((0, 0))
|
|
638
699
|
elif last_group:
|
|
639
700
|
agli.append((0x7FFFFFFF, 0))
|
|
@@ -642,7 +703,7 @@ def _title(number, title, offset, vob_id):
|
|
|
642
703
|
ilvu_info[(k, a)][i] = (flags, unit_end - lay[i], nxt_sa, nxt_sz, agli)
|
|
643
704
|
nsml = []
|
|
644
705
|
for b in range(9):
|
|
645
|
-
if b >= n_angles:
|
|
706
|
+
if b >= n_angles or block.seamless:
|
|
646
707
|
nsml.append(0)
|
|
647
708
|
else:
|
|
648
709
|
d = layouts[(k, b)][i] - lay[i]
|
|
@@ -655,17 +716,19 @@ def _title(number, title, offset, vob_id):
|
|
|
655
716
|
lay[i1 - 1], lay[i1 - 1] + vs[i1 - 1].end - vs[i1 - 1].start,
|
|
656
717
|
vs[i0].s_ptm, e, len(programs), a + 1, n_angles,
|
|
657
718
|
stc_discontinuity=seg == 1, seamless=block.seamless and seg > 1,
|
|
658
|
-
interleaved=True,
|
|
719
|
+
interleaved=True, seamless_angle=block.seamless,
|
|
720
|
+
segment=(k, seg)))
|
|
659
721
|
for a in range(n_angles):
|
|
660
722
|
jobs.append((k, a, vob_id + a))
|
|
661
723
|
vob_id += n_angles
|
|
724
|
+
highlights = _title_buttons(number, title, loaded, part_start)
|
|
662
725
|
# fill every VOB now that every sector, and every flag of the VOBUs
|
|
663
726
|
# before an angle block, is known
|
|
664
727
|
for k, a, vid in jobs:
|
|
665
728
|
st, vs, vob = loaded[k][a]
|
|
666
729
|
vob_cells = [(vid, c.cell_id, c.first_vobu, c.last_vobu) for c in cells if c.vob_id == vid]
|
|
667
730
|
nav.fill(vob, vob_cells, stream=st, layout=layouts[(k, a)], ilvu=ilvu_info[(k, a)],
|
|
668
|
-
nsml=nsml_info[(k, a)])
|
|
731
|
+
nsml=nsml_info[(k, a)], highlights=highlights.get((k, a)))
|
|
669
732
|
lay = layouts[(k, a)]
|
|
670
733
|
if len(loaded[k]) == 1:
|
|
671
734
|
chunks.append((lay[0], vob, 0, len(vob)))
|
|
@@ -685,6 +748,49 @@ def _title(number, title, offset, vob_id):
|
|
|
685
748
|
return w, sector, vob_id
|
|
686
749
|
|
|
687
750
|
|
|
751
|
+
def _title_buttons(number, title, loaded, part_start):
|
|
752
|
+
"""The HLI of the title's buttons: {(part, angle): {VOBU: HLI}}, in the
|
|
753
|
+
VOBUs that play during each stretch (every angle's), shown from the
|
|
754
|
+
stretch's start to its end in the VOB's time (a stretch that began in
|
|
755
|
+
an earlier part from the VOB's start; one that goes on into a later
|
|
756
|
+
part to the VOB's end; one without an end never ending)."""
|
|
757
|
+
if not title.buttons:
|
|
758
|
+
return {}
|
|
759
|
+
width, height = loaded[0][0][0].sequence[:2]
|
|
760
|
+
stretches = []
|
|
761
|
+
for n, tb in enumerate(title.buttons, 1):
|
|
762
|
+
what = f"title {number}, buttons {n}"
|
|
763
|
+
menus_.check_title_buttons(tb, width, height, what)
|
|
764
|
+
end = None if tb.end is None else int(round(tb.end * 90000))
|
|
765
|
+
stretches.append((int(round(tb.start * 90000)), end, tb, what))
|
|
766
|
+
stretches.sort(key=lambda x: x[0])
|
|
767
|
+
for (_, e1, _, w1), (s2, _, _, w2) in zip(stretches, stretches[1:]):
|
|
768
|
+
if e1 is None or e1 > s2:
|
|
769
|
+
raise VtsError(f"{w1} and {w2} overlap in time")
|
|
770
|
+
out = {}
|
|
771
|
+
used = set()
|
|
772
|
+
for k, part in enumerate(loaded):
|
|
773
|
+
for a, (_, vs, _) in enumerate(part):
|
|
774
|
+
base, vob_end = vs[0].s_ptm, vs[-1].e_ptm
|
|
775
|
+
table = out[(k, a)] = {}
|
|
776
|
+
for s, e, tb, what in stretches:
|
|
777
|
+
for i, v in enumerate(vs):
|
|
778
|
+
t0 = part_start[k] + v.s_ptm - base
|
|
779
|
+
t1 = part_start[k] + v.e_ptm - base
|
|
780
|
+
if t1 <= s or e is not None and t0 >= e:
|
|
781
|
+
continue
|
|
782
|
+
if i in table:
|
|
783
|
+
raise VtsError(f"{what} start in the VOBU (part {k + 1}, VOBU {i + 1}) where "
|
|
784
|
+
"the buttons before end: a VOBU carries one set of buttons")
|
|
785
|
+
he = 0xFFFFFFFF if e is None else min(base + e - part_start[k], vob_end)
|
|
786
|
+
table[i] = tb.hli(base + max(s - part_start[k], 0), he)
|
|
787
|
+
used.add(what)
|
|
788
|
+
for _, _, _, what in stretches:
|
|
789
|
+
if what not in used:
|
|
790
|
+
raise VtsError(f"{what} start after the title ends")
|
|
791
|
+
return out
|
|
792
|
+
|
|
793
|
+
|
|
688
794
|
def _attributes(written, pan_scan, letterbox, audio, subpictures, what, captions=(False, False)):
|
|
689
795
|
"""(video attr, audio count, audio attrs, subpicture count, subpicture
|
|
690
796
|
attrs, sub-streams) of the VOBs: the picture the same in every one, the
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: vtskit
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: DVD-Video titlesets in pure Python: VOBs, navigation packs and IFO files
|
|
5
5
|
Author: kajisho5
|
|
6
6
|
License-Expression: LGPL-3.0-or-later
|
|
@@ -85,18 +85,22 @@ play the discs vtskit writes.
|
|
|
85
85
|
## Installing
|
|
86
86
|
|
|
87
87
|
vtskit is one package of plain Python (3.11 or later), with nothing else
|
|
88
|
-
to install
|
|
88
|
+
to install:
|
|
89
89
|
|
|
90
90
|
```
|
|
91
|
-
pip install
|
|
91
|
+
pip install vtskit
|
|
92
92
|
```
|
|
93
93
|
|
|
94
|
-
A
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
94
|
+
A release is made by the version: when the version in `pyproject.toml`
|
|
95
|
+
(and `vtskit/__init__.py`) reaches `main` without a tag of its own,
|
|
96
|
+
`.github/workflows/release.yml` runs the tests, builds the package, makes
|
|
97
|
+
the GitHub release with its tag and publishes to PyPI. Merging the version
|
|
98
|
+
bump is the whole act; a tag `v*` pushed by hand releases the same way.
|
|
99
|
+
PyPI knows this repository and workflow as the project's trusted
|
|
100
|
+
publisher. oncemore does not install vtskit: it
|
|
101
|
+
carries a copy of each release as `oncemore/_vendor/vtskit`, taken from
|
|
102
|
+
PyPI with its `tools/vendor.py vtskit --pypi <version>` (a pull request
|
|
103
|
+
does it there each day a newer release is out).
|
|
100
104
|
|
|
101
105
|
## What is here now
|
|
102
106
|
|
|
@@ -129,9 +133,23 @@ oncemore writing its discs with vtskit.
|
|
|
129
133
|
libdvdnav a title whose post commands run out plays on into its next
|
|
130
134
|
program chain; dvdauthor writes none of these. A title may be
|
|
131
135
|
several parts in a row, streams and `Angles` blocks of 2 to 9 streams: an
|
|
132
|
-
angle block is interleaved on the disc
|
|
133
|
-
|
|
134
|
-
|
|
136
|
+
angle block is interleaved on the disc and its cells are marked as an
|
|
137
|
+
angle block. A seamless block (the default) is switched at the end of an
|
|
138
|
+
interleaved unit: its navigation packs point from each unit to every
|
|
139
|
+
angle's next (SML_AGLI) and its cells say "seamless angle"; a block
|
|
140
|
+
switched with a break (`seamless=False`) points instead at every angle's
|
|
141
|
+
VOBU of the same moment (NSML_AGLI). Only one of the two is filled, as
|
|
142
|
+
players take NSML_AGLI whenever it is there. A stream whose interleaved
|
|
143
|
+
units start with open groups of pictures (B-pictures leaning on the group
|
|
144
|
+
before, another angle's after a switch) is refused. A title may carry
|
|
145
|
+
buttons over stretches of it (`vtskit.menu.TitleButtons`: from a start
|
|
146
|
+
to an end, the buttons, the palette entries that draw them): their
|
|
147
|
+
overlay is a forced subpicture added with `vtskit.subtitles`, and the
|
|
148
|
+
highlight information goes only into the VOBUs that play during the
|
|
149
|
+
stretch, with the stretch's start and end as its start time, end time
|
|
150
|
+
and the end of the selection (dvdinfo's hli_s_ptm, hli_e_ptm,
|
|
151
|
+
btn_sl_e_ptm). dvdauthor gives a title's buttons the whole title (every
|
|
152
|
+
VOBU, never ending), which is what buttons without an end write.
|
|
135
153
|
* `vtskit.subtitles` adds subtitles to a title's program stream, as spumux
|
|
136
154
|
does: each cue a picture of up to four colours placed on the screen and
|
|
137
155
|
shown for a while, as subpicture packs a little before their time.
|
|
@@ -172,13 +190,18 @@ dvdauthor's (its buttons made with spumux), and a menu's overlay in a
|
|
|
172
190
|
normal colour is spumux's unit byte for byte; subtitles muxed by vtskit are
|
|
173
191
|
spumux's units, placed where spumux places them, and the title written
|
|
174
192
|
from that stream (4:3, and 16:9 with and without pan-scan or letterbox) is
|
|
175
|
-
dvdauthor's
|
|
193
|
+
dvdauthor's; a title with buttons over the whole of it is dvdauthor's
|
|
194
|
+
VOB byte for byte. A VIDEO_TS written by vtskit alone plays
|
|
176
195
|
its titles and chapters in order under libdvdnav, its commands jumping
|
|
177
196
|
between titles, its menus' buttons lead where they say, and its jumps into
|
|
178
197
|
another titleset's chapters land; a title with an angle block plays and
|
|
179
198
|
switches angles under libdvdnav, every pointer of its navigation packs
|
|
180
|
-
landing on the unit it names
|
|
181
|
-
|
|
199
|
+
landing on the unit it names, and the pictures libdvdnav hands a player
|
|
200
|
+
through a switch, decoded with ffmpeg, are every picture once, in order,
|
|
201
|
+
the new angle from the end of a unit (dvdauthor writes no angles, so a
|
|
202
|
+
set-top player is the check still owed); a title's buttons over a stretch
|
|
203
|
+
are offered by libdvdnav in that stretch's VOBUs alone, and run their
|
|
204
|
+
commands
|
|
182
205
|
(`python -m unittest discover -s tests -t .`; a test is skipped when a tool
|
|
183
206
|
it needs is missing). oncemore's switch to vtskit (step 4) is merged:
|
|
184
207
|
[oncemore#90](https://github.com/kajisho5/oncemore/pull/90) and
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|