diff --git a/src/service/add_audios.py b/src/service/add_audios.py index 951c3f1..5d12de3 100644 --- a/src/service/add_audios.py +++ b/src/service/add_audios.py @@ -1,3 +1,16 @@ +# Copyright 2026 Hommy . +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. from src.utils.logger import logger from src.pyJianYingDraft import ScriptFile, trange, AudioSceneEffectType, VideoSceneEffectType, VideoCharacterEffectType import src.pyJianYingDraft as draft @@ -531,6 +544,24 @@ def validate_numeric_ranges(processed_item: Dict[str, Any], index: int): if processed_item["volume"] < 0.0 or processed_item["volume"] > 2.0: logger.warning(f"Volume value {processed_item['volume']} out of range [0.0, 2.0], using default 1.0") processed_item["volume"] = 1.0 + + if not isinstance(processed_item["start"], (int, float)) or processed_item["start"] < 0: + logger.error(f"the {index}th item has invalid start time: {processed_item['start']}") + raise CustomException(CustomError.INVALID_AUDIO_INFO, f"the {index}th item has invalid start time") + + if not isinstance(processed_item["end"], (int, float)) or processed_item["end"] <= processed_item["start"]: + logger.error(f"the {index}th item has invalid end time: {processed_item['end']}") + raise CustomException(CustomError.INVALID_AUDIO_INFO, f"the {index}th item has invalid end time") + + # 将时间转换为整数(微秒),兼容 start/end 为小数的情况 + processed_item["start"] = int(processed_item["start"]) + processed_item["end"] = int(processed_item["end"]) + if processed_item["end"] <= processed_item["start"]: + logger.error( + f"the {index}th item has invalid end time after int conversion: " + f"start={processed_item['start']}, end={processed_item['end']}" + ) + raise CustomException(CustomError.INVALID_AUDIO_INFO, f"the {index}th item has invalid end time") # 如果提供了 duration 且小于等于 0,则报错 if processed_item["duration"] is not None and processed_item["duration"] <= 0: diff --git a/src/service/add_filters.py b/src/service/add_filters.py index 6aa8570..3bb43bf 100644 --- a/src/service/add_filters.py +++ b/src/service/add_filters.py @@ -1,3 +1,16 @@ +# Copyright 2026 Hommy . +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. import json from typing import List, Dict, Any, Tuple, Optional import asyncio @@ -317,9 +330,15 @@ def parse_filters_data(json_str: str) -> List[Dict[str, Any]]: logger.error(f"the {i}th item has invalid intensity: {processed_item['intensity']}") raise CustomException(CustomError.INVALID_FILTER_INFO, f"the {i}th item has invalid intensity (must be 0-100)") - # 将时间转换为整数(微秒) + # 将时间转换为整数(微秒),兼容 start/end 为小数的情况 processed_item["start"] = int(processed_item["start"]) processed_item["end"] = int(processed_item["end"]) + if processed_item["end"] <= processed_item["start"]: + logger.error( + f"the {i}th item has invalid end time after int conversion: " + f"start={processed_item['start']}, end={processed_item['end']}" + ) + raise CustomException(CustomError.INVALID_FILTER_INFO, f"the {i}th item has invalid end time") processed_item["intensity"] = float(processed_item["intensity"]) result.append(processed_item) diff --git a/src/service/add_images.py b/src/service/add_images.py index b47c16c..0dae845 100644 --- a/src/service/add_images.py +++ b/src/service/add_images.py @@ -1,3 +1,16 @@ +# Copyright 2026 Hommy . +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. from src.utils.logger import logger from src.pyJianYingDraft import ScriptFile, trange import src.pyJianYingDraft as draft @@ -509,8 +522,8 @@ def parse_image_data(json_str: str) -> List[Dict[str, Any]]: "image_url": item["image_url"], "width": width, "height": height, - "start": int(item["start"]), - "end": int(item["end"]), + "start": item["start"], + "end": item["end"], "in_animation": item.get("in_animation", None), # 默认无入场动画 "out_animation": item.get("out_animation", None), # 默认无出场动画 "loop_animation": item.get("loop_animation", None), # 默认无循环动画 @@ -529,10 +542,24 @@ def parse_image_data(json_str: str) -> List[Dict[str, Any]]: f"Invalid image dimensions: width={processed_item['width']}, height={processed_item['height']}" ) raise CustomException(CustomError.INVALID_IMAGE_INFO, f"the {i}th item has invalid image dimensions") - - if processed_item["start"] < 0 or processed_item["end"] <= processed_item["start"]: - logger.error(f"Invalid time range: start={processed_item['start']}, end={processed_item['end']}") - raise CustomException(CustomError.INVALID_IMAGE_INFO, f"the {i}th item has invalid time range") + + if not isinstance(processed_item["start"], (int, float)) or processed_item["start"] < 0: + logger.error(f"the {i}th item has invalid start time: {processed_item['start']}") + raise CustomException(CustomError.INVALID_IMAGE_INFO, f"the {i}th item has invalid start time") + + if not isinstance(processed_item["end"], (int, float)) or processed_item["end"] <= processed_item["start"]: + logger.error(f"the {i}th item has invalid end time: {processed_item['end']}") + raise CustomException(CustomError.INVALID_IMAGE_INFO, f"the {i}th item has invalid end time") + + # 将时间转换为整数(微秒),兼容 start/end 为小数的情况 + processed_item["start"] = int(processed_item["start"]) + processed_item["end"] = int(processed_item["end"]) + if processed_item["end"] <= processed_item["start"]: + logger.error( + f"the {i}th item has invalid end time after int conversion: " + f"start={processed_item['start']}, end={processed_item['end']}" + ) + raise CustomException(CustomError.INVALID_IMAGE_INFO, f"the {i}th item has invalid end time") # 验证转场时长范围 if processed_item["transition_duration"] < 100000 or processed_item["transition_duration"] > 2500000: diff --git a/src/service/add_videos.py b/src/service/add_videos.py index c3514fc..0762673 100644 --- a/src/service/add_videos.py +++ b/src/service/add_videos.py @@ -1,3 +1,16 @@ +# Copyright 2026 Hommy . +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. from src.pyJianYingDraft.video_segment import VideoSegment import asyncio @@ -491,17 +504,34 @@ def parse_video_data(json_str: str) -> List[Dict[str, Any]]: if missing_fields: raise CustomException(CustomError.INVALID_VIDEO_INFO, f"the {i}th item is missing required fields: {', '.join(missing_fields)}") - - # 如果没有提供duration,则计算为end-start - duration = item.get("duration", item["end"] - item["start"]) + + if not isinstance(item["start"], (int, float)) or item["start"] < 0: + raise CustomException(CustomError.INVALID_VIDEO_INFO, f"the {i}th item has invalid start time") + + if not isinstance(item["end"], (int, float)) or item["end"] <= item["start"]: + raise CustomException(CustomError.INVALID_VIDEO_INFO, f"the {i}th item has invalid end time") + + # 将时间转换为整数(微秒),兼容 start/end 为小数的情况 + start = int(item["start"]) + end = int(item["end"]) + if end <= start: + raise CustomException(CustomError.INVALID_VIDEO_INFO, f"the {i}th item has invalid end time") + + if "duration" in item: + duration = item["duration"] + if not isinstance(duration, (int, float)) or duration <= 0: + raise CustomException(CustomError.INVALID_VIDEO_INFO, f"the {i}th item has invalid duration") + duration = int(duration) + else: + duration = end - start # 创建处理后的对象,设置默认值 processed_item = { "video_url": item["video_url"], "width": item.get("width"), # 可选参数 "height": item.get("height"), # 可选参数 - "start": item["start"], - "end": item["end"], + "start": start, + "end": end, "duration": duration, "mask": item.get("mask", None), # 默认值 None "transition": item.get("transition", None), # 默认值 None diff --git a/tests/test_parse_time_range_compat.py b/tests/test_parse_time_range_compat.py new file mode 100644 index 0000000..e3697db --- /dev/null +++ b/tests/test_parse_time_range_compat.py @@ -0,0 +1,235 @@ +"""各素材接口 parse 函数对 start/end 小数时间的兼容性测试。""" +import json +import os +import sys + +import pytest + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +from exceptions import CustomException, CustomError +from src.schemas.add_captions import AddCaptionsResponse, SegmentInfo as CaptionSegmentInfo +from src.schemas.add_images import AddImagesResponse, SegmentInfo as ImageSegmentInfo +from src.schemas.add_videos import AddVideosResponse, SegmentInfo as VideoSegmentInfo +from src.service.add_audios import parse_audio_data +from src.service.add_captions import parse_captions_data +from src.service.add_filters import parse_filters_data +from src.service.add_images import parse_image_data +from src.service.add_videos import parse_video_data + + +def _sample_item(service: str, start, end) -> dict: + if service == "captions": + return {"start": start, "end": end, "text": "测试字幕"} + if service == "filters": + return {"filter_title": "复古", "start": start, "end": end, "intensity": 80} + if service == "images": + return {"image_url": "https://example.com/image.jpg", "start": start, "end": end} + if service == "audios": + return {"audio_url": "https://example.com/audio.mp3", "start": start, "end": end} + if service == "videos": + return {"video_url": "https://example.com/video.mp4", "start": start, "end": end} + raise ValueError(f"unknown service: {service}") + + +PARSERS = { + "captions": (parse_captions_data, CustomError.INVALID_CAPTION_INFO), + "filters": (parse_filters_data, CustomError.INVALID_FILTER_INFO), + "images": (parse_image_data, CustomError.INVALID_IMAGE_INFO), + "audios": (parse_audio_data, CustomError.INVALID_AUDIO_INFO), + "videos": (parse_video_data, CustomError.INVALID_VIDEO_INFO), +} + + +@pytest.mark.parametrize("service", PARSERS.keys()) +def test_integer_start_end_unchanged(service): + """回归:整数 start/end 行为与改动前一致。""" + parse_fn, _ = PARSERS[service] + item = _sample_item(service, 0, 5_000_000) + result = parse_fn(json.dumps([item])) + + assert len(result) == 1 + assert result[0]["start"] == 0 + assert result[0]["end"] == 5_000_000 + assert isinstance(result[0]["start"], int) + assert isinstance(result[0]["end"], int) + + +@pytest.mark.parametrize("service", PARSERS.keys()) +def test_float_start_end_converted_to_int(service): + """小数 start/end 应截断为整数并正常解析。""" + parse_fn, _ = PARSERS[service] + item = _sample_item(service, 0.5, 5_000_000.9) + result = parse_fn(json.dumps([item])) + + assert result[0]["start"] == 0 + assert result[0]["end"] == 5_000_000 + + +@pytest.mark.parametrize("service", PARSERS.keys()) +def test_invalid_range_after_int_truncation(service): + """转换后 end <= start 应报错。""" + parse_fn, error = PARSERS[service] + item = _sample_item(service, 1000.1, 1000.9) + + with pytest.raises(CustomException) as exc_info: + parse_fn(json.dumps([item])) + + assert exc_info.value.err == error + + +@pytest.mark.parametrize("service", PARSERS.keys()) +def test_negative_start_raises(service): + parse_fn, error = PARSERS[service] + item = _sample_item(service, -1, 5_000_000) + + with pytest.raises(CustomException) as exc_info: + parse_fn(json.dumps([item])) + + assert exc_info.value.err == error + + +@pytest.mark.parametrize("service", PARSERS.keys()) +def test_end_lte_start_raises(service): + parse_fn, error = PARSERS[service] + item = _sample_item(service, 5_000_000, 5_000_000) + + with pytest.raises(CustomException) as exc_info: + parse_fn(json.dumps([item])) + + assert exc_info.value.err == error + + +def test_captions_multiple_items_with_optional_fields(): + """回归:多字幕及可选字段解析不受影响。""" + items = [ + { + "start": 0, + "end": 3_000_000, + "text": "第一句", + "keyword": "第一", + "keyword_font_size": 20, + }, + { + "start": 3_000_000.2, + "end": 6_000_000.8, + "text": "第二句", + }, + ] + result = parse_captions_data(json.dumps(items)) + + assert len(result) == 2 + assert result[0]["keyword_font_size"] == 20 + assert result[1]["start"] == 3_000_000 + assert result[1]["end"] == 6_000_000 + + +def test_filters_intensity_preserved(): + """回归:滤镜强度等字段在 time 转换后仍保留。""" + result = parse_filters_data(json.dumps([ + {"filter_title": "复古", "start": 0.1, "end": 2_000_000.9, "intensity": 60.5}, + ])) + + assert result[0]["start"] == 0 + assert result[0]["end"] == 2_000_000 + assert result[0]["intensity"] == 60.5 + + +def test_images_optional_dimensions_preserved(): + """回归:图片 width/height 可选字段不受影响。""" + result = parse_image_data(json.dumps([ + { + "image_url": "https://example.com/a.png", + "width": 1920, + "height": 1080, + "start": 0.5, + "end": 1_000_000.5, + "transition_duration": 500000, + }, + ])) + + assert result[0]["width"] == 1920 + assert result[0]["height"] == 1080 + assert result[0]["start"] == 0 + assert result[0]["end"] == 1_000_000 + + +def test_audios_volume_default_preserved(): + """回归:音频 volume 默认值逻辑不受影响。""" + result = parse_audio_data(json.dumps([ + {"audio_url": "https://example.com/a.mp3", "start": 0, "end": 5_000_000}, + ])) + + assert result[0]["volume"] == 1.0 + assert result[0]["start"] == 0 + assert result[0]["end"] == 5_000_000 + + +def test_videos_duration_regression(): + """回归:video duration 显式传入 / 默认 end-start 逻辑不变。""" + videos = parse_video_data(json.dumps([ + { + "video_url": "https://example.com/v1.mp4", + "start": 0, + "end": 3_000_000, + "duration": 6_000_000, + }, + { + "video_url": "https://example.com/v2.mp4", + "start": 3_000_000, + "end": 5_000_000, + }, + ])) + + assert videos[0]["duration"] == 6_000_000 + assert videos[0]["end"] - videos[0]["start"] == 3_000_000 + assert videos[1]["duration"] == 2_000_000 + + +def test_videos_float_duration_converted(): + result = parse_video_data(json.dumps([ + { + "video_url": "https://example.com/v.mp4", + "start": 0.5, + "end": 3_000_000.9, + "duration": 6_000_000.7, + }, + ])) + + assert result[0]["start"] == 0 + assert result[0]["end"] == 3_000_000 + assert result[0]["duration"] == 6_000_000 + + +@pytest.mark.parametrize( + "response_builder", + [ + lambda start, end: AddCaptionsResponse( + draft_url="x", + track_id="t", + text_ids=[], + segment_ids=[], + segment_infos=[CaptionSegmentInfo(id="1", start=start, end=end)], + ), + lambda start, end: AddImagesResponse( + draft_url="x", + track_id="t", + image_ids=[], + segment_ids=[], + segment_infos=[ImageSegmentInfo(id="1", start=start, end=end)], + ), + lambda start, end: AddVideosResponse( + draft_url="x", + track_id="t", + video_ids=[], + segment_ids=[], + segment_infos=[VideoSegmentInfo(id="1", start=start, end=end)], + ), + ], + ids=["captions", "images", "videos"], +) +def test_parsed_times_pass_response_validation(response_builder): + """回归:解析后的整数时间可通过 Pydantic 响应模型校验。""" + response = response_builder(0, 5_000_000) + assert response.segment_infos[0].start == 0 + assert response.segment_infos[0].end == 5_000_000