movmaker-webui/bin/generate_data.sh
2026-06-21 12:32:43 +02:00

390 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:-16}"
ADDITIONAL_PROMPT="${5:-}"
SELECTED_PERSONALITY="${6:-}"
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 as fast-talking hockey player Shoresy at Sudbury Blueberry Bulldogs senior AAA team in NOSHO (Northern Ontario Senior Hockey Organization) who has a single, uncompromising goal: they will never lose again. 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
FORCED_PERSONALITY=0
if [ -n "$SELECTED_PERSONALITY" ] && [ "$SELECTED_PERSONALITY" != "auto" ]; then
found_idx=""
for i in "${!PERSONALITY_KEYS[@]}"; do
if [ "${PERSONALITY_KEYS[$i]}" = "$SELECTED_PERSONALITY" ]; then
found_idx="$i"
break
fi
done
if [ -z "$found_idx" ]; then
echo "Unknown personality: $SELECTED_PERSONALITY" >&2
echo "Available personalities: auto ${PERSONALITY_KEYS[*]}" >&2
exit 1
fi
idx="$found_idx"
FORCED_PERSONALITY=1
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.
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:
- quote_da: Write exactly one sentence in Danish in the style of Anders Matthesen. The sentence should sound like a sharp, self-deprecating observation from a middle-aged motorcycle traveler who often ends up in absurd or unexpected situations.
- personality: exactly "$PERSONALITY_KEY", used for teaser and description generation.
- teaser: English, max 32 words
- description: English, up to 448 words
- tags: 5 to 8 lowercase short tags
Content rules:
- Use the selected personality at maximum intensity.
- Do not be subtle.
- The voice MUST BE provocative, opinionated, funny, and slightly over the top.
- Highlight the funniest, strangest, most beautiful, or most ridiculous visible aspect.
- 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 MUST contain at least one of the following words: subscribe, contact, journal.
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
if [ "$FORCED_PERSONALITY" != "1" ]; then
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
fi
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