diff --git a/app.php b/app.php new file mode 100644 index 0000000..c4e028d --- /dev/null +++ b/app.php @@ -0,0 +1,642 @@ + 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', + }; + } diff --git a/assets/css/custom.css b/assets/css/custom.css index 789132e..268ab04 100644 --- a/assets/css/custom.css +++ b/assets/css/custom.css @@ -1,403 +1,1056 @@ +:root { + --bg: #f5f6f7; + --surface: rgba(255, 255, 255, 0.94); + --surface-alt: #f8fafc; + --surface-strong: #eef2f6; + --border: #d9dde3; + --border-strong: #bcc4cf; + --text: #14171a; + --muted: #5c6672; + --accent: #111111; + --accent-soft: #1f7a4d; + --success: #1f7a4d; + --warning: #8a5a00; + --danger: #b42318; + --body-gradient: radial-gradient(circle at top left, rgba(31, 122, 77, 0.10), transparent 28%), + radial-gradient(circle at top right, rgba(17, 17, 17, 0.06), transparent 22%), + linear-gradient(180deg, #f7f8fa 0%, #f1f4f7 100%); + --nav-bg: rgba(245, 246, 247, 0.82); + --nav-border: rgba(217, 221, 227, 0.82); + --panel-elevated: linear-gradient(180deg, rgba(255, 255, 255, 0.97), rgba(246, 249, 252, 0.97)); + --soft-card: rgba(255, 255, 255, 0.7); + --field-bg: #ffffff; + --footer-bg: rgba(245, 246, 247, 0.55); + --overlay-bg: rgba(20, 23, 26, 0.28); + --toast-bg: #111111; + --toast-text: #ffffff; + --radius-sm: 8px; + --radius-md: 12px; + --radius-lg: 16px; + --radius-xl: 28px; + --shadow-sm: 0 1px 2px rgba(16, 24, 40, 0.05); + --shadow-md: 0 18px 60px rgba(16, 24, 40, 0.08); +} + +html[data-theme="dark"] { + color-scheme: dark; + --bg: #0f1418; + --surface: rgba(17, 23, 29, 0.92); + --surface-alt: #141b22; + --surface-strong: #1b2430; + --border: rgba(115, 132, 150, 0.32); + --border-strong: rgba(129, 149, 170, 0.48); + --text: #eef2f6; + --muted: #a5b1bd; + --accent: #f5f7fa; + --accent-soft: #4ecb86; + --success: #4ecb86; + --warning: #ffca6b; + --danger: #ff8c82; + --body-gradient: radial-gradient(circle at top left, rgba(78, 203, 134, 0.13), transparent 24%), + radial-gradient(circle at top right, rgba(92, 136, 255, 0.12), transparent 24%), + linear-gradient(180deg, #0c1115 0%, #111821 100%); + --nav-bg: rgba(10, 14, 18, 0.76); + --nav-border: rgba(115, 132, 150, 0.28); + --panel-elevated: linear-gradient(180deg, rgba(20, 27, 34, 0.98), rgba(12, 17, 22, 0.98)); + --soft-card: rgba(20, 27, 34, 0.78); + --field-bg: #0f151b; + --footer-bg: rgba(10, 14, 18, 0.62); + --overlay-bg: rgba(5, 7, 10, 0.56); + --toast-bg: #edf2f7; + --toast-text: #111111; + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.22); + --shadow-md: 0 20px 60px rgba(0, 0, 0, 0.35); +} + +html { + scroll-behavior: smooth; +} + body { - background: linear-gradient(-45deg, #ee7752, #e73c7e, #23a6d5, #23d5ab); - background-size: 400% 400%; - animation: gradient 15s ease infinite; - color: #212529; - font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; - font-size: 14px; - margin: 0; + position: relative; + background: var(--body-gradient); + color: var(--text); + font-family: Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; min-height: 100vh; } -.main-wrapper { - display: flex; +main, +footer, +nav, +section, +aside, +article { + position: relative; + z-index: 1; +} + +a { + color: inherit; + text-decoration: none; +} + +a:hover { + color: inherit; +} + +.page-orb { + position: fixed; + width: 20rem; + height: 20rem; + border-radius: 50%; + filter: blur(10px); + opacity: 0.45; + pointer-events: none; + z-index: 0; +} + +.orb-left { + top: 5rem; + left: -7rem; + background: radial-gradient(circle, rgba(31, 122, 77, 0.18) 0%, rgba(31, 122, 77, 0) 70%); +} + +.orb-right { + top: 11rem; + right: -7rem; + background: radial-gradient(circle, rgba(17, 17, 17, 0.12) 0%, rgba(17, 17, 17, 0) 72%); +} + +.app-nav { + background: var(--nav-bg); + border-bottom: 1px solid var(--nav-border); + backdrop-filter: blur(18px); +} + +.navbar-brand { + color: var(--text); + font-weight: 800; + letter-spacing: -0.03em; +} + +.brand-mark { + width: 2.25rem; + height: 2.25rem; + display: inline-flex; align-items: center; justify-content: center; - min-height: 100vh; - width: 100%; - padding: 20px; - box-sizing: border-box; - position: relative; - z-index: 1; + border-radius: 12px; + background: linear-gradient(145deg, #111111, #2b3138); + color: #ffffff; + font-size: 0.84rem; + font-weight: 800; + box-shadow: 0 10px 24px rgba(17, 17, 17, 0.18); } -@keyframes gradient { - 0% { - background-position: 0% 50%; - } - 50% { - background-position: 100% 50%; - } - 100% { - background-position: 0% 50%; - } +.nav-actions .nav-link { + color: var(--muted); + transition: color 0.2s ease, transform 0.2s ease; } -.chat-container { - width: 100%; - max-width: 600px; - background: rgba(255, 255, 255, 0.85); - border: 1px solid rgba(255, 255, 255, 0.3); - border-radius: 20px; +.nav-actions .nav-link:hover, +.nav-actions .nav-link:focus-visible { + color: var(--text); + transform: translateY(-1px); +} + +.nav-link-pill { + padding: 0.55rem 0.8rem !important; + border-radius: 999px; + border: 1px solid var(--border); + background: var(--soft-card); +} + +.hero-shell, +.app-card, +.hero-panel, +.info-card { + background: var(--surface); + border: 1px solid rgba(217, 221, 227, 0.92); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-sm); + backdrop-filter: blur(12px); +} + +.hero-shell { + padding: 0.35rem; + box-shadow: var(--shadow-md); +} + +.hero-panel, +.app-card, +.info-card { + padding: 1.5rem; +} + +.app-card-highlight { + background: var(--panel-elevated); + box-shadow: var(--shadow-md); +} + +.hero-panel { + min-height: 100%; display: flex; flex-direction: column; - height: 85vh; - box-shadow: 0 20px 40px rgba(0,0,0,0.2); - backdrop-filter: blur(15px); - -webkit-backdrop-filter: blur(15px); + justify-content: center; overflow: hidden; } -.chat-header { - padding: 1.5rem; - border-bottom: 1px solid rgba(0, 0, 0, 0.05); - background: rgba(255, 255, 255, 0.5); - font-weight: 700; - font-size: 1.1rem; - display: flex; - justify-content: space-between; +.hero-copy-block { + max-width: 42rem; +} + +.eyebrow { + display: inline-flex; align-items: center; + gap: 0.375rem; + text-transform: uppercase; + letter-spacing: 0.12em; + font-size: 0.75rem; + font-weight: 700; + color: var(--muted); + margin-bottom: 1rem; } -.chat-messages { - flex: 1; - overflow-y: auto; - padding: 1.5rem; - display: flex; - flex-direction: column; - gap: 1.25rem; -} - -/* Custom Scrollbar */ -::-webkit-scrollbar { - width: 6px; -} - -::-webkit-scrollbar-track { - background: transparent; -} - -::-webkit-scrollbar-thumb { - background: rgba(255, 255, 255, 0.3); - border-radius: 10px; -} - -::-webkit-scrollbar-thumb:hover { - background: rgba(255, 255, 255, 0.5); -} - -.message { - max-width: 85%; - padding: 0.85rem 1.1rem; - border-radius: 16px; - line-height: 1.5; - font-size: 0.95rem; - box-shadow: 0 4px 15px rgba(0,0,0,0.05); - animation: fadeIn 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275); -} - -@keyframes fadeIn { - from { opacity: 0; transform: translateY(20px) scale(0.95); } - to { opacity: 1; transform: translateY(0) scale(1); } -} - -.message.visitor { - align-self: flex-end; - background: linear-gradient(135deg, #212529 0%, #343a40 100%); - color: #fff; - border-bottom-right-radius: 4px; -} - -.message.bot { - align-self: flex-start; - background: #ffffff; - color: #212529; - border-bottom-left-radius: 4px; -} - -.chat-input-area { - padding: 1.25rem; - background: rgba(255, 255, 255, 0.5); - border-top: 1px solid rgba(0, 0, 0, 0.05); -} - -.chat-input-area form { - display: flex; - gap: 0.75rem; -} - -.chat-input-area input { - flex: 1; - border: 1px solid rgba(0, 0, 0, 0.1); - border-radius: 12px; - padding: 0.75rem 1rem; - outline: none; - background: rgba(255, 255, 255, 0.9); - transition: all 0.3s ease; -} - -.chat-input-area input:focus { - border-color: #23a6d5; - box-shadow: 0 0 0 3px rgba(35, 166, 213, 0.2); -} - -.chat-input-area button { - background: #212529; - color: #fff; - border: none; - padding: 0.75rem 1.5rem; - border-radius: 12px; - cursor: pointer; - font-weight: 600; - transition: all 0.3s ease; -} - -.chat-input-area button:hover { - background: #000; - transform: translateY(-2px); - box-shadow: 0 5px 15px rgba(0,0,0,0.2); -} - -/* Background Animations */ -.bg-animations { - position: fixed; - top: 0; - left: 0; - width: 100%; - height: 100%; - z-index: 0; - overflow: hidden; - pointer-events: none; -} - -.blob { - position: absolute; - width: 500px; - height: 500px; - background: rgba(255, 255, 255, 0.2); - border-radius: 50%; - filter: blur(80px); - animation: move 20s infinite alternate cubic-bezier(0.45, 0, 0.55, 1); -} - -.blob-1 { - top: -10%; - left: -10%; - background: rgba(238, 119, 82, 0.4); -} - -.blob-2 { - bottom: -10%; - right: -10%; - background: rgba(35, 166, 213, 0.4); - animation-delay: -7s; - width: 600px; - height: 600px; -} - -.blob-3 { - top: 40%; - left: 30%; - background: rgba(231, 60, 126, 0.3); - animation-delay: -14s; - width: 450px; - height: 450px; -} - -@keyframes move { - 0% { transform: translate(0, 0) rotate(0deg) scale(1); } - 33% { transform: translate(150px, 100px) rotate(120deg) scale(1.1); } - 66% { transform: translate(-50px, 200px) rotate(240deg) scale(0.9); } - 100% { transform: translate(0, 0) rotate(360deg) scale(1); } -} - -.header-link { - font-size: 14px; - color: #fff; - text-decoration: none; - background: rgba(0, 0, 0, 0.2); - padding: 0.5rem 1rem; - border-radius: 8px; - transition: all 0.3s ease; -} - -.header-link:hover { - background: rgba(0, 0, 0, 0.4); - text-decoration: none; -} - -/* Admin Styles */ -.admin-container { - max-width: 900px; - margin: 3rem auto; - padding: 2.5rem; - background: rgba(255, 255, 255, 0.85); - backdrop-filter: blur(20px); - -webkit-backdrop-filter: blur(20px); - border-radius: 24px; - box-shadow: 0 20px 50px rgba(0,0,0,0.15); - border: 1px solid rgba(255, 255, 255, 0.4); - position: relative; - z-index: 1; -} - -.admin-container h1 { - margin-top: 0; - color: #212529; +h1, +.detail-title { + font-size: clamp(2.25rem, 4vw, 4rem); + line-height: 0.98; + letter-spacing: -0.055em; + margin-bottom: 1rem; font-weight: 800; } -.table { - width: 100%; - border-collapse: separate; - border-spacing: 0 8px; - margin-top: 1.5rem; +.hero-copy { + color: var(--muted); + max-width: 44rem; + margin-bottom: 0; + font-size: 1.02rem; + line-height: 1.7; } -.table th { - background: transparent; - border: none; - padding: 1rem; - color: #6c757d; - font-weight: 600; - text-transform: uppercase; - font-size: 0.75rem; - letter-spacing: 1px; -} - -.table td { - background: #fff; - padding: 1rem; - border: none; -} - -.table tr td:first-child { border-radius: 12px 0 0 12px; } -.table tr td:last-child { border-radius: 0 12px 12px 0; } - -.form-group { - margin-bottom: 1.25rem; -} - -.form-group label { - display: block; - margin-bottom: 0.5rem; - font-weight: 600; - font-size: 0.9rem; -} - -.form-control { - width: 100%; - padding: 0.75rem 1rem; - border: 1px solid rgba(0, 0, 0, 0.1); - border-radius: 12px; - background: #fff; - transition: all 0.3s ease; - box-sizing: border-box; -} - -.form-control:focus { - outline: none; - border-color: #23a6d5; - box-shadow: 0 0 0 3px rgba(35, 166, 213, 0.1); -} - -.header-container { +.hero-actions { display: flex; - justify-content: space-between; + flex-wrap: wrap; + gap: 0.75rem; +} + +.hero-points { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.9rem; +} + +.hero-points-tight { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.point-card, +.stat-card, +.detail-item, +.download-panel, +.flow-card, +.dropzone-label, +.status-summary { + background: var(--surface-alt); + border: 1px solid var(--border); + border-radius: var(--radius-md); +} + +.point-card, +.stat-card, +.detail-item, +.flow-card, +.download-panel, +.status-summary { + padding: 1rem; +} + +.point-card-accent { + background: linear-gradient(180deg, rgba(31, 122, 77, 0.08), rgba(255, 255, 255, 0.95)); +} + +.point-label, +.stat-label, +.section-subtitle, +.form-help, +.mini-note, +.stat-note, +.meta-list dt, +.detail-item span, +.point-copy, +.form-help-label, +.status-summary-label, +.footer-copy { + color: var(--muted); + font-size: 0.92rem; +} + +.point-value, +.stat-value, +.detail-item strong, +.form-help-value, +.status-summary-title, +.footer-title { + font-size: 1rem; + font-weight: 700; + color: var(--text); +} + +.point-copy { + margin-top: 0.45rem; + line-height: 1.55; +} + +.trust-strip { + display: flex; + flex-wrap: wrap; + gap: 0.65rem; +} + +.trust-pill { + display: inline-flex; align-items: center; + padding: 0.5rem 0.75rem; + border-radius: 999px; + background: rgba(255, 255, 255, 0.7); + border: 1px solid rgba(188, 196, 207, 0.85); + font-size: 0.86rem; + color: var(--muted); } -.header-links { - display: flex; +.metrics-grid, +.workflow-grid, +.info-strip { + display: grid; gap: 1rem; } -.admin-card { - background: rgba(255, 255, 255, 0.6); - padding: 2rem; - border-radius: 20px; - border: 1px solid rgba(255, 255, 255, 0.5); - margin-bottom: 2.5rem; - box-shadow: 0 10px 30px rgba(0,0,0,0.05); +.metrics-grid { + grid-template-columns: repeat(4, minmax(0, 1fr)); } -.admin-card h3 { - margin-top: 0; - margin-bottom: 1.5rem; +.info-strip { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.workflow-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.workflow-grid-2 { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.info-card p, +.flow-card p { + color: var(--muted); + line-height: 1.65; +} + +.stat-value { + font-size: 1.9rem; + letter-spacing: -0.05em; + margin: 0.3rem 0; +} + +.stat-value-sm { + font-size: 1.2rem; + letter-spacing: -0.03em; +} + +.section-title { + font-size: 1.15rem; + letter-spacing: -0.03em; font-weight: 700; } -.btn-delete { - background: #dc3545; - color: white; - border: none; - padding: 0.25rem 0.5rem; - border-radius: 4px; - cursor: pointer; +.card-header-row { + display: flex; + gap: 1rem; + justify-content: space-between; + align-items: flex-start; } -.btn-add { - background: #212529; - color: white; - border: none; - padding: 0.5rem 1rem; - border-radius: 4px; - cursor: pointer; - margin-top: 1rem; -} - -.btn-save { - background: #0088cc; - color: white; - border: none; - padding: 0.8rem 1.5rem; - border-radius: 12px; - cursor: pointer; - font-weight: 600; - width: 100%; - transition: all 0.3s ease; -} - -.webhook-url { - font-size: 0.85em; - color: #555; - margin-top: 0.5rem; -} - -.history-table-container { - overflow-x: auto; - background: rgba(255, 255, 255, 0.4); - padding: 1rem; - border-radius: 12px; - border: 1px solid rgba(255, 255, 255, 0.3); -} - -.history-table { - width: 100%; -} - -.history-table-time { - width: 15%; +.status-chip { + display: inline-flex; + align-items: center; + padding: 0.45rem 0.75rem; + border-radius: 999px; + font-size: 0.82rem; + border: 1px solid var(--border); + background: var(--soft-card); white-space: nowrap; - font-size: 0.85em; - color: #555; + font-weight: 600; } -.history-table-user { - width: 35%; - background: rgba(255, 255, 255, 0.3); - border-radius: 8px; - padding: 8px; +.status-chip.ready { + color: var(--success); + border-color: rgba(31, 122, 77, 0.25); + background: rgba(31, 122, 77, 0.06); } -.history-table-ai { - width: 50%; - background: rgba(255, 255, 255, 0.5); - border-radius: 8px; - padding: 8px; +.status-chip.offline { + color: var(--warning); + border-color: rgba(138, 90, 0, 0.22); + background: rgba(138, 90, 0, 0.06); } -.no-messages { +.form-control, +.btn, +.table, +.alert, +.badge, +.toast, +.selected-file { + border-radius: var(--radius-sm) !important; +} + +.dropzone-label { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.75rem; + min-height: 17rem; + padding: 1.5rem; + border-style: dashed; text-align: center; - color: #777; -} \ No newline at end of file + cursor: pointer; + transition: border-color 0.2s ease, transform 0.2s ease, background 0.2s ease, box-shadow 0.2s ease; +} + +.dropzone-label:hover, +.dropzone-label:focus, +.dropzone-label:focus-within { + border-color: var(--accent-soft); + transform: translateY(-1px); + background: linear-gradient(180deg, rgba(31, 122, 77, 0.05), rgba(255, 255, 255, 0.9)); + box-shadow: 0 16px 40px rgba(17, 17, 17, 0.08); + outline: none; +} + +.dropzone-label.is-dragging, +.dropzone-label.is-has-file { + border-color: var(--accent-soft); +} + +.dropzone-label.is-dragging { + transform: translateY(-2px) scale(1.01); + background: linear-gradient(180deg, rgba(31, 122, 77, 0.12), rgba(255, 255, 255, 0.94)); + box-shadow: 0 22px 44px rgba(31, 122, 77, 0.14); +} + +.dropzone-label.is-has-file { + background: linear-gradient(180deg, rgba(31, 122, 77, 0.08), rgba(255, 255, 255, 0.94)); +} + +.dropzone-badge { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.42rem 0.72rem; + border-radius: 999px; + border: 1px solid rgba(31, 122, 77, 0.16); + background: rgba(31, 122, 77, 0.08); + color: var(--text); + font-size: 0.78rem; + font-weight: 700; + letter-spacing: 0.02em; +} + +.dropzone-icon { + width: 3.4rem; + height: 3.4rem; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 20px; + background: linear-gradient(145deg, rgba(17, 17, 17, 0.94), rgba(43, 49, 56, 0.94)); + color: #ffffff; + font-size: 1.4rem; + box-shadow: 0 12px 24px rgba(17, 17, 17, 0.14); +} + +.dropzone-copy { + display: grid; + gap: 0.25rem; + max-width: 24rem; + line-height: 1.55; +} + +.dropzone-title { + font-weight: 700; + font-size: 1.05rem; +} + +.dropzone-meta, +.dropzone-subtitle { + color: var(--muted); + font-size: 0.92rem; +} + +.dropzone-meta { + max-width: 24rem; +} + +.dropzone-label .form-control { + position: absolute; + inset: 0; + opacity: 0; + cursor: pointer; +} + +.form-control { + border-color: var(--border-strong); + padding: 0.85rem 1rem; + box-shadow: none !important; +} + +.form-control:focus { + border-color: #111111; +} + +.selected-file { + border: 1px dashed var(--border-strong); + background: var(--field-bg); + color: var(--text); + padding: 0.95rem 1rem; + font-size: 0.94rem; +} + +.selected-file-name { + display: block; + font-weight: 700; +} + +.selected-file-meta { + display: block; + color: var(--muted); + margin-top: 0.2rem; +} + +.form-help-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.75rem; +} + +.form-help-grid > div { + background: var(--surface-alt); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 0.85rem 0.9rem; +} + +.form-help-label { + display: block; + margin-bottom: 0.15rem; +} + +.btn { + padding: 0.82rem 1.05rem; + font-weight: 700; + border-width: 1px; + box-shadow: none !important; + transition: transform 0.18s ease, box-shadow 0.18s ease, background 0.18s ease, color 0.18s ease; +} + +.btn:hover, +.btn:focus-visible { + transform: translateY(-1px); +} + +.btn-dark { + background: linear-gradient(135deg, #111111, #2a3138); + border-color: #111111; +} + +.btn-soft { + background: var(--soft-card); + border: 1px solid var(--border-strong); + color: var(--text); +} + +.btn-soft:hover, +.btn-soft:focus-visible { + background: var(--field-bg); + border-color: var(--accent-soft); +} + +.btn-outline-dark { + border-color: var(--border-strong); +} + +.mini-note { + border-top: 1px solid var(--border); + padding-top: 1rem; +} + +.mini-note-title { + color: var(--text); + font-weight: 700; + margin-bottom: 0.5rem; +} + +.mini-steps li + li { + margin-top: 0.35rem; +} + +.app-table thead th { + color: var(--muted); + font-size: 0.82rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + border-bottom-color: var(--border); + padding-top: 0; +} + +.app-table td, +.app-table th { + padding: 1rem 0.85rem; + border-color: var(--border); + vertical-align: middle; +} + +.file-name-cell { + max-width: 16rem; +} + +.empty-state { + padding: 1.6rem; + border: 1px dashed var(--border-strong); + background: linear-gradient(180deg, rgba(248, 250, 252, 0.9), rgba(255, 255, 255, 0.96)); + border-radius: var(--radius-md); + text-align: center; +} + +.empty-state h3 { + font-size: 1.15rem; + margin-bottom: 0.4rem; +} + +.detail-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.85rem; +} + +.download-panel { + background: linear-gradient(180deg, rgba(31, 122, 77, 0.07), rgba(255, 255, 255, 0.97)); +} + +.download-panel-copy p { + color: var(--muted); +} + +.detail-badge { + font-size: 0.9rem; + padding: 0.55rem 0.75rem; +} + +.meta-list { + display: grid; + gap: 1rem; +} + +.meta-list dt { + margin-bottom: 0.25rem; +} + +.meta-list dd { + margin: 0; + font-weight: 500; + word-break: break-word; +} + +.status-summary { + min-height: 100%; + display: flex; + flex-direction: column; + justify-content: center; + gap: 0.55rem; + background: linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(248, 250, 252, 0.95)); +} + +.status-summary-title { + font-size: 1.35rem; + letter-spacing: -0.04em; +} + +.status-summary-completed { + border-color: rgba(31, 122, 77, 0.24); + background: linear-gradient(180deg, rgba(31, 122, 77, 0.08), rgba(255, 255, 255, 0.96)); +} + +.status-summary-failed { + border-color: rgba(180, 35, 24, 0.18); + background: linear-gradient(180deg, rgba(180, 35, 24, 0.05), rgba(255, 255, 255, 0.96)); +} + +.status-summary-processing { + border-color: rgba(138, 90, 0, 0.2); + background: linear-gradient(180deg, rgba(138, 90, 0, 0.06), rgba(255, 255, 255, 0.96)); +} + +.flow-card { + min-height: 100%; +} + +.flow-step { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2.2rem; + height: 2.2rem; + border-radius: 999px; + background: #111111; + color: #ffffff; + font-size: 0.86rem; + font-weight: 700; + margin-bottom: 1rem; +} + +.app-alert { + border-color: transparent; +} + +.app-alert-block { + border-radius: var(--radius-md); +} + +.loading-overlay { + position: fixed; + inset: 0; + background: var(--overlay-bg); + display: flex; + align-items: center; + justify-content: center; + padding: 1rem; + z-index: 1080; +} + +.loading-card { + background: var(--field-bg); + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: 1.5rem; + width: min(100%, 24rem); + text-align: center; +} + +.loading-card h2 { + font-size: 1.1rem; + margin: 1rem 0 0.5rem; + letter-spacing: -0.03em; +} + +.toast { + background: var(--toast-bg); + color: var(--toast-text); + border: 1px solid rgba(255, 255, 255, 0.08); +} + +.app-footer { + border-top: 1px solid var(--nav-border); + background: var(--footer-bg); + backdrop-filter: blur(10px); +} + +.footer-title { + font-size: 1rem; + margin-bottom: 0.2rem; +} + +.footer-links a { + color: var(--muted); +} + +.footer-links a:hover, +.footer-links a:focus-visible { + color: var(--text); +} + +@media (max-width: 1199.98px) { + .metrics-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .info-strip, + .workflow-grid { + grid-template-columns: 1fr; + } +} + +@media (max-width: 991.98px) { + .hero-points, + .hero-points-tight, + .detail-grid, + .form-help-grid, + .workflow-grid-2 { + grid-template-columns: 1fr; + } + + .page-orb { + display: none; + } +} + +@media (max-width: 767.98px) { + .hero-panel, + .app-card, + .info-card { + padding: 1.125rem; + } + + .card-header-row, + .hero-actions { + flex-direction: column; + align-items: flex-start; + } + + .metrics-grid { + grid-template-columns: 1fr; + } + + .app-table td, + .app-table th { + white-space: nowrap; + } + + h1, + .detail-title { + font-size: clamp(2rem, 8vw, 2.8rem); + } +} + + +.theme-toggle { + display: inline-flex; + align-items: center; + gap: 0.45rem; +} + +.theme-toggle-icon { + font-size: 0.95rem; + line-height: 1; +} + +.selected-file-warning { + border-color: rgba(180, 35, 24, 0.45); + background: linear-gradient(180deg, rgba(180, 35, 24, 0.08), var(--field-bg)); +} + +html[data-theme="dark"] .brand-mark, +html[data-theme="dark"] .dropzone-icon, +html[data-theme="dark"] .btn-dark, +html[data-theme="dark"] .flow-step { + background: linear-gradient(145deg, #eef2f6, #b8c4d0); + color: #111111; + border-color: rgba(255, 255, 255, 0.12); +} + +html[data-theme="dark"] .dropzone-label:hover, +html[data-theme="dark"] .dropzone-label:focus, +html[data-theme="dark"] .dropzone-label:focus-within { + background: linear-gradient(180deg, rgba(78, 203, 134, 0.08), rgba(20, 27, 34, 0.92)); +} + +html[data-theme="dark"] .dropzone-label.is-dragging { + background: linear-gradient(180deg, rgba(78, 203, 134, 0.15), rgba(20, 27, 34, 0.96)); + box-shadow: 0 22px 44px rgba(0, 0, 0, 0.34); +} + +html[data-theme="dark"] .dropzone-label.is-has-file { + background: linear-gradient(180deg, rgba(78, 203, 134, 0.11), rgba(20, 27, 34, 0.94)); +} + +html[data-theme="dark"] .dropzone-badge { + background: rgba(78, 203, 134, 0.16); + border-color: rgba(78, 203, 134, 0.26); +} + +html[data-theme="dark"] .download-panel { + background: linear-gradient(180deg, rgba(78, 203, 134, 0.09), rgba(17, 23, 29, 0.96)); +} + +html[data-theme="dark"] .empty-state { + background: linear-gradient(180deg, rgba(20, 27, 34, 0.95), rgba(13, 18, 24, 0.98)); +} + +html[data-theme="dark"] .status-summary { + background: linear-gradient(180deg, rgba(20, 27, 34, 0.98), rgba(12, 17, 22, 0.96)); +} + +html[data-theme="dark"] .status-summary-completed { + background: linear-gradient(180deg, rgba(78, 203, 134, 0.10), rgba(17, 23, 29, 0.96)); +} + +html[data-theme="dark"] .status-summary-failed { + background: linear-gradient(180deg, rgba(255, 140, 130, 0.09), rgba(17, 23, 29, 0.96)); +} + +html[data-theme="dark"] .status-summary-processing { + background: linear-gradient(180deg, rgba(255, 202, 107, 0.09), rgba(17, 23, 29, 0.96)); +} + +html[data-theme="dark"] .form-control { + background: var(--field-bg); + color: var(--text); +} + +html[data-theme="dark"] .form-control::file-selector-button { + background: rgba(255, 255, 255, 0.08); + color: var(--text); + border: 0; +} + +html[data-theme="dark"] .table { + --bs-table-bg: transparent; + --bs-table-color: var(--text); + --bs-table-border-color: var(--border); + --bs-table-striped-bg: rgba(255, 255, 255, 0.02); + --bs-table-hover-bg: rgba(255, 255, 255, 0.03); +} + +html[data-theme="dark"] .btn-outline-dark { + color: var(--text); + border-color: var(--border-strong); +} + +html[data-theme="dark"] .btn-outline-dark:hover, +html[data-theme="dark"] .btn-outline-dark:focus-visible { + background: rgba(255, 255, 255, 0.08); + color: var(--text); +} + +html[data-theme="dark"] .toast .btn-close { + filter: invert(1); +} + + +.tool-grid, +.recent-job-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + gap: 1rem; +} + +.tool-card, +.recent-job-card { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.tool-card-accent { + background: var(--panel-elevated); +} + +.tool-card-header, +.recent-job-head, +.card-header-row { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; +} + +.tool-card-title { + font-size: 1.25rem; + line-height: 1.2; + letter-spacing: -0.03em; + margin: 0; + font-weight: 700; +} + +.field-stack { + display: grid; + gap: 1.25rem; +} + +.option-panel { + padding: 1rem; + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: var(--surface-alt); +} + +.compact-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.status-pill { + display: inline-flex; + align-items: center; + justify-content: center; + white-space: nowrap; + padding: 0.45rem 0.75rem; + border-radius: 999px; + font-size: 0.78rem; + font-weight: 700; + border: 1px solid var(--border); + background: var(--surface-alt); +} + +.status-pill-success { + color: var(--success); +} + +.status-pill-warning { + color: var(--warning); +} + +.status-pill-danger { + color: var(--danger); +} + +.empty-state-card { + text-align: center; + padding: 2rem; +} + +.loading-card { + width: min(26rem, calc(100vw - 2rem)); + padding: 1.5rem; + border-radius: var(--radius-lg); + background: rgba(15, 20, 24, 0.92); + color: #fff; + text-align: center; + box-shadow: var(--shadow-md); +} + +.loading-overlay { + position: fixed; + inset: 0; + z-index: 1060; + display: flex; + align-items: center; + justify-content: center; + background: var(--overlay-bg); + backdrop-filter: blur(8px); +} + +#appToast { + background: var(--toast-bg); + color: var(--toast-text); + box-shadow: var(--shadow-md); +} + +html[data-theme="dark"] #appToast .btn-close { + filter: invert(1); +} + +@media (max-width: 767.98px) { + .tool-card-header, + .recent-job-head, + .card-header-row { + flex-direction: column; + } + + .compact-grid { + grid-template-columns: 1fr; + } +} diff --git a/assets/js/main.js b/assets/js/main.js index d349598..1694258 100644 --- a/assets/js/main.js +++ b/assets/js/main.js @@ -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…'; + } + }); + } }); diff --git a/db/migrations/20260419_create_video_conversion_jobs.sql b/db/migrations/20260419_create_video_conversion_jobs.sql new file mode 100644 index 0000000..5ba4f3a --- /dev/null +++ b/db/migrations/20260419_create_video_conversion_jobs.sql @@ -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; diff --git a/download.php b/download.php new file mode 100644 index 0000000..de7c511 --- /dev/null +++ b/download.php @@ -0,0 +1,29 @@ + 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); diff --git a/index.php b/index.php index 7205f3d..17b592f 100644 --- a/index.php +++ b/index.php @@ -1,150 +1,388 @@ ($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'] ?? [] + ), + ]; +} ?> - - - New Style - - - - - - - - - - - - - - - + + + <?= h($meta['title']) ?> + + + + + + + + + + + + + - - + + + - -
-
-

Analyzing your requirements and generating your website…

-
- Loading… + + + +
+ + +
+ + + + +
+
+
+
+
+ Sell more than a single codec swap +

One dashboard for video exports and subtitle conversions.

+

FormatShift now combines the original WEBM → MP4 flow with social-ready MP4 presets and a lightweight SRT ↔ VTT subtitle utility, so the product feels closer to a creator toolkit than a commodity converter.

+
+ + + +
+
+
Tool bundle
+
3 converters
+

Video compatibility, social presets, and subtitle reformatting.

+
+
+
Upload limit
+
Up to MB
+

Applies to video uploads and subtitle assets.

+
+
+
Retention
+
hours
+

Temporary files auto-clean so the VM stays lean.

+
+
+
+
+ +
+ +
+
+
+ +
+
+
+

Conversion tools

+

A tighter bundle for teams that need compatibility, publishing formats, and caption assets in one place.

+
+
+
+ $tool): ?> +
+
+
+
+

