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)