Autosave: 20260419-011253
This commit is contained in:
parent
4424ee6f31
commit
f1be87d826
642
app.php
Normal file
642
app.php
Normal 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
@ -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…';
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
22
db/migrations/20260419_create_video_conversion_jobs.sql
Normal file
22
db/migrations/20260419_create_video_conversion_jobs.sql
Normal 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
29
download.php
Normal 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
24
healthz.php
Normal 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
508
index.php
@ -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
250
job.php
Normal 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
124
process_conversion.php
Normal 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.');
|
||||
BIN
var/converted/6860a83a8266dd38cb84b94e3c739295.mp4
Normal file
BIN
var/converted/6860a83a8266dd38cb84b94e3c739295.mp4
Normal file
Binary file not shown.
BIN
var/converted/e78a40e97113e5e506b51b1719fc5898.mp4
Normal file
BIN
var/converted/e78a40e97113e5e506b51b1719fc5898.mp4
Normal file
Binary file not shown.
BIN
var/uploads/6860a83a8266dd38cb84b94e3c739295.webm
Normal file
BIN
var/uploads/6860a83a8266dd38cb84b94e3c739295.webm
Normal file
Binary file not shown.
BIN
var/uploads/e78a40e97113e5e506b51b1719fc5898.webm
Normal file
BIN
var/uploads/e78a40e97113e5e506b51b1719fc5898.webm
Normal file
Binary file not shown.
Loading…
x
Reference in New Issue
Block a user