diplotocus 0.0.0__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.
- diplotocus/GUI/GUI.html +120 -0
- diplotocus/GUI/__init__.py +2 -0
- diplotocus/GUI/app.js +1646 -0
- diplotocus/GUI/server.py +1352 -0
- diplotocus/GUI/src/bg.png +0 -0
- diplotocus/GUI/src/btn/btn_bg_Draw.png +0 -0
- diplotocus/GUI/src/btn/btn_bg_Morph.png +0 -0
- diplotocus/GUI/src/btn/btn_bg_Rotate.png +0 -0
- diplotocus/GUI/src/btn/btn_bg_Scale.png +0 -0
- diplotocus/GUI/src/btn/btn_bg_Sequence.png +0 -0
- diplotocus/GUI/src/btn/btn_bg_Translate.png +0 -0
- diplotocus/GUI/src/btn/btn_bg_Tween.png +0 -0
- diplotocus/GUI/src/btn/btn_bg_default.png +0 -0
- diplotocus/GUI/src/favicon.ico +0 -0
- diplotocus/GUI/src/logo_bg.png +0 -0
- diplotocus/GUI/style.css +622 -0
- diplotocus/__init__.py +4 -0
- diplotocus/_version.py +1 -0
- diplotocus/animations.py +3686 -0
- diplotocus/core.py +358 -0
- diplotocus/easings.py +284 -0
- diplotocus-0.0.0.dist-info/METADATA +16 -0
- diplotocus-0.0.0.dist-info/RECORD +26 -0
- diplotocus-0.0.0.dist-info/WHEEL +5 -0
- diplotocus-0.0.0.dist-info/licenses/LICENSE +21 -0
- diplotocus-0.0.0.dist-info/top_level.txt +1 -0
diplotocus/GUI/server.py
ADDED
|
@@ -0,0 +1,1352 @@
|
|
|
1
|
+
import json,sys,threading,webbrowser,re,ast,mimetypes,shutil,tempfile,uuid
|
|
2
|
+
import numpy as np
|
|
3
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from urllib.parse import parse_qs, urlparse
|
|
6
|
+
import diplotocus.easings as easings
|
|
7
|
+
import diplotocus.animations as animations
|
|
8
|
+
|
|
9
|
+
SPECIAL_CLIP_TYPES = {
|
|
10
|
+
"axis_zoom",
|
|
11
|
+
"axis_limits",
|
|
12
|
+
"axis_move",
|
|
13
|
+
"axis_alpha",
|
|
14
|
+
"fig_width_ratio",
|
|
15
|
+
"fig_height_ratio",
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
def _safe_int(value, default=0):
|
|
19
|
+
try:
|
|
20
|
+
return int(float(value))
|
|
21
|
+
except Exception:
|
|
22
|
+
return default
|
|
23
|
+
|
|
24
|
+
def _safe_float(value, default=0.0):
|
|
25
|
+
try:
|
|
26
|
+
return float(value)
|
|
27
|
+
except Exception:
|
|
28
|
+
return float(default)
|
|
29
|
+
|
|
30
|
+
def _normalize_scalar(value):
|
|
31
|
+
if isinstance(value, np.ndarray):
|
|
32
|
+
if value.size == 0:
|
|
33
|
+
return None
|
|
34
|
+
return _normalize_scalar(value.ravel()[0])
|
|
35
|
+
if isinstance(value, (list, tuple)):
|
|
36
|
+
if len(value) == 0:
|
|
37
|
+
return None
|
|
38
|
+
return _normalize_scalar(value[0])
|
|
39
|
+
if isinstance(value, np.generic):
|
|
40
|
+
return value.item()
|
|
41
|
+
return value
|
|
42
|
+
|
|
43
|
+
def _parse_scalar_like(value, default=None):
|
|
44
|
+
if value is None:
|
|
45
|
+
return default
|
|
46
|
+
|
|
47
|
+
normalized = _normalize_scalar(value)
|
|
48
|
+
if isinstance(normalized, str):
|
|
49
|
+
text = normalized.strip()
|
|
50
|
+
if text == "":
|
|
51
|
+
return default
|
|
52
|
+
|
|
53
|
+
if text.startswith("array(") and text.endswith(")"):
|
|
54
|
+
text = text[6:-1].strip()
|
|
55
|
+
|
|
56
|
+
try:
|
|
57
|
+
parsed = ast.literal_eval(text)
|
|
58
|
+
except Exception:
|
|
59
|
+
return text
|
|
60
|
+
return _normalize_scalar(parsed)
|
|
61
|
+
|
|
62
|
+
return normalized
|
|
63
|
+
|
|
64
|
+
def _parse_optional_float(value):
|
|
65
|
+
if value is None:
|
|
66
|
+
return None
|
|
67
|
+
if isinstance(value, str) and value.strip() == "":
|
|
68
|
+
return None
|
|
69
|
+
|
|
70
|
+
parsed = _parse_scalar_like(value, default=None)
|
|
71
|
+
if parsed is None:
|
|
72
|
+
return None
|
|
73
|
+
if isinstance(parsed, str) and parsed.strip() == "":
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
return float(parsed)
|
|
77
|
+
|
|
78
|
+
def _parse_optional_center(payload, key_x="center_x", key_y="center_y"):
|
|
79
|
+
cx = _parse_optional_float(payload.get(key_x))
|
|
80
|
+
cy = _parse_optional_float(payload.get(key_y))
|
|
81
|
+
if cx is None or cy is None:
|
|
82
|
+
return None
|
|
83
|
+
return (cx, cy)
|
|
84
|
+
|
|
85
|
+
def _safe_name(name):
|
|
86
|
+
text = str(name).strip().lower()
|
|
87
|
+
text = re.sub(r"\s+", "_", text)
|
|
88
|
+
text = re.sub(r"[^a-z0-9_]+", "", text)
|
|
89
|
+
return text
|
|
90
|
+
|
|
91
|
+
class GUI:
|
|
92
|
+
"""A Graphical User Interface object that launches a python server that serves a webpage.
|
|
93
|
+
|
|
94
|
+
Parameters
|
|
95
|
+
----------
|
|
96
|
+
timeline : Timeline
|
|
97
|
+
the Timeline object that renders our animation.
|
|
98
|
+
plot_objects : list[dict]
|
|
99
|
+
a list of dictionaries containing the keys:
|
|
100
|
+
- `name`: the display name shown in the GUI.
|
|
101
|
+
- `object`: the plot object.
|
|
102
|
+
- `new_x`/`new_y` (optional): datapoints to be used when the
|
|
103
|
+
morph() animation is used on the plot object.
|
|
104
|
+
min_tracks : int, default=3
|
|
105
|
+
the minimum number of tracks to be shown. This will be the
|
|
106
|
+
initial number of tracks.
|
|
107
|
+
host : str, default="localhost"
|
|
108
|
+
the URL to which the server serves content.
|
|
109
|
+
port : int, default=8008
|
|
110
|
+
the port used to communicate with the server.
|
|
111
|
+
open_browser : bool, default=True
|
|
112
|
+
if True, opens the webpage at host:port immediatly after
|
|
113
|
+
the server is launched.
|
|
114
|
+
"""
|
|
115
|
+
|
|
116
|
+
def __init__(
|
|
117
|
+
self,
|
|
118
|
+
timeline=None,
|
|
119
|
+
plot_objects=None,
|
|
120
|
+
min_tracks: int = 3,
|
|
121
|
+
host: str = "localhost",
|
|
122
|
+
port: int = 8008,
|
|
123
|
+
open_browser: bool = True,
|
|
124
|
+
) -> None:
|
|
125
|
+
self.host = host
|
|
126
|
+
self.port = port
|
|
127
|
+
self._static_dir = Path(__file__).resolve().parent
|
|
128
|
+
self._server: ThreadingHTTPServer | None = None
|
|
129
|
+
self._thread: threading.Thread | None = None
|
|
130
|
+
self.url: str | None = None
|
|
131
|
+
self.tl = timeline
|
|
132
|
+
self.plot_objects = [] if plot_objects is None else plot_objects
|
|
133
|
+
self.min_tracks = max(1, _safe_int(min_tracks, 3))
|
|
134
|
+
|
|
135
|
+
self._lock = threading.RLock()
|
|
136
|
+
self._anim_to_clip_id = {}
|
|
137
|
+
self._clip_id_to_anim = {}
|
|
138
|
+
self._clip_id_to_owner = {}
|
|
139
|
+
self._clip_id_to_type = {}
|
|
140
|
+
self._clip_rows = {}
|
|
141
|
+
self._detached_clip_ids = set()
|
|
142
|
+
self._clip_seed = 0
|
|
143
|
+
self._render_revision = 0
|
|
144
|
+
self._timeline_width_override = None
|
|
145
|
+
self._row_count_override = None
|
|
146
|
+
self._last_invalidated_from = 0
|
|
147
|
+
self._temp_exports = {}
|
|
148
|
+
self.serve(open_browser=open_browser)
|
|
149
|
+
self.wait()
|
|
150
|
+
|
|
151
|
+
def _all_objects(self):
|
|
152
|
+
objects = []
|
|
153
|
+
for el in self.plot_objects:
|
|
154
|
+
if not isinstance(el, dict):
|
|
155
|
+
continue
|
|
156
|
+
obj = el.get("object")
|
|
157
|
+
if obj is None:
|
|
158
|
+
continue
|
|
159
|
+
objects.append(obj)
|
|
160
|
+
return objects
|
|
161
|
+
|
|
162
|
+
def _display_clip_type(self, type_key):
|
|
163
|
+
return str(type_key or "clip").capitalize()
|
|
164
|
+
|
|
165
|
+
def _clip_type_for_clip(self, clip_id, anim):
|
|
166
|
+
mapped_type = self._clip_id_to_type.get(clip_id)
|
|
167
|
+
if mapped_type:
|
|
168
|
+
return mapped_type
|
|
169
|
+
|
|
170
|
+
owner_index = self._find_clip_owner_index(clip_id)
|
|
171
|
+
if owner_index is not None and 0 <= owner_index < len(self.plot_objects):
|
|
172
|
+
entry = self.plot_objects[owner_index]
|
|
173
|
+
if isinstance(entry, dict):
|
|
174
|
+
owner_obj = entry.get("object")
|
|
175
|
+
owner_type = _safe_name(owner_obj.__class__.__name__) if owner_obj is not None else ""
|
|
176
|
+
if owner_type in SPECIAL_CLIP_TYPES:
|
|
177
|
+
return owner_type
|
|
178
|
+
|
|
179
|
+
return _safe_name(anim.get("name", "")) or "clip"
|
|
180
|
+
|
|
181
|
+
def _is_selectable_plot_entry(self, entry):
|
|
182
|
+
return isinstance(entry, dict) and entry.get("object") is not None and not bool(entry.get("_gui_special", False))
|
|
183
|
+
|
|
184
|
+
def _first_selectable_plot_object(self):
|
|
185
|
+
for index, entry in enumerate(self.plot_objects):
|
|
186
|
+
if self._is_selectable_plot_entry(entry):
|
|
187
|
+
return index, entry.get("object")
|
|
188
|
+
return None, None
|
|
189
|
+
|
|
190
|
+
def _default_axis_for_special(self, source_object):
|
|
191
|
+
source_axis = getattr(source_object, "axis", None)
|
|
192
|
+
if source_axis is not None:
|
|
193
|
+
return source_axis
|
|
194
|
+
if self.tl is not None and hasattr(self.tl, "main_axis"):
|
|
195
|
+
return self.tl.main_axis
|
|
196
|
+
return None
|
|
197
|
+
|
|
198
|
+
def _default_ratio_length(self, axis, use_width):
|
|
199
|
+
if axis is None:
|
|
200
|
+
return 2
|
|
201
|
+
try:
|
|
202
|
+
grid = axis.get_gridspec()
|
|
203
|
+
if use_width:
|
|
204
|
+
return max(1, int(getattr(grid, "ncols", 2)))
|
|
205
|
+
return max(1, int(getattr(grid, "nrows", 2)))
|
|
206
|
+
except Exception:
|
|
207
|
+
return 2
|
|
208
|
+
|
|
209
|
+
def _build_special_animation(self, clip_type, duration, delay, easing, axis):
|
|
210
|
+
if clip_type == "axis_zoom":
|
|
211
|
+
anim = animations.axis_zoom(zoom=1.0, duration=duration, delay=delay, easing=easing, axis=axis)
|
|
212
|
+
if anim.anims:
|
|
213
|
+
if axis is not None:
|
|
214
|
+
anim.anims[0]["_initial_xlim"] = axis.get_xlim()
|
|
215
|
+
anim.anims[0]["_initial_ylim"] = axis.get_ylim()
|
|
216
|
+
else:
|
|
217
|
+
anim.anims[0]["_initial_xlim"] = (0.0, 1.0)
|
|
218
|
+
anim.anims[0]["_initial_ylim"] = (0.0, 1.0)
|
|
219
|
+
return anim
|
|
220
|
+
|
|
221
|
+
if clip_type == "axis_limits":
|
|
222
|
+
xlim = axis.get_xlim() if axis is not None else (0.0, 1.0)
|
|
223
|
+
ylim = axis.get_ylim() if axis is not None else (0.0, 1.0)
|
|
224
|
+
return animations.axis_limits(duration=duration, xlim=xlim, ylim=ylim, delay=delay, easing=easing, axis=axis)
|
|
225
|
+
|
|
226
|
+
if clip_type == "axis_move":
|
|
227
|
+
return animations.axis_move(end_pos=(0.0, 0.0), duration=duration, start_pos=None, delay=delay, easing=easing, axis=axis)
|
|
228
|
+
|
|
229
|
+
if clip_type == "axis_alpha":
|
|
230
|
+
return animations.axis_alpha(start_alpha=0.0, end_alpha=1.0, duration=duration, delay=delay, easing=easing, axis=axis)
|
|
231
|
+
|
|
232
|
+
if clip_type == "fig_width_ratio":
|
|
233
|
+
ncols = self._default_ratio_length(axis, use_width=True)
|
|
234
|
+
ratios = tuple([1.0] * ncols)
|
|
235
|
+
return animations.fig_width_ratio(start_widths=ratios, end_widths=ratios, duration=duration, delay=delay, easing=easing, axis=axis)
|
|
236
|
+
|
|
237
|
+
if clip_type == "fig_height_ratio":
|
|
238
|
+
nrows = self._default_ratio_length(axis, use_width=False)
|
|
239
|
+
ratios = tuple([1.0] * nrows)
|
|
240
|
+
return animations.fig_height_ratio(start_heights=ratios, end_heights=ratios, duration=duration, delay=delay, easing=easing, axis=axis)
|
|
241
|
+
|
|
242
|
+
raise ValueError(f"Unsupported special clip type: {clip_type}")
|
|
243
|
+
|
|
244
|
+
def _next_clip_id(self):
|
|
245
|
+
self._clip_seed += 1
|
|
246
|
+
return f"clip-{self._clip_seed}"
|
|
247
|
+
|
|
248
|
+
def _clip_id_for_anim(self, anim):
|
|
249
|
+
key = id(anim)
|
|
250
|
+
if key not in self._anim_to_clip_id:
|
|
251
|
+
clip_id = self._next_clip_id()
|
|
252
|
+
self._anim_to_clip_id[key] = clip_id
|
|
253
|
+
self._clip_id_to_anim[clip_id] = anim
|
|
254
|
+
return self._anim_to_clip_id[key]
|
|
255
|
+
|
|
256
|
+
def _type_from_anim(self, clip_id, anim):
|
|
257
|
+
return self._display_clip_type(self._clip_type_for_clip(clip_id, anim))
|
|
258
|
+
|
|
259
|
+
def _max_frame(self):
|
|
260
|
+
max_frame = self._rightmost_clip()
|
|
261
|
+
if self._timeline_width_override is not None:
|
|
262
|
+
return max(50, max(max_frame, int(self._timeline_width_override)))
|
|
263
|
+
if max_frame == 0:
|
|
264
|
+
return 150
|
|
265
|
+
return max(50, max_frame)
|
|
266
|
+
|
|
267
|
+
def _rightmost_clip(self):
|
|
268
|
+
rightmost = 0
|
|
269
|
+
for obj in self._all_objects():
|
|
270
|
+
for anim in getattr(obj, "anims", []):
|
|
271
|
+
rightmost = max(rightmost, _safe_int(anim.get("delay", 0)) + _safe_int(anim.get("duration", 0)))
|
|
272
|
+
return rightmost
|
|
273
|
+
|
|
274
|
+
def _serialize_clips(self):
|
|
275
|
+
clips = []
|
|
276
|
+
for clip_id, anim in self._clip_id_to_anim.items():
|
|
277
|
+
anim = self._clip_id_to_anim.get(clip_id)
|
|
278
|
+
if anim is None:
|
|
279
|
+
raise KeyError(f"Unknown clip id: {clip_id}")
|
|
280
|
+
clip_type = self._clip_type_for_clip(clip_id, anim)
|
|
281
|
+
|
|
282
|
+
object_options = ["None"]
|
|
283
|
+
for obj in self.plot_objects:
|
|
284
|
+
if not self._is_selectable_plot_entry(obj):
|
|
285
|
+
continue
|
|
286
|
+
if clip_type == "morph" and not ("new_x" in obj and "new_y" in obj):
|
|
287
|
+
continue
|
|
288
|
+
object_options.append(obj["name"])
|
|
289
|
+
|
|
290
|
+
if clip_type in SPECIAL_CLIP_TYPES:
|
|
291
|
+
current_object_id = 0
|
|
292
|
+
elif clip_id in self._clip_id_to_owner:
|
|
293
|
+
owner_index = self._clip_id_to_owner.get(clip_id)
|
|
294
|
+
current_object_id = 0 if owner_index is None else owner_index + 1
|
|
295
|
+
else:
|
|
296
|
+
owner_index = self._find_clip_owner_index(clip_id)
|
|
297
|
+
current_object_id = 0 if owner_index is None else owner_index + 1
|
|
298
|
+
|
|
299
|
+
clip = {
|
|
300
|
+
"id": clip_id,
|
|
301
|
+
"type": self._type_from_anim(clip_id, anim),
|
|
302
|
+
"x": _safe_int(anim.get("delay", 0)),
|
|
303
|
+
"width": max(1, _safe_int(anim.get("duration", 1))),
|
|
304
|
+
"row": self._clip_rows.get(clip_id, 0),
|
|
305
|
+
"plotObjectName":object_options[current_object_id]
|
|
306
|
+
}
|
|
307
|
+
clips.append(clip)
|
|
308
|
+
|
|
309
|
+
if clip_id in self._detached_clip_ids:
|
|
310
|
+
self._clip_id_to_owner[clip_id] = None
|
|
311
|
+
continue
|
|
312
|
+
|
|
313
|
+
if clip_id not in self._clip_id_to_owner:
|
|
314
|
+
owner_index = self._find_clip_owner_index(clip_id)
|
|
315
|
+
if owner_index is not None:
|
|
316
|
+
self._clip_id_to_owner[clip_id] = owner_index
|
|
317
|
+
|
|
318
|
+
row_end = []
|
|
319
|
+
clips.sort(key=lambda c: (c["x"], c["width"]))
|
|
320
|
+
for clip in clips:
|
|
321
|
+
if clip["id"] in self._clip_rows:
|
|
322
|
+
continue
|
|
323
|
+
placed = False
|
|
324
|
+
for row_i, end_x in enumerate(row_end):
|
|
325
|
+
if clip["x"] >= end_x:
|
|
326
|
+
clip["row"] = row_i
|
|
327
|
+
row_end[row_i] = clip["x"] + clip["width"]
|
|
328
|
+
placed = True
|
|
329
|
+
break
|
|
330
|
+
if not placed:
|
|
331
|
+
clip["row"] = len(row_end)
|
|
332
|
+
row_end.append(clip["x"] + clip["width"])
|
|
333
|
+
self._clip_rows[clip["id"]] = clip["row"]
|
|
334
|
+
|
|
335
|
+
return clips
|
|
336
|
+
|
|
337
|
+
def _state_payload(self):
|
|
338
|
+
clips = self._serialize_clips()
|
|
339
|
+
row_count = max(self.min_tracks, 1 + max((clip["row"] for clip in clips), default=0))
|
|
340
|
+
if self._row_count_override is not None:
|
|
341
|
+
row_count = max(row_count, int(self._row_count_override))
|
|
342
|
+
return {
|
|
343
|
+
"trackWidth": self._max_frame(),
|
|
344
|
+
"rowCount": row_count,
|
|
345
|
+
"minRowCount": self.min_tracks,
|
|
346
|
+
"clips": clips,
|
|
347
|
+
"hasTimeline": self.tl is not None,
|
|
348
|
+
"hasPlotObjects": len(self._all_objects()) > 0,
|
|
349
|
+
"renderRevision": self._render_revision,
|
|
350
|
+
"timelineName": getattr(self.tl, "name", None),
|
|
351
|
+
"invalidatedFromFrame": self._last_invalidated_from,
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
def _render_dirs(self):
|
|
355
|
+
if self.tl is None:
|
|
356
|
+
return []
|
|
357
|
+
dirs = [Path(self.tl.name)]
|
|
358
|
+
if hasattr(self.tl, "full_path"):
|
|
359
|
+
dirs.insert(0, Path(self.tl.full_path) / self.tl.name)
|
|
360
|
+
unique = []
|
|
361
|
+
seen = set()
|
|
362
|
+
for d in dirs:
|
|
363
|
+
key = str(d.resolve()) if d.exists() else str(d)
|
|
364
|
+
if key in seen:
|
|
365
|
+
continue
|
|
366
|
+
seen.add(key)
|
|
367
|
+
unique.append(d)
|
|
368
|
+
return unique
|
|
369
|
+
|
|
370
|
+
def _invalidate_render_cache(self, from_frame=None):
|
|
371
|
+
if self.tl is None:
|
|
372
|
+
return
|
|
373
|
+
if from_frame is None:
|
|
374
|
+
from_frame = 0
|
|
375
|
+
self._last_invalidated_from = max(0, int(from_frame))
|
|
376
|
+
prefix = f"{self.tl.name}_"
|
|
377
|
+
pattern = re.compile(rf"^{re.escape(self.tl.name)}_(\d+)\.png$")
|
|
378
|
+
for d in self._render_dirs():
|
|
379
|
+
if not d.exists() or not d.is_dir():
|
|
380
|
+
continue
|
|
381
|
+
for png in d.glob(f"{prefix}*.png"):
|
|
382
|
+
m = pattern.match(png.name)
|
|
383
|
+
if not m:
|
|
384
|
+
continue
|
|
385
|
+
frame_n = int(m.group(1))
|
|
386
|
+
if frame_n < int(from_frame):
|
|
387
|
+
continue
|
|
388
|
+
try:
|
|
389
|
+
png.unlink()
|
|
390
|
+
except OSError:
|
|
391
|
+
pass
|
|
392
|
+
self._render_revision += 1
|
|
393
|
+
|
|
394
|
+
def _clear_render_cache_on_load(self):
|
|
395
|
+
if self.tl is None:
|
|
396
|
+
return
|
|
397
|
+
prefix = f"{self.tl.name}_"
|
|
398
|
+
for d in self._render_dirs():
|
|
399
|
+
if not d.exists() or not d.is_dir():
|
|
400
|
+
continue
|
|
401
|
+
for f in d.glob(f"{prefix}*.png"):
|
|
402
|
+
try:
|
|
403
|
+
f.unlink()
|
|
404
|
+
except OSError:
|
|
405
|
+
pass
|
|
406
|
+
self._last_invalidated_from = 0
|
|
407
|
+
self._render_revision += 1
|
|
408
|
+
|
|
409
|
+
def _create_clip(self, clip_type, x, width, row):
|
|
410
|
+
obj_index, target = self._first_selectable_plot_object()
|
|
411
|
+
if target is None:
|
|
412
|
+
raise RuntimeError("No plot objects available. Pass plot_objects to GUI_web.GUI(...).")
|
|
413
|
+
|
|
414
|
+
name = _safe_name(clip_type)
|
|
415
|
+
duration = max(1, _safe_int(width, 120))
|
|
416
|
+
delay = max(0, _safe_int(x, 0))
|
|
417
|
+
|
|
418
|
+
if name in SPECIAL_CLIP_TYPES:
|
|
419
|
+
easing = easings.easeLinear()
|
|
420
|
+
axis = self._default_axis_for_special(target)
|
|
421
|
+
special_object = self._build_special_animation(name, duration, delay, easing, axis)
|
|
422
|
+
self.plot_objects.append({
|
|
423
|
+
"name": str(clip_type),
|
|
424
|
+
"object": special_object,
|
|
425
|
+
"_gui_special": True,
|
|
426
|
+
})
|
|
427
|
+
|
|
428
|
+
anim = special_object.anims[0]
|
|
429
|
+
clip_id = self._clip_id_for_anim(anim)
|
|
430
|
+
self._clip_id_to_owner[clip_id] = len(self.plot_objects) - 1
|
|
431
|
+
self._clip_id_to_type[clip_id] = name
|
|
432
|
+
self._clip_rows[clip_id] = max(0, _safe_int(row, 0))
|
|
433
|
+
self._invalidate_render_cache(from_frame=delay)
|
|
434
|
+
return clip_id
|
|
435
|
+
|
|
436
|
+
before = len(target.anims)
|
|
437
|
+
if name == "translate":
|
|
438
|
+
target.translate((0, 0), (1, 1), duration=duration, delay=delay)
|
|
439
|
+
elif name == "rotate":
|
|
440
|
+
target.rotate(0, 360, duration=duration, delay=delay)
|
|
441
|
+
elif name == "scale":
|
|
442
|
+
target.scale((1, 1), (2, 2), duration=duration, delay=delay)
|
|
443
|
+
elif name == "tween":
|
|
444
|
+
target.tween(property="alpha", start=0, end=1, duration=duration, delay=delay)
|
|
445
|
+
elif name == "draw":
|
|
446
|
+
target.draw(duration=duration, delay=delay)
|
|
447
|
+
elif name == "morph":
|
|
448
|
+
if isinstance(self.plot_objects[obj_index], dict) and "new_x" in self.plot_objects[obj_index] and "new_y" in self.plot_objects[obj_index]:
|
|
449
|
+
target.morph(
|
|
450
|
+
new_x=self.plot_objects[obj_index]["new_x"],
|
|
451
|
+
new_y=self.plot_objects[obj_index]["new_y"],
|
|
452
|
+
duration=duration,
|
|
453
|
+
delay=delay,
|
|
454
|
+
)
|
|
455
|
+
else:
|
|
456
|
+
target.translate((0, 0), (1, 1), duration=duration, delay=delay)
|
|
457
|
+
elif name == "sequence":
|
|
458
|
+
target.sequence(duration=duration, delay=delay)
|
|
459
|
+
else:
|
|
460
|
+
target.translate((0, 0), (1, 1), duration=duration, delay=delay)
|
|
461
|
+
|
|
462
|
+
if len(target.anims) <= before:
|
|
463
|
+
raise RuntimeError("Could not create animation clip.")
|
|
464
|
+
|
|
465
|
+
anim = target.anims[-1]
|
|
466
|
+
clip_id = self._clip_id_for_anim(anim)
|
|
467
|
+
self._clip_id_to_owner[clip_id] = obj_index
|
|
468
|
+
self._clip_id_to_type[clip_id] = name
|
|
469
|
+
self._clip_rows[clip_id] = max(0, _safe_int(row, 0))
|
|
470
|
+
self._invalidate_render_cache(from_frame=delay)
|
|
471
|
+
return clip_id
|
|
472
|
+
|
|
473
|
+
def _update_clip(self, clip_id, x, width, row):
|
|
474
|
+
anim = self._clip_id_to_anim.get(clip_id)
|
|
475
|
+
if anim is None:
|
|
476
|
+
raise KeyError(f"Unknown clip id: {clip_id}")
|
|
477
|
+
old_delay = _safe_int(anim.get("delay", 0))
|
|
478
|
+
anim["delay"] = max(0, _safe_int(x, anim.get("delay", 0)))
|
|
479
|
+
anim["duration"] = max(1, _safe_int(width, anim.get("duration", 1)))
|
|
480
|
+
if row is not None:
|
|
481
|
+
self._clip_rows[clip_id] = max(0, _safe_int(row, 0))
|
|
482
|
+
self._invalidate_render_cache(from_frame=min(old_delay, anim["delay"]))
|
|
483
|
+
|
|
484
|
+
def _available_easings(self):
|
|
485
|
+
names = []
|
|
486
|
+
for name in dir(easings):
|
|
487
|
+
if name.startswith("ease"):
|
|
488
|
+
candidate = getattr(easings, name)
|
|
489
|
+
if callable(candidate):
|
|
490
|
+
names.append(name)
|
|
491
|
+
return sorted(set(names))
|
|
492
|
+
|
|
493
|
+
def _find_clip_owner_index(self, clip_id):
|
|
494
|
+
owner_index = self._clip_id_to_owner.get(clip_id)
|
|
495
|
+
if owner_index is not None and 0 <= owner_index < len(self.plot_objects):
|
|
496
|
+
return owner_index
|
|
497
|
+
|
|
498
|
+
anim = self._clip_id_to_anim.get(clip_id)
|
|
499
|
+
if anim is None:
|
|
500
|
+
return None
|
|
501
|
+
|
|
502
|
+
for index, entry in enumerate(self.plot_objects):
|
|
503
|
+
if not isinstance(entry, dict):
|
|
504
|
+
continue
|
|
505
|
+
owner_obj = entry.get("object")
|
|
506
|
+
if owner_obj is None:
|
|
507
|
+
continue
|
|
508
|
+
if anim in getattr(owner_obj, "anims", []):
|
|
509
|
+
return index
|
|
510
|
+
return None
|
|
511
|
+
|
|
512
|
+
def _clip_properties(self, clip_id):
|
|
513
|
+
anim = self._clip_id_to_anim.get(clip_id)
|
|
514
|
+
if anim is None:
|
|
515
|
+
raise KeyError(f"Unknown clip id: {clip_id}")
|
|
516
|
+
|
|
517
|
+
easing_obj = anim.get("easing")
|
|
518
|
+
easing_name = easing_obj.__class__.__name__ if easing_obj is not None else "easeLinear"
|
|
519
|
+
props = {
|
|
520
|
+
"id": clip_id,
|
|
521
|
+
"type": self._type_from_anim(clip_id, anim),
|
|
522
|
+
"delay": _safe_int(anim.get("delay", 0)),
|
|
523
|
+
"duration": max(1, _safe_int(anim.get("duration", 1))),
|
|
524
|
+
"persistent": bool(anim.get("persistent", True)),
|
|
525
|
+
"easing": easing_name,
|
|
526
|
+
"availableEasings": self._available_easings(),
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
clip_type = self._clip_type_for_clip(clip_id, anim)
|
|
530
|
+
object_options = [{"id": 0, "name": "None"}]
|
|
531
|
+
for i, obj in enumerate(self.plot_objects):
|
|
532
|
+
if not self._is_selectable_plot_entry(obj):
|
|
533
|
+
continue
|
|
534
|
+
if clip_type == "morph" and not ("new_x" in obj and "new_y" in obj):
|
|
535
|
+
continue
|
|
536
|
+
object_options.append({"id": i + 1, "name": obj["name"]})
|
|
537
|
+
|
|
538
|
+
if clip_type in SPECIAL_CLIP_TYPES:
|
|
539
|
+
current_object_id = 0
|
|
540
|
+
elif clip_id in self._clip_id_to_owner:
|
|
541
|
+
owner_index = self._clip_id_to_owner.get(clip_id)
|
|
542
|
+
current_object_id = 0 if owner_index is None else owner_index + 1
|
|
543
|
+
else:
|
|
544
|
+
owner_index = self._find_clip_owner_index(clip_id)
|
|
545
|
+
current_object_id = 0 if owner_index is None else owner_index + 1
|
|
546
|
+
|
|
547
|
+
props["plotObjectOptions"] = object_options
|
|
548
|
+
props["plotObjectId"] = current_object_id
|
|
549
|
+
|
|
550
|
+
name = clip_type
|
|
551
|
+
if name in {"translate", "axis_move"}:
|
|
552
|
+
start = anim.get("start", (0, 0))
|
|
553
|
+
end = anim.get("end", (1, 1))
|
|
554
|
+
if start is None:
|
|
555
|
+
start = (0, 0)
|
|
556
|
+
if end is None:
|
|
557
|
+
end = (1, 1)
|
|
558
|
+
props.update({
|
|
559
|
+
"start_x": float(start[0]),
|
|
560
|
+
"start_y": float(start[1]),
|
|
561
|
+
"end_x": float(end[0]),
|
|
562
|
+
"end_y": float(end[1]),
|
|
563
|
+
})
|
|
564
|
+
elif name == "scale":
|
|
565
|
+
start = anim.get("start", (0, 0))
|
|
566
|
+
end = anim.get("end", (1, 1))
|
|
567
|
+
center = anim.get("center", None)
|
|
568
|
+
center_x = ""
|
|
569
|
+
center_y = ""
|
|
570
|
+
if center is not None and len(center) == 2:
|
|
571
|
+
center_x = float(center[0])
|
|
572
|
+
center_y = float(center[1])
|
|
573
|
+
props.update({
|
|
574
|
+
"start_x": float(start[0]),
|
|
575
|
+
"start_y": float(start[1]),
|
|
576
|
+
"end_x": float(end[0]),
|
|
577
|
+
"end_y": float(end[1]),
|
|
578
|
+
"center_x": center_x,
|
|
579
|
+
"center_y": center_y,
|
|
580
|
+
})
|
|
581
|
+
elif name == "axis_zoom":
|
|
582
|
+
props.update({"zoom": float(anim.get("zoom", 1.0))})
|
|
583
|
+
elif name == "axis_limits":
|
|
584
|
+
xlim = anim.get("xlim")
|
|
585
|
+
ylim = anim.get("ylim")
|
|
586
|
+
props.update({
|
|
587
|
+
"xlim_left": None if xlim is None else float(xlim[0]),
|
|
588
|
+
"xlim_right": None if xlim is None else float(xlim[1]),
|
|
589
|
+
"ylim_bottom": None if ylim is None else float(ylim[0]),
|
|
590
|
+
"ylim_top": None if ylim is None else float(ylim[1]),
|
|
591
|
+
})
|
|
592
|
+
elif name == "rotate":
|
|
593
|
+
center = anim.get("center", None)
|
|
594
|
+
center_x = ""
|
|
595
|
+
center_y = ""
|
|
596
|
+
if center is not None and len(center) == 2:
|
|
597
|
+
center_x = float(center[0])
|
|
598
|
+
center_y = float(center[1])
|
|
599
|
+
props.update({
|
|
600
|
+
"start": float(anim.get("start", 0)),
|
|
601
|
+
"end": float(anim.get("end", 360)),
|
|
602
|
+
"center_x": center_x,
|
|
603
|
+
"center_y": center_y,
|
|
604
|
+
})
|
|
605
|
+
elif name == "axis_alpha":
|
|
606
|
+
props.update({
|
|
607
|
+
"start": float(anim.get("start", 1.0)),
|
|
608
|
+
"end": float(anim.get("end", 1.0)),
|
|
609
|
+
})
|
|
610
|
+
elif name == "tween":
|
|
611
|
+
tween_property = _normalize_scalar(anim.get("property", "alpha"))
|
|
612
|
+
tween_start = _normalize_scalar(anim.get("start", 0))
|
|
613
|
+
tween_end = _normalize_scalar(anim.get("end", 1))
|
|
614
|
+
props.update({
|
|
615
|
+
"tween_property": "alpha" if tween_property is None else str(tween_property),
|
|
616
|
+
"tween_start": "0" if tween_start is None else str(tween_start),
|
|
617
|
+
"tween_end": "1" if tween_end is None else str(tween_end),
|
|
618
|
+
})
|
|
619
|
+
elif name == "draw":
|
|
620
|
+
props.update({
|
|
621
|
+
"reverse": str([anim.get("reverse",False)])
|
|
622
|
+
})
|
|
623
|
+
elif name in {"fig_width_ratio", "fig_height_ratio"}:
|
|
624
|
+
props.update({
|
|
625
|
+
"ratio_start": str(list(np.ravel(anim.get("start", [1.0])))),
|
|
626
|
+
"ratio_end": str(list(np.ravel(anim.get("end", [1.0])))),
|
|
627
|
+
})
|
|
628
|
+
|
|
629
|
+
props["typeKey"] = clip_type
|
|
630
|
+
|
|
631
|
+
return props
|
|
632
|
+
|
|
633
|
+
def _parse_list_like(self, value, default):
|
|
634
|
+
if value is None:
|
|
635
|
+
return default
|
|
636
|
+
if isinstance(value, (list, tuple)):
|
|
637
|
+
return list(value)
|
|
638
|
+
if isinstance(value, str):
|
|
639
|
+
try:
|
|
640
|
+
parsed = ast.literal_eval(value)
|
|
641
|
+
except Exception:
|
|
642
|
+
return default
|
|
643
|
+
if isinstance(parsed, (list, tuple)):
|
|
644
|
+
return list(parsed)
|
|
645
|
+
return default
|
|
646
|
+
return default
|
|
647
|
+
|
|
648
|
+
def _update_clip_properties(self, clip_id, payload):
|
|
649
|
+
anim = self._clip_id_to_anim.get(clip_id)
|
|
650
|
+
if anim is None:
|
|
651
|
+
raise KeyError(f"Unknown clip id: {clip_id}")
|
|
652
|
+
|
|
653
|
+
clip_type = self._clip_type_for_clip(clip_id, anim)
|
|
654
|
+
|
|
655
|
+
if clip_type not in SPECIAL_CLIP_TYPES:
|
|
656
|
+
old_owner_index = self._find_clip_owner_index(clip_id)
|
|
657
|
+
new_object_id = _safe_int(payload.get("plotObjectId", old_owner_index + 1 if old_owner_index is not None else 0), 0)
|
|
658
|
+
if new_object_id == 0:
|
|
659
|
+
if old_owner_index is not None and 0 <= old_owner_index < len(self.plot_objects):
|
|
660
|
+
old_owner_entry = self.plot_objects[old_owner_index]
|
|
661
|
+
old_owner_obj = old_owner_entry.get("object") if isinstance(old_owner_entry, dict) else None
|
|
662
|
+
if old_owner_obj is not None and anim in old_owner_obj.anims:
|
|
663
|
+
old_owner_obj.anims.remove(anim)
|
|
664
|
+
self._detached_clip_ids.add(clip_id)
|
|
665
|
+
self._clip_id_to_owner[clip_id] = None
|
|
666
|
+
else:
|
|
667
|
+
new_owner_index = new_object_id - 1
|
|
668
|
+
if not (0 <= new_owner_index < len(self.plot_objects)):
|
|
669
|
+
raise ValueError("Invalid plot object selection.")
|
|
670
|
+
|
|
671
|
+
if old_owner_index is not None and old_owner_index != new_owner_index:
|
|
672
|
+
old_owner_entry = self.plot_objects[old_owner_index]
|
|
673
|
+
old_owner_obj = old_owner_entry.get("object") if isinstance(old_owner_entry, dict) else None
|
|
674
|
+
if old_owner_obj is not None and anim in old_owner_obj.anims:
|
|
675
|
+
old_owner_obj.anims.remove(anim)
|
|
676
|
+
|
|
677
|
+
new_owner_entry = self.plot_objects[new_owner_index]
|
|
678
|
+
if not isinstance(new_owner_entry, dict) or "object" not in new_owner_entry:
|
|
679
|
+
raise ValueError("Invalid plot object selection.")
|
|
680
|
+
|
|
681
|
+
new_owner_obj = new_owner_entry["object"]
|
|
682
|
+
if anim not in new_owner_obj.anims:
|
|
683
|
+
new_owner_obj.anims.append(anim)
|
|
684
|
+
self._detached_clip_ids.discard(clip_id)
|
|
685
|
+
self._clip_id_to_owner[clip_id] = new_owner_index
|
|
686
|
+
|
|
687
|
+
old_delay = _safe_int(anim.get("delay", 0))
|
|
688
|
+
|
|
689
|
+
anim["delay"] = max(0, _safe_int(payload.get("delay", anim.get("delay", 0))))
|
|
690
|
+
anim["duration"] = max(1, _safe_int(payload.get("duration", anim.get("duration", 1))))
|
|
691
|
+
anim["persistent"] = bool(payload.get("persistent", anim.get("persistent", True)))
|
|
692
|
+
|
|
693
|
+
easing_name = payload.get("easing")
|
|
694
|
+
if easing_name and hasattr(easings, easing_name):
|
|
695
|
+
anim["easing"] = getattr(easings, easing_name)()
|
|
696
|
+
|
|
697
|
+
name = clip_type
|
|
698
|
+
if name in {"translate", "axis_move"}:
|
|
699
|
+
current_start = anim.get("start", (0, 0))
|
|
700
|
+
current_end = anim.get("end", (1, 1))
|
|
701
|
+
if current_start is None:
|
|
702
|
+
current_start = (0, 0)
|
|
703
|
+
if current_end is None:
|
|
704
|
+
current_end = (1, 1)
|
|
705
|
+
start_x = float(payload.get("start_x", current_start[0]))
|
|
706
|
+
start_y = float(payload.get("start_y", current_start[1]))
|
|
707
|
+
end_x = float(payload.get("end_x", current_end[0]))
|
|
708
|
+
end_y = float(payload.get("end_y", current_end[1]))
|
|
709
|
+
anim["start"] = (start_x, start_y)
|
|
710
|
+
anim["end"] = (end_x, end_y)
|
|
711
|
+
elif name == "scale":
|
|
712
|
+
start_x = float(payload.get("start_x", anim.get("start", (0, 0))[0]))
|
|
713
|
+
start_y = float(payload.get("start_y", anim.get("start", (0, 0))[1]))
|
|
714
|
+
end_x = float(payload.get("end_x", anim.get("end", (1, 1))[0]))
|
|
715
|
+
end_y = float(payload.get("end_y", anim.get("end", (1, 1))[1]))
|
|
716
|
+
anim["start"] = (start_x, start_y)
|
|
717
|
+
anim["end"] = (end_x, end_y)
|
|
718
|
+
anim["center"] = _parse_optional_center(payload)
|
|
719
|
+
elif name == "axis_zoom":
|
|
720
|
+
anim["zoom"] = _safe_float(payload.get("zoom", anim.get("zoom", 1.0)), 1.0)
|
|
721
|
+
initial_xlim = anim.get("_initial_xlim", (0.0, 1.0))
|
|
722
|
+
initial_ylim = anim.get("_initial_ylim", (0.0, 1.0))
|
|
723
|
+
start_width = initial_xlim[1] - initial_xlim[0]
|
|
724
|
+
start_height = initial_ylim[1] - initial_ylim[0]
|
|
725
|
+
zoom = anim.get("zoom", 1.0)
|
|
726
|
+
anim["start_width"] = start_width
|
|
727
|
+
anim["start_height"] = start_height
|
|
728
|
+
anim["end_width"] = start_width / zoom
|
|
729
|
+
anim["end_height"] = start_height / zoom
|
|
730
|
+
elif name == "axis_limits":
|
|
731
|
+
current_xlim = anim.get("xlim")
|
|
732
|
+
current_ylim = anim.get("ylim")
|
|
733
|
+
anim["xlim"] = (
|
|
734
|
+
_safe_float(payload.get("xlim_left", current_xlim[0] if current_xlim is not None else 0.0), 0.0),
|
|
735
|
+
_safe_float(payload.get("xlim_right", current_xlim[1] if current_xlim is not None else 1.0), 1.0),
|
|
736
|
+
)
|
|
737
|
+
anim["ylim"] = (
|
|
738
|
+
_safe_float(payload.get("ylim_bottom", current_ylim[0] if current_ylim is not None else 0.0), 0.0),
|
|
739
|
+
_safe_float(payload.get("ylim_top", current_ylim[1] if current_ylim is not None else 1.0), 1.0),
|
|
740
|
+
)
|
|
741
|
+
elif name == "rotate":
|
|
742
|
+
anim["start"] = float(payload.get("start", anim.get("start", 0)))
|
|
743
|
+
anim["end"] = float(payload.get("end", anim.get("end", 360)))
|
|
744
|
+
anim["center"] = _parse_optional_center(payload)
|
|
745
|
+
elif name == "axis_alpha":
|
|
746
|
+
anim["start"] = _safe_float(payload.get("start", anim.get("start", 1.0)), 1.0)
|
|
747
|
+
anim["end"] = _safe_float(payload.get("end", anim.get("end", 1.0)), 1.0)
|
|
748
|
+
elif name in {"fig_width_ratio", "fig_height_ratio"}:
|
|
749
|
+
starts = self._parse_list_like(payload.get("ratio_start"), list(np.ravel(anim.get("start", [1.0]))))
|
|
750
|
+
ends = self._parse_list_like(payload.get("ratio_end"), list(np.ravel(anim.get("end", [1.0]))))
|
|
751
|
+
if len(starts) == 0:
|
|
752
|
+
starts = [1.0]
|
|
753
|
+
if len(ends) == 0:
|
|
754
|
+
ends = [1.0]
|
|
755
|
+
n = min(len(starts), len(ends))
|
|
756
|
+
if n == 0:
|
|
757
|
+
n = 1
|
|
758
|
+
starts = [1.0]
|
|
759
|
+
ends = [1.0]
|
|
760
|
+
starts = np.ravel(starts[:n]).astype(float)
|
|
761
|
+
ends = np.ravel(ends[:n]).astype(float)
|
|
762
|
+
start_sum = np.sum(starts)
|
|
763
|
+
end_sum = np.sum(ends)
|
|
764
|
+
starts = starts / start_sum if start_sum != 0 else np.ones_like(starts) / len(starts)
|
|
765
|
+
ends = ends / end_sum if end_sum != 0 else np.ones_like(ends) / len(ends)
|
|
766
|
+
anim["start"] = starts
|
|
767
|
+
anim["end"] = ends
|
|
768
|
+
elif name == "tween":
|
|
769
|
+
owner_index = self._clip_id_to_owner.get(clip_id)
|
|
770
|
+
owner_obj = None
|
|
771
|
+
if owner_index is not None and 0 <= owner_index < len(self.plot_objects):
|
|
772
|
+
entry = self.plot_objects[owner_index]
|
|
773
|
+
if isinstance(entry, dict):
|
|
774
|
+
owner_obj = entry.get("object")
|
|
775
|
+
|
|
776
|
+
prop = _parse_scalar_like(payload.get("tween_property"), anim.get("property", "alpha"))
|
|
777
|
+
start = _parse_scalar_like(payload.get("tween_start"), anim.get("start", 0))
|
|
778
|
+
end = _parse_scalar_like(payload.get("tween_end"), anim.get("end", 1))
|
|
779
|
+
|
|
780
|
+
if prop is None or str(prop).strip() == "":
|
|
781
|
+
prop = "alpha"
|
|
782
|
+
|
|
783
|
+
props = [str(prop)]
|
|
784
|
+
starts = [start]
|
|
785
|
+
ends = [end]
|
|
786
|
+
|
|
787
|
+
if owner_obj is not None:
|
|
788
|
+
props = list(owner_obj.get_main_alias(props))
|
|
789
|
+
props, starts, ends = owner_obj.sanitize_colors(props, starts, ends)
|
|
790
|
+
|
|
791
|
+
anim["property"] = props[0]
|
|
792
|
+
anim["start"] = starts[0]
|
|
793
|
+
anim["end"] = ends[0]
|
|
794
|
+
elif name == "draw":
|
|
795
|
+
reverse_val = payload.get('reverse', False)
|
|
796
|
+
if isinstance(reverse_val, str):
|
|
797
|
+
anim['reverse'] = reverse_val.lower() in ['true', '1', 'yes']
|
|
798
|
+
else:
|
|
799
|
+
anim['reverse'] = bool(reverse_val)
|
|
800
|
+
|
|
801
|
+
self._invalidate_render_cache(from_frame=min(old_delay, anim["delay"]))
|
|
802
|
+
|
|
803
|
+
def _set_timeline_width(self, width):
|
|
804
|
+
width = max(50, _safe_int(width, 1000))
|
|
805
|
+
width = max(width, self._rightmost_clip())
|
|
806
|
+
self._timeline_width_override = width
|
|
807
|
+
|
|
808
|
+
def _set_row_count(self, row_count):
|
|
809
|
+
self._row_count_override = max(self.min_tracks, _safe_int(row_count, self.min_tracks))
|
|
810
|
+
|
|
811
|
+
def _delete_track_row(self, row):
|
|
812
|
+
target_row = max(0, _safe_int(row, 0))
|
|
813
|
+
current_rows = self._state_payload()["rowCount"]
|
|
814
|
+
if target_row < self.min_tracks:
|
|
815
|
+
raise ValueError(f"Cannot delete protected track. First {self.min_tracks} tracks are required.")
|
|
816
|
+
if current_rows <= self.min_tracks:
|
|
817
|
+
raise ValueError(f"Cannot delete track: minimum row count is {self.min_tracks}.")
|
|
818
|
+
if target_row >= current_rows:
|
|
819
|
+
raise ValueError("Invalid track row index.")
|
|
820
|
+
|
|
821
|
+
removed_clip_ids = [clip_id for clip_id, clip_row in self._clip_rows.items() if clip_row == target_row]
|
|
822
|
+
invalidate_from = None
|
|
823
|
+
|
|
824
|
+
for clip_id in removed_clip_ids:
|
|
825
|
+
anim = self._clip_id_to_anim.get(clip_id)
|
|
826
|
+
if anim is not None:
|
|
827
|
+
delay = _safe_int(anim.get("delay", 0), 0)
|
|
828
|
+
invalidate_from = delay if invalidate_from is None else min(invalidate_from, delay)
|
|
829
|
+
|
|
830
|
+
owner_index = self._find_clip_owner_index(clip_id)
|
|
831
|
+
if owner_index is not None and 0 <= owner_index < len(self.plot_objects):
|
|
832
|
+
owner_entry = self.plot_objects[owner_index]
|
|
833
|
+
owner_obj = owner_entry.get("object") if isinstance(owner_entry, dict) else None
|
|
834
|
+
if owner_obj is not None and anim is not None and anim in owner_obj.anims:
|
|
835
|
+
owner_obj.anims.remove(anim)
|
|
836
|
+
|
|
837
|
+
if anim is not None:
|
|
838
|
+
key = id(anim)
|
|
839
|
+
if self._anim_to_clip_id.get(key) == clip_id:
|
|
840
|
+
self._anim_to_clip_id.pop(key, None)
|
|
841
|
+
|
|
842
|
+
self._clip_rows.pop(clip_id, None)
|
|
843
|
+
self._clip_id_to_anim.pop(clip_id, None)
|
|
844
|
+
self._clip_id_to_owner.pop(clip_id, None)
|
|
845
|
+
self._clip_id_to_type.pop(clip_id, None)
|
|
846
|
+
self._detached_clip_ids.discard(clip_id)
|
|
847
|
+
|
|
848
|
+
if owner_index is not None and 0 <= owner_index < len(self.plot_objects):
|
|
849
|
+
owner_entry = self.plot_objects[owner_index]
|
|
850
|
+
if isinstance(owner_entry, dict) and owner_entry.get("_gui_special", False):
|
|
851
|
+
owner_entry["object"] = None
|
|
852
|
+
|
|
853
|
+
for clip_id in list(self._clip_rows.keys()):
|
|
854
|
+
if self._clip_rows[clip_id] > target_row:
|
|
855
|
+
self._clip_rows[clip_id] -= 1
|
|
856
|
+
|
|
857
|
+
self._row_count_override = max(self.min_tracks, current_rows - 1)
|
|
858
|
+
|
|
859
|
+
if invalidate_from is not None:
|
|
860
|
+
self._invalidate_render_cache(from_frame=invalidate_from)
|
|
861
|
+
|
|
862
|
+
def _delete_clips(self, clip_ids):
|
|
863
|
+
if not isinstance(clip_ids, list):
|
|
864
|
+
clip_ids = [clip_ids]
|
|
865
|
+
|
|
866
|
+
removed_any = False
|
|
867
|
+
invalidate_from = None
|
|
868
|
+
|
|
869
|
+
for clip_id in clip_ids:
|
|
870
|
+
clip_id = str(clip_id)
|
|
871
|
+
anim = self._clip_id_to_anim.get(clip_id)
|
|
872
|
+
if anim is None:
|
|
873
|
+
continue
|
|
874
|
+
|
|
875
|
+
removed_any = True
|
|
876
|
+
delay = _safe_int(anim.get("delay", 0), 0)
|
|
877
|
+
invalidate_from = delay if invalidate_from is None else min(invalidate_from, delay)
|
|
878
|
+
|
|
879
|
+
owner_index = self._find_clip_owner_index(clip_id)
|
|
880
|
+
if owner_index is not None and 0 <= owner_index < len(self.plot_objects):
|
|
881
|
+
owner_entry = self.plot_objects[owner_index]
|
|
882
|
+
owner_obj = owner_entry.get("object") if isinstance(owner_entry, dict) else None
|
|
883
|
+
if owner_obj is not None and anim in owner_obj.anims:
|
|
884
|
+
owner_obj.anims.remove(anim)
|
|
885
|
+
|
|
886
|
+
key = id(anim)
|
|
887
|
+
if self._anim_to_clip_id.get(key) == clip_id:
|
|
888
|
+
self._anim_to_clip_id.pop(key, None)
|
|
889
|
+
|
|
890
|
+
self._clip_rows.pop(clip_id, None)
|
|
891
|
+
self._clip_id_to_anim.pop(clip_id, None)
|
|
892
|
+
self._clip_id_to_owner.pop(clip_id, None)
|
|
893
|
+
self._clip_id_to_type.pop(clip_id, None)
|
|
894
|
+
self._detached_clip_ids.discard(clip_id)
|
|
895
|
+
|
|
896
|
+
if owner_index is not None and 0 <= owner_index < len(self.plot_objects):
|
|
897
|
+
owner_entry = self.plot_objects[owner_index]
|
|
898
|
+
if isinstance(owner_entry, dict) and owner_entry.get("_gui_special", False):
|
|
899
|
+
owner_entry["object"] = None
|
|
900
|
+
|
|
901
|
+
if not removed_any:
|
|
902
|
+
raise KeyError("No valid clip ids provided.")
|
|
903
|
+
|
|
904
|
+
if invalidate_from is not None:
|
|
905
|
+
self._invalidate_render_cache(from_frame=invalidate_from)
|
|
906
|
+
|
|
907
|
+
def _frame_file_paths(self, frame):
|
|
908
|
+
filename = f"{self.tl.name}_{frame}.png"
|
|
909
|
+
paths = [Path(self.tl.name) / filename]
|
|
910
|
+
if hasattr(self.tl, "full_path"):
|
|
911
|
+
paths.insert(0, Path(self.tl.full_path) / self.tl.name / filename)
|
|
912
|
+
return paths
|
|
913
|
+
|
|
914
|
+
def _frame_url_path(self, frame):
|
|
915
|
+
if self.tl is None:
|
|
916
|
+
raise RuntimeError("No Timeline object available.")
|
|
917
|
+
frame = max(0, _safe_int(frame, 0))
|
|
918
|
+
return f"/frames/{self.tl.name}_{frame}.png"
|
|
919
|
+
|
|
920
|
+
def _frame_path_from_name(self, filename):
|
|
921
|
+
if self.tl is None:
|
|
922
|
+
return None
|
|
923
|
+
expected_prefix = f"{self.tl.name}_"
|
|
924
|
+
if not filename.startswith(expected_prefix) or not filename.endswith(".png"):
|
|
925
|
+
return None
|
|
926
|
+
|
|
927
|
+
for d in self._render_dirs():
|
|
928
|
+
p = d / filename
|
|
929
|
+
if p.is_file():
|
|
930
|
+
return p
|
|
931
|
+
return None
|
|
932
|
+
|
|
933
|
+
def _ensure_frame_rendered(self, frame):
|
|
934
|
+
frame = max(0, _safe_int(frame, 0))
|
|
935
|
+
|
|
936
|
+
for path in self._frame_file_paths(frame):
|
|
937
|
+
if path.is_file():
|
|
938
|
+
return path
|
|
939
|
+
|
|
940
|
+
if self.tl is None:
|
|
941
|
+
raise RuntimeError("No Timeline object available. Pass a timeline to GUI_web.GUI(timeline=...).")
|
|
942
|
+
|
|
943
|
+
objs = self._all_objects()
|
|
944
|
+
if len(objs) == 0:
|
|
945
|
+
raise RuntimeError("No plot objects available. Pass plot_objects to GUI_web.GUI(...).")
|
|
946
|
+
|
|
947
|
+
self.tl.clean_all()
|
|
948
|
+
self.tl.plot(objs, x=frame)
|
|
949
|
+
|
|
950
|
+
for path in self._frame_file_paths(frame):
|
|
951
|
+
if path.is_file():
|
|
952
|
+
return path
|
|
953
|
+
|
|
954
|
+
raise RuntimeError("Rendered frame file not found after timeline.plot call.")
|
|
955
|
+
|
|
956
|
+
def _render_frame_png(self, frame):
|
|
957
|
+
return self._ensure_frame_rendered(frame).read_bytes()
|
|
958
|
+
|
|
959
|
+
def _export_video_to_temp(self, timeline_width=None):
|
|
960
|
+
if self.tl is None:
|
|
961
|
+
raise RuntimeError("No Timeline object available. Pass a timeline to GUI_web.GUI(timeline=...).")
|
|
962
|
+
|
|
963
|
+
max_frame = _safe_int(timeline_width, self._max_frame())
|
|
964
|
+
self.tl.x = max(self.tl.x, max_frame + 1)
|
|
965
|
+
|
|
966
|
+
ext = ".mov" if getattr(self.tl, "transparent", False) else ".mp4"
|
|
967
|
+
tmpdir = Path(tempfile.mkdtemp(prefix="diplotocus_export_"))
|
|
968
|
+
outfile = tmpdir / f"{getattr(self.tl, 'name', 'video')}{ext}"
|
|
969
|
+
|
|
970
|
+
self.tl.save_video(path=str(outfile), clean=False)
|
|
971
|
+
token = uuid.uuid4().hex
|
|
972
|
+
self._temp_exports[token] = {"path": outfile, "dir": tmpdir}
|
|
973
|
+
ctype = mimetypes.guess_type(str(outfile))[0] or "application/octet-stream"
|
|
974
|
+
return {
|
|
975
|
+
"token": token,
|
|
976
|
+
"filename": outfile.name,
|
|
977
|
+
"contentType": ctype,
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
def _read_and_cleanup_temp_export(self, token):
|
|
981
|
+
entry = self._temp_exports.pop(token, None)
|
|
982
|
+
if entry is None:
|
|
983
|
+
return None
|
|
984
|
+
path = entry["path"]
|
|
985
|
+
tmpdir = entry["dir"]
|
|
986
|
+
if not path.is_file():
|
|
987
|
+
try:
|
|
988
|
+
shutil.rmtree(tmpdir, ignore_errors=True)
|
|
989
|
+
except OSError:
|
|
990
|
+
pass
|
|
991
|
+
return None
|
|
992
|
+
data = path.read_bytes()
|
|
993
|
+
filename = path.name
|
|
994
|
+
ctype = mimetypes.guess_type(str(path))[0] or "application/octet-stream"
|
|
995
|
+
try:
|
|
996
|
+
shutil.rmtree(tmpdir, ignore_errors=True)
|
|
997
|
+
except OSError:
|
|
998
|
+
pass
|
|
999
|
+
return data, filename, ctype
|
|
1000
|
+
|
|
1001
|
+
def _save_project_file(self, filename):
|
|
1002
|
+
if self.tl is None or not hasattr(self.tl, "save_project"):
|
|
1003
|
+
raise RuntimeError("Save project unavailable: no timeline attached.")
|
|
1004
|
+
|
|
1005
|
+
name = str(filename or "").strip()
|
|
1006
|
+
if not name:
|
|
1007
|
+
raise ValueError("Missing filename.")
|
|
1008
|
+
|
|
1009
|
+
target = Path(name).expanduser()
|
|
1010
|
+
if not target.is_absolute():
|
|
1011
|
+
target = Path.cwd() / target
|
|
1012
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
1013
|
+
|
|
1014
|
+
self.tl.save_project(str(target))
|
|
1015
|
+
return str(target)
|
|
1016
|
+
|
|
1017
|
+
def _read_project_file(self, filename):
|
|
1018
|
+
path = Path(str(filename or "").strip()).expanduser()
|
|
1019
|
+
if not path.is_absolute():
|
|
1020
|
+
path = Path.cwd() / path
|
|
1021
|
+
if not path.is_file():
|
|
1022
|
+
raise FileNotFoundError("Project file not found.")
|
|
1023
|
+
data = path.read_bytes()
|
|
1024
|
+
ctype = mimetypes.guess_type(str(path))[0] or "application/octet-stream"
|
|
1025
|
+
return data, path.name, ctype
|
|
1026
|
+
|
|
1027
|
+
def _make_handler(self):
|
|
1028
|
+
gui = self
|
|
1029
|
+
static_dir = str(self._static_dir)
|
|
1030
|
+
|
|
1031
|
+
class Handler(BaseHTTPRequestHandler):
|
|
1032
|
+
def _is_client_disconnect(self, exc):
|
|
1033
|
+
if isinstance(exc, (BrokenPipeError, ConnectionResetError, ConnectionAbortedError)):
|
|
1034
|
+
return True
|
|
1035
|
+
if isinstance(exc, OSError) and getattr(exc, "errno", None) in {32, 54, 104}:
|
|
1036
|
+
return True
|
|
1037
|
+
return False
|
|
1038
|
+
|
|
1039
|
+
def _send_json(self, payload, status=200):
|
|
1040
|
+
body = json.dumps(payload).encode("utf-8")
|
|
1041
|
+
try:
|
|
1042
|
+
self.send_response(status)
|
|
1043
|
+
self.send_header("Content-Type", "application/json")
|
|
1044
|
+
self.send_header("Content-Length", str(len(body)))
|
|
1045
|
+
self.end_headers()
|
|
1046
|
+
self.wfile.write(body)
|
|
1047
|
+
except Exception as exc:
|
|
1048
|
+
if not self._is_client_disconnect(exc):
|
|
1049
|
+
raise
|
|
1050
|
+
|
|
1051
|
+
def _send_bytes(self, payload, content_type="application/octet-stream", status=200):
|
|
1052
|
+
try:
|
|
1053
|
+
self.send_response(status)
|
|
1054
|
+
self.send_header("Content-Type", content_type)
|
|
1055
|
+
self.send_header("Content-Length", str(len(payload)))
|
|
1056
|
+
self.end_headers()
|
|
1057
|
+
self.wfile.write(payload)
|
|
1058
|
+
except Exception as exc:
|
|
1059
|
+
if not self._is_client_disconnect(exc):
|
|
1060
|
+
raise
|
|
1061
|
+
|
|
1062
|
+
def _send_error(self, message, status=400):
|
|
1063
|
+
self._send_json({"error": message}, status=status)
|
|
1064
|
+
|
|
1065
|
+
def _read_json(self):
|
|
1066
|
+
length = int(self.headers.get("Content-Length", "0") or "0")
|
|
1067
|
+
raw = self.rfile.read(length) if length > 0 else b"{}"
|
|
1068
|
+
try:
|
|
1069
|
+
return json.loads(raw.decode("utf-8") or "{}")
|
|
1070
|
+
except Exception:
|
|
1071
|
+
return {}
|
|
1072
|
+
|
|
1073
|
+
def _serve_static(self, rel_path):
|
|
1074
|
+
rel = rel_path.lstrip("/")
|
|
1075
|
+
if rel == "":
|
|
1076
|
+
rel = "GUI.html"
|
|
1077
|
+
path = (Path(static_dir) / rel).resolve()
|
|
1078
|
+
base = Path(static_dir).resolve()
|
|
1079
|
+
if not str(path).startswith(str(base)) or not path.is_file():
|
|
1080
|
+
self.send_error(404)
|
|
1081
|
+
return
|
|
1082
|
+
|
|
1083
|
+
if path.suffix == ".html":
|
|
1084
|
+
ctype = "text/html; charset=utf-8"
|
|
1085
|
+
elif path.suffix == ".css":
|
|
1086
|
+
ctype = "text/css; charset=utf-8"
|
|
1087
|
+
elif path.suffix == ".js":
|
|
1088
|
+
ctype = "application/javascript; charset=utf-8"
|
|
1089
|
+
else:
|
|
1090
|
+
ctype = "application/octet-stream"
|
|
1091
|
+
|
|
1092
|
+
data = path.read_bytes()
|
|
1093
|
+
self._send_bytes(data, ctype)
|
|
1094
|
+
|
|
1095
|
+
def do_GET(self):
|
|
1096
|
+
parsed = urlparse(self.path)
|
|
1097
|
+
|
|
1098
|
+
if parsed.path == "/api/state":
|
|
1099
|
+
with gui._lock:
|
|
1100
|
+
gui._clear_render_cache_on_load()
|
|
1101
|
+
payload = gui._state_payload()
|
|
1102
|
+
self._send_json(payload)
|
|
1103
|
+
return
|
|
1104
|
+
|
|
1105
|
+
if parsed.path == "/api/save-video-file":
|
|
1106
|
+
params = parse_qs(parsed.query)
|
|
1107
|
+
token = str(params.get("token", [""])[0])
|
|
1108
|
+
if not token:
|
|
1109
|
+
self._send_error("Missing token.", status=400)
|
|
1110
|
+
return
|
|
1111
|
+
with gui._lock:
|
|
1112
|
+
payload = gui._read_and_cleanup_temp_export(token)
|
|
1113
|
+
if payload is None:
|
|
1114
|
+
self._send_error("Export file not found.", status=404)
|
|
1115
|
+
return
|
|
1116
|
+
data, filename, ctype = payload
|
|
1117
|
+
try:
|
|
1118
|
+
self.send_response(200)
|
|
1119
|
+
self.send_header("Content-Type", ctype)
|
|
1120
|
+
self.send_header("Content-Length", str(len(data)))
|
|
1121
|
+
self.send_header("Content-Disposition", f'attachment; filename="{filename}"')
|
|
1122
|
+
self.end_headers()
|
|
1123
|
+
self.wfile.write(data)
|
|
1124
|
+
except Exception as exc:
|
|
1125
|
+
if not self._is_client_disconnect(exc):
|
|
1126
|
+
raise
|
|
1127
|
+
return
|
|
1128
|
+
|
|
1129
|
+
if parsed.path == "/api/save-project-file":
|
|
1130
|
+
params = parse_qs(parsed.query)
|
|
1131
|
+
filename = str(params.get("filename", [""])[0])
|
|
1132
|
+
if not filename:
|
|
1133
|
+
self._send_error("Missing filename.", status=400)
|
|
1134
|
+
return
|
|
1135
|
+
try:
|
|
1136
|
+
with gui._lock:
|
|
1137
|
+
data, download_name, ctype = gui._read_project_file(filename)
|
|
1138
|
+
self.send_response(200)
|
|
1139
|
+
self.send_header("Content-Type", ctype)
|
|
1140
|
+
self.send_header("Content-Length", str(len(data)))
|
|
1141
|
+
self.send_header("Content-Disposition", f'attachment; filename="{download_name}"')
|
|
1142
|
+
self.end_headers()
|
|
1143
|
+
self.wfile.write(data)
|
|
1144
|
+
except Exception as exc:
|
|
1145
|
+
self._send_error(str(exc), status=404 if isinstance(exc, FileNotFoundError) else 400)
|
|
1146
|
+
return
|
|
1147
|
+
|
|
1148
|
+
if parsed.path == "/api/render-frame":
|
|
1149
|
+
params = parse_qs(parsed.query)
|
|
1150
|
+
frame = _safe_int(params.get("frame", [0])[0], 0)
|
|
1151
|
+
try:
|
|
1152
|
+
with gui._lock:
|
|
1153
|
+
data = gui._render_frame_png(frame)
|
|
1154
|
+
self._send_bytes(data, "image/png")
|
|
1155
|
+
except Exception as exc:
|
|
1156
|
+
self._send_error(str(exc), status=500)
|
|
1157
|
+
return
|
|
1158
|
+
|
|
1159
|
+
if parsed.path.startswith("/frames/"):
|
|
1160
|
+
filename = parsed.path.split("/frames/", 1)[-1]
|
|
1161
|
+
with gui._lock:
|
|
1162
|
+
frame_path = gui._frame_path_from_name(filename)
|
|
1163
|
+
if frame_path is None:
|
|
1164
|
+
self._send_error("Frame not found.", status=404)
|
|
1165
|
+
return
|
|
1166
|
+
try:
|
|
1167
|
+
self._send_bytes(frame_path.read_bytes(), "image/png")
|
|
1168
|
+
except Exception as exc:
|
|
1169
|
+
self._send_error(str(exc), status=500)
|
|
1170
|
+
return
|
|
1171
|
+
|
|
1172
|
+
self._serve_static(parsed.path)
|
|
1173
|
+
|
|
1174
|
+
def do_POST(self):
|
|
1175
|
+
parsed = urlparse(self.path)
|
|
1176
|
+
payload = self._read_json()
|
|
1177
|
+
|
|
1178
|
+
if parsed.path == "/api/clip/create":
|
|
1179
|
+
try:
|
|
1180
|
+
with gui._lock:
|
|
1181
|
+
clip_id = gui._create_clip(
|
|
1182
|
+
payload.get("type", "Translate"),
|
|
1183
|
+
payload.get("x", 0),
|
|
1184
|
+
payload.get("width", 120),
|
|
1185
|
+
payload.get("row", 0),
|
|
1186
|
+
)
|
|
1187
|
+
state = gui._state_payload()
|
|
1188
|
+
self._send_json({"ok": True, "clipId": clip_id, "state": state})
|
|
1189
|
+
except Exception as exc:
|
|
1190
|
+
self._send_error(str(exc), status=400)
|
|
1191
|
+
return
|
|
1192
|
+
|
|
1193
|
+
if parsed.path == "/api/clip/update":
|
|
1194
|
+
try:
|
|
1195
|
+
with gui._lock:
|
|
1196
|
+
gui._update_clip(
|
|
1197
|
+
payload.get("id", ""),
|
|
1198
|
+
payload.get("x", 0),
|
|
1199
|
+
payload.get("width", 120),
|
|
1200
|
+
payload.get("row"),
|
|
1201
|
+
)
|
|
1202
|
+
state = gui._state_payload()
|
|
1203
|
+
self._send_json({"ok": True, "state": state})
|
|
1204
|
+
except Exception as exc:
|
|
1205
|
+
self._send_error(str(exc), status=400)
|
|
1206
|
+
return
|
|
1207
|
+
|
|
1208
|
+
if parsed.path == "/api/clip/delete":
|
|
1209
|
+
try:
|
|
1210
|
+
with gui._lock:
|
|
1211
|
+
if "ids" in payload:
|
|
1212
|
+
gui._delete_clips(payload.get("ids", []))
|
|
1213
|
+
else:
|
|
1214
|
+
gui._delete_clips(payload.get("id", ""))
|
|
1215
|
+
state = gui._state_payload()
|
|
1216
|
+
self._send_json({"ok": True, "state": state})
|
|
1217
|
+
except Exception as exc:
|
|
1218
|
+
self._send_error(str(exc), status=400)
|
|
1219
|
+
return
|
|
1220
|
+
|
|
1221
|
+
if parsed.path == "/api/clip/get":
|
|
1222
|
+
try:
|
|
1223
|
+
with gui._lock:
|
|
1224
|
+
props = gui._clip_properties(payload.get("id", ""))
|
|
1225
|
+
self._send_json({"ok": True, "properties": props})
|
|
1226
|
+
except Exception as exc:
|
|
1227
|
+
self._send_error(str(exc), status=400)
|
|
1228
|
+
return
|
|
1229
|
+
|
|
1230
|
+
if parsed.path == "/api/clip/properties":
|
|
1231
|
+
try:
|
|
1232
|
+
with gui._lock:
|
|
1233
|
+
gui._update_clip_properties(payload.get("id", ""), payload)
|
|
1234
|
+
state = gui._state_payload()
|
|
1235
|
+
self._send_json({"ok": True, "state": state})
|
|
1236
|
+
except Exception as exc:
|
|
1237
|
+
self._send_error(str(exc), status=400)
|
|
1238
|
+
return
|
|
1239
|
+
|
|
1240
|
+
if parsed.path == "/api/timeline/width":
|
|
1241
|
+
try:
|
|
1242
|
+
with gui._lock:
|
|
1243
|
+
gui._set_timeline_width(payload.get("width", 1000))
|
|
1244
|
+
if "rowCount" in payload:
|
|
1245
|
+
gui._set_row_count(payload.get("rowCount", gui.min_tracks))
|
|
1246
|
+
state = gui._state_payload()
|
|
1247
|
+
self._send_json({"ok": True, "state": state})
|
|
1248
|
+
except Exception as exc:
|
|
1249
|
+
self._send_error(str(exc), status=400)
|
|
1250
|
+
return
|
|
1251
|
+
|
|
1252
|
+
if parsed.path == "/api/timeline/rows":
|
|
1253
|
+
try:
|
|
1254
|
+
with gui._lock:
|
|
1255
|
+
gui._set_row_count(payload.get("rowCount", gui.min_tracks))
|
|
1256
|
+
state = gui._state_payload()
|
|
1257
|
+
self._send_json({"ok": True, "state": state})
|
|
1258
|
+
except Exception as exc:
|
|
1259
|
+
self._send_error(str(exc), status=400)
|
|
1260
|
+
return
|
|
1261
|
+
|
|
1262
|
+
if parsed.path == "/api/timeline/delete-row":
|
|
1263
|
+
try:
|
|
1264
|
+
with gui._lock:
|
|
1265
|
+
gui._delete_track_row(payload.get("row", 0))
|
|
1266
|
+
state = gui._state_payload()
|
|
1267
|
+
self._send_json({"ok": True, "state": state})
|
|
1268
|
+
except Exception as exc:
|
|
1269
|
+
self._send_error(str(exc), status=400)
|
|
1270
|
+
return
|
|
1271
|
+
|
|
1272
|
+
if parsed.path == "/api/render":
|
|
1273
|
+
try:
|
|
1274
|
+
frame = _safe_int(payload.get("frame", 0), 0)
|
|
1275
|
+
with gui._lock:
|
|
1276
|
+
gui._ensure_frame_rendered(frame)
|
|
1277
|
+
frame_path = gui._frame_url_path(frame)
|
|
1278
|
+
rev = gui._render_revision
|
|
1279
|
+
self._send_json({"ok": True, "framePath": frame_path, "renderRevision": rev, "frame": frame})
|
|
1280
|
+
except Exception as exc:
|
|
1281
|
+
self._send_error(str(exc), status=400)
|
|
1282
|
+
return
|
|
1283
|
+
|
|
1284
|
+
if parsed.path == "/api/save-video-temp":
|
|
1285
|
+
try:
|
|
1286
|
+
with gui._lock:
|
|
1287
|
+
export = gui._export_video_to_temp(
|
|
1288
|
+
timeline_width=payload.get("timelineWidth"),
|
|
1289
|
+
)
|
|
1290
|
+
self._send_json({"ok": True, **export})
|
|
1291
|
+
except Exception as exc:
|
|
1292
|
+
self._send_error(str(exc), status=400)
|
|
1293
|
+
return
|
|
1294
|
+
|
|
1295
|
+
if parsed.path == "/api/save-project":
|
|
1296
|
+
try:
|
|
1297
|
+
with gui._lock:
|
|
1298
|
+
filename = gui._save_project_file(payload.get("filename", ""))
|
|
1299
|
+
self._send_json({"ok": True, "filename": filename})
|
|
1300
|
+
except Exception as exc:
|
|
1301
|
+
self._send_error(str(exc), status=400)
|
|
1302
|
+
return
|
|
1303
|
+
|
|
1304
|
+
self._send_error("Unknown API endpoint.", status=404)
|
|
1305
|
+
|
|
1306
|
+
def log_message(self, format, *args):
|
|
1307
|
+
return
|
|
1308
|
+
|
|
1309
|
+
return Handler
|
|
1310
|
+
|
|
1311
|
+
def serve(self, open_browser: bool = True) -> str:
|
|
1312
|
+
if self._server is not None:
|
|
1313
|
+
if self.url is not None:
|
|
1314
|
+
return self.url
|
|
1315
|
+
raise RuntimeError("Server is already running.")
|
|
1316
|
+
|
|
1317
|
+
try:
|
|
1318
|
+
self._server = ThreadingHTTPServer((self.host, self.port), self._make_handler())
|
|
1319
|
+
except OSError as exc:
|
|
1320
|
+
raise RuntimeError(
|
|
1321
|
+
f"Could not start web GUI server on {self.host}:{self.port}. "
|
|
1322
|
+
"Try another port."
|
|
1323
|
+
) from exc
|
|
1324
|
+
|
|
1325
|
+
self.url = f"http://{self.host}:{self.port}/GUI.html"
|
|
1326
|
+
self._thread = threading.Thread(target=self._server.serve_forever, daemon=True)
|
|
1327
|
+
self._thread.start()
|
|
1328
|
+
|
|
1329
|
+
print(f"Diplotocus web GUI running at {self.url}")
|
|
1330
|
+
print("Use Ctrl+C in the terminal or call .stop() to stop the server.")
|
|
1331
|
+
|
|
1332
|
+
if open_browser:
|
|
1333
|
+
webbrowser.open(self.url)
|
|
1334
|
+
|
|
1335
|
+
return self.url
|
|
1336
|
+
|
|
1337
|
+
def wait(self) -> None:
|
|
1338
|
+
if self._thread is None:
|
|
1339
|
+
return
|
|
1340
|
+
try:
|
|
1341
|
+
while self._thread is not None and self._thread.is_alive():
|
|
1342
|
+
self._thread.join(timeout=0.2)
|
|
1343
|
+
except KeyboardInterrupt:
|
|
1344
|
+
self.stop()
|
|
1345
|
+
|
|
1346
|
+
def stop(self) -> None:
|
|
1347
|
+
if self._server is None:
|
|
1348
|
+
return
|
|
1349
|
+
self._server.shutdown()
|
|
1350
|
+
self._server.server_close()
|
|
1351
|
+
self._server = None
|
|
1352
|
+
self._thread = None
|