|
|
@ -5,13 +5,20 @@ import com.xujie.sys.common.utils.OfficeConverter; |
|
|
import com.xujie.sys.common.utils.R; |
|
|
import com.xujie.sys.common.utils.R; |
|
|
import com.xujie.sys.modules.oss.entity.SysOssEntity; |
|
|
import com.xujie.sys.modules.oss.entity.SysOssEntity; |
|
|
import com.xujie.sys.modules.oss.service.SysOssService; |
|
|
import com.xujie.sys.modules.oss.service.SysOssService; |
|
|
|
|
|
import org.bytedeco.ffmpeg.global.avcodec; |
|
|
|
|
|
import org.bytedeco.ffmpeg.global.avutil; |
|
|
|
|
|
import org.bytedeco.javacv.FFmpegFrameGrabber; |
|
|
|
|
|
import org.bytedeco.javacv.FFmpegFrameRecorder; |
|
|
|
|
|
import org.bytedeco.javacv.Frame; |
|
|
import org.slf4j.Logger; |
|
|
import org.slf4j.Logger; |
|
|
import org.slf4j.LoggerFactory; |
|
|
import org.slf4j.LoggerFactory; |
|
|
import org.springframework.beans.factory.annotation.Autowired; |
|
|
import org.springframework.beans.factory.annotation.Autowired; |
|
|
import org.springframework.beans.factory.annotation.Value; |
|
|
import org.springframework.beans.factory.annotation.Value; |
|
|
|
|
|
import org.springframework.util.StringUtils; |
|
|
import org.springframework.web.bind.annotation.*; |
|
|
import org.springframework.web.bind.annotation.*; |
|
|
import org.springframework.web.multipart.MultipartFile; |
|
|
import org.springframework.web.multipart.MultipartFile; |
|
|
|
|
|
|
|
|
|
|
|
import jakarta.servlet.http.HttpServletRequest; |
|
|
import jakarta.servlet.http.HttpServletResponse; |
|
|
import jakarta.servlet.http.HttpServletResponse; |
|
|
import java.io.*; |
|
|
import java.io.*; |
|
|
import java.net.HttpURLConnection; |
|
|
import java.net.HttpURLConnection; |
|
|
@ -20,14 +27,25 @@ import java.net.URLEncoder; |
|
|
import java.nio.charset.StandardCharsets; |
|
|
import java.nio.charset.StandardCharsets; |
|
|
import java.nio.file.Files; |
|
|
import java.nio.file.Files; |
|
|
import java.util.List; |
|
|
import java.util.List; |
|
|
|
|
|
import java.util.concurrent.ConcurrentHashMap; |
|
|
|
|
|
|
|
|
@RestController |
|
|
@RestController |
|
|
@RequestMapping("/oss") |
|
|
@RequestMapping("/oss") |
|
|
public class OssController { |
|
|
public class OssController { |
|
|
|
|
|
|
|
|
private static final Logger logger = LoggerFactory.getLogger(OssController.class); |
|
|
private static final Logger logger = LoggerFactory.getLogger(OssController.class); |
|
|
|
|
|
private static final String MEDIA_INFO_VIDEO_STANDARD = "VIDEO_STD_MP4_H264_AAC"; |
|
|
|
|
|
|
|
|
@Value("${sys-file.mes-url}") |
|
|
@Value("${sys-file.mes-url}") |
|
|
private String mesUrl; |
|
|
private String mesUrl; |
|
|
|
|
|
|
|
|
|
|
|
@Value("${longchuang.production-report.enable-playback-transcode:false}") |
|
|
|
|
|
private boolean enablePlaybackTranscode; |
|
|
|
|
|
|
|
|
|
|
|
@Value("${longchuang.production-report.playback-transcode-force-mp4:false}") |
|
|
|
|
|
private boolean playbackTranscodeForceMp4; |
|
|
|
|
|
|
|
|
|
|
|
private final ConcurrentHashMap<String, Object> playbackTranscodeLockMap = new ConcurrentHashMap<>(); |
|
|
@Autowired |
|
|
@Autowired |
|
|
private SysOssService sysOssService; |
|
|
private SysOssService sysOssService; |
|
|
|
|
|
|
|
|
@ -75,6 +93,74 @@ public class OssController { |
|
|
sysOssService.previewOssFileById2(id,response); |
|
|
sysOssService.previewOssFileById2(id,response); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
@GetMapping("/video/{id}") |
|
|
|
|
|
public void previewVideoStream(@PathVariable("id") Long id, HttpServletRequest request, HttpServletResponse response) throws IOException { |
|
|
|
|
|
try { |
|
|
|
|
|
SysOssEntity attachment = sysOssService.getById(id); |
|
|
|
|
|
if (attachment == null || !StringUtils.hasText(attachment.getUrl())) { |
|
|
|
|
|
response.sendError(HttpServletResponse.SC_NOT_FOUND, "视频不存在"); |
|
|
|
|
|
return; |
|
|
|
|
|
} |
|
|
|
|
|
File sourceFile = new File(attachment.getUrl()); |
|
|
|
|
|
if (!sourceFile.exists() || !sourceFile.isFile()) { |
|
|
|
|
|
response.sendError(HttpServletResponse.SC_NOT_FOUND, "视频文件不存在"); |
|
|
|
|
|
return; |
|
|
|
|
|
} |
|
|
|
|
|
File videoFile = sourceFile; |
|
|
|
|
|
boolean playbackTranscoded = false; |
|
|
|
|
|
if (shouldPlaybackTranscode(attachment, sourceFile)) { |
|
|
|
|
|
videoFile = ensurePlaybackVideoFile(sourceFile); |
|
|
|
|
|
playbackTranscoded = !sourceFile.getAbsolutePath().equals(videoFile.getAbsolutePath()); |
|
|
|
|
|
} |
|
|
|
|
|
long fileLength = videoFile.length(); |
|
|
|
|
|
if (fileLength <= 0) { |
|
|
|
|
|
response.sendError(HttpServletResponse.SC_NOT_FOUND, "视频文件为空"); |
|
|
|
|
|
return; |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
String contentType = playbackTranscoded ? "video/mp4" : resolveVideoContentType(attachment, videoFile); |
|
|
|
|
|
response.setContentType(contentType); |
|
|
|
|
|
response.setHeader("Accept-Ranges", "bytes"); |
|
|
|
|
|
response.setHeader("Cache-Control", "no-cache, no-store, must-revalidate"); |
|
|
|
|
|
response.setHeader("Pragma", "no-cache"); |
|
|
|
|
|
response.setHeader("Expires", "0"); |
|
|
|
|
|
response.setHeader("Content-Disposition", "inline"); |
|
|
|
|
|
|
|
|
|
|
|
String rangeHeader = request.getHeader("Range"); |
|
|
|
|
|
if (!StringUtils.hasText(rangeHeader) || !rangeHeader.startsWith("bytes=")) { |
|
|
|
|
|
response.setStatus(HttpServletResponse.SC_OK); |
|
|
|
|
|
response.setHeader("Content-Length", String.valueOf(fileLength)); |
|
|
|
|
|
writeVideoRange(videoFile, 0, fileLength - 1, response); |
|
|
|
|
|
return; |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
long[] range = resolveVideoRange(rangeHeader, fileLength); |
|
|
|
|
|
if (range == null) { |
|
|
|
|
|
response.setStatus(HttpServletResponse.SC_REQUESTED_RANGE_NOT_SATISFIABLE); |
|
|
|
|
|
response.setHeader("Content-Range", "bytes */" + fileLength); |
|
|
|
|
|
return; |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
long start = range[0]; |
|
|
|
|
|
long end = range[1]; |
|
|
|
|
|
long contentLength = end - start + 1; |
|
|
|
|
|
response.setStatus(HttpServletResponse.SC_PARTIAL_CONTENT); |
|
|
|
|
|
response.setHeader("Content-Range", "bytes " + start + "-" + end + "/" + fileLength); |
|
|
|
|
|
response.setHeader("Content-Length", String.valueOf(contentLength)); |
|
|
|
|
|
writeVideoRange(videoFile, start, end, response); |
|
|
|
|
|
} catch (Exception e) { |
|
|
|
|
|
if (isClientAbortException(e)) { |
|
|
|
|
|
logger.debug("视频流已由客户端主动断开,id={}, msg={}", id, e.getMessage()); |
|
|
|
|
|
return; |
|
|
|
|
|
} |
|
|
|
|
|
if (!response.isCommitted()) { |
|
|
|
|
|
response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "视频预览失败"); |
|
|
|
|
|
return; |
|
|
|
|
|
} |
|
|
|
|
|
throw e; |
|
|
|
|
|
} |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
/** |
|
|
/** |
|
|
* 通过OSS服务代理预览或下载附件 |
|
|
* 通过OSS服务代理预览或下载附件 |
|
|
* OSS接口:POST http://172.26.68.17:8091//oss/2/{id} |
|
|
* OSS接口:POST http://172.26.68.17:8091//oss/2/{id} |
|
|
@ -285,9 +371,243 @@ public class OssController { |
|
|
case "rar": return "application/x-rar-compressed"; |
|
|
case "rar": return "application/x-rar-compressed"; |
|
|
case "7z": return "application/x-7z-compressed"; |
|
|
case "7z": return "application/x-7z-compressed"; |
|
|
case "mp4": return "video/mp4"; |
|
|
case "mp4": return "video/mp4"; |
|
|
|
|
|
case "m4v": return "video/mp4"; |
|
|
|
|
|
case "webm": return "video/webm"; |
|
|
|
|
|
case "mov": return "video/quicktime"; |
|
|
|
|
|
case "3gp": return "video/3gpp"; |
|
|
case "avi": return "video/x-msvideo"; |
|
|
case "avi": return "video/x-msvideo"; |
|
|
|
|
|
case "mkv": return "video/x-matroska"; |
|
|
case "mp3": return "audio/mpeg"; |
|
|
case "mp3": return "audio/mpeg"; |
|
|
default: return "application/octet-stream"; |
|
|
default: return "application/octet-stream"; |
|
|
} |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
private String resolveVideoContentType(SysOssEntity attachment, File videoFile) { |
|
|
|
|
|
String ext = ""; |
|
|
|
|
|
if (attachment != null && StringUtils.hasText(attachment.getFileType())) { |
|
|
|
|
|
ext = attachment.getFileType(); |
|
|
|
|
|
} |
|
|
|
|
|
if (!StringUtils.hasText(ext) && attachment != null && StringUtils.hasText(attachment.getFileName())) { |
|
|
|
|
|
ext = resolveExtByName(attachment.getFileName()); |
|
|
|
|
|
} |
|
|
|
|
|
if (!StringUtils.hasText(ext) && videoFile != null) { |
|
|
|
|
|
ext = resolveExtByName(videoFile.getName()); |
|
|
|
|
|
} |
|
|
|
|
|
String resolved = getContentType(ext); |
|
|
|
|
|
if (StringUtils.hasText(resolved) && !"application/octet-stream".equalsIgnoreCase(resolved)) { |
|
|
|
|
|
return resolved; |
|
|
|
|
|
} |
|
|
|
|
|
return "video/mp4"; |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
private String resolveExtByName(String fileName) { |
|
|
|
|
|
String raw = fileName == null ? "" : fileName.trim(); |
|
|
|
|
|
int index = raw.lastIndexOf("."); |
|
|
|
|
|
if (index < 0 || index >= raw.length() - 1) { |
|
|
|
|
|
return ""; |
|
|
|
|
|
} |
|
|
|
|
|
return raw.substring(index + 1); |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
private boolean shouldPlaybackTranscode(SysOssEntity attachment, File sourceFile) { |
|
|
|
|
|
if (!enablePlaybackTranscode || sourceFile == null) { |
|
|
|
|
|
return false; |
|
|
|
|
|
} |
|
|
|
|
|
if (attachment != null && StringUtils.hasText(attachment.getCAdditionalInfo())) { |
|
|
|
|
|
String additionalInfo = attachment.getCAdditionalInfo().trim().toUpperCase(); |
|
|
|
|
|
if (additionalInfo.contains(MEDIA_INFO_VIDEO_STANDARD)) { |
|
|
|
|
|
return false; |
|
|
|
|
|
} |
|
|
|
|
|
} |
|
|
|
|
|
String sourceName = sourceFile.getName(); |
|
|
|
|
|
if (sourceName.endsWith("_stream.mp4")) { |
|
|
|
|
|
return false; |
|
|
|
|
|
} |
|
|
|
|
|
String ext = resolveExtByName(sourceName); |
|
|
|
|
|
if (!StringUtils.hasText(ext)) { |
|
|
|
|
|
return true; |
|
|
|
|
|
} |
|
|
|
|
|
String lowerExt = ext.toLowerCase(); |
|
|
|
|
|
if ("mp4".equals(lowerExt)) { |
|
|
|
|
|
return playbackTranscodeForceMp4; |
|
|
|
|
|
} |
|
|
|
|
|
return true; |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
private File ensurePlaybackVideoFile(File sourceFile) { |
|
|
|
|
|
if (sourceFile == null || !sourceFile.exists() || !sourceFile.isFile()) { |
|
|
|
|
|
return sourceFile; |
|
|
|
|
|
} |
|
|
|
|
|
String sourceName = sourceFile.getName(); |
|
|
|
|
|
int dotIndex = sourceName.lastIndexOf("."); |
|
|
|
|
|
String baseName = dotIndex > 0 ? sourceName.substring(0, dotIndex) : sourceName; |
|
|
|
|
|
File cachedFile = new File(sourceFile.getParentFile(), baseName + "_stream.mp4"); |
|
|
|
|
|
if (cachedFile.exists() && cachedFile.length() > 0 && cachedFile.lastModified() >= sourceFile.lastModified()) { |
|
|
|
|
|
return cachedFile; |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
String lockKey = sourceFile.getAbsolutePath(); |
|
|
|
|
|
Object lock = playbackTranscodeLockMap.computeIfAbsent(lockKey, key -> new Object()); |
|
|
|
|
|
synchronized (lock) { |
|
|
|
|
|
try { |
|
|
|
|
|
if (cachedFile.exists() && cachedFile.length() > 0 && cachedFile.lastModified() >= sourceFile.lastModified()) { |
|
|
|
|
|
return cachedFile; |
|
|
|
|
|
} |
|
|
|
|
|
transcodeVideoForPlayback(sourceFile, cachedFile); |
|
|
|
|
|
if (cachedFile.exists() && cachedFile.length() > 0) { |
|
|
|
|
|
return cachedFile; |
|
|
|
|
|
} |
|
|
|
|
|
return sourceFile; |
|
|
|
|
|
} catch (Exception e) { |
|
|
|
|
|
logger.error("播放转码失败,source={}, cache={}, error={}", |
|
|
|
|
|
sourceFile.getAbsolutePath(), cachedFile.getAbsolutePath(), e.getMessage(), e); |
|
|
|
|
|
return sourceFile; |
|
|
|
|
|
} finally { |
|
|
|
|
|
playbackTranscodeLockMap.remove(lockKey); |
|
|
|
|
|
} |
|
|
|
|
|
} |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
private void transcodeVideoForPlayback(File sourceFile, File targetFile) throws Exception { |
|
|
|
|
|
if (targetFile.exists() && !targetFile.delete()) { |
|
|
|
|
|
throw new IOException("播放转码目标文件清理失败"); |
|
|
|
|
|
} |
|
|
|
|
|
FFmpegFrameGrabber grabber = new FFmpegFrameGrabber(sourceFile); |
|
|
|
|
|
FFmpegFrameRecorder recorder = null; |
|
|
|
|
|
try { |
|
|
|
|
|
grabber.start(); |
|
|
|
|
|
int width = grabber.getImageWidth(); |
|
|
|
|
|
int height = grabber.getImageHeight(); |
|
|
|
|
|
if (width <= 0 || height <= 0) { |
|
|
|
|
|
throw new IllegalArgumentException("视频分辨率无效"); |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
int audioChannels = Math.max(grabber.getAudioChannels(), 0); |
|
|
|
|
|
recorder = new FFmpegFrameRecorder(targetFile, width, height, audioChannels); |
|
|
|
|
|
recorder.setInterleaved(true); |
|
|
|
|
|
recorder.setFormat("mp4"); |
|
|
|
|
|
recorder.setVideoCodec(avcodec.AV_CODEC_ID_H264); |
|
|
|
|
|
recorder.setVideoOption("preset", "veryfast"); |
|
|
|
|
|
recorder.setVideoOption("crf", "23"); |
|
|
|
|
|
recorder.setVideoOption("movflags", "+faststart"); |
|
|
|
|
|
recorder.setPixelFormat(avutil.AV_PIX_FMT_YUV420P); |
|
|
|
|
|
|
|
|
|
|
|
double frameRate = grabber.getVideoFrameRate(); |
|
|
|
|
|
recorder.setFrameRate(frameRate > 0 ? frameRate : 25); |
|
|
|
|
|
int videoBitrate = grabber.getVideoBitrate(); |
|
|
|
|
|
recorder.setVideoBitrate(videoBitrate > 0 ? videoBitrate : 1500_000); |
|
|
|
|
|
|
|
|
|
|
|
if (audioChannels > 0) { |
|
|
|
|
|
recorder.setAudioCodec(avcodec.AV_CODEC_ID_AAC); |
|
|
|
|
|
int sampleRate = grabber.getSampleRate(); |
|
|
|
|
|
recorder.setSampleRate(sampleRate > 0 ? sampleRate : 44100); |
|
|
|
|
|
recorder.setAudioChannels(audioChannels); |
|
|
|
|
|
int audioBitrate = grabber.getAudioBitrate(); |
|
|
|
|
|
recorder.setAudioBitrate(audioBitrate > 0 ? audioBitrate : 128000); |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
recorder.start(); |
|
|
|
|
|
Frame frame; |
|
|
|
|
|
while ((frame = grabber.grabFrame()) != null) { |
|
|
|
|
|
recorder.record(frame); |
|
|
|
|
|
} |
|
|
|
|
|
recorder.stop(); |
|
|
|
|
|
grabber.stop(); |
|
|
|
|
|
if (!targetFile.exists() || targetFile.length() <= 0) { |
|
|
|
|
|
throw new IOException("播放转码结果为空"); |
|
|
|
|
|
} |
|
|
|
|
|
} finally { |
|
|
|
|
|
try { |
|
|
|
|
|
if (recorder != null) { |
|
|
|
|
|
recorder.release(); |
|
|
|
|
|
} |
|
|
|
|
|
} catch (Exception ignored) { |
|
|
|
|
|
} |
|
|
|
|
|
try { |
|
|
|
|
|
grabber.release(); |
|
|
|
|
|
} catch (Exception ignored) { |
|
|
|
|
|
} |
|
|
|
|
|
} |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
private long[] resolveVideoRange(String rangeHeader, long fileLength) { |
|
|
|
|
|
try { |
|
|
|
|
|
String rangeValue = rangeHeader.substring("bytes=".length()).trim(); |
|
|
|
|
|
int commaIndex = rangeValue.indexOf(","); |
|
|
|
|
|
if (commaIndex > -1) { |
|
|
|
|
|
rangeValue = rangeValue.substring(0, commaIndex).trim(); |
|
|
|
|
|
} |
|
|
|
|
|
int dashIndex = rangeValue.indexOf("-"); |
|
|
|
|
|
if (dashIndex < 0) { |
|
|
|
|
|
return null; |
|
|
|
|
|
} |
|
|
|
|
|
String startText = rangeValue.substring(0, dashIndex).trim(); |
|
|
|
|
|
String endText = rangeValue.substring(dashIndex + 1).trim(); |
|
|
|
|
|
long start; |
|
|
|
|
|
long end; |
|
|
|
|
|
if (!StringUtils.hasText(startText)) { |
|
|
|
|
|
long suffixLength = Long.parseLong(endText); |
|
|
|
|
|
if (suffixLength <= 0) { |
|
|
|
|
|
return null; |
|
|
|
|
|
} |
|
|
|
|
|
start = suffixLength >= fileLength ? 0 : fileLength - suffixLength; |
|
|
|
|
|
end = fileLength - 1; |
|
|
|
|
|
} else { |
|
|
|
|
|
start = Long.parseLong(startText); |
|
|
|
|
|
end = StringUtils.hasText(endText) ? Long.parseLong(endText) : fileLength - 1; |
|
|
|
|
|
} |
|
|
|
|
|
if (start < 0 || end < 0 || start >= fileLength) { |
|
|
|
|
|
return null; |
|
|
|
|
|
} |
|
|
|
|
|
if (end >= fileLength) { |
|
|
|
|
|
end = fileLength - 1; |
|
|
|
|
|
} |
|
|
|
|
|
if (end < start) { |
|
|
|
|
|
return null; |
|
|
|
|
|
} |
|
|
|
|
|
return new long[]{start, end}; |
|
|
|
|
|
} catch (Exception e) { |
|
|
|
|
|
return null; |
|
|
|
|
|
} |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
private void writeVideoRange(File file, long start, long end, HttpServletResponse response) throws IOException { |
|
|
|
|
|
OutputStream outputStream = response.getOutputStream(); |
|
|
|
|
|
try (RandomAccessFile randomAccessFile = new RandomAccessFile(file, "r")) { |
|
|
|
|
|
randomAccessFile.seek(start); |
|
|
|
|
|
byte[] buffer = new byte[8192]; |
|
|
|
|
|
long remain = end - start + 1; |
|
|
|
|
|
while (remain > 0) { |
|
|
|
|
|
int readLength = randomAccessFile.read(buffer, 0, (int) Math.min(buffer.length, remain)); |
|
|
|
|
|
if (readLength < 0) { |
|
|
|
|
|
break; |
|
|
|
|
|
} |
|
|
|
|
|
outputStream.write(buffer, 0, readLength); |
|
|
|
|
|
remain -= readLength; |
|
|
|
|
|
} |
|
|
|
|
|
outputStream.flush(); |
|
|
|
|
|
} |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
private boolean isClientAbortException(Throwable throwable) { |
|
|
|
|
|
Throwable current = throwable; |
|
|
|
|
|
while (current != null) { |
|
|
|
|
|
String className = current.getClass().getName(); |
|
|
|
|
|
String message = current.getMessage(); |
|
|
|
|
|
if (className.contains("ClientAbortException") || className.contains("AsyncRequestNotUsableException")) { |
|
|
|
|
|
return true; |
|
|
|
|
|
} |
|
|
|
|
|
if (message != null) { |
|
|
|
|
|
String lowerMessage = message.toLowerCase(); |
|
|
|
|
|
if (lowerMessage.contains("broken pipe") |
|
|
|
|
|
|| lowerMessage.contains("connection reset") |
|
|
|
|
|
|| lowerMessage.contains("connection reset by peer") |
|
|
|
|
|
|| lowerMessage.contains("forcibly closed")) { |
|
|
|
|
|
return true; |
|
|
|
|
|
} |
|
|
|
|
|
} |
|
|
|
|
|
current = current.getCause(); |
|
|
|
|
|
} |
|
|
|
|
|
return false; |
|
|
|
|
|
} |
|
|
} |
|
|
} |