+
+ +
+

+ +
    + +
  • :
  • + +
+ +

+ +
+ +
+
+ +
+
+
+
+
+
+

Run a conversion

+

Pick the tool, choose any preset/output options, then upload the source file.

+
+
+ +
+
+
+ + +
+
+
+ +
+ + +
+ +
+ + +
Choose the opposite format of your upload, for example SRT → VTT or VTT → SRT.
+
+ +
+ + + +
+
+
+ + +
+
+
+ +
+ +
+
+
+ +
+
+
+

Recent jobs

+

Each entry now shows which converter ran, which format changed, and whether a download is ready.

+
+
+ + +
+

No conversions yet

+

Your first WEBM, social export, or subtitle conversion will show up here with a dedicated detail page and download action.

+
+ +
+ +
+
+
+
+

+

+
+ +
+
+
+ Created + +
+
+ Input + +
+
+ Output + +
+
+ Download + +
+
+
+ View details + + + +
+
+ +
+ +
-
- Page updated: (UTC) -
+ + + +
+
+
+
Ready.
+ +
+
+
+ + + + diff --git a/job.php b/job.php new file mode 100644 index 0000000..85e5bc6 --- /dev/null +++ b/job.php @@ -0,0 +1,250 @@ + + + + + + + <?= h($meta['title']) ?> + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+ + + + + +
+

