1945 lines
67 KiB
Python
1945 lines
67 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
Prepare Android theme resources for mobile-gym (V2 - static assets).
|
||
|
||
Source (ignored by git): ./themes/
|
||
Destination (gitignored, served via /cdn/themes by vite/nginx, mirrored
|
||
to R2 at r2:mobilegym-data/themes for production CDN):
|
||
./mobilegym-data/themes/
|
||
|
||
Outputs:
|
||
- mobilegym-data/themes/manifest.json
|
||
- mobilegym-data/themes/{themeId}/preview/* (copied)
|
||
- mobilegym-data/themes/{themeId}/icons/{package}.png (extracted from icons.mrc)
|
||
- mobilegym-data/themes/{themeId}/statusbar/{name}.png (extracted & normalized)
|
||
- mobilegym-data/themes/{themeId}/shade/{active|inactive}.png (extracted & normalized)
|
||
- mobilegym-data/themes/{themeId}/wallpaper/default.* (best-effort)
|
||
- mobilegym-data/themes/{themeId}/components/{code}/... (selected app assets)
|
||
|
||
Note: manifest.json's `widgets[].variants[].preview` field is emitted as
|
||
`/themes/{themeId}/{clockCode}/preview.png` (legacy absolute prefix).
|
||
At runtime, os/wmr/WmrWidgetService.ts#getWidgetPreviewUrl detects
|
||
this prefix and rewrites it to the CDN URL via os/utils/cdn.ts. Kept
|
||
this way to avoid touching every existing manifest entry.
|
||
|
||
Run:
|
||
python3 scripts/dev/prepare-themes.py
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import datetime as _dt
|
||
import json
|
||
import re
|
||
import shutil
|
||
import zipfile
|
||
import io
|
||
import math
|
||
from pathlib import Path
|
||
from typing import Iterable, Optional
|
||
|
||
try:
|
||
from PIL import Image # type: ignore
|
||
except Exception:
|
||
Image = None # Pillow is optional; battery sprite extraction will be skipped.
|
||
|
||
DEFAULT_THEME_IDS = [
|
||
# 纷紫
|
||
"0132f200-3467-4d4b-b8bd-1bd882145875",
|
||
# 夜半
|
||
"af0f7f90-04fb-417b-941e-ae7b549fe5e5",
|
||
# 小青山
|
||
"e80e4cfc-f4ba-4eef-a487-9a7008744873",
|
||
]
|
||
|
||
DEFAULT_DEFAULT_THEME_ID = "af0f7f90-04fb-417b-941e-ae7b549fe5e5"
|
||
|
||
|
||
def _now_iso() -> str:
|
||
return _dt.datetime.now(_dt.timezone.utc).isoformat()
|
||
|
||
|
||
def read_json(path: Path) -> dict:
|
||
return json.loads(path.read_text(encoding="utf-8"))
|
||
|
||
|
||
def ensure_dir(path: Path) -> None:
|
||
path.mkdir(parents=True, exist_ok=True)
|
||
|
||
|
||
def copy_tree(src_dir: Path, dst_dir: Path) -> None:
|
||
if not src_dir.exists():
|
||
return
|
||
ensure_dir(dst_dir.parent)
|
||
if dst_dir.exists():
|
||
shutil.rmtree(dst_dir)
|
||
shutil.copytree(src_dir, dst_dir)
|
||
|
||
|
||
def iter_subresources(theme_meta: dict) -> Iterable[tuple[str, str]]:
|
||
"""
|
||
Returns (resourceCode, localId) pairs from a theme meta (.mrm JSON).
|
||
"""
|
||
for sr in theme_meta.get("subResources", []) or []:
|
||
code = sr.get("resourceCode")
|
||
local_id = sr.get("localId")
|
||
if not code or not local_id:
|
||
continue
|
||
yield str(code), str(local_id)
|
||
|
||
_DENSITY_RANK = {
|
||
"xxxhdpi": 6,
|
||
"xxhdpi": 5,
|
||
"nxhdpi": 5, # some Android themes use nxhdpi
|
||
"xhdpi": 4,
|
||
"hdpi": 3,
|
||
"mdpi": 2,
|
||
"nodpi": 1,
|
||
"raw": 0,
|
||
}
|
||
|
||
|
||
def _density_score(path: str) -> int:
|
||
lower = path.lower()
|
||
for k, v in _DENSITY_RANK.items():
|
||
if k in lower:
|
||
return v
|
||
return 0
|
||
|
||
|
||
def _is_png(path: str) -> bool:
|
||
return path.lower().endswith(".png") or path.lower().endswith(".9.png")
|
||
|
||
|
||
def _read_text(path: Path) -> str:
|
||
return path.read_text(encoding="utf-8")
|
||
|
||
|
||
def parse_app_package_names(root: Path) -> list[str]:
|
||
"""
|
||
Parse packageName list from both `apps/*/manifest.ts` and `system/*/manifest.ts`.
|
||
Each app manifest declares `packageName: 'com.example.app'`.
|
||
"""
|
||
pkgs: set[str] = set()
|
||
for base_dir_name in ("apps", "system"):
|
||
base_dir = root / base_dir_name
|
||
if not base_dir.exists():
|
||
continue
|
||
for manifest_path in base_dir.glob("*/manifest.ts"):
|
||
try:
|
||
text = _read_text(manifest_path)
|
||
except Exception:
|
||
continue
|
||
hits = re.findall(r"packageName\s*:\s*['\"]([^'\"]+)['\"]", text)
|
||
for h in hits:
|
||
v = h.strip()
|
||
if v:
|
||
pkgs.add(v)
|
||
return sorted(pkgs)
|
||
|
||
|
||
def parse_settings_main_icon_names(root: Path) -> list[str]:
|
||
"""
|
||
Parse icon keys referenced by the Settings main page.
|
||
Settings 页面大配置已迁移到 JSON(见 system/Settings/data/pages.json)。
|
||
"""
|
||
settings_data_dir = root / "system/Settings/data"
|
||
if not settings_data_dir.exists():
|
||
return []
|
||
pages_path = settings_data_dir / "pages.json"
|
||
if not pages_path.exists():
|
||
return []
|
||
|
||
def collect_icons(obj: object, out: set[str]):
|
||
if isinstance(obj, dict):
|
||
for k, v in obj.items():
|
||
if k == "icon" and isinstance(v, str) and v.strip():
|
||
out.add(v.strip())
|
||
collect_icons(v, out)
|
||
return
|
||
if isinstance(obj, list):
|
||
for it in obj:
|
||
collect_icons(it, out)
|
||
|
||
names: set[str] = set()
|
||
try:
|
||
pages = json.loads(_read_text(pages_path))
|
||
collect_icons(pages.get("mainSections", []), names)
|
||
collect_icons(pages.get("pages", {}), names)
|
||
except Exception:
|
||
return []
|
||
|
||
overrides_path = settings_data_dir / "overrides.json"
|
||
if overrides_path.exists():
|
||
try:
|
||
overrides = json.loads(_read_text(overrides_path))
|
||
collect_icons(overrides, names)
|
||
except Exception:
|
||
pass
|
||
|
||
return sorted(names)
|
||
|
||
|
||
def extract_zip_entry(zf: zipfile.ZipFile, entry_name: str, dst: Path) -> bool:
|
||
try:
|
||
info = zf.getinfo(entry_name)
|
||
except KeyError:
|
||
return False
|
||
if info.is_dir():
|
||
return False
|
||
ensure_dir(dst.parent)
|
||
with zf.open(entry_name, "r") as src, dst.open("wb") as out:
|
||
shutil.copyfileobj(src, out)
|
||
return True
|
||
|
||
|
||
def _is_png_bytes(b: bytes) -> bool:
|
||
return b[:8] == b"\x89PNG\r\n\x1a\n"
|
||
|
||
def _is_fully_transparent_png_bytes(png_bytes: bytes) -> bool:
|
||
"""
|
||
some Android themes include 1px-wide fully-transparent placeholder PNGs.
|
||
On real devices these often behave like "no override" (fallback to default) or are invisible.
|
||
In our simulator, keeping them creates huge empty gaps in the status bar.
|
||
"""
|
||
if Image is None:
|
||
return False
|
||
if not _is_png_bytes(png_bytes):
|
||
return False
|
||
try:
|
||
im = Image.open(io.BytesIO(png_bytes)).convert("RGBA")
|
||
except Exception:
|
||
return False
|
||
return im.getchannel("A").getbbox() is None
|
||
|
||
STATUSBAR_TARGET_FILL = 0.82
|
||
STATUSBAR_BASE_SIDE = 56 # UI uses 14px, so 56px -> exact 1/4 scale
|
||
BATTERY_BASE_SIDE = 40 # UI uses 20px, so 40px -> exact 1/2 scale
|
||
|
||
|
||
def normalize_png_icon_to_square(
|
||
png_bytes: bytes,
|
||
*,
|
||
base_side: int = STATUSBAR_BASE_SIDE,
|
||
target_fill: float = STATUSBAR_TARGET_FILL,
|
||
) -> bytes | None:
|
||
"""
|
||
Normalize a PNG icon so that:
|
||
- transparent padding is trimmed (alpha bbox)
|
||
- the trimmed icon is centered on a square canvas with consistent fill ratio
|
||
|
||
This keeps visual icon sizes more consistent across different theme packs.
|
||
"""
|
||
if Image is None:
|
||
return None
|
||
if not _is_png_bytes(png_bytes):
|
||
return None
|
||
try:
|
||
im = Image.open(io.BytesIO(png_bytes)).convert("RGBA")
|
||
except Exception:
|
||
return None
|
||
|
||
bbox = im.getchannel("A").getbbox()
|
||
if bbox:
|
||
im = im.crop(bbox)
|
||
|
||
w, h = im.size
|
||
if w <= 0 or h <= 0:
|
||
return None
|
||
|
||
side = int(base_side) if isinstance(base_side, int) else STATUSBAR_BASE_SIDE
|
||
if side < 16 or side > 256:
|
||
side = STATUSBAR_BASE_SIDE
|
||
|
||
tf = float(target_fill)
|
||
if not (0.2 <= tf <= 0.98):
|
||
tf = STATUSBAR_TARGET_FILL
|
||
|
||
desired = max(1, int(round(side * tf)))
|
||
scale = desired / max(w, h)
|
||
|
||
new_w = max(1, int(round(w * scale)))
|
||
new_h = max(1, int(round(h * scale)))
|
||
|
||
if (new_w, new_h) != (w, h):
|
||
if hasattr(Image, "Resampling"):
|
||
resample_up = Image.Resampling.BICUBIC
|
||
resample_down = Image.Resampling.LANCZOS
|
||
else:
|
||
resample_up = Image.BICUBIC
|
||
resample_down = Image.LANCZOS
|
||
im = im.resize((new_w, new_h), resample=(resample_up if scale > 1 else resample_down))
|
||
|
||
canvas = Image.new("RGBA", (side, side), (0, 0, 0, 0))
|
||
ox = (side - im.size[0]) // 2
|
||
oy = (side - im.size[1]) // 2
|
||
canvas.paste(im, (ox, oy), im)
|
||
|
||
out = io.BytesIO()
|
||
canvas.save(out, format="PNG")
|
||
return out.getvalue()
|
||
|
||
|
||
def extract_battery_from_sprite_png(png_bytes: bytes) -> bytes | None:
|
||
"""
|
||
some Android themes store battery meter as a vertical sprite strip (very tall PNG).
|
||
We extract one representative frame (the most 'filled' one) into a normal-sized PNG.
|
||
|
||
Returns: cropped PNG bytes, or None if extraction is not possible.
|
||
"""
|
||
if Image is None:
|
||
return None
|
||
if not _is_png_bytes(png_bytes):
|
||
return None
|
||
try:
|
||
im = Image.open(io.BytesIO(png_bytes)).convert("RGBA")
|
||
except Exception:
|
||
return None
|
||
|
||
w, h = im.size
|
||
# Already icon-like -> keep as-is.
|
||
if h <= 256 and w <= 256 and h <= w * 4:
|
||
return png_bytes
|
||
|
||
alpha = im.getchannel("A")
|
||
pix = alpha.load()
|
||
|
||
# For each row: any non-transparent pixel?
|
||
has = [False] * h
|
||
for y in range(h):
|
||
any_nz = False
|
||
for x in range(w):
|
||
if pix[x, y] != 0:
|
||
any_nz = True
|
||
break
|
||
has[y] = any_nz
|
||
|
||
# Find contiguous content runs (candidate frames)
|
||
runs: list[tuple[int, int]] = []
|
||
y = 0
|
||
while y < h:
|
||
if not has[y]:
|
||
y += 1
|
||
continue
|
||
start = y
|
||
while y < h and has[y]:
|
||
y += 1
|
||
end = y # exclusive
|
||
if end - start >= 2:
|
||
runs.append((start, end))
|
||
|
||
if len(runs) <= 1:
|
||
return None
|
||
|
||
# Choose run with max non-transparent pixels (most filled)
|
||
best = None
|
||
best_score = -1
|
||
for start, end in runs:
|
||
crop_alpha = alpha.crop((0, start, w, end))
|
||
hist = crop_alpha.histogram()
|
||
# histogram[0] is count of zero-alpha pixels
|
||
nonzero = (end - start) * w - (hist[0] if hist else 0)
|
||
if nonzero > best_score:
|
||
best_score = nonzero
|
||
best = (start, end)
|
||
|
||
if not best:
|
||
return None
|
||
|
||
start, end = best
|
||
region = im.crop((0, start, w, end))
|
||
bbox = region.getchannel("A").getbbox()
|
||
if bbox:
|
||
region = region.crop(bbox)
|
||
|
||
# Sanity check: avoid still-sprite outputs
|
||
rw, rh = region.size
|
||
if rw <= 0 or rh <= 0:
|
||
return None
|
||
if rh > 256 or rw > 256:
|
||
return None
|
||
if rh > rw * 4:
|
||
return None
|
||
|
||
out = io.BytesIO()
|
||
region.save(out, format="PNG")
|
||
return out.getvalue()
|
||
|
||
|
||
def extract_battery_frames_from_sprite_png(png_bytes: bytes) -> list[bytes]:
|
||
"""
|
||
Extract ALL battery frames from a vertical sprite strip.
|
||
Returns normalized (BATTERY_BASE_SIDE x BATTERY_BASE_SIDE) PNG bytes in low->high order.
|
||
"""
|
||
if Image is None:
|
||
return []
|
||
if not _is_png_bytes(png_bytes):
|
||
return []
|
||
try:
|
||
im = Image.open(io.BytesIO(png_bytes)).convert("RGBA")
|
||
except Exception:
|
||
return []
|
||
|
||
w, h = im.size
|
||
# Single icon
|
||
if h <= 256 and w <= 256 and h <= w * 4:
|
||
normalized = normalize_png_icon_to_square(png_bytes, base_side=BATTERY_BASE_SIDE)
|
||
return [normalized or png_bytes]
|
||
|
||
alpha = im.getchannel("A")
|
||
pix = alpha.load()
|
||
|
||
has = [False] * h
|
||
for y in range(h):
|
||
any_nz = False
|
||
for x in range(w):
|
||
if pix[x, y] != 0:
|
||
any_nz = True
|
||
break
|
||
has[y] = any_nz
|
||
|
||
runs: list[tuple[int, int]] = []
|
||
y = 0
|
||
while y < h:
|
||
if not has[y]:
|
||
y += 1
|
||
continue
|
||
start = y
|
||
while y < h and has[y]:
|
||
y += 1
|
||
end = y
|
||
if end - start >= 2:
|
||
runs.append((start, end))
|
||
|
||
if not runs:
|
||
return []
|
||
|
||
frames: list[bytes] = []
|
||
scores: list[int] = []
|
||
for start, end in runs:
|
||
region = im.crop((0, start, w, end))
|
||
bbox = region.getchannel("A").getbbox()
|
||
if bbox:
|
||
region = region.crop(bbox)
|
||
|
||
raw = io.BytesIO()
|
||
region.save(raw, format="PNG")
|
||
raw_bytes = raw.getvalue()
|
||
|
||
normalized = normalize_png_icon_to_square(raw_bytes, base_side=BATTERY_BASE_SIDE)
|
||
out_bytes = normalized or raw_bytes
|
||
frames.append(out_bytes)
|
||
|
||
# Score for orientation: non-transparent alpha pixels
|
||
try:
|
||
rim = Image.open(io.BytesIO(out_bytes)).convert("RGBA")
|
||
a = rim.getchannel("A")
|
||
hist = a.histogram()
|
||
nonzero = rim.size[0] * rim.size[1] - (hist[0] if hist else 0)
|
||
scores.append(int(nonzero))
|
||
except Exception:
|
||
scores.append(0)
|
||
|
||
if not frames:
|
||
return []
|
||
|
||
# Most themes store low->high from top to bottom; ensure "full" is at the end.
|
||
max_i = max(range(len(scores)), key=lambda i: scores[i]) if scores else (len(frames) - 1)
|
||
if max_i < len(frames) // 2:
|
||
frames = list(reversed(frames))
|
||
|
||
return frames
|
||
|
||
|
||
def extract_statusbar_battery_icon(zf: zipfile.ZipFile, entry_name: str, dst: Path) -> bool:
|
||
"""
|
||
Extract a battery icon for our simulator. For tall sprite sheets, crop one frame.
|
||
"""
|
||
try:
|
||
data = zf.read(entry_name)
|
||
except KeyError:
|
||
return False
|
||
if not _is_png_bytes(data):
|
||
return False
|
||
|
||
cropped = extract_battery_from_sprite_png(data)
|
||
if not cropped:
|
||
# If we can't crop, skip battery to avoid rendering a thin line.
|
||
return False
|
||
|
||
ensure_dir(dst.parent)
|
||
normalized = normalize_png_icon_to_square(cropped, base_side=BATTERY_BASE_SIDE)
|
||
dst.write_bytes(normalized or cropped)
|
||
return True
|
||
|
||
|
||
def extract_statusbar_battery_assets(zf: zipfile.ZipFile, entry_name: str, out_dir: Path) -> dict | None:
|
||
"""
|
||
Export battery assets in a "real-device-like" way:
|
||
- statusbar/battery.png (full / representative frame)
|
||
- statusbar/battery_sprite.png (vertical strip of normalized frames)
|
||
Returns metadata for manifest: frames/sprite name/frame side.
|
||
"""
|
||
if Image is None:
|
||
return None
|
||
try:
|
||
data = zf.read(entry_name)
|
||
except KeyError:
|
||
return None
|
||
if not _is_png_bytes(data):
|
||
return None
|
||
|
||
frames = extract_battery_frames_from_sprite_png(data)
|
||
if not frames:
|
||
# Fallback: single-frame battery PNG (sprite extraction failed). We still
|
||
# advertise it as a 1-frame "sprite" so the front-end mask-image path
|
||
# tints it with state colors instead of routing to a separate <img>
|
||
# branch that ignores charging/saver state.
|
||
ok = extract_statusbar_battery_icon(zf, entry_name, out_dir / "battery.png")
|
||
return (
|
||
{"batteryFrames": 1, "batterySprite": "battery.png", "batteryFrameSide": BATTERY_BASE_SIDE}
|
||
if ok else None
|
||
)
|
||
|
||
ensure_dir(out_dir)
|
||
|
||
# battery.png = full (last frame)
|
||
(out_dir / "battery.png").write_bytes(frames[-1])
|
||
|
||
# battery_sprite.png = stacked frames
|
||
side = BATTERY_BASE_SIDE
|
||
sprite = Image.new("RGBA", (side, side * len(frames)), (0, 0, 0, 0))
|
||
for i, fb in enumerate(frames):
|
||
try:
|
||
fr = Image.open(io.BytesIO(fb)).convert("RGBA")
|
||
except Exception:
|
||
continue
|
||
if fr.size != (side, side):
|
||
fr = fr.resize((side, side))
|
||
sprite.paste(fr, (0, i * side), fr)
|
||
|
||
out = io.BytesIO()
|
||
sprite.save(out, format="PNG")
|
||
(out_dir / "battery_sprite.png").write_bytes(out.getvalue())
|
||
|
||
return {"batteryFrames": len(frames), "batterySprite": "battery_sprite.png", "batteryFrameSide": side}
|
||
|
||
|
||
def extract_statusbar_battery_variant_assets(
|
||
zf: zipfile.ZipFile,
|
||
entry_name: str,
|
||
out_dir: Path,
|
||
*,
|
||
prefix: str,
|
||
manifest_prefix: str,
|
||
) -> dict | None:
|
||
"""
|
||
Export a battery sprite variant (e.g. power-save style) using the same format as normal battery.
|
||
Writes:
|
||
- {prefix}.png
|
||
- {prefix}_sprite.png
|
||
Returns manifest fields:
|
||
- {manifest_prefix}Frames / {manifest_prefix}Sprite / {manifest_prefix}FrameSide
|
||
"""
|
||
if Image is None:
|
||
return None
|
||
try:
|
||
data = zf.read(entry_name)
|
||
except KeyError:
|
||
return None
|
||
if not _is_png_bytes(data):
|
||
return None
|
||
|
||
frames = extract_battery_frames_from_sprite_png(data)
|
||
if not frames:
|
||
return None
|
||
|
||
ensure_dir(out_dir)
|
||
|
||
# {prefix}.png = full (last frame)
|
||
(out_dir / f"{prefix}.png").write_bytes(frames[-1])
|
||
|
||
# {prefix}_sprite.png = stacked frames
|
||
side = BATTERY_BASE_SIDE
|
||
sprite = Image.new("RGBA", (side, side * len(frames)), (0, 0, 0, 0))
|
||
for i, fb in enumerate(frames):
|
||
try:
|
||
fr = Image.open(io.BytesIO(fb)).convert("RGBA")
|
||
except Exception:
|
||
continue
|
||
if fr.size != (side, side):
|
||
fr = fr.resize((side, side))
|
||
sprite.paste(fr, (0, i * side), fr)
|
||
|
||
out = io.BytesIO()
|
||
sprite.save(out, format="PNG")
|
||
sprite_name = f"{prefix}_sprite.png"
|
||
(out_dir / sprite_name).write_bytes(out.getvalue())
|
||
|
||
return {
|
||
f"{manifest_prefix}Frames": len(frames),
|
||
f"{manifest_prefix}Sprite": sprite_name,
|
||
f"{manifest_prefix}FrameSide": side,
|
||
}
|
||
|
||
|
||
def _png_pixel_identical(a_path: Path, b_path: Path) -> bool:
|
||
"""Return True iff two PNG files have the same RGBA pixel data."""
|
||
if Image is None or not a_path.exists() or not b_path.exists():
|
||
return False
|
||
try:
|
||
a = Image.open(a_path).convert("RGBA")
|
||
b = Image.open(b_path).convert("RGBA")
|
||
except Exception:
|
||
return False
|
||
if a.size != b.size:
|
||
return False
|
||
return a.tobytes() == b.tobytes()
|
||
|
||
|
||
def _png_has_visible_alpha(png_bytes: bytes, *, min_pixels: int = 50) -> bool:
|
||
"""Reject empty 1×N placeholder PNGs (some Android themes ship these for
|
||
`stat_sys_battery_charging.png` / `stat_sys_quick_charging.png`)."""
|
||
if Image is None or not _is_png_bytes(png_bytes):
|
||
return False
|
||
try:
|
||
im = Image.open(io.BytesIO(png_bytes)).convert("RGBA")
|
||
except Exception:
|
||
return False
|
||
w, h = im.size
|
||
if w < 4 or h < 4:
|
||
return False
|
||
visible = sum(1 for px in im.getdata() if px[3] > 50)
|
||
return visible >= min_pixels
|
||
|
||
|
||
def extract_battery_bolt_from_charge_png(png_bytes: bytes) -> bytes | None:
|
||
"""
|
||
Isolate the charging bolt from a `stat_sys_battery_charge.png` from some Android themes
|
||
sprite sheet.
|
||
|
||
The source PNG encodes battery body + bolt in DIFFERENT RGB colors:
|
||
- Body fill is rendered in a saturated color (e.g. green ~ (0,192,64))
|
||
- Bolt + outline is rendered in light gray (~ (224,224,224))
|
||
|
||
The bolt position is fixed across all frames (it doesn't move with the
|
||
fill level), so we collapse all frames into a single static overlay by
|
||
taking the alpha-max of the gray-only mask across every frame.
|
||
|
||
Returns a normalized BATTERY_BASE_SIDE × BATTERY_BASE_SIDE PNG, or None
|
||
if extraction failed.
|
||
"""
|
||
if Image is None or not _is_png_bytes(png_bytes):
|
||
return None
|
||
try:
|
||
im = Image.open(io.BytesIO(png_bytes)).convert("RGBA")
|
||
except Exception:
|
||
return None
|
||
|
||
W, H = im.size
|
||
if H <= W:
|
||
# Probably a single-frame placeholder, not a sprite sheet.
|
||
return None
|
||
|
||
alpha = im.getchannel("A")
|
||
runs: list[tuple[int, int]] = []
|
||
y = 0
|
||
while y < H:
|
||
# Quick row-has-pixel test
|
||
row_alpha = [alpha.getpixel((x, y)) for x in range(W)]
|
||
if all(a == 0 for a in row_alpha):
|
||
y += 1
|
||
continue
|
||
s = y
|
||
while y < H:
|
||
row_alpha = [alpha.getpixel((x, y)) for x in range(W)]
|
||
if all(a == 0 for a in row_alpha):
|
||
break
|
||
y += 1
|
||
runs.append((s, y))
|
||
|
||
if not runs:
|
||
return None
|
||
|
||
# Find the body color (most common saturated RGB) and bolt color (most
|
||
# common low-saturation/light RGB) by sampling a mid-fill frame.
|
||
sample_idx = min(len(runs) // 2, len(runs) - 1)
|
||
s, e = runs[sample_idx]
|
||
sample = im.crop((0, s, W, e))
|
||
from collections import Counter
|
||
body_votes: Counter = Counter()
|
||
bolt_votes: Counter = Counter()
|
||
for r, g, b, a in sample.getdata():
|
||
if a < 80:
|
||
continue
|
||
# Bolt = roughly neutral light (R≈G≈B and bright)
|
||
if min(r, g, b) > 192 and max(r, g, b) - min(r, g, b) < 32:
|
||
bolt_votes[(r // 16 * 16, g // 16 * 16, b // 16 * 16)] += 1
|
||
else:
|
||
body_votes[(r // 16 * 16, g // 16 * 16, b // 16 * 16)] += 1
|
||
|
||
if not bolt_votes:
|
||
return None
|
||
|
||
# Build per-frame bolt mask, take alpha-max across all frames.
|
||
union_mask = Image.new("L", (W, runs[0][1] - runs[0][0]), 0)
|
||
pixels = im.load()
|
||
for s, e in runs:
|
||
fh = e - s
|
||
if union_mask.size != (W, fh):
|
||
# Pad/crop to common frame size
|
||
new_union = Image.new("L", (W, fh), 0)
|
||
new_union.paste(union_mask)
|
||
union_mask = new_union
|
||
for y in range(fh):
|
||
for x in range(W):
|
||
r, g, b, a = pixels[x, s + y]
|
||
if a < 80:
|
||
continue
|
||
# Bolt criterion: near-neutral light pixel.
|
||
if min(r, g, b) > 192 and max(r, g, b) - min(r, g, b) < 32:
|
||
if a > union_mask.getpixel((x, y)):
|
||
union_mask.putpixel((x, y), a)
|
||
|
||
# Crop to bolt bbox and normalize to a square frame
|
||
bbox = union_mask.getbbox()
|
||
if not bbox:
|
||
return None
|
||
cropped_alpha = union_mask.crop(bbox)
|
||
bw, bh = cropped_alpha.size
|
||
|
||
# Composite into a transparent RGBA where the bolt is white (mask-image
|
||
# rendering ignores RGB anyway, only alpha matters).
|
||
bolt_rgba = Image.new("RGBA", (bw, bh), (255, 255, 255, 0))
|
||
bolt_alpha = cropped_alpha
|
||
bolt_rgba.putalpha(bolt_alpha)
|
||
|
||
# Center on a transparent BATTERY_BASE_SIDE square. We want the bolt to
|
||
# land where it sits on the source frame, but our normal battery sprite is
|
||
# also center-cropped/normalized — so center-align the bolt within the
|
||
# frame at the same scale ratio as the source frame side.
|
||
src_frame_side = max(runs[0][1] - runs[0][0], W)
|
||
scale = BATTERY_BASE_SIDE / src_frame_side
|
||
new_size = (max(int(round(bw * scale)), 1), max(int(round(bh * scale)), 1))
|
||
bolt_rgba = bolt_rgba.resize(new_size, Image.LANCZOS)
|
||
|
||
canvas = Image.new("RGBA", (BATTERY_BASE_SIDE, BATTERY_BASE_SIDE), (0, 0, 0, 0))
|
||
# Position: original bbox center mapped to canvas center
|
||
src_cx = (bbox[0] + bbox[2]) / 2
|
||
src_cy = (bbox[1] + bbox[3]) / 2
|
||
src_w = W
|
||
src_h = runs[0][1] - runs[0][0]
|
||
cx = src_cx / src_w * BATTERY_BASE_SIDE
|
||
cy = src_cy / src_h * BATTERY_BASE_SIDE
|
||
paste_x = int(round(cx - new_size[0] / 2))
|
||
paste_y = int(round(cy - new_size[1] / 2))
|
||
canvas.alpha_composite(bolt_rgba, (paste_x, paste_y))
|
||
|
||
out = io.BytesIO()
|
||
canvas.save(out, format="PNG")
|
||
return out.getvalue()
|
||
|
||
|
||
def extract_statusbar_icon_normalized(zf: zipfile.ZipFile, entry_name: str, dst: Path) -> bool:
|
||
"""
|
||
Extract and normalize a regular statusbar icon (wifi/signal/bluetooth).
|
||
"""
|
||
try:
|
||
data = zf.read(entry_name)
|
||
except KeyError:
|
||
return False
|
||
if not _is_png_bytes(data):
|
||
return False
|
||
if _is_fully_transparent_png_bytes(data):
|
||
return False
|
||
normalized = normalize_png_icon_to_square(data)
|
||
ensure_dir(dst.parent)
|
||
dst.write_bytes(normalized or data)
|
||
return True
|
||
|
||
|
||
def extract_statusbar_icon_normalized_to_square(
|
||
zf: zipfile.ZipFile,
|
||
entry_name: str,
|
||
dst: Path,
|
||
*,
|
||
base_side: int,
|
||
) -> bool:
|
||
"""
|
||
Like `extract_statusbar_icon_normalized`, but allows custom square side.
|
||
Useful for overlays that must align with battery sprite.
|
||
"""
|
||
try:
|
||
data = zf.read(entry_name)
|
||
except KeyError:
|
||
return False
|
||
if not _is_png_bytes(data):
|
||
return False
|
||
if _is_fully_transparent_png_bytes(data):
|
||
return False
|
||
normalized = normalize_png_icon_to_square(data, base_side=base_side)
|
||
ensure_dir(dst.parent)
|
||
dst.write_bytes(normalized or data)
|
||
return True
|
||
|
||
|
||
def best_path_for_basename(
|
||
all_paths: list[str],
|
||
basenames: list[str],
|
||
*,
|
||
prefer_nightmode: Optional[bool] = None,
|
||
prefer_res_root: bool = True,
|
||
avoid_ninepatch: bool = False,
|
||
) -> Optional[str]:
|
||
"""
|
||
Choose best matching zip entry path for the first matching basename list.
|
||
"""
|
||
want = set(basenames)
|
||
candidates = [p for p in all_paths if Path(p).name in want and _is_png(p)]
|
||
if avoid_ninepatch:
|
||
candidates = [p for p in candidates if not p.lower().endswith(".9.png")]
|
||
if not candidates:
|
||
return None
|
||
|
||
def score(p: str) -> tuple[int, int, int, int]:
|
||
lower = p.lower()
|
||
s = 0
|
||
# Prefer `res/...` (non-nightmode) or `nightmode/res/...` depending on context
|
||
if prefer_nightmode is True and lower.startswith("nightmode/"):
|
||
s += 50
|
||
if prefer_nightmode is False and not lower.startswith("nightmode/"):
|
||
s += 50
|
||
if prefer_res_root and lower.startswith("res/"):
|
||
s += 10
|
||
# Density
|
||
s += _density_score(p) * 5
|
||
# Avoid _darkmode variants when we are going to tint anyway
|
||
if "_darkmode" in lower:
|
||
s -= 2
|
||
# Prefer drawable over raw unless explicitly targeting raw
|
||
if "/drawable" in lower:
|
||
s += 1
|
||
return (s, _density_score(p), -len(p), 0)
|
||
|
||
candidates.sort(key=score, reverse=True)
|
||
return candidates[0]
|
||
|
||
|
||
def find_statusbar_misc_icons(pngs: list[str]) -> dict[str, str]:
|
||
"""
|
||
Find common statusbar "extra" icons with stable canonical names.
|
||
Returned keys are our output basenames (without .png).
|
||
"""
|
||
mapping: dict[str, list[str]] = {
|
||
# Flight / SIM / connectivity
|
||
"airplane": [
|
||
"stat_sys_signal_flightmode.png",
|
||
"stat_sys_signal_flightmode_darkmode.png",
|
||
"stat_sys_airplane_mode.png",
|
||
"stat_sys_airplane_mode_darkmode.png",
|
||
],
|
||
"no_sim": ["stat_sys_no_sim.png", "stat_sys_no_sim_darkmode.png"],
|
||
"vpn": ["stat_sys_vpn.png", "stat_sys_vpn_darkmode.png"],
|
||
# Alerts
|
||
"alarm": ["stat_sys_alarm.png", "stat_sys_alarm_darkmode.png"],
|
||
"silent": ["stat_sys_ringer_silent.png", "stat_sys_ringer_silent_darkmode.png"],
|
||
# Accessories
|
||
"headset": [
|
||
"stat_sys_headset.png",
|
||
"stat_sys_headset_darkmode.png",
|
||
"stat_sys_headset_without_mic.png",
|
||
"stat_sys_headset_without_mic_darkmode.png",
|
||
],
|
||
# Misc toggles
|
||
"nfc": [
|
||
"stat_sys_nfc.png",
|
||
"stat_sys_nfc_darkmode.png",
|
||
"stat_sys_data_nfc.png",
|
||
"stat_sys_data_nfc_darkmode.png",
|
||
],
|
||
# NOTE: quick_charging intentionally omitted — in some Android theme bundles
|
||
# the file is typically a 1×60 empty placeholder (the charging bolt is
|
||
# baked into the `stat_sys_battery_charge` sprite, with color applied at
|
||
# runtime). We render fast-charging via a tint in SystemShell.
|
||
# Null / no-service variants
|
||
"signal_null": [
|
||
"stat_sys_signal_null.png",
|
||
"stat_sys_signal_null_darkmode.png",
|
||
"stat_sys_signal_null_half.png",
|
||
"stat_sys_signal_null_half_darkmode.png",
|
||
],
|
||
"wifi_null": [
|
||
"stat_sys_wifi_signal_null.png",
|
||
"stat_sys_wifi_signal_null_darkmode.png",
|
||
],
|
||
}
|
||
|
||
out: dict[str, str] = {}
|
||
for key, basenames in mapping.items():
|
||
p = best_path_for_basename(pngs, basenames, prefer_nightmode=False)
|
||
if p:
|
||
out[key] = p
|
||
return out
|
||
|
||
|
||
def find_statusbar_data_type_icons(pngs: list[str]) -> dict[str, str]:
|
||
"""
|
||
Find mobile data type icons (4G/5G/LTE/3G/E...), returned as {type: entry_path}.
|
||
We export them as `data_{type}.png`.
|
||
"""
|
||
mapping: dict[str, list[str]] = {
|
||
"5g": ["stat_sys_signal_5g.png", "stat_sys_data_5g.png", "stat_sys_5g.png"],
|
||
"4g": ["stat_sys_signal_4g.png", "stat_sys_data_4g.png", "stat_sys_4g.png"],
|
||
"4g_lte": ["stat_sys_signal_4g_lte.png", "stat_sys_data_4g_lte.png"],
|
||
"lte": ["stat_sys_signal_lte.png", "stat_sys_data_lte.png", "stat_sys_lte.png"],
|
||
"3g": ["stat_sys_signal_3g.png", "stat_sys_data_3g.png", "stat_sys_3g.png"],
|
||
"e": ["stat_sys_signal_e.png", "stat_sys_data_e.png", "stat_sys_e.png"],
|
||
}
|
||
|
||
out: dict[str, str] = {}
|
||
for t, basenames in mapping.items():
|
||
p = best_path_for_basename(pngs, basenames, prefer_nightmode=False)
|
||
if p:
|
||
out[t] = p
|
||
return out
|
||
|
||
|
||
def find_statusbar_icons(zf: zipfile.ZipFile) -> dict[str, str]:
|
||
names = zf.namelist()
|
||
pngs = [n for n in names if _is_png(n)]
|
||
|
||
# Prefer non-nightmode for status bar; we tint icons in runtime anyway.
|
||
out: dict[str, str] = {}
|
||
|
||
wifi = best_path_for_basename(
|
||
pngs,
|
||
["stat_sys_wifi_signal_4.png", "stat_sys_wifi_signal_3.png", "stat_sys_wifi_signal_2.png"],
|
||
prefer_nightmode=False,
|
||
)
|
||
if wifi:
|
||
out["wifi"] = wifi
|
||
|
||
signal = best_path_for_basename(
|
||
pngs,
|
||
["stat_sys_signal_4.png", "stat_sys_signal_5.png", "stat_sys_signal_3.png"],
|
||
prefer_nightmode=False,
|
||
)
|
||
if signal:
|
||
out["signal"] = signal
|
||
|
||
bt = best_path_for_basename(
|
||
pngs,
|
||
["stat_sys_data_bluetooth.png", "stat_sys_data_bluetooth_connected.png"],
|
||
prefer_nightmode=False,
|
||
)
|
||
if bt:
|
||
out["bluetooth"] = bt
|
||
|
||
# Battery: many Android themes use `res/raw-*/stat_sys_battery.png`
|
||
battery = best_path_for_basename(
|
||
pngs,
|
||
[
|
||
"stat_sys_battery_100.png",
|
||
"stat_sys_battery_full.png",
|
||
"stat_sys_battery.png",
|
||
],
|
||
prefer_nightmode=False,
|
||
)
|
||
if battery:
|
||
out["battery"] = battery
|
||
|
||
return out
|
||
|
||
|
||
def extract_statusbar_wifi_levels(zf: zipfile.ZipFile, out_dir: Path) -> int:
|
||
"""
|
||
Export Wi-Fi signal icons by level: wifi_0..4.png (+ wifi.png alias).
|
||
Returns number of levels exported (0 if not possible).
|
||
"""
|
||
names = zf.namelist()
|
||
pngs = [n for n in names if _is_png(n)]
|
||
levels = [0, 1, 2, 3, 4]
|
||
|
||
found: dict[int, str] = {}
|
||
for lvl in levels:
|
||
entry = best_path_for_basename(
|
||
pngs,
|
||
[f"stat_sys_wifi_signal_{lvl}.png", f"stat_sys_wifi_signal_{lvl}_darkmode.png"],
|
||
prefer_nightmode=False,
|
||
)
|
||
if entry:
|
||
found[lvl] = entry
|
||
|
||
# Fallback: use the highest available level
|
||
fallback = None
|
||
for lvl in reversed(levels):
|
||
p = found.get(lvl)
|
||
if not p:
|
||
continue
|
||
try:
|
||
if not _is_fully_transparent_png_bytes(zf.read(p)):
|
||
fallback = p
|
||
break
|
||
except Exception:
|
||
continue
|
||
if not fallback:
|
||
# any non-transparent
|
||
for p in found.values():
|
||
try:
|
||
if not _is_fully_transparent_png_bytes(zf.read(p)):
|
||
fallback = p
|
||
break
|
||
except Exception:
|
||
continue
|
||
if not fallback:
|
||
return 0
|
||
|
||
ensure_dir(out_dir)
|
||
for lvl in levels:
|
||
entry = found.get(lvl)
|
||
if entry:
|
||
ok = extract_statusbar_icon_normalized(zf, entry, out_dir / f"wifi_{lvl}.png")
|
||
if not ok:
|
||
ok = extract_statusbar_icon_normalized(zf, fallback, out_dir / f"wifi_{lvl}.png")
|
||
else:
|
||
ok = extract_statusbar_icon_normalized(zf, fallback, out_dir / f"wifi_{lvl}.png")
|
||
if not ok:
|
||
return 0
|
||
|
||
# wifi.png = full level
|
||
try:
|
||
shutil.copy2(out_dir / "wifi_4.png", out_dir / "wifi.png")
|
||
except Exception:
|
||
pass
|
||
return len(levels)
|
||
|
||
|
||
def extract_statusbar_signal_levels(zf: zipfile.ZipFile, out_dir: Path) -> int:
|
||
"""
|
||
Export cellular signal icons by level: signal_0..5.png (+ signal.png alias).
|
||
Returns number of levels exported (0 if not possible).
|
||
"""
|
||
names = zf.namelist()
|
||
pngs = [n for n in names if _is_png(n)]
|
||
levels = [0, 1, 2, 3, 4, 5]
|
||
|
||
found: dict[int, str] = {}
|
||
for lvl in levels:
|
||
entry = best_path_for_basename(
|
||
pngs,
|
||
[f"stat_sys_signal_{lvl}.png", f"stat_sys_signal_{lvl}_darkmode.png"],
|
||
prefer_nightmode=False,
|
||
)
|
||
if entry:
|
||
found[lvl] = entry
|
||
|
||
fallback = None
|
||
for lvl in reversed(levels):
|
||
p = found.get(lvl)
|
||
if not p:
|
||
continue
|
||
try:
|
||
if not _is_fully_transparent_png_bytes(zf.read(p)):
|
||
fallback = p
|
||
break
|
||
except Exception:
|
||
continue
|
||
if not fallback:
|
||
for p in found.values():
|
||
try:
|
||
if not _is_fully_transparent_png_bytes(zf.read(p)):
|
||
fallback = p
|
||
break
|
||
except Exception:
|
||
continue
|
||
if not fallback:
|
||
return 0
|
||
|
||
ensure_dir(out_dir)
|
||
for lvl in levels:
|
||
entry = found.get(lvl)
|
||
if entry:
|
||
ok = extract_statusbar_icon_normalized(zf, entry, out_dir / f"signal_{lvl}.png")
|
||
if not ok:
|
||
ok = extract_statusbar_icon_normalized(zf, fallback, out_dir / f"signal_{lvl}.png")
|
||
else:
|
||
ok = extract_statusbar_icon_normalized(zf, fallback, out_dir / f"signal_{lvl}.png")
|
||
if not ok:
|
||
return 0
|
||
|
||
# signal.png = full level (prefer 5)
|
||
try:
|
||
src = out_dir / ("signal_5.png" if (out_dir / "signal_5.png").exists() else "signal_4.png")
|
||
shutil.copy2(src, out_dir / "signal.png")
|
||
except Exception:
|
||
pass
|
||
return len(levels)
|
||
|
||
|
||
def find_shade_backgrounds(zf: zipfile.ZipFile) -> dict[str, str]:
|
||
names = zf.namelist()
|
||
pngs = [n for n in names if _is_png(n)]
|
||
|
||
# Control center in this project is dark-blur; prefer nightmode assets.
|
||
active = best_path_for_basename(
|
||
pngs,
|
||
[
|
||
"ic_cc_qs_bg_active_normal.png",
|
||
"qs_tile_bg_on.png",
|
||
"qs_background_enabled.png",
|
||
"ic_qs_bg_enabled.png",
|
||
],
|
||
prefer_nightmode=True,
|
||
avoid_ninepatch=True,
|
||
)
|
||
inactive = best_path_for_basename(
|
||
pngs,
|
||
[
|
||
"ic_cc_qs_bg_inactive.png",
|
||
"qs_tile_bg_off.png",
|
||
"qs_background_disabled.png",
|
||
"ic_qs_bg_disabled.png",
|
||
],
|
||
prefer_nightmode=True,
|
||
avoid_ninepatch=True,
|
||
)
|
||
out: dict[str, str] = {}
|
||
if active:
|
||
out["active"] = active
|
||
if inactive:
|
||
out["inactive"] = inactive
|
||
return out
|
||
|
||
|
||
def extract_app_icons_from_icons_mrc(
|
||
mrc_path: Path,
|
||
out_dir: Path,
|
||
package_names: list[str],
|
||
) -> list[str]:
|
||
if not mrc_path.exists():
|
||
return []
|
||
extracted: list[str] = []
|
||
with zipfile.ZipFile(mrc_path, "r") as zf:
|
||
names = [n for n in zf.namelist() if _is_png(n)]
|
||
# Build basename->best path mapping once
|
||
best_by_key: dict[str, str] = {}
|
||
for n in names:
|
||
if not n.startswith("res/") and not n.startswith("nightmode/"):
|
||
continue
|
||
base = Path(n).name
|
||
if not base.lower().endswith(".png"):
|
||
continue
|
||
key = base[:-4]
|
||
prev = best_by_key.get(key)
|
||
if prev is None:
|
||
best_by_key[key] = n
|
||
else:
|
||
if _density_score(n) > _density_score(prev):
|
||
best_by_key[key] = n
|
||
|
||
ensure_dir(out_dir)
|
||
for pkg in package_names:
|
||
entry = best_by_key.get(pkg)
|
||
if not entry:
|
||
# Fallback: common path
|
||
fallback = f"res/drawable-xxhdpi/{pkg}.png"
|
||
entry = fallback if fallback in names else None
|
||
if not entry:
|
||
continue
|
||
dst = out_dir / f"{pkg}.png"
|
||
if extract_zip_entry(zf, entry, dst):
|
||
extracted.append(pkg)
|
||
return extracted
|
||
|
||
|
||
def extract_selected_component_assets(
|
||
mrc_path: Path,
|
||
out_component_dir: Path,
|
||
asset_names: list[str],
|
||
*,
|
||
prefer_nightmode: bool,
|
||
) -> list[str]:
|
||
if not mrc_path.exists() or not asset_names:
|
||
return []
|
||
extracted: list[str] = []
|
||
with zipfile.ZipFile(mrc_path, "r") as zf:
|
||
names = zf.namelist()
|
||
pngs = [n for n in names if _is_png(n)]
|
||
|
||
for a in asset_names:
|
||
# Prefer exact drawable hit first (xxhdpi)
|
||
preferred_basenames = [f"{a}.png", f"{a}.9.png"]
|
||
chosen = best_path_for_basename(
|
||
pngs,
|
||
preferred_basenames,
|
||
prefer_nightmode=prefer_nightmode,
|
||
prefer_res_root=True,
|
||
)
|
||
if not chosen:
|
||
continue
|
||
dst = out_component_dir / chosen
|
||
if extract_zip_entry(zf, chosen, dst):
|
||
extracted.append(a)
|
||
return extracted
|
||
|
||
|
||
CLOCK_WIDGET_CODES = ["clock_2x4", "clock_3x4"]
|
||
CLOCK_SPAN: dict[str, tuple[int, int]] = {
|
||
"clock_2x4": (4, 2),
|
||
"clock_3x4": (4, 3),
|
||
}
|
||
VARIANT_SPEC: dict[str, tuple[int, int]] = {
|
||
"widget_2x2": (2, 2),
|
||
"widget_4x2": (4, 2),
|
||
"widget_3x4": (3, 4),
|
||
"widget_4x4": (4, 4),
|
||
}
|
||
|
||
|
||
def extract_clock_widget_mrc(mrc_path: Path, out_dir: Path) -> bool:
|
||
"""
|
||
Extract a clock widget .mrc (zip) into out_dir, preserving internal paths.
|
||
Returns True if at least one file was extracted.
|
||
"""
|
||
if not mrc_path.exists():
|
||
return False
|
||
try:
|
||
with zipfile.ZipFile(mrc_path, "r") as zf:
|
||
ensure_dir(out_dir)
|
||
count = 0
|
||
for info in zf.infolist():
|
||
if info.is_dir():
|
||
continue
|
||
dst = out_dir / info.filename
|
||
ensure_dir(dst.parent)
|
||
with zf.open(info, "r") as src, dst.open("wb") as out:
|
||
shutil.copyfileobj(src, out)
|
||
count += 1
|
||
return count > 0
|
||
except zipfile.BadZipFile:
|
||
return False
|
||
|
||
|
||
def extract_standalone_wmr_widget(
|
||
widget_dir: Path,
|
||
dest_root: Path,
|
||
) -> Optional[dict]:
|
||
"""
|
||
Extract a standalone WMR widget from external themes/maml_widget/<id>/ resources.
|
||
Returns a manifest entry dict, or None on failure.
|
||
"""
|
||
meta_path = widget_dir / "meta.json"
|
||
if not meta_path.exists():
|
||
return None
|
||
try:
|
||
meta = json.loads(meta_path.read_text(encoding="utf-8"))
|
||
except Exception:
|
||
return None
|
||
|
||
widget_id = meta.get("id") or widget_dir.name
|
||
title = pick_locale_field(meta, "titleMap") or meta.get("title") or widget_id
|
||
author = (
|
||
pick_locale_field(meta, "authorMap")
|
||
or pick_locale_field(meta, "designerMap")
|
||
or ""
|
||
)
|
||
description = pick_locale_field(meta, "descMap") or meta.get("desc") or ""
|
||
|
||
out_dir = dest_root / widget_id
|
||
if out_dir.exists():
|
||
shutil.rmtree(out_dir)
|
||
ensure_dir(out_dir)
|
||
|
||
# Copy previews
|
||
preview_src = widget_dir / "preview"
|
||
if preview_src.is_dir():
|
||
copy_tree(preview_src, out_dir / "preview")
|
||
|
||
# Extract each variant zip
|
||
variants: list[dict] = []
|
||
for variant_name, (sx, sy) in VARIANT_SPEC.items():
|
||
variant_file = widget_dir / variant_name
|
||
if not variant_file.exists():
|
||
continue
|
||
variant_out = out_dir / variant_name
|
||
try:
|
||
with zipfile.ZipFile(variant_file, "r") as zf:
|
||
ensure_dir(variant_out)
|
||
count = 0
|
||
for info in zf.infolist():
|
||
if info.is_dir():
|
||
continue
|
||
dst = variant_out / info.filename
|
||
ensure_dir(dst.parent)
|
||
with zf.open(info, "r") as src, dst.open("wb") as out_f:
|
||
shutil.copyfileobj(src, out_f)
|
||
count += 1
|
||
if count == 0:
|
||
continue
|
||
except zipfile.BadZipFile:
|
||
continue
|
||
|
||
# Preview image path (relative to widget root)
|
||
preview_file = f"preview/{variant_name}.png"
|
||
if not (out_dir / preview_file).exists():
|
||
preview_file = ""
|
||
|
||
variants.append({
|
||
"entry": variant_name,
|
||
"spanX": sx,
|
||
"spanY": sy,
|
||
"preview": preview_file,
|
||
})
|
||
|
||
if not variants:
|
||
# No usable variants; clean up
|
||
if out_dir.exists():
|
||
shutil.rmtree(out_dir)
|
||
return None
|
||
|
||
# Collect all preview filenames for the manifest entry
|
||
previews: list[str] = []
|
||
preview_out = out_dir / "preview"
|
||
if preview_out.is_dir():
|
||
previews = sorted(p.name for p in preview_out.iterdir() if p.is_file())
|
||
|
||
return {
|
||
"id": widget_id,
|
||
"title": title,
|
||
"author": author,
|
||
"description": description,
|
||
"type": "widget",
|
||
"previews": previews,
|
||
"variants": variants,
|
||
}
|
||
|
||
|
||
def extract_wallpaper(mrc_path: Path, out_dir: Path) -> Optional[str]:
|
||
if not mrc_path.exists():
|
||
return None
|
||
ensure_dir(out_dir)
|
||
# Wallpaper packages vary; best-effort:
|
||
try:
|
||
with zipfile.ZipFile(mrc_path, "r") as zf:
|
||
infos = [i for i in zf.infolist() if not i.is_dir()]
|
||
# prefer larger image files
|
||
candidates = [
|
||
i
|
||
for i in infos
|
||
if Path(i.filename).suffix.lower() in {".jpg", ".jpeg", ".png", ".webp"}
|
||
]
|
||
# Filter obvious thumbnails
|
||
filtered = []
|
||
for i in candidates:
|
||
n = i.filename.lower()
|
||
if "thumb" in n or "thumbnail" in n or "small" in n:
|
||
continue
|
||
filtered.append(i)
|
||
candidates = filtered or candidates
|
||
if not candidates:
|
||
return None
|
||
candidates.sort(key=lambda i: i.file_size, reverse=True)
|
||
pick = candidates[0]
|
||
ext = Path(pick.filename).suffix.lower().lstrip(".") or "jpg"
|
||
out_name = f"default.{ext}"
|
||
if extract_zip_entry(zf, pick.filename, out_dir / out_name):
|
||
return out_name
|
||
return None
|
||
except zipfile.BadZipFile:
|
||
# Some wallpaper .mrc are raw image bytes; detect common formats by signature.
|
||
data = mrc_path.read_bytes()
|
||
head = data[:16]
|
||
ext = "bin"
|
||
if head.startswith(b"\xff\xd8\xff"):
|
||
ext = "jpg"
|
||
elif head.startswith(b"\x89PNG\r\n\x1a\n"):
|
||
ext = "png"
|
||
elif len(head) >= 12 and head[:4] == b"RIFF" and head[8:12] == b"WEBP":
|
||
ext = "webp"
|
||
elif head.startswith(b"GIF87a") or head.startswith(b"GIF89a"):
|
||
ext = "gif"
|
||
out_name = f"default.{ext}"
|
||
(out_dir / out_name).write_bytes(data)
|
||
return out_name
|
||
|
||
|
||
def pick_locale_field(d: dict, key: str) -> str:
|
||
m = d.get(key)
|
||
if not isinstance(m, dict):
|
||
return ""
|
||
return str(m.get("zh_CN") or m.get("fallback") or (next(iter(m.values()), "")) or "")
|
||
|
||
|
||
def build_theme_entry(theme_meta: dict) -> dict:
|
||
theme_id = str(theme_meta.get("localId") or "")
|
||
title = pick_locale_field(theme_meta, "titles") or theme_id
|
||
author = pick_locale_field(theme_meta, "authors") or pick_locale_field(theme_meta, "designers") or ""
|
||
description = pick_locale_field(theme_meta, "descriptions") or ""
|
||
built_in_previews = []
|
||
bip = theme_meta.get("builtInPreviews") or {}
|
||
if isinstance(bip, dict):
|
||
v = bip.get("fallback")
|
||
if isinstance(v, list):
|
||
built_in_previews = [str(x) for x in v if x]
|
||
sub_resources = [
|
||
{"resourceCode": code, "localId": local_id}
|
||
for code, local_id in iter_subresources(theme_meta)
|
||
]
|
||
return {
|
||
"id": theme_id,
|
||
"title": title,
|
||
"author": author,
|
||
"description": description,
|
||
"price": theme_meta.get("price") if isinstance(theme_meta.get("price"), (int, float)) else None,
|
||
"version": str(theme_meta.get("version")) if theme_meta.get("version") is not None else None,
|
||
"assemblyId": str(theme_meta.get("assemblyId")) if theme_meta.get("assemblyId") else None,
|
||
"previews": built_in_previews,
|
||
"subResources": sub_resources,
|
||
}
|
||
|
||
|
||
def main() -> None:
|
||
parser = argparse.ArgumentParser()
|
||
parser.add_argument(
|
||
"--src",
|
||
default="themes",
|
||
help="Source themes directory (default: ./themes)",
|
||
)
|
||
parser.add_argument(
|
||
"--dest",
|
||
default="mobilegym-data/themes",
|
||
help="Destination directory (default: ./mobilegym-data/themes)",
|
||
)
|
||
parser.add_argument(
|
||
"--themes",
|
||
nargs="*",
|
||
default=DEFAULT_THEME_IDS,
|
||
help="Theme localIds to include (default: a curated set)",
|
||
)
|
||
parser.add_argument(
|
||
"--all-themes",
|
||
action="store_true",
|
||
help="Include ALL themes under themes/meta/theme (ignores --themes)",
|
||
)
|
||
parser.add_argument(
|
||
"--default-theme-id",
|
||
default=DEFAULT_DEFAULT_THEME_ID,
|
||
help="Default theme id written into manifest.json",
|
||
)
|
||
parser.add_argument(
|
||
"--no-fonts",
|
||
action="store_true",
|
||
help="Do NOT include fonts under themes/meta/fonts (preview only)",
|
||
)
|
||
parser.add_argument(
|
||
"--no-aod",
|
||
action="store_true",
|
||
help="Do NOT include AOD under themes/meta/aod (preview only)",
|
||
)
|
||
parser.add_argument(
|
||
"--no-widgets",
|
||
action="store_true",
|
||
help="Do NOT include standalone WMR widgets under themes/maml_widget",
|
||
)
|
||
parser.add_argument(
|
||
"--clean",
|
||
action="store_true",
|
||
help="Delete existing destination directory (e.g. mobilegym-data/themes) before writing",
|
||
)
|
||
args = parser.parse_args()
|
||
|
||
root = Path(__file__).resolve().parents[2]
|
||
src_root = (root / args.src).resolve()
|
||
dest_root = (root / args.dest).resolve()
|
||
|
||
if not src_root.exists():
|
||
raise SystemExit(f"Source not found: {src_root}")
|
||
if args.clean and dest_root.exists():
|
||
shutil.rmtree(dest_root)
|
||
ensure_dir(dest_root)
|
||
|
||
package_names = parse_app_package_names(root)
|
||
settings_icon_names = parse_settings_main_icon_names(root)
|
||
|
||
# Small sets used by other apps:
|
||
contact_asset_names = [f"dialpad_{d}" for d in list("0123456789")] + ["dialpad_*", "dialpad_#"] + [
|
||
f"key_{d}" for d in list("0123456789")
|
||
] + ["key_*", "key_#", "call_button"]
|
||
mms_asset_names = [
|
||
"bubble_out",
|
||
"bubble_send",
|
||
"msg_out",
|
||
"message_out",
|
||
"chat_to",
|
||
"sms_out",
|
||
"bubble_in",
|
||
"bubble_recv",
|
||
"msg_in",
|
||
"message_in",
|
||
"chat_from",
|
||
"sms_in",
|
||
]
|
||
|
||
# Source dirs
|
||
src_meta_theme = src_root / "meta/theme"
|
||
src_meta_fonts = src_root / "meta/fonts"
|
||
src_meta_aod = src_root / "meta/aod"
|
||
src_preview_theme = src_root / "preview/theme"
|
||
src_content_base = src_root / "content"
|
||
|
||
# Theme ids to process
|
||
if args.all_themes:
|
||
theme_ids = sorted([p.stem for p in src_meta_theme.glob("*.mrm")])
|
||
else:
|
||
theme_ids = list(args.themes)
|
||
|
||
missing: list[str] = []
|
||
themes_out: list[dict] = []
|
||
fonts_out: list[dict] = []
|
||
aod_out: list[dict] = []
|
||
clock_widgets_collected: list[dict] = []
|
||
skipped_wrappers: dict[str, list[str]] = {"fonts_only": [], "aod_only": []}
|
||
|
||
for theme_id in theme_ids:
|
||
theme_meta_path = src_meta_theme / f"{theme_id}.mrm"
|
||
if not theme_meta_path.exists():
|
||
missing.append(f"meta/theme/{theme_id}.mrm")
|
||
continue
|
||
|
||
meta = read_json(theme_meta_path)
|
||
|
||
# Some entries under meta/theme are actually "wrapper resources" for fonts/AOD only.
|
||
# When exporting all themes, we keep categories clean by excluding these from `themes`.
|
||
codes = sorted({code for code, _ in iter_subresources(meta)})
|
||
if codes == ["fonts"]:
|
||
skipped_wrappers["fonts_only"].append(theme_id)
|
||
continue
|
||
if codes == ["aod"]:
|
||
skipped_wrappers["aod_only"].append(theme_id)
|
||
continue
|
||
|
||
entry = build_theme_entry(meta)
|
||
entry["type"] = "full"
|
||
|
||
# Prepare destination dirs
|
||
theme_dir = dest_root / theme_id
|
||
preview_dir = theme_dir / "preview"
|
||
icons_dir = theme_dir / "icons"
|
||
statusbar_dir = theme_dir / "statusbar"
|
||
shade_dir = theme_dir / "shade"
|
||
wallpaper_dir = theme_dir / "wallpaper"
|
||
components_dir = theme_dir / "components"
|
||
|
||
# Fresh theme dir (avoid stale files when re-running without --clean)
|
||
if theme_dir.exists():
|
||
shutil.rmtree(theme_dir)
|
||
ensure_dir(theme_dir)
|
||
|
||
# Copy previews
|
||
copy_tree(src_preview_theme / theme_id, preview_dir)
|
||
|
||
# Subresources lookup
|
||
sub_map: dict[str, str] = {code: local_id for code, local_id in iter_subresources(meta)}
|
||
|
||
capabilities: list[str] = []
|
||
extracted: dict = {}
|
||
|
||
# ---- App icons ----
|
||
icons_local_id = sub_map.get("icons")
|
||
if icons_local_id:
|
||
capabilities.append("icons")
|
||
icons_mrc = src_content_base / "icons" / f"{icons_local_id}.mrc"
|
||
try:
|
||
extracted_pkgs = extract_app_icons_from_icons_mrc(icons_mrc, icons_dir, package_names)
|
||
except zipfile.BadZipFile:
|
||
extracted_pkgs = []
|
||
entry["iconPackageNames"] = extracted_pkgs
|
||
extracted["icons"] = bool(extracted_pkgs)
|
||
else:
|
||
entry["iconPackageNames"] = []
|
||
|
||
# ---- Status bar icons + shade backgrounds ----
|
||
status_local_id = sub_map.get("statusbar")
|
||
statusbar_icons: list[str] = []
|
||
statusbar_dynamic: dict[str, object] = {}
|
||
shade: dict[str, bool] = {"active": False, "inactive": False}
|
||
if status_local_id:
|
||
capabilities.append("statusbar")
|
||
status_mrc = src_content_base / "statusbar" / f"{status_local_id}.mrc"
|
||
try:
|
||
with zipfile.ZipFile(status_mrc, "r") as zf:
|
||
names = zf.namelist()
|
||
pngs = [n for n in names if _is_png(n)]
|
||
|
||
sb = find_statusbar_icons(zf)
|
||
# bluetooth (static)
|
||
if "bluetooth" in sb:
|
||
if extract_statusbar_icon_normalized(zf, sb["bluetooth"], statusbar_dir / "bluetooth.png"):
|
||
statusbar_icons.append("bluetooth")
|
||
|
||
# wifi levels (+ wifi.png alias)
|
||
wifi_levels = extract_statusbar_wifi_levels(zf, statusbar_dir)
|
||
if wifi_levels:
|
||
statusbar_icons.append("wifi")
|
||
statusbar_dynamic["wifiLevels"] = wifi_levels
|
||
elif "wifi" in sb:
|
||
if extract_statusbar_icon_normalized(zf, sb["wifi"], statusbar_dir / "wifi.png"):
|
||
statusbar_icons.append("wifi")
|
||
|
||
# signal levels (+ signal.png alias)
|
||
signal_levels = extract_statusbar_signal_levels(zf, statusbar_dir)
|
||
if signal_levels:
|
||
statusbar_icons.append("signal")
|
||
statusbar_dynamic["signalLevels"] = signal_levels
|
||
elif "signal" in sb:
|
||
if extract_statusbar_icon_normalized(zf, sb["signal"], statusbar_dir / "signal.png"):
|
||
statusbar_icons.append("signal")
|
||
|
||
# battery: export sprite + full frame
|
||
if "battery" in sb:
|
||
info = extract_statusbar_battery_assets(zf, sb["battery"], statusbar_dir)
|
||
if info:
|
||
statusbar_icons.append("battery")
|
||
# Only include non-null values in manifest
|
||
if isinstance(info.get("batteryFrames"), int):
|
||
statusbar_dynamic["batteryFrames"] = info["batteryFrames"]
|
||
if isinstance(info.get("batteryFrameSide"), int):
|
||
statusbar_dynamic["batteryFrameSide"] = info["batteryFrameSide"]
|
||
if isinstance(info.get("batterySprite"), str) and info["batterySprite"]:
|
||
statusbar_dynamic["batterySprite"] = info["batterySprite"]
|
||
|
||
# State variant sprites — preserve theme RGBA so the front-end
|
||
# can render the theme designer's chosen colors directly
|
||
# (charging green, saver yellow, etc.). Front-end falls back
|
||
# to runtime tint on the base sprite when a variant is missing.
|
||
charge_entry = best_path_for_basename(
|
||
pngs,
|
||
["stat_sys_battery_charge.png", "stat_sys_battery_charge_darkmode.png"],
|
||
prefer_nightmode=False,
|
||
)
|
||
if charge_entry:
|
||
ch_info = extract_statusbar_battery_variant_assets(
|
||
zf, charge_entry, statusbar_dir,
|
||
prefix="battery_charge", manifest_prefix="batteryCharge",
|
||
)
|
||
if ch_info:
|
||
statusbar_dynamic.update(ch_info)
|
||
|
||
power_save_entry = best_path_for_basename(
|
||
pngs,
|
||
["stat_sys_battery_power_save.png", "stat_sys_battery_power_save_darkmode.png"],
|
||
prefer_nightmode=False,
|
||
)
|
||
if power_save_entry:
|
||
ps_info = extract_statusbar_battery_variant_assets(
|
||
zf, power_save_entry, statusbar_dir,
|
||
prefix="battery_power_save", manifest_prefix="batteryPowerSave",
|
||
)
|
||
# Drop the variant if it's pixel-identical to the normal
|
||
# sprite (some themes ship the same PNG for both, with the
|
||
# color difference applied at runtime). The
|
||
# front-end's system-tint path will produce yellow itself.
|
||
if ps_info and _png_pixel_identical(
|
||
statusbar_dir / "battery_sprite.png",
|
||
statusbar_dir / "battery_power_save_sprite.png",
|
||
):
|
||
(statusbar_dir / "battery_power_save.png").unlink(missing_ok=True)
|
||
(statusbar_dir / "battery_power_save_sprite.png").unlink(missing_ok=True)
|
||
elif ps_info:
|
||
statusbar_dynamic.update(ps_info)
|
||
|
||
power_save_charge_entry = best_path_for_basename(
|
||
pngs,
|
||
["stat_sys_battery_power_save_charge.png",
|
||
"stat_sys_battery_power_save_charge_darkmode.png"],
|
||
prefer_nightmode=False,
|
||
)
|
||
if power_save_charge_entry:
|
||
psc_info = extract_statusbar_battery_variant_assets(
|
||
zf, power_save_charge_entry, statusbar_dir,
|
||
prefix="battery_power_save_charge",
|
||
manifest_prefix="batteryPowerSaveCharge",
|
||
)
|
||
if psc_info and _png_pixel_identical(
|
||
statusbar_dir / "battery_charge_sprite.png",
|
||
statusbar_dir / "battery_power_save_charge_sprite.png",
|
||
):
|
||
(statusbar_dir / "battery_power_save_charge.png").unlink(missing_ok=True)
|
||
(statusbar_dir / "battery_power_save_charge_sprite.png").unlink(missing_ok=True)
|
||
elif psc_info:
|
||
statusbar_dynamic.update(psc_info)
|
||
|
||
# Charging bolt overlay (used by the system-tint fallback path
|
||
# when a theme doesn't ship a `_charge` variant). Two sources:
|
||
# 1. Real `stat_sys_battery_charging.png` overlay (some
|
||
# themes provide a single-frame bolt drawable).
|
||
# 2. Color-encoded bolt extracted from the charge sprite —
|
||
# some themes bake body fill in saturated color and the bolt in
|
||
# neutral gray within the same alpha shape; we isolate
|
||
# the gray-only pixels and collapse to a single overlay.
|
||
bolt_overlay_entry = best_path_for_basename(
|
||
pngs,
|
||
[
|
||
"stat_sys_battery_charging.png",
|
||
"stat_sys_battery_charging_darkmode.png",
|
||
"battery_meter_charging.png",
|
||
"battery_meter_charging_dark.png",
|
||
],
|
||
prefer_nightmode=False,
|
||
)
|
||
bolt_written = False
|
||
if bolt_overlay_entry:
|
||
try:
|
||
raw = zf.read(bolt_overlay_entry)
|
||
if _png_has_visible_alpha(raw):
|
||
normalized = normalize_png_icon_to_square(raw, base_side=BATTERY_BASE_SIDE)
|
||
ensure_dir(statusbar_dir)
|
||
(statusbar_dir / "battery_bolt.png").write_bytes(normalized or raw)
|
||
statusbar_dynamic["batteryBoltOverlay"] = "battery_bolt.png"
|
||
bolt_written = True
|
||
except KeyError:
|
||
pass
|
||
if not bolt_written and charge_entry:
|
||
try:
|
||
raw = zf.read(charge_entry)
|
||
bolt_bytes = extract_battery_bolt_from_charge_png(raw)
|
||
if bolt_bytes:
|
||
ensure_dir(statusbar_dir)
|
||
(statusbar_dir / "battery_bolt.png").write_bytes(bolt_bytes)
|
||
statusbar_dynamic["batteryBoltOverlay"] = "battery_bolt.png"
|
||
except KeyError:
|
||
pass
|
||
|
||
# mobile data type icons (4G/5G/LTE...)
|
||
data_types = find_statusbar_data_type_icons(pngs)
|
||
exported_types: list[str] = []
|
||
for t, entry_path in data_types.items():
|
||
name = f"data_{t}"
|
||
if extract_statusbar_icon_normalized(zf, entry_path, statusbar_dir / f"{name}.png"):
|
||
statusbar_icons.append(name)
|
||
exported_types.append(t)
|
||
if exported_types:
|
||
statusbar_dynamic["dataTypes"] = sorted(set(exported_types))
|
||
|
||
# misc extras (airplane/no_sim/vpn/alarm/...)
|
||
misc = find_statusbar_misc_icons(pngs)
|
||
for name, entry_path in misc.items():
|
||
if extract_statusbar_icon_normalized(zf, entry_path, statusbar_dir / f"{name}.png"):
|
||
statusbar_icons.append(name)
|
||
|
||
sh = find_shade_backgrounds(zf)
|
||
if "active" in sh and extract_zip_entry(zf, sh["active"], shade_dir / "active.png"):
|
||
shade["active"] = True
|
||
if "inactive" in sh and extract_zip_entry(zf, sh["inactive"], shade_dir / "inactive.png"):
|
||
shade["inactive"] = True
|
||
except zipfile.BadZipFile:
|
||
pass
|
||
|
||
entry["statusbarIcons"] = sorted(set(statusbar_icons))
|
||
if statusbar_dynamic:
|
||
entry["statusbarDynamic"] = statusbar_dynamic
|
||
if statusbar_icons:
|
||
extracted["statusbar"] = True
|
||
if shade["active"] or shade["inactive"]:
|
||
capabilities.append("shade")
|
||
extracted["shade"] = shade
|
||
|
||
# ---- Wallpaper ----
|
||
wallpaper_local_id = sub_map.get("wallpaper")
|
||
if wallpaper_local_id:
|
||
capabilities.append("wallpaper")
|
||
wp_mrc = src_content_base / "wallpaper" / f"{wallpaper_local_id}.mrc"
|
||
wp_name = extract_wallpaper(wp_mrc, wallpaper_dir)
|
||
if wp_name:
|
||
extracted["wallpaper"] = {"default": wp_name}
|
||
|
||
# ---- Selected component assets (for getAppAsset) ----
|
||
# Settings icons (prefer non-nightmode for light Settings UI)
|
||
settings_local_id = sub_map.get("com.android.settings")
|
||
if settings_local_id and settings_icon_names:
|
||
capabilities.append("settings")
|
||
mrc = src_content_base / "com.android.settings" / f"{settings_local_id}.mrc"
|
||
extracted_names = extract_selected_component_assets(
|
||
mrc,
|
||
components_dir / "com.android.settings",
|
||
settings_icon_names,
|
||
prefer_nightmode=False,
|
||
)
|
||
if extracted_names:
|
||
extracted["com.android.settings"] = {"count": len(extracted_names)}
|
||
|
||
# Contact dialpad assets (light UI)
|
||
contact_local_id = sub_map.get("contact")
|
||
if contact_local_id:
|
||
capabilities.append("contact")
|
||
mrc = src_content_base / "contact" / f"{contact_local_id}.mrc"
|
||
extracted_names = extract_selected_component_assets(
|
||
mrc,
|
||
components_dir / "contact",
|
||
contact_asset_names,
|
||
prefer_nightmode=False,
|
||
)
|
||
if extracted_names:
|
||
extracted["contact"] = {"count": len(extracted_names)}
|
||
|
||
# MMS bubble assets (light UI)
|
||
mms_local_id = sub_map.get("mms")
|
||
if mms_local_id:
|
||
capabilities.append("mms")
|
||
mrc = src_content_base / "mms" / f"{mms_local_id}.mrc"
|
||
extracted_names = extract_selected_component_assets(
|
||
mrc,
|
||
components_dir / "mms",
|
||
mms_asset_names,
|
||
prefer_nightmode=False,
|
||
)
|
||
if extracted_names:
|
||
extracted["mms"] = {"count": len(extracted_names)}
|
||
|
||
# ---- Clock widgets (WMR, from theme subResources) ----
|
||
for clock_code in CLOCK_WIDGET_CODES:
|
||
clock_local_id = sub_map.get(clock_code)
|
||
if not clock_local_id:
|
||
continue
|
||
mrc = src_content_base / clock_code / f"{clock_local_id}.mrc"
|
||
clock_out = theme_dir / clock_code
|
||
if extract_clock_widget_mrc(mrc, clock_out):
|
||
extracted[clock_code] = True
|
||
# Copy built-in preview if available
|
||
preview_name = f"preview_{clock_code}_0.png"
|
||
preview_src = src_preview_theme / theme_id / preview_name
|
||
if preview_src.exists():
|
||
shutil.copy2(preview_src, clock_out / "preview.png")
|
||
# Collect for widgets array
|
||
spanX, spanY = CLOCK_SPAN.get(clock_code, (4, 2))
|
||
clock_widgets_collected.append({
|
||
"themeId": theme_id,
|
||
"themeTitle": entry.get("title", theme_id),
|
||
"clockCode": clock_code,
|
||
"spanX": spanX,
|
||
"spanY": spanY,
|
||
"hasPreview": preview_src.exists(),
|
||
})
|
||
|
||
entry["capabilities"] = sorted(set(capabilities))
|
||
entry["extracted"] = extracted
|
||
|
||
# ---- componentAssets: index of available PNGs per component dir ----
|
||
component_assets: dict[str, dict[str, str]] = {}
|
||
if components_dir.is_dir():
|
||
for comp_path in sorted(components_dir.iterdir()):
|
||
if not comp_path.is_dir():
|
||
continue
|
||
assets: dict[str, str] = {}
|
||
for png in sorted(comp_path.rglob("*.png")):
|
||
rel = str(png.relative_to(comp_path))
|
||
base = png.stem
|
||
if png.name.endswith(".9.png"):
|
||
base = png.name[:-6]
|
||
if base not in assets:
|
||
assets[base] = rel
|
||
if assets:
|
||
component_assets[comp_path.name] = assets
|
||
if component_assets:
|
||
entry["componentAssets"] = component_assets
|
||
|
||
themes_out.append(entry)
|
||
|
||
# ---- Optional: fonts / AOD (preview-only) ----
|
||
if not args.no_fonts and src_meta_fonts.exists():
|
||
for p in sorted(src_meta_fonts.glob("*.mrm")):
|
||
meta = read_json(p)
|
||
entry = build_theme_entry(meta)
|
||
entry["type"] = "font"
|
||
font_id = entry.get("id") or p.stem
|
||
font_dir = dest_root / font_id
|
||
if font_dir.exists():
|
||
shutil.rmtree(font_dir)
|
||
ensure_dir(font_dir)
|
||
copy_tree(src_preview_theme / font_id, font_dir / "preview")
|
||
fonts_out.append(entry)
|
||
|
||
if not args.no_aod and src_meta_aod.exists():
|
||
for p in sorted(src_meta_aod.glob("*.mrm")):
|
||
meta = read_json(p)
|
||
entry = build_theme_entry(meta)
|
||
entry["type"] = "aod"
|
||
aod_id = entry.get("id") or p.stem
|
||
aod_dir = dest_root / aod_id
|
||
if aod_dir.exists():
|
||
shutil.rmtree(aod_dir)
|
||
ensure_dir(aod_dir)
|
||
copy_tree(src_preview_theme / aod_id, aod_dir / "preview")
|
||
aod_out.append(entry)
|
||
|
||
# ---- Clock widgets (from themes) → widget entries ----
|
||
widgets_out: list[dict] = []
|
||
for cw in clock_widgets_collected:
|
||
widget_id = f"{cw['themeId']}_{cw['clockCode']}"
|
||
preview_rel = (
|
||
f"/themes/{cw['themeId']}/{cw['clockCode']}/preview.png"
|
||
if cw["hasPreview"] else ""
|
||
)
|
||
widgets_out.append({
|
||
"id": widget_id,
|
||
"title": f"{cw['themeTitle']}时钟",
|
||
"author": "",
|
||
"description": "",
|
||
"source": "theme",
|
||
"themeId": cw["themeId"],
|
||
"variants": [
|
||
{
|
||
"entry": cw["clockCode"],
|
||
"spanX": cw["spanX"],
|
||
"spanY": cw["spanY"],
|
||
"preview": preview_rel,
|
||
}
|
||
],
|
||
})
|
||
|
||
# ---- Standalone WMR widgets ----
|
||
src_widget_dir = src_root / "maml_widget"
|
||
if not args.no_widgets and src_widget_dir.exists():
|
||
for widget_dir in sorted(src_widget_dir.iterdir()):
|
||
if not widget_dir.is_dir():
|
||
continue
|
||
entry = extract_standalone_wmr_widget(widget_dir, dest_root)
|
||
if entry:
|
||
widgets_out.append(entry)
|
||
|
||
manifest = {
|
||
"version": 1,
|
||
"generatedAt": _now_iso(),
|
||
"defaultThemeId": args.default_theme_id,
|
||
"themes": themes_out,
|
||
"fonts": fonts_out,
|
||
"aod": aod_out,
|
||
"widgets": widgets_out,
|
||
}
|
||
|
||
ensure_dir(dest_root)
|
||
(dest_root / "manifest.json").write_text(
|
||
json.dumps(manifest, ensure_ascii=False, indent=2),
|
||
encoding="utf-8",
|
||
)
|
||
|
||
print("=== Theme preparation (V2 static) complete ===")
|
||
print(f"Output dir: {dest_root}")
|
||
print(f"Themes: {len(themes_out)}")
|
||
print(f"Widgets: {len(widgets_out)}")
|
||
if skipped_wrappers["fonts_only"] or skipped_wrappers["aod_only"]:
|
||
print(f"Skipped wrappers (fonts_only): {len(skipped_wrappers['fonts_only'])}")
|
||
print(f"Skipped wrappers (aod_only): {len(skipped_wrappers['aod_only'])}")
|
||
if missing:
|
||
print("")
|
||
print("Missing files (skipped):")
|
||
for m in missing[:200]:
|
||
print(f" - {m}")
|
||
if len(missing) > 200:
|
||
print(f" ... and {len(missing) - 200} more")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|