bdmvkit 0.3.0__tar.gz → 0.4.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. {bdmvkit-0.3.0/bdmvkit.egg-info → bdmvkit-0.4.0}/PKG-INFO +19 -4
  2. {bdmvkit-0.3.0 → bdmvkit-0.4.0}/README.md +18 -3
  3. {bdmvkit-0.3.0 → bdmvkit-0.4.0}/bdmvkit/__init__.py +2 -2
  4. {bdmvkit-0.3.0 → bdmvkit-0.4.0}/bdmvkit/__main__.py +15 -9
  5. {bdmvkit-0.3.0 → bdmvkit-0.4.0}/bdmvkit/disc.py +61 -14
  6. {bdmvkit-0.3.0 → bdmvkit-0.4.0}/bdmvkit/encode.py +47 -0
  7. {bdmvkit-0.3.0 → bdmvkit-0.4.0}/bdmvkit/nav.py +1 -1
  8. {bdmvkit-0.3.0 → bdmvkit-0.4.0}/bdmvkit/program.py +17 -1
  9. {bdmvkit-0.3.0 → bdmvkit-0.4.0/bdmvkit.egg-info}/PKG-INFO +19 -4
  10. {bdmvkit-0.3.0 → bdmvkit-0.4.0}/bdmvkit.egg-info/SOURCES.txt +2 -1
  11. {bdmvkit-0.3.0 → bdmvkit-0.4.0}/pyproject.toml +1 -1
  12. bdmvkit-0.4.0/tests/test_popup.py +107 -0
  13. {bdmvkit-0.3.0 → bdmvkit-0.4.0}/COPYING +0 -0
  14. {bdmvkit-0.3.0 → bdmvkit-0.4.0}/COPYING.LESSER +0 -0
  15. {bdmvkit-0.3.0 → bdmvkit-0.4.0}/bdmvkit/clip.py +0 -0
  16. {bdmvkit-0.3.0 → bdmvkit-0.4.0}/bdmvkit/errors.py +0 -0
  17. {bdmvkit-0.3.0 → bdmvkit-0.4.0}/bdmvkit/formats.py +0 -0
  18. {bdmvkit-0.3.0 → bdmvkit-0.4.0}/bdmvkit/hdmv.py +0 -0
  19. {bdmvkit-0.3.0 → bdmvkit-0.4.0}/bdmvkit/igs.py +0 -0
  20. {bdmvkit-0.3.0 → bdmvkit-0.4.0}/bdmvkit/m2ts.py +0 -0
  21. {bdmvkit-0.3.0 → bdmvkit-0.4.0}/bdmvkit/pgs.py +0 -0
  22. {bdmvkit-0.3.0 → bdmvkit-0.4.0}/bdmvkit/playlist.py +0 -0
  23. {bdmvkit-0.3.0 → bdmvkit-0.4.0}/bdmvkit/uops.py +0 -0
  24. {bdmvkit-0.3.0 → bdmvkit-0.4.0}/bdmvkit.egg-info/dependency_links.txt +0 -0
  25. {bdmvkit-0.3.0 → bdmvkit-0.4.0}/bdmvkit.egg-info/entry_points.txt +0 -0
  26. {bdmvkit-0.3.0 → bdmvkit-0.4.0}/bdmvkit.egg-info/top_level.txt +0 -0
  27. {bdmvkit-0.3.0 → bdmvkit-0.4.0}/setup.cfg +0 -0
  28. {bdmvkit-0.3.0 → bdmvkit-0.4.0}/tests/test_disc.py +0 -0
  29. {bdmvkit-0.3.0 → bdmvkit-0.4.0}/tests/test_files.py +0 -0
  30. {bdmvkit-0.3.0 → bdmvkit-0.4.0}/tests/test_menus.py +0 -0
  31. {bdmvkit-0.3.0 → bdmvkit-0.4.0}/tests/test_nav.py +0 -0
  32. {bdmvkit-0.3.0 → bdmvkit-0.4.0}/tests/test_pgs.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: bdmvkit
3
- Version: 0.3.0
3
+ Version: 0.4.0
4
4
  Summary: Blu-ray (BDMV) authoring in pure Python: playlists, clip information, HDMV navigation, PGS subtitles
5
5
  Author: kajisho5
6
6
  License-Expression: LGPL-3.0-or-later
@@ -35,14 +35,14 @@ only to encode.
35
35
 
36
36
  日本語の説明は [README.ja.md](README.ja.md) にあります。
37
37
 
38
- **Status: 0.3.** Titles with chapters, sound and subtitle tracks;
38
+ **Status: 0.4.** Titles with chapters, sound and subtitle tracks;
39
39
  playlists (in order, at random or shuffled) and play all; menus whose
