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