'','date'=>'','place'=>'','description'=>'','captions'=>[],'video_audio'=>[]]; $file = $dir . '/data.txt';
if (!is_file($file)) return $data;
$lines = file($file, FILE_IGNORE_NEW_LINES);
for ($i = 0; $i < count($lines); $i++) {
$line = trim($lines[$i]);
if ($line === '' || str_starts_with($line, '#') || !str_contains($line, ':')) continue;
[$key, $value] = array_map('trim', explode(':', $line, 2));
$low = strtolower($key);
if ($value === '|' && in_array($low, ['description','desc','synopsis'], true)) {
$block = [];
while ($i + 1 < count($lines)) {
$next = $lines[$i + 1];
if (trim($next) !== '' && $next[0] !== ' ' && $next[0] !== "\t") break;
$i++;
if (str_starts_with($next, ' ')) $next = substr($next, 2);
elseif (str_starts_with($next, ' ') || str_starts_with($next, "\t")) $next = substr($next, 1);
$block[] = rtrim($next);
}
$data['description'] = trim(implode("\n", $block));
} elseif (in_array($low, ['title','name'], true)) $data['title'] = $value;
elseif (in_array($low, ['date','dates','when'], true)) $data['date'] = $value;
elseif (in_array($low, ['place','location','where'], true)) $data['place'] = $value;
elseif (in_array($low, ['description','desc','synopsis'], true)) $data['description'] = $value;
elseif (str_ends_with($low, ' audio')) { $fileKey = trim(substr($key, 0, -6)); if ($fileKey !== '' && in_array(strtolower($value), ['1','yes','true','on'], true)) $data['video_audio'][$fileKey] = true; }
elseif ($key !== '') $data['captions'][$key] = $value;
}
return $data;
}
function write_data($dir, $post){
$title = trim($post['title'] ?? '');
$place = trim($post['place'] ?? '');
$date = trim($post['date'] ?? '');
$description = trim($post['description'] ?? '');
if (function_exists('mb_substr')) $description = mb_substr($description, 0, 5000, 'UTF-8'); else $description = substr($description, 0, 5000);
$lines = [];
if ($title !== '') $lines[] = 'Title: ' . $title;
if ($place !== '') $lines[] = 'Location: ' . $place;
if ($date !== '') $lines[] = 'Date: ' . $date;
if ($description !== '') {
$lines[] = 'Description: |';
$description = str_replace(["\r\n", "\r"], "\n", $description);
foreach (explode("\n", $description) as $descLine) $lines[] = ' ' . rtrim($descLine);
}
$useAudioFiles = $post['use_audio_files'] ?? [];
$useAudio = is_array($useAudioFiles) ? array_flip(array_map('basename', array_map('strval', $useAudioFiles))) : [];
foreach (array_keys($useAudio) as $fileName) if ($fileName !== '') $lines[] = $fileName . ' audio: yes';
$captionFiles = $post['caption_files'] ?? [];
$captions = $post['captions'] ?? [];
if (is_array($captionFiles) && is_array($captions)) {
foreach ($captionFiles as $i => $fileName) {
$fileName = basename((string)$fileName);
$caption = trim((string)($captions[$i] ?? ''));
if ($fileName !== '' && $caption !== '') $lines[] = $fileName . ': ' . $caption;
}
}
file_put_contents($dir . '/data.txt', implode("\n", $lines) . "\n");
}
function editable_file($f){ $ext = strtolower(pathinfo((string)$f, PATHINFO_EXTENSION)); return $f !== '' && $f[0] !== '.' && $f !== 'data.txt' && in_array($ext, ['jpg','jpeg','png','webp','gif','mp4','mov','m4v','avi','mkv','webm','mp3','wav','m4a','aac','ogg','flac'], true); }
function natural_file_compare($a, $b){ return strnatcasecmp((string)$a, (string)$b); }
function list_files($dir){ $files = array_values(array_filter(scandir($dir), fn($f)=>editable_file($f) && is_file($dir.'/'.$f))); usort($files, 'natural_file_compare'); return $files; }
function media_sort_files($dir, $files){
usort($files, function($a, $b) use ($dir) {
$da = media_file_date($dir . '/' . $a) ?? date('Ymd', filemtime($dir . '/' . $a));
$db = media_file_date($dir . '/' . $b) ?? date('Ymd', filemtime($dir . '/' . $b));
return $da === $db ? natural_file_compare($a, $b) : strcmp($da, $db);
});
return $files;
}
function media_file_date($path){
$ext = strtolower(pathinfo((string)$path, PATHINFO_EXTENSION));
if (in_array($ext, ['jpg','jpeg','tif','tiff'], true) && function_exists('exif_read_data')) {
$exif = @exif_read_data($path);
foreach (['DateTimeOriginal','DateTimeDigitized','DateTime'] as $k) {
if (!empty($exif[$k]) && preg_match('/^(\d{4}):(\d{2}):(\d{2})/', (string)$exif[$k], $m)) return "$m[1]$m[2]$m[3]";
}
}
if (in_array($ext, ['mp4','mov','m4v','avi','mkv','webm'], true)) {
$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=creation_time:stream_tags=creation_time '.escapeshellarg($path).' 2>/dev/null');
$data = json_decode((string)$json, true);
$candidates = [];
if (is_array($data['format']['tags'] ?? null)) $candidates[] = $data['format']['tags']['creation_time'] ?? '';
foreach (($data['streams'] ?? []) as $stream) if (is_array($stream['tags'] ?? null)) $candidates[] = $stream['tags']['creation_time'] ?? '';
foreach ($candidates as $value) {
if (preg_match('/^(\d{4})-(\d{2})-(\d{2})/', (string)$value, $m)) return "$m[1]$m[2]$m[3]";
}
}
}
return null;
}
function input_dir_date_from_media($dir){
$dates = [];
foreach (list_files($dir) as $file) {
$date = media_file_date($dir . '/' . $file);
if ($date !== null) $dates[] = $date;
}
if ($dates) {
sort($dates, SORT_STRING);
return $dates[0];
}
return date('Ymd');
}
function input_dir_date_from_text($text){
$text = trim((string)$text);
if ($text === '') return null;
if (preg_match('/^(\d{4})[.-]?(\d{2})[.-]?(\d{2})$/', $text, $m) && checkdate((int)$m[2], (int)$m[3], (int)$m[1])) return "$m[1]$m[2]$m[3]";
if (preg_match('/^(\d{1,2})[\/. -](\d{1,2})[\/. -](\d{4})$/', $text, $m) && checkdate((int)$m[2], (int)$m[1], (int)$m[3])) return sprintf('%04d%02d%02d', $m[3], $m[2], $m[1]);
$ts = strtotime($text);
if ($ts !== false) return date('Ymd', $ts);
if (preg_match('/^(jan|january|feb|february|mar|march|apr|april|may|jun|june|jul|july|aug|august|sep|sept|september|oct|october|nov|november|dec|december)\s+(\d{4})$/i', $text, $m)) {
$months = ['jan'=>1,'january'=>1,'feb'=>2,'february'=>2,'mar'=>3,'march'=>3,'apr'=>4,'april'=>4,'may'=>5,'jun'=>6,'june'=>6,'jul'=>7,'july'=>7,'aug'=>8,'august'=>8,'sep'=>9,'sept'=>9,'september'=>9,'oct'=>10,'october'=>10,'nov'=>11,'november'=>11,'dec'=>12,'december'=>12];
return sprintf('%04d%02d01', (int)$m[2], $months[strtolower($m[1])]);
}
return null;
}
function unique_input_dir($base, $slug){
$dir = $base . '/' . $slug;
if (!file_exists($dir)) return $dir;
for ($i = 2; ; $i++) {
$candidate = $base . '/' . $slug . '-' . $i;
if (!file_exists($candidate)) return $candidate;
}
}
function input_dir_signature($dir){
$entries = [];
foreach (scandir($dir) ?: [] as $file) {
if ($file === '.' || $file === '..' || in_array($file, ['.movmaker-state.json','.movmaker-enabled','.mvlog-hidden'], true)) continue;
if ($file === '.movmaker-lock') continue;
$path = $dir . '/' . $file;
if (is_file($path)) $entries[] = [$file, filesize($path), filemtime($path)];
}
sort($entries);
return hash('sha256', json_encode($entries, JSON_UNESCAPED_UNICODE));
}
function input_dir_info($dir){
$data = read_data($dir);
$files = list_files($dir);
return [
'name' => basename($dir),
'title' => $data['title'] ?: basename($dir),
'sort_date' => input_dir_date_from_text($data['date'] ?? '') ?? input_dir_date_from_media($dir),
'file_count' => count($files),
'signature' => input_dir_signature($dir),
];
}
function load_input_dir_cache($dirs){
$cacheFile = __DIR__ . '/cache/input-dirs.json';
$cache = is_file($cacheFile) ? json_decode((string)file_get_contents($cacheFile), true) : [];
if (!is_array($cache) || ($cache['version'] ?? 0) !== 1) $cache = ['version'=>1,'items'=>[]];
$oldItems = is_array($cache['items'] ?? null) ? $cache['items'] : [];
$items = []; $changed = false;
foreach ($dirs as $dir) {
$name = basename($dir);
$sig = input_dir_signature($dir);
$cached = $oldItems[$name] ?? null;
if (is_array($cached) && ($cached['signature'] ?? '') === $sig) {
$items[$name] = $cached;
} else {
$items[$name] = input_dir_info($dir);
$changed = true;
}
}
if (array_diff(array_keys($oldItems), array_keys($items))) $changed = true;
if ($changed) file_put_contents($cacheFile, json_encode(['version'=>1,'generated_at'=>date('c'),'items'=>$items], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT), LOCK_EX);
return $items;
}
function sort_input_dirs_by_metadata_date(&$dirs, $dirInfo){
usort($dirs, function($a, $b) use ($dirInfo) {
$ad = $dirInfo[basename($a)]['sort_date'] ?? '00000000';
$bd = $dirInfo[basename($b)]['sort_date'] ?? '00000000';
$cmp = strcmp($bd, $ad);
return $cmp !== 0 ? $cmp : strnatcasecmp(basename($b), basename($a));
});
}
function orphan_videos($config){
$referenced = [];
foreach (input_dirs($config['uploads_dir']) as $dir) {
$stateFile = $dir . '/.movmaker-state.json';
$state = is_file($stateFile) ? json_decode((string)file_get_contents($stateFile), true) : [];
if (is_array($state) && !empty($state['output'])) $referenced[basename((string)$state['output'])] = true;
}
$videos = glob($config['videos_dir'].'/*.{mp4,webm,mov,m4v}', GLOB_BRACE) ?: [];
return array_values(array_filter($videos, fn($v)=>empty($referenced[basename($v)])));
}
function input_dir_status($dir){
$stateFile = $dir . '/.movmaker-state.json';
$lockDir = $dir . '/.movmaker-lock';
$state = is_file($stateFile) ? json_decode((string)file_get_contents($stateFile), true) : [];
$status = is_array($state) ? (string)($state['status'] ?? '') : '';
if (is_dir($lockDir)) return $status !== '' ? $status : 'processing';
return in_array($status, ['processing', 'stale', 'error'], true) ? $status : '';
}
function input_dir_running($dir){
$stateFile = $dir . '/.movmaker-state.json';
$lockDir = $dir . '/.movmaker-lock';
$state = is_file($stateFile) ? json_decode((string)file_get_contents($stateFile), true) : [];
$status = is_array($state) ? (string)($state['status'] ?? '') : '';
return is_dir($lockDir) || $status === 'processing';
}
function input_dir_enabled($dir){ return is_file($dir . '/.movmaker-enabled'); }
function set_input_dir_enabled($dir, $enabled){
$path = $dir . '/.movmaker-enabled';
if ($enabled) { file_put_contents($path, "enabled\n"); chmod($path, 0664); }
elseif (is_file($path)) unlink($path);
}
function input_dir_visible($dir){ return !is_file($dir . '/.mvlog-hidden'); }
function set_input_dir_visible($dir, $visible){
$path = $dir . '/.mvlog-hidden';
if ($visible) { if (is_file($path)) unlink($path); }
else { file_put_contents($path, "hidden\n"); chmod($path, 0664); }
}
function input_dir_output($dir){
$stateFile = $dir . '/.movmaker-state.json';
$state = is_file($stateFile) ? json_decode((string)file_get_contents($stateFile), true) : [];
return is_array($state) ? basename((string)($state['output'] ?? '')) : '';
}
function cached_video_metadata($output, $fallback){
$meta = ['title'=>$fallback['title'] ?? '', 'date'=>$fallback['date'] ?? '', 'location'=>$fallback['place'] ?? '', 'description'=>$fallback['description'] ?? ''];
$cacheFile = __DIR__ . '/cache/videos.json';
$cache = is_file($cacheFile) ? json_decode((string)file_get_contents($cacheFile), true) : [];
$cached = $cache['items'][$output]['metadata'] ?? null;
if (is_array($cached)) {
foreach (['title','date','location','description'] as $k) if (!empty($cached[$k])) $meta[$k] = $cached[$k];
}
return $meta;
}
function paginate($items, $page, $perPage = 8){
$total = count($items);
$pages = max(1, (int)ceil($total / $perPage));
$page = max(1, min((int)$page, $pages));
return [$page, $pages, array_slice($items, ($page - 1) * $perPage, $perPage), $total];
}
function page_links($tab, $page, $pages){
if ($pages <= 1) return '';
$html = '
';
for ($i = 1; $i <= $pages; $i++) {
$class = $i === $page ? 'active' : '';
$html .= '
'.$i.'';
}
return $html . '
';
}
function save_uploads($field, $dest, $allowed){
if (empty($_FILES[$field])) return [];
$files = $_FILES[$field]; $saved = [];
$count = is_array($files['name']) ? count($files['name']) : 0;
for ($i=0; $i<$count; $i++) {
if ($files['error'][$i] === UPLOAD_ERR_NO_FILE) continue;
if ($files['error'][$i] !== UPLOAD_ERR_OK) throw new RuntimeException("Upload failed: ".$files['name'][$i]);
$ext = strtolower(pathinfo($files['name'][$i], PATHINFO_EXTENSION));
if (!in_array($ext, $allowed, true)) throw new RuntimeException("File type not allowed: ".$files['name'][$i]);
$base = preg_replace('/[^A-Za-z0-9._-]+/', '_', basename($files['name'][$i]));
$target = $dest . '/' . sprintf('%03d_', count(glob($dest.'/*') ?: [])+count($saved)+1) . $base;
if (!move_uploaded_file($files['tmp_name'][$i], $target)) throw new RuntimeException("Cannot save upload");
$saved[] = basename($target);
}
return $saved;
}
$msg = $_GET['msg'] ?? null; $err = null;
$allowedMedia = ['jpg','jpeg','png','webp','gif','mp4','mov','m4v','avi','mkv','webm'];
$allowedAudio = ['mp3','wav','m4a','aac','ogg','flac'];
try {
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$action = $_POST['action'] ?? 'create';
if ($action === 'create') {
$title = trim($_POST['title'] ?? '');
if ($title === '') throw new RuntimeException('Title is required.');
$tmpSlug = '.upload-' . date('YmdHis') . '-' . bin2hex(random_bytes(4));
$tmpDir = $config['uploads_dir'] . '/' . $tmpSlug;
if (!mkdir($tmpDir, 0775, true)) throw new RuntimeException('Cannot create temporary input directory.');
chmod($tmpDir, 02775);
$media = save_uploads('media', $tmpDir, $allowedMedia);
if (!$media) { rrmdir($tmpDir); throw new RuntimeException('Upload at least one image or video.'); }
save_uploads('audio', $tmpDir, $allowedAudio);
$mediaDate = input_dir_date_from_text($_POST['date'] ?? '') ?? input_dir_date_from_media($tmpDir);
$slug = $mediaDate . '_' . slugify($title);
$dir = unique_input_dir($config['uploads_dir'], $slug);
$slug = basename($dir);
if (!rename($tmpDir, $dir)) { rrmdir($tmpDir); throw new RuntimeException('Cannot create input directory.'); }
chmod($dir, 02775);
write_data($dir, $_POST);
set_input_dir_enabled($dir, false);
set_input_dir_visible($dir, false);
$msg = "Created movmaker input directory: in-dir/$slug";
$_GET['edit'] = $slug;
} elseif ($action === 'set_enabled') {
$dir = safe_input_dir($config['uploads_dir'], $_POST['dir'] ?? '');
$enabledNow = !empty($_POST['enabled']);
set_input_dir_enabled($dir, $enabledNow);
if (!empty($_POST['ajax'])) ajax_json(['ok'=>true, 'enabled'=>$enabledNow, 'dir'=>basename($dir)]);
header('Location: new.php?tab=edit&msg=' . rawurlencode(($enabledNow ? 'Enabled rendering for: in-dir/' : 'Disabled rendering for: in-dir/') . basename($dir)));
exit;
} elseif ($action === 'set_visible') {
$dir = safe_input_dir($config['uploads_dir'], $_POST['dir'] ?? '');
$visibleNow = !empty($_POST['visible']);
set_input_dir_visible($dir, $visibleNow);
if (!empty($_POST['ajax'])) ajax_json(['ok'=>true, 'visible'=>$visibleNow, 'dir'=>basename($dir)]);
header('Location: new.php?tab=edit&msg=' . rawurlencode(($visibleNow ? 'Shown: in-dir/' : 'Hidden: in-dir/') . basename($dir)));
exit;
} elseif ($action === 'update') {
$dir = safe_input_dir($config['uploads_dir'], $_POST['dir'] ?? '');
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);
save_uploads('media', $dir, $allowedMedia);
save_uploads('audio', $dir, $allowedAudio);
header('Location: new.php?tab=edit&msg=' . rawurlencode('Updated input directory: in-dir/' . basename($dir)));
exit;
} elseif ($action === 'delete_file') {
$dir = safe_input_dir($config['uploads_dir'], $_POST['dir'] ?? '');
if (input_dir_running($dir)) throw new RuntimeException('This input directory is being rendered. Editing is disabled until the job completes.');
$file = basename((string)($_POST['file'] ?? ''));
if (!editable_file($file) || !is_file($dir.'/'.$file)) throw new RuntimeException('Invalid file.');
unlink($dir.'/'.$file);
$msg = 'Removed file: ' . $file;
$_GET['edit'] = basename($dir);
} elseif ($action === 'delete_video') {
$file = basename((string)($_POST['video'] ?? ''));
$path = $config['videos_dir'] . '/' . $file;
if ($file === '' || !is_file($path)) throw new RuntimeException('Invalid video.');
unlink($path);
$cache = __DIR__ . '/cache/videos.json';
if (is_file($cache)) unlink($cache);
$msg = 'Deleted video: ' . $file;
} elseif ($action === 'delete_dir') {
$dir = safe_input_dir($config['uploads_dir'], $_POST['dir'] ?? '');
if (input_dir_running($dir)) throw new RuntimeException('This input directory is being rendered. Editing is disabled until the job completes.');
$name = basename($dir);
$stateFile = $dir . '/.movmaker-state.json';
$state = is_file($stateFile) ? json_decode((string)file_get_contents($stateFile), true) : [];
$output = is_array($state) ? basename((string)($state['output'] ?? '')) : '';
if (!empty($_POST['delete_output']) && $output !== '') {
$outPath = $config['videos_dir'] . '/' . $output;
if (is_file($outPath)) unlink($outPath);
$cache = __DIR__ . '/cache/videos.json';
if (is_file($cache)) unlink($cache);
}
rrmdir($dir);
header('Location: new.php?msg=' . rawurlencode('Deleted input directory: in-dir/' . $name));
exit;
}
}
} catch (Throwable $e) {
if ($_SERVER['REQUEST_METHOD'] === 'POST' && !empty($_POST['ajax'])) {
http_response_code(400);
ajax_json(['ok'=>false, 'error'=>$e->getMessage()]);
}
$err = $e->getMessage();
}
$dirs = input_dirs($config['uploads_dir']);
$dirInfo = load_input_dir_cache($dirs);
sort_input_dirs_by_metadata_date($dirs, $dirInfo);
$orphanVideos = orphan_videos($config);
$tab = $_GET['tab'] ?? (isset($_GET['edit']) ? 'edit' : 'new');
$page = max(1, (int)($_GET['page'] ?? 1));
[$editPage, $editPages, $dirsPage, $dirsTotal] = paginate($dirs, $tab === 'edit' ? $page : 1);
[$videoPage, $videoPages, $orphanVideosPage, $orphanVideosTotal] = paginate($orphanVideos, $tab === 'videos' ? $page : 1);
$editName = $_GET['edit'] ?? '';
$editDir = null; $editStatus = ''; $editRunning = false; $editData = ['title'=>'','date'=>'','place'=>'','description'=>'','captions'=>[],'video_audio'=>[]]; $editFiles = [];
if ($editName !== '') { try { $editDir = safe_input_dir($config['uploads_dir'], $editName); $editStatus = input_dir_status($editDir); $editRunning = input_dir_running($editDir); if (!$editRunning) { $editData = read_data($editDir); $editFiles = media_sort_files($editDir, list_files($editDir)); } } catch (Throwable $e) { $err = $e->getMessage(); } }
?>
Admin - =h($config['site_name'])?>
=h($msg)?>
=h($err)?>
Videos without input dir
No orphan videos.
=page_links('videos',$videoPage,$videoPages)?>
Existing input dirs
No input directories yet.
No video yet=h(ucfirst($runStatus))?>Edit =h($meta['title'] ?: ($info['title'] ?? basename($d)))?>
=h($meta['date'])?>=h($meta['location'])?>No video
=nl2br(h($meta['description']), false)?>
=h(basename($d))?> · =h($info['file_count'] ?? count(list_files($d)))?> files · = $enabled ? 'Rendering enabled' : 'Rendering disabled' ?> · = $visible ? 'Shown' : 'Hidden' ?>= $running ? ' · '.h(ucfirst($runStatus)) : '' ?>
=page_links('edit',$editPage,$editPages)?>
Edit input dir
in-dir/=h(basename($editDir))?>
This input directory is =h($editStatus)?>. Editing is disabled until the job completes.