643 lines
24 KiB
PHP
643 lines
24 KiB
PHP
<?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',
|
||
};
|
||
}
|