mixarchive 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.
- mixarchive/__init__.py +2 -0
- mixarchive/__main__.py +3 -0
- mixarchive/art.py +159 -0
- mixarchive/cli.py +1214 -0
- mixarchive/collection.py +162 -0
- mixarchive/gui.html +739 -0
- mixarchive/gui.py +339 -0
- mixarchive/gui_hybrid.html +262 -0
- mixarchive/gui_music.html +481 -0
- mixarchive/gui_panel.html +248 -0
- mixarchive/gui_progress.js +81 -0
- mixarchive/gui_radio.html +250 -0
- mixarchive/gui_skin.js +313 -0
- mixarchive/modland.py +166 -0
- mixarchive/rbdb.py +413 -0
- mixarchive/rbfiles.py +1006 -0
- mixarchive/rbmerge.py +389 -0
- mixarchive/rbxml.py +116 -0
- mixarchive/singles.py +232 -0
- mixarchive/split.py +262 -0
- mixarchive/tracklist.py +231 -0
- mixarchive/util.py +241 -0
- mixarchive/yt.py +233 -0
- mixarchive-0.2.0.dist-info/METADATA +487 -0
- mixarchive-0.2.0.dist-info/RECORD +29 -0
- mixarchive-0.2.0.dist-info/WHEEL +5 -0
- mixarchive-0.2.0.dist-info/entry_points.txt +5 -0
- mixarchive-0.2.0.dist-info/licenses/LICENSE +21 -0
- mixarchive-0.2.0.dist-info/top_level.txt +1 -0
mixarchive/__init__.py
ADDED
mixarchive/__main__.py
ADDED
mixarchive/art.py
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""Artwork: square covers for rekordbox/CDJ (600x600 baseline JPEG).
|
|
2
|
+
|
|
3
|
+
- mix cover: the video thumbnail letterboxed onto a square (nothing cropped)
|
|
4
|
+
- per-track cover (opt-in, e.g. off1k): a square crop of a video frame grabbed
|
|
5
|
+
a few seconds into each track, where an on-screen info box shows the track
|
|
6
|
+
- MOD-render covers: the track cover with a corner badge
|
|
7
|
+
"""
|
|
8
|
+
import io
|
|
9
|
+
import os
|
|
10
|
+
import subprocess
|
|
11
|
+
|
|
12
|
+
from PIL import Image, ImageChops, ImageDraw, ImageFont
|
|
13
|
+
|
|
14
|
+
from . import yt
|
|
15
|
+
from .util import settings
|
|
16
|
+
|
|
17
|
+
SIZE = 600
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _save(im, path):
|
|
21
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
22
|
+
im.convert("RGB").resize((SIZE, SIZE), Image.LANCZOS).save(
|
|
23
|
+
path, quality=90, progressive=False)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _run(density, min_density, gap=4):
|
|
27
|
+
"""(start, end) of the longest stretch of dense columns/rows (small gaps bridged)."""
|
|
28
|
+
best, start, last = None, None, None
|
|
29
|
+
for i, d in enumerate(density + [0.0] * (gap + 1)):
|
|
30
|
+
if d >= min_density:
|
|
31
|
+
if start is None or i - last > gap:
|
|
32
|
+
start = i
|
|
33
|
+
last = i
|
|
34
|
+
if best is None or last - start > best[1] - best[0]:
|
|
35
|
+
best = (start, last + 1)
|
|
36
|
+
return best
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _dense_box(mask):
|
|
40
|
+
"""Bounding box of the main object in a content mask: the longest run of columns
|
|
41
|
+
that are well filled, then of rows within it. A caption or logo in the backdrop
|
|
42
|
+
is thin, so it doesn't stretch the box the way a plain getbbox() would."""
|
|
43
|
+
w, h = mask.size
|
|
44
|
+
px = mask.load()
|
|
45
|
+
cols = [sum(1 for y in range(h) if px[x, y]) / h for x in range(w)]
|
|
46
|
+
xs = _run(cols, 0.15)
|
|
47
|
+
if not xs:
|
|
48
|
+
return None
|
|
49
|
+
rows = [sum(1 for x in range(*xs) if px[x, y]) / (xs[1] - xs[0]) for y in range(h)]
|
|
50
|
+
ys = _run(rows, 0.15)
|
|
51
|
+
return (xs[0], ys[0], xs[1], ys[1]) if ys else None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _content_box(im, tol=28):
|
|
55
|
+
"""Bounding box inside flat borders (letterbox/pillarbox bars, or a plain backdrop
|
|
56
|
+
around a record sleeve), or None when the picture runs to the edges."""
|
|
57
|
+
small = im.resize((320, max(1, round(320 * im.height / im.width))))
|
|
58
|
+
w, h = small.size
|
|
59
|
+
corners = [small.getpixel(xy) for xy in ((1, 1), (w - 2, 1), (1, h - 2), (w - 2, h - 2))]
|
|
60
|
+
bg = sorted(corners, key=sum)[1] # robust to one odd corner
|
|
61
|
+
if sum(1 for c in corners if max(abs(a - b) for a, b in zip(c, bg)) <= tol) < 3:
|
|
62
|
+
return None
|
|
63
|
+
diff = ImageChops.difference(small, Image.new("RGB", small.size, bg)).convert("L")
|
|
64
|
+
mask = diff.point(lambda v: 255 if v > tol else 0)
|
|
65
|
+
box = _dense_box(mask)
|
|
66
|
+
if not box or (box[2] - box[0]) * (box[3] - box[1]) < 0.15 * w * h:
|
|
67
|
+
return None
|
|
68
|
+
if box[2] - box[0] >= 0.97 * w and box[3] - box[1] >= 0.97 * h:
|
|
69
|
+
return None
|
|
70
|
+
sx, sy = im.width / w, im.height / h
|
|
71
|
+
ix, iy = (box[2] - box[0]) * 0.01, (box[3] - box[1]) * 0.01 # no sliver of backdrop
|
|
72
|
+
return (round((box[0] + ix) * sx), round((box[1] + iy) * sy),
|
|
73
|
+
round((box[2] - ix) * sx), round((box[3] - iy) * sy))
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def square_from_bytes(data, path):
|
|
77
|
+
"""Square cover from a thumbnail. Flat borders are trimmed; a roughly square
|
|
78
|
+
remainder is the release's artwork and fills the square, anything else (a video
|
|
79
|
+
frame) is letterboxed whole. Returns 'cover' or 'letterbox'."""
|
|
80
|
+
th = Image.open(io.BytesIO(data)).convert("RGB")
|
|
81
|
+
box = _content_box(th)
|
|
82
|
+
if box:
|
|
83
|
+
inner = th.crop(box)
|
|
84
|
+
if 0.85 <= inner.width / inner.height <= 1.18:
|
|
85
|
+
side = min(inner.size)
|
|
86
|
+
l, t = (inner.width - side) // 2, (inner.height - side) // 2
|
|
87
|
+
_save(inner.crop((l, t, l + side, t + side)), path)
|
|
88
|
+
return "cover"
|
|
89
|
+
th = inner
|
|
90
|
+
side = max(th.size)
|
|
91
|
+
sq = Image.new("RGB", (side, side), "black")
|
|
92
|
+
sq.paste(th, ((side - th.width) // 2, (side - th.height) // 2))
|
|
93
|
+
_save(sq, path)
|
|
94
|
+
return "letterbox"
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def fetch_image(url):
|
|
98
|
+
"""(mime, bytes) of an image URL (e.g. a video's thumbnail), or None."""
|
|
99
|
+
import urllib.request
|
|
100
|
+
if not url:
|
|
101
|
+
return None
|
|
102
|
+
try:
|
|
103
|
+
with urllib.request.urlopen(url, timeout=20) as r:
|
|
104
|
+
return (r.headers.get_content_type(), r.read())
|
|
105
|
+
except OSError:
|
|
106
|
+
return None
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def crop_frame(frame_path, box, path):
|
|
110
|
+
_save(Image.open(frame_path).convert("RGB").resize((1280, 720)).crop(tuple(box)), path)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def grab_frames(video_id, items, offset, dest_dir, prefix):
|
|
114
|
+
"""items: [(track no, start sec)] -> one 720p frame `offset` s into each track,
|
|
115
|
+
saved as <dest>/<prefix>_<NN>.jpg. Returns paths (None where grabbing failed)."""
|
|
116
|
+
url = yt.stream_url(video_id)
|
|
117
|
+
out = []
|
|
118
|
+
for n, start in items:
|
|
119
|
+
p = os.path.join(dest_dir, f"{prefix}_{n:02d}.jpg")
|
|
120
|
+
os.makedirs(dest_dir, exist_ok=True)
|
|
121
|
+
subprocess.run([settings()["ffmpeg"], "-hide_banner", "-v", "error", "-y",
|
|
122
|
+
"-ss", str(int(start) + offset), "-i", url, "-frames:v", "1",
|
|
123
|
+
"-q:v", "2", p], check=False, stdin=subprocess.DEVNULL)
|
|
124
|
+
out.append(p if os.path.exists(p) else None)
|
|
125
|
+
return out
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _font(size):
|
|
129
|
+
for f in ("C:/Windows/Fonts/arialbd.ttf", "/System/Library/Fonts/Supplemental/Arial Bold.ttf"):
|
|
130
|
+
try:
|
|
131
|
+
return ImageFont.truetype(f, size)
|
|
132
|
+
except OSError:
|
|
133
|
+
pass
|
|
134
|
+
return ImageFont.load_default()
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def badge(src_path, dst_path, text="MOD RENDER"):
|
|
138
|
+
im = Image.open(src_path).convert("RGB").resize((SIZE, SIZE))
|
|
139
|
+
d = ImageDraw.Draw(im)
|
|
140
|
+
font = _font(40)
|
|
141
|
+
w = d.textlength(text, font=font)
|
|
142
|
+
d.rectangle([SIZE - w - 36, SIZE - 68, SIZE - 8, SIZE - 8], fill=(230, 40, 120))
|
|
143
|
+
d.text((SIZE - w - 22, SIZE - 64), text, fill="white", font=font)
|
|
144
|
+
_save(im, dst_path)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def contact_sheet(items, path, cols=10, cell=160):
|
|
148
|
+
"""items: [(label, image path)] -> one review sheet."""
|
|
149
|
+
font = _font(16)
|
|
150
|
+
rows = max(1, (len(items) + cols - 1) // cols)
|
|
151
|
+
sheet = Image.new("RGB", (cols * cell, rows * cell), "gray")
|
|
152
|
+
for i, (label, p) in enumerate(items):
|
|
153
|
+
im = Image.open(p).convert("RGB").resize((cell - 4, cell - 4))
|
|
154
|
+
d = ImageDraw.Draw(im)
|
|
155
|
+
d.rectangle([0, 0, 8 + 9 * len(label), 20], fill="black")
|
|
156
|
+
d.text((3, 1), label, fill="yellow", font=font)
|
|
157
|
+
sheet.paste(im, ((i % cols) * cell + 2, (i // cols) * cell + 2))
|
|
158
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
159
|
+
sheet.save(path, quality=88)
|