diff --git a/docs/plans/2026-10-22-transcode-preserve-aspect.md b/docs/plans/2026-10-22-transcode-preserve-aspect.md new file mode 100644 index 0000000..5dfa957 --- /dev/null +++ b/docs/plans/2026-10-22-transcode-preserve-aspect.md @@ -0,0 +1,27 @@ +# 转码保持原始宽高比 + +## 目标 + +- 横屏源按档位短边作为高度,竖屏源按档位短边作为宽度。 +- 源短边小于档位短边时按源短边输出,不放大。 +- 输出宽高均为偶数,SAR 固定为 1。 +- rendition 的 width/height 使用 ffprobe 探测到的实际值。 + +## 实施 + +1. ffprobe 读取源显示方向尺寸,旋转 90/270 度时交换宽高。 +2. `effectiveShort = min(sourceShort, rungShort)` 并向下取偶数。 +3. 横屏使用 `scale=-2::flags=lanczos,setsar=1`; + 竖屏使用 `scale=:-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`。 diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index 880cf06..a7ab43f 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -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 链路本身正常。 + 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 a2196f6..a0f9a43 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 @@ -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 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) { + } }