diff --git a/safety-eval-app/src/main/java/org/qinan/safetyeval/app/executor/FileUploadExecutor.java b/safety-eval-app/src/main/java/org/qinan/safetyeval/app/executor/FileUploadExecutor.java index 56f42cf6..2a602582 100644 --- a/safety-eval-app/src/main/java/org/qinan/safetyeval/app/executor/FileUploadExecutor.java +++ b/safety-eval-app/src/main/java/org/qinan/safetyeval/app/executor/FileUploadExecutor.java @@ -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 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 diff --git a/safety-eval-app/src/main/java/org/qinan/safetyeval/app/executor/VideoDurationHelper.java b/safety-eval-app/src/main/java/org/qinan/safetyeval/app/executor/VideoDurationHelper.java new file mode 100644 index 00000000..0b55cb3d --- /dev/null +++ b/safety-eval-app/src/main/java/org/qinan/safetyeval/app/executor/VideoDurationHelper.java @@ -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 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; + } + } +} diff --git a/safety-eval-client/src/main/java/org/qinan/safetyeval/client/co/FileInfoCO.java b/safety-eval-client/src/main/java/org/qinan/safetyeval/client/co/FileInfoCO.java index 0ca20957..fa28ab2f 100644 --- a/safety-eval-client/src/main/java/org/qinan/safetyeval/client/co/FileInfoCO.java +++ b/safety-eval-client/src/main/java/org/qinan/safetyeval/client/co/FileInfoCO.java @@ -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; } diff --git a/safety-eval-start/src/main/resources/nacos/config-spring.yml b/safety-eval-start/src/main/resources/nacos/config-spring.yml index 2f5ff8f0..66406bde 100644 --- a/safety-eval-start/src/main/resources/nacos/config-spring.yml +++ b/safety-eval-start/src/main/resources/nacos/config-spring.yml @@ -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: