fix(media): 转码按短边等比定档并回写实际尺寸

This commit is contained in:
Zhou Weidong committed 2026-10-22 14:31:22 +08:00
1 parent 217ff24f1c
commit 04d25cd23d
2 files changed
+155 -7

No files matched your search

+39
View File
@@ -25421,3 +25421,42 @@ expected: "1:1"
小源码片仍被放大,且未固定 SAR。横屏基础尺寸用例已通过,说明测试片生成、
ffprobe 探测与 HLS 链路本身正常。
### 实现
- ffprobe 读取源视频 width/height;存在 90/270 度旋转元数据时按最终显示方向交换宽高。
- 每档短边值取 `min(档位 width/height, 源显示短边)`,再向下取偶数。
- 横屏使用 `scale=-2:<short>:flags=lanczos,setsar=1`;
竖屏使用 `scale=<short>:-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 接口字段。
@@ -45,10 +45,11 @@ public class FfmpegTranscodeRunner implements TranscodeRunner {
}
createDirectories(request.outputDir());
int durationSec = probeDuration(request);
SourceDimensions source = probeSourceDimensions(request);
List<ProducedRendition> 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<String> 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<ProducedRendition> 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) {
}
}