feat(media): 支持后台转码配置与缩略图轴
This commit is contained in:
1 parent
70da939a58
commit
316baf28d6
39 files changed
+1523
-38
No files matched your search
+61
@@ -0,0 +1,61 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
package com.fruitshorts.media.config;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 后台可配置的转码档位与雪碧图参数。
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
public class MediaTranscodeProfile {
|
||||
|
||||
public static final int DEFAULT_SEGMENT_SECONDS = 6;
|
||||
public static final int DEFAULT_SPRITE_INTERVAL = 1;
|
||||
public static final int DEFAULT_SPRITE_COLUMNS = 10;
|
||||
public static final int DEFAULT_THUMB_WIDTH = 160;
|
||||
|
||||
private boolean enabled = true;
|
||||
private int segmentSeconds = DEFAULT_SEGMENT_SECONDS;
|
||||
private List<Rung> ladder = defaultLadder();
|
||||
private Sprite sprite = new Sprite();
|
||||
|
||||
/** 默认四档与第一阶段 yml 保持一致。 */
|
||||
public static List<Rung> defaultLadder() {
|
||||
List<Rung> ladder = new ArrayList<>(4);
|
||||
ladder.add(new Rung("360p", 640, 360, 400, 64, true));
|
||||
ladder.add(new Rung("480p", 854, 480, 800, 64, true));
|
||||
ladder.add(new Rung("720p", 1280, 720, 1600, 96, true));
|
||||
ladder.add(new Rung("1080p", 1920, 1080, 3200, 128, true));
|
||||
return ladder;
|
||||
}
|
||||
|
||||
/** 一档清晰度。 */
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public static class Rung {
|
||||
private String quality;
|
||||
private int width;
|
||||
private int height;
|
||||
private int videoBitrateKbps;
|
||||
private int audioBitrateKbps;
|
||||
private boolean enabled = true;
|
||||
}
|
||||
|
||||
/** 雪碧图采样参数。 */
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
public static class Sprite {
|
||||
private boolean enabled = true;
|
||||
private int intervalSeconds = DEFAULT_SPRITE_INTERVAL;
|
||||
private int columns = DEFAULT_SPRITE_COLUMNS;
|
||||
private int thumbWidth = DEFAULT_THUMB_WIDTH;
|
||||
}
|
||||
}
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
package com.fruitshorts.media.config;
|
||||
|
||||
import com.fruitshorts.common.exception.ErrorCode;
|
||||
import com.fruitshorts.common.exception.ServiceException;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 转码配置合法性校验。
|
||||
*/
|
||||
public final class MediaTranscodeProfileValidator {
|
||||
|
||||
private static final int MIN_SEGMENT_SECONDS = 2;
|
||||
private static final int MAX_SEGMENT_SECONDS = 10;
|
||||
private static final int MIN_SPRITE_INTERVAL = 1;
|
||||
private static final int MAX_SPRITE_INTERVAL = 10;
|
||||
private static final int MIN_COLUMNS = 5;
|
||||
private static final int MAX_COLUMNS = 20;
|
||||
private static final int MIN_THUMB_WIDTH = 120;
|
||||
private static final int MAX_THUMB_WIDTH = 240;
|
||||
private static final int MAX_QUALITY_LENGTH = 16;
|
||||
private static final int EVEN_DIVISOR = 2;
|
||||
|
||||
private MediaTranscodeProfileValidator() {
|
||||
}
|
||||
|
||||
/** 非法配置转成统一 400;调用方必须在持久化前执行。 */
|
||||
public static void validate(MediaTranscodeProfile profile) {
|
||||
if (profile == null) {
|
||||
throw invalid("转码配置不能为空");
|
||||
}
|
||||
validateSegment(profile.getSegmentSeconds());
|
||||
validateLadder(profile.getLadder());
|
||||
validateSprite(profile.getSprite());
|
||||
}
|
||||
|
||||
private static void validateSegment(int value) {
|
||||
if (value < MIN_SEGMENT_SECONDS || value > MAX_SEGMENT_SECONDS) {
|
||||
throw invalid("segmentSeconds 必须在 2-10 之间");
|
||||
}
|
||||
}
|
||||
|
||||
private static void validateLadder(Iterable<MediaTranscodeProfile.Rung> ladder) {
|
||||
if (ladder == null) {
|
||||
throw invalid("ladder 不能为空");
|
||||
}
|
||||
Set<String> qualities = new HashSet<>();
|
||||
boolean hasEnabled = false;
|
||||
for (MediaTranscodeProfile.Rung rung : ladder) {
|
||||
validateRung(rung, qualities);
|
||||
hasEnabled = hasEnabled || rung.isEnabled();
|
||||
}
|
||||
if (!hasEnabled) {
|
||||
throw invalid("至少需要启用一档清晰度");
|
||||
}
|
||||
}
|
||||
|
||||
private static void validateRung(MediaTranscodeProfile.Rung rung,
|
||||
Set<String> qualities) {
|
||||
if (rung == null || rung.getQuality() == null || rung.getQuality().isBlank()) {
|
||||
throw invalid("quality 不能为空");
|
||||
}
|
||||
String quality = rung.getQuality().trim();
|
||||
if (quality.length() > MAX_QUALITY_LENGTH || !qualities.add(quality)) {
|
||||
throw invalid("quality 必须唯一且不超过 16 个字符");
|
||||
}
|
||||
if (rung.getWidth() <= 0 || rung.getWidth() % EVEN_DIVISOR != 0
|
||||
|| rung.getHeight() <= 0 || rung.getHeight() % EVEN_DIVISOR != 0) {
|
||||
throw invalid("宽高必须为正偶数");
|
||||
}
|
||||
if (rung.getVideoBitrateKbps() <= 0 || rung.getAudioBitrateKbps() <= 0) {
|
||||
throw invalid("音视频码率必须大于 0");
|
||||
}
|
||||
}
|
||||
|
||||
private static void validateSprite(MediaTranscodeProfile.Sprite sprite) {
|
||||
if (sprite == null) {
|
||||
throw invalid("sprite 配置不能为空");
|
||||
}
|
||||
if (sprite.getIntervalSeconds() < MIN_SPRITE_INTERVAL
|
||||
|| sprite.getIntervalSeconds() > MAX_SPRITE_INTERVAL) {
|
||||
throw invalid("intervalSeconds 必须在 1-10 之间");
|
||||
}
|
||||
if (sprite.getColumns() < MIN_COLUMNS || sprite.getColumns() > MAX_COLUMNS) {
|
||||
throw invalid("columns 必须在 5-20 之间");
|
||||
}
|
||||
if (sprite.getThumbWidth() < MIN_THUMB_WIDTH
|
||||
|| sprite.getThumbWidth() > MAX_THUMB_WIDTH) {
|
||||
throw invalid("thumbWidth 必须在 120-240 之间");
|
||||
}
|
||||
}
|
||||
|
||||
private static ServiceException invalid(String message) {
|
||||
return new ServiceException(ErrorCode.BAD_REQUEST, message);
|
||||
}
|
||||
}
|
||||
+8
@@ -60,6 +60,8 @@ public class TranscodeProperties {
|
||||
private int height;
|
||||
private int videoBitrateKbps;
|
||||
private int audioBitrateKbps;
|
||||
/** 单档开关;yml 未填写时保持启用,兼容既有四档配置。 */
|
||||
private boolean enabled = true;
|
||||
|
||||
public Rung() {
|
||||
}
|
||||
@@ -71,5 +73,11 @@ public class TranscodeProperties {
|
||||
this.videoBitrateKbps = videoBitrateKbps;
|
||||
this.audioBitrateKbps = audioBitrateKbps;
|
||||
}
|
||||
|
||||
public Rung(String quality, int width, int height, int videoBitrateKbps,
|
||||
int audioBitrateKbps, boolean enabled) {
|
||||
this(quality, width, height, videoBitrateKbps, audioBitrateKbps);
|
||||
this.enabled = enabled;
|
||||
}
|
||||
}
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
package com.fruitshorts.media.dal.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.FieldStrategy;
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableLogic;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/** 单集缩略图雪碧图与 VTT 时间轴。 */
|
||||
@Data
|
||||
@TableName("media_thumbnail")
|
||||
public class MediaThumbnail {
|
||||
|
||||
public static final String STATUS_READY = "READY";
|
||||
public static final String STATUS_FAILED = "FAILED";
|
||||
|
||||
@TableId(type = IdType.ASSIGN_ID)
|
||||
private Long id;
|
||||
private Long episodeId;
|
||||
private Long dramaId;
|
||||
private String vttObjectKey;
|
||||
private String spritePrefix;
|
||||
private Integer intervalSec;
|
||||
private Integer columns;
|
||||
private Integer rows;
|
||||
private Integer thumbWidth;
|
||||
private Integer thumbHeight;
|
||||
private Integer frameCount;
|
||||
private Integer durationSec;
|
||||
private Long sizeBytes;
|
||||
private String status;
|
||||
private String errorMsg;
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@TableField(updateStrategy = FieldStrategy.NEVER)
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
@TableLogic
|
||||
private Integer deleted;
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
package com.fruitshorts.media.dal.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.fruitshorts.media.dal.entity.MediaThumbnail;
|
||||
|
||||
/**
|
||||
* 缩略图时间轴数据访问。
|
||||
*/
|
||||
public interface MediaThumbnailMapper extends BaseMapper<MediaThumbnail> {
|
||||
}
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
package com.fruitshorts.media.service;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fruitshorts.common.exception.ErrorCode;
|
||||
import com.fruitshorts.common.exception.ServiceException;
|
||||
import com.fruitshorts.media.config.MediaTranscodeProfile;
|
||||
import com.fruitshorts.media.config.MediaTranscodeProfileValidator;
|
||||
import com.fruitshorts.media.config.TranscodeProperties;
|
||||
import com.fruitshorts.system.service.ConfigService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 转码配置读写:后台立即落库,worker 每 30 秒感知一次;异常配置回退 yml。
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class MediaTranscodeProfileService {
|
||||
|
||||
public static final String CONFIG_KEY = "media.transcode.profile";
|
||||
public static final Duration CACHE_TTL = Duration.ofSeconds(30);
|
||||
|
||||
private final ConfigService configService;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final TranscodeProperties defaults;
|
||||
private volatile CachedProfile cached;
|
||||
|
||||
/** 读取当前有效配置;缺省或非法时回退 yml 默认。 */
|
||||
public MediaTranscodeProfile currentProfile() {
|
||||
CachedProfile current = cached;
|
||||
Instant now = Instant.now();
|
||||
if (current != null && current.expiresAt().isAfter(now)) {
|
||||
return current.profile();
|
||||
}
|
||||
MediaTranscodeProfile loaded = loadProfile();
|
||||
cached = new CachedProfile(loaded, now.plus(CACHE_TTL));
|
||||
return loaded;
|
||||
}
|
||||
|
||||
/** 后台读取不做 30 秒缓存,确保直接改库后刷新页面即可看到最新值。 */
|
||||
public MediaTranscodeProfile readConfiguredProfile() {
|
||||
return loadProfile();
|
||||
}
|
||||
|
||||
/** 保存前校验;非法配置不会触发 sys_config 写操作。 */
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public MediaTranscodeProfile save(MediaTranscodeProfile profile) {
|
||||
MediaTranscodeProfileValidator.validate(profile);
|
||||
String json = writeJson(profile);
|
||||
configService.update(Map.of(CONFIG_KEY, json));
|
||||
cached = new CachedProfile(profile, Instant.now().plus(CACHE_TTL));
|
||||
return profile;
|
||||
}
|
||||
|
||||
/** yml/代码默认配置,后台缺省值也使用这一份。 */
|
||||
public MediaTranscodeProfile defaultProfile() {
|
||||
MediaTranscodeProfile profile = new MediaTranscodeProfile();
|
||||
profile.setEnabled(true);
|
||||
profile.setSegmentSeconds(defaults.getSegmentSeconds());
|
||||
profile.setLadder(defaults.getLadder().stream()
|
||||
.map(rung -> new MediaTranscodeProfile.Rung(
|
||||
rung.getQuality(), rung.getWidth(), rung.getHeight(),
|
||||
rung.getVideoBitrateKbps(), rung.getAudioBitrateKbps(),
|
||||
rung.isEnabled()))
|
||||
.toList());
|
||||
return profile;
|
||||
}
|
||||
|
||||
/** worker 的 ffmpeg 路径仍以 yml 为运行环境配置。 */
|
||||
public String ffmpegPath() {
|
||||
return defaults.getFfmpegPath();
|
||||
}
|
||||
|
||||
public String ffprobePath() {
|
||||
return defaults.getFfprobePath();
|
||||
}
|
||||
|
||||
public int posterSecond() {
|
||||
return defaults.getPosterSecond();
|
||||
}
|
||||
|
||||
public int timeoutSeconds() {
|
||||
return defaults.getTimeoutSeconds();
|
||||
}
|
||||
|
||||
private MediaTranscodeProfile loadProfile() {
|
||||
try {
|
||||
String json = configService.get(CONFIG_KEY, "");
|
||||
if (json == null || json.isBlank()) {
|
||||
return defaultProfile();
|
||||
}
|
||||
MediaTranscodeProfile profile =
|
||||
objectMapper.readValue(json, MediaTranscodeProfile.class);
|
||||
MediaTranscodeProfileValidator.validate(profile);
|
||||
return profile;
|
||||
} catch (Exception ex) {
|
||||
log.warn("转码配置无效,回退 yml 默认:{}", ex.getMessage());
|
||||
return defaultProfile();
|
||||
}
|
||||
}
|
||||
|
||||
private String writeJson(MediaTranscodeProfile profile) {
|
||||
try {
|
||||
return objectMapper.writeValueAsString(profile);
|
||||
} catch (JsonProcessingException ex) {
|
||||
throw new ServiceException(ErrorCode.INTERNAL_ERROR, "转码配置序列化失败");
|
||||
}
|
||||
}
|
||||
|
||||
private record CachedProfile(MediaTranscodeProfile profile, Instant expiresAt) {
|
||||
}
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
package com.fruitshorts.media.service;
|
||||
|
||||
import com.fruitshorts.media.dal.entity.MediaThumbnail;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 把 READY 缩略图记录解析成公开且已签名的 VTT 地址。
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class ThumbnailUrlResolver {
|
||||
|
||||
private final TranscodeService transcodeService;
|
||||
private final MediaStorageService storageService;
|
||||
private final PlaybackSigner signer;
|
||||
|
||||
/** 单集播放使用。 */
|
||||
public String resolve(Long episodeId) {
|
||||
return toUrl(transcodeService.findReadyThumbnail(episodeId));
|
||||
}
|
||||
|
||||
/** 详情列表一次批量解析,禁止 N+1。 */
|
||||
public Map<Long, String> resolveAll(Collection<Long> episodeIds) {
|
||||
Map<Long, MediaThumbnail> thumbnails =
|
||||
transcodeService.listReadyThumbnails(episodeIds);
|
||||
Map<Long, String> result = new LinkedHashMap<>();
|
||||
thumbnails.forEach((episodeId, thumbnail) -> {
|
||||
String url = toUrl(thumbnail);
|
||||
if (url != null) {
|
||||
result.put(episodeId, url);
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
private String toUrl(MediaThumbnail thumbnail) {
|
||||
if (thumbnail == null || !StringUtils.hasText(thumbnail.getVttObjectKey())) {
|
||||
return null;
|
||||
}
|
||||
String publicUrl = storageService.toPublicUrl(thumbnail.getVttObjectKey());
|
||||
String signed = signer.sign(publicUrl);
|
||||
return StringUtils.hasText(signed) ? signed : null;
|
||||
}
|
||||
}
|
||||
+59
@@ -6,8 +6,10 @@ import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import com.fruitshorts.common.exception.ErrorCode;
|
||||
import com.fruitshorts.common.exception.ServiceException;
|
||||
import com.fruitshorts.media.dal.entity.MediaRendition;
|
||||
import com.fruitshorts.media.dal.entity.MediaThumbnail;
|
||||
import com.fruitshorts.media.dal.entity.TranscodeTask;
|
||||
import com.fruitshorts.media.dal.mapper.MediaRenditionMapper;
|
||||
import com.fruitshorts.media.dal.mapper.MediaThumbnailMapper;
|
||||
import com.fruitshorts.media.dal.mapper.TranscodeTaskMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -17,6 +19,7 @@ import java.time.LocalDateTime;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Collection;
|
||||
|
||||
/**
|
||||
* 转码任务编排。
|
||||
@@ -38,6 +41,7 @@ public class TranscodeService {
|
||||
|
||||
private final TranscodeTaskMapper transcodeTaskMapper;
|
||||
private final MediaRenditionMapper mediaRenditionMapper;
|
||||
private final MediaThumbnailMapper mediaThumbnailMapper;
|
||||
|
||||
/** 上传完成后入队;同一媒资重复入队时复用未完成的任务,避免重复转码。 */
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
@@ -82,6 +86,13 @@ public class TranscodeService {
|
||||
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void succeed(Long taskId, List<MediaRendition> renditions) {
|
||||
succeed(taskId, renditions, null);
|
||||
}
|
||||
|
||||
/** 主转码成功:登记码率产物、缩略图结果并完成任务。 */
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void succeed(Long taskId, List<MediaRendition> renditions,
|
||||
MediaThumbnail thumbnail) {
|
||||
TranscodeTask task = getExisting(taskId);
|
||||
for (MediaRendition rendition : renditions) {
|
||||
rendition.setMediaAssetId(task.getMediaAssetId());
|
||||
@@ -90,9 +101,14 @@ public class TranscodeService {
|
||||
rendition.setStatus("ready");
|
||||
mediaRenditionMapper.insert(rendition);
|
||||
}
|
||||
if (thumbnail != null) {
|
||||
upsertThumbnail(task, thumbnail);
|
||||
}
|
||||
task.setStatus(STATUS_SUCCESS);
|
||||
task.setProgress(100);
|
||||
task.setFinishedAt(LocalDateTime.now());
|
||||
task.setErrorMsg(thumbnail == null || thumbnail.getErrorMsg() == null
|
||||
? "" : thumbnail.getErrorMsg());
|
||||
transcodeTaskMapper.updateById(task);
|
||||
}
|
||||
|
||||
@@ -123,6 +139,31 @@ public class TranscodeService {
|
||||
.orderByAsc(MediaRendition::getVideoBitrate));
|
||||
}
|
||||
|
||||
/** 播放侧查询单集 READY 缩略图轴。 */
|
||||
public MediaThumbnail findReadyThumbnail(Long episodeId) {
|
||||
if (episodeId == null) {
|
||||
return null;
|
||||
}
|
||||
return mediaThumbnailMapper.selectOne(Wrappers.<MediaThumbnail>lambdaQuery()
|
||||
.eq(MediaThumbnail::getEpisodeId, episodeId)
|
||||
.eq(MediaThumbnail::getStatus, MediaThumbnail.STATUS_READY)
|
||||
.last("limit 1"));
|
||||
}
|
||||
|
||||
/** 详情页一次批量取回,避免按集 N+1。 */
|
||||
public Map<Long, MediaThumbnail> listReadyThumbnails(Collection<Long> episodeIds) {
|
||||
if (episodeIds == null || episodeIds.isEmpty()) {
|
||||
return Map.of();
|
||||
}
|
||||
List<MediaThumbnail> rows = mediaThumbnailMapper.selectList(
|
||||
Wrappers.<MediaThumbnail>lambdaQuery()
|
||||
.in(MediaThumbnail::getEpisodeId, episodeIds.stream().sorted().toList())
|
||||
.eq(MediaThumbnail::getStatus, MediaThumbnail.STATUS_READY));
|
||||
Map<Long, MediaThumbnail> result = new LinkedHashMap<>();
|
||||
rows.forEach(row -> result.put(row.getEpisodeId(), row));
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 转码看板数据。
|
||||
*
|
||||
@@ -154,6 +195,24 @@ public class TranscodeService {
|
||||
.eq(TranscodeTask::getStatus, status));
|
||||
}
|
||||
|
||||
private void upsertThumbnail(TranscodeTask task, MediaThumbnail thumbnail) {
|
||||
if (task.getEpisodeId() == null || task.getEpisodeId() <= 0) {
|
||||
return;
|
||||
}
|
||||
MediaThumbnail existing = mediaThumbnailMapper.selectOne(
|
||||
Wrappers.<MediaThumbnail>lambdaQuery()
|
||||
.eq(MediaThumbnail::getEpisodeId, task.getEpisodeId())
|
||||
.last("limit 1"));
|
||||
thumbnail.setDramaId(task.getDramaId() == null ? 0L : task.getDramaId());
|
||||
thumbnail.setEpisodeId(task.getEpisodeId());
|
||||
if (existing == null) {
|
||||
mediaThumbnailMapper.insert(thumbnail);
|
||||
return;
|
||||
}
|
||||
thumbnail.setId(existing.getId());
|
||||
mediaThumbnailMapper.updateById(thumbnail);
|
||||
}
|
||||
|
||||
private TranscodeTask getExisting(Long id) {
|
||||
TranscodeTask task = id == null ? null : transcodeTaskMapper.selectById(id);
|
||||
if (task == null) {
|
||||
|
||||
+178
@@ -0,0 +1,178 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
package com.fruitshorts.media.service.transcode;
|
||||
|
||||
import com.fruitshorts.common.exception.ErrorCode;
|
||||
import com.fruitshorts.common.exception.ServiceException;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import javax.imageio.ImageIO;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
/**
|
||||
* 从源视频生成雪碧图与 WebVTT;失败只返回 FAILED 元数据,不阻断 HLS。
|
||||
*/
|
||||
@Slf4j
|
||||
public class FfmpegThumbnailGenerator {
|
||||
|
||||
private static final String STATUS_READY = "READY";
|
||||
private static final String STATUS_FAILED = "FAILED";
|
||||
private static final String THUMBNAIL_DIR = "thumbnails";
|
||||
private static final String VTT_FILE = "thumbnails.vtt";
|
||||
private static final String SPRITE_PATTERN = "sprite_%03d.jpg";
|
||||
private static final int STDERR_TAIL = 600;
|
||||
private static final int JPEG_QUALITY = 3;
|
||||
|
||||
/** 生成缩略图轴;禁用时返回 null。 */
|
||||
public TranscodeRunner.ProducedThumbnail generate(
|
||||
TranscodeRunner.TranscodeRequest request, int durationSec) {
|
||||
if (request.sprite() == null || !request.sprite().enabled()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return generateFiles(request, durationSec);
|
||||
} catch (Exception ex) {
|
||||
log.warn("缩略图生成失败 task={}:{}", request.taskId(), ex.getMessage());
|
||||
return failed(request, durationSec, ex.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private TranscodeRunner.ProducedThumbnail generateFiles(
|
||||
TranscodeRunner.TranscodeRequest request, int durationSec) throws IOException {
|
||||
Path thumbnailDir = request.outputDir().resolve(THUMBNAIL_DIR);
|
||||
createDirectories(thumbnailDir);
|
||||
TranscodeRunner.SpriteOptions sprite = request.sprite();
|
||||
int frames = ThumbnailVttBuilder.frameCount(durationSec, sprite.intervalSeconds());
|
||||
int rows = ThumbnailVttBuilder.rows(frames, sprite.columns());
|
||||
runFfmpeg(request, thumbnailDir, rows);
|
||||
|
||||
Path firstSprite = thumbnailDir.resolve(String.format(SPRITE_PATTERN, 1));
|
||||
BufferedImage image = ImageIO.read(firstSprite.toFile());
|
||||
if (image == null || image.getWidth() <= 0 || image.getHeight() <= 0) {
|
||||
throw new ServiceException(ErrorCode.INTERNAL_ERROR, "ffmpeg 未产出有效雪碧图");
|
||||
}
|
||||
ThumbnailVttBuilder.Layout layout = new ThumbnailVttBuilder.Layout(
|
||||
durationSec, sprite.intervalSeconds(), sprite.columns(), rows,
|
||||
image.getWidth(), image.getHeight());
|
||||
Path vtt = thumbnailDir.resolve(VTT_FILE);
|
||||
Files.writeString(vtt, ThumbnailVttBuilder.build(layout), StandardCharsets.UTF_8);
|
||||
|
||||
return new TranscodeRunner.ProducedThumbnail(
|
||||
joinKey(request.objectKeyPrefix(), THUMBNAIL_DIR + "/" + VTT_FILE),
|
||||
joinKey(request.objectKeyPrefix(), THUMBNAIL_DIR + "/sprite_"),
|
||||
sprite.intervalSeconds(), sprite.columns(), rows, image.getWidth(),
|
||||
image.getHeight(), frames, durationSec, sizeOf(thumbnailDir),
|
||||
STATUS_READY, "");
|
||||
}
|
||||
|
||||
private void runFfmpeg(TranscodeRunner.TranscodeRequest request, Path dir, int rows) {
|
||||
TranscodeRunner.SpriteOptions sprite = request.sprite();
|
||||
String filter = "fps=1/" + sprite.intervalSeconds()
|
||||
+ ",scale=" + sprite.thumbWidth() + ":-2"
|
||||
+ ",tile=" + sprite.columns() + "x" + rows;
|
||||
List<String> command = List.of(
|
||||
request.ffmpegPath(), "-y",
|
||||
"-i", request.sourceFile().toString(),
|
||||
"-vf", filter,
|
||||
"-q:v", String.valueOf(JPEG_QUALITY),
|
||||
dir.resolve(SPRITE_PATTERN).toString());
|
||||
runProcess(command, request.timeoutSeconds(), "生成缩略图失败");
|
||||
}
|
||||
|
||||
private TranscodeRunner.ProducedThumbnail failed(
|
||||
TranscodeRunner.TranscodeRequest request, int durationSec, String message) {
|
||||
TranscodeRunner.SpriteOptions sprite = request.sprite();
|
||||
Path thumbnailDir = request.outputDir().resolve(THUMBNAIL_DIR);
|
||||
int frames = ThumbnailVttBuilder.frameCount(durationSec, sprite.intervalSeconds());
|
||||
int rows = ThumbnailVttBuilder.rows(frames, sprite.columns());
|
||||
return new TranscodeRunner.ProducedThumbnail(
|
||||
joinKey(request.objectKeyPrefix(), THUMBNAIL_DIR + "/" + VTT_FILE),
|
||||
joinKey(request.objectKeyPrefix(), THUMBNAIL_DIR + "/sprite_"),
|
||||
sprite.intervalSeconds(), sprite.columns(), rows, sprite.thumbWidth(), 0,
|
||||
frames, durationSec, sizeOf(thumbnailDir), STATUS_FAILED,
|
||||
message == null ? "缩略图生成失败" : message);
|
||||
}
|
||||
|
||||
private String runProcess(List<String> command, int timeoutSeconds, String errorPrefix) {
|
||||
Path logFile = null;
|
||||
try {
|
||||
logFile = Files.createTempFile("fruitshorts-thumbnail-", ".log");
|
||||
Process process = new ProcessBuilder(command)
|
||||
.redirectErrorStream(true)
|
||||
.redirectOutput(logFile.toFile())
|
||||
.start();
|
||||
if (!process.waitFor(timeoutSeconds, TimeUnit.SECONDS)) {
|
||||
process.destroyForcibly();
|
||||
throw new ServiceException(ErrorCode.INTERNAL_ERROR, errorPrefix + ":超时");
|
||||
}
|
||||
String output = Files.readString(logFile, StandardCharsets.UTF_8);
|
||||
if (process.exitValue() != 0) {
|
||||
throw new ServiceException(ErrorCode.INTERNAL_ERROR,
|
||||
errorPrefix + ":" + tail(output));
|
||||
}
|
||||
return output;
|
||||
} catch (ServiceException ex) {
|
||||
throw ex;
|
||||
} catch (IOException ex) {
|
||||
throw new ServiceException(ErrorCode.INTERNAL_ERROR,
|
||||
errorPrefix + ":" + ex.getMessage());
|
||||
} catch (InterruptedException ex) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new ServiceException(ErrorCode.INTERNAL_ERROR, errorPrefix + ":被中断");
|
||||
} finally {
|
||||
deleteQuietly(logFile);
|
||||
}
|
||||
}
|
||||
|
||||
private void createDirectories(Path path) throws IOException {
|
||||
Files.createDirectories(path);
|
||||
}
|
||||
|
||||
private long sizeOf(Path dir) {
|
||||
if (!Files.isDirectory(dir)) {
|
||||
return 0L;
|
||||
}
|
||||
try (Stream<Path> files = Files.walk(dir)) {
|
||||
return files.filter(Files::isRegularFile)
|
||||
.mapToLong(path -> {
|
||||
try {
|
||||
return Files.size(path);
|
||||
} catch (IOException ex) {
|
||||
return 0L;
|
||||
}
|
||||
})
|
||||
.sum();
|
||||
} catch (IOException ex) {
|
||||
return 0L;
|
||||
}
|
||||
}
|
||||
|
||||
private void deleteQuietly(Path path) {
|
||||
if (path == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
Files.deleteIfExists(path);
|
||||
} catch (IOException ignored) {
|
||||
// 临时日志清理失败不影响业务结果
|
||||
}
|
||||
}
|
||||
|
||||
private String tail(String output) {
|
||||
String trimmed = output == null ? "" : output.trim();
|
||||
return trimmed.length() <= STDERR_TAIL
|
||||
? trimmed : trimmed.substring(trimmed.length() - STDERR_TAIL);
|
||||
}
|
||||
|
||||
private String joinKey(String prefix, String relative) {
|
||||
String cleanPrefix = prefix == null ? "" : prefix.replaceAll("/+$", "");
|
||||
return cleanPrefix.isEmpty() ? relative : cleanPrefix + "/" + relative;
|
||||
}
|
||||
}
|
||||
+6
-3
@@ -35,6 +35,7 @@ public class FfmpegTranscodeRunner implements TranscodeRunner {
|
||||
private static final String VARIANT_PLAYLIST = "index.m3u8";
|
||||
private static final String POSTER_FILE = "poster.jpg";
|
||||
private static final int STDERR_TAIL = 800;
|
||||
private final FfmpegThumbnailGenerator thumbnailGenerator = new FfmpegThumbnailGenerator();
|
||||
|
||||
@Override
|
||||
public TranscodeResult run(TranscodeRequest request) {
|
||||
@@ -51,10 +52,12 @@ public class FfmpegTranscodeRunner implements TranscodeRunner {
|
||||
}
|
||||
writeMasterPlaylist(request, renditions);
|
||||
ProducedRendition poster = extractPoster(request);
|
||||
log.info("转码完成 task={}:{} 档 HLS + 封面,时长 {}s",
|
||||
request.taskId(), renditions.size(), durationSec);
|
||||
ProducedThumbnail thumbnail = thumbnailGenerator.generate(request, durationSec);
|
||||
log.info("转码完成 task={}:{} 档 HLS + 封面 + 缩略图状态={},时长 {}s",
|
||||
request.taskId(), renditions.size(),
|
||||
thumbnail == null ? "DISABLED" : thumbnail.status(), durationSec);
|
||||
return new TranscodeResult(joinKey(request.objectKeyPrefix(), MASTER_PLAYLIST), renditions,
|
||||
poster.objectKey(), durationSec);
|
||||
poster.objectKey(), durationSec, thumbnail);
|
||||
}
|
||||
|
||||
private ProducedRendition transcodeRung(TranscodeRequest request, LadderRung rung) {
|
||||
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
package com.fruitshorts.media.service.transcode;
|
||||
|
||||
/**
|
||||
* 生成 WebVTT 缩略图索引;每个 cue 指向雪碧图中的 xywh 区域。
|
||||
*/
|
||||
public final class ThumbnailVttBuilder {
|
||||
|
||||
private static final String HEADER = "WEBVTT\n\n";
|
||||
private static final int SECONDS_PER_MINUTE = 60;
|
||||
private static final int MINUTES_PER_HOUR = 60;
|
||||
|
||||
private ThumbnailVttBuilder() {
|
||||
}
|
||||
|
||||
/** 雪碧图布局与尺寸。 */
|
||||
public record Layout(int durationSec, int intervalSec, int columns, int rows,
|
||||
int thumbWidth, int thumbHeight) {
|
||||
}
|
||||
|
||||
/** 按固定 tile 布局生成所有 cue。 */
|
||||
public static String build(Layout layout) {
|
||||
int frames = frameCount(layout.durationSec(), layout.intervalSec());
|
||||
int framesPerSprite = layout.columns() * layout.rows();
|
||||
int duration = layout.durationSec() > 0
|
||||
? layout.durationSec() : layout.intervalSec();
|
||||
StringBuilder body = new StringBuilder(HEADER);
|
||||
for (int index = 0; index < frames; index++) {
|
||||
appendCue(body, layout, duration, index, framesPerSprite);
|
||||
}
|
||||
return body.toString();
|
||||
}
|
||||
|
||||
/** 采样帧数,未知时长也保留一帧。 */
|
||||
public static int frameCount(int durationSec, int intervalSec) {
|
||||
if (durationSec <= 0 || intervalSec <= 0) {
|
||||
return 1;
|
||||
}
|
||||
return Math.max(1, (durationSec + intervalSec - 1) / intervalSec);
|
||||
}
|
||||
|
||||
/** 根据帧数和列数计算 tile 行数。 */
|
||||
public static int rows(int frameCount, int columns) {
|
||||
if (frameCount <= 0 || columns <= 0) {
|
||||
return 1;
|
||||
}
|
||||
int required = Math.max(1, (frameCount + columns - 1) / columns);
|
||||
return Math.min(columns, required);
|
||||
}
|
||||
|
||||
private static void appendCue(StringBuilder body, Layout layout, int duration,
|
||||
int index, int framesPerSprite) {
|
||||
int sheet = index / framesPerSprite + 1;
|
||||
int frameInSheet = index % framesPerSprite;
|
||||
int column = frameInSheet % layout.columns();
|
||||
int row = frameInSheet / layout.columns();
|
||||
int start = index * layout.intervalSec();
|
||||
int end = Math.min(start + layout.intervalSec(), duration);
|
||||
body.append(formatTimestamp(start)).append(" --> ")
|
||||
.append(formatTimestamp(end)).append('\n')
|
||||
.append(String.format("sprite_%03d.jpg#xywh=%d,%d,%d,%d",
|
||||
sheet, column * layout.thumbWidth(), row * layout.thumbHeight(),
|
||||
layout.thumbWidth(), layout.thumbHeight()))
|
||||
.append("\n\n");
|
||||
}
|
||||
|
||||
private static String formatTimestamp(int totalSeconds) {
|
||||
int hours = totalSeconds / (SECONDS_PER_MINUTE * MINUTES_PER_HOUR);
|
||||
int minutes = totalSeconds / SECONDS_PER_MINUTE % MINUTES_PER_HOUR;
|
||||
int seconds = totalSeconds % SECONDS_PER_MINUTE;
|
||||
return String.format("%02d:%02d:%02d.%03d", hours, minutes, seconds, 0);
|
||||
}
|
||||
}
|
||||
+34
-2
@@ -29,7 +29,24 @@ public interface TranscodeRunner {
|
||||
*/
|
||||
record TranscodeRequest(Long taskId, Path sourceFile, Path outputDir, String objectKeyPrefix,
|
||||
List<LadderRung> ladder, String ffmpegPath, String ffprobePath,
|
||||
int segmentSeconds, int posterSecond, int timeoutSeconds) {
|
||||
int segmentSeconds, int posterSecond, int timeoutSeconds,
|
||||
SpriteOptions sprite) {
|
||||
|
||||
/** 兼容未启用缩略图的既有调用。 */
|
||||
public TranscodeRequest(Long taskId, Path sourceFile, Path outputDir, String objectKeyPrefix,
|
||||
List<LadderRung> ladder, String ffmpegPath, String ffprobePath,
|
||||
int segmentSeconds, int posterSecond, int timeoutSeconds) {
|
||||
this(taskId, sourceFile, outputDir, objectKeyPrefix, ladder, ffmpegPath, ffprobePath,
|
||||
segmentSeconds, posterSecond, timeoutSeconds, SpriteOptions.disabled());
|
||||
}
|
||||
}
|
||||
|
||||
/** 雪碧图采样参数。 */
|
||||
record SpriteOptions(boolean enabled, int intervalSeconds, int columns, int thumbWidth) {
|
||||
|
||||
public static SpriteOptions disabled() {
|
||||
return new SpriteOptions(false, 1, 10, 160);
|
||||
}
|
||||
}
|
||||
|
||||
/** 一档产物。 */
|
||||
@@ -37,14 +54,29 @@ public interface TranscodeRunner {
|
||||
int audioBitrateKbps, String objectKey, long sizeBytes, int segmentCount) {
|
||||
}
|
||||
|
||||
/** 雪碧图与 VTT 产物;失败也返回本记录,保证主转码可继续成功。 */
|
||||
record ProducedThumbnail(String vttObjectKey, String spritePrefix, int intervalSec,
|
||||
int columns, int rows, int thumbWidth, int thumbHeight,
|
||||
int frameCount, int durationSec, long sizeBytes,
|
||||
String status, String errorMessage) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 转码产物。
|
||||
*
|
||||
* @param masterObjectKey HLS 主播放列表的 objectKey(客户端可播入口)
|
||||
* @param posterObjectKey 封面截图 objectKey
|
||||
* @param durationSec 视频时长(ffprobe 读出,0 表示读不到)
|
||||
* @param thumbnail 缩略图产物;未启用时为 null
|
||||
*/
|
||||
record TranscodeResult(String masterObjectKey, List<ProducedRendition> renditions,
|
||||
String posterObjectKey, int durationSec) {
|
||||
String posterObjectKey, int durationSec,
|
||||
ProducedThumbnail thumbnail) {
|
||||
|
||||
/** 兼容既有测试与未启用缩略图的调用。 */
|
||||
public TranscodeResult(String masterObjectKey, List<ProducedRendition> renditions,
|
||||
String posterObjectKey, int durationSec) {
|
||||
this(masterObjectKey, renditions, posterObjectKey, durationSec, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
+60
-8
@@ -2,10 +2,12 @@
|
||||
|
||||
package com.fruitshorts.media.service.transcode;
|
||||
|
||||
import com.fruitshorts.media.config.TranscodeProperties;
|
||||
import com.fruitshorts.media.config.MediaTranscodeProfile;
|
||||
import com.fruitshorts.media.dal.entity.MediaThumbnail;
|
||||
import com.fruitshorts.media.dal.entity.TranscodeTask;
|
||||
import com.fruitshorts.media.service.MediaService;
|
||||
import com.fruitshorts.media.service.MediaStorageService;
|
||||
import com.fruitshorts.media.service.MediaTranscodeProfileService;
|
||||
import com.fruitshorts.media.service.TranscodeService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
@@ -39,7 +41,7 @@ public class TranscodeWorker {
|
||||
private final MediaService mediaService;
|
||||
private final MediaStorageService storageService;
|
||||
private final TranscodeRunner transcodeRunner;
|
||||
private final TranscodeProperties properties;
|
||||
private final MediaTranscodeProfileService profileService;
|
||||
|
||||
/** 定时轮询:队列空就按 poll-delay 再来一轮;上一轮没跑完不会被下一轮叠加(fixedDelay)。 */
|
||||
@Scheduled(fixedDelayString = "${fruitshorts.media.transcode.poll-delay-ms:5000}")
|
||||
@@ -71,14 +73,17 @@ public class TranscodeWorker {
|
||||
long startedAt = System.currentTimeMillis();
|
||||
String objectKeyPrefix = outputPrefix(task);
|
||||
try {
|
||||
MediaTranscodeProfile profile = profileService.currentProfile();
|
||||
Path sourceFile = storageService.fileFor(storageService.objectKeyOf(task.getSourceUrl()));
|
||||
Path outputDir = storageService.fileFor(objectKeyPrefix);
|
||||
TranscodeRunner.TranscodeResult result = transcodeRunner.run(new TranscodeRunner.TranscodeRequest(
|
||||
task.getId(), sourceFile, outputDir, objectKeyPrefix, ladder(), properties.getFfmpegPath(),
|
||||
properties.getFfprobePath(), properties.getSegmentSeconds(), properties.getPosterSecond(),
|
||||
properties.getTimeoutSeconds()));
|
||||
task.getId(), sourceFile, outputDir, objectKeyPrefix, ladder(profile),
|
||||
profileService.ffmpegPath(), profileService.ffprobePath(),
|
||||
profile.getSegmentSeconds(), profileService.posterSecond(),
|
||||
profileService.timeoutSeconds(), spriteOptions(profile)));
|
||||
|
||||
transcodeService.succeed(task.getId(), toRenditions(result));
|
||||
transcodeService.succeed(task.getId(), toRenditions(result),
|
||||
toThumbnail(task, result.thumbnail()));
|
||||
if (task.getMediaAssetId() != null && task.getMediaAssetId() > 0) {
|
||||
mediaService.markReady(task.getMediaAssetId(),
|
||||
storageService.toPublicUrl(result.masterObjectKey()),
|
||||
@@ -88,6 +93,7 @@ public class TranscodeWorker {
|
||||
log.info("转码成功 task={} asset={} 档数={} 耗时={}ms 产物前缀={}",
|
||||
task.getId(), task.getMediaAssetId(), result.renditions().size(),
|
||||
System.currentTimeMillis() - startedAt, objectKeyPrefix);
|
||||
logThumbnailResult(result.thumbnail());
|
||||
} catch (Exception ex) {
|
||||
String reason = ex.getMessage() == null ? ex.getClass().getSimpleName() : ex.getMessage();
|
||||
int attempt = (task.getRetryCount() == null ? 0 : task.getRetryCount()) + 1;
|
||||
@@ -107,15 +113,61 @@ public class TranscodeWorker {
|
||||
return "transcoded/" + task.getId();
|
||||
}
|
||||
|
||||
private List<TranscodeRunner.LadderRung> ladder() {
|
||||
private List<TranscodeRunner.LadderRung> ladder(MediaTranscodeProfile profile) {
|
||||
List<TranscodeRunner.LadderRung> ladder = new ArrayList<>();
|
||||
for (TranscodeProperties.Rung rung : properties.getLadder()) {
|
||||
for (MediaTranscodeProfile.Rung rung : profile.getLadder()) {
|
||||
if (!rung.isEnabled()) {
|
||||
continue;
|
||||
}
|
||||
ladder.add(new TranscodeRunner.LadderRung(rung.getQuality(), rung.getWidth(), rung.getHeight(),
|
||||
rung.getVideoBitrateKbps(), rung.getAudioBitrateKbps()));
|
||||
}
|
||||
return ladder;
|
||||
}
|
||||
|
||||
private TranscodeRunner.SpriteOptions spriteOptions(MediaTranscodeProfile profile) {
|
||||
MediaTranscodeProfile.Sprite sprite = profile.getSprite();
|
||||
return new TranscodeRunner.SpriteOptions(
|
||||
sprite.isEnabled(), sprite.getIntervalSeconds(),
|
||||
sprite.getColumns(), sprite.getThumbWidth());
|
||||
}
|
||||
|
||||
private MediaThumbnail toThumbnail(
|
||||
TranscodeTask task, TranscodeRunner.ProducedThumbnail produced) {
|
||||
if (produced == null) {
|
||||
return null;
|
||||
}
|
||||
MediaThumbnail thumbnail = new MediaThumbnail();
|
||||
thumbnail.setEpisodeId(task.getEpisodeId());
|
||||
thumbnail.setDramaId(task.getDramaId());
|
||||
thumbnail.setVttObjectKey(produced.vttObjectKey());
|
||||
thumbnail.setSpritePrefix(produced.spritePrefix());
|
||||
thumbnail.setIntervalSec(produced.intervalSec());
|
||||
thumbnail.setColumns(produced.columns());
|
||||
thumbnail.setRows(produced.rows());
|
||||
thumbnail.setThumbWidth(produced.thumbWidth());
|
||||
thumbnail.setThumbHeight(produced.thumbHeight());
|
||||
thumbnail.setFrameCount(produced.frameCount());
|
||||
thumbnail.setDurationSec(produced.durationSec());
|
||||
thumbnail.setSizeBytes(produced.sizeBytes());
|
||||
thumbnail.setStatus(produced.status());
|
||||
thumbnail.setErrorMsg(produced.errorMessage());
|
||||
return thumbnail;
|
||||
}
|
||||
|
||||
private void logThumbnailResult(TranscodeRunner.ProducedThumbnail thumbnail) {
|
||||
if (thumbnail == null) {
|
||||
log.info("缩略图未启用");
|
||||
return;
|
||||
}
|
||||
if (MediaThumbnail.STATUS_FAILED.equals(thumbnail.status())) {
|
||||
log.warn("主转码成功但缩略图失败:{}", thumbnail.errorMessage());
|
||||
return;
|
||||
}
|
||||
log.info("缩略图完成:{} 帧,{}x{}", thumbnail.frameCount(),
|
||||
thumbnail.columns(), thumbnail.rows());
|
||||
}
|
||||
|
||||
private List<com.fruitshorts.media.dal.entity.MediaRendition> toRenditions(
|
||||
TranscodeRunner.TranscodeResult result) {
|
||||
List<com.fruitshorts.media.dal.entity.MediaRendition> renditions = new ArrayList<>();
|
||||
|
||||
Reference in new issue
Block a user