zxl
2026-03-20 a0b2af0203b00e1dfcb8b5977dd5019491533c26
Merge remote-tracking branch 'origin/show-demo' into show_demo
2个文件已修改
1个文件已添加
766 ■■■■■ 已修改文件
jyz-base-start/pom.xml 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jyz-base-start/src/main/java/com/tievd/jyz/controller/VideoController.java 758 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
system-quick-start/src/main/resources/application.yml 1 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jyz-base-start/pom.xml
@@ -162,6 +162,13 @@
            <artifactId>spring-boot-starter-test</artifactId>
        </dependency>
        <!-- JavaCV 视频处理 -->
        <dependency>
            <groupId>org.bytedeco</groupId>
            <artifactId>javacv-platform</artifactId>
            <version>1.5.9</version>
        </dependency>
    </dependencies>
</project>
jyz-base-start/src/main/java/com/tievd/jyz/controller/VideoController.java
New file
@@ -0,0 +1,758 @@
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;
    @Value("${image.local.path:E:\\yclCode\\DEV_ZHJYZ\\DEV_ZHJYZ\\images}")
    private String imageBasePath;
    private final Map<String, AtomicInteger> 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<String> 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<File> 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("/random-images")
    @Operation(summary = "获取随机图片地址列表")
    public Result<?> getRandomImages(@RequestParam(defaultValue = "20") Integer count, HttpServletRequest request) {
        try {
            int safeCount = Math.max(1, Math.min(count, 200));
            File imageDir = new File(imageBasePath);
            if (!imageDir.exists() || !imageDir.isDirectory()) {
                return Result.error("图片目录不存在: " + imageBasePath);
            }
            File[] imageFiles = imageDir.listFiles(file -> file.isFile() && isImageFile(file.getName()));
            if (imageFiles == null || imageFiles.length == 0) {
                return Result.error("目录中没有图片文件");
            }
            String baseUrl = request.getRequestURL().toString().replace(request.getRequestURI(), "");
            Random random = new Random();
            List<String> imageUrls = new ArrayList<>();
            for (int i = 0; i < safeCount; i++) {
                File selectedImage = imageFiles[random.nextInt(imageFiles.length)];
                imageUrls.add(baseUrl + "/cube/jyz/video/image/" + selectedImage.getName());
            }
            return Result.ok(imageUrls);
        } catch (Exception e) {
            log.error("获取随机图片失败", e);
            return Result.error("获取随机图片失败: " + e.getMessage());
        }
    }
    @GetMapping("/image/{imageName:.+}")
    @Operation(summary = "返回本地图片文件")
    public void streamImage(@PathVariable String imageName, HttpServletResponse response) {
        try {
            if (!imageName.matches("^[a-zA-Z0-9._-]+$")) {
                response.sendError(HttpServletResponse.SC_BAD_REQUEST);
                return;
            }
            File imageDir = new File(imageBasePath);
            File imageFile = new File(imageDir, imageName);
            String basePath = imageDir.getCanonicalPath() + File.separator;
            String imagePath = imageFile.getCanonicalPath();
            if (!imagePath.startsWith(basePath) || !imageFile.exists() || !imageFile.isFile()) {
                response.sendError(HttpServletResponse.SC_NOT_FOUND);
                return;
            }
            String contentType = Files.probeContentType(imageFile.toPath());
            if (contentType == null || !contentType.startsWith("image/")) {
                contentType = MediaType.IMAGE_JPEG_VALUE;
            }
            response.setHeader("Access-Control-Allow-Origin", "*");
            response.setHeader("Cache-Control", "max-age=3600");
            response.setContentType(contentType);
            response.setHeader("Content-Length", String.valueOf(imageFile.length()));
            Files.copy(imageFile.toPath(), response.getOutputStream());
            response.getOutputStream().flush();
        } catch (Exception e) {
            log.error("图片读取失败: {}", imageName, e);
            try {
                response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
            } catch (IOException ignored) {
            }
        }
    }
    @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 isImageFile(String fileName) {
        if (fileName == null) {
            return false;
        }
        String lowerName = fileName.toLowerCase(Locale.ROOT);
        return lowerName.endsWith(".jpg") || lowerName.endsWith(".jpeg") || lowerName.endsWith(".png") || lowerName.endsWith(".webp") || lowerName.endsWith(".gif") || lowerName.endsWith(".bmp");
    }
    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<String, String> 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<Resource> 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<String> successList = new ArrayList<>();
            List<String> 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<String, Object> 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());
        }
    }
}
system-quick-start/src/main/resources/application.yml
@@ -86,6 +86,7 @@
          - /demo/**
          - /sysLogo/**
          - /jyz/sysInfo/localInfo
          - /jyz/video/**
  mybatis-plus:
    plugin:
      enable-tenant: false