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

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

No files matched your search

@@ -45,7 +45,8 @@ class FfmpegTranscodeRunnerSmokeTest {
1L, sample, output, "transcoded/smoke",
List.of(new TranscodeRunner.LadderRung("360p", 640, 360, 400, 64),
new TranscodeRunner.LadderRung("480p", 854, 480, 800, 64)),
ffmpeg, ffprobe, 2, 1, 120));
ffmpeg, ffprobe, 2, 1, 120,
new TranscodeRunner.SpriteOptions(true, 1, 10, 160)));
assertThat(Files.exists(output.resolve("master.m3u8"))).isTrue();
assertThat(Files.exists(output.resolve("poster.jpg"))).isTrue();
@@ -59,6 +60,10 @@ class FfmpegTranscodeRunnerSmokeTest {
assertThat(rendition.sizeBytes()).isGreaterThan(0L);
}
assertThat(result.durationSec()).isBetween(1, 5);
assertThat(result.thumbnail()).isNotNull();
assertThat(result.thumbnail().status()).isEqualTo("READY");
assertThat(Files.exists(output.resolve("thumbnails/thumbnails.vtt"))).isTrue();
assertThat(Files.exists(output.resolve("thumbnails/sprite_001.jpg"))).isTrue();
assertThat(readLines(output.resolve("master.m3u8")))
.anyMatch(line -> line.contains("360p/index.m3u8"))
.anyMatch(line -> line.contains("480p/index.m3u8"));
@@ -0,0 +1,65 @@
// Author: fruit.com.cn
package com.fruitshorts.worker;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fruitshorts.media.config.MediaTranscodeProfile;
import com.fruitshorts.media.config.TranscodeProperties;
import com.fruitshorts.media.service.MediaTranscodeProfileService;
import com.fruitshorts.system.service.ConfigService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
/**
* 后台转码配置的解析、缓存与非法回退。
*/
class MediaTranscodeProfileServiceTest {
private ConfigService configService;
private MediaTranscodeProfileService service;
@BeforeEach
void setUp() {
configService = mock(ConfigService.class);
service = new MediaTranscodeProfileService(
configService, new ObjectMapper(), new TranscodeProperties());
}
@Test
void should_read_valid_profile_and_cache_for_30_seconds() throws Exception {
MediaTranscodeProfile profile = new MediaTranscodeProfile();
profile.setSegmentSeconds(4);
profile.getLadder().get(0).setVideoBitrateKbps(500);
given(configService.get(eq(MediaTranscodeProfileService.CONFIG_KEY), anyString()))
.willReturn(new ObjectMapper().writeValueAsString(profile));
MediaTranscodeProfile first = service.currentProfile();
MediaTranscodeProfile second = service.currentProfile();
assertThat(first.getSegmentSeconds()).isEqualTo(4);
assertThat(first.getLadder().get(0).getVideoBitrateKbps()).isEqualTo(500);
assertThat(second).isSameAs(first);
verify(configService, times(1))
.get(eq(MediaTranscodeProfileService.CONFIG_KEY), anyString());
}
@Test
void should_fallback_to_yml_default_when_profile_is_illegal() {
given(configService.get(eq(MediaTranscodeProfileService.CONFIG_KEY), anyString()))
.willReturn("{\"segmentSeconds\":1,\"ladder\":[]}");
MediaTranscodeProfile profile = service.currentProfile();
assertThat(profile.getSegmentSeconds()).isEqualTo(6);
assertThat(profile.getLadder()).extracting(MediaTranscodeProfile.Rung::getQuality)
.containsExactly("360p", "480p", "720p", "1080p");
}
}
@@ -0,0 +1,44 @@
// Author: fruit.com.cn
package com.fruitshorts.worker;
import com.fruitshorts.media.service.transcode.ThumbnailVttBuilder;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/**
* WebVTT cue 时间与雪碧图行列坐标。
*/
class ThumbnailVttBuilderTest {
@Test
void should_write_time_ranges_and_xywh_coordinates() {
String vtt = ThumbnailVttBuilder.build(
new ThumbnailVttBuilder.Layout(3, 1, 10, 1, 160, 90));
assertThat(vtt).startsWith("WEBVTT");
assertThat(vtt).contains("00:00:00.000 --> 00:00:01.000\n"
+ "sprite_001.jpg#xywh=0,0,160,90");
assertThat(vtt).contains("00:00:01.000 --> 00:00:02.000\n"
+ "sprite_001.jpg#xywh=160,0,160,90");
assertThat(vtt).contains("00:00:02.000 --> 00:00:03.000\n"
+ "sprite_001.jpg#xywh=320,0,160,90");
}
@Test
void should_switch_to_second_sprite_after_full_tile() {
String vtt = ThumbnailVttBuilder.build(
new ThumbnailVttBuilder.Layout(101, 1, 10, 10, 160, 90));
assertThat(vtt).contains("00:01:40.000 --> 00:01:41.000\n"
+ "sprite_002.jpg#xywh=0,0,160,90");
}
@Test
void should_cap_tile_rows_at_column_count() {
assertThat(ThumbnailVttBuilder.frameCount(21, 1)).isEqualTo(21);
assertThat(ThumbnailVttBuilder.rows(21, 10)).isEqualTo(3);
assertThat(ThumbnailVttBuilder.rows(121, 10)).isEqualTo(10);
}
}
@@ -6,8 +6,11 @@ import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.fruitshorts.common.exception.ServiceException;
import com.fruitshorts.media.config.MediaProperties;
import com.fruitshorts.media.dal.entity.MediaRendition;
import com.fruitshorts.media.dal.entity.MediaThumbnail;
import com.fruitshorts.media.dal.entity.TranscodeTask;
import com.fruitshorts.media.dal.mapper.MediaRenditionMapper;
import com.fruitshorts.media.dal.mapper.MediaThumbnailMapper;
import com.fruitshorts.media.dal.mapper.TranscodeTaskMapper;
import com.fruitshorts.media.service.MediaStorageService;
import com.fruitshorts.media.service.TranscodeService;
@@ -39,12 +42,16 @@ class TranscodeClaimAndStorageTest {
*/
@BeforeAll
static void initTableInfoCache() {
TableInfoHelper.initTableInfo(
new MapperBuilderAssistant(new MybatisConfiguration(), ""), TranscodeTask.class);
MapperBuilderAssistant assistant =
new MapperBuilderAssistant(new MybatisConfiguration(), "");
TableInfoHelper.initTableInfo(assistant, TranscodeTask.class);
TableInfoHelper.initTableInfo(assistant, MediaRendition.class);
TableInfoHelper.initTableInfo(assistant, MediaThumbnail.class);
}
private TranscodeTaskMapper transcodeTaskMapper;
private MediaRenditionMapper mediaRenditionMapper;
private MediaThumbnailMapper mediaThumbnailMapper;
private TranscodeService transcodeService;
private MediaProperties mediaProperties;
private MediaStorageService storageService;
@@ -53,7 +60,9 @@ class TranscodeClaimAndStorageTest {
void setUp() {
transcodeTaskMapper = mock(TranscodeTaskMapper.class);
mediaRenditionMapper = mock(MediaRenditionMapper.class);
transcodeService = new TranscodeService(transcodeTaskMapper, mediaRenditionMapper);
mediaThumbnailMapper = mock(MediaThumbnailMapper.class);
transcodeService = new TranscodeService(
transcodeTaskMapper, mediaRenditionMapper, mediaThumbnailMapper);
mediaProperties = new MediaProperties();
mediaProperties.setRootDir("/tmp/fruitshorts-storage-test");
mediaProperties.setPublicPrefix("/files");
@@ -88,6 +97,24 @@ class TranscodeClaimAndStorageTest {
verify(transcodeTaskMapper, never()).update(any(), any());
}
@Test
void should_insert_thumbnail_when_main_transcode_succeeds() {
TranscodeTask task = task(3L);
task.setDramaId(7L);
task.setEpisodeId(11L);
given(transcodeTaskMapper.selectById(3L)).willReturn(task);
MediaThumbnail thumbnail = new MediaThumbnail();
thumbnail.setEpisodeId(11L);
thumbnail.setVttObjectKey("transcoded/3/thumbnails/thumbnails.vtt");
thumbnail.setStatus(MediaThumbnail.STATUS_READY);
transcodeService.succeed(3L, java.util.List.of(), thumbnail);
verify(mediaThumbnailMapper).insert(thumbnail);
assertThat(task.getStatus()).isEqualTo(TranscodeService.STATUS_SUCCESS);
verify(transcodeTaskMapper).updateById(task);
}
@Test
void should_reject_object_key_that_escapes_the_storage_root() {
assertThatThrownBy(() -> storageService.fileFor("../../etc/passwd"))
@@ -2,11 +2,13 @@
package com.fruitshorts.worker;
import com.fruitshorts.media.config.TranscodeProperties;
import com.fruitshorts.media.config.MediaTranscodeProfile;
import com.fruitshorts.media.dal.entity.MediaRendition;
import com.fruitshorts.media.dal.entity.MediaThumbnail;
import com.fruitshorts.media.dal.entity.TranscodeTask;
import com.fruitshorts.media.service.MediaService;
import com.fruitshorts.media.service.MediaStorageService;
import com.fruitshorts.media.service.MediaTranscodeProfileService;
import com.fruitshorts.media.service.TranscodeService;
import com.fruitshorts.media.service.transcode.TranscodeRunner;
import com.fruitshorts.media.service.transcode.TranscodeWorker;
@@ -41,6 +43,7 @@ class TranscodeWorkerTest {
private MediaService mediaService;
private MediaStorageService storageService;
private StubRunner runner;
private MediaTranscodeProfileService profileService;
private TranscodeWorker worker;
@BeforeEach
@@ -48,9 +51,15 @@ class TranscodeWorkerTest {
transcodeService = mock(TranscodeService.class);
mediaService = mock(MediaService.class);
storageService = mock(MediaStorageService.class);
profileService = mock(MediaTranscodeProfileService.class);
runner = new StubRunner();
worker = new TranscodeWorker(transcodeService, mediaService, storageService, runner,
new TranscodeProperties());
profileService);
given(profileService.currentProfile()).willReturn(new MediaTranscodeProfile());
given(profileService.ffmpegPath()).willReturn("ffmpeg");
given(profileService.ffprobePath()).willReturn("ffprobe");
given(profileService.posterSecond()).willReturn(1);
given(profileService.timeoutSeconds()).willReturn(120);
given(storageService.objectKeyOf(anyString())).willReturn("episode/202610/1.mp4");
given(storageService.fileFor(anyString())).willReturn(Paths.get("/tmp/fruitshorts-test/1.mp4"));
given(storageService.toPublicUrl(anyString()))
@@ -65,11 +74,17 @@ class TranscodeWorkerTest {
@SuppressWarnings("unchecked")
ArgumentCaptor<List<MediaRendition>> captor = ArgumentCaptor.forClass(List.class);
verify(transcodeService).succeed(eq(TASK_ID), captor.capture());
@SuppressWarnings("unchecked")
ArgumentCaptor<MediaThumbnail> thumbnailCaptor =
ArgumentCaptor.forClass(MediaThumbnail.class);
verify(transcodeService).succeed(eq(TASK_ID), captor.capture(),
thumbnailCaptor.capture());
assertThat(captor.getValue()).hasSize(2);
// 对外地址一律经 MediaStorageService,worker 不自己拼磁盘路径
assertThat(captor.getValue().get(0).getPlayUrl()).startsWith("/files/transcoded/88/");
assertThat(captor.getValue().get(0).getFormat()).isEqualTo("HLS");
assertThat(thumbnailCaptor.getValue().getStatus())
.isEqualTo(MediaThumbnail.STATUS_READY);
verify(mediaService).markReady(eq(ASSET_ID), eq("/files/transcoded/88/master.m3u8"),
eq("/files/transcoded/88/poster.jpg"), eq(120));
verify(transcodeService, never()).fail(any(), anyString());
@@ -87,6 +102,31 @@ class TranscodeWorkerTest {
assertThat(runner.request.objectKeyPrefix()).isEqualTo("transcoded/88");
}
@Test
void should_keep_main_transcode_successful_when_thumbnail_generation_fails() {
given(transcodeService.claimNext()).willReturn(queuedTask());
runner.thumbnail = new TranscodeRunner.ProducedThumbnail(
"transcoded/88/thumbnails/thumbnails.vtt",
"transcoded/88/thumbnails/sprite_",
1, 10, 1, 160, 0, 120, 120, 0L,
MediaThumbnail.STATUS_FAILED, "tile 滤镜失败");
worker.processOne();
@SuppressWarnings("unchecked")
ArgumentCaptor<List<MediaRendition>> renditions =
ArgumentCaptor.forClass(List.class);
ArgumentCaptor<MediaThumbnail> thumbnail =
ArgumentCaptor.forClass(MediaThumbnail.class);
verify(transcodeService).succeed(eq(TASK_ID), renditions.capture(),
thumbnail.capture());
assertThat(thumbnail.getValue().getStatus())
.isEqualTo(MediaThumbnail.STATUS_FAILED);
assertThat(thumbnail.getValue().getErrorMsg()).contains("tile");
verify(mediaService).markReady(eq(ASSET_ID), anyString(), anyString(), eq(120));
verify(transcodeService, never()).fail(any(), anyString());
}
@Test
void should_keep_task_queued_and_record_reason_when_transcode_fails_before_max_retry() {
TranscodeTask task = queuedTask();
@@ -144,6 +184,7 @@ class TranscodeWorkerTest {
private static final class StubRunner implements TranscodeRunner {
private TranscodeRequest request;
private RuntimeException failure;
private ProducedThumbnail thumbnail;
private boolean called;
@Override
@@ -153,13 +194,20 @@ class TranscodeWorkerTest {
if (failure != null) {
throw failure;
}
ProducedThumbnail resultThumbnail = thumbnail == null
? new ProducedThumbnail(
request.objectKeyPrefix() + "/thumbnails/thumbnails.vtt",
request.objectKeyPrefix() + "/thumbnails/sprite_",
1, 10, 1, 160, 90, 120, 120, 2048L,
MediaThumbnail.STATUS_READY, "")
: thumbnail;
return new TranscodeResult(
request.objectKeyPrefix() + "/master.m3u8",
List.of(new ProducedRendition("360p", 640, 360, 400, 64,
request.objectKeyPrefix() + "/360p/index.m3u8", 1024L, 4),
new ProducedRendition("480p", 854, 480, 800, 64,
request.objectKeyPrefix() + "/480p/index.m3u8", 2048L, 4)),
request.objectKeyPrefix() + "/poster.jpg", 120);
request.objectKeyPrefix() + "/poster.jpg", 120, resultThumbnail);
}
}
}