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){
|
||||
$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' => ''];
|
||||
|
||||
if (preg_match('/^(\d{4})(\d{2})(\d{2})[_-](.+)$/', $base, $m)) {
|
||||
$meta['date'] = "$m[1]-$m[2]-$m[3]";
|
||||
$meta['sort_date'] = "$m[1]-$m[2]-$m[3]";
|
||||
$meta['title'] = nice_title($m[4]);
|
||||
}
|
||||
|
||||
foreach ([dirname($path).'/'.$base.'.txt', dirname($path).'/'.$base.'.data.txt'] as $sidecar) {
|
||||
if (!is_file($sidecar)) continue;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
$ffprobe = trim((string)shell_exec('command -v ffprobe 2>/dev/null'));
|
||||
if ($ffprobe !== '') {
|
||||
$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 = [];
|
||||
foreach ($tags as $k => $v) $lower[strtolower($k)] = $v;
|
||||
if (!empty($lower['title'])) $meta['title'] = $lower['title'];
|
||||
if (!empty($lower['date'])) {
|
||||
$meta['date'] = $lower['date'];
|
||||
}
|
||||
if (!empty($lower['date'])) $meta['date'] = $lower['date'];
|
||||
if (!empty($lower['creation_time'])) {
|
||||
$meta['sort_date'] = substr($lower['creation_time'], 0, 10);
|
||||
if (empty($lower['date'])) $meta['date'] = $meta['sort_date'];
|
||||
|
|
@ -65,7 +60,6 @@ function load_video_cache($videos){
|
|||
$items = $cache['items'] ?? [];
|
||||
$newItems = [];
|
||||
$changed = false;
|
||||
|
||||
foreach ($videos as $path) {
|
||||
$key = basename($path);
|
||||
$mtime = filemtime($path);
|
||||
|
|
@ -77,7 +71,6 @@ function load_video_cache($videos){
|
|||
}
|
||||
$newItems[$key] = $cached;
|
||||
}
|
||||
|
||||
if (array_diff(array_keys($items), array_keys($newItems))) $changed = true;
|
||||
if ($changed) {
|
||||
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;
|
||||
}
|
||||
$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);
|
||||
usort($videos, function($a, $b) use ($videoCache) {
|
||||
$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>
|
||||
<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); ?>
|
||||
<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>
|
||||
</main><footer>Bubulescu.Org</footer><script>
|
||||
(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);
|
||||
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 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 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) ?: []; }
|
||||
|
|
@ -344,6 +353,7 @@ try {
|
|||
if (!rename($tmpDir, $dir)) { rrmdir($tmpDir); throw new RuntimeException('Cannot create input directory.'); }
|
||||
chmod($dir, 02775);
|
||||
write_data($dir, $_POST);
|
||||
run_gpsmap($dir);
|
||||
set_input_dir_enabled($dir, false);
|
||||
set_input_dir_preview($dir, false);
|
||||
set_input_dir_visible($dir, false);
|
||||
|
|
@ -436,6 +446,7 @@ try {
|
|||
write_data($dir, $_POST);
|
||||
save_uploads('media', $dir, $allowedMedia);
|
||||
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)]);
|
||||
header('Location: new.php?tab=edit');
|
||||
exit;
|
||||
|
|
@ -509,7 +520,7 @@ $runningJobs = active_worker_jobs($config);
|
|||
<?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==='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">
|
||||
<input type="hidden" name="action" value="set_preview">
|
||||
<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}
|
||||
.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