前端视频封面截取黑帧问题剖析

522 阅读8分钟

一、黑帧现象

在 Web 开发中,我们经常需要从视频中截取封面图。典型实现流程如下:

const video = document.createElement('video');
video.src = 'video.mp4';
video.currentTime = 1; // 定位到第1秒

video.addEventListener('seeked', () => {
  const canvas = document.createElement('canvas');
  const ctx = canvas.getContext('2d');
  ctx.drawImage(video, 0, 0);
  // 获取封面图...
});

然而,有时截取到的图像却是全黑画面,尽管在页面上播放时该时间点有正常画面,并且不管你使用 seek,onloadmeta,还是 canplay 等等,该现象依然会偶现。

二、视频编码 GOP

要理解为什么视频会出现黑帧,必须了解视频编码的基本原理:GOP

GOP(Group of Pictures)代表一组连续的帧序列,该序列中有三种类型的帧:

  • I 帧:完整保存画面信息的全图像帧,可独立解码
  • P 帧:只存储与前帧差异的帧,依赖其他帧才能解码,属于预测帧
  • B 帧:存储前后差异的帧,也属于预测帧。

GOP在编码的时候可以指定一个GOP的长度,比如下面就是一个长度为8的:

image.png

一个GOP的结构是:以 I 帧为开头,B 帧和 P 帧为后续填充,举个直观的例子:

  • I 帧:你拍了一张全景照片
  • P 帧:你记录了“人物往左移动5像素”
  • B 帧:你记录了“人物正从左边走向右边的位置”

所以 I -> P -> B 的序列不是视觉上一帧一帧的画面,而是只保存了变化数据,需要参考帧+变化信息来重建真实画面,如果记录的信息如下:

I  →  P   →   B   →   B   →   P   →   I
t=0    t=1     t=2     t=3     t=4     t=5

那么你在 t=2 看到的画面,就是根据 t=1 的 P 帧和 t=4 的 P 帧推测重建出来的,而 t=1 也是 P 帧,所以 t=1 要先还原 t=0 的画面,而 t=4 也只能参考 t=0 的 I帧,因为 P 帧和 B 帧只能参考当前GOP范围内的帧。

⚠️ 注意:视频总帧数不等于 GOP 的长度。

视频的总帧数 = 帧率 x 时长,一个 10 秒的视频,帧率为30fps,总帧数就是 300 帧,假设 GOP 长度为 30,那么:

每 30 帧为一组(1 个 GOP),一共有 10 个 GOP。

帧编号:   0   1   2   3   4  ...  29   30   31  ...  59   60 ...
帧类型:   I   B   B   P   B  ...  B    I    B  ...  B    I ...
GOP编号: [GOP1]             [GOP2]             [GOP3]

以下图为例,多个画面展示的情况下,可能的GOP排列就是:

image.png

那么问题来了,如果截图截到了 P 帧或者 B 帧,而画面渲染需要找到对应的 I 帧 + 差异数据,而同时 canvas 做了绘制操作,黑帧是不是就出现了!

三、问题剖析和解决方案

当进入 video 的加载回调函数后,渲染其实就已经在找 I 帧并且计算渲染绘制了,但是目标真可能尚未完成解码,这个时候去使用 canvas 来 drawImage,获取的就是中间的解码状态,也就是黑帧。

其本质就是浏览器的 video 标签是用 GPU / Decode 直接渲染到画布上,不一定会把完整图像放到 CPU 供 canvas.drawImage() 使用,特别是在 GPU 解码路径中,帧画面在显存里,而不是在 CPU 能读取的位置,截图失败就会是黑的或者未渲染。

一句话:浏览器能播放不等于浏览器能把当前帧绘制到 canvas。

image.png

