39726-vm/index.php
2026-04-19 01:12:47 +00:00

389 lines
20 KiB
PHP

<?php
declare(strict_types=1);
require_once __DIR__ . '/app.php';
app_boot();
$meta = page_meta(project_name() . ' — Multi-format media converter', 'Convert WEBM to MP4, create social-ready MP4 exports, and switch subtitle files between SRT and VTT.');
$jobs = fetch_recent_jobs();
$tools = tool_catalog();
$notice = trim((string)($_GET['notice'] ?? ''));
$noticeType = trim((string)($_GET['type'] ?? 'info')) ?: 'info';
$ffmpegReady = ffmpeg_is_available();
$maxUpload = effective_upload_limit_mb();
$retentionHours = APP_RETENTION_HOURS;
$completedCount = count(array_filter($jobs, static fn(array $job): bool => ($job['status'] ?? '') === 'completed'));
$failedCount = count(array_filter($jobs, static fn(array $job): bool => ($job['status'] ?? '') === 'failed'));
$latestJob = $jobs[0] ?? null;
$toolConfig = [];
foreach ($tools as $key => $tool) {
$toolConfig[$key] = [
'label' => $tool['label'],
'description' => $tool['description'],
'accept' => tool_accept_attribute($key),
'acceptSummary' => $tool['accept_summary'],
'submitLabel' => $tool['submit_label'],
'requiresFfmpeg' => !empty($tool['requires_ffmpeg']),
'presets' => array_map(
static fn(array $preset): array => [
'label' => $preset['label'],
'description' => $preset['description'],
],
$tool['presets'] ?? []
),
];
}
?>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><?= h($meta['title']) ?></title>
<meta name="description" content="<?= h($meta['description']) ?>">
<?php if ($meta['description'] !== ''): ?>
<meta property="og:description" content="<?= h($meta['description']) ?>">
<meta property="twitter:description" content="<?= h($meta['description']) ?>">
<?php endif; ?>
<?php if ($meta['image'] !== ''): ?>
<meta property="og:image" content="<?= h($meta['image']) ?>">
<meta property="twitter:image" content="<?= h($meta['image']) ?>">
<?php endif; ?>
<meta property="og:title" content="<?= h($meta['title']) ?>">
<meta property="twitter:title" content="<?= h($meta['title']) ?>">
<meta name="twitter:card" content="summary_large_image">
<script>
(() => {
try {
const savedTheme = localStorage.getItem('fs-theme');
const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
const theme = savedTheme || (prefersDark ? 'dark' : 'light');
document.documentElement.setAttribute('data-theme', theme);
document.documentElement.setAttribute('data-bs-theme', theme);
} catch (error) {
document.documentElement.setAttribute('data-theme', 'light');
document.documentElement.setAttribute('data-bs-theme', 'light');
}
})();
</script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="/assets/css/custom.css?v=<?= time() ?>">
</head>
<body data-app-state="<?= $ffmpegReady ? 'ready' : 'offline' ?>" data-upload-limit-mb="<?= h((string)$maxUpload) ?>">
<nav class="navbar navbar-expand-lg app-nav sticky-top">
<div class="container">
<div class="d-flex align-items-center justify-content-between w-100 gap-3 flex-wrap">
<a class="navbar-brand d-flex align-items-center gap-2" href="/" aria-label="<?= h(project_name()) ?> home">
<span class="brand-mark">FS</span>
<span><?= h(project_name()) ?></span>
</a>
<div class="nav-actions d-flex align-items-center gap-2 gap-lg-3 small text-secondary">
<a class="nav-link px-0" href="#tools">Tools</a>
<a class="nav-link px-0" href="#convert">Convert</a>
<a class="nav-link px-0" href="#recent">Recent jobs</a>
<button class="btn btn-sm btn-soft theme-toggle" id="theme-toggle" type="button" aria-label="Toggle color theme" aria-pressed="false">
<span class="theme-toggle-icon" id="theme-toggle-icon" aria-hidden="true">🌙</span>
<span class="theme-toggle-label" id="theme-toggle-label">Dark mode</span>
</button>
<a class="nav-link nav-link-pill" href="/healthz.php">Health</a>
</div>
</div>
</div>
</nav>
<main class="py-4 py-lg-5">
<div class="page-orb orb-left" aria-hidden="true"></div>
<div class="page-orb orb-right" aria-hidden="true"></div>
<div class="container position-relative">
<?php if ($notice !== ''): ?>
<div class="alert alert-<?= h($noticeType) ?> app-alert shadow-sm" role="alert">
<?= h($notice) ?>
</div>
<?php endif; ?>
<section class="hero-shell mb-4 mb-lg-5">
<div class="row g-4 align-items-stretch">
<div class="col-xl-7">
<div class="hero-panel h-100">
<div class="hero-copy-block">
<span class="eyebrow">Sell more than a single codec swap</span>
<h1>One dashboard for video exports and subtitle conversions.</h1>
<p class="hero-copy">FormatShift now combines the original <strong>WEBM → MP4</strong> flow with <strong>social-ready MP4 presets</strong> and a lightweight <strong>SRT ↔ VTT</strong> subtitle utility, so the product feels closer to a creator toolkit than a commodity converter.</p>
</div>
<div class="hero-actions">
<a class="btn btn-dark btn-lg" href="#convert">Start a conversion</a>
<a class="btn btn-soft btn-lg" href="#recent">Review recent jobs</a>
</div>
<div class="hero-points mt-4">
<article class="point-card point-card-accent">
<div class="point-label">Tool bundle</div>
<div class="point-value">3 converters</div>
<p class="point-copy mb-0">Video compatibility, social presets, and subtitle reformatting.</p>
</article>
<article class="point-card">
<div class="point-label">Upload limit</div>
<div class="point-value">Up to <?= h((string)$maxUpload) ?> MB</div>
<p class="point-copy mb-0">Applies to video uploads and subtitle assets.</p>
</article>
<article class="point-card">
<div class="point-label">Retention</div>
<div class="point-value"><?= h((string)$retentionHours) ?> hours</div>
<p class="point-copy mb-0">Temporary files auto-clean so the VM stays lean.</p>
</article>
</div>
</div>
</div>
<div class="col-xl-5">
<aside class="app-card app-card-highlight h-100">
<div class="status-summary status-summary-<?= $ffmpegReady ? 'completed' : 'processing' ?> mb-4">
<div class="status-summary-label">Runtime snapshot</div>
<div class="status-summary-title"><?= $ffmpegReady ? 'Video tools online' : 'Video tools paused' ?></div>
<p class="mb-0"><?= $ffmpegReady ? 'FFmpeg is available, so the MP4-based tools are ready to run.' : 'FFmpeg is unavailable, so only subtitle conversions will succeed right now.' ?></p>
</div>
<div class="detail-grid compact-grid">
<div class="detail-item">
<span>Completed jobs</span>
<strong><?= h((string)$completedCount) ?></strong>
</div>
<div class="detail-item">
<span>Failed jobs</span>
<strong><?= h((string)$failedCount) ?></strong>
</div>
<div class="detail-item">
<span>Latest activity</span>
<strong><?= $latestJob ? h(job_tool_label($latestJob)) : '—' ?></strong>
</div>
<div class="detail-item">
<span>Health endpoint</span>
<strong><a href="/healthz.php">/healthz.php</a></strong>
</div>
</div>
</aside>
</div>
</div>
</section>
<section id="tools" class="mb-4 mb-lg-5">
<div class="card-header-row mb-3">
<div>
<h2 class="section-title mb-1">Conversion tools</h2>
<p class="section-subtitle mb-0">A tighter bundle for teams that need compatibility, publishing formats, and caption assets in one place.</p>
</div>
</div>
<div class="tool-grid">
<?php foreach ($tools as $toolKey => $tool): ?>
<article class="app-card tool-card <?= $toolKey === 'social_mp4' ? 'tool-card-accent' : '' ?>">
<div class="tool-card-header">
<div>
<div class="point-label"><?= h($tool['short_label']) ?></div>
<h3 class="tool-card-title"><?= h($tool['label']) ?></h3>
</div>
<span class="badge text-bg-light border"><?= h(implode(' / ', array_map('format_label', $tool['input_extensions']))) ?></span>
</div>
<p class="mb-3"><?= h($tool['description']) ?></p>
<?php if (!empty($tool['presets'])): ?>
<ul class="mini-steps ps-3 mb-0">
<?php foreach ($tool['presets'] as $preset): ?>
<li><strong><?= h($preset['label']) ?>:</strong> <?= h($preset['description']) ?></li>
<?php endforeach; ?>
</ul>
<?php else: ?>
<p class="form-help mb-0"><?= h($tool['accept_summary']) ?></p>
<?php endif; ?>
</article>
<?php endforeach; ?>
</div>
</section>
<section id="convert" class="mb-4 mb-lg-5">
<div class="row g-4 align-items-start">
<div class="col-lg-7">
<section class="app-card app-card-highlight">
<div class="card-header-row mb-4">
<div>
<h2 class="section-title mb-1">Run a conversion</h2>
<p class="section-subtitle mb-0">Pick the tool, choose any preset/output options, then upload the source file.</p>
</div>
</div>
<form id="converter-form" action="/process_conversion.php" method="post" enctype="multipart/form-data" novalidate>
<div class="field-stack">
<div>
<label class="form-label" for="tool_key">Converter type</label>
<select class="form-select form-select-lg" id="tool_key" name="tool_key">
<?php foreach ($tools as $toolKey => $tool): ?>
<option value="<?= h($toolKey) ?>"><?= h($tool['label']) ?></option>
<?php endforeach; ?>
</select>
<div class="form-help mt-2" id="tool-description"><?= h($tools['webm_mp4']['description']) ?></div>
<div class="form-help mt-1" id="tool-runtime-note"><?= $ffmpegReady ? 'Server runtime is ready for FFmpeg-backed video conversions.' : 'FFmpeg-backed video tools are currently offline; subtitle conversion still works.' ?></div>
</div>
<div id="preset-group" class="option-panel d-none" aria-live="polite">
<label class="form-label" for="preset_key">Social preset</label>
<select class="form-select" id="preset_key" name="preset_key">
<option value="">Choose a preset</option>
<?php foreach ($tools['social_mp4']['presets'] as $presetKey => $preset): ?>
<option value="<?= h($presetKey) ?>"><?= h($preset['label']) ?> — <?= h($preset['description']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div id="subtitle-target-group" class="option-panel d-none" aria-live="polite">
<label class="form-label" for="subtitle_target">Subtitle output</label>
<select class="form-select" id="subtitle_target" name="subtitle_target">
<option value="">Choose target format</option>
<?php foreach (subtitle_target_options() as $value => $label): ?>
<option value="<?= h($value) ?>"><?= h($label) ?></option>
<?php endforeach; ?>
</select>
<div class="form-help mt-2">Choose the opposite format of your upload, for example SRT → VTT or VTT → SRT.</div>
</div>
<div>
<label class="form-label" for="source_file">Source file</label>
<label class="dropzone-label" id="file-dropzone" for="source_file" tabindex="0" aria-describedby="file-input-copy file-input-meta">
<span class="dropzone-badge">Drag & drop</span>
<span class="dropzone-icon" aria-hidden="true">⇪</span>
<span class="dropzone-title" id="file-input-title">Drop your source file here</span>
<span class="dropzone-copy" id="file-input-copy">WEBM, MP4, MOV, SRT, or VTT depending on the selected tool.</span>
<span class="dropzone-meta" id="file-input-meta">or click to browse from your device · Up to <?= h((string)$maxUpload) ?> MB</span>
</label>
<input class="form-control d-none" type="file" id="source_file" name="source_file" accept="<?= h(tool_accept_attribute('webm_mp4')) ?>" required>
<div id="selected-file" class="selected-file d-none mt-3" aria-live="polite"></div>
</div>
</div>
<div class="d-flex flex-column flex-sm-row gap-2 mt-4">
<button class="btn btn-dark btn-lg" id="submit-button" type="submit" data-default-label="<?= h($tools['webm_mp4']['submit_label']) ?>"><?= h($tools['webm_mp4']['submit_label']) ?></button>
<a class="btn btn-soft btn-lg" href="#recent">See recent jobs</a>
</div>
</form>
</section>
</div>
<div class="col-lg-5">
<aside class="app-card h-100">
<h2 class="section-title mb-3">What this bundle sells better</h2>
<dl class="meta-list mb-4">
<div>
<dt>Core utility</dt>
<dd>WEBM → MP4 stays as the universal compatibility tool.</dd>
</div>
<div>
<dt>Upsell angle</dt>
<dd>Social presets turn a generic conversion app into a publishing workflow for agencies and growth teams.</dd>
</div>
<div>
<dt>Add-on utility</dt>
<dd>Subtitle format conversion supports accessibility and multi-channel publishing without external tools.</dd>
</div>
<div>
<dt>Storage policy</dt>
<dd>Jobs and files are retained for <?= h((string)$retentionHours) ?> hours before cleanup.</dd>
</div>
</dl>
<div class="mini-note">
<div class="mini-note-title">Helpful defaults</div>
<ul class="mini-steps mb-0 ps-3">
<li>Use <strong>WEBM → MP4</strong> for simple playback compatibility.</li>
<li>Use <strong>Social export presets</strong> for landscape, square, or vertical delivery.</li>
<li>Use <strong>Subtitle converter</strong> when captions need SRT or VTT output.</li>
</ul>
</div>
</aside>
</div>
</div>
</section>
<section id="recent">
<div class="card-header-row mb-3">
<div>
<h2 class="section-title mb-1">Recent jobs</h2>
<p class="section-subtitle mb-0">Each entry now shows which converter ran, which format changed, and whether a download is ready.</p>
</div>
</div>
<?php if ($jobs === []): ?>
<section class="app-card empty-state-card">
<h3 class="tool-card-title mb-2">No conversions yet</h3>
<p class="mb-0">Your first WEBM, social export, or subtitle conversion will show up here with a dedicated detail page and download action.</p>
</section>
<?php else: ?>
<div class="recent-job-grid">
<?php foreach ($jobs as $job): ?>
<article class="app-card recent-job-card">
<div class="recent-job-head">
<div>
<div class="point-label"><?= h(job_tool_label($job)) ?></div>
<h3 class="tool-card-title mb-1"><?= h(job_conversion_summary($job)) ?></h3>
<p class="form-help mb-0 text-break"><?= h((string)$job['original_name']) ?></p>
</div>
<span class="status-pill status-pill-<?= h(status_badge_class((string)$job['status'])) ?>"><?= h(ucfirst((string)$job['status'])) ?></span>
</div>
<div class="detail-grid compact-grid mt-3">
<div class="detail-item">
<span>Created</span>
<strong><?= h(format_datetime((string)$job['created_at'])) ?></strong>
</div>
<div class="detail-item">
<span>Input</span>
<strong><?= h(format_bytes(isset($job['input_size']) ? (int)$job['input_size'] : null)) ?></strong>
</div>
<div class="detail-item">
<span>Output</span>
<strong><?= h(format_bytes(isset($job['output_size']) ? (int)$job['output_size'] : null)) ?></strong>
</div>
<div class="detail-item">
<span>Download</span>
<strong><?= job_output_exists($job) ? h(job_download_label($job)) : 'Pending' ?></strong>
</div>
</div>
<div class="d-flex flex-column flex-sm-row gap-2 mt-3">
<a class="btn btn-soft" href="/job.php?id=<?= urlencode((string)$job['public_id']) ?>">View details</a>
<?php if (($job['status'] ?? '') === 'completed' && job_output_exists($job)): ?>
<a class="btn btn-dark" href="/download.php?id=<?= urlencode((string)$job['public_id']) ?>"><?= h(job_download_label($job)) ?></a>
<?php endif; ?>
</div>
</article>
<?php endforeach; ?>
</div>
<?php endif; ?>
</section>
</div>
</main>
<div id="loading-overlay" class="loading-overlay d-none" aria-hidden="true">
<div class="loading-card">
<div class="spinner-border text-light mb-3" role="status" aria-hidden="true"></div>
<div class="h5 mb-2">Processing your job</div>
<p class="mb-0">Large uploads and FFmpeg-based exports can take a little while on this VM.</p>
</div>
</div>
<div class="toast-container position-fixed bottom-0 end-0 p-3">
<div id="appToast" class="toast align-items-center border-0" role="status" aria-live="polite" aria-atomic="true">
<div class="d-flex">
<div class="toast-body">Ready.</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button>
</div>
</div>
</div>
<script>
window.formatShiftTools = <?= json_encode($toolConfig, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>;
</script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" defer></script>
<script src="/assets/js/main.js?v=<?= time() ?>" defer></script>
</body>
</html>