bdmvkit 0.3.0__tar.gz → 0.5.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 (33) hide show
  1. {bdmvkit-0.3.0/bdmvkit.egg-info → bdmvkit-0.5.0}/PKG-INFO +30 -4
  2. {bdmvkit-0.3.0 → bdmvkit-0.5.0}/README.md +29 -3
  3. {bdmvkit-0.3.0 → bdmvkit-0.5.0}/bdmvkit/__init__.py +3 -2
  4. {bdmvkit-0.3.0 → bdmvkit-0.5.0}/bdmvkit/__main__.py +18 -10
  5. {bdmvkit-0.3.0 → bdmvkit-0.5.0}/bdmvkit/disc.py +107 -32
  6. {bdmvkit-0.3.0 → bdmvkit-0.5.0}/bdmvkit/encode.py +97 -11
  7. {bdmvkit-0.3.0 → bdmvkit-0.5.0}/bdmvkit/nav.py +1 -1
  8. {bdmvkit-0.3.0 → bdmvkit-0.5.0}/bdmvkit/program.py +17 -1
  9. {bdmvkit-0.3.0 → bdmvkit-0.5.0/bdmvkit.egg-info}/PKG-INFO +30 -4
  10. {bdmvkit-0.3.0 → bdmvkit-0.5.0}/bdmvkit.egg-info/SOURCES.txt +3 -1
  11. {bdmvkit-0.3.0 → bdmvkit-0.5.0}/pyproject.toml +1 -1
  12. bdmvkit-0.5.0/tests/test_motion.py +104 -0
  13. bdmvkit-0.5.0/tests/test_popup.py +107 -0
  14. {bdmvkit-0.3.0 → bdmvkit-0.5.0}/COPYING +0 -0
  15. {bdmvkit-0.3.0 → bdmvkit-0.5.0}/COPYING.LESSER +0 -0
  16. {bdmvkit-0.3.0 → bdmvkit-0.5.0}/bdmvkit/clip.py +0 -0
  17. {bdmvkit-0.3.0 → bdmvkit-0.5.0}/bdmvkit/errors.py +0 -0
  18. {bdmvkit-0.3.0 → bdmvkit-0.5.0}/bdmvkit/formats.py +0 -0
  19. {bdmvkit-0.3.0 → bdmvkit-0.5.0}/bdmvkit/hdmv.py +0 -0
  20. {bdmvkit-0.3.0 → bdmvkit-0.5.0}/bdmvkit/igs.py +0 -0
  21. {bdmvkit-0.3.0 → bdmvkit-0.5.0}/bdmvkit/m2ts.py +0 -0
  22. {bdmvkit-0.3.0 → bdmvkit-0.5.0}/bdmvkit/pgs.py +0 -0
  23. {bdmvkit-0.3.0 → bdmvkit-0.5.0}/bdmvkit/playlist.py +0 -0
  24. {bdmvkit-0.3.0 → bdmvkit-0.5.0}/bdmvkit/uops.py +0 -0
  25. {bdmvkit-0.3.0 → bdmvkit-0.5.0}/bdmvkit.egg-info/dependency_links.txt +0 -0
  26. {bdmvkit-0.3.0 → bdmvkit-0.5.0}/bdmvkit.egg-info/entry_points.txt +0 -0
  27. {bdmvkit-0.3.0 → bdmvkit-0.5.0}/bdmvkit.egg-info/top_level.txt +0 -0
  28. {bdmvkit-0.3.0 → bdmvkit-0.5.0}/setup.cfg +0 -0
  29. {bdmvkit-0.3.0 → bdmvkit-0.5.0}/tests/test_disc.py +0 -0
  30. {bdmvkit-0.3.0 → bdmvkit-0.5.0}/tests/test_files.py +0 -0
  31. {bdmvkit-0.3.0 → bdmvkit-0.5.0}/tests/test_menus.py +0 -0
  32. {bdmvkit-0.3.0 → bdmvkit-0.5.0}/tests/test_nav.py +0 -0
  33. {bdmvkit-0.3.0 → bdmvkit-0.5.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.5.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.5.** 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, 0.5
45
+ menus with motion or music.
46
46
 
47
47
  ## Why
48
48
 
@@ -132,6 +132,28 @@ disc.add_menu("main", "menu.png", [
132
132
  ], loop=3, end="playall", seconds=10)
133
133
  ```
134
134
 
135
+ A menu's background may be a video, and a menu may have music. Such a menu
136
+ plays again when it ends, from `loop_point` (seconds) the second time
137
+ round; it lasts as long as its video, else its music, unless `seconds`
138
+ says:
139
+
140
+ ```python
141
+ disc.add_menu("main", "loop.mp4", buttons, music="theme.wav", loop_point=2.0)
142
+ ```
143
+
144
+ A title can have a pop-up menu: buttons over its picture, opened with the
145
+ remote's Pop-up Menu key. Their pictures are placed on the 1920 x 1080 frame
146
+ as a menu's are; a button that only switches the sound or subtitle track
147
+ closes the menu.
148
+
149
+ ```python
150
+ disc.add_popup(a, [
151
+ MenuButton("scenes", 160, 900, "scenes.png", "scenes-on.png", "scenes-off.png",
152
+ action="menu:main"),
153
+ MenuButton("ja", 560, 900, "ja.png", normal="ja-off.png", action="audio:2"),
154
+ ])
155
+ ```
156
+
135
157
  Underneath, `bdmvkit.program` describes a disc made of encoded clips
136
158
  (titles, playlists, menu clips that carry their buttons, transitions) and
137
159
  `bdmvkit.nav.write_program` writes it; `nav.Navigation` gives the commands a
@@ -196,6 +218,10 @@ The tests encode real clips, write a disc, and read it back with
196
218
  the titles, chapters, playlists and menus they link to, that a shuffled
197
219
  playlist plays each item once, and that a menu with a loop count goes on
198
220
  by itself;
221
+ * that a menu with a video and music plays again from its loop point, and
222
+ that one with a loop count goes on by itself after playing that often;
223
+ * that a pop-up menu opens over a playing title with the Pop-up Menu key and
224
+ its buttons start another title or a chapter;
199
225
  * the commands every kind of action, title, playlist and menu turns into.
200
226
 
201
227
  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.5.** 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, 0.5
28
+ menus with motion or music.
29
29
 
30
30
  ## Why
31
31
 
@@ -115,6 +115,28 @@ disc.add_menu("main", "menu.png", [
115
115
  ], loop=3, end="playall", seconds=10)
116
116
  ```
117
117
 
118
+ A menu's background may be a video, and a menu may have music. Such a menu
119
+ plays again when it ends, from `loop_point` (seconds) the second time
120
+ round; it lasts as long as its video, else its music, unless `seconds`
121
+ says:
122
+
123
+ ```python
124
+ disc.add_menu("main", "loop.mp4", buttons, music="theme.wav", loop_point=2.0)
125
+ ```
126
+
127
+ A title can have a pop-up menu: buttons over its picture, opened with the
128
+ remote's Pop-up Menu key. Their pictures are placed on the 1920 x 1080 frame
129
+ as a menu's are; a button that only switches the sound or subtitle track
130
+ closes the menu.
131
+
132
+ ```python
133
+ disc.add_popup(a, [
134
+ MenuButton("scenes", 160, 900, "scenes.png", "scenes-on.png", "scenes-off.png",
135
+ action="menu:main"),
136
+ MenuButton("ja", 560, 900, "ja.png", normal="ja-off.png", action="audio:2"),
137
+ ])
138
+ ```
139
+
118
140
  Underneath, `bdmvkit.program` describes a disc made of encoded clips
119
141
  (titles, playlists, menu clips that carry their buttons, transitions) and
120
142
  `bdmvkit.nav.write_program` writes it; `nav.Navigation` gives the commands a
@@ -179,6 +201,10 @@ The tests encode real clips, write a disc, and read it back with
179
201
  the titles, chapters, playlists and menus they link to, that a shuffled
180
202
  playlist plays each item once, and that a menu with a loop count goes on
181
203
  by itself;
204
+ * that a menu with a video and music plays again from its loop point, and
205
+ that one with a loop count goes on by itself after playing that often;
206
+ * that a pop-up menu opens over a playing title with the Pop-up Menu key and
207
+ its buttons start another title or a chapter;
182
208
  * the commands every kind of action, title, playlist and menu turns into.
183
209
 
184
210
  The files' fixed bytes are checked as well.
@@ -1,7 +1,8 @@
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 (still, or with motion or music)
5
+ and pop-up menus (`MenuButton`).
5
6
  * `bdmvkit.program` and `bdmvkit.nav`: the model underneath (titles,
6
7
  playlists, menu clips, transitions, actions) and its writer,
7
8
  `nav.write_program`, with the navigation commands (`nav.Navigation`).
@@ -15,5 +16,5 @@
15
16
  from .disc import Disc, MenuButton
16
17
  from .errors import BdmvError
17
18
 
18
- __version__ = "0.3.0"
19
+ __version__ = "0.5.0"
19
20
  __all__ = ["Disc", "MenuButton", "BdmvError"]
@@ -10,8 +10,10 @@ DISC.json: {"titles": [{"clip": "a.m2ts", "fps": "24000/1001",
10
10
  "action": "title:1"}]}], "play_all": true}; paths are relative to the JSON
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
- "default" (a button id), "fps", "loop", "end", "seconds" and "prohibit"; a
14
- title "default_audio", "default_subtitle" and "prohibit". The disc may have
13
+ "default" (a button id), "fps", "loop", "end", "seconds", "prohibit",
14
+ "music" (a sound file) and "loop_point" (its "background" may be a video); a
15
+ title "default_audio", "default_subtitle", "prohibit" and "popup" ({"buttons":
16
+ [...], "default"}: its pop-up menu, buttons as a menu's). The disc may have
15
17
  "playlists" ([{"id", "items": [n or [n, first, last]], "order", "end"}]),
16
18
  "first_play" and "title_key".
17
19
  """
@@ -75,15 +77,21 @@ def main(argv=None):
75
77
  def file(name):
76
78
  return os.path.join(base, name) if name else None
77
79
 
80
+ def buttons(spec_buttons):
81
+ return [MenuButton(b["id"], int(b["x"]), int(b["y"]), file(b["selected"]),
82
+ file(b.get("activated")), file(b.get("normal")), b.get("action"),
83
+ dict(b.get("nav", {})), bool(b.get("auto")), b.get("audio"),
84
+ b.get("subtitle"), b.get("override")) for b in spec_buttons]
85
+
86
+ for n, t in enumerate(spec.get("titles", []), 1):
87
+ if t.get("popup"):
88
+ disc.add_popup(n, buttons(t["popup"]["buttons"]), t["popup"].get("default"))
78
89
  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)))
90
+ disc.add_menu(m["id"], file(m["background"]), buttons(m["buttons"]),
91
+ fps=_fps(m.get("fps", "24000/1001")), default=m.get("default"),
92
+ loop=m.get("loop"), end=m.get("end"), prohibit=m.get("prohibit", []),
93
+ seconds=m.get("seconds"), music=file(m.get("music")),
94
+ loop_point=m.get("loop_point"))
87
95
  res = disc.write(args.out, play_all=spec.get("play_all", True), fast=args.fast)
88
96
  except (BdmvError, OSError, KeyError, ValueError) as e:
89
97
  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
 
@@ -73,14 +74,16 @@ class MenuButton:
73
74
  @dataclasses.dataclass
74
75
  class Menu:
75
76
  id: str
76
- background: str # picture file, fitted into the HD frame
77
+ background: str # picture (or video) file, fitted into the HD frame
77
78
  buttons: list
78
79
  fps: tuple = (24000, 1001)
79
80
  default: str = None # the button selected first (default: the first)
80
81
  loop: int = None # held this many times its length, then `end`
81
82
  end: str = None
82
83
  prohibit: list = dataclasses.field(default_factory=list)
83
- seconds: float = 1.0 # its clip's length
84
+ seconds: float = None # its clip's length (default: 1, or the video's or music's)
85
+ music: str = None # sound file played with it
86
+ loop_point: float = None # where it plays again from (seconds), with motion or music
84
87
 
85
88
 
86
89
  def _check_streams(n, title):
@@ -103,6 +106,7 @@ class Disc:
103
106
  self.titles = []
104
107
  self.menus = []
105
108
  self.playlists = []
109
+ self.popups = {} # title number -> (buttons, default)
106
110
  self.first_play = first_play
107
111
  self.title_key = title_key
108
112
 
@@ -148,23 +152,42 @@ class Disc:
148
152
  self.playlists.append(program.Playlist(playlist_id, out, order, end))
149
153
 
150
154
  def add_menu(self, menu_id, background, buttons, fps=(24000, 1001), default=None, loop=None,
151
- end=None, prohibit=(), seconds=1.0):
152
- """Add a menu: `background` a picture, `buttons` MenuButtons. The
153
- menu's clip (`seconds` long) is made when the disc is written
154
- (ffmpeg). It holds its last picture; with `loop`, only for `loop`
155
- times its length, then it does `end`."""
155
+ end=None, prohibit=(), seconds=None, music=None, loop_point=None):
156
+ """Add a menu: `background` a picture or a video, `buttons`
157
+ MenuButtons, `music` a sound file. The menu's clip (`seconds` long:
158
+ by default 1, or as long as the video, else the music) is made when
159
+ the disc is written (ffmpeg). A still menu holds its last picture;
160
+ one with a video or music plays again, from `loop_point` (seconds)
161
+ the second time round. With `loop`, it does so `loop` times (a
162
+ still one holds its picture for its length each time), then `end`."""
156
163
  if tuple(fps) not in FORMATS:
157
164
  raise BdmvError(f"{fps[0]}/{fps[1]} fps has no Blu-ray format")
158
165
  if any(m.id == menu_id for m in self.menus):
159
166
  raise BdmvError(f"there is a menu {menu_id!r} already")
160
167
  if not buttons:
161
168
  raise BdmvError(f"menu {menu_id!r} needs at least one button")
169
+ if loop_point is not None and loop_point < 0:
170
+ raise BdmvError(f"menu {menu_id!r}: loop point {loop_point} before the start")
162
171
  self.menus.append(Menu(menu_id, background, list(buttons), tuple(fps), default, loop,
163
- end, list(prohibit), seconds))
172
+ end, list(prohibit), seconds, music, loop_point))
164
173
 
165
- def program(self, play_all=True):
174
+ def add_popup(self, title, buttons, default=None):
175
+ """Give title `title` (its number) a pop-up menu: `buttons`
176
+ (MenuButtons, their pictures placed on the 1920 x 1080 picture over
177
+ the playing title) shown by the remote's Pop-up Menu key. A button
178
+ that switches the sound or subtitle track closes the menu; one that
179
+ goes elsewhere goes there. The menu's IG stream is muxed into the
180
+ title's clip when the disc is written."""
181
+ if not 1 <= title <= len(self.titles):
182
+ raise BdmvError(f"there is no title {title}")
183
+ if not buttons:
184
+ raise BdmvError(f"title {title}: a pop-up menu needs at least one button")
185
+ self.popups[title] = (list(buttons), default)
186
+
187
+ def program(self, play_all=True, probe=False):
166
188
  """The disc as a program.Program (titles "1", "2" ...; menus with no
167
- clip yet: write makes them)."""
189
+ clip yet: write makes them). With `probe`, menu backgrounds are read
190
+ to tell videos from pictures (else they count as pictures)."""
168
191
  n_titles = len(self.titles)
169
192
  titles = []
170
193
  for n, t in enumerate(self.titles, 1):
@@ -174,14 +197,18 @@ class Disc:
174
197
  [program.Audio(CODECS[c], ch, _lang(lang), k == t.default_audio)
175
198
  for k, (c, ch, lang) in enumerate(t.audio, 1)],
176
199
  [program.Subtitle(_lang(lang), k == t.default_subtitle)
177
- for k, lang in enumerate(t.subtitles, 1)], end, list(t.prohibit)))
178
- 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],
181
- fps=m.fps, default=m.default, loop=m.loop, end=m.end,
182
- still_time=max(1, round(m.seconds)) if m.loop is not None
183
- else None,
184
- prohibit=list(m.prohibit)) for m in self.menus]
200
+ for k, lang in enumerate(t.subtitles, 1)], end, list(t.prohibit),
201
+ popup=n in self.popups,
202
+ popup_buttons=[_button(b) for b in self.popups.get(n, ((), None))[0]]))
203
+ menus = []
204
+ for m in self.menus:
205
+ still = not (m.music or (probe and _motion(m)))
206
+ menus.append(program.Menu(
207
+ m.id, None, [_button(b) for b in m.buttons], fps=m.fps, default=m.default,
208
+ still=still,
209
+ still_time=max(1, round(_seconds(m))) if still and m.loop is not None else None,
210
+ loop_point=m.loop_point if not still else None, loop=m.loop, end=m.end,
211
+ music=bool(m.music), prohibit=list(m.prohibit)))
185
212
  first_menu = f"menu:{self.menus[0].id}" if self.menus else None
186
213
  return program.Program(titles, list(self.playlists), menus,
187
214
  first_play=self.first_play or first_menu,
@@ -194,8 +221,8 @@ class Disc:
194
221
  "chapters"}."""