当然,这只是其中一种黑帧的原因,我们来整理一下 seek 成功但是黑帧的场景有哪些:

  • seek 到了 P / B 帧,但相关 I 帧还没来得及 decode

  • 浏览器优化策略导致 readyState = 4,但是画面内容没有绘制出来

  • 视频源使用了较长的 GOP,导致需要解析很多帧才能还原

  • 网络慢,视频的缓冲流还没到那一帧

  • 视频用了高级编码 HEVC / VP9,解码器延迟高

  • ...

当然,最好的解决方式一定是在服务端做视频的解析(ffmpeg等),并且能和OSS高效配合的情况下(不用服务端再下载一次),而在纯前端能做的也只是如何把问题出现的比例降低,现在我们来针对不同的问题给出不同的解答方式:

  1. 通过读取 canvas 像素的方式来判定当前是否是黑帧
function isValidFrame(ctx, width, height) {
  const imageData = ctx.getImageData(0, 0, width, height);
  const data = imageData.data;
  
  // 1. 检查纯黑帧
  let darkPixels = 0;
  // 2. 检查纯白帧
  let lightPixels = 0;
  
  const darkThreshold = 20;   // RGB值小于20视为暗色
  const lightThreshold = 235; // RGB值大于235视为亮色
  
  // 采样检测(每4个像素检测1个)
  for (let i = 0; i < data.length; i += 16) {
    const r = data[i];
    const g = data[i + 1];
    const b = data[i + 2];
    
    // 纯黑检测
    if (r < darkThreshold && g < darkThreshold && b < darkThreshold) {
      darkPixels++;
    }
    // 纯白检测
    else if (r > lightThreshold && g > lightThreshold && b > lightThreshold) {
      lightPixels++;
    }
  }
  
  const totalSampled = data.length / 16;
  const darkRatio = darkPixels / totalSampled;
  const lightRatio = lightPixels / totalSampled;
  
  // 如果超过90%像素是纯黑或纯白,则认为无效
  return darkRatio < 0.9 && lightRatio < 0.9;
}
  1. 引入重复截图重试机制(尝试3次)
const captureTimes = [0.1, 0.5, 1, 2];
let coverUrl = '';
let lastValidBlackBlob: Blob | null = null;

