- unconditional map regeneration on Save changes

- logging to /var/log/mvlog_map.log
This commit is contained in:
hbrain 2026-06-02 13:05:29 +02:00
parent 2745e1cc35
commit 129711d798
3 changed files with 373 additions and 56 deletions

View file

@ -57,16 +57,60 @@ function map_hidden_outputs($config){
return $hidden; return $hidden;
} }
function public_url_path($path){
$path = str_replace('\\', '/', trim((string)$path));
if ($path === '') return '';
$parts = array_values(array_filter(explode('/', $path), static fn($p) => $p !== ''));
return implode('/', array_map('rawurlencode', $parts));
}
function video_thumb_public_url($config, $videoName){ function video_thumb_public_url($config, $videoName){
$videoName = basename((string)$videoName); $videoName = basename((string)$videoName);
if ($videoName === '') return ''; if ($videoName === '') return '';
$base = pathinfo($videoName, PATHINFO_FILENAME); $info = function_exists('video_thumb_source_info') ? video_thumb_source_info($config, $videoName) : [];
foreach (['jpg','jpeg','png','webp','gif'] as $ext) { $sourcePath = (string)($info['source_path'] ?? '');
$file = $config['videos_dir'] . '/' . $base . '_thumb.' . $ext; if ($sourcePath !== '') {
if (is_file($file)) return $config['public_videos'] . '/' . rawurlencode($base . '_thumb.' . $ext) . '?v=' . filemtime($file); $abs = str_starts_with($sourcePath, '/') ? $sourcePath : __DIR__ . '/' . ltrim($sourcePath, '/');
if (is_file($abs)) return public_url_path($sourcePath) . '?v=' . filemtime($abs);
}
$sourceFile = basename((string)($info['source_file'] ?? ''));
$inputDir = basename((string)($info['input_dir'] ?? ''));
if ($sourceFile !== '' && $inputDir !== '') {
$rel = 'in-dir/' . $inputDir . '/' . $sourceFile;
$abs = __DIR__ . '/' . $rel;
if (is_file($abs)) return public_url_path($rel) . '?v=' . filemtime($abs);
} }
return ''; return '';
} }
function video_thumb_source_info($config, $videoName){
$videoName = basename((string)$videoName);
if ($videoName === '') return [];
$base = pathinfo($videoName, PATHINFO_FILENAME);
$file = $config['videos_dir'] . '/' . $base . '_thumb.json';
if (!is_file($file)) return [];
$data = json_decode((string)file_get_contents($file), true);
return is_array($data) ? $data : [];
}
function video_article_id($config, $videoName){
$videoName = basename((string)$videoName);
if ($videoName === '') return '';
foreach (glob($config['uploads_dir'].'/*', GLOB_ONLYDIR) ?: [] as $dir) {
$stateFile = $dir . '/.movmaker-state.json';
if (!is_file($stateFile)) continue;
$state = json_decode((string)file_get_contents($stateFile), true);
if (!is_array($state)) continue;
$output = basename((string)($state['output'] ?? ''));
$previewOutput = basename((string)($state['preview_output'] ?? ''));
if ($videoName === $output || $videoName === $previewOutput) return (string)($state['article_id'] ?? '');
}
return '';
}
function article_og_image_url($articleId){
$articleId = strtolower(trim((string)$articleId));
if (!preg_match('/^[0-9]{14}[a-f0-9]{16}$/', $articleId)) return '';
$path = __DIR__ . '/out-dir/' . $articleId . '_og.jpg';
if (!is_file($path)) return '';
return 'https://bubulescu.org/out-dir/' . rawurlencode($articleId . '_og.jpg');
}
function video_metadata($path){ function video_metadata($path){
$base = pathinfo($path, PATHINFO_FILENAME); $base = pathinfo($path, PATHINFO_FILENAME);
@ -181,8 +225,8 @@ usort($videos, function($a, $b) use ($videoCache) {
$articleId = trim((string)($_GET['id'] ?? '')); $articleId = trim((string)($_GET['id'] ?? ''));
if ($articleId !== '') { if ($articleId !== '') {
$videos = array_values(array_filter($videos, function($v) use ($articleId) { $videos = array_values(array_filter($videos, function($v) use ($articleId, $config) {
return pathinfo($v, PATHINFO_FILENAME) === $articleId; return video_article_id($config, basename($v)) === $articleId;
})); }));
$rawKeyword = ''; $rawKeyword = '';
$keywordType = 'general'; $keywordType = 'general';
@ -248,6 +292,21 @@ $slice = array_slice($videos, ($page - 1) * $per, $per);
$activeFilterCount = ($keywordValue !== '') ? 1 : 0; $activeFilterCount = ($keywordValue !== '') ? 1 : 0;
$baseParams = ($rawKeyword !== '') ? ['q' => $rawKeyword] : []; $baseParams = ($rawKeyword !== '') ? ['q' => $rawKeyword] : [];
$defaultOgImageUrl = 'https://bubulescu.org/assets/img/bubulescuorg.jpg';
$ogImageUrl = $defaultOgImageUrl;
$ogImageAlt = 'MVLog: journal of an unreliable narrator.';
if ($articleId !== '') {
$customOg = article_og_image_url($articleId);
if ($customOg !== '') {
$ogImageUrl = $customOg;
if (!empty($slice)) {
$firstVideo = basename((string)$slice[0]);
$firstMeta = $videoCache[$firstVideo]['metadata'] ?? video_metadata($slice[0]);
$firstTitle = trim((string)($firstMeta['title'] ?? ''));
if ($firstTitle !== '') $ogImageAlt = 'MVLog: ' . $firstTitle;
}
}
}
$headerTitles = [ $headerTitles = [
'Multiple Voices Log', 'Multiple Voices Log',
'MultiVerse Log', 'MultiVerse Log',
@ -307,15 +366,15 @@ $siteHeaderTitle = $headerTitles[array_rand($headerTitles)];
<meta property="og:site_name" content="Bubulescu.Org"> <meta property="og:site_name" content="Bubulescu.Org">
<!-- Image --> <!-- Image -->
<meta property="og:image" content="https://bubulescu.org/assets/img/bubulescuorg.jpg"> <meta property="og:image" content="<?=h($ogImageUrl)?>">
<meta property="og:image:secure_url" content="https://bubulescu.org/assets/img/bubulescuorg.jpg"> <meta property="og:image:secure_url" content="<?=h($ogImageUrl)?>">
<meta property="og:image:type" content="image/jpeg"> <meta property="og:image:type" content="image/jpeg">
<meta property="og:image:width" content="1200"> <meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630"> <meta property="og:image:height" content="630">
<meta property="og:image:alt" content="MVLog: journal of an unreliable narrator."> <meta property="og:image:alt" content="<?=h($ogImageAlt)?>">
<meta name="twitter:card" content="summary_large_image"> <meta name="twitter:card" content="summary_large_image">
<meta name="twitter:image" content="https://bubulescu.org/assets/img/bubulescuorg.jpg"> <meta name="twitter:image" content="<?=h($ogImageUrl)?>">
<link rel="alternate" type="application/rss+xml" title="MVLog RSS" href="feed.php"> <link rel="alternate" type="application/rss+xml" title="MVLog RSS" href="feed.php">
<link rel="icon" type="image/png" href="assets/img/moto_travel.png"> <link rel="icon" type="image/png" href="assets/img/moto_travel.png">
@ -385,7 +444,8 @@ $siteHeaderTitle = $headerTitles[array_rand($headerTitles)];
<?php foreach($slice as $v): <?php foreach($slice as $v):
$name = basename($v); $name = basename($v);
$base = pathinfo($name, PATHINFO_FILENAME); $base = pathinfo($name, PATHINFO_FILENAME);
$permalinkUrl = query_url(['id' => $base]); $articleIdForVideo = video_article_id($config, $name);
$permalinkUrl = $articleIdForVideo !== '' ? query_url(['id' => $articleIdForVideo]) : '';
$url = $config['public_videos'].'/'.rawurlencode($name); $url = $config['public_videos'].'/'.rawurlencode($name);
$m = $videoCache[$name]['metadata'] ?? video_metadata($v); $m = $videoCache[$name]['metadata'] ?? video_metadata($v);
$posterUrl = video_thumb_public_url($config, $name); $posterUrl = video_thumb_public_url($config, $name);
@ -394,7 +454,7 @@ $siteHeaderTitle = $headerTitles[array_rand($headerTitles)];
?> ?>
<article class="video-row"> <article class="video-row">
<div class="video-row-head video-row-head-mobile"> <div class="video-row-head video-row-head-mobile">
<h2 class="video-row-title"><a href="<?=h($permalinkUrl)?>"><?=h($m['title'])?></a></h2> <h2 class="video-row-title"><?php if($permalinkUrl !== ''): ?><a href="<?=h($permalinkUrl)?>"><?=h($m['title'])?></a><?php else: ?><?=h($m['title'])?><?php endif; ?></h2>
<p class="meta"> <p class="meta">
<span><a href="<?=h($dateFilterUrl)?>"><?=h($m['date'])?></a></span> <span><a href="<?=h($dateFilterUrl)?>"><?=h($m['date'])?></a></span>
<?php if(!empty($m['location'])): ?><span><a href="<?=h($locFilterUrl)?>"><?=h($m['location'])?></a></span><?php endif; ?> <?php if(!empty($m['location'])): ?><span><a href="<?=h($locFilterUrl)?>"><?=h($m['location'])?></a></span><?php endif; ?>
@ -412,7 +472,7 @@ $siteHeaderTitle = $headerTitles[array_rand($headerTitles)];
<?php endif; ?> <?php endif; ?>
</div> </div>
<div class="video-info"> <div class="video-info">
<h2 class="video-row-title video-row-title-desktop"><a href="<?=h($permalinkUrl)?>"><?=h($m['title'])?></a></h2> <h2 class="video-row-title video-row-title-desktop"><?php if($permalinkUrl !== ''): ?><a href="<?=h($permalinkUrl)?>"><?=h($m['title'])?></a><?php else: ?><?=h($m['title'])?><?php endif; ?></h2>
<p class="meta meta-desktop"> <p class="meta meta-desktop">
<span><a href="<?=h($dateFilterUrl)?>"><?=h($m['date'])?></a></span> <span><a href="<?=h($dateFilterUrl)?>"><?=h($m['date'])?></a></span>
<?php if(!empty($m['location'])): ?><span><a href="<?=h($locFilterUrl)?>"><?=h($m['location'])?></a></span><?php endif; ?> <?php if(!empty($m['location'])): ?><span><a href="<?=h($locFilterUrl)?>"><?=h($m['location'])?></a></span><?php endif; ?>
@ -425,6 +485,7 @@ $siteHeaderTitle = $headerTitles[array_rand($headerTitles)];
<?php endforeach; ?> <?php endforeach; ?>
</div> </div>
<?php if ($articleId === ''): ?>
<div class="pages"> <div class="pages">
<?php for($i=1;$i<=$pages;$i++): <?php for($i=1;$i<=$pages;$i++):
$linkParams = $baseParams; $linkParams = $baseParams;
@ -433,6 +494,7 @@ $siteHeaderTitle = $headerTitles[array_rand($headerTitles)];
<a class="<?=$i===$page?'active':''?>" href="<?=h(query_url($linkParams))?>"><?=$i?></a> <a class="<?=$i===$page?'active':''?>" href="<?=h(query_url($linkParams))?>"><?=$i?></a>
<?php endfor; ?> <?php endfor; ?>
</div> </div>
<?php endif; ?>
</section> </section>
</main> </main>
<?php include __DIR__ . '/_footer.php'; ?> <?php include __DIR__ . '/_footer.php'; ?>

View file

@ -97,7 +97,11 @@ function try_send_show_notification($articleRefOrJob, $jobdir, $mvlog_root = nul
$job = basename((string)$jobdir); $job = basename((string)$jobdir);
$articleRef = strtolower(trim((string)$articleRefOrJob)); $articleRef = strtolower(trim((string)$articleRefOrJob));
$articleId = mvlog_article_id_is_valid($articleRef) ? $articleRef : mvlog_read_article_id((string)$jobdir); $articleId = mvlog_article_id_is_valid($articleRef) ? $articleRef : mvlog_read_article_id((string)$jobdir);
$identity = $articleId !== '' ? $articleId : $job; if ($articleId === '') {
error_log("[mvlog] article id missing, skipping push for $job\n", 3, $logfile);
return false;
}
$identity = $articleId;
// Don't notify for hidden jobs // Don't notify for hidden jobs
if (is_file($jobdir . '/.mvlog-hidden')) { if (is_file($jobdir . '/.mvlog-hidden')) {
@ -169,14 +173,12 @@ function try_send_show_notification($articleRefOrJob, $jobdir, $mvlog_root = nul
if ($articleTitle === '') $articleTitle = mvlog_nice_title((string)$job); if ($articleTitle === '') $articleTitle = mvlog_nice_title((string)$job);
$filterUrl = 'https://bubulescu.org/?' . http_build_query([ $articleUrl = './?id=' . rawurlencode($articleId);
'q' => $articleTitle,
]);
$payload = json_encode([ $payload = json_encode([
'title' => $articleTitle, 'title' => $articleTitle,
'body' => 'New aticle @ MVL!', 'body' => 'New aticle @ MVL!',
'url' => $filterUrl, 'url' => $articleUrl,
'tag' => "mvlog-show-{$identity}", 'tag' => "mvlog-show-{$identity}",
], JSON_UNESCAPED_UNICODE); ], JSON_UNESCAPED_UNICODE);

329
new.php
View file

@ -48,17 +48,160 @@ function ascii_safe($s){
function slugify($s){ $s = ascii_safe($s); $s = strtolower(trim($s)); $s = preg_replace('/[^a-z0-9]+/', '-', $s); return trim($s, '-') ?: 'movie'; } function slugify($s){ $s = ascii_safe($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 apply_thumbnail_overlay($imagePath, $title, $teaser) { function apply_thumbnail_overlay($imagePath, $title, $teaser) {
$logFile = __DIR__ . '/logs/image_magick.log';
file_put_contents($logFile, "--- New Thumbnail Job ---\n", FILE_APPEND);
$fontPath = __DIR__ . '/assets/fonts/IBMPlexSans-SemiBold.ttf'; $fontPath = __DIR__ . '/assets/fonts/IBMPlexSans-SemiBold.ttf';
$cmd = sprintf( $titleFontPath = is_file(__DIR__ . '/assets/fonts/BebasNeue-Regular.ttf')
'convert %s -font %s -pointsize 30 -fill "#F3F4F6" -gravity northwest -annotate +20+20 %s -font %s -pointsize 20 -fill "#EADFC9" -gravity northwest -annotate +20+60 %s %s', ? __DIR__ . '/assets/fonts/BebasNeue-Regular.ttf'
escapeshellarg($imagePath), : $fontPath;
escapeshellarg($fontPath), $teaserFontPath = is_file(__DIR__ . '/assets/fonts/NotoSans-Bold.ttf')
escapeshellarg($title), ? __DIR__ . '/assets/fonts/NotoSans-Bold.ttf'
escapeshellarg($fontPath), : $fontPath;
escapeshellarg($teaser), if (!is_file($fontPath)) {
escapeshellarg($imagePath) throw new RuntimeException('Thumbnail font file is missing.');
}
$imgW = 1200;
$imgH = 630;
$normalize = static function ($text): string {
$text = trim((string)$text);
if ($text === '') return '';
$text = preg_replace('/\s+/u', ' ', $text) ?? $text;
return trim($text);
};
$wrapText = static function ($text, int $width) use ($normalize): string {
$text = $normalize($text);
return $text === '' ? '' : wordwrap($text, $width, "\n", false);
};
$clampWrappedText = static function ($text, int $width, int $maxLines, bool $cutLongWords = false) use ($normalize): string {
$text = $normalize($text);
if ($text === '') return '';
$words = preg_split('/\s+/u', $text, -1, PREG_SPLIT_NO_EMPTY) ?: [];
if (!$words) return '';
$candidate = implode(' ', $words);
while (true) {
$wrapped = wordwrap($candidate, $width, "\n", $cutLongWords);
$lineCount = substr_count($wrapped, "\n") + 1;
if ($lineCount <= $maxLines || count($words) <= 1) return $wrapped;
array_pop($words);
$candidate = implode(' ', $words) . '…';
}
};
$fitTitleText = static function ($text) use ($wrapText): array {
$bestText = '';
$bestSize = 95;
$bestWidth = 28;
$bestLines = 1;
for ($size = 84; $size >= 54; $size -= 4) {
$width = max(16, min(46, (int)round(23 + (84 - $size) / 2.7)));
$candidate = $wrapText($text, $width);
$lines = substr_count($candidate, "\n") + 1;
$bestText = $candidate;
$bestSize = $size;
$bestWidth = $width;
$bestLines = $lines;
if ($lines <= 2) break;
}
return [$bestText, $bestSize, $bestWidth, $bestLines];
};
[$titleText, $titleFontSize, $titleWrapWidth, $titleLines] = $fitTitleText($title);
$teaserText = $clampWrappedText($teaser, 34, 6, true);
$teaserLines = max(1, substr_count($teaserText, "\n") + 1);
// Move the whole block a bit left and higher while keeping left-aligned text.
$left = max(14, (int)round($imgW * 0.046));
$titleY = max(20, (int)round($imgH * 0.062));
$titleLineStep = max(68, (int)round($imgH * 0.043));
$teaserY = max((int)round($imgH * 0.235), $titleY + ($titleLines * $titleLineStep) + max(54, (int)round($imgH * 0.03)));
$overlayBase = tempnam(sys_get_temp_dir(), 'thumb_overlay_');
$outputPath = dirname($imagePath) . '/' . basename($imagePath) . '.tmp.' . uniqid('', true) . '.jpg';
if ($overlayBase === false) {
throw new RuntimeException('Failed to create temporary thumbnail overlay file.');
}
$overlayPath = $overlayBase . '.png';
@unlink($overlayBase);
$cmd1 = sprintf(
'/usr/bin/convert -size %dx%d xc:none -font %s -pointsize %d -fill "#F3F4F6" -stroke black -strokewidth 2 -interline-spacing 2 -gravity northwest -annotate +%d+%d %s -font %s -pointsize 58 -fill "#EADFC9" -stroke black -strokewidth 2 -interline-spacing -18 -gravity northwest -annotate +%d+%d %s %s 2>&1',
$imgW,
$imgH,
escapeshellarg($titleFontPath),
$titleFontSize,
$left,
$titleY,
escapeshellarg($titleText),
escapeshellarg($teaserFontPath),
$left,
$teaserY,
escapeshellarg($teaserText),
escapeshellarg($overlayPath)
); );
shell_exec($cmd); file_put_contents($logFile, "Overlay CMD: " . $cmd1 . "\n", FILE_APPEND);
$out1 = shell_exec($cmd1);
file_put_contents($logFile, "Overlay Output: " . $out1 . "\n", FILE_APPEND);
if (!is_file($overlayPath) || filesize($overlayPath) < 100) {
@unlink($overlayPath);
@unlink($outputPath);
throw new RuntimeException('Failed to render thumbnail overlay: ' . trim((string)$out1));
}
$maxBytes = 600 * 1024;
$qualities = [88, 84, 80, 76, 72, 68, 64, 60, 56, 52, 48];
$finalPath = '';
$finalSize = 0;
$finalOut = '';
foreach ($qualities as $quality) {
$attemptPath = dirname($imagePath) . '/' . basename($imagePath) . '.tmp.' . uniqid('', true) . '.jpg';
$cmd2 = sprintf(
'/usr/bin/convert -filter Lanczos -define filter:blur=0.85 %s -resize %dx%d^ -gravity center -extent %dx%d -modulate 70,82 %s -composite -strip -interlace Plane -sampling-factor 4:2:0 -quality %d %s 2>&1',
escapeshellarg($imagePath),
$imgW,
$imgH,
$imgW,
$imgH,
escapeshellarg($overlayPath),
$quality,
escapeshellarg($attemptPath)
);
file_put_contents($logFile, "Composite CMD (q=$quality): " . $cmd2 . "\n", FILE_APPEND);
$out2 = shell_exec($cmd2);
file_put_contents($logFile, "Composite Output (q=$quality): " . $out2 . "\n", FILE_APPEND);
if (!is_file($attemptPath) || filesize($attemptPath) < 100) {
@unlink($attemptPath);
$finalOut = trim((string)$out2);
continue;
}
$finalPath = $attemptPath;
$finalSize = filesize($attemptPath);
$finalOut = trim((string)$out2);
if ($finalSize <= $maxBytes) break;
}
if ($finalPath === '') {
@unlink($overlayPath);
@unlink($outputPath);
throw new RuntimeException('Failed to compose thumbnail overlay: ' . $finalOut);
}
if ($finalSize > $maxBytes) {
file_put_contents($logFile, "Thumbnail still above size limit: " . $finalSize . " bytes\n", FILE_APPEND);
}
if (!@rename($finalPath, $imagePath)) {
@unlink($overlayPath);
@unlink($finalPath);
throw new RuntimeException('Failed to replace thumbnail image.');
}
@unlink($overlayPath);
@unlink($outputPath);
} }
function input_dirs($base){ return glob($base.'/*', GLOB_ONLYDIR) ?: []; } function input_dirs($base){ return glob($base.'/*', GLOB_ONLYDIR) ?: []; }
@ -585,16 +728,39 @@ function is_image_media_file($name){
$ext = strtolower(pathinfo((string)$name, PATHINFO_EXTENSION)); $ext = strtolower(pathinfo((string)$name, PATHINFO_EXTENSION));
return in_array($ext, ['jpg','jpeg','png','webp','gif'], true); return in_array($ext, ['jpg','jpeg','png','webp','gif'], true);
} }
function public_url_path($path){
$path = str_replace('\\', '/', trim((string)$path));
if ($path === '') return '';
$parts = array_values(array_filter(explode('/', $path), static fn($p) => $p !== ''));
return implode('/', array_map('rawurlencode', $parts));
}
function video_thumb_public_url($config, $videoName){ function video_thumb_public_url($config, $videoName){
$videoName = basename((string)$videoName); $videoName = basename((string)$videoName);
if ($videoName === '') return ''; if ($videoName === '') return '';
$base = pathinfo($videoName, PATHINFO_FILENAME); $info = video_thumb_source_info($config, $videoName);
foreach (['jpg','jpeg','png','webp','gif'] as $ext) { $sourcePath = (string)($info['source_path'] ?? '');
$file = $config['videos_dir'] . '/' . $base . '_thumb.' . $ext; if ($sourcePath !== '') {
if (is_file($file)) return $config['public_videos'] . '/' . rawurlencode($base . '_thumb.' . $ext) . '?v=' . filemtime($file); $abs = str_starts_with($sourcePath, '/') ? $sourcePath : __DIR__ . '/' . ltrim($sourcePath, '/');
if (is_file($abs)) return public_url_path($sourcePath) . '?v=' . filemtime($abs);
}
$sourceFile = basename((string)($info['source_file'] ?? ''));
$inputDir = basename((string)($info['input_dir'] ?? ''));
if ($sourceFile !== '' && $inputDir !== '') {
$rel = 'in-dir/' . $inputDir . '/' . $sourceFile;
$abs = __DIR__ . '/' . $rel;
if (is_file($abs)) return public_url_path($rel) . '?v=' . filemtime($abs);
} }
return ''; return '';
} }
function video_thumb_source_info($config, $videoName){
$videoName = basename((string)$videoName);
if ($videoName === '') return [];
$base = pathinfo($videoName, PATHINFO_FILENAME);
$file = $config['videos_dir'] . '/' . $base . '_thumb.json';
if (!is_file($file)) return [];
$data = json_decode((string)file_get_contents($file), true);
return is_array($data) ? $data : [];
}
function ensure_state_article_id($dir){ function ensure_state_article_id($dir){
$articleId = ensure_article_id($dir); $articleId = ensure_article_id($dir);
$stateFile = $dir . '/.movmaker-state.json'; $stateFile = $dir . '/.movmaker-state.json';
@ -647,9 +813,13 @@ function input_dir_output($dir){
return basename((string)($state['output'] ?? '')); return basename((string)($state['output'] ?? ''));
} }
function regenerate_input_dir_maps($dir, $config){ function regenerate_input_dir_maps($dir, $config){
$logFile = '/var/log/mvlog_map.log';
$python = trim((string)shell_exec('command -v python3 2>/dev/null')); $python = trim((string)shell_exec('command -v python3 2>/dev/null'));
$script = __DIR__ . '/bin/gpsmap.py'; $script = __DIR__ . '/bin/gpsmap.py';
if ($python === '' || !is_file($script)) return; if ($python === '' || !is_file($script)) {
file_put_contents($logFile, '[mvlog] map regenerate skipped for ' . basename((string)$dir) . ' (python=' . ($python !== '' ? 'yes' : 'no') . ', script=' . (is_file($script) ? 'yes' : 'no') . ")\n", FILE_APPEND);
return;
}
$state = input_dir_state($dir); $state = input_dir_state($dir);
$targets = []; $targets = [];
@ -665,14 +835,19 @@ function regenerate_input_dir_maps($dir, $config){
} }
$targets = array_values(array_unique($targets)); $targets = array_values(array_unique($targets));
file_put_contents($logFile, '[mvlog] map regenerate requested for ' . basename((string)$dir) . ' targets=' . implode(', ', array_map('basename', $targets)) . "\n", FILE_APPEND);
foreach ($targets as $outPng) { foreach ($targets as $outPng) {
file_put_contents($logFile, '[mvlog] map regenerate start for ' . basename((string)$dir) . ' target=' . basename((string)$outPng) . "\n", FILE_APPEND);
$cmd = escapeshellarg($python) . ' ' . escapeshellarg($script) . ' ' . escapeshellarg($dir) . ' ' . escapeshellarg($outPng) . ' 2>&1'; $cmd = escapeshellarg($python) . ' ' . escapeshellarg($script) . ' ' . escapeshellarg($dir) . ' ' . escapeshellarg($outPng) . ' 2>&1';
$output = (string)shell_exec($cmd); $output = (string)shell_exec($cmd);
if (is_file($outPng)) { if (is_file($outPng)) {
@chmod($outPng, 0664); @chmod($outPng, 0664);
clearstatcache(true, $outPng);
$size = @filesize($outPng);
file_put_contents($logFile, '[mvlog] map regenerate done for ' . basename((string)$dir) . ' target=' . basename((string)$outPng) . ' bytes=' . ($size !== false ? $size : 'unknown') . "\n", FILE_APPEND);
} else { } else {
error_log('[mvlog] map regenerate failed for '.basename((string)$dir).' target='.basename((string)$outPng).' output='.trim($output)."\n", 3, '/var/log/mvlog_map.log'); file_put_contents($logFile, '[mvlog] map regenerate failed for ' . basename((string)$dir) . ' target=' . basename((string)$outPng) . ' output=' . trim($output) . "\n", FILE_APPEND);
} }
} }
} }
@ -860,6 +1035,9 @@ try {
header('Location: new.php?tab=edit&msg=' . rawurlencode($message)); header('Location: new.php?tab=edit&msg=' . rawurlencode($message));
exit; exit;
} elseif ($action === 'set_video_thumb') { } elseif ($action === 'set_video_thumb') {
$logFile = __DIR__ . '/logs/image_magick.log';
file_put_contents($logFile, "Entering set_video_thumb action...\n", FILE_APPEND); // LOG 1
$dir = resolve_input_dir_from_request($config['uploads_dir'], $_POST['id'] ?? '', $_POST['dir'] ?? '', $articleIndex); $dir = resolve_input_dir_from_request($config['uploads_dir'], $_POST['id'] ?? '', $_POST['dir'] ?? '', $articleIndex);
$articleId = ensure_state_article_id($dir); $articleId = ensure_state_article_id($dir);
if (input_dir_running($dir)) throw new RuntimeException('This input directory is being rendered. Editing is disabled until the job completes.'); if (input_dir_running($dir)) throw new RuntimeException('This input directory is being rendered. Editing is disabled until the job completes.');
@ -876,6 +1054,19 @@ try {
if (!$outputs) throw new RuntimeException('No rendered video found for this input directory yet.'); if (!$outputs) throw new RuntimeException('No rendered video found for this input directory yet.');
$src = $dir . '/' . $file; $src = $dir . '/' . $file;
$thumbName = $articleId . '_og.jpg';
$target = $config['videos_dir'] . '/' . $thumbName;
if (is_file($target)) @unlink($target);
if (!@copy($src, $target)) {
file_put_contents($logFile, "Failed to copy thumbnail.\n", FILE_APPEND); // LOG 2
throw new RuntimeException('Failed to write thumbnail.');
}
@chmod($target, 0664);
file_put_contents($logFile, "Thumbnail copied, now applying overlay...\n", FILE_APPEND); // LOG 3
// Apply text overlay after smart resizing/cropping to 1200x630
apply_thumbnail_overlay($target, (string)($meta['title'] ?? ''), (string)($meta['teaser'] ?? ''));
$written = []; $written = [];
foreach ($outputs as $out) { foreach ($outputs as $out) {
$base = pathinfo($out, PATHINFO_FILENAME); $base = pathinfo($out, PATHINFO_FILENAME);
@ -884,15 +1075,20 @@ try {
$old = $config['thumbs_dir'] . '/' . $base . '.' . $oldExt; $old = $config['thumbs_dir'] . '/' . $base . '.' . $oldExt;
if (is_file($old)) @unlink($old); if (is_file($old)) @unlink($old);
} }
$thumbMeta = [
// Copy new thumbnail to videos_dir with _thumb.jpg suffix 'article_id' => $articleId,
$target = $config['videos_dir'] . '/' . $base . '_thumb.jpg'; 'input_dir' => basename($dir),
if (!@copy($src, $target)) throw new RuntimeException('Failed to write thumbnail.'); 'source_file' => $file,
@chmod($target, 0664); 'source_path' => 'in-dir/' . basename($dir) . '/' . $file,
'thumb_file' => $thumbName,
// Apply text overlay 'thumb_path' => 'out-dir/' . $thumbName,
apply_thumbnail_overlay($target, $meta['title'], $meta['teaser']); 'created_at' => gmdate('c'),
];
$thumbMetaPath = $config['videos_dir'] . '/' . $base . '_thumb.json';
if (file_put_contents($thumbMetaPath, json_encode($thumbMeta, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) . "\n", LOCK_EX) === false) {
throw new RuntimeException('Failed to write thumbnail metadata.');
}
@chmod($thumbMetaPath, 0664);
$written[] = basename($target); $written[] = basename($target);
} }
@ -912,11 +1108,15 @@ try {
$dir = resolve_input_dir_from_request($config['uploads_dir'], $_POST['id'] ?? '', $_POST['dir'] ?? '', $articleIndex); $dir = resolve_input_dir_from_request($config['uploads_dir'], $_POST['id'] ?? '', $_POST['dir'] ?? '', $articleIndex);
$articleId = ensure_state_article_id($dir); $articleId = ensure_state_article_id($dir);
if (input_dir_running($dir)) throw new RuntimeException('This input directory is being rendered. Editing is disabled until the job completes.'); if (input_dir_running($dir)) throw new RuntimeException('This input directory is being rendered. Editing is disabled until the job completes.');
write_data($dir, $_POST); file_put_contents('/var/log/mvlog_map.log', '[mvlog] save changes pressed for ' . basename($dir) . ' id=' . $articleId . "\n", FILE_APPEND);
save_uploads('media', $dir, $allowedMedia); try {
save_uploads('audio', $dir, $allowedAudio); write_data($dir, $_POST);
// Always regenerate map image(s) on Save changes. save_uploads('media', $dir, $allowedMedia);
regenerate_input_dir_maps($dir, $config); save_uploads('audio', $dir, $allowedAudio);
} finally {
// Always regenerate map image(s) whenever Save changes is pressed.
regenerate_input_dir_maps($dir, $config);
}
if (!empty($_POST['ajax'])) ajax_json(['ok'=>true, 'message'=>'Updated input directory: in-dir/' . basename($dir), 'dir'=>basename($dir), 'id'=>$articleId]); if (!empty($_POST['ajax'])) ajax_json(['ok'=>true, 'message'=>'Updated input directory: in-dir/' . basename($dir), 'dir'=>basename($dir), 'id'=>$articleId]);
header('Location: new.php?tab=edit'); header('Location: new.php?tab=edit');
exit; exit;
@ -1067,7 +1267,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><form method="get" class="admin-search-form"><input type="hidden" name="tab" value="edit"><div class="admin-search-wrap"><input name="q" value="<?=h($rawKeyword)?>" placeholder="Search title/teaser/quote_da/description or use date=... / location=..." aria-label="Search input dirs"><a class="admin-search-clear <?=$searchEmpty ? 'is-empty' : ''?>" href="new.php?tab=edit" aria-label="Clear filter" title="Clear filter">×</a></div><div class="admin-search-filters"><label class="admin-filter-item" title="Preview"><input type="checkbox" name="f_preview" value="1" <?=$filterPreview?'checked':''?>><span>P</span></label><label class="admin-filter-item" title="Render"><input type="checkbox" name="f_render" value="1" <?=$filterRender?'checked':''?>><span>R</span></label><label class="admin-filter-item" title="Show"><input type="checkbox" name="f_shown" value="1" <?=$filterShown?'checked':''?>><span>S</span></label></div><button type="submit" class="admin-search-submit" aria-label="Filter"><span class="icon" aria-hidden="true">🔍</span><span class="sr-only">Filter</span></button></form><?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 : ''; $posterUrl=$hasVideo ? video_thumb_public_url($config, $displayOutput) : ''; $previewVideo=$hasVideo && $displayOutput === $previewOutput; $canShow=$hasFullVideo && !$preview; $visible=$canShow && input_dir_visible($d); $mapHidden=input_dir_map_hidden($d); $info=$dirInfo[basename($d)] ?? input_dir_info($d); $data=read_data($d); $meta=['title'=>(string)($data['title'] ?? ''),'teaser'=>(string)($data['teaser'] ?? ''),'date'=>(string)($data['date'] ?? ''),'location'=>(string)($data['place'] ?? ''),'quote_da'=>(string)($data['quote_da'] ?? ''),'description'=>(string)($data['description'] ?? '')]; $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))?>" data-id="<?=h(read_article_id($d))?>"><div><?php if($hasVideo): ?><div class="video-wrapper"><video controls preload="metadata" src="<?=h($config['public_videos'].'/'.rawurlencode($displayOutput))?>"<?= $posterUrl !== '' ? ' poster="'.h($posterUrl).'"' : '' ?>></video><?php $map_n = ($displayOutput !== '' ? pathinfo($displayOutput, PATHINFO_FILENAME) : basename($d)) . '_map.png'; if(is_file($config['videos_dir'].'/'.$map_n)): ?><a class="map-image-link" href="<?=h($config['public_videos'].'/'.rawurlencode($map_n))?>"><img class="map-image" src="<?=h($config['public_videos'].'/'.rawurlencode($map_n))?>" alt="Map"></a><?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"> <?php if($tab==='edit' && !$editDir): ?><section><form method="get" class="admin-search-form"><input type="hidden" name="tab" value="edit"><div class="admin-search-wrap"><input name="q" value="<?=h($rawKeyword)?>" placeholder="Search title/teaser/quote_da/description or use date=... / location=..." aria-label="Search input dirs"><a class="admin-search-clear <?=$searchEmpty ? 'is-empty' : ''?>" href="new.php?tab=edit" aria-label="Clear filter" title="Clear filter">×</a></div><div class="admin-search-filters"><label class="admin-filter-item" title="Preview"><input type="checkbox" name="f_preview" value="1" <?=$filterPreview?'checked':''?>><span>P</span></label><label class="admin-filter-item" title="Render"><input type="checkbox" name="f_render" value="1" <?=$filterRender?'checked':''?>><span>R</span></label><label class="admin-filter-item" title="Show"><input type="checkbox" name="f_shown" value="1" <?=$filterShown?'checked':''?>><span>S</span></label></div><button type="submit" class="admin-search-submit" aria-label="Filter"><span class="icon" aria-hidden="true">🔍</span><span class="sr-only">Filter</span></button></form><?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 : ''; $posterUrl=$hasVideo ? video_thumb_public_url($config, $displayOutput) : ''; $previewVideo=$hasVideo && $displayOutput === $previewOutput; $canShow=$hasFullVideo && !$preview; $visible=$canShow && input_dir_visible($d); $mapHidden=input_dir_map_hidden($d); $info=$dirInfo[basename($d)] ?? input_dir_info($d); $data=read_data($d); $meta=['title'=>(string)($data['title'] ?? ''),'teaser'=>(string)($data['teaser'] ?? ''),'date'=>(string)($data['date'] ?? ''),'location'=>(string)($data['place'] ?? ''),'quote_da'=>(string)($data['quote_da'] ?? ''),'description'=>(string)($data['description'] ?? '')]; $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))?>" data-id="<?=h(read_article_id($d))?>"><div><?php if($hasVideo): ?><div class="video-wrapper"><video controls preload="metadata" src="<?=h($config['public_videos'].'/'.rawurlencode($displayOutput))?>"<?= $posterUrl !== '' ? ' poster="'.h($posterUrl).'"' : '' ?>></video><?php $map_n = ($displayOutput !== '' ? pathinfo($displayOutput, PATHINFO_FILENAME) : basename($d)) . '_map.png'; if(is_file($config['videos_dir'].'/'.$map_n)): ?><a class="map-image-link" href="<?=h($config['public_videos'].'/'.rawurlencode($map_n))?>"><img class="map-image" src="<?=h($config['public_videos'].'/'.rawurlencode($map_n))?>" alt="Map"></a><?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"><?php $thumbMeta = $hasVideo ? video_thumb_source_info($config, $displayOutput) : []; if(!empty($thumbMeta['source_file'])): ?><p style="margin:.2rem 0 .45rem;color:#A0A4AB;font-size:.82rem">Thumb source: <?=h((string)$thumbMeta['source_file'])?></p><?php endif; ?><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))?>">
@ -1111,7 +1311,7 @@ $runningJobs = active_worker_jobs($config);
<?php if($hasVideo): ?><time class="admin-time<?= $staleLabel ? ' admin-time-stale' : '' ?>" title="<?=h($staleLabel ?: 'Video created at')?>"><?=h(date('d.m.Y H:i', filemtime($displayVideoPath)))?></time><?php endif; ?> <?php if($hasVideo): ?><time class="admin-time<?= $staleLabel ? ' admin-time-stale' : '' ?>" title="<?=h($staleLabel ?: 'Video created at')?>"><?=h(date('d.m.Y H:i', filemtime($displayVideoPath)))?></time><?php endif; ?>
</div><div class="admin-actions"><?php if($running): ?><span class="button disabled" title="Rendering now" data-edit-action="1">Rendering</span><?php else: ?><a class="button" href="?id=<?=rawurlencode(read_article_id($d))?>&edit=<?=rawurlencode(basename($d))?>" data-edit-action="1" data-edit-href="?id=<?=rawurlencode(read_article_id($d))?>&edit=<?=rawurlencode(basename($d))?>">Edit</a><?php endif; ?></div></div><h2><?=h($meta['title'] ?: ($info['title'] ?? basename($d)))?></h2><p class="meta"><?php if($meta['date']): ?><span><?=h($meta['date'])?></span><?php endif; ?><?php if($meta['location']): ?><span><?=h($meta['location'])?></span><?php endif; ?><?php if(!$hasVideo): ?><span>No video</span><?php endif; ?><?php if($previewVideo): ?><span>Preview video</span><?php endif; ?></p><?php if(!empty($meta['teaser'])): ?><p class="description-teaser"><?=h((string)$meta['teaser'])?></p><?php endif; ?><?php if($meta['description']): ?><p class="description"><?=nl2br(h($meta['description']), false)?></p><?php endif; ?><?php if(!empty($meta['quote_da'])): ?><p class="description-quote">“<?=h(trim((string)$meta['quote_da'], "\"“”"))?>”</p><?php endif; ?><p class="details"><?=h(basename($d))?> · <?=h($info['file_count'] ?? count(list_files($d)))?> files</p></div></article><?php endforeach; ?></div><?=page_links('edit',$editPage,$editPages)?></section><?php endif; ?> </div><div class="admin-actions"><?php if($running): ?><span class="button disabled" title="Rendering now" data-edit-action="1">Rendering</span><?php else: ?><a class="button" href="?id=<?=rawurlencode(read_article_id($d))?>&edit=<?=rawurlencode(basename($d))?>" data-edit-action="1" data-edit-href="?id=<?=rawurlencode(read_article_id($d))?>&edit=<?=rawurlencode(basename($d))?>">Edit</a><?php endif; ?></div></div><h2><?=h($meta['title'] ?: ($info['title'] ?? basename($d)))?></h2><p class="meta"><?php if($meta['date']): ?><span><?=h($meta['date'])?></span><?php endif; ?><?php if($meta['location']): ?><span><?=h($meta['location'])?></span><?php endif; ?><?php if(!$hasVideo): ?><span>No video</span><?php endif; ?><?php if($previewVideo): ?><span>Preview video</span><?php endif; ?></p><?php if(!empty($meta['teaser'])): ?><p class="description-teaser"><?=h((string)$meta['teaser'])?></p><?php endif; ?><?php if($meta['description']): ?><p class="description"><?=nl2br(h($meta['description']), false)?></p><?php endif; ?><?php if(!empty($meta['quote_da'])): ?><p class="description-quote">“<?=h(trim((string)$meta['quote_da'], "\"“”"))?>”</p><?php endif; ?><p class="details"><?=h(basename($d))?> · <?=h($info['file_count'] ?? count(list_files($d)))?> files</p></div></article><?php endforeach; ?></div><?=page_links('edit',$editPage,$editPages)?></section><?php endif; ?>
<?php if($tab==='edit' && $editDir && $editRunning): ?><section><h2>Edit input dir</h2><p><code>in-dir/<?=h(basename($editDir))?></code></p><div class="err">This input directory is <?=h($editStatus)?>. Editing is disabled until the job completes.</div></section><?php endif; ?> <?php if($tab==='edit' && $editDir && $editRunning): ?><section><h2>Edit input dir</h2><p><code>in-dir/<?=h(basename($editDir))?></code></p><div class="err">This input directory is <?=h($editStatus)?>. Editing is disabled until the job completes.</div></section><?php endif; ?>
<?php if($tab==='edit' && $editDir && !$editRunning): ?><section><h2>Edit input dir</h2><p><code>in-dir/<?=h(basename($editDir))?></code></p><form method="post" enctype="multipart/form-data" id="edit-form"><input type="hidden" name="action" value="update"><input type="hidden" name="dir" value="<?=h(basename($editDir))?>"><input type="hidden" name="id" value="<?=h(ensure_article_id($editDir))?>"><label>Title<input name="title" value="<?=h($editData['title'])?>"></label><label>Teaser<input name="teaser" value="<?=h($editData['teaser'])?>" maxlength="240"></label><label>Danish quote<input name="quote_da" value="<?=h($editData['quote_da'])?>" maxlength="280"></label><label>Date<input name="date" value="<?=h($editData['date'])?>"></label><label>Place<input name="place" value="<?=h($editData['place'])?>"></label><label>Description<textarea name="description" maxlength="5000" data-counter="description-counter-edit"><?=h($editData['description'])?></textarea><small id="description-counter-edit" class="counter"></small></label><label>Add images / videos<input type="file" name="media[]" multiple accept="image/*,video/*"></label><label>Add audio<input type="file" name="audio[]" multiple accept="audio/*"></label><h3>Files and captions</h3><div class="file-list"><?php foreach($editFiles as $f): $ext=strtolower(pathinfo($f, PATHINFO_EXTENSION)); $fileUrl='in-dir/'.rawurlencode(basename($editDir)).'/'.rawurlencode($f); ?><div class="caption-row"><div class="preview"><?php if(in_array($ext,['jpg','jpeg','png','webp','gif'])): ?><img src="<?=h($fileUrl)?>" alt=""><?php elseif(in_array($ext,['mp4','mov','m4v','webm'])): ?><video src="<?=h($fileUrl)?>" controls preload="metadata"></video><a class="download-link" href="<?=h($fileUrl)?>" download>Download</a><?php else: ?><span><?=h(strtoupper($ext ?: 'FILE'))?></span><?php endif; ?></div><div class="caption-fields"><strong><?=h($f)?></strong><?php if(in_array($ext,['jpg','jpeg','png','webp','gif','mp4','mov','m4v','webm'])): ?><input type="hidden" name="caption_files[]" value="<?=h($f)?>"><textarea name="captions[]" placeholder="Optional caption for this file"><?=h($editData['captions'][$f] ?? '')?></textarea><?php if(in_array($ext,['mp4','mov','m4v','webm'])): ?><label class="checkbox-label"><input type="checkbox" name="use_audio_files[]" value="<?=h($f)?>" <?=!empty($editData['video_audio'][$f])?'checked':''?>> <span>Use video file audio</span></label><?php endif; ?><?php else: ?><small>No caption for audio files</small><?php endif; ?></div><div class="file-actions" style="display:flex;gap:.45rem;flex-wrap:wrap;align-self:start"><?php if(in_array($ext,['jpg','jpeg','png','webp','gif'])): ?><button type="button" onclick="setThumb(<?=h(json_encode($f))?>)">Set as thumb</button><?php endif; ?><button type="button" onclick="deleteFile(<?=h(json_encode($f))?>)">Remove</button></div></div><?php endforeach; ?></div><button type="submit">Save changes</button></form><form method="post" id="delete-file-form" style="display:none"><input type="hidden" name="action" value="delete_file"><input type="hidden" name="dir" value="<?=h(basename($editDir))?>"><input type="hidden" name="id" value="<?=h(ensure_article_id($editDir))?>"><input type="hidden" name="file" id="delete-file-name" value=""></form><form method="post" onsubmit="return confirm(\'Delete this input directory\?\')" id="delete-dir-form""><input type="hidden" name="action" value="delete_dir"><input type="hidden" name="dir" value="<?=h(basename($editDir))?>"><input type="hidden" name="id" value="<?=h(ensure_article_id($editDir))?>"><label class="checkbox-label"><input type="checkbox" name="delete_output" value="1"> <span>Also delete generated video, if any</span></label><button class="danger" type="submit">Delete input dir</button></form></section><?php endif; ?> <?php if($tab==='edit' && $editDir && !$editRunning): ?><section><h2>Edit input dir</h2><p><code>in-dir/<?=h(basename($editDir))?></code></p><form method="post" enctype="multipart/form-data" id="edit-form"><input type="hidden" name="action" value="update"><input type="hidden" name="dir" value="<?=h(basename($editDir))?>"><input type="hidden" name="id" value="<?=h(ensure_article_id($editDir))?>"><label>Title<input name="title" value="<?=h($editData['title'])?>"></label><label>Teaser<input name="teaser" value="<?=h($editData['teaser'])?>" maxlength="240"></label><label>Danish quote<input name="quote_da" value="<?=h($editData['quote_da'])?>" maxlength="280"></label><label>Date<input name="date" value="<?=h($editData['date'])?>"></label><label>Place<input name="place" value="<?=h($editData['place'])?>"></label><label>Description<textarea name="description" maxlength="5000" data-counter="description-counter-edit"><?=h($editData['description'])?></textarea><small id="description-counter-edit" class="counter"></small></label><label>Add images / videos<input type="file" name="media[]" multiple accept="image/*,video/*"></label><label>Add audio<input type="file" name="audio[]" multiple accept="audio/*"></label><h3>Files and captions</h3><div class="file-list"><?php foreach($editFiles as $f): $ext=strtolower(pathinfo($f, PATHINFO_EXTENSION)); $fileUrl='in-dir/'.rawurlencode(basename($editDir)).'/'.rawurlencode($f); ?><div class="caption-row"><div class="preview"><?php if(in_array($ext,['jpg','jpeg','png','webp','gif'])): ?><img src="<?=h($fileUrl)?>" alt=""><?php elseif(in_array($ext,['mp4','mov','m4v','webm'])): ?><video src="<?=h($fileUrl)?>" controls preload="metadata"></video><a class="download-link" href="<?=h($fileUrl)?>" download>Download</a><?php else: ?><span><?=h(strtoupper($ext ?: 'FILE'))?></span><?php endif; ?></div><div class="caption-fields"><strong><?=h($f)?></strong><?php if(in_array($ext,['jpg','jpeg','png','webp','gif','mp4','mov','m4v','webm'])): ?><input type="hidden" name="caption_files[]" value="<?=h($f)?>"><textarea name="captions[]" placeholder="Optional caption for this file"><?=h($editData['captions'][$f] ?? '')?></textarea><?php if(in_array($ext,['mp4','mov','m4v','webm'])): ?><label class="checkbox-label"><input type="checkbox" name="use_audio_files[]" value="<?=h($f)?>" <?=!empty($editData['video_audio'][$f])?'checked':''?>> <span>Use video file audio</span></label><?php endif; ?><?php else: ?><small>No caption for audio files</small><?php endif; ?></div><div class="file-actions" style="display:flex;gap:.45rem;flex-wrap:wrap;align-self:start"><?php if(in_array($ext,['jpg','jpeg','png','webp','gif'])): ?><button type="button" class="set-thumb-btn" onclick="setThumb(<?=h(json_encode($f))?>)">Set as thumb</button><?php endif; ?><button type="button" onclick="deleteFile(<?=h(json_encode($f))?>)">Remove</button></div></div><?php endforeach; ?></div><button type="submit">Save changes</button></form><form method="post" id="delete-file-form" style="display:none"><input type="hidden" name="action" value="delete_file"><input type="hidden" name="dir" value="<?=h(basename($editDir))?>"><input type="hidden" name="id" value="<?=h(ensure_article_id($editDir))?>"><input type="hidden" name="file" id="delete-file-name" value=""></form><form method="post" onsubmit="return confirm(\'Delete this input directory\?\')" id="delete-dir-form""><input type="hidden" name="action" value="delete_dir"><input type="hidden" name="dir" value="<?=h(basename($editDir))?>"><input type="hidden" name="id" value="<?=h(ensure_article_id($editDir))?>"><label class="checkbox-label"><input type="checkbox" name="delete_output" value="1"> <span>Also delete generated video, if any</span></label><button class="danger" type="submit">Delete input dir</button></form></section><?php endif; ?>
<?php if($tab==='new'): ?><section><h2>Add new movie input</h2><form method="post" enctype="multipart/form-data" id="new-form"><input type="hidden" name="action" value="create"><label>Title*<input name="title" required></label><label>Teaser<input name="teaser" maxlength="240"></label><label>Danish quote<input name="quote_da" maxlength="280"></label><label>Date<input name="date" placeholder="2026-05-25"></label><label>Place<input name="place"></label><label>Description<textarea name="description" maxlength="5000" data-counter="description-counter-new"></textarea><small id="description-counter-new" class="counter"></small></label><label>Images / videos*<input type="file" name="media[]" multiple required accept="image/*,video/*"></label><label>Audio (optional)<input type="file" name="audio[]" multiple accept="audio/*"></label><button type="submit">Create input-dir</button></form></section><?php endif; ?> <?php if($tab==='new'): ?><section><h2>Add new movie input</h2><form method="post" enctype="multipart/form-data" id="new-form"><input type="hidden" name="action" value="create"><label>Title*<input name="title" required></label><label>Teaser<input name="teaser" maxlength="240"></label><label>Danish quote<input name="quote_da" maxlength="280"></label><label>Date<input name="date" placeholder="2026-05-25"></label><label>Place<input name="place"></label><label>Description<textarea name="description" maxlength="5000" data-counter="description-counter-new"></textarea><small id="description-counter-new" class="counter"></small></label><label>Images / videos*<input type="file" name="media[]" multiple required accept="image/*,video/*"></label><label>Audio (optional)<input type="file" name="audio[]" multiple accept="audio/*"></label><button type="submit">Create input-dir</button></form></section><?php endif; ?>
<script> <script>
function deleteFile(name){ function deleteFile(name){
@ -1149,9 +1349,53 @@ function deleteFile(name){
}); });
} }
var thumbBusyOverlay = null;
function ensureThumbBusyStyles(){
if(document.getElementById('thumb-busy-styles')) return;
var style = document.createElement('style');
style.id = 'thumb-busy-styles';
style.textContent = '@keyframes mvlog-spin{to{transform:rotate(360deg)}}' +
'#thumb-busy-overlay{position:fixed;inset:0;background:rgba(17,24,39,.42);backdrop-filter:blur(1px);display:none;align-items:center;justify-content:center;z-index:2990;}' +
'#thumb-busy-overlay.show{display:flex;}' +
'#thumb-busy-overlay .panel{background:rgba(15,23,42,.92);color:#F3F4F6;border:1px solid rgba(255,255,255,.14);box-shadow:0 20px 60px rgba(0,0,0,.38);border-radius:16px;padding:22px 28px;min-width:240px;text-align:center;}' +
'#thumb-busy-overlay .spinner{width:52px;height:52px;border:5px solid rgba(255,255,255,.18);border-top-color:#F3F4F6;border-radius:50%;margin:0 auto 14px;animation:mvlog-spin .8s linear infinite;}' +
'#thumb-busy-overlay .label{font-size:1rem;font-weight:600;}' +
'button.set-thumb-btn.is-busy-target{opacity:.55;cursor:not-allowed;}';
document.head.appendChild(style);
}
function ensureThumbBusyOverlay(){
if(thumbBusyOverlay) return thumbBusyOverlay;
thumbBusyOverlay = document.getElementById('thumb-busy-overlay');
if(thumbBusyOverlay) return thumbBusyOverlay;
ensureThumbBusyStyles();
thumbBusyOverlay = document.createElement('div');
thumbBusyOverlay.id = 'thumb-busy-overlay';
thumbBusyOverlay.innerHTML = '<div class="panel"><div class="spinner"></div><div class="label">Creating thumbnail…</div></div>';
document.body.appendChild(thumbBusyOverlay);
return thumbBusyOverlay;
}
function setThumbButtonsDisabled(disabled){
var buttons = document.querySelectorAll('button.set-thumb-btn');
for(var i=0;i<buttons.length;i++){
buttons[i].disabled = disabled;
buttons[i].classList.toggle('is-busy-target', disabled);
}
}
function showThumbBusy(label){
var overlay = ensureThumbBusyOverlay();
setThumbButtonsDisabled(true);
var text = overlay.querySelector('.label');
if(text && label) text.textContent = label;
overlay.classList.add('show');
}
function hideThumbBusy(){
if(thumbBusyOverlay) thumbBusyOverlay.classList.remove('show');
setThumbButtonsDisabled(false);
}
function setThumb(name){ function setThumb(name){
var ctx = getEditDirContext(); var ctx = getEditDirContext();
if(!ctx.dir){ showToast('Internal error: missing input dir', false); return; } if(!ctx.dir){ showToast('Internal error: missing input dir', false); return; }
showThumbBusy('Creating thumbnail…');
var body = 'action=set_video_thumb&dir=' + encodeURIComponent(ctx.dir) + '&id=' + encodeURIComponent(ctx.id) + '&file=' + encodeURIComponent(name) + '&ajax=1'; var body = 'action=set_video_thumb&dir=' + encodeURIComponent(ctx.dir) + '&id=' + encodeURIComponent(ctx.id) + '&file=' + encodeURIComponent(name) + '&ajax=1';
fetch('new.php', { fetch('new.php', {
method: 'POST', method: 'POST',
@ -1162,8 +1406,10 @@ function setThumb(name){
.then(function(json){ .then(function(json){
if(!json || !json.ok) throw new Error((json && (json.error || json.message)) ? (json.error || json.message) : 'Set thumb failed'); if(!json || !json.ok) throw new Error((json && (json.error || json.message)) ? (json.error || json.message) : 'Set thumb failed');
showToast(json.message || ('Thumbnail set from ' + name), true); showToast(json.message || ('Thumbnail set from ' + name), true);
setTimeout(hideThumbBusy, 120);
}).catch(function(err){ }).catch(function(err){
showToast(err.message || String(err), false); showToast(err.message || String(err), false);
setTimeout(hideThumbBusy, 120);
}); });
} }
@ -1386,7 +1632,7 @@ function handleAjaxFormSubmit(form, redirectTab) {
data.set('ajax', '1'); data.set('ajax', '1');
const action = String(data.get('action') || ''); const action = String(data.get('action') || '');
const verb = action === 'update' ? 'Saving' const verb = action === 'update' ? 'Saving changes…'
: action === 'delete_video' ? 'Deleting' : action === 'delete_video' ? 'Deleting'
: action === 'delete_dir' ? 'Deleting' : action === 'delete_dir' ? 'Deleting'
: action === 'delete_file' ? 'Deleting' : action === 'delete_file' ? 'Deleting'
@ -1395,18 +1641,22 @@ function handleAjaxFormSubmit(form, redirectTab) {
let timer = 0; let timer = 0;
let success = false; let success = false;
const useBusyOverlay = action === 'update';
if (submit) { if (submit) {
submit.dataset.label = submit.dataset.label || submit.textContent.trim() || 'Submit'; submit.dataset.label = submit.dataset.label || submit.textContent.trim() || 'Submit';
submit.disabled = true; submit.disabled = true;
submit.style.opacity = '0.78'; submit.style.opacity = '0.78';
submit.style.cursor = 'wait'; submit.style.cursor = 'wait';
let dots = 0; if (!useBusyOverlay) {
submit.textContent = verb; let dots = 0;
timer = window.setInterval(function(){ submit.textContent = verb;
dots = (dots + 1) % 4; timer = window.setInterval(function(){
submit.textContent = verb + '.'.repeat(dots); dots = (dots + 1) % 4;
}, 320); submit.textContent = verb + '.'.repeat(dots);
}, 320);
}
} }
if (useBusyOverlay) showThumbBusy('Saving changes…');
try{ try{
const res = await fetch('new.php', { method: 'POST', body: data, credentials: 'same-origin', headers: { 'Accept': 'application/json' }}); const res = await fetch('new.php', { method: 'POST', body: data, credentials: 'same-origin', headers: { 'Accept': 'application/json' }});
@ -1414,9 +1664,11 @@ function handleAjaxFormSubmit(form, redirectTab) {
if(!res.ok || !json.ok) throw new Error(json.error || 'Operation failed'); if(!res.ok || !json.ok) throw new Error(json.error || 'Operation failed');
success = true; success = true;
showToast(json.message || 'Operation successful', true); showToast(json.message || 'Operation successful', true);
if (useBusyOverlay) setTimeout(hideThumbBusy, 120);
setTimeout(() => { window.location.href = 'new.php?tab=' + (json.tab || redirectTab); }, 450); setTimeout(() => { window.location.href = 'new.php?tab=' + (json.tab || redirectTab); }, 450);
} catch(e) { } catch(e) {
showToast(e.message || 'Operation failed', false); showToast(e.message || 'Operation failed', false);
if (useBusyOverlay) setTimeout(hideThumbBusy, 120);
} finally { } finally {
if (timer) window.clearInterval(timer); if (timer) window.clearInterval(timer);
if(submit && !success) { if(submit && !success) {
@ -1425,6 +1677,7 @@ function handleAjaxFormSubmit(form, redirectTab) {
submit.style.cursor = ''; submit.style.cursor = '';
submit.textContent = submit.dataset.label || 'Submit'; submit.textContent = submit.dataset.label || 'Submit';
} }
if (useBusyOverlay && !success) hideThumbBusy();
} }
}); });
} }