Add GPS map generation and display on index and new pages

This commit is contained in:
hbrain 2026-05-31 01:07:22 +02:00
parent 0fd6d43adb
commit d640491e16
4 changed files with 626 additions and 11 deletions

585
bin/gpsmap.py Normal file
View file

@ -0,0 +1,585 @@
#!/usr/bin/env python3
import io
import json
import math
import subprocess
import sys
import time
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
from urllib.parse import urlencode
from urllib.request import Request, urlopen
from PIL import Image, ImageDraw, ImageFont
VERSION = "2026-05-31-infographic-v4-date-stats-centered-counts"
MAP_WIDTH = 906
MAP_HEIGHT = 510
TILE_SIZE = 256
MIN_ZOOM = 3
MAX_ZOOM = 13
CLUSTER_RADIUS_KM = 2.0
USER_AGENT = "gpsmap-infographic-script/1.0"
PHOTO_EXTENSIONS = {
".jpg", ".jpeg", ".png", ".heic", ".heif", ".webp", ".tif", ".tiff"
}
VIDEO_EXTENSIONS = {
".mp4", ".mov", ".m4v", ".avi", ".mkv", ".3gp"
}
FONT_BOLD = "/usr/share/fonts/truetype/noto/NotoSans-Bold.ttf"
FONT_REGULAR = "/usr/share/fonts/truetype/noto/NotoSans-Regular.ttf"
@dataclass
class MediaItem:
path: Path
lat: float
lon: float
kind: str
taken_at: datetime | None = None
@dataclass
class Cluster:
items: list[MediaItem] = field(default_factory=list)
@property
def lat(self):
return sum(i.lat for i in self.items) / len(self.items)
@property
def lon(self):
return sum(i.lon for i in self.items) / len(self.items)
@property
def photos(self):
return sum(1 for i in self.items if i.kind == "photo")
@property
def videos(self):
return sum(1 for i in self.items if i.kind == "video")
@property
def first_sort_key(self):
return min(sort_key(i) for i in self.items)
def load_font(path, size):
try:
return ImageFont.truetype(path, size)
except Exception:
return ImageFont.load_default()
def parse_exif_date(value):
if not value:
return None
# Common exiftool format: 2025:07:19 16:47:12
for fmt in (
"%Y:%m:%d %H:%M:%S",
"%Y:%m:%d %H:%M:%S%z",
"%Y-%m-%d %H:%M:%S",
"%Y-%m-%dT%H:%M:%S",
"%Y-%m-%dT%H:%M:%S%z",
):
try:
return datetime.strptime(str(value).split(".")[0], fmt)
except Exception:
pass
return None
def media_kind(path):
ext = path.suffix.lower()
if ext in PHOTO_EXTENSIONS:
return "photo"
if ext in VIDEO_EXTENSIONS:
return "video"
return "other"
def get_metadata(file_path):
try:
result = subprocess.run(
[
"exiftool",
"-json",
"-n",
"-GPSLatitude",
"-GPSLongitude",
"-DateTimeOriginal",
"-CreateDate",
"-MediaCreateDate",
"-TrackCreateDate",
str(file_path),
],
capture_output=True,
text=True,
check=True,
)
data = json.loads(result.stdout)[0]
lat = data.get("GPSLatitude")
lon = data.get("GPSLongitude")
if lat is None or lon is None:
return None
taken_at = None
for key in ("DateTimeOriginal", "CreateDate", "MediaCreateDate", "TrackCreateDate"):
taken_at = parse_exif_date(data.get(key))
if taken_at:
break
return float(lat), float(lon), taken_at
except Exception:
return None
def sort_key(item):
# EXIF date first; filename fallback keeps your 001_, 003_, 005_ order stable.
return (item.taken_at or datetime.max, item.path.name)
def haversine_km(lat1, lon1, lat2, lon2):
r = 6371.0
p1 = math.radians(lat1)
p2 = math.radians(lat2)
dp = math.radians(lat2 - lat1)
dl = math.radians(lon2 - lon1)
a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2
return 2 * r * math.atan2(math.sqrt(a), math.sqrt(1 - a))
def collect_media(media_dir):
items = []
for file_path in media_dir.rglob("*"):
if not file_path.is_file():
continue
kind = media_kind(file_path)
if kind == "other":
continue
meta = get_metadata(file_path)
if not meta:
continue
lat, lon, taken_at = meta
item = MediaItem(file_path, lat, lon, kind, taken_at)
items.append(item)
print(f"GPS: {file_path.name} -> ({lat}, {lon}) [{kind}]")
return sorted(items, key=sort_key)
def make_clusters(items):
clusters = []
for item in items:
best_cluster = None
best_distance = None
for cluster in clusters:
dist = haversine_km(item.lat, item.lon, cluster.lat, cluster.lon)
if dist <= CLUSTER_RADIUS_KM and (best_distance is None or dist < best_distance):
best_cluster = cluster
best_distance = dist
if best_cluster:
best_cluster.items.append(item)
else:
clusters.append(Cluster([item]))
return sorted(clusters, key=lambda c: c.first_sort_key)
def country_language(country_code):
return {
"dk": "da", "de": "de", "at": "de", "ch": "de", "hr": "hr",
"se": "sv", "no": "no", "fi": "fi", "pl": "pl", "cz": "cs",
"sk": "sk", "fr": "fr", "it": "it", "es": "es", "pt": "pt",
"nl": "nl", "be": "nl", "ba": "bs", "rs": "sr", "si": "sl",
"hu": "hu", "gb": "en", "uk": "en", "ie": "en",
}.get(country_code.lower(), "en")
def nominatim_reverse(lat, lon, language):
params = urlencode({
"format": "jsonv2",
"lat": lat,
"lon": lon,
"addressdetails": 1,
"accept-language": language,
})
url = f"https://nominatim.openstreetmap.org/reverse?{params}"
request = Request(url, headers={"User-Agent": USER_AGENT})
with urlopen(request, timeout=20) as response:
return json.loads(response.read().decode("utf-8"))
def reverse_geocode_parts(lat, lon):
try:
first = nominatim_reverse(lat, lon, "en")
country_code = first.get("address", {}).get("country_code", "en")
language = country_language(country_code)
time.sleep(1)
data = nominatim_reverse(lat, lon, language)
address = data.get("address", {})
city = (
address.get("city")
or address.get("town")
or address.get("village")
or address.get("municipality")
or ""
)
region = (
address.get("state_district")
or address.get("region")
or address.get("state")
or address.get("county")
or ""
)
country = address.get("country", "")
return {"city": city, "region": region, "country": country.upper() if country else ""}
except Exception:
return {"city": "", "region": "", "country": ""}
def make_map_label(items):
lats = [i.lat for i in items]
lons = [i.lon for i in items]
sample_points = [
(min(lats), min(lons)),
(max(lats), max(lons)),
(sum(lats) / len(lats), sum(lons) / len(lons)),
]
cities, regions, countries = set(), set(), set()
for lat, lon in sample_points:
info = reverse_geocode_parts(lat, lon)
if info["city"]:
cities.add(info["city"])
if info["region"]:
regions.add(info["region"])
if info["country"]:
countries.add(info["country"])
if len(countries) > 1:
return "".join(sorted(countries))
country = next(iter(countries), "")
if len(regions) > 1:
return country
region = next(iter(regions), "")
if len(cities) > 1:
return f"{region} {country}".strip()
city = next(iter(cities), "")
if city and region and country:
return f"{city}, {region} {country}"
if region and country:
return f"{region} {country}"
return country
def format_date_range(items):
dates = [i.taken_at for i in items if i.taken_at]
if not dates:
return ""
start = min(dates)
end = max(dates)
if start.date() == end.date():
return start.strftime("%-d %b %Y")
if start.year == end.year and start.month == end.month:
return f"{start.strftime('%-d')} {end.strftime('%-d %b %Y')}"
if start.year == end.year:
return f"{start.strftime('%-d %b')} {end.strftime('%-d %b %Y')}"
return f"{start.strftime('%-d %b %Y')} {end.strftime('%-d %b %Y')}"
def latlon_to_pixel(lat, lon, zoom):
sin_lat = math.sin(math.radians(lat))
scale = TILE_SIZE * 2**zoom
x = (lon + 180.0) / 360.0 * scale
y = (0.5 - math.log((1 + sin_lat) / (1 - sin_lat)) / (4 * math.pi)) * scale
return x, y
def choose_zoom(points, width, height):
# Leave room for bottom title/stat text.
pad_x = 80
pad_top = 55
pad_bottom = 125
usable_width = width - 2 * pad_x
usable_height = height - pad_top - pad_bottom
if len(points) <= 1:
return 11
for zoom in range(MAX_ZOOM, MIN_ZOOM - 1, -1):
pixels = [latlon_to_pixel(lat, lon, zoom) for lat, lon in points]
xs = [p[0] for p in pixels]
ys = [p[1] for p in pixels]
if max(xs) - min(xs) <= usable_width and max(ys) - min(ys) <= usable_height:
return zoom
return MIN_ZOOM
def map_view(points, zoom):
pixels = [latlon_to_pixel(lat, lon, zoom) for lat, lon in points]
xs = [p[0] for p in pixels]
ys = [p[1] for p in pixels]
# Center route slightly above image center so title/stat text has room at bottom.
center_x = (min(xs) + max(xs)) / 2
center_y = (min(ys) + max(ys)) / 2 + 25
top_left_x = center_x - MAP_WIDTH / 2
top_left_y = center_y - MAP_HEIGHT / 2
return top_left_x, top_left_y
def download_tile(x, y, z):
url = f"https://tile.openstreetmap.org/{z}/{x}/{y}.png"
request = Request(url, headers={"User-Agent": USER_AGENT})
with urlopen(request, timeout=20) as response:
return Image.open(io.BytesIO(response.read())).convert("RGB")
def render_tiles(top_left_x, top_left_y, zoom):
first_tile_x = int(top_left_x // TILE_SIZE)
first_tile_y = int(top_left_y // TILE_SIZE)
last_tile_x = int((top_left_x + MAP_WIDTH) // TILE_SIZE)
last_tile_y = int((top_left_y + MAP_HEIGHT) // TILE_SIZE)
image = Image.new("RGB", (MAP_WIDTH, MAP_HEIGHT), "white")
max_tile = 2**zoom
for tile_x in range(first_tile_x, last_tile_x + 1):
for tile_y in range(first_tile_y, last_tile_y + 1):
wrapped_x = tile_x % max_tile
if tile_y < 0 or tile_y >= max_tile:
continue
try:
tile = download_tile(wrapped_x, tile_y, zoom)
time.sleep(0.1)
except Exception as e:
print(f"Failed tile {zoom}/{wrapped_x}/{tile_y}: {e}")
continue
paste_x = int(tile_x * TILE_SIZE - top_left_x)
paste_y = int(tile_y * TILE_SIZE - top_left_y)
image.paste(tile, (paste_x, paste_y))
return image
def screen_xy(lat, lon, zoom, top_left_x, top_left_y):
px, py = latlon_to_pixel(lat, lon, zoom)
return int(px - top_left_x), int(py - top_left_y)
def draw_arrow(draw, x1, y1, x2, y2, fill):
angle = math.atan2(y2 - y1, x2 - x1)
size = 13
cx = x1 + (x2 - x1) * 0.62
cy = y1 + (y2 - y1) * 0.62
p1 = (cx + math.cos(angle) * size, cy + math.sin(angle) * size)
p2 = (cx + math.cos(angle + 2.55) * size, cy + math.sin(angle + 2.55) * size)
p3 = (cx + math.cos(angle - 2.55) * size, cy + math.sin(angle - 2.55) * size)
draw.polygon([p1, p2, p3], fill=fill, outline="white")
def draw_route(draw, route_points):
if len(route_points) < 2:
return
# White underlay makes the route visible on busy map tiles.
draw.line(route_points, fill="white", width=7, joint="curve")
draw.line(route_points, fill="red", width=4, joint="curve")
for (x1, y1), (x2, y2) in zip(route_points, route_points[1:]):
if math.hypot(x2 - x1, y2 - y1) > 35:
draw_arrow(draw, x1, y1, x2, y2, "red")
def marker_text(cluster):
parts = []
if cluster.photos:
parts.append(f"{cluster.photos} photo" + ("s" if cluster.photos != 1 else ""))
if cluster.videos:
parts.append(f"{cluster.videos} video" + ("s" if cluster.videos != 1 else ""))
return parts
def draw_cluster_marker(draw, x, y, cluster):
font = load_font(FONT_BOLD, 22)
total = cluster.photos + cluster.videos
text = str(total)
bbox = draw.textbbox((0, 0), text, font=font)
tw = bbox[2] - bbox[0]
th = bbox[3] - bbox[1]
# Smaller marker, with the number truly centered in the circle.
r = max(14, int(max(tw, th) / 2) + 8)
draw.ellipse(
(x - r, y - r, x + r, y + r),
fill="white",
outline="red",
width=2,
)
text_x = x - (bbox[0] + bbox[2]) / 2
text_y = y - (bbox[1] + bbox[3]) / 2
draw.text(
(text_x, text_y),
text,
font=font,
fill="black",
)
def plural(n, singular, plural_word=None):
if n == 1:
return f"{n} {singular}"
return f"{n} {plural_word or singular + 's'}"
def route_distance_km(clusters):
total = 0.0
for a, b in zip(clusters, clusters[1:]):
total += haversine_km(a.lat, a.lon, b.lat, b.lon)
return total
def draw_centered_text(draw, y, text, font, fill="black"):
bbox = draw.textbbox((0, 0), text, font=font)
w = bbox[2] - bbox[0]
draw.text(((MAP_WIDTH - w) / 2, y), text, font=font, fill=fill)
def draw_bottom_title(draw, label, stats, date_text=""):
title_font = load_font(FONT_BOLD, 38)
stats_font = load_font(FONT_REGULAR, 30)
date_font = load_font(FONT_REGULAR, 22)
title_bbox = draw.textbbox((0, 0), label, font=title_font)
stats_bbox = draw.textbbox((0, 0), stats, font=stats_font)
date_bbox = draw.textbbox((0, 0), date_text, font=date_font) if date_text else (0, 0, 0, 0)
title_h = title_bbox[3] - title_bbox[1]
stats_h = stats_bbox[3] - stats_bbox[1]
date_h = date_bbox[3] - date_bbox[1] if date_text else 0
gap = 4
bottom_margin = 20
y_title = MAP_HEIGHT - title_h - bottom_margin
y_stats = y_title - stats_h - gap
y_date = y_stats - date_h - gap if date_text else None
if date_text:
draw_centered_text(draw, y_date, date_text, date_font, fill=(40, 40, 40))
draw_centered_text(draw, y_stats, stats, stats_font, fill=(30, 30, 30))
draw_centered_text(draw, y_title, label, title_font, fill="black")
def main():
print(f"gpsmap version: {VERSION}")
if len(sys.argv) != 3:
print(f"Usage: {sys.argv[0]} <media_directory> <output_directory>")
sys.exit(1)
media_dir = Path(sys.argv[1])
output_dir = Path(sys.argv[2])
if not media_dir.is_dir():
print(f"Media directory not found: {media_dir}")
sys.exit(1)
output_dir.mkdir(parents=True, exist_ok=True)
output_png = output_dir / f"{media_dir.resolve().name}.png"
items = collect_media(media_dir)
if not items:
print("No GPS coordinates found.")
sys.exit(2)
clusters = make_clusters(items)
points = [(c.lat, c.lon) for c in clusters]
zoom = choose_zoom(points, MAP_WIDTH, MAP_HEIGHT)
top_left_x, top_left_y = map_view(points, zoom)
label = make_map_label(items)
photo_count = sum(1 for i in items if i.kind == "photo")
video_count = sum(1 for i in items if i.kind == "video")
distance = round(route_distance_km(clusters))
stats_parts = [plural(photo_count, "photo")]
if video_count:
stats_parts.append(plural(video_count, "video"))
stats_parts.append(f"{distance} km route")
stats = "".join(stats_parts)
date_text = format_date_range(items)
print(f"Map label: {label}")
if date_text:
print(f"Date: {date_text}")
print(f"Stats: {stats}")
image = render_tiles(top_left_x, top_left_y, zoom)
draw = ImageDraw.Draw(image)
route_points = [screen_xy(c.lat, c.lon, zoom, top_left_x, top_left_y) for c in clusters]
draw_route(draw, route_points)
for cluster, (x, y) in zip(clusters, route_points):
draw_cluster_marker(draw, x, y, cluster)
draw_bottom_title(draw, label, stats, date_text)
image.save(output_png)
print()
print(f"Created: {output_png}")
print(f"Photos: {photo_count}")
print(f"Videos: {video_count}")
print(f"Locations: {len(clusters)}")
print(f"Zoom: {zoom}")
if __name__ == "__main__":
main()

View file

@ -17,13 +17,11 @@ function hidden_video_outputs($config){
function video_metadata($path){ function video_metadata($path){
$base = pathinfo($path, PATHINFO_FILENAME); $base = pathinfo($path, PATHINFO_FILENAME);
$meta = ['title' => nice_title($base), 'date' => date('Y-m-d', filemtime($path)), 'sort_date' => date('Y-m-d', filemtime($path)), 'location' => '', 'description' => '']; $meta = ['title' => nice_title($base), 'date' => date('Y-m-d', filemtime($path)), 'sort_date' => date('Y-m-d', filemtime($path)), 'location' => '', 'description' => ''];
if (preg_match('/^(\d{4})(\d{2})(\d{2})[_-](.+)$/', $base, $m)) { if (preg_match('/^(\d{4})(\d{2})(\d{2})[_-](.+)$/', $base, $m)) {
$meta['date'] = "$m[1]-$m[2]-$m[3]"; $meta['date'] = "$m[1]-$m[2]-$m[3]";
$meta['sort_date'] = "$m[1]-$m[2]-$m[3]"; $meta['sort_date'] = "$m[1]-$m[2]-$m[3]";
$meta['title'] = nice_title($m[4]); $meta['title'] = nice_title($m[4]);
} }
foreach ([dirname($path).'/'.$base.'.txt', dirname($path).'/'.$base.'.data.txt'] as $sidecar) { foreach ([dirname($path).'/'.$base.'.txt', dirname($path).'/'.$base.'.data.txt'] as $sidecar) {
if (!is_file($sidecar)) continue; if (!is_file($sidecar)) continue;
foreach (file($sidecar, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) { foreach (file($sidecar, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
@ -33,7 +31,6 @@ function video_metadata($path){
if (in_array($k, ['title','date','location','place','description'], true)) $meta[$k === 'place' ? 'location' : $k] = $v; if (in_array($k, ['title','date','location','place','description'], true)) $meta[$k === 'place' ? 'location' : $k] = $v;
} }
} }
$ffprobe = trim((string)shell_exec('command -v ffprobe 2>/dev/null')); $ffprobe = trim((string)shell_exec('command -v ffprobe 2>/dev/null'));
if ($ffprobe !== '') { if ($ffprobe !== '') {
$json = shell_exec(escapeshellarg($ffprobe).' -v quiet -print_format json -show_entries format_tags '.escapeshellarg($path).' 2>/dev/null'); $json = shell_exec(escapeshellarg($ffprobe).' -v quiet -print_format json -show_entries format_tags '.escapeshellarg($path).' 2>/dev/null');
@ -42,9 +39,7 @@ function video_metadata($path){
$lower = []; $lower = [];
foreach ($tags as $k => $v) $lower[strtolower($k)] = $v; foreach ($tags as $k => $v) $lower[strtolower($k)] = $v;
if (!empty($lower['title'])) $meta['title'] = $lower['title']; if (!empty($lower['title'])) $meta['title'] = $lower['title'];
if (!empty($lower['date'])) { if (!empty($lower['date'])) $meta['date'] = $lower['date'];
$meta['date'] = $lower['date'];
}
if (!empty($lower['creation_time'])) { if (!empty($lower['creation_time'])) {
$meta['sort_date'] = substr($lower['creation_time'], 0, 10); $meta['sort_date'] = substr($lower['creation_time'], 0, 10);
if (empty($lower['date'])) $meta['date'] = $meta['sort_date']; if (empty($lower['date'])) $meta['date'] = $meta['sort_date'];
@ -65,7 +60,6 @@ function load_video_cache($videos){
$items = $cache['items'] ?? []; $items = $cache['items'] ?? [];
$newItems = []; $newItems = [];
$changed = false; $changed = false;
foreach ($videos as $path) { foreach ($videos as $path) {
$key = basename($path); $key = basename($path);
$mtime = filemtime($path); $mtime = filemtime($path);
@ -77,7 +71,6 @@ function load_video_cache($videos){
} }
$newItems[$key] = $cached; $newItems[$key] = $cached;
} }
if (array_diff(array_keys($items), array_keys($newItems))) $changed = true; if (array_diff(array_keys($items), array_keys($newItems))) $changed = true;
if ($changed) { if ($changed) {
file_put_contents($cacheFile, json_encode(['version' => 2, 'generated_at' => date('c'), 'items' => $newItems], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT), LOCK_EX); file_put_contents($cacheFile, json_encode(['version' => 2, 'generated_at' => date('c'), 'items' => $newItems], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT), LOCK_EX);
@ -85,7 +78,7 @@ function load_video_cache($videos){
return $newItems; return $newItems;
} }
$hiddenOutputs = hidden_video_outputs($config); $hiddenOutputs = hidden_video_outputs($config);
$videos = array_values(array_filter(glob($config['videos_dir'].'/*.{mp4,webm,mov,m4v}', GLOB_BRACE) ?: [], fn($v)=>empty($hiddenOutputs[basename($v)]) && !preg_match('/_preview\.(mp4|webm|mov|m4v)$/i', basename($v)) && !preg_match('/_preview\.(mp4|webm|mov|m4v)$/i', basename($v)))); $videos = array_values(array_filter(glob($config['videos_dir'].'/*.{mp4,webm,mov,m4v}', GLOB_BRACE) ?: [], fn($v)=>empty($hiddenOutputs[basename($v)]) && !preg_match('/_preview\.(mp4|webm|mov|m4v)$/i', basename($v))));
$videoCache = load_video_cache($videos); $videoCache = load_video_cache($videos);
usort($videos, function($a, $b) use ($videoCache) { usort($videos, function($a, $b) use ($videoCache) {
$am = $videoCache[basename($a)] ?? []; $am = $videoCache[basename($a)] ?? [];
@ -100,7 +93,17 @@ $page=max(1,(int)($_GET['page']??1)); $per=$config['items_per_page']; $total=cou
<header class="site-header admin-header"><div class="brand-wrap"><a class="header-logo" href="index.php" aria-label="MVLog home"><img src="assets/img/moto_travel.png" alt=""></a><a class="brand" href="index.php"><h1>MVLog</h1><p>Bubulescu.Org</p></a></div><button id="push-toggle" class="push-toggle" type="button" hidden aria-label="Notifications" title="Notifications"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 22a2.7 2.7 0 0 0 2.6-2h-5.2A2.7 2.7 0 0 0 12 22Zm7-6V11a7 7 0 0 0-5-6.7V3a2 2 0 0 0-4 0v1.3A7 7 0 0 0 5 11v5l-2 2v1h18v-1l-2-2Z"/></svg></button><a class="rss-link" href="feed.php" aria-label="RSS feed" title="RSS feed"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6.2 17.8a2 2 0 1 1-4 0 2 2 0 0 1 4 0ZM2.2 8.7v3.1a10 10 0 0 1 10 10h3.1A13.1 13.1 0 0 0 2.2 8.7Zm0-5.5v3.1a15.5 15.5 0 0 1 15.5 15.5h3.1A18.6 18.6 0 0 0 2.2 3.2Z"/></svg></a></header><main> <header class="site-header admin-header"><div class="brand-wrap"><a class="header-logo" href="index.php" aria-label="MVLog home"><img src="assets/img/moto_travel.png" alt=""></a><a class="brand" href="index.php"><h1>MVLog</h1><p>Bubulescu.Org</p></a></div><button id="push-toggle" class="push-toggle" type="button" hidden aria-label="Notifications" title="Notifications"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 22a2.7 2.7 0 0 0 2.6-2h-5.2A2.7 2.7 0 0 0 12 22Zm7-6V11a7 7 0 0 0-5-6.7V3a2 2 0 0 0-4 0v1.3A7 7 0 0 0 5 11v5l-2 2v1h18v-1l-2-2Z"/></svg></button><a class="rss-link" href="feed.php" aria-label="RSS feed" title="RSS feed"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6.2 17.8a2 2 0 1 1-4 0 2 2 0 0 1 4 0ZM2.2 8.7v3.1a10 10 0 0 1 10 10h3.1A13.1 13.1 0 0 0 2.2 8.7Zm0-5.5v3.1a15.5 15.5 0 0 1 15.5 15.5h3.1A18.6 18.6 0 0 0 2.2 3.2Z"/></svg></a></header><main>
<section id="videos"><?php if(!$slice): ?><p>No videos yet.</p><?php endif; ?><div class="video-list"> <section id="videos"><?php if(!$slice): ?><p>No videos yet.</p><?php endif; ?><div class="video-list">
<?php foreach($slice as $v): $name=basename($v); $url=$config['public_videos'].'/'.rawurlencode($name); $m=$videoCache[$name]['metadata'] ?? video_metadata($v); ?> <?php foreach($slice as $v): $name=basename($v); $url=$config['public_videos'].'/'.rawurlencode($name); $m=$videoCache[$name]['metadata'] ?? video_metadata($v); ?>
<article class="video-row"><time class="created-at" datetime="<?=h(date('c', filemtime($v)))?>"><?=h(date('d.m.Y H:i', filemtime($v)))?></time><video controls controlsList="nodownload" oncontextmenu="return false" preload="metadata" src="<?=h($url)?>"></video><div class="video-info"><h2><?=h($m['title'])?></h2><p class="meta"><span><?=h($m['date'])?></span><?php if($m['location']): ?><span><?=h($m['location'])?></span><?php endif; ?></p><?php if($m['description']): ?><p class="description"><?=nl2br(h($m['description']), false)?></p><?php endif; ?></div></article> <article class="video-row"><time class="created-at" datetime="<?=h(date('c', filemtime($v)))?>"><?=h(date('d.m.Y H:i', filemtime($v)))?></time>
<div class="video-wrapper">
<video controls controlsList="nodownload" oncontextmenu="return false" preload="metadata" src="<?=h($url)?>"></video>
<?php
$map=pathinfo($name, PATHINFO_FILENAME).".png";
if(is_file($config["videos_dir"]."/".$map)):
?>
<img class="map-image" src="<?=h($config["public_videos"]."/".rawurlencode($map))?>" alt="Map">
<?php endif; ?>
</div>
<div class="video-info"><h2><?=h($m['title'])?></h2><p class="meta"><span><?=h($m['date'])?></span><?php if($m['location']): ?><span><?=h($m['location'])?></span><?php endif; ?></p><?php if($m['description']): ?><p class="description"><?=nl2br(h($m['description']), false)?></p><?php endif; ?></div></article>
<?php endforeach; ?></div><div class="pages"><?php for($i=1;$i<=$pages;$i++): ?><a class="<?=$i===$page?'active':''?>" href="?page=<?=$i?>"><?=$i?></a><?php endfor; ?></div></section> <?php endforeach; ?></div><div class="pages"><?php for($i=1;$i<=$pages;$i++): ?><a class="<?=$i===$page?'active':''?>" href="?page=<?=$i?>"><?=$i?></a><?php endfor; ?></div></section>
</main><footer>Bubulescu.Org</footer><script> </main><footer>Bubulescu.Org</footer><script>
(function(){ (function(){

13
new.php
View file

@ -6,6 +6,15 @@ foreach (["videos_dir", "thumbs_dir", "uploads_dir"] as $d) if (!is_dir($config[
if (!is_dir(__DIR__ . "/cache")) mkdir(__DIR__ . "/cache", 0775, true); if (!is_dir(__DIR__ . "/cache")) mkdir(__DIR__ . "/cache", 0775, true);
function h($s){ return htmlspecialchars((string)$s, ENT_QUOTES, "UTF-8"); } function h($s){ return htmlspecialchars((string)$s, ENT_QUOTES, "UTF-8"); }
function ajax_json($data){ header('Content-Type: application/json; charset=UTF-8'); echo json_encode($data, JSON_UNESCAPED_UNICODE); exit; } function ajax_json($data){ header('Content-Type: application/json; charset=UTF-8'); echo json_encode($data, JSON_UNESCAPED_UNICODE); exit; }
function run_gpsmap($dir) {
$stateFile = $dir . '/.movmaker-state.json';
$state = is_file($stateFile) ? json_decode((string)file_get_contents($stateFile), true) : [];
$output = basename((string)($state['output'] ?? ''));
$map_filename = ($output !== '' ? pathinfo($output, PATHINFO_FILENAME) : basename($dir)) . '.png';
$out_path = '/var/www/html/mvlog/out-dir/' . $map_filename;
$cmd = "python3 /var/www/html/mvlog/bin/gpsmap.py " . escapeshellarg($dir) . " " . escapeshellarg($out_path) . " > /dev/null 2>&1 &";
shell_exec($cmd);
}
function slugify($s){ $s = strtolower(trim($s)); $s = preg_replace('/[^a-z0-9]+/', '-', $s); return trim($s, '-') ?: 'movie'; } function slugify($s){ $s = strtolower(trim($s)); $s = preg_replace('/[^a-z0-9]+/', '-', $s); return trim($s, '-') ?: 'movie'; }
function rrmdir($dir){ if(!is_dir($dir)) return; foreach(scandir($dir) as $f){ if($f==='.'||$f==='..') continue; $p="$dir/$f"; is_dir($p)?rrmdir($p):unlink($p);} rmdir($dir); } function rrmdir($dir){ if(!is_dir($dir)) return; foreach(scandir($dir) as $f){ if($f==='.'||$f==='..') continue; $p="$dir/$f"; is_dir($p)?rrmdir($p):unlink($p);} rmdir($dir); }
function input_dirs($base){ return glob($base.'/*', GLOB_ONLYDIR) ?: []; } function input_dirs($base){ return glob($base.'/*', GLOB_ONLYDIR) ?: []; }
@ -344,6 +353,7 @@ try {
if (!rename($tmpDir, $dir)) { rrmdir($tmpDir); throw new RuntimeException('Cannot create input directory.'); } if (!rename($tmpDir, $dir)) { rrmdir($tmpDir); throw new RuntimeException('Cannot create input directory.'); }
chmod($dir, 02775); chmod($dir, 02775);
write_data($dir, $_POST); write_data($dir, $_POST);
run_gpsmap($dir);
set_input_dir_enabled($dir, false); set_input_dir_enabled($dir, false);
set_input_dir_preview($dir, false); set_input_dir_preview($dir, false);
set_input_dir_visible($dir, false); set_input_dir_visible($dir, false);
@ -436,6 +446,7 @@ try {
write_data($dir, $_POST); write_data($dir, $_POST);
save_uploads('media', $dir, $allowedMedia); save_uploads('media', $dir, $allowedMedia);
save_uploads('audio', $dir, $allowedAudio); save_uploads('audio', $dir, $allowedAudio);
run_gpsmap($dir);
if (!empty($_POST['ajax'])) ajax_json(['ok'=>true, 'message'=>'Updated input directory: in-dir/' . basename($dir), 'dir'=>basename($dir)]); if (!empty($_POST['ajax'])) ajax_json(['ok'=>true, 'message'=>'Updated input directory: in-dir/' . basename($dir), 'dir'=>basename($dir)]);
header('Location: new.php?tab=edit'); header('Location: new.php?tab=edit');
exit; exit;
@ -509,7 +520,7 @@ $runningJobs = active_worker_jobs($config);
<?php if($err): ?><div class="err"><?=h($err)?></div><?php endif; ?> <?php if($err): ?><div class="err"><?=h($err)?></div><?php endif; ?>
<?php if($tab==='videos'): ?><section><h2>Videos without input dir</h2><?php if(!$orphanVideos): ?><p>No orphan videos.</p><?php endif; ?><div class="admin-list"><?php foreach($orphanVideosPage as $v): $vn=basename($v); ?><form method="post" class="admin-item" onsubmit="return confirm('Delete this generated video?') js-ajax-form"><div><strong><?=h($vn)?></strong><br><span><?=h(round(filesize($v)/1048576,1))?> MB</span></div><input type="hidden" name="action" value="delete_video"><input type="hidden" name="video" value="<?=h($vn)?>"><button type="submit">Delete video</button></form><?php endforeach; ?></div><?=page_links('videos',$videoPage,$videoPages)?></section><?php endif; ?> <?php if($tab==='videos'): ?><section><h2>Videos without input dir</h2><?php if(!$orphanVideos): ?><p>No orphan videos.</p><?php endif; ?><div class="admin-list"><?php foreach($orphanVideosPage as $v): $vn=basename($v); ?><form method="post" class="admin-item" onsubmit="return confirm('Delete this generated video?') js-ajax-form"><div><strong><?=h($vn)?></strong><br><span><?=h(round(filesize($v)/1048576,1))?> MB</span></div><input type="hidden" name="action" value="delete_video"><input type="hidden" name="video" value="<?=h($vn)?>"><button type="submit">Delete video</button></form><?php endforeach; ?></div><?=page_links('videos',$videoPage,$videoPages)?></section><?php endif; ?>
<?php if($tab==='edit' && !$editDir): ?><section><h2>Existing input dirs</h2><?php if(!$dirs): ?><p>No input directories yet.</p><?php endif; ?><div class="admin-list video-list"><?php foreach($dirsPage as $d): $runStatus=input_dir_status($d); $running=input_dir_running($d); $enabled=input_dir_enabled($d); $preview=input_dir_preview($d); $state=input_dir_state($d); $fullOutput=basename((string)($state['output'] ?? '')); $previewOutput=basename((string)($state['preview_output'] ?? '')); $hasFullVideo=$fullOutput !== '' && is_file($config['videos_dir'].'/'.$fullOutput); $hasPreviewVideo=$previewOutput !== '' && is_file($config['videos_dir'].'/'.$previewOutput); $displayOutput=($preview && $hasPreviewVideo) ? $previewOutput : ($hasFullVideo ? $fullOutput : ($hasPreviewVideo ? $previewOutput : '')); $hasVideo=$displayOutput !== ''; $displayVideoPath=$hasVideo ? $config['videos_dir'].'/'.$displayOutput : ''; $previewVideo=$hasVideo && $displayOutput === $previewOutput; $canShow=$hasFullVideo && !$preview; $visible=$canShow && input_dir_visible($d); $info=$dirInfo[basename($d)] ?? input_dir_info($d); $data=read_data($d); $meta=cached_video_metadata($displayOutput, $data); $currentSignature=$info['signature'] ?? ''; $fullFingerprint=is_array($state)?(string)($state['fingerprint'] ?? ''):''; $previewFingerprint=is_array($state)?(string)($state['preview_fingerprint'] ?? ''):''; $editedSinceRender=$hasFullVideo && $currentSignature !== '' && $fullFingerprint !== '' && $currentSignature !== $fullFingerprint; $editedSincePreview=!$hasFullVideo && $previewVideo && $currentSignature !== '' && $previewFingerprint !== '' && $currentSignature !== $previewFingerprint; $staleLabel=$editedSinceRender ? 'Edited since render' : ($editedSincePreview ? 'Edited since preview' : ''); ?><article class="video-row admin-video-row<?= $visible ? ' shown' : '' ?>" data-job="<?=h(basename($d))?>"><div><?php if($hasVideo): ?><video controls preload="metadata" src="<?=h($config['public_videos'].'/'.rawurlencode($displayOutput))?>"></video><?php else: ?><div class="video-placeholder"><img src="assets/img/moto_travel.png" alt=""><span>No video yet</span></div><?php endif; ?></div><div class="video-info"><div class="admin-row-top"><div class="admin-switches"> <?php if($tab==='edit' && !$editDir): ?><section><h2>Existing input dirs</h2><?php if(!$dirs): ?><p>No input directories yet.</p><?php endif; ?><div class="admin-list video-list"><?php foreach($dirsPage as $d): $runStatus=input_dir_status($d); $running=input_dir_running($d); $enabled=input_dir_enabled($d); $preview=input_dir_preview($d); $state=input_dir_state($d); $fullOutput=basename((string)($state['output'] ?? '')); $previewOutput=basename((string)($state['preview_output'] ?? '')); $hasFullVideo=$fullOutput !== '' && is_file($config['videos_dir'].'/'.$fullOutput); $hasPreviewVideo=$previewOutput !== '' && is_file($config['videos_dir'].'/'.$previewOutput); $displayOutput=($preview && $hasPreviewVideo) ? $previewOutput : ($hasFullVideo ? $fullOutput : ($hasPreviewVideo ? $previewOutput : '')); $hasVideo=$displayOutput !== ''; $displayVideoPath=$hasVideo ? $config['videos_dir'].'/'.$displayOutput : ''; $previewVideo=$hasVideo && $displayOutput === $previewOutput; $canShow=$hasFullVideo && !$preview; $visible=$canShow && input_dir_visible($d); $info=$dirInfo[basename($d)] ?? input_dir_info($d); $data=read_data($d); $meta=cached_video_metadata($displayOutput, $data); $currentSignature=$info['signature'] ?? ''; $fullFingerprint=is_array($state)?(string)($state['fingerprint'] ?? ''):''; $previewFingerprint=is_array($state)?(string)($state['preview_fingerprint'] ?? ''):''; $editedSinceRender=$hasFullVideo && $currentSignature !== '' && $fullFingerprint !== '' && $currentSignature !== $fullFingerprint; $editedSincePreview=!$hasFullVideo && $previewVideo && $currentSignature !== '' && $previewFingerprint !== '' && $currentSignature !== $previewFingerprint; $staleLabel=$editedSinceRender ? 'Edited since render' : ($editedSincePreview ? 'Edited since preview' : ''); ?><article class="video-row admin-video-row<?= $visible ? ' shown' : '' ?>" data-job="<?=h(basename($d))?>"><div><?php if($hasVideo): ?><div class="video-wrapper"><video controls preload="metadata" src="<?=h($config['public_videos'].'/'.rawurlencode($displayOutput))?>"></video> <?php $map_n = ($displayOutput != "" ? pathinfo($displayOutput, PATHINFO_FILENAME) : basename($d)) . ".png"; if(is_file($config["videos_dir"]."/".$map_n)): ?><img class="map-image" src="<?=h($config["public_videos"]."/".rawurlencode($map_n))?>" alt="Map"><?php endif; ?></div><?php else: ?><div class="video-placeholder"><img src="assets/img/moto_travel.png" alt=""><span>No video yet</span></div><?php endif; ?></div><div class="video-info"><div class="admin-row-top"><div class="admin-switches">
<form method="post" class="inline-form switch-form" data-switch="preview"> <form method="post" class="inline-form switch-form" data-switch="preview">
<input type="hidden" name="action" value="set_preview"> <input type="hidden" name="action" value="set_preview">
<input type="hidden" name="dir" value="<?=h(basename($d))?>"> <input type="hidden" name="dir" value="<?=h(basename($d))?>">

View file

@ -45,3 +45,19 @@
} }
.describe-button{display:inline-block;margin-right:.6rem} .describe-button{display:inline-block;margin-right:.6rem}
.map-image{
width: 100%;
aspect-ratio: 16/9;
background: #111315;
border-radius: .5rem;
margin-top: 1rem;
}
.map-image {
width: 100%;
aspect-ratio: 16 / 9;
object-fit: contain;
background: #111315;
border-radius: .5rem;
margin-top: 1rem;
}