merge: 修复转码保持原始宽高比
This commit is contained in:
commit
a35b02dd18
4 files changed
+344
-8
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`。
|
||||
@@ -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:<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 接口字段。
|
||||
|
||||
+116
-7
@@ -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) {
|
||||
}
|
||||
}
|
||||
+131
-1
@@ -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) {
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user