diff --git a/docs/api-phase1.md b/docs/api-phase1.md index 99b0abb..59dd970 100644 --- a/docs/api-phase1.md +++ b/docs/api-phase1.md @@ -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 用户管理(已实现) | 方法 | 路径 | 权限点 | 说明 | diff --git a/docs/plans/2026-10-22-media-transcode-profile-sprite.md b/docs/plans/2026-10-22-media-transcode-profile-sprite.md new file mode 100644 index 0000000..9358de0 --- /dev/null +++ b/docs/plans/2026-10-22-media-transcode-profile-sprite.md @@ -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 后续环境验收, + 本任务不启动服务、不改客户端。 diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index 4a97681..246c8ab 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -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。 + diff --git a/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/MediaController.java b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/MediaController.java index 56e7698..c776ac3 100644 --- a/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/MediaController.java +++ b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/MediaController.java @@ -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 get(@PathVariable Long id) { return Result.ok(mediaService.get(id)); } + + @Operation(summary = "读取转码档位配置") + @GetMapping("/transcode-profile") + @PreAuthorize("@ss.hasPermission('episode:upload')") + public Result transcodeProfile() { + return Result.ok(profileService.readConfiguredProfile()); + } + + @Operation(summary = "保存转码档位配置") + @PutMapping("/transcode-profile") + @PreAuthorize("@ss.hasPermission('episode:upload')") + @OperationLog(module = "后台管理", action = "修改转码设置") + public Result saveTranscodeProfile( + @Valid @RequestBody MediaTranscodeProfile profile) { + ImpersonationGuard.rejectWrite(); + return Result.ok(profileService.save(profile)); + } } diff --git a/fruitshorts-admin-api/src/test/java/com/fruitshorts/admin/controller/MediaTranscodeProfileContractTest.java b/fruitshorts-admin-api/src/test/java/com/fruitshorts/admin/controller/MediaTranscodeProfileContractTest.java new file mode 100644 index 0000000..4bb33a1 --- /dev/null +++ b/fruitshorts-admin-api/src/test/java/com/fruitshorts/admin/controller/MediaTranscodeProfileContractTest.java @@ -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> captor = ArgumentCaptor.forClass(Map.class); + verify(configService).update(captor.capture()); + assertThat(captor.getValue().get(MediaTranscodeProfileService.CONFIG_KEY)) + .contains("\"segmentSeconds\":4"); + } +} diff --git a/fruitshorts-app-api/src/main/java/com/fruitshorts/app/service/AppCatalogService.java b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/service/AppCatalogService.java index 674d64a..c73a5b3 100644 --- a/fruitshorts-app-api/src/main/java/com/fruitshorts/app/service/AppCatalogService.java +++ b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/service/AppCatalogService.java @@ -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 categoryNames = categoryNameMap(); @@ -110,6 +112,8 @@ public class AppCatalogService { List episodes = episodeService.listPublishedByDrama(dramaId); Map episodeCommentCounts = commentService.countByEpisodes( episodes.stream().map(DramaEpisode::getId).sorted().toList()); + Map 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 previews = episodeService.firstPlayableByDramas(ids); Map commentCounts = commentService.countByEpisodes( previews.values().stream().map(DramaEpisode::getId).sorted().toList()); + Map 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 categoryNames, - DramaEpisode preview, Map commentCounts) { + DramaEpisode preview, Map commentCounts, + Map 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 commentCounts) { + private AppEpisodeVO toEpisode(DramaEpisode episode, Map commentCounts, + Map 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))); diff --git a/fruitshorts-app-api/src/main/java/com/fruitshorts/app/vo/AppEpisodeVO.java b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/vo/AppEpisodeVO.java index 757b024..c46f882 100644 --- a/fruitshorts-app-api/src/main/java/com/fruitshorts/app/vo/AppEpisodeVO.java +++ b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/vo/AppEpisodeVO.java @@ -24,4 +24,6 @@ public class AppEpisodeVO { * 详情页拿到就能直接播,不必按集再请求一次播放票据。 */ private String playUrl; + /** 雪碧图 WebVTT 时间轴地址;没有产物时为 null */ + private String thumbnailsUrl; } diff --git a/fruitshorts-app-api/src/test/java/com/fruitshorts/app/controller/AppPlaybackContractTest.java b/fruitshorts-app-api/src/test/java/com/fruitshorts/app/controller/AppPlaybackContractTest.java index 9907a4d..7cc7c35 100644 --- a/fruitshorts-app-api/src/test/java/com/fruitshorts/app/controller/AppPlaybackContractTest.java +++ b/fruitshorts-app-api/src/test/java/com/fruitshorts/app/controller/AppPlaybackContractTest.java @@ -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); diff --git a/fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/AppCatalogServiceTest.java b/fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/AppCatalogServiceTest.java index d4cb24e..5e147de 100644 --- a/fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/AppCatalogServiceTest.java +++ b/fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/AppCatalogServiceTest.java @@ -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 diff --git a/fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/DramaServiceFavoriteCountTest.java b/fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/DramaServiceFavoriteCountTest.java index e584b3f..d1c1fb0 100644 --- a/fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/DramaServiceFavoriteCountTest.java +++ b/fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/DramaServiceFavoriteCountTest.java @@ -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> captor = ArgumentCaptor.forClass(Wrapper.class); diff --git a/fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/DramaServiceFeedTest.java b/fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/DramaServiceFeedTest.java index 5d74ea5..5e4111b 100644 --- a/fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/DramaServiceFeedTest.java +++ b/fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/DramaServiceFeedTest.java @@ -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 diff --git a/fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/DramaServiceK88Test.java b/fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/DramaServiceK88Test.java index 1f428c5..6287c06 100644 --- a/fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/DramaServiceK88Test.java +++ b/fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/DramaServiceK88Test.java @@ -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 diff --git a/fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/DramaServiceMultiTagTest.java b/fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/DramaServiceMultiTagTest.java index 70a14ee..7d6c712 100644 --- a/fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/DramaServiceMultiTagTest.java +++ b/fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/DramaServiceMultiTagTest.java @@ -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 diff --git a/fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/PlaybackServiceTest.java b/fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/PlaybackServiceTest.java index 9aab416..b6762e9 100644 --- a/fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/PlaybackServiceTest.java +++ b/fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/PlaybackServiceTest.java @@ -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")); diff --git a/fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/ThumbnailUrlResolverTest.java b/fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/ThumbnailUrlResolverTest.java new file mode 100644 index 0000000..34a9f0e --- /dev/null +++ b/fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/ThumbnailUrlResolverTest.java @@ -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(); + } +} diff --git a/fruitshorts-module-media/pom.xml b/fruitshorts-module-media/pom.xml index a254d1a..756f5e0 100644 --- a/fruitshorts-module-media/pom.xml +++ b/fruitshorts-module-media/pom.xml @@ -21,5 +21,9 @@ com.fruitshorts fruitshorts-framework + + com.fruitshorts + fruitshorts-module-system + diff --git a/fruitshorts-module-media/src/main/java/com/fruitshorts/media/config/MediaTranscodeProfile.java b/fruitshorts-module-media/src/main/java/com/fruitshorts/media/config/MediaTranscodeProfile.java new file mode 100644 index 0000000..1b83f74 --- /dev/null +++ b/fruitshorts-module-media/src/main/java/com/fruitshorts/media/config/MediaTranscodeProfile.java @@ -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 ladder = defaultLadder(); + private Sprite sprite = new Sprite(); + + /** 默认四档与第一阶段 yml 保持一致。 */ + public static List defaultLadder() { + List 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; + } +} diff --git a/fruitshorts-module-media/src/main/java/com/fruitshorts/media/config/MediaTranscodeProfileValidator.java b/fruitshorts-module-media/src/main/java/com/fruitshorts/media/config/MediaTranscodeProfileValidator.java new file mode 100644 index 0000000..a88a25c --- /dev/null +++ b/fruitshorts-module-media/src/main/java/com/fruitshorts/media/config/MediaTranscodeProfileValidator.java @@ -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 ladder) { + if (ladder == null) { + throw invalid("ladder 不能为空"); + } + Set 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 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); + } +} diff --git a/fruitshorts-module-media/src/main/java/com/fruitshorts/media/config/TranscodeProperties.java b/fruitshorts-module-media/src/main/java/com/fruitshorts/media/config/TranscodeProperties.java index 234117f..c81b17b 100644 --- a/fruitshorts-module-media/src/main/java/com/fruitshorts/media/config/TranscodeProperties.java +++ b/fruitshorts-module-media/src/main/java/com/fruitshorts/media/config/TranscodeProperties.java @@ -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; + } } } diff --git a/fruitshorts-module-media/src/main/java/com/fruitshorts/media/dal/entity/MediaThumbnail.java b/fruitshorts-module-media/src/main/java/com/fruitshorts/media/dal/entity/MediaThumbnail.java new file mode 100644 index 0000000..192723a --- /dev/null +++ b/fruitshorts-module-media/src/main/java/com/fruitshorts/media/dal/entity/MediaThumbnail.java @@ -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; +} diff --git a/fruitshorts-module-media/src/main/java/com/fruitshorts/media/dal/mapper/MediaThumbnailMapper.java b/fruitshorts-module-media/src/main/java/com/fruitshorts/media/dal/mapper/MediaThumbnailMapper.java new file mode 100644 index 0000000..57e9fcf --- /dev/null +++ b/fruitshorts-module-media/src/main/java/com/fruitshorts/media/dal/mapper/MediaThumbnailMapper.java @@ -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 { +} diff --git a/fruitshorts-module-media/src/main/java/com/fruitshorts/media/service/MediaTranscodeProfileService.java b/fruitshorts-module-media/src/main/java/com/fruitshorts/media/service/MediaTranscodeProfileService.java new file mode 100644 index 0000000..cd1e511 --- /dev/null +++ b/fruitshorts-module-media/src/main/java/com/fruitshorts/media/service/MediaTranscodeProfileService.java @@ -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) { + } +} diff --git a/fruitshorts-module-media/src/main/java/com/fruitshorts/media/service/ThumbnailUrlResolver.java b/fruitshorts-module-media/src/main/java/com/fruitshorts/media/service/ThumbnailUrlResolver.java new file mode 100644 index 0000000..b32b0ac --- /dev/null +++ b/fruitshorts-module-media/src/main/java/com/fruitshorts/media/service/ThumbnailUrlResolver.java @@ -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 resolveAll(Collection episodeIds) { + Map thumbnails = + transcodeService.listReadyThumbnails(episodeIds); + Map 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; + } +} diff --git a/fruitshorts-module-media/src/main/java/com/fruitshorts/media/service/TranscodeService.java b/fruitshorts-module-media/src/main/java/com/fruitshorts/media/service/TranscodeService.java index d9f5879..f8c1f36 100644 --- a/fruitshorts-module-media/src/main/java/com/fruitshorts/media/service/TranscodeService.java +++ b/fruitshorts-module-media/src/main/java/com/fruitshorts/media/service/TranscodeService.java @@ -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 renditions) { + succeed(taskId, renditions, null); + } + + /** 主转码成功:登记码率产物、缩略图结果并完成任务。 */ + @Transactional(rollbackFor = Exception.class) + public void succeed(Long taskId, List 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.lambdaQuery() + .eq(MediaThumbnail::getEpisodeId, episodeId) + .eq(MediaThumbnail::getStatus, MediaThumbnail.STATUS_READY) + .last("limit 1")); + } + + /** 详情页一次批量取回,避免按集 N+1。 */ + public Map listReadyThumbnails(Collection episodeIds) { + if (episodeIds == null || episodeIds.isEmpty()) { + return Map.of(); + } + List rows = mediaThumbnailMapper.selectList( + Wrappers.lambdaQuery() + .in(MediaThumbnail::getEpisodeId, episodeIds.stream().sorted().toList()) + .eq(MediaThumbnail::getStatus, MediaThumbnail.STATUS_READY)); + Map 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.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) { diff --git a/fruitshorts-module-media/src/main/java/com/fruitshorts/media/service/transcode/FfmpegThumbnailGenerator.java b/fruitshorts-module-media/src/main/java/com/fruitshorts/media/service/transcode/FfmpegThumbnailGenerator.java new file mode 100644 index 0000000..ffb916c --- /dev/null +++ b/fruitshorts-module-media/src/main/java/com/fruitshorts/media/service/transcode/FfmpegThumbnailGenerator.java @@ -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 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 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 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; + } +} diff --git a/fruitshorts-module-media/src/main/java/com/fruitshorts/media/service/transcode/FfmpegTranscodeRunner.java b/fruitshorts-module-media/src/main/java/com/fruitshorts/media/service/transcode/FfmpegTranscodeRunner.java index 8f45d89..13f9ca6 100644 --- a/fruitshorts-module-media/src/main/java/com/fruitshorts/media/service/transcode/FfmpegTranscodeRunner.java +++ b/fruitshorts-module-media/src/main/java/com/fruitshorts/media/service/transcode/FfmpegTranscodeRunner.java @@ -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) { diff --git a/fruitshorts-module-media/src/main/java/com/fruitshorts/media/service/transcode/ThumbnailVttBuilder.java b/fruitshorts-module-media/src/main/java/com/fruitshorts/media/service/transcode/ThumbnailVttBuilder.java new file mode 100644 index 0000000..ecdd2fd --- /dev/null +++ b/fruitshorts-module-media/src/main/java/com/fruitshorts/media/service/transcode/ThumbnailVttBuilder.java @@ -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); + } +} diff --git a/fruitshorts-module-media/src/main/java/com/fruitshorts/media/service/transcode/TranscodeRunner.java b/fruitshorts-module-media/src/main/java/com/fruitshorts/media/service/transcode/TranscodeRunner.java index cd9fdf2..814b096 100644 --- a/fruitshorts-module-media/src/main/java/com/fruitshorts/media/service/transcode/TranscodeRunner.java +++ b/fruitshorts-module-media/src/main/java/com/fruitshorts/media/service/transcode/TranscodeRunner.java @@ -29,7 +29,24 @@ public interface TranscodeRunner { */ record TranscodeRequest(Long taskId, Path sourceFile, Path outputDir, String objectKeyPrefix, List 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 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 renditions, - String posterObjectKey, int durationSec) { + String posterObjectKey, int durationSec, + ProducedThumbnail thumbnail) { + + /** 兼容既有测试与未启用缩略图的调用。 */ + public TranscodeResult(String masterObjectKey, List renditions, + String posterObjectKey, int durationSec) { + this(masterObjectKey, renditions, posterObjectKey, durationSec, null); + } } } diff --git a/fruitshorts-module-media/src/main/java/com/fruitshorts/media/service/transcode/TranscodeWorker.java b/fruitshorts-module-media/src/main/java/com/fruitshorts/media/service/transcode/TranscodeWorker.java index b2772d8..fed0eb7 100644 --- a/fruitshorts-module-media/src/main/java/com/fruitshorts/media/service/transcode/TranscodeWorker.java +++ b/fruitshorts-module-media/src/main/java/com/fruitshorts/media/service/transcode/TranscodeWorker.java @@ -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 ladder() { + private List ladder(MediaTranscodeProfile profile) { List 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 toRenditions( TranscodeRunner.TranscodeResult result) { List renditions = new ArrayList<>(); diff --git a/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/service/PlaybackService.java b/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/service/PlaybackService.java index 0c0e260..0404c61 100644 --- a/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/service/PlaybackService.java +++ b/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/service/PlaybackService.java @@ -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)) { // 还没转码或没上传片源:明确告诉客户端"没有片源",而不是给一个播不了的地址 diff --git a/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/vo/PlayRespVO.java b/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/vo/PlayRespVO.java index efcb5b1..c2c2682 100644 --- a/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/vo/PlayRespVO.java +++ b/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/vo/PlayRespVO.java @@ -23,6 +23,8 @@ public class PlayRespVO { private String playUrl; /** 可用的清晰度档位 */ private List qualities; + /** 雪碧图 WebVTT 时间轴地址;没有产物时为 null */ + private String thumbnailsUrl; /** 需要多少金币解锁 */ private Integer unlockCoin; /** 试看秒数,0 表示不限制 */ diff --git a/fruitshorts-transcode-worker/src/test/java/com/fruitshorts/worker/FfmpegTranscodeRunnerSmokeTest.java b/fruitshorts-transcode-worker/src/test/java/com/fruitshorts/worker/FfmpegTranscodeRunnerSmokeTest.java index 1534f38..36374b7 100644 --- a/fruitshorts-transcode-worker/src/test/java/com/fruitshorts/worker/FfmpegTranscodeRunnerSmokeTest.java +++ b/fruitshorts-transcode-worker/src/test/java/com/fruitshorts/worker/FfmpegTranscodeRunnerSmokeTest.java @@ -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")); diff --git a/fruitshorts-transcode-worker/src/test/java/com/fruitshorts/worker/MediaTranscodeProfileServiceTest.java b/fruitshorts-transcode-worker/src/test/java/com/fruitshorts/worker/MediaTranscodeProfileServiceTest.java new file mode 100644 index 0000000..6dbe869 --- /dev/null +++ b/fruitshorts-transcode-worker/src/test/java/com/fruitshorts/worker/MediaTranscodeProfileServiceTest.java @@ -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"); + } +} diff --git a/fruitshorts-transcode-worker/src/test/java/com/fruitshorts/worker/ThumbnailVttBuilderTest.java b/fruitshorts-transcode-worker/src/test/java/com/fruitshorts/worker/ThumbnailVttBuilderTest.java new file mode 100644 index 0000000..f79332c --- /dev/null +++ b/fruitshorts-transcode-worker/src/test/java/com/fruitshorts/worker/ThumbnailVttBuilderTest.java @@ -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); + } +} diff --git a/fruitshorts-transcode-worker/src/test/java/com/fruitshorts/worker/TranscodeClaimAndStorageTest.java b/fruitshorts-transcode-worker/src/test/java/com/fruitshorts/worker/TranscodeClaimAndStorageTest.java index 68cc564..dba5fb1 100644 --- a/fruitshorts-transcode-worker/src/test/java/com/fruitshorts/worker/TranscodeClaimAndStorageTest.java +++ b/fruitshorts-transcode-worker/src/test/java/com/fruitshorts/worker/TranscodeClaimAndStorageTest.java @@ -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")) diff --git a/fruitshorts-transcode-worker/src/test/java/com/fruitshorts/worker/TranscodeWorkerTest.java b/fruitshorts-transcode-worker/src/test/java/com/fruitshorts/worker/TranscodeWorkerTest.java index 89eb541..ce2134d 100644 --- a/fruitshorts-transcode-worker/src/test/java/com/fruitshorts/worker/TranscodeWorkerTest.java +++ b/fruitshorts-transcode-worker/src/test/java/com/fruitshorts/worker/TranscodeWorkerTest.java @@ -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> captor = ArgumentCaptor.forClass(List.class); - verify(transcodeService).succeed(eq(TASK_ID), captor.capture()); + @SuppressWarnings("unchecked") + ArgumentCaptor 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> renditions = + ArgumentCaptor.forClass(List.class); + ArgumentCaptor 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); } } } diff --git a/sql/data-phase1.sql b/sql/data-phase1.sql index 3b0097e..b4d47e5 100644 --- a/sql/data-phase1.sql +++ b/sql/data-phase1.sql @@ -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'; diff --git a/sql/patch-2026-10-22-media-thumbnail.sql b/sql/patch-2026-10-22-media-thumbnail.sql new file mode 100644 index 0000000..9a4ca70 --- /dev/null +++ b/sql/patch-2026-10-22-media-thumbnail.sql @@ -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; diff --git a/sql/schema-phase1.sql b/sql/schema-phase1.sql index ae10afe..1110c7f 100644 --- a/sql/schema-phase1.sql +++ b/sql/schema-phase1.sql @@ -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