test(media): 覆盖转码保持原始宽高比

This commit is contained in:
Zhou Weidong committed 2026-10-22 14:30:08 +08:00
1 parent 40a884cc85
commit 217ff24f1c
3 files changed
+189 -1

No files matched your search

@@ -0,0 +1,27 @@
# 转码保持原始宽高比
## 目标
- 横屏源按档位短边作为高度,竖屏源按档位短边作为宽度。
- 源短边小于档位短边时按源短边输出,不放大。
- 输出宽高均为偶数,SAR 固定为 1。
- rendition 的 width/height 使用 ffprobe 探测到的实际值。
## 实施
1. ffprobe 读取源显示方向尺寸,旋转 90/270 度时交换宽高。
2. `effectiveShort = min(sourceShort, rungShort)` 并向下取偶数。
3. 横屏使用 `scale=-2:<effectiveShort>:flags=lanczos,setsar=1`;
竖屏使用 `scale=<effectiveShort>:-2:flags=lanczos,setsar=1`。
4. 转码完成后 ffprobe variant playlist,写入实际输出尺寸。
5. master playlist 的 RESOLUTION 同步使用实际尺寸。
## 不放大策略
选用“用源尺寸输出”:高于源短边的档位仍保留原 quality 标识和码率,
但分辨率封顶到源短边,避免所有档位都被跳过导致无产物。
## 验证
- 先补横屏、竖屏、小源码片、偶数、SAR=1 与实际尺寸回写测试。
- 门禁:`mvn -pl fruitshorts-module-media,fruitshorts-transcode-worker test`。
+31
View File
@@ -25390,3 +25390,34 @@ $ mvn test
解读:admin 51、app 109、worker 32 个测试均通过,P1 入队/重转与 worker
链路未回归。
## 2026-10-22 转码保持原始宽高比
- 分支:`feature/transcode-preserve-aspect`
- 基线:`develop` / `origin/develop` = `40a884c`
- 隔离工作树:`/private/tmp/fruitserver-preserve-aspect`
- 计划:`docs/plans/2026-10-22-transcode-preserve-aspect.md`
按 TDD 先扩展真实 ffmpeg smoke,覆盖横屏、竖屏、源码短边小于档位、
输出偶数尺寸、SAR=1、ffprobe 实际尺寸与 master RESOLUTION。
生产代码暂不修改,下一步记录红测输出。
### TDD 红测
```text
$ mvn -pl fruitshorts-transcode-worker -am \
-Dtest=FfmpegTranscodeRunnerSmokeTest \
-Dsurefire.failIfNoSpecifiedTests=false test
[ERROR] should_keep_portrait_orientation_and_even_dimensions:
expected: 202
but was: 640
[ERROR] should_not_upscale_when_source_short_edge_is_below_rung:
expected: "1:1"
but was: "1280:1281"
[ERROR] Tests run: 4, Failures: 2, Errors: 0, Skipped: 0
[INFO] BUILD FAILURE
```
解读:竖屏实际输出为 202x360,却把名义 640x360 回写到 rendition;
小源码片仍被放大,且未固定 SAR。横屏基础尺寸用例已通过,说明测试片生成、
ffprobe 探测与 HLS 链路本身正常。
@@ -74,9 +74,58 @@ class FfmpegTranscodeRunnerSmokeTest {
}
}
@Test
void should_keep_landscape_aspect_and_write_actual_dimensions() throws Exception {
String ffmpeg = binaryPath("ffmpeg");
String ffprobe = binaryPath("ffprobe");
assumeTrue(ffmpeg != null && ffprobe != null,
"本机没有 ffmpeg/ffprobe,跳过真实转码 smoke");
TranscodeRunner.TranscodeResult result = transcodeOneRung(
ffmpeg, ffprobe, 1024, 576,
new TranscodeRunner.LadderRung("360p", 640, 360, 400, 64));
assertRenditionDimensions(result, 0, 640, 360);
assertNoUpscaleOrDistortion(result, 1024, 576, 640, 360);
}
@Test
void should_keep_portrait_orientation_and_even_dimensions() throws Exception {
String ffmpeg = binaryPath("ffmpeg");
String ffprobe = binaryPath("ffprobe");
assumeTrue(ffmpeg != null && ffprobe != null,
"本机没有 ffmpeg/ffprobe,跳过真实转码 smoke");
TranscodeRunner.TranscodeResult result = transcodeOneRung(
ffmpeg, ffprobe, 720, 1280,
new TranscodeRunner.LadderRung("360p", 640, 360, 400, 64));
assertRenditionDimensions(result, 0, 360, 640);
assertNoUpscaleOrDistortion(result, 720, 1280, 360, 640);
}
@Test
void should_not_upscale_when_source_short_edge_is_below_rung() throws Exception {
String ffmpeg = binaryPath("ffmpeg");
String ffprobe = binaryPath("ffprobe");
assumeTrue(ffmpeg != null && ffprobe != null,
"本机没有 ffmpeg/ffprobe,跳过真实转码 smoke");
TranscodeRunner.TranscodeResult result = transcodeOneRung(
ffmpeg, ffprobe, 640, 360,
new TranscodeRunner.LadderRung("480p", 854, 480, 800, 64));
assertRenditionDimensions(result, 0, 640, 360);
assertNoUpscaleOrDistortion(result, 640, 360, 640, 360);
}
private void generateSample(String ffmpeg, Path sample) throws Exception {
generateSample(ffmpeg, sample, 640, 360);
}
private void generateSample(String ffmpeg, Path sample, int width, int height) throws Exception {
Process process = new ProcessBuilder(ffmpeg, "-y",
"-f", "lavfi", "-i", "testsrc=size=640x360:rate=15",
"-f", "lavfi", "-i", "testsrc=size=" + width + "x" + height + ":rate=15",
"-f", "lavfi", "-i", "sine=frequency=440:duration=2",
"-shortest", "-t", "2",
"-c:v", "libx264", "-pix_fmt", "yuv420p", "-c:a", "aac",
@@ -87,6 +136,84 @@ class FfmpegTranscodeRunnerSmokeTest {
assertThat(process.exitValue()).as("生成测试片失败").isZero();
}
private TranscodeRunner.TranscodeResult transcodeOneRung(
String ffmpeg, String ffprobe, int width, int height,
TranscodeRunner.LadderRung rung) throws Exception {
Path work = Files.createTempDirectory("fruitshorts-aspect-smoke");
try {
Path sample = work.resolve("sample-" + width + "x" + height + ".mp4");
generateSample(ffmpeg, sample, width, height);
Path output = work.resolve("out");
TranscodeRunner.TranscodeResult result = new FfmpegTranscodeRunner().run(
new TranscodeRunner.TranscodeRequest(
2L, sample, output, "transcoded/aspect",
List.of(rung), ffmpeg, ffprobe, 2, 1, 120,
new TranscodeRunner.SpriteOptions(false, 1, 10, 160)));
Path playlist = output.resolve(rung.quality()).resolve("index.m3u8");
Dimensions probed = probeDimensions(ffprobe, playlist);
assertThat(result.renditions()).hasSize(1);
assertThat(result.renditions().get(0).width()).isEqualTo(probed.width());
assertThat(result.renditions().get(0).height()).isEqualTo(probed.height());
assertThat(probeSampleAspectRatio(ffprobe, playlist)).isEqualTo("1:1");
assertThat(readLines(output.resolve("master.m3u8")))
.anyMatch(line -> line.contains("RESOLUTION="
+ probed.width() + "x" + probed.height()));
return result;
} finally {
deleteRecursively(work);
}
}
private void assertRenditionDimensions(
TranscodeRunner.TranscodeResult result, int index, int width, int height) {
TranscodeRunner.ProducedRendition rendition = result.renditions().get(index);
assertThat(rendition.width()).isEqualTo(width);
assertThat(rendition.height()).isEqualTo(height);
}
private void assertNoUpscaleOrDistortion(
TranscodeRunner.TranscodeResult result,
int sourceWidth, int sourceHeight, int width, int height) {
TranscodeRunner.ProducedRendition rendition = result.renditions().get(0);
assertThat(rendition.width() % 2).isZero();
assertThat(rendition.height() % 2).isZero();
assertThat(rendition.width()).isLessThanOrEqualTo(sourceWidth);
assertThat(rendition.height()).isLessThanOrEqualTo(sourceHeight);
double sourceRatio = sourceWidth / (double) sourceHeight;
double outputRatio = rendition.width() / (double) rendition.height();
assertThat(Math.abs(sourceRatio - outputRatio)).isLessThan(0.01);
assertThat(rendition.width()).isEqualTo(width);
assertThat(rendition.height()).isEqualTo(height);
}
private Dimensions probeDimensions(String ffprobe, Path playlist) throws Exception {
String output = runAndRead(List.of(
ffprobe, "-v", "error", "-select_streams", "v:0",
"-show_entries", "stream=width,height",
"-of", "csv=s=x:p=0", playlist.toString()));
String[] parts = output.lines().findFirst().orElse("").split("x");
assertThat(parts).hasSize(2);
return new Dimensions(Integer.parseInt(parts[0]), Integer.parseInt(parts[1]));
}
private String probeSampleAspectRatio(String ffprobe, Path playlist) throws Exception {
String output = runAndRead(List.of(
ffprobe, "-v", "error", "-select_streams", "v:0",
"-show_entries", "stream=sample_aspect_ratio",
"-of", "default=noprint_wrappers=1:nokey=1",
playlist.toString()));
return output.lines().findFirst().orElse("").trim();
}
private String runAndRead(List<String> command) throws Exception {
Process process = new ProcessBuilder(command).redirectErrorStream(true).start();
String output = new String(process.getInputStream().readAllBytes()).trim();
assertThat(process.waitFor(30, TimeUnit.SECONDS)).as("ffprobe 超时").isTrue();
assertThat(process.exitValue()).as("ffprobe 失败:%s", output).isZero();
return output;
}
/** 在 PATH 里找可执行文件;找不到返回 null(由调用方 assume)。 */
private String binaryPath(String name) {
try {
@@ -122,4 +249,7 @@ class FfmpegTranscodeRunnerSmokeTest {
// 同上
}
}
private record Dimensions(int width, int height) {
}
}