From b609a37986eafeab6562cd822752eeb937eb85f7 Mon Sep 17 00:00:00 2001 From: Hommy <16620803786@163.com> Date: Wed, 24 Sep 2025 14:47:27 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E6=B7=BB=E5=8A=A0=E5=AD=97=E5=B9=95?= =?UTF-8?q?=E7=9A=84=E6=8E=A5=E5=8F=A3=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/add_captions.md | 228 +++++++++++++++++++++ exceptions.py | 2 + src/router/v1.py | 34 ++++ src/schemas/add_captions.py | 46 +++++ src/service/__init__.py | 3 +- src/service/add_captions.py | 389 ++++++++++++++++++++++++++++++++++++ 6 files changed, 701 insertions(+), 1 deletion(-) create mode 100644 docs/add_captions.md create mode 100644 src/schemas/add_captions.py create mode 100644 src/service/add_captions.py diff --git a/docs/add_captions.md b/docs/add_captions.md new file mode 100644 index 0000000..1ee7233 --- /dev/null +++ b/docs/add_captions.md @@ -0,0 +1,228 @@ +# add_captions 接口文档 + +## 接口描述 +向剪映草稿批量添加字幕,支持丰富的字幕样式设置,包括关键词高亮、字体样式、边框效果、对齐方式、透明度和动画效果等。 + +## 接口信息 +- **方法**: POST +- **路径**: `/v1/add_captions` +- **Content-Type**: `application/json` + +## 请求参数 + +### 请求体 +```json +{ + "draft_url": "https://ts.fyshark.com/#/cozeToJianyin?drafId=...", + "captions": "[{\"start\":0,\"end\":10000000,\"text\":\"你好,剪映\",\"keyword\":\"好\",\"keyword_color\":\"#457616\",\"keyword_font_size\":15,\"font_size\":15}]", + "text_color": "#ffffff", + "border_color": null, + "alignment": 1, + "alpha": 1.0, + "font": null, + "font_size": 15, + "letter_spacing": null, + "line_spacing": null, + "scale_x": 1.0, + "scale_y": 1.0, + "transform_x": 0, + "transform_y": 0, + "style_text": false +} +``` + +### 参数说明 + +| 字段名 | 类型 | 必填 | 默认值 | 描述 | +|--------|------|------|--------|------| +| draft_url | string | 是 | - | 草稿URL | +| captions | string | 是 | - | 字幕信息列表的JSON字符串 | +| text_color | string | 否 | "#ffffff" | 文本颜色(十六进制) | +| border_color | string | 否 | null | 边框颜色(十六进制) | +| alignment | integer | 否 | 1 | 文本对齐方式(0-5) | +| alpha | number | 否 | 1.0 | 文本透明度(0.0-1.0) | +| font | string | 否 | null | 字体名称 | +| font_size | integer | 否 | 15 | 字体大小 | +| letter_spacing | number | 否 | null | 字间距 | +| line_spacing | number | 否 | null | 行间距 | +| scale_x | number | 否 | 1.0 | 水平缩放 | +| scale_y | number | 否 | 1.0 | 垂直缩放 | +| transform_x | integer | 否 | 0 | 水平位移 | +| transform_y | integer | 否 | 0 | 垂直位移 | +| style_text | boolean | 否 | false | 是否使用样式文本 | + +### captions 字段详细说明 +captions 是一个JSON字符串,包含字幕数组,每个字幕对象包含以下字段: + +| 字段名 | 类型 | 必填 | 默认值 | 描述 | +|--------|------|------|--------|------| +| start | integer | 是 | - | 字幕开始时间(微秒) | +| end | integer | 是 | - | 字幕结束时间(微秒) | +| text | string | 是 | - | 字幕文本内容 | +| keyword | string | 否 | null | 关键词(用\|分隔多个关键词) | +| keyword_color | string | 否 | "#ff7100" | 关键词颜色 | +| keyword_font_size | integer | 否 | 15 | 关键词字体大小 | +| font_size | integer | 否 | 15 | 文本字体大小 | +| in_animation | string | 否 | null | 入场动画 | +| out_animation | string | 否 | null | 出场动画 | +| loop_animation | string | 否 | null | 循环动画 | +| in_animation_duration | integer | 否 | null | 入场动画时长 | +| out_animation_duration | integer | 否 | null | 出场动画时长 | +| loop_animation_duration | integer | 否 | null | 循环动画时长 | + +### 对齐方式说明 + +| 值 | 说明 | +|---|------| +| 0 | 左对齐 | +| 1 | 居中对齐 | +| 2 | 右对齐 | +| 3 | 垂直居中 | +| 4 | 垂直左对齐 | +| 5 | 垂直右对齐 | + +## 响应结果 + +### 成功响应 +```json +{ + "draft_url": "https://ts.fyshark.com/#/cozeToJianyin?drafId=...", + "track_id": "text_track_123", + "text_ids": ["text_001", "text_002"], + "segment_ids": ["seg_001", "seg_002"] +} +``` + +### 响应字段说明 + +| 字段名 | 类型 | 描述 | +|--------|------|------| +| draft_url | string | 草稿URL | +| track_id | string | 字幕轨道ID | +| text_ids | array | 字幕ID列表 | +| segment_ids | array | 字幕片段ID列表 | + +### 错误响应 +```json +{ + "code": 2018, + "message": "无效的字幕信息,请检查captions字段值是否正确" +} +``` + +## 使用示例 + +### cURL 示例 +```bash +curl -X POST "http://localhost:8000/v1/add_captions" \ + -H "Content-Type: application/json" \ + -d '{ + "draft_url": "https://ts.fyshark.com/#/cozeToJianyin?drafId=example123", + "captions": "[{\"start\":0,\"end\":5000000,\"text\":\"你好,剪映\",\"keyword\":\"好\",\"keyword_color\":\"#ff0000\"}]", + "text_color": "#ffffff", + "alignment": 1, + "alpha": 1.0, + "font_size": 20 + }' +``` + +### Python 示例 +```python +import requests +import json + +url = "http://localhost:8000/v1/add_captions" +captions_data = [ + { + "start": 0, + "end": 5000000, + "text": "你好,剪映", + "keyword": "好", + "keyword_color": "#ff0000", + "font_size": 20 + }, + { + "start": 5000000, + "end": 10000000, + "text": "欢迎使用字幕功能", + "keyword": "字幕", + "keyword_color": "#00ff00", + "font_size": 18 + } +] + +payload = { + "draft_url": "https://ts.fyshark.com/#/cozeToJianyin?drafId=example123", + "captions": json.dumps(captions_data), + "text_color": "#ffffff", + "alignment": 1, + "alpha": 1.0, + "font_size": 16, + "transform_y": -100 +} + +response = requests.post(url, json=payload) +print(response.json()) +``` + +### JavaScript 示例 +```javascript +const url = "http://localhost:8000/v1/add_captions"; +const captionsData = [ + { + start: 0, + end: 5000000, + text: "你好,剪映", + keyword: "好", + keyword_color: "#ff0000", + font_size: 20 + } +]; + +const payload = { + draft_url: "https://ts.fyshark.com/#/cozeToJianyin?drafId=example123", + captions: JSON.stringify(captionsData), + text_color: "#ffffff", + alignment: 1, + alpha: 1.0, + font_size: 16 +}; + +fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(payload) +}) +.then(response => response.json()) +.then(data => console.log(data)); +``` + +## 注意事项 + +1. **时间单位**: 所有时间参数使用微秒为单位(1秒 = 1,000,000微秒) +2. **字幕时长**: end 时间必须大于 start 时间 +3. **颜色格式**: 颜色值使用十六进制格式,如 "#ffffff"、"#ff0000" +4. **关键词高亮**: 暂未完全实现,目前为预留功能 +5. **动画效果**: 暂未完全实现,目前为预留功能 +6. **字体支持**: 字体名称需要系统支持或使用默认字体 +7. **对齐方式**: 目前仅支持基础对齐方式(0-2),高级对齐方式(3-5)为预留功能 +8. **坐标系统**: transform_x 和 transform_y 使用像素值,会自动转换为草稿相对坐标 + +## 错误码说明 + +| 错误码 | 错误信息 | 说明 | +|--------|----------|------| +| 2001 | 无效的草稿URL | 草稿URL格式错误或草稿不存在 | +| 2018 | 无效的字幕信息 | captions字段格式错误或包含无效数据 | +| 2019 | 字幕添加失败 | 添加字幕过程中发生错误 | + +## 高级功能(待实现) + +1. **关键词高亮**: 支持对指定关键词设置不同颜色和字体大小 +2. **入场/出场动画**: 支持字幕的动画效果 +3. **循环动画**: 支持字幕的循环动画效果 +4. **样式文本**: 支持富文本格式的字幕样式 +5. **字体选择**: 支持自定义字体文件 +6. **边框和阴影**: 支持字幕边框和阴影效果 \ No newline at end of file diff --git a/exceptions.py b/exceptions.py index 262aeb1..d6afc47 100644 --- a/exceptions.py +++ b/exceptions.py @@ -29,6 +29,8 @@ class CustomError(Enum): SEGMENT_NOT_FOUND = (2015, "片段未找到,请检查segment_id是否正确", "Segment not found, please check if the segment_id is correct.") INVALID_SEGMENT_TYPE = (2016, "无效的片段类型,该片段不支持关键帧", "Invalid segment type, this segment does not support keyframes.") INVALID_KEYFRAME_PROPERTY = (2017, "无效的关键帧属性类型", "Invalid keyframe property type.") + INVALID_CAPTION_INFO = (2018, "无效的字幕信息,请检查captions字段值是否正确", "Invalid caption information, please check if the value of the captions field is correct.") + CAPTION_ADD_FAILED = (2019, "字幕添加失败", "Caption addition failed") # ===== 系统错误码 (9000-9999) ===== INTERNAL_SERVER_ERROR = (9998, "系统内部错误", "Internal server error") diff --git a/src/router/v1.py b/src/router/v1.py index 2153059..0f55958 100644 --- a/src/router/v1.py +++ b/src/router/v1.py @@ -5,6 +5,7 @@ from src.schemas.add_audios import AddAudiosResponse from src.schemas.add_images import AddImagesResponse from src.schemas.add_sticker import AddStickerResponse from src.schemas.add_keyframes import AddKeyframesResponse +from src.schemas.add_captions import AddCaptionsResponse from src.schemas.save_draft import SaveDraftResponse from src.schemas.create_draft import CreateDraftResponse from fastapi import APIRouter, Request, Depends @@ -14,6 +15,7 @@ from src.schemas.add_audios import AddAudiosRequest, AddAudiosResponse from src.schemas.add_images import AddImagesRequest, AddImagesResponse from src.schemas.add_sticker import AddStickerRequest, AddStickerResponse from src.schemas.add_keyframes import AddKeyframesRequest, AddKeyframesResponse +from src.schemas.add_captions import AddCaptionsRequest, AddCaptionsResponse from src.schemas.save_draft import SaveDraftRequest, SaveDraftResponse from src.schemas.gen_video import GenVideoRequest, GenVideoResponse from src.schemas.get_draft import GetDraftRequest, GetDraftResponse @@ -151,6 +153,38 @@ def add_keyframes(akr: AddKeyframesRequest) -> AddKeyframesResponse: affected_segments=affected_segments ) +@router.post(path="/add_captions", response_model=AddCaptionsResponse) +def add_captions(acr: AddCaptionsRequest) -> AddCaptionsResponse: + """ + 向剪映草稿批量添加字幕 (v1版本) + """ + + # 调用service层处理业务逻辑 + draft_url, track_id, text_ids, segment_ids = service.add_captions( + draft_url=acr.draft_url, + captions=acr.captions, + text_color=acr.text_color, + border_color=acr.border_color, + alignment=acr.alignment, + alpha=acr.alpha, + font=acr.font, + font_size=acr.font_size, + letter_spacing=acr.letter_spacing, + line_spacing=acr.line_spacing, + scale_x=acr.scale_x, + scale_y=acr.scale_y, + transform_x=acr.transform_x, + transform_y=acr.transform_y, + style_text=acr.style_text + ) + + return AddCaptionsResponse( + draft_url=draft_url, + track_id=track_id, + text_ids=text_ids, + segment_ids=segment_ids + ) + @router.get(path="/get_draft", response_model=GetDraftResponse) def get_draft(params: Annotated[GetDraftRequest, Depends()]) -> GetDraftResponse: """ diff --git a/src/schemas/add_captions.py b/src/schemas/add_captions.py new file mode 100644 index 0000000..f623764 --- /dev/null +++ b/src/schemas/add_captions.py @@ -0,0 +1,46 @@ +from pydantic import BaseModel, Field +from typing import List, Optional + + +class AddCaptionsRequest(BaseModel): + """批量添加字幕请求参数""" + draft_url: str = Field(default="", description="草稿URL") + captions: str = Field(default="", description="字幕信息列表, 用JSON字符串表示") + text_color: str = Field(default="#ffffff", description="文本颜色(十六进制)") + border_color: Optional[str] = Field(default=None, description="边框颜色(十六进制)") + alignment: int = Field(default=1, ge=0, le=5, description="文本对齐方式(0-5)") + alpha: float = Field(default=1.0, ge=0.0, le=1.0, description="文本透明度(0.0-1.0)") + font: Optional[str] = Field(default=None, description="字体名称") + font_size: int = Field(default=15, ge=1, description="字体大小") + letter_spacing: Optional[float] = Field(default=None, description="字间距") + line_spacing: Optional[float] = Field(default=None, description="行间距") + scale_x: float = Field(default=1.0, description="水平缩放") + scale_y: float = Field(default=1.0, description="垂直缩放") + transform_x: int = Field(default=0, description="水平位移") + transform_y: int = Field(default=0, description="垂直位移") + style_text: bool = Field(default=False, description="是否使用样式文本") + + +class CaptionItem(BaseModel): + """单个字幕信息""" + start: int = Field(..., description="字幕开始时间(微秒)") + end: int = Field(..., description="字幕结束时间(微秒)") + text: str = Field(..., description="字幕文本内容") + keyword: Optional[str] = Field(default=None, description="关键词(用|分隔多个关键词)") + keyword_color: str = Field(default="#ff7100", description="关键词颜色") + keyword_font_size: int = Field(default=15, ge=1, description="关键词字体大小") + font_size: int = Field(default=15, ge=1, description="文本字体大小") + in_animation: Optional[str] = Field(default=None, description="入场动画") + out_animation: Optional[str] = Field(default=None, description="出场动画") + loop_animation: Optional[str] = Field(default=None, description="循环动画") + in_animation_duration: Optional[int] = Field(default=None, description="入场动画时长") + out_animation_duration: Optional[int] = Field(default=None, description="出场动画时长") + loop_animation_duration: Optional[int] = Field(default=None, description="循环动画时长") + + +class AddCaptionsResponse(BaseModel): + """添加字幕响应参数""" + draft_url: str = Field(default="", description="草稿URL") + track_id: str = Field(default="", description="字幕轨道ID") + text_ids: List[str] = Field(default=[], description="字幕ID列表") + segment_ids: List[str] = Field(default=[], description="字幕片段ID列表") \ No newline at end of file diff --git a/src/service/__init__.py b/src/service/__init__.py index 784884e..8d347d7 100644 --- a/src/service/__init__.py +++ b/src/service/__init__.py @@ -4,8 +4,9 @@ from .add_audios import add_audios from .add_images import add_images from .add_sticker import add_sticker from .add_keyframes import add_keyframes +from .add_captions import add_captions from .save_draft import save_draft from .gen_video import gen_video from .get_draft import get_draft -__all__ = ["create_draft", "add_videos", "add_audios", "add_images", "add_sticker", "add_keyframes", "save_draft", "gen_video", "get_draft"] +__all__ = ["create_draft", "add_videos", "add_audios", "add_images", "add_sticker", "add_keyframes", "add_captions", "save_draft", "gen_video", "get_draft"] diff --git a/src/service/add_captions.py b/src/service/add_captions.py new file mode 100644 index 0000000..5255ab2 --- /dev/null +++ b/src/service/add_captions.py @@ -0,0 +1,389 @@ +import json +from typing import List, Dict, Any, Tuple, Optional, Literal + +from src.utils.logger import logger +from src.pyJianYingDraft import ScriptFile, TrackType, TextSegment, TextStyle, ClipSettings, Timerange +from src.utils.draft_cache import DRAFT_CACHE +from exceptions import CustomException, CustomError +from src.utils import helper + + +def add_captions( + draft_url: str, + captions: str, + text_color: str = "#ffffff", + border_color: Optional[str] = None, + alignment: int = 1, + alpha: float = 1.0, + font: Optional[str] = None, + font_size: int = 15, + letter_spacing: Optional[float] = None, + line_spacing: Optional[float] = None, + scale_x: float = 1.0, + scale_y: float = 1.0, + transform_x: int = 0, + transform_y: int = 0, + style_text: bool = False +) -> Tuple[str, str, List[str], List[str]]: + """ + 批量添加字幕到剪映草稿的业务逻辑 + + Args: + draft_url: 草稿URL + captions: 字幕信息列表的JSON字符串,格式如下: + [ + { + "start": 0, # 字幕开始时间(微秒) + "end": 10000000, # 字幕结束时间(微秒) + "text": "你好,剪映", # 字幕文本内容 + "keyword": "好", # 关键词(用|分隔多个关键词),可选参数 + "keyword_color": "#457616", # 关键词颜色,可选参数 + "keyword_font_size": 15, # 关键词字体大小,可选参数 + "font_size": 15, # 文本字体大小,可选参数 + "in_animation": None, # 入场动画,可选参数 + "out_animation": None, # 出场动画,可选参数 + "loop_animation": None, # 循环动画,可选参数 + "in_animation_duration": None, # 入场动画时长,可选参数 + "out_animation_duration": None, # 出场动画时长,可选参数 + "loop_animation_duration": None # 循环动画时长,可选参数 + } + ] + text_color: 文本颜色(十六进制),默认"#ffffff" + border_color: 边框颜色(十六进制),默认None + alignment: 文本对齐方式(0-5),默认1 + alpha: 文本透明度(0.0-1.0),默认1.0 + font: 字体名称,默认None + font_size: 字体大小,默认15 + letter_spacing: 字间距,默认None + line_spacing: 行间距,默认None + scale_x: 水平缩放,默认1.0 + scale_y: 垂直缩放,默认1.0 + transform_x: 水平位移,默认0 + transform_y: 垂直位移,默认0 + style_text: 是否使用样式文本,默认False + + Returns: + draft_url: 草稿URL + track_id: 字幕轨道ID + text_ids: 字幕ID列表 + segment_ids: 字幕片段ID列表 + + Raises: + CustomException: 字幕添加失败 + """ + logger.info(f"add_captions started, draft_url: {draft_url}, captions count: {len(json.loads(captions) if captions else [])}") + + # 1. 提取草稿ID + draft_id = helper.get_url_param(draft_url, "draft_id") + if (not draft_id) or (draft_id not in DRAFT_CACHE): + logger.error(f"Invalid draft_url or draft not found in cache: {draft_url}") + raise CustomException(CustomError.INVALID_DRAFT_URL) + + # 2. 解析字幕信息 + caption_items = parse_captions_data(json_str=captions) + if len(caption_items) == 0: + logger.info(f"No caption info provided, draft_id: {draft_id}") + raise CustomException(CustomError.INVALID_CAPTION_INFO) + + logger.info(f"Parsed {len(caption_items)} caption items") + + # 3. 从缓存中获取草稿 + script: ScriptFile = DRAFT_CACHE[draft_id] + + # 4. 添加字幕轨道 + track_name = f"caption_track_{helper.gen_unique_id()}" + script.add_track(track_type=TrackType.text, track_name=track_name) + logger.info(f"Added caption track: {track_name}") + + # 5. 遍历字幕信息,添加字幕到草稿中的指定轨道,收集片段ID + segment_ids = [] + text_ids = [] + for i, caption in enumerate(caption_items): + try: + logger.info(f"Processing caption {i+1}/{len(caption_items)}, text: {caption['text'][:20]}...") + + segment_id, text_id = add_caption_to_draft( + script, track_name, + caption=caption, + text_color=text_color, + border_color=border_color, + alignment=alignment, + alpha=alpha, + font=font, + font_size=font_size, + letter_spacing=letter_spacing, + line_spacing=line_spacing, + scale_x=scale_x, + scale_y=scale_y, + transform_x=transform_x, + transform_y=transform_y, + style_text=style_text + ) + segment_ids.append(segment_id) + text_ids.append(text_id) + logger.info(f"Added caption {i+1}/{len(caption_items)}, segment_id: {segment_id}") + except Exception as e: + logger.error(f"Failed to add caption {i+1}/{len(caption_items)}, error: {str(e)}") + raise + + # 6. 保存草稿 + script.save() + logger.info(f"Draft saved successfully") + + # 7. 获取当前字幕轨道ID + track_id = "" + for key in script.tracks.keys(): + if script.tracks[key].name == track_name: + track_id = script.tracks[key].track_id + break + logger.info(f"Caption track created, draft_id: {draft_id}, track_id: {track_id}") + + logger.info(f"add_captions completed successfully - draft_id: {draft_id}, track_id: {track_id}, captions_added: {len(caption_items)}") + + return draft_url, track_id, text_ids, segment_ids + + +def add_caption_to_draft( + script: ScriptFile, + track_name: str, + caption: dict, + text_color: str = "#ffffff", + border_color: Optional[str] = None, + alignment: int = 1, + alpha: float = 1.0, + font: Optional[str] = None, + font_size: int = 15, + letter_spacing: Optional[float] = None, + line_spacing: Optional[float] = None, + scale_x: float = 1.0, + scale_y: float = 1.0, + transform_x: int = 0, + transform_y: int = 0, + style_text: bool = False +) -> Tuple[str, str]: + """ + 向剪映草稿中添加单个字幕 + + Args: + script: 草稿文件对象 + track_name: 字幕轨道名称 + caption: 字幕信息字典,包含以下字段: + start: 字幕开始时间(微秒) + end: 字幕结束时间(微秒) + text: 字幕文本内容 + keyword: 关键词(用|分隔多个关键词),可选 + keyword_color: 关键词颜色,可选 + keyword_font_size: 关键词字体大小,可选 + font_size: 文本字体大小,可选 + in_animation: 入场动画,可选 + out_animation: 出场动画,可选 + loop_animation: 循环动画,可选 + in_animation_duration: 入场动画时长,可选 + out_animation_duration: 出场动画时长,可选 + loop_animation_duration: 循环动画时长,可选 + 其他参数:字幕样式设置 + + Returns: + segment_id: 片段ID + text_id: 文本ID(material_id) + + Raises: + CustomException: 添加字幕失败 + """ + try: + # 1. 创建时间范围 + caption_duration = caption['end'] - caption['start'] + timerange = Timerange(start=caption['start'], duration=caption_duration) + + # 2. 解析颜色 + rgb_color = hex_to_rgb(text_color) + + # 3. 创建文本样式 + align_value: Literal[0, 1, 2] = 0 + if alignment == 1: + align_value = 1 + elif alignment == 2: + align_value = 2 + + text_style = TextStyle( + size=float(caption.get('font_size', font_size)), + color=rgb_color, + alpha=alpha, + align=align_value, + letter_spacing=int(letter_spacing) if letter_spacing is not None else 0, + line_spacing=int(line_spacing) if line_spacing is not None else 0, + auto_wrapping=True # 字幕默认开启自动换行 + ) + + # 4. 创建图像调节设置 + clip_settings = ClipSettings( + scale_x=scale_x, + scale_y=scale_y, + transform_x=float(transform_x) / script.width * 2, # 转换为半画布宽度单位 + transform_y=float(transform_y) / script.height * 2 # 转换为半画布高度单位 + ) + + # 5. 创建文本片段 + text_segment = TextSegment( + text=caption['text'], + timerange=timerange, + style=text_style, + clip_settings=clip_settings + ) + + logger.info(f"Created text segment, material_id: {text_segment.material_id}") + logger.info(f"Text segment details - start: {caption['start']}, duration: {caption_duration}, text: {caption['text'][:50]}") + + # 6. TODO: 处理关键词高亮(这需要更复杂的实现) + if caption.get('keyword'): + logger.info(f"Keyword highlighting specified but not implemented yet: {caption['keyword']}") + + # 7. TODO: 处理动画效果(需要导入相应的动画类型) + if caption.get('in_animation'): + logger.info(f"In animation specified but not implemented yet: {caption['in_animation']}") + if caption.get('out_animation'): + logger.info(f"Out animation specified but not implemented yet: {caption['out_animation']}") + if caption.get('loop_animation'): + logger.info(f"Loop animation specified but not implemented yet: {caption['loop_animation']}") + + # 8. 向指定轨道添加片段 + script.add_segment(text_segment, track_name) + + return text_segment.segment_id, text_segment.material_id + + except CustomException: + logger.error(f"Add caption to draft failed, caption: {caption}") + raise + except Exception as e: + logger.error(f"Add caption to draft failed, error: {str(e)}") + raise CustomException(CustomError.CAPTION_ADD_FAILED) + + +def parse_captions_data(json_str: str) -> List[Dict[str, Any]]: + """ + 解析字幕数据的JSON字符串,处理可选字段的默认值 + + Args: + json_str: 包含字幕数据的JSON字符串,格式如下: + [ + { + "start": 0, # [必选] 字幕开始时间(微秒) + "end": 10000000, # [必选] 字幕结束时间(微秒) + "text": "你好,剪映", # [必选] 字幕文本内容 + "keyword": "好", # [可选] 关键词(用|分隔多个关键词) + "keyword_color": "#457616", # [可选] 关键词颜色,默认"#ff7100" + "keyword_font_size": 15, # [可选] 关键词字体大小,默认15 + "font_size": 15, # [可选] 文本字体大小,默认15 + "in_animation": None, # [可选] 入场动画,默认None + "out_animation": None, # [可选] 出场动画,默认None + "loop_animation": None, # [可选] 循环动画,默认None + "in_animation_duration": None, # [可选] 入场动画时长,默认None + "out_animation_duration": None, # [可选] 出场动画时长,默认None + "loop_animation_duration": None # [可选] 循环动画时长,默认None + } + ] + + Returns: + 包含字幕对象的数组,每个对象都处理了默认值 + + Raises: + CustomException: 当JSON格式错误或缺少必选字段时抛出 + """ + try: + # 解析JSON字符串 + data = json.loads(json_str) + except json.JSONDecodeError as e: + logger.error(f"JSON parse error: {e.msg}") + raise CustomException(CustomError.INVALID_CAPTION_INFO, f"JSON parse error: {e.msg}") + + # 确保输入是列表 + if not isinstance(data, list): + logger.error("captions should be a list") + raise CustomException(CustomError.INVALID_CAPTION_INFO, "captions should be a list") + + result = [] + + for i, item in enumerate(data): + if not isinstance(item, dict): + logger.error(f"the {i}th item should be a dict") + raise CustomException(CustomError.INVALID_CAPTION_INFO, f"the {i}th item should be a dict") + + # 检查必选字段 + required_fields = ["start", "end", "text"] + missing_fields = [field for field in required_fields if field not in item] + + if missing_fields: + logger.error(f"the {i}th item is missing required fields: {', '.join(missing_fields)}") + raise CustomException(CustomError.INVALID_CAPTION_INFO, f"the {i}th item is missing required fields: {', '.join(missing_fields)}") + + # 创建处理后的对象,设置默认值 + processed_item = { + "start": item["start"], + "end": item["end"], + "text": item["text"], + "keyword": item.get("keyword", None), + "keyword_color": item.get("keyword_color", "#ff7100"), + "keyword_font_size": item.get("keyword_font_size", 15), + "font_size": item.get("font_size", 15), + "in_animation": item.get("in_animation", None), + "out_animation": item.get("out_animation", None), + "loop_animation": item.get("loop_animation", None), + "in_animation_duration": item.get("in_animation_duration", None), + "out_animation_duration": item.get("out_animation_duration", None), + "loop_animation_duration": item.get("loop_animation_duration", None) + } + + # 验证数值类型和范围 + 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_CAPTION_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_CAPTION_INFO, f"the {i}th item has invalid end time") + + if not isinstance(processed_item["text"], str) or len(processed_item["text"].strip()) == 0: + logger.error(f"the {i}th item has invalid text: {processed_item['text']}") + raise CustomException(CustomError.INVALID_CAPTION_INFO, f"the {i}th item has invalid text") + + # 验证字体大小 + if not isinstance(processed_item["font_size"], (int, float)) or processed_item["font_size"] <= 0: + processed_item["font_size"] = 15 + if not isinstance(processed_item["keyword_font_size"], (int, float)) or processed_item["keyword_font_size"] <= 0: + processed_item["keyword_font_size"] = 15 + + result.append(processed_item) + + logger.info(f"Successfully parsed {len(result)} caption items") + return result + + +def hex_to_rgb(hex_color: str) -> tuple: + """ + 将十六进制颜色值转换为RGB三元组(0-1范围) + + Args: + hex_color: 十六进制颜色值,如"#ffffff"或"ffffff" + + Returns: + RGB三元组,取值范围为[0, 1] + """ + # 移除#号(如果存在) + hex_color = hex_color.lstrip('#') + + # 确保是6位十六进制 + if len(hex_color) != 6: + logger.warning(f"Invalid hex color format: {hex_color}, using white as default") + return (1.0, 1.0, 1.0) + + try: + # 转换为RGB值(0-255) + r = int(hex_color[0:2], 16) + g = int(hex_color[2:4], 16) + b = int(hex_color[4:6], 16) + + # 转换为0-1范围 + return (r / 255.0, g / 255.0, b / 255.0) + except ValueError: + logger.warning(f"Invalid hex color format: {hex_color}, using white as default") + return (1.0, 1.0, 1.0) \ No newline at end of file