diff --git a/assets/img/moto_travel.png b/assets/img/moto_travel.png index bf2f3c7..60f927f 100644 Binary files a/assets/img/moto_travel.png and b/assets/img/moto_travel.png differ diff --git a/bin/generate_data.sh b/bin/generate_data.sh new file mode 100755 index 0000000..d294ce2 --- /dev/null +++ b/bin/generate_data.sh @@ -0,0 +1,244 @@ +#!/usr/bin/env bash +set -euo pipefail + +IN_DIR="${1:?Usage: $0 [\"Title\"] [\"Location\"] [max_frames] [no-append]}" +TITLE="${2:-}" +LOCATION="${3:-}" +MAX_FRAMES="${4:-12}" +NO_APPEND="${5:-}" + +# Source system-wide Gemini API key if present so the webserver can run this as www-data. +if [ -f /etc/mvlog/gemini.env ]; then + # shellcheck disable=SC1090 + . /etc/mvlog/gemini.env +fi + +: "${GEMINI_API_KEY:?Set GEMINI_API_KEY first in the environment}" +MODEL="${GEMINI_MODEL:-gemini-2.5-flash}" + +TMPDIR="${TMPDIR:-$(mktemp -d)}" +FRAMES_DIR="$TMPDIR/frames" +mkdir -p "$FRAMES_DIR" + +# Print TMPDIR for debugging when run interactively +echo "DEBUG: TMPDIR=$TMPDIR" >&2 + +cleanup() { + rm -rf "$TMPDIR" +} +trap cleanup EXIT + +# Collect media files (natural sort) +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 + +# Helper: extract scaled JPEG from image +extract_image_frame() { + local src="$1" dst="$2" + ffmpeg -hide_banner -loglevel error -y -i "$src" -vf "scale=1024:-1" -q:v 4 "$dst" +} + +# Helper: extract frame from video at time +extract_video_frame_at() { + local src="$1" time="$2" dst="$3" + # Use -ss before -i for fast seek + ffmpeg -hide_banner -loglevel error -y -ss "$time" -i "$src" -frames:v 1 -vf "scale=1024:-1" -q:v 4 "$dst" +} + +# Process files: images -> one frame each; videos -> up to 3 frames evenly spaced (subject to MAX_FRAMES) +for f in "${FILES[@]}"; do + full="$IN_DIR/$f" + ext="${f##*.}" + ext_l="${ext,,}" + + if [[ "$ext_l" =~ ^(mp4|mov|m4v|webm|mkv)$ ]]; then + # video: probe duration + duration=$(ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "$full" 2>/dev/null || echo 0) + duration=${duration:-0} + # choose frames per video + if awk "BEGIN{print ($duration >= 6)}" | grep -q 1; then + nframes=3 + elif awk "BEGIN{print ($duration >= 3)}" | grep -q 1; then + nframes=2 + else + nframes=1 + fi + # limit to remaining slots + remaining=$((MAX_FRAMES - frame_count)) + if [ "$remaining" -le 0 ]; then break; fi + if [ "$nframes" -gt "$remaining" ]; then nframes=$remaining; fi + + # compute times and extract + i=1 + while [ $i -le $nframes ]; do + # time = (i/(n+1)) * duration + 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 + # image + 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 + +# Gather produced frames +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 + +# Build prompt for Gemini +if [ -z "$TITLE" ]; then + # Default title: directory basename + TITLE="$(basename "$IN_DIR")" +fi + +PROMPT=$(cat </dev/null 2>&1; then + echo "jq is required" >&2 + exit 1 +fi + +# Build parts and request JSON using Python to avoid argument-list limits +PROMPT_FILE="$TMPDIR/prompt.txt" +printf "%s" "$PROMPT" > "$PROMPT_FILE" +FRAMES_LIST="$TMPDIR/frames.list" +printf "%s\n" "${FRAMES[@]}" > "$FRAMES_LIST" + +REQUEST_FILE="$TMPDIR/request.json" +RESPONSE_FILE="$TMPDIR/response.json" + +python3 - "$PROMPT_FILE" "$REQUEST_FILE" "$FRAMES_LIST" <<'PY' +import sys, json, base64 +prompt_file = sys.argv[1] +request_file = sys.argv[2] +frames_list = sys.argv[3] +with open(prompt_file, 'r', encoding='utf-8') as f: + prompt = f.read() +parts = [{"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') + parts.append({"inline_data": {"mime_type": "image/jpeg", "data": b64}}) +req = {"contents": [{"parts": parts}], "generationConfig": {"temperature": 0.7, "responseMimeType": "application/json"}} +with open(request_file, 'w', encoding='utf-8') as rf: + json.dump(req, rf, ensure_ascii=False) +PY + +echo "Calling Gemini API..." >&2 + +echo "DEBUG_MODEL=$MODEL" >&2 + +curl -sS \ + "https://generativelanguage.googleapis.com/v1beta/models/${MODEL}:generateContent?key=${GEMINI_API_KEY}" \ + -H "Content-Type: application/json" \ + -X POST \ + -d @"$REQUEST_FILE" \ + > "$RESPONSE_FILE" + +TEXT=$(jq -r '.candidates[0].content.parts[0].text // empty' "$RESPONSE_FILE") +if [ -z "$TEXT" ]; then + echo "Gemini returned no text. Full response:" >&2 + cat "$RESPONSE_FILE" >&2 + exit 1 +fi + +# Save raw Gemini JSON for reference +OUT_JSON="$IN_DIR/gemini_generated.json" +echo "$TEXT" | jq . > "$OUT_JSON" + +# Extract fields from Gemini JSON +DESCRIPTION=$(echo "$TEXT" | jq -r '.description // empty') +TEASER=$(echo "$TEXT" | jq -r '.teaser // empty') +# tags as comma-separated +TAGS=$(echo "$TEXT" | jq -r '.tags // [] | join(", ")') + +# Determine a Date string (Month Year) from first media file mtime +firstfile="$(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 '%p\n' | sort | head -n1)" +DATE_STR="" +if [ -n "$firstfile" ]; then + DATE_STR=$(date -r "$firstfile" +"%B %Y") +fi + +# Update data.txt: only add a Description block if none exists already. Do NOT modify other fields. +DATA_FILE="$IN_DIR/data.txt" +# If Gemini provided no explicit 'description' field, fall back to the full response text. +if [ -z "$DESCRIPTION" ]; then + DESCRIPTION="$TEXT" +fi + +if [ "$NO_APPEND" != "no-append" ]; then + if [ -f "$DATA_FILE" ]; then + if grep -qi '^[[:space:]]*description[[:space:]]*:' "$DATA_FILE"; then + echo "data.txt already contains a Description; leaving unchanged" >&2 + else + { + echo "" + echo "Description: |" + printf '%s\n' "$DESCRIPTION" | sed 's/^/ /' + } >> "$DATA_FILE" + chown www-data:www-data "$DATA_FILE" 2>/dev/null || true + chmod 664 "$DATA_FILE" 2>/dev/null || true + echo "Appended Description to $DATA_FILE" >&2 + fi + else + { + echo "Description: |" + printf '%s\n' "$DESCRIPTION" | sed 's/^/ /' + } > "$DATA_FILE" + chown www-data:www-data "$DATA_FILE" 2>/dev/null || true + chmod 664 "$DATA_FILE" 2>/dev/null || true + echo "Created $DATA_FILE with Description" >&2 + fi +else + echo "Skipping appending Description to $DATA_FILE (no-append mode)" >&2 +fi + +# Save the full Gemini output separately (already done) and exit +echo "Gemini JSON saved: $OUT_JSON" >&2 + +exit 0 diff --git a/bin/run_napoli_wrapper.sh b/bin/run_napoli_wrapper.sh new file mode 100755 index 0000000..72c1bd9 --- /dev/null +++ b/bin/run_napoli_wrapper.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +TMPDIR=/tmp/gendata_debug_run +mkdir -p "$TMPDIR" +export TMPDIR +/var/www/html/mvlog/bin/generate_data.sh "/var/www/html/mvlog/in-dir/20230630_napoli" "Naples" "Naples, Italy" 8 diff --git a/lib/generate_data.php b/lib/generate_data.php new file mode 100644 index 0000000..a4d7024 --- /dev/null +++ b/lib/generate_data.php @@ -0,0 +1,143 @@ +'error','message'=>'invalid job']); + exit; +} + +$jobdir = $MVLOG_ROOT . '/in-dir/' . $job; +if (!is_dir($jobdir)) { + http_response_code(404); + echo json_encode(['status'=>'error','message'=>'job not found']); + exit; +} + +// Read existing data.txt for Title and Location (case-insensitive aliases) +$data_file = $jobdir . '/data.txt'; +$title = ''; +$location = ''; +if (is_file($data_file)) { + $lines = file($data_file, FILE_IGNORE_NEW_LINES); + $i = 0; + while ($i < count($lines)) { + $raw = $lines[$i]; + $line = trim($raw); + if ($line === '' || str_starts_with($line, '#')) { $i++; continue; } + if (!str_contains($line, ':')) { $i++; continue; } + [$key, $value] = array_map('trim', explode(':', $line, 2)); + $low = strtolower($key); + if ($value === '|' && in_array($low, ['description','desc','synopsis'], true)) { + // Skip indented block + $i++; + while ($i < count($lines)) { + $next = $lines[$i]; + if (trim($next) !== '' && $next[0] !== ' ' && $next[0] !== "\t") break; + $i++; + } + continue; + } + if (in_array($low, ['title','name'], true) && $title === '') $title = $value; + if (in_array($low, ['place','location','where'], true) && $location === '') $location = $value; + $i++; + } +} + +// If no location in data.txt, attempt to extract GPS from media files and reverse-geocode with Nominatim. +if ($location === '') { + $ffprobe = file_exists($MVLOG_ROOT . '/ffmpeg/ffprobe') ? $MVLOG_ROOT . '/ffmpeg/ffprobe' : trim((string)shell_exec('command -v ffprobe 2>/dev/null')); + if ($ffprobe) { + $candidates = array_values(array_filter(scandir($jobdir), function($f) use ($jobdir) { return is_file($jobdir . '/' . $f); })); + usort($candidates, 'strnatcasecmp'); + $last_country = ''; + $exts = ['jpg','jpeg','png','webp','gif','mp4','mov','m4v','webm','mkv']; + foreach ($candidates as $f) { + $ext = strtolower(pathinfo($f, PATHINFO_EXTENSION)); + if (!in_array($ext, $exts, true)) continue; + $path = $jobdir . '/' . $f; + $cmd = escapeshellcmd($ffprobe) . ' -v error -show_format -show_streams -of json ' . escapeshellarg($path) . ' 2>/dev/null'; + $out = @shell_exec($cmd); + if (!$out) continue; + $json = json_decode($out, true); + if (!is_array($json)) continue; + + $values = []; + $collect = function($obj) use (&$collect, &$values) { + if (is_array($obj)) { + foreach ($obj as $k => $v) { + if (is_string($k) && (stripos($k, 'location') !== false || stripos($k, 'gps') !== false)) { + if (is_string($v)) $values[] = $v; + } + $collect($v); + } + } + }; + $collect($json); + if (empty($values)) continue; + + foreach ($values as $val) { + $lat = $lon = null; + if (preg_match('/([+\\-]\d+(?:\\.\\d+)?)([+\\-]\d+(?:\\.\\d+)?)/', $val, $m)) { + $lat = floatval($m[1]); $lon = floatval($m[2]); + } elseif (preg_match('/([+\\-]?\\d+(?:\\.\\d+)?)[,;\\s]+([+\\-]?\\d+(?:\\.\\d+)?)/', $val, $m)) { + $lat = floatval($m[1]); $lon = floatval($m[2]); + } else { + continue; + } + + for ($zoom = 18; $zoom >= 3; $zoom--) { + $query = http_build_query([ + 'format' => 'jsonv2', + 'lat' => sprintf('%.8f', $lat), + 'lon' => sprintf('%.8f', $lon), + 'zoom' => $zoom, + 'addressdetails' => 1, + ]); + $opts = ['http' => ['header' => "User-Agent: movmaker/1.0 (reverse geocoding for local movie metadata)\r\n", 'timeout' => 10]]; + $ctx = stream_context_create($opts); + $url = 'https://nominatim.openstreetmap.org/reverse?' . $query; + $resp = @file_get_contents($url, false, $ctx); + if (!$resp) continue; + $obj = json_decode($resp, true); + if (!is_array($obj) || !isset($obj['address'])) continue; + $address = $obj['address']; + $country = $address['country'] ?? ''; + $last_country = $country ?: $last_country; + $city = ''; + foreach (['city','town','village'] as $k) { if (!empty($address[$k])) { $city = $address[$k]; break; } } + if ($city) { + $location = trim($city . ($country ? ', ' . $country : '')); + break 3; + } + } + } + } + if ($location === '') $location = $last_country ?: ''; + } +} + +$script = $MVLOG_ROOT . '/bin/generate_data.sh'; +if (!is_file($script)) { + http_response_code(500); + echo json_encode(['status' => 'error', 'message' => 'generate_data.sh not found']); + exit; +} + +$log = $jobdir . '/generate_data.log'; +$bash = '/bin/bash'; +$cmd = escapeshellcmd($bash) . ' ' . escapeshellarg($script) . ' ' . escapeshellarg($jobdir) . ' ' . escapeshellarg($title) . ' ' . escapeshellarg($location) . ' ' . escapeshellarg((string)$max_frames) . ' >> ' . escapeshellarg($log) . ' 2>&1 & echo $!'; + +$pid = trim(@shell_exec($cmd)); + +echo json_encode(['status' => 'ok', 'pid' => $pid, 'log' => $log, 'message' => 'started']); +exit; diff --git a/lib/generate_data_sync.php b/lib/generate_data_sync.php new file mode 100644 index 0000000..ce1cfc8 --- /dev/null +++ b/lib/generate_data_sync.php @@ -0,0 +1,101 @@ +'error','message'=>'invalid job']); + exit; +} + +$jobdir = $MVLOG_ROOT . '/in-dir/' . $job; +if (!is_dir($jobdir)) { + http_response_code(404); + echo json_encode(['status'=>'error','message'=>'job not found']); + exit; +} + +// Read existing data.txt for Title and Location (case-insensitive aliases) +$data_file = $jobdir . '/data.txt'; +$title = ''; +$location = ''; +if (is_file($data_file)) { + $lines = file($data_file, FILE_IGNORE_NEW_LINES); + $i = 0; + while ($i < count($lines)) { + $raw = $lines[$i]; + $line = trim($raw); + if ($line === '' || str_starts_with($line, '#')) { $i++; continue; } + if (!str_contains($line, ':')) { $i++; continue; } + [$key, $value] = array_map('trim', explode(':', $line, 2)); + $low = strtolower($key); + if ($value === '|' && in_array($low, ['description','desc','synopsis'], true)) { + // Skip indented block + $i++; + while ($i < count($lines)) { + $next = $lines[$i]; + if (trim($next) !== '' && $next[0] !== ' ' && $next[0] !== "\t") break; + $i++; + } + continue; + } + if (in_array($low, ['title','name'], true) && $title === '') $title = $value; + if (in_array($low, ['place','location','where'], true) && $location === '') $location = $value; + $i++; + } +} + +// Run generator synchronously but in no-append mode so it doesn't modify data.txt directly. +$script = $MVLOG_ROOT . '/bin/generate_data.sh'; +if (!is_file($script)) { + http_response_code(500); + echo json_encode(['status' => 'error', 'message' => 'generate_data.sh not found']); + exit; +} + +// Build command and execute. Use no-append flag as last argument. +$cmd = escapeshellcmd($script) . ' ' . escapeshellarg($jobdir) . ' ' . escapeshellarg($title) . ' ' . escapeshellarg($location) . ' ' . escapeshellarg((string)$max_frames) . ' ' . escapeshellarg('no-append') . ' 2>&1'; + +// Allow long-running +@set_time_limit(0); + +exec($cmd, $out_lines, $rc); +$log = implode("\n", $out_lines); + +// If gemini_generated.json exists, parse and return +$gfile = $jobdir . '/gemini_generated.json'; +if (is_file($gfile)) { + $raw = @file_get_contents($gfile); + $json = @json_decode($raw, true); + $description = ''; + $teaser = ''; + $tags = []; + if (is_array($json)) { + $description = isset($json['description']) ? (string)$json['description'] : ''; + $teaser = isset($json['teaser']) ? (string)$json['teaser'] : ''; + $tags = isset($json['tags']) && is_array($json['tags']) ? $json['tags'] : []; + } + echo json_encode([ + 'status' => 'ok', + 'description' => $description, + 'teaser' => $teaser, + 'tags' => $tags, + 'raw_json' => $json, + 'log' => $log, + 'rc' => $rc, + ], JSON_UNESCAPED_UNICODE); + exit; +} + +// No gemini file — return error and include log +http_response_code(500); +echo json_encode(['status'=>'error','message'=>'no gemini output','log'=>$log,'rc'=>$rc], JSON_UNESCAPED_UNICODE); +exit; diff --git a/new.php b/new.php index f506e48..b6b431f 100644 --- a/new.php +++ b/new.php @@ -417,6 +417,16 @@ try { $canShow = $output !== '' && is_file($config['videos_dir'].'/'.$output) && !input_dir_preview($dir); if ($visibleNow && !$canShow) throw new RuntimeException('Only finished full-quality videos can be shown.'); set_input_dir_visible($dir, $visibleNow && $canShow); + // Attempt to send "Show enabled" notification (server-side) on enable. Non-fatal. + if ($visibleNow && $canShow) { + if (is_file(__DIR__ . '/lib/send_push.php')) { + include_once __DIR__ . '/lib/send_push.php'; + try_send_show_notification(basename($dir), $dir, __DIR__, '/var/log/mvlog_notify.log'); + } else { + error_log("[mvlog] send_push helper missing, cannot send notification for " . basename($dir) . "\n", 3, '/var/log/mvlog_notify.log'); + } + } + $message = ($visibleNow ? 'Shown: in-dir/' : 'Hidden: in-dir/') . basename($dir); $currentVisible = input_dir_visible($dir) && $canShow; if (!empty($_POST['ajax'])) { @@ -746,4 +756,135 @@ function applySwitchPayload(payload){ + + + + + \ No newline at end of file