import json, zlib, os, sys, uuid, hashlib, datetime, re from typing import List, Dict, Optional def load_clipchamp(path: str) -> Dict: with open(path, "rb") as f: raw = f.read() try: data = zlib.decompress(raw).decode("utf-8") except Exception as e: raise RuntimeError(f"Failed to decompress {path}: {e}") return json.loads(data) def save_clipchamp(obj: Dict, outpath: str) -> None: jb = json.dumps(obj, indent=2, ensure_ascii=False).encode("utf-8") with open(outpath, "wb") as f: f.write(zlib.compress(jb)) def _now_iso_ms() -> str: return datetime.datetime.utcnow().isoformat(timespec="milliseconds") + "Z" _DETERMINISTIC_NAMESPACE = uuid.UUID("22222222-3333-4444-5555-666666666666") def deterministic_id(seed: str) -> str: return str(uuid.uuid5(_DETERMINISTIC_NAMESPACE, seed)) # Strips .0 from floats to match JavaScript integer serialization def clean_num(val): if isinstance(val, (int, float)): return int(val) if float(val).is_integer() else float(val) return val def apply_edits_to_clipchamp_template( template_path: str, edits: List[Dict], out_path: str, track_label: str = "video_track_1", make_item_ids_deterministic: bool = True, ) -> str: project = load_clipchamp(template_path) if "assets" not in project or "byId" not in project["assets"]: raise ValueError("Template does not contain expected 'assets.byId' structure.") assets_by_id = project["assets"]["byId"] basename_map = {} uri_basename_map = {} hash_map = {} for aid, aobj in assets_by_id.items(): name = aobj.get("name") if name: basename_map.setdefault(name, aid) ext = aobj.get("extData") or {} uri = ext.get("uri") if isinstance(uri, str): bn = os.path.basename(uri) uri_basename_map.setdefault(bn, aid) fh = aobj.get("fileHashes", {}) or {} for hval in fh.values(): if hval: hash_map.setdefault(hval, aid) if "items" not in project: project["items"] = {"byId": {}, "allIds":[], "transitions": {}} items_by_id = project["items"].setdefault("byId", {}) items_allids = project["items"].setdefault("allIds",[]) project["items"].setdefault("transitions", {}) if "tracks" not in project: project["tracks"] = {"byId": {}, "allIds":[]} tracks_by_id = project["tracks"].setdefault("byId", {}) tracks_allids = project["tracks"].setdefault("allIds",[]) chosen_track_id = None for tid, tobj in tracks_by_id.items(): if tobj.get("type") == "video": chosen_track_id = tid break if chosen_track_id is None: project_id_seed = project.get("id") or os.path.basename(template_path) chosen_track_id = deterministic_id(f"{project_id_seed}:{track_label}") track_obj = { "id": chosen_track_id, "hidden": False, "type": "video", "itemIds":[], "transitionIds":[] } tracks_by_id[chosen_track_id] = track_obj tracks_allids.append(chosen_track_id) if "timeline" in project: del project["timeline"] cursor = 0.0 for item in items_by_id.values(): if item.get("trackId") == chosen_track_id: end_time = item.get("startTime", 0.0) + item.get("duration", 0.0) if end_time > cursor: cursor = end_time for idx, e in enumerate(edits, start=1): source_spec = e.get("source") if source_spec is None: continue matched_aid = None if source_spec in assets_by_id: matched_aid = source_spec if matched_aid is None and source_spec in basename_map: matched_aid = basename_map[source_spec] if matched_aid is None and source_spec in uri_basename_map: matched_aid = uri_basename_map[source_spec] if matched_aid is None and source_spec in hash_map: matched_aid = hash_map[source_spec] if matched_aid is None: s_bn = os.path.basename(source_spec) cleaned = re.sub(r'^(source|style|template)_', '', s_bn) cleaned = re.sub(r'_[a-f0-9]{8}(\.[a-z0-9]+)$', r'\1', cleaned, flags=re.IGNORECASE) if cleaned in basename_map: matched_aid = basename_map[cleaned] elif cleaned in uri_basename_map: matched_aid = uri_basename_map[cleaned] if matched_aid is None: s_bn = os.path.basename(source_spec).lower() for asset_name, aid in basename_map.items(): if asset_name.lower() in s_bn or s_bn in asset_name.lower(): matched_aid = aid break if matched_aid is None: raise ValueError(f"Could not match source '{source_spec}' to any asset.") # --- NATIVE MATH (EXACT PRECISION, NO ROUNDING) --- matched_asset = assets_by_id.get(matched_aid, {}) # Extract exact duration float, DO NOT TRUNCATE asset_duration = float(matched_asset.get("metadata", {}).get("duration", 0.0)) if asset_duration == 0.0: asset_duration = float(e.get("end_time", 1.0)) start = float(e.get("start_time", 0.0)) end = float(e.get("end_time", start)) speed = float(e.get("speed", 1.0)) # Absolute bounds clamping so we NEVER request more media than exists start = max(0.0, min(start, asset_duration)) end = max(start, min(end, asset_duration)) active_media_duration = end - start timeline_duration = active_media_duration / speed trim_from = start trim_to = max(0.0, asset_duration - end) if make_item_ids_deterministic: seed = f"{project.get('id') or template_path}:item:{matched_aid}:{start}:{end}:{idx}" item_id = deterministic_id(seed) else: item_id = str(uuid.uuid4()) # Exact structure mapped from editsdone.json item_obj = { "id": item_id, "trim": { "from": clean_num(trim_from), "to": clean_num(trim_to) }, "speed": clean_num(speed), "startTime": clean_num(cursor), "trackId": chosen_track_id, "transform": { "strategy": "freehand", "hasFixedBounds": True }, "disableCaptions": False, "duration": clean_num(timeline_duration), "assetId": matched_aid } # Match editor behavior: only include volume key if it's altered vol = 0 if e.get("mute") else float(e.get("volume", 1.0)) if vol != 1.0: item_obj["volume"] = clean_num(vol) items_by_id[item_id] = item_obj if item_id not in items_allids: items_allids.append(item_id) tracks_by_id[chosen_track_id].setdefault("itemIds",[]).append(item_id) # Advance cursor un-rounded to prevent timeline drift cursor += timeline_duration # Bump version to force editor to accept the new state project["version"] = project.get("version", 0) + 1 project["updatedAt"] = _now_iso_ms() save_clipchamp(project, out_path) return out_path def main_cli(argv): if len(argv) < 3: print("Usage: python patch_clipchamp_with_edits.py TEMPLATE.clipchamp OUT.clipchamp") return template = argv[1] out = argv[2] print("Enter edits JSON (list of edit objects), or press Ctrl-D to abort:") try: raw = sys.stdin.read() if not raw.strip(): print("No edits provided; aborting.") return edits = json.loads(raw) except Exception as e: print("Failed to read edits JSON from stdin:", e) return try: outpath = apply_edits_to_clipchamp_template(template, edits, out) print("Wrote patched clipchamp to:", outpath) except Exception as exc: print("Failed to apply edits:", exc) if __name__ == "__main__": main_cli(sys.argv)