diff --git a/docs/add_captions.md b/docs/add_captions.md index 8d3d98e..da6b652 100644 --- a/docs/add_captions.md +++ b/docs/add_captions.md @@ -27,7 +27,7 @@ Batch-add captions to an existing CapCut/Jianying draft. Supports text color, bo | captions | string | ✅ | - | Caption list as a **JSON string** (not a raw JSON array) | | text_color | string | ❌ | `"#ffffff"` | Normal text color (hex) | | border_color | string | ❌ | `null` | Normal text stroke color (hex); `null` means no stroke | -| alignment | integer | ❌ | `1` | Alignment: `0` left, `1` center, `2` right (`3`-`5` reserved) | +| alignment | integer | ❌ | `1` | Alignment: `0` left, `1` center, `2` right (horizontal); `3` vertical center, `4` vertical left, `5` vertical right (vertical typesetting) | | alpha | number | ❌ | `1.0` | Opacity in `[0.0, 1.0]` | | font | string | ❌ | `null` | Font name from Supported Fonts below (enum/display/alias also ok); `null` uses default | | font_size | integer | ❌ | `15` | Default font size when a caption item omits `font_size`; must be `>= 1` | @@ -91,6 +91,19 @@ Default shadow when enabled without `*_shadow_info`: } ``` +### Notes on alignment + +| Value | Meaning | +|---|---| +| 0 | Left (horizontal) | +| 1 | Center (horizontal) | +| 2 | Right (horizontal) | +| 3 | Vertical center (vertical typesetting + center) | +| 4 | Vertical left (vertical typesetting + left) | +| 5 | Vertical right (vertical typesetting + right) | + +`alignment` controls in-box typesetting and line alignment, not canvas position. Use `transform_x` / `transform_y` to move captions on screen. Values `3`/`4`/`5` set the draft material `typesetting` to vertical. + ### Notes on text_effect vs shadow If `text_effect` resolves to a valid effect, the API resets `text_color` to `#ffffff`, `border_color` to `null`, `has_shadow` to `false`, and disables keyword shadow. Omit/leave `text_effect` null when you need custom colors or shadows. @@ -1314,7 +1327,7 @@ VHS "text_color": "#ffffff", // [Optional] normal text color "border_color": "#333333", // [Optional] normal stroke color - "alignment": 1, // [Optional] 0 left / 1 center / 2 right + "alignment": 1, // [Optional] 0 left / 1 center / 2 right (horizontal); 3/4/5 vertical center/left/right "alpha": 1.0, // [Optional] opacity [0,1] "font": "得意黑", // [Optional] font name "font_size": 15, // [Optional] top-level default font size diff --git a/docs/add_captions.zh.md b/docs/add_captions.zh.md index 04daa73..3f3fa92 100644 --- a/docs/add_captions.zh.md +++ b/docs/add_captions.zh.md @@ -27,7 +27,7 @@ POST /openapi/capcut-mate/v1/add_captions | captions | string | ✅ | - | 字幕信息列表的 **JSON 字符串**(不是 JSON 数组本身) | | text_color | string | ❌ | `"#ffffff"` | 普通字幕文本颜色,十六进制,如 `#ffffff` | | border_color | string | ❌ | `null` | 普通字幕描边颜色,十六进制;`null` 表示无描边 | -| alignment | integer | ❌ | `1` | 文本对齐方式:`0` 左对齐,`1` 居中,`2` 右对齐(`3`-`5` 为预留) | +| alignment | integer | ❌ | `1` | 文本对齐方式:`0` 左对齐,`1` 居中,`2` 右对齐(横排);`3` 垂直居中,`4` 垂直左对齐,`5` 垂直右对齐(竖排) | | alpha | number | ❌ | `1.0` | 文本透明度,取值范围 `[0.0, 1.0]`,`1.0` 为不透明 | | font | string | ❌ | `null` | 字体名称,须为下方「支持的字体」中的展示名(也支持枚举名/别名);`null` 使用默认字体 | | font_size | integer | ❌ | `15` | 接口级默认字号;当 caption 项未指定 `font_size` 时生效,须 `>= 1` | @@ -97,12 +97,14 @@ POST /openapi/capcut-mate/v1/add_captions | 值 | 说明 | |---|------| -| 0 | 左对齐 | -| 1 | 居中对齐 | -| 2 | 右对齐 | -| 3 | 垂直居中(预留) | -| 4 | 垂直左对齐(预留) | -| 5 | 垂直右对齐(预留) | +| 0 | 左对齐(横排) | +| 1 | 居中对齐(横排) | +| 2 | 右对齐(横排) | +| 3 | 垂直居中(竖排 + 居中) | +| 4 | 垂直左对齐(竖排 + 左对齐) | +| 5 | 垂直右对齐(竖排 + 右对齐) | + +`alignment` 控制文本框内的排版方向与行对齐,不会改变字幕在画布上的位置。画面位置请使用 `transform_x` / `transform_y`。`3`/`4`/`5` 会将草稿素材的 `typesetting` 设为竖排。 #### 花字与阴影的关系 @@ -1327,7 +1329,7 @@ VHS "text_color": "#ffffff", // 【可选】普通文本颜色 "border_color": "#333333", // 【可选】普通文本描边颜色 - "alignment": 1, // 【可选】对齐:0左/1中/2右 + "alignment": 1, // 【可选】对齐:0左/1中/2右(横排),3垂直居中/4垂直左/5垂直右(竖排) "alpha": 1.0, // 【可选】透明度 [0,1] "font": "得意黑", // 【可选】字体名称 "font_size": 15, // 【可选】接口级默认字号 diff --git a/openapi.yaml b/openapi.yaml index 4bae1d7..40efe42 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -12183,7 +12183,7 @@ paths: properties: alignment: default: 1 - description: 文本对齐方式(0-5) + description: 文本对齐方式:0左/1中/2右(横排),3垂直居中/4垂直左对齐/5垂直右对齐(竖排) type: integer alpha: default: 1 diff --git a/src/schemas/add_captions.py b/src/schemas/add_captions.py index ade98a3..a3e9f0d 100644 --- a/src/schemas/add_captions.py +++ b/src/schemas/add_captions.py @@ -17,7 +17,12 @@ class AddCaptionsRequest(BaseModel): 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)") + alignment: int = Field( + default=1, + ge=0, + le=5, + description="文本对齐方式:0左/1中/2右(横排),3垂直居中/4垂直左对齐/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="字体大小") diff --git a/src/service/add_captions.py b/src/service/add_captions.py index f87dc9e..404d3d1 100644 --- a/src/service/add_captions.py +++ b/src/service/add_captions.py @@ -104,6 +104,28 @@ def resolve_font_type(font_name: str) -> Optional[FontType]: return None +# alignment → (草稿 alignment 0/1/2, 是否竖排) +# 0/1/2 为横排左/中/右;3/4/5 为竖排居中/左/右(对齐值分别对应 1/0/2) +CAPTION_ALIGNMENT_MAP: Dict[int, Tuple[Literal[0, 1, 2], bool]] = { + 0: (0, False), + 1: (1, False), + 2: (2, False), + 3: (1, True), + 4: (0, True), + 5: (2, True), +} + + +def resolve_caption_alignment(alignment: int) -> Tuple[Literal[0, 1, 2], bool]: + """将接口 alignment 映射为草稿字段 (align, vertical)。 + + 0 左对齐、1 居中、2 右对齐(横排); + 3 垂直居中、4 垂直左对齐、5 垂直右对齐(竖排)。 + 未识别的值回退为横排左对齐,与历史实现一致。 + """ + return CAPTION_ALIGNMENT_MAP.get(alignment, (0, False)) + + def add_captions( draft_url: str, captions: str, @@ -156,7 +178,7 @@ def add_captions( ] text_color: 文本颜色(十六进制),默认"#ffffff" border_color: 边框颜色(十六进制),默认 None - alignment: 文本对齐方式(0-5),默认 1 + alignment: 文本对齐方式,0左/1中/2右(横排),3垂直居中/4垂直左/5垂直右(竖排),默认 1 alpha: 文本透明度(0.0-1.0),默认 1.0 font: 字体名称,默认 None font_size: 字体大小,默认 15 @@ -315,7 +337,7 @@ async def add_captions_async( captions: JSON 字符串,包含字幕信息列表,详见 add_captions 函数 text_color: 文本颜色(十六进制),默认"#ffffff" border_color: 边框颜色(十六进制),默认 None - alignment: 文本对齐方式(0-5),默认 1 + alignment: 文本对齐方式,0左/1中/2右(横排),3垂直居中/4垂直左/5垂直右(竖排),默认 1 alpha: 文本透明度(0.0-1.0),默认 1.0 font: 字体名称,默认 None font_size: 字体大小,默认 15 @@ -447,7 +469,7 @@ def add_caption_to_draft( loop_animation_duration: 循环动画时长,可选 text_color: 文本颜色(十六进制),默认"#ffffff" border_color: 边框颜色(十六进制),默认 None - alignment: 文本对齐方式(0-5),默认 1 + alignment: 文本对齐方式,0左/1中/2右(横排),3垂直居中/4垂直左/5垂直右(竖排),默认 1 alpha: 文本透明度(0.0-1.0),默认 1.0 font: 字体名称,默认 None font_size: 字体大小,默认 15 @@ -504,11 +526,7 @@ def add_caption_to_draft( 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 + align_value, is_vertical = resolve_caption_alignment(alignment) # 根据需求修改:只有当caption中明确指定了font_size时才使用,否则不设置默认值 font_size_value = font_size @@ -521,6 +539,7 @@ def add_caption_to_draft( color=rgb_color, alpha=alpha, align=align_value, + vertical=is_vertical, 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, # 字幕默认开启自动换行 @@ -528,7 +547,11 @@ def add_caption_to_draft( italic=italic, bold=bold ) - logger.info(f"Created text style, text_style.size: {text_style.size}, font_size from caption: {font_size}") + logger.info( + f"Created text style, text_style.size: {text_style.size}, " + f"align: {text_style.align}, vertical: {text_style.vertical}, " + f"font_size from caption: {font_size}" + ) # 4. 创建文本描边(如果提供了border_color) text_border = None diff --git a/tests/test_caption_alignment.py b/tests/test_caption_alignment.py new file mode 100644 index 0000000..2dbbff9 --- /dev/null +++ b/tests/test_caption_alignment.py @@ -0,0 +1,155 @@ +"""add_captions alignment:0/1/2 横排保持不变,3/4/5 映射为竖排。""" + +from __future__ import annotations + +import json + +import pytest +from pydantic import ValidationError + +from src.pyJianYingDraft import ScriptFile, TrackType +from src.schemas.add_captions import AddCaptionsRequest +from src.service.add_captions import ( + add_caption_to_draft, + resolve_caption_alignment, +) + + +def _make_script(track_name: str = "caption_track") -> ScriptFile: + script = ScriptFile(width=1920, height=1080, fps=30, maintrack_adsorb=False) + script.add_track(TrackType.text, track_name) + return script + + +def _add_and_export_material(alignment: int, *, caption_extra: dict | None = None, **kwargs): + script = _make_script() + caption = {"start": 0, "end": 1_000_000, "text": "你好世界"} + if caption_extra: + caption.update(caption_extra) + _, text_id, _ = add_caption_to_draft( + script, + "caption_track", + caption=caption, + alignment=alignment, + **kwargs, + ) + material = next((item for item in script.materials.texts if item["id"] == text_id), None) + assert material is not None + return script, material + + +@pytest.mark.parametrize( + "alignment, expected_align, expected_vertical", + [ + (0, 0, False), + (1, 1, False), + (2, 2, False), + (3, 1, True), + (4, 0, True), + (5, 2, True), + ], +) +def test_resolve_caption_alignment_mapping(alignment, expected_align, expected_vertical): + assert resolve_caption_alignment(alignment) == (expected_align, expected_vertical) + + +def test_resolve_caption_alignment_unknown_falls_back_to_horizontal_left(): + """与历史实现一致:无法识别的值回退为横排左对齐。""" + assert resolve_caption_alignment(-1) == (0, False) + assert resolve_caption_alignment(6) == (0, False) + + +@pytest.mark.parametrize( + "alignment, expected_align, expected_typesetting", + [ + (0, 0, 0), + (1, 1, 0), + (2, 2, 0), + (3, 1, 1), + (4, 0, 1), + (5, 2, 1), + ], +) +def test_add_caption_to_draft_writes_alignment_and_typesetting( + alignment, expected_align, expected_typesetting +): + _, material = _add_and_export_material(alignment) + assert material["alignment"] == expected_align + assert material["typesetting"] == expected_typesetting + + +def test_default_alignment_remains_center_horizontal(): + """默认 alignment=1:居中横排,不影响既有默认行为。""" + _, material = _add_and_export_material(1) + assert material["alignment"] == 1 + assert material["typesetting"] == 0 + assert material["type"] == "subtitle" + + +def test_horizontal_alignment_keeps_existing_style_fields(): + """0/1/2 不应改变字号、颜色、自动换行等既有样式。""" + _, material = _add_and_export_material( + 0, + text_color="#ff0000", + font_size=22, + underline=True, + italic=True, + bold=True, + ) + content = json.loads(material["content"]) + base_style = content["styles"][0] + + assert material["alignment"] == 0 + assert material["typesetting"] == 0 + assert material["type"] == "subtitle" + assert base_style["size"] == 22 + assert base_style["underline"] is True + assert base_style["italic"] is True + assert base_style["bold"] is True + assert base_style["fill"]["content"]["solid"]["color"] == [1.0, 0.0, 0.0] + + +def test_vertical_alignment_does_not_break_font_size_or_wrapping(): + """3/4/5 只打开竖排,字号与自动换行保持原逻辑。""" + _, material = _add_and_export_material( + 3, + caption_extra={"font_size": 18}, + font_size=15, + ) + content = json.loads(material["content"]) + base_style = content["styles"][0] + + assert material["alignment"] == 1 + assert material["typesetting"] == 1 + assert material["type"] == "subtitle" + assert base_style["size"] == 18.0 + + +def test_vertical_alignment_keeps_keyword_highlight(): + """竖排不应打断关键词高亮分区。""" + _, material = _add_and_export_material( + 5, + caption_extra={"keyword": "世界", "keyword_color": "#ff7100", "keyword_font_size": 20}, + ) + content = json.loads(material["content"]) + + assert material["alignment"] == 2 + assert material["typesetting"] == 1 + assert len(content["styles"]) >= 2 + highlight = next(style for style in content["styles"] if style.get("size") == 20) + assert highlight["range"] == [2, 4] + + +def test_schema_accepts_alignment_0_to_5(): + captions = json.dumps([{"start": 0, "end": 1000, "text": "hi"}]) + for value in range(6): + req = AddCaptionsRequest(draft_url="http://x?draft_id=1", captions=captions, alignment=value) + assert req.alignment == value + + +def test_schema_rejects_alignment_out_of_range(): + captions = json.dumps([{"start": 0, "end": 1000, "text": "hi"}]) + with pytest.raises(ValidationError): + AddCaptionsRequest(draft_url="http://x?draft_id=1", captions=captions, alignment=6) + with pytest.raises(ValidationError): + AddCaptionsRequest(draft_url="http://x?draft_id=1", captions=captions, alignment=-1)