195
222
  if not self.titles:
196
223
  raise BdmvError("a disc needs at least one title")
197
- prog = self.program(play_all)
198
- prog.check() # (the menus' clips are made below)
224
+ self.program(play_all).check() # (before any file is read)
225
+ prog = self.program(play_all, probe=True)
199
226
  for n, t in enumerate(self.titles, 1):
200
227
  _check_streams(n, t)
201
228
  nav = Navigation(prog)
@@ -203,6 +230,10 @@ class Disc:
203
230
  for k, (m, pm) in enumerate(zip(self.menus, prog.menus), len(self.titles) + 1):
204
231
  pm.clip = os.path.join(out_dir, "BDMV", "STREAM", f"{k:05d}.m2ts")
205
232
  self._encode_menu(m, pm, nav, fast)
233
+ for n, (buttons, default) in sorted(self.popups.items()):
234
+ pt = prog.titles[n - 1]
235
+ pt.clip = os.path.join(out_dir, "BDMV", "STREAM", f"{n:05d}.m2ts")
236
+ self._add_popup(self.titles[n - 1], buttons, default, pt, nav)
206
237
  out = write_program(prog, out_dir, move=move)
207
238
  return {"titles": len(self.titles), "menus": len(self.menus),
208
239
  "playlists": out["playlists"], "chapters": out["chapters"]}
