feat(media): 支持后台转码配置与缩略图轴
This commit is contained in:
1 parent
70da939a58
commit
316baf28d6
39 files changed
+1523
-38
No files matched your search
+6
-1
@@ -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"));
|
||||
|
||||
+65
@@ -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");
|
||||
}
|
||||
}
|
||||
+44
@@ -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);
|
||||
}
|
||||
}
|
||||
+30
-3
@@ -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"))
|
||||
|
||||
+52
-4
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user