Skip to content

lasagnastack.video_editors.pycapcut

lasagnastack.video_editors.pycapcut

PyCapCut-backed VideoEditorAdapter and CapCut-specific helpers.

PyCapCutAdapter

Bases: VideoEditorAdapter

Produces CapCut draft folders via pyCapCut.

build_draft

build_draft(
    cut_list: CutList,
    draft_parent: Path,
    folder_name: str,
    display_name: str,
    input_dir: Path,
    reel_style_sheet: ReelStyleSheet | None = None,
) -> Path

Build a CapCut draft folder from the cut list.

Parameters:

Name Type Description Default
cut_list CutList

Approved cut list from Stage 4.

required
draft_parent Path

Directory that will contain the new draft subfolder.

required
folder_name str

Name for the new draft subfolder.

required
display_name str

Human-readable project name written into draft_info.json.

required
input_dir Path

Folder containing original source clips.

required
reel_style_sheet ReelStyleSheet | None

Optional per-cut visual styling from Stage 5.

None

Returns:

Type Description
Path

Path to the created draft folder.

Source code in src/lasagnastack/video_editors/pycapcut.py
def build_draft(
    self,
    cut_list: CutList,
    draft_parent: Path,
    folder_name: str,
    display_name: str,
    input_dir: Path,
    reel_style_sheet: ReelStyleSheet | None = None,
) -> Path:
    """Build a CapCut draft folder from the cut list.

    Args:
        cut_list: Approved cut list from Stage 4.
        draft_parent: Directory that will contain the new draft subfolder.
        folder_name: Name for the new draft subfolder.
        display_name: Human-readable project name written into draft_info.json.
        input_dir: Folder containing original source clips.
        reel_style_sheet: Optional per-cut visual styling from Stage 5.

    Returns:
        Path to the created draft folder.
    """
    style_map: dict[int, CutStyle] = {}
    if reel_style_sheet:
        style_map = {cs.cut_order: cs for cs in reel_style_sheet.cut_styles}

    script = DraftFolder(str(draft_parent)).create_draft(
        folder_name, _DRAFT_WIDTH, _DRAFT_HEIGHT, _DRAFT_FPS, allow_replace=True
    )
    # CapCut 8.x expects draft_info.json; pyCapCut defaults to draft_content.json.
    script.save_path = str(draft_parent / folder_name / "draft_info.json")
    script.content["name"] = display_name
    script.add_track(TrackType.video)

    if any(cut.caption for cut in cut_list.cuts):
        script.add_track(TrackType.text, "captions")
    for idx in range(len(cut_list.overlays)):
        script.add_track(TrackType.text, f"overlay_{idx}")

    default_caption_style = TextStyle(bold=True, align=1, auto_wrapping=True)
    timeline_pos = 0

    for cut in cut_list.cuts:
        cut_style = style_map.get(cut.order)
        src_path = input_dir / cut.source_file
        src_start_us = _parse_timestamp(cut.in_)
        src_end_us = _parse_timestamp(cut.out)

        material = VideoMaterial(str(src_path))
        disp_w, disp_h = _display_dimensions(src_path)
        if (disp_w, disp_h) != (material.width, material.height):
            material.width, material.height = disp_w, disp_h
        clip_settings = _make_clip_settings(
            cut.crop, disp_w, disp_h, _DRAFT_WIDTH, _DRAFT_HEIGHT
        )

        src_end_us = min(src_end_us, material.duration)
        src_duration_us = src_end_us - src_start_us
        target_duration_us = round(src_duration_us / cut.speed)
        src_tr = Timerange(src_start_us, src_duration_us)
        target_tr = Timerange(timeline_pos, target_duration_us)

        seg = VideoSegment(
            material,
            target_tr,
            source_timerange=src_tr,
            speed=cut.speed,
            clip_settings=clip_settings,
        )

        transition_type = cut.transition_out
        if cut_style and cut_style.transition_out:
            transition_type = cut_style.transition_out.type
        if transition_type in {"fade", "dissolve"}:
            seg.add_transition(TransitionType.叠化)

        script.add_segment(seg)

        if cut.caption:
            cap_effect = cut_style.caption_effect if cut_style else None
            cap_style = (
                _make_text_style(cap_effect)
                if cap_effect
                else default_caption_style
            )
            border = _make_text_border(cap_effect) if cap_effect else None

            cap_start = timeline_pos + cut.caption.in_ms * 1000
            cap_end = min(
                timeline_pos + cut.caption.out_ms * 1000,
                timeline_pos + target_duration_us,
            )
            cap_duration = cap_end - cap_start
            cap_tr = Timerange(cap_start, cap_duration)
            txt_seg = TextSegment(
                cut.caption.text,
                cap_tr,
                font=_resolve_font(cap_effect) if cap_effect else None,
                style=cap_style,
                border=border,
                clip_settings=ClipSettings(
                    transform_y=_caption_y(cut.caption.position)
                ),
            )

            if cap_effect:
                _apply_text_animations(txt_seg, cap_effect)

            script.add_segment(txt_seg, "captions")

        log.info("render_cut", order=cut.order, source=cut.source_file)
        timeline_pos += target_duration_us

    overlay_style_map: dict[int, CaptionEffect] = {}
    if reel_style_sheet:
        overlay_style_map = {
            ov_style_entry.overlay_index: ov_style_entry.caption_effect
            for ov_style_entry in reel_style_sheet.overlay_styles
        }

    for idx, overlay in enumerate(cut_list.overlays):
        ov_effect = overlay_style_map.get(idx)
        ov_style = (
            _make_text_style(ov_effect) if ov_effect else default_caption_style
        )
        ov_border = _make_text_border(ov_effect) if ov_effect else None
        ov_start = overlay.start_ms * 1000
        ov_end = min(overlay.end_ms * 1000, timeline_pos)
        ov_tr = Timerange(ov_start, ov_end - ov_start)
        ov_seg = TextSegment(
            overlay.text,
            ov_tr,
            font=_resolve_font(ov_effect) if ov_effect else None,
            style=ov_style,
            border=ov_border,
            clip_settings=ClipSettings(transform_y=_caption_y(overlay.position)),
        )
        if ov_effect:
            _apply_text_animations(ov_seg, ov_effect)
        script.add_segment(ov_seg, f"overlay_{idx}")
        log.info(
            "render_overlay",
            index=idx,
            start_ms=overlay.start_ms,
            end_ms=overlay.end_ms,
        )

    script.save()
    _patch_platform(draft_parent / folder_name / "draft_info.json")
    return draft_parent / folder_name

export

export(
    draft_path: Path, input_dir: Path, cut_list: CutList
) -> Path | None

Copy the draft into CapCut's local drafts directory.

Parameters:

Name Type Description Default
draft_path Path

Path to the locally-built draft folder.

required
input_dir Path

Folder containing original source clips.

required
cut_list CutList

Cut list (used to enumerate unique source files).

required

Returns:

Type Description
Path | None

Path to the draft inside CapCut, or None if CapCut is not installed.

Source code in src/lasagnastack/video_editors/pycapcut.py
def export(
    self,
    draft_path: Path,
    input_dir: Path,
    cut_list: CutList,
) -> Path | None:
    """Copy the draft into CapCut's local drafts directory.

    Args:
        draft_path: Path to the locally-built draft folder.
        input_dir: Folder containing original source clips.
        cut_list: Cut list (used to enumerate unique source files).

    Returns:
        Path to the draft inside CapCut, or None if CapCut is not installed.
    """
    return _export_to_capcut(draft_path, input_dir, cut_list)