From bd00c9564a40c77f9d628b6f913c17790dc8965b Mon Sep 17 00:00:00 2001 From: Hommy <16620803786@163.com> Date: Wed, 24 Sep 2025 17:33:40 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E5=8A=A0=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E9=81=AE=E7=BD=A9=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_masks.md | 154 ++++++++++++++++++++++++ exceptions.py | 3 + src/router/v1.py | 30 +++++ src/schemas/add_masks.py | 25 ++++ src/service/__init__.py | 3 +- src/service/add_masks.py | 252 +++++++++++++++++++++++++++++++++++++++ 6 files changed, 466 insertions(+), 1 deletion(-) create mode 100644 docs/add_masks.md create mode 100644 src/schemas/add_masks.py create mode 100644 src/service/add_masks.py diff --git a/docs/add_masks.md b/docs/add_masks.md new file mode 100644 index 0000000..bf56b20 --- /dev/null +++ b/docs/add_masks.md @@ -0,0 +1,154 @@ +# 添加遮罩接口文档 + +## 接口概述 +向现有草稿中的指定片段添加遮罩效果。遮罩是视频编辑中的重要功能,通过遮罩可以控制图像的可见区域,创造出各种视觉效果。支持多种遮罩类型(线性、镜面、圆形、矩形、爱心、星形),每种遮罩都可以精确配置位置、大小、羽化、旋转等属性。 + +## 接口信息 +- **请求方式**: POST +- **接口路径**: `/v1/add_masks` +- **Content-Type**: `application/json` + +## 请求参数 + +### 请求体 (Body) + +| 参数名 | 类型 | 必填 | 默认值 | 描述 | +|--------|------|------|--------|------| +| draft_url | string | 是 | "" | 目标草稿的完整URL | +| segment_ids | array | 是 | [] | 要应用遮罩的片段ID数组 | +| name | string | 否 | "线性" | 遮罩类型名称 | +| X | integer | 否 | 0 | 遮罩中心X坐标(像素) | +| Y | integer | 否 | 0 | 遮罩中心Y坐标(像素) | +| width | integer | 否 | 512 | 遮罩宽度(像素) | +| height | integer | 否 | 512 | 遮罩高度(像素) | +| feather | integer | 否 | 0 | 羽化程度(0-100) | +| rotation | integer | 否 | 0 | 旋转角度(度) | +| invert | boolean | 否 | false | 是否反转遮罩 | +| roundCorner | integer | 否 | 0 | 圆角半径(0-100) | + +### 支持的遮罩类型 + +| 遮罩名称 | 描述 | 适用场景 | +|----------|------|----------| +| 线性 | 线性渐变遮罩 | 线性过渡效果、渐变显示隐藏 | +| 镜面 | 镜像对称遮罩 | 对称效果、镜像反射 | +| 圆形 | 圆形遮罩 | 聚光灯效果、圆形裁剪 | +| 矩形 | 矩形遮罩 | 窗口效果、矩形裁剪 | +| 爱心 | 爱心形状遮罩 | 浪漫场景、特殊形状裁剪 | +| 星形 | 星形遮罩 | 闪光效果、装饰性裁剪 | + +### 遮罩配置详解 + +- **位置控制**: X、Y参数控制遮罩在画面中的位置 +- **尺寸控制**: width、height控制遮罩的大小 +- **羽化效果**: feather参数控制边缘的柔化程度,值越大边缘越柔和 +- **旋转变换**: rotation参数控制遮罩的旋转角度 +- **反转效果**: invert参数可以反转遮罩的显示/隐藏区域 +- **圆角处理**: roundCorner参数为矩形遮罩添加圆角效果 + +## 请求示例 + +```bash +curl -X POST "http://localhost:8000/v1/add_masks" \ +-H "Content-Type: application/json" \ +-d '{ + "draft_url": "https://ts.fyshark.com/#/cozeToJianyin?drafId=7427078525303048221", + "segment_ids": ["d62994b4-25fe-422a-a123-87ef05038558", "e73995c5-36ef-533b-b234-98fg16149669"], + "name": "圆形", + "X": 100, + "Y": 200, + "width": 300, + "height": 300, + "feather": 20, + "rotation": 0, + "invert": false, + "roundCorner": 0 +}' +``` + +## 响应格式 + +### 成功响应 + +```json +{ + "code": 0, + "message": "Success", + "data": { + "draft_url": "https://ts.fyshark.com/#/cozeToJianyin?drafId=7427078525303048221", + "masks_added": 2, + "affected_segments": ["d62994b4-25fe-422a-a123-87ef05038558", "e73995c5-36ef-533b-b234-98fg16149669"], + "mask_ids": ["mask_001", "mask_002"] + } +} +``` + +**响应字段说明**: +- `draft_url`: 草稿URL +- `masks_added`: 成功添加的遮罩数量 +- `affected_segments`: 受影响的片段ID列表 +- `mask_ids`: 遮罩ID列表 + +### 错误响应 + +```json +{ + "code": 2023, + "message": "无效的遮罩信息,请检查遮罩参数是否正确" +} +``` + +## 错误码说明 + +| 错误码 | 错误信息 | 描述 | +|--------|----------|------| +| 2001 | 无效的草稿URL | 草稿URL格式错误或草稿不存在 | +| 2015 | 片段未找到 | 指定的segment_id不存在 | +| 2016 | 无效的片段类型 | 片段类型不支持添加遮罩 | +| 2023 | 无效的遮罩信息 | 遮罩参数格式错误或值无效 | +| 2024 | 遮罩添加失败 | 添加遮罩过程中发生错误 | +| 2025 | 遮罩类型未找到 | 指定的遮罩名称不存在 | + +## 使用说明 + +1. **片段要求**: 只有视频片段(VideoSegment)支持添加遮罩 +2. **遮罩限制**: 每个片段只能添加一个遮罩,重复添加会失败 +3. **坐标系统**: X、Y坐标以像素为单位,原点位于素材中心 +4. **参数范围**: + - feather: 0-100,羽化程度 + - rotation: 0-360度,旋转角度 + - roundCorner: 0-100,圆角半径(仅矩形遮罩有效) +5. **批量处理**: 支持同时为多个片段添加相同配置的遮罩 + +## 应用场景 + +### 创意视觉效果 +- **聚光灯效果**: 使用圆形遮罩突出画面重点 +- **窗口效果**: 使用矩形遮罩创建窗口视觉 +- **浪漫场景**: 使用爱心遮罩营造浪漫氛围 +- **装饰效果**: 使用星形遮罩增加装饰元素 + +### 画面过渡 +- **渐变显示**: 使用线性遮罩实现渐进显示效果 +- **对称效果**: 使用镜面遮罩创建对称视觉 +- **柔和边缘**: 通过羽化参数实现柔和的边缘过渡 + +### 内容遮挡 +- **隐私保护**: 遮挡敏感信息区域 +- **焦点引导**: 突出重要内容,弱化无关区域 +- **艺术创作**: 创造独特的视觉艺术效果 + +## 注意事项 + +- 遮罩效果只对视频片段有效,不支持音频、文本或贴纸片段 +- 添加遮罩后会影响片段的视觉呈现,请确保遮罩配置符合预期 +- 遮罩参数的单位和坐标系以素材自身为基准 +- 建议在添加遮罩前预览效果,确保达到预期的视觉效果 +- 遮罩的渲染性能可能影响导出速度,请根据需要合理使用 + +## 相关接口 + +- [创建草稿](./create_draft.md) - 创建新的剪映草稿 +- [添加视频](./add_videos.md) - 向草稿添加视频内容 +- [添加特效](./add_effects.md) - 向草稿添加视频特效 +- [保存草稿](./save_draft.md) - 保存草稿更改 \ No newline at end of file diff --git a/exceptions.py b/exceptions.py index be68411..622192a 100644 --- a/exceptions.py +++ b/exceptions.py @@ -34,6 +34,9 @@ class CustomError(Enum): INVALID_EFFECT_INFO = (2020, "无效的特效信息,请检查effect_infos字段值是否正确", "Invalid effect information, please check if the value of the effect_infos field is correct.") EFFECT_ADD_FAILED = (2021, "特效添加失败", "Effect addition failed") EFFECT_NOT_FOUND = (2022, "特效未找到,请检查特效名称是否正确", "Effect not found, please check if the effect name is correct.") + INVALID_MASK_INFO = (2023, "无效的遮罩信息,请检查遮罩参数是否正确", "Invalid mask information, please check if mask parameters are correct.") + MASK_ADD_FAILED = (2024, "遮罩添加失败", "Mask addition failed") + MASK_NOT_FOUND = (2025, "遮罩类型未找到,请检查遮罩名称是否正确", "Mask type not found, please check if the mask name is correct.") # ===== 系统错误码 (9000-9999) ===== INTERNAL_SERVER_ERROR = (9998, "系统内部错误", "Internal server error") diff --git a/src/router/v1.py b/src/router/v1.py index c0e8432..84b7709 100644 --- a/src/router/v1.py +++ b/src/router/v1.py @@ -7,6 +7,7 @@ from src.schemas.add_sticker import AddStickerResponse from src.schemas.add_keyframes import AddKeyframesResponse from src.schemas.add_captions import AddCaptionsResponse from src.schemas.add_effects import AddEffectsResponse +from src.schemas.add_masks import AddMasksResponse from src.schemas.save_draft import SaveDraftResponse from src.schemas.create_draft import CreateDraftResponse from fastapi import APIRouter, Request, Depends @@ -18,6 +19,7 @@ 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.add_effects import AddEffectsRequest, AddEffectsResponse +from src.schemas.add_masks import AddMasksRequest, AddMasksResponse from src.schemas.save_draft import SaveDraftRequest, SaveDraftResponse from src.schemas.gen_video import GenVideoRequest, GenVideoResponse from src.schemas.get_draft import GetDraftRequest, GetDraftResponse @@ -206,6 +208,34 @@ def add_effects(aer: AddEffectsRequest) -> AddEffectsResponse: segment_ids=segment_ids ) +@router.post(path="/add_masks", response_model=AddMasksResponse) +def add_masks(amr: AddMasksRequest) -> AddMasksResponse: + """ + 向剪映草稿添加遮罩 (v1版本) + """ + + # 调用service层处理业务逻辑 + draft_url, masks_added, affected_segments, mask_ids = service.add_masks( + draft_url=amr.draft_url, + segment_ids=amr.segment_ids, + name=amr.name, + X=amr.X, + Y=amr.Y, + width=amr.width, + height=amr.height, + feather=amr.feather, + rotation=amr.rotation, + invert=amr.invert, + roundCorner=amr.roundCorner + ) + + return AddMasksResponse( + draft_url=draft_url, + masks_added=masks_added, + affected_segments=affected_segments, + mask_ids=mask_ids + ) + @router.get(path="/get_draft", response_model=GetDraftResponse) def get_draft(params: Annotated[GetDraftRequest, Depends()]) -> GetDraftResponse: """ diff --git a/src/schemas/add_masks.py b/src/schemas/add_masks.py new file mode 100644 index 0000000..a02ea83 --- /dev/null +++ b/src/schemas/add_masks.py @@ -0,0 +1,25 @@ +from pydantic import BaseModel, Field +from typing import List + + +class AddMasksRequest(BaseModel): + """添加遮罩请求参数""" + draft_url: str = Field(default="", description="草稿URL") + segment_ids: List[str] = Field(default=[], description="要应用遮罩的片段ID数组") + name: str = Field(default="线性", description="遮罩类型名称") + X: int = Field(default=0, description="遮罩中心X坐标(像素)") + Y: int = Field(default=0, description="遮罩中心Y坐标(像素)") + width: int = Field(default=512, description="遮罩宽度(像素)") + height: int = Field(default=512, description="遮罩高度(像素)") + feather: int = Field(default=0, description="羽化程度(0-100)") + rotation: int = Field(default=0, description="旋转角度(度)") + invert: bool = Field(default=False, description="是否反转遮罩") + roundCorner: int = Field(default=0, description="圆角半径(0-100)") + + +class AddMasksResponse(BaseModel): + """添加遮罩响应参数""" + draft_url: str = Field(default="", description="草稿URL") + masks_added: int = Field(default=0, description="成功添加的遮罩数量") + affected_segments: List[str] = Field(default=[], description="受影响的片段ID列表") + mask_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 52aedf3..727415b 100644 --- a/src/service/__init__.py +++ b/src/service/__init__.py @@ -6,8 +6,9 @@ from .add_sticker import add_sticker from .add_keyframes import add_keyframes from .add_captions import add_captions from .add_effects import add_effects +from .add_masks import add_masks 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", "add_captions", "add_effects", "save_draft", "gen_video", "get_draft"] +__all__ = ["create_draft", "add_videos", "add_audios", "add_images", "add_sticker", "add_keyframes", "add_captions", "add_effects", "add_masks", "save_draft", "gen_video", "get_draft"] diff --git a/src/service/add_masks.py b/src/service/add_masks.py new file mode 100644 index 0000000..8bd9356 --- /dev/null +++ b/src/service/add_masks.py @@ -0,0 +1,252 @@ +from typing import List, Dict, Any, Tuple, Optional +from src.utils.logger import logger +from src.pyJianYingDraft import ScriptFile, MaskType +from src.pyJianYingDraft.video_segment import VideoSegment +from src.pyJianYingDraft.segment import VisualSegment +from src.utils.draft_cache import DRAFT_CACHE +from exceptions import CustomException, CustomError +from src.utils import helper + + +def add_masks( + draft_url: str, + segment_ids: List[str], + name: str = "线性", + X: int = 0, + Y: int = 0, + width: int = 512, + height: int = 512, + feather: int = 0, + rotation: int = 0, + invert: bool = False, + roundCorner: int = 0 +) -> Tuple[str, int, List[str], List[str]]: + """ + 向现有草稿中的指定片段添加遮罩效果的业务逻辑 + + Args: + draft_url: 草稿URL,必选参数 + segment_ids: 要应用遮罩的片段ID数组,必选参数 + name: 遮罩类型名称,默认值:"线性"。支持:"线性", "镜面", "圆形", "矩形", "爱心", "星形" + X: 遮罩中心X坐标(像素),默认值:0 + Y: 遮罩中心Y坐标(像素),默认值:0 + width: 遮罩宽度(像素),默认值:512 + height: 遮罩高度(像素),默认值:512 + feather: 羽化程度(0-100),默认值:0 + rotation: 旋转角度(度),默认值:0 + invert: 是否反转遮罩,默认值:false + roundCorner: 圆角半径(0-100),默认值:0 + + Returns: + Tuple[str, int, List[str], List[str]]: 返回元组包含以下信息 + - draft_url: 草稿URL + - masks_added: 成功添加的遮罩数量 + - affected_segments: 受影响的片段ID列表 + - mask_ids: 遮罩ID列表 + + Raises: + CustomException: 遮罩添加失败 + """ + logger.info(f"add_masks started, draft_url: {draft_url}, segment_ids: {segment_ids}, mask_name: {name}") + + # 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. 验证片段ID列表 + if not segment_ids or len(segment_ids) == 0: + logger.error("No segment_ids provided") + raise CustomException(CustomError.INVALID_MASK_INFO) + + logger.info(f"Processing {len(segment_ids)} segments for mask addition") + + # 3. 从缓存中获取草稿 + script: ScriptFile = DRAFT_CACHE[draft_id] + + # 4. 查找遮罩类型 + mask_type = find_mask_type_by_name(name) + if mask_type is None: + logger.error(f"Mask type not found for name: {name}") + raise CustomException(CustomError.MASK_NOT_FOUND) + + # 5. 遍历片段ID,为每个片段添加遮罩 + masks_added = 0 + affected_segments: List[str] = [] + mask_ids: List[str] = [] + + for i, segment_id in enumerate(segment_ids): + try: + logger.info(f"Processing segment {i+1}/{len(segment_ids)}, segment_id: {segment_id}") + + mask_id = add_mask_to_segment( + script=script, + segment_id=segment_id, + mask_type=mask_type, + center_x=X, + center_y=Y, + width=width, + height=height, + feather=feather, + rotation=rotation, + invert=invert, + round_corner=roundCorner + ) + + masks_added += 1 + affected_segments.append(segment_id) + mask_ids.append(mask_id) + logger.info(f"Added mask to segment {i+1}/{len(segment_ids)}, mask_id: {mask_id}") + + except Exception as e: + logger.error(f"Failed to add mask to segment {i+1}/{len(segment_ids)}, segment_id: {segment_id}, error: {str(e)}") + raise + + # 6. 保存草稿 + script.save() + logger.info(f"Draft saved successfully") + + logger.info(f"add_masks completed successfully - draft_id: {draft_id}, masks_added: {masks_added}") + + return draft_url, masks_added, affected_segments, mask_ids + + +def add_mask_to_segment( + script: ScriptFile, + segment_id: str, + mask_type: MaskType, + center_x: int = 0, + center_y: int = 0, + width: int = 512, + height: int = 512, + feather: int = 0, + rotation: int = 0, + invert: bool = False, + round_corner: int = 0 +) -> str: + """ + 向指定片段添加遮罩 + + Args: + script: 草稿文件对象 + segment_id: 目标片段ID + mask_type: 遮罩类型 + center_x: 遮罩中心X坐标(像素) + center_y: 遮罩中心Y坐标(像素) + width: 遮罩宽度(像素) + height: 遮罩高度(像素) + feather: 羽化程度(0-100) + rotation: 旋转角度(度) + invert: 是否反转遮罩 + round_corner: 圆角半径(0-100) + + Returns: + mask_id: 遮罩ID + + Raises: + CustomException: 添加遮罩失败 + """ + try: + # 1. 查找片段 + segment = find_segment_by_id(script, segment_id) + if segment is None: + logger.error(f"Segment not found: {segment_id}") + raise CustomException(CustomError.SEGMENT_NOT_FOUND) + + # 2. 验证片段类型(只有VideoSegment支持遮罩) + if not isinstance(segment, VideoSegment): + logger.error(f"Segment {segment_id} is not a video segment, cannot add mask") + raise CustomException(CustomError.INVALID_SEGMENT_TYPE) + + # 3. 检查片段是否已有遮罩 + if segment.mask is not None: + logger.error(f"Segment {segment_id} already has a mask") + raise CustomException(CustomError.MASK_ADD_FAILED) + + # 4. 计算遮罩尺寸参数 + # 根据 add_mask 方法的要求,size 是主要尺寸(以占素材高度的比例表示) + material_width, material_height = segment.material_size + size = height / material_height # 高度比例 + rect_width = width / material_width if mask_type == MaskType.矩形 else None + + logger.info(f"Adding mask to segment {segment_id}: type={mask_type.value.name}, center=({center_x}, {center_y}), size={size}") + logger.info(f"Mask details - width: {width}, height: {height}, feather: {feather}, rotation: {rotation}, invert: {invert}, round_corner: {round_corner}") + + # 5. 添加遮罩到片段 + segment.add_mask( + mask_type=mask_type, + center_x=float(center_x), + center_y=float(center_y), + size=size, + rotation=float(rotation), + feather=float(feather), + invert=invert, + rect_width=rect_width, + round_corner=float(round_corner) + ) + + mask_id = segment.mask.global_id if segment.mask is not None else "" + if not mask_id: + logger.error(f"Failed to create mask for segment {segment_id}") + raise CustomException(CustomError.MASK_ADD_FAILED) + + logger.info(f"Successfully added mask to segment {segment_id}, mask_id: {mask_id}") + + return mask_id + + except CustomException: + logger.error(f"Add mask to segment failed, segment_id: {segment_id}") + raise + except Exception as e: + logger.error(f"Add mask to segment failed, error: {str(e)}") + raise CustomException(CustomError.MASK_ADD_FAILED) + + +def find_segment_by_id(script: ScriptFile, segment_id: str) -> Optional[VisualSegment]: + """ + 通过segment_id在草稿中查找对应的片段 + + Args: + script: 草稿文件对象 + segment_id: 片段ID + + Returns: + 找到的片段对象,如果未找到则返回None + """ + logger.info(f"Searching for segment with id: {segment_id}") + + # 遍历所有轨道 + for track_name, track in script.tracks.items(): + logger.info(f"Searching in track: {track_name}, segments count: {len(track.segments)}") + + # 遍历轨道中的所有片段 + for segment in track.segments: + if segment.segment_id == segment_id: + logger.info(f"Found segment {segment_id} in track {track_name}") + return segment + + logger.warning(f"Segment {segment_id} not found in any track") + return None + + +def find_mask_type_by_name(mask_name: str) -> Optional[MaskType]: + """ + 根据遮罩名称查找对应的遮罩类型 + + Args: + mask_name: 遮罩名称 + + Returns: + 对应的遮罩类型枚举,如果未找到则返回None + """ + logger.info(f"Searching for mask type with name: {mask_name}") + + # 搜索MaskType中的遮罩 + for mask_type in MaskType: + if mask_type.value.name == mask_name: + logger.info(f"Found mask type: {mask_name}") + return mask_type + + logger.warning(f"Mask type not found for name: {mask_name}") + return None \ No newline at end of file