הטמעת סטרימינג וידאו HLS ו-DASH
אתם משיקים שירות וידאו, ואחרי העלאת הסרטונים הראשונים אתם מגלים — הנגן מגמגם, הטעינה לא נגמרת, ומשתמשי מובייל רואים רק ספינר. זה מצב מוכר — נתקלנו בו פעמים רבות. הניסיון שלנו: 7+ שנים בסטרימינג וידאו. הפתרון הוא סטרימינג אדפטיבי עם פילוח נכון, אינטגרציית CDN ותמיכה במספר רזולוציות. במאמר זה נסביר כיצד אנו מיישמים צינור HLS/DASH במפתח-סוהר, ואת המלכודות שנתקלנו בהן.
HLS לעומת DASH: באיזה לבחור?
HLS (HTTP Live Streaming) הוא תקן של אפל, נתמך על ידי כל הדפדפנים דרך hls.js (למעט Safari, שיש לו תמיכה מובנית). DASH (Dynamic Adaptive Streaming over HTTP) הוא תקן פתוח המשמש לתאימות DRM. בפועל, HLS הוא הבחירה המועדפת: חביון נמוך יותר, תמיכת CDN רחבה יותר. DASH נדרש כאשר נדרשים מספר מערכות DRM (Widevine, PlayReady).
בפרויקטים שלנו, אנו משתמשים ב-HLS כבסיס ומוסיפים DASH לפי דרישה. השוואה בטבלה:
| מאפיין | HLS | DASH |
|---|---|---|
| תמיכה | כל הדפדפנים (hls.js עבור שאינם Safari) | כל הדפדפנים (dash.js) |
| מיכל | MPEG-TS או fMP4 | fMP4 |
| DRM | FairPlay (אפל), Widevine | Widevine, PlayReady |
| חביון | 6–30 שניות (LL-HLS: <2 שניות) | 2–10 שניות |
| תאימות CDN | מצוינת | מצוינת |
בבדיקות שלנו, HLS הראה חביון נמוך פי 2 מ-DASH.
למה FFmpeg הוא לא האפשרות היחידה?
לקידוד וידאו, אנו משתמשים ב-FFmpeg יחד עם תורים (Laravel Horizon), אך לנפחים גבוהים אנו משלבים AWS MediaConvert. נפרק את שתי הגישות.
FFmpeg + תור (באירוח עצמי)
הסקריפט transcode-hls.sh חותך את הווידאו לשלוש איכויות: 1080p, 720p, 360p. מקטעים של 6 שניות (fMP4). פלט: master.m3u8 ורשימות השמעה משניות.
הצג סקריפט קידוד מלא
```bash #!/bin/bash # transcode-hls.shINPUT="$1" OUTPUT_DIR="$2"
mkdir -p "$OUTPUT_DIR"
ffmpeg -i "$INPUT" -filter_complex "[0:v]split=3[v1][v2][v3]; [v1]scale=1920:1080:force_original_aspect_ratio=decrease[v1080]; [v2]scale=1280:720:force_original_aspect_ratio=decrease[v720]; [v3]scale=640:360:force_original_aspect_ratio=decrease[v360]" -map "[v1080]" -map 0:a -c:v:0 libx264 -crf 22 -preset fast -b:v:0 5000k -maxrate:v:0 5500k -bufsize:v:0 10000k -c:a:0 aac -b:a:0 192k -map "[v720]" -map 0:a -c:v:1 libx264 -crf 23 -preset fast -b:v:1 2500k -maxrate:v:1 2750k -bufsize:v:1 5000k -c:a:1 aac -b:a:1 128k -map "[v360]" -map 0:a -c:v:2 libx264 -crf 24 -preset fast -b:v:2 800k -maxrate:v:2 880k -bufsize:v:2 1600k -c:a:2 aac -b:a:2 96k -f hls -hls_time 6 -hls_playlist_type vod -hls_flags independent_segments -hls_segment_type fmp4 -hls_segment_filename "$OUTPUT_DIR/v%v/seg%06d.m4s" -master_pl_name master.m3u8 -var_stream_map "v:0,a:0,name:1080p v:1,a:1,name:720p v:2,a:2,name:360p" "$OUTPUT_DIR/v%v/index.m3u8"
</details> Результат: output/ ├── master.m3u8 ├── 1080p/ │ ├── index.m3u8 │ ├── seg000001.m4s │ └── ... ├── 720p/ │ └── ... └── 360p/ └── ...
#### PHP-джоба для очереди Класс TranscodeToHlsJob — типичная job для Laravel. Таймаут 2 часа, две попытки. Обрабатываем только успешный кейс.
```php
class TranscodeToHlsJob implements ShouldQueue
{
public int $timeout = 7200;
public int $tries = 2;
public function __construct(private Video $video) {}
public function handle(): void
{
$this->video->update(['status' => 'transcoding']);
$inputUrl = Storage::disk('s3')->temporaryUrl($this->video->original_key, now()->addHours(3));
$outputDir = sys_get_temp_dir() . '/hls_' . $this->video->id;
$s3Prefix = "hls/{$this->video->user_id}/{$this->video->id}";
mkdir($outputDir, 0777, true);
$process = new \Symfony\Component\Process\Process([
'/usr/local/bin/transcode-hls.sh',
$inputUrl,
$outputDir
]);
$process->setTimeout(7200);
$process->run();
if (!$process->isSuccessful()) {
throw new \RuntimeException('HLS transcoding failed: ' . $process->getErrorOutput());
}
// Загрузить все файлы в S3
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($outputDir)
);
foreach ($iterator as $file) {
if (!$file->isFile()) continue;
$relativePath = str_replace($outputDir . '/', '', $file->getPathname());
$s3Key = "{$s3Prefix}/{$relativePath}";
$contentType = str_ends_with($file->getFilename(), '.m3u8') ? 'application/x-mpegURL' : 'video/mp4';
Storage::disk('s3')->put($s3Key, file_get_contents($file->getPathname()), [
'ContentType' => $contentType,
'CacheControl' => str_ends_with($file->getFilename(), '.m3u8') ? 'no-cache' // плейлист не кэшируем
: 'public, max-age=31536000', // сегменты кэшируем навсегда
]);
}
$this->video->update([
'status' => 'ready',
'hls_manifest' => "{$s3Prefix}/master.m3u8",
]);
// Очистить временные файлы
exec("rm -rf {$outputDir}");
}
}
``` AWS MediaConvert (מנוהל)
לפרויקטים בעומס גבוה, אנו משתמשים ב-MediaConvert. הגדרה בפייתון:
def create_hls_job(input_key: str, output_prefix: str) -> str:
client = boto3.client('mediaconvert', endpoint_url=MEDIACONVERT_ENDPOINT)
job = client.create_job(
Role=MEDIACONVERT_ROLE,
Settings={
'Inputs': [{'FileInput': f's3://bucket/{input_key}', 'VideoSelector': {}, 'AudioSelectors': {'Audio 1': {'DefaultSelection': 'DEFAULT'}}}],
'OutputGroups': [{
'Name': 'Apple HLS',
'OutputGroupSettings': {
'Type': 'HLS_GROUP_SETTINGS',
'HlsGroupSettings': {
'Destination': f's3://bucket/{output_prefix}/',
'SegmentLength': 6,
'MinSegmentLength': 0,
'DirectoryStructure': 'SUBDIRECTORY_PER_STREAM',
},
},
'Outputs': [
{'NameModifier': '_1080p', 'VideoDescription': {'Width': 1920, 'Height': 1080, 'CodecSettings': {'Codec': 'H_264', 'H264Settings': {'Bitrate': 5000000, 'RateControlMode': 'CBR', 'GopSize': 90}}}, 'AudioDescriptions': [{'CodecSettings': {'Codec': 'AAC', 'AacSettings': {'Bitrate': 192000}}}]},
{'NameModifier': '_720p', 'VideoDescription': {'Width': 1280, 'Height': 720, 'CodecSettings': {'Codec': 'H_264', 'H264Settings': {'Bitrate': 2500000, 'RateControlMode': 'CBR', 'GopSize': 90}}}, 'AudioDescriptions': [{'CodecSettings': {'Codec': 'AAC', 'AacSettings': {'Bitrate': 128000}}}]},
{'NameModifier': '_360p', 'VideoDescription': {'Width': 640, 'Height': 360, 'CodecSettings': {'Codec': 'H_264', 'H264Settings': {'Bitrate': 800000, 'RateControlMode': 'CBR', 'GopSize': 90}}}, 'AudioDescriptions': [{'CodecSettings': {'Codec': 'AAC', 'AacSettings': {'Bitrate': 96000}}}]},
],
}],
}
)
return job['Job']['Id']
MediaConvert מייצר רשימות השמעה HLS בעצמו. אנו משלמים רק עבור דקות קידוד.
CloudFront CDN
כדי להבטיח אספקת וידאו מהירה ברחבי העולם, אנו מגדירים CloudFront עם שתי מדיניות מטמון: עבור רשימות השמעה (TTL 5 שניות) ומקטעים (TTL שנה).
resource "aws_cloudfront_distribution" "video" {
origin {
domain_name = aws_s3_bucket.videos.bucket_regional_domain_name
origin_id = "s3-videos"
origin_access_control_id = aws_cloudfront_origin_access_control.default.id
}
enabled = true
is_ipv6_enabled = true
default_cache_behavior {
allowed_methods = ["GET", "HEAD"]
cached_methods = ["GET", "HEAD"]
target_origin_id = "s3-videos"
viewer_protocol_policy = "redirect-to-https"
forwarded_values {
query_string = false
cookies {
forward = "none"
}
headers = ["Origin", "Access-Control-Request-Method", "Access-Control-Request-Headers"]
}
min_ttl = 0
default_ttl = 86400
max_ttl = 31536000
}
# Плейлисты .m3u8 — короткий кэш
ordered_cache_behavior {
path_pattern = "*.m3u8"
allowed_methods = ["GET", "HEAD"]
cached_methods = ["GET", "HEAD"]
target_origin_id = "s3-videos"
viewer_protocol_policy = "redirect-to-https"
forwarded_values {
query_string = false
cookies {
forward = "none"
}
}
min_ttl = 0
default_ttl = 5
max_ttl = 30
}
} React Player עם hls.js
אינטגרציה בצד הלקוח: hls.js לדפדפנים מודרניים, גיבוי ל-HLS מובנה ב-Safari.
import Hls from 'hls.js';
import { useEffect, useRef } from 'react';
interface VideoPlayerProps {
manifestUrl: string;
poster?: string;
}
export function VideoPlayer({ manifestUrl, poster }: VideoPlayerProps) {
const videoRef = useRef<HTMLVideoElement>(null);
useEffect(() => {
const video = videoRef.current;
if (!video) return;
if (Hls.isSupported()) {
const hls = new Hls({
maxBufferLength: 30,
maxMaxBufferLength: 60,
lowLatencyMode: false,
});
hls.loadSource(manifestUrl);
hls.attachMedia(video);
hls.on(Hls.Events.ERROR, (_, data) => {
if (data.fatal) {
if (data.type === Hls.ErrorTypes.NETWORK_ERROR) {
hls.startLoad();
} else {
hls.destroy();
}
}
});
return () => hls.destroy();
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
// Safari: нативная поддержка HLS
video.src = manifestUrl;
}
}, [manifestUrl]);
return (
<video
ref={videoRef}
poster={poster}
controls
playsInline
style={{ width: '100%', maxHeight: '80vh' }}
/>
);
} מה כלול בעבודה
אנו מטפלים במחזור המלא להטמעת סטרימינג HLS/DASH באתר שלכם:
- קידוד וידאו לשלוש איכויות (1080p, 720p, 360p) באמצעות FFmpeg או AWS MediaConvert.
- אינטגרציה עם תורים (Laravel Horizon) לעיבוד אסינכרוני.
- פיתוח נגן React מותאם אישית המבוסס על hls.js עם תמיכה בהתאמת ממשק משתמש.
- הגדרת CDN (CloudFront או Cloudflare) לאספקת תוכן מהירה.
- תיעוד מפורט של ארכיטקטורת הצינור.
- הכשרת הצוות שלכם על המערכת.
- שבועיים של תמיכה לאחר ההשקה.
טעויות אופייניות
- קצבי סיביות שגויים: נמוכים מדי עבור 1080p, גבוהים מדי עבור 360p. אנו משתמשים בהמלצות אפל.
- שמירת רשימות השמעה במטמון: אם master.m3u8 נשמר במטמון, הנגן לא יראה איכויות חדשות אחרי קידוד מחדש. אנו מגדירים no-cache.
- חוסר CORS: הדפדפן חוסם מקטעים. אנו מגדירים CORS על S3/CloudFront.
- אי-התאמת הידרציה: אם הנגן מרנדר על השרת, עלולה להיות אי-התאמה עם הלקוח. אנו משתמשים בייבוא דינמי.
תהליך העבודה
- ניתוח — לימוד סרטוני המקור, דרישות DRM, קהל היעד.
- עיצוב — בחירת מחסנית (FFmpeg באירוח עצמי או MediaConvert), קביעת קצבי סיביות, CDN.
- יישום — כתיבת מקודד, עבודה, נגן.
- בדיקות — בדיקת LCP, CLS, INP, Core Web Vitals.
- פריסה — פריסה לייצור, ניטור לוגים.
לוח זמנים ועלות
| שלב | לוח זמנים |
|---|---|
| קידוד HLS עם FFmpeg בתור | 4–5 ימים |
| אינטגרציית AWS MediaConvert | 2–3 ימים |
| נגן hls.js עם React | 1–2 ימים |
| אספקת CloudFront CDN | 1–2 ימים |
| צינור מלא (העלאה → קידוד → נגן) | 7–14 ימים |
העלות מחושבת באופן אישי לפי נפח הווידאו, קצבי הסיביות וצרכי ה-DRM. צרו קשר להערכת הפרויקט שלכם — נחשב את לוח הזמנים והעלות. הזמינו הטמעת סטרימינג HLS/DASH וקבלו ייעוץ. אנו מבטיחים שהווידאו שלכם יעבוד ללא תקיעות בכל מכשיר.







