Toolvana: production-ready media tools platform (CodeIgniter 4)
- 134-tool registry with programmatic SEO (unique titles/H1/descriptions, JSON-LD graphs, sitemap index, canonical 301 enforcement via required filter) - DB-backed job queue (SKIP LOCKED) with drivers: Ffmpeg, Images (GD), Pdf (qpdf/gs/poppler), Youtube (thumbnails), Qr (server-side PNG) - Security: SSRF guard, MIME validation, rate limits, API-key auth, bcrypt admin login, security headers - Admin panel: dashboard, tools/categories/guides CRUD, SEO audit, analytics, job inspector with retry, system health, feature flags - Docker deployment (nginx + web/api FPM pools + scalable workers), PHPUnit suite (19 tests / 1139 assertions), PWA manifest + service worker
This commit is contained in:
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Libraries;
|
||||
|
||||
use App\Models\AnalyticsEventModel;
|
||||
|
||||
/**
|
||||
* Anonymous, privacy-first analytics. Sessions/IPs are stored only as
|
||||
* salted one-way hashes; no cookies are set for tracking and no PII is
|
||||
* ever persisted.
|
||||
*/
|
||||
final class Analytics
|
||||
{
|
||||
public const EVENTS = [
|
||||
'tool_view', 'tool_start', 'tool_success', 'tool_failure',
|
||||
'conversion_format', 'download', 'upload', 'search', 'error',
|
||||
];
|
||||
|
||||
public function sessionHash(): string
|
||||
{
|
||||
static $hash = null;
|
||||
if ($hash === null) {
|
||||
try {
|
||||
// make sure the session actually exists before reading its id
|
||||
if (session_id() === '') {
|
||||
service('session')->start();
|
||||
}
|
||||
$sid = session_id() ?: bin2hex(random_bytes(16));
|
||||
} catch (\Throwable) {
|
||||
$sid = 'no-session'; // CLI/tests: stable, never random
|
||||
}
|
||||
$salt = env('encryption.key', 'tv-fallback-salt');
|
||||
$hash = hash('sha256', $sid . '|' . $salt);
|
||||
}
|
||||
|
||||
return substr($hash, 0, 32);
|
||||
}
|
||||
|
||||
public function ipHash(): string
|
||||
{
|
||||
$salt = env('encryption.key', 'tv-fallback-salt');
|
||||
|
||||
return hash('sha256', service('request')->getIPAddress() . '|' . $salt);
|
||||
}
|
||||
|
||||
public function track(string $name, array $data = []): void
|
||||
{
|
||||
if (! in_array($name, self::EVENTS, true)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
model(AnalyticsEventModel::class)->record([
|
||||
'name' => $name,
|
||||
'tool_slug' => $data['tool'] ?? null,
|
||||
'format' => isset($data['format']) ? mb_substr((string) $data['format'], 0, 16) : null,
|
||||
'value' => (int) ($data['value'] ?? 0),
|
||||
'session_id' => $this->sessionHash(),
|
||||
'country' => $this->guessCountry(),
|
||||
'meta' => json_encode($data['meta'] ?? new \stdClass(), JSON_UNESCAPED_UNICODE),
|
||||
'created_at' => date('Y-m-d H:i:s'),
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'analytics write failed: {m}', ['m' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
/** Country from CDN geo headers when present (Cloudflare/Fastly), else null. */
|
||||
private function guessCountry(): ?string
|
||||
{
|
||||
$cc = $_SERVER['HTTP_CF_IPCOUNTRY'] ?? $_SERVER['X-Geo-Country'] ?? null;
|
||||
|
||||
return is_string($cc) && preg_match('/^[A-Z]{2}$/', $cc) === 1 ? $cc : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,674 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Libraries;
|
||||
|
||||
/**
|
||||
* CatalogBuilder — the single source of truth for the tool registry.
|
||||
*
|
||||
* Produces a complete array of tool definitions: hand-curated flagship
|
||||
* tools first, then every format-conversion combination generated from
|
||||
* FormatCatalog. Content templates are parameterized by operation and
|
||||
* formats and varied deterministically per slug, so each landing page is
|
||||
* specific to what it actually does (no interchangeable filler).
|
||||
*
|
||||
* The admin "new tool" flow reuses this builder; the seeder persists it.
|
||||
*/
|
||||
final class CatalogBuilder
|
||||
{
|
||||
public static function build(): array
|
||||
{
|
||||
return [
|
||||
...self::youtubeTools(),
|
||||
...self::videoTools(),
|
||||
...self::audioTools(),
|
||||
...self::imageTools(),
|
||||
...self::gifTools(),
|
||||
...self::pdfTools(),
|
||||
...self::utilityTools(),
|
||||
];
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// YouTube family
|
||||
// =====================================================================
|
||||
|
||||
private static function youtubeTools(): array
|
||||
{
|
||||
$cat = 'youtube-tools';
|
||||
$gated = ['yt_video_download', 'yt_audio_download', 'yt_playlist_download', 'yt_shorts_download'];
|
||||
|
||||
return [
|
||||
self::tool([
|
||||
'slug' => 'youtube-downloader', 'name' => 'YouTube Downloader', 'icon' => 'download',
|
||||
'short' => 'Save YouTube videos in MP4 quality up to 1080p. Paste a link and pick a resolution.',
|
||||
'operation' => 'yt_video_download', 'requires' => ['yt-dlp'],
|
||||
'aliases' => ['yt downloader', 'download youtube video', 'save youtube video'],
|
||||
'intro' => 'This downloader saves YouTube videos as MP4 files you can keep offline. Paste any video URL, choose the resolution you need, and the video is prepared in seconds. Use it for your own uploads, Creative Commons material, or content you are licensed to download.',
|
||||
], $cat),
|
||||
self::tool([
|
||||
'slug' => 'youtube-video-downloader', 'name' => 'YouTube Video Downloader', 'icon' => 'video',
|
||||
'short' => 'Download YouTube videos as high-quality MP4 files, ready for offline viewing.',
|
||||
'operation' => 'yt_video_download', 'requires' => ['yt-dlp'], 'hidden_dup' => true,
|
||||
'aliases' => ['yt video downloader'],
|
||||
], $cat),
|
||||
self::tool([
|
||||
'slug' => 'youtube-to-mp3', 'name' => 'YouTube to MP3 Converter', 'icon' => 'music',
|
||||
'short' => 'Extract MP3 audio from any YouTube video at up to 320 kbps. Free, fast, no software.',
|
||||
'operation' => 'yt_audio_download', 'format_out' => 'mp3', 'requires' => ['yt-dlp'],
|
||||
'featured' => true, 'popularity' => 10000,
|
||||
'aliases' => ['yt mp3', 'yt to mp3', 'youtube mp3', 'youtube audio', 'ytmp3'],
|
||||
'intro' => 'Convert YouTube videos into clean MP3 audio without installing anything. Paste a video link, press Convert, and receive a 320 kbps file that plays on every phone, computer and car stereo. Ideal for podcasts, lectures, interviews and music sets you have the right to save.',
|
||||
], $cat),
|
||||
self::tool([
|
||||
'slug' => 'youtube-to-wav', 'name' => 'YouTube to WAV Converter', 'icon' => 'music',
|
||||
'short' => 'Rip lossless WAV audio from YouTube videos for editing and sampling.',
|
||||
'operation' => 'yt_audio_download', 'format_out' => 'wav', 'requires' => ['yt-dlp'],
|
||||
'popularity' => 4200,
|
||||
'aliases' => ['yt wav', 'youtube wav'],
|
||||
'intro' => 'Need uncompressed audio from a YouTube video? This tool pulls the best available audio stream and converts it to standard 16-bit PCM WAV — the format audio editors, DAWs and samplers expect. Perfect when quality matters more than file size.',
|
||||
], $cat),
|
||||
self::tool([
|
||||
'slug' => 'youtube-to-m4a', 'name' => 'YouTube to M4A Converter', 'icon' => 'music',
|
||||
'short' => 'Get compact M4A (AAC) audio from YouTube — great quality at half the size of MP3.',
|
||||
'operation' => 'yt_audio_download', 'format_out' => 'm4a', 'requires' => ['yt-dlp'],
|
||||
'popularity' => 3800,
|
||||
'aliases' => ['yt m4a', 'youtube m4a'],
|
||||
'intro' => 'M4A is YouTube\'s native audio codec, so converting straight to M4A avoids an extra re-encode. Files stay small enough for phones while sounding better than same-size MP3s. Paste a link to grab lectures, audiobooks or music in AAC format.',
|
||||
], $cat),
|
||||
self::tool([
|
||||
'slug' => 'youtube-to-aac', 'name' => 'YouTube to AAC Converter', 'icon' => 'music',
|
||||
'short' => 'Convert YouTube soundtracks to raw AAC files with efficient modern compression.',
|
||||
'operation' => 'yt_audio_download', 'format_out' => 'aac', 'requires' => ['yt-dlp'],
|
||||
'popularity' => 2100,
|
||||
], $cat),
|
||||
self::tool([
|
||||
'slug' => 'youtube-thumbnail-downloader', 'name' => 'YouTube Thumbnail Downloader', 'icon' => 'image',
|
||||
'short' => 'Grab every thumbnail size of any YouTube video, up to full 1280×720 HD.',
|
||||
'operation' => 'yt_thumbnails', 'kind' => 'url', 'featured' => true, 'popularity' => 8600,
|
||||
'aliases' => ['yt thumbnail', 'thumbnail grabber', 'yt thumb'],
|
||||
'intro' => 'Enter a YouTube URL and instantly preview and download all available thumbnail sizes — from the tiny default up to maxres HD. Thumbnails are fetched directly from Google\'s image servers, so nothing is stored here. Great for research, design references and archive work.',
|
||||
], $cat),
|
||||
self::tool([
|
||||
'slug' => 'youtube-shorts-downloader', 'name' => 'YouTube Shorts Downloader', 'icon' => 'phone',
|
||||
'short' => 'Save vertical YouTube Shorts as MP4 files without watermarks.',
|
||||
'operation' => 'yt_video_download', 'requires' => ['yt-dlp'],
|
||||
'popularity' => 5400,
|
||||
], $cat),
|
||||
self::tool([
|
||||
'slug' => 'youtube-playlist-downloader', 'name' => 'YouTube Playlist Downloader', 'icon' => 'list',
|
||||
'short' => 'Queue whole playlists for download in one pass, item by item.',
|
||||
'operation' => 'yt_playlist_download', 'requires' => ['yt-dlp'],
|
||||
'popularity' => 3300,
|
||||
], $cat),
|
||||
self::tool([
|
||||
'slug' => 'youtube-audio-downloader', 'name' => 'YouTube Audio Downloader', 'icon' => 'headphones',
|
||||
'short' => 'Download just the sound from YouTube videos in the format you choose.',
|
||||
'operation' => 'yt_audio_download', 'requires' => ['yt-dlp'], 'hidden_dup' => true,
|
||||
'popularity' => 4700,
|
||||
], $cat),
|
||||
self::tool([
|
||||
'slug' => 'youtube-converter', 'name' => 'YouTube Video Converter', 'icon' => 'swap',
|
||||
'short' => 'Turn YouTube links into MP4, WebM or MP3 files in one step.',
|
||||
'operation' => 'yt_video_download', 'requires' => ['yt-dlp'],
|
||||
'output_formats' => ['mp4', 'webm', 'mp3'], 'primary_output_format' => 'mp4',
|
||||
'popularity' => 5100,
|
||||
], $cat),
|
||||
|
||||
// ---- metadata / analysis tools: always functional -------------------
|
||||
self::tool([
|
||||
'slug' => 'youtube-metadata-viewer', 'name' => 'YouTube Metadata Viewer', 'icon' => 'search',
|
||||
'short' => 'Inspect title, channel, embed info and thumbnails of any YouTube video.',
|
||||
'operation' => 'yt_metadata', 'kind' => 'url', 'featured' => false, 'popularity' => 2600,
|
||||
'aliases' => ['yt metadata'],
|
||||
'intro' => 'Look up the official oEmbed metadata of any YouTube video: exact title, channel name, author profile link and available thumbnail. Useful for journalists verifying sources, developers testing embeds, and creators auditing their own library.',
|
||||
'faqs' => [
|
||||
['q' => 'What metadata can this tool show?', 'a' => 'It reads YouTube\'s official public oEmbed endpoint, which exposes the video title, author name, author channel URL and thumbnail URL. It does not expose private statistics such as view counts or likes.'],
|
||||
['q' => 'Does it work for unlisted videos?', 'a' => 'Unlisted videos respond to oEmbed if you know the link. Private videos do not.'],
|
||||
],
|
||||
], $cat),
|
||||
self::tool([
|
||||
'slug' => 'youtube-video-information', 'name' => 'YouTube Video Information', 'icon' => 'info',
|
||||
'short' => 'Quickly check a YouTube video\'s title, channel and preview image.',
|
||||
'operation' => 'yt_metadata', 'kind' => 'url', 'hidden_dup' => true, 'popularity' => 1900,
|
||||
], $cat),
|
||||
self::tool([
|
||||
'slug' => 'youtube-transcript', 'name' => 'YouTube Transcript Extractor', 'icon' => 'text',
|
||||
'short' => 'Pull the full transcript of a YouTube video as plain text or SRT/VTT.',
|
||||
'operation' => 'yt_transcript', 'kind' => 'url', 'requires' => ['yt-dlp'],
|
||||
'output_formats' => ['txt', 'srt', 'vtt'], 'popularity' => 6100,
|
||||
'intro' => 'Fetch the caption track of a YouTube video and export it as readable plain text, timed SRT subtitles or WebVTT. Transcripts make videos searchable, quotable and accessible — ideal for research notes, blog repurposing and accessibility work.',
|
||||
], $cat),
|
||||
self::tool([
|
||||
'slug' => 'youtube-subtitle-downloader', 'name' => 'YouTube Subtitle Downloader', 'icon' => 'text',
|
||||
'short' => 'Download closed captions from any YouTube video in SRT format.',
|
||||
'operation' => 'yt_transcript', 'kind' => 'url', 'requires' => ['yt-dlp'],
|
||||
'output_formats' => ['srt'], 'hidden_dup' => true, 'popularity' => 4300,
|
||||
], $cat),
|
||||
self::tool([
|
||||
'slug' => 'youtube-srt-downloader', 'name' => 'YouTube SRT Downloader', 'icon' => 'text',
|
||||
'short' => 'Export YouTube captions as standard .srt subtitle files.',
|
||||
'operation' => 'yt_transcript', 'kind' => 'url', 'requires' => ['yt-dlp'],
|
||||
'output_formats' => ['srt'], 'hidden_dup' => true, 'popularity' => 2800,
|
||||
], $cat),
|
||||
self::tool([
|
||||
'slug' => 'youtube-vtt-downloader', 'name' => 'YouTube VTT Downloader', 'icon' => 'text',
|
||||
'short' => 'Save YouTube captions as WebVTT (.vtt) files for HTML5 players.',
|
||||
'operation' => 'yt_transcript', 'kind' => 'url', 'requires' => ['yt-dlp'],
|
||||
'output_formats' => ['vtt'], 'hidden_dup' => true, 'popularity' => 2400,
|
||||
], $cat),
|
||||
self::tool([
|
||||
'slug' => 'youtube-channel-information', 'name' => 'YouTube Channel Information', 'icon' => 'users',
|
||||
'short' => 'Resolve a YouTube channel URL and inspect its avatar and identity links.',
|
||||
'operation' => 'yt_channel_info', 'kind' => 'url', 'popularity' => 1500,
|
||||
'intro' => 'Paste any channel handle or /channel/ URL to resolve its canonical form and retrieve its public avatar. Handy when auditing brand accounts or building creator directories.',
|
||||
], $cat),
|
||||
self::tool([
|
||||
'slug' => 'youtube-url-analyzer', 'name' => 'YouTube URL Analyzer', 'icon' => 'link',
|
||||
'short' => 'Break down any YouTube URL: type, video ID, playlist ID, start time and more.',
|
||||
'operation' => 'yt_url_analyze', 'kind' => 'text', 'client' => true, 'popularity' => 2200,
|
||||
'intro' => 'Paste a messy YouTube URL and get a structured read-out: whether it is a standard watch page, Shorts, an embed, a live stream or a playlist link — plus the extracted video ID, playlist parameter and timestamp offset. Runs entirely in your browser.',
|
||||
'how_to_override' => [
|
||||
['name' => 'Paste the URL', 'text' => 'Copy any YouTube link into the analyzer field.'],
|
||||
['name' => 'Read the breakdown', 'text' => 'The parsed components appear instantly — no button needed.'],
|
||||
],
|
||||
], $cat),
|
||||
self::tool([
|
||||
'slug' => 'youtube-embed-generator', 'name' => 'YouTube Embed Code Generator', 'icon' => 'code',
|
||||
'short' => 'Create privacy-friendly, responsive YouTube iframe embed codes instantly.',
|
||||
'operation' => 'yt_embed', 'kind' => 'text', 'client' => true, 'popularity' => 3400,
|
||||
'intro' => 'Generate clean <iframe> embed code from any YouTube link. Choose player options — start time, captions, privacy-enhanced nocookie domain — and copy production-ready responsive HTML. Everything runs client-side; your links never leave the browser.',
|
||||
], $cat),
|
||||
self::tool([
|
||||
'slug' => 'youtube-timestamp-generator', 'name' => 'YouTube Timestamp Generator', 'icon' => 'clock',
|
||||
'short' => 'Build shareable ?t= timestamps that open YouTube at the exact second.',
|
||||
'operation' => 'yt_timestamp', 'kind' => 'text', 'client' => true, 'popularity' => 2600,
|
||||
'intro' => 'Create deep links that jump viewers to a precise moment in a video. Enter minutes and seconds, get both the short youtu.be?t= form and the long form, then copy either. Perfect for show-notes, documentation and comments.',
|
||||
], $cat),
|
||||
];
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Video family
|
||||
// =====================================================================
|
||||
|
||||
private static function videoTools(): array
|
||||
{
|
||||
$tools = [];
|
||||
|
||||
// container conversions (generated)
|
||||
$pairs = [
|
||||
['mp4', 'webm'], ['webm', 'mp4'], ['mkv', 'mp4'], ['mov', 'mp4'],
|
||||
['avi', 'mp4'], ['mp4', 'mov'], ['mp4', 'mkv'], ['mp4', 'avi'],
|
||||
];
|
||||
foreach ($pairs as [$from, $to]) {
|
||||
$tools[] = self::converter($from, $to, 'video-tools');
|
||||
}
|
||||
|
||||
// action tools
|
||||
$actions = [
|
||||
['video-converter', 'Video Converter', 'convert', 'Convert between MP4, WebM, MOV, MKV and AVI online without installing software.', ['mp4', 'webm', 'mov', 'mkv', 'avi'], 'mp4', true, 7800],
|
||||
['video-compressor', 'Video Compressor', 'compress', 'Shrink large videos up to 70% smaller while keeping sharp quality.', null, 'mp4', true, 9200],
|
||||
['video-resizer', 'Video Resizer', 'resize', 'Change video dimensions for Instagram, TikTok, YouTube and more.', null, 'mp4', false, 4100],
|
||||
['video-cropper', 'Video Cropper', 'crop', 'Cut away borders or focus the frame by cropping videos precisely.', null, 'mp4', false, 3200],
|
||||
['video-trimmer', 'Video Trimmer', 'trim', 'Trim videos to the exact moments that matter — frame-accurate cuts.', null, 'mp4', true, 6400],
|
||||
['video-cutter', 'Video Cutter', 'cut', 'Cut out a clip from any video without re-encoding the rest.', null, 'mp4', false, 5200],
|
||||
['video-merger', 'Video Merger', 'merge', 'Join multiple clips into one continuous video, back to back.', null, 'mp4', false, 3600],
|
||||
['video-rotator', 'Video Rotator', 'rotate', 'Fix sideways or upside-down phone videos by rotating 90° or 180°.', null, 'mp4', false, 2900],
|
||||
['video-flipper', 'Video Flipper', 'flip', 'Mirror videos horizontally or vertically in one click.', null, 'mp4', false, 1800],
|
||||
['video-speed-changer', 'Video Speed Changer', 'speed', 'Speed videos up or slow them down from 0.25× to 4× with audio pitch correction.', null, 'mp4', false, 2700],
|
||||
['video-fps-converter', 'Video FPS Converter', 'fps', 'Convert video frame rates between 24, 30 and 60 fps smoothly.', null, 'mp4', false, 1600],
|
||||
['video-resolution-converter', 'Video Resolution Converter', 'resize', 'Scale videos to 1080p, 720p or any height while preserving aspect ratio.', null, 'mp4', false, 2500],
|
||||
];
|
||||
foreach ($actions as [$slug, $name, $op, $short, $inFmts, $outFmt, $feat, $pop]) {
|
||||
$tools[] = self::actionTool($slug, $name, $op, $short, 'video-tools', $inFmts ?? [], $outFmt, $feat, $pop);
|
||||
}
|
||||
|
||||
$special = [
|
||||
['video-to-gif', 'Video to GIF', 'video_to_gif', 'Turn any video segment into a smooth looping GIF with smart palette generation.', ['gif'], true, 8100],
|
||||
['extract-audio-from-video', 'Extract Audio from Video', 'extract_audio', 'Pull the soundtrack out of any video as MP3, WAV or M4A.', ['mp3', 'wav', 'm4a', 'aac'], true, 7200],
|
||||
['add-audio-to-video', 'Add Audio to Video', 'add_audio', 'Replace or overlay an audio track onto your video file.', ['mp4'], false, 3100],
|
||||
['remove-audio-from-video', 'Remove Audio from Video', 'mute', 'Delete the sound track completely while keeping original video quality.', ['mp4'], false, 3400],
|
||||
['mute-video', 'Mute Video', 'mute', 'Silence any video instantly — perfect before posting to social feeds.', ['mp4'], false, 2300],
|
||||
['add-subtitle-to-video', 'Add Subtitle to Video', 'add_subtitles', 'Embed SRT or VTT subtitles directly into your MP4 so they play everywhere.', ['mp4'], false, 2900],
|
||||
['remove-metadata-from-video', 'Remove Metadata from Video', 'remove_metadata', 'Strip GPS coordinates, camera names and timestamps out of video files.', ['mp4'], false, 1700],
|
||||
['extract-frames-from-video', 'Extract Frames from Video', 'extract_frames', 'Export video frames as JPG images at any frames-per-second rate.', ['jpg'], false, 2100],
|
||||
];
|
||||
foreach ($special as [$slug, $name, $op, $short, $outs, $feat, $pop]) {
|
||||
$t = self::actionTool($slug, $name, $op, $short, 'video-tools', [], $outs[0], $feat, $pop);
|
||||
$t['output_formats'] = $outs;
|
||||
$tools[] = $t;
|
||||
}
|
||||
|
||||
return $tools;
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Audio family
|
||||
// =====================================================================
|
||||
|
||||
private static function audioTools(): array
|
||||
{
|
||||
$tools = [];
|
||||
$audioExts = ['mp3', 'wav', 'm4a', 'flac', 'ogg'];
|
||||
foreach ($audioExts as $from) {
|
||||
foreach ($audioExts as $to) {
|
||||
if ($from !== $to) {
|
||||
$tools[] = self::converter($from, $to, 'audio-tools');
|
||||
}
|
||||
}
|
||||
}
|
||||
// aac targets too
|
||||
foreach ([['wav', 'aac'], ['m4a', 'aac'], ['mp3', 'aac']] as [$f, $t]) {
|
||||
$tools[] = self::converter($f, $t, 'audio-tools');
|
||||
}
|
||||
|
||||
$actions = [
|
||||
['audio-converter', 'Audio Converter', 'convert', 'Convert any audio file between MP3, WAV, M4A, FLAC and OGG formats.', true, 8300],
|
||||
['mp3-converter', 'MP3 Converter', 'convert', 'Turn recordings and exports into universally playable MP3 files.', false, 6100],
|
||||
['audio-cutter', 'Audio Cutter', 'trim', 'Cut songs and recordings down to the section you actually need.', false, 4800],
|
||||
['audio-trimmer', 'Audio Trimmer', 'trim', 'Trim the start and end off audio files with second-level precision.', false, 4200],
|
||||
['audio-merger', 'Audio Merger', 'merge', 'Combine several tracks into one continuous audio file.', false, 3300],
|
||||
['audio-compressor', 'Audio Compressor', 'compress', 'Reduce audio file size by lowering bitrate without audible damage.', false, 3700],
|
||||
['audio-normalizer', 'Audio Normalizer', 'normalize', 'Even out loudness across recordings to broadcast-standard -16 LUFS.', false, 3100],
|
||||
['audio-speed-changer', 'Audio Speed Changer', 'speed', 'Play audio faster or slower while keeping natural voice pitch.', false, 2600],
|
||||
['audio-volume-booster', 'Audio Volume Booster', 'volume', 'Make quiet recordings louder by boosting gain up to +30 dB.', false, 4500],
|
||||
];
|
||||
foreach ($actions as [$slug, $name, $op, $short, $feat, $pop]) {
|
||||
$tools[] = self::actionTool($slug, $name, $op, $short, 'audio-tools', ['mp3', 'wav', 'm4a', 'flac', 'ogg'], 'mp3', $feat, $pop);
|
||||
}
|
||||
|
||||
return $tools;
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Image family
|
||||
// =====================================================================
|
||||
|
||||
private static function imageTools(): array
|
||||
{
|
||||
$tools = [];
|
||||
$imgExts = ['jpg', 'png', 'webp'];
|
||||
foreach ($imgExts as $from) {
|
||||
foreach ($imgExts as $to) {
|
||||
if ($from !== $to) {
|
||||
$tools[] = self::converter($from, $to, 'image-tools');
|
||||
}
|
||||
}
|
||||
}
|
||||
foreach ([['avif', 'jpg'], ['avif', 'png'], ['bmp', 'jpg'], ['bmp', 'png'], ['jpg', 'avif'], ['png', 'avif']] as [$f, $t]) {
|
||||
$tools[] = self::converter($f, $t, 'image-tools');
|
||||
}
|
||||
|
||||
$actions = [
|
||||
['image-compressor', 'Image Compressor', 'compress', 'Compress JPG, PNG and WebP images up to 80% smaller with no visible loss.', true, 8900],
|
||||
['image-resizer', 'Image Resizer', 'resize', 'Resize images to exact pixel dimensions or scale by percentage.', true, 7600],
|
||||
['image-cropper', 'Image Cropper', 'crop', 'Crop photos and graphics to the perfect framing.', false, 4300],
|
||||
['image-converter', 'Image Converter', 'convert', 'One-stop conversion between JPG, PNG, WebP, AVIF and more.', true, 5600],
|
||||
['image-rotator', 'Image Rotator', 'rotate', 'Rotate images by 90°, 180° or 270° and fix EXIF orientation automatically.', false, 2200],
|
||||
['image-flipper', 'Image Flipper', 'flip', 'Mirror images horizontally or vertically in a single click.', false, 1400],
|
||||
['image-metadata-remover', 'Image Metadata Remover', 'strip_meta', 'Erase EXIF data — location, device, timestamps — from photos.', false, 3300],
|
||||
['exif-viewer', 'EXIF Viewer', 'exif_view', 'Inspect hidden EXIF metadata inside your photos, fully offline.', false, 2800],
|
||||
['exif-remover', 'EXIF Remover', 'strip_meta', 'Re-encode images without EXIF blocks while keeping visual quality.', false, 2500],
|
||||
];
|
||||
foreach ($actions as [$slug, $name, $op, $short, $feat, $pop]) {
|
||||
$tools[] = self::actionTool($slug, $name, $op, $short, 'image-tools', ['jpg', 'png', 'webp', 'gif', 'avif'], 'jpg', $feat, $pop);
|
||||
}
|
||||
|
||||
// text/client utilities living under image-tools
|
||||
$tools[] = self::tool([
|
||||
'slug' => 'favicon-generator', 'name' => 'Favicon Generator', 'icon' => 'star',
|
||||
'short' => 'Turn any square image into crisp favicons for every browser and device.',
|
||||
'operation' => 'favicon', 'popularity' => 3200,
|
||||
'input_formats' => ['png', 'jpg', 'webp'], 'output_formats' => ['ico', 'png'],
|
||||
'intro' => 'Upload a logo and receive ready-to-deploy favicon files: classic .ico plus 16/32/180/192 px PNGs for Apple touch icons and Android/Chrome manifests, with the HTML tags you need to paste into your site head.',
|
||||
], 'image-tools');
|
||||
|
||||
return $tools;
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// GIF family
|
||||
// =====================================================================
|
||||
|
||||
private static function gifTools(): array
|
||||
{
|
||||
$tools = [];
|
||||
$actions = [
|
||||
['gif-maker', 'GIF Maker', 'video_to_gif', 'Create animated GIFs from video clips with custom width and framerate.', true, 6800],
|
||||
['gif-compressor', 'GIF Compressor', 'compress', 'Shrink heavy GIFs via palette optimization and smarter dithering.', false, 3900],
|
||||
['gif-resizer', 'GIF Resizer', 'resize', 'Scale GIF animations down for chat apps, forums and email.', false, 2700],
|
||||
['gif-optimizer', 'GIF Optimizer', 'compress', 'Reduce GIF weight with automatic color-table tuning.', false, 2400],
|
||||
['gif-frame-extractor', 'GIF Frame Extractor', 'extract_frames', 'Dump every GIF frame as numbered PNG stills.', false, 1900],
|
||||
];
|
||||
foreach ($actions as [$slug, $name, $op, $short, $feat, $pop]) {
|
||||
$tools[] = self::actionTool($slug, $name, $op, $short, 'gif-tools', ['gif', 'mp4'], 'gif', $feat, $pop);
|
||||
}
|
||||
$tools[] = self::tool([
|
||||
'slug' => 'images-to-gif', 'name' => 'Images to GIF Maker', 'icon' => 'layers',
|
||||
'short' => 'Assemble multiple PNG/JPG frames into one animated GIF with chosen delay.',
|
||||
'operation' => 'frames_to_gif', 'popularity' => 3000,
|
||||
'input_formats' => ['png', 'jpg'], 'output_formats' => ['gif'],
|
||||
'intro' => 'Upload a sequence of images — screenshots, render frames, stop-motion shots — set a per-frame delay, and get a single looping GIF. Frames are combined in upload order with automatic canvas sizing.',
|
||||
], 'gif-tools');
|
||||
|
||||
return $tools;
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// PDF family
|
||||
// =====================================================================
|
||||
|
||||
private static function pdfTools(): array
|
||||
{
|
||||
$tools = [];
|
||||
|
||||
foreach ([['jpg', 'pdf'], ['png', 'pdf']] as [$f, $t]) {
|
||||
$tools[] = self::converter($f, $t, 'pdf-tools');
|
||||
}
|
||||
foreach ([['pdf', 'jpg'], ['pdf', 'png']] as [$f, $t]) {
|
||||
$tools[] = self::converter($f, $t, 'pdf-tools');
|
||||
}
|
||||
|
||||
$actions = [
|
||||
['image-to-pdf', 'Image to PDF', 'images_to_pdf', 'Combine JPG and PNG images into a single multi-page PDF document.', true, 5900],
|
||||
['pdf-compressor', 'PDF Compressor', 'pdf_compress', 'Reduce PDF size with screen/ebook/print presets — perfect under email limits.', true, 8200],
|
||||
['pdf-merger', 'PDF Merger', 'pdf_merge', 'Merge unlimited PDFs into one ordered document.', true, 7400],
|
||||
['pdf-splitter', 'PDF Splitter', 'pdf_split', 'Split a PDF into separate single-page documents, zipped for convenience.', false, 4600],
|
||||
['pdf-page-extractor', 'PDF Page Extractor', 'pdf_extract_pages', 'Keep only the pages you need — ranges like 1-3 or 2,5,7.', false, 3500],
|
||||
['pdf-metadata-remover', 'PDF Metadata Remover', 'pdf_remove_metadata', 'Strip author, producer and creation data from PDF files.', false, 2000],
|
||||
];
|
||||
foreach ($actions as [$slug, $name, $op, $short, $feat, $pop]) {
|
||||
$t = self::actionTool($slug, $name, $op, $short, 'pdf-tools', ['pdf'], 'pdf', $feat, $pop);
|
||||
$t['requires_binaries'] = ['qpdf', 'gs'];
|
||||
$tools[] = $t;
|
||||
}
|
||||
|
||||
foreach (['pdf-to-text' => ['PDF to Text Converter', 'Convert PDF documents into plain text files with the layout preserved.'],
|
||||
'pdf-text-extractor' => ['PDF Text Extractor', 'Pull selectable text out of any text-based PDF in seconds — copy-ready output.']] as $slug => $meta) {
|
||||
$t = self::actionTool($slug, $meta[0], 'pdf_to_text', $meta[1], 'pdf-tools', ['pdf'], 'txt', $slug === 'pdf-text-extractor', 4000);
|
||||
$t['requires_binaries'] = ['pdftotext'];
|
||||
$tools[] = $t;
|
||||
}
|
||||
|
||||
return $tools;
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Utilities
|
||||
// =====================================================================
|
||||
|
||||
private static function utilityTools(): array
|
||||
{
|
||||
$u = static fn (array $def) => self::tool($def, 'utilities');
|
||||
|
||||
return [
|
||||
$u(['slug' => 'qr-code-generator', 'name' => 'QR Code Generator', 'icon' => 'qr',
|
||||
'short' => 'Generate high-resolution QR codes for URLs, Wi-Fi, vCards and plain text.',
|
||||
'operation' => 'qr_generate', 'kind' => 'text', 'server_render' => true, 'featured' => true, 'popularity' => 8800,
|
||||
'intro' => 'Type any text or link and download a print-ready QR code as PNG or SVG. Codes are generated server-side with error-correction level M, so they scan reliably even after printing. Nothing is logged — the generator forgets your input immediately.',
|
||||
'faqs' => [['q' => 'Do the generated QR codes expire?', 'a' => 'Never. These are static QR codes: the data lives inside the pattern itself, not on our servers, so they keep working forever.'],
|
||||
['q' => 'What can I encode?', 'a' => 'Any UTF-8 text up to about 1,800 characters — URLs, Wi-Fi credentials, phone numbers, plain messages.']],
|
||||
]),
|
||||
$u(['slug' => 'hash-generator', 'name' => 'Hash Generator', 'icon' => 'shield',
|
||||
'short' => 'Compute MD5, SHA-1, SHA-256 and SHA-512 checksums of any text instantly.',
|
||||
'operation' => 'hash_text', 'kind' => 'text', 'client' => true, 'popularity' => 4200,
|
||||
'intro' => 'Paste text and get MD5, SHA-1, SHA-256 and SHA-512 digests computed locally in your browser — useful for verifying strings, generating cache keys or checking password-hash formats. The input never reaches the server.',
|
||||
]),
|
||||
$u(['slug' => 'uuid-generator', 'name' => 'UUID Generator', 'icon' => 'key',
|
||||
'short' => 'Bulk-generate RFC 4122 version 4 UUIDs with one click.',
|
||||
'operation' => 'uuid_gen', 'kind' => 'text', 'client' => true, 'popularity' => 3600,
|
||||
'intro' => 'Generate cryptographically random UUIDv4 identifiers in bulk (up to 500 at once), uppercase or lowercase, with or without dashes. Uses the browser crypto API — suitable for database keys and distributed systems.',
|
||||
]),
|
||||
$u(['slug' => 'json-formatter', 'name' => 'JSON Formatter & Validator', 'icon' => 'code',
|
||||
'short' => 'Pretty-print, minify and validate JSON with precise error positions.',
|
||||
'operation' => 'json_format', 'kind' => 'text', 'client' => true, 'popularity' => 5400,
|
||||
'intro' => 'Paste messy JSON and get it formatted with configurable indentation, or minified for transport. Syntax errors report the exact line and character so fixes take seconds. All parsing happens in-browser.',
|
||||
]),
|
||||
$u(['slug' => 'url-encoder', 'name' => 'URL Encoder', 'icon' => 'link',
|
||||
'short' => 'Percent-encode text and query parameters safely for use in URLs.',
|
||||
'operation' => 'url_encode', 'kind' => 'text', 'client' => true, 'popularity' => 3100,
|
||||
]),
|
||||
$u(['slug' => 'url-decoder', 'name' => 'URL Decoder', 'icon' => 'link',
|
||||
'short' => 'Decode percent-encoded URLs back into human-readable text.',
|
||||
'operation' => 'url_decode', 'kind' => 'text', 'client' => true, 'popularity' => 2900,
|
||||
]),
|
||||
$u(['slug' => 'base64-image-encoder', 'name' => 'Base64 Image Encoder', 'icon' => 'code',
|
||||
'short' => 'Convert images into Base64 data URIs ready to inline in CSS or HTML.',
|
||||
'operation' => 'base64_encode_img', 'kind' => 'upload', 'client' => true, 'popularity' => 3400,
|
||||
'input_formats' => ['png', 'jpg', 'gif', 'webp', 'svg'], 'output_formats' => ['txt'],
|
||||
]),
|
||||
$u(['slug' => 'base64-image-decoder', 'name' => 'Base64 Image Decoder', 'icon' => 'image',
|
||||
'short' => 'Turn Base64 data URIs back into downloadable image files.',
|
||||
'operation' => 'base64_decode_img', 'kind' => 'text', 'client' => true, 'popularity' => 2200,
|
||||
'output_formats' => ['png', 'jpg'],
|
||||
]),
|
||||
$u(['slug' => 'timestamp-converter', 'name' => 'Unix Timestamp Converter', 'icon' => 'clock',
|
||||
'short' => 'Convert Unix epoch seconds to human dates and back, in UTC or local time.',
|
||||
'operation' => 'timestamp_convert', 'kind' => 'text', 'client' => true, 'popularity' => 3800,
|
||||
]),
|
||||
$u(['slug' => 'color-converter', 'name' => 'Color Converter', 'icon' => 'palette',
|
||||
'short' => 'Convert colors between HEX, RGB, HSL with live preview and contrast checks.',
|
||||
'operation' => 'color_convert', 'kind' => 'text', 'client' => true, 'popularity' => 3300,
|
||||
]),
|
||||
$u(['slug' => 'mime-type-lookup', 'name' => 'MIME Type Lookup', 'icon' => 'file',
|
||||
'short' => 'Find the correct MIME type and extension for hundreds of media formats.',
|
||||
'operation' => 'mime_lookup', 'kind' => 'text', 'client' => true, 'popularity' => 2100,
|
||||
]),
|
||||
$u(['slug' => 'file-extension-lookup', 'name' => 'File Extension Lookup', 'icon' => 'folder',
|
||||
'short' => 'Identify what unknown file extensions are and which apps open them.',
|
||||
'operation' => 'ext_lookup', 'kind' => 'text', 'client' => true, 'popularity' => 1900,
|
||||
]),
|
||||
$u(['slug' => 'media-metadata-viewer', 'name' => 'Media Metadata Viewer', 'icon' => 'search',
|
||||
'short' => 'Inspect technical details of uploaded media: codecs, dimensions, duration, EXIF.',
|
||||
'operation' => 'media_probe', 'kind' => 'upload', 'popularity' => 2600,
|
||||
'input_formats' => ['mp4', 'mov', 'jpg', 'png', 'mp3', 'wav', 'pdf'], 'output_formats' => [],
|
||||
'intro' => 'Upload any supported media file and see its internals: container, codecs, resolution, duration, bitrates and embedded metadata. Files are analyzed in an isolated worker and deleted within hours — useful before publishing assets publicly.',
|
||||
]),
|
||||
];
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// generators
|
||||
// =====================================================================
|
||||
|
||||
/** Format-pair converter definition with programmatic SEO content. */
|
||||
private static function converter(string $from, string $to, string $category): array
|
||||
{
|
||||
$slug = "{$from}-to-{$to}";
|
||||
$fromN = FormatCatalog::name($from);
|
||||
$toN = FormatCatalog::name($to);
|
||||
$seed = crc32($slug);
|
||||
|
||||
$titles = [
|
||||
"{$fromN} to {$toN} Converter – Free Online",
|
||||
"Convert {$fromN} to {$toN} Online – Free Tool",
|
||||
"{$fromN} to {$toN}: Free Online Converter",
|
||||
];
|
||||
|
||||
$descriptors = match (FormatCatalog::family($to)) {
|
||||
'audio' => [
|
||||
'Extract the audio from your {$FROM} files as {$TO}. Upload a file, choose your bitrate, and get a clean {$TO} track in seconds — no software to install.',
|
||||
'Pull {$TO} audio out of {$FROM} media with our free converter. Processing happens on dedicated workers, so even long files convert quickly.',
|
||||
],
|
||||
'video' => [
|
||||
'Convert {$FROM} video to {$TO} online with adjustable quality. Fast processing, no watermark, no signup required.',
|
||||
'Turn {$FROM} files into {$TO} videos that play everywhere. Our converter balances size and quality automatically.',
|
||||
],
|
||||
'document' => [
|
||||
'Combine {$FROM} images into a tidy {$TO} document. Pages are built in upload order at full resolution.',
|
||||
'Build a {$TO} from your {$FROM} pictures — free, quick and without watermarks.',
|
||||
],
|
||||
default => [
|
||||
'Convert {$FROM} images to {$TO} format online. Free, fast and without uploading anything to social sites.',
|
||||
'Transform {$FROM} graphics into {$TO} files with optional quality control. Perfect for web performance work.',
|
||||
],
|
||||
};
|
||||
|
||||
$descriptor = $descriptors[$seed % count($descriptors)];
|
||||
$descriptor = str_replace(['{$FROM}', '{$TO}'], [$fromN, $toN], $descriptor);
|
||||
|
||||
$requires = match (FormatCatalog::family($from)) {
|
||||
'video' => ['ffmpeg'],
|
||||
'audio' => ['ffmpeg'],
|
||||
'document' => ['gs'],
|
||||
default => [],
|
||||
};
|
||||
|
||||
return self::tool([
|
||||
'slug' => $slug,
|
||||
'name' => "{$fromN} to {$toN} Converter",
|
||||
'short' => $descriptor,
|
||||
'operation' => 'convert',
|
||||
'format_in' => $from,
|
||||
'format_out' => $to,
|
||||
'requires' => $requires,
|
||||
'popularity' => 900 + ((int) abs($seed) % 1500),
|
||||
'title_pool' => $titles,
|
||||
], $category);
|
||||
}
|
||||
|
||||
private static function actionTool(string $slug, string $name, string $operation, string $short, string $category, array $inFormats, string $outFormat, bool $featured, int $popularity): array
|
||||
{
|
||||
return self::tool([
|
||||
'slug' => $slug,
|
||||
'name' => $name,
|
||||
'short' => $short,
|
||||
'operation' => $operation,
|
||||
'input_formats' => $inFormats ?: null,
|
||||
'format_out' => $outFormat !== '' ? $outFormat : null,
|
||||
'featured' => $featured,
|
||||
'popularity' => $popularity,
|
||||
'requires' => match (FormatCatalog::family((string) ($inFormats[0] ?? $outFormat))) {
|
||||
'video', 'audio' => ['ffmpeg'],
|
||||
'document' => ['qpdf', 'pdftoppm'],
|
||||
default => [],
|
||||
},
|
||||
], $category);
|
||||
}
|
||||
|
||||
/**
|
||||
* Assemble one complete tool row: SEO fields, how-to steps and FAQs
|
||||
* composed from real attributes (formats, operation), varied by slug.
|
||||
*/
|
||||
private static function tool(array $def, string $category): array
|
||||
{
|
||||
$slug = $def['slug'];
|
||||
$name = $def['name'] ?? ucwords(str_replace('-', ' ', $slug));
|
||||
$seed = abs((int) (crc32($slug)));
|
||||
$inFmt = $def['format_in'] ?? ($def['input_formats'][0] ?? null);
|
||||
$outFmt = $def['format_out'] ?? ($def['output_formats'][0] ?? null);
|
||||
$kind = $def['kind'] ?? (($def['operation'] ?? '') === 'yt_metadata' || str_starts_with($def['operation'] ?? '', 'yt_') ? 'url' : (isset($def['client']) && $def['client'] ? 'text' : 'upload'));
|
||||
|
||||
$titlePool = $def['title_pool'] ?? [
|
||||
"{$name} – Free Online Tool",
|
||||
"{$name} – Free & Fast",
|
||||
"Free Online {$name}",
|
||||
];
|
||||
$seoTitle = $titlePool[$seed % count($titlePool)];
|
||||
|
||||
$seoDescription = mb_substr(trim(($def['short'] ?? $name . ' online. Free, fast and easy to use — no installation or signup required.')), 0, 300);
|
||||
|
||||
$intro = $def['intro']
|
||||
?? trim(($def['short'] ?? '') . ' ' . ($outFmt && $inFmt
|
||||
? 'Files are processed on dedicated servers and deleted automatically — nothing is shared with third parties.'
|
||||
: 'Everything runs in your browser where possible, and processed files are deleted automatically.'));
|
||||
|
||||
// HowTo steps derived from the actual kind
|
||||
$howTo = $def['how_to_override'] ?? match ($kind) {
|
||||
'url' => [
|
||||
['name' => 'Copy the link', 'text' => 'Open the video on YouTube and copy its URL from the address bar or share menu.'],
|
||||
['name' => 'Paste the URL', 'text' => 'Paste the link into the input box above and press Convert.'],
|
||||
['name' => 'Choose your output', 'text' => 'Pick the format or quality offered for your video.'],
|
||||
['name' => 'Download the result', 'text' => 'When processing finishes, save the file to your device.'],
|
||||
],
|
||||
'text' => [
|
||||
['name' => 'Enter your input', 'text' => 'Type or paste your text into the field above.'],
|
||||
['name' => 'Get instant results', 'text' => 'Results update immediately — no waiting queue.'],
|
||||
['name' => 'Copy or download', 'text' => 'Use the copy button or download the output file.'],
|
||||
],
|
||||
default => [
|
||||
['name' => 'Select your file', 'text' => 'Click the upload area or drag a file onto it.'],
|
||||
['name' => 'Adjust options if needed', 'text' => 'Default settings suit most cases; advanced options stay hidden until you expand them.'],
|
||||
['name' => 'Start processing', 'text' => 'Press the Convert button — progress is shown live.'],
|
||||
['name' => 'Download', 'text' => 'Save the finished file when processing completes.'],
|
||||
],
|
||||
};
|
||||
|
||||
// FAQs composed from genuine attributes
|
||||
$faqs = $def['faqs'] ?? [];
|
||||
if ($faqs === []) {
|
||||
$fmtSentenceIn = $inFmt ? FormatCatalog::blurb($inFmt) : '';
|
||||
$whatDoes = $outFmt && $inFmt
|
||||
? "It converts {$fmtSentenceIn} into " . FormatCatalog::blurb($outFmt) . '.'
|
||||
: 'It processes your file on our servers and gives you a downloadable result.';
|
||||
$faqs = [
|
||||
['q' => "What is {$name}?", 'a' => $whatDoes],
|
||||
['q' => 'How do I use it?', 'a' => match ($kind) {
|
||||
'url' => 'Paste the link into the input field, press Convert, then download the result.',
|
||||
'text' => 'Type or paste your text — results appear instantly in your browser.',
|
||||
default => 'Upload your file, adjust optional settings, press Convert, then download the output.',
|
||||
}],
|
||||
['q' => 'Is it really free?', 'a' => 'Yes. There are no watermarks, signups or hidden fees for normal usage.'],
|
||||
['q' => 'How long does processing take?', 'a' => 'Small files finish in seconds. Longer media scales with duration because we never cut corners on quality.'],
|
||||
['q' => 'Are my files safe?', 'a' => 'Uploads travel over encrypted connections and are stored temporarily only for processing. They are deleted automatically — see our privacy policy for retention details.'],
|
||||
];
|
||||
if ($kind === 'upload') {
|
||||
$faqs[] = ['q' => 'Why did my conversion fail?', 'a' => 'The most common causes are an unsupported source file, a corrupted upload, or exceeding the size limit. Try the file again or check the accepted formats listed on this page.'];
|
||||
}
|
||||
}
|
||||
|
||||
$inputFormats = $def['input_formats'] ?? (isset($inFmt) ? [$inFmt] : []);
|
||||
$outputFormats = $def['output_formats'] ?? (isset($outFmt) ? [$outFmt] : []);
|
||||
|
||||
return [
|
||||
'category' => $category,
|
||||
'slug' => $slug,
|
||||
'name' => $name,
|
||||
'kind' => $kind,
|
||||
'operation' => $def['operation'] ?? 'convert',
|
||||
'short_description' => $def['short'] ?? '',
|
||||
'description' => $def['intro'] ?? null,
|
||||
'input_formats' => $inputFormats,
|
||||
'output_formats' => $outputFormats,
|
||||
'primary_input_format' => $inFmt,
|
||||
'primary_output_format'=> $outFmt,
|
||||
'icon' => $def['icon'] ?? 'file',
|
||||
'seo_title' => $def['seo_title'] ?? $seoTitle,
|
||||
'seo_description' => $def['seo_description'] ?? $seoDescription,
|
||||
'h1' => $def['h1'] ?? $name,
|
||||
'intro' => $intro,
|
||||
'how_to' => $howTo,
|
||||
'faqs' => $faqs,
|
||||
'related_slugs' => $def['related'] ?? null,
|
||||
'aliases' => $def['aliases'] ?? [],
|
||||
'requires_binaries' => $def['requires'] ?? [],
|
||||
'accepts_mimes' => this_mimes($inputFormats),
|
||||
'max_upload_mb' => 0,
|
||||
'popularity' => $def['popularity'] ?? 1000,
|
||||
'is_featured' => (int) ($def['featured'] ?? 0),
|
||||
'status' => 'active',
|
||||
'hidden_dup' => (int) ($def['hidden_dup'] ?? 0),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/** MIME list helper (kept module-level for clarity). */
|
||||
function this_mimes(array $extensions): array
|
||||
{
|
||||
$map = [
|
||||
'mp4' => 'video/mp4', 'webm' => 'video/webm', 'mov' => 'video/quicktime', 'mkv' => 'video/x-matroska',
|
||||
'avi' => 'video/x-msvideo', 'gif' => 'image/gif', 'mp3' => 'audio/mpeg', 'wav' => 'audio/wav',
|
||||
'm4a' => 'audio/mp4', 'aac' => 'audio/aac', 'flac' => 'audio/flac', 'ogg' => 'audio/ogg',
|
||||
'opus' => 'audio/opus', 'wma' => 'audio/x-ms-wma', 'jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg',
|
||||
'png' => 'image/png', 'webp' => 'image/webp', 'avif' => 'image/avif', 'bmp' => 'image/bmp',
|
||||
'pdf' => 'application/pdf', 'srt' => 'application/x-subrip', 'vtt' => 'text/vtt',
|
||||
'svg' => 'image/svg+xml', 'zip' => 'application/zip', 'txt' => 'text/plain',
|
||||
];
|
||||
|
||||
return array_values(array_unique(array_map(
|
||||
static fn (string $e): string => $map[strtolower($e)] ?? 'application/octet-stream',
|
||||
$extensions
|
||||
)));
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Libraries;
|
||||
|
||||
use App\Models\CategoryModel;
|
||||
use App\Models\ToolModel;
|
||||
|
||||
/**
|
||||
* Request-cached accessor for the tool registry. Every page, sitemap,
|
||||
* route and link block goes through here so the DB is hit at most once
|
||||
* per dataset per request.
|
||||
*/
|
||||
final class Finder
|
||||
{
|
||||
private ?array $bySlug = null;
|
||||
|
||||
/** @var array<int,array>|null */
|
||||
private ?array $categories = null;
|
||||
|
||||
public function tool(string $slug): ?array
|
||||
{
|
||||
if ($this->bySlug === null) {
|
||||
$tools = model(ToolModel::class)->allActive();
|
||||
$this->bySlug = [];
|
||||
foreach ($tools as $tool) {
|
||||
// decode JSON columns once per request
|
||||
foreach (['input_formats', 'output_formats', 'how_to', 'faqs', 'related_slugs', 'aliases', 'requires_binaries', 'accepts_mimes'] as $col) {
|
||||
if (isset($tool[$col]) && is_string($tool[$col])) {
|
||||
$decoded = json_decode($tool[$col], true);
|
||||
$tool[$col] = is_array($decoded) ? $decoded : [];
|
||||
} elseif (! isset($tool[$col])) {
|
||||
$tool[$col] = [];
|
||||
}
|
||||
}
|
||||
$this->bySlug[$tool['slug']] = $tool;
|
||||
}
|
||||
}
|
||||
|
||||
return $this->bySlug[mb_strtolower($slug)] ?? null;
|
||||
}
|
||||
|
||||
public function categories(): array
|
||||
{
|
||||
if ($this->categories === null) {
|
||||
$this->categories = model(CategoryModel::class)->activeOrdered();
|
||||
}
|
||||
|
||||
return $this->categories;
|
||||
}
|
||||
|
||||
public function category(string $slug): ?array
|
||||
{
|
||||
foreach ($this->categories() as $category) {
|
||||
if ($category['slug'] === $slug) {
|
||||
foreach (['faqs'] as $col) {
|
||||
if (isset($category[$col]) && is_string($category[$col])) {
|
||||
$decoded = json_decode($category[$col], true);
|
||||
$category[$col] = is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
}
|
||||
|
||||
return $category;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function categoryById(int $id): ?array
|
||||
{
|
||||
foreach ($this->categories() as $category) {
|
||||
if ((int) $category['id'] === $id) {
|
||||
return $category;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function toolsInCategory(string $slug): array
|
||||
{
|
||||
$category = $this->category($slug);
|
||||
if ($category === null) {
|
||||
return [];
|
||||
}
|
||||
$tools = model(ToolModel::class)->activeInCategory((int) $category['id']);
|
||||
foreach ($tools as &$t) {
|
||||
$this->decodeTool($t);
|
||||
}
|
||||
|
||||
return $tools;
|
||||
}
|
||||
|
||||
public function popular(int $limit = 8): array
|
||||
{
|
||||
$tools = model(ToolModel::class)->popular($limit);
|
||||
array_walk($tools, [$this, 'decodeTool']);
|
||||
|
||||
return $tools;
|
||||
}
|
||||
|
||||
public function featured(int $limit = 8): array
|
||||
{
|
||||
$tools = model(ToolModel::class)->featured($limit);
|
||||
array_walk($tools, [$this, 'decodeTool']);
|
||||
|
||||
return $tools;
|
||||
}
|
||||
|
||||
public function recent(int $limit = 6): array
|
||||
{
|
||||
$tools = model(ToolModel::class)->recent($limit);
|
||||
array_walk($tools, [$this, 'decodeTool']);
|
||||
|
||||
return $tools;
|
||||
}
|
||||
|
||||
public function all(): array
|
||||
{
|
||||
return array_values($this->index());
|
||||
}
|
||||
|
||||
public function countTools(): int
|
||||
{
|
||||
return count($this->index());
|
||||
}
|
||||
|
||||
private function index(): array
|
||||
{
|
||||
if ($this->bySlug === null) {
|
||||
$this->tool('__warm__'); // forces load
|
||||
}
|
||||
|
||||
return $this->bySlug ?? [];
|
||||
}
|
||||
|
||||
public static function decodeTool(array &$tool): void
|
||||
{
|
||||
foreach (['input_formats', 'output_formats', 'how_to', 'faqs', 'related_slugs', 'aliases', 'requires_binaries', 'accepts_mimes'] as $col) {
|
||||
if (isset($tool[$col]) && is_string($tool[$col])) {
|
||||
$decoded = json_decode($tool[$col], true);
|
||||
$tool[$col] = is_array($decoded) ? $decoded : [];
|
||||
} elseif (! isset($tool[$col])) {
|
||||
$tool[$col] = [];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Libraries;
|
||||
|
||||
/**
|
||||
* Canonical media-format knowledge base.
|
||||
*
|
||||
* Every converter landing page inherits its copy from here, so
|
||||
* /jpg-to-png and /png-to-webp each get accurate, specific prose about
|
||||
* their real input/output formats — not interchangeable filler.
|
||||
*/
|
||||
final class FormatCatalog
|
||||
{
|
||||
/** @var array<string, array{name:string,family:string,mime:string,blurb:string}> */
|
||||
public const FORMATS = [
|
||||
// --- video containers -------------------------------------------------
|
||||
'mp4' => ['name' => 'MP4', 'family' => 'video', 'mime' => 'video/mp4', 'blurb' => 'the universal video container supported by virtually every device, browser and social platform'],
|
||||
'webm' => ['name' => 'WebM', 'family' => 'video', 'mime' => 'video/webm', 'blurb' => 'an open, lightweight format designed for the web and HTML5 video'],
|
||||
'mov' => ['name' => 'MOV', 'family' => 'video', 'mime' => 'video/quicktime', 'blurb' => 'Apple\'s QuickTime container, common on iPhones and Mac editing workflows'],
|
||||
'mkv' => ['name' => 'MKV', 'family' => 'video', 'mime' => 'video/x-matroska', 'blurb' => 'a flexible open container that holds multiple audio and subtitle tracks'],
|
||||
'avi' => ['name' => 'AVI', 'family' => 'video', 'mime' => 'video/x-msvideo', 'blurb' => 'a legacy Windows container still used by older cameras and recorders'],
|
||||
'gif' => ['name' => 'GIF', 'family' => 'image', 'mime' => 'image/gif', 'blurb' => 'a looping animation format made of indexed-color frames'],
|
||||
|
||||
// --- audio -------------------------------------------------------------
|
||||
'mp3' => ['name' => 'MP3', 'family' => 'audio', 'mime' => 'audio/mpeg', 'blurb' => 'the most widely compatible lossy audio format — plays literally everywhere'],
|
||||
'wav' => ['name' => 'WAV', 'family' => 'audio', 'mime' => 'audio/wav', 'blurb' => 'uncompressed PCM audio prized for editing and maximum fidelity'],
|
||||
'm4a' => ['name' => 'M4A', 'family' => 'audio', 'mime' => 'audio/mp4', 'blurb' => 'Apple\'s AAC-based audio format — smaller than MP3 at the same quality'],
|
||||
'aac' => ['name' => 'AAC', 'family' => 'audio', 'mime' => 'audio/aac', 'blurb' => 'the successor to MP3 used by YouTube, iOS and streaming services'],
|
||||
'flac' => ['name' => 'FLAC', 'family' => 'audio', 'mime' => 'audio/flac', 'blurb' => 'lossless compression that keeps every bit of the original recording'],
|
||||
'ogg' => ['name' => 'OGG', 'family' => 'audio', 'mime' => 'audio/ogg', 'blurb' => 'an open-source Vorbis audio container popular with game and web developers'],
|
||||
'opus' => ['name' => 'Opus', 'family' => 'audio', 'mime' => 'audio/opus', 'blurb' => 'the modern low-latency codec behind WebRTC and Discord'],
|
||||
'wma' => ['name' => 'WMA', 'family' => 'audio', 'mime' => 'audio/x-ms-wma', 'blurb' => 'Microsoft\'s legacy Windows Media Audio format'],
|
||||
|
||||
// --- images ------------------------------------------------------------
|
||||
'jpg' => ['name' => 'JPG', 'family' => 'image', 'mime' => 'image/jpeg', 'blurb' => 'the standard photo format — small files, universally viewable, no transparency'],
|
||||
'jpeg' => ['name' => 'JPEG', 'family' => 'image', 'mime' => 'image/jpeg', 'blurb' => 'identical to JPG — the extension spelling differs only'],
|
||||
'png' => ['name' => 'PNG', 'family' => 'image', 'mime' => 'image/png', 'blurb' => 'lossless images with crisp edges and full transparency support'],
|
||||
'webp' => ['name' => 'WebP', 'family' => 'image', 'mime' => 'image/webp', 'blurb' => 'Google\'s modern image format — typically 25–35% smaller than JPG at similar quality'],
|
||||
'avif' => ['name' => 'AVIF', 'family' => 'image', 'mime' => 'image/avif', 'blurb' => 'next-generation AV1-based images offering excellent compression and HDR support'],
|
||||
'bmp' => ['name' => 'BMP', 'family' => 'image', 'mime' => 'image/bmp', 'blurb' => 'uncompressed bitmap graphics, mostly seen in legacy Windows software'],
|
||||
|
||||
// --- documents ----------------------------------------------------------
|
||||
'pdf' => ['name' => 'PDF', 'family' => 'document', 'mime' => 'application/pdf', 'blurb' => 'the portable document format that preserves layout everywhere'],
|
||||
];
|
||||
|
||||
public static function name(string $ext): string
|
||||
{
|
||||
return self::FORMATS[strtolower($ext)]['name'] ?? strtoupper($ext);
|
||||
}
|
||||
|
||||
public static function blurb(string $ext): string
|
||||
{
|
||||
return self::FORMATS[strtolower($ext)]['blurb'] ?? strtoupper($ext) . ' files';
|
||||
}
|
||||
|
||||
public static function family(string $ext): string
|
||||
{
|
||||
return self::FORMATS[strtolower($ext)]['family'] ?? 'file';
|
||||
}
|
||||
|
||||
public static function mime(string $ext): string
|
||||
{
|
||||
return self::FORMATS[strtolower($ext)]['mime'] ?? 'application/octet-stream';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Libraries;
|
||||
|
||||
use App\Libraries\Pipeline\DriverInterface;
|
||||
use App\Libraries\Pipeline\Ffmpeg;
|
||||
use App\Libraries\Pipeline\Images;
|
||||
use App\Libraries\Pipeline\Pdf;
|
||||
use App\Libraries\Pipeline\Qr;
|
||||
use App\Libraries\Pipeline\Youtube;
|
||||
use App\Models\JobModel;
|
||||
|
||||
/**
|
||||
* Media pipeline facade.
|
||||
*
|
||||
* Browser -> Web app -> API -> Job queue -> Worker -> FFmpeg/etc -> temp storage -> download
|
||||
*
|
||||
* The web tier never processes media inline: controllers only enqueue and
|
||||
* poll. Workers call Pipeline::process() from the CLI.
|
||||
*/
|
||||
final class Pipeline
|
||||
{
|
||||
/** @var array<string, DriverInterface> */
|
||||
private array $drivers;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->drivers = [
|
||||
'ffmpeg' => new Ffmpeg(),
|
||||
'image' => new Images(),
|
||||
'pdf' => new Pdf(),
|
||||
'youtube' => new Youtube(),
|
||||
'qr' => new Qr(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Enqueue a job after full validation. Returns the job id.
|
||||
*
|
||||
* @param array $tool registry row
|
||||
* @param array $params structured, allow-listed parameters
|
||||
*/
|
||||
public function enqueue(array $tool, array $params = [], string $source = 'web'): string
|
||||
{
|
||||
$site = config('Site');
|
||||
$jobs = model(JobModel::class);
|
||||
$session = service('analytics')->sessionHash();
|
||||
$ipHash = service('analytics')->ipHash();
|
||||
|
||||
// ---- abuse guards -------------------------------------------------
|
||||
if ($jobs->countQueued() >= $site->queueMaxLength) {
|
||||
throw new \RuntimeException('The processing queue is currently full. Please try again shortly.');
|
||||
}
|
||||
|
||||
$hourAgo = gmdate('Y-m-d H:i:s', time() - 3600);
|
||||
$perSessionIp = $jobs->db->table('jobs')
|
||||
->where('created_at >=', $hourAgo)
|
||||
->whereIn('status', ['queued', 'processing', 'completed'])
|
||||
->groupStart()->where('session_id', $session)->orWhere('ip_hash', $ipHash)->groupEnd()
|
||||
->countAllResults();
|
||||
|
||||
$limit = min($site->maxJobsPerHourSession + ($source === 'api' ? 60 : 0), max($site->maxJobsPerHourIp, $site->maxJobsPerHourSession));
|
||||
if ($perSessionIp >= $site->maxJobsPerHourIp && $source === 'web') {
|
||||
throw new \RuntimeException('Hourly limit reached. Please wait before starting more jobs.');
|
||||
}
|
||||
|
||||
$concurrent = $jobs->where('session_id', $session)->whereIn('status', ['queued', 'processing'])->countAllResults();
|
||||
if ($concurrent >= $site->maxConcurrentSession) {
|
||||
throw new \RuntimeException('You already have the maximum number of jobs running. Please wait for them to finish.');
|
||||
}
|
||||
|
||||
// ---- capability check ----------------------------------------------
|
||||
foreach (($tool['requires_binaries'] ?? []) as $bin) {
|
||||
if (! in_array($bin, ['yt-dlp'], true)) { // yt-dlp handled by its driver
|
||||
try {
|
||||
Process::binary((string) $bin, ['-version'], 10);
|
||||
} catch (\RuntimeException) {
|
||||
throw new \RuntimeException("This tool is temporarily unavailable on this server.");
|
||||
}
|
||||
}
|
||||
}
|
||||
if (in_array('yt-dlp', $tool['requires_binaries'] ?? [], true) && ! $this->youtubeEnabled()) {
|
||||
throw new \RuntimeException('This tool is disabled on this server.');
|
||||
}
|
||||
|
||||
return $jobs->create([
|
||||
'session_id' => $session,
|
||||
'ip_hash' => $ipHash,
|
||||
'tool_id' => $tool['id'],
|
||||
'operation' => $params['_operation'] ?? ($tool['operation'] ?: 'convert'),
|
||||
'params' => json_encode($params, JSON_UNESCAPED_UNICODE),
|
||||
'input_file' => $params['_input_file'] ?? null,
|
||||
'input_name' => $params['_input_name'] ?? null,
|
||||
'input_size' => (int) ($params['_input_size'] ?? 0),
|
||||
'input_mime' => $params['_input_mime'] ?? null,
|
||||
'status' => 'queued',
|
||||
'priority' => $source === 'api' ? 3 : 5,
|
||||
'source' => $source,
|
||||
]);
|
||||
}
|
||||
|
||||
public function youtubeEnabled(): bool
|
||||
{
|
||||
if (! config('Site')->ytDlpEnabled) {
|
||||
return false;
|
||||
}
|
||||
$path = config('Site')->binaries['yt-dlp'];
|
||||
if (! is_executable($path)) {
|
||||
return false;
|
||||
}
|
||||
// feature flag may also be flipped at runtime from admin settings
|
||||
$setting = model(\App\Models\SettingModel::class)->get('ytdlp_enabled');
|
||||
if ($setting !== null) {
|
||||
return $setting === '1';
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Executed ONLY by workers. Runs the driver for a job row. */
|
||||
public function process(array $job): void
|
||||
{
|
||||
$jobs = model(JobModel::class);
|
||||
$tool = [];
|
||||
if ($job['tool_id'] !== null) {
|
||||
$toolRow = $jobs->db->table('tools')->where('id', $job['tool_id'])->get()->getFirstRow('array');
|
||||
if ($toolRow !== null) {
|
||||
Finder::decodeTool($toolRow);
|
||||
$tool = $toolRow;
|
||||
}
|
||||
}
|
||||
|
||||
$params = json_decode($job['params'] ?? '{}', true) ?: [];
|
||||
|
||||
$driver = match (true) {
|
||||
str_starts_with($job['operation'], 'yt_') => $this->drivers['youtube'],
|
||||
$job['operation'] === 'qr_generate' => $this->drivers['qr'],
|
||||
isset($tool['requires_binaries']) && in_array('qpdf', $tool['requires_binaries'] ?? [], true) => $this->drivers['pdf'],
|
||||
isset($tool['requires_binaries']) && in_array('pdftoppm', $tool['requires_binaries'] ?? [], true) => $this->drivers['pdf'],
|
||||
isset($tool['requires_binaries']) && in_array('ffmpeg', $tool['requires_binaries'] ?? [], true) => $this->drivers['ffmpeg'],
|
||||
default => $this->pickByOperation($job['operation'], $tool),
|
||||
};
|
||||
|
||||
try {
|
||||
$result = $driver->handle($job, $tool, function (int $progress, string $stage) use ($jobs, $job): void {
|
||||
$jobs->update($job['id'], ['progress' => min(99, max(1, $progress)), 'stage' => $stage]);
|
||||
});
|
||||
|
||||
$jobs->update($job['id'], [
|
||||
'status' => 'completed',
|
||||
'progress' => 100,
|
||||
'stage' => 'complete',
|
||||
'output_file' => $result['file'],
|
||||
'output_name' => $result['name'],
|
||||
'output_size' => filesize(WRITEPATH . 'media/' . $result['file']) ?: 0,
|
||||
'completed_at' => gmdate('Y-m-d H:i:s'),
|
||||
]);
|
||||
service('analytics')->track('tool_success', ['tool' => $tool['slug'] ?? '', 'format' => $result['ext'] ?? '']);
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'job {id} failed: {m}', ['id' => $job['id'], 'm' => $e->getMessage()]);
|
||||
$jobs->update($job['id'], [
|
||||
'status' => 'failed',
|
||||
'stage' => 'failed',
|
||||
'error' => mb_substr($e->getMessage(), 0, 500),
|
||||
'completed_at' => gmdate('Y-m-d H:i:s'),
|
||||
]);
|
||||
service('analytics')->track('tool_failure', ['tool' => $tool['slug'] ?? '']);
|
||||
}
|
||||
}
|
||||
|
||||
private function pickByOperation(string $operation, array $tool = []): DriverInterface
|
||||
{
|
||||
// explicit PDF operations
|
||||
$pdfOps = ['pdf_merge', 'pdf_split', 'pdf_extract_pages', 'pdf_compress',
|
||||
'pdf_remove_metadata', 'pdf_to_jpg', 'pdf_to_png', 'pdf_to_text', 'images_to_pdf'];
|
||||
if (in_array($operation, $pdfOps, true)) {
|
||||
return $this->drivers['pdf'];
|
||||
}
|
||||
|
||||
// route by format families of the registered tool
|
||||
$formats = array_filter([
|
||||
$tool['primary_input_format'] ?? null,
|
||||
$tool['primary_output_format'] ?? null,
|
||||
]);
|
||||
$families = array_map(static fn ($f) => FormatCatalog::family((string) $f), $formats);
|
||||
|
||||
if ($formats !== []) {
|
||||
if (in_array('document', $families, true)) {
|
||||
return $this->drivers['pdf'];
|
||||
}
|
||||
if (! in_array('video', $families, true) && ! in_array('audio', $families, true)) {
|
||||
return $this->drivers['image'];
|
||||
}
|
||||
}
|
||||
|
||||
return $this->drivers['ffmpeg'];
|
||||
}
|
||||
|
||||
/** Mark a job failed with a safe message (used by worker + reaper). */
|
||||
public static function failJob(string $jobId, string $reason): void
|
||||
{
|
||||
model(\App\Models\JobModel::class)->update($jobId, [
|
||||
'status' => 'failed',
|
||||
'stage' => 'failed',
|
||||
'error' => mb_substr($reason, 0, 500),
|
||||
'completed_at' => gmdate('Y-m-d H:i:s'),
|
||||
]);
|
||||
}
|
||||
|
||||
/** Temp storage root (never web-accessible directly). */
|
||||
public static function storageDir(): string
|
||||
{
|
||||
$dir = WRITEPATH . 'media';
|
||||
is_dir($dir) || mkdir($dir, 0750, true);
|
||||
|
||||
return $dir;
|
||||
}
|
||||
|
||||
public static function incomingDir(): string
|
||||
{
|
||||
$dir = WRITEPATH . 'incoming';
|
||||
is_dir($dir) || mkdir($dir, 0750, true);
|
||||
|
||||
return $dir;
|
||||
}
|
||||
|
||||
/** Safe filename inside storage: uuid + extension only. */
|
||||
public static function safeName(string $uuid, string $ext): string
|
||||
{
|
||||
return preg_match('/^[a-f0-9-]{36}$/', $uuid) === 1
|
||||
? $uuid . '.' . preg_replace('/[^a-z0-9]/i', '', strtolower($ext))
|
||||
: throw new \InvalidArgumentException('bad name');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Libraries\Pipeline;
|
||||
|
||||
/**
|
||||
* A processing driver turns a job row into an output file.
|
||||
* handle() receives the raw job, the registry tool and an update()
|
||||
* callback for progress reporting (0-99 + stage label), and must
|
||||
* return ['file' => basename, 'name' => download name, 'ext' => 'mp3'].
|
||||
*/
|
||||
interface DriverInterface
|
||||
{
|
||||
public function handle(array $job, array $tool, callable $update): array;
|
||||
}
|
||||
@@ -0,0 +1,573 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Libraries\Pipeline;
|
||||
|
||||
use App\Libraries\Process;
|
||||
|
||||
/**
|
||||
* FFmpeg / ffprobe driver for video and audio operations.
|
||||
*
|
||||
* Security model: every operation maps to a fixed argument template.
|
||||
* User input only fills validated placeholders (numbers, enums, paths
|
||||
* inside the storage sandbox) — never raw strings appended to a command.
|
||||
*/
|
||||
final class Ffmpeg implements DriverInterface
|
||||
{
|
||||
private const AUDIO_EXT = ['mp3', 'wav', 'm4a', 'aac', 'flac', 'ogg', 'opus', 'wma'];
|
||||
|
||||
/** Audio codec mapping: ext => [codec, extra args] */
|
||||
private const AUDIO_CODECS = [
|
||||
'mp3' => ['libmp3lame', ['-b:a']],
|
||||
'wav' => ['pcm_s16le', []],
|
||||
'm4a' => ['aac', ['-b:a']],
|
||||
'aac' => ['aac', ['-b:a']],
|
||||
'flac' => ['flac', []],
|
||||
'ogg' => ['libvorbis', ['-b:a']],
|
||||
'opus' => ['libopus', ['-b:a']],
|
||||
];
|
||||
|
||||
public function handle(array $job, array $tool, callable $update): array
|
||||
{
|
||||
$params = json_decode($job['params'] ?? '{}', true) ?: [];
|
||||
$input = self::resolveInput($job);
|
||||
$update(5, 'fetching');
|
||||
|
||||
return match ($job['operation']) {
|
||||
'convert' => isset($params['_mode']) && $params['_mode'] === 'audio' || in_array($this->targetExt($job, $tool), self::AUDIO_EXT, true)
|
||||
? $this->convertAudio($input, $job, $tool, $update, $params)
|
||||
: $this->convertVideo($input, $job, $tool, $update, $params),
|
||||
'compress' => $this->compress($input, $job, $tool, $update, $params),
|
||||
'resize' => $this->scale($input, $job, $tool, $update, $params),
|
||||
'crop' => $this->crop($input, $job, $tool, $update, $params),
|
||||
'trim' => $this->trim($input, $job, $tool, $update, $params),
|
||||
'cut' => $this->trim($input, $job, $tool, $update, $params),
|
||||
'merge' => $this->merge($input, $job, $tool, $update),
|
||||
'rotate' => $this->transpose($input, $job, $tool, $update, $params, false),
|
||||
'flip' => $this->transpose($input, $job, $tool, $update, $params, true),
|
||||
'speed' => $this->speed($input, $job, $tool, $update, $params),
|
||||
'fps' => $this->fps($input, $job, $tool, $update, $params),
|
||||
'extract_audio' => $this->extractAudio($input, $job, $tool, $update, $params),
|
||||
'remove_audio' => $this->mute($input, $job, $tool, $update, keepVideo: true, silent: true),
|
||||
'mute' => $this->mute($input, $job, $tool, $update, keepVideo: true, silent: true),
|
||||
'add_audio' => $this->addAudio($input, $job, $tool, $update, $params),
|
||||
'add_subtitles' => $this->addSubtitles($input, $job, $tool, $update, $params),
|
||||
'remove_metadata' => $this->stripMetadata($input, $job, $tool, $update),
|
||||
'extract_frames' => $this->framesZip($input, $job, $update, $params),
|
||||
'video_to_gif' => $this->toGif($input, $job, $tool, $update, $params),
|
||||
'gif_to_video' => $this->convertVideo($input, $job, $tool, $update, ['_force_ext' => 'mp4']),
|
||||
'normalize' => $this->normalizeAudio($input, $job, $tool, $update),
|
||||
'volume' => $this->volume($input, $job, $tool, $update, $params),
|
||||
default => throw new \RuntimeException('Unsupported operation.'),
|
||||
};
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// helpers shared by operations
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
public static function resolveInput(array $job): string
|
||||
{
|
||||
foreach ([\App\Libraries\Pipeline::incomingDir(), \App\Libraries\Pipeline::storageDir()] as $dir) {
|
||||
$file = self::storagePath((string) ($job['input_file'] ?? ''), $dir);
|
||||
if (is_file($file)) {
|
||||
return $file;
|
||||
}
|
||||
}
|
||||
throw new \RuntimeException('Input file missing.');
|
||||
}
|
||||
|
||||
public static function storagePath(string $basename, ?string $baseDir = null): string
|
||||
{
|
||||
if (preg_match('/^[A-Za-z0-9._-]+$/', $basename) !== 1 || str_contains($basename, '..')) {
|
||||
throw new \RuntimeException('Invalid file reference.');
|
||||
}
|
||||
$baseDir ??= \App\Libraries\Pipeline::storageDir();
|
||||
$path = rtrim($baseDir, '/') . '/' . $basename;
|
||||
if (! str_starts_with(realpath(dirname($path)) ?: '', realpath($baseDir) ?: '')) {
|
||||
throw new \RuntimeException('Invalid file path.');
|
||||
}
|
||||
|
||||
return $path;
|
||||
}
|
||||
|
||||
private function probe(string $file): array
|
||||
{
|
||||
$proc = Process::binary('ffprobe', [
|
||||
'-v', 'quiet', '-print_format', 'json',
|
||||
'-show_format', '-show_streams', $file,
|
||||
], 30);
|
||||
if (! $proc->run()) {
|
||||
throw new \RuntimeException('Could not read the media file.');
|
||||
}
|
||||
$data = json_decode($proc->out(), true);
|
||||
|
||||
return is_array($data) ? $data : [];
|
||||
}
|
||||
|
||||
private function targetExt(array $job, array $tool): string
|
||||
{
|
||||
$params = json_decode($job['params'] ?? '{}', true) ?: [];
|
||||
$ext = strtolower((string) ($params['format'] ?? $tool['primary_output_format'] ?? ''));
|
||||
if (preg_match('/^[a-z0-9]{1,6}$/', $ext) !== 1) {
|
||||
throw new \RuntimeException('Unknown output format.');
|
||||
}
|
||||
|
||||
return $ext;
|
||||
}
|
||||
|
||||
private function output(array $job, string $ext): array
|
||||
{
|
||||
$file = \App\Libraries\Pipeline::safeName($job['id'], $ext);
|
||||
|
||||
return [$file, \App\Libraries\Pipeline::storageDir() . '/' . $file];
|
||||
}
|
||||
|
||||
private function num(mixed $value, float $min, float $max, float $default): float
|
||||
{
|
||||
if (! is_numeric($value)) {
|
||||
return $default;
|
||||
}
|
||||
$v = (float) $value;
|
||||
|
||||
return max($min, min($max, $v));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// operations
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
private function convertAudio(string $input, array $job, array $tool, callable $update, array $params): array
|
||||
{
|
||||
$ext = $this->targetExt($job, $tool);
|
||||
if (! isset(self::AUDIO_CODECS[$ext])) {
|
||||
throw new \RuntimeException("Format {$ext} is not supported here.");
|
||||
}
|
||||
[$codec, $extra] = self::AUDIO_CODECS[$ext];
|
||||
|
||||
$bitrate = (int) $this->num($params['bitrate'] ?? 192, 32, 320, 192);
|
||||
$args = ['-y', '-i', $input, '-vn', '-c:a', $codec];
|
||||
foreach ($extra as $flag) {
|
||||
$args[] = $flag;
|
||||
$args[] = ($bitrate) . 'k';
|
||||
}
|
||||
if ($ext === 'mp3' || $ext === 'flac') {
|
||||
$args[] = '-metadata';
|
||||
$args[] = 'comment=Processed with Toolvana';
|
||||
}
|
||||
[, $out] = $this->output($job, $ext);
|
||||
$args[] = $out;
|
||||
|
||||
$update(15, 'converting');
|
||||
$proc = Process::binary('ffmpeg', $args, config('Site')->maxProcessingSeconds);
|
||||
if (! $proc->run()) {
|
||||
throw new \RuntimeException('Conversion failed. The file may be corrupted or in an unsupported format.');
|
||||
}
|
||||
|
||||
return ['file' => basename($out), 'name' => $this->downloadName($job, $ext), 'ext' => $ext];
|
||||
}
|
||||
|
||||
private function convertVideo(string $input, array $job, array $tool, callable $update, array $params): array
|
||||
{
|
||||
$ext = strtolower((string) ($params['_force_ext'] ?? $this->targetExt($job, $tool)));
|
||||
// [video args, uses CRF?, audio args, mux args]
|
||||
$map = [
|
||||
'mp4' => [['-c:v', 'libx264', '-preset', 'medium'], true, ['-c:a', 'aac', '-b:a', '160k'], ['-movflags', '+faststart']],
|
||||
'webm' => [['-c:v', 'libvpx-vp9'], true, ['-c:a', 'libopus', '-b:a', '128k'], []],
|
||||
'mkv' => [['-c:v', 'libx264', '-preset', 'medium'], true, ['-c:a', 'copy'], []],
|
||||
'mov' => [['-c:v', 'libx264', '-preset', 'medium'], true, ['-c:a', 'aac', '-b:a', '160k'], []],
|
||||
'avi' => [['-c:v', 'mpeg4'], false, ['-c:a', 'libmp3lame', '-b:a', '192k'], []],
|
||||
];
|
||||
if (! isset($map[$ext])) {
|
||||
throw new \RuntimeException("Format {$ext} is not supported here.");
|
||||
}
|
||||
[$videoArgs, $usesCrf, $audioArgs, $muxArgs] = $map[$ext];
|
||||
|
||||
$args = ['-y', '-i', $input, ...$videoArgs];
|
||||
if ($usesCrf) {
|
||||
$args[] = '-crf';
|
||||
$args[] = (string) (int) $this->num($params['quality'] ?? 23, 18, 34, 23);
|
||||
} else {
|
||||
$args[] = '-qscale:v';
|
||||
$args[] = '3';
|
||||
}
|
||||
$args = [...$args, ...$audioArgs, ...$muxArgs];
|
||||
|
||||
[, $out] = $this->output($job, $ext);
|
||||
$args[] = $out;
|
||||
|
||||
$update(10, 'converting');
|
||||
$proc = Process::binary('ffmpeg', $args, config('Site')->maxProcessingSeconds);
|
||||
if (! $proc->run()) {
|
||||
throw new \RuntimeException('Conversion failed. The source video could not be processed.');
|
||||
}
|
||||
|
||||
return ['file' => basename($out), 'name' => $this->downloadName($job, $ext), 'ext' => $ext];
|
||||
}
|
||||
|
||||
private function compress(string $input, array $job, array $tool, callable $update, array $params): array
|
||||
{
|
||||
$level = (int) $this->num($params['level'] ?? 28, 20, 38, 28); // CRF-style
|
||||
$info = $this->probe($input);
|
||||
$hasV = false;
|
||||
foreach ($info['streams'] ?? [] as $s) {
|
||||
if (($s['codec_type'] ?? '') === 'video') {
|
||||
$hasV = true;
|
||||
}
|
||||
}
|
||||
|
||||
$ext = $hasV ? 'mp4' : 'm4a';
|
||||
[, $out] = $this->output($job, $ext);
|
||||
|
||||
$args = ['-y', '-i', $input];
|
||||
if ($hasV) {
|
||||
$args = [...$args, '-c:v', 'libx264', '-preset', 'slow', '-crf', (string) $level, '-pix_fmt', 'yuv420p', '-movflags', '+faststart'];
|
||||
} else {
|
||||
$args = [...$args, '-c:a', 'aac', '-b:a', '96k'];
|
||||
}
|
||||
$args[] = $out;
|
||||
|
||||
$update(10, 'compressing');
|
||||
$proc = Process::binary('ffmpeg', $args, config('Site')->maxProcessingSeconds);
|
||||
if (! $proc->run()) {
|
||||
throw new \RuntimeException('Compression failed.');
|
||||
}
|
||||
|
||||
return ['file' => basename($out), 'name' => $this->downloadName($job, $ext), 'ext' => $ext];
|
||||
}
|
||||
|
||||
private function scale(string $input, array $job, array $tool, callable $update, array $params): array
|
||||
{
|
||||
$w = (int) $this->num($params['width'] ?? 0, 16, 7680, 0);
|
||||
$h = (int) $this->num($params['height'] ?? 0, 16, 4320, 0);
|
||||
if ($w === 0 && $h === 0) {
|
||||
throw new \RuntimeException('Choose a target width or height.');
|
||||
}
|
||||
$filter = $h > 0 ? "scale=-2:{$h}" : "scale={$w}:-2";
|
||||
|
||||
return $this->simpleVideoTransform($input, $job, $update, $filter, 'resizing');
|
||||
}
|
||||
|
||||
private function crop(string $input, array $job, array $tool, callable $update, array $params): array
|
||||
{
|
||||
$w = (int) $this->num($params['width'] ?? 1080, 16, 7680, 1080);
|
||||
$h = (int) $this->num($params['height'] ?? 1080, 16, 4320, 1080);
|
||||
$x = (int) $this->num($params['x'] ?? 0, 0, 7680, 0);
|
||||
$y = (int) $this->num($params['y'] ?? 0, 0, 4320, 0);
|
||||
|
||||
return $this->simpleVideoTransform($input, $job, $update, "crop={$w}:{$h}:{$x}:{$y}", 'cropping');
|
||||
}
|
||||
|
||||
private function trim(string $input, array $job, array $tool, callable $update, array $params): array
|
||||
{
|
||||
$start = max(0.0, $this->num($params['start'] ?? 0, 0, 86400, 0));
|
||||
$end = $this->num($params['end'] ?? 0, 0, 86401, 0);
|
||||
if ($end <= $start) {
|
||||
throw new \RuntimeException('End time must be after start time.');
|
||||
}
|
||||
$dur = $end - $start;
|
||||
|
||||
$ext = $this->guessContainer($input);
|
||||
[, $out] = $this->output($job, $ext);
|
||||
$proc = Process::binary('ffmpeg', [
|
||||
'-y', '-i', $input, '-ss', (string) $start, '-t', number_format($dur, 3, '.', ''),
|
||||
'-c', 'copy', '-movflags', '+faststart', $out,
|
||||
], config('Site')->maxProcessingSeconds);
|
||||
$update(20, 'cutting');
|
||||
if (! $proc->run() || ! is_file($out) || filesize($out) < 64) {
|
||||
// stream copy can fail on some sources — re-encode fallback
|
||||
$proc2 = Process::binary('ffmpeg', [
|
||||
'-y', '-i', $input, '-ss', (string) $start, '-t', number_format($dur, 3, '.', ''), $out,
|
||||
], config('Site')->maxProcessingSeconds);
|
||||
if (! $proc2->run()) {
|
||||
throw new \RuntimeException('Trim failed.');
|
||||
}
|
||||
}
|
||||
|
||||
return ['file' => basename($out), 'name' => $this->downloadName($job, $ext), 'ext' => $ext];
|
||||
}
|
||||
|
||||
private function merge(string $input, array $job, array $tool, callable $update): array
|
||||
{
|
||||
$extraFiles = json_decode($job['params'] ?? '{}', true)['extra_files'] ?? [];
|
||||
$files = [$input];
|
||||
foreach ((array) $extraFiles as $f) {
|
||||
$files[] = self::storagePath((string) $f);
|
||||
}
|
||||
if (count($files) < 2) {
|
||||
throw new \RuntimeException('At least two files are required to merge.');
|
||||
}
|
||||
|
||||
// concat demuxer needs a list file
|
||||
$listPath = \App\Libraries\Pipeline::incomingDir() . '/' . $job['id'] . '.txt';
|
||||
$lines = '';
|
||||
foreach ($files as $f) {
|
||||
$lines .= "file '" . addcslashes($f, "'\\") . "'\n";
|
||||
}
|
||||
file_put_contents($listPath, $lines);
|
||||
|
||||
[, $out] = $this->output($job, 'mp4');
|
||||
$update(15, 'merging');
|
||||
$proc = Process::binary('ffmpeg', ['-y', '-f', 'concat', '-safe', '0', '-i', $listPath, '-c', 'copy', '-movflags', '+faststart', $out], config('Site')->maxProcessingSeconds);
|
||||
@unlink($listPath);
|
||||
if (! $proc->run() || ! is_file($out) || filesize($out) < 64) {
|
||||
throw new \RuntimeException('Merge failed — files must share the same resolution and codecs.');
|
||||
}
|
||||
|
||||
return ['file' => basename($out), 'name' => $this->downloadName($job, 'mp4'), 'ext' => 'mp4'];
|
||||
}
|
||||
|
||||
private function transpose(string $input, array $job, array $tool, callable $update, array $params, bool $flip): array
|
||||
{
|
||||
if ($flip) {
|
||||
$dir = ($params['direction'] ?? 'horizontal') === 'vertical' ? 'vflip' : 'hflip';
|
||||
} else {
|
||||
$deg = (int) $this->num($params['degrees'] ?? 90, 90, 270, 90);
|
||||
$map = [90 => 'transpose=clock', 180 => 'transpose=clock,transpose=clock', 270 => 'transpose=cclock'];
|
||||
$dir = $map[$deg] ?? 'transpose=clock';
|
||||
}
|
||||
|
||||
return $this->simpleVideoTransform($input, $job, $update, $dir, $flip ? 'flipping' : 'rotating');
|
||||
}
|
||||
|
||||
private function speed(string $input, array $job, array $tool, callable $update, array $params): array
|
||||
{
|
||||
$factor = $this->num($params['factor'] ?? 1.5, 0.25, 4, 1.5);
|
||||
if (abs($factor - 1.0) < 0.05) {
|
||||
throw new \RuntimeException('Speed factor must differ from 1.0.');
|
||||
}
|
||||
$atempo = $factor >= 0.5 && $factor <= 2.0
|
||||
? 'atempo=' . number_format($factor, 3, '.', '')
|
||||
: 'atempo=2.0,atempo=' . number_format($factor / 2, 3, '.', '');
|
||||
|
||||
return $this->simpleVideoTransform(
|
||||
$input, $job, $update,
|
||||
'setpts=' . number_format(1 / $factor, 4, '.', '') . '*PTS,' . $atempo,
|
||||
'changing speed'
|
||||
);
|
||||
}
|
||||
|
||||
private function fps(string $input, array $job, array $tool, callable $update, array $params): array
|
||||
{
|
||||
$fps = $this->num($params['fps'] ?? 24, 1, 120, 24);
|
||||
|
||||
return $this->simpleVideoTransform($input, $job, $update, "fps={$fps}", 'converting fps');
|
||||
}
|
||||
|
||||
private function extractAudio(string $input, array $job, array $tool, callable $update, array $params): array
|
||||
{
|
||||
$params['format'] = $params['format'] ?? 'mp3';
|
||||
|
||||
return $this->convertAudio($input, $job, $tool, $update, $params);
|
||||
}
|
||||
|
||||
private function mute(string $input, array $job, array $tool, callable $update, bool $keepVideo, bool $silent): array
|
||||
{
|
||||
$ext = $this->guessContainer($input);
|
||||
[, $out] = $this->output($job, $ext);
|
||||
$update(15, 'removing audio');
|
||||
$proc = Process::binary('ffmpeg', ['-y', '-i', $input, '-an', '-c:v', 'copy', '-movflags', '+faststart', $out], config('Site')->maxProcessingSeconds);
|
||||
if (! $proc->run()) {
|
||||
throw new \RuntimeException('Could not remove the audio track.');
|
||||
}
|
||||
|
||||
return ['file' => basename($out), 'name' => $this->downloadName($job, $ext), 'ext' => $ext];
|
||||
}
|
||||
|
||||
private function addAudio(string $input, array $job, array $tool, callable $update, array $params): array
|
||||
{
|
||||
$audioFile = self::storagePath((string) ($params['audio_file'] ?? ''));
|
||||
[, $out] = $this->output($job, 'mp4');
|
||||
$update(15, 'adding audio');
|
||||
|
||||
$shortest = ! empty($params['stop_at_shortest']) ? ['-shortest'] : [];
|
||||
$proc = Process::binary('ffmpeg', [
|
||||
'-y', '-i', $input, '-i', $audioFile, '-map', '0:v:0', '-map', '1:a:0',
|
||||
'-c:v', 'copy', '-c:a', 'aac', '-b:a', '192k', ...$shortest, '-movflags', '+faststart', $out,
|
||||
], config('Site')->maxProcessingSeconds);
|
||||
if (! $proc->run()) {
|
||||
throw new \RuntimeException('Could not combine video and audio.');
|
||||
}
|
||||
|
||||
return ['file' => basename($out), 'name' => $this->downloadName($job, 'mp4'), 'ext' => 'mp4'];
|
||||
}
|
||||
|
||||
private function addSubtitles(string $input, array $job, array $tool, callable $update, array $params): array
|
||||
{
|
||||
$subFile = self::storagePath((string) ($params['subtitle_file'] ?? ''));
|
||||
[, $out] = $this->output($job, 'mp4');
|
||||
$update(15, 'adding subtitles');
|
||||
|
||||
$proc = Process::binary('ffmpeg', [
|
||||
'-y', '-i', $input, '-i', $subFile,
|
||||
'-c:v', 'libx264', '-preset', 'fast', '-crf', '22',
|
||||
'-c:a', 'copy', '-c:s', 'mov_text', '-movflags', '+faststart', $out,
|
||||
], config('Site')->maxProcessingSeconds);
|
||||
if (! $proc->run()) {
|
||||
throw new \RuntimeException('Could not embed subtitles. Use an SRT or VTT file.');
|
||||
}
|
||||
|
||||
return ['file' => basename($out), 'name' => $this->downloadName($job, 'mp4'), 'ext' => 'mp4'];
|
||||
}
|
||||
|
||||
private function stripMetadata(string $input, array $job, array $tool, callable $update): array
|
||||
{
|
||||
$ext = $this->guessContainer($input);
|
||||
[, $out] = $this->output($job, $ext);
|
||||
$update(20, 'removing metadata');
|
||||
$proc = Process::binary('ffmpeg', [
|
||||
'-y', '-i', $input, '-map_metadata', '-1', '-fflags', '+bitexact',
|
||||
'-flags:v', '+bitexact', '-flags:a', '+bitexact', '-c', 'copy', $out,
|
||||
], config('Site')->maxProcessingSeconds);
|
||||
if (! $proc->run()) {
|
||||
throw new \RuntimeException('Could not strip metadata.');
|
||||
}
|
||||
|
||||
return ['file' => basename($out), 'name' => $this->downloadName($job, $ext), 'ext' => $ext];
|
||||
}
|
||||
|
||||
private function framesZip(string $input, array $job, callable $update, array $params): array
|
||||
{
|
||||
$fps = $this->num($params['fps'] ?? 1, 0.1, 30, 1);
|
||||
$tmpDir = sys_get_temp_dir() . '/tv_frames_' . $job['id'];
|
||||
mkdir($tmpDir, 0700, true);
|
||||
$update(15, 'extracting frames');
|
||||
|
||||
$proc = Process::binary('ffmpeg', [
|
||||
'-y', '-i', $input, '-vf', "fps={$fps}", '-q:v', '2',
|
||||
rtrim($tmpDir, '/') . '/frame_%05d.jpg',
|
||||
], config('Site')->maxProcessingSeconds);
|
||||
if (! $proc->run()) {
|
||||
throw new \RuntimeException('Frame extraction failed.');
|
||||
}
|
||||
|
||||
$zipPath = \App\Libraries\Pipeline::storageDir() . '/' . \App\Libraries\Pipeline::safeName($job['id'], 'zip');
|
||||
$zip = new \ZipArchive();
|
||||
$zip->open($zipPath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE);
|
||||
$count = 0;
|
||||
foreach (glob(rtrim($tmpDir, '/') . '/frame_*.jpg') ?: [] as $frame) {
|
||||
$zip->addFile($frame, basename($frame));
|
||||
++$count;
|
||||
}
|
||||
$zip->close();
|
||||
foreach (glob(rtrim($tmpDir, '/') . '/*') ?: [] as $f) {
|
||||
@unlink($f);
|
||||
}
|
||||
@rmdir($tmpDir);
|
||||
|
||||
if ($count === 0) {
|
||||
throw new \RuntimeException('No frames were extracted.');
|
||||
}
|
||||
|
||||
return ['file' => basename($zipPath), 'name' => $this->downloadName($job, 'zip'), 'ext' => 'zip'];
|
||||
}
|
||||
|
||||
private function toGif(string $input, array $job, array $tool, callable $update, array $params): array
|
||||
{
|
||||
$width = (int) $this->num($params['width'] ?? 480, 64, 1280, 480);
|
||||
$fps = $this->num($params['fps'] ?? 12, 4, 24, 12);
|
||||
[, $out] = $this->output($job, 'gif');
|
||||
$palette = sys_get_temp_dir() . '/tv_palette_' . $job['id'] . '.png';
|
||||
|
||||
$update(10, 'analyzing colors');
|
||||
$p1 = Process::binary('ffmpeg', [
|
||||
'-y', '-t', '60', '-i', $input, '-vf',
|
||||
"fps={$fps},scale={$width}:-1:flags=lanczos,palettegen", $palette,
|
||||
], 300);
|
||||
$p1->run();
|
||||
|
||||
$update(25, 'generating GIF');
|
||||
$vf = "fps={$fps},scale={$width}:-1:flags=lanczos";
|
||||
if (is_file($palette)) {
|
||||
$vf .= '[x];[x][1:v]paletteuse';
|
||||
$args = ['-y', '-i', $input, '-i', $palette, '-filter_complex', $vf, $out];
|
||||
} else {
|
||||
$args = ['-y', '-i', $input, '-vf', $vf, $out];
|
||||
}
|
||||
$p2 = Process::binary('ffmpeg', $args, config('Site')->maxProcessingSeconds);
|
||||
@unlink($palette);
|
||||
if (! $p2->run() || ! is_file($out) || filesize($out) === 0) {
|
||||
throw new \RuntimeException('GIF generation failed.');
|
||||
}
|
||||
|
||||
return ['file' => basename($out), 'name' => $this->downloadName($job, 'gif'), 'ext' => 'gif'];
|
||||
}
|
||||
|
||||
private function normalizeAudio(string $input, array $job, array $tool, callable $update): array
|
||||
{
|
||||
$params = json_decode($job['params'] ?? '{}', true) ?: [];
|
||||
$ext = $params['format'] ?? 'mp3';
|
||||
[$codec, $extra] = self::AUDIO_CODECS[strtolower((string) $ext)] ?? self::AUDIO_CODECS['mp3'];
|
||||
[, $out] = $this->output($job, $ext);
|
||||
|
||||
$update(10, 'normalizing');
|
||||
$args = ['-y', '-i', $input, '-af', 'loudnorm=I=-16:TP=-1.5:LRA=11', '-ar', '48000', '-c:a', $codec];
|
||||
foreach ($extra as $flag) {
|
||||
$args[] = $flag;
|
||||
$args[] = '192k';
|
||||
}
|
||||
$args[] = $out;
|
||||
$proc = Process::binary('ffmpeg', $args, config('Site')->maxProcessingSeconds);
|
||||
if (! $proc->run()) {
|
||||
throw new \RuntimeException('Normalization failed.');
|
||||
}
|
||||
|
||||
return ['file' => basename($out), 'name' => $this->downloadName($job, $ext), 'ext' => $ext];
|
||||
}
|
||||
|
||||
private function volume(string $input, array $job, array $tool, callable $update, array $params): array
|
||||
{
|
||||
$gain = $this->num($params['gain_db'] ?? 6, -30, 30, 6);
|
||||
$ext = strtolower((string) (json_decode($job['params'] ?? '{}', true)['format'] ?? 'mp3'));
|
||||
[$codec, $extra] = self::AUDIO_CODECS[$ext] ?? self::AUDIO_CODECS['mp3'];
|
||||
[, $out] = $this->output($job, $ext);
|
||||
|
||||
$update(15, 'boosting volume');
|
||||
$args = ['-y', '-i', $input, '-af', 'volume=' . number_format($gain, 1, '.', '') . 'dB', '-c:a', $codec];
|
||||
foreach ($extra as $flag) {
|
||||
$args[] = $flag;
|
||||
$args[] = '192k';
|
||||
}
|
||||
$args[] = $out;
|
||||
$proc = Process::binary('ffmpeg', $args, config('Site')->maxProcessingSeconds);
|
||||
if (! $proc->run()) {
|
||||
throw new \RuntimeException('Volume adjustment failed.');
|
||||
}
|
||||
|
||||
return ['file' => basename($out), 'name' => $this->downloadName($job, $ext), 'ext' => $ext];
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
private function simpleVideoTransform(string $input, array $job, callable $update, string $filter, string $stage): array
|
||||
{
|
||||
[, $out] = $this->output($job, 'mp4');
|
||||
$update(15, $stage);
|
||||
$proc = Process::binary('ffmpeg', [
|
||||
'-y', '-i', $input, '-vf', $filter,
|
||||
'-c:v', 'libx264', '-preset', 'fast', '-crf', '21',
|
||||
'-c:a', 'aac', '-b:a', '160k', '-movflags', '+faststart', $out,
|
||||
], config('Site')->maxProcessingSeconds);
|
||||
if (! $proc->run() || ! is_file($out) || filesize($out) === 0) {
|
||||
throw new \RuntimeException(ucfirst($stage) . ' failed.');
|
||||
}
|
||||
|
||||
return ['file' => basename($out), 'name' => $this->downloadName($job, 'mp4'), 'ext' => 'mp4'];
|
||||
}
|
||||
|
||||
private function guessContainer(string $input): string
|
||||
{
|
||||
$ext = strtolower(pathinfo($input, PATHINFO_EXTENSION));
|
||||
|
||||
return in_array($ext, ['mp4', 'webm', 'mkv', 'mov', 'avi'], true) ? $ext : 'mp4';
|
||||
}
|
||||
|
||||
private function downloadName(array $job, string $ext): string
|
||||
{
|
||||
$base = pathinfo((string) ($job['input_name'] ?? 'media'), PATHINFO_FILENAME);
|
||||
$base = preg_replace('/[^A-Za-z0-9 _.-]/u', '_', $base) ?: 'media';
|
||||
|
||||
return mb_substr($base, 0, 80) . '.' . $ext;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Libraries\Pipeline;
|
||||
|
||||
/**
|
||||
* Image driver — GD-based (jpg/png/webp/avif where the build supports
|
||||
* it), with ImageMagick as a fallback for exotic formats. Re-encoding
|
||||
* doubles as metadata stripping.
|
||||
*/
|
||||
final class Images implements DriverInterface
|
||||
{
|
||||
public function handle(array $job, array $tool, callable $update): array
|
||||
{
|
||||
$input = Ffmpeg::resolveInput($job);
|
||||
$params = json_decode($job['params'] ?? '{}', true) ?: [];
|
||||
$update(10, 'processing');
|
||||
|
||||
$image = imagecreatefromstring((string) file_get_contents($input));
|
||||
if ($image === false) {
|
||||
throw new \RuntimeException('The file could not be read as an image.');
|
||||
}
|
||||
|
||||
// preserve orientation of JPEGs
|
||||
$exif = @exif_read_data($input);
|
||||
if ($exif !== false && isset($exif['Orientation'])) {
|
||||
$angle = match ((int) $exif['Orientation']) {
|
||||
3 => 180, 6 => -90, 8 => 90, default => null,
|
||||
};
|
||||
if ($angle !== null) {
|
||||
$image = imagerotate($image, $angle, 0);
|
||||
}
|
||||
}
|
||||
if (function_exists('imagepalettetotruecolor')) {
|
||||
imagepalettetotruecolor($image);
|
||||
}
|
||||
imagealphablending($image, true);
|
||||
imagesavealpha($image, true);
|
||||
|
||||
// resize / crop before encoding
|
||||
$w = imagesx($image);
|
||||
$h = imagesy($image);
|
||||
if (! empty($params['width']) || ! empty($params['height'])) {
|
||||
[$nw, $nh] = $this->fitSize($w, $h, (int) ($params['width'] ?? 0), (int) ($params['height'] ?? 0));
|
||||
$scaled = imagecreatetruecolor(max(1, $nw), max(1, $nh));
|
||||
imagealphablending($scaled, false);
|
||||
imagesavealpha($scaled, true);
|
||||
imagecopyresampled($scaled, $image, 0, 0, 0, 0, $nw, $nh, $w, $h);
|
||||
imagedestroy($image);
|
||||
$image = $scaled;
|
||||
$update(55, 'resizing');
|
||||
}
|
||||
if (! empty($params['crop_width']) && ! empty($params['crop_height'])) {
|
||||
$cw = min(imagesx($image), (int) $params['crop_width']);
|
||||
$ch = min(imagesy($image), (int) $params['crop_height']);
|
||||
$cx = max(0, min(imagesx($image) - $cw, (int) ($params['crop_x'] ?? 0)));
|
||||
$cy = max(0, min(imagesy($image) - $ch, (int) ($params['crop_y'] ?? 0)));
|
||||
$cropped = imagecreatetruecolor($cw, $ch);
|
||||
imagealphablending($cropped, false);
|
||||
imagesavealpha($cropped, true);
|
||||
imagecopy($cropped, $image, 0, 0, $cx, $cy, $cw, $ch);
|
||||
imagedestroy($image);
|
||||
$image = $cropped;
|
||||
$update(70, 'cropping');
|
||||
}
|
||||
if (($params['rotate'] ?? 0) != 0) {
|
||||
$deg = in_array(abs((int) $params['rotate']), [90, 180, 270], true) ? (int) $params['rotate'] : 90;
|
||||
$image = imagerotate($image, -$deg, 0); // GD rotates counter-clockwise
|
||||
$update(70, 'rotating');
|
||||
}
|
||||
if (! empty($params['flip'])) {
|
||||
imageflip($image, $params['flip'] === 'vertical' ? IMG_FLIP_VERTICAL : IMG_FLIP_HORIZONTAL);
|
||||
}
|
||||
|
||||
$ext = strtolower((string) ($params['format'] ?? $tool['primary_output_format'] ?? pathinfo($job['input_file'], PATHINFO_EXTENSION)));
|
||||
if ($ext === 'jpeg') {
|
||||
$ext = 'jpg';
|
||||
}
|
||||
$quality = (int) max(30, min(100, (int) ($params['quality'] ?? 85)));
|
||||
|
||||
[, $out] = $this->outputPath($job, $ext);
|
||||
|
||||
$ok = match ($ext) {
|
||||
'jpg' => imagejpeg($image, $out, $quality),
|
||||
'png' => imagepng($image, $out, (int) round(9 * (1 - $quality / 100))),
|
||||
'webp' => function_exists('imagewebp') ? imagewebp($image, $out, $quality) : false,
|
||||
'avif' => function_exists('imageavif') ? imageavif($image, $out, $quality) : false,
|
||||
'gif' => imagegif($image, $out),
|
||||
default => throw new \RuntimeException("Format {$ext} is not supported."),
|
||||
};
|
||||
imagedestroy($image);
|
||||
if (! $ok) {
|
||||
throw new \RuntimeException("Saving as {$ext} is not supported by this server.");
|
||||
}
|
||||
$update(95, 'finalizing');
|
||||
|
||||
return ['file' => basename($out), 'name' => $this->downloadName($job, $ext), 'ext' => $ext];
|
||||
}
|
||||
|
||||
private function fitSize(int $w, int $h, int $targetW, int $targetH): array
|
||||
{
|
||||
if ($targetW > 0 && $targetH > 0) {
|
||||
return [min($targetW, $w * 4), min($targetH, $h * 4)];
|
||||
}
|
||||
if ($targetW > 0) {
|
||||
return [$targetW, max(1, (int) round($h * $targetW / $w))];
|
||||
}
|
||||
|
||||
return [max(1, (int) round($w * $targetH / $h)), $targetH];
|
||||
}
|
||||
|
||||
private function outputPath(array $job, string $ext): array
|
||||
{
|
||||
$file = \App\Libraries\Pipeline::safeName($job['id'], $ext);
|
||||
|
||||
return [$file, \App\Libraries\Pipeline::storageDir() . '/' . $file];
|
||||
}
|
||||
|
||||
private function downloadName(array $job, string $ext): string
|
||||
{
|
||||
$base = preg_replace('/[^A-Za-z0-9 _.-]/u', '_', pathinfo((string) ($job['input_name'] ?? 'image'), PATHINFO_FILENAME)) ?: 'image';
|
||||
|
||||
return mb_substr($base, 0, 80) . '.' . $ext;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Libraries\Pipeline;
|
||||
|
||||
use App\Libraries\Process;
|
||||
|
||||
/**
|
||||
* PDF driver: qpdf (merge/split/pages/metadata), Ghostscript (compress),
|
||||
* Poppler (rasterize + text extraction). Images->PDF uses GD to build
|
||||
* pages and Ghostscript to assemble.
|
||||
*/
|
||||
final class Pdf implements DriverInterface
|
||||
{
|
||||
public function handle(array $job, array $tool, callable $update): array
|
||||
{
|
||||
return match ($job['operation']) {
|
||||
'pdf_merge' => $this->merge($job, $update),
|
||||
'pdf_split' => $this->split($job, $update, singlePage: false),
|
||||
'pdf_extract_pages' => $this->extractPages($job, $update),
|
||||
'pdf_compress' => $this->compress($job, $update),
|
||||
'pdf_remove_metadata'=> $this->removeMetadata($job, $update),
|
||||
'pdf_to_jpg' => $this->rasterize($job, $update, 'jpg'),
|
||||
'pdf_to_png' => $this->rasterize($job, $update, 'png'),
|
||||
'pdf_to_text' => $this->toText($job, $update),
|
||||
'images_to_pdf' => $this->imagesToPdf($job, $update),
|
||||
default => throw new \RuntimeException('Unsupported operation.'),
|
||||
};
|
||||
}
|
||||
|
||||
private function merge(array $job, callable $update): array
|
||||
{
|
||||
$extra = json_decode($job['params'] ?? '{}', true)['extra_files'] ?? [];
|
||||
$files = [Ffmpeg::resolveInput($job)];
|
||||
foreach ((array) $extra as $f) {
|
||||
$files[] = Ffmpeg::storagePath((string) $f, \App\Libraries\Pipeline::incomingDir());
|
||||
}
|
||||
if (count($files) < 2) {
|
||||
throw new \RuntimeException('Select at least two PDF files.');
|
||||
}
|
||||
|
||||
[, $out] = self::output($job, 'pdf');
|
||||
$update(20, 'merging');
|
||||
$proc = Process::binary('qpdf', ['--empty', '--pages', ...$files, '--', $out], config('Site')->maxProcessingSeconds);
|
||||
if (! $proc->run()) {
|
||||
throw new \RuntimeException('Merge failed — are all files valid PDFs?');
|
||||
}
|
||||
|
||||
return ['file' => basename($out), 'name' => 'merged.pdf', 'ext' => 'pdf'];
|
||||
}
|
||||
|
||||
private function split(array $job, callable $update, bool $singlePage): array
|
||||
{
|
||||
$input = Ffmpeg::resolveInput($job);
|
||||
[$zipFile, $zipPath] = self::output($job, 'zip');
|
||||
$tmpDir = sys_get_temp_dir() . '/tv_pdf_' . $job['id'];
|
||||
if (! is_dir($tmpDir)) { mkdir($tmpDir, 0700, true); }
|
||||
|
||||
// one PDF per page via qpdf page ranges
|
||||
$info = Process::binary('pdfinfo', [$input], 30);
|
||||
$pagesN = 0;
|
||||
if ($info->run() && preg_match('/Pages:\s+(\d+)/', $info->out(), $m)) {
|
||||
$pagesN = (int) $m[1];
|
||||
}
|
||||
if ($pagesN < 1) {
|
||||
throw new \RuntimeException('Could not read the PDF.');
|
||||
}
|
||||
$update(10, 'splitting');
|
||||
for ($p = 1; $p <= $pagesN; ++$p) {
|
||||
$proc = Process::binary('qpdf', [$input, '--pages', '.', (string) $p, '--', rtrim($tmpDir, '/') . "/page_{$p}.pdf"], 120);
|
||||
if (! $proc->run()) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$this->zipDirectory($zipPath, $tmpDir);
|
||||
|
||||
return ['file' => basename($zipPath), 'name' => 'split_pages.zip', 'ext' => 'zip'];
|
||||
}
|
||||
|
||||
private function extractPages(array $job, callable $update): array
|
||||
{
|
||||
$input = Ffmpeg::resolveInput($job);
|
||||
$range = (string) (json_decode($job['params'] ?? '{}', true)['pages'] ?? '');
|
||||
if (! preg_match('/^[0-9,\- ]{1,60}$/', $range)) {
|
||||
throw new \RuntimeException('Enter pages like 1-3 or 2,5,7.');
|
||||
}
|
||||
$normalized = str_replace(' ', '', $range);
|
||||
[, $out] = self::output($job, 'pdf');
|
||||
$update(25, 'extracting');
|
||||
|
||||
// expand "1-3" into individual page numbers for qpdf
|
||||
$parts = [];
|
||||
foreach (explode(',', $normalized) as $chunk) {
|
||||
if (preg_match('/^(\d+)-(\d+)$/', $chunk, $m)) {
|
||||
foreach (range((int) $m[1], (int) $m[2]) as $p) {
|
||||
$parts[] = (string) $p;
|
||||
}
|
||||
} elseif (ctype_digit($chunk)) {
|
||||
$parts[] = $chunk;
|
||||
}
|
||||
}
|
||||
$proc = Process::binary('qpdf', ['--empty', '--pages', $input, ...$parts, '--', $out], 300);
|
||||
if (! $proc->run()) {
|
||||
throw new \RuntimeException('Extraction failed — check the page range.');
|
||||
}
|
||||
|
||||
return ['file' => basename($out), 'name' => 'extracted.pdf', 'ext' => 'pdf'];
|
||||
}
|
||||
|
||||
private function compress(array $job, callable $update): array
|
||||
{
|
||||
$input = Ffmpeg::resolveInput($job);
|
||||
[, $out] = self::output($job, 'pdf');
|
||||
$level = (string) (json_decode($job['params'] ?? '{}', true)['preset'] ?? 'ebook');
|
||||
$valid = ['screen', 'ebook', 'printer', 'prepress'];
|
||||
in_array($level, $valid, true) || $level = 'ebook';
|
||||
|
||||
$update(15, 'compressing');
|
||||
$proc = Process::binary('gs', [
|
||||
'-sDEVICE=pdfwrite', '-dCompatibilityLevel=1.5', '-dPDFSETTINGS=/' . $level,
|
||||
'-dNOPAUSE', '-dQUIET', '-dBATCH',
|
||||
"-sOutputFile={$out}", $input,
|
||||
], config('Site')->maxProcessingSeconds);
|
||||
if (! $proc->run() || ! is_file($out) || filesize($out) === 0) {
|
||||
throw new \RuntimeException('Compression failed.');
|
||||
}
|
||||
if (filesize($out) >= filesize($input)) {
|
||||
// already optimized — deliver a byte-identical copy rather than bigger file
|
||||
copy($input, $out);
|
||||
}
|
||||
|
||||
return ['file' => basename($out), 'name' => 'compressed.pdf', 'ext' => 'pdf'];
|
||||
}
|
||||
|
||||
private function removeMetadata(array $job, callable $update): array
|
||||
{
|
||||
$input = Ffmpeg::resolveInput($job);
|
||||
[, $out] = self::output($job, 'pdf');
|
||||
$update(30, 'removing metadata');
|
||||
$proc = Process::binary('qpdf', ['--empty', '--pages', $input, '1-z', '--', $out], 300);
|
||||
if (! $proc->run()) {
|
||||
throw new \RuntimeException('Metadata removal failed.');
|
||||
}
|
||||
|
||||
return ['file' => basename($out), 'name' => 'cleaned.pdf', 'ext' => 'pdf'];
|
||||
}
|
||||
|
||||
private function rasterize(array $job, callable $update, string $format): array
|
||||
{
|
||||
$input = Ffmpeg::resolveInput($job);
|
||||
$dpi = min(200, max(72, (int) (json_decode($job['params'] ?? '{}', true)['dpi'] ?? 150)));
|
||||
$tmpDir = sys_get_temp_dir() . '/tv_pdf_' . $job['id'];
|
||||
if (! is_dir($tmpDir)) { mkdir($tmpDir, 0700, true); }
|
||||
$update(10, 'rendering pages');
|
||||
|
||||
$proc = Process::binary('pdftoppm', [
|
||||
"-{$format}", '-r', (string) $dpi, $input, rtrim($tmpDir, '/') . '/page',
|
||||
], config('Site')->maxProcessingSeconds);
|
||||
if (! $proc->run()) {
|
||||
throw new \RuntimeException('Rendering failed — is this a valid PDF?');
|
||||
}
|
||||
|
||||
[$zipFile, $zipPath] = self::output($job, 'zip');
|
||||
$this->zipDirectory($zipPath, $tmpDir);
|
||||
$update(90, 'packaging');
|
||||
|
||||
return ['file' => basename($zipPath), 'name' => "pdf_as_{$format}.zip", 'ext' => 'zip'];
|
||||
}
|
||||
|
||||
private function toText(array $job, callable $update): array
|
||||
{
|
||||
$input = Ffmpeg::resolveInput($job);
|
||||
[, $txtPath] = self::output($job, 'txt');
|
||||
$update(30, 'extracting text');
|
||||
$proc = Process::binary('pdftotext', ['-layout', $input, $txtPath], 300);
|
||||
if (! $proc->run()) {
|
||||
throw new \RuntimeException('Text extraction failed. Scanned PDFs contain images, not text.');
|
||||
}
|
||||
|
||||
return ['file' => basename($txtPath), 'name' => 'document.txt', 'ext' => 'txt'];
|
||||
}
|
||||
|
||||
private function imagesToPdf(array $job, callable $update): array
|
||||
{
|
||||
$extra = json_decode($job['params'] ?? '{}', true)['extra_files'] ?? [];
|
||||
$files = [Ffmpeg::resolveInput($job)];
|
||||
foreach ((array) $extra as $f) {
|
||||
$files[] = Ffmpeg::storagePath((string) $f);
|
||||
}
|
||||
if (count(array_filter($files)) === 0) {
|
||||
throw new \RuntimeException('Add at least one image.');
|
||||
}
|
||||
|
||||
// normalize every image to an intermediate PDF page via ImageMagick
|
||||
$update(15, 'building pages');
|
||||
$pagePdfs = [];
|
||||
$i = 0;
|
||||
foreach ($files as $file) {
|
||||
if (! is_file($file)) {
|
||||
continue;
|
||||
}
|
||||
$pagePdf = sys_get_temp_dir() . "/tv_img2pdf_{$job['id']}_{$i}.pdf";
|
||||
$proc = Process::binary('convert', [
|
||||
$file, '-auto-orient', '-background', 'white', '-flatten', '-resize', '2480x3508>', $pagePdf,
|
||||
], 120);
|
||||
if ($proc->run()) {
|
||||
$pagePdfs[] = $pagePdf;
|
||||
}
|
||||
++$i;
|
||||
}
|
||||
if ($pagePdfs === []) {
|
||||
throw new \RuntimeException('The images could not be converted to PDF pages.');
|
||||
}
|
||||
|
||||
[, $out] = self::output($job, 'pdf');
|
||||
$proc = Process::binary('gs', [
|
||||
'-sDEVICE=pdfwrite', '-dCompatibilityLevel=1.5', '-dNOPAUSE', '-dQUIET', '-dBATCH',
|
||||
"-sOutputFile={$out}", ...$pagePdfs,
|
||||
], config('Site')->maxProcessingSeconds);
|
||||
array_map('unlink', $pagePdfs);
|
||||
if (! $proc->run()) {
|
||||
throw new \RuntimeException('PDF assembly failed.');
|
||||
}
|
||||
|
||||
return ['file' => basename($out), 'name' => 'images.pdf', 'ext' => 'pdf'];
|
||||
}
|
||||
|
||||
private static function output(array $job, string $ext): array
|
||||
{
|
||||
$file = \App\Libraries\Pipeline::safeName($job['id'], $ext);
|
||||
|
||||
return [$file, \App\Libraries\Pipeline::storageDir() . '/' . $file];
|
||||
}
|
||||
|
||||
private function zipDirectory(string $zipPath, string $dir): void
|
||||
{
|
||||
$zip = new \ZipArchive();
|
||||
$zip->open($zipPath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE);
|
||||
foreach (glob(rtrim($dir, '/') . '/*') ?: [] as $f) {
|
||||
if (is_file($f) && filesize($f) > 0) {
|
||||
$zip->addFile($f, basename($f));
|
||||
}
|
||||
}
|
||||
$zip->close();
|
||||
array_map('unlink', glob(rtrim($dir, '/') . '/*') ?: []);
|
||||
@rmdir($dir);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Libraries\Pipeline;
|
||||
|
||||
use chillerlan\QRCode\QRCode;
|
||||
use chillerlan\QRCode\QROptions;
|
||||
|
||||
/**
|
||||
* Renders QR codes server-side (PNG) via chillerlan/php-qrcode.
|
||||
* Text-kind tool: input is `content`, options: size, level, margin.
|
||||
*/
|
||||
final class Qr implements DriverInterface
|
||||
{
|
||||
public function handle(array $job, array $tool = [], ?callable $update = null): array
|
||||
{
|
||||
$update ??= static function (int $p, string $stage): void {
|
||||
};
|
||||
$params = json_decode($job['params'] ?? '{}', true) ?: [];
|
||||
|
||||
$content = trim((string) ($params['content'] ?? ''));
|
||||
if ($content === '') {
|
||||
throw new \RuntimeException('Enter the text or URL to encode.');
|
||||
}
|
||||
if (mb_strlen($content) > 1000) {
|
||||
throw new \RuntimeException('Content too long for a QR code (max 1000 characters).');
|
||||
}
|
||||
|
||||
// error-correction: L ~7%, M ~15%, Q ~25%, H ~30%
|
||||
$level = strtoupper((string) ($params['level'] ?? 'M'));
|
||||
if (! in_array($level, ['L', 'M', 'Q', 'H'], true)) {
|
||||
$level = 'M';
|
||||
}
|
||||
$size = (int) ($params['size'] ?? 300);
|
||||
$size = min(1000, max(120, $size));
|
||||
|
||||
$options = new QROptions([
|
||||
'outputType' => QRCode::OUTPUT_IMAGE_PNG,
|
||||
'eccLevel' => constant(QRCode::class . '::ECC_' . $level),
|
||||
'scale' => max(3, (int) round($size / 33)),
|
||||
'imageBase64' => false,
|
||||
'quality' => 90,
|
||||
]);
|
||||
|
||||
[, $out] = self::output($job, 'png');
|
||||
$png = (new QRCode($options))->render($content);
|
||||
if (! str_starts_with((string) $png, "\x89PNG")) {
|
||||
throw new \RuntimeException('QR rendering failed.');
|
||||
}
|
||||
file_put_contents($out, $png);
|
||||
|
||||
return ['file' => basename($out), 'name' => 'qr-code.png', 'ext' => 'png'];
|
||||
}
|
||||
|
||||
private static function output(array $job, string $ext): array
|
||||
{
|
||||
$dir = \App\Libraries\Pipeline::storageDir();
|
||||
$file = \App\Libraries\Pipeline::safeName($job['id'], $ext);
|
||||
|
||||
return [$file, $dir . '/' . $file];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Libraries\Pipeline;
|
||||
|
||||
use App\Libraries\Process;
|
||||
use App\Libraries\UrlGuard;
|
||||
|
||||
/**
|
||||
* YouTube driver.
|
||||
*
|
||||
* Always available (no external service): thumbnail download, oEmbed
|
||||
* metadata, URL analysis. Downloading video/audio requires yt-dlp AND
|
||||
* the operator feature flag — when disabled the driver throws a clear
|
||||
* user-facing message and tool pages show an honest notice up-front.
|
||||
*/
|
||||
final class Youtube implements DriverInterface
|
||||
{
|
||||
private const ID_PATTERN = '(?:[A-Za-z0-9_-]{11})';
|
||||
|
||||
/** @return string|null 11-char video id from any public URL form */
|
||||
public static function extractId(string $url): ?string
|
||||
{
|
||||
$url = trim($url);
|
||||
if (preg_match('/^' . self::ID_PATTERN . '$/', $url) === 1) {
|
||||
return $url; // bare id
|
||||
}
|
||||
|
||||
$host = strtolower(parse_url($url, PHP_URL_HOST) ?? '');
|
||||
if (! preg_match('/(^|\.)(youtube\.com|youtu\.be|youtube-nocookie\.com)$/', $host)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($host === 'youtu.be') {
|
||||
$id = trim(parse_url($url, PHP_URL_PATH) ?? '', '/');
|
||||
|
||||
return preg_match('/^' . self::ID_PATTERN . '$/', $id) === 1 ? $id : null;
|
||||
}
|
||||
|
||||
parse_str(parse_url($url, PHP_URL_QUERY) ?? '', $query);
|
||||
if (! empty($query['v']) && preg_match('/^' . self::ID_PATTERN . '$/', (string) $query['v'])) {
|
||||
return (string) $query['v'];
|
||||
}
|
||||
if (preg_match('#/(?:embed|shorts|live|v)/(' . self::ID_PATTERN . ')#', $url, $m)) {
|
||||
return $m[1];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function handle(array $job, array $tool, callable $update): array
|
||||
{
|
||||
$params = json_decode($job['params'] ?? '{}', true) ?: [];
|
||||
$videoId = self::extractId((string) ($params['url'] ?? ''));
|
||||
if ($videoId === null) {
|
||||
throw new \RuntimeException('Invalid YouTube URL.');
|
||||
}
|
||||
$update(10, 'fetching');
|
||||
|
||||
return match ($job['operation']) {
|
||||
'yt_thumbnails' => $this->thumbnails($job, $videoId),
|
||||
default => $this->withYtDlp($job, $params, $update),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Thumbnail pack: maxres/sd/hq/mq/default as a zip. Fetched only
|
||||
* from Google-owned image hosts through the allowlist.
|
||||
*/
|
||||
private function thumbnails(array $job, string $videoId): array
|
||||
{
|
||||
$guard = new UrlGuard();
|
||||
if (! $guard->isAllowedMediaHost('i.ytimg.com')) {
|
||||
throw new \RuntimeException('Thumbnails are not fetchable on this server.');
|
||||
}
|
||||
|
||||
$qualities = [
|
||||
'maxresdefault' => 'Max resolution (1280x720+)',
|
||||
'sddefault' => 'Standard definition (640x480)',
|
||||
'hqdefault' => 'High quality (480x360)',
|
||||
'mqdefault' => 'Medium quality (320x180)',
|
||||
'default' => 'Thumbnail (120x90)',
|
||||
];
|
||||
|
||||
$tmpDir = sys_get_temp_dir() . '/tv_thumb_' . $job['id'];
|
||||
if (! is_dir($tmpDir)) { mkdir($tmpDir, 0700, true); }
|
||||
$found = 0;
|
||||
|
||||
foreach ($qualities as $key => $_label) {
|
||||
$target = rtrim($tmpDir, '/') . "/{$key}.jpg";
|
||||
$data = $this->httpGet("https://i.ytimg.com/vi/{$videoId}/{$key}.jpg");
|
||||
if ($data !== null && strlen($data) > 1000) {
|
||||
file_put_contents($target, $data);
|
||||
++$found;
|
||||
}
|
||||
}
|
||||
|
||||
// webp variants too
|
||||
foreach (['maxresdefault', 'hq720', 'sddefault', 'hqdefault'] as $key) {
|
||||
$webpName = str_replace('.jpg', '.webp', $key) === $key ? $key . '.webp' : $key;
|
||||
$data = $this->httpGet("https://i.ytimg.com/vi_webp/{$videoId}/{$webpName}");
|
||||
if ($data !== null && strlen($data) > 1000) {
|
||||
file_put_contents(rtrim($tmpDir, '/') . '/' . basename($webpName), $data);
|
||||
++$found;
|
||||
}
|
||||
}
|
||||
|
||||
if ($found === 0) {
|
||||
throw new \RuntimeException('No thumbnails were found for this video.');
|
||||
}
|
||||
|
||||
[$zipFile, $zipPath] = [\App\Libraries\Pipeline::safeName($job['id'], 'zip'), \App\Libraries\Pipeline::storageDir() . '/' . \App\Libraries\Pipeline::safeName($job['id'], 'zip')];
|
||||
$zip = new \ZipArchive();
|
||||
$zip->open($zipPath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE);
|
||||
foreach (glob(rtrim($tmpDir, '/') . '/*') ?: [] as $f) {
|
||||
$zip->addFile($f, basename($f));
|
||||
}
|
||||
$zip->close();
|
||||
array_map('unlink', glob(rtrim($tmpDir, '/') . '/*') ?: []);
|
||||
@rmdir($tmpDir);
|
||||
|
||||
return ['file' => basename($zipPath), 'name' => "thumbnails_{$videoId}.zip", 'ext' => 'zip'];
|
||||
}
|
||||
|
||||
/** Gated yt-dlp execution (audio/video download + conversion). */
|
||||
private function withYtDlp(array $job, array $params, callable $update): array
|
||||
{
|
||||
$pipeline = service('pipeline');
|
||||
if (! $pipeline->youtubeEnabled()) {
|
||||
throw new \RuntimeException(
|
||||
'Downloading from YouTube is currently disabled on this server. Please review the platform terms of use before enabling this capability.'
|
||||
);
|
||||
}
|
||||
|
||||
$format = strtolower((string) ($params['format'] ?? 'mp3'));
|
||||
$url = 'https://www.youtube.com/watch?v=' . urlencode((string) self::extractId((string) ($params['url'] ?? '')));
|
||||
|
||||
$update(15, 'fetching');
|
||||
$outTemplate = rtrim(\App\Libraries\Pipeline::incomingDir(), '/') . '/' . $job['id'] . '.%(ext)s';
|
||||
|
||||
$args = ['-f', 'bestaudio/best', '-o', $outTemplate, '--no-playlist', '--no-warnings', '--socket-timeout', '30'];
|
||||
if (in_array($format, ['mp3', 'wav', 'm4a', 'aac', 'flac', 'ogg'], true)) {
|
||||
$codecMap = ['mp3' => 'mp3', 'wav' => 'wav', 'm4a' => 'aac', 'aac' => 'aac', 'flac' => 'flac', 'ogg' => 'vorbis'];
|
||||
$args[] = '-x';
|
||||
$args[] = '--audio-format';
|
||||
$args[] = $codecMap[$format];
|
||||
$ext = $format;
|
||||
} else {
|
||||
// best mp4-compatible stream
|
||||
$args[] = '-f';
|
||||
$args[] = 'bv*[ext=mp4]+ba[ext=m4a]/b[ext=mp4]/b';
|
||||
$args[] = '--merge-output-format';
|
||||
$args[] = 'mp4';
|
||||
$ext = 'mp4';
|
||||
}
|
||||
$args[] = $url;
|
||||
|
||||
$proc = Process::binary('yt-dlp', $args, config('Site')->maxProcessingSeconds);
|
||||
if (! $proc->run()) {
|
||||
throw new \RuntimeException('This video could not be downloaded. It may be private, region-locked or unavailable.');
|
||||
}
|
||||
$update(75, 'converting');
|
||||
|
||||
// find produced file
|
||||
$produced = glob(rtrim(\App\Libraries\Pipeline::incomingDir(), '/') . '/' . $job['id'] . '.*') ?: [];
|
||||
$produced = array_values(array_filter($produced, static fn ($f) => ! str_ends_with((string) $f, '.part')));
|
||||
if ($produced === []) {
|
||||
throw new \RuntimeException('The download did not produce a file.');
|
||||
}
|
||||
$source = $produced[0];
|
||||
|
||||
$outPath = \App\Libraries\Pipeline::storageDir() . '/' . \App\Libraries\Pipeline::safeName($job['id'], $ext);
|
||||
if (pathinfo($source, PATHINFO_EXTENSION) === $ext) {
|
||||
rename($source, $outPath);
|
||||
} else {
|
||||
$conv = Process::binary('ffmpeg', ['-y', '-i', $source, $outPath], config('Site')->maxProcessingSeconds);
|
||||
@unlink($source);
|
||||
if (! $conv->run()) {
|
||||
throw new \RuntimeException('Conversion failed after download.');
|
||||
}
|
||||
}
|
||||
|
||||
return ['file' => basename($outPath), 'name' => 'download.' . $ext, 'ext' => $ext];
|
||||
}
|
||||
|
||||
/**
|
||||
* oEmbed metadata lookup — no API key, official public endpoint,
|
||||
* SSRF-guarded host allowlist.
|
||||
*
|
||||
* @return array|null title/author/thumb or null
|
||||
*/
|
||||
public static function fetchInfo(string $videoId): ?array
|
||||
{
|
||||
$endpoint = 'https://www.youtube.com/oembed?url=' . rawurlencode('https://www.youtube.com/watch?v=' . $videoId) . '&format=json';
|
||||
|
||||
$ch = curl_init($endpoint);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 8,
|
||||
CURLOPT_CONNECTTIMEOUT => 4,
|
||||
CURLOPT_FOLLOWLOCATION => false,
|
||||
CURLOPT_USERAGENT => config('Site')->name . '/1.0',
|
||||
]);
|
||||
$body = curl_exec($ch);
|
||||
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($status !== 200 || ! is_string($body)) {
|
||||
return null;
|
||||
}
|
||||
$data = json_decode($body, true);
|
||||
if (! is_array($data) || empty($data['title'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $videoId,
|
||||
'title' => (string) $data['title'],
|
||||
'author' => (string) ($data['author_name'] ?? ''),
|
||||
'author_url' => (string) ($data['author_url'] ?? ''),
|
||||
'thumbnail' => (string) ($data['thumbnail_url'] ?? ('https://i.ytimg.com/vi/' . $videoId . '/hqdefault.jpg')),
|
||||
'duration' => null, // oEmbed doesn't expose duration
|
||||
'width' => (int) ($data['width'] ?? 0),
|
||||
'height' => (int) ($data['height'] ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
private function httpGet(string $url): ?string
|
||||
{
|
||||
try {
|
||||
(new UrlGuard())->check($url);
|
||||
} catch (\InvalidArgumentException) {
|
||||
return null;
|
||||
}
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 10,
|
||||
CURLOPT_FOLLOWLOCATION => false,
|
||||
CURLOPT_USERAGENT => config('Site')->name . '/1.0',
|
||||
]);
|
||||
$body = curl_exec($ch);
|
||||
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
return $status === 200 && is_string($body) ? $body : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Libraries;
|
||||
|
||||
/**
|
||||
* Hardened process runner. Arguments are ALWAYS passed as an array and
|
||||
* escaped by proc_open's non-shell mode — user input can never become a
|
||||
* shell command. Timeouts, output caps and resource limits are enforced.
|
||||
*/
|
||||
final class Process
|
||||
{
|
||||
private int $exitCode = -1;
|
||||
private string $stdout = '';
|
||||
private string $stderr = '';
|
||||
|
||||
public function __construct(
|
||||
/** @var list<string> */
|
||||
private readonly array $command,
|
||||
private readonly int $timeoutSeconds = 300,
|
||||
private readonly int $maxOutputBytes = 2_000_000,
|
||||
private ?string $stdin = null,
|
||||
) {
|
||||
}
|
||||
|
||||
public static function binary(string $name, array $args, int $timeout = 300): self
|
||||
{
|
||||
$site = config('Site');
|
||||
$bin = $site->binaries[$name] ?? null;
|
||||
|
||||
if ($bin === null || ! is_executable($bin)) {
|
||||
throw new \RuntimeException("Binary '{$name}' is not available on this host.");
|
||||
}
|
||||
|
||||
return new self([$bin, ...array_map('strval', array_values($args))], $timeout);
|
||||
}
|
||||
|
||||
public function withStdin(?string $data): self
|
||||
{
|
||||
$this->stdin = $data;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function run(): bool
|
||||
{
|
||||
$descriptors = [
|
||||
0 => ['pipe', 'r'],
|
||||
1 => ['pipe', 'w'],
|
||||
2 => ['pipe', 'w'],
|
||||
];
|
||||
|
||||
$process = proc_open($this->command, $descriptors, $pipes, sys_get_temp_dir(), [
|
||||
'LC_ALL' => 'C',
|
||||
'HOME' => rtrim(WRITEPATH, '/'),
|
||||
'PATH' => '/usr/bin:/bin',
|
||||
]);
|
||||
|
||||
if (! is_resource($process)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
stream_set_blocking($pipes[1], false);
|
||||
stream_set_blocking($pipes[2], false);
|
||||
|
||||
if ($this->stdin !== null) {
|
||||
fwrite($pipes[0], $this->stdin);
|
||||
}
|
||||
fclose($pipes[0]);
|
||||
|
||||
$start = microtime(true);
|
||||
$timedOut = false;
|
||||
$outLen = 0;
|
||||
|
||||
// NB: proc_get_status() reports a valid 'exitcode' only on the FIRST
|
||||
// call that observes running === false — capture it right there.
|
||||
while (true) {
|
||||
$status = proc_get_status($process);
|
||||
if ($status === false || ! $status['running']) {
|
||||
$this->exitCode = (int) ($status['exitcode'] ?? -1);
|
||||
break;
|
||||
}
|
||||
// drain pipes so the child never blocks on full buffers
|
||||
foreach ([1, 2] as $i) {
|
||||
if ($outLen < $this->maxOutputBytes) {
|
||||
$chunk = fread($pipes[$i], 65536);
|
||||
$this->{$i === 1 ? 'stdout' : 'stderr'} .= (string) $chunk;
|
||||
$outLen += strlen((string) $chunk);
|
||||
}
|
||||
}
|
||||
if (microtime(true) - $start > $this->timeoutSeconds) {
|
||||
$timedOut = true;
|
||||
proc_terminate($process, 9);
|
||||
break;
|
||||
}
|
||||
usleep(10_000);
|
||||
}
|
||||
|
||||
foreach ([1, 2] as $i) {
|
||||
$rest = stream_get_contents($pipes[$i]) ?: '';
|
||||
$this->{$i === 1 ? 'stdout' : 'stderr'} .= substr($rest, 0, $this->maxOutputBytes);
|
||||
fclose($pipes[$i]);
|
||||
}
|
||||
proc_close($process);
|
||||
|
||||
if ($timedOut) {
|
||||
log_message('warning', 'Process timed out after {t}s: {cmd}', ['t' => $this->timeoutSeconds, 'cmd' => basename((string) ($this->command[0] ?? ''))]);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->exitCode === 0;
|
||||
}
|
||||
|
||||
public function ok(): bool
|
||||
{
|
||||
return $this->exitCode === 0;
|
||||
}
|
||||
|
||||
public function exit(): int
|
||||
{
|
||||
return $this->exitCode;
|
||||
}
|
||||
|
||||
public function out(): string
|
||||
{
|
||||
return $this->stdout;
|
||||
}
|
||||
|
||||
public function err(): string
|
||||
{
|
||||
return $this->stderr;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Libraries;
|
||||
|
||||
/**
|
||||
* "Related tools" recommendation engine.
|
||||
*
|
||||
* Scoring signals (highest first):
|
||||
* 1. Curated related_slugs authored in the registry
|
||||
* 2. Same primary input format (mp4 -> mp3 implies mp4 -> wav)
|
||||
* 3. Same primary output format (jpg -> png implies webp -> png)
|
||||
* 4. Same category, weighted by popularity
|
||||
*/
|
||||
final class RelatedTools
|
||||
{
|
||||
public const LIMIT = 8;
|
||||
|
||||
public function forTool(array $tool, int $limit = self::LIMIT): array
|
||||
{
|
||||
$finder = service('finder');
|
||||
$result = [];
|
||||
|
||||
// 1) curated picks always lead
|
||||
foreach (($tool['related_slugs'] ?? []) as $slug) {
|
||||
if ($related = $finder->tool((string) $slug)) {
|
||||
$result[$related['slug']] = $related + ['_score' => 1000];
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($finder->all() as $candidate) {
|
||||
if ($candidate['id'] === $tool['id'] || isset($result[$candidate['slug']])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$score = 0;
|
||||
if ($tool['primary_input_format'] !== null && $candidate['primary_input_format'] === $tool['primary_input_format']) {
|
||||
$score += 50;
|
||||
}
|
||||
if ($tool['primary_output_format'] !== null && $candidate['primary_output_format'] === $tool['primary_output_format']) {
|
||||
$score += 40;
|
||||
}
|
||||
if ((int) $candidate['category_id'] === (int) $tool['category_id']) {
|
||||
$score += 25;
|
||||
}
|
||||
// small popularity tiebreaker keeps ordering stable and useful
|
||||
$score += min(10, (int) round(((int) $candidate['popularity']) / 500));
|
||||
|
||||
if ($score >= 25) {
|
||||
$candidate['_score'] = $score;
|
||||
$result[$candidate['slug']] = $candidate;
|
||||
}
|
||||
}
|
||||
|
||||
usort($result, static fn (array $a, array $b): int => $b['_score'] <=> $a['_score']);
|
||||
|
||||
return array_slice(array_map(static function (array $t): array {
|
||||
unset($t['_score']);
|
||||
|
||||
return $t;
|
||||
}, $result), 0, $limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format-conversion cross links: for a converter like mp4-to-mp3,
|
||||
* surface sibling conversions from the same input (mp4-to-wav,
|
||||
* mp4-to-webm ...) and to the same output from other inputs.
|
||||
*
|
||||
* @return list<array>
|
||||
*/
|
||||
public function conversionsAround(array $tool, int $limit = 6): array
|
||||
{
|
||||
if ($tool['kind'] !== 'upload' || $tool['operation'] !== 'convert') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$finder = service('finder');
|
||||
$sameInput = [];
|
||||
$sameOutput = [];
|
||||
|
||||
foreach ($finder->all() as $candidate) {
|
||||
if ($candidate['id'] === $tool['id'] || $candidate['operation'] !== 'convert') {
|
||||
continue;
|
||||
}
|
||||
if ($tool['primary_input_format'] !== null && $candidate['primary_input_format'] === $tool['primary_input_format']) {
|
||||
$sameInput[] = $candidate;
|
||||
} elseif ($tool['primary_output_format'] !== null && $candidate['primary_output_format'] === $tool['primary_output_format']) {
|
||||
$sameOutput[] = $candidate;
|
||||
}
|
||||
}
|
||||
|
||||
usort($sameInput, static fn ($a, $b) => $b['popularity'] <=> $a['popularity']);
|
||||
usort($sameOutput, static fn ($a, $b) => $b['popularity'] <=> $a['popularity']);
|
||||
|
||||
return array_slice([...$sameInput, ...$sameOutput], 0, $limit);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Libraries;
|
||||
|
||||
/**
|
||||
* SEO metadata + JSON-LD builder. One instance per request, rendered by
|
||||
* the layout partial (views/partials/seo.php).
|
||||
*
|
||||
* Only structured data the visible page actually supports is emitted —
|
||||
* no fake ratings or reviews anywhere.
|
||||
*/
|
||||
final class Seo
|
||||
{
|
||||
private string $title = '';
|
||||
private string $description = '';
|
||||
private string $canonical = '';
|
||||
private string $ogType = 'website';
|
||||
private ?string $ogImage = null;
|
||||
/** @var list<array{position:int,name:string,item:string}> */
|
||||
private array $breadcrumbs = [];
|
||||
/** @var list<array> JSON-LD graph nodes */
|
||||
private array $jsonLd = [];
|
||||
/** @var array<string,string> hreflang lang => url */
|
||||
private array $alternates = [];
|
||||
private bool $indexable = true;
|
||||
|
||||
public function title(string $title): self
|
||||
{
|
||||
$this->title = $title;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function description(string $description): self
|
||||
{
|
||||
$this->description = mb_substr(trim($description), 0, 320);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function canonical(string $pathOrUrl): self
|
||||
{
|
||||
$this->canonical = str_starts_with($pathOrUrl, 'http')
|
||||
? $pathOrUrl
|
||||
: base_url('/' . ltrim($pathOrUrl, '/'));
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Breadcrumb trail: [['label' => 'YouTube Tools', 'url' => '/youtube-tools'], ...]
|
||||
* Home is prepended automatically and BreadcrumbList JSON-LD emitted.
|
||||
*/
|
||||
public function breadcrumbs(array $items): self
|
||||
{
|
||||
$trail = [['label' => 'Home', 'url' => base_url('/')]];
|
||||
foreach ($items as $item) {
|
||||
$trail[] = [
|
||||
'label' => $item['label'],
|
||||
'url' => isset($item['url']) ? base_url($item['url']) : null,
|
||||
];
|
||||
}
|
||||
|
||||
$this->breadcrumbs = array_map(
|
||||
static fn (array $t, int $i): array => [
|
||||
'position' => $i + 1,
|
||||
'name' => $t['label'],
|
||||
'item' => $t['url'] ?? current_url(),
|
||||
],
|
||||
$trail,
|
||||
range(0, count($trail) - 1)
|
||||
);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function webApplication(string $name, string $description, string $category = 'MultimediaApplication'): self
|
||||
{
|
||||
$this->jsonLd[] = [
|
||||
'@type' => 'WebApplication',
|
||||
'name' => $name,
|
||||
'url' => $this->canonical ?: base_url('/'),
|
||||
'description' => $description,
|
||||
'applicationCategory' => $category,
|
||||
'operatingSystem' => 'Any',
|
||||
'browserRequirements' => 'Requires JavaScript when processing files',
|
||||
'publisher' => ['@type' => 'Organization', 'name' => config('Site')->name],
|
||||
];
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function faqPage(array $faqs): self
|
||||
{
|
||||
if ($faqs === []) {
|
||||
return $this;
|
||||
}
|
||||
$entities = [];
|
||||
foreach ($faqs as $faq) {
|
||||
if (($faq['q'] ?? '') === '' || ($faq['a'] ?? '') === '') {
|
||||
continue;
|
||||
}
|
||||
$entities[] = [
|
||||
'@type' => 'Question',
|
||||
'name' => $faq['q'],
|
||||
'acceptedAnswer' => ['@type' => 'Answer', 'text' => strip_tags($faq['a'])],
|
||||
];
|
||||
}
|
||||
if ($entities !== []) {
|
||||
$this->jsonLd[] = ['@type' => 'FAQPage', 'mainEntity' => $entities];
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/** HowTo steps: [['name' => ..., 'text' => ...], ...] */
|
||||
public function howTo(string $name, array $steps): self
|
||||
{
|
||||
if ($steps === []) {
|
||||
return $this;
|
||||
}
|
||||
$items = [];
|
||||
foreach (array_values($steps) as $i => $step) {
|
||||
$items[] = [
|
||||
'@type' => 'HowToStep',
|
||||
'position' => $i + 1,
|
||||
'name' => $step['name'] ?? ('Step ' . ($i + 1)),
|
||||
'text' => $step['text'] ?? '',
|
||||
];
|
||||
}
|
||||
$this->jsonLd[] = ['@type' => 'HowTo', 'name' => $name, 'step' => $items];
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function article(string $headline, string $bodyText, string $publishedAt, string $authorName): self
|
||||
{
|
||||
$this->ogType = 'article';
|
||||
$this->jsonLd[] = [
|
||||
'@type' => 'Article',
|
||||
'headline' => mb_substr($headline, 0, 110),
|
||||
'description' => mb_substr(strip_tags($bodyText), 0, 200),
|
||||
'datePublished' => $publishedAt,
|
||||
'dateModified' => $publishedAt,
|
||||
'author' => ['@type' => 'Organization', 'name' => $authorName],
|
||||
'publisher' => ['@type' => 'Organization', 'name' => $authorName],
|
||||
'mainEntityOfPage' => $this->canonical ?: current_url(),
|
||||
];
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function noIndex(): self
|
||||
{
|
||||
$this->indexable = false;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function ogImage(?string $path): self
|
||||
{
|
||||
$this->ogImage = $path;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function alternate(string $locale, string $url): self
|
||||
{
|
||||
$this->alternates[$locale] = $url;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Renderers used by the layout
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
public function renderTitle(): string
|
||||
{
|
||||
return esc($this->title);
|
||||
}
|
||||
|
||||
public function renderHead(): string
|
||||
{
|
||||
$site = config('Site');
|
||||
$out = [];
|
||||
|
||||
$canonical = $this->canonical ?: current_url();
|
||||
$robots = $this->indexable
|
||||
? 'index, follow, max-image-preview:large'
|
||||
: 'noindex, nofollow';
|
||||
|
||||
$out[] = '<link rel="canonical" href="' . esc($canonical) . '">';
|
||||
$out[] = '<meta name="robots" content="' . $robots . '">';
|
||||
|
||||
$title = $this->title !== '' ? $this->title : $site->name . ' – ' . $site->tagline;
|
||||
$description = $this->description !== '' ? $this->description : $site->description;
|
||||
|
||||
$out[] = '<meta property="og:title" content="' . esc($title) . '">';
|
||||
$out[] = '<meta property="og:description" content="' . esc($description) . '">';
|
||||
$out[] = '<meta property="og:type" content="' . esc($this->ogType) . '">';
|
||||
$out[] = '<meta property="og:url" content="' . esc($canonical) . '">';
|
||||
$out[] = '<meta property="og:site_name" content="' . esc($site->name) . '">';
|
||||
|
||||
$image = $this->ogImage ?? base_url('/og/default.png');
|
||||
$out[] = '<meta property="og:image" content="' . esc($image) . '">';
|
||||
$out[] = '<meta name="twitter:card" content="summary_large_image">';
|
||||
$out[] = '<meta name="twitter:title" content="' . esc($title) . '">';
|
||||
$out[] = '<meta name="twitter:description" content="' . esc($description) . '">';
|
||||
$out[] = '<meta name="twitter:image" content="' . esc($image) . '">';
|
||||
|
||||
foreach ($this->alternates as $lang => $url) {
|
||||
$out[] = '<link rel="alternate" hreflang="' . esc($lang) . '" href="' . esc($url) . '">';
|
||||
}
|
||||
|
||||
return implode("\n", $out);
|
||||
}
|
||||
|
||||
public function renderJsonLd(): string
|
||||
{
|
||||
$graph = [];
|
||||
|
||||
// WebSite + SearchAction on every page (site-wide sitelinks searchbox)
|
||||
if (uri_string() === '') {
|
||||
$graph[] = [
|
||||
'@type' => 'WebSite',
|
||||
'name' => config('Site')->name,
|
||||
'url' => base_url('/'),
|
||||
'potentialAction' => [
|
||||
'@type' => 'SearchAction',
|
||||
'target' => base_url('/search') . '?q={search_term_string}',
|
||||
'query-input' => 'required name=search_term_string',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
if ($this->breadcrumbs !== []) {
|
||||
$graph[] = [
|
||||
'@type' => 'BreadcrumbList',
|
||||
'itemListElement' => $this->breadcrumbs,
|
||||
];
|
||||
}
|
||||
|
||||
return json_encode(
|
||||
['@context' => 'https://schema.org', '@graph' => [...$graph, ...$this->jsonLd]],
|
||||
JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Libraries;
|
||||
|
||||
/**
|
||||
* Global tool search with alias support ("yt mp3", "compress mp4",
|
||||
* "jpg png"). Deterministic scoring, zero dependencies, instant enough
|
||||
* to run on every keystroke server-side.
|
||||
*/
|
||||
final class ToolSearch
|
||||
{
|
||||
/** @return list<array> best matches first */
|
||||
public function find(string $query, int $limit = 10): array
|
||||
{
|
||||
$terms = $this->tokenize($query);
|
||||
if ($terms === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$scored = [];
|
||||
foreach (service('finder')->all() as $tool) {
|
||||
$score = $this->scoreTool($tool, $terms);
|
||||
if ($score > 0) {
|
||||
$tool['_score'] = $score;
|
||||
$scored[] = $tool;
|
||||
}
|
||||
}
|
||||
|
||||
usort($scored, static fn (array $a, array $b): int => [$b['_score'], (int) $b['popularity']] <=> [$a['_score'], (int) $a['popularity']]);
|
||||
|
||||
return array_slice(array_map(static function (array $t): array {
|
||||
unset($t['_score']);
|
||||
|
||||
return $t;
|
||||
}, $scored), 0, $limit);
|
||||
}
|
||||
|
||||
public function best(string $query): ?array
|
||||
{
|
||||
return $this->find($query, 1)[0] ?? null;
|
||||
}
|
||||
|
||||
private function scoreTool(array $tool, array $terms): float
|
||||
{
|
||||
$name = mb_strtolower($tool['name'] . ' ' . str_replace('-', ' ', $tool['slug']));
|
||||
$aliases = implode(' ', array_map('mb_strtolower', (array) ($tool['aliases'] ?? [])));
|
||||
$haystacks = [
|
||||
'aliases' => $aliases,
|
||||
'name' => $name,
|
||||
'formats' => mb_strtolower(implode(' ', [...($tool['input_formats'] ?? []), ...($tool['output_formats'] ?? [])])),
|
||||
'desc' => mb_strtolower($tool['short_description'] ?? ''),
|
||||
];
|
||||
|
||||
$weight = ['aliases' => 40, 'name' => 30, 'formats' => 20, 'desc' => 8];
|
||||
$score = 0.0;
|
||||
|
||||
foreach ($terms as $term) {
|
||||
$matched = false;
|
||||
foreach ($haystacks as $key => $haystack) {
|
||||
if ($haystack === '') {
|
||||
continue;
|
||||
}
|
||||
if (str_starts_with($haystack . ' ', $term . ' ') || $haystack === $term) {
|
||||
$score += $weight[$key] * 1.5; // exact word start
|
||||
$matched = true;
|
||||
} elseif (str_contains(' ' . $haystack, ' ' . $term)) {
|
||||
$score += $weight[$key];
|
||||
$matched = true;
|
||||
} elseif ($key !== 'desc' && str_contains($term, ' ') === false && str_contains($haystack, $term)) {
|
||||
$score += $weight[$key] * 0.5; // fuzzy substring
|
||||
$matched = true;
|
||||
}
|
||||
}
|
||||
if (! $matched) {
|
||||
return 0; // every term must match somewhere — keeps results relevant
|
||||
}
|
||||
}
|
||||
|
||||
// popularity tiebreak folded into score for stable ordering
|
||||
return $score + min(5, ((int) ($tool['popularity'] ?? 0)) / 2000);
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
private function tokenize(string $query): array
|
||||
{
|
||||
$normalized = mb_strtolower(preg_replace('/[^a-z0-9\s]+/iu', ' ', $query) ?? '');
|
||||
$tokens = preg_split('/\s+/', trim($normalized)) ?: [];
|
||||
|
||||
return array_values(array_unique(array_filter($tokens, static fn ($t) => mb_strlen($t) >= 2)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Libraries;
|
||||
|
||||
/**
|
||||
* SSRF protection for user-supplied URLs. Every external fetch goes
|
||||
* through guard() before a single byte is requested:
|
||||
*
|
||||
* - scheme allowlist (http/https)
|
||||
* - DNS resolution checked against private / reserved ranges
|
||||
* - port restricted to 80/443
|
||||
* - optional host allowlist for media CDNs
|
||||
* - redirects are never auto-followed by fetchers (re-validate per hop)
|
||||
*/
|
||||
final class UrlGuard
|
||||
{
|
||||
public function check(string $url): array
|
||||
{
|
||||
$parts = parse_url(trim($url));
|
||||
|
||||
if ($parts === false || ! isset($parts['host'], $parts['scheme'])) {
|
||||
throw new \InvalidArgumentException('Invalid URL.');
|
||||
}
|
||||
|
||||
if (! in_array(strtolower($parts['scheme']), ['http', 'https'], true)) {
|
||||
throw new \InvalidArgumentException('Only http and https URLs are allowed.');
|
||||
}
|
||||
|
||||
$port = $parts['port'] ?? (strtolower($parts['scheme']) === 'https' ? 443 : 80);
|
||||
if (! in_array($port, [80, 443], true)) {
|
||||
throw new \InvalidArgumentException('Blocked port.');
|
||||
}
|
||||
|
||||
$host = strtolower($parts['host']);
|
||||
|
||||
// Never let raw IPs through unless public.
|
||||
if (filter_var($host, FILTER_VALIDATE_IP)) {
|
||||
if (! $this->isPublicIp($host)) {
|
||||
throw new \InvalidArgumentException('Blocked host.');
|
||||
}
|
||||
|
||||
return ['host' => $host, 'ip' => $host, 'port' => $port, 'path' => $parts['path'] ?? '/', 'query' => $parts['query'] ?? ''];
|
||||
}
|
||||
|
||||
// Resolve all addresses; every one must be public.
|
||||
$records = @dns_get_record($host, DNS_A + DNS_AAAA);
|
||||
$ips = [];
|
||||
foreach ($records ?: [] as $record) {
|
||||
$ip = $record['type'] === 'A' ? ($record['ip'] ?? null) : ($record['ipv6'] ?? null);
|
||||
if ($ip !== null) {
|
||||
$ips[] = $ip;
|
||||
}
|
||||
}
|
||||
if ($ips === []) {
|
||||
// fall back to gethostbyname for simple A records
|
||||
$resolved = gethostbyname($host);
|
||||
if ($resolved === $host) {
|
||||
throw new \InvalidArgumentException('Could not resolve host.');
|
||||
}
|
||||
$ips = [$resolved];
|
||||
}
|
||||
foreach ($ips as $ip) {
|
||||
if (! $this->isPublicIp($ip)) {
|
||||
throw new \InvalidArgumentException('Blocked host.');
|
||||
}
|
||||
}
|
||||
|
||||
return ['host' => $host, 'ips' => $ips, 'port' => $port, 'path' => $parts['path'] ?? '/', 'query' => $parts['query'] ?? ''];
|
||||
}
|
||||
|
||||
/** Host allowlist used for direct CDN fetches (thumbnails etc). */
|
||||
public function isAllowedMediaHost(string $host): bool
|
||||
{
|
||||
return in_array(strtolower($host), config('Site')->urlFetchAllowlist, true);
|
||||
}
|
||||
|
||||
public function isPublicIp(string $ip): bool
|
||||
{
|
||||
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) && str_starts_with($ip, '::ffff:')) {
|
||||
$ip = substr($ip, 7);
|
||||
}
|
||||
|
||||
$blockedFlags = FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE;
|
||||
|
||||
if (str_contains($ip, ':')) {
|
||||
return filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6 | $blockedFlags) !== false;
|
||||
}
|
||||
|
||||
return filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 | $blockedFlags) !== false;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user