添加关键词阴影。

This commit is contained in:
Hommy
2026-08-05 22:17:22 +08:00
parent fdc276f489
commit 5763a5d182
5 changed files with 287 additions and 115 deletions
+6
View File
@@ -94,6 +94,12 @@ Default shadow when enabled without `*_shadow_info`:
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.
### Notes on keyword shadow
`keyword_has_shadow` / `keyword_shadow_info` work like `keyword_color` / `keyword_border_color`: they stay on the **same caption segment** and do not create an extra text line.
When keyword shadow is enabled, the caption is split into non-overlapping `styles` partitions: normal ranges get `shadows: []`, keyword ranges get the shadow params. Without keyword shadow fields, the original base + keyword overlay path is unchanged.
## Fully Annotated Request Example
`//` comments are for documentation only and are **not** valid in a real request body.
+7 -1
View File
@@ -107,6 +107,12 @@ POST /openapi/capcut-mate/v1/add_captions
`text_effect` 能解析到有效花字时,系统会将 `text_color` 重置为 `#ffffff``border_color` 重置为 `null``has_shadow` 重置为 `false`,并禁用关键词阴影(`keyword_has_shadow` 不生效)。若需要自定义颜色/阴影,请不要同时传有效花字。
#### 关键词阴影如何生效
`keyword_has_shadow` / `keyword_shadow_info``keyword_color` / `keyword_border_color` 一样,都作用在**同一条字幕**内,不会新建额外字幕行。
实现上会把字幕拆成互不重叠的 `styles` 分区:普通文字分区 `shadows: []`,关键词分区写入阴影参数,从而尽量只让关键词带阴影。不传阴影相关字段时,仍走原来的「base + 关键词叠加样式」路径,行为与增加阴影功能前一致。
## 完整参数请求示例(含注释)
下列为**全部接口级参数 + captions 全部字段**的示意;`//` 注释仅用于说明,不能直接作为请求体。
@@ -370,7 +376,7 @@ curl -X POST https://capcut-mate.jcaigc.cn/openapi/capcut-mate/v1/add_captions \
4. **动画名称**:请通过 `get_text_animations` 获取可用名称
5. **花字名称**:请通过 `get_text_effects` 获取可用名称或 `effect_id`
6. **坐标系统**`transform_x` / `transform_y` 使用像素,内部会按画布尺寸换算
7. **关键词阴影**仅作用于关键词字符范围;整段阴影由 `has_shadow` / `shadow_info` 控制
7. **关键词阴影**与关键词颜色/描边一样写在同一字幕的 styles 分区内,不另建字幕行;整段阴影`has_shadow` / `shadow_info` 控制
## 工作流程
+47 -40
View File
@@ -307,6 +307,8 @@ class TextSegment(VisualSegment):
self.bubble = None
self.effect = None
self.extra_styles = []
# 为 True 时 export 使用 extra_styles 作为完整 styles(互不重叠分区),不再叠加全量 base_style
self.use_extra_styles_only = False
@classmethod
def create_from_template(cls, text: str, timerange: Timerange, template: "TextSegment") -> "TextSegment":
@@ -400,53 +402,58 @@ class TextSegment(VisualSegment):
check_flag |= 8
if self.background:
check_flag |= 16
# 整段阴影,或关键词等 extra_styles 上的阴影,都需要打开阴影位
has_extra_shadow = any(
isinstance(style, dict) and style.get("shadows")
for style in self.extra_styles
if self.use_extra_styles_only and self.extra_styles:
styles = list(self.extra_styles)
else:
# 创建基础样式
base_style = {
"fill": {
"alpha": 1.0,
"content": {
"render_type": "solid",
"solid": {
"alpha": 1.0,
"color": list(self.style.color)
}
}
},
"range": [0, len(self.text.encode('utf-16-le'))],
"size": self.style.size,
"bold": self.style.bold,
"italic": self.style.italic,
"underline": self.style.underline,
"strokes": [self.border.export_json()] if self.border else []
}
# 合并基础样式和额外样式(额外样式按 range 覆盖 fill/size/strokes 等)
styles = [base_style] + self.extra_styles
# 素材级 shadow,或 styles 分区内的非空 shadows,都需要打开阴影位
has_style_shadows = any(
isinstance(style, dict) and bool(style.get("shadows"))
for style in styles
)
if self.shadow or has_extra_shadow:
if self.shadow or has_style_shadows:
check_flag |= 32
# 创建基础样式
base_style = {
"fill": {
"alpha": 1.0,
"content": {
"render_type": "solid",
"solid": {
"alpha": 1.0,
"color": list(self.style.color)
}
}
},
"range": [0, len(self.text.encode('utf-16-le'))],
"size": self.style.size,
"bold": self.style.bold,
"italic": self.style.italic,
"underline": self.style.underline,
"strokes": [self.border.export_json()] if self.border else []
}
# 合并基础样式和额外样式
styles = [base_style] + self.extra_styles
content_json = {
"styles": styles,
"text": self.text
}
if self.font:
content_json["styles"][0]["font"] = {
"id": self.font.resource_id,
"path": "D:" # 并不会真正在此处放置字体文件
}
if self.effect:
content_json["styles"][0]["effectStyle"] = {
"id": self.effect.effect_id,
"path": "C:" # 并不会真正在此处放置素材文件
}
if self.shadow:
content_json["styles"][0]["shadows"] = [self.shadow.export_json()]
if styles:
if self.font:
content_json["styles"][0]["font"] = {
"id": self.font.resource_id,
"path": "D:" # 并不会真正在此处放置字体文件
}
if self.effect:
content_json["styles"][0]["effectStyle"] = {
"id": self.effect.effect_id,
"path": "C:" # 并不会真正在此处放置素材文件
}
# 仅素材级整段阴影写入 styles[0];分区阴影已在各自 style 中
if self.shadow:
content_json["styles"][0]["shadows"] = [self.shadow.export_json()]
ret = {
"id": self.material_id,
+225 -72
View File
@@ -242,7 +242,7 @@ def add_captions(
bold=bold,
has_shadow=has_shadow,
shadow_info=shadow_info,
text_effect=text_effect
text_effect=text_effect,
)
segment_ids.append(segment_id)
text_ids.append(text_id)
@@ -418,7 +418,7 @@ def add_caption_to_draft(
bold: bool = False,
has_shadow: bool = False,
shadow_info: Optional[ShadowInfo] = None,
text_effect: Optional[str] = None
text_effect: Optional[str] = None,
) -> Tuple[str, str, dict]:
"""
向剪映草稿中添加单个字幕
@@ -598,7 +598,7 @@ def add_caption_to_draft(
except Exception as e:
logger.error(f"Failed to add text effect '{text_effect}': {str(e)}")
# 11. 处理关键词高亮
# 11. 处理关键词高亮(颜色/描边/字号/阴影均在同一字幕的 styles 分区内完成,不另建片段)
if caption.get('keyword'):
keyword_color = caption.get('keyword_color', '#ff7100') # 默认橙色
keyword_rgb_color = hex_to_rgb(keyword_color)
@@ -617,16 +617,35 @@ def add_caption_to_draft(
keyword_has_shadow = False
keyword_shadow_info = caption.get('keyword_shadow_info')
# 应用关键词颜色和字体大小到文本样式中
apply_keyword_highlight(
text_segment,
caption['keyword'],
keyword_rgb_color,
keyword_font_size,
keyword_border_rgb_color,
keyword_has_shadow=keyword_has_shadow,
keyword_shadow_info=keyword_shadow_info,
)
if keyword_has_shadow:
# 与 keyword_color / keyword_border_color 一样:在同一段字幕内用互不重叠的 styles 分区表达。
# 有关键词阴影时改用完整分区,避免「全量 base + 局部 shadows」被剪映当成整段阴影。
normal_shadows = None
if has_shadow and text_shadow is not None:
normal_shadows = [text_shadow.export_json()]
# 阴影已写入各 style 分区,清除素材级 shadow,避免再强制写到 styles[0]
text_segment.shadow = None
apply_keyword_partitioned_styles(
text_segment,
caption['keyword'],
keyword_rgb_color,
keyword_font_size,
keyword_border_rgb_color,
keyword_shadows=_shadow_info_to_export_list(keyword_shadow_info),
normal_shadows=normal_shadows,
normal_border_rgb=(
hex_to_rgb(border_color) if border_color else None
),
)
else:
apply_keyword_highlight(
text_segment,
caption['keyword'],
keyword_rgb_color,
keyword_font_size,
keyword_border_rgb_color,
)
logger.info(
f"Applied keyword highlighting: {caption['keyword']} with color {keyword_color}, "
f"font size {keyword_font_size}, border color {keyword_border_color or border_color}, "
@@ -697,6 +716,187 @@ def add_caption_to_draft(
raise CustomException(CustomError.CAPTION_ADD_FAILED)
def _shadow_info_to_export_list(shadow_info: Optional[dict] = None) -> List[dict]:
"""将 keyword_shadow_info / 默认阴影转为 styles[].shadows 数组。"""
return [_build_text_shadow(shadow_info).export_json()]
def _find_keyword_ranges(text: str, keywords: str) -> List[Tuple[int, int]]:
"""按关键词长度优先匹配,返回不重叠的 [start, end) 字符下标区间。"""
keyword_list = sorted(
(kw.strip() for kw in keywords.split('|') if kw.strip()),
key=len,
reverse=True,
)
used = set()
ranges: List[Tuple[int, int]] = []
for keyword in keyword_list:
start_pos = 0
while start_pos < len(text):
start_pos = text.find(keyword, start_pos)
if start_pos == -1:
break
end_pos = start_pos + len(keyword)
if any(i in used for i in range(start_pos, end_pos)):
start_pos = end_pos
continue
ranges.append((start_pos, end_pos))
used.update(range(start_pos, end_pos))
start_pos = end_pos
ranges.sort(key=lambda item: item[0])
return ranges
def _build_style_partition(
start: int,
end: int,
*,
color: tuple,
font_size: float,
bold: bool,
italic: bool,
underline: bool,
border_rgb: Optional[tuple] = None,
shadows: Optional[List[dict]] = None,
include_shadow_field: bool = False,
) -> dict:
"""构造单个互不重叠的 style 分区(range 使用字符下标,与 keyword_color 历史行为一致)。"""
style = {
"fill": {
"alpha": 1.0,
"content": {
"render_type": "solid",
"solid": {
"alpha": 1.0,
"color": list(color),
},
},
},
"range": [start, end],
"size": font_size,
"bold": bold,
"italic": italic,
"underline": underline,
"strokes": [],
}
if border_rgb is not None:
style["strokes"] = [{
"content": {
"solid": {
"alpha": 1.0,
"color": list(border_rgb),
}
},
"width": 0.08,
}]
if include_shadow_field:
style["shadows"] = list(shadows) if shadows else []
return style
def apply_keyword_partitioned_styles(
text_segment: TextSegment,
keywords: str,
keyword_color: tuple,
keyword_font_size: float = None,
keyword_border_color: tuple = None,
keyword_shadows: Optional[List[dict]] = None,
normal_shadows: Optional[List[dict]] = None,
normal_border_rgb: Optional[tuple] = None,
):
"""
用互不重叠的 styles 分区表达关键词样式(含可选局部阴影)。
与 add_text_style / keyword_color 相同:仍是同一条字幕,不新建片段。
当需要关键词阴影时,必须用完整分区,而不能「全量 base + 局部 shadows 叠加」,
否则剪映容易把阴影作用到整段文字。
"""
text = text_segment.text
font_size = keyword_font_size if keyword_font_size is not None else text_segment.style.size
keyword_ranges = _find_keyword_ranges(text, keywords)
include_shadow_field = bool(keyword_shadows or normal_shadows)
styles: List[dict] = []
cursor = 0
for start, end in keyword_ranges:
if cursor < start:
styles.append(_build_style_partition(
cursor,
start,
color=text_segment.style.color,
font_size=text_segment.style.size,
bold=text_segment.style.bold,
italic=text_segment.style.italic,
underline=text_segment.style.underline,
border_rgb=normal_border_rgb,
shadows=normal_shadows,
include_shadow_field=include_shadow_field,
))
styles.append(_build_style_partition(
start,
end,
color=keyword_color,
font_size=font_size,
bold=text_segment.style.bold,
italic=text_segment.style.italic,
underline=text_segment.style.underline,
border_rgb=keyword_border_color,
shadows=keyword_shadows,
include_shadow_field=include_shadow_field,
))
cursor = end
if cursor < len(text):
styles.append(_build_style_partition(
cursor,
len(text),
color=text_segment.style.color,
font_size=text_segment.style.size,
bold=text_segment.style.bold,
italic=text_segment.style.italic,
underline=text_segment.style.underline,
border_rgb=normal_border_rgb,
shadows=normal_shadows,
include_shadow_field=include_shadow_field,
))
if not styles:
styles.append(_build_style_partition(
0,
len(text),
color=text_segment.style.color,
font_size=text_segment.style.size,
bold=text_segment.style.bold,
italic=text_segment.style.italic,
underline=text_segment.style.underline,
border_rgb=normal_border_rgb,
shadows=normal_shadows,
include_shadow_field=include_shadow_field,
))
text_segment.extra_styles = styles
text_segment.use_extra_styles_only = True
def _build_text_shadow(shadow_info: Optional[dict] = None) -> TextShadow:
"""根据 shadow_info(或默认值)构造 TextShadow。"""
if shadow_info is None:
return TextShadow(
alpha=0.9,
color=hex_to_rgb("#000000"),
diffuse=15.0,
distance=5.0,
angle=-45.0,
)
return TextShadow(
alpha=float(shadow_info.get("shadow_alpha", 1.0)),
color=hex_to_rgb(str(shadow_info.get("shadow_color", "#000000"))),
diffuse=float(shadow_info.get("shadow_diffuse", 15.0)),
distance=float(shadow_info.get("shadow_distance", 5.0)),
angle=float(shadow_info.get("shadow_angle", -45.0)),
)
def apply_keyword_highlight(
text_segment: TextSegment,
keywords: str,
@@ -707,67 +907,29 @@ def apply_keyword_highlight(
keyword_shadow_info: Optional[dict] = None,
):
"""
应用关键词高亮到文本片段
Args:
text_segment: 文本片段对象
keywords: 关键词字符串,用'|'分隔多个关键词
keyword_color: 关键词颜色的RGB元组 (0-1范围)
keyword_font_size: 关键词字体大小,默认为None,使用文本默认字体大小
keyword_border_color: 关键词边框颜色的RGB元组 (0-1范围),默认为None
keyword_has_shadow: 是否启用关键词阴影,默认 False
keyword_shadow_info: 关键词阴影参数字典,字段与 ShadowInfo 一致,默认为None
应用关键词高亮到文本片段(颜色 / 字号 / 描边)。
与历史行为一致:在同一字幕上追加 extra_stylesrange 使用字符下标。
关键词阴影请走 apply_keyword_partitioned_styles。
"""
# 分割关键词
del keyword_has_shadow, keyword_shadow_info
keyword_list = keywords.split('|')
text = text_segment.text
# 使用关键词字体大小,如果没有指定则使用文本默认字体大小
font_size = keyword_font_size if keyword_font_size is not None else text_segment.style.size
# 预构建关键词阴影(与字幕级 TextShadow.export_json 格式一致)
keyword_shadows = None
if keyword_has_shadow:
if keyword_shadow_info is None:
shadow_rgb_color = hex_to_rgb("#000000")
shadow_alpha = 0.9
shadow_diffuse = 15.0
shadow_distance = 5.0
shadow_angle = -45.0
else:
shadow_rgb_color = hex_to_rgb(keyword_shadow_info.get("shadow_color", "#000000"))
shadow_alpha = float(keyword_shadow_info.get("shadow_alpha", 1.0))
shadow_diffuse = float(keyword_shadow_info.get("shadow_diffuse", 15.0))
shadow_distance = float(keyword_shadow_info.get("shadow_distance", 5.0))
shadow_angle = float(keyword_shadow_info.get("shadow_angle", -45.0))
keyword_shadows = [{
"diffuse": shadow_diffuse / 100.0 / 6, # /6是剪映自带的映射
"alpha": shadow_alpha,
"distance": shadow_distance,
"content": {
"solid": {
"color": list(shadow_rgb_color),
}
},
"angle": shadow_angle
}]
# 为每个关键词创建高亮样式
for keyword in keyword_list:
keyword = keyword.strip()
if not keyword:
continue
# 查找所有匹配的关键词位置
start_pos = 0
while start_pos < len(text):
start_pos = text.find(keyword, start_pos)
if start_pos == -1:
break
end_pos = start_pos + len(keyword)
# 创建关键词高亮样式
highlight_style = {
"fill": {
"alpha": 1.0,
@@ -775,22 +937,18 @@ def apply_keyword_highlight(
"render_type": "solid",
"solid": {
"alpha": 1.0,
"color": list(keyword_color) # 使用关键词颜色
"color": list(keyword_color)
}
}
},
"range": [start_pos, end_pos],
"size": font_size, # 使用关键词字体大小
"size": font_size,
"bold": text_segment.style.bold,
"italic": text_segment.style.italic,
"underline": text_segment.style.underline
}
# 处理关键词边框颜色:当keyword_border_color不为None时添加描边
# keyword_border_color的值由调用方决定:优先使用keyword_border_color参数,否则使用border_color
if keyword_border_color is not None:
# 使用指定的关键词边框颜色创建描边
# 注意:剪映的stroke格式需要包含content.solid结构
highlight_style["strokes"] = [{
"content": {
"solid": {
@@ -798,14 +956,9 @@ def apply_keyword_highlight(
"color": list(keyword_border_color)
}
},
"width": 0.08 # 默认边框宽度(与剪映内部表示一致)
"width": 0.08
}]
# 注意:当keyword_border_color为None时(即既没有指定keyword_border_color也没有指定border_color),不添加描边
if keyword_shadows is not None:
highlight_style["shadows"] = keyword_shadows
# 添加到文本片段的额外样式中
text_segment.extra_styles.append(highlight_style)
start_pos = end_pos
+2 -2
View File
@@ -60,8 +60,8 @@ def test_keyword_font_size_application():
assert len(text_segment.extra_styles) == 1
highlight_style = text_segment.extra_styles[0]
assert highlight_style["size"] == 20.0 # 验证关键词字体大小是否为20
assert highlight_style["range"] == [3, 5] # 验证关键词位置是否正确
print("keyword_font_size parameter application test passed")
assert highlight_style["range"] == [3, 5] # 字符下标:「你好,」后是「剪映」
print("keyword_font_size parameter application test passed")
def test_keyword_font_size_default():