shapes_mc (tools/anki-deck-visual.py) is the same shape images as "shapes" but multiple choice instead of type-the-name, matching the plain-text A/B/C/D pattern anki-deck-periodic.py's "category" deck already uses (no clickable UI, since that needs a desktop-only Anki add-on and breaks on AnkiDroid/AnkiMobile). periodic prehs/hs (tools/anki-deck-periodic.py) now show each element's real sample photo alongside every card, fetched once from Wikipedia's own MediaWiki pageimages API and cached under tools/periodic_images/ — the one part of this tooling that needs internet access at generation time; --no-images restores the old text-only cards. Elements 100 (Fermium) through 118 (Oganesson) are excluded, since none has ever existed in a photographable quantity — every fetch is otherwise per-element and non-fatal, with progress printed so failures are visible. This fetch logic could not be exercised against the real Wikipedia API from this sandbox (no route to en.wikipedia.org here) — --dry-run-tts now also substitutes a placeholder image so the pipeline is at least structurally tested end to end. Real-network behavior needs verifying on an actual run. Added tools/*.apkg, tools/media_*/, tools/periodic_images/, and tools/__pycache__/ to .gitignore — all generated/cached locally, never meant to be committed. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014DyceEVVeQ33EeS6C1PDv5
504 lines
19 KiB
Python
504 lines
19 KiB
Python
#!/usr/bin/env python3
|
|
"""tools/anki-deck-visual.py — Generate image-based Anki decks (.apkg) for
|
|
shapes, clocks, and coin-counting, with Anki's built-in type-the-answer
|
|
input and Piper (offline, local neural TTS) audio. See
|
|
tools/anki-deck-math.py's docstring for one-time setup (venv, genanki +
|
|
piper-tts, downloading a voice) — same steps apply here, plus one more
|
|
package this script alone needs: `pip install pillow` (for drawing the
|
|
images — see the note below on PNG vs SVG for why).
|
|
|
|
All images are drawn programmatically (regular-polygon geometry,
|
|
clock-hand trigonometry, coin layouts) rather than AI-generated — image
|
|
generation (local or cloud) is a poor fit for content that has to be
|
|
exactly correct (an exact clock time, an exact side count, an exact coin
|
|
total), not just plausible-looking. See this script's own point/angle
|
|
generation functions for how each shape's geometry is computed directly
|
|
rather than approximated.
|
|
|
|
Rendered as PNG (via Pillow), not SVG — AnkiDroid has a long-documented
|
|
history of unreliable SVG rendering (multiple open ankidroid/Anki-Android
|
|
GitHub issues going back years: some SVGs render, some silently don't,
|
|
with no clear pattern tied to how the file itself is written). PNG has no
|
|
such history on any Anki client. Confirmed live: an earlier SVG-based
|
|
version of this script produced images that displayed fine on desktop
|
|
Anki but never appeared at all on a mobile client.
|
|
|
|
Decks:
|
|
shapes regular polygons (3-10 sides, image->name and name->sides)
|
|
plus 5 quadrilateral types (image->name: square, rectangle,
|
|
rhombus, trapezoid, parallelogram — each one's geometry is
|
|
genuinely distinct, not just differently labeled)
|
|
shapes_mc same shape images as "shapes", but as multiple choice
|
|
(A/B/C/D shown as plain text below the image — not a
|
|
clickable UI, since that needs a desktop-only Anki add-on
|
|
and would break on AnkiDroid/AnkiMobile — same approach as
|
|
tools/anki-deck-periodic.py's "category" deck), type the
|
|
letter
|
|
clocks analog clock faces, all 144 hour/5-min combinations,
|
|
type the time as H:MM (the hour hand moves fractionally
|
|
with the minutes, e.g. 6:30 sits halfway between 6 and 7 —
|
|
a static hour hand is the most common "looks right but
|
|
teaches wrong" bug in generated clock faces)
|
|
currency US coins (nickel/dime/quarter — no pennies, since they're
|
|
barely used day to day at this point), 1-4 coins per card,
|
|
type the total in cents
|
|
|
|
Usage (run with the venv from anki-deck-math.py's docstring activated):
|
|
python3 anki-deck-visual.py --deck shapes
|
|
python3 anki-deck-visual.py --deck shapes_mc
|
|
python3 anki-deck-visual.py --deck clocks
|
|
python3 anki-deck-visual.py --deck currency
|
|
(add --voice en_US-amy-medium etc.; --model-path if a voice isn't found
|
|
automatically; --dry-run-tts to test the deck-building logic without any
|
|
voice model at all, using silent placeholder audio)
|
|
"""
|
|
import argparse
|
|
import hashlib
|
|
import genanki
|
|
import math
|
|
import os
|
|
import random
|
|
|
|
from PIL import Image, ImageDraw, ImageFont
|
|
import subprocess
|
|
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument("--deck", required=True,
|
|
choices=["shapes", "shapes_mc", "clocks", "currency"])
|
|
parser.add_argument("--voice", default="en_US-lessac-medium")
|
|
parser.add_argument("--model-path", default=None)
|
|
parser.add_argument("--dry-run-tts", action="store_true")
|
|
args = parser.parse_args()
|
|
|
|
SCRATCH = os.path.dirname(os.path.abspath(__file__))
|
|
|
|
_CANDIDATES = [
|
|
args.model_path,
|
|
f"{args.voice}.onnx",
|
|
os.path.join(SCRATCH, f"{args.voice}.onnx"),
|
|
os.path.expanduser(f"~/{args.voice}.onnx"),
|
|
os.path.expanduser(f"~/.local/share/piper/voices/{args.voice}.onnx"),
|
|
]
|
|
VOICE_MODEL = next((p for p in _CANDIDATES if p and os.path.isfile(p)), None)
|
|
|
|
if VOICE_MODEL is None and not args.dry_run_tts:
|
|
raise SystemExit(
|
|
f"Voice model for '{args.voice}' not found. Checked:\n"
|
|
+ "\n".join(f" {p}" for p in _CANDIDATES if p)
|
|
+ f"\n\nFind it with: find / -iname '{args.voice}.onnx' 2>/dev/null"
|
|
+ "\nThen pass its exact path with --model-path /the/real/path.onnx"
|
|
+ "\n(or pass --dry-run-tts to test deck-building without any voice at all)"
|
|
)
|
|
|
|
|
|
def piper_tts(text: str, out_path: str) -> None:
|
|
if args.dry_run_tts:
|
|
with open(out_path, "wb") as f:
|
|
f.write(
|
|
b"RIFF$\x00\x00\x00WAVEfmt \x10\x00\x00\x00\x01\x00\x01\x00"
|
|
b"\x22\x56\x00\x00\x44\xac\x00\x00\x02\x00\x10\x00data\x00\x00\x00\x00"
|
|
)
|
|
return
|
|
subprocess.run(
|
|
["python3", "-m", "piper", "-m", VOICE_MODEL, "-f", out_path],
|
|
input=text.encode("utf-8"),
|
|
check=True,
|
|
capture_output=True,
|
|
)
|
|
|
|
|
|
ONES = ["zero", "one", "two", "three", "four", "five", "six", "seven",
|
|
"eight", "nine", "ten", "eleven", "twelve", "thirteen", "fourteen",
|
|
"fifteen", "sixteen", "seventeen", "eighteen", "nineteen"]
|
|
TENS = ["", "", "twenty", "thirty", "forty", "fifty", "sixty", "seventy",
|
|
"eighty", "ninety"]
|
|
|
|
|
|
def num2words(n):
|
|
if n < 20:
|
|
return ONES[n]
|
|
if n < 100:
|
|
t, o = divmod(n, 10)
|
|
return TENS[t] + ("-" + ONES[o] if o else "")
|
|
h, rem = divmod(n, 100)
|
|
return ONES[h] + " hundred" + (" " + num2words(rem) if rem else "")
|
|
|
|
|
|
assert num2words(15) == "fifteen"
|
|
assert num2words(40) == "forty"
|
|
|
|
|
|
def time_words(hour, minute):
|
|
"""3, 5 -> 'three oh five'; 3, 15 -> 'three fifteen'; 3, 0 -> 'three o'clock'."""
|
|
if minute == 0:
|
|
return f"{num2words(hour)} o'clock"
|
|
if minute < 10:
|
|
return f"{num2words(hour)} oh {num2words(minute)}"
|
|
return f"{num2words(hour)} {num2words(minute)}"
|
|
|
|
|
|
assert time_words(3, 0) == "three o'clock"
|
|
assert time_words(3, 5) == "three oh five"
|
|
assert time_words(3, 15) == "three fifteen"
|
|
assert time_words(12, 45) == "twelve forty-five"
|
|
|
|
|
|
def build_model(deck_key):
|
|
voice_hash = int(hashlib.sha256(f"{deck_key}:{args.voice}".encode()).hexdigest(), 16)
|
|
model_id = 1_800_000_000 + (voice_hash % 90_000_000)
|
|
return model_id, genanki.Model(
|
|
model_id,
|
|
f"Visual Fact ({deck_key}, {args.voice})",
|
|
fields=[{"name": "Image"}, {"name": "Answer"}, {"name": "QSound"}, {"name": "ASound"}],
|
|
templates=[{
|
|
"name": "Card",
|
|
"qfmt": """
|
|
<div class="imgwrap">{{Image}}</div>
|
|
{{QSound}}
|
|
{{type:Answer}}
|
|
""",
|
|
"afmt": """
|
|
<div class="imgwrap">{{Image}}</div>
|
|
<hr id="answer">
|
|
{{type:Answer}}
|
|
{{ASound}}
|
|
""",
|
|
}],
|
|
css="""
|
|
.card { font-family: Arial, sans-serif; font-size: 24px; text-align: center; }
|
|
.imgwrap { margin: 10px auto; }
|
|
.imgwrap img { max-width: 260px; max-height: 260px; }
|
|
.mc-choices { display: inline-block; text-align: left; margin-top: 14px; font-size: 22px; }
|
|
.mc-choices div { margin: 4px 0; }
|
|
""",
|
|
)
|
|
|
|
|
|
def build_deck(deck_key, deck_title):
|
|
voice_hash = int(hashlib.sha256(f"{deck_key}:{args.voice}".encode()).hexdigest(), 16)
|
|
deck_id = 2_200_000_000 + (voice_hash % 90_000_000)
|
|
return genanki.Deck(deck_id, deck_title)
|
|
|
|
|
|
def add_note(deck, model, image_html, answer, qtext, atext, media_files, tag):
|
|
qfile = f"q_{tag}.wav"
|
|
afile = f"a_{tag}.wav"
|
|
qpath = os.path.join(MEDIA_DIR, qfile)
|
|
apath = os.path.join(MEDIA_DIR, afile)
|
|
piper_tts(qtext, qpath)
|
|
piper_tts(atext, apath)
|
|
media_files += [qpath, apath]
|
|
deck.add_note(genanki.Note(
|
|
model=model,
|
|
fields=[image_html, answer, f"[sound:{qfile}]", f"[sound:{afile}]"],
|
|
))
|
|
|
|
|
|
def add_text_note(deck, model, text, answer, qtext, atext, media_files, tag):
|
|
"""For directions that don't need an image (e.g. name -> number of sides)."""
|
|
add_note(deck, model, f'<div style="font-size:36px;">{text}</div>', answer,
|
|
qtext, atext, media_files, tag)
|
|
|
|
|
|
# ─── PNG generation (Pillow) ─────────────────────────────────────────────────
|
|
_FONT_CACHE = {}
|
|
_FONT_PATH_CANDIDATES = [
|
|
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
|
|
"/usr/share/fonts/dejavu/DejaVuSans-Bold.ttf",
|
|
"/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf",
|
|
]
|
|
|
|
|
|
def load_font(size):
|
|
"""Loads a real bold TTF font if one of the common Debian/Ubuntu paths
|
|
exists (DejaVu Sans Bold ships with fonts-dejavu-core, a very common
|
|
baseline package); falls back to Pillow's own scalable default font
|
|
otherwise, so this never crashes even if no system font is found."""
|
|
if size in _FONT_CACHE:
|
|
return _FONT_CACHE[size]
|
|
for path in _FONT_PATH_CANDIDATES:
|
|
if os.path.isfile(path):
|
|
font = ImageFont.truetype(path, size)
|
|
_FONT_CACHE[size] = font
|
|
return font
|
|
font = ImageFont.load_default(size=size)
|
|
_FONT_CACHE[size] = font
|
|
return font
|
|
|
|
|
|
def draw_text_centered(draw, xy, text, font, fill="black"):
|
|
bbox = draw.textbbox((0, 0), text, font=font)
|
|
w, h = bbox[2] - bbox[0], bbox[3] - bbox[1]
|
|
x, y = xy
|
|
draw.text((x - w / 2 - bbox[0], y - h / 2 - bbox[1]), text, font=font, fill=fill)
|
|
|
|
|
|
def save_png(filename, draw_fn, size=(200, 200)):
|
|
img = Image.new("RGB", size, "white")
|
|
draw = ImageDraw.Draw(img)
|
|
draw_fn(draw)
|
|
path = os.path.join(MEDIA_DIR, filename)
|
|
img.save(path)
|
|
return path
|
|
|
|
|
|
def regular_polygon_points(n_sides, cx=100, cy=100, r=80):
|
|
points = []
|
|
# Start pointing up (-90deg) so shapes sit "upright" rather than vertex-right.
|
|
start_angle = -90
|
|
for i in range(n_sides):
|
|
angle_deg = start_angle + i * (360 / n_sides)
|
|
angle_rad = math.radians(angle_deg)
|
|
x = cx + r * math.cos(angle_rad)
|
|
y = cy + r * math.sin(angle_rad)
|
|
points.append((round(x, 1), round(y, 1)))
|
|
return points
|
|
|
|
|
|
def polygon_png(points, filename):
|
|
def draw_fn(draw):
|
|
draw.polygon(points, fill=(111, 168, 220), outline=(28, 69, 135), width=4)
|
|
return save_png(filename, draw_fn)
|
|
|
|
|
|
POLYGON_NAMES = {
|
|
3: "triangle", 4: "square", 5: "pentagon", 6: "hexagon", 7: "heptagon",
|
|
8: "octagon", 9: "nonagon", 10: "decagon",
|
|
}
|
|
|
|
QUADRILATERALS = {
|
|
"square": [(50, 50), (150, 50), (150, 150), (50, 150)],
|
|
"rectangle": [(30, 60), (170, 60), (170, 140), (30, 140)],
|
|
"rhombus": [(100, 20), (170, 100), (100, 180), (30, 100)],
|
|
"trapezoid": [(60, 60), (140, 60), (170, 140), (30, 140)],
|
|
"parallelogram": [(60, 60), (160, 60), (140, 140), (40, 140)],
|
|
}
|
|
|
|
|
|
def clock_png(hour, minute, filename):
|
|
cx, cy, r = 100, 100, 90
|
|
minute_angle = minute * 6 - 90
|
|
hour_angle = (hour % 12) * 30 + minute * 0.5 - 90
|
|
|
|
def draw_fn(draw):
|
|
draw.ellipse([cx - r, cy - r, cx + r, cy + r], fill="white", outline="black", width=3)
|
|
|
|
font = load_font(15)
|
|
for h in range(1, 13):
|
|
angle = math.radians(h * 30 - 90)
|
|
tx1, ty1 = cx + (r - 10) * math.cos(angle), cy + (r - 10) * math.sin(angle)
|
|
tx2, ty2 = cx + r * math.cos(angle), cy + r * math.sin(angle)
|
|
draw.line([(tx1, ty1), (tx2, ty2)], fill="black", width=2)
|
|
nx, ny = cx + (r - 22) * math.cos(angle), cy + (r - 22) * math.sin(angle)
|
|
draw_text_centered(draw, (nx, ny), str(h), font)
|
|
|
|
def hand(angle_deg, length, width):
|
|
rad = math.radians(angle_deg)
|
|
x2, y2 = cx + length * math.cos(rad), cy + length * math.sin(rad)
|
|
draw.line([(cx, cy), (x2, y2)], fill="black", width=width)
|
|
|
|
hand(hour_angle, 45, 6)
|
|
hand(minute_angle, 70, 4)
|
|
draw.ellipse([cx - 4, cy - 4, cx + 4, cy + 4], fill="black")
|
|
|
|
return save_png(filename, draw_fn)
|
|
|
|
|
|
COIN_INFO = {5: ("#c0c0c0", "5¢"), 10: ("#d9d9d9", "10¢"), 25: ("#b8b8b8", "25¢")}
|
|
COIN_NAMES = {5: "nickel", 10: "dime", 25: "quarter"}
|
|
|
|
|
|
def coins_png(coin_values, filename):
|
|
n = len(coin_values)
|
|
spacing = 200 // (n + 1)
|
|
|
|
def draw_fn(draw):
|
|
font = load_font(14)
|
|
for i, v in enumerate(coin_values):
|
|
cx = spacing * (i + 1)
|
|
color, label = COIN_INFO[v]
|
|
radius = 30 if v == 25 else (26 if v == 10 else 28)
|
|
draw.ellipse([cx - radius, 100 - radius, cx + radius, 100 + radius],
|
|
fill=color, outline=(68, 68, 68), width=2)
|
|
draw_text_centered(draw, (cx, 100), label, font)
|
|
|
|
return save_png(filename, draw_fn)
|
|
|
|
|
|
def coin_list_words(coin_values):
|
|
names = [COIN_NAMES[v] for v in coin_values]
|
|
if len(names) == 1:
|
|
return f"a {names[0]}"
|
|
if len(names) == 2:
|
|
return f"a {names[0]} and a {names[1]}"
|
|
return ", ".join(f"a {n}" for n in names[:-1]) + f", and a {names[-1]}"
|
|
|
|
|
|
# ─── Per-deck generators ─────────────────────────────────────────────────────
|
|
def gen_shapes():
|
|
deck_key = "shapes"
|
|
model_id, model = build_model(deck_key)
|
|
deck = build_deck(deck_key, "Shapes: Polygons & Quadrilaterals")
|
|
media_files = []
|
|
|
|
jobs = []
|
|
for n in range(3, 11):
|
|
jobs.append(("polygon_image", n))
|
|
jobs.append(("polygon_sides", n))
|
|
for qname in QUADRILATERALS:
|
|
jobs.append(("quad_image", qname))
|
|
random.seed(60)
|
|
random.shuffle(jobs)
|
|
|
|
for kind, val in jobs:
|
|
if kind == "polygon_image":
|
|
n = val
|
|
name = POLYGON_NAMES[n]
|
|
png_path = polygon_png(regular_polygon_points(n), f"poly_{n}.png")
|
|
media_files.append(png_path)
|
|
add_note(deck, model, f'<img src="poly_{n}.png">', name,
|
|
"What shape is this?", name, media_files, f"shape_img_{n}")
|
|
elif kind == "polygon_sides":
|
|
n = val
|
|
name = POLYGON_NAMES[n]
|
|
add_text_note(deck, model, name.capitalize(), str(n),
|
|
f"How many sides does a {name} have?", num2words(n),
|
|
media_files, f"shape_sides_{n}")
|
|
else:
|
|
qname = val
|
|
png_path = polygon_png(QUADRILATERALS[qname], f"quad_{qname}.png")
|
|
media_files.append(png_path)
|
|
add_note(deck, model, f'<img src="quad_{qname}.png">', qname,
|
|
"What shape is this?", qname, media_files, f"shape_quad_{qname}")
|
|
return deck, media_files, len(jobs)
|
|
|
|
|
|
def gen_shapes_mc():
|
|
"""Same shape images as gen_shapes(), but multiple choice instead of
|
|
type-the-name — A/B/C/D distractors drawn from every other shape name
|
|
in the pool (polygons and quadrilaterals share one distractor pool, so
|
|
a polygon question can pull "square" as a wrong answer and vice versa)."""
|
|
deck_key = "shapes_mc"
|
|
model_id, model = build_model(deck_key)
|
|
deck = build_deck(deck_key, "Shapes: Polygons & Quadrilaterals (multiple choice)")
|
|
media_files = []
|
|
|
|
jobs = [("polygon", n, POLYGON_NAMES[n]) for n in range(3, 11)]
|
|
jobs += [("quad", qname, qname) for qname in QUADRILATERALS]
|
|
all_names = [name for _, _, name in jobs]
|
|
random.seed(63)
|
|
random.shuffle(jobs)
|
|
|
|
letters = ["A", "B", "C", "D"]
|
|
for kind, val, name in jobs:
|
|
if kind == "polygon":
|
|
png_path = polygon_png(regular_polygon_points(val), f"mc_poly_{val}.png")
|
|
tag = f"shape_mc_poly_{val}"
|
|
else:
|
|
png_path = polygon_png(QUADRILATERALS[val], f"mc_quad_{val}.png")
|
|
tag = f"shape_mc_quad_{val}"
|
|
media_files.append(png_path)
|
|
|
|
distractor_pool = [n for n in all_names if n != name]
|
|
distractors = random.sample(distractor_pool, 3)
|
|
choices = distractors + [name]
|
|
random.shuffle(choices)
|
|
correct_letter = letters[choices.index(name)]
|
|
|
|
choice_html = "".join(f"<div>{letter}) {choice}</div>"
|
|
for letter, choice in zip(letters, choices))
|
|
image_html = (f'<img src="{os.path.basename(png_path)}">'
|
|
f'<div class="mc-choices">{choice_html}</div>')
|
|
|
|
add_note(deck, model, image_html, correct_letter,
|
|
"What shape is this?", name, media_files, tag)
|
|
return deck, media_files, len(jobs)
|
|
|
|
|
|
def gen_clocks():
|
|
deck_key = "clocks"
|
|
model_id, model = build_model(deck_key)
|
|
deck = build_deck(deck_key, "Telling Time: Analog Clocks")
|
|
media_files = []
|
|
|
|
times = [(h, m) for h in range(1, 13) for m in range(0, 60, 5)]
|
|
random.seed(61)
|
|
random.shuffle(times)
|
|
|
|
# The question prompt ("What time is it?") is identical for every card —
|
|
# generate it once instead of 144 times.
|
|
shared_qfile = "q_clock_prompt.wav"
|
|
piper_tts("What time is it?", os.path.join(MEDIA_DIR, shared_qfile))
|
|
media_files.append(os.path.join(MEDIA_DIR, shared_qfile))
|
|
|
|
for hour, minute in times:
|
|
png_path = clock_png(hour, minute, f"clock_{hour}_{minute:02d}.png")
|
|
media_files.append(png_path)
|
|
answer = f"{hour}:{minute:02d}"
|
|
afile = f"a_clock_{hour}_{minute:02d}.wav"
|
|
apath = os.path.join(MEDIA_DIR, afile)
|
|
piper_tts(time_words(hour, minute), apath)
|
|
media_files.append(apath)
|
|
deck.add_note(genanki.Note(
|
|
model=model,
|
|
fields=[f'<img src="clock_{hour}_{minute:02d}.png">', answer,
|
|
f"[sound:{shared_qfile}]", f"[sound:{afile}]"],
|
|
))
|
|
return deck, media_files, len(times)
|
|
|
|
|
|
def gen_currency():
|
|
deck_key = "currency"
|
|
model_id, model = build_model(deck_key)
|
|
# Deliberately nickel/dime/quarter only, no pennies — pennies are barely
|
|
# used day to day at this point, and skipping them keeps every total a
|
|
# multiple of 5 cents, which is a cleaner first pass at coin counting.
|
|
deck = build_deck(deck_key, "Counting Coins (nickels, dimes, quarters)")
|
|
media_files = []
|
|
denoms = [5, 10, 25]
|
|
|
|
combos = set()
|
|
for count in range(1, 5):
|
|
def rec(remaining, current):
|
|
if remaining == 0:
|
|
combos.add(tuple(sorted(current)))
|
|
return
|
|
for d in denoms:
|
|
if not current or d >= current[-1]:
|
|
rec(remaining - 1, current + [d])
|
|
rec(count, [])
|
|
combos = sorted(combos)
|
|
random.seed(62)
|
|
random.shuffle(combos)
|
|
|
|
for coin_values in combos:
|
|
total = sum(coin_values)
|
|
tag = "_".join(map(str, coin_values))
|
|
png_path = coins_png(list(coin_values), f"coins_{tag}.png")
|
|
media_files.append(png_path)
|
|
qtext = f"How much money is {coin_list_words(list(coin_values))}?"
|
|
atext = f"{num2words(total)} cents"
|
|
add_note(deck, model, f'<img src="coins_{tag}.png">',
|
|
str(total), qtext, atext, media_files, f"coins_{tag}")
|
|
return deck, media_files, len(combos)
|
|
|
|
|
|
deck_key = args.deck
|
|
|
|
MEDIA_DIR = os.path.join(SCRATCH, f"media_{deck_key}_{args.voice}")
|
|
os.makedirs(MEDIA_DIR, exist_ok=True)
|
|
|
|
GENERATORS = {
|
|
"shapes": gen_shapes, "shapes_mc": gen_shapes_mc,
|
|
"clocks": gen_clocks, "currency": gen_currency,
|
|
}
|
|
deck, media_files, count = GENERATORS[args.deck]()
|
|
|
|
package = genanki.Package(deck)
|
|
package.media_files = media_files
|
|
out_path = os.path.join(SCRATCH, f"{deck_key}_{args.voice}.apkg")
|
|
package.write_to_file(out_path)
|
|
|
|
size_mb = os.path.getsize(out_path) / (1024 * 1024)
|
|
print(f"\nDone: {out_path} ({size_mb:.1f} MB, {count} cards, {len(media_files)} media files)")
|