Spaces:
Sleeping
Sleeping
| 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) |