AutoVideoEditor / patch_clipchamp_with_edits.py
Tecnhotron
BIG CHANGES
9584232
raw
history blame
8.12 kB
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)