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..d88bae4 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -25390,3 +25390,73 @@ $ 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 链路本身正常。 + +### 实现 + +- ffprobe 读取源视频 width/height;存在 90/270 度旋转元数据时按最终显示方向交换宽高。 +- 每档短边值取 `min(档位 width/height, 源显示短边)`,再向下取偶数。 +- 横屏使用 `scale=-2::flags=lanczos,setsar=1`; + 竖屏使用 `scale=:-2:flags=lanczos,setsar=1`,只等比缩放。 +- 不放大策略统一为“高于源短边的档位仍保留 quality 与码率,分辨率封顶到源短边”, + 因而不会出现全部档位被跳过、无 rendition 可播的情况。 +- 每档完成后 ffprobe 读取 variant HLS 的实际 width/height,写入 + `ProducedRendition`,worker 继续原样回写 `media_rendition`; + master playlist 的 RESOLUTION 也使用实际尺寸。 + +### 定向测试 + +```text +$ mvn -pl fruitshorts-transcode-worker -am \ + -Dtest=FfmpegTranscodeRunnerSmokeTest \ + -Dsurefire.failIfNoSpecifiedTests=false test +[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0 +[INFO] BUILD SUCCESS +``` + +解读:横屏、竖屏、不放大、偶数尺寸、SAR=1、ffprobe 实际尺寸与 +master RESOLUTION 均通过真实 ffmpeg 验证。 + +### 最终门禁 + +```text +$ mvn -pl fruitshorts-module-media,fruitshorts-transcode-worker test +[INFO] Tests run: 8, Failures: 0, Errors: 0, Skipped: 0 +[INFO] Tests run: 35, Failures: 0, Errors: 0, Skipped: 0 +[INFO] fruitshorts-module-media ........................... SUCCESS +[INFO] fruitshorts-transcode-worker ....................... SUCCESS +[INFO] BUILD SUCCESS +``` + +解读:P2 查询测试、worker 入队/失败回写测试和新增宽高比 smoke 全部通过。 +未启停服务、未执行数据库变更、未改前端/App/specs 或 P2 接口字段。 + 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 13f9ca6..3f911cf 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 @@ -45,10 +45,11 @@ public class FfmpegTranscodeRunner implements TranscodeRunner { } createDirectories(request.outputDir()); int durationSec = probeDuration(request); + SourceDimensions source = probeSourceDimensions(request); List renditions = new ArrayList<>(request.ladder().size()); for (LadderRung rung : request.ladder()) { - renditions.add(transcodeRung(request, rung)); + renditions.add(transcodeRung(request, rung, source)); } writeMasterPlaylist(request, renditions); ProducedRendition poster = extractPoster(request); @@ -60,17 +61,20 @@ public class FfmpegTranscodeRunner implements TranscodeRunner { poster.objectKey(), durationSec, thumbnail); } - private ProducedRendition transcodeRung(TranscodeRequest request, LadderRung rung) { + private ProducedRendition transcodeRung( + TranscodeRequest request, LadderRung rung, SourceDimensions source) { String relativeDir = rung.quality() + "/"; Path rungDir = request.outputDir().resolve(rung.quality()); createDirectories(rungDir); + int shortEdge = effectiveShortEdge(rung, source); + String scaleFilter = source.landscape() + ? "scale=-2:" + shortEdge + ":flags=lanczos,setsar=1" + : "scale=" + shortEdge + ":-2:flags=lanczos,setsar=1"; List command = List.of( request.ffmpegPath(), "-y", "-i", request.sourceFile().toString(), - // 等比缩放到目标档位(不拉伸)。force_divisible_by=2 不能省: - // 640x360 的源在 854x480 档会算出 853x480 这种奇数宽度,libx264 直接拒绝(width not divisible by 2) - "-vf", "scale=" + rung.width() + ":" + rung.height() - + ":force_original_aspect_ratio=decrease:force_divisible_by=2", + // 按源方向只用短边定档;-2 保证自动推导的一边为偶数,setsar=1 避免播放器再次拉伸 + "-vf", scaleFilter, "-c:v", "libx264", "-preset", "veryfast", "-b:v", rung.videoBitrateKbps() + "k", // 强制在每个分片边界插关键帧:只写 -hls_time 的话,片源关键帧间隔大于分片时长时 @@ -88,12 +92,19 @@ public class FfmpegTranscodeRunner implements TranscodeRunner { throw new ServiceException(ErrorCode.INTERNAL_ERROR, "ffmpeg 未产出播放列表:" + playlist); } - return new ProducedRendition(rung.quality(), rung.width(), rung.height(), + Dimensions output = probeOutputDimensions(request, playlist); + return new ProducedRendition(rung.quality(), output.width(), output.height(), rung.videoBitrateKbps(), rung.audioBitrateKbps(), joinKey(request.objectKeyPrefix(), relativeDir + VARIANT_PLAYLIST), sizeOf(rungDir), countSegments(rungDir)); } + private int effectiveShortEdge(LadderRung rung, SourceDimensions source) { + int rungShortEdge = Math.min(rung.width(), rung.height()); + int capped = Math.min(rungShortEdge, source.shortEdge()); + return Math.max(2, capped - capped % 2); + } + /** 手写 master.m3u8:按带宽升序,把每档的 index.m3u8 串起来。 */ private void writeMasterPlaylist(TranscodeRequest request, List renditions) { StringBuilder body = new StringBuilder("#EXTM3U\n#EXT-X-VERSION:3\n"); @@ -151,6 +162,91 @@ public class FfmpegTranscodeRunner implements TranscodeRunner { } } + /** + * ffprobe 同时读取编码尺寸和旋转信息;旋转 90/270 度时按最终显示方向交换宽高。 + */ + private SourceDimensions probeSourceDimensions(TranscodeRequest request) { + String output = runProcess(List.of( + request.ffprobePath(), "-v", "error", + "-select_streams", "v:0", + "-show_entries", + "stream=width,height:stream_side_data=rotation:stream_tags=rotate", + "-of", "default=noprint_wrappers=1", + request.sourceFile().toString()), 60, "探测视频尺寸失败"); + int width = 0; + int height = 0; + Double sideRotation = null; + Double tagRotation = null; + for (String line : output.split("\\R")) { + String[] pair = line.trim().split("=", 2); + if (pair.length != 2) { + continue; + } + String key = pair[0]; + String value = pair[1]; + if ("width".equals(key)) { + width = parseInt(value); + } else if ("height".equals(key)) { + height = parseInt(value); + } else if ("rotation".equals(key)) { + sideRotation = parseDouble(value); + } else if ("TAG:rotate".equals(key)) { + tagRotation = parseDouble(value); + } + } + if (width <= 0 || height <= 0) { + throw new ServiceException(ErrorCode.INTERNAL_ERROR, + "ffprobe 未返回有效视频尺寸:" + width + "x" + height); + } + double rotation = sideRotation != null + ? sideRotation : tagRotation == null ? 0 : tagRotation; + boolean quarterTurn = Math.abs(Math.round(rotation)) % 180 == 90; + return new SourceDimensions( + quarterTurn ? height : width, + quarterTurn ? width : height); + } + + /** rendition 必须记录真实产物尺寸,不能沿用档位名义宽高。 */ + private Dimensions probeOutputDimensions(TranscodeRequest request, Path playlist) { + String output = runProcess(List.of( + request.ffprobePath(), "-v", "error", + "-select_streams", "v:0", + "-show_entries", "stream=width,height", + "-of", "csv=s=x:p=0", + playlist.toString()), 60, "探测转码产物尺寸失败"); + String first = output.lines().findFirst().orElse("").trim(); + String[] pair = first.split("x"); + if (pair.length != 2) { + throw new ServiceException(ErrorCode.INTERNAL_ERROR, + "ffprobe 返回了无法解析的产物尺寸:" + first); + } + int width = parseInt(pair[0]); + int height = parseInt(pair[1]); + if (width <= 0 || height <= 0 || width % 2 != 0 || height % 2 != 0) { + throw new ServiceException(ErrorCode.INTERNAL_ERROR, + "转码产物尺寸非法:" + width + "x" + height); + } + return new Dimensions(width, height); + } + + private int parseInt(String value) { + try { + return Integer.parseInt(value.trim()); + } catch (NumberFormatException ex) { + throw new ServiceException(ErrorCode.INTERNAL_ERROR, + "ffprobe 返回了非法数字:" + value); + } + } + + private double parseDouble(String value) { + try { + return Double.parseDouble(value.trim()); + } catch (NumberFormatException ex) { + throw new ServiceException(ErrorCode.INTERNAL_ERROR, + "ffprobe 返回了非法旋转角度:" + value); + } + } + /** * 起进程并等结果;非 0 退出把输出尾部带进异常,方便定位是片源问题还是参数问题。 * @@ -235,4 +331,17 @@ public class FfmpegTranscodeRunner implements TranscodeRunner { String cleanPrefix = prefix == null ? "" : prefix.replaceAll("/+$", ""); return cleanPrefix.isEmpty() ? relative : cleanPrefix + "/" + relative; } + + private record SourceDimensions(int width, int height) { + private boolean landscape() { + return width >= height; + } + + private int shortEdge() { + return Math.min(width, height); + } + } + + private record Dimensions(int width, int height) { + } } 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) { + } }