40
40
  buttons start titles, chapters, playlists and other menus, switch sound and
41
41
  subtitle tracks, or open a menu on a chosen button; loop counts, end-action
42
42
  overrides, link transitions, remote keys a title turns off, and 720p. This
43
43
  navigation is the one [oncemore](https://github.com/kajisho5/oncemore)
44
- writes, byte for byte. Encoding pop-up menus and menus with motion or music
45
- comes next.
44
+ writes, byte for byte. 0.4 adds pop-up menus over a playing title. Menus
45
+ with motion or music come next.
46
46
 
47
47
  ## Why
48
48
 
@@ -132,6 +132,19 @@ disc.add_menu("main", "menu.png", [
132
132
  ], loop=3, end="playall", seconds=10)
133
133
  ```
134
134
 
135
+ A title can have a pop-up menu: buttons over its picture, opened with the
136
+ remote's Pop-up Menu key. Their pictures are placed on the 1920 x 1080 frame
137
+ as a menu's are; a button that only switches the sound or subtitle track
138
+ closes the menu.
139
+
140
+ ```python
141
+ disc.add_popup(a, [
142
+ MenuButton("scenes", 160, 900, "scenes.png", "scenes-on.png", "scenes-off.png",
143
+ action="menu:main"),
144
+ MenuButton("ja", 560, 900, "ja.png", normal="ja-off.png", action="audio:2"),
145
+ ])
146
+ ```
147
+
135
148
  Underneath, `bdmvkit.program` describes a disc made of encoded clips
136
149
  (titles, playlists, menu clips that carry their buttons, transitions) and
137
150
  `bdmvkit.nav.write_program` writes it; `nav.Navigation` gives the commands a
@@ -196,6 +209,8 @@ The tests encode real clips, write a disc, and read it back with
196
209
  the titles, chapters, playlists and menus they link to, that a shuffled
197
210
  playlist plays each item once, and that a menu with a loop count goes on
198
211
  by itself;
212
+ * that a pop-up menu opens over a playing title with the Pop-up Menu key and
213
+ its buttons start another title or a chapter;
199
214
  * the commands every kind of action, title, playlist and menu turns into.
200
215
 
201
216
  The files' fixed bytes are checked as well.
@@ -18,14 +18,14 @@ only to encode.
18
18
 
19
19
  日本語の説明は [README.ja.md](README.ja.md) にあります。
20
20
 
21
- **Status: 0.3.** Titles with chapters, sound and subtitle tracks;
21
+ **Status: 0.4.** Titles with chapters, sound and subtitle tracks;
22
22
  playlists (in order, at random or shuffled) and play all; menus whose
23
23
  buttons start titles, chapters, playlists and other menus, switch sound and
24
24
  subtitle tracks, or open a menu on a chosen button; loop counts, end-action
25
25
  overrides, link transitions, remote keys a title turns off, and 720p. This
26
26
  navigation is the one [oncemore](https://github.com/kajisho5/oncemore)
27
- writes, byte for byte. Encoding pop-up menus and menus with motion or music
28
- comes next.
27
+ writes, byte for byte. 0.4 adds pop-up menus over a playing title. Menus
28
+ with motion or music come next.
29
29
 
30
30
  ## Why
31
31
 
@@ -115,6 +115,19 @@ disc.add_menu("main", "menu.png", [
115
115
  ], loop=3, end="playall", seconds=10)
116
116
  ```
117
117
 
118
+ A title can have a pop-up menu: buttons over its picture, opened with the
119
+ remote's Pop-up Menu key. Their pictures are placed on the 1920 x 1080 frame
120
+ as a menu's are; a button that only switches the sound or subtitle track
121
+ closes the menu.
122
+
123
+ ```python
124
+ disc.add_popup(a, [
125
+ MenuButton("scenes", 160, 900, "scenes.png", "scenes-on.png", "scenes-off.png",
126
+ action="menu:main"),
127
+ MenuButton("ja", 560, 900, "ja.png", normal="ja-off.png", action="audio:2"),
128
+ ])
129
+ ```
130
+
118
131
  Underneath, `bdmvkit.program` describes a disc made of encoded clips
119
132
  (titles, playlists, menu clips that carry their buttons, transitions) and
120
133
  `bdmvkit.nav.write_program` writes it; `nav.Navigation` gives the commands a
@@ -179,6 +192,8 @@ The tests encode real clips, write a disc, and read it back with
179
192
  the titles, chapters, playlists and menus they link to, that a shuffled
180
193
  playlist plays each item once, and that a menu with a loop count goes on
181
194
  by itself;
195
+ * that a pop-up menu opens over a playing title with the Pop-up Menu key and
196
+ its buttons start another title or a chapter;
182
197
  * the commands every kind of action, title, playlist and menu turns into.
183
198
 
184
199
  The files' fixed bytes are checked as well.
@@ -1,7 +1,7 @@
1
1
  """bdmvkit: Blu-ray (BDMV) authoring in pure Python.
2
2
 
3
3
  * `bdmvkit.Disc`: a BDMV folder from encoded clips (titles, chapters, sound
4
- and subtitle tracks), playlists and menus (`MenuButton`).
4
+ and subtitle tracks), playlists, menus and pop-up menus (`MenuButton`).
5
5
  * `bdmvkit.program` and `bdmvkit.nav`: the model underneath (titles,
6
6
  playlists, menu clips, transitions, actions) and its writer,
7
7
  `nav.write_program`, with the navigation commands (`nav.Navigation`).
@@ -15,5 +15,5 @@
15
15
  from .disc import Disc, MenuButton
16
16
  from .errors import BdmvError
17
17
 
18
- __version__ = "0.3.0"
18
+ __version__ = "0.4.0"
19
19
  __all__ = ["Disc", "MenuButton", "BdmvError"]
@@ -11,7 +11,8 @@ DISC.json: {"titles": [{"clip": "a.m2ts", "fps": "24000/1001",
11
11
  file. A button may also have "activated" and "normal" pictures, "nav"
12
12
  ({"up": id, ...}), "auto", "audio", "subtitle" and "override"; a menu
13
13
  "default" (a button id), "fps", "loop", "end", "seconds" and "prohibit"; a
14
- title "default_audio", "default_subtitle" and "prohibit". The disc may have
14
+ title "default_audio", "default_subtitle", "prohibit" and "popup" ({"buttons":
15
+ [...], "default"}: its pop-up menu, buttons as a menu's). The disc may have
15
16
  "playlists" ([{"id", "items": [n or [n, first, last]], "order", "end"}]),
16
17
  "first_play" and "title_key".
17
18
  """
@@ -75,15 +76,20 @@ def main(argv=None):
75
76
  def file(name):
76
77
  return os.path.join(base, name) if name else None
77
78
 
79
+ def buttons(spec_buttons):
80
+ return [MenuButton(b["id"], int(b["x"]), int(b["y"]), file(b["selected"]),
81
+ file(b.get("activated")), file(b.get("normal")), b.get("action"),
82
+ dict(b.get("nav", {})), bool(b.get("auto")), b.get("audio"),
83
+ b.get("subtitle"), b.get("override")) for b in spec_buttons]
84
+
85
+ for n, t in enumerate(spec.get("titles", []), 1):
86
+ if t.get("popup"):
87
+ disc.add_popup(n, buttons(t["popup"]["buttons"]), t["popup"].get("default"))
78
88
  for m in spec.get("menus", []):
79
- disc.add_menu(m["id"], file(m["background"]), [
80
- MenuButton(b["id"], int(b["x"]), int(b["y"]), file(b["selected"]),
81
- file(b.get("activated")), file(b.get("normal")), b.get("action"),
82
- dict(b.get("nav", {})), bool(b.get("auto")), b.get("audio"),
83
- b.get("subtitle"), b.get("override"))
84
- for b in m["buttons"]], fps=_fps(m.get("fps", "24000/1001")),
85
- default=m.get("default"), loop=m.get("loop"), end=m.get("end"),
86
- prohibit=m.get("prohibit", []), seconds=float(m.get("seconds", 1.0)))
89
+ disc.add_menu(m["id"], file(m["background"]), buttons(m["buttons"]),
90
+ fps=_fps(m.get("fps", "24000/1001")), default=m.get("default"),
91
+ loop=m.get("loop"), end=m.get("end"), prohibit=m.get("prohibit", []),
92
+ seconds=float(m.get("seconds", 1.0)))
87
93
  res = disc.write(args.out, play_all=spec.get("play_all", True), fast=args.fast)
88
94
  except (BdmvError, OSError, KeyError, ValueError) as e:
89
95
  print(f"bdmvkit: {getattr(e, 'message', e)}", file=sys.stderr)
@@ -34,10 +34,11 @@ import dataclasses
34
34
  import os
35
35
  import tempfile
36
36
 
37
- from . import encode, igs, program
37
+ from . import encode, hdmv, igs, program
38
38
  from .errors import BdmvError
39
39
  from .formats import AUDIO_PID, CODECS, FORMATS, HEIGHT, PG_PID, WIDTH, lang3
40
40
  from .m2ts import stream_pids
41
+ from .program import parse_action
41
42
  from .nav import DIRS, Navigation, _write, chapter_marks, write_program # noqa: F401
42
43
 
43
44
 
@@ -103,6 +104,7 @@ class Disc:
103
104
  self.titles = []
104
105
  self.menus = []
105
106
  self.playlists = []
107
+ self.popups = {} # title number -> (buttons, default)
106
108
  self.first_play = first_play
107
109
  self.title_key = title_key
108
110
 
@@ -162,6 +164,19 @@ class Disc:
162
164
  self.menus.append(Menu(menu_id, background, list(buttons), tuple(fps), default, loop,
163
165
  end, list(prohibit), seconds))
164
166
 
167
+ def add_popup(self, title, buttons, default=None):
168
+ """Give title `title` (its number) a pop-up menu: `buttons`
169
+ (MenuButtons, their pictures placed on the 1920 x 1080 picture over
170
+ the playing title) shown by the remote's Pop-up Menu key. A button
171
+ that switches the sound or subtitle track closes the menu; one that
172
+ goes elsewhere goes there. The menu's IG stream is muxed into the
173
+ title's clip when the disc is written."""
174
+ if not 1 <= title <= len(self.titles):
175
+ raise BdmvError(f"there is no title {title}")
176
+ if not buttons:
177
+ raise BdmvError(f"title {title}: a pop-up menu needs at least one button")
178
+ self.popups[title] = (list(buttons), default)
179
+
165
180
  def program(self, play_all=True):
166
181
  """The disc as a program.Program (titles "1", "2" ...; menus with no
167
182
  clip yet: write makes them)."""
@@ -174,10 +189,11 @@ class Disc:
174
189
  [program.Audio(CODECS[c], ch, _lang(lang), k == t.default_audio)
175
190
  for k, (c, ch, lang) in enumerate(t.audio, 1)],
176
191
  [program.Subtitle(_lang(lang), k == t.default_subtitle)
177
- for k, lang in enumerate(t.subtitles, 1)], end, list(t.prohibit)))
192
+ for k, lang in enumerate(t.subtitles, 1)], end, list(t.prohibit),
193
+ popup=n in self.popups,
194
+ popup_buttons=[_button(b) for b in self.popups.get(n, ((), None))[0]]))
178
195
  menus = [program.Menu(m.id, None,
179
- [program.Button(b.id, b.action, b.audio, b.subtitle,
180
- override=b.override) for b in m.buttons],
196
+ [_button(b) for b in m.buttons],
181
197
  fps=m.fps, default=m.default, loop=m.loop, end=m.end,
182
198
  still_time=max(1, round(m.seconds)) if m.loop is not None
183
199
  else None,
@@ -203,6 +219,10 @@ class Disc:
203
219
  for k, (m, pm) in enumerate(zip(self.menus, prog.menus), len(self.titles) + 1):
204
220
  pm.clip = os.path.join(out_dir, "BDMV", "STREAM", f"{k:05d}.m2ts")
205
221
  self._encode_menu(m, pm, nav, fast)
222
+ for n, (buttons, default) in sorted(self.popups.items()):
223
+ pt = prog.titles[n - 1]
224
+ pt.clip = os.path.join(out_dir, "BDMV", "STREAM", f"{n:05d}.m2ts")
225
+ self._add_popup(self.titles[n - 1], buttons, default, pt, nav)
206
226
  out = write_program(prog, out_dir, move=move)
207
227
  return {"titles": len(self.titles), "menus": len(self.menus),
208
228
  "playlists": out["playlists"], "chapters": out["chapters"]}
@@ -210,16 +230,7 @@ class Disc:
210
230
  def _encode_menu(self, m, pm, nav, fast):
211
231
  """Menu `m`'s clip (pm.clip): its buttons' pictures read, the IG
212
232
  stream made with their commands and encoded with its picture."""
213
- buttons = []
214
- for b, pb in zip(m.buttons, pm.buttons):
215
- w, h, sel = encode.picture(b.selected)
216
- if b.x < 0 or b.y < 0 or b.x + w > WIDTH or b.y + h > HEIGHT:
217
- raise BdmvError(f"menu {m.id!r}: button {b.id!r} ({w} x {h} at {b.x}, {b.y}) "
218
- f"is not inside the {WIDTH} x {HEIGHT} frame")
219
- act = encode.picture(b.activated, (w, h))[2] if b.activated else None
220
- nor = encode.picture(b.normal, (w, h))[2] if b.normal else None
221
- buttons.append(igs.Button(b.id, b.x, b.y, w, h, sel, act, nor,
222
- nav.button(pb) if b.action else [], dict(b.nav), b.auto))
233
+ buttons = _ig_buttons(f"menu {m.id!r}", m.buttons, pm.buttons, nav)
223
234
  _, frame_rate = FORMATS[m.fps]
224
235
  with tempfile.TemporaryDirectory(prefix="bdmvkit-") as work:
225
236
  sup = os.path.join(work, "menu.sup")
@@ -229,6 +240,42 @@ class Disc:
229
240
  encode.encode_menu(m.background, sup, pm.clip, m.fps, seconds=m.seconds, fast=fast)
230
241
 
231
242
 
243
+ def _add_popup(self, t, buttons, default, pt, nav):
244
+ """The title's clip with its pop-up menu's IG stream, as pt.clip."""
245
+ _, frame_rate = FORMATS[t.fps]
246
+ ig = _ig_buttons(f"title {pt.id}'s pop-up menu", buttons, pt.popup_buttons, nav,
247
+ popup=True)
248
+ with tempfile.TemporaryDirectory(prefix="bdmvkit-") as work:
249
+ sup = os.path.join(work, "popup.sup")
250
+ with open(sup, "wb") as f:
251
+ f.write(igs.ig_stream(ig, frame_rate, default=default, popup=True))
252
+ encode.add_ig_stream(t.clip, sup, pt.clip)
253
+
254
+
255
+ def _button(b):
256
+ return program.Button(b.id, b.action, b.audio, b.subtitle, override=b.override)
257
+
258
+
259
+ def _ig_buttons(where, buttons, program_buttons, nav, popup=False):
260
+ """MenuButtons as igs.Buttons: their pictures read, their commands the
261
+ navigation's (in a pop-up menu, a button that only switches streams
262
+ closes the menu too)."""
263
+ out = []
264
+ for b, pb in zip(buttons, program_buttons):
265
+ w, h, sel = encode.picture(b.selected)
266
+ if b.x < 0 or b.y < 0 or b.x + w > WIDTH or b.y + h > HEIGHT:
267
+ raise BdmvError(f"{where}: button {b.id!r} ({w} x {h} at {b.x}, {b.y}) "
268
+ f"is not inside the {WIDTH} x {HEIGHT} frame")
269
+ act = encode.picture(b.activated, (w, h))[2] if b.activated else None
270
+ nor = encode.picture(b.normal, (w, h))[2] if b.normal else None
271
+ commands = nav.button(pb) if b.action else []
272
+ if popup and b.action and parse_action(b.action)[0] in ("audio", "subtitle"):
273
+ commands.append(hdmv.popup_off())
274
+ out.append(igs.Button(b.id, b.x, b.y, w, h, sel, act, nor, commands, dict(b.nav),
275
+ b.auto))
276
+ return out
277
+
278
+
232
279
  def _lang(code):
233
280
  code = (code or "").lower()
234
281
  return code if len(code) == 3 and code.isalpha() else lang3(code)
@@ -9,6 +9,7 @@ interlaced (1080i) stream, as the format has no 1080p at those rates.
9
9
  """
10
10
 
11
11
  import dataclasses
12
+ import os
12
13
  import shutil
13
14
  import subprocess
14
15
 
@@ -207,3 +208,49 @@ def encode_menu(background, sup, out, fps=(24000, 1001), seconds=1.0, fast=False
207
208
  f"{p.stderr.strip()[-800:]}")
208
209
  patch_ig_stream_type(out)
209
210
  return out
211
+
212
+
213
+ def add_ig_stream(clip, sup, out, ffmpeg=None):
214
+ """`clip` with the IG stream `sup` (igs.ig_stream, a pop-up menu) muxed
215
+ in, as `out`: the clip's streams copied with their PIDs and their times,
216
+ the buttons with IG_PID at the clip's first picture, marked Interactive
217
+ Graphics in the PMT. (Segments before the first picture are before the
218
+ play item's start, and players leave them out: so the buttons are timed
219
+ at it, not at the clip's own start, which the sound may begin.)"""
220
+ ffmpeg = ffmpeg or shutil.which("ffmpeg")
221
+ if not ffmpeg:
222
+ raise BdmvError("muxing needs ffmpeg")
223
+ from .igs import patch_ig_stream_type
224
+ probe = shutil.which("ffprobe") or ffmpeg.replace("ffmpeg", "ffprobe")
225
+ p = subprocess.run([probe, "-v", "error", "-show_entries", "stream=index,id", "-of",
226
+ "csv=p=0", clip], capture_output=True, text=True)
227
+ # (ffprobe lists a stream again under its program: one each, by index)
228
+ streams = dict(line.split(",")[:2] for line in p.stdout.split() if "," in line)
229
+ pids = [int(streams[k], 16) for k in sorted(streams, key=int)]
230
+ if not pids:
231
+ raise BdmvError(f"cannot read the streams of {clip}")
232
+ from .m2ts import scan_m2ts
233
+ start = scan_m2ts(clip).first_pts
234
+ with open(sup, "rb") as f:
235
+ data = bytearray(f.read())
236
+ i = 0
237
+ while i + 13 <= len(data): # each segment's PTS and DTS: the start
238
+ data[i + 2:i + 10] = (start & 0xFFFFFFFF).to_bytes(4, "big") * 2
239
+ i += 13 + int.from_bytes(data[i + 11:i + 13], "big")
240
+ timed = out + ".sup"
241
+ with open(timed, "wb") as f:
242
+ f.write(data)
243
+ cmd = [ffmpeg, "-hide_banner", "-nostdin", "-v", "error", "-y", "-copyts", "-i", clip,
244
+ "-f", "sup", "-i", timed, "-map", "0", "-map", "1:0", "-c", "copy"]
245
+ for k, pid in enumerate(pids + [IG_PID]):
246
+ cmd += ["-streamid", f"{k}:{pid}"]
247
+ cmd += ["-f", "mpegts", "-mpegts_m2ts_mode", "1", "-mpegts_pmt_start_pid", str(PMT_PID),
248
+ out]
249
+ p = subprocess.run(cmd, capture_output=True, text=True)
250
+ os.remove(timed)
251
+ if p.returncode != 0:
252
+ raise BdmvError(f"ffmpeg could not add the pop-up menu to {clip}: "
253
+ f"{p.stderr.strip()[-800:]}")
254
+ patch_ig_stream_type(out)
255
+ return out
256
+
@@ -121,7 +121,7 @@ class Navigation:
121
121
  # own default subtitles over the chosen ones (_start)
122
122
  self.stream_links = any(
123
123
  (b.audio or b.subtitle is not None) and b.action and parse_action(b.action)[0] in PLAYS
124
- for m in p.menus for b in m.buttons)
124
+ for b in p.buttons())
125
125
 
126
126
  def menu_number(self, menu_id):
127
127
  return self.numbers.get(f"menu:{menu_id}")
@@ -74,6 +74,10 @@ class Title:
74
74
  end: str = None # action after it (None: stop)
75
75
  prohibit: list = dataclasses.field(default_factory=list) # uops.KEYS names
76
76
  popup: bool = False # its clip carries a pop-up menu (an IG stream)
77
+ popup_buttons: list = dataclasses.field(default_factory=list)
78
+ # (Buttons of a pop-up menu the Program writes into the clip, as
79
+ # Disc.add_popup does; empty when the clip's IG stream is made elsewhere
80
+ # from a menu's buttons)
77
81
  hold: bool = False # each chapter a picture held until Next (a slideshow)
78
82
 
79
83
 
@@ -147,8 +151,15 @@ class Program:
147
151
  if m.loop is not None:
148
152
  out.append(m.end)
149
153
  out += [t.end for t in self.titles] + [p.end for p in self.playlists]
154
+ for t in self.titles:
155
+ out += [b.action for b in t.popup_buttons] + [b.override for b in t.popup_buttons]
150
156
  return [a for a in out if a]
151
157
 
158
+ def buttons(self):
159
+ """Every button: the menus', then the pop-up menus' of the titles."""
160
+ return [b for m in self.menus for b in m.buttons] + \
161
+ [b for t in self.titles for b in t.popup_buttons]
162
+
152
163
  @property
153
164
  def uses_playall(self):
154
165
  return "playall" in self.actions()
@@ -157,7 +168,7 @@ class Program:
157
168
  def overrides(self):
158
169
  """End-action overrides buttons use, in a fixed order (1-based numbers
159
170
  in the override register)."""
160
- return sorted({b.override for m in self.menus for b in m.buttons if b.override})
171
+ return sorted({b.override for b in self.buttons() if b.override})
161
172
 
162
173
  @property
163
174
  def link_transitions(self):
@@ -221,6 +232,11 @@ class Program:
221
232
  if b.transition and b.transition not in self.transitions:
222
233
  raise BdmvError(f"menu {m.id!r}, button {b.id!r}: no transition "
223
234
  f"{b.transition!r}")
235
+ for t in self.titles:
236
+ for b in t.popup_buttons:
237
+ if b.transition:
238
+ raise BdmvError(f"title {t.id!r}: a pop-up menu's button ({b.id!r}) "
239
+ "cannot play a transition")
224
240
 
225
241
 
226
242
  def transition_key(button):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: bdmvkit
3
- Version: 0.3.0
3
+ Version: 0.4.0
4
4
  Summary: Blu-ray (BDMV) authoring in pure Python: playlists, clip information, HDMV navigation, PGS subtitles
5
5
  Author: kajisho5
6
6
  License-Expression: LGPL-3.0-or-later
@@ -35,14 +35,14 @@ only to encode.
35
35
 
36
36
  日本語の説明は [README.ja.md](README.ja.md) にあります。
37
37
 
38
- **Status: 0.3.** Titles with chapters, sound and subtitle tracks;
38
+ **Status: 0.4.** Titles with chapters, sound and subtitle tracks;
39
39
  playlists (in order, at random or shuffled) and play all; menus whose
40
40
  buttons start titles, chapters, playlists and other menus, switch sound and
41
41
  subtitle tracks, or open a menu on a chosen button; loop counts, end-action
42
42
  overrides, link transitions, remote keys a title turns off, and 720p. This
43
43
  navigation is the one [oncemore](https://github.com/kajisho5/oncemore)
44
- writes, byte for byte. Encoding pop-up menus and menus with motion or music
45
- comes next.
44
+ writes, byte for byte. 0.4 adds pop-up menus over a playing title. Menus
45
+ with motion or music come next.
46
46
 
47
47
  ## Why
48
48
 
@@ -132,6 +132,19 @@ disc.add_menu("main", "menu.png", [
132
132
  ], loop=3, end="playall", seconds=10)
133
133
  ```
134
134
 
135
+ A title can have a pop-up menu: buttons over its picture, opened with the
136
+ remote's Pop-up Menu key. Their pictures are placed on the 1920 x 1080 frame
137
+ as a menu's are; a button that only switches the sound or subtitle track
138
+ closes the menu.
139
+
140
+ ```python
141
+ disc.add_popup(a, [
142
+ MenuButton("scenes", 160, 900, "scenes.png", "scenes-on.png", "scenes-off.png",
143
+ action="menu:main"),
144
+ MenuButton("ja", 560, 900, "ja.png", normal="ja-off.png", action="audio:2"),
145
+ ])
146
+ ```
147
+
135
148
  Underneath, `bdmvkit.program` describes a disc made of encoded clips
136
149
  (titles, playlists, menu clips that carry their buttons, transitions) and
137
150
  `bdmvkit.nav.write_program` writes it; `nav.Navigation` gives the commands a
@@ -196,6 +209,8 @@ The tests encode real clips, write a disc, and read it back with
196
209
  the titles, chapters, playlists and menus they link to, that a shuffled
197
210
  playlist plays each item once, and that a menu with a loop count goes on
198
211
  by itself;
212
+ * that a pop-up menu opens over a playing title with the Pop-up Menu key and
213
+ its buttons start another title or a chapter;
199
214
  * the commands every kind of action, title, playlist and menu turns into.
200
215
 
201
216
  The files' fixed bytes are checked as well.
@@ -26,4 +26,5 @@ tests/test_disc.py
26
26
  tests/test_files.py
27
27
  tests/test_menus.py
28
28
  tests/test_nav.py
29
- tests/test_pgs.py
29
+ tests/test_pgs.py
30
+ tests/test_popup.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "bdmvkit"
7
- version = "0.3.0"
7
+ version = "0.4.0"
8
8
  description = "Blu-ray (BDMV) authoring in pure Python: playlists, clip information, HDMV navigation, PGS subtitles"
9
9
  readme = "README.md"
10
10
  license = "LGPL-3.0-or-later"
@@ -0,0 +1,107 @@
1
+ """Pop-up menus: buttons over a playing title, muxed into its clip as an IG
2
+ stream, opened with the remote's Pop-up Menu key and played by libbluray."""
3
+
4
+ import os
5
+ import shutil
6
+ import tempfile
7
+ import unittest
8
+
9
+ from bdmvkit import BdmvError, Disc, MenuButton, hdmv
10
+ from bdmvkit.encode import encode_clip
11
+ from bdmvkit.formats import CODING_IG, IG_PID
12
+ from bdmvkit.m2ts import stream_pids
13
+
14
+ from .test_disc import ENCODE_OK, source
15
+ from .test_menus import png
16
+
17
+ try:
18
+ from . import bluray_sim
19
+ LIBBLURAY = bluray_sim.load_library()
20
+ except Exception: # pragma: no cover
21
+ LIBBLURAY = None
22
+
23
+
24
+ @unittest.skipUnless(ENCODE_OK and LIBBLURAY, "needs ffmpeg with libx264, and libbluray")
25
+ class TestPopup(unittest.TestCase):
26
+ """Title 1 has a pop-up menu: TWO (title 2) and SCENE (its own chapter 2)."""
27
+
28
+ @classmethod
29
+ def setUpClass(cls):
30
+ cls.tmp = d = tempfile.mkdtemp(prefix="bdmvkit-popup-")
31
+ a = encode_clip(source(os.path.join(d, "a.mp4"), 8), os.path.join(d, "a.m2ts"),
32
+ chapters=[0, 4], fast=True)
33
+ b = encode_clip(source(os.path.join(d, "b.mp4"), 2), os.path.join(d, "b.m2ts"),
34
+ fast=True)
35
+ lit = png(os.path.join(d, "lit.png"), 300, 60, "orange")
36
+ off = png(os.path.join(d, "off.png"), 300, 60, "gray")
37
+ disc = Disc()
38
+ cls.a = a
39
+ disc.add_title(a, chapters=[0, 4], end="stop")
40
+ disc.add_title(b, end="stop")
41
+ disc.add_popup(1, [MenuButton("two", 160, 900, lit, normal=off, action="title:2"),
42
+ MenuButton("scene", 760, 900, lit, normal=off,
43
+ action="chapter:1:2"),
44
+ MenuButton("sub", 1360, 900, lit, normal=off, action="subtitle:off")])
45
+ cls.out = os.path.join(d, "disc")
46
+ disc.write(cls.out, play_all=False, fast=True)
47
+
48
+ @classmethod
49
+ def tearDownClass(cls):
50
+ shutil.rmtree(cls.tmp, ignore_errors=True)
51
+
52
+ def test_in_the_clip(self):
53
+ clip = os.path.join(self.out, "BDMV", "STREAM", "00001.m2ts")
54
+ self.assertEqual(stream_pids(clip).get(IG_PID), CODING_IG)
55
+ # (the clip given is left as it was)
56
+ self.assertNotIn(IG_PID, stream_pids(self.a))
57
+ # the second title has none
58
+ self.assertNotIn(IG_PID, stream_pids(os.path.join(self.out, "BDMV", "STREAM",
59
+ "00002.m2ts")))
60
+
61
+ def test_opens_and_its_buttons_work(self):
62
+ with bluray_sim.Disc(LIBBLURAY, self.out) as disc:
63
+ disc.info()
64
+ ev = [e for e in disc.play(keys=("live:popup", "live:enter"))
65
+ if e[0] in ("title", "key", "menu") and e[1] != 0xFFFF]
66
+ keys = [e for e in ev if e[0] in ("title", "key")]
67
+ # title 1, the pop-up opened over it, TWO chosen: title 2
68
+ self.assertEqual(keys[:4], [("title", 1), ("key", "live:popup"), ("key", "live:enter"),
69
+ ("title", 2)])
70
+ drawn = [d for e in ev if e[0] == "menu" for d in e[1]]
71
+ xs = sorted({d[1] for d in drawn if d[0] == "draw"})
72
+ self.assertTrue({160, 760, 1360} <= set(xs), drawn)
73
+
74
+ def test_a_chapter_from_the_popup(self):
75
+ with bluray_sim.Disc(LIBBLURAY, self.out) as disc:
76
+ disc.info()
77
+ ev = disc.play(keys=("live:popup", "live:right", "live:enter"))
78
+ after = ev[next(i for i, e in enumerate(ev) if e == ("key", "live:enter")):]
79
+ self.assertIn(("at_chapter", 2), after)
80
+
81
+ def test_a_stream_button_closes_the_menu(self):
82
+ disc = Disc()
83
+ disc.add_title("x.m2ts")
84
+ p = disc.program()
85
+ from bdmvkit.disc import _ig_buttons
86
+ from bdmvkit.nav import Navigation
87
+ lit = os.path.join(self.tmp, "lit.png")
88
+ nav = Navigation(p)
89
+ sub = MenuButton("sub", 100, 100, lit, action="subtitle:off")
90
+ two = MenuButton("one", 100, 300, lit, action="title:1")
91
+ from bdmvkit.disc import _button
92
+ ig = _ig_buttons("t", [sub, two], [_button(sub), _button(two)], nav, popup=True)
93
+ self.assertEqual(ig[0].commands[-1], hdmv.popup_off())
94
+ self.assertNotIn(hdmv.popup_off(), ig[1].commands)
95
+
96
+
97
+ class TestChecks(unittest.TestCase):
98
+ def test_refused(self):
99
+ disc = Disc()
100
+ with self.assertRaises(BdmvError):
101
+ disc.add_popup(1, [MenuButton("a", 0, 0, "a.png", action="title:1")])
102
+ disc.add_title("x.m2ts")
103
+ with self.assertRaises(BdmvError):
104
+ disc.add_popup(1, [])
105
+ disc.add_popup(1, [MenuButton("a", 0, 0, "a.png", action="title:9")])
106
+ with self.assertRaises(BdmvError): # leads nowhere
107
+ disc.program().check()
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