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', }; }