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