Job not found

+

The requested conversion record is missing or has already expired from the retention window.

+ Back to dashboard +
+ +
+
+
+
+ +

+

Original file:

+ +
+
+
+ +
+
+
+ +
+
+
+
+
+

Job metrics

+

A concise view of the source, output, preset, and runtime status for this job.

+
+
+ +
+
+ Converter + +
+
+ Formats + +
+
+ Input size + +
+
+ Output size + +
+
+ Started + +
+
+ Completed + +
+
+ + +
+
+

+

The output file is available now. Downloads remain available until the -hour retention window expires.

+
+ +
+ +
+ Conversion failed. +
+
+ +
+ This job is still marked as processing. Refresh the page in a few seconds if needed. +
+ +
+ +
+
+ A +

Input captured

+

The source file and requested tool settings were stored before processing started, so the result can be tracked even if the run fails.

+
+
+ B +

Output retained

+

Generated files stay available for hours, after which cleanup removes the temporary artifacts.

+
+
+
+ +
+ +
+
+ +
+
+ + + + + diff --git a/process_conversion.php b/process_conversion.php new file mode 100644 index 0000000..7591a6f --- /dev/null +++ b/process_conversion.php @@ -0,0 +1,124 @@ + 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.'); diff --git a/var/converted/6860a83a8266dd38cb84b94e3c739295.mp4 b/var/converted/6860a83a8266dd38cb84b94e3c739295.mp4 new file mode 100644 index 0000000..5029543 Binary files /dev/null and b/var/converted/6860a83a8266dd38cb84b94e3c739295.mp4 differ diff --git a/var/converted/e78a40e97113e5e506b51b1719fc5898.mp4 b/var/converted/e78a40e97113e5e506b51b1719fc5898.mp4 new file mode 100644 index 0000000..5029543 Binary files /dev/null and b/var/converted/e78a40e97113e5e506b51b1719fc5898.mp4 differ diff --git a/var/uploads/6860a83a8266dd38cb84b94e3c739295.webm b/var/uploads/6860a83a8266dd38cb84b94e3c739295.webm new file mode 100644 index 0000000..6aec412 Binary files /dev/null and b/var/uploads/6860a83a8266dd38cb84b94e3c739295.webm differ diff --git a/var/uploads/e78a40e97113e5e506b51b1719fc5898.webm b/var/uploads/e78a40e97113e5e506b51b1719fc5898.webm new file mode 100644 index 0000000..6aec412 Binary files /dev/null and b/var/uploads/e78a40e97113e5e506b51b1719fc5898.webm differ