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

643 lines
24 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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