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);
|
||||
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){
|
|||
<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>(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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue