package com.imyeyu.api.util; import org.bytedeco.javacv.FFmpegFrameGrabber; import org.bytedeco.javacv.Frame; import org.bytedeco.javacv.Java2DFrameConverter; import javax.imageio.ImageIO; import java.awt.image.BufferedImage; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.InputStream; /// JavaCV 工具类 /// /// @author 夜雨 /// @since 2025-10-23 17:05 public class JavaCV { /// 单次跳转分片上限 /// /// 任何真实文件的当前位置加该值都不会溢出 long private static final long SKIP_PIECE = 1L << 40; /// 从视频流顺序解码并提取指定时间附近的一帧 PNG 图片 /// /// 不执行时间定位,避免为了取实时缩略图而把整个视频复制到临时文件 /// /// moov 原子位于文件尾部的视频(非 faststart)仍会触发 FFmpeg 回调 AVSEEK_SIZE 探测文件大小, /// 依赖底层流的 skip/reset 语义正确,因此必须配合 `SkipSafeInputStream` 规避 GridFS 的溢出缺陷 /// /// @param stream 视频输入流 /// @param targetSeconds 目标时间,单位为秒 /// @return PNG 图片字节流 /// @throws Exception 视频解析或取帧失败 public static ByteArrayOutputStream captureThumbnail(InputStream stream, double targetSeconds) throws Exception { long targetMicros = toMicros(targetSeconds); ByteArrayOutputStream firstFrame = new ByteArrayOutputStream(); try (FFmpegFrameGrabber grabber = new FFmpegFrameGrabber(new SkipSafeInputStream(stream))) { grabber.start(); try (Java2DFrameConverter converter = new Java2DFrameConverter()) { Frame frame; while ((frame = grabber.grabImage()) != null) { BufferedImage image = converter.getBufferedImage(frame); if (image == null) { continue; } if (!(0 < firstFrame.size())) { firstFrame = toPng(image); } if (grabber.getTimestamp() >= targetMicros) { return targetMicros == 0 ? firstFrame : toPng(image); } } } } // 短视频提前结束时退回首个有效帧 return firstFrame; } private static long toMicros(double targetSeconds) { double seconds = Double.isFinite(targetSeconds) ? Math.max(0, targetSeconds) : 0; return Math.round(seconds * 1_000_000D); } private static ByteArrayOutputStream toPng(BufferedImage image) throws IOException { ByteArrayOutputStream output = new ByteArrayOutputStream(); if (ImageIO.write(image, "png", output)) { return output; } return new ByteArrayOutputStream(); } /// 分片跳转流包装 /// /// GridFSDownloadStreamImpl 的 skip 以 `currentPosition + n` 计算目标位置且不防护 long 溢出, /// 当 javacv 的 SeekCallback 以 `skip(Long.MAX_VALUE)` 探测流结尾时,位于流末尾的第二次调用 /// 会因溢出落入正常分支,返回未实际跳过的 `Long.MAX_VALUE` 并污染内部位置, /// 导致 `avformat_open_input` 在 `while (true)` 内 100% CPU 死循环 /// /// 此包装将大额跳转拆分为不超过 {@link #SKIP_PIECE} 的分片,保证每次调用的目标位置不溢出 private static class SkipSafeInputStream extends InputStream { private final InputStream stream; private SkipSafeInputStream(InputStream stream) { this.stream = stream; } @Override public int read() throws IOException { return stream.read(); } @Override public int read(byte[] b, int off, int len) throws IOException { return stream.read(b, off, len); } @Override public long skip(long n) throws IOException { if (n <= 0) { return 0; } long total = 0; while (0 < n) { long skipped = stream.skip(Math.min(n, SKIP_PIECE)); if (skipped <= 0) { break; } total += skipped; n -= skipped; } return total; } @Override public int available() throws IOException { return stream.available(); } @Override public void close() throws IOException { stream.close(); } @Override public synchronized void mark(int readlimit) { stream.mark(readlimit); } @Override public synchronized void reset() throws IOException { stream.reset(); } @Override public boolean markSupported() { return stream.markSupported(); } } }