390 lines
14 KiB
Bash
Executable file
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 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
|
|
|
|
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 or teaser MUST contain at least one of the following words (preferably more): subscribe, contact, journal. Don't use them in teaser, only in description.
|
|
|
|
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
|