SolarumAsteridion commited on
Commit
dfdb4a0
·
verified ·
1 Parent(s): ad048b8

Update clipchamp_export.py

Browse files
Files changed (1) hide show
  1. clipchamp_export.py +186 -69
clipchamp_export.py CHANGED
@@ -1,19 +1,103 @@
1
- # clipchamp_export.py (improved)
2
  import os
3
  import json
4
  import zlib
5
- import time
 
6
  import datetime
7
  import mimetypes
8
  from typing import Dict, Optional
9
 
10
  CLIPCHAMP_OUTPUT_FOLDER = os.environ.get("CLIPCHAMP_OUTPUT_FOLDER", "clipchamp_projects")
11
 
 
 
 
 
 
12
 
 
 
 
13
  def _now_iso():
14
- return datetime.datetime.utcnow().replace(microsecond=0).isoformat() + "Z"
 
15
 
16
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
  def build_clipchamp_bytes(
18
  project_name: str,
19
  ai_json: Dict,
@@ -21,144 +105,177 @@ def build_clipchamp_bytes(
21
  source_to_duration: Dict = None,
22
  aspect_ratio: Optional[str] = "16:9",
23
  render_settings: Optional[Dict] = None,
24
- schema_version: str = "1.0.0",
25
  ) -> bytes:
26
  """
27
- Serialize AI editing plan into a zlib-compressed .clipchamp project file.
28
- Produces a richer structure (assets.allIds, items.allIds, tracks, createdAt/updatedAt, renderSettings).
 
29
  """
 
30
  if source_to_duration is None:
31
  source_to_duration = {}
32
 
33
  created_at = _now_iso()
34
- updated_at = created_at
35
 
36
- # Base project skeleton (richer metadata than the minimal version)
37
  project = {
38
- "version": "1.0",
39
  "schemaVersion": schema_version,
 
40
  "name": project_name,
41
  "createdAt": created_at,
42
- "updatedAt": updated_at,
43
  "aspectRatio": aspect_ratio,
44
  "renderSettings": render_settings or {
45
  "format": "mp4",
46
  "resolution": {"width": 1920, "height": 1080},
47
  "frameRate": 30
48
  },
49
- "metadata": {"description": ai_json.get("description"), "projectName": project_name},
50
- # assets/items/tracks use both byId and allIds for predictable ordering
 
 
51
  "assets": {"byId": {}, "allIds": []},
52
  "items": {"byId": {}, "allIds": []},
53
  "tracks": {"byId": {}, "allIds": []},
54
- # timeline will include tracks and duration
55
- "timeline": {"duration": 0, "tracks": {}}
56
  }
57
 
58
- # Helper to resolve file metadata when available
59
- def _file_meta(path):
60
- try:
61
- if os.path.exists(path):
62
- size = os.path.getsize(path)
63
- else:
64
- size = None
65
- except Exception:
66
- size = None
67
- mime = mimetypes.guess_type(path)[0] or None
68
- return {"size": size, "mimeType": mime}
69
-
70
- # Build assets
71
  for key, fname in source_to_filename.items():
72
- aid = f"asset_{key}"
 
 
 
 
73
  meta = _file_meta(fname)
 
 
 
74
  asset_obj = {
75
  "id": aid,
76
- "fileName": os.path.basename(fname),
77
- "originalFileName": os.path.basename(fname),
78
- "assetKey": key,
 
 
 
79
  "metadata": {
80
  "duration": source_to_duration.get(key),
81
  "size": meta["size"],
82
  "mimeType": meta["mimeType"],
83
  },
84
- # uri kept as given so Clipchamp can find it; you can change to absolute path if you prefer
85
- "uri": fname,
86
- # importedSize kept for compatibility with some exports
87
- "importedSize": meta["size"],
 
 
 
 
 
88
  }
 
89
  project["assets"]["byId"][aid] = asset_obj
90
  project["assets"]["allIds"].append(aid)
91
 
92
- # single video track for now (expandable)
93
- track_id = "track_1"
94
  project["tracks"]["byId"][track_id] = {
95
  "id": track_id,
96
  "type": "video",
97
  "index": 0,
98
- "items": [], # ordered list of item IDs on this track
99
  "muted": False
100
  }
101
  project["tracks"]["allIds"].append(track_id)
102
 
103
- # Build timeline items in order
104
  sorted_clips = sorted(ai_json.get("clips", []), key=lambda c: (c.get("order") or 0))
105
  cursor = 0.0
106
- for n, c in enumerate(sorted_clips, start=1):
107
- # ensure types
108
- start = float(c["start_time"])
109
- end = float(c["end_time"])
 
 
 
 
 
 
 
110
  duration = max(0.0, end - start)
111
 
112
- item_id = f"item_{n}"
113
- asset_id = f"asset_{c['source']}"
 
 
 
 
 
 
 
 
 
 
 
114
 
115
  item_obj = {
116
  "id": item_id,
117
  "assetId": asset_id,
118
- "trackId": track_id,
119
  # position on timeline (seconds)
120
  "startTime": cursor,
121
  "duration": duration,
122
  # trim ranges on the source asset (seconds)
123
  "trim": {"from": start, "to": end},
124
- # source ms fields for accuracy
125
- "source_start_ms": int(c.get("start_time_ms", round(start * 1000))),
126
  "source_end_ms": int(c.get("end_time_ms", round(end * 1000))),
127
- # playback settings
128
- "speed": 1.0,
129
- "volume": 0.0 if c.get("mute") else 1.0,
130
- # boolean flags commonly present in project files
131
- "enabled": True,
132
- "locked": False,
133
- # decorative/position data (Clipchamp sometimes stores this)
134
- "position": {"x": 0, "y": 0, "scale": 1.0, "rotation": 0},
135
  "order": c.get("order"),
 
 
 
 
 
136
  }
137
 
138
  project["items"]["byId"][item_id] = item_obj
139
  project["items"]["allIds"].append(item_id)
140
 
141
- # append item id to track ordering
142
  project["tracks"]["byId"][track_id]["items"].append(item_id)
143
 
 
144
  cursor += duration
145
 
146
- # timeline info
147
- project["timeline"]["duration"] = cursor
148
- project["timeline"]["tracks"][track_id] = {
149
- "id": track_id,
150
- "items": project["tracks"]["byId"][track_id]["items"],
151
- "duration": cursor,
152
- }
 
153
 
154
- # update updatedAt timestamp
155
  project["updatedAt"] = _now_iso()
156
 
157
- # ensure stable pretty JSON to ease differences/debugging
158
  json_bytes = json.dumps(project, indent=2, ensure_ascii=False).encode("utf-8")
159
  return zlib.compress(json_bytes)
160
 
161
 
 
 
 
162
  def save_clipchamp_project(
163
  project_name: str,
164
  ai_json: Dict,
@@ -168,8 +285,8 @@ def save_clipchamp_project(
168
  **build_kwargs
169
  ) -> str:
170
  """
171
- Build a .clipchamp project from the AI JSON and save it to a local folder.
172
- Returns the absolute path of the saved file.
173
  """
174
  folder = output_folder or CLIPCHAMP_OUTPUT_FOLDER
175
  os.makedirs(folder, exist_ok=True)
@@ -182,4 +299,4 @@ def save_clipchamp_project(
182
  with open(out_path, "wb") as f:
183
  f.write(clip_bytes)
184
 
185
- return os.path.abspath(out_path)
 
 
1
  import os
2
  import json
3
  import zlib
4
+ import uuid
5
+ import hashlib
6
  import datetime
7
  import mimetypes
8
  from typing import Dict, Optional
9
 
10
  CLIPCHAMP_OUTPUT_FOLDER = os.environ.get("CLIPCHAMP_OUTPUT_FOLDER", "clipchamp_projects")
11
 
12
+ # -------------------------
13
+ # Configuration / Namespace
14
+ # -------------------------
15
+ # Use a fixed namespace UUID so uuid5 outputs are deterministic across runs/machines.
16
+ _DETERMINISTIC_NAMESPACE = uuid.UUID("11111111-2222-3333-4444-555555555555")
17
 
18
+ # -------------------------
19
+ # Helpers
20
+ # -------------------------
21
  def _now_iso():
22
+ # milliseconds precision
23
+ return datetime.datetime.utcnow().isoformat(timespec="milliseconds") + "Z"
24
 
25
 
26
+ def _file_meta(path):
27
+ size = None
28
+ mime = None
29
+ if path:
30
+ # Accept URIs like file:///... or plain paths
31
+ if path.startswith("file://"):
32
+ p = path[7:]
33
+ else:
34
+ p = path
35
+ try:
36
+ if os.path.exists(p):
37
+ size = os.path.getsize(p)
38
+ except Exception:
39
+ size = None
40
+ try:
41
+ mime = mimetypes.guess_type(p)[0]
42
+ except Exception:
43
+ mime = None
44
+ return {"size": size, "mimeType": mime}
45
+
46
+
47
+ def _sha1_of_file(path):
48
+ if not path:
49
+ return None
50
+ if path.startswith("file://"):
51
+ p = path[7:]
52
+ else:
53
+ p = path
54
+ try:
55
+ if not os.path.exists(p):
56
+ return None
57
+ h = hashlib.sha1()
58
+ with open(p, "rb") as f:
59
+ for chunk in iter(lambda: f.read(8192), b""):
60
+ h.update(chunk)
61
+ return h.hexdigest()
62
+ except Exception:
63
+ return None
64
+
65
+
66
+ def _guess_type(mime, filename):
67
+ if mime and mime.startswith("video"):
68
+ return "video"
69
+ ext = os.path.splitext(filename)[1].lower()
70
+ if ext in (".mp4", ".mov", ".m4v", ".avi", ".webm"):
71
+ return "video"
72
+ if ext in (".mp3", ".wav", ".aac"):
73
+ return "audio"
74
+ if ext in (".png", ".jpg", ".jpeg", ".gif", ".webp"):
75
+ return "image"
76
+ return "file"
77
+
78
+
79
+ # deterministic id functions (uuid5)
80
+ def deterministic_project_id(project_name: str) -> str:
81
+ return str(uuid.uuid5(_DETERMINISTIC_NAMESPACE, f"project:{project_name}"))
82
+
83
+
84
+ def deterministic_asset_id(key_or_path: str) -> str:
85
+ # key_or_path should be unique per asset (e.g. "video_1" or absolute path)
86
+ return str(uuid.uuid5(_DETERMINISTIC_NAMESPACE, f"asset:{key_or_path}"))
87
+
88
+
89
+ def deterministic_item_id(project_id: str, index: int, source_key: str, start_ms: Optional[int]) -> str:
90
+ base = f"item:{project_id}:{index}:{source_key}:{start_ms}"
91
+ return str(uuid.uuid5(_DETERMINISTIC_NAMESPACE, base))
92
+
93
+
94
+ def deterministic_track_id(project_id: str, track_label: str = "video_track_1") -> str:
95
+ return str(uuid.uuid5(_DETERMINISTIC_NAMESPACE, f"track:{project_id}:{track_label}"))
96
+
97
+
98
+ # -------------------------
99
+ # Main builder
100
+ # -------------------------
101
  def build_clipchamp_bytes(
102
  project_name: str,
103
  ai_json: Dict,
 
105
  source_to_duration: Dict = None,
106
  aspect_ratio: Optional[str] = "16:9",
107
  render_settings: Optional[Dict] = None,
108
+ schema_version: str = "8.6.4",
109
  ) -> bytes:
110
  """
111
+ Build a zlib-compressed JSON blob that closely matches Clipchamp's export shape.
112
+ Deterministic IDs are produced via uuid5 so repeated runs with identical inputs
113
+ will produce stable IDs.
114
  """
115
+
116
  if source_to_duration is None:
117
  source_to_duration = {}
118
 
119
  created_at = _now_iso()
120
+ project_id = deterministic_project_id(project_name)
121
 
122
+ # Base project skeleton using keys observed in Clipchamp exports
123
  project = {
124
+ "version": 7,
125
  "schemaVersion": schema_version,
126
+ "id": project_id,
127
  "name": project_name,
128
  "createdAt": created_at,
129
+ "updatedAt": created_at,
130
  "aspectRatio": aspect_ratio,
131
  "renderSettings": render_settings or {
132
  "format": "mp4",
133
  "resolution": {"width": 1920, "height": 1080},
134
  "frameRate": 30
135
  },
136
+ # keep containers expected by Clipchamp
137
+ "entities": {},
138
+ "flavor": {},
139
+ "groups": {},
140
  "assets": {"byId": {}, "allIds": []},
141
  "items": {"byId": {}, "allIds": []},
142
  "tracks": {"byId": {}, "allIds": []},
 
 
143
  }
144
 
145
+ # Build assets deterministically
146
+ asset_id_map = {}
 
 
 
 
 
 
 
 
 
 
 
147
  for key, fname in source_to_filename.items():
148
+ # Use provided key (if available) to derive deterministic ID; fallback to filename
149
+ id_seed = key if key else (os.path.basename(fname) if fname else str(uuid.uuid4()))
150
+ aid = deterministic_asset_id(id_seed)
151
+ asset_id_map[key] = aid
152
+
153
  meta = _file_meta(fname)
154
+ sha1 = _sha1_of_file(fname)
155
+ filename = os.path.basename(fname) if fname else (key or "unknown")
156
+
157
  asset_obj = {
158
  "id": aid,
159
+ "name": filename,
160
+ "type": _guess_type(meta["mimeType"], filename),
161
+ "loop": False,
162
+ "hidden": False,
163
+ # include sha1 if available
164
+ "fileHashes": {"sha1": sha1} if sha1 else {},
165
  "metadata": {
166
  "duration": source_to_duration.get(key),
167
  "size": meta["size"],
168
  "mimeType": meta["mimeType"],
169
  },
170
+ # richer extData: preserve originalFileName, uri, importedAt and importedSize
171
+ "extData": {
172
+ "originalFileName": filename,
173
+ "uri": fname,
174
+ "importedAt": created_at,
175
+ "importedSize": meta["size"],
176
+ # keep a deterministic file id too (useful for Clipchamp internals)
177
+ "fileId": deterministic_asset_id(f"file:{filename}:{meta.get('size')}"),
178
+ },
179
  }
180
+
181
  project["assets"]["byId"][aid] = asset_obj
182
  project["assets"]["allIds"].append(aid)
183
 
184
+ # Single default video track (deterministic id)
185
+ track_id = deterministic_track_id(project_id, "video_track_1")
186
  project["tracks"]["byId"][track_id] = {
187
  "id": track_id,
188
  "type": "video",
189
  "index": 0,
190
+ "items": [],
191
  "muted": False
192
  }
193
  project["tracks"]["allIds"].append(track_id)
194
 
195
+ # Build timeline items from ai_json['clips'] preserving order
196
  sorted_clips = sorted(ai_json.get("clips", []), key=lambda c: (c.get("order") or 0))
197
  cursor = 0.0
198
+
199
+ for idx, c in enumerate(sorted_clips, start=1):
200
+ # Defensive conversions - ensure fields exist
201
+ source_key = c.get("source")
202
+ if source_key is None:
203
+ # skip invalid entry
204
+ continue
205
+
206
+ # start/end times may be provided as floats or strings
207
+ start = float(c.get("start_time", 0.0))
208
+ end = float(c.get("end_time", start))
209
  duration = max(0.0, end - start)
210
 
211
+ # deterministic item id based on project, index, source and source start (ms)
212
+ start_ms = int(c.get("start_time_ms", round(start * 1000)))
213
+ item_id = deterministic_item_id(project_id, idx, source_key, start_ms)
214
+
215
+ # map asset id
216
+ asset_id = asset_id_map.get(source_key)
217
+ # fallback: try to find asset that matches filename basename
218
+ if asset_id is None:
219
+ # try match by basename
220
+ for k, fname in source_to_filename.items():
221
+ if k == source_key or os.path.basename(fname) == source_key:
222
+ asset_id = asset_id_map.get(k)
223
+ break
224
 
225
  item_obj = {
226
  "id": item_id,
227
  "assetId": asset_id,
228
+ "type": "timeline_item",
229
  # position on timeline (seconds)
230
  "startTime": cursor,
231
  "duration": duration,
232
  # trim ranges on the source asset (seconds)
233
  "trim": {"from": start, "to": end},
234
+ # source ms fields
235
+ "source_start_ms": start_ms,
236
  "source_end_ms": int(c.get("end_time_ms", round(end * 1000))),
237
+ "speed": float(c.get("speed", 1.0)),
238
+ "volume": 0.0 if c.get("mute") else float(c.get("volume", 1.0)),
239
+ "enabled": bool(c.get("enabled", True)),
240
+ "locked": bool(c.get("locked", False)),
241
+ "position": c.get("position", {"x": 0, "y": 0, "scale": 1.0, "rotation": 0}),
 
 
 
242
  "order": c.get("order"),
243
+ # extData for traceability: include original clip payload and deterministic local id
244
+ "extData": {
245
+ "orig_source_key": source_key,
246
+ "orig_index": idx,
247
+ }
248
  }
249
 
250
  project["items"]["byId"][item_id] = item_obj
251
  project["items"]["allIds"].append(item_id)
252
 
253
+ # add to track
254
  project["tracks"]["byId"][track_id]["items"].append(item_id)
255
 
256
+ # advance cursor
257
  cursor += duration
258
 
259
+ # timeline (duration and per-track mapping)
260
+ project["timeline"] = {"duration": cursor, "tracks": {}}
261
+ for tid, t in project["tracks"]["byId"].items():
262
+ project["timeline"]["tracks"][tid] = {
263
+ "id": tid,
264
+ "items": t["items"],
265
+ "duration": cursor
266
+ }
267
 
268
+ # finalize updated timestamp
269
  project["updatedAt"] = _now_iso()
270
 
271
+ # stable pretty JSON
272
  json_bytes = json.dumps(project, indent=2, ensure_ascii=False).encode("utf-8")
273
  return zlib.compress(json_bytes)
274
 
275
 
276
+ # -------------------------
277
+ # File-saving wrapper
278
+ # -------------------------
279
  def save_clipchamp_project(
280
  project_name: str,
281
  ai_json: Dict,
 
285
  **build_kwargs
286
  ) -> str:
287
  """
288
+ Build a .clipchamp project from AI JSON and save it to disk.
289
+ Returns absolute path of saved file.
290
  """
291
  folder = output_folder or CLIPCHAMP_OUTPUT_FOLDER
292
  os.makedirs(folder, exist_ok=True)
 
299
  with open(out_path, "wb") as f:
300
  f.write(clip_bytes)
301
 
302
+ return os.path.abspath(out_path)