ASO: renderers dark-panorama (iPhone + iPad) para screenshots 1.5.0

frame_panorama_codex.py / _ipad.py: panorámica premium oscura (Impact, marco real,
+22.7% verde, línea continua) con copy localizado nativo en 7 idiomas. Usado para
las screenshots publicadas de la 1.5.0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L2p3gUZRNWW388rWFRjiU7
This commit is contained in:
alexandrev-tibco
2026-07-25 11:47:52 +02:00
parent 8d5f309f21
commit 20a73ea957
4 changed files with 1223 additions and 26 deletions
+246 -13
View File
@@ -23,6 +23,13 @@ DEFAULT_GRAD_TOP = "#0B5FFF"
DEFAULT_GRAD_BOTTOM = "#00C2A8"
HEX_RE = re.compile(r"#[0-9A-Fa-f]{6}\b")
# --- Dark premium / fintech theme -----------------------------------------
DARK_GRAD_TOP = "#0F172A" # slate-900
DARK_GRAD_BOTTOM = "#020617" # near black
HEADLINE_COLOR = (248, 250, 252) # #F8FAFC near-white
ACCENT_COLOR = (52, 211, 153) # #34D399 vibrant green
MUTED_COLOR = (148, 163, 184) # #94A3B8 muted slate
FONT_CANDIDATES = (
# CJK-capable macOS fonts first.
@@ -38,6 +45,24 @@ FONT_CANDIDATES = (
"/System/Library/Fonts/SFNS.ttf",
)
# Bold-weight candidates for headline / stat callout.
BOLD_FONT_CANDIDATES = (
"/System/Library/Fonts/ヒラギノ角ゴシック W7.ttc",
"/System/Library/Fonts/ヒラギノ角ゴシック W6.ttc",
"/System/Library/Fonts/Supplemental/Arial Bold.ttf",
"/System/Library/Fonts/SFNS.ttf",
"/System/Library/Fonts/Supplemental/Arial.ttf",
)
# Regular-weight candidates for secondary / substat text.
REGULAR_FONT_CANDIDATES = (
"/System/Library/Fonts/ヒラギノ角ゴシック W4.ttc",
"/System/Library/Fonts/ヒラギノ角ゴシック W5.ttc",
"/System/Library/Fonts/Supplemental/Arial.ttf",
"/System/Library/Fonts/SFNS.ttf",
"/System/Library/Fonts/Supplemental/Arial Unicode.ttf",
)
def parse_hex_color(value: str) -> tuple[int, int, int]:
"""Return an RGB tuple from #RRGGBB."""
@@ -140,8 +165,11 @@ def make_vertical_gradient(size: tuple[int, int], top: str, bottom: str) -> Imag
return gradient.convert("RGBA")
def load_font(size: int) -> ImageFont.ImageFont:
for font_path in FONT_CANDIDATES:
def load_font(
size: int,
candidates: Sequence[str] = FONT_CANDIDATES,
) -> ImageFont.ImageFont:
for font_path in candidates:
path = Path(font_path)
if not path.exists():
continue
@@ -158,6 +186,14 @@ def load_font(size: int) -> ImageFont.ImageFont:
return ImageFont.load_default()
def load_bold_font(size: int) -> ImageFont.ImageFont:
return load_font(size, BOLD_FONT_CANDIDATES)
def load_regular_font(size: int) -> ImageFont.ImageFont:
return load_font(size, REGULAR_FONT_CANDIDATES)
def text_bbox(draw: ImageDraw.ImageDraw, text: str, font: ImageFont.ImageFont) -> tuple[int, int]:
if not text:
return 0, 0
@@ -244,19 +280,21 @@ def fit_headline(
max_width: int,
max_height: int,
draw: ImageDraw.ImageDraw,
max_scale: float = 0.052,
font_loader=load_font,
) -> tuple[str, ImageFont.ImageFont, int, int]:
_, height = canvas_size
max_size = max(34, round(height * 0.052))
max_size = max(34, round(height * max_scale))
min_size = 28
for size in range(max_size, min_size - 1, -2):
font = load_font(size)
font = font_loader(size)
wrapped = wrap_headline(headline, font, max_width, draw)
text_width, text_height = text_bbox(draw, wrapped, font)
if text_width <= max_width and text_height <= max_height:
return wrapped, font, text_width, text_height
font = load_font(min_size)
font = font_loader(min_size)
wrapped = wrap_headline(headline, font, max_width, draw)
text_width, text_height = text_bbox(draw, wrapped, font)
return wrapped, font, text_width, text_height
@@ -276,12 +314,32 @@ def paste_with_shadow(
screenshot: Image.Image,
position: tuple[int, int],
radius: int,
glow: bool = False,
) -> None:
x, y = position
shadow_offset = max(10, round(canvas.width * 0.014))
shadow_blur = max(18, round(canvas.width * 0.025))
shadow_opacity = 105
if glow:
# Soft light halo so the device lifts off the dark background.
halo_blur = max(28, round(canvas.width * 0.05))
halo_pad = max(24, round(canvas.width * 0.03))
halo_size = (screenshot.width + halo_pad * 2, screenshot.height + halo_pad * 2)
halo = Image.new("RGBA", halo_size, (0, 0, 0, 0))
halo_mask = Image.new("L", halo_size, 0)
ImageDraw.Draw(halo_mask).rounded_rectangle(
(halo_pad, halo_pad, halo_pad + screenshot.width, halo_pad + screenshot.height),
radius=radius,
fill=70,
)
halo.putalpha(halo_mask)
# Tint the halo toward a cool light so it reads as premium elevation.
halo_rgb = Image.new("RGBA", halo_size, (148, 197, 255, 0))
halo_rgb.putalpha(halo.getchannel("A"))
halo_rgb = halo_rgb.filter(ImageFilter.GaussianBlur(halo_blur))
canvas.alpha_composite(halo_rgb, (x - halo_pad, y - halo_pad))
shadow = Image.new("RGBA", screenshot.size, (0, 0, 0, 0))
mask = Image.new("L", screenshot.size, 0)
ImageDraw.Draw(mask).rounded_rectangle((0, 0, screenshot.width, screenshot.height), radius=radius, fill=shadow_opacity)
@@ -292,9 +350,17 @@ def paste_with_shadow(
canvas.alpha_composite(rounded_image(screenshot, radius), position)
def resize_screenshot(image: Image.Image, canvas_width: int, canvas_height: int) -> Image.Image:
target_width = round(canvas_width * 0.78)
def resize_screenshot(
image: Image.Image,
canvas_width: int,
canvas_height: int,
width_frac: float = 0.78,
max_height_px: int | None = None,
) -> Image.Image:
target_width = round(canvas_width * width_frac)
target_height = round(canvas_height * 0.72)
if max_height_px is not None:
target_height = min(target_height, max_height_px)
scale = min(target_width / image.width, target_height / image.height, 1.0)
new_size = (max(1, round(image.width * scale)), max(1, round(image.height * scale)))
if new_size == image.size:
@@ -311,8 +377,18 @@ def frame_screenshot(
grad_top: str,
grad_bottom: str,
device: str | None = None,
theme: str = "light",
stat: str | None = None,
substat: str | None = None,
) -> None:
del device # Reserved for future per-device tuning.
if theme == "dark":
frame_screenshot_dark(
input_path, output_path, width, height,
headline, grad_top, grad_bottom, stat, substat,
)
return
canvas = make_vertical_gradient((width, height), grad_top, grad_bottom)
draw = ImageDraw.Draw(canvas)
@@ -353,6 +429,133 @@ def frame_screenshot(
canvas.convert("RGB").save(output_path, "PNG")
def frame_screenshot_dark(
input_path: Path,
output_path: Path,
width: int,
height: int,
headline: str,
grad_top: str,
grad_bottom: str,
stat: str | None,
substat: str | None,
) -> None:
"""Dark premium / fintech framing: near-black gradient, near-white bold
headline, optional big green stat callout, and a device shot lifted off the
background with a subtle light glow."""
canvas = make_vertical_gradient((width, height), grad_top, grad_bottom)
draw = ImageDraw.Draw(canvas)
horizontal_margin = round(width * 0.08)
top_margin = round(height * 0.055)
max_text_width = width - horizontal_margin * 2
line_spacing = max(8, round(height * 0.006))
# --- Headline: bold, near-white, slightly tighter/smaller than the light
# theme so it reads premium rather than shouty.
headline_area_height = round(height * 0.11)
wrapped, headline_font, _, headline_h = fit_headline(
headline,
(width, height),
max_text_width,
headline_area_height,
draw,
max_scale=0.044,
font_loader=load_bold_font,
)
cursor_y = top_margin
draw.multiline_text(
(width // 2, cursor_y),
wrapped,
font=headline_font,
fill=HEADLINE_COLOR + (255,),
anchor="ma",
align="center",
spacing=line_spacing,
)
cursor_y += headline_h
# --- Optional stat callout: big vibrant-green number + muted secondary.
if stat:
cursor_y += round(height * 0.022)
stat_size = max(48, round(height * 0.062))
stat_font = load_bold_font(stat_size)
# Shrink to fit width if the number is long.
while stat_size > 40:
if text_bbox(draw, stat, stat_font)[0] <= max_text_width:
break
stat_size -= 2
stat_font = load_bold_font(stat_size)
_, stat_h = text_bbox(draw, stat, stat_font)
draw.text(
(width // 2, cursor_y),
stat,
font=stat_font,
fill=ACCENT_COLOR + (255,),
anchor="ma",
)
cursor_y += stat_h
if substat:
cursor_y += round(height * 0.010)
sub_size = max(26, round(height * 0.026))
sub_font = load_regular_font(sub_size)
sub_wrapped = wrap_headline(substat, sub_font, max_text_width, draw)
_, sub_h = text_bbox(draw, sub_wrapped, sub_font)
# A leading "▲" (or up-arrow) is a positive signal -> green.
sub_color = ACCENT_COLOR if substat.strip().startswith(("", "", "+")) else MUTED_COLOR
draw.multiline_text(
(width // 2, cursor_y),
sub_wrapped,
font=sub_font,
fill=sub_color + (255,),
anchor="ma",
align="center",
spacing=line_spacing,
)
cursor_y += sub_h
elif substat:
# substat without a big stat -> supporting line. Positive-signal
# prefixes (▲ ↑ +) get the green accent; otherwise muted slate.
cursor_y += round(height * 0.014)
sub_size = max(26, round(height * 0.030))
sub_font = load_bold_font(sub_size)
sub_wrapped = wrap_headline(substat, sub_font, max_text_width, draw)
_, sub_h = text_bbox(draw, sub_wrapped, sub_font)
sub_color = ACCENT_COLOR if substat.strip().startswith(("", "", "+")) else MUTED_COLOR
draw.multiline_text(
(width // 2, cursor_y),
sub_wrapped,
font=sub_font,
fill=sub_color + (255,),
anchor="ma",
align="center",
spacing=line_spacing,
)
cursor_y += sub_h
# --- Device shot below the text block, with glow + shadow.
# The device top is pinned just under the text block; it is scaled down so
# it always fits the remaining space and never overlaps the copy.
screenshot_y = cursor_y + round(height * 0.05)
available_bottom = height - round(height * 0.045)
max_device_height = available_bottom - screenshot_y
screenshot = resize_screenshot(
Image.open(input_path), width, height, max_height_px=max_device_height
)
screenshot_x = (width - screenshot.width) // 2
# Center the device within the space left below the text for balance.
slack = max(0, (available_bottom - screenshot_y) - screenshot.height)
screenshot_y += slack // 2
radius = max(24, round(width * 0.03))
paste_with_shadow(canvas, screenshot, (screenshot_x, screenshot_y), radius, glow=True)
output_path.parent.mkdir(parents=True, exist_ok=True)
canvas.convert("RGB").save(output_path, "PNG")
def positive_int(value: str) -> int:
number = int(value)
if number <= 0:
@@ -378,8 +581,28 @@ def build_parser() -> argparse.ArgumentParser:
parser.add_argument("--width", required=True, type=positive_int, help="Final canvas width.")
parser.add_argument("--height", required=True, type=positive_int, help="Final canvas height.")
parser.add_argument("--headline", required=True, help="Localized headline text.")
parser.add_argument("--grad-top", required=True, help="Top gradient color as #RRGGBB.")
parser.add_argument("--grad-bottom", required=True, help="Bottom gradient color as #RRGGBB.")
parser.add_argument(
"--grad-top",
help="Top gradient color as #RRGGBB (defaults per theme).",
)
parser.add_argument(
"--grad-bottom",
help="Bottom gradient color as #RRGGBB (defaults per theme).",
)
parser.add_argument(
"--theme",
choices=("light", "dark"),
default="light",
help="Framing theme. 'dark' = dark premium / fintech look.",
)
parser.add_argument(
"--stat",
help="Optional big accent stat callout, e.g. '€717,382' (dark theme).",
)
parser.add_argument(
"--substat",
help="Optional smaller secondary line, e.g. '▲ +9.4% since last check-in'.",
)
parser.add_argument("--device", choices=("iphone", "ipad"), help="Optional device family hint.")
return parser
@@ -388,8 +611,15 @@ def main(argv: Sequence[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
parse_hex_color(args.grad_top)
parse_hex_color(args.grad_bottom)
if args.theme == "dark":
grad_top = args.grad_top or DARK_GRAD_TOP
grad_bottom = args.grad_bottom or DARK_GRAD_BOTTOM
else:
grad_top = args.grad_top or DEFAULT_GRAD_TOP
grad_bottom = args.grad_bottom or DEFAULT_GRAD_BOTTOM
parse_hex_color(grad_top)
parse_hex_color(grad_bottom)
print_brand_colors(find_repo_root(Path(__file__).resolve()))
frame_screenshot(
@@ -398,9 +628,12 @@ def main(argv: Sequence[str] | None = None) -> int:
width=args.width,
height=args.height,
headline=args.headline,
grad_top=args.grad_top,
grad_bottom=args.grad_bottom,
grad_top=grad_top,
grad_bottom=grad_bottom,
device=args.device,
theme=args.theme,
stat=args.stat,
substat=args.substat,
)
print(f"Wrote {args.output} ({args.width}x{args.height})")
return 0