Skip to content

Commit 50091bd

Browse files
committed
fix(cli): make batch preflight side-effect free
Validate every batch entry before copying local materials, reuse one managed copy per resolved source, and clean up all batch-created files when preparation fails. Reject unsafe clip speeds and keep trusted custom audio support after rebasing onto current main.
1 parent b3599f6 commit 50091bd

2 files changed

Lines changed: 246 additions & 21 deletions

File tree

cli.py

Lines changed: 99 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -959,6 +959,11 @@ def _validate_batch_task_params(
959959
raise ValueError("custom_position requires subtitle_position=custom")
960960
if not math.isfinite(params.custom_position) or not 0 <= params.custom_position <= 100:
961961
raise ValueError("custom_position must be a finite number between 0 and 100")
962+
if params.video_clip_speed is not None and (
963+
not math.isfinite(params.video_clip_speed)
964+
or not 0.5 <= params.video_clip_speed <= 2.0
965+
):
966+
raise ValueError("video_clip_speed must be a finite number between 0.5 and 2.0")
962967
if params.text_background_color is False and params.rounded_subtitle_background:
963968
raise ValueError(
964969
"rounded_subtitle_background requires an enabled subtitle background"
@@ -1047,11 +1052,31 @@ def _build_batch_tasks(args: argparse.Namespace) -> list[VideoParams]:
10471052
raise ValueError(f"invalid batch task {index}: {exc}") from exc
10481053
tasks.append(params)
10491054

1055+
validation_plans = []
10501056
for index, params in enumerate(tasks, start=1):
10511057
try:
1052-
prepare_cli_files(params, stop_at=args.stop_at)
1058+
validation_plans.append(_validate_cli_files(params, stop_at=args.stop_at))
10531059
except (OSError, ValueError) as exc:
10541060
raise ValueError(f"invalid batch task {index}: {exc}") from exc
1061+
1062+
prepared_paths: dict[str, str] = {}
1063+
created_paths: list[str] = []
1064+
try:
1065+
for index, (local_videos_dir, resolved_materials) in enumerate(
1066+
validation_plans, start=1
1067+
):
1068+
try:
1069+
_prepare_cli_materials(
1070+
local_videos_dir,
1071+
resolved_materials,
1072+
prepared_paths=prepared_paths,
1073+
created_paths=created_paths,
1074+
)
1075+
except OSError as exc:
1076+
raise ValueError(f"invalid batch task {index}: {exc}") from exc
1077+
except Exception:
1078+
_remove_cli_material_copies(created_paths)
1079+
raise
10551080
return tasks
10561081

10571082

@@ -1126,13 +1151,15 @@ def _resolve_managed_resource_file(
11261151
)
11271152

11281153

1129-
def prepare_cli_files(params: VideoParams, stop_at: str) -> None:
1154+
def _validate_cli_files(
1155+
params: VideoParams, stop_at: str
1156+
) -> tuple[str, list[tuple[MaterialInfo, str, str]]]:
11301157
"""
1131-
在调用 LLM/TTS 前准备 CLI 文件,避免长流程运行到后期才报告路径错误
1158+
无副作用地解析并校验 CLI 文件,避免批量预检留下素材副本
11321159
1133-
服务层为了保护 API 请求,只允许读取 ``storage/local_videos`` 内的素材。
1134-
CLI 是本地入口,接受当前目录相对路径和绝对路径。目录外素材会
1135-
复制到受控目录,再把参数替换为服务层可安全使用的绝对路径
1160+
自定义音频、BGM 和字体会被规范化为服务层可使用的路径或名称;本地素材
1161+
仅解析来源和扩展名,实际复制由 ``_prepare_cli_materials`` 在所有批量条目
1162+
校验通过后统一完成
11361163
"""
11371164
from app.models import const
11381165
from app.services import bgm as bgm_service
@@ -1202,9 +1229,9 @@ def prepare_cli_files(params: VideoParams, stop_at: str) -> None:
12021229
params.font_name = os.path.basename(font_path)
12031230

12041231
if params.video_source != "local" or stop_at not in {"materials", "video"}:
1205-
return
1232+
return "", []
12061233

1207-
local_videos_dir = utils.storage_dir("local_videos", create=True)
1234+
local_videos_dir = utils.storage_dir("local_videos")
12081235
resolved_materials: list[tuple[MaterialInfo, str, str]] = []
12091236
for material in params.video_materials or []:
12101237
source_path = _resolve_cli_file(
@@ -1221,9 +1248,39 @@ def prepare_cli_files(params: VideoParams, stop_at: str) -> None:
12211248
)
12221249
resolved_materials.append((material, source_path, extension))
12231250

1224-
# 所有输入检查通过后再复制,避免第二个文件无效时留下第一个文件的
1225-
# 孤儿副本。
1226-
prepared_paths: dict[str, str] = {}
1251+
return local_videos_dir, resolved_materials
1252+
1253+
1254+
def _remove_cli_material_copies(created_paths: Sequence[str]) -> None:
1255+
"""Best-effort cleanup for managed copies created by one CLI preparation."""
1256+
for file_path in reversed(created_paths):
1257+
try:
1258+
if os.path.isfile(file_path):
1259+
os.remove(file_path)
1260+
except OSError as exc:
1261+
logger.warning(
1262+
f"failed to remove prepared CLI material: path={file_path}, "
1263+
f"error={exc}"
1264+
)
1265+
1266+
1267+
def _prepare_cli_materials(
1268+
local_videos_dir: str,
1269+
resolved_materials: Sequence[tuple[MaterialInfo, str, str]],
1270+
*,
1271+
prepared_paths: dict[str, str] | None = None,
1272+
created_paths: list[str] | None = None,
1273+
) -> None:
1274+
"""Copy validated local materials once and update every matching reference."""
1275+
if not resolved_materials:
1276+
return
1277+
1278+
os.makedirs(local_videos_dir, exist_ok=True)
1279+
if prepared_paths is None:
1280+
prepared_paths = {}
1281+
if created_paths is None:
1282+
created_paths = []
1283+
12271284
for material, source_path, extension in resolved_materials:
12281285
prepared_path = prepared_paths.get(source_path)
12291286
if prepared_path is None:
@@ -1234,7 +1291,16 @@ def prepare_cli_files(params: VideoParams, stop_at: str) -> None:
12341291
local_videos_dir,
12351292
f"cli-material-{uuid4().hex}{extension}",
12361293
)
1237-
shutil.copy2(source_path, prepared_path)
1294+
try:
1295+
shutil.copy2(source_path, prepared_path)
1296+
except OSError:
1297+
try:
1298+
if os.path.exists(prepared_path):
1299+
os.remove(prepared_path)
1300+
except OSError:
1301+
pass
1302+
raise
1303+
created_paths.append(prepared_path)
12381304
logger.info(
12391305
"copied CLI local material into managed storage: "
12401306
f"source={source_path}, target={prepared_path}"
@@ -1244,6 +1310,21 @@ def prepare_cli_files(params: VideoParams, stop_at: str) -> None:
12441310
material.url = prepared_path
12451311

12461312

1313+
def prepare_cli_files(params: VideoParams, stop_at: str) -> None:
1314+
"""Validate and prepare files for one trusted local CLI task."""
1315+
local_videos_dir, resolved_materials = _validate_cli_files(params, stop_at)
1316+
created_paths: list[str] = []
1317+
try:
1318+
_prepare_cli_materials(
1319+
local_videos_dir,
1320+
resolved_materials,
1321+
created_paths=created_paths,
1322+
)
1323+
except Exception:
1324+
_remove_cli_material_copies(created_paths)
1325+
raise
1326+
1327+
12471328
def _run_batch_tasks(args: argparse.Namespace, tasks: list[VideoParams]) -> int:
12481329
from app.services import task as tm
12491330
from app.utils import utils
@@ -1266,7 +1347,12 @@ def _run_batch_tasks(args: argparse.Namespace, tasks: list[VideoParams]) -> int:
12661347
failed_stage = None
12671348
error = None
12681349
try:
1269-
result = tm.start(task_id=task_id, params=params, stop_at=args.stop_at)
1350+
result = tm.start(
1351+
task_id=task_id,
1352+
params=params,
1353+
stop_at=args.stop_at,
1354+
allow_server_file_input=True,
1355+
)
12701356
except Exception as exc:
12711357
failed_stage = "runtime"
12721358
error = str(exc)

test/services/test_cli.py

Lines changed: 147 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -645,6 +645,8 @@ def test_batch_json_array_merges_cli_defaults_and_prints_summary(self):
645645
)
646646
self.assertEqual(first_call.kwargs["params"].voice_name, "global-voice")
647647
self.assertEqual(second_call.kwargs["params"].voice_name, "global-voice")
648+
self.assertIs(first_call.kwargs["allow_server_file_input"], True)
649+
self.assertIs(second_call.kwargs["allow_server_file_input"], True)
648650
summary = json.loads(output.getvalue())
649651
self.assertEqual(
650652
{key: summary[key] for key in ("total", "succeeded", "failed")},
@@ -733,27 +735,142 @@ def test_invalid_later_batch_task_prevents_every_task_from_starting(self):
733735
self.assertIn("unknown VideoParams fields", str(log_error.call_args))
734736

735737
def test_missing_file_in_later_batch_task_prevents_every_task_from_starting(self):
736-
with tempfile.TemporaryDirectory() as temp_dir:
738+
with (
739+
tempfile.TemporaryDirectory() as temp_dir,
740+
tempfile.TemporaryDirectory() as managed_dir,
741+
):
742+
source_file = Path(temp_dir) / "valid.mp4"
743+
source_file.write_bytes(b"valid video")
737744
manifest = Path(temp_dir) / "tasks.json"
738745
manifest.write_text(
739746
json.dumps(
740747
[
741-
{"video_subject": "valid"},
742748
{
743-
"video_subject": "missing audio",
744-
"custom_audio_file": "missing.mp3",
749+
"video_subject": "valid local material",
750+
"video_source": "local",
751+
"video_materials": [
752+
{"provider": "local", "url": "valid.mp4"}
753+
],
754+
},
755+
{
756+
"video_subject": "missing local material",
757+
"video_source": "local",
758+
"video_materials": [
759+
{"provider": "local", "url": "missing.mp4"}
760+
],
745761
},
746762
]
747763
),
748764
encoding="utf-8",
749765
)
750-
with patch("app.services.task.start") as start:
766+
with (
767+
patch("app.services.task.start") as start,
768+
patch("app.utils.utils.storage_dir", return_value=managed_dir),
769+
):
751770
code = cli.run_cli(
752-
["--batch-file", str(manifest), "--stop-at", "audio"]
771+
["--batch-file", str(manifest), "--stop-at", "materials"]
753772
)
754773

755-
self.assertEqual(code, 2)
756-
start.assert_not_called()
774+
self.assertEqual(code, 2)
775+
start.assert_not_called()
776+
self.assertEqual(os.listdir(managed_dir), [])
777+
778+
def test_batch_reuses_one_managed_copy_for_repeated_local_material(self):
779+
with (
780+
tempfile.TemporaryDirectory() as manifest_dir,
781+
tempfile.TemporaryDirectory() as managed_dir,
782+
):
783+
source_file = Path(manifest_dir) / "shared.mp4"
784+
source_file.write_bytes(b"shared video")
785+
manifest = Path(manifest_dir) / "tasks.json"
786+
manifest.write_text(
787+
json.dumps(
788+
[
789+
{
790+
"video_subject": subject,
791+
"video_source": "local",
792+
"video_materials": [
793+
{"provider": "local", "url": "shared.mp4"}
794+
],
795+
}
796+
for subject in ("first", "second")
797+
]
798+
),
799+
encoding="utf-8",
800+
)
801+
with (
802+
patch(
803+
"app.services.task.start",
804+
side_effect=[
805+
{"state": 1, "materials": ["first"]},
806+
{"state": 1, "materials": ["second"]},
807+
],
808+
) as start,
809+
patch(
810+
"app.utils.utils.get_uuid",
811+
side_effect=["task-one", "task-two"],
812+
),
813+
patch("app.utils.utils.storage_dir", return_value=managed_dir),
814+
redirect_stdout(io.StringIO()),
815+
):
816+
code = cli.run_cli(
817+
["--batch-file", str(manifest), "--stop-at", "materials"]
818+
)
819+
820+
first_path = start.call_args_list[0].kwargs["params"].video_materials[0].url
821+
second_path = start.call_args_list[1].kwargs["params"].video_materials[0].url
822+
managed_files = os.listdir(managed_dir)
823+
824+
self.assertEqual(code, 0)
825+
self.assertEqual(first_path, second_path)
826+
self.assertEqual(len(managed_files), 1)
827+
self.assertTrue(managed_files[0].startswith("cli-material-"))
828+
829+
def test_batch_copy_failure_removes_all_managed_materials(self):
830+
with (
831+
tempfile.TemporaryDirectory() as manifest_dir,
832+
tempfile.TemporaryDirectory() as managed_dir,
833+
):
834+
first_source = Path(manifest_dir) / "first.mp4"
835+
second_source = Path(manifest_dir) / "second.mp4"
836+
first_source.write_bytes(b"first video")
837+
second_source.write_bytes(b"second video")
838+
manifest = Path(manifest_dir) / "tasks.json"
839+
manifest.write_text(
840+
json.dumps(
841+
[
842+
{
843+
"video_subject": name,
844+
"video_source": "local",
845+
"video_materials": [
846+
{"provider": "local", "url": f"{name}.mp4"}
847+
],
848+
}
849+
for name in ("first", "second")
850+
]
851+
),
852+
encoding="utf-8",
853+
)
854+
real_copy = cli.shutil.copy2
855+
856+
def fail_second_copy(source, target):
857+
if os.path.basename(source) == "second.mp4":
858+
Path(target).write_bytes(b"partial copy")
859+
raise OSError("simulated copy failure")
860+
return real_copy(source, target)
861+
862+
with (
863+
patch("app.services.task.start") as start,
864+
patch("app.utils.utils.storage_dir", return_value=managed_dir),
865+
patch.object(cli.shutil, "copy2", side_effect=fail_second_copy),
866+
):
867+
code = cli.run_cli(
868+
["--batch-file", str(manifest), "--stop-at", "materials"]
869+
)
870+
871+
self.assertEqual(code, 2)
872+
start.assert_not_called()
873+
self.assertEqual(os.listdir(managed_dir), [])
757874

758875
def test_batch_rejects_non_object_and_unknown_material_fields(self):
759876
invalid_manifests = (
@@ -817,6 +934,28 @@ def test_batch_rejects_invalid_subtitle_color_before_start(self):
817934
self.assertEqual(code, 2)
818935
start.assert_not_called()
819936

937+
def test_batch_rejects_invalid_video_clip_speed_before_start(self):
938+
with tempfile.TemporaryDirectory() as temp_dir:
939+
manifest = Path(temp_dir) / "tasks.json"
940+
manifest.write_text(
941+
json.dumps(
942+
[
943+
{
944+
"video_subject": "invalid speed",
945+
"video_clip_speed": -1,
946+
}
947+
]
948+
),
949+
encoding="utf-8",
950+
)
951+
with patch("app.services.task.start") as start:
952+
code = cli.run_cli(
953+
["--batch-file", str(manifest), "--stop-at", "script"]
954+
)
955+
956+
self.assertEqual(code, 2)
957+
start.assert_not_called()
958+
820959
def test_batch_manifest_limits_size_and_task_count(self):
821960
with tempfile.TemporaryDirectory() as temp_dir:
822961
oversized = Path(temp_dir) / "oversized.jsonl"

0 commit comments

Comments
 (0)