Skip to content

lasagnastack.video_editor_processors.pycapcut_inverter

lasagnastack.video_editor_processors.pycapcut_inverter

Converts a CapCut draft folder (draft_info.json) into (CutList, ReelStyleSheet).

Recovers all structurally encoded data: clip timing, speed, crop orientation, captions, overlays, transitions, text colour, size, bold/italic, border, font identity, and entrance/exit animations. Fields that are LLM-only (source_segment_id, role, alt_captions, reel tone) are filled with sentinel values so the resulting CutList validates without schema changes.

parse_draft

parse_draft(
    draft_dir: Path,
) -> tuple[CutList, ReelStyleSheet]

Parse a CapCut draft folder into a (CutList, ReelStyleSheet) pair.

Parameters:

Name Type Description Default
draft_dir Path

Path to a CapCut draft folder containing draft_info.json.

required

Returns:

Type Description
tuple[CutList, ReelStyleSheet]

A (CutList, ReelStyleSheet) tuple reconstructed from the draft.

Raises:

Type Description
FileNotFoundError

If draft_info.json is absent.

ValueError

If the draft contains no video track.

Source code in src/lasagnastack/video_editor_processors/pycapcut_inverter.py
def parse_draft(draft_dir: Path) -> tuple[CutList, ReelStyleSheet]:
    """Parse a CapCut draft folder into a (CutList, ReelStyleSheet) pair.

    Args:
        draft_dir: Path to a CapCut draft folder containing ``draft_info.json``.

    Returns:
        A ``(CutList, ReelStyleSheet)`` tuple reconstructed from the draft.

    Raises:
        FileNotFoundError: If ``draft_info.json`` is absent.
        ValueError: If the draft contains no video track.
    """
    draft_info_path = draft_dir / "draft_info.json"
    draft = json.loads(draft_info_path.read_text(encoding="utf-8"))

    canvas = draft.get("canvas_config", {})
    canvas_w: int = canvas.get("width", _CANVAS_W)
    canvas_h: int = canvas.get("height", _CANVAS_H)

    materials = draft.get("materials", {})
    video_mat_lookup: dict[str, dict] = {
        v["id"]: v for v in materials.get("videos", [])
    }
    text_mat_lookup: dict[str, dict] = {t["id"]: t for t in materials.get("texts", [])}
    anim_lookup: dict[str, dict] = {
        a["id"]: a for a in materials.get("material_animations", [])
    }
    transition_lookup: dict[str, dict] = {
        t["id"]: t for t in materials.get("transitions", [])
    }

    tracks = draft.get("tracks", [])
    video_tracks = [t for t in tracks if t.get("type") == "video"]
    text_tracks = [t for t in tracks if t.get("type") == "text"]

    if not video_tracks:
        raise ValueError(f"No video track found in {draft_dir}")

    video_segs = sorted(
        video_tracks[0].get("segments", []),
        key=lambda s: s["target_timerange"]["start"],
    )

    # --- Pass 1: parse video segments ------------------------------------
    # Each entry: (segment_dict, material_dict, timeline_start_us, timeline_end_us)
    cut_data: list[tuple[dict, dict, int, int]] = []
    for seg in video_segs:
        mat = video_mat_lookup.get(seg.get("material_id", ""), {})
        t_start: int = seg["target_timerange"]["start"]
        t_end: int = t_start + seg["target_timerange"]["duration"]
        cut_data.append((seg, mat, t_start, t_end))

    cut_timeline_ranges = [(t_start, t_end) for _, _, t_start, t_end in cut_data]

    # --- Pass 2: classify text segments as captions or overlays ----------
    # cut_captions[cut_idx] = (Caption, CaptionEffect)
    cut_captions: dict[int, tuple[Caption, CaptionEffect]] = {}
    overlays: list[Overlay] = []
    overlay_effects: list[CaptionEffect] = []

    for text_track in text_tracks:
        for text_seg in text_track.get("segments", []):
            mat = text_mat_lookup.get(text_seg.get("material_id", ""), {})
            text = _extract_text(mat)
            if not text:
                continue

            ts_start: int = text_seg["target_timerange"]["start"]
            ts_end: int = ts_start + text_seg["target_timerange"]["duration"]
            transform_y: float = (
                text_seg.get("clip", {}).get("transform", {}).get("y", -0.8)
            )
            position = _infer_position(transform_y)
            effect, anim_in, anim_out = _extract_caption_effect(
                mat, text_seg, anim_lookup
            )
            effect = effect.model_copy(
                update={"animation_in": anim_in, "animation_out": anim_out}
            )

            idx = _find_containing_cut(ts_start, ts_end, cut_timeline_ranges)
            if idx is not None and idx not in cut_captions:
                cut_start = cut_timeline_ranges[idx][0]
                caption = Caption(
                    text=text,
                    style="bold",
                    position=position,
                    in_ms=(ts_start - cut_start) // 1000,
                    out_ms=(ts_end - cut_start) // 1000,
                )
                cut_captions[idx] = (caption, effect)
                log.debug("inverter_caption", cut_idx=idx, text=text[:30])
            else:
                overlays.append(
                    Overlay(
                        text=text,
                        style="bold",
                        position=position,
                        start_ms=ts_start // 1000,
                        end_ms=ts_end // 1000,
                    )
                )
                overlay_effects.append(effect)
                log.debug("inverter_overlay", text=text[:30])

    # --- Pass 3: assemble Cut objects ------------------------------------
    cuts: list[Cut] = []
    for i, (seg, mat, t_start, t_end) in enumerate(cut_data):
        src_start_us: int = seg["source_timerange"]["start"]
        src_end_us: int = src_start_us + seg["source_timerange"]["duration"]
        target_dur_us: int = t_end - t_start
        speed: float = seg.get("speed", 1.0)

        clip = seg.get("clip", {})
        mat_w: int = mat.get("width", canvas_w)
        mat_h: int = mat.get("height", canvas_h)
        crop = _reverse_crop(clip, mat_w, mat_h, canvas_w, canvas_h)

        src_file = Path(mat.get("path", "")).name or f"clip_{i + 1}"
        caption, _ = cut_captions.get(i, (None, None))

        cuts.append(
            Cut(
                order=i + 1,
                source_segment_id="human_edit",
                source_file=src_file,
                **{"in": _us_to_mmss(src_start_us)},
                out=_us_to_mmss(src_end_us),
                duration_sec=round(target_dur_us / 1_000_000, 3),
                role="unknown",
                crop=crop,
                speed=speed,
                transition_in="cut",
                transition_out=_detect_transition(seg, transition_lookup),
                caption=caption,
            )
        )

    total_us: int = cut_timeline_ranges[-1][1] if cut_timeline_ranges else 0
    cut_list = CutList(
        reel_meta=ReelMeta(
            title=draft_dir.name,
            target_duration_sec=round(total_us / 1_000_000, 1),
            aspect_ratio=f"{canvas_w}:{canvas_h}",
            tone="unknown",
        ),
        cuts=cuts,
        alt_captions=[],
        overlays=overlays,
    )

    # --- Build ReelStyleSheet --------------------------------------------
    cut_styles: list[CutStyle] = []
    for i, (seg, _mat, _ts, _te) in enumerate(cut_data):
        transition_type = _detect_transition(seg, transition_lookup)
        _, cap_effect = cut_captions.get(i, (None, None))
        cut_styles.append(
            CutStyle(
                cut_order=i + 1,
                transition_out=(
                    TransitionSpec(type=transition_type)
                    if transition_type != "cut"
                    else None
                ),
                caption_effect=cap_effect,
            )
        )

    reel_style_sheet = ReelStyleSheet(
        cut_styles=cut_styles,
        overlay_styles=[
            OverlayStyle(overlay_index=i, caption_effect=eff)
            for i, eff in enumerate(overlay_effects)
        ],
    )

    log.info(
        "inverter_parsed",
        draft=draft_dir.name,
        cuts=len(cuts),
        captions=len(cut_captions),
        overlays=len(overlays),
    )
    return cut_list, reel_style_sheet