לקוחות מעלים וידאו בפורמטים שונים — מ-MP4 מטלפונים ועד MOV ממצלמות מקצועיות. אם ממירים תוך כדי תנועה, השרת קורס תחת העומס, בקשות נופלות בזמן אאוט, ומשתמשים מקבלים קבצים פגומים. ללא ארכיטקטורה נכונה, העלאת וידאו הופכת לצוואר בקבוק עבור כל אתר מדיה. אנו מפתחים צינור אסינכרוני: העלאה חתומה מראש לענן עוקפת את השרת מיד, וההמרה מתבצעת בתור על עובדים ייעודיים או שירותים מנוהלים.
תרחיש טיפוסי: משתמש מצלם 4K ב-60fps (500 MB). העלאה ישירה לשרת גורמת ל-nginx timeout. כתובת חתומה מראש פותרת זאת — הקובץ עובר ל-S3 תוך כמה דקות, והשרת רק מאשר. לאחר מכן, עובד מרים את הקובץ וממיר ברקע, מבלי לחסום את המשתמש. ההתקדמות מוצגת דרך WebSocket — המשתמש רואה סטטוס בזמן אמת.
למה להעביר את ההמרה לתור רקע?
המרה היא פעולה אינטנסיבית למעבד. אפילו וידאו קצר ב-1080p מריץ את המעבד ב-100% למשך דקות. אם זה נעשה בתהליך web, בקשות אחרות נחסמות. הצינור שלנו משתמש ב-S3 Event → SQS/Lambda → עובדים עם FFmpeg. זה מאפשר קנה מידה אופקי מבלי לפגוע בזמן התגובה של האתר. עומס המעבד יורד ב-90% כשמעבירים לתור.
אילו בעיות מערכת העלאת והמרת הווידאו פותרת?
- חסימת מעבד: העיבוד לא משפיע על שרת ה-web.
- רזולוציות מרובות: יצירה אוטומטית של 360p, 720p, 1080p ו-WebM לכיסוי כל המכשירים.
- העלאה איטית של משתמשים: קישור חתום מראש מאפשר להם להעלות ישירות לענן, תוך עקיפת השרת; מעקב התקדמות דרך WebSocket מעדכן אותם.
איך אנו בונים את מערכת העלאת והמרת הווידאו
אנו משתמשים בכתובות חתומות מראש מ-AWS S3: הלקוח מקבל קישור זמני ושולח את הקובץ ישירות ל-bucket. לאחר סיום ההעלאה, השרת כותב רשומת DB ומכניס משימה לתור (למשל, דרך SQS או Laravel Horizon).
ארכיטקטורת הצינור
[Client] -> [Presigned S3 Upload] -> [S3: original/]
| [S3 Event -> SQS/Lambda] -> [Transcoding Worker (FFmpeg)]
| [S3: processed/{quality}/] -> [CDN CloudFront]
| [Update DB: video.status = ready, paths = {...}]
| [WebSocket/Webhook -> Client notification] שלב 1: העלאה חתומה מראש ל-S3
// Генерация presigned URL для прямой загрузки с клиента
class VideoController extends Controller
{
public function initiateUpload(Request $request): JsonResponse
{
$request->validate([
'filename' => 'required|string|max:255',
'content_type' => 'required|in:video/mp4,video/webm,video/quicktime,video/x-msvideo',
'size' => 'required|integer|max:5368709120', // 5 GB
]);
$key = sprintf(
'original/%d/%s/%s',
auth()->id(),
now()->format('Y/m'),
Str::uuid() . '.' . pathinfo($request->filename, PATHINFO_EXTENSION)
);
$s3 = app('aws')->createClient('s3');
$command = $s3->getCommand('PutObject', [
'Bucket' => config('filesystems.disks.s3.bucket'),
'Key' => $key,
'ContentType' => $request->content_type,
]);
$presigned = $s3->createPresignedRequest($command, '+2 hours');
// Создаём запись в БД со статусом pending
$video = Video::create([
'user_id' => auth()->id(),
'original_key' => $key,
'original_name' => $request->filename,
'status' => 'pending',
'size' => $request->size,
]);
return response()->json([
'video_id' => $video->id,
'upload_url' => (string) $presigned->getUri(),
'key' => $key,
]);
}
// Клиент вызывает после успешной загрузки
public function confirmUpload(Request $request, Video $video): JsonResponse
{
$this->authorize('update', $video);
$video->update(['status' => 'uploaded']);
TranscodeVideoJob::dispatch($video);
return response()->json(['status' => 'processing']);
}
} שלב 2: המרה עם FFmpeg
class TranscodeVideoJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable;
public int $timeout = 7200; // 2 часа
public int $tries = 2;
const QUALITIES = [
'360p' => ['width' => 640, 'height' => 360, 'bitrate' => '800k', 'audiorate' => '96k'],
'720p' => ['width' => 1280, 'height' => 720, 'bitrate' => '2500k', 'audiorate' => '128k'],
'1080p' => ['width' => 1920, 'height' => 1080, 'bitrate' => '5000k', 'audiorate' => '192k'],
];
public function __construct(private Video $video) {}
public function handle(): void
{
$this->video->update(['status' => 'transcoding']);
// Скачать оригинал во временный файл
$tempInput = tempnam(sys_get_temp_dir(), 'video_') . '.mp4';
Storage::disk('s3')->copy($this->video->original_key, $tempInput); // упрощённо
// Реально: stream из S3 через signed URL
$inputUrl = Storage::disk('s3')->temporaryUrl($this->video->original_key, now()->addHour());
$paths = [];
foreach (self::QUALITIES as $quality => $params) {
$outputKey = sprintf(
'processed/%d/%s/%s.mp4',
$this->video->user_id,
$this->video->id,
$quality
);
$outputPath = sys_get_temp_dir() . "/{$this->video->id}_{$quality}.mp4";
$scale = "scale={$params['width']}:{$params['height']}:force_original_aspect_ratio=decrease,pad={$params['width']}:{$params['height']}:(ow-iw)/2:(oh-ih)/2";
$command = [
'ffmpeg',
'-y',
'-i', $inputUrl,
'-vf', $scale,
'-c:v', 'libx264',
'-preset', 'medium', // баланс скорость/качество
'-crf', '23',
'-maxrate', $params['bitrate'],
'-bufsize', (int)($params['bitrate']) * 2 . 'k',
'-c:a', 'aac',
'-b:a', $params['audiorate'],
'-movflags', '+faststart', // для web streaming
$outputPath,
];
$process = new \Symfony\Component\Process\Process($command);
$process->setTimeout(3600);
$process->run();
if (!$process->isSuccessful()) {
throw new \RuntimeException("FFmpeg failed for {$quality}: " . $process->getErrorOutput());
}
// Загрузить в S3
Storage::disk('s3')->putFileAs(
dirname($outputKey),
new \Illuminate\Http\File($outputPath),
basename($outputKey),
);
$paths[$quality] = $outputKey;
unlink($outputPath);
}
// Генерация thumbnail из 10% длительности
$thumbKey = "thumbnails/{$this->video->user_id}/{$this->video->id}.jpg";
$thumbPath = sys_get_temp_dir() . "/{$this->video->id}_thumb.jpg";
$process = new \Symfony\Component\Process\Process([
'ffmpeg',
'-y',
'-i', $inputUrl,
'-ss', '10%', // 10% от длительности
'-vframes', '1',
'-q:v', '2',
$thumbPath,
]);
$process->run();
if (file_exists($thumbPath)) {
Storage::disk('s3')->putFileAs(
dirname($thumbKey),
new \Illuminate\Http\File($thumbPath),
basename($thumbKey),
);
}
// Получить метаданные
$ffprobe = \FFMpeg\FFProbe::create();
$duration = $ffprobe->streams($inputUrl)->videos()->first()->get('duration');
$resolution = $ffprobe->streams($inputUrl)->videos()->first()->getDimensions();
$this->video->update([
'status' => 'ready',
'paths' => $paths,
'thumbnail_key' => $thumbKey,
'duration' => (int) $duration,
'width' => $resolution->getWidth(),
'height' => $resolution->getHeight(),
]);
// Уведомить пользователя
$this->video->user->notify(new VideoReadyNotification($this->video));
}
public function failed(\Throwable $e): void
{
$this->video->update(['status' => 'failed', 'error' => $e->getMessage()]);
Log::error('Video transcoding failed', ['video_id' => $this->video->id, 'error' => $e->getMessage()]);
}
} באיזו גישה לבחור: FFmpeg על תשתית עצמית או AWS MediaConvert?
FFmpeg נותן שליטה מלאה וללא עלויות AWS נוספות, אבל צריך לנהל שרתים ותורים. MediaConvert הוא שירות מנוהל: משלמים לפי דקת המרה, ללא דאגות קנה מידה. לסטארטאפים ופרויקטים בינוניים, FFmpeg בתור (למשל, Laravel Horizon) הוא ידידותי לתקציב. לקטלוגי וידאו גדולים עם עומס גבוה, MediaConvert משתלם יותר: הוא מתמודד עם קפיצות עומס אוטומטית ותומך ב-HLS. לנפחים עד 1000 דקות בחודש, FFmpeg על תשתית עצמית זול פי שלושה מ-MediaConvert, וחוסך עד $30 בחודש.
| קריטריון | FFmpeg + תור | AWS MediaConvert |
|---|---|---|
| שליטה | שליטה מלאה בפרמטרים ובקודקים | מוגבל לפרסטים |
| קנה מידה | דורש הגדרת autoscaling לעובדים | קנה מידה אוטומטי |
| עלות ל-1000 דקות/חודש | $0 (רק שרת) | ~$30 (לפי תעריף) |
| זמן הגדרה | 2–3 ימים | יום אחד |
MediaConvert מהיר פי שניים בנפחים גדולים בזכות עיבוד מקבילי, אבל FFmpeg זול פי שלושה לנפחים קטנים.
AWS Elastic Transcoder / MediaConvert
import boto3
def transcode_with_mediaconvert(input_key: str, output_prefix: str) -> str:
client = boto3.client('mediaconvert', region_name='eu-west-1', endpoint_url='https://abc123.mediaconvert.eu-west-1.amazonaws.com')
job = client.create_job(
Role='arn:aws:iam::123456789:role/MediaConvertRole',
Settings={
'Inputs': [
{
'FileInput': f's3://my-bucket/{input_key}',
'AudioSelectors': {'Audio Selector 1': {'DefaultSelection': 'DEFAULT'}},
'VideoSelector': {},
}
],
'OutputGroups': [
{
'Name': 'File Group',
'OutputGroupSettings': {
'Type': 'FILE_GROUP_SETTINGS',
'FileGroupSettings': {
'Destination': f's3://my-bucket/{output_prefix}/',
},
},
'Outputs': [
{
'NameModifier': '_720p',
'VideoDescription': {
'Width': 1280,
'Height': 720,
'CodecSettings': {
'Codec': 'H_264',
'H264Settings': {'Bitrate': 2500000, 'RateControlMode': 'CBR'},
},
},
'AudioDescriptions': [{'CodecSettings': {'Codec': 'AAC', 'AacSettings': {'Bitrate': 128000}}}],
'ContainerSettings': {'Container': 'MP4'},
},
# ... 360p, 1080p аналогично
],
}
],
}
)
return job['Job']['Id']
התקדמות ההמרה
// Отдавать прогресс через SSE или WebSocket
Route::get('/videos/{video}/status', function (Video $video) {
return response()->json([
'status' => $video->status,
'progress' => $video->transcoding_progress,
'paths' => $video->status === 'ready' ? $video->paths : null,
]);
}); תהליך העבודה
- ניתוח: איסוף סוגי וידאו, נפח צפוי, דרישות לנגינה אדפטיבית.
- עיצוב: בחירת S3 מול אחסון עצמי, הגדרת פרמטרי FFmpeg (bitrate, CRF, פרסטים).
- יישום: יצירת כתובות חתומות מראש, כתיבת עובדי תור, הגדרת WebSocket.
- בדיקות: בדיקת עומס עם גדלי וידאו שונים, אימות איכות תמונות ממוזערות ואודיו.
- פריסה: הגדרת ניטור (התראות על שגיאות המרה), CI/CD לעדכוני צינור.
לוח זמנים
| משימה | זמן |
|---|---|
| העלאה חתומה מראש + FFmpeg בתור | 4–5 ימים |
| יצירת תמונות ממוזערות + מטא-דאטה | +1–2 ימים |
| אינטגרציית AWS MediaConvert | 2–3 ימים |
| HLS סטרימינג אדפטיבי | +3–4 ימים (משימה נפרדת) |
מה כלול
- תיעוד ארכיטקטורת הצינור.
- קוד מקור עם הערות (Laravel + FFmpeg או Python + MediaConvert).
- הגדרת גישת S3, קונפיגורציית תפקידי IAM.
- סקריפטים לניטור והתראות.
- הכשרת צוות לתמיכה.
- זמן תגובה מובטח לכרטוסי תמיכה (SLA).
- אינטגרציה מוסמכת עם ספקי ענן מרכזיים (AWS, GCP, Azure).
- 5+ שנות ניסיון בבניית צינורות וידאו, עם למעלה מ-30 פרויקטים מוצלחים לאתרי מדיה ופלטפורמות חינוכיות.
טעויות נפוצות בפועל: לא להגדיר timeout למשימות — המרה יכולה לקחת שעות ועובדים קורסים; לא לטפל בשגיאות העלאה (הפסקת אינטרנט, content-type שגוי); כתובת חתומה מראש קצרה מדי — המשתמש לא מספיק לסיים את ההעלאה.
אנו מעריכים את הפרויקט שלך ביום אחד — פשוט צור קשר. קבל ייעוץ על ארכיטקטורת צינור וידאו. השאר בקשה כדי לקבל הערכה תוך יום אחד.







