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:
+246
-13
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user