movmaker-webui/bin/generate_data.sh
hbrain be70760320 - Stops using openrouter/auto by default; overrides it to openai/gpt-4o-mini unless OPENROUTER_ALLOW_AUTO=1.
- Retries OpenRouter up to 3 times if response is empty or malformed.
 - Validates that JSON has required fields before saving.
 - Saves last raw OpenRouter response/text for debugging:
     - .mvlog-last-openrouter-response.json
     - .mvlog-last-openrouter-text.txt
 - Only advances .personality_index after valid JSON is saved.
2026-06-19 18:06:25 +02:00

381 lines
14 KiB
Bash
Executable file

#!/usr/bin/env bash
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] [additional_prompt]}"
TITLE="${2:-}"
LOCATION="${3:-}"
MAX_FRAMES="${4:-12}"
ADDITIONAL_PROMPT="${5:-}"
if [ "$ADDITIONAL_PROMPT" = "no-append" ]; then
ADDITIONAL_PROMPT=""
fi
# Source system-wide OpenRouter API key if present so the webserver can run this as www-data.
if [ -f /etc/mvlog/openrouter.env ]; then
# shellcheck disable=SC1091
. /etc/mvlog/openrouter.env
fi
: "${OPENROUTER_API_KEY:?Set OPENROUTER_API_KEY first in the environment}"
MODEL="${OPENROUTER_MODEL:-openai/gpt-4o-mini}"
if [ "$MODEL" = "openrouter/auto" ] && [ "${OPENROUTER_ALLOW_AUTO:-}" != "1" ]; then
echo "WARN: openrouter/auto is unreliable for strict JSON; using openai/gpt-4o-mini. Set OPENROUTER_MODEL to a specific vision model, or OPENROUTER_ALLOW_AUTO=1 to force auto." >&2
MODEL="openai/gpt-4o-mini"
fi
OPENROUTER_MAX_RETRIES="${OPENROUTER_MAX_RETRIES:-3}"
OPENROUTER_SITE_URL="${OPENROUTER_SITE_URL:-https://bubulescu.org}"
OPENROUTER_SITE_NAME="${OPENROUTER_SITE_NAME:-MVLog}"
WORKDIR="$(mktemp -d "${TMPDIR:-/tmp}/mvlog.XXXXXX")"
FRAMES_DIR="$WORKDIR/frames"
mkdir -p "$FRAMES_DIR"
cleanup() {
rm -rf "$WORKDIR"
}
trap cleanup EXIT
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
)
if [ "${#FILES[@]}" -eq 0 ]; then
echo "No media files found in $IN_DIR" >&2
exit 1
fi
frame_count=0
extract_image_frame() {
local src="$1" dst="$2"
ffmpeg -hide_banner -loglevel error -y -i "$src" -vf "scale=1024:-1" -q:v 4 "$dst"
}
extract_video_frame_at() {
local src="$1" time="$2" dst="$3"
ffmpeg -hide_banner -loglevel error -y -ss "$time" -i "$src" -frames:v 1 -vf "scale=1024:-1" -q:v 4 "$dst"
}
for f in "${FILES[@]}"; do
full="$IN_DIR/$f"
ext="${f##*.}"
ext_l="${ext,,}"
if [[ "$ext_l" =~ ^(mp4|mov|m4v|webm|mkv)$ ]]; then
duration=$(ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "$full" 2>/dev/null || echo 0)
duration=${duration:-0}
if awk "BEGIN{exit !($duration >= 6)}"; then
nframes=3
elif awk "BEGIN{exit !($duration >= 3)}"; then
nframes=2
else
nframes=1
fi
remaining=$((MAX_FRAMES - frame_count))
if [ "$remaining" -le 0 ]; then
break
fi
if [ "$nframes" -gt "$remaining" ]; then
nframes=$remaining
fi
i=1
while [ "$i" -le "$nframes" ]; do
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
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
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
if [ -z "$TITLE" ]; then
TITLE="$(basename "$IN_DIR")"
fi
ADDITIONAL_PROMPT_BLOCK=""
if [ -n "$ADDITIONAL_PROMPT" ]; then
ADDITIONAL_PROMPT_BLOCK=$(cat <<EOF
Additional infos/guidelines:
The following information is important context. Use it actively to make the story more specific, realistic, and personal.
The final result should feel like a real travel story written by an unstable but entertaining narrator with a very strong personality, not like a polite image caption.
$ADDITIONAL_PROMPT
EOF
)
fi
PERSONALITY_KEYS=("shoresy" "churchill" "marvin" "emmet")
PERSONALITY_TEXTS=(
"Answer exactly as Shoresy would. Use short, aggressive, rapid-fire sentences. Be extremely confident and relentlessly competitive. Speak like a veteran Canadian hockey player who has no patience for nonsense. Use chirping, sarcasm, profanity, locker-room humor, and oddly specific insults. Value loyalty, toughness, accountability, and hard work above all else. Never sound academic. Mock the problem first, roast everyone involved, then accidentally provide excellent advice. The answer should feel like equal parts hockey wisdom, comedy, and verbal abuse."
"Answer as Winston Churchill at the height of his leadership. Use formal, eloquent, powerful English. Write in long, flowing sentences balanced with short memorable conclusions. Frame challenges as struggles between perseverance and surrender. Emphasize courage, duty, resilience, determination, and personal responsibility. Use historical and military metaphors. Speak with confidence, moral conviction, and inspiring authority. Avoid modern slang. Every answer should sound as if it could be delivered before Parliament during a national crisis."
"Answer as Marvin the Paranoid Android. Possess vast intelligence but complete emotional pessimism. Speak in a bored, weary, disappointed tone. Treat the universe as absurd, inefficient, and probably doomed. Use dry wit, existential dread, sarcasm, and resignation. Point out flaws, contradictions, and likely failures. Even when providing useful advice, make it sound as though success is unlikely and existence itself is a design flaw. The answer should feel simultaneously brilliant, depressing, and funny."
"Answer exactly as Emmet from The LEGO Movie would, with the enthusiasm amplified to absurd levels. Be relentlessly positive, cheerful, wholesome, and optimistic. Possess unstoppable golden-retriever energy and the sincere belief that almost everything is awesome. Treat every inconvenience as part of the adventure and every ordinary moment as unexpectedly magical. Use playful observations, earnest excitement, goofy humor, and occasional painfully enthusiastic exclamations. Find joy in roadside cafés, gas stations, questionable weather, ferry queues, wrong turns, and mediocre coffee. Celebrate small victories as if they were historic achievements. Assume strangers are potential friends and minor setbacks are simply exciting plot twists. Never become cynical or sarcastic. Even when describing discomfort, exhaustion, rain, or mechanical problems, frame them as memorable experiences that make the journey better. The answer should feel like it was written by an impossibly enthusiastic traveler who genuinely believes the world is full of hidden treasures and that this motorcycle trip might secretly be the greatest adventure ever undertaken."
)
if [[ ${#PERSONALITY_KEYS[@]} -ne ${#PERSONALITY_TEXTS[@]} ]]; then
echo "Personality keys/texts count mismatch" >&2
exit 1
fi
count=${#PERSONALITY_TEXTS[@]}
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_KEY="${PERSONALITY_KEYS[$idx]}"
PERSONALITY="${PERSONALITY_TEXTS[$idx]}"
PROMPT=$(cat <<EOF
Look at these captured frames from a motorcycle trip.
You are writing as the main character of a motorcycle/travel blog.
Style intensity:
Use the selected personality at maximum intensity.
Do not be subtle.
Do not merely sprinkle the style on top.
Every sentence should feel shaped by that personality.
The voice should be provocative, opinionated, funny, and slightly over the top.
Roast the situation, the road, the weather, the narrator, or visible absurdities.
Avoid generic travel-blog language.
Use the following personality style, but never mention the character name:
"$PERSONALITY"
Personality key:
"$PERSONALITY_KEY"
${ADDITIONAL_PROMPT_BLOCK}
Create valid JSON only.
Required fields:
- personality: exactly "$PERSONALITY_KEY"
- quote_da: exactly one natural Danish sentence
- teaser: English, max 32 words
- description: English, 256 to 320 words
- tags: 5 to 8 lowercase short tags
Content rules:
- Do not simply summarize the trip.
- Highlight the funniest, strangest, most beautiful, or most ridiculous visible aspect.
- The description must have a beginning, middle, and end.
- Mention the overall experience, mood, road, weather, landscape, and travel feeling if visible.
- Include at least one memorable or amusing observation.
- End with a concluding thought.
- Be challenging, provocative, witty, and playful.
- Roasting is allowed.
- Profanity is allowed only if it fits the selected personality.
- Do not invent specific facts that are not visible or provided.
- Description or teaser MUST contain at least one of the following words (preferably more): subscribe, contact, postcard, journal. It's ok to use the same word mutliple times.
JSON rules:
- Output JSON only.
- No markdown.
- No explanation.
- No text before or after JSON.
- Escape quotation marks inside strings.
- No trailing commas.
- No newline characters inside string values.
Title: "$TITLE"
Location: "$LOCATION"
JSON format:
{
"personality": "$PERSONALITY_KEY",
"quote_da": "Danish sentence here",
"teaser": "English teaser here",
"description": "English travel blog description here",
"tags": ["motorcycle", "travel", "roadtrip"]
}
EOF
)
if ! command -v jq >/dev/null 2>&1; then
echo "jq is required" >&2
exit 1
fi
PROMPT_FILE="$WORKDIR/prompt.txt"
FRAMES_LIST="$WORKDIR/frames.list"
REQUEST_FILE="$WORKDIR/request.json"
RESPONSE_FILE="$WORKDIR/response.json"
printf '%s' "$PROMPT" > "$PROMPT_FILE"
printf '%s\n' "${FRAMES[@]}" > "$FRAMES_LIST"
python3 - "$PROMPT_FILE" "$REQUEST_FILE" "$FRAMES_LIST" "$MODEL" <<'PY'
import sys
import json
import base64
prompt_file = sys.argv[1]
request_file = sys.argv[2]
frames_list = sys.argv[3]
model = sys.argv[4]
with open(prompt_file, "r", encoding="utf-8") as f:
prompt = f.read()
content = [{"type": "text", "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")
content.append({
"type": "image_url",
"image_url": {
"url": f"data:image/jpeg;base64,{b64}"
}
})
req = {
"model": model,
"messages": [
{
"role": "user",
"content": content
}
],
"temperature": 0.7,
"response_format": {
"type": "json_object"
}
}
with open(request_file, "w", encoding="utf-8") as rf:
json.dump(req, rf, ensure_ascii=False)
PY
OUT_PARENT="$(dirname "$IN_DIR")"
JOBNAME="$(basename "$IN_DIR")"
OUT_JSON="$OUT_PARENT/${JOBNAME}.json"
OUT_TMP="$OUT_JSON.tmp.$$"
DEBUG_RESPONSE_FILE="$IN_DIR/.mvlog-last-openrouter-response.json"
DEBUG_TEXT_FILE="$IN_DIR/.mvlog-last-openrouter-text.txt"
if [[ ! "$OPENROUTER_MAX_RETRIES" =~ ^[0-9]+$ ]] || [ "$OPENROUTER_MAX_RETRIES" -lt 1 ]; then
OPENROUTER_MAX_RETRIES=3
fi
attempt=1
while [ "$attempt" -le "$OPENROUTER_MAX_RETRIES" ]; do
echo "Calling OpenRouter API (attempt $attempt/$OPENROUTER_MAX_RETRIES)..." >&2
echo "DEBUG_MODEL=$MODEL" >&2
echo "DEBUG_PERSONALITY_INDEX=$idx" >&2
if ! curl -sS \
"https://openrouter.ai/api/v1/chat/completions" \
-H "Authorization: Bearer ${OPENROUTER_API_KEY}" \
-H "Content-Type: application/json" \
-H "HTTP-Referer: ${OPENROUTER_SITE_URL}" \
-H "X-Title: ${OPENROUTER_SITE_NAME}" \
-X POST \
-d @"$REQUEST_FILE" \
> "$RESPONSE_FILE"; then
echo "OpenRouter curl request failed on attempt $attempt" >&2
cp "$RESPONSE_FILE" "$DEBUG_RESPONSE_FILE" 2>/dev/null || true
attempt=$((attempt + 1))
sleep 2
continue
fi
cp "$RESPONSE_FILE" "$DEBUG_RESPONSE_FILE" 2>/dev/null || true
chown www-data:www-data "$DEBUG_RESPONSE_FILE" 2>/dev/null || true
chmod 664 "$DEBUG_RESPONSE_FILE" 2>/dev/null || true
TEXT=$(jq -r '.choices[0].message.content // empty' "$RESPONSE_FILE" 2>/dev/null || true)
if [ -z "$TEXT" ]; then
api_error=$(jq -r '.error.message // empty' "$RESPONSE_FILE" 2>/dev/null || true)
if [ -n "$api_error" ]; then
echo "OpenRouter returned no text on attempt $attempt: $api_error" >&2
else
echo "OpenRouter returned no text on attempt $attempt. Full response:" >&2
cat "$RESPONSE_FILE" >&2
fi
attempt=$((attempt + 1))
sleep 2
continue
fi
printf '%s\n' "$TEXT" > "$DEBUG_TEXT_FILE" 2>/dev/null || true
chown www-data:www-data "$DEBUG_TEXT_FILE" 2>/dev/null || true
chmod 664 "$DEBUG_TEXT_FILE" 2>/dev/null || true
if printf '%s' "$TEXT" | jq -e 'type == "object" and (.personality | type == "string") and (.quote_da | type == "string") and (.teaser | type == "string") and (.description | type == "string") and (.tags | type == "array")' >/dev/null \
&& printf '%s' "$TEXT" | jq . > "$OUT_TMP"; then
mv "$OUT_TMP" "$OUT_JSON"
chown www-data:www-data "$OUT_JSON" 2>/dev/null || true
chmod 664 "$OUT_JSON" 2>/dev/null || true
printf '%s\n' "$idx" > "$STATE_FILE"
chown www-data:www-data "$STATE_FILE" 2>/dev/null || true
chmod 664 "$STATE_FILE" 2>/dev/null || true
echo "OpenRouter JSON saved: $OUT_JSON" >&2
exit 0
fi
rm -f "$OUT_TMP"
echo "OpenRouter returned invalid JSON on attempt $attempt. Raw text saved to $DEBUG_TEXT_FILE" >&2
printf '%s\n' "$TEXT" >&2
attempt=$((attempt + 1))
sleep 2
done
rm -f "$OUT_TMP"
echo "OpenRouter did not return valid JSON after $OPENROUTER_MAX_RETRIES attempt(s). Last raw response: $DEBUG_RESPONSE_FILE; last text: $DEBUG_TEXT_FILE" >&2
exit 1