feat(media): 支持后台转码配置与缩略图轴

This commit is contained in:
Zhou Weidong committed 2026-10-22 11:20:28 +08:00
1 parent 70da939a58
commit 316baf28d6
39 files changed
+1523 -38

No files matched your search

@@ -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;
}
}
@@ -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);
}
}
@@ -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;
}
}
}
@@ -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;
}
@@ -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> {
}
@@ -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) {
}
}
@@ -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;
}
}
@@ -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) {
@@ -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;
}
}
@@ -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) {
@@ -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);
}
}
@@ -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);
}
}
}
@@ -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<>();