package com.tievd.jyz.controller; import com.tievd.cube.commons.base.Result; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.extern.slf4j.Slf4j; import org.bytedeco.ffmpeg.global.avcodec; import org.bytedeco.ffmpeg.global.avutil; import org.bytedeco.javacv.FFmpegFrameGrabber; import org.bytedeco.javacv.FFmpegFrameRecorder; import org.springframework.beans.factory.annotation.Value; import org.springframework.core.io.FileSystemResource; import org.springframework.core.io.Resource; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.*; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.util.*; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicInteger; import java.util.stream.Collectors; import java.util.Map; /** * 本地视频播放控制器 * 用于首页视频播放测试 */ @Slf4j @RestController @RequestMapping("/jyz/video") @Tag(name = "本地视频播放接口") public class VideoController { @Value("${video.local.path:E:\\yclCode\\DEV_ZHJYZ\\DEV_ZHJYZ\\video-test}") private String videoBasePath; private final Map convertingTasks = new ConcurrentHashMap<>(); private final int MAX_CONCURRENT_CONVERSIONS = 2; @GetMapping("/list") @Operation(summary = "获取本地视频列表") public Result getVideoList() { try { File videoDir = new File(videoBasePath); if (!videoDir.exists() || !videoDir.isDirectory()) { return Result.error("视频目录不存在: " + videoBasePath); } List videoFiles = Arrays.stream(videoDir.listFiles()) .filter(file -> file.isFile() && file.getName().toLowerCase().endsWith(".mp4")) .map(File::getName) .collect(Collectors.toList()); return Result.ok(videoFiles); } catch (Exception e) { log.error("获取视频列表失败", e); return Result.error("获取视频列表失败: " + e.getMessage()); } } @GetMapping("/random") @Operation(summary = "获取随机视频完整地址") public Result getRandomVideo(HttpServletRequest request) { try { File videoDir = new File(videoBasePath); if (!videoDir.exists() || !videoDir.isDirectory()) { return Result.error("视频目录不存在: " + videoBasePath); } List videoFiles = Arrays.stream(videoDir.listFiles()) .filter(file -> file.isFile() && file.getName().toLowerCase().endsWith(".mp4")) .collect(Collectors.toList()); if (videoFiles.isEmpty()) { return Result.error("目录中没有视频文件"); } int randomIndex = (int) (Math.random() * videoFiles.size()); File selectedVideo = videoFiles.get(randomIndex); String baseUrl = request.getRequestURL().toString().replace(request.getRequestURI(), ""); String mp4Url = baseUrl + "/cube/jyz/video/stream/" + selectedVideo.getName(); log.info("=== 返回 mp4 URL ===\n{}", mp4Url); return Result.ok(mp4Url); } catch (Exception e) { log.error("获取随机视频失败", e); return Result.error("获取随机视频失败: " + e.getMessage()); } } @GetMapping("/stream/{videoName}") @Operation(summary = "返回视频文件") public void streamVideo(@PathVariable String videoName, HttpServletRequest request, HttpServletResponse response) { try { if (!videoName.toLowerCase().endsWith(".mp4")) { videoName = videoName + ".mp4"; } File videoFile = new File(videoBasePath, videoName); if (isConvertedVideoName(videoName)) { if (isBrowserPlayableVideoFile(videoFile)) { serveStandardVideo(videoFile, request, response); return; } String sourceName = videoName.replaceAll("_converted\\.mp4$", ".mp4"); File sourceFile = new File(videoBasePath, sourceName); if (!sourceFile.exists()) { response.sendError(HttpServletResponse.SC_NOT_FOUND); return; } boolean converted = ensureConvertedVideo(sourceFile, videoFile, sourceName); if (converted && isBrowserPlayableVideoFile(videoFile)) { serveStandardVideo(videoFile, request, response); return; } serveStandardVideo(sourceFile, request, response); return; } if (!videoFile.exists()) { response.sendError(HttpServletResponse.SC_NOT_FOUND); return; } byte[] header = new byte[16]; try (FileInputStream fis = new FileInputStream(videoFile)) { fis.read(header); } boolean isHikvisionFormat = header[0] == 0x49 && header[1] == 0x4D && header[2] == 0x4B && header[3] == 0x48; String convertedName = videoName.replaceAll("\\.mp4$", "_converted.mp4"); File convertedFile = new File(videoBasePath, convertedName); if (isBrowserPlayableVideoFile(convertedFile)) { serveStandardVideo(convertedFile, request, response); return; } if (isHikvisionFormat || needsBrowserCompatibleConversion(videoFile)) { boolean converted = ensureConvertedVideo(videoFile, convertedFile, videoName); if (converted && isBrowserPlayableVideoFile(convertedFile)) { serveStandardVideo(convertedFile, request, response); return; } serveStandardVideo(videoFile, request, response); return; } serveStandardVideo(videoFile, request, response); } catch (Exception e) { log.error("视频播放失败: {}", videoName, e); try { response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); } catch (IOException ignored) {} } } private boolean ensureConvertedVideo(File inputFile, File outputFile, String taskId) { if (isBrowserPlayableVideoFile(outputFile)) { return true; } AtomicInteger marker = new AtomicInteger(1); AtomicInteger currentTask = convertingTasks.putIfAbsent(taskId, marker); if (currentTask != null) { return waitForConvertedVideo(outputFile, 20000); } try { int currentCount = convertingTasks.values().stream().mapToInt(AtomicInteger::get).sum(); if (currentCount > MAX_CONCURRENT_CONVERSIONS) { return waitForConvertedVideo(outputFile, 20000); } return convertVideoWithJavaCV(inputFile, outputFile); } finally { convertingTasks.remove(taskId); } } private boolean isValidVideoFile(File file) { return file != null && file.exists() && file.isFile() && file.length() > 0; } private boolean isConvertedVideoName(String videoName) { return videoName != null && videoName.toLowerCase(Locale.ROOT).endsWith("_converted.mp4"); } private boolean waitForConvertedVideo(File outputFile, long waitMs) { long deadline = System.currentTimeMillis() + waitMs; while (System.currentTimeMillis() < deadline) { if (isBrowserPlayableVideoFile(outputFile)) { return true; } try { Thread.sleep(200); } catch (InterruptedException e) { Thread.currentThread().interrupt(); return isBrowserPlayableVideoFile(outputFile); } } return isBrowserPlayableVideoFile(outputFile); } private boolean needsBrowserCompatibleConversion(File videoFile) { FFmpegFrameGrabber grabber = null; try { grabber = new FFmpegFrameGrabber(videoFile); grabber.start(); String videoCodec = normalizeCodecName(grabber.getVideoCodecName()); String audioCodec = normalizeCodecName(grabber.getAudioCodecName()); boolean videoCompatible = "h264".equals(videoCodec) || "avc1".equals(videoCodec); boolean audioCompatible = audioCodec.isEmpty() || "aac".equals(audioCodec) || "mp3".equals(audioCodec) || "mp2".equals(audioCodec); log.info("视频编码检测 {} -> videoCodec={}, audioCodec={}", videoFile.getName(), videoCodec, audioCodec); if (videoCodec.isEmpty()) { return true; } return !(videoCompatible && audioCompatible); } catch (Exception e) { log.warn("视频编码检测失败,进入转换流程: {}", videoFile.getName(), e); return true; } finally { try { if (grabber != null) { grabber.stop(); grabber.release(); } } catch (Exception e) { log.warn("释放编码检测资源失败: {}", videoFile.getName(), e); } } } private boolean isPlayableVideoFile(File file) { if (!isValidVideoFile(file)) { return false; } FFmpegFrameGrabber grabber = null; try { grabber = new FFmpegFrameGrabber(file); grabber.start(); return grabber.getImageWidth() > 0 || grabber.getLengthInFrames() > 0 || grabber.getLengthInTime() > 0; } catch (Exception e) { return false; } finally { try { if (grabber != null) { grabber.stop(); grabber.release(); } } catch (Exception ignored) { } } } private boolean isBrowserPlayableVideoFile(File file) { if (!isPlayableVideoFile(file)) { return false; } FFmpegFrameGrabber grabber = null; try { grabber = new FFmpegFrameGrabber(file); grabber.start(); String format = normalizeCodecName(grabber.getFormat()); String videoCodec = normalizeCodecName(grabber.getVideoCodecName()); String audioCodec = normalizeCodecName(grabber.getAudioCodecName()); boolean containerCompatible = format.contains("mp4") || format.contains("mov"); boolean videoCompatible = "h264".equals(videoCodec) || "avc1".equals(videoCodec); boolean audioCompatible = audioCodec.isEmpty() || "aac".equals(audioCodec) || "mp3".equals(audioCodec) || "mp2".equals(audioCodec); boolean durationValid = grabber.getLengthInTime() > 0 || grabber.getLengthInFrames() > 0; return containerCompatible && videoCompatible && audioCompatible && durationValid; } catch (Exception e) { return false; } finally { try { if (grabber != null) { grabber.stop(); grabber.release(); } } catch (Exception ignored) { } } } private String normalizeCodecName(String codecName) { return codecName == null ? "" : codecName.trim().toLowerCase(Locale.ROOT); } private boolean convertVideoWithJavaCV(File inputFile, File outputFile) { FFmpegFrameGrabber grabber = null; FFmpegFrameRecorder recorder = null; try { log.info("开始使用 JavaCV 转换视频: {} -> {}", inputFile.getName(), outputFile.getName()); if (outputFile.exists() && !outputFile.delete()) { log.warn("删除旧的转换文件失败: {}", outputFile.getAbsolutePath()); } grabber = new FFmpegFrameGrabber(inputFile); grabber.start(); int width = Math.max(grabber.getImageWidth(), 2); int height = Math.max(grabber.getImageHeight(), 2); if (width % 2 != 0) { width -= 1; } if (height % 2 != 0) { height -= 1; } width = Math.max(width, 2); height = Math.max(height, 2); int audioChannels = Math.max(grabber.getAudioChannels(), 0); recorder = new FFmpegFrameRecorder(outputFile, width, height, audioChannels); recorder.setFormat("mp4"); recorder.setOption("movflags", "faststart"); recorder.setVideoCodec(avcodec.AV_CODEC_ID_H264); recorder.setPixelFormat(avutil.AV_PIX_FMT_YUV420P); recorder.setVideoOption("preset", "medium"); recorder.setVideoOption("profile", "main"); recorder.setVideoBitrate(grabber.getVideoBitrate() > 0 ? grabber.getVideoBitrate() : 2_000_000); if (audioChannels > 0) { recorder.setAudioCodec(avcodec.AV_CODEC_ID_AAC); recorder.setAudioBitrate(128000); recorder.setSampleRate(grabber.getSampleRate() > 0 ? grabber.getSampleRate() : 44100); recorder.setAudioChannels(audioChannels); } else { recorder.setAudioChannels(0); } double frameRate = grabber.getFrameRate(); if (frameRate > 0 && frameRate < 120) { recorder.setFrameRate(frameRate); } else { recorder.setFrameRate(25); } recorder.start(); org.bytedeco.javacv.Frame frame; while ((frame = grabber.grabFrame()) != null) { if (frame.timestamp > 0) { recorder.setTimestamp(frame.timestamp); } recorder.record(frame); } recorder.stop(); grabber.stop(); if (isBrowserPlayableVideoFile(outputFile)) { log.info("视频转换成功: {} -> {}", inputFile.getName(), outputFile.getName()); return true; } log.error("视频转换失败: 输出文件不可播放 {}", outputFile.getAbsolutePath()); if (outputFile.exists() && !outputFile.delete()) { log.warn("删除失败的转换文件失败: {}", outputFile.getAbsolutePath()); } return false; } catch (Exception e) { log.error("视频转换异常: {}", inputFile.getName(), e); if (outputFile.exists()) { outputFile.delete(); } return false; } finally { try { if (recorder != null) { recorder.release(); } if (grabber != null) { grabber.release(); } } catch (Exception e) { log.warn("释放转码资源异常: {}", inputFile.getName(), e); } } } private void serveStandardVideo(File videoFile, HttpServletRequest request, HttpServletResponse response) throws IOException { long fileSize = videoFile.length(); response.setHeader("Access-Control-Allow-Origin", "*"); response.setContentType("video/mp4"); response.setHeader("Content-Length", String.valueOf(fileSize)); response.setHeader("Accept-Ranges", "bytes"); response.setHeader("Cache-Control", "max-age=86400"); if (request != null) { String range = request.getHeader("Range"); if (range != null && range.startsWith("bytes=")) { String[] ranges = range.substring(6).split("-"); long start = Long.parseLong(ranges[0]); long end = ranges.length > 1 && !ranges[1].isEmpty() ? Long.parseLong(ranges[1]) : fileSize - 1; long contentLength = end - start + 1; response.setStatus(HttpServletResponse.SC_PARTIAL_CONTENT); response.setHeader("Content-Range", "bytes " + start + "-" + end + "/" + fileSize); response.setHeader("Content-Length", String.valueOf(contentLength)); try (FileInputStream fis = new FileInputStream(videoFile)) { fis.skip(start); byte[] buffer = new byte[8192]; long remaining = contentLength; int bytesRead; while (remaining > 0 && (bytesRead = fis.read(buffer, 0, (int) Math.min(buffer.length, remaining))) != -1) { response.getOutputStream().write(buffer, 0, bytesRead); remaining -= bytesRead; } } response.getOutputStream().flush(); return; } } Files.copy(videoFile.toPath(), response.getOutputStream()); response.getOutputStream().flush(); } @GetMapping("/hls/{videoName}") @Operation(summary = "实时转码为HLS流播放") public Result streamHls(@PathVariable String videoName, HttpServletRequest request) { try { if (!videoName.matches("^[a-zA-Z0-9_.]+$")) { return Result.error("无效的文件名"); } String baseName = videoName.replaceAll("\\.mp4$", ""); File inputFile = new File(videoBasePath, videoName); File hlsDir = new File(videoBasePath, baseName + "_hls"); if (!inputFile.exists()) { return Result.error("视频文件不存在: " + videoName); } hlsDir.mkdirs(); File m3u8File = new File(hlsDir, "playlist.m3u8"); String baseUrl = request.getRequestURL().toString().replace(request.getRequestURI(), ""); String m3u8Url = baseUrl + "/cube/jyz/video/hls/" + baseName + "/playlist.m3u8"; String tsUrl = baseUrl + "/cube/jyz/video/hls/" + baseName + "/"; String m3u8Content = "#EXTM3U\n" + "#EXT-X-VERSION:3\n" + "#EXT-X-TARGETDURATION:10\n" + "#EXT-X-MEDIA-SEQUENCE:0\n" + "#EXTINF:10.0,\n" + "segment0.ts\n" + "#EXT-X-ENDLIST\n"; Files.write(m3u8File.toPath(), m3u8Content.getBytes()); for (int i = 0; i < 10; i++) { File tsFile = new File(hlsDir, "segment" + i + ".ts"); ProcessBuilder pb = new ProcessBuilder( "ffmpeg", "-y", "-i", inputFile.getAbsolutePath(), "-ss", String.valueOf(i * 10), "-t", "10", "-c:v", "libx264", "-crf", "23", "-preset", "fast", "-c:a", "aac", "-b:a", "128k", "-f", "mpegts", tsFile.getAbsolutePath() ); pb.redirectErrorStream(true); Process process = pb.start(); try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) { while (reader.readLine() != null) {} } process.waitFor(); } log.info("HLS 转码完成: {}", m3u8File.getAbsolutePath()); Map result = new HashMap<>(); result.put("m3u8Url", m3u8Url); result.put("type", "hls"); return Result.ok(result); } catch (Exception e) { log.error("HLS 转码失败: {}", videoName, e); return Result.error("HLS 转码失败: " + e.getMessage()); } } @GetMapping("/hls/{baseName}/**") @Operation(summary = "HLS文件服务") public void serveHlsFile(@PathVariable String baseName, HttpServletRequest request, HttpServletResponse response) { String pathInfo = request.getRequestURI(); String fileName = pathInfo.substring(pathInfo.lastIndexOf('/') + 1); File hlsDir = new File(videoBasePath, baseName + "_hls"); File file = new File(hlsDir, fileName); if (!file.exists()) { response.setStatus(HttpServletResponse.SC_NOT_FOUND); return; } try { String contentType = fileName.endsWith(".m3u8") ? "application/vnd.apple.mpegurl" : "video/MP2T"; response.setContentType(contentType); response.setHeader("Access-Control-Allow-Origin", "*"); response.setHeader("Cache-Control", "no-cache"); Files.copy(file.toPath(), response.getOutputStream()); response.getOutputStream().flush(); } catch (Exception e) { log.error("HLS 文件服务失败: {}", fileName, e); } } @GetMapping("/download/{videoName}") @Operation(summary = "下载视频文件") public ResponseEntity downloadVideo(@PathVariable String videoName) { try { if (!videoName.matches("^[a-zA-Z0-9_.]+$")) { return ResponseEntity.badRequest().build(); } if (!videoName.toLowerCase().endsWith(".mp4")) { videoName = videoName + ".mp4"; } Path videoPath = Paths.get(videoBasePath, videoName); File videoFile = videoPath.toFile(); if (!videoFile.exists()) { return ResponseEntity.notFound().build(); } Resource resource = new FileSystemResource(videoFile); return ResponseEntity.ok() .contentType(MediaType.APPLICATION_OCTET_STREAM) .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + videoName + "\"") .body(resource); } catch (Exception e) { log.error("视频下载失败: {}", videoName, e); return ResponseEntity.internalServerError().build(); } } @GetMapping("/convert/{videoName}") @Operation(summary = "转换海康视频为标准MP4") public Result convertVideo(@PathVariable String videoName) { try { if (!videoName.matches("^[a-zA-Z0-9_.]+$")) { return Result.error("无效的文件名"); } String outputName = videoName.replaceAll("\\.mp4$", "") + "_converted.mp4"; File inputFile = new File(videoBasePath, videoName); File outputFile = new File(videoBasePath, outputName); if (!inputFile.exists()) { return Result.error("源视频文件不存在: " + videoName); } if (outputFile.exists()) { return Result.ok("/cube/jyz/video/stream/" + outputName); } ProcessBuilder pb = new ProcessBuilder( "ffmpeg", "-y", "-i", inputFile.getAbsolutePath(), "-c:v", "libx264", "-crf", "23", "-preset", "medium", "-c:a", "aac", "-b:a", "128k", outputFile.getAbsolutePath() ); pb.redirectErrorStream(true); Process process = pb.start(); StringBuilder output = new StringBuilder(); try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) { String line; while ((line = reader.readLine()) != null) { output.append(line).append("\n"); } } int exitCode = process.waitFor(); log.info("FFmpeg 转换输出: {}", output); if (exitCode == 0 && outputFile.exists()) { log.info("视频转换成功: {} -> {}", videoName, outputName); return Result.ok("/cube/jyz/video/stream/" + outputName); } else { log.error("视频转换失败, exitCode: {}, output: {}", exitCode, output); return Result.error("视频转换失败: " + output.substring(Math.max(0, output.length() - 500))); } } catch (Exception e) { log.error("视频转换异常: {}", videoName, e); return Result.error("视频转换异常: " + e.getMessage()); } } @GetMapping("/convert/all") @Operation(summary = "批量转换所有海康视频") public Result convertAllVideos() { try { File videoDir = new File(videoBasePath); File[] videoFiles = videoDir.listFiles((dir, name) -> name.toLowerCase().endsWith(".mp4") && !name.contains("_converted")); if (videoFiles == null || videoFiles.length == 0) { return Result.ok("没有需要转换的视频文件"); } int successCount = 0; int failCount = 0; List successList = new ArrayList<>(); List failList = new ArrayList<>(); for (File inputFile : videoFiles) { String outputName = inputFile.getName().replaceAll("\\.mp4$", "") + "_converted.mp4"; File outputFile = new File(videoBasePath, outputName); if (outputFile.exists()) { successCount++; successList.add(outputName); continue; } try { ProcessBuilder pb = new ProcessBuilder( "ffmpeg", "-y", "-i", inputFile.getAbsolutePath(), "-c:v", "libx264", "-crf", "23", "-preset", "medium", "-c:a", "aac", "-b:a", "128k", outputFile.getAbsolutePath() ); pb.redirectErrorStream(true); Process process = pb.start(); try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) { while (reader.readLine() != null) {} } int exitCode = process.waitFor(); if (exitCode == 0 && outputFile.exists()) { successCount++; successList.add(outputName); log.info("转换成功: {}", inputFile.getName()); } else { failCount++; failList.add(inputFile.getName()); log.error("转换失败: {}", inputFile.getName()); } } catch (Exception e) { failCount++; failList.add(inputFile.getName() + "(" + e.getMessage() + ")"); log.error("转换异常: {}", inputFile.getName(), e); } } Map result = new HashMap<>(); result.put("successCount", successCount); result.put("failCount", failCount); result.put("successList", successList); result.put("failList", failList); return Result.ok(result); } catch (Exception e) { log.error("批量转换异常", e); return Result.error("批量转换异常: " + e.getMessage()); } } @GetMapping("/check-ffmpeg") @Operation(summary = "检查FFmpeg是否可用") public Result checkFFmpeg() { try { ProcessBuilder pb = new ProcessBuilder("ffmpeg", "-version"); pb.redirectErrorStream(true); Process process = pb.start(); StringBuilder output = new StringBuilder(); try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) { String line = reader.readLine(); if (line != null) { output.append(line); } } int exitCode = process.waitFor(); if (exitCode == 0) { return Result.ok("FFmpeg 已安装: " + output.toString().split("\n")[0]); } else { return Result.error("FFmpeg 未安装或不可用"); } } catch (Exception e) { return Result.error("FFmpeg 检查失败: " + e.getMessage()); } } }