for (const time of captureTimes) {
  try {
    await seekToTime(video, time);
    if (!ctx) throw new Error('Canvas context lost');

    ctx.drawImage(video, 0, 0);
    let blob = await new Promise<Blob | null>((resolve) =>
      canvas.toBlob(resolve, 'image/jpeg', 0.8),
    );

    if (!blob) {
      const dataUrl = canvas.toDataURL('image/jpeg', 0.8);
      blob = await (await fetch(dataUrl)).blob();
    }

    if (!blob) continue;

    if (isValidFrame(ctx, canvas.width, canvas.height)) {
      console.warn(`Black frame at ${time}s`);
      lastValidBlackBlob = blob;
      continue;
    }

    const file = new File([blob], 'cover.jpg', { type: 'image/jpeg' });
    coverUrl = await uploadFile(file);
    break;
  } catch (err) {
    console.warn(`Seek/capture failed at ${time}s`, err);
    continue;
  }
}
  1. Seek 函数可以用 requestVideoAnimationFrame 来保证视频帧正常获取,非兼容情况下可以使用多个 requestAnimationFrame 或者setTimeout来尽量让当前帧进入渲染管线(但是要注意 requestVideoAnimationFrame 的调用时机,必须播放,或者有新的帧才会执行到它的回调函数)。
  const seekToTime = async (video: HTMLVideoElement, time: number): Promise<void> => {
    video.currentTime = time;

    return new Promise<void>((resolve, reject) => {
      let timeoutId: NodeJS.Timeout;

      const onSeeked = async () => {
        try {
          clearTimeout(timeoutId);
          video.removeEventListener('seeked', onSeeked);
          video.removeEventListener('error', onError);

          // 等待视频帧准备完成
          await waitForFrame(video);
          resolve();
        } catch (err) {
          reject(err);
        }
      };

      const onError = () => {
        clearTimeout(timeoutId);
        video.removeEventListener('seeked', onSeeked);
        video.removeEventListener('error', onError);
        reject(new Error(`Video error during seek to ${time}`));
      };

      video.addEventListener('seeked', onSeeked, { once: true });
      video.addEventListener('error', onError, { once: true });

      // 超时处理
      timeoutId = setTimeout(() => {
        video.removeEventListener('seeked', onSeeked);
        video.removeEventListener('error', onError);
        reject(new Error(`Seek timeout at ${time}`));
      }, 10000);
    });
  };
  
  
   const requestVideoFrame = (
    video: HTMLVideoElement,
    callback: (now: number, metadata?: any) => void,
  ): number | null => {
    if ('requestVideoFrameCallback' in video) {
      return (video as any).requestVideoFrameCallback(callback);
    }
    // 降级到 requestAnimationFrame
    return requestAnimationFrame(() => callback(performance.now()));
  };

  const cancelVideoFrame = (video: HTMLVideoElement, id: number): void => {
    if ('cancelVideoFrameCallback' in video) {
      (video as any).cancelVideoFrameCallback(id);
    } else {
      cancelAnimationFrame(id);
    }
  };

  // 使用 requestVideoFrameCallback 等待帧准备
  const waitForFrame = (video: HTMLVideoElement): Promise<void> => {
    return new Promise((resolve, reject) => {
      let frameId: number | null = null;
      let timeoutId: NodeJS.Timeout;

      const cleanup = () => {
        if (frameId !== null) {
          cancelVideoFrame(video, frameId);
        }
        if (timeoutId) {
          clearTimeout(timeoutId);
        }
      };

      const onFrame = (now: number, metadata?: any) => {
        cleanup();
        resolve();
      };

      const onTimeout = () => {
        cleanup();
        reject(new Error('Frame timeout'));
      };

      frameId = requestVideoFrame(video, onFrame);
      timeoutId = setTimeout(onTimeout, 5000); // 5秒超时
    });
  };
  1. 使用 offscreenCanvas + WebCodecs,但是它有一定的兼容性问题,本质上和 <video> + canvas.drawImage() 差别不是很大,只是你可以有跳帧操作的能力了。
  2. 使用 video.play(),等50毫秒再video.pause(),此时再截帧(经验兜底而已)。

下面给出一个比较完整的例子:

