更新 2026-04-18 18:32
This commit is contained in:
parent
afcd39011a
commit
e3b95b45b7
@ -1,5 +1,6 @@
|
|||||||
package com.petstore.controller;
|
package com.petstore.controller;
|
||||||
|
|
||||||
|
import com.petstore.entity.HighlightFailReason;
|
||||||
import com.petstore.entity.Report;
|
import com.petstore.entity.Report;
|
||||||
import com.petstore.entity.ReportImage;
|
import com.petstore.entity.ReportImage;
|
||||||
import com.petstore.entity.Store;
|
import com.petstore.entity.Store;
|
||||||
@ -60,9 +61,25 @@ public class ReportController {
|
|||||||
target.put("highlightVideoStatus", r.getHighlightVideoStatus());
|
target.put("highlightVideoStatus", r.getHighlightVideoStatus());
|
||||||
target.put("highlightVideoError", r.getHighlightVideoError());
|
target.put("highlightVideoError", r.getHighlightVideoError());
|
||||||
target.put("highlightDurationSec", r.getHighlightDurationSec());
|
target.put("highlightDurationSec", r.getHighlightDurationSec());
|
||||||
|
target.put("highlightComposeMode", r.getHighlightComposeMode());
|
||||||
|
String failCode = r.getHighlightFailReason();
|
||||||
|
target.put("highlightFailReason", failCode);
|
||||||
|
target.put("highlightFailReasonLabel", resolveHighlightFailLabel(failCode));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 触发服务回顾短片生成(异步;依赖服务器 ffmpeg) */
|
private static String resolveHighlightFailLabel(String code) {
|
||||||
|
if (code == null || code.isBlank()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
for (HighlightFailReason fr : HighlightFailReason.values()) {
|
||||||
|
if (fr.getCode().equalsIgnoreCase(code)) {
|
||||||
|
return fr.getShortLabel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 触发服务回顾短片生成(异步;依赖服务器 ffmpeg)。composeMode:preset | interleave;时长由素材自动计算(有上限)。 */
|
||||||
@PostMapping(value = "/highlight/start", produces = MediaType.APPLICATION_JSON_VALUE + ";charset=UTF-8")
|
@PostMapping(value = "/highlight/start", produces = MediaType.APPLICATION_JSON_VALUE + ";charset=UTF-8")
|
||||||
public Map<String, Object> startHighlight(@RequestBody Map<String, Object> body) {
|
public Map<String, Object> startHighlight(@RequestBody Map<String, Object> body) {
|
||||||
if (body.get("reportId") == null || body.get("operatorUserId") == null || body.get("role") == null) {
|
if (body.get("reportId") == null || body.get("operatorUserId") == null || body.get("role") == null) {
|
||||||
@ -71,11 +88,8 @@ public class ReportController {
|
|||||||
Long reportId = Long.valueOf(body.get("reportId").toString());
|
Long reportId = Long.valueOf(body.get("reportId").toString());
|
||||||
Long operatorUserId = Long.valueOf(body.get("operatorUserId").toString());
|
Long operatorUserId = Long.valueOf(body.get("operatorUserId").toString());
|
||||||
String role = body.get("role").toString();
|
String role = body.get("role").toString();
|
||||||
int durationSec = 15;
|
String composeMode = body.get("composeMode") == null ? "preset" : body.get("composeMode").toString().trim();
|
||||||
if (body.get("durationSec") != null) {
|
return reportHighlightVideoService.requestGenerate(reportId, operatorUserId, role, composeMode);
|
||||||
durationSec = Integer.parseInt(body.get("durationSec").toString());
|
|
||||||
}
|
|
||||||
return reportHighlightVideoService.requestGenerate(reportId, durationSec, operatorUserId, role);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/create")
|
@PostMapping("/create")
|
||||||
|
|||||||
@ -46,12 +46,14 @@ public class ReportLeadController {
|
|||||||
return Map.of("code", 400, "message", "需要勾选同意接收服务提醒");
|
return Map.of("code", 400, "message", "需要勾选同意接收服务提醒");
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
ReportLead lead = reportLeadService.submit(token, phone, extractClientIp(request), loginCode);
|
ReportLeadService.LeadSubmitOutcome outcome = reportLeadService.submit(token, phone, extractClientIp(request), loginCode);
|
||||||
|
ReportLead lead = outcome.lead();
|
||||||
Map<String, Object> data = new HashMap<>();
|
Map<String, Object> data = new HashMap<>();
|
||||||
data.put("ok", true);
|
data.put("ok", true);
|
||||||
data.put("leadId", lead.getId());
|
data.put("leadId", lead.getId());
|
||||||
data.put("remindDate", lead.getRemindDate() == null ? null : lead.getRemindDate().toString());
|
data.put("remindDate", lead.getRemindDate() == null ? null : lead.getRemindDate().toString());
|
||||||
data.put("unsubscribeToken", lead.getUnsubscribeToken());
|
data.put("unsubscribeToken", lead.getUnsubscribeToken());
|
||||||
|
data.put("wechatBound", outcome.wechatBound());
|
||||||
return Map.of("code", 200, "data", data);
|
return Map.of("code", 200, "data", data);
|
||||||
} catch (IllegalArgumentException e) {
|
} catch (IllegalArgumentException e) {
|
||||||
return Map.of("code", 400, "message", e.getMessage());
|
return Map.of("code", 400, "message", e.getMessage());
|
||||||
|
|||||||
45
src/main/java/com/petstore/entity/HighlightFailReason.java
Normal file
45
src/main/java/com/petstore/entity/HighlightFailReason.java
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
package com.petstore.entity;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 报告短片生成失败时对宠主展示的原因归类(不含技术细节)。
|
||||||
|
*/
|
||||||
|
public enum HighlightFailReason {
|
||||||
|
/** 素材缺失、无法读取或编码失败(单段) */
|
||||||
|
MATERIAL("material"),
|
||||||
|
/** 合成服务异常(ffmpeg 拼接/转码、磁盘等) */
|
||||||
|
SERVICE("service"),
|
||||||
|
/** 网络类异常(极少见于服务端,预留) */
|
||||||
|
NETWORK("network"),
|
||||||
|
/** 未归类 */
|
||||||
|
UNKNOWN("unknown");
|
||||||
|
|
||||||
|
private final String code;
|
||||||
|
|
||||||
|
HighlightFailReason(String code) {
|
||||||
|
this.code = code;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getCode() {
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 对宠主展示的说明(中文,无堆栈、无英文技术术语) */
|
||||||
|
public String getUserMessage() {
|
||||||
|
return switch (this) {
|
||||||
|
case MATERIAL -> "部分照片或视频无法用于合成,请调整素材后重新生成。";
|
||||||
|
case SERVICE -> "短片合成服务暂时不可用,请稍后重试。若多次失败请联系门店。";
|
||||||
|
case NETWORK -> "网络异常,请检查网络连接后重试。";
|
||||||
|
case UNKNOWN -> "短片生成失败,请稍后重试或联系门店。";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 短标题,供前端与 reason 并列展示 */
|
||||||
|
public String getShortLabel() {
|
||||||
|
return switch (this) {
|
||||||
|
case MATERIAL -> "素材问题";
|
||||||
|
case SERVICE -> "服务不可用";
|
||||||
|
case NETWORK -> "网络异常";
|
||||||
|
case UNKNOWN -> "生成失败";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -67,4 +67,12 @@ public class Report {
|
|||||||
|
|
||||||
@Column(name = "highlight_duration_sec")
|
@Column(name = "highlight_duration_sec")
|
||||||
private Integer highlightDurationSec;
|
private Integer highlightDurationSec;
|
||||||
|
|
||||||
|
/** 短片编排:preset=对比封面+分段顺序;interleave=全局照片/视频穿插 */
|
||||||
|
@Column(name = "highlight_compose_mode", length = 16)
|
||||||
|
private String highlightComposeMode;
|
||||||
|
|
||||||
|
/** 失败原因归类:material | service | network | unknown(对宠主展示,无技术细节) */
|
||||||
|
@Column(name = "highlight_fail_reason", length = 16)
|
||||||
|
private String highlightFailReason;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
package com.petstore.service;
|
package com.petstore.service;
|
||||||
|
|
||||||
import com.petstore.entity.Appointment;
|
import com.petstore.entity.Appointment;
|
||||||
|
import com.petstore.entity.HighlightFailReason;
|
||||||
import com.petstore.entity.Report;
|
import com.petstore.entity.Report;
|
||||||
import com.petstore.entity.ReportImage;
|
import com.petstore.entity.ReportImage;
|
||||||
import com.petstore.entity.User;
|
import com.petstore.entity.User;
|
||||||
@ -34,14 +35,21 @@ import java.util.concurrent.ConcurrentHashMap;
|
|||||||
import java.util.concurrent.Executor;
|
import java.util.concurrent.Executor;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 将服务报告中的服务前 / 过程中 / 服务后图片与短视频,按「照片与视频穿插」编排后拼接为竖屏短片(默认 15s 或 30s)。
|
* 将服务报告素材拼为竖屏短片,时长按素材量自动计算(受 {@code max-duration-sec} 上限约束)。
|
||||||
* <p>图片段使用轻微 Ken Burns(缩放平移);视频段保留原音(无音轨时补静音)。可选配置文件路径叠加 BGM。</p>
|
* <p><b>preset</b> 预设顺序:</p>
|
||||||
* <p>依赖本机安装 {@code ffmpeg};可选 {@code ffprobe} 用于读取视频时长与是否含音轨。</p>
|
* <ol>
|
||||||
|
* <li>封面:服务前第一张 + 服务后第一张 横向对比图(均为照片且文件存在时);</li>
|
||||||
|
* <li>服务前其余照片(上传顺序);</li>
|
||||||
|
* <li>过程中照片与视频(上传顺序);</li>
|
||||||
|
* <li>服务后除第一张外的其余照片;</li>
|
||||||
|
* <li>收尾:服务后第一张再次作为最后一镜。</li>
|
||||||
|
* </ol>
|
||||||
|
* <p><b>interleave</b>:在「服务前→过程→服务后」排序后,将全部照片与全部小视频均匀穿插(组间插入)。</p>
|
||||||
|
* <p>图片段 Ken Burns;视频段保留原音(无音轨补静音)。可选 BGM。</p>
|
||||||
*/
|
*/
|
||||||
@Service
|
@Service
|
||||||
public class ReportHighlightVideoService {
|
public class ReportHighlightVideoService {
|
||||||
|
|
||||||
private static final int[] ALLOWED_DURATIONS = {15, 30};
|
|
||||||
private static final int OUTPUT_FPS = 30;
|
private static final int OUTPUT_FPS = 30;
|
||||||
|
|
||||||
private final ReportMapper reportMapper;
|
private final ReportMapper reportMapper;
|
||||||
@ -66,10 +74,14 @@ public class ReportHighlightVideoService {
|
|||||||
@Value("${app.highlight-video.image-segment-sec:2.0}")
|
@Value("${app.highlight-video.image-segment-sec:2.0}")
|
||||||
private double imageSegmentSec;
|
private double imageSegmentSec;
|
||||||
|
|
||||||
/** 单段小视频最多取用时长(秒),实际还会按总时长预算缩放 */
|
/** 单段小视频最多截取的时长(秒) */
|
||||||
@Value("${app.highlight-video.video-cap-sec:8.0}")
|
@Value("${app.highlight-video.video-cap-sec:8.0}")
|
||||||
private double videoCapSec;
|
private double videoCapSec;
|
||||||
|
|
||||||
|
/** 成片最大总时长(秒);素材自然时长超过时整体等比压缩 */
|
||||||
|
@Value("${app.highlight-video.max-duration-sec:300}")
|
||||||
|
private double maxDurationSec;
|
||||||
|
|
||||||
/** 可选:本地绝对路径,商用授权 BGM(mp3/aac/m4a 等 ffmpeg 可读格式) */
|
/** 可选:本地绝对路径,商用授权 BGM(mp3/aac/m4a 等 ffmpeg 可读格式) */
|
||||||
@Value("${app.highlight-video.bgm-path:}")
|
@Value("${app.highlight-video.bgm-path:}")
|
||||||
private String bgmPath;
|
private String bgmPath;
|
||||||
@ -96,19 +108,16 @@ public class ReportHighlightVideoService {
|
|||||||
return reportLocks.computeIfAbsent(reportId, k -> new Object());
|
return reportLocks.computeIfAbsent(reportId, k -> new Object());
|
||||||
}
|
}
|
||||||
|
|
||||||
public Map<String, Object> requestGenerate(Long reportId, int durationSec, Long operatorUserId, String role) {
|
/**
|
||||||
|
* @param composeModeRaw {@code preset} 预设顺序;{@code interleave} 穿插(亦接受 ordered / spread)
|
||||||
|
*/
|
||||||
|
public Map<String, Object> requestGenerate(Long reportId, Long operatorUserId, String role, String composeModeRaw) {
|
||||||
if (reportId == null || operatorUserId == null || role == null || role.isBlank()) {
|
if (reportId == null || operatorUserId == null || role == null || role.isBlank()) {
|
||||||
return Map.of("code", 400, "message", "参数不完整");
|
return Map.of("code", 400, "message", "参数不完整");
|
||||||
}
|
}
|
||||||
boolean okDur = false;
|
String mode = normalizeComposeMode(composeModeRaw);
|
||||||
for (int d : ALLOWED_DURATIONS) {
|
if (mode == null) {
|
||||||
if (d == durationSec) {
|
return Map.of("code", 400, "message", "composeMode 仅支持 preset(预设顺序)或 interleave(穿插)");
|
||||||
okDur = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!okDur) {
|
|
||||||
return Map.of("code", 400, "message", "durationSec 仅支持 15 或 30");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
synchronized (lockFor(reportId)) {
|
synchronized (lockFor(reportId)) {
|
||||||
@ -133,80 +142,162 @@ public class ReportHighlightVideoService {
|
|||||||
|
|
||||||
report.setHighlightVideoStatus("processing");
|
report.setHighlightVideoStatus("processing");
|
||||||
report.setHighlightVideoError(null);
|
report.setHighlightVideoError(null);
|
||||||
report.setHighlightDurationSec(durationSec);
|
report.setHighlightFailReason(null);
|
||||||
|
report.setHighlightComposeMode(mode);
|
||||||
report.setUpdateTime(LocalDateTime.now());
|
report.setUpdateTime(LocalDateTime.now());
|
||||||
reportMapper.save(report);
|
reportMapper.save(report);
|
||||||
|
|
||||||
int d = durationSec;
|
highlightExecutor.execute(() -> composeSafely(reportId, mode));
|
||||||
highlightExecutor.execute(() -> composeSafely(reportId, d));
|
return Map.of("code", 200, "message", "已开始生成", "data", Map.of("status", "processing", "composeMode", mode));
|
||||||
return Map.of("code", 200, "message", "已开始生成", "data", Map.of("status", "processing"));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void composeSafely(Long reportId, int durationSec) {
|
private static String normalizeComposeMode(String raw) {
|
||||||
|
if (raw == null || raw.isBlank()) {
|
||||||
|
return "preset";
|
||||||
|
}
|
||||||
|
String s = raw.trim().toLowerCase(Locale.ROOT);
|
||||||
|
if ("preset".equals(s) || "ordered".equals(s)) {
|
||||||
|
return "preset";
|
||||||
|
}
|
||||||
|
if ("interleave".equals(s) || "spread".equals(s)) {
|
||||||
|
return "interleave";
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void composeSafely(Long reportId, String composeMode) {
|
||||||
try {
|
try {
|
||||||
composeInternal(reportId, durationSec);
|
composeInternal(reportId, composeMode);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
markFailed(reportId, e.getMessage() != null ? e.getMessage() : "合成失败");
|
markFailed(reportId, classifyFailure(e));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void markFailed(Long reportId, String err) {
|
/**
|
||||||
|
* 将异常映射为宠主可见的失败类型(不向用户暴露英文、退出码与堆栈)。
|
||||||
|
*/
|
||||||
|
private static HighlightFailReason classifyFailure(Throwable e) {
|
||||||
|
Throwable cur = e;
|
||||||
|
int depth = 0;
|
||||||
|
while (cur != null && depth++ < 8) {
|
||||||
|
if (cur instanceof java.net.SocketTimeoutException
|
||||||
|
|| cur instanceof java.net.UnknownHostException
|
||||||
|
|| cur instanceof java.net.ConnectException) {
|
||||||
|
return HighlightFailReason.NETWORK;
|
||||||
|
}
|
||||||
|
cur = cur.getCause();
|
||||||
|
}
|
||||||
|
String m = e.getMessage() != null ? e.getMessage() : "";
|
||||||
|
if (m.contains("素材") || m.contains("不存在") || m.contains("无法读取")) {
|
||||||
|
return HighlightFailReason.MATERIAL;
|
||||||
|
}
|
||||||
|
if (m.contains("图片转视频") || m.contains("视频片段重编码")) {
|
||||||
|
return HighlightFailReason.MATERIAL;
|
||||||
|
}
|
||||||
|
if (m.contains("ffmpeg") || m.contains("拼接失败") || m.contains("退出码")) {
|
||||||
|
return HighlightFailReason.SERVICE;
|
||||||
|
}
|
||||||
|
if (m.contains("分段时长")) {
|
||||||
|
return HighlightFailReason.SERVICE;
|
||||||
|
}
|
||||||
|
if (e instanceof java.io.IOException) {
|
||||||
|
return HighlightFailReason.SERVICE;
|
||||||
|
}
|
||||||
|
return HighlightFailReason.UNKNOWN;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void markFailed(Long reportId, HighlightFailReason reason) {
|
||||||
synchronized (lockFor(reportId)) {
|
synchronized (lockFor(reportId)) {
|
||||||
reportMapper.findByIdAndDeletedFalse(reportId).ifPresent(r -> {
|
reportMapper.findByIdAndDeletedFalse(reportId).ifPresent(r -> {
|
||||||
r.setHighlightVideoStatus("failed");
|
r.setHighlightVideoStatus("failed");
|
||||||
r.setHighlightVideoError(err != null && err.length() > 500 ? err.substring(0, 500) : err);
|
r.setHighlightFailReason(reason.getCode());
|
||||||
|
String msg = reason.getUserMessage();
|
||||||
|
r.setHighlightVideoError(msg.length() > 500 ? msg.substring(0, 500) : msg);
|
||||||
r.setUpdateTime(LocalDateTime.now());
|
r.setUpdateTime(LocalDateTime.now());
|
||||||
reportMapper.save(r);
|
reportMapper.save(r);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void composeInternal(Long reportId, int durationSec) throws Exception {
|
private void composeInternal(Long reportId, String composeMode) throws Exception {
|
||||||
Optional<Report> opt = reportMapper.findByIdAndDeletedFalse(reportId);
|
Optional<Report> opt = reportMapper.findByIdAndDeletedFalse(reportId);
|
||||||
if (opt.isEmpty()) {
|
if (opt.isEmpty()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
List<ReportImage> ordered = sortedImages(reportImageMapper.findByReportIdOrderBySortOrderAscIdAsc(reportId));
|
List<ReportImage> ordered = sortedImages(reportImageMapper.findByReportIdOrderBySortOrderAscIdAsc(reportId));
|
||||||
List<ReportImage> photos = new ArrayList<>();
|
List<ReportImage> before = filterByPhotoType(ordered, "before");
|
||||||
List<ReportImage> videos = new ArrayList<>();
|
List<ReportImage> during = filterByPhotoType(ordered, "during");
|
||||||
for (ReportImage img : ordered) {
|
List<ReportImage> after = filterByPhotoType(ordered, "after");
|
||||||
if (isVideoMedia(img)) {
|
|
||||||
videos.add(img);
|
|
||||||
} else {
|
|
||||||
photos.add(img);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
List<ReportImage> timeline = interleaveSpread(photos, videos);
|
|
||||||
|
|
||||||
List<Path> sources = new ArrayList<>();
|
|
||||||
List<Boolean> isVideo = new ArrayList<>();
|
|
||||||
for (ReportImage img : timeline) {
|
|
||||||
Path p = resolveUploadFile(img.getPhotoUrl());
|
|
||||||
if (p == null || !Files.isRegularFile(p)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
sources.add(p);
|
|
||||||
isVideo.add(isVideoMedia(img));
|
|
||||||
}
|
|
||||||
if (sources.isEmpty()) {
|
|
||||||
markFailed(reportId, "素材文件在服务器上不存在或无法读取");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
int n = sources.size();
|
|
||||||
double[] segmentSecs = computeSegmentDurations(sources, isVideo, durationSec);
|
|
||||||
double sumSeg = 0;
|
|
||||||
for (double s : segmentSecs) {
|
|
||||||
sumSeg += s;
|
|
||||||
}
|
|
||||||
if (sumSeg < 0.5) {
|
|
||||||
markFailed(reportId, "分段时长计算异常");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
Path workDir = Files.createTempDirectory("highlight_" + reportId + "_");
|
Path workDir = Files.createTempDirectory("highlight_" + reportId + "_");
|
||||||
try {
|
try {
|
||||||
|
List<Path> sources = new ArrayList<>();
|
||||||
|
List<Boolean> isVideo = new ArrayList<>();
|
||||||
|
|
||||||
|
if ("interleave".equals(composeMode)) {
|
||||||
|
List<ReportImage> photos = new ArrayList<>();
|
||||||
|
List<ReportImage> videos = new ArrayList<>();
|
||||||
|
for (ReportImage img : ordered) {
|
||||||
|
if (isVideoMedia(img)) {
|
||||||
|
videos.add(img);
|
||||||
|
} else {
|
||||||
|
photos.add(img);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (ReportImage img : interleaveSpread(photos, videos)) {
|
||||||
|
appendResolvedMedia(sources, isVideo, img);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
boolean coverAdded = false;
|
||||||
|
if (!before.isEmpty() && !after.isEmpty()) {
|
||||||
|
ReportImage b0 = before.get(0);
|
||||||
|
ReportImage a0 = after.get(0);
|
||||||
|
if (!isVideoMedia(b0) && !isVideoMedia(a0)) {
|
||||||
|
Path pb = resolveUploadFile(b0.getPhotoUrl());
|
||||||
|
Path pa = resolveUploadFile(a0.getPhotoUrl());
|
||||||
|
if (pb != null && pa != null && Files.isRegularFile(pb) && Files.isRegularFile(pa)) {
|
||||||
|
Path cover = workDir.resolve("cover_compare.jpg");
|
||||||
|
if (buildComparisonCover(pb, pa, cover)) {
|
||||||
|
sources.add(cover);
|
||||||
|
isVideo.add(false);
|
||||||
|
coverAdded = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int bStart = coverAdded ? 1 : 0;
|
||||||
|
for (int i = bStart; i < before.size(); i++) {
|
||||||
|
appendResolvedMedia(sources, isVideo, before.get(i));
|
||||||
|
}
|
||||||
|
for (ReportImage d : during) {
|
||||||
|
appendResolvedMedia(sources, isVideo, d);
|
||||||
|
}
|
||||||
|
for (int i = 1; i < after.size(); i++) {
|
||||||
|
appendResolvedMedia(sources, isVideo, after.get(i));
|
||||||
|
}
|
||||||
|
if (!after.isEmpty()) {
|
||||||
|
appendResolvedMedia(sources, isVideo, after.get(0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sources.isEmpty()) {
|
||||||
|
markFailed(reportId, HighlightFailReason.MATERIAL);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
int n = sources.size();
|
||||||
|
double[] segmentSecs = computeSegmentDurationsNatural(sources, isVideo);
|
||||||
|
double totalDurationSec = 0;
|
||||||
|
for (double s : segmentSecs) {
|
||||||
|
totalDurationSec += s;
|
||||||
|
}
|
||||||
|
if (totalDurationSec < 0.5) {
|
||||||
|
markFailed(reportId, HighlightFailReason.SERVICE);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
List<Path> segments = new ArrayList<>();
|
List<Path> segments = new ArrayList<>();
|
||||||
for (int i = 0; i < n; i++) {
|
for (int i = 0; i < n; i++) {
|
||||||
Path seg = workDir.resolve("seg_" + i + ".mp4");
|
Path seg = workDir.resolve("seg_" + i + ".mp4");
|
||||||
@ -236,6 +327,7 @@ public class ReportHighlightVideoService {
|
|||||||
"-c", "copy", merged.toString()
|
"-c", "copy", merged.toString()
|
||||||
));
|
));
|
||||||
if (c1 != 0) {
|
if (c1 != 0) {
|
||||||
|
String tStr = String.format(Locale.ROOT, "%.3f", totalDurationSec);
|
||||||
int c2 = runFfmpeg(List.of(
|
int c2 = runFfmpeg(List.of(
|
||||||
ffmpegBinary, "-y", "-f", "concat", "-safe", "0", "-i", listFile.toString(),
|
ffmpegBinary, "-y", "-f", "concat", "-safe", "0", "-i", listFile.toString(),
|
||||||
"-vf", "scale=1080:1920:force_original_aspect_ratio=decrease,pad=1080:1920:(ow-iw)/2:(oh-ih)/2",
|
"-vf", "scale=1080:1920:force_original_aspect_ratio=decrease,pad=1080:1920:(ow-iw)/2:(oh-ih)/2",
|
||||||
@ -243,7 +335,7 @@ public class ReportHighlightVideoService {
|
|||||||
"-profile:v", "baseline", "-level", "3.0",
|
"-profile:v", "baseline", "-level", "3.0",
|
||||||
"-c:a", "aac", "-b:a", "128k",
|
"-c:a", "aac", "-b:a", "128k",
|
||||||
"-movflags", "+faststart",
|
"-movflags", "+faststart",
|
||||||
"-t", String.valueOf(durationSec),
|
"-t", tStr,
|
||||||
merged.toString()
|
merged.toString()
|
||||||
));
|
));
|
||||||
if (c2 != 0) {
|
if (c2 != 0) {
|
||||||
@ -267,12 +359,14 @@ public class ReportHighlightVideoService {
|
|||||||
String publicBase = baseUrl.endsWith("/") ? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl;
|
String publicBase = baseUrl.endsWith("/") ? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl;
|
||||||
String url = publicBase + "/api/upload/image/" + datePath + "/" + filename;
|
String url = publicBase + "/api/upload/image/" + datePath + "/" + filename;
|
||||||
|
|
||||||
|
int durationRoundedSec = (int) Math.ceil(totalDurationSec);
|
||||||
synchronized (lockFor(reportId)) {
|
synchronized (lockFor(reportId)) {
|
||||||
reportMapper.findByIdAndDeletedFalse(reportId).ifPresent(r -> {
|
reportMapper.findByIdAndDeletedFalse(reportId).ifPresent(r -> {
|
||||||
r.setHighlightVideoUrl(url);
|
r.setHighlightVideoUrl(url);
|
||||||
r.setHighlightVideoStatus("done");
|
r.setHighlightVideoStatus("done");
|
||||||
r.setHighlightVideoError(null);
|
r.setHighlightVideoError(null);
|
||||||
r.setHighlightDurationSec(durationSec);
|
r.setHighlightFailReason(null);
|
||||||
|
r.setHighlightDurationSec(durationRoundedSec);
|
||||||
r.setUpdateTime(LocalDateTime.now());
|
r.setUpdateTime(LocalDateTime.now());
|
||||||
reportMapper.save(r);
|
reportMapper.save(r);
|
||||||
});
|
});
|
||||||
@ -293,7 +387,7 @@ public class ReportHighlightVideoService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 将 np 张照片均匀拆成 nv+1 组,组与组之间插入视频,实现「穿插」编排(类似相册成片)。
|
* 将照片均匀拆成 nv+1 组,组间插入小视频(全局「穿插」模式使用)。
|
||||||
*/
|
*/
|
||||||
private static List<ReportImage> interleaveSpread(List<ReportImage> photos, List<ReportImage> videos) {
|
private static List<ReportImage> interleaveSpread(List<ReportImage> photos, List<ReportImage> videos) {
|
||||||
int np = photos.size();
|
int np = photos.size();
|
||||||
@ -332,7 +426,51 @@ public class ReportHighlightVideoService {
|
|||||||
return a;
|
return a;
|
||||||
}
|
}
|
||||||
|
|
||||||
private double[] computeSegmentDurations(List<Path> sources, List<Boolean> isVideo, int totalSec) {
|
private static List<ReportImage> filterByPhotoType(List<ReportImage> imgs, String photoType) {
|
||||||
|
List<ReportImage> out = new ArrayList<>();
|
||||||
|
if (imgs == null) {
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
for (ReportImage img : imgs) {
|
||||||
|
if (photoType.equals(img.getPhotoType())) {
|
||||||
|
out.add(img);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void appendResolvedMedia(List<Path> sources, List<Boolean> isVideo, ReportImage img) {
|
||||||
|
Path p = resolveUploadFile(img.getPhotoUrl());
|
||||||
|
if (p == null || !Files.isRegularFile(p)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sources.add(p);
|
||||||
|
isVideo.add(isVideoMedia(img));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 服务前第一张 | 服务后第一张 左右拼接为 1080×1920 竖屏对比图(左前右后)。
|
||||||
|
*/
|
||||||
|
private boolean buildComparisonCover(Path beforeFile, Path afterFile, Path outJpg) throws Exception {
|
||||||
|
String fc = "[0:v]scale=540:1920:force_original_aspect_ratio=decrease,pad=540:1920:(ow-iw)/2:(oh-ih)/2,setsar=1[va];"
|
||||||
|
+ "[1:v]scale=540:1920:force_original_aspect_ratio=decrease,pad=540:1920:(ow-iw)/2:(oh-ih)/2,setsar=1[vb];"
|
||||||
|
+ "[va][vb]hstack=inputs=2,format=yuv420p[v]";
|
||||||
|
int code = runFfmpeg(List.of(
|
||||||
|
ffmpegBinary, "-y",
|
||||||
|
"-i", beforeFile.toString(),
|
||||||
|
"-i", afterFile.toString(),
|
||||||
|
"-filter_complex", fc,
|
||||||
|
"-map", "[v]", "-frames:v", "1",
|
||||||
|
"-q:v", "2",
|
||||||
|
outJpg.toString()
|
||||||
|
));
|
||||||
|
return code == 0 && Files.isRegularFile(outJpg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按图片固定秒数 + 视频实长(封顶)得到自然总长;超过 {@link #maxDurationSec} 时整体等比压缩。
|
||||||
|
*/
|
||||||
|
private double[] computeSegmentDurationsNatural(List<Path> sources, List<Boolean> isVideo) {
|
||||||
int n = sources.size();
|
int n = sources.size();
|
||||||
double[] raw = new double[n];
|
double[] raw = new double[n];
|
||||||
double sum = 0;
|
double sum = 0;
|
||||||
@ -349,25 +487,24 @@ public class ReportHighlightVideoService {
|
|||||||
sum += raw[i];
|
sum += raw[i];
|
||||||
}
|
}
|
||||||
if (sum <= 0) {
|
if (sum <= 0) {
|
||||||
double eq = (double) totalSec / n;
|
|
||||||
for (int i = 0; i < n; i++) {
|
for (int i = 0; i < n; i++) {
|
||||||
raw[i] = eq;
|
raw[i] = 1.0;
|
||||||
}
|
}
|
||||||
return raw;
|
sum = n;
|
||||||
}
|
}
|
||||||
double scale = totalSec / sum;
|
double targetTotal = Math.min(sum, maxDurationSec);
|
||||||
|
double scale = targetTotal / sum;
|
||||||
double[] out = new double[n];
|
double[] out = new double[n];
|
||||||
double rounded = 0;
|
double acc = 0;
|
||||||
for (int i = 0; i < n; i++) {
|
for (int i = 0; i < n; i++) {
|
||||||
out[i] = raw[i] * scale;
|
out[i] = raw[i] * scale;
|
||||||
if (out[i] < 0.35) {
|
if (out[i] < 0.35) {
|
||||||
out[i] = 0.35;
|
out[i] = 0.35;
|
||||||
}
|
}
|
||||||
rounded += out[i];
|
acc += out[i];
|
||||||
}
|
}
|
||||||
// 修正浮点误差,使总长接近 totalSec
|
if (n > 0) {
|
||||||
if (n > 0 && Math.abs(rounded - totalSec) > 0.05) {
|
out[n - 1] = Math.max(0.35, out[n - 1] + (targetTotal - acc));
|
||||||
out[n - 1] = Math.max(0.35, out[n - 1] + (totalSec - rounded));
|
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -28,8 +28,12 @@ public class ReportLeadService {
|
|||||||
private final ReportMapper reportMapper;
|
private final ReportMapper reportMapper;
|
||||||
private final ServiceIntervalMapper serviceIntervalMapper;
|
private final ServiceIntervalMapper serviceIntervalMapper;
|
||||||
private final UserMapper userMapper;
|
private final UserMapper userMapper;
|
||||||
|
private final UserService userService;
|
||||||
private final WechatMiniProgramService wechatMiniProgramService;
|
private final WechatMiniProgramService wechatMiniProgramService;
|
||||||
|
|
||||||
|
/** 留资提交结果:记录 + 是否已将微信 openid 绑定到宠主账号(S3→S4) */
|
||||||
|
public record LeadSubmitOutcome(ReportLead lead, boolean wechatBound) {}
|
||||||
|
|
||||||
/** 当服务类型不在已配置表中时使用的兜底间隔(天) */
|
/** 当服务类型不在已配置表中时使用的兜底间隔(天) */
|
||||||
private static final int FALLBACK_MIN = 28;
|
private static final int FALLBACK_MIN = 28;
|
||||||
private static final int FALLBACK_MAX = 35;
|
private static final int FALLBACK_MAX = 35;
|
||||||
@ -108,7 +112,7 @@ public class ReportLeadService {
|
|||||||
*
|
*
|
||||||
* @param loginCode 小程序 uni.login 的 code,服务端 jscode2session 取 openid/unionid 写入留资;H5 可空。
|
* @param loginCode 小程序 uni.login 的 code,服务端 jscode2session 取 openid/unionid 写入留资;H5 可空。
|
||||||
*/
|
*/
|
||||||
public ReportLead submit(String reportToken, String phone, String consentIp, String loginCode) {
|
public LeadSubmitOutcome submit(String reportToken, String phone, String consentIp, String loginCode) {
|
||||||
Report report = reportMapper.findFirstByReportTokenAndDeletedFalse(reportToken).orElse(null);
|
Report report = reportMapper.findFirstByReportTokenAndDeletedFalse(reportToken).orElse(null);
|
||||||
if (report == null) {
|
if (report == null) {
|
||||||
throw new IllegalArgumentException("报告不存在或已失效");
|
throw new IllegalArgumentException("报告不存在或已失效");
|
||||||
@ -165,7 +169,18 @@ public class ReportLeadService {
|
|||||||
lead.setWechatUnionid(uid);
|
lead.setWechatUnionid(uid);
|
||||||
}
|
}
|
||||||
|
|
||||||
return reportLeadMapper.save(lead);
|
lead = reportLeadMapper.save(lead);
|
||||||
|
|
||||||
|
boolean wechatBound = false;
|
||||||
|
if (oid != null && !oid.isBlank()) {
|
||||||
|
User u = userService.ensureCustomerByPhone(phone);
|
||||||
|
if (u != null && "customer".equals(u.getRole())) {
|
||||||
|
User after = userService.bindWechatMiniIdentity(u.getId(), oid, uid);
|
||||||
|
wechatBound = after != null && after.getWechatOpenid() != null && !after.getWechatOpenid().isBlank();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new LeadSubmitOutcome(lead, wechatBound);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 退订:按 unsubscribeToken 找记录并置状态 */
|
/** 退订:按 unsubscribeToken 找记录并置状态 */
|
||||||
|
|||||||
@ -61,11 +61,11 @@ public class UserService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 已通过短信或微信授权校验后的手机号登录(与验证码登录成功后的逻辑一致)。
|
* 宠主侧:按手机号确保存在 customer 账号(不存在则创建),已存在老板/员工账号时原样返回,不降级角色。
|
||||||
*/
|
*/
|
||||||
public Map<String, Object> loginByVerifiedPhone(String phone) {
|
public User ensureCustomerByPhone(String phone) {
|
||||||
if (phone == null || !phone.matches("^1\\d{10}$")) {
|
if (phone == null || !phone.matches("^1\\d{10}$")) {
|
||||||
return Map.of("code", 400, "message", "手机号格式不正确");
|
return null;
|
||||||
}
|
}
|
||||||
User user = userMapper.findByPhoneAndDeletedFalse(phone);
|
User user = userMapper.findByPhoneAndDeletedFalse(phone);
|
||||||
if (user == null) {
|
if (user == null) {
|
||||||
@ -80,6 +80,20 @@ public class UserService {
|
|||||||
user.setDeleted(false);
|
user.setDeleted(false);
|
||||||
user = userMapper.save(user);
|
user = userMapper.save(user);
|
||||||
}
|
}
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 已通过短信或微信授权校验后的手机号登录(与验证码登录成功后的逻辑一致)。
|
||||||
|
*/
|
||||||
|
public Map<String, Object> loginByVerifiedPhone(String phone) {
|
||||||
|
if (phone == null || !phone.matches("^1\\d{10}$")) {
|
||||||
|
return Map.of("code", 400, "message", "手机号格式不正确");
|
||||||
|
}
|
||||||
|
User user = ensureCustomerByPhone(phone);
|
||||||
|
if (user == null) {
|
||||||
|
return Map.of("code", 400, "message", "手机号格式不正确");
|
||||||
|
}
|
||||||
Store store = null;
|
Store store = null;
|
||||||
if (user.getStoreId() != null) {
|
if (user.getStoreId() != null) {
|
||||||
store = storeMapper.findByIdAndDeletedFalse(user.getStoreId()).orElse(null);
|
store = storeMapper.findByIdAndDeletedFalse(user.getStoreId()).orElse(null);
|
||||||
|
|||||||
@ -53,5 +53,6 @@ app:
|
|||||||
ffprobe-binary: ffprobe
|
ffprobe-binary: ffprobe
|
||||||
image-segment-sec: 2.0
|
image-segment-sec: 2.0
|
||||||
video-cap-sec: 8.0
|
video-cap-sec: 8.0
|
||||||
|
max-duration-sec: 300
|
||||||
bgm-path: ""
|
bgm-path: ""
|
||||||
ken-burns-enabled: true
|
ken-burns-enabled: true
|
||||||
|
|||||||
@ -54,6 +54,8 @@ app:
|
|||||||
image-segment-sec: 2.0
|
image-segment-sec: 2.0
|
||||||
# 单段小视频最多取用秒数(源更长则截断)
|
# 单段小视频最多取用秒数(源更长则截断)
|
||||||
video-cap-sec: 8.0
|
video-cap-sec: 8.0
|
||||||
|
# 成片最大总秒数(素材自然时长超过则整体压缩)
|
||||||
|
max-duration-sec: ${HIGHLIGHT_MAX_DURATION_SEC:300}
|
||||||
# 可选:服务器上商用授权 BGM 文件绝对路径;留空则不混音
|
# 可选:服务器上商用授权 BGM 文件绝对路径;留空则不混音
|
||||||
bgm-path: ${HIGHLIGHT_BGM_PATH:}
|
bgm-path: ${HIGHLIGHT_BGM_PATH:}
|
||||||
ken-burns-enabled: true
|
ken-burns-enabled: true
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user