mirror of
https://github.com/ChrisTitusTech/winutil.git
synced 2026-10-09 21:21:18 +11:00
Automate WinUtil title screen generation (#4965)
* feat: add title screen generation tooling Add local automation for capturing WinUtil in Light and Dark themes and generating the composite title-screen image. * ci: automate title screen updates Add a manual Windows workflow that compiles WinUtil, generates the Light and Dark composite, and opens an image-only pull request. * docs: update AGENTS.md and SPEC.md with title-screen generation details * fix: address review comments Tested image is correct, SHA-256 hash is identical to the previously verified output. * fix: deselect bitmap before reading capture pixels
This commit is contained in:
@@ -0,0 +1,180 @@
|
||||
"""Create WinUtil's framed diagonal Light and Dark title-screen image.
|
||||
|
||||
WinUtil captures include a narrow near-black DWM shadow around the actual WPF
|
||||
window. The compositor detects that shadow from the Dark capture, applies the
|
||||
same crop to both themes, and blends them with a supersampled diagonal mask.
|
||||
Only the final composite is written; the cropped and framed theme images remain
|
||||
in memory.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
|
||||
BORDER_COLOR = (180, 180, 180, 255)
|
||||
BORDER_WIDTH = 2
|
||||
# DWM shadow pixels are nearly black. WinUtil's actual Dark theme remains above
|
||||
# this threshold at the window edges used by the bounds scan.
|
||||
BRIGHTNESS_THRESHOLD = 20
|
||||
LARGE_SHADOW_MARGIN = 30
|
||||
MASK_SCALE = 4
|
||||
# These endpoints keep the split clear of the left tab labels near the top and
|
||||
# the right-side action controls near the bottom of the Tweaks layout.
|
||||
TOP_SPLIT = 0.16
|
||||
BOTTOM_SPLIT = 0.86
|
||||
|
||||
|
||||
def _contains_content(pixel) -> bool:
|
||||
"""Return whether a pixel is brighter than the near-black DWM shadow."""
|
||||
return any(channel > BRIGHTNESS_THRESHOLD for channel in pixel[:3])
|
||||
|
||||
|
||||
def _find_content_bounds(image: Image.Image) -> tuple[int, int, int, int]:
|
||||
"""Return the non-shadow bounds detected from a Dark theme capture.
|
||||
|
||||
Every edge coordinate is checked because Windows 11 shadows can be narrower
|
||||
than ten pixels. Sampling at a fixed interval could skip the first content
|
||||
pixel and trim real UI from the final image.
|
||||
"""
|
||||
width, height = image.size
|
||||
pixels = image.load()
|
||||
|
||||
left = next(
|
||||
(
|
||||
x
|
||||
for x in range(width)
|
||||
if any(_contains_content(pixels[x, y]) for y in range(height))
|
||||
),
|
||||
0,
|
||||
)
|
||||
right = next(
|
||||
(
|
||||
x
|
||||
for x in range(width - 1, -1, -1)
|
||||
if any(_contains_content(pixels[x, y]) for y in range(height))
|
||||
),
|
||||
width - 1,
|
||||
)
|
||||
top = next(
|
||||
(
|
||||
y
|
||||
for y in range(height)
|
||||
if any(_contains_content(pixels[x, y]) for x in range(width))
|
||||
),
|
||||
0,
|
||||
)
|
||||
bottom = next(
|
||||
(
|
||||
y
|
||||
for y in range(height - 1, -1, -1)
|
||||
if any(_contains_content(pixels[x, y]) for x in range(width))
|
||||
),
|
||||
height - 1,
|
||||
)
|
||||
|
||||
right_margin = width - right - 1
|
||||
bottom_margin = height - bottom - 1
|
||||
margins = (left, top, right_margin, bottom_margin)
|
||||
if any(margin > LARGE_SHADOW_MARGIN for margin in margins):
|
||||
print(
|
||||
"Warning: unusually large shadow margins detected "
|
||||
f"(left={left}, top={top}, right={right_margin}, "
|
||||
f"bottom={bottom_margin}). Input images may already be cropped or "
|
||||
"framed."
|
||||
)
|
||||
|
||||
return left, top, right + 1, bottom + 1
|
||||
|
||||
|
||||
def _apply_frame(image: Image.Image) -> Image.Image:
|
||||
"""Return a copy with the title-screen border drawn inside its bounds."""
|
||||
framed = image.copy()
|
||||
width, height = framed.size
|
||||
draw = ImageDraw.Draw(framed)
|
||||
for inset in range(BORDER_WIDTH):
|
||||
draw.rectangle(
|
||||
[inset, inset, width - 1 - inset, height - 1 - inset],
|
||||
outline=BORDER_COLOR,
|
||||
)
|
||||
return framed
|
||||
|
||||
|
||||
def create_composite(
|
||||
dark_path: str | Path,
|
||||
light_path: str | Path,
|
||||
output_path: str | Path,
|
||||
) -> Path:
|
||||
"""Create one framed diagonal composite from matching theme captures."""
|
||||
dark_path = Path(dark_path)
|
||||
light_path = Path(light_path)
|
||||
output_path = Path(output_path)
|
||||
|
||||
with Image.open(dark_path) as dark_source:
|
||||
dark_image = dark_source.convert("RGBA")
|
||||
with Image.open(light_path) as light_source:
|
||||
light_image = light_source.convert("RGBA")
|
||||
|
||||
if dark_image.size != light_image.size:
|
||||
raise ValueError(
|
||||
"Image dimensions do not match: "
|
||||
f"Dark is {dark_image.size}, Light is {light_image.size}"
|
||||
)
|
||||
|
||||
crop_box = _find_content_bounds(dark_image)
|
||||
print(f"Content crop box (stripping DWM shadow): {crop_box}")
|
||||
|
||||
dark_framed = _apply_frame(dark_image.crop(crop_box))
|
||||
light_framed = _apply_frame(light_image.crop(crop_box))
|
||||
width, height = dark_framed.size
|
||||
|
||||
# Drawing the mask at four times the target resolution and downsampling it
|
||||
# with Lanczos produces a smooth diagonal without softening either UI image.
|
||||
mask_width = width * MASK_SCALE
|
||||
mask_height = height * MASK_SCALE
|
||||
mask_high_resolution = Image.new("L", (mask_width, mask_height), 0)
|
||||
draw_mask = ImageDraw.Draw(mask_high_resolution)
|
||||
draw_mask.polygon(
|
||||
[
|
||||
(0, 0),
|
||||
(TOP_SPLIT * mask_width, 0),
|
||||
(BOTTOM_SPLIT * mask_width, mask_height),
|
||||
(0, mask_height),
|
||||
],
|
||||
fill=255,
|
||||
)
|
||||
mask = mask_high_resolution.resize(
|
||||
(width, height),
|
||||
resample=Image.Resampling.LANCZOS,
|
||||
)
|
||||
|
||||
# A white mask selects Light, so Light occupies the left side and Dark the
|
||||
# right. Reapplying the frame removes any mask antialiasing at the outer edge.
|
||||
composite = Image.composite(light_framed, dark_framed, mask)
|
||||
composite = _apply_frame(composite)
|
||||
composite.save(output_path, "PNG")
|
||||
|
||||
print(f"Generated title-screen composite: {output_path} ({width}x{height})")
|
||||
return output_path
|
||||
|
||||
|
||||
def _parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Create WinUtil's diagonal Light and Dark title-screen image."
|
||||
)
|
||||
parser.add_argument("dark", type=Path, help="Raw Dark theme PNG")
|
||||
parser.add_argument("light", type=Path, help="Raw Light theme PNG")
|
||||
parser.add_argument("output", type=Path, help="Destination composite PNG")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = _parse_args()
|
||||
create_composite(args.dark, args.light, args.output)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user