From 04d25cd23d5d629675ba696c70f8925efe33888d Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Thu, 22 Oct 2026 14:31:22 +0800 Subject: [PATCH] =?UTF-8?q?fix(media):=20=E8=BD=AC=E7=A0=81=E6=8C=89?= =?UTF-8?q?=E7=9F=AD=E8=BE=B9=E7=AD=89=E6=AF=94=E5=AE=9A=E6=A1=A3=E5=B9=B6?= =?UTF-8?q?=E5=9B=9E=E5=86=99=E5=AE=9E=E9=99=85=E5=B0=BA=E5=AF=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/PROGRESS.md | 39 ++++++ .../transcode/FfmpegTranscodeRunner.java | 123 +++++++++++++++++- 2 files changed, 155 insertions(+), 7 deletions(-) diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index a7ab43f..d88bae4 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -25421,3 +25421,42 @@ expected: "1:1" 小源码片仍被放大,且未固定 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) { + } }