PHP竖屏短视频播放技术实践:字段设计、ffmpeg压缩与签名鉴权
简介面向需要搭建竖屏短视频播放功能的PHP开发者这份源码以完整可运行的Web代码库形式覆盖从用户上传视频、服务端处理到前端竖屏播放的主要流程适合用于课程设计、功能原型或短视频方向入门学习。包体共13个文件、约802KB其中2个PHP脚本负责上传、鉴权与动态播放地址生成HTML/CSS/JS文件构成前端播放器壳体配套JSON和PNG素材用于配置与界面展示整体目录结构紧凑方便快速部署和对照阅读。源码在实现层面展示了FFmpeg视频转码压缩、缩略图生成、数据库元信息管理、播放器交互控制以及动态播放URL防直接访问等关键思路可帮助初学者理解音视频应用从后端到前端的完整协作方式。资料还直接给出了可复用的播放器页面和基础样式适合在已有PHP环境中直接调试并扩展到真实项目。已有326人学习/下载对于想用低成本方式接触短视频开发的读者是一份轻量而具体的参考示例。1. 竖屏短视频里 PHP 的角色不是播放是分发同一个视频地址在 PC 浏览器里能正常播放换成微信内置浏览器就黑屏iOS 上循环得好好的安卓上播完就卡住画面。这类问题在竖屏短视频场景里被无限放大因为所有内容都是 9:16 的满屏画面任何一层适配出错都会直接影响完播率。PHP 短视频竖屏播放源码听起来像是一套前端播放器的事实际落地时PHP 端要解决的是视频的生成、存储、鉴权、防爬和列表供给播放器只是最末端的一个消费方。本文按「字段设计 → 视频压缩 → 播放管理 → 鉴权分发」的顺序把 PHP 技术在竖屏播放链路里真正有价值的部分讲透适合正在做 H5 短视频信息流、也在纠结播放体验和内容安全的人。2. 竖屏视频的字段设计与列表接口2.1 视频表至少要有分辨率、时长和状态三个字段多数人建视频表只存video_url和cover_url这是第一个坑。竖屏播放要求前端准确知道视频的宽高比例以便决定容器尺寸和封面裁剪方式。表结构里少了width、height、duration三个字段前端就只能等视频loadedmetadata之后才能拿到尺寸首屏会闪一下黑边或者错误比例。CREATE TABLE videos ( id INT UNSIGNED NOT NULL AUTO_INCREMENT, title VARCHAR(120) NOT NULL DEFAULT , video_url VARCHAR(500) NOT NULL DEFAULT , cover_url VARCHAR(500) NOT NULL DEFAULT , width SMALLINT UNSIGNED NOT NULL DEFAULT 1080, height SMALLINT UNSIGNED NOT NULL DEFAULT 1920, duration FLOAT UNSIGNED NOT NULL DEFAULT 0, status TINYINT UNSIGNED NOT NULL DEFAULT 1 COMMENT 1正常 0下架, created_at INT UNSIGNED NOT NULL DEFAULT 0, PRIMARY KEY (id), KEY idx_status_id (status, id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;width和height由转码服务在视频处理完成后回写PHP 接口直接读取即可。duration用于前端展示进度条和预估加载体积不必等浏览器解析。状态字段用于内容下架时快速隐藏不至于让已签发的 URL 继续生效。这里有一个设计细节主索引用(status, id)联合索引而不是单独status索引是因为竖屏信息流是倒序拉取列表WHERE status 1 ORDER BY id DESC能直接命中这个复合索引。2.2 列表接口返回的是签名 URL而不是原始地址竖屏信息流的列表接口不会一次返回全部数据而是分页拉取每次 10 条左右。分页方式建议用游标分页不用LIMIT offset, limit。偏移分页在视频持续上新时会重复返回数据用户滑着滑着就看到了上一条游标分页用上一批最后一条的 ID 作为下次请求的起点避免这个问题。public function feeds(int $lastId 0, int $limit 10): array { $condition [status 1]; if ($lastId 0) { $condition[id ] $lastId; } $list Db::name(videos) -where($condition) -order(id desc) -limit($limit) -select(); foreach ($list as $item) { $item[video_url_signed] UrlSign::make($item[video_url], 600); unset($item[video_url]); } return [code 0, data $list]; }核心逻辑有三个。其一unset($item[video_url])保证原始地址永远不进接口响应体客户端只能拿到带签名的临时地址从源头减少视频被直接拿走二次分发的风险。其二签名 URL 的过期时间设为 600 秒短视频单次播放时长通常在 15 到 60 秒10 分钟的有效期足够播完本条和预加载下一条过期后再去拉一次列表拿新签名即可。其三返回数组使用「code data」结构data里每一条的字段名固定前端不需要关注 PHP 内部数据是对象还是数组接口稳定性的价值在这里。2.3 前端播放器的竖屏适配参数接口给到前端后播放器需要用video标签自行渲染。竖屏适配的关键不是 CSS 里的宽高而是playsinline系列属性。iOS Safari 默认把视频当全屏播放微信内置浏览器基于 X5 内核又有独立的播放策略不加这几个属性就控不住播放形式。video idplayer classplayer playsinline webkit-playsinline x5-playsinline x5-video-player-typeh5 preloadauto muted /video.player { position: fixed; inset: 0; width: 100vw; height: 100vh; object-fit: cover; }playsinline让视口内的视频内联播放webkit-playsinline覆盖 iOS 10 以下的老版本x5-playsinline和x5-video-player-typeh5则是告诉腾讯 X5 内核用 H5 方式渲染不要弹层。object-fit: cover在视频比例与屏幕比例不一致时裁剪边缘填满容器竖屏素材本身是 9:16与手机屏幕比例接近裁剪量很小。muted属性保证首帧能自动播放短视频信息流基本都是静音起播、用户点击后出声这是各家平台的通用交互。3. 视频压缩是 PHP 队列任务不能在请求进程里做3.1 PHP 调 ffmpeg 的三种错误姿势竖屏短视频上传后必须压缩转码否则原始素材动辄几十 MB用户在移动网络下拉不动。很多 PHP 开发者的第一反应是直接用exec()调 ffmpeg。这个做法在请求进程里执行PHP-FPM 的max_execution_time默认只有 30 秒一个 30 秒的视频转码可能要跑 1 到 3 分钟进程被拖死请求直接超时。第二种错误姿势是把max_execution_time调到 300 秒这会耗尽 PHP-FPM 的进程池高并发下其他请求全部排队。第三种错误姿势是用proc_open异步调 ffmpeg 后不管输出视频转完了没有回调、没有失败重试文件残缺也不可知。正确做法是写一个 CLI 脚本负责转码通过队列系统调度。PHP-FPM 请求进程只做一件事——把上传的视频信息丢进队列然后立刻返回。CLI 进程常驻后台从队列取任务执行失败自动重试。这样 PHP-FPM 的进程池完全不被转码任务占用耗时和并发彻底解耦。3.2 ffmpeg 压缩竖屏视频的参数组合视频压缩参数按「尽量不损失画质、移动端硬解兼容、流式加载」三个目标来配置。竖屏视频的典型输出是 1080x1920上传素材超过这个尺寸就降下来低于这个尺寸则不放大避免模糊。ffmpeg -i input.mp4 \ -vf scalemin(1080,iw):min(1920,ih):force_original_aspect_ratiodecrease \ -c:v libx264 \ -profile:v main \ -level 4.0 \ -crf 23 \ -maxrate 2M \ -bufsize 4M \ -c:a aac -b:a 128k -ac 2 \ -movflags faststart \ -y output.mp4参数值作用与坑点-profile:v mainmain兼容绝大多数安卓机型硬解用 high 档会有一批老旧 SoC 无法硬解直接黑屏-level 4.04.0限制编码复杂度配合 profile 保证手机芯片能解码-crf 2318~2823 是画质与体积的平衡点18 画质好但体积翻倍28 体积小但暗部出现色块-maxrate 2M2M~4M限制视频峰值码率移动网络下防止缓冲卡顿-movflags faststart必有把 moov 元数据移到文件头视频无需完整下载就能开始播放force_original_aspect_ratiodecrease是最容易漏的参数它保证在缩放时保持原比例不会为了适配 1080x1920 而拉伸画面。如果原视频是 9:16 但分辨率是 720x1280输出仍然是 720x1280:decrease只往下不往上。3.3 用 Redis 队列接管转码任务队列选型常见的是 Redis 列表结构PHP 的RPUSH入队CLI 端的BLPOP阻塞取任务。Redis 自带列表天然支持先进先出不需要额外装 RabbitMQ中小规模视频站点足够用。public function commit(int $videoId): void { Redis::rpush(queue:transcode, json_encode([ video_id $videoId, attempts 0, ])); }public function handle(Job $job, array $data): void { $videoId (int) $data[video_id]; try { $output VideoTranscode::run($videoId); if ($output[status] ! 0) { $data[attempts] $data[attempts] 1; if ($data[attempts] 3) { VideoModel::updateStatus($videoId, failed); return; } $job-release(30); return; } VideoModel::updateRes($videoId, $output[width], $output[height], $output[duration]); VideoModel::updateStatus($videoId, ready); } catch (\Throwable $e) { Log::error(transcode_failed, [video_id $videoId, err $e-getMessage()]); $job-fail($e); } }release(30)是让失败的转码任务 30 秒后重新排队attempts记录重试次数超过 3 次标记为失败人工介入。任务失败的最大原因是上传的视频文件损坏或编码格式不被 ffmpeg 识别这类失败重试多少次都没有意义所以attempts的阈值设成 3 即可。CLI 进程可以按 CPU 核数开多个 worker每个 worker 独立BLPOP并发转码互不干扰CPU 打满时自动排队。4. 列表滑动、预加载与播放状态管理4.1 首屏 3 条预加载滑动到视口才播竖屏信息流与普通视频列表最大的区别在于一次只播一个而且滑到就播。前端代码需要在滚动结束后判断当前哪些视频卡片进入可视区域优先让进入可视区超过 60% 的那一条开始播放其余的全部暂停。常见做法是给每个卡片容器加>const observer new IntersectionObserver((entries) { for (const entry of entries) { if (entry.isIntersecting entry.intersectionRatio 0.6) { player.src entry.target.dataset.src; player.play(); currentId entry.target.dataset.id; } else { if (entry.target.dataset.id currentId) { player.pause(); } } } }, { threshold: [0.4, 0.6] }); document.querySelectorAll(.video-card).forEach((card) { observer.observe(card); });threshold: [0.4, 0.6]用来控制触发灵敏度。阈值设得太低会在用户刚滑动手指时触发播放导致视频反复加载0.6 在手指停留时才会真正起播。entry.target.dataset.src是预先写在 DOM 上的签名 URL滚动到附近时才赋值给当前播放器避免所有视频同时发起网络请求。注意这里只保留了一个全局player实例切卡时复用src替换而不是为每个卡片创建新的video元素浏览器对同时存在的 video 实例有资源限制实例多了会出现无声、不加载的问题。4.2 预加载下一条的时机判断信息流体验的关键是滑动后立刻出画面预加载时机选在「当前视频播放开始后的 1 秒」。此时用户大概率会继续滑动且当前视频的带宽消耗已趋缓。实现方式是用new Audio()或new Image()提前请求下一条视频的前 1MB 到 2MB 数据。原生video标签没有直接的预加载 API通常做法是在页面隐藏一个link relprefetch或者直接在 JS 里创建辅助 video 元素设置preloadauto。function preloadNext(nextId) { const nextCard document.querySelector(.video-card[data-id${nextId}]); if (!nextCard || !nextCard.dataset.src) return; const probe document.createElement(video); probe.preload auto; probe.src nextCard.dataset.src; probe.muted true; }逻辑上要注意预加载的辅助 video 元素用完要清空src并丢弃否则在 Android 某些 WebView 里多个 video 元素同时加载会争夺解码器出现主播放器卡顿。预加载只触发一条滑过两条以上时不追加载因为用户快速滑动时预加载的内容已经过了播放位置白白浪费流量。4.3 续播进度与音量记忆要落到 PHP播放状态管理包含两个维度单次会话内的续播以及跨会话的播放进度记忆。会话内续播在用户滑走再滑回时要从上次的播放位置继续。音量记忆则是把用户调节过的音量大小存下来下次进入页面时直接恢复而不是每次都用系统默认值。状态项存储位置过期策略当前播放位置localStorage按视频 ID 分开存覆盖即更新音量大小localStorage永久保存上限 1.0播放进度上传Redis / 数据库服务器端保存最近一次进度进度上传接口要控制频率不能每 100 毫秒打一个请求public function progress(int $videoId, float $percent): void { $key sprintf(play:%d:%s, $videoId, $this-clientId); $last (float) Cache::get($key); if (abs($percent - $last) 5) { return; } Cache::set($key, $percent, 3600); }abs($percent - $last) 5的意义是变化小于 5% 时不上报。视频播放进度是周期性回调20 秒的视频每 0.25 秒触发一次timeupdate如果每次都请求后端一个用户一分钟产生 240 个请求1000 人在线时后端压力很大。阈值过滤后每个视频最多上报十几条数据完整度足够用于「继续观看」和后台播放分析。5. 用签名 URL 做播放鉴权与防盗链5.1 签名 URL 的生成规则前面接口返回的video_url_signed是真正生效的地址。签名机制的核心是原始视频存储在私有目录CDN 或 Nginx 层校验 URL 中的签名与过期时间校验通过才返回视频流。PHP 端的实现分为生成和验证两部分。class UrlSign { private static string $secret your-32-char-secret; public static function make(string $url, int $ttl): string { $expire time() $ttl; $uri parse_url($url, PHP_URL_PATH); $sign md5(sprintf(%s:%d:%s, $uri, $expire, self::$secret)); return sprintf(%s?expire%dsign%s, $url, $expire, $sign); } public static function verify(string $uri, int $expire, string $sign): bool { if (time() $expire) { return false; } $expected md5(sprintf(%s:%d:%s, $uri, $expire, self::$secret)); return hash_equals($expected, $sign); } }hash_equals是防止时序攻击的关键。直接用比较签名时攻击者可以通过测量响应时间逐个猜测签名位hash_equals恒定时间比较排除了这个侧信道风险。参数里没有把video_id放进签名原文是因为播放地址本身已经包含了文件路径签名只绑定「路径 过期时间 密钥」逻辑更干净。$secret只存放在 PHP 服务端不能出现在前端代码、Nginx 配置模板或日志里。5.2 Nginx 层拦截而不是 PHP 层拦截签名验证放在 PHP 层有个明显的缺陷PHP 进程要处理完请求才能返回拒绝结果恶意请求打到 PHP-FPM 依然消耗进程资源。正确做法是在 Nginx 层用if指令或 OpenResty 的access_by_lua校验sign和expire校验失败直接返回 403。这里给出一个 Nginx 配置片段location ~ ^/protected/ { if ($arg_expire ) { return 403; } if ($time_local $arg_expire) { return 403; } ... }Nginx 层的$time_local是字符串格式实际项目中更适合的做法是使用 OpenResty 的ngx.now()做时间比较或者在 PHP 里生成带签名 URL 时把一个带签名的完整查询串交给 Nginx 的第三方模块解析。更轻量的方案是直接用 Nginx 的secure_link_module生成方式类似但验证逻辑由 Nginx 原生的secure_link_uri完成性能损耗极低。不过这个模块需要确认编译时已加载不是默认启用。5.3 验证签名链路的三条命令部署后需要分别验证正常播放、过期失效、篡改拒绝三条链路。写一个简单的 PHP 测试脚本生成签名 URL$url UrlSign::make(/protected/videos/1001.mp4, 60); echo http://your-cdn-host{$url}\n;拿到输出后用 curl 测试。注意返回 HTTP 200 还是 403# 正常访问短期内返回 200 curl -I http://your-cdn-host/protected/videos/1001.mp4?expire1750000000signabcdef... # 篡改签名把 sign 改成任意值应返回 403 curl -I http://your-cdn-host/protected/videos/1001.mp4?expire1750000000sign0000000000 # 过期访问把 expire 改成过去的 Unix 时间应返回 403 curl -I http://your-cdn-host/protected/videos/1001.mp4?expire1600000000signabcdef...实际踩坑最多的是第一条很多人在浏览器里直接粘贴签名 URL发现视频能打开但抓包看 response 是 200 且时间戳已经过期。原因是浏览器缓存了视频文件第二次访问根本没到服务器。排错时用curl -H Cache-Control: no-cache绕过缓存确认服务端的真实响应。防盗链配置完成后再检查一遍$secret有没有被打印到日志以及接口返回的video_url_signed是否在unset($item[video_url])之后才进入响应体这两处是签名方案最容易泄密的漏洞。本文还有配套的精品资源点击获取