File size: 10,159 Bytes
ad048b8
 
 
dfdb4a0
 
ad048b8
 
 
e089525
 
 
dfdb4a0
 
 
 
 
e089525
dfdb4a0
 
 
ad048b8
dfdb4a0
 
ad048b8
 
dfdb4a0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ad048b8
 
 
 
 
 
 
dfdb4a0
ad048b8
 
dfdb4a0
 
 
ad048b8
dfdb4a0
e089525
 
 
ad048b8
dfdb4a0
ad048b8
dfdb4a0
e089525
dfdb4a0
ad048b8
dfdb4a0
ad048b8
 
dfdb4a0
ad048b8
 
 
 
 
 
dfdb4a0
 
 
 
ad048b8
 
 
e089525
 
dfdb4a0
 
e089525
dfdb4a0
 
 
 
 
ad048b8
dfdb4a0
 
 
ad048b8
 
dfdb4a0
 
 
 
 
 
ad048b8
 
 
 
 
dfdb4a0
 
 
 
 
 
 
 
 
e089525
dfdb4a0
ad048b8
 
 
dfdb4a0
 
ad048b8
 
 
 
dfdb4a0
ad048b8
 
 
e089525
dfdb4a0
e089525
 
dfdb4a0
 
 
 
 
 
 
 
 
 
 
e089525
ad048b8
dfdb4a0
 
 
 
 
 
 
 
 
 
 
 
 
ad048b8
 
e089525
ad048b8
dfdb4a0
ad048b8
e089525
 
ad048b8
e089525
dfdb4a0
 
ad048b8
dfdb4a0
 
 
 
 
e089525
dfdb4a0
 
 
 
 
e089525
ad048b8
 
 
 
dfdb4a0
ad048b8
 
dfdb4a0
e089525
 
dfdb4a0
 
 
 
 
 
 
 
ad048b8
dfdb4a0
ad048b8
 
dfdb4a0
ad048b8
 
e089525
 
dfdb4a0
 
 
ad048b8
 
 
 
 
 
 
 
e089525
dfdb4a0
 
e089525
 
 
 
ad048b8
e089525
ad048b8
 
 
e089525
 
 
dfdb4a0
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
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
import os
import json
import zlib
import uuid
import hashlib
import datetime
import mimetypes
from typing import Dict, Optional

CLIPCHAMP_OUTPUT_FOLDER = os.environ.get("CLIPCHAMP_OUTPUT_FOLDER", "clipchamp_projects")

# -------------------------
# Configuration / Namespace
# -------------------------
# Use a fixed namespace UUID so uuid5 outputs are deterministic across runs/machines.
_DETERMINISTIC_NAMESPACE = uuid.UUID("11111111-2222-3333-4444-555555555555")

# -------------------------
# Helpers
# -------------------------
def _now_iso():
    # milliseconds precision
    return datetime.datetime.utcnow().isoformat(timespec="milliseconds") + "Z"


def _file_meta(path):
    size = None
    mime = None
    if path:
        # Accept URIs like file:///... or plain paths
        if path.startswith("file://"):
            p = path[7:]
        else:
            p = path
        try:
            if os.path.exists(p):
                size = os.path.getsize(p)
        except Exception:
            size = None
        try:
            mime = mimetypes.guess_type(p)[0]
        except Exception:
            mime = None
    return {"size": size, "mimeType": mime}


def _sha1_of_file(path):
    if not path:
        return None
    if path.startswith("file://"):
        p = path[7:]
    else:
        p = path
    try:
        if not os.path.exists(p):
            return None
        h = hashlib.sha1()
        with open(p, "rb") as f:
            for chunk in iter(lambda: f.read(8192), b""):
                h.update(chunk)
        return h.hexdigest()
    except Exception:
        return None


def _guess_type(mime, filename):
    if mime and mime.startswith("video"):
        return "video"
    ext = os.path.splitext(filename)[1].lower()
    if ext in (".mp4", ".mov", ".m4v", ".avi", ".webm"):
        return "video"
    if ext in (".mp3", ".wav", ".aac"):
        return "audio"
    if ext in (".png", ".jpg", ".jpeg", ".gif", ".webp"):
        return "image"
    return "file"


