培训-视频上传获取时长

dev-tmp1
zhanglei 2026-08-18 09:43:53 +08:00
parent 688d7e14b0
commit 608b60be20
4 changed files with 373 additions and 5 deletions

View File

@ -15,6 +15,10 @@ import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import javax.annotation.Resource;
import java.io.InputStream;
import java.math.BigDecimal;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
@ -54,7 +58,40 @@ public class FileUploadExecutor implements FileUploadApi {
@Override
public SingleResponse<FileInfoCO> uploadInternal(MultipartFile file) {
return doUpload(file, true);
if (file == null || file.isEmpty()) {
throw new BizException(ErrorCode.FILE_UPLOAD_EMPTY);
}
if (!VideoDurationHelper.isVideo(file)) {
return doUpload(file, true);
}
// 视频:只读一次流落盘,再解析时长并上传,避免 MultipartFile 流二次读取失败
Path temp = null;
try {
temp = VideoDurationHelper.copyToTemp(file);
BigDecimal videoDuration = VideoDurationHelper.resolveDurationMinutesSeconds(temp);
if (videoDuration == null) {
log.warn("Video uploaded but duration not resolved, filename={}, contentType={}",
file.getOriginalFilename(), file.getContentType());
}
String originalFilename = file.getOriginalFilename();
try (InputStream inputStream = Files.newInputStream(temp)) {
FileInfo ossFileInfo = fileStorageService.of(inputStream, originalFilename).upload();
log.info("OSS video upload success: url={}, size={}, videoDuration={}",
ossFileInfo.getUrl(), ossFileInfo.getSize(), videoDuration);
FileStorage fileStorage = FileStorageHelper.buildResource(ossFileInfo, originalFilename, true);
FileStorage saved = fileStorageDomainService.saveResource(fileStorage);
FileInfoCO co = FileStorageHelper.toCO(saved, null);
co.setVideoDuration(videoDuration);
return SingleResponse.success(co);
}
} catch (BizException e) {
throw e;
} catch (Exception e) {
log.error("Video file upload failed: {}", file.getOriginalFilename(), e);
throw new BizException(ErrorCode.FILE_UPLOAD_FAILED, e);
} finally {
VideoDurationHelper.deleteQuietly(temp);
}
}
@Override

View File

@ -0,0 +1,327 @@
package org.qinan.safetyeval.app.executor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.util.StringUtils;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.io.InputStream;
import java.io.RandomAccessFile;
import java.math.BigDecimal;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.channels.FileChannel;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Arrays;
import java.util.HashSet;
import java.util.Locale;
import java.util.Set;
/**
* JDK
*/
final class VideoDurationHelper {
private static final Logger log = LoggerFactory.getLogger(VideoDurationHelper.class);
private static final Set<String> VIDEO_EXTS = new HashSet<>(Arrays.asList(
"mp4", "mov", "m4v", "m4a", "3gp", "3g2", "avi", "mkv", "webm", "flv", "wmv", "mpeg", "mpg", "ts"
));
private static final byte[] MVHD = "mvhd".getBytes(StandardCharsets.US_ASCII);
private static final byte[] MOOV = "moov".getBytes(StandardCharsets.US_ASCII);
/** 尾部扫描窗口:覆盖常见 moov 体积 */
private static final int TAIL_SCAN_BYTES = 16 * 1024 * 1024;
private VideoDurationHelper() {
}
static boolean isVideo(MultipartFile file) {
if (file == null) {
return false;
}
String contentType = file.getContentType();
if (StringUtils.hasText(contentType) && contentType.toLowerCase(Locale.ROOT).startsWith("video/")) {
return true;
}
String ext = extractExt(file.getOriginalFilename());
return ext != null && VIDEO_EXTS.contains(ext);
}
static Path copyToTemp(MultipartFile file) throws IOException {
Path temp = Files.createTempFile("safety-video-", ".tmp");
try (InputStream in = file.getInputStream()) {
Files.copy(in, temp, java.nio.file.StandardCopyOption.REPLACE_EXISTING);
}
return temp;
}
/**
* . 3618 36.18 null
*/
static BigDecimal resolveDurationMinutesSeconds(Path file) {
if (file == null || !Files.exists(file)) {
return null;
}
try {
double rawSeconds = parseDurationSeconds(file);
if (rawSeconds <= 0) {
log.warn("Video duration parse got empty result, file={}, size={}", file, Files.size(file));
return null;
}
long totalSeconds = Math.round(rawSeconds);
long minutes = totalSeconds / 60;
long secs = totalSeconds % 60;
BigDecimal duration = new BigDecimal(String.format("%d.%02d", minutes, secs));
log.info("Video duration parsed: totalSeconds={}, encoded={}", totalSeconds, duration);
return duration;
} catch (Exception e) {
log.warn("Resolve video duration failed: {}", file, e);
return null;
}
}
static void deleteQuietly(Path path) {
if (path == null) {
return;
}
try {
Files.deleteIfExists(path);
} catch (Exception ignored) {
// ignore
}
}
private static double parseDurationSeconds(Path file) throws IOException {
try (RandomAccessFile raf = new RandomAccessFile(file.toFile(), "r");
FileChannel channel = raf.getChannel()) {
long fileSize = channel.size();
if (fileSize < 16) {
return -1;
}
double seconds = scanTopLevelForMoov(channel, fileSize);
if (seconds > 0) {
return seconds;
}
seconds = scanBufferForAtom(channel, fileSize, MOOV, true);
if (seconds > 0) {
return seconds;
}
return scanBufferForAtom(channel, fileSize, MVHD, false);
}
}
private static double scanTopLevelForMoov(FileChannel channel, long fileSize) throws IOException {
long pos = 0;
int guard = 0;
while (pos < fileSize - 8 && guard++ < 10000) {
BoxHeader header = readBoxHeader(channel, pos, fileSize);
if (header == null || header.size <= 0) {
break;
}
if ("moov".equals(header.type)) {
double seconds = scanMoov(channel, header.dataStart, header.dataEnd);
if (seconds > 0) {
return seconds;
}
}
long next = pos + header.size;
if (next <= pos) {
break;
}
pos = next;
}
return -1;
}
/**
* moov/mvhd
*
* @param atomIsMoov true moov mvhdfalse mvhd
*/
private static double scanBufferForAtom(FileChannel channel, long fileSize, byte[] atom,
boolean atomIsMoov) throws IOException {
int window = (int) Math.min(fileSize, TAIL_SCAN_BYTES);
long start = fileSize - window;
ByteBuffer buf = ByteBuffer.allocate(window);
channel.position(start);
channel.read(buf);
byte[] bytes = buf.array();
int idx = 0;
while ((idx = indexOf(bytes, atom, idx)) >= 0) {
long absPos = start + idx;
// fourcc 前 4 字节是 size
if (absPos >= 4) {
long boxPos = absPos - 4;
BoxHeader header = readBoxHeader(channel, boxPos, fileSize);
if (header != null) {
if (atomIsMoov && "moov".equals(header.type)) {
double seconds = scanMoov(channel, header.dataStart, header.dataEnd);
if (seconds > 0) {
return seconds;
}
} else if (!atomIsMoov && "mvhd".equals(header.type)) {
double seconds = parseMvhd(channel, header.dataStart, header.dataEnd - header.dataStart);
if (seconds > 0) {
return seconds;
}
}
}
}
idx += 4;
}
return -1;
}
private static int indexOf(byte[] data, byte[] pattern, int from) {
outer:
for (int i = from; i <= data.length - pattern.length; i++) {
for (int j = 0; j < pattern.length; j++) {
if (data[i + j] != pattern[j]) {
continue outer;
}
}
return i;
}
return -1;
}
private static double scanMoov(FileChannel channel, long dataStart, long dataEnd) throws IOException {
long pos = dataStart;
int guard = 0;
while (pos <= dataEnd - 8 && guard++ < 10000) {
BoxHeader header = readBoxHeader(channel, pos, dataEnd);
if (header == null || header.size <= 0) {
break;
}
if ("mvhd".equals(header.type)) {
return parseMvhd(channel, header.dataStart, header.dataEnd - header.dataStart);
}
long next = pos + header.size;
if (next <= pos) {
break;
}
pos = next;
}
return -1;
}
private static double parseMvhd(FileChannel channel, long dataStart, long dataSize) throws IOException {
if (dataSize < 20) {
return -1;
}
ByteBuffer buf = ByteBuffer.allocate((int) Math.min(dataSize, 128));
buf.order(ByteOrder.BIG_ENDIAN);
channel.position(dataStart);
int read = channel.read(buf);
if (read < 20) {
return -1;
}
buf.flip();
int version = buf.get() & 0xFF;
buf.position(buf.position() + 3);
long timescale;
long duration;
if (version == 1) {
if (buf.remaining() < 32) {
return -1;
}
buf.getLong();
buf.getLong();
timescale = buf.getInt() & 0xFFFFFFFFL;
duration = buf.getLong();
} else {
if (buf.remaining() < 16) {
return -1;
}
buf.getInt();
buf.getInt();
timescale = buf.getInt() & 0xFFFFFFFFL;
duration = buf.getInt() & 0xFFFFFFFFL;
}
if (timescale <= 0 || duration <= 0) {
return -1;
}
return (double) duration / (double) timescale;
}
private static BoxHeader readBoxHeader(FileChannel channel, long pos, long limit) throws IOException {
if (pos + 8 > limit || pos < 0) {
return null;
}
channel.position(pos);
ByteBuffer header = ByteBuffer.allocate(16);
header.order(ByteOrder.BIG_ENDIAN);
int n = channel.read(header);
if (n < 8) {
return null;
}
header.flip();
long size = header.getInt() & 0xFFFFFFFFL;
byte[] typeBytes = new byte[4];
header.get(typeBytes);
String type = new String(typeBytes, StandardCharsets.US_ASCII);
int headerLen = 8;
if (size == 1) {
if (pos + 16 > limit) {
return null;
}
if (header.remaining() >= 8) {
size = header.getLong();
} else {
ByteBuffer ext = ByteBuffer.allocate(8);
ext.order(ByteOrder.BIG_ENDIAN);
if (channel.read(ext) < 8) {
return null;
}
ext.flip();
size = ext.getLong();
}
headerLen = 16;
} else if (size == 0) {
size = limit - pos;
}
if (size < headerLen) {
return null;
}
long dataStart = pos + headerLen;
long dataEnd = pos + size;
if (dataEnd > limit) {
dataEnd = limit;
}
return new BoxHeader(type, size, dataStart, dataEnd);
}
private static String extractExt(String filename) {
if (!StringUtils.hasText(filename) || !filename.contains(".")) {
return null;
}
return filename.substring(filename.lastIndexOf('.') + 1).toLowerCase(Locale.ROOT);
}
private static final class BoxHeader {
private final String type;
private final long size;
private final long dataStart;
private final long dataEnd;
private BoxHeader(String type, long size, long dataStart, long dataEnd) {
this.type = type;
this.size = size;
this.dataStart = dataStart;
this.dataEnd = dataEnd;
}
}
}

View File

@ -5,6 +5,7 @@ import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import org.springframework.format.annotation.DateTimeFormat;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
@ -43,4 +44,7 @@ public class FileInfoCO {
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private LocalDateTime createdTime;
@ApiModelProperty(value = "视频时长(分.秒如36分18秒为36.18);非视频或解析失败为 null")
private BigDecimal videoDuration;
}

View File

@ -19,13 +19,13 @@ spring:
servlet:
multipart:
enabled: true
max-file-size: 100MB
max-request-size: 500MB
max-file-size: 500MB
max-request-size: 1024MB
server:
tomcat:
max-http-post-size: 100MB
connection-timeout: 180000
connection-timeout: 180000ms
max-http-form-post-size: 500MB
fastjson:
parser: