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.
Files changed (32) hide show
  1. {vtskit-0.1.0/vtskit.egg-info → vtskit-0.2.0}/PKG-INFO +38 -15
  2. {vtskit-0.1.0 → vtskit-0.2.0}/README.md +37 -14
  3. {vtskit-0.1.0 → vtskit-0.2.0}/pyproject.toml +1 -1
  4. {vtskit-0.1.0 → vtskit-0.2.0}/tests/test_angles.py +150 -16
  5. vtskit-0.2.0/tests/test_title_buttons.py +210 -0
  6. {vtskit-0.1.0 → vtskit-0.2.0}/vtskit/__init__.py +1 -1
  7. {vtskit-0.1.0 → vtskit-0.2.0}/vtskit/menu.py +103 -13
  8. {vtskit-0.1.0 → vtskit-0.2.0}/vtskit/nav.py +11 -5
  9. {vtskit-0.1.0 → vtskit-0.2.0}/vtskit/ps.py +5 -1
  10. {vtskit-0.1.0 → vtskit-0.2.0}/vtskit/titleset.py +122 -16
  11. {vtskit-0.1.0 → vtskit-0.2.0/vtskit.egg-info}/PKG-INFO +38 -15
  12. {vtskit-0.1.0 → vtskit-0.2.0}/vtskit.egg-info/SOURCES.txt +1 -0
  13. {vtskit-0.1.0 → vtskit-0.2.0}/COPYING +0 -0
  14. {vtskit-0.1.0 → vtskit-0.2.0}/COPYING.LESSER +0 -0
  15. {vtskit-0.1.0 → vtskit-0.2.0}/setup.cfg +0 -0
  16. {vtskit-0.1.0 → vtskit-0.2.0}/tests/test_ifo.py +0 -0
  17. {vtskit-0.1.0 → vtskit-0.2.0}/tests/test_lang.py +0 -0
  18. {vtskit-0.1.0 → vtskit-0.2.0}/tests/test_menu.py +0 -0
  19. {vtskit-0.1.0 → vtskit-0.2.0}/tests/test_nav.py +0 -0
  20. {vtskit-0.1.0 → vtskit-0.2.0}/tests/test_subtitles.py +0 -0
  21. {vtskit-0.1.0 → vtskit-0.2.0}/tests/test_titleset.py +0 -0
  22. {vtskit-0.1.0 → vtskit-0.2.0}/tests/test_vm.py +0 -0
  23. {vtskit-0.1.0 → vtskit-0.2.0}/tests/test_vmg.py +0 -0
  24. {vtskit-0.1.0 → vtskit-0.2.0}/vtskit/errors.py +0 -0
  25. {vtskit-0.1.0 → vtskit-0.2.0}/vtskit/ifo.py +0 -0
  26. {vtskit-0.1.0 → vtskit-0.2.0}/vtskit/lang.py +0 -0
  27. {vtskit-0.1.0 → vtskit-0.2.0}/vtskit/spu.py +0 -0
  28. {vtskit-0.1.0 → vtskit-0.2.0}/vtskit/subtitles.py +0 -0
  29. {vtskit-0.1.0 → vtskit-0.2.0}/vtskit/vm.py +0 -0
  30. {vtskit-0.1.0 → vtskit-0.2.0}/vtskit/vmg.py +0 -0
  31. {vtskit-0.1.0 → vtskit-0.2.0}/vtskit.egg-info/dependency_links.txt +0 -0
  32. {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.1.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. Until it is on PyPI:
88
+ to install:
89
89
 
90
90
  ```
91
- pip install git+https://github.com/kajisho5/vtskit
91
+ pip install vtskit
92
92
  ```
93
93
 
94
- A tag `v*` on this repository builds the package, attaches it to a GitHub
95
- release and publishes it to PyPI (`.github/workflows/release.yml`); the
96
- first PyPI release needs the project registered there with this
97
- repository as its trusted publisher. oncemore does not install vtskit: it
98
- carries a copy of `vtskit/` as `oncemore/_vendor/vtskit`, updated with its
99
- `tools/vendor_vtskit.py`.
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, its navigation packs point from
133
- each unit to every angle's next, and its cells are marked as an angle
134
- block.
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. A VIDEO_TS written by vtskit alone plays
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 (dvdauthor writes no angles, so a set-top
181
- player is the check still owed)
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. Until it is on PyPI:
71
+ to install:
72
72
 
73
73
  ```
74
- pip install git+https://github.com/kajisho5/vtskit
74
+ pip install vtskit
75
75
  ```
76
76
 
77
- A tag `v*` on this repository builds the package, attaches it to a GitHub
78
- release and publishes it to PyPI (`.github/workflows/release.yml`); the
79
- first PyPI release needs the project registered there with this
80
- repository as its trusted publisher. oncemore does not install vtskit: it
81
- carries a copy of `vtskit/` as `oncemore/_vendor/vtskit`, updated with its
82
- `tools/vendor_vtskit.py`.
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, its navigation packs point from
116
- each unit to every angle's next, and its cells are marked as an angle
117
- block.
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. A VIDEO_TS written by vtskit alone plays
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 (dvdauthor writes no angles, so a set-top
164
- player is the check still owed)
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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "vtskit"
7
- version = "0.1.0"
7
+ version = "0.2.0"
8
8
  description = "DVD-Video titlesets in pure Python: VOBs, navigation packs and IFO files"
9
9
  readme = "README.md"
10
10
  license = "LGPL-3.0-or-later"
@@ -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, and libdvdnav playing and switching the
4
- angles. dvdauthor writes no angles, so there is nothing of its to compare
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
- self.assertEqual(cats[1:4], [0x40 | 0x10 | 0x04 | 0x02, 0x80 | 0x10 | 0x04 | 0x02, 0xC0 | 0x10 | 0x04 | 0x02])
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: every angle's VOBU of the same moment
204
- for b in range(9):
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()
@@ -21,5 +21,5 @@ subtitles and angles, and dvdauthor's command language:
21
21
 
22
22
  from .errors import VtsError
23
23
 
24
- __version__ = "0.1.0"
24
+ __version__ = "0.2.0"
25
25
  __all__ = ["VtsError"]
@@ -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
- for n, b in enumerate(menu.buttons, 1):
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(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:
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
- buttons = menu.buttons
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, 0xFFFFFFFF, 0xFFFFFFFF, 0x1000,
153
- menu.button_offset, len(buttons), len(buttons), 0, menu.first_button, 0)
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
- 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))
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. Returns the VOBUs."""
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
- if hli and vob_id in hli:
236
- if len(hli[vob_id]) != 0x316 - HLI:
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] = hli[vob_id]
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, where its
45
- angle's next unit is, and where every angle's next unit is (SML_AGLI);
46
- every PCI where every angle's VOBU of the same moment is (NSML_AGLI); the
47
- three VOBUs before a block carry the PREU flags; the block's cells are one
48
- an angle, marked first, middle and last of an angle block, interleaved,
49
- and seamless when asked. dvdauthor writes no angles, so none of this is
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.seamless and self.angle else 0)
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 = [_load(p) for p in part.streams]
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, segment=(k, seg)))
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.1.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. Until it is on PyPI:
88
+ to install:
89
89
 
90
90
  ```
91
- pip install git+https://github.com/kajisho5/vtskit
91
+ pip install vtskit
92
92
  ```
93
93
 
94
- A tag `v*` on this repository builds the package, attaches it to a GitHub
95
- release and publishes it to PyPI (`.github/workflows/release.yml`); the
96
- first PyPI release needs the project registered there with this
97
- repository as its trusted publisher. oncemore does not install vtskit: it
98
- carries a copy of `vtskit/` as `oncemore/_vendor/vtskit`, updated with its
99
- `tools/vendor_vtskit.py`.
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, its navigation packs point from
133
- each unit to every angle's next, and its cells are marked as an angle
134
- block.
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. A VIDEO_TS written by vtskit alone plays
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 (dvdauthor writes no angles, so a set-top
181
- player is the check still owed)
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
@@ -8,6 +8,7 @@ tests/test_lang.py
8
8
  tests/test_menu.py
9
9
  tests/test_nav.py
10
10
  tests/test_subtitles.py
11
+ tests/test_title_buttons.py
11
12
  tests/test_titleset.py
12
13
  tests/test_vm.py
13
14
  tests/test_vmg.py
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