更新 2026-04-18 18:32
This commit is contained in:
parent
afcd39011a
commit
e3b95b45b7
@ -1,5 +1,6 @@
|
||||
package com.petstore.controller;
|
||||
|
||||
import com.petstore.entity.HighlightFailReason;
|
||||
import com.petstore.entity.Report;
|
||||
import com.petstore.entity.ReportImage;
|
||||
import com.petstore.entity.Store;
|
||||
@ -60,9 +61,25 @@ public class ReportController {
|
||||
target.put("highlightVideoStatus", r.getHighlightVideoStatus());
|
||||
target.put("highlightVideoError", r.getHighlightVideoError());
|
||||
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")
|
||||
public Map<String, Object> startHighlight(@RequestBody Map<String, Object> body) {
|
||||
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 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);
|
||||
String composeMode = body.get("composeMode") == null ? "preset" : body.get("composeMode").toString().trim();
|
||||
return reportHighlightVideoService.requestGenerate(reportId, operatorUserId, role, composeMode);
|
||||
}
|
||||
|
||||
@PostMapping("/create")
|
||||
|
||||
@ -46,12 +46,14 @@ public class ReportLeadController {
|
||||
return Map.of("code", 400, "message", "需要勾选同意接收服务提醒");
|
||||
}
|
||||
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<>();
|
||||
data.put("ok", true);
|
||||
data.put("leadId", lead.getId());
|
||||
data.put("remindDate", lead.getRemindDate() == null ? null : lead.getRemindDate().toString());
|
||||
data.put("unsubscribeToken", lead.getUnsubscribeToken());
|
||||
data.put("wechatBound", outcome.wechatBound());
|
||||
return Map.of("code", 200, "data", data);
|
||||
} catch (IllegalArgumentException e) {
|
||||
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")
|
||||
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;
|
||||
|
||||
import com.petstore.entity.Appointment;
|
||||
import com.petstore.entity.HighlightFailReason;
|
||||
import com.petstore.entity.Report;
|
||||
import com.petstore.entity.ReportImage;
|
||||
import com.petstore.entity.User;
|
||||
@ -34,14 +35,21 @@ import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.Executor;
|
||||
|
||||
/**
|
||||
* 将服务报告中的服务前 / 过程中 / 服务后图片与短视频,按「照片与视频穿插」编排后拼接为竖屏短片(默认 15s 或 30s)。
|
||||
* <p>图片段使用轻微 Ken Burns(缩放平移);视频段保留原音(无音轨时补静音)。可选配置文件路径叠加 BGM。</p>
|
||||
* <p>依赖本机安装 {@code ffmpeg};可选 {@code ffprobe} 用于读取视频时长与是否含音轨。</p>
|
||||
* 将服务报告素材拼为竖屏短片,时长按素材量自动计算(受 {@code max-duration-sec} 上限约束)。
|
||||
* <p><b>preset</b> 预设顺序:</p>
|
||||
* <ol>
|
||||
* <li>封面:服务前第一张 + 服务后第一张 横向对比图(均为照片且文件存在时);</li>
|
||||
* <li>服务前其余照片(上传顺序);</li>
|
||||
* <li>过程中照片与视频(上传顺序);</li>
|
||||
* <li>服务后除第一张外的其余照片;</li>
|
||||
* <li>收尾:服务后第一张再次作为最后一镜。</li>
|
||||
* </ol>
|
||||
* <p><b>interleave</b>:在「服务前→过程→服务后」排序后,将全部照片与全部小视频均匀穿插(组间插入)。</p>
|
||||
* <p>图片段 Ken Burns;视频段保留原音(无音轨补静音)。可选 BGM。</p>
|
||||
*/
|
||||
@Service
|
||||
public class ReportHighlightVideoService {
|
||||
|
||||
private static final int[] ALLOWED_DURATIONS = {15, 30};
|
||||
private static final int OUTPUT_FPS = 30;
|
||||
|
||||
private final ReportMapper reportMapper;
|
||||
@ -66,10 +74,14 @@ public class ReportHighlightVideoService {
|
||||
@Value("${app.highlight-video.image-segment-sec:2.0}")
|
||||
private double imageSegmentSec;
|
||||
|
||||
/** 单段小视频最多取用时长(秒),实际还会按总时长预算缩放 */
|
||||
/** 单段小视频最多截取的时长(秒) */
|
||||
@Value("${app.highlight-video.video-cap-sec:8.0}")
|
||||
private double videoCapSec;
|
||||
|
||||
/** 成片最大总时长(秒);素材自然时长超过时整体等比压缩 */
|
||||
@Value("${app.highlight-video.max-duration-sec:300}")
|
||||
private double maxDurationSec;
|
||||
|
||||
/** 可选:本地绝对路径,商用授权 BGM(mp3/aac/m4a 等 ffmpeg 可读格式) */
|
||||
@Value("${app.highlight-video.bgm-path:}")
|
||||
private String bgmPath;
|
||||
@ -96,19 +108,16 @@ public class ReportHighlightVideoService {
|
||||
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()) {
|
||||
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");
|
||||
String mode = normalizeComposeMode(composeModeRaw);
|
||||
if (mode == null) {
|
||||
return Map.of("code", 400, "message", "composeMode 仅支持 preset(预设顺序)或 interleave(穿插)");
|
||||
}
|
||||
|
||||
synchronized (lockFor(reportId)) {
|
||||
@ -133,41 +142,100 @@ public class ReportHighlightVideoService {
|
||||
|
||||
report.setHighlightVideoStatus("processing");
|
||||
report.setHighlightVideoError(null);
|
||||
report.setHighlightDurationSec(durationSec);
|
||||
report.setHighlightFailReason(null);
|
||||
report.setHighlightComposeMode(mode);
|
||||
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"));
|
||||
highlightExecutor.execute(() -> composeSafely(reportId, mode));
|
||||
return Map.of("code", 200, "message", "已开始生成", "data", Map.of("status", "processing", "composeMode", mode));
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
composeInternal(reportId, durationSec);
|
||||
composeInternal(reportId, composeMode);
|
||||
} 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)) {
|
||||
reportMapper.findByIdAndDeletedFalse(reportId).ifPresent(r -> {
|
||||
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());
|
||||
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);
|
||||
if (opt.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
List<ReportImage> ordered = sortedImages(reportImageMapper.findByReportIdOrderBySortOrderAscIdAsc(reportId));
|
||||
List<ReportImage> before = filterByPhotoType(ordered, "before");
|
||||
List<ReportImage> during = filterByPhotoType(ordered, "during");
|
||||
List<ReportImage> after = filterByPhotoType(ordered, "after");
|
||||
|
||||
Path workDir = Files.createTempDirectory("highlight_" + reportId + "_");
|
||||
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) {
|
||||
@ -177,36 +245,59 @@ public class ReportHighlightVideoService {
|
||||
photos.add(img);
|
||||
}
|
||||
}
|
||||
List<ReportImage> timeline = interleaveSpread(photos, videos);
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
int bStart = coverAdded ? 1 : 0;
|
||||
for (int i = bStart; i < before.size(); i++) {
|
||||
appendResolvedMedia(sources, isVideo, before.get(i));
|
||||
}
|
||||
sources.add(p);
|
||||
isVideo.add(isVideoMedia(img));
|
||||
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, "素材文件在服务器上不存在或无法读取");
|
||||
markFailed(reportId, HighlightFailReason.MATERIAL);
|
||||
return;
|
||||
}
|
||||
|
||||
int n = sources.size();
|
||||
double[] segmentSecs = computeSegmentDurations(sources, isVideo, durationSec);
|
||||
double sumSeg = 0;
|
||||
double[] segmentSecs = computeSegmentDurationsNatural(sources, isVideo);
|
||||
double totalDurationSec = 0;
|
||||
for (double s : segmentSecs) {
|
||||
sumSeg += s;
|
||||
totalDurationSec += s;
|
||||
}
|
||||
if (sumSeg < 0.5) {
|
||||
markFailed(reportId, "分段时长计算异常");
|
||||
if (totalDurationSec < 0.5) {
|
||||
markFailed(reportId, HighlightFailReason.SERVICE);
|
||||
return;
|
||||
}
|
||||
|
||||
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");
|
||||
@ -236,6 +327,7 @@ public class ReportHighlightVideoService {
|
||||
"-c", "copy", merged.toString()
|
||||
));
|
||||
if (c1 != 0) {
|
||||
String tStr = String.format(Locale.ROOT, "%.3f", totalDurationSec);
|
||||
int c2 = runFfmpeg(List.of(
|
||||
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",
|
||||
@ -243,7 +335,7 @@ public class ReportHighlightVideoService {
|
||||
"-profile:v", "baseline", "-level", "3.0",
|
||||
"-c:a", "aac", "-b:a", "128k",
|
||||
"-movflags", "+faststart",
|
||||
"-t", String.valueOf(durationSec),
|
||||
"-t", tStr,
|
||||
merged.toString()
|
||||
));
|
||||
if (c2 != 0) {
|
||||
@ -267,12 +359,14 @@ public class ReportHighlightVideoService {
|
||||
String publicBase = baseUrl.endsWith("/") ? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl;
|
||||
String url = publicBase + "/api/upload/image/" + datePath + "/" + filename;
|
||||
|
||||
int durationRoundedSec = (int) Math.ceil(totalDurationSec);
|
||||
synchronized (lockFor(reportId)) {
|
||||
reportMapper.findByIdAndDeletedFalse(reportId).ifPresent(r -> {
|
||||
r.setHighlightVideoUrl(url);
|
||||
r.setHighlightVideoStatus("done");
|
||||
r.setHighlightVideoError(null);
|
||||
r.setHighlightDurationSec(durationSec);
|
||||
r.setHighlightFailReason(null);
|
||||
r.setHighlightDurationSec(durationRoundedSec);
|
||||
r.setUpdateTime(LocalDateTime.now());
|
||||
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) {
|
||||
int np = photos.size();
|
||||
@ -332,7 +426,51 @@ public class ReportHighlightVideoService {
|
||||
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();
|
||||
double[] raw = new double[n];
|
||||
double sum = 0;
|
||||
@ -349,25 +487,24 @@ public class ReportHighlightVideoService {
|
||||
sum += raw[i];
|
||||
}
|
||||
if (sum <= 0) {
|
||||
double eq = (double) totalSec / n;
|
||||
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 rounded = 0;
|
||||
double acc = 0;
|
||||
for (int i = 0; i < n; i++) {
|
||||
out[i] = raw[i] * scale;
|
||||
if (out[i] < 0.35) {
|
||||
out[i] = 0.35;
|
||||
}
|
||||
rounded += out[i];
|
||||
acc += out[i];
|
||||
}
|
||||
// 修正浮点误差,使总长接近 totalSec
|
||||
if (n > 0 && Math.abs(rounded - totalSec) > 0.05) {
|
||||
out[n - 1] = Math.max(0.35, out[n - 1] + (totalSec - rounded));
|
||||
if (n > 0) {
|
||||
out[n - 1] = Math.max(0.35, out[n - 1] + (targetTotal - acc));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@ -28,8 +28,12 @@ public class ReportLeadService {
|
||||
private final ReportMapper reportMapper;
|
||||
private final ServiceIntervalMapper serviceIntervalMapper;
|
||||
private final UserMapper userMapper;
|
||||
private final UserService userService;
|
||||
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_MAX = 35;
|
||||
@ -108,7 +112,7 @@ public class ReportLeadService {
|
||||
*
|
||||
* @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);
|
||||
if (report == null) {
|
||||
throw new IllegalArgumentException("报告不存在或已失效");
|
||||
@ -165,7 +169,18 @@ public class ReportLeadService {
|
||||
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 找记录并置状态 */
|
||||
|
||||
@ -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}$")) {
|
||||
return Map.of("code", 400, "message", "手机号格式不正确");
|
||||
return null;
|
||||
}
|
||||
User user = userMapper.findByPhoneAndDeletedFalse(phone);
|
||||
if (user == null) {
|
||||
@ -80,6 +80,20 @@ public class UserService {
|
||||
user.setDeleted(false);
|
||||
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;
|
||||
if (user.getStoreId() != null) {
|
||||
store = storeMapper.findByIdAndDeletedFalse(user.getStoreId()).orElse(null);
|
||||
|
||||
@ -53,5 +53,6 @@ app:
|
||||
ffprobe-binary: ffprobe
|
||||
image-segment-sec: 2.0
|
||||
video-cap-sec: 8.0
|
||||
max-duration-sec: 300
|
||||
bgm-path: ""
|
||||
ken-burns-enabled: true
|
||||
|
||||
@ -54,6 +54,8 @@ app:
|
||||
image-segment-sec: 2.0
|
||||
# 单段小视频最多取用秒数(源更长则截断)
|
||||
video-cap-sec: 8.0
|
||||
# 成片最大总秒数(素材自然时长超过则整体压缩)
|
||||
max-duration-sec: ${HIGHLIGHT_MAX_DURATION_SEC:300}
|
||||
# 可选:服务器上商用授权 BGM 文件绝对路径;留空则不混音
|
||||
bgm-path: ${HIGHLIGHT_BGM_PATH:}
|
||||
ken-burns-enabled: true
|
||||
|
||||
Loading…
Reference in New Issue
Block a user