Files
Cable/frame_screens.sh
Stefan Lange-Hegermann 34e8c0f74b Add duty cycle/utilization fields, wheel picker for goals, and updated screenshots
- Add dutyCyclePercent and defaultUtilizationFactorPercent to ComponentLibraryItem
  with normalization logic and backend field fetching
- Change default dailyUsageHours from 1h to 24h
- Replace goal editor stepper with day/hour/minute wheel pickers
- Update app icon colors and remove duplicate icon assets
- Move SavedBattery.swift into Batteries/ directory, remove Pods group
- Add iPad-only flag and start frame support to screenshot framing scripts
- Rework localized App Store screenshot titles across all languages
- Add runtime goals and BOM completed items to sample data
- Bump version to 1.5.1 (build 41)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 21:49:21 +01:00

591 lines
18 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/bin/bash
set -euo pipefail
FONT_COLOR="#3C3C3C" # color for light text
FONT_BOLD_COLOR="#B51700" # color for bold text
ONLY_IPHONE=false
ONLY_IPAD=false
usage() {
cat <<'EOF'
Usage: frame_screens.sh [--iphone-only|--ipad-only] [SRC_ROOT] [BG_IMAGE] [OUT_ROOT]
--iphone-only Only frame screenshots whose device slug is not iPad.
--ipad-only Only frame screenshots whose device slug contains 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
;;
--ipad-only)
ONLY_IPAD=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 [[ "$ONLY_IPHONE" == true && "$ONLY_IPAD" == true ]]; then
echo "Cannot use --iphone-only and --ipad-only together." >&2
exit 1
fi
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-phone.png}" # default background image for iPhone shots
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
TITLE_POINTSIZE="${TITLE_POINTSIZE:-148}" # baseline point size for titles
# 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=0 # 0100
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
START_FRAME_IMAGE="${START_FRAME_IMAGE:-Shots/frame-start-iphone.png}" # static framed asset for the intro screen
START_FRAME_FILENAME="${START_FRAME_FILENAME:-00-StartFrameTitle.png}" # output filename for the intro screen
START_FRAME_TITLE_KEY="${START_FRAME_TITLE_KEY:-StartFrameTitle}" # key in the localization files
START_FRAME_TITLE_Y="${START_FRAME_TITLE_Y:-$((TITLE_MARGIN - 100))}" # Y offset for the intro title text
START_FRAME_POINTSIZE="${START_FRAME_POINTSIZE:-$TITLE_POINTSIZE}" # point size for intro title (defaults to standard)
START_FRAME_RESIZE="${START_FRAME_RESIZE:-true}" # resize intro frame to target dimensions
# 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:-Shots/frame-bg.png}"
IPAD_CANVAS_MARGIN="${IPAD_CANVAS_MARGIN:-240}"
IPAD_TITLE_MARGIN="${IPAD_TITLE_MARGIN:-180}"
IPAD_FRAME_WIDTH="${IPAD_FRAME_WIDTH:-2200}"
IPAD_FRAME_TOP_OFFSET="${IPAD_FRAME_TOP_OFFSET:-572}"
IPAD_CORNER_RADIUS="${IPAD_CORNER_RADIUS:-60}"
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 point_size="${5:-$TITLE_POINTSIZE}"
local line_spacing="${6:-$TITLE_LINE_SPACING}"
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 * 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 "$point_size" -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 "$point_size" -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 "$point_size" -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***"
}
should_render_start_frame() {
local device_slug="$1"
if [[ ! -f "$START_FRAME_IMAGE" ]]; then
return 1
fi
if [[ -z "$device_slug" ]]; then
return 1
fi
local slug_lower
slug_lower="$(printf '%s' "$device_slug" | tr '[:upper:]' '[:lower:]')"
if [[ "$slug_lower" == *"iphone"* ]]; then
return 0
fi
return 1
}
render_start_frame_for_lang() {
local output_dir="$1"
local lang="$2"
local log_prefix="$3"
local target_width="$4"
local target_height="$5"
local canvas_margin="$6"
local title_margin="$7"
if [[ ! -f "$START_FRAME_IMAGE" ]]; then
return 0
fi
local title_text
title_text="$(get_title "$lang" "$START_FRAME_TITLE_KEY")"
if [[ -z "$title_text" || "$title_text" == "***NOT SET***" ]]; then
echo "Skipped start frame (no title): ${log_prefix}/${START_FRAME_FILENAME}"
return 0
fi
mkdir -p "$output_dir"
local dest="$output_dir/$START_FRAME_FILENAME"
rm -f "$dest"
local resize_flag
resize_flag="$(printf '%s' "$START_FRAME_RESIZE" | tr '[:upper:]' '[:lower:]')"
local working_canvas
working_canvas="$(mktemp /tmp/start_frame_canvas.XXXXXX_$$.png)"
if [[ "$resize_flag" == "true" && -n "${target_width:-}" && -n "${target_height:-}" ]]; then
magick "$START_FRAME_IMAGE" -resize "${target_width}x${target_height}^" -gravity center -extent "${target_width}x${target_height}" "$working_canvas"
else
cp "$START_FRAME_IMAGE" "$working_canvas"
fi
local point_size="$START_FRAME_POINTSIZE"
local scaled_title_y="$START_FRAME_TITLE_Y"
local scaled_line_spacing="$TITLE_LINE_SPACING"
if [[ -n "${target_height:-}" && -n "${canvas_margin:-}" && -n "${title_margin:-}" ]]; then
local base_canvas_height=$(( target_height + 2 * canvas_margin + title_margin ))
if (( base_canvas_height > 0 )); then
read -r scaled_title_y scaled_line_spacing <<EOF
$(python3 - <<PY
target_height=${target_height}
base_canvas_height=${base_canvas_height}
title_y=${START_FRAME_TITLE_Y}
line_spacing=${TITLE_LINE_SPACING}
scale = target_height / base_canvas_height if base_canvas_height else 1
print(int(round(title_y * scale)), int(round(line_spacing * scale)))
PY
)
EOF
fi
fi
if [[ -z "$scaled_title_y" || "$scaled_title_y" -lt 0 ]]; then
scaled_title_y="$START_FRAME_TITLE_Y"
fi
if [[ -z "$scaled_line_spacing" || "$scaled_line_spacing" -le 0 ]]; then
scaled_line_spacing="$TITLE_LINE_SPACING"
fi
render_mixed_font_title "$working_canvas" "$title_text" "$scaled_title_y" "$dest" "$point_size" "$scaled_line_spacing"
rm -f "$working_canvas"
if [[ -f "$dest" ]]; then
echo "Start frame: $log_prefix/$START_FRAME_FILENAME"
fi
}
# 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"
local frame_width="${10:-}"
local frame_top_offset="${11:-}"
local corner_radius_override="${12:-}"
local title_text
title_text="$(get_title "$lang" "$screenshot_name")"
if [[ -z "$title_text" || "$title_text" == "***NOT SET***" ]]; then
echo "Skipped (no title): ${lang}/${screenshot_name}"
return 0
fi
# Read sizes
read -r W H <<<"$(identify -format "%w %h" "$in")"
# Determine corner radius
local R
if [[ -n "$corner_radius_override" ]]; then
R="$corner_radius_override"
elif [[ "$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"
if [[ -n "$frame_width" ]]; then
magick "$rounded" -resize "${frame_width}x" "$rounded"
fi
# 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 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 placement_gravity="center"
local placement_geometry="+0+${screenshot_offset}"
if [[ -n "$frame_top_offset" ]]; then
placement_gravity="north"
placement_geometry="+0+${frame_top_offset}"
fi
local temp_result
temp_result="$(mktemp /tmp/temp_result.XXXXXX_$$.png)"
magick "$with_title" "$shadow" -gravity "$placement_gravity" -geometry "$placement_geometry" -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"
PROFILE_FRAME_WIDTH=""
PROFILE_FRAME_TOP_OFFSET=""
PROFILE_CORNER_RADIUS=""
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"
PROFILE_FRAME_WIDTH="$IPAD_FRAME_WIDTH"
PROFILE_FRAME_TOP_OFFSET="$IPAD_FRAME_TOP_OFFSET"
PROFILE_CORNER_RADIUS="$IPAD_CORNER_RADIUS"
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")"
local dest="$out_dir/$base"
rm -f "$dest"
frame_one \
"$shot" \
"$dest" \
"$PROFILE_BG" \
"$lang" \
"$base" \
"$PROFILE_TARGET_WIDTH" \
"$PROFILE_TARGET_HEIGHT" \
"$PROFILE_CANVAS_MARGIN" \
"$PROFILE_TITLE_MARGIN" \
"$PROFILE_FRAME_WIDTH" \
"$PROFILE_FRAME_TOP_OFFSET" \
"$PROFILE_CORNER_RADIUS"
if [[ -f "$dest" ]]; then
echo "Framed: $log_prefix/$base"
fi
done
if should_render_start_frame "$device_slug"; then
render_start_frame_for_lang \
"$out_dir" \
"$lang" \
"$log_prefix" \
"$PROFILE_TARGET_WIDTH" \
"$PROFILE_TARGET_HEIGHT" \
"$PROFILE_CANVAS_MARGIN" \
"$PROFILE_TITLE_MARGIN"
fi
}
shopt -s nullglob
found_any=false
skipped_for_iphone_filter=false
skipped_for_ipad_filter=false
for entry in "$SRC_ROOT"/*; do
[[ -d "$entry" ]] || continue
entry_basename="$(basename "$entry")"
entry_lower="$(printf '%s' "$entry_basename" | tr '[:upper:]' '[:lower:]')"
is_ipad_entry=false
if [[ "$entry_lower" == *"ipad"* ]]; then
is_ipad_entry=true
fi
if [[ "$ONLY_IPHONE" == true && "$is_ipad_entry" == true ]]; then
skipped_for_iphone_filter=true
continue
fi
if [[ "$ONLY_IPAD" == true && "$is_ipad_entry" == false ]]; then
skipped_for_ipad_filter=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_iphone_filter=true
continue
fi
fi
if [[ "$ONLY_IPAD" == 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_ipad_filter=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 ]]; then
if [[ "$skipped_for_iphone_filter" == true ]]; then
echo "No iPhone screenshots found under $SRC_ROOT" >&2
else
echo "No screenshots found under $SRC_ROOT" >&2
fi
elif [[ "$ONLY_IPAD" == true ]]; then
if [[ "$skipped_for_ipad_filter" == true ]]; then
echo "No iPad screenshots found under $SRC_ROOT" >&2
else
echo "No screenshots found under $SRC_ROOT" >&2
fi
else
echo "No screenshots found under $SRC_ROOT" >&2
fi
fi
echo "Done. Framed images in: $OUT_ROOT/"