# deterministic id functions (uuid5)
def deterministic_project_id(project_name: str) -> str:
    return str(uuid.uuid5(_DETERMINISTIC_NAMESPACE, f"project:{project_name}"))


def deterministic_asset_id(key_or_path: str) -> str:
    # key_or_path should be unique per asset (e.g. "video_1" or absolute path)
    return str(uuid.uuid5(_DETERMINISTIC_NAMESPACE, f"asset:{key_or_path}"))


def deterministic_item_id(project_id: str, index: int, source_key: str, start_ms: Optional[int]) -> str:
    base = f"item:{project_id}:{index}:{source_key}:{start_ms}"
    return str(uuid.uuid5(_DETERMINISTIC_NAMESPACE, base))


def deterministic_track_id(project_id: str, track_label: str = "video_track_1") -> str:
    return str(uuid.uuid5(_DETERMINISTIC_NAMESPACE, f"track:{project_id}:{track_label}"))


# -------------------------
# Main builder
# -------------------------
def build_clipchamp_bytes(
    project_name: str,
    ai_json: Dict,
    source_to_filename: Dict,
    source_to_duration: Dict = None,
    aspect_ratio: Optional[str] = "16:9",
    render_settings: Optional[Dict] = None,
    schema_version: str = "8.6.4",
) -> bytes:
    """
    Build a zlib-compressed JSON blob that closely matches Clipchamp's export shape.
    Deterministic IDs are produced via uuid5 so repeated runs with identical inputs
    will produce stable IDs.
    """

    if source_to_duration is None:
        source_to_duration = {}

    created_at = _now_iso()
    project_id = deterministic_project_id(project_name)

    # Base project skeleton using keys observed in Clipchamp exports
    project = {
        "version": 7,
        "schemaVersion": schema_version,
        "id": project_id,
        "name": project_name,
        "createdAt": created_at,
        "updatedAt": created_at,
        "aspectRatio": aspect_ratio,
        "renderSettings": render_settings or {
            "format": "mp4",
            "resolution": {"width": 1920, "height": 1080},
            "frameRate": 30
        },
        # keep containers expected by Clipchamp
        "entities": {},
        "flavor": {},
        "groups": {},
        "assets": {"byId": {}, "allIds": []},
        "items": {"byId": {}, "allIds": []},
        "tracks": {"byId": {}, "allIds": []},
    }

    # Build assets deterministically
    asset_id_map = {}
    for key, fname in source_to_filename.items():
        # Use provided key (if available) to derive deterministic ID; fallback to filename
        id_seed = key if key else (os.path.basename(fname) if fname else str(uuid.uuid4()))
        aid = deterministic_asset_id(id_seed)
        asset_id_map[key] = aid

        meta = _file_meta(fname)
        sha1 = _sha1_of_file(fname)
        filename = os.path.basename(fname) if fname else (key or "unknown")

        asset_obj = {
            "id": aid,
            "name": filename,
            "type": _guess_type(meta["mimeType"], filename),
            "loop": False,
            "hidden": False,
            # include sha1 if available
            "fileHashes": {"sha1": sha1} if sha1 else {},
            "metadata": {
                "duration": source_to_duration.get(key),
                "size": meta["size"],
                "mimeType": meta["mimeType"],
            },
            # richer extData: preserve originalFileName, uri, importedAt and importedSize
            "extData": {
                "originalFileName": filename,
                "uri": fname,
                "importedAt": created_at,
                "importedSize": meta["size"],
                # keep a deterministic file id too (useful for Clipchamp internals)
                "fileId": deterministic_asset_id(f"file:{filename}:{meta.get('size')}"),
            },
        }

        project["assets"]["byId"][aid] = asset_obj
        project["assets"]["allIds"].append(aid)

    # Single default video track (deterministic id)
    track_id = deterministic_track_id(project_id, "video_track_1")
    project["tracks"]["byId"][track_id] = {
        "id": track_id,
        "type": "video",
        "index": 0,
        "items": [],
        "muted": False
    }
    project["tracks"]["allIds"].append(track_id)

    # Build timeline items from ai_json['clips'] preserving order
    sorted_clips = sorted(ai_json.get("clips", []), key=lambda c: (c.get("order") or 0))
    cursor = 0.0

    for idx, c in enumerate(sorted_clips, start=1):
        # Defensive conversions - ensure fields exist
        source_key = c.get("source")
        if source_key is None:
            # skip invalid entry
            continue

        # start/end times may be provided as floats or strings
        start = float(c.get("start_time", 0.0))
        end = float(c.get("end_time", start))
        duration = max(0.0, end - start)

        # deterministic item id based on project, index, source and source start (ms)
        start_ms = int(c.get("start_time_ms", round(start * 1000)))
        item_id = deterministic_item_id(project_id, idx, source_key, start_ms)

        # map asset id
        asset_id = asset_id_map.get(source_key)
        # fallback: try to find asset that matches filename basename
        if asset_id is None:
            # try match by basename
            for k, fname in source_to_filename.items():
                if k == source_key or os.path.basename(fname) == source_key:
                    asset_id = asset_id_map.get(k)
                    break

        item_obj = {
            "id": item_id,
            "assetId": asset_id,
            "type": "timeline_item",
            # position on timeline (seconds)
            "startTime": cursor,
            "duration": duration,
            # trim ranges on the source asset (seconds)
            "trim": {"from": start, "to": end},
            # source ms fields
            "source_start_ms": start_ms,
            "source_end_ms": int(c.get("end_time_ms", round(end * 1000))),
            "speed": float(c.get("speed", 1.0)),
            "volume": 0.0 if c.get("mute") else float(c.get("volume", 1.0)),
            "enabled": bool(c.get("enabled", True)),
            "locked": bool(c.get("locked", False)),
            "position": c.get("position", {"x": 0, "y": 0, "scale": 1.0, "rotation": 0}),
            "order": c.get("order"),
            # extData for traceability: include original clip payload and deterministic local id
            "extData": {
                "orig_source_key": source_key,
                "orig_index": idx,
            }
        }

        project["items"]["byId"][item_id] = item_obj
        project["items"]["allIds"].append(item_id)

        # add to track
        project["tracks"]["byId"][track_id]["items"].append(item_id)

        # advance cursor
        cursor += duration

    # timeline (duration and per-track mapping)
    project["timeline"] = {"duration": cursor, "tracks": {}}
    for tid, t in project["tracks"]["byId"].items():
        project["timeline"]["tracks"][tid] = {
            "id": tid,
            "items": t["items"],
            "duration": cursor
        }

    # finalize updated timestamp
    project["updatedAt"] = _now_iso()

    # stable pretty JSON
    json_bytes = json.dumps(project, indent=2, ensure_ascii=False).encode("utf-8")
    return zlib.compress(json_bytes)


# -------------------------
# File-saving wrapper
# -------------------------
def save_clipchamp_project(
    project_name: str,
    ai_json: Dict,
    source_to_filename: Dict,
    source_to_duration: Dict = None,
    output_folder: str = None,
    **build_kwargs
) -> str:
    """
    Build a .clipchamp project from AI JSON and save it to disk.
    Returns absolute path of saved file.
    """
    folder = output_folder or CLIPCHAMP_OUTPUT_FOLDER
    os.makedirs(folder, exist_ok=True)

    clip_bytes = build_clipchamp_bytes(project_name, ai_json, source_to_filename, source_to_duration, **build_kwargs)

    # sanitize project_name for filename
    safe_name = "".join(c if c.isalnum() or c in (" ", "_", "-") else "_" for c in project_name).strip()
    out_path = os.path.join(folder, f"{safe_name}.clipchamp")
    with open(out_path, "wb") as f:
        f.write(clip_bytes)

    return os.path.abspath(out_path)