From ce21bad400b156552b38fe5d4bb02fff8ddc4632 Mon Sep 17 00:00:00 2001 From: MaDaLei Date: Fri, 17 Apr 2026 18:58:04 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=9C=8D=E5=8A=A1=E5=9B=9E=E9=A1=BE?= =?UTF-8?q?=E7=9F=AD=E7=89=87=E5=90=8E=E7=AB=AF=E6=94=AF=E6=8C=81(ffmpeg?= =?UTF-8?q?=E5=90=88=E6=88=90)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../config/HighlightExecutorConfig.java | 22 + .../petstore/controller/ReportController.java | 28 ++ src/main/java/com/petstore/entity/Report.java | 14 + .../service/ReportHighlightVideoService.java | 417 ++++++++++++++++++ src/main/resources/application.yml | 5 + 5 files changed, 486 insertions(+) create mode 100644 src/main/java/com/petstore/config/HighlightExecutorConfig.java create mode 100644 src/main/java/com/petstore/service/ReportHighlightVideoService.java diff --git a/src/main/java/com/petstore/config/HighlightExecutorConfig.java b/src/main/java/com/petstore/config/HighlightExecutorConfig.java new file mode 100644 index 0000000..6d96043 --- /dev/null +++ b/src/main/java/com/petstore/config/HighlightExecutorConfig.java @@ -0,0 +1,22 @@ +package com.petstore.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +import java.util.concurrent.Executor; + +@Configuration +public class HighlightExecutorConfig { + + @Bean(name = "highlightTaskExecutor") + public Executor highlightTaskExecutor() { + ThreadPoolTaskExecutor ex = new ThreadPoolTaskExecutor(); + ex.setCorePoolSize(1); + ex.setMaxPoolSize(2); + ex.setQueueCapacity(30); + ex.setThreadNamePrefix("highlight-video-"); + ex.initialize(); + return ex; + } +} diff --git a/src/main/java/com/petstore/controller/ReportController.java b/src/main/java/com/petstore/controller/ReportController.java index c289a8e..8899275 100644 --- a/src/main/java/com/petstore/controller/ReportController.java +++ b/src/main/java/com/petstore/controller/ReportController.java @@ -3,9 +3,11 @@ package com.petstore.controller; import com.petstore.entity.Report; import com.petstore.entity.ReportImage; import com.petstore.entity.Store; +import com.petstore.service.ReportHighlightVideoService; import com.petstore.service.ReportService; import com.petstore.service.StoreService; import lombok.RequiredArgsConstructor; +import org.springframework.http.MediaType; import org.springframework.web.bind.annotation.*; import org.springframework.beans.factory.annotation.Value; import org.springframework.data.domain.Page; @@ -20,6 +22,7 @@ import java.util.stream.Collectors; public class ReportController { private final ReportService reportService; private final StoreService storeService; + private final ReportHighlightVideoService reportHighlightVideoService; @Value("${app.base-url:http://localhost:8080}") private String baseUrl; @@ -47,6 +50,29 @@ public class ReportController { return "photo"; } + private void putHighlightFields(Map target, Report r) { + target.put("highlightVideoUrl", r.getHighlightVideoUrl() != null ? fullUrl(r.getHighlightVideoUrl()) : null); + target.put("highlightVideoStatus", r.getHighlightVideoStatus()); + target.put("highlightVideoError", r.getHighlightVideoError()); + target.put("highlightDurationSec", r.getHighlightDurationSec()); + } + + /** 触发服务回顾短片生成(异步;依赖服务器 ffmpeg) */ + @PostMapping(value = "/highlight/start", produces = MediaType.APPLICATION_JSON_VALUE + ";charset=UTF-8") + public Map startHighlight(@RequestBody Map body) { + if (body.get("reportId") == null || body.get("operatorUserId") == null || body.get("role") == null) { + return Map.of("code", 400, "message", "缺少 reportId / operatorUserId / role"); + } + Long reportId = Long.valueOf(body.get("reportId").toString()); + Long operatorUserId = Long.valueOf(body.get("operatorUserId").toString()); + String role = body.get("role").toString(); + int durationSec = 15; + if (body.get("durationSec") != null) { + durationSec = Integer.parseInt(body.get("durationSec").toString()); + } + return reportHighlightVideoService.requestGenerate(reportId, durationSec, operatorUserId, role); + } + @PostMapping("/create") public Map create(@RequestBody Report report) { System.out.println(">>> Report create received: appointmentId=" + report.getAppointmentId() + ", userId=" + report.getUserId() + ", images count=" + (report.getImages() == null ? 0 : report.getImages().size())); @@ -115,6 +141,7 @@ public class ReportController { item.put("beforePhotos", beforePhotos); item.put("afterPhotos", afterPhotos); item.put("duringMedia", duringMedia); + putHighlightFields(item, r); return item; }).collect(Collectors.toList()); if (usePaging && paged != null) { @@ -190,6 +217,7 @@ public class ReportController { storeInfo.put("address", store.getAddress()); data.put("store", storeInfo); } + putHighlightFields(data, report); result.put("code", 200); result.put("data", data); } else { diff --git a/src/main/java/com/petstore/entity/Report.java b/src/main/java/com/petstore/entity/Report.java index de78a8a..3034ac3 100644 --- a/src/main/java/com/petstore/entity/Report.java +++ b/src/main/java/com/petstore/entity/Report.java @@ -53,4 +53,18 @@ public class Report { @Column(nullable = false, columnDefinition = "TINYINT(1) DEFAULT 0") private Boolean deleted = false; + + /** 服务回顾短片(由服务前/中/后素材合成)相对 URL,如 /api/upload/image/... */ + @Column(name = "highlight_video_url", length = 500) + private String highlightVideoUrl; + + /** processing | done | failed */ + @Column(name = "highlight_video_status", length = 32) + private String highlightVideoStatus; + + @Column(name = "highlight_video_error", length = 500) + private String highlightVideoError; + + @Column(name = "highlight_duration_sec") + private Integer highlightDurationSec; } diff --git a/src/main/java/com/petstore/service/ReportHighlightVideoService.java b/src/main/java/com/petstore/service/ReportHighlightVideoService.java new file mode 100644 index 0000000..1a37ebc --- /dev/null +++ b/src/main/java/com/petstore/service/ReportHighlightVideoService.java @@ -0,0 +1,417 @@ +package com.petstore.service; + +import com.petstore.entity.Appointment; +import com.petstore.entity.Report; +import com.petstore.entity.ReportImage; +import com.petstore.entity.User; +import com.petstore.mapper.AppointmentMapper; +import com.petstore.mapper.ReportImageMapper; +import com.petstore.mapper.ReportMapper; +import com.petstore.mapper.UserMapper; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.io.BufferedReader; +import java.io.BufferedWriter; +import java.io.InputStreamReader; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardCopyOption; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Executor; + +/** + * 将服务报告中的服务前 / 过程中 / 服务后图片与短视频,按时间线拼接为竖屏短片(默认 15s 或 30s)。 + *

依赖本机安装 {@code ffmpeg};后续可在此类接入云端「生成式视频」API,与本地管线切换。

+ */ +@Service +public class ReportHighlightVideoService { + + private static final int[] ALLOWED_DURATIONS = {15, 30}; + + private final ReportMapper reportMapper; + private final ReportImageMapper reportImageMapper; + private final UserMapper userMapper; + private final AppointmentMapper appointmentMapper; + private final Executor highlightExecutor; + + @Value("${upload.path:uploads/}") + private String uploadPath; + + @Value("${app.highlight-video.ffmpeg-binary:ffmpeg}") + private String ffmpegBinary; + + private final ConcurrentHashMap reportLocks = new ConcurrentHashMap<>(); + + public ReportHighlightVideoService( + ReportMapper reportMapper, + ReportImageMapper reportImageMapper, + UserMapper userMapper, + AppointmentMapper appointmentMapper, + @Qualifier("highlightTaskExecutor") Executor highlightExecutor) { + this.reportMapper = reportMapper; + this.reportImageMapper = reportImageMapper; + this.userMapper = userMapper; + this.appointmentMapper = appointmentMapper; + this.highlightExecutor = highlightExecutor; + } + + private Object lockFor(Long reportId) { + return reportLocks.computeIfAbsent(reportId, k -> new Object()); + } + + public Map requestGenerate(Long reportId, int durationSec, Long operatorUserId, String role) { + if (reportId == null || operatorUserId == null || role == null || role.isBlank()) { + return Map.of("code", 400, "message", "参数不完整"); + } + boolean okDur = false; + for (int d : ALLOWED_DURATIONS) { + if (d == durationSec) { + okDur = true; + break; + } + } + if (!okDur) { + return Map.of("code", 400, "message", "durationSec 仅支持 15 或 30"); + } + + synchronized (lockFor(reportId)) { + Optional opt = reportMapper.findByIdAndDeletedFalse(reportId); + if (opt.isEmpty()) { + return Map.of("code", 404, "message", "报告不存在"); + } + Report report = opt.get(); + if (!canOperate(operatorUserId, role, report)) { + return Map.of("code", 403, "message", "无权生成该报告的短片"); + } + List imgs = reportImageMapper.findByReportIdOrderBySortOrderAscIdAsc(reportId); + if (!hasRenderableMedia(imgs)) { + return Map.of("code", 400, "message", "请先上传服务前、过程中或服务后的照片或视频"); + } + if ("processing".equals(report.getHighlightVideoStatus())) { + return Map.of("code", 409, "message", "短片正在生成中,请稍候刷新"); + } + if (!ffmpegAvailable()) { + return Map.of("code", 503, "message", "服务器未安装或未配置 ffmpeg,无法生成短片"); + } + + report.setHighlightVideoStatus("processing"); + report.setHighlightVideoError(null); + report.setHighlightDurationSec(durationSec); + report.setUpdateTime(LocalDateTime.now()); + reportMapper.save(report); + + int d = durationSec; + highlightExecutor.execute(() -> composeSafely(reportId, d)); + return Map.of("code", 200, "message", "已开始生成", "data", Map.of("status", "processing")); + } + } + + private void composeSafely(Long reportId, int durationSec) { + try { + composeInternal(reportId, durationSec); + } catch (Exception e) { + markFailed(reportId, e.getMessage() != null ? e.getMessage() : "合成失败"); + } + } + + private void markFailed(Long reportId, String err) { + synchronized (lockFor(reportId)) { + reportMapper.findByIdAndDeletedFalse(reportId).ifPresent(r -> { + r.setHighlightVideoStatus("failed"); + r.setHighlightVideoError(err != null && err.length() > 500 ? err.substring(0, 500) : err); + r.setUpdateTime(LocalDateTime.now()); + reportMapper.save(r); + }); + } + } + + private void composeInternal(Long reportId, int durationSec) throws Exception { + Optional opt = reportMapper.findByIdAndDeletedFalse(reportId); + if (opt.isEmpty()) { + return; + } + List imgs = sortedImages(reportImageMapper.findByReportIdOrderBySortOrderAscIdAsc(reportId)); + List sources = new ArrayList<>(); + List isVideo = new ArrayList<>(); + for (ReportImage img : imgs) { + Path p = resolveUploadFile(img.getPhotoUrl()); + if (p == null || !Files.isRegularFile(p)) { + continue; + } + boolean video = isVideoMedia(img); + sources.add(p); + isVideo.add(video); + } + if (sources.isEmpty()) { + markFailed(reportId, "素材文件在服务器上不存在或无法读取"); + return; + } + + int n = sources.size(); + double perClip = (double) durationSec / n; + if (perClip < 0.35) { + perClip = 0.35; + } + + Path workDir = Files.createTempDirectory("highlight_" + reportId + "_"); + try { + List segments = new ArrayList<>(); + for (int i = 0; i < n; i++) { + Path seg = workDir.resolve("seg_" + i + ".mp4"); + if (Boolean.TRUE.equals(isVideo.get(i))) { + encodeVideoSegment(sources.get(i), seg, perClip); + } else { + encodeImageSegment(sources.get(i), seg, perClip); + } + segments.add(seg); + } + + Path listFile = workDir.resolve("concat.txt"); + try (BufferedWriter w = Files.newBufferedWriter(listFile, StandardCharsets.UTF_8)) { + for (Path seg : segments) { + String abs = seg.toAbsolutePath().toString().replace('\\', '/'); + abs = abs.replace("'", "'\\''"); + w.write("file '"); + w.write(abs); + w.write("'\n"); + } + } + + Path merged = workDir.resolve("merged.mp4"); + int c1 = runFfmpeg(List.of( + ffmpegBinary, "-y", "-f", "concat", "-safe", "0", "-i", listFile.toString(), + "-c", "copy", merged.toString() + )); + if (c1 != 0) { + int c2 = runFfmpeg(List.of( + ffmpegBinary, "-y", "-f", "concat", "-safe", "0", "-i", listFile.toString(), + "-c:v", "libx264", "-preset", "veryfast", "-crf", "23", + "-c:a", "aac", "-b:a", "96k", merged.toString() + )); + if (c2 != 0) { + throw new IllegalStateException("ffmpeg 拼接失败(退出码 " + c2 + ")"); + } + } + + String datePath = LocalDate.now().toString().replace("-", "/"); + String base = uploadPath.endsWith("/") ? uploadPath : uploadPath + "/"; + Path outDir = Paths.get(base + datePath); + Files.createDirectories(outDir); + String filename = "highlight_" + reportId + "_" + UUID.randomUUID().toString().replace("-", "") + ".mp4"; + Path finalPath = outDir.resolve(filename); + Files.copy(merged, finalPath, StandardCopyOption.REPLACE_EXISTING); + + String url = "/api/upload/image/" + datePath + "/" + filename; + + synchronized (lockFor(reportId)) { + reportMapper.findByIdAndDeletedFalse(reportId).ifPresent(r -> { + r.setHighlightVideoUrl(url); + r.setHighlightVideoStatus("done"); + r.setHighlightVideoError(null); + r.setHighlightDurationSec(durationSec); + r.setUpdateTime(LocalDateTime.now()); + reportMapper.save(r); + }); + } + } finally { + try { + Files.walk(workDir) + .sorted(Comparator.reverseOrder()) + .forEach(p -> { + try { + Files.deleteIfExists(p); + } catch (Exception ignored) { + } + }); + } catch (Exception ignored) { + } + } + } + + private boolean hasRenderableMedia(List imgs) { + if (imgs == null || imgs.isEmpty()) { + return false; + } + for (ReportImage img : imgs) { + Path p = resolveUploadFile(img.getPhotoUrl()); + if (p != null && Files.isRegularFile(p)) { + return true; + } + } + return false; + } + + private boolean ffmpegAvailable() { + try { + ProcessBuilder pb = new ProcessBuilder(ffmpegBinary, "-version"); + pb.redirectErrorStream(true); + Process p = pb.start(); + drain(p); + return p.waitFor() == 0; + } catch (Exception e) { + return false; + } + } + + private int runFfmpeg(List cmd) throws Exception { + ProcessBuilder pb = new ProcessBuilder(cmd); + pb.redirectErrorStream(true); + Process p = pb.start(); + drain(p); + return p.waitFor(); + } + + private void drain(Process p) throws Exception { + try (BufferedReader r = new BufferedReader(new InputStreamReader(p.getInputStream(), StandardCharsets.UTF_8))) { + while (r.readLine() != null) { + // discard + } + } + } + + private void encodeImageSegment(Path image, Path out, double seconds) throws Exception { + String t = String.format(Locale.ROOT, "%.3f", seconds); + String vf = "scale=1080:1920:force_original_aspect_ratio=decrease," + + "pad=1080:1920:(ow-iw)/2:(oh-ih)/2,format=yuv420p,fps=30"; + int code = runFfmpeg(List.of( + ffmpegBinary, "-y", + "-loop", "1", "-framerate", "1", "-i", image.toString(), + "-f", "lavfi", "-i", "anullsrc=channel_layout=stereo:sample_rate=44100", + "-vf", vf, + "-c:v", "libx264", "-preset", "veryfast", "-crf", "23", + "-c:a", "aac", "-b:a", "96k", + "-pix_fmt", "yuv420p", + "-t", t, + out.toString() + )); + if (code != 0) { + throw new IllegalStateException("图片转视频失败(退出码 " + code + ")"); + } + } + + private void encodeVideoSegment(Path video, Path out, double seconds) throws Exception { + String t = String.format(Locale.ROOT, "%.3f", seconds); + String vf = "scale=1080:1920:force_original_aspect_ratio=decrease," + + "pad=1080:1920:(ow-iw)/2:(oh-ih)/2,format=yuv420p,fps=30"; + int code = runFfmpeg(List.of( + ffmpegBinary, "-y", + "-i", video.toString(), + "-f", "lavfi", "-i", "anullsrc=channel_layout=stereo:sample_rate=44100", + "-vf", vf, + "-map", "0:v:0", "-map", "1:a:0", + "-c:v", "libx264", "-preset", "veryfast", "-crf", "23", + "-c:a", "aac", "-b:a", "96k", + "-pix_fmt", "yuv420p", + "-t", t, + out.toString() + )); + if (code != 0) { + throw new IllegalStateException("视频片段重编码失败(退出码 " + code + ")"); + } + } + + private List sortedImages(List imgs) { + if (imgs == null) { + return List.of(); + } + List copy = new ArrayList<>(imgs); + copy.sort(Comparator + .comparingInt((ReportImage i) -> typeOrder(i.getPhotoType())) + .thenComparingInt(i -> Optional.ofNullable(i.getSortOrder()).orElse(0)) + .thenComparingLong(i -> Optional.ofNullable(i.getId()).orElse(0L))); + return copy; + } + + private static int typeOrder(String photoType) { + if ("before".equals(photoType)) { + return 0; + } + if ("during".equals(photoType)) { + return 1; + } + if ("after".equals(photoType)) { + return 2; + } + return 3; + } + + private boolean isVideoMedia(ReportImage img) { + String mt = img.getMediaType(); + if (mt != null && mt.toLowerCase(Locale.ROOT).contains("video")) { + return true; + } + String u = img.getPhotoUrl(); + if (u == null) { + return false; + } + String lower = u.toLowerCase(Locale.ROOT); + return lower.endsWith(".mp4") || lower.endsWith(".mov") || lower.endsWith(".m4v") + || lower.endsWith(".webm") || lower.endsWith(".avi") || lower.endsWith(".mkv") + || lower.endsWith(".3gp"); + } + + private Path resolveUploadFile(String photoUrl) { + if (photoUrl == null || photoUrl.isBlank()) { + return null; + } + String p = photoUrl.trim(); + try { + if (p.startsWith("http://") || p.startsWith("https://")) { + URI uri = URI.create(p); + p = uri.getPath(); + } + } catch (Exception ignored) { + } + String rel = null; + if (p.startsWith("/api/upload/image/")) { + rel = p.substring("/api/upload/image/".length()); + } else if (p.startsWith("/api/upload/legacy/")) { + rel = p.substring("/api/upload/legacy/".length()); + } + if (rel == null || rel.isBlank()) { + return null; + } + String rawBase = uploadPath.endsWith("/") ? uploadPath.substring(0, uploadPath.length() - 1) : uploadPath; + Path base = Paths.get(rawBase).toAbsolutePath().normalize(); + Path full = base.resolve(rel).normalize(); + if (!full.startsWith(base)) { + return null; + } + return full; + } + + private boolean canOperate(Long operatorUserId, String role, Report report) { + String r = role == null ? "" : role.trim(); + if ("boss".equals(r) || "staff".equals(r)) { + Optional u = userMapper.findByIdAndDeletedFalse(operatorUserId); + if (u.isEmpty()) { + return false; + } + Long sid = u.get().getStoreId(); + return sid != null && sid.equals(report.getStoreId()); + } + if ("customer".equals(r)) { + Long apptId = report.getAppointmentId(); + if (apptId == null) { + return false; + } + Optional ap = appointmentMapper.findByIdAndDeletedFalse(apptId); + return ap.isPresent() && operatorUserId.equals(ap.get().getUserId()); + } + return false; + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index d12afff..92c4f65 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -44,6 +44,11 @@ logging: level: com.petstore: debug +# 服务回顾短片:本地 ffmpeg 拼接(可换为云端生成式视频 API) +app: + highlight-video: + ffmpeg-binary: ${HIGHLIGHT_FFMPEG:ffmpeg} + # 微信小程序(与 manifest / 微信后台一致;生产环境建议用环境变量覆盖) wechat: appid: ${WECHAT_APPID:wx8ca2dfa89af72edf}