培训-视频上传获取时长
parent
688d7e14b0
commit
608b60be20
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析视频时长,编码为「分.秒」(如 36分18秒 → 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 再扫 mvhd;false 表示直接搜 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Reference in New Issue