package com.ycwl.basic.template.service; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.ycwl.basic.exception.BizException; import com.ycwl.basic.mapper.TemplateV2Mapper; import com.ycwl.basic.mapper.TemplateV2SegmentMapper; import com.ycwl.basic.template.api.ITemplateRenderPlanApi; import com.ycwl.basic.template.api.dto.RenderPlan; import com.ycwl.basic.template.api.dto.RenderPlanSegment; import com.ycwl.basic.template.api.dto.TemplatePlanBuildCommand; import com.ycwl.basic.template.api.dto.TemplateSlotMaterial; import com.ycwl.basic.template.enums.TemplateV2SegmentType; import com.ycwl.basic.template.enums.TemplateV2SourceType; import com.ycwl.basic.template.model.entity.TemplateV2Entity; import com.ycwl.basic.template.model.entity.TemplateV2SegmentEntity; import com.ycwl.basic.template.service.onlyif.OnlyIfExpression; import com.ycwl.basic.template.service.onlyif.OnlyIfExpressionParser; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import java.net.URI; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Objects; /** * Template v2:RenderPlan 冻结服务 */ @Service @RequiredArgsConstructor public class TemplateRenderPlanService implements ITemplateRenderPlanApi { private static final int STATUS_ENABLED = 1; private final TemplateV2Mapper templateV2Mapper; private final TemplateV2SegmentMapper templateV2SegmentMapper; private final ObjectMapper objectMapper; private final OnlyIfExpressionParser onlyIfExpressionParser; @Override public RenderPlan buildRenderPlan(TemplatePlanBuildCommand command) { if (command == null || command.getTemplateId() == null) { throw new BizException(400, "templateId不能为空"); } TemplateV2Entity template = templateV2Mapper.getById(command.getTemplateId()); if (template == null) { throw new BizException(404, "模板不存在"); } if (command.getTemplateVersion() != null && !command.getTemplateVersion().equals(template.getVersion())) { throw new BizException(400, "模板版本不匹配"); } if (template.getStatus() != null && !Objects.equals(template.getStatus(), STATUS_ENABLED)) { throw new BizException(400, "模板未启用"); } validateOutputSpec(template); List templateSegments = templateV2SegmentMapper.listByTemplateId(template.getId()); if (templateSegments == null || templateSegments.isEmpty()) { throw new BizException(400, "模板未配置片段"); } Map> materialsBySlot = Objects.requireNonNullElse(command.getMaterialsBySlot(), Map.of()); Map slotCounts = buildSlotCounts(materialsBySlot); List planSegments = new ArrayList<>(); int startTimeMs = 0; int planSegmentIndex = 0; for (TemplateV2SegmentEntity templateSegment : templateSegments) { validateSegment(templateSegment); if (!shouldInclude(templateSegment, slotCounts)) { continue; } RenderPlanSegment planSegment = new RenderPlanSegment(); planSegment.setTemplateSegmentIndex(templateSegment.getSegmentIndex()); planSegment.setPlanSegmentIndex(planSegmentIndex++); planSegment.setStartTimeMs(startTimeMs); planSegment.setDurationMs(templateSegment.getDurationMs()); planSegment.setSegmentType(templateSegment.getSegmentType()); planSegment.setSourceType(templateSegment.getSourceType()); planSegment.setSourceRef(templateSegment.getSourceRef()); String boundMaterialUrl = bindMaterial(templateSegment, materialsBySlot); planSegment.setBoundMaterialUrl(boundMaterialUrl); planSegment.setRenderSpec(parseJsonRequiredOrNull(templateSegment.getRenderSpecJson(), "renderSpecJson")); planSegment.setAudioSpec(parseJsonRequiredOrNull(templateSegment.getAudioSpecJson(), "audioSpecJson")); planSegments.add(planSegment); startTimeMs = Math.addExact(startTimeMs, templateSegment.getDurationMs()); } if (planSegments.isEmpty()) { throw new BizException(400, "RenderPlan为空:所有片段均被only_if裁剪"); } RenderPlan plan = new RenderPlan(); plan.setTemplateId(template.getId()); plan.setTemplateVersion(template.getVersion()); plan.setOutputWidth(template.getOutputWidth()); plan.setOutputHeight(template.getOutputHeight()); plan.setOutputFps(template.getOutputFps()); plan.setBgmUrl(template.getBgmUrl()); plan.setTotalDurationMs(startTimeMs); plan.setSegments(planSegments); return plan; } private void validateOutputSpec(TemplateV2Entity template) { if (template.getOutputWidth() == null || template.getOutputWidth() <= 0) { throw new BizException(400, "outputWidth非法"); } if (template.getOutputHeight() == null || template.getOutputHeight() <= 0) { throw new BizException(400, "outputHeight非法"); } if (template.getOutputFps() == null || template.getOutputFps() <= 0) { throw new BizException(400, "outputFps非法"); } } private Map buildSlotCounts(Map> materialsBySlot) { Map counts = new HashMap<>(); for (Map.Entry> entry : materialsBySlot.entrySet()) { String slotKey = entry.getKey(); List materials = entry.getValue(); int count = 0; if (materials != null) { for (TemplateSlotMaterial material : materials) { if (material != null && material.getUrl() != null && !material.getUrl().isBlank()) { count++; } } } counts.put(slotKey, count); } return counts; } private void validateSegment(TemplateV2SegmentEntity segment) { if (segment.getDurationMs() == null || segment.getDurationMs() <= 0) { throw new BizException(400, "片段durationMs非法:segmentIndex=" + segment.getSegmentIndex()); } TemplateV2SegmentType segmentType = TemplateV2SegmentType.parse(segment.getSegmentType()); if (segmentType == null) { throw new BizException(400, "片段segmentType非法:segmentIndex=" + segment.getSegmentIndex()); } TemplateV2SourceType sourceType = TemplateV2SourceType.parse(segment.getSourceType()); if (sourceType == null) { throw new BizException(400, "片段sourceType非法:segmentIndex=" + segment.getSegmentIndex()); } if (segmentType == TemplateV2SegmentType.FIXED && sourceType != TemplateV2SourceType.FIXED_URL) { throw new BizException(400, "FIXED片段仅支持sourceType=FIXED_URL:segmentIndex=" + segment.getSegmentIndex()); } if (segmentType == TemplateV2SegmentType.RENDER && sourceType != TemplateV2SourceType.SLOT) { throw new BizException(400, "RENDER片段仅支持sourceType=SLOT:segmentIndex=" + segment.getSegmentIndex()); } if (segment.getSourceRef() == null || segment.getSourceRef().isBlank()) { throw new BizException(400, "片段sourceRef不能为空:segmentIndex=" + segment.getSegmentIndex()); } } private boolean shouldInclude(TemplateV2SegmentEntity segment, Map slotCounts) { String onlyIfExpr = segment.getOnlyIfExpr(); if (onlyIfExpr == null || onlyIfExpr.isBlank()) { return true; } JsonNode node; try { node = objectMapper.readTree(onlyIfExpr); } catch (JsonProcessingException e) { throw new BizException(400, "onlyIfExpr解析失败:segmentIndex=" + segment.getSegmentIndex()); } OnlyIfExpression expr = onlyIfExpressionParser.parse(node); return expr.evaluate(slotCounts); } private String bindMaterial(TemplateV2SegmentEntity segment, Map> materialsBySlot) { TemplateV2SegmentType segmentType = TemplateV2SegmentType.parse(segment.getSegmentType()); if (segmentType == TemplateV2SegmentType.FIXED) { if (!isHttpUrl(segment.getSourceRef())) { throw new BizException(400, "FIXED片段sourceRef必须是http/https URL:segmentIndex=" + segment.getSegmentIndex()); } return segment.getSourceRef(); } String slotKey = segment.getSourceRef(); TemplateSlotMaterial chosen = chooseMaterial(materialsBySlot.get(slotKey)); if (chosen == null) { throw new BizException(400, "缺少slot素材:" + slotKey); } if (!isHttpUrl(chosen.getUrl())) { throw new BizException(400, "slot素材URL必须是http/https:" + slotKey); } return chosen.getUrl(); } private TemplateSlotMaterial chooseMaterial(List materials) { if (materials == null || materials.isEmpty()) { return null; } boolean hasCreateTime = false; for (TemplateSlotMaterial material : materials) { if (material != null && material.getCreateTimeMs() != null) { hasCreateTime = true; break; } } if (!hasCreateTime) { for (TemplateSlotMaterial material : materials) { if (material != null && material.getUrl() != null && !material.getUrl().isBlank()) { return material; } } return null; } TemplateSlotMaterial best = null; for (TemplateSlotMaterial material : materials) { if (material == null || material.getUrl() == null || material.getUrl().isBlank()) { continue; } if (best == null) { best = material; continue; } best = compareMaterial(material, best) < 0 ? material : best; } return best; } private int compareMaterial(TemplateSlotMaterial a, TemplateSlotMaterial b) { Long aTime = a.getCreateTimeMs(); Long bTime = b.getCreateTimeMs(); if (aTime == null && bTime == null) { return compareUrl(a.getUrl(), b.getUrl()); } if (aTime == null) { return 1; } if (bTime == null) { return -1; } int timeCompare = Long.compare(bTime, aTime); if (timeCompare != 0) { return timeCompare; } return compareUrl(a.getUrl(), b.getUrl()); } private int compareUrl(String a, String b) { if (a == null && b == null) { return 0; } if (a == null) { return 1; } if (b == null) { return -1; } return a.compareTo(b); } private JsonNode parseJsonRequiredOrNull(String json, String fieldName) { if (json == null || json.isBlank()) { return null; } try { return objectMapper.readTree(json); } catch (JsonProcessingException e) { throw new BizException(400, fieldName + "解析失败"); } } private boolean isHttpUrl(String url) { if (url == null || url.isBlank()) { return false; } try { URI uri = URI.create(url); String scheme = uri.getScheme(); return "http".equalsIgnoreCase(scheme) || "https".equalsIgnoreCase(scheme); } catch (Exception e) { return false; } } }