142 lines
4.1 KiB
Java
142 lines
4.1 KiB
Java
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();
|
|
}
|
|
}
|
|
}
|