bdmvkit 0.2.0__py3-none-any.whl
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.
- bdmvkit/__init__.py +16 -0
- bdmvkit/__main__.py +86 -0
- bdmvkit/clip.py +70 -0
- bdmvkit/disc.py +257 -0
- bdmvkit/encode.py +152 -0
- bdmvkit/errors.py +9 -0
- bdmvkit/formats.py +45 -0
- bdmvkit/hdmv.py +149 -0
- bdmvkit/igs.py +241 -0
- bdmvkit/m2ts.py +78 -0
- bdmvkit/pgs.py +260 -0
- bdmvkit/playlist.py +62 -0
- bdmvkit-0.2.0.dist-info/METADATA +165 -0
- bdmvkit-0.2.0.dist-info/RECORD +19 -0
- bdmvkit-0.2.0.dist-info/WHEEL +5 -0
- bdmvkit-0.2.0.dist-info/entry_points.txt +2 -0
- bdmvkit-0.2.0.dist-info/licenses/COPYING +674 -0
- bdmvkit-0.2.0.dist-info/licenses/COPYING.LESSER +165 -0
- bdmvkit-0.2.0.dist-info/top_level.txt +1 -0
bdmvkit/pgs.py
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
"""Blu-ray subtitles: cues -> a PGS (Presentation Graphics) stream (.sup).
|
|
2
|
+
|
|
3
|
+
A text cue is a picture of white text with a black outline on a
|
|
4
|
+
transparent HD frame (RGBA, drawn by the caller); indexed() keeps 64
|
|
5
|
+
colours of it: 8 steps from the outline to the fill colour, each at 8
|
|
6
|
+
levels of opacity, so edges stay smooth, and palette() gives them in the
|
|
7
|
+
cue's own fill and outline colours. A picture cue keeps its own colours
|
|
8
|
+
(picture_palette). Palettes are BT.709 YCbCr, as Blu-ray HD pictures are.
|
|
9
|
+
|
|
10
|
+
A cue becomes a display set at its start (presentation composition, window,
|
|
11
|
+
palette and the run-length coded object) and an empty composition at its
|
|
12
|
+
end, which clears it. The stream begins with an empty composition at time 0
|
|
13
|
+
so that ffmpeg, which starts every input at 0, keeps the cue times.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import re
|
|
17
|
+
import struct
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
PCS, WDS, PDS, ODS, END = 0x16, 0x17, 0x14, 0x15, 0x80
|
|
21
|
+
EPOCH_START, NORMAL = 0x80, 0x00
|
|
22
|
+
STEPS = 8 # fill/outline mix steps and opacity levels
|
|
23
|
+
MAX_SEGMENT = 65535
|
|
24
|
+
MIN_GAP = 0.2 # seconds between two cues the decoder needs (about 5 frames)
|
|
25
|
+
|
|
26
|
+
# per byte value: the step (0-7) of a lightness or an opacity
|
|
27
|
+
_STEP = bytes(min(STEPS - 1, v * STEPS // 256) for v in range(256))
|
|
28
|
+
_VISIBLE = bytes(1 if a >= 16 else 0 for a in range(256))
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _rgb(value):
|
|
32
|
+
"""'#RRGGBB' -> (r, g, b)."""
|
|
33
|
+
s = value.lstrip("#")
|
|
34
|
+
return int(s[0:2], 16), int(s[2:4], 16), int(s[4:6], 16)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def ycbcr(rgb):
|
|
38
|
+
"""BT.709, studio range (what HD Blu-ray players expect)."""
|
|
39
|
+
r, g, b = (c / 255 for c in rgb)
|
|
40
|
+
y = 0.2126 * r + 0.7152 * g + 0.0722 * b
|
|
41
|
+
cb, cr = (b - y) / 1.8556, (r - y) / 1.5748
|
|
42
|
+
return (round(16 + 219 * y), round(128 + 224 * cb), round(128 + 224 * cr))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def palette(fill, outline):
|
|
46
|
+
"""Entries (index, Y, Cr, Cb, alpha): 0 transparent, then 1 + 8 * mix +
|
|
47
|
+
opacity, mix 0 = outline .. 7 = fill."""
|
|
48
|
+
f, o = _rgb(fill), _rgb(outline)
|
|
49
|
+
out = [(0, 16, 128, 128, 0)]
|
|
50
|
+
for m in range(STEPS):
|
|
51
|
+
t = m / (STEPS - 1)
|
|
52
|
+
rgb = tuple(round(oc + (fc - oc) * t) for fc, oc in zip(f, o))
|
|
53
|
+
y, cb, cr = ycbcr(rgb)
|
|
54
|
+
for a in range(STEPS):
|
|
55
|
+
alpha = round((a + 1) * 255 / STEPS)
|
|
56
|
+
out.append((1 + STEPS * m + a, y, cr, cb, alpha))
|
|
57
|
+
return out
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def picture_palette(colours):
|
|
61
|
+
"""Entries for a picture's own colours [(r, g, b, a)]: index k for the
|
|
62
|
+
k-th (0 transparent)."""
|
|
63
|
+
return [(0, 16, 128, 128, 0)] + [(k, *_ycrcb(c[:3]), c[3])
|
|
64
|
+
for k, c in enumerate(colours, 1)]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _ycrcb(rgb):
|
|
68
|
+
y, cb, cr = ycbcr(rgb)
|
|
69
|
+
return y, cr, cb
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def indexed(rgba, W, H):
|
|
73
|
+
"""RGBA (white text, black outline) -> (index bytes, w, h, x, y) cropped
|
|
74
|
+
to what is visible, or None for an empty picture. Runs at C speed: the
|
|
75
|
+
two steps are combined as big integers (no byte overflows into the next)."""
|
|
76
|
+
alpha = rgba[3::4]
|
|
77
|
+
vis = alpha.translate(_VISIBLE)
|
|
78
|
+
first, last = vis.find(1), vis.rfind(1)
|
|
79
|
+
if first < 0:
|
|
80
|
+
return None
|
|
81
|
+
y0, y1 = first // W, last // W
|
|
82
|
+
x0, x1 = W, -1
|
|
83
|
+
for yy in range(y0, y1 + 1):
|
|
84
|
+
row = vis[yy * W:(yy + 1) * W]
|
|
85
|
+
a, b = row.find(1), row.rfind(1)
|
|
86
|
+
if a >= 0:
|
|
87
|
+
x0, x1 = min(x0, a), max(x1, b)
|
|
88
|
+
w, h = x1 - x0 + 1, y1 - y0 + 1
|
|
89
|
+
rows_l, rows_a, rows_v = [], [], []
|
|
90
|
+
red = rgba[0::4]
|
|
91
|
+
for yy in range(y0, y1 + 1):
|
|
92
|
+
s = slice(yy * W + x0, yy * W + x1 + 1)
|
|
93
|
+
v = vis[s]
|
|
94
|
+
rows_v.append(v)
|
|
95
|
+
# invisible pixels get step 0 in both, and no +1 below
|
|
96
|
+
rows_l.append(_mask(red[s].translate(_STEP), v))
|
|
97
|
+
rows_a.append(_mask(alpha[s].translate(_STEP), v))
|
|
98
|
+
n = w * h
|
|
99
|
+
lum = int.from_bytes(b"".join(rows_l), "big")
|
|
100
|
+
opa = int.from_bytes(b"".join(rows_a), "big")
|
|
101
|
+
one = int.from_bytes(b"".join(rows_v), "big")
|
|
102
|
+
idx = (lum * STEPS + opa + one).to_bytes(n, "big")
|
|
103
|
+
return idx, w, h, x0, y0
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _mask(steps, visible):
|
|
107
|
+
"""Steps where visible, 0 elsewhere (multiplying by the 0/1 mask)."""
|
|
108
|
+
n = len(steps)
|
|
109
|
+
if not n:
|
|
110
|
+
return steps
|
|
111
|
+
s = int.from_bytes(steps, "big")
|
|
112
|
+
v = int.from_bytes(visible, "big")
|
|
113
|
+
# visible bytes are 0 or 1; spread each to 0x00 / 0xFF and AND
|
|
114
|
+
return (s & (v * 0xFF)).to_bytes(n, "big")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
_RUN = re.compile(rb"(.)\1*", re.S)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def rle(idx, w, h):
|
|
121
|
+
"""PGS run-length coding, line by line (colour 0 = transparent)."""
|
|
122
|
+
out = bytearray()
|
|
123
|
+
for y in range(h):
|
|
124
|
+
row = idx[y * w:(y + 1) * w]
|
|
125
|
+
for m in _RUN.finditer(row):
|
|
126
|
+
c, n = m.group()[0], m.end() - m.start()
|
|
127
|
+
while n:
|
|
128
|
+
k = min(n, 16383)
|
|
129
|
+
if c == 0:
|
|
130
|
+
out += bytes([0, k]) if k < 64 else bytes([0, 0x40 | k >> 8, k & 0xFF])
|
|
131
|
+
elif k < 3:
|
|
132
|
+
out += bytes([c]) * k
|
|
133
|
+
elif k < 64:
|
|
134
|
+
out += bytes([0, 0x80 | k, c])
|
|
135
|
+
else:
|
|
136
|
+
out += bytes([0, 0xC0 | k >> 8, k & 0xFF, c])
|
|
137
|
+
n -= k
|
|
138
|
+
out += b"\0\0"
|
|
139
|
+
return bytes(out)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _segment(kind, pts, data):
|
|
143
|
+
return b"PG" + struct.pack(">IIBH", pts & 0xFFFFFFFF, 0, kind, len(data)) + data
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _pcs(size, number, state, objects):
|
|
147
|
+
W, H = size
|
|
148
|
+
data = struct.pack(">HHBHBBBB", W, H, 0x10, number & 0xFFFF, state, 0, 0, len(objects))
|
|
149
|
+
for x, y in objects:
|
|
150
|
+
data += struct.pack(">HBBHH", 0, 0, 0, x, y)
|
|
151
|
+
return data
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def display_set(pts, size, number, pic, entries):
|
|
155
|
+
"""Show `pic` (index bytes, w, h, x, y) from pts (90 kHz)."""
|
|
156
|
+
idx, w, h, x, y = pic
|
|
157
|
+
out = _segment(PCS, pts, _pcs(size, number, EPOCH_START, [(x, y)]))
|
|
158
|
+
out += _segment(WDS, pts, struct.pack(">BBHHHH", 1, 0, x, y, w, h))
|
|
159
|
+
out += _segment(PDS, pts, bytes([0, 0]) + b"".join(bytes(e) for e in entries))
|
|
160
|
+
coded = rle(idx, w, h)
|
|
161
|
+
data = (len(coded) + 4).to_bytes(3, "big") + struct.pack(">HH", w, h) + coded
|
|
162
|
+
first = MAX_SEGMENT - 4
|
|
163
|
+
chunks = [data[:first]] + [data[i:i + MAX_SEGMENT - 4]
|
|
164
|
+
for i in range(first, len(data), MAX_SEGMENT - 4)]
|
|
165
|
+
for k, chunk in enumerate(chunks):
|
|
166
|
+
flags = (0x80 if k == 0 else 0) | (0x40 if k == len(chunks) - 1 else 0)
|
|
167
|
+
out += _segment(ODS, pts, struct.pack(">HBB", 0, 0, flags) + chunk)
|
|
168
|
+
return out + _segment(END, pts, b"")
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def clear_set(pts, size, number, window):
|
|
172
|
+
out = _segment(PCS, pts, _pcs(size, number, NORMAL if window else EPOCH_START, []))
|
|
173
|
+
if window:
|
|
174
|
+
x, y, w, h = window
|
|
175
|
+
out += _segment(WDS, pts, struct.pack(">BBHHHH", 1, 0, x, y, w, h))
|
|
176
|
+
else:
|
|
177
|
+
out += _segment(WDS, pts, b"\0") # no windows
|
|
178
|
+
return out + _segment(END, pts, b"")
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def timeline(cues):
|
|
182
|
+
"""(start, end) per cue, each ending MIN_GAP before the next one starts so
|
|
183
|
+
the player has time to decode it; cues left with no time are dropped."""
|
|
184
|
+
out = []
|
|
185
|
+
for i, c in enumerate(cues):
|
|
186
|
+
end = c.end
|
|
187
|
+
if i + 1 < len(cues):
|
|
188
|
+
end = min(end, round(cues[i + 1].start - MIN_GAP, 6))
|
|
189
|
+
out.append((c.start, end) if end > c.start else None)
|
|
190
|
+
return out
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def write_sup(path, pictures, size):
|
|
194
|
+
"""pictures: [(start s, end s, pic or None, palette entries)]."""
|
|
195
|
+
data = clear_set(0, size, 0, None)
|
|
196
|
+
n = 1
|
|
197
|
+
for start, end, pic, entries in pictures:
|
|
198
|
+
if pic is None:
|
|
199
|
+
continue
|
|
200
|
+
a, b = round(start * 90000), round(end * 90000)
|
|
201
|
+
data += display_set(a, size, n, pic, entries)
|
|
202
|
+
data += clear_set(b, size, n + 1, (pic[3], pic[4], pic[1], pic[2]))
|
|
203
|
+
n += 2
|
|
204
|
+
with open(path, "wb") as f:
|
|
205
|
+
f.write(data)
|
|
206
|
+
return path
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def decode(data):
|
|
210
|
+
"""Read a .sup back: [(pts, [(x, y, w, h, index bytes)], palette)] per
|
|
211
|
+
composition. For tests and checks."""
|
|
212
|
+
out, pos, pal, objs, wins, comp, buf = [], 0, {}, [], {}, None, b""
|
|
213
|
+
while pos < len(data):
|
|
214
|
+
assert data[pos:pos + 2] == b"PG", "segment magic"
|
|
215
|
+
pts, _, kind, size = struct.unpack(">IIBH", data[pos + 2:pos + 13])
|
|
216
|
+
body = data[pos + 13:pos + 13 + size]
|
|
217
|
+
pos += 13 + size
|
|
218
|
+
if kind == PCS:
|
|
219
|
+
n = body[10]
|
|
220
|
+
comp = (pts, [struct.unpack(">HBBHH", body[11 + 8 * i:19 + 8 * i])[3:]
|
|
221
|
+
for i in range(n)])
|
|
222
|
+
objs = []
|
|
223
|
+
elif kind == PDS:
|
|
224
|
+
pal = {body[i]: tuple(body[i + 1:i + 5]) for i in range(2, len(body), 5)}
|
|
225
|
+
elif kind == ODS:
|
|
226
|
+
flags = body[3]
|
|
227
|
+
buf = (body[4:] if flags & 0x80 else buf + body[4:])
|
|
228
|
+
if flags & 0x40:
|
|
229
|
+
w, h = struct.unpack(">HH", buf[3:7])
|
|
230
|
+
objs.append((w, h, unrle(buf[7:], w, h)))
|
|
231
|
+
elif kind == END:
|
|
232
|
+
pics = [(x, y, w, h, px) for (x, y), (w, h, px) in zip(comp[1], objs)]
|
|
233
|
+
out.append((comp[0], pics, dict(pal)))
|
|
234
|
+
return out
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def unrle(data, w, h):
|
|
238
|
+
out, i, row = bytearray(), 0, 0
|
|
239
|
+
while i < len(data) and row < h:
|
|
240
|
+
b = data[i]
|
|
241
|
+
i += 1
|
|
242
|
+
if b:
|
|
243
|
+
out.append(b)
|
|
244
|
+
continue
|
|
245
|
+
f = data[i]
|
|
246
|
+
i += 1
|
|
247
|
+
if f == 0:
|
|
248
|
+
row += 1
|
|
249
|
+
continue
|
|
250
|
+
n = f & 0x3F
|
|
251
|
+
if f & 0x40:
|
|
252
|
+
n = n << 8 | data[i]
|
|
253
|
+
i += 1
|
|
254
|
+
c = 0
|
|
255
|
+
if f & 0x80:
|
|
256
|
+
c = data[i]
|
|
257
|
+
i += 1
|
|
258
|
+
out += bytes([c]) * n
|
|
259
|
+
assert len(out) == w * h, (len(out), w * h)
|
|
260
|
+
return bytes(out)
|
bdmvkit/playlist.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Playlists (.mpls): play items with their streams, and chapter marks."""
|
|
2
|
+
|
|
3
|
+
from .formats import (AUDIO_PID, CODING_H264, CODING_IG, CODING_PG, IG_PID, PG_PID, VERSION,
|
|
4
|
+
VIDEO_PID)
|
|
5
|
+
from .hdmv import _u8, _u16, _u32
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _video_attr(video_format, frame_rate):
|
|
9
|
+
return bytes([CODING_H264, video_format << 4 | frame_rate, 0, 0, 0])
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _audio_attr(track):
|
|
13
|
+
coding, channels, lang = track
|
|
14
|
+
fmt = 6 if channels > 2 else 3
|
|
15
|
+
return bytes([coding, fmt << 4 | 1]) + lang.encode("ascii")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _pg_attr(lang, coding=CODING_PG):
|
|
19
|
+
return bytes([coding]) + lang.encode("ascii") + b"\0"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def mpls(items, marks, uo=0):
|
|
23
|
+
"""A playlist. items: [(clip name, in, out, video_format, frame_rate,
|
|
24
|
+
audio, pg, ig, still[, uo])] played one after the other, times in 45 kHz
|
|
25
|
+
on each clip's time line, audio [(coding, channels, lang3)], pg [lang3]
|
|
26
|
+
(subtitles), ig: the clip carries menu buttons, still: 2 holds the
|
|
27
|
+
last picture (a still menu), (1, s) holds it for s seconds, uo: the
|
|
28
|
+
item's UO mask; marks: chapter marks as (item index, time); uo: the
|
|
29
|
+
whole playlist's UO mask (the remote keys it does not allow)."""
|
|
30
|
+
app = _u32(14) + bytes([0, 1]) + _u16(0) + uo.to_bytes(8, "big") + _u16(0)
|
|
31
|
+
body = b""
|
|
32
|
+
for it in items:
|
|
33
|
+
clip, in_time, out_time, video_format, frame_rate, audio, pg, ig, still = it[:9]
|
|
34
|
+
item_uo = it[9] if len(it) > 9 else 0
|
|
35
|
+
stn = _u16(0) + bytes([1, len(audio), len(pg), int(ig), 0, 0, 0, 0]) + bytes(4)
|
|
36
|
+
entry = _u8(1) + _u16(VIDEO_PID) + bytes(6)
|
|
37
|
+
stn += _u8(len(entry)) + entry + _u8(5) + _video_attr(video_format, frame_rate)
|
|
38
|
+
for k, tr in enumerate(audio):
|
|
39
|
+
entry = _u8(1) + _u16(AUDIO_PID + k) + bytes(6)
|
|
40
|
+
stn += _u8(len(entry)) + entry + _u8(5) + _audio_attr(tr)
|
|
41
|
+
for k, lang in enumerate(pg):
|
|
42
|
+
entry = _u8(1) + _u16(PG_PID + k) + bytes(6)
|
|
43
|
+
stn += _u8(len(entry)) + entry + _u8(5) + _pg_attr(lang)
|
|
44
|
+
if ig:
|
|
45
|
+
entry = _u8(1) + _u16(IG_PID) + bytes(6)
|
|
46
|
+
stn += _u8(len(entry)) + entry + _u8(5) + _pg_attr("und", CODING_IG)
|
|
47
|
+
still_mode, still_time = still if isinstance(still, tuple) else (still, 0)
|
|
48
|
+
# connection_condition 1: the clips are not seamless; STC id 0
|
|
49
|
+
item = clip.encode("ascii") + b"M2TS" + _u16(1) + _u8(0) + _u32(in_time) + \
|
|
50
|
+
_u32(out_time) + item_uo.to_bytes(8, "big") + _u8(0) + _u8(still_mode) + _u16(still_time) + \
|
|
51
|
+
_u16(len(stn)) + stn
|
|
52
|
+
body += _u16(len(item)) + item
|
|
53
|
+
playlist = _u16(0) + _u16(len(items)) + _u16(0) + body
|
|
54
|
+
playlist = _u32(len(playlist)) + playlist
|
|
55
|
+
mark = _u16(len(marks))
|
|
56
|
+
for ref, t in marks:
|
|
57
|
+
mark += _u8(0) + _u8(1) + _u16(ref) + _u32(t) + _u16(0xFFFF) + _u32(0)
|
|
58
|
+
mark = _u32(len(mark)) + mark
|
|
59
|
+
list_pos = 40 + len(app)
|
|
60
|
+
mark_pos = list_pos + len(playlist)
|
|
61
|
+
return b"MPLS" + VERSION + _u32(list_pos) + _u32(mark_pos) + _u32(0) + bytes(20) + app + \
|
|
62
|
+
playlist + mark
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: bdmvkit
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Blu-ray (BDMV) authoring in pure Python: playlists, clip information, HDMV navigation, PGS subtitles
|
|
5
|
+
Author: kajisho5
|
|
6
|
+
License-Expression: LGPL-3.0-or-later
|
|
7
|
+
Project-URL: Homepage, https://github.com/kajisho5/bdmvkit
|
|
8
|
+
Keywords: blu-ray,bdmv,mpls,clpi,hdmv,pgs,authoring
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Operating System :: OS Independent
|
|
11
|
+
Classifier: Topic :: Multimedia :: Video
|
|
12
|
+
Requires-Python: >=3.11
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
License-File: COPYING
|
|
15
|
+
License-File: COPYING.LESSER
|
|
16
|
+
Dynamic: license-file
|
|
17
|
+
|
|
18
|
+
<p><img src="docs/images/logo/lockup-dark.svg" alt="bdmvkit" height="56"></p>
|
|
19
|
+
|
|
20
|
+
# bdmvkit
|
|
21
|
+
|
|
22
|
+
**Blu-ray (BDMV) authoring in pure Python.** bdmvkit writes the files a
|
|
23
|
+
Blu-ray player navigates by:
|
|
24
|
+
|
|
25
|
+
* playlists with chapter marks (`.mpls`);
|
|
26
|
+
* clip information with the entry-point map players seek with (`.clpi`);
|
|
27
|
+
* `index.bdmv` and `MovieObject.bdmv` with HDMV navigation commands;
|
|
28
|
+
* menus: Interactive Graphics streams with buttons, the arrow keys between
|
|
29
|
+
them and the commands they run;
|
|
30
|
+
* PGS subtitle streams (`.sup`).
|
|
31
|
+
|
|
32
|
+
It also encodes the clips with ffmpeg and x264, using the Blu-ray settings.
|
|
33
|
+
There are no dependencies beyond the standard library, and ffmpeg is needed
|
|
34
|
+
only to encode.
|
|
35
|
+
|
|
36
|
+
日本語の説明は [README.ja.md](README.ja.md) にあります。
|
|
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.
|
|
43
|
+
|
|
44
|
+
## Why
|
|
45
|
+
|
|
46
|
+
Open-source Blu-ray tools mux streams into a BDMV folder (tsMuxer, for one),
|
|
47
|
+
but they stop short of the navigation layer: menus, movie objects, playlists
|
|
48
|
+
and commands. bdmvkit writes that layer itself, and is tested by playing
|
|
49
|
+
every disc back with libbluray.
|
|
50
|
+
|
|
51
|
+
## Install
|
|
52
|
+
|
|
53
|
+
```sh
|
|
54
|
+
pip install bdmvkit
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
A release is made by the version: when the version in `pyproject.toml`
|
|
58
|
+
(and `bdmvkit/__init__.py`) reaches `main` without a tag of its own,
|
|
59
|
+
`.github/workflows/release.yml` runs the tests, builds the package, makes
|
|
60
|
+
the GitHub release with its tag and publishes to PyPI (trusted publishing,
|
|
61
|
+
environment `pypi`). Merging the version bump is all it takes.
|
|
62
|
+
|
|
63
|
+
## Use
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
from bdmvkit import Disc
|
|
67
|
+
from bdmvkit.encode import encode_clip
|
|
68
|
+
|
|
69
|
+
encode_clip("feature.mov", "feature.m2ts", chapters=[0, 300, 600]) # key frames at the chapters
|
|
70
|
+
encode_clip("extras.mp4", "extras.m2ts", fps=(25, 1))
|
|
71
|
+
|
|
72
|
+
disc = Disc()
|
|
73
|
+
disc.add_title("feature.m2ts", chapters=[0, 300, 600], audio=[("ac3", 2, "en")])
|
|
74
|
+
disc.add_title("extras.m2ts", fps=(25, 1))
|
|
75
|
+
disc.write("out") # out/BDMV/...
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
A menu is a picture and its buttons. Each button is a picture of how it
|
|
79
|
+
looks when selected (and, if given, when activated and before it is
|
|
80
|
+
selected), placed on the 1920 x 1080 menu, and a link:
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
from bdmvkit import Disc, MenuButton
|
|
84
|
+
|
|
85
|
+
disc = Disc()
|
|
86
|
+
disc.add_title("feature.m2ts", chapters=[0, 300, 600], end="menu:main")
|
|
87
|
+
disc.add_menu("main", "menu.png", [
|
|
88
|
+
MenuButton("play", 160, 700, "play-lit.png", "play-on.png", action="title:1"),
|
|
89
|
+
MenuButton("scene2", 160, 800, "scene2-lit.png", action="chapter:1:2"),
|
|
90
|
+
])
|
|
91
|
+
disc.write("out")
|
|
92
|
+
```
|
|
93
|
+
|
|
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.
|
|
98
|
+
|
|
99
|
+
```sh
|
|
100
|
+
bdmvkit encode feature.mov feature.m2ts --chapters 0,300,600
|
|
101
|
+
bdmvkit build disc.json out
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
`disc.json` looks like this:
|
|
105
|
+
|
|
106
|
+
```json
|
|
107
|
+
{"titles": [{"clip": "feature.m2ts", "fps": "24000/1001", "chapters": [0, 300, 600],
|
|
108
|
+
"audio": [["ac3", 2, "en"]], "subtitles": ["ja"], "end": "menu:main"}],
|
|
109
|
+
"menus": [{"id": "main", "background": "menu.png",
|
|
110
|
+
"buttons": [{"id": "play", "x": 160, "y": 700, "selected": "play-lit.png",
|
|
111
|
+
"action": "title:1"}]}],
|
|
112
|
+
"play_all": true}
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
How the disc is laid out:
|
|
116
|
+
|
|
117
|
+
* 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.
|
|
119
|
+
* Sound is AC-3, LPCM or DTS.
|
|
120
|
+
* Subtitles are PGS streams muxed into the clip. `pgs.write_sup` makes them
|
|
121
|
+
from pictures, and `encode_clip(..., sups=[...])` muxes them in.
|
|
122
|
+
* First play and the Top Menu key open the first menu, or start title 1
|
|
123
|
+
when there is none.
|
|
124
|
+
* A menu is an interactive title after the titles: its picture held as a
|
|
125
|
+
still, with its buttons as an Interactive Graphics stream in its clip.
|
|
126
|
+
The menus are encoded when the disc is written (ffmpeg).
|
|
127
|
+
|
|
128
|
+
To make a disc image of the folder, use
|
|
129
|
+
[udfkit](https://github.com/kajisho5/udfkit) (UDF 2.50):
|
|
130
|
+
`udfkit bluray out disc.iso --label MY_DISC`.
|
|
131
|
+
|
|
132
|
+
## Tests
|
|
133
|
+
|
|
134
|
+
```sh
|
|
135
|
+
python -m unittest discover -s tests -t .
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
The tests encode real clips, write a disc, and read it back with
|
|
139
|
+
**libbluray**, the library VLC, mpv and Kodi play Blu-ray with. They check:
|
|
140
|
+
|
|
141
|
+
* the titles and their lengths;
|
|
142
|
+
* the chapter positions;
|
|
143
|
+
* the video, audio and subtitle streams and their languages;
|
|
144
|
+
* that running the disc from first play reaches the titles and chapters in
|
|
145
|
+
order;
|
|
146
|
+
* that a menu's buttons, chosen and pressed with the remote's keys, start
|
|
147
|
+
the titles, chapters and menus they link to.
|
|
148
|
+
|
|
149
|
+
The files' fixed bytes are checked as well.
|
|
150
|
+
|
|
151
|
+
## Support
|
|
152
|
+
|
|
153
|
+
bdmvkit is free and stays free. If it saves you time, you can support its
|
|
154
|
+
development through [GitHub Sponsors](https://github.com/sponsors/kajisho5)
|
|
155
|
+
(the **Sponsor** button at the top of this repository). Bug reports help just
|
|
156
|
+
as much: [Issues](https://github.com/kajisho5/bdmvkit/issues).
|
|
157
|
+
|
|
158
|
+
The logo, colours and writing rules: [docs/DESIGN.md](docs/DESIGN.md).
|
|
159
|
+
|
|
160
|
+
## License
|
|
161
|
+
|
|
162
|
+
GNU Lesser General Public License, version 3 or later. See
|
|
163
|
+
[COPYING.LESSER](COPYING.LESSER) and [COPYING](COPYING). You may use
|
|
164
|
+
bdmvkit in programs under any license. Changes to bdmvkit itself stay under
|
|
165
|
+
the LGPL.
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
bdmvkit/__init__.py,sha256=dUjhMn39PM5g-7DxWKHCDVtLpSltg8KM7K1Cg-lqKHA,691
|
|
2
|
+
bdmvkit/__main__.py,sha256=50a2lAnbH7WUOlR5HzAinfHuZjM5BOHDrZJoGOU1wSk,3767
|
|
3
|
+
bdmvkit/clip.py,sha256=nZsAiIpUKZQbXXsgwSroGVu79dR3oWnr6VXo5JDTbL0,3492
|
|
4
|
+
bdmvkit/disc.py,sha256=A7uEfGO4ICpKJFCtWYHIBNIYyfabNh3JUjUTTGUMXUg,12655
|
|
5
|
+
bdmvkit/encode.py,sha256=KaGbBDIrchLvRi3y1SbtgTqpyElZGBbNPTAXEnqyPxo,7523
|
|
6
|
+
bdmvkit/errors.py,sha256=zQ_18MUQJYgJioYO4uHSLWZmtRwqIPG_DyUTyWmMZDw,214
|
|
7
|
+
bdmvkit/formats.py,sha256=Rbytess5vOrLTJb31iE2JPk9DeFnh87Rurzqshaw-MY,1813
|
|
8
|
+
bdmvkit/hdmv.py,sha256=jJd0XAwKuFQGxwNoZfnwgfxy8JRfXs5vywLYkzYR_qc,4549
|
|
9
|
+
bdmvkit/igs.py,sha256=w9z8WbXAT4-O1PqJjQgEuZvNXhBGE0fz4p_PkJ9CqcI,10644
|
|
10
|
+
bdmvkit/m2ts.py,sha256=1PiWorH_DcdRGJ8QaOKQksAjYpZIHXUkddaqnP4OPMA,3011
|
|
11
|
+
bdmvkit/pgs.py,sha256=8GPY57yugIGea47lDwjcHTWRoGNVVRL98RUto6QoLys,9309
|
|
12
|
+
bdmvkit/playlist.py,sha256=emB7TN2vax9KDVZ_liGZ8rRW36FmeQOK34VyIJwjuBM,2991
|
|
13
|
+
bdmvkit-0.2.0.dist-info/licenses/COPYING,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
|
|
14
|
+
bdmvkit-0.2.0.dist-info/licenses/COPYING.LESSER,sha256=46mU2C5kSwOnkqkw9XQAJlhBL2JAf1_uCD8lVcXyMRg,7652
|
|
15
|
+
bdmvkit-0.2.0.dist-info/METADATA,sha256=cDbNKbg4aO-XyP9ClLOn_STqcwRZ9mRgGFD1nlmuqXU,6052
|
|
16
|
+
bdmvkit-0.2.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
17
|
+
bdmvkit-0.2.0.dist-info/entry_points.txt,sha256=qBL1KfAN9ym2yMsO49KD4LslfR7jiW9SC7vQ2DOEVXc,50
|
|
18
|
+
bdmvkit-0.2.0.dist-info/top_level.txt,sha256=jEuoAleUJJak9d70JUB7ySd2VRD_B5Wk5oHlzjM4hNQ,8
|
|
19
|
+
bdmvkit-0.2.0.dist-info/RECORD,,
|