Spaces:
Sleeping
Sleeping
File size: 8,116 Bytes
9584232 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 | 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) |