diff --git a/src/pyJianYingDraft/script_file.py b/src/pyJianYingDraft/script_file.py index 90e7598..3e68bca 100644 --- a/src/pyJianYingDraft/script_file.py +++ b/src/pyJianYingDraft/script_file.py @@ -371,8 +371,8 @@ class ScriptFile: self.materials.audio_effects.append(effect) self.materials.speeds.append(segment.speed) elif isinstance(segment, TextSegment): - # 出入场等动画 - if (segment.animations_instance is not None) and (segment.animations_instance not in self.materials): + # 出入场等动画(含空 sticker_animation,对齐剪映文本片段引用) + if segment.animations_instance is not None and segment.animations_instance not in self.materials: self.materials.animations.append(segment.animations_instance) # 气泡效果 if segment.bubble is not None: diff --git a/src/pyJianYingDraft/text_segment.py b/src/pyJianYingDraft/text_segment.py index d52363a..0cc8720 100644 --- a/src/pyJianYingDraft/text_segment.py +++ b/src/pyJianYingDraft/text_segment.py @@ -1,6 +1,7 @@ """定义文本片段及其相关类""" import json +import math import uuid from copy import deepcopy @@ -14,6 +15,47 @@ from .animation import SegmentAnimations, Text_animation from .metadata import FontType, EffectMeta from .metadata import TextIntro, TextOutro, TextLoopAnim + +def _rgb_to_hex(color: Tuple[float, float, float]) -> str: + r, g, b = [max(0, min(255, int(round(c * 255)))) for c in color] + return f"#{r:02X}{g:02X}{b:02X}" + + +def _style_range_end(text: str) -> int: + """剪映 content.styles[].range 使用字符下标(非 UTF-16 字节长度)。""" + return len(text) + + +def _font_content_json(font: Optional[EffectMeta]) -> Dict[str, str]: + """styles[].font:系统字体 id 为空;自定义字体用 resource_id,path 留空由剪映按 id 解析。""" + if font is None: + return {"id": "", "path": ""} + return {"id": font.resource_id, "path": ""} + + +def _build_fonts_material_entry(font: EffectMeta) -> Dict[str, Any]: + """materials.texts[].fonts[] 单项,对齐剪映官方草稿结构。""" + return { + "category_id": "", + "category_name": "", + "effect_id": font.resource_id, + "file_uri": "", + "id": str(uuid.uuid4()).upper(), + "path": "", + "request_id": "", + "resource_id": font.resource_id, + "source_platform": 0, + "team_id": "", + "title": font.name, + } + + +def _shadow_point(angle: float, distance: float) -> Dict[str, float]: + """按角度/距离估算 shadow_point;默认 -45°/5 与官方草稿一致。""" + rad = math.radians(angle) + factor = 0.9 * (distance / 5.0) + return {"x": math.cos(rad) * factor, "y": math.sin(rad) * factor} + class TextStyle: """字体样式类""" @@ -310,6 +352,10 @@ class TextSegment(VisualSegment): # 为 True 时 export 使用 extra_styles 作为完整 styles(互不重叠分区),不再叠加全量 base_style self.use_extra_styles_only = False + # 剪映文本片段的 extra_material_refs 指向 sticker_animation,而非 speed + self.animations_instance = SegmentAnimations() + self.extra_material_refs = [self.animations_instance.animation_id] + @classmethod def create_from_template(cls, text: str, timerange: Timerange, template: "TextSegment") -> "TextSegment": """根据模板创建新的文本片段, 并指定其文本内容""" @@ -322,7 +368,8 @@ class TextSegment(VisualSegment): if template.animations_instance: new_segment.animations_instance = deepcopy(template.animations_instance) new_segment.animations_instance.animation_id = uuid.uuid4().hex - new_segment.extra_material_refs.append(new_segment.animations_instance.animation_id) + # 替换默认空动画引用,避免重复挂载 + new_segment.extra_material_refs = [new_segment.animations_instance.animation_id] if template.bubble: new_segment.add_bubble(template.bubble.effect_id, template.bubble.resource_id) if template.effect: @@ -394,9 +441,32 @@ class TextSegment(VisualSegment): self.extra_material_refs.append(self.effect.global_id) return self + def export_json(self) -> Dict[str, Any]: + """导出轨道片段 JSON,对齐剪映官方文本片段字段。""" + json_dict = super().export_json() + json_dict.update({ + "caption_info": None, + "cartoon": False, + "enable_adjust": False, + "enable_lut": False, + "group_id": "", + "hdr_settings": None, + "intensifies_audio": False, + "is_placeholder": False, + "responsive_layout": { + "enable": False, + "horizontal_pos_layout": 0, + "size_layout": 0, + "target_follow": "", + "vertical_pos_layout": 0, + }, + "template_id": "", + "template_scene": "default", + }) + return json_dict + def export_material(self) -> Dict[str, Any]: - """与此文本片段联系的素材, 以此不再单独定义Text_material类""" - # 叠加各类效果的flag + """与此文本片段联系的素材,字段对齐剪映官方草稿以便二次编辑。""" check_flag: int = 7 if self.border: check_flag |= 8 @@ -406,29 +476,28 @@ class TextSegment(VisualSegment): if self.use_extra_styles_only and self.extra_styles: styles = list(self.extra_styles) else: - # 创建基础样式 - base_style = { + base_style: Dict[str, Any] = { "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'))], + "range": [0, _style_range_end(self.text)], "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 等) + # 仅在启用时写出格式位,贴近官方草稿精简结构 + if self.style.bold: + base_style["bold"] = True + if self.style.italic: + base_style["italic"] = True + if self.style.underline: + base_style["underline"] = True + if self.border: + base_style["strokes"] = [self.border.export_json()] 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 @@ -441,45 +510,164 @@ class TextSegment(VisualSegment): "text": self.text } if styles: - # 片段级字体填充到尚未单独指定 font 的分区(关键词等可在 style 内覆盖) - if self.font: - font_json = { - "id": self.font.resource_id, - "path": "D:" # 并不会真正在此处放置字体文件 - } - for style in content_json["styles"]: - if isinstance(style, dict) and "font" not in style: - style["font"] = dict(font_json) + font_json = _font_content_json(self.font) + for style in content_json["styles"]: + if isinstance(style, dict) and "font" not in style: + style["font"] = dict(font_json) if self.effect: content_json["styles"][0]["effectStyle"] = { "id": self.effect.effect_id, - "path": "C:" # 并不会真正在此处放置素材文件 + "path": "" } - # 仅素材级整段阴影写入 styles[0];分区阴影已在各自 style 中 if self.shadow: content_json["styles"][0]["shadows"] = [self.shadow.export_json()] - ret = { - "id": self.material_id, - "content": json.dumps(content_json, ensure_ascii=False), + # 保证 styles 中 font 键顺序贴近官方:fill → font → size → range + normalized_styles: List[Dict[str, Any]] = [] + for style in content_json["styles"]: + if not isinstance(style, dict): + continue + ordered: Dict[str, Any] = {} + for key in ("fill", "font", "size", "range"): + if key in style: + ordered[key] = style[key] + for key, value in style.items(): + if key not in ordered: + ordered[key] = value + normalized_styles.append(ordered) + content_json["styles"] = normalized_styles - "typesetting": int(self.style.vertical), + shadow_alpha = 0.9 + shadow_angle = -45.0 + shadow_color = "" + shadow_distance = 5.0 + shadow_smoothing = 0.45 + if self.shadow: + shadow_alpha = self.shadow.alpha + shadow_angle = self.shadow.angle + shadow_color = _rgb_to_hex(self.shadow.color) + shadow_distance = self.shadow.distance + shadow_smoothing = self.shadow.diffuse / 100.0 * 3.0 # 15 → 0.45 + + border_color = "" + border_alpha = 1.0 + border_width = 0.08 + if self.border: + border_color = _rgb_to_hex(self.border.color) + border_alpha = self.border.alpha + border_width = self.border.width + + fonts_list: List[Dict[str, Any]] = [] + font_path = "" + font_resource_id = "" + if self.font: + font_resource_id = self.font.resource_id + fonts_list.append(_build_fonts_material_entry(self.font)) + + ret: Dict[str, Any] = { + "add_type": 0, "alignment": self.style.align, + "background_alpha": 1.0, + "background_color": "", + "background_height": 0.14, + "background_horizontal_offset": 0.0, + "background_round_radius": 0.0, + "background_style": 0, + "background_vertical_offset": 0.0, + "background_width": 0.14, + "base_content": "", + "bold_width": 0.0, + "border_alpha": border_alpha, + "border_color": border_color, + "border_width": border_width, + "caption_template_info": { + "category_id": "", + "category_name": "", + "effect_id": "", + "is_new": False, + "path": "", + "request_id": "", + "resource_id": "", + "resource_name": "", + "source_platform": 0, + }, + "check_flag": check_flag, + "combo_info": {"text_templates": []}, + "content": json.dumps(content_json, ensure_ascii=False), + "fixed_height": -1.0, + "fixed_width": -1.0, + "font_category_id": "", + "font_category_name": "", + "font_id": "", + "font_name": "", + "font_path": font_path, + "font_resource_id": font_resource_id, + "font_size": self.style.size, + "font_source_platform": 0, + "font_team_id": "", + "font_title": "none", + "font_url": "", + "fonts": fonts_list, + "force_apply_line_max_width": False, + "global_alpha": self.style.alpha, + "group_id": "", + "has_shadow": bool(self.shadow or has_style_shadows), + "id": self.material_id, + "initial_scale": 1.0, + "inner_padding": -1.0, + "is_rich_text": False, + "italic_degree": 0, + "ktv_color": "", + "language": "", + "layer_weight": 1, "letter_spacing": self.style.letter_spacing * 0.05, - "line_spacing": 0.02 + self.style.line_spacing * 0.05, - "line_feed": 1, "line_max_width": self.style.max_line_width, - "force_apply_line_max_width": False, - - "check_flag": check_flag, - - "type": "subtitle" if self.style.auto_wrapping else "text", - - # 混合 (+4) - "global_alpha": self.style.alpha, - - # 发光 (+64),属性由extra_material_refs记录 + "line_spacing": 0.02 + self.style.line_spacing * 0.05, + "multi_language_current": "none", + "name": "", + "original_size": [], + "preset_category": "", + "preset_category_id": "", + "preset_has_set_alignment": False, + "preset_id": "", + "preset_index": 0, + "preset_name": "", + "recognize_task_id": "", + "recognize_type": 0, + "relevance_segment": [], + "shadow_alpha": shadow_alpha, + "shadow_angle": shadow_angle, + "shadow_color": shadow_color, + "shadow_distance": shadow_distance, + "shadow_point": _shadow_point(shadow_angle, shadow_distance), + "shadow_smoothing": shadow_smoothing, + "shape_clip_x": False, + "shape_clip_y": False, + "source_from": "", + "style_name": "", + "sub_type": 0, + "subtitle_keywords": None, + "subtitle_template_original_fontsize": 0, + "text_alpha": self.style.alpha, + "text_color": _rgb_to_hex(self.style.color), + "text_curve": None, + "text_preset_resource_id": "", + "text_size": 30, + "text_to_audio_ids": [], + "tts_auto_update": False, + # 官方手动添加字幕使用 type=text(非 subtitle),否则二次编辑兼容性差 + "type": "text", + "typesetting": int(self.style.vertical), + "underline": bool(self.style.underline), + "underline_offset": 0.22, + "underline_width": 0.05, + "use_effect_default_color": True, + "words": { + "end_time": [], + "start_time": [], + "text": [], + }, } if self.background: diff --git a/src/service/add_captions.py b/src/service/add_captions.py index 404d3d1..28bfdf7 100644 --- a/src/service/add_captions.py +++ b/src/service/add_captions.py @@ -788,12 +788,12 @@ def _find_keyword_ranges(text: str, keywords: str) -> List[Tuple[int, int]]: def _font_style_json(font_meta) -> Optional[dict]: - """将 EffectMeta 转为 styles[].font 结构。""" + """将 EffectMeta 转为 styles[].font 结构(path 留空,由剪映按 resource_id 解析)。""" if font_meta is None: return None return { "id": font_meta.resource_id, - "path": "D:", + "path": "", } @@ -814,22 +814,21 @@ def _build_style_partition( """构造单个互不重叠的 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 bold: + style["bold"] = True + if italic: + style["italic"] = True + if underline: + style["underline"] = True if border_rgb is not None: style["strokes"] = [{ "content": { @@ -996,21 +995,21 @@ def apply_keyword_highlight( end_pos = start_pos + len(keyword) highlight_style = { "fill": { - "alpha": 1.0, "content": { - "render_type": "solid", "solid": { - "alpha": 1.0, "color": list(keyword_color) } } }, "range": [start_pos, end_pos], "size": font_size, - "bold": text_segment.style.bold, - "italic": text_segment.style.italic, - "underline": text_segment.style.underline } + if text_segment.style.bold: + highlight_style["bold"] = True + if text_segment.style.italic: + highlight_style["italic"] = True + if text_segment.style.underline: + highlight_style["underline"] = True if keyword_border_color is not None: highlight_style["strokes"] = [{ @@ -1030,6 +1029,8 @@ def apply_keyword_highlight( body_font_json = _font_style_json(text_segment.font) if body_font_json is not None: highlight_style["font"] = dict(body_font_json) + elif text_segment.font is None: + highlight_style["font"] = {"id": "", "path": ""} text_segment.extra_styles.append(highlight_style) start_pos = end_pos diff --git a/tests/test_caption_alignment.py b/tests/test_caption_alignment.py index 2dbbff9..25e1055 100644 --- a/tests/test_caption_alignment.py +++ b/tests/test_caption_alignment.py @@ -83,7 +83,7 @@ def test_default_alignment_remains_center_horizontal(): _, material = _add_and_export_material(1) assert material["alignment"] == 1 assert material["typesetting"] == 0 - assert material["type"] == "subtitle" + assert material["type"] == "text" def test_horizontal_alignment_keeps_existing_style_fields(): @@ -101,7 +101,7 @@ def test_horizontal_alignment_keeps_existing_style_fields(): assert material["alignment"] == 0 assert material["typesetting"] == 0 - assert material["type"] == "subtitle" + assert material["type"] == "text" assert base_style["size"] == 22 assert base_style["underline"] is True assert base_style["italic"] is True @@ -121,7 +121,7 @@ def test_vertical_alignment_does_not_break_font_size_or_wrapping(): assert material["alignment"] == 1 assert material["typesetting"] == 1 - assert material["type"] == "subtitle" + assert material["type"] == "text" assert base_style["size"] == 18.0 diff --git a/tests/test_caption_jianying_compat.py b/tests/test_caption_jianying_compat.py new file mode 100644 index 0000000..16fbf02 --- /dev/null +++ b/tests/test_caption_jianying_compat.py @@ -0,0 +1,87 @@ +"""验证字幕素材导出结构对齐剪映官方草稿,便于二次编辑。""" + +from __future__ import annotations + +import json +import math + +from src.pyJianYingDraft import ScriptFile, TrackType, FontType +from src.service.add_captions import add_caption_to_draft + + +def _add_material(**kwargs): + script = ScriptFile(width=1920, height=1080, fps=30, maintrack_adsorb=False) + script.add_track(TrackType.text, "caption_track") + caption = {"start": 0, "end": 3_000_000, "text": "第一行字幕"} + caption.update(kwargs.pop("caption_extra", {}) or {}) + call_kwargs = {"alignment": 1, "font_size": 15} + call_kwargs.update(kwargs) + _, text_id, _ = add_caption_to_draft( + script, + "caption_track", + caption=caption, + **call_kwargs, + ) + material = next(item for item in script.materials.texts if item["id"] == text_id) + track = list(script.tracks.values())[0] + segment = track.segments[0] + return script, material, segment + + +def test_caption_material_matches_jianying_schema(): + script, material, segment = _add_material() + content = json.loads(material["content"]) + style = content["styles"][0] + + assert material["type"] == "text" + assert material["text_color"] == "#FFFFFF" + assert material["font_size"] == 15 + assert material["font_resource_id"] == "" + assert material["fonts"] == [] + assert material["words"] == {"end_time": [], "start_time": [], "text": []} + assert "caption_template_info" in material + assert style["range"] == [0, 5] + assert style["font"] == {"id": "", "path": ""} + assert "alpha" not in style["fill"] + assert "bold" not in style + assert "strokes" not in style + + seg_json = segment.export_json() + assert seg_json["enable_adjust"] is False + assert seg_json["enable_lut"] is False + assert len(seg_json["extra_material_refs"]) >= 1 + assert len(script.materials.animations) == 1 + anim = script.materials.animations[0].export_json() + assert anim["type"] == "sticker_animation" + assert anim["animations"] == [] + assert anim["id"] == seg_json["extra_material_refs"][0] + + +def test_caption_custom_font_writes_fonts_array_and_resource_id(): + _, material, _ = _add_material( + font="三极宋黑体超粗", + font_size=12, + caption_extra={"text": "第二行字幕"}, + ) + content = json.loads(material["content"]) + style = content["styles"][0] + expected_id = FontType.三极宋黑体超粗.value.resource_id + + assert material["type"] == "text" + assert material["font_size"] == 12 + assert material["font_resource_id"] == expected_id + assert len(material["fonts"]) == 1 + assert material["fonts"][0]["resource_id"] == expected_id + assert material["fonts"][0]["title"] == "三极宋黑体超粗" + assert style["font"]["id"] == expected_id + assert style["font"]["path"] == "" + assert style["size"] == 12 + assert style["range"] == [0, 5] + + +def test_caption_transform_y_matches_half_canvas_scale_from_demo(): + """demo1-2: UI Y=500 → transform.y ≈ 500/1080。""" + _, _, segment = _add_material(transform_y=500) + clip = segment.export_json()["clip"] + assert math.isclose(clip["transform"]["y"], 500 / 1080, rel_tol=1e-9) + assert clip["transform"]["x"] == 0