הערה: כאשר משתמש מעלה וידאו, הדבר הראשון שהוא רואה הוא התצוגה המקדימה. אם היא חסרה או לא אינפורמטיבית, מדדי המעורבות וה-SEO יורדים. ללא תצוגה מקדימה, דף וידאו מאבד עד 30% מהקליקים בתוצאות החיפוש, ו-Core Web Vitals נפגעים עקב חוסר אופטימיזציה של LCP. אנו הופכים את חילוץ פריימים לתצוגה מקדימה לאוטומטי באמצעות FFmpeg, כך שכל וידאו מקבל פוסטר איכותי ו-sprite sheet עבור סרגל ההתקדמות. הניסיון שלנו בפיתוח אתרים עולה על 8 שנים, עם יותר מ-50 פרויקטים הכוללים תצוגות מקדימות של וידאו בפלטפורמות מ-Laravel ועד Next.js. צרו קשר לייעוץ — נבחן את הפרויקט שלכם.
האתגר המרכזי הוא בחירת פריים אינפורמטיבי. פריים אקראי הוא לעיתים קרובות כהה או מטושטש. FFmpeg פותר זאת עם מסנן ה-thumbnail, אשר מנתח פריימים באמצעות מדד SAD (Sum of Absolute Differences) ומוצא את הטוב ביותר. הבעיה השנייה היא ביצועים: עם מספר גדול של וידאו, יצירת תצוגות מקדימות באופן סינכרוני אינה יעילה. אנו משתמשים ב-Laravel queues עם Jobs, ומפעילים את היצירה לאחר העלאת הווידאו. הודות לעיבוד ברקע, השרת אינו חסום, והמשתמש רואה מיד placeholder סטנדרטי עד לסיום היצירה.
יצירת הפוסטר עם FFmpeg
האפשרות הפשוטה ביותר היא פריים בנקודת זמן ספציפית:
ffmpeg -i input.mp4 -ss 00:00:05 -vframes 1 -q:v 2 thumbnail.jpg ffmpeg -i input.mp4 -ss 00:00:05 -vframes 1 -q:v 2 thumbnail.jpg — מיקום (5 שניות מתחילת הווידאו). -ss 00:00:05 — פריים אחד. -vframes 1 — איכות JPEG (1 = הטוב ביותר, 31 = הגרוע ביותר). עדיף לא למקם אותו בשנייה 0 (לעיתים קרובות פריים שחור) ולא בשנייה האחרונה. הuristic טוב הוא 10% מאורך הווידאו, אך לא פחות מ-3 שניות ולא יותר מ-30.
כיצד FFmpeg בוחר את הפריים הטוב ביותר?
FFmpeg יכול לבחור פריימים לפי מדד שינוי באמצעות מסנן ה-thumbnail:
ffmpeg -i input.mp4 -vf "thumbnail=300" -vframes 1 thumbnail.jpg לפי תיעוד FFmpeg, מסנן ה-thumbnail מנתח כל פריים 300, מחשב SAD ביחס לפריים הקודם, ובוחר את הפריים עם השינוי המקסימלי. זה בערך פעם בכל 10–12 שניות ב-24 fps. זה איטי יותר מבחירה ישירה לפי זמן, אך התוצאה אינפורמטיבית יותר.
בחירת נקודת הפוסטר האופטימלית
אנו ממליצים על גישה היברידית: אם הווידאו מכיל סצנה מרכזית (לדוגמה, לוגו בתחילתו), השתמשו בחותמת זמן קבועה. אחרת, השתמשו בבחירה אוטומטית. בשני המקרים, יש להחיל scaling עם padding לגודל אחיד.
השוו בין שיטות בחירת פריימים:
| פרמטר | פריים אקראי | מסנן Thumbnail |
|---|---|---|
| מהירות | מיידי | ~2–5 שניות לדקת וידאו |
| איכות | לעיתים קרובות כהה/מטושטש | מייצג |
| מקרה שימוש | פוסטר ברירת מחדל | פוסטר לוידאו חשוב |
יתרונות Sprite Sheet על פני תצוגות מקדימות בודדות
Sprite sheet הוא תמונה אחת עם רשת של פריימים שהנגן משתמש בה כדי להציג תצוגות מקדימות בעת ריחוף מעל סרגל ההתקדמות. זה מפחית בקשות HTTP לאחת, מוריד את עומס השרת ומאיץ את התגובה. עבור וידאו ארוך (10+ דקות), זה קריטי — תצוגות מקדימות בודדות היו יוצרות עשרות קבצים.
עבור וידאו של 10 דקות, רשת של 10×10 נותנת מרווח של 6 שניות. רוחב פריים בודד הוא 160px, הרשת היא 1600×900px (ב-thumb של 16:9). קוד בצד הלקוח מחשב את מיקום ה-sprite לפי זמן:
function getThumbnailPosition(currentTime, spriteData) {
const { columns, rows, thumbWidth, interval, duration } = spriteData;
const thumbHeight = Math.round(thumbWidth * 9 / 16);
const frameIndex = Math.min(Math.floor(currentTime / interval), columns * rows - 1);
const col = frameIndex % columns;
const row = Math.floor(frameIndex / columns);
return { x: col * thumbWidth, y: row * thumbHeight, width: thumbWidth, height: thumbHeight };
}בריחוף מעל סרגל ההתקדמות, פשוט הגדירו background-image והזיזו את הרקע לקואורדינטות המחושבות. בממוצע, זה מפחית בקשות ב-90% בהשוואה לטעינת תמונות ממוזערות בודדות.
שירות PHP ליצירת תמונות ממוזערות
קוד מלא עבור VideoThumbnailService
namespace App\Services;
class VideoThumbnailService
{
public function generatePoster(
string $videoPath,
string $outputPath,
?float $offsetSeconds = null,
int $width = 1280,
int $height = 720
): void {
$duration = $this->getVideoDuration($videoPath);
$offset = $offsetSeconds ?? max(3.0, $duration * 0.1);
$offset = min($offset, $duration - 1.0);
$scaleFilter = "scale={$width}:{$height}:force_original_aspect_ratio=decrease," .
"pad={$width}:{$height}:(ow-iw)/2:(oh-ih)/2:black";
$cmd = implode(' ', [
'ffmpeg -y',
'-ss ' . number_format($offset, 3, '.', ''),
'-i ' . escapeshellarg($videoPath),
'-vframes 1',
"-vf " . escapeshellarg($scaleFilter),
'-q:v 3',
escapeshellarg($outputPath),
'2>&1',
]);
exec($cmd, $output, $exitCode);
if ($exitCode !== 0 || !file_exists($outputPath)) {
throw new \RuntimeException("Thumbnail generation failed: " . implode("\n", $output));
}
}
public function generateSpriteSheet(
string $videoPath,
string $outputPath,
int $columns = 10,
int $rows = 10,
int $thumbWidth = 160
): array {
$duration = $this->getVideoDuration($videoPath);
$totalFrames = $columns * $rows;
$interval = $duration / $totalFrames;
$fps = 1 / $interval;
$filter = "fps={$fps},scale={$thumbWidth}:-1,tile={$columns}x{$rows}";
$cmd = implode(' ', [
'ffmpeg -y',
'-i ' . escapeshellarg($videoPath),
"-vf " . escapeshellarg($filter),
'-vframes 1',
'-q:v 5',
escapeshellarg($outputPath),
'2>&1',
]);
exec($cmd, $output, $exitCode);
if ($exitCode !== 0) {
throw new \RuntimeException(implode("\n", $output));
}
return [
'path' => $outputPath,
'columns' => $columns,
'rows' => $rows,
'thumb_width' => $thumbWidth,
'interval' => $interval,
'duration' => $duration,
];
}
public function getVideoDuration(string $path): float
{
$cmd = "ffprobe -v error -show_entries format=duration -of csv=p=0 " .
escapeshellarg($path);
$output = shell_exec($cmd);
return (float) trim($output ?? '0');
}
} אינטגרציה לתוך Laravel באמצעות Job
class GenerateVideoThumbnailsJob implements ShouldQueue {
public int $timeout = 300;
public int $tries = 2;
public function __construct(private int $videoId) {}
public function handle(VideoThumbnailService $service): void
{
$video = Video::findOrFail($this->videoId);
$inputPath = Storage::disk('videos')->path($video->original_path);
$dir = Storage::disk('videos')->path('thumbnails/' . $video->id);
@mkdir($dir, 0755, true);
$posterPath = "{$dir}/poster.jpg";
$service->generatePoster($inputPath, $posterPath);
$spritePath = "{$dir}/sprite.jpg";
$spriteData = $service->generateSpriteSheet($inputPath, $spritePath);
$video->update([
'poster_path' => "thumbnails/{$video->id}/poster.jpg",
'sprite_path' => "thumbnails/{$video->id}/sprite.jpg",
'sprite_data' => $spriteData,
]);
}
}מטא-נתונים של Sprite (עמודות, שורות, מרווח) נשמרים בעמודת JSONB בטבלת הווידאו. הלקוח מקבל אותם יחד עם נתוני הווידאו ומשתמש בהם לרינדור תצוגות מקדימות על סרגל ההתקדמות.
תהליך העבודה שלנו
- ניתוח — חקר מבנה הווידאו ודרישות התצוגה המקדימה (גודל, פורמט).
- עיצוב — בחירת המחסנית: FFmpeg + Laravel queue. הגדרת פרמטרי ה-sprite sheet.
- יישום — כתיבת השירות וה-Job, אינטגרציה עם המודל.
- בדיקות — בדיקה על וידאו אמיתי באורכים שונים, התאמת ה-heuristics.
- פריסה — הגדרת queue, ניטור שגיאות.
מה כלול בעבודה
- הגדרת FFmpeg עם פרמטרים אופטימליים לפוסטר ו-sprite sheet.
- כתיבת מחלקת VideoThumbnailService וה-Job בתור.
- אינטגרציה עם מודל הווידאו (שמירת מטא-נתונים ב-JSONB).
- אופטימיזציה של התור (תור גבוה לוידאו בעדיפות, timeout של 300 שניות).
- בדיקות על וידאו באורכים ורזולוציות שונות.
- תיעוד נקודות קצה API ופורמט מטא-נתוני ה-sprite.
לוח זמנים ותמחור
לוח זמנים — מ-3 עד 5 ימים turnkey. התמחור מחושב באופן אישי בהתאם למורכבות האינטגרציה. בממוצע, לקוחות חוסכים עד 70% מהזמן המושקע בהכנת תצוגות מקדימות, ומפחיתים עלויות ניהול תוכן. ההשקעה באינטגרציה מחזירה את עצמה תוך 2–3 חודשים הודות לעיבוד תוכן מהיר יותר. צרו קשר — נבחן את הפרויקט שלכם.
לשם הבהרה, השוו בין הגישות:
| פרמטר | פוסטר בודד | Sprite Sheet |
|---|---|---|
| מספר קבצים | 1 | 1 |
| בקשות HTTP | 1 | 1 |
| יצירה | מהירה | איטית יותר (מנתחת את כל הפריימים) |
| שימוש | כריכת וידאו | תצוגה מקדימה בסרגל התקדמות |
| גודל קובץ | קטן (עד 100 KB) | גדול (עד 5 MB) |
אנו מבטיחים יצירה באיכות גבוהה העומדת בסטנדרטים מודרניים. הזמינו יישום של יצירת תמונות ממוזערות לתוכן הווידאו שלכם — צרו קשר לייעוץ.







