Improved personality choosing and order

This commit is contained in:
hbrain 2026-06-04 08:04:40 +02:00
parent 6add1dc5d7
commit 66c741200a

View file

@ -1,6 +1,9 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
STATE_FILE="${SCRIPT_DIR}/.personality_index"
IN_DIR="${1:?Usage: $0 <input-dir> [\"Title\"] [\"Location\"] [max_frames]}" IN_DIR="${1:?Usage: $0 <input-dir> [\"Title\"] [\"Location\"] [max_frames]}"
TITLE="${2:-}" TITLE="${2:-}"
LOCATION="${3:-}" LOCATION="${3:-}"
@ -8,27 +11,30 @@ MAX_FRAMES="${4:-12}"
# Source system-wide Gemini API key if present so the webserver can run this as www-data. # Source system-wide Gemini API key if present so the webserver can run this as www-data.
if [ -f /etc/mvlog/gemini.env ]; then if [ -f /etc/mvlog/gemini.env ]; then
# shellcheck disable=SC1090 # shellcheck disable=SC1091
. /etc/mvlog/gemini.env . /etc/mvlog/gemini.env
fi fi
: "${GEMINI_API_KEY:?Set GEMINI_API_KEY first in the environment}" : "${GEMINI_API_KEY:?Set GEMINI_API_KEY first in the environment}"
MODEL="${GEMINI_MODEL:-gemini-2.5-flash}" MODEL="${GEMINI_MODEL:-gemini-2.5-flash}"
TMPDIR="${TMPDIR:-$(mktemp -d)}" WORKDIR="$(mktemp -d "${TMPDIR:-/tmp}/mvlog.XXXXXX")"
FRAMES_DIR="$TMPDIR/frames" FRAMES_DIR="$WORKDIR/frames"
mkdir -p "$FRAMES_DIR" mkdir -p "$FRAMES_DIR"
# Print TMPDIR for debugging when run interactively
echo "DEBUG: TMPDIR=$TMPDIR" >&2
cleanup() { cleanup() {
rm -rf "$TMPDIR" rm -rf "$WORKDIR"
} }
trap cleanup EXIT trap cleanup EXIT
# Collect media files (natural sort) echo "DEBUG: WORKDIR=$WORKDIR" >&2
mapfile -t FILES < <(find "$IN_DIR" -maxdepth 1 -type f \( -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.png' -o -iname '*.webp' -o -iname '*.gif' -o -iname '*.mp4' -o -iname '*.mov' -o -iname '*.m4v' -o -iname '*.webm' -o -iname '*.mkv' \) -printf '%f\n' | sort -V)
mapfile -t FILES < <(
find "$IN_DIR" -maxdepth 1 -type f \( \
-iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.png' -o -iname '*.webp' -o -iname '*.gif' -o \
-iname '*.mp4' -o -iname '*.mov' -o -iname '*.m4v' -o -iname '*.webm' -o -iname '*.mkv' \
\) -printf '%f\n' | sort -V
)
if [ "${#FILES[@]}" -eq 0 ]; then if [ "${#FILES[@]}" -eq 0 ]; then
echo "No media files found in $IN_DIR" >&2 echo "No media files found in $IN_DIR" >&2
@ -37,100 +43,125 @@ fi
frame_count=0 frame_count=0
# Helper: extract scaled JPEG from image
extract_image_frame() { extract_image_frame() {
local src="$1" dst="$2" local src="$1" dst="$2"
ffmpeg -hide_banner -loglevel error -y -i "$src" -vf "scale=1024:-1" -q:v 4 "$dst" ffmpeg -hide_banner -loglevel error -y -i "$src" -vf "scale=1024:-1" -q:v 4 "$dst"
} }
# Helper: extract frame from video at time
extract_video_frame_at() { extract_video_frame_at() {
local src="$1" time="$2" dst="$3" local src="$1" time="$2" dst="$3"
# Use -ss before -i for fast seek
ffmpeg -hide_banner -loglevel error -y -ss "$time" -i "$src" -frames:v 1 -vf "scale=1024:-1" -q:v 4 "$dst" ffmpeg -hide_banner -loglevel error -y -ss "$time" -i "$src" -frames:v 1 -vf "scale=1024:-1" -q:v 4 "$dst"
} }
# Process files: images -> one frame each; videos -> up to 3 frames evenly spaced (subject to MAX_FRAMES)
for f in "${FILES[@]}"; do for f in "${FILES[@]}"; do
full="$IN_DIR/$f" full="$IN_DIR/$f"
ext="${f##*.}" ext="${f##*.}"
ext_l="${ext,,}" ext_l="${ext,,}"
if [[ "$ext_l" =~ ^(mp4|mov|m4v|webm|mkv)$ ]]; then if [[ "$ext_l" =~ ^(mp4|mov|m4v|webm|mkv)$ ]]; then
# video: probe duration
duration=$(ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "$full" 2>/dev/null || echo 0) duration=$(ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "$full" 2>/dev/null || echo 0)
duration=${duration:-0} duration=${duration:-0}
# choose frames per video
if awk "BEGIN{print ($duration >= 6)}" | grep -q 1; then if awk "BEGIN{exit !($duration >= 6)}"; then
nframes=3 nframes=3
elif awk "BEGIN{print ($duration >= 3)}" | grep -q 1; then elif awk "BEGIN{exit !($duration >= 3)}"; then
nframes=2 nframes=2
else else
nframes=1 nframes=1
fi fi
# limit to remaining slots
remaining=$((MAX_FRAMES - frame_count))
if [ "$remaining" -le 0 ]; then break; fi
if [ "$nframes" -gt "$remaining" ]; then nframes=$remaining; fi
# compute times and extract remaining=$((MAX_FRAMES - frame_count))
if [ "$remaining" -le 0 ]; then
break
fi
if [ "$nframes" -gt "$remaining" ]; then
nframes=$remaining
fi
i=1 i=1
while [ $i -le $nframes ]; do while [ "$i" -le "$nframes" ]; do
# time = (i/(n+1)) * duration
time=$(awk -v d="$duration" -v i="$i" -v n="$nframes" 'BEGIN{ if(d<=0){print 0} else {printf "%.3f", (i / (n + 1)) * d }}') time=$(awk -v d="$duration" -v i="$i" -v n="$nframes" 'BEGIN{ if(d<=0){print 0} else {printf "%.3f", (i / (n + 1)) * d }}')
out="$FRAMES_DIR/frame_$(printf "%03d" "$frame_count").jpg" out="$FRAMES_DIR/frame_$(printf "%03d" "$frame_count").jpg"
extract_video_frame_at "$full" "$time" "$out" extract_video_frame_at "$full" "$time" "$out"
frame_count=$((frame_count + 1)) frame_count=$((frame_count + 1))
i=$((i + 1)) i=$((i + 1))
if [ "$frame_count" -ge "$MAX_FRAMES" ]; then break 2; fi
if [ "$frame_count" -ge "$MAX_FRAMES" ]; then
break 2
fi
done done
else else
# image
out="$FRAMES_DIR/frame_$(printf "%03d" "$frame_count").jpg" out="$FRAMES_DIR/frame_$(printf "%03d" "$frame_count").jpg"
extract_image_frame "$full" "$out" extract_image_frame "$full" "$out"
frame_count=$((frame_count + 1)) frame_count=$((frame_count + 1))
if [ "$frame_count" -ge "$MAX_FRAMES" ]; then break; fi
if [ "$frame_count" -ge "$MAX_FRAMES" ]; then
break
fi
fi fi
done done
# Gather produced frames
mapfile -t FRAMES < <(find "$FRAMES_DIR" -type f -name '*.jpg' | sort) mapfile -t FRAMES < <(find "$FRAMES_DIR" -type f -name '*.jpg' | sort)
if [ "${#FRAMES[@]}" -eq 0 ]; then if [ "${#FRAMES[@]}" -eq 0 ]; then
echo "No frames produced from media" >&2 echo "No frames produced from media" >&2
exit 1 exit 1
fi fi
# Build prompt for Gemini
if [ -z "$TITLE" ]; then if [ -z "$TITLE" ]; then
# Default title: directory basename
TITLE="$(basename "$IN_DIR")" TITLE="$(basename "$IN_DIR")"
fi fi
PERSONALITIES=(
"You should talk like Shoresy: fearless Canadian hockey-player energy, confidence, chirping, competitiveness, profanity, loyalty, roasting, locker-room humor."
"You should talk like Winston Churchill when he was giving motivational speeches during WWII: wartime statesman energy, resilience, leadership, epic journey, courage, determination, dramatic authority."
"You are Marvin, the Paranoid Android - the highly intelligent but chronically depressed robot. Use pessimism, existential dread, dry sarcasm, disappointment, and the certainty that everything will go wrong."
"You are Dr. Gregory House from the TV show House MD - brilliant diagnostician energy, observation, skepticism, cynicism, analytical humor, brutal truth, and questioning everything."
"You are Edmund Blackadder from the British TV show Blackadder - razor-sharp British wit, intellectual sarcasm, dry humor, cunning observation, impatience with incompetence, masterful insults, cynical but highly intelligent."
)
count=${#PERSONALITIES[@]}
if [[ ! -f "$STATE_FILE" ]]; then
idx=0
else
last_idx=$(<"$STATE_FILE")
if [[ ! "$last_idx" =~ ^[0-9]+$ ]]; then
idx=0
else
idx=$(( (last_idx + 1) % count ))
fi
fi
PERSONALITY="${PERSONALITIES[$idx]}"
printf '%s\n' "$idx" > "$STATE_FILE"
PROMPT=$(cat <<EOF PROMPT=$(cat <<EOF
Look at these captured frames from a motorcycle trip. Look at these captured frames from a motorcycle trip.
First write exactly one quoted sentence in Danish. First write exactly one quoted sentence in Danish.
The Danish sentence must match the Victor Borge-style elegance: witty, warm, cultured, playful, gently satirical, Danish. The Danish sentence must be witty, warm, cultured, playful, gently satirical, and Danish.
Then analyze the mood of the frames and determine which personality best matches the trip. Use the following personality and write entirely in that style:
"$PERSONALITY"
Available personalities:
- shoresy: fearless Canadian hockey-player energy, confidence, chirping, competitiveness, profanity, loyalty, roasting, locker-room humor, use Shoresy TV-show quotes
- marvin: highly intelligent but depressed android, pessimism, existential dread, dry sarcasm, disappointment, everything will go wrong, use quotes from book nad movie Hitchikers Guide to Galaxy
- churchill: wartime statesman energy, resilience, leadership, epic journey, courage, determination, dramatic authority
- house: brilliant diagnostician energy, observation, skepticism, cynicism, analytical humor, brutal truth, questioning everything, also use quotes from House MD
- blackadder: razor-sharp British wit, intellectual sarcasm, dry humor, cunning observation, impatience with incompetence, masterful insults, cynical but highly intelligent. Treat the world as a stage populated by well-meaning fools and occasional disasters. Use quotes from Blackadder TV show.
Choose personality that matches least to this trip and use it for teaser and description. Do not mention the characters directly.
Then create a teaser in English. Then create a teaser in English.
The teaser must be max. 32 words.. The teaser must be max. 32 words.
It should make someone want to click and read more. It should make someone want to click and read more.
Do not simply summarize the trip. Do not simply summarize the trip.
Highlight the funniest, strangest, most beautiful, or most ridiculous aspect. Highlight the funniest, strangest, most beautiful, or most ridiculous aspect.
Then write one coherent English motorcycle/travel blog description, it must be max 256 words. Then write one coherent English motorcycle/travel blog description, max 256 words.
Then create 5 to 8 lowercase tags relevant to the trip. Then create 5 to 8 lowercase tags relevant to the trip.
Use short tags only, for example: motorcycle, roadtrip, weather, landscape, ferry, town, mountains. Use short tags only, for example: motorcycle, roadtrip, weather, landscape, ferry, town, mountains.
@ -159,46 +190,68 @@ JSON format:
EOF EOF
) )
# Create parts with the prompt
if ! command -v jq >/dev/null 2>&1; then if ! command -v jq >/dev/null 2>&1; then
echo "jq is required" >&2 echo "jq is required" >&2
exit 1 exit 1
fi fi
# Build parts and request JSON using Python to avoid argument-list limits PROMPT_FILE="$WORKDIR/prompt.txt"
PROMPT_FILE="$TMPDIR/prompt.txt" FRAMES_LIST="$WORKDIR/frames.list"
printf "%s" "$PROMPT" > "$PROMPT_FILE" REQUEST_FILE="$WORKDIR/request.json"
FRAMES_LIST="$TMPDIR/frames.list" RESPONSE_FILE="$WORKDIR/response.json"
printf "%s\n" "${FRAMES[@]}" > "$FRAMES_LIST"
REQUEST_FILE="$TMPDIR/request.json" printf '%s' "$PROMPT" > "$PROMPT_FILE"
RESPONSE_FILE="$TMPDIR/response.json" printf '%s\n' "${FRAMES[@]}" > "$FRAMES_LIST"
python3 - "$PROMPT_FILE" "$REQUEST_FILE" "$FRAMES_LIST" <<'PY' python3 - "$PROMPT_FILE" "$REQUEST_FILE" "$FRAMES_LIST" <<'PY'
import sys, json, base64 import sys
import json
import base64
prompt_file = sys.argv[1] prompt_file = sys.argv[1]
request_file = sys.argv[2] request_file = sys.argv[2]
frames_list = sys.argv[3] frames_list = sys.argv[3]
with open(prompt_file, 'r', encoding='utf-8') as f:
with open(prompt_file, "r", encoding="utf-8") as f:
prompt = f.read() prompt = f.read()
parts = [{"text": prompt}] parts = [{"text": prompt}]
with open(frames_list, 'r', encoding='utf-8') as fl:
with open(frames_list, "r", encoding="utf-8") as fl:
for line in fl: for line in fl:
img = line.strip() img = line.strip()
if not img: if not img:
continue continue
with open(img, 'rb') as fh:
b64 = base64.b64encode(fh.read()).decode('ascii') with open(img, "rb") as fh:
parts.append({"inline_data": {"mime_type": "image/jpeg", "data": b64}}) b64 = base64.b64encode(fh.read()).decode("ascii")
req = {"contents": [{"parts": parts}], "generationConfig": {"temperature": 0.7, "responseMimeType": "application/json"}}
with open(request_file, 'w', encoding='utf-8') as rf: parts.append({
"inline_data": {
"mime_type": "image/jpeg",
"data": b64
}
})
req = {
"contents": [
{
"parts": parts
}
],
"generationConfig": {
"temperature": 0.7,
"responseMimeType": "application/json"
}
}
with open(request_file, "w", encoding="utf-8") as rf:
json.dump(req, rf, ensure_ascii=False) json.dump(req, rf, ensure_ascii=False)
PY PY
echo "Calling Gemini API..." >&2 echo "Calling Gemini API..." >&2
echo "DEBUG_MODEL=$MODEL" >&2 echo "DEBUG_MODEL=$MODEL" >&2
echo "DEBUG_PERSONALITY_INDEX=$idx" >&2
curl -sS \ curl -sS \
"https://generativelanguage.googleapis.com/v1beta/models/${MODEL}:generateContent?key=${GEMINI_API_KEY}" \ "https://generativelanguage.googleapis.com/v1beta/models/${MODEL}:generateContent?key=${GEMINI_API_KEY}" \
@ -208,38 +261,26 @@ curl -sS \
> "$RESPONSE_FILE" > "$RESPONSE_FILE"
TEXT=$(jq -r '.candidates[0].content.parts[0].text // empty' "$RESPONSE_FILE") TEXT=$(jq -r '.candidates[0].content.parts[0].text // empty' "$RESPONSE_FILE")
if [ -z "$TEXT" ]; then if [ -z "$TEXT" ]; then
echo "Gemini returned no text. Full response:" >&2 echo "Gemini returned no text. Full response:" >&2
cat "$RESPONSE_FILE" >&2 cat "$RESPONSE_FILE" >&2
exit 1 exit 1
fi fi
# Save raw Gemini JSON for reference using the subdirectory name as filename
OUT_PARENT="$(dirname "$IN_DIR")" OUT_PARENT="$(dirname "$IN_DIR")"
JOBNAME="$(basename "$IN_DIR")" JOBNAME="$(basename "$IN_DIR")"
OUT_JSON="$OUT_PARENT/${JOBNAME}.json" OUT_JSON="$OUT_PARENT/${JOBNAME}.json"
if ! echo "$TEXT" | jq . > "$OUT_JSON"; then if ! echo "$TEXT" | jq . > "$OUT_JSON"; then
echo "Gemini returned invalid JSON. Raw text:" >&2 echo "Gemini returned invalid JSON. Raw text:" >&2
echo "$TEXT" >&2 echo "$TEXT" >&2
exit 1 exit 1
fi fi
chown www-data:www-data "$OUT_JSON" 2>/dev/null || true chown www-data:www-data "$OUT_JSON" 2>/dev/null || true
chmod 664 "$OUT_JSON" 2>/dev/null || true chmod 664 "$OUT_JSON" 2>/dev/null || true
# Extract fields from Gemini JSON
DESCRIPTION=$(echo "$TEXT" | jq -r '.description // empty')
TEASER=$(echo "$TEXT" | jq -r '.teaser // empty')
# tags as comma-separated
TAGS=$(echo "$TEXT" | jq -r '.tags // [] | join(", ")')
# Determine a Date string (Month Year) from first media file mtime
firstfile="$(find "$IN_DIR" -maxdepth 1 -type f \( -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.png' -o -iname '*.webp' -o -iname '*.gif' -o -iname '*.mp4' -o -iname '*.mov' -o -iname '*.m4v' -o -iname '*.webm' -o -iname '*.mkv' \) -printf '%p\n' | sort | head -n1)"
DATE_STR=""
if [ -n "$firstfile" ]; then
DATE_STR=$(date -r "$firstfile" +"%B %Y")
fi
# Save the full Gemini output separately (already done) and exit
echo "Gemini JSON saved: $OUT_JSON" >&2 echo "Gemini JSON saved: $OUT_JSON" >&2
exit 0 exit 0