ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

视频在线播放全链路:从FFmpeg转码到HLS分发实践

视频在线播放全链路:从FFmpeg转码到HLS分发实践 当产品经理丢来一句“最新视频来袭快来看看吧”时后端和前端同学的真实反应往往不是点开视频看内容而是先皱眉这个视频给谁看、在哪个端上看、要用什么格式、在线播放还是下载后看、流量大不大、需不需要转码如果只是把 MP4 往服务器一放、前端写一个video标签那么小规模演示没问题。可一旦视频多起来、用户网络不稳定、手机和 PC 都要兼容这套朴素方案就会立刻露馅首屏加载慢、拖动进度条卡顿、移动端播放黑屏、流量成本居高不下。这篇文章不讨论怎么剪视频、怎么做得好看。我们只解决一个技术问题当你的项目需要“发布最新视频”并让用户在 Web 端流畅观看时后端要做什么、前端要做什么、中间走了哪些流程。我会从视频在线播放的核心原理讲起用 FFmpeg 做转码与 HLS 切片用 Nginx 做静态分发最后用 hls.js 在浏览器里播放完整跑通一条发布链路。1. 这篇文章真正要解决的问题先说判断视频发布链路的真正瓶颈不是“怎么把视频文件上传到服务器”而是“如何让不同网络环境下的用户都能流畅播放”。一个 1080p、时长 10 分钟、体积接近 1GB 的 MP4 文件如果直接放在服务器上给用户点播会出现三个问题。第一个问题是带宽压力。1GB 的文件用户要不要下载完才能播不一定HTML5 的video标签支持渐进式播放Progressive Download用户可以边下边播。但拖动进度条时浏览器会从新的位置开始拉取数据如果服务器没有做范围请求支持很多播放器会直接重新从头加载体验非常差。第二个问题是网络适配。手机用户在地铁里网速波动大直接播放固定码率的高清视频很容易频繁缓冲。比较好的方案是给同一个视频准备多档码率播放器根据当前网速自动切换。这就需要对源视频做“转多码率处理”。第三个问题是格式兼容。浏览器支持 H.264 编码的 MP4但不一定支持 H.265/HEVC 编码也不一定支持 MKV、AVI 这些封装格式。更麻烦的是如果希望视频支持拖动远跳、播放进度秒开后端需要提供更复杂的分片能力。所以当我们说“把最新视频发布出去”时真正要做的事情是上传原始视频到服务器对视频做转码与切片生成适合网络传输的 HLS 格式再通过静态服务器或 CDN 分发最后在前端接入一个支持 HLS 的播放器。这就是本文要讲清楚的一条完整链路。读这篇文章的人我推荐下面三类。后端开发者以后遇到视频需求知道该用什么工具、什么协议、如何处理上传与转码的异步逻辑。前端开发者理解为什么前端拿到的不是 MP4 链接而是一个.m3u8地址知道如何接入播放器。技术负责人/架构师评估视频功能的技术选型时能对“自研转码 静态分发”和“直接用云厂商视频服务”做出更合理的取舍。2. 视频在线播放的核心概念在动手操作之前必须先厘清几个概念。很多同学把“格式”“编码”“协议”混为一谈这一步搞不清楚后面排错会非常痛苦。2.1 视频编码与封装格式一个视频文件可以拆成两大部分视频流、音频流的编码数据以及把这些数据组合起来的容器。编码Codec解决的是压缩问题。H.264 是目前兼容性最好的视频编码标准几乎所有浏览器和移动端都支持。H.265HEVC压缩率更高但浏览器兼容性参差不齐。在实际项目中如果没有特殊需求建议优先输出 H.264这是在线播放的“保底方案”。封装格式Container解决的是存储问题。MP4、MKV、AVI 都是容器它们把编码后的视频流、音频流、字幕、元数据打包在一起。MP4 是 Web 端最通用的封装但 MP4 的索引信息moov box通常在文件末尾如果服务端不支持范围请求播起来会很慢。HLS 的切片格式 TS 也是一种容器Web 端直接播放 TS 分片也是有原生支持度的。简单记忆编码决定画质和体积封装决定文件形态协议决定传输方式。2.2 码率、分辨率和关键帧码率Bitrate是每秒视频的数据量单位通常是 kbps 或 Mbps。码率越高单秒数据量越大画质通常越好但网络要求也越高。分辨率是宽高像素数比如 1920x1080。码率和分辨率不是一回事1080p 的视频如果码率太低画面照样模糊。关键帧Keyframe是视频解码时的关键参考点。HLS 切片时每个切片最好从关键帧开始这样播放器可以独立解码每一个分片。如果切片没有对齐关键帧播放时容易出现花屏或卡顿。FFmpeg 在切片时可以通过参数强制对齐这个后面会写。2.3 流媒体协议HLS 与 DASHHLSHTTP Live Streaming是 Apple 提出的基于 HTTP 的流媒体协议。它的核心思路是把视频切成一个个小分片通常每个 2 到 10 秒生成一个索引文件.m3u8播放器先读取索引文件再按顺序请求分片并播放。因为走的是 HTTPNginx、CDN、对象存储都能直接支持这是 HLS 最大的优势。DASHDynamic Adaptive Streaming over HTTP是另一个自适应流媒体标准功能上和 HLS 类似但前端播放器的原生支持不如 HLS 广泛。在 Web 场景下很多项目直接用 hls.js 播放 HLSDASH 通常配合 shaka-player 使用。这里的关键点是HLS 不只是“切片”它还可以在一个.m3u8文件中描述多档码率。播放器根据当前带宽自动切到合适的码率这在弱网环境下非常重要。这个特性叫自适应码率ABR。2.4 渐进式播放与流媒体播放的区别渐进式播放是“一个文件从头传到尾”用户下载多少播多少拖动会跳到对应位置继续下载。它实现简单但不可控。流媒体播放是“把文件切成很多小片按需拉取”。用户播到第 10 秒播放器只需要请求包含第 10 秒数据的那几个分片不需要关心整个文件有多大。HLS、DASH 都属于这一类。对于“最新视频来袭”这种有多用户并发观看、需要快速启动、需要适应不同网速的场景流媒体播放几乎是唯一选择。3. 环境准备与前置条件本文的示例会用到 FFmpeg、Nginx、JavaSpring Boot和前端 JavaScript。为了不把文章变成某个特定版本的说明书我先给出通用环境要求实际使用时请以你本机的版本为准。3.1 需要的软件FFmpeg视频转码与切片工具。安装完成后在终端执行ffmpeg -version能正常输出版本信息即可。Nginx静态文件服务器用于分发 HLS 切片。也可以直接用对象存储和 CDN但本地方案比较容易演示。JDK 8 或更高版本用于运行 Spring Boot 示例。Maven管理 Java 项目依赖。如果你更熟悉 Python可以用 Flask/FastAPI 实现同样的接口逻辑核心流程一致。浏览器我建议用 Chrome 或 Edge并在 DevTools 里切换到手机模拟器测试移动端播放效果。3.2 准备一个测试视频你可以自己用手机录一段短视频也可以下载一个公开的样片。请勿使用有版权争议的内容做线上演示本地开发可以随意。测试视频建议不要太长30 秒到 1 分钟即可因为转码过程会消耗 CPU。我假设你的测试文件名为input.mp4放在/data/video/origin/目录下。后文的路径都基于这个假设。3.3 版本选择建议FFmpeg 的版本迭代很快不同版本的编码器参数略有差异。如果你在生产环境使用建议固定一个长期支持版本并把 FFmpeg 命令和参数放到配置文件或代码常量里统一管理避免队友各自装版本导致输出结果不一致。4. 视频转码与 HLS 切片准备工作完成后我们先做最重要的一步把原始视频转成适合 Web 播放的 HLS 格式。4.1 为什么不能直接对源文件做切片有些人可能觉得既然 HLS 就是切片那直接把 MP4 切片不就完了这个想法不完全错但不够严谨。一个正常的 MP4 文件它的编码格式可能不是 H.264也可能是 H.265、VP9甚至可能是音频编码不兼容的格式。直接切片但不转码生成的 TS 分片照样无法在某些浏览器里播放。另一个问题是多码率。如果只做一份切片那所有用户都看同一份清晰度网络差的人就只能不断缓冲。所以正确做法是先转码生成多档位的 H.264 编码 MP4 或 TS再对每一档做切片最后合并成一个多码率的.m3u8索引。4.2 单码率转码与切片命令先写一个最简版本。下面这条命令做的是读取/data/video/origin/input.mp4将视频流转为 H.264将音频流转为 AAC然后按 6 秒一个分片切成 HLS 格式并生成索引文件index.m3u8。ffmpeg -i /data/video/origin/input.mp4 \ -c:v libx264 \ -c:a aac \ -b:v 2000k \ -b:a 128k \ -vf scale1280:720 \ -force_key_frames expr:gte(t,n_forced*6) \ -sc_threshold 0 \ -hls_time 6 \ -hls_list_size 0 \ -hls_segment_filename /data/video/hls/720p/segment_%03d.ts \ /data/video/hls/720p/index.m3u8参数解释如下。-c:v libx264视频编码器使用 H.264。-c:a aac音频编码器使用 AAC浏览器兼容性好。-b:v 2000k视频目标码率 2000kbps。实际码率会由编码器根据画面复杂度动态调整但平均会接近这个值。-vf scale1280:720分辨率缩放到 720p。-vf是视频滤镜的简写scale 用于修改分辨率。-force_key_frame强制每 6 秒设置一个关键帧这与后面的切片时长对齐。-sc_threshold 0关闭场景切换自动插入关键帧的逻辑保证切片对齐可控。-hls_time 6每个分片时长为 6 秒。-hls_list_size 0生成的index.m3u8保留所有分片而不是只保留最近几个分片。直播场景中这个参数很重要点播场景通常设为 0。-hls_segment_filename指定切片文件的命名规则。运行结束后/data/video/hls/720p/下会生成很多.ts分片和一个index.m3u8。此时你可以直接把这个地址扔给支持 HLS 的播放器试试。4.3 多码率版本生成为了让播放器能根据网络情况自动切换清晰度我们需要生成多个码率版本。我会用 Bash 脚本依次生成 360p、720p、1080p 三个版本然后手动编写主索引文件。#!/bin/bash INPUT/data/video/origin/input.mp4 OUTPUT_DIR/data/video/hls mkdir -p $OUTPUT_DIR/360p $OUTPUT_DIR/720p $OUTPUT_DIR/1080p ffmpeg -i $INPUT \ -c:v libx264 -c:a aac -b:v 800k -b:a 96k \ -vf scale640:360 \ -force_key_frames expr:gte(t,n_forced*6) \ -sc_threshold 0 \ -hls_time 6 -hls_list_size 0 \ -hls_segment_filename $OUTPUT_DIR/360p/segment_%03d.ts \ $OUTPUT_DIR/360p/index.m3u8 ffmpeg -i $INPUT \ -c:v libx264 -c:a aac -b:v 2000k -b:a 128k \ -vf scale1280:720 \ -force_key_frames expr:gte(t,n_forced*6) \ -sc_threshold 0 \ -hls_time 6 -hls_list_size 0 \ -hls_segment_filename $OUTPUT_DIR/720p/segment_%03d.ts \ $OUTPUT_DIR/720p/index.m3u8 ffmpeg -i $INPUT \ -c:v libx264 -c:a aac -b:v 4000k -b:a 192k \ -vf scale1920:1080 \ -force_key_frames expr:gte(t,n_forced*6) \ -sc_threshold 0 \ -hls_time 6 -hls_list_size 0 \ -hls_segment_filename $OUTPUT_DIR/1080p/segment_%03d.ts \ $OUTPUT_DIR/1080p/index.m3u8 echo transcode finished注意720p 和 1080p 的源视频如果本身分辨率不够scale会强制拉高这样反而会浪费码率。更靠谱的做法是先读取源视频分辨率再按比例决定生成哪几档。生产环境通常会有专门的转码服务来处理这些判断逻辑。4.4 编写多码率主索引三个子目录各有自己的index.m3u8我们还需要一个顶层索引把它们组合起来。新建/data/video/hls/master.m3u8#EXTM3U #EXT-X-VERSION:3 #EXT-X-STREAM-INF:BANDWIDTH800000,RESOLUTION640x360 360p/index.m3u8 #EXT-X-STREAM-INF:BANDWIDTH2000000,RESOLUTION1280x720 720p/index.m3u8 #EXT-X-STREAM-INF:BANDWIDTH4000000,RESOLUTION1920x1080 1080p/index.m3u8播放器解析master.m3u8时会看到三档码率然后根据当前网速和播放器策略决定请求哪个子索引。这里要特别注意子索引的路径是相对路径。如果你的 master 文件放在/data/video/hls/那么360p/index.m3u8会正确解析到/data/video/hls/360p/index.m3u8。如果目录结构变了相对路径会失效这是比较常见的坑。4.5 转码过程的工程化思考上面的脚本能跑通但它有几个明显问题。串行执行三档转码时长是三次转码之和效率偏低。没有失败重试机制中途异常需要人工介入。没有把转码参数与业务配置分离换台机器或者换需求就又要改脚本。生产环境推荐的做法是把转码任务放入消息队列用后端服务消费队列调用 FFmpeg 执行转码并记录转码状态。这样上传视频和转码流程可以异步解耦用户上传完成后立刻得到响应转码完成后系统再通过回调通知业务方。后面的完整示例会写一个最简 Java 实现。5. 完整示例Spring Boot 上传视频并触发转码现在我们进入完整示例阶段。这个示例做三层事情提供一个文件上传接口、把上传文件保存到本地、启动外部 FFmpeg 进程完成转码切片。考虑代码可读性我没有引入复杂的消息队列而是用线程池执行转码任务。生产环境请替换成 MQ 方案。5.1 项目结构video-demo/ ├── pom.xml └── src/main/ ├── java/com/example/videodemo/ │ ├── VideoDemoApplication.java │ ├── controller/VideoController.java │ ├── service/VideoService.java │ └── config/AsyncConfig.java └── resources/ └── application.yml5.2 pom.xml?xml version1.0 encodingUTF-8? project xmlnshttp://maven.apache.org/POM/4.0.0 xmlns:xsihttp://www.w3.org/2001/XMLSchema-instance xsi:schemaLocationhttp://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd modelVersion4.0.0/modelVersion parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version2.7.18/version relativePath/ /parent groupIdcom.example/groupId artifactIdvideo-demo/artifactId version1.0.0/version properties java.version8/java.version /properties dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency /dependencies /project版本说明Spring Boot 2.7.18 是一个可用的版本。如果你的公司有其他版本统一要求按照自己的项目版本即可。5.3 application.ymlserver: port: 8080 video: origin-dir: /data/video/origin hls-dir: /data/video/hls ffmpeg-path: /usr/local/bin/ffmpeg这三个配置项是核心。origin-dir存原始文件hls-dir存转码切片结果ffmpeg-path是 FFmpeg 可执行文件路径。生产环境这些路径应该放到配置中心或环境变量里不要写死在代码中。5.4 异步转码配置转码是耗时操作绝不能写在 HTTP 请求线程里。用线程池异步执行能让上传接口快速返回。package com.example.videodemo.config; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; import java.util.concurrent.Executor; import java.util.concurrent.ThreadPoolExecutor; Configuration public class AsyncConfig { Bean(videoTranscodeExecutor) public Executor videoTranscodeExecutor() { ThreadPoolTaskExecutor executor new ThreadPoolTaskExecutor(); executor.setCorePoolSize(2); executor.setMaxPoolSize(4); executor.setQueueCapacity(100); executor.setThreadNamePrefix(video-transcode-); executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); executor.initialize(); return executor; } }线程池的策略值得说一句。CallerRunsPolicy的意思是如果队列满了任务不会被丢弃而是由提交任务的线程直接执行。这样做的代价是 HTTP 请求线程会被转码任务拖住但在演示场景里可以避免任务静默丢失。生产环境这种策略要谨慎使用更合理的做法是让队列足够长并配合监控报警。5.5 上传接口package com.example.videodemo.controller; import com.example.videodemo.service.VideoService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.multipart.MultipartFile; RestController public class VideoController { Autowired private VideoService videoService; PostMapping(/api/video/upload) public String upload(RequestParam(file) MultipartFile file) { if (file.isEmpty()) { throw new IllegalArgumentException(file is empty); } return videoService.processUpload(file); } }5.6 转码服务package com.example.videodemo.service; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; import java.io.File; import java.io.IOException; import java.nio.file.Files; import java.nio.file.StandardCopyOption; import java.util.UUID; Service public class VideoService { private static final Logger log LoggerFactory.getLogger(VideoService.class); Value(${video.origin-dir}) private String originDir; Value(${video.hls-dir}) private String hlsDir; Value(${video.ffmpeg-path}) private String ffmpegPath; public String processUpload(MultipartFile file) { try { String videoId UUID.randomUUID().toString().replace(-, ); String originFileName videoId .mp4; File originFile new File(originDir, originFileName); if (!originFile.getParentFile().exists()) { originFile.getParentFile().mkdirs(); } Files.copy(file.getInputStream(), originFile.toPath(), StandardCopyOption.REPLACE_EXISTING); transcode(originFile, videoId); return videoId; } catch (IOException e) { throw new RuntimeException(save origin file failed, e); } } Async(videoTranscodeExecutor) public void transcode(File originFile, String videoId) { File hlsVideoDir new File(hlsDir, videoId); if (!hlsVideoDir.exists()) { hlsVideoDir.mkdirs(); } String output hlsVideoDir.getAbsolutePath() /index.m3u8; ProcessBuilder pb new ProcessBuilder( ffmpegPath, -i, originFile.getAbsolutePath(), -c:v, libx264, -c:a, aac, -b:v, 2000k, -b:a, 128k, -vf, scale1280:720, -force_key_frames, expr:gte(t,n_forced*6), -sc_threshold, 0, -hls_time, 6, -hls_list_size, 0, -hls_segment_filename, hlsVideoDir.getAbsolutePath() /segment_%03d.ts, output ); pb.redirectErrorStream(true); try { Process process pb.start(); int exitCode process.waitFor(); if (exitCode 0) { log.info(video transcode success, videoId{}, videoId); } else { log.error(video transcode failed, videoId{}, exitCode{}, videoId, exitCode); } } catch (IOException e) { log.error(start ffmpeg failed, e); } catch (InterruptedException e) { Thread.currentThread().interrupt(); log.error(transcode interrupted, e); } } }这里有几个容易踩的坑我单独说一下。第一Async注解要生效必须确保 Spring Boot 启动类上有EnableAsync否则方法会同步执行上传接口会被卡住很久。第二任务没有降级状态。上面的代码里转码失败只打了日志但数据库里没有状态字段前端无法感知这个视频是否转码完成。生产环境至少要维护一个video表包含 status 字段processing/success/failed并在转码结束回调 HTTP 接口更新状态。第三multipart上传默认大小限制是 1MB。演示时如果视频稍微大一点你会直接得到 500 错误或者文件为空。需要在配置文件里调大spring.servlet.multipart.max-file-size和spring.servlet.multipart.max-request-size。修改application.ymlspring: servlet: multipart: max-file-size: 2048MB max-request-size: 2048MB5.7 启动类package com.example.videodemo; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.scheduling.annotation.EnableAsync; SpringBootApplication EnableAsync public class VideoDemoApplication { public static void main(String[] args) { SpringApplication.run(VideoDemoApplication.class, args); } }6. Nginx 配置与前端播放器接入转码完成后你有了.m3u8索引和一堆.ts分片。下一步是把它们通过 HTTP 暴露出去并在浏览器端播放。6.1 Nginx 静态服务配置HLS 走的是普通 HTTPNginx 只需要把/data/video/hls目录映射成一个 URL 路径即可。新建/etc/nginx/conf.d/video.confserver { listen 8088; server_name localhost; location /hls/ { alias /data/video/hls/; add_header Cache-Control no-cache; types { application/vnd.apple.mpegurl m3u8; video/mp2t ts; } } }配置说明alias与location /hls/组合后http://localhost:8088/hls/{videoId}/index.m3u8会对应到/data/video/hls/{videoId}/index.m3u8。.m3u8的 MIME type 必须设置为application/vnd.apple.mpegurl很多播放器解析失败就是因为 Nginx 把它当成普通文本返回了。.ts分片的 MIME type 是video/mp2t。这里用add_header Cache-Control no-cache;避免播放器缓存旧的分片列表。点播场景其实也可以考虑打开缓存但前提是你的转码结果不可变即同一个 URL 永远不会更新内容。修改 Nginx 配置后需要执行nginx -t nginx -s reload然后可以在浏览器里直接访问http://localhost:8088/hls/{videoId}/index.m3u8如果浏览器直接下载了.m3u8文件说明静态服务已经通了。6.2 使用 hls.js 播放原生 HTML5video不支持 HLSSafari 除外所以 Chrome/Firefox 需要借助 hls.js 把 HLS 转成 MSE 支持的格式。在页面中引入 hls.js 的 CDN 版本即可。!DOCTYPE html html langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 title最新视频播放/title /head body video idvideo controls autoplay playsinline stylewidth: 100%;/video script srchttps://cdn.jsdelivr.net/npm/hls.js1.5.13/script script const video document.getElementById(video); const videoId 你的视频ID; const hlsUrl http://localhost:8088/hls/${videoId}/index.m3u8; if (Hls.isSupported()) { const hls new Hls({ maxBufferLength: 30, maxMaxBufferLength: 60 }); hls.loadSource(hlsUrl); hls.attachMedia(video); hls.on(Hls.Events.MANIFEST_PARSED, function () { video.play(); }); } else if (video.canPlayType(application/vnd.apple.mpegurl)) { // Safari 原生支持 HLS video.src hlsUrl; video.addEventListener(loadedmetadata, function () { video.play(); }); } /script /body /html这段代码兼容两类场景。Chrome/Firefox 等支持 MSE 的浏览器走 hls.jsSafari 原生支持 HLS直接把.m3u8地址赋给video.src即可。还有一个更省事的方案直接使用 video.js 播放器它内部集成了 HLS 支持但对 hls.js 的底层控制能力稍弱。如果你追求稳定可控我建议直接用 hls.js 原生video标签这样排查问题时思路更清晰。7. 运行结果与效果验证代码写完之后必须验证整条链路是否真正跑通了。我按下面的顺序做验证每一步都观察输出结果。7.1 验证上传与转码启动 Spring Boot 服务后用 curl 上传一个本地视频curl -X POST http://localhost:8080/api/video/upload \ -F file/data/video/origin/input.mp4如果一切正常接口会返回一个视频 ID。此时到/data/video/hls目录下查看是否生成了对应的目录ls -lh /data/video/hls/{videoId}/看到index.m3u8和segment_000.ts、segment_001.ts等分片文件说明转码成功。7.2 验证 Nginx 静态服务用 curl 请求 m3u8 地址检查返回内容curl -i http://localhost:8088/hls/{videoId}/index.m3u8预期响应中Content-Type是application/vnd.apple.mpegurlBody 内容包含#EXTM3U和分片文件名。再请求一个分片文件curl -I http://localhost:8088/hls/{videoId}/segment_000.ts预期Content-Type是video/mp2tContent-Length不为零。7.3 验证浏览器播放在浏览器里打开前端页面观察视频是否可以播放。如果在移动端模拟器下测试重点关注两点视频首帧出现的时间是否快拖动进度条后能否快速恢复播放。也可以用 VLC 播放器打开http://localhost:8088/hls/{videoId}/index.m3u8VLC 对 HLS 支持比较成熟如果 VLC 能播而浏览器不能播问题大概率在前端播放器集成如果 VLC 也播不了问题大概率在后端切片或 Nginx 配置。7.4 验证多码率切换如果你按前面的脚本生成了多码率版本建议打开 Chrome DevTools 的 Network 面板观察播放过程中网络请求的.m3u8文件和.ts文件。当你切换网络限速时可以明显看到播放器请求的文件码率发生变化。这是 HLS 自适应码率是否生效的最直观证据。8. 常见问题与排查思路视频链路的排查思路和普通 Web 接口不太一样因为它涉及进程、文件、协议、浏览器四层。我整理了频率最高的几个问题。问题现象可能原因排查方式解决方案上传接口返回 500且日志提示文件太大Spring Boot 文件上传限制未调大查看异常堆栈检查响应是否包含 MaxUploadSizeExceededException调大spring.servlet.multipart.max-file-size和max-request-size.m3u8请求返回 404Nginx alias 路径映射错误或目录不存在先确认文件在磁盘中的实际路径再对比 Nginx alias 拼接结果调整 Nginx location 的 alias 路径播放器报 CORS 错误前端页面和视频流不在同一个域名在 Nginx location 中添加add_header Access-Control-Allow-Origin *;根据业务域名限制允许来源不建议直接使用*视频可以播放但拖动非常不流畅切片时长过长或关键帧未对齐用 FFmpeg 打印日志检查关键帧间隔切片前设置-force_key_frames确保分片从关键帧开始移动端无法播放编码格式问题或缺少playsinline用 MediaInfo 工具检查输出视频编码格式统一转码为 H.264 AAC视频先黑屏几秒分片加载慢或 hls.js 缓冲策略太保守打开 Network 面板观察分片加载耗时调整 hls.js 的maxBufferLength或结合 CDN 加速分片分发转码脚本卡住不退出视频文件本身有损坏或 FFmpeg 等待输入手动执行 FFmpeg 命令加-y覆盖输出观察终端日志检查原始视频文件完整性多码率播放时始终选最高码率播放器策略问题或没有正确配置 BANDWIDTH抓取请求确认 master.m3u8 是否被加载检查 master.m3u8 的子索引路径和 BANDWIDTH 值其中有一个问题容易忽略HLS 索引和分片一旦生成普通用户理论上也可以直接拿到分片地址绕过业务系统的权限校验。如果你的视频需要付费观看或登录后才能看就必须考虑播放鉴权不能只在网页层面控制按钮显隐。9. 最佳实践与工程建议视频功能上手容易做好很难。下面这些建议来自实际项目中的常见教训。9.1 转码任务必须异步化和可重试不要在上传请求的线程里直接执行 FFmpeg否则一个 1GB 的视频会让 HTTP 请求挂起几分钟。把任务提交给消息队列转码服务消费任务转码结果更新到数据库。失败的任务要有重试机制重试次数超过阈值要进入人工处理队列。9.2 转码产物要有版本和清理策略HLS 切片会产生大量小文件。一个 10 分钟的视频6 秒切一个分片会产生 100 个左右的.ts文件。多码率版本会更多。如果业务上传量大磁盘空间会快速消耗。建议对转码产物设置生命周期超过一定时间且用户不再观看的转入冷存储或直接删除。9.3 不要忽略防盗链和签名鉴权HLS 分片本质上是静态文件只要拿到 URL 就能下载。如果视频有商业价值建议做以下几层防护限制 Referer生成带签名和过期时间的 URL分片文件名用不可猜测的随机字符串。在某些场景下甚至需要对分片内容做 AES-128 加密HLS 协议原生支持这种加密方式。FFmpeg 切片时可以传入-hls_key_info_file参数配合密钥文件但这部分内容涉及播放器端密钥分发属于更高阶的玩法建议在有明确安全需求时再引入。9.4 监控转码服务的 CPU 和磁盘FFmpeg 是 CPU 密集型工具多路并发转码可能直接打满服务器 CPU。生产环境最好把转码服务单独部署并与应用服务隔离。磁盘方面不仅原始视频占空间切片文件也占空间要建立磁盘使用率监控。9.5 考虑云厂商视频服务的边界本文讲解的是自建视频处理链路适合学习、内部系统或对成本敏感、数据要私有的场景。但如果你要做的是一个面向海量用户的高并发视频产品我更建议认真评估云厂商的云点播、媒体处理服务。原因不是自建做不了而是视频处理涉及存储、转码、CDN、加密、播放器适配多个环节每一环都有成本和运维门槛。自建可以但要有团队能长期维护。9.6 尽早定义视频状态模型建议在建表时就设计好字段video_id、title、origin_url、hls_url、status、duration、error_message、created_at。前端通过轮询或 WebSocket 感知转码状态。没有状态模型后续做失败重试、数据统计、视频审核都会非常痛苦。10. 总结与后续学习方向这篇文章从“最新视频来袭”这个看似简单的产品需求出发讲清楚了一条完整的视频在线播放链路视频编码与封装、HLS 协议原理、FFmpeg 转码与切片、Spring Boot 上传与异步任务、Nginx 静态分发、hls.js 前端播放。如果你能动手把这篇示例跑通等于掌握了一套可复用的视频发布基础框架。在这个基础上进一步学习的方向可以选这三条一是深入 HLS 协议理解加密、鉴权、时间戳对齐和低延迟模式二是研究转码服务的工程化比如引入 Docker 封装 FFmpeg、用消息队列削峰、通过任务调度实现失败重试三是探索播放器内部机制理解 MSE、缓冲区和自适应码率的调度策略。遇到视频播放问题不要先怀疑播放器按照“源文件编码 - 转码产物 - Nginx 响应 - 前端播放器”的顺序逐层排查基本能定位九成问题。把这套链路吃透之后即使以后用到云厂商的视频服务你也能更准确地判断他们的计费逻辑和参数配置而不是只看一行“开启智能转码”。
返回列表