返回文章列表
技术文章2026年7月12日·26 次阅读

这次问题不是一个 CSS 问题

做动态视频预览时,我原本以为事情很简单:列表里放一个视频缩略图,点击后交给 react-photo-view 放大,再在全屏里播放视频。

真正落到移动端,问题却一层接一层地出现:有些视频可以点开,有些点了没有反应;有时能打开,但视频不在屏幕中央;有时打开后是一片黑;即使能播放,收起时也没有回到原缩略图的动画。

我先后尝试过延后播放、等待元数据、把 Photo View 的动画属性直接传给 <video>、按视频宽高重新挂载预览节点。这些尝试各自缓解了一部分现象,却没有让体验稳定下来。

最后真正解决问题的思路很简单:不要让视频承担图片查看器的动画职责。让封面图片负责展开和收起,让视频只在动画稳定后覆盖到图片上播放。

为什么直接把 video 放进 Photo View 不稳定

自定义媒体不是原生图片路径

react-photo-view 对普通图片有一套完整的能力:它知道图片的原始尺寸、缩略图的位置和 object-fit,因此可以计算出从缩略图到全屏的变换。

但自定义 render 视频节点时,宽高通常要等 loadedmetadata 才能拿到。问题在于图库的媒体注册不会可靠地随着自定义节点的异步尺寸再次初始化。结果就是:节点看上去已经更新了,点击注册仍可能保留旧状态。

// 看起来合理,但视频宽高稍后才到,注册时机已经错过。
<PhotoView width={videoWidth} height={videoHeight} render={() => <video src={src} />}>
  <video src={src} />
</PhotoView>

这也是“有时能点开、有时不能”的来源。它不是单纯的事件冒泡问题,而是媒体注册与元数据到达时机发生了竞争。

视频没有天然的动画首帧

即使视频成功打开,全屏层里也是一个新建的 <video>。它需要重新请求、解封装和解码首帧;而移动浏览器还可能拦截延迟触发的有声自动播放。

于是图片查看器已经把黑色遮罩拉起来了,视频还没有可显示的帧,用户看到的就是黑屏。

object-cover 的起点也会丢失

九宫格中的视频通常使用 object-cover 裁剪。原生图片预览可以根据图片元素计算裁剪后的起点;直接用视频作为自定义根节点时,这段图片专用的换算不再可靠。即使最终尺寸对了,动画也可能从错误的位置开始。

最终架构:封面负责动画,视频负责播放

最终把普通视频拆成两个职责明确的层:

  1. 列表和 PhotoView 始终使用一张封面图。
  2. 封面通过原生 PhotoView src 完成展开、居中、缩放和收起。
  3. 视频提前加载,但保持透明。
  4. 等图片动画稳定、视频达到 canplay 后,读取原生 .PhotoView__Photo 的实际边界,把视频固定覆盖到完全相同的位置并淡入。
  5. 关闭时先暂停并隐藏视频,再让底下的封面执行原生收起动画。
<PhotoView src={posterUrl}>
  <Image
    src={posterUrl}
    fill
    className="object-cover cursor-zoom-in"
    unoptimized
  />
</PhotoView>

视频覆盖层不参与 Photo View 的尺寸计算:

const rect = getActivePhotoRect(posterUrl)
 
<video
  src={videoUrl}
  poster={posterUrl}
  preload="auto"
  playsInline
  controls
  style={{
    position: "fixed",
    left: rect.left,
    top: rect.top,
    width: rect.width,
    height: rect.height,
    objectFit: "contain",
  }}
/>

这里有一个小但很重要的细节:不要只用固定的 400ms 定时器认为动画结束。实现里会持续读取图片实际边界,等连续几帧的边界稳定后再显示视频。这样打开、滑动切换图片和移动端方向变化都能得到正确位置。

新视频:上传时生成并保存封面

如果每次查看都让浏览器从视频里截帧,首开成本会很高。因此新上传的视频在后台发布页选择完成后,就会生成封面:

  • 等待真实元数据;
  • 取视频约 5% 处、且不超过 0.5 秒的可用帧;
  • 限制封面最长边为 1280 像素;
  • 优先导出 WebP,不能导出时回退 JPEG;
  • 封面文件通过 thumbnailFile:<clientId> 和视频绑定上传。
const poster = await generateVideoPoster(file)
const thumbnailFile = posterBlobToFile(poster.blob, file.name)
 
formData.append(`mediaFile:${id}`, file)
formData.append(`thumbnailFile:${id}`, thumbnailFile)

服务端不信任浏览器生成的图片,仍会用 Sharp 重新读取、旋转校正、缩放并转成 WebP,然后将地址写入既有的 thumbnails[]

const thumbnailBuffer = await sharp(uploadedPoster)
  .rotate()
  .resize({ width: 1280, height: 1280, fit: "inside", withoutEnlargement: true })
  .webp({ quality: 82 })
  .toBuffer()

这样不需要新增数据库字段:

  • 图片的 thumbnails[index] 仍然是图片缩略图;
  • 视频的 thumbnails[index] 变成视频封面;
  • images[]thumbnails[]livePhotoVideos[] 继续按索引对齐。

创建、编辑、重排和删除都沿用同一套索引规则。封面提取失败不会阻止视频发布,只是该视频暂时没有持久封面。

旧视频:运行时补封面,但必须有降级路径

旧数据没有封面,不能因此让旧视频失去可用性。当前策略是在动态卡片进入视口附近后,利用浏览器空闲时间预载旧视频并生成运行时封面。

这里要考虑对象存储的跨域边界:视频元素可以播放,不代表画布可以导出这一帧。COS 没有合适 CORS 响应时,canvas.toBlob() 会失败。

所以运行时流程有两级结果:

  1. 截帧成功:使用真实首帧生成 Blob URL 封面。
  2. 截帧受跨域限制:用视频真实宽高生成一张中性占位封面,仍然让原生动画和播放流程成立。

若视频连元数据也无法读取,前台不会猜测比例,而是保留明确的“不支持此视频”提示。

用户在旧视频封面尚未准备好时点击,也不再是无响应:界面先显示加载状态,记录本次打开意图,封面生成完毕后自动触发一次图片预览。

播放、BGM 与关闭顺序

视频覆盖层只有在动画稳定且 canplay 后才尝试播放。默认尝试带声音自动播放;若浏览器拦截,就继续显示封面和居中的播放按钮,让用户主动开始播放。

播放开始时暂停 BGM;暂停、结束、切换媒体或关闭预览时恢复 BGM。关闭路径统一遵循同一个顺序:

暂停视频
  -> 隐藏视频覆盖层
  -> 恢复 BGM
  -> 让封面执行 Photo View 原生收起动画

遮罩、Escape、下拉手势和固定关闭按钮都会进入这条清理路径。这样不会出现点击关闭反而触发播放,或视频还在屏幕上而图片已经开始缩小的错位。

验证结果与经验

这次用真实 Chromium 分别验证了桌面和 390×844 移动视口。旧视频能够生成运行时封面;展开过程中图片有连续的中间尺寸;最终视频覆盖层和封面边界误差不足 1 像素,两个视口的中心偏差均为 0。视频达到 readyState = 4 后才显示和播放,关闭时则回到封面再收起。

这次最值得记住的不是某个 CSS 参数,而是媒体预览的职责划分:

擅长几何动画的组件负责几何动画,擅长解码播放的组件负责解码播放。不要让一个刚创建的视频节点,同时承担缩略图定位、过渡动画、首帧加载和声音策略。

把封面当成稳定的视觉锚点后,移动端的黑屏、定位和动画问题才终于被一次性收住。