v5.1.0: history tab, portable data folder, optional faststart, new icons & build tools
This commit is contained in:
@@ -0,0 +1,179 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Benchmark Video Concat's pipeline stages on real disks.
|
||||
|
||||
Runs ffmpeg with exactly the flags src-tauri/src/concat.rs uses, so the numbers
|
||||
mean what the app will do. Nothing here touches the app itself.
|
||||
|
||||
python tools/bench_concat.py --ffmpeg-dir "C:/Users/me/Documents/VideoConcat" --make 6 --src D:/bench --dst D:/bench/out
|
||||
python tools/bench_concat.py --ffmpeg-dir ... --src D:/recordings/session --dst E:/out --reps 3
|
||||
|
||||
Stages:
|
||||
probe_serial one ffprobe per file, one after another (the pre-5.1 behaviour)
|
||||
probe_parallel bounded parallel probing
|
||||
mp4_faststart -c copy -movflags +faststart -> .mp4 (the pre-5.1 default)
|
||||
mp4_plain -c copy -> .mp4 (the 5.1 default)
|
||||
mkv -c copy -> .mkv
|
||||
raw_copy byte-for-byte copy of the same input bytes: the I/O floor
|
||||
|
||||
Measured 2026-09-25, 6 x 60 s H.264/AAC FLV segments, 1.7 GB:
|
||||
local NVMe -> NVMe faststart 1.77 s plain 1.47 s raw 1.39 s
|
||||
NAS -> NAS (10GbE) faststart 27.4 s plain 10.9 s raw 6.9 s
|
||||
|
||||
Mind the OS file cache: a second run over the same inputs reads them from
|
||||
RAM. Compare variants within one run, not across runs.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import shutil
|
||||
import statistics
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
NOWIN = 0x08000000 if os.name == "nt" else 0
|
||||
|
||||
|
||||
def tool(ffdir: str | None, name: str) -> str:
|
||||
exe = name + (".exe" if os.name == "nt" else "")
|
||||
if ffdir and (Path(ffdir) / exe).is_file():
|
||||
return str(Path(ffdir) / exe)
|
||||
found = shutil.which(name)
|
||||
if not found:
|
||||
raise SystemExit(f"{name} not found - pass --ffmpeg-dir")
|
||||
return found
|
||||
|
||||
|
||||
def make_segments(ff: str, dst: Path, count: int, seconds: int) -> None:
|
||||
"""High-bitrate H.264/AAC FLV segments, shaped like a BiliBili recording."""
|
||||
dst.mkdir(parents=True, exist_ok=True)
|
||||
seed = dst / "_seed.flv"
|
||||
subprocess.run([ff, "-hide_banner", "-loglevel", "error", "-y",
|
||||
"-f", "lavfi", "-i", "testsrc2=size=1280x720:rate=30",
|
||||
"-f", "lavfi", "-i", "sine=frequency=440:sample_rate=48000", "-t", "10",
|
||||
"-c:v", "libx264", "-preset", "ultrafast", "-b:v", "40M", "-minrate", "40M",
|
||||
"-maxrate", "40M", "-bufsize", "40M", "-x264-params", "nal-hrd=cbr:force-cfr=1",
|
||||
"-g", "60", "-c:a", "aac", "-b:a", "128k", "-f", "flv", str(seed)],
|
||||
check=True, creationflags=NOWIN)
|
||||
reps = max(1, seconds // 10)
|
||||
lst = dst / "_seed.txt"
|
||||
lst.write_text("".join(f"file '{seed.as_posix()}'\n" for _ in range(reps)), encoding="utf-8")
|
||||
first = dst / "seg1.flv"
|
||||
subprocess.run([ff, "-hide_banner", "-loglevel", "error", "-y", "-f", "concat", "-safe", "0",
|
||||
"-i", str(lst), "-c", "copy", "-f", "flv", str(first)], check=True, creationflags=NOWIN)
|
||||
for i in range(2, count + 1):
|
||||
shutil.copyfile(first, dst / f"seg{i}.flv")
|
||||
seed.unlink()
|
||||
lst.unlink()
|
||||
|
||||
|
||||
def probe(fp: str, path: Path) -> float:
|
||||
out = subprocess.run([fp, "-v", "error", "-show_entries", "format=duration",
|
||||
"-of", "default=noprint_wrappers=1:nokey=1", str(path)],
|
||||
capture_output=True, text=True, creationflags=NOWIN)
|
||||
try:
|
||||
return float(out.stdout.strip())
|
||||
except ValueError:
|
||||
return 0.0
|
||||
|
||||
|
||||
def probe_parallel(fp: str, files: list[Path]) -> None:
|
||||
ts = [threading.Thread(target=probe, args=(fp, f)) for f in files]
|
||||
for t in ts:
|
||||
t.start()
|
||||
for t in ts:
|
||||
t.join()
|
||||
|
||||
|
||||
def concat(ff: str, files: list[Path], out: Path, faststart: bool) -> float:
|
||||
lst = out.parent / f".bench_list_{os.getpid()}.txt"
|
||||
lst.write_text("".join(f"file '{f.resolve().as_posix()}'\n" for f in files), encoding="utf-8")
|
||||
args = [ff, "-y", "-f", "concat", "-safe", "0", "-i", str(lst), "-c:v", "copy", "-c:a", "copy"]
|
||||
if faststart:
|
||||
args += ["-movflags", "+faststart"]
|
||||
args += ["-nostdin", "-hide_banner", "-loglevel", "error", str(out)]
|
||||
t0 = time.perf_counter()
|
||||
r = subprocess.run(args, capture_output=True, text=True, creationflags=NOWIN)
|
||||
dt = time.perf_counter() - t0
|
||||
lst.unlink(missing_ok=True)
|
||||
if r.returncode:
|
||||
raise RuntimeError(r.stderr)
|
||||
return dt
|
||||
|
||||
|
||||
def raw_copy(files: list[Path], out: Path) -> float:
|
||||
t0 = time.perf_counter()
|
||||
with open(out, "wb") as o:
|
||||
for f in files:
|
||||
with open(f, "rb") as i:
|
||||
shutil.copyfileobj(i, o, 8 * 1024 * 1024)
|
||||
o.flush()
|
||||
os.fsync(o.fileno())
|
||||
return time.perf_counter() - t0
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
ap.add_argument("--ffmpeg-dir")
|
||||
ap.add_argument("--src", type=Path, required=True, help="folder with seg*.flv (or where --make writes them)")
|
||||
ap.add_argument("--dst", type=Path, required=True, help="where outputs are written (deleted after each run)")
|
||||
ap.add_argument("--make", type=int, default=0, metavar="N", help="first generate N segments in --src")
|
||||
ap.add_argument("--seconds", type=int, default=60, help="length of each generated segment")
|
||||
ap.add_argument("--reps", type=int, default=3)
|
||||
ap.add_argument("--only", help="comma list of stages to run")
|
||||
a = ap.parse_args()
|
||||
|
||||
ff, fp = tool(a.ffmpeg_dir, "ffmpeg"), tool(a.ffmpeg_dir, "ffprobe")
|
||||
if a.make:
|
||||
print(f"generating {a.make} x {a.seconds}s segments in {a.src} ...")
|
||||
make_segments(ff, a.src, a.make, a.seconds)
|
||||
files = sorted(a.src.glob("seg*.flv"))
|
||||
if not files:
|
||||
raise SystemExit(f"no seg*.flv in {a.src} - use --make")
|
||||
only = set(a.only.split(",")) if a.only else None
|
||||
want = lambda s: only is None or s in only # noqa: E731
|
||||
a.dst.mkdir(parents=True, exist_ok=True)
|
||||
total_mb = sum(f.stat().st_size for f in files) / 1048576
|
||||
print(f"inputs {len(files)} files, {total_mb:.0f} MB | {a.src} -> {a.dst} | median of {a.reps}\n")
|
||||
|
||||
def timed(fn):
|
||||
t0 = time.perf_counter()
|
||||
fn()
|
||||
return time.perf_counter() - t0
|
||||
|
||||
if want("probe_serial"):
|
||||
t = statistics.median(timed(lambda: [probe(fp, f) for f in files]) for _ in range(a.reps))
|
||||
print(f"{'probe_serial':15s} {t * 1000:8.0f} ms")
|
||||
if want("probe_parallel"):
|
||||
t = statistics.median(timed(lambda: probe_parallel(fp, files)) for _ in range(a.reps))
|
||||
print(f"{'probe_parallel':15s} {t * 1000:8.0f} ms")
|
||||
|
||||
results = {}
|
||||
for name, ext, fs in (("mp4_faststart", ".mp4", True), ("mp4_plain", ".mp4", False), ("mkv", ".mkv", False)):
|
||||
if not want(name):
|
||||
continue
|
||||
runs = []
|
||||
for _ in range(a.reps):
|
||||
out = a.dst / f"bench_{name}{ext}"
|
||||
runs.append(concat(ff, files, out, fs))
|
||||
out.unlink()
|
||||
results[name] = t = statistics.median(runs)
|
||||
print(f"{name:15s} {t:8.2f} s {total_mb / t:6.0f} MB/s")
|
||||
if want("raw_copy"):
|
||||
runs = []
|
||||
for _ in range(a.reps):
|
||||
out = a.dst / "bench_raw.bin"
|
||||
runs.append(raw_copy(files, out))
|
||||
out.unlink()
|
||||
t = statistics.median(runs)
|
||||
print(f"{'raw_copy':15s} {t:8.2f} s {total_mb / t:6.0f} MB/s <- I/O floor")
|
||||
|
||||
if "mp4_faststart" in results and "mp4_plain" in results:
|
||||
d = results["mp4_faststart"] - results["mp4_plain"]
|
||||
print(f"\n+faststart costs {d:.2f} s = {100 * d / results['mp4_faststart']:.0f}% of a faststart run")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,248 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Regenerate every app icon from the vector source (src-tauri/icons/icon.svg).
|
||||
|
||||
The artwork is described once, in 512x512 units, and rasterised per target size
|
||||
with 8x supersampling plus a little per-size hinting: the filmstrip sprocket
|
||||
holes and the play-triangle knockout are dropped at the sizes where they
|
||||
would be sub-pixel and only turn to mush.
|
||||
|
||||
Everything is produced with a real alpha channel - the rounded-square tile has
|
||||
transparent corners, which is exactly what the old flattened .ico was missing.
|
||||
|
||||
python tools/make_icons.py
|
||||
|
||||
Requires: pillow, numpy.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import re
|
||||
import struct
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
ICONS = ROOT / "src-tauri" / "icons"
|
||||
SVG = ICONS / "icon.svg"
|
||||
|
||||
CANVAS = 512.0
|
||||
SS = 8 # supersampling factor
|
||||
|
||||
# --- tile -------------------------------------------------------------------
|
||||
# The original raster left a 22px margin for a drop shadow. The vector version
|
||||
# has no shadow, so the tile reclaims that space and reads larger in the taskbar.
|
||||
TILE = (14.0, 14.0, 484.0, 484.0) # x, y, w, h
|
||||
TILE_R = 99.0
|
||||
GRAD_SPAN = (22.0, 468.0) # gradient is still fitted against the original tile
|
||||
|
||||
# --- filmstrips -------------------------------------------------------------
|
||||
STRIPS = [(113.0, 147.0), (96.0, 226.0), (113.0, 305.0)]
|
||||
STRIP_W, STRIP_H, STRIP_R = 79.0, 60.0, 12.0
|
||||
HOLE_DX, HOLE_DY, HOLE_S, HOLE_R = 68.0, 4.0, 7.5, 1.8
|
||||
HOLE_PITCH, HOLE_N = 11.25, 5
|
||||
|
||||
|
||||
def parse_svg(svg_text):
|
||||
"""Pull the gradient stops and the glyph path back out of the vector source."""
|
||||
lin = re.search(r'id="tile".*?</linearGradient>', svg_text, re.S).group(0)
|
||||
lin_stops = [
|
||||
(float(o), tuple(int(h[i:i + 2], 16) for i in (0, 2, 4)))
|
||||
for o, h in re.findall(r'offset="([\d.]+)" stop-color="#([0-9A-Fa-f]{6})"', lin)
|
||||
]
|
||||
rad = re.search(r'id="glow".*?</radialGradient>', svg_text, re.S).group(0)
|
||||
gc = re.search(r'cx="([-\d.]+)" cy="([-\d.]+)" r="([\d.]+)"', rad)
|
||||
glow_xy = (float(gc.group(1)), float(gc.group(2)))
|
||||
glow_r = float(gc.group(3))
|
||||
ghex = re.search(r'stop-color="#([0-9A-Fa-f]{6})"', rad).group(1)
|
||||
glow_rgb = tuple(int(ghex[i:i + 2], 16) for i in (0, 2, 4))
|
||||
rad_stops = [
|
||||
(float(o), float(a))
|
||||
for o, a in re.findall(
|
||||
r'offset="([\d.]+)" stop-color="#[0-9A-Fa-f]{6}" stop-opacity="([\d.]+)"', rad
|
||||
)
|
||||
]
|
||||
path_d = re.search(r'<path d="([^"]+)"', svg_text).group(1)
|
||||
return lin_stops, glow_xy, glow_r, glow_rgb, rad_stops, path_d
|
||||
|
||||
|
||||
def ramp(stops, t):
|
||||
"""Piecewise-linear colour ramp over an array of positions in [0, 1]."""
|
||||
offs = np.array([s[0] for s in stops])
|
||||
cols = np.array([s[1] for s in stops], dtype=np.float64)
|
||||
idx = np.clip(np.searchsorted(offs, t) - 1, 0, len(offs) - 2)
|
||||
span = np.maximum(offs[idx + 1] - offs[idx], 1e-9)
|
||||
f = ((t - offs[idx]) / span)[..., None]
|
||||
return cols[idx] * (1 - f) + cols[idx + 1] * f
|
||||
|
||||
|
||||
def ramp1(stops, t):
|
||||
offs = np.array([s[0] for s in stops])
|
||||
vals = np.array([s[1] for s in stops], dtype=np.float64)
|
||||
idx = np.clip(np.searchsorted(offs, t) - 1, 0, len(offs) - 2)
|
||||
span = np.maximum(offs[idx + 1] - offs[idx], 1e-9)
|
||||
f = (t - offs[idx]) / span
|
||||
return vals[idx] * (1 - f) + vals[idx + 1] * f
|
||||
|
||||
|
||||
def flatten_path(d, scale):
|
||||
"""Flatten the glyph path data into polygons, subdividing beziers."""
|
||||
toks = re.findall(r"([MLCZ])([-\d.,e ]*)", d)
|
||||
polys, cur, pos, start = [], [], (0.0, 0.0), (0.0, 0.0)
|
||||
|
||||
def nums(s):
|
||||
return [float(v) for v in re.split(r"[, ]+", s.strip()) if v]
|
||||
|
||||
for cmd, args in toks:
|
||||
a = nums(args)
|
||||
if cmd == "M":
|
||||
if len(cur) > 2:
|
||||
polys.append(cur)
|
||||
pos = start = (a[0], a[1])
|
||||
cur = [pos]
|
||||
elif cmd == "L":
|
||||
pos = (a[0], a[1])
|
||||
cur.append(pos)
|
||||
elif cmd == "C":
|
||||
p0, p1, p2, p3 = pos, (a[0], a[1]), (a[2], a[3]), (a[4], a[5])
|
||||
span = math.dist(p0, p1) + math.dist(p1, p2) + math.dist(p2, p3)
|
||||
n = max(4, min(96, int(span * scale / 2.5)))
|
||||
for i in range(1, n + 1):
|
||||
t = i / n
|
||||
mt = 1 - t
|
||||
x = mt ** 3 * p0[0] + 3 * mt * mt * t * p1[0] + 3 * mt * t * t * p2[0] + t ** 3 * p3[0]
|
||||
y = mt ** 3 * p0[1] + 3 * mt * mt * t * p1[1] + 3 * mt * t * t * p2[1] + t ** 3 * p3[1]
|
||||
cur.append((x, y))
|
||||
pos = p3
|
||||
elif cmd == "Z":
|
||||
if len(cur) > 2:
|
||||
polys.append(cur)
|
||||
cur, pos = [], start
|
||||
if len(cur) > 2:
|
||||
polys.append(cur)
|
||||
return polys
|
||||
|
||||
|
||||
def render(size, lin_stops, glow_xy, glow_r, glow_rgb, rad_stops, path_d):
|
||||
n = size * SS
|
||||
k = n / CANVAS # 512-space -> device pixels
|
||||
|
||||
# per-size hinting: sub-pixel detail is dropped rather than smeared, and the
|
||||
# glyph is nudged up a little where it would otherwise get lost
|
||||
keep_holes = size >= 48 # sprocket holes are < 0.7px below this
|
||||
keep_triangle = size >= 64 # the play knockout reads as a speck below this
|
||||
gscale = 1.09 if size <= 32 else 1.0
|
||||
|
||||
def S(v):
|
||||
return v * k
|
||||
|
||||
def G(v): # glyph coordinate, scaled about the tile centre
|
||||
return (CANVAS / 2 + (v - CANVAS / 2) * gscale) * k
|
||||
|
||||
# the tile shape is the icon's whole alpha channel - corners stay transparent
|
||||
tile = Image.new("L", (n, n), 0)
|
||||
ImageDraw.Draw(tile).rounded_rectangle(
|
||||
[S(TILE[0]), S(TILE[1]), S(TILE[0] + TILE[2]), S(TILE[1] + TILE[3])],
|
||||
radius=S(TILE_R),
|
||||
fill=255,
|
||||
)
|
||||
|
||||
# background: diagonal purple-to-blue ramp with a soft highlight near the top
|
||||
yy, xx = np.mgrid[0:n, 0:n]
|
||||
ux = (xx + 0.5) / k
|
||||
uy = (yy + 0.5) / k
|
||||
t = np.clip(((ux + uy) - 2 * GRAD_SPAN[0]) / (2 * GRAD_SPAN[1]), 0, 1)
|
||||
base = ramp(lin_stops, t)
|
||||
g = np.clip(np.hypot(ux - glow_xy[0], uy - glow_xy[1]) / glow_r, 0, 1)
|
||||
a = ramp1(rad_stops, g)[..., None]
|
||||
rgb = base * (1 - a) + np.array(glow_rgb, dtype=np.float64) * a
|
||||
|
||||
glyph = Image.new("L", (n, n), 0)
|
||||
gd = ImageDraw.Draw(glyph)
|
||||
polys = flatten_path(path_d, k)
|
||||
outer, holes = polys[0], polys[1:] # outline first, play-triangle knockout after
|
||||
gd.polygon([(G(x), G(y)) for x, y in outer], fill=255)
|
||||
if keep_triangle:
|
||||
for hole in holes:
|
||||
gd.polygon([(G(x), G(y)) for x, y in hole], fill=0)
|
||||
|
||||
for sx, sy in STRIPS:
|
||||
gd.rounded_rectangle(
|
||||
[G(sx), G(sy), G(sx + STRIP_W), G(sy + STRIP_H)], radius=S(STRIP_R * gscale), fill=255
|
||||
)
|
||||
if keep_holes:
|
||||
for i in range(HOLE_N):
|
||||
hx, hy = sx + HOLE_DX, sy + HOLE_DY + i * HOLE_PITCH
|
||||
gd.rounded_rectangle(
|
||||
[G(hx), G(hy), G(hx + HOLE_S), G(hy + HOLE_S)],
|
||||
radius=S(HOLE_R * gscale),
|
||||
fill=0,
|
||||
)
|
||||
|
||||
gm = (np.asarray(glyph, dtype=np.float64) / 255.0)[..., None]
|
||||
rgb = rgb * (1 - gm) + 255.0 * gm
|
||||
|
||||
out = np.dstack([np.clip(rgb, 0, 255), np.asarray(tile, dtype=np.float64)]).astype(np.uint8)
|
||||
return Image.fromarray(out, "RGBA").resize((size, size), Image.LANCZOS)
|
||||
|
||||
|
||||
def write_ico(path, images):
|
||||
"""Write a PNG-compressed .ico (Vista+ format), preserving full alpha."""
|
||||
blobs = []
|
||||
for im in images:
|
||||
buf = BytesIO()
|
||||
im.save(buf, "PNG", optimize=True)
|
||||
blobs.append(buf.getvalue())
|
||||
with open(path, "wb") as f:
|
||||
f.write(struct.pack("<HHH", 0, 1, len(images)))
|
||||
offset = 6 + 16 * len(images)
|
||||
for im, blob in zip(images, blobs):
|
||||
w = 0 if im.width >= 256 else im.width
|
||||
h = 0 if im.height >= 256 else im.height
|
||||
f.write(struct.pack("<BBBBHHII", w, h, 0, 0, 1, 32, len(blob), offset))
|
||||
offset += len(blob)
|
||||
for blob in blobs:
|
||||
f.write(blob)
|
||||
|
||||
|
||||
PNG_TARGETS = {
|
||||
"32x32.png": 32,
|
||||
"64x64.png": 64,
|
||||
"128x128.png": 128,
|
||||
"128x128@2x.png": 256,
|
||||
"icon.png": 512,
|
||||
"Square30x30Logo.png": 30,
|
||||
"Square44x44Logo.png": 44,
|
||||
"Square71x71Logo.png": 71,
|
||||
"Square89x89Logo.png": 89,
|
||||
"Square107x107Logo.png": 107,
|
||||
"Square142x142Logo.png": 142,
|
||||
"Square150x150Logo.png": 150,
|
||||
"Square284x284Logo.png": 284,
|
||||
"Square310x310Logo.png": 310,
|
||||
"StoreLogo.png": 50,
|
||||
}
|
||||
ICO_SIZES = [16, 24, 32, 48, 64, 128, 256]
|
||||
|
||||
|
||||
def main():
|
||||
params = parse_svg(SVG.read_text(encoding="utf-8"))
|
||||
cache = {}
|
||||
|
||||
def get(size):
|
||||
if size not in cache:
|
||||
cache[size] = render(size, *params)
|
||||
return cache[size]
|
||||
|
||||
for name, size in PNG_TARGETS.items():
|
||||
get(size).save(ICONS / name, "PNG", optimize=True)
|
||||
print(" %-24s %dx%d" % (name, size, size))
|
||||
|
||||
write_ico(ICONS / "icon.ico", [get(s) for s in ICO_SIZES])
|
||||
print(" %-24s %s" % ("icon.ico", ICO_SIZES))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,182 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Replace the icon inside an already-built .exe, without rebuilding it.
|
||||
|
||||
Useful when a binary you want to keep byte-for-byte was built with a bad icon.
|
||||
Only the RT_ICON / RT_GROUP_ICON resources are rewritten; no code is touched.
|
||||
|
||||
python tools/patch_exe_icon.py "C:/path/to/App.exe"
|
||||
python tools/patch_exe_icon.py "C:/path/to/App.exe" --icon other.ico --no-backup
|
||||
|
||||
Sizes up to 64px are embedded as 32-bit BGRA DIBs (what Windows shells have
|
||||
always understood); 128 and 256 go in PNG-compressed, as Vista+ expects.
|
||||
|
||||
Windows only. Requires: pillow.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import ctypes
|
||||
import shutil
|
||||
import struct
|
||||
import sys
|
||||
from ctypes import wintypes
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
DEFAULT_ICO = ROOT / "src-tauri" / "icons" / "icon.ico"
|
||||
|
||||
RT_ICON = 3
|
||||
RT_GROUP_ICON = 14
|
||||
LANG_EN_US = 1033
|
||||
PNG_FROM = 128 # sizes >= this are stored as PNG, smaller ones as DIB
|
||||
|
||||
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
||||
kernel32.BeginUpdateResourceW.argtypes = [wintypes.LPCWSTR, wintypes.BOOL]
|
||||
kernel32.BeginUpdateResourceW.restype = wintypes.HANDLE
|
||||
kernel32.UpdateResourceW.argtypes = [
|
||||
wintypes.HANDLE, wintypes.LPCWSTR, wintypes.LPCWSTR,
|
||||
wintypes.WORD, wintypes.LPVOID, wintypes.DWORD,
|
||||
]
|
||||
kernel32.UpdateResourceW.restype = wintypes.BOOL
|
||||
kernel32.EndUpdateResourceW.argtypes = [wintypes.HANDLE, wintypes.BOOL]
|
||||
kernel32.EndUpdateResourceW.restype = wintypes.BOOL
|
||||
|
||||
|
||||
def _int_resource(value):
|
||||
"""MAKEINTRESOURCE: an integer id is passed where a string pointer is expected."""
|
||||
return ctypes.cast(ctypes.c_void_p(value), wintypes.LPCWSTR)
|
||||
|
||||
|
||||
def _check(ok, what):
|
||||
if not ok:
|
||||
err = ctypes.get_last_error()
|
||||
raise OSError(err, f"{what} failed: {ctypes.FormatError(err)}")
|
||||
|
||||
|
||||
def to_dib(img: Image.Image) -> bytes:
|
||||
"""32bpp bottom-up BGRA DIB plus the 1bpp AND mask, as RT_ICON expects."""
|
||||
w, h = img.size
|
||||
px = img.convert("RGBA").load()
|
||||
|
||||
xor = bytearray()
|
||||
for y in range(h - 1, -1, -1):
|
||||
for x in range(w):
|
||||
r, g, b, a = px[x, y]
|
||||
xor += bytes((b, g, r, a))
|
||||
|
||||
stride = ((w + 31) // 32) * 4
|
||||
mask = bytearray()
|
||||
for y in range(h - 1, -1, -1):
|
||||
row = bytearray(stride)
|
||||
for x in range(w):
|
||||
if px[x, y][3] == 0: # 1 = transparent
|
||||
row[x // 8] |= 0x80 >> (x % 8)
|
||||
mask += row
|
||||
|
||||
header = struct.pack(
|
||||
"<IiiHHIIiiII", 40, w, h * 2, 1, 32, 0, len(xor) + len(mask), 0, 0, 0, 0
|
||||
)
|
||||
return bytes(header + xor + mask)
|
||||
|
||||
|
||||
def to_png(img: Image.Image) -> bytes:
|
||||
buf = BytesIO()
|
||||
img.save(buf, "PNG", optimize=True)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def load_sizes(ico_path: Path):
|
||||
"""Read every image out of a .ico, smallest first."""
|
||||
sizes = sorted(Image.open(ico_path).info["sizes"])
|
||||
out = []
|
||||
for size in sizes:
|
||||
im = Image.open(ico_path)
|
||||
im.size = size
|
||||
out.append(im.convert("RGBA"))
|
||||
return out
|
||||
|
||||
|
||||
def build_group(images, first_id: int, blobs) -> bytes:
|
||||
"""GRPICONDIR + one GRPICONDIRENTRY per image."""
|
||||
data = struct.pack("<HHH", 0, 1, len(images))
|
||||
for i, (im, blob) in enumerate(zip(images, blobs)):
|
||||
w = 0 if im.width >= 256 else im.width
|
||||
h = 0 if im.height >= 256 else im.height
|
||||
data += struct.pack("<BBBBHHIH", w, h, 0, 0, 1, 32, len(blob), first_id + i)
|
||||
return data
|
||||
|
||||
|
||||
def patch(exe: Path, ico: Path, group_id: int, backup: bool) -> None:
|
||||
images = load_sizes(ico)
|
||||
blobs = [to_png(im) if im.width >= PNG_FROM else to_dib(im) for im in images]
|
||||
|
||||
if backup:
|
||||
bak = exe.with_suffix(exe.suffix + ".bak")
|
||||
if bak.exists():
|
||||
print(f" backup already exists, leaving it alone: {bak.name}")
|
||||
else:
|
||||
shutil.copy2(exe, bak)
|
||||
print(f" backup -> {bak.name}")
|
||||
|
||||
handle = kernel32.BeginUpdateResourceW(str(exe), False)
|
||||
if not handle:
|
||||
_check(False, "BeginUpdateResource")
|
||||
|
||||
try:
|
||||
for i, (im, blob) in enumerate(zip(images, blobs)):
|
||||
buf = ctypes.create_string_buffer(blob, len(blob))
|
||||
_check(
|
||||
kernel32.UpdateResourceW(
|
||||
handle, _int_resource(RT_ICON), _int_resource(i + 1),
|
||||
LANG_EN_US, ctypes.cast(buf, wintypes.LPVOID), len(blob),
|
||||
),
|
||||
f"UpdateResource RT_ICON #{i + 1}",
|
||||
)
|
||||
kind = "png" if im.width >= PNG_FROM else "dib"
|
||||
print(f" RT_ICON #{i + 1:<2} {im.width:>3}x{im.height:<3} {kind} {len(blob):>7,}b")
|
||||
|
||||
group = build_group(images, 1, blobs)
|
||||
buf = ctypes.create_string_buffer(group, len(group))
|
||||
_check(
|
||||
kernel32.UpdateResourceW(
|
||||
handle, _int_resource(RT_GROUP_ICON), _int_resource(group_id),
|
||||
LANG_EN_US, ctypes.cast(buf, wintypes.LPVOID), len(group),
|
||||
),
|
||||
"UpdateResource RT_GROUP_ICON",
|
||||
)
|
||||
print(f" RT_GROUP_ICON #{group_id} {len(images)} entries")
|
||||
except Exception:
|
||||
kernel32.EndUpdateResourceW(handle, True) # discard
|
||||
raise
|
||||
|
||||
_check(kernel32.EndUpdateResourceW(handle, False), "EndUpdateResource")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description=__doc__)
|
||||
ap.add_argument("exe", type=Path)
|
||||
ap.add_argument("--icon", type=Path, default=DEFAULT_ICO)
|
||||
ap.add_argument("--group-id", type=int, default=32512,
|
||||
help="RT_GROUP_ICON id the shell shows (default: 32512)")
|
||||
ap.add_argument("--no-backup", action="store_true")
|
||||
args = ap.parse_args()
|
||||
|
||||
if sys.platform != "win32":
|
||||
print("This tool uses the Windows resource API.", file=sys.stderr)
|
||||
return 1
|
||||
for p in (args.exe, args.icon):
|
||||
if not p.exists():
|
||||
print(f"not found: {p}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
print(f"{args.exe}")
|
||||
patch(args.exe, args.icon, args.group_id, not args.no_backup)
|
||||
print("done - Explorer caches icons, so run `ie4uinit.exe -show` if the old one lingers")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user