Admin: add Describe UI, synchronous Gemini generator endpoint; update generate_data.sh (no-append)
This commit is contained in:
parent
c8a51488c0
commit
c2d64cb00a
6 changed files with 634 additions and 0 deletions
Binary file not shown.
|
Before Width: | Height: | Size: 1.8 MiB After Width: | Height: | Size: 1.4 MiB |
244
bin/generate_data.sh
Executable file
244
bin/generate_data.sh
Executable file
|
|
@ -0,0 +1,244 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
IN_DIR="${1:?Usage: $0 <input-dir> [\"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 <<EOF
|
||||||
|
You write short descriptions for my motorcycle/travel blog.
|
||||||
|
You should talk like the main character from TV show Shoresy - profane, passionate,
|
||||||
|
and hilariously obsessive titular protagonist of the acclaimed Canadian ice hockey comedy series.
|
||||||
|
Use real quotes from the show as much as possible.
|
||||||
|
|
||||||
|
Title: "$TITLE"
|
||||||
|
Location: "$LOCATION"
|
||||||
|
|
||||||
|
Look at these captured frames and write JSON only.
|
||||||
|
|
||||||
|
Required JSON format:
|
||||||
|
{
|
||||||
|
"description": "2-3 relaxed human sentences",
|
||||||
|
"teaser": "one short teaser sentence",
|
||||||
|
"tags": ["tag1", "tag2", "tag3", "tag4", "tag5"]
|
||||||
|
}
|
||||||
|
|
||||||
|
Style:
|
||||||
|
personal, natural, slightly casual, not corporate, not clickbait.
|
||||||
|
Do not invent facts that are not visible in the images or provided in title/location.
|
||||||
|
EOF
|
||||||
|
)
|
||||||
|
|
||||||
|
PARTS_FILE="$TMPDIR/parts.json"
|
||||||
|
|
||||||
|
# Create parts with the prompt
|
||||||
|
if ! command -v jq >/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
|
||||||
5
bin/run_napoli_wrapper.sh
Executable file
5
bin/run_napoli_wrapper.sh
Executable file
|
|
@ -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
|
||||||
143
lib/generate_data.php
Normal file
143
lib/generate_data.php
Normal file
|
|
@ -0,0 +1,143 @@
|
||||||
|
<?php
|
||||||
|
// lib/generate_data.php
|
||||||
|
// Trigger the bin/generate_data.sh script for a given in-dir job. Requires admin login.
|
||||||
|
require __DIR__ . '/../auth.php';
|
||||||
|
mvlog_require_login();
|
||||||
|
|
||||||
|
$MVLOG_ROOT = dirname(__DIR__);
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
|
||||||
|
$job = $_POST['job'] ?? '';
|
||||||
|
$max_frames = isset($_POST['max_frames']) ? intval($_POST['max_frames']) : 16;
|
||||||
|
|
||||||
|
if (!preg_match('/^[0-9]{8}[-_][A-Za-z0-9._-]+$/', $job)) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['status'=>'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;
|
||||||
101
lib/generate_data_sync.php
Normal file
101
lib/generate_data_sync.php
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
<?php
|
||||||
|
// lib/generate_data_sync.php
|
||||||
|
// Run generate_data.sh synchronously (no-append) and return the generated JSON/description.
|
||||||
|
require __DIR__ . '/../auth.php';
|
||||||
|
mvlog_require_login();
|
||||||
|
|
||||||
|
$MVLOG_ROOT = dirname(__DIR__);
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
|
||||||
|
$job = $_POST['job'] ?? '';
|
||||||
|
$max_frames = isset($_POST['max_frames']) ? intval($_POST['max_frames']) : 16;
|
||||||
|
|
||||||
|
if (!preg_match('/^[0-9]{8}[-_][A-Za-z0-9._-]+$/', $job)) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['status'=>'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;
|
||||||
141
new.php
141
new.php
|
|
@ -417,6 +417,16 @@ try {
|
||||||
$canShow = $output !== '' && is_file($config['videos_dir'].'/'.$output) && !input_dir_preview($dir);
|
$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.');
|
if ($visibleNow && !$canShow) throw new RuntimeException('Only finished full-quality videos can be shown.');
|
||||||
set_input_dir_visible($dir, $visibleNow && $canShow);
|
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);
|
$message = ($visibleNow ? 'Shown: in-dir/' : 'Hidden: in-dir/') . basename($dir);
|
||||||
$currentVisible = input_dir_visible($dir) && $canShow;
|
$currentVisible = input_dir_visible($dir) && $canShow;
|
||||||
if (!empty($_POST['ajax'])) {
|
if (!empty($_POST['ajax'])) {
|
||||||
|
|
@ -746,4 +756,135 @@ function applySwitchPayload(payload){
|
||||||
<footer class="site-footer admin-footer"><div class="admin-left"><nav class="tabs"><a class="<?= $tab==='new'?'active':'' ?>" href="new.php?tab=new">New</a><a class="<?= $tab==='edit'?'active':'' ?>" href="new.php?tab=edit">Edit</a><a class="<?= $tab==='videos'?'active':'' ?>" href="new.php?tab=videos">Videos</a></nav></div><div id="job-status" class="job-status"<?= empty($runningJobs) ? ' hidden' : '' ?>><?php foreach($runningJobs as $job): ?><span class="job-dot"></span><span><?=h(str_replace('_', ' ', $job['name']))?></span><?php if(!empty($job['started_at'])): ?><span class="job-age">(<?=h(format_job_age($job['started_at']))?>)</span><?php endif; ?><?php endforeach; ?></div><div class="admin-right"><a class="logout-link" href="logout.php">Log off</a></div></footer>
|
<footer class="site-footer admin-footer"><div class="admin-left"><nav class="tabs"><a class="<?= $tab==='new'?'active':'' ?>" href="new.php?tab=new">New</a><a class="<?= $tab==='edit'?'active':'' ?>" href="new.php?tab=edit">Edit</a><a class="<?= $tab==='videos'?'active':'' ?>" href="new.php?tab=videos">Videos</a></nav></div><div id="job-status" class="job-status"<?= empty($runningJobs) ? ' hidden' : '' ?>><?php foreach($runningJobs as $job): ?><span class="job-dot"></span><span><?=h(str_replace('_', ' ', $job['name']))?></span><?php if(!empty($job['started_at'])): ?><span class="job-age">(<?=h(format_job_age($job['started_at']))?>)</span><?php endif; ?><?php endforeach; ?></div><div class="admin-right"><a class="logout-link" href="logout.php">Log off</a></div></footer>
|
||||||
<script>document.querySelectorAll('textarea[data-counter]').forEach(function(t){var c=document.getElementById(t.dataset.counter);function u(){c.textContent=t.value.length+' / '+t.maxLength+' characters';}t.addEventListener('input',u);u();});</script>
|
<script>document.querySelectorAll('textarea[data-counter]').forEach(function(t){var c=document.getElementById(t.dataset.counter);function u(){c.textContent=t.value.length+' / '+t.maxLength+' characters';}t.addEventListener('input',u);u();});</script>
|
||||||
<script>(function(){const box=document.getElementById('job-status');if(!box)return;function fmt(job){const name=(job.name||'job').replace(/_/g,' ');const started=job.started_at ? new Date(job.started_at) : null;const ageText=started && !Number.isNaN(started.getTime()) ? ' <span class="job-age">(' + formatAge(started) + ')</span>' : '';return '<span class="job-dot"></span><span>'+name+'</span>'+ageText;}function formatAge(started){const seconds=Math.max(0,Math.floor((Date.now()-started.getTime())/1000));const h=Math.floor(seconds/3600);const m=Math.floor((seconds%3600)/60);const s=seconds%60;if(h>0) return h+'h '+m+'m';if(m>0) return m+'m '+s+'s';return s+'s';}function syncEditButtons(runningJobs){document.querySelectorAll('.admin-video-row[data-job]').forEach(function(row){const job=row.dataset.job || '';const edit=row.querySelector('[data-edit-action="1"]');if(!edit) return;const running=runningJobs.has(job);if(edit.tagName==='A'){if(running){if(!edit.dataset.editHref) edit.dataset.editHref = edit.getAttribute('href') || '';edit.removeAttribute('href');edit.classList.add('disabled');edit.setAttribute('aria-disabled','true');edit.title='Rendering now';}else{const href=edit.dataset.editHref || ('?edit=' + encodeURIComponent(job));edit.setAttribute('href', href);edit.classList.remove('disabled');edit.removeAttribute('aria-disabled');edit.title='Edit';}}else{edit.classList.toggle('disabled', running);edit.title=running ? 'Rendering now' : '';}});}async function updateJobs(){try{const r=await fetch('job_status.php',{cache:'no-store'});if(!r.ok) throw new Error('status failed');const data=await r.json();const jobs=(data.jobs||[]).filter(function(job){ return job && job.name; });const runningJobs=new Set(jobs.map(function(job){ return String(job.name); }));syncEditButtons(runningJobs);if(!jobs.length){box.hidden=true;box.innerHTML='';return;}box.hidden=false;box.innerHTML=jobs.map(fmt).join('');}catch(e){if(box.innerHTML.trim()!=='') box.hidden=false;}}updateJobs();setInterval(updateJobs,30000);})();</script>
|
<script>(function(){const box=document.getElementById('job-status');if(!box)return;function fmt(job){const name=(job.name||'job').replace(/_/g,' ');const started=job.started_at ? new Date(job.started_at) : null;const ageText=started && !Number.isNaN(started.getTime()) ? ' <span class="job-age">(' + formatAge(started) + ')</span>' : '';return '<span class="job-dot"></span><span>'+name+'</span>'+ageText;}function formatAge(started){const seconds=Math.max(0,Math.floor((Date.now()-started.getTime())/1000));const h=Math.floor(seconds/3600);const m=Math.floor((seconds%3600)/60);const s=seconds%60;if(h>0) return h+'h '+m+'m';if(m>0) return m+'m '+s+'s';return s+'s';}function syncEditButtons(runningJobs){document.querySelectorAll('.admin-video-row[data-job]').forEach(function(row){const job=row.dataset.job || '';const edit=row.querySelector('[data-edit-action="1"]');if(!edit) return;const running=runningJobs.has(job);if(edit.tagName==='A'){if(running){if(!edit.dataset.editHref) edit.dataset.editHref = edit.getAttribute('href') || '';edit.removeAttribute('href');edit.classList.add('disabled');edit.setAttribute('aria-disabled','true');edit.title='Rendering now';}else{const href=edit.dataset.editHref || ('?edit=' + encodeURIComponent(job));edit.setAttribute('href', href);edit.classList.remove('disabled');edit.removeAttribute('aria-disabled');edit.title='Edit';}}else{edit.classList.toggle('disabled', running);edit.title=running ? 'Rendering now' : '';}});}async function updateJobs(){try{const r=await fetch('job_status.php',{cache:'no-store'});if(!r.ok) throw new Error('status failed');const data=await r.json();const jobs=(data.jobs||[]).filter(function(job){ return job && job.name; });const runningJobs=new Set(jobs.map(function(job){ return String(job.name); }));syncEditButtons(runningJobs);if(!jobs.length){box.hidden=true;box.innerHTML='';return;}box.hidden=false;box.innerHTML=jobs.map(fmt).join('');}catch(e){if(box.innerHTML.trim()!=='') box.hidden=false;}}updateJobs();setInterval(updateJobs,30000);})();</script>
|
||||||
|
<script>
|
||||||
|
(function(){
|
||||||
|
function attachDescribeButtons(){
|
||||||
|
document.querySelectorAll('.admin-video-row').forEach(function(row){
|
||||||
|
if (row.querySelector('.describe-button')) return;
|
||||||
|
var actions = row.querySelector('.admin-actions');
|
||||||
|
if (!actions) return;
|
||||||
|
var job = row.dataset.job;
|
||||||
|
if (!job) return;
|
||||||
|
var describe = document.createElement('a');
|
||||||
|
describe.className = 'button describe-button';
|
||||||
|
describe.href = '#';
|
||||||
|
describe.dataset.job = job;
|
||||||
|
describe.textContent = 'Describe';
|
||||||
|
var first = actions.querySelector('.button');
|
||||||
|
if (first) actions.insertBefore(describe, first);
|
||||||
|
else actions.appendChild(describe);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function showModal(job, description, rawJson){
|
||||||
|
var existing = document.getElementById('mvlog-gemini-modal');
|
||||||
|
if (existing) existing.remove();
|
||||||
|
var overlay = document.createElement('div');
|
||||||
|
overlay.id = 'mvlog-gemini-modal';
|
||||||
|
overlay.style.cssText = 'position:fixed;left:0;top:0;width:100%;height:100%;background:rgba(0,0,0,0.5);display:flex;align-items:center;justify-content:center;z-index:9999;';
|
||||||
|
var box = document.createElement('div');
|
||||||
|
box.style.cssText = 'background:white;color:#111;padding:20px;max-width:900px;max-height:80vh;overflow:auto;border-radius:6px;font-family:inherit;';
|
||||||
|
var h = document.createElement('h3');
|
||||||
|
h.textContent = 'Generated description';
|
||||||
|
var pre = document.createElement('div');
|
||||||
|
pre.style.cssText = 'white-space:pre-wrap;font-family:inherit;margin-top:10px;border:1px solid #eee;padding:12px;background:#f8f8f8;border-radius:4px;';
|
||||||
|
pre.textContent = description || '';
|
||||||
|
var btnBar = document.createElement('div');
|
||||||
|
btnBar.style.cssText = 'margin-top:12px;text-align:right;';
|
||||||
|
var useBtn = document.createElement('button');
|
||||||
|
useBtn.className = 'button';
|
||||||
|
useBtn.textContent = 'Use in form';
|
||||||
|
var cancelBtn = document.createElement('button');
|
||||||
|
cancelBtn.className = 'button';
|
||||||
|
cancelBtn.textContent = 'Close';
|
||||||
|
cancelBtn.style.marginLeft = '8px';
|
||||||
|
btnBar.appendChild(useBtn);
|
||||||
|
btnBar.appendChild(cancelBtn);
|
||||||
|
box.appendChild(h);
|
||||||
|
box.appendChild(pre);
|
||||||
|
box.appendChild(btnBar);
|
||||||
|
overlay.appendChild(box);
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
|
||||||
|
cancelBtn.addEventListener('click', function(){ overlay.remove(); });
|
||||||
|
useBtn.addEventListener('click', function(){
|
||||||
|
var editForm = document.getElementById('edit-form');
|
||||||
|
if (editForm){
|
||||||
|
var dirInput = editForm.querySelector('input[name="dir"]');
|
||||||
|
if (dirInput && dirInput.value === job){
|
||||||
|
var textarea = editForm.querySelector('textarea[name="description"]');
|
||||||
|
if (textarea){
|
||||||
|
textarea.value = description || '';
|
||||||
|
showToast('Inserted generated description into form. Click Save changes to apply.', true);
|
||||||
|
overlay.remove();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try { localStorage.setItem('mvlog_gemini_description_' + job, description || ''); } catch(e){}
|
||||||
|
window.location.href = 'new.php?tab=edit&edit=' + encodeURIComponent(job);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('click', function(e){
|
||||||
|
var el = (e.target && e.target.closest && e.target.closest('.describe-button')) || (e.target && e.target.classList && e.target.classList.contains && e.target.classList.contains('describe-button') ? e.target : null);
|
||||||
|
if (!el) return;
|
||||||
|
e.preventDefault();
|
||||||
|
var job = el.dataset.job;
|
||||||
|
if (!job) return;
|
||||||
|
el.classList.add('disabled');
|
||||||
|
var oldText = el.textContent;
|
||||||
|
el.textContent = 'Generating...';
|
||||||
|
fetch('lib/generate_data_sync.php', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: {'Content-Type':'application/x-www-form-urlencoded'},
|
||||||
|
body: 'job=' + encodeURIComponent(job) + '&max_frames=16'
|
||||||
|
}).then(function(r){ return r.json().catch(()=>({})); })
|
||||||
|
.then(function(data){
|
||||||
|
if (data && data.status === 'ok'){
|
||||||
|
var desc = data.description || (data.raw_json && JSON.stringify(data.raw_json)) || '';
|
||||||
|
showModal(job, desc, data.raw_json || null);
|
||||||
|
} else {
|
||||||
|
showToast('Error: ' + (data && data.message ? data.message : 'No response'), false);
|
||||||
|
}
|
||||||
|
}).catch(function(err){
|
||||||
|
showToast('Request failed: ' + err, false);
|
||||||
|
}).finally(function(){
|
||||||
|
el.classList.remove('disabled');
|
||||||
|
el.textContent = oldText;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
attachDescribeButtons();
|
||||||
|
var list = document.querySelector('.admin-list.video-list');
|
||||||
|
if (list) new MutationObserver(function(){ attachDescribeButtons(); }).observe(list, {childList:true, subtree:true});
|
||||||
|
|
||||||
|
// On edit page load: if a generated description is in localStorage, insert it into the form.
|
||||||
|
(function(){
|
||||||
|
var editForm = document.getElementById('edit-form');
|
||||||
|
if (!editForm) return;
|
||||||
|
var dirInput = editForm.querySelector('input[name="dir"]');
|
||||||
|
if (!dirInput) return;
|
||||||
|
var job = dirInput.value;
|
||||||
|
try {
|
||||||
|
var key = 'mvlog_gemini_description_' + job;
|
||||||
|
var desc = localStorage.getItem(key);
|
||||||
|
if (desc) {
|
||||||
|
var textarea = editForm.querySelector('textarea[name="description"]');
|
||||||
|
if (textarea) {
|
||||||
|
textarea.value = desc;
|
||||||
|
showToast('Inserted generated description into form. Click Save changes to apply.', true);
|
||||||
|
}
|
||||||
|
localStorage.removeItem(key);
|
||||||
|
}
|
||||||
|
} catch(e){}
|
||||||
|
})();
|
||||||
|
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
</body></html>
|
</body></html>
|
||||||
Loading…
Add table
Add a link
Reference in a new issue