bdmvkit 0.2.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 (33) hide show
  1. {bdmvkit-0.2.0/bdmvkit.egg-info → bdmvkit-0.4.0}/PKG-INFO +80 -13
  2. {bdmvkit-0.2.0 → bdmvkit-0.4.0}/README.md +79 -12
  3. {bdmvkit-0.2.0 → bdmvkit-0.4.0}/bdmvkit/__init__.py +5 -2
  4. {bdmvkit-0.2.0 → bdmvkit-0.4.0}/bdmvkit/__main__.py +26 -9
  5. bdmvkit-0.4.0/bdmvkit/disc.py +281 -0
  6. {bdmvkit-0.2.0 → bdmvkit-0.4.0}/bdmvkit/encode.py +121 -17
  7. {bdmvkit-0.2.0 → bdmvkit-0.4.0}/bdmvkit/formats.py +26 -1
  8. {bdmvkit-0.2.0 → bdmvkit-0.4.0}/bdmvkit/hdmv.py +5 -0
  9. {bdmvkit-0.2.0 → bdmvkit-0.4.0}/bdmvkit/m2ts.py +25 -1
  10. bdmvkit-0.4.0/bdmvkit/nav.py +417 -0
  11. bdmvkit-0.4.0/bdmvkit/program.py +245 -0
  12. bdmvkit-0.4.0/bdmvkit/uops.py +32 -0
  13. {bdmvkit-0.2.0 → bdmvkit-0.4.0/bdmvkit.egg-info}/PKG-INFO +80 -13
  14. {bdmvkit-0.2.0 → bdmvkit-0.4.0}/bdmvkit.egg-info/SOURCES.txt +6 -1
  15. {bdmvkit-0.2.0 → bdmvkit-0.4.0}/pyproject.toml +1 -1
  16. {bdmvkit-0.2.0 → bdmvkit-0.4.0}/tests/test_disc.py +68 -2
  17. bdmvkit-0.4.0/tests/test_nav.py +244 -0
  18. bdmvkit-0.4.0/tests/test_popup.py +107 -0
  19. bdmvkit-0.2.0/bdmvkit/disc.py +0 -257
  20. {bdmvkit-0.2.0 → bdmvkit-0.4.0}/COPYING +0 -0
  21. {bdmvkit-0.2.0 → bdmvkit-0.4.0}/COPYING.LESSER +0 -0
  22. {bdmvkit-0.2.0 → bdmvkit-0.4.0}/bdmvkit/clip.py +0 -0
  23. {bdmvkit-0.2.0 → bdmvkit-0.4.0}/bdmvkit/errors.py +0 -0
  24. {bdmvkit-0.2.0 → bdmvkit-0.4.0}/bdmvkit/igs.py +0 -0
  25. {bdmvkit-0.2.0 → bdmvkit-0.4.0}/bdmvkit/pgs.py +0 -0
  26. {bdmvkit-0.2.0 → bdmvkit-0.4.0}/bdmvkit/playlist.py +0 -0
  27. {bdmvkit-0.2.0 → bdmvkit-0.4.0}/bdmvkit.egg-info/dependency_links.txt +0 -0
  28. {bdmvkit-0.2.0 → bdmvkit-0.4.0}/bdmvkit.egg-info/entry_points.txt +0 -0
  29. {bdmvkit-0.2.0 → bdmvkit-0.4.0}/bdmvkit.egg-info/top_level.txt +0 -0
  30. {bdmvkit-0.2.0 → bdmvkit-0.4.0}/setup.cfg +0 -0
  31. {bdmvkit-0.2.0 → bdmvkit-0.4.0}/tests/test_files.py +0 -0
  32. {bdmvkit-0.2.0 → bdmvkit-0.4.0}/tests/test_menus.py +0 -0
  33. {bdmvkit-0.2.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.2.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,11 +35,14 @@ only to encode.
35
35
 
36
36
  日本語の説明は [README.ja.md](README.ja.md) にあります。
37
37
 
38
- **Status: 0.2.** Titles with chapters, sound and subtitle tracks, and menus
39
- whose buttons start titles, chapters and other menus. Pop-up menus (over a
40
- playing title) and menus with motion or music come next; they already work
41
- in [oncemore](https://github.com/kajisho5/oncemore), where this code comes
42
- from.
38
+ **Status: 0.4.** Titles with chapters, sound and subtitle tracks;
39
+ playlists (in order, at random or shuffled) and play all; menus whose
40
+ buttons start titles, chapters, playlists and other menus, switch sound and
41
+ subtitle tracks, or open a menu on a chosen button; loop counts, end-action
42
+ overrides, link transitions, remote keys a title turns off, and 720p. This
43
+ navigation is the one [oncemore](https://github.com/kajisho5/oncemore)
44
+ writes, byte for byte. 0.4 adds pop-up menus over a playing title. Menus
45
+ with motion or music come next.
43
46
 
44
47
  ## Why
45
48
 
@@ -75,6 +78,23 @@ disc.add_title("extras.m2ts", fps=(25, 1))
75
78
  disc.write("out") # out/BDMV/...
76
79
  ```
77
80
 
81
+ A clip can carry several sound tracks, each from the video itself or from
82
+ another file; track k is the title's audio track k (silence when a file has
83
+ no sound, and sound shorter than the picture is made up with silence):
84
+
85
+ ```python
86
+ from bdmvkit.encode import AudioTrack
87
+
88
+ encode_clip("feature.mov", "feature.m2ts", audio=[
89
+ AudioTrack(), # the video's own sound, AC-3
90
+ AudioTrack("commentary.wav", "lpcm"), # another file, LPCM
91
+ AudioTrack("feature.mov", "ac3", 6, stream=1), # its second sound stream, 5.1
92
+ ])
93
+ disc.add_title("feature.m2ts", audio=[("ac3", 2, "en"), ("lpcm", 2, "en"), ("ac3", 6, "ja")])
94
+ ```
95
+
96
+ `Disc.write` checks that every track a title declares is in its clip.
97
+
78
98
  A menu is a picture and its buttons. Each button is a picture of how it
79
99
  looks when selected (and, if given, when activated and before it is
80
100
  selected), placed on the 1920 x 1080 menu, and a link:
@@ -91,10 +111,44 @@ disc.add_menu("main", "menu.png", [
91
111
  disc.write("out")
92
112
  ```
93
113
 
94
- Links are `title:<n>`, `chapter:<n>:<c>` and `menu:<id>`; a title's `end`
95
- takes the same. The arrow keys go to the nearest button that way unless a
96
- button's `nav` says (`{"down": "scene2"}`). `bdmvkit.igs` makes the button
97
- stream on its own, for other tools.
114
+ A button's `action`, a title's `end` and the disc's first play are
115
+ actions: `title:<n>`, `chapter:<n>:<c>`, `playlist:<id>`, `playall`,
116
+ `menu:<id>` (or `menu:<id>:<button>`, that button selected), `lastmenu`,
117
+ `resume`, `audio:<n>`, `subtitle:<n>` or `subtitle:off`, and `stop`. The
118
+ arrow keys go to the nearest button that way unless a button's `nav` says
119
+ (`{"down": "scene2"}`). `bdmvkit.igs` makes the button stream on its own,
120
+ for other tools.
121
+
122
+ ```python
123
+ disc = Disc(title_key="menu:main")
124
+ a = disc.add_title("feature.m2ts", chapters=[0, 300], audio=[("ac3", 6, "en"), ("ac3", 6, "ja")],
125
+ subtitles=["en", "ja"], default_subtitle=1, end="lastmenu")
126
+ n = disc.add_title("notice.m2ts", prohibit=["next", "fast_forward", "menu"], end="title:1")
127
+ disc.add_playlist("both", [n, (a, 2, None)], order="shuffle", end="menu:main")
128
+ disc.add_menu("main", "menu.png", [
129
+ MenuButton("play", 160, 700, "play.png", action="title:2"), # the notice first
130
+ MenuButton("ja", 160, 800, "ja.png", action="title:1", audio=2, subtitle=0),
131
+ MenuButton("both", 160, 900, "both.png", action="playlist:both"),
132
+ ], loop=3, end="playall", seconds=10)
133
+ ```
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
+
148
+ Underneath, `bdmvkit.program` describes a disc made of encoded clips
149
+ (titles, playlists, menu clips that carry their buttons, transitions) and
150
+ `bdmvkit.nav.write_program` writes it; `nav.Navigation` gives the commands a
151
+ menu's buttons run, for tools that make their own IG streams.
98
152
 
99
153
  ```sh
100
154
  bdmvkit encode feature.mov feature.m2ts --chapters 0,300,600
@@ -115,12 +169,20 @@ bdmvkit build disc.json out
115
169
  How the disc is laid out:
116
170
 
117
171
  * Frame rates are 23.976 (1080p), 25 and 29.97. The last two are coded as
118
- progressive frames in a 1080i stream, as the format asks.
172
+ progressive frames in a 1080i stream, as the format asks. A program can
173
+ also be 720p (23.976, 50, 59.94; 25 and 29.97 go on at twice their rate);
174
+ `encode_clip` makes 1080 clips.
119
175
  * Sound is AC-3, LPCM or DTS.
120
176
  * Subtitles are PGS streams muxed into the clip. `pgs.write_sup` makes them
121
177
  from pictures, and `encode_clip(..., sups=[...])` muxes them in.
122
178
  * First play and the Top Menu key open the first menu, or start title 1
123
- when there is none.
179
+ when there is none (`Disc(first_play=..., title_key=...)` choose).
180
+ * Each title, playlist, play all and menu is a Blu-ray title, in that
181
+ order; random and shuffled playlists play a playlist file per item, picked
182
+ with the player's random number command. The general registers 1 to 8
183
+ hold the last menu, the button a link selects, a menu's loop count, an
184
+ end-action override, a stream choice, and a random playlist's state
185
+ (`nav.py`).
124
186
  * A menu is an interactive title after the titles: its picture held as a
125
187
  still, with its buttons as an Interactive Graphics stream in its clip.
126
188
  The menus are encoded when the disc is written (ffmpeg).
@@ -144,7 +206,12 @@ The tests encode real clips, write a disc, and read it back with
144
206
  * that running the disc from first play reaches the titles and chapters in
145
207
  order;
146
208
  * that a menu's buttons, chosen and pressed with the remote's keys, start
147
- the titles, chapters and menus they link to.
209
+ the titles, chapters, playlists and menus they link to, that a shuffled
210
+ playlist plays each item once, and that a menu with a loop count goes on
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;
214
+ * the commands every kind of action, title, playlist and menu turns into.
148
215
 
149
216
  The files' fixed bytes are checked as well.
150
217
 
@@ -18,11 +18,14 @@ only to encode.
18
18
 
19
19
  日本語の説明は [README.ja.md](README.ja.md) にあります。
20
20
 
21
- **Status: 0.2.** Titles with chapters, sound and subtitle tracks, and menus
22
- whose buttons start titles, chapters and other menus. Pop-up menus (over a
23
- playing title) and menus with motion or music come next; they already work
24
- in [oncemore](https://github.com/kajisho5/oncemore), where this code comes
25
- from.
21
+ **Status: 0.4.** Titles with chapters, sound and subtitle tracks;
22
+ playlists (in order, at random or shuffled) and play all; menus whose
23
+ buttons start titles, chapters, playlists and other menus, switch sound and
24
+ subtitle tracks, or open a menu on a chosen button; loop counts, end-action
25
+ overrides, link transitions, remote keys a title turns off, and 720p. This
26
+ navigation is the one [oncemore](https://github.com/kajisho5/oncemore)
27
+ writes, byte for byte. 0.4 adds pop-up menus over a playing title. Menus
28
+ with motion or music come next.
26
29
 
27
30
  ## Why
28
31
 
@@ -58,6 +61,23 @@ disc.add_title("extras.m2ts", fps=(25, 1))
58
61
  disc.write("out") # out/BDMV/...
59
62
  ```
60
63
 
64
+ A clip can carry several sound tracks, each from the video itself or from
65
+ another file; track k is the title's audio track k (silence when a file has
66
+ no sound, and sound shorter than the picture is made up with silence):
67
+
68
+ ```python
69
+ from bdmvkit.encode import AudioTrack
70
+
71
+ encode_clip("feature.mov", "feature.m2ts", audio=[
72
+ AudioTrack(), # the video's own sound, AC-3
73
+ AudioTrack("commentary.wav", "lpcm"), # another file, LPCM
74
+ AudioTrack("feature.mov", "ac3", 6, stream=1), # its second sound stream, 5.1
75
+ ])
76
+ disc.add_title("feature.m2ts", audio=[("ac3", 2, "en"), ("lpcm", 2, "en"), ("ac3", 6, "ja")])
77
+ ```
78
+
79
+ `Disc.write` checks that every track a title declares is in its clip.
80
+
61
81
  A menu is a picture and its buttons. Each button is a picture of how it
62
82
  looks when selected (and, if given, when activated and before it is
63
83
  selected), placed on the 1920 x 1080 menu, and a link:
@@ -74,10 +94,44 @@ disc.add_menu("main", "menu.png", [
74
94
  disc.write("out")
75
95
  ```
76
96
 
77
- Links are `title:<n>`, `chapter:<n>:<c>` and `menu:<id>`; a title's `end`
78
- takes the same. The arrow keys go to the nearest button that way unless a
79
- button's `nav` says (`{"down": "scene2"}`). `bdmvkit.igs` makes the button
80
- stream on its own, for other tools.
97
+ A button's `action`, a title's `end` and the disc's first play are
98
+ actions: `title:<n>`, `chapter:<n>:<c>`, `playlist:<id>`, `playall`,
99
+ `menu:<id>` (or `menu:<id>:<button>`, that button selected), `lastmenu`,
100
+ `resume`, `audio:<n>`, `subtitle:<n>` or `subtitle:off`, and `stop`. The
101
+ arrow keys go to the nearest button that way unless a button's `nav` says
102
+ (`{"down": "scene2"}`). `bdmvkit.igs` makes the button stream on its own,
103
+ for other tools.
104
+
105
+ ```python
106
+ disc = Disc(title_key="menu:main")
107
+ a = disc.add_title("feature.m2ts", chapters=[0, 300], audio=[("ac3", 6, "en"), ("ac3", 6, "ja")],
108
+ subtitles=["en", "ja"], default_subtitle=1, end="lastmenu")
109
+ n = disc.add_title("notice.m2ts", prohibit=["next", "fast_forward", "menu"], end="title:1")
110
+ disc.add_playlist("both", [n, (a, 2, None)], order="shuffle", end="menu:main")
111
+ disc.add_menu("main", "menu.png", [
112
+ MenuButton("play", 160, 700, "play.png", action="title:2"), # the notice first
113
+ MenuButton("ja", 160, 800, "ja.png", action="title:1", audio=2, subtitle=0),
114
+ MenuButton("both", 160, 900, "both.png", action="playlist:both"),
115
+ ], loop=3, end="playall", seconds=10)
116
+ ```
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
+
131
+ Underneath, `bdmvkit.program` describes a disc made of encoded clips
132
+ (titles, playlists, menu clips that carry their buttons, transitions) and
133
+ `bdmvkit.nav.write_program` writes it; `nav.Navigation` gives the commands a
134
+ menu's buttons run, for tools that make their own IG streams.
81
135
 
82
136
  ```sh
83
137
  bdmvkit encode feature.mov feature.m2ts --chapters 0,300,600
@@ -98,12 +152,20 @@ bdmvkit build disc.json out
98
152
  How the disc is laid out:
99
153
 
100
154
  * Frame rates are 23.976 (1080p), 25 and 29.97. The last two are coded as
101
- progressive frames in a 1080i stream, as the format asks.
155
+ progressive frames in a 1080i stream, as the format asks. A program can
156
+ also be 720p (23.976, 50, 59.94; 25 and 29.97 go on at twice their rate);
157
+ `encode_clip` makes 1080 clips.
102
158
  * Sound is AC-3, LPCM or DTS.
103
159
  * Subtitles are PGS streams muxed into the clip. `pgs.write_sup` makes them
104
160
  from pictures, and `encode_clip(..., sups=[...])` muxes them in.
105
161
  * First play and the Top Menu key open the first menu, or start title 1
106
- when there is none.
162
+ when there is none (`Disc(first_play=..., title_key=...)` choose).
163
+ * Each title, playlist, play all and menu is a Blu-ray title, in that
164
+ order; random and shuffled playlists play a playlist file per item, picked
165
+ with the player's random number command. The general registers 1 to 8
166
+ hold the last menu, the button a link selects, a menu's loop count, an
167
+ end-action override, a stream choice, and a random playlist's state
168
+ (`nav.py`).
107
169
  * A menu is an interactive title after the titles: its picture held as a
108
170
  still, with its buttons as an Interactive Graphics stream in its clip.
109
171
  The menus are encoded when the disc is written (ffmpeg).
@@ -127,7 +189,12 @@ The tests encode real clips, write a disc, and read it back with
127
189
  * that running the disc from first play reaches the titles and chapters in
128
190
  order;
129
191
  * that a menu's buttons, chosen and pressed with the remote's keys, start
130
- the titles, chapters and menus they link to.
192
+ the titles, chapters, playlists and menus they link to, that a shuffled
193
+ playlist plays each item once, and that a menu with a loop count goes on
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;
197
+ * the commands every kind of action, title, playlist and menu turns into.
131
198
 
132
199
  The files' fixed bytes are checked as well.
133
200
 
@@ -1,7 +1,10 @@
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, played in order) and menus (`MenuButton`).
4
+ and subtitle tracks), playlists, menus and pop-up menus (`MenuButton`).
5
+ * `bdmvkit.program` and `bdmvkit.nav`: the model underneath (titles,
6
+ playlists, menu clips, transitions, actions) and its writer,
7
+ `nav.write_program`, with the navigation commands (`nav.Navigation`).
5
8
  * `bdmvkit.igs`: a menu's buttons as an Interactive Graphics stream.
6
9
  * `bdmvkit.encode.encode_clip`: a clip encoded for Blu-ray with ffmpeg + x264.
7
10
  * `bdmvkit.pgs`: PGS subtitle streams (.sup) from pictures.
@@ -12,5 +15,5 @@
12
15
  from .disc import Disc, MenuButton
13
16
  from .errors import BdmvError
14
17
 
15
- __version__ = "0.2.0"
18
+ __version__ = "0.4.0"
16
19
  __all__ = ["Disc", "MenuButton", "BdmvError"]
@@ -9,7 +9,12 @@ DISC.json: {"titles": [{"clip": "a.m2ts", "fps": "24000/1001",
9
9
  "buttons": [{"id": "play", "x": 160, "y": 700, "selected": "play.png",
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
- ({"up": id, ...}) and "auto"; a menu "default" (a button id) and "fps".
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", "prohibit" and "popup" ({"buttons":
15
+ [...], "default"}: its pop-up menu, buttons as a menu's). The disc may have
16
+ "playlists" ([{"id", "items": [n or [n, first, last]], "order", "end"}]),
17
+ "first_play" and "title_key".
13
18
  """
14
19
 
15
20
  import argparse
@@ -56,23 +61,35 @@ def main(argv=None):
56
61
  with open(args.disc, encoding="utf-8") as f:
57
62
  spec = json.load(f)
58
63
  base = os.path.dirname(os.path.abspath(args.disc))
59
- disc = Disc()
64
+ disc = Disc(spec.get("first_play"), spec.get("title_key"))
60
65
  for t in spec.get("titles", []):
61
66
  disc.add_title(os.path.join(base, t["clip"]), fps=_fps(t.get("fps", "24000/1001")),
62
67
  chapters=t.get("chapters", [0]),
63
68
  audio=[tuple(a) for a in t.get("audio", [["ac3", 2, "und"]])],
64
- subtitles=t.get("subtitles", []), end=t.get("end"))
69
+ subtitles=t.get("subtitles", []), end=t.get("end"),
70
+ default_audio=t.get("default_audio"),
71
+ default_subtitle=t.get("default_subtitle"),
72
+ prohibit=t.get("prohibit", []))
73
+ for p in spec.get("playlists", []):
74
+ disc.add_playlist(p["id"], p["items"], p.get("order", "sequential"), p.get("end"))
65
75
 
66
76
  def file(name):
67
77
  return os.path.join(base, name) if name else None
68
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"))
69
88
  for m in spec.get("menus", []):
70
- disc.add_menu(m["id"], file(m["background"]), [
71
- MenuButton(b["id"], int(b["x"]), int(b["y"]), file(b["selected"]),
72
- file(b.get("activated")), file(b.get("normal")), b.get("action"),
73
- dict(b.get("nav", {})), bool(b.get("auto")))
74
- for b in m["buttons"]], fps=_fps(m.get("fps", "24000/1001")),
75
- default=m.get("default"))
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)))
76
93
  res = disc.write(args.out, play_all=spec.get("play_all", True), fast=args.fast)
77
94
  except (BdmvError, OSError, KeyError, ValueError) as e:
78
95
  print(f"bdmvkit: {getattr(e, 'message', e)}", file=sys.stderr)
@@ -0,0 +1,281 @@
1
+ """A BDMV folder from encoded clips: titles with chapters, played from the
2
+ first one on (or each on its own), with their sound and subtitle tracks.
3
+
4
+ disc = Disc()
5
+ disc.add_title("feature.m2ts", chapters=[0, 300, 600], audio=[("ac3", 6, "en")],
6
+ subtitles=["en", "ja"])
7
+ disc.add_title("extras.m2ts")
8
+ disc.write("out") # out/BDMV/...
9
+
10
+ Each title is a playlist with a chapter mark at every chapter (on the key
11
+ frame nearest to it: encode the clip with key frames there, as
12
+ encode.encode_clip does) and a movie object that plays it. First play and
13
+ the Top Menu key start title 1; with `play_all`, each title goes on to the
14
+ next when it ends. Clips are BDAV streams with the PIDs formats.py names.
15
+
16
+ Menus: a picture with buttons, each linked to a title, a chapter or
17
+ another menu:
18
+
19
+ disc.add_menu("main", "menu.png", [
20
+ MenuButton("play", 160, 700, "play.png", action="title:1"),
21
+ MenuButton("scenes", 160, 800, "scenes.png", "scenes-lit.png", action="menu:scenes"),
22
+ ])
23
+ disc.add_title("feature.m2ts", end="menu:main") # back to the menu at the end
24
+
25
+ A button's pictures are its selected (and activated) look, drawn over the
26
+ menu where it is; with a `normal` picture it is drawn there before it is
27
+ selected too. The arrow keys go to the nearest button that way unless
28
+ `nav` says otherwise. Each menu is an interactive title after the titles:
29
+ its picture held as a still, its buttons an IG stream (igs.py) in its clip.
30
+ With menus, first play and the Top Menu key open the first menu.
31
+ """
32
+
33
+ import dataclasses
34
+ import os
35
+ import tempfile
36
+
37
+ from . import encode, hdmv, igs, program
38
+ from .errors import BdmvError
39
+ from .formats import AUDIO_PID, CODECS, FORMATS, HEIGHT, PG_PID, WIDTH, lang3
40
+ from .m2ts import stream_pids
41
+ from .program import parse_action
42
+ from .nav import DIRS, Navigation, _write, chapter_marks, write_program # noqa: F401
43
+
44
+
45
+ @dataclasses.dataclass
46
+ class Title:
47
+ clip: str # an encoded .m2ts
48
+ fps: tuple = (24000, 1001) # its frame rate: 24000/1001, 25/1 or 30000/1001
49
+ chapters: list = dataclasses.field(default_factory=lambda: [0.0]) # starts, seconds
50
+ audio: list = dataclasses.field(default_factory=lambda: [("ac3", 2, "und")])
51
+ subtitles: list = dataclasses.field(default_factory=list) # PG tracks: languages
52
+ end: str = None # the action after it (None: the next title with play_all)
53
+ default_audio: int = None # the sound track the disc starts with (1-based)
54
+ default_subtitle: int = None # the subtitle track shown when it starts (None: off)
55
+ prohibit: list = dataclasses.field(default_factory=list) # uops.KEYS names
56
+
57
+
58
+ @dataclasses.dataclass
59
+ class MenuButton:
60
+ id: str
61
+ x: int # top-left on the 1920 x 1080 menu
62
+ y: int
63
+ selected: str # picture files (PNG ...): selected,
64
+ activated: str = None # activated (default: the selected one),
65
+ normal: str = None # and before it is selected (default: nothing)
66
+ action: str = None # where it goes: an action (program.py), e.g. "title:2"
67
+ nav: dict = dataclasses.field(default_factory=dict) # direction -> button id
68
+ auto: bool = False # runs as soon as it is selected
69
+ audio: int = None # switch to this sound track (1-based) first
70
+ subtitle: int = None # ... and to this subtitle track (0: off)
71
+ override: str = None # the end action of the title it starts, instead of its own
72
+
73
+
74
+ @dataclasses.dataclass
75
+ class Menu:
76
+ id: str
77
+ background: str # picture file, fitted into the HD frame
78
+ buttons: list
79
+ fps: tuple = (24000, 1001)
80
+ default: str = None # the button selected first (default: the first)
81
+ loop: int = None # held this many times its length, then `end`
82
+ end: str = None
83
+ prohibit: list = dataclasses.field(default_factory=list)
84
+ seconds: float = 1.0 # its clip's length
85
+
86
+
87
+ def _check_streams(n, title):
88
+ """The title's sound and subtitle tracks must be in its clip, with the
89
+ PIDs the playlist gives them (encode.encode_clip makes them so)."""
90
+ pids = stream_pids(title.clip)
91
+ want = [("audio", AUDIO_PID, k) for k in range(len(title.audio))] + \
92
+ [("subtitle", PG_PID, k) for k in range(len(title.subtitles))]
93
+ for kind, first, k in want:
94
+ if first + k not in pids:
95
+ raise BdmvError(f"title {n}: {kind} track {k + 1} is not in {title.clip} "
96
+ f"(no stream with PID {first + k:#06x})")
97
+
98
+
99
+ class Disc:
100
+ """first_play: the action the disc starts with (default: the first menu,
101
+ or title 1); title_key: the Top Menu key's (default: the same)."""
102
+
103
+ def __init__(self, first_play=None, title_key=None):
104
+ self.titles = []
105
+ self.menus = []
106
+ self.playlists = []
107
+ self.popups = {} # title number -> (buttons, default)
108
+ self.first_play = first_play
109
+ self.title_key = title_key
110
+
111
+ def add_title(self, clip, fps=(24000, 1001), chapters=(0.0,), audio=(("ac3", 2, "und"),),
112
+ subtitles=(), end=None, default_audio=None, default_subtitle=None,
113
+ prohibit=()):
114
+ """Add a title; returns its number (1-based), its id in actions
115
+ ("title:2"). audio: (codec, channels, language) per track, codec
116
+ "ac3", "lpcm" or "dts"; languages are ISO 639-1 ("en") or 639-2
117
+ ("eng") codes. end: the action when it ends (default: the next title
118
+ with play_all). default_audio: the sound track the disc starts with;
119
+ default_subtitle: the subtitle track shown from its start (else
120
+ none); prohibit: remote keys it does not allow (uops.KEYS)."""
121
+ if tuple(fps) not in FORMATS:
122
+ raise BdmvError(f"{fps[0]}/{fps[1]} fps has no Blu-ray format")
123
+ for codec, _, _ in audio:
124
+ if codec not in CODECS:
125
+ raise BdmvError(f"unknown audio codec {codec!r} (ac3, lpcm or dts)")
126
+ if not audio:
127
+ raise BdmvError("a title needs at least one audio track")
128
+ for name, k, n in (("default_audio", default_audio, len(audio)),
129
+ ("default_subtitle", default_subtitle, len(subtitles))):
130
+ if k is not None and not 1 <= k <= n:
131
+ raise BdmvError(f"{name} {k}: the title has {n} such tracks")
132
+ self.titles.append(Title(clip, tuple(fps), list(chapters) or [0.0], list(audio),
133
+ list(subtitles), end, default_audio, default_subtitle,
134
+ list(prohibit)))
135
+ return len(self.titles)
136
+
137
+ def add_playlist(self, playlist_id, items, order="sequential", end=None):
138
+ """Add a playlist: items (title, first chapter, last chapter), or
139
+ title numbers for whole titles, played one after another
140
+ ("sequential"), picked at random as many times as there are items
141
+ ("random"), or each once in a random order ("shuffle"). It is
142
+ started by "playlist:<id>"."""
143
+ if any(p.id == playlist_id for p in self.playlists):
144
+ raise BdmvError(f"there is a playlist {playlist_id!r} already")
145
+ out = []
146
+ for it in items:
147
+ n, first, last = (it, 1, None) if isinstance(it, int) else tuple(it) + (None,) * \
148
+ (3 - len(it))
149
+ out.append(program.PlaylistItem(str(n), first or 1, last))
150
+ self.playlists.append(program.Playlist(playlist_id, out, order, end))
151
+
152
+ def add_menu(self, menu_id, background, buttons, fps=(24000, 1001), default=None, loop=None,
153
+ end=None, prohibit=(), seconds=1.0):
154
+ """Add a menu: `background` a picture, `buttons` MenuButtons. The
155
+ menu's clip (`seconds` long) is made when the disc is written
156
+ (ffmpeg). It holds its last picture; with `loop`, only for `loop`
157
+ times its length, then it does `end`."""
158
+ if tuple(fps) not in FORMATS:
159
+ raise BdmvError(f"{fps[0]}/{fps[1]} fps has no Blu-ray format")
160
+ if any(m.id == menu_id for m in self.menus):
161
+ raise BdmvError(f"there is a menu {menu_id!r} already")
162
+ if not buttons:
163
+ raise BdmvError(f"menu {menu_id!r} needs at least one button")
164
+ self.menus.append(Menu(menu_id, background, list(buttons), tuple(fps), default, loop,
165
+ end, list(prohibit), seconds))
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
+
180
+ def program(self, play_all=True):
181
+ """The disc as a program.Program (titles "1", "2" ...; menus with no
182
+ clip yet: write makes them)."""
183
+ n_titles = len(self.titles)
184
+ titles = []
185
+ for n, t in enumerate(self.titles, 1):
186
+ end = t.end or (f"title:{n + 1}" if play_all and n < n_titles else None)
187
+ titles.append(program.Title(
188
+ str(n), t.clip, t.fps, list(t.chapters),
189
+ [program.Audio(CODECS[c], ch, _lang(lang), k == t.default_audio)
190
+ for k, (c, ch, lang) in enumerate(t.audio, 1)],
191
+ [program.Subtitle(_lang(lang), k == t.default_subtitle)
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]]))
195
+ menus = [program.Menu(m.id, None,
196
+ [_button(b) for b in m.buttons],
197
+ fps=m.fps, default=m.default, loop=m.loop, end=m.end,
198
+ still_time=max(1, round(m.seconds)) if m.loop is not None
199
+ else None,
200
+ prohibit=list(m.prohibit)) for m in self.menus]
201
+ first_menu = f"menu:{self.menus[0].id}" if self.menus else None
202
+ return program.Program(titles, list(self.playlists), menus,
203
+ first_play=self.first_play or first_menu,
204
+ title_key=self.title_key)
205
+
206
+ def write(self, out_dir, move=False, play_all=True, fast=False):
207
+ """Write the BDMV folder into `out_dir` (copying the clips into
208
+ BDMV/STREAM, or moving them with `move`; menu clips are encoded,
209
+ quickly with `fast`). Returns {"titles", "menus", "playlists",
210
+ "chapters"}."""
211
+ if not self.titles:
212
+ raise BdmvError("a disc needs at least one title")
213
+ prog = self.program(play_all)
214
+ prog.check() # (the menus' clips are made below)
215
+ for n, t in enumerate(self.titles, 1):
216
+ _check_streams(n, t)
217
+ nav = Navigation(prog)
218
+ os.makedirs(os.path.join(out_dir, "BDMV", "STREAM"), exist_ok=True)
219
+ for k, (m, pm) in enumerate(zip(self.menus, prog.menus), len(self.titles) + 1):
220
+ pm.clip = os.path.join(out_dir, "BDMV", "STREAM", f"{k:05d}.m2ts")
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)
226
+ out = write_program(prog, out_dir, move=move)
227
+ return {"titles": len(self.titles), "menus": len(self.menus),
228
+ "playlists": out["playlists"], "chapters": out["chapters"]}
229
+
230
+ def _encode_menu(self, m, pm, nav, fast):
231
+ """Menu `m`'s clip (pm.clip): its buttons' pictures read, the IG
232
+ stream made with their commands and encoded with its picture."""
233
+ buttons = _ig_buttons(f"menu {m.id!r}", m.buttons, pm.buttons, nav)
234
+ _, frame_rate = FORMATS[m.fps]
235
+ with tempfile.TemporaryDirectory(prefix="bdmvkit-") as work:
236
+ sup = os.path.join(work, "menu.sup")
237
+ with open(sup, "wb") as f:
238
+ f.write(igs.ig_stream(buttons, frame_rate, default=m.default,
239
+ follow_selection=nav.follows_selection(m.id)))
240
+ encode.encode_menu(m.background, sup, pm.clip, m.fps, seconds=m.seconds, fast=fast)
241
+
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
+
279
+ def _lang(code):
280
+ code = (code or "").lower()
281
+ return code if len(code) == 3 and code.isalpha() else lang3(code)