Add GPS map generation and display on index and new pages
This commit is contained in:
parent
0fd6d43adb
commit
d640491e16
4 changed files with 626 additions and 11 deletions
585
bin/gpsmap.py
Normal file
585
bin/gpsmap.py
Normal 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()
|
||||||
23
index.php
23
index.php
|
|
@ -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
13
new.php
|
|
@ -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))?>">
|
||||||
|
|
|
||||||
16
style.css
16
style.css
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue