feat: 服务回顾短片后端支持(ffmpeg合成)

This commit is contained in:
MaDaLei 2026-04-17 18:58:04 +08:00
parent cd1555ef0a
commit ce21bad400
5 changed files with 486 additions and 0 deletions

View File

@ -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;
}
}

View File

@ -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<String, Object> 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<String, Object> startHighlight(@RequestBody Map<String, Object> 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<String, Object> 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 {

View File

@ -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;
}

View File

@ -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
* <p>依赖本机安装 {@code ffmpeg}后续可在此类接入云端生成式视频API与本地管线切换</p>
*/
@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<Long, Object> 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<String, Object> 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<Report> 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<ReportImage> 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<Report> opt = reportMapper.findByIdAndDeletedFalse(reportId);
if (opt.isEmpty()) {
return;
}
List<ReportImage> imgs = sortedImages(reportImageMapper.findByReportIdOrderBySortOrderAscIdAsc(reportId));
List<Path> sources = new ArrayList<>();
List<Boolean> 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<Path> 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<ReportImage> 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<String> 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<ReportImage> sortedImages(List<ReportImage> imgs) {
if (imgs == null) {
return List.of();
}
List<ReportImage> 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<User> 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<Appointment> ap = appointmentMapper.findByIdAndDeletedFalse(apptId);
return ap.isPresent() && operatorUserId.equals(ap.get().getUserId());
}
return false;
}
}

View File

@ -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}