export const getVideoCoverUrl = async (source: string | File): Promise<string> => {
  const video = document.createElement('video');
  video.preload = 'metadata';
  video.muted = true;
  video.playsInline = true;
  video.crossOrigin = 'anonymous';
  video.src =
    typeof videoSource === 'string' ? videoSource : URL.createObjectURL(videoSource);

  const canvas = document.createElement('canvas');
  let ctx = canvas.getContext('2d');

  const cleanUp = () => {
    video.remove();
    if (typeof videoSource !== 'string') {
      URL.revokeObjectURL(video.src);
    }
    canvas.width = 0;
    canvas.height = 0;
    ctx = null;
  };

  const isValidFrame = (ctx: CanvasRenderingContext2D, w: number, h: number): boolean => {
    const frame = ctx.getImageData(0, 0, w, h);
    const pixels = frame.data;

    // 采样检测(最多检测1000个像素点)
    const sampleSize = Math.min(1000, pixels.length / 4);

    let darkPixels = 0; // 暗像素计数
    let lightPixels = 0; // 亮像素计数
    let colorVariance = 0; // 颜色方差(用于检测图像复杂度)
    const darkThreshold = 10; // 暗像素阈值
    const lightThreshold = 245; // 亮像素阈值

    for (let i = 0; i < sampleSize; i++) {
      const pixelIndex = Math.floor(Math.random() * (pixels.length / 4)) * 4;
      const r = pixels[pixelIndex];
      const g = pixels[pixelIndex + 1];
      const b = pixels[pixelIndex + 2];

      // 计算颜色方差(RGB与平均值的差异)
      const avg = (r + g + b) / 3;
      colorVariance += Math.abs(r - avg) + Math.abs(g - avg) + Math.abs(b - avg);

      // 统计暗像素和亮像素
      if (r < darkThreshold && g < darkThreshold && b < darkThreshold) {
        darkPixels++;
      } else if (r > lightThreshold && g > lightThreshold && b > lightThreshold) {
        lightPixels++;
      }
    }

    // 计算比例和平均方差
    const avgVariance = colorVariance / sampleSize;
    const darkRatio = darkPixels / sampleSize;
    const lightRatio = lightPixels / sampleSize;

    // 判断有效性:暗像素比例<98% AND 亮像素比例<98% AND 有足够的颜色变化
    return darkRatio < 0.98 && lightRatio < 0.98 && avgVariance > 5;
  };

  const captureFrame = (): Promise<Blob | null> => {
    return new Promise((resolve, reject) => {
      let frameId: number | null = null;
      let timeoutId: NodeJS.Timeout;
      let startTime = Date.now();
      const maxDuration = 5000;
      const minCheckTime = 500;

      const cleanup = () => {
        if (frameId !== null) {
          if ('cancelVideoFrameCallback' in video) {
            (video as any).cancelVideoFrameCallback(frameId);
          } else {
            cancelAnimationFrame(frameId);
          }
        }
        if (timeoutId) {
          clearTimeout(timeoutId);
        }
        video.pause();
      };

      // 首先尝试截取静态的第一帧
      const captureStaticFirstFrame = () => {
        if (!ctx) {
          return false;
        }

        try {
          // 确保视频在第一帧位置
          video.currentTime = 0;

          // 等待一小段时间确保帧更新
          setTimeout(() => {
            // 检查画布上下文是否仍然有效
            if (!ctx) {
              startPlaybackCapture();
              return;
            }

            ctx.clearRect(0, 0, canvas.width, canvas.height);
            ctx.drawImage(video, 0, 0);

            // 检查是否为有效帧
            if (isValidFrame(ctx, canvas.width, canvas.height)) {
              cleanup();
              canvas.toBlob((blob) => resolve(blob), 'image/jpeg', 0.8);
              return; // 修复:这里应该直接返回,不需要return true
            }

            // 如果第一帧是黑帧,则开始播放寻找有效帧
            startPlaybackCapture();
          }, 100);

          return true;
        } catch (error) {
          console.warn('Static frame capture failed:', error);
          return false;
        }
      };

      // 播放模式的帧捕获
      const startPlaybackCapture = () => {
        let frameCheckCount = 0; // 添加帧检查计数器
        const checkFrame = () => {
          const elapsed = Date.now() - startTime;
          const currentTime = video.currentTime;
          frameCheckCount++;

          if (!ctx) {
            cleanup();
            reject(new Error('Canvas context lost'));
            return;
          }

          try {
            ctx.clearRect(0, 0, canvas.width, canvas.height);
            ctx.drawImage(video, 0, 0);

            if (isValidFrame(ctx, canvas.width, canvas.height)) {
              console.log(
                `🎉 找到有效帧!时间: ${currentTime.toFixed(2)}s, 总检测次数: ${frameCheckCount}, 总耗时: ${elapsed}ms`,
              );
              cleanup();
              canvas.toBlob((blob) => resolve(blob), 'image/jpeg', 0.8);
              return;
            }

            if (elapsed >= maxDuration) {
              console.warn(
                `⏰ 检测超时(${elapsed}ms),使用当前帧 - 时间: ${currentTime.toFixed(2)}s, 检测次数: ${frameCheckCount}`,
              );
              cleanup();
              canvas.toBlob((blob) => resolve(blob), 'image/jpeg', 0.8);
              return;
            }

            if (elapsed < minCheckTime) {
              setTimeout(() => {
                if ('requestVideoFrameCallback' in video) {
                  frameId = (video as any).requestVideoFrameCallback(checkFrame);
                } else {
                  frameId = requestAnimationFrame(checkFrame);
                }
              }, 50);
            } else {
              if ('requestVideoFrameCallback' in video) {
                frameId = (video as any).requestVideoFrameCallback(checkFrame);
              } else {
                frameId = requestAnimationFrame(checkFrame);
              }
            }
          } catch (error) {
            console.error('❌ 帧检测过程中出错:', error);
            cleanup();
            reject(error);
          }
        };

        // 开始播放并截帧
        video
          .play()
          .then(() => {
            startTime = Date.now();

            if ('requestVideoFrameCallback' in video) {
              frameId = (video as any).requestVideoFrameCallback(checkFrame);
            } else {
              frameId = requestAnimationFrame(checkFrame);
            }

            timeoutId = setTimeout(() => {
              console.warn(`⏰ 全局超时(${maxDuration + 1000}ms),强制使用当前帧`);
              cleanup();
              if (ctx) {
                ctx.clearRect(0, 0, canvas.width, canvas.height);
                ctx.drawImage(video, 0, 0);
                canvas.toBlob((blob) => resolve(blob), 'image/jpeg', 0.8);
              } else {
                reject(new Error('Capture timeout - no context'));
              }
            }, maxDuration + 1000);
          })
          .catch((playError) => {
            console.warn('Video play failed, trying muted:', playError);
            video.muted = true;
            video
              .play()
              .then(() => {
                startTime = Date.now();
                if ('requestVideoFrameCallback' in video) {
                  frameId = (video as any).requestVideoFrameCallback(checkFrame);
                } else {
                  frameId = requestAnimationFrame(checkFrame);
                }
                timeoutId = setTimeout(() => {
                  cleanup();
                  if (ctx) {
                    ctx.clearRect(0, 0, canvas.width, canvas.height);
                    ctx.drawImage(video, 0, 0);
                    canvas.toBlob((blob) => resolve(blob), 'image/jpeg', 0.8);
                  } else {
                    reject(new Error('Capture timeout - no context'));
                  }
                }, maxDuration + 1000);
              })
              .catch((mutedPlayError) => {
                console.error('Muted play also failed:', mutedPlayError);
                cleanup();
                reject(new Error('Cannot play video'));
              });
          });
      };

      // 确保视频可以播放
      if (video.readyState < HTMLMediaElement.HAVE_CURRENT_DATA) {
        cleanup();
        reject(new Error('Video not ready'));
        return;
      }

      // 首先尝试截取静态第一帧
      if (!captureStaticFirstFrame()) {
        // 如果静态截取失败,直接开始播放模式
        startPlaybackCapture();
      }
    });
  };

  try {
    // 等待视频加载
    await new Promise<void>((resolve, reject) => {
      const onLoaded = () => {
        video.removeEventListener('loadeddata', onLoaded);
        video.removeEventListener('error', onError);
        resolve();
      };

      const onError = () => {
        video.removeEventListener('loadeddata', onLoaded);
        video.removeEventListener('error', onError);
        reject(new Error('Video load failed'));
      };

      video.addEventListener('loadeddata', onLoaded, { once: true });
      video.addEventListener('error', onError, { once: true });
    });

    canvas.width = video.videoWidth;
    canvas.height = video.videoHeight;

    // 直接截帧,不再 seek 到特定时间点
    const blob = await captureFrame();

    if (!blob) {
      throw new Error('Failed to capture frame');
    }

    const file = new File([blob], 'cover.jpg', { type: 'image/jpeg' });
    // 自行接入上传服务
    const coverUrl = await uploadFile(file);

    if (!coverUrl) {
      throw new Error('Upload failed');
    }

    return coverUrl;
  } catch (err) {
    console.error('getVideoCoverUrl error:', err);
    return '';
  } finally {
    cleanUp();
  }
};