合入main分支的改动。

This commit is contained in:
Hommy
2026-06-11 11:39:48 +08:00
parent bed48552b8
commit 2dece00aef
5 changed files with 354 additions and 12 deletions

View File

@@ -1,3 +1,16 @@
# Copyright 2026 Hommy <taohongmin@sina.cn>.
#
# 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:

View File

@@ -1,3 +1,16 @@
# Copyright 2026 Hommy <taohongmin@sina.cn>.
#
# 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)

View File

@@ -1,3 +1,16 @@
# Copyright 2026 Hommy <taohongmin@sina.cn>.
#
# 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:

View File

@@ -1,3 +1,16 @@
# Copyright 2026 Hommy <taohongmin@sina.cn>.
#
# 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

View File

@@ -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