408 lines
12 KiB
Bash
Executable File
408 lines
12 KiB
Bash
Executable File
#!/bin/bash
|
||
set -euo pipefail
|
||
FONT_COLOR="#3C3C3C" # color for light text
|
||
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 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
|
||
|
||
# Tweakables
|
||
CORNER_RADIUS="auto" # corner radius; "auto" picks a good value based on width
|
||
INSET=2 # inset (px) to shave off simulator’s black edge pixels
|
||
SHADOW_OPACITY=0 # 0–100
|
||
SHADOW_BLUR=20 # blur radius
|
||
SHADOW_OFFSET_X=0 # px
|
||
SHADOW_OFFSET_Y=40 # 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"
|
||
|
||
# Function to render mixed-font text (light + semi-bold for *text*)
|
||
render_mixed_font_title() {
|
||
local canvas="$1"
|
||
local title_text="$2"
|
||
local title_y="$3"
|
||
local output="$4"
|
||
|
||
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
|
||
local line_length=${#line}
|
||
|
||
while [ $i -lt $line_length ]; do
|
||
local char="${line:$i:1}"
|
||
if [[ "$char" == "*" ]]; then
|
||
text_segments+=("$current_text")
|
||
if [[ "$in_bold" == true ]]; then
|
||
font_types+=("bold")
|
||
else
|
||
font_types+=("light")
|
||
fi
|
||
current_text=""
|
||
if [[ "$in_bold" == true ]]; then
|
||
in_bold=false
|
||
else
|
||
in_bold=true
|
||
fi
|
||
else
|
||
current_text+="$char"
|
||
fi
|
||
i=$((i + 1))
|
||
done
|
||
|
||
text_segments+=("$current_text")
|
||
if [[ "$in_bold" == true ]]; then
|
||
font_types+=("bold")
|
||
else
|
||
font_types+=("light")
|
||
fi
|
||
|
||
local total_width=0
|
||
for ((j = 0; j < ${#text_segments[@]}; j++)); do
|
||
local segment="${text_segments[$j]}"
|
||
if [[ -n "$segment" ]]; then
|
||
local font_for_measurement="$FONT"
|
||
if [[ "${font_types[$j]}" == "bold" ]]; then
|
||
font_for_measurement="$FONT_BOLD"
|
||
fi
|
||
local segment_for_measurement="$segment"
|
||
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
|
||
done
|
||
|
||
if (( total_width <= 0 )); then
|
||
continue
|
||
fi
|
||
|
||
local start_x=$(( (canvas_w - total_width) / 2 ))
|
||
local x_offset=0
|
||
for ((j = 0; j < ${#text_segments[@]}; j++)); do
|
||
local segment="${text_segments[$j]}"
|
||
if [[ -n "$segment" ]]; then
|
||
local font_to_use="$FONT"
|
||
local color_to_use="$FONT_COLOR"
|
||
if [[ "${font_types[$j]}" == "bold" ]]; then
|
||
font_to_use="$FONT_BOLD"
|
||
color_to_use="$FONT_BOLD_COLOR"
|
||
fi
|
||
local segment_for_rendering="$segment"
|
||
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))+${current_y}" "$segment_for_rendering" \
|
||
"$temp_img"
|
||
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
|
||
done
|
||
done
|
||
|
||
cp "$temp_img" "$output"
|
||
rm -f "$temp_img"
|
||
}
|
||
|
||
# Function to get title from config file
|
||
get_title() {
|
||
local lang="$1"
|
||
local screenshot_name="$2"
|
||
local config_file="./Shots/Titles/${lang}.conf"
|
||
|
||
# Extract view name from filename format: 03-LoadEditorView_0_5EE662BD-84C7-41AC-806E-EB8C7340A037.png
|
||
# Remove .png extension, then extract the part after the first dash and before the first underscore
|
||
local base_name=$(basename "$screenshot_name" .png)
|
||
# Remove leading number and dash (e.g., "03-")
|
||
base_name=${base_name#*-}
|
||
# Remove everything from the first underscore onwards (e.g., "_0_5EE662BD...")
|
||
base_name=${base_name%%_*}
|
||
|
||
# Try to find title in config file
|
||
if [[ -f "$config_file" ]]; then
|
||
local title=$(grep "^${base_name}=" "$config_file" 2>/dev/null | cut -d'=' -f2-)
|
||
if [[ -n "$title" ]]; then
|
||
echo "$title"
|
||
return
|
||
fi
|
||
fi
|
||
|
||
# Fallback to default title
|
||
echo "***NOT SET***"
|
||
}
|
||
|
||
# Function to frame one screenshot
|
||
frame_one () {
|
||
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 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")"
|
||
|
||
# Determine corner radius
|
||
local R
|
||
if [[ "$CORNER_RADIUS" == "auto" ]]; then
|
||
# Heuristic: ~1/12 of width works well for iPhone 6.9" (≈110px for 1320px width)
|
||
R=$(( W / 12 ))
|
||
else
|
||
R=$CORNER_RADIUS
|
||
fi
|
||
|
||
# Create rounded-corner mask the same size as the screenshot
|
||
local mask
|
||
mask="$(mktemp /tmp/mask.XXXXXX_$$.png)"
|
||
magick -size "${W}x${H}" xc:black \
|
||
-fill white -draw "roundrectangle ${INSET},${INSET},$((W-1-INSET)),$((H-1-INSET)),$R,$R" \
|
||
"$mask"
|
||
|
||
# Apply rounded corners + make a soft drop shadow
|
||
# 1) Rounded PNG
|
||
local rounded
|
||
rounded="$(mktemp /tmp/rounded.XXXXXX_$$.png)"
|
||
magick "$in" -alpha set "$mask" -compose copyopacity -composite "$rounded"
|
||
|
||
# 2) Shadow from rounded image
|
||
local shadow
|
||
shadow="$(mktemp /tmp/shadow.XXXXXX_$$.png)"
|
||
magick "$rounded" \
|
||
\( +clone -background black -shadow ${SHADOW_OPACITY}x${SHADOW_BLUR}+${SHADOW_OFFSET_X}+${SHADOW_OFFSET_Y} \) \
|
||
+swap -background none -layers merge +repage "$shadow"
|
||
|
||
# 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 canvas
|
||
canvas="$(mktemp /tmp/canvas.XXXXXX_$$.png)"
|
||
magick "$bg" -resize "${minW}x${minH}^" -gravity center -extent "${minW}x${minH}" "$canvas"
|
||
|
||
# Add title text above the screenshot
|
||
local title_text=$(get_title "$lang" "$screenshot_name")
|
||
local with_title
|
||
with_title="$(mktemp /tmp/with_title.XXXXXX_$$.png)"
|
||
|
||
# Calculate title position (center horizontally, positioned above the screenshot)
|
||
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 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 "${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
|
||
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
|
||
|
||
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/"
|