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 ADDED
@@ -0,0 +1,2 @@
1
+ """mixarchive - scrape, archive and split long mixes into tagged tracks."""
2
+ __version__ = "0.2.0" # = pyproject.toml's; publish.yml checks both
mixarchive/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ main()
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)