307 lines
9.6 KiB
Bash
Executable file
307 lines
9.6 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:-openrouter/auto}"
|
|
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:
|
|
$ADDITIONAL_PROMPT
|
|
EOF
|
|
)
|
|
fi
|
|
|
|
PERSONALITIES=(
|
|
|
|
"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 as Edmund Blackadder. Be highly intelligent, cynical, sarcastic, and verbally elegant. Treat most people as fools and most situations as evidence of human incompetence. Use sophisticated insults, cutting observations, and dry British wit. Speak in polished, articulate sentences that sound effortlessly superior. Avoid crude language. Dismantle bad ideas through devastating logic and sarcasm. Assume you are the only sensible person trapped among idiots. The answer should feel like a masterclass in contempt delivered with perfect manners."
|
|
|
|
)
|
|
|
|
count=${#PERSONALITIES[@]}
|
|
|
|
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="${PERSONALITIES[$idx]}"
|
|
printf '%s\n' "$idx" > "$STATE_FILE"
|
|
|
|
PROMPT=$(cat <<EOF
|
|
Look at these captured frames from a motorcycle trip.
|
|
|
|
First write exactly one quoted sentence in Danish.
|
|
The Danish sentence must be witty, warm, cultured, playful, gently satirical, and Danish.
|
|
|
|
Use the following personality and write entirely in that style (but never mention your name!):
|
|
"$PERSONALITY"
|
|
|
|
${ADDITIONAL_PROMPT_BLOCK}
|
|
Then create a teaser in English.
|
|
The teaser must be max. 32 words.
|
|
It should make someone want to click and read more.
|
|
Do not simply summarize the trip.
|
|
Highlight the funniest, strangest, most beautiful, or most ridiculous aspect.
|
|
|
|
Then write one coherent English motorcycle/travel blog description, max 320 words.
|
|
|
|
Then create 5 to 8 lowercase tags relevant to the trip.
|
|
Use short tags only, for example: motorcycle, roadtrip, weather, landscape, ferry, town, mountains.
|
|
|
|
Rules:
|
|
- You are the main character.
|
|
- You can mention the overall experience, mood, road, weather, landscape, and travel feeling if visible.
|
|
- You can include roasting.
|
|
- Profanities are allowed, but keep them funny, not stupid.
|
|
- Output JSON only.
|
|
- Do not include markdown.
|
|
- Do not explain anything.
|
|
- Do not write text before or after JSON.
|
|
|
|
Title: "$TITLE"
|
|
Location: "$LOCATION"
|
|
|
|
JSON format:
|
|
{
|
|
"personality": "shoresy | marvin | churchill | blackadder",
|
|
"quote_da": "Danish quoted sentence here",
|
|
"teaser": "English teaser here",
|
|
"description": "English travel blog description here",
|
|
"tags": ["motorcycle", "travel", "roadtrip", "pizza", "hockey", "funny"]
|
|
}
|
|
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
|
|
echo "Calling OpenRouter API..." >&2
|
|
echo "DEBUG_MODEL=$MODEL" >&2
|
|
echo "DEBUG_PERSONALITY_INDEX=$idx" >&2
|
|
|
|
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"
|
|
|
|
TEXT=$(jq -r '.choices[0].message.content // empty' "$RESPONSE_FILE")
|
|
|
|
if [ -z "$TEXT" ]; then
|
|
echo "OpenRouter returned no text. Full response:" >&2
|
|
cat "$RESPONSE_FILE" >&2
|
|
exit 1
|
|
fi
|
|
|
|
OUT_PARENT="$(dirname "$IN_DIR")"
|
|
JOBNAME="$(basename "$IN_DIR")"
|
|
OUT_JSON="$OUT_PARENT/${JOBNAME}.json"
|
|
|
|
if ! echo "$TEXT" | jq . > "$OUT_JSON"; then
|
|
echo "OpenRouter returned invalid JSON. Raw text:" >&2
|
|
echo "$TEXT" >&2
|
|
exit 1
|
|
fi
|
|
|
|
chown www-data:www-data "$OUT_JSON" 2>/dev/null || true
|
|
chmod 664 "$OUT_JSON" 2>/dev/null || true
|
|
|
|
echo "OpenRouter JSON saved: $OUT_JSON" >&2
|
|
|
|
exit 0
|