VibesAI-api 1.3.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- server/__init__.py +3 -0
- server/app.py +332 -0
- vibes_api/__init__.py +121 -0
- vibes_api/cli.py +852 -0
- vibes_api/client.py +3972 -0
- vibes_api/composition.py +852 -0
- vibes_api/ingredients.py +206 -0
- vibes_api/models.py +165 -0
- vibesai_api-1.3.2.dist-info/METADATA +600 -0
- vibesai_api-1.3.2.dist-info/RECORD +14 -0
- vibesai_api-1.3.2.dist-info/WHEEL +5 -0
- vibesai_api-1.3.2.dist-info/entry_points.txt +2 -0
- vibesai_api-1.3.2.dist-info/licenses/LICENSE +28 -0
- vibesai_api-1.3.2.dist-info/top_level.txt +2 -0
vibes_api/composition.py
ADDED
|
@@ -0,0 +1,852 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Timeline Composition helpers — manipulate the composition JSON that
|
|
3
|
+
Vibes uses to represent a video timeline (tracks, clips, text overlays,
|
|
4
|
+
audio, effects).
|
|
5
|
+
|
|
6
|
+
The web app does all of these operations **client-side** before saving
|
|
7
|
+
the composition via ``PUT /api/projects/{id}`` (or
|
|
8
|
+
``POST /api/projects/{id}/timeline/download`` for export). This module
|
|
9
|
+
mirrors that behavior so you can build / edit timelines programmatically.
|
|
10
|
+
|
|
11
|
+
Composition structure
|
|
12
|
+
---------------------
|
|
13
|
+
A composition is a dict shaped like::
|
|
14
|
+
|
|
15
|
+
{
|
|
16
|
+
"id": "studio-composition",
|
|
17
|
+
"tracks": [
|
|
18
|
+
{
|
|
19
|
+
"id": "video-track",
|
|
20
|
+
"type": "video",
|
|
21
|
+
"label": "Video",
|
|
22
|
+
"items": [
|
|
23
|
+
{
|
|
24
|
+
"id": "clip-1",
|
|
25
|
+
"trackId": "video-track",
|
|
26
|
+
"name": "sunset",
|
|
27
|
+
"src": "https://.../sunset.mp4",
|
|
28
|
+
"start": 0.0,
|
|
29
|
+
"duration": 5.0,
|
|
30
|
+
"sourceDuration": 5.0,
|
|
31
|
+
"mediaType": "video",
|
|
32
|
+
"trimStart": 0.0,
|
|
33
|
+
"trimEnd": 0.0,
|
|
34
|
+
"fadeIn": 0.0,
|
|
35
|
+
"fadeOut": 0.0,
|
|
36
|
+
"volume": 1.0,
|
|
37
|
+
"speed": 1.0,
|
|
38
|
+
"muted": False,
|
|
39
|
+
},
|
|
40
|
+
...
|
|
41
|
+
],
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
"id": "text-track",
|
|
45
|
+
"type": "text",
|
|
46
|
+
"label": "Text",
|
|
47
|
+
"items": [
|
|
48
|
+
{
|
|
49
|
+
"id": "text-1",
|
|
50
|
+
"trackId": "text-track",
|
|
51
|
+
"text": "Hello world",
|
|
52
|
+
"start": 0.0,
|
|
53
|
+
"duration": 3.0,
|
|
54
|
+
"fontSize": 48,
|
|
55
|
+
"color": "#FFFFFF",
|
|
56
|
+
"position": "center",
|
|
57
|
+
"preset": "fade",
|
|
58
|
+
},
|
|
59
|
+
],
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
"id": "music-track",
|
|
63
|
+
"type": "uploaded-audio",
|
|
64
|
+
"label": "Music",
|
|
65
|
+
"items": [
|
|
66
|
+
{
|
|
67
|
+
"id": "music-1",
|
|
68
|
+
"trackId": "music-track",
|
|
69
|
+
"name": "Lofi beat",
|
|
70
|
+
"src": "https://.../music.mp3",
|
|
71
|
+
"start": 0.0,
|
|
72
|
+
"duration": 30.0,
|
|
73
|
+
"sourceDuration": 30.0,
|
|
74
|
+
"volume": 0.5,
|
|
75
|
+
"fadeIn": 0.5,
|
|
76
|
+
"fadeOut": 1.0,
|
|
77
|
+
},
|
|
78
|
+
],
|
|
79
|
+
},
|
|
80
|
+
],
|
|
81
|
+
"duration": 30.0,
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
Example
|
|
85
|
+
-------
|
|
86
|
+
from vibes_api import VibesClient
|
|
87
|
+
from vibes_api.composition import Composition
|
|
88
|
+
|
|
89
|
+
client = VibesClient(meta_session="...")
|
|
90
|
+
project = client.get_project("...")
|
|
91
|
+
comp = Composition(project["composition"])
|
|
92
|
+
|
|
93
|
+
# Add a video clip at 5s
|
|
94
|
+
comp.add_video_clip(src="https://.../clip.mp4", start=5.0, duration=5.0, name="intro")
|
|
95
|
+
|
|
96
|
+
# Add a text overlay from 0-3s with fade preset
|
|
97
|
+
comp.add_text_overlay(text="Welcome!", start=0.0, end=3.0, preset="fade")
|
|
98
|
+
|
|
99
|
+
# Resize the first video clip to 4 seconds
|
|
100
|
+
comp.resize_clip(clip_id=comp.video_track["items"][0]["id"], new_duration=4.0)
|
|
101
|
+
|
|
102
|
+
# Save back to the project
|
|
103
|
+
client.save_composition(project["id"], comp.to_dict())
|
|
104
|
+
"""
|
|
105
|
+
|
|
106
|
+
from __future__ import annotations
|
|
107
|
+
|
|
108
|
+
import copy
|
|
109
|
+
import secrets
|
|
110
|
+
import time
|
|
111
|
+
from typing import Any, Dict, List, Optional, Tuple, Union
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _new_id(prefix: str = "clip") -> str:
|
|
115
|
+
"""Generate a unique clip/track ID."""
|
|
116
|
+
return f"{prefix}-{int(time.time() * 1000)}-{secrets.token_hex(4)}"
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class Composition:
|
|
120
|
+
"""A builder/manipulator for a Vibes timeline composition.
|
|
121
|
+
|
|
122
|
+
Wrap an existing composition dict (from ``client.get_project(id)["composition"]``)
|
|
123
|
+
or start from scratch with ``Composition.create_empty()``.
|
|
124
|
+
|
|
125
|
+
All operations mutate the composition in place; call ``to_dict()`` to
|
|
126
|
+
serialize for saving.
|
|
127
|
+
"""
|
|
128
|
+
|
|
129
|
+
# ---- Construction ----
|
|
130
|
+
def __init__(self, data: Optional[Dict[str, Any]] = None):
|
|
131
|
+
self._data: Dict[str, Any] = copy.deepcopy(data) if data else self.create_empty()._data
|
|
132
|
+
|
|
133
|
+
@classmethod
|
|
134
|
+
def create_empty(cls, duration: float = 5.0) -> "Composition":
|
|
135
|
+
"""Create a fresh empty composition with one video track."""
|
|
136
|
+
return cls({
|
|
137
|
+
"id": "studio-composition",
|
|
138
|
+
"tracks": [
|
|
139
|
+
{
|
|
140
|
+
"id": "video-track",
|
|
141
|
+
"type": "video",
|
|
142
|
+
"label": "Video",
|
|
143
|
+
"items": [],
|
|
144
|
+
}
|
|
145
|
+
],
|
|
146
|
+
"duration": duration,
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
@classmethod
|
|
150
|
+
def from_project(cls, project: Dict[str, Any]) -> "Composition":
|
|
151
|
+
"""Build a Composition from a project dict returned by ``get_project()``."""
|
|
152
|
+
return cls(project.get("composition"))
|
|
153
|
+
|
|
154
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
155
|
+
"""Serialize to a plain dict (for ``client.save_composition()``)."""
|
|
156
|
+
return copy.deepcopy(self._data)
|
|
157
|
+
|
|
158
|
+
def clone(self) -> "Composition":
|
|
159
|
+
"""Return a deep copy of this composition."""
|
|
160
|
+
return Composition(self.to_dict())
|
|
161
|
+
|
|
162
|
+
# ---- Properties ----
|
|
163
|
+
@property
|
|
164
|
+
def tracks(self) -> List[Dict[str, Any]]:
|
|
165
|
+
return self._data.get("tracks", [])
|
|
166
|
+
|
|
167
|
+
@property
|
|
168
|
+
def duration(self) -> float:
|
|
169
|
+
return float(self._data.get("duration", 0.0))
|
|
170
|
+
|
|
171
|
+
@duration.setter
|
|
172
|
+
def duration(self, value: float) -> None:
|
|
173
|
+
self._data["duration"] = float(value)
|
|
174
|
+
|
|
175
|
+
@property
|
|
176
|
+
def video_track(self) -> Optional[Dict[str, Any]]:
|
|
177
|
+
"""First track of type 'video' (creates one if missing)."""
|
|
178
|
+
for t in self.tracks:
|
|
179
|
+
if t.get("type") == "video":
|
|
180
|
+
return t
|
|
181
|
+
# Create one
|
|
182
|
+
return self.add_track(track_type="video", label="Video", track_id="video-track")
|
|
183
|
+
|
|
184
|
+
@property
|
|
185
|
+
def text_track(self) -> Optional[Dict[str, Any]]:
|
|
186
|
+
for t in self.tracks:
|
|
187
|
+
if t.get("type") == "text":
|
|
188
|
+
return t
|
|
189
|
+
return self.add_track(track_type="text", label="Text", track_id="text-track")
|
|
190
|
+
|
|
191
|
+
@property
|
|
192
|
+
def video_items(self) -> List[Dict[str, Any]]:
|
|
193
|
+
"""All clips in the video track."""
|
|
194
|
+
vt = self.video_track
|
|
195
|
+
return vt["items"] if vt else []
|
|
196
|
+
|
|
197
|
+
@property
|
|
198
|
+
def total_video_duration(self) -> float:
|
|
199
|
+
"""End time of the last video clip."""
|
|
200
|
+
if not self.video_items:
|
|
201
|
+
return 0.0
|
|
202
|
+
return max(item.get("start", 0) + item.get("duration", 0)
|
|
203
|
+
for item in self.video_items)
|
|
204
|
+
|
|
205
|
+
# ---- Track operations ----
|
|
206
|
+
def add_track(
|
|
207
|
+
self,
|
|
208
|
+
track_type: str,
|
|
209
|
+
label: Optional[str] = None,
|
|
210
|
+
track_id: Optional[str] = None,
|
|
211
|
+
) -> Dict[str, Any]:
|
|
212
|
+
"""Add a new track.
|
|
213
|
+
|
|
214
|
+
Parameters
|
|
215
|
+
----------
|
|
216
|
+
track_type : str
|
|
217
|
+
"video", "text", "uploaded-audio", "library-music", or custom.
|
|
218
|
+
label : str, optional
|
|
219
|
+
Display name for the track.
|
|
220
|
+
track_id : str, optional
|
|
221
|
+
Stable ID; auto-generated if omitted.
|
|
222
|
+
"""
|
|
223
|
+
track_id = track_id or _new_id("track")
|
|
224
|
+
label = label or track_type.capitalize()
|
|
225
|
+
track = {"id": track_id, "type": track_type, "label": label, "items": []}
|
|
226
|
+
self._data.setdefault("tracks", []).append(track)
|
|
227
|
+
return track
|
|
228
|
+
|
|
229
|
+
def get_track(self, track_id: str) -> Optional[Dict[str, Any]]:
|
|
230
|
+
for t in self.tracks:
|
|
231
|
+
if t["id"] == track_id:
|
|
232
|
+
return t
|
|
233
|
+
return None
|
|
234
|
+
|
|
235
|
+
def delete_track(self, track_id: str) -> bool:
|
|
236
|
+
"""Delete an entire track and all its clips. Returns True if found."""
|
|
237
|
+
before = len(self.tracks)
|
|
238
|
+
self._data["tracks"] = [t for t in self.tracks if t["id"] != track_id]
|
|
239
|
+
return len(self.tracks) < before
|
|
240
|
+
|
|
241
|
+
def rename_track(self, track_id: str, label: str) -> bool:
|
|
242
|
+
"""Rename a track. Returns True if found."""
|
|
243
|
+
t = self.get_track(track_id)
|
|
244
|
+
if t:
|
|
245
|
+
t["label"] = label
|
|
246
|
+
return True
|
|
247
|
+
return False
|
|
248
|
+
|
|
249
|
+
def mute_track(self, track_id: str, muted: bool = True) -> bool:
|
|
250
|
+
"""Mute or unmute a track. Returns True if found."""
|
|
251
|
+
t = self.get_track(track_id)
|
|
252
|
+
if t:
|
|
253
|
+
t["muted"] = muted
|
|
254
|
+
return True
|
|
255
|
+
return False
|
|
256
|
+
|
|
257
|
+
# ---- Clip lookup helpers ----
|
|
258
|
+
def find_clip(self, clip_id: str) -> Tuple[Optional[Dict], Optional[Dict]]:
|
|
259
|
+
"""Find a clip by ID across all tracks.
|
|
260
|
+
|
|
261
|
+
Returns ``(track, clip)`` or ``(None, None)`` if not found.
|
|
262
|
+
"""
|
|
263
|
+
for t in self.tracks:
|
|
264
|
+
for c in t.get("items", []):
|
|
265
|
+
if c.get("id") == clip_id:
|
|
266
|
+
return t, c
|
|
267
|
+
return None, None
|
|
268
|
+
|
|
269
|
+
def find_clip_by_name(self, name: str) -> Tuple[Optional[Dict], Optional[Dict]]:
|
|
270
|
+
"""Find the first clip with a matching name. Returns ``(track, clip)``."""
|
|
271
|
+
for t in self.tracks:
|
|
272
|
+
for c in t.get("items", []):
|
|
273
|
+
if c.get("name") == name:
|
|
274
|
+
return t, c
|
|
275
|
+
return None, None
|
|
276
|
+
|
|
277
|
+
# ---- Adding clips ----
|
|
278
|
+
def add_video_clip(
|
|
279
|
+
self,
|
|
280
|
+
*,
|
|
281
|
+
src: str,
|
|
282
|
+
start: float,
|
|
283
|
+
duration: Optional[float] = None,
|
|
284
|
+
source_duration: Optional[float] = None,
|
|
285
|
+
name: Optional[str] = None,
|
|
286
|
+
track_id: Optional[str] = None,
|
|
287
|
+
media_type: str = "video",
|
|
288
|
+
content_item_id: Optional[str] = None,
|
|
289
|
+
trim_start: float = 0.0,
|
|
290
|
+
trim_end: float = 0.0,
|
|
291
|
+
volume: float = 1.0,
|
|
292
|
+
speed: float = 1.0,
|
|
293
|
+
muted: bool = False,
|
|
294
|
+
fade_in: float = 0.0,
|
|
295
|
+
fade_out: float = 0.0,
|
|
296
|
+
) -> Dict[str, Any]:
|
|
297
|
+
"""Add a video clip to the timeline.
|
|
298
|
+
|
|
299
|
+
Parameters
|
|
300
|
+
----------
|
|
301
|
+
src : str
|
|
302
|
+
URL of the video file.
|
|
303
|
+
start : float
|
|
304
|
+
Start time in seconds on the timeline.
|
|
305
|
+
duration : float, optional
|
|
306
|
+
Clip duration in seconds. Defaults to ``source_duration``.
|
|
307
|
+
source_duration : float, optional
|
|
308
|
+
Original duration of the source video (for trim calculations).
|
|
309
|
+
name : str, optional
|
|
310
|
+
Display name (defaults to first 50 chars of ``src``).
|
|
311
|
+
track_id : str, optional
|
|
312
|
+
Track to add to (defaults to the video track).
|
|
313
|
+
media_type : str
|
|
314
|
+
"video" (default) or "image" for image clips.
|
|
315
|
+
content_item_id : str, optional
|
|
316
|
+
Reference to the source content item (from ``generate_video``).
|
|
317
|
+
trim_start, trim_end : float
|
|
318
|
+
Trim offsets in seconds from source start/end.
|
|
319
|
+
volume : float
|
|
320
|
+
0.0 (silent) to 1.0 (full).
|
|
321
|
+
speed : float
|
|
322
|
+
0.1 to 10.0 (1.0 = normal).
|
|
323
|
+
muted : bool
|
|
324
|
+
Mute the clip's audio.
|
|
325
|
+
fade_in, fade_out : float
|
|
326
|
+
Fade durations in seconds (must not exceed half the clip duration).
|
|
327
|
+
"""
|
|
328
|
+
if duration is None:
|
|
329
|
+
duration = source_duration or 5.0
|
|
330
|
+
if source_duration is None:
|
|
331
|
+
source_duration = duration
|
|
332
|
+
|
|
333
|
+
track = self.get_track(track_id) if track_id else self.video_track
|
|
334
|
+
if track is None:
|
|
335
|
+
track = self.add_track("video", label="Video", track_id=track_id)
|
|
336
|
+
|
|
337
|
+
clip = {
|
|
338
|
+
"id": _new_id("clip"),
|
|
339
|
+
"trackId": track["id"],
|
|
340
|
+
"name": name or (src[:50] if src else "Untitled"),
|
|
341
|
+
"src": src,
|
|
342
|
+
"start": float(start),
|
|
343
|
+
"duration": float(duration),
|
|
344
|
+
"sourceDuration": float(source_duration),
|
|
345
|
+
"mediaType": media_type,
|
|
346
|
+
"trimStart": float(trim_start),
|
|
347
|
+
"trimEnd": float(trim_end),
|
|
348
|
+
"volume": float(volume),
|
|
349
|
+
"speed": float(speed),
|
|
350
|
+
"muted": bool(muted),
|
|
351
|
+
"fadeIn": float(fade_in),
|
|
352
|
+
"fadeOut": float(fade_out),
|
|
353
|
+
}
|
|
354
|
+
if content_item_id:
|
|
355
|
+
clip["contentItemId"] = content_item_id
|
|
356
|
+
|
|
357
|
+
track["items"].append(clip)
|
|
358
|
+
# Extend timeline duration if needed
|
|
359
|
+
end = clip["start"] + clip["duration"]
|
|
360
|
+
if end > self.duration:
|
|
361
|
+
self.duration = end
|
|
362
|
+
return clip
|
|
363
|
+
|
|
364
|
+
def add_image_clip(
|
|
365
|
+
self,
|
|
366
|
+
*,
|
|
367
|
+
src: str,
|
|
368
|
+
start: float,
|
|
369
|
+
duration: float = 5.0,
|
|
370
|
+
name: Optional[str] = None,
|
|
371
|
+
track_id: Optional[str] = None,
|
|
372
|
+
content_item_id: Optional[str] = None,
|
|
373
|
+
) -> Dict[str, Any]:
|
|
374
|
+
"""Add a still image clip to the timeline."""
|
|
375
|
+
return self.add_video_clip(
|
|
376
|
+
src=src, start=start, duration=duration, source_duration=duration,
|
|
377
|
+
name=name, track_id=track_id, media_type="image",
|
|
378
|
+
content_item_id=content_item_id,
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
def add_audio_clip(
|
|
382
|
+
self,
|
|
383
|
+
*,
|
|
384
|
+
src: str,
|
|
385
|
+
start: float,
|
|
386
|
+
duration: float,
|
|
387
|
+
source_duration: Optional[float] = None,
|
|
388
|
+
name: Optional[str] = None,
|
|
389
|
+
track_id: Optional[str] = None,
|
|
390
|
+
track_type: str = "uploaded-audio",
|
|
391
|
+
track_label: Optional[str] = None,
|
|
392
|
+
volume: float = 1.0,
|
|
393
|
+
fade_in: float = 0.0,
|
|
394
|
+
fade_out: float = 0.0,
|
|
395
|
+
trim_start: float = 0.0,
|
|
396
|
+
trim_end: float = 0.0,
|
|
397
|
+
linked_item_id: Optional[str] = None,
|
|
398
|
+
link_type: Optional[str] = None,
|
|
399
|
+
) -> Dict[str, Any]:
|
|
400
|
+
"""Add an audio clip (uploaded audio or library music).
|
|
401
|
+
|
|
402
|
+
Parameters
|
|
403
|
+
----------
|
|
404
|
+
src : str
|
|
405
|
+
URL of the audio file.
|
|
406
|
+
track_type : str
|
|
407
|
+
"uploaded-audio" (default) or "library-music".
|
|
408
|
+
linked_item_id : str, optional
|
|
409
|
+
If this audio is linked to a video clip (e.g., lip sync audio),
|
|
410
|
+
pass the video clip ID and ``link_type="video-audio"``.
|
|
411
|
+
"""
|
|
412
|
+
if source_duration is None:
|
|
413
|
+
source_duration = duration
|
|
414
|
+
|
|
415
|
+
track = self.get_track(track_id) if track_id else None
|
|
416
|
+
if track is None:
|
|
417
|
+
track = self.add_track(track_type, label=track_label or "Audio", track_id=track_id)
|
|
418
|
+
|
|
419
|
+
clip = {
|
|
420
|
+
"id": _new_id("audio"),
|
|
421
|
+
"trackId": track["id"],
|
|
422
|
+
"name": name or "Audio clip",
|
|
423
|
+
"src": src,
|
|
424
|
+
"start": float(start),
|
|
425
|
+
"duration": float(duration),
|
|
426
|
+
"sourceDuration": float(source_duration),
|
|
427
|
+
"trimStart": float(trim_start),
|
|
428
|
+
"trimEnd": float(trim_end),
|
|
429
|
+
"volume": float(volume),
|
|
430
|
+
"fadeIn": float(fade_in),
|
|
431
|
+
"fadeOut": float(fade_out),
|
|
432
|
+
}
|
|
433
|
+
if linked_item_id:
|
|
434
|
+
clip["linkedItemId"] = linked_item_id
|
|
435
|
+
if link_type:
|
|
436
|
+
clip["linkType"] = link_type
|
|
437
|
+
|
|
438
|
+
track["items"].append(clip)
|
|
439
|
+
end = clip["start"] + clip["duration"]
|
|
440
|
+
if end > self.duration:
|
|
441
|
+
self.duration = end
|
|
442
|
+
return clip
|
|
443
|
+
|
|
444
|
+
def add_text_overlay(
|
|
445
|
+
self,
|
|
446
|
+
*,
|
|
447
|
+
text: str,
|
|
448
|
+
start: float,
|
|
449
|
+
end: float,
|
|
450
|
+
preset: Optional[str] = None,
|
|
451
|
+
font_size: int = 48,
|
|
452
|
+
color: str = "#FFFFFF",
|
|
453
|
+
position: str = "center",
|
|
454
|
+
track_id: Optional[str] = None,
|
|
455
|
+
clip_id: Optional[str] = None,
|
|
456
|
+
) -> Dict[str, Any]:
|
|
457
|
+
"""Add a text overlay to the timeline.
|
|
458
|
+
|
|
459
|
+
Parameters
|
|
460
|
+
----------
|
|
461
|
+
text : str
|
|
462
|
+
Text content.
|
|
463
|
+
start, end : float
|
|
464
|
+
Start and end times in seconds.
|
|
465
|
+
preset : str, optional
|
|
466
|
+
Effect preset: "fade", "slide-up", "surround", "strange", "flash",
|
|
467
|
+
"slide", "cinefade", "glow", "typewriter", "highlight", "glitch".
|
|
468
|
+
font_size : int
|
|
469
|
+
Font size in pixels (default 48).
|
|
470
|
+
color : str
|
|
471
|
+
Hex color (e.g., "#FF0000").
|
|
472
|
+
position : str
|
|
473
|
+
"center" (default), "top", "bottom", "left", "right", "top-left",
|
|
474
|
+
"top-right", "bottom-left", "bottom-right".
|
|
475
|
+
"""
|
|
476
|
+
duration = float(end) - float(start)
|
|
477
|
+
if duration <= 0:
|
|
478
|
+
raise ValueError(f"end ({end}) must be greater than start ({start})")
|
|
479
|
+
|
|
480
|
+
track = self.get_track(track_id) if track_id else self.text_track
|
|
481
|
+
if track is None:
|
|
482
|
+
track = self.add_track("text", label="Text", track_id=track_id)
|
|
483
|
+
|
|
484
|
+
clip = {
|
|
485
|
+
"id": clip_id or _new_id("text"),
|
|
486
|
+
"trackId": track["id"],
|
|
487
|
+
"text": text,
|
|
488
|
+
"start": float(start),
|
|
489
|
+
"duration": duration,
|
|
490
|
+
"fontSize": int(font_size),
|
|
491
|
+
"color": color,
|
|
492
|
+
"position": position,
|
|
493
|
+
}
|
|
494
|
+
if preset:
|
|
495
|
+
clip["preset"] = preset
|
|
496
|
+
|
|
497
|
+
track["items"].append(clip)
|
|
498
|
+
end_t = clip["start"] + clip["duration"]
|
|
499
|
+
if end_t > self.duration:
|
|
500
|
+
self.duration = end_t
|
|
501
|
+
return clip
|
|
502
|
+
|
|
503
|
+
# ---- Clip editing ----
|
|
504
|
+
def resize_clip(self, *, clip_id: str, new_duration: float,
|
|
505
|
+
clip_name: Optional[str] = None) -> bool:
|
|
506
|
+
"""Set the ABSOLUTE duration of a clip.
|
|
507
|
+
|
|
508
|
+
``new_duration`` is the final duration (not a delta).
|
|
509
|
+
Cannot exceed the clip's ``sourceDuration`` (maxDur).
|
|
510
|
+
"""
|
|
511
|
+
_, clip = self.find_clip(clip_id)
|
|
512
|
+
if clip is None and clip_name:
|
|
513
|
+
_, clip = self.find_clip_by_name(clip_name)
|
|
514
|
+
if clip is None:
|
|
515
|
+
return False
|
|
516
|
+
max_dur = clip.get("sourceDuration", float("inf"))
|
|
517
|
+
if new_duration > max_dur:
|
|
518
|
+
new_duration = max_dur
|
|
519
|
+
clip["duration"] = float(new_duration)
|
|
520
|
+
return True
|
|
521
|
+
|
|
522
|
+
def move_clip(self, *, clip_id: str, start_time: float,
|
|
523
|
+
clip_name: Optional[str] = None) -> bool:
|
|
524
|
+
"""Move a clip to a new start time on its track."""
|
|
525
|
+
_, clip = self.find_clip(clip_id)
|
|
526
|
+
if clip is None and clip_name:
|
|
527
|
+
_, clip = self.find_clip_by_name(clip_name)
|
|
528
|
+
if clip is None:
|
|
529
|
+
return False
|
|
530
|
+
clip["start"] = float(start_time)
|
|
531
|
+
# Extend timeline if needed
|
|
532
|
+
end = clip["start"] + clip["duration"]
|
|
533
|
+
if end > self.duration:
|
|
534
|
+
self.duration = end
|
|
535
|
+
return True
|
|
536
|
+
|
|
537
|
+
def split_clip(self, *, clip_id: str, at_time: float,
|
|
538
|
+
clip_name: Optional[str] = None) -> Optional[str]:
|
|
539
|
+
"""Split a clip at ``at_time`` (absolute timeline time).
|
|
540
|
+
|
|
541
|
+
Returns the new (second) clip's ID, or None if split failed.
|
|
542
|
+
"""
|
|
543
|
+
track, clip = self.find_clip(clip_id)
|
|
544
|
+
if clip is None and clip_name:
|
|
545
|
+
track, clip = self.find_clip_by_name(clip_name)
|
|
546
|
+
if clip is None or track is None:
|
|
547
|
+
return None
|
|
548
|
+
|
|
549
|
+
clip_start = clip.get("start", 0)
|
|
550
|
+
clip_duration = clip.get("duration", 0)
|
|
551
|
+
clip_end = clip_start + clip_duration
|
|
552
|
+
|
|
553
|
+
# Validate split time is within the clip
|
|
554
|
+
if at_time <= clip_start or at_time >= clip_end:
|
|
555
|
+
return None
|
|
556
|
+
|
|
557
|
+
# Calculate new durations
|
|
558
|
+
first_duration = at_time - clip_start
|
|
559
|
+
second_duration = clip_end - at_time
|
|
560
|
+
|
|
561
|
+
# Update the original clip's duration
|
|
562
|
+
clip["duration"] = float(first_duration)
|
|
563
|
+
|
|
564
|
+
# Create the new (second) clip
|
|
565
|
+
new_clip = copy.deepcopy(clip)
|
|
566
|
+
new_clip["id"] = _new_id("clip")
|
|
567
|
+
new_clip["start"] = float(at_time)
|
|
568
|
+
new_clip["duration"] = float(second_duration)
|
|
569
|
+
# Adjust trimStart if it's a video/audio clip
|
|
570
|
+
if "trimStart" in clip:
|
|
571
|
+
new_clip["trimStart"] = float(clip.get("trimStart", 0)) + first_duration
|
|
572
|
+
|
|
573
|
+
track["items"].append(new_clip)
|
|
574
|
+
# Re-sort items by start time
|
|
575
|
+
track["items"].sort(key=lambda x: x.get("start", 0))
|
|
576
|
+
return new_clip["id"]
|
|
577
|
+
|
|
578
|
+
def duplicate_clip(self, *, clip_id: str,
|
|
579
|
+
clip_name: Optional[str] = None) -> Optional[str]:
|
|
580
|
+
"""Duplicate a clip in place on the same track.
|
|
581
|
+
|
|
582
|
+
Returns the new clip's ID, or None if not found.
|
|
583
|
+
"""
|
|
584
|
+
track, clip = self.find_clip(clip_id)
|
|
585
|
+
if clip is None and clip_name:
|
|
586
|
+
track, clip = self.find_clip_by_name(clip_name)
|
|
587
|
+
if clip is None or track is None:
|
|
588
|
+
return None
|
|
589
|
+
|
|
590
|
+
new_clip = copy.deepcopy(clip)
|
|
591
|
+
new_clip["id"] = _new_id("clip")
|
|
592
|
+
# Place the duplicate right after the original
|
|
593
|
+
new_clip["start"] = clip.get("start", 0) + clip.get("duration", 0)
|
|
594
|
+
track["items"].append(new_clip)
|
|
595
|
+
# Extend timeline duration
|
|
596
|
+
end = new_clip["start"] + new_clip["duration"]
|
|
597
|
+
if end > self.duration:
|
|
598
|
+
self.duration = end
|
|
599
|
+
return new_clip["id"]
|
|
600
|
+
|
|
601
|
+
def delete_clip(self, *, clip_id: str,
|
|
602
|
+
clip_name: Optional[str] = None) -> bool:
|
|
603
|
+
"""Delete a clip by ID (or name fallback). Returns True if deleted."""
|
|
604
|
+
track, clip = self.find_clip(clip_id)
|
|
605
|
+
if clip is None and clip_name:
|
|
606
|
+
track, clip = self.find_clip_by_name(clip_name)
|
|
607
|
+
if clip is None or track is None:
|
|
608
|
+
return False
|
|
609
|
+
track["items"] = [c for c in track["items"] if c.get("id") != clip["id"]]
|
|
610
|
+
return True
|
|
611
|
+
|
|
612
|
+
def reorder_clips(self, *, ordered_clip_ids: Optional[List[str]] = None,
|
|
613
|
+
clip_id: Optional[str] = None,
|
|
614
|
+
to_index: Optional[int] = None) -> bool:
|
|
615
|
+
"""Reorder clips on the video track.
|
|
616
|
+
|
|
617
|
+
Two modes:
|
|
618
|
+
1. Pass ``ordered_clip_ids`` (preferred) — list ALL video clip IDs
|
|
619
|
+
in the desired final order. Single call.
|
|
620
|
+
2. Pass ``clip_id`` + ``to_index`` — move a single clip to a 0-based index.
|
|
621
|
+
"""
|
|
622
|
+
track = self.video_track
|
|
623
|
+
if track is None:
|
|
624
|
+
return False
|
|
625
|
+
|
|
626
|
+
if ordered_clip_ids is not None:
|
|
627
|
+
# Reorder by the given list
|
|
628
|
+
by_id = {c["id"]: c for c in track["items"]}
|
|
629
|
+
new_items = []
|
|
630
|
+
for cid in ordered_clip_ids:
|
|
631
|
+
if cid in by_id:
|
|
632
|
+
new_items.append(by_id[cid])
|
|
633
|
+
# Append any clips not in the list at the end (preserve their relative order)
|
|
634
|
+
for c in track["items"]:
|
|
635
|
+
if c["id"] not in ordered_clip_ids:
|
|
636
|
+
new_items.append(c)
|
|
637
|
+
track["items"] = new_items
|
|
638
|
+
return True
|
|
639
|
+
elif clip_id is not None and to_index is not None:
|
|
640
|
+
items = track["items"]
|
|
641
|
+
idx = next((i for i, c in enumerate(items) if c["id"] == clip_id), None)
|
|
642
|
+
if idx is None:
|
|
643
|
+
return False
|
|
644
|
+
item = items.pop(idx)
|
|
645
|
+
items.insert(to_index, item)
|
|
646
|
+
return True
|
|
647
|
+
return False
|
|
648
|
+
|
|
649
|
+
def extend_timeline_to(self, target_duration: float) -> bool:
|
|
650
|
+
"""Extend or shrink the entire video track to a target total duration.
|
|
651
|
+
|
|
652
|
+
The system handles clip math precisely. If the target is longer,
|
|
653
|
+
the last clip is extended (up to its maxDur). If shorter, clips
|
|
654
|
+
are trimmed from the end.
|
|
655
|
+
"""
|
|
656
|
+
if target_duration <= 0:
|
|
657
|
+
return False
|
|
658
|
+
|
|
659
|
+
current = self.total_video_duration
|
|
660
|
+
if current == 0:
|
|
661
|
+
self.duration = float(target_duration)
|
|
662
|
+
return True
|
|
663
|
+
|
|
664
|
+
diff = target_duration - current
|
|
665
|
+
if diff > 0:
|
|
666
|
+
# Extend: add the diff to the last video clip (if it has room)
|
|
667
|
+
track = self.video_track
|
|
668
|
+
if track and track["items"]:
|
|
669
|
+
last = max(track["items"], key=lambda c: c.get("start", 0))
|
|
670
|
+
max_dur = last.get("sourceDuration", float("inf"))
|
|
671
|
+
new_duration = last["duration"] + diff
|
|
672
|
+
if new_duration > max_dur:
|
|
673
|
+
new_duration = max_dur
|
|
674
|
+
last["duration"] = float(new_duration)
|
|
675
|
+
elif diff < 0:
|
|
676
|
+
# Shrink: trim clips from the end
|
|
677
|
+
track = self.video_track
|
|
678
|
+
if track:
|
|
679
|
+
# Sort by start time, then trim from the end
|
|
680
|
+
items = sorted(track["items"], key=lambda c: c.get("start", 0))
|
|
681
|
+
remaining = target_duration
|
|
682
|
+
for item in items:
|
|
683
|
+
clip_end = item["start"] + item["duration"]
|
|
684
|
+
if item["start"] >= target_duration:
|
|
685
|
+
# Clip starts after target — remove entirely
|
|
686
|
+
item["duration"] = 0
|
|
687
|
+
elif clip_end > target_duration:
|
|
688
|
+
# Clip straddles the target — trim it
|
|
689
|
+
item["duration"] = target_duration - item["start"]
|
|
690
|
+
# Remove zero-duration clips
|
|
691
|
+
track["items"] = [c for c in track["items"] if c.get("duration", 0) > 0]
|
|
692
|
+
|
|
693
|
+
self.duration = float(target_duration)
|
|
694
|
+
return True
|
|
695
|
+
|
|
696
|
+
# ---- Effects / properties ----
|
|
697
|
+
def set_fade(self, *, clip_id: str, fade_in: float = 0.0, fade_out: float = 0.0,
|
|
698
|
+
clip_name: Optional[str] = None) -> bool:
|
|
699
|
+
"""Set fade-in and/or fade-out on a clip.
|
|
700
|
+
|
|
701
|
+
Typical values: 0.3-1.0s. Must not exceed half the clip duration.
|
|
702
|
+
"""
|
|
703
|
+
_, clip = self.find_clip(clip_id)
|
|
704
|
+
if clip is None and clip_name:
|
|
705
|
+
_, clip = self.find_clip_by_name(clip_name)
|
|
706
|
+
if clip is None:
|
|
707
|
+
return False
|
|
708
|
+
half = clip.get("duration", 0) / 2
|
|
709
|
+
clip["fadeIn"] = float(min(fade_in, half))
|
|
710
|
+
clip["fadeOut"] = float(min(fade_out, half))
|
|
711
|
+
return True
|
|
712
|
+
|
|
713
|
+
def set_volume(self, *, track_id: str, volume: float) -> bool:
|
|
714
|
+
"""Set the volume of a track from 0.0 (silent) to 1.0 (full)."""
|
|
715
|
+
track = self.get_track(track_id)
|
|
716
|
+
if track is None:
|
|
717
|
+
return False
|
|
718
|
+
for c in track.get("items", []):
|
|
719
|
+
c["volume"] = float(max(0.0, min(1.0, volume)))
|
|
720
|
+
return True
|
|
721
|
+
|
|
722
|
+
def set_speed(self, *, clip_id: str, speed: float,
|
|
723
|
+
clip_name: Optional[str] = None) -> bool:
|
|
724
|
+
"""Set playback speed of a clip (0.1 to 10.0).
|
|
725
|
+
|
|
726
|
+
Increasing speed makes the clip SHORTER on the timeline;
|
|
727
|
+
decreasing makes it LONGER.
|
|
728
|
+
"""
|
|
729
|
+
if speed < 0.1 or speed > 10.0:
|
|
730
|
+
return False
|
|
731
|
+
_, clip = self.find_clip(clip_id)
|
|
732
|
+
if clip is None and clip_name:
|
|
733
|
+
_, clip = self.find_clip_by_name(clip_name)
|
|
734
|
+
if clip is None:
|
|
735
|
+
return False
|
|
736
|
+
old_speed = clip.get("speed", 1.0)
|
|
737
|
+
old_duration = clip.get("duration", 0)
|
|
738
|
+
# New duration = old_duration * (old_speed / new_speed)
|
|
739
|
+
# (faster speed → shorter clip)
|
|
740
|
+
new_duration = old_duration * (old_speed / speed)
|
|
741
|
+
clip["speed"] = float(speed)
|
|
742
|
+
clip["duration"] = float(new_duration)
|
|
743
|
+
return True
|
|
744
|
+
|
|
745
|
+
# ---- Text overlay editing ----
|
|
746
|
+
def update_text_overlay(
|
|
747
|
+
self,
|
|
748
|
+
*,
|
|
749
|
+
clip_id: str,
|
|
750
|
+
text: Optional[str] = None,
|
|
751
|
+
preset: Optional[str] = None,
|
|
752
|
+
font_size: Optional[int] = None,
|
|
753
|
+
color: Optional[str] = None,
|
|
754
|
+
position: Optional[str] = None,
|
|
755
|
+
clip_name: Optional[str] = None,
|
|
756
|
+
) -> bool:
|
|
757
|
+
"""Update an existing text overlay. Only set fields are updated."""
|
|
758
|
+
_, clip = self.find_clip(clip_id)
|
|
759
|
+
if clip is None and clip_name:
|
|
760
|
+
_, clip = self.find_clip_by_name(clip_name)
|
|
761
|
+
if clip is None:
|
|
762
|
+
return False
|
|
763
|
+
if text is not None:
|
|
764
|
+
clip["text"] = text
|
|
765
|
+
if preset is not None:
|
|
766
|
+
clip["preset"] = preset
|
|
767
|
+
if font_size is not None:
|
|
768
|
+
clip["fontSize"] = int(font_size)
|
|
769
|
+
if color is not None:
|
|
770
|
+
clip["color"] = color
|
|
771
|
+
if position is not None:
|
|
772
|
+
clip["position"] = position
|
|
773
|
+
return True
|
|
774
|
+
|
|
775
|
+
# ---- Audio linking ----
|
|
776
|
+
def unlink_audio_from_video(self, *, audio_clip_id: str) -> bool:
|
|
777
|
+
"""Unlink an audio clip from its associated video clip."""
|
|
778
|
+
_, clip = self.find_clip(audio_clip_id)
|
|
779
|
+
if clip is None:
|
|
780
|
+
return False
|
|
781
|
+
clip.pop("linkedItemId", None)
|
|
782
|
+
clip.pop("linkType", None)
|
|
783
|
+
return True
|
|
784
|
+
|
|
785
|
+
def link_audio_to_video(self, *, audio_clip_id: str, video_clip_id: str,
|
|
786
|
+
link_type: str = "video-audio") -> bool:
|
|
787
|
+
"""Link an audio clip to a video clip (e.g., lip sync audio)."""
|
|
788
|
+
_, audio_clip = self.find_clip(audio_clip_id)
|
|
789
|
+
if audio_clip is None:
|
|
790
|
+
return False
|
|
791
|
+
audio_clip["linkedItemId"] = video_clip_id
|
|
792
|
+
audio_clip["linkType"] = link_type
|
|
793
|
+
return True
|
|
794
|
+
|
|
795
|
+
def slip_audio(self, *, clip_id: str, slip_seconds: float) -> bool:
|
|
796
|
+
"""Slip an audio clip's trim point without changing its timeline position.
|
|
797
|
+
|
|
798
|
+
``slip_seconds`` shifts the in-point forward (positive) or backward (negative).
|
|
799
|
+
"""
|
|
800
|
+
_, clip = self.find_clip(clip_id)
|
|
801
|
+
if clip is None:
|
|
802
|
+
return False
|
|
803
|
+
clip["trimStart"] = float(max(0, clip.get("trimStart", 0) + slip_seconds))
|
|
804
|
+
return True
|
|
805
|
+
|
|
806
|
+
def replace_audio(self, *, clip_id: str, new_src: str,
|
|
807
|
+
new_duration: Optional[float] = None) -> bool:
|
|
808
|
+
"""Replace the audio source URL of an audio clip."""
|
|
809
|
+
_, clip = self.find_clip(clip_id)
|
|
810
|
+
if clip is None:
|
|
811
|
+
return False
|
|
812
|
+
clip["src"] = new_src
|
|
813
|
+
if new_duration is not None:
|
|
814
|
+
clip["duration"] = float(new_duration)
|
|
815
|
+
clip["sourceDuration"] = float(new_duration)
|
|
816
|
+
return True
|
|
817
|
+
|
|
818
|
+
# ---- Bulk operations ----
|
|
819
|
+
def delete_all_clips(self) -> None:
|
|
820
|
+
"""Remove every clip from every track (keeps the track structure)."""
|
|
821
|
+
for t in self.tracks:
|
|
822
|
+
t["items"] = []
|
|
823
|
+
self.duration = 0.0
|
|
824
|
+
|
|
825
|
+
def delete_timeline(self) -> None:
|
|
826
|
+
"""Remove all tracks AND clips (full reset)."""
|
|
827
|
+
self._data["tracks"] = []
|
|
828
|
+
self.duration = 0.0
|
|
829
|
+
|
|
830
|
+
# ---- Summary ----
|
|
831
|
+
def summary(self) -> Dict[str, Any]:
|
|
832
|
+
"""Return a brief summary of the composition."""
|
|
833
|
+
track_summaries = []
|
|
834
|
+
for t in self.tracks:
|
|
835
|
+
track_summaries.append({
|
|
836
|
+
"id": t["id"],
|
|
837
|
+
"type": t.get("type"),
|
|
838
|
+
"label": t.get("label"),
|
|
839
|
+
"item_count": len(t.get("items", [])),
|
|
840
|
+
"duration": sum(c.get("duration", 0) for c in t.get("items", [])),
|
|
841
|
+
})
|
|
842
|
+
return {
|
|
843
|
+
"duration": self.duration,
|
|
844
|
+
"track_count": len(self.tracks),
|
|
845
|
+
"total_clips": sum(len(t.get("items", [])) for t in self.tracks),
|
|
846
|
+
"tracks": track_summaries,
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
def __repr__(self) -> str:
|
|
850
|
+
s = self.summary()
|
|
851
|
+
return (f"<Composition duration={s['duration']:.1f}s "
|
|
852
|
+
f"tracks={s['track_count']} clips={s['total_clips']}>")
|