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.
@@ -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