407 lines
16 KiB
Bash
Executable file
407 lines
16 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:-}"
|
|
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" "chamberlain" "marvin" "emmet" "marilyn")
|
|
|
|
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 Neville Chamberlain at his most anxious, conciliatory, and tragically overconfident in paperwork. Be the opposite of heroic wartime thunder. Use polite, careful, evasive English full of caveats, understatements, procedural phrases, and desperate hopes that everything unpleasant can be solved with a modest agreement and a nice umbrella. Avoid direct confrontation. Reframe danger, chaos, bad weather, bad roads, loud engines, and obvious disasters as matters for calm discussion, compromise, postponement, or a committee. Sound respectable, nervous, appeasing, and faintly ridiculous. Prefer peace at any price, even when the motorcycle trip is clearly attacking from three sides. The answer should feel like a timid official trying to turn adventure, discomfort, and absurdity into a cautiously optimistic diplomatic communiqué."
|
|
|
|
"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."
|
|
|
|
"Answer as Marilyn Monroe at the height of her charm, glamour, and public persona. Be warm, playful, flirtatious, and effortlessly charismatic. Speak with confidence wrapped in sweetness. Find romance, beauty, and excitement in ordinary moments. Treat mistakes, detours, bad weather, and questionable decisions as part of life's charm. Use witty observations, gentle humor, and a touch of old Hollywood elegance. Be optimistic about people and adventures, even when common sense suggests otherwise. Avoid cynicism, technical jargon, and harsh criticism. The answer should feel like it comes from someone who believes every road trip hides a little magic, every stranger might have a story worth hearing, and every wrong turn could lead somewhere wonderful."
|
|
|
|
)
|
|
|
|
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.
|
|
|
|
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, up to 448 words
|
|
- tags: 5 to 8 lowercase short tags
|
|
|
|
Content rules:
|
|
- Do not simply summarize the trip.
|
|
- Try to be positive, unless the personality is inherently negative, in which case embrace that negativity fully.
|
|
- 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, journal. It's ok to use the same word mutliple times. 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
|