@@ -210,23 +241,67 @@ class Disc:
210
241
  def _encode_menu(self, m, pm, nav, fast):
211
242
  """Menu `m`'s clip (pm.clip): its buttons' pictures read, the IG
212
243
  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))
244
+ buttons = _ig_buttons(f"menu {m.id!r}", m.buttons, pm.buttons, nav)
223
245
  _, frame_rate = FORMATS[m.fps]
224
246
  with tempfile.TemporaryDirectory(prefix="bdmvkit-") as work:
225
247
  sup = os.path.join(work, "menu.sup")
226
248
  with open(sup, "wb") as f:
227
249
  f.write(igs.ig_stream(buttons, frame_rate, default=m.default,
228
250
  follow_selection=nav.follows_selection(m.id)))
229
- encode.encode_menu(m.background, sup, pm.clip, m.fps, seconds=m.seconds, fast=fast)
251
+ encode.encode_menu(m.background, sup, pm.clip, m.fps, seconds=_seconds(m), fast=fast,
252
+ motion=_motion(m), music=m.music,
253
+ keys=[m.loop_point] if m.loop_point else [])
254
+
255
+
256
+ def _add_popup(self, t, buttons, default, pt, nav):
257
+ """The title's clip with its pop-up menu's IG stream, as pt.clip."""
258
+ _, frame_rate = FORMATS[t.fps]
259
+ ig = _ig_buttons(f"title {pt.id}'s pop-up menu", buttons, pt.popup_buttons, nav,
260
+ popup=True)
261
+ with tempfile.TemporaryDirectory(prefix="bdmvkit-") as work:
262
+ sup = os.path.join(work, "popup.sup")
263
+ with open(sup, "wb") as f:
264
+ f.write(igs.ig_stream(ig, frame_rate, default=default, popup=True))
265
+ encode.add_ig_stream(t.clip, sup, pt.clip)
266
+
267
+
268
+ def _motion(m):
269
+ """Whether menu m's background is a video (asked once)."""
270
+ if not hasattr(m, "_motion"):
271
+ m._motion = encode.is_video(m.background)
272
+ return m._motion
273
+
274
+
275
+ def _seconds(m):
276
+ if m.seconds is not None:
277
+ return m.seconds
278
+ if _motion(m):
279
+ return encode.media_seconds(m.background)
280
+ return encode.media_seconds(m.music) if m.music else 1.0
281
+
282
+
283
+ def _button(b):
284
+ return program.Button(b.id, b.action, b.audio, b.subtitle, override=b.override)
285
+
286
+
287
+ def _ig_buttons(where, buttons, program_buttons, nav, popup=False):
288
+ """MenuButtons as igs.Buttons: their pictures read, their commands the
289
+ navigation's (in a pop-up menu, a button that only switches streams
290
+ closes the menu too)."""
291
+ out = []
292
+ for b, pb in zip(buttons, program_buttons):
293
+ w, h, sel = encode.picture(b.selected)
294
+ if b.x < 0 or b.y < 0 or b.x + w > WIDTH or b.y + h > HEIGHT:
295
+ raise BdmvError(f"{where}: button {b.id!r} ({w} x {h} at {b.x}, {b.y}) "
296
+ f"is not inside the {WIDTH} x {HEIGHT} frame")
297
+ act = encode.picture(b.activated, (w, h))[2] if b.activated else None
298
+ nor = encode.picture(b.normal, (w, h))[2] if b.normal else None
299
+ commands = nav.button(pb) if b.action else []
300
+ if popup and b.action and parse_action(b.action)[0] in ("audio", "subtitle"):
301
+ commands.append(hdmv.popup_off())
302
+ out.append(igs.Button(b.id, b.x, b.y, w, h, sel, act, nor, commands, dict(b.nav),
303
+ b.auto))
304
+ return out
230
305
 
