merge: 合并转码配置与缩略图轴

This commit is contained in:
Zhou Weidong committed 2026-10-22 11:20:35 +08:00
commit 6bdd245a83
39 files changed
+1523 -38

No files matched your search

+44 -1
View File
@@ -227,7 +227,8 @@ app.register.max-per-device-per-day
"unlockCoin": 0,
"likeCount": "45",
"commentCount": "8",
"playUrl": "/files/episode/202610/xxx.mp4?e=1760000000&s=9f2c..."
"playUrl": "/files/episode/202610/xxx.mp4?e=1760000000&s=9f2c...",
"thumbnailsUrl": "/files/transcoded/88/thumbnails/thumbnails.vtt?e=1760000000&s=8a1d..."
}
]
}
@@ -244,6 +245,7 @@ app.register.max-per-device-per-day
| `viewCount` | Long(JSON 字符串) | 取 `drama.view_count`,累计播放量,既有字段语义不变 |
| `episodes[].likeCount` | Long(JSON 字符串) | 本集点赞数,取 `drama_episode.like_count` |
| `episodes[].commentCount` | Long(JSON 字符串) | 本集未删除评论数,按 `episode_id` 统计 |
| `episodes[].thumbnailsUrl` | String / null | 本集雪碧图 WebVTT 时间轴签名地址;没有 READY 产物时为 `null` |
计数是只读快照:互动操作完成后客户端重新拉取详情即可刷新;`Long` 字段一律按字符串下发,
客户端需要转成数字再展示(与 `viewCount` 相同)。
@@ -263,6 +265,7 @@ app.register.max-per-device-per-day
"accessType": "FREE",
"playUrl": "/files/episode/202610/xxx.mp4?e=1760000000&s=9f2c...",
"qualities": ["AUTO"],
"thumbnailsUrl": "/files/transcoded/88/thumbnails/thumbnails.vtt?e=1760000000&s=8a1d...",
"unlockCoin": 0,
"previewSeconds": 0,
"likeCount": "45",
@@ -277,6 +280,10 @@ app.register.max-per-device-per-day
**判定顺序由服务端决定,客户端不得自行推断可否播放。** 签名默认 15 分钟有效;
开启 `fruitshorts.media.signature-required` 后,`/files/episode/**` 没带有效签名会返回 403。
`thumbnailsUrl` 指向 WebVTT:cue 链接为同目录相对雪碧图
`sprite_XXX.jpg#xywh=x,y,w,h`。有 READY `media_thumbnail` 时返回签名地址,
无产物时为 `null`;该字段不影响 `playUrl`、`qualities` 和 HLS/原文件回落。
### 1.5 观看记录与收藏(已实现)
| 方法 | 路径 | 说明 |
@@ -597,6 +604,42 @@ GET /app/notices?pageIndex=1&pageSize=20
**上传会话归属**:`uploadId` 相当于上传凭证,`chunk` / `complete` / 进度查询都校验会话发起人,
别人拿到 `uploadId` 也塞不进分片(`{"code":403,"message":"无权操作该上传会话"}`)。
**转码档位配置**
| 方法 | 路径 | 权限点 | 说明 |
| --- | --- | --- | --- |
| GET | `/admin/media/transcode-profile` | `episode:upload` | 读取当前配置;缺省返回四档默认值 |
| PUT | `/admin/media/transcode-profile` | `episode:upload` | 保存配置并记录操作日志 |
配置保存在 `sys_config` 的 `media.transcode.profile`,JSON 结构:
```json
{
"enabled": true,
"segmentSeconds": 6,
"ladder": [
{
"quality": "360p",
"width": 640,
"height": 360,
"videoBitrateKbps": 400,
"audioBitrateKbps": 64,
"enabled": true
}
],
"sprite": {
"enabled": true,
"intervalSeconds": 1,
"columns": 10,
"thumbWidth": 160
}
}
```
`quality` 必须唯一且非空,宽高必须为大于 0 的偶数,音视频码率必须大于 0,
至少启用一档;`segmentSeconds` 2–10、`intervalSeconds` 1–10、`columns` 5–20、
`thumbWidth` 120–240。非法请求返回统一错误码 `400` 且不写库。
### 2.3 App 用户管理(已实现)
| 方法 | 路径 | 权限点 | 说明 |
@@ -0,0 +1,53 @@
# 转码档位配置、雪碧图与播放缩略图轴
## 目标
1. 后台可配置转码档位与雪碧图参数,worker 对之后的新任务在 30 秒内自动生效。
2. 转码成功后生成雪碧图与 VTT,登记到 `media_thumbnail`。
3. 播放接口与详情剧集返回 `thumbnailsUrl`,无产物时为 `null`。
## 配置模型
- `sys_config.config_key = media.transcode.profile`,值为 JSON。
- 配置包含总开关、分片秒数、码率阶梯与雪碧图参数。
- 缺省配置等价于现有 360p/480p/720p/1080p 四档。
- 后台 GET 缺省或历史配置非法时返回 yml 等价默认值;PUT 非法时返回统一 400,且不写库。
- worker 读取配置缓存 30 秒;缺省、解析失败或校验失败时回退 yml 默认阶梯。
## 权限取舍
现有权限点没有 `media:transcode`。本次两个接口复用最小可用的
`episode:upload`,避免新增权限点导致已有角色需要补授权;不新增菜单或权限 SQL。
## 数据与产物
- 新增 `media_thumbnail`,按 `episode_id` 唯一,记录 VTT、雪碧图前缀、采样参数、
尺寸、帧数、时长、状态、错误与总大小。
- 雪碧图目录固定在 `transcoded/{taskId}/thumbnails/`:
`thumbnails.vtt` + `sprite_001.jpg` 等多张图。
- VTT cue 链接使用相对路径 `sprite_XXX.jpg#xywh=x,y,w,h`,同一 VTT 目录下可直接解析。
- 缩略图失败只把 `media_thumbnail.status` 置 `FAILED` 并记录错误;主 HLS 仍成功。
## 播放
- `PlayRespVO` 和 `AppEpisodeVO` 增加 `thumbnailsUrl`。
- 查询 `media_thumbnail` 仅读取 `READY` 产物;objectKey 经
`MediaStorageService.toPublicUrl` 后使用现有 `PlaybackSigner` 签名。
- 详情剧集列表批量查询缩略图,禁止 N+1。
- 不改变现有 HLS/原文件回落与 qualities 列表。
## 测试
- worker:配置解析、非法配置回退 yml、30 秒缓存、VTT 坐标、多雪碧图索引、
缩略图失败不阻断主转码。
- admin:GET 默认值、PUT 非法不落库、PUT 成功落库。
- playback:有 READY 缩略图返回签名 `thumbnailsUrl`,无产物返回 `null`。
- 门禁:`mvn -pl fruitshorts-transcode-worker,fruitshorts-admin-api,fruitshorts-app-api -am test`。
## 风险 / 边界
- 本次只对合并后的新转码任务生效,不回跑历史任务。
- 配置中的顶层 `enabled` 会持久化并返回;本单不改变既有队列消费开关语义,
worker 仍以 `worker-enabled` 决定是否消费任务。
- 雪碧图生成依赖 ffmpeg `tile` 滤镜;真实产物由 worker 门禁和 PM 后续环境验收,
本任务不启动服务、不改客户端。
+71
View File
@@ -24740,3 +24740,74 @@ mvn -pl fruitshorts-admin-api -am test
解读:已推送历史不改写;身份合规靠命令级覆盖防复发;本条仅文档留痕,无代码改动。
---
## 2026-10-22 | 转码档位配置、雪碧图与播放缩略图轴
### 开工确认与计划
- 仓库:FruitServer
- 基线:`develop` / `origin/develop` = `70da939`
- 工作区:开工前干净
- 分支:`feature/media-transcode-profile-sprite`
- 计划:`docs/plans/2026-10-22-media-transcode-profile-sprite.md`
```text
$ git status --short --branch && git rev-parse --short HEAD
## develop...origin/develop
70da939
```
解读:基于指定 develop 基线创建隔离工作树。现有权限点无 `media:transcode`,
本次复用 `episode:upload`,不新增菜单或权限初始化数据。
### 实现与门禁
- 新增 `media.transcode.profile` JSON 模型、统一校验与 30 秒 worker 缓存;
后台 GET 每次直读,PUT 校验通过后才写 `sys_config`。
- 新增幂等迁移 `sql/patch-2026-10-22-media-thumbnail.sql`,包含该表、
唯一索引、更新时间触发器与默认四档配置;同步 `schema-phase1.sql` / `data-phase1.sql`。
- worker 产出 `thumbnails/thumbnails.vtt` 与按需多张 `sprite_%03d.jpg`;
失败以 `media_thumbnail.status=FAILED`、`error_msg` 留存,主 HLS 仍成功。
- 播放与详情增加 `thumbnailsUrl`;详情按剧集 ID 批量查询后统一签名。
- 首次 reactor 测试暴露 4 个既有 App 测试未跟随 `DramaService` 新增
`EpisodePriceSync` 构造参数,已在测试准备代码补齐替身,未改内容服务生产逻辑。
```text
$ mvn -pl fruitshorts-transcode-worker,fruitshorts-admin-api,fruitshorts-app-api -am -DskipTests compile
[INFO] fruitshorts-module-media ............................ SUCCESS
[INFO] fruitshorts-admin-api ............................... SUCCESS
[INFO] fruitshorts-app-api ................................. SUCCESS
[INFO] fruitshorts-transcode-worker ........................ SUCCESS
[INFO] BUILD SUCCESS
```
解读:新增 media→system 依赖和全部生产代码编译通过。
```text
$ mvn -pl fruitshorts-transcode-worker,fruitshorts-admin-api,fruitshorts-app-api -am test
[INFO] Tests run: 41, Failures: 0, Errors: 0, Skipped: 0
[INFO] Tests run: 103, Failures: 0, Errors: 0, Skipped: 0
[INFO] Tests run: 19, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
```
解读:admin 41、app-api 103、worker 19 全过;worker 包含真实 ffmpeg smoke,
验证 2 秒样本产出 HLS、封面、VTT 与 `sprite_001.jpg`。
```text
$ mvn test
[INFO] Reactor Summary for fruitshorts 1.0.0-SNAPSHOT:
[INFO] fruitshorts-admin-api .............................. SUCCESS
[INFO] fruitshorts-app-api ................................ SUCCESS
[INFO] fruitshorts-transcode-worker ....................... SUCCESS
[INFO] BUILD SUCCESS
[INFO] Total time: 24.439 s
$ sed -n '38,43p' sql/patch-2026-10-22-media-thumbnail.sql | ... | jq -e '.ladder | length == 4'
true
```
解读:仓库根全量 14 个 reactor 模块测试通过。迁移脚本按要求只写文件、
默认配置 JSON 解析为 4 档;未执行数据库变更;未启动 8080/8081/worker,
未操作真机,未改 Web/Flutter。
@@ -5,7 +5,10 @@ package com.fruitshorts.admin.controller;
import com.fruitshorts.common.pojo.Result;
import com.fruitshorts.framework.security.SecurityUtils;
import com.fruitshorts.framework.security.ImpersonationGuard;
import com.fruitshorts.framework.log.OperationLog;
import com.fruitshorts.media.config.MediaTranscodeProfile;
import com.fruitshorts.media.service.MediaService;
import com.fruitshorts.media.service.MediaTranscodeProfileService;
import com.fruitshorts.media.vo.MediaAssetRespVO;
import com.fruitshorts.media.vo.UploadCompleteReqVO;
import com.fruitshorts.media.vo.UploadInitReqVO;
@@ -19,6 +22,7 @@ import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
@@ -35,6 +39,7 @@ import org.springframework.web.multipart.MultipartFile;
public class MediaController {
private final MediaService mediaService;
private final MediaTranscodeProfileService profileService;
@Operation(summary = "初始化分片上传")
@PostMapping("/upload/init")
@@ -78,4 +83,21 @@ public class MediaController {
public Result<MediaAssetRespVO> get(@PathVariable Long id) {
return Result.ok(mediaService.get(id));
}
@Operation(summary = "读取转码档位配置")
@GetMapping("/transcode-profile")
@PreAuthorize("@ss.hasPermission('episode:upload')")
public Result<MediaTranscodeProfile> transcodeProfile() {
return Result.ok(profileService.readConfiguredProfile());
}
@Operation(summary = "保存转码档位配置")
@PutMapping("/transcode-profile")
@PreAuthorize("@ss.hasPermission('episode:upload')")
@OperationLog(module = "后台管理", action = "修改转码设置")
public Result<MediaTranscodeProfile> saveTranscodeProfile(
@Valid @RequestBody MediaTranscodeProfile profile) {
ImpersonationGuard.rejectWrite();
return Result.ok(profileService.save(profile));
}
}
@@ -0,0 +1,98 @@
// Author: fruit.com.cn
package com.fruitshorts.admin.controller;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fruitshorts.admin.support.ControllerContractSupport;
import com.fruitshorts.media.config.MediaTranscodeProfile;
import com.fruitshorts.media.config.TranscodeProperties;
import com.fruitshorts.media.service.MediaService;
import com.fruitshorts.media.service.MediaTranscodeProfileService;
import com.fruitshorts.system.service.ConfigService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* 后台转码配置接口:默认值、非法拒绝与成功落库。
*/
class MediaTranscodeProfileContractTest {
private ConfigService configService;
private ObjectMapper objectMapper;
private MockMvc mockMvc;
@BeforeEach
void setUp() {
configService = mock(ConfigService.class);
objectMapper = new ObjectMapper();
MediaTranscodeProfileService profileService = new MediaTranscodeProfileService(
configService, objectMapper, new TranscodeProperties());
MediaController controller = new MediaController(mock(MediaService.class), profileService);
mockMvc = ControllerContractSupport.mockMvcOf(controller);
given(configService.get(eq(MediaTranscodeProfileService.CONFIG_KEY), anyString()))
.willReturn("");
}
@Test
void should_return_default_profile_when_config_is_missing() throws Exception {
mockMvc.perform(get("/admin/media/transcode-profile"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.segmentSeconds").value(6))
.andExpect(jsonPath("$.data.ladder.length()").value(4))
.andExpect(jsonPath("$.data.ladder[0].quality").value("360p"))
.andExpect(jsonPath("$.data.sprite.columns").value(10));
}
@Test
void should_reject_illegal_profile_without_writing_config() throws Exception {
MediaTranscodeProfile profile = new MediaTranscodeProfile();
profile.getLadder().get(0).setWidth(641);
mockMvc.perform(put("/admin/media/transcode-profile")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(profile)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(400))
.andExpect(jsonPath("$.message").value("宽高必须为正偶数"));
verify(configService, never()).update(any());
}
@Test
void should_persist_profile_when_request_is_valid() throws Exception {
MediaTranscodeProfile profile = new MediaTranscodeProfile();
profile.setSegmentSeconds(4);
mockMvc.perform(put("/admin/media/transcode-profile")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(profile)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.segmentSeconds").value(4));
@SuppressWarnings("unchecked")
ArgumentCaptor<Map<String, String>> captor = ArgumentCaptor.forClass(Map.class);
verify(configService).update(captor.capture());
assertThat(captor.getValue().get(MediaTranscodeProfileService.CONFIG_KEY))
.contains("\"segmentSeconds\":4");
}
}
@@ -20,6 +20,7 @@ import com.fruitshorts.content.service.DramaService;
import com.fruitshorts.content.service.EpisodeService;
import com.fruitshorts.interaction.service.CommentService;
import com.fruitshorts.media.service.PlaybackSigner;
import com.fruitshorts.media.service.ThumbnailUrlResolver;
import com.fruitshorts.content.vo.DramaCategoryRespVO;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
@@ -50,6 +51,7 @@ public class AppCatalogService {
private final DramaCategoryService dramaCategoryService;
private final PlaybackSigner playbackSigner;
private final CommentService commentService;
private final ThumbnailUrlResolver thumbnailUrlResolver;
public AppFeedVO homeFeed() {
Map<Long, String> categoryNames = categoryNameMap();
@@ -110,6 +112,8 @@ public class AppCatalogService {
List<DramaEpisode> episodes = episodeService.listPublishedByDrama(dramaId);
Map<Long, Long> episodeCommentCounts = commentService.countByEpisodes(
episodes.stream().map(DramaEpisode::getId).sorted().toList());
Map<Long, String> thumbnailUrls = thumbnailUrlResolver.resolveAll(
episodes.stream().map(DramaEpisode::getId).sorted().toList());
String categoryName = categoryNameMap().getOrDefault(drama.getCategoryId(), "");
AppDramaDetailVO detail = new AppDramaDetailVO();
@@ -128,7 +132,7 @@ public class AppCatalogService {
detail.setFavoriteCount(drama.getFavoriteCount());
detail.setCommentCount(commentService.countByDrama(dramaId));
detail.setEpisodes(episodes.stream()
.map(episode -> toEpisode(episode, episodeCommentCounts))
.map(episode -> toEpisode(episode, episodeCommentCounts, thumbnailUrls))
.toList());
return detail;
}
@@ -176,14 +180,18 @@ public class AppCatalogService {
Map<Long, DramaEpisode> previews = episodeService.firstPlayableByDramas(ids);
Map<Long, Long> commentCounts = commentService.countByEpisodes(
previews.values().stream().map(DramaEpisode::getId).sorted().toList());
Map<Long, String> thumbnailUrls = thumbnailUrlResolver.resolveAll(
previews.values().stream().map(DramaEpisode::getId).sorted().toList());
return dramas.stream()
.map(drama -> toCard(
drama, categoryNames, previews.get(drama.getId()), commentCounts))
drama, categoryNames, previews.get(drama.getId()), commentCounts,
thumbnailUrls))
.toList();
}
private AppDramaCardVO toCard(Drama drama, Map<Long, String> categoryNames,
DramaEpisode preview, Map<Long, Long> commentCounts) {
DramaEpisode preview, Map<Long, Long> commentCounts,
Map<Long, String> thumbnailUrls) {
AppDramaCardVO card = new AppDramaCardVO();
card.setId(drama.getId());
card.setTitle(drama.getTitle());
@@ -201,11 +209,13 @@ public class AppCatalogService {
? 0L : commentCounts.getOrDefault(preview.getId(), 0L));
card.setLanguage(drama.getLanguage());
card.setDramaType(drama.getDramaType());
card.setPreview(preview == null ? null : toEpisode(preview, commentCounts));
card.setPreview(preview == null
? null : toEpisode(preview, commentCounts, thumbnailUrls));
return card;
}
private AppEpisodeVO toEpisode(DramaEpisode episode, Map<Long, Long> commentCounts) {
private AppEpisodeVO toEpisode(DramaEpisode episode, Map<Long, Long> commentCounts,
Map<Long, String> thumbnailUrls) {
AppEpisodeVO vo = new AppEpisodeVO();
vo.setId(episode.getId());
vo.setNumber(episode.getEpNo());
@@ -219,6 +229,7 @@ public class AppCatalogService {
vo.setUnlockCoin(episode.getUnlockCoin());
vo.setLikeCount(episode.getLikeCount() == null ? 0L : episode.getLikeCount());
vo.setCommentCount(commentCounts.getOrDefault(episode.getId(), 0L));
vo.setThumbnailsUrl(thumbnailUrls.get(episode.getId()));
if (free) {
// 播放地址统一在这里签名:客户端拿不到可长期复用的裸地址
vo.setPlayUrl(playbackSigner.sign(playableSource(episode)));
@@ -24,4 +24,6 @@ public class AppEpisodeVO {
* 详情页拿到就能直接播,不必按集再请求一次播放票据。
*/
private String playUrl;
/** 雪碧图 WebVTT 时间轴地址;没有产物时为 null */
private String thumbnailsUrl;
}
@@ -15,6 +15,7 @@ import org.springframework.test.web.servlet.MockMvc;
import java.util.List;
import java.util.Map;
import static org.hamcrest.Matchers.nullValue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
@@ -55,7 +56,9 @@ class AppPlaybackContractTest {
@Test
void should_return_free_access_when_episode_is_free() throws Exception {
given(playbackService.play(isNull(), eq(11L), any(), any()))
.willReturn(ticket(11L, true, PlaybackService.ACCESS_FREE, "/files/ep.mp4?s=1"));
.willReturn(ticket(11L, true, PlaybackService.ACCESS_FREE,
"/files/ep.mp4?s=1",
"/files/transcoded/11/thumbnails/thumbnails.vtt?e=1&s=sig"));
mockMvc.perform(get("/app/episodes/11/play").header("X-Device-Id", "device-9"))
.andExpect(status().isOk())
@@ -66,13 +69,15 @@ class AppPlaybackContractTest {
.andExpect(jsonPath("$.data.accessible").value(true))
.andExpect(jsonPath("$.data.accessType").value(PlaybackService.ACCESS_FREE))
.andExpect(jsonPath("$.data.playUrl").value("/files/ep.mp4?s=1"))
.andExpect(jsonPath("$.data.thumbnailsUrl")
.value("/files/transcoded/11/thumbnails/thumbnails.vtt?e=1&s=sig"))
.andExpect(jsonPath("$.data.likeCount").value("88"))
.andExpect(jsonPath("$.data.commentCount").value("0"));
}
@Test
void should_return_coin_required_when_episode_is_locked_and_not_entitled() throws Exception {
PlayRespVO locked = ticket(12L, false, PlaybackService.ACCESS_COIN_REQUIRED, "");
PlayRespVO locked = ticket(12L, false, PlaybackService.ACCESS_COIN_REQUIRED, "", null);
locked.setUnlockCoin(60);
given(playbackService.play(isNull(), eq(12L), any(), any())).willReturn(locked);
@@ -81,13 +86,15 @@ class AppPlaybackContractTest {
.andExpect(jsonPath("$.data.accessible").value(false))
.andExpect(jsonPath("$.data.accessType").value(PlaybackService.ACCESS_COIN_REQUIRED))
.andExpect(jsonPath("$.data.playUrl").value(""))
.andExpect(jsonPath("$.data.thumbnailsUrl").value(nullValue()))
.andExpect(jsonPath("$.data.unlockCoin").value(60));
}
@Test
void should_pass_episode_id_and_device_header_when_play_is_requested() throws Exception {
given(playbackService.play(isNull(), eq(13L), eq("device-9"), any()))
.willReturn(ticket(13L, true, PlaybackService.ACCESS_FREE, "/files/ep13.mp4?s=1"));
.willReturn(ticket(13L, true, PlaybackService.ACCESS_FREE,
"/files/ep13.mp4?s=1", null));
mockMvc.perform(get("/app/episodes/13/play").header("X-Device-Id", "device-9"))
.andExpect(status().isOk());
@@ -95,7 +102,8 @@ class AppPlaybackContractTest {
verify(playbackService).play(isNull(), eq(13L), eq("device-9"), any());
}
private PlayRespVO ticket(Long episodeId, boolean accessible, String accessType, String playUrl) {
private PlayRespVO ticket(Long episodeId, boolean accessible, String accessType,
String playUrl, String thumbnailsUrl) {
PlayRespVO vo = new PlayRespVO();
vo.setEpisodeId(episodeId);
vo.setDramaId(7L);
@@ -105,6 +113,7 @@ class AppPlaybackContractTest {
vo.setAccessible(accessible);
vo.setAccessType(accessType);
vo.setPlayUrl(playUrl);
vo.setThumbnailsUrl(thumbnailsUrl);
vo.setQualities(List.of("AUTO"));
vo.setUnlockCoin(0);
vo.setPreviewSeconds(0);
@@ -12,6 +12,7 @@ import com.fruitshorts.content.service.DramaService;
import com.fruitshorts.content.service.EpisodeService;
import com.fruitshorts.interaction.service.CommentService;
import com.fruitshorts.media.service.PlaybackSigner;
import com.fruitshorts.media.service.ThumbnailUrlResolver;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -35,6 +36,7 @@ class AppCatalogServiceTest {
private EpisodeService episodeService;
private DramaCategoryService dramaCategoryService;
private CommentService commentService;
private ThumbnailUrlResolver thumbnailUrlResolver;
private AppCatalogService catalogService;
@BeforeEach
@@ -44,9 +46,11 @@ class AppCatalogServiceTest {
BannerService bannerService = mock(BannerService.class);
dramaCategoryService = mock(DramaCategoryService.class);
PlaybackSigner playbackSigner = mock(PlaybackSigner.class);
thumbnailUrlResolver = mock(ThumbnailUrlResolver.class);
given(thumbnailUrlResolver.resolveAll(any())).willReturn(Map.of());
commentService = mock(CommentService.class);
catalogService = new AppCatalogService(dramaService, episodeService, bannerService,
dramaCategoryService, playbackSigner, commentService);
dramaCategoryService, playbackSigner, commentService, thumbnailUrlResolver);
}
@Test
@@ -66,6 +70,8 @@ class AppCatalogServiceTest {
given(commentService.countByDrama(42L)).willReturn(96L);
given(commentService.countByEpisodes(List.of(11L, 22L)))
.willReturn(Map.of(11L, 2L, 22L, 4L));
given(thumbnailUrlResolver.resolveAll(List.of(11L, 22L)))
.willReturn(Map.of(22L, "/files/transcoded/22/thumbnails/thumbnails.vtt"));
AppDramaDetailVO detail = catalogService.dramaDetail(42L);
@@ -75,7 +81,10 @@ class AppCatalogServiceTest {
assertThat(detail.getCommentCount()).isEqualTo(96L);
assertThat(detail.getEpisodes().get(0).getLikeCount()).isEqualTo(66L);
assertThat(detail.getEpisodes().get(0).getCommentCount()).isEqualTo(4L);
assertThat(detail.getEpisodes().get(0).getThumbnailsUrl())
.isEqualTo("/files/transcoded/22/thumbnails/thumbnails.vtt");
verify(commentService).countByDrama(42L);
verify(thumbnailUrlResolver).resolveAll(List.of(11L, 22L));
}
@Test
@@ -12,6 +12,7 @@ import com.fruitshorts.content.dal.mapper.DramaMapper;
import com.fruitshorts.content.dal.mapper.DramaReviewRecordMapper;
import com.fruitshorts.content.service.DramaCategoryService;
import com.fruitshorts.content.service.DramaService;
import com.fruitshorts.content.service.EpisodePriceSync;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
@@ -41,7 +42,8 @@ class DramaServiceFavoriteCountTest {
dramaMapper,
mock(DramaEpisodeMapper.class),
mock(DramaReviewRecordMapper.class),
mock(DramaCategoryService.class));
mock(DramaCategoryService.class),
mock(EpisodePriceSync.class));
given(dramaMapper.update(isNull(), any())).willReturn(1);
ArgumentCaptor<Wrapper<Drama>> captor = ArgumentCaptor.forClass(Wrapper.class);
@@ -11,6 +11,7 @@ import com.fruitshorts.content.dal.mapper.DramaMapper;
import com.fruitshorts.content.dal.mapper.DramaReviewRecordMapper;
import com.fruitshorts.content.service.DramaCategoryService;
import com.fruitshorts.content.service.DramaService;
import com.fruitshorts.content.service.EpisodePriceSync;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
@@ -45,7 +46,8 @@ class DramaServiceFeedTest {
dramaMapper,
mock(DramaEpisodeMapper.class),
mock(DramaReviewRecordMapper.class),
mock(DramaCategoryService.class));
mock(DramaCategoryService.class),
mock(EpisodePriceSync.class));
}
@Test
@@ -12,6 +12,7 @@ import com.fruitshorts.content.dal.mapper.DramaMapper;
import com.fruitshorts.content.dal.mapper.DramaReviewRecordMapper;
import com.fruitshorts.content.service.DramaCategoryService;
import com.fruitshorts.content.service.DramaService;
import com.fruitshorts.content.service.EpisodePriceSync;
import com.fruitshorts.content.vo.DramaSaveReqVO;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.junit.jupiter.api.BeforeAll;
@@ -46,7 +47,8 @@ class DramaServiceK88Test {
dramaMapper,
mock(DramaEpisodeMapper.class),
mock(DramaReviewRecordMapper.class),
mock(DramaCategoryService.class));
mock(DramaCategoryService.class),
mock(EpisodePriceSync.class));
}
@Test
@@ -13,6 +13,7 @@ import com.fruitshorts.content.dal.mapper.DramaMapper;
import com.fruitshorts.content.dal.mapper.DramaReviewRecordMapper;
import com.fruitshorts.content.service.DramaCategoryService;
import com.fruitshorts.content.service.DramaService;
import com.fruitshorts.content.service.EpisodePriceSync;
import com.fruitshorts.content.vo.DramaPageReqVO;
import com.fruitshorts.content.vo.DramaRespVO;
import com.fruitshorts.content.vo.DramaSaveReqVO;
@@ -54,7 +55,8 @@ class DramaServiceMultiTagTest {
dramaMapper,
mock(DramaEpisodeMapper.class),
mock(DramaReviewRecordMapper.class),
categoryService);
categoryService,
mock(EpisodePriceSync.class));
}
@Test
@@ -6,6 +6,7 @@ import com.fruitshorts.content.dal.entity.DramaEpisode;
import com.fruitshorts.content.service.DramaService;
import com.fruitshorts.content.service.EpisodeService;
import com.fruitshorts.media.service.PlaybackSigner;
import com.fruitshorts.media.service.ThumbnailUrlResolver;
import com.fruitshorts.media.service.TranscodeService;
import com.fruitshorts.playback.dal.entity.PlaybackTicket;
import com.fruitshorts.playback.dal.mapper.PlaybackTicketMapper;
@@ -42,6 +43,7 @@ class PlaybackServiceTest {
private PlaybackSigner signer;
private EntitlementPort entitlementPort;
private TranscodeService transcodeService;
private ThumbnailUrlResolver thumbnailUrlResolver;
private PlaybackService playbackService;
@BeforeEach
@@ -52,10 +54,12 @@ class PlaybackServiceTest {
signer = mock(PlaybackSigner.class);
entitlementPort = mock(EntitlementPort.class);
transcodeService = mock(TranscodeService.class);
thumbnailUrlResolver = mock(ThumbnailUrlResolver.class);
playbackService = new PlaybackService(episodeService, dramaService, ticketMapper, signer,
entitlementPort, transcodeService);
entitlementPort, transcodeService, thumbnailUrlResolver);
given(dramaService.getPublished(anyLong())).willReturn(drama());
given(transcodeService.listRenditions(anyLong())).willReturn(List.of());
given(thumbnailUrlResolver.resolve(anyLong())).willReturn(null);
given(signer.sign(anyString())).willReturn("/files/episode/1.mp4?e=1&s=sig");
}
@@ -72,6 +76,27 @@ class PlaybackServiceTest {
verify(ticketMapper, never()).insert(any(PlaybackTicket.class));
}
@Test
void should_return_signed_thumbnails_url_when_thumbnail_exists() {
given(episodeService.getPublished(11L)).willReturn(episode(11L, 1, 0, "/files/episode/1.mp4"));
given(thumbnailUrlResolver.resolve(11L))
.willReturn("/files/transcoded/1/thumbnails/thumbnails.vtt?e=1&s=sig");
PlayRespVO resp = playbackService.play(null, 11L, "device-9", "203.0.113.8");
assertThat(resp.getThumbnailsUrl())
.isEqualTo("/files/transcoded/1/thumbnails/thumbnails.vtt?e=1&s=sig");
}
@Test
void should_return_null_thumbnails_url_when_thumbnail_is_absent() {
given(episodeService.getPublished(11L)).willReturn(episode(11L, 1, 0, "/files/episode/1.mp4"));
PlayRespVO resp = playbackService.play(null, 11L, "device-9", "203.0.113.8");
assertThat(resp.getThumbnailsUrl()).isNull();
}
@Test
void should_return_coin_required_when_episode_is_locked_and_not_entitled() {
given(episodeService.getPublished(12L)).willReturn(episode(12L, 0, 60, "/files/episode/2.mp4"));
@@ -0,0 +1,55 @@
// Author: fruit.com.cn
package com.fruitshorts.app.service;
import com.fruitshorts.media.dal.entity.MediaThumbnail;
import com.fruitshorts.media.service.MediaStorageService;
import com.fruitshorts.media.service.PlaybackSigner;
import com.fruitshorts.media.service.ThumbnailUrlResolver;
import com.fruitshorts.media.service.TranscodeService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
/**
* 缩略图 objectKey 到公开签名地址的转换。
*/
class ThumbnailUrlResolverTest {
private TranscodeService transcodeService;
private MediaStorageService storageService;
private PlaybackSigner signer;
private ThumbnailUrlResolver resolver;
@BeforeEach
void setUp() {
transcodeService = mock(TranscodeService.class);
storageService = mock(MediaStorageService.class);
signer = mock(PlaybackSigner.class);
resolver = new ThumbnailUrlResolver(transcodeService, storageService, signer);
}
@Test
void should_sign_vtt_public_url_when_ready_thumbnail_exists() {
MediaThumbnail thumbnail = new MediaThumbnail();
thumbnail.setVttObjectKey("transcoded/88/thumbnails/thumbnails.vtt");
given(transcodeService.findReadyThumbnail(11L)).willReturn(thumbnail);
given(storageService.toPublicUrl(thumbnail.getVttObjectKey()))
.willReturn("/files/transcoded/88/thumbnails/thumbnails.vtt");
given(signer.sign("/files/transcoded/88/thumbnails/thumbnails.vtt"))
.willReturn("/files/transcoded/88/thumbnails/thumbnails.vtt?e=1&s=sig");
assertThat(resolver.resolve(11L))
.isEqualTo("/files/transcoded/88/thumbnails/thumbnails.vtt?e=1&s=sig");
verify(signer).sign("/files/transcoded/88/thumbnails/thumbnails.vtt");
}
@Test
void should_return_null_when_thumbnail_record_is_absent() {
assertThat(resolver.resolve(12L)).isNull();
}
}
+4
View File
@@ -21,5 +21,9 @@
<groupId>com.fruitshorts</groupId>
<artifactId>fruitshorts-framework</artifactId>
</dependency>
<dependency>
<groupId>com.fruitshorts</groupId>
<artifactId>fruitshorts-module-system</artifactId>
</dependency>
</dependencies>
</project>
@@ -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<>();
@@ -8,6 +8,7 @@ import com.fruitshorts.content.service.DramaService;
import com.fruitshorts.content.service.EpisodeService;
import com.fruitshorts.media.dal.entity.MediaRendition;
import com.fruitshorts.media.service.PlaybackSigner;
import com.fruitshorts.media.service.ThumbnailUrlResolver;
import com.fruitshorts.media.service.TranscodeService;
import com.fruitshorts.playback.dal.entity.PlaybackTicket;
import com.fruitshorts.playback.dal.mapper.PlaybackTicketMapper;
@@ -46,6 +47,7 @@ public class PlaybackService {
private final PlaybackSigner signer;
private final EntitlementPort entitlementPort;
private final TranscodeService transcodeService;
private final ThumbnailUrlResolver thumbnailUrlResolver;
@Transactional(rollbackFor = Exception.class)
public PlayRespVO play(Long userId, Long episodeId, String deviceId, String clientIp) {
@@ -65,6 +67,7 @@ public class PlaybackService {
vo.setUnlockCoin(episode.getUnlockCoin());
vo.setPreviewSeconds(episode.getPreviewSeconds());
vo.setQualities(qualitiesOf(episode.getId()));
vo.setThumbnailsUrl(thumbnailUrlResolver.resolve(episode.getId()));
if (!StringUtils.hasText(source)) {
// 还没转码或没上传片源:明确告诉客户端"没有片源",而不是给一个播不了的地址
@@ -23,6 +23,8 @@ public class PlayRespVO {
private String playUrl;
/** 可用的清晰度档位 */
private List<String> qualities;
/** 雪碧图 WebVTT 时间轴地址;没有产物时为 null */
private String thumbnailsUrl;
/** 需要多少金币解锁 */
private Integer unlockCoin;
/** 试看秒数,0 表示不限制 */
@@ -45,7 +45,8 @@ class FfmpegTranscodeRunnerSmokeTest {
1L, sample, output, "transcoded/smoke",
List.of(new TranscodeRunner.LadderRung("360p", 640, 360, 400, 64),
new TranscodeRunner.LadderRung("480p", 854, 480, 800, 64)),
ffmpeg, ffprobe, 2, 1, 120));
ffmpeg, ffprobe, 2, 1, 120,
new TranscodeRunner.SpriteOptions(true, 1, 10, 160)));
assertThat(Files.exists(output.resolve("master.m3u8"))).isTrue();
assertThat(Files.exists(output.resolve("poster.jpg"))).isTrue();
@@ -59,6 +60,10 @@ class FfmpegTranscodeRunnerSmokeTest {
assertThat(rendition.sizeBytes()).isGreaterThan(0L);
}
assertThat(result.durationSec()).isBetween(1, 5);
assertThat(result.thumbnail()).isNotNull();
assertThat(result.thumbnail().status()).isEqualTo("READY");
assertThat(Files.exists(output.resolve("thumbnails/thumbnails.vtt"))).isTrue();
assertThat(Files.exists(output.resolve("thumbnails/sprite_001.jpg"))).isTrue();
assertThat(readLines(output.resolve("master.m3u8")))
.anyMatch(line -> line.contains("360p/index.m3u8"))
.anyMatch(line -> line.contains("480p/index.m3u8"));
@@ -0,0 +1,65 @@
// Author: fruit.com.cn
package com.fruitshorts.worker;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fruitshorts.media.config.MediaTranscodeProfile;
import com.fruitshorts.media.config.TranscodeProperties;
import com.fruitshorts.media.service.MediaTranscodeProfileService;
import com.fruitshorts.system.service.ConfigService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
/**
* 后台转码配置的解析、缓存与非法回退。
*/
class MediaTranscodeProfileServiceTest {
private ConfigService configService;
private MediaTranscodeProfileService service;
@BeforeEach
void setUp() {
configService = mock(ConfigService.class);
service = new MediaTranscodeProfileService(
configService, new ObjectMapper(), new TranscodeProperties());
}
@Test
void should_read_valid_profile_and_cache_for_30_seconds() throws Exception {
MediaTranscodeProfile profile = new MediaTranscodeProfile();
profile.setSegmentSeconds(4);
profile.getLadder().get(0).setVideoBitrateKbps(500);
given(configService.get(eq(MediaTranscodeProfileService.CONFIG_KEY), anyString()))
.willReturn(new ObjectMapper().writeValueAsString(profile));
MediaTranscodeProfile first = service.currentProfile();
MediaTranscodeProfile second = service.currentProfile();
assertThat(first.getSegmentSeconds()).isEqualTo(4);
assertThat(first.getLadder().get(0).getVideoBitrateKbps()).isEqualTo(500);
assertThat(second).isSameAs(first);
verify(configService, times(1))
.get(eq(MediaTranscodeProfileService.CONFIG_KEY), anyString());
}
@Test
void should_fallback_to_yml_default_when_profile_is_illegal() {
given(configService.get(eq(MediaTranscodeProfileService.CONFIG_KEY), anyString()))
.willReturn("{\"segmentSeconds\":1,\"ladder\":[]}");
MediaTranscodeProfile profile = service.currentProfile();
assertThat(profile.getSegmentSeconds()).isEqualTo(6);
assertThat(profile.getLadder()).extracting(MediaTranscodeProfile.Rung::getQuality)
.containsExactly("360p", "480p", "720p", "1080p");
}
}
@@ -0,0 +1,44 @@
// Author: fruit.com.cn
package com.fruitshorts.worker;
import com.fruitshorts.media.service.transcode.ThumbnailVttBuilder;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/**
* WebVTT cue 时间与雪碧图行列坐标。
*/
class ThumbnailVttBuilderTest {
@Test
void should_write_time_ranges_and_xywh_coordinates() {
String vtt = ThumbnailVttBuilder.build(
new ThumbnailVttBuilder.Layout(3, 1, 10, 1, 160, 90));
assertThat(vtt).startsWith("WEBVTT");
assertThat(vtt).contains("00:00:00.000 --> 00:00:01.000\n"
+ "sprite_001.jpg#xywh=0,0,160,90");
assertThat(vtt).contains("00:00:01.000 --> 00:00:02.000\n"
+ "sprite_001.jpg#xywh=160,0,160,90");
assertThat(vtt).contains("00:00:02.000 --> 00:00:03.000\n"
+ "sprite_001.jpg#xywh=320,0,160,90");
}
@Test
void should_switch_to_second_sprite_after_full_tile() {
String vtt = ThumbnailVttBuilder.build(
new ThumbnailVttBuilder.Layout(101, 1, 10, 10, 160, 90));
assertThat(vtt).contains("00:01:40.000 --> 00:01:41.000\n"
+ "sprite_002.jpg#xywh=0,0,160,90");
}
@Test
void should_cap_tile_rows_at_column_count() {
assertThat(ThumbnailVttBuilder.frameCount(21, 1)).isEqualTo(21);
assertThat(ThumbnailVttBuilder.rows(21, 10)).isEqualTo(3);
assertThat(ThumbnailVttBuilder.rows(121, 10)).isEqualTo(10);
}
}
@@ -6,8 +6,11 @@ import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.fruitshorts.common.exception.ServiceException;
import com.fruitshorts.media.config.MediaProperties;
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 com.fruitshorts.media.service.MediaStorageService;
import com.fruitshorts.media.service.TranscodeService;
@@ -39,12 +42,16 @@ class TranscodeClaimAndStorageTest {
*/
@BeforeAll
static void initTableInfoCache() {
TableInfoHelper.initTableInfo(
new MapperBuilderAssistant(new MybatisConfiguration(), ""), TranscodeTask.class);
MapperBuilderAssistant assistant =
new MapperBuilderAssistant(new MybatisConfiguration(), "");
TableInfoHelper.initTableInfo(assistant, TranscodeTask.class);
TableInfoHelper.initTableInfo(assistant, MediaRendition.class);
TableInfoHelper.initTableInfo(assistant, MediaThumbnail.class);
}
private TranscodeTaskMapper transcodeTaskMapper;
private MediaRenditionMapper mediaRenditionMapper;
private MediaThumbnailMapper mediaThumbnailMapper;
private TranscodeService transcodeService;
private MediaProperties mediaProperties;
private MediaStorageService storageService;
@@ -53,7 +60,9 @@ class TranscodeClaimAndStorageTest {
void setUp() {
transcodeTaskMapper = mock(TranscodeTaskMapper.class);
mediaRenditionMapper = mock(MediaRenditionMapper.class);
transcodeService = new TranscodeService(transcodeTaskMapper, mediaRenditionMapper);
mediaThumbnailMapper = mock(MediaThumbnailMapper.class);
transcodeService = new TranscodeService(
transcodeTaskMapper, mediaRenditionMapper, mediaThumbnailMapper);
mediaProperties = new MediaProperties();
mediaProperties.setRootDir("/tmp/fruitshorts-storage-test");
mediaProperties.setPublicPrefix("/files");
@@ -88,6 +97,24 @@ class TranscodeClaimAndStorageTest {
verify(transcodeTaskMapper, never()).update(any(), any());
}
@Test
void should_insert_thumbnail_when_main_transcode_succeeds() {
TranscodeTask task = task(3L);
task.setDramaId(7L);
task.setEpisodeId(11L);
given(transcodeTaskMapper.selectById(3L)).willReturn(task);
MediaThumbnail thumbnail = new MediaThumbnail();
thumbnail.setEpisodeId(11L);
thumbnail.setVttObjectKey("transcoded/3/thumbnails/thumbnails.vtt");
thumbnail.setStatus(MediaThumbnail.STATUS_READY);
transcodeService.succeed(3L, java.util.List.of(), thumbnail);
verify(mediaThumbnailMapper).insert(thumbnail);
assertThat(task.getStatus()).isEqualTo(TranscodeService.STATUS_SUCCESS);
verify(transcodeTaskMapper).updateById(task);
}
@Test
void should_reject_object_key_that_escapes_the_storage_root() {
assertThatThrownBy(() -> storageService.fileFor("../../etc/passwd"))
@@ -2,11 +2,13 @@
package com.fruitshorts.worker;
import com.fruitshorts.media.config.TranscodeProperties;
import com.fruitshorts.media.config.MediaTranscodeProfile;
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.service.MediaService;
import com.fruitshorts.media.service.MediaStorageService;
import com.fruitshorts.media.service.MediaTranscodeProfileService;
import com.fruitshorts.media.service.TranscodeService;
import com.fruitshorts.media.service.transcode.TranscodeRunner;
import com.fruitshorts.media.service.transcode.TranscodeWorker;
@@ -41,6 +43,7 @@ class TranscodeWorkerTest {
private MediaService mediaService;
private MediaStorageService storageService;
private StubRunner runner;
private MediaTranscodeProfileService profileService;
private TranscodeWorker worker;
@BeforeEach
@@ -48,9 +51,15 @@ class TranscodeWorkerTest {
transcodeService = mock(TranscodeService.class);
mediaService = mock(MediaService.class);
storageService = mock(MediaStorageService.class);
profileService = mock(MediaTranscodeProfileService.class);
runner = new StubRunner();
worker = new TranscodeWorker(transcodeService, mediaService, storageService, runner,
new TranscodeProperties());
profileService);
given(profileService.currentProfile()).willReturn(new MediaTranscodeProfile());
given(profileService.ffmpegPath()).willReturn("ffmpeg");
given(profileService.ffprobePath()).willReturn("ffprobe");
given(profileService.posterSecond()).willReturn(1);
given(profileService.timeoutSeconds()).willReturn(120);
given(storageService.objectKeyOf(anyString())).willReturn("episode/202610/1.mp4");
given(storageService.fileFor(anyString())).willReturn(Paths.get("/tmp/fruitshorts-test/1.mp4"));
given(storageService.toPublicUrl(anyString()))
@@ -65,11 +74,17 @@ class TranscodeWorkerTest {
@SuppressWarnings("unchecked")
ArgumentCaptor<List<MediaRendition>> captor = ArgumentCaptor.forClass(List.class);
verify(transcodeService).succeed(eq(TASK_ID), captor.capture());
@SuppressWarnings("unchecked")
ArgumentCaptor<MediaThumbnail> thumbnailCaptor =
ArgumentCaptor.forClass(MediaThumbnail.class);
verify(transcodeService).succeed(eq(TASK_ID), captor.capture(),
thumbnailCaptor.capture());
assertThat(captor.getValue()).hasSize(2);
// 对外地址一律经 MediaStorageService,worker 不自己拼磁盘路径
assertThat(captor.getValue().get(0).getPlayUrl()).startsWith("/files/transcoded/88/");
assertThat(captor.getValue().get(0).getFormat()).isEqualTo("HLS");
assertThat(thumbnailCaptor.getValue().getStatus())
.isEqualTo(MediaThumbnail.STATUS_READY);
verify(mediaService).markReady(eq(ASSET_ID), eq("/files/transcoded/88/master.m3u8"),
eq("/files/transcoded/88/poster.jpg"), eq(120));
verify(transcodeService, never()).fail(any(), anyString());
@@ -87,6 +102,31 @@ class TranscodeWorkerTest {
assertThat(runner.request.objectKeyPrefix()).isEqualTo("transcoded/88");
}
@Test
void should_keep_main_transcode_successful_when_thumbnail_generation_fails() {
given(transcodeService.claimNext()).willReturn(queuedTask());
runner.thumbnail = new TranscodeRunner.ProducedThumbnail(
"transcoded/88/thumbnails/thumbnails.vtt",
"transcoded/88/thumbnails/sprite_",
1, 10, 1, 160, 0, 120, 120, 0L,
MediaThumbnail.STATUS_FAILED, "tile 滤镜失败");
worker.processOne();
@SuppressWarnings("unchecked")
ArgumentCaptor<List<MediaRendition>> renditions =
ArgumentCaptor.forClass(List.class);
ArgumentCaptor<MediaThumbnail> thumbnail =
ArgumentCaptor.forClass(MediaThumbnail.class);
verify(transcodeService).succeed(eq(TASK_ID), renditions.capture(),
thumbnail.capture());
assertThat(thumbnail.getValue().getStatus())
.isEqualTo(MediaThumbnail.STATUS_FAILED);
assertThat(thumbnail.getValue().getErrorMsg()).contains("tile");
verify(mediaService).markReady(eq(ASSET_ID), anyString(), anyString(), eq(120));
verify(transcodeService, never()).fail(any(), anyString());
}
@Test
void should_keep_task_queued_and_record_reason_when_transcode_fails_before_max_retry() {
TranscodeTask task = queuedTask();
@@ -144,6 +184,7 @@ class TranscodeWorkerTest {
private static final class StubRunner implements TranscodeRunner {
private TranscodeRequest request;
private RuntimeException failure;
private ProducedThumbnail thumbnail;
private boolean called;
@Override
@@ -153,13 +194,20 @@ class TranscodeWorkerTest {
if (failure != null) {
throw failure;
}
ProducedThumbnail resultThumbnail = thumbnail == null
? new ProducedThumbnail(
request.objectKeyPrefix() + "/thumbnails/thumbnails.vtt",
request.objectKeyPrefix() + "/thumbnails/sprite_",
1, 10, 1, 160, 90, 120, 120, 2048L,
MediaThumbnail.STATUS_READY, "")
: thumbnail;
return new TranscodeResult(
request.objectKeyPrefix() + "/master.m3u8",
List.of(new ProducedRendition("360p", 640, 360, 400, 64,
request.objectKeyPrefix() + "/360p/index.m3u8", 1024L, 4),
new ProducedRendition("480p", 854, 480, 800, 64,
request.objectKeyPrefix() + "/480p/index.m3u8", 2048L, 4)),
request.objectKeyPrefix() + "/poster.jpg", 120);
request.objectKeyPrefix() + "/poster.jpg", 120, resultThumbnail);
}
}
}
+12
View File
@@ -338,5 +338,17 @@ INSERT INTO sys_config (id, config_key, config_value, remark) VALUES
(4, 'app.register.max-per-device-per-day', '0', '0=不限制;生产建议 3')
ON CONFLICT (config_key) DO NOTHING;
INSERT INTO sys_config (id, config_key, config_value, remark) VALUES
(2026102201,
'media.transcode.profile',
'{"enabled":true,"segmentSeconds":6,"ladder":[' ||
'{"quality":"360p","width":640,"height":360,"videoBitrateKbps":400,"audioBitrateKbps":64,"enabled":true},' ||
'{"quality":"480p","width":854,"height":480,"videoBitrateKbps":800,"audioBitrateKbps":64,"enabled":true},' ||
'{"quality":"720p","width":1280,"height":720,"videoBitrateKbps":1600,"audioBitrateKbps":96,"enabled":true},' ||
'{"quality":"1080p","width":1920,"height":1080,"videoBitrateKbps":3200,"audioBitrateKbps":128,"enabled":true}],' ||
'"sprite":{"enabled":true,"intervalSeconds":1,"columns":10,"thumbWidth":160}}',
'转码档位与缩略图轴配置')
ON CONFLICT (config_key) DO NOTHING;
-- K23:二级菜单统一只显示文字,历史图标值也一并清空。
UPDATE sys_menu SET icon = '' WHERE type = 'MENU';
+45
View File
@@ -0,0 +1,45 @@
-- Author: fruit.com.cn
-- 转码缩略图轴:默认档位配置 + 单集雪碧图/VTT 元数据(幂等)
CREATE TABLE IF NOT EXISTS media_thumbnail (
id BIGINT PRIMARY KEY,
episode_id BIGINT NOT NULL,
drama_id BIGINT NOT NULL DEFAULT 0,
vtt_object_key VARCHAR(512) NOT NULL DEFAULT '',
sprite_prefix VARCHAR(512) NOT NULL DEFAULT '',
interval_sec INT NOT NULL DEFAULT 1,
columns INT NOT NULL DEFAULT 10,
rows INT NOT NULL DEFAULT 1,
thumb_width INT NOT NULL DEFAULT 160,
thumb_height INT NOT NULL DEFAULT 0,
frame_count INT NOT NULL DEFAULT 0,
duration_sec INT NOT NULL DEFAULT 0,
size_bytes BIGINT NOT NULL DEFAULT 0,
status VARCHAR(16) NOT NULL DEFAULT 'FAILED',
error_msg VARCHAR(500) NOT NULL DEFAULT '',
created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted SMALLINT NOT NULL DEFAULT 0
);
CREATE UNIQUE INDEX IF NOT EXISTS uk_media_thumbnail_episode
ON media_thumbnail (episode_id);
CREATE INDEX IF NOT EXISTS idx_media_thumbnail_drama
ON media_thumbnail (drama_id);
COMMENT ON TABLE media_thumbnail IS '单集缩略图雪碧图与 VTT 时间轴';
DROP TRIGGER IF EXISTS trg_media_thumbnail_updated_at ON media_thumbnail;
CREATE TRIGGER trg_media_thumbnail_updated_at
BEFORE UPDATE ON media_thumbnail
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
INSERT INTO sys_config (id, config_key, config_value, remark) VALUES
(2026102201,
'media.transcode.profile',
'{"enabled":true,"segmentSeconds":6,"ladder":[' ||
'{"quality":"360p","width":640,"height":360,"videoBitrateKbps":400,"audioBitrateKbps":64,"enabled":true},' ||
'{"quality":"480p","width":854,"height":480,"videoBitrateKbps":800,"audioBitrateKbps":64,"enabled":true},' ||
'{"quality":"720p","width":1280,"height":720,"videoBitrateKbps":1600,"audioBitrateKbps":96,"enabled":true},' ||
'{"quality":"1080p","width":1920,"height":1080,"videoBitrateKbps":3200,"audioBitrateKbps":128,"enabled":true}],' ||
'"sprite":{"enabled":true,"intervalSeconds":1,"columns":10,"thumbWidth":160}}',
'转码档位与缩略图轴配置')
ON CONFLICT (config_key) DO NOTHING;
+27 -1
View File
@@ -785,6 +785,32 @@ CREATE UNIQUE INDEX IF NOT EXISTS uk_rendition ON media_rendition (media_asset_i
CREATE INDEX IF NOT EXISTS idx_rendition_episode ON media_rendition (episode_id);
COMMENT ON TABLE media_rendition IS '多码率产物;播放票据按此生成 master playlist';
CREATE TABLE IF NOT EXISTS media_thumbnail (
id BIGINT PRIMARY KEY,
episode_id BIGINT NOT NULL,
drama_id BIGINT NOT NULL DEFAULT 0,
vtt_object_key VARCHAR(512) NOT NULL DEFAULT '',
sprite_prefix VARCHAR(512) NOT NULL DEFAULT '',
interval_sec INT NOT NULL DEFAULT 1,
columns INT NOT NULL DEFAULT 10,
rows INT NOT NULL DEFAULT 1,
thumb_width INT NOT NULL DEFAULT 160,
thumb_height INT NOT NULL DEFAULT 0,
frame_count INT NOT NULL DEFAULT 0,
duration_sec INT NOT NULL DEFAULT 0,
size_bytes BIGINT NOT NULL DEFAULT 0,
status VARCHAR(16) NOT NULL DEFAULT 'FAILED',
error_msg VARCHAR(500) NOT NULL DEFAULT '',
created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted SMALLINT NOT NULL DEFAULT 0
);
CREATE UNIQUE INDEX IF NOT EXISTS uk_media_thumbnail_episode
ON media_thumbnail (episode_id);
CREATE INDEX IF NOT EXISTS idx_media_thumbnail_drama
ON media_thumbnail (drama_id);
COMMENT ON TABLE media_thumbnail IS '单集缩略图雪碧图与 VTT 时间轴';
-- ============================================================
-- 八、运营补充(PRD 08 平台运营后台 / 10 上线)
-- ============================================================
@@ -858,7 +884,7 @@ BEGIN
'wallet', 'product', 'orders', 'store_receipt', 'entitlement', 'refund_record',
'reconcile_task', 'creator', 'creator_document', 'content_submission',
'creator_stat_daily', 'creator_settlement',
'transcode_task', 'media_rendition',
'transcode_task', 'media_rendition', 'media_thumbnail',
'ops_notice', 'ops_daily_stat'
]
LOOP