better presentation fot the App Store

This commit is contained in:
Stefan Lange-Hegermann
2025-10-20 15:35:29 +02:00
parent dd13178f0e
commit 420a6ea014
25 changed files with 1332 additions and 258 deletions

View File

@@ -1,11 +1,58 @@
#!/bin/bash
set -euo pipefail
FONT_COLOR="#3C3C3C" # color for light text
FONT_BOLD_COLOR="#B51700" # color for bold texto pipefail
FONT_BOLD_COLOR="#B51700" # color for bold text
ONLY_IPHONE=false
usage() {
cat <<'EOF'
Usage: frame_screens.sh [--iphone-only] [SRC_ROOT] [BG_IMAGE] [OUT_ROOT]
--iphone-only Only frame screenshots whose device slug is not iPad.
SRC_ROOT Root folder with device/lang subfolders (default: Shots/Screenshots)
BG_IMAGE Background image to use (default: Shots/frame-bg.png)
OUT_ROOT Output folder for framed shots (default: Shots/Framed)
EOF
}
POSITIONAL_ARGS=()
while [[ $# -gt 0 ]]; do
case "$1" in
--iphone-only)
ONLY_IPHONE=true
shift
;;
-h|--help)
usage
exit 0
;;
--)
shift
POSITIONAL_ARGS+=("$@")
break
;;
-*)
echo "Unknown option: $1" >&2
usage >&2
exit 1
;;
*)
POSITIONAL_ARGS+=("$1")
shift
;;
esac
done
if ((${#POSITIONAL_ARGS[@]})); then
set -- "${POSITIONAL_ARGS[@]}"
else
set --
fi
# Inputs
SRC_ROOT="${1:-Shots/Screenshots}" # root folder with lang subfolders (de/, fr/, en/…)
BG_IMAGE="${2:-Shots/frame-bg.png}" # background image (portrait)
SRC_ROOT="${1:-Shots/Screenshots}" # root folder with device/lang subfolders (iphone-17-pro-max/en/, ipad-pro-13-inch-m4/de/…)
BG_IMAGE="${2:-Shots/frame-bg.png}" # default background image
OUT_ROOT="${3:-Shots/Framed}" # output folder
FONT="./Shots/Fonts/Oswald-Light.ttf" # font for title text
FONT_BOLD="./Shots/Fonts/Oswald-SemiBold.ttf" # font for *emphasized* text
@@ -13,12 +60,22 @@ FONT_BOLD="./Shots/Fonts/Oswald-SemiBold.ttf" # font for *emphasized* text
# Tweakables
CORNER_RADIUS="auto" # corner radius; "auto" picks a good value based on width
INSET=2 # inset (px) to shave off simulators black edge pixels
SHADOW_OPACITY=60 # 0100
SHADOW_OPACITY=0 # 0100
SHADOW_BLUR=20 # blur radius
SHADOW_OFFSET_X=0 # px
SHADOW_OFFSET_Y=40 # px
CANVAS_MARGIN=190 # margin around the device on the background, px
TITLE_MARGIN=120 # margin above the device for title text, px
CANVAS_MARGIN=245 # default margin around the device on the background, px
TITLE_MARGIN=378 # default margin above the device for title text, px
TITLE_LINE_SPACING=220 # vertical distance between lines of the title text, px
# Device-specific overrides (can be tuned via env vars)
TARGET_WIDTH_PHONE="${TARGET_WIDTH_PHONE:-1320}"
TARGET_HEIGHT_PHONE="${TARGET_HEIGHT_PHONE:-2868}"
TARGET_WIDTH_IPAD="${TARGET_WIDTH_IPAD:-2048}"
TARGET_HEIGHT_IPAD="${TARGET_HEIGHT_IPAD:-2732}"
IPAD_BG_IMAGE="${IPAD_BG_IMAGE:-$BG_IMAGE}"
IPAD_CANVAS_MARGIN="${IPAD_CANVAS_MARGIN:-240}"
IPAD_TITLE_MARGIN="${IPAD_TITLE_MARGIN:-180}"
mkdir -p "$OUT_ROOT"
@@ -28,29 +85,43 @@ render_mixed_font_title() {
local title_text="$2"
local title_y="$3"
local output="$4"
if [[ "$title_text" == *"*"* ]]; then
# Get canvas dimensions
read -r canvas_w canvas_h <<<"$(identify -format "%w %h" "$canvas")"
# Create a temporary image to measure and render text parts
local temp_img
temp_img="$(mktemp /tmp/text_temp.XXXXXX_$$.png)"
cp "$canvas" "$temp_img"
# Parse text into segments with their font types
declare -a text_segments=()
declare -a font_types=()
local expanded_title
expanded_title="$(printf '%b' "$title_text")"
local temp_img
temp_img="$(mktemp /tmp/text_temp.XXXXXX_$$.png)"
cp "$canvas" "$temp_img"
read -r canvas_w canvas_h <<<"$(identify -format "%w %h" "$canvas")"
local -a lines=()
while IFS= read -r line || [[ -n "$line" ]]; do
lines+=("$line")
done < <(printf '%s' "$expanded_title")
if ((${#lines[@]} == 0)); then
lines+=("$expanded_title")
fi
if ((${#lines[@]} > 2)); then
lines=("${lines[@]:0:2}")
fi
for idx in "${!lines[@]}"; do
local line="${lines[$idx]}"
local current_y=$((title_y + idx * TITLE_LINE_SPACING))
local -a text_segments=()
local -a font_types=()
local current_text=""
local in_bold=false
local i=0
while [ $i -lt ${#title_text} ]; do
local char="${title_text:$i:1}"
local line_length=${#line}
while [ $i -lt $line_length ]; do
local char="${line:$i:1}"
if [[ "$char" == "*" ]]; then
# Save current segment (even if empty, to handle cases like "**")
text_segments+=("$current_text")
if [[ "$in_bold" == true ]]; then
font_types+=("bold")
@@ -58,7 +129,6 @@ render_mixed_font_title() {
font_types+=("light")
fi
current_text=""
# Toggle bold state
if [[ "$in_bold" == true ]]; then
in_bold=false
else
@@ -69,95 +139,62 @@ render_mixed_font_title() {
fi
i=$((i + 1))
done
# Handle remaining text
if [[ -n "$current_text" ]]; then
text_segments+=("$current_text")
if [[ "$in_bold" == true ]]; then
font_types+=("bold")
else
font_types+=("light")
fi
text_segments+=("$current_text")
if [[ "$in_bold" == true ]]; then
font_types+=("bold")
else
font_types+=("light")
fi
# Debug: print segments (remove this later)
echo "DEBUG: Text segments:"
local debug_i=0
while [ $debug_i -lt ${#text_segments[@]} ]; do
echo " [$debug_i]: '${text_segments[$debug_i]}' (${font_types[$debug_i]})"
debug_i=$((debug_i + 1))
done
# Calculate total width
local total_width=0
local j=0
while [ $j -lt ${#text_segments[@]} ]; do
for ((j = 0; j < ${#text_segments[@]}; j++)); do
local segment="${text_segments[$j]}"
local font_type="${font_types[$j]}"
# Skip empty segments for width calculation
if [[ -n "$segment" ]]; then
local font_for_measurement="$FONT"
if [[ "$font_type" == "bold" ]]; then
if [[ "${font_types[$j]}" == "bold" ]]; then
font_for_measurement="$FONT_BOLD"
fi
# Replace leading/trailing spaces with non-breaking spaces for measurement
local segment_for_measurement="$segment"
segment_for_measurement="${segment_for_measurement/#/ }" # leading space
segment_for_measurement="${segment_for_measurement/%/ }" # trailing space
local part_width=$(magick -font "$font_for_measurement" -pointsize 148 -size x label:"$segment_for_measurement" -format "%w" info:)
segment_for_measurement="${segment_for_measurement/#/ }"
segment_for_measurement="${segment_for_measurement/%/ }"
local part_width
part_width=$(magick -font "$font_for_measurement" -pointsize 148 -size x label:"$segment_for_measurement" -format "%w" info:)
total_width=$((total_width + part_width))
fi
j=$((j + 1))
done
# Calculate starting X position to center the entire text
if (( total_width <= 0 )); then
continue
fi
local start_x=$(( (canvas_w - total_width) / 2 ))
# Render each segment
local x_offset=0
j=0
while [ $j -lt ${#text_segments[@]} ]; do
for ((j = 0; j < ${#text_segments[@]}; j++)); do
local segment="${text_segments[$j]}"
local font_type="${font_types[$j]}"
# Skip empty segments for rendering
if [[ -n "$segment" ]]; then
local font_to_use="$FONT"
local color_to_use="$FONT_COLOR"
if [[ "$font_type" == "bold" ]]; then
if [[ "${font_types[$j]}" == "bold" ]]; then
font_to_use="$FONT_BOLD"
color_to_use="$FONT_BOLD_COLOR"
fi
# Replace leading/trailing spaces with non-breaking spaces for rendering
local segment_for_rendering="$segment"
segment_for_rendering="${segment_for_rendering/#/ }" # leading space
segment_for_rendering="${segment_for_rendering/%/ }" # trailing space
segment_for_rendering="${segment_for_rendering/#/ }"
segment_for_rendering="${segment_for_rendering/%/ }"
magick "$temp_img" \
-font "$font_to_use" -pointsize 148 -fill "$color_to_use" \
-gravity northwest -annotate "+$((start_x + x_offset))+${title_y}" "$segment_for_rendering" \
-gravity northwest -annotate "+$((start_x + x_offset))+${current_y}" "$segment_for_rendering" \
"$temp_img"
# Calculate width of rendered text for next position (use same processed segment)
local text_width=$(magick -font "$font_to_use" -pointsize 148 -size x label:"$segment_for_rendering" -format "%w" info:)
local text_width
text_width=$(magick -font "$font_to_use" -pointsize 148 -size x label:"$segment_for_rendering" -format "%w" info:)
x_offset=$((x_offset + text_width))
fi
j=$((j + 1))
done
cp "$temp_img" "$output"
rm -f "$temp_img"
else
# No asterisks, simple rendering
magick "$canvas" \
-font "$FONT" -pointsize 148 -fill "$FONT_COLOR" \
-gravity north -annotate "+0+${title_y}" "$title_text" \
"$output"
fi
done
cp "$temp_img" "$output"
rm -f "$temp_img"
}
# Function to get title from config file
@@ -189,11 +226,15 @@ get_title() {
# Function to frame one screenshot
frame_one () {
local in="$1" # input screenshot (e.g., 1320x2868)
local out="$2" # output image
local in="$1" # input screenshot (e.g., 1320x2868)
local out="$2" # output image
local bg="$3"
local lang="$4" # language code (e.g., "de", "en")
local screenshot_name="$5" # screenshot filename
local lang="$4" # language code (e.g., "de", "en")
local screenshot_name="$5" # screenshot filename
local target_width="$6"
local target_height="$7"
local canvas_margin="$8"
local title_margin="$9"
# Read sizes
read -r W H <<<"$(identify -format "%w %h" "$in")"
@@ -230,8 +271,8 @@ frame_one () {
# Compose on the background, centered
# First, scale background to be at least screenshot+margin in both dimensions
read -r BW BH <<<"$(identify -format "%w %h" "$bg")"
local minW=$((W + 2*CANVAS_MARGIN))
local minH=$((H + 2*CANVAS_MARGIN + TITLE_MARGIN))
local minW=$((W + 2*canvas_margin))
local minH=$((H + 2*canvas_margin + title_margin))
local canvas
canvas="$(mktemp /tmp/canvas.XXXXXX_$$.png)"
magick "$bg" -resize "${minW}x${minH}^" -gravity center -extent "${minW}x${minH}" "$canvas"
@@ -242,35 +283,125 @@ frame_one () {
with_title="$(mktemp /tmp/with_title.XXXXXX_$$.png)"
# Calculate title position (center horizontally, positioned above the screenshot)
local title_y=$((TITLE_MARGIN - 10)) # 10px from top of title margin
local title_y=$((title_margin - 100)) # 10px from top of title margin
# Render title with mixed fonts
render_mixed_font_title "$canvas" "$title_text" "$title_y" "$with_title"
# Now place shadow (which already includes the rounded image) positioned below the title
# Calculate the vertical offset to center the screenshot in the remaining space below the title
local screenshot_offset=$((TITLE_MARGIN*2))
local screenshot_offset=$((title_margin*2))
local temp_result
temp_result="$(mktemp /tmp/temp_result.XXXXXX_$$.png)"
magick "$with_title" "$shadow" -gravity center -geometry "+0+${screenshot_offset}" -compose over -composite "$temp_result"
# Final step: scale to exact dimensions 1320 × 2868px
magick "$temp_result" -resize "1320x2868^" -gravity center -extent "1320x2868" "$out"
magick "$temp_result" -resize "${target_width}x${target_height}^" -gravity center -extent "${target_width}x${target_height}" "$out"
rm -f "$mask" "$rounded" "$shadow" "$canvas" "$with_title" "$temp_result"
}
# Process all screenshots in SRC_ROOT/*/*.png
resolve_device_profile() {
local device_slug="$1"
PROFILE_BG="$BG_IMAGE"
PROFILE_TARGET_WIDTH="$TARGET_WIDTH_PHONE"
PROFILE_TARGET_HEIGHT="$TARGET_HEIGHT_PHONE"
PROFILE_CANVAS_MARGIN="$CANVAS_MARGIN"
PROFILE_TITLE_MARGIN="$TITLE_MARGIN"
if [[ -n "$device_slug" ]]; then
local slug_lower
slug_lower="$(printf '%s' "$device_slug" | tr '[:upper:]' '[:lower:]')"
if [[ "$slug_lower" == *"ipad"* ]]; then
PROFILE_BG="$IPAD_BG_IMAGE"
PROFILE_TARGET_WIDTH="$TARGET_WIDTH_IPAD"
PROFILE_TARGET_HEIGHT="$TARGET_HEIGHT_IPAD"
PROFILE_CANVAS_MARGIN="$IPAD_CANVAS_MARGIN"
PROFILE_TITLE_MARGIN="$IPAD_TITLE_MARGIN"
fi
fi
}
process_lang_dir() {
local lang_path="$1"
local lang="$2"
local device_slug="$3"
local out_dir="$OUT_ROOT"
local log_prefix="$lang"
if [[ -n "$device_slug" ]]; then
out_dir="$out_dir/$device_slug"
log_prefix="$device_slug/$lang"
fi
out_dir="$out_dir/$lang"
mkdir -p "$out_dir"
resolve_device_profile "$device_slug"
shopt -s nullglob
for shot in "$lang_path"/*.png; do
local base="$(basename "$shot")"
frame_one \
"$shot" \
"$out_dir/$base" \
"$PROFILE_BG" \
"$lang" \
"$base" \
"$PROFILE_TARGET_WIDTH" \
"$PROFILE_TARGET_HEIGHT" \
"$PROFILE_CANVAS_MARGIN" \
"$PROFILE_TITLE_MARGIN"
echo "Framed: $log_prefix/$base"
done
}
shopt -s nullglob
for langdir in "$SRC_ROOT"/*; do
[[ -d "$langdir" ]] || continue
rel="$(basename "$langdir")"
mkdir -p "$OUT_ROOT/$rel"
for shot in "$langdir"/*.png; do
base="$(basename "$shot")"
frame_one "$shot" "$OUT_ROOT/$rel/$base" "$BG_IMAGE" "$rel" "$base"
echo "Framed: $rel/$base"
found_any=false
skipped_for_device=false
for entry in "$SRC_ROOT"/*; do
[[ -d "$entry" ]] || continue
entry_basename="$(basename "$entry")"
entry_lower="$(printf '%s' "$entry_basename" | tr '[:upper:]' '[:lower:]')"
if [[ "$ONLY_IPHONE" == true && "$entry_lower" == *"ipad"* ]]; then
skipped_for_device=true
continue
fi
pattern="${entry%/}/*.png"
if compgen -G "$pattern" > /dev/null; then
process_lang_dir "$entry" "$(basename "$entry")" ""
found_any=true
continue
fi
for langdir in "$entry"/*; do
[[ -d "$langdir" ]] || continue
if [[ "$ONLY_IPHONE" == true ]]; then
lang_device_slug="$(basename "$entry")"
lang_slug_lower="$(printf '%s' "$lang_device_slug" | tr '[:upper:]' '[:lower:]')"
if [[ "$lang_slug_lower" == *"ipad"* ]]; then
skipped_for_device=true
continue
fi
fi
pattern="${langdir%/}/*.png"
if compgen -G "$pattern" > /dev/null; then
process_lang_dir "$langdir" "$(basename "$langdir")" "$(basename "$entry")"
found_any=true
fi
done
done
echo "Done. Framed images in: $OUT_ROOT/"
if [[ "$found_any" == false ]]; then
if [[ "$ONLY_IPHONE" == true && "$skipped_for_device" == true ]]; then
echo "No iPhone screenshots found under $SRC_ROOT" >&2
else
echo "No screenshots found under $SRC_ROOT" >&2
fi
fi
echo "Done. Framed images in: $OUT_ROOT/"