From cbd4112dec75ce12e82cfb2019064fb50d12e70e Mon Sep 17 00:00:00 2001 From: zj1123581321 Date: Tue, 6 Oct 2026 19:43:36 +0800 Subject: [PATCH] =?UTF-8?q?=E5=B0=86=E9=9B=B6=E6=97=B6=E9=95=BF=E6=8C=89?= =?UTF-8?q?=E6=9C=AA=E7=9F=A5=E5=A4=84=E7=90=86=E5=B9=B6=E9=80=80=E5=9B=9E?= =?UTF-8?q?=20SDK=20=E8=87=AA=E5=8A=A8=E9=A2=84=E7=AE=97=20(#190)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 直播录制的容器头部会把 format.duration 写成 0,旧行为按公式算出 0*4+120=120 秒的预算,确定性掐断本来能跑完的任务,且只有一行 WARNING 可辨。 _transcription_deadline 的拒绝条件从 media_duration < 0 收紧为 <= 0,0 与负数 同属「拿不到时长」,调用点不传 deadline_total,由 SDK 改用转码后的实际采样数 算时长。预算公式、系数与 _finite_time_or_none 的语义均未改动。 Agent-Executor: minimax-pi Agent-Model: MiniMax-M3.1-Flash-Preview Agent-Effort: high Dispatch-Id: dlg-20261006-113110-4de7e7 Task-Id: VideoTranscriptAPI-20261006-07 --- .../transcriber/capswriter_client.py | 10 +++- tests/README.md | 16 ++++++ tests/unit/test_capswriter_deadline_budget.py | 56 ++++++++++++++++--- tests/unit/test_media_duration_probe.py | 6 +- tests/unit/test_youtube_api_duration.py | 10 +++- 5 files changed, 85 insertions(+), 13 deletions(-) diff --git a/src/video_transcript_api/transcriber/capswriter_client.py b/src/video_transcript_api/transcriber/capswriter_client.py index c4189453..428898c7 100644 --- a/src/video_transcript_api/transcriber/capswriter_client.py +++ b/src/video_transcript_api/transcriber/capswriter_client.py @@ -120,6 +120,10 @@ def update_server(cls, addr: str = None, port: int = None): # 退回 max(120, duration + 60) 的小预算,宁可让长媒体走到超时也不静默掐断。 # generic 与 recorder 路径自 PR #173(#170)起同样能拿到 ffprobe 时长,因此 # 「拿不到时长」现在只出现在真的没探测到的退化输入上。 +# +# 时长为 0 与「拿不到时长」同等对待(issue #190):直播录制的容器头部会把 +# duration 写 0,拿它算预算等于 0*4+120=120 秒,会确定性掐断本来能跑完的任务。 +# 退回 SDK 自动预算后,SDK 改用转码后实际采样数算时长,不受这个 0 影响。 DEADLINE_REALTIME_FACTOR = 4.0 DEADLINE_OVERHEAD_SECONDS = 120.0 @@ -251,11 +255,15 @@ def _transcription_deadline(media_duration: Optional[float] = None) -> Optional[ ``media_duration`` 取自下载阶段 ffprobe 已解析出的时长(见 ``downloaders/base.py::_validate_media_file``)。拿不到时长时返回 ``None``,调用点据此不传 ``deadline_total``,保持 SDK 自动预算。 + + 非正时长同样按「拿不到时长」处理(issue #190):直播录制的容器头部会把 + duration 写成 0,0*4+120=120 秒的预算会确定性掐断本来能跑完的任务;而 + SDK 的自动预算改用转码后的实际采样数算时长,不受这个假 0 影响。 """ if ( media_duration is None or not (float("-inf") < media_duration < float("inf")) - or media_duration < 0 + or media_duration <= 0 ): logger.warning("transcription_deadline duration=unknown fallback=sdk_auto") return None diff --git a/tests/README.md b/tests/README.md index aab0226e..a1f861e2 100644 --- a/tests/README.md +++ b/tests/README.md @@ -168,6 +168,22 @@ ffprobe / ffmpeg、无预置 fixture、没有 skip 分支。拒绝路径的完 LLM 处理器空输入守卫(#179):`tests/unit/test_empty_input_processors.py`。 锁的不变量:`PlainTextProcessor._calibrate_segments` 与 `SpeakerAwareProcessor._calibrate_chunks` 在输入分段/分块列表为空时直接返回与空输入同构的空结果,不启动 `ThreadPoolExecutor(max_workers=0)`,不发起 LLM 调用;`NotesProcessor.process` 在空 chapters payload 下走 `is_valid` 校验失败返回 `FAILED`,不发起 LLM 调用;端到端空文本/空对话校对返回诚实状态 `calibration_status=none` 且不抛 `ValueError`。 +转录时限预算的零时长守卫(#190):`tests/unit/test_capswriter_deadline_budget.py`。 +锁的不变量:`media_duration` 为 0 或负数时与「拿不到时长」完全同路—— +`_transcription_deadline` 返回 `None`,且真的不把 `deadline_total` 键传给 +`transcribe_file_sync`,由 SDK 改用转码后的实际采样数算时长 +(`test_non_positive_duration_is_treated_as_unknown`, +覆盖 `0` / `0.0` / `-5.0`;`test_unknown_duration_omits_deadline_kwarg` 与 +`test_transcription_deadline_math_equivalence` 把同一判据铺进参数化矩阵与直调断言)。 +背景:直播录制的容器头部会把 `format.duration` 写 0,旧行为按公式算出 +`0*4+120=120` 秒的预算,确定性掐断本来能跑完的任务。预算值本身没变(同一条 +`duration*4+120` 公式),变的只是 0 的来源从本仓显式路径交回 SDK +(`test_sdk_auto_budget_is_what_covers_zero_duration`; +`tests/unit/test_youtube_api_duration.py::test_repo_budget_formula_matches_sdk_auto_budget` +锁住两侧仍相等)。同 PR 把 `test_media_duration_probe.py` 里 +`test_every_admitted_duration_is_accepted_by_the_deadline_budget` 的 `"0"` 用例移出—— +0 不再是「准入认可的已知时长」。 + ## 并发压测 `scripts/perf/concurrent_load.py` 会提交本地 API 任务,并使用真实抖音和 B 站 diff --git a/tests/unit/test_capswriter_deadline_budget.py b/tests/unit/test_capswriter_deadline_budget.py index 0206d6c9..f32518ee 100644 --- a/tests/unit/test_capswriter_deadline_budget.py +++ b/tests/unit/test_capswriter_deadline_budget.py @@ -1,10 +1,11 @@ """转录时限预算按实测吞吐计算(issue #155)。 拿得到时长时本仓显式传 ``deadline_total = duration*4 + 120``;拿不到时长时不传该键, -回退 SDK 自动预算。SDK 的自动预算自 pin ``492fe19``(上游 PR #71)起是 -``_auto_budget(duration) = duration*4 + 120``,语义是 watchdog(挂死检测)而非识别时限 -SLA,由 ``test_sdk_auto_budget_*`` 单独锁住。每个用例都断言真正传给 -``transcribe_file_sync`` 的实参。 +回退 SDK 自动预算。时长为 0 同属「拿不到」(issue #190):直播录制的容器头部会写 0, +拿它算预算等于 120 秒,会确定性掐断本来能跑完的任务。SDK 的自动预算自 pin +``492fe19``(上游 PR #71)起是 ``_auto_budget(duration) = duration*4 + 120``,语义是 +watchdog(挂死检测)而非识别时限 SLA,由 ``test_sdk_auto_budget_*`` 单独锁住。每个用例都 +断言真正传给 ``transcribe_file_sync`` 的实参。 """ import inspect @@ -93,7 +94,7 @@ def test_duration_flows_through_transcriber_layer(tmp_path, sdk_config, audio): @pytest.mark.parametrize("duration,expected", [ - (0.0, 120.0), (1.0, 124.0), (30.0, 240.0), (45.0, 300.0), + (1.0, 124.0), (30.0, 240.0), (45.0, 300.0), (45.1, 300.4), (93.08898, 492.35592), (600.0, 2520.0), (3600.0, 14520.0), ]) def test_known_duration_formula(tmp_path, sdk_config, audio, duration, expected): @@ -102,20 +103,44 @@ def test_known_duration_formula(tmp_path, sdk_config, audio, duration, expected) assert sdk_call.call_args.kwargs["deadline_total"] == pytest.approx(expected) -@pytest.mark.parametrize("media_duration", [None, -1.0, float("nan"), float("inf"), float("-inf")]) +@pytest.mark.parametrize("media_duration", [None, 0, 0.0, -1.0, -5.0, float("nan"), float("inf"), float("-inf")]) def test_unknown_duration_omits_deadline_kwarg(tmp_path, sdk_config, audio, media_duration): with _patch_sdk() as sdk_call: _make_client(tmp_path).transcribe_file(str(audio), media_duration=media_duration) assert "deadline_total" not in sdk_call.call_args.kwargs +@pytest.mark.parametrize("media_duration", [0, 0.0, -5.0]) +def test_non_positive_duration_is_treated_as_unknown(tmp_path, sdk_config, audio, media_duration): + """时长 0 / 负数一律按「拿不到时长」退回 SDK 自动预算(issue #190)。 + + 两层都要断言:``_transcription_deadline`` 返回 None,且真的没把 + ``deadline_total`` 传给 SDK——只有第一层的话,调用点仍可能用别的值覆盖。 + + 时长为 0 的现实来源是直播录制的容器头部:ffprobe 给出 ``format.duration`` + 为 0,按公式算出 0*4+120=120 秒的预算,会在远端还在正常识别时掐断任务。 + SDK 自动预算改用转码后的实际采样数算时长,不受这个假 0 影响。 + """ + assert _transcription_deadline(media_duration) is None + with _patch_sdk() as sdk_call: + success, _ = _make_client(tmp_path).transcribe_file( + str(audio), media_duration=media_duration + ) + assert success is True + assert "deadline_total" not in sdk_call.call_args.kwargs + + def test_transcription_deadline_math_equivalence(): - """锁住链式比较与 math.isfinite 逐值等价,覆盖 nan, ±inf, 负数与正常值。""" + """锁住链式比较与 math.isfinite 逐值等价,覆盖 nan, ±inf, 零与负数。""" assert _transcription_deadline(None) is None assert _transcription_deadline(float("nan")) is None assert _transcription_deadline(float("inf")) is None assert _transcription_deadline(float("-inf")) is None assert _transcription_deadline(-1.0) is None + # issue #190: 0 不是「已知时长」,与负数同样退回 SDK 自动预算。 + assert _transcription_deadline(0) is None + assert _transcription_deadline(0.0) is None + assert _transcription_deadline(-5.0) is None assert _transcription_deadline(93.08898) == pytest.approx(492.35592) assert _transcription_deadline(600.0) == 2520.0 @@ -247,5 +272,20 @@ def test_sdk_auto_budget_grows_with_duration_and_matches_repo_budget(): assert auto_budget(600.0) > auto_budget(93.1) > auto_budget(0) # 显式路径没有被上游改动的部分:两者在拿得到时长时必须给同一个秒数, # 这正是本卡保留显式 deadline_total 的前提。 - for duration in (0.0, 93.08898, 600.0, 3600.0): + for duration in (93.08898, 600.0, 3600.0): assert auto_budget(duration) == pytest.approx(_transcription_deadline(duration)) + + +def test_sdk_auto_budget_is_what_covers_zero_duration(): + """时长 0 改由 SDK 自动预算兜底,两条路径给出的秒数仍然相等(issue #190)。 + + 价值不在预算值(都是 120),而在「0 不再掐断任务」:显式路径交出控制权, + SDK 转码后按实际采样数算时长,长直播拿到的是长预算。 + """ + auto_budget = _sdk_auto_budget() + + assert _transcription_deadline(0.0) is None + assert auto_budget(0.0) == pytest.approx(120.0) + # SDK 拿到的时长是转码后的实际采样数,不是容器头部那个 0,所以预算随真实 + # 素材长度放大——这正是 0 必须退回 SDK 的理由。 + assert auto_budget(17501.67) == pytest.approx(17501.67 * 4 + 120) diff --git a/tests/unit/test_media_duration_probe.py b/tests/unit/test_media_duration_probe.py index 5a5fcc7b..3f71e74f 100644 --- a/tests/unit/test_media_duration_probe.py +++ b/tests/unit/test_media_duration_probe.py @@ -263,12 +263,14 @@ def test_admission_returns_none_for_unusable_duration(tmp_path, raw): assert _transcription_deadline(parsed) is None -@pytest.mark.parametrize("raw", ["600.0", "1286.78", "17501.67", "0"]) +@pytest.mark.parametrize("raw", ["600.0", "1286.78", "17501.67"]) def test_every_admitted_duration_is_accepted_by_the_deadline_budget(tmp_path, raw): """准入认可的时长必须被预算公式真正采纳(不能是「算出来是 None」的哑弹)。 锁的是副作用的可观测面:时长一旦流向预算,``deadline_total`` 就是 - ``duration*4+120``,而不是悄悄退回 SDK 自动预算。 + ``duration*4+120``,而不是悄悄退回 SDK 自动预算。容器头部写 0 的直播录制 + 不在此列:0 按未知时长处理,由 ``test_capswriter_deadline_budget.py`` 覆盖 + (issue #190)。 """ parsed = _admission_duration(tmp_path / "a.mp3", raw) assert parsed is not None diff --git a/tests/unit/test_youtube_api_duration.py b/tests/unit/test_youtube_api_duration.py index 44ff9d4a..19a66556 100644 --- a/tests/unit/test_youtube_api_duration.py +++ b/tests/unit/test_youtube_api_duration.py @@ -263,7 +263,13 @@ def test_repo_budget_formula_matches_sdk_auto_budget(): """ from capswriter_asr.client import _auto_budget - for duration in (0.0, 1.0, 93.0, 600.0, 1286.78, 17501.67): + for duration in (1.0, 93.0, 600.0, 1286.78, 17501.67): explicit = _transcription_deadline(duration) assert explicit == pytest.approx(_auto_budget(duration)), duration - assert explicit == pytest.approx(duration * 4 + 120), duration \ No newline at end of file + assert explicit == pytest.approx(duration * 4 + 120), duration + + # 时长 0 不再是「已知时长」(issue #190:直播录制容器头部写 0):显式路径 + # 交出控制权,预算由 SDK 按转码后的实际采样数给出。同一条公式,所以 0 的 + # 预算仍是 120 秒——变的是来源,不是秒数。 + assert _transcription_deadline(0.0) is None + assert _auto_budget(0.0) == pytest.approx(120.0) \ No newline at end of file