Autosave: 20260419-011253

This commit is contained in:
Flatlogic Bot 2026-04-19 01:12:47 +00:00
parent 4424ee6f31
commit f1be87d826
13 changed files with 2734 additions and 524 deletions

642
app.php Normal file
View File

@ -0,0 +1,642 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/db/config.php';
const APP_MAX_UPLOAD_MB = 512;
const APP_RETENTION_HOURS = 24;
const APP_UPLOAD_DIR = __DIR__ . '/var/uploads';
const APP_OUTPUT_DIR = __DIR__ . '/var/converted';
const APP_MIGRATION_FILE = __DIR__ . '/db/migrations/20260419_create_video_conversion_jobs.sql';
function project_name(): string
{
return trim((string)($_SERVER['PROJECT_NAME'] ?? 'FormatShift')) ?: 'FormatShift';
}
function project_description(): string
{
return trim((string)($_SERVER['PROJECT_DESCRIPTION'] ?? 'Server-side video, social export, and subtitle conversion tools.'))
?: 'Server-side video, social export, and subtitle conversion tools.';
}
function page_meta(string $fallbackTitle, string $fallbackDescription): array
{
return [
'title' => trim((string)($_SERVER['PROJECT_NAME'] ?? '')) ?: $fallbackTitle,
'description' => trim((string)($_SERVER['PROJECT_DESCRIPTION'] ?? '')) ?: $fallbackDescription,
'image' => trim((string)($_SERVER['PROJECT_IMAGE_URL'] ?? '')),
];
}
function base_url(): string
{
$scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
$host = $_SERVER['HTTP_HOST'] ?? 'localhost';
return $scheme . '://' . $host;
}
function ffmpeg_binary(): ?string
{
static $path = null;
if ($path !== null) {
return $path;
}
$output = [];
$code = 0;
@exec('command -v ffmpeg 2>/dev/null', $output, $code);
$resolved = trim((string)($output[0] ?? ''));
$path = ($code === 0 && $resolved !== '') ? $resolved : null;
return $path;
}
function ffmpeg_is_available(): bool
{
return ffmpeg_binary() !== null;
}
function ensure_runtime_dirs(): void
{
foreach ([APP_UPLOAD_DIR, APP_OUTPUT_DIR] as $dir) {
if (!is_dir($dir)) {
mkdir($dir, 0775, true);
}
}
}
function ensure_video_jobs_table(): void
{
static $ready = false;
if ($ready) {
return;
}
$sql = file_get_contents(APP_MIGRATION_FILE);
if ($sql === false) {
throw new RuntimeException('Failed to load migration SQL.');
}
db()->exec($sql);
ensure_video_job_columns();
$ready = true;
}
function ensure_video_job_columns(): void
{
$columns = [
'tool_key' => "VARCHAR(40) NOT NULL DEFAULT 'webm_mp4'",
'source_format' => 'VARCHAR(32) DEFAULT NULL',
'target_format' => 'VARCHAR(32) DEFAULT NULL',
'preset_key' => 'VARCHAR(40) DEFAULT NULL',
'output_mime' => 'VARCHAR(100) DEFAULT NULL',
'download_name' => 'VARCHAR(255) DEFAULT NULL',
];
foreach ($columns as $column => $definition) {
ensure_column_exists('video_jobs', $column, $definition);
}
}
function ensure_column_exists(string $table, string $column, string $definition): void
{
$stmt = db()->prepare('SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = :schema_name AND TABLE_NAME = :table_name AND COLUMN_NAME = :column_name');
$stmt->bindValue(':schema_name', DB_NAME, PDO::PARAM_STR);
$stmt->bindValue(':table_name', $table, PDO::PARAM_STR);
$stmt->bindValue(':column_name', $column, PDO::PARAM_STR);
$stmt->execute();
if ((int) $stmt->fetchColumn() > 0) {
return;
}
db()->exec(sprintf('ALTER TABLE `%s` ADD COLUMN `%s` %s', $table, $column, $definition));
}
function cleanup_expired_jobs(): void
{
static $done = false;
if ($done) {
return;
}
$done = true;
ensure_runtime_dirs();
ensure_video_jobs_table();
$stmt = db()->prepare('SELECT id, input_path, output_path FROM video_jobs WHERE created_at < (UTC_TIMESTAMP() - INTERVAL :hours HOUR)');
$stmt->bindValue(':hours', APP_RETENTION_HOURS, PDO::PARAM_INT);
$stmt->execute();
$jobs = $stmt->fetchAll();
foreach ($jobs as $job) {
foreach (['input_path', 'output_path'] as $field) {
$path = (string)($job[$field] ?? '');
if ($path !== '' && is_file($path)) {
@unlink($path);
}
}
}
$delete = db()->prepare('DELETE FROM video_jobs WHERE created_at < (UTC_TIMESTAMP() - INTERVAL :hours HOUR)');
$delete->bindValue(':hours', APP_RETENTION_HOURS, PDO::PARAM_INT);
$delete->execute();
}
function app_boot(): void
{
ensure_runtime_dirs();
ensure_video_jobs_table();
cleanup_expired_jobs();
}
function format_bytes(?int $bytes): string
{
if (!$bytes || $bytes < 1) {
return '—';
}
$units = ['B', 'KB', 'MB', 'GB'];
$power = min((int) floor(log($bytes, 1024)), count($units) - 1);
$value = $bytes / (1024 ** $power);
return number_format($value, $power === 0 ? 0 : 1) . ' ' . $units[$power];
}
function format_datetime(?string $value): string
{
if (!$value) {
return '—';
}
try {
return (new DateTimeImmutable($value, new DateTimeZone('UTC')))->format('M j, Y · H:i') . ' UTC';
} catch (Throwable $e) {
return $value;
}
}
function parse_ini_size_to_bytes(string $value): int
{
$value = trim($value);
if ($value === '') {
return 0;
}
$number = (float) $value;
$unit = strtolower(substr($value, -1));
return match ($unit) {
'g' => (int) round($number * 1024 * 1024 * 1024),
'm' => (int) round($number * 1024 * 1024),
'k' => (int) round($number * 1024),
default => (int) round($number),
};
}
function php_upload_limit_bytes(): int
{
$uploadMax = parse_ini_size_to_bytes((string) ini_get('upload_max_filesize'));
$postMax = parse_ini_size_to_bytes((string) ini_get('post_max_size'));
if ($uploadMax > 0 && $postMax > 0) {
return min($uploadMax, $postMax);
}
return max($uploadMax, $postMax);
}
function effective_upload_limit_bytes(): int
{
$appLimit = APP_MAX_UPLOAD_MB * 1024 * 1024;
$phpLimit = php_upload_limit_bytes();
if ($phpLimit > 0) {
return min($appLimit, $phpLimit);
}
return $appLimit;
}
function effective_upload_limit_mb(): int
{
return (int) ceil(effective_upload_limit_bytes() / (1024 * 1024));
}
function request_exceeds_upload_limit(): bool
{
$contentLength = (int) ($_SERVER['CONTENT_LENGTH'] ?? 0);
$limit = effective_upload_limit_bytes();
return $limit > 0 && $contentLength > $limit && empty($_FILES);
}
function h(?string $value): string
{
return htmlspecialchars((string) $value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
function format_label(?string $value): string
{
$value = strtoupper((string) $value);
return $value !== '' ? $value : '—';
}
function file_extension(string $filename): string
{
return strtolower((string) pathinfo($filename, PATHINFO_EXTENSION));
}
function status_badge_class(string $status): string
{
return match ($status) {
'completed' => 'success',
'failed' => 'danger',
default => 'warning',
};
}
function tool_catalog(): array
{
static $catalog = null;
if ($catalog !== null) {
return $catalog;
}
$catalog = [
'webm_mp4' => [
'label' => 'WEBM → MP4',
'short_label' => 'WEBM to MP4',
'description' => 'Convert a WEBM upload into a playback-friendly MP4 for decks, CMS uploads, and cross-device sharing.',
'input_extensions' => ['webm'],
'output_extension' => 'mp4',
'output_mime' => 'video/mp4',
'requires_ffmpeg' => true,
'submit_label' => 'Convert to MP4',
'accept_summary' => 'Accepts WEBM video files.',
'presets' => [],
],
'social_mp4' => [
'label' => 'Social export presets',
'short_label' => 'Social MP4',
'description' => 'Resize WEBM, MP4, or MOV clips into publish-ready MP4 exports for landscape, square, and vertical placements.',
'input_extensions' => ['webm', 'mp4', 'mov'],
'output_extension' => 'mp4',
'output_mime' => 'video/mp4',
'requires_ffmpeg' => true,
'submit_label' => 'Create social export',
'accept_summary' => 'Accepts WEBM, MP4, and MOV video files.',
'presets' => [
'landscape_16_9' => [
'label' => 'Landscape 16:9',
'description' => '1920 × 1080 for YouTube, decks, and standard ads.',
'suffix' => 'landscape-16x9',
'filter' => 'scale=w=1920:h=1080:force_original_aspect_ratio=decrease,pad=1920:1080:(ow-iw)/2:(oh-ih)/2:color=black,setsar=1',
],
'square_1_1' => [
'label' => 'Square 1:1',
'description' => '1080 × 1080 for feeds and mixed placements.',
'suffix' => 'square-1x1',
'filter' => 'scale=w=1080:h=1080:force_original_aspect_ratio=decrease,pad=1080:1080:(ow-iw)/2:(oh-ih)/2:color=black,setsar=1',
],
'vertical_9_16' => [
'label' => 'Vertical 9:16',
'description' => '1080 × 1920 for Reels, Shorts, and story placements.',
'suffix' => 'vertical-9x16',
'filter' => 'scale=w=1080:h=1920:force_original_aspect_ratio=decrease,pad=1080:1920:(ow-iw)/2:(oh-ih)/2:color=black,setsar=1',
],
],
],
'subtitle_convert' => [
'label' => 'Subtitle converter',
'short_label' => 'SRT ↔ VTT',
'description' => 'Convert subtitle assets between SRT and WebVTT without leaving the browser workflow.',
'input_extensions' => ['srt', 'vtt'],
'output_extension' => '',
'output_mime' => '',
'requires_ffmpeg' => false,
'submit_label' => 'Convert subtitles',
'accept_summary' => 'Accepts SRT and VTT subtitle files.',
'presets' => [],
],
];
return $catalog;
}
function tool_option(string $toolKey): array
{
$catalog = tool_catalog();
return $catalog[$toolKey] ?? $catalog['webm_mp4'];
}
function preset_option(string $toolKey, ?string $presetKey): ?array
{
if ($presetKey === null || $presetKey === '') {
return null;
}
$tool = tool_option($toolKey);
return $tool['presets'][$presetKey] ?? null;
}
function tool_accept_attribute(string $toolKey): string
{
$tool = tool_option($toolKey);
$extensions = array_map(static fn(string $extension): string => '.' . $extension, $tool['input_extensions']);
return implode(',', $extensions);
}
function tool_requires_ffmpeg(string $toolKey): bool
{
return !empty(tool_option($toolKey)['requires_ffmpeg']);
}
function subtitle_target_options(): array
{
return ['srt' => 'SRT', 'vtt' => 'VTT'];
}
function build_download_name(string $originalName, string $toolKey, string $targetFormat, ?string $presetKey = null): string
{
$base = preg_replace('/[^A-Za-z0-9._-]/', '-', (string) pathinfo($originalName, PATHINFO_FILENAME)) ?: 'converted-file';
$suffix = '';
if ($toolKey === 'social_mp4') {
$preset = preset_option($toolKey, $presetKey);
$suffix = '-' . ($preset['suffix'] ?? 'social-export');
} elseif ($toolKey === 'subtitle_convert') {
$suffix = '-converted';
}
return $base . $suffix . '.' . strtolower($targetFormat);
}
function fetch_recent_jobs(int $limit = 8): array
{
app_boot();
$stmt = db()->prepare('SELECT id, public_id, original_name, status, input_size, output_size, created_at, completed_at, tool_key, source_format, target_format, preset_key, output_mime, download_name FROM video_jobs ORDER BY id DESC LIMIT :limit');
$stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
$stmt->execute();
return $stmt->fetchAll();
}
function find_job(string $publicId): ?array
{
app_boot();
$stmt = db()->prepare('SELECT * FROM video_jobs WHERE public_id = :public_id LIMIT 1');
$stmt->bindValue(':public_id', $publicId, PDO::PARAM_STR);
$stmt->execute();
$job = $stmt->fetch();
return $job ?: null;
}
function notify_redirect(string $location, string $type, string $message): void
{
$qs = http_build_query(['notice' => $message, 'type' => $type]);
$separator = str_contains($location, '?') ? '&' : '?';
header('Location: ' . $location . $separator . $qs);
exit;
}
function create_job_record(
string $publicId,
string $originalName,
string $inputPath,
int $inputSize,
string $toolKey,
string $sourceFormat,
string $targetFormat,
?string $presetKey,
?string $outputMime,
string $downloadName
): int {
$stmt = db()->prepare('INSERT INTO video_jobs (public_id, original_name, input_path, input_size, status, tool_key, source_format, target_format, preset_key, output_mime, download_name) VALUES (:public_id, :original_name, :input_path, :input_size, :status, :tool_key, :source_format, :target_format, :preset_key, :output_mime, :download_name)');
$stmt->bindValue(':public_id', $publicId, PDO::PARAM_STR);
$stmt->bindValue(':original_name', $originalName, PDO::PARAM_STR);
$stmt->bindValue(':input_path', $inputPath, PDO::PARAM_STR);
$stmt->bindValue(':input_size', $inputSize, PDO::PARAM_INT);
$stmt->bindValue(':status', 'processing', PDO::PARAM_STR);
$stmt->bindValue(':tool_key', $toolKey, PDO::PARAM_STR);
$stmt->bindValue(':source_format', $sourceFormat, PDO::PARAM_STR);
$stmt->bindValue(':target_format', $targetFormat, PDO::PARAM_STR);
$stmt->bindValue(':preset_key', $presetKey, $presetKey !== null ? PDO::PARAM_STR : PDO::PARAM_NULL);
$stmt->bindValue(':output_mime', $outputMime, $outputMime !== null ? PDO::PARAM_STR : PDO::PARAM_NULL);
$stmt->bindValue(':download_name', $downloadName, PDO::PARAM_STR);
$stmt->execute();
return (int) db()->lastInsertId();
}
function mark_job_completed(int $id, string $outputPath, int $outputSize): void
{
$stmt = db()->prepare('UPDATE video_jobs SET status = :status, output_path = :output_path, output_size = :output_size, completed_at = UTC_TIMESTAMP(), error_message = NULL WHERE id = :id');
$stmt->bindValue(':status', 'completed', PDO::PARAM_STR);
$stmt->bindValue(':output_path', $outputPath, PDO::PARAM_STR);
$stmt->bindValue(':output_size', $outputSize, PDO::PARAM_INT);
$stmt->bindValue(':id', $id, PDO::PARAM_INT);
$stmt->execute();
}
function mark_job_failed(int $id, string $error): void
{
$stmt = db()->prepare('UPDATE video_jobs SET status = :status, error_message = :error_message WHERE id = :id');
$stmt->bindValue(':status', 'failed', PDO::PARAM_STR);
$stmt->bindValue(':error_message', mb_substr($error, 0, 1500), PDO::PARAM_STR);
$stmt->bindValue(':id', $id, PDO::PARAM_INT);
$stmt->execute();
}
function convert_media_to_mp4(string $inputPath, string $outputPath, ?string $videoFilter = null): array
{
$binary = ffmpeg_binary();
if (!$binary) {
return ['success' => false, 'message' => 'FFmpeg is not installed on this server yet.'];
}
$filterArg = $videoFilter ? ' -vf ' . escapeshellarg($videoFilter) : '';
$cmd = sprintf(
'%s -y -i %s%s -c:v libx264 -preset veryfast -crf 22 -movflags +faststart -pix_fmt yuv420p -c:a aac -b:a 128k %s 2>&1',
escapeshellarg($binary),
escapeshellarg($inputPath),
$filterArg,
escapeshellarg($outputPath)
);
$output = [];
$code = 0;
exec($cmd, $output, $code);
$log = trim(implode(PHP_EOL, $output));
if ($code !== 0 || !is_file($outputPath)) {
return ['success' => false, 'message' => $log !== '' ? $log : 'Conversion failed.'];
}
return ['success' => true, 'message' => 'Conversion completed.'];
}
function normalize_newlines(string $content): string
{
$content = preg_replace('/^/', '', $content) ?? $content;
return str_replace(["
", " "], "
", $content);
}
function convert_srt_content_to_vtt(string $content): string
{
$content = normalize_newlines($content);
$content = preg_replace('/(\d{2}:\d{2}:\d{2}),(\d{3})/', '$1.$2', $content) ?? $content;
return "WEBVTT
" . trim($content) . "
";
}
function convert_vtt_content_to_srt(string $content): string
{
$content = normalize_newlines($content);
$content = preg_replace('/^WEBVTT\s*/', '', $content) ?? $content;
$blocks = preg_split('/
{2,}/', trim($content)) ?: [];
$cues = [];
$counter = 1;
foreach ($blocks as $block) {
$lines = array_values(array_filter(explode("
", trim($block)), static fn(string $line): bool => trim($line) !== ''));
if ($lines === []) {
continue;
}
$headline = strtoupper(trim($lines[0]));
if (str_starts_with($headline, 'NOTE') || str_starts_with($headline, 'STYLE') || str_starts_with($headline, 'REGION')) {
continue;
}
if (count($lines) > 1 && !str_contains($lines[0], '-->') && str_contains($lines[1], '-->')) {
array_shift($lines);
}
$timecode = array_shift($lines);
if ($timecode === null || !str_contains($timecode, '-->')) {
continue;
}
$timecode = preg_replace('/(\d{2}:\d{2}:\d{2})\.(\d{3})/', '$1,$2', $timecode) ?? $timecode;
$cues[] = $counter . "
" . $timecode . "
" . implode("
", $lines);
$counter++;
}
return $cues !== [] ? implode("
", $cues) . "
" : '';
}
function convert_subtitle_file(string $inputPath, string $outputPath, string $sourceFormat, string $targetFormat): array
{
$content = file_get_contents($inputPath);
if ($content === false) {
return ['success' => false, 'message' => 'Could not read the uploaded subtitle file.'];
}
$converted = match (strtolower($sourceFormat) . ':' . strtolower($targetFormat)) {
'srt:vtt' => convert_srt_content_to_vtt($content),
'vtt:srt' => convert_vtt_content_to_srt($content),
default => null,
};
if ($converted === null || $converted === '') {
return ['success' => false, 'message' => 'Unsupported subtitle conversion request.'];
}
if (file_put_contents($outputPath, $converted) === false) {
return ['success' => false, 'message' => 'Could not write the converted subtitle file.'];
}
return ['success' => true, 'message' => 'Subtitle conversion completed.'];
}
function job_tool_key(array $job): string
{
$toolKey = (string)($job['tool_key'] ?? '');
return array_key_exists($toolKey, tool_catalog()) ? $toolKey : 'webm_mp4';
}
function job_source_format(array $job): string
{
$source = strtolower((string)($job['source_format'] ?? ''));
if ($source !== '') {
return $source;
}
return file_extension((string)($job['original_name'] ?? 'video.webm')) ?: 'webm';
}
function job_target_format(array $job): string
{
$target = strtolower((string)($job['target_format'] ?? ''));
if ($target !== '') {
return $target;
}
return 'mp4';
}
function job_tool_label(array $job): string
{
return tool_option(job_tool_key($job))['label'];
}
function job_preset_label(array $job): string
{
$preset = preset_option(job_tool_key($job), (string)($job['preset_key'] ?? ''));
return $preset['label'] ?? '';
}
function job_conversion_summary(array $job): string
{
$summary = format_label(job_source_format($job)) . ' → ' . format_label(job_target_format($job));
$presetLabel = job_preset_label($job);
if ($presetLabel !== '') {
$summary .= ' · ' . $presetLabel;
}
return $summary;
}
function job_download_label(array $job): string
{
return 'Download ' . format_label(job_target_format($job));
}
function job_output_exists(array $job): bool
{
return !empty($job['output_path']) && is_file((string) $job['output_path']);
}
function job_download_name(array $job): string
{
$downloadName = trim((string)($job['download_name'] ?? ''));
if ($downloadName !== '') {
return $downloadName;
}
return build_download_name(
(string)($job['original_name'] ?? 'converted-file'),
job_tool_key($job),
job_target_format($job),
(string)($job['preset_key'] ?? '')
);
}
function job_output_mime(array $job): string
{
$mime = trim((string)($job['output_mime'] ?? ''));
if ($mime !== '') {
return $mime;
}
return match (job_target_format($job)) {
'vtt' => 'text/vtt',
'srt' => 'application/x-subrip',
default => 'video/mp4',
};
}

File diff suppressed because it is too large Load Diff

View File

@ -1,39 +1,267 @@
document.addEventListener('DOMContentLoaded', () => {
const chatForm = document.getElementById('chat-form');
const chatInput = document.getElementById('chat-input');
const chatMessages = document.getElementById('chat-messages');
const form = document.getElementById('converter-form');
const input = document.getElementById('source_file') || document.getElementById('video_file');
const selectedFile = document.getElementById('selected-file');
const overlay = document.getElementById('loading-overlay');
const toastEl = document.getElementById('appToast');
const submitButton = document.getElementById('submit-button');
const themeToggle = document.getElementById('theme-toggle');
const themeLabel = document.getElementById('theme-toggle-label');
const themeIcon = document.getElementById('theme-toggle-icon');
const toolSelect = document.getElementById('tool_key');
const toolDescription = document.getElementById('tool-description');
const toolRuntimeNote = document.getElementById('tool-runtime-note');
const presetGroup = document.getElementById('preset-group');
const subtitleTargetGroup = document.getElementById('subtitle-target-group');
const dropzone = document.getElementById('file-dropzone');
const fileInputTitle = document.getElementById('file-input-title');
const fileInputCopy = document.getElementById('file-input-copy');
const fileInputMeta = document.getElementById('file-input-meta');
const appState = document.body?.dataset?.appState || 'ready';
const uploadLimitMb = Number(document.body?.dataset?.uploadLimitMb || 0);
const toolConfig = window.formatShiftTools || {};
const appendMessage = (text, sender) => {
const msgDiv = document.createElement('div');
msgDiv.classList.add('message', sender);
msgDiv.textContent = text;
chatMessages.appendChild(msgDiv);
chatMessages.scrollTop = chatMessages.scrollHeight;
const applyTheme = (theme) => {
document.documentElement.setAttribute('data-theme', theme);
document.documentElement.setAttribute('data-bs-theme', theme);
if (themeToggle) {
themeToggle.setAttribute('aria-pressed', theme === 'dark' ? 'true' : 'false');
}
if (themeLabel) {
themeLabel.textContent = theme === 'dark' ? 'Light mode' : 'Dark mode';
}
if (themeIcon) {
themeIcon.textContent = theme === 'dark' ? '☀️' : '🌙';
}
};
chatForm.addEventListener('submit', async (e) => {
e.preventDefault();
const message = chatInput.value.trim();
if (!message) return;
let currentTheme = 'light';
try {
const savedTheme = localStorage.getItem('fs-theme');
const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
currentTheme = savedTheme || (prefersDark ? 'dark' : 'light');
} catch (error) {
currentTheme = document.documentElement.getAttribute('data-theme') || 'light';
}
applyTheme(currentTheme);
appendMessage(message, 'visitor');
chatInput.value = '';
if (themeToggle) {
themeToggle.addEventListener('click', () => {
currentTheme = document.documentElement.getAttribute('data-theme') === 'dark' ? 'light' : 'dark';
applyTheme(currentTheme);
try {
localStorage.setItem('fs-theme', currentTheme);
} catch (error) {
// Ignore storage failures and keep the in-memory theme.
}
});
}
try {
const response = await fetch('api/chat.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message })
});
const data = await response.json();
// Artificial delay for realism
setTimeout(() => {
appendMessage(data.reply, 'bot');
}, 500);
} catch (error) {
console.error('Error:', error);
appendMessage("Sorry, something went wrong. Please try again.", 'bot');
const currentTool = () => {
if (!toolSelect) {
return null;
}
});
return toolConfig[toolSelect.value] || null;
};
const setDropzoneState = (state) => {
if (!dropzone) {
return;
}
dropzone.classList.toggle('is-dragging', state === 'dragging');
dropzone.classList.toggle('is-has-file', state === 'has-file');
};
const assignFiles = (files) => {
if (!input || !files || files.length === 0) {
return false;
}
if (typeof DataTransfer === 'undefined') {
return false;
}
const transfer = new DataTransfer();
Array.from(files).slice(0, 1).forEach((file) => transfer.items.add(file));
input.files = transfer.files;
return true;
};
const renderSelectedFile = () => {
if (!input || !selectedFile) {
return;
}
const file = input.files && input.files[0] ? input.files[0] : null;
if (!file) {
selectedFile.classList.add('d-none');
selectedFile.classList.remove('selected-file-warning');
selectedFile.textContent = '';
setDropzoneState('idle');
return;
}
const config = currentTool();
const sizeMb = file.size / (1024 * 1024);
const sizeLabel = sizeMb.toFixed(2);
const extension = file.name.includes('.') ? file.name.split('.').pop().toLowerCase() : '';
const allowedExtensions = (config?.accept || '').split(',').map((item) => item.replace('.', '').trim()).filter(Boolean);
const badExtension = allowedExtensions.length > 0 && extension !== '' && !allowedExtensions.includes(extension);
const exceedsLimit = uploadLimitMb > 0 && sizeMb > uploadLimitMb;
selectedFile.innerHTML = '';
const name = document.createElement('span');
name.className = 'selected-file-name';
name.textContent = file.name;
const meta = document.createElement('span');
meta.className = 'selected-file-meta';
if (badExtension) {
meta.textContent = `${sizeLabel} MB selected · Not supported for the current converter`;
} else if (exceedsLimit) {
meta.textContent = `${sizeLabel} MB selected · Above the ${uploadLimitMb} MB limit`;
} else {
meta.textContent = `${sizeLabel} MB selected · Ready for ${config?.label || 'conversion'}`;
}
selectedFile.appendChild(name);
selectedFile.appendChild(meta);
selectedFile.classList.toggle('selected-file-warning', badExtension || exceedsLimit);
selectedFile.classList.remove('d-none');
setDropzoneState('has-file');
if (fileInputTitle) {
fileInputTitle.textContent = `Replace file for ${config?.label || 'this converter'}`;
}
};
const syncToolUi = () => {
const config = currentTool();
if (!config) {
renderSelectedFile();
return;
}
if (input) {
input.setAttribute('accept', config.accept || '');
}
if (toolDescription) {
toolDescription.textContent = config.description || '';
}
if (toolRuntimeNote) {
if (config.requiresFfmpeg && appState !== 'ready') {
toolRuntimeNote.textContent = 'This tool needs FFmpeg, which is currently unavailable on the server.';
} else if (config.requiresFfmpeg) {
toolRuntimeNote.textContent = 'This tool runs through the server FFmpeg pipeline.';
} else {
toolRuntimeNote.textContent = 'This tool runs without FFmpeg, so it works even if the video runtime is offline.';
}
}
if (presetGroup) {
presetGroup.classList.toggle('d-none', !config.presets || Object.keys(config.presets).length === 0);
}
if (subtitleTargetGroup) {
subtitleTargetGroup.classList.toggle('d-none', toolSelect?.value !== 'subtitle_convert');
}
if (fileInputTitle && !(input?.files && input.files[0])) {
fileInputTitle.textContent = `Drop a file for ${config.label}`;
}
if (fileInputCopy) {
fileInputCopy.textContent = config.acceptSummary || 'Choose a supported file to continue.';
}
if (fileInputMeta) {
const limitCopy = uploadLimitMb > 0 ? `Up to ${uploadLimitMb} MB` : 'Any file size supported by the server';
fileInputMeta.textContent = `or click to browse from your device · ${limitCopy}`;
}
if (submitButton) {
submitButton.dataset.defaultLabel = config.submitLabel || 'Convert file';
submitButton.textContent = config.submitLabel || 'Convert file';
}
renderSelectedFile();
};
if (toastEl && window.bootstrap) {
const toastBody = toastEl.querySelector('.toast-body');
if (toastBody) {
const config = currentTool();
if (appState === 'ready') {
toastBody.textContent = config ? `Ready for ${config.label}.` : 'Ready to convert files.';
} else {
toastBody.textContent = 'Video tools are paused because FFmpeg is unavailable, but subtitle conversion still works.';
}
}
const toast = new bootstrap.Toast(toastEl);
toast.show();
}
if (toolSelect) {
toolSelect.addEventListener('change', syncToolUi);
syncToolUi();
}
if (input && selectedFile) {
input.addEventListener('change', renderSelectedFile);
}
if (dropzone && input) {
['dragenter', 'dragover'].forEach((eventName) => {
dropzone.addEventListener(eventName, (event) => {
event.preventDefault();
event.stopPropagation();
setDropzoneState('dragging');
});
});
['dragleave', 'dragend'].forEach((eventName) => {
dropzone.addEventListener(eventName, (event) => {
event.preventDefault();
event.stopPropagation();
setDropzoneState(input.files && input.files[0] ? 'has-file' : 'idle');
});
});
dropzone.addEventListener('drop', (event) => {
event.preventDefault();
event.stopPropagation();
const files = event.dataTransfer?.files;
const assigned = assignFiles(files);
setDropzoneState(assigned ? 'has-file' : 'idle');
if (assigned) {
renderSelectedFile();
}
});
dropzone.addEventListener('keydown', (event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
input.click();
}
});
}
if (form && overlay) {
form.addEventListener('submit', () => {
overlay.classList.remove('d-none');
overlay.setAttribute('aria-hidden', 'false');
if (submitButton) {
submitButton.disabled = true;
submitButton.textContent = 'Uploading and processing…';
}
});
}
});

View File

@ -0,0 +1,22 @@
CREATE TABLE IF NOT EXISTS video_jobs (
id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
public_id CHAR(36) NOT NULL UNIQUE,
original_name VARCHAR(255) NOT NULL,
input_path VARCHAR(255) NOT NULL,
output_path VARCHAR(255) DEFAULT NULL,
status VARCHAR(24) NOT NULL DEFAULT 'processing',
error_message TEXT DEFAULT NULL,
input_size BIGINT UNSIGNED DEFAULT NULL,
output_size BIGINT UNSIGNED DEFAULT NULL,
tool_key VARCHAR(40) NOT NULL DEFAULT 'webm_mp4',
source_format VARCHAR(32) DEFAULT NULL,
target_format VARCHAR(32) DEFAULT NULL,
preset_key VARCHAR(40) DEFAULT NULL,
output_mime VARCHAR(100) DEFAULT NULL,
download_name VARCHAR(255) DEFAULT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
completed_at DATETIME DEFAULT NULL,
INDEX idx_video_jobs_created_at (created_at),
INDEX idx_video_jobs_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

29
download.php Normal file
View File

@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/app.php';
app_boot();
$jobId = trim((string)($_GET['id'] ?? ''));
$job = $jobId !== '' ? find_job($jobId) : null;
if (!$job || ($job['status'] ?? '') !== 'completed' || !job_output_exists($job)) {
http_response_code(404);
header('Content-Type: text/plain; charset=utf-8');
echo 'File not found.';
exit;
}
$downloadName = preg_replace('/[^A-Za-z0-9._-]/', '-', job_download_name($job)) ?: 'converted-file.' . job_target_format($job);
$filePath = (string) $job['output_path'];
$contentLength = filesize($filePath);
$mime = job_output_mime($job);
header('Content-Type: ' . $mime);
if ($contentLength !== false) {
header('Content-Length: ' . $contentLength);
}
header('Content-Disposition: attachment; filename="' . $downloadName . '"');
header('X-Content-Type-Options: nosniff');
readfile($filePath);
exit;

24
healthz.php Normal file
View File

@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/app.php';
$checks = [
'php' => PHP_VERSION,
'ffmpeg' => ffmpeg_is_available() ? 'available' : 'missing',
'database' => 'pending',
'time' => gmdate('c'),
];
$statusCode = 200;
try {
app_boot();
$checks['database'] = 'connected';
} catch (Throwable $e) {
$checks['database'] = 'error';
$checks['error'] = $e->getMessage();
$statusCode = 500;
}
http_response_code($statusCode);
header('Content-Type: application/json; charset=utf-8');
echo json_encode($checks, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);

508
index.php
View File

@ -1,150 +1,388 @@
<?php
declare(strict_types=1);
@ini_set('display_errors', '1');
@error_reporting(E_ALL);
@date_default_timezone_set('UTC');
$phpVersion = PHP_VERSION;
$now = date('Y-m-d H:i:s');
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>New Style</title>
<?php
// Read project preview data from environment
$projectDescription = $_SERVER['PROJECT_DESCRIPTION'] ?? '';
$projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? '';
?>
<?php if ($projectDescription): ?>
<!-- Meta description -->
<meta name="description" content='<?= htmlspecialchars($projectDescription) ?>' />
<!-- Open Graph meta tags -->
<meta property="og:description" content="<?= htmlspecialchars($projectDescription) ?>" />
<!-- Twitter meta tags -->
<meta property="twitter:description" content="<?= htmlspecialchars($projectDescription) ?>" />
<?php endif; ?>
<?php if ($projectImageUrl): ?>
<!-- Open Graph image -->
<meta property="og:image" content="<?= htmlspecialchars($projectImageUrl) ?>" />
<!-- Twitter image -->
<meta property="twitter:image" content="<?= htmlspecialchars($projectImageUrl) ?>" />
<?php endif; ?>
<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;700&display=swap" rel="stylesheet">
<style>
:root {
--bg-color-start: #6a11cb;
--bg-color-end: #2575fc;
--text-color: #ffffff;
--card-bg-color: rgba(255, 255, 255, 0.01);
--card-border-color: rgba(255, 255, 255, 0.1);
}
body {
margin: 0;
font-family: 'Inter', sans-serif;
background: linear-gradient(45deg, var(--bg-color-start), var(--bg-color-end));
color: var(--text-color);
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
text-align: center;
overflow: hidden;
position: relative;
}
body::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100"><path d="M-10 10L110 10M10 -10L10 110" stroke-width="1" stroke="rgba(255,255,255,0.05)"/></svg>');
animation: bg-pan 20s linear infinite;
z-index: -1;
}
@keyframes bg-pan {
0% { background-position: 0% 0%; }
100% { background-position: 100% 100%; }
}
main {
padding: 2rem;
}
.card {
background: var(--card-bg-color);
border: 1px solid var(--card-border-color);
border-radius: 16px;
padding: 2rem;
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.1);
}
.loader {
margin: 1.25rem auto 1.25rem;
width: 48px;
height: 48px;
border: 3px solid rgba(255, 255, 255, 0.25);
border-top-color: #fff;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.hint {
opacity: 0.9;
}
.sr-only {
position: absolute;
width: 1px; height: 1px;
padding: 0; margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap; border: 0;
}
h1 {
font-size: 3rem;
font-weight: 700;
margin: 0 0 1rem;
letter-spacing: -1px;
}
p {
margin: 0.5rem 0;
font-size: 1.1rem;
}
code {
background: rgba(0,0,0,0.2);
padding: 2px 6px;
border-radius: 4px;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
footer {
position: absolute;
bottom: 1rem;
font-size: 0.8rem;
opacity: 0.7;
}
</style>
<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>
<main>
<div class="card">
<h1>Analyzing your requirements and generating your website…</h1>
<div class="loader" role="status" aria-live="polite" aria-label="Applying initial changes">
<span class="sr-only">Loading…</span>
<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>
<p class="hint"><?= ($_SERVER['HTTP_HOST'] ?? '') === 'appwizzy.com' ? 'AppWizzy' : 'Flatlogic' ?> AI is collecting your requirements and applying the first changes.</p>
<p class="hint">This page will update automatically as the plan is implemented.</p>
<p>Runtime: PHP <code><?= htmlspecialchars($phpVersion) ?></code> — UTC <code><?= htmlspecialchars($now) ?></code></p>
</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>
<footer>
Page updated: <?= htmlspecialchars($now) ?> (UTC)
</footer>
<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>

250
job.php Normal file
View File

@ -0,0 +1,250 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/app.php';
app_boot();
$jobId = trim((string)($_GET['id'] ?? ''));
$job = $jobId !== '' ? find_job($jobId) : null;
$notice = trim((string)($_GET['notice'] ?? ''));
$noticeType = trim((string)($_GET['type'] ?? 'info')) ?: 'info';
$toolLabel = $job ? job_tool_label($job) : 'Conversion';
$conversionSummary = $job ? job_conversion_summary($job) : 'Job summary';
$meta = page_meta(project_name() . ' — ' . $toolLabel . ' job', 'Review the result, status, and download link for a completed conversion job.');
$statusTitle = 'Conversion in progress';
$statusCopy = 'This job is still processing. Refresh the page in a few seconds if needed.';
if ($job) {
if (($job['status'] ?? '') === 'completed') {
$statusTitle = 'Download ready';
$statusCopy = 'The conversion finished successfully and the output file is ready for download.';
} elseif (($job['status'] ?? '') === 'failed') {
$statusTitle = 'Conversion failed';
$statusCopy = 'The job did not finish cleanly. Review the error details below and try again.';
}
}
?>
<!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="<?= ffmpeg_is_available() ? 'ready' : 'offline' ?>" data-upload-limit-mb="<?= h((string)effective_upload_limit_mb()) ?>">
<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="/">Dashboard</a>
<a class="nav-link px-0" href="/healthz.php">Health</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>
</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; ?>
<?php if (!$job): ?>
<section class="app-card empty-state-card">
<h1 class="detail-title">Job not found</h1>
<p class="hero-copy mb-4">The requested conversion record is missing or has already expired from the retention window.</p>
<a class="btn btn-dark" href="/">Back to dashboard</a>
</section>
<?php else: ?>
<section class="hero-shell mb-4">
<div class="row g-4 align-items-stretch">
<div class="col-xl-8">
<div class="hero-panel h-100">
<span class="eyebrow"><?= h($toolLabel) ?></span>
<h1 class="detail-title"><?= h($conversionSummary) ?></h1>
<p class="hero-copy mb-4 text-break">Original file: <strong><?= h((string)$job['original_name']) ?></strong></p>
<div class="hero-actions">
<?php if (($job['status'] ?? '') === 'completed' && job_output_exists($job)): ?>
<a class="btn btn-dark btn-lg" href="/download.php?id=<?= urlencode((string)$job['public_id']) ?>"><?= h(job_download_label($job)) ?></a>
<?php endif; ?>
<a class="btn btn-soft btn-lg" href="/">Run another conversion</a>
</div>
</div>
</div>
<div class="col-xl-4">
<aside class="app-card app-card-highlight h-100">
<div class="status-summary status-summary-<?= h((string)$job['status']) ?>">
<div class="status-summary-label">Current state</div>
<div class="status-summary-title"><?= h($statusTitle) ?></div>
<p class="mb-0"><?= h($statusCopy) ?></p>
</div>
</aside>
</div>
</div>
</section>
<div class="row g-4">
<div class="col-lg-8">
<section class="app-card mb-4">
<div class="card-header-row mb-4">
<div>
<h2 class="section-title mb-1">Job metrics</h2>
<p class="section-subtitle mb-0">A concise view of the source, output, preset, and runtime status for this job.</p>
</div>
</div>
<div class="detail-grid">
<div class="detail-item">
<span>Converter</span>
<strong><?= h($toolLabel) ?></strong>
</div>
<div class="detail-item">
<span>Formats</span>
<strong><?= h($conversionSummary) ?></strong>
</div>
<div class="detail-item">
<span>Input size</span>
<strong><?= h(format_bytes(isset($job['input_size']) ? (int)$job['input_size'] : null)) ?></strong>
</div>
<div class="detail-item">
<span>Output size</span>
<strong><?= h(format_bytes(isset($job['output_size']) ? (int)$job['output_size'] : null)) ?></strong>
</div>
<div class="detail-item">
<span>Started</span>
<strong><?= h(format_datetime((string)$job['created_at'])) ?></strong>
</div>
<div class="detail-item">
<span>Completed</span>
<strong><?= h(format_datetime((string)($job['completed_at'] ?? ''))) ?></strong>
</div>
</div>
<?php if (($job['status'] ?? '') === 'completed' && job_output_exists($job)): ?>
<div class="download-panel mt-4">
<div class="download-panel-copy">
<h2 class="section-title mb-2"><?= h(job_download_label($job)) ?></h2>
<p class="mb-0">The output file is available now. Downloads remain available until the <?= h((string)APP_RETENTION_HOURS) ?>-hour retention window expires.</p>
</div>
<div class="d-flex flex-column flex-sm-row gap-2 mt-3">
<a class="btn btn-dark" href="/download.php?id=<?= urlencode((string)$job['public_id']) ?>"><?= h(job_download_label($job)) ?></a>
<a class="btn btn-soft" href="/">Go back to dashboard</a>
</div>
</div>
<?php elseif (($job['status'] ?? '') === 'failed'): ?>
<div class="alert alert-danger mt-4 mb-0 app-alert-block">
<strong>Conversion failed.</strong>
<div class="small mt-2"><?= nl2br(h((string)($job['error_message'] ?? 'The converter did not finish successfully.'))) ?></div>
</div>
<?php else: ?>
<div class="alert alert-warning mt-4 mb-0 app-alert-block">
This job is still marked as processing. Refresh the page in a few seconds if needed.
</div>
<?php endif; ?>
</section>
<section class="workflow-grid workflow-grid-2">
<article class="app-card flow-card">
<span class="flow-step">A</span>
<h2 class="section-title mb-2">Input captured</h2>
<p class="mb-0">The source file and requested tool settings were stored before processing started, so the result can be tracked even if the run fails.</p>
</article>
<article class="app-card flow-card">
<span class="flow-step">B</span>
<h2 class="section-title mb-2">Output retained</h2>
<p class="mb-0">Generated files stay available for <?= h((string)APP_RETENTION_HOURS) ?> hours, after which cleanup removes the temporary artifacts.</p>
</article>
</section>
</div>
<div class="col-lg-4">
<aside class="app-card h-100">
<h2 class="section-title mb-3">Run details</h2>
<dl class="meta-list mb-4">
<div>
<dt>Original file</dt>
<dd class="text-break"><?= h((string)$job['original_name']) ?></dd>
</div>
<div>
<dt>Status</dt>
<dd><?= h(ucfirst((string)$job['status'])) ?></dd>
</div>
<div>
<dt>Preset</dt>
<dd><?= h(job_preset_label($job) !== '' ? job_preset_label($job) : '—') ?></dd>
</div>
<div>
<dt>Download name</dt>
<dd class="text-break"><?= h(job_download_name($job)) ?></dd>
</div>
</dl>
<div class="mini-note">
<div class="mini-note-title">Next move</div>
<ul class="mini-steps mb-0 ps-3">
<?php if (($job['status'] ?? '') === 'completed'): ?>
<li>Download the generated file while it is still within the retention window.</li>
<li>Return to the dashboard if you want to run another tool or preset.</li>
<?php elseif (($job['status'] ?? '') === 'failed'): ?>
<li>Review the error output above for clues.</li>
<li>Try a different source file, output choice, or a smaller upload.</li>
<?php else: ?>
<li>Refresh the page after a short wait.</li>
<li>If it stays stuck, start a fresh run from the dashboard.</li>
<?php endif; ?>
</ul>
</div>
</aside>
</div>
</div>
<?php endif; ?>
</div>
</main>
<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>

124
process_conversion.php Normal file
View File

@ -0,0 +1,124 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/app.php';
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
header('Location: /');
exit;
}
try {
app_boot();
} catch (Throwable $e) {
notify_redirect('/', 'danger', 'The converter is not ready yet. Please try again in a moment.');
}
if (request_exceeds_upload_limit()) {
notify_redirect('/', 'warning', 'That upload is larger than the current ' . effective_upload_limit_mb() . ' MB limit. Please choose a smaller file.');
}
$toolKey = trim((string)($_POST['tool_key'] ?? 'webm_mp4'));
$catalog = tool_catalog();
if (!isset($catalog[$toolKey])) {
notify_redirect('/', 'warning', 'Please choose a supported conversion tool.');
}
$tool = $catalog[$toolKey];
if (!empty($tool['requires_ffmpeg']) && !ffmpeg_is_available()) {
notify_redirect('/', 'warning', 'FFmpeg is not available on the server yet, so this conversion tool is temporarily offline.');
}
$file = $_FILES['source_file'] ?? $_FILES['video_file'] ?? null;
if (!is_array($file)) {
notify_redirect('/', 'warning', 'Please choose a file to convert.');
}
$errorCode = (int) ($file['error'] ?? UPLOAD_ERR_NO_FILE);
if (in_array($errorCode, [UPLOAD_ERR_INI_SIZE, UPLOAD_ERR_FORM_SIZE], true)) {
notify_redirect('/', 'warning', 'That upload is larger than the current ' . effective_upload_limit_mb() . ' MB limit. Please choose a smaller file.');
}
if ($errorCode !== UPLOAD_ERR_OK) {
notify_redirect('/', 'danger', 'Upload failed. Please try another file.');
}
$originalName = (string) ($file['name'] ?? 'upload.bin');
$safeOriginalName = preg_replace('/[^A-Za-z0-9._-]/', '-', $originalName) ?: 'upload.bin';
$extension = file_extension($safeOriginalName);
if (!in_array($extension, $tool['input_extensions'], true)) {
notify_redirect('/', 'warning', 'That file type is not supported for ' . $tool['label'] . '.');
}
$size = (int) ($file['size'] ?? 0);
if ($size < 1) {
notify_redirect('/', 'warning', 'The selected file looks empty. Please upload a valid file.');
}
if ($size > effective_upload_limit_bytes()) {
notify_redirect('/', 'warning', 'That upload is larger than the current ' . effective_upload_limit_mb() . ' MB limit. Please choose a smaller file.');
}
$finfo = new finfo(FILEINFO_MIME_TYPE);
$mime = strtolower((string) $finfo->file((string) $file['tmp_name']));
$allowedMimes = match ($toolKey) {
'webm_mp4' => ['video/webm', 'application/octet-stream'],
'social_mp4' => ['video/webm', 'video/mp4', 'application/mp4', 'video/quicktime', 'application/octet-stream'],
'subtitle_convert' => ['text/plain', 'text/vtt', 'application/octet-stream', 'application/x-subrip'],
default => ['application/octet-stream'],
};
if (!in_array($mime, $allowedMimes, true)) {
notify_redirect('/', 'warning', 'The uploaded file does not match the expected file type for this converter.');
}
$targetFormat = 'mp4';
$presetKey = null;
$outputMime = $tool['output_mime'] !== '' ? $tool['output_mime'] : null;
if ($toolKey === 'social_mp4') {
$presetKey = trim((string)($_POST['preset_key'] ?? ''));
if ($presetKey === '' || preset_option($toolKey, $presetKey) === null) {
notify_redirect('/', 'warning', 'Please choose a social export preset.');
}
}
if ($toolKey === 'subtitle_convert') {
$requestedTarget = strtolower(trim((string)($_POST['subtitle_target'] ?? '')));
$allowedTargets = array_keys(subtitle_target_options());
if (!in_array($requestedTarget, $allowedTargets, true)) {
notify_redirect('/', 'warning', 'Please choose whether to export to SRT or VTT.');
}
if ($requestedTarget === $extension) {
notify_redirect('/', 'warning', 'Please choose the opposite subtitle format so the file is actually converted.');
}
$targetFormat = $requestedTarget;
$outputMime = $targetFormat === 'vtt' ? 'text/vtt' : 'application/x-subrip';
}
$publicId = bin2hex(random_bytes(16));
$inputPath = APP_UPLOAD_DIR . '/' . $publicId . '.' . $extension;
$outputPath = APP_OUTPUT_DIR . '/' . $publicId . '.' . $targetFormat;
if (!move_uploaded_file((string) $file['tmp_name'], $inputPath)) {
notify_redirect('/', 'danger', 'We could not save the uploaded file. Please try again.');
}
$downloadName = build_download_name($originalName, $toolKey, $targetFormat, $presetKey);
$jobId = create_job_record($publicId, $originalName, $inputPath, $size, $toolKey, $extension, $targetFormat, $presetKey, $outputMime, $downloadName);
$result = match ($toolKey) {
'webm_mp4' => convert_media_to_mp4($inputPath, $outputPath),
'social_mp4' => convert_media_to_mp4($inputPath, $outputPath, (string) preset_option($toolKey, $presetKey)['filter']),
'subtitle_convert' => convert_subtitle_file($inputPath, $outputPath, $extension, $targetFormat),
default => ['success' => false, 'message' => 'Unsupported conversion request.'],
};
if (!empty($result['success']) && is_file($outputPath)) {
mark_job_completed($jobId, $outputPath, (int) filesize($outputPath));
notify_redirect('/job.php?id=' . urlencode($publicId), 'success', $tool['label'] . ' completed. Your download is ready.');
}
mark_job_failed($jobId, (string) ($result['message'] ?? 'Conversion failed.'));
notify_redirect('/job.php?id=' . urlencode($publicId), 'danger', $tool['label'] . ' failed. Check the job details for more information.');

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.