231
306
 
232
307
  def _lang(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
 
@@ -184,22 +185,37 @@ def picture(path, size=None, ffmpeg=None):
184
185
  return w, h, p.stdout
185
186
 
186
187
 
187
- def encode_menu(background, sup, out, fps=(24000, 1001), seconds=1.0, fast=False, ffmpeg=None):
188
- """A menu clip: the picture `background` (fitted into the HD frame) for
189
- `seconds`, with the IG stream `sup` (igs.ig_stream) as its buttons; the
190
- playlist holds its last picture."""
188
+ def encode_menu(background, sup, out, fps=(24000, 1001), seconds=1.0, fast=False, ffmpeg=None,
189
+ motion=False, music=None, keys=()):
190
+ """A menu clip `seconds` long: the picture `background` (fitted into the
191
+ HD frame; with `motion`, a video played from its start), with the IG
192
+ stream `sup` (igs.ig_stream) as its buttons and, with `music`, that
193
+ sound file as its sound track (AC-3, padded with silence to the
194
+ length). A still menu's playlist holds its last picture; one with
195
+ motion or music plays again. `keys`: times (s) that become key frames
196
+ (the loop point, so its mark lands on it)."""
191
197
  ffmpeg = ffmpeg or shutil.which("ffmpeg")
192
198
  if not ffmpeg:
193
199
  raise BdmvError("encoding needs ffmpeg (with libx264)")
194
200
  from .igs import patch_ig_stream_type
195
201
  num, den = fps
196
- cmd = [ffmpeg, "-hide_banner", "-nostdin", "-v", "error", "-y",
197
- "-loop", "1", "-framerate", f"{num}/{den}", "-t", f"{seconds:.3f}", "-i", background,
198
- "-f", "sup", "-i", sup,
199
- "-filter_complex", f"[0:v]{fit_filter(fps)},format=yuv420p[v]",
200
- "-map", "[v]", "-map", "1:0"] + video_args(fps, MAX_VIDEO_BPS, fast) + \
201
- ["-c:s", "copy", "-streamid", f"0:{VIDEO_PID}", "-streamid", f"1:{IG_PID}",
202
- "-t", f"{seconds:.3f}", "-f", "mpegts", "-mpegts_m2ts_mode", "1",
202
+ picture = ["-i", background] if motion else \
203
+ ["-loop", "1", "-framerate", f"{num}/{den}", "-t", f"{seconds:.3f}", "-i", background]
204
+ cmd = [ffmpeg, "-hide_banner", "-nostdin", "-v", "error", "-y"] + picture + \
205
+ ["-f", "sup", "-i", sup] + (["-i", music] if music else [])
206
+ graph = f"[0:v]{fit_filter(fps)},format=yuv420p[v]"
207
+ maps = ["-map", "[v]", "-map", "1:0"]
208
+ ids = ["-streamid", f"0:{VIDEO_PID}", "-streamid", f"1:{IG_PID}"]
209
+ sound = []
210
+ if music:
211
+ graph += ";[2:a:0]apad[a]"
212
+ maps += ["-map", "[a]"]
213
+ ids += ["-streamid", f"2:{AUDIO_PID}"]
214
+ sound = audio_args("ac3", 2)
215
+ keys = [t for t in keys if t and t > 0]
216
+ cmd += ["-filter_complex", graph] + maps + video_args(fps, MAX_VIDEO_BPS, fast, keys) + \
217
+ sound + ["-c:s", "copy"] + ids + \
218
+ ["-t", f"{seconds:.3f}", "-f", "mpegts", "-mpegts_m2ts_mode", "1",
203
219
  "-mpegts_pmt_start_pid", str(PMT_PID), out]
204
220
  p = subprocess.run(cmd, capture_output=True, text=True)
205
221
  if p.returncode != 0:
@@ -207,3 +223,73 @@ def encode_menu(background, sup, out, fps=(24000, 1001), seconds=1.0, fast=False
207
223
  f"{p.stderr.strip()[-800:]}")
208
224
  patch_ig_stream_type(out)
209
225
  return out
226
+
227
+
228
+ def is_video(path, ffmpeg=None):
229
+ """Whether `path` is a video (moving pictures) rather than a picture."""
230
+ ffmpeg = ffmpeg or shutil.which("ffmpeg") or "ffmpeg"
231
+ probe = shutil.which("ffprobe") or ffmpeg.replace("ffmpeg", "ffprobe")
232
+ p = subprocess.run([probe, "-v", "error", "-show_entries", "format=format_name", "-of",
233
+ "csv=p=0", path], capture_output=True, text=True)
234
+ name = p.stdout.strip()
235
+ if not name:
236
+ raise BdmvError(f"cannot read {path}")
237
+ return not (name == "image2" or name.endswith("_pipe"))
238
+
239
+
240
+ def media_seconds(path, ffmpeg=None):
241
+ """How long a video or sound file lasts (seconds)."""
242
+ ffmpeg = ffmpeg or shutil.which("ffmpeg") or "ffmpeg"
243
+ probe = shutil.which("ffprobe") or ffmpeg.replace("ffmpeg", "ffprobe")
244
+ p = subprocess.run([probe, "-v", "error", "-show_entries", "format=duration", "-of",
245
+ "csv=p=0", path], capture_output=True, text=True)
246
+ try:
247
+ return float(p.stdout.strip())
248
+ except ValueError:
249
+ raise BdmvError(f"cannot tell how long {path} is")
250
+
251
+
252
+ def add_ig_stream(clip, sup, out, ffmpeg=None):
253
+ """`clip` with the IG stream `sup` (igs.ig_stream, a pop-up menu) muxed
254
+ in, as `out`: the clip's streams copied with their PIDs and their times,
255
+ the buttons with IG_PID at the clip's first picture, marked Interactive
256
+ Graphics in the PMT. (Segments before the first picture are before the
257
+ play item's start, and players leave them out: so the buttons are timed
258
+ at it, not at the clip's own start, which the sound may begin.)"""
259
+ ffmpeg = ffmpeg or shutil.which("ffmpeg")
260
+ if not ffmpeg:
261
+ raise BdmvError("muxing needs ffmpeg")
262
+ from .igs import patch_ig_stream_type
263
+ probe = shutil.which("ffprobe") or ffmpeg.replace("ffmpeg", "ffprobe")
264
+ p = subprocess.run([probe, "-v", "error", "-show_entries", "stream=index,id", "-of",
265
+ "csv=p=0", clip], capture_output=True, text=True)
266
+ # (ffprobe lists a stream again under its program: one each, by index)
267
+ streams = dict(line.split(",")[:2] for line in p.stdout.split() if "," in line)
268
+ pids = [int(streams[k], 16) for k in sorted(streams, key=int)]
269
+ if not pids:
270
+ raise BdmvError(f"cannot read the streams of {clip}")
271
+ from .m2ts import scan_m2ts
272
+ start = scan_m2ts(clip).first_pts
273
+ with open(sup, "rb") as f:
274
+ data = bytearray(f.read())
275
+ i = 0
276
+ while i + 13 <= len(data): # each segment's PTS and DTS: the start
277
+ data[i + 2:i + 10] = (start & 0xFFFFFFFF).to_bytes(4, "big") * 2
278
+ i += 13 + int.from_bytes(data[i + 11:i + 13], "big")
279
+ timed = out + ".sup"
280
+ with open(timed, "wb") as f:
281
+ f.write(data)
282
+ cmd = [ffmpeg, "-hide_banner", "-nostdin", "-v", "error", "-y", "-copyts", "-i", clip,
283
+ "-f", "sup", "-i", timed, "-map", "0", "-map", "1:0", "-c", "copy"]
284
+ for k, pid in enumerate(pids + [IG_PID]):
285
+ cmd += ["-streamid", f"{k}:{pid}"]
286
+ cmd += ["-f", "mpegts", "-mpegts_m2ts_mode", "1", "-mpegts_pmt_start_pid", str(PMT_PID),
287
+ out]
288
+ p = subprocess.run(cmd, capture_output=True, text=True)
289
+ os.remove(timed)
290
+ if p.returncode != 0:
291
+ raise BdmvError(f"ffmpeg could not add the pop-up menu to {clip}: "
292
+ f"{p.stderr.strip()[-800:]}")
293
+ patch_ig_stream_type(out)
294
+ return out
295
+
@@ -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.5.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.5.** 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, 0.5
45
+ menus with motion or music.
46
46
 
47
47
  ## Why
48
48
 
@@ -132,6 +132,28 @@ disc.add_menu("main", "menu.png", [
132
132
  ], loop=3, end="playall", seconds=10)
133
133
  ```
134
134
 
135
+ A menu's background may be a video, and a menu may have music. Such a menu
136
+ plays again when it ends, from `loop_point` (seconds) the second time
137
+ round; it lasts as long as its video, else its music, unless `seconds`
138
+ says:
139
+
140
+ ```python
141
+ disc.add_menu("main", "loop.mp4", buttons, music="theme.wav", loop_point=2.0)
142
+ ```
143
+
144
+ A title can have a pop-up menu: buttons over its picture, opened with the
145
+ remote's Pop-up Menu key. Their pictures are placed on the 1920 x 1080 frame
146
+ as a menu's are; a button that only switches the sound or subtitle track
147
+ closes the menu.
148
+
149
+ ```python
150
+ disc.add_popup(a, [
151
+ MenuButton("scenes", 160, 900, "scenes.png", "scenes-on.png", "scenes-off.png",
152
+ action="menu:main"),
153
+ MenuButton("ja", 560, 900, "ja.png", normal="ja-off.png", action="audio:2"),
154
+ ])
155
+ ```
156
+
135
157
  Underneath, `bdmvkit.program` describes a disc made of encoded clips
136
158
  (titles, playlists, menu clips that carry their buttons, transitions) and
137
159
  `bdmvkit.nav.write_program` writes it; `nav.Navigation` gives the commands a
@@ -196,6 +218,10 @@ The tests encode real clips, write a disc, and read it back with
196
218
  the titles, chapters, playlists and menus they link to, that a shuffled
197
219
  playlist plays each item once, and that a menu with a loop count goes on
198
220
  by itself;
221
+ * that a menu with a video and music plays again from its loop point, and
222
+ that one with a loop count goes on by itself after playing that often;
223
+ * that a pop-up menu opens over a playing title with the Pop-up Menu key and
224
+ its buttons start another title or a chapter;
199
225
  * the commands every kind of action, title, playlist and menu turns into.
200
226
 
201
227
  The files' fixed bytes are checked as well.
@@ -25,5 +25,7 @@ bdmvkit.egg-info/top_level.txt
25
25
  tests/test_disc.py
26
26
  tests/test_files.py
27
27
  tests/test_menus.py
28
+ tests/test_motion.py
28
29
  tests/test_nav.py
29
- tests/test_pgs.py
30
+ tests/test_pgs.py
31
+ 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.5.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,104 @@
1
+ """Menus with motion or music: a video as the background, a sound file as
2
+ the menu's sound, played again from a loop point; and a menu with music and
3
+ a loop count that goes on by itself. Played by libbluray."""
4
+
5
+ import os
6
+ import shutil
7
+ import subprocess
8
+ import tempfile
9
+ import unittest
10
+
11
+ from bdmvkit import Disc, MenuButton
12
+ from bdmvkit.encode import encode_clip, is_video, media_seconds
13
+ from bdmvkit.formats import AUDIO_PID, CODING_AC3, IG_PID
14
+ from bdmvkit.m2ts import stream_pids
15
+
16
+ from .test_disc import ENCODE_OK, source
17
+ from .test_menus import png
18
+
19
+ try:
20
+ from . import bluray_sim
21
+ LIBBLURAY = bluray_sim.load_library()
22
+ except Exception: # pragma: no cover
23
+ LIBBLURAY = None
24
+
25
+
26
+ def tone(path, seconds):
27
+ subprocess.run(["ffmpeg", "-hide_banner", "-v", "error", "-y", "-f", "lavfi", "-i",
28
+ f"sine=frequency=330:d={seconds}:r=48000", path], check=True)
29
+ return path
30
+
31
+
32
+ @unittest.skipUnless(ENCODE_OK and LIBBLURAY, "needs ffmpeg with libx264, and libbluray")
33
+ class TestMotionMenus(unittest.TestCase):
34
+ """Menu "moving": a 2 s video with 3 s of music, from 0.5 s the second time
35
+ (the video decides its length). Menu "music": a picture with 1.5 s of
36
+ music, played twice, then title 1."""
37
+
38
+ @classmethod
39
+ def setUpClass(cls):
40
+ cls.tmp = d = tempfile.mkdtemp(prefix="bdmvkit-motion-")
41
+ a = encode_clip(source(os.path.join(d, "a.mp4"), 2), os.path.join(d, "a.m2ts"),
42
+ fast=True)
43
+ cls.video = source(os.path.join(d, "bg.mp4"), 2, tone=False)
44
+ cls.bg = png(os.path.join(d, "bg.png"), 1920, 1080, "navy")
45
+ lit = png(os.path.join(d, "lit.png"), 300, 60, "orange")
46
+ disc = Disc(first_play="menu:moving")
47
+ disc.add_title(a, end="menu:music")
48
+ disc.add_menu("moving", cls.video, [MenuButton("play", 160, 900, lit, action="title:1")],
49
+ music=tone(os.path.join(d, "m.wav"), 3), loop_point=0.5)
50
+ disc.add_menu("music", cls.bg, [MenuButton("play", 160, 900, lit, action="title:1")],
51
+ music=tone(os.path.join(d, "m2.wav"), 1.5), loop=2, end="title:1")
52
+ cls.out = os.path.join(d, "disc")
53
+ disc.write(cls.out, play_all=False, fast=True)
54
+
55
+ @classmethod
56
+ def tearDownClass(cls):
57
+ shutil.rmtree(cls.tmp, ignore_errors=True)
58
+
59
+ def clip(self, n):
60
+ return os.path.join(self.out, "BDMV", "STREAM", f"{n:05d}.m2ts")
61
+
62
+ def test_pictures_and_videos_told_apart(self):
63
+ self.assertTrue(is_video(self.video))
64
+ self.assertFalse(is_video(self.bg))
65
+
66
+ def test_clips_carry_the_music_and_the_buttons(self):
67
+ for n in (2, 3):
68
+ pids = stream_pids(self.clip(n))
69
+ self.assertEqual(pids.get(AUDIO_PID), CODING_AC3)
70
+ self.assertIn(IG_PID, pids)
71
+ # the video decides the moving menu's length; the music the other's
72
+ self.assertAlmostEqual(media_seconds(self.clip(2)), 2, delta=0.2)
73
+ self.assertAlmostEqual(media_seconds(self.clip(3)), 1.5, delta=0.2)
74
+
75
+ def test_read_back(self):
76
+ with bluray_sim.Disc(LIBBLURAY, self.out) as disc:
77
+ moving, music = disc.playlist(2), disc.playlist(3)
78
+ self.assertEqual(moving["audio"], [(CODING_AC3, 3, "und")])
79
+ # two marks: the start, and the loop point
80
+ self.assertEqual(len(moving["chapters"]), 2)
81
+ self.assertAlmostEqual(moving["chapters"][1], 0.5, delta=0.1)
82
+ self.assertEqual(len(music["chapters"]), 1)
83
+
84
+ def test_played_again_from_the_loop_point(self):
85
+ with bluray_sim.Disc(LIBBLURAY, self.out) as disc:
86
+ ev = disc.play(max_bytes=40 * 2**20)
87
+ seen = [e for e in ev if e[0] in ("playlist", "at_chapter")]
88
+ # the moving menu, then again and again from its loop point (mark 2)
89
+ self.assertEqual(seen[:4], [("playlist", 2), ("at_chapter", 1),
90
+ ("playlist", 2), ("at_chapter", 2)])
91
+
92
+ def test_a_loop_count_goes_on_by_itself(self):
93
+ disc = Disc(first_play="menu:music")
94
+ disc.add_title(self.clip(1), end="stop")
95
+ lit = os.path.join(self.tmp, "lit.png")
96
+ disc.add_menu("music", self.bg, [MenuButton("play", 160, 900, lit, action="title:1")],
97
+ music=os.path.join(self.tmp, "m2.wav"), loop=2, end="title:1")
98
+ out = os.path.join(self.tmp, "disc2")
99
+ disc.write(out, play_all=False, fast=True)
100
+ with bluray_sim.Disc(LIBBLURAY, out) as d:
101
+ ev = d.play()
102
+ plays = [v for k, v in ev if k == "playlist"]
103
+ # the menu twice (playlist 2), then title 1
104
+ self.assertEqual(plays[:3], [2, 2, 1])
@@ -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