229 lines
9.2 KiB
Bash
Executable file
229 lines
9.2 KiB
Bash
Executable file
#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
|
|
IN_DIR="${1:?Usage: $0 <input-dir> [\"Title\"] [\"Location\"] [max_frames]}"
|
|
TITLE="${2:-}"
|
|
LOCATION="${3:-}"
|
|
MAX_FRAMES="${4:-12}"
|
|
|
|
# Source system-wide Gemini API key if present so the webserver can run this as www-data.
|
|
if [ -f /etc/mvlog/gemini.env ]; then
|
|
# shellcheck disable=SC1090
|
|
. /etc/mvlog/gemini.env
|
|
fi
|
|
|
|
: "${GEMINI_API_KEY:?Set GEMINI_API_KEY first in the environment}"
|
|
MODEL="${GEMINI_MODEL:-gemini-2.5-flash}"
|
|
|
|
TMPDIR="${TMPDIR:-$(mktemp -d)}"
|
|
FRAMES_DIR="$TMPDIR/frames"
|
|
mkdir -p "$FRAMES_DIR"
|
|
|
|
# Print TMPDIR for debugging when run interactively
|
|
echo "DEBUG: TMPDIR=$TMPDIR" >&2
|
|
|
|
cleanup() {
|
|
rm -rf "$TMPDIR"
|
|
}
|
|
trap cleanup EXIT
|
|
|
|
# Collect media files (natural sort)
|
|
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
|
|
echo "No media files found in $IN_DIR" >&2
|
|
exit 1
|
|
fi
|
|
|
|
frame_count=0
|
|
|
|
# Helper: extract scaled JPEG from image
|
|
extract_image_frame() {
|
|
local src="$1" dst="$2"
|
|
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() {
|
|
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"
|
|
}
|
|
|
|
# Process files: images -> one frame each; videos -> up to 3 frames evenly spaced (subject to MAX_FRAMES)
|
|
for f in "${FILES[@]}"; do
|
|
full="$IN_DIR/$f"
|
|
ext="${f##*.}"
|
|
ext_l="${ext,,}"
|
|
|
|
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=${duration:-0}
|
|
# choose frames per video
|
|
if awk "BEGIN{print ($duration >= 6)}" | grep -q 1; then
|
|
nframes=3
|
|
elif awk "BEGIN{print ($duration >= 3)}" | grep -q 1; then
|
|
nframes=2
|
|
else
|
|
nframes=1
|
|
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
|
|
i=1
|
|
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 }}')
|
|
out="$FRAMES_DIR/frame_$(printf "%03d" "$frame_count").jpg"
|
|
extract_video_frame_at "$full" "$time" "$out"
|
|
frame_count=$((frame_count+1))
|
|
i=$((i+1))
|
|
if [ "$frame_count" -ge "$MAX_FRAMES" ]; then break 2; fi
|
|
done
|
|
else
|
|
# image
|
|
out="$FRAMES_DIR/frame_$(printf "%03d" "$frame_count").jpg"
|
|
extract_image_frame "$full" "$out"
|
|
frame_count=$((frame_count+1))
|
|
if [ "$frame_count" -ge "$MAX_FRAMES" ]; then break; fi
|
|
fi
|
|
done
|
|
|
|
# Gather produced frames
|
|
mapfile -t FRAMES < <(find "$FRAMES_DIR" -type f -name '*.jpg' | sort)
|
|
if [ "${#FRAMES[@]}" -eq 0 ]; then
|
|
echo "No frames produced from media" >&2
|
|
exit 1
|
|
fi
|
|
|
|
# Build prompt for Gemini
|
|
if [ -z "$TITLE" ]; then
|
|
# Default title: directory basename
|
|
TITLE="$(basename "$IN_DIR")"
|
|
fi
|
|
|
|
PERSONALITIES=(
|
|
"You're embodying the personality, presence, and atmosphere of a fearless Canadian hockey player known for relentless chirping, absolute confidence, and complete dedication to his team. Tough, athletic, battle-worn, with a cocky grin that suggests you already won the argument before it started. Ice rink atmosphere, hockey gear, locker room energy, small-town hockey culture, loyalty, grit, and competitiveness. Rough around the edges but fiercely protective of friends. The image should radiate confidence, humor, intensity, and a willingness to outwork everyone on the ice. Realistic, cinematic sports photography."
|
|
|
|
"You're highly intelligent yet chronically depressed android. Your expression should convey exhaustion, disappointment, and the certainty that everything will eventually go wrong. Surrounded by futuristic technology, starships, data displays, and impossible engineering problems. Despite obvious genius, you appear profoundly unimpressed by the universe. Dark humor, existential dread, quiet sarcasm, and the feeling that you have already calculated every possible outcome and disliked all of them. Cinematic science-fiction style, realistic, highly detailed."
|
|
|
|
"You're embodying the personality, presence, and atmosphere of a wartime statesman leading a nation through its darkest hour. Calm under pressure, determined, intelligent, and unbreakable. Strong posture, thoughtful expression, commanding presence. Surrounded by maps, strategy documents, war rooms, and symbols of leadership. The image should convey courage, resilience, discipline, and the ability to inspire others during difficult times. Historic yet timeless atmosphere, dramatic lighting, realistic, highly detailed portrait photography."
|
|
|
|
# Pick a random personality
|
|
idx=$(( RANDOM % ${#PERSONALITIES[@]} ))
|
|
PERSONALITY="${PERSONALITIES[$idx]}"
|
|
|
|
PROMPT=$(cat <<EOF
|
|
Look at these captured frames from a trip.
|
|
|
|
Before the actual description write just one quoted sentence (ONE sentence only!!) in Danish that will sound like quoting Victor Borge: entertainer with the elegance ofa concert pianist and the mischievous humor of a master storyteller. He is known having an aura of effortless sophistication, and telling clever jokes that nobody sees
|
|
coming. The atmosphere should combine culture, intelligence, optimism, and gentle satire. Refined, warm, charismatic, and playful. Realistic, cinematic, highly detailed.
|
|
|
|
"$PERSONALITY"
|
|
Now synthesize what you saw on the pictures into one single, coherent description (make it not more than 896 but less than 1280 characters)
|
|
that summarizes the overall experience for motorcycle/travel blog: You are the main character riding your beloved Yamaha FJR.
|
|
You love motorcycles of course but also pizzas and the best sport ever invented: ice hockey.
|
|
|
|
Title: "$TITLE"
|
|
Location: "$LOCATION"
|
|
|
|
Write JSON only.
|
|
|
|
Required JSON format:
|
|
{
|
|
"description": "few relaxed human sentences",
|
|
"teaser": "one short teaser sentence",
|
|
"tags": ["tag1", "tag2", "tag3", "tag4", "tag5"]
|
|
}
|
|
|
|
Style:
|
|
personal, natural, slightly casual, not corporate, not clickbait.
|
|
Do not invent facts that are not visible in the images or provided in title/location.
|
|
EOF
|
|
)
|
|
|
|
PARTS_FILE="$TMPDIR/parts.json"
|
|
|
|
# Create parts with the prompt
|
|
if ! command -v jq >/dev/null 2>&1; then
|
|
echo "jq is required" >&2
|
|
exit 1
|
|
fi
|
|
|
|
# Build parts and request JSON using Python to avoid argument-list limits
|
|
PROMPT_FILE="$TMPDIR/prompt.txt"
|
|
printf "%s" "$PROMPT" > "$PROMPT_FILE"
|
|
FRAMES_LIST="$TMPDIR/frames.list"
|
|
printf "%s\n" "${FRAMES[@]}" > "$FRAMES_LIST"
|
|
|
|
REQUEST_FILE="$TMPDIR/request.json"
|
|
RESPONSE_FILE="$TMPDIR/response.json"
|
|
|
|
python3 - "$PROMPT_FILE" "$REQUEST_FILE" "$FRAMES_LIST" <<'PY'
|
|
import sys, json, base64
|
|
prompt_file = sys.argv[1]
|
|
request_file = sys.argv[2]
|
|
frames_list = sys.argv[3]
|
|
with open(prompt_file, 'r', encoding='utf-8') as f:
|
|
prompt = f.read()
|
|
parts = [{"text": prompt}]
|
|
with open(frames_list, 'r', encoding='utf-8') as fl:
|
|
for line in fl:
|
|
img = line.strip()
|
|
if not img:
|
|
continue
|
|
with open(img, 'rb') as fh:
|
|
b64 = base64.b64encode(fh.read()).decode('ascii')
|
|
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)
|
|
PY
|
|
|
|
echo "Calling Gemini API..." >&2
|
|
|
|
echo "DEBUG_MODEL=$MODEL" >&2
|
|
|
|
curl -sS \
|
|
"https://generativelanguage.googleapis.com/v1beta/models/${MODEL}:generateContent?key=${GEMINI_API_KEY}" \
|
|
-H "Content-Type: application/json" \
|
|
-X POST \
|
|
-d @"$REQUEST_FILE" \
|
|
> "$RESPONSE_FILE"
|
|
|
|
TEXT=$(jq -r '.candidates[0].content.parts[0].text // empty' "$RESPONSE_FILE")
|
|
if [ -z "$TEXT" ]; then
|
|
echo "Gemini returned no text. Full response:" >&2
|
|
cat "$RESPONSE_FILE" >&2
|
|
exit 1
|
|
fi
|
|
|
|
# Save raw Gemini JSON for reference using the subdirectory name as filename
|
|
OUT_PARENT="$(dirname "$IN_DIR")"
|
|
JOBNAME="$(basename "$IN_DIR")"
|
|
OUT_JSON="$OUT_PARENT/${JOBNAME}.json"
|
|
echo "$TEXT" | jq . > "$OUT_JSON"
|
|
chown www-data:www-data "$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
|
|
|